2023.09 ~ 2024.01
Backend
Givemeticon
만료가 임박한 기프티콘을 사고파는 중고거래 플랫폼
선착순 쿠폰 발급 구조를 측정 결과에 따라 재설계하고, 인기순 조회의 성능을 개선했습니다.
01 / SCALABILITY · RabbitMQ, MySQL, Redis
측정 결과를 따라 세 번 바꾼 선착순 쿠폰 발급 구조
문제1,000명이 동시에 신청하는 선착순 쿠폰에서 초과 발급, 처리량 한계, DB 장애 시 신청 유실이 드러났습니다.
잠금 없이는 107장 발급, 잠그면 656rps에서 정체 · DB가 멈추면 신청도 멈춤
UNIQUE · 조건부 차감 · 대가: 결과 확인까지 지연
판단 근거 · 세 번의 결정
실제 트래픽이 없어 부하·장애 실험 환경을 만들고,
구조의 한계가 측정으로 드러날 때마다 다음 구조를 골랐습니다.
- ① 처리량 · MySQL 판정 → Redis 판정: 재고 행에 비관적 락을 걸어 초과 발급은 0이 됐지만 처리량이 656rps에서 멈췄습니다. 행 잠금·커넥션 풀·fsync를 하나씩 바꿔 800rps에서 재측정했고, 재고 행을 4개로 나누자 p50이 3,362ms에서 2.4ms가 되어 원인을 한 행의 잠금 대기로 특정했습니다. 판정을 Redis Lua 원자 카운터로 옮겨 응답이 밀리지 않는 최대 부하가 400에서 1,600rps가 됐습니다.
- ② 가용성 · Redis 판정 → Redis 판정 + RabbitMQ: DB를 약 48초 멈추자 성공 응답이 54초간 0이었고 당첨 기록 200건이 유실됐습니다. 응답 경로에서 DB를 빼고 브로커가 저장을 확인한 뒤 응답하도록 바꿔, DB 중단 중에도 신청 31,002건이 전부 성공했습니다.
- ③ 정합성 · Redis 제거: 판정은 Redis, 기록은 MySQL에 있어 둘이 어긋나면 정답을 정할 기준이 없었습니다. 대조 실험에서 재시작 후 5,013장이 초과 발급됐고, Redis를 빼 판정과 기록을 MySQL 트랜잭션 하나에 두었습니다.
Outbox · Redis Streams · Kafka · RabbitMQ를 비교한 이유
| 대안 | 판단 |
|---|---|
| Outbox · DB 워커 | 신청을 먼저 DB에 써야 해서 응답 경로에서 DB를 빼지 못합니다. |
| Redis Streams | 기록이 카운터와 같은 Redis에 남아 장애가 분리되지 않습니다. |
| Kafka | 가능하지만 메시지 하나가 실패했을 때 그것만 다시 받으려면 재시도 경로를 따로 설계해야 합니다. |
| RabbitMQ · 선택 | 저장 후 확인, 메시지별 확인·재전달이 가능합니다. |
최종 구조의 구현
- 접수: 브로커 저장 확인을 받은 뒤에만 202와 접수 번호를 응답합니다. 확인이 늦으면 접수 번호와 함께 503을 주고, 큐가 가득 차면 새 신청을 거절합니다.
- 판정: 소비자 1개가 순서대로 MySQL 트랜잭션 하나에서 중복 확인 → 재고 차감 → 결과 기록을 처리합니다. 같은 접수 번호는 한 번만, 한 사람은 한 장만(UNIQUE), 재고가 남을 때만 차감합니다.
- 확인: DB에 커밋한 뒤에 처리 완료(ACK)를 알려, 소비자가 죽어도 판정이 사라지지 않게 했습니다.
- 장애 처리: DB 연결이 끊긴 오류면 소비를 멈췄다 재개하고, 그 밖의 실패는 3번 시도 후 격리 큐로 보냅니다.
| 800rps에서 | 이전 | 변경 |
|---|---|---|
| 신청 응답 p95 | 35~121ms | 18~92ms |
| 당첨 결과를 아는 시점 | 응답 즉시 | 중앙값 42~53초 뒤 |
| 소비 속도(행/초) | 740~890 | 320~440 |
| 신청당 DB 기록 | 1행 | 2행 |
응답과 확정 시점은 두 구조 각 3회, 소비 속도는 큐가 줄어든 기울기로 구한 값입니다. 판정 위치와 기록량이 함께 바뀌어 차이를 하나의 원인으로 돌리지 않습니다.
검증 결과 · 최종 구조
1,000명 동시 신청·재고 100장을 10회 단위로 반복하고, 부하 중 DB·브로커를 멈추거나 소비자를 강제로 종료했습니다. 부하는 k6로 초당 요청 수를 단계별로 올렸고, 측정 전에 예상 결과를 문서에 적은 뒤 맞았는지 판정했습니다.
02 / READ PERFORMANCE · MySQL, 사전 집계
인기순 조회에서 반복 집계 비용 제거
문제좋아요 100만 건을 실시간 집계하면서 인기순 Top 20 조회가 4.6초까지 느려졌습니다.
데이터 증가 → 반복 집계 비용 증가
읽기 경로에서 실시간 집계 제거
판단 근거
20개를 보여주기 위해 매번 전체를
집계하는 비용에 주목했습니다.
- 시행착오: 좋아요 테이블에 인덱스를 추가했지만,
GROUP BY + COUNT + ORDER BY의 반복 계산은 남았습니다. - 원인 판단:
LIMIT 20이어도 상위 상품을 찾기 위한 집계·정렬 비용이 먼저 발생하는 구조였습니다. - 구조 전환: 쓰기 시점에 메타 테이블의 집계값을 갱신하고, 인기순 커버링 인덱스로 집계값 조회의 I/O를 줄였습니다.
사전 집계와 커버링 인덱스의 역할
사전 집계는 조회 시 좋아요 행을 다시 세는 작업을 제거합니다. 커버링 인덱스는 필요한 정렬 키와 집계값을 인덱스에서 읽게 해 추가 테이블 접근을 줄입니다.
상품 정보는 별도 JOIN으로 조회합니다. 아래 전체 쿼리가 모두 커버링된다는 의미는 아니며, 실제 접근 경로와 정렬 여부는 EXPLAIN으로 확인해야 합니다.
핵심 쿼리
MyBatis · ItemMapper 발췌SELECT i.id,
i.brand_id,
i.name,
i.price,
m.like_count,
m.view_count
FROM item_favorite_meta m
JOIN item i
ON i.id = m.item_id
ORDER BY m.like_count DESC, m.item_id DESC
LIMIT #{limit};좋아요를 실시간 COUNT하지 않고 저장된 like_count로 정렬합니다. 같은 좋아요 수는 item_id로 순서를 결정합니다.
검증 결과 · Top 20 조회 시간
상품 10만 개 · 좋아요 100만 건
실시간 집계 → 메타 테이블 기반 조회
기존 측정 결과 기준. 실행 환경·반복 횟수·캐시 상태의 상세 기록은 별도 보완이 필요합니다.