728x90

@Valid @Validated 차이

@Valid

@Valid는 JSR-303표준 스펙이다.

org.hibernate.validator.internal.constraintvalidators 안에 구현된

여러 Validator 구현체들로 인해 값을 검증해준다.

이의 핵심은 LocalValidatorFactoryBean 이며, 나는 스프링 부트를

사용하였기 때문에 자동으로 구성이 된다.

동작 원리

기본적으로 컨트롤러에서 @Valid가 없더라도

유효성 검증을 처리하는 로직을 지나간다.
이유는??

InvocableHandlerMethod는 적절한 파라미터 처리기를 찾으려고

HandlerMethodArgumentResolverComposite로 보낸다.

HandlerMethodArgumentResolverComposite

얘가 처리해줄 resolver를 찾는데 getArgumentResolver();

private final Map<MethodParameter, HandlerMethodArgumentResolver>argumentResolverCache = new ConcurrentHashMap<>(256);

이 인자에서 들어있는 RequestResponseBodyMethodProcessor를 통해

RequestResponseBodyMethodProcessor

validation을 진행한다.

RequestResponseBodyMethodProcessor

AbstractMessageConverterMethodArgumentResolver 를 상속받고 있는데

상속받는 이 클래스의 validateIfApplicable에서 어노테이션 for 루프를 돌면서

AbstractMessageConverterMethodArgumentResolver

@Valid가 있는지 검색한다.

있으면 DataBinder객체에 넘겨서 validate를 수행한다.

여기서 검증에 오류가 있으면 MethodArgumentNotValidException이 발생하고,

이는 스프링 ExceptionResolverDefaultHandlerExceptionResolver덕분에

400 에러를 뱉게된다.

@Validated


@Validated (전역 컨트롤러에 붙임)
위의 @Valid와 다르게 cglib 그러니까 AOP기반으로 메소드 요청을

MethodValidationInterceptor가 받아서 처리해준다.

왜 cglib이냐면 SampleController는 일반 클래스이므로

인터페이스처럼 JDK 동적 프록시가 아닌 Cglib proxy를 사용한다.

그리고선 이 프록시가 요청을 가로채서 유효성 검증을 진행해준다.

검증을 수행하고서는 Set<ConstraintViolation<Object>>result;

가 비어있는 값이 아니라면 ConstraintViolationException을 던져주는데

에러 메시지의 기본값은 javax.validation.constraints.XXX.message properties에 정의되어있다.

이는 위처럼 DefaultHandlerExceptionResolver에 등록되어 있는 객체가 아니기에

500에러와 함께 밖으로 뱉어주게 된다. 별도의 ExceptionHandler를 같이 구현해주어야 할것이다.

아래는 내가 구현한 예제 소스이다.

SampleController.java

import javax.validation.Valid;
import javax.validation.constraints.Min;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@Validated
public class SampleController {

    @PostMapping("/hello")
    public String hello(@Valid @RequestBody MessageRequest messageRequest) {
        log.info(messageRequest.getMessage());
        return "hello";
    }

    @GetMapping("/hi")
    public String hi(@Min(value = 1) int value) {
        log.info(String.valueOf(value));
        return "hi";
    }

}

MessageRequest.java

import javax.validation.constraints.NotNull;
import lombok.Getter;

@Getter
public class MessageRequest {

    @NotNull(message = "message는 null일 수 없습니다.")
    private String message;

}

SampleControllerTest.java

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.lsj8367.web.request.MessageRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@WebMvcTest(SampleController.class)
class SampleControllerTest {

    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext ctx;

    @Autowired
    private ObjectMapper objectMapper;

    @BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(ctx)
            .alwaysDo(print())
            .build();
    }

    @Test
    @DisplayName("Post @Valid 테스트")
    void test() throws Exception {
        final String obj = objectMapper.writeValueAsString(new MessageRequest());

        mockMvc.perform(post("/hello")
                .content(obj)
                .contentType(MediaType.APPLICATION_JSON_VALUE))
            .andExpect(status().isBadRequest());
    }

    @Test
    @DisplayName("Get @Validated 테스트")
    void hiTest() throws Exception {
        mockMvc.perform(get("/hi")
                .param("value", "0")
                .contentType(MediaType.APPLICATION_JSON_VALUE)
            )
            .andExpect(status().isInternalServerError());
    }
728x90

'Spring' 카테고리의 다른 글

분산 락  (0) 2023.04.15
@ModelAttribute, @RequestBody 커맨드 객체  (3) 2022.09.15
AOP  (0) 2022.08.10
@ExceptionHandler  (0) 2022.08.10
728x90

예제는 깃허브에 있다.

AOP (Aspect Oriented Programming)

AOP는 스프링의 핵심 구성요소중 하나이다.
관점지향 프로그래밍은 프로그램 구조에 대한 또 다른 사고방식을 제공하며
객체 지향 프로그래밍을 보완해준다.

  • OOP의 모듈화 핵심 단위
    • 클래스
  • AOP의 모듈화 단위
    • 관점 (aspect)

AOP는 횡단 관심사의 분리를 허용해주어 모듈성을 높이는 것을 목표로 하는 패러다임이다.
코드 자체를 수정하지 않고 기존 코드에 추가 동작을 추가해서 수행한다.

개념 그리고 용어

  • Aspect
    • 여러 클래스에 중복되어 있는 관심사의 모듈화
    • 대표적인 예로 트랜잭션 관리가 있다.
    • Spring AOP 에서는 @Aspect를 사용한다.
  • JoinPoint
    • 메소드 실행이나 예외 처리와 같은 프로그램 실행중인 지점
    • AOP에서의 JoinPoint는 항상 메소드 실행을 나타냄.
  • Advice
    • 언제 공통 관심 기능을 핵심 로직에 적용할지를 정의
    • around, before, after 등이 있음.
    • AOP 프레임워크는 관점을 인터셉터로 모델링하고 유지한다.
    • 타깃 오브젝트에 종속되지 않는 순수한 부가기능을 담은 오브젝트
  • PointCut
    • Advice가 이 포인트컷 표현식과 연관되고, 일치하는 모든 조인 포인트에서 실행되게 한다.
    • JoinPoint의 상세 스펙을 정의한 것이다.
    • 스프링은 기본적으로 AspectJ의 pointcut 표현식을 사용한다.
  • Advisor
    • PointCutAdvice를 하나씩 가지고 있는 오브젝트
    • 어떤 기능을 어디에 전달할 것인지를 알고있는 가장 기본이 되는 모듈
    • Spring AOP에서만 사용되는 용어

스프링 AOP의 특징

프록시 패턴 기반의 AOP 구현체, 프록시 객체를 사용하는 이유는 여러개의 부가 기능들을 추가하기 위해서 사용한다.
스프링 빈에만 AOP를 적용할 수 있다.
스프링 IoC와 연동해서 중복 코드, 프록시 패턴 구현의 번거로움, 객체간 복잡도 해결을 진행한다.
결국 프록시 패턴, 데코레이터 패턴에 대한 중복도도 제거하려고 나온것이 스프링 AOP라고 생각한다.

스프링 프록시 방식의 AOP 적용

프록시 방식의 AOP를 적용하려면 최소 아래의 네가지 빈을 등록해야 한다.

  • AutoProxyCreator
  • Advice
  • PointCut
  • Advisor

일반 스프링 프레임워크에서는 설정을 해주려면 xml에 여러가지 설정들을 해주어야 하지만,

부트에서는 build.gradle에 의존성 하나만 추가해주면 자동으로 설정이 된다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-aop
}

추가적으로 PointCut을 정의할 때에는 위에서 설명했던 것 처럼
AspectJ 표현식을 통해 정의해준다. 자세한건 여기를 통해서 확인할 수 있다.

AOP 어노테이션

모든 어노테이션 뒤에는 AspectJ 표현식을 사용해서 적용할 부분을 정의해준다.

  • @Pointcut
    • AspectJ를 적용할 타겟을 정의해준다.
    • 전체 컨트롤러의 함수대상, 특정 어노테이션을 설정한 함수대상, 특정 메소드 대상 등 적용하기를 원하는 범위를 정의하는 어노테이션
  • @Before
    • 조건 표현식에 정의한 메소드들이 실행되기 전에 수행
  • @AfterReturning
    • 적용된 타깃 메소드가 실행된 후에 수행
  • @Around
    • 타깃 메소드 실행 전, 후 처리 둘다 수행이 가능
    • 사용해줄 때 해당 메소드를 ProceedingJoinPoint로 받아준다.

프록시 팩토리의 기술 선택 방법

  • 대상에 인터페이스가 있으면
    • JDK 동적 프록시, 인터페이스 기반 프록시
  • 대상에 인터페이스가 없으면
    • cglib, 구체 클래스 기반 프록시
  • ProxyFactory의 setProxyTargetClass(true);
    • cglib, 구체클래스 기반 프록시, 인터페이스 여부 상관없음
728x90

'Spring' 카테고리의 다른 글

@ModelAttribute, @RequestBody 커맨드 객체  (3) 2022.09.15
@Valid, @Validated 차이  (0) 2022.08.10
@ExceptionHandler  (0) 2022.08.10
Spring Rest Docs 테스트로 문서화를 해보자!  (0) 2022.08.10
728x90

@ExceptionHandler

예외 처리기가 어떻게 동작하는지에 대해서 궁금했어서
업무중에 돌려보게 되었다. (예제코드는 다시 작성할 예정)
일단 동작과정은 DB에서 해당 id를 찾아 검색했을 때 없을 경우 예외를 던져주게 하는

예시

간단하게 보면

public class UserService {
    private final UserRepository userRepository;

    public User findById(final int id) {
        return userRepository.findById(id)
                             .orElseThrow(() -> new NotFoundException("해당 유저를 찾을 수 없습니다"));
    }
}

라고 로직을 구성했을 때 이 로직의 예외에 대한 핸들러 동작을 파보게 되었다.
일단 get 메소드로 조회 로직을 수행하고

그 요청을 RequestMappingHandlerAdapter로 위임해서 처리를 요청한다.

그다음 주어진 값들로 컨트롤러에 대한 로직을 처리하는데

InvocableHandlerMethod

다음 그림이 InvocableHandlerMethod 클래스이다.

스크린샷 2022-02-09 오전 11 14 03

이 때, getBridgedMethod().invoke(getBean(), args); 부분이

cglibAopProxy쪽으로 조회 로직을 맡기게 되고 그 곳에서 repository에 대한 동작을 수행하다가
findById에서 못찾았을 경우에 에러를 던지게 된다.
다시 cglibAopProxy에서 요청결과에 대한 에러를 잡아서 다시 처리되는데

이것이 InvocableHandlerMethodcatch로 넘어온다.

catch (InvocationTargetException ex) 이부분에서

RuntimeException의 인자인지를 확인한다.

NotFoundException

이 부분에서 내가 구현한 NotFoundException 의 상속도는

NotFoundException -> NoSuchElementException -> RuntimeException -> Exception 순서기 때문에

instanceof RuntimeException 으로 처리가 되게 된다.

그렇게해서 exception 객체를 담아서 DispatcherServlet 으로 넘겨주게 된다.

DispatcherServlet

이젠 디스패쳐 서블릿이 받은 에러를 처리해줄 누군가를 찾기 시작하는데

스크린샷 2022-02-09 오전 11 18 55

HandlerExceptionResolverComposite

스크린샷 2022-02-09 오전 11 19 56

이 두 개중 HandlerExceptionResolverComposite 로 처리를 진행해준다.

그렇게 해서 resolveException 메소드를 실행해주는데

다음 이미지를 보게되면 resolvers에 3개가 들어있게 된다.

스크린샷 2022-02-09 오전 11 20 44

그 리졸버들이 바로 HandlerExceptionResolver 들을 구현한것들

스크린샷 2022-02-09 오전 11 22 39

그중에서도 나는 ResponseStatusException을 날려준게 아니기 때문에

그중에서 최종적으로 HandlerExceptionResolver 인터페이스 구현체인

ExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver

세개를 가지고 for문을 돌게 된다

InvocableTargetException 이라는 객체의 target

즉, 대상이 어떤 Exception이냐에 따라서 Exception 에맞는 @ExceptionHandler로 파싱되어 커스텀된 응답으로 나가게 된다.

마무리

똑같은 로직 이라고 가정했을때 미묘하게 최적화를 하고싶다? 한다면

HandlerExceptionResolver 리스트의 맨앞인 ExceptionHandlerExceptionResolver를 사용 그러니까

커스텀으로 @ExceptionHandler 를 만들어 쓰는것이 ResponseStatusException 을 던지거나, @ResponseStatus 을 사용하는것보다

빠를 수 있겠다.

728x90

'Spring' 카테고리의 다른 글

@Valid, @Validated 차이  (0) 2022.08.10
AOP  (0) 2022.08.10
Spring Rest Docs 테스트로 문서화를 해보자!  (0) 2022.08.10
Service Layer에 대한 생각  (0) 2022.08.10
728x90

728x90

'Spring' 카테고리의 다른 글

AOP  (0) 2022.08.10
@ExceptionHandler  (0) 2022.08.10
Service Layer에 대한 생각  (0) 2022.08.10
Validaion  (0) 2022.08.09
728x90

Service Layer

이직하고 프로젝트에 대해 구조 파악을 하면서 리팩토링을 진행중에 좀 생각하게 된게 꽤많았다.
사실 서비스 레이어라고 해서 비즈니스 로직을 다 넣는건가?
또는 비즈니스 로직이 다 들어가 있는 것이다. 라는 얘기들이 많았다.
사실 나도 그렇게 생각했었던 사람중 1명이었다.
이게 근데 잘못된 생각이었다.

결국엔 사실 소스 코드를 다 분리하고 봐도 하나로 합쳐져서 동작하게 되는건 사실 맞다고 본다.

그러니까 다시 말하면 클래스 하나의 메소드에서 엄청나게 많은 줄을 가지고 개발을 할 수도 있다는 것이다.
근데 이거는 객체 지향 설계 관점에선 너무 안좋은 것이고
각자의 책임이 있을 것인데 그걸 분리한게 객체 지향인거다.

Service Layer에 대한 오해

일단 이 서비스 레이어에서는 비즈니스 로직이 넘쳐나게 될 것이 아니라,

적어도 뭔가의 조건을 통해 돌려주고 수정하고 하는 로직들은 도메인 객체가 해야될 일이라는 것이다.

보통의 서비스 레이어 특징

  • @Service에 사용되는 서비스
  • 일반적으로는 @Controller, @Repository에 사용된다.
  • @Transactional 이 사용되는 영역

도메인 특징

  • 도메인이라고 불리는 대상이 뭘 하는 객체인지 모든 사람들이 알 수 있고 공유하게 만든 모델
    • 주문, 상품 등등.. 이 도메인이 될 수 있다.
  • JPA를 사용한다면 @Entity 모델이 될 수도 있다.
    • 그렇지만, DB의 테이블과 동일해야 한다? 에서는 NO라고 할 수 있다.

모아서 보니까 결국엔 비즈니스 로직은 도메인이 가져야 한다.

그렇다면 서비스 레이어는???

트랜잭션 관리, 도메인의 순서 대로 객체에게 할당을 하여 식만 조합해주는 느낌으로 가야한다.
그래서 나는 예시 소스로 간단하게 만들어보자면

public class ExampleService {
    private final ExampleRepository1 exampleRepository1;
    private final ExampleRepository2 exampleRepository2;
    private final ExampleRepository3 exampleRepository3;

    public ExampleService(ExampleRepository1 exampleRepository1,
     ExampleRepository2 exampleRepository2, ExampleRepository3 exampleRepository3) {
        this.exampleRepository1 = exampleRepository1;
        this.exampleRepository2 = exampleRepository2;
        this.exampleRepository3 = exampleRepository3;
    }
}

만약 이런 클래스가 있어서 각 repository별로 사용하는 트랜잭션이 다르다면 트랜잭션 처리가 애매했다.
위 구조를 아래와 같이 변경했다.

public class ExampleFacade {
    private final ExampleService1 exampleService1;
    private final ExampleService2 exampleService2;
    private final ExampleService3 exampleService3;

    public ExampleService(ExampleService1 exampleService1,
     ExampleService2 exampleService2, ExampleService3 exampleService3) {
        this.exampleService1 = exampleService1;
        this.exampleService2 = exampleService2;
        this.exampleService3 = exampleService3;
    }

}

@Transactional
public class ExampleService1 {
    private final ExampleRepository1 exampleRepository1;

    public ExampleService1(ExampleRepository1 exampleRepository1) {
        this.exampleRepository1 = exampleRepository1;
    }
}

@Transactional
public class ExampleService2 {
    private final ExampleRepository2 exampleRepository2;

    public ExampleService1(ExampleRepository2 exampleRepository2) {
        this.exampleRepository2 = exampleRepository2;
    }
}

@Transactional
public class ExampleService3 {
    private final ExampleRepository3 exampleRepository3;

    public ExampleService1(ExampleRepository3 exampleRepository3) {
        this.exampleRepository3 = exampleRepository3;
    }
}

이렇게 분리를 해서 트랜잭션 관리를 해줬었다.
이게 이제 이전에 내가 퍼사드 패턴 적용기 라고 해서 포스팅을 했었던 구조인데
거기에 +@ 로 덧붙인다면 각 도메인에 대한 로직 수행을 서비스 -> 도메인 이 과정을
넣어주어야 완성인 것 같다.
객체 설계에 대해 점차 이해가 쏙쏙 되는 중이다.

728x90

'Spring' 카테고리의 다른 글

@ExceptionHandler  (0) 2022.08.10
Spring Rest Docs 테스트로 문서화를 해보자!  (0) 2022.08.10
Validaion  (0) 2022.08.09
Filter, Interceptor 정리  (0) 2022.08.07
728x90

모든 코드는 깃허브 에 있다.

Validation

Validation의 사전적 정의는 확인 이다.

프로그래밍을 하면서 Validation을 한다고 하면 유효성 검증,

즉, 수없이 싸우는 null과 빈 값에 대해 검증을 한다.

뭐 때에 따라서는 조건에 맞는 값이 들어와야 한다는 것도 포함이다.

유효성 검증

애초에 검증을 한다는 것을 단순하게 생각하면

스프링에서 어떤 요청이 하나 들어왔다고 치자.

@RestController
public class Hello {

    @GetMapping("/hello")
    public String hello(@RequestParam String name) {
        return "hello " + name;
    }
}

/hello?name=이름이라는 주소로 호출을 한다면 파라미터에 name값이 들어갈 것이다.

우리는 저 부분에서 name이 null이거나 빈문자열인 경우를 검증해줄 수 있다.

그래서

@GetMapping("/hello")
public String hello(@RequestParam String name) {
    if (Objects.isNull(name) || name.equals("") {
        throw new RuntimeException("이름은 빈 문자열이거나 Null일 수 없습니다.");
    }
    return "hello " + name;
}

이런식으로 RuntimeException 을 던져줄 수도 있다.

이런 하나하나를 다 해주게 된다면 코드 로직이 좀 더 뚱뚱해지고 가독성을 헤칠 수 있고

빈 문자열이나 null을 검증한다. 라는 공통적 의미가 겹치는 로직이 많이 발생하게 될 것이다.

그래서 이 동작을 해줄 수 있는 편리한 라이브러리 javax.validation 을 사용하는 것이다.

의존성

검증은 javax.validation:validation-api 이 의존성을 추가하고 (버전은 명시하지 않음)

검증 API 참조는 org.hibernate.validator:hibernate-validator를 이용했다.

그러나 내가 해본 것은 Spring Boot에서 해보았기 때문에

그레이들에는 아래와 같이 설정해주었다.

dependencies {
    implementation (
            'org.springframework.boot:spring-boot-starter-web',
            'org.springframework.boot:spring-boot-starter-validation',
            'org.projectlombok:lombok'
    )
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation (
            'org.springframework.boot:spring-boot-starter-test',
            'org.assertj:assertj-core:3.21.0'
    )
}

어노테이션 종류

일단 검증을 할 수 있게 해주는 어노테이션을 알아보자

스크린샷 2021-12-15 오후 10 23 46

 어노테이션

예제 

 설명

 @DecimalMax

 @DecimalMax(value = "5.5")

- 소수 최대값 지정

- 같은값 허용

- null 허용

 @DecimalMin

 @DecimalMin(value = "5.5")

- 소수 최소값 지정

- 같은값 허용

- null 허용

 @Max

 @Max(value = 10)

- 정수 최대값 지정

- 같은값 허용

- null 허용

 @Min

 @Min(value = 10)

- 정수 최소값 지정 

- 같은값 허용

- null 허용

 @Digits

 @Digits(integer = 3, fraction = 2)

- interger 정수 허용 자리수 ex) 3이니까 100자리 까지허용

- fraction은 소수점 허용 자리수 ex)2니까 소수점 둘째짜리 까지 허용

- null 허용

 @Size

 @Size(min = 2, max = 4)

- 길이를 검증
- null 허용

- ex) "a" 비허용, "abcd"허용

 @NotNull

 @NotNull

 설명생략

 @Pattern

 @Pattern(regexp = "")

- 정규식에 해당하는 것만 통과

 @NotEmpty

 @NotEmpty

- null 비허용

- 길이가 0 비허용 ex) ""

 @Positive

 @Positive

- 양수만 허용

- 0 비허용 
- null 비허용

 @PositiveOrZero  @PositiveOrZero

- 양수 허용

- 0 허용
- null 비허용

 @Negative @Negative

- 음수만 허용

- 0 비허용 
- null 비허용

 @NegativeOrZero  @NegativeOrZero

- 음수만 허용

- 0 허용 
- null 비허용

 @Email  @Email(regexp = "정규식")

- 정규식따로 작성 권장 , "abc@def" 같은 케이스도 통과해버림

 @Future @Future

- 미래날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러발생

- LocalDate권장 

 @FutureOrPresent @FutureOrPresent

- 미래, 현재날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러.

- LocalDate권장

 @Past  @Past

 - 과거날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러.

- LocalDate권장 

 @PastOrPresent

 @FutureOrPresent

 - 과거, 현재 날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러.

- LocalDate권장 

이런 어노테이션들이 있다.

기본 Hibernate Test

유저 라는 클래스를 구현해놓고 null과 길이에 대한 검증만 진행하는 어노테이션을 구현해놓았다.

@Getter
@NoArgsConstructor
public class User {
    @NotNull(message = "email is not null")
    private String email;

    @NotNull(message = "name is not null")
    @Size(min = 2, max = 4, message = "name must be between 2 and 4")
    private String name;

    @Min(value = 1, message = "age is more than 0")
    private int age;

    public User(final String email, final String name, final int age) {
        this.email = email;
        this.name = name;
        this.age = age;
    }

}

UserTest.java

import static org.assertj.core.api.Assertions.assertThat;
import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class UserTest {

    private Validator validator;

    @BeforeEach
    void setUp() {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        validator = validatorFactory.getValidator();
    }

    @Test
    void 이메일이_null일_때() {
        final User user = new User(null, "홍길동", 12);
        final Set<ConstraintViolation<User>> validate = validator.validate(user);

        assertThat(validate.stream().findFirst()
            .get().getMessage()).isEqualTo("email is not null");
    }

    @Test
    void 이름_null() {
        final User user = new User("test@email.com", null, 12);
        final Set<ConstraintViolation<User>> validate = validator.validate(user);

        assertThat(validate.stream().findFirst()
            .get().getMessage()).isEqualTo("name is not null");
    }

    @Test
    void 이름_2글자에서_4글자_사이가_아닐_때() {
        final User min = new User("test@email.com", "홍", 22);
        final User max = new User("test@email.com", "홍홍홍홍홍", 22);
        final Set<ConstraintViolation<User>> minValidate = validator.validate(min);
        final Set<ConstraintViolation<User>> maxValidate = validator.validate(max);

        assertThat(minValidate.stream().findFirst().get().getMessage()).isEqualTo("name must be between 2 and 4");

        assertThat(maxValidate.stream().findFirst()
            .get().getMessage()).isEqualTo("name must be between 2 and 4");
    }

    @Test
    void 나이_1보다_작을_때() {
        final User user = new User("test@abc.com", "lsj", 0);
        final Set<ConstraintViolation<User>> validUser = validator.validate(user);

        assertThat(validUser.stream().findFirst().get().getMessage()).isEqualTo("age is more than 0");
    }
}

이 테스트를 진행하면 hibernate가 돌아가면서 검증을 수행해주게 된다.
이 부분에서는 어떠한 예외가 던져져서 Exception 객체로 나오지는 않는다.

RestController Test

아래는 RestController로 테스트 했을 때 나오는 경우이다.

UserController.java

@Valid 어노테이션을 사용하여 검증을 시켜주었다.

MethodArgumentNotValidException 클래스는 @Valid 어노테이션이 달린 인자의

검증이 실패하면 던지게 된다.

스크린샷 2021-12-15 오후 11 00 55
@RestController
public class UserController {

    @PostMapping("/user")
    public ResponseEntity<User> getUser(@Valid @RequestBody User user) {
        return ResponseEntity.ok(user);
    }
}

일단 다른 값들의 검증은 위에서 살펴보았으니 단순하게 에러를 던지는 과정만 테스트에서 보도록 하겠다.

UserControllerTest.java

@WebMvcTest(UserController.class)
@AutoConfigureMockMvc
class UserControllerTest {

    ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private MockMvc mockMvc;

    @Test
    void 유저_검증_이메일_null() throws Exception {
        final User user = new User(null, "테스트", 34);
        mockMvc.perform(post("/user")
                .contentType(APPLICATION_JSON_VALUE)
                .content(objectMapper.writeValueAsString(user)))
            .andExpect(status().isBadRequest())
            .andDo(print());
    }

}

스크린샷 2021-12-15 오후 11 05 38

보면 email값이 null로 요청이 들어왔고 그래서 예외에는 MethodArgumentNotValidException이 던져졌다.

이럴때 이제 @ExceptionHandler를 통해 커스텀으로 예외를 구현해주면 되겠다.

커스텀으로 구현해주는 이유는 통째로 값을 내려줄 수는 있지만,

불필요한 정보와 시스템 내부 정보가 포함되어 있기 때문에 별도로 정의하여 내려주는 것이 좋다.

12-29 추가

커스텀으로 validator를 구현하여 검증을 진행해 줄 수 있다.

리스트의 경우에는 @Valid가 붙어있음에도 불구하고 유효성 검사를 못하는 경우가 발생한다.

이렇게 해서 안에서 예외를 처리해주는 방법도 있다.

@Component
public class CustomValidator implements Validator {

    private SpringValidatorAdapter validatorAdapter;

    public CustomValidator() {
        this.validatorAdapter = new SpringValidatorAdapter(Validation.buildDefaultValidatorFactory()
            .getValidator());
    }

    @Override
    public boolean supports(final Class<?> clazz) {
        return Collection.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(final Object target, final Errors errors) {
        if (target instanceof Collection) {
            Collection collection = (Collection) target;

            for (Object object : collection) {
                validatorAdapter.validate(object, errors);
            }
        } else {
            validatorAdapter.validate(target, errors);
        }
    }
}

방법2

내가 사용한 방법인데

Controller로 넘어오는 RequestBody 객체에 리스트가 있는 경우에는 그 객체안에 List에 @Valid를 붙여준다.

@Valid
List<FormClass> formClassList;

이런식으로 붙여주고 객체 안에서 여러 변수에 유효성 체크하는 어노테이션을 넣어두면 잘 작동하게 되는데,

Controller에서는 이것을 처리해줄 것이 필요하다.

그게 바로 BindingResult 객체이다.

@PutMapping("/")
public ResponseEntity<String> update(
    @Valid @RequestBody List<FormClass> formClassList, BindingResult bindingResult) throws BindException {
    if (bindingResult.hasErrors()) {
        throw new BindException(bindingResult);
    }
}

이렇게 되게 되면 유효성 검사하고 발생된 에러들이 BindingResult 객체에 담겨있는것을 디버그모드로 확인할 수 있다.

그래서 그 객체가 에러를 갖고 있다면 BindException을 던져주고

나는 ExceptionHandler에서 처리를 진행해주었다.

참고

Baeldung - SpringBoot Bean Validation
Baeldung - javax Validation

728x90

'Spring' 카테고리의 다른 글

Spring Rest Docs 테스트로 문서화를 해보자!  (0) 2022.08.10
Service Layer에 대한 생각  (0) 2022.08.10
Filter, Interceptor 정리  (0) 2022.08.07
Jasypt  (0) 2022.08.07

+ Recent posts