← Back to list

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

소개

sungkwon ha in NAVER Pay Dev Blog · 2025-07-07 07:35 · 12 claps · 3.5 min read
#dev #backend #mongodb #ndjson
Open on Medium ↗
Wiki topics: 🌐 · Web Development

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

소개

안녕하세요. 네이버페이 혜택&정산BE 에서 네이버페이 정산 개발을 담당하고 있는 하성권 입니다.

최근 대용량 데이터의 조회 성능을 개선하기 위해 CQRS 패턴 기반의 MongoDB 시스템을 도입했습니다. 이 과정에서 예상치 못한 성능 병목 현상을 겪었고, 그 해결 과정을 공유하고자 합니다.

MongoDB와 CQRS 패턴을 적용한 이후 API 조회 속도는 기존 샤딩 DB 대비 40배 이상 향상됐습니다. 하지만, 엑셀 다운로드 성능은 전혀 개선되지 않았습니다. MongoDB 튜닝도 하고, 엑셀 라이브러리도 변경해봤지만 효과가 없었습니다.

문제의 원인은 의외로 Tomcat(Spring Web Embedded)에서 Flux 리턴 타입으로 NDJSON을 스트리밍할 때, 한 줄씩 blocking 되는 구조에 있었습니다.

1. 배경: 대형 가맹점의 고통

네이버페이 정산 시스템은 가맹점 기반의 샤딩 DB를 사용 중에 있습니다. 성능 개선 및 스케일아웃에 유리하도록 채택한 샤딩 DB 구조였지만 샤딩 불균형 문제가 발생하고 있습니다.

상위 1위 가맹점: 전체 데이터의 2%

상위 1~10위 가맹점 합산: 전체 데이터의 약 7%

서비스가 확장되고, 쿼리가 날로 복잡해짐에 따라 이런 대형 가맹점들은 조회할 때마다 타임아웃에 시달렸습니다. 그래서 CQRS 패턴을 도입하기로 했습니다.

1.1 CQRS 도입: 절반의 성공

MongoDB에 미리 가공된 데이터를 저장해 join 없이 빠르게 조회되는 시스템을 구축했습니다.

  • API 성능 측정 결과

기획팀에게 성능 개선 효과 좋다고 자랑하러 가도 되겠다!!

기획팀에게 성능 개선 효과 좋다고 자랑하러 가도 되겠다!!

  • 엑셀 다운로드 성능 측정 결과

어….?

어….?

2. 원인을 찾아서

성능 병목을 발생시키는 원인 파악을 위해서 구간별로 성능 테스트를 진행하였습니다.

대략적인 엑셀 파일 생성 흐름도

대략적인 엑셀 파일 생성 흐름도

2.1. MongoDB 조회 성능

  • readConcern 레벨 조정
  • projection으로 필요한 필드만 조회

결과: 쿼리 성능 약 10% 내외의 조회 성능 개선이 있었지만 엑셀 파일 생성 성능에 영향은 없었음

2.2. 엑셀 라이브러리 성능

  • POI SXSSF(현재 사용중) vs EasyExcel vs FastExcel 벤치마킹
  • FastExcel 도입 시 약 2배 수준의 엑셀 파일 생성 성능 개선 효과

결과: 엑셀 파일 생성 성능은 여전히 개선되지 않음

2.3. 네트워크 성능

  • 엑셀 결과 파일과 비슷한 용량의 파일 전송 속도 테스트

결과: 8만건 약 1초 내외로 전송 완료되어 네트워크는 전혀 이슈가 되지 않음

2.4 원인 발견: Tomcat + NDJSON 조합

남은 구간(Spring web) 에서 테스트를 하다가 이상한 점을 발견했습니다.

# JSON 형식 (일반 배열)
curl localhost:8080/api/excel/json
> Speed: 37.7MB/s

# NDJSON 형식 (줄바꿈으로 구분)
curl localhost:8080/api/excel/ndjson  
> Speed: 3.7MB/s

3. 원인 분석

NDJSON 이란? Newline Delimited JSON의 약자로 각 줄이 JSON 객체 하나를 나타내고, 각 JSON 객체는 개행 문자(\n)로 구분되어 저장되는 데이터 포맷을 의미합니다.

// 예시
{"name": "John", "age": 30}
{"name": "Jane", "age": 25}
{"name": "Peter", "age": 40}

이러한 NDJSON의 특성으로 인해 spring web에서 Flux 리턴 타입의 NDJSON 응답 형식을 처리 시 아래와 같은 처리를 순서대로 하게 됩니다.

  1. 한 줄 직렬화 → HttpResponse에 write
  2. Tomcat은 write 완료까지 blocking
  3. 50만 줄 = 50만 번 blocking(사실 개행문 까지 포함하여 100만 번)
//  org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitterReturnValueHandler

private static final class DefaultSseEmitterHandler implements ResponseBodyEmitter.Handler {
  // sendInternal 메소드에 의해 위에서 httpResponse가 write & flush가 수행됩니다.
  @Override
  public void send(Object data, @Nullable MediaType mediaType) throws IOException {
   sendInternal(data, mediaType);
   this.outputMessage.flush();
  }
}
// org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitterReturnValueHandler.ReactiveTypeHandler

private static class JsonEmitterSubscriber extends AbstractEmitterSubscriber {

JsonEmitterSubscriber(
  ResponseBodyEmitter emitter, TaskExecutor executor) {
  super(emitter, executor, null);
}

// Ndjson 형식의 경우 JsonEmitterSubscriber 가 Flux의 item을 건별로 처리합니다.
// 각 item 들은 위에서 정의된 handler에 따라 json 한줄 write & flush 수행 후 
// 개행 write & flush 를 수행합니다.
@Override
protected void send(Object element) throws IOException {
   getEmitter().send(element, MediaType.APPLICATION_JSON);
   getEmitter().send("\n", MediaType.TEXT_PLAIN);
  }
}

반면 JSON 형식으로 응답 시 하나의 배열 형태의 JSON으로 합친 후 한 번만 write & flush 가 발생하여 I/O blocking 시간이 절약되기 때문에 NDJSON에 비해 10배 빠른 성능을 보여주는 것을 확인할 수 있었습니다.

I/O에 의한 성능 저하가 원인이라면, non-blocking 환경인 Netty에서는 이 부분이 문제가 되지 않을 것으로 보고 테스트를 해보았습니다. 그 결과 아래와 같이 Tomcat 대비 약 7배가량의 처리 속도를 확인할 수 있었습니다.

  • Tomcat: 3–4MB/s
  • Netty: 19–20MB/s

이참에 netty 환경으로 전환해 볼까? 하고 살펴봤지만 하나의 기능만을 위해 너무나 변경사항이 생겼고, 그 많은 변경사항을 확인할 리소스 또한 제한적이었습니다.

4. 해결책: 버퍼링으로 극복

Spring web에서 한 줄씩 처리하도록 Flux<T> 타입으로 리턴하지 않고, HttpServletResponse에서 직접 write & flush 처리를 하도록 확장 함수를 구현 및 적용하였습니다.

fun <T> Flux<T>.writeBufferedStreamResponse(
    response: HttpServletResponse, 
    objectMapper: ObjectMapper
) {
    response.writer.use { writer ->
        this.buffer(1000)  // 1000개씩 모아서
            .map { buffered ->
                buffered.forEach {
                    writer.write("${objectMapper.writeValueAsString(it)}\n")
                }
                writer.flush()  // 한번에 flush!
            }
            .blockLast()
    }
}

4.1. 최종 결과

1000개씩 모아 응답을 write & flush 하도록 한 결과 드디어 기대하던 개선 효과를 볼 수 있었습니다.

드디어 기획팀에게 진짜 성능이 개선되었다고 말할 수 있겠다…!

드디어 기획팀에게 진짜 성능이 개선되었다고 말할 수 있겠다…!

Lesson Learned

  1. 성능 문제의 원인은 예상 밖의 곳에 있을 수 있다: DB도 빠르고, 라이브러리도 빠른데 왜 느릴까? → 프레임워크 이슈일 수도있다
  2. 스트리밍 처리는 프레임워크 특성을 고려해야 한다: Servlet 기반에서 Reactive 스타일 사용 시 주의
  3. 구간별 측정이 중요하다: 전체가 아닌 각 구간을 측정해야 진짜 병목을 찾을 수 있다

이제 정말로 CQRS 도입 효과를 자랑할 수 있게 되었습니다! 🎉

참고 문헌


메타데이터
post_id
1e8f7bebe35d
slug
test-1e8f7bebe35d
url
https://medium.com/naverfinancial/test-1e8f7bebe35d
canonical_url
https://medium.com/naverfinancial/test-1e8f7bebe35d
author_url
https://medium.com/@sungkwon-ha
status
ok
fetched_at
2026-07-19 03:03:37