본 글은 Spring Boot 프로젝트를 진행하면서 외부에서 받아오는 DTO 값을 검증하는 역할을 수햄한 경험을 토대로 작성했습니다.
본 내용은 Spring Framework, Java Language, MVC 구조에 대해 기본적인 이해를 필요로 하고 있습니다.
DTO는 Data Tranfer Object의 약자로, 데이터를 전달하기 위해서 사용하는 객체를 의미합니다.
제 프로젝트에서는 크게 세가지 용도로 사용되었습니다.
각각 어떻게 사용될 수 있는지 보여드리도록 하겠습니다.
외부에서 어떠한 요청(request)이 있을 때, 이러한 요청의 내용을 가지고 Backend 로직을 수행하게 됩니다. 이러한 요청의 내용을 받아오기 위해서 DTO를 사용할 수 있습니다.
진행중인 프로젝트의 일부를 가져와서 본다면, 다음과 같습니다.
Member의 회원가입을 위해서 Http 요청을 받습니다.
@PostMapping("/auth/signup")
public ResponseEntity<SignupDto> signup(@RequestBody final SignupDto request) {
SignupDto response = memberService.createMember(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
이때 사용하는 SignupDto
가 외부에서 받아오기 위한 DTO 입니다.
해당 DTO를 사용함으로써 얻는 것은 요청과 함께 온 값을 미리 확인하고 검증할 수 있다는 점이 있습니다.
프로젝트마다 내부에 Layer를 두어서 서로의 역할을 분리합니다. 이때 전달하는 값도 Layer가 달라짐에 따라 달라져야 합니다.
본 프로젝트는 MVC 아키텍처로 만들어져 있고, 내부 구조가 복잡하지 않아 Layer 간 DTO가 필요로 하지 않았습니다. 하지만 예시를 들어서 설명하도록 하겠습니다.
[추후 수정]
이러한 방식을 사용하면, 계층 간 꼭 필요한 값만을 전달해줄 수 있습니다.
요청에 대한 응답을 만들어서 보내줄 때, 응답의 내용을 DTO로 작성할 수 있습니다.
진행중인 프로젝트의 일부를 가져와서 본다면, 다음과 같습니다.
Member의 id를 통해서 조회하는 경우입니다.
Controller 로직은 다음과 같습니다.
@GetMapping("/members/{memberId}")
public ResponseEntity<MemberInformationDto> find(HttpServletRequest request, @PathVariable("memberId") Long memberId) {
String token = jwtCommunicationServlet.extract(request);
Long userId = Long.parseLong(jwtProvider.getClaims(token, "userId"));
MemberInformationDto response = memberService.findMember(memberId, userId);
return ResponseEntity.status(HttpStatus.OK).body(response);
}
Service 로직은 다음과 같습니다.
public MemberInformationDto findMember(Long memberId, Long currentMemberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(
() -> new MemberNotFoundException("member not found")
);
if (member.getId() != currentMemberId) {
throw new CurrentMemberMismatchException("current member mismatch");
}
return MemberInformationDto.newInstance(
member.getId(),
member.getNickname(),
member.getRole().toString()
);
}
MemberInformationDto
를 생성해서 Response의 Body에 실어서 보내주었습니다.
해당 DTO를 사용하게 되면, 많은 장점이 있습니다.
그리고 이렇게 DTO를 작성함을 통해서 얻을 수 있는 큰 장점은 값이 잘 들어왔는지 검증하는 부분의 역할을 외부로 넘길 수 있습니다. 이제 DTO의 Validation 하는 방법을 작성해보도록 하겠습니다.
Validation은 검증이라는 단어로, DTO Validation은 DTO 값이 우리가 원하는 형태로 잘 들어왔는지 확인하는 것을 의미합니다.
예를 들어서 우리가 원하는 값은 나이인데, 갑자기 해당 부분에 문자열이 들어온다면 어떨까요?
아니면 휴대폰번호와 같은 형태로 값이 들어오면 어떻까요? ...
이렇게 아무런 값이나 가져와서 저장할까요?
실제로 하게 된다면 아무런 값이나 가져와서 저장하지 않고 입력 값이 유효한 값인지 확인을 하고 저장해야 할 것입니다. 그래야 내부에서 서버의 예기치 않은 동작을 제어할 수 있겠죠?
그래서 DTO 객체에 대한 Validation이 필요합니다. 이제 DTO Validation을 적용하는 방법에 대해서 설명하도록 하겠습니다.
가장 먼저 의존성을 추가해주어야 합니다.
Spring Boot에서는 DTO 값을 검증 할 수 있는 Validation을 지원하고 있습니다.
Project가 Gradle 기준으로 다음과 같은 의존을 추가합니다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
Validation은 어노테이션을 통해서 많은 값의 검증을 도와줍니다. 어노테이션에 대해 자세하게 알고 싶으면 Validation API Docs 를 통해 확인해보세요.
DTO에 Validation 어노테이션을 사용해 값을 검증하도록 구현합니다.
DTO를 생성하고, 검증하는 어노테이션을 사용해서 각 필드 값에 검증이 이루어지도록 하겠습니다.
...
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class SignupDto {
@NotBlank
private String email;
@NotBlank
private String password;
@NotBlank
private String nickname;
public static SignupDto newInstance(
String email,
String password,
String nickname
) {
SignupDto signupDto = new SignupDto();
signupDto.email = email;
signupDto.password = password;
signupDto.nickname = nickname;
return signupDto;
}
}
문자열의 경우 @NotBlank
를 문자열이 Null이거나, 비어있거나, ' '
인지 확인합니다.
객체의 값 검증 인 경우에는 @NotNull
, Collection의 경우에는 @NotEmpty
를 사용해서 값이 존재하지 않는지를 검증할 수 있습니다.
해당 DTO를 사용하는 Controller에서 @Valid를 사용하겠습니다.
Controller에서 DTO를 그냥 매개값으로 사용한다고 해서, 값의 검증이 이루어지는 것은 아닙니다. 값의 검증이 이루어지기 위해서는 @Valid
를 이용해서 값을 검증하겠다고 미리 알려주어야 합니다.
...
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class MemberController {
private final MemberService memberService;
private final JwtCommunicationServlet jwtCommunicationServlet;
private final JwtProvider jwtProvider;
@PostMapping("/auth/signup")
public ResponseEntity<SignupDto> signup(@RequestBody @Valid final SignupDto request) {
SignupDto response = memberService.createMember(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
...
}
매개값으로 사용하는 SignupDto의 값을 미리 검증하겠다는 의미로 @Valid
를 추가해주었습니다. 해당 코드가 없다면 값의 검증이 이루어지지 않습니다.
해당 유효성 검사 실패 시, 검증 실패를 처리할 수 있도록 예외를 처리해야 합니다.
유효성 검사가 성공하면 괜찮지만, 유효성 검사가 실패하는 경우도 있습니다. 이때를 대비해서 실패에 대해 Client에 알려주어야 합니다. 해당 예외처리 로직을 구현하는 GlobalExceptionHandler를 작성하겠습니다.
...
@RestControllerAdvice
public class GlobalExceptionHandler {
...
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> dtoValidation(final MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
e.getBindingResult().getAllErrors().forEach((error)-> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(errors);
}
...
}
만약 Validation Exception(MethodArgumentNotValidException)이 발생하면, 해당 예외처리 로직을 통해서 Client에게 해당 Error의 내용을 담은 Response를 제공합니다.
DTO를 사용하면, 얻을 수 있는 장점은 아주 많지만, 그중 한가지는 들어오는 값에 대한 검증이 가능하다는 것입니다. 이는 유효성 검사의 역할을 분리하는 결과를 가져와서 결국 결합도를 떨어트리는 결과를 가져옵니다.
다들 DTO를 이용해서 값 검증을 하도록 합시다. 😃
감사합니다.