Search

테이블의 JSON 필드 인터페이스 추상화하기

Created
2024/11/06 00:58
태그
개발
Status
Done

개요

개발 배경

역량검사 3.0 개발에 있어 버전관리를 인터페이스 추상화로 결정한 상황에서, 인터페이스를 엔티티 필드로 사용하여 테이블에 저장하도록 구현해보자.
일반적으로 인터페이스를 엔티티 필드로 사용하면, JPA는 어떤 구현체를 사용하여 데이터를 저장할 지 혹은 데이터를 꺼내올 지 알 수 없어 에러를 발생시킨다. 현재 개발해야하는 상황에서는 JSON(String)으로 저장된 엔티티 필드를 인터페이스로 추상화하고 버전에 따라 서로 다른 객체로 반환하도록 해야한다.
이를 해결하는 방법은 JsonTypeInfo 애노테이션을 통한 방법과 컨버터를 직접 구현하는 방법 두 가지가 있다. 이 상황 같이 DB에서 데이터를 꺼낼 때 사용되는 컨버터는 AttributeConverter를 구현하면 된다.

JsonTypeInfo

JsonTypeInfo는 Jackson 라이브러리에서 제공하는 기능으로 JSON 데이터를 직렬화 / 역직렬화에 필요한 속성을 지정하는데 사용되고, JsonSubTypes와 같이 사용되기도 한다.
@Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @JacksonAnnotation public @interface JsonTypeInfo { Id use(); As include() default JsonTypeInfo.As.PROPERTY; String property() default ""; Class<?> defaultImpl() default JsonTypeInfo.class; boolean visible() default false; }
Java
복사
위와 같이 속성으로는 use, include, property, visible, defaultImpl이 있다.

use

Id 타입을 받으며, JSON 객체의 역직렬화 시에 필요한 타입 정보를 어떻게 나타낼 지를 결정하는 속성이다.
public static enum Id { NONE((String)null), CLASS("@class"), MINIMAL_CLASS("@c"), NAME("@type"), DEDUCTION((String)null), CUSTOM((String)null); private final String _defaultPropertyName; private Id(String defProp) { this._defaultPropertyName = defProp; } public String getDefaultPropertyName() { return this._defaultPropertyName; } }
Java
복사
NONE : 타입 정보를 저장하지 않고 객체 타입을 추론하지도 않는다. 일반적으로 타입 정보를 숨기고 싶을 때 사용되며, 타입 정보가 없기 때문에 타입을 명확히 알고 있거나 다른 방법을 통해 타입을 지정해주어야 한다.
NAME : 타입 정보를 타입 이름으로 저장하고, 역직렬화 시 JSON 데이터에서 타입 정보를 읽고 해당 값을 통해 클래스 이름을 찾는다. 때문에 JSON 데이터 내에 타입 정보를 명시적으로 포함해야한다.
CLASS : 타입 정보를 클래스의 패키지 명을 포함한 전체 경로로 저장한다. 나머지는 위의 NAME 속성과 동일.
MINIMAL_CLASS : 타입 정보를 클래스명으로 저장한다.
DEDUCTION : 자식 클래스 혹은 구현 클래스의 필드를 보고 타입을 추론한다. 구조가 명확하고 특정한 패턴을 따르는 등 일관적인 경우에 사용된다.

include

As 타입을 받으며, JSON 데이터 내에 타입 정보를 포함하는 방법을 결정하는 속성이다.
public static enum As { PROPERTY, WRAPPER_OBJECT, WRAPPER_ARRAY, EXTERNAL_PROPERTY, EXISTING_PROPERTY; private As() { } }
Java
복사
PROPERTY : 타입 정보를 JSON 테이터의 속성으로 포함한다.
(ex. {”type”: “className”, … })
WRAPPER_OBJECT : 타입 정보를 JSON 데이터의 객체로 래핑한다.
(ex. {”className”: { 객체 데이터 } })
WRAPPER_ARRAY : 타입 정보를 JSON 데이터의 객체 배열로 래핑한다.
(ex. {”className”: [”객체 데이터 key”, { 객체 데이터 value }, … ] })
EXTERNAL_PROPERTY : 타입 정보를 JSON 객체의 외부 속성으로 분리하여 포함한다.
(ex. {”type”: “className”, “data”: { 객체 데이터 } })
EXISTING_PROPERTY : 객체 내에 이미 존재하는 속성을 사용해 타입 정보를 결정한다.

그 외 속성

visible : JSON 데이터 내에 저장된 타입 정보가 역직렬화된 객체에 표시되는지를 결정한다.
defaultImpl : 지정된 속성(property)에 대한 타입 정보가 없는 경우에 역직렬화 할 default 구현 클래스를 지정한다.

JsonTypeInfo 사용예시

@JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type", visible = true ) interface jsonClass { }
Java
복사

JsonSubTypes

타입 정보 속성에 따라 어떤 구현 클래스로 변환할 건지를 지정해주기 위한 애노테이션이다.
@Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @JacksonAnnotation public @interface JsonSubTypes { Type[] value(); boolean failOnRepeatedNames() default false; public @interface Type { Class<?> value(); String name() default ""; String[] names() default {}; } }
Java
복사
일반적으로 다음과 같이 사용된다.
@JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type", visible = true ) @JsonSubTypes( @JsonSubTypes.Type(value = implClass1.class, name = "className1"), @JsonSubTypes.Type(value = implClass2.class, name = "className2") ) interface jsonClass { }
Java
복사
use 속성을 DEDUCTION으로 하면 타입을 자체적으로 추론하기 때문에 다음과 같이 name 속성을 지정하지 않아도 된다.
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) @JsonSubTypes( @JsonSubTypes.Type(implClass1.class), @JsonSubTypes.Type(implClass2.class) ) interface jsonClass { }
Java
복사

AttributeConverter

일반적으로 AttributeConverter는 JPA가 지원하지 않는 타입을 매핑하거나 데이터의 저장 전/후처리가 필요한 경우에 사용된다.
public interface AttributeConverter<X, Y> { Y convertToDatabaseColumn(X var1); X convertToEntityAttribute(Y var1); }
Java
복사
AttributeConverter는 convertToDatabaseColumn 메서드와 convertToEntityAttribute 메서드를 구현하면 된다.
Y convertToDatabaseColumn(X attribute) : 엔티티의 필드인 X 타입을 DB 컬럼인 Y 타입으로 변환
X convertToEntityAttribute(Y dbData) : DB 컬럼인 Y 타입을 엔티티 필드인 X 타입으로 변환
위의 두 메서드를 직접 구현하여 원하는 타입으로 변환하거나, 여러 데이터를 묶어 새로운 타입으로 변환할 수 있게 된다.

구현

JsonTypeInfo 애노테이션 방식

구현해야하는 부분은 JSON 타입(String 타입)으로 저장되어 있는 필드를 인터페이스로 추상화하여, 버전에 따라 서로 다른 객체로 변환하는 부분이다. 적용하기에 앞서 JsonTypeInfo 애노테이션 방식을 사용할 수 있는지 알아보자.
역량검사 2.1 결과에는 버전 정보를 포함하지 않고 있고, 그에 따라 JSON 데이터 내에 타입 정보가 필요한 NAME, CLASS 속성은 사용이 불가능하다.
그나마 사용이 가능한 DEDUCTION의 경우에는 필드 구성을 보고 Jackson 라이브러리가 변환 타입을 결정하기 때문에 필드가 명확히 구분되어야 한다. 하지만 역량검사 2.1과 3.0 결과는 객체의 필드 구성은 동일하고 해당 필드의 하위 속성에서 차이가 있기 때문에 이 역시 사용이 불가하다.

AttributeConverter 방식

AttributeConverter의 경우에는 컨버터로 등록 후, DB에서 데이터를 꺼내와 변환 시에 해당 컨버터를 불러 변환을 수행하게 된다.
interface Result { }
Java
복사
public class ResultV2 implements Result { ... }
Java
복사
public class ResultV3 implements Result { private final String version = "V3"; ... }
Java
복사
먼저 이와 같이 추상화 인터페이스를 작성하고, 기존의 ResultV2와 새로 생성한 ResultV3 객체를 통해 인터페이스를 구현하도록 한다.
@Converter(autoApply = true) public class ResultConverter implements AttributeConverter<Result, String> { private final ObjectMapper objectMapper = new ObjectMapper(); public CapabilityResultSummaryConverter() { objectMapper.registerModule(new JavaTimeModule()); } @Override public String convertToDatabaseColumn(final Result result) { try { return objectMapper.writeValueAsString(result); } catch (JsonProcessingException e) { throw ConvertException.toJsonConvertException(e.getMessage()); } } @Override public Result convertToEntityAttribute(final String resultString) { try { if (resultString.contains("V3")) { return objectMapper.readValue(resultString, ResultV3.class); } return objectMapper.readValue(resultString, resultV2.class); } catch (JsonProcessingException e) { throw ConvertException.fromJsonConvertException(e.getMessage()); } } }
Java
복사
다음으로 이처럼 컨버터를 구현하여 등록한다. 여기서 ObjectMapper에 JavaTimeModule을 등록해준 이유는 ObjectMapper의 기본 변환 기능으로는 LocalDateTime을 변환하려하면 에러가 발생하게 된다. JavaTimeModule을 등록하여 이를 해결할 수 있다.
@Entity public class EntityClass { ... @Column(nullable = false, columnDefinition = "longtext") @Type(JsonType.class) private ResultV2 result; ... }
Java
복사
이와 같이 구현되어 있던 엔티티 필드를,
@Entity public class EntityClass { ... @Column(nullable = false, columnDefinition = "longtext") @Convert(converter = ResultConverter.class) private Result result; ... }
Java
복사
이처럼 인터페이스로 바꾸고 컨버터를 통해 데이터를 가져옴을 명시해주면 된다.
여기서 중요한 점은 @Type(JsonType.class) 애노테이션을 반드시 삭제해주어야 한다는 것이다. 해당 애노테이션이 붙어있으면, 컨버터는 동작하지 않고 에러가 발생하게 된다.
이를 통해 서로 다른 두 구현 클래스를 인터페이스로 묶어 JSON으로 같이 저장할 수 있게 된다.

결과

동작 확인

이와 같이 서로 다른 버전의 JSON을 같은 필드에 저장하고 조회할 수 있다.
이를 통해서 기존의 역량검사 로직을 그대로 사용하면서, 결과지에 대한 부분만 추상화를 통해 분리하여 구현할 수 있었다. 또한 이후 4.0 개발 시 조금 더 수월하게 개발하는 것이 가능해졌다.

참고