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

[청하] 26. 정책 리스트 조회 기능 구현

by Lpromotion 2024. 10. 18.

이전에 구현한 외부 청년 정책 데이터 수집 기능을 바탕으로 저장된 정책 데이터를 조회할 수 있는 “정책 리스트 조회” 기능을 구현했다.

클라이언트의 구현에 최대한 적은 리소스가 들도록 외부 정책 API를 호출하는 것과 최대한 비슷하게 구현했다.

 

1. 프로젝트 구조

src/main/java/com/example/withpeace/
│
├── config/                  # 설정 관련 클래스
│   ├── SecurityConfig.java  # Spring Security 설정
│   └── WebMvcConfig.java    # Web MVC 설정
│
├── constant/
│   └── Constant.java        # 상수 정의
│
├── domain/                 # 도메인 모델 (엔티티)
│   └── YouthPolicy.java    # 청년 정책 엔티티
│
├── repository/                       # 데이터 접근 계층
│   └── YouthPolicyRepository.java    # 게시글 레포지토리
│
├── dto/                                     # 데이터 전송 객체
│   └── response/
│       └── PolicyListResponseDto.java       # 정책 리스트 응답 DTO
│
├── controller/               # 컨트롤러 계층
│   └── PolicyController.java # 정책 관련 API 엔드포인트
│
└── service/                  # 비즈니스 로직 계층
    └── PolicyService.java    # 정책 관련 비즈니스 로직

 

2. Spring Security 설정 파일 구현

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    // ... (기존 설정 생략)

    @Bean
    protected SecurityFilterChain securityFilterChain(final HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .sessionManagement((sessionManagement) ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(requestMatcherRegistry -> requestMatcherRegistry
                        // ... (다른 경로 생략)
                        .requestMatchers("/api/v1/policies/**").permitAll()
                        .anyRequest().authenticated())

                // ... (기존 설정 생략)
    }
}
.requestMatchers("/api/v1/policies/**").permitAll()

`/api/v1/policies/` 로 시작하는 모든 경로에 대해 인정없이 모든 사용자의 접근을 허용한다.

 

 

3. 상수 정의

Constant

public class Constant {
    // ... (다른 상수들)
    
    public static final List<String> NO_NEED_AUTH_URLS = List.of(
            // ... (다른 URL들)
            "/api/v1/policies", // 정책 관련 URL 추가
    );
}

Constant 클래스에서는 인증이 필요없는 URL 목록을 정의한다.

`NO_NEED_AUTH_URLS` 리스트에 정책 관련 URL을 추가했다. 이 경로에 대한 요청은 JWT 인증 필터를 거치지 않는다.

 

3. 레포지토리 구현

YouthPolicyRepository

public interface YouthPolicyRepository extends JpaRepository<YouthPolicy, Long> {

    Page<YouthPolicy> findByRegionInAndClassificationIn(List<EPolicyRegion> regions, List<EPolicyClassification> classifications, Pageable pageable);

    Page<YouthPolicy> findByRegionIn(List<EPolicyRegion> regions, Pageable pageable);

    Page<YouthPolicy> findByClassificationIn(List<EPolicyClassification> classifications, Pageable pageable);

}
  • findByRegionInAndClassificationIn: 지역과 정책 분야를 기준으로 정책을 검색한다.
  • findByRegionIn: 지역만을 기준으로 정책을 검색한다.
  • findByClassificationIn: 정책 분야만을 기준으로 정책을 검색한다.

 

Pageable pageable 파라미터와 Page<YouthPolicy> 반환 타입은 Spring Data JPA에서 제공하는 페이징 기능이다.

  • Pageable : 페이지 번호, 페이지 크기, 정렬 정보 등을 포함하는 인터페이스이다.
  • Page<YouthPolicy> : 쿼리 결과와 함께 총 결과 수, 총 페이지 수 등의 페이징 정보를 포함하는 인터페이스이다.

ex) findByRegionIn 메서드는 아래의 SQL 쿼리로 변환된다.

SELECT * FROM youth_policies
WHERE region IN (:regions)
LIMIT :pageSize OFFSET :offset;

:regions는 메서드에 전달된 List<EPolicyRegion> 값이 들어가고, LIMITOFFSETPageable 객체를 통해 페이징이 적용된다.

 

3. DTO 구현

YouthPolicyResponseDto

package com.example.withpeace.dto.response;

import com.example.withpeace.domain.YouthPolicy;
import com.example.withpeace.type.EPolicyClassification;
import com.example.withpeace.type.EPolicyRegion;
import lombok.Builder;

@Builder
public record PolicyListResponseDto(
        String id, // 정책 ID
        String title, // 정책명
        String introduce, // 정책 소개
        EPolicyClassification classification, // 정책 분야
        EPolicyRegion region, // 지역
        String ageInfo) { // 연령 정보

    public static PolicyListResponseDto from(YouthPolicy policy) {
        return new PolicyListResponseDto(
                policy.getId(),
                policy.getTitle(),
                policy.getIntroduce(),
                policy.getClassification(),
                policy.getRegion(),
                policy.getAgeInfo()
        );
    }
}

PolicyListResponseDto 클래스는 정책 리스트 조회 결과를 전달하기 위한 DTO 이다.

 

from 메서드

YouthPolicy 엔티티 객체를 받아 PolicyListResponseDto 객체로 변환하는 정적 메서드이다.

이 메서드를 통해 엔티티 객체에서 DTO 객체로 데이터를 쉽게 변환할 수 있다. Service 코드를 작성하며 코드 가독성을 위해 추가했다. Service 계층에서 엔티티를 DTO로 변환해 Controller에 전달할 때 유용하다.

 

4. 컨트롤러 구현

YouthPolicyController

@RestController
@RequiredArgsConstructor
@RequestMapping("api/v1/policies")
@Slf4j
public class YouthPolicyController {
    private final YouthPolicyService youthPolicyService;
    // ... (기존 코드 생략)

    // 정책 리스트 조회
    @GetMapping("")
    public ResponseDto<?> getPolicyList(@RequestParam(defaultValue = "") String region,
                                        @RequestParam(defaultValue = "") String classification,
                                        @RequestParam(defaultValue = "1") @Valid @NotNull @Min(1) Integer pageIndex,
                                        @RequestParam(defaultValue = "10") @Valid @NotNull @Min(10) Integer display) {
        // 인자 값이 빈 문자열인 경우 null로 처리하여 전달
        List<PolicyListResponseDto> policyList = youthPolicyService.getPolicyList(
                region, classification, pageIndex - 1, display);

        return ResponseDto.ok(policyList);
    }
}

 

필터링 조건

region은 지역 코드, classification은 정책 분류 코드로 쉼표(,)로 구분해 여러 값을 받을 수 있다.

region과 classification은 `@RequestParam(defaultValue = "")`로 설정하여 클라이언트가 해당 파라미터를 제공하지 않았을 때 빈 문자열(””)을 기본값으로 사용한다. 안드로이드 팀원의 요청에 따라 “&region=&classification=”와 같이 빈 값을 입력했을 때도 전체 조회가 가능하도록 구현한 것이다.

 

페이징 정보

  • pageIndx(페이지 번호)는 1부터 시작하도록 기본값과 최소값을 1로 설정했고, getPolicyList(서비스 계층)에 전달 시 0부터 시작하도록 조정하여(pageIndex - 1) 전달했다.
    기본값을 1로 설정한 이유는 정책 Open API에서 1부터 시작하도록 제공하기 때문에 최대한 비슷하게 요청할 수 있도록 1로 설정했다. (회의에서 IOS 파트 팀원이 페이징 사용 시 pageIndex를 기본적으로 1부터 시작되도록 사용한다고 하여 이 부분도 참고했다.)
  • 왜 pageIndex를 0부터 시작하도록 조정했는지
    보통 인덱스는 0부터 시작하는 관행을 따르기 때문에 Spring Data JPA의 PageRequest 도 0부터 시작하는 페이지 번호를 사용하므로, API의 pageIndex도 0부터 시작하도록 조정하여 의도한 페이지를 가져올 수 있도록 했다.
  • display(페이지당 항목 수)는 기본값과 최소값을 10으로 설정했다.

 

5. 서비스 구현

YouthPolicyService

@Transactional
public List<PolicyListResponseDto> getPolicyList(String region, String classification, Integer pageIndex, Integer display) {
    // 지역 필터링 조건 설정
    List<EPolicyRegion> regionList = null;
    if (StringUtils.isNotBlank(region)) { // null, 빈 문자열, 공백만 있는 문자열을 모두 처리
        regionList = Arrays.stream(region.split(","))
                .map(EPolicyRegion::fromCode)
                .collect(Collectors.toList());
    }

    // 정책 분야 필터링 조건 설정
    List<EPolicyClassification> classificationList = null;
    if (StringUtils.isNotBlank(classification)) {
        classificationList = Arrays.stream(classification.split(","))
                .map(EPolicyClassification::fromCode)
                .collect(Collectors.toList());
    }

    // 페이지 처리 설정
    Pageable pageable = PageRequest.of(pageIndex, display);
    Page<YouthPolicy> youthPolicyPage;

    // 정책 필터링에 따른 정책 조회 (쿼리 실행)
    if (regionList != null && classificationList != null) {
        youthPolicyPage = youthPolicyRepository.findByRegionInAndClassificationIn(regionList, classificationList, pageable);
    } else if (regionList != null) {
        youthPolicyPage = youthPolicyRepository.findByRegionIn(regionList, pageable);
    } else if (classificationList != null) {
        youthPolicyPage = youthPolicyRepository.findByClassificationIn(classificationList, pageable);
    } else {
        youthPolicyPage = youthPolicyRepository.findAll(pageable);
    }

    // 데이터 매핑(엔티티를 DTO로 변환)
    List<PolicyListResponseDto> policyListResponseDtos = youthPolicyPage.getContent().stream()
            .map(PolicyListResponseDto::from)
            .collect(Collectors.toList());

    return policyListResponseDtos;
}

 

정책 필터링 조건 설정

// 지역 필터링 조건 설정
List<EPolicyRegion> regionList = null;
if (StringUtils.isNotBlank(region)) { // null, 빈 문자열, 공백만 있는 문자열을 모두 처리
    regionList = Arrays.stream(region.split(","))
            .map(EPolicyRegion::fromCode)
            .collect(Collectors.toList());
}

// 정책 분야 필터링 조건 설정
List<EPolicyClassification> classificationList = null;
if (StringUtils.isNotBlank(classification)) {
    classificationList = Arrays.stream(classification.split(","))
            .map(EPolicyClassification::fromCode)
            .collect(Collectors.toList());
}

전달받은 region(지역 코드), classification(정책 분류) 를 기반으로 정책 필터링을 위한 리스트를 생성한다.

 

` StringUtils.isNotBlank()` 메서드를 사용하여 전달받은 매개변수가 null, 빈 문자열, 공백만으로 이루어진 문자열인지 확인한다. 컨트롤러 단계에서 빈 값을 입력받았을 때, 빈 문자열로 처리되기 때문에 빈 문자열과 공백으로만 이루어진 문자열에 대한 경우를 처리하기 위해 사용한 것이다.
(클라이언트의 실수가 없다면 사실 빈 문자열만 처리하도록 해도 상관없긴 하다. 방어적 프로그래밍이라고 볼 수 있다.)

 

유효한 값이 있다면, 쉼표(,)로 구분된 문자열을 분할해 각 지역 코드 또는 정책 분류 코드를 fromCode 메서드를 통해 Enum 타입으로 변환하고 리스트로 수집한다.

 

regionList 와 classificationList 는 “정책 데이터 검색” 단계에서 데이터베이스 쿼리에서 필터링 조건으로 사용된다.

 

페이징 처리 설정

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 객체들을 리스트로 수집힌다.

 

6. 테스트

postman에서 테스트 수행

6.1. 필터링 조건 선택 X

  • [GET] http://localhost:8080/api/v1/policies

 

6.2. 필터링 조건 선택 O

  • [GET] http://localhost:8080/api/v1/policies?region=003002003,003002008015&classification=023030,023010
    • region=003002003(대구),003002008015(경기)
    • classification=023030(교육),023010(일자리)

반응형

댓글