프로젝트/redis

Redis를 통해 분산락 구현하기 - 실전편

Junuuu 2023. 10. 27. 00:01

Redis를 통해 분산락 구현하기 - 이론 편에 이어 실제로 구현을 해봅니다.

 

100장의 티켓을 구매하는 여러 스레드의 요청에도 분산락을 통해 예상한 대로 매진 시 0건 이하가 발생하지 않거나 50건의 요청이 있었다면 티켓 50장이 남아있는지 시나리오를 통해 동시성이 잘 제어되는지 확인해보려 합니다.

 

개발 환경

  • Spring Boot 3.1
  • Java 17

 

Redisson 의존성 추가

implementation ("org.redisson:redisson-spring-boot-starter:3.18.0")

 

RedissonConfig 설정

@Configuration
class RedissonConfig {
    @Value("\${spring.data.redis.host}")
    private val redisHost: String? = null

    @Value("\${spring.redis.port}")
    private val redisPort = 0

    private val REDISSON_HOST_PREFIX = "redis://"

    @Bean
    fun redissonClient(): RedissonClient {
        val config = Config()
        config.useSingleServer().setAddress("$REDISSON_HOST_PREFIX$redisHost:$redisPort")
        return Redisson.create(config)
    }

}

 

docker-compose 설정

version: '3'
services:
  redis:
    image: redis:latest
    ports:
      - "6379:6379"

 

Ticket Entity 구성

@Entity
class Ticket(
    private val name: String,
    private var availableStock: Long,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private val id: Long? = null

    fun sell() {
        validateStockCount()
        availableStock -= 1
    }

    private fun validateStockCount() {
        require(availableStock >= 1)
    }
}


@Repository
interface TicketRepository: JpaRepository<Ticket, Long> {
}

JpaEntity를 구성해주며 티켓의 가용개수가 1개가 이상인 경우에만 판매할 수 있습니다.

 

 

Service & Controller

@Service
class SellTicketService(
    private val ticketRepository: TicketRepository,
) {
    fun sellTicket(id: Long) {
        val ticket = ticketRepository.findByIdOrNull(id)
            ?: throw NoSuchElementException("해당 id로 티켓을 조회할 수 없습니다 id = $id")
        ticket.sell()
        ticketRepository.save(ticket)
    }
}


@RestController
class SellTicketController(
    private val sellTicketService: SellTicketService,
) {

    @PostMapping("/ticket/{id}")
    fun sellTicket(@PathVariable id: Long): ResponseEntity<Unit> {
        sellTicketService.sellTicket(id)
        return ResponseEntity.ok().build()
    }
}

contoller에서는 id를 인자로 받아 티켓을 판매합니다.

 

service에서는 티켓의 id로 티켓을 조회하고 판매(티켓의 개수를 -1 처리) 후 저장합니다.

read -> update -> save의 구조로 별다른 조치를 취하지 않는다면 race condition 이 발생할 가능성이 있습니다.

 

TestCode 작성

 

@SpringBootTest
class ApplicationTests @Autowired constructor(
	private val sellTicketService: SellTicketService,
	private val ticketRepository: TicketRepository,
) {

	@Test
	fun `100개의 티켓을 만들고 50개의 요청을 동시에 요청하면 50개의 티켓수량이 남아있어야 한다`(){
		val demoTicket = Ticket(
			name = "demo",
			availableStock = 100L,
		)
		val ticket = ticketRepository.save(demoTicket)
		val numberOfThreads = 50
		val id = ticket.id!!

		val executorService = newFixedThreadPool(50)

		val latch = CountDownLatch(numberOfThreads)

		repeat(numberOfThreads) {
			executorService.submit {
				try {
					sellTicketService.sellTicket(id)
				} finally {
					latch.countDown()
				}
			}
		}
		latch.await()

		val actual= ticketRepository.findById(id)
			.orElseThrow { IllegalArgumentException() }

		Assertions.assertEquals(50, actual.getAvailableStockCount())
	}

}

 

demo라는 이름의 티켓을 100장 만들어냅니다.

이후 ExecutorService를 활용하여 50개의 쓰레드풀로 한 번에 티켓판매를 요청합니다.

50개의 티켓이 남아있을것이라 기대했지만 결과는 어떻게 될까요?

결과

race condition이 발생했기 때문에 50개의 티켓이 남아있어야 하지만 6개의 티켓만 감소된 모습을 보여줍니다.

락이 없다보니 동시에 요청이 왔을 때 각자 읽은 쿠폰의 잔여개수가 다르기에 데이터 정합성이 깨져버렸습니다.

 

DistributedLock을 여러 군대에서 잘 사용하기 위해 AOP로 구현

/**
 * Redisson Distributed Lock annotation
 */
@Target(AnnotationTarget.FUNCTION) //어노테이션이 적용될 수 있는 대상
@Retention(AnnotationRetention.RUNTIME) //어노테이션이 유지되는 기간
annotation class DistributedLock(
    /**
     * 락의 이름
     */
    val key: String,
    /**
     * 락의 시간 단위
     */
    val timeUnit: TimeUnit = TimeUnit.SECONDS,
    /**
     * 락을 기다리는 시간 (default - 5s)
     * 락 획득을 위해 waitTime 만큼 대기한다
     */
    val waitTime: Long = 5L,
    /**
     * 락 임대 시간 (default - 3s)
     * 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
     */
    val leaseTime: Long = 3L
)

DistributedLock 어노테이션을 만들고 메서드 위에 붙일 계획으로 타깃은 FUNCTION으로 설정합니다.

락의 이름, 락 대기 시간, 락 최대 임대시간을 정의합니다.

 

@Aspect
@Component
class DistributedLockAop @Autowired constructor(
    private val redissonClient: RedissonClient,
    private val aopForTransaction: AopForTransaction
) {
    @Around("@annotation(com.example.study.aop.DistributedLock)")
    fun lock(joinPoint: ProceedingJoinPoint): Any? {
        val signature = joinPoint.signature as MethodSignature
        val method: Method = signature.method
        val distributedLock: DistributedLock = method.getAnnotation(DistributedLock::class.java)
        val key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(
            signature.parameterNames,
            joinPoint.args,
            distributedLock.key
        )
        val rLock = redissonClient.getLock(key) // (1)
        return try {
            val available =
                rLock.tryLock(distributedLock.waitTime, distributedLock.leaseTime, distributedLock.timeUnit) // (2)
            if (!available) {
                false
            } else aopForTransaction.proceed(joinPoint)
            // (3)
        } catch (e: InterruptedException) {
            throw InterruptedException()
        } finally {
            try {
                rLock.unlock() // (4)
            } catch (e: IllegalMonitorStateException) {
                logger.info(
                    "Redisson Lock Already UnLock {} {}",
                    method.name, key
                )
            }
        }
    }

    companion object {
        private const val REDISSON_LOCK_PREFIX = "LOCK:"
    }
}

(1)에서는 락의 이름으로 RLock 인스턴스를 가져옵니다.

(2)에서는 정의된 waitTime까지 획득을 시도하고, 정의된 leaseTime이 지나면 잠금을 해제합니다.

(3) DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행합니다.

(4) 종료시 무조건 락을 해제합니다.

 

여기서 CustomSpringELParser , AopForTransaction는 어떤 책임이 있을까요?

 

CustomSpringELParser

object CustomSpringELParser {
    fun getDynamicValue(parameterNames: Array<String>, args: Array<Any>, key: String): Any? {
        val parser: ExpressionParser = SpelExpressionParser()
        val context = StandardEvaluationContext()
        for (i in parameterNames.indices) {
            context.setVariable(parameterNames[i], args[i])
        }
        return parser.parseExpression(key).getValue(context, Any::class.java)
    }
}

Lock의 이름을 Spring Expression Language로 파싱 하여 읽어옵니다.

이를 통해 Lock의 이름을 보다 자유롭게 전달할 수 있습니다.

 

AopForTransaction

@Component
class AopForTransaction {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun proceed(joinPoint: ProceedingJoinPoint): Any? {
        return joinPoint.proceed()
    }
}

 

기존 트랜잭션의 유무와 관계없이 별도의 트랜잭션으로 동작하게 끔 설정해줍니다.

그리고 반드시 트랜잭션 커밋 이후 락이 해제되도록 처리해야 합니다.

커밋 이전에 락이 해제되는 경우에는 데이터 정합성이 깨질 수 있습니다. (마치 read uncommitted처럼)

 

다시 테스트

@Service
class SellTicketService(
    private val ticketRepository: TicketRepository,
) {

    @DistributedLock(key = "#id")
    fun sellTicket(id: Long) {
        val ticket = ticketRepository.findByIdOrNull(id)
            ?: throw NoSuchElementException("해당 id로 티켓을 조회할 수 없습니다 id = $id")
        ticket.sell()
        ticketRepository.save(ticket)
    }
}

Service 코드에 @DistributedLock 어노테이션을 달고 id를 키로 넘겨보았습니다.

이제 다시 테스트코드를 돌린다면?

단지 어노테이션을 붙여주는 것만으로 동시성을 제어하는 효과를 줄 수 있게 되었습니다.

 

 

참고자료

https://www.baeldung.com/redis-redisson

https://github.com/redisson/redisson

https://helloworld.kurly.com/blog/distributed-redisson-lock/