Spring

Seed를 사용하여 Pagenation + Random에서 중복 없이 데이터 가져오는 방법 | ORDERBY RANDOM PostgreSQL seed

YATTA! 2025. 3. 16. 23:55

어느날 페이지네이션을 사용하면서 랜덤한 값들을 응답해줘야하는 요구사항이 들어왔습니다.

1. 한 번 요청시 5개씩 데이터를 응답해줘야 한다.

2. 중복된 데이터가 나오면 안된다.

3. 완전 랜덤한 데이터가 나와야 한다.

 

오늘은 해당 요구사항을 구현하는 방법인 seed에 대하여 알아보고, postgres + jpa 환경에서의 구현 방법을 알아보고자 합니다.

 

Seed란 무엇인가?

컴퓨터는 원래 난수를 생성할 수 없다는 사실 아시나요? 저희가 보기엔 마치 난수처럼 보이지만, 사실 정말 임의의 값이 아닌 특정 방법이나 ms를 사용하는 등의 계산 과정을 거쳐 나온 '의사 난수'들입니다.

흔하게는 난수표를 사용하는데, 난수표를 선택하는 값을 시드(seed)라고 합니다. 그리고 seed값에 따라 같은 난수표를 사용하기 때문에 같은 seed라면 같게 정렬이 됩니다.

 

흔히 랜덤 API를 구현하게 된다면 일반 RANDOM 쿼리를 사용하거나, 서비스 코드에서 랜덤한 값을 필터링 해주는 방법을 생각할 수 있습니다. 하지만 그렇게 한다면 '중복된 데이터가 나오면 안된다.'라는 요구사항을 지킬 수 없게 됩니다.

 

그래서 저희는 컴퓨터가 난수를 만들 때 사용하는 seed값 아이디어를 차용해 랜덤 API를 구현할 것입니다.

 

기본적인 아이디어는 클라이언트에서 시드(seed)값을 API 요청 시 생성해 넘겨주고, 저희는 그 seed값을 이용해 쿼리를 날리는 것입니다. 이렇게 구현할 경우 랜덤 API이지만 페이지 내에서 새로 고침을 할 경우 값이 달라지지 않게 구현할 수 있다는 장점도 존재합니다.

 

MySQL RAND 함수

MySQL에서 RAND 함수는 seed라는 파라미터를 제공합니다.

SELECT * FROM topic ORDER BY RAND(1234) LIMIT 10;

이렇게 기본 RAND 함수 파라미터로 원하는 seed값을 넣으면 해당 seed값에 따라 쿼리가 실행되게 됩니다.

Spring JPA에서는 아래와같이 사용할 수 있습니다.

 

@Query(value = "SELECT * FROM topic ORDER BY RAND(:seed)", 
       countQuery = "SELECT COUNT(*) FROM topic", 
       nativeQuery = true)
Page<TopicEntity> findAllRandomWithSeed(@Param("seed") int seed, Pageable pageable);

* nativeQuery + Pagenation 사용시 count Query 없으면 오류가 납니다. (java.lang.IllegalStateException: No position associated)

 

그런데 하나 문제가 있었습니다. MySQL같은 경우 위와 같이 사용하면 되지만, PostgreSQL의 경우 위와 같은 문법을 지원하지 않았습니다. (그리고 제 프로젝트는 PostgreSQL를 사용하죠....)

SELECT setseed(0.5);
SELECT * FROM topic ORDER BY random() LIMIT 10;

PosrgreSQL은 MySQL과는 다르게 random 함수 실행 전 setseed를 통하여 시드를 설정할 수 있었습니다.

setseed는 동일한 세션에서만 적용이 되기 때문에 주의가 필요합니다.

 

그래서 위와 같은 방법으로 시도를 해보았는데요.

@Query(value = "SELECT setseed(:seed)", nativeQuery = true)
void setSeed(@Param("seed") double seed);

@Query(value = "SELECT * FROM topic ORDER BY random()",
       countQuery = "SELECT COUNT(*) FROM topic",
       nativeQuery = true)
Page<TopicEntity> findAllRandom(Pageable pageable);

잘 동작하지만, JPA보다 JDBC를 직접 연결하는 것이 안정성이 더 좋습니다. JPA에서는 커넥션 풀을 통해 세션을 재사용하기 때문에 시드값이 잘 반영되지 않을 수도 있기 때문입니다. setseed 말고 다른 방법도 가져왔습니다.

 

JPA + Postgres에서 seed를 사용하는 방법

@Query(value = """
    SELECT * FROM topic ORDER BY MOD(ABS(CAST(hashtext(CAST(topic_id AS TEXT) || CAST(:seed AS TEXT)) AS BIGINT)), 1000000)
    """,
    countQuery = "SELECT COUNT(*) FROM topic",
    nativeQuery = true)
Page<TopicEntity> findAllRandomWithSeed(@Param("seed") int seed, Pageable pageable);

 

 

 

위 Query를 통하여 seed값으로 ORDER BY를 할 수 있습니다. 

중요하게 봐야할 부분은 hashtext(CAST(topic_id AS TEXT) || CAST(:seed AS TEXT)) 인데요.

CAST AS TEXT는 단순히 숫자를 문자열로 변환하는 것이니 빼고 확인하자면 hashtext(topic_id || seed) 가 되겠습니다.

 

||는 두개의 문자열을 합쳐줍니다. 만약 topic_id가 '1'이고, seed가 '2'라면 '12'라는 값이 나오게 되죠. hastext는 문자열을 입력받아 정수형 해시값을 반환합니다. topic_id + seed이기 때문에 동일한 시드를 사용할 경우 항상 같은 해시값이 생성됩니다.

 

그 외에 해시값이 음수일 수도 있기때문에 ABS()를 사용해주고, 해시값은 -2,147,483,648 ~ 2,147,483,647 등 엄청나게 커질 수 있기 때문에 MOD로 한 번 나누어줍니다. (해시값이 1000000보다 작더라도 문제가 일어나지 않습니다.)

 

이런식으로 random 함수를 만들어주면, int값을 (0~2147483647까지 값) seed로 사용해 페이지네이션에서도 랜덤한 값을 유지하며 중복 없이 응답해 줄 수 있습니다.

 

마무리하며

무언갈 개발하는 새로운 방법을 알게되는건 항상 재밌는 것 같습니다.

피드백이나 궁금한점은 댓글로 남겨주세요.

읽어주셔서 감사합니다.