웹 프로그래밍/Spring

[Spring] DTO의 @Builder, @RequestBody와 생성자

아장아장 초보 개발자 2022. 3. 10. 18:38
728x90
반응형

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로 매핑해줍니다.

 

 

https://klyhyeon.tistory.com/250

https://yuja-kong.tistory.com/99

728x90
반응형