스포츠 SNS 플랫폼 백엔드 개발 중 피드 조회 성능 문제가 발생했다. Caffeine 캐시를 도입하여 평균 응답 시간을 800ms에서 20~40ms로 개선했다.
핵심 지표
피드는 두 가지 타입으로 구성된다.
정렬 우선순위는 다음과 같다.
1. 친구 참가 경기
2. 일반 경기
3. 동일 우선순위 내 최신순
각 피드 아이템에는 isFriend, isMyGame 등 유저별 정보가 포함된다.
네이티브 쿼리와 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) }
}
측정 환경
응답 시간 측정 결과

평균: 820ms
중앙값: 780ms
P95: 1200ms
P99: 1800ms
병목 지점 분석
쿼리 실행: 650ms (79%)
N+1 쿼리: 120ms (15%)
객체 매핑: 30ms (4%)
기타: 20ms (2%)
UNION ALL은 4개의 독립적인 쿼리를 실행 후 병합한다. MySQL은 이를 최적화하지 못하여 Full Table Scan이 중복 발생한다.
EXPLAIN ANALYZE 결과:
- game_records (500 rows) × 2회 스캔
- custom_game (500 rows) × 2회 스캔
- friend (250 rows) × 2회 스캔
DTO 변환 과정에서 Lazy Loading 및 추가 조회가 발생한다.
results.map { toFeedItemResponse(it) }
각 아이템당 발생하는 쿼리:
game.rule (Lazy Loading)game.participations (Lazy Loading)reviewRepository.findByGameId()commentService.getCommentCount()실제 쿼리 수: 1 (메인) + 20 × 4 = 81 queries
OFFSET 방식의 페이지네이션은 offset 값만큼 row를 스캔 후 버린다. OFFSET이 증가할수록 성능이 저하된다.
피드 데이터의 특성을 분석한 결과 캐싱에 적합하다고 판단했다.
데이터 특성
Redis와 Caffeine을 비교하여 Caffeine을 선택했다.
| 항목 | Redis | Caffeine |
|---|---|---|
| 지연 시간 | 1-5ms (네트워크 I/O) | <0.1ms (인메모리) |
| 운영 복잡도 | 높음 (별도 서버) | 낮음 (임베디드) |
| 적용 환경 | 분산 환경 | 단일 인스턴스 |
단일 인스턴스 환경에서 네트워크 I/O가 불필요하므로 Caffeine을 선택했다.
[서버 시작]
↓
warmupCache()
├─ 최근 6주 완료 경기 → Completed Cache (최대 5000개)
└─ 예정/진행 중 경기 → Upcoming Cache (최대 2000개)
[조회 요청]
↓
1. 친구 목록 조회 (1 query)
2. 캐시에서 피드 조회 (0 query)
3. 메모리에서 정렬/필터링
4. 페이지네이션
↓
[응답]
서버 시작 시 DB에서 데이터를 로딩하고, 이후 조회는 메모리에서만 수행한다.
초기에는 expireAfterWrite(30, MINUTES)로 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 }
)
}
}
유저 독립적인 데이터만 캐싱하고, 유저별 데이터는 조회 시점에 계산하도록 수정했다.
@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()
}
}
@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초다. 서버 시작 시간이 증가하지만 허용 가능한 수준이다.
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
)
}

| 지표 | Before | After | 개선율 |
|---|---|---|---|
| 평균 | 820ms | 38ms | 95% |
| 중앙값 | 780ms | 32ms | 96% |
| P95 | 1200ms | 65ms | 95% |
| P99 | 1800ms | 95ms | 95% |
DB 부하가 98% 감소했다.
친구 조회: 5ms (13%)
캐시 읽기: 1ms (3%)
정렬/필터링: 15ms (39%)
페이지네이션: 2ms (5%)
기타: 15ms (40%)
대부분의 시간이 메모리 연산에 소요된다.
완료 게임 250개: 22MB
예정 게임 250개: 23MB
총합: 45MB
최대 (5000개): 450MB 예상
서버 메모리 2GB 대비 충분한 여유가 있다.
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% |
서버 재시작 시 캐시가 초기화된다. @PostConstruct로 자동 워밍되지만 2초 정도 소요된다. 트래픽이 증가하면 Redis 도입을 검토할 예정이다.
현재는 단일 인스턴스 환경이다. 서버를 확장할 경우 각 인스턴스가 독립적인 캐시를 가지므로 동기화 문제가 발생한다.
해결 방안
1. Redis로 전환 (분산 캐시)
2. 캐시 무효화 이벤트를 Kafka로 브로드캐스트
3. 로드밸런서 sticky session 사용
친구 추가/삭제 시 피드 반영이 필요하다. 현재는 isFriend를 조회 시점에 계산하므로 캐시 무효화 없이 즉시 반영된다.
복잡한 네이티브 쿼리와 N+1 문제로 인한 성능 병목을 Caffeine 캐시로 해결했다.
핵심 전략
1. 유저 독립적인 데이터만 캐싱
2. 유저별 데이터는 조회 시점에 동적 계산
3. 서버 시작 시 캐시 워밍
개선 결과
트레이드오프