🚀선착순 쿠폰 발급 시스템 - 6


Phase 2 : Redis 전략 적용 + 분산락 버그 해결

배경

  • 기존에 Redis 분산락을 적용한 코드가 잘 적용이 되었던건지 검증을 위해 이전 게시글에서 200VU 이후에 지연이 생겼던 문제가 있었는데 과연 개선이 되었는지 체크를 위해 500vu로 테스트를 돌려봤다.

문제

  • 근데 테스트 결과는 충격적이었다.
지표 DB-Only(Phase1) Redis 적용(기존코드) 차이
TPS 590 260 56% 감소
p50(ms) 573ms 1,530ms 2.7배 악화
p95(ms) 1,040ms 1,960ms 1.9배 악화
p99(ms) 1,370ms 2,580ms 1.9배 악화
정합성 👌 👌 동일
에러율 0.0% 0.0% 동일
  • Redis를 붙였더니 오히려 더 느려져있었다…
  • 1년 6개월 전의 나는 무슨 짓을 한걸까?
  • 원인이 뭔지 찬찬히 찾아보자

의심1 : 이중 잠금

  • 기존에 코드를 구현할 때 점진적으로 발전에만 초점을 맞춰서 DB단에 비관적 락 -> Redis 분산락 적용할 때 DB단에 비관락을 제거하지 않아 이중으로 락을 걸었던 문제가 있었다.
1
Redis 분산락 대기 -> DB 비관적 락 대기 -> 커밋
  • Redis라는 별도의 캐시를 도입을 하긴 했지만 여전히 DB가 커밋전가지 락을 유지하기 때문에 개선이 되지 않고 오히려 +가 된것이다..

  • 해결

    • @Lock(LockModeType.PESSIMISTIC_WRITE) 이걸 제거했다.

새로운 문제

  • 제거하고 나니까 DB 갱신 손실문제가 생겼다.
1
2
3
4
Total Stock:  10000
Remain Stock: 9087   ← DB에 남아있는 재고
Issued Count: 10000  ← 실제 발급된 건수
기대값: 10000 - 9087 = 913 → 10000과 불일치
  • VU=500, 재고=10000에서 정합성 불일치가 발생했다.

원인

1
2
3
4
5
6
7
8
9
10
11
@Transactional
public CouponIssueResult issueCoupon(CouponIssueRequest request) {
	String requestId = UUID.randomUUID().toString();
	log.debug("[{}]: 쿠폰 발급 시도 시작 | requestId: {} userId: {}",
			request.code(), requestId, request.userId());

	//2. 중복 발급 검증
	validateDuplicateIssue(request.code(), request.userId());

	return processCouponIssueRequest(request, requestId);
}
  • 지금 현재 쿠폰 발급 메서드에 issueCoupon 메서드 전체가 @Transactional이 잡혀있다.
  • 그리고 processCouponIssueRequest()메서드 안에서 Redis 락 획득이 시작된다. 즉 트랜잭션(DB 스냅샷 MVCC)이 Redis의 락 획득 보다 먼저 시작 된다는 것이다
    • MySQL 기본 격리 수준인 REPEATABLE READ는 트랜잭션 시작 시점의 스냅샷을 기반으로 select를 수행한다. redis락이 직렬화를 보장해도, 트랜잭션이 락 회득 전에 시작되었기 때문에 락 획득 후의 DB 읽기에도 여전히 오래된 스냅샷을 사용해서 정합성 이슈가 생긴것이다.
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
시간 →
────────────────────────────────────────────────────────────

TX_A                    TX_B
────────────────────────────────────────────────────────────
BEGIN                  BEGIN   ← 이미 시작됨 (스냅샷 생성됨)

Redis Lock 획득 시도
[획득 성공]

                        Redis Lock 대기중

SELECT stock = 10  ← 스냅샷 읽기
UPDATE stock = 9

COMMIT

Redis Lock 해제
                        Redis Lock 획득

                        SELECT stock = 10  ← 여전히 과거 스냅샷
                        UPDATE stock = 9

                        COMMIT

────────────────────────────────────────────────────────────
결과:
실제 기대: 10 → 8
실제 결과: 10 → 9  (Lost Update 발생)
  • 더 느려진 성능을 개선하기 위해서 Redis를 도입을 했는데 그래서 문제였던 이중 락을 제거를 했더니 트랜잭션 시작 시점과 락 획득 시점의 미스 문제가 발견이 되었다.

그렇다면 어떻게 해결?

  • 가장 먼저 떠오른 방법은 DB단에서 정합성을 보장 + 트랜잭션 경계 조정하도록 하는것이었다. 결국 MVCC 스냅샷 문제 + Transaction 시작 시점 미스로 이런 문제가 생겼으니 말이다.
  • 트랜잭션이 먼저 시작되면서 MVCC 스냅샷이 고정되었고 Redis락으로 직렬화 해도 오래된 스냅샷을 읽게 된다.
  • 그래서?
    1. 트랜잭션 바깥에서 Redis 락을 먼저 획득하고
    2. DB 처리만 별도의 Transaction으로 수행하고(최신 상태를 DB단에서 select for update로 읽어오거나 아니면 update ... where stock > 0과 같은 조건부 벌크 업데이트로 원자성을 확보하는 방식? -> MVCC 스냅샷 문제로 생기는 갱신 손실은 방지 할 수 있을 것이다.)
    3. 커밋 이후에 락을 해제하면 해결 할 수 있을 것이다.

==그런데 여기서 한가지 의문이 생겼다.==

Redis 분산락을 썼는데, 왜 DB에서 또 정합성을 보장해야하지? (락을 잡았는데도 정합성이 깨짐) 단지 재고를 1감소시키는 짧은 연산을 처리학기 위해 이런 복잡한 과정을 거쳐야 하는거지? 문제를 해결하고 있는 것이 아니라 더 어려운 방식으로 우회해서 해결하려고 하는 것 같다는 생각이 들었다.

  • “락으로 직렬화할 것이 아니라 재고를 원자적으로 변경하면 되지 않을까?”
    • 요청 줄세우기 -> 재고를 빠르게 변경

      의심2 : 분산락은 필요없다?

분산락은 쿠폰 발급(제고 관리)에서는 과한 방법이었다.

==분산락은 어떨때 사용하는걸까?==

  1. 여러 인스턴스 간 공유 자원 보호
  2. 긴 임계 구역 보호
  3. 복잡한 상태 변경 제어 예: 같은 배치 작업이 여러 인스턴스에서 동시에 돌면 안 되는 경우, 외부 API 호출 포함한 긴 작업 등

하지만 쿠폰 발급 같은 경우에는 단순한 카운터 감소만 제어하면 되고, 고 트래픽 환경이다. 즉 가벼운 연산을 원자적으로 처리할 문제였다.


해결

  • 그래서 분산락 대신 ==Redis의 원자 연산==을 사용하기로 했다.
  • ==Redis가 먼저 재고를 관리하다가 DB에서는 성공한 요청만 DB로 내려보내는 것이다.==
  • 이렇게 바꾸면 락 대기가 없고 요청을 줄 세우지 않으며 빠르게 실패를 반환 할 수 있다.
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
31
32
33
34
35
36
37
38
39
시간 →
────────────────────────────────────────────────────────────

요청 A                   요청 B
────────────────────────────────────────────────────────────
Redis 재고 감소 시도
DECR stock (10→9) 성공

                        Redis 재고 감소 시도
                        DECR stock (9→8) 성공

DB 트랜잭션 시작
BEGIN

INSERT CouponIssue
(유니크 체크)

UPDATE stock = stock - 1

COMMIT

                        DB 트랜잭션 시작
                        BEGIN

                        INSERT CouponIssue
                        (중복이면 실패)

                        → 예외 발생

                        ROLLBACK

                        Redis 보상 복구
                        INCR stock (8→9)

────────────────────────────────────────────────────────────
결과:
Redis: 10 → 9
DB:    10 → 9
정합성 유지
  • Redis는 재고를 빠르게 처리하는 계층(원자적) DB는 최종 정합성을 보장하는 계층
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
요청 → hasStock() ──실패──→ [즉시 반환: 재고 없음]
         │
        성공
         ↓
      decreaseStock() ──실패──→ [즉시 반환: 재고 없음]
(Lua: GET → check → DECR)
         │
        성공 (Redis 재고 차감됨)
         ↓
      issueReservedCoupon()  ← @Transactional
         ├─ 쿠폰 조회 + 기간 검증
         ├─ decreaseRemainStockAtomically()  ← DB 재고도 차감
         ├─ CouponIssue saveAndFlush()       ← 중복 발급 방지
         └─ 발급 이력 저장
         │
     예외 발생 시
         ↓
      increaseStock()  ← Redis 재고 복구
  1. Redis에서 재고를 원자적으로 감소시키고
  2. 성공한 요청만 DB로 내려보낸 뒤
  3. DB에서 유니크 제약과 조건부 업데이트로 최종 확정 즉 Redis는 요청이 처리 가능한지 빠르게 판단하고 DB는 이 요청이 유효한지 검증을 담당한다.

그렇다면 Redis에서 먼저 재고를 줄였는데 DB에서 실패하면? 정합성이 깨지지 않을까? 이 문제는 Redis에서 재고를 선점했지만 DB에서 중복 발급이나 실패가 발생하면 Redis 재고를 다시 증가하는 방식으로 해결하면 된다.

  • 이렇게 변경하면 기존에 락 획득시간이 사라지게 되면서 불필요한 RTT가 감소가 되면서 전체 레이턴시가 감소되지 않을까 예상을 해본다.

부하 테스트 비교

  • 조건은 db-only일때와 동일한 조건으로 진행을 했다.
VU Phase 1 TPS Phase 2 TPS Phase 1 p99 Phase 2 p99
200 580 1,916 167ms 311ms
500 590 2,198 1,370ms 445ms
1000 591 2,218 2,110ms 804ms

분석

  • ==TPS: 590 -> 1,916~2,198 (3.2~3.8x)==
1
2
3
4
5
6
Phase 0: 재고 소진 이후에도 모든 요청이 DB 비관적 락 획득 후 재고 확인
         → 10개 커넥션이 항상 포화 상태, 590 TPS 상한 유지

Phase 1: 재고 소진 시점부터 Redis hasStock() = false → DB 접근 없이 즉시 거절
         → DB 커넥션 부하가 실제 발급 수(10,000건)로 한정
         → 커넥션이 빠르게 반환되어 발급 구간의 처리량 극대화
  • ==p99레이턴시: VU=500기준 1,370ms -> 445ms (-68%)==
    • Phase 0은 VU=500에서 모든 요청이 DB락 경합 큐에 쌓이며 대기하고
    • Phase 1은 DB 트랜잭션 자체는 동일하게 존재하지만, 재고 소진 후 Redis에서 즉시 거절하므로 DB 대기 큐가 짧아진다.

결론

줄세우기가 아니라 원자성(충돌없는 연산)으로 해결

  • 확실히 동일한 DB 커넥션 풀(max=10) 조건에서도 수정된 이후에 뚜렸한 성능 개선 성과가 있었다.
  • 핵심적인 이유는 크게 두가지가 있는데
    1. 재고 소진 판정 위치 차이
      • 기존 db-only의 경우 DB 비관락 내부에서 판단을 했었다 그래서 항상 락 획득 비용이 발생했었는데
      • 이번에 phase2의 경우 Redis의 원자 연산으로 판단을 진행해서 이 단계에서 실패 요청의 경우는 DB 커넥션을 사용하지 않게 되었다
    2. 커넥션 반환 속도
      • 기존 phase1의 경우는 재고 소진 후에도 모든 요청이 DB 커넥션 점유를 했다면
      • phase2의 경우 재고 소진 시점 이후에는 DB 커넥션 부하가 급감하여 발급 구간 처리량에 집중할 수 있게 되었다.

다음으로..

  • Redis의 도입으로 DB의 커넥션 경쟁을 재고의 수만큼만 경쟁하도록 개선하여 성능을 크게 개선을 하였다.
  • 하지만 아직 한계는 있다.
    1. 우선 Redis가 단일 인스턴스여서 SPOF(Single Point of Failure)문제가 생긴다.
      • 만약 Redis가 죽으면? 쿠폰 발급 서비스 자체가 중단된다. db-only 방식(phase0)를 failback 로직으로 둘 수는 있겠지만 이러면 원활한 서비스 운영이 아닐것이다.
    2. DB가 여전히 동기 병목이 있다
      • 앞서 설명했듯이 재고의 수만큼만(Redis 선차감을 통과한 요청) DB 커넥션 경쟁에 참여하는데 DB 트랜잭션을 동기적으로 완료해야한다.
      • 즉 재고 100개 쿠폰에 10,000명이 동시 요청하면 DB 접근은 ~100건으로 줄지만, 이 100건이 동시에 몰린다
      • 근데 재고가 적을 때는 큰문제 아닐것이다. 하지만 만약 재고가 많아진다면? 그런 경우에는 또다시 병목이 생길것이다. -> 이 경우는 DB의 커넥션 풀(HikariCP)을 늘려서 어느정도는 해결을 할 수는 있겠지만 무진장 늘릴수도 없을 뿐더러 잘못하면 오히려 DB CPU의 부하가 발생할 수 도 있다.
    3. API의 응답이 동기 구조이다.
      • 동기 구조이기 때문에 클라이언트는 발급이 완료될 때까지는 HTTP 커넥션을 유지해야한다.
      • 만약 트래픽이 증가를 하면 Tomcat 스레드 풀이 빠르게 소진될것이고 -> DB 트랜잭션 대기 시간이 길어지면 Tomcat 스레드가 더 오래 점유될것이다 -> 스레드가 고갈되어 후속 요청에 대해 reject가 될것이다.
      • 스레드 풀을 조정하면 되지 않냐고? -> 역시 마찬가지로 무한정 늘릴수도 없을 뿐더러 얼마나 트래픽이 몰릴지를 예측하기도 힘들것이다.
    4. Redis와 DB 재고 불일치 보상 트랜잭션이 실패할수있음
      • 현재 구조는 Rollback이 되거나 스케줄러상에서 불일치가 발생하면 보상 로직이 실행되도록 구현되어있는데
      • 이 보상 트랜잭션 자체가 오류가 나는 경우에는 여전히 Redis 재고가 DB보다 적게 남을수있고 만약 스케줄러가 보정을 한다고 해도 실시간으로 스케줄러를 돌리는 것이 아니기떄문에 그 사이의 시간동안 불일치가 유지 된다.
    5. 중복 발급 검증이 DB에서만 이뤄진다
      • Redis 경로에서는 사전 중복 검증없이 바로 재고를 차감하도록 되어있고. 중복 발급이 발생한다면 DB의 유니크 제약으로 방어를 하도록 되어있다. 그래서 insert가 실패하면 보상 로직이 실행되도록 구현이 되어있다.
      • 역시 불필요한 DB 커넥션소비가 있다. Redis -> DB까지 갔다가 롤백을 해야하는 불필요한 과정을 거치게 되고 만약 중복 요청이 많이 발생한다면 장애로까지 이어질 수 있다.

==또 한가지 치명적인 한계가 있다. 3번과 이어지는 부분이긴한데 지금 구조가 순서를 보장하지 않고 있었다는 것이다.== 그래서 사용자의 요청이 도착 순서대로 발급되는걸 보장하지 않는다.

이런 문제들을 다음편에서 해결해보자