서문
이전 DTO 프로젝션 적용 후, 응답 시간이 두 자릿수(ms) 수준으로 개선되어 사용자 경험 측면에서는 불편함이 거의 사라졌습니다.
그러나 일정 서비스의 과거 게임 데이터는 불변성이라는 특성을 가지고 있기에, 캐시를 활용하여 더욱 최적화를 할 수 있다고 판단하였습니다.
본문
서비스 특성 고려하여 캐싱 전략 수립
LCK 일정 서비스 업데이트 주기가 6시간 간격으로 스케줄링을 통해 저장됩니다.
하지만 오늘 경기가 아직 진행중일 경우 절반만 업데이트되는 경우가 있을 수 있습니다.
이럴 경우를 대비해 총 5가지의 캐시로 분류하였습니다.
- 오늘 경기 일정

- 오늘 경기 상세정보

- 과거 경기 일정
- 과거 경기 상세정보
- 기록 페이지
오늘 경기 일정, 상세정보는 과거 데이터들과 다르게 스케줄링이 성공하면 @CacheEvict를 통해 무효화 하였습니다.

중앙 캐시 vs. 로컬 캐시
캐싱 방식 중 어떤 것을 선택할 지 고민하였습니다.
- 중앙 캐시(Redis, Memcached 등): 여러 서버 인스턴스가 동일한 캐시 데이터를 공유하므로, 분산 환경에서 데이터 일관성을 유지하기에 유리합니다.
- 로컬 캐시(Caffeine, Ehcache 등): 각 서버 인스턴스의 메모리에 캐시를 저장합니다. 네트워크 지연 없이 가장 빠른 응답 속도를 보장하지만, 서버 간 데이터 동기화 문제가 발생할 수 있습니다.
저희 서비스의 특성상 현재 단일 서버 환경에서 운영 중이며, 가장 빠른 응답 속도와 단순한 구조가 필요하다고 판단했습니다. 또한, 불변 데이터의 특성상 캐시 동기화 이슈에 대한 부담이 적으므로, 네트워크 왕복 비용이 없는 로컬 캐시를 선택했습니다.
+) 두 캐시를 비교하며 추가적인 차이도 알게되었습니다.
- 로컬 캐시: 데이터를 JVM 힙 메모리에 자바 객체 그대로 저장합니다. 이는 별도의 직렬화 과정이 없어 매우 빠르고 적은 메모리 공간을 차지합니다. (동일 객체에 대하여 JSON 직렬화보다 더 적은 바이트 공간을 차지한다는 것을 확인하였습니다.)
| JSON 크기 | 자바 객체 크기 | |
| 상세정보 DTO | 4,297B(압축 시 777B) | 448B |
- 중앙 캐시: 데이터를 네트워크로 주고받기 때문에 바이트 배열 형태로 직렬화하여 저장합니다. 이때 JSON 직렬화 외에도 바이너리 직렬화나 압축 기술을 활용할 수 있습니다.
캐시 동작 흐름
과거 데이터 읽기 작업과 불변 데이터의 특성으로 Cache Aside 패턴을 적용하였습니다.

로컬 캐시 구현 : Caffeine 선택
복잡한 기능은 필요없으므로 로컬 캐시 중에서 고성능,최적화된 Caffeine을 채택했습니다.
- 효율적인 캐시 제거 정책 (W-TinyLFU): 캐시가 가득 찼을 때 효율적인 알고리즘을 사용하여 메모리 사용량을 효과적으로 관리
- Spring Boot와의 통합 용이성: @Cacheable, @CacheEvict 등의 어노테이션으로 간편하게 적용 가능
캐시에 담을 DTO 크기 측정
최대한 효율적으로 메모리 용량을 설정하기 위해 캐시에 담을 DTO 크기를 측정해서 설정하기로 하였습니다.
| 일정 서비스 | 상세정보 서비스 | 기록 서비스 | |
| DTO 크기 | 128B | 424B | 608B |
| 캐시 크기 설정 | 1MB | 4MB | 6MB |
| 최대 저장 가능 개수 | 약 8000개 | 약 9800개 | 약 1만개 |

위 DTO 측정 클래스를 만들고 추가적인 build.gradle 설정과 VM options를 세팅하였습니다.
-javaagent:$PROJECT_DIR$/build/libs/nar-agent-0.0.1.jar --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED
-> String, LocalDate와 같은 내부 필드 접근을 허용하기 위해 VM Options 설정 필요
maximumSize와 maximumWeight 중 고민
- maximumSize
- 장점 : key 개수 기반이라 확인하기 용이
- 단점 : DTO가 크게 유동적이면 측정하기 힘듦
- maximumWeight
- 장점 : 정확한 메모리 계산 가능
- 단점 : VM Option과 build.gradle 추가 해야하는 코드들 많음 → 배포환경 잠재적 에러 가능성
결론적으로 제가 선택한 건 maximumSize입니다.
- 로컬환경에서 이미 각각의 DTO크기들 확인하여 최대 메모리 계산 완료
- 서비스 특성상 평균치를 넘는 DTO발생 가능성 희박
- 가장 유동적인 DTO가 상세정보 서비스인데 최대 경기 수 5개인 경우도 평균치와 24Byte 차이밖에 안남(448 - 424)

- maximumWeight에 비해 추가 설정 필요 없음
성능 비교
기존 두 자릿수(ms) 응답 속도에서 개선 후 한 자릿수(ms) 응답 속도가 나오는 것을 확인하였습니다.
| 일정 서비스 | 상세정보 서비스 | 기록 서비스 | |
| 개선 전 | 31ms | 53ms | 57ms |
| 개선 후 | 5ms | 7ms | 8ms |
estimatedSize()와 asMap().keySet()을 활용하여 잘 저장되는지 확인하였습니다.

'나르지지' 카테고리의 다른 글
| SQLite와 MySQL 동시쓰기 비교해보기 (0) | 2025.12.05 |
|---|---|
| MySQL에서 SQLite로 마이그레이션 한 이유 (1) | 2025.11.15 |
| DTO 프로젝션을 활용한 일정 서비스 성능 개선 (2) | 2025.08.15 |
| 8만 건 데이터 DB 마이그레이션 자동화 구축 (4) | 2025.08.06 |
| OOM 원인 API, DB 최적화로 해결하기 (4) | 2025.07.31 |