본문 바로가기
JAVA

Jackson Library 이해하기(Feat. Jackson NullPointerException)

by 열정적인 이찬형 2024. 5. 4.

[상황]

기존 코드 유지 보수 중, 수정 API 1개가 500 Error가 뜨고 있었습니다.

 

 

PostMan으로 API 호출을 진행하였을 때, NullpointException이 발생하였지만, DB에는 데이터가 변경된 것을 확인하였습니다.

 

API 구조

※ 참, Hexagonal Architecture으로 구성되어 있습니다.

 

DB에 데이터가 변경되었다?

→ Business Layer가 아닌 InBound Layer에서는 잘못되었다는 것이라고 생각하고 접근하기 시작하였습니다.

 

InBound Layer을 생각하고, 코드 분석 및 디버깅을 돌렸을 때 Domain 객체를 그대로 응답으로 출력하고 있는 것을 확인되었습니다.

 

get(Ex. getSearch)으로 시작하는 비즈니스 로직 함수가 존재하였으며, 해당 함수가 호출되면서 NullPointerException이 발생하고 있었습니다.

 

//임의의 Domain 객체 임의 예시 코드입니다.

public class Domain{

 private String id;
 private List<String> values;
 ....


 public List<String> getSearch(){
		return values.stream().....;
 }
}

 

해당 비즈니스 함수를 어디서 호출하는 지 확인하였을 때 Jackson 내부 코드에서 호출하는 것을 확인하게 되었습니다.

 

Jackson Library???

 

Spring에 포함된 Library로 API을 요청 및 응답할 때 JSON 구조 형식으로 데이터를 처리해주는 것이 아닌가??\

 

찾아본 결과로 JSON 데이터로 변환할 때 Getter을 모두 호출하며, 함수 명에 get으로 시작하는 모든 함수를 Getter로 인식하고 있었습니다.

 

즉, Domain의 get으로 시작하는 비즈니스 로직이 Getter로 인식하고 호출되면서, Stream()에 대한 NullPointerException이 발생하고 있었습니다.

 

※ 수정 API에 대한 오류는 Business Layer에 Domain이 InBound Layer에 영향을 끼치면 안되기 때문에 Response에 대한 객체를 만들어주어서 해결하였습니다.

 

 

이 경험으로 저는 Jackson Library에 대해서 알고있던 지식이 위 내용과 어노테이션 몇 개만 알고 있었던 것을 깨닫게 되었습니다.

 

→ 추후, 비슷한 문제가 생기면 다른 분들에게 정확한 이유와 개념을 설명해줘야겠다는 의욕이 생기면서 Jackson Library에 대해서 알아보게 되었습니다.


[Jackson Libary]

 

[관련 링크]

 

GitHub - FasterXML/jackson: Main Portal page for the Jackson project

Main Portal page for the Jackson project. Contribute to FasterXML/jackson development by creating an account on GitHub.

github.com

 

jackson-databind 2.17.0 javadoc (com.fasterxml.jackson.core)

Latest version of com.fasterxml.jackson.core:jackson-databind https://javadoc.io/doc/com.fasterxml.jackson.core/jackson-databind Current version 2.17.0 https://javadoc.io/doc/com.fasterxml.jackson.core/jackson-databind/2.17.0 package-list path (used for ja

javadoc.io

 

Jackson GitHub에서 설명하는 내용을 번역하면

 

Jackson은 "Java JSON 라이브러리" 또는 "Java의 최고의 JSON 파서"로 알려져 있습니다. 또는 간단히 "Java를 위한 JSON"으로 알려져 있습니다.

 

그 이상으로, Jackson은 Java(및 JVM 플랫폼)를 위한 데이터 처리 도구 모음이며, 이에는 플래그십 스트리밍 JSON 파서/생성기 라이브러리, 매칭 데이터 바인딩 라이브러리(JSON → POJO, POJO → JSON) 및 Avro, BSON, CBOR, CSV, Smile, (Java) Properties, Protobuf, TOML, XML 또는 YAML로 인코딩된 데이터를 처리하는 추가 데이터 형식 모듈이 포함됩니다. 또한 Guava, Joda, PCollections 등 널리 사용되는 데이터 타입을 지원하는 대량의 데이터 형식 모듈도 있습니다.

 

[원문]

더보기

Jackson has been known as "the Java JSON library" or "the best JSON parser for Java". Or simply as "JSON for Java".

 

More than that, Jackson is a suite of data-processing tools for Java (and the JVM platform), including the flagship streaming JSON parser / generator library, matching data-binding library (POJOs to and from JSON) and additional data format modules to process data encoded in Avro, BSON, CBOR, CSV, Smile, (Java) Properties, Protobuf, TOML, XML or YAML; and even the large set of data format modules to support data types of widely used data types such as Guava, Joda, PCollections and many, many more (see below).

 

JSON 파싱을 하기 위해서 항상 Jackson을 사용해야 하는가??

 

Jackson을 사용하지 않고 2가지의 방향으로 접근이 가능합니다.

 

1. 직접 JSON 형식의 데이터를 String으로 보내기

 

응답으로 보내야 하는 JSON Data

{
	"name":"홍길동",
	"age":"22"
}

 

class Person{
	private String name;
	private Integer age;
	
	public String getName(){
		return this.name;
	}
	public Integer getAge(){
		return this.age;
	}
}
-----------------------------

String response = "{\"name\" : \"" 
		+ person.getName() 
		+ "\",\"age\":\"" 
		+ person.getAge() 
		+ "\"}";

 

위와 같이 작성할 수 있지만, 각각 응답마다 JSON 형식의 String을 만드는 것은 말도 안되는 일입니다.

→ 더 나은 방법으로 Reflection을 이용해서 Mapper 함수를 만들어서 적용할 수도 있습니다.

 

2. 다른 JSON Parse Library을 사용하기

 

다른 Json Parse Library의 대표적으로 GSON가 존재합니다.

 

Gson gson = new Gson();

Person person = new Person("홍길동", 22);

String json = gson.toJson(person);

 

그러면, 다른 Library을 사용해도 되는 것인데, Jackson을 사용하는 것이 더 좋은 것인가??

 

Jackson Library에서는 대량의 데이터 형식 모듈(Guava, Joda, PCollections 등)도 지원하기 때문에 대용량 데이터를 처리하는데 효율적입니다.

 

환경에 따른 성능 테스트를 통해서 성능이 달라질 수는 있지만, 대용량 데이터가 아닌 경우 성능상의 차이는 크게 나지 않습니다.

 

Spring에서 Jackson을 사용해야 하는가??

 

[성능]

 

Jackson은 대량의 데이터를 처리하는 데 있어서 GSON보다 빠른 성능을 보여주는 경향이 있습니다.

 

Jackson : Stream API을 사용하여 데이터를 처리하기 때문에, JSON 데이터를 한 번에 전체를 메모리에 로드하지 않고 스트림으로부터 점진적으로 데이터를 읽어서 처리한다.

 

※Guava, Joda, PCollections 등 널리 사용되는 데이터 타입을 지원하는 대량의 데이터 형식 모듈도 지원합니다.

 

GSON : Tree Model을 통해서 데이터를 처리하기 때문에, JSON 데이터를 한 번에 메모리에 로드하여 처리합니다.

 

[유연성]

 

Spring 3.0부터는 Jackson에 대한 API가 추가되어서, Jackson을 사용하면 요청, 응답에 대한 JSON을 객체 형태로 자동화가 가능하게 되었습니다.

 

또한, Jackson은 어노테이션과 자동적으로 MessageConverter가 등록되어 따로 설정 및 코드를 작성할 필요없이 사용이 가능합니다.

 

Jackson : @JsonSerialize, @JsonDeserialize 등 어노테이션을 사용하여 사용자 정의 직렬화 및 역직렬화, 직렬화를 제어, 동적 필터링을 수행할 수 있습니다.

 

GSON : TypeAdapter, JsonSeriallizer, JsonDeserializer커스텀 직렬화 및 역직렬화를 수행할 수 있으며, JSON과 객체 사이의 변환 과정을 세밀하게 제어가 가능합니다.

 

Spring에서는 Jackson의 성능의 이점과 유연성을 기반으로 JSON Parse Library로 주로 사용되고 있습니다.

 


[Jackson 동작 방식]

 

✭ Custom이 아닌 Default MappingJackson2HttpMessageConverter을 기반으로 설명할 것입니다.

 

먼저, 대략적인 동작방식을 설명하겠습니다.

어노테이션(@JsonIgnore.. )이 사용되지 않았다고 가정하겠습니다.

 

Request : JSON → 객체

 

1. HTTP 요청의 body 부분을 읽어 JSON 문자열을 얻습니다.

2. ObjectMapper를 사용하여 JSON 데이터를 Java 객체로 역직렬화합니다.

→ 파라미터의 맞는 인수가 존재하지 않으면 기본 생성자를 호출합니다.

Reflection을 이용해서 객체 안에 Getter, Setter에 대한 데이터에 대한 필드에 데이터를 변경합니다.

3. 역직렬화된 Java 객체를 반환합니다.

Response : 객체 → JSON

 

1. ObjectMapper를 사용하여 Java 객체를 JSON 데이터로 직렬화합니다.

→ Reflection을 이용해서 객체 안에 Getter을 모두 호출한 결과 값을 ObjectMapper에 저장합니다.

2. 직렬화된 JSON 데이터를 HTTP 응답 본문에 작성합니다.

→ ObjectMapper에서 저장된 데이터를 기반으로 writeAsString()을 통해서 JSON을 만듭니다.

3. 직렬화된 JSON을 반환합니다.

 

 

세부 동작 확인하기

 

[MappingJackson2HttpMessageConverter]

 

package org.springframework.http.converter.json;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;

public class MappingJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
    @Nullable
    private String jsonPrefix;

    //Jackson Library 사용할 ObejctMapper을 Build()한다.
    public MappingJackson2HttpMessageConverter() {
        this(Jackson2ObjectMapperBuilder.json().build());
    }

    public MappingJackson2HttpMessageConverter(ObjectMapper objectMapper) {
        super(objectMapper, new MediaType[]{MediaType.APPLICATION_JSON, new MediaType("application", "*+json")});
    }

    public void setJsonPrefix(String jsonPrefix) {
        this.jsonPrefix = jsonPrefix;
    }

    public void setPrefixJson(boolean prefixJson) {
        this.jsonPrefix = prefixJson ? ")]}', " : null;
    }

    protected void writePrefix(JsonGenerator generator, Object object) throws IOException {
        if (this.jsonPrefix != null) {
            generator.writeRaw(this.jsonPrefix);
        }

    }
}

 

주요 메서드

 

canRead(Class<?>, MediaType): 주어진 클래스 타입의 객체를 읽을 수 있는지 (즉, JSON에서 해당 타입으로 변환할 수 있는지) 확인합니다.

 

[AbstractHttpMessageConverter]

    protected boolean canRead(@Nullable MediaType mediaType) {
        if (mediaType == null) {
            return true;
        } else {
            Iterator var2 = this.getSupportedMediaTypes().iterator();

            MediaType supportedMediaType;
            do {
                if (!var2.hasNext()) {
                    return false;
                }

                supportedMediaType = (MediaType)var2.next();
            } while(!supportedMediaType.includes(mediaType));

            return true;
        }
    }

 

현재 지원하는 MediaType 중에서 주어진 클래스 타입이 존재하는지 확인.

 

[AbstractJackson2HttpMessageConverter]

    public boolean canRead(Type type, @Nullable Class<?> contextClass, @Nullable MediaType mediaType) {
        if (!this.canRead(mediaType)) {
            return false;
        } else {
            JavaType javaType = this.getJavaType(type, contextClass);
            ObjectMapper objectMapper = this.selectObjectMapper(javaType.getRawClass(), mediaType);
            if (objectMapper == null) {
                return false;
            } else {
                AtomicReference<Throwable> causeRef = new AtomicReference();
                if (objectMapper.canDeserialize(javaType, causeRef)) {
                    return true;
                } else {
                    this.logWarningIfNecessary(javaType, (Throwable)causeRef.get());
                    return false;
                }
            }
        }
    }

 

ObjectMapper.canDeserialize()을 통해서 해당 .class에 대해서 역직렬화 할 수 있는지 확인합니다.

 

canWrite(Class<?>, MediaType): 주어진 클래스 타입의 객체를 쓸 수 있는지 (즉, 해당 타입의 객체를 JSON으로 변환할 수 있는지) 확인합니다.

canRead()와 매우 유사하며, ENCODING이 되는 charset인지 확인하는 과정이 추가됩니다.

 

read(Class<? extends T>, HttpInputMessage): HTTP 입력 메시지를 주어진 타입의 객체로 읽습니다.

 

[AbstractJackson2HttpMessageConverter]

 

    private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) throws IOException {
        MediaType contentType = inputMessage.getHeaders().getContentType();
        Charset charset = this.getCharset(contentType);
        ObjectMapper objectMapper = this.selectObjectMapper(javaType.getRawClass(), contentType);
        Assert.state(objectMapper != null, () -> {
            return "No ObjectMapper for " + javaType;
        });
        boolean isUnicode = ENCODINGS.containsKey(charset.name()) || "UTF-16".equals(charset.name()) || "UTF-32".equals(charset.name());

        try {
            InputStream inputStream = StreamUtils.nonClosing(inputMessage.getBody());
            if (inputMessage instanceof MappingJacksonInputMessage mappingJacksonInputMessage) {
                Class<?> deserializationView = mappingJacksonInputMessage.getDeserializationView();
                if (deserializationView != null) {
                    ObjectReader objectReader = objectMapper.readerWithView(deserializationView).forType(javaType);
                    objectReader = this.customizeReader(objectReader, javaType);
                    if (isUnicode) {
                        return objectReader.readValue(inputStream);
                    }

                    Reader reader = new InputStreamReader(inputStream, charset);
                    return objectReader.readValue(reader);
                }
            }

            ObjectReader objectReader = objectMapper.reader().forType(javaType);
            objectReader = this.customizeReader(objectReader, javaType);
            if (isUnicode) {
                return objectReader.readValue(inputStream);
            } else {
                Reader reader = new InputStreamReader(inputStream, charset);
                return objectReader.readValue(reader);
            }
        } catch (InvalidDefinitionException var12) {
            throw new HttpMessageConversionException("Type definition error: " + var12.getType(), var12);
        } catch (JsonProcessingException var13) {
            throw new HttpMessageNotReadableException("JSON parse error: " + var13.getOriginalMessage(), var13, inputMessage);
        }
    }

 

read()을 실행하면 파라미터가 다르더라도 결국 위에 method()을 실행하게 되어있습니다.

 

1. Encoding-Type, Unicode 확인 및 ObjectMapper 선택

2. 특정 DeserializationView을 사용할 때에는 해당 View를 사용하여 역직렬화 진행

    - 어노테이션으로 필터링 및 형식 변경시 사용

3. 특정 변경되는 점이 없으면 Default형식으로 역직렬화 진행

 

역직렬화 → ObjectReader.readValue()

ObejctReader : Jackson의 존재하는 ObjectMapper기반으로 생성된 ObjectReader

 

write(T, MediaType, HttpOutputMessage): 주어진 객체를 HTTP 출력 메시지로 씁니다.

 

[AbstractJackson2HttpMessageConverter]

    protected void writeInternal(Object object, @Nullable Type type, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
        MediaType contentType = outputMessage.getHeaders().getContentType();
        JsonEncoding encoding = this.getJsonEncoding(contentType);
        Class var10000;
        if (object instanceof MappingJacksonValue mappingJacksonValue) {
            var10000 = mappingJacksonValue.getValue().getClass();
        } else {
            var10000 = object.getClass();
        }

        Class<?> clazz = var10000;
        ObjectMapper objectMapper = this.selectObjectMapper(clazz, contentType);
        Assert.state(objectMapper != null, () -> {
            return "No ObjectMapper for " + clazz.getName();
        });
        OutputStream outputStream = StreamUtils.nonClosing(outputMessage.getBody());

        try {
            JsonGenerator generator = objectMapper.getFactory().createGenerator(outputStream, encoding);

            try {
                this.writePrefix(generator, object);
                Object value = object;
                Class<?> serializationView = null;
                FilterProvider filters = null;
                JavaType javaType = null;
                if (object instanceof MappingJacksonValue mappingJacksonValue) {
                    value = mappingJacksonValue.getValue();
                    serializationView = mappingJacksonValue.getSerializationView();
                    filters = mappingJacksonValue.getFilters();
                }

                if (type != null && TypeUtils.isAssignable(type, value.getClass())) {
                    javaType = this.getJavaType(type, (Class)null);
                }

                ObjectWriter objectWriter = serializationView != null ? objectMapper.writerWithView(serializationView) : objectMapper.writer();
                if (filters != null) {
                    objectWriter = objectWriter.with(filters);
                }

                if (javaType != null && javaType.isContainerType()) {
                    objectWriter = objectWriter.forType(javaType);
                }

                SerializationConfig config = objectWriter.getConfig();
                if (contentType != null && contentType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM) && config.isEnabled(SerializationFeature.INDENT_OUTPUT)) {
                    objectWriter = objectWriter.with(this.ssePrettyPrinter);
                }

                objectWriter = this.customizeWriter(objectWriter, javaType, contentType);
                objectWriter.writeValue(generator, value);
                this.writeSuffix(generator, object);
                generator.flush();
            } catch (Throwable var17) {
                if (generator != null) {
                    try {
                        generator.close();
                    } catch (Throwable var16) {
                        var17.addSuppressed(var16);
                    }
                }

                throw var17;
            }

            if (generator != null) {
                generator.close();
            }

        } catch (InvalidDefinitionException var18) {
            throw new HttpMessageConversionException("Type definition error: " + var18.getType(), var18);
        } catch (JsonProcessingException var19) {
            throw new HttpMessageNotWritableException("Could not write JSON: " + var19.getOriginalMessage(), var19);
        }
    }

 

write()을 실행하면 파라미터가 다르더라도 응답에 대한 Header, HttpOutputMessage을 설정한 뒤 writeInternal()을 호출하게 됩니다.

 

1. Content-Type 헤더 설정, 및 Encoding 설정

2. MappingJacksonValue일 때 실제 값을 사용 및 뷰/필터 등 정보 가져오기

    - 어노테이션으로 필터링 및 형식 변경시 사용

3. ObjectWriter을 통해 객체를 JSON 으로 변환

SSE(MediaType.TEXT_EVENT_STREAM)호환되는 경우 ssePrettyPrinter를 사용하여 JSON 정돈

4. CustomWriter가 존재할 경우 추가적으로 Custom 진행 후 body()에 flush()한 뒤 close()합니다.

 

직렬화 → ObjectWriter.writeValue()

ObjectWriter : Jackson의 존재하는 ObjectMapper기반으로 생성된 ObjectWriter

✭ writeValue()를 통해서 객체에 정보를 ObjectWriter에 저장할 때에는 모든 Getter을 호출해서 ObjectWriter에 저장합니다.

 

즉, 여기서 모든 Getter을 호출하기 때문에 응답 객체에 Getter을 구성할 때 고려해야 합니다.

get…()으로 시작하는 method는 모두 Getter로 인식한다.

 

[Jackson2ObjectMapperBuilder]

 

Jackson에서 직렬화, 역직렬화를 진행하기 위해서 사용되는 ObjectMapper입니다.

 

Jackson2ObjectMapperBuilder (Spring Framework 6.1.6 API)

postConfigurer An option to apply additional customizations directly to the ObjectMapper instances at the end, after all other config properties of the builder have been applied. Parameters: configurer - a configurer to apply. If several configurers are re

docs.spring.io

 

    public static Jackson2ObjectMapperBuilder json() {
        return new Jackson2ObjectMapperBuilder();
    }

    public static Jackson2ObjectMapperBuilder xml() {
        return (new Jackson2ObjectMapperBuilder()).createXmlMapper(true);
    }

    public static Jackson2ObjectMapperBuilder smile() {
        return (new Jackson2ObjectMapperBuilder()).factory((new SmileFactoryInitializer()).create());
    }

    public static Jackson2ObjectMapperBuilder cbor() {
        return (new Jackson2ObjectMapperBuilder()).factory((new CborFactoryInitializer()).create());
    }

 

데이터 포멧(Json, Xml, Smile, Cbor)에 따른 Jackson2ObjectMapperBuilder을 만들어 주고 있습니다.

 

    public void configure(ObjectMapper objectMapper) {
    ...
            if (this.moduleClasses != null) {
            Class[] var3 = this.moduleClasses;
            int var4 = var3.length;

            for(int var5 = 0; var5 < var4; ++var5) {
                Class<? extends Module> moduleClass = var3[var5];
                this.registerModule((Module)BeanUtils.instantiateClass(moduleClass), modulesToRegister);
            }
    ...
        if (this.dateFormat != null) {
            objectMapper.setDateFormat(this.dateFormat);
        }

        if (this.locale != null) {
            objectMapper.setLocale(this.locale);
        }
    ...
    
    }

 

configure()함수에서는 Jackson에서 사용하는 ObjectMapper 옵션들을 정의해주고 있습니다.

 

옵션 : dateFormat, Timezone, locale, AnnotationIntrospector, PropertyNamingStrategy 등

※ Docs에서 각 옵션들을 확인하실 수 있습니다.

 

Jackson2ObjectMapperBuilder.build()을 호출하면 configure()을 호출하게 되어 있습니다.

즉, build()을 진행하였을 때 지금까지의 Jackson2ObjectMapperBuilder의 옵션들을 토대로 ObjectMapper만들어주고 있습니다.


[테스트]

 

import lombok.Getter;

import java.util.List;

@Getter
public class Temp {
    String name;
    int age;
    List<String> friends;

    public Temp(String name, int age, List<String> friends) {
        this.name = name;
        this.age = age;
        this.friends = friends;
    }

    public String getFriendsName(){
        return "FriendsName";
    }

}
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/test")
public class TestController {



    @GetMapping("/contain/getter")
    public Temp readTemp(){
        return new Temp("홍길동", 20, List.of("ho", "lee"));
    }

    @PostMapping("/default/constructor")
    public Temp constructorTemp(@RequestBody Temp temp){
        return temp;
    }
}

 

[Jackson은 Getter을 모두 호출한다.]

 

/test/contain/getter

 

Temp.class에서 getFriendsName()가 Getter로 인식되어서 Jackson에서 객체를 JSON으로 바꿀 때 friendsName이 포함되어서 나오는 것을 확인하실 수 있습니다.

 

Getter을 모두 호출한다!!!

Getter을 호출할 때 NullPointerException이 나올 수 있습니다.

getFriendsName()을 아래와 같이 수정하고, Temp 내에 friends가 null일 때

 

    public List<String> getFriendsName(){
        return friends.stream().map(friend -> friend + "님").toList();
    }
    
    
    -------------------------------------------------------------
    
    @GetMapping("/contain/getter")
    public Temp readTemp(){
        return new Temp("홍길동", 20, null);
    }

 

Temp.class에서 getFriendsName()을 호출했지만, Stream()을 진행할 때 friends가 null이기 때문에 Jackson에서

객체 → JSON으로 변환할 때 NullPointerException이 발생한 것입니다.

 

[Jackson은 요청에 대해서 Construtor을 통해서 객체를 생성한다.]

 

Temp.class의 Constructor을 아래와 같이 있을 경우

    public Temp(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
    
    public Temp() {}

 

JSON Request을 보냈을 때

{
    "name" : "홍길동",
    "age" : "20",
    "friends" : ["hey", "ho"]
}

 

{name, age}을 사용하는 생성자를 사용한 뒤, Setter, Getter에 대한 필드명과 JSON Key에 대한 Value값을 구해서 변경합니다.

 

만약 아무 생성자가 존재하지 않을 경우

import lombok.Getter;

import java.util.List;

@Getter
public class Temp {
    private  String name;
    private Integer age;
    private List<String> friends;

    public String getFriendsName(){
        return "FriendsName";
    }
}

 

기본 생성자를 만들어서 사용됩니다.

✭ 주의

파라미터가 1개인 생성자만 사용하였을 때에는 BadRequestException 발생합니다.

※ Jackson에 대한 생성자를 인식하는 어떤 행위도 취하지 않았을 경우.

 

import lombok.Getter;

import java.util.List;

@Getter
public class Temp {
    private  String name;
    private Integer age;
    private List<String> friends;

    public Temp(String name) {
        this.name = name;
    }

    public String getFriendsName(){
        return "FriendsName";
    }

}

 

해당 문제는 생성자의 모호함으로 인하여 발생합니다.

 

해당 생성자를 통해서

"string-value"

{"name" : "value"}

 

2가지 중 어느 것이 정확한지 선택해야하는 모호함이 발생합니다.

모호한 생성자를 선택할 바에, 기본 생성자를 선택하게 됩니다.

 

하지만, 기본 생성자가 존재하지 않기 때문에 BadRequestException이 나타나게 되는 것입니다.

아래와 같이 기본 생성자를 추가해서 진행하면 정상적으로 출력하는 것을 확인하실 수 있습니다.

 

import lombok.Getter;

import java.util.List;

@Getter
public class Temp {
    private  String name;
    private Integer age;
    private List<String> friends;
    
    public Temp(String name) {
        this.name = name;
    }

    public Temp() {
    }

    public String getFriendsName(){
        return "FriendsName";
    }

}

 

1개의 필드에 대한 생성자만 사용하고 싶다면 @JsonCreator을 통해 생성자를 인식하는 Mode를 정의해줍니다.

    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public Temp(String name) {
        this.name = name;
    }

 

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)을 사용하여 속성값으로 인식하도록 설정할 수 있습니다.

 

관련 이슈 링크

 

Jackson fails to deserialize 1 field POJO · Issue #3085 · FasterXML/jackson-databind

Describe the bug When trying to deserialize JSON to POJO it fails when POJO has only one field. Version information 2.11.4 To Reproduce POJO: @AllArgsConstructor //lombok, if i add constructor manu...

github.com

 

의문점

 

Temp.class는 필드가 3개인데 생성자는 2개의 파라미터만 받고 있습니다.

 

그러면, 남은 1개의 필드는 어떻게 데이터가 반영되어 있는 것인가??

 

ObjectMapper생성자로 객체를 생성한 뒤, Reflection을 통해서 Getter, Setter을 모두 불러와서 그에 해당하는 값이 JSON에 존재하는지 확인하고 존재하면 값을 변경해줍니다.

 

getFriendName()이라는 Getter가 있으면, ‘friendName’ 이라는 필드가 존재하면 JSON 값과 매칭시켜서 데이터를 변경시켜줍니다.

 

import java.util.List;


public class Temp {
    String name;
    private Integer age;
    private List<String> friends;

    public Temp(Integer age, List<String> friends) {
        this.age = age;
        this.friends = friends;
    }

    public Integer getAge() {
        return age;
    }

    public List<String> getFriends() {
        return friends;
    }

    public String getFriendsName(){
        return "FriendsName";
    }

}
    @PostMapping("/default/constructor")
    public Temp failTemp(@RequestBody Temp temp){
        log.info("Temp name Value is {}", temp.name);
        return temp;
    }

 

필드 name은 Getter, Setter가 모두 존재하지 않고, 생성자 파라미터에도 존재하지 않습니다. API을 호출해서 log에 대한 결과를 살펴보겠습니다.

 

생성자로 객체를 생성한 뒤, Getter, Setter을 탐색할 때 name 필드와 관련된 것이 없기 때문에 null으로 데이터가 변경되지 않은 것을 확인할 수 있습니다.

 

Setter만 추가해보도록 하겠습니다.

import java.util.List;


public class Temp {
      String name;
    private Integer age;
    private List<String> friends;

    public Temp(Integer age, List<String> friends) {
        this.age = age;
        this.friends = friends;
    }

    public Integer getAge() {
        return age;
    }

    public List<String> getFriends() {
        return friends;
    }

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

    public String getFriendsName(){
        return "FriendsName";
    }

}

 

Getter가 없기 때문에 API 응답값으로는 전달되지 않지만, Setter을 통해서 name필드를 찾을 수 있기 때문에 Request에 대한 JSON 값이 Temp에 반영된 것을 확인하실 수 있습니다.


[Jackson Annotation]

@JsonCreator

JSON → 객체로 역직렬화를 진행할 때, 사용할 생성자를 지정합니다.

    public Temp(String name, Integer age, List<String> friends) {
        this.name = name;
        this.age = age;
        this.friends = friends;
    }
    
    @JsonCreator
    public Temp(Integer age, List<String> friends) {
        this.age = age;
        this.friends = friends;
    }

 

@JsonIgnore

직렬화, 역직렬화를 진행할 때 해당 필드를 무시하고 진행하라고 정의합니다.

    @JsonIgnore
    private String name;

    private Integer age;

    private List<String> friends;

 

@JsonGetter

필드에 대해서 Jackson이 Getter로 인식하도록 정의합니다.

    @JsonGetter("name")
    public String imNotGetter() {
        return name;
    }

 

@JsonSetter

필드에 대해서 Jackson이 Setter로 인식하도록 정의합니다.

    @JsonSetter("name")
    public void imNotSetter(String name) {
        this.name = name;
    }

 

@JsonValue

Jackson이 EnumResponse으로 전달할 때 Getter로 인식할 Method을 설정합니다.

import com.fasterxml.jackson.annotation.JsonValue;

public enum TempEnum {

    ONE(1, "one"), TWO(2, "two"), THREE(3, "three");

    private Integer id;
    private String name;

    TempEnum(Integer id, String name) {
        this.id = id;
        this.name = name;
    }
    
    @JsonValue
    public String getName() {
        return name;
    }
}

 

@JsonSerialize

객체 → JSON으로 직렬화를 진행할 때 수행할 Custom Serialize을 지정해주기 위해 사용합니다.

public class TempDataSerializer extends JsonSerializer<Date> {
    
    private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Override
    public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        gen.writeString(dateFormat.format(value));
    }
}
    @JsonSerialize(using = TempDataSerializer.class)
    private Date data;

 

 

@JsonDeserialize

JSON → 객체으로 역직렬화를 진행할 때 수행할 Custom Deserialize을 지정해주기 위해 사용합니다.

public class TempDataDeserializer  extends JsonDeserializer<Date> {
    private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Override
    public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException {
        String date = p.getText();
        try {
            return dateFormat.parse(date);
        } catch (ParseException e) {
            throw new RuntimeException(e);
        }
    }
}
    @JsonDeserialize(using = TempDataDeserializer.class)
    private Date data;

 

다양한 Jackson Annotation에 대해서는 아래 링크에서 더 자세히 살펴보실 수 있습니다.

 

https://www.baeldung.com/jackson-annotations


[정리]

Jackson은 Spring에서 Default Json Parse으로 사용되고 있다.

 

이유 : 대규모 데이터 효율적 처리(성능), 어노테이션 및 자동 정의(유연성)이 존재하기 때문입니다.

 

또한, ObjectMapper을 통해서 직렬화, 역직렬화가 진행됩니다.

→ 생성자를 통해서 객체를 생성한 뒤, Reflection으로 데이터를 반영하며, 반영되는 데이터는 Getter, Setter을 통해서 얻습니다.

→ 역직렬화를 진행할 때에도 Getter을 기준으로 JSON을 만들기 때문에, Getter을 신경써주어야 합니다.

 

이 전체적인 내용들을 정리해서 1마디로 전달하고자 한 핵심!!!

 

Jackson으로 직렬화, 역직렬화를 진행할 때에는 Getter을 고려해야 합니다!!

댓글