[Spring] DTO의 @Builder, @RequestBody와 생성자
RestAPI를 생성 후 API 테스트를 하던 중 몇 가지 사실을 알게 되어 같은 문제로 어려움을 겪고 있는 분들께 도움이 되고자 글을 작성하였습니다.
잘못된 내용이 있다면 가감 없이 피드백 주시길 바랍니다 🙇♀️
아래의 예제는 임시로 만든 DTO, Controller이니 가볍게 보고 넘어가시길 바랍니다.
오늘 포스트의 핵심은 Controller가 Client로부터 받은 request body를 DTO로 매핑시키는 방법과 @Builder 어노테이션 사용 시 주의할 점이니 이를 떠올리며 읽어주시면 좋을 것 같습니다~
우선 모든 클라이언트 요청을 주고받을 때 사용하는 DTO 클래스를 각각 역할 별로 나누어서 작성하였습니다.
아래는 로드 밸런서 생성 시 @RequestBody로 넘어올 객체를 정의한 클래스 예시입니다.
@Getter
@Schema(description = "테스트를 위한 객체")
public class CreateTestRequestDto implements Serializable {
@Schema(description = "이름")
private String name;
@Schema(description = "설명")
private String description;
@Builder
public CreateTestRequestDto(String name, String description) {
this.name = name;
this.description = description;
}
@Override
public String toString() {
return "CreateTestRequestDto{" +
"name='" + name + '\'' +
", description=" + description +
'}';
}
}
위 DTO를 Request Body로 받는 Controller를 정의해 주었고 Swagger를 통해 API 테스트를 해보았습니다.
Swagger가 궁금하시다면 이 글에서 참고하시길 바랍니다.
@Tag(name = "Test Controller")
@RestController
@RequestMapping("/tests")
@RequiredArgsConstructor
public class TestController {
private final LoadbalancerService loadbalancerService;
@Operation(summary = "테스트 생성")
@PostMapping
public ResponseEntity<?> createTest(@RequestBody CreateTestRequestDto createTestRequestDto) {
TestResponseDto testResponseDto = loadbalancerService.create(createTestRequestDto);
return ResponseEntity.created(URI.create("/tests")).body(TestResponseDto);
}
}
해당 API를 테스트 했을 때, 아래와 같은 메시지와 함께 에러가 발생했습니다.
2022-03-07 12:58:45.346 ERROR 9225 --- [nio-9696-exec-4] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class com.innogrid.cloudit6.domain.network.loadbalancer.dto.CreateLoadbalancerRequestDto]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.innogrid.cloudit6.domain.network.loadbalancer.dto.CreateLoadbalancerRequestDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 2, column: 3]] with root cause
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.innogrid.cloudit6.domain.network.loadbalancer.dto.CreateLoadbalancerRequestDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 2, column: 3]
at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67) ~[jackson-databind-2.13.1.jar:2.13.1]
at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1904) ~[jackson-databind-2.13.1.jar:2.13.1]
at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:400) ~[jackson-databind-2.13.1.jar:2.13.1]
...
에러 메세지를 읽어 보니 기본 생성자가 없어서 발생하는 문제였습니다.
@Builder 어노테이션을 썼을 경우 당연히 기본 생성자가 생기는 줄 알았는데 그게 아니었습니다...😅
빌더 패턴을 생각해보면 당연히 기본 생성자는 고려 대상이 아니라는 것을 알 수 있었는데 왜 거기까지 생각을 안 했는지ㅎㅎ
빌더 패턴의 기본 사용 목적은 필드들의 초기화 작업을 좀 더 유연하게 도와주는 역할을 하기 때문에 기본 생성자를 자동으로 만들어 줄 이유가 없습니다.
해당 에러는 @NoArgsContstructor를 추가해주었더니 아래와 같이 요청이 잘 넘어오는 것을 확인할 수 있었습니다.
위의 에러를 보고 난 후 @Builder 패턴에 대해 찾던 중 많은 사람들이 @Builder 패턴과 @NoArgsConstructor를 함께 쓸 때 에러가 발생하고, @AllArgsConstructor를 추가해주었을 때 에러가 해결되었다는 글을 많이 보았습니다.
흠,,,근데 저의 경우에는 @Builder와 @NoArgsConstructor만 사용하고 실행했을 때도 정상적으로 잘 실행되었습니다.
뭔가 찜찜한 기분이 들어 @Builder 어노테이션에 대한 설명을 찾아보았습니다.
Lombok 공식 사이트 @Builder 설명 中
Finally, applying @Builder to a class is as if you added @AllArgsConstructor(access = AccessLevel.PACKAGE) to the class and applied the @Builder annotation to this all-args-constructor. This only works if you haven't written any explicit constructors yourself.
@Builder를 사용할 때, 생성자가 없으면 @AllArgsConstructor(access=AccessLevel.PACKAGE)가 암묵적으로 적용된다고 합니다. 이를 반대로 말하면 생성자가 있을 경우, @AllArgsConstructor가 적용되지 않는다는 것 같습니다.
여기서 한 가지 더 의문점이 생겼습니다.
Lombok 사이트의 설명에 따르면 저는 @AllArgsConstructor를 선언하지 않았기 때문에 Request Body의 값이 CreateTestRequestDto에 제대로 담기지 않거나 오류가 발생해야 한다고 생각하는데 모든 정보가 정상적으로 넘어왔습니다.
생성자에 description 필드를 빼고 선언을 해주었을 때에도 역시 description에 대한 데이터도 잘 넘어왔습니다.
Controller에서 request body가 생성자를 통해 DTO 객체를 초기화하여 매핑된다고 생각했는데 아니었습니다.
찾아보니 Controller에서 @RequestBody
를 사용하면 HttpMessageConverter
를 통해 HTTP body를 매핑해준다고 합니다.
HttpMessageConverter
는 HTTP request message를 역직렬화(Object로 파싱) 해준다고 합니다.
알게된 점 🙂
1. @Builder를 사용할 경우, 생성자가 없다면 @AllArgsConstructor가 내부적으로 초기화됩니다. 그러나 기본 생성자가 필요하므로 @NoArgsConstructor를 선언할 경우 @AllArgsConstructor 또는 전체 생성자를 직접 생성해주어야 합니다.
2. @RequestBody를 사용할 경우, HttpMessageConverter가 HTTP body를 DTO로 매핑해줍니다.