@RequestBody 학습 로그

appti·2023년 4월 16일
5

학습 로그

목록 보기
3/8

@RequestBody

요약

정의

  • @RequestBody
    • HTTP Request Message Body를 통해 전달된 데이터를 지정한 객체로 역직렬화하는 애노테이션
    • HttpMessageConverter의 일종인 MappingJackson2HttpMessageConverter으로 처리

생성자를 통한 사용 방법

  • 기본 생성자 & getter
    • getter가 없어도 DTO의 생성 자체는 가능하지만, 필드가 초기화되지 않음
    • setter를 사용해도 되지만, DTO의 특성상 getter를 사용할 것을 권장
  • 모든 필드를 초기화할 수 있는 단 하나의 생성자
    • 컴파일 옵션으로 -parameters 필요
      • Gradle 환경에서는 별도의 설정 없이 사용 가능
      • Intellij 환경에서 실행할 경우 추가 설정 필요
      • Maven 환경에서는 spring-boot-starter-parent 필요
    • 모든 필드를 초기화할 수 있는 생성자가 여러 개 있는 경우 예외 발생
      • Property 생성자를 명시하거나 Handler를 추가한 Custom ObjectMapper를 사용해 해결할 수도 있음
    • 모든 필드를 초기화하지 않는 생성자의 경우 예외 발생
  • 기본 생성자모든 필드를 초기화할 수 있는 단 하나의 생성자가 있을 경우, 기본 생성자가 우선권을 가짐

서론

처음 @RequestBody를 사용했을 때, 아무튼 기본 생성자가 필요하다는 점 정도만 알고, 나머지는 잘 몰랐던 저는 다음과 같이 코드를 작성했습니다.

public class GameRequest {

    private String names;
    private int count;

    public GameRequest() {
    }

    public GameRequest(final String names, final int count) {
        this.names = names;
        this.count = count;
    }
    
    public String getNames() {
        return names;
    }

    public int getCount() {
        return count;
    }
}

그러자 다음과 같은 피드백을 받았습니다.

리뷰어께서 사용하지 않는 생성자기본 생성자를 왜 작성했는지, 그리고 접근 제어자는 왜 public으로 둔 것인지 질문을 하셨습니다.

그래서 이와 관련해 조금 더 검색한 결과, @RequestBody는 아무튼 내부적으로 알아서 리플렉션을 활용하기 때문에 굳이 접근 제어자를 public으로 둘 필요가 없다는 사실을 알게 되었습니다.

public class GameRequest {

    private String names;
    private int count;

    private GameRequest() {
    }

    private GameRequest(final String names, final int count) {
        this.names = names;
        this.count = count;
    }
    
    public String getNames() {
        return names;
    }

    public int getCount() {
        return count;
    }
}

그래서 이렇게 리펙토링 했습니다.
기본 생성자는 @RequestBody를 만드는 데에만 사용하기 때문에, 외부에서 접근할 수 없도록 막았습니다.

구글링을 했을 때 대부분의 코드가 이런 식으로 처리했기도 해서 이 정도면 충분하겠다고 생각했습니다.

그러자 또 다음과 같은 피드백을 받았습니다.

?

public class GameRequest {

    private final String names;
    private final int count;

    public GameRequest(final String names, final int count) {
        this.names = names;
        this.count = count;
    }

    public String getNames() {
        return names;
    }

    public int getCount() {
        return count;
    }
}

바로 기본 생성자를 삭제하고 실행해봤습니다.

?
안되는데요..?

당황한 저는 하루 정도 @RequestBody 관련해 구글링을 했지만, 답을 찾지 못했습니다.

뭐지? 리뷰어께서는 어떤 것을 의도하신거지?
개꿀잼 몰카인가? 싶었던 저는, 결국 그냥 코드를 까보기로 결심했습니다.

사전 지식

코드를 까 보기 전 먼저 이것들이 어떠한 용도인지 살펴보도록 하겠습니다.

@RequestBody

API 문서에는 다음과 같이 설명하고 있습니다.

메소드 파라미터가 web request body에 명시한 데이터를 통해 bind 될 것을 나타내는 애노테이션입니다.
request body는 HttpMessageConverter에게 전달되어 바인딩됩니다.
필요하다면 @Valid 애노테이션을 사용해 유효성 검사를 자동으로 처리할 수 있습니다.

HTTP Request MessageBodyHttpMessageConverter를 통해 내부적으로 알아서 처리해서 지정한 클래스로 binding한다는 의미로 이해했습니다.

HttpMessageConverter

API 문서에는 다음과 같이 설명하고 있습니다.

HTTP request와 response를 특정 객체로 변환하기 위한 전략 인터페이스입니다.

너무 짧으니 Spring 문서도 살펴보도록 하겠습니다.

spring-web 모듈에는 InputStream과 OutputStream을 통해 HTTP 요청과 응답의 본문을 읽고 쓰기 위한 HttpMessageConverter 인스턴스가 포함되어 있습니다.
HttpMessageConverter 인스턴스는 클라이언트 측(예: RestTemplate)과 서버 측(예: Spring MVC REST 컨트롤러)에서 사용됩니다.

주요 미디어(MIME) 유형에 대한 구체적인 구현은 프레임워크에서 제공되며, 기본적으로 클라이언트 측에서는 RestTemplate에, 서버 측에서는 RequestMappingHandlerAdapter에 등록되어 있습니다.

그 밑에는 HttpMessageConverter 구현체가 설명되어 있는데, 그 중 MappingJackson2HttpMessageConverter가 현 상황에 알맞은 HttpMessageConverter입니다.

MappingJackson2HttpMessageConverter

Jackson의 ObjectMapper를 사용해 JSON을 읽고 쓸 수 있는 HttpMessageConverter 구현체입니다.
Jackson에서 제공하는 주석을 사용하여 필요에 따라 JSON 매핑을 Customize 할 수 있습니다.
추가적으로 처리해야하는 로직이 필요한 경우(특정 유형에 대해 사용자 정의 JSON serializers/deserializers를 제공해야 하는 경우) ObjectMapper Property를 통해 사용자 정의 ObjectMapper를 주입할 수 있습니다.
기본적으로 이 HttpMessageConverter는 application/json을 지원합니다.

application/json을 지원해준다고 명시되어있으므로 현재 상황에 가장 알맞을 것입니다.

그럼 API 문서도 확인해보도록 하겠습니다.

Jackson 2.x의 ObjectMapper를 사용하여 JSON을 읽고 쓸 수 있는 HttpMessageConverter의 구현체입니다.
이 HttpMessageConverter는 유형화된 빈 또는 유형화되지 않은 해시맵 인스턴스에 바인딩하는 데 사용할 수 있습니다.
기본적으로 이 변환기는 UTF-8 문자 집합으로 application/json 및 application/*+json을 지원합니다.
이는 supportedMediaTypes 속성을 설정하여 재정의할 수 있습니다.

그런데 코드를 확인해보니, 다음과 같은 메소드만이 명시되어 있었습니다.

  • public void setJsonPrefix(String jsonPrefix)
  • public void setPrefixJson(boolean prefixJson)
  • protected void writePrefix(JsonGenerator generator, Object object)

단순히 JsonPrefix과 관련된 메소드만이 있어, 핵심적인 기능은 다른 곳에 있다고 판단하고 MappingJackson2HttpMessageConverter의 상위 클래스 AbstractJackson2HttpMessageConverter를 살펴봤습니다.

AbstractJackson2HttpMessageConverter

API 문서를 살펴보겠습니다.

Jackson 기반의 Content-Type과는 독립적인 HttpMessageConverter 구현을 위한 Abstract based class입니다.

기반이라는 의미는 다음과 같습니다.

Jackson 라이브러리를 사용하여 HTTP 요청/응답 본문을 Java 객체로 변환하고 Java 객체를 JSON으로 변환하는 HttpMessageConverter의 기본적인 구현을 제공

그래서 메소드를 확인하면 canRead(), canWrite()와 같이 사용할 수 있는지 여부를 확인하는 메소드와 read(), write()와 같은 실제 Converter 로직 등 주요한 메소드가 정의되어 있습니다.

jackson objectMapper를 활용하는 HttpMessageConverterMappingJackson2HttpMessageConverter를 포함해 4가지가 있는데, 이 HttpMessageConverter 모두 jackson objectMapper를 사용하다보니 로직이 동일해 이를 하나로 추상화했다고 이해했습니다.


여기까지 살펴본 내용을 정리하면 다음과 같습니다.

  • @RequestBodyHttp Request Message Body로 전달된 데이터를 지정한 객체로 binding 하겠다는 의미를 지닌 일종의 마커 애노테이션
  • @RequestBody로 인해 객체가 binding될 때, HttpMessageConverterapplication/json을 지원하는 MappingJackson2HttpMessageConverter으로 인해 binding 진행

ArgumentResolver

여기까지 진행하자 이러한 궁금증이 생겼습니다.

HttpMessageConverter를 호출하는 주체는 무엇일까?

그래서 찾아봤습니다.

이 그림은 요청을 수신한 후 응답을 반환할 때까지 Spring MVC의 처리 흐름을 표현한 그림입니다.

이 중 2단계에서 해당 요청을 처리할 수 있는 Controller(Handler)를 찾고, 3단계에서 이 요청을 처리하기 위해 Controller가 필요로 하는 형식의 데이터로 변환해 4단계, Controller를 호출하는 로직이 진행됩니다.

이 3단계의 HandlerAdapter, 여기서는 RequestMappingHandlerAdapterHttpMessageConverter, 지금은 MappingJackson2HttpMessageConverter를 통해 주어진 요청의 데이터를 Controller가 필요한 형식의 데이터로 변환합니다.

이렇게 데이터의 형식을 변경해야 Controller의 메소드를 호출하면서 필요한 데이터를 전달할 수 있게 됩니다.

더 자세히 알아보고 싶으시다면 그림의 출처인 여기를 참고해주시면 좋을 것 같습니다.

아무튼 지금은 HandlerAdapterArgumentResolver를 호출하고, ArgumentResolver에서 HttpMessageConverter를 호출한다 정도로 넘어가겠습니다.

디버깅

일단 어렴풋하게 용도와 흐름에 대해 이해했으니 디버깅을 해보겠습니다.

공통

  1. AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters()

@RequestBody를 처리하는 RequestResponseBodyMethodProcessorArgumentResolverAbstractMessageConverterMethodArgumentResolver.readWithMessageConverters()를 호출합니다.

AbstractMessageConverterMethodArgumentResolver는 내부적으로 List<HttpMessageConverter<?>> messageConverters를 가지고 있으며, AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters()에서 모든 HttpMessageConverter를 확인하며 해당 요청을 처리할 수 있는 HttpMessageConverter를 찾아 read() 메소드를 호출합니다.

  1. AbstractJackson2HttpMessageConverter.canRead()

해당 HttpMessageConverter가 해당 HTTP Request Body를 읽을 수 있는지 판단하기 위해 canRead()가 호출됩니다.

AbstractJackson2HttpMessageConverter.canRead()의 내부를 확인하면 MediaTypeapplication/json인지, 역직렬화 하고자 하는 타입을 핸들링할 수 있는 ObjectMapper가 존재하는지, 그 ObjectMapper가 해당 타입으로 역직렬화가 가능한지 확인하고 여부를 반환합니다.

  1. AbstractJackson2HttpMessageConverter.read()

application/json이므로 MappingJackson2HttpMessageConverter가 선택되고, HTTP Request Messagre Body가 존재하므로 @RequestBody 로직을 처리하기 위해서 AbstractJackson2HttpMessageConverter.read() 메소드를 호출합니다.

read() 메소드는 어떠한 클래스로 binding을 수행해야하는지 확인하고, AbstractJackson2HttpMessageConverter.readJavaType()를 호출합니다.

여기서 TypeJavaType으로 변환하는데, Type은 자바 리플렉션이며, JavaTypejackson 라이브러리에 포함되어있는, deserializer에서 역직렬화를 수행할 때 사용되는 클래스입니다.

  1. AbstractJackson2HttpMessageConverter.readJavaType()

먼저 해당 HTTP Request Message Bodycharset을 확인합니다.
기본적으로 Content-Type: appllication/json; charset=UTF-8이므로 내부 플래그 isUnicodetrue가 됩니다.

이제 아래 try-catch 블록을 수행하게 됩니다.

첫 번째 분기문은 false이므로 다음으로 넘어갑니다.

두 번째 분기문은 isUnicodetrue이므로 ObjectMapper.readValue()가 호출됩니다.

  1. ObjectMapper.readValue()

ObjectMapperreadValue()는 오버로딩 되어있는 상태입니다.

이 오버로딩된 ObjectMapper.readValue()ObjectMapper._readMapAndClose() 메소드를 호출합니다.

  1. ObjectMapper._readMapAndClose()

ObjectMapper._readMapAndClose()에서 HTTP Request Message Body를 변환한 객체를 result로 반환합니다.

여기서 JsonToken을 통해 json 데이터가 있는지 확인합니다. JsonTokenjson의 시작 글자를 토큰으로 가져오며, 시작, 끝, 필드 이름, 문자열등으로 분류할 수 있습니다.

JsonToken t = _initForReading(p, valueType);를 통해 JsonToken을 조회하는데, 현재 HTTP Request Message Body가 있는 것이 이전에 확인되었으므로 검증하는 타입은 {, 시작 토큰이므로 값은 START_OBJECT가 됩니다.

그러므로 모든 분기에 걸리지 않고, else 블록에서 객체를 변환하게 됩니다.

  1. DefaultDeserializationContext.readRootValue()

_config는 일반적인 역직렬화 관련 구성을 의미합니다.
해당 기능은 기본적으로 비활성화 되어 있으므로 다음 분기문으로 넘어갑니다.

ObjectMapper._readMapAndClose()에서 valueToUpdatenull올 전달했기 때문에 두 번째 분기문이 동작합니다.

  1. BeanDeserializer.deserialize()

JsonParser는 이전에 START_OBJECT, {이므로 첫 번째 분기문에 들어갑니다.

이후 _vanillaProcessingfalse(특수 기능이 없는지 여부를 표현하는 플래그)고, _objectIdReadernull이기 때문에(요청에 objectId가 존재하지 않으므로 관련 Reader가 존재하지 않음) 마지막 return문이 실행됩니다.

  1. BeanDeserializer.deserializeFromObject()

여기까지가 공통처리이며, 기본 생성자가 있는지 없는지에 따라 해당 메소드에서 분기 처리가 됩니다.

기본 생성자

public class GameRequest {

    private String names;
    private int count;

    public GameRequest() {
    }

    public String getNames() {
        return names;
    }

    public int getCount() {
        return count;
    }
}

위와 같이 기본 생성자가 있는 경우입니다.

  1. BeanDeserializer.deserializeFromObject()

위의 분기문을 모두 무시하고 createUsingDefault() 메소드를 호출합니다.

  1. ValueInstantiator.createUsingDefault()

_defaultCrator.call()을 호출합니다.

  1. AnnotatedConstructor.call()

자바 리플렉션으로 가져온 기본 생성자를 통해 객체를 생성하는 것을 확인할 수 있습니다.

필드를 모두 초기화할 수 있는 생성자

public class GameRequest {

    private String names;
    private int count;
    
    public GameRequest(final String names, final int count) {
        this.names = names;
        this.count = count;
    }

    public String getNames() {
        return names;
    }

    public int getCount() {
        return count;
    }
}

기본 생성자 대신 필드를 모두 초기화할 수 있는 생성자를 추가했습니다.

  1. BeanDeserializer.deserializeFromObject()

기본 생성자를 사용했을 때에는 무시되던 분기문 중, _nonStandardCreationtrue가 되어 deserializeFromObjectUsingNonDefault() 메소드를 호출합니다.

  1. BeanDeserializer.deserializeFromObjectUsingNonDefault()

현재 DTO로 사용하고 있는 GameRequestdelegate가 적용되어있지 않으므로 두 번째 분기문으로 가게 됩니다.

필드를 모두 초기화할 수 있는 생성자가 단 하나이기 때문에 이는 Property 생성자 로 인식되고, 이를 토대로 DTO 객체를 생성합니다.

  1. BeanDeserializer._deserializeUsingPropertyBased()

중간에 생성자를 통해 빈을 생성하고 이를 반환합니다.

오동작의 원인

그래서 왜 기존 프로젝트에서는 기본 생성자 없이는 안된건가?에 대한 부분은, 솔직히 디버깅으로는 알 수 없었습니다.

그래서 찾아본 결과, ParameterNamesModule이 원인인 것을 확인할 수 있었습니다.

ParameterNamesModule

이 클래스의 용도는 다음과 같습니다.

Jackson의 ParameterNamesModule은 JavaBean 규칙이나 애노테이션을 사용하는 대신 매개변수 이름을 사용하여 Java 개체를 직렬화 및 역직렬화하는 방법을 제공하는 Java 라이브러리입니다.
이를 통해 개발자는 직렬화 및 역직렬화 중에 생성자 또는 메서드 매개 변수 이름을 JSON 속성 이름으로 사용할 수 있습니다.

Jackson을 사용할 때, 기본적으로는 기본 생성자 & getter / setter와 같은 JavaBean 규칙을 따르거나 @JsonCreator / @JsonProperty와 같은 애노테이션을 통해 Property Creator를 지정해줘야 하지만,

ParameterNamesModule를 등록한다면 매개 변수 이름을 통해서도 접근이 가능하다는 의미라고 볼 수 있습니다.
매개 변수 이름이니 생성자를 통해서도 접근이 가능할 것입니다.

이러한 ParameterNamesModuleorg.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration에서 자동으로 추가됩니다.

@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {

    // ...

	@Configuration(proxyBeanMethods = false)
	@ConditionalOnClass(ParameterNamesModule.class)
	static class ParameterNamesModuleConfiguration {

		@Bean
		@ConditionalOnMissingBean
		ParameterNamesModule parameterNamesModule() {
			return new ParameterNamesModule(JsonCreator.Mode.DEFAULT);
		}

	}
    
    // ...

ObjectMapper가 있고, ParameterNamesModule이 없으면 무조건 등록됩니다.

즉 무조건 등록된다고 볼 수 있습니다.

이제 생성자로 건네주고 있는 JsonCreaor.Mode도 살펴보도록 하겠습니다.

JsonCreator.Mode

JsonCreator.Modeinner enum입니다.

javadoc 문서의 설명을 매우 짧게 요약하자면 다음과 같습니다.

생성자 및 팩토리 메소드를 연관된 클래스의 새 인스턴스를 인스턴스화하는 데 사용할 하나의 메서드로 정의하는 데 사용할 수 있는 마커 애노테이션입니다.

DEFAULT, DELEGATING, PROPERTIES, DISABLED로 총 4가지가 존재하는데, 일단 DEFAULT만 살펴보겠습니다.

JsonCreator.Mode.DEFAULT의 경우, ObjectMapper는 생성자 메소드의 매개변수와 입력 JSON 객체의 필드를 매칭합니다.
이는 JSON 필드가 생성자의 매개변수와 동일한 이름을 가져야 함을 의미합니다.
각 매개변수에 대해 일치하는 항목이 발견되면 JSON 개체의 해당 값을 사용하여 메서드가 호출됩니다.

설명만 보면 필드를 모두 초기화하는 생성자로도 충분히 사용할 수 있을 것 같습니다.

진짜 원인

그래서 왜 Intellij 환경에서는 동작하지 않는가?에 대한 대답은 Jacksongithub repositoryREADME.md에서 확인할 수 있습니다.(링크는 여기)

첫 번째 항목입니다.

Person 클래스는 공식 매개변수 이름을 저장하는 옵션(-parameters 옵션)이 켜져 있는 Java 8 호환 컴파일러로 컴파일해야 합니다.
런타임에 매개변수 이름에 액세스하기 위한 Java 8 API에 대한 자세한 내용은 method parameter reflection을 참조하세요.

program arguement-parameters 설정을 줘야 한다는 것입니다.

Intellij 환경

이제 Intellij 환경에서도 실행할 수 있게 되었습니다.

settings -> Java Compiler -> Additional command line parameters-parameters를 입력합니다.

Build -> Rebuild Project를 통해 적용하면 됩니다.

정상적으로 실행됩니다.

Gradle

그렇다면 왜 gradle에서는 설정 없이도 정상적으로 동작했을까요?

gradle java plugin 문서에서는 못 찾았지만, 스프링 부트 문서에서는 찾을 수 있었습니다.

10번 항목에 -parameters 컴파일러 인수를 사용하도록 모든 JavaCompile 작업을 구성합니다라고 명시되어 있기 때문이었습니다.

Maven

안 쓸 것 같기는 하지만 Maven은 또 어떤가 싶어서 스프링 부트 문서를 확인해봤습니다.

spring-boot-starter-parent를 사용한다면 여러 기본 값을 세팅해주는데, 그 중 세 번째 항목처럼 -parameters를 통해 컴파일을 해 준다고 명시되어 있습니다.

결론

다음과 같이 결론을 지을 수 있을 것 같습니다.

  • ObjectMapper.readValue()기본 생성자 & POJO 혹은 Property Creator가 기본
  • 기본 생성자Property Creator를 사용하지 않고 모든 필드를 초기화하는 생성자를 통해 binding하고자 하는 경우 -paremeters 컴파일 옵션 필요
    • Intellj : 별도 설정 필요
    • Gradle : 자동
    • Maven : spring-boot-starter-parent 필요

궁금했던 내용

디버깅을 통해 @RequestBody가 어떤 식으로 동작하는지 살펴봤으니 궁금했던 부분을 하나씩 실험해보도록 하겠습니다.

기본 생성자를 사용할 때 getter가 필요한 이유

막연히 자바 리플렉션을 사용하기 때문이라고 알고 있었는데, 조금 더 자세히 확인하고 싶었습니다.

확인해본 결과, BeanDeserializer.deserializeFromObject()에서 ValueInstantiator.createUsingDefault() 호출로 DTO 객체를 생성한 이후 리플렉션을 통해 값을 세팅하고 있었습니다.

그렇다면 getter를 사용하지 않으면 객체가 생성되지만, 필드는 초기화되지 않을 것 같습니다.

기본 생성자를 사용할 때 getter가 없는 경우

동일한 코드에서 property를 찾지 못해 null로 표현된 것을 확인할 수 있습니다.

디버깅으로 확인해본 결과 예상대로 GameRequest의 필드는 초기화되지 않았음을 확인할 수 있었습니다.

@Valid를 통해 DTO의 값을 검증한 결과, 객체 생성 이후 필드가 초기화되지 않아 검증에 실패한 것을 확인할 수 있었습니다.

모든 필드를 초기화하지 않는 생성자만 있는 경우

public class GameRequest {

    private String names;
    private int count;

    public GameRequest(final String names) {
        this.names = names;
    }
}

이렇게 모든 필드를 초기화하지 않는 생성자만이 존재한다면 어떤 식으로 동작할까요?

일단 deserializeFromObjectUsingNonDefault() 메소드가 호출됩니다.

이후 deserializeFromObjectUsingNonDefault() 메소드까지는 진행되지만, _propertyBasedCreatornull이여서 예외가 반환됩니다.

여담으로 HttpMessageConversionException 발생 시 메세지를 어디서 만들어주는지도 확인할 수 있었습니다.

기본 생성자와 모든 필드를 초기화하는 생성자가 모두 존재할 경우

public class GameRequest {

    private String names;
    private int count;

    public GameRequest() {
    }

    public GameRequest(final String names, final int count) {
        this.names = names;
        this.count = count;
    }
    
    public String getNames() {
        return names;
    }

    public int getCount() {
        return count;
    }
}

제가 처음에 작성했던 코드는 @RequestBody로 인해 binding될 때 어떤 생성자를 사용할지 궁금했습니다.

기본 생성자가 우선되는 것을 확인할 수 있었습니다.

모든 필드를 초기화하는 생성자가 여러 개 있는 경우

public class GameRequest {

    private String names;
    private int count;

    public GameRequest(final String names, final int count) {
        this.names = names;
        this.count = count;
    }

    public GameRequest(final int count, final String names) {
        this.names = names;
        this.count = count;
    }

    public String getNames() {
        return names;
    }

    public int getCount() {
        return count;
    }
}

이 경우에는 어떤 생성자가 호출될까요?

모든 필드를 초기화하지 않는 생성자만 있는 경우와 같이, 어떤 생성자가 Property 생성자인지 판단할 수 없어 예외가 발생하는 것을 확인할 수 있었습니다.

해결방법

그냥 생성자 둘 중 하나를 삭제하면 되겠지만 굳이 다른 방법으로 해결해보도록 하겠습니다.

Property 생성자 지정

@JsonCreator@JsonProperty를 사용하면 어떤 생성자를 사용할지 ObjectMapper에게 알려줄 수 있습니다.

public class GameRequest {

    private String names;
    private int count;

    @JsonCreator
    public GameRequest(@JsonProperty("names") final String names, @JsonProperty("count") final int count) {
        this.names = names;
        this.count = count;
    }

    public GameRequest(final int count, final String names) {
        this.names = names;
        this.count = count;
    }

    public String getNames() {
        return names;
    }

    public int getCount() {
        return count;
    }
}

이전과 다르게 _propertyBasedCreator가 존재하는 것을 확인할 수 있습니다.

_propertyBasedCreator를 확인해보면 @JsonCreator로 지정한 GameRequest(String, int)임을 확인할 수 있습니다.

ObjectMapper 커스텀

이전에 기본 생성자가 아닌 생성자를 통해 binding을 할 때, 다음과 같은 상황에서는 예외가 발생한다고 말씀드렸습니다.

하지만 사실 바로 예외가 발생하는 것은 아니고, 과정이 하나 더 있습니다.

DeserializationContext.handleMissingInstantiator()에서 생성자를 찾지 못한 상황이라도 이를 수행할 수 있는 h, handler가 있다면 객체를 binding해서 반환합니다.

등록된 모든 handler를 찾게 되는데, 기본적으로는 아무 handler로 등록되어 있지 않습니다.

이러한 과정을 거친 뒤에야 위와 같이 예외를 발생시키는 객체를 반환하는 방식입니다.

handlerDeserializationProblemHandler으로, 이를 확장한 handler를 정의해 ObjectMapper에 등록하면 이를 해결할 수 있습니다.

// Handler
public class GameRequestDeserializationProblemHandler extends DeserializationProblemHandler {

    @Override
    public Object handleMissingInstantiator(final DeserializationContext ctxt, final Class<?> instClass,
            final ValueInstantiator valueInsta, final JsonParser p, final String msg) throws IOException {
        if (GameRequest.class.isAssignableFrom(instClass)) {
            return handleMissingInstantiatorForGameRequest(ctxt);
        }
        return super.handleMissingInstantiator(ctxt, instClass, valueInsta, p, msg);
    }

    private Object handleMissingInstantiatorForGameRequest(final DeserializationContext ctxt) {
        try {
            final JsonNode jsonNode = ctxt.getParser().getCodec().readTree(ctxt.getParser());
            final String names = jsonNode.get("names").asText();
            final int count = jsonNode.get("count").asInt();

            return new GameRequest(names, count);
        } catch (final IOException e) {
            throw new RuntimeException(e);
        }
    }
}

// Configuration
@Configuration
public class RaceConfiguration {

    @Bean
    ObjectMapper objectMapper() {
        final ObjectMapper objectMapper = new ObjectMapper();

        objectMapper.addHandler(new GameRequestDeserializationProblemHandler());
        return objectMapper;
    }
}

이 경우 GameRequestDeserializationProblemHandler를 등록했기 때문에 정상적으로 처리가 가능합니다.

결론

모르는 기능을 사용하고자 할 때에는 먼저 문서를 봐야 할 것 같습니다.

profile
안녕하세요

6개의 댓글

comment-user-thumbnail
2023년 4월 17일

역시 지토 폼 미쳤다.

2개의 답글
comment-user-thumbnail
2023년 4월 17일

지선생님! 달달하게 빨대 꼽고 갑니다~

1개의 답글
comment-user-thumbnail
2023년 4월 27일

이게 진짜지

답글 달기