문제상황
{
"seq": "01",
"postDate": "20230524",
"mCodeName": "어쩌고"
}
위와 같은 데이터(예시)를 @RequestBody로 요청을 받았는데 DTO에 자꾸 null 값이 들어오는 것을 확인했다.
아래는 DTO
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TistoryDTO {
String seq;
String postDate;
String mCodeName;
}
문제가 되는 파라미터는 mCodeName이다. 이 데이터가 자꾸 null로 들어와서 날 화나게 만듦;; null 안 보냈다고 ㅡㅡ
반대로 쿼리에서 M_CODE_NAME이라는 컬럼을 select 해 왔을 때도 제대로 맵핑이 안됐다. 카멜 케이스 지켰다고 ㅡㅡ
원인 1. Jackson
Springboot에서 JSON 데이터를 자바 객체(DTO)로 변환하거나 객체를 JSON으로 변환하는 데 사용되는 라이브러리리다. Jackson은 스프링부트에서 기본 JSON 라이브러리로 사용되며, ObjectMapper라는 클래스를 사용하여 데이터 변환해서 맵핑해준다.
- 직렬화 Serialization
자바 객체(Object)를 JSON으로 변환하는 과정이다.
ObjectMapper 클래스의 writeValueAsString() 메서드를 사용하여 자바 객체를 JSON 문자열로 변환한다. 다음, 객체의 필드 또는 속성값을 가져와서 해당 값을 JSON 키와 맵핑하여 JSON 객체를 생성하고 이를 문자열로 반환시킨다. - 역직렬화 Deserialization
JSON 데이터를 자바 객체(Object)로 변환하는 과정이다.
ObjectMapper 클래스의 readValue() 메서드를 사용하여 JSON 데이터를 객체로 변환한다. Jackson은 JSON 데이터의 키와 객체 필드를 맵핑하여 해당 값을 자바 객체에 설정한 후 변환된 객체를 반환한다.
이 직렬화/역직렬화 과정에서 Key 값을 맵핑하는 규칙이 있는데 이게 중요하다.
1.1 JavaBeans 규약
Jackson이 JSON key로 변환할 때 JavaBeans 규약을 따른다.
자바빈은 Java 프로그래밍에서 소프트웨어 컴포넌트(빈)를 개발하기 위한 설계 패턴 중 하나이며 JavaBeans 규약이란 자바빈을 사용하기 위한 규칙과 규약의 집합이다.
규약은 다음과 같은 규칙을 포함한다.
- 속성 값(멤버 변수)는 private 속성을 가진다.
- 속성 값에 접근하기 위한 public 접근자 getter/setter 메서드를 제공해야 한다.
- 매개변수가 없는 기본 생성자(Default Constructor)를 반드시 제공해야 한다. 이를 통해 다른 컴포넌트에서 JavaBeans를 인스턴스화 할 수 있다.
- java.io.Serializable 인터페이스를 구현하여 직렬화가 가능해야 한다.
- 관례적 네이밍; 이름은 카멜케이스로 작성되며 첫글자는 소문자로 시작한다.
여기서 이제 문제가 되는 규약이 있다.
클래스의 이름은 일반적으로 대문자로 시작하지만, 개발자들은 식별자가 소문자로 시작하는 것에 익숙하기 떄문에 첫번째 글자를 소문자로 변환한다. 다만 모든 문자를 대문자로 사용하는 경우도 있기 때문에 이 경우는 예외이다. 예외 케이스를 판별하기 위해 첫 두글자가 모두 대문자인지를 확인한다. (출처: https://stackoverflow.com/questions/2948083/naming-convention-for-getters-setters-in-java)
Jackson이 JSON key로 변환할 때 기본적으로 이 JavaBeans 규약을 따르지만 다른 부분도 있다.
1.2 JavaBeans 규약과 다른 Jackson 규칙
- 맨 앞 두글자가 모두 대문자일 때 이어진 대문자를 모두 소문자로 바꾼다.
- 나머지 모든 케이스는 맨 앞글자만 소문자로 바꿔준다.
저 규칙 중 첫번째 규칙이 다르다. 자바빈 규약에서는 첫 두글자가 모두 대문자면 예외로 두지만 Jackson에서는 첫 두글자가 대문자면 이어진 대문자들은 모두 소문자로 바꿔버린다.
- AAaa → aaaa : 앞 두 글자가 대문자라서 소문자로 변경
- CCcC → cccC : 앞 두글자가 대문자라서 이어진 대문자를 모두 소문자로 변경
Jackson은 Getter 를 기준으로 이름을 변경한다. 예시 필드와 Getter를 아래와 같이 설정해보자.
@NoArgsConstructor
public class ExDTO {
private String aaaa;
private String bbbB;
private String CccC;
private String dDdd;
public String getAaaa() {
return aaaa;
}
public String getBbbB() {
return bbbB;
}
public String getCccC() {
return CccC;
}
public String getdDdd() {
return dDdd;
}
}
다음과 같은 컨트롤러 요청을 줘보자.
{
"aaaa": "a",
"bbbB": "b",
"CccC": "c",
"dDdd": "d"
}
그렇다면 이제 JSON 변환 결과값을 예상해보면
- getAaaa() : 첫글자가 대문자이므로 소문자로 바뀌어서 → aaaa
- getBbbB() : 역시 첫글자만 대문자이므로 → bbbB
- getCccC() : BbbB와 마찬가지이므로 → cccC
- getdDdd() : 그대로 사용 → dDdd
예상되는 JSON key값과 요청온 DTO 필드 값 중 CccC만 다르다. (cccC != CccC)
{
"aaaa": "a",
"bbbB": "b",
"cccC": null,
"dDdd": "d",
}
cccC에만 null 이 들어오는 것을 확인할 수 있다.
1.3 Jackson 결론
DTO 필드명이 대문자로 시작하면 Request 요청시 값이 제대로 들어오지 않는다. 필드명이 대문자로 시작하면 Getter도 대문자로 시작하는 수 밖에 없다. Jackson 규칙에 의해 get 이후 대문자로 시작하면 첫글자가 항상 소문자로 바뀐다.
따라서 대문자로 시작하는 필드명과 일치하지 않아서 생기는 현상이다.
원인 2. Lombok
그렇다면 Lombok은 무슨 상관인가
대부분의 많은 개발자들이 개발 편의를 위해 Lombok 어노테이션을 많이 사용한다. 나역시도 그렇다.
이 중 @Getter 어노테이션 사용은 거의 필수적으로 사용된다. (나는 여기서 @Getter가 포함된 @Data를 씀)
2.1 @Getter 생성 규칙
필드명의 첫글자를 대문자로 바꿔서 get 이후로 붙여서 메서드를 생성한다.
나에게 문제가 되었던 mCodeName 이라는 필드는 @Getter에 의해서 getMCodeName() 이라는 get 메서드가 생성되는 것이다.
여기서 이제 Jackson의 JSON 변환에 의해서
getMCodeName() => mcodeName key 값으로 변환되는 것이다.
즉, 변환된 제이슨 키값과 필드명이 불일치해서 생긴 문제였다ㅜ ( mCodeName != mcodeName )
해결 방법
1. @JsonProperty 어노테이션 사용
@JsonProperty("mCodeName")
String mCodeName;
2. Getter/Setter 직접 생성
롬복 @Getter 또는 (@Getter가 포함된) @Data 사용 대신 직접 수동으로 Getter를 만들면 된다.
public String getmCodeName() {
return this.mCodeName;
}
3. 이름 잘 짓기 ⭐
애초부터 필드명이 맨 첫글자만 소문자인 상황을 만들지 말자
mCodeName <-- 'm'이 뭐지? 줄여쓰지 말고 풀어쓰자. (예시 파라미터명이긴 하지만 실제 데이터 명도 별로 예쁘지 않음ㅎㅎ)
클린코드가 이래서 중요함ㅎ 이름을 잘 지으면 이런 일을 겪을 일이 없다 ㅠ
4. 포기하고 소문자로 쓰기
나처럼 이미 DB 컬럼이 정해져 있어 이름 바꾸기도 애매하거나, 저 위에 1,2번 방법도 귀찮다 하면 구냥 받아들이고 뒷글자도 소문자로 쓰자!!
mcodeName <-- 보기싫지만 오류는 안납니다ㅎㅎ
참고