본문 바로가기

Project/부트캠프

[최종 프로젝트] [백엔드] 랜덤 10명을 뽑을때 어떻게 해야 빠르게 가져올까

 

 

백엔드 개발을 처음 시작하면 텅 빈 데이터베이스를 마주하게 됩니다. 기능이 "작동"하는 것만 확인하려면 데이터 몇 개면 충분하지만, 실무에 가까운 환경을 경험해보고 싶어서 더미 데이터를 열심히 채워 넣고 있었습니다.

 

목표는 유저 100만 명. 그런데 이 데이터를 가지고 **"비회원 10명을 랜덤으로 추첨해 쿠폰을 지급하는 기능"**을 구현하려다 보니, 예상치 못한 성능 이슈와 마주하게 되었습니다. 오늘은 그 삽질의 기록과 해결 과정을 정리해 보려 합니다.

1. 상황: 100명일 땐 몰랐던 것들

초기 개발 단계에서 유저가 100명일 때는 아주 단순하게 접근했습니다.

Java
 
// 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 한 줄이면 되니까 가장 먼저 생각나는 방법입니다.

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)로 이동하면 어떨까요?

SQL
 
-- 전체 개수 중 랜덤한 위치(Offset)로 점프
SELECT * FROM user LIMIT 1 OFFSET {RandomNumber}

OFFSET 방식은 앞부분을 건너뛰기 위해 DB가 데이터를 순차적으로 읽어야 합니다. 뒤쪽 데이터(예: 90만 번째)를 뽑으려 하면 앞의 90만 개를 읽고 버리는 작업이 수행되므로 성능이 매우 느립니다.

4. 해결책: 인덱스를 태우기 위한 "Where In" 전략

결국 제가 원한 건 100만 번을 조회하는 게 아니라, PK(인덱스)를 통해 딱 10명만 핀포인트로 가져오는 것이었습니다.

그래서 선택한 방법은 애플리케이션에서 난수를 생성하고, WHERE IN으로 조회하는 방식입니다.

[구현 로직]

  1. Max ID 조회: DB에서 가장 큰 ID 값이 1억이라는 것을 확인합니다.
  2. 난수 생성: 1 ~ 1억 사이의 랜덤 숫자(ID 후보)를 생성합니다.
  3. 조회: 생성한 난수들을 IN 절에 넣어 조회합니다.
SQL
 
SELECT * FROM user 
WHERE id IN (난수1, 난수2, ..., 난수10) 
AND is_member = false;

⚠️ 문제점과 보완 (Retry Logic)

제 시나리오에서는 ID가 1억까지 퍼져있고 실제 유저는 100만 명이므로, 유효한 ID를 맞힐 확률이 1% 밖에 안 됩니다. 10개를 던져도 하나도 안 걸릴 수 있죠.

그래서 반복(Loop) 로직을 추가했습니다.

  1. 난수를 넉넉하게(예: 100개) 생성해서 조회한다.
  2. 조회된 유저 수가 10명이 안 되면, 부족한 만큼 다시 난수를 생성해서 조회한다.
  3. 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/

 

참고를 번역한 내글