이전에 구현한 외부 청년 정책 데이터 수집 기능을 바탕으로 저장된 정책 데이터를 조회할 수 있는 “정책 리스트 조회” 기능을 구현했다.
클라이언트의 구현에 최대한 적은 리소스가 들도록 외부 정책 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>
값이 들어가고, LIMIT
과 OFFSET
은 Pageable
객체를 통해 페이징이 적용된다.
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 = "")`로 설정하여 클라이언트가 해당 파라미터를 제공하지 않았을 때 빈 문자열(””)을 기본값으로 사용한다. 안드로이드 팀원의 요청에 따라 “®ion=&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`를 기반으로 데이터베이스에서 정책 데이터를 검색(조회)한다.
조건
- 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 객체들을 리스트로 수집힌다.
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(일자리)
'Projects > 청하-청년을 위한 커뮤니티 서비스' 카테고리의 다른 글
[청하] 28. GCP 설정 (feat. GCS 설정 및 스프링부트 연동) (0) | 2024.10.19 |
---|---|
[청하] 27. 정책 상세 조회 기능 구현 (3) | 2024.10.18 |
[청하] 25. 청년 정책 데이터 리프레시 기능 구현 (0) | 2024.10.17 |
[청하] 24. 청년 정책 Open API 연동 및 데이터베이스 저장 (feat. XML 데이터 매핑, 스케줄러 적용) (5) | 2024.10.17 |
[청하] 23. 게시글 삭제 기능 - 외래키 제약 조건 수정 (2) | 2024.10.16 |
댓글