타임딜 시스템 설계 — 장애는 어디서 발생할까?

Posted by : on

Category : architecture



요즘 시스템 설계 스터디를 하나 하고 있습니다. 주제는 “100만 명이 동시에 몰리는 선착순 한정 판매 커머스”입니다. 한정판 스니커즈 드랍이나 티켓팅처럼 한 타임에 트래픽이 폭발하고, 재고는 극소량이고, 순서가 중요한 서비스를 MSA 구조로 설계해보는 건데요. 4명이서 파트를 나눴고 제가 맡은 건 데이터 정합성 보장 및 장애 복구입니다.

그런데 막상 시작하려니까 좀 막막했습니다. 다른 파트는 설계 범위가 비교적 명확하거든요. 대기열이면 “폭주 트래픽을 어떻게 제어할 것인가”, 분산 락이면 “재고를 어떻게 안전하게 차감할 것인가”처럼요. 반면 제 파트는 “모든 게 깨졌을 때의 수습”을 책임지는 성격이라 경계 자체가 잘 안 잡히더라고요. 다른 파트 설계가 다 나온 뒤에 대응하자니 뒷북이고, 그렇다고 아무 정보 없는 상태에서 뭘 먼저 해야 할지도 모르겠고요.

그래서 본격적인 설계에 들어가기 전에, “뭐가 터질 수 있는지부터 상상해보자”로 출발했습니다. 이번 포스트는 그 상상의 기록입니다. 정답을 가지고 쓴 글이 아니라, “이런 일들이 벌어질 수 있을 것 같고, 이렇게 막아보면 어떨까” 정도의 사전 고민이에요. 실제로 팀에서 어떤 선택을 하게 될지는 설계가 끝난 뒤 2편에서 정리할 예정입니다.

참고: 이 포스트는 실제 운영 중인 시스템 경험이 아니라, 설계 스터디 과정의 사고 기록입니다. 코드 예시와 수치는 설명을 위해 단순화한 예시입니다.


1. 주문은 생성됐는데, 결제·재고 서비스가 그 사실을 모를 때

상황 설정

MSA 구조를 가정해보겠습니다. 유저가 주문을 누르면 주문 서비스가 주문 DB에 기록을 남기고, 결제 서비스와 재고 서비스한테 “이 주문 처리해주세요” 메시지를 보냅니다. 여기서 한 가지 궁금증이 생길 수 있어요 — 왜 동기 호출 대신 메시지로 비동기 처리를 할까요?

동기로 호출하면 주문 서비스가 결제·재고 서비스의 응답까지 다 기다려야 해서 한 요청의 처리 시간이 길어집니다. 100만 명이 몰리는 상황에서는 주문 서비스의 스레드가 금방 고갈되고, 결제나 재고 서비스가 잠깐 느려지기만 해도 그 여파가 주문 서비스로 번져 연쇄 장애로 이어지기 쉽거든요. 그래서 보통 Kafka 같은 메시지 브로커를 통해 “메시지만 던지고 응답” 하는 비동기 구조를 선택합니다. 실제 처리는 결제·재고 서비스가 각자의 페이스로 소비하고요.

이 그림이 당연해 보이는데, 여기에 숨어 있는 함정이 있습니다. 주문은 DB에 저장됐는데 메시지가 발행되지 못하면 어떻게 될까요?

상세 시나리오

주문 서비스의 API 코드가 이런 식이라고 해보겠습니다.

// 1. 주문을 PENDING 상태로 DB 저장
order.setStatus(OrderStatus.PENDING);
orderRepository.save(order);

// 2. Kafka에 "이 주문 처리 시작해주세요" 이벤트 발행
kafkaTemplate.send("order-created", orderEvent);

// 3. 유저에게 "주문 접수 완료, 결제 진행 중입니다" 응답
return ResponseEntity.ok(order);

딱 봤을 땐 당연히 돌아갈 것 같은 코드죠. 그런데 1번이 성공한 직후, 2번을 실행하기 직전에 서버가 죽으면 어떻게 될까요. 타임딜 오픈 직후처럼 트래픽이 폭발하는 시점엔 파드 eviction, OOM, 순간적 크래시 같은 일이 꽤 일어나거든요.

복구하고 나면 이런 상태가 됩니다.

  • 주문 DB: PENDING 상태의 주문이 저장돼 있음
  • 결제 서비스: 이 주문의 존재 자체를 모름 → 결제가 영원히 시작되지 않음
  • 재고 서비스: 당연히 모름 → 재고 점유도, 차감도 안 일어남
  • 유저: 응답조차 못 받음 (3번 전에 죽었으니까)

이 주문은 영원히 PENDING 상태로 남는 유령 주문이 돼버립니다. 유저는 “왜 결제창이 안 뜨지?” 하다가 결국 재시도할 텐데, 그러면 같은 상품에 PENDING 주문이 2건 생겨서 나중에 뭐가 진짜 주문이었는지 구분도 안 됩니다.

왜 이런 일이 발생하는가

DB에 쓰는 것과 Kafka에 메시지를 보내는 것은 서로 다른 시스템이잖아요. 그래서 하나의 트랜잭션으로 묶일 수가 없습니다. 애플리케이션 코드에서 두 번 나눠 쓰는 순간, 그 사이의 어느 지점에서든 문제가 생기면 데이터가 반쪽만 남는 상태가 되거든요. 이런게 Dual-Write에서의 발생하는 문제입니다.

방법 A — 애플리케이션 레벨 재시도

try-catch로 묶어서 Kafka 발행이 실패하면 다시 보내는 방식입니다. 간단한데, 서버 자체가 죽으면 재시도 로직도 같이 죽잖아요. 근본 해결이 안 됩니다.

방법 B — Transactional Outbox 패턴

DB 트랜잭션 안에 Outbox라는 테이블을 하나 더 둬서, 주문 저장과 함께 “보낼 메시지 내용”까지 같이 저장해버리는 방식입니다.

BEGIN;
  INSERT INTO orders (id, status, ...) VALUES (...);
  INSERT INTO outbox (id, topic, payload, ...) VALUES (...);  -- 보낼 메시지 기록
COMMIT;

주문 저장과 “메시지 발행 예정” 기록이 하나의 트랜잭션으로 묶이니까 반쪽 상태가 없어집니다. 별도 프로세스가 Outbox 테이블을 읽어서 Kafka로 실제 발행하고, 발행에 성공하면 해당 row를 처리됨으로 마킹하는 구조입니다.

저의 선택

방법 B가 낫다고 봅니다. 서버가 죽어도 DB에는 기록이 남아있으니까 살아난 뒤 그 기록을 보고 다시 보낼 수 있거든요.

“Outbox 테이블을 어떻게 읽을 것인가”에도 몇 가지 선택지가 있습니다. 주기적 폴링(1초마다 SELECT)으로 가져가는 방식이 있고, CDC(Change Data Capture, Debezium 같은 도구)로 DB의 binlog를 실시간 스트리밍하는 방식도 있습니다. 폴링은 운영이 단순한 대신 초 단위 지연이 있고, CDC는 밀리초 단위 지연이지만 Kafka Connect 같은 별도 스택 운영 부담이 있죠. 타임딜처럼 지연이 유저 경험에 직결되는 도메인이면 CDC 쪽이 더 유리해 보입니다.

DB에 두 번 쓰는 비용은 늘어나지만, 유령 PENDING 주문으로 인한 CS 대응 비용에 비하면 충분히 감수할 만한 트레이드오프라고 봅니다.


2. 대기열을 책임지던 Redis가 페일오버되는 순간

상황 설정

100만 명이 정오에 몰리는 타임딜에서는 대기열이 필수입니다. 전체 트래픽을 곧바로 주문·결제 API로 흘려보내면 서버가 견딜 수가 없거든요. 대기열 자료구조로는 보통 Redis의 Sorted Set을 많이 쓴다고 하더라고요. ZADD로 유저를 진입시키면서 score에 진입 timestamp를 넣고, ZRANK로 본인 순번을 조회하는 식입니다.

그런데 100만 명 트래픽을 Redis 한 대가 받아내기는 쉽지 않습니다. 두 가지 위험이 있거든요.

  • OOM 위험: Sorted Set의 메모리 사용량이 순식간에 수 GB를 넘어서 OOM이 날 수 있음
  • 단일 장애점(SPOF): 죽으면 대기열 전체가 유실됨

그래서 마스터-리플리카 구성으로 가용성을 확보하고, 나아가 Redis Cluster로 샤딩해서 메모리 부담을 분산시키는 게 기본 구성입니다. 마스터가 죽어도 리플리카가 승격되면서 서비스는 이어지고요.

그런데 여기서 새로운 문제

Redis는 기본적으로 비동기 복제입니다. 마스터가 쓴 데이터가 리플리카로 넘어가는 데 약간의 지연이 있거든요 (보통 수십 ms ~ 100 ms). 평소엔 이게 거의 문제가 안 되는데, 타임딜처럼 초 단위에 수만 명의 진입 기록이 쌓이는 상황에선 꽤 심각한 구멍이 될 수 있습니다.

상세 시나리오

타임딜 오픈 직후 초당 수만 건의 ZADD가 마스터로 들어옵니다. 그중 일부가 아직 리플리카로 복제되지 못한 시점에 마스터가 갑자기 다운된다면 어떻게 될까요.

리플리카가 승격돼서 서비스는 계속되는데, 복제되지 못한 수천 명의 진입 기록은 영영 사라집니다. 이 유저들은 시스템 입장에서 “원래 없던 사람”이 돼버리는 거죠.

유저 관점에서 벌어지는 일을 그려보면:

  • 앱 화면엔 “당신은 5만 번째 대기 중입니다” 라는 정보가 남아있음 (마지막 폴링 응답 기준)
  • 다음 폴링에서 서버가 ZRANK 조회 → null 반환
  • 유저에게 “순번을 찾을 수 없습니다” 에러 표시 또는 재진입 유도

여기서 단순히 재진입시키면 맨 뒤 순번으로 이동합니다. 이미 10분을 기다린 유저 수천 명이 갑자기 가장 뒤에서 다시 시작하는 상황인 거죠. “선착순으로 정확하게 기회가 주어져야 한다”는 비즈니스 요구사항이 정통으로 깨집니다.

왜 이런 일이 발생하는가

근본 원인은 “고성능을 위한 비동기 복제는 완벽한 안전망이 아니다”라는 점입니다. Redis의 빠른 속도는 상당 부분 이 비동기 복제 덕분인데, 대가로 아주 짧은 유실 구간이 항상 존재하거든요. 평소엔 눈에 안 띄는 이 구간이 타임딜의 트래픽 밀도에선 수천 명 규모로 확대될 수 있습니다.

방법 A — 토큰 기반 재진입 (클라이언트 측 복구)

유저가 대기열에 진입할 때 서버가 토큰을 하나 발급해서 클라이언트에 같이 내려주는 방식입니다.

{
  "user_id": "u-12345",
  "original_score": 1745200000123,
  "signature": "..."
}

클라이언트는 이후 폴링할 때마다 이 토큰을 같이 보냅니다. 서버는 ZRANK로 순번을 조회해보고, 유저가 사라진 상태(null)면 토큰의 original_score를 이용해 원래 위치로 재삽입합니다.

rank = ZRANK(queue, user_id)
if rank == null:
  verify(token.signature)            # 위조 방지
  ZADD queue token.original_score user_id   # 원래 score로 복원
  rank = ZRANK(queue, user_id)
return rank

같은 토큰으로 여러 번 호출해도 이미 들어있으면 재삽입이 skip되니까 멱등성이 보장됩니다. 유저 입장에선 페일오버가 거의 투명하게 지나가고 순번도 보존되고요.

다만 이 방식엔 제약이 있습니다. 클라이언트가 토큰을 갖고 있을 때만 성립하거든요. 유저가 앱을 강제 종료하거나 브라우저 캐시를 날리면 토큰도 같이 사라집니다. 그리고 original_score가 노출되니까 위조 방지를 위해 서버 측 HMAC 서명 검증이 필수고요.

방법 B — 영속 로그 병행 (서버 측 복구)

유실 자체를 더 근본적으로 커버하는 방향입니다. Redis ZADD동시에 별도 영속 저장소(Kafka 같은 append-only 로그, 또는 RDBMS)에도 QueueEntered 이벤트를 기록합니다.

ZADD queue <timestamp> <user_id>                # Redis (빠른 조회용)
kafkaTemplate.send("queue-events", event)       # Kafka (영속 로그, source of truth)

페일오버가 감지되면, 별도의 복구 프로세스가 Kafka에서 누락 구간을 읽어 새 마스터 Redis에 재적재합니다. 이 구조에서 진실의 원천(Source of Truth)은 Kafka가 되고, Redis는 “빠른 조회를 위한 캐시”에 가까운 역할로 바뀌는 셈입니다.

장점은 클라이언트 상태와 무관하게 서버 측에서 일괄 복구가 가능하다는 점입니다. 토큰을 잃어버린 유저도 복구됩니다. 단점은 쓰기 부하가 2배로 늘어나고, 결정적으로 Kafka 발행 자체도 실패할 수 있는데 그 순간 시나리오 1과 똑같은 Dual-Write 문제가 또 등장한다는 거예요. 그래서 Kafka 발행도 Outbox로 감싸야 하는 식으로 복잡도가 꼬리를 물고 늘어납니다.

저의 선택

방법 A를 메인, 방법 B를 선택적 보조로 쓰는 조합이 현실적으로 보입니다.

  • 방법 A는 구현 비용이 낮고, 유저 관점에서 투명하게 복구됩니다. 대부분의 페일오버 시나리오를 여기서 흡수할 수 있고요.
  • 방법 B는 강력한 보장을 주지만 쓰기 부하 증가, Dual-Write 문제 재귀, 복구 지연이 있어서 전면 도입은 부담스럽습니다. 그래서 “대규모 복제 지연 장애처럼 드물지만 치명적인 케이스”의 2차 방어선으로 두는 쪽이 비용 대비 효율이 좋아 보여요.

그리고 이 문제는 장애 당시의 UX도 같이 고민해야 합니다. 재진입이 진행되는 몇 초 동안 유저 화면에 “순번 재확인 중입니다” 같은 투명한 안내가 있으면, 내부적으로 벌어지는 복잡한 복구 로직을 유저가 인지하지 못한 채 지나갈 수 있거든요.


3. 마음 급한 유저의 광클, 그리고 중복 결제

상황 설정

대기열을 통과한 유저가 드디어 결제 페이지에 도착했습니다. 10분을 기다려서 온 결제 창인데, 100,000 TPS 트래픽 속에서 네트워크가 살짝 느려진 그 순간 불안해진 유저가 결제 버튼을 3번 연타합니다. 하필이면 이 요청들이 0.01초 차이로 각각 다른 서버에 도착하고요 (로드밸런서가 요청을 분산시키니까요).

한정판 1,000족인데 한 유저가 3켤레를 사버리거나, 같은 카드에서 돈이 3번 나가는 대참사가 벌어질 수 있습니다. 유저 광클만 문제가 아닙니다. 시스템 내부의 자동 재시도 — Kafka의 at-least-once 배달, HTTP 클라이언트 재시도 — 로도 똑같은 중복 요청이 발생하거든요.

왜 이런 일이 발생하는가

기본 가정이 “한 요청 = 한 처리”인데, 분산 환경에서는 같은 요청이 여러 번 들어오는 게 오히려 디폴트거든요. 그래서 서버가 “아, 이거 이미 처리했어”라고 알아채고 중복을 걸러낼 수 있어야 합니다. 이걸 멱등성(Idempotency) 보장이라고 부릅니다.

멱등성을 위해선 “같은 요청”을 식별할 키가 필요합니다. 보통 클라이언트가 하나의 논리적 작업(예: 이 결제 시도)당 한 번 발급하고, 재시도 시에도 같은 값을 유지하는 Idempotency-Key(UUID 같은 값)를 HTTP 헤더에 실어 보냅니다. 광클이든 자동 재시도든 “같은 의도의 요청”엔 같은 키가 붙어야 중복을 걸러낼 수 있거든요. 서버는 이 키로 “이 요청 이전에 처리했는지”를 판단합니다.

방법 A — DB 유니크 제약조건

결제 테이블에 idempotency_key 컬럼을 두고 UNIQUE 제약을 걸어두는 방식입니다. 같은 키로 두 번째 요청이 들어오면 DB가 거부하고요. 확실한데, 10만 TPS 상황에서 매번 DB에 물어보는 건 부담이 크더라고요.

방법 B — Redis 기반 멱등성 체크

요청이 들어오면 가장 먼저 Redis에 SET ... NX EX 명령으로 키를 저장해봅니다. “키가 없을 때만 세팅(NX) + TTL 설정(EX)“을 원자적으로 처리하는 명령이에요.

SET idempotency:{key} "processing" NX EX 300

저장에 성공하면 새 요청이니 처리 진행. 실패하면(키가 이미 있음) 중복 요청이니 이전 처리 결과를 그대로 반환합니다. Redis는 메모리 기반이라 10만 TPS도 가볍게 견뎌요.

참고로 레거시 SETNX를 쓰고 EXPIRE를 따로 호출하는 방식은 그 사이에 서버가 죽으면 TTL 없는 키가 영구히 남는 유명한 함정이 있습니다. 그래서 원자적 SET ... NX EX 쪽을 써야 합니다.

저의 선택

방법 B를 메인으로 두고, 방법 A를 최후의 방어선으로 두는 2중 방어가 좋아 보입니다. Redis는 빠르게 중복을 걸러내고, 혹시 Redis가 잠깐 죽거나 정합성이 깨져도 DB 유니크 제약이 마지막에 막아주거든요. “속도”와 “정확성”을 각 레이어에서 분담시키는 구조입니다.

하나의 장치에만 의존하면 그게 무너졌을 때 방어선이 사라지는데, 두 겹으로 쌓아두면 서로 다른 실패 모드를 갖기 때문에 동시에 뚫릴 확률이 확 낮아지잖아요. “초과 판매 절대 불가”가 워낙 강한 비즈니스 요구사항이라 오버엔지니어링이라기보단 보험에 가깝다고 봅니다.


마무리

여기까지가 설계에 들어가기 전에 제 머릿속에 쌓인 고민들입니다. 정리하면서 몇 가지 깨달은 게 있는데요.

첫째로, 이 파트는 애플리케이션이 아니라 플랫폼에 가깝다는 생각이 들었습니다. 다른 파트는 “뭘 만들 것인가”에 집중한다면, 이 파트는 “다른 파트들이 만든 게 깨졌을 때 어떻게 복구할 것인가”를 고민하는 쪽이더라고요. 그래서 다른 파트 설계가 다 끝난 뒤 따라가는 게 아니라, 오히려 다른 파트에 제약을 주는 규칙을 먼저 정해야 하는 역할이었습니다. “너희 이벤트는 이런 규격으로 보내줘”, “멱등성 키는 이렇게 붙여줘” 같은 식으로요.

둘째로, 장애 시나리오를 상상해보는 것만으로도 설계의 경계가 선명해지더라고요. “뭐가 터질 수 있는가”를 하나씩 그려보니 “어떤 패턴이 왜 필요한가”가 자연스럽게 따라왔습니다. Outbox 패턴을 써야 하는지는 Dual-Write 문제를 직접 상상해본 뒤에야 진짜로 와닿았고, 대기열 페일오버 대응도 “유실이 실제로 일어난다면” 하고 그려보니까 토큰 / 영속 로그라는 대응이 머리에 남더라고요.

셋째로, 이 포스트에 적은 선택들은 다 “이게 정답이다”가 아니라 “이렇게 해볼 수 있을 것 같다” 정도입니다. 실제로 팀원들이 설계한 파트 1(대기열), 2(분산 락), 3(Saga)의 구체적인 선택에 따라 제 답안도 달라지겠죠.

그래서 다음 편은 실제 팀원들의 설계가 나온 뒤에 쓸 예정입니다. “이 상황에서는 이렇게 대응하면 될 것 같다”는 가정형을 빼고, 실제 시스템 설계 위에서 “이 상황이 오면 이렇게 방어한다“로 구체화해보려고 합니다.

한 줄 요약: 본격 설계에 들어가기 전에 “뭐가 터질 수 있는지”부터 상상해보니, 필요한 패턴이 왜 필요한지가 오히려 선명해졌고 제 파트의 역할도 더 명확해진 경험이었습니다.

분산 시스템 설계나 정합성 이슈 방어에 고민이 있으신 분들께 조금이라도 도움이 됐으면 좋겠습니다. 다른 접근이나 “이 시나리오에선 저도 이렇게 고민해봤어요” 같은 경험 있으시면 댓글로 공유해주시면 너무 좋을 것 같아요!


About Woody Park
Woody Park

Hi, my name is Woody Park. I'm a backend developer.

Email : harry122226@gmail.com

Website : https://github.com/wooodypark

About Woody Park

Hi, my name is Woody Park. I'm a backend developer.

Follow @wooodypark
Categories
Useful Links