[자바/코틀린] 잭슨 (Jackson)

잭슨(Jackson)은 자바 JSON 파서(parser) 라이브러리이다. 자바 객체와 JSON 문자열 간 직렬화 및 역직렬화를 수행한다. com.fasterxml.jackson.databind.ObjectMapper를 사용하여 자바 객체(엔티티 객체)와 JSON 문자열 간 직렬화 및 역직렬화 수행한다. JSON이 프로퍼티의 값으로 객체를 가지고 있는 경우에도 자바 객체로 역직렬화 시 참조 관계에 있는 객체에 대해서 매핑이 수행된다.


기본 동작

잭슨 라이브러리는 기본적으로 자동 구성(auto-configuration) 기능을 통해 직렬화 및 역직렬화 처리를 수행한다. 자동 구성이란 미리 정의된 명명 규칙과 메서드 시그니처 템플릿을 사용하여 데이터 바인딩에 사용할 메서드를 찾는 것을 의미한다. 파라미터가 없으며 값을 반환하고 메서드명에 접두사 get이 붙은 public 멤버 메서드가 정의되어 있으면 이를 자동으로 감지하여 해당 메서드가 반환하는 필드를 JSON 객체의 필드로 직렬화 처리하며, set이 붙은 public 멤버 메서드에 대해서는 역직렬화 처리한다.

잭슨 라이브러리는 자바의 기본 빈 명명 규칙을 사용하여 프로퍼티를 처리한다. 주의할 점은 자바 기본 빈 명명 규칙에 따르면 부울 타입의 getter 메서드명은 isXXX(), setter 메서드명은 setXXX()이다. 따라서 잭슨 라이브러리는 부울 타입 프로퍼티의 getter 메서드에 대해 is 접두어 뒤의 문자열을 JSON 필드명으로 직렬화 처리하며, setter 메서드에 대해 set 접두어 뒤의 문자열을 프로퍼티명으로 역직렬화 처리한다. 부울 타입 외의 타입에 대해서는 is 접두어 존재 여부와 관계 없이 프로퍼티명 그대로 처리한다. 이러한 처리 방식으로 인해 롬복 라이브러리를 사용하여 getter와 setter 메서드를 자동 구성하는 경우, is로 시작하는 부울 타입의 프로퍼티명은 is 접두어가 제거된 XXX 필드명으로 JSON 구조의 직렬화 및 역직렬화가 일어나게 된다. 따라서 isXXX 그대로 처리하는 것이 의도된 경우 별도로 getter와 setter 메서드를 정의하거나 @JsonProperty를 사용해야 한다.


매핑 처리

잭슨 라이브러리가 수행하는 매핑 처리 방식은 다음과 같다.

  • 직렬화: 자바 객체 → JSON 문자열 (프로퍼티 매핑)
    ObjectMapper objectMapper = new ObjectMapper();
    String jsonStr = objectMapper.writeValueAsString(obj);
    
  • 역직렬화: JSON 문자열 → 자바 객체 (프로퍼티 매핑)
    String jsonStr = "JSON";
    Obj obj = objectMapper.readValue(jsonStr, Obj.class);
    
  • 역직렬화: JSON 문자열 → 자바 리스트 객체 (리스트 구조로 매핑)
    String jsonStr = "JSON";
    List<Obj> listObj = objectMapper.readValue(jsonStr, new TypeReference<List<Obj>>(){});
    
  • 역직렬화: JSON 문자열 → 자바 맵 객체 (맵 구조로 매핑)
    String jsonStr = "JSON";
    Map<String, Object> mapObj = objectMapper.readValue(jsonStr, new TypeReference<Map<String, Object>>(){});
    
  • 역직렬화: JSON 문자열 → JsonNode 객체
    String jsonStr = "JSON";
    JsonNode jsonNode = objectMapper.readTree(json);
    String prop = jsonNode.get("prop").asText();
    


기능 구성

ObjectMapperconfigure() 메서드를 통해 직렬화 및 역직렬화 시 다양한 기능 구성을 할 수 있다.

  • 직렬화링크
    objectMapper.configure(SerializationFeature.FLUSH_AFTER_WRITE_VALUE, false);
    
  • 역직렬화링크
    objectMapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false);
    


JSON 문자열을 Object가 아닌 특정 모델 객체로 역직렬화 시 FAIL_ON_UNKNOWN_PROPERTIES 값은 기본적으로 true이므로 역직렬화할 JSON 구조가 변경되어 자바 객체에 정의되지 않은 프로퍼티가 존재하는 경우 역직렬화 시UnrecognizedPropertyException 런타임 예외가 발생하게 된다. FAIL_ON_UNKNOWN_PROPERTIESfalse로 설정하여 새롭게 추가된 프로퍼티에 대해서는 역직렬화하지 않도록 할 수 있다. JSON 구조에 정의되어 있지만 자바 객체에는 정의되어 있지 않은 프로퍼티에 대해서는 역직렬화 시 예외가 발생하지 않는다. 변경된 JSON 구조를 정상적으로 역직렬화하기 위해 커스텀 역직렬화 클래스를 정의하여 사용할 수도 있다.


어노테이션

잭스 라이브라리의 자동 구성 기능에 의해 의도하지 않는 매핑 처리가 수행될 수도 있다. 이 경우 라이브러리가 제공하는 다양한 어노테이션을 사용하여 매핑 처리 방식을 변경할 수 있다. 어노테이션은 클래스나 클래스의 멤버(필드, 메서드)에 추가할 수 있으며 잭슨 라이브러리는 지정된 어노테이션을 기반을 메타데이터로 하여 매핑 처리 수행 방식을 결정한다.

@JsonAutoDetect 클래스 어노테이션은 자동 구성 기능을 통해 감지할 메서드의 종류와 최소 접근 수준을 정의하는데 사용된다.

@JsonProperty 어노테이션은 getter 또는 setter 정적 메서드와 정적 객체 필드에 사용되는 마커 어노테이션이다. 프로퍼티명을 변경하는데 사용된다. 이 어노테이션의 기본 파라미터인 value의 값을 지정하면 직렬화 또는 역직렬화할 JSON 객체의 필드명을 다른 이름으로 지정할 수 있다.


동적 프로퍼티 매핑

JSON 구조의 문자열 데이터를 직렬화 및 역직렬화하는 경우 데이터 구조가 변경되지 않는다면 ObjectMapperwriteValueAsString() 메서드의 인자로 객체를 전달하거나 readValue() 메서드의 인자로 JSON 문자열과 자바 객체를 인자로 전달하여 객체와 JSON 문자열 간 직렬화 및 역직렬화 수행할 수 있다.

JSON 데이터 구조가 변경 가능한 동적(가변) 구조인 경우 변경되는(또는 변경될지 알 수 없는) 구조를 자바 객체로 정상적으로 매핑하기 위해 고정되는 프로퍼티 외에 변경되는 프로퍼티를 com.fasterxml.jackson.databind.JsonNode 객체로 매핑하거나 Map<String, Object> 타입의 맵 구조로 매핑하면 된다. 이때 매핑은 Map<String, Object>의 경우 객체 구조에 대해서만 가능하다. JSON 데이터의 가장 바깥 구조가 객체 구조이면서 동적이라면 매핑할 객체에 하나의 프로퍼티를 동적 매핑 타입으로 선언한다.

// 동적 구조
{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}
class Response {
  JsonNode jsonNode; 
}
class Response {
  Map<String, Object> map; 
}


가장 바깥 구조가 아닌 내부 프로퍼티가 객체 구조이고 동적이라면 해당 프로퍼티만 동적 매핑 타입으로 선언한다.

{
  "key1": "value1",
  // 동적 구조
  "key2": {
    "key3": "value3",
    "key4": "value4",
    "key5": "value5"
  },
  "key6": "value6"
}
class Response {
  String key1;
  JsonNode jsonNode;
  String key6;
}
class Response {
  String key1;
  Map<String, Object> map;
  String key6;
}


객체의 프로퍼티 타입으로 JsonNode를 사용할 경우 자바 객체는 잭슨 라이브러리에 대한 의존성이 생기게 된다는 단점이 있다.

JSON 구조에서 동적 프로퍼티의 개수가 몇 개인지 모른다면 한 개 이상의 동적 프로퍼티를 맵 구조로 매핑하기 위해 @JsonAnySetter 어노테이션을 사용하여 동적 매핑 처리를 할 수 있다.

{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}
class Response {
  String key1;
  Map<String, String> map;
  
  @JsonAnySetter
  public void map(String key, String value) {
    map.put(key, string);
  }
}


널 값 처리


역직렬화 시 주의점

JSON 프로퍼티가 빈 문자열일 때, 또는 빈 배열일 때 자바 객체의 프로퍼티를 널 값으로 매핑할지 결정이 필요하다. 널 값을 비즈니스 상 의미있는 값으로 처리할 것이라면 역직렬화 시 해당 프로퍼티가 널 값이 될 수 있도록 처리하고 해당 프로퍼티 사용 시 널 역참조로 인한 예외가 발생하지 않도록 주의해야 한다.

역직렬화할 JSON 구조가 변경되는 경우 동적 매핑 처리를 사용하여 JsonNode나 맵 구조 형태로 객체를 구성할지, 커스텀 역직렬화 클래스를 정의하여 변경된 구조를 자바 객체로 역직렬화 되도록 할지 결정이 필요하다.


잭슨 코틀린 모듈

잭슨 라이브러리의 코틀린 지원 모듈(jackson-module-kotlin)링크은 코틀린 언어에서 클래스와 데이터 클래스의 직렬화 및 역직렬화를 위한 잭슨 모듈이다.

코틀린 컴파일러는 클래스나 데이터 클래스에 정의된 프로퍼티에 대해 자동으로 getter와 setter 메서드를 정의한다. XXX라는 이름의 val 프로퍼티에 대해 getXXX() getter가 자동으로 생성되며, var 프로퍼티에 대해서는 getXXX() getter와 setXXX() setter가 자동으로 생성된다. is라는 접두어가 붙은 프로퍼티명의 경우 다른 매핑 규칙이 사용되며 getter의 이름은 프로퍼티명과 동일하고 setter의 경우 isset으로 대체된다. 이 규칙은 부울 타입 뿐만 아니라 모든 타입의 프로퍼티에 적용된다.

잭슨 라이브러리도 위와 같은 방식으로 자바의 기본 빈 명명 규칙을 사용하여 프로퍼티를 처리한다. 2.11 이전 버전의 경우 이로 인해 is로 시작하는 부울 타입의 프로퍼티명의 경우 is 접두어가 제거된 XXX 필드를 대상으로 직렬화 및 역직렬화 처리된다. 코드에 정의한 isXXX 그대로 JSON의 필드로 정의하는 것이 의도된 경우 @get:JsonProperty 또는 @set:JsonProperty 어노테이션을 사용해야 한다.

2.15 이전 버전에서는 is로 시작하는 부울 타입 외의 타입의 프로퍼티가 직렬화 처리 대상에서 제외되는 이슈가 존재한다링크.


SpringDoc

코틀린에서 객체의 멤버변수 선언 시 var를 사용하는 경우 스웨거 UI 상에 요청 및


isXXX 테스트

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException;
import lombok.Data;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class JacksonJavaTest {
    @Data
    @JsonPropertyOrder({"true1", "true2", "isTrue3"})
    static class TestClass1 {
        boolean isTrue1;
        boolean isTrue2;
        String isTrue3;

        TestClass1() {
        }

        TestClass1(boolean isTrue1, boolean isTrue2, String isTrue3) {
            this.isTrue1 = isTrue1;
            this.isTrue2 = isTrue2;
            this.isTrue3 = isTrue3;
        }
    }

    static class TestClass2 {
        @JsonProperty(index = 1)
        boolean isTrue1;
        @JsonProperty(index = 2)
        boolean isTrue2;
        @JsonProperty(index = 3)
        String isTrue3;

        TestClass2() {
        }

        TestClass2(boolean isTrue1, boolean isTrue2, String isTrue3) {
            this.isTrue1 = isTrue1;
            this.isTrue2 = isTrue2;
            this.isTrue3 = isTrue3;
        }

        public boolean getIsTrue1() {
            return this.isTrue1;
        }

        public boolean getIsTrue2() {
            return this.isTrue2;
        }

        public String getIsTrue3() {
            return this.isTrue3;
        }

        public void setIsTrue1(boolean isTrue1) {
            this.isTrue1 = isTrue1;
        }

        public void setIsTrue2(boolean isTrue2) {
            this.isTrue2 = isTrue2;
        }

        public void setIsTrue2(String isTrue3) {
            this.isTrue3 = isTrue3;
        }
    }

    @Test
    void serializationTest() throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        TestClass1 testClass1 = new TestClass1(false, true, "false");
        TestClass2 testClass2 = new TestClass2(false, true, "false");
        String serialized1 = objectMapper.writeValueAsString(testClass1);
        Assertions.assertEquals("{\"true1\":false,\"true2\":true,\"isTrue3\":\"false\"}", serialized1);
        String serialized2 = objectMapper.writeValueAsString(testClass2);
        Assertions.assertEquals("{\"isTrue1\":false,\"isTrue2\":true,\"isTrue3\":\"false\"}", serialized2);
    }

    @Test
    void deserializationTest() throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        String jsonStr = "{\"isTrue1\":false,\"isTrue2\":false,\"isTrue3\":\"true\"}";
        Assertions.assertThrows(
            UnrecognizedPropertyException.class,
            () -> objectMapper.readValue(jsonStr, TestClass1.class)
        );
        Assertions.assertDoesNotThrow(
            () -> objectMapper.readValue(jsonStr, TestClass2.class)
        );
    }
}


참고

Comments