Skip to main content Link Menu Expand (external link) Document Search Copy Copied

Dto Validation 예외 처리를 AOP를 적용해 개선하기

Table of contents

  1. 기존 방식
  2. AOP 적으로 개선을 시켜보자
    1. @Aspect
    2. @Component
    3. @Around
    4. ProceedingJoinPoint
    5. Advice 로직
    6. joinPoint.proceed()
  3. 개선 후
    1. 테스트 코드

기존 방식


implementation 'org.springframework.boot:spring-boot-starter-validation'

validation 라이브러리를 사용하면

Controller에서 @RequestBody 어노테이션으로 매핑하는 request dto의 필드 유효성을 검사할 수 있다.


public class UserCreateRequest {

    @NotBlank(message = "이메일은 필수 입력 항목입니다.")
    private String email;
    @NotBlank(message = "비밀번호는 필수 입력 항목입니다.")
    private String password;
    @NotBlank(message = "닉네임은 필수 입력 항목입니다.")
    private String nickname;
    @NotBlank(message = "핸드폰 번호는 필수 입력 항목입니다.")
    private String phone;

}

위와 같이 매핑되는 필드에 validation에서 제공하는 어노테이션을 사용하면 된다.

예를 들어, 위 객체에 사용한 @NotBlank 어노테이션의 경우 null, ”“, ” “(공백으로만 이루어진 문자열) 을 허용하지 않는다.


@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserApiController {
    private final UserService userService;

    @PostMapping
    public ResponseEntity<Response<UserCreateResponse>> create(@Validated @RequestBody UserCreateRequest request, BindingResult br) {
	   if (br.hasErrors()) {
    		throw new BindingException(br.getFieldError().getDefaultMessage());
	    }	
        UserCreateResponse response = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(Response.success(response));
    }
}

validation 어노테이션을 적용한 뒤에는, @RequestBody 어노테이션 앞에 @Validated 혹은 @Valid 어노테이션을 추가로 붙여주고

바로 뒤에 BindingResult 객체를 파라미터로 추가해주면 예외처리를 할 수 있다.

보통, POST 요청이나 PUT 요청의 경우 Http Body에 데이터를 담아서 요청을 하기 때문에, validation 처리를 유용하게 사용할 수 있을 것이다.


💡 적용하고나니, 여기서 더 개선하고 싶은 부분이 보였다.

if (br.hasErrors()) {
        throw new BindingException(br.getFieldError().getDefaultMessage());
}

validation을 통해 예외처리를 하다보면, 이 부분이 공통적으로 사용될 것이 예상되었다.

AOP적으로 처리한다면 컨트롤러 메서드 안이 핵심 비즈니스 로직만 존재할 수 있어 가독성을 높일 수 있고 불필요한 코드를 줄일 수 있을 것이라 판단이 들었다.


AOP 적으로 개선을 시켜보자


// 자바 17버전, 스프링 부트 3.0.5 기준
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-aop', version: '3.0.5'
testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-aop', version: '3.0.5'

먼저 위에 있는 라이브러리를 추가해준다.

위 라이브러리를 추가하면, 스프링 부트 자동 설정으로 AnnotationAwareAspectJAutoProxyCreator 라는 빈 후처리기가 스프링 빈에 등록된다.

📌 빈 후처리기(BeanPostProcessor) : 빈 등록을 하기 전에 빈을 원하는 대로 조작할 수 있는 기능을 제공한다.

image-20230404005142965

스프링 빈으로 등록된 Advisor를 자동으로 찾아서 필요한 곳에 프록시를 적용해준다.

또한, 아래 testImplementation 까지 추가를 해주어야 AOP 기능이 테스트 코드에서도 정상적으로 동작한다.


@Aspect
@Component
public class BindingCheck {

    @Around(value = "execution(* com.shoekream.controller..*.*(..))")
    public Object validAdviceHandler(ProceedingJoinPoint joinPoint) throws Throwable {
        Object[] args = joinPoint.getArgs();
        for (Object arg : args) {
            if (arg instanceof BindingResult) {
                BindingResult bindingResult = (BindingResult) arg;
                if (bindingResult.hasErrors()) {
                    String errorMessage = bindingResult.getFieldError().getDefaultMessage();
                    throw new BindingException(errorMessage);
                }
            }
        }
        return joinPoint.proceed();
    }
}

위 코드를 추가해준다.


@Aspect

포인트컷(Pointcut)어드바이스(Advice) 로 구성된 어드바이저(Advisor) 의 생성을 편리하게 해주는 기능을 가진 어노테이션이다.

📌 PointCut : 부가 기능을 적용할 대상인지 아닌지를 판별해주는 필터링 로직, 주로 클래스와 메서드 이름을 가지고 판별하며 적용 대상이라면 부가 기능을 추가하고, 적용대상이 아닌 경우 실제 타깃의 메서드만을 실행

📌 Advice : 타깃 오브젝트에 적용할 부가기능을 담은 오브젝트

📌 Advisor : PointCut과 Advice를 갖고 있는, 즉 부가기능과 부가기능을 적용할 대상을 알고 있는 오브젝트


@Component

AnnotationAwareAspectJAutoProxyCreator 빈 후처리기가 어드바이저를 찾을 수 있도록 빈으로 등록해주어야 한다.


@Around

어드바이스(부가기능) 로직을 실행할 위치를 지정해준다. value 값으로는 AspectJ 표현식을 사용해 적용 위치를 지정한다.

위 코드의 경우에는

execution(* com.shoekream.controller..*.*(..))

리턴타입에 상관없이 com.shoekream.controller 패키지 및 하위 패키지에 있는, 인자값이 0개 이상인 메서드 호출


ProceedingJoinPoint

어드바이스가 @Around인 경우 사용하는 객체로, 호출되는 객체에 대한 정보, 실행되는 메서드에 대한 정보 등에 접근할 수 있다.

getArgs() 메서드를 통해서 어드바이스가 적용된 메서드의 파라미터의 목록을 구했다.

@Validated를 사용하는 메서드의 파라미터에는 BindingResult 가 있으므로, 이를 추출해서 처리할 수 있게된다.


Advice 로직

if (arg instanceof BindingResult) {
    BindingResult bindingResult = (BindingResult) arg;
    if (bindingResult.hasErrors()) {
        String errorMessage = bindingResult.getFieldError().getDefaultMessage();
        throw new BindingException(errorMessage);
    }
}

ProceedingJoinPoint 로 가져온 파라미터중에 BindingResult 객체를 추출한 뒤, 에러가 존재하면 예외처리하는 로직이다.

if (br.hasErrors()) {
    throw new BindingException(br.getFieldError().getDefaultMessage());
}	

원래 컨트롤러에 존재하던 예외처리 로직과 동일하다고 보면 된다.


joinPoint.proceed()

부가기능 로직이 끝났으니, 대상 객체의 원래 메서드를 실행시키는 구문이다.

BindingResult 에 에러가 존재하지 않으면, 기존의 컨트롤러 로직대로 실행될 것이다.


위 코드 원리를 바탕으로 Stream API 방식으로 변경하면 다음과 같다.

@Around(value = "execution(* com.shoekream.controller..*.*(..))")
    public Object validAdviceHandler(ProceedingJoinPoint joinPoint) throws Throwable {

        Stream.of(joinPoint.getArgs())
                .filter(arg -> arg instanceof BindingResult)
                .map(arg -> (BindingResult) arg)
                .filter(br -> br.hasErrors())
                .findAny()
                .ifPresent((br)->{
                    String errorMessage = br.getFieldError().getDefaultMessage();
                    throw new BindingException(errorMessage);
                });

        return joinPoint.proceed();
    }


개선 후

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserApiController {
    private final UserService userService;

    @PostMapping
    public ResponseEntity<Response<UserCreateResponse>> create(@Validated @RequestBody UserCreateRequest request, BindingResult br) {
        UserCreateResponse response = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(Response.success(response));
    }
}


Controller 코드가 정말 깔끔해졌다.

참고로, 메서드 파라미터에 BindingResult br 는 존재해야 정상 동작한다!


테스트 코드


먼저, Controller 테스트를 WebMvcTest에 직접 설정한 Spring Security를 import 해서 사용하고 있었다.

aop 적용을 위해서는 이전에 설명했듯, 테스트용 라이브러리도 추가해주어야 하지만 추가 어노테이션을 달아야한다.

@WebMvcTest(value = UserApiController.class)
@EnableAspectJAutoProxy
@Import({SecurityConfig.class, BindingCheck.class})
class UserApiControllerTest {
    
   .....
   
}

@EnableAspectJAutoProxy 과 정의한 어드바이져 클래스인 BindingCheck.class 를 추가해주었다.


@Test
@DisplayName("회원가입 실패 테스트 (Binding Error 발생)")
void error3() throws Exception{

        UserCreateRequest request = new UserCreateRequest("email@email.com", "password1!", null, "010-0000-0000");

        mockMvc.perform(post("/api/v1/users")
                .contentType(APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
                .andDo(print())
                .andExpect(jsonPath("$.message").exists())
                .andExpect(jsonPath("$.message").value("ERROR"))
                .andExpect(jsonPath("$.result").exists());
        }

위와 같이 Controller 테스트 코드를 작성해보았다. nickName 부분이 null로 입력되어있고, BindingError가 발생될 것이다.

image-20230404014520104

테스트 코드도 정상 동작됨이 확인된다!


참고한 블로그

  1. https://yejun-the-developer.tistory.com/8