이 포스팅은 mongoDB를 사용중인 서비스에서 매우 느린 검색 기능의 응답속도를 단축시키기 위해 겪은 험난했던 과정을 작성한 후기글이다.
직장에서 mongoDB를 사용중인 서비스가 있다. 한 페이지에 20개씩 보여주는 검색 페이지가 있는데 실제 실행중인 프로덕트 환경에서 검색 결과수 에 따라 응답 시간이 다르긴 하지만 검색 결과가 대략 8000개 일때 응답하는데 무려 4~5초가 걸렸고 로컬 개발환경에서는 2초가 걸렸다. 첫응답만 느린게 아니라 페이지를 넘길때 마다 4~5초씩 기다려야했고 로컬에서 테스트할 때도 상당히 답답한 수준이었다. 현재 db에 데이터가 대략 2만건인데 앞으로 데이터가 많아질수록 더더욱 느려질거고 언젠가는 해결해야할 문제였지만 당장 업무처리 우선순위에는 있지 않아서 방치된 상태였다. 하지만 개인적으로 이런 문제는 언제가 한번 개선해보고 싶었고 마침 그럴만한 기회가 와서 개인적으로 집에서 개선을 해보기로 했다.
현재 db에는 Memberships
과 Products
컬렉션이 있다. 검색 페이지는 product를 검색하는데 해당 product를 생성한 유저의 membership 등급에 따라 검색결과의 상위에 노출 시키고 있다. membership은 rootEmail
, product는 email
이라는 필드로 유저의 이메일을 가지고있다.
검색 결과를 처리하는 코드는 아래의 순서대로 실행된다.
중요한 부분은 2번 이다. 이 코드를 처음봤을때 대체 왜 skip과 limit 없이 모든 데이터를 가져오는지 몰랐고 처음엔 당연히 페이지네이션을 적용해서 필요한 데이터만 가져오는 방식으로 수정을 하려고 했지만 현재 서비스의 요구 사항을 충족하기 위해선 결국 데이터를 다 가져와야할수 밖에 없었다
위의 조건을 충족하려면 모든 데이터를 한번에 가져와서 처리하는것 말고는 다른 방법이 떠오르지가 않았다. 결국 현재의 로직은 그대로 두고 이 로직의 시간을 단축하는 방향으로 여러가지 시도를 해보았다.
속도 개선을 위해 인덱스, 병렬처리, db 레벨에서 조인 등 여러가지 방법을 시도했지만 결과적으론 성공하지는 못했다.
처음엔 위의 처리 순서중 3번 ~ 5번에 해당되는 product를 가져와 멤버십을 연결하고 분류하고 섞는 과정에서 시간이 많이 걸릴것이라 판단을 했었다. 해당 코드가 product 목록과 membership 목록을 가지고 중첩 반복문을 돌면서 처리했기 때문에 현재 db에 membership이 120개 정도에 product가 24000개쯤 있는데 최악의 경우 120 * 24000 번 반복하니까 여기서 시간이 오래걸리지 않을까 추측했고 처음부터 db에서 가져올때 product에 membership을 연결해서 가져오면 membership을 연결하느라 반복문을 돌릴필요가 없으니 빨라지지 않을까 라고 생각했다.
//product에 membership 이라는 별도의 필드를 추가하여 가져오기
const products=await Products.aggregate([
{ $match:query }, //검색 조건
{
$lookup:{
from:'memberships',
localField:'email',
foreignField:'rootEmail',
as:'membership'
}
},
//후략
])
mongoDB의 $lookup
으로 db레벨에서 product에 membership을 포함시켜서 가져오도록 작성했다. 이렇게 하니 기존 product에 membership을 연결하는 코드도 없어지니 깔끔하고 좋아 보였다. 문제는 기존 코드 보다 더 느리다는거였다. 기존 코드 보다 대략 20% 정도 더 느렸다. 속도가 오히려 더 느려졌으니 이 방법은 실패했다. 나중에 알게된 사실이지만 문제의 근본적인 원인이 db에서 데이터를 가져올때 발생한는 것이었는데 거기에 추가적으로 join 연산을 하다보니 당연히 더 느려진것이었다.
사실 이때 까지 mongoDB의 인덱스 라는 개념 자체를 모르고있었다. 문제 해결을 위해 ai에게 질문을 하던중 인덱스를 생성하라고 해서 product를 검색할때 기준이 되는 필드값에다 인덱스를 생성하고 실행해 보았으나 실행 속도는 그대로였다. 나중에 알게된 사실이지만 인덱스는 find에서 조건 검색을 할때 성능을 향상 시킬수 있지만 find()
와 같이 모든 데이터를 가져와야 할경우에는 성능상 차이가 없다고한다. 내가 지금 수정해야할 문제는 product의 모든 데이터를 가져와야하는 경우에도 실행 시간을 단축시켜야 하기때문에 이 문제는 인덱스로는 해결할수 없었다.
여러방법을 시도하던중 대체 어디서 이렇게 시간이 오래 걸리는지 찾아내려고 코드 기능 마다 전부 console.time()
을 찍어서 소요 시간을 확인하다 기가막힌 사실을 하나 발견했다. 바로 전체 응답 시간중 mongoDB에서 find 로 데이터를 가져오는데 걸리는 시간이 70% 이상이라는 것이었다 그리고 내가 제일 처음 시간이 오래걸릴것이라 판단하였던 product와 membership을 연결하고 분류하는 로직은 실제로 0.5초 밖에 안걸린다는 사실이었다.(사실 0.5초도 사용자 입장에선 긴 편이긴 하지만) 문제의 원인은 알았으니 db에서 데이터를 가져오는 시간을 단축시키려고 간단하게 한번에 전부 가져오는게 아니라 여러 등분으로 나눠서 promise.All
로 한번에 가져오기를 시도해봤다.
//예시 코드
const count //쿼리의 검색 결과수
const chunkSize=Math.ceil(count / 3) //3등분
const list=[]
for(let i=0;chunkSize * i < count; i++){
const skip=chunkSize * i
const promise=Products.find().skip(skip).limit(chunkSize)
list.push(promise)
}
const result = await Promise.all(list)
이 코드는 한번의 find로 가져오는걸 3등분으로 나눠서 Promise.all()을 통해 동시에 실행시키는 코드 이다. 단순한 논리 대로라면 실행 시간도 1/3이 되어야 하지만 이 방법 또한 문제를 해결하지 못했다. 병렬로 실행은 했지만 단일 쿼리로 실행하는것과 시간 차이가 없었다. 마치 병렬요청을해도 db자체에서는 한번에 하나의 요청만 처리하는듯한 느낌이 들었다. 혹시나 해서 mongodb.connect() 옵션에 poolSize를 늘려봤지만 그대로 였다. 코드뿐만이 아니라 mongoDB 자체의 연결관련 설정도 찾아봤지만 해결하지 못하여 이 방법은 일단 보류했다.
위의 방법을 시도해보기 전에도 캐시를 사용할까 고민은 했었다 하지만 캐시를 사용하려면 별도의 캐시서버를 구축해야하므로 비용문제와 캐싱관련 코드도 추가되기때문에 살짝 오버일수도 있다는 판단으로 일단 다른방법을 시도해보고 캐시는 마지막 방법으로 사용하자라는 생각이었는데 막상 다른방법이 다 실패하니 캐시 말곤 방법이 보이지 않았다. 지금 문제는 db에서 데이터를 가져올때 시간이 오래걸리는 상황이라 db 검색 결과 자체를 통째로 캐시에 넣어두고 사용자의 요청이 캐시에 있는 데이터와 같은 검색조건일 경우 캐시에 있는 데이터를 사용하면 어떨까 라는 방법을 생각해보았다.
다행인건 현재 product 컬렉션의 총 크기는 약 5Mib 밖에 안돼서 충분히 캐시에 저장할수 있을정도였다.
캐시 서버는 redis를 사용하기로 하고 간단하게 테스트용으로 docker 이미지를 사용했다.
현재 검색을 처리하는 백엔드 코드는 query string으로 검색 조건을 받아서 mongoDB의 find() 에 사용할 쿼리인 q
라는 객체를 동적으로 생성하고 find(q) 를 호출하여 데이터를 가져온다. 이 q라는 변수를 JSON.stringfy() 하여 문자열로 바꾸고 그걸 key로 사용하고 q를 통해 가져온 데이터도 마찬가지로 문자열로 변환시켜서 redis에 저장했다. 그리고 다음에 요청을 받았을때 만약 캐시에 저장된 데이터가 있다면 db요청을 하지 않고 캐시에있는걸 그대로 사용하여 응답하도록 수정했다. 대략적인 코드는 아래와 같다.
//검색 처리 코드의 일부분
const q = {}
// q는 query string에 따라 동적으로 값을 추가한다
// ...
const key=JSON.stringfy(q)
const cachedData=await redis.get(key)
if(cachedData) {
const data=JSON.parse(cachedData)
// 데이터 처리 및 응답
return
}
const data=await Products.find(q)
//캐시된 데이터가 없을경우 db에서 가져온후 새로 캐시에 저장
await redis.set(key,JSON.stringfy(data))
// 데이터 처리 및 응답
추가적으로 지금은 테스트용으로 간단하게 작성한 코드라 생략된 부분이지만 모든 검색조건마다 캐시에 결과를 저장할 필요는 없었다.
검색결과가 4000건 이상이 넘어갈때부터 체감될 정도로 응답 시간이 느렸지만 그 미만의 경우 1초 이내로 응답했기때문에 조건부로 검색된 데이터가 얼마이상일 경우에만 캐시에 저장하도록 구현해도 충분히 괜찮은 방법이었다.
캐시가 적용되지 않고 db에서 데이터를 가져왔을땐 로컬 테스트 기준으로 평균 2초 정도가 걸렸다. 위 이미지에서 2.7초가 걸린건 redis에 저장하는시간이 추가되어 2.7초가 소요된걸로 추측된다.
캐시가 저장된 이후에 응답 시간은 대략 0.7초가 걸렸다. 정확히 redis에서 캐시를 가져오는데 0.2초가 걸렸고 가져온 데이터를 처리하는 작업에 0.5초가 걸려 총 0.7초 정도 소요됐다. 데이터를 처리하는 작업 0.5초가 여전히 짧은 시간은 아니지만 db에서 데이터를 가져오는 시간은 기존 1.5~1.6초 에서 0.2초 수준으로 무려 80% 이상 감소했다. 테스트중에도 페이지마다 2초가 걸리던게 1초 미만으로 줄어들었으니 체감상 엄청난 차이가 느껴졌고 꽤나 만족스러운 결과였다.
사실 이 문제는 수정해야한다는 지시도 없었고 개인적으로 적용해본 내용이라 아쉽게도 실제 서비스에 반영되진 못했지만 그래도 이것저것 시도해보며 mongoDB에 대해 꽤나 많은 내용을 새로알게되었다 또한 언젠가는 검색속도를 개선시켜야할 때가 왔을때 이번에 시도했던 내용을 조금 다듬어 바로 적용할수 있을것이다.