키워드 기반 게시물 검색 쿼리 및 구현 상세 분석
1. 서론
현대적인 웹 애플리케이션에서는 키워드 기반 검색이 필수적인 기능으로 자리 잡고 있습니다. 특히, 소셜 미디어나 게시판과 같은 플랫폼에서는 다양한 데이터를 효과적으로 검색하고 정렬하는 기능이 사용자 경험의 핵심입니다. 이번 글에서는 PostgreSQL의 ts_rank
및 to_tsvector
기능을 활용한 키워드 기반 게시물 검색 쿼리를 분석하고, Spring Data JPA를 활용하여 이를 구현한 방식을 살펴보겠습니다. 제공된 코드는 게시물 제목, 내용, 해시태그, 댓글, 좋아요 수 등을 종합적으로 고려하여 가장 적합한 결과를 반환하도록 설계되었습니다.
2. 주요 기능 및 쿼리 분석
2.1. 쿼리 목적
해당 쿼리는 다음과 같은 요구사항을 충족합니다:
- 다중 키워드 기반 검색: 제목, 내용, 해시태그에서 키워드를 검색.
- 사용자 정보 포함: 작성자의 닉네임 및 프로필 이미지.
- 종합적인 정렬 기준:
ts_rank
를 기반으로 한 키워드 관련성.- 좋아요(
likeCount
), 조회수(viewCount
), 댓글 수(commentCount
).
- 페이징 처리: Spring Data JPA의
Pageable
을 활용한 효율적 데이터 반환.
2.2. 주요 SQL 요소
2.2.1. CTE(Common Table Expression)
WITH keyword_search AS (
SELECT DISTINCT
p.id as id,
p.user_id as userId,
p.title,
p.content,
p.created_at as createdAt,
u.nickname,
u.profile_image_url as profileImageUrl,
p.share_count as shareCount,
p.view_count as viewCount,
COUNT(DISTINCT pl.id) as likeCount,
COUNT(DISTINCT c.id) as commentCount,
ts_rank(
setweight(to_tsvector('simple', COALESCE(p.title,'')), 'A') ||
setweight(to_tsvector('simple', COALESCE(p.content,'')), 'B') ||
setweight(to_tsvector('simple', string_agg(COALESCE(h.tag_name,''), ' ')), 'C'),
to_tsquery('simple', :searchQuery)
) as rank
FROM posts p
INNER JOIN users u ON p.user_id = u.id
LEFT JOIN post_likes pl ON p.id = pl.post_id
LEFT JOIN comments c ON p.id = c.post_id
LEFT JOIN post_hashtags h ON p.id = h.post_id
WHERE p.status = :activeStatus
AND p.visibility = :publicVisibility
AND (
p.title ILIKE ANY(SELECT CONCAT('%', TRIM(k), '%') FROM unnest(:keywords) AS k)
OR p.content ILIKE ANY(SELECT CONCAT('%', TRIM(k), '%') FROM unnest(:keywords) AS k)
OR h.tag_name ILIKE ANY(SELECT CONCAT('%', TRIM(k), '%') FROM unnest(:keywords) AS k)
)
GROUP BY p.id, p.user_id, p.title, p.content, p.created_at, u.nickname, u.profile_image_url
)
2.2.1.1. 주요 작업
WITH keyword_search AS (...)
는 검색 결과를 임시 테이블로 생성합니다.- 랭킹 계산:
setweight
와to_tsvector
를 조합하여 가중치 기반 검색.to_tsquery
로 키워드 쿼리를 생성.
- 관련 데이터 포함:
LEFT JOIN
을 활용하여 좋아요, 댓글, 해시태그 데이터를 추가.
- 필터링 조건:
ILIKE
로 키워드 부분 검색.- 상태 및 공개 여부 조건 추가.
2.2.2. 최종 결과 반환
SELECT * FROM keyword_search
ORDER BY
rank DESC,
likeCount DESC,
viewCount DESC,
commentCount DESC
LIMIT :#{#pageable.pageSize}
OFFSET :#{#pageable.offset}
2.2.2.1. 정렬 기준
- 랭킹 우선:
ts_rank
점수로 관련성 정렬. - 보조 정렬: 좋아요, 조회수, 댓글 수로 추가 정렬.
2.2.2.2. 페이징 처리
LIMIT
와OFFSET
을 사용하여 Spring Data JPA의Pageable
을 처리.
3. PostgreSQL 쿼리 함수 설명
3.1. to_tsvector
- 텍스트를 토큰화하여 검색 가능한 형태로 변환합니다.
- 예:
to_tsvector('simple', 'hello world')
→'hello':1 'world':2
- 여러 필드를 결합하여 검색에 사용할 수 있습니다.
to_tsvector('simple', title || ' ' || content)
3.2. to_tsquery
to_tsvector
로 변환된 데이터와 매칭되는 검색 쿼리를 생성합니다.- 예:
to_tsquery('simple', 'hello & world')
→'hello' AND 'world'
3.3. setweight
to_tsvector
결과에 가중치를 부여합니다.- 가중치 레벨:
'A'
: 최고 우선순위 (예: 제목)'B'
: 중간 우선순위 (예: 내용)'C'
: 낮은 우선순위 (예: 해시태그)
- 예:
setweight(to_tsvector('simple', COALESCE(title, '')), 'A')
3.4. string_agg
- 여러 행의 값을 하나의 문자열로 결합합니다.
- 예:
→ 태그 이름을 공백으로 구분하여 결합.string_agg(tag_name, ' ')
3.5. ts_rank
to_tsvector
와to_tsquery
의 매칭 점수를 계산하여 결과를 정렬합니다.- 예:
ts_rank(vector, query)
3.6. ILIKE
- 대소문자를 구분하지 않고 문자열 매칭을 수행합니다.
- 예:
ILIKE '%keyword%'
3.7. COALESCE
COALESCE()
함수는 NULL 값을 처리하는 데 사용됩니다. 첫 번째 인자가 NULL일 경우 두 번째 인자를 반환합니다.- 예:
→COALESCE(p.title, '')
title
필드가 NULL일 경우 빈 문자열을 반환하여to_tsvector
함수가 오류 없이 처리되도록 보장합니다.
3.8. unnest
- PostgreSQL의
unnest()
함수는 배열을 개별 행으로 변환하는 데 사용됩니다. 배열 내의 각 요소를 독립적인 행으로 변환하여 SQL 쿼리에서 개별적으로 처리할 수 있도록 합니다.
활용 설명
- 기능: 배열 타입 데이터를 개별 행으로 변환하여 쿼리에서 배열의 각 요소를 독립적으로 처리할 수 있습니다.
- 장점: 키워드 검색과 같이 다중 요소를 조건으로 사용해야 하는 시나리오에서 매우 유용합니다.
- 예시:
→ 결과:SELECT unnest(ARRAY['keyword1', 'keyword2', 'keyword3']);
keyword1 keyword2 keyword3
활용 사례
- 다중 키워드 검색: 키워드 배열을
unnest()
로 변환하여 각 키워드를 개별 조건으로 활용. - 동적 조건 생성: 배열 데이터를 조건문에서 동적으로 처리.
응용 방법
- 쿼리 작성자는
unnest()
를 통해 배열 데이터를 손쉽게 행으로 분리하여 조건문에서 활용할 수 있습니다. 이를 통해 보다 유연하고 확장 가능한 SQL 조건을 작성할 수 있습니다. - 응용 예시:
→ 각 키워드가 제목에 포함된 모든 게시글을 반환.SELECT * FROM posts WHERE title ILIKE ANY (SELECT '%' || unnest(ARRAY['keyword1', 'keyword2', 'keyword3']) || '%');
문서에서 선택된 내용을 표로 다시 작성하며, 잘못된 텍스트를 정리했습니다. 새로운 표 형식을 아래와 같이 제시하겠습니다:
함수/구문 | 설명 |
---|---|
WITH | 공통 테이블 표현식(CTE)을 정의합니다. 여기서는 'keyword_search'라는 임시 결과 집합을 생성합니다. |
DISTINCT | 중복된 행을 제거합니다. |
COUNT | 지정된 조건을 만족하는 행의 수를 계산합니다. |
COALESCE | 첫 번째로 NULL이 아닌 인자를 반환합니다. NULL 값 처리에 사용됩니다. |
to_tsvector | 문자열을 tsvector 타입으로 변환합니다. 전문 검색에 사용됩니다. |
setweight | tsvector의 각 어휘소에 가중치를 할당합니다. |
to_tsquery | 검색어를 tsquery 타입으로 변환합니다. |
ts_rank | 검색 결과의 관련성 순위를 계산합니다. |
string_agg | 지정된 구분자로 문자열을 연결합니다. |
INNER JOIN | 두 테이블 간의 내부 조인을 수행합니다. |
LEFT JOIN | 왼쪽 테이블의 모든 행과 오른쪽 테이블의 일치하는 행을 반환합니다. |
ILIKE | 대소문자를 구분하지 않는 패턴 매칭을 수행합니다. |
ANY | 배열의 어느 값과도 일치하는지 확인합니다. |
unnest | 배열을 행으로 확장합니다. |
GROUP BY | 지정된 열을 기준으로 결과를 그룹화합니다. |
ORDER BY | 결과를 지정된 열을 기준으로 정렬합니다. |
LIMIT | 반환되는 행의 수를 제한합니다. |
OFFSET | 결과 집합에서 건너뛸 행의 수를 지정합니다. |
4. Spring Data JPA 구현
4.1. 함수 정의
함수 주석
/**
* <h3>키워드로 게시물 검색</h3>
* <hr>
* <p>- 상세 설명 -</p>
* <p> 키워드로 게시물을 검색합니다. 키워드는 게시물의 제목, 내용, 해시태그에 포함된 키워드입니다.</p>
* @since 2024-12-25
* @param searchQuery 검색 키워드
* @param keywords 검색 키워드 목록
* @param activeStatus 게시물 상태
* @param publicVisibility 게시물 공개 여부
* @param pageable 페이지 요청
* @return 게시물 목록
*/
함수 선언
@Query(value = "...", nativeQuery = true)
Page<PostSearchProjection> searchPostsByKeyword(
@Param("searchQuery") String searchQuery,
@Param("keywords") String[] keywords,
@Param("activeStatus") Integer activeStatus,
@Param("publicVisibility") Integer publicVisibility,
Pageable pageable);
5. 성능 최적화
5.1. 인덱스 활용
GIN
인덱스를 활용하여 검색 성능을 크게 향상시켰습니다. 다음은 적용된 인덱스입니다:
CREATE INDEX idx_posts_search ON posts USING gin(to_tsvector('simple', title || ' ' || content));
CREATE INDEX idx_hashtags_search ON post_hashtags USING gin(to_tsvector('simple', tag_name));
5.1.1. 효과
posts
테이블의title
및content
필드에 대한 검색 속도가 크게 개선됩니다.post_hashtags
테이블에서tag_name
을 검색할 때도 효율이 향상됩니다.- 인덱스는
to_tsvector
를 활용한 Full-Text Search와 잘 결합되어 빠른 검색을 지원합니다.
5.2. JOIN 최적화
LEFT JOIN
대신 필요한 경우INNER JOIN
사용.GROUP BY
및 COUNT 작업 최소화.
5.3. 캐싱
5.3.1. Redis 캐싱의 도입
Redis는 인메모리 데이터 저장소로, 자주 사용되는 데이터나 결과를 캐싱함으로써 데이터베이스 요청 부하를 줄이고 성능을 크게 향상시킬 수 있습니다. 특히, 본 구현에서는 검색 쿼리의 결과를 캐싱하여 반복적인 키워드 검색 요청에서 빠른 응답을 제공합니다.
5.3.2. Redis 캐시 적용 방법
- Spring Cache를 활용하여 Redis와 통합.
- 키워드와 페이징 정보를 캐시 키로 설정.
@Transactional(readOnly = true)
@Cacheable(value = "postSearchCache", key = "#keywords.toString() + #pageable.pageNumber + #pageable.pageSize")
public Page<PostResponse> searchPosts(List<String> keywords, Pageable pageable) {
if (keywords == null || keywords.isEmpty()) {
return Page.empty(pageable);
}
// 정렬 조건을 제거한 새로운 Pageable 객체 생성
Pageable pageableWithoutSort = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize()
);
// 키워드를 하나의 검색 쿼리로 변환
String searchQuery = keywords.stream()
.map(keyword -> keyword.trim().replaceAll("\\s+", " & "))
.collect(Collectors.joining(" | "));
// 키워드를 배열로 변환
String[] keywordsArray = keywords.toArray(new String[0]);
// 단일 쿼리로 검색 수행
Page<PostSearchProjection> searchResults = postRepository.searchPostsByKeyword(
searchQuery,
keywordsArray,
PostStatus.POST_STATUS_PUBLISHED.getValue(),
PostVisibility.POST_VISIBILITY_PUBLIC.getValue(),
pageableWithoutSort
);
if (searchResults.isEmpty()) {
return Page.empty(pageable);
}
return searchResults.map(this::convertToPostResponse);
}
5.3.3. 기대 효과
- 동일한 키워드와 페이지 요청에 대해 Redis에서 즉시 응답하여 데이터베이스 부하를 감소.
- 자주 사용되는 검색 요청에서 처리 속도를 크게 단축.
- Redis의 TTL(Time-To-Live)을 설정하여 캐시된 데이터의 유효 기간 관리.
6. 결론
해당 구현은 다양한 요구 사항을 충족하면서도 PostgreSQL의 강력한 검색 기능을 효과적으로 활용하는 예입니다. 성능 최적화를 위해 인덱스 추가와 쿼리 튜닝을 통해 검색 속도를 크게 개선하였으며, Redis 캐싱을 도입하여 반복적인 요청에 대한 응답 성능을 향상시켰습니다. 이와 함께, Spring Data JPA의 페이징 기능과 Redis를 결합하여 높은 확장성을 제공하는 구조를 설계하였습니다. 이러한 최적화는 사용자 경험을 크게 향상시킬 수 있습니다.