티스토리 뷰

Spring

[Spring] ObjectMapper 동작 방식

DevBee 2023. 11. 21. 00:14

기존에 JSON을 객체로 변경하면서 custom 하게 deserialize/serialize 할 일이 있어서 Object Mapper를 빈 등록하여 사용하였습니다. 이렇게 작성을 하고 나니 Object Mapper가 어떻게 동작하는지 꼼꼼하게 알 필요가 있을 것 같아 해당 내용을 정리하려고 합니다.

 

Object Mapper 란?

JSON 컨텐츠를 Java 객체로 deserialization 하거나 Java 객체를 JSON으로 serialization 할 때 사용하는 Jackson 라이브러리의 클래스입니다.

 

Jackson 라이브러리는 다음과 같이 의존성 추가할 수 있습니다.

<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.9.4</version>
</dependency>

 

Java 객체를 JSON으로 serialization 할 때는 다음과 같이 writeValue() 메서드를 사용합니다.

public class ObjectMapperTest {
    public static void main(String[] args) {
        ObjectMapper objectMapper = new ObjectMapper();
        Member member = new Member("Son", 30);

        try {
            // Member객체에 저장되어 있는 값을 JSON형식으로 변환 후 member.json파일을 생성합니다.
            objectMapper.writeValue(new File("member.json"), member);
            // Member객체에 저장되어 있는 값을 JSON형식으로 변환합니다.
            String memberInfo = objectMapper.writeValueAsString(member);
            System.out.println(memberInfo);
        } catch (JsonGenerationException e) {
            e.printStackTrace();
        } catch (JsonMappingException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

 

이때 주의할 점은 Java 클래스에 Getter가 있어야 한다는 것입니다.

@Getter
@ToString
@NoArgsConstructor
public class Member {
    private String name;
    private int age;
    
    public Member(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

 

Jackson 라이브러리는 Java 클래스를 JSON으로 직렬화할 때 Getter, Setter를 사용하여 prefix를 잘라내고 맨 앞 글자를 소문자로 만들어서 필드를 식별하기 때문입니다.

 

JSON을 Java 객체로 deserialization 할 때는 다음과 같이 readValue() 메서드를 사용합니다.

public class ObjectMapperTest2 {
	public static void main(String[] args) throws JsonParseException, JsonMappingException, IOException {
        String jsonData = "{ \"name\" : \"son\", \"age\" : \"30\" }"; 

        ObjectMapper objectMapper = new ObjectMapper();

        // JSON -> Object
        // jsonData의 JSON 데이터를 읽어서 Member 객체에 역직렬화했습니다. 
        Member member = objectMapper.readValue(jsonData, Member.class);
        System.out.println(member.toString()); // Member(name=son, age=30)

        // JSON File to Object
        // member.json 파일의 JSON 데이터를 읽어서 Member 객체에 역직렬화했습니다.
        Member member2 = objectMapper.readValue(new File("member.json"), Member.class);
        System.out.println(member2.toString()); // Member(name=son, age=30)

        // JSON URL to Object
        // file:member.json URL에서 JSON 데이터를 읽어서 Member 객체에 역직렬화했습니다.
        Member member3 = objectMapper.readValue(new URL("file:member.json"), Member.class);
        System.out.println(member3.toString()); // Member(name=son, age=30)
    }
}

 

이때도 주의할 점은 JSON이 변경될 Java 객체에 기본 생성자가 존재해야 한다는 것입니다. 기본적으로 Jackson 라이브러리의 역직렬화는 기본 생성자로 객체를 생성하고 Getter나 Setter를 통해 필드 찾은 뒤 Reflection 방식을 통해 데이터를 지정하기 때문입니다.

 

그외 객체 생성을 위해 @JsonCreator 등의 애노테이션을 제공하기도 합니다. (이 부분은 추가로 더 공부하고 정리할 예정입니다.)

 

잠깐!

xxx라는 필드는 없지만 getXXX(), setXXX() 라는 메서드가 있는 경우는 어떻게 될까요?

이때는 의도치 않게 xxx 라는 값이 포함될 수 있기 때문에 메서드 위에 @JsonIgnore 라는 애노테이션을 추가하여 JSON 변환 과정에 포함되지 않게 처리할 수 있습니다.

 

SpringBoot 와 Object Mapper

Spring Boot에서는 MessageConverter를 통해 요청, 응답 시 타입 변환을 진행합니다. 이때 JSON -> 객체, 객체 -> JSON 변환 과정에서 MessageConverter가 ObjectMapper를 사용하게 됩니다.

 

Spring Boot 프로젝트 생성 시 spring-boot-starter-web 의존성을 추가하게 되면 자동으로 Jackson 라이브러리가 같이 추가됩니다.

dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-web'
    ...
}

 

이를 잘 이해하기 위해 Spring MVC의 동작 방식을 알 필요가 있습니다.

 

  • Spring은 Request가 들어오면 HandlerMapping을 통해 사용자의 요청과 해당 요청을 처리하는 Hanller를 매핑합니다.
  • 그리고 HandlerMethodArgumentResolver를 사용해 Controller의 Argument(Parameter)에 지정된 변수들을 Annotation이나 객체의 Type에 따라서 적절한 Resolver를 거칩니다.
  • HandlerArgumentResolver를 상속한 RequestResponseBodyMethodProcessor 를 보면 @RequestBody를 확인하는 것을 볼 수 있습니다. @RequestBody를 사용한 것이 확인이 된다면 read Or write로 넘어가게 됩니다.
  • Content-type이 Json인 것을 확인한 후 Jackson Converter(MappingJackson2HttpMessageConverter)를 통해 Json Data를 직렬화합니다. 이 때 사용되는 것이 ObjectMapper입니다.

Object Mapper 에 custom serialize/deserialize를 적용하려면 어떤 방법이 있을까요?

 

일단 커스텀한 serialize/deserialize는 다음과 같습니다.

@Slf4j
@Component
public class CustomLocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
    @Override
    public Class<LocalDateTime> handledType() {
        return LocalDateTime.class;
    }

    @Override
    public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        gen.writeString(value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
    }
}
@Slf4j
@Component
public class CustomLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
    private final String[] patterns = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "yyyy-MM-dd"};

    @Override
    public Class<?> handledType() {
        return LocalDateTime.class;
    }

    @Override
    public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        return LocalDateTime.parse(p.getText(), makeDateTimeFormatter());
    }

    private DateTimeFormatter makeDateTimeFormatter() {
        StringBuilder pattern = new StringBuilder();

        for (String p : patterns) {
            pattern.append("[").append(p).append("]");
        }

        return new DateTimeFormatterBuilder()
                .appendPattern(pattern.toString())
                .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
                .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
                .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
                .toFormatter();
    }
}

 

1) ObjectMapper 를 직접 빈으로 등록

@Configuration
public class ObjectMapperConfig {

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(customLocalDateTimeModule());
        return objectMapper;
    }

    @Bean
    public SimpleModule customLocalDateTimeModule() {
        SimpleModule module = new SimpleModule();

        module.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
        module.addSerializer(LocalDateTime.class, new CustomLocalDateTimeSerializer());

        module.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
        module.addDeserializer(LocalDateTime.class, new CustomLocalDateTimeDeserializer());

        return module;
    }
}

 

ObjectMapper를 직접 생성하여 빈으로 등록할 때 Module을 추가해주는 방식입니다.

 

2) Jackson2ObjectMapperBuilderCustomizer 사용

@Configuration
public class ObjectMapperConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
        return builder -> {
            builder.simpleDateFormat(LOCAL_DATE_TIME_FORMAT);

            builder.serializers(
                    new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")),
                    new CustomLocalDateTimeSerializer()
            );

            builder.deserializers(
                    new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")),
                    new CustomLocalDateTimeDeserializer()
            );
        };
    }
}

 

기존에 제공하던 ObjectMapper에 설정을 변경하는 대신 custom 한 serializer/deserializer만 추가할 수 있습니다.

기존에 이미 ObjectMapper를 사용하여 처리를 하고 있던 경우라고 한다면 새로운 ObjectMapper를 생성하고 Module을 추가하는 방식 대신 두번째 방식을 사용하는 것이 설정 측면에서 더 안정성이 있다고 할 수 있습니다.


지금까지 ObjectMapper를 통해 객체와 JSON 간의 데이터 타입 변환에 대해 알아보았습니다. 이 부분을 공부하다보니 Spring MVC의 동작 방식에 대해 더 깊이 있게 알아야겠다고 생각했고 애노테이션 처리, ArgumentResolver에 대해서도 조금 더 자세히 알아야겠다고 생각했습니다.

다음 글에서는 ArgumentResolver와 함께 @RequestParam, @RequestBody 등이 어떤 식의 변환 과정을 거치는지 조금 더 깊게 살펴보려고 합니다.

 

참고

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함