반응형
그니_
삽질탐방기
그니_
  • 분류 전체보기 (24)
    • 개발 (15)
    • ETC (1)
    • 트러블슈팅 & 삽질기록 (7)
    • 성능개선 (1)

인기 글

최근 글

최근 댓글

태그

  • chatgpt 히스토리 삭제
  • chatgpt 채팅 삭제
  • Spring
  • spring log
  • db
  • chatgpt 확장 추천
  • Database
  • spring docker
  • 네트워크
  • java
  • chatgpt 정리
  • timeunit
  • 마이크로소프트 ai tour
  • springboot
  • easyrandom
  • chatgpt 확장 프로그램
  • chatgpt 기록 삭제
  • index
  • GPT 플러그인
  • spring docker compose
hELLO · Designed By 정상우.
그니_

삽질탐방기

성능개선

[성능개선] MongoDB 쿼리 최적화로 30% 성능 향상시키기: batchSize와 쿼리 조건 개선

2025. 8. 3. 16:07
반응형

저는 그동안 RDBMS나 Elasticsearch처럼 익숙한 시스템들을 주로 다뤄왔고, MongoDB는 이번 프로젝트에서 처음 사용하게 되었는데요. 이 MongoDB는 실제 서비스에 등록된 사용자 데이터를 비롯한 여러 데이터들을 보관하는 용도로 사용되고 있습니다.

로그수집기내에서는 이 데이터를 조회해 메모리에 캐시(Map 형태)로 적재해두고, 이후 수집된 로그 파일들에서 특정 필드를 기준으로 MongoDB의 데이터를 조인하듯 매핑해 메타데이터를 추가합니다. 그렇게 가공된 로그는 Kafka → Logstash → Elasticsearch로 이어지는 파이프라인을 타게 됩니다.

 

MongoDB는 여기서 메타데이터의 원천 역할을 하고 있는 셈입니다. 이 캐시 적재 작업은 매일 새벽 한 번 실행되고 있었고, 수년간 큰 문제 없이 잘 돌아가고 있었습니다. 하지만 차세대 개발을 준비하며 기존 로직을 하나하나 살펴보는 과정에서 성능 병목 가능성이 있는 포인트를 발견했고, 직접 실험을 해보며 개선 작업을 진행해보기로 했습니다.

 

그 결과 약 1000만 건의 데이터를 처리하는 데 걸리는 시간을 17초에서 12초로 약 32% 단축할 수 있었고, 이번 글에서는 그 개선 과정을 공유하려고 합니다.

 

로컬 테스트 환경 구성

테스트 데이터를 준비하기 위해 Docker로 MongoDB 환경을 구성했습니다. 간단한 docker-compose.yml 설정은 다음과 같습니다

version: '3.8'

services:
  mongo:
    image: mongo:8.0
    container_name: mongo-sample
    ports:
      - "27017:27017"
    volumes:
      - mongodb_data:/data/db
    restart: unless-stopped

volumes:
  mongodb_data:

 

데이터는 1000만 건의 고객 정보를 시딩하기 위해 아래 Java 프로그램을 작성했습니다

 

public class CustomerSeeder {
    public static void main(String[] args) {
        try (MongoClient client = MongoClients.create("mongodb://localhost:27017")) {
            MongoCollection<Document> collection = client
                    .getDatabase("example")
                    .getCollection("customer");

            int total = 10_000_000;
            int batchSize = 10_000;
            List<Document> batch = new ArrayList<>(batchSize);

            for (int seq = 0; seq < total; seq++) {
                String customerNo = String.format("CS%07d", seq);
                String userId = String.format("U%07d", seq);

                Document document = new Document()
                        .append("customerNo", customerNo)
                        .append("userId", userId);

                batch.add(document);

                if (batch.size() >= batchSize) {
                    collection.insertMany(batch);
                    batch.clear();
                }
            }

            if (!batch.isEmpty()) {
                collection.insertMany(batch);
            }

            System.out.println("생성 완료");
        }
    }
}

 

이 코드는 customerNo와 userId 필드로 구성된 1000만 건의 문서를 생성하고, userId에 인덱스를 추가했습니다. 모든 userId는 null이 아닌 고유 값(U0000000 ~ U9999999)입니다.

기존 로직 분석

기존 시스템은 매일 새벽 1000만 건의 고객 데이터를 MongoDB에서 조회해 Java 메모리 캐시(HashMap)에 적재했습니다. 사용된 쿼리는 아래 테스트 코드에서 확인할 수 있습니다.

package org.example;

import com.mongodb.client.*;
import org.bson.Document;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static com.mongodb.client.model.Projections.include;

public class MongoCacheLoaderTest {
    private static MongoCollection<Document> collection;

    @BeforeAll
    static void setup() {
        MongoClient client = MongoClients.create("mongodb://localhost:27017");
        MongoDatabase db = client.getDatabase("example");
        collection = db.getCollection("customer");
    }

    /* 기존 쿼리: 10,000,000건, 소요 시간: 17743ms */
    @Test
    void 기존_작성된_쿼리_및_캐시_초기화방식() {
        long start = System.currentTimeMillis();
        Map<String, String> cacheMap = new HashMap<>();

        MongoCursor<Document> cursor = collection
                .find(new Document("userId", new Document("$ne", null)))
                .projection(include("userId", "customerNo"))
                .iterator();

        while (cursor.hasNext()) {
            Document document = cursor.next();
            String userId = document.getString("userId");
            String customerNo = document.getString("customerNo");

            cacheMap.put(userId, customerNo);
        }

        long end = System.currentTimeMillis();
        System.out.printf("기존 쿼리: %,d건, 소요 시간: %dms%n", cacheMap.size(), (end - start));
    }
}

 

로컬 환경(Mac)에서 이 쿼리를 실행했을 때 약 17.7초가 소요되었습니다. 1일 1회 실행되는 작업이라 큰 문제는 없었지만, 성능을 개선할 여지가 있다면 의미 있는 작업이라고 판단했습니다.

 

문제점 발견

MongoDB 문서와 구현부의 로직들을 확인하며 파악한 문제점으 아래와 같았습니다.

  1. $ne: null 조건의 비효율성
    • $ne 연산은 인덱스를 효율적으로 활용하지 못할 수 있으며, 불필요한 풀 컬렉션 스캔을 유발할 가능성이 있습니다.
  2. 기본 batchSize (101)의 한계
    • MongoDB Java 드라이버의 기본 batchSize는 101로, 많은 네트워크 왕복을 유발하여 성능 병목이 발생할 수 있습니다.
  3. try-with-resources 미사용
    • 기존 코드에서는 커서에 대한 리소스 해제가 누락되어 있었고, 자원 누수 가능성을 고려하면 try-with-resources 구문을 활용하는 것이 안정성 측면에서도 바람직합니다.

성능 개선 시도

다음 세 가지를 개선했습니다.

    • $ne: null 필터 제거: 서버 측 필터링을 제거하고 클라이언트(Java)에서 null 체크를 수행했습니다.
    • try-with-resources 적용: 커서 리소스를 안전하게 해제하기 위해 try-with-resources 구문 추가 (AutoCloseable)
    • batchSize 조절: batchSize를 10,000으로 설정해 네트워크 호출 횟수를 대폭 줄였습니다.
      • 실제 환경에 맞춰 메모리를 고려해 1000 ~10000건의 사이즈로 구성

개선된 코드는 아래 테스트 코드에서 확인할 수 있습니다

package org.example;

import com.mongodb.client.*;
import org.bson.Document;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static com.mongodb.client.model.Projections.include;

public class MongoCacheLoaderTest {
    private static MongoCollection<Document> collection;

    @BeforeAll
    static void setup() {
        MongoClient client = MongoClients.create("mongodb://localhost:27017");
        MongoDatabase db = client.getDatabase("example");
        collection = db.getCollection("customer");
    }

    /* 개선 쿼리:  10,000,000건, 소요 시간: 11967ms */
    @Test
    void 개선된_쿼리_및_캐시_초기화방식() {
        long start = System.currentTimeMillis();
        Map<String, String> cacheMap = new HashMap<>();

        FindIterable<Document> cursor = collection
                .find()
                .projection(include("userId", "customerNo"))
                .batchSize(10000);

        try (MongoCursor<Document> it = cursor.iterator()) {
            while (it.hasNext()) {
                Document doc = it.next();
                String userId = doc.getString("userId");
                String customerNo = doc.getString("customerNo");

                if (userId != null) {
                    cacheMap.put(userId, customerNo);
                }
            }
        }

        long end = System.currentTimeMillis();
        System.out.printf("개선 쿼리: %,d건, 소요 시간: %dms%n", cacheMap.size(), (end - start));
    }
}

결과

개선된 쿼리를 로컬 환경에서 실행한 결과, 처리 시간이 17.7초에서 12.0초로 약 32% 단축되었습니다.

 

마무리하며

이번 작업은 단순한 쿼리 하나도 어떤 조건이 인덱스에 영향을 주고 실행 계획이 어떻게 달라지는지, 실제로 데이터를 넣고 explain()으로 실행 계획을 확인하며 하나하나 실험해 본 과정이었습니다.

작업을 하면서 다음과 같은 인사이트가 확실히 남았습니다

  • 쿼리 조건 하나로 인덱스가 무력화될 수 있다
  • batchSize 조정만으로도 눈에 띄는 성능 차이를 만들 수 있다
  • 커서를 안전하게 닫는 습관은 성능보다도 안정성 측면에서 중요하다

그리고 무엇보다도, 기능이 '돌아가기만 하면 됐다'가 아니라 혹시 더 나을 수는 없을까? 를 계속 의심하고 실험해보는 태도가 백엔드 개발자에게 꼭 필요하다는 걸 다시금 느꼈습니다.

비슷한 환경에서 MongoDB를 사용하는 분들께도, 이 사례가 작은 힌트라도 되었으면 합니다.

반응형
저작자표시 (새창열림)
    그니_
    그니_
    머리속에서만 존재하는 내용을 글로 정리

    티스토리툴바