본문 바로가기

🧑🏻‍💻 Dev/SpringBoot

JackSon DTO 역직렬화 파헤치기 (JackSon과 Getter)

🖥️ 사용한 기술 버전 확인


SpringBoot: 2.7.15

JackSon: 2.13.5

Lombok: 1.18.28

 

 

🤨 분석을 해보기로 한 이유


Controller에서 Json에서 DTO로 역직렬화를 할 때, @NoArgsConstructor + @Setter가 있으면 역직렬화가 되는지 알고 있었습니다.

JackSon의 내부 코드를 모두 까보지 않으면 모르지만, 개발하며 마주한 몇 가지 생각지 못한 케이스가 있어서 다뤄보려고 합니다.

 

추가적인 말이 없을 때까지는 아래의 HTTP 요청과 Controller를 사용했을 때의 결과입니다.

POST http://localhost:8080/test
Content-Type: application/json

{
  "testString": "테스트",
  "testInteger": 100
}
@PostMapping("/test")
public String test01(@RequestBody TestDTO dto) {
    System.out.println(">>> " + dto);

    return "ok";
}

 

 

1. @NoArgsConstructor


첫 번째는 비어있는 생성자만 있을 때입니다.

@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}

toString()은 값이 제대로 바인딩되었는지 콘솔에서 확인하기 위한 용도입니다.

[결과]
>>> testString: null, testInteger=null

바인딩이 제대로 되지 않았습니다.

 

 

 

2. @NoArgsConstructor + @Getter


두 번째는 기본 생성자에 Getter를 붙여봅니다.

@Getter
@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}
[결과]
>>> testString: 테스트, testInteger=100

바인딩이 제대로 되었습니다.

 

 

 

3. @NoArgsConstructor + @Setter


세 번째는 Setter와 생성자의 조합입니다.

@Setter
@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}
[결과]
>>> testString: 테스트, testInteger=100

바인딩이 제대로 되었습니다.

 

여기서 중간으로 확인해야 할 것이 있습니다. 기본 생성자만 있을 때는 바인딩이 안 됐었는데, Getter나 Setter가 있으면 바인딩을 해줍니다. 한 가지 특별 케이스를 만들어봅시다.

 

 

 

4. @NoArgsConstructor + 커스텀 Getter


만약 Getter의 기능을 하는 다른 이름의 메서드를 만들고 싶어 졌다고 해봅시다.

"나는 get이라는 걸 안 쓰고 find를 써서 새롭게 만들어볼래." <-- 똑같은 건 싫어 싫어 개발자

@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String findTestString() {
        return testString;
    }

    public Integer findTestInteger() {
        return testInteger;
    }

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}
[결과]
>>> testString: null, testInteger=null

???... Getter와 똑같은 기능을 하는 메서드를 만들었는데 데이터가 정상적으로 바인딩되지 않았다. 그럼 여기서 또 궁금한 건 못 참는 피곤한 사람(나)들은 get이라는 이름으로 커스텀 메서드를 만들어봅니다. ㅎ

@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String getTestString() {
        return testString;
    }

    public Integer getTestInteger() {
        return testInteger;
    }

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}
[결과]
>>> testString: 테스트, testInteger=100

!!!? 바인딩이 되었습니다. 더 궁금한걸 못 참는 분들은 이미 해보시고 있겠지만, Setter도 동일합니다. bindTestInteger() 이런 식으로 Setter를 만들면 바인딩이 제대로 되지 않습니다.

 

그럼 여기서 얻어볼 수 있는 첫 번째 결론은 Getter 또는 Setter가 필요한데, 이름이 get 어쩌고, set 어쩌고여야 한다는 것입니다.

 

이 궁금점은 JackSon 홈페이지에 가보면 알 수 있습니다. 영어를 잘하는 분들은 이런 노가다짓은 안 하고 계시겠죠..? 흑 하지만... 재밌는 걸 어떡하죠. 🤥

To read Java objects from JSON with Jackson properly, it is important to know how Jackson maps the fields of a JSON object to the fields of a Java object, so I will explain how Jackson does that.By default Jackson maps the fields of a JSON object to fields in a Java object by matching the names of the JSON field to the getter and setter methods in the Java object. Jackson removes the "get" and "set" part of the names of the getter and setter methods, and converts the first character of the remaining name to lowercase. For instance, the JSON field named brand matches the Java getter and setter methods called getBrand() and setBrand(). The JSON field named engineNumber would match the getter and setter named getEngineNumber() and setEngineNumber().If you need to match JSON object fields to Java object fields in a different way, you need to either use a custom serializer and deserializer, or use some of the many Jackson Annotations.

Jackson에서 필드를 바인딩할 때 getter, setter 메서드에서 get, set 부분을 제거하고 첫 문자를 소문자로 변경하는 방식으로 해당 필드가 있는지 확인한다고 합니다. 즉, 해당 변수를 찾을 때 변수명을 확인하는 게 아니고 getter와 setter를 보고 해당 필드가 있는지 확인을 하고, 매칭한다는 것을 의미합니다.

 

 

 

5. Getter 테스트 (제일 재밌는 부분 😆)


Jackson에서는 getter, setter에서 get, set 부분을 제거하고, 첫 문자를 소문자로 변경하는 방식으로 해당 필드를 찾는다고 했습니다.

궁금한 건 못 참는 두 번째 테스트를 해봤습니다.

 

5.1 get + 소문자 시작

@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public void gettestString() {
        return testString;
    }

    public void gettestInteger() {
        return testInteger;
    }

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}

CamelCase로 작성하기 때문에 이렇게는 안 적겠지만, 궁금해서 테스트해 봤습니다.

get을 제외하면 testString이고, 여기서 첫 문자를 소문자로 바꾸면 testString 그대로 이기 때문에 바인딩이 될 것이라고 예상했습니다.

[결과]
>>> testString: null, testInteger=null

바인딩이 정상적으로 되지 않았습니다.

 

 

5.2 get + 대문자 시작 + 소문자(두 번째 단어) (1)

이번에는 첫 문자는 CamelCase대로 대문자로 시작하고, 두 번째 단어를 소문자로 변경해 봤습니다.

@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String getTeststring() {
        return testString;
    }

    public Integer getTestinteger() {
        return testInteger;
    }

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}
[결과]
>>> testString: null, testInteger=null

역시나 바인딩이 되지 않았습니다. 위에 건 예상 못했는데, 이건 사실 예상을 했습니다.

JackSon에서 말하는 첫 문자를 소문자로 변경한다는 것이 어디까지를 말하는 것일지 궁금해서 더 테스트해 봤습니다.

 

 

5.3 get + 대문자 시작 + 소문자 시작(두 번째 단어) (2)

메서드 조건은 5.2와 동일하게 가져가고, 이번에는 변수 이름을 변경해 봤습니다.

@NoArgsConstructor
public static class TestDTO {
    private String teststring;
    private Integer testinteger;

    public String getTeststring() {
        return teststring;
    }

    public Integer getTestinteger() {
        return testinteger;
    }

    public String toString() {
        return "teststring: " + this.teststring + ", testinteger=" + this.testinteger;
    }
}
[결과]
>>> teststring: 테스트, testinteger=100

바인딩이 되었습니다. 음 이쯤 되면 조금 억지스러운 테스트를 한번 해보려고 합니다.

 

 

5.4 get + 대문자 여러 개 + 소문자

@NoArgsConstructor
public static class TestDTO {
    private String aBCdef;
    private Integer ghijk;

    public String getABCdef() {
        return aBCdef;
    }

    public Integer getGHIjk() {
        return ghijk;
    }

    public String toString() {
        return "aBCdef: " + this.aBCdef + ", ghijk=" + this.ghijk;
    }
}

저는 getABCdef()에서 get을 제외하면 ABCdef가 남고, 첫 문자를 소문자로 변경한다고 되어 있었습니다.

예상하는 건 "aBCdef라는 변수가 있으면 바인딩이 제대로 될 것이다."라는 가정을 하고 접근했습니다.

 

아래 있는 getGHIjk()는 get을 제외하면 GHIjk에서 처음으로 만나는 소문자 j 전까지를 한 문자로 본다고 생각하고, 적어봤습니다.

예상이 맞다면 ghijk라는 변수에 바인딩이 정상적으로 될 것입니다. 둘 중에 하나는... 맞겠지 🥺

 

[결과]
>>> aBCdef: null, ghijk=100

두두둥! 두 번째 예상이 맞은 거 같습니다. get을 제외하고, 그다음에 나오는 단어에서 첫 번째로 만나는 소문자 전까지의 대문자를 모두 소문자로 변경하는 것 같습니다.

 

 

5.5 진짜 정말 마지막 테스트

예상한 것이 맞는지 이해한 대로 마지막 테스트를 진행해 봅니다.

  1. getter에서 get을 제외합니다.
  2. 나머지 단어에서 첫 번째 소문자가 나오기 전까지의 대문자는 모두 소문자로 변경합니다.
@NoArgsConstructor
public static class TestDTO {
    private String abcdEFG;
    private Integer abcDEFG;

    public String getABcdEFG() {
        return abcdEFG;
    }

    public Integer getabcDEFG() {
        return abcDEFG;
    }

    public String toString() {
        return "abcdEFG: " + this.abcdEFG + ", abcDEFG=" + this.abcDEFG;
    }
}

첫 번째 변수를 제가 생각한 대로 변경해 보겠습니다. getABcdEFG에서 get을 제거하면 ABcdEFG가 됩니다.

여기서 처음으로 만나는 소문자(c) 전까지의 모든 대문자를 소문자로 변경하면 abcdEFG가 됩니다.

 

두 번째 변수를 제가 생각한 대로 변경해 보겠습니다. getabcDEFG에서 get을 제거하면 abcDEFG가 됩니다.

여기서 처음으로 만나는 소문자(a) 이전에는 대문자가 존재하지 않기 때문에 그대로 abcDEFG가 됩니다.

 

[결과]
>>> abcdEFG: 테스트, abcDEFG=100

 

정상적으로 바인딩이 되었습니다. 다음에는 @Builder, @AllArgsConstructor과 JackSon을 사용해 보고, 이번글에서 상세하게 정리해보지 못한 Setter 관련해서도 적어보려고 합니다. 시간이... 된다면?