※ 영상을 보며 아래 노션 페이지에 내용을 정리하였고, 포스트에는 노션을 한 번 더 요약한 내용을 담았습니다.
내용 요약
배달의민족은 2018년 일 평균 50만건의 주문이 발생했지만 2023년 현재는 일 평균 300만건의 주문이 발생한다. 이러한 빠른 성장 속에서 겪었던 여러가지 성장통들을 소개한다.
문제상황 1. 단일 장애 포인트
과거 배민은 중앙 집중 DB(Ruby)를 사용했었다. 따라서 여러 시스템들 중 하나에서 장애가 발생하면 이는 루비의 부하로 이어졌고, 결과적으로 중앙 DB를 바라보는 다른 모든 시스템에서 장애가 발생하는 문제점이 있었다.
해결
이를 해결하기 위해 중앙 저장소에서 각 시스템을 분리하는 '탈루비 프로젝트'를 진행하였다.
MSA 구조를 채택하여 각 시스템마다 DB를 모두 따로 두며 점차 루비와의 결합을 느슨하게 하였다.
MSA 구조가 안정화된 이후에는 루비를 완전히 없애고, 시스템 간 통신은 Message Queue 기반으로 통신하도록 만들었다.
효과
특정 시스템에 장애가 발생할 경우 과거에는 중앙 저장소의 부하로 이어져 전체 시스템이 피해를 보았지만, MSA를 적용하고 난 이후에는 단순히 해당 시스템의 메시지 발행이 실패하는 것으로 끝난다.
문제상황 2. 대용량 데이터
주문내역 하나를 유저에게 보여주기 위해서는 정말 다양한 정보들이 필요하다. (주문정보, 메뉴정보, 결제정보, 배달정보, 가게정보 등)
정규화된 애그리거트는 조회 시 조인 연산을 필요로 하게 했고, 성능 저하로 이어졌다.
해결
조회 성능을 높이기 위해 루트 엔티티(Order) 기반으로 역정규화하여 하나의 단일 테이블로 유지하기로 했다.
동기화 문제는 CQRS를 적용하여 해결했다.
- Command(초록색) : 정규화된 RDBMS에 저장하여 데이터 정합성을 보장한다.
- Query(빨간색) : 역정규화된 MongoDB를 사용하여 조회 성능을 개선하였다.
RDBMS와 MongoDB간 동기화 유지 방법
- 주문 도메인 로직이 변경될 때마다 MQ에 도메인 이벤트를 발행한다.
- 도메인 이벤트가 발행되면 '주문 이벤트 처리기'에서 MongoDB에 데이터를 실시간으로 동기화한다.
CQRS (Command and Query Responsibility Segregation)
- 데이터 저장소로부터의 읽기와 업데이트 작업을 분리하는 패턴
- 명령(Command)을 통해 데이터를 쓰고, 쿼리(Query)를 통해 데이터를 읽는다.
- 명령(Command)은 보통 동기적으로 처리되기보단, 비동기적으로 큐에 쌓인 후 수행된다.
- 쿼리(Query)는 결코 DB를 수정하지 않는다.
효과
단순히 단일 도큐먼트에서 id 기반의 조회만으로 주문 정보를 모두 가져올 수 있게 되었다.
문제상황 3. 규모 트랜잭션
주문 DB의 HA 구성
- 쓰기 요청 : 하나의 primary 장비를 통해 쓰기 요청을 받는다.
- 조회 요청 : n개의 레플리카 장비를 통해 실시간성 조회를 받아낸다.
조회 요청이 증가하면 레플리카 장비를 스케일 아웃하여 대응할 수 있었다.
그에 반해 쓰기 요청이 증가하면 스케일 업으로밖에 대응할 수 없었고, AWS에서 제공해주는 최고 스펙 장비로도 처리할 수 없는 수준까지 오게 되었다.
해결
HA 구성을 똑같이 n번 복제(샤딩)하여 쓰기 요청도 스케일 아웃을 통해 분산처리를 하기로 한다.
하지만 AWS 오로라 장비는 샤딩을 지원하지 않는 엔진이었기 때문에 코드로 샤딩을 구현하였다.
※ 코드로 샤딩을 구현하는 과정은 노션에서 확인
주문 내역을 보여줄 때 각 주문 정보가 n개의 샤드에 분산 저장되어 있기 때문에 다건 조회가 필요했다.
하지만 앞서 DB 저장과 조회 로직을 분리해 둔 덕분에 성능적으로 큰 문제없이 수월하게 샤딩을 적용할 수 있었다.
샤딩 적용 후 아키텍처
문제상황 4. 복잡한 이벤트 아키텍처
문제 1. 서비스 로직을 수행 및 이벤트 발행의 주체를 파악하기 어려웠다.
기존에는 주문 서비스 레이어에서 도메인 로직을 수행한 후 스프링 이벤트를 발행한다. 이 이벤트를 구독하고 있는 각각의 post process 서비스 레이어들이 서비스 로직을 수행했었다.
위 사진을 보면 주문 관련 서비스 로직, 현금영수증 관련 서비스 로직이 여러 다른 어플리케이션에서 수행되는 것을 볼 수 있다. 이는 특정 이벤트가 어떤 서버에서부터 비롯되었는지 파악하는 데에 어려움을 주었다.
기능을 추가할 때도, 특정 이벤트를 처리하는 서버를 놓치게 되어, 그 서버에서만 서비스 로직이 수행되지 않는 문제도 발생했다.
문제 2. 이벤트 유실이 발생할 경우 재처리가 어려웠다.
SQS에 문제가 발생할 경우, 이벤트를 재발행할 수 있는 수단이 없었다.
해결
문제 1의 해결법 : 내부/외부 이벤트 정리
- 내부 이벤트 : 주문 도메인 이벤트
- 외부 이벤트 : 서비스 로직을 수행하는 부분
주문 라이프사이클에서 처리되는 도메인 로직들은 sqs를 태우고, sqs를 구독하는 이벤트 처리기가 도메인 이벤트를 수신해서 외부 이벤트를 발행하는 구조로 설계하였다.
내부 이벤트는 서비스 로직을 싣기 어렵게 하기 위해 ZERO-PAYLOAD 전략을 사용하였다. 내부 이벤트는 단순히 도메인 로직을 수행한 결과가 무엇인지만 전달하게 하였다. 그 도메인 이벤트를 수신한 이벤트 처리기가 서비스 로직을 처리하기 위해 필요한 데이터를 그때 그때 채워주는 방식으로 설계하였다.
서비스 로직 처리는 이벤트 처리기로 몰아주었다. 기존에는 어떤 어플리케이션이 어떤 서비스 로직을 수행하는지 헷갈렸었는데, 어플리케이션들은 단순히 도메인 이벤트만 발행하게끔 수정하고, 그 도메인 이벤트를 이벤트 처리기가 수신해서 모든 서비스 로직을 수행하게끔하였다.
요약하자면..
[수정 전]
주문 도메인 로직 수행
→ 스프링 이벤트 발행
→ 이를 구독하고 있는 포스트 프로세스 서비스 레이어들이 각각 서비스 로직 수행
→ 서비스 로직 수행 후 각각 이벤트 발행
---- (여기까지 하나의 어플리케이션 내부에서 일어나는 일) ----
→ 이를 구독하고 있는 외부 시스템들이 이벤트 수신
[수정 후]
주문 도메인 로직 수행
---- (여기까지 하나의 어플리케이션 내부에서 일어나는 일) ----
→ SQS에 이벤트 발행 (내부 이벤트, ZERO-PAYLOAD : 도메인 로직 수행 결과만 알려줌)
→ 이를 구독하고 있는 단일화된 이벤트 처리기가 서비스 로직 수행
→ 서비스 로직 수행 후 각각 이벤트 발행
→ 이를 구독하고 있는 외부 시스템들이 이벤트 수신
크게 보자면
'주문 도메인 로직 수행' → '이벤트 발행' → '서비스 로직 수행' → '이벤트 발행'
이 구조인데,
과거에는 서비스 로직을 여러 다양한 서비스 레이어에서 수행했었지만 지금은 '이벤트 처리기'라는 단일화된 어플리케이션이 SQS에서 모인 이벤트들을 단독으로 수신받아 서비스 처리하는 것과 여러 외부 시스템들로 이벤트 뿌려주는 것까지 혼자서 다 한다
라고 이해했는데 맞는지는 잘 모르겠음
문제 2의 해결법 : 트랜잭션 아웃박스 패턴 적용
- 트랜잭션 내부에서 이벤트 발행 실패할 경우 : 도메인 로직, 서비스 로직 자체가 실패되기 떄문에 큰 이슈가 없다. - 일관성이 유지돼서
- 트랜잭션 밖에서 이벤트 발행이 실패될 경우 : 도메인 로직은 성공했지만 서비스 로직은 실패하면서 서비스의 일관성을 해칠 수 있다.
OUTBOX 엔티티는 이벤트 발행에 페이로드를 전달하는 테이블이다.
서비스 로직을 수행한 후, OUTBOX 테이블에 페이로드를 저장하고 트랜잭션을 커밋한다. 그 후에 이벤트를 발행한다.
트랜잭션 밖에서 이벤트 발행이 실패할 경우, 트랜잭션 내부에서 OUTBOX 엔티티에 페이로드를 저장한 후 commit 하였기 때문에 추후에 이벤트 재발행이 가능해진다.
Outbox 엔티티가 적용한 이벤트 스냅샷을 통해 '주문 Batch'에서 이벤트를 재발행한다. 특정 키를 통해서, 혹은 특정 기간을 통해서 이벤트 재발행이 일어나며, 이벤트는 중복 발행은 될 수 있지만 유실은 되지 않게끔 설계하였다.
최종 아키텍처
새롭게 알게 된 점 / 느낀 점
- 5년 전만 해도 배민이 모놀리식 구조였다니..!
- 현업에서 MSA에서의 시스템 간 통신을 MQ로 하는 게 일반적인지 궁금했었는데 배민의 사례를 보니 그런 듯 하다.
- 애그리거트(aggregate)라는 용어를 알게 됐다.
- CQRS 아키텍처를 알게 됐다. 데이터 조회 성능을 높이기 위해 NoSQL 서버를, 데이터 정합성을 보장하기 위해 RDBMS를 각각 두고 이 둘을 동기화한다는 것을 배웠다.
- DB의 HA를 위해 데이터 쓰기 요청은 primary DB에서, 조회 요청은 primary DB의 replica에서 처리한다는 사실을 알게 됐다.
- 쓰기 요청을 받는 primary DB는 스케일 아웃이 불가능하다는 단점을 해결하게 위해 샤딩(Sharding)을 적용할 수 있다는 것을 배웠다.
- Amazon에서 SQS라는 비동기 메시지 큐 서비스를 제공하고 있다는 사실을 알게 됐다.
- '복잡한 이벤트 아키텍처' 파트는 사실 아키텍처가 머릿속에 잘 그려지지 않아 명확히 이해하진 못했는데 추가적인 공부가 필요할 것 같다.
- DB 상태를 변경하는 트랜잭션과 함께 이벤트를 발행해야할 경우, 이 둘을 같은 트랜잭션에 묶지 않으면 문제가 발생할 수 있다는 사실을 알게 됐다. 이벤트가 유실될 경우 재발행할 수 있는 수단이 없기 때문인데, 이 문제를 '트랜잭션 아웃박스 패턴'으로 해결할 수 있다는 사실도 알게 됐다.
'IT 일상 > 세미나 및 컨퍼런스' 카테고리의 다른 글
[우아콘2023] Kafka Streams를 활용한 이벤트 스트림 처리 삽질기 (1) | 2024.01.26 |
---|---|
[우아콘2023] Kafka를 활용한 이벤트 기반 아키텍처 구축 (0) | 2024.01.21 |
AWS Summit Korea 2022 (22.05.10 - 22.05.11) (0) | 2022.05.11 |
디지털 대전환 엑스포 (21.11.27) (0) | 2021.12.15 |
취업특강 '취업에 성공하는 면접요령(사회초년생)' (21.09.15) (0) | 2021.09.15 |