System Design
마켓컬리를 설계해보자 - 2️⃣ 배송 준비
1편에서 주문 처리를 다뤘는데, 이번엔 자정 마감 후 새벽 7시까지 배송 완료하는 과정을 설계해봤다.
자정 마감이 되면 가장 먼저 뭘 해야 할까?
확정된 주문들을 배송 가능한 형태로의 가공이 필요하다. 식품을 크게 냉동/냉장/상온으로 구분해야 하고, 지역별로 그룹핑, 배송기사 배분도 필요할 것이다.
온도대별 재고 집계 (냉동/냉장/상온)
→ 지역별 주문 그룹핑
→ 배송 기사 배분
→ 물류 시스템 전달
❓ 그럼 이걸 어떻게 처리할까?
실시간으로 주문이 들어올 때마다 처리하기엔 문제가 꽤 있다.
1. 마감 전까지 취소/변경이 가능한데 이미 만든 배송 그룹을 계속 수정해야 함
2. 주문마다 DB 쿼리 → 수십만 번 DB 접근
3. 확정되지 않은 데이터로 작업하는 것 자체가 불안정
자정 마감 후 확정된 주문을 한꺼번에 배치로 처리하자.
* 마감 후 확정된 주문만 처리 (취소/변경 없음)
* 수십만 건 한꺼번에 조회
* DB 접근 1번 → 네트워크 왕복 1번
* 온도대별/지역별 그룹핑 한 번에 처리
이제 각 단계별로 파보자!
배치 실행 전 검증
자정 마감 후 전체 주문 검증이 필요할 것 같다.
(주문자 정보 검증, 재고 불일치 확인, 중복 주문 확인 등등)
❓ 그런데,, 이미 주문 접수도 자정도 지났는데 재고 불일치가 발견되면 어떻게 처리해야 할까?
언더셀링/오버셀링에 따라 다를 것이다.
케이스 1. Redis ↔ DB 불일치 (언더셀링)
→ DB가 진실의 원천
→ DB 기준으로 재고 복구 후 동기화
케이스 2. DB 재고 < 실제 주문 수 (오버셀링)
→ 주문 순서 역순으로 취소 대상 선정
→ 즉시 알림 발송 (푸시/SMS/이메일)
→ 알림 실패 시 내부 메신저로 에스컬레이션
→ 담당자가 전화로 최종 안내
(아무래도 새벽이라 전화는 어려울지도?)
배치 설계 - 부하 분산
아무래도 수십만 건을 한꺼번에 처리하면 DB 부하가 폭발하니 청크 단위로 처리해야 한다.
수십만 건을 N건씩 끊어서 처리
→ DB 부하 분산
→ 메모리 사용량 제어
→ 청크 실패 시 재처리 범위 최소화
청크 사이즈는 당연히! 이론이 아닌 스테이징 서버에서 부하테스트로 결정한다. (컬리는 스테이징서버가 당연히 있을 거니까 ㅎㅎ)
병렬 처리
❓ 청크로 잘 잘라서 처리하는데도 속도가 너무 느리다면?
지역별로 병렬 워커를 두면 속도를 올릴 수 있지 않을까?
강남 청크 → 워커 A
강북 청크 → 워커 B
강서 청크 → 워커 C
↓
병렬 처리로 속도 향상!
이렇게 처리 속도는 높였는데, 또 다른 할 일이 생겼다.
병렬 작업에서 필수인 중복 처리 방지!
중복 처리 방지
병렬 워커가 같은 주문을 처리하지 않도록 막아야 한다. 아래처럼 워커별 담당 범위를 미리 정해두면 중복 처리가 발생하지 않을 것이다.
- 워커 A: 주문 1~10000번
- 워커 B: 주문 10001~20000번
- 워커 C: 주문 20001~30000번
큐에서 다 넣고 워커가 계속 꺼내가는 방식은 처리 일관성이 떨어지고, DB 락은 서버 부하가 너무 크다.
❓ 워커별 범위를 나눠서 중복 처리도 막았는데, 만약에 워커가 죽는다면?
워커B가 죽으면 10001~20000번 주문자는 오전 7시에 상품을 받지 못할텐데!
장애 감지와 작업 이어받기
먼저, Heartbeat로 워커가 진짜 죽은 건지 느린 건지 구분해야 한다.
1. 워커 생존
→ 주기적으로 Heartbeat 신호 전송
→ Redis에 타임스탬프 업데이트
2. 워커 사망
→ 마지막 Heartbeat로부터 N초 이상 지남 → 장애로 판단
→ 다른 워커가 해당 범위 이어받음
Heartbeat 신호를 기준으로 장애여부를 구분해 다른 워커에게 작업을 위임한다.
이어받을 때는 처리하지 않은 것만 재처리해야 한다.
각 주문마다 처리 상태 저장
PENDING → PROCESSING → COMPLETED → FAILED
↓
재시작 시 COMPLETED 건 스킵
PENDING/FAILED 건만 재처리 😎
Spring Batch를 사용하면 Job Repository가 이 상태를 자동으로 관리해준다.
그런데 만약만약에,,,, N초 이상 지나서 장애로 판단하고 워커 C가 B의 작업을 이어받았는데
❓ 사실은 heartbeat 응답이 느린 거였다면? N+1초 후에 응답이 온다면?!
워커 B → 10초 동안 응답 없음
모니터링: "장애다!" → 워커 C가 이어받기 시작
↓
11초째에 워커 B 살아서 응답 (워커B: 살아있었지롱)
↓
워커 B, C 둘 다 같은 범위 처리 중 💀
→ 중복 처리 발생
이걸 막으려면 워커 C가 이어받을 때 워커 B를 강제 종료시키면 되는데, 강제 종료도 실패할 수 있다. 결국 이것까지 막기 위해선 처리 상태 + 낙관적 락 조합이 필요할 것 같다.
워커 B, C 둘 다 같은 주문 처리 시도
↓
PROCESSING으로 상태 업데이트 시도
→ 먼저 업데이트한 워커만 성공
→ 나머지 워커는 이미 PROCESSING인 걸 보고 스킵 😎
❓ 그럼 Heartbeat 신호는 얼마나 자주 보내야 할까? 그리고 응답 타임아웃은 어떻게 설정해야 할까?
먼저 Heartbeat 신호 주기는 배치 총 처리 시간 대비 결정이 필요하다.
(*주의: 예시임)
배치 총 처리 시간이 2시간이라고 가정할 경우
Heartbeat 간격: 5분
→ 장애 감지 최대 5분 소요 (전체 시간의 4%)
→ 부하도 감당 가능한 수준
응답을 기다리는 타임아웃은 아래처럼 지정할 것 같다.
Heartbeat 간격: 5분
타임아웃: 3분
→ 3분 안에 응답 없으면 해당 신호 실패로 판단
→ N회 연속 실패하면 장애로 판단
타임아웃 > Heartbeat 간격이면
→ 다음 신호가 먼저 와버려서 타임아웃이 의미 없어짐 💀
DB 분리 - 장애 격리
❓ 작업을 이어받을 때 Spring Batch Job Repository가 상태를 자동으로 관리해준다고 했는데, 그럼 Job Repository에 저장이 안되거나 이 데이터가 날아간다면?
배치 실패 → 재시작
↓
Job Repository에서 마지막 완료 지점 확인 필요
↓
근데 Job Repository가 날아갔으면? 💀
→ 어디서부터 재시작해야 할지 모름
→ 처음부터 전체 재실행 위험
그래서 Job Repository DB를 메인 DB와 분리하기로 했다.
같은 DB 쓰면
→ 메인 DB 장애 = 배치 상태도 날아감 💀
→ 어디서부터 재시작해야 할지 모름
DB 분리하면
→ 메인 DB 장애나도 배치 상태 보존
→ 재시작 시 Job Repository에서 마지막 완료 지점 확인
→ 거기서부터 이어서 실행
근데 이러면 두 DB가 다른 트랜잭션이라 정합성 문제가 생긴다.
주문 처리 성공
↓
Job Repository 저장 실패 💀
↓
재시작 시 이미 처리한 주문 또 처리 위험
여기선 아웃박스 패턴을 사용할 수 있을 것 같다.
주문 처리 + 아웃박스 PENDING 저장 (같은 트랜잭션!)
↓
Job Repository 저장 성공 → 아웃박스 COMPLETED
Job Repository 저장 실패 → 아웃박스 PENDING 유지
↓
재시작 시 아웃박스 PENDING 건 확인
→ Job Repository에 없는 건 재처리
→ COMPLETED 건 스킵 😎
여기까지의 설계 플로우를 정리해보면 아래처럼 된다.
배송 준비 설계 끝! 다음번엔 배송 처리 및 추적을 설계해야지 🎶