관련 Issue & PR
기존 랭킹 시스템
기존 랭킹 시스템은 mysql rank()
에 의존해 구현했다.
사실 단순한 조회이기 때문에 설계상의 문제는 없다고 생각한다.
하지만, 페이지 사이즈 만큼 쿼리가 나가는 과제가 남았다.
923ms까지도 걸리는 레전드 쿼리다…
분석
현재 상황
랭킹 조회는 다음과 같은 과정으로 진행된다:
- SQL로 랭킹, 세션 Id 리스트 조회
- 세션 Id 마다 알맞은 게임 세션 정보를 찾아 매핑
- 응답
sessionId
를 AppleGame
객체로 매핑하는 과정에서 추가 쿼리가 발생한다.
고민해보기
View 사용
최신화를 할 필요가 없는 복제 테이블은 없을까?
고민하다가 View라는 아이디어가 떠올랐다.
찾아보니 JPA는 꼭 테이블만 Entity로 매핑할 수 있는 것이 아니라, 뷰도 지원한다고 한다.
View를 사용하면 sessionId
를 어플리케이션에서 직접 매핑하지 않아도 된다.
DB에서 조인 후 가져온 결과를 Ranking 객체로 바로 매핑할 수 있다.
그리고 이 Ranking 객체들을 응답으로 내려주면 꽤나 유려하게 어플리케이션을 구현할 수 있다.
또한 어플리케이션은 Hibernate DDL validation에 의해 View 스키마가 최신임을 검증한 후 어플리케이션을 실행할 수 있다.
Downside - 유지보수
View는 Hibernate가 만들어줄 수 있는 부분이 아니며, DB에 저장되는 가상 테이블이자 쿼리문이다.
따라서 리더보드라는 중요한 비즈니스가 스키마 상에만 존재하게 된다.
사실상 MySQL에서 쓰던 Procedure와 다를게 없지 않을까?
리더보드를 수정하려면, 어플리케이션 코드를 만지는 게 아니라, DB에 저장된 View를 수정해야한다.
관심사가 잘못 분리된 상황인 것 같다.
Downside - 인덱싱 불가
view는 쿼리 그 자체이기 때문에 별도로 인덱싱이 불가능하다.
쿼리 캐싱 자체도 MySQL 8.0 이후로 사라졌다.
Join 사용
가져올 때 직접 Join을 해서 가져온 후, Ranking 및 AppleGame 도메인으로 매핑한다.
실행 결과
당연하게도 수십개의 쿼리가 사라지니 성능이 개선되었다.
하지만 유지보수 측면은 어떨까?
장단점
장점:
- DB에서 한번에 퍼오므로 연결 오버헤드가 적다.
- 항상 최신 상태이다.
- DB 스키마에 View를 관리 및 유지하는 비용이 없다.
단점:
- 어찌되었든 네트워크 연결 비용만 줄었을 뿐, 전송 용량은 동일하다.
- 상세 도메인을 직접 조립해야 한다.
- 랭킹 조회의 경우, Game 및 Member 정보가 필요하기 때문에 이를 직접 조립해야 한다.
- 어지러운 쿼리와 의존성으로 유지보수성이 꽝이다.
쿼리 관리 하실분 소온~? - 필요없지만,
AppleGame
객체가 JPA에 의해 영속성이 관리되는 상태가 아니라는 점은 알아두자.
의문점
하지만 이렇게 만들어진 AppleGame 도메인은 영속 상태의 도메인과 어떻게 구분해야 할까?
- 영속화 상태를 체크하면 된다. 별로 유려한 방법은 아닌 것 같다.
- 마찬가지로 영속 상태로 만들수도 있다.
게임 캐싱하기
다시 처음으로 돌아가서 ‘게임 점수’라는 도메인을 다시 생각해보자.
게임은 한 번 끝나면 바뀌지 않는 도메인이며, 우리의 목적은 게임에 대한 쿼리를 제거하는 것이다.
게임 자체를 캐싱하면 어떨까?
한 번 랭킹을 조회한 후에는, 랭킹 요청 시 쿼리가 거의 필요하지 않다!
Spring Cache 추상화를 사용해서 캐싱해보자.
@Cacheable(value = "games", key = "#sessionId", unless = "!#result.done")
default AppleGame getBy(Long sessionId) {
return findById(sessionId).orElseThrow(NoSuchSessionException::new);
}
우선 조회된 게임들 중 끝난 게임은 모두 캐싱하도록 했고, 캐시 크기는 100 정도로 설정했다.
과정은 다음과 같다:
이로써 최근 랭킹은 모두 캐싱되며, 새로 추가된 랭킹 몇 개만 쿼리를 날리게 된다.
결과
- 첫 요청
- 두 번째 요청부터:
- 새로 기록을 새운 경우:
아직 캐싱된 내용이 없어 쿼리가 그대로 나간다.
두 번째 요청부터 준수한 성능을 보인다.
최소한의 쿼리만 발생한다 👍
우선 이렇게까지만 해보고, 랭킹 테이블을 운영할지는 뒤에 고민해보도록 하자.
랭킹 테이블 운영하기
랭킹이라는 테이블을 만들어 운영하는 방법도 있다.
조회 성능이 훨씬 개선될 것이다.
대신 게임 쓰기 성능이 살짝 감소할 수도 있다.
왜냐하면, 게임을 마칠 때마다 삽입이 발생하기 하기 때문이다.
랭킹 테이블을 만들지 않은 이유
다음과 같은 문제들이 있었다:
- 단순 조회이므로 해당 객체가 할 일이 딱히 없다.
- 지속적으로 최신화 해줘야 한다 → 비용 발생 + 최신 정보가 아닐 수 있다
- 50개가 딱 맞도록 유지해야 한다
- 순위를 계산하는 쿼리(
rank()
)는 결국 개선되지 않는다.