숙박 예약 시스템을 구현하는 미니 프로젝트 진행 중 검색 인자로 checkIn, checkOut 날짜를 무조건 받아야 하는 상황이었는데 물론 프론트엔드에서 초기 값을 무조건 서버로 전달해주겠지만, 무조건 받아야 하는 default value를 서버에서도 직접 지정하고 싶었다.
@GetMapping("/v1/accommodation")
public BaseResponse<List<AccommodationResponse>> findAll(
@RequestParam(name = "checkIn", required = false) LocalDate checkIn,
@RequestParam(name = "checkOut", required = false) LocalDate checkOut) {
List<AccommodationResponse> responses = accommodationService.findAllAccommodation();
return BaseResponse.response(responses);
}
간단하게 모든 Accommodation을 조회하는 기능을 가진 메서드이다.
프로젝트에서 정한 초기값은 아래와 같다.
@GetMapping("/v1/accommodation")
public BaseResponse<List<AccommodationResponse>> findAll(
@RequestParam(name = "checkIn", required = false) LocalDate checkIn,
@RequestParam(name = "checkOut", required = false) LocalDate checkOut) {
// null체크 및 default value 주입
if(checkIn == null) {
checkIn = LocalDate.now();
}
if(checkOut == null) {
checkOut = LocalDate.now();
}
List<AccommodationResponse> responses = accommodationService.findAllAccommodation();
return BaseResponse.response(responses);
}
외부에서 받아온 값을 1차적으로 체크해 결과에 따라 로직 내부에서 새로운 value를 주입하는 방법이다. 가장 간단한 방법이지만 위의 검증문을 Controller에 놓을지, Service로직에 놓을지에 대한 고민과 전체 조회 기능 말고도 여러가지의 조회 & 검색 메서드를 사용하고 있는데 이 상황해서 해당 검증문을 통해 값을 주입하는 방식을 사용하면 메서드 별로 검증문을 놓거나 record를 사용해야 하는 등의 부가 메서드만 늘어날 것 같고, record 사용 자체가 익숙하지 않아 해당 방법은 선택하지 않았다.
@RequestMapping("/v1/accommodation")
public BaseResponse<List<AccommodationResponse>> findAll(
@RequestParam(name = "checkIn", required = false, defaultValue = "#{T(java.time.LocalDate).now()}")
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate checkIn,
@RequestParam(name = "checkOut", required = false, defaultValue = "#{T(java.time.LocalDate).now().plusDays(1)}")
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate checkOut) {
List<AccommodationResponse> responses = accommodationService.findAllAccommodation();
return BaseResponse.response(responses);
}
@RequestParam의 defaultValue를 사용하면 문제를 손쉽게 해결할 수 있었다. SpEL(Spring Expression Langauge)을 사용해 메서드의 파라미터에 값이 전달되지 않은 경우 Java의 LocalDate에 직접 접근해 현재 날짜를 defaultValue로 설정했다.
위 방법의 단점 또한 checkIn과 checkOut을 받는 모든 @RequestParam 설정에 defaultValue를 추가해야 함으로써 파라미터 부분의 코드가 지저분해지는 것이라고 생각했다. 물론 로직 자체에도 문제가 없고 검증문을 추가하는 것 처럼 6~7줄이 추가되지도 않으니 신경쓰지 않을 수도 있다.
하지만 나는 Swagger API로 문서를 작성하고 있고, 기능 구현이 거의 끝난 이후에는 Swagger 문서화를 본격적으로 진행할 생각이다. 그 과정에서 클래스 & 메서드 단위에 붙게되는 어노테이션이 늘어나게 될 것이고, 컨트롤러 레이어에 클라이언트와의 통신을 통해 비즈니스 로직을 호출하는 부분보다 어노테이션이 더 많아지는 경우를 보게 된다면 상당히 지저분할 것 같다고 느낄 것 같았다.
또 다른 단점은 defaultValue를 설정하기 위해 @RequestParam 자체에 종속되어야 한다는 문제였다. 첫번째 방법인 검증문을 사용한다면 요청 파라미터를 바인딩하는 어노테이션을 다른 종류로 바꾼다고 해도 defaultValue를 설정하는 것 자체에는 문제가 없을 것이다.(물론 바인딩 방법에 따라 권장되는 방식이 있을 수 있다. 그부분에 대해서는 생략하자)
하지만 @RequestParam으로 defaultValue를 바인딩하는 방법을 결과적으로 선택하게 된다면 추후 개발 과정에서 변경이 필요한 사항이 생기거나 관련 정책을 바꿀 때 코드에 미치는 영향이 클 것이다.
나는 defaultValue를 설정하는 과정에서 작동하는 로직을 전역적으로 설정하고 싶었고, 변경이나 확장에도 용이하게 활용할 수 있는 기능을 원했고, 추후 checkIn, checkOut이 아닌 다른 파라미터의 defaultValue를 정할 수도 있는 상황을 예상했기에 위의 두가지 방법 이외에 다른 방법이 있지 않을까 고민했다.
나는 현재 ArgumemtResolver와 커스텀 어노테이션을 사용해 jwt토큰으로부터 인증된 사용자 정보를 추출해 컨트롤러에 파라미터 값을 넘겨주고 있다.
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MemberInfo { // jwt 토큰의 사용자 정보를 받기 위한 dto객체
private Long id;
private String email;
private Role role;
public static MemberInfo toParameter(Member member) {
return MemberInfo.builder()
.id(member.getId())
.email(member.getEmail())
.role(member.getRole())
.build();
}
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Parameter(hidden = true)
public @interface JwtAuthentication { // ArgumemtResolver로 파라미터 값을 보내기 위한 어노테이션
}
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthMemberResolver implements HandlerMethodArgumentResolver {
private final MemberRepository memberRepository;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(JwtAuthentication.class); // 해당 어노테이션 사용 여부 검증
}
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 사용자가 인증되지 않은 경우 예외 처리
if (authentication == null || !(authentication.getPrincipal() instanceof UserDetails userDetails)) {
throw new BaseException(NOT_AUTHENTICATED_USER);
}
MemberPrincipal memberPrincipal = memberRepository.findByMemberEmail(userDetails.getUsername())
.map(MemberPrincipal::new)
.orElseThrow(() -> new BaseException(EMAIL_NOT_FOUND));
return MemberInfo
.toParameter(memberPrincipal.getMember()
.orElseThrow(() -> new BaseException(PRINCIPAL_IS_NOT_FOUND)));
}
}
먼저 Jwt를 구성한 사용자 정보를 받기 위한 dto 객체(MemberInfo)를 생성했다. @JwtAuthentication
어노테이션을 생성해 ArgumemtResolver와 MemberInfo를 바인딩 후 인증 여부를 판단해 MemberInfo를 리턴하는 방식으로 사용했다.
ArgumemtResolver를 사용하면 컨트롤러 메서드 작동 전 checkIn, checkOut 요청 값을 탈취한 후 검증을 통해 여부에 따라 defaultValue를 설정해주는 것도 가능하다고 판단했다. 무엇보다 내가 원하던 전역 설정이 가능하기 때문에 어쩌면 가장 적합한 방법이 아닐까 생각했다. 하지만 Resolver를 정의하고 checkIn, checkOut 파라미터를 받기 위한 dto생성 및 resolver와 dto의 바인딩 방법을 고려해야한다는 과제가 생겨 보류하게 되었다.
현재 나에게 맞는 여러가지 방법을 고민해보고, 관련 사례를 찾아본 결과 AOP(Aspect-Oriented Programming)를 도입한다는 결론이 나왔다.
AOP(Aspect-Oriented Programming)는 관점 지향 프로그래밍이라고 불린다. 스프링에서 설명하는 관점 지향은 어떠한 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나누어 보고 그 관점을 기준으로 모듈화한다는 것이다.
나는 AOP를 활용해 내가 Controller 레이어에서 필수로 받아야 하는 checkIn, checkOut 파라미터의 default value를 전역적으로 관리하고 AOP를 구성하는 디렉토리를 따로 만들어 기능 구현을 위한 레이어와 분리해 사용하는 방식을 선택했다.
먼저 내가 다루어야 하는 로직을 핵심적인 관점과 부가적인 관점으로 나누어서 바라보는 단계부터 시작했다.
checkIn
과 checkOut
이 null이거나 공백일 경우 기존에 설정한 정책대로 defaultValue를 사용하는 것관점을 기준으로 파라미터의 defaultValue 설정이라는 관심사를 모듈화 한 후 코드로 작성했다.
@Aspect
@Component
public class DateParamDefaultValueAspect {
@Around(value = "execution(* com.core.miniproject.src.accommodation.controller.*.*(..)) && args(checkIn, checkOut, ..)", argNames = "joinPoint, checkIn, checkOut")
public Object setDefaultParameterValues(ProceedingJoinPoint joinPoint, LocalDate checkIn, LocalDate checkOut) throws Throwable {
// checkIn과 checkOut이 null인 경우 defaultValue로 설정
if (checkIn == null) {
checkIn = LocalDate.now();
}
if (checkOut == null) {
checkOut = LocalDate.now().plusDays(1);
}
// 수정된 인자로 메서드 실행
Object result = joinPoint.proceed(new Object[]{checkIn, checkOut});
// BaseResponse(isSuccess=true, statusCode=200, message=요청에 성공했습니다., status=SUCCESS, data=[])
System.out.println(result.toString());
// Aspect 내에서 수정된 값 출력(테스트용)
System.out.println("Modified CheckIn: " + checkIn);
System.out.println("Modified CheckOut: " + checkOut);
return result;
}
}
먼저 @Around
어노테이션을 사용해 타겟 메서드의 실행 전/후로 원하는 로직이 실행되도록 설정했다. 포인트 컷은 내가 defaultValue를 주입할 컨트롤러의 경로 및 파라미터를 지정했다.
- *.*: 패키지 내의 모든 클래스를 의미한다.
- *(..): 메서드의 시그니처를 나타내며, 여기서는 메서드 이름과 파라미터 타입이 어떤 것이든지 상관없이 매칭된다.
이후 타겟 메서드 실행 전 checkIn과 checkOut의 값을 가로채 null체크 후 결과에 따라 defaultValue를 주입했다.
ProceedingJoinPoint
의 proceed()
메서드를 호출하여 타겟 메서드를 실행해 변환한 파라미터를 사용한다.
이후 모든 로직을 거친 뒤 return result;
를 통해 변경된 결과가 응답으로 전달된다.
@Around
어노테이션을 사용한 경우 타겟 메서드의 전과 후에 대한 모든 책임을 져야 하기 때문에 메서드 실행 이전 로직을 정하고 return value를 설정해 메서드 실행 후에 대한 책임도 해당 클래스에서 져야한다.
AOP를 프로젝트에 적용함으로써 defaultValue 설정을 위한 반복적인 로직을 없애고 원하는 타겟을 설정해 원하는 관점을 전역적으로 주입할 수 있도록 했다. 아직 AOP에 대한 기본적인 개념이 많이 부족한 상황이고, 추후 defaultValue 설정에 필요한 다른 파라미터의 등장이나 주입 방식의 변화 등을 고려해보아야 겠다.