
이번 포스팅에서는 Controller Layer에서 유효성 검사를 해야 하는 이유와 어떤 식으로 유효성 검사를 하는지에 대해 알아보고, 추가적으로 유효성 코드 검사 시 발생한 중복코드를 어떻게 처리할 수 있는지 알아보도록 하겠습니다.
Controller Layer에서 유효성 검사를 해야 하는 이유?
Member를 등록하는 간단한 API를 동작시켰을 때 다음과 같은 에러가 발생한다면 어느 단계에서 발생한 에러일까요?


에러 코드를 확인해 봤을 때 4번에서 발생한 에러임을 확인할 수 있습니다. 그렇다면 문제는 Repository에서 호출한 쿼리가 잘못됐다고 판단할 수 있을까요? mybatis를 사용했다면 쿼리를 확인해 볼 필요가 있고, JPA의 save를 사용했다면 쿼리를 의심해 볼 순 없을 것 같습니다.

사실 위의 에러는 클라이언트에서 잘못된 데이터를 Controller에 요청해서 발생된 문제입니다. Controller는 해당 데이터를 검증하지 않고 Service, Repository 영역까지 전달해 마지막 쿼리를 수행하는 부분에서 에러가 발생하게 되었습니다. 저 에러를 보고 클라이언트에서 잘못된 데이터를 보냈음을 바로 알 수 있더라도 위에서 동작한 것처럼 불필요한 프로세스들을 수행하게 됩니다. 만약 Service 영역에서 시간이 소요되는 프로세스가 있다면 더욱이 문제가 될 수 있습니다.
각각의 Layer에서는 각자의 역할이 있는데 Controller의 역할 중 하나는 Client에서 넘어온 데이터의 유효성을 검증하는 것입니다.
개선이 필요했던 Member 엔티티와, Controller에서 클라이언트와 주고받는 DTO, Controller는 아래와 같습니다.
@Entity
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(nullable = false, length = 4)
private String name;
@Column(nullable = false, length = 6)
private String nikName;
@Builder
public Member(String name, String nikName) {
this.name = name;
this.nikName = nikName;
}
}
@Data
public class MemberSaveReqDto {
private String name;
private String nikName;
public Member toEntity() {
return Member.builder()
.name(name)
.nikName(nikName)
.build();
}
}
@Data
public class CommonResDto<T> {
private String msg;
private T body;
@Builder
public CommonResDto(String msg, T body) {
this.msg = msg;
this.body = body;
}
}
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("/api/member")
public ResponseEntity<?> saveMember(@RequestBody MemberSaveReqDto memberSaveReqDto) {
memberService.saveMember(memberSaveReqDto);
return new ResponseEntity<>(CommonResDto.builder().msg("등록 성공").build(),HttpStatus.CREATED);
}
}
@Valid를 이용한 유효성 검사
@Valid는 JSR-303 표준 스펙으로써 빈 검증기(Bean Validator)를 이용해 객체의 필드에 달린 어노테이션으로 편리하게 검증할 수 있도록 합니다. 검증에 오류가 발생하면 MethodArgumentNotValidException 예외가 발생하고, Dispatcher Servlet에 기본으로 등록된 Exception Resolver인 DefaultHandlerExceptionResolver에 의해 400 BadRequest 에러가 발생합니다. SpringBoot에서는 아래의 의존성을 추가하면 사용할 수 있습니다.
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation'
Controller에서 유효성 검사의 대상은 클라이언트로부터 전달받은 MemberSaveReqDto입니다. MemberSaveReqDto의 필드에 제약 조건을 의미하는 어노테이션을 추가합니다. Member 엔티티의 이름과 별명은 4글자, 6글자의 제한이 있으므로 다음과 같이 설정할 수 있습니다.
@Size(max = 4, message = "4 글자 이내의 이름을 입력하세요")
private String name;
@Size(max = 6, message = "6 글자 이내의 별명을 입력하세요")
private String nikName;
MemberSaveReqDto의 필드에 제약 조건 어노테이션을 추가했으니 Controller에서 이를 검증하는 @Valid 어노테이션을 추가합니다.
@PostMapping("/api/member")
public ResponseEntity<?> saveMember(@RequestBody @Valid MemberSaveReqDto memberSaveReqDto) {
memberService.saveMember(memberSaveReqDto);
return new ResponseEntity<>(CommonResDto.builder().msg("등록 성공").build(),HttpStatus.CREATED);
}
위에서와 동일하게 제약 조건에 맞지 않는 데이터를 전송하여 Member를 추가하는 API를 실행하면 다음과 같습니다.
@Valid를 통한 유효성 검증을 하지 않았을 때는 500 Internal Server Error 가 발생되었지만 이제는 400 Bad Request를 발생함으로써 클라이언트에서의 오류임을 확인할 수 있게 되었습니다. 하지만 위와 같이 에러 메시지를 발생한다면 직관적으로 파악하기 힘들기 때문에 BindingResult를 통해 조회한 에러메시지를 클라이언트로 보내주는 DTO(CommonResDto)에 담아 보내주었으면 합니다.
BindingResult를 통한 에러메시지 조회
BindingResult는 Spring에서 제공하는 검증 오류를 보관하는 객체입니다. BindingResult를 통해 조회한 에러메시지를 CommonResDto에 담아 전송하는 코드는 다음과 같습니다.
@PostMapping("/api/member")
public ResponseEntity<?> saveMember(@RequestBody @Valid MemberSaveReqDto memberSaveReqDto, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
Map<String, String> errorMap = new HashMap<>();
for (FieldError fe : bindingResult.getFieldErrors()) {
errorMap.put(fe.getField(), fe.getDefaultMessage());
}
return new ResponseEntity<>(CommonResDto.builder().msg(errorMap.toString()).build(),HttpStatus.BAD_REQUEST);
}
memberService.saveMember(memberSaveReqDto);
return new ResponseEntity<>(CommonResDto.builder().msg("등록 성공").build(),HttpStatus.CREATED);
}
이제 다시 동일한 API를 실행하면 다음과 같습니다.

지금까지 유효성 검사가 완벽히 이루어졌는데 한 가지 아쉬운 점은 Controller의 모든 검증하는 곳마다 BindingResult의 에러 메시지를 조회하여 CommonResDto에 담는 코드를 중복해서 넣어야 한다는 것입니다. 이러한 중복코드를 없애기 위해서는 Controller에서 발생한 에러 처리를 일괄적으로 처리하는 @RestControllerAdvice를 사용해야 합니다.
@RestControllerAdvice를 통한 Controller 예외 일괄 처리
@ControllerAdvice는 @ExceptionHandler, @ModelAttribute, @InitBinder 가 적용된 메서드들을 AOP를 적용해 컨트롤러에서 적용하기 위해 고안된 어노테이션입니다. @RestControllerAdvice는 @ControllerAdvice에 @ResponseBody 가 결합되어있어 응답을 Json으로 보낼 수 있습니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> methodArgumentNotValidException(MethodArgumentNotValidException e) {
Map<String, String> errorMap = new HashMap<>();
for (FieldError fe : e.getFieldErrors()) {
errorMap.put(fe.getField(), fe.getDefaultMessage());
}
return new ResponseEntity<>(CommonResDto.builder().msg(errorMap.toString()).build(), HttpStatus.BAD_REQUEST);
}
}
@PostMapping("/api/member")
public ResponseEntity<?> saveMember(@RequestBody @Valid MemberSaveReqDto memberSaveReqDto) {
memberService.saveMember(memberSaveReqDto);
return new ResponseEntity<>(CommonResDto.builder().msg("등록 성공").build(),HttpStatus.CREATED);
}
위와 같이 @ExceptionHanler에 MethodArgumentNotValidException을 등록하고 중복되었던 코드를 입력함으로써 GlobalExceptionHandler를 완성합니다.