운영 시스템에서는 데이터의 현재 상태만큼이나 어떻게 변경되었는지를 추적하는 기능이 중요합니다.
특히 관리형 백오피스, 승인 시스템, 정산 시스템처럼 데이터의 변경 책임을 분명히 해야 하는 환경에서는 History 구조가 사실상 필수입니다.
이 글에서는 JPA 기반 프로젝트에서 사용할 수 있는 통일된 History 관리 패턴을 정리합니다.
실제 업무 코드에 바로 적용할 수 있도록, 도메인 의존 표현은 제거하고 예제는 범용적인 형태로 바꿨습니다.
일반적으로 변경 이력 관리가 필요한 이유는 아래와 같습니다.
단순히 updated_at 하나만 두는 방식으로는 충분하지 않습니다.
INSERT / UPDATE / DELETE 각각의 시점별 스냅샷이 남아 있어야 실제 운영에서 의미가 있습니다.
핵심 아이디어는 단순합니다.
Master PK + 변경일시(CHG_DT) 조합으로 구성한다.이 구조를 사용하면, 서비스 계층에서는 “저장 후 record 한 번”으로 이력 적재를 통일할 수 있습니다.
HistoryConvertible<T>Master 엔티티가 자신의 History 엔티티를 생성하는 규약입니다.
package com.example.common.audit;
public interface HistoryConvertible<T> {
T toHistory(ChangeType changeType);
}
역할은 명확합니다.
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(...) 코드를 제거참고로 실무에서는
@PersistenceContext와final조합보다, 일반 생성자 주입으로EntityManager를 받는 쪽이 더 자연스럽습니다.
예시는 구조 설명에 집중하기 위해 단순화했습니다.
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 같은 업무 이벤트를 추가할 수도 있습니다.
다만 처음에는 너무 세분화하지 말고 변경 행위 중심으로 두는 편이 관리하기 쉽습니다.
가장 무난한 방식은 아래 규칙입니다.
MASTER_TABLE_NAME + _HIST
예시:
ARTICLE → ARTICLE_HISTUSER_PROFILE → USER_PROFILE_HIST규칙이 단순해야 운영자, DBA, 개발자 모두 빠르게 이해할 수 있습니다.
History 테이블의 PK는 아래처럼 잡는 것을 권장합니다.
Master PK + CHG_DT
예를 들어 Master PK가 단일 키라면:
ARTICLE_ID + CHG_DT
복합 키라면:
ARTICLE_ID + VERSION_NO + CHG_DT
이 방식의 장점은 다음과 같습니다.
단, 동일한 CHG_DT 값이 충돌하지 않도록 주의해야 합니다.
초당 다건 변경 가능성이 높다면 DB timestamp 정밀도 또는 별도 sequence 전략도 검토해야 합니다.
History 테이블에는 최소한 아래 필드가 필요합니다.
@Column(name = "CHG_DIV", length = 5)
private String chgDiv;
의미:
I : 등록U : 수정D : 삭제이 값이 있어야 같은 PK 기준 이력 목록을 볼 때 어떤 이벤트인지 즉시 구분할 수 있습니다.
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;
}
장점은 명확합니다.
이제 범용적인 예제로 전체 구조를 보겠습니다.
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;
}
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;
}
포인트는 두 가지입니다.
contentIdchgDt이 두 개가 복합키가 됩니다.
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 매핑 오류가 발생합니다.
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 엔티티가 가진다”는 점입니다.
서비스 계층이 모든 필드를 복사하게 만들면:
따라서 변환 책임은 엔티티 쪽에 두는 것이 낫습니다.
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);
}
}
서비스 계층에서는 규칙이 매우 단순합니다.
이 패턴이 모든 도메인에 반복 적용되면 전체 프로젝트의 감사 이력 구조가 정리됩니다.
@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;
}
@Entity
@Table(name = "ARTICLE_HIST")
@IdClass(ArticleHistId.class)
public class ArticleHist extends ArticleBase {
@Id
private String articleId;
@Id
private LocalDateTime chgDt;
private String chgDiv;
}
구조는 단순합니다.
ARTICLE_IDARTICLE_ID + CHG_DT복합키를 가진 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;
}
@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;
}
복합키여도 규칙은 같습니다.
CHG_DT 추가CHG_DIV 로 이벤트 구분이 패턴은 단순하지만, 실제 적용 시 몇 가지는 반드시 정리해 두는 것이 좋습니다.
chgDt 값의 기준을 통일해야 한다가장 흔한 문제는 Master 의 chgDt 와 History 의 chgDt 생성 시점이 달라지는 경우입니다.
예를 들어:
이 둘이 다르면 추적이 어색해집니다.
가급적이면 다음 중 하나로 통일하는 것이 좋습니다.
즉, chgDt 는 “대충 현재 시간”이 아니라 같은 트랜잭션에서 동일한 기준값이어야 합니다.
실무에서는 실제 삭제보다 Soft Delete가 훨씬 안전합니다.
이유는 단순합니다.
즉, 보통은 다음 방식이 더 낫습니다.
D 적재이 구조의 가장 큰 위험은 “어떤 서비스에서는 record를 호출했고, 어떤 서비스에서는 빼먹는” 상황입니다.
대응 방법은 보통 세 가지입니다.
다만 JPA 기반 업무 로직에서 가장 읽기 쉬운 방식은 여전히 서비스 계층에서 명시적으로 호출하는 방식입니다.
트레이드오프는 있지만, 디버깅과 제어가 쉽습니다.
toHistory() 에서 필드를 하나라도 빠뜨리면, 그 컬럼은 이력에 남지 않습니다.
그래서 아래 기준을 추천합니다.
toHistory() 는 builder 순서를 고정엔티티가 많아지면 MapStruct 같은 매핑 도구를 고민할 수도 있지만,
History 는 보통 “의도적으로 어떤 필드가 남는지”가 중요하므로 초반에는 수동 매핑이 더 명확할 때가 많습니다.
History 테이블은 시간이 갈수록 커집니다.
따라서 최소한 아래 인덱스는 고려해야 합니다.
CHG_DT 정렬/범위 조회 인덱스CHG_DIV 포함 인덱스예를 들어, 특정 데이터의 변경 이력을 최신순으로 자주 본다면
(MASTER_PK..., CHG_DT DESC) 형태가 유리할 수 있습니다.
다음과 같은 프로젝트에 특히 잘 맞습니다.
반대로 아래 경우에는 다른 방식도 고려할 수 있습니다.
즉, 이 방식은 JPA 중심의 일반적인 업무 시스템에서 가장 실용적입니다.
정리하면, 이 History 관리 방식의 핵심은 아래 네 가지입니다.
HistoryRecorder 로 저장을 일원화한다.Master PK + CHG_DT 로 구성한다.CHG_DIV 로 변경 이벤트를 구분한다.이 패턴의 장점은 화려하지 않지만 강력합니다.
이력 관리는 기능 개발 초반에는 귀찮아 보여도,
운영 단계에 들어가면 “없으면 곤란한 기능”이 됩니다.
처음부터 규칙을 통일해 두면 이후 비용이 크게 줄어듭니다.
다음 단계로는 이런 것도 확장할 수 있습니다.
createdBy, updatedBy 와 연계한 변경 사용자 자동 기록@EntityListeners 기반 공통 감사 필드 자동화프로젝트 규모가 커질수록 “History를 남긴다”보다
“어떻게 일관되게 남기느냐” 가 더 중요합니다.
그 기준을 만드는 데 이 패턴이 충분히 좋은 출발점이 될 수 있습니다.