익숙한 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 피드 요구사항

피드는 두 종류다:

  • 완료된 경기: 결과, 점수, 리뷰 포함
  • 예정된 경기: 매칭 중, 예정, 진행 중 상태

정렬은 이렇게 해야 했다:

  1. 친구 참가 경기가 위로
  2. 그 다음 일반 경기
  3. 같은 우선순위면 최신순

각 피드 아이템에는 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 분산 환경

지금은 단일 인스턴스다. 서버 늘리면 각 인스턴스가 독립적인 캐시를 갖게 돼서 동기화 문제가 생긴다.

해결 방안

  1. Redis로 전환
  2. 캐시 무효화 이벤트를 Kafka로 브로드캐스트
  3. 로드밸런서 sticky session

5.3 친구 관계 변경

친구 추가/삭제 시 피드에 반영돼야 한다. 지금은 isFriend를 조회할 때 계산하니까 캐시 무효화 없이 바로 반영된다.


6. 정리

캐싱은 “빨라지게 하는 기술”이 아니라 “비싼 연산을 언제 한 번만 하게 만들 것인가”에 대한 선택이다.

핵심

  1. 유저 독립적인 데이터만 캐싱
  2. 유저별 데이터는 조회할 때 계산
  3. 서버 시작 시 캐시 워밍

결과

  • 응답 시간: 820ms → 38ms (95% 개선)
  • DB 쿼리: 81개 → 1개 (98% 감소)
  • 처리량: 3.8 req/s → 78.2 req/s (20배 증가)

트레이드오프

  • 서버 부팅 시간 증가
  • 메모리 수백 MB 증가
  • 코드 복잡도 증가