익숙한 N+1 문제, 캐싱이 가져온 26배의 속도 차이
개요
스포츠 SNS 플랫폼을 운영하다가 앱 활용 중 피드 조회가 너무 느린 현상을 발견했다. Caffeine 캐시를 넣어서 평균 응답 시간을 800ms에서 38ms로 줄였다.
핵심 지표
- 응답 시간: 800ms → 38ms (95% 개선)
- DB 쿼리: 81개 → 1개 (98% 감소)
- 처리량: 3.8 req/s → 78.2 req/s (20배 증가)
1. 문제
1.1 피드 요구사항
피드는 두 종류다:
- 완료된 경기: 결과, 점수, 리뷰 포함
- 예정된 경기: 매칭 중, 예정, 진행 중 상태
정렬은 이렇게 해야 했다:
- 친구 참가 경기가 위로
- 그 다음 일반 경기
- 같은 우선순위면 최신순
각 피드 아이템에는 isFriend, isMyGame 같은 유저별 정보도 들어간다.
1.2 초기 구현
네이티브 쿼리와 UNION ALL로 친구 경기 우선 정렬을 구현했다.
fun getFeed(myUserId: Long): FeedResponse {
val query = entityManager.createNativeQuery("""
SELECT gr.id, 1 as priority FROM game_records gr
JOIN custom_game g ON gr.game_id = g.id
JOIN game_participation p ON p.game_id = g.id
JOIN friend f ON f.user_id = :myUserId AND f.friend_id = p.user_id
WHERE g.game_status = 'COMPLETED' AND f.status = 'ACCEPTED'
UNION ALL
-- 예정 경기 (친구) ...
UNION ALL
-- 완료 경기 (비친구) ...
UNION ALL
-- 예정 경기 (비친구) ...
ORDER BY priority, created_at DESC
""")
return results.map { toFeedItemResponse(it) }
}
1.3 성능 병목
측정 환경
- 게임: 500개
- 유저: 50명
- 친구 관계: 평균 5명
응답 시간

평균: 820ms
중앙값: 780ms
P95: 1200ms
P99: 1800ms병목 지점
쿼리 실행: 650ms (79%)
N+1 쿼리: 120ms (15%)
객체 매핑: 30ms (4%)
기타: 20ms (2%)문제 1: UNION ALL 다중 실행
UNION ALL이 4개의 독립적인 쿼리를 실행 후 병합한다. MySQL이 이걸 최적화 못 해서 Full Table Scan이 중복으로 일어난다.
EXPLAIN ANALYZE 결과:
- game_records (500 rows) × 2회 스캔
- custom_game (500 rows) × 2회 스캔
- friend (250 rows) × 2회 스캔
문제 2: N+1 쿼리
DTO 변환하면서 Lazy Loading이랑 추가 조회가 발생한다.
results.map { toFeedItemResponse(it) }
각 아이템당:
game.rule(Lazy Loading)game.participations(Lazy Loading)reviewRepository.findByGameId()commentService.getCommentCount()
실제 쿼리 수: 1 (메인) + 20 × 4 = 81 queries
문제 3: 페이지네이션 비효율
OFFSET 방식이라 offset 값만큼 row를 스캔하고 버린다. 페이지 뒤로 갈수록 느려진다.
2. 해결
2.1 캐싱 결정
피드 데이터 특성을 보니까 마치 교과서적인 상황처럼, 캐싱하기 좋았다.
- 읽기:쓰기 비율 = 100:1
- 완료된 경기는 안 바뀜
- 예정 경기도 자주 안 바뀜
2.2 왜 Caffeine인가
ConcurrentHashMap<Long, FeedItemResponse>로도 충분하지 않나? 처음엔 그렇게 생각했다.
// 단순 Map 방식
private val cache = ConcurrentHashMap<Long, FeedItemResponse>()
fun put(id: Long, item: FeedItemResponse) {
cache[id] = item
}
문제는 메모리 관리다. Map은 명시적으로 remove하지 않으면 계속 쌓인다. 게임이 매일 수십 개씩 생기는데 제거 로직을 직접 짜야 한다.
Caffeine은 이걸 알아서 해준다:
Caffeine.newBuilder()
.maximumSize(5000) // 5000개 초과하면 자동 제거
.recordStats() // 히트율, 제거 횟수 모니터링
.build()
- maximumSize: Window TinyLFU 알고리즘으로 접근 빈도 낮은 항목 자동 제거
- recordStats: 캐시 히트율 모니터링 가능. 튜닝할 때 필요함
직접 LRU 구현하는 것보다 검증된 라이브러리 쓰는 게 낫다.
2.3 Redis vs Caffeine
Redis랑 Caffeine을 비교했다.
| 항목 | Redis | Caffeine |
|---|---|---|
| 지연 시간 | 1-5ms (네트워크) | <0.1ms (인메모리) |
| 운영 복잡도 | 높음 (별도 서버) | 낮음 (임베디드) |
| 적용 환경 | 분산 환경 | 단일 인스턴스 |
단일 인스턴스라서 네트워크 I/O가 필요 없다. Caffeine으로 갔다.
2.4 아키텍처
[서버 시작]
↓
warmupCache()
├─ 최근 6주 완료 경기 → Completed Cache (최대 5000개)
└─ 예정/진행 중 경기 → Upcoming Cache (최대 2000개)
[조회 요청]
↓
1. 친구 목록 조회 (1 query)
2. 캐시에서 피드 조회 (0 query)
3. 메모리에서 정렬/필터링
4. 페이지네이션
↓
[응답]서버 시작할 때 DB에서 데이터 로딩하고, 이후 조회는 메모리에서만 한다.
2.5 설계 결정
TTL 안 씀
처음에는 expireAfterWrite(30, MINUTES)로 TTL을 줬다. 근데 생각해보니까:
- 워밍할 때 최근 6주 데이터만 로딩함
- 오래된 데이터는 애초에 캐시에 안 들어감
- TTL 관리가 불필요한 오버헤드
그래서 maximumSize로 메모리 상한만 제어하고, Caffeine의 Window TinyLFU 알고리즘이 알아서 제거하게 했다.
private val completedGameCache = Caffeine.newBuilder()
.maximumSize(5000)
.recordStats()
.build()
유저별 데이터 분리
처음에는 유저별 데이터(isFriend, isMyGame)를 캐시에 저장했다. 버그 났다. 모든 유저가 같은 캐시를 공유하니까 다른 사람 정보가 보이는 문제.
수정 전 (버그)
val feedItem = toFeedItemResponse(
gameRecord,
isFriend = gameService.isFriend(myUserId),
isMyGame = gameService.isMyGame(myUserId)
)
cache.put(gameRecordId, feedItem)
수정 후
// 캐싱: 유저 독립적 데이터만
val feedItem = toFeedItemResponse(
gameRecord,
isFriend = false,
isMyGame = false
)
cache.put(gameRecordId, feedItem)
// 조회: 동적 계산
fun getFeed(myUserId: Long): FeedResponse {
val myFriendIds = friendRepository
.findAllByUserIdAndStatus(myUserId, ACCEPTED)
.map { it.friend.id }
.toSet()
val allFeeds = cache.getAllFeeds()
return allFeeds.map { feedItem ->
feedItem.copy(
isFriend = feedItem.players.any { it.id in myFriendIds },
isMyGame = feedItem.players.any { it.id == myUserId }
)
}
}
유저 독립적인 데이터만 캐싱하고, 유저별 데이터는 조회할 때 계산하도록 바꿨다.
3. 구현
3.1 캐시 서비스
@Service
class FeedCacheService {
private val completedGameCache: Cache<Long, FeedItemResponse> =
Caffeine.newBuilder()
.maximumSize(5000)
.recordStats()
.build()
private val upcomingGameCache: Cache<Long, FeedItemResponse> =
Caffeine.newBuilder()
.maximumSize(2000)
.recordStats()
.build()
fun putCompletedGame(id: Long, item: FeedItemResponse) {
completedGameCache.put(id, item)
}
fun getAllFeeds(): List<FeedItemResponse> {
return completedGameCache.asMap().values.toList() +
upcomingGameCache.asMap().values.toList()
}
}
3.2 캐시 워밍
@PostConstruct
@Transactional(readOnly = true)
fun warmupFeedCache() {
val sixWeeksAgo = LocalDateTime.now().minusWeeks(6)
// 완료 경기 로딩
val completedGames = gameRecordRepository.findAll()
.filter { it.recordTime.isAfter(sixWeeksAgo) }
completedGames.forEach { gameRecord ->
try {
val feedItem = toFeedItemResponse(gameRecord, false, false)
feedCacheService.putCompletedGame(gameRecord.id, feedItem)
} catch (e: Exception) {
log.error(e) { "캐시 워밍 실패: ${gameRecord.id}" }
}
}
// 예정 경기 로딩
val upcomingStatuses = listOf(MATCHING, SCHEDULED, IN_PROGRESS)
val upcomingGames = gameRepository.findAll()
.filter { it.gameStatus in upcomingStatuses }
upcomingGames.forEach { game ->
try {
val feedItem = toUpcomingGameFeedItem(game, false, false)
feedCacheService.putUpcomingGame(game.id, feedItem)
} catch (e: Exception) {
log.error(e) { "캐시 워밍 실패: ${game.id}" }
}
}
log.info { "캐시 워밍 완료: ${completedGames.size + upcomingGames.size}개" }
}
워밍 시간은 500개 게임 기준 약 2초. 서버 시작 시간이 좀 늘어나지만 괜찮은 수준이다.
3.3 피드 조회
fun getFeed(myUserId: Long, page: Int = 0, size: Int = 20): FeedResponse {
// 1. 친구 목록 조회 (1 query)
val myFriendIds = friendRepository
.findAllByUserIdAndStatus(myUserId, FriendStatus.ACCEPTED)
.map { it.friend.id!! }
.toSet()
// 2. 캐시에서 피드 조회 (0 query)
val allFeeds = feedCacheService.getAllFeeds()
// 3. 유저별 데이터 계산 및 정렬
val processedFeeds = allFeeds
.map { feedItem ->
feedItem.copy(
isFriend = feedItem.players.any { it.id in myFriendIds },
isMyGame = feedItem.players.any { it.id == myUserId }
)
}
.sortedWith(
compareBy<FeedItemResponse> {
when {
it.isFriend && it.isCompleted -> 1
it.isFriend && !it.isCompleted -> 2
!it.isFriend && it.isCompleted -> 3
else -> 4
}
}.thenByDescending { it.date }
)
// 4. 페이지네이션
val startIndex = page * size
val endIndex = minOf(startIndex + size, processedFeeds.size)
val pagedFeeds = if (startIndex < processedFeeds.size) {
processedFeeds.subList(startIndex, endIndex)
} else {
emptyList()
}
return FeedResponse(
items = pagedFeeds,
totalPages = (processedFeeds.size + size - 1) / size,
currentPage = page
)
}
4. 성능 측정
4.1 응답 시간

| 지표 | Before | After | 개선율 |
|---|---|---|---|
| 평균 | 820ms | 38ms | 95% |
| 중앙값 | 780ms | 32ms | 96% |
| P95 | 1200ms | 65ms | 95% |
| P99 | 1800ms | 95ms | 95% |
4.2 쿼리 수
- Before: 평균 81 queries
- After: 1 query (친구 목록 조회)
DB 부하 98% 감소.
4.3 처리 시간 분해 (After)
친구 조회: 5ms (13%)
캐시 읽기: 1ms (3%)
정렬/필터링: 15ms (39%)
페이지네이션: 2ms (5%)
기타: 15ms (40%)대부분 메모리 연산이다.
4.4 메모리 사용량
완료 게임 250개: 22MB
예정 게임 250개: 23MB
총합: 45MB
최대 (5000개): 450MB 예상서버 메모리 2GB 대비 여유 있다.
4.5 동시성 테스트
Apache Bench로 부하 테스트:
ab -n 10000 -c 100 http://localhost:8080/api/feed
| 지표 | Before | After | 개선율 |
|---|---|---|---|
| Requests/sec | 3.8 | 78.2 | 20배 |
| Time/request | 820ms | 38ms | 95% |
5. 제약 사항
5.1 서버 재시작
서버 재시작하면 캐시가 날아간다. @PostConstruct로 자동 워밍되긴 하는데 2초 정도 걸린다. 트래픽 늘어나면 Redis 검토할 예정.
5.2 분산 환경
지금은 단일 인스턴스다. 서버 늘리면 각 인스턴스가 독립적인 캐시를 갖게 돼서 동기화 문제가 생긴다.
해결 방안
- Redis로 전환
- 캐시 무효화 이벤트를 Kafka로 브로드캐스트
- 로드밸런서 sticky session
5.3 친구 관계 변경
친구 추가/삭제 시 피드에 반영돼야 한다. 지금은 isFriend를 조회할 때 계산하니까 캐시 무효화 없이 바로 반영된다.
6. 정리
캐싱은 “빨라지게 하는 기술”이 아니라 “비싼 연산을 언제 한 번만 하게 만들 것인가”에 대한 선택이다.
핵심
- 유저 독립적인 데이터만 캐싱
- 유저별 데이터는 조회할 때 계산
- 서버 시작 시 캐시 워밍
결과
- 응답 시간: 820ms → 38ms (95% 개선)
- DB 쿼리: 81개 → 1개 (98% 감소)
- 처리량: 3.8 req/s → 78.2 req/s (20배 증가)
트레이드오프
- 서버 부팅 시간 증가
- 메모리 수백 MB 증가
- 코드 복잡도 증가