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라는 클래스를 사용하여 데이터 변환하여 매핑을 해줍니다.
  1. 객체를 JSON으로 직렬화를 할 때 ObjectMapper 클래스의 writeValueAsString() 메서드를 사용하여 객체를 JSON 문자열로 변환한다.
  2. 객체의 필드 또는 속성 값을 가져와서 해당 값을 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 공식 문서에 설명되어 있습니다.

by Sun Microsystems

 

간단히 요약하면 아래의 설명과 같습니다.


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 애노테이션을 사용해서 해결하면 된다.