이번 포스트에서는 랜덤 문제 풀이를 구현하면서 "랜덤한 순서"는 되어도 "랜덤한 문제"는 아니었던 저의 단순한 사고의 개발 과정과 고민했던 내용들을 정리해보았습니다.
단순한 사고 ONE
프론트: 범블비님 단어 퀴즈 API에서 랜덤 기능 추가해 주세요~
범블비: 네네~ 금방 만들겠습니다!
기존에 "퀴즈 조회 API에서 랜덤 요청 필터가 들어오면 단어를 한 번 섞어서 리턴하면 되겠다!"라고 생각하고 개발을 진행했습니다.
프론트에서 limit, page, ... 여러 필터 파라미터를 받아서 그 기준으로 데이터를 조회하고 배열에 담아서 Fisher-Yates 알고리즘으로 한 번 뒤죽박죽 섞어주면 될거라고 생각했습니다
// Fisher-Yates 알고리즘
shuffleArray<T>(array: T[]): T[] {
{
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
}
그런데 여기서 놓친 부분이 있었습니다. 위와 같이 DB에서 조회한 데이터를 기준으로 배열의 순서를 바꾸는 건 "랜덤한 순서"는 되어도 "랜덤한 문제"는 아니었던것이죠
단순한 사고 TWO
데이터부터 랜덤하게 조회하기 위해서 orderBy에 RAND()를 사용해서 랜덤한 문제를 뽑아오고, 페이지네이션을 통해 나눠서 보여주는 구조로 수정하였습니다.
처음에는 단순하게 seed를 넘겨받아 사용하도록 구성하였습니다.
"seed는 단어 섞는 '열쇠' 같은 거예요.
같은 seed로 요청하면 항상 같은 순서로 단어가 섞여요.
퀴즈 시작할 땐 새로운 seed로 요청하고, 퀴즈 중엔 계속 그 seed로 요청하면 돼요."
queryBuilder.orderBy('RAND()', 'ASC').take(limit).skip(offset);
queryBuilder.orderBy('RAND(:seed)', 'ASC').setParameters({ seed });
이렇게 하면 seed 기반으로 랜덤 정렬도 되고, offset/limit으로 페이징도 되니까 괜찮다고 생각했습니다.
그런데 위 방식에도 예상치 못한 문제가 있었습니다.
- LIMIT 5만 필요해도 → 전체 row를 메모리에 올리고 계산하고 정렬해야 함
- 특히 인덱스를 사용할 수 없어서 풀스캔 + 정렬 연산이 발생
- 데이터가 많거나 동시에 많은 사용자가 호출하면 DB에 큰 부하가 발생
조금 더 고민해보자,,
- 랜덤 셔플은 유지하면서
- 페이지네이션으로 중복 없이 순서대로 문제를 보여주고
- 사용자마다 동일한 시드(seed)로 요청하면 항상 같은 순서로 퀴즈가 진행되며
- 성능적으로도 문제없는 구조를 만들 수 없을까?
SortIndex를 미리 계산해두는 방식
랜덤한 순서를 미리 계산해서 정렬 기준으로 저장해두고, 이후에는 그냥 그걸 기준으로 페이지네이션해서 가져오는 방식
SELECT * FROM question
WHERE userId = 123
ORDER BY sortIndex ASC
LIMIT 5 OFFSET 0;
이 방식을 쓰기 위한 준비
1. question 테이블에 sortIndex 컬럼 추가
ALTER TABLE question ADD COLUMN sortIndex INT;
2. 퀴즈 시작 시 서버에서 사용자 데이터를 랜덤하게 정렬
await this.questionRepository.query(`
UPDATE question
SET sortIndex = FLOOR(RAND(:seed) * 1000000)
WHERE user_id = :userId
`, { seed, userId });
- 이 작업은 퀴즈 시작할 때 딱 한 번만 실행
- 이후에는 sortIndex만 정렬 기준으로 사용
3. 실제 조회 쿼리에서는
queryBuilder.orderBy('question.sortIndex', 'ASC');
queryBuilder.take(limit).skip(offset);
이렇게 하면 RAND()를 실시간으로 돌리지 않아도 되니까 랜덤 정렬을 미리 계산해두는 전략이 성능 향상에 더 효과적입니다.
마무리
단순하게 생각하고 개발해서 랜덤인 척!?해서 사용자 경험을 해칠 수 있었는데 리턴되는 응답 값이 이상함을 느끼고 문제를 빠르게 파악해서 "랜덤 한 순서"가 아닌 "랜덤 한 문제"로 수정하고 성능까지 신경 써서 개발하는 경험을 하였습니다.
사용자들이 느낄 수 없을 수도 있지만 작지만 소중한 경험을 제공해 드린 것 같아서 뿌듯한 경험이 추가되었습니다!
'아키텍처 고민' 카테고리의 다른 글
Node.js 버전 차이로 인한 장애 대응기 (0) | 2025.03.17 |
---|---|
Android In-App Purchase RTDN(Real-time Developer Notifications) 개발기 (0) | 2025.03.14 |
iOS In-App Purchase와 Apple Server Notification(ASN) 개발기 (0) | 2025.03.14 |