pile·
백엔드·네이버페이네이버페이·

MongoDB CQRS 성능 개선기: 예상치 못한 Tomcat NDJSON 병목 해결

네이버페이 정산 시스템이 대형 가맹점 조회 타임아웃을 풀려고 CQRS 패턴 기반 MongoDB를 도입한 뒤 겪은 병목과 해결 과정을 다룬다. API 조회는 샤딩 DB 대비 40배 빨라졌지만 엑셀 다운로드 성능은 그대로였다. 원인은 MongoDB도 엑셀 라이브러리도 아닌, Tomcat이 Flux 리턴 타입으로 NDJSON을 한 줄씩 write·flush하며 매번 blocking하는 구조였다. 1000건씩 모아 한 번에 flush하는 버퍼링으로 해결했다.

핵심 포인트
  • 가맹점 기반 샤딩 DB가 상위 대형 가맹점에 데이터가 쏠려(1위 2퍼센트, 상위 10위 합산 7퍼센트) 조회 시 타임아웃이 잦았고, 이를 풀려고 CQRS로 MongoDB에 가공 데이터를 미리 저장했다.
  • CQRS 도입으로 join 없는 API 조회는 샤딩 DB 대비 40배 빨라졌지만, 엑셀 다운로드 속도는 전혀 개선되지 않았다.
  • MongoDB 튜닝(readConcern·projection)과 엑셀 라이브러리 교체(POI SXSSF에서 FastExcel)로도 엑셀 생성 병목이 안 풀렸다.
  • 진짜 원인은 Spring MVC(Tomcat)에서 Flux 리턴 타입으로 NDJSON을 스트리밍할 때 한 줄마다 write·flush가 blocking되는 구조였다.
  • Flux를 리턴하지 않고 HttpServletResponse에 1000건씩 모아 write 후 한 번 flush하는 확장 함수로 해결했다.
상세 정리
  • 배경: 정산 시스템은 성능·스케일아웃을 노리고 가맹점 기반 샤딩 DB를 썼으나, 대형 가맹점 데이터 쏠림으로 샤딩 불균형이 생겨 조회마다 타임아웃이 났다.
  • CQRS 설계: MongoDB에 미리 가공된 데이터를 저장해 join 없이 빠르게 읽는 조회 전용 모델을 구축했다.
  • 절반의 성공: API 조회 성능은 기존 샤딩 DB 대비 40배 이상 향상됐지만, 엑셀 다운로드는 개선 효과가 없었다.
  • 구간별 측정: 병목 위치를 좁히려고 MongoDB 조회, 엑셀 생성, 네트워크, Spring web 구간을 나눠 각각 성능 테스트했다.
  • MongoDB 조회: readConcern 레벨 조정과 projection으로 필요한 필드만 조회해 쿼리 성능은 10퍼센트 내외 개선됐지만 엑셀 생성 시간에는 영향이 없었다.
  • 엑셀 라이브러리: POI SXSSF, EasyExcel, FastExcel을 벤치마킹해 FastExcel이 파일 생성 2배 빨랐지만, 전체 다운로드 병목은 여전했다.
  • 네트워크: 결과 파일과 비슷한 용량 전송 테스트에서 8만 건이 1초 내외로 끝나 네트워크는 병목이 아니었다.
  • 결정적 단서: 같은 데이터를 JSON 배열로 응답하면 37.7MB/s, NDJSON으로 응답하면 3.7MB/s로 10배 차이가 났다.
  • NDJSON 구조: 각 줄이 JSON 객체 하나이고 개행으로 구분되는 포맷이라, Flux 아이템을 건별로 직렬화 후 write·flush하고 개행도 다시 write·flush한다.
  • blocking 원리: Tomcat이 write 완료까지 blocking하므로 50만 줄이면 개행 포함 약 100만 번 blocking이 발생한다(ResponseBodyEmitter의 JsonEmitterSubscriber가 item별 처리).
  • JSON 배열은 하나로 합쳐 write·flush를 한 번만 해 I/O blocking 시간이 절약돼 NDJSON보다 10배 빨랐다.
  • 런타임 비교: 같은 스트리밍을 non-blocking Netty에서 돌리자 Tomcat 3~4MB/s 대비 19~20MB/s로 약 7배 빨랐지만, 한 기능 때문에 Netty 전환은 변경 범위가 커 포기했다.
  • 해결: Flux의 writeBufferedStreamResponse 확장 함수로 buffer(1000) 후 각 배치를 writer로 쓰고 한 번 flush, blockLast로 마무리해 write·flush 횟수를 1/1000로 줄였다.
  • 결과: 버퍼링 적용 후 기대한 엑셀 다운로드 성능 개선을 얻었고, 병목이 DB·라이브러리가 아닌 프레임워크의 스트리밍 방식에 있었음을 확인했다.
왜 읽나Spring MVC(Tomcat)에서 Flux/NDJSON으로 대량 데이터를 스트리밍하다 원인 모를 성능 저하를 겪는 백엔드 개발자에게 실전 진단·해결 레퍼런스.
네이버페이
네이버페이 블로그
원문은 여기서 이어서 읽을 수 있어요
원문 읽기
읽음 (0)

이 글과 비슷한

  1. 백엔드·github-engGitHub Engineering·

    조기 종료를 없애야 벡터화된다 — 메모리 속도 소스 코드 케이스 폴딩

    GitHub의 코드 검색 엔진 Blackbird는 480TB 이상의 소스 코드를 인덱싱하기 전 모든 바이트에 case folding을 적용한다. 이 글은 Rust로 구현한 case folding을 메모리 대역폭 한계(45+ GiB/s)까지 끌어올린 두 가지 반직관적 최적화를 상세히 다룬다. 핵심은 루프 조기 종료(break) 제거로 LLVM 벡터화를 유도하고, UTF-8을 디코딩하지 않고 바이트 공간 산술만으로 fold를 수행하는 것이다.

    #rust#unicode#simd+2
  2. 백엔드·여기어때 (GC컴퍼니)여기어때 (GC컴퍼니)·

    트랜잭션 스크립트에서 숙소 메타 + 가격 계산 모듈로 — 전시 아키텍처 개선기 (2/3)

    여기어때 전시개발팀이 숙소 상세(PDP) API를 해부한 결과, 코드상으로는 DB 호출 3번처럼 보이던 요청이 실제로는 MongoDB $lookup 체인으로 컬렉션을 19회 접근하는 구조였다. 이 트랜잭션 스크립트 방식의 핵심 문제는 "aggregation이 I/O를 가린다"는 점으로, 독립적인 쿼리 10개가 단일 파이프라인에 직렬화되어 병렬화 기회를 잃고, 가격 때문에 거의 안 바뀌는 이미지까지 매 요청마다 읽어야 하는 읽기 증폭이 발생했다. V3에서는 "조회 시점 조립"을 "쓰기 시점 사전 조립"으로 전환하고, 화면별로 복제되던 가격 계산 로직을 goodsprice 단일 모듈로 수렴했다. 4개 API(PLP/PDP/RDP/ILP)의 반복 마이그레이션은 Claude Code skill로 절차를 고정하고 쉐도잉 + 동일성 검증으로 안전망을 마련하는 방식으로 진행됐다.

    #architecture#migration#caching+2