피드(게시물) 조회 성능 개선 (800ms → 30ms)

woojin·2025년 11월 5일

개요

스포츠 SNS 플랫폼 백엔드 개발 중 피드 조회 성능 문제가 발생했다. Caffeine 캐시를 도입하여 평균 응답 시간을 800ms에서 20~40ms로 개선했다.

핵심 지표

  • 응답 시간: 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를 스캔 후 버린다. OFFSET이 증가할수록 성능이 저하된다.


2. 해결 방안

2.1 캐싱 도입 결정

피드 데이터의 특성을 분석한 결과 캐싱에 적합하다고 판단했다.

데이터 특성

  • 읽기:쓰기 비율 = 100:1
  • 완료된 경기는 불변
  • 예정 경기 변경 빈도 낮음

2.2 캐시 라이브러리 선택

Redis와 Caffeine을 비교하여 Caffeine을 선택했다.

항목RedisCaffeine
지연 시간1-5ms (네트워크 I/O)<0.1ms (인메모리)
운영 복잡도높음 (별도 서버)낮음 (임베디드)
적용 환경분산 환경단일 인스턴스

단일 인스턴스 환경에서 네트워크 I/O가 불필요하므로 Caffeine을 선택했다.

2.3 아키텍처 설계

[서버 시작]
    ↓
warmupCache()
    ├─ 최근 6주 완료 경기 → Completed Cache (최대 5000개)
    └─ 예정/진행 중 경기 → Upcoming Cache (최대 2000개)

[조회 요청]
    ↓
1. 친구 목록 조회 (1 query)
2. 캐시에서 피드 조회 (0 query)
3. 메모리에서 정렬/필터링
4. 페이지네이션
    ↓
[응답]

서버 시작 시 DB에서 데이터를 로딩하고, 이후 조회는 메모리에서만 수행한다.

2.4 설계 결정 사항

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 응답 시간

지표BeforeAfter개선율
평균820ms38ms95%
중앙값780ms32ms96%
P951200ms65ms95%
P991800ms95ms95%

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
지표BeforeAfter개선율
Requests/sec3.878.220배
Time/request820ms38ms95%

5. 제약 사항 및 향후 과제

5.1 서버 재시작

서버 재시작 시 캐시가 초기화된다. @PostConstruct로 자동 워밍되지만 2초 정도 소요된다. 트래픽이 증가하면 Redis 도입을 검토할 예정이다.

5.2 분산 환경

현재는 단일 인스턴스 환경이다. 서버를 확장할 경우 각 인스턴스가 독립적인 캐시를 가지므로 동기화 문제가 발생한다.

해결 방안
1. Redis로 전환 (분산 캐시)
2. 캐시 무효화 이벤트를 Kafka로 브로드캐스트
3. 로드밸런서 sticky session 사용

5.3 친구 관계 변경

친구 추가/삭제 시 피드 반영이 필요하다. 현재는 isFriend를 조회 시점에 계산하므로 캐시 무효화 없이 즉시 반영된다.


6. 결론

복잡한 네이티브 쿼리와 N+1 문제로 인한 성능 병목을 Caffeine 캐시로 해결했다.

핵심 전략
1. 유저 독립적인 데이터만 캐싱
2. 유저별 데이터는 조회 시점에 동적 계산
3. 서버 시작 시 캐시 워밍

개선 결과

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

트레이드오프

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

0개의 댓글