사용자별 상이한 홈 컨텐츠 조회 기능
최초 사용자가 홈 화면에 랜딩되고 홈 컨텐츠를 조회한다. 홈 컨텐츠는 모든 사용자가 공통의 컨텐츠를 보는 것이 아니다. 끼어들기 모듈이라는 각 컨텐츠별 모듈이 정해져있고, 각 모듈별 정렬 기준에 맞게 사용자별 조회되는 컨텐츠가 다르다. 사용자가 홈 화면을 스크롤하여 추가적인 데이터 로드 시 앞서 홈 화면에 노출된 컨텐츠는 다음 페이지에 다시 등장하지 않도록 중복을 제거하여 노출하는 것이 요구사항이다.
구현방법
1. @PostMapping
요청으로 현재 노출된 컨텐츠 ids RequestBody
로 받기.
중복을 제거하기 위한 가장 간단한 방법으로는 @PostMapping
으로 RequestBody
에 사용자가 홈 화면에서 현재 노출된 컨텐츠의 ids를 담아서 보낸다. 클라이언트가 넘겨준 ids를 query where
절의 not in
조건으로 사용하여 중복된 컨텐츠를 조회하지 않도록 한다. 마지막으로 조회 컨텐츠와 함께 클라이언트가 넘겨준 ids에 더하여 필터링하여 조회된 새로운 ids를 더하여 응답으로 넘겨준다.
문제점
1. GET
요청이 아닌, POST
요청으로 @RequestBody
에 포함하여 보낸다는 점이 Restful하지 못하다.
조회 요청인데 페이징 처리 할 때 매 요청 시 중복 컨텐츠를 제외하기 위해 기존 컨텐츠 id를 담은 excludeIds
를 포함해야 한다. GET
요청에 @RequestParam
으로 id를 콤마로 이어붙여 보낼 수 있지만, 무한히 페이지를 갱신하게 되면 결국 url 길이 제한에 도달할 위험이 있다.
이 문제를 해결하기 위해서 GET
요청이 아닌, POST
요청으로 @RequestBody
에 excludeIds
를 포함하여 요청을 보내면 문제를 해결할 수 있지만 Restful 설계 원칙을 지키지 못한다.
2. NOT IN 절 사용으로 인한 쿼리 성능 저하
대량의 값을 IN 절에 넣으면 실행 계획 생성 시간도 늘어나고 성능이 저하된다. 특히 NOT IN은 인덱스를 효과적으로 사용하지 못하기 때문에 쿼리 성능이 저하된다. IN은 구체적인 값의 집합을 찾는 것이므로 DB 옵티마이저가 더 효율적인 계획을 세울 수 있다. NOT IN은 부정 조건으로, 대부분의 경우 전체 테이블 스캔이나 인덱스 풀 스캔이 발생한다.
- NOT IN
SELECT p FROM Post p WHERE p.id NOT IN :excludeIds
- 문법이 간단하고 직관적이다.
- NOT IN 사용으로 인한 Full Scan
- NOT IN은 부정 조건으로, “이것이 아닌 모든 것”을 찾아야 하므로, 대부분의 DB 엔진에서는 전체 인덱스나 테이블을 스캔한 후 조건에 맞지 않는 레코드를 필터링해야 한다. 인덱스를 효율적으로 활용하기 위한 Range Scan보다 Full Scan에 의존하게 된다. 또 대부분의 DB 엔진은 긍정 조건(IN, =, BETWEEN 등)에 대한 통계를 더 잘 활용하여 실행 계획을 최적화한다. - IN 사용 시 NULL 처리를 주의해야 한다.
- 기본적으로 IN 목록에 NULL이 들어갈 경우 예상치 못한 결과가 발생할 수 있다. 하지만 위 쿼리 경우 id를 조회하므로 NULL인 경우가 없어서 괜찮다.
- LEFT JOIN + IS NULL
SELECT p FROM Post p LEFT JOIN ( SELECT temp.id FROM Post temp WHERE temp.id IN :excludeIds ) filtered ON p.id = filtered.id WHERE filtered.id IS NULL
- 인덱스를 효율적으로 활용할 수 있다.
- 서브쿼리(
temp.id IN :excludeIds
)가 먼저 실행되고, 이 결과셋이 매우 작은 경우 메인 쿼리와의 JOIN 작업이 효율적으로 수행되어 JOIN 최적화가 된다.p.id = filtered.id
JOIN 조건은 모두 id 필드를 사용하므로, PK 인덱스를 사용하여 매우 효율적으로 JOIN 된다.filtered.id IS NULL
조건은 JOIN 후에 적용되며, 이미 필터링된 작은 결과셋에 대해 동작하므로 효율적이다. - 서브쿼리로 인한 추가 처리 단계가 필요하다. SELECT temp.id FROM Post temp WHERE temp.id IN :excludeIds
서브쿼리가 실행되면, 서브쿼리의 결과가 메모리나 임시 테이블에 저장된다. 이후 메인 쿼리와 임시 결과셋 사이에 조인 연산이 수행된다.- 서브 쿼리 실행으로 인한 추가적인 단계가 작은 데이터셋에서는 오버헤드가 될 수 있지만, 오히려 대규모 데이터에서 조회하는 경우 인덱스를 효율적으로 사용하여 전체 성능을 향상시킬 수 있다.
- NOT EXISTS
SELECT p FROM Post p WHERE NOT EXISTS ( SELECT 1 FROM Post temp WHERE temp.id = p.id AND temp.id IN :excludeIds )
- 행 단위 평가와 인덱스 활용 효율성이 높다.
NOT EXISTS
는 각 행에 대해 서브쿼리를 평가하고, 첫 번째 매칭되는 행을 찾자마자 평가를 중단한다.NOT EXISTS
는 “존재하지 않음”을 확인하기 위한 목적으로 설계됐기 때문에, DBMS가 이 의도를 명확히 이해하고 최적화할 수 있다.
성능비교
소규모 데이터셋(수천 건 이하)
모든 방식이 비슷한 성능을 나타낼 수 있다.
중규모 데이터셋
NOT EXISTS
, LEFT JOIN + IS NULL
방식이 NOT IN
보다 약간 효율적인 경우가 많다.
대규모 데이터셋(수백만 건 이상)
NOT IN
은 성능이 현저히 떨어진다.
NOT EXISTS
가 대부분의 DBMS에서 가장 효율적인 성능을 보인다.
LEFT JOIN + IS NULL
은 특정 DBMS에서 좋은 성능을 보일 수 있지만, JOIN 연산에 따른 오버헤드가 발생할 수 있다.
결론적으로 NOT EXISTS
방식이 가장 안정적이고 확장성 있는 접근법이다.
해결되지 않은 문제
근본적으로 해결되지 않는 문제는, 끼어들기 모듈이 포함되는 홈 컨텐츠 조회는 모든 사용자가 서비스에 최초 랜딩되는 화면의 기능이라는 점이다. 많은 사용자가 동시다발적으로 추가적인 데이터를 로드 요청할 것이기 때문에 쿼리 최적화를 하더라도 문제가 발생할 것으로 예상되기 때문에 문제 해결을 위해 다른 방법이 필요하다.
2. 엘라스틱 서치 또는 레디스를 통해 사용자의 요청을 카테고리화하여 조회한다.
예를 들어 영감 끼어들기 모듈은 좋아요 많은 순 상위 50개 중 랜덤으로 선택된 5개를 조회한다. 구현해야 할 동작은 스크롤하여 새로운 페이징 처리 시 기존에 노출된 컨텐츠를 제외한 새로운 5개를 조회하도록 해야한다. 여기에 더해서 모든 사용자가 같은 구성을 보는 것이 아니라, 일부 개인화된 조회를 해야한다.
구현
- 좋아요 많은 순으로 50개 데이터 중에서 랜덤으로 섞은 n개의 데이터셋을 엘라스틱 서치 또는 레디스에 저장한다.
- enum과 같은 구분을 통해서 A,B,C,D,E … 유형을 나누어 조회 요청 시 enum을 요청 파라미터로 받아서 각 enum에 맞는 데이터를 조회한다. 또는 로그인한 사용자의 JWT 엑세스 토큰을 바탕으로 해시 함수를 적용해 결과값을 기반으로 사용자를 특정 데이터셋에 할당한다. 이를 통해 사용자별로 조회요청을 고르게 분산한다.
- cursorId를 기준으로 각 데이터셋을 순차적으로 조회 하여 중복없이 새로운 컨텐츠를 제공한다.
- 일일 배치 작업으로 요구사항에 맞춰 데이터셋을 갱신한다.
장점
- 미리 데이터셋을 엘라스틱 서치 또는 레디스에 캐싱한 데이터를 조회하기 때문에 조회 성능이 좋다. 만일의 경우 DB에서 조회 하더라도 cursorId를 통해 PK 인덱스를 직접 조회하여 성능이 우수하다. AWS Personalize를 사용하지 않고도 중복 컨텐츠가 없는 개인화된 조회를 할 수 있다는 점으로 비용을 줄일 수 있다.
단점
- 미리 데이터셋을 구성해야 하므로, 좋아요 많은 순 상위 50개 중 랜덤으로 선택된 5개를 조회한다는 조건을 조회 시 실시간으로 가져올 수 없다는 점이다.
하지만 좋아요 많은 순 50개 중 랜덤으로 5개를 조회한다는 요구사항 자체가 “인기 급상승 컨텐츠” 와 같이 실시간성이 중요한 것은 아니라고 판단한다면 심각한 단점은 아니기 때문에 충분히 고려해볼만 하다고 생각한다.
댓글남기기