json을 다양한 클래스로 매핑하기
일반적으로 json을 deserialize할 때 특정 유형에 따라 다른 객체로 매핑을 하고 싶을 경우가 있다.
REST로 @RequestBody의 특정 json을 받는 경우나, DB에서 데이터를 읽어서 객체로 매핑하는 경우가 있다.
이런 경우 기본적인 유형의 경우는 별 문제가 없지만 추상 클래스로 정의되어 있는 유형일 경우에는 일반적인 매핑 방법을 사용하지 못한다.
추상 클래스 사례
아래와 같은 Message
클래스가 있다고 하자. Message에는 TextMessage
와 FileMessage
두 가지의 유형이 존재한다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class Message {
private long messageId;
private MessageType type;
public Message(long messageId, MessageType type) {
this.messageId = messageId;
this.type = type;
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class TextMessage extends Message {
private String message;
public TextMessage(long messageId, String message) {
super(messageId, MessageType.TEXT);
this.message = message;
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class FileMessage extends Message {
private String fileInfo;
public FileMessage(long messageId, String fileInfo) {
super(messageId, MessageType.FILE);
this.fileInfo = fileInfo;
}
}
public enum MessageType {
TEXT,
FILE;
}
기본 매핑
여기서 아래와 같은 json을 Message 클래스로 deserialize 해보자.
{
"messageId" : 1,
"type" : "TEXT",
"message" : "텍스트"
}
objectmapper로 deserialize할 때 이렇게 사용할 수 있다.
ObjectMapper mapper = new ObjectMapper();
String json = "{\"messageId\":1,\"type\":\"TEXT\",\"message\":\"텍스트\"}";
Message message = mapper.readValue(json, TextMessage.class); // TEXT로 매핑할 경우
Message message = mapper.readValue(json, FileMessage.class); // File로 매핑할 경우
하지만 위의 경우는 구체적인 유형이 결정되어 있는 상태라서 매핑이 되지만 아래와 같은 방식으로 사용할 수 없을까?
Message message = mapper.readValue(json, Message.class);
실행을 해보면 아래와 같은 오류가 발생한다.
Exception in thread "main" com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.example.jsontypeinfo.message.Message` (no Creators, like default constructor, exist): abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information
at [Source: (String)"{"messageId":1,"type":"TEXT","message":"텍스트"}"; line: 1, column: 1]
오류 내용은 구체 클래스로 매핑되거나 추가적인 정보를 포함해야 한다고 한다.
추상 클래스 매핑
위에서 본 것 처럼 type=TEXT 인 경우는 TextMessage로 매핑하고 type=FILE인 경우는 FileMessage로 자동으로 매핑해주는 방법이다.
- type: TEXT
{
"messageId" : 1,
"type" : "TEXT",
"message" : "텍스트"
}
Message message = mapper.readValue(json, Message.class); // TextMessage
- type: FILE
{
"messageId" : 1,
"type" : "FILE",
"fileInfo" : "fileinfo"
}
Message message = mapper.readValue(json, Message.class); // FileMessage
@JsonTypeInfo
이렇 듯 추상 타입으로 매핑(deserialize)할 때 특정 필드 값을 참조하여 자동으로 매핑을 해주는 것이 @JsonTypeInfo
이다.
@JsonTypeInfo
은 추상타입에 정의를 하고 어떤 필드(type)가 어떤 클래스로 매핑될 지 결정을 해주는 역할을 한다.
또한 추상 타입의 하위 타입이 어떤 것인지 정의하는 @JsonSubTypes
와 같이 사용하게 된다.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true)
@JsonSubTypes({
@JsonSubTypes.Type(value = TextMessage.class, name = "TEXT"),
@JsonSubTypes.Type(value = FileMessage.class, name = "FILE")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class Message {
private long messageId;
private MessageType type;
public Message(long messageId, MessageType type) {
this.messageId = messageId;
this.type = type;
}
}
@JsonTypeInfo
use = JsonTypeInfo.Id.NAME
식별자를 어떤 것으로 지정할 것인지 정의
include = JsonTypeInfo.As.EXISTING_PROPERTY
- 현재 필드 정보를 사용
- 만일 JsonTypeInfo.As.PROPERY를 사용한다면 직렬화 시 type정보가 두 번 표시된다.
property = "type"
- 유형에 대한 필드 명이다. 소스 내에서
MessageType type
으로 지정이 되어 있다.visible = true
type으로 지정한 필드(MessageType)로 역직렬화를 할지 여부
- false로 지정되면 직렬화되지 않는다. (null)
defaultImpl = TextMessage.class
여기서 설정하지는 않았는데,
defaultImpl
은 type 값이 존재하지 않을 경우 매핑할 클래스의 기본 값을 설정할 수 있다.예를 들어
defaultImpl = TextMessage.class
으로 지정을 하는 경우에는 아래와 같이type
이 없는 경우에 TextMessage로 매핑하겠다는 의미이다.{ "messageId" : 1, "message" : "텍스트" }
@JsonSubTypes
- value = TextMessage.class는 어떤 클래스로 매핑할 지 정의
- name = "TEXT"는 type의 이름이 TEXT일 경우 TextMessage로 매핑한다는 의미이다.
여기서name
은 생략될 수 있는 데 생략하는 경우 아래와 같이 실제 매핑되는 클래스에@JsonTypeName
를 정의해야 한다.@JsonTypeName(value = "TEXT") public class TextMessage extends Message { ... }
- 아래와 같이 두 값을 동시에 설정하니
@JsonSubTypes
에 정의한 name이 우선 적용된다.// 1. TEXT로 정의 @JsonSubTypes.Type(value = TextMessage.class, name = "TEXT") // text로 정의 @JsonTypeName(value = "text") public class TextMessage extends Message { .. }
이렇게 @JsonTypeInfo와 @JsonSubTypes를 설정한 다음 실행해보자.
String json = "{\"messageId\":1,\"type\":\"TEXT\",\"message\":\"텍스트\"}";
Message message = mapper.readValue(json, Message.class);
System.out.println(message); // com.example.jsontypeinfo.message.TextMessage@704921a5
잘 실행되는 것을 확인할 수 있다.
type이 여러 값(TEXT, text)일 경우 TextMessage에 매핑되게 하려면 어떻게 하면 될까?
name
대신 names
로 배열을 넘기면 된다.
@JsonSubTypes.Type(value = TextMessage.class, names = {"TEXT", "text"})
type
이 TEXT
이거나 text
일 경우 TextMessage로 매핑한다.