만약 다음 프로세스가 이어져야하는 상황에서 분산 환경 동시성 제어는 어떻게 이루어질까?
배경
https://golf-dev.tistory.com/53
제 블로그를 많이 읽어보셨다면 익숙한 글 입니다. Redis INCR 동작원리를 이용해서 분산환경에서 Lock을 구현했었는데요. 이 방식이 과연 최선이었는가에 대해서 고민을 해보는 시간을 가져볼 겁니다.
뭐가 문젠데?
요청 프로세스를 다이어그램으로 그려보면 다음과 같습니다.
이미 요청 횟수를 정해놓고 구현했기 때문에 INCR로 두 서버에서 요청수를 count하고 그 이상이 들어오면 튕겨냅니다. 그리고 TTL이 걸려있어 동일한 요청을 TTL이 살아있는 동안에는 보낼 수 없습니다.
즉, 정해놓은 요청 안에 사용자가 들어와서 아무런 작업을 안해도 이미 정해져 있는 요청 횟수 이상으로 들어올 수 없기 때문에 TTL이 끝나지 않는 한 동일한 요청은 할 수 없게 됩니다.
이를 간단하게 예매 프로그램으로 간다면 어떻게 될까요?
위 플로우 차트 처럼 stock이 10개가 남아있는데도 불구하고 TTL 동안 동일한 티켓을 구매할 수 없게 됩니다. 그렇다면 이 문제는 어떻게 해결해야할까요?
분산락 (Redisson)
첫 번째 글에서 Named Lock과 Lettuce를 이용한 Redis Lock을 회사에서 사용할 수 없었던 이유를 적어놨으니 확인 부탁드립니다. 혹시나를 위해 간단하게 적자면 다음과 같습니다.
Lettuce는 SpinLock 형태이고 이 특성 때문에 Redis에 많은 부하를 일으킬 수 있기 때문에 제외한다.
MySQL Named Lock은 DB에 부하를 주는 방식이고 처리량이나 지연시간도 길어질 수 있습니다. 또한 디스크와 인메모리 각각의 데이터 저장 방식에서도 성능차이가 있습니다. (실제로 개발하실 때에는 서버의 전체적인 상황을 고려하여 개발하면 좋습니다.)
제가 실제로 전 글에서 시도했던 방식이 setnx와 매우 유사합니다. 재시도를 할 수 있게 구현은 가능하지만 그렇게 되면 레디스에 많은 부하를 주어야 했기 때문에 일정 요청만큼만 받고 나머지 요청은 흘려보냈습니다.
그러면 Redission과 Lettuce의 setnx Lock을 비교해 보고 과연 위에서 생각한 예시에는 어떤 Lock이 더 잘어울릴지 고민해봐야합니다.
비교 | setnx | pub/sub |
Lock을 거는 방식 | 0 과 1을 기준으로 Lock 획득 여부를 판단 | Lock을 걸고 pub/sub을 이용해 작업이 끝나면 channel을 이용해 점유가 끝남을 다른 스레드에게 알림 |
재시도 시 서버 부하 | Spin Lock으로 구현하여 계속해서 작업이 끝났음을 레디스에 물어보는 요청을 보내야하기 때문에 부담이 큼 | 점유가 끝나면 끝나는 event를 대기하는 thread에게 push 해주기 때문에 Spin Lock으로 구현할 필요가 없어 부담 적음 |
사용하는 Redis Client | Lettuce | Redisson |
예매는 재시도를 해보는 것이 중요한 비즈니스입니다. 사용자 입장에서 튕겨져 버리면 대기열 맨 뒤로 가지는 것과 같으니 매우 불편함과 불만을 느낄 수 있습니다.
못 사면 끝 아니야? 라고 생각하실 수 있겠지만 만약에 사용자가 구매하지 않고 나가버리면 결국 티켓은 판매되지 않습니다. 그러면 다음 사용자는 살 수 있음에도 불구하고 요청 횟수 제한 때문에 사질 못합니다.
그러면 예매 프로그램을 예시로 코드를 확인해보겠습니다. 자세한건 GitHub를 참고해주세요
Redisson 설정은 간단합니다.
RedisConfig.kt
@Bean
fun redissonClient(): RedissonClient {
val config = Config()
config.useSingleServer().address = "redis://${host}:${port}"
return Redisson.create(config)
}
Redisson Client는 URL을 이용하여 Redis에 연결하고 서버와 통신하기 때문에 url이 필요합니다. redis:// 접두사는 Redis 서버의 위치를 식별하기 위한 표준 규칙입니다.
설정을 마쳤으니 이제 Redisson 을 이용해 Lock을 구현해봅시다. 이전에 코드에서 좀 더 고도화 한게 있다면 비즈니스 로직에서 Lock을 잡기 위해 try - finally 구조로 했는데 이를 다른 서비스 클래스로 이관하여 책임을 덜고 사용 의도를 명확하게 들어내었습니다.
TicketLockService.kt
@Component
class TicketLockService {
fun <T> withLock(redissonClient: RedissonClient,
lockName: String,
lockWaitTime: Long = 10,
lockLeaseTime: Long = 10,
action: () -> T): T {
val lock = redissonClient.getLock(lockName)
return try {
if (lock.tryLock(lockWaitTime, lockLeaseTime, TimeUnit.SECONDS)) {
action()
} else {
throw GetLockFailException()
}
} finally {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
}
TicketService.kt
fun payment(request: PaymentRequestDto): PaymentResponseDto {
val lockName = "${request.ticketId}"
paymentProcess(lockName, request)
return PaymentResponseDto(ACCOUNT_NUMBER)
}
@Transactional
protected fun paymentProcess(
lockName: String,
request: PaymentRequestDto
) {
ticketLockService.withLock(redissonClient, lockName) {
val ticket = ticketRepository.findByIdOrNull(request.ticketId)
?: throw TicketNotFoundException()
if (!ticket.isPurchasable(request.stock)) {
throw TicketPurchaseFailException()
}
ticket.decreaseStock(request.stock)
val totalPrice = ticket.calculateTicketPrice(request.stock)
paymentService.createPayment(
PaymentCreateDto(
ticketId = request.ticketId,
memberId = request.memberId,
accountNumber = request.accountNumber,
totalPrice = totalPrice
)
)
}
}
먼저 코드 구조가 이상하게 보일 수 있는데 이렇게 짠 이유는 Dirty Checking 시 commit이 되면 update 문이 날라가 DB에 반영되는데 이 프로세스 때문에 Lock을 걸었음에도 commit 되는 시점 때문에 동시성 이슈가 발생합니다.
그렇기 때문에 Lock 범위와 DB에 반영되는 시점을 잘 고민해서 로직을 짜야합니다. 우선 payment 프로세스라는 메소드를 만들어서 결제가 진행되는 Process를 하나의 범위로 묶고 Lock이 해제되는 시점에 줄어든 stock이 DB에 반영될 수 있게 개선하였습니다.
(무통장 입금만 처리된다 라고 가정하고 로직을 작성하였습니다. 그리고 실제로 결제가 일어나더라도 Transactional로 묶여 있기 때문에 Rollback이 일어나 실패 시 데이터 일관성은 지켜집니다.)
Flow chart
다시 설명드리면 Redis에서는 Lock이 해제 되면 해제된 이벤트를 발행하고 channel로 보냅니다. 그리고 이 채널을 구독한 각 클라이언트(WAS) 서버는 해제 알림을 받게 되고 이로서 분산환경에서도 SpinLock 같은 부하 없이 잠금을 사용하여 데이터 정합성을 지킬 수 있습니다.
테스트
테스트는 Jmeter를 이용해서 진행해보았습니다.
세팅은 다음과 같습니다.
테스트 시 결과는 다음과 같습니다.
select count(*) from payment;
select stock from ticket;
마무리
분산락은 얼핏보면 구현하기 쉬워 누구나 구현할 수 있지만 많은 고민을 했는데요. 대표적으로 부하에 대해선 어떻게 해야할까 입니다. 요청이 10000개가 들어오는데 Lock을 잡아 정합성을 맞출 순 있지만 결제 또는 다른 처리에 부가 처리에 의해서 사용자가 엄청나게 긴 기간 동안 대기해야할 수 있습니다.
또한 Lock 범위가 지나치게 넓으면 그거 또한 성능 문제로 이어질 수 있습니다. 당연히 고민해봐야할 과제입니다.
이상 Redisson을 이용한 분산락 구현해보기였습니다. 읽어주셔서 감사합니다!