목차
청년 정책 서비스에서 검색 기능이 빠질 수 없다. 사용자들이 본인에게 필요한 정책을 빠르고 정확하게 찾을 수 있어야 한다.
고려 사항
- 키워드 처리 로직
- 사용자가 “청년 취업 지원” 처럼 공백으로 구분된 검색어를 입력했을 때, “쳥년 취업 지원 사업” 같은 전체 문구가 그대로 포함된 정책도 찾아야 하고, “청년을 위한 취업 교육 지원”처럼 각 키워드가 따로 포함된 정책도 검색되어야 한다.
- 검색 대상 필드
- 사용자가 찾으려는 정책을 최대한 포함하도록 검색 범위를 선정했다. 청년 정책 데이터에는 다양한 정보가 포함되어 있는데, 그중에서 제목(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<PolicyListResponseDto> 타입의 policies로 정의하여 기존의 PolicyListResponseDto를 재사용했다. (PolicyListResponseDto는 정책의 id, title, introduce 등 기본 정보를 포함하는 정책 리스트 응답 DTO) 기존의 정책 리스트 조회와 동일한 정책 정보를 반환하고 검색 결과의 총 개수를 나타내는 totalCount 필드만 추가했다.
of 를 사용한 이유
PolicySearchResponseDto 는 이미 DTO 형태로 변환된 정책 목록 (List<PolicyListResponseDto>)과 같이 단순한 카운트 값을 받아서 새로운 응답 객체를 생성한다. 새로운 변환 로직이 필요없이 기존 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 {
// ... (필드 생략)
}
복합 인덱스 추가
- 초기 의도
- 정책 검색 기능의 성능 향상을 위해 title, introduce, applicationDetails 필드에 복합 인덱스를 추가했다.
- 검색이 빈번하게 발생하는 기능이므로, 데이터베이스 인덱스를 통해 검색 속도를 개선하려는 목적이었다.
- 실제 구현 이후 분석
- 최종적으로 구현된 검색 로직은 사용자 편의성을 위해 %검색어% 형태의 패턴을 사용하게 되었다.
- %검색어% 패턴에서는 인덱스가 사용되지 않고, 데이터베이스는 Full Table Scan을 수행하게 된다.
- 결과적으로, 현재 추가된 복합 인덱스는 실질적으로 검색 성능 향상에 기여하지 못하고 있다.
⇒ 향후 검색 방식에 대해 더 고민하면서 인덱스 활용 방안을 다시 검토할 계획
4. 컨트롤러 구현
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으로 설정
5. 에러 코드 추가
ErrorCode
INVALID_SEARCH_KEYWORD(40008, HttpStatus.BAD_REQUEST, "검색어는 2자 이상 입력해주세요.")
6. 서비스 구현
1. getSearchPolicyList() - 메인 로직
2. createSearchSpecification() - 검색 조건 생성
3. getFavoritePolicyIds() - 찜한 정책 조회
코드의 가독성과 각 메서드의 단일 책임을 위해 3개의 메서드로 분리했다.
6.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 문제를 방지하도록 했다.
6.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는 특수문자를 이스케이프 처리해서 안전하게 LIKE 검색에 사용할 수 있게 만든다. 이렇게 하면 특수문자들이 검색어 자체로 인식되어 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 검색에서 와일드카드(%)가 검색어 앞에 있으면 인덱스를 활용할 수 없다. "청년"으로 검색할 때 "청년~~", "~~청년" 모두를 찾기 위해 '%청년%' 패턴을 사용하는데, 이 경우 데이터베이스는 인덱스를 사용하지 않고 전체 테이블을 스캔하게 된다.
그래서 현재는 인덱스를 생성하는 것이 불필요하고, 만약 '검색어%’와 같이 검색어로 시작하는 패턴을 사용하게 되면 인덱스 적용을 고려할 수 있다.
6.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번째 페이지에 해당)
→ PageRequest.of(pageIndex-1, pageSize)
⇒ 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 객체들을 리스트로 수집힌다.
7. 최종 정리
구분 | 사용된 방식 |
검색어 매칭 방식 | %검색어% 형태의 부분 일치 LIKE 검색 |
검색 대상 | title, introduce, applicationDetails |
검색 쿼리 생성 | JPA Specification |
데이터 스캔 방식 | Full Table Scan (LIKE로 인해 인덱스 사용 불가) |
정렬 방식 | 최신순 (rnum ASC) |
찜 여부 처리 | 별도 조회(Set) 후 O(1) 확인 |
성능 최적화 요소 | N+1 방지 (찜 정보 조회 최적화) |
'Projects > 청하-청년을 위한 커뮤니티 서비스' 카테고리의 다른 글
[청하] 밸런스 게임/토론 기능 - (4) 댓글 조회 기능 개선 (feat. N+1 문제 방지를 위한 설계와 구현) (0) | 2025.04.30 |
---|---|
[청하] TimeFormatter 클래스 리팩터링 (feat. @UtilityClass) (0) | 2025.04.04 |
[청하] Swagger UI 개선 - API 문서 가독성 향상 (0) | 2025.04.03 |
[청하] 청년 정책 검색 기능 - (1) 설계 (0) | 2025.02.04 |
[청하] 정책 필터링 조회 기능 - LazyInitializationException 해결 (feat. 트랜잭션 범위 확장과 프록시 컬렉션 복사) (0) | 2025.02.02 |
댓글