포인트 차감과 보상 로직 구현, 동시성 문제 처리 본문

포인트 차감과 보상 로직 구현, 동시성 문제 처리

JinHwan Kim 2025. 9. 8. 00:29

배경

회사 서비스 중 하나로, IoT 기기를 코드로 조회/제어할 수 있는 API를 제공하고 있다. 서비스가 커지면서 해당 서비스에 과금 정책을 도입하여, 서버 비용을 충당하고, 과하게 리소스를 사용하는 사용자를 제한하고자 한다. 나는 포인트 결제, 차감, 보상 처리를 맡아, 잔여 포인트를 확인하고 제한하거나, 차감하는 트랜잭션 처리를 개발하고 있다. 

 

개발 요구 사항은 아래와 같다.

1. 사용자의 잔여 포인트를 조회하고 사용량만큼 차감되어야 한다.
2. 잔여 포인트 부족 시, 비즈니스 로직이 수행되어선 안된다.
3. 서버 문제로 비정상 응답을 반환할 경우, 포인트는 차감되어선 안된다.

 

문제 시나리오, 차감과 보상 사이의 포인트 부족 현상

서버 이상으로 비정상 응답이 반환될 때는 포인트가 차감되어선 안된다. 요청이 처리되기 전 보유 포인트 확인 및 차감을 처리하고, 5xx대 응답이 발생된다면, 차감한 포인트를 롤백하는 보상 트랜잭션을 수행한다.

1. 포인트 차감 트랜잭션 수행
2. 비즈니스 로직 수행
3. 예외 발생 시, 포인트 롤백 트랜잭션 수행

 

이 시나리오에선 사용자의 요청이 누락될 여지가 있다. 포인트 차감과 보상 사이에 포인트 부족으로 처리되지 못한 요청 처리가 발생한다면, 요청을 마친 사용자는 포인트가 남아있지만, 포인트 부족으로 처리되지 않은 요청도 존재하게 된다. 고객 입장에선 잔여 포인트가 있는데도 처리되지 못한 요청이 있는 것이다.

 

사용자 입장에선 5번 요청도 수행되어야 한다.

 

예시 : 포인트 4, 5개 동시 요청, 4번 요청은 서버 에러로 포인트 복구를 가정
1. 1번~4번 요청 : 포인트 차감 성공 
2. 5번 요청 : 포인트 부족으로 에러 반환
3. 4번 요청 : 서버 에러로 포인트 복구 처리 (포인트 사용 내역엔 0P 차감으로 표시)
-> 포인트 사용 내역을 확인하는 사용자 입장에선 포인트가 남아있는데, 포인트 부족으로 처리되지 않은 요청(5번)이 존재

 

문제 조건 부수기

문제 발생 조건을 정리하면 아래와 같다. 만약 셋 중 하나라도 만족하지 않는다면, 문제가 발생하지 않는다.

 

조건 1. 포인트가 차감되었다가 복구된다.

조건 2. 그 사이에 요청이 처리된다.

조건 3. 2번 요청은 보유 포인트 확인 시, 포인트 부족으로 예외가 발생한다.

 

그렇다면 조건 2와 조건 3을 엮어서, 포인트가 부족할 수 있는 상황에서만 요청을 순차 처리하면 어떨까? 포인트가 부족할 수 있는 상황이 아니라면 사용자 요청은 문제없이 병렬 처리 되고, 포인트가 부족한 상황에서만 순차 처리되어 포인트가 꼬이는 문제에 안전하다.

 

포인트가 부족할 수 있는 상황에선 순차 처리

정책상 우리 서비스는 한 사용자가 동시에 5개 이상의 요청을 할 수 없다. 이미 5개 요청이 처리 중에 있다면, 다른 요청이 처리 완료되기 전까지 그 이후 요청은 버려진다. 특정 사용자가 너무 많은 요청을 한 번에 전달하여 서버 리소스를 차지하는 문제를 막기 위해서다. (사실 이미 그런 공격 아닌 공격으로 호되게 당하고 만들었던 처리율 제한과 어뷰징 유저 제한 필터다.)

 

그렇기에 5개 요청 이상을 처리할 수 있는 포인트가 있는 유저는 문제 조건에서 벗어난다. 예를 들어 5 포인트를 갖고 있는 사용자를 가정하자. 동시에 최대 5개의 요청을 보낼 수 있는 제약에서, 포인트가 부족한 상황을 만들 수 없다. 반면 사용자가 4 이하의 포인트를 갖고 있다면, 동시에 5개의 요청을 수행하고 포인트가 차감할 때, 포인트 부족으로 실패할 여지가 있다.

 

즉 내 서비스는 4개 이하의 포인트를 보유한 사용자에 한하여 포인트가 부족할 수 있는 상태가 되고, 앞선 조건 3을 만족하게 된다. 포인트가 5개 이상인 사용자는 편하게 병렬 처리하고, 그보다 작은 사용자는 순차 처리하면 문제가 해결된다.

 

레디스를 이용한 스핀 락

사용자 별 요청 순차 처리를 위해, 레디스로 락을 구현했다. 레디스의 SETNX 명령어를 사용하면, 키가 존재하지 않으면 값을 쓰는 처리를 원자적으로 수행할 수 있다. 로직을 간단히 그리면 아래와 같다. 락 획득을 시도하고, 획득에 성공하면 현재 처리 중인 다른 요청이 없는 것을 확인할 수 있다. 반대로 이미 레디스에 키가 있다면, 다른 요청이 수행되고 있는 중이므로, 요청이 끝나고 키를 반납할 때까지 키 확인과 점유 시도를 반복한다.

var lockKey = username;
while(N초_동안) {
    val result = redisService.SETNX(lockKey, EXPIRE_TTL);
    if(result == true) {
       log.info("락 획득 성공, 사용자 요청 수행");
       redisService.delete(lockKey);
       return;
    } 
    log.info("락 획득 실패, 약간의 대기 후 재시도");
    Thread.sleep(intervalMs);
}

 

이런 확인 반복을 통한 락 점유 방식을 스핀 락이라고 한다. 락을 확인하기 위해 지속적으로 커멘드를 날려야 하기 때문에 레디스에 부하가 생긴다. 또, 반복 사이에 약간의 간격을 두기 때문에, 락이 풀리자마자 곧바로 다음 락 점유를 확신할 수 없다. 락 점유 시도 반복 사이의 간격을 좁히면 레디스로의 부하가 늘 것이고, 간격을 넓히면 락을 확인하는 지연 시간이 커진다.

 

Pub / Sub을 이용한 부하, 지연 개선

Redisson은 레디스 Pub/Sub을 이용한 락 기능을 제공하는 레디스 클라이언트이다. Redisson도 락이 해제되었을 때 이벤트를 발행하고, 대기 중인 구독자들은 락 점유를 시도한다. 락이 해제되었을 때만 경합이 일어나기에 레디스 부하가 적고, 불필요한 지연이 없다.

 

이런 Pub/Sub을 사용한 락 구현 아이디어는 이상적이지만, 실제 구현까지 완벽하게 효율적인 것은 또 아니다. Redis Pub/Sub은 토픽의 모든 구독자들에게 이벤트가 전달되기에, 이를 사용하는 Redisson의 락 역시 단일 수신자를 지정 없어, 대기 중인 모든 구독자들의 경합이 필요하다. 락을 대기했던 순서대로 다음 락 소유자가 정해지지 않는다.

 

또, Redis Pub/Sub은 수신자가 제대로 수신했는지, 처리에 성공했는지 관심이 없다. 만약 Lock 해제를 알리는 이벤트가 발행되는 시점에, 네트워크나 레디스 이슈로 제대로 전달되지 못한다고 재시도를 하거나 이벤트를 보관하지 않는다. 이런 이벤트 유실로 락 점유가 안 되는 상황을 피하기 위해, Redisson 내부에서는 폴링을 함께 수행하여, 락 해제 이벤트 유실 문제를 만회한다.

 

레디스 Fall back

레디스에 문제가 생기는 경우도 배제할 수 없다. 꼼꼼한 포인트 관리도 좋지만, 메인 서비스 자체가 수행 안되는 일은 없어야 한다. 차라리 포인트 차감이 없더라도 사용자의 요청은 처리할 수 있도록 하는 것이 낫다는 판단이었다. 그래서 아래 두 규칙을 정하고, 이를 만족할 수 있도록 락 대기 시간과 레디스 키 TTL 정의, 예외 처리를 구성하였다.

 

1. 레디스 커넥션에 이상이 있다면, 락 점유와 상관없이 다음 로직을 수행한다. -> 락  점유 시도 중 레디스 예외를 무시한다. 

2. 한 요청이 오랜 시간 락을 점유하고 있다면, 다른 요청에서 락을 빼앗고 다음 로직을 수행한다. -> 락 TTL 시간과 락 대기 시간을 동일하게 한다.

var lock = redisson.getLock(user_key);
var 락_TTL = 5초;
var 락_대기_시간 = 락_TTL;

try {
    if(lock.tryLock(락_대기_시간, 락_TTL)) {
        log.info("락 획득 성공");
    }
    log.info("비즈니스 로직 시작");
} catch (RedisConnectionException | RedisTimeoutException | InterruptedException | WriteRedisConnectionException ex) {
    log.info("레디스 커넥션 이상 무시");
    log.info("비즈니스 로직 시작");
}
if(lock.isHeldByCurrentThread()) {
    lock.unlock();
    log.info("락 해제 완료");
}

 

정리 

작업 : 포인트 차감, 보상 로직 구현, 동시성 문제 해결

이슈 :

   - 한 요청에서 포인트 차감과 보상이 일어나고, 그 사이의 다른 요청에서 포인트 부족이 발생하는 경우,

   - 사용자 입장에선 포인트가 남아 있음에도 서비스 사용이 제한된 현상 발생

처리 :

   - 포인트 부족이 발생할 수 있는 상황(n개 이하의 포인트 보유 사용자)에 한하여 요청 순차 처리

   - Redis를 사용한 락 구현과 Pub/Sub을 이용한 효율성 개선

   - 문제 상황에서만 순차 처리를 적용하여 동시성 문제를 해결하면서도, 일반 상황에선 병렬 처리를 유지하여 성능 문제를 피함

예외 : 

   - 레디스 커넥션 이상 시, 락 점유와 상관없이 메인 로직 수행하도록 예외 처리

   - 한 요청이 오랜 시간 락을 점유할 시, 다른 요청에서 락을 빼앗고 다음 로직을 수행할 수 있도록 TTL 설정

 

 

Comments