본문 바로가기

Memo

선착순 처리 기능 구현에 대한 고민

'요청을 선착순으로 처리하는 기능을 설계한다면 어떻게 할 것인가'

최근에 면접을 보면서 받았던 질문 중 하나다. 그때 당시에는 바로 든 생각은 단순하게 '트랜잭션 처리해서 결과를 보장하도록 구현하면 되지 않나'라고 생각했던 거 같다. 이걸 어떤 식으로 접근해야 할지 감이 안 와서 제대로 답은 못했고 그냥 '락 걸어서 처리하면 되지 않을까'라는 식으로 대답했다. 면접을 봐주셨던 개발자분께서 설명해 주시면서 레디스 관련 이야기도 나왔는데, 뒤에서 이 방식에 대해 정리를 하려고 하고, 그전에 간단하게 생각했던 DB 트랜잭션을 사용해서 구현하는걸 좀 더 구체적으로 정리해보려고 한다.

DB 트랜잭션 사용

처음 생각했을 때는 단순하게 요청 들어오는 대로 즉시 하나씩 처리하면 되지 않을까? 락걸면서?라고 생가했지만 당연히 이건 지연이 너무 될 거 같아서 현실성이 없어 보인다. 그래서 생각한 방식은 요청이 들어오면 요청들 객체를 생성하고 나중에 별도의 프로세스로 그걸 처리하는 방식이다. 구체적인 과정은 다음과 같다.

 

  1. 요청 수신 및 DB에 기록:
    클라이언트로부터 요청을 받고 각 요청에 대한 정보(예: 사용자 ID, 요청 데이터, 시간스탬프 등)를 데이터베이스에 기록한다. 이 때  '대기 중' 또는 '미처리' 상태로 표시한다.
  2. 처리 프로세스:
    별도의 프로세스 또는 스케줄러(cron job, Celery Beat 등)가 주기적으로 데이터베이스를 조회하여 처리해야 할 요청을 찾은 뒤 트랜잭션을 시작하여, 가장 먼저 들어온 요청을 '처리 중' 같은 상태로 변경한다.
  3. 요청 처리:
    실제 비즈니스 로직을 수행하여 요청을 처리하고 처리가 완료되면 요청 상태를 '완료됨'으로 업데이트한다.
  4. 트랜잭션 커밋:
    요청 처리가 완료되면 트랜잭션을 커밋하고 오류가 발생한 경우 트랜잭션을 롤백하고, 요청 상태를 '오류'나 '재처리 대기'로 변경한다.

여기서 한가지 가정 그리고 고민할 부분이 있는데,

2번 과정에서 한 가정은 일단 처리 프로세스나 스케줄러가 하나의 인스턴스로 이루어져 있다고 가정한다. 만약에 여기서 이게 여러 개라면 또 동시성 이슈가 있어서 이때 처음 생각한 거랑 비슷하게 '비관적 락'을 사용한다면 지연이 생길 것이다. 아무튼 일단은 하나의 인스턴스로 처리한다고 가정했다.

또 다른게 있는데

4번에서 오류가 발생한 경우이다. 기존에 요청을 생성하는 서버가 인스턴스들이 제대로 요청 등록하고 그걸 처리하는 인스턴스가 차례로 트랜잭션을 처리하면 문제 생길일이 없는데, 100%라는 게 없고 네트워크 문제나 무슨 문제든 생기기 마련이어서 저런 롤백하는 경우가 생길 거다. 그럼 이것들을 어떻게 처리할지에 대한 고민이 있었다. 간단하게는 그걸 모아두는 재처리 대기 큐 같은 걸 만들어 놓고 처리하는 단계에서 우선순위를 얘네를 두면 되지 않을까? 생각이 든다. 그리고 재처리 로직을 추가를 하는 것도 있다. 근데 재처리를 해도 무한정할 수는 없으니 큐랑 재처리 둘 다 포함을 시켜야 할거 같다.

여기까지가 트랜잭션을 기반으로 구현한 방식이고,

다음은 메시지 큐와 레디스를 사용한 방식을 정리하려 한다. 이 부분은 찾아보니 잘 정리한 글이 있어서 이 글을 정리하려고 한다.

참고 글:

https://techblog.gccompany.co.kr/redis-kafka%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%BF%A0%ED%8F%B0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B0%9C%EB%B0%9C%EA%B8%B0-feat-%EB%84%A4%EA%B3%A0%EC%99%95-ec6682e39731

 

Redis&Kafka를 활용한 선착순 쿠폰 이벤트 개발기 (feat. 네고왕)

안녕하세요. 유저혜택개발팀 쿠폰 백앤드 개발자 페이든입니다.

techblog.gccompany.co.kr

 

기존의 선착순 이벤트 문제점

  • 기존의 쿠폰 시스템을 활용한 선착순 이벤트에서는 RDB에 의존하여 수량 체크가 이루어졌기 때문에, 동시성 이슈로 인한 선착순 쿠폰이 초과 지급될 위험이 있었음 (이 부분은 구체적으로 어떤 상황에서 생기는지 궁금했다)
  • Monolithic 한 시스템 구조로 인해 쿠폰 시스템 장애 발생 시 서비스 전체 장애 전파될 가능성도 있는 상황.

선착순 이벤트 요구사항은 다음과 같다.

  1. 이벤트 기간 동안, 매일 특정 시간 오픈하며 총 지급 수량을 한정한다.
  2. 쿠폰의 지급 수량은 당일 정해진 양을 초과해서는 안된다.
  3. 쿠폰은 1인당 1장만 지급한다

1번과 2번은 레디스의 INCR 커맨드를 활용해 지급 재고를 관리해서 해결했지만 3번은 지급받은 사용자들의 정보를 별도로 저장해야 하는 이슈가 있었다고 한다.
따라서 레디스의 SET자료형을 사용.

근데 트래픽이 몰리는 경우 동시성 이슈가 생김

그래서 레디스의 Transaction을 사용해서 문제를 해결함. 다만 여기서는 기존 RDB랑 다르게 롤백 이 불가능해서 따로 구현해야 한다고 함.

동시성 이슈는 해결했지만 성능 문제가 있음

실제 쿠폰을 지급하는 Insert 쿼리는 선착순 쿠폰 지급 API 비즈니스 로직 내에서 이뤄져서 Wirte DB에 부하가 발생한다고 함. 더욱이 선착순 이벤트뿐만 아니라 기존 쿠폰 서비스에도 사용하고 있어서 Write DB의 경우 Scale-up을 통해서 대비를 해도 쿠폰 서비스 전체에 영향을 주게 된다고 한다.

그래서 EDA 기반 Kafka를 활용하여 쿠폰 지급 처리하도록 변경

이를 통해 실제 이벤트 서비스 API가 DB에 접근하지 않고 지급 가능 여부만 판단하여 고객에게 응답을 주기 때문에 처리 속도가 개선되었다고 함.


카프카를 직접 사용해보지 않아서 구체적인 내용은 잘 모르지만, 어차피 메시지 큐 방식이 비슷하기 때문에 위처럼 구조를 바꿔 주게 된다면 기존에 쿠폰 서비스를 담당하던 로직들도 나중에 따로 이벤트, 그니까 메시지로 변경할 수 있고 나중에 기능이 추가될 때도 쉽게 확장이 가능하겠다고 생각이 들었다. 그리고 언급된 거처럼 책임이 분리돼서 전체 서비스로 퍼질 수 있는 문제도 해결 가능한 거 같다.

 

또 다른 방식이 있겠지만 보통 큰 흐름은 비슷하고 조금씩 다르게 처리하는 부분들이 있는거 같다. 결론적으로 가능하다면 높은 처리량을 위해 메시시 큐를 도입하는게 좋을거 같다.

또 이번에 질문 받으면서 느낀건 평소에 레디스를 쓸때 '캐시나 이런저런 상황에서 쓰면 되겠거니' 라고 생가했는데 구체적으로 상황을 생각 해보지 않으니까 막상 문제를 고민할때 같이 떠올리지 못하는거 같다. 이런 저런 상황에 대해서 좀 깊게 고민을 해야겠다.