Overview
키즈핑 프로젝트를 진행하면서 REST API를 테스트하다가 이상한 이슈에 직면했습니다.
컨트롤러 단에서 @RequestBody를 사용해 클라이언트의 요청 값을 받기 위한 DTO 클래스를 만들었습니다. 포스트맨으로 테스트를 해서 제가 만든 API로 요청을 보냈는데 DTO 클래스의 필드에 값이 안 들어오는 겁니다.
아래는 제가 만든 컨트롤러와 DTO 클래스입니다.
컨트롤러
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/kid")
public class KidController {
...
@PostMapping("/mbti")
public void diagnoseKidMBTI(@RequestBody KidMBTIDRequest diagnosisRequest) {
int a = diagnosisRequest.getUserId();
System.out.println(a);
}
}
DTO
@Getter
@Setter
public class KidMBTIDRequest {
private int userId;
private int eScore;
private int iScore;
private int sScore;
private int nScore;
private int tScore;
private int fScore;
private int jScore;
private int pScore;
}
그리고 아래와 같이 포스트맨을 통해 데이터를 서버로 전송했습니다.
하지만 실제 DB에는 포스트맨에서 보낸 데이터가 아닌 자꾸 값이 0으로 들어갔습니다.


그래서 디버깅 모드를 통해 실제 객체에 어떤 값이 들어오는지 확인을 했는데 아래 이미지와 같이 userId를 제외한 다른 필드들은 기본값들이 들어오고 있었습니다.

처음에는 오타가 있거나 잘못 만든 건 줄 알았는데 변수명을 바꾸니 잘 동작했습니다.
예를 들어, DTO 클래스의 변수명들을 아래와 같이 수정했습니다.
@Getter
@Setter
public class KidMBTIDRequest {
private int userId;
private int extraversionScore; // Extraversion 점수
private int introversionScore; // Introversion 점수
private int sensingScore; // Sensing 점수
private int intuitionScore; // Intuition 점수
private int thinkingScore; // Thinking 점수
private int feelingScore; // Feeling 점수
private int judgingScore; // Judging 점수
private int perceivingScore; // Perceiving 점수
}
그리고 다시 포스트맨을 통해 서버로 값을 보냈고 디버깅 모드로 확인한 결과 값이 제대로 들어옴을 확인했습니다.


그리고 실제 DB도 확인하니 값이 잘 저장됨을 확인했습니다.

필드명만 바꿨는데 하나는 값이 제대로 안 들어오고 하나는 값이 제대로 들어왔습니다.
예를 들어 변수명이 eScore 일 때는 동작하지 않았는데 extraversionScore로 바꾸니 제대로 값이 들어왔습니다.
그래서 자료 조사를 하면서 Jackson, Lombok에 대해서 알게 된 사실을 정리합니다.
1. Jackson
Spring은 JSON 데이터를 매핑하기 위한 Message Converter로 Jackson을 사용합니다.
(Http Message Converters with the Spring Framework - Baeldung 참고)
위에서 제시한 문제의 원인은 Lombok이었지만 Jackson의 JsonMessageConverter 의 동작에도 원인이 숨겨져 있습니다. 이를 확인하기 위해서는 Jackson 의 DTO <-> Json 과정이 어떻게 이루어지는지 먼저 파악이 필요합니다.
1.1. Jackson 은 Getter의 이름을 기반으로 Json Key 값을 만든다
Jackson 에는 한 가지 재미있는 사실이 있습니다.
Object -> Json으로 변환하면 Json의 키가 해당 Object 의 필드명을 기준으로 될 거라고 생각했는데 사실 Getter의 이름 기준으로 바뀝니다. 아래 예시 코드를 보겠습니다.
public class JacksonDtoTest {
private String name;
public String getNameChange() {
return name;
}
}
- 필드명은 name 이지만 Getter 이름은 getNameChange() 입니다.
public class DtoTest {
private static final ObjectMapper objectMapper = new ObjectMapper();
@Test
void test_jackson_dto() throws Exception {
JacksonDtoTest jacksonDto = new JacksonDtoTest("my name");
String content = objectMapper.writeValueAsString(jacksonDto);
// 출력 = Jackson : {"nameChange":"my name"}
System.out.println("Jackson : " + content);
}
}
- 출력을 해보면 Jackson : {"nameChange":"my name"} 이렇게 출력이 됩니다.
- 즉 필드명 대신 Getter의 이름인 nameChange가 Json Key로 설정되었습니다.
- 그동안 Getter의 이름은 필드명과 동일하게 지어와서 지금까지 눈치채지 못했습니다.
1.2. Jackson 이 Json Key 이름을 변환하는 데는 일정한 규칙이 있다
Object의 필드명을 Getter로 바꿀 때 일반적으로 맨 앞 글자를 대문자로 바꿔줍니다.
ex) name -> getName()
Jackson 은 Getter를 기준으로 변환시키기 때문에 Jackson 내부적으로도 나름의 기준을 갖고 변환합니다.
기본적으로는 JavaBeans 규약을 따르지만 다른 부분이 있었습니다.
먼저 JavaBeans 규약을 먼저 알아봅니다.
2. JavaBeans 규약
JavaBeans는 메서드 이름에서 필드명을 추출할 때 일정한 규칙이 존재합니다.
stack overflow의 Naming convention for getters/setters in Java의 답변을 보면 Java Bean 규약을 첨부한 답변이 있습니다.
여기서 8.8 Capitalization of inferred names 챕터를 보면 아래와 같습니다.
When we use design patterns to infer a property or event name, we need to decide what rules to follow for capitalizing the inferred name.
If we extract the name from the middle of a normal mixedCase style Java name then the name will, by default, begin with a capital letter.
Java programmers are accustomed to having normal identifiers start with lower case letters.
Vigorous reviewer input has convinced us that we should follow this same conventional rule for property and event names.Thus when we extract a property or event name from the middle of an existing Java name, we normally convert the first character to lower case.
However to support the occasional use of all upper-case names, we check if the first two characters of the name are both upper case and if
so leave it alone. So for example,“FooBah” becomes “fooBah”
“Z” becomes “z”
“URL” becomes “URL”We provide a method Introspector.decapitalize which implements this conversion rule.
간단히 요약하면 클래스의 이름은 일반적으로 대문자로 시작하지만, 개발자들은 식별자가 소문자로 시작하는 것에 익숙하기 때문에 첫 번째 글자를 소문자로 변환한다는 겁니다. 다만, 모든 문자를 대문자로 사용하는 경우도 있기 때문에 이런 경우는 예외로 둔다고 합니다. 그리고 예외 케이스를 판별하기 위해 첫 두 문자가 모두 대문자인지를 확인합니다. 그리고 java.beans 패키지에 있는 Introspector 클래스를 확인해 보면 실제로 어떤 로직이 들어가 있는지 알 수 있습니다.
public class Introspector {
// ...
public static String decapitalize(String name) {
if (name == null || name.length() == 0) {
return name;
}
if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) &&
Character.isUpperCase(name.charAt(0))){
return name;
}
char chars[] = name.toCharArray();
chars[0] = Character.toLowerCase(chars[0]);
return new String(chars);
}
// ...
}
- 맨 앞 두 개가 전부 대문자라면 그대로 리턴하고 아니라면 맨 앞 문자 하나만 소문자로 바꿔서 리턴합니다.
3. 그렇다면 Jackson에서는?
Jackson 도 JavaBeans 규약을 따르지만 다른 점이 하나 있습니다.
테스트로 알아본 Jackson의 규칙은 다음과 같습니다.
- 맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.
- 나머지 모든 케이스에서는 맨 앞 글자만 소문자로 바꿔준다.
JavaBeans 규약과 다른 부분은 1번입니다.
JavaBeans 규약에서는 앞 두 글자가 대문자인 경우 그대로 사용한다고 했으나 Jackson 은 맨 앞부터 이어진 대문자를 모두 소문자로 변경합니다. 예제를 통해서 확인해 보겠습니다.
3.1. 맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.
사실 JavaBeans 규약과 다른 게 이 부분입니다.
Jackson에서는 맨 앞 두 글자가 대문자라면 이어진 모든 대문자를 소문자로 변경합니다.
- AAaa -> aaaa : 앞 두 글자가 대문자라서 소문자로 변경
- BBBb -> bbbb : 앞 두 글자가 대문자라서 이어진 세 번째 문자까지 소문자로 변경
- CCcC -> cccC : 앞 두 글자를 소문자로 변경하지만 맨 뒤의 대문자는 이어져 있지 않아서 그대로 사용
- DDDD -> dddd : 앞 두 글자부터 이어진 대문자를 모두 소문자로 변경
DTO 정의
@ToString
@NoArgsConstructor
public class OneDto {
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;
}
}
controller 정의
@RestController
public class HelloController {
@PostMapping("/one")
public ResponseEntity<OneDto> postOne(@RequestBody OneDto dto) {
System.out.println("----- Request POST /one ------");
System.out.println(dto);
return ResponseEntity.ok(dto);
}
}
- 실제로 요청이 왔을 때 값이 어떻게 들어오는지 확인합니다.
- 받은 @RequestBody 값을 그대로 다시 Response로 내려줍니다.
Request
POST http://localhost:8080/one
Content-Type: application/json
{
"AAaa": "a",
"BBBb": "b",
"CCcC": "c",
"DDDD": "d"
}
- IntelliJ에서 제공하는 http request tool을 사용했습니다.
Log
----- Request POST /one ------
OneDto(AAaa=null, BBBb=null, CCcC=null, DDDD=null)
- Controller에서 찍어둔 print입니다.
- 값이 전부 null로 들어옵니다.
Response
{
"aaaa": null,
"bbbb": null,
"cccC": null,
"dddd": null
}
- 예측한 대로 나오는 걸 확인할 수 있습니다.
- 요청으로 들어온 OneDto 값을 그대로 리턴했을 뿐인데 Message Converter에 의해 요청값과 응답값의 Json Key 값이 바뀌었습니다.
3.2. 맨 앞 두 글자가 대문자가 아니면 맨 앞 글자만 소문자로 바꿔준다
이거는 그냥 단순하게 1번을 제외한 모든 케이스에서는 맨 앞글자만 소문자로 바꿔줍니다.
뒤에 오는 대문자나 소문자는 신경 쓰지 않습니다.
DTO 정의
@NoArgsConstructor
public class TwoDto {
private String aaaa;
private String bbbB;
private String Cccc;
private String DddD;
private String eEee;
private String fFfF;
public String getAaaa() {
return aaaa;
}
public String getBbbB() {
return bbbB;
}
public String getCccc() {
return Cccc;
}
public String getDddD() {
return DddD;
}
public String geteEee() {
return eEee;
}
public String getfFfF() {
return fFfF;
}
}
- DTO를 정의하고 Controller 코드는 OneDto 와 동일하게 실행합니다.
Request
POST http://localhost:8080/two
Content-Type: application/json
{
"aaaa": "a",
"bbbB": "b",
"Cccc": "c",
"DddD": "d",
"eEee": "e",
"fFfF": "f"
}
Log
----- Request POST /two ------
TwoDto(aaaa=a, bbbB=b, Cccc=null, DddD=null, eEee=e, fFfF=f)
- Cccc, DddD 를 제외한 나머지는 전부 값이 제대로 들어옵니다.
Response
{
"aaaa": "a",
"bbbB": "b",
"cccc": null,
"dddD": null,
"eEee": "e",
"fFfF": "f"
}
- 예측한 대로 잘 나옵니다.
- 맨 앞 글자가 대문자였던 Cccc 와 DddD 만 바뀌고 나머지는 그대로입니다.
- 중요하게 볼 점은 TwoDto 의 필드명과 달라진 애들은 값이 제대로 들어오지 않는다는 사실입니다.
3.3. Jackson 결론
우리는 지금까지의 테스트를 통해서 한 가지 사실을 알았습니다.
DTO의 필드명이 대문자로 시작하면 Request 요청 시 값이 제대로 들어오지 않습니다.
필드명이 대문자로 시작하면 Getter 도 대문자로 시작합니다.
Jackson의 규칙에 따라서 get 이후가 대문자로 시작하면 최소한 첫 글자는 항상 소문자로 바뀝니다.
따라서 필드명과 일치하지 않아 데이터가 들어가지 않는 현상입니다.
필드명을 대문자로 시작하는 경우는 많이 없지만 URL 처럼 모두 대문자로 사용했다가 안될 가능성도 있습니다.
4. Lombok 은 무슨 관계일까?
Lombok 은 개발자들이 일일이 만들어야 하는 반복적인 코드를 줄일 수 있게 도와주는 라이브러리입니다.
그중에서도@Getter 어노테이션은 거의 모든 Object에 필수적으로 사용됩니다.
제가 이슈를 겪었던 DTO 오브젝트도 롬복을 사용했습니다.
그렇다면 롬복의 문제점은 무엇일까요?
4.1. Lombok의 Getter 생성 규칙
Lombok의@Getter 어노테이션을 붙이면 클래스의 Getter 메서드를 자동으로 생성해 줍니다.
그런데 @Getter 의 생성 규칙은 굉장히 단순합니다.
get 다음에 무조건 필드명의 맨 앞 글자를 대문자로 바꿔서 만들어줍니다.
lombok의 Github Issue 에도 이 내용에 대한 문의가 있습니다.
제가 문제를 겪었던 필드명도 eScore, iScore.. 부분이었습니다.였습니다.
Lombok이 getEScore로 생성해 주고 Jackson을 거치니 escore가 되어서 필드명이 일치하지 않아 문제가 발생했었습니다. 반면eeScore는 getEeScore가 되고 Jackson을 거쳐도 eeScore가 되어서 정상적으로 값이 들어오죠.
4.2. 인텔리제이 Generator의 Getter 생성 규칙
public class ScoreDto {
private int eScore;
public int geteScore() {
return eScore;
}
}
Lombok 대신 인텔리제이에서 제공하는 제네레이터로 Getter를 만들면 위 이슈를 회피할 수 있습니다.
getEScore 대신에geteScore로 만들어주기 때문에 Jackson을 거쳐도eScore라는 필드명과 일치합니다.
더 자세히 키즈핑에서는 어떻게 Lombok이 작동하여 문제였는지 살펴보겠습니다.
Controller
/*
자녀 성향 진단
*/
@PostMapping("/mbti/diagnosis")
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
public ResponseEntity<KidMbtiDiagnosisRequest> diagnoseKidMbti(
@RequestBody KidMbtiDiagnosisRequest diagnosisRequest) {
log.info("getEScore {}", diagnosisRequest.getEScore());
log.info("getFScore {}", diagnosisRequest.getFScore());
log.info("getJScore {}", diagnosisRequest.getJScore());
return ResponseEntity.ok(diagnosisRequest);
//kidService.diagnoseKidMbti(diagnosisRequest);
}
DTO
@Getter
@Setter
public class KidMbtiDiagnosisRequest {
private Long userId;
private int eScore;
private int iScore;
private int sScore;
private int nScore;
private int tScore;
private int fScore;
private int jScore;
private int pScore;
...
}
Request
아래와 같이 값을 전송하겠습니다.

Log
실제 로그를 찍어보면 DTO 객체에 제대로 값이 들어오지 않음을 알 수 있습니다.

Response
그래서 DTO 객체를 그대로 반환을 하면 필드들이 바뀐 걸 알 수 있습니다.

위 상황을 요약하면 eScore 필드의 getter가 룸북으로 인해 getEScore()로 생성이 됩니다.
그러면 앞서 설명한 Jackson에서의 규약으로 Json의 키가 escore가 됩니다.
그래서 DTO 객체의 eScore 필드와 Json의 키인 escore가 서로 이름이 달라 매핑이 되지 않아 값이 들어오지 않았던 거였습니다.
Conclusion
지금까지 정리한 내용을 요약하면 아래와 같습니다.
- Spring의 Json Message Converter는 Jackson 라이브러리를 사용
- lombok의 Getter는 필드명 맨 앞을 항상 대문자로 만듦
- Jackson 라이브러리는 Getter의 맨 앞 두 글자가 전부 대문자인 경우 필드명과 Json key 값이 달라짐
- eScore라는 필드명을 lombok을 사용해서 Getter를 만들면 getEScore() 가 되기 때문에 이슈가 발생
위 문제를 해결하려면 필드명을 작성할 때 첫 번째는 소문자, 두 번째는 대문자인 케이스로 만들지 않으면 됩니다.
그래도 꼭 사용해야 한다면 lombok의@Getter 대신 직접 Getter를 만들거나, @JsonProperty 를 사용하면 됩니다.
참고
'프로젝트 > Kidsping' 카테고리의 다른 글
개발 기록 - 선착순 응모 시스템 이슈 및 해결 과정 (0) | 2024.10.30 |
---|---|
트러블 슈팅 - AWS 프리티어 EC2 인스턴스 메모리 부족 현상 해결하기 (0) | 2024.10.25 |
개발 기록 - N + 1 문제 fetch join, Batch Size로 해결 (0) | 2024.10.24 |
개발 기록 - Java에서 Enum 의 비교는 '==' 인가? 'equals' 인가? (0) | 2024.10.22 |
개발 기록 - 자녀 성향 진단 로직 구현 및 리팩터링 (0) | 2024.10.22 |
Overview
키즈핑 프로젝트를 진행하면서 REST API를 테스트하다가 이상한 이슈에 직면했습니다.
컨트롤러 단에서 @RequestBody를 사용해 클라이언트의 요청 값을 받기 위한 DTO 클래스를 만들었습니다. 포스트맨으로 테스트를 해서 제가 만든 API로 요청을 보냈는데 DTO 클래스의 필드에 값이 안 들어오는 겁니다.
아래는 제가 만든 컨트롤러와 DTO 클래스입니다.
컨트롤러
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/kid")
public class KidController {
...
@PostMapping("/mbti")
public void diagnoseKidMBTI(@RequestBody KidMBTIDRequest diagnosisRequest) {
int a = diagnosisRequest.getUserId();
System.out.println(a);
}
}
DTO
@Getter
@Setter
public class KidMBTIDRequest {
private int userId;
private int eScore;
private int iScore;
private int sScore;
private int nScore;
private int tScore;
private int fScore;
private int jScore;
private int pScore;
}
그리고 아래와 같이 포스트맨을 통해 데이터를 서버로 전송했습니다.
하지만 실제 DB에는 포스트맨에서 보낸 데이터가 아닌 자꾸 값이 0으로 들어갔습니다.


그래서 디버깅 모드를 통해 실제 객체에 어떤 값이 들어오는지 확인을 했는데 아래 이미지와 같이 userId를 제외한 다른 필드들은 기본값들이 들어오고 있었습니다.

처음에는 오타가 있거나 잘못 만든 건 줄 알았는데 변수명을 바꾸니 잘 동작했습니다.
예를 들어, DTO 클래스의 변수명들을 아래와 같이 수정했습니다.
@Getter
@Setter
public class KidMBTIDRequest {
private int userId;
private int extraversionScore; // Extraversion 점수
private int introversionScore; // Introversion 점수
private int sensingScore; // Sensing 점수
private int intuitionScore; // Intuition 점수
private int thinkingScore; // Thinking 점수
private int feelingScore; // Feeling 점수
private int judgingScore; // Judging 점수
private int perceivingScore; // Perceiving 점수
}
그리고 다시 포스트맨을 통해 서버로 값을 보냈고 디버깅 모드로 확인한 결과 값이 제대로 들어옴을 확인했습니다.


그리고 실제 DB도 확인하니 값이 잘 저장됨을 확인했습니다.

필드명만 바꿨는데 하나는 값이 제대로 안 들어오고 하나는 값이 제대로 들어왔습니다.
예를 들어 변수명이 eScore 일 때는 동작하지 않았는데 extraversionScore로 바꾸니 제대로 값이 들어왔습니다.
그래서 자료 조사를 하면서 Jackson, Lombok에 대해서 알게 된 사실을 정리합니다.
1. Jackson
Spring은 JSON 데이터를 매핑하기 위한 Message Converter로 Jackson을 사용합니다.
(Http Message Converters with the Spring Framework - Baeldung 참고)
위에서 제시한 문제의 원인은 Lombok이었지만 Jackson의 JsonMessageConverter 의 동작에도 원인이 숨겨져 있습니다. 이를 확인하기 위해서는 Jackson 의 DTO <-> Json 과정이 어떻게 이루어지는지 먼저 파악이 필요합니다.
1.1. Jackson 은 Getter의 이름을 기반으로 Json Key 값을 만든다
Jackson 에는 한 가지 재미있는 사실이 있습니다.
Object -> Json으로 변환하면 Json의 키가 해당 Object 의 필드명을 기준으로 될 거라고 생각했는데 사실 Getter의 이름 기준으로 바뀝니다. 아래 예시 코드를 보겠습니다.
public class JacksonDtoTest {
private String name;
public String getNameChange() {
return name;
}
}
- 필드명은 name 이지만 Getter 이름은 getNameChange() 입니다.
public class DtoTest {
private static final ObjectMapper objectMapper = new ObjectMapper();
@Test
void test_jackson_dto() throws Exception {
JacksonDtoTest jacksonDto = new JacksonDtoTest("my name");
String content = objectMapper.writeValueAsString(jacksonDto);
// 출력 = Jackson : {"nameChange":"my name"}
System.out.println("Jackson : " + content);
}
}
- 출력을 해보면 Jackson : {"nameChange":"my name"} 이렇게 출력이 됩니다.
- 즉 필드명 대신 Getter의 이름인 nameChange가 Json Key로 설정되었습니다.
- 그동안 Getter의 이름은 필드명과 동일하게 지어와서 지금까지 눈치채지 못했습니다.
1.2. Jackson 이 Json Key 이름을 변환하는 데는 일정한 규칙이 있다
Object의 필드명을 Getter로 바꿀 때 일반적으로 맨 앞 글자를 대문자로 바꿔줍니다.
ex) name -> getName()
Jackson 은 Getter를 기준으로 변환시키기 때문에 Jackson 내부적으로도 나름의 기준을 갖고 변환합니다.
기본적으로는 JavaBeans 규약을 따르지만 다른 부분이 있었습니다.
먼저 JavaBeans 규약을 먼저 알아봅니다.
2. JavaBeans 규약
JavaBeans는 메서드 이름에서 필드명을 추출할 때 일정한 규칙이 존재합니다.
stack overflow의 Naming convention for getters/setters in Java의 답변을 보면 Java Bean 규약을 첨부한 답변이 있습니다.
여기서 8.8 Capitalization of inferred names 챕터를 보면 아래와 같습니다.
When we use design patterns to infer a property or event name, we need to decide what rules to follow for capitalizing the inferred name.
If we extract the name from the middle of a normal mixedCase style Java name then the name will, by default, begin with a capital letter.
Java programmers are accustomed to having normal identifiers start with lower case letters.
Vigorous reviewer input has convinced us that we should follow this same conventional rule for property and event names.Thus when we extract a property or event name from the middle of an existing Java name, we normally convert the first character to lower case.
However to support the occasional use of all upper-case names, we check if the first two characters of the name are both upper case and if
so leave it alone. So for example,“FooBah” becomes “fooBah”
“Z” becomes “z”
“URL” becomes “URL”We provide a method Introspector.decapitalize which implements this conversion rule.
간단히 요약하면 클래스의 이름은 일반적으로 대문자로 시작하지만, 개발자들은 식별자가 소문자로 시작하는 것에 익숙하기 때문에 첫 번째 글자를 소문자로 변환한다는 겁니다. 다만, 모든 문자를 대문자로 사용하는 경우도 있기 때문에 이런 경우는 예외로 둔다고 합니다. 그리고 예외 케이스를 판별하기 위해 첫 두 문자가 모두 대문자인지를 확인합니다. 그리고 java.beans 패키지에 있는 Introspector 클래스를 확인해 보면 실제로 어떤 로직이 들어가 있는지 알 수 있습니다.
public class Introspector {
// ...
public static String decapitalize(String name) {
if (name == null || name.length() == 0) {
return name;
}
if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) &&
Character.isUpperCase(name.charAt(0))){
return name;
}
char chars[] = name.toCharArray();
chars[0] = Character.toLowerCase(chars[0]);
return new String(chars);
}
// ...
}
- 맨 앞 두 개가 전부 대문자라면 그대로 리턴하고 아니라면 맨 앞 문자 하나만 소문자로 바꿔서 리턴합니다.
3. 그렇다면 Jackson에서는?
Jackson 도 JavaBeans 규약을 따르지만 다른 점이 하나 있습니다.
테스트로 알아본 Jackson의 규칙은 다음과 같습니다.
- 맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.
- 나머지 모든 케이스에서는 맨 앞 글자만 소문자로 바꿔준다.
JavaBeans 규약과 다른 부분은 1번입니다.
JavaBeans 규약에서는 앞 두 글자가 대문자인 경우 그대로 사용한다고 했으나 Jackson 은 맨 앞부터 이어진 대문자를 모두 소문자로 변경합니다. 예제를 통해서 확인해 보겠습니다.
3.1. 맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.
사실 JavaBeans 규약과 다른 게 이 부분입니다.
Jackson에서는 맨 앞 두 글자가 대문자라면 이어진 모든 대문자를 소문자로 변경합니다.
- AAaa -> aaaa : 앞 두 글자가 대문자라서 소문자로 변경
- BBBb -> bbbb : 앞 두 글자가 대문자라서 이어진 세 번째 문자까지 소문자로 변경
- CCcC -> cccC : 앞 두 글자를 소문자로 변경하지만 맨 뒤의 대문자는 이어져 있지 않아서 그대로 사용
- DDDD -> dddd : 앞 두 글자부터 이어진 대문자를 모두 소문자로 변경
DTO 정의
@ToString
@NoArgsConstructor
public class OneDto {
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;
}
}
controller 정의
@RestController
public class HelloController {
@PostMapping("/one")
public ResponseEntity<OneDto> postOne(@RequestBody OneDto dto) {
System.out.println("----- Request POST /one ------");
System.out.println(dto);
return ResponseEntity.ok(dto);
}
}
- 실제로 요청이 왔을 때 값이 어떻게 들어오는지 확인합니다.
- 받은 @RequestBody 값을 그대로 다시 Response로 내려줍니다.
Request
POST http://localhost:8080/one
Content-Type: application/json
{
"AAaa": "a",
"BBBb": "b",
"CCcC": "c",
"DDDD": "d"
}
- IntelliJ에서 제공하는 http request tool을 사용했습니다.
Log
----- Request POST /one ------
OneDto(AAaa=null, BBBb=null, CCcC=null, DDDD=null)
- Controller에서 찍어둔 print입니다.
- 값이 전부 null로 들어옵니다.
Response
{
"aaaa": null,
"bbbb": null,
"cccC": null,
"dddd": null
}
- 예측한 대로 나오는 걸 확인할 수 있습니다.
- 요청으로 들어온 OneDto 값을 그대로 리턴했을 뿐인데 Message Converter에 의해 요청값과 응답값의 Json Key 값이 바뀌었습니다.
3.2. 맨 앞 두 글자가 대문자가 아니면 맨 앞 글자만 소문자로 바꿔준다
이거는 그냥 단순하게 1번을 제외한 모든 케이스에서는 맨 앞글자만 소문자로 바꿔줍니다.
뒤에 오는 대문자나 소문자는 신경 쓰지 않습니다.
DTO 정의
@NoArgsConstructor
public class TwoDto {
private String aaaa;
private String bbbB;
private String Cccc;
private String DddD;
private String eEee;
private String fFfF;
public String getAaaa() {
return aaaa;
}
public String getBbbB() {
return bbbB;
}
public String getCccc() {
return Cccc;
}
public String getDddD() {
return DddD;
}
public String geteEee() {
return eEee;
}
public String getfFfF() {
return fFfF;
}
}
- DTO를 정의하고 Controller 코드는 OneDto 와 동일하게 실행합니다.
Request
POST http://localhost:8080/two
Content-Type: application/json
{
"aaaa": "a",
"bbbB": "b",
"Cccc": "c",
"DddD": "d",
"eEee": "e",
"fFfF": "f"
}
Log
----- Request POST /two ------
TwoDto(aaaa=a, bbbB=b, Cccc=null, DddD=null, eEee=e, fFfF=f)
- Cccc, DddD 를 제외한 나머지는 전부 값이 제대로 들어옵니다.
Response
{
"aaaa": "a",
"bbbB": "b",
"cccc": null,
"dddD": null,
"eEee": "e",
"fFfF": "f"
}
- 예측한 대로 잘 나옵니다.
- 맨 앞 글자가 대문자였던 Cccc 와 DddD 만 바뀌고 나머지는 그대로입니다.
- 중요하게 볼 점은 TwoDto 의 필드명과 달라진 애들은 값이 제대로 들어오지 않는다는 사실입니다.
3.3. Jackson 결론
우리는 지금까지의 테스트를 통해서 한 가지 사실을 알았습니다.
DTO의 필드명이 대문자로 시작하면 Request 요청 시 값이 제대로 들어오지 않습니다.
필드명이 대문자로 시작하면 Getter 도 대문자로 시작합니다.
Jackson의 규칙에 따라서 get 이후가 대문자로 시작하면 최소한 첫 글자는 항상 소문자로 바뀝니다.
따라서 필드명과 일치하지 않아 데이터가 들어가지 않는 현상입니다.
필드명을 대문자로 시작하는 경우는 많이 없지만 URL 처럼 모두 대문자로 사용했다가 안될 가능성도 있습니다.
4. Lombok 은 무슨 관계일까?
Lombok 은 개발자들이 일일이 만들어야 하는 반복적인 코드를 줄일 수 있게 도와주는 라이브러리입니다.
그중에서도@Getter 어노테이션은 거의 모든 Object에 필수적으로 사용됩니다.
제가 이슈를 겪었던 DTO 오브젝트도 롬복을 사용했습니다.
그렇다면 롬복의 문제점은 무엇일까요?
4.1. Lombok의 Getter 생성 규칙
Lombok의@Getter 어노테이션을 붙이면 클래스의 Getter 메서드를 자동으로 생성해 줍니다.
그런데 @Getter 의 생성 규칙은 굉장히 단순합니다.
get 다음에 무조건 필드명의 맨 앞 글자를 대문자로 바꿔서 만들어줍니다.
lombok의 Github Issue 에도 이 내용에 대한 문의가 있습니다.
제가 문제를 겪었던 필드명도 eScore, iScore.. 부분이었습니다.였습니다.
Lombok이 getEScore로 생성해 주고 Jackson을 거치니 escore가 되어서 필드명이 일치하지 않아 문제가 발생했었습니다. 반면eeScore는 getEeScore가 되고 Jackson을 거쳐도 eeScore가 되어서 정상적으로 값이 들어오죠.
4.2. 인텔리제이 Generator의 Getter 생성 규칙
public class ScoreDto {
private int eScore;
public int geteScore() {
return eScore;
}
}
Lombok 대신 인텔리제이에서 제공하는 제네레이터로 Getter를 만들면 위 이슈를 회피할 수 있습니다.
getEScore 대신에geteScore로 만들어주기 때문에 Jackson을 거쳐도eScore라는 필드명과 일치합니다.
더 자세히 키즈핑에서는 어떻게 Lombok이 작동하여 문제였는지 살펴보겠습니다.
Controller
/*
자녀 성향 진단
*/
@PostMapping("/mbti/diagnosis")
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
public ResponseEntity<KidMbtiDiagnosisRequest> diagnoseKidMbti(
@RequestBody KidMbtiDiagnosisRequest diagnosisRequest) {
log.info("getEScore {}", diagnosisRequest.getEScore());
log.info("getFScore {}", diagnosisRequest.getFScore());
log.info("getJScore {}", diagnosisRequest.getJScore());
return ResponseEntity.ok(diagnosisRequest);
//kidService.diagnoseKidMbti(diagnosisRequest);
}
DTO
@Getter
@Setter
public class KidMbtiDiagnosisRequest {
private Long userId;
private int eScore;
private int iScore;
private int sScore;
private int nScore;
private int tScore;
private int fScore;
private int jScore;
private int pScore;
...
}
Request
아래와 같이 값을 전송하겠습니다.

Log
실제 로그를 찍어보면 DTO 객체에 제대로 값이 들어오지 않음을 알 수 있습니다.

Response
그래서 DTO 객체를 그대로 반환을 하면 필드들이 바뀐 걸 알 수 있습니다.

위 상황을 요약하면 eScore 필드의 getter가 룸북으로 인해 getEScore()로 생성이 됩니다.
그러면 앞서 설명한 Jackson에서의 규약으로 Json의 키가 escore가 됩니다.
그래서 DTO 객체의 eScore 필드와 Json의 키인 escore가 서로 이름이 달라 매핑이 되지 않아 값이 들어오지 않았던 거였습니다.
Conclusion
지금까지 정리한 내용을 요약하면 아래와 같습니다.
- Spring의 Json Message Converter는 Jackson 라이브러리를 사용
- lombok의 Getter는 필드명 맨 앞을 항상 대문자로 만듦
- Jackson 라이브러리는 Getter의 맨 앞 두 글자가 전부 대문자인 경우 필드명과 Json key 값이 달라짐
- eScore라는 필드명을 lombok을 사용해서 Getter를 만들면 getEScore() 가 되기 때문에 이슈가 발생
위 문제를 해결하려면 필드명을 작성할 때 첫 번째는 소문자, 두 번째는 대문자인 케이스로 만들지 않으면 됩니다.
그래도 꼭 사용해야 한다면 lombok의@Getter 대신 직접 Getter를 만들거나, @JsonProperty 를 사용하면 됩니다.
참고
'프로젝트 > Kidsping' 카테고리의 다른 글
개발 기록 - 선착순 응모 시스템 이슈 및 해결 과정 (0) | 2024.10.30 |
---|---|
트러블 슈팅 - AWS 프리티어 EC2 인스턴스 메모리 부족 현상 해결하기 (0) | 2024.10.25 |
개발 기록 - N + 1 문제 fetch join, Batch Size로 해결 (0) | 2024.10.24 |
개발 기록 - Java에서 Enum 의 비교는 '==' 인가? 'equals' 인가? (0) | 2024.10.22 |
개발 기록 - 자녀 성향 진단 로직 구현 및 리팩터링 (0) | 2024.10.22 |