JPA 기반 History 관리 가이드

바다·6일 전

Spring

목록 보기
14/14
post-thumbnail

들어가며

운영 시스템에서는 데이터의 현재 상태만큼이나 어떻게 변경되었는지를 추적하는 기능이 중요합니다.
특히 관리형 백오피스, 승인 시스템, 정산 시스템처럼 데이터의 변경 책임을 분명히 해야 하는 환경에서는 History 구조가 사실상 필수입니다.

이 글에서는 JPA 기반 프로젝트에서 사용할 수 있는 통일된 History 관리 패턴을 정리합니다.
실제 업무 코드에 바로 적용할 수 있도록, 도메인 의존 표현은 제거하고 예제는 범용적인 형태로 바꿨습니다.


왜 별도 History 테이블이 필요한가

일반적으로 변경 이력 관리가 필요한 이유는 아래와 같습니다.

  • 누가 어떤 데이터를 언제 변경했는지 추적해야 한다.
  • 삭제된 데이터도 복원 가능하거나 조회 가능해야 한다.
  • 감사 대응, 장애 분석, 운영 이슈 추적이 필요하다.
  • 여러 테이블에서 같은 방식으로 이력을 남겨야 유지보수가 쉽다.

단순히 updated_at 하나만 두는 방식으로는 충분하지 않습니다.
INSERT / UPDATE / DELETE 각각의 시점별 스냅샷이 남아 있어야 실제 운영에서 의미가 있습니다.


이 글에서 제안하는 구조

핵심 아이디어는 단순합니다.

  1. Master 엔티티는 자신의 History 엔티티로 변환할 수 있어야 한다.
  2. HistoryRecorder가 공통 방식으로 History를 저장한다.
  3. History 테이블의 PK
    Master PK + 변경일시(CHG_DT) 조합으로 구성한다.
  4. 변경 구분값(CHG_DIV) 으로 INSERT / UPDATE / DELETE를 기록한다.

이 구조를 사용하면, 서비스 계층에서는 “저장 후 record 한 번”으로 이력 적재를 통일할 수 있습니다.


핵심 컴포넌트

1. HistoryConvertible<T>

Master 엔티티가 자신의 History 엔티티를 생성하는 규약입니다.

package com.example.common.audit;

public interface HistoryConvertible<T> {
    T toHistory(ChangeType changeType);
}

역할은 명확합니다.

  • Master → History 변환 책임을 엔티티 자신이 가진다.
  • 외부 서비스가 각 엔티티 필드를 일일이 복사하지 않아도 된다.
  • 패턴이 통일되어 새 테이블 추가 시 구현이 쉽다.

2. HistoryRecorder

공통 History 저장 서비스입니다.

package com.example.common.audit;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class HistoryRecorder {

    @PersistenceContext
    private final EntityManager em;

    public <T> void record(HistoryConvertible<T> master, ChangeType changeType) {
        T history = master.toHistory(changeType);
        em.persist(history);
    }
}

역할은 다음과 같습니다.

  • 서비스 계층에서 중복되는 historyRepository.save(...) 코드를 제거
  • 모든 History 저장 경로를 하나의 패턴으로 통일
  • 추후 공통 로깅, 예외 처리, 확장 포인트 추가가 쉬움

참고로 실무에서는 @PersistenceContextfinal 조합보다, 일반 생성자 주입으로 EntityManager 를 받는 쪽이 더 자연스럽습니다.
예시는 구조 설명에 집중하기 위해 단순화했습니다.


3. ChangeType

변경 유형을 코드로 관리합니다.

package com.example.common.audit;

import lombok.Getter;

@Getter
public enum ChangeType {
    INSERT("I"),
    UPDATE("U"),
    DELETE("D");

    private final String code;

    ChangeType(String code) {
        this.code = code;
    }

    public String code() {
        return code;
    }
}

보통 I / U / D 정도면 충분합니다.
필요하다면 RESTORE, APPROVE, CANCEL 같은 업무 이벤트를 추가할 수도 있습니다.
다만 처음에는 너무 세분화하지 말고 변경 행위 중심으로 두는 편이 관리하기 쉽습니다.


테이블 및 엔티티 설계 원칙

1. History 테이블 명명 규칙

가장 무난한 방식은 아래 규칙입니다.

MASTER_TABLE_NAME + _HIST

예시:

  • ARTICLEARTICLE_HIST
  • USER_PROFILEUSER_PROFILE_HIST

규칙이 단순해야 운영자, DBA, 개발자 모두 빠르게 이해할 수 있습니다.


2. History 테이블의 PK 구성

History 테이블의 PK는 아래처럼 잡는 것을 권장합니다.

Master PK + CHG_DT

예를 들어 Master PK가 단일 키라면:

ARTICLE_ID + CHG_DT

복합 키라면:

ARTICLE_ID + VERSION_NO + CHG_DT

이 방식의 장점은 다음과 같습니다.

  • 같은 데이터의 여러 시점 스냅샷을 모두 저장 가능
  • 시점별 조회가 쉬움
  • Master PK 기준 이력 정렬이 직관적

단, 동일한 CHG_DT 값이 충돌하지 않도록 주의해야 합니다.
초당 다건 변경 가능성이 높다면 DB timestamp 정밀도 또는 별도 sequence 전략도 검토해야 합니다.


3. 변경 구분 필드

History 테이블에는 최소한 아래 필드가 필요합니다.

@Column(name = "CHG_DIV", length = 5)
private String chgDiv;

의미:

  • I : 등록
  • U : 수정
  • D : 삭제

이 값이 있어야 같은 PK 기준 이력 목록을 볼 때 어떤 이벤트인지 즉시 구분할 수 있습니다.


4. Base 클래스 활용

Master 와 History 가 거의 같은 컬럼 구조를 가진다면, 공통 필드는 @MappedSuperclass 로 추출하는 것이 좋습니다.

import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@MappedSuperclass
@Getter
@SuperBuilder
@NoArgsConstructor
public abstract class ContentBase {

    @Column(name = "TITLE", length = 200)
    protected String title;

    @Column(name = "BODY", length = 2000)
    protected String body;

    @Column(name = "STATUS_CD", length = 20)
    protected String statusCd;
}

장점은 명확합니다.

  • Master / History 양쪽에 동일 필드 중복 선언 방지
  • 컬럼 추가/변경 시 수정 포인트 감소
  • 엔티티 구조 일관성 확보

구현 예제

이제 범용적인 예제로 전체 구조를 보겠습니다.


Step 1. Base 클래스 작성

import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@MappedSuperclass
@Getter
@SuperBuilder
@NoArgsConstructor
public abstract class ContentBase {

    @Column(name = "TITLE", length = 200)
    protected String title;

    @Column(name = "BODY", length = 2000)
    protected String body;

    @Column(name = "STATUS_CD", length = 20)
    protected String statusCd;

    @Column(name = "CHG_EMP_ID", length = 30)
    protected String chgEmpId;
}

Step 2. History 엔티티 작성

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@Entity
@Table(name = "CONTENT_HIST")
@IdClass(ContentHistId.class)
@Getter
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
public class ContentHist extends ContentBase {

    @Id
    @Column(name = "CONTENT_ID", length = 20)
    private String contentId;

    @Id
    @Column(name = "CHG_DT")
    private LocalDateTime chgDt;

    @Column(name = "CHG_DIV", length = 5)
    private String chgDiv;
}

포인트는 두 가지입니다.

  • Master PK인 contentId
  • 변경 시점인 chgDt

이 두 개가 복합키가 됩니다.


Step 3. HistId 클래스 작성

import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class ContentHistId implements Serializable {
    private String contentId;
    private LocalDateTime chgDt;
}

IdClass 사용 시 주의할 점은 아래와 같습니다.

  • 반드시 Serializable 구현
  • equals, hashCode 구현 필요
  • 필드명과 타입이 엔티티의 @Id 필드와 정확히 일치해야 함

이 부분이 어긋나면 JPA 매핑 오류가 발생합니다.


Step 4. Master 엔티티에서 HistoryConvertible 구현

import com.example.common.audit.ChangeType;
import com.example.common.audit.HistoryConvertible;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@Entity
@Table(name = "CONTENT")
@Getter
@SuperBuilder
@NoArgsConstructor
public class Content extends ContentBase
        implements HistoryConvertible<ContentHist> {

    @Id
    @Column(name = "CONTENT_ID", length = 20)
    private String contentId;

    @Column(name = "CHG_DT")
    private LocalDateTime chgDt;

    @Override
    public ContentHist toHistory(ChangeType changeType) {
        return ContentHist.builder()
                .contentId(this.contentId)
                .title(this.title)
                .body(this.body)
                .statusCd(this.statusCd)
                .chgEmpId(this.chgEmpId)
                .chgDiv(changeType.code())
                .chgDt(this.chgDt)
                .build();
    }
}

이 방식의 핵심은 “History 스냅샷 생성 책임은 Master 엔티티가 가진다”는 점입니다.

서비스 계층이 모든 필드를 복사하게 만들면:

  • 필드 누락 가능성 증가
  • 엔티티별 복사 코드 중복
  • 컬럼 추가 시 여러 곳 수정 필요

따라서 변환 책임은 엔티티 쪽에 두는 것이 낫습니다.


Step 5. 서비스에서 History 기록

import com.example.common.audit.ChangeType;
import com.example.common.audit.HistoryRecorder;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class ContentService {

    private final ContentRepository contentRepository;
    private final HistoryRecorder historyRecorder;

    @Transactional
    public void createContent(CreateContentRequest request) {
        Content content = ContentFactory.create(request);

        contentRepository.save(content);
        historyRecorder.record(content, ChangeType.INSERT);
    }

    @Transactional
    public void updateContent(String id, UpdateContentRequest request) {
        Content content = contentRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("content not found"));

        content.update(request);

        contentRepository.save(content);
        historyRecorder.record(content, ChangeType.UPDATE);
    }

    @Transactional
    public void deleteContent(String id) {
        Content content = contentRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("content not found"));

        content.delete();

        contentRepository.save(content);
        historyRecorder.record(content, ChangeType.DELETE);
    }
}

서비스 계층에서는 규칙이 매우 단순합니다.

  • 저장
  • History 기록

이 패턴이 모든 도메인에 반복 적용되면 전체 프로젝트의 감사 이력 구조가 정리됩니다.


단일 PK 예제

Master 엔티티

@Entity
@Table(name = "ARTICLE")
public class Article extends ArticleBase implements HistoryConvertible<ArticleHist> {

    @Id
    @Column(name = "ARTICLE_ID")
    private String articleId;

    @Column(name = "CHG_DT")
    private LocalDateTime chgDt;
}

History 엔티티

@Entity
@Table(name = "ARTICLE_HIST")
@IdClass(ArticleHistId.class)
public class ArticleHist extends ArticleBase {

    @Id
    private String articleId;

    @Id
    private LocalDateTime chgDt;

    private String chgDiv;
}

구조는 단순합니다.

  • Master PK: ARTICLE_ID
  • History PK: ARTICLE_ID + CHG_DT

복합 PK 예제

복합키를 가진 Master 도 같은 원리로 처리할 수 있습니다.

Master 엔티티

@Entity
@Table(name = "ARTICLE_ITEM")
@IdClass(ArticleItemId.class)
public class ArticleItem extends ArticleItemBase
        implements HistoryConvertible<ArticleItemHist> {

    @Id
    private String articleId;

    @Id
    private Integer itemSeq;

    private LocalDateTime chgDt;
}

History 엔티티

@Entity
@Table(name = "ARTICLE_ITEM_HIST")
@IdClass(ArticleItemHistId.class)
public class ArticleItemHist extends ArticleItemBase {

    @Id
    private String articleId;

    @Id
    private Integer itemSeq;

    @Id
    private LocalDateTime chgDt;

    private String chgDiv;
}

복합키 클래스

import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class ArticleItemHistId implements Serializable {
    private String articleId;
    private Integer itemSeq;
    private LocalDateTime chgDt;
}

복합키여도 규칙은 같습니다.

  • Master PK 전체를 그대로 포함
  • 마지막에 CHG_DT 추가
  • CHG_DIV 로 이벤트 구분

실무에서 주의할 점

이 패턴은 단순하지만, 실제 적용 시 몇 가지는 반드시 정리해 두는 것이 좋습니다.

1. chgDt 값의 기준을 통일해야 한다

가장 흔한 문제는 Master 의 chgDt 와 History 의 chgDt 생성 시점이 달라지는 경우입니다.

예를 들어:

  • Master 저장 시점의 시간
  • History 저장 시점의 시간

이 둘이 다르면 추적이 어색해집니다.

가급적이면 다음 중 하나로 통일하는 것이 좋습니다.

  • 서비스 레벨에서 변경 시각을 한 번 생성해 Master 와 History 에 함께 사용
  • 엔티티 공통 로직에서 같은 값을 주입

즉, chgDt 는 “대충 현재 시간”이 아니라 같은 트랜잭션에서 동일한 기준값이어야 합니다.


2. DELETE 는 Hard Delete보다 Soft Delete가 관리에 유리하다

실무에서는 실제 삭제보다 Soft Delete가 훨씬 안전합니다.

이유는 단순합니다.

  • 삭제 후에도 현재 테이블에서 상태 확인 가능
  • 복구 가능
  • 외래키, 참조 무결성 관리가 쉬움

즉, 보통은 다음 방식이 더 낫습니다.

  1. Master 에 삭제 플래그 반영
  2. 저장
  3. History 에 D 적재

3. History 저장 누락을 방지해야 한다

이 구조의 가장 큰 위험은 “어떤 서비스에서는 record를 호출했고, 어떤 서비스에서는 빼먹는” 상황입니다.

대응 방법은 보통 세 가지입니다.

  • 코드 리뷰 체크리스트에 포함
  • 서비스 패턴 템플릿화
  • 더 강하게 가려면 AOP / EntityListener / DB Trigger 검토

다만 JPA 기반 업무 로직에서 가장 읽기 쉬운 방식은 여전히 서비스 계층에서 명시적으로 호출하는 방식입니다.
트레이드오프는 있지만, 디버깅과 제어가 쉽습니다.


4. Base 필드 누락에 주의해야 한다

toHistory() 에서 필드를 하나라도 빠뜨리면, 그 컬럼은 이력에 남지 않습니다.

그래서 아래 기준을 추천합니다.

  • Base 클래스에 공통 필드 최대한 정리
  • toHistory() 는 builder 순서를 고정
  • 신규 컬럼 추가 시 체크리스트에 “History 반영” 포함

엔티티가 많아지면 MapStruct 같은 매핑 도구를 고민할 수도 있지만,
History 는 보통 “의도적으로 어떤 필드가 남는지”가 중요하므로 초반에는 수동 매핑이 더 명확할 때가 많습니다.


5. 조회 성능을 고려해 인덱스를 준비해야 한다

History 테이블은 시간이 갈수록 커집니다.
따라서 최소한 아래 인덱스는 고려해야 합니다.

  • Master PK 기준 조회 인덱스
  • CHG_DT 정렬/범위 조회 인덱스
  • 필요 시 CHG_DIV 포함 인덱스

예를 들어, 특정 데이터의 변경 이력을 최신순으로 자주 본다면
(MASTER_PK..., CHG_DT DESC) 형태가 유리할 수 있습니다.


언제 이 구조가 적합한가

다음과 같은 프로젝트에 특히 잘 맞습니다.

  • 관리자 시스템
  • 승인/결재 시스템
  • 정산/회계성 데이터 관리
  • 운영자가 수동 수정하는 백오피스
  • 변경 책임 추적이 중요한 시스템

반대로 아래 경우에는 다른 방식도 고려할 수 있습니다.

  • 엔티티 수가 매우 많고 변경이 극단적으로 빈번한 경우
  • CDC 기반 로그 수집 체계가 이미 있는 경우
  • DB 트리거나 이벤트 소싱 등 별도 표준이 정해진 조직

즉, 이 방식은 JPA 중심의 일반적인 업무 시스템에서 가장 실용적입니다.


마무리

정리하면, 이 History 관리 방식의 핵심은 아래 네 가지입니다.

  • Master 엔티티가 자신의 History 엔티티로 변환한다.
  • 공통 HistoryRecorder 로 저장을 일원화한다.
  • History PK는 Master PK + CHG_DT 로 구성한다.
  • CHG_DIV 로 변경 이벤트를 구분한다.

이 패턴의 장점은 화려하지 않지만 강력합니다.

  • 구조가 단순하다.
  • 팀 내 규칙으로 굳히기 쉽다.
  • 도메인이 늘어나도 같은 방식으로 확장된다.
  • 운영 이슈 대응력이 좋아진다.

이력 관리는 기능 개발 초반에는 귀찮아 보여도,
운영 단계에 들어가면 “없으면 곤란한 기능”이 됩니다.
처음부터 규칙을 통일해 두면 이후 비용이 크게 줄어듭니다.


함께 보면 좋은 확장 포인트

다음 단계로는 이런 것도 확장할 수 있습니다.

  • createdBy, updatedBy 와 연계한 변경 사용자 자동 기록
  • @EntityListeners 기반 공통 감사 필드 자동화
  • History 조회 전용 API / 화면 제공
  • 특정 변경 diff 비교 기능 추가
  • 벌크성 변경 작업에 대한 별도 이력 정책 분리

프로젝트 규모가 커질수록 “History를 남긴다”보다
“어떻게 일관되게 남기느냐” 가 더 중요합니다.
그 기준을 만드는 데 이 패턴이 충분히 좋은 출발점이 될 수 있습니다.

profile
ᴘʜɪʟɪᴘᴘɪᴀɴs 3:14

0개의 댓글