주문 버튼을 누르면 결제·재고차감·알림발송·정산기록이 모두 동기로 줄줄이 실행됩니다. 평소엔 괜찮은데, 이벤트 세일에 주문이 폭주하자 알림 서버가 느려지면서 주문 응답 전체가 멈춥니다. 알림 때문에 주문이 안 되는 황당한 상황입니다. 옆 서비스는 주문만 처리하고 알림·정산은 '이벤트'로 큐에 던져둡니다. 알림이 느려도 주문은 멀쩡합니다. 동기/비동기와 이벤트 기반 구조를 알면, "왜 한 부분이 느린데 전체가 멈추나"를 이해하고 그 결합을 끊는 설계를 판단할 수 있습니다.
- 1동기와 비동기 호출의 차이를 "응답을 기다리는가"로 설명할 수 있다
- 2메시지 큐가 생산자-소비자를 분리·완충·격리하는 원리를 설명할 수 있다
- 3전달 보장(at-least-once 등)과 멱등 처리·DLQ의 필요성을 설명할 수 있다
- 4동기 체인의 장애 전파를 비동기로 끊는 설계를 판단할 수 있다
확대
위 그림처럼 동기 처리는 모든 단계를 기다려 응답이 느리지만, 비동기는 큐에 이벤트를 발행하고 즉시 응답해 UX를 개선합니다.
동기 vs 비동기
기다리느냐, 맡기고 가느냐
핵심 판단: 사용자 응답에 꼭 필요한 것(결제 성공)만 동기로, 나머지(알림·정산·집계)는 비동기로 뺍니다. 이는 모놀리식 vs 마이크로서비스의 MSA에서 서비스 간 결합을 끊는 핵심 수단이며, 12-Factor App의 무상태·확장성과도 맞물립니다.
메시지 큐 — 분리·완충·격리
생산자와 소비자 사이의 버퍼
메시지 큐(Kafka·RabbitMQ·SQS·Redis)는 생산자(이벤트 발행)와 소비자(처리) 사이에 버퍼를 둡니다.
| 용어 | 뜻 | 비고 |
|---|---|---|
| Producer / Consumer | 메시지 발행자 / 소비자 | 둘이 분리(decouple) |
| Topic / Queue | 메시지가 쌓이는 채널 | 주제별 분리 |
| Partition / Offset | 병렬 처리 단위 / 읽은 위치 | Kafka의 확장 핵심 |
| Consumer Group | 소비자 묶음(부하 분산) | 병렬 소비 |
| Broker | 큐 서버 | 보통 클러스터로 이중화 |
세 가지 이점:
- 분리(decouple): 생산자는 소비자가 누군지·살았는지 몰라도 됨.
- 완충(buffer): 스파이크를 큐가 쌓아두고 소비자가 자기 속도로 처리.
- 격리(isolation): 소비자가 죽어도 메시지가 큐에 남아 유실 안 됨, 복구 후 처리.
깊은 운영(파티션·보존기간·디스크 산정)은 기술스택 읽기에서 본 것처럼 별도 인프라 투자가 필요합니다. 큐는 '또 하나의 서버'가 아니라 클러스터·디스크·모니터링이 따르는 상태 저장 시스템입니다.
확대
위 그림처럼 메시지 큐는 프로듀서와 컨슈머 사이의 완충제로, 각 컨슈머가 독립적으로 소비하고 장애 시 재전달이 보장됩니다.
전달 보장과 멱등 — 비동기의 함정
중복은 정상이다 — 그래서 멱등이 필수
비동기의 가장 흔한 함정은 중복입니다. 대부분의 큐는 전달을 이렇게 보장합니다.
at-most-once : 최대 한 번(유실 가능, 중복 없음) — 손실 OK인 지표 등
at-least-once : 최소 한 번(중복 가능, 유실 없음) — 가장 흔함
exactly-once : 정확히 한 번(이상적이나 비싸고 제약 많음)
대부분 'at-least-once' → 같은 메시지가 2번 올 수 있다!
"주문 생성" 이벤트 중복 → 멱등 처리 안 하면 결제 2번/알림 2번
대응:
- 멱등 처리(idempotent): Idempotency Key·중복 체크로 "같은 메시지 두 번 처리해도 결과 1번"(요구사항 정의).
- DLQ(Dead Letter Queue): N번 실패한 메시지를 격리 보관 → 정상 흐름을 안 막고 원인 분석·재처리.
- 재시도(retry) + 백오프: 일시 장애는 점점 간격을 늘려 재시도.
PM·인프라 관점: "비동기로 빼자"는 결정엔 중복·실패·순서라는 새 문제가 따라옵니다. 멱등·DLQ·재시도 설계 없이 큐만 도입하면 중복 사고가 납니다.
동기 체인 장애 전파 진단 — 직접 확인
"한 곳이 느린데 전체가 멈춘다"가 의심되면, 요청 경로의 동기 호출 체인과 비핵심 작업의 위치를 점검합니다.
점검: 주문 API 한 번에 동기로 무엇이 실행되나?
[결제] ← 사용자 응답에 필수(동기 OK)
[재고차감] ← 보통 필수
[알림발송] ← 비핵심인데 동기? → 알림 지연이 주문을 막음(끊어야 함)
[정산기록] ← 비핵심인데 동기? → 비동기로 뺄 후보
[추천갱신] ← 비핵심 → 비동기
진단 신호(관측성):
- 주문 API p95 지연이 알림 서버 지연과 함께 치솟나? → 동기 결합 증거
- 외부 호출(알림 API) 타임아웃이 주문 실패로 번지나? → 격리 필요
트레이스 분석 예(용어사전) — POST /orders 총 3200ms:
| 구간 | 소요 |
|---|---|
| 결제 | 120ms |
| 재고 | 40ms |
| 알림발송 | 2900ms ← 외부 알림 API 지연이 주문 응답을 지배! |
| 정산 | 140ms |
→ 알림·정산을 이벤트로 빼면 주문 응답 ~300ms로 단축 + 알림 장애 격리
echo '요청 처리 경로의 동기 호출 체인과 외부 의존 확인'- 분산 트레이스에서 한 요청의 단계별 소요를 본다(용어사전) — 비핵심 작업(알림·정산)이 전체 시간을 지배하면 동기 결합 증거. 비동기로 분리 후보
- 외부 서비스(알림·결제사) 호출이 동기 체인에 있으면 그 외부 장애가 우리 핵심 기능을 멈춘다 → 비동기화 또는 서킷브레이커·타임아웃으로 격리
- 비동기로 뺐다면 중복 도착에 멱등 처리가 됐는지 확인 — 같은 이벤트 2번에 결제/알림이 2번 나가면 at-least-once 함정에 빠진 것
- DLQ가 쌓이고 있으면 특정 메시지가 반복 실패 중 → 그 메시지 형식·소비자 버그 조사. DLQ 적체는 "조용히 실패가 누적되는" 위험 신호
상황: 주문 처리에서 알림 발송을 동기로 호출하는데, 외부 알림(SMS/푸시) 게이트웨이가 느려지자 주문 API 응답이 함께 멈추고 타임아웃이 번져 주문 자체가 실패합니다. 정작 결제는 멀쩡한데 알림 때문에 매출이 막힙니다.
원인: 비핵심 작업을 핵심 흐름에 동기로 결합했습니다. 외부 의존(알림 API)의 장애가 호출 체인을 타고 핵심 기능(주문)으로 전파된 것입니다.
진단:
□ 분산 트레이스에서 주문 지연의 대부분이 알림 단계인가? (그렇다면 확정)
□ 알림 호출에 타임아웃·서킷브레이커가 없나? (없으면 무한 대기)
해결: (1) 알림·정산·집계 같은 비핵심 작업을 이벤트로 분리 — 주문은 "주문생성됨" 이벤트만 큐에 발행하고 즉시 응답, 알림 소비자가 비동기로 처리. (2) 부득이 동기로 남길 외부 호출엔 타임아웃 + 서킷브레이커로 장애 격리. (3) 비동기 전환 시 멱등 처리·DLQ·재시도를 함께 설계해 중복·실패에 대비. "무엇이 사용자 응답에 진짜 필요한가"를 기준으로 동기/비동기를 가르는 것이 핵심입니다.
확대
위 그림처럼 CQRS는 명령(쓰기)과 조회(읽기)를 별도 경로로 처리해, 각각의 DB를 목적에 맞게 최적화할 수 있습니다.
심화 — 큐를 넣는 순간 생기는 두 번째 층의 문제들
심화: DB 커밋과 이벤트 발행 사이 — 이중 쓰기 문제와 아웃박스 패턴
"주문을 DB에 저장하고, 주문생성 이벤트를 발행한다" — 이 두 줄 사이에는 멱등·DLQ 다음의 함정이 하나 더 있습니다. 둘은 서로 다른 시스템에 대한 쓰기라 하나의 트랜잭션으로 묶이지 않습니다.
- 이중 쓰기(dual write) 문제: DB 커밋은 성공했는데 브로커 발행 직전에 프로세스가 죽으면 — 주문은 있는데 이벤트가 없습니다(소비자들은 영원히 모름). 순서를 바꿔 발행부터 하면 — 이벤트는 나갔는데 주문이 없는 '유령 이벤트'가 됩니다. 어느 순서든 장애 타이밍에 따라 불일치가 생깁니다.
- 트랜잭셔널 아웃박스(Transactional Outbox): 이벤트를 브로커로 바로 보내지 않고, 같은 DB 트랜잭션 안에서 outbox 테이블에 저장합니다. 별도 릴레이 프로세스(폴링 또는 CDC)가 outbox를 읽어 브로커로 발행합니다. "주문 저장 + 이벤트 기록"이 원자적이 되므로 유실·유령이 사라집니다. 대신 릴레이는 재시도 기반이라 전달이 at-least-once가 됩니다 — 소비자 멱등이 여전히, 그리고 더욱 필수인 이유입니다.
- 규모가 커지면 lag이 문제의 중심이 됩니다: 소비 지연(consumer lag)은 '유입량 > 처리량'이 쌓인 적분값이라, 문제가 시작되고 한참 뒤에야 증상이 보입니다. 그리고 파티션 수가 병렬 소비의 상한입니다 — 파티션이 8개면 소비자를 20개로 늘려도 8개만 일합니다. 파티션 키가 특정 값에 쏠리면(핫 파티션) 전체 처리량은 남아도는데 한 파티션만 몇 시간씩 밀리는 기현상이 납니다.
- 리밸런싱은 공짜가 아닙니다: 소비자 그룹에 소비자가 들고 나면(배포·장애·스케일) 파티션 재배정 동안 그룹 전체의 소비가 잠시 멈춥니다. 소비자가 자주 재시작되는 환경이면 이 정지가 누적돼 lag의 숨은 원인이 됩니다.
상황: 이벤트당 처리가 무거운(외부 API 호출 + 집계) 소비자가 있습니다. 트래픽이 늘자 lag이 급증하는데, 소비자 프로세스는 CPU·메모리 모두 정상입니다. 로그를 보니 같은 메시지를 반복 처리하고 있고, 브로커 쪽에는 컨슈머 그룹 리밸런싱이 몇 분 간격으로 계속 찍힙니다.
원인: 한 번에 가져온 메시지 배치의 처리 시간이 브로커가 허용하는 폴링 간격 상한(max.poll.interval)을 넘었습니다. 브로커는 "이 소비자가 죽었다"고 판단해 그룹에서 제외하고 리밸런싱합니다. 처리 중이던 메시지는 커밋되지 못해 다른 소비자에게 재전달되고, 그 소비자도 같은 이유로 시간을 초과합니다 — 살아 있는 소비자들이 서로를 쫓아내는 리밸런싱 루프입니다. 처리량은 0에 수렴하고 중복 처리만 쌓입니다.
진단:
□ 브로커/컨슈머 로그에 rebalance가 주기적으로 반복되나? (루프 확정 신호)
□ 배치 처리 시간(메시지 수 × 건당 처리시간)이 poll 간격 상한을 넘나?
□ 소비자 수를 늘려도 lag이 줄지 않나? (늘릴수록 리밸런싱만 잦아짐)
해결: (1) 한 번에 가져오는 메시지 수(max.poll.records)를 줄여 배치 처리 시간을 상한 아래로 낮춥니다. (2) 무거운 처리를 소비 루프에서 분리합니다 — 소비자는 가볍게 받아 내부 워커에 넘기고 빠르게 다음 poll로. (3) 중복 재처리의 피해는 멱등 처리가 막고 있어야 합니다 — 이 사고에서 멱등이 없었다면 외부 API 중복 호출 사고가 겹쳤을 것입니다. (4) 재발 방지로 lag과 함께 리밸런싱 횟수에 알람을 겁니다(용어사전) — lag은 결과고, 잦은 리밸런싱이 원인 쪽 신호입니다.
인프라/SRE로서 비동기·이벤트 기반 도입은 큰 운영 책임을 동반합니다 — 메시지 브로커(Kafka 등) 클러스터 이중화·디스크/보존기간 산정·소비자 지연(consumer lag) 모니터링·DLQ 적체 알람을 설계해야 합니다(용어사전). "비동기로 빼자"는 한마디가 새로운 인프라(브로커·모니터링)와 새로운 실패 모드(중복·순서·DLQ)를 가져온다는 것을 의사결정에 올립니다. PM은 동기/비동기 경계를 "사용자 응답에 필수인가"로 판단해, 비핵심 작업의 지연·장애가 핵심 매출 흐름을 막지 않도록 요구사항(요구사항 정의)과 설계를 정렬합니다.
이것으로 Phase 5(아키텍처)를 마칩니다. 다음 Phase에서는 이 모든 것을 떠받치는 품질·관측·운영 문화를 다룹니다.
실전 랩으로 손에 익히기: 동기·비동기 실습 — 동기/비동기를 판단하고 메시지 큐·이벤트 구조를 설계합니다.