Web/Spring&Spring Boot
[Lombok][Jackson] Naming convention for getters/setters in Java
균지니
2023. 7. 16. 15:23
개요
대외서비스 연계 API 테스트를 하다가 특정 필드의 데이터가 null로 들어오는 이슈에 직면했습니다.
처음에 오타가 있는 줄 알고 연계 인터페이스 In/Out 정의서 토대로 변수명을 재차 확인했는데 틀리지 않았음.
잘못 구현했나 싶었으나 다른 필드는 다 잘 매핑됨. 띠용. 따로 샘플 코드 만들어서 테스트해 보고 구글링 통해서 알게 된 Jackson, Lombok, Java Beans 네이밍 규약에 대해서 정리합니다.
이슈내용
외부 API 응답 데이터를 Response 객체에 매핑하여 내부 서비스로 응답해줄 때, 특정 필드값이 null로 매핑되는 현상
{
"prcssResult": "200",
"requestId" : "124837",
"aBCDNo": null
}
- 내용 : 위와 같은 데이터(샘플)를 응답받는데 특정 필드값이 null 값이 들어오는 것을 확인.
이슈 상세
- 대외서비스 연계 API 개발을 담당하고 있어서 외부 API 응답값을 그대로 내부 서비스로 보내주는 프로세스이고 내부 서비스에서 연계 서비스의 거래 내역을 확인할 수 있다. (내부 서비스에서 해당 값을 사용함)
- 외부 API 거래를 Spring Framework기반 HttpURLConnection 방식으로 구현하고 있어 응답 문자열을 로그로 출력해 봤다.
- 로그상 응답 문자열의 key와 value값도 제대로 나오고, 응답 문자열을 JSON 객체로 변환한 뒤 응답객체로 역직렬화하는 과정도 매핑이 잘된다.
- 그러나 내부 서비스에서 연계 서비스의 거래 내역을 확인해보면 응답값의 특정 필드가 null로 매핑된다.
- 정리하면, 외부 API 응답값을 응답객체에 잘 매핑하여 내부 서비스로 HTTP 응답을 주면 특정 필드가 null이 되는 것이다.
샘플코드 작성
- 원인분석을 위해 Spring Boot로 도메인과 컨트롤러를 테스트용으로 구현했습니다.
- 블로그에 모든 구현내용을 담을 수 없어서 간단하게 다음과 같이 테스트 코드를 작성했습니다.
- 요청값을 매핑해서, 입력한 대로 잘 매핑되어 출력하는지 확인
Domain
@Setter(AccessLevel.PRIVATE)
@Getter
@ToString
@NoArgsConstructor
public class NamingDto {
private String aBCDNo;
private String AAaa;
private String BBBb;
private String CCcC;
private String DDDD;
private String AAAAAAa;
private String Aa;
private String aaA;
private String Fab;
}
Controller
@Slf4j
@RestController
public class NamingController {
@PostMapping("/v1/issue/naming/lombok")
public ResponseEntity<NamingDto> getValue(@RequestBody NamingDto dto) {
log.info("Request v1/issue/naming/lombok POST/ getValue");
log.info(String.valueOf(dto));
return ResponseEntity.ok(dto);
}
}
Postman 결과
- 결과를 보면 JSON Key값과 필드명도 다르고 null로 찍힘. 골 때림.
- 원인분석을 하다 보니깐 HTTP 메시지 컨버터의 쪽의 문제가 아닐까 해서 찾아봤다.
- HTTP 메시지 컨버터가 무엇인가요?
- 요청 본문에서 메시지를 읽어 들이거나(@RequestBody), 응답 본문에 메시지를 작성할 때(@ResponseBody) HTTP 메시지 컨버터가 동작한다.
- HTTP 헤더와 컨트롤러 요청 및 반환타입 정보를 조합해서 컨버터의 여러 종류 중 하나가 선택된다.
- API는 JSON 형태로 받기 때문에 MappingJackson2HttpMessageConverter가 선택되어 Jackson 컨버터가 실행된다.
- 그래서 Jackson 컨버터가 네이밍 규칙에 따라 데이터를 찰떡같이 매핑해준다.
- Jackson과 Jackson의 네이밍 규칙을 알아보자. ✨
원인분석
1. Jackson
- 스프링에서 JSON 데이터를 객체로 변환하거나 객체를 JSON 데이터로 변환해 주는 라이브러리로 스프링부트에서는 기본적으로 제공해 준다. Jackson 라이브러리는 ObjectMapper라는 클래스를 사용하여 데이터 변환하여 매핑을 해줍니다.
- 객체를 JSON으로 직렬화를 할 때 ObjectMapper 클래스의 writeValueAsString() 메서드를 사용하여 객체를 JSON 문자열로 변환한다.
- 객체의 필드 또는 속성 값을 가져와서 해당 값을 JSON 키와 매핑하여 JSON 객체를 생성하고 이를 문자열로 반환시켜 줍니다.
- 위 과정에서 Key 값을 매핑하는 규칙이 존재.
1.1 Jackson은 Getter의 이름을 기반으로 Json Key 값을 만든다.
- 필드명 대신에 Getter 이름으로 JSON key값이 설정됨.
Domain
public class JacksonDto {
private String aBCDNo;
public void setaBCDNo(String aBCDNo) {
this.aBCDNo = aBCDNo;
}
public String getCheckaBCDNo(){
return aBCDNo;
}
}
Domain 테스트 코드
@Slf4j
class JacksonDtoTest {
private ObjectMapper objectMapper;
@BeforeEach
public void setUp(){
this.objectMapper = new ObjectMapper();
}
@Test
public void Jackson_Getter() throws IOException {
JacksonDto dto = new JacksonDto();
dto.setaBCDNo("hello");
String s = this.objectMapper.writeValueAsString(dto);
log.info(s);
}
}
테스트 결과
- {"checkaBCDNo":"hello"}
- 클래스에 선언된 필드는 aBCDNo이고, Getter메서드명은 getCheckaBCDNo()이다.
- get다음으로 이어지는 이름을 CheckaBCDNo 가져와서 Jackson의 네이밍 규칙에 의해 변형하여 매핑시킨다. 🫢😲
- 이제 Jackson 네이밍 규칙을 알아보겠습니다.
1.2 Jackson 네이밍 규칙
- 모든 케이스에서는 첫 번째 문자 하나만 소문자로 변경합니다.
ex) CheckaBCDNo -> checkaBCDNo - 예외 케이스로는 맨 앞 두 글자 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경합니다.
ex) AAaa -> aaaa , AAAAAAa -> aaaaaaa - 기본적으로 JavaBeans 규약을 따르지만 다른 부분이 있음.
2. Java Bean
2.1 Java Beans 네이밍 규칙
- Java Beans는 메서드 이름에서 필드명을 추출할 때 일정한 규칙이 존재함.
- 오라클에서 제공하는 Java Beans API specification 공식 문서에 설명되어 있습니다.
간단히 요약하면 아래의 설명과 같습니다.
1. 일반적으로는 첫 번째 문자 하나만 소문자로 변환하여 리턴합니다.
2. 예외 케이스로는 두 개 이상의 문자가 있고 첫번째 문자와 두 번째 문자 모두 대문자인 경우 그대로 다시 리턴합니다.
java.beans 패키지에 있는 Introspector 클래스의 decapitalize() 메서드를 확인해 보면 실제로 어떤 로직이 들어가 있는지 알 수 있습니다.
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);
}
}
IDE에서 제공하는 Getter and Setter 메서드 자동생성 기능이 Java Beans 네이밍 규약으로 만들어집니다.
3. Jackson와 Java Beans 차이점
- 둘 다 공통적으로 첫 번째 문자 하나만 소문자로 변환하여 리턴한다.
- 다른 점은 맨 앞 두 글자(첫 번째, 두 번째) 모두 대문자인 경우
- Jackson은 이어지는 모든 대문자 -> 소문자로 변경하여 반환
- Java Beans은 원래 문자 그대로 반환
4. Lombok의 Getter 생성 규칙
- 롬복은 맨 앞 글자를 대문자로 바꿔서 만들어줍니다.
- 롬복의 Getter 이름으로 Jackson 네이밍 규칙에 의해 JSON key값이 설정됨.
- 필드명 -> Lombok -> Jackson 점진적으로 변경되는 과정을 보자.
필드명 >> | Lombok >> | Jackson |
aBCDNo | getABCDNo | abcdno |
AAaa | getAAaa | aaaa |
BBBb | getBBBb | bbbb |
CCcC | getCCcC | cccC |
DDDD | getDDDD | dddd |
AAAAAAa | getAAAAAAa | aaaaaaa |
Aa | getAa | aa |
aaA | getAaA | aaA (해당 필드명만 매핑됨) |
Fab | getFab | fab |
맨 처음 작성했던 샘플코드의 테스트 결과와 동일합니다.
결국, 원인은 Jackson HttpMessageConverter 네이밍 규칙에 의해서
객체의 필드와 JSON Key값 불일치로 매핑 안됨
aBCDNo(필드명) >> getABCDNo(롬) >> abcdno(Jackson)
해결방법
필드명을 변경할 수 없는 경우
1. @JsonProperty 애노테이션 사용
@JsonProperty("aBCDNo")
private String aBCDNo;
- @JsonProperty 사용하기 위해 Jackson 라이브러리를 사용해야 합니다.
- 라이브러리가 없는 경우 com.fasterxml.jackson.core 패키지가 필요합니다.
- @JsonProperty(name)은 JSON 속성 이름을 Java 필드명에 매핑하도록 Jackson ObjectMapper에 지시하여 지정한(name)으로 getter 및 setter 메서드를 지정하는 데 사용합니다.
2. Getter/Setter 직접 생성
public void setaBCDNo(String aBCDNo) {
this.aBCDNo = aBCDNo;
}
public String getaBCDNo() {
return aBCDNo;
}
- 롬복 @Getter 또는 @Data 애노테이션 사용 대신 Getter와 Setter 메서드를 직접 생성합니다.
- Getter/Setter 직접 생성을 하면 자바빈 규약에 의해서 기존 필드명 그대로 유지가 됩니다. ex) aBCDNo -> getaBCDNo()
- 저의 경우 인텔리제이가 제공하는 Getter and Setter 메서드 자동생성 기능을 사용해서 해결했습니다.
- 첫번째가 문자가 대문자인 AAaa, Bbb 같은 필드명은 @JsonProperty 애노테이션을 사용해야 해결 가능합니다.
- Getter/Setter 직접 생성으로 해결하지 못하는 필드명이 존재할 수도 있다는 생각에 모든 API 요청 및 응답 필드를 확인해 보니, 필드명의 첫번째 문자가 대문자로 오는 경우는 없었다.
필드명을 변경할 수 있는 경우
1. 네이밍 잘 짓기 ✨
- 지양해야 하는 필드명(컬럼명)
case 1) 첫번째 소문자, 두번째 대문자 : aBCDNo(Field) -> getABCDNo(롬복) -> abcdno(Jackson)
case 2) 첫번째 대문자, 두번째 대문자 : BBbb(Field) -> getBBbb(롬복) -> bbbb(Jackson) - 지향해야 하는 필드명(컬럼명)
필드명의 첫번째, 두번째는 소문자인 케이스로 지향합시다.
🐪 카멜 표기법 중 LCC(Lower Camel Case)를 사용하자.
첫 단어는 소문자로 표기하며 이후 연결되는 단어부터는 첫 글자를 대문자로 표기한다.
해결방법으로 테스트
class NamingDtoTest {
private static String jsonStr = "{\"aBCDNo\":\"hello world\"}";
private ObjectMapper objectMapper;
@BeforeEach
public void setUp(){
this.objectMapper = new ObjectMapper();
}
@Test
public void 롬복사용() throws IOException {
NamingDto result = this.objectMapper.readValue(jsonStr, NamingDto.class);
assertThat(result.getABCDNo(), is("hello world"));
}
@Test
public void Getter_Setter_직접생성() throws IOException {
NamingDtoV2 result = this.objectMapper.readValue(jsonStr, NamingDtoV2.class);
assertThat(result.getaBCDNo(), is("hello world"));
}
@Test
public void 롬복_잭슨애노테이션적용() throws IOException {
NamingDtoV3 result = this.objectMapper.readValue(jsonStr, NamingDtoV3.class);
assertThat(result.getABCDNo(), is("hello world"));
}
}
테스트 결과
- 위 (1), (2) 해결방법으로 테스트 코드를 작성해서 검증해 봤습니다.
- 롬복의 @Getter 사용한 객체는 테스트 실패가 옳은 테스트입니다.
- (Jackson 네이밍 규약에 의해서 필드와의 매핑 안 되는 게 정상)
- 1.@JsonProperty 애노테이션 사용, 2. Getter/Setter 직접으로 통한 해결은 테스트 통과가 됩니다.
API 요청 결과
- 원인분석을 위해 작성했던 상단의 샘플코드에도 @JsonProperty 추가하여 확인했습니다.
@Setter(AccessLevel.PRIVATE)
@Getter
@ToString
@NoArgsConstructor
public class NamingDto {
@JsonProperty(value = "aBCDNo")
private String aBCDNo;
@JsonProperty(value = "AAaa")
private String AAaa;
@JsonProperty("BBBb")
private String BBBb;
@JsonProperty("CCcC")
private String CCcC;
@JsonProperty("DDDD")
private String DDDD;
@JsonProperty("AAAAAAa")
private String AAAAAAa;
@JsonProperty("Aa")
private String Aa;
private String aaA;
@JsonProperty("Fab")
private String Fab;
@JsonProperty("aA")
private String aA;
}
이슈 | 해결 (@JsonProperty 적용) |
회고
- 애초부터 필드명(컬럼명)의 네이밍 컨벤션을 잘 정의하면 이런 일을 겪지 않는다는 걸 배웠음.
- 외부 API 응답을 받아서 처리하는 프로세스인데 파악해 보니 해당 API가 여러 외부 수행사에서도 쓰임
- 거래하고 있는 몇 개 API에서도 포스팅 주제와 같은 이슈인 필드명이 여러 개 존재한다는 것도 확인 ㅠ
- 팀 내부적으로 해당 이슈를 공유드리면서 자체 코드 수정으로 해결을 봤다.
- 역시 네이밍의 중요성을 느끼는 이슈였다. 🫠
정리
지금까지 정리한 내용을 요약하면 아래와 같습니다.
1. Spring의 JSON Message Converter는 Jackson 라이브러리를 사용
2. Jackson 라이브러리는 Getter의 맨 앞 두 글자가 대문자인 경우 이어진 대문자를 모두 소문자로 변경하여 리턴함
3. Lombok의 Getter는 필드명 맨 앞 첫 번째 글자를 항상 대문자로 만든다.
4. Controller 단에서 Json 데이터와 Target Entity를 매핑할 때 Lombok와 Jackson의 네이밍 규칙에 따른 필드명 불일치 사태가 일어나 key 매핑이 안됨.
5. 필드명(컬럼명)을 수정하거나, Getter/Setter 직접 생성하거나, @JsonProperty 애노테이션을 사용해서 해결하면 된다.