관련 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; // 각 파티션의 1등만을 사용한다
연산이 늘어나면서 상대적 비용이 커졌다.
지금은 용인 가능한 수준이지만, 이것을 실시간으로 계산하는 것은 지속가능하지 않다고 생각한다.
개인의 최대점수 계산을 게임 기록 저장 시점으로 앞당기면 어떨까?
공간 복잡도와 시간 복잡도를 트레이드 오프하는 것이다!
다음과 같은 장점이 있다:
- 큰 임시 테이블 단위의 계산이 적어진다.
- 조회에 드는 리소스가 절약된다.
- 쿼리의 복잡도가 줄어든다.
점수 집계 객체 사용
최고 점수를 BestScore
도메인 및 상응하는 테이블로 관리하도록 해보자.
(마이그레이션은 직접 진행했다)
쿼리
select rank() over (order by score desc) as `rank`, best.score
from best_score best
order by best.score desc
limit 50
인덱싱의 ’i’ 자도 히지 않았으나 벌써 조회 비용 차이가 명확하다.
오히려 복잡한 요구사항(개인 최대 점수)이 없을 때보다도 비용이 저렴해졌다.
논리적으로 생각해봐도 다음과 같은 이유들로 성능이 더 좋다:
- 사용자의 최대점수 계산에 비용이 들지 않는다.
- 그만큼 계산 범위가 줄어든다.
성능 외, 유지보수 관점에서 장점도 있다.
휴먼 에러 가능성이 있는 길고 복잡한 쿼리가 줄어든다는 것이다.
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; // 각 파티션의 1등만을 사용한다
vs
select rank() over (order by score desc) as `rank`, best.score
from best_score best
order by best.score desc
limit 50
물론 집계 객체(최고 점수)를 최신으로 유지하기 위한 어플리케이션 복잡도가 약간 증가할 수는 있다.
하지만 한 번 안정성을 확보해 놓으면 감수할만한 수준이라고 생각했다.
의존성 방향
위에서 생각한 내용을 바탕으로 집계 객체를 만들었다.
하지만 의존성 방향이 불편하다.
게임 플레이 컨텍스트에서 → 최고 기록 객체를 생성하고 있기 때문이다.
게임 플레이는 최고기록 집계에 영향받아서는 안된다.집계에 영향 없이 플레이 및 저장할 수 있도록 독립적이어야 한다.
IoC를 통해 의존성을 한 방향으로 정렬해주자.
나는 EventListener를 사용한 의존성 역전을 택했고, 이유는 다음과 같다:
- 최고 기록 저장이 단방향 소통이다 (반환이 필요 없다)
- 추후 다른 랭킹 시스템 추가를 고려해 보았을 때, 더 확장성이 좋다고 판단했다.
- 비동기 처리가 간단하다. (
테스트는 안간단하다 ㅎㅎ)
게임 종료 이벤트를 기반으로 여러 집계 객체로 확장하면 간단하다.
기술적으로 게임 종료와 사용자 랭킹 조회 사이에는 간극이 있으므로,
집계 저장을 기다리지 않고 HTTP Response를 먼저 보내줄 수도 있다.
쿼리 최적화
이제 테스트한 쿼리를 실전에 적용하고 다듬을 차례다.
AS-IS
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()가 이뤄지고 있다.
조금 개선해 볼 수 있을 것 같다.
TO-BE
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;
best_score
테이블에 대해서 type이 ALL
이다. 즉, full scan이 발생하고 있다.
best_score
에 접근하는 부분은 WITH
절 내부인데, 여기서부터 임시 테이블을 사용하므로 그 이후는 인덱싱이 불가하다.
다만 WITH
절 내부가 개선 가능해보인다.
score
, owner_id
를 커버링 인덱스로 만들어 Disk I/O를 덜어보자.
Action
create index best_score_score_owner_id_index
on best_score (score desc, owner_id asc);
WITH
절 내부의 쿼리 성능을 개선하기 위해 복합 인덱스를 추가했다.
TO-BE
인덱싱되어서 row가 50으로 줄었다. 이제 조회 속도는 데이터량에 크게 영향받지 않을 것이다.
끝?
~ 라고 예상했는데, 데이터 크기가 작아서인지 실제로 비용이나 속도의 개선이 없었다.
그렇다면 데이터를 추가로 넣어보자. 약 4000개 가량 데이터를 추가했다.
- 커버링 인덱스가 없을 때
- 커버링 인덱스 추가
역시 4000개를 넣으니까 상대적으로 비용이 증가했다.
나쁘지는 않지만, 진정 데이터수에 영향받지 않는다고 말하기는 어렵다.
차이가 극명하다!
위에서 가설을 세웠던 ‘이제 조회 속도는 데이터량에 크게 영향받지 않을 것이다’를 검증할 수 있었다.
결과
꽤 복잡한 요구사항(개인 최고 기록 기반 랭킹 시스템)을 만족하면서도, 소프트웨어의 복잡도는 낮추고, 성능은 크게 개선할 수 있었다.
쿼리에 있던 비즈니스를 어플리케이션 레벨로 올려 표현한것, 그리고 성능은 오히려 개선한 것이 성과라고 생각한다.
이렇게 트레이드 오프를 적절히 다뤄, 최대한 두마리 토끼를 잡는게 바로 기술을 활용하는 것이 아닐까?
앞으로도 이런 방향으로 성장해 나갈 수 있다면 좋겠다.
아쉬운 점
아직 사용자가 그렇게 많지 않아서 인덱스 과정이 꼭 필요하다고 하기 어렵다.
사실 간헐적 레이턴시 트러블 슈팅을 하면서 혹시? 하고 짚어보았던 부분인데, 이것도 아니었다 😇
또, 집계 객체 갱신 과정에서 동시성 문제를 발견했다.
하지만 이것까지 같이 하기엔 작업 단위가 너무 커지기에.. 다음 할 일로 남기고 성능 개선 여정을 마치도록 하자.