
목차
- 개요
- 초기 승리요정 랭킹의 문제점
- 성능 문제 - 비효율적인 쿼리 구조
- 공정성 문제 - 불합리한 랭킹 기준
- 공정한 랭킹을 위한 베이즈 정리 도입
- 기존 해결책들의 한계
- 베이즈 정리란?
- 베이즈 평균을 코드에 적용하기
- 부하테스트와 인덱스 최적화
- 첫 번째 부하테스트 - 충격적인 결과
- 인덱스 튜닝으로 개선 시도
- 통계 테이블로 근본적인 해결
- Redis를 선택하지 않은 이유
- 통계 테이블 설계
- 스케줄러 기반 데이터 관리
- 통계용 테이블 조회 성능 최적화
- 초기 통계 테이블 조회 쿼리
- 인덱스 실험들
- 서브쿼리 + FORCE INDEX 조합으로 해결
- 개선 결과
- 최종 부하테스트 결과
- 개선 전 결과
- 개선 후 결과
- 성능 개선 결과
개요
야구보구의 핵심 재미 요소인 승리요정 랭킹을 고도화하면서 겪었던 모든 과정을 담았다.
단순 승률 계산해서 시작해 베이즈 정리 도입, 인덱스 튜닝, 그리고 통계 테이블 설계까지 API 응답을 45초에서 139ms로, 약 323배 개선하기까지의 과정이다.
재밌게 감상해주세요.

초기의 승리요정 랭킹의 문제점
야구보구에는 개인의 승률 통계가 있다. 초기에는 큰 문제의식 없이 이 승률을 기반으로 승리요정 랭킹을 세웠다.
성능 문제 - 비효율적인 쿼리 구조
초기의 승리요정 랭킹 코드는 다음과 같은 흐름이었다.
1. 모든 멤버의 체크인(직관 인증) 기록을 한 번에 가져온다.
2. 쿼리 안에서 JOIN game을 사용해 경기 결과를 조인한다.
3. 각 멤버별로 직관한 경기 수와 그 중 본인이 응원하는 팀이 이긴 경기 수를 집계해서 승률을 계산한다.
4. 자바 코드에서 전체 결과 리스트를 승률 내림차순으로 다시 한 번 정렬한다.
5. 정렬된 리스트에서 상위 5명을 잘라서 응답한다.
6. 같은 리스트에서 내 인덱스를 찾아 "내 랭킹" 데이터를 만든다.
이 시점에는 모든 것이 check_ins + games 원본 테이블 기준으로 실시간 계산하는 구조였다.
여기서 두 가지 문제가 있다.
첫 번째 문제는 조인 비용이었다. 모든 체크인 행을 읽고, 각 행마다 게임을 조인한다는 게 너무 비효율적이다. 체크인이 1,000건만 되어도 1,000개의 조인이 수행된다.
두 번째 문제는 정렬 방식이었다. 쿼리 레벨에서는 정렬을 하지 않고, 모든 멤버의 랭킹 후보를 전부 가져온 뒤 자바 코드에서 정렬했다.
List<VictoryFairyRankingEntryResponse> memberCheckIns = checkInRepository.findVictoryFairyRankingCandidates();
그 다음 자바 코드에서 스트림을 이용해 정렬했다.
memberCheckIns.stream()
.sorted(Comparator
.comparingDouble(VictoryFairyRankingEntryResponse::winPercent).reversed()
.thenComparing(Comparator.comparing(VictoryFairyRankingEntryResponse::totalCheckIns).reversed())
.thenComparing(VictoryFairyRankingEntryResponse::nickname))
.toList();
결국 상위 5명만 보여주기 위해 모든 유저 데이터를 매번 메모리로 가져와서 정렬하는 비효율적인 구조가 되어버렸다.
초기에는 데이터가 적어서 괜찮다고 생각했고, "상위 5명"이라는 숫자가 화면 요구사항에 따라 바뀔 수 있다고 예상해서 서비스 레이어에서 유연하게 처리하려 했다. 하지만 이 선택이 데이터 양에 비례하는 비용을 가져왔고, 나중에 부하테스트에서 발목을 잡게 되었다.
사실 가장 큰 문제였던 공정성 - 불합리한 랭킹 기준
여기까지가 성능 상의 문제였고, 초반 승리요정 랭킹에서의 가장 큰 치명적인 문제가 있었다. 바로 공정성과 신뢰성이다.
승리요정 랭킹을 처음 설계할 때, “어떤 기준으로 점수를 매겨야 공정할까?”라는 질문이 계속 따라왔다.
단순 승률만으로 랭킹을 만들면 데이터가 적은 유저가 지나치게 유리하고, 직관 횟수만 강조하면 그건 승리요정 랭킹이 아니었다.
두 경우 모두 원하는 결과는 아니었다.
예를 들어보자.
헤비유저 A가 직관 인증을 20번 했는데 그중 10번을 졌다고 하자. 신규 유저 B는 인증을 2번 했는데 그 중 1번을 졌다. 그러면 A의 승률은 50%, B의 승률도 50%가 되어 A와 B의 랭킹 순위가 같아진다. 그리고 A는 직관을 많이 하면 할수록 승률이 떨어져서 랭킹 안에 못 들게 될 것이다.
이 문제는 야구보구 서비스의 치명적인 결함이라고 판단했다. 우리 앱에서 핵심 재미요소이고, 특히 랭킹은 서비스 내에서 가장 경쟁심과 참여도를 끌어올릴 수 있는 요소였기 때문에 이 랭킹만큼은 공정하게 순위를 매겨야 한다고 생각했다.
만약 이 문제를 고치지 않고 간다면 크게 두 가지 리스크가 있다고 보았다.
첫 번째, 헤비유저 이탈이다.
앱을 가장 많이 쓰고 애정이 있는 유저들이 랭킹에서 보상을 받지 못하니까 아무리 열심히 해도 소용없다고 생각하고 이탈할 가능성이 있다. 실제로 서비스의 핵심 가치는 이런 유저들이 만드는 건데, 절대 놓칠 수 없었다.
두 번째, 랭킹 시스템 자체에 대한 신뢰도 하락이다.
단 한 번만 인증했고 우연히 이긴 유저가 1위에 있으면, 다른 유저가 보기에 이 랭킹이 대체 뭘 의미하는건지 의문이 들 수밖에 없다. 심지어 악의적으로 경기가 끝날 때쯤 자기 팀이 이기면 그 때만 인증하는 유저가 있을 수도 있다.
공정한 랭킹을 위한 베이즈 정리 도입
이 문제를 해결하기 위해 여러 해결법을 찾아보았다.
첫 번째로 시도한 해결법 - 최소 직관 횟수 제한
최소 3번 이상 직관한 사람만 승률로 랭킹에 넣자는 방식이었다.
하지만 이 방법은 신규 유저를 자동으로 제외시키는 효과가 있었다.
직관 횟수가 적다는 이유만으로 랭킹에 아예 반영되지 않는 구조라 참여 장벽을 만들어버린다는 점이 가장 컸다.
그리고 “왜 하필 3번인가?”라는 기준을 정당화하기 어려웠다.
두 번째로 시도한 해결법 - 직관 횟수 가중치
승률만 보지 말고, 여기에 직관 횟수를 가중치로 곱해서 점수를 만들자라는 방식이었다.
이론상 그럴듯해 보였지만 실제로 적용해보면 문제가 많았다.
먼저 가중치를 조금 크게 잡아보았다. 예를 들어 k = 0.1이라고 하면 다음과 같은 상황이 나온다.
유저 A는 2경기 2승이다.
승률은 100%, 직관 횟수는 2번이다.
점수는 100 × (1 + 0.1 × 2) = 100 × 1.2 = 120점이 된다.
유저 B는 20경기 15승이다.
승률은 75%, 직관 횟수는 20번이다.
점수는 75 × (1 + 0.1 × 20) = 75 × 3.0 = 225점이 된다.
여기까지만 보면 그럭저럭 자연스럽다.
꾸준히 많이 직관하면서 승률도 높은 유저가 더 높은 점수를 받는다.
문제는 승률이 많이 낮은데도 직관만 매우 많은 유저가 있을 때이다.
유저 C가 40경기 20승이라고 해보자.
승률은 50%, 직관 횟수는 40번이다.
점수는 50 × (1 + 0.1 × 40) = 50 × 5.0 = 250점이 된다.
승률만 보면 B는 75%, C는 50%다.
누가 봐도 B가 “승리요정”에 더 가깝다.
그런데 이 가중치 공식에서는 C가 A와 B를 둘 다 이기고 1등으로 올라가버린다.
직관 횟수 가중치를 조금만 세게 주면, 승률이 어느 정도 이상이기만 하면 결국 많이 직관하기만하면 이기는 구조가 되기 쉽다.
그래서 가중치를 줄여 보았다.
이번에는 k = 0.01이라고 두고 다시 계산해봤다.
같은 조건에서 A는 2경기 2승이다.
점수는 100 × (1 + 0.01 × 2) = 100 × 1.02 = 102점이다.
B는 20경기 15승이다.
점수는 75 × (1 + 0.01 × 20) = 75 × 1.2 = 90점이다.
C는 40경기 20승이다.
점수는 50 × (1 + 0.01 × 40) = 50 × 1.4 = 70점이다.
이 경우에는 사실상 승률 순서대로 점수가 나와버린다.
직관 횟수의 영향력이 너무 약해서, 다시 단순 승률 기반 랭킹과 거의 차이가 나지 않는다.
결국 문제가 명확해진다.
가중치를 조금만 키우면 직관 횟수가 점수를 거의 압도하게 되고, 가중치를 줄이면 직관 횟수는 사실상 의미를 잃는다.
중간 어딘가에 적당한 값이 있을 것 같긴 했지만 그 값을 왜 선택했는지 설명할 수 있는 기준을 만들기가 매우 어려웠다.
예를 들어 누가 “k를 0.07로 둔 이유가 뭐냐”고 물으면, 대답이 결국 “여러 값 넣어보고 느낌이 괜찮아서요” 라고밖에 할 말이 없었다. 수치를 수학적으로 정당화할만한 다른 방식이 필요했다.
그래서 베이즈 정리가 뭔데?
그 수치를 수학적으로 정당화할만한 공식이 바로 베이즈 평균 정리였다.
Stanford Encyclopedia of Philosophy(SEP)는 베이즈 정리를 다음과 같이 설명한다.
Bayes’ theorem provides a principled way to revise or update one’s beliefs when new evidence is presented.
어떤 사건에 대해 우리가 가진 사전 믿음(prior)이 있고, 새로운 증거(evidence)가 들어왔을 때 그 믿음을 어떻게 조정할지(posteriors)를 알려주는 것이 바로 베이즈정리이다.
이 원리를 승리요정 랭킹 문제에 그대로 적용할 수 있다.
우리가 알고 싶었던 건 "이 사람이 진짜 승리요정인가?"였다.
즉, 이 사람이 경기장에 갔을 때 팀이 이길 진짜 확률이 어느 정도인지가 궁금한 것이다.
그러나 우리는 이 확률을 직접 관찰할 수 없다.
대신 지금까지의 직관기록이라는 증거(evidence)만 알 수 있다.
그리고 전체 사용자 집단의 평균 승률과 평균 직관 수라는 사전 지식(prior)도 갖고 있다.
이 구조는 SEP가 설명한 베이즈 정리와 완전히 일치한다.
→ 기존의 믿음(prior)에 새로 관측한 데이터(evidence)를 조합해서 갱신된 추정치(posterior)를 만든다.
사실 베이즈 정리를 그대로 사용하려면 너무 계산이 복잡해서 승리요정 랭킹에서는 이 베이즈 철학을 베이즈 평균 형태로 단순화해 적용했다.
- 1번이라도 인증한 모든 유저의 평균 승률(m)
- 1번이라도 인증한 모든 유저의 평균 직관 수(C)
- 개인의 승리횟수(W)
- 개인의 총 직관횟수(N)
공식은 다음과 같다.

직관 횟수가 적은 유저는 W, N이 작기 때문에 전체 평균을 나타내는 C, m의 영향력이 커진다.
감이 잘 안 올테니 숫자를 직접 대입해보자.
예를 들어 전체 평균 승률이 0.53, 평균 직관 횟수를 20이라고 해보자.
유저 A (1전 1승)
W=1, N=1, m=0.53, C=20
베이즈 점수는 다음과 같다.

승률 100%와는 완전히 다른 값이긴 하다.
여기서 분모를 보면 1 + 20 = 21로, 실제 데이터인 N=1보다 C=20이 훨씬 많이 반영됐다.
즉, 아직 이 사람을 100% 신뢰하기엔 데이터가 너무 부족하다고 판단하는 것이다.
유저 B (20전 15승) (데이터 충분)
W=15, N=20, m=0.53, C=20
베이즈 점수는 다음과 같다.

승률은 75%지만 베이즈 점수는 64% 정도로 내려가긴 했다.
여기서 분모는 20 + 20 = 40으로 비중이 거의 같아진다.
데이터가 충분해질수록 평균의 영향력이 줄고 점수가 실제 승률쪽으로 가까워진다.
결과적으로 베이즈 평균은 공정성과 신뢰도를 모두 만족할 수 있었고,
승리요정 랭킹 기준을 결정할 때 가장 자연스럽고 설득력 있는 선택이었다.
베이즈 평균을 코드에 적용하기
그럼 이 베이즈 정리를 어떻게 서비스 코드에 도입했는지 알아보자.
핵심은 db에서 한 번에 계산해서 쿼리 안에서 바로 score(베이즈점수)를 만들어 정렬하는 방식이라는 점이다.
우리가 쓰는 베이즈 점수 공식은 아래와 같다.

- 1번이라도 인증한 모든 유저의 평균 승률(m)
- 1번이라도 인증한 모든 유저의 평균 직관 수(C)
- 개인의 승리횟수(W)
- 개인의 총 직관횟수(N)
이 네 값을 전부 db에서 한 번에 계산해서 쿼리 안에서 바로 score을 만들어 정렬하는 방식으로 바꿨다.
그걸 위해 CheckInRepository에 QueryDSL 기반 커스텀 구현을 붙였다.
먼저, 전체 유저에 대한 통계(m, C)를 계산한다.
@Override
public double calculateTotalAverageWinRate(final int year) {
// 전체 승리 횟수와 총 직관 횟수를 계산해서 m 반환
// ...
return (totalCounts == 0) ? 0.0 : (double) winCounts / totalCounts;
}
@Override
public double calculateAverageCheckInCount(final int year) {
// 전체 직관 횟수 / 직관한 유저 수로 C 반환
// ...
return (perCheckInCount == 0) ? 0.0 : (double) totalCheckInCount / perCheckInCount;
}이 m,C 값들은 서비스 레이어에 저장해두고 다음 쿼리에 사용한다.
그 다음 개별 유저에 대해서는 베이즈 점수를 QueryDSL로 계산한다.
private NumberExpression<Double> calculateWinRankingScore(
final double m,
final double c,
final NumberExpression<Long> wins,
final NumberExpression<Long> total
) {
NumberExpression<Double> denominator = total.doubleValue().add(Expressions.constant(c)); // N + C
NumberExpression<Double> numerator = wins.doubleValue().add(Expressions.constant(c * m)); // W + C*m
return new CaseBuilder().when(denominator.ne(0.0))
.then(numerator.divide(denominator)) // (W + C*m) / (N + C)
.otherwise(0.0);
}이제 상위 n명의 랭킹을 쿼리에서 바로 가져올 수 있다.
return jpaQueryFactory.select(...)
.from(MEMBER)
.leftJoin(CHECK_IN).on(...)
.leftJoin(GAME).on(...)
.groupBy(MEMBER.id, ...)
.orderBy(score.desc()) // 베이즈 점수 기준 정렬
.limit(limit) // 상위 N명
.fetch();이전에는 서비스 레이어에서 전체 랭킹 리스트를 가져와서 정렬했지만,
지금은 쿼리로 정렬까지 한 후 상위 n명을 반환한다.
정리하면 다음과 같다.
베이즈 정리를 도입한 후에는
1. Repository에서 m, C인 전체 유저에 대한 통계를 먼저 계산하고
2. QueryDSL 쿼리 안에서 W, N과 함께 베이즈 점수를 만들고
3. 그 점수로 정렬 + limit + 내 순위 계산까지 디비에서 한 번에 처리한다.
베이즈 정리를 도입하게 된 이유는 공정한 랭킹 기준을 만들기 위해서였지만,
결과적으로 애플리케이션 레벨에서 하던 정렬 부담까지 줄어들어 성능 문제까지 해결할 수 있었다.
그런데... 정말 성능 문제가 해결됐을까?
부하테스트와 인덱스 최적화
첫 번째 부하테스트 - 충격적인 결과
승리요정 랭킹이 실제 트래픽에서 버틸 수 있는지 확인하기 위해
dev서버에 체크인(직관 인증) 데이터 110만 건, 유저 10만 명, 게임 1만개를 미리 넣어두었다.
그리고 홈 화면에서 승리요정 랭킹을 열어봤는데, 화면이 그냥 멈춰 있었다. 거의 1분은 기다려야 겨우 랭킹이 떴다.
문제는 두 가지가 있었다.
첫째, 하나의 API요청에서 무거운 쿼리가 6개나 실행됐다.
- m 계산 쿼리 1번
- c 계산 쿼리 2번
- 전체 랭킹 쿼리 1번
- 내 랭킹 쿼리 2번
총 6개의 무거운 쿼리가 한 번의 api 요청 안에서 돌고 있었다.
둘째, 각 쿼리가 매우 비효율적이었다.
각 멤버별로 check_ins를 조인하고, games까지 조인해서 필터링했다.
10만 명의 유저 × 110만 개의 체크인 × 1만 개의 게임을 조합해서 매번 다시 계산하는 구조였다.
모든 쿼리에서 너무 병목이 심했는데, 그 중 병목이 심했던 쿼리 중 하나인 전체 랭킹 쿼리만 살펴보자.
FROM members m
JOIN teams t ...
LEFT JOIN check_ins ci ...
LEFT JOIN games g ...
WHERE m.deleted_at IS NULL
GROUP BY
m.member_id,
m.nickname,
m.image_url,
t.short_name
ORDER BY score DESC
LIMIT 5;
EXPLAIN ANALYZE를 찍어보니 Nested loop join으로 약 23만 9,992 row를 생성하고, 최종적으로 6.6초가 소요됐다.
Using where; Using temporary; Using filesort
...
Aggregate using temporary table
Table scan on <temporary>
Sort: score DESC, limit input to 5 row(s) per chunk
다음과 같은 뜻이다.
- members 10만명을 한 번에 읽고
- 각 멤버에 대해 check_ins, games를 조인해서 W, N을 계산하고
- 그 결과를 임시 테이블에 다 쌓은 다음
- score(베이즈 점수) 기준으로 정렬하고
- 거기서 상위 5명을 자른다.
말만 들어도 너무 느리다.
인덱스 튜닝으로 개선 시도
실무 DBA님께서 "전체 쿼리 튜닝의 90%가 index로 해결된다"고 하셨다.
따라서 우리도 인덱스를 걸어서 이 문제를 해결해보려고 했다.
이 단계에서는 두 테이블에 집중해서 인덱스를 추가했다.
- check_ins : (member_id, game_id, team_id) 복합 인덱스
- games : (game_state, date) 복합 인덱스
두 인덱스 모두 필터링과 조인에 쓰는 칼럼을 한 번에 태워서 테이블 I/O를 최대한 줄이는 것이 목표였다.
check_ins 복합 인덱스 - (member_id, game_id, team_id)
승리요정 랭킹은 거의 모든 쿼리에서 check_ins를 사용한다.
이 멤버가 어떤 팀으로, 어떤 경기에서 체크인을 했는지가 핵심이기 때문이다.
(ex 포라가 롯데 팀으로 7/25 경기에 직관인증을 했음)
이 인덱스로 한 번의 인덱스 스캔으로 회원, 경기, 팀 정보를 모두 가져올 수 있게 됐다.
- 전체 랭킹 쿼리: 6.6초 → 3.7초
- m 계산 쿼리: 6.5초 → 1.3초
games 복합 인덱스 - (game_state, date)
랭킹을 만들려면 지난 1년 동안 완료된 경기들에서 이기거나 진 횟수를 세야한다.
따라서 대부분의 쿼리가 game_state = 'COMPLETED' AND date BETWEEN ... 조건을 사용한다. 기존에는 만 건을 읽고 조건에 맞는 800~900개만 남겼는데, 인덱스 적용 후에는 처음부터 865건만 읽게 됐다.
- type: ALL → range
- 읽는 row 수 → 10,050 → 865
- Extra → Using index (테이블을 거의 안 읽음)
특히 Using index가 찍힌 건 매우 중요한데, 이는 쿼리가 필요한 컬럼들이 인덱스 안에 있어서 실제 테이블을 거의 안 읽는다는 의미다.
인덱스 적용 후 k6 부하테스트 결과
멤버 10만 명, 체크인 110만 건 기준
- http_req_duration 평균: 약 44.92초
- min: 23.64s / median: 46.86s / max: 60s
- 실패율: 30% (일부 요청은 60초까지 걸리다가 타임아웃)
인덱스만으로는 한계가 있었다. 데모데이 때 구구가 우리 팀의 쿼리를 보시더니 "인덱스로는 무조건 한계가 있을 것 같으니 다른 방식을 고민해봐야겠다"고 말씀해주셨는데, 정말 그 시기가 왔다.
통계 테이블로 근본적인 해결
승리요정 랭킹 기능은 앱을 열면 바로 보이는 항목이다. 그래서 조회 횟수가 매우 많다.
반면 쓰기는 거의 없다. 한 사람이 하루에 직관하는 경기는 보통 1개, 최대 5개다.
즉, 최적화해야 하는 부분은 쓰기가 아니라 조회이다.
Redis를 선택하지 않은 이유
Redis는 빠르게 조회할 수 있는 장점이 있다. Redis 도입도 고려했지만 다음 이유로 선택하지 않았다.
- 규모가 Redis를 쓸 정도는 아니다.
- 실시간 반영이 필요 없다. 승리요정 랭킹은 하루에 한 번만 변하면 된다.
- 운영 복잡도가 증가한다. 메모리 기반이라 장애 시 데이터 유실 위험이 있고, 백업 구성이 필요하다.
- 결국 DB 조인이 필요하다. 랭킹에 표시할 닉네임이나 프로필 이미지 정보는 DB에서 가져와야 한다.
- 러닝 커브가 크다.
결론적으로 우리 서비스 규모에서 Redis는 과한 선택이었다.
통계용 테이블 설계
Redis를 쓰지 않기로 했다면, 어떻게 우리는 조회 성능을 개선할 수 있었을까?
결론은 매번 계산하지 말고, 미리 계산해두자는 전략이었다.
기존 방식의 문제는 랭킹을 조회할 때마다 10만 명의 유저 x 110만 건의 체크인 x 1만 건의 게임을 전부 조인하고 집계한다는 것이었다. 하지만 생각해보면, 승리요정 점수가 바뀌는 시점은 경기가 끝났을 때 딱 한 번 뿐이다.
그렇다면 경기가 끝날 때 점수를 미리 계산해서 저장해두고, 조회할 때는 그냥 꺼내 쓰면 되는 것 아닌가?
이게 바로 통계용 테이블을 설계한 이유였다.
CREATE TABLE victory_fairy_rankings (
member_id BIGINT,
win_count INT, -- W (승리 횟수)
attend_count INT, -- N (직관 횟수)
fairy_score DOUBLE, -- 베이즈 평균 점수
year INT, -- 시즌 구분
PRIMARY KEY (member_id, year)
);
중요한 부분은 W와 N을 저장한다는 점이다. 베이즈 공식에서 m과 C는 전체 유저 통계라 매번 계산해야 하지만, W와 N은 개인 데이터라서 증분 업데이트가 가능하다.
- 승리하면: W+1, N+1
- 패배하면: W그대로, N+1
이렇게 하면 경기 결과가 나올 때마다 해당 유저의 행만 딱 업데이트 하면 된다.
10만 명 전체를 다시 계산할 필요가 없다. 👍🏽
스케줄러 기반 데이터 관리
통계용 테이블은 두 가지 스케줄러에 의해 관리된다.
1. 매일 자정 (00:00) - 경기 결과 동기화 + 실시간 증분 업데이트
매일 자정에 스케줄러가 어제 경기 결과를 가져온다.
KBO 경기 결과를 가져와서 각 경기의 상태를 업데이트한다.
경기 상태가 COMPLETED로 바뀌면 이벤트가 발행되고, 해당 경기에 인증했던 사용자들의 통계만 업데이트한다.
if (game.getGameState().isCompleted()) {
applicationEventPublisher.publishEvent(new GameCompletedEvent(game.getId()));
}
하루에 야구 경기가 최대 5개니까 이 이벤트는 하루에 최대 5번 발행된다. 경기가 COMPLETED 상태로 확인되면, 그 경기에 직관 인증했던 사용자들만 골라서 통계 테이블을 업데이트한다.
이때 승리/패배/무승부에 따라 다르게 처리한다.
- 승리하면: 승리횟수(W)+1, 직관횟수(N)+1
- 패배하면: 승리횟수(W) 그대로, 직관횟수(N)+1
- 무승부하면: 둘 다 그대로 (랭킹에 반영되지 않지만, row는 만들어둔다. 0번 인증한 사람과 1번 인증한 사람은 UI가 다르기 때문이다.)
여기서 중요한 점은 "어제 경기 인증한 사용자들만" 업데이트한다는 것이다. 10만 명 전체를 다시 계산하는 게 아니라, 그 경기에 직관한 일부만 처리하면 된다. 그리고 W와 N이 바뀌면 나중에 조회할 때 다시 계산할 필요가 없도록 베이즈 점수도 바로 재계산해서 저장한다.
참고로 자정에 경기가 끝나지 않았을 수도 있다. 연장전이 길어지거나, 우천 지연이 있거나, KBO 페이지에 업데이트가 늦어질 수 있다. 이런 경우를 대비해 admin이 수동으로 경기 결과를 업데이트할 수 있는 API도 만들어두었다.
그래도 C, m 계산이 여전히 무겁지 않나?
맞다. 베이즈 점수를 계산하려면 전체 유저의 평균 승률(m)과 평균 직관 횟수(c)가 필요하다. 이건 전체 유저를 한 번 훑어야 해서 피할 수 없는 비용이다.
하지만 중요한 건 누가 언제 이 비용을 부담하느냐이다.
기존에는 사용자가 홈 화면을 열 때마다 이 계산이 돌았다. 사용자 1만 명이 하루에 평균 3번씩 앱을 열면, 3만 번의 무거운 계산이 실행됐던 것이다.
변경 후에는 경기가 끝날 때만 계산한다. 하루에 야구 경기는 최대 5개니까 자정에 딱 5번만 이 무거운 계산을 하면 된다. 비용 부담 주체가 "모든 사용자의 모든 요청"에서 "서버의 스케줄러"로 바뀐 것이다.
2. 매일 새벽 3시 - 정합성 검증
증분 업데이트 방식에는 한 가지 리스크가 있다. 바로 이벤트가 누락되는 경우이다.
예를 들어 자정에 네트워크 문제로 특정 경기의 업데이트가 실패했다면, 그 경기에 인증했던 유저들의 W, N이 실제 기록과 맞지 않게 된다.
이 문제를 해결하기 위해 매일 새벽 3시에 정합성 검증 배치를 돌린다. 자정에 동기화가 끝나고 3시간 여유를 두고 검증을 시작한다.
검증 흐름은 다음과 같다.
1. 어제 인증했던 사용자들을 조회한다.
2. 각 회원의 실제 체크인 기록에서 W, N을 처음부터 다시 계산한다.
3. 통계 테이블에 저장된 값과 비교한다.
4. 다르면 로그를 남기고 올바른 값으로 보정한다.
로그를 찍기 때문에 이벤트가 누락됐음을 인지할 수 있고, 나중에 원인을 추적할 수도 있다.
청크 단위 처리
정합성 검증을 할 때 또 하나의 고민이 있었다.
만약 어제 10만 명이 인증했다면..?(ㄷㄷ) 10만 명의 데이터를 한 번에 메모리에 올리면 서버가 터질 수 있다.
그래서 1,000명 단위로 끊어서 처리하기로 했다. 1,000명 조회하고, 검증하고, 저장하고, 다시 다음 1,000명 조회하고, 검증하고, 저장하고 이런 식이다.
트랜잭션도 1,000명 단위로 끊었다.
전체를 하나의 트랜잭션으로 묶으면 중간에 실패했을 때 전부 롤백되기 때문이다.
청크 단위로 트랜잭션을 분리하면 실패한 청크만 롤백되고 나머지는 유지된다.
전체 흐름 정리
23:00 경기 종료
↓
00:00 경기 결과 스케줄러 실행 → 어제 경기 결과 가져옴 → 완료된 경기마다 인증 회원들 점수 업데이트
↓
03:00 정합성 검증 스케줄러 실행 → 어제 인증한 회원들 대상으로 검증 → 혹시 누락된 데이터 있으면 보정
↓
이후 사용자들이 앱 열 때 통계 테이블에서 미리 계산된 점수로 승리요정 랭킹 계산
통계용 테이블 조회 성능 최적화
통계 테이블과 스케줄러 구조를 완성하고 나서 다시 부하테스트를 돌렸다. 결과는... 생각보다 아쉬웠다.
분명 기존보다는 훨씬 빨라졌지만, 여전이 응답 시간이 4~5초 정도 걸렸다.
10만 명의 유저 데이터가 담긴 통계 테이블에서 상위 5명을 찾는 과정이 병목원인이었다.
초기 통계 테이블 조회 쿼리
SELECT RANK() OVER (ORDER BY vfr.score DESC) AS `rank`,
m.member_id, vfr.score, m.nickname, m.image_url, t.short_name
FROM victory_fairy_rankings vfr
JOIN members m ON m.member_id = vfr.member_id
JOIN teams t ON t.team_id = m.team_id
WHERE vfr.game_year = 2025 AND m.deleted_at IS NULL
ORDER BY vfr.score DESC
LIMIT 5;
EXPLAIN ANALYZE를 찍어보니 4,735ms가 걸렸다.
실행 계획을 보니 Using temporary; Using filesort;가 나타났다.
아하 문제는 다음과 같았다.
1. victory_fairy_ranking 테이블 풀 스캔 → 99,061개 행을 모두 읽음
2. 각 행마다 members, teams를 조인 (members 조인 99,061번, teams 조인 99,026번)
3. 임시 테이블에 모든 데이터를 쌓음
4. score로 정렬
5. 정렬 후 상위 5명을 자름
조인이 필요한 이유는 랭킹 화면에 표시할 정보 때문이다.
통계 테이블에는 점수만 있지만, 실제로는 닉네임(members), 프로필 이미지(members), 팀 이름(teams) 같은 정보가 필요하다.
문제는 이 조인을 10만 번 하고 있다는 것이었다.
- members 조인: 닉네임, 프로필 이미지 가져오기
- teams 조인: 팀 이름 가져오기
전체 시간의 절반 이상을 잡아먹었다.
인덱스 실험들
목표는 조인 횟수를 줄이는 것이다.
10만 건 전체를 조인하지 않고, 상위 5명만 찾아서 그 5명에 대해서만 조인하면 된다.
그러려면 통계 테이블에서 인덱스를 타고 상위 5명을 빠르게 찾을 수 있어야 한다.
game_year로 필터링하고 score 순으로 이미 정렬된 인덱스가 있다면, 상위 5개를 쏙 뽑아올 수 있을 것 같았다.
실험 1 - (game_year, score DESC)
CREATE INDEX idx_vfr_year_score
ON victory_fairy_rankings (game_year, score DESC);
인덱스를 타고 상위 5명만 바로 가져올 거라 예상했지만,
실제로는 여전히 1,769ms가 소요됐고, EXPLAIN을 보니 MySQL 옵티마이저가 members 테이블을 먼저 풀스캔했다.
-> Table scan on m1_0 (cost=10149 rows=99160)
-> Single-row index lookup on vfr1_0 (100,009번 반복)
순서가 예상과 반대였다. 통계 테이블에서 상위 5명을 찾지 않고, members 10만 명을 먼저 읽고, 각각에 대한 통계 테이블을 찾고 있었다.
실험2 - 인덱스 칼럼 순서 바꾸기
혹시 인덱스 칼럼 순서 문제였나? 싶어서 여러 조합을 시도해보았다.
CREATE INDEX idx_vfr_year_score2
ON victory_fairy_rankings (game_year, member_id, score DESC);
CREATE INDEX idx_vfr_year_score3
ON victory_fairy_rankings (game_year, score DESC, member_id);
CREATE INDEX idx_vfr_year_score4
ON victory_fairy_rankings (member_id, game_year, score DESC);
결론적으로 EXPLAIN을 찍어봐도 기존 유니크 인덱스(member_id, game_year)만 사용했다. 새로 만든 인덱스는 possible_keys에는 나타났지만, 실제로는 선택되지 않았다.
그럼 왜 MySQL 옵티마이저는 내 인덱스를 무시했을까?
우리가 생각했을 때는 (game_year, score, DESC)를 사용하면
game_year=2025로 필터링된 데이터가 이미 score 순으로 정렬되어 있기 때문에 상위 5개만 읽고 멈출 수 있다(Early stop)는 것을 알고 있다.
하지만, 옵티마이저는 다음과 같이 계산해버린다.
내가 만든 인덱스(game_year, score DESC)를 사용하면 아래와 같이 생각한다.
1. victory_fairy_rankings에서 game_year=2025 필터링
→ 99,000개가 있음
2. 이 99,000개를 전부 조인해야 할 것 같은데?
(Early Stop을 제대로 계산 못함)
예상 비용: 99,000개 + 조인 작업
기존 인덱스(member_id, game_year)를 사용하면 아래와 같이 생각한다.
1. members 100,000명 읽기
2. deleted_at으로 필터링하면 많이 줄어들 것 같은데?
(실제로는 거의 안 줄어듦)
예상 비용: 100,000개 + 조인 작업
결과적으로 옵티마이저 입장에서는 둘 다 비슷하니까 기존 거 쓰자고 판단한 것이다.
서브쿼리 + FORCE INDEX 조합으로 해결
옵티마이저가 말을 안 들었기 때문에 강제하는 방식으로 해결했다.
SELECT RANK() OVER (ORDER BY vfr.score DESC) AS `rank`,
m.member_id, vfr.score, m.nickname, m.image_url, t.short_name
FROM (
SELECT STRAIGHT_JOIN vfr.member_id, vfr.score
FROM victory_fairy_rankings vfr
FORCE INDEX (idx_vfr_year_score)
JOIN members m ON m.member_id = vfr.member_id
WHERE vfr.game_year = 2025
AND m.deleted_at IS NULL
AND m.team_id IS NOT NULL
ORDER BY vfr.score DESC
LIMIT 5
) AS top_vfr
JOIN victory_fairy_rankings vfr
ON top_vfr.member_id = vfr.member_id AND vfr.game_year = 2025
JOIN members m ON m.member_id = vfr.member_id
JOIN teams t ON t.team_id = m.team_id
ORDER BY vfr.score DESC;
바뀐 부분들은 다음과 같다.
1. FORCE INDEX로 인덱스 강제하기
FROM victory_fairy_rankings vfr
FORCE INDEX (idx_vfr_year_score)
MySQL에게 내가 만든 인덱스를 쓰라고 강제한다. 이 인덱스는 이미 score 순으로 정렬되어 있다.
하지면 결론은 여전히 느렸다. 인덱스를 타서 데이터가 이미 정렬되어 있어도, WHERE 절에 m.deleted_at IS NULL 조건이 있으면 MySQL은 deleted_at을 체크하려면 일단 전부 조인해봐야 알 수 있다라고 판단한다.
결국 전체를 다 조인하고 필터링한 후 LIMIT을 적용한다.
2. 서브쿼리로 데이터 먼저 줄이기
SELECT ...
FROM (
SELECT vfr.member_id, vfr.score
FROM victory_fairy_rankings vfr
FORCE INDEX (idx_vfr_year_score)
JOIN members m ON m.member_id = vfr.member_id
WHERE vfr.game_year = 2025
AND m.deleted_at IS NULL
ORDER BY vfr.score DESC
LIMIT 5 -- 여기서 먼저 5개로 줄임!
) AS top_vfr
JOIN victory_fairy_rankings vfr ON ...
JOIN members m ON ...
JOIN teams t ON ...
서브쿼리 안에서 limit 5를 적용해서 데이터를 먼저 줄이기로 했다.
서브쿼리 내부에서 상위 5명의 member_id만 추출해서 그 정보에 대해서만 외부 쿼리에서 vfr, members, teams를 조인하는 방식이다.
여기서 한 가지 문제가 있었는데, 바로 옵티마이저가 서브쿼리 안에서 조인할 때 members를 먼저 읽는 경우이다.
옵티마이저는 기본적으로 조인 순서를 알아서 결정하기 때문에, 어떤 테이블을 먼저 읽을지 모른다.
만약 members를 먼저 읽으면 FORCE INDEX만 쓴 경우와 비슷하게 느려진다.
3. STRAIGHT JOIN으로 조인 순서 강제하기
FROM (
SELECT STRAIGHT_JOIN vfr.member_id, vfr.score
FROM victory_fairy_rankings vfr
FORCE INDEX (idx_vfr_year_score)
JOIN members m ON m.member_id = vfr.member_id
WHERE vfr.game_year = 2025
AND m.deleted_at IS NULL
ORDER BY vfr.score DESC
LIMIT 5
) AS top_vfr
옵티마이저는 조인 순서를 자동으로 결정하기 때문에 예를 들어 아래와 같이 쓰면
FROM victory_fairy_rankings vfr
JOIN members m ON ...
members를 먼저 읽고, vfr을 조인하는게 나을 것 같다고 판단할 수 있다.
따라서 victory_fairy_rankings를 먼저 읽고 (FORCE INDEX로 인덱스 타면서) 그다음 members를 조인하라고 강제했다.
이렇게 해야 인덱스를 타고 상위 5개를 빠르게 찾아서, 그 5개에 대해서만 members를 조인할 수 있다.
순서가 바뀌면 members 10만 명을 먼저 읽고 조인하게 되어 다시 느려진다.
개선 결과
| 항목 | 개선 전 | 개선 후 |
| 실행 시간 | 1,769ms | 0.2ms |
| 읽은 행 수 | 100,046개 | 6개 |
| members 조인 | 100,009번 | 약 10번 |
| teams 조인 | 100,009번 | 5번 |
약 8,845배나 개선됐다!
통계 테이블을 만드는 것과 조회 쿼리까지 최적화해야 진짜 우리가 원하는 만큼 빠른 응답을 만들 수 있었다.
최종 부하테스트 결과
부하테스트 환경
최종 부하테스트를 진행했다. 테스트 환경은 다음과 같다.
- 체크인 데이터: 110만 건
- 유저: 10만 명
- 게임: 1만 개
- 테스트 도구: k6
- 테스트 방식: 단일 요청 10회 반복
k6 테스트 스크립트
// single-request-test.js
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 1, // 사용자 1명
iterations: 10, // 10번 반복
};
const BASE_URL = 'http://<부하테스트서버ip>.80/api/check-ins/victory-fairy/rankings';
export default function () {
const url = `${BASE_URL}?team=ALL&year=2025`;
const params = {
headers: {
'Accept': '*/*',
'Authorization': AUTH_TOKEN,
},
};
const response = http.get(url, params);
check(response, {
'status is 200': (r) => r.status === 200,
});
}
개선 전 결과
- 평균 응답 시간: 44.92초
- 실패율: 30% (10번 중 3번 타임아웃)
- 최악의 경우: 1분 이상 (타임아웃)
개선 후 결과 (통계 테이블 + 인덱스 적용)
- 평균 응답 시간: 139.13ms
- 실패율: 0%
- 최악의 경우: 201.02ms
성능 개선 결과
| 지표 | 개선 전 | 개선 후 | 개선율 |
| 평균 응답 시간 | 44.92s | 139ms | 약 323배 |
| 실패율 | 30% | 0% | - |
| p(95) 응답 시간 | 1m+ | 177ms | - |
| 처리량 (req/s) | 0.0222 | 4.96 | 약 225배 |
개선 전에는 10번의 요청 중 3번이 타임아웃으로 실패했고, 성공한 요청도 평균 45초가 걸렸다. 개선 후에는 모든 요청이 성공했고, 평균 139ms 안에 응답이 돌아왔다.
마무리
사실 처음 승리요정 랭킹을 만들 때는 단순하게 생각했다. 승률 높은 사람이 1등이면 되는 거 아닌가?
그런데 직접 테스트해보니까 뭔가 이상했다.
한 번 직관하고 운 좋게 이긴 사람이 랭킹 1위에 올라가 있었다.
그때 든 생각이 "내가 저 유저라면 어떤 기분일까?"였다.
매주 시간 내서 야구장 가고, 앱 켜서 직관 인증하고, 그렇게 쌓아온 기록이 단 한 번 인증한 사람한테 밀린다면?
아마 이 기능 자체를 신뢰하지 않게 될 것 같았다. 랭킹이 랭킹으로서 의미가 없어지는 거다.
그래서 베이즈 정리를 도입했고, 통계 테이블을 설계했고, 인덱스를 튜닝했다. 45초를 139ms로 줄인 건 물론 뿌듯하다.
하지만 솔직히 더 뿌듯한 건 이제 꾸준히 야구장을 찾는 유저가 랭킹에서 제대로 인정받을 수 있게 됐다는 것이다.
이제 승리요정 랭킹이 진짜 승리요정을 보여줄 수 있게 됐다. 그냥 그게 제일 뿌듯했다.
'공뷰 > Spring' 카테고리의 다른 글
| [Spring, MySQL] 야구보구 현장톡의 모든 것 - 1편 (Polling, Cursor Pagination, 인덱스 튜닝) (0) | 2025.11.18 |
|---|---|
| [Spring, HikariCP] 커넥션 풀 고갈로 인한 서버 병목 해결하기 (0) | 2025.11.08 |
| [Spring] 야구보구의 실시간 기능, 배포 후 발생한 SSE 문제 정리(nginx 설정) (2) | 2025.10.05 |
| [Spring] Soft Delete와 영속성 컨텍스트 (flush, clear, JPA 엔티티 상태) (0) | 2025.09.29 |
| [Spring] 야구보구 홈 화면 SSE(Server-Sent Events)로 실시간 팬 현황 갱신하기 (5) | 2025.09.15 |