존재하지 않는 유령 인덱스 조회와 불필요한 다국어 루프, 레거시 데이터를 제거하여 API 호출 비용과 레이턴시를 대폭 줄인 생생한 백엔드 리팩토링 최적화 사례를 공유합니다.
분명 기능은 정상적으로 잘 도는데, 이상하게 제품 상세 조회 API의 레이턴시가 데이터 규모에 비해 느린 네트워크 속도를 보이는 경우가 있습니다.
최근 시스템 성능을 모니터링하던 중, 존재하지도 않는 데이터와 필드를 찾기 위해 매 요청마다 시스템이 헛바퀴를 돌며 리소스를 낭비하고 있던 기이한 현상을 발견했습니다. 결론부터 말씀드리면, 안 쓰는 레거시와 유령 인덱스를 걷어내는 것만으로도 API 호출 비용과 서버 부하를 드라마틱하게 줄였습니다.
내가 짠 코드가 혹시 서버 뒤편에서 존재하지 않는 유령을 잡으려고 섀도우 복싱을 하고 있지는 않은지, 이번 트러블슈팅 사례를 통해 점검해 보세요.
문제의 발단
성능 최적화를 위해 제품 조회 로그를 파고들던 중, 에러 레이어에는 잡히지 않지만 내부 연산 시간을 갉아먹는 이상한 쿼리 요청을 발견했습니다.
// [실제 로그에 포착된 문제의 조회 쿼리 예시]
{
"productId": "prod_99482",
"searchOptions": {
"targetIndex": "IngredientTextIndex", // 🤔 엇, 이거 예전에 없앤 인덱스인데?
"languages": ["ko", "en", "ja"] // 🌐 한국어만 보여주면 되는 화면인데 왜 다 돌지?
}
}
아키텍처가 변경되면서 성분 데이터 구조에서 IngredientTextIndex는 이미 완전히 삭제된 상태였습니다. 하지만 백엔드 비즈니스 로직이나 ORM 레이어 어딘가에서는 이 유령 같은 인덱스를 매 요청마다 끊임없이 호출하고 있었습니다. 없는 인덱스를 참조하려니 데이터베이스 옵티마이저가 제대로 작동할 리 만무했고, 시스템은 내부적으로 풀 스캔에 준하는 비효율적인 탐색을 반복하고 있었습니다.
내 프로젝트 내부의 3가지 '비용 도둑'
코드를 더 깊숙이 뜯어보니 문제는 단순히 인덱스 하나만의 문제가 아니었습니다. 세 가지 비효율이 얽혀 있었습니다.
-
도둑 1: 존재하지 않는 인덱스 반복 조회 이미 사라진
IngredientTextIndex를 기준으로 성분 정보를 매번 탐색했습니다. 당장 에러가 나진 않지만, 뒷단에서 데이터베이스 서버가 헛바퀴를 돌며 컴퓨팅 자원을 낭비하는 주범이었습니다. -
도둑 2: 과도한 다국어 루프 현재 유저 화면에 보여줘야 하는 것은 오직 한국어(
ko) 데이터뿐이었습니다. 한국어 필드만 빠르게 매핑해서 응답하면 끝날 일인데, 코드 내부에서는 영어(en), 일본어(ja) 필드까지 전부 순회하며 데이터를 가공하고 있었습니다. 필드가 없으면 스킵이라도 빨라야 하는데, 상위에서 유령 인덱스를 매핑하느라 연산 비용이 배로 뛰었습니다. -
도둑 3: 150개의 유령 레거시 데이터 과거 초기 기획 단계나 테스트 용도로 생성된 뒤, 현재는 제품 서비스 어디에서도 쓰이지 않는 옛날 데이터 약 150개가 DB 한편을 차지하고 있었습니다. 문제는 제품을 조회할 때마다 이 150개의 유령 데이터까지 같이 로드되어
IngredientTextIndex가 있는지 검사하는 로직을 통과하고 있었다는 점입니다.
최적화의 정답은 명확했습니다. 안 쓰는 것은 확실하게 지우고, 없는 것은 찾지 않는다. 복잡한 알고리즘을 도입하는 것보다 '과감한 삭제'가 최고의 튜닝이 될 때가 있습니다.
AS-IS (기존의 비효율적인 레거시 로직)
// 매 요청마다 굳이 없어진 인덱스를 찾고 다국어를 다 돌던 로직
async function getProductDetails(productId) {
const product = await db.products.find(productId);
// 🚨 [문제 1] 존재하지 않는 인덱스를 계속 탐색
const ingredients = await db.ingredients.findByIndex('IngredientTextIndex');
// 🚨 [문제 2] 한국어만 필요한 상황에서 전체 언어 루프 가공
const languagePack = ['ko', 'en', 'ja'];
const processedData = languagePack.map(lang => {
// 🚨 [문제 3] 쓰이지도 않는 150개의 옛날 데이터까지 여기서 함께 필터링 됨
return ingredients.filter(ing => ing.lang === lang && !ing.isDeprecated);
});
return { product, processedData };
}
TO-BE (개선 후 깔끔해진 코드)
// 유령 인덱스, 다국어 루프, 쓰레기 데이터를 모두 걷어낸 모습
async function getProductDetails(productId) {
const product = await db.products.find(productId);
// ✅ 불필요한 인덱스 탐색 코드를 통째로 삭제!
// ✅ 쓰이지 않는 150개의 옛날 데이터는 DB 마이그레이션(Delete)으로 영구 제거!
// ✅ 현재 컨텍스트(ko)에 필요한 데이터만 조건절로 다이렉트 조회
const koreanIngredients = await db.ingredients.find({
productId: productId,
lang: 'ko'
});
return { product, ingredients: koreanIngredients };
}
섀도우 복싱을 멈추면 얻게 되는 것들
거창한 인프라 스케일업 없이 오직 방치되어 있던 레거시 코드와 유령 데이터를 청소했을 뿐이지만, 결과는 기대 이상이었습니다.
-
API 응답 속도(Latency) 개선: 무의미한 다국어 메모리 내 객체 순회와 유령 인덱스 탐색 연산이 사라져, 제품 상세 조회 속도가 직관적으로 빨라졌습니다.
-
컴퓨팅 리소스 및 API 호출 비용 절감: DB 대기 시간(I/O Bottleneck)이 줄어들고 불필요한 쿼리 연산이 제거되면서 서버 CPU 부하가 크게 감소했습니다. 결과적으로 불필요한 API 호출 비용을 대폭 아낄 수 있었습니다.
바로 실천하는 리펙토링 행동 가이드
지금 운영 중인 서비스의 성능을 점검하고 싶다면 딱 3가지만 먼저 확인해 보세요.
-
DB 마이그레이션 이력 확인: 과거에 삭제한 인덱스나 필드를 여전히 참조하고 있는 소스코드가 남아있는지 글로벌 검색(
Ctrl + Shift + F)을 돌려보세요. -
Context 기반 다국어 처리: 무조건 전체 언어 스펙을 다 불러와서 프론트엔드로 던지고 있지 않나요? 현재 유저가 요청한 언어팩만 쿼리 조건절(
where)에서 필터링하세요. -
더미 데이터 청소: '나중에 쓰겠지' 하고 남겨둔 과거의 데이터가 매번 전체 조회(
FindAll) 성능을 갉아먹고 있다면, 과감히 백업 후 프로덕션 DB에서 지우셔야 합니다.
"코드를 추가하는 것보다, 안 쓰는 코드를 안전하게 지우는 것이 몇 배는 더 어렵고 위대하다."
시간이 지나 파편화된 레거시는 결국 서비스의 발목을 잡는 족쇄가 됩니다. 오늘 당장 여러분의 코드 베이스에서 '유령'을 찾아 제거해 보시는 건 어떨까요?
Q1. 존재하지 않는 인덱스를 호출하면 DB 에러가 나지 않나요?
A. ORM 설정이나 DB 종류에 따라 다릅니다. 명시적으로 쿼리 에러를 뱉는 경우도 있지만, 힌트(Hint)절이나 특정 조회 옵션으로 들어가 있는 경우 에러 없이 인덱스를 무시하고 풀 스캔(Full Scan)을 수행하여 성능만 떨어뜨리는 경우가 많으므로 주의 깊게 로그를 확인해야 합니다.
Q2. 쓰이지 않는 레거시 데이터를 안전하게 지우는 팁이 있을까요?
A. 프로덕션 DB에서 바로 DELETE를 치는 것은 위험합니다. 먼저 해당 데이터들을 별도의 백업 테이블이나 콜드 스토리지로 마이그레이션(Dump)해 둔 뒤, 서비스 영향도가 없는 것을 확인하고 프로덕션 테이블에서 제거하는 것을 추천합니다.
Q3. 다국어 처리는 무조건 DB에서 필터링하는 게 좋나요?
A. 네, 그렇습니다. 백엔드 메모리로 모든 언어(ko, en, ja 등)의 데이터를 가져온 뒤 루프를 돌며 필터링하는 것은 O(N)의 비용을 유저 요청마다 쓰게 만듭니다. 처음부터 DB 조회 조건에 lang: 'ko'와 같이 인덱스를 탈 수 있는 조건을 걸어 필요한 데이터만 가져오는 것이 비용 최적화의 핵심입니다.