청년 정책 서비스에서 검색 기능이 빠질 수 없다. 사용자들이 본인에게 필요한 정책을 빠르고 정확하게 찾을 수 있어야 한다.
고려 사항
- 키워드 처리 로직
- 사용자가 “청년 취업 지원” 처럼 공백으로 구분된 검색어를 입력했을 때, “쳥년 취업 지원 사업” 같은 전체 문구가 그대로 포함된 정책도 찾아야 하고, “청년을 위한 취업 교육 지원”처럼 각 키워드가 따로 포함된 정책도 검색되어야 한다.
- 검색 대상 필드
- 사용자가 찾으려는 정책을 최대한 포함하도록 검색 범위를 선정했다. 청년 정책 데이터에는 다양한 정보가 포함되어 있는데, 그중에서 제목(title), 정책 소개(introduce), 신청 상세내용(applicationDetails) 필드가 정책의 가장 핵심 필드라고 판단하여 선택했다.
- 검색 결과 정렬
- 검색 기능에 대한 여러 레퍼런스를 찾아봤을 때, 정렬로는 “정확도순” 또는 “최신순” 등이 있었다. 우선 기장 기본적인 검색 기능만 구현하기 위해 “최신순” 정렬을 먼저 구현하기로 했다.
1. 프로젝트 구조
src/main/java/com/example/withpeace/
│
├── domain/ # 도메인 모델 (엔티티)
│ └── YouthPolicy.java # 청년 정책 엔티티
│
├── repository/ # 데이터 접근 계층
│ └── YouthPolicyRepository.java # 게시글 레포지토리
│
├── dto/ # 데이터 전송 객체
│ └── response/
│ ├── PolicySearchResponseDto.java # 정책 검색 응답 DTO
│ └── PolicyListResponseDto.java # 정책 리스트 응답 DTO
│
├── controller/ # 컨트롤러 계층
│ └── PolicyController.java # 정책 관련 API 엔드포인트
│
└── service/ # 비즈니스 로직 계층
└── PolicyService.java # 정책 관련 비즈니스 로직
2. DTO 구현
@Builder
public record PolicySearchResponseDto(
List<PolicyListResponseDto> policies,
long totalCount
) {
public static PolicySearchResponseDto of(List<PolicyListResponseDto> policies, long totalCount) {
return new PolicySearchResponseDto(policies, totalCount);
}
}
PolicySearchResponseDto는 정책 검색 결과를 응답하기 위한 DTO이다.
검색된 정책 목록을 List 타입의 policies로 정의하여 기존의 PolicyListResponseDto를 재사용했다. (PolicyListResponseDto는 정책의 id, title, introduce 등 기본 정보를 포함하는 정책 리스트 응답 DTO) 기존의 정책 리스트 조회와 동일한 정책 정보를 반환하고 검색 결과의 총 개수를 나타내는 totalCount 필드만 추가했다.
of 를 사용한 이유
PolicySearchResponseDto 는 이미 DTO 형태로 변환된 정책 목록 (List)과 같이 단순한 카운트 값을 받아서 새로운 응답 객체를 생성한다. 새로운 변환 로직이 필요없이 기존 DTO를 재사용하면서 검색 결과의 총 개수만 추가하는 구조이기 때문에 of 메서드를 사용했다. 데이터 변환보다 데이터 조합에 더 적절하기 때문이다.
from vs of 네이밍 컨벤션 차이
from 사용하는 경우
- 한 타입에서 다른 타입으로의 변환이 필요할 때
- 복잡한 변환 로직이 포함될 때
- Entity → DTO와 같은 계층 간 변환 시
- 예)
PolicyListResponseDto.from(YouthPolicy)
of 사용하는 경우
- 이미 적절한 형태로 존재하는 데이터들을 모아서 새 객체를 만들 때
- 기존 DTO를 재사용하면서 추가 정보만 덧붙이는 경우
- 변환 로직 없이 단순 데이터 조합이 필요한 경우
- 예)
PolicySearchResponseDto.of(List<DTO>, count)
3. 엔티티 수정
YouthPolicy
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicUpdate
@Table(name = "youth_policies", indexes = {
@Index(name = "idx_policy_search", columnList = "title,introduce,application_details")
})
public class YouthPolicy {
// ... (필드 생략)
}
(복합 인덱스 추가로 검색 성능 향상)
- 복합 인덱스 영향:
- 검색 쿼리 성능은 향상되지만, INSERT/UPDATE 작업 시 인덱스도 함께 업데이트되어야 하므로 약간의 성능 저하가 있을 수 있습니다.
- 하지만 검색이 빈번한 기능이므로 검색 성능 향상의 이점이 더 큽니다.
- 디스크 공간을 추가로 사용하지만, 검색 성능 향상을 위해 감수할 만한 수준입니다.
3. 컨트롤러 구현
YouthPolicyController
@RestController
@RequiredArgsConstructor
@RequestMapping("api/v1/policies")
@Slf4j
public class YouthPolicyController {
// ... (정책 관련 메서드 생략)
// 청년 정책 검색
@GetMapping("/search")
public ResponseDto<?> getSearchPolicyList(@UserId Long userId,
@RequestParam String keyword,
@RequestParam(defaultValue = "1") @Valid @NotNull @Min(1) Integer pageIndex,
@RequestParam(defaultValue = "10") @Valid @NotNull @Min(10) Integer pageSize) {
PolicySearchResponseDto searchPolicyList = youthPolicyService.getSearchPolicyList(userId, keyword, pageIndex - 1, pageSize);
return ResponseDto.ok(searchPolicyList);
}
}
사용자 ID, 검색 키워드, 페이지 정보를 입력받아 서비스 계층에 전달하고, 검색된 정책 목록과 총 개수를 포함한 결과를 반환한다.
@RequestParam
: HTTP 요청의 파라미터를 메서드 파라미터에 바인딩한다.@Valid
: 해당 파라미터의 유효성 검사를 수행한다.@NotNull
: 파라미터가 null이 아님을 검증한다.@Min
: 파라미터의 최소값을 지정한다.pageIndex
(기본값: 1, 최소값: 1)- 클라이언트 입장에서 페이지는 1부터 시작하는 것이 기본적
- 서비스 계층에서 JPA 사용을 위해
pageIndex - 1
로 변환 (JPA의 Page가 0부터 시작)
pageSize
(기본값: 10, 최소값: 10)- 일반적인 페이징 UI에서 흔히 사용되는 값
- 너무 적은 데이터는 UX를 저하시킬 수 있어 최소값 10으로 설정
- 복합 인덱스를 통한 검색 성능 최적화
- SQL Injection 방지를 위한 특수문자 이스케이프 처리
- 검색 조건 로직의 분리
- 로깅
- 검색어 유효성 검증
- 기존 코드
public PolicySearchResponseDto getSearchPolicyList(Long userId, String keyword, Integer pageIndex, Integer pageSize) { // 사용자 존재 여부 확인 User user = getUserById(userId); // 검색어 전처리 String fullKeyword = keyword.trim(); // 문자열의 앞뒤 공백 제거 String[] splitKeywords = fullKeyword.split("\\s+"); // 공백으로 키워드 구분 // 동적 쿼리 생성 Specification<YouthPolicy> spec = createSearchSpecification(fullKeyword, splitKeywords); // 최신순 정렬 PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, Sort.by(Sort.Direction.ASC, "rnum")); // 검색 실행 Page<YouthPolicy> searchResult = youthPolicyRepository.findAll(spec, pageRequest); // 찜한 정책 목록을 한 번에 조회 (N+1 문제 해결) Set<String> favoriteIds = getFavoritePolicyIds(user.getId()); // 검색된 정책들을 DTO로 변환 List<PolicyListResponseDto> policies = searchResult.getContent().stream() .map(policy -> PolicyListResponseDto.from(policy, favoriteIds.contains(policy.getId()))) .toList(); // 응답 DTO 생성 및 반환 return PolicySearchResponseDto.of(policies, searchResult.getTotalElements()); } // 검색 조건 생성 private Specification<YouthPolicy> createSearchSpecification(String fullKeyword, String[] splitKeywords) { return (root, query, builder) -> { List<Predicate> predicates = new ArrayList<>(); // 1. 전체 문구 검색 조건 List<Predicate> fullKeywordPredicates = new ArrayList<>(); fullKeywordPredicates.add(builder.like(root.get("title"), "%" + fullKeyword + "%")); fullKeywordPredicates.add(builder.like(root.get("introduce"), "%" + fullKeyword + "%")); fullKeywordPredicates.add(builder.like(root.get("applicationDetails"), "%" + fullKeyword + "%")); predicates.add(builder.or(fullKeywordPredicates.toArray(new Predicate[fullKeywordPredicates.size()]))); // 2. 키워드별 검색 조건 List<Predicate> keywordMatchPredicates = new ArrayList<>(); for (String kw : splitKeywords) { List<Predicate> singleKeywordPredicates = new ArrayList<>(); singleKeywordPredicates.add(builder.like(root.get("title"), "%" + kw + "%")); singleKeywordPredicates.add(builder.like(root.get("introduce"), "%" + kw + "%")); singleKeywordPredicates.add(builder.like(root.get("applicationDetails"), "%" + kw + "%")); keywordMatchPredicates.add(builder.or(singleKeywordPredicates.toArray(new Predicate[singleKeywordPredicates.size()]))); } if (!keywordMatchPredicates.isEmpty()) { predicates.add(builder.and(keywordMatchPredicates.toArray(new Predicate[keywordMatchPredicates.size()]))); } // 전체 문구 검색 OR 키워드별 검색 조건 만족 return builder.or(predicates.toArray(new Predicate[0])); }; } // 사용자가 찜한 정책 ID 목록 조회 private Set<String> getFavoritePolicyIds(Long userId) { return favoritePolicyRepository.findByUserId(userId) .stream() .map(FavoritePolicy::getPolicyId) .collect(Collectors.toSet()); }
에러 코드 추가
ErrorCode
INVALID_SEARCH_KEYWORD(40008, HttpStatus.BAD_REQUEST, "검색어는 2자 이상 입력해주세요.")
4. 서비스 구현
1. getSearchPolicyList() - 메인 로직
2. createSearchSpecification() - 검색 조건 생성
3. getFavoritePolicyIds() - 찜한 정책 조회
코드의 가독성과 각 메서드의 단일 책임을 위해 3개의 메서드로 분리했다.
4.1. 정책 검색 메인 메서드
getSearchPolicyList
public PolicySearchResponseDto getSearchPolicyList(Long userId, String keyword, Integer pageIndex, Integer pageSize) {
// 사용자 존재 여부 확인
User user = getUserById(userId);
// 검색어 검증 (null 체크 및 최소 2자 이상)
if(keyword == null || keyword.trim().length() < 2) {
throw new CommonException(ErrorCode.INVALID_POLICY_SEARCH_KEYWORD);
}
// 최신순 정렬 (rnum이 작을수록 최신)
PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, Sort.by(Sort.Direction.ASC, "rnum"));
// 동적 검색 조건 생성
Specification<YouthPolicy> spec = createSearchSpecification(keyword);
// 검색 실행
Page<YouthPolicy> searchResult = youthPolicyRepository.findAll(spec, pageRequest);
// 사용자가 찜한 정책 목록 조회
Set<String> favoriteIds = getFavoritePolicyIds(user.getId());
// 검색 결과를 DTO로 변환 및 찜하기 정보 포함
List<PolicyListResponseDto> policies = searchResult.getContent().stream()
.map(policy -> PolicyListResponseDto.from(policy, favoriteIds.contains(policy.getId())))
.toList();
// 응답 DTO 생성 및 반환
return PolicySearchResponseDto.of(policies, searchResult.getTotalElements());
}
검색어를 기반으로 정책을 검색하고, 사용자의 찜하기 정보를 포함해 결과를 반환한다.
PageRequest로 페이징 처리
PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, Sort.by(Sort.Direction.ASC, "rnum"));
PageRequest는 페이징과 정렬을 한 번에 처리할 수 있는 Spring Data JPA에서 제공하는 클래스이다.
rnum
이 작을수록 최신 데이터이기 때문에 ASC 정렬을 사용했다.
Specification로 동적 검색 조건 생성
정책 검색 시 사용자는 다양한 키워드로 검색할 수 있고, 전체 문구 검색과 키워드별 검색이 모두 가능해야 한다. 이런 동적인 검색 조건을 SQL문으로 작성하면 복잡하고 유지보수가 어려워질 수 있어서, JPA의 Specification을 사용했다.
Specification은 JPA Criteria API를 사용한 동적 쿼리 생성을 위한 인터페이스이다. 검색 조건을 객체 지향적으로 만들 수 있고, 복잡한 SQL 없이 동적 쿼리를 만들 수 있다.
Specification<YouthPolicy> spec = createSearchSpecification(keyword);
Specification을 사용하면 title
, introduce
, applicationDetails
필드에 대한 검색 조건을 각각 생성하고, OR 조건으로 조합하는 과정을 객체 지향적으로 구현할 수 있다. (아래 createSearchSpecification 메서드 참고)
Page
Page<YouthPolicy> searchResult = youthPolicyRepository.findAll(spec, pageRequest);
Page는 페이징 처리된 결과를 담는 Spring Data JPA의 인터페이스이다. 검색 결과를 한 번에 가져오지 않고 페이지 단위로 나누어서 가져올 수 있고, getTotalElements()
를 통해 전체 검색 결과 수도 제공한다.
Set 사용과 getFavoritePolicyIds 분리
사용자가 찜한 정책 정보와 검색 결과를 매칭할 때 최적화하는 과정이 필요했다.
Set<String> favoriteIds = getFavoritePolicyIds(user.getId());
List<PolicyListResponseDto> policies = searchResult.getContent().stream()
.map(policy -> PolicyListResponseDto.from(policy, favoriteIds.contains(policy.getId())))
.toList();
Set의 contains()는 O(1)의 시간복잡도로 각 정책의 찜하기 여부를 빠르게 확인할 수 있다. 그리고 from 메서드에 찜하기 여부를 확인하지 않고 getFavoritePolicyIds 로 분리하여 한 번의 쿼리로 찜하기 정보를 모두 가져와서 N+1 문제를 방지하도록 했다.
4.2. 검색 조건 생성 메서드
createSearchSpecification
private Specification<YouthPolicy> createSearchSpecification(String keyword) {
// SQL Injection 방지를 위한 특수문자 이스케이프 처리
String escapedKeyword = keyword.trim().replaceAll("[%_\\\\]", "\\\\$0");
return (root, query, builder) -> {
List<Predicate> predicates = new ArrayList<>();
// 1. 전체 문구 검색
List<Predicate> fullKeywordPredicates = Arrays.asList(
builder.like(root.get("title"), "%" + escapedKeyword + "%"),
builder.like(root.get("introduce"), "%" + escapedKeyword + "%"),
builder.like(root.get("applicationDetails"), "%" + escapedKeyword + "%")
);
predicates.add(builder.or(fullKeywordPredicates.toArray(new Predicate[fullKeywordPredicates.size()])));
// 2. 공백으로 분리된 키워드별 검색
String[] keywords = escapedKeyword.split("\\s+");
if(keywords.length > 1) { // 여러 개의 키워드가 있을 때
List<Predicate> keywordPredicates = Arrays.stream(keywords)
.map(kw -> Arrays.asList(
builder.like(root.get("title"), "%" + kw + "%"),
builder.like(root.get("introduce"), "%" + kw + "%"),
builder.like(root.get("applicationDetails"), "%" + kw + "%")
))
.map(fieldPredicates -> builder.or(fieldPredicates.toArray(new Predicate[0])))
.collect(Collectors.toList());
// 모든 키워드가 하나 이상의 필드에 포함되어야 함 (AND 조건)
predicates.add(builder.and(keywordPredicates.toArray(new Predicate[0])));
}
// 전체 문구 검색 결과 OR 키워드별 검색 결과
return builder.or(predicates.toArray(new Predicate[0]));
};
}
전체 문구 검색과 공백으로 구분한 키워드별 검색을 조합하여 검색 조건을 생성한다.
SQL Injection 방지 처리
사용자가 입력한 검색어를 그대로 쿼리에 사용하면 보안에 취약할 수 있다.
ex) 검색어에 ' OR '1'='1
를 입력하면 모든 정책이 노출될 수 있다.
SELECT * FROM youth_policies
WHERE title LIKE '%' OR '1'='1%' -- 항상 참이 되어 모든 데이터가 노출됨
이렇게 되면 WHERE 절이 항상 참이 되어 모든 정책 데이터가 노출되는 문제가 발생한다. 특수문자를 이스케이프 처리하여 이 문제를 방지할 수 있다.
String escapedKeyword = keyword.trim().replaceAll("[%_\\\\]", "\\\\$0");
replaceAll
을 사용하여 위험 가능성이 있는 문자를 치환한다. 정규식 패턴 [%_\\\\]
는 %, _, \ 문자를 찾고, \\\\$0
는 찾은 문자 앞에 \를 붙여 이스케이프 처리한다.이렇게 하면 특수문자들이 검색어 자체로 인식되어 SQL 쿼리 구조를 변경할 수 없게 된다.
동적 검색 조건 생성
예를 들어 “청년 주거 지원”이라고 검색했을 때, 전체 문구로도 검색하고 “청년”, “주거”, “지원” 각각의 키워드로도 검색해서 관련된 정책을 모두 찾을 수 있도록 했다.
// 1. 전체 문구 검색
predicates.add(builder.or(fullKeywordPredicates.toArray(new Predicate[fullKeywordPredicates.size()])));
// 2. 키워드별 검색
if(keywords.length > 1) {
predicates.add(builder.and(keywordPredicates.toArray(new Predicate[0])));
}
// 전체 문구 검색 결과 OR 키워드별 검색 결과
return builder.or(predicates.toArray(new Predicate[0]));
- 전체 문구 검색: 입력된 검색어를 그대로 사용하여 각 필드를 검색한다.
- 키워드별 검색: 공백으로 분리된 각 키워드가 모든 필드에서 검색되도록 AND 조건을 적용한다.
- OR 조건으로 두 검색 결과를 통합한다.
복합 인덱스를 사용하지 않은 이유
정책 검색은 사용자들이 자주 사용하게 될 기능이라고 생각한다. 그래서 처음에는 title, introduce, applicationDetails 이 세 개의 필드를 함께 검색하는 경우가 많아질 것이라고 생각하여 이 세 필드에 대해 복합 인덱스를 적용할까 생각했다. 그런데 찾아보니 현재 구현된 검색 방식에는 효과가 없다고 한다.
-- 현재 사용 중인 LIKE 검색 방식
WHERE title LIKE '%검색어%'
OR introduce LIKE '%검색어%'
OR application_details LIKE '%검색어%'
LIKE 검색에서 와일드카드(%)가 검색어 앞에 있으면 인덱스를 활용할 수 없다. "청년"으로 검색할 때 "청년
", "
청년" 모두를 찾기 위해 '%청년%' 패턴을 사용하는데, 이 경우 데이터베이스는 인덱스를 사용하지 않고 전체 테이블을 스캔하게 된다.
그래서 현재는 인덱스를 생성하는 것이 불필요하고, 만약 '검색어%’와 같이 검색어로 시작하는 패턴을 사용하게 되면 인덱스 적용을 고려할 수 있다.
4.3. 사용자가 찜한 정책 ID 목록 조회 메서드
getFavoritePolicyIds
private Set<String> getFavoritePolicyIds(Long userId) {
// 사용자가 찜한 정책 목록을 조회하여 정책 ID만 Set으로 변환
return favoritePolicyRepository.findByUserId(userId)
.stream()
.map(FavoritePolicy::getPolicyId)
.collect(Collectors.toSet());
}
검색 결과에 찜하기 여부를 표시하기 위해 각 정책마다 찜하기 정보를 조회하면 N+1 문제로 성능이 저하될 수 있다. 이 문제를 해결하기 위해서는 사용자의 찜하기 정보를 한 번의 쿼리로 모두 가져오고, Set에 담아 메모리에서 빠르게 조회할 수 있도록 했다. 이렇게 하면 검색 결과가 많더라도 찜하기 정보 조회로 인한 추가 쿼리는 발생하지 않는다.
N+1 문제란
N+1 문제는 연관 관계가 있는 엔티티를 조회할 때 발생하는 문제이다. 검색 결과로 N개의 정책이 조회되었다면, 각 정책마다 찜하기 정보를 조회하기 위해 N번의 추가 쿼리가 발생하는 것이다. 이렇게 되면 데이터베이스에 큰 부하를 주게 된다.
페이징 처리 설정
Pageable pageable = PageRequest.of(pageIndex, display);
Page<YouthPolicy> youthPolicyPage;
Spring Data JPA에서 제공하는 Pageable 인터페이스와 PageRequest 클래스를 사용해 페이징 처리한다.
Pageable 인터페이스
페이지 정보를 나타내는 인터페이스로 PageRequest.of
메서드를 통해 생성된다.
ex)
Pageable pageable = PageRequest.of(2, 10);
PageRequest.of(페이지 번호, 페이지 당 항목수)
- 페이지 번호는 0부터 시작한다. (위 예시에서 2번째 페이지이기 때문에 실제로는 3번째 페이지에 해당)
⇒ 31번째 레코드부터 10개의 레코드 조회
정책 데이터 검색
if (regionList != null && classificationList != null) { // regionList와 classificationList가 모두 null이 아닌 경우
youthPolicyPage = youthPolicyRepository.findByRegionInAndClassificationIn(regionList, classificationList, pageable);
} else if (regionList != null) { // 하나만 null인 경우
youthPolicyPage = youthPolicyRepository.findByRegionIn(regionList, pageable);
} else if (classificationList != null) { // 하나만 null인 경우
youthPolicyPage = youthPolicyRepository.findByClassificationIn(classificationList, pageable);
} else { // 모두 null인 경우
youthPolicyPage = youthPolicyRepository.findAll(pageable);
}
“정책 필터링 조건 설정” 단계에서 생성한 regionList와 classificationList를 기반으로 데이터베이스에서 정책 데이터를 검색(조회)한다.
조건
- regionList와 classificationList가 모두 null이 아닌 경우:
findByRegionInAndClassificationIn
메서드를 통해 지역과 정책 분류에 따라 정책을 검색한다. - 하나만 null인 경우: 각각
findByRegionIn
또는findByClassificationIn
메서드를 사용헤 해당 필터링 조건에 따라 검색한다. - 모두 null인 경우:
findAll
메서드를 사용해 모든 정책 데이터를 조회한다.
데이터 매핑
List<PolicyListResponseDto> policyListResponseDtos = youthPolicyPage.getContent().stream()
.map(PolicyListResponseDto::from)
.collect(Collectors.toList());
데이터베이스에서 조회한 YouthPolicy 엔티티들을 PolicyListResponseDto 로 변환해 리스트로 수집한다.
youthPolicyPage.getContent().stream()
을 통해 YouthPolicy 엔티티들의 스트림을 생성하고,.map(PolicyListResponseDto::from)
을 통해 각 엔티티들을 PolicyListResponseDto 로 변환한 다음,.collect(Collectors.toList())
로 변환된 DTO 객체들을 리스트로 수집힌다.
'Projects > 청하-청년을 위한 커뮤니티 서비스' 카테고리의 다른 글
[청하] 밸런스 게임/토론 기능 구현 - (1) 설계 (0) | 2025.04.04 |
---|---|
[청하] Swagger UI 개선 - API 문서 가독성 향상 (0) | 2025.04.03 |
[청하] 정책 필터링 조회 기능 - LazyInitializationException 해결 (feat. 트랜잭션 범위 확장과 프록시 컬렉션 복사) (0) | 2025.02.02 |
[청하] 맞춤 & 핫한 정책 조회 기능 - MySQL의 ONLY_FULL_GROUP_BY 오류 해결 (0) | 2025.01.01 |
[청하] AI 사서 프롬프트 엔지니어링 (0) | 2024.12.31 |
댓글