티스토리 뷰

반응형

배경

https://golf-dev.tistory.com/53

 

Redis INCR을 이용한 분산환경에서의 동시성 제어하기

문제 상황 회사에서 분산환경에서 하루에 한 번만 요청이 가능한 기능이었지만 한 사람이 3번 이상 요청을 보낸 기록이 있어 원인을 찾아보았습니다. 우선 샘플 코드는 다음과 같습니다. fun save(

golf-dev.tistory.com

제 블로그를 많이 읽어보셨다면 익숙한 글 입니다. 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을 이용한 분산락 구현해보기였습니다. 읽어주셔서 감사합니다!

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함