불변 감사 원장
고칠 수 있는 문자열 로그가 변경의 기록을 잃는 과정을 재현하고, 행위자와 사유, 요청, 변경 전후의 값을 하나의 감사 이벤트로 기록하는 추가 전용 원장을 만듭니다. 트리거로 UPDATE와 DELETE를 거부하고, 잘못된 기록은 보정 이벤트로 바로잡습니다.
감사 로그(audit log)는 디버그 메시지의 다른 이름이 아닙니다. "운영자 42가 검수 기록을 확인해 게시글 제목을 정정했다"는 사건은 주체와 이유, 변경 전후의 값, 일어난 시각, 요청의 식별자를 함께 가져야 하며, 하나라도 빠지면 나중에 누가 바꾸었고 그 변경이 정당했는지 판단할 수 없습니다.
또 하나의 함정은 감사 테이블 자체가 평범한 UPDATE의 대상이라는 점입니다. 잘못된 기록을 조용히 고치게 허용하면 원래 어떤 값이 있었는지가 다시 사라지므로, 정정이 필요할 때는 기존 이벤트를 바꾸지 않고 취소나 보정의 이벤트를 덧붙입니다.
이 글은 게시글의 제목 정정을 현재 테이블에 반영하면서, 공통 감사 헤더와 필드 변경의 자식 행을 함께 기록합니다. 헤더는 엔티티의 시간선과 행위자 검색을 맡고, 자식은 어떤 JSON 경로가 바뀌었는지 빠르게 찾게 합니다. 예제의 before_state와 after_state는 변경 전후의 제목만 보존하며, 전체 게시글의 스냅샷과는 범위가 다릅니다.
일반 애플리케이션 계정에는 승인된 프로시저의 실행만 허용하는 권한 정책이 필요합니다. 아래 코드는 UPDATE와 DELETE를 거부하는 트리거를 구현하지만 계정의 권한 설정은 따로이며, 데이터베이스 소유자는 트리거를 지울 수도 있으므로 주기적인 다이제스트와 외부 보관도 함께 논의합니다.
자유 형식 감사 기록의 한계
엔티티와 메시지의 두 열만 있으면 "제목을 바꾼 사건"을 구조적으로 검색할 수 없습니다. 더 심각하게는 운영자가 문구를 다듬는 UPDATE로 처음의 기록을 덮을 수 있으므로, 행이 있다는 사실만으로는 기록을 믿을 수 없습니다.
DROP TABLE IF EXISTS post_audit_message_bad;
CREATE TABLE post_audit_message_bad (
log_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
entity_name VARCHAR(40) NOT NULL,
entity_id BIGINT UNSIGNED NOT NULL,
message TEXT NOT NULL,
logged_at DATETIME(6) NOT NULL,
PRIMARY KEY (log_id)
) ENGINE=InnoDB;
INSERT INTO post_audit_message_bad
(entity_name, entity_id, message, logged_at)
VALUES
('posts', 7001, 'user changed something', '2026-07-15 14:00:00'),
('posts', 7001, 'view_count 60 to 90', '2026-07-15 14:05:00');
UPDATE post_audit_message_bad
SET message = 'approved correction'
WHERE log_id = 1;
DELETE FROM post_audit_message_bad
WHERE log_id = 2;
SELECT * FROM post_audit_message_bad;
SELECT log_id FROM post_audit_message_bad
WHERE message LIKE '%title%';log_id | entity_name | entity_id | message | logged_at
1 | posts | 7001 | approved correction | 2026-07-15 14:00:00.000000UPDATE와 DELETE가 모두 성공해, 처음의 메시지는 덮어써지고 조회수의 기록은 사라졌습니다. 제목을 바꾼 사건을 찾는 두 번째 조회는 빈 결과이고, 행위자와 요청, 변경 전 상태는 어디에도 없어 되살릴 수 없습니다.
문자열 하나에는 기계가 검증할 수 있는 규칙이 없습니다. 행위자가 실제 회원인지 외래 키로 확인할 수 없고, 요청의 중복을 UNIQUE로 막을 수도 없으며, 변경 전과 후가 달라졌는지 비교하기도 어렵습니다.
추가 전용(append-only) 감사 원장은 이력 테이블과 목적이 다릅니다. 스냅샷은 과거의 상태를 읽기 쉽게 복제하고, 감사 이벤트는 누가 어떤 이유로 변경을 승인했는지 확인하게 합니다. 스냅샷이 있다고 행위자가 저절로 생기지 않고, 감사 JSON이 있다고 정확한 시점 복원 쿼리가 완성되지도 않습니다.
한 변경에서 제목과 view_count가 함께 바뀌어도 행위자와 사유는 하나이므로, 공통 속성은 필드마다 반복하지 않고 헤더에 둡니다. 자식의 기본 키 (event_id, field_path)는 같은 이벤트가 같은 경로를 두 번 기록하는 모순을 막습니다.
추가 전용 감사의 원칙
현재 행의 UPDATE는 성공했는데 감사 INSERT가 실패하거나 그 반대가 되면, 기록과 상태가 갈라집니다. 두 쓰기는 하나의 트랜잭션에서 커밋해야 하며, 애플리케이션이 다른 연결로 로그를 나중에 보낸다면 그 사이의 장애를 설계가 감당해야 합니다.
request_id는 네트워크의 재전송을 식별합니다. 같은 요청이 다시 도착하면 새 이벤트를 만들지 않고 기존의 event_id를 돌려줍니다. 아래 프로시저는 중복 요청의 조회와 게시글 행의 잠금, 현재 행의 변경, 헤더와 필드의 기록을 차례로 수행하며, 뒤의 두 호출은 차례로 일어난 재전송의 예입니다. 동시에 경합할 때는 중복 키나 교착 상태의 오류를 처리하는 재시도 정책도 필요합니다.
감사 내용 자체가 잘못되었다면, 원래 행은 그대로 두고 original_event_id로 그 행을 가리키는 보정 이벤트를 더하고 새 행의 사유에 보정의 이유를 적습니다(연습 문제 2). 삭제를 요구받을 수 있는 개인정보는 감사 내용에 원문을 넣지 않거나, 암호화나 토큰화의 정책을 먼저 세웁니다.
트리거는 권한 실수를 막는 마지막 방어선일 뿐, 외부에서의 변조까지 확인하지는 못합니다. 바이너리 로그의 보존과 백업의 접근 통제, 주기적인 다이제스트의 서명을 별도의 운영 통제로 함께 둡니다.
더 깊이: 행을 막는 트리거가 막지 못하는 문장
트리거가 반응하는 사건은 행 단위의 INSERT, UPDATE, DELETE뿐입니다. MySQL 8.4 매뉴얼에 따르면 TRUNCATE TABLE은 DELETE와 비슷해 보여도 DDL로 분류되며 ON DELETE 트리거를 실행하지 않으므로, 아래의 deny_audit_field_delete 같은 트리거가 있어도 테이블의 모든 행을 비울 수 있습니다. 게다가 TRUNCATE는 암묵적으로 커밋되어 롤백할 수 없고, 평소에는 번호를 다시 쓰지 않는 InnoDB에서도 AUTO_INCREMENT 값을 시작값으로 되돌립니다. DROP TABLE이나 트리거 자체를 지우는 DROP TRIGGER도 DML이 아니므로 트리거로는 막을 수 없습니다.
외래 키가 일부를 막아 주기는 합니다. 다른 테이블의 외래 키가 가리키는 InnoDB 테이블은 TRUNCATE가 실패하므로, final_audit_field_change가 참조하는 final_audit_event는 이 방법으로 비울 수 없습니다. 하지만 참조하는 쪽인 final_audit_field_change는 그대로 비울 수 있어, 이벤트 헤더만 남고 필드의 변경 내용이 사라질 수 있습니다.
그래서 끝까지 기댈 곳은 트리거가 아니라 권한입니다. TRUNCATE에는 테이블의 DROP 권한이, DROP TRIGGER에는 그 테이블의 TRIGGER 권한이 필요하므로, 애플리케이션과 운영 계정에서 감사 테이블에 대한 이 두 권한을 빼 두어야 트리거가 지키려는 규칙이 우회되지 않습니다.
원자적인 변경 기록
DDL은 요청의 유일성과 행위자의 외래 키, 동작의 집합, 사유의 최소 길이를 선언합니다. 변경 전후의 JSON은 유효한 JSON 타입으로 저장되고, 필드 자식은 JSON 경로별 값을 그대로 보관합니다.
DROP PROCEDURE IF EXISTS correct_post_with_audit;
DROP TRIGGER IF EXISTS deny_audit_event_update;
DROP TRIGGER IF EXISTS deny_audit_event_delete;
DROP TRIGGER IF EXISTS deny_audit_field_update;
DROP TRIGGER IF EXISTS deny_audit_field_delete;
DROP TABLE IF EXISTS final_audit_field_change;
DROP TABLE IF EXISTS final_audit_event;
CREATE TABLE final_audit_event (
event_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
request_id BINARY(16) NOT NULL,
entity_type VARCHAR(40) NOT NULL,
entity_id BIGINT UNSIGNED NOT NULL,
action_code VARCHAR(30) NOT NULL,
actor_id BIGINT UNSIGNED NOT NULL,
reason VARCHAR(240) NOT NULL,
before_state JSON NOT NULL,
after_state JSON NOT NULL,
occurred_at DATETIME(6) NOT NULL,
recorded_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (event_id),
CONSTRAINT uq_audit_request UNIQUE (request_id),
CONSTRAINT fk_audit_actor FOREIGN KEY (actor_id)
REFERENCES members (id) ON DELETE RESTRICT,
CONSTRAINT chk_audit_action CHECK (
action_code IN ('CREATE','CORRECT','STATUS_CHANGE','SOFT_DELETE','RESTORE')
),
CONSTRAINT chk_audit_reason CHECK (CHAR_LENGTH(TRIM(reason)) >= 10),
INDEX ix_audit_entity_time
(entity_type, entity_id, occurred_at DESC, event_id DESC)
) ENGINE=InnoDB;
CREATE TABLE final_audit_field_change (
event_id BIGINT UNSIGNED NOT NULL,
field_path VARCHAR(120) NOT NULL,
before_value JSON NOT NULL,
after_value JSON NOT NULL,
PRIMARY KEY (event_id, field_path),
CONSTRAINT fk_audit_field_event FOREIGN KEY (event_id)
REFERENCES final_audit_event (event_id) ON DELETE RESTRICT
) ENGINE=InnoDB;
DELIMITER //
CREATE TRIGGER deny_audit_event_update
BEFORE UPDATE ON final_audit_event FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit event is immutable';
END//
CREATE TRIGGER deny_audit_event_delete
BEFORE DELETE ON final_audit_event FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit event is immutable';
END//
CREATE TRIGGER deny_audit_field_update
BEFORE UPDATE ON final_audit_field_change FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit field change is immutable';
END//
CREATE TRIGGER deny_audit_field_delete
BEFORE DELETE ON final_audit_field_change FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit field change is immutable';
END//
CREATE PROCEDURE correct_post_with_audit (
IN p_request_id BINARY(16),
IN p_post_id BIGINT UNSIGNED,
IN p_actor_id BIGINT UNSIGNED,
IN p_new_title VARCHAR(160),
IN p_reason VARCHAR(240),
IN p_occurred_at DATETIME(6)
)
proc: BEGIN
DECLARE v_event_id BIGINT UNSIGNED;
DECLARE v_old_title VARCHAR(160);
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
SELECT event_id INTO v_event_id
FROM final_audit_event
WHERE request_id = p_request_id
FOR UPDATE;
IF v_event_id IS NOT NULL THEN
COMMIT;
SELECT v_event_id AS event_id, 'REPLAY' AS outcome;
LEAVE proc;
END IF;
SELECT title INTO v_old_title
FROM posts
WHERE id = p_post_id
FOR UPDATE;
IF v_old_title IS NULL OR CHAR_LENGTH(TRIM(p_reason)) < 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'invalid audited correction';
END IF;
UPDATE posts
SET title = p_new_title
WHERE id = p_post_id;
INSERT INTO final_audit_event
(request_id, entity_type, entity_id, action_code, actor_id,
reason, before_state, after_state, occurred_at)
VALUES
(p_request_id, 'posts', p_post_id, 'CORRECT', p_actor_id,
p_reason, JSON_OBJECT('title', v_old_title),
JSON_OBJECT('title', p_new_title), p_occurred_at);
SET v_event_id = LAST_INSERT_ID();
INSERT INTO final_audit_field_change
(event_id, field_path, before_value, after_value)
VALUES
(v_event_id, '$.title', JSON_QUOTE(v_old_title), JSON_QUOTE(p_new_title));
COMMIT;
SELECT v_event_id AS event_id, 'APPLIED' AS outcome;
END//
DELIMITER ;
SET @post_id = (SELECT id FROM posts ORDER BY id LIMIT 1);
SET @author_id = (SELECT author_id FROM posts WHERE id = @post_id);
SET @audit_request = UUID_TO_BIN('55555555-5555-4555-8555-555555555555');
CALL correct_post_with_audit(
@audit_request, @post_id, @author_id,
'append-only 감사 실습', '운영자가 원본 검수 기록을 확인한 제목 정정',
'2026-07-15 14:00:00.000000'
);
CALL correct_post_with_audit(
@audit_request, @post_id, @author_id,
'재전송에서 무시될 제목', '응답 손실 뒤 동일 요청을 그대로 재전송',
'2026-07-15 14:00:00.000000'
);
SELECT
(SELECT COUNT(*) FROM final_audit_event
WHERE request_id = @audit_request) AS events,
(SELECT COUNT(*) FROM final_audit_field_change AS f
JOIN final_audit_event AS e ON e.event_id = f.event_id
WHERE e.request_id = @audit_request) AS field_rows,
(SELECT title FROM posts WHERE id = @post_id) AS current_title;event_id | outcome
1 | APPLIED
event_id | outcome
1 | REPLAY
events | field_rows | current_title
1 | 1 | append-only 감사 실습첫 호출만 상태를 바꾸고 기록을 남겼으며, 두 번째 호출은 기존의 event_id 1을 돌려주었습니다. 감사 테이블은 새로 만들었으므로 첫 이벤트의 id는 1이고, 현재 제목은 첫 호출의 값입니다. 프로시저 안에서 이벤트 INSERT 뒤에 오류가 나면 현재 제목까지 롤백됩니다.
게시글의 존재는 UPDATE 앞의 SELECT ... FOR UPDATE로 판단합니다. 같은 제목으로 다시 바꾸면 MySQL이 실제로 바뀐 행의 수를 0으로 보고할 수 있으므로(UPDATE), 바뀐 행이 0이라는 것만으로 게시글이 없다고 판정하지 않습니다.
같은 request_id가 이미 처리되었다면 새 인자의 값은 적용하지 않습니다. 이 프로시저는 요청 ID를 찾으면 제목과 게시글, 행위자 같은 새 인자를 비교하기 전에 REPLAY로 빠지므로, 같은 ID에 같은 업무 입력이 들어왔는지까지는 검증하지 않습니다. 두 번째 호출의 제목을 일부러 다르게 준 것도 그 점을 보여 주려는 것이며, 요청 ID를 다른 업무에 다시 쓰지 않는다는 규칙과 입력 일치의 검증이 필요하다면 따로 설계합니다.
15장 1편의 스냅샷 이력을 함께 운영한다면, 이 프로시저도 revise_post_revision처럼 이력을 발행해야 합니다. 이 예제는 감사 원장만 보여 주려고 생략했으므로, 15장 1편의 현재와 열린 스냅샷의 대조 쿼리가 이 게시글의 제목 차이를 찾아냅니다.
entity_type과 entity_id는 여러 테이블을 가리키는 다형 참조라 일반적인 외래 키를 걸 수 없습니다. 이것이 공통 원장의 대가이므로, 허용하는 엔티티의 목록과 엔티티별 쓰기 프로시저를 관리하고 고아 검사는 entity_type별 UNION 쿼리로 합니다. 중요한 도메인은 전용 감사 테이블이 더 나을 수 있습니다.
헤더의 JSON과 필드 행이 서로 맞는지는 field_path로 헤더의 값을 꺼내 자식의 값과 비교해 확인합니다. JSON의 null과 SQL의 NULL은 다르므로 모든 필드 값을 JSON 타입 NOT NULL로 저장하고, 값이 없음을 나타낼 때는 CAST('null' AS JSON)을 씁니다. JSON의 리터럴은 소문자여야 합니다.
감사 원장의 무결성 확인
변조를 시도해 예상한 SIGNAL을 확인한 뒤, 네 가지 대조를 실행합니다. 대조 쿼리는 변화가 있는데 필드 행이 없는 이벤트, 헤더와 자식의 값이 다른 경우, 대상 게시글이 없는 경우, 요청의 중복을 각각 돌려줍니다.
UPDATE final_audit_event
SET reason = '나중에 바꾼 사유'
WHERE request_id = @audit_request;
DELETE FROM final_audit_event
WHERE request_id = @audit_request;
UPDATE final_audit_field_change
SET after_value = JSON_QUOTE('나중에 바꾼 제목')
WHERE event_id = (
SELECT event_id FROM final_audit_event WHERE request_id = @audit_request
) AND field_path = '$.title';
DELETE FROM final_audit_field_change
WHERE event_id = (
SELECT event_id FROM final_audit_event WHERE request_id = @audit_request
) AND field_path = '$.title';
SELECT e.event_id
FROM final_audit_event AS e
LEFT JOIN final_audit_field_change AS f ON f.event_id = e.event_id
WHERE e.before_state <> e.after_state
GROUP BY e.event_id
HAVING COUNT(f.field_path) = 0;
SELECT e.event_id, f.field_path
FROM final_audit_event AS e
JOIN final_audit_field_change AS f ON f.event_id = e.event_id
WHERE NOT (JSON_EXTRACT(e.before_state, f.field_path) <=> f.before_value)
OR NOT (JSON_EXTRACT(e.after_state, f.field_path) <=> f.after_value);
SELECT e.event_id
FROM final_audit_event AS e
LEFT JOIN posts AS s
ON e.entity_type = 'posts' AND s.id = e.entity_id
WHERE e.entity_type = 'posts' AND s.id IS NULL;
SELECT HEX(request_id), COUNT(*) AS copies
FROM final_audit_event
GROUP BY request_id
HAVING COUNT(*) <> 1;ERROR 1644 (45000): audit event is immutable
ERROR 1644 (45000): audit event is immutable
ERROR 1644 (45000): audit field change is immutable
ERROR 1644 (45000): audit field change is immutable헤더와 필드 자식에 대한 네 변조 문장은 각자의 BEFORE 트리거에서 거부됩니다. 헤더의 DELETE는 자식의 외래 키 RESTRICT에도 걸리지만, BEFORE DELETE 트리거가 행을 지우기 전에 먼저 실행되므로 트리거의 메시지가 보고됩니다.
이어지는 네 SELECT는 모두 빈 결과입니다. 첫 쿼리는 변화가 있는데 필드 행이 하나도 없는 이벤트를 찾고 두 번째는 기록된 경로의 값을 비교하므로, 여러 경로 가운데 일부만 빠진 경우까지 모두 찾아내지는 못합니다. 감사 필드를 일부러 빼거나 헤더 JSON을 직접 넣은 데이터를 별도의 복사 스키마에서 만들면, 해당 대조가 정확한 event_id를 가리킵니다.
운영 계정은 SHOW GRANTS와 적용된 역할까지 확인해 테이블의 직접 DML을 제한하고, 승인된 프로시저의 실행 권한을 점검합니다. information_schema.table_privileges만 보고 다른 범위에서 부여된 권한까지 없다고 판단하지는 않으며, 트리거만 믿으면 소유자의 인증 정보가 탈취된 경우를 탐지하지 못합니다.
감사 원장의 통제가 보장하는 범위
게시글과 요청 식별자를 확인한다. 기존 사건을 반환한다. 각 단계의 값과 전환 이유를 비교합니다.
AUDIT EVENT · ATOMIC CHANGE / REPLAY / CORRECTION
감사 원장도 변경 차단·완전성·외부 통제를 함께 설계한다
요청 유일성과 UPDATE·DELETE 거절 트리거는 선언된 범위에서 동작합니다. 기록하지 않은 필드, 다형 엔티티 고아, 소유자 DDL 권한과 외부 증거는 별 검증·운영 책임입니다.
| 통제 | 원문에서 막거나 찾는 것 | 남는 책임 |
|---|---|---|
| 요청 유일성 | 같은 request_id의 이벤트 중복 | 동시 경합 오류의 재시도 정책 |
| 변경 거절 트리거 | 헤더·필드 행의 UPDATE와 DELETE | 트리거 제거·DDL 권한 통제 |
| 행위자·원본 FK | 존재하는 회원·보정 원본 참조 | 다형 entity_id의 별도 고아 검사 |
| 헤더·필드 대사 | 기록한 JSON 경로 값의 불일치 | 모든 변경 경로를 기록했는지 검토 |
| 외부 보관·다이제스트 | 이 예제에는 구현 없음 | 정규화·서명·보관 정책 별도 구현 |
- 요청 유일성
- 원문에서 막거나 찾는 것: 같은 request_id의 이벤트 중복남는 책임: 동시 경합 오류의 재시도 정책
- 변경 거절 트리거
- 원문에서 막거나 찾는 것: 헤더·필드 행의 UPDATE와 DELETE남는 책임: 트리거 제거·DDL 권한 통제
- 행위자·원본 FK
- 원문에서 막거나 찾는 것: 존재하는 회원·보정 원본 참조남는 책임: 다형 entity_id의 별도 고아 검사
- 헤더·필드 대사
- 원문에서 막거나 찾는 것: 기록한 JSON 경로 값의 불일치남는 책임: 모든 변경 경로를 기록했는지 검토
- 외부 보관·다이제스트
- 원문에서 막거나 찾는 것: 이 예제에는 구현 없음남는 책임: 정규화·서명·보관 정책 별도 구현
같은 요청을 순차 재전송 · 새 인자 무시
첫 호출 APPLIED → 제목 append-only 감사 실습
둘째 REPLAY → 같은 event_id · 이벤트 1행 / 필드 1행
둘째의 재전송에서 무시될 제목은 적용하지 않음
예제 JSON은 제목만 보존합니다. 동시 경합은 중복 키·교착 재시도 정책이 필요합니다. 잘못된 감사 기록은 원본 UPDATE 대신 새 보정 이벤트로 추가하며 현재 게시글을 자동 정정하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 새 요청 잠금부터 같은 UUID 재시도까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 설명·조건 | 요청 유일성과 UPDATE·DELETE 거절 트리거는 선언된 범위에서 동작합니다. 기록하지 않은 필드, 다형 엔티티 고아, 소유자 DDL 권한과 외부 증거는 별도 검증·운영 책임입니다. |
| 제목 | 원 프로시저의 신규 요청과 재전송 분기 |
| 관계 설명 | 요청 ID를 조회하여 기존 이벤트가 있으면 새 인자 대조 없이 COMMIT 후 REPLAY로 끝납니다. 새 ID면 posts 잠금과 검사 후 현재 제목 UPDATE, 이벤트와 제목 필드 각 1행 INSERT를 같은 트랜잭션으로 커밋합니다. 새 ID에서 같은 제목의 정정을 거절하는 조건은 없습니다. SQL 예외는 처리기가 ROLLBACK하며 모든 오류 경로를 그림에 나열하지는 않습니다. 외부 보관/DDL 통제는 아래 비교표의 별도 책임입니다. |
| 표의 값 | 요청 유일성. 원문에서 막거나 찾는 것: 같은 request_id의 이벤트 중복. 남는 책임: 동시 경합 오류의 재시도 정책. 변경 거절 트리거. 원문에서 막거나 찾는 것: 헤더·필드 행의 UPDATE와 DELETE. 남는 책임: 트리거 제거·DDL 권한 통제. 행위자·원본 FK. 원문에서 막거나 찾는 것: 존재하는 회원·보정 원본 참조. 남는 책임: 다형 entity_id의 별도 고아 검사. 헤더·필드 대사. 원문에서 막거나 찾는 것: 기록한 JSON 경로 값의 불일치. 남는 책임: 모든 변경 경로를 기록했는지 검토. 외부 보관·다이제스트. 원문에서 막거나 찾는 것: 이 예제에는 구현 없음. 남는 책임: 정규화·서명·보관 정책 별도 구현. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 새 요청 잠금 게시글과 요청 식별자를 확인한다. |
|
|
| 함께 기록 현재 행과 사건·필드 행을 저장한다. |
|
|
| APPLIED 확정 성공하면 현재 행과 감사 기록이 함께 공개된다. |
| |
| 같은 UUID 재시도 기존 사건을 반환한다. |
|
|
감사 원장의 통제가 보장하는 범위
게시글과 요청 식별자를 확인한다. 기존 사건을 반환한다. 각 단계의 값과 전환 이유를 비교합니다.
AUDIT EVENT · ATOMIC CHANGE / REPLAY / CORRECTION
감사 원장도 변경 차단·완전성·외부 통제를 함께 설계한다
요청 유일성과 UPDATE·DELETE 거절 트리거는 선언된 범위에서 동작합니다. 기록하지 않은 필드, 다형 엔티티 고아, 소유자 DDL 권한과 외부 증거는 별 검증·운영 책임입니다.
| 통제 | 원문에서 막거나 찾는 것 | 남는 책임 |
|---|---|---|
| 요청 유일성 | 같은 request_id의 이벤트 중복 | 동시 경합 오류의 재시도 정책 |
| 변경 거절 트리거 | 헤더·필드 행의 UPDATE와 DELETE | 트리거 제거·DDL 권한 통제 |
| 행위자·원본 FK | 존재하는 회원·보정 원본 참조 | 다형 entity_id의 별도 고아 검사 |
| 헤더·필드 대사 | 기록한 JSON 경로 값의 불일치 | 모든 변경 경로를 기록했는지 검토 |
| 외부 보관·다이제스트 | 이 예제에는 구현 없음 | 정규화·서명·보관 정책 별도 구현 |
- 요청 유일성
- 원문에서 막거나 찾는 것: 같은 request_id의 이벤트 중복남는 책임: 동시 경합 오류의 재시도 정책
- 변경 거절 트리거
- 원문에서 막거나 찾는 것: 헤더·필드 행의 UPDATE와 DELETE남는 책임: 트리거 제거·DDL 권한 통제
- 행위자·원본 FK
- 원문에서 막거나 찾는 것: 존재하는 회원·보정 원본 참조남는 책임: 다형 entity_id의 별도 고아 검사
- 헤더·필드 대사
- 원문에서 막거나 찾는 것: 기록한 JSON 경로 값의 불일치남는 책임: 모든 변경 경로를 기록했는지 검토
- 외부 보관·다이제스트
- 원문에서 막거나 찾는 것: 이 예제에는 구현 없음남는 책임: 정규화·서명·보관 정책 별도 구현
같은 요청을 순차 재전송 · 새 인자 무시
첫 호출 APPLIED → 제목 append-only 감사 실습
둘째 REPLAY → 같은 event_id · 이벤트 1행 / 필드 1행
둘째의 재전송에서 무시될 제목은 적용하지 않음
예제 JSON은 제목만 보존합니다. 동시 경합은 중복 키·교착 재시도 정책이 필요합니다. 잘못된 감사 기록은 원본 UPDATE 대신 새 보정 이벤트로 추가하며 현재 게시글을 자동 정정하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 새 요청 잠금부터 같은 UUID 재시도까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 설명·조건 | 요청 유일성과 UPDATE·DELETE 거절 트리거는 선언된 범위에서 동작합니다. 기록하지 않은 필드, 다형 엔티티 고아, 소유자 DDL 권한과 외부 증거는 별도 검증·운영 책임입니다. |
| 제목 | 원 프로시저의 신규 요청과 재전송 분기 |
| 관계 설명 | 요청 ID를 조회하여 기존 이벤트가 있으면 새 인자 대조 없이 COMMIT 후 REPLAY로 끝납니다. 새 ID면 posts 잠금과 검사 후 현재 제목 UPDATE, 이벤트와 제목 필드 각 1행 INSERT를 같은 트랜잭션으로 커밋합니다. 새 ID에서 같은 제목의 정정을 거절하는 조건은 없습니다. SQL 예외는 처리기가 ROLLBACK하며 모든 오류 경로를 그림에 나열하지는 않습니다. 외부 보관/DDL 통제는 아래 비교표의 별도 책임입니다. |
| 표의 값 | 요청 유일성. 원문에서 막거나 찾는 것: 같은 request_id의 이벤트 중복. 남는 책임: 동시 경합 오류의 재시도 정책. 변경 거절 트리거. 원문에서 막거나 찾는 것: 헤더·필드 행의 UPDATE와 DELETE. 남는 책임: 트리거 제거·DDL 권한 통제. 행위자·원본 FK. 원문에서 막거나 찾는 것: 존재하는 회원·보정 원본 참조. 남는 책임: 다형 entity_id의 별도 고아 검사. 헤더·필드 대사. 원문에서 막거나 찾는 것: 기록한 JSON 경로 값의 불일치. 남는 책임: 모든 변경 경로를 기록했는지 검토. 외부 보관·다이제스트. 원문에서 막거나 찾는 것: 이 예제에는 구현 없음. 남는 책임: 정규화·서명·보관 정책 별도 구현. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 새 요청 잠금 게시글과 요청 식별자를 확인한다. |
|
|
| 함께 기록 현재 행과 사건·필드 행을 저장한다. |
|
|
| APPLIED 확정 성공하면 현재 행과 감사 기록이 함께 공개된다. |
| |
| 같은 UUID 재시도 기존 사건을 반환한다. |
|
|
각 이벤트의 정규화한 내용과 이전 해시를 합쳐 SHA-256 다이제스트를 저장하면, 중간 행의 삭제나 순서의 변경을 대조로 찾을 수 있습니다. 다만 JSON의 정규형 직렬화와 연쇄를 나누는 규칙이 정확해야 하므로, JSON 문자열을 단순히 이어 붙이는 방식으로 즉흥 구현하지 않습니다. 마지막 다이제스트를 정기적으로 별도의 보안 저장소에 서명해 보관해야, 데이터베이스 전체가 변조된 경우에도 의미가 있습니다. 해시 함수와 전자 서명이 각각 무엇을 확인해 주는지는 네트워크 교재의 암호화의 기초에서 다룹니다.
감사라는 이유로 이메일이나 접근 토큰, 자유 입력의 본문을 before_state에 그대로 복사하면 삭제 요청과 유출의 범위가 커집니다. 사건을 판단하는 데 필요한 식별자와 바뀐 업무 값만 남기고, 민감한 원문은 암호화된 보관소의 참조로 대신합니다.
주요 조회는 특정 엔티티의 시간선과 행위자별 기간 검색, 요청 단건의 판단입니다. 앞의 두 조회의 빈도와 보존량을 측정해 (actor_id, occurred_at) 보조 인덱스를 더할지 정하고, JSON의 모든 경로를 무분별하게 생성 열로 만들지는 않습니다.
| 따져 볼 것 | 질문 |
|---|---|
| 질문 | 여러 엔티티를 행위자나 요청을 기준으로 한 시간선에서 찾아야 하는가 |
| 무결성 | 다형 entity_id의 고아를 사후 대조로 감당할 수 있는가 |
| 내용 | 도메인마다 변경 전후의 구조와 보존 등급이 크게 다른가 |
| 변조 | 데이터베이스 안의 차단 외에 외부 다이제스트와 백업 통제가 있는가 |
| 권한 | 직접 DML을 막고 승인된 프로시저만 노출할 수 있는가 |
행위자와 요청을 가로질러 조사하는 요구가 강하면 공통 헤더가 유용하고, 금융처럼 엔티티의 외래 키와 타입을 엄격히 명시한 열이 더 중요하면 전용 원장을 고릅니다. 두 모델을 섞는다면 어느 테이블이 법적인 원장인지 한곳으로 정합니다.
정리
- 감사 이벤트는 행위자와 사유, 요청, 변경 전후의 값이 함께 있어야 조사할 수 있는 기록이 됩니다.
- 원본의 변경과 감사의 추가는 한 트랜잭션으로 묶어 부분 성공을 없앱니다.
- 같은
request_id의 재전송은 새 이벤트를 만들지 않고 기존 이벤트를 돌려줍니다. - 트리거와 최소 권한은 UPDATE와 DELETE를 막고, 보정은 새 사건으로 나타냅니다.
- 필드 자식과 헤더 JSON의 일치, 엔티티의 고아, 요청의 중복을 정기적으로 대조합니다.
연습 문제
1. 같은 요청과 같은 값
본문의 첫 호출이 성공한 직후의 두 경우를 따로 생각해 보세요. A는 같은 request_id로 다른 게시글과 제목을 보냅니다. B는 새 request_id로 지금과 같은 제목을 보내되, 있는 게시글과 유효한 행위자, 10글자 이상의 사유를 씁니다. 동시 경합과 다른 오류가 없다면 어느 경우가 새 이벤트를 만드나요?
해설 보기
A는 기존의 event_id 1을 돌려주고 새 인자를 적용하지 않습니다. 요청 ID를 찾자마자 REPLAY로 빠지기 때문입니다. B는 새 ID이므로 게시글을 잠그고, 새 이벤트와 제목 필드 행을 하나씩 더합니다. 현재 제목과 값이 같아도 이 프로시저에는 같은 값의 정정을 거부하는 조건이 없으므로, B의 before_state와 after_state의 제목은 같을 수 있습니다.
같은 요청의 재전송을 알아보는 것과 실제 값이 달라졌는지 판단하는 것은 서로 다른 조건입니다. B 같은 이벤트를 막으려면 v_old_title <=> p_new_title일 때 SIGNAL하거나 NOOP으로 응답하는 분기를 더합니다.
2. 보정 이벤트
잘못 기록된 제목 감사 이벤트를 지우거나 고치지 말고, 보정 이벤트로 바로잡아 보세요. 새 이벤트는 original_event_id로 원래 이벤트를 가리키고, 원래 이벤트의 변경 후 제목과 보정 이벤트의 변경 전 제목이 같은지 대조로 확인되어야 합니다.
해설 보기
감사 헤더에 NULL을 허용하는 original_event_id 자기 참조 외래 키를 더하고, 동작의 집합에 CORRECTION을 넣습니다. 보정하는 쪽은 원래 이벤트를 잠근 뒤 새 행을 더하며, 원래 이벤트의 시각과 내용은 건드리지 않습니다. 아래의 수동 트랜잭션은 원래 이벤트가 있는 성공 경로이므로, 도중에 오류가 나면 다음 문장을 실행하지 말고 ROLLBACK합니다. 보정의 대상은 감사 기록이며, 현재의 posts.title을 바꾸지는 않습니다.
ALTER TABLE final_audit_event
ADD COLUMN original_event_id BIGINT UNSIGNED NULL,
ADD CONSTRAINT fk_audit_correction_original
FOREIGN KEY (original_event_id)
REFERENCES final_audit_event (event_id) ON DELETE RESTRICT;
ALTER TABLE final_audit_event DROP CHECK chk_audit_action;
ALTER TABLE final_audit_event
ADD CONSTRAINT chk_audit_action CHECK (
action_code IN ('CREATE','CORRECT','STATUS_CHANGE','SOFT_DELETE','RESTORE','CORRECTION')
);
START TRANSACTION;
SELECT event_id INTO @original_event
FROM final_audit_event
WHERE request_id = @audit_request
FOR UPDATE;
INSERT INTO final_audit_event
(request_id, entity_type, entity_id, action_code, actor_id, reason,
before_state, after_state, occurred_at, original_event_id)
SELECT UUID_TO_BIN('66666666-6666-4666-8666-666666666666'),
entity_type, entity_id, 'CORRECTION', @author_id,
'원본 감사 기록의 제목 표기를 보정',
after_state, JSON_OBJECT('title', 'append-only 감사 원장 실습'),
'2026-07-15 16:00:00', event_id
FROM final_audit_event
WHERE event_id = @original_event;
SET @correction_event = LAST_INSERT_ID();
INSERT INTO final_audit_field_change
(event_id, field_path, before_value, after_value)
SELECT event_id, '$.title',
JSON_EXTRACT(before_state, '$.title'),
JSON_EXTRACT(after_state, '$.title')
FROM final_audit_event
WHERE event_id = @correction_event;
COMMIT;
SELECT c.event_id
FROM final_audit_event AS c
JOIN final_audit_event AS o ON o.event_id = c.original_event_id
WHERE c.action_code = 'CORRECTION'
AND NOT (c.before_state <=> o.after_state);원래 이벤트 1은 그대로 남고 보정 이벤트 2가 더해지며, 마지막 대조는 빈 결과입니다. CHECK는 지우고 다시 만드는 두 문장으로 나누어, 같은 이름을 한 ALTER 안에서 다시 쓰지 않게 했습니다. 기존 이벤트의 CORRECT는 새 CHECK도 지키므로 다시 만드는 단계가 실패하지 않습니다.
ALTER TABLE은 트리거의 대상이 아니므로 열을 더할 수 있고, 새 행의 INSERT도 막히지 않습니다. 보정 이벤트를 다시 UPDATE하려 하면 같은 트리거가 ERROR 1644 (45000): audit event is immutable로 거부합니다.
다음 글 이중 시간 모델에서는 실제로 유효했던 시각과 데이터베이스가 그 사실을 알게 된 시각을 함께 질의합니다.