Spring Jackson 직렬화 심화

Spring Boot와 Jackson

Spring Boot는 JSON 직렬화/역직렬화에 Jackson을 기본으로 사용한다. @RestController의 응답, @RequestBody의 요청 파싱, RestClient/WebClient의 HTTP 통신 모두 Jackson의 ObjectMapper를 거친다. 기본 설정만으로도 대부분 동작하지만, 실무에서는 날짜 포맷, null 처리, 다형성 직렬화 등 세밀한 커스터마이징이 필수다.

전역 ObjectMapper 설정

@Configuration
public class JacksonConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
        return builder -> builder
            // snake_case ↔ camelCase 자동 변환
            .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
            // null 필드 제외
            .serializationInclusion(JsonInclude.Include.NON_NULL)
            // 알 수 없는 필드 무시 (API 버전 호환)
            .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            // 빈 객체 직렬화 허용
            .featuresToDisable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
            // 날짜를 timestamp 대신 ISO 8601로
            .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            // Java 8 날짜/시간 모듈
            .modules(new JavaTimeModule());
    }
}

// 또는 application.yml로 설정
// spring:
//   jackson:
//     property-naming-strategy: SNAKE_CASE
//     default-property-inclusion: non_null
//     deserialization:
//       fail-on-unknown-properties: false
//     serialization:
//       write-dates-as-timestamps: false

Jackson2ObjectMapperBuilderCustomizer를 사용하면 Spring Boot의 자동 설정을 유지하면서 추가 커스터마이징할 수 있다. ObjectMapper Bean을 직접 등록하면 자동 설정이 전부 무시되므로 주의해야 한다.

필드 레벨 어노테이션

public record UserResponse(
    Long id,

    @JsonProperty("user_name")  // JSON 키 이름 변경
    String name,

    @JsonIgnore  // 직렬화/역직렬화 모두 제외
    String password,

    @JsonProperty(access = JsonProperty.Access.READ_ONLY)  // 응답에만 포함
    LocalDateTime createdAt,

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)  // 요청에서만 수용
    String secretToken,

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    LocalDateTime updatedAt,

    @JsonInclude(JsonInclude.Include.NON_EMPTY)  // 빈 리스트 제외
    List<String> roles
) {}

// 조건부 직렬화
public class OrderDto {

    @JsonInclude(value = JsonInclude.Include.CUSTOM,
                 valueFilter = SensitiveFilter.class)
    private String cardNumber;

    // 필터: 값이 민감 데이터면 제외
    public static class SensitiveFilter {
        @Override
        public boolean equals(Object obj) {
            return obj instanceof String s && s.length() > 10;
        }
    }
}

커스텀 Serializer / Deserializer

// 금액을 "₩10,000" 형식으로 직렬화
public class MoneySerializer extends JsonSerializer<BigDecimal> {
    private static final DecimalFormat FORMAT = new DecimalFormat("#,##0");

    @Override
    public void serialize(BigDecimal value, JsonGenerator gen,
                          SerializerProvider provider) throws IOException {
        gen.writeString("₩" + FORMAT.format(value));
    }
}

// "₩10,000" → BigDecimal 역직렬화
public class MoneyDeserializer extends JsonDeserializer<BigDecimal> {
    @Override
    public BigDecimal deserialize(JsonParser p, DeserializationContext ctx)
            throws IOException {
        String text = p.getText().replaceAll("[₩,]", "");
        return new BigDecimal(text);
    }
}

// 적용 방법 1: 필드에 직접
public record ProductResponse(
    String name,
    @JsonSerialize(using = MoneySerializer.class)
    @JsonDeserialize(using = MoneyDeserializer.class)
    BigDecimal price
) {}

// 적용 방법 2: 모듈로 전역 등록
@Configuration
public class JacksonModuleConfig {
    @Bean
    public Module moneyModule() {
        SimpleModule module = new SimpleModule("MoneyModule");
        module.addSerializer(Money.class, new MoneySerializer());
        module.addDeserializer(Money.class, new MoneyDeserializer());
        return module;
    }
}

@JsonView: 동일 DTO, 다른 뷰

// 뷰 정의
public class Views {
    public interface Summary {}
    public interface Detail extends Summary {}
    public interface Admin extends Detail {}
}

// DTO
public record UserDto(
    @JsonView(Views.Summary.class) Long id,
    @JsonView(Views.Summary.class) String name,
    @JsonView(Views.Detail.class) String email,
    @JsonView(Views.Detail.class) LocalDateTime createdAt,
    @JsonView(Views.Admin.class) String role,
    @JsonView(Views.Admin.class) boolean active
) {}

// 컨트롤러
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping
    @JsonView(Views.Summary.class)  // id, name만 반환
    public List<UserDto> listUsers() { ... }

    @GetMapping("/{id}")
    @JsonView(Views.Detail.class)  // id, name, email, createdAt 반환
    public UserDto getUser(@PathVariable Long id) { ... }

    @GetMapping("/{id}/admin")
    @JsonView(Views.Admin.class)  // 모든 필드 반환
    @PreAuthorize("hasRole('ADMIN')")
    public UserDto getUserAdmin(@PathVariable Long id) { ... }
}

같은 DTO를 엔드포인트마다 다른 형태로 노출할 수 있다. 별도 Response DTO를 만들지 않아도 되므로 Spring ArgumentResolver와 함께 사용하면 코드 중복을 크게 줄일 수 있다.

다형성 직렬화: @JsonTypeInfo

// 이벤트 상속 계층
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "type"
)
@JsonSubTypes({
    @JsonSubTypes.Type(value = OrderCreatedEvent.class, name = "order_created"),
    @JsonSubTypes.Type(value = OrderShippedEvent.class, name = "order_shipped"),
    @JsonSubTypes.Type(value = OrderCancelledEvent.class, name = "order_cancelled"),
})
public sealed interface OrderEvent permits
    OrderCreatedEvent, OrderShippedEvent, OrderCancelledEvent {
    Long orderId();
    Instant occurredAt();
}

public record OrderCreatedEvent(
    Long orderId, Instant occurredAt,
    BigDecimal totalAmount, List<String> items
) implements OrderEvent {}

public record OrderShippedEvent(
    Long orderId, Instant occurredAt,
    String trackingNumber, String carrier
) implements OrderEvent {}

public record OrderCancelledEvent(
    Long orderId, Instant occurredAt,
    String reason
) implements OrderEvent {}

// JSON 결과:
// {"type":"order_created","order_id":1,"total_amount":"₩50,000",...}
// {"type":"order_shipped","order_id":1,"tracking_number":"ABC123",...}

// 역직렬화: type 필드로 자동 타입 결정
@PostMapping("/events")
public void handleEvent(@RequestBody OrderEvent event) {
    switch (event) {
        case OrderCreatedEvent e -> processCreated(e);
        case OrderShippedEvent e -> processShipped(e);
        case OrderCancelledEvent e -> processCancelled(e);
    }
}

Mixin: 외부 클래스 직렬화 제어

// 외부 라이브러리 클래스에 Jackson 어노테이션을 입힐 수 없을 때
// → Mixin으로 해결

@JsonIgnoreProperties({"internalState", "debugInfo"})
public abstract class ExternalDtoMixin {

    @JsonProperty("display_name")
    abstract String getName();

    @JsonFormat(pattern = "yyyy-MM-dd")
    abstract LocalDate getBirthDate();
}

@Configuration
public class JacksonMixinConfig {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer mixinCustomizer() {
        return builder -> builder.mixIn(ExternalDto.class, ExternalDtoMixin.class);
    }
}

성능 최적화

@Configuration
public class JacksonPerformanceConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer performanceCustomizer() {
        return builder -> builder
            // Afterburner: 리플렉션 대신 바이트코드 생성
            .modules(new AfterburnerModule())
            // 또는 Blackbird (Java 11+, Afterburner 후속)
            // .modules(new BlackbirdModule())

            // 스트리밍 API로 대용량 JSON 처리
            .featuresToEnable(JsonGenerator.Feature.AUTO_CLOSE_TARGET)

            // 날짜 캐싱
            .featuresToEnable(SerializationFeature.WRITE_DATE_KEYS_AS_TIMESTAMPS);
    }
}
최적화 효과 적용
Afterburner/Blackbird 직렬화 20-30% 성능 향상 jackson-module-afterburner 의존성 추가
ObjectMapper 재사용 인스턴스 생성 비용 제거 Bean으로 싱글톤 관리 (Spring 기본)
NON_NULL 포함 JSON 크기 감소, 네트워크 절약 전역 serializationInclusion 설정
@JsonView 불필요한 필드 직렬화 방지 엔드포인트별 뷰 적용

Jackson은 Spring의 보이지 않는 심장이다. Spring RestClient의 HTTP 통신부터 API 응답까지 모든 JSON 처리를 담당하므로, 전역 설정을 올바르게 잡고 커스텀 Serializer/Mixin을 적재적소에 활용하는 것이 API 품질을 좌우한다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux