여러분은 처음 코딩 공부를 할때 어떻게 시작을 하셨나요?
저는 node.js 를 공부할때도 그랬었고, 무언가를 시작할때 강의나 책을 보며 공부를 하는 스타일이 아닙니다.
기본적인건 docs 에서 읽고 어느정도 번듯하게(?) 만들어진 프로젝트를 가져와서 코드리딩+ 복사붙여넣기 하며 뭔가를 만들며 배우는 타입이죠... 코딩 커비 타입 입니다.
이렇게 코딩을 하다보면 결과물이 빨리나와 기분은 좋지만 치명적인 구멍이 종종 생기곤 합니다. 저는 jasckson 의 직렬/역직렬화 메커니즘을 모르고 사용을 했었는데, 오늘은 거기에서 겪였던 오류를 소개하고자 합니다.
@Data
@NoArgsConstructor
public class ChatRequest{
private Integer senderId;
private String content;
private String chatType;
}
@Data
@RequiredArgsConstructor
public class MenuOrderRequest{
public final Integer ordererId;
public final String menuName;
public final String menuType;
}
해당 Dto 는 동일하게 메뉴를 주문할때 보내는 요청입니다.
다른 점이라면 1번은 기본 생성자가 있고, 2번은 전체 필드를 받는 생성자가 있다는 점 입니다.
보통 Dto 는 엄격하게 관리되는 Entity 와 다르게 큰 제한을 두지 않고 @Data , 기본 생성자 퍼블릭, @RequiredArgsConstructor 등등 을 쓰는 편입니다.
저는 평소 1번 방법으로 사용하는데 어느날 같이 협업을 하는 동료가 2번 방식으로 pr 을 올려서 "뭔가 저렇게 해도 되나보군..." 하고 머지를 했는데! 제 로컬에서 pull 하니 오류가 나는 사태가 났습니다...
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Cannot construct instance of `너의Dto`
(no Creators, like default constructor, exist):
cannot deserialize from Object value (no delegate- or property-based Creator)
at `들어온 데이터`
들어온 데이터를 Dto 객체로 역직렬화 할 수 없다는 오류가 납니다.
분명 동료의 로컬에서는 굉장히 잘 돌아갔다는데...?
우선 기본 생성자가 없는 문제 인 것 같으니, 기본 생성자를 넣어 오류를 해결하였습니다.
그런데 동료 컴에서는 잘 되고 제 컴에는 안되는 마법같은 일이 너무 찝찝해서 jakson 의 원리를 알아보았습니다. (지금까지 모르고 써서 죄송합니다...)
우선 spring 에서 @RequestBody
로 들어온 데이터 형변환은 HttpMessageConverter가 하게 되고 그중 해당 컨텐트 타입이 json 형태이면 MappingJackson2HttpMessageConverter
가 형변환을 하게 됩니다. (우리가 사용하는 read() 함수는 AbstractJackson2HttpMessageConverter 에 있음)
해당 컨버터는 ObjectMapper 를 사용해서 데이터를 읽고, 쓰게 됩니다.
그렇기 때문에 밑에서는 ObjectMapper 의 직렬/역직렬화 메커니즘에 대해 설명하겠습니다.
@Data
@NoArgsConstructor
public class ChatRequest{
private Integer senderId;
private String content;
private String chatType;
}
1번의 경우 기본생성자 + getter + setter 의 조합으로 구성 되어 있습니다.
jackson 은 기본적으로 "기본 생성자" 를 통해 객체를 생성하는 방식으로 역직렬화 합니다.
데이터 바인딩
이후 JSON 필드의 이름을 Java 객체의 getter 및 setter 메소드와 일치시켜 JSON 객체의 필드를 Java 객체의 필드에 매핑합니다. Jackson은 getter 및 setter 메서드 이름의 "get" 및 "set" 부분을 제거하고 나머지 이름의 첫 번째 문자를 소문자로 변환하여 사용합니다.
ex)
Object { public String getName() { return this.name } }
-> getName()
을 인식 get 을 제거 한 후 Name 의 첫문자를 소문자로 변경해 key 값 사용 -> json {"name": "mimi"}
즉, 직접 필드에 접근하는 것이 아닌 getter 및 setter 메소드를 통해 필드 값을 유추해 매핑을 진행한다는 것 을 알 수 있습니다.
기본 생성자를 통해 인스턴스를 생성하고 setter 을 통해 필드값을 주입해 주는 줄 알았는데, setter 가 없어도 역직렬화가 잘 이루어집니다. 그렇다면 jackson 은 어떤식으로 데이터를 주입해 주는 걸까요?
간단히 말해 reflection 을 사용해 값을 주입하기 때문에 setter 는 애초에 필드 값을 인식하는 곳에만 사용한다는 것 입니다. 즉, getter 가 있다면 setter 는 굳이 존재하지 않아도 됩니다.
Tip.
그리고 reflection 을 사용하면 기본 생성자 접근 제한자가 protected 이상이기만 하면 되기때문에 타이트한 관리를 위해 접근제한자를 protected 로 바꾸어도 좋습니다
굉장히 자세한 원리가 적힌 블로그
해당 조합에서는 밑 방식으로 직렬/역직렬화를 하는 것을 알 수 있습니다.
@Data
@RequiredArgsConstructor
public class MenuOrderRequest{
public final Integer ordererId;
public final String menuName;
public final String menuType;
}
위에서 jackson 은 기본적으로 "기본 생성자" 를 통해 객체를 생성하는 방식으로 역직렬화를 한다고 했습니다. 그럼 만약 기본생성자가 없다면 어떻게 되는 걸까요? (물론 저는 오류가 났습니다)
해당 사진은 기본 생성자가 없을때 호출 되는 함수입니다.
기본 생성자가 없다면 jackson 은 delegateSerializer
를 사용합니다. (제공된 property 사용 한다는 뜻)
delegate? 일반적으로 한 객체 또는 메서드가 일부 책임 또는 작업을 다른 객체 또는 메서드에게 위임하는 것을 의미.
public class Book { private String title; private String author; @JsonCreator public Book(@JsonProperty("name") String title, @JsonProperty("writer") String author) { this.title = title; this.author = author; } // getters and setters }
위에서 우리는 delegate를 설정한 적도 없고, inner class 도 아니니 맨 마지막 오류 메세지가 떴던 겁니다.
스프링 부트 2.x 를 이후부터는 ObjectMapper 에 jackson-module-parameter-names 가 자동으로 등록 되어 있는 걸 볼 수 있습니다.
jackson-module-parameter-names는 기본 생성자 없이도 인식한 필드가 들어갈만한 "파라미터가 있는 생성자" 가 있으면 해당 생성자로 역직렬화를 바로 진행 합니다. 우리가 @JsonCreator 로 delegate를 설정한 것 처럼 말입니다.
내용을 요약하자면, Jackson Modules: Java 8 부터는 JsonProperty 없이도 알아서 바인딩 잘 해준다는 뜻입니다.
대신 조건이 있음 (이게 진짜 개중요;;; 모르면 삽질 합니다)
파라미터가 있는 생성자가 여러개거나, 파라미터가 1개인 생성자면 여전히 어노테이션을 명시해 줘야한다는 것입니다.
해당 속성은 jackson-module-parameter-names 으로 자동 매핑할때 쓰는 속성인데 저기 단일 인자면 작동 안된다고 써있답니다. jackson3에서 고쳐질 수도 있다는 소문이... 소문의 근원
맞습니다... 사실 되어야 합니다!!!!!
저걸 다 알아보고도 안돼서 기묘해서 아예 레포를 싹 지우고 다시 클론하니 되었습니다...
저는 뭔가 내부적으로 모듈이 꼬인건지 그레들을 새로 로딩 하니 되더라구요...ㅠㅠ 하 내 시간...
만약 안되시는 분은 삭깔 한번 해보세요...!
해당 조합에서는 밑 방식으로 직렬/역직렬화를 하는 것을 알 수 있습니다.
ref
파라미터가 1개인 경우 자동 매핑 실패 스택오버플로우
https://beaniejoy.tistory.com/76
https://velog.io/@conatuseus/RequestBody에-왜-기본-생정자는-필요하고-Setter는-필요-없을까-2-ejk5siejhh
https://velog.io/@conatuseus/RequestBody에-기본-생성자는-왜-필요한가
https://jenkov.com/tutorials/java-json/jackson-objectmapper.html#jackson-databind
https://github.com/FasterXML/jackson-modules-java8/tree/master/parameter-names
포스팅 잘 읽었습니다...!
제가 알기론 @Data에 @RequiredArgsConstructor가 들어가있는걸로 아는데
그러면 되도록 1번처럼 @Data + @NoArgsConstructor도 필수로 다는게 좋을까요?