--- id: db-audit-log-patterns title: Audit Log — Trigger / 이벤트 / 변경 추적 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [database, audit, log, trigger, vibe-coding] tech_stack: { language: "SQL / Postgres", applicable_to: ["Backend"] } applied_in: [] aliases: [audit trail, change data capture, CDC, who changed what, history table] --- # Audit Log > 누가 / 언제 / 무엇을 / 왜 변경했는지 추적. **Compliance (SOC2/GDPR) + 디버깅**. 트리거 / 앱 레벨 / CDC (Debezium) 3가지 방식. 같은 DB / 별도 테이블 / 별도 시스템. ## 📖 핵심 개념 - Append-only: 변경 이력은 절대 수정/삭제 X. - 4가지 정보: who, when, what (table+pk), how (old → new). - WORM: write-once read-many. compliance 요구. ## 💻 코드 패턴 ### Trigger (Postgres) — 자동 캡처 ```sql CREATE TABLE audit_log ( id BIGSERIAL PRIMARY KEY, table_name TEXT NOT NULL, operation TEXT NOT NULL, -- INSERT/UPDATE/DELETE row_id TEXT NOT NULL, old_data JSONB, new_data JSONB, changed_by UUID, changed_at TIMESTAMPTZ DEFAULT NOW(), ip TEXT ); CREATE OR REPLACE FUNCTION audit_trigger() RETURNS trigger AS $$ DECLARE uid UUID; BEGIN -- 앱에서 SET LOCAL app.user_id 로 주입 uid := current_setting('app.user_id', true)::UUID; IF TG_OP = 'INSERT' THEN INSERT INTO audit_log(table_name, operation, row_id, new_data, changed_by) VALUES (TG_TABLE_NAME, 'INSERT', NEW.id::TEXT, to_jsonb(NEW), uid); RETURN NEW; ELSIF TG_OP = 'UPDATE' THEN INSERT INTO audit_log(table_name, operation, row_id, old_data, new_data, changed_by) VALUES (TG_TABLE_NAME, 'UPDATE', NEW.id::TEXT, to_jsonb(OLD), to_jsonb(NEW), uid); RETURN NEW; ELSIF TG_OP = 'DELETE' THEN INSERT INTO audit_log(table_name, operation, row_id, old_data, changed_by) VALUES (TG_TABLE_NAME, 'DELETE', OLD.id::TEXT, to_jsonb(OLD), uid); RETURN OLD; END IF; END; $$ LANGUAGE plpgsql; CREATE TRIGGER users_audit AFTER INSERT OR UPDATE OR DELETE ON users FOR EACH ROW EXECUTE FUNCTION audit_trigger(); ``` ### 앱이 user 주입 ```ts // 매 트랜잭션 시작 시 await db.execute(`SET LOCAL app.user_id = '${ctx.userId}'`); ``` ### App-level audit (간단 케이스) ```ts async function updateUser(id: string, patch: Partial, by: string) { return db.transaction(async (tx) => { const before = await tx.user.findUnique({ where: { id } }); const after = await tx.user.update({ where: { id }, data: patch }); await tx.auditLog.create({ data: { table: 'users', op: 'UPDATE', rowId: id, oldData: before, newData: after, changedBy: by, }, }); return after; }); } ``` ### History 테이블 (full row snapshot) ```sql CREATE TABLE users_history ( history_id BIGSERIAL PRIMARY KEY, id UUID NOT NULL, -- 원래 PK email TEXT, -- ... users 의 모든 컬럼 valid_from TIMESTAMPTZ, valid_to TIMESTAMPTZ DEFAULT 'infinity', changed_by UUID ); -- 시점별 조회 SELECT * FROM users_history WHERE id = $1 AND valid_from <= $time AND valid_to > $time; ``` ### CDC (Debezium / wal2json) - Postgres logical replication slot → Debezium → Kafka → audit service. - DB 부하 작음, 별 시스템 분리. - Compliance 시스템에 적합. ### 차이만 저장 ```sql -- json diff 만 저장 (적은 공간) INSERT INTO audit_log(table_name, row_id, diff) VALUES (..., jsonb_build_object('email', ARRAY[OLD.email, NEW.email])); ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | 단순 변경 추적 | Trigger + audit_log | | 시점별 row 복원 | History 테이블 | | 다른 시스템에 stream | CDC (Debezium) | | Compliance 강함 | WORM 별 시스템 (S3 Object Lock) | | 앱 코드 복잡 | Trigger (한 곳) | | Multi-tenant | tenant_id 같이 저장 | ## ❌ 안티패턴 - **App-level only 인데 raw SQL 우회**: trigger 가 안전망. - **PII 그대로 audit log**: GDPR — 마스킹 또는 별 보안 영역. - **audit_log 인덱스 없음**: row_id / changed_at 검색 느림. - **audit 테이블 update/delete 허용**: WORM 깨짐. 권한 분리. - **변경 내용만 — who 없음**: 추적 무의미. - **JSON 거대**: 1MB+ 필드는 reference 만. - **Retention 정책 없음**: 무한히 자라남. ## 🤖 LLM 활용 힌트 - Trigger 가 자동, app-level 은 빠뜨릴 수 있음. - who = SET LOCAL 또는 ORM hook. - old/new JSONB 가 가장 단순 강력. ## 🔗 관련 문서 - [[DB_Soft_Delete_Patterns]] - [[GDPR_Data_Retention]] - [[Observability_Stack]]