본문으로 건너뛰기

설계로 구현하는 ALCOA+: 코드 속 데이터 무결성

📍 현재 위치: 5부 · 신뢰(Trust) — 23장. 1장부터 우리가 구축해 온 PostgreSQL 기반 데이터 시스템인 플랫폼은 이제 배치(batch, 그 기록이 시스템을 흘러 다니는 생산 실행)를 수집하고, 저장하고, 맥락화하고, 시각화합니다. 이 장은 데이터를 설계 자체로 신뢰할 수 있게(trustworthy by construction) 만듭니다. 모든 검사관이 관심을 갖는 데이터 무결성(data integrity) 규칙을 PostgreSQL 스키마(schema), 트리거(trigger), 해시 체인(hash chain)에 곧바로 새겨 넣고, 그 보장이 실제로 성립함을 증명하는 테스트를 작성합니다.

쉽게 말하면

모든 페이지에 번호가 매겨져 있고, 지워지지 않는 잉크로 작성되며, 작성하면서 서명과 날짜를 기록하고, 너무 단단히 제본되어 있어서 페이지를 찢어내거나 몰래 끼워 넣거나 순서를 뒤바꾸면 반드시 표가 나는 실험 노트를 상상해 보세요. 누군가 실제로 페이지를 찢어내거나 순서를 뒤섞으면 페이지 번호가 맞지 않게 되어 모두가 알아챌 것입니다. 우리가 여기서 코드로 만드는 것이 바로 그것입니다. 규제 대상 기록(regulated record)에 대한 모든 변경은 (덮어쓰지 않고) 추가만 되며, 누가, 언제, 변경했는지가 기록되고, 직전 변경과 암호학적으로 연결됩니다. 그래서 이력의 순서변조 입증 가능(tamper-evident) 해집니다. 한계도 분명히 짚어 둡니다. 이 링크 검사는 삭제되거나, 순서가 뒤바뀌거나, 끼워 넣어진 항목은 잡아내지만, 링크를 그대로 둔 채 항목 내용 만 몰래 수정한 경우는 잡지 못합니다. 더구나 어떤 SQL 파일도 약속할 수 없는 변조 방지(tamper-proof) 와도 거리가 멉니다. 그렇지만 변조 입증 가능 은 규제 당국이 실제로 요구하는 수준이며, 이 코드가 어디까지 닿는지를 정확히 밝힙니다.

이 장에서 다루는 내용

지난 열아홉 장에 걸쳐 우리는 데이터를 센서에서 깨끗하고 맥락화되며 질의 가능한 플랫폼으로 옮겨 왔습니다. 이제 그 데이터를 규제 당국이 신뢰하도록 만들어야 합니다. 그 신뢰를 줄여서 부르는 말이 ALCOA+ 입니다. 데이터는 Attributable(귀속 가능), Legible(판독 가능), Contemporaneous(동시 기록), Original(원본 보존), Accurate(정확)해야 하며(앞 다섯 글자가 ALCOA를 이룹니다), 여기에 + 가 더하는 네 가지 — Complete(완전), Consistent(일관), Enduring(지속), Available(가용) — 가 더해집니다 [1]. 이는 FDA의 데이터 무결성 기대 사항 [2], EU Annex 11(전산화 시스템을 규율하는 EU GMP — Good Manufacturing Practice, 모든 의약품 제조자가 법적으로 준수해야 하는 품질 규칙 — 가이드의 부속서) [3], 그리고 PIC/S(Pharmaceutical Inspection Co-operation Scheme, GMP 검사관을 정렬하는 국제 기구) 검사관 프레임워크 [4]의 근간을 이룹니다.

ALCOA+ 를 사후에 감사하는 체크리스트로 다루고 싶은 유혹이 듭니다. 이 장은 그 반대를 주장합니다. 무결성은 설계 단계에 새겨 넣을(engineered in) 때 가장 저렴하고 가장 강력합니다. 우리는 다음을 수행합니다.

  • 각 ALCOA+ 속성을 구체적인 스키마 또는 파이프라인(pipeline) 메커니즘(추가 전용, 품질 플래그, 귀속 메타데이터)에 매핑합니다.
  • 규제 대상 테이블에 대한 모든 변경의 이전 값/새 값/누가/언제/왜를 기록하는 트리거 기반 감사 추적(audit trail) 을 PostgreSQL에 구축합니다.
  • 그 감사 로그를 해시 체인 으로 묶어, 사후 수정이 탐지 가능해지도록 만듭니다.
  • 그리고 보장을 기계적으로 단언하는 pytest 테스트 모음 을 실행합니다. 추가 전용 동작, 끊기지 않은 체인, 포착된 갱신을 검증합니다.

여기에 나오는 모든 코드 조각은 동반 저장소(companion repo)에 있는 실제 테스트된 두 파일에서 가져온 것입니다. 데이터베이스가 처음 초기화될 때 자동으로 적용되는 examples/platform/db/50-alcoa.sql 의 DDL(Data Definition Language — 테이블, 트리거, 함수를 만드는 SQL)(db/ 디렉터리가 PostgreSQL의 /docker-entrypoint-initdb.d 에 마운트되는데(컨테이너 안에서 보이게 만들어지는데), 이는 PostgreSQL이 최초 기동 시 자동으로 실행하는 특별한 폴더이므로 make up 이 스택을 띄우면서 이를 실행합니다. 이어서 make seed 가 로깅을 트리거하는 행들을 추가합니다), 그리고 make test 로 실행되는 examples/tests/test_db.py 의 단언문입니다. 어느 것도 예시용이 아닙니다. 여러분의 노트북에서 직접 체인을 끊고 테스트가 빨갛게 실패하는 것을 지켜볼 수 있습니다.

ALCOA+ 는 포스터가 아니라 설계 요구 사항의 집합입니다

코드를 보기 전에, ALCOA+ 를 공학적 요구 사항의 목록으로 읽어 보면 도움이 됩니다. 컴플라이언스 벽지 정도로 취급하기를 멈추는 순간 ALCOA+ 가 바로 그것이 되기 때문입니다. MHRA(영국의 의약품·의료제품 규제청, Medicines and Healthcare products Regulatory Agency)의 가이던스는 이 속성들과, 메타데이터가 기록의 일부 라는 핵심 요점을 가장 깔끔하게 진술합니다. 값 주변의 누가언제 는 값 그 자체만큼이나 규제 대상입니다 [5].

ALCOA+ 속성시스템에 요구하는 것코드에서 강제하는 위치
Attributable(귀속 가능)모든 값이 사람 또는 장치로 추적됨감사 로그의 db_user / app_user 컬럼
Legible(판독 가능)읽을 수 있고 영구적인 기록jsonb 이전/이후 스냅샷, 평문 SQL, 내구성 있는 저장소
Contemporaneous(동시 기록)사건이 일어난 시점에 기록각 행의 clock_timestamp() 서버 타임스탬프
Original(원본 보존)덮어쓰지 않고 보존되는 최초 수집본추가 전용 change_log; UPDATE/DELETE 없음
Accurate(정확)올바른 값과 함께 붙는 품질 플래그모든 판독값의 레거시 OPC DA(오래된 산업용 데이터 프로토콜) quality 플래그
Complete(완전)변경을 포함해 아무것도 몰래 누락되지 않음모든 INSERT/UPDATE/DELETE 에서 트리거 작동
Consistent(일관)온전하고 정렬된 순서단조 증가(항상 증가하는) seq 식별자 + 해시 체인
Enduring / Available(지속 / 가용)살아남고 검색 가능PostgreSQL 내구성 + 보존 정책(26장)

이 가운데 둘은 앞 장에서 이미 값을 치렀습니다. Accurate 는 히스토리안(historian, 공정 판독값을 저장하는 시계열 데이터베이스로 9장에서 구축)이 모든 판독값에 싣고 다니는 quality 플래그에 올라타 있습니다. examples/platform/db/20-historian.sql 의 컬럼을 떠올려 보세요. 7장에서 확립한 레거시 OPC DA 상태 코드(192 Good, 64 Uncertain, 0 Bad — OPC 표준이 정의한 비트 코드 값으로, 임의의 값이 아닙니다)가 값과 나란히 저장되어, 하위 소비자가 Bad 판독값을 신뢰할 수 있는 값으로 결코 오인하지 못합니다. Contemporaneous 는 컬렉터(collector)가 각 판독값을 삽입 시점이 아니라 취득 시점에 타임스탬프를 찍는다는 사실에 올라타 있습니다. 이 장의 임무는 더 어려운 셋, 즉 Attributable, Original, Complete, 그리고 이들을 한데 묶어 Consistent 를 완성하는 변조 입증성입니다. 남은 셋 — Legible, Enduring, Available — 은 여기서 별도의 장치가 필요하기보다는 테이블 설계에서 저절로 나옵니다(판독 가능한 jsonb 스냅샷, PostgreSQL의 내구성 있는 저장소, 그리고 26장의 보존 작업).

감사 추적: 규제 대상 테이블을 감시하는 트리거

이 메커니즘은 GMP 기록을 담은 테이블에 부착된 단일 PostgreSQL 트리거 함수입니다. 데이터베이스 트리거 는 행이 변경될 때마다 데이터베이스가 자동으로 실행하는 작은 함수입니다. 행 수준(row-level) 트리거는 영향받은 행마다 한 번씩 실행되며, 그 행의 OLD(변경 전)와 NEW(변경 후) 버전, 그리고 TG_OP 변수에 담긴 연산 유형 — INSERT, UPDATE, 또는 DELETE — 을 봅니다. 이것이 바로 감사 추적이 필요로 하는 원재료입니다 [8]. 다음은 examples/platform/db/50-alcoa.sql 에서 가져온 추가 전용 로그 테이블입니다.

CREATE TABLE audit.change_log (
seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT clock_timestamp(),
db_user text NOT NULL DEFAULT current_user,
app_user text, -- set via SET app.user = '...'
table_name text NOT NULL,
action text NOT NULL, -- INSERT | UPDATE | DELETE
row_key text,
old_row jsonb,
new_row jsonb,
reason text, -- set via SET app.reason = '...'
prev_hash text,
row_hash text NOT NULL
);

SQL 타입에 익숙하지 않은 독자를 위해 한마디 덧붙입니다. bigint 은 큰 정수, text 는 문자열, timestamptz 는 시간대를 가진 타임스탬프, jsonb 는 데이터베이스가 색인하고 질의할 수 있는 이진 형식으로 저장되는 JSON 값입니다. NOT NULL 은 빈 값을 금지하고, DEFAULT 는 값이 주어지지 않았을 때 하나를 공급하며, PRIMARY KEY 는 각 행을 고유하게 식별하는 컬럼을 표시합니다.

이 테이블을 컬럼으로 표현된 ALCOA+ 로 읽어 보세요. db_userapp_user 는 변경을 Attributable 하게 만듭니다. 앞의 것은 데이터베이스 역할(role)로, 뒤의 것은 애플리케이션이 인증한 사람으로 귀속시킵니다(우리는 이를 SET app.user = '...' 로 설정하며, 24장에서 이를 실제 Keycloak 신원에 연결합니다 — Keycloak은 거기서 소개하는 오픈 소스 로그인/신원 관리자입니다). clock_timestamp() 로 기본값이 설정되는 ts 는 그것을 Contemporaneous 하게 만듭니다. now() 가 아니라 clock_timestamp() 라는 점에 주목하세요. now()트랜잭션(함께 커밋되는 문장들의 묶음) 시작 시점에 고정되어 한 번에 쓰이는 행들에 모두 같은 시각을 보고하지만, clock_timestamp() 는 행이 기록되는 실제 벽시계 순간입니다. jsonb 형식의 old_rownew_row 는 기록을 Legible 하고 Original 하게 유지합니다. 차이(diff)만이 아니라 행의 변경 전후 전체 이미지가 보존됩니다. 그리고 이 테이블은 사용 관행상 추가 전용 입니다. 시스템의 어느 부분도 이 테이블에 UPDATEDELETE 를 실행하지 않습니다. reason 컬럼은 규제 당국이 가장 좋아하는 것으로, 모든 변경 뒤의 를 포착합니다.

audit.change_log 한 행의 해부: 열두 컬럼, 하나의 링크

이 장 전체는 이 테이블의 한 행 안에 들어 있습니다. 그러니 7장이 OPC UA 노드(node, 앞서 말한 레거시 OPC DA의 현대적 후속)를, 9장이 히스토리안 판독값 하나를 분해했던 것처럼, 이 행도 필드별로 해부해 볼 가치가 있습니다. 분석가 jdoe 가 벤치 분석기 출력물을 다시 읽고 시료 BATCH-2026-001-OFF-014 의 포도당(glucose)을 7.8 g/L 로 수정한 뒤 verify_chain() 이 깨끗하다고 판정할 로그가 담고 있을 행을 가져와 컬럼별로 읽어 봅시다. 앞의 여섯 컬럼은 무엇누가언제 를 말하고, 일곱 번째(row_key)는 어느 기록이 변경되었는지를 가리키고, 다음 셋은 이전, 이후, 를 싣고, 마지막 둘 — prev_hashrow_hash — 은 순서를 변조 입증 가능하게 만드는 링크입니다.

audit.change_log 한 행의 신원 카드. seq, ts, table_name, action, db_user, app_user, row_key, old_row, new_row, reason, prev_hash, row_hash 열두 컬럼을 각각의 값과 의미와 함께 나열하고, prev_hash와 row_hash 체인 링크 쌍을 가장 중요한 필드로 강조한다. 신원 카드로 표현한 audit.change_log 한 행: 열두 컬럼이 누가 무엇을 언제 왜 변경했는지, 변경 전후 이미지, 그리고 그 행을 직전 행에 묶는 SHA-256 링크(prev_hash 다음 row_hash)를 포착한다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

열두 컬럼 가운데 넷이 이 장의 짐을 짊어집니다. seqGENERATED ALWAYS AS IDENTITY 입니다. PostgreSQL이 부여하며 애플리케이션이 덮어쓸 수 없는 값이라, 이력의 순서 는 기록하는 쪽이 아니라 데이터베이스가 지킵니다. row_key 는 원시 기본 키가 아니라 합쳐진(coalesced) 키입니다. 트리거가 coalesce(new_row ->> 'batch_id', old_row ->> 'batch_id', new_row ->> 'sample_id', old_row ->> 'sample_id') 를 계산하는데, 여기서 ->> 는 JSON 행에서 이름 붙은 필드를 텍스트로 꺼내고 coalesce(...) 는 그중 NULL이 아닌 첫 값을 반환합니다. 그래서 배치 변경은 그 batch_id 로, 실험실 결과 변경은 그 sample_id 로 키가 매겨집니다(여기서는 offline_assays.csv 의 실제 공정 중 시료인 BATCH-2026-001-OFF-014). 어느 테이블에서 왔든 변경된 기록을 가리키는 한 컬럼입니다. 그리고 prev_hash/row_hash 는 다음에서 풀어 볼 쌍입니다. 다른 모든 컬럼은 증거 이지만, 이 둘은 체인 입니다.

이 행이 3부작에서 오는 곳

audit.change_log 행은 3부작 전체를 관통하는 사슬의 세 번째 정거장입니다. 이 행이 기록하는 물리적 사건 — 운영자나 분석가가 핵심 공정 파라미터나 규격 외 결과를 수정하는 일 — 은 1권 품질, 규제, 그리고 데이터에서 다루는 GMP 변경입니다. 2권은 그 변경을 데이터 무결성 문제로 바꾸어, 위의 바로 그 12개 컬럼 행을 아홉 가지 ALCOA+ 속성에 매핑하고 시스템이 어떻게 그것을 신뢰할 수 있게 만드는지 묻습니다: 데이터 무결성과 ALCOA+. 이 장은 그에 대한 코드로 된 답 입니다. 그 두 장이 요구하는 바를 구현하는 트리거, 스키마, 해시 체인이죠.

실제 작업은 트리거 함수가 합니다. 이 역시 examples/platform/db/50-alcoa.sql 에서 가져왔습니다.

CREATE OR REPLACE FUNCTION audit.log_change() RETURNS trigger AS $$
DECLARE
v_prev text;
v_key text;
v_old jsonb := CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE to_jsonb(OLD) END;
v_new jsonb := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE to_jsonb(NEW) END;
v_app text := current_setting('app.user', true);
v_reason text := current_setting('app.reason', true);
v_hash text;
BEGIN
SELECT row_hash INTO v_prev FROM audit.change_log ORDER BY seq DESC LIMIT 1;
v_key := coalesce((v_new ->> 'batch_id'), (v_old ->> 'batch_id'),
(v_new ->> 'sample_id'), (v_old ->> 'sample_id'));
-- chain hash = H(prev_hash || payload)
v_hash := encode(digest(
coalesce(v_prev, '') || TG_TABLE_NAME || TG_OP ||
coalesce(v_old::text, '') || coalesce(v_new::text, '') ||
coalesce(v_app, '') || clock_timestamp()::text, 'sha256'), 'hex');

INSERT INTO audit.change_log(app_user, table_name, action, row_key,
old_row, new_row, reason, prev_hash, row_hash)
VALUES (v_app, TG_TABLE_NAME, TG_OP, v_key, v_old, v_new, v_reason, v_prev, v_hash);
RETURN coalesce(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

세 줄은 좀 더 자세히 볼 만합니다. to_jsonb(OLD)to_jsonb(NEW)전체 행을 JSON으로 직렬화하므로, 로그가 스키마에 구애받지 않습니다. 테이블이 어떤 컬럼을 갖든 동작하여, 동일한 함수가 테이블별로 손수 작성한(bespoke) 컬럼 코드 한 줄 없이 검사 결과, 배치, 또는 레시피(recipe) 파라미터를 모두 감사하게 해 줍니다. current_setting('app.user', true) 는 애플리케이션이 트랜잭션 시작 시 설정하는 세션 변수를 읽습니다. true 는 변수가 설정된 적이 없을 때 오류를 내지 않고 NULL 을 반환하게 합니다. 그리고 digest(...,'sha256') 호출이 해시 체인이며, 이는 다음에서 풀어 설명합니다.

이 트리거는 규제 대상 기록을 담은 바로 그 테이블에 부착됩니다. 역시 examples/platform/db/50-alcoa.sql 에 있습니다.

CREATE TRIGGER audit_result AFTER INSERT OR UPDATE OR DELETE ON lab.result
FOR EACH ROW EXECUTE FUNCTION audit.log_change();
CREATE TRIGGER audit_batch AFTER INSERT OR UPDATE OR DELETE ON s88.batch
FOR EACH ROW EXECUTE FUNCTION audit.log_change();
CREATE TRIGGER audit_recipe_p AFTER INSERT OR UPDATE OR DELETE ON s88.recipe_parameter
FOR EACH ROW EXECUTE FUNCTION audit.log_change();

이는 검사관이 가장 신경 쓰는 세 테이블입니다. 분석 결과(lab.result), 배치 기록 그 자체(s88.batch), 그리고 제품이 어떻게 만들어지는지를 정의하는 레시피 파라미터(s88.recipe_parameter, 27장에서 valid_from/valid_to 컬럼을 사용해 제자리 버전 관리합니다)입니다. 트리거가 모든 INSERT OR UPDATE OR DELETE 에서 작동하므로 아무것도 빠져나가지 못합니다. 그것이 기계적으로 구현된 Complete 속성입니다.

해시 체인: 기록들을 서로 연결하기

추가 전용 로그는 그 아래의 저장소만큼만 정직합니다. 테이블 접근 권한을 가진 작심한 내부자라면 원칙적으로 audit.change_log 에 손을 뻗어 행 순서를 바꾸거나, 중간 항목을 삭제하거나, 조작된 항목을 끼워 넣을 수 있습니다. 우리는 부류의 공격을 클라우드보다 오래된 기법으로 탐지 가능하게 만듭니다. 바로 연결된 해시 체인(linked hash chain) 입니다. 이는 Haber와 Stornetta가 1991년에 디지털 문서에 타임스탬프를 찍어 그 순서가 조용히 재배열되거나 변경되지 못하게 하는 방법으로 처음 기술했습니다 [7]. 나중에 블록체인(blockchain)의 토대가 된 바로 그 구성이지만, 우리에게는 분산 합의 장치가 전혀 필요 없습니다. 연결만 있으면 됩니다.

prev_hash 가 행들을 묶는 방식

SHA-256 해시는 어떤 입력으로부터 계산되는 짧고 고정 길이인(항상 16진수 64자리) 지문입니다. 입력의 단 1바이트만 바꿔도 지문은 완전히, 예측 불가능하게 바뀝니다. 같은 입력은 항상 같은 지문을 내고 어떤 변경이든 다른 지문을 낸다는 바로 그 속성이 변조를 탐지 가능하게 만듭니다.

발상은 단순합니다. 트리거가 행을 기록할 때, 그 행의 페이로드(payload) 그리고 직전 행에 저장된 해시에 대해 SHA-256 해시를 계산한 뒤, 두 값을 모두 저장합니다. 직전 행의 해시는 prev_hash 에, 새 다이제스트는 row_hash 에 저장합니다. 개념적으로는 다음과 같습니다.

row_hash[n] = SHA256( row_hash[n-1] || table || op || old || new || app_user || clock_timestamp() )
prev_hash[n] = row_hash[n-1]

이 다이제스트는 한 가지를 해시하는 것이 아니라 일곱 가지의 연결(concatenation) 을 해시합니다. 직전 행의 해시, 그다음 이 행의 table, op, old, new, app_user, 그리고 타임스탬프를 ||(여기서는 논리 OR가 아니라 문자열을 끝끼리 이어 붙이는 SQL 연산자)로 이어 붙여 digest(..., 'sha256') 한 번에 넣습니다. 첫 부분은 과거로의 링크이고, 나머지는 이 행 자신의 페이로드입니다. 부분별로 열어 보면, 다시 연결되거나 순서가 바뀐 행이 왜 검증에 실패하는지, 그리고 다이제스트가 왜 저장된 컬럼들로부터 재현 불가능한지 둘 다 드러납니다.

SHA-256 입력 문자열을 일곱 개의 연결된 부분으로 분해한 그림. 링크인 prev_hash, 그다음 페이로드인 table, op, old, new, app_user, 그리고 저장되지 않는 새 clock_timestamp가 하나의 SHA-256을 거쳐 64자리 16진수 row_hash를 만든다. row_hash 다이제스트를 열어 본 그림: prev_hash 가 직전 행으로 이어지고, 그다음 이 행의 table, op, old, new, app_user 가 페이로드를 이루며, 결코 저장되지 않는 새 clock_timestamp() 가 접혀 들어간다. 그래서 64자리 16진수 row_hash 는 저장된 컬럼들로부터 재현할 수 없다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

이 다이제스트에 대해 두 가지 정직한 메모를 남깁니다. 이 장의 핵심이 코드가 실제로 전달하는 것만을 주장하는 데 있기 때문입니다. 첫째, 해시는 트리거 내부에서 평가된 clock_timestamp() 를 접어 넣습니다. 이는 ts 컬럼에 저장되는 값과 다른 벽시계 순간입니다(ts 컬럼은 자체적인 독립적 clock_timestamp() 기본값을 가집니다). 저장된 행의 어디에도 해시에 들어간 정확한 타임스탬프가 기록되지 않으므로, row_hash 는 저장된 컬럼들로부터 재현 불가능 합니다. 둘째, 그 결과로, 배포된 검증기(verifier)는 페이로드로부터 row_hash 를 결코 재계산하지 않습니다. 저장된 prev_hash 컬럼과 row_hash 컬럼 사이의 링크 가 일관적인지만 확인합니다. 이것이 무엇을 잡고 무엇을 못 잡는지 정확히 살펴봅니다.

pgcrypto 확장(extension, PostgreSQL 안에서 켜는 선택적 추가 모듈)은 그 SHA-256을 계산하고 16진수로 인코딩하는 데이터베이스 내장 digest()encode() 함수를 제공합니다 [9]. digest(...) 는 원시 32바이트 해시를 만들고, encode(..., 'hex') 는 그것을 여러분이 저장된 채로 보는 64자리 16진수 텍스트로 렌더링합니다. 이는 스택의 최상단인 examples/platform/db/00-init.sql 에서 한 번 활성화됩니다.

CREATE EXTENSION IF NOT EXISTS pgcrypto; -- digest() for the ALCOA+ hash chain

각 행이 직전 행의 해시를 자신의 prev_hash 컬럼에 저장하므로, 로그는 링크를 확인할 수 있는 체인이 됩니다. 행을 삭제하거나, 행 순서를 바꾸거나, prev_hash/row_hash 값을 덮어쓰면 저장된 링크가 더 이상 맞아떨어지지 않습니다. 그것이 검증기가 테스트하는 속성이며, 결정적으로, 검증기가 테스트하는 유일한 속성입니다.

각 상자에 seq, action, who, old/new JSON이 표시된 감사 로그 행들이 세로로 이어진 체인. 화살표가 각 행에 저장된 SHA-256 row_hash를 다음 행에 저장된 prev_hash 컬럼으로 내려보낸다. 한 링크가 덮어써져 저장된 prev_hash가 더 이상 직전 행의 row_hash와 같지 않게 되어, 그 링크가 빨갛게 변하고 해당 seq가 끊긴 링크로 표시된다.

연결된 해시 체인으로서의 감사 로그: 각 행은 직전 행의 row_hash 를 자신의 prev_hash 컬럼에 저장하므로, 해시 링크 컬럼을 삭제하거나, 순서를 바꾸거나, 덮어쓰면 저장된 링크가 끊기고 verify_chain()prev_hash 가 더 이상 직전 행의 row_hash 와 일치하지 않는 첫 seq 를 표시한다. 검증기는 링크 일관성만 확인한다는 점에 주의하라. 해시를 재계산하지 않으므로, 해시 컬럼은 그대로 둔 채 행 페이로드만 몰래 수정한 경우는 여기서 잡히지 않는다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

끊긴 링크를 탐지하는 것은 그 자체로 하나의 함수이며, examples/platform/db/50-alcoa.sql 의 마지막 블록입니다.

-- Verify the chain is intact: returns rows where a stored prev_hash does not
-- equal the previous row's stored row_hash (a broken/reordered/deleted link).
CREATE OR REPLACE FUNCTION audit.verify_chain()
RETURNS TABLE(seq bigint, ok boolean) AS $$
WITH chained AS (
SELECT c.seq, c.row_hash, c.prev_hash,
lag(c.row_hash) OVER (ORDER BY c.seq) AS expected_prev
FROM audit.change_log c
)
SELECT seq, (prev_hash IS NOT DISTINCT FROM expected_prev) AS ok
FROM chained
WHERE prev_hash IS DISTINCT FROM expected_prev;
$$ LANGUAGE sql;

lag(...) OVER (ORDER BY c.seq) 윈도우 함수는 로그를 순서대로 훑으며, 각 행마다 직전 행에 저장된 row_hash 를 되돌아봅니다. 그것이 현재 행이 기록한 prev_hash 와 일치하지 않으면, 저장된 링크가 끊긴 것입니다(IS DISTINCT FROM / IS NOT DISTINCT FROM 연산자는 두 NULL을 같다고 취급하는 방식으로 이 비교를 수행하므로, 첫 행의 비어 있는 prev_hash 도 깔끔하게 처리됩니다). 건강한 체인은 0개의 행 을 반환합니다. 모든 링크가 일관적입니다. 검증기가 반환하는 행이 있다면, 그것은 저장된 링크가 맞아떨어지지 않게 된 지점을 가리킵니다. 그 seq 가 체인이 처음으로 어긋난 위치입니다. 범위를 정확히 짚어 둡니다. 이는 prev_hashrow_hash 컬럼만 비교합니다. old_row/new_row/app_user 로부터 해시를 다시 도출하지 않으므로, 삭제되거나, 순서가 바뀌거나, 다시 연결된 행은 탐지하지만, 해시 컬럼을 건드리지 않은 채 행 페이로드만 몰래 수정한 경우는 탐지하지 못합니다. 아래 데모에서는 검증기가 잡을 수 있는 바로 그 공격을 사용하고, 그것이 남기는 빈틈은 "실제 현장에서는" 에서 다시 다룹니다. (이 함수에 대한 저장소 자체의 주석도 신중하게 일치시킵니다. 검증기가 "저장된 prev_hash 가 직전 행의 저장된 row_hash 와 같지 않은 행을 반환한다" 고 적고, "이는 링크 일관성만 확인하며, 페이로드로부터 row_hash 를 재계산하지 않는다" 고 덧붙입니다. 아무것도 재계산하지 않으므로, 일부러 해시를 재계산한다고 묘사하지 않는 것입니다.)

전형적인 건강한 로그를 직접 질의하면 다음과 같이 보입니다(예시용 건강한 로그로, 분석가 이름은 양식화한 것입니다. 시드는 app_user 를 NULL로 두고 테스트는 pytest 로 기록하므로, 이 seed/jdoe 행들은 실제로 시드된 것이 아니라 대표적인 예입니다).

seq | ts | app_user | table_name | action | row_key | prev_hash | row_hash
-----+----------------------------+----------+------------+--------+------------------------+-------------+-------------
1 | 2026-01-05 00:00:00.142+00 | seed | batch | INSERT | BATCH-2026-001 | (null) | 9f2a...c41b
2 | 2026-01-05 00:00:00.197+00 | seed | result | INSERT | BATCH-2026-001-OFF-001 | 9f2a...c41b | 1ce8...77a0
3 | 2026-01-13 09:14:22.030+00 | jdoe | result | UPDATE | BATCH-2026-001-OFF-014 | 1ce8...77a0 | b430...e9f2

3행은 분석가가 1월 13일에 결과를 수정하는 것입니다 — 시료 BATCH-2026-001-OFF-014 의 포도당을 실제 값인 7.8 g/L 로 다시 키 입력합니다. app_userjdoe, 동작은 UPDATE, 그리고 그 prev_hash 는 2행의 row_hash 와 같습니다. 링크가 맞아떨어지므로 verify_chain() 은 아무것도 반환하지 않습니다.

규제 대상 행에서 출발해 AFTER ROW 트리거를 거치며, 추가 전용 audit.change_log와 row_hash 링크로 갈라져 나가고, 그 둘이 모두 audit.verify_chain으로 흘러드는 흐름.

약속이 아니라 테스트로 증명하기

테스트하지 않은 보장은 희망에 불과합니다. 동반 저장소는 무결성 규칙을 실행 가능한 인수 기준(acceptance criteria)으로 다루며, pytest 테스트 모음으로 단언합니다. pytest는 파이썬의 테스트 러너이고, assert 문은 조건이 참이 아니면 테스트를 실패시키는 줄인데, pytest의 평이한 assert 재작성(rewriting) 덕분에 한 줄짜리 단언이 완전한 무결성 검사를 대신할 수 있습니다 [10]. 이 테스트들은 살아 있는 스택(make up && make seed && make load 로 띄워졌다가 다시 내려지는, 실행 중인 서비스 묶음. make load 는 샘플 데이터를 적재합니다)을 대상으로 실행되며, 데이터베이스에 접근할 수 없으면 깔끔하게 건너뜁니다. examples/tests/test_db.py 에서 가져왔습니다.

def test_alcoa_chain_intact(conn):
assert _scalar(conn, "select count(*) from audit.change_log") > 0
assert _scalar(conn, "select count(*) from audit.verify_chain()") == 0 # 0 broken links

그 두 번째 단언이 이 장 전체를 한 줄로 담습니다. 체인에는 항목이 있고, 그 링크 가운데 0개 가 끊겨 있습니다. 다음 테스트가 더 흥미롭습니다. 실제로 수정을 가하고 그 추적이 그것을 포착했음을 증명하기 때문입니다.

def test_audit_captures_update(conn):
# an UPDATE must record old + new + who + why and keep the chain intact
with conn.cursor() as cur:
cur.execute("select set_config('app.user','pytest',false), "
"set_config('app.reason','test correction',false)")
cur.execute("update lab.result set value = value where result_id = "
"(select result_id from lab.result limit 1)")
conn.commit()
last = _scalar(conn, "select action from audit.change_log "
"where app_user='pytest' order by seq desc limit 1")
assert last == "UPDATE"
assert _scalar(conn, "select count(*) from audit.verify_chain()") == 0

이것이 무엇을 증명하는지 따라가 봅니다. set_config('app.user','pytest', ...)set_config('app.reason', ...)귀속 가능한 누가이유 를 설정합니다. 정확히 트리거가 읽는 세션 변수입니다. update lab.result set value = value 는 일부러 사소하게 만든 수정이지만(값을 자기 자신으로 설정합니다), 트리거는 여전히 작동해 UPDATE 행을 기록합니다. 감사 추적이 순(net) 차이만이 아니라 변경하는 행위 자체를 기록하기 때문입니다. 그런 다음 테스트 모음은 pytest 가 남긴 가장 최근 행이 UPDATE 임을, 그리고 그 후에도 체인이 여전히 온전함을 단언합니다. Attributable, Original, Complete, Consistent — 넷 모두가, 기계적으로, 노트북에서 검증됩니다. (이 제자리 UPDATE 는 트리거가 어떤 변경이든 포착함을 보이기 위한 것일 뿐입니다. 실제 운영의 정정 경로는 원본을 제자리에서 변경하지 않고, UNIQUE(sample_id, test_id, result_ts) 제약 — 그 조합을 공유하는 두 행을 금지하는 규칙이라, 각 정정이 별개의 타임스탬프 행으로 안착합니다 — 아래에 새 검증(verified) 상태 행을 추가 하는 10장의 패턴입니다.)

검증기가 잡는 것 — 그리고 잡지 못하는 것

검증기가 변조를 잡아내는 것을 보려면 새 코드가 필요 없습니다. 로그에 직접 손을 뻗어 링크를 끊으면 됩니다. verify_chain() 이 탐지하도록 만들어진 공격은 저장된 해시 컬럼을 흔드는 것, 즉 행을 삭제하거나, 순서를 바꾸거나, 체인을 끼워 넣는 것입니다. 여기서는 한 행의 prev_hash 를 덮어써 링크를 끊습니다.

-- Simulate an insider trying to splice the chain by relinking row 3.
UPDATE audit.change_log SET prev_hash = 'deadbeef'
WHERE seq = 3;
SELECT * FROM audit.verify_chain();
seq | ok
-----+----
3 | f

3행의 저장된 prev_hash 가 더 이상 2행의 저장된 row_hash 와 같지 않으므로 링크가 맞아떨어지지 않습니다. 검증기는 seq = 3 을 첫 끊긴 링크로 표시하고, test_alcoa_chain_intact 는 다음 make test 에서 빨갛게 실패합니다. 2행을 통째로 삭제하거나 seq 순서를 뒤바꿔도 링크는 같은 방식으로 끊기며 같은 방식으로 잡힙니다. 변조는 숨은 채로 남지 못했습니다.

이 검증기가 잡지 못하는 것에 대해서도 똑같이 분명히 해 둡니다. 이것이 변조 입증 가능 과 우리가 뒷받침할 수 없는 주장의 차이이기 때문입니다. 내부자가 링크를 끊는 대신 행 페이로드만 몰래 수정한다면 — UPDATE audit.change_log SET new_row = jsonb_set(new_row, '{value}', '99.9') WHERE seq = 2; — 그리고 prev_hash/row_hash 컬럼은 그대로 두면, verify_chain()0개의 행 을 반환합니다. 저장된 모든 링크가 여전히 맞아떨어지고, 수정은 탐지되지 않습니다. 빈틈을 막으려면 페이로드로부터 각 row_hash 를 재계산해 저장된 값과 비교하는 검증기가 필요합니다. 그런데 위에서 언급했듯 현재 스키마는 그것을 지원할 수 없습니다. 해시에 들어간 clock_timestamp() 가 결코 저장되지 않기 때문입니다. 그 한계를 여기서 정직하게 밝히고, 그 해결책(저장된 ts 를 해시하고, 체인 헤드를 데이터베이스 밖에 저장)은 "실제 현장에서는" 과 24장에서 다시 다룹니다.

왜 중요한가

데이터 무결성은 FDA 경고 서한(warning letter)과 EU GMP 지적 사항에서 단연 가장 흔한 주제입니다. 기업들이 데이터를 위조하려 작정해서가 아니라, 그들의 시스템이 무결성을 선택 사항 으로 만들기 때문입니다. 끌 수 있는 감사 추적, 흔적 없이 덮어쓸 수 있는 값, 사용자가 임의로 설정할 수 있는 타임스탬프. ALCOA+ 는 규제 당국이 "신뢰할 수 있다" 가 무엇을 뜻하는지 설명할 때 쓰는 언어이며, Annex 11과 21 CFR Part 11(연방규정집 제21편, Code of Federal Regulations Title 21 — FDA의 전자 기록 및 전자 서명 규정)은 그것을 전산화 시스템에 대한 법으로 만듭니다 [3][2].

현장에서 감사 추적 실패는 어떤 모습인가

이 실패 양상은 특이한 것이 아닙니다. FDA 자신의 데이터 무결성 가이던스는 거듭 답해야 했던 질문들을 중심으로 구성되어 있습니다. 감사 추적을 검토해야 하는가(그렇다, 일상적인 기록 검토의 일부로), 공유 로그인이 허용되는가(아니다, Attributable 을 파괴하므로), 사용자가 감사 추적을 끄거나 항목을 소급 입력할 수 있는 시스템이 허용되는가(아니다) — 이는 검사관이 실제로 적발하는 결함의 정직한 목록입니다 [1]. 반복되는 모습은 구체적입니다. 분석가가 공유 계정을 재사용해 어떤 변경도 사람으로 추적되지 않는 경우, 규격 이탈(out-of-specification, 승인된 허용 한계를 벗어난) 결과 — 가령 3.0% 규격을 넘겨 돌아온 응집체 수준 결과(SEC HMW%, 크기 배제 크로마토그래피로 측정하는 고분자량 분획) — 를 합격 결과로 덮어쓰고 변경 전 이미지를 남기지 않는 "합격할 때까지 시험(testing into compliance)", 운영자가 임의로 설정할 수 있어 Contemporaneous 를 무력화하는 시계, 또는 소프트웨어에는 있지만 설정에서 꺼져 있는 감사 추적 기능. PIC/S PI 041 은 같은 패턴을 목록화하고 검사관에게 정확히 그것을 찾으라고 지시하며 [4], MHRA의 가이던스는 그것이 중요한지를 못 박습니다. 값 주변의 메타데이터(누가, 언제, 왜)는 그 자체가 규제 대상 기록의 일부이므로, 그것을 잃는 것은 기록을 잃는 것입니다 [5].

이 실패들 가운데 어느 하나도 우리가 구축한 스키마 아래에서는 도달할 수 없습니다. app_user 를 벗겨 내는 공유 로그인 경로가 없습니다. 트리거가 모든 기록에서 db_user 와 애플리케이션 신원을 함께 포착하기 때문입니다. 흔적 없이 덮어쓰는 경로가 없습니다. lab.result 에 대한 UPDATE 는 값을 제자리에서 교체하는 대신 이전 이미지와 새 이미지를 가진 행을 추가 하기 때문입니다. 사용자가 설정할 수 있는 시계가 없습니다. ts 가 서버의 clock_timestamp() 이기 때문입니다. 그리고 일상 사용자가 닿을 수 있는 끄기 스위치가 없습니다. 트리거가 설계상 모든 연산에서 작동하기 때문입니다. "설계로 구현하는" 의 요점은 바로, 흔한 경고 서한 지적 사항들을 실수로라도 저지를 수 없게 만든다는 것입니다.

무결성을 스키마에 새겨 넣으면 기본값이 뒤집힙니다. 트리거가 부착되어 있으면, 귀속 가능하고, 이유가 붙고, 타임스탬프가 찍히고, 해시로 연결된 감사 항목을 기록하지 않고서 규제 대상 행을 변경할 경로가 없습니다. 옳은 일이 데이터베이스가 허용하는 유일한 일이 됩니다. 그것이 "설계로 구현하는" 의 의미이며, 사람들에게 변경 사항을 문서화하는 것을 잊지 말라고 부탁하는 절차보다 훨씬 강력합니다. FDA 자체의 데이터 무결성 가이던스는 감사 추적과 귀속 가능한 메타데이터를, 단지 약속하는 것이 아니라 반드시 구현해야 하는 CGMP 기대 사항으로 규정합니다 [1]. 우리는 그것을 약 70줄의 SQL로 구현했습니다.

또한 이는 예외 기반 검토(review-by-exception)를 가능하게 합니다. 검토자가 모든 기록이 아니라 변경되었거나 이탈한(GMP 일탈 — 승인된 절차나 규격에서 벗어난 문서화된 이탈) 기록만 살펴보는 것이죠. 검사관이나 QA 검토자는 단 한 번의 질의 — SELECT * FROM audit.change_log WHERE table_name = 'result' AND action = 'UPDATE' — 로 결과에 가해진 모든 수정 이력을, 변경 전 이미지, 변경 후 이미지, 분석가, 그리고 이유와 함께 볼 수 있습니다. 그것이 Part 11과 PIC/S PI 041이 기대하는 감사 추적 검토이며, 포렌식 작업이 아니라 하나의 질의입니다 [4].

실제 현장에서는

이 책이 약속하는 정직한 결산이 여기 있습니다. 우리가 구축한 패턴은 실제로 작동하고, 실행 가능하며, 표준에 부합합니다. SQL:2011(SQL 표준의 2011년 개정판)의 시스템 버전 이력(system-versioned history) 개념 — 행이 변경될 때 원본 행을 보존하는 것으로, 시간(temporal) 테이블이 자신의 이력을 자동으로 유지하는 방식과 같습니다 — 을 구현합니다. PostgreSQL은 (버전 18까지도) SQL:2011의 시스템 버전 시간 테이블(temporal table) 기능 [6] 을 네이티브로 구현하지 않으므로, 트리거가 그 동작을 얻는 관용적인 오픈 소스 방식입니다. 해시 체인은 진짜 Haber–Stornetta 구성입니다 [7]. 그리고 이 모든 테스트 모음은 make test 로 매 커밋마다 실행할 수 있습니다.

변조 입증 가능하지 변조 방지는 아니다: 정직한 경계

그러나 이것은 변조 입증 가능하지, 변조 방지는 아니며, 정직하게 짚어야 할 별개의 두 경계가 있습니다. 첫 번째는 검증기 자체 의 한계로, "어떤 수정이든 잡힌다" 는 강한 이야기보다 더 좁습니다. verify_chain() 은 저장된 prev_hash/row_hash 링크가 맞아떨어지는지만 확인하므로, 삭제되거나, 순서가 바뀌거나, 다시 연결된 행은 잡지만, 해시 컬럼을 그대로 둔 채 행의 old_row/new_row/app_user/reason 페이로드만 몰래 수정한 경우는 잡지 못합니다. 더 나쁘게도, 현재 스키마는 재계산하는 검증기로 쉽게 업그레이드할 수 없습니다. 다이제스트가 행이 결코 저장하지 않는 새 clock_timestamp() 를 접어 넣기 때문입니다. 그래서 올바른 재도출조차 row_hash 를 재현할 수 없습니다. 해결책은 24장을 위해 표시해 두는 작은 저장소 변경입니다. 행의 저장된 ts 값을 해시하거나(또는 해시된 정확한 순간을 저장해) row_hash 를 재현 가능하게 만든 뒤, 저장된 컬럼들로부터 각 다이제스트를 재계산해 비교하는 검증기를 추가하는 것입니다. 그래야만 "어떤 과거 항목을 변경하든 탐지된다" 가 열망이 아닌 참인 진술이 됩니다.

두 번째 경계는 운영적이며 더 큽니다. PostgreSQL 슈퍼유저(superuser, 전능한 관리자 역할) — 또는 audit.change_log 테이블의 소유자 — 는 DISABLE TRIGGER 를 실행해 감사 로깅을 꺼서 감사 항목 없이 규제 대상 행을 수정하고, 그런 다음 시스템이 쓰는 바로 그 해싱 도구를 자신도 쥐고 있으므로 수정 지점부터 앞으로 전체 해시 체인을 재계산한 뒤, 깨끗하게 검증되는 체인을 남길 수 있습니다. 우리의 방어는 우발적 변조를 가시화하고 고의적 변조의 비용을 막대하게 끌어올리지만, 데이터베이스 관리자를 이길 수는 없습니다. 그 빈틈을 막는 것은 코드 문제가 아니라 운영 문제입니다. 데이터베이스를 관리할 수 있는 사람과 데이터를 소유한 사람이 분리되는 직무 분리(segregation of duties), 잠긴 역할(role), 체인 헤드(가장 최근의 row_hash)의 데이터베이스 밖 사본(DBA가 나중에 변경할 수 없는 1회 기록(write-once) 저장소에 주기적으로 기록하는 것 — 24장에서 세우는 SeaweedFS WORM(1회 기록 다회 판독, write-once-read-many) 버킷이나, 어떤 값이 특정 시점에 존재했음을 증명하는 표준 암호학적 증명인 RFC 3161 신뢰 타임스탬프 같은 것), 그리고 특권 데이터베이스 세션을 로깅하는 PostgreSQL 확장인 pgAudit. 이 모두는 24장과 25장의 검증(validation) 작업이 떠맡습니다. 어떤 오픈 소스 구성 요소도 상자에서 꺼내자마자 21 CFR Part 11을 준수하지는 않으며, 이것도 예외가 아닙니다. 컴플라이언스는 검증된 시스템 그리고 그 절차 의 속성이지, 결코 SQL 파일의 속성이 아닙니다.

여기가 또한 오픈 소스 대 상용의 경계가 떨어지는 지점입니다. 검증된(25장에 따라 GMP 충족을 위해 공식적으로 시험되고 문서화된) 상용 히스토리안이나 MES(Manufacturing Execution System, 생산을 실행하고 기록하는 소프트웨어. 예로는 감사 하위 시스템을 갖춘 AVEVA PI, 또는 종이 배치 기록의 디지털 버전인 벤더 MES 전자 배치 기록이 있습니다)는 여러분이 구축하는 대신 구성하는 감사 추적을 제공하며, 책임과 검증 패키지를 짊어지는 공급자가 그 뒤를 받칩니다. 우리 스택에서는 여러분 이 감사 로직을 소유합니다. 그 말은 여러분이 그것을 검증하고, DDL을 버전 관리하고(Git에 들어 있어 변경 관리에 진짜 이점입니다), 적격성 평가(qualification, 전산화 시스템이 GMP 용도에 적합함을 증명하는 공식 IQ/OQ/PQ — 25장 참조) 아래에서 방어하는 것을 소유한다는 뜻입니다.

같은 기록을 트리플로: 트리거만이 아니라 SHACL로 게이트하기

관계형 audit.change_log 행에는 깔끔한 시맨틱 쌍둥이가 있고, 그것을 살펴보는 데 한 문단을 쓸 가치가 있습니다. 이 장을 시맨틱과 디지털 스레드의 지식 그래프에 잇기 때문입니다. 변경 사건 한 행은 그 자체로 작은 출처(provenance) 그래프입니다. W3C PROV-O(출처를 위한 표준 온톨로지 — 누가 무엇을, 어느 대상에, 언제 했는지)에서 결과를 변경하는 행위는 prov:Activity, app_user 에 이름 붙은 분석가는 그것이 prov:wasAssociatedWith 로 연결되는 prov:Agent, row_key 로 키가 매겨진 lab.result 행은 그것이 prov:used 하고 새 버전을 생성한 prov:Entity, 그리고 reason 은 그 활동에 붙는 prov:value 입니다. 그래프의 언어로 보면 트리거는 데이터베이스 안에서 돌아가는 PROV-O 구체화기(materializer)입니다. 그리고 이 장이 PL/pgSQL로 강제하는 닫힌 세계(closed-world) 완전성 — 모든 규제 대상 변경은 반드시 누가, 언제, 를 실어야 한다 — 은 트리플 쪽에서 SHACL(Shapes Constraint Language — 그래프 데이터가 필요한 구조를 갖췄는지 검증하는 방법) 셰이프가 선언적으로 표현하는 바로 그것입니다.

# Illustrative: the closed-world gate the trigger enforces, written as a SHACL shape.
bp:AuditEntryShape a sh:NodeShape ;
sh:targetClass prov:Activity ;
sh:property [ sh:path prov:wasAssociatedWith ; sh:minCount 1 ;
sh:message "Every regulated change must be attributable (app_user)." ] ;
sh:property [ sh:path prov:atTime ; sh:minCount 1 ; sh:maxCount 1 ] ;
sh:property [ sh:path bp:reason ; sh:minCount 1 ] .

에이전트에 붙은 그 sh:minCount 1Attributable 규칙이며, 누가 가 빠진 것은 지금 당장 실패 입니다. 4권이 출시 게이트로 실행하는 바로 그 닫힌 세계 "필수 필드가 존재하는가?" 질문이죠. OWL의 열린 세계(open world)라면 그 부재를 "알 수 없음" 이라고만 부를 것입니다. 그러면 규제 당국의 감사 추적 검토는 SPARQL 컴피턴시 질문(competency question) — "이유가 빠진 결과 변경을 모두, 그 에이전트와 시각과 함께 나열하라" — 이 됩니다. 위에서 실행한 SELECT * FROM audit.change_log WHERE action = 'UPDATE' 의 그래프 버전입니다. 4권은 출시 게이트와 SHACL에서 열린 세계 OWL 대 닫힌 세계 SHACL의 바로 이 분업을 전개하며, 거기서 게이트하는 bp:approvedBy 서명은 24장이 실제 신원에 묶는 바로 그 훅입니다. 그래서 여기의 감사 행, PROV-O 활동, 그리고 출시된 로트의 서명은 하나의 규제 대상 사실에 대한 세 가지 관점입니다.

왜 이것이 어떤 모델 아래의 바닥인가: 신뢰할 수 있는 데이터는 ML의 전제 조건

학습하는 모델은 그것이 학습한 기록만큼만 신뢰할 수 있습니다. 그래서 이 장은 5권이 쌓아 올리는 모든 것 아래의 화려하지 않은 바닥입니다. 바이오공정 모델 — 역가(titer)를 위한 라만(Raman) 소프트 센서, 출시 예측 분류기 — 은 정확히 이 트리거가 지키는 lab.results88.batch 행으로 훈련됩니다. 그 모델의 세 가지 필요는 이 감사 추적이 곧바로 공급하는 거버넌스 필요입니다. 첫째, 재현 가능한 훈련 데이터: 모델 문서(dossier)는 학습한 정확한 행을 못 박아야 하며, 해시 체인으로 묶인 추가 전용 로그는 데이터셋 해시(dataset hash)가 닻을 내릴 모델 계보(lineage) 기록을 줍니다. examples/platform/ml/ 모음은 매 재훈련마다 정확히 그런 데이터셋 해시를 기록합니다. 둘째, 누출 없는 배치 인식 검증: 모든 결과가 자신의 batch_id(여기서는 row_key)를 싣고 있으므로, 변경을 귀속 가능하게 만드는 바로 그 맥락이 모델이 데이터를 행이 아니라 배치 별로 나누게 합니다. 모델이 훈련에서 외운 배치로 다시 시험받아 부정행위를 하지 못하게 막는, 그룹화된 배치 단위 제외(leave-one-batch-out) 교차 검증입니다. 5권이 모델과 검증에서 못 박는 핵심 함정이죠. 셋째, 드리프트 대 변조: 감사 추적 단절과 모델의 입력 드리프트 경보는 같은 스트림 위의 서로 다른 신호입니다. 해시 체인 실패는 기록이 변경되었다 고 말하고, 개체군 안정성 지수(Population Stability Index) 변동은 공정이 움직였다 고 말합니다. MLOps 루프는 둘 다 필요로 하며, 모델의 정확한 훈련 세트를 재현하게 해 주는 그 계보가 바로 정정된 결과가 몰래 덮어써지지 않고 진짜로 다시 훈련에 쓰였음을 증명하게 해 주는 계보입니다(5권이 MLOps와 라이프사이클에서 자세히 다루는 라이프사이클). 표어는 단호합니다. 신뢰할 수 없는 데이터 위에 신뢰할 수 있는 모델은 없으며, 이 장은 데이터가 그 형용사를 얻어내는 곳입니다.

핵심 용어

  • ALCOA+ — 신뢰할 수 있는 GMP 기록을 정의하는 데이터 무결성 속성(Attributable, Legible, Contemporaneous, Original, Accurate, 더하여 Complete, Consistent, Enduring, Available).
  • 감사 추적(audit trail) — 누가 무엇을, 언제, 왜 변경했는지에 대한 안전한 추가 전용 기록. 여기서는 트리거가 채우는 audit.change_log 테이블.
  • 트리거 기반 감사(trigger-based audit) — 애플리케이션 코드에 의존하는 대신, PostgreSQL AFTER ... FOR EACH ROW 트리거를 사용해 모든 변경에서 OLD/NEW 행 이미지를 자동으로 포착하는 것.
  • 시스템 버전(시간) 이력(system-versioned/temporal history) — 행이 변경될 때 이전 버전을 보존해 원본을 결코 잃지 않는 SQL:2011 패턴. 여기서는 트리거로 모방한다.
  • 해시 체인(hash chain) — 각 행이 직전 행의 해시를 포함하는 SHA-256 해시를 저장하는 기록의 연속. 여기서는 삭제되거나, 순서가 바뀌거나, 다시 연결된 항목을 탐지 가능하게 만든다(해시 컬럼을 그대로 둔 채 행 페이로드만 몰래 수정한 경우는, 페이로드로부터 해시를 재계산하지 않는 한 탐지되지 않는다).
  • 변조 입증 가능 대 변조 방지(tamper-evident vs tamper-proof) — 체인은 변경을 탐지 가능(입증 가능) 하게 만든다. 변경을 불가능(방지) 하게 만들지는 않으며, 특권을 가진 DBA는 여전히 이를 이길 수 있다.
  • pgcrypto — 체인을 구축하는 데 쓰이는 digest()/SHA-256을 포함해 데이터베이스 내장 암호 함수를 제공하는 PostgreSQL 확장.
  • 귀속 가능한 메타데이터(attributable metadata) — 값 주변에 기록되는 db_user/app_user/reason 맥락. 이 자체가 규제 대상 기록의 일부다.
  • GENERATED ALWAYS AS IDENTITY — 데이터베이스가 값을 부여하며 애플리케이션이 덮어쓸 수 없는 PostgreSQL 컬럼(여기서는 seq). 그래서 감사 이력의 순서 는 기록하는 쪽이 아니라 시스템이 지킨다.
  • prev_hash / row_hash — 각 audit.change_log 행의 두 링크 컬럼. prev_hash 는 직전 행의 row_hash 를 그대로 복사한 것이고, row_hashprev_hash 에 이 행의 페이로드를 더해 계산한 SHA-256이다. 둘이 함께 verify_chain() 이 훑는 체인을 이룬다.
  • row_key — 트리거가 변경된 행에서 추출하는 합쳐진(coalesced) batch_id/sample_id. 어느 규제 대상 테이블에서 왔든 한 컬럼이 영향받은 기록을 가리킨다.
  • PROV-O — W3C 출처(provenance) 온톨로지. audit.change_log 한 행은 prov:Activity(변경), prov:Agent(app_user), prov:Entity(규제 대상 행)로 매핑되므로, 트리거는 사실상 출처 그래프 구체화기다.
  • SHACL 게이트(닫힌 세계) — 트리거의 강제를 그래프 쪽에서 쌍둥이로 표현한 것. 에이전트·시각·이유에 붙는 sh:minCount 1 이 누락된 필드를 지금 당장 실패 로 만드는 sh:NodeShape 다. 4권이 출시 게이트로 실행하는 바로 그 닫힌 세계 완전성 검사이며, OWL의 열린 세계라면 그 부재를 "알 수 없음" 이라고만 부른다.
  • 모델 계보 / 데이터셋 해시(model lineage / dataset hash) — 배포된 모델이 필요로 하는 재현성 기록, 즉 어떤 정확한 규제 대상 행으로 훈련되었는가. 해시 체인으로 묶인 추가 전용 감사 로그는 모델의 데이터셋 해시가 닻을 내리는 토대라, 정정된 결과가 몰래 덮어써지지 않고 다시 훈련에 쓰였음을 증명할 수 있다.
  • 배치 그룹화(배치 단위 제외) 검증(batch-grouped/leave-one-batch-out validation) — 모델의 데이터를 행이 아니라 batch_id(여기서는 row_key)별로 나누어, 모델이 훈련에서 외운 배치로 시험받아 부정행위를 하지 못하게 하는 것. 변경을 게이트하는 그 귀속 가능한 맥락이 바로 누출 없는 검증을 가능하게 한다.

다음 이야기

우리는 설계로 데이터를 변조 입증 가능하고 귀속 가능하게 만들었습니다. 그러나 감사 추적은 무엇이 변경되었는지 에는 답하지만, 누가 법적으로 유의미한 서명으로 그것을 공식 승인했는지 에는 아직 답하지 못합니다. 24장 — 전자 기록 및 서명: 오픈 소스로 구현하는 Part 11 / Annex 11 에서는 이 장의 app.userreason 훅(hook)을 가져와 실제 인증된 신원에 묶고, 특권 세션 로깅을 위한 pgAudit과, 오픈 소스 전자 실험 노트인 eLabFTW를 통한 암호학적 전자 서명(변조 방지 디지털 승인) 및 변경 이유 서비스를 추가합니다. 그리고 오픈 소스가 어떤 Part 11 조항을 충족하고 어떤 조항이 여전히 절차나 상용 도구를 요구하는지 정확히 보여주는, 가차 없이 정직한 빈틈 명세서(gap register)를 그립니다.