Spring

Redis 분산락을 이용해서 한 번에 한 번씩 결제하기

develua 2023. 3. 20. 23:31

문제 상황

3/9 일 경, PM 분들에게 네이버페이 이용건 중 결제 후 바로 결제 취소 처리되는 이슈를 넘겨받게 되었습니다. 해당 케이스를 조금 살펴 보니 카셰어링(쏘카) + KTX 상품을 묶음으로 구매할 때, 네이버 페이 포인트를 포함한 결제를 진행하는 경우 포인트 작액 부족에 의해서 결제 실패가 되었고, 이에 따라 결제 취소 API 가 바로 호출되고 있었습니다.

 

해당 케이스에 경우, 내부 로직 상 묶음 상품(카셰어링 + KTX 상품1 + KTX 상품2) 에 의해 총 3번의 결제가 이루어져야 했는데, 카셰어링 + KTX 상품1 에 대해서만 결제가 성공하고, KTX 상품2 는 결제가 실패한 상태였기 때문에 한건에 결제 실패에 의해 나머지 결제가 성공한 건들 모두 결제가 취소되어야 하는 상태였습니다. 이에 따라 구매 실패에 의한 복구 로직의 진행 결과로 결제 취소 API가 호출되고 있는 상태로 확인이 되었습니다.

 

그런데 포인트 잔액 부족이라는 결과는 납득이 되지 않았습니다. 일반적으로 포인트 결제금액을 따로 지정해서 결제하지 않는 이상, 포인트가 존재하는 경우에만 포인트를 이용한 결제가 이뤄지고, 나머지는 모두 신용카드로 이뤄져야 정상인데 포인트의 잔액이 부족하다는 응답이 정상적이지는 않아보였습니다.

 

결론적으론 네이버페이로부터 응답을 받은 내용으로 납득이 가지 않는 상황에 대한 의문이 해소되게 되었습니다.

 

네이버페이에서 결제에 대한 처리가 크게 예약과 승인 2단계로 이루어져 있는데, 하나의 결제 요청에 대해 승인까지 완료가 되어야 정상적으로 포인트 잔액이 반영이 되고 있었습니다. 결과적으로 한 사용자의 복수의 결제 요청이 있을 시엔 반드시 한 결제가 성공적으로 완료가 된 이후에 2번째 결제 요청을 보내야 첫 결제에서 사용된 포인트가 차감된 채로 두번째 결제가 이뤄질 수 있는 것입니다.

 

하지만, 저희 서비스에서는 KTX 상품에 대해서 네이버 페이로의 결제 요청을 비동기로 처리하고 있으며, 그에 따라 동일한 사용자의 복수의 결제 요청이 (1)번 흐름과 같이 순차적으로 처리되지 않고, (2)번 흐름과 같이 처리된 것이 문제였습니다.

 

(1) 번 흐름

  1. KTX 상품1 결제 예약
  2. KTX 상품1 결제 승인(KTX 상품1 결제 포인트 차감)
  3. KTX 상품2 결제 예약
  4. KTX 상품2 승인(KTX 상품2 포인트 차감)

(2) 번 흐름

  1. KTX 상품1 결제 예약
  2. KTX 상품2 예약
  3. KTX 상품1 결제 승인(KTX 상품1 결제 포인트 차감)
  4. KTX 상품2 승인((2) 예약 시에 확인한 포인트 만큼 차감 - 포인트 부족)

결과적으로는 5000 포인트를 가지고 있는 사용자가 KTX 기차표 5000 원짜리 2매를 구매하고자 할 때, 첫번째로 승인이 나는 결제건은 정상적으로 포인트 차감되어 결제가 성공하나 두번째로 승인이 나는 결제건은 이미 포인트가 차감된 이후임으로 포인트 부족으로 결제가 실패하게 되는 것입니다.

 

그래서 위 문제를 해결하기 위해 아래와 같은 방법들이 있었으나,

1) 결제 요청 시, 비동기 처리 코드를 동기 처리로 전환
2) 각 상품에 대해서는 별개의 결제(payment)건으로 관리하되, PG사로 결제 요청 시에는 하나의 금액으로 묶어 실결제 요청(단일 transaction 으로 관리)

 

(1) 해당 이슈는 NaverPay 에만 한정된 문제기 때문에 비동기 로직을 동기처리로 전환했을 때, 발생할 수 있는 side effect이 더 크다는 문제가 있고, (2) 핵심 로직의 코드 수정이 불가피하다는 문제에 의해 결론적으로 분산락을 이용해 동일 사용자의 NaverPay 결제 요청건에 대해선 순차적으로 결제될 수 있도록 구현하기로 결정했습니다.

 

이전까지는 단순히 분산환경에서 동시성 이슈 해결을 위해 분산락을 이용할 수 있다 정도로만 알고 있었는데, 분산락은 무엇이고, 이를 어떻게 적용할 수 있으며, 발생한 문제를 어떻게 처리할 수 있을지 알아보도록 하겠습니다.

 


 

분산락 너는 무엇이니?

분산락은 분산 환경에서 동시성 문제를 해결하기 위한 방법입니다.

동시성 문제는 일반적으로 멀티스레드 환경에서 발생하는 문제로 여러개의 스레드가 하나의 자원에 동시에 접근하려고 할 때, 동시 접근에 의해 데이터 정합성이 깨지는 문제를 일컫습니다.

 

자바에서는 synchronized라는 동기화 기법을 제공해 모니터 (모니터는 임계 구역에 대한 접근을 한 번에 하나의 스레드만 접근할 수 있도록 제한합니다.) 기반 상호 배제를 보장할 수 있으나, 분산환경에서는 임계 구역에 대한 접근이 각각의 노드에서 이뤄지기 때문에 위 동기화 기법으로는 하나의 노드에서 공유 자원를 점유하더라도 이를 다른 노드에서 제어할 수는 없습니다.

 

즉, 분산락은 단일 어플리케이션 환경이 아닌 분산 환경에서 공유 자원에 대한 여러 노드들의 접근을 제어하는 동기화 메커니즘입니다.

 

분산락을 이용해 어떻게 해결할 수 있을까?

우선, 전체 흐름은 상기 이미지와 같이 잡아보았습니다.
여기서 1) 각 결제 요청은 동일한 사용자의 결제 요청이며, 2) 사용자는 단 하나의 NaverPay 결제 키를 보유하고 있습니다. 또한, 3) 공유 자원의 대상은 NaverPay 결제 키 입니다.

 

(1) 구매서비스에서 비동기로 KTX 상품에 대한 결제 요청을 보내면, 서로 다른 PG 노드에
서 결제 요청을 (거의) 동시에 받고,
(2) 각각 Redis 를 통해 공유 자원에 대한 Lock을 시도한 뒤,
(3) 먼저 Lock에 성공한 PG 노드가 NaverPay로 결제 요청을 보낸 뒤, 결제 응답을 받습니다.
(4) 먼저 Lock을 획득한 결제 요청이 결제 응답을 받을 때까지 KTX 상품 1 결제 요청을 받은 PG 노드는 Lock이 해제될때까지 대기합니다.
(5) 결제 처리가 완료되면, 획득한 Lock을 해제합니다. 이후에 다른 노드가 동일한 과정을 반복하고, NaverPay로 결제 요청을 보내게 됩니다.

위와 같은 흐름으로 동작하게 되면, 결과적으로 동일한 결제 키로의 요청에 대해선 (1) 번 흐름의 형태를 띌 수 있게 됩니다. 

 


이렇게 구현할 내용의 전체적인 흐름을 잡아보았으니, 세부적으로 어떻게 구현하면 좋을지 알아보겠습니다.

우선, Redis 를 연동하여 분산락을 적용해 보기에 앞서 다음과 같은 궁금증이 생길 수 있습니다.

  1. 정기 결제 키에 대해 Lock을 시도하고, 해제는 어떻게 구현할 수 있을까?
  2. NaverPay 로 결제 요청 대기 중인 노드는 Lock 해제에 대한 signal을 어떻게 받을 수 있을까?

 

Lock의 구현 방식

위 2가지 의문은 Redis Lock을 어떤 방법으로 구현하느냐에 따라 다른 답변이 나올 수 있습니다. Redis Lock을 구현하는 방법에는 크게 2가지가 있습니다.

  1. SpinLock
  2. Pub/Sub

✔️ Spin Lock 방식

Lock을 점유할 수 있는지를 반복문을 통해 계속해서 확인하는 방식입니다. 아래와 같이 구현될 수 있습니다.

val lockKey = "hello lock"  

try {  
    // Lock 획득을 위해 지속 시도
    while (!tryLock(lockKey)) { 
        try {  
            // 무분별한 redis 부하 증가를 예방하기 위해 획득 시도 지연 처리
            Thread.sleep(50)  
        } catch (e: InterruptedException) {  
            throw RuntimeException(e)  
        }  
    }  

    // Lock 획득 후, 처리 로직

} finally {  
    // Lock의 해제
    unlock(lockKey) 
}

fun tryLock(key: String): Boolean? {  
    return lockRedisTemplate.opsForValue()  
        .setIfAbsent(key, "lock", Duration.ofMillis(3000L))  
}  

fun unlock(key: String): Boolean? {  
    return lockRedisTemplate.delete(key)  
}

tryLock(key) 함수 내에 구현된 setIfAbsent 함수의 경우, Lock의 점유 가능 여부 확인과 점유가 Atomic 하게 이루어져 하기 때문에 내부적으로 Lock의 점유 가능 여부 확인과 점유를 동시에 할 수 있는 레디스의 setnx 명령어를 사용하여 구현되어 있습니다.

하지만, 스핀락으로 구현할 경우, 코드에서 보여지는 바와 같이 Lock 획득을 위해 반복문으로 지속적으로 점유 가능 여부 확인을 시도합니다. 이는 결과적으로 redis 서버의 부하 증가를 야기합니다.

✔️ Pub/Sub 방식

위와 같이 Lock이 해제되었나를 반복적으로 확인하는 방법 대신 점유 대상(키 값)과 관련된 채널을 구독하고, 구독한 채널을 통해 Lock의 상태 변경(Lock의 획득과 해제, Lock의 점유 요청, Lock의 만료 등)에 따른 이벤트를 발행, 수신하면서 Lock을 관리합니다.

Lock 점유를 요청하는 스레드 당 하나씩 점유 대상 키 값을 기반으로 RedissonLockEntry 가 생성되며, 생성된 RedissonLockEntry 를 통해 Lock의 상태 변경에 따른 이벤트를 발행하고, 수신할 수 있게 됩니다. 결과적으로 반복적으로 Lock이 해제 되었는지 확인하지 않아도 Lock의 해제에 따른 알림을 수신함으로써 Lock을 획득할 수 있게 됩니다.

 

위 두가지 방식 중, pub/sub 방식의 구현을 통하여 위에서 소개한 전체 흐름(분산락)을 구현해 보도록 하겠습니다.

 

Pub/Sub 방식의 구현

일반적으로 Pub/Sub 방식은 Redisson Redis Client Library를 통해 구현할 수 있습니다.

✔️ 의존성 추가

Redisson 을 SpringBoot 와 함께 사용하기 위해서 아래 의존성을 추가합니다.
아래 의존성은 Redisson Client 을 구성하는 복잡한 작업을 자동으로 처리해줄 수 있도록 돕습니다. 저희는 관련 구성을 application.yaml 내에 spring.redis.redisson 키 값의 value 의 형태로 다음 링크와 같이 설정을 정의해 주기만 하면, Redisson client 에 해당 설정이 반영됩니다.

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

 

✔️ application.yaml 에 Redis 관련 설정 추가

redis:  
  host: ${redisHost}
  port: 6379

 

✔️ Redisson 을 이용한 분산락의 구현

RedisClient 구현

@Service  
class RedisClient(  
    // redissonClient 주입
    private val redissonClient: RedissonClient,  
) {  
    private val logger = LoggerFactory.getLogger(this::class.java)  

    companion object {  
        // 1)
        private const val WAIT_TIME = 10L  
        private const val LEASE_TIME = 9L  
        private const val LOCK_PREFIX = "id-for-pay:"  
    }  

    fun <T> lockForPay(key: String, func: () -> T): T {  
        // 2) 
        val lock = redissonClient.getLock("$LOCK_PREFIX$key")  
        return try {  
            // 3)
            val available = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)  

            // Lock 획득 실패  
            if (!available) {  
                throw Exception()  
            }  
            // 4) 
            func()  

        // func() 실행 중에 발생하는 Exception을 관리합니다. 
        } catch (e: PayException) {  
            throw e  
        } catch (e: Exception) {  
            logger.error("Lock 획득 실패: $key ${e.message}")  
            throw PayException("잠시 후 다시 이용해주세요")  
        } finally {  
            // 5)
            lock?.let {  
                if (it.isHeldByCurrentThread) {  
                    it.unlock()  
                }  
            }  
        }  
    }  
}

 

적용

redisClient.lockForPay(key) {  
    try {  
        // 결제 진행 로직
        doProcess()
    catch (e: Exception) {  
        throw PayException("잠시 후 다시 이용해주세요")  
    }  
}

분산락 구현 내용을 조금 살펴보도록 하겠습니다.

1) Lock관련 설정값들을 정의합니다.

    - WAIT_TIME: Lock 보유를 위해 대기하는 시간입니다. WAIT_TIME 만큼만 Lock 획득을 위해 대기합니다.

    - LEASE_TIME: Lock 획득 후, 보유 가능한 최대 시간입니다.

  - 최대 2건의 결제 요청에 대해 평균 응답 시간 + cushion time을 포함한 시간을 LEASE_TIME으로 잡고, WAIT_TIME 을 LEASE_TIME 보다 높게 잡아 2건의 동시 결제 건 중 대기하고 있던 요청이 먼저 대기를 취소하지 않도록 지정했습니다.

    - 단 위 설정은 2건에 결제 요청에 대해서만을 고려한 내용이며, 실제로는 적절한 WAIT_TIME, LEASE_TIME 값을 선정해 Lock 선점 요청의 충돌을 최소화하고, 결제 요청자가 오래 응답을 대기하는 상태를 예방할 필요가 있습니다.
2) RedissonLock 객체는 Redisson에서 분산락을 획득하고 해제하는 데 사용되는 클래스입니다. getLock() 메소드는 RedissonLock 객체를 생성하여 반환합니다. 이 객체는 tryLock(), lock(), unlock() 등의 메소드를 통해 Lock을 관리합니다. RedissonLockEntry 객체로 부터 Lock의 상태 변화를 전달받고, Lock 획득을 시도합니다.
3) Lock 획득을 시도하는 로직입니다. 만약, Lock 획득을 실패했다면, 해당 위치에서 최대 WAIT_TIME 만큼 머물게 됩니다. (tryLock(..) 함수의 내부 로직은 아래 내용을 참고해 주세요.)

4) 실제 결제 진행 로직(Lock을 걸고자 하는 로직)을 실행합니다. Lock의 획득 시에만 해당 func()을 실행할 수 있기때문에 동시성에 의해 발현된 문제를 해결할 수 있게 됩니다. 

5) Lock을 획득하지 못한 스레드에서 Lock을 해제하려고 할 때를 예방하기 위해 Lock(해제 대상)을 획득한 스레드가 맞는지 검사하는 과정을 추가합니다. 다음과 같은 경우들이 해당 로직을 추가해야 하는 실례로 볼 수 있습니다.
   - WAIT_TIME이 초과하여 기 점유된 Lock을 획득하지 못하고 예외 발생 -> 예외 처리 -> finally 의 흐름을 타는 경우, 획득한 Lock이 없으나 해제를 시도(unlock)하게 됩니다.
   - 첫번째로 Lock 획득을 시도하는 요청이지만,  tryLock() 실행 중에 Lock을 획득하지 못하고 예외 발생 -> 예외 처리 -> finally 의 흐름을 타는 경우에도 획득한 Lock이 없으나 해제를 시도(unlock)하게 됩니다.

 

tryLock() 내부 구현(RedissonLock.java)

간단하게 tryLock 함수의 내부 구현은 어떻게 동작하는지 알아보겠습니다. 해당 구현은 RedissonLock 클래스 내에 정의되어있습니다.

@Override  
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {  
    long time = unit.toMillis(waitTime);  
    long current = System.currentTimeMillis();  
    long threadId = Thread.currentThread().getId();  
    // Lock 획득을 시도합니다. 
    Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);  
    // ttl 은 Lock 획득까지의 대기 시간으로 ttl이 null 인 경우, Lock 획득에 성공했음을 의미함으로 true를 반환합니다. 
    //  이런 경우, RedissonLockEntry 의 생성이 일어나지 않습니다. 
    if (ttl == null) {  
        return true;  
    }  

    // waitTime 이 초과되면, acquireFailed() 처리 및 false 반환 로직(생략)

    current = System.currentTimeMillis();  
    // 채널을 구독합니다. 채널을 통해 Lock의 상태 변경 사항을 수신할 Thread 의 id를 같이 넘겨줍니다. 
    CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);  
    try {  
        // get() 처리를 통해 비동기 작업의 결과를 대기합니다. 
        // 응답이 time 이전에 반환이 되었다는 것은 정상적으로 `subscribeFuture`가 완료 되었음을 의미하며, 
        // RedissonLockEntry 객체 또한 subscribe() 호출될 때 생성됩니다. 
        subscribeFuture.get(time, TimeUnit.MILLISECONDS);  
    } catch (TimeoutException e) {  
            ...
        acquireFailed(waitTime, unit, threadId);  
        return false;        
    } catch (ExecutionException e) {  
        acquireFailed(waitTime, unit, threadId);  
        return false;        
    }  

    try {  
        // waitTime 이 초과되면, acquireFailed() 처리 및 false 반환 로직(생략)

        while (true) {  
            long currentTime = System.currentTimeMillis();  
            // Lock 획득 시도
            ttl = tryAcquire(waitTime, leaseTime, unit, threadId);  
            // Lock 획득 성공, true 반환
            if (ttl == null) {  
                return true;  
            }  

            // waitTime 이 초과되면, acquireFailed() 처리 및 false 반환 로직(생략)
            currentTime = System.currentTimeMillis();  
            if (ttl >= 0 && ttl < time) {  
                // ttl 이 null이 아닌 상태로 다른 클라이언트가 이미 해당 Lock을 점유하고 있는 상태입니다. 
                // 하기 메서드를 호출하여 다른 클라이언트가 Lock을 해제할 때까지 기다립니다. 
                // 이후에도 Lock을 획득하지 못한 경우, `acquireFailed(waitTime, unit, threadId)` 메서드를 호출하고
                // `return false` 문을 통해 Lock을 획득하지 못한 것으로 처리합니다.

                // 즉, `tryAcquire(waitTime, leaseTime, unit, threadId)` 메서드 호출을 통해 
                // Lock을 획득하기 위해 대기하고(및 상태 변화 수신 대기), 
                // `ttl` 값이 null이 되면 Lock을 획득한 것으로 처리됩니다.
                commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);  
            } else {  
                commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);  
            }  
          // waitTime 이 초과되면, acquireFailed() 처리 및 false 반환 로직(생략)
        }  
    } finally {  
        // 채널 구독을 해제합니다.  
        unsubscribe(commandExecutor.getNow(subscribeFuture), threadId);  
    }  
}

 

구현 검증

이제, 분산락을 이용한 구현을 마쳤으니, 간단한 테스트를 통해서 동시성이 잘 보장되는지를 확인해 보도록 하겠습니다.

테스트 방법은 아래와 같습니다.

  1. 동시처리에 취약한 공유 변수 하나를 선언합니다.
  2. 비동기 요청을 통해 공유 변수를 하나씩 카운트 업합니다.
  3. 최종 공유 변수의 값이 Lock 획득 요청 횟수와 동일한지 확인합니다.
    만약, 최종 공유 변수의 값 == 요청 횟수 라면, 동시성이 보장되었다고 볼 수 있습니다.

테스트 방법은 코드로 구현하면, 아래와 같습니다.

@Test  
fun `같은 키 값으로 여러 건 동시 시도 - 모두 정상 처리(동시성 보장)`() {  
    // given  
    // 공유 변수
    var integer = 0  
    val key = UUID.randomUUID().toString()  
    val requestCount = 6

    // when  
    runBlocking {  
        withContext(Dispatchers.IO) {  
            // 요청 횟수만큼 비동기로 요청
            (1..requestCount).forEach { _ ->  
                launch {  
                    redisClient.lockRecurrentIdForPay(key) { integer += 1 }  
                }            
            }        
        }    
    }  
    // then  
    // 요청 횟수와 공유 변수 값의 비교
    assertThat(integer).isEqualTo(requestCount)  
}

결과는 아래와 같았습니다. 

정상적으로 요청 횟수와 공유 변수의 최종 결과 값(integer)이 동일한 것을 보아 동시성이 보장되었다고 예상할 수 있습니다. 

 

⁉️ 만약, WAIT_TIME 이 초과해 Lock 획득 요청이 일부 실패하는 경우는 어떨까요?

 

Lock 획득 요청을 실패하게 하기 위해선 Lock의 LEASE_TIME(점유 시간)이 다른 요청의 WAIT_TIME 보다 길고, WAIT_TIME 동안에 Lock을 획득하지 못한 요청 스레드의 경우, 공유 변수 카운트 업을 못하게 됩니다.

 

비동기로 6개의 요청이 동시에 Lock을 획득을 시도하는 형태를 구현해 보도록 합니다.

  • WAIT_TIME 은 10초로 지정합니다.
  • 또, 획득한 Lock의 반환 시점을 미루기 위해 Lock 획득 후, 3초간 스레드를 블로킹 시킵니다.

이런 경우, 총 6개의 요청 중에 2개의 요청이 Lock 획득을 실패합니다.
총 4개의 요청이 WAIT_TIME 내에 Lock을 획득할 수 있게 되고, 나머지 2번의 요청은 모두 WAIT_TIME이 초과해 Lock 획득에 실패하게 되기 때문입니다. 

 

실제로 그러한지 테스트를 위해 아래와 같은 테스트 코드를 작성합니다.

@Test  
fun `같은 키 값으로 여러 건 동시 시도 - 일부 정상 처리 실패(WAIT_TIME_OVER)`() {  
    // given  
    var integer = 0  
    val key = UUID.randomUUID().toString()  
    val requestCount = 6

    // when  
    assertThrows<PaymentGatewayClientException> {  
        runBlocking {  
            withContext(Dispatchers.IO) {  
                (1..requestCount).forEach { _ ->  
                    launch {  
                        redisClient.lockRecurrentIdForPay(key) {  
                            // Lock 획득 시간을 지연시키기 위해 스레드를 일정시간 블로킹합니다.
                             Thread.sleep(3000)  
                            integer += 1  
                        }  
                    }                
                }            
            }        
        }    
    }    

    // then  
    // Lock 점유에 실패한 요청이 있기 때문에 integer 값이 keys.size 와 일치하지 않습니다.  
    assertThat(integer).isNotEqualTo(requestCount)  
}

테스트 결과는 아래와 같습니다.
총 6번의 요청건 중 2번의 요청이 Lock 획득을 실패했고, 공유 변수의 최종 결과 값(integer)은 4로 4번만 카운트된 것을 확인해 볼 수 있습니다.


여기까지 직면한 문제와 함께 분산락을 도입해 해결한 내용을 담은 내용이었습니다 : )

읽어 주셔서 감사합니다.