본문 바로가기
자바

[자바] @JsonTypeInfo, @JsonSubTypes 를 활용하여 수정 이력 쌓아보기

by kdohyeon (김대니) 2023. 3. 17.
반응형

다양한 도메인의 백오피스 어드민을 개발하다보면 필수 요구사항으로 수정 이력 기능이 항상 포함되어 있습니다. 데이터에 대한 트래킹을 하기 위해 이 기능이 필요한데, 다양한 방법을 활용하여 구현할 수 있습니다. 이번에는 Jackson 라이브러리에서 제공하는 @JsonTypeInfo 와 @JsonSubTypes 를 활용하여 수정 이력 데이터를 쌓아보도록 하겠습니다. (구글에 찾아보니 대부분의 경우 spring-data-envers 라는 라이브러리를 활용하는 예시를 찾아볼 수 있었는데, 다음에는 spring-data-envers 를 활용해보고 비교를 한번 해보면 좋을 것 같습니다.)


환경 및 요구사항

수정 이력이 쌓이는 환경은 다음과 같다.

  • 예시: 상품 (Product) 도메인
  • 상품 (Product) 엔티티와 상품의 수정 이력 (ProductHistory) 엔티티는 서로 1:N 맵핑으로 구성되어 있다.
  • 상품이 생성, 수정, 또는 삭제 (CUD) 될 때, 수정 이력이 남는다.
  • 상품 엔티티는 다양한 형태의 데이터 구조가 포함될 수 있다 (예: json)

상품과 수정 이력은 1:N 관계로 맵핑되어 있다.

수정 이력에는 다음의 정보가 기록되어야 한다.

  • 생성/수정/삭제 등의 이력 타입
  • 변경 전 데이터
  • 변경 후 데이터

위 정보를 취합하여 필요한 기능을 다음으로 정리할 수 있다.

요구사항
1) 생성, 수정, 삭제의 경우에 수정 이력이 남아야 하며, 2) 수정 이력은 변경 전/후 데이터를 기록해야 하며 3) JSON 타입 등 다양한 형태의 데이터를 지원할 수 있어야 한다.

수정 이력 엔티티

수정 이력을 남기기 위한 테이블 구조는 다음과 같이 설계했다.

ProductHistory 테이블

  • HistoryType: 생성, 수정, 삭제, 복사 등의 이력 타입
  • HistoryReason: 수정 이력에 대해 표현할 수 있는 한 문장 (어드민에 노출시킬 용도)
  • HistoryDetail: 수정 이력에 대한 상세 데이터(변경 전/후)를 JSON 형태로 관리

HistoryDetail

HistoryDetail 이 중요한 부분이라 좀 더 자세히 설명을 하면, Captured 라는 인터페이스를 리스트 형태로 가지고 있다. 하나의 Captured 는 하나의 필드에 대해 변경 전/후 값을 가지고 있다. 예를 들어, 하나의 상품이 "상품명", "상품 설명", "상품 이미지" 라는 필드로 구성되어 있다고 한다면 3개의 Captured 가 존재한다. 또한, addIfChanged(...) 라는 메소드를 통해 각 Captured 를 탐색하여 변경 전/후를 비교하고 다른 점이 포착되면 changes 리스트에 추가한다.

Captured 가 인터페이스 형태로 존재하는 이유는 각 데이터 형태마다 custom 한 데이터 전/후 비교 로직을 구현해야 하기 때문이다. 인터페이스를 상속받아 각 구현체에서 입맛에 맞게 비교 로직을 구현하면 된다. 문자열 (String) 비교는 .equals() 메소드를 활용해야 하고, 숫자 비교는 == 를 활용해야 한다. 또한 Custom 하게 구성된 데이터 (예: dto) 는 별도의 비교 로직으로 처리해야 한다. 

전체 구조

Jackson 라이브러리의 @JsonTypeInfo, @JsonSubTypes

Captured 가 인터페이스 형태로 제공이 되고 다양한 형태의 데이터에 대해 비교 로직을 구현해내야 하는데, 코드 레벨에서 해당 기능을 제공하려면 Jackson 라이브러리의 @JsonTypeInfo 와 @JsonSubTypes 를 활용할 수 있다.

Captured

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    property = "type"
)
@JsonSubTypes({
    @JsonSubTypes.Type(value = StringCaptured.class, name = "STRING"),
    @JsonSubTypes.Type(value = NumberCaptured.class, name = "NUMBER"),
    @JsonSubTypes.Type(value = JsonMetadataCaptured.class, name = "JSON_METADATA"),
    @JsonSubTypes.Type(value = DtoCaptured.class, name = "DTO"),
})
public interface Captured {
    boolean changed();
}
  • @JsonTypeInfo: 인터페이스나 추상 클래스에 적용할 수 있고 구현 클래스를 정의하기 위한 어노테이션. uses = JsonTypeInfo.Id.Name 을 통해 직접 지정한 name 으로 구현 클래스를 정의하고자 했다. 
  • @JsonSubTypes: 구현 클래스에 대한 정보를 정의하기 위한 어노테이션

결국 Json 데이터를 자바 객체로 변환 (직렬화, 역직렬화) 하는 과정이 필요한데, 인터페이스/추상 클래스를 바로 직렬화 할 수는 없다. 구현체를 지정해주어야 직렬화를 할 수 있는데, 그 때 어떤 구현체를 참조해야 할지에 대한 정의를 내리는 부분이라고 이해하면 된다. 예시에서 처럼, StringCaptured.class 로 변환을 할건지, NumberCaptured.class 로 변환을 할건지에 대해 name = "STRING", name = "NUMBER" 로 정의하고 이를 기반으로 변환을 한다.

구현체, StringCaptured 

@Getter
@EqualsAndHashCode
public class StringCaptured implements Captured {
    private final String name;
    private final String oldValue;
    private final String newValue;

    @Builder
    @ConstructorProperties({"name", "oldValue", "newValue"})
    public StringCaptured(String name, String oldValue, String newValue) {
        this.name = name;
        this.oldValue = oldValue;
        this.newValue = newValue;
    }

    @Override
    public boolean changed() {
        return !StringUtils.equalsIgnoreCase(oldValue, newValue);
    }
}

DTO 클래스 구현체

커스텀한 DTO 클래스를 구현하는 경우에는 그 DTO 에 @EqualsAndHashCode 등을 활용해 커스텀한 equals 메소드를 override 해서 정의해둔 뒤 아래를 참고하여 구현할 수 있다.

@Getter
@EqualsAndHashCode
public class DtoCaptured implements Captured {
    private final String name;
    private final Dto oldValue;
    private final Dto newValue;

    @Builder
    @ConstructorProperties({"name", "oldValue", "newValue"})
    public PlanDisplayTypeCaptured(String name, Dto oldValue, Dto newValue) {
        this.name = name;
        this.oldValue = oldValue;
        this.newValue = newValue;
    }

    @Override
    public boolean changed() {
        return !oldValue.equals(newValue);
    }
}

데이터

데이터베이스에 데이터가 저장되면 아래와 같이 저장된다.

{
  "changes": [
    {
      "name": "상품명",
      "type": "STRING",
      "newValue": "아이폰 11 PRO",
      "oldValue": "iPhone 11 Pro"
    },
    {
      "name": "상품 설명",
      "type": "STRING",
      "newValue": "아이폰 11 PRO 입니다.",
      "oldValue": "This is iPhone 11 Pro"
    },
    {
      "name": "상품 이미지",
      "type": "LIST",
      "newValue": [
        {
          "image": "a.jpg"
        }
      ],
      "oldValue": [
        {
          "image": "a.jpg"
        },
        {
          "image": "b.jpg"
        }
      ]
    }
  ]
}

 

반응형

댓글