우아콘 2023 - 대규모 트랜잭션을 처리하는 배민 주문시스템 규모에 따른 진화
강홍구: 푸드주문서버개발팀
배민 주문시스템
- 장바구니 / 주문하기 / 주문내역 쪽 BE 담당.
- 일반적인 커머스와는 달리, 점심 / 저녁에 트래픽 폭증하는 구조.
가게, 메뉴, 주문, 결제, 배달 등 다양한 서비스의 결합으로 이루어져 있음.
- 한쪽의 장애가 다른 쪽으로 전파되지 않는 '느슨한 결합'이 중요하다.
일평균 300만건의 주문 + 수년간의 데이터 저장 / 관리.
- 방대한 데이터 저장, 조회 성능 필요
순간적으로 몰리는 대규모 트랜잭션의 안정적인 처리방법
MSA와 '느슨한 결합' 구조를 위해 이벤트 기반 통신
- 이벤트 유실 시 재소비 방법
- 이벤트 흐름을 가시적으로 확인할 수 있는 방법
성장하는 주문 시스템
2018년만 해도 일 100만 건이 안 됐음. 지금은 일 300만 건.
- 특정 이벤트 (월드컵, 아시안게임 등) 에는 더 많은 주문 발생.
마주했던 문제들
- SPOF (single point of failture): 하나의 서비스 장애가 시스템 전체로 이어지는 구조
- 대용량 데이터의 조회 성능이 낮아짐.
- 대규모 트랜잭션으로 장비의 Write/min 한계치 도달.
- 복잡한 이벤트 아키텍처 - 무분별한 이벤트 발행, 이벤트 유실 대응, 시스템 복잡도 증가.
SPOF
최초에는 중앙집중형 저장소가 있었고, 모든 시스템이 DB에 의존.
- 시스템 하나에 장애 발생하면 중앙DB에 부하 -> 모든 시스템에 영향이 가는 구조.
중앙 저장소와의 의존도를 낮추도록 각 도메인별로 저장소 관리하도록 설계.
- 시스템 간 통신은 Message Queue 기반.
- 특정 시스템의 장애는 '메시지 발행 실패'로 끝난다.
- 시스템 복구되면 이벤트 재발행 + 재소비 가능함.
대용량 데이터 조회 성능 저하
주문 시스템 구조를 들여다보면
- 주문 API: 사용자의 요청 받아서, RDBMS인 주문 DB에 저장. 주문 도메인 이벤트를 MQ에 발행
- 주문 internal API: 주문내역, 기타 주문정보가 필요한 서비스에 데이터 제공하는 API
- 주문 이벤트 처리기
- 도메인 로직과는 무관하지만 주문이벤트 받아서 처리해야 하는 것들 수행.
주문 DB에서 '저장과 조회가 함께 발생하는' 구조.
주문내역에는 주문정보, 메뉴정보, 결제정보, 배달정보 / 가게정보 등 서로 다른 도메인에서 가져와야 하는 데이터가 많다.
- Order라는 Entity 기반으로 join해서 데이터를 가져오는 방식
- 정규화 보장하는 DB에서는 데이터 양이 많아질수록 join 성능이 떨어지기 시작함
역정규화를 통해 해결 시도
- Order라는 root entity에서 join으로 조회해오지 말고, 필요한 필드를 다 찾아서 하나의 Document로 저장 + 조회하자.
- MongoDB + 단일 Document 방식으로 변경. -> document id 기반 조회로 성능 개선.
동기화 방법?
주문시스템은 '생성 - 접수 - 배달완료 or 주문취소'라는 lifecycle이 있다.
- 이 lifecycle 안에서만 도메인 변경이 발생한다.
- 즉 RDBMS 테이블의 변경은 도메인 내에서만 발생한다.
- 도메인 변경이 발생할 때마다 이벤트를 발행한다.
- 발행한 이벤트는 '주문 이벤트 처리기' 에서 받은 뒤 MongoDB에 동기화하는 식으로 구현.
CQRS 아키텍처 적용함.
- '주문 요청'이 왔을 경우 주문 데이터는 정규화를 보장하는 RDBMS에 저장
- '조회 요청'이 왔을 경우 MongoDB에서 주문데이터를 빠르게 조회
대규모 트랜잭션
- 대규모 Read 요청은 Read Replica를 늘려서 scale out으로 대응
- Write 요청은 Primary DB의 scale up으로 대응해왔음.
- 서비스 사용량이 증가하면서, AWS에서 제공하는 최고 스펙 장비로도 분당 쓰기처리량에 한계치 도달.
DB 샤딩을 하려 했는데, AWS Aurora는 샤딩을 지원 안 하고 있었음.
그래서 애플리케이션 샤딩을 구현하기로 함. 두 가지 문제를 해결해야 했는데
- 어떤 샤드에 데이터를 저장할지 결정하는 샤딩 전략
- 여러 샤드에 있는 데이터를 어떻게 aggregate해서 전달할 것인지
샤딩 방법?
- Key based: 특정 key값을 기준으로
- Range based: 특정 값의 range 기준으로
- Directory based: lookup table 기준으로
주문번호를 샤드 키로 설정. hash 돌려서 나온 값을 shard key로 사용한다면?
- 균등하게 데이터 분배가 가능하지만
- 해시함수가 바뀌면 / 장비를 동적으로 추가, 제거할 때마다 전부 재배치 필요.
가격 기반 샤딩?
- 구현이 쉬우나
- 데이터가 균등하게 분배되지 않음 (hotspot 현상)
별도의 lookup table 사용?
- 샤드 결정 로직이 분리되어 있으므로, 동적으로 샤딩 추가하기 쉬움
- lookup table 조회 실패하면 장애 발생
주문시스템의 특징을 생각해보면
- 주문은 핵심 기능. 정상 동작하지 않으면 사용자에게 좋지 않은 경험을 제공하게 된다.
- 동적 주문 데이터는 최대 30일만 저장한다.
- 주문 - 배달 - 완료로 이어지는 과정은 보통 하루 안에 완결되므로, 데이터가 완결되는 시간은 하루 이내.
따라서
- 단일 장애 포인트는 최대한 피한다.
- 샤드 추가 이후 30일이 지나면, 데이터는 다시 균등하게 분배한다.
조건을 만족하는 샤딩 기능으로 key based 방식을 사용함.
주문번호를 통해 주문 순번을 알 수 있도록 설계해뒀음. 따라서 주문번호로 주문 순번을 알 수 있다.
- 주문 순번 % 샤드 수 = 샤드 번호
- 주문 순번에 따라 고르게 데이터를 샤드 DB에 저장할 수 있음.
AOP와 AbstractRoutingDataSource로 구현.
다건 조회 + Aggregate 로직
여러 개의 샤드에 분산저장된 데이터를 어떻게 조합해서 응답해야 하나?
- 대용량 데이터 조회를 위해 구축한 MongoDB 사용.
- 저장 / 조회가 분리되어 있었기 때문에 샤딩 적용해도 큰 무리가 없었음.
따라서 write는 샤딩된 DB cluster를 scale out하면 해결됨. read는 별도의 DB에서 처리되므로 고민할 필요가 없다.
복잡한 이벤트 아키텍처
이벤트 기반으로 관심사가 분리되어 있음. 도메인 로직 / 외부 서비스 로직은 격리되어 있고, 이벤트로 통신한다.
- 도메인 로직이 수행되면, 대응되는 이벤트 - 생성, 접수, 완료, 취소 등 - 를 spring event로 호출한다
- 각 이벤트 타입에 맞는 post processor (i.e. OrderOpenPostProcessService...) 가 sqs로 발행.
모든 주문시스템 애플리케이션은 order-event-module이라는 의존성을 가지고 있음.
- 결국 이벤트를 보내는 주체는 모듈을 import한 '애플리케이션'. "알림 전송" 이라는 로직을 파악하려면
- 주문 생성 쪽 이벤트를 보려면 주문 API
- 주문 취소 쪽 이벤트를 보려면 주문 배치
- 현금영수증 신청은 이벤츠 처리기
- 현금영수증 취소는 주문 배치
- ...
- 매번 애플리케이션에 import받은 모듈을 구현해야 하고, 어느 이벤트는 어느 애플리케이션에서 발행하는지 파악해야 하는 복잡도가 올라감
메시지 보내는 sqs (spring event)에 문제가 생기면, 이벤트 재처리가 어려움
내부 / 외부 이벤트 정의.
- '주문' lifecycle 안에서 처리되는 도메인 이벤트는 "내부 이벤트" -> SQS로.
- SQS에서 이벤트 받아서 외부로 이벤트 발행하는 서비스 로직을 '외부 이벤트'로 정의
내부 이벤트는 Zero Payload 전략 사용
- 내부 이벤트에는 서비스 로직을 넣지 못하게끔. payload가 있으면 서비스로직과의 결합도가 생길 수 있기 때문.
- 내부 이벤트가 이벤트 처리기로 전달되었을 때, 필요한 payload를 DB에서 조회해서 추가한 뒤 외부에 전달하는 식.
- 필요한 데이터를 그때그때 채워주는 식.
- 조회에 쓰는 NoSQL 서버 부하가 크지 않아서 가능함.
도메인 로직을 처리하면 도메인 이벤트만 발행하고, 서비스에 필요한 로직을 '이벤트 처리기'로 몰아넣은 구조.
- 물론 이러면 SQS로 이벤트 전달하는 과정에서의 네트워크 비용이 있음.
- 서비스 로직을 단일화해서 복잡도를 낮추는 게 더 중요한 과제라고 생각했다.
이벤트 발행 실패 유형
- Transaction 안에서 이벤트 발행 실패: 도메인 로직 전체가 실패처리되므로 일관성 확보
- Transaction 밖에서 이벤트 발행 실패: 도메인은 성공했는데 이벤트 발행이 실패 => 재발행도 어렵고, 일관성을 보장하기 어렵다
Transaction Outbox Pattern 적용해서 해결.
- 이벤트 유실이 발생하면, outbox table 확인해서 재발행.
Summary
- 단일 장애포인트: MSA 적용 + 이벤트 기반 통신으로 결합도 낮춰서 해결
- 대규모 데이터 조회성능 저하: CQRS 패턴 적용
- 대규모 트랜잭션: 애플리케이션 레벨에서의 샤딩
- 이벤트 구조 개선: 도메인 로직 / 서비스 로직 분리하고, 외부 이벤트 발행에 관련된 건 전부 서비스 로직으로 위임. Transactional Outbox Pattern으로 이벤트 재발행이 쉬운 구조 확보