Jackson 딥다이브 - 4: 설정·어노테이션·함정

2021-11-27 · 엔지니어링 · 시리즈 · Jackson 딥다이브
Jackson 딥다이브 - 4: 설정·어노테이션·함정

1편에서 구현체 흐름을, 2편에서 직렬화기를 직접 만들어보고, 3편에서 응답·요청 객체 설계와 불변 객체 받기를 봤습니다. 마지막 편은 개발을 하다 보면 자주 만나게 되는 ObjectMapper 설정과 어노테이션 정리, 그리고 발생할 수 있는 문제들을 모았습니다.

ObjectMapper 설정으로 동작이 갈린다

ObjectMapper 설정에 따라, 같은 객체·같은 JSON이라도 직렬화·역직렬화 결과가 달라질 수 있다는 사실, 알고 계셨나요? 실무에서 자주 만진 설정만 추려보면 이렇습니다.

ObjectMapper mapper = JsonMapper.builder()
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)     // 날짜를 숫자 말고 ISO-8601 문자열로
    .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)  // 모르는 필드 와도 안 죽게
    .serializationInclusion(JsonInclude.Include.NON_NULL)        // null 필드는 응답에서 생략
    .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) // camelCase ↔ snake_case 자동 변환
    .addModule(new JavaTimeModule())                            // java.time 지원
    .build();
  • WRITE_DATES_AS_TIMESTAMPS(SerializationFeature): 켜져 있으면 날짜가 [2021,11,16] 같은 숫자로 나갑니다. 보통 꺼서 ISO 문자열로 둡니다.
  • FAIL_ON_UNKNOWN_PROPERTIES(DeserializationFeature): 기본이 켜짐이라, JSON에 모르는 필드가 하나라도 있으면 예외가 납니다. 외부 응답을 받을 땐 꺼두는 편이 안전했습니다.
  • PropertyNamingStrategies.SNAKE_CASE: 자바는 camelCase, JSON은 snake_case를 쓰는 경우가 많은데 이걸로 한 번에 맞췄습니다.
  • JavaTimeModule: java.time 타입(LocalDateTime 등) 지원. Spring Boot는 자동 등록해주지만, 순수 Jackson을 직접 만들 땐 빼먹기 쉽습니다.

Spring Boot라면 이걸 코드로 만들기보다 application.yml의 spring.jackson.*나 Jackson2ObjectMapperBuilderCustomizer로 설정하는 방법이 더 명시적이고 관리가 수월할 것 같았습니다. 또한 @RequestBody/@ResponseBody가 쓰는 건 스프링 MVC 메시지 컨버터가 들고 있는 별도 ObjectMapper이기 때문에, 해당 ObjectMapper 설정을 할 것이라면 Spring 컨텍스트에 빈으로 등록을 해주거나 빌더를 등록해주면 됩니다.

// 1) 빌더 커스터마이저로 (Boot 기본 구성을 유지한 채 필요한 것만 얹기, 권장)
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
    return builder -> builder
        .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .serializationInclusion(JsonInclude.Include.NON_NULL);
}

// 2) ObjectMapper 빈을 직접 등록 (Boot가 이 빈을 우선 사용)
@Bean
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
    return builder
        .failOnUnknownProperties(false)
        .serializationInclusion(JsonInclude.Include.NON_NULL)
        .build();
}

2번처럼 직접 등록할 땐 new ObjectMapper()을 쌩으로 등록하기보다, Jackson2ObjectMapperBuilder를 거치는 게 안전합니다. 그래야 JavaTimeModule 등록 같은 Boot의 기본 구성이 빠지지 않기 때문입니다.

자주 쓰이는 어노테이션 정리

분명 자주 쓰는 것들인데도 매번 헷갈리고 다시 찾아보게 되지 않나요? 그래서 의미를 한번 표로 정리해봤습니다.

어노테이션정확히 하는 일
@JsonProperty("name")그 프로퍼티의 JSON 키 이름을 지정. access로 읽기/쓰기 전용도 지정 가능
@JsonIgnore그 프로퍼티를 직렬화·역직렬화 양쪽에서 제외
@JsonIgnoreProperties(ignoreUnknown=true)(클래스에) JSON에 있는 모르는 필드를 무시
@JsonInclude(NON_NULL)null(또는 빈 값)인 프로퍼티를 출력에서 생략
@JsonCreator역직렬화 시 호출할 생성자/팩토리 메서드 지정(불변 객체용)
@JsonValue객체 전체를 이 메서드의 반환값 하나로 직렬화(예: enum 코드값)
@JsonAlias({"a","b"})역직렬화 때 여러 입력 키 이름을 같은 프로퍼티로 받음
@JsonFormat(pattern=...)날짜·숫자 등의 표현 형식 지정
@JsonAnyGetter / @JsonAnySetterMap을 펼쳐 평평한 키들로 직렬화 / 모르는 키들을 Map에 모아 받기
@JsonNaming(...)그 클래스에 네이밍 전략(예: snake_case)을 따로 적용

양방향 연관관계의 무한 순환

예전에 JPA 양방향 엔티티 연관관계를 그대로 직렬화하다 만난 문제가 있는데요(JPA 양방향 Entity 무한 재귀로도 정리했던 그 종류입니다).

User가 List<Order>를 갖고 각 Order가 다시 User를 가리키면, Jackson은 getter를 타고 User → Order → User … 를 끝없이 따라가다 StackOverflowError가 발생하게 됩니다. 이 문제를 해결하는 방법은 @JsonManagedReference/@JsonBackReference(한쪽만 직렬화), @JsonIgnore(한쪽 제외), @JsonIdentityInfo(두 번째 등장부터 id만) 정도가 있지만, 근본적으로는 응답 전용 DTO로 분리하면 순환 자체가 안 생기니, 결국 이게 제일 깔끔한 방법인 것 같았습니다.

ObjectMapper는 재사용하자

마지막으로 ObjectMapper는 만들 때 비용이 있는 객체이기 때문에, 설정이 끝난 뒤에는 스레드 세이프해서 같이 사용해도 됩니다. 그래서 필요할 때마다 인스턴스를 생성해서 사용하지 않고, 같은 유스케이스의 JSON 직렬화 전략을 가져간다면 하나를 만들어 재사용하는 게 좋습니다.

정리

세 편에 걸쳐 Jackson을 구현체부터 설계, 설정, 불변까지 따라가 봤습니다. 결국 직렬화는 BeanPropertyWriter가 getter로 값을 꺼내 쓰고, 역직렬화는 ValueInstantiator로 만든 뒤 SettableBeanProperty로 채우는 한 쌍의 구조였고, 응답·요청 설계와 불변 고민, 의외의 함정까지 전부 이 구조에서 갈렸습니다. “Jackson은 필드가 아니라 접근자를 본다”, 이 핵심만 기억하고 있어도 대부분 설명이 됐음.

참고

Jackson Databind: Documentation Spring Boot: Customize the Jackson ObjectMapper

시리즈 Jackson 딥다이브 전 4편

  1. 01 구현체 동작원리 살펴보기
  2. 02 Jackson 직접 구현해보기
  3. 03 응답·요청 객체 설계 이전
  4. 04 설정·어노테이션·함정 현재 글

댓글

GitHub(giscus) 댓글은 설정 완료 후 활성화됩니다.