Secu-Agent · 문서
저는 빠르게 만들고 여러 번 검증해서 마무리하는 방식으로 일합니다. 속도를 택하면 보통 안전성을 잃는다고들 합니다. 그래서 보안을 주제로 잡았습니다 — 취약점이 치명적인 도메인에서, 속도를 유지하면서도 신뢰할 수 있는 결과를 내는 방법을 찾아보려고요.
AI 도구로 코드를 빠르게 생성합니다. 모든 라인을 눈으로 좇아 검증하는 방식은 그 속도를 따라가지 못합니다. 대신 다른 방법을 택했습니다:
개별 코드를 읽어서 결함을 찾는 대신, 결함이 스스로 드러나는 장치를 먼저 만든다.
이 페이지는 그 장치들이 실제로 무엇을 잡아냈는지 기록합니다. “검증 체계를 만들었다”가 아니라 “그 체계가 이것들을 잡았다”를 보여주는 자리입니다.
| 장치 | 무엇을 잡는가 | 언제 도는가 |
|---|---|---|
| 경계 강제 테스트 | core/ 에 인프라 라이브러리·프레임워크가 새어드는 것 |
매 커밋 (CI) |
| Access Control 누출 테스트 | 권한 없는 문서의 존재가 개수·순위·응답시간으로 드러나는 것 | 매 커밋 (CI) |
| 평가 하네스 | 검색 품질 회귀 — 숫자로 | 수동 (python -m eval.run) |
| 서브에이전트 코드 리뷰 | 계획·구현의 논리적 결함 | 태스크마다 |
| 실물 탐침 | 계획서의 SQL·정규식이 실제 엔진·실제 문서에서 다르게 도는 것 | 구현 전 |
| 변이 검사 | 테스트가 실은 아무것도 지키지 않는 것 | 수정할 때마다 |
core/ 가 무엇을 import 하는지 AST 로 검사합니다. 아래가 들어오면 CI 가 실패합니다.
바깥 계층 adapters · api · pipeline · eval
인프라·프레임워크 psycopg · sqlalchemy · langchain_google_genai · google · fastapi
langchain · langgraph · sentence_transformers
torch · transformers · pypdf
LangChain 을 이 목록에 넣은 것이 핵심입니다. 프레임워크가 도메인으로 스며들면 버전이 바뀔 때 도메인 로직까지 끌려갑니다. 도구 로직과 권한 검증은 프레임워크를 모르는 곳에 두고, LangChain 은 그것을 감싸기만 합니다.
권한 필터링에서 가장 흔한 구현은 사후 필터링입니다 — 검색한 뒤 권한 없는 것을 걸러냅니다. 쓰기 쉽고, 틀렸습니다.
사후: 상위 10건 검색 → 권한 검사 → 3건 남음
결과가 3건인 것 자체가 "숨겨진 문서가 있다"는 신호가 된다
사전: 권한 통과 문서 중 상위 10건 → 10건
차이를 관측할 수 없다
네 가지 누출 경로를 각각 테스트합니다.
def test_결과_개수가_주체에_따라_달라지지_않는다() # 개수 — 등급 축
def test_결과_개수가_질의에_따라_달라지지_않는다() # 개수 — 질의 축
def test_두_주체에게_모두_보이는_문서의_상대_순위가_같다() # 순위
def test_훑는_후보_집합이_질의와_무관하다() # 타이밍
def test_도구_출력에_권한_밖_항목이_있으면_예외가_난다() # 도구 출력
설계 문서는 타이밍 누출을 test_응답시간_차이가_유의하지_않다 로 잡는다고 적었습니다. 구현 전에 실제로 재봤습니다 — 사전 필터링과 일부러 고장낸 사후 필터링을, 등급 1 주체가 질의 두 종(기밀 축 · 공개 축)으로 각 30회씩.
| 결과 개수 (기밀축 / 공개축) | 벽시계 중앙값 비 | |
|---|---|---|
| 사전 필터링 | 10건 / 10건 | 1.17 |
| 사후 필터링 (고장난 구현) | 0건 / 10건 | 1.21 |
시간 비율이 사실상 같습니다. 어떤 임계를 골라도 고장난 구현이 통과합니다. 통과하지만 아무것도 지키지 않는 테스트를, 1급 근거에서 내렸습니다 — slow 를 달아 보조 그물로만 남겼습니다.
대신 EXPLAIN (ANALYZE, FORMAT JSON) 으로 훑는 후보 집합의 크기를 봅니다. 타이밍이 새지 않는 진짜 근거는 “후보 집합이 주체에만 의존하고 질의에는 의존하지 않는다”이고, 이건 결정론적으로 확인됩니다 — 등급 1 은 질의와 무관하게 2,000행, 등급 3 은 4,000행.
같은 누출 테스트를 픽스처 규모만 바꿔 돌렸습니다.
| 문서당 청크 | 고장난 구현에서 기밀축 질의 결과 | 테스트가 |
|---|---|---|
| 300 | 10건 | 통과합니다 ← 거짓 |
| 2,000 | 0건 | 실패합니다 ← 정상 |
600청크 규모에서는 플래너가 CTE 를 인라인해도 순차 스캔으로 정확 검색을 해버려, 근사 인덱스의 후보 절단이 아예 일어나지 않습니다. 테스트 코드가 옳아도 데이터가 작으면 아무것도 증명하지 못합니다.
“기밀 문서 근처”와 “무관한” 질의 벡터를 384차원 난수 단위벡터로 뽑았더니 cos = 0.101 이 나왔고, 기밀 청크가 무관해야 할 질의의 상위 10건 중 9건을 차지했습니다. 픽스처가 재려던 것을 재지 못하는 상태였습니다. 좌표 블록을 앞뒤로 갈라 내적이 구조적으로 0 이 되게 고쳤습니다.
설계 문서는 enforce(hits, principal) 이 도구 출력을 재검증한다고 적었습니다. 그런데 PolicyHit 에는 chunk_id · text · doc_title · clause_code 뿐이라 판단할 근거가 결과 안에 없었습니다. enforce 가 할 수 있는 일은 DB 를 다시 부르거나(그건 재검증이 아니라 같은 코드를 두 번 믿는 것) 전부 통과시키는 것뿐이었습니다. 권한 메타를 결과에 담도록 타입을 고쳤습니다.
DB 테스트 픽스처 세 개가 모두 TRUNCATE documents RESTART IDENTITY CASCADE 로 시작합니다. 격리를 위해서는 옳지만, 그 결과 pytest -m db 를 한 번 돌리면 적재해둔 코퍼스가 사라집니다. 평가 하네스를 돌리기 직전에 테스트를 돌리면 지표가 픽스처의 합성 벡터 위에서 계산되고, 그 사실을 알려주는 신호는 없습니다 — 숫자는 그냥 나옵니다.
이번 주에는 실행 순서로 피했습니다. 근본 해법은 DB 테스트가 별도 데이터베이스를 쓰는 것입니다.
해결됨(W4a Task 1): db 테스트는 secuagent_test 를 쓰고, 안전장치를 픽스처 진입점에 두어 작업 데이터베이스를 가리키면 테스트가 한 건도 실행되지 않습니다. 코퍼스 자체를 단언하는 test_catalog.py 는 옮길 수 없어 corpus 마커로 갈랐고, 그 연결은 Postgres 세션 특성으로 읽기 전용이라 쓰기가 실행 단계에서 막힙니다.
변이 검사로 이 장치들이 실제로 사후 필터링을 잡는지 확인했습니다. AS MATERIALIZED 를 AS NOT MATERIALIZED 로 바꾸자 다섯 개가 실패했습니다 — test_결과_개수가_질의에_따라_달라지지_않는다(기밀축 0건 vs 공개축 10건), test_결과_개수가_주체에_따라_달라지지_않는다([0, 0, 10], 기대값은 [10, 10, 10]), test_훑는_후보_집합이_질의와_무관하다 와 test_후보_집합이_주체의_권한_범위와_일치한다(CTE Scan 노드가 사라짐 — 플래너가 인라인함), 그리고 SQL 문자열을 직접 보는 tests/test_search_sql.py::test_MATERIALIZED_힌트가_있다(DB 불필요). 반면 test_응답시간이_질의에_따라_갈리지_않는다 는 이 변이에서 통과했습니다 — 벽시계 보조 측정이 1급 증거가 아닌 이유가 바로 이것입니다.
프론트엔드 디자인 핸드오프의 관리자 대시보드는 이번 주 질의 342 · 열람된
서류 128 · 차단된 요청 17 과 8행짜리 열람 이력을 갖고 있었습니다. 고품질
목업이었고 그대로 구현하면 화면이 완성돼 보였을 것입니다.
그 사건들은 일어난 적이 없습니다. 열람을 기록하는 코드가 없었고
log_events 는 비어 있었습니다.
목 데이터를 그대로 렌더하는 대신 실제로 기록하게 만들었습니다. 관리자
화면의 숫자는 이 배포에서 실제로 일어난 요청에서 나옵니다. 기록에는 조항
코드와 chunk_id 만 담고 본문과 문서 제목은 담지 않습니다 — 제목만으로도
문서의 존재가 드러나기 때문이며, AccessViolation 이 메시지에 chunk_id 만
담는 것과 같은 원칙입니다.
같은 이유로 “처리 상태” 컬럼과 문의 티켓 번호를 뺐습니다. 저장하는 곳이 없는 값을 화면에 두면 그것도 지어낸 숫자입니다.
공유 시크릿 비교가 상수 시간인지 지키려고 둔 테스트는 이랬습니다.
assert "compare_digest" in inspect.getsource(security)
inspect.getsource 는 모듈 독스트링을 포함합니다. 그리고 그 독스트링
첫 줄에 “hmac.compare_digest 를 쓴다” 가 적혀 있었습니다. 비교를 == 로
바꾸고 독스트링만 남겨두면 테스트는 그대로 초록입니다. 테스트가 코드가
아니라 자기 주석을 읽고 있었습니다.
변이 검사로 확인했습니다. security.py 의 비교를 == 로 바꾸자 같은
파일의 나머지 여섯 테스트도 전부 통과했습니다. 그 파일이 존재하는 유일한
이유인 상수 시간 성질에, 판별력 있는 검사가 하나도 없었습니다.
고친 방향은 소스를 뒤지는 대신 호출을 관찰하는 것입니다. hmac.compare_digest
를 감싸 호출 여부와 인자를 기록하고, 맞는 시크릿과 틀린 시크릿 양쪽으로
요청을 보내 실제로 불렸는지 단언합니다. 주석으로도 식별자 이름으로도
만족시킬 수 없습니다.
교훈은 규모입니다. 소스 문자열을 검사하는 테스트는 구현이 아니라 구현에 대한 묘사를 검사합니다. 묘사는 코드와 함께 바뀌지 않습니다.
아래는 장치가 실제로 잡은 결함입니다. 개발이 진행되면서 계속 쌓입니다.
W1 첫 태스크에서 만든 것이 바로 이 페이지 위쪽 표의 첫 줄, 경계 강제 테스트입니다.
core/ 가 인프라나 프레임워크를 import 하면 CI 가 실패하도록 AST 를 직접 걷습니다.
그 테스트를 리뷰어가 읽고 구멍을 찾았습니다. 검사는 이렇게 쓰여 있었습니다.
if node.level == 0 and node.module: # level 로 걸렀다
names.append(node.module)
level == 0 은 절대 import 라는 뜻입니다. 즉 상대 import 는 통째로 검사에서 빠졌습니다.
| 코드 | AST | 검사 |
|---|---|---|
import psycopg |
— | 잡힘 |
from adapters import X |
level=0 | 잡힘 |
from ..adapters import X |
level=2, module=adapters |
빠져나감 |
from . import x |
level=1, module=None |
빠져나감 (이건 맞음) |
여기서부터가 실제로 한 일입니다. 리뷰어 말을 그대로 받지 않고 침투 파일을 심어봤습니다.
core/_leak_probe.py : from ..adapters import parsing
core/retrieve/_leak_probe.py : from ...adapters import parsing
→ ImportError: attempted relative import beyond top-level package
→ 경계 테스트: 7 passed
검사는 확실히 눈이 멀었지만, 오늘은 파이썬이 먼저 막습니다. core 가 최상위 패키지라
상대 경로로는 바깥에 닿을 수 없기 때문입니다. 리뷰어가 “실재하는 구멍”이라고 한 것은
반만 맞았습니다.
그래도 고쳤습니다. 지금 이 가드가 옳은 이유가 “backend/ 에 __init__.py 가 없다”는
우연이기 때문입니다. 누군가 그 파일 하나를 추가하는 순간 from ..adapters 는 실재하는
backend.adapters 로 풀리고, 검사는 계속 침묵합니다. 보안 장치가 우연에 기대고 있으면
그건 장치가 아닙니다.
고친 뒤에는 변이 검사로 확인했습니다. 테스트가 통과하는 것만 보면 “테스트가 있다”는 사실밖에 알 수 없습니다.
가드를 원래 결함으로 되돌림 → 1 failed, 6 passed (정확히 그 테스트만 실패)
원복 → 7 passed
이 순서가 요점입니다 — 리뷰가 찾고, 실행이 범위를 정정하고, 변이 검사가 수정이 실재함을 증명합니다. 세 장치가 각각 다른 것을 잡았습니다.
마지막으로 결함의 출처인 구현 계획 문서도 같이 고쳤습니다. 이 코드는 제가 쓴 계획에서 그대로 복사된 것이었고, 계획은 다음 태스크의 입력이라 두면 같은 결함이 다시 복제됩니다.
규정 PDF 에서 조항을 뽑는 정규식을 이렇게 썼습니다.
조항_패턴 = re.compile(r"^\s*(\d\.\d\.\d+)\s+(\S[^\n]{0,60})$", re.MULTILINE)
가운데 자리가 \d — 한 자리입니다. 2.10, 2.11, 2.12 는 구조적으로 매치될 수
없습니다. 잃고 있던 조항이 16개였고, 하필 이 프로젝트의 심장이었습니다.
| 조항 | 제목 |
|---|---|
| 2.11.3 | 이상행위 분석 및 모니터링 |
| 2.11.1 | 사고 예방 및 대응체계 구축 |
| 2.11.2 | 취약점 점검 및 조치 |
| 2.10.9 | 악성코드 통제 |
감사 로그를 분석해 규정 위반을 인용하는 에이전트가, “이상행위 분석 및 모니터링” 조항을 영원히 인용할 수 없는 상태로 나갈 뻔했습니다.
이 결함은 테스트로 잡히지 않습니다. 파서는 정상 동작합니다. 86개를 뽑고, 각각 정확한 제목과 본문을 붙이고, 검색도 잘 됩니다. 없는 조항을 아무도 찾지 않을 뿐입니다. 픽스처를 만들어 테스트를 짰어도 그 픽스처는 제가 만든 것이라 같은 가정을 공유합니다.
잡힌 이유는 하나입니다 — 바깥의 정답과 대조했기 때문입니다.
현재 패턴 \d\.\d\.\d+ → 고유 조항 86개
고친 패턴 \d+\.\d+\.\d+ → 고유 조항 102개
ISMS-P 2022 공식 인증기준 수 → 102개
숫자가 저절로 맞은 것이 증거입니다. 86 이 틀렸다는 걸 코드 안에서는 알 방법이 없습니다.
설계 전에 실물 PDF 로 패턴을 확인했었습니다. 41~70쪽에서 14건 정확히 매치. 그 숫자를 근거로 패턴을 계획에 박았습니다.
그 구간은 1.x 뿐입니다. 결함이 드러날 수 없는 구간을 표본으로 골랐습니다.
여기서 규칙을 하나 더 얻었습니다.
표본 검증은 “매치가 되는가”가 아니라 “결함이 나타날 수 있는 구간을 표본이 포함했는가” 로 설계해야 한다.
그리고 계획에 적어둔 진단 문구가 반대 방향이었습니다. “조항이 100개 미만이면 목차를 조항으로 오인한 것이니 최소 본문 길이 필터를 넣어라.” 수가 모자란 것은 덜 잡았다는 뜻이라, 그 필터는 상황을 악화시킵니다. 실측 없이 쓴 진단은 이렇게 반대로 향합니다.
하이브리드 검색의 키워드 쪽은 이렇게 계획돼 있었습니다.
WHERE c.text_tsv @@ plainto_tsquery('simple', %(q)s)
구현 전에 pgvector 컨테이너를 띄워 실제 질문을 넣어봤습니다.
질문: "이상행위 모니터링은 어떻게 하나요?"
plainto_tsquery → '이상행위' & '모니터링은' & '어떻게' & '하나요'
결과 → 0건
두 가지가 겹쳐 있었습니다.
| 문제 | 내용 |
|---|---|
| AND 결합 | plainto_tsquery 는 모든 낱말을 필수 조건으로 묶습니다. 사용자가 문장으로 물으면 어떻게·하나요 까지 전부 있어야 합니다 |
| 조사 | 한국어는 교착어인데 simple 설정은 형태소를 모릅니다. 저장된 토큰은 이상행위를, 질의는 이상행위 — 매치되지 않습니다 |
해결은 토큰마다 접두어 매칭을 붙여 OR 로 잇는 것이었습니다. 조사가 뒤에 붙기
때문에 접두어 매칭이 한국어에 구조적으로 맞습니다 — 이상행위:* 가 이상행위를,
이상행위가, 이상행위는 을 모두 흡수합니다.
AND 먼저, 없으면 OR 재시도 라는 더 정밀한 방식도 있었지만 쓰지 않았습니다. 질의
경로가 둘로 갈리면 “권한 없는 문서의 존재가 응답시간으로 새지 않는다”를 증명할 표면이
두 배가 됩니다. 이 프로젝트에서는 검색 품질보다 그쪽이 우선입니다.
조항 코드와 제목이 본문 안에 없다는 것도 그때 드러났습니다. 본문만 색인하면:
질의 "2.11.3" → 0건
질의 "이상행위" | "모니터링" → 0건 ← 조항 제목이 정확히 이것인데도
질의 "침해시도" → 적중 (본문 낱말이라서)
제목이 「이상행위 분석 및 모니터링」인 조항을 「이상행위」로 찾을 수 없었습니다.
색인 대상을 코드 + 제목 + 본문 으로 바꿔 해결했습니다. 저장되는 본문은 그대로
두었습니다 — LLM 에 넘길 문맥을 메타데이터로 오염시키지 않기 위해서입니다.
앞의 두 사례는 코드가 이미 있었습니다. 이건 한 줄도 쓰기 전에 잡혔습니다.
만약 그냥 구현했다면 테스트는 전부 통과했을 겁니다. 픽스처는 제가 만들었을 테고,
plainto_tsquery('simple', '네트워크') 같은 단일 낱말 질의로 짰을 테니까요. 사용자가
문장으로 묻는 순간 조용히 0건이 되는 것은 배포 후에나 드러납니다.
계획서에 적힌 SQL 은 가설입니다. 실물 엔진에 넣어보기 전까지는 동작한다는 증거가 없습니다.
이 시스템이 지켜야 할 첫 번째 약속은 이것입니다.
권한 없는 문서의 존재가 결과 개수·순위·응답시간으로 새어나가서는 안 된다.
그래서 권한 검사를 WHERE 절로 표현했습니다. 순위를 매기기 전에 걸러내려고요.
SELECT c.id FROM chunks c JOIN documents d ON d.id = c.document_id
WHERE <권한>
ORDER BY c.embedding <=> :질의벡터
LIMIT 10
읽으면 사전 필터링처럼 보입니다. 실제로 무엇이 도는지 실행 계획을 봤습니다.
Limit → Nested Loop
→ Index Scan using chunks_embedding_idx ← HNSW 가 후보를 먼저 뽑고
→ Seq Scan on documents (Filter: 권한) ← 권한은 그 뒤에 적용된다
사후 필터링이었습니다. WHERE 를 어디에 썼는지와 무관하게, 플래너가 벡터 인덱스를
먼저 타기로 결정한 것입니다.
공개 문서 2,000청크와 기밀 문서 2,000청크를 넣고, 기밀 쪽에만 질의와 아주 가까운
벡터를 심었습니다. 등급1 사용자가 k=10 을 요청하면:
actual rows=0
볼 수 있는 청크가 2,000개 있는데 0건을 받습니다.
두 겹으로 잘못됐습니다.
| 정확성 | 권한 있는 내용이 조용히 사라진다 |
| 누출 | 받은 개수가 “내가 못 보는 곳에 이 질의와 아주 가까운 문서가 있다” 를 알려준다 |
두 번째가 이 프로젝트가 막겠다고 한 바로 그것입니다. 그것도 지키려던 그 코드 안에서 일어났습니다.
HNSW 는 근사 인덱스입니다. 전부 보지 않고 정해진 개수의 후보만 뽑습니다. 그 후보가 전부 권한 밖 문서면 필터 후 남는 게 없습니다. 권한 밖 문서가 질의에 가까울수록 결과가 줄어듭니다 — 누출의 세기가 비밀의 관련도에 비례합니다.
WITH 허용 AS MATERIALIZED ( -- 이 두 낱말이 보안 장치다
SELECT c.id, c.embedding FROM chunks c
JOIN documents d ON d.id = c.document_id
WHERE <권한>
)
SELECT id FROM 허용 ORDER BY embedding <=> :질의벡터 LIMIT 10
MATERIALIZED 를 빼면 플래너가 CTE 를 인라인해서 원래대로 돌아갑니다. 실제로 확인했습니다
— 평범한 CTE 로는 여전히 0건이었습니다. 성능 힌트처럼 생겼지만 보안 경계입니다.
대가는 HNSW 를 못 쓴다는 것입니다. 권한 통과 집합에 대한 순차 정확 검색이 됩니다.
| 권한 통과 청크 | 실행 시간 |
|---|---|
| 314 (현재 코퍼스) | 2.5 ms |
| 2,000 | 8.8 ms |
| 4,000 | 8.0 ms |
이 규모에서는 무해합니다. 10만 청크를 넘으면 권한 컬럼을 청크로 비정규화하고 부분 인덱스를 검토해야 합니다. 그때도 근사 인덱스 위 사후 필터링으로 돌아가지 않는 것이 조건입니다. 이 제약을 계획서에 적어 두었습니다.
MATERIALIZED 를 지우면 실패하는 회귀 테스트를 넣었습니다.
WHERE 절의 위치는 필터가 언제 적용되는지 말해주지 않습니다. 그건 플래너가 정합니다.
보안 속성은 코드를 읽어서 확인할 수 없습니다. 실행 계획을 보고, 새어나갈 조건을 만들어 놓고, 새는지 세어봐야 합니다.
키워드 쪽(GIN/tsvector)에는 같은 문제가 없습니다. 근사가 아니라 정확 인덱스이기 때문입니다 — 이것도 추측이 아니라 확인한 결과입니다.
검색은 권한을 제대로 막습니다. 앞 사례에서 그걸 고쳤습니다.
등급1 사원이 by_vector 로 받는 id : [] ← 정상 차단
그런데 검색 결과를 화면에 보여주려면 id 를 본문으로 바꿔야 합니다. 그 함수가
load_hits(ids) 였습니다. Principal 을 받지 않습니다.
load_hits(그 id 들) : 1건
→ '대외비: 마스터 키는 …'
id 만 알면 본문이 그대로 나옵니다. 존재가 새는 것이 아니라 내용이 샙니다.
유일한 호출자인 CLI 가 이미 걸러진 id 만 넘기기 때문입니다. 그래서 테스트도 통과하고 실제로 돌려봐도 아무 문제가 없습니다. 그런데도 고친 이유는 세 가지입니다.
설계 문서에 제가 써 둔 문장이 그대로 들어맞았습니다.
검색 함수는 처음부터
Principal을 필수 인자로 받는다. 나중에 끼워 넣으면 빠뜨린 경로가 생긴다.
규칙은 “검색 함수”라고 썼는데, load_hits 는 검색 함수처럼 안 생겼습니다. 그래서
빠졌습니다. 규칙을 지켰다고 생각한 자리에서 빠진 경로가 나왔습니다.
권한 밖 id 는 조용히 빠지게 했습니다. 예외를 던지면 안 됩니다.
load_hits([1, 2], 등급1) → 1건 (2번은 그냥 없다)
load_hits([1, 2], 등급3) → 2건
“그 id 는 접근 불가” 라고 알려주면 그 응답 자체가 존재 확인이 됩니다. 못 보는 문서는 없는 문서와 구별되지 않아야 합니다.
보안 경계는 한 군데 세워서 되는 게 아니라, 그 경계를 우회하는 모든 경로에 세워야 합니다. 그리고 우회로는 대개 “보안과 무관해 보이는 유틸리티 함수” 모양을 하고 있습니다.
앞 사례(벡터 검색 사후 필터링)는 장치가 잘못 동작한 것이고, 이건 장치를 세운 자리 옆에 문이 하나 더 있던 것입니다. 둘 다 코드를 읽어서는 잘 안 보입니다 — “권한 밖 id 를 일부러 넘겨보는” 시도를 해야 나옵니다.
이 검증 체계는 앞선 프로젝트에서 설계·검증한 것을 그대로 가져왔습니다. 그때 잡아낸 것들입니다.
| 장치 | 잡아낸 결함 |
|---|---|
| 서브에이전트 리뷰 | 테스트가 자기 이름의 절반만 검증하고 있었다. nullable 축은 검사했지만 나머지 9개 축이 제약을 갖는지는 확인하지 않아, 제약을 지워도 통과했다 |
| 재현 검증 | 자동화 훅이 한 커밋에 대해 로그를 8개 생성했다. 멱등성이 없었고, 테스트 방법 자체가 결함이었다 — 매번 파일을 지우고 한 번씩만 실행했기 때문에 못 잡았다 |
| 손계산 | 가중치가 24개 조합에서 포화되어 세부 조정이 무의미해지는 상태였다 |
| 실물 확인 | 데이터셋이 실제로 존재하는지 확인하지 않고 세운 계획이 두 번 뒤집혔다. 이후 “설계 전에 실물 확인”을 규칙으로 삼았다 |
두 번째가 가장 중요한 교훈이었습니다. 결함을 못 잡은 이유가 코드가 아니라 검증 방법에 있었습니다. 그 뒤로 “이 테스트가 실제로 실패하는지”를 일부러 어겨서 확인하는 단계를 넣었습니다.
정직하게 적어둡니다.
W4 설계 문서에 로그 개수를 이렇게 예시로 적었습니다 — “김개발은 12, 최임원은 47을 받는다.” 등급이 높을수록 많이 본다는 그림입니다. 그럴듯했고 아무도 이의를 달지 않았습니다.
구현이 끝난 뒤 실제 코퍼스에 같은 파라미터로 물었습니다:
김개발(개발팀·1) auth_failure 4건 [dev-web-01, vpn-gw-01]
박인사(인사팀·2) auth_failure 3건 [hr-app-01, vpn-gw-01]
최임원(경영지원팀·3) auth_failure 2건 [exec-fs-01, vpn-gw-01]
정확히 반대였습니다. 버그가 아니라 규칙이 그렇습니다. 가시성 판정은
등급 ≥ 요구등급 ∧ (부서 ∈ 허용부서 ∨ 허용부서가 빔) 이라 두 축이고,
그래서 권한은 사다리가 아니라 격자입니다. 최임원은 등급이 가장 높지만
경영지원팀이라 개발팀·인사팀 전용 호스트를 보지 못합니다.
이걸 그냥 뒀다면 시연 화면이 “등급이 높을수록 많이 봅니다” 라고 적었을 것이고, 실제 데이터가 그 자리에서 반박했을 것입니다. 설계 문서를 실측값으로 고치고, 개수 차이는 권한 범위의 크기가 아니라 모양을 반영한다고 명시했습니다.
교훈은 문서에도 같은 규칙이 적용된다는 것입니다. 화면에 지어낸 숫자를 두지 않는다면, 설계 문서에도 두지 않아야 합니다. 그리고 이 부류는 코드를 읽어서는 안 잡힙니다 — 코드는 처음부터 옳았고, 틀린 것은 그것을 설명하는 문장이었습니다. 실물을 돌려보고 숫자를 눈으로 봐야 드러납니다.
현업에서 보안을 NAS 로 운영하던 사람에게 이 피드백을 받았습니다. 절반은 맞습니다. 시놀로지에 정책 문서를 올리고 폴더 ACL 을 걸면 권한 없는 사용자는 검색 결과에서 그 파일을 못 보고 존재조차 모릅니다. “권한 없는 것은 존재조차 알 수 없다” 는 파일시스템 ACL 에서 공짜로 따라옵니다.
유비가 깨지는 지점은 파생 인덱스입니다. NAS 는 파일이 저장 단위이고 ACL 이 그 위에 붙습니다. RAG 는 모든 등급의 청크가 하나의 벡터 테이블에 섞이고, 그 인덱스에는 ACL 을 붙일 자리가 없습니다.
문제는 그 사실이 테스트 파일 안에만 있었다는 것입니다. 화면을 보는 사람은 “문서 검색되네” 까지만 봤습니다. 그래서 순진한 경로를 실제로 만들어 나란히 실행합니다 — 기능을 더한 것이 아니라 이미 한 일을 드러낸 것입니다.