Home 감사 이력 관리 방법 비교
Post
Cancel

감사 이력 관리 방법 비교

글을 작성하게 된 계기


PG사는 대부분의 변경 작업에 대해 감사 이력을 남겨야 하는데요, 이 과정에서 여러 가지 방법을 비교하며 생각을 정리하기 위해 글을 작성하게 되었습니다.





1. 히스토리 관리 방법


고민했던 히스토리 관리 방법에는 크게 Trigger, Jpa Envers, 직접 INSERT, 로그 정도가 있었습니다. 각 방식의 장/단점에 대해 살펴보겠습니다. 설명을 위해 가맹점에 적용되는 수수료가 변경될 경우, 히스토리를 남긴다 는 예제를 사용하겠습니다.

1
2
3
4
5
6
7
8
9
10
-- 가맹점 수수료 관리 테이블
CREATE TABLE merchant_fee (
    merchant_id BIGINT PRIMARY KEY COMMENT '가맹점 ID',
    fee_percent DECIMAL(5, 2) NOT NULL COMMENT '수수료율 (%)',
    updated_by VARCHAR(50) NOT NULL COMMENT '수정자',
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '수정일시'
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci
COMMENT = '가맹점 수수료 관리 테이블';
1
2
3
4
5
6
7
8
9
10
11
12
-- 가맹점 수수료 변경 이력 테이블
CREATE TABLE merchant_fee_history (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
    merchant_id BIGINT NOT NULL COMMENT '가맹점 ID',
    before_fee_percent DECIMAL(5, 2) COMMENT '변경 전 수수료율 (%)',
    after_fee_percent DECIMAL(5, 2) COMMENT '변경 후 수수료율 (%)',
    updated_by VARCHAR(50) NOT NULL COMMENT '수정자',
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '변경일시'
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci
COMMENT = '가맹점 수수료 변경 이력 테이블';




1-1. Trigger

트리거(Trigger)는 데이터베이스에서 제공하는 기능으로, 특정 이벤트(INSERT, UPDATE, DELETE 등)가 발생할 때 자동으로 실행되는 프로시저입니다.

A trigger is a named database object that is associated with a table, and that activates when a particular event occurs for the table. Some uses for triggers are to perform checks of values to be inserted into a table or to perform calculations on values involved in an update.



트리거를 사용하면 테이블에 변경이 생길 때마다 데이터베이스 레벨 에서 히스토리를 기록할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
DELIMITER //

CREATE TRIGGER merchant_fee_history_trigger
AFTER UPDATE ON merchant_fee
FOR EACH ROW
BEGIN
    IF NEW.fee_percent <> OLD.fee_percent THEN
        INSERT INTO merchant_fee_history (
            merchant_id,
            before_fee_percent,
            after_fee_percent,
            updated_by,
            updated_at
        )
        VALUES (
            OLD.merchant_id,
            OLD.fee_percent,
            NEW.fee_percent,
            NEW.updated_by,
            NOW()
        );
    END IF;
END //

DELIMITER ;




트리거의 장점은 성능, 일관성 입니다.

  • 성능: 데이터베이스 내부에서 직접 실행되기 때문에 네트워크를 거치거나 애플리케이션 단에서 별도로 쿼리를 실행할 필요가 없습니다. 이로 인해 별도의 부하 없이 빠르게 감사 이력을 남길 수 있습니다.
  • 일관성: 데이터베이스에 정의되어 있기 때문에 누가 어떤 경로로 데이터를 변경하더라도 반드시 실행됩니다. API, 관리자 도구, 또는 직접 DB 수정 등 어떤 방식으로 데이터를 변경하더라도 동일하게 동작하기 때문에, 시스템 전반에서 일관된 감사 로그를 유지할 수 있습니다.



반면 단점은 유지보수의 어려움, 예측의 어려움, 트랜잭션 및 예외 처리 관리의 어려움 입니다.

  • 유지보수: 테이블에 컬럼이 추가되거나 수정될 경우 트리거 역시 함께 수정해야 하는데, 이를 놓치면 감사 이력이 누락되거나 잘못 기록될 수 있습니다.
  • 예측의 어려움: 대량 변경 시 부하가 발생하는 것은 모든 방식에 해당하지만, 트리거는 코드에 명시되지 않고 자동으로 동작하기 때문에 부하를 예측하고 조정하기 어렵다는 점에서 더 위험합니다.
  • 트랜잭션 및 예외 처리: 트리거 내부에서 오류가 발생할 경우 전체 트랜잭션이 실패하게 되며, 이러한 오류는 애플리케이션 단에서 직접 잡아내기 어렵습니다. 또한 트리거는 코드에 명시되지 않기 때문에 신규 개발자나 다른 팀원이 인지하지 못하고 놓치는 경우가 발생할 수 있습니다.





1-2. Jpa Envers

Envers는 Hibernate에서 제공하는 감사(Audit) 기능으로, 별도의 테이블에 엔티티의 변경 이력을 자동으로 관리해 주는 기능입니다. 별도의 트리거를 두거나 직접 INSERT를 작성할 필요 없이, JPA 엔티티에 @Audited 어노테이션 하나로 손쉽게 감사 이력을 남길 수 있습니다.

Envers is an extension to Hibernate ORM that provides an easy way to add auditing / versioning for entities.



이는 내부적으로 별도의 REVINFO 테이블과 엔티티별 _AUD 테이블을 생성하여 관리하기 때문인데, 예를 들어, merchant_fee 테이블이 있다면, Envers는 아래와 같은 테이블을 자동으로 만들어 사용합니다.

1
2
3
4
CREATE TABLE REVINFO (
    REV INT AUTO_INCREMENT PRIMARY KEY,
    REVTSTMP BIGINT
);
1
2
3
4
5
6
7
8
9
CREATE TABLE merchant_fee_AUD (
    merchant_id BIGINT NOT NULL,
    fee_percent DECIMAL(5,2),
    updated_by VARCHAR(50),
    updated_at TIMESTAMP,
    REV INT NOT NULL,
    REVTYPE TINYINT,
    PRIMARY KEY (merchant_id, REV)
);




Envers의 장점은 개발 편의성 입니다. @Audited 어노테이션 한 줄로 간단하게 적용할 수 있으며, Insert, Update, Delete 전부 알아서 히스토리를 남겨겨주기 때문에 개발자가 별도로 신경 쓸 필요가 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
@Audited
@Table(name = "merchant_fee")
public class MerchantFee {
    @Id
    private Long merchantId;

    @Column(name = "fee_percent")
    private BigDecimal feePercent;

    @Column(name = "updated_by")
    private String updatedBy;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
}



반면 단점은 JPA에 대한 의존성, 성능 입니다.

  • JPA에 대한 의존성: MyBatis, JDBC 등 다른 방식과 혼용할 경우 감사 이력이 누락될 수 있습니다.
  • 성능: 모든 변경 사항을 별도로 Insert 하기 때문에 기본적으로 한 칼럼을 변경하더라도 전체 레코드가 INSERT 되기 때문입니다. 또한 JPA는 더티 체킹을 통해 변경된 엔티티를 추적하기 때문에, 대량의 데이터를 처리할 때 성능 저하가 발생할 수 있습니다.





1-3. 직접 INSERT

직접 INSERT 방식은 애플리케이션 코드에서 변경 전/후 값을 비교한 후, 히스토리 테이블에 직접 INSERT 쿼리를 작성하는 방식입니다. 개발자가 명시적으로 쿼리를 작성하기 때문에 가장 직관적이고, 가장 유연한 방식입니다. 예를 들어, 다음과 같이 JDBC를 통해 변경 전/후 값을 조회하고, 변경 시 히스토리를 기록할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
BigDecimal beforeFee = jdbcTemplate.queryForObject(
    "SELECT fee_percent FROM merchant_fee WHERE merchant_id = ?",
    BigDecimal.class,
    merchantId
);

jdbcTemplate.update(
    "UPDATE merchant_fee SET fee_percent = ?, updated_by = ?, updated_at = NOW() WHERE merchant_id = ?",
    newFee, updatedBy, merchantId
);

if (!Objects.equals(beforeFee, newFee)) {
    jdbcTemplate.update(
        "INSERT INTO merchant_fee_history (merchant_id, before_fee_percent, after_fee_percent, updated_by, updated_at) VALUES (?, ?, ?, ?, NOW())",
        merchantId, beforeFee, newFee, updatedBy
    );
}





또는 대량 데이터 변경의 경우, batchUpdate 를 이용해 한번에 여러 건을 효율적으로 넣을 수도 있고요. 단점은 코드 작성량이 많다진다 는 점인데요, 딱 이 정도 있을 것 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
List<Object[]> batchParams = new ArrayList<>();
for (MerchantFeeUpdateRequest req : requestList) {
    batchParams.add(new Object[] {
        req.getMerchantId(),
        req.getBeforeFeePercent(),
        req.getAfterFeePercent(),
        req.getUpdatedBy()
    });
}

jdbcTemplate.batchUpdate(
    "INSERT INTO merchant_fee_history (merchant_id, before_fee_percent, after_fee_percent, updated_by, updated_at) VALUES (?, ?, ?, ?, NOW())",
    batchParams
);







2. 언제 어떤 방법을 사용할까?


각 방법은 상황에 따라 장단점이 다르기 때문에, 프로젝트의 규모, 데이터 변경 방식, 성능 요구사항 등을 고려하여 적절한 방법을 선택해야 합니다.

또한 조직의 성향에 따라 히스토리를 개발자가 관리할지 DBA가 관리할지도 중요한 고려 대상 중 하나 입니다.



2-1. 트리거

트리거는 변경 이력을 반드시 남겨야 할 때 유용합니다. 특히 외부 시스템이나 관리자 툴에서 직접 DB를 수정 하는 경우까지 대비하고 싶다면 트리거가 적절합니다. 혹은 한 번 구조가 정해지면 거의 변하지 않는 테이블 일 경우에도요.

  • 외부 시스템이나 관리자 툴에서 직접 DB를 수정하는 경우까지 기록을 남기고 싶을 때
  • 구조가 거의 변하지 않는 테이블에 대해 변경 이력을 남겨야 할 때




2-2. JPA Envers

JPA Envers는 소규모 시스템 에서 사용하는 것이 적합합니다. 모든 데이터 변경이 JPA를 통해 이루어지고, 외부에서 DB를 직접 수정할 일이 없는 환경 에서요. 예를 들어, 관리자 페이지나 내부 서비스처럼 단순하게 변경 이력을 관리하고 싶을 때 효율적입니다.

  • 소규모 또는 단일 시스템에서 JPA를 통해 모든 데이터 변경이 이루어질 때
  • 관리 대상 테이블이 많지 않고, 관리자 페이지나 내부 서비스처럼 단순하게 변경 이력을 관리하고 싶을 때




2-3. 직접 INSERT

직접 INSERT 방식은 프로젝트 규모와 관계없이 사용 할 수 있습니다. 특히 JPA와 MyBatis, JDBC가 혼용되거나, 이력 관리에 유연함이 필요한 경우 유용합니다. 복잡한 비즈니스 로직 에서 특정 컬럼만 이력 을 남기거나, 변경 사유, 메타데이터를 함께 저장해야 하는 경우 에 적합하며, 대량의 데이터를 처리해야 하는 환경에서도 많이 사용됩니다.

  • JPA와 MyBatis, JDBC가 혼용되는 환경에서 유연한 이력 관리가 필요할 때
  • 복잡한 비즈니스 로직에서 특정 컬럼만 이력을 남기거나, 변경 사유, 메타데이터를 함께 저장해야 할 때
  • 대량의 데이터를 처리해야 하는 환경에서 효율적인 이력 관리가 필요할 때







3. 정리


현재 프로젝트에서는 JPA를 사용하지만 직접 INSERT 방식을 사용하고 있습니다. 수백, 수천만 건 단위로 데이터를 다루다 보니 JPA Envers를 사용하기에는 성능 부담이 크고, 트리거를 사용하기에는 유연성이 떨어지기 때문입니다.


This post is licensed under CC BY 4.0 by the author.

MySQL 벌크 UPDATE에 대한 오해: Row-by-Row

대량 쓰기 작업에서의 멱등성은 어떻게 보장할까?