Query Optimization / Cache
My FC Seoul
FC서울 팬이 직관 기록을 남기고 랭킹·선수 후원·경기 예측으로 시즌을 아카이빙하는 서비스입니다. 3인 팀에서 백엔드 도메인 설계와 조회 성능을 담당했습니다.
- 기간
- 2026.03 ~ 2026.05
- 유형
- 팀 프로젝트
- 역할
- 백엔드
직관 기록을 사진과 메모로만 흩어 두던 경험을, 한곳에 쌓이고 다시 꺼내 볼 수 있는 형태로 옮긴 서비스입니다. 프론트엔드 1명·백엔드 2명으로 구성된 팀에서 도메인 설계와 데이터베이스 모델링을 맡고, 직관 게시글·선수·랭킹·경기·베팅 API를 구현했습니다. 인증·인가는 다른 팀원이 Keycloak으로 담당했습니다.
Architecture/
전체 구조
단일 EC2 인스턴스 위에 컨테이너를 나눠 올리고, Nginx가 경로를 기준으로 애플리케이션 서버와 인증 서버로 트래픽을 나눕니다.

- 직관 기록·경기·선수·후원·랭킹·베팅·회원·관리자를 각각 독립된 도메인으로 나누고, 도메인마다 컨트롤러·서비스·리포지토리를 함께 두었습니다.
- 복잡한 조회는 Spring Data JPA 대신 QueryDSL로 분리해 타입 안전하게 작성했습니다.
Collaboration/
API 명세를 먼저 확정하고 병렬로 개발
화면과 서버가 동시에 진행되어야 했기 때문에, 코드를 쓰기 전에 주고받을 데이터의 모양부터 합의했습니다.
3인 팀에서 프론트엔드는 한 명이었습니다. 서버가 완성될 때까지 화면 작업이 멈추면 일정이 그대로 밀리는 구조였습니다. 그래서 기능 목록을 먼저 쪼갠 뒤, 각 기능마다 엔드포인트·HTTP 메서드·요청 바디·응답 바디와 상태 코드를 문서로 확정하고 시작했습니다.

- 백엔드는 명세를 기준으로 구현하고, springdoc으로 Swagger UI를 띄워 실제 응답을 바로 대조할 수 있게 했습니다.
- 브랜치는 Git-flow로 main·dev·feature/*를 나누고, 기능 단위 이슈 번호를 커밋 메시지에 붙여 작업 범위를 추적했습니다.
명세가 먼저 있으니 서로의 진행 속도가 달라도 막히지 않았고, 통합할 때 확인할 것이 '명세대로 왔는가' 하나로 줄었습니다. 이후 트러블슈팅도 대부분 이 경계 안쪽, 즉 서버가 같은 응답을 얼마나 빠르고 안전하게 만들어 내느냐의 문제였습니다.
Performance/
선수 랭킹 조회의 N+1 제거
선수 페이지는 현역 선수 전원과, 각 선수를 가장 많이 후원한 상위 3명의 닉네임을 한 번에 내려줍니다. 이 화면 하나가 후원받은 선수 수만큼 쿼리를 만들어 냈습니다.
문제/
후원 기록을 모두 읽은 뒤 기록마다 선수와 후원자 정보를 꺼내 쓰는데, 그 값에 접근하는 시점마다 조회가 따로 나가는 구조였습니다.
원인/
Donation은 User와 Player를 @ManyToOne(FetchType.LAZY)로 참조합니다. 지연 로딩된 연관은 프록시로 들어오고, 식별자 외의 필드를 읽는 순간 초기화되면서 각각 조회됩니다.
해결/
선수는 QueryDSL fetch join으로 함께 읽고, 후원자 닉네임은 Donation에 반정규화해 User를 아예 초기화하지 않도록 했습니다.

연관을 어떻게 가져올지는 둘로 나눠 판단했습니다. 선수 정보는 화면에 이름·등번호·포지션까지 그대로 나가야 하므로 fetch join으로 함께 읽었습니다. 반면 후원자에게 필요한 값은 닉네임 하나뿐이라, User를 통째로 끌고 오는 대신 후원 시점의 닉네임을 Donation에 직접 저장했습니다. 닉네임이 바뀌면 후원 기록의 값도 함께 갱신해 두 값이 어긋나지 않게 했습니다.
이렇게 하고 나면 나머지는 이미 메모리에 올라온 데이터를 다루는 일입니다. 후원 기록을 선수 id로 그룹핑해 Map을 만들고 — Key는 선수 id, Value는 그 선수가 받은 모든 후원 기록입니다 — 선수마다 포인트 내림차순으로 정렬한 뒤 상위 3개만 잘라 냈습니다.

2회
조회 쿼리 수
현역 선수 목록 1회 + 후원 기록 1회로 고정
선수 수와 무관
쿼리 증가량
선수가 늘어도 DB 접근 횟수는 그대로
0회
User 초기화
닉네임 반정규화로 후원자 조회 자체를 제거
순위 계산을 DB가 아니라 애플리케이션에서 한 것은 장점이라기보다 선택이었습니다. 선수마다 상위 3명을 DB에서 뽑으려면 선수 수만큼 쿼리를 보내거나 윈도 함수를 써야 하는데, 후원 기록은 fetch join으로 어차피 전부 읽어 온 상태였습니다. 그 위에서 정렬하고 자르는 편이 쿼리 수가 예측 가능하고 코드도 단순했습니다.
다만 이 방식은 후원 기록 전체를 메모리에 올렸습니다. 기록이 수십만 건 단위로 늘어나면 오히려 부담이 되고, 그때는 DB에서 선수별 상위 N명을 뽑아 오거나 집계 결과를 따로 보관하는 쪽이 맞습니다. 서비스 규모에 맞춰 내린 판단이라는 점을 인지하고 있습니다.
Caching/
조회 캐시 도입과 무효화 지점 설계
쿼리를 2회로 줄인 뒤에도, 선수 페이지에 들어올 때마다 같은 집계를 처음부터 다시 계산하고 있었습니다.
배경/
선수 명단과 후원 순위는 초 단위로 바뀌는 데이터가 아닙니다. 요청마다 전체 조회와 정렬을 반복할 이유가 없었습니다.
고민/
그렇다고 TTL만 걸면, 후원한 직후에도 예전 순위가 보입니다. 본인이 방금 한 행동이 반영되지 않는 것은 사용자가 바로 알아챕니다.
결정/
TTL로 기본 수명을 주되, 데이터가 실제로 바뀌는 지점마다 캐시를 명시적으로 파기하는 방식을 택했습니다.
선수 전체 조회 결과에 @Cacheable을 붙여 응답 리스트를 통째로 캐싱했습니다. 캐시 이름은 allPlayers, 키는 시즌 연도입니다. 시즌이 바뀌어도 같은 캐시 이름 아래 다른 키로 공존할 수 있는 구조입니다.

무효화 지점은 '이 캐시에 담긴 값이 달라지는 순간'을 기준으로 찾았습니다. 캐시를 하나 만들 때마다 그 안에 담긴 데이터를 건드리는 메서드를 전부 되짚고, 해당 메서드에 @CacheEvict를 걸었습니다. 캐시가 늘어날수록 이 대응을 머리로만 기억하기 어려워, 캐시와 무효화 지점을 표로 정리해 두고 작업했습니다.


실제로 의도대로 동작하는지는 Redis 키를 직접 들여다보며 확인했습니다. 선수 페이지에 접근하면 allPlayers 키가 생기고, 임의의 선수에게 후원하면 그 키가 즉시 사라집니다.


캐시가 적중하면 선수 전체 조회·후원 기록 조회·그룹핑·정렬이 전부 생략되고 메모리에 있는 결과가 그대로 나갑니다. 이 경로에서 DB 왕복은 0회가 됩니다.
프로젝트를 마친 뒤에는 Redis 대신 Caffeine 기반 스프링 캐시로 옮겼습니다. Redis는 애플리케이션 밖의 별도 프로세스라 캐시를 읽을 때마다 네트워크를 한 번 왕복하고 JSON 직렬화·역직렬화를 거치는데, Caffeine은 같은 JVM 안에 객체를 그대로 들고 있어 그 과정이 통째로 사라집니다. 서버가 한 대인 구성에서는 캐시를 프로세스 밖에 둘 이유가 없다고 판단했습니다.
Trouble Shooting/
캐시는 만들어졌는데 읽을 수 없던 문제
승률 랭킹 캐시가 정상적으로 생성된 것을 확인했는데, 같은 API를 한 번 더 호출하자 500이 떨어졌습니다.
증상/
캐시 생성까지는 성공. 재호출 시 500 Internal Server Error와 함께 Jackson의 InvalidDefinitionException이 발생했습니다.
원인/
Jackson은 JSON을 객체로 되돌릴 때 기본 생성자로 빈 객체를 먼저 만든 뒤 값을 주입합니다. 응답 DTO에 기본 생성자가 없었습니다.
해결/
캐시에 담기는 모든 response 객체에 @NoArgsConstructor를 추가했습니다.
쓰기는 성공하고 읽기만 실패하는 형태라 처음에는 원인을 찾기 어려웠습니다. 캐시 키가 생성된 것만 보고 정상으로 판단했기 때문입니다. 로그를 따라가 보니 Jackson 라이브러리의 InvalidDefinitionException이었고, 저장이 아니라 역직렬화 단계에서 터진 것이었습니다.

@AllArgsConstructor만 선언하면 자바가 넣어 주던 기본 생성자가 사라집니다. 애플리케이션 코드에서는 모든 필드를 채워 생성하니 아무 문제가 없었지만, JSON에서 객체를 복원하는 Jackson에게는 값을 넣기 전에 만들 빈 껍데기가 필요했습니다.
객체를 캐시에 넣는 순간 그 객체는 직렬화·역직렬화 계약을 함께 갖게 된다는 것을 알게 됐습니다. 이후 캐시나 응답으로 나가는 DTO는 기본 생성자 유무를 먼저 확인하고, 저장이 됐다는 사실만으로 정상이라 판단하지 않게 됐습니다. 뒤이어 캐시를 Caffeine으로 옮기면서 JSON을 거치는 단계 자체가 없어졌지만, 원인을 짚어 두지 않았다면 같은 문제를 다른 곳에서 다시 만났을 것입니다.
Query Optimization/
QueryDSL로 정리한 조회 경로
선수 랭킹 외에도 조회가 무거워지는 지점이 있었습니다. 반복 쿼리를 없애고, 집계는 DB가 잘하는 일로 남겼습니다.
과거 베팅 이력 조회는 지난 경기 목록을 가져온 뒤 경기마다 베팅 정보와 내 기록을 따로 조회했습니다. 1 + 2N번의 쿼리가 나가는 구조라 DTO Projection을 써서 한 번에 읽도록 바꿨습니다.
return jpaqueryFactory
.select(Projections.constructor(
BetHistoryResponse.class,
bet.id, betHistory.id, game.id,
// 상대팀 계산을 DB에서 처리 — FC서울이 홈이면 원정팀, 아니면 홈팀
new CaseBuilder()
.when(game.homeTeam.eq("FC서울")).then(game.awayTeam)
.otherwise(game.homeTeam),
game.date, bet.bettors, betHistory.totalPoint,
betHistory.winPoint, betHistory.drawPoint, betHistory.losePoint,
game.result, betHistory.payoutPoint
))
.from(betHistory)
.join(betHistory.game, game)
.leftJoin(bet).on(bet.game.id.eq(game.id))
.where(betHistory.user.id.eq(loginId), game.date.before(now))
.orderBy(game.date.desc())
.fetch();- 엔티티를 영속성 컨텍스트에 올리지 않고 응답에 필요한 컬럼만 DTO로 받습니다.
- 상대팀처럼 조건에 따라 갈리는 값은 CaseBuilder로 DB에서 계산했습니다.
직관왕·승률왕 랭킹은 반대로 집계를 DB에 맡겼습니다. GROUP BY로 집계하고, JPQL에 LIMIT이 없어 Pageable로 상위 3명만 잘라 왔습니다. 승률이 같으면 직관 경기가 많은 사람이 앞서도록 동점 처리 규칙도 ORDER BY에 함께 적었습니다.
한쪽은 애플리케이션에서, 한쪽은 DB에서 계산해 기준이 달라 보이지만 판단은 하나였습니다. 이미 메모리에 올라온 데이터라면 애플리케이션에서 다루고, 전체를 훑어야 결과가 나오는 집계라면 DB에서 끝냅니다.
- 포지션별 선수 조회는 같은 fetch join 쿼리에 where 조건만 바꿔 재사용했습니다.
- Q타입으로 필드명과 타입이 컴파일 시점에 검증돼, 문자열 쿼리에서 나던 오타를 미리 잡을 수 있었습니다.