Jackson으로 다형성 JSON 역직렬화하기

2026. 9. 28. 21:46·Java & Kotlin

다형성 JSON에서 문제가 생기는 지점

Jackson의 ObjectMapper를 이용하면 JSON을 Java 객체로 쉽게 변환할 수 있다. 아래와 같은 코드가 있다고 생각해보자.

// 역직렬화 코드
ObjectMapper objectMapper = new ObjectMapper();
Payment payment = objectMapper.readValue(json, Payment.class);

/*
// Payment.class 코드
public class Payment {

    private String orderId;
    private Card card;
}

// JSON 예시
{
  "orderId": "ORDER-1",
  "card": {
    "cardNumber": "1234-5678",
    "installment": 3
  }
}
*/

이때 Jackson은 Payment.class를 기준으로 어떤 객체를 생성해야 하는지 판단하고, JSON의 각 프로퍼티를 Java 객체의 필드에 매핑한다. Payment를 역직렬화할 때 Jackson은 orderId는 String, card는 Card 타입이라는 것을 알 수 있다. 따라서 Card 객체를 생성하고 그 안에 cardNumber, installment 값을 매핑하면 된다. 그런데 card가 하나의 구체 타입이 아니라 인터페이스라면 이야기가 조금 달라진다. 예를 들어서 아래와 같은 상황이라고 가정해보자.

public class Payment {
    private String orderId;
    private PaymentMethod paymentMethod;
}

// 인터페이스
public interface PaymentMethod {
}

// 구현체1
public class CardPayment implements PaymentMethod {

    private String cardNumber;
    private int installment;
}

// 구현체2
public class BankTransfer implements PaymentMethod {

    private String bankCode;
    private String accountNumber;
}

Producer에서는 상황에 따라 CardPayment나 BankTransfer를 paymentMethod에 넣어 JSON으로 직렬화할 수 있다. 직렬화 자체는 문제가 없다. 직렬화 시점에는 이미 생성된 객체가 존재하기 때문에 Jackson이 해당 객체의 실제 런타임 타입을 확인할 수 있기 때문이다. 예를 들어 변수의 선언 타입이 PaymentMethod라고 하더라도 실제로 들어 있는 객체가 CardPayment라면 Jackson은 이를 CardPayment로 인식하고 해당 객체의 프로퍼티를 읽어 JSON으로 직렬화할 수 있다.

 

문제는 반대 방향인 역직렬화다. 역직렬화 시점에는 아직 어떤 구현체의 객체도 생성되어 있지 않다. Jackson은 JSON과 paymentMethod의 선언 타입인 PaymentMethod를 가지고 새 객체를 생성해야 한다. 하지만 PaymentMethod는 인터페이스이기 때문에 직접 인스턴스를 생성할 수 없고, JSON에 별도의 타입 정보가 없다면 CardPayment와 BankTransfer 중 어떤 구현체를 생성해야 하는지도 판단할 수 없다.

역직렬화하는 시점에 Jackson이 알고 있는 것은 paymentMethod의 타입이 PaymentMethod라는 사실뿐이다. 하지만 PaymentMethod는 인터페이스이기 때문에 직접 객체를 생성할 수 없다. 그리고 JSON만 봐서는 어떤 구현체를 생성해야 하는지도 알 수 없다. 물론, 사람이 보면 어떤 구현체를 선택해야하는지 쉽게 판단할 수 있다. 하지만 Jackson이 기본적으로 애플리케이션에 존재하는 모든 PaymentMethod 구현체를 찾아서 각 필드 구성을 비교한 뒤 가장 적절한 클래스를 선택해주는 것은 아니다. 결국 Jackson 입장에서는 "PaymentMethod 대신 실제로 어떤 클래스를 생성해야 하는가?" 라는 질문에 대한 답이 없는 것이다. 때문에 별도의 설정 없이 역직렬화를 시도하면 Cannot construct instance와 같은 런타임 예외가 발생한다.

다형성 역직렬화를 위한 타입 정보

이를 해결하려면 JSON 안에 어떤 타입인지 구분할 수 있는 정보가 있어야 한다. 예를 들어 아래처럼 JSON 필드에 type이라는 프로퍼티를 추가할 수 있다.

{
  "orderId": "ORDER-1",
  "paymentMethod": {
    "type": "CARD",
    "cardNumber": "1234-5678",
    "installment": 3
  }
}

type이 CARD라면 CardPayment, BANK_TRANSFER라면 BankTransfer를 생성하도록 약속하는 것이다. Jackson에서는 이런 다형성 처리를 위해 @JsonTypeInfo와 @JsonSubTypes를 제공하고 있다.

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "type"
)
@JsonSubTypes({
    @JsonSubTypes.Type(
        value = CardPayment.class,
        name = "CARD"
    ),
    @JsonSubTypes.Type(
        value = BankTransfer.class,
        name = "BANK_TRANSFER"
    )
})
public interface PaymentMethod {
}

두 어노테이션은 역할이 조금 다르다. @JsonTypeInfo는 JSON에서 타입을 어떤 방식으로 표현하고 찾을 것인지를 설정한다. 반면 @JsonSubTypes는 찾아낸 타입 값과 실제 Java 클래스를 연결한다. 위 설정에서는 JSON의 type 프로퍼티를 확인하고 그 값이 CARD라면 CardPayment를 생성한다. 즉, `"type": "CARD"` 라는 JSON의 정보와 CardPayment.class를 연결해주는 것이 @JsonSubTypes다.

즉, 정리하면 @JsonSubTypes만 선언한다고 다형성 역직렬화가 활성화되는 것은 아니라는 것이다. @JsonSubTypes는 어떤 subtype이 존재하는지를 알려주는 역할이고, 실제로 타입 정보를 어떻게 읽을지는 @JsonTypeInfo를 통해 설정해야 한다.

JsonTypeInfo

use

@JsonTypeInfo에는 다형성 처리 방식을 결정하는 몇 가지 옵션이 있다. 가장 먼저 use다. use는 JSON에서 어떤 값을 타입 식별자로 사용할 것인지를 의미한다. 

1. Id.NAME

일반적으로 가장 사용하기 편한 방식이다. JSON에는 실제 Java 클래스 이름 대신 별도로 정의한 이름이 들어간다. Java 클래스 이름이 변경되더라도 "CARD"라는 JSON 규격은 그대로 유지할 수 있다. Producer와 Consumer가 서로 독립적으로 배포되는 구조라면 Java 클래스 이름을 그대로 노출하는 것보다 이런 식으로 논리적인 타입 이름을 사용하는 편이 훨씬 낫다.

 

2. Id.CLASS

Id.CLASS는 Java 클래스의 Fully Qualified Class Name 자체를 타입 정보로 사용한다. JSON은 대략 다음과 같은 형태가 된다.

{
  "@class": "com.example.payment.CardPayment",
  "cardNumber": "1234-5678",
  "installment": 3
}

별도의 이름을 등록하지 않아도 Jackson이 클래스를 바로 찾을 수 있다는 장점은 있다. 다만 서비스 간 메시지에 사용하기에는 결합도가 너무 높아진다. 패키지를 이동하거나 클래스 이름을 변경하는 것만으로도 메시지 규격이 변경되기 때문이다. Java 내부 구현이 그대로 외부 JSON 계약에 노출된다는 점도 좋지 않다. 특히 신뢰할 수 없는 외부 데이터를 역직렬화하는 상황에서는 class name 기반의 다형성 역직렬화가 보안 측면에서도 주의가 필요하다. 때문에 외부 API나 서비스 간 이벤트라면 개인적으로 Id.CLASS를 선택할 이유는 많지 않다고 생각한다. Id.MINIMAL_CLASS 역시 클래스명을 조금 더 짧게 표현할 뿐 Java 클래스 구조에 의존한다는 특징은 비슷하다.

 

3. Id.DEDUCTION

타입을 명시적으로 넣지 않고 JSON에 존재하는 필드를 기반으로 subtype을 추론하는 방식도 있다.

@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes({
    @JsonSubTypes.Type(CardPayment.class),
    @JsonSubTypes.Type(BankTransfer.class)
})
public interface PaymentMethod {
}

예를 들어 CardPayment만 cardNumber라는 필드를 가지고 있다면, Jackson이 필드 구성을 보고 CardPayment라고 판단할 수 있다.

처음 보면 꽤 편리해 보인다. 하지만 타입을 결정하는 기준이 명시적인 값이 아니라 JSON 구조에 의존하게 된다. 처음에는 subtype마다 필드가 확실하게 구분되더라도 모델이 변경되면서 서로 비슷한 구조를 가지게 될 수 있다. 타입을 구분하는 것이 중요한 메시지라면 차라리 "type": "CARD"처럼 명시적으로 표현하는 편이 계약을 이해하기도 쉽고 변경에도 대응하기 좋다고 생각한다.

include

include는 타입 정보를 JSON의 어디에 표현할지 결정한다. 가장 일반적인 방식은 PROPERTY다.

{
  "orderId": "ORDER-1",
  "paymentMethod": {
    "type": "CARD",
    "cardNumber": "1234-5678",
    "installment": 3
  }
}

즉, 위의 예시처럼 타입 정보가 일반 프로퍼티처럼 객체 안에 들어가는 형태다. 이외에도 WRAPPER_OBJECT, WRAPPER_ARRAY, EXTERNAL_PROPERTY 등의 옵션이 존재한다. 

 

이미 객체 안에 타입을 나타내는 필드가 존재한다면 EXISTING_PROPERTY를 사용할 수도 있다.

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.EXISTING_PROPERTY,
    property = "type",
    visible = true
)

EXISTING_PROPERTY는 Jackson이 타입 정보를 위해 별도의 프로퍼티를 만드는 것이 아니라 객체에 이미 존재하는 프로퍼티를 타입 식별자로 사용한다. 여기서 같이 볼 수 있는 옵션이 visible이다. 기본값은 false로, Jackson은 type 값을 subtype을 결정하기 위해 사용하고 나면 실제 객체의 일반적인 프로퍼티에는 전달하지 않는다. 하지만 객체에서도 type 값을 그대로 사용하고 싶다면 true로 변경하면 된다. 그러면 타입 판별에 사용된 값이 실제 객체의 type 프로퍼티에도 바인딩된다.

JsonSubTypes

@JsonSubTypes의 역할은 비교적 단순하다. value에는 실제 subtype 클래스를 지정하고, name에는 JSON에서 사용할 타입 식별자를 지정한다.

name

@JsonSubTypes({
    @JsonSubTypes.Type(
        value = CardPayment.class,
        name = "CARD"
    ),
    @JsonSubTypes.Type(
        value = BankTransfer.class,
        name = "BANK_TRANSFER"
    )
})

이렇게 등록하면 "CARD"라는 값과 CardPayment.class가 연결된다. Subtype 쪽에 직접 이름을 정의하는 방법도 있다.

경우에 따라서는 하나의 subtype에 여러 이름을 허용할 수도 있다. 예를 들어 기존에는 "CREDIT_CARD"라는 값을 사용했지만 새로운 규격에서는 "CARD"를 사용한다고 해보자.

@JsonSubTypes.Type(
    value = CardPayment.class,
    names = {"CARD", "CREDIT_CARD"}
)

두 값을 동일한 CardPayment로 역직렬화할 수 있기 때문에 메시지 스키마를 변경하는 과정에서 하위 호환성을 유지하는 용도로 활용할 수 있다.

defaultImpl

@JsonTypeInfo에는 등록되지 않은 타입이나 타입 정보가 없는 경우 사용할 기본 구현체를 지정하는 defaultImpl도 있다. Consumer가 아직 지원하지 않는 타입을 받을 수 있는 환경이라면 유용해 보일 수 있다. 하지만 이 역시 사용 목적을 명확히 하는 것이 좋다. 예를 들어 Producer에 새로운 결제 방식이 추가됐다고 해보자. Consumer는 아직 CRYPTO를 지원하지 않는다. 이때 역직렬화 자체를 실패시키면 적어도 Consumer가 처리할 수 없는 메시지가 들어왔다는 사실이 명확하다.

반대로 모든 알 수 없는 타입을 UnknownPayment로 받아버리면 역직렬화에는 성공하지만 이후 로직에서 해당 메시지가 정상적으로 처리되고 있는 것처럼 보일 수도 있다. 결국 역직렬화에 성공하는 것과 메시지를 올바르게 처리하는 것은 다른 문제다. 따라서 defaultImpl을 사용한다면 이후에 알 수 없는 타입을 어떻게 처리할 것인지도 함께 설계해야 한다.

다형성 JSON을 사용하는 것이 좋은 설계일까?

@JsonTypeInfo와 @JsonSubTypes를 사용하면 꽤 편리하다.

Java에서는 인터페이스를 기준으로 모델을 정의할 수 있고, Consumer에서는 별도로 JSON을 분석하지 않아도 Jackson이 적절한 구현체까지 만들어준다. Subtype의 개수가 많지 않고, 해당 타입들이 실제 도메인에서도 자연스럽게 하나의 추상 타입으로 묶이는 구조라면 충분히 사용할 만한 방법이라고 생각한다.

다만 서비스 간 메시지에 사용한다면 조금 더 신중하게 볼 필요가 있다고 생각한다. 가장 먼저 생각할 부분은 메시지와 Java 타입 사이의 결합이다. 특히 Id.CLASS처럼 Java 클래스 자체를 타입 정보로 사용하면 내부 구현이 그대로 메시지 계약이 되어버린다. 물론, 이 문제는 Id.NAME을 사용하면 상당히 줄일 수 있지만, 그렇다고 다형성 메시지 자체의 변경 비용이 사라지는 것은 아니다.

특히, 개인적으로 또 하나 신경 쓰이는 부분은 중요한 메시지 규칙이 Jackson 어노테이션 안에 숨어들 수 있다는 점이다. 코드는 분명 간결하다. 하지만 어떤 타입의 메시지를 지원하고 있고, 어떤 값을 어떤 클래스에 연결하는지가 Jackson 설정의 일부가 된다.

Subtype이 몇 개 없을 때는 문제가 크지 않지만 메시지 종류가 계속 증가하면 하나의 부모 타입이 너무 많은 메시지 규격을 알고 있게 될 수도 있다. 특히 API나 Kafka 메시지 DTO와 도메인 모델을 같은 클래스로 사용한다면 직렬화 규칙이 도메인 모델에 직접 섞이는 문제도 생긴다. 이런 경우에는 타입을 메시지 바깥에서 명시적으로 관리하는 방법이 나을 수 있다고 생각한다.

Consumer는 eventType에 따라 적절한 handler를 선택하고 해당 handler가 payload를 자신의 DTO로 역직렬화하도록 만들 수 있다. 해당 구현 방식은 @JsonSubTypes를 사용하는 것보다 작성해야 하는 코드는 조금 많아진다. 대신 어떤 이벤트를 지원하는지, 지원하지 않는 이벤트가 들어왔을 때 어떻게 처리하는지, 이벤트의 버전을 어떻게 관리하는지가 코드에 조금 더 명시적으로 드러난다.

물론 어느 한쪽이 항상 더 좋은 방법이라고 보기는 어렵다. 같은 애플리케이션 안에서 제한된 subtype을 처리한다면 Jackson의 다형성 기능이 훨씬 간결할 수 있다. 반대로 여러 서비스가 오랫동안 공유하는 메시지 계약이라면 역직렬화의 편의성보다 타입 추가와 변경에 따른 호환성까지 같이 생각할 필요가 있다.

정리

Jackson은 JSON을 역직렬화할 때 대상 Java 타입을 기준으로 생성할 객체를 결정한다. 따라서 필드의 타입이 구체 클래스라면 큰 문제가 없지만 인터페이스나 추상 클래스라면 JSON만으로 실제 구현체를 결정할 수 없다.

이를 해결하기 위해 @JsonTypeInfo로 타입 정보를 읽는 방법을 정의하고, @JsonSubTypes로 타입 식별자와 실제 Java 클래스를 연결할 수 있다. 사용 방법 자체는 어렵지 않다.

다만 다형성 JSON이 서비스 간 메시지에 사용되기 시작하면 단순히 Jackson의 역직렬화 문제로만 볼 수는 없다. 새로운 subtype을 추가하는 것이 곧 새로운 메시지 타입을 추가하는 것이 될 수 있고, Producer와 Consumer 사이의 호환성 문제로 이어진다.

그래서 개인적으로는 Jackson이 다형성을 지원한다는 이유만으로 메시지를 다형성 구조로 만드는 것은 조금 조심스러운 편이다. 다형성이 실제 데이터 모델에서도 자연스럽고 subtype의 범위가 명확하다면 좋은 선택이 될 수 있다. 하지만 타입이 계속 늘어날 가능성이 있거나 여러 서비스가 해당 JSON을 장기간 계약으로 사용한다면, Jackson이 이를 역직렬화할 수 있는가보다 이 타입 구조를 메시지 계약으로 가져가는 것이 적절한가를 먼저 고민하는 편이 좋다고 생각한다.

'Java & Kotlin' 카테고리의 다른 글

안전하게 토큰 비교하기 - String.equals() vs MessageDigest.isEqual()  (0) 2025.06.18
안전하고 Thread-safe하게 난수 생성하기  (0) 2025.05.31
'Java & Kotlin' 카테고리의 다른 글
  • 안전하게 토큰 비교하기 - String.equals() vs MessageDigest.isEqual()
  • 안전하고 Thread-safe하게 난수 생성하기
wing1008
wing1008
휘발을 막기 위한 기록
  • wing1008
    차곡차곡
    wing1008
  • 전체
    오늘
    어제
    • 분류 전체보기 (77)
      • Spring (23)
      • JPA&JDBC (14)
      • Java & Kotlin (3)
      • Computer Science (19)
        • 데이터베이스 (9)
        • 운영체제 (1)
        • 네트워크 (4)
        • 자료구조&알고리즘 (5)
      • System Architecture (8)
        • Kafka (2)
        • Redis (1)
      • Observability (4)
      • Etc (3)
      • 끄적끄적 (3)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

    • Github
  • 공지사항

    • 블로그 정착 완료!
  • 인기 글

  • 태그

    write back
    edumate
    실전! 스프링 부트와 jpa 활용 2
    쓰기 전략
    알고리즘
    JPA
    카카오 코테 기출
    자바 orm 표준 jpa 프로그래밍 - 기본편
    write around
    한끼족보
    백준
    실전! 스프링 부트와 jpa 활용1
    프로그래머스
    스프링 JPA
    Transaction
    spring
    realmysql1
    look aside
    write through
    read through
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
wing1008
Jackson으로 다형성 JSON 역직렬화하기
상단으로

티스토리툴바