명령(Write)과 조회(Read)의 책임을 분리한다.

왜 써야하지?
제 경험으로는 장애가 한번 발생 했었는데 알람이 엄청나게 왔었습니다.
상황은 어디에선가 엄청나게 호출을 하고 있었고 로그를 보니 사내에서 제공하는 Spark와 Hadoop을 지원하는 곳에서 호출
을 하고 있었습니다.
즉, 읽기 트래픽이 폭증하면서 Akka와 HBase RegionServer의 리소스(CPU, IO, 네트워크, 쓰레드, GC)가 읽기 때문에 많이 사용하고 있었고 그 결과 쓰기 요청들이 타임아웃/에러로 이어진 상황 이였습니다.
하필 뉴스 컬렉션이라서..민감한 부분이였습니다. ㅠㅠ
한가지 문제로 인해 쓰기가 문제가 된것은 아닐것 입니다.
Akka 자원 경쟁
HBase RegionServer 자원 경쟁
Data size도 커서 네트워크 문제 발생
GC STW 발생 문제
등등
우선적인 조치로는 정확히 어디에서 누가 사용하는지는 알수가 없어서 저희쪽에서는 알수가 없어서
사내에서 제공하는 서비스쪽에 요청을 하여 해당 Job 우선 막아달라고 요청을 하였습니다.
사내에서 제공하는 api라서 보통은 요청을 하고 사용을 하는데 몰래 사용을 하다 일어난 장애였습니다.
후속조치로 CQRS 패턴, Circuit Breaker를 적용하면 좋을것 같다는 생각을 했습니다.
이런 장애에 대한 영향도를 낮추기 위해서는
RegionServer Scale Out : RegionServer를 늘리면 Region들이 분산 되고 읽기/쓰기 부하를 감소 시킬 수 있습니다.
Gateway에서 Rate limit : 읽기 요청이 무제한이면 안되겠죠? API앞단에 Gateway를 둬서 throttling(조절)을 합니다.
Circuit Breaker : API단에서 HBase(RPC)를 호출할 때, 응답이 느려지거나 에러율이 올라가면 호출을 차단해서
HBase 클러스터로의 부하와 API 리소스 고갈을 방지합니다.
Cache : 캐시는 HBase 부하를 줄이는 데 효과적이지만, 객체 크기가 크면 메모리를 많이 잡아먹어 OOM 위험이 있습니다.
그래서 대용량 payload 전체보다는, 자주 쓰는 메타데이터나 요약 정보 위주로 캐싱하거나
max 메모리/eviction 정책을 꼭 함께 설계해야 합니다.
Token 사용 : 데이터를 public이 아닌 private하게 데이터를 Token을 발급하여 제어 합니다.
token을 가지고 있는 유관부서만 협의하에 api 사용 가능
CQRS 패턴 : 읽기와 쓰기를 분리! 이제 어떻게 이 패턴을 적용할 지 고려해보겠습니다.
우선 아래처럼 하나의 데이터베이스를 공유하는 CRUD형태가 있다고 하면
읽기와 쓰기가 서로 의존하며 동작하는 구조

이런 CRUD형태에서 command와 query부분 그리고 공통인 부분들을 나눕니다.
나눌 때는 기준을 잡아야할텐데
DB의 상태를 바꾸는 INSERT, UPDATE, DELETE 로직은 command쪽
DB에서 조회(READ)를 하는 로직이면 query쪽으로 나누고
처음부터 디비를 나누기보다 코드레벨에서의 논리적분리부터 진행 합니다.

그리고나서 아래처럼 도메인별로 나눕니다.
즉, 물리적으로 분리 시킵니다.
아래처럼 도메인별로 읽기와 쓰기가 나뉘어지게 되는게 CQRS의 지향점입니다.

이제 위처럼 도메인별로 물리적으로 나누기 위해 우선 교집합부분을 모델로 분리합니다.

공통된 부분을 나눌때 특히 조회쪽은 원하는것을 조회하기 위해 아래 그림처럼 조회 모델 생성을 하는곳이 필요 합니다.
이 조회 모델 생성 부분에서는 Entity나 DTO등으로 생성이 가능하고 DTO로 원하는것만 넣고 데이터를 가공 합니다.
이때 DB조회를 통해 DTO에 담아서 데이터를 가공하고 하는 구조라 부하가 발생 합니다.

그래서 일반적인 CRUD에서는 조회 모델 생성과 조회 모델 사이에 보통 Cache를 두게 됩니다.

하지만 CQRS에서는 조회 모델쪽의 저장소를 Redis, Elasticsearch, MongoDB등을 사용 합니다.
또한 명령 도메인에서 발생하는 Write 이벤트를 조회 도메인에서도 정학성을 위해 업데이트를 해야하기 때문에
아래와 같이 이벤트소싱 패턴을 사용하여 성능적 이슈를 해결 합니다.
어플리케이션A에서 변경 이벤트가 발생하면 Event Store에 넣고 어플리케이션 C에서 구독을(Consumer) 하고 있고
이벤트 발생 시 Read Model의 저장소에 저장합니다.

조금 더 자세하게 풀어보면 컨슈머로 조회 모델(json)을 생성,저장하고 api서버에서는 조회를 합니다.
그리고 위의 그림에서는 이벤트 발생 시 단건씩으로 Event Store로 넣습니다.

이벤트를 단건으로 처리하는건 비효율적이라 이벤트 로깅 후 redis를 버퍼로 사용해서 스케줄러를 통해
조회 모델을 벌크로 생성 및 저장하는 구조이며 정합성을 위해 매 시간마다 Full Batch를 돌려준다고 합니다.

조금 오래됐지만 많은 생각을 하게 만든 영상이였습니다. (우아콘 2021)
마지막으로 CDC의 사용과 kafka Connect를 검토한다고 말하며 영상이 끝납니다.

기억해야할 3가지는 이벤트감지, 이벤트소싱, 이벤트처리로 구성 그리고 버퍼를 둬서 처리하는 방법!
앞으로 해야할 것은? kafka , store(redis, elasticsearch, mongodb) 조금 더 deeeeep~하게 알기:)
출처 : 우아콘2021 https://www.youtube.com/watch?v=fg5xbs59Lro
https://www.youtube.com/watch?v=Yd7TXUdcaUQ
그런데 말입니다.

아래의 그림에서 Read 모델부분..에 대해서 생각해보면
읽기가 폭증한다는건 api서버를 통해서 읽기가 많이 들어오는 상황이라고 하면
정합성을 위해 event 발생 시 컨슈머를 통해서 업데이트를 시켜줘야하는데
read가 많아지면 write역시 지연이 발생할 텐데..같은 문제가 발생하는것이 아닌가??

우선 이벤트 소싱의 정합성은 Eventual Consistency 라는 특성이 있습니다.
Write Model → Event → Consumer → Read Model 업데이트
Read Model은 그래서 조회 최적화 DB를 사용해서 읽기와 쓰기가 거의 영향을 주고 받지 않습니다.
예를 들면 검색엔진인 Elasticsearch라고 하면 색인 기반 구조라서 검색 성능이 매우 빠르고
이벤트가 발생하면 컨슈머에서 동적색인으로 색인을 업데이트 합니다.
또는 MongoDB 클러스터에서
Consumer : event를 읽어서 Mongo에 update or upsert
Api Server : Mongo에서 find, aggregate 미친듯이 날림
당연히 CPU, IO, Lock, Connect pool 자원을 두고 싸움 → Consumer 쓰기 지연 발생
Mongo 복제 구조(Replica Set) 활용
- Primary : 쓰기+중요한 읽기(컨슈머 쓰기, 일부 api)
- Secondary : 읽기 전용 Api 트래픽 → readPreference=secondary
또는 Redis같은 캐시 레이어를 두고 miss위주로만 처리 합니다.
다음으로는 DB 내부의 read/write 충돌을 줄이는 MVCC에 대해서 알아보겠습니다:)
'역량 UP! > Architecture' 카테고리의 다른 글
| Backoff Retry와 DLQ(Dead Letter Queue) 차이? 사용법? (2) | 2025.11.11 |
|---|---|
| 동시성(Concurrency) 모델&스케줄링 모델(feat. ForkJoinPool) (0) | 2025.11.04 |
| Blocking&non-Blocking 그리고 Sync&Async 이해하기! (0) | 2025.11.03 |
| 대용량 데이터 처리 시 고려사항 (0) | 2025.10.26 |
| Saga Pattern(outbox pattern) 좀 더 보기! (0) | 2025.10.24 |