들어가며
앞선 모아온 조회 API 성능 개선기 (1) - 700만 건 테스트 데이터 생성에서 조회 성능 테스트를 위한 데이터 세팅 과정을 다뤘다.
700만 건의 테스트 데이터를 생성하고 나서 가장 먼저 마주한 문제는 Out Of Memory 에러였다.
이 글에서는 조회 API에서 발생한 OOM 문제의 원인과 해결 과정을 정리한다.
이 글에서 다루는 내용
- 페이징 API에서 발생할 수 있는 OOM 문제
- COUNT 쿼리를 잘못 사용한 사례
- fetch().size() vs COUNT 쿼리 비교
예상 독자
Spring Boot와 MySQL, QueryDSL을 사용하는 백엔드 개발자
1️⃣ Timeout 문제 발생
100만 건의 아티클 데이터를 넣고 다양한 케이스로 테스트하던 중, 특정 API 요청에서 5분이 지나도 응답이 오지 않아 Nginx Timeout이 발생했다. 로그와 모니터링 대시보드를 확인한 결과 Out Of Memory 에러가 발생한 것을 확인했다. 힙 메모리가 가득 차면서 서버가 더 이상 요청을 처리할 수 없는 상태가 된 것이다.
2️⃣ 문제가 발생한 API
OOM이 발생한 API는 "아티클 탐색" 기능이었다.
API 스펙
- 엔드포인트: GET /articles
- 쿼리 파라미터
- 검색: search
- 필터링: topics, techStacks, sector
- 정렬: sort
- 무한스크롤: limit, cursor
응답 구조
{
"totalCount": 1000000, // 전체 아티클 개수,
"hasNext": true,
"nextCursor": "2_8",
"contents": [
// 20개의 아티클
]
}
아티클 탐색 페이지에서 "00개의 아티클이 모여있어요"라는 문구를 표시하기 위해, 응답에서 totalCount 값을 함께 반환하고 있었다.

3️⃣ 원인 분석: 아티클 100만 개를 메모리에 올리고 있었다!!
문제는 totalCount를 계산하는 방식에 있었다.
문제의 코드
@Override
public long countWithSearchCondition(ArticleQueryCondition queryCondition) {
SearchKeyword searchKeyword = queryCondition.search();
Sector sector = queryCondition.sector();
List<Topic> topics = queryCondition.topics();
return jpaQueryFactory
.select(article.countDistinct())
.from(article)
.where(
equalSector(sector),
containsAllTopics(topics),
satisfiesMatchScore(searchKeyword)
)
.fetch() // ‼️ 전체 데이터를 메모리에 로드한다
.size(); // ‼️ List의 크기 측정해서 반환한다
}
문제 원인 분석
코드를 보면 select(article.countDistinct())로 COUNT 쿼리를 작성한 것처럼 보인다.
하지만 마지막에 fetch().size()를 사용하면서 문제가 발생했다.
fetch()는 쿼리 결과를 List로 가져오는 메서드다.
즉, WHERE 조건을 만족하는 100만 개의 Article 엔티티를 모두 메모리에 올린 후, 그 List의 크기를 측정하는 방식이었다.
실제 실행 흐름
1. DB에서 WHERE 조건을 만족하는 100만 개 Article 조회
2. 100만 개의 Article을 Java 객체로 변환
3. List<Article>에 100만 개 저장 (메모리 사용량 폭증)
4. list.size() 호출로 100만 반환
페이징으로 20개만 보여주는 API인데, totalCount를 위해 100만 개를 전부 메모리에 올리고 있었던 것이다.
데이터가 적을 때는 문제가 없었지만, 대량의 데이터 환경에서는 메모리가 부족해져 OOM이 발생했다.
4️⃣ 해결: COUNT 쿼리 제대로 사용하기
해결 방법은 간단했다. DB에서 개수만 세어 오도록 쿼리를 수정하면 된다.
개선된 코드
@Override
public long countWithSearchCondition(ArticleQueryCondition queryCondition) {
if (hasTechStackFilter(queryCondition)) {
return countWithTechStackFilter(queryCondition);
}
SearchKeyword searchKeyword = queryCondition.search();
Sector sector = queryCondition.sector();
List<Topic> topics = queryCondition.topics();
return Optional.ofNullable(
jpaQueryFactory
.select(article.count()) // COUNT 쿼리
.from(article)
.where(
equalSector(sector),
satisfiesMatchScore(searchKeyword),
containsAllTopics(topics)
)
.fetchOne() // ✅ 단일 숫자 값만 가져옴
).orElse(0L);
}
변경 사항
- 변경 전: fetch().size() - 전체 데이터를 List로 가져와서 크기 측정
- 변경 후: fetchOne() - COUNT 결과(Long 타입 하나)만 가져옴
이제 DB에서 개수만 세어서 하나의 숫자 값만 반환한다. 메모리에는 Long 타입 하나만 올라가므로 OOM 걱정이 없다.
5️⃣ 결과 및 교훈
개선 결과
- OOM 에러 해결
- totalCount 조회 시간 대폭 감소
- 메모리 사용량 정상화
배운 점
이번 경험을 통해 몇 가지를 배웠다.
- 익숙한 방법이 항상 옳은 것은 아니다
쿼리보다 Java 코드가 더 익숙해서 fetch().size()를 썼지만, 대량 데이터 환경에서는 치명적인 문제가 될 수 있었다. - 적은 데이터로는 문제를 발견하기 어렵다
80개, 1,000개 수준에서는 아무 문제 없었다. 하지만 100만 개가 되자 문제가 명확하게 드러났다. 성능 테스트용 대량 데이터가 필요한 이유를 실감할 수 있었다. - ORM은 편리하지만 실행되는 쿼리를 이해해야 한다
QueryDSL의 fetch()가 어떻게 동작하는지 정확히 이해하지 못한 채 사용했다. ORM을 쓰더라도 실제로 어떤 쿼리가 실행되는지, 데이터가 어떻게 로드되는지 이해하고 사용해야 한다.
OOM 문제를 해결했지만, 여전히 조회 성능은 만족스럽지 못했다. 특정 조건에서는 여전히 초 단위로 소요되는 쿼리들이 있었다.
다음 글에서는 쿼리 최적화와 인덱싱을 통해 성능을 개선하려고 시도한 과정을 다룰 예정이다.
'개발 > 기타' 카테고리의 다른 글
| 모아온 조회 API 성능 개선기 (3) - 쿼리 튜닝의 한계 (3) | 2025.12.01 |
|---|---|
| 모아온 조회 API 성능 개선기 (1) - 700만 건 테스트 데이터 생성 (0) | 2025.10.06 |