초당 5,000건 트래픽, 한 번 고민해보기

2025. 11. 7. 00:06·SERVER

해당 포스팅은 빅테크 기업의 시니어 엔지니어 입장에서 현실적인 기술 문제를 해결해보기 위해 임의로 페르소나와 상황을 정하여 고민해보았습니다. 구체적인 코드를 제공하지 않고, 해결과정의 흐름과 과정을 담아습니다.

 

문제상황

[가상 시나리오] 배달의민족 10주년 쿠폰 이벤트

우리 팀은 "배민 10주년 기념 2만원 쿠폰" 이벤트를 준비 중입니다. 선착순 1만 명에게 지급되며, 오픈 예상 트래픽은 첫 10초간 3~5만 건 요청 (초당 3,000 ~ 5,000건)입니다. 지난 이벤트에서는 오픈 15초 만에 14,700장이 발급되어 9,400만 원의 손실이 발생했고, 일부 사용자가 동일 쿠폰을 2~3회 중복 사용한 사례가 230건 발견되었습니다. 이는, 쿠폰 발급 시 "잔여수량 확인 → 발급 → 차감" 과정에서 동시 요청이 몰리면 race condition이 발생하고, 주문 시에도 "쿠폰 유효성 검증 → 사용처리" 사이에서 멱등성이 보장되지 않습니다. 모바일 앱에서 네트워크 지연으로 인한 재시도 요청과 악의적인 동시 API 호출이 혼재되어 있어, 쿠폰 발급과 사용 전 과정에서 정합성과 멱등성을 보장해야 합니다.

 

먼저 원인을 분석해보았습니다.

1. 발급 시: "재고 확인 → 발급 → 차감" 과정에서 Race Condition
2. 사용 시: "유효성 검증 → 사용 처리" 사이에서 멱등성 미보장
3. 네트워크 지연 재시도 + 악의적 동시 호출 혼재

 

내가 고민한 점

이 문제를 받았을 때 가장 먼저 든 생각 : 락 걸면 되는거 아닌가..?

하지만 초당 5,000건 트래픽에서는 모든 당연한 게 당연하지 않았습니다.

 

동시성 제어 고민 과정: 3가지 시도와 실패

시도1 : 비관적 락(Pessimistic Lock)

  • 데이터 간의 충돌이 많다는 가정하에, 데이터에 액세스 하기 전에 먼저 락을 걸어 충돌을 예방하는 방식
  • SELECT FOR UPDATE
  • 장점은 무엇일까? 
    • 데이터 정합성은 확실하게 보장
  • 단점은 무엇일까?
    • 락이 걸리는 동안 다른 트랜잭션 접근을 막기 때문에 트랜잭션 대기 시간이 증가

먼저 DB락을 생각해보았는데요, 비관적락의 가장 큰 문제점 락이 걸리는 동안 다른 트랜잭션 접근을 막습니다. 즉, 1만개 쿠폰이라는 동일한 자원(DB Row)에 락을 걸기 위해 동시에 몰려든다는 것입니다. 초당 5,000건의 요청은 5,000개의 DB 커넥션이 필요하지만, DB 커넥션 풀은 보통 50~100개로 순식간에 고갈될 것입니다. 이는, 락을 기다리는 수많은 요청들로 인해, 전체 시스템의 마비를 불러올 수 있습니다. 

초당 5,000건의 요청
      ↓
5,000개의 DB 커넥션 필요
      ↓
DB 커넥션 풀: 보통 50~100개
      ↓
순식간에 고갈
      ↓
락을 기다리는 수많은 요청들
     ↓
전체 시스템 마비

 

시도2 : 낙관적 락(Optimistic Lock)

  • 데이터 간의 충돌이 많지 않다는 가정하에, 데이터에 액세스할 시점에는 락을 걸지 않고 커밋시 충돌을 검사하여 문제가 있을 경우 롤백하는 방식
  • version 증가 방식
  • 장점은 무엇일까?
    • 성능이 우수함
  • 단점은 무엇일까?
    • 충돌 빈도가 높을 경우, 재시도가 많아져 응답 지연이 발생하여 성능 저하 가능

두번째 DB락인 낙관적락을 보았습니다. 요구사항에서 일반적인 동시성 제어가 아닌, 순간의 트래픽 폭주 상황이라면 큰 문제가 발생합니다. 오픈 예상 트래픽인 5,000건의 동시 요청이 있을 때 1건이 성공한다면, 나머지 4,999건은 충돌하게 됩니다. 99% 실패인것이죠, 그렇다면 다시 발급하기 버튼을 누르고, 쿠폰을 받지 못한 4,999건 재시도가 발생합니다.

실패 -> 재시도 -> 더 큰 부하 -> 더많은 실패

악순환에 빠지게 됩니다. 

 

시도3 : 분산 락

  • Redis의 SETNX 명령을 활용해 분산 환경에서 락 구현
  • Redisson 같은 라이브러리 사용
  • 장점은 무엇일까?
    • DB 락보다 빠르고, 다중 서버 환경에서 동작
  • 단점은 무엇일까?
    • 락 타임아웃 관리 필요
    • 락 대기 시간 증가
    • 서버 다운 시 락 해제 문제

그럼 왜 안되었을까? 분산락에도 여러 문제가 있었습니다.

1. 락 타임아웃 지옥

Thread 1: 락 획득 (3초 타임아웃)
Thread 2~5000: 대기...
       ↓
평균 대기 시간: 너무 김
       ↓
타임아웃 발생
      ↓
사용자 경험 최악

 

Thread 1이 락을 획득하고 3초의 타임아웃입니다. 이때 나머지 동시 요청되었던 나머지 4999개의 스레드가 동시에 락을 기다립니다. 락이 오래 유지되면, 대부분 요청은 타임아웃으로 실패하게 되는 것이죠. 사용자는 계속 재시도 버튼을 누르게됩니다.

 

2. 서버 다운 시 락 해제 문제

Server A: 락 획득
Server A: 다운
       ↓
락이 영원히 안 풀림
       ↓
백그라운드 스레드로 주기적 갱신 필요
      ↓
복구 로직 추가
     ↓
복잡도 폭발

 

만약, 서버가 락을 잡은채 다운된다면? A가 락을 획득하고 트랜잭션 중에 다운된다면 Redis에 남은 락은 해제되지 않은 채 영원히 대기 상태에 머무르게 됩니다. 락 해제를 자동화하기 위해 TTL(만료 시간)을 설정할 수도 있지만, 이 경우 트랜잭션이 길어질 때 락이 먼저 풀려 정합성이 깨질 위험도 있습니다.

 

3. 락 범위 최소화의 딜레마

락을 더 세분화해서, 예를들어 쿠폰이 아닌 userId단위로 락을 건다면...?

  • 전체 락 -> 안전하지만 느림
  • userId별 락 -> 빠르지만 race condition 여전

락의 범위를 좁히면, 처리량은 올라가지만 정합성은 깨지고,

락의 범위를 넓히면, 정합성은 유지되지만 처리량은 급격히 떨어집니다. 결국 트레이드오프의 딜레마에 빠집니다.


전환점: "실시간 처리를 포기하면 어떨까?"

세 가지 시도의 공통점: "모든 요청을 즉시 처리하려고 했다"

그때 떠오른 질문:

꼭 즉시 처리를 하지말고, 대기열(Queue)에 쌓아두고 순차적 처리를 진행하자!

 

트래픽 규모에 맞게 현실적인 대안이 무엇인지 고민해보았습니다.

 

대기열 + Lua Script

기존 접근 (실시간 처리):
Client → Server → 즉시 발급 시도
→ 5,000건 동시 처리 필요
→ 락 경쟁, 커넥션 풀 고갈

새로운 접근(비동기 + 대기열):
Client → Server → 대기열 등록 (즉시 202 응답)
→ Worker가 순차 처리 (Lua Script)
→ 락 불필요, 안정적 처리

 

사용자는 즉시 응답을 받지만, 실제 쿠폰 발급은 백엔드 Worker가 순서대로 수행합니다.

 

그렇다면, 왜 Lua Script인가?

대기열로 순차 처리하기로 했지만, Worker에서도 동시성 문제가 남아있습니다.

Worker가 여러 개일때, 동시에 같은 쿠폰 재고에 접근할 수 있습니다.

1. Worker에도 동시성 문제..?

20개 Worker가 동시에 큐에서 꺼냄
     ↓
같은 쿠폰(재고)에 동시 접근
     ↓
여전히 Race Condition 가능

 

2. 분산 락을 다시 고민해본다면...?

그건 다시 락 관리와 네트워크 왕복 문제를 불러옵니다.

Worker → Redis: 락 획득 (왕복 1)
Worker → Redis: 재고 확인 (왕복 2)
Worker → Redis: 재고 차감 (왕복 3)
Worker → Redis: 발급 기록 (왕복 4)
Worker → Redis: 락 해제 (왕복 5)

총 5번 네트워크 왕복 + 락 관리 복잡도
 

3. Lua Script 선택 이유

Redis는 싱글 스레드로 동작하기 때문에,
Lua Script를 통해 여러 명령을 하나의 원자적 연산으로 묶을 수 있습니다.
즉, 여러 Worker가 동시에 접근하더라도 내부적으로 순차적으로 처리됩니다.

Worker → Redis: Lua Script 전송 (왕복 1)
└→ Redis 내부에서 원자적 실행:
        1. 중복 발급 체크 (SISMEMBER)
        2. 재고 확인 (GET)
        3. 재고 차감 (DECR)
        4. 발급 기록 (SADD)

총 1번 왕복 + 락 불필요 + Redis Single Thread 특성으로 원자성 보장

 

결정적인 차이는,
  • 분산락 대비 Lua Script는 네트워크 왕복이 5회 -> 1회로 80% 감소됩니다.
  • "중복 발급 체크 -> 재고 차감 -> 발급 기록"을 하나의 원자적 명령으로 실행합니다.
  • Redis Single Thread 특성상 락 경쟁 불필요합니다.

그 후 : 안정성을 위한 보조 전략

1. 서버 부하 관리

수평확장 구조와 로드 밸런서 방식

  • 여러 서버 인스턴스를 띄우고, 로드밸런서로 요청을 균등하게 분산
  • 장점 : 트래픽 증가시 서버를 늘려서 처리량 확장 가능
  • 단점 : 초기 구축 비용과 세션 관리와 같은 추가 고려사항 필요

초당 5,000건의 트래픽은 분산하지 않고 단일 서버에서 불가능하다고 판단하였고, 확장 구조를 고려하지 않을 수 없다고 생각했습니다. 트래픽이 폭주하는 상황에서 자동적으로 서버를 늘리고, 로드 밸런서를 통해 분산하면 안정적으로 요청을 처리 가능하다고 생각합니다.

 

그렇다면, 초당 5,000건 요청, 서버당 처리 능력 1,000TPS 라 했을 때 기본 5대 + 여유분 40%로 총 7대 운영이 적합하다고 생각하였습니다. 그리고 오픈 3분전 7대로 스케일 아웃, CPU 70% 이상 시 서버 추가와 CPU 30% 이하 시 서버 감소로 잡았습니다.

 

이때, 비동기 처리에 대해서 고민이 되었는데요,

초당 5,000건 정도면 대기열 방식으로 충분하다고 생각하였고, 초당 10,000건 이상시 Kafka 등 비동기 메시징을 고려하기로 했습니다.

 

2. 악의적인 요청 차단

문제점에도 악의적인 동시 API 호출, 클라이언트 버그로 무한 재시도가 있었습니다. 이는 Rate Limit 을 설정하는 방법을 생각해봤습니다.

 

1단계: 인프라 계층

IP 기반 제한, 초당 10회 제안

 

2단계: 애플리케이션 계층
사용자 기반 제한 1분 5회 제안

 

3단계: 비즈니스 계층

쿠폰 발급 및 사용 제한 10초 3회 (짧은 시간 연타 방지), 1시간 20회 (전체 제한)

대기열 중복 방지 (이미 대기 중이면 거부)

마치며,

이 문제를 풀면서 가장 크게 느낀 점은 

"당연한 것이 당연하지 않다"

 

"락 걸면 되지"를 너무 단순하게 생각하였던 것이죠, 대규모 트래픽인지 얼마나 대규모인지에 따라 기술 선택과 해결 방법이 달라지는 것을 알았습니다. 어떻게 보면 오버엔지니어링이 될 수 있기 때문이죠. 실제 개발 환경이 아니기 때문에 제가 쓴 이 답이 정답이다 라고 할 수 없는 것이고, 그저 상황이 주어지면 어떤 방법이 있을까? 라는 고민의 시작이었던 포스팅이었습니다.

 

'SERVER' 카테고리의 다른 글

Elastic Beanstalk 환경생성 오류  (0) 2023.10.13
'SERVER' 카테고리의 다른 글
  • Elastic Beanstalk 환경생성 오류
jiixon
jiixon
  • jiixon
    Dev:elop
    jiixon
  • 전체
    오늘
    어제
    • 분류 전체보기 (26)
      • JAVA (0)
      • SPRING (3)
      • SERVER (2)
      • 공부 (20)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • POST
    • SETTING
  • 공지사항

  • 인기 글

  • 태그

    AWS
    Bdd
    junit
    배포
    프로그래머스
    tdd
    spring
    Elastic Beanstalk
    spring jpa
    springboot
    서버
    Kotlin
    java
    알고리즘
    AssertJ
    자바
    테스트
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
jiixon
초당 5,000건 트래픽, 한 번 고민해보기
상단으로

티스토리툴바