들어가며
- 롤 e-스포츠 데이터를 다루는 개인 프로젝트 ‘NAR’에서 “챔피언 조합 통계(Combination)” API를 만들었습니다.
- 첫 구현은 메모리 중심 설계로 하였습니다. DB에서 GameParticipant 전부를 읽어 와 자바 컬렉션으로 그룹핑, 집계, 정렬, 페이징까지 처리하는 방식입니다.
문제상황
서비스 운영 중, java.lang.OutOfMemoryError: Java heap space
에러가 터짐. 로그를 확인해보니 챔피언 조합 통계 API가 반복적으로 호출됨.
운영 환경
- 인스턴스: AWS EC2 t3.micro (vCPU: 2, Memory: 1GB)
- JVM 옵션 : -Xmx512m
성능 테스트 (기존 코드)
정확한 진단을 위해 VisualVM 모니터링 툴을 이용하여
로컬에서 운영환경과 똑같은 환경의 스프링 컨테이너를 구축한 후 테스트해보았습니다.
부하 테스트는 Jmeter를 활용하여 초당 20개의 API콜을 지속적으로 요청하였습니다.
API URL : {{local}}/api/combinations/?champions=Galio


- CPU: 지속적인 높은 사용률(평균 60% 이상)과 잦은 스파이크 발생. GC 활동(파란색)이 CPU 사용량에 직접적인 영향을 미치는 것을 확인.
- Heap: Used Heap(사용된 힙)이 Max Heap(최대 힙 크기)에 거의 도달하는 Full GC가 빈번하게 발생. 메모리가 해제되지 못하고 쌓이는 공격적인 톱니 패턴(Sawtooth Pattern)으로 인해 심각한 GC 오버헤드가 발생함.

평균 1.8초의 지연시간, 최대 약 9초의 지연시간이 걸렸습니다.
HeapDump 스냅샷 파일을 생성하여 어느 부분에서
메모리를 많이 먹는지 확인해보았습니다.

개별 응답마다 6.9%의 메모리 점유율을 가지고 있는걸 확인할 수 있습니다.

더 깊게 들어가보니 ArrayList에서 7870개의 아이템을 가지고 있었습니다.
이는 서비스 코드의 GameParticipant를 담은 List와 일치하다는 것을 알았습니다.


DB단을 살펴보면

위와 같은 쿼리문으로 작동하는데 만약 필터링된 값이 하나도 없다면
지금과 같이 하나의 챔피언에 대하여 수천건의 데이터가 하나의 List에 담길 수 있는 구조였습니다.

문제점
- Repository에서 대량의 GameParticipant 엔티티를 List에 담아 반환 (가장 심각)
- GameParticipant를 TeamComposition 객체로 전부 변환
- 최종 조합 리스트를 정렬하는 과정(sorted())
- 메모리 내 페이징(subList())
주요 개선 전략
메모리 내에서 수행하던 모든 데이터 가공 작업을 DB가 직접 처리하도록 전면 수정하였습니다.
- Native Query와 GROUP_CONCAT
- 표준 JPQL로는 불가능한, 동적으로 계산된 '챔피언 조합' 문자열을 기준으로 그룹핑하기 위해 Native Query와 DB 고유 함수(GROUP_CONCAT)를 사용했습니다.
- CTE와 2단계 집계 패턴
- 1단계 집계 (CTE team_combinations): 먼저 조건에 맞는 (game_id, team_id)를 기준으로, 각 팀의 5명 챔피언 조합 문자열, 승패 여부 등 개별 조합 정보를 만듭니다.
- 2단계 집계 (Final SELECT): 1단계에서 만들어진 개별 조합들을 championCombination 문자열로 다시 그룹핑하여, 최종적인 빈도수, 승률, 최신 플레이 날짜 등을 계산합니다.


- DB 페이징 구현
- 정렬(ORDER BY)과 페이징(LIMIT)이 모두 DB에서 실행되도록 하여, 애플리케이션에는 최종 결과 페이지(예: 10건)의 DTO만 전달되도록 네트워크와 메모리 사용을 최소화했습니다.

성능 테스트 (개선 코드)
기존 측정과 동일하게 하였습니다. (초당 20개 API콜)


- CPU: CPU 사용률이 평균 15% 미만으로 크게 감소했으며, 스파이크 현상이 거의 사라짐. GC 활동이 0%로 유지되며 매우 안정적인 상태를 보임.
- Heap: 메모리 사용량이 현저히 낮아지고, 안정적인 Minor GC 패턴을 보임. Used Heap이 Max Heap에 훨씬 못 미치는 낮은 수준에서 관리되어 GC로 인한 부하가 완전히 해소됨.
똑같이 Heap Dump 파일을 분석해보았습니다.

이전과 달리 ResultSetImpl이 사라진 것을 볼 수 있습니다.
+) 기존 코드에서 6.9%의 ResultSetImpl이 가장 큰 용량이었는데 지금은 오히려 LaunchedClassLoader가 13.5%나 차지한 걸 보고 깜짝 놀랐다.. 이유는 상대적으로 공간 점유 비율이 달라진 것 뿐이었다. (이전 Heap Dump에서도 LaunchedClassLoader 용량이 똑같이 5,571,416B였다.)
향 후 계획
현재 DB에는 약 7만 2천 건의 데이터가 들어가있습니다.
약 10만 건이라고 가정해보겠습니다. (올 해 추가되는 데이터 고려)
그러면 기존 10년치 데이터 업데이트 + 앞으로 4년간(페이커 계약 4년 연장) 서비스를 운영한다했을때
약 140만 건의 데이터를 DB에 저장해야 합니다.
인덱싱
현재 기본적인 인덱싱은 적용되어 있으나, 데이터가 10배 이상 증가하는 상황에서는 더욱 정밀한 튜닝이 필수적입니다.
EXPLAIN을 통해 모든 조회 쿼리의 실행 계획을 분석하여, 불필요한 Full Table Scan이나 filesort가 발생하는 지점을 찾아내고
복합 인덱스 등을 활용하여 쿼리 성능을 최적화할 예정입니다.
단일 챔피언 기반 집계 테이블
실시간 집계 방식의 한계를 극복하기 위해, 단일 챔피언 기반의 집계 테이블을 우선적으로 도입합니다.
2개 이상의 챔피언 조합은 경우의 수가 기하급수적으로 증가하는 것에 비해 실제 사용 빈도는 낮으므로,
단일 챔피언(171개) 기반의 조합 통계를 미리 계산하는 것이 가장 효율적입니다.
현재 6시간 주기로 새로운 경기 데이터가 스케줄링을 통해 업데이트되고 있으며, 이 시점에 맞춰 집계 테이블 또한 동기화하여 데이터의 최신성을 유지할 계획입니다.
이 방식을 통해 어떤 챔피언을 검색하더라도 항상 빠르고 안정적인 성능을 보장하는 것을 목표로 합니다.
'나르지지' 카테고리의 다른 글
| 반복되는 일정 서비스 API콜에 대한 캐시 적용 및 전략 (3) | 2025.08.15 |
|---|---|
| DTO 프로젝션을 활용한 일정 서비스 성능 개선 (2) | 2025.08.15 |
| 8만 건 데이터 DB 마이그레이션 자동화 구축 (4) | 2025.08.06 |