팀 프로젝트 중 DTO 사용 관련 논의가 있었습니다.
DTO 내에 getter이외의 로직을 담지 않는 게 어떨지 제안하는 과정에서
제가 생각하는 DTO에 대한 정의를 이야기해보았습니다.
이와 관련해 다시 한 번 정리해봅니다.
DTO 내 기본적 유효성 검증을 묶어주는 메서드를 만들어도 괜찮을까?
public class SlackMessageRequest {
@Nullable
private String keyword;
@Nullable
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private LocalDateTime date;
@NotNull
private List<Long> channelIds;
private boolean needPastMessage = true;
@Nullable
private Long messageId;
@Min(0)
private int messageCount = 20;
public SlackMessageRequest() {
}
}
데이터베이스에 저장되어 있는 메시지들을 조회하기 위한 요청 DTO입니다.
/api/messages GET 요청에 Query Parameter로 전달되는 필드들의 목록이기도 합니다.
기본적인 Null 검증과 최소값 검증 등을 Bean Validation을 이용해 수행하고 있었습니다.
그런데 date와 messageId는 동시에 적용될 수 없는 검색조건이기 때문에,
둘 모두가 전달될 경우 400, Bad Reqeust 응답을 주도록 코드를 작성해보았습니다.
@GetMapping
public SlackMessageResponses findSlackMessages(@Valid SlackMessageRequest slackMessageRequest) {
if (duplicateFindCondition(slackMessageRequest)) {
throw new WrongMessageRequestException();
}
return messageService.find(slackMessageRequest);
}
private boolean duplicateFindCondition(final SlackMessageRequest slackMessageRequest) {
return Objects.nonNull(slackMessageRequest.getDate()) && Objects.nonNull(slackMessageRequest.getMessageId());
}
위처럼 컨트롤러에서 요청 DTO 내부의 값을 확인하여,
둘다 존재할 경우 예외를 발생시키는 로직을 가지고있게 되었습니다.
이와 관련해 컨트롤러가 로직을 갖게 되는 것이 부자연스러우니,
duplicateFindCondistion 메서드를 DTO내부에 두는 것은 어떠냐는 의견이 제안되었습니다.
제안의 핵심 요지는 이미 Nullable, NotNull, Min 등의 기본적인 유효성 검증은 DTO내에서 이뤄지므로,
기본적 유효성 검증 두 가지를 묶어서 진행하는 메서드 정도는 DTO내에서 가져가도 되지 않겠냐는 것이었습니다.
DTO는 택배상자다
이에 대한 저의 의견은 아래와 같았습니다.
사실 원칙론적으로 접근하여 DTO 에는 getter 외의 로직은 없어야한다 라고만 생각해왔지
Bean Validation 관련하여 구체적으로 생각해본 적은 없었던 것 같습니다.
특히 DTO 내 메서드를 만들자는 의견의 핵심인
이미 기본적인 유효성 검증은 DTO 내에서 이뤄지고 있는 것이 아니냐 라는 이야기에 대해서는
고민해보지 않았던 지점이라는 걸 분명히 느꼈습니다.
그러나 곰곰이 생각해보니, Query Parameter로 전달되는 값은
ModelAttribute 애너테이션을 생략할 수 있는 경우이고,
즉 스프링에서 기본적으로 제공하는 ArgumentResolver에서 조립하여 DTO로 전달되는 것이며,
따라서 Bean Validation을 DTO에 값을 주입하는 ArgumentResolver에서 수행할 거라는 생각이 들었습니다.
이렇게 생각해보니 DTO에는 메타 정보를 추가했을 뿐,
실제 유효성 검증은 DTO가 아닌 ArgumentResolver단에서 이뤄지고 있을 거라고 생각되었습니다.
그러고 나서야 모든 것이 정리가 되는 느낌이었습니다. 아래와 같이 말이죠.
- DTO는 택배상자이다.
- 택배상자는 아무것도 할 줄 모른다. 자신에게 담긴 걸 전달할 뿐.
- 물류창고에서(HttpServletRequest) 포장해주는 사람이(ArgumentResolver) 택배를(RequestDTO) 포장해준다.
- 택배를 포장할 때 불량을 검수한다 (Bean Validation)
- 검수할 때, 택배 상자에 적혀있는 정보대로 불량을 검수한다 (@Min(0), NotNull 등)
- 따라서 택배상자는 검증의 역할을 수행하지 않는다.
- 컨트롤러에서 검증하는 코드는 택배를 받자마자 불량이 있는지 확인하는 모습과 같다.
- DTO를 택배상자로 정의한다면, 포장 열기(getter) 외에 메서드를 구현하지 말아야 한다.
검수 및 택배 포장은 누가 해주나
택배 예시에 공감해주어서 대화는 여기서 끝났지만,
실제로 상상한대로 이루어지는지는 솔직히 확신이 들진 않았습니다.
Bean Validation은 실제로 어디에서 누구에 의해 이루어지는지 확인해보고 싶었습니다.
DispatcherServlet의 doDispatch 1067번 라인
// Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
- 디스패처 서블릿에서 요청을 받습니다.
- 핸들러를 찾고, 핸들러 어댑터를 찾아서 요청을 처리하도록 핸들러 어댑터의 handle 메서드를 호출합니다.
ModelAttributeMethodProcessor 의 resolveArgument 147번 라인
// Create attribute instance
try {
attribute = createAttribute(name, parameter, binderFactory, webRequest);
}
- 핸들러 어댑터는 핸들러에 전달할 매개변수를 준비합니다
- 이 과정에서 ModelAttributeMethodProcessor를 이용해 매개변수로 전달할 객체를 생성합니다.
- 즉, 검증을 먼저 하고 생성하는 게 아니라, 생성을 먼저 하고 있는 모습입니다.
- messageCount에는 @Min(0)이 설정되어 있습니다.
- 의도적으로 -100을 전달했습니다만 일단 attribute 변수에 RequestDTO가 정상 생성 완료되었습니다.
MethodAttributeMethodProcessor 의 resolveArgument 173번 라인
validateIfApplicable(binder, parameter);
- validateIfApplicable 메서드를 호출합니다.
- 첫번째 매개변수로 전달되는 binder에는 target 객체로 앞서 생성한 DTO 객체가 담겨있습니다.
ValidatorImpl 의 validateConstraintsForSingleDefaultGroupElement 506번 라인
- javax.validation.Validator 인터페이스를 구현한 ValidatorImpl 입니다.
- org.hibernate.validator.internal.engine 패키지에 속해있습니다.
- 검사해야하는 제약조건들을 for문으로 순회하며 검증을 수행합니다.
SimpleConstraintTree 의 validateConstraints 54번 라인
- 적절한 validator 구현체를 찾습니다.
- 유효성 검증 메서드를 호출합니다.
AbstractMinValidator.isValid 와 MinValidatorForInteger 의 compare
- 최소값 유효성 검증을 위한 AbstractMinValidator 추상클래스입니다
- 템플릿 메서드 패턴이 적용된 모습입니다. compare메서드가 추상 메서드입니다
- Integer값이기 때문에 MinValidatorForInteger구현체의 compare 메서드가 호출됩니다
유효성 검증이 실패하면?
- 유효성 검증이 한 번 실패했다고 바로 프로세스를 중단하지 않습니다.
- for문을 통해 모든 유효성 검증을 수행하되, 실패한 경우 이를 validationContext 에 모두 담습니다.
- 그리고 이는 다시 ModelAttributeMethodProcessor에서 확인됩니다.
- validateIfApplicable 메서드 바로 아래에 있는 if 조건문에서
예외가 발생했는지를 확인한 뒤에, 발생했을 경우 예외 정보를 포함해서 BindException을 던집니다. - 바로 이 BindException이 ControllerAdvice에서 핸들링해주던 그 예외객체네요!
요약
- 컨트롤러 매개변수에 대한 @Valid, @Min(0) 등의 검증은 ArgumentResolve 과정에서 수행된다
- 요청 객체를 우선 생성한 뒤에, 메타정보를 순회하며 유효성 검증을 수행한다
- 하나의 유효성 검증이 실패한다고 중단하지 않고 전부를 수행하여 모든 실패정보를 담을 수 있게 한다
- BindException 예외를 던질 때 getBindingResult() 를 생성자 매개변수로 전달해 모든 실패 정보가 전달된다
- DTO는 택배상자다.
- 택배 상자에 스티커를 붙이며, 이 택배상자엔 이러이러한 값이 담겨야 한다 라고 메타 정보만 붙인다
- 즉, 택배 상자 자신이 검증하는 것이 아니라 포장해주는 사람이 검증한다
- 재밌는 건 물건을 담기 전에 검증하는 게 아니라 담은 후에 검증한다는 것이다
- 하나의 불량이 있다고 바로 중단하는 게 아니라 모든 불량을 검증한 후 이 정보를 한 번에 보고해준다
'Java & Spring' 카테고리의 다른 글
🥄 쓰레드풀 한 스푼 (2) | 2022.09.29 |
---|---|
jitpack, github를 이용한 라이브러리 배포하기 (0) | 2022.08.27 |
의존이 복잡하게 얽힌 Bean들은 어떻게 생성될까? (4) | 2022.07.05 |
🌱 Spring에 Handler가 등록되는 과정 (0) | 2022.06.21 |
🖋 Servlet부터 DispatcherServlet까지 (Front Controller 패턴) (0) | 2022.06.20 |