불변 감사 원장
행위자·사유·변경 전/변경 후·필드 변경을 하나의 감사 이벤트로 기록하고 UPDATE와 DELETE를 DB에서 거절합니다.
감사 로그는 디버그 메시지의 다른 이름이 아닙니다.
“운영자 42가 검수 기록을 확인해 게시글 제목을 정정했다”라는 사건은 주체, 이유, 변경 전후 값, 발생 시각, 요청 식별자를 함께 가져야 합니다.
이 중 하나가 빠지면 나중에 책임과 정당성을 판단할 수 없습니다.
또 하나의 함정은 감사 테이블 자체가 평범한 UPDATE 대상이라는 점입니다.
잘못된 기록을 조용히 고치도록 허용하면 원래 어떤 값이 있었는지 다시 사라집니다.
정정이 필요할 때는 기존 이벤트를 바꾸지 않고 취소 또는 보정 이벤트를 덧붙여야 합니다.
이번 실습은 게시글 제목 정정을 현재 테이블에 반영하면서 공통 감사 헤더와 필드 변경 자식을 기록합니다.
헤더는 엔티티 시간선과 행위자 검색을 담당하고, 자식은 어떤 JSON 경로가 바뀌었는지 빠르게 찾습니다.
before_state와 after_state는 당시 변경 전후 상태 전체를 보존한 증거입니다.
일반 애플리케이션 계정에는 감사 INSERT만 허용하고 UPDATE·DELETE는 트리거로 이중 차단합니다.
DB 소유자조차 트리거를 제거할 수 있으므로 주기적 다이제스트와 외부 보관도 함께 논의합니다.
자유 형식 감사 기록의 한계
엔티티와 메시지 두 열만 있으면 “제목을 바꾼 사건”을 구조적으로 검색할 수 없습니다.
더 심각하게는 운영자가 문구를 다듬는 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%';UPDATE와 DELETE는 성공하고 행위자, 사유, 변경 전 상태는 복원되지 않습니다.
오류 실행 결과remaining rows: 1
remaining message: approved correction
events changing title: 0
deleted evidence rows recoverable: 0
actor_id/request_id/before_state/after_state: absent문자열 하나는 기계가 검증할 수 있는 규칙을 갖지 않습니다.
행위자가 실제 회원인지 FK로 확인할 수도 없고, 요청 중복도 UNIQUE로 막을 수 없으며, 변경 전과 변경 후가 달라졌는지 비교하기도 어렵습니다.
추가 전용은 이력 테이블과 목적이 다릅니다.
스냅샷은 과거 상태를 읽기 쉽게 복제하고, 감사 이벤트는 누가 어떤 이유로 변경을 승인했는지 확인합니다.
스냅샷이 있다고 행위자가 자동으로 생기지 않고, 감사 JSON이 있다고 정확한 시점 복원 쿼리가 저절로 완성되지 않습니다.
이벤트 헤더와 필드 자식을 나누는 이유
한 변경에서 제목과 view_count가 함께 바뀌어도 행위자와 사유는 하나입니다.
공통 속성을 필드마다 반복하지 않고 헤더에 둡니다.
자식 PK(event_id, field_path)는 같은 이벤트가 동일 경로를 두 번 주장하는 모순을 막습니다.
추가 전용 감사 원칙
현재 UPDATE가 성공했는데 감사 INSERT에서 오류가 발생하거나 그 반대가 남으면 증거와 상태가 갈라집니다.
두 쓰기는 하나의 트랜잭션에서 커밋되어야 합니다.
애플리케이션이 서로 다른 연결로 로그를 나중에 보낸다면 그 사이 장애를 설계가 감당해야 합니다.
request_id는 네트워크 재전송을 식별합니다.
같은 요청이 다시 도착하면 새 이벤트를 만들지 않고 기존 event_id를 반환해야 합니다.
이 예제의 핵심 프로시저는 중복 요청 조회, 게시글 행 잠금, 현재 변경, 헤더/필드 기록을 차례로 수행합니다.
수정 대신 보정 이벤트를 연결하기
감사 내용 자체가 잘못되었다면 original_event_id를 참조하는 수정 동작을 추가할 수 있습니다.
원래 행은 보존하고 새 행의 사유에 보정 근거를 적습니다.
삭제 요구가 있는 개인정보는 감사 내용에 원문을 넣지 않거나 암호화·토큰화 정책을 먼저 세워야 합니다.
MySQL 8.4 메모: 트리거는 권한 실수를 막는 마지막 방어선이지 외부 변조 확인 전체가 아닙니다. 바이너리 로그 보존, 백업 접근 통제, 주기 다이제스트 서명을 별도 운영 통제로 결합합니다.
원자적 변경 기록
DDL은 요청 유일성, 행위자 FK, 동작 집합, 사유 최소 길이를 선언합니다.
변경 전/변경 후 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'
);첫 호출만 상태와 증거를 만들고 두 번째 호출은 기존 event_id를 돌려줍니다.
first outcome=APPLIED
second outcome=REPLAY
events for request=1
field rows for event=1
current title=append-only 감사 실습
UPDATE/DELETE attempts=SIGNAL 45000프로시저가 이벤트 INSERT 뒤 오류가 발생하면 현재 제목까지 롤백됩니다.
반대로 현재 UPDATE가 0행이면 게시글 없음으로 중단해야 하므로 SELECT FOR UPDATE에서 존재를 판단합니다.
같은 request_id가 이미 처리됐다면 새 인자 값은 적용하지 않습니다.
entity_type과 entity_id는 다형 참조라 일반 FK를 걸 수 없습니다.
공통 원장의 대가입니다.
허용 엔티티 목록과 엔티티별 쓰기 주체 프로시저를 관리하고, 고아 검사는 entity_type별 UNION 쿼리로 수행합니다.
중요한 도메인은 전용 감사 테이블이 더 나을 수 있습니다.
변경 전/변경 후와 필드 행이 서로 맞는지 검산하기
field_path를 사용해 헤더 JSON의 값을 추출하고 자식 값과 비교합니다.
JSON NULL과 SQL NULL은 다르므로 모든 필드 값을 JSON 타입 NOT NULL로 저장합니다.
값이 없음을 표현할 때는 CAST('NULL' AS JSON)을 사용합니다.
감사 원장의 무결성 확인
변조 시도는 예상 SIGNAL을 확인한 뒤 트랜잭션 상태를 정리합니다.
일치 검사 쿼리는 이벤트에서 실제 변화가 있는데 필드가 없는 경우, 헤더와 자식이 다른 경우, 대상 게시글이 없는 경우를 각각 반환합니다.
UPDATE final_audit_event
SET reason = '나중에 바꾼 사유'
WHERE request_id = @audit_request;
-- ERROR 1644: audit event is immutable
DELETE FROM final_audit_event
WHERE request_id = @audit_request;
-- ERROR 1644: audit event is immutable
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';
-- ERROR 1644: audit field change is immutable
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';
-- ERROR 1644: audit field change is immutable
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;헤더와 필드 자식에 대한 네 변조 구문은 각각의 트리거에서 거절됩니다.
이어지는 네 SELECT는 모두 0행이어야 합니다.
감사 필드를 수동으로 빼거나 헤더 JSON을 직접 넣는 우회 예제 데이터를 별도 복사 스키마에서 실행하면 해당 일치 검사가 정확히 event_id를 가리킵니다.
운영 계정의 GRANT도 확인합니다.
information_schema.table_privileges에서 UPDATE와 DELETE가 없어야 하며 프로시저 실행만 허용합니다.
트리거만 믿으면 소유자 인증 정보 탈취를 탐지하지 못합니다.
다이제스트 연쇄를 추가하면 알 수 있는 것
각 이벤트의 정규화한 내용과 이전 해시를 합쳐 SHA-256 다이제스트를 저장하면 중간 행 삭제나 순서 변경을 일치 검사할 수 있습니다.
정규형 JSON 직렬화와 연쇄 파티션 규칙이 정확해야 하므로 단순 JSON 문자열 연결로 즉흥 구현하지 않습니다.
정기적으로 마지막 다이제스트를 별도 보안 저장소에 서명해 보관해야 DB 전체 변조에도 의미가 있습니다.
개인정보 최소화
감사라는 이유로 이메일, 접근 토큰, 자유 입력 본문을 무조건 before_state에 복사하면 삭제 요청과 유출 범위가 커집니다.
사건 판단에 필요한 식별자와 변경된 업무 값만 남기고 민감 원문은 암호화된 보관소의 참조로 대체합니다.
감사 검색 인덱스
주요 조회는 특정 엔티티 시간선, 행위자별 기간 검색, 요청 단건 판단입니다.
앞의 두 쿼리 빈도와 보존량을 측정해 (actor_id, occurred_at) 보조 인덱스를 추가할지 결정합니다.
JSON 경로 전부를 무분별하게 생성 열로 만들지 않습니다.
감사 원장 유형 선택
| 판단 축 | 확인할 질문 |
|---|---|
| 질문 | 여러 엔티티를 행위자나 요청 기준으로 한 시간선에서 찾아야 하는가? |
| 무결성 | 다형 entity_id의 고아를 사후 일치 검사로 감당할 수 있는가? |
| 이벤트 내용 | 도메인마다 변경 전/변경 후 구조와 보존 등급이 크게 다른가? |
| 변조 | DB 내부 차단 외에 외부 다이제스트·백업 통제가 준비되어 있는가? |
| 권한 | 직접 DML을 막고 승인된 프로시저만 노출할 수 있는가? |
행위자와 요청을 가로질러 조사하는 요구가 강하면 공통 헤더가 유용합니다.
금융성 사건처럼 엔티티 FK와 엄격한 타입 명시 열이 더 중요하면 전용 원장을 선택합니다.
두 모델을 섞을 때 어떤 테이블이 법적 원장인지 한 곳으로 지정합니다.
연습 문제
잘못 기록된 제목 감사 이벤트를 지우거나 고치지 말고 수정 이벤트로 보정하세요.
새 이벤트는 original_event_id를 가리키고, 이전 변경 후 제목과 보정 변경 전 제목이 같은지 FK와 일치 검사 쿼리로 확인되어야 합니다.
해설과 예시 답안
감사 헤더에 NULL 허용 original_event_id 자기 참조 FK를 추가하고 동작 집합에 수정을 넣습니다.
보정 쓰기 주체는 원본 이벤트를 잠근 뒤 새 행을 추가합니다.
원래 이벤트의 시각과 내용은 손대지 않습니다.
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,
ADD CONSTRAINT chk_audit_action CHECK (
action_code IN ('CREATE','CORRECT','STATUS_CHANGE','SOFT_DELETE','RESTORE','CORRECTION')
);
SET @original_event = (
SELECT event_id FROM final_audit_event WHERE request_id = @audit_request
);
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,
'원본 감사 payload의 제목 표기를 보정',
after_state, JSON_OBJECT('title', 'append-only 감사 원장 실습'),
'2026-07-15 16:00:00', event_id
FROM final_audit_event
WHERE event_id = @original_event;
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);원본 이벤트는 그대로 한 건 남고 보정 이벤트가 추가됩니다.
마지막 일치 검사는 0행이어야 합니다.
보정 이벤트를 다시 UPDATE하려는 시도도 같은 변경 불가 트리거에서 거절됩니다.
핵심 정리
- 감사 이벤트는 행위자, 사유, 요청, 변경 전/변경 후가 함께 있어야 조사 가능한 증거가 됩니다.
- 원본 변경과 감사 추가는 한 트랜잭션으로 묶어 부분 성공을 없앱니다.
- 트리거와 최소 권한은 UPDATE·DELETE를 막고 보정은 새 사건으로 표현합니다.
- 필드 자식과 헤더 JSON의 일치, 엔티티 고아, 요청 중복을 정기 일치 여부를 확인합니다.
다음 문서에서는 실제 유효 시각과 DB가 그 사실을 알게 된 시각을 동시에 질의합니다.