져니의 개발 정원 가꾸기

Jackson 라이브러리 (Spring MVC에서 Json데이터와 vo는 서로 어떻게 변환될까?) 본문

개발노트/Spring | Java

Jackson 라이브러리 (Spring MVC에서 Json데이터와 vo는 서로 어떻게 변환될까?)

전전쪄니 2023. 3. 19. 01:25

목차

    직렬화/역직렬화

    스프링MVC 혹은 부트 프레임워크로 개발을 하다보면 api를 개발하거나 호출하는 경우가 많다. api를 호출하기 위해 우리는 주로 json 형식의 데이터를 함께 전송하곤 하는데, 과연 json 데이터가 어떻게 POJO객체(객체 쓰임상 dto나 vo라고도 불리운다)에 매핑될까?

    더불어 반대의 경우(POJO 객체 -> JSON 형태)는 어떻게 변환되는 것일까?

    직렬화 / 역직렬화

    변환 과정을 바로 알아보기에 앞서 먼저 직렬화와 역직렬화에 대한 개념을 이해해야 한다.

    • 직렬화(serallization) : 데이터구조나 객체의 상태를 파일이나 메모리 버퍼에 저장하거나 다른 환경으로 전송(ex. 네트워크 통신)할 수 있도록 재구성할 수 있는 포맷으로 변환하는 과정을 말한다.
    • 역직렬화(deseriallizaion) : 반대로 포맷에서 데이터구조나 객체 상태로 추출해내는 과정을 말한다.

    즉, 객체와 데이터포맷 간의 변환은 직렬화, 역직렬화라고 하며, POJO 객체를 JSON데이터로 만드는 작업을 직렬화, JSON데이터를 POJO 객체로 만드는 작업은 역직렬화라고 한다.

    Jackson 라이브러리

    스프링에서 역직렬화/직렬화를 하기 위해 여러가지 라이브러리(ex. Jackson, gson, JSON-B)들이 지원되고 있다. 이 글에서는 그 중에서 가장 많이 쓰이는 jackson 라이브러리에 대해 알아볼 것이다.

    앞에 설명에서 알 수 있듯이 Jackson 라이브러리는 스프링MVC나 부트에서 JSON데이터와 POJO 사이의 변환을 처리하는 라이브러리이이고, 크게 세 가지의 라이브러리로 구성된다.

    세 라이브러리의 포함관계는 jackson-core ⊂ jackson-annotaions ⊂ jackson-databind 로, jackson-databind 라이브러리를 추가하면 나머지 두 라이브러리도 불러와진다.

    참고 : spring MVC의 경우 직접 라이브러리를 추가해줘야하지만, spring boot의 경우 별도로 추가하지 않더라도 jackson관련 라이브러리들이 자동으로 추가된다. gson, JSON-B을 사용하도록 커스터마이즈해도 되지만 jackson이 디폴트이다.

    Jackson 라이브러리를 사용한 변환

    RequestMappingHandlerAdapter(변환의 시작)

    사용자가 보낸 json 데이터가 어떻게 POJO 객체로 변환되는지 보기 위해서는 먼저 RequestMappingHandlerAdapter의 역할과 동작을 봐야한다.

    RequestMappingHandlerAdapter를 이해하기 위해서는 전체적으로 애플리케이션이 어떻게 요청을 처리하는지를 알아야 하는데 이 글의 주제가 spring mvc의 동작원리를 설명하는 주제는 아니기 때문에 간단한 설명만 확인하고 넘어가고자 한다.

    Spring MVC 동작원리

    클라이언트가 애플리케이션 서버에 요청을 보냈을 때 가장 먼저 디스패처 서블릿이 요청을 처리하기 적합한 핸들러 어댑터를 찾는다. 디스패처 서블릿은 찾은 핸들러 어댑터를 호출하여 적합한 컨트롤러에 요청을 보내고 이 요청에 대한 응답을 반환받는다. 만약 핸들러 어댑터가 컨트롤러로부터 받은 응답이 view에 대한 정보를 가진 객체라면, 디스패처 서블릿은 viewResolver을 호출하여 controller가 처리한 결과를 보여줄 view를 반환받는다. 그리고 디스패처 서블릿은 이 view를 받아 최종적으로 응답화면을 생성하여 client에게 반환한다.

    한편, 컨트롤러가 view정보를 포함하지 않고 데이터 응답(ex. 회원 조회시 회원 정보만 있는 형태)만을 반환했다면 view를 생성하는 과정은 거치지 않는다. 즉, 위 그림에서의 6~8번 과정을 거치지 않는다. (RestController를 다루는 글 후반부 이미지를 보면 말한 차이점을 직관적으로 이해하는데 도움이 될 것 같다.)

    RequestMappingHandlerAdapter 동작 원리

     그러면 핸들러 어댑터는 어떻게 json 데이터를 POJO로 변환한 뒤 적절한 controller로 넘겨주는 것일까? 어떤 핸들러 어댑터를 사용하는 것일까?

     우리는 흔히 컨트롤러를 개발할 때 @RequestMapping과 같은 애너테이션을 사용하는, 즉 uri를 매핑하여 요청을 처리하는 방식을 많이 쓴다. 이처럼 uri를 매핑하여 처리하는 방식에서 RequestMappingHandlerAdpater가 동작한다. RequestMappingHandlerApdater는 가장 먼저 ArgumentResolver를 통해 요청시 같이 들어온 데이터들을 처리한다. ArgumentResolver는 HttpMessageConverter를 통해 인자로 들어온 데이터를 POJO 객체로 변환하고, RequstMappingHandlerAdapter는 uri 기반으로 적합한 컨트롤러에 변환된 객체들과 함께 메시지 전달을 한다.

     반대로 POJO객체를 json으로 직렬화할 때, Controller는 전송 대상 객체를 ReturnValueHandler에 전달한다. RetrunValueHandler는 HttpMessageConverter를 사용하여 객체를 json 포맷 데이터로 변환하여 핸들러 어댑터로 반환하고, 최종적으로 데이터는 디스패처 서블릿을 거쳐 사용자에게 전달된다.

     참고로 HttpMessageConverter는 인터페이스로 많은 구현체들이 존재한다. 그리고 RequestMappingHandlerAdapter는 messageConverters 이름을 가진 HttpMessageConverter<?> 타입의 컬렉션 멤버를 가지고 있는데, 요청, 응답 형식에 따라 컬렉션 멤버에 추가된 HttpMessageConverter구현체를 선택하여 변환한다.

    HttpMessageConverter 의 구현체, MappingJackson2HttpMessageConverter

    HttpMessageConverter의 구현체 중 MappingJackson2HttpMessageConverter는 jackson라이브러리에 있는 ObjectMapper를 사용하여 JSON 데이터 포맷과 POJO객체간의 변환을 지원한다.

    @RequestBody, @ResponseBody

    요청시 인자를 POJO객체로 받기위해 @RuquestBody를 붙인다. ArgumentResovler에서 해당 애너테이션이 붙은 것을 감지하면 MappingJackson2HttpMessageConverter를 사용하여 변환을 시도한다.

    응답의 경우 요청을 처리한 뒤 객체에 데이터를 싣어 응답을 보낸다. 이 때 객체는 ResponseBody형태로 보내진다. 이 경우도 마찬가지로MappingJackson2HttpMessageConverter를 사용하여 변환을 시도한다.

    MappingJackson2HttpMessageConverter 와 ObjectMapper

    자, 그럼 MappingJackson2HttpMessageConverter는 어떻게 변환하는 것일까? 실제 직렬화, 역직렬화는 ObjectMapper를 통해서 이루어진다.

    그런데 MappingJackson2HttpMessageConverter 클래스를 보면 ObjectMapper를 멤버로 가지고 있지도 않은데 어떻게 된 것일까? 아래의 MappingJackson2HttpMessageConverter 클래스의 상속관계를 보자.

    상속 관계

    보다시피 MappingJackson2HttpMessageConverter의 부모인 AbstractJackson2HttpMessageConverter가 ObjectMapper를 가지고 있다. 따라서 MappingJackson2HttpMessageConverter는 부모클래스의 ObjectMapper의 메서드를 호출하여 직렬화 및 역직렬화를 진행하는 것이다.

    역직렬화 (Deserialization : JSON -> POJO)

    먼저 역직렬화를 살펴보기 위해 아주 간단한 api를 만들었다. (주문 접수 api)

    http://localhost:8080/api/order

    위 api로 다음과 같은 json형식의 주문 정보를 body에 싣어서 요청을 보낸다.

    {
      "orderNumber": 12345,
      "userName": "soo",
      "totalPrice": 10000
    }
    @RestController
    @RequestMapping("/api/order")
    public class OrderController {
    
        @PostMapping()
        public String order(@RequestBody Order order) {
            System.out.println(order.requestOrderInfo());
            return "success";
        }
    
    }

    case1. 멤버 변수를 public으로 한 경우

    1. public 멤버 변수 + 기본 생성자 O -> 200 OK

    @ToString
    public class Order {
        public int orderNumber;
        public String userName;
        public int totalPrice;
    
        public String requestOrderInfo() {
            return this.toString();
        }
    }

     아래 디버깅 콜스택을 보면 converter는 ObejctReader.readValue()를 호출하여 역직렬화를 진행한다. ObjectReader.readValue()에서부터 콜스택을 들어가다보면 BeanDeserializer.deserializerFromObject()를 호출하는 것을 볼 수 있는데,이곳에서 생성자와 getter/setter가 어떻게 선언되었는지에 따라 다른 분기를 탄다(중요).

     지금처럼 역직렬화 대상 객체에 기본생성자가 선언되어 있는 경우 reflection API (Consturctor.newInstance()와 Field.get())를 호출하여 객체를 만들고 필드 값들을 설정한다.

    기본생성자 - Constructor 로 객체 생성
    BeanDeserializer.deserializeFromObject()에서 반복문을 돌려 속성정보를 세팅하기 위한 부분
    Field reflection API를 사용해서 객체 값 세팅

    2. public 멤버 변수 + 기본 생성자 X -> 500 에러

    @ToString
    @AllArgsConstructor
    public class Order {
        public int orderNumber;
        public String userName;
        public int totalPrice;
    
        public String requestOrderInfo() {
            return this.toString();
        }
    }

    서버쪽에서 다음과 같은 익셉션이 난다. 왜 익셉션이 나는지 에러 메시지를 따라가보자.

    com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.example.simple_practice.jackson_example.domain.Order` (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.14.2.jar:2.14.2]
        at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1909) ~[jackson-databind-2.14.2.jar:2.14.2]
        at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:408) ~[jackson-databind-2.14.2.jar:2.14.2]
        at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1354) ~[jackson-databind-2.14.2.jar:2.14.2]
        at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1420) ~[jackson-databind-2.14.2.jar:2.14.2]
        at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:352) ~[jackson-databind-2.14.2.jar:2.14.2]
        at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185) ~[jackson-databind-2.14.2.jar:2.14.2]
        at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323) ~[jackson-databind-2.14.2.jar:2.14.2]
        at com.fasterxml.jackson.databind.ObjectReader._bindAndClose(ObjectReader.java:2105) ~[jackson-databind-2.14.2.jar:2.14.2]
        at com.fasterxml.jackson.databind.ObjectReader.readValue(ObjectReader.java:1481) ~[jackson-databind-2.14.2.jar:2.14.2]
        at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.readJavaType(AbstractJackson2HttpMessageConverter.java:395) ~[spring-web-6.0.5.jar:6.0.5]
        at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.read(AbstractJackson2HttpMessageConverter.java:354) ~[spring-web-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters(AbstractMessageConverterMethodArgumentResolver.java:183) ~[spring-webmvc-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.readWithMessageConverters(RequestResponseBodyMethodProcessor.java:163) ~[spring-webmvc-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.resolveArgument(RequestResponseBodyMethodProcessor.java:136) ~[spring-webmvc-6.0.5.jar:6.0.5]
        at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122) ~[spring-web-6.0.5.jar:6.0.5]
        at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:181) ~[spring-web-6.0.5.jar:6.0.5]
        at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:148) ~[spring-web-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117) ~[spring-webmvc-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:884) ~[spring-webmvc-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:797) ~[spring-webmvc-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1081) ~[spring-webmvc-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:974) ~[spring-webmvc-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1011) ~[spring-webmvc-6.0.5.jar:6.0.5]
        at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) ~[spring-webmvc-6.0.5.jar:6.0.5]

     우선 기본생성자가 있을 때와의 가장 큰 차이점은, BeanDesiralizer.deserializeFromObject()에서 다른 분기를 탄다는 것이다. 기본생성자가 있는 경우는 _valueInstantiator.createUsingDefault(ctxt)를 호출하여 대상 객체로 역직렬화를 진행하였지만, 기본 생성자가 없는 경우에는 BeanDeserializerBase.deserializeFromObjectUsingNonDefault(p, ctxt)를 호출하여 역직렬화한다.

    BeanDeserializer::deserializeFromObject()

     deserializeFromObjectUsingNonDefault는 파라미터가 있는 다른 생성자들에 기반해서 역직렬화하는 메서드다. 기본생성자가 아닌 매개변수가 있는 생성자의 경우 매개변수마다 @JsonProperty("field_name")을 반드시 붙여줘야 역직렬화가 가능하다. 해당 애노테이션을 붙여줘야 (아래 이미지 참고) _propertyBasedCreator 가 Null이 아닌 값이 들어가 _deserializeUsingPropertyBased(p, ctxt)를 호출하여 역직렬화를 진행하기 때문이다.

    BeanDeserializerBase::deserializeFromObjectUsingNonDefault()

    즉, 매개변수를 받는 생성자가 있다고 하더라도 매개변수마다 애노테이션을 붙여주지 않아 위와 같은 exception을 냈던 것이다. 기본생성자를 쓰지 않지만 역직렬화를 하고 싶다면 아래 코드처럼 매개변수에 @JsonProperty("field_name")을 붙이자.

    @ToString
    @AllArgsConstructor
    public class Order {
        public int orderNumber;
        public String userName;
        public int totalPrice;
    
        public String requestOrderInfo() {
            return this.toString();
        }
    
        public Order(@JsonProperty("orderNumber") int orderNumber) {
            this.orderNumber = orderNumber;
        }
    }

    case2. 멤버 변수가 private인 불변 객체로 선언할 경우

    1. private 멤버 변수 + 기본 생성자 X -> 500 에러

    case1-2에서 다루었던 내용과 같은 이유에서 500에러가 난다. 기본생성자가 아닌 다른 생성자가 있을 경우 매개변수마다 @JsonProperty("field_name")을 붙여주지 않으면 역직렬화에 실패하여 익셉션이 발생하여 500을 반환하게 된다.

    (참고로 매개변수에 @JsonProperty("field_name")을 붙여주면, 매개변수로 들어온 값들만 세팅이 되고, 나머지 필드 값들은 기본값이나 null,0으로 할당된다.)

     

    2. private 멤버 변수 + 기본 생성자 O -> 200 OK (하지만 실제 값을 못 가져옴)

    3. private 멤버 변수 + 기본 생성자 O + getter or setter-> 200 OK

     case2-2처럼 getter/setter없이 기본생성자만 있으면 json으로 넘어오는 값들을 제대로 받아오지 못한다. 왜 그럴까? 이 역시 답은 BeanDeserializer.deserializeFromObject()에 있다.

     이 함수에서 역직렬화 대상 객체를 생성한 뒤 객체의 필드 값을 넣어주는 과정을 자세히 보자. 값을 설정할 수 있는 필드가 있는 경우에SettableBeanProperty타입의 객체의 deserializeAndSet()을 함수를 호출하여 실제 값을 넣어준다. 중요한 것은 실제 값으로 설정하기 전에 _beanProperties.find()를 호출하여 세팅 가능한 필드의 속성정보(SettableBeanProperty)를 가져오는데 , 기본생성자만 있을 경우, 기본생성자와 getter만 있을 경우, 기본생성자와 setter만 있을 경우, 기본생성자와 getter/setter가 모두 있는 경우, 각각에 맞는 SettableBeanProperty 하위 타입 객체를 받아온다는 것이다.

     

    기본생성자만 있는 경우 (setter, getter 둘 다 없음) -> null 

    BeanDeserializer::deserializeFromObject

    기본생성자 + getter만 있는 경우 -> FieldProperty -> Field.set() reflection API 을 이용해서 값 세팅 (필드가 public인 경우와 동일)

    BeanDeserializer::deserializeFromObject

    기본생성자 + setter만 있는 경우 -> MethodProperty ->  Method.invoke() reflection API 를 통해 값 세팅

    BeanDeserializer::deserializeFromObject()
    MethodProperty::deserializeAndSet()

    기본생성자 + setter, getter 둘 다 있는 경우 -> MethodProperty -> Method.invoke() reflection API 를 통해 값 세팅

    BeanDeserializer::deserializeFromObject

    디버깅 콜스택을 살펴본 것들을 토대로 정리하자면, 역직렬화를 지원하기 위해서는 대상객체는 setter와 getter 둘 중 하나와 기본생성자를 가져야한다.

    직렬화 (Serialization : POJO -> JSON)

    이번에는 주문 번호로 주문 정보를 조회하는 아주아주 간단한 api의 예시를 통해 어떻게 직렬화를 하는지 알아보자.

    (주문 정보 조회 api는 http://localhost:8080/api/order?orderNumber=1 라고 하자.)

    case1. 멤버 변수를 public으로 한 경우

    멤버 변수 전체를 public으로 선언할 멤버 변수의 이름들(orderNumber, userName, totalPrice) 을 JSON 데이터의 키로 매핑한다. (실제 직렬화 과정은 원리는 case2에서 같이 보자.)

    @RestController
    @RequestMapping("/api/order")
    public class OrderController {
    
        @GetMapping()
        public Order getOrder(@RequestParam int orderNumber) {
            return new Order(orderNumber, "홍길동", 13000);
        }
        
        @PostMapping()
        public String order(@RequestBody Order order) {
            System.out.println(order.requestOrderInfo());
            return "success";
        }
    }
    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    public class Order {
        public int orderNumber;
        public String userName;
        public int totalPrice;
    
        public String requestOrderInfo() {
            return this.toString();
        }
    }
    {
      "orderNumber": 1,
      "userName": "홍길동",
      "totalPrice": 13000
    }

    case2. 멤버 변수가 private인 불변 객체로 선언할 경우

    하지만 클래스의 멤버들을 모두 public접근 제한자로 열어버린다면 외부에서 값을 마음대로 바꿔치기 할 수 있을 것이다. 따라서 멤버들을 pirvate으로 선언하여 불변성을 보장하곤 하는데, 여전히 동작할까?

     

    1. private 필드 + getter -> 200 OK

     먼저 직렬화가 되는 경우다.

    @Getter
    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    public class Order {
        private int orderNumber;
        private String userName;
        private int totalPrice;
    
        public String requestOrderInfo() {
            return this.toString();
        }
    }

     처음으로 컨버터를 사용하여 직렬화와 관련된 메시지를 전달하는 곳은 AbstractMessageConverterMethodProcessor클래스의 writeWithMessageConverters메서드이다. 이 곳에서는 컨버터를 사용해 직렬화를 시작하기 앞서 먼저 직렬화가 가능한지를 검증한다. 그리고  바로 이 분기 부분이 뒤에 나올 직렬화가 안 되는 두 케이스와 다른 분기처리를 타게 되는 지점이다. 이곳에서는 컨버터의 canWrite()를 호출하여 직렬화 가능여부를 판단하고, true(=직렬화 가능)이 반환되면 직렬화 작업을 진행한다. 직렬화 작업은 converter의 write() 함수를 호출하는 것으로 시작한다. 

    AbstractMessageConverterMethodProcessor::writeWithMessageConverters

     converter.write() 메서드 호출에 성공하면 내부적으로 연달아 writeInternal() 메서드를 호출한다.그리고  이곳에서는 컨버터 녀석이 멤버로 가지고 있는 ObjectMapper를 ObjectWriter로 변환한 뒤 writeValue() 메서드를 호출한다. (Mapper를 Writer로 변환하는 이유는 쓰레드 때문이라고 했는데, 나중에 보자..)

     json 데이터의 경우 MappingJackson2HttpMessageConverter를 사용하는데, 이 컨버터는 부모 클래스인 AbstractJackson2HttpMessageConverter의 write()를 호출하여 직렬화를 진행한다. write() 메서드 내부에서 직렬화 과정중에 ObjectWriter.writeValue()를 호출하여 실질적인 직렬화 처리를 한다.

    AbstractJackson2HttpMessageConverter::writeInternal()

     ObjectWriter.writeValue()를 더 파고 들어가다보면 BeanPropertyWriter.serializeAsField()에서 실제 값을 불러오는 부분을 확인 할 수 있다. 이지점이 아까 말한 case1(public 접근제한자 필드)의 경우와 case2의 처리가 달라지는 부분이다. getter를 썼을 경우는 Method.invoke 리플렉션 API를 사용하여 값을 얻어온다. (아래 이미지 참고)

    private + getter 인경우

     반면 필드에 public 접근제한자를 썼을 경우에는 Field.get() 리플렉션 api를 통해 객체의 값을 얻어와 설정한다. 

    public 필드일 때

    2. private 필드 -> 406 error 발생

    자 그럼 이제 안 되는 케이스이다.

    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    public class Order {
        private int orderNumber;
        private String userName;
        private int totalPrice;
    
        public String requestOrderInfo() {
            return this.toString();
        }
    }

     왜 406 에러가 발생했을까?

     그렇다. 아까 말했던 직렬화 검증 부분(coverter.canWrite())을 통과하지 못했기 때문이다. 

     아래 디버깅 콜스택을 보면 직렬화 가능 여부 확인하는 과정에서 AbstractJackson2HttpMessageConverter.canWrite()를 거쳐 ObjectMapper.canSerialize()를 호출되는 것을 볼 수 있다.

     ObjectMapper.canSerialize()가 true를 반환하기 위한 필요 조건 중 하나는 UnknownTypeSerializer가 아닌 유효한 serializer가 있는가이다. serializer를 찾기 위해 먼저 POJOPropertiesCollector.collectAll()을 호출하여 객체에서 속성 정보들을 모으는 작업을 하는데, 이 함수 내부에서  _removeUnwantedProperties()를 호출하여 직렬화에 부적합한 속성 정보를 제외시키는 작업 역시 진행한다.

     제외 조건 중 하나가 바로 anyVisible()이다. anyVisible()은 prop(속성 정보)들마다 호출되는데, 만약 호출 결과가 false면 해당 속성 정보를 누락시켜버린다. anyVisible()은 객체의 필드들이 public이거나, getter나 setter가 있으면 true를 반환하지만 private인데 getter와 setter 모두 없으면 속성을 제거한다. 이 경우 _removeUnwantedProperties() 호출 이후 모든 속성 정보들이 anyVisible()의 결과가 false이므로 props에 아무것도 존재하지 않게 된다.

     결국 직렬화 해야할 객체의 속성 정보가 남아있지 않아 serializer를 찾지 못하고 상위에서 직렬자를 찾기위해 호출했던 메서드인_findExplicitUntypedSerializer()는 null을 반환한다. 이에 ObjectMapper.canSerialize()는 false를 반환하여 컨버터의 canWrite()의 결과는 false가 된 것이다.

     최종적으로 AbstractMessageConverterMethodProcessor::writeWithMessageConverters()는 더이상 직렬화 작업을 진행하지 않고 HttpMediaTypeNotAcceptableException를 발생시켜 406를 반환하고 만다.

    3. private필드 + setter -> 여전히 406 error

    @Setter
    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    public class Order {
        private int orderNumber;
        private String userName;
        private int totalPrice;
    
        public String requestOrderInfo() {
            return this.toString();
        }
    }

     한편 setter만 있는 경우 anyVisible()단계는 true로 무사히 통과한다. 그런데 왜 406에러가 뜨는 것일까?

     이는 직렬화 가능 여부를 확인하는 과정에서 다음 단계 중 하나인 멤버에 접근할 수 있는지를 검사하는 부분을 통과하지 못했기 때문이다.  collectAll()에서 _removeUnwantedProperties()가 호출될 때 속성 정보들을 지켜낼 수 있었지만, 다른 검증 단계(접근 가능성 여부 검사)에서는 지켜내지 못한 것이다.

     접근 가능성 여부 검사는 특정 타입의 필드들을 직렬화 대상에서 제외하는 과정인, removeIgnorableTypes() 내부로직에서 이루어진다. 이 함수의 로직을 보면 특정 타입의 필드를 제거하기 전에 필드 값에 접근할 수 없는 필드를 제거하는 작업을 한다. 실제 필드 접근 가능 여부는 아래와 같이 BeanPropertyDefinition.getAccesor()를 호출하여 접근자를 얻을 수 있는가로 확인한다. 필드가 public이거나 public의 getter함수를 제공하는 경우에만 AnnotatedMember를 반환하고, 이외의 경우에는 필드의 접근자가 없는 것으로 null을 반환한다. 만약 null이 반환되면 removeIgnorableTypes()는 해당 필드를 접근할 수 없다고 판단하여 직렬화 대상에서 제외한다.

     setter만 쓸 경우 null이 반환되어 클래스의 seriallizer를 찾지 못했기 때문에 UnknownTypeSerializer가 반환되고만 것이다. 이전 케이스와 동일하게 serializer가 UnknownTypeSerializer이기 때문에 _findExplicitUntypedSerializer는 null을 반환한다. 결국ObjectMapper.canSerialize()는 false가 되고 최종적으로 HttpMediaTypeNotAcceptableException를 발생하여 406 에러가 반환된다.

    직렬화 가능 여부를 판단하고 직렬화를 진행하는 AbstractMessageConverterMethodProcessor::writeWithMessageConverters

     앞에 내용들을 토대로 private 접근제한자로 선언된 경우, 최소한 getter를 가지고 있어야 직렬화가 가능하다! 

    정리

    여태까지의 내용들을 종합해보면 json과 객체 간의 변환은 다음과 같이 정리할 수 있다

    • 요청 데이터 혹은 반환 데이터를 미디어 타입에 맞게 변환하기 위해 HttpMessageConverter타입의 컨버터를 사용한다.
    • 그 중에서 객체 <-> json 간 변환은MappingJackson2HttpMessageConverter구현체를 사용한다.
    • 실제로는 MappingJackson2HttpMessageConverter가 부모클래스(AbstractJackson2HttpMessageConverter)가 가지고 있는 ObjectMapper를 통해 직렬화, 역직렬화 작업을 처리한다.
    • private 필드를 가지는 불변 객체는 역직렬화/직렬화를 모두 하기 위한 최소한의 조건으로 getter와 기본생성자를 가져야 한다.
      • 역직렬화는 reflection API를 통해 기본생성자를 만들고 getter 혹은 setter로 값을 채우는 과정을 거치기 때문이다.
      • 직렬화는 getter를 통해 객체의 필드 값들을 읽는 과정을 거치기 때문이다.

    *유의점 : 이 글에서는 setter와 getter를 만들 때 @Setter, @Getter애너테이션을 붙여주는 방식을 선택했다. 역직렬화, 직렬화 과정에서 setter, getter를 찾을 때 기본 규칙 (get접두사 + [필드네임], set접두사 + [필드네임])으로 메서드를 찾기 때문에 setter, getter의 이름은 막 짓지말고 기본 규칙을 준수해서 만들자.

    (추가1) @RestController를 쓰는 이유

    @Controller
    @RestController

    @RestController는 @Controller + @ResponseBody이다.

    @Controller가 붙은 컨트롤러에서 기본적으로 viewName 혹은 ModeAndView등 뷰 정보를 보내는 것을 많이 볼 수 있는데, 이 경우 DispatcherServlet가 ViewResolver를 통해 이에 대응하는 화면(view)을 생성한다. 한편 컨트롤러가 REST api로서만 동작하게 하기 위해서는 (순수 데이터들만 상호작용하기 위해서는) 컨트롤러에서 반환하는 객체에 @ResponseBody를 붙여줘야한다. 그러면 ViewResolver를 타지 않게된다.

    그래서 REST API를 만들고자 한다면 @Controller + @ResponseBody 조합의 @RestController를 쓰는 것이 유용하며, 보편적이다.

    (추가2) 컨버터 설정 커스터마이즈하기

    간혹 잭슨 converter에 대해서 커스터마이즈해야하는 순간이 있다. 가령 dto가 LocalDateTime인 필드를 가지고 있다고 해보자. 직렬화된 데이터를 보면 다음과 같이 날짜가 ISO-8601 형태로 온다.

    {
        "orderNumber": 1,
        "userName": "홍길동",
        "totalPrice": 13000,
        "createdAt": "2023-03-18T23:20:01.650429",
        "updateAt": "2023-03-18T23:20:01.65047"
    }

     아래와 같이 configuration 빈을 생성해주면 ISO-8601이 아닌 다른 패턴으로 날짜를 직렬화할 수 있다. 먼저 @Configuration을 선언하고 WebMvcConfigurer의 extendsMessageConverters()메서드를 재정의한다. 이 메서드는 기본으로 등록된 컨버터들을 인자로 받아와 새로운 컨버터를 추가하는 기능을 가진다. 재정의된 메서드는 원하는 형식(ex.YYYY-MM-DD hh:mm:ss)으로 날짜를 포매팅하는 설정을 추가한 ObjectMapper와, 이를 주입한 MappingJackson2HttpMessageConverter를 만들어 컨버터 리스트에 추가한다.

     여기서 기존 컨버터 리스트 맨 앞에 새로운 컨버터를 추가하는데, 이는 컨버터 리스트에서 같은 타입의 컨버터가 여러개 있을 경우 가장 먼저 등장하는 녀석을 사용하기 때문이다. 즉, MappingJackson2HttpMessageConverter가 기본 컨버터 리스트에 이미 등록이 되어 있어 새롭게 만든 녀석을 쓰기 위해 맨 앞에 넣은 것이다.

    @EnableWebMvc
    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    
        @Override
        public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("YYYY-MM-DD hh:mm:ss");
            ObjectMapper objectMapper = Jackson2ObjectMapperBuilder
                    .json()
                    .serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(formatter))
                    .build();
            converters.add(0, new MappingJackson2HttpMessageConverter(objectMapper));
        }
    }
    {
        "orderNumber": 1,
        "userName": "홍길동",
        "totalPrice": 13000,
        "createdAt": "2023-03-78 12:07:05",
        "updateAt": "2023-03-78 12:07:05"
    }

    WebMvcConfigurer

    스프링 MVC는 WebMvcAutoCofiguration에 기반하여 자동구성되는데, WebMvcConfigurer인터페이스는 스프링 MVC 구성에 Formatter, MessageConverter등을 추가로 등록, 설정하는 역할을 한다.

     WebMvcConfigurer는 default메서드로만 구성된 인터페이스이다. 그래서 다른 메서드들을 재정의하지 않고 extendsMessageConverters()만 재정의해도 컨버터가 잘 추가되어 잘 동작한다. 참고로 extendsMessageConverters()가 아닌configureMessageConverters()메서드를 재정의하면 기본으로 등록된 컨버터들을 사용하지 않고 직접 추가한 컨버터들만 사용한다.

    @EnableWebConfig

     사실 @EnableWebConfig없이 @Configuration과 WebMvcConfigurer만 있어도 잘 작동한다. @EnableWebConfig는 MVC 관련 Bean들을 커스터마이즈하기 위해서 사용된다. 

     앞에서 말한 것처럼 스프링 MVC는 WebMvcAutoCofiguration에 기반하여 자동구성된다. 우리의 예시처럼 기본 자동구성을 유지하고 기능만 확장하고 싶을 때는 @Configuration과 WebMvcConfigurer만 사용해도 된다.

     @EnableWebConfig는 (애노테이션 정의 참조)DelegatingWebMvcConfiguration를 임포트하는데, DelegatingWebMvcConfiguration는 WebMvcAutoConfigurationSupport클래스를 상속받아 MVC 관련 빈들의 커스터마이징을 가능하게 한다. 그런데 WebMvcAutoConfigurationSupport빈이 생성되면 WebMvcAutoConfiguration은 활성화되지 않는다(WebMvcAutoConfiguration 클래스 정의 참고). 즉, @EnableWebConfig를 사용하면 기본 설정을 사용하지 않는다는 것이다. 실제로 아래 이미지에서 볼 수 있듯이 @EnableWebConfig 사용여부에 따라서 다른 설정을 바라보게 되어 기본으로 등록되는 컨버터들도 달라진다. 

     이 예시에서는 다른 MVC 빈들을 커스터마이즈하지 않아 @EnableWebConfig를 굳이 쓸 필요는 없지만, 큰 프로젝트에서는 빈들을 커스터마이즈하는 경우들이 있기에 종종 쓰이는 것을 볼 수 있다.

    기본 설정 - 기본 등록 컨버터 8개
    @EnablieConfig - 기본 등록 컨버터 6개

    (추가3) OpenFeign은 어떻게 주고 받지?

    (Spring Cloud) OpenFeign을 통한 마이크로 서비스 api에 대한 요청과 응답

    Spring Cloud에서 제공하는 OpenFeign도 내부적으로 HttpMessageConverters를 쓴다. 그래서 우리가 위에서 알아본바와 같이 역직렬화 대상 POJO에 기본생성자를 생성하고 @Getter애노테이션를 붙여줘야 정상적으로 가져올 수 있다.

     

    (추가4) inner class는?

    직렬화/역직렬화 대상의 inner class가 있다면 잘 작동할까?

    결론만 말하자면 static class로 만들어줘야한다.

     

    inner class는 크게 anonymous, non-static, static 세 가지로 나뉜다. 

    inner class를 정의할 때 대개는 inner class안에 private접근자로 변수들을 선언한다. 이 때 컴파일러는 private 변수들을 초기화하기 위해 기본적으로 매개변수들을 갖고 있는 inner class 생성자를 만든다. 하지만 jackson은 inner class의 경우도 기본생성자를 이용하여 객체 매핑을 시도하기 때문에 기본 생성자가 없다면 non-static inner class는 매핑할 수 없다는 런타임에러가 마주하게 된다. 

     

    왜 기본생성자 없이 non-static 이너클래스를 만들면 안 되는지만 설명했는데, 더 자세한 내용은 아래 링크를 참고해보길 바란다.
    inner class까지 매핑하기 위한 쉬운 방법으로 inner class에 static을 붙여줘야 한다는 것만 기억하자.

    (자세한 내용은 https://dev.to/pavel_polivka/using-java-inner-classes-for-jackson-serialization-4ef8 를 참고해보길 바란다.)

     

     

    참고

    https://github.com/FasterXML/jackson-docs

    https://cloud.spring.io/spring-cloud-static/Finchley.SR4/multi/multi_spring-cloud-feign.html

    https://stackoverflow.com/questions/35853908/how-to-set-custom-jackson-objectmapper-with-spring-cloud-netflix-feign

    https://beaniejoy.tistory.com/75

    https://xxxelppa.tistory.com/331

    https://leejaedoo.github.io/@RestControllerAndJackson/

    http://honeymon.io/tech/2018/03/13/spring-boot-mvc-controller.html

    https://jaimemin.tistory.com/1823

    https://velog.io/@park2348190/Jackson-ObjectMapper%EC%97%90%EC%84%9C-%EA%B8%B0%EB%B3%B8-%EC%83%9D%EC%84%B1%EC%9E%90-%EC%97%86%EC%9D%B4-Deserialization-%ED%95%98%EA%B8%B0 https://joyykim.tistory.com/21.

    https://www.baeldung.com/jackson-exception

    https://incheol-jung.gitbook.io/docs/q-and-a/spring/enablewebmvc 

    https://www.baeldung.com/spring-httpmessageconverter-rest 

    https://dev.to/pavel_polivka/using-java-inner-classes-for-jackson-serialization-4ef8