개요
Dktechin 주관 기업실무 프로젝트를 진행하면서 Argument Resolver를 이용해서 컨트롤러에서 조건을 어노테이션으로 받아 조금 더 가독성있고 간단하게 사용하는 방법을 알게되어서 정리해보려고 한다.
이 방법을 통해서 검색 조건 파라미터들을 @SearchParam이라는 어노테이션으로 받아서 MemberSearchParam, BoardSearchParam 등 여러 Record 객체로 만들어서 사용했다.
HTTP 메시지 컨버터
컨트롤러에서 변수 바인딩 처리 방법
- 특정 변수에 바인딩
- @RequestParam 사용
- 가변 경로 변수 바인딩
- @PathVariable
- Http Body 변수 바인딩
- @RequestBody(주로 DTO 사용)
- 파일과 같은 Multipart
- @RequestPart
이 때 Argument Resolver를 이용하여 특정 조건에 맞는 파라미터를 DTO 객체에 바인딩하는 것으로 조건 파라미터를 좀 더 편하게 받는 방법이 있다.
동작 방식
- Client에서 요청 발생
- Dispatcher Servlet에서 요청 처리
- Client 요청에 맞는 Handler 처리
- 알맞는 Handler 매핑
- Interceptor 처리
- Argument Resolver 처리
- MessageConverter 처리
- Controller Method Invoke
Argument Resolver
예시
MemberSearchParam 해당 DTO 객체로 Member 검색 조건을 바인딩해서 사용
MemberSearchParam
package org.mongdol.dkationbe.global.aspect.member;
import lombok.AccessLevel;
import lombok.Builder;
import java.util.List;
@Builder(access = AccessLevel.PRIVATE)
public record MemberSearchParam(
String name,
String accountId,
Boolean isPenalty,
List<String> department
) {
public static MemberSearchParam of(String name, String accountId,
Boolean isPenalty, List<String> department) {
return MemberSearchParam.builder()
.name(name)
.accountId(accountId)
.isPenalty(isPenalty)
.department(department)
.build();
}
}
Argument Resolver를 사용하지 않는 경우
컨트롤러
@Operation(summary = "[어드민] 사용자 목록 조회")
@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = MemberInfosResDto.class)))
@Secured("ROLE_ADMIN")
@GetMapping
public ResponseEntity<SuccessResponse<?>> getMemberList(@RequestParam(required = false) final String name,
@RequestParam(required = false) final String accountId,
@RequestParam(required = false) final Boolean isPenalty,
@RequestParam(required = false) final List<String> department,
final Pageable pageable) {
MemberSearchParam memberSearchParam = MemberSearchParam.of(name, accountId, isPenalty, department);
final MemberInfosResDto memberInfosResDto = getMemberInfosListService.execute(memberSearchParam, pageable);
return SuccessResponse.ok(memberInfosResDto);
}
한눈에 봐도 복잡하고 작성하기 쉽지 않다.
MemberSearchParam을 한번만 사용한다면 한번만 고생하면되지만 프로젝트 특성 상 Admin, User API가 나누어져있는데다가 Admin에서 Member에 대한 필터링을 하는 경우가 여럿있어서 상당히 비효율적이다.
Argument Resolver를 사용하는 경우
컨트롤러
@Operation(summary = "[어드민] 사용자 목록 조회")
@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = MemberInfosResDto.class)))
@Secured("ROLE_ADMIN")
@GetMapping
public ResponseEntity<SuccessResponse<?>> getMemberList(@SearchRequest final MemberSearchParam searchParam,
final Pageable pageable) {
final MemberInfosResDto memberInfosResDto = getMemberInfosListService.execute(searchParam, pageable);
return SuccessResponse.ok(memberInfosResDto);
}
@SearchRequest라는 커스텀 어노테이션으로 MemberSearchParam DTO 객체로 바로 바인딩해서 받아 사용할 수 있다.
위와 비교했을 때 한눈에 봐도 효율성에서 차이가 큰 것을 볼 수 있다.
사용 방법
순서
- 커스텀 어노테이션 생성
- 바인딩할 DTO 객체 생성
- HandlerMethodArgumentResolver 인터페이스 구현체 생성
- 해당 구현체 WebConfig에 등록
HandlerMethodArgumentResolver 인터페이스
ArgumentResolver를 생성하기 위해서 해당 인터페이스의 구현체를 생성하고 등록해야 한다.
이 때 두개의 메서드가 있다.
- supportsParameter()
- parameter의 getParameterType가 원하는 DTO 객체인지 + hasParameterAnnotation이 원하는 어노테이션인지 확인한다.
- 위 예시에서는 getParameterType이 MemberSearchParam인지, hasParameterAnnotation이 @SearchRequest인지 확인
- resolveArguement()
- webRequest에서 값을 추출하여 원하는 DTO 객체로 바인딩하는 역할을 한다.
- 위 예시에서는 webReqeust에서 MemberSearchParam에 name, accountId 등을 추출하여 해당 객체를 만들고 리턴한다.
실 사용
MemberSearchParamHandlerMethodArgumentResolver
package org.mongdol.dkationbe.global.aspect.member;
import org.mongdol.dkationbe.global.annotation.SearchRequest;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import static org.mongdol.dkationbe.global.aspect.member.MemberSearchParamEnum.*;
public class MemberSearchParamHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(MemberSearchParam.class)
&& parameter.hasParameterAnnotation(SearchRequest.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
String name = webRequest.getParameter(NAME.getDesc());
String accountId = webRequest.getParameter(ACCOUNT_ID.getDesc());
Boolean isPenalty = Boolean.valueOf(webRequest.getParameter(IS_PENALTY.getDesc()));
String departmentParam = webRequest.getParameter(DEPARTMENT.getDesc());
List<String> departmentList = validateAndGetDepartmentParam(departmentParam);
return MemberSearchParam.of(name, accountId, isPenalty, departmentList);
}
private List<String> validateAndGetDepartmentParam(String departmentParam) {
if (departmentParam == null || departmentParam.isBlank()) {
return new ArrayList<>();
}
return Arrays.stream(departmentParam.split(","))
.map(String::valueOf)
.collect(Collectors.toList());
}
}
MemberSearchParamEnum
package org.mongdol.dkationbe.global.aspect.member;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public enum MemberSearchParamEnum {
NAME("name"),
ACCOUNT_ID("accountId"),
DEPARTMENT("department"),
IS_PENALTY("isPenalty"),;
private final String desc;
}
MemberSearchParam에 매핑할 정보를 Enum을 이용해서 추출했다.(필수 x)
가독성을 높이고 String 타입보다는 오타나 문제가 발생할 확률이 낮기 때문에 선택
WebConfig
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final JwtUtil jwtUtil;
public WebConfig(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new MemberSearchParamHandlerMethodArgumentResolver());
resolvers.add(getPageableResolver());
}
private PageableHandlerMethodArgumentResolver getPageableResolver() {
PageableHandlerMethodArgumentResolver resolver = new PageableHandlerMethodArgumentResolver();
resolver.setOneIndexedParameters(true);
return resolver;
}
}
@SearchReqeust
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface SearchRequest {
}
@SearchRequest라는 커스텀 어노테이션 생성
- @Interface
- 어노테이션으로 지정
- @Target(ElementType.PARAMETER)
- 해당 어노테이션이 생성될 위치 지정
- @Retention(RetentionPolicy.RUNTIME)
- 어노테이션 유지 정책 설정
- Runtime -> 바이트 코드까지 어노테이션으로 유지
- 어노테이션 유지 정책 설정
결론
같은 요청을 보내도 아주 깔끔하게 @SearchRequest MemberSearchParam 객체로 뽑아낼 수 있다.
사전 작업이 이해하기 어렵고 작성할 코드가 많았지만 이해하고 적응한다면 아주 효율적인 방법인 것 같다.
특히 MemberSearchParam처럼 검색 조건에만 사용하지 않고 JWT AccessToken의 Payload를 같은 방식으로 사용하여
매 번 AccessToken을 파싱하고 MemberInfo 객체를 생성하는 중복 코드없이 @MemberRequest MemberInfo 객체로 컨트롤러에서 바인딩하여 가져오니 가독성과 효율성이 아주 높아지는 효과를 얻을 수 있었다.
역시 아는 것이 힘이라고 매번 귀찮은데... 귀찮은데....라고 중얼거리면서 개발할 때보다 훨씬 편하게 개발하고 있다.
귀차니즘은 개발자의 원동력...인가?
역시 뭔가 중복되고 귀찮은 작업은 아무생각없이 하는 것 보다
다른 사람도 나랑 비슷하지 않을까? 그렇다면 방법이 있지 않을까? 더 줄일 방법 없을까?
하는 생각으로 개발하는 마음가짐을 가져야겠다!!!
앞으로도 화이팅!!
출처
'Dev > Spring' 카테고리의 다른 글
Controller Test하기 feat WebMvcTest (1) | 2024.06.02 |
---|---|
Spring Boot AWS S3 연결 (0) | 2024.04.27 |
@Builder 사용 시 List 초기화 NullPointException (0) | 2024.04.21 |
Spring Cloud Eureka Swagger 연결하기 (2) | 2024.04.15 |
JWT 리프레시 토큰 Cookie에 저장하기 (1) | 2024.03.28 |