백엔드 개발을 처음 시작하면 텅 빈 데이터베이스를 마주하게 됩니다. 기능이 "작동"하는 것만 확인하려면 데이터 몇 개면 충분하지만, 실무에 가까운 환경을 경험해보고 싶어서 더미 데이터를 열심히 채워 넣고 있었습니다.
목표는 유저 100만 명. 그런데 이 데이터를 가지고 **"비회원 10명을 랜덤으로 추첨해 쿠폰을 지급하는 기능"**을 구현하려다 보니, 예상치 못한 성능 이슈와 마주하게 되었습니다. 오늘은 그 삽질의 기록과 해결 과정을 정리해 보려 합니다.
1. 상황: 100명일 땐 몰랐던 것들
초기 개발 단계에서 유저가 100명일 때는 아주 단순하게 접근했습니다.
// User 100명을 전부 가져와서 애플리케이션에서 랜덤 선택
List<User> users = userRepository.findAll();
Collections.shuffle(users);
return users.subList(0, 10);
JPA를 쓰든 MyBatis를 쓰든, SELECT * FROM user로 일단 다 가져와서 코드단에서 섞는 방식이죠. 하지만 멘토님들이나 시니어 개발자분들이 항상 하시는 말씀이 있습니다.
"실무에서 SELECT * 막 쓰면 혼난다."
유저가 100만 명이라고 가정해 봅시다. 100만 개의 객체를 DB에서 꺼내 네트워크를 타고 애플리케이션 메모리에 올린다? 이건 OOM(Out of Memory)을 유발하거나 DB 대역폭을 다 잡아먹는, 그야말로 **"미친 짓"**에 가깝습니다.
2. 가상의 시나리오와 제약 조건
그래서 조금 더 가혹하지만 현실적인 제약 조건을 걸어보았습니다.
- 총 유저 수: 100만 명
- User ID (PK): AUTO_INCREMENT가 아닌 1 ~ 1억 사이의 무작위 값 (데이터가 매우 희소함, Sparsity)
- 미션: 이 중에서 비회원 10명을 빠르고 효율적으로 뽑아라.
3. 실패한 접근 방법들 (Why Not?)
DB 부하를 최소화하면서 랜덤을 구현하기 위해 몇 가지 방법을 떠올렸지만, 곧바로 기각했습니다.
❌ 시도 1: 애플리케이션 레벨의 Random Index 접근
users.get(random_index)를 하고 싶어도 불가능합니다. ID가 1부터 100만까지 꽉 차 있는(연속적인) 상태가 아니기 때문입니다. ID가 띄엄띄엄 존재하므로 인덱스로 접근할 수가 없습니다.
❌ 시도 2: ORDER BY RAND()
SQL 한 줄이면 되니까 가장 먼저 생각나는 방법입니다.
SELECT * FROM user WHERE is_member = false ORDER BY RAND() LIMIT 10;
하지만 이건 대용량 데이터에서 절대 금기시되는 패턴입니다. ORDER BY RAND()는 인덱스를 타지 못하고 **100만 건을 전부 로딩한 뒤 임시 테이블에서 정렬(Full Scan & File Sort)**을 수행합니다. DB CPU가 순식간에 100%를 칠 것입니다.
❌ 시도 3: LIMIT & OFFSET 활용
그렇다면 전체 개수(Count)를 구해서 랜덤한 위치(OFFSET)로 이동하면 어떨까요?
-- 전체 개수 중 랜덤한 위치(Offset)로 점프
SELECT * FROM user LIMIT 1 OFFSET {RandomNumber}
OFFSET 방식은 앞부분을 건너뛰기 위해 DB가 데이터를 순차적으로 읽어야 합니다. 뒤쪽 데이터(예: 90만 번째)를 뽑으려 하면 앞의 90만 개를 읽고 버리는 작업이 수행되므로 성능이 매우 느립니다.
4. 해결책: 인덱스를 태우기 위한 "Where In" 전략
결국 제가 원한 건 100만 번을 조회하는 게 아니라, PK(인덱스)를 통해 딱 10명만 핀포인트로 가져오는 것이었습니다.
그래서 선택한 방법은 애플리케이션에서 난수를 생성하고, WHERE IN으로 조회하는 방식입니다.
[구현 로직]
- Max ID 조회: DB에서 가장 큰 ID 값이 1억이라는 것을 확인합니다.
- 난수 생성: 1 ~ 1억 사이의 랜덤 숫자(ID 후보)를 생성합니다.
- 조회: 생성한 난수들을 IN 절에 넣어 조회합니다.
SELECT * FROM user
WHERE id IN (난수1, 난수2, ..., 난수10)
AND is_member = false;
⚠️ 문제점과 보완 (Retry Logic)
제 시나리오에서는 ID가 1억까지 퍼져있고 실제 유저는 100만 명이므로, 유효한 ID를 맞힐 확률이 1% 밖에 안 됩니다. 10개를 던져도 하나도 안 걸릴 수 있죠.
그래서 반복(Loop) 로직을 추가했습니다.
- 난수를 넉넉하게(예: 100개) 생성해서 조회한다.
- 조회된 유저 수가 10명이 안 되면, 부족한 만큼 다시 난수를 생성해서 조회한다.
- 10명이 채워질 때까지 반복한다.
이 방식은 쿼리를 몇 번 더 날릴 수도 있지만, ORDER BY RAND()처럼 DB 전체를 뒤흔드는 것보다 훨씬 가볍고 빠릅니다.
5. 마치며: DB 설계의 중요성 (PK는 신중하게)
이 기능을 구현하면서 뼈저리게 느낀 점이 하나 있습니다.
"만약 실무에서 누군가 '보안을 위해 PK를 1~1억 사이 랜덤 값으로 하시죠'라고 한다면? 말려야겠다."
랜덤한 PK는 데이터 삽입 시 인덱스 페이지 분할(Page Split)을 일으켜 쓰기 성능을 저하시킬 뿐만 아니라, 이번처럼 랜덤 추출이나 통계 쿼리를 짤 때도 엄청난 비효율을 초래합니다.
특별한 이유가 없다면 PK는 AUTO_INCREMENT를 사용하여 순차적으로 증가하게 만들고, 랜덤성이 필요하다면 별도의 UUID 컬럼을 두거나 애플리케이션 로직으로 푸는 것이 정답인 것 같습니다.
단순한 "랜덤 뽑기" 기능 하나였지만, 데이터 규모가 커짐에 따라 네트워크 비용, 메모리, DB 인덱스 구조까지 고민해 볼 수 있었던 좋은 경험이었습니다.
참고
https://jan.kneschke.de/projects/mysql/order-by-rand/
참고를 번역한 내글
'Project > 부트캠프' 카테고리의 다른 글
| [최종프로젝트] MySQL에서 ORDER BY RAND() 사용 시 성능 문제 및 해결 전략 번역 및 요약 (0) | 2025.11.24 |
|---|