저는 그동안 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 문서와 구현부의 로직들을 확인하며 파악한 문제점으 아래와 같았습니다.
- $ne: null 조건의 비효율성
- $ne 연산은 인덱스를 효율적으로 활용하지 못할 수 있으며, 불필요한 풀 컬렉션 스캔을 유발할 가능성이 있습니다.
- 기본 batchSize (101)의 한계
- MongoDB Java 드라이버의 기본 batchSize는 101로, 많은 네트워크 왕복을 유발하여 성능 병목이 발생할 수 있습니다.
- 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를 사용하는 분들께도, 이 사례가 작은 힌트라도 되었으면 합니다.