JPA에 JSON 컬럼을 제네릭으로 유연하게 매핑하기

게으른 사람·2022년 11월 27일
3
post-thumbnail

1. 개요

이 글은 JPA에서 JSON컬럼을 매핑하는 방법을 소개합니다.
Spring boot 2.7.6 환경에서 작성된 글이며 다음 라이브러리가 필요합니다. spring-boot-starter-data-jpa, spring-boot-starter-json (보통 web에 포함되어 있다.)


2. 데이터 타입을 클래스로

엔티티에 컬럼을 매칭할 때 JSON컬럼은 단순하게 매핑할 수가 없다. JPA는 기본값 타입에 대한 매핑만 지원을 하며 그외의 타입은 @Converter를 제공하여 매핑할 수 있게한다.

@Converter에 대한 상세설명은 공식문서, baeldung 예시에서 찾아볼 수 있다.

@Converter가 무슨 기능인지 파악을 했다면 이제 JSON컬럼을 객체로 변환해보자.
우리는 다음과 같은 테이블을 엔티티로 변환해야한다.

CREATE TABLE product (
  product_id int,
  product_property json,
  product_prices json -- JSON 배열
)

3. JSON 컬럼 매핑하기 (객체)

우선 AttributeConverter을 상속하여 Converter객체를 작성해주어야한다.

@Converter
public class JsonConverter<T> implements AttributeConverter<T, String> { // 1

    protected final ObjectMapper objectMapper;

    public JsonConverter() {
        objectMapper = new ObjectMapper(); // 2
    }

    @Override
    public String convertToDatabaseColumn(T attribute) {
        if (ObjectUtils.isEmpty(attribute)) {
            return null;
        }
        try {
            return objectMapper.writeValueAsString(attribute); // 3
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public T convertToEntityAttribute(String dbData) {
        if (StringUtils.hasText(dbData)) {
            Class<?> aClass = 
                GenericTypeResolver.resolveTypeArgument(getClass(), JsonConverter.class); // 4
            try {
                return (T) objectMapper.readValue(dbData, aClass); // 5
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
        return null;
    }
}
  1. 기본적인 Converter 사용방식이다. AttributeConverter<X,Y>의 제네릭 X는 엔티티 속성으로 변환될 타입, 제네릭 Y는 컬럼 데이터 타입으로 변환될 타입이다. 여기서 X는 상속 시 정의될 속성의 타입 제네릭으로 선언하고, Y는 String으로 선언했다.
  2. 문자열을 JSON으로 변환하기위한 ObjectMapper() 다른 방식으로 생성하거나, 다른 라이브러리를 사용해도 좋다.
  3. 엔티티 속성을 데이터 타입으로 변환하는 메소드이다. 여기선 writeValueAsString을 사용해 변환해주었다.
  4. 문자열을 엔티티 속성으로 변환하는 메소드이다. 엔티티 속성의 타입을 알기 위해 Spring의 GenericTypeResolver.resolveTypeArgument을 사용하였다. 다른 방법이 있다면 그 방법을 사용하면된다. 목적은 엔티티 속성의 타입을 얻는 것이다.
  5. readValue를 사용하여 문자열을 엔티티 속성 타입으로 변환해주었다.

이제 이렇게 정의한 Converter 클래스는 다음과 같이 사용할 수 있다.

public class ProductProperty {

    private LocalTime sellingStartTime;
    private LocalTime sellingEndTime;

    private Integer stock;

    // getters and setters
    // override equals and hashcode

    public static class ProductPropertyConverter extends JsonConverter<ProductProperty> {} // 1
}

엔티티 속성에서 사용될 클래스를 정의해준다.
1. Converter클래스 선언을 해주었다. JsonConverter<T>를 상속하였고 제네릭에 매핑할 클래스인 ProductProperty를 선언했다.

⚠️ 주의
equalshashcode를 재정의해주어야 한다. JPA에서 변경감지 시 영속성 컨텍스트에 있는 엔티티와 수정한 엔티티를 비교할 때 해당 속성 객체의 equals, hashcode를 사용하여 동등성을 비교하는데 재정의하지 않을 시 두 엔티티의 인스턴스가 다르므로 무조건 update 쿼리가 실행된다. 이를 방지하기 위해 꼭 equalshashcode를 재정의하자.

엔티티 내에선 다음과 같이 작성한다.

@Entity
public class Product {

    @Id @GeneratedValue
    private Long productId;

    @Convert(converter = ProductProperty.ProductPropertyConverter.class)
    private ProductProperty productProperty;

    ...
}

4. JSON 컬럼 매핑하기 (배열)

대부분 JSON 객체안에 배열을 선언하여 잘 사용될 일은 없지만 그래도 사용방법을 소개해보도록 한다.
객체때와 마찬가지로 AttributeConverter을 상속하여 Converter객체를 작성한다.

@Converter
public class JsonArrayConverter<T> implements AttributeConverter<List<T>, String> { // 1

    private final TypeReference<List<T>> typeReference; // 2

    public JsonArrayConverter(TypeReference<List<T>> typeReference) {
        this.typeReference = typeReference;
    }

    @Override
    public String convertToDatabaseColumn(List<T> attribute) {
        if (attribute == null || attribute.isEmpty()) {
            return null;
        }
        return objectMapper.writeValueAsString(attribute);
    }

    @Override
    public List<T> convertToEntityAttribute(String dbData) {
        if (StringUtils.hasText(dbData)) {
            return objectMapper.readValue(dbData, typeReference);
        }
        return null;
    }
}
  1. 배열을 매핑하기 위해 List를 사용하였다.
  2. 제네릭이 중첩되므로 전체 제네릭 타입을 얻기 위해 jackson 라이브러리의 Typereference 변수를 JsonArrayConverter 생성 시 초기화 해주어야 한다.

이제 이렇게 정의한 Converter 클래스는 다음과 같이 사용할 수 있다.

public class ProductPrices {

    private Long price;

    // getters and setters
    // override equals and hashcode

    public static class ProductPricesConverter extends JsonArrayConverter<ProductPrices> {
        public ProductPricesConverter() {
            super(new TypeReference<>() {}); // 1
        }
    }
}

엔티티 속성에서 사용될 클래스를 정의해준다.
1. 현재 클래스의 TypeReference를 생성하여 상위 클래스에 넘겨준다.

엔티티 내에선 다음과 같이 작성한다.

@Entity
public class Product {

    @Id @GeneratedValue
    private Long productId;

    @Convert(converter = ProductProperty.ProductPropertyConverter.class)
    private ProductProperty productProperty;

    @Convert(converter = ProductPrices.ProductPricesConverter.class)
    private List<ProductPrices> productPrices;
    ...
}

5. 결론

Converter를 사용하면 같은 데이터 타입이더라도 다양한 객체로 매핑할 수 있습니다.

예시의 전체 소스코드는 깃헙에서 확인할 수 있습니다.

profile
웹/앱 백앤드 개발자

0개의 댓글