관련 Issue & PR
목적
따라서 개인 최대 기록 기준으로 리더보드를 운영한다.
한 사람이 여러 번 랭크되는 것은 본인에게도 거의 의미가 없다고 판단했기 때문이다.
목표
개인 최고 기록으로 랭크 시스템을 운영한다.
쿼리 복잡성 및 성능도 개선한다.
수단
아래의 쿼리 비교는 같은 데이터량 기준으로 비교된다.
복잡한 쿼리 사용
기존 쿼리에 추가적인 필터링을 거쳐 구현해보았으나, 쿼리가 복잡해지고 성능이 배로 떨어졌다.
- 사용자별 최대 점수를 계산하지 않는 기존 쿼리
SELECT rank() over (ORDER BY score DESC) as ranking, session_id
FROM apple_game
WHERE is_ended
limit 50;
- 사용자별 최대 점수를 계산하는 쿼리
SELECT a.session_id,
a.owner_id,
a.score
FROM (SELECT session_id,
owner_id,
score,
RANK() OVER (PARTITION BY owner_id ORDER BY score DESC, created_at DESC) AS `rank`
FROM apple_game
WHERE is_ended) a
WHERE a.rank = 1;
낮은 성능에는 아직 인덱스가 없는 탓이 분명히 있다.
하지만 인덱싱을 해도 성능 개선에 한계가 있다.
PARTITION, RANK() 등 큰 임시 테이블을 다루는 함수들을 사용하기 때문이다.
개인의 최대기록 계산을 게임 저장 시점으로 앞당기면 어떨까?
- 큰 임시 테이블 단위의 계산이 적어진다 ← rank 함수를 한 번만 사용하게 된다.
- 조회에 드는 리소스가 절약된다.
- 쿼리의 복잡도가 줄어든다.
따라서 최고 점수를 BestScore
도메인 및 상응하는 테이블로 관리하도록 해보자.
점수 집계 객체(및 테이블) 사용
- 쿼리
select rank() over (order by score desc) as `rank`, best.score
from best_score best
order by best.score desc
limit 50
인덱싱의 ’i’ 자도 히지 않았으나 벌써 조회 비용 차이가 명확하다.
오히려 복잡한 요구사항(개인 최대 점수)이 없을 때보다도 비용이 저렴해졌다.
논리적으로 생각해봐도 다음과 같은 이유들로 성능이 더 좋다:
- 사용자의 최대점수 계산에 비용이 들지 않는다.
- 그만큼 계산 범위가 줄어든다.
성능 외, 유지보수 관점에서 장점도 있다.
오류 가능성이 있는 길고 복잡한 쿼리가 줄어든다는 것이다.
현재 쿼리가 문자열로 관리되는 상황이라 휴먼 에러 가능성이 있다.
집계 객체(최대 점수)를 최신으로 유지하므로 어플리케이션 복잡도가 약간 증가할 수는 있다.
하지만 한 번 안정성을 확보해 놓으면 감수할만한 수준이다.
의존성 방향
위에서 생각한 내용을 기반으로 집계 객체를 만들었다.
하지만 의존성 방향이 불편하다.
게임 플레이 컨텍스트에서 → 최고 기록 객체를 생성하고 있기 때문이다.
게임 패키지는 최고기록 집계에 영향받아서는 안된다.즉, 집계에 영향 없이 플레이 및 저장할 수 있도록 독립적이어야 한다.
IoC를 통해 의존성을 한 방향으로 정렬해주자.
나는 EventListener를 사용한 의존성 역전을 택했고, 이유는 다음과 같다:
- 최고 기록 저장이 단방향 소통이다 (반환이 필요 없다)
- 추후 다른 랭킹 시스템 추가를 고려해 보았을 때, 더 확장성이 좋다고 판단했다.
- 비동기 처리가 간단하다. (
테스트는 안간단하다 ㅎㅎ)
게임 종료 이벤트를 기반으로 여러 집계 객체로 확장하면 간단하다.
게임 종료와 사용자 랭킹 조회 사이에는 간극이 있으므로, 집계 저장을 기다리지 않고 HTTP Response를 먼저 보내도 된다.
쿼리 최적화
개선 전
select rank() over (order by score desc) as `rank`,
best.score,
m.id as owner_id,
m.name as owner_name,
mg.id as owner_group_id,
mg.name as owner_group_name
from best_score best
left join member m on m.id = best.owner_id
left join member_group mg on mg.id = m.group_id
order by best.score desc
limit 50;
rank() 함수는 limit 보다 먼저 평가되므로 전체 테이블에 대해 rank()가 이뤄지고 있다.
개선 후
WITH best AS (select score, owner_id
from best_score
order by score desc
limit 50)
select rank() over (order by best.score desc) as `rank`, best.score,
m.id as owner_id, m.name as owner_name, mg.id as owner_group_id, mg.name as owner_group_name
from best
inner join member m on m.id = best.owner_id
left join member_group mg on mg.id = m.group_id;
WITH 절로 작은 크기(50 rows)의 임시 테이블을 만들고, 그 다음에 rank()를 사용해 쿼리를 최적화했다.
이제 전체 랭킹은 데이터 수에 거의 영향받지 않는다.
인덱싱
- 대상 쿼리
WITH best AS (select score, owner_id
from best_score
order by score desc
limit 50)
select rank() over (order by best.score desc) as `rank`, best.score,
m.id as owner_id, m.name as owner_name, mg.id as owner_group_id, mg.name as owner_group_name
from best
inner join member m on m.id = best.owner_id
left join member_group mg on mg.id = m.group_id;
WITH 절 안의 테이블이 score로 정렬하여 owner_id를 select 하고 있다.
따라서 score 및 owner_id 에 대한 복합 인덱스가 필요하다.
개선 전
개선 후
create index best_score_score_owner_id_index
on best_score (score desc, owner_id asc)
인덱싱되어서 row가 50으로 줄었다. 이제 조회 속도는 데이터량에 크게 영향받지 않을 것이다.
~ 라고 예상했는데, 데이터 크기가 작아서인지 실제로 비용이나 속도의 개선이 없었다.
그렇다면 데이터를 추가로 넣어보자. 4000개 가량의 데이터를 추가했다.
- 인덱싱 전
(2~3초 - 0.1xx초)대가 나온다.
- 인덱싱 후
차이가 극명해졌다. 거의 10/1 수준이다.
결과
꽤 복잡한 요구사항(개인 최고 기록 랭킹 시스템)을 만족하면서도, 소프트웨어의 복잡도는 낮추고, 성능은 크게 개선할 수 있었다.
주관적인 영역이지만,쿼리에 있던 비즈니스를 어플리케이션 레벨로 올려 표현한것, 그리고 성능은 오히려 개선한 것이 성과라고 생각한다.
이렇게 트레이드 오프를 적절히 다뤄, 최대한 두마리 토끼를 잡는게 바로 ‘기술을 활용’하는 것이라고 생각한다.
앞으로도 이런 방향으로 성장해 나갈 수 있다면 좋겠다.
사실 집계 객체 갱신 과정에서 동시성 문제를 발견했다.
하지만 작업 단위가 너무 커져 다음 할 일로 남겨두고, 즐거웠던 성능 개선 여정을 마치도록 하자.