글 작성자: beaniejoy

Spring Framework를 이용해 web application을 개발하다보면 request - POJO 객체 - response로 data가 흘러간다는 것은 누구나 다 알게 됩니다.

최근에는 web application에 들어오고 나가는 data를 대부분 JSON 포맷으로 처리하고 있습니다. 그러면 이러한 JSON 데이터를 application 내의 POJO 객체로 변환하고 처리된 데이터를 다시 JSON 형태로 변환해서 내보내야 하는데 이러한 일들을 누가 하는 것일까요.

Spring에서는 보편적으로 이러한 일을 jackson 라이브러리가 맡아서 작업하고 있습니다.
(Spring Boot를 사용하면 jackson 라이브러리는 기본적으로 내장되어 data binding에 사용되고 있습니다.)

jackson 라이브러리를 통해 어떻게 JSON 데이터가 POJO 객체로 변환되는지 여러가지 케이스를 통해 알아보겠습니다.

이번 내용은 field &, getter/setter를 중점적으로 다루었습니다. constructor와 관련된 내용은 다음 게시물에 작성할 계획입니다.

 

📌 1. public 필드만 있는 경우

jackson은 기본적으로 필드의 네임을 기준으로 data를 매핑합니다. 예를 들어 {“name”: “beanie”} 라는 데이터를 POJO 객체로 변환할 때 변환할 클래스에 정의된 맴버변수(필드)인 “name”을 참조하게 됩니다.

public class Member {
	public String name;
	public String address;
	//...
}

jackson doc에서 가장 처음 use case에 소개되는 내용인데요. 맴버변수의 이름(name, address)이 json 데이터의 key 이름과 매핑된다고 보시면 됩니다.

중요한 것은 맴버변수를 기준으로 할 때는 가시성 접근자가 public 이어야 한다는 것입니다. public이 아닌 접근가를 설정하게 되면 json 데이터와 매핑이 안되기 때문에 주의해야 합니다.

그런데 한가지 생각해야될 부분은 application에 적용되는 POJO 형태의 dto 클래스는 기본적으로 private 필드로 설정하고 getter(추가로 setter)로 해당 필드에 접근하도록 되어 있습니다. 결국 위의 public 접근자인 맴버변수 형태는 거의 쓰이질 않는다는 얘기입니다.

 

📌 2. private 필드와 getter로 이루어진 경우

public이 아닌 접근자(protected, private)인 필드에 대해서는 getter를 설정함으로써 json 데이터와 매핑을 지어줄 수 있습니다.

// 기본적인 POJO 형태가 됩니다.
public class Member {
	private String name;
	private String address;
	//...

	public String getName() {
		return this.name;
	}

	public String getAddress() {
		return this.address;
	}
}

위 코드처럼 getter를 설정하면 해당 getter의 이름을 가지고 맴버변수에 데이터를 매핑하게 됩니다.
예를 들어 {”name”: “beanie”} 데이터를 객체로 바인딩했을 때 POJO의 getName 메소드에 의해 name 필드와 매핑이 됩니다.
getXxxx() 메소드는 “xxxx” 필드와 연결이 됩니다.

// POJO 내부 코드
private String helloId;

public String getHelloId() {
	return helloId;
}

위의 코드에서는 {”helloId”: 1 } 이런 데이터에 helloId 필드에 매핑이 될 것입니다.
(이것은 언제나 private field와 getter만 있는 상황에 해당하는 내용입니다.)

 

📌 3. 필드명과 getter 이름이 불일치 하는 경우

위의 예제에서 확인할 수 있었듯이 필드명과 getter 이름은 일치해야 합니다. 그러면 서로 불일치하는 경우는 어떻게 될까요. POJO 클래스에 다음과 같은 코드가 있다고 해봅시다.

// MemberRequestDto 일부

private String name;

// ...

public String getHelloName() {
	return id;
}

name이라는 필드와 getHelloName 메소드가 있습니다. 이를 ObjectMapper로 json 형태에서 역직렬화를 해봅시다.

@Test
@DisplayName("3. getter 이름과 field 이름 다른 경우에서 ObjectMapper 예외 발생 테스트")
void checkValidMappingWithAnotherGetterName() 
	throws JsonProcessingException, JSONException {
    String helloName = "joy";

	// HELLO_NAME = "helloName"  
    json.put(HELLO_NAME, helloName); // Unrecognized field "helloName"

    JsonProcessingException exception = 
    	assertThrows(JsonProcessingException.class, () -> {
            mapper.readValue(json.toString(), MemberRequestDto.class);
        }, "JSON parsing 관련 에러가 발생하지 않았습니다.");

    logger.info(exception.getMessage());
}

JUnit 테스트에 예상되는 Exception을 설정하였고 테스트 결과 예상대로 JsonProcessingException이 발생했고 Unrecognized field "helloName" 내용을 확인할 수 있습니다.

그리고 getHelloName 으로 getter이름을 설정했다면 직렬화를 통해 json 데이터로 내보낼 때 {”helloName”: “value”} 이런 식으로 내보내지게 됩니다.

이는 api 규격을 깨트릴 수 있기 때문에 필드와 getter만으로 구성된 클래스에서는 getter 이름과 필드명은 필히 일치시켜줘야 합니다.

 

📌 4. getter에서 임의의 내용을 리턴하는 경우

// MemberRequestDto 일부

private String name;

// ...

public String getName() {
	return "custom name";
}

위의 내용처럼 getter에서 임의로 지정한 값을 리턴할 때 어떤 일이 벌어질까요. 이 또한 JUnit 테스트로 알아보겠습니다.

@Test
@DisplayName("4. getter 에서 임의로 지정한 내용을 return 하는 경우")
void checkValidMappingWithGetterCustomReturnValue() 
	throws JsonProcessingException, JSONException {
    MemberRequestDto4 dto = 
    	mapper.readValue(json.toString(), MemberRequestDto4.class);
    
    logger.info(dto.toString());

    assertEquals(id, dto.getId());
    assertNotEquals(name, dto.getName()); // 값이 다른 것은 당연
    assertEquals(address, dto.getAddress());
    assertEquals(email, dto.getEmail());

    String resultJson = mapper.writeValueAsString(dto);
    logger.info(resultJson);

    assertTrue(resultJson.contains("\\"name\\":\\"custom name\\""));
}

ObjectMapper를 통해 객체로 binding하고 해당 POJO 객체의 로그를 찍어보면 json 데이터 입력값대로 필드에 잘 주입이 된 것을 확인할 수 있습니다.

하지만 해당 객체를 다시 직렬화를 통해 json 형태로 만들 때 getter에 임의로 지정한 값(”custom name”)이 내보내지게 됩니다.
여기서 한가지 알 수 있는 중요한 내용은 getter는 단순히 매핑관계 설정해주는 역할을 넘어서 직렬화를 통해 데이터를 json 형태로 내보낼 때 getter의 return 값으로 내보낸다는 점입니다.

 

📌 5. private 필드와 setter만으로 이루어진 경우

jackson 라이브러리는 getter 뿐만아니라 setter로도 필드 binding이 가능합니다.

private String name;

// ...

public void setName(String name) {
	this.name = name;
}

이렇게 private 필드와 setter만(getter 존재 X) 존재하는 경우에는 어떤 일이 일어날까요.

@Test
@DisplayName("5. private field & setter 조합만으로 ObjectMapper 테스트")
void checkValidMappingWithPrivateFieldAndSetter() throws JsonProcessingException {
    MemberRequestDto5 dto = 
    	mapper.readValue(json.toString(), MemberRequestDto5.class);

    String dtoInfo = dto.toString();
    logger.info(dtoInfo);

    assertTrue(dtoInfo.contains("id=" + id));
    assertTrue(dtoInfo.contains("name='" + name + "'"));
    assertTrue(dtoInfo.contains("address='" + address + "'"));
    assertTrue(dtoInfo.contains("email='" + email + "'"));

    String resultJson = mapper.writeValueAsString(dto);
    logger.info(resultJson);
}

테스트를 진행하면 다음과 같은 에러가 발생하면서 테스트가 실패합니다.

No serializer found for class io.beaniejoy.jacksonbindtest.dto.MemberRequestDto5 and no properties discovered to create BeanSerializer

setter만 있고 getter가 없기 때문에 직렬화하는데 있어서 참고할 프로퍼티가 존재하지 않는다고 나와 있습니다. 위에 4번째 단계에서 언급했듯이 (private 필드인 경우) getter를 기준으로 직렬화를 하게 되는데 getter가 없으면 직렬화할 때 참고할 내용이 없어서 내보낼 데이터가 없게 됩니다.

mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
String resultJson = mapper.writeValueAsString(dto);

assertEquals(resultJson, "{}"); // getter가 없기에 내보낼 데이터가 없다.

위와 같이 mapper에 FAIL_ON_EMPTY_BEANS를 disable 처리하고 직렬화를 수행하면 결과값으로 데이터가 비어있는 json string을 반환하게 됩니다.

이를 Controller Test에 적용하면 또다른 내용을 확인할 수 있습니다.

@Test
@DisplayName("5. private field & setter 조합만으로 406 응답코드(Not Acceptable) 반환 테스트")
public void bindingDtoOneCaseTest() throws Exception {
    // getter가 없어서 response를 내보낼 때 각 field에 저장된 데이터에 접근할 수 없게됨
    mvc.perform(post("/api/member/five")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(requestJson.toString()))
            .andExpect(status().isNotAcceptable())
            .andDo(print());
}

api 호출 테스트를 해보면 setter만 있는 dto를 response로 내보낼 때 406(Not Acceptable) 응답코드를 반환하게 됩니다. getter가 없기에 response로 내보낼 dto안에 데이터가 있음에도 내보낼 json 데이터가 만들어지지 않습니다.

여기서 또 하나 중요한 내용을 알 수 있습니다. getter는 웬만하면 필수로 설정해야 한다는 점입니다. getter만 있어도 필드와 데이터 바인딩도 가능해지고 setter가 존재할 때도 getter가 있음으로 인해 json 직렬화를 가능하게 해주기 때문입니다.

deserialize 할 때 setter를 사용하는 경우 default 생성자는 필수로 있어야 합니다. 물론 getter도 마찬가지입니다. 생성자 관련한 jackson bind 내용은 따로 게시글을 작성할 예정입니다.

 

📌 6. setter에 임의의 값을 필드에 주입하는 경우

이번에는 setter에 임의로 지정한 값을 필드에 할당하는 경우에는 어떻게 되는지 알아봅시다.

private String name;

// ...

public void setName(String name) {
	this.name = "default name";
}

다음과 같이 setName을 통해 인자를 받아왔지만 임의로 지정한 String을 필드에 주입하는 경우에 대해서 테스트해보겠습니다.

@Test
@DisplayName("6. 기본적인 POJO 구조에서 setter에 임의의 값 주입하는 경우에서 ObjectMapper 테스트")
void checkValidMappingWithSetterCustomValue() throws JsonProcessingException {
    MemberRequestDto6 dto = 
    	mapper.readValue(json.toString(), MemberRequestDto6.class);

    logger.info(dto.toString());

    assertEquals(id, dto.getId());
    assertEquals("default name", dto.getName()); // 임의로 지정된 값이 dto에 할당
    assertEquals(email, dto.getEmail());
}

mapper는 기본 생성자를 통해 해당 객체를 생성후 setter로 json에서 받아온 데이터를 주입시킵니다. 이 때 임의로 지정한 값을 setter에서 할당했기 때문에 당연하게 역직렬화된 dto 객체 안에는 임의로 지정한 값이 할당된 것을 볼 수 있습니다.

 

📌 7. setter의 이름이 필드명과 일치하지 않는 경우

private String name;

// ...

public void setHelloName(String name) {
	this.name = name;
}

이번에는 setter method명과 필드명이 일치하지 않는 경우 어떻게 되는지 알아보겠습니다. 위와 같이 setter 이름을 setHelloName으로 지정하였고 해당 setter는 name 필드에 값을 할당하도록 구성해봤습니다.

@Test
@DisplayName("7. setter의 이름이 필드명과 일치하지 않는 경우에서 ObjectMapper 테스트")
void checkValidMappingWithCustomSetterName() 
	throws JsonProcessingException, JSONException {
    // { ... , "helloName": "joy", "helloAddress": "joy's address", ... }
    json.put(HELLO_NAME, "joy");

    MemberRequestDto7 dto = 
    	mapper.readValue(json.toString(), MemberRequestDto7.class);

    logger.info(dto.toString());

    assertEquals(id, dto.getId());
    assertEquals("joy", dto.getName()); // name 필드에 값이 잘 할당됨
    assertEquals(email, dto.getEmail());
}

필드명이 name 이라서 json 데이터는 {”name”:”joy”} 일 것 같지만 위의 테스트 코드에서 알 수 있듯이 {”helloName”: “joy”} 을 binding 시키고 있습니다.

key 이름이 helloName이라 binding 과정에서 setHelloName을 호출하게 되고 name 필드에 값을 할당하기 때문에 dto 필드에는 helloName으로 들어온 value(”joy”)가 정상적으로 할당된 것을 확인할 수 있습니다.

setter부분은 역직렬화(request 에서 POJO 객체로 변환)하는 과정에서 사용되기 때문에 Controller Test는 ObjectMapper 테스트 내용과 비슷하여 생략하였습니다.

 

📌 8. 정리

  • 역직렬화할 대상이되는 객체의 클래스를 필드만으로 구성할 경우 public 접근자 필수 
    → 근데 이런 형태는 거의 안쓰임
  • private 필드 + getter 조합만으로도 binding 가능 (기본 생성자는 필수)
     getter명이 필드명과 일치하지 않는경우(ex. getHelloName() - name), 제대로 binding도 안되고 직렬화해서 json 데이터로 내보낼 때 api 규격을 깨트리는 위험한 요소가 될 수 있음
     getter명과 필드명은 필히 일치시켜줍시다. (java에서는 lombok, kotlin에서는 주생성자 기능을 통해 쉽게 적용 가능)
  • setter는 기본생성자와 같이 json에서 역직렬화할 때 사용되는 메소드다.
     setter 역시 setter명과 필드명을 일치시켜줍시다.
     private field + setter 조합만으로 역직렬화 binding은 가능합니다. 
      여기서 getter가 없다면 해당 객체를 json으로 내보낼 때 문제가 발생하니 getter는 웬만하면 필히 지정합시다.
  • 제대로 언급은 안드렸지만 일단 jackson binding시 기본생성자는 필수 구성이라고 기억해둡시다.
    (상황마다 조금 다르긴 합니다. 해당 내용은 다음 게시글에 작성하겠습니다.)

위의 내용에 대한 코드 내용은 github에 커밋하였습니다. 참고하세요.
https://github.com/beaniejoy/test-project-repository/tree/main/spring-jacksonbind-test

 

GitHub - beaniejoy/test-project-repository: 🧪 Study & Test Repository, which manages and tests various frameworks, libraries

🧪 Study & Test Repository, which manages and tests various frameworks, libraries and modules, that consists of directories named by each topic. - GitHub - beaniejoy/test-project-repository: 🧪 S...

github.com

 

틀린 내용이 있을 수 있으니 언제든 코멘트 주세요. 감사합니다.