본문 바로가기
Projects/청하-청년을 위한 커뮤니티 서비스

[청하] 청년 정책 검색 기능 - (2) 구현

by Lpromotion 2025. 2. 4.

청년 정책 서비스에서 검색 기능이 빠질 수 없다. 사용자들이 본인에게 필요한 정책을 빠르고 정확하게 찾을 수 있어야 한다.

고려 사항

  1. 키워드 처리 로직
  2. 사용자가 “청년 취업 지원” 처럼 공백으로 구분된 검색어를 입력했을 때, “쳥년 취업 지원 사업” 같은 전체 문구가 그대로 포함된 정책도 찾아야 하고, “청년을 위한 취업 교육 지원”처럼 각 키워드가 따로 포함된 정책도 검색되어야 한다.
  3. 검색 대상 필드
  4. 사용자가 찾으려는 정책을 최대한 포함하도록 검색 범위를 선정했다. 청년 정책 데이터에는 다양한 정보가 포함되어 있는데, 그중에서 제목(title), 정책 소개(introduce), 신청 상세내용(applicationDetails) 필드가 정책의 가장 핵심 필드라고 판단하여 선택했다.
  5. 검색 결과 정렬
  6. 검색 기능에 대한 여러 레퍼런스를 찾아봤을 때, 정렬로는 “정확도순” 또는 “최신순” 등이 있었다. 우선 기장 기본적인 검색 기능만 구현하기 위해 “최신순” 정렬을 먼저 구현하기로 했다.

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 {
    // ... (필드 생략)
}

(복합 인덱스 추가로 검색 성능 향상)

  1. 복합 인덱스 영향:
  • 검색 쿼리 성능은 향상되지만, 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를 기반으로 데이터베이스에서 정책 데이터를 검색(조회)한다.

조건

  1. regionList와 classificationList가 모두 null이 아닌 경우: findByRegionInAndClassificationIn 메서드를 통해 지역과 정책 분류에 따라 정책을 검색한다.
  2. 하나만 null인 경우: 각각 findByRegionIn 또는 findByClassificationIn 메서드를 사용헤 해당 필터링 조건에 따라 검색한다.
  3. 모두 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 객체들을 리스트로 수집힌다.

반응형

댓글