6. AI가 권한 요청을 먼저 검토한다
앞의 기능들은 비교적 명확한 요청을 자동화한다. 하지만 현실의 권한 요청은 늘 애매한 부분을 남긴다.
> “이 그룹, 줘도 되나?”
이 판단을 사람이 매번 처음부터 하면 다시 병목이 된다. 요청이 쌓이면 결국 충분히 보지 못하고 통과시키는 순간이 생긴다.
이 문제를 줄이기 위해 모든 셀프서비스 요청은 먼저 AI 검토 단계인 claude-judge를 통과한다. 사용 모델은 claude-sonnet-4-6이다.


LLM의 판단 위에 코드 기반 안전장치를 둔다
claude-judge는 회사의 보안 원칙을 시스템 프롬프트로 받는다.
최소 권한
기본 거부
Zero Trust
admin 권한 자동 부여 금지
그리고 요청을 셋 중 하나로 분류한 JSON을 반환한다.
approve: 자동 승인 가능review: 사람 검토 필요reject: 거절

여기까지만 보면 흔한 “LLM에게 판단을 맡긴다”는 구조처럼 보일 수 있다. 하지만 이 플랫폼의 핵심은 반대다.
LLM을 그대로 믿지 않는다.
LLM은 자연어 요청을 해석하고 초안을 만든다. 하지만 최종 결정은 코드로 정의한 안전장치가 다시 덮어쓴다.
모델이 위험한 요청을 approve해도 코드가 reject로 바꾼다. 반대로 모델이 안전한 읽기 권한 요청을 지나치게 보수적으로 review로 분류하면 코드가 approve로 끌어올린다.
// lib/claude-judge.ts — LLM 응답을 파싱한 뒤, 결정론적 안전망이 최종 결정을 덮어쓴다
const j = JSON.parse(m[0]) as Judgment;// 안전망 1) admin 그룹은 모델이 approve 해도 강제 reject
if (j.decision === 'approve' && req.type === 'access-group') {
const tgt = req.target.toLowerCase();
if (DANGEROUS_GROUPS.some((g) => tgt.includes(g)) || /[_-]admin$/i.test(tgt)) {
return { decision: 'reject', confidence: 1, reason: 'admin 그룹은 자동 처리 불가 (안전망)' };
}
}// 안전망 2) 읽기 권한 패턴이면, 모델이 review 로 분류했더라도 강제 approve
if (req.type === 'access-group' && j.decision !== 'reject') {
const tgt = req.target.toLowerCase();
if (/[_-](read_user|viewer|user|auditor)$/i.test(tgt)
&& !DANGEROUS_GROUPS.some((g) => tgt.includes(g))) {
return { decision: 'approve', confidence: 0.9,
reason: 'read 권한 그룹 — 자동 처리 가능 (안전망)',
action: { kind: 'okta-add-group', params: { group: req.target } } };
}
}또한 되돌리기 어렵거나 법적 책임이 따르는 요청은 모델 판단과 무관하게 항상 사람에게 넘긴다.
MFA 재등록: 계정 탈취 가능성이 있어 본인 확인 필요
단말 격리: 되돌리기 어려운 조치
셀러 개인정보 파기: 법적 책임이 따르는 작업
피싱 신고: 사고 대응 맥락 확인 필요
예를 들어 MFA 재등록은 모델이 approve하더라도 코드가 다시 review로 바꾼다.
// 안전장치 3) MFA 재등록은 본인 확인이 필요하므로 항상 review
if (req.type === 'mfa-reset' && j.decision === 'approve') {
return {
decision: 'review',
confidence: 1,
reason: 'MFA 재등록은 본인 확인 필요 - 검토 후 처리 (안전장치)',
action: {
kind: 'okta-reset-factors',
params: {},
},
};
}이 구조를 한 문장으로 정리하면 이렇다.
LLM은 자연어 요청을 구조화된 판단으로 바꾸고, 코드는 절대 넘으면 안 되는 선을 지킨다.
AI에게 모든 결정을 맡기지도 않고, 규칙만으로 모든 경우를 일일이 정의하려고 하지도 않았다. AI의 유연함과 코드의 결정론적 안전장치를 함께 사용했다.
무엇이 좋아졌나
명확한 요청은 AI와 규칙이 즉시 처리하고, 사람은 정말 판단이 필요한 요청만 본다.
담당자는 빈 화면 앞에서 처음부터 판단하지 않는다. AI가 요청을 요약하고, 어떤 이유로 승인 가능하거나 검토가 필요한지 정리해 둔다. 사람은 그 맥락 위에서 최종 결정을 내린다.
바쁠 때 대충 통과시키는 위험도 줄었다. 시스템이 명확한 요청을 먼저 걸러주기 때문에, 담당자 앞에 온 요청은 실제로 볼 가치가 있는 요청에 가까워졌다.
앞으로 하고 싶은 일
지금 AI는 1차 검토를 맡고, 애매한 요청은 사람에게 넘긴다.
다음 단계는 AI의 판단과 사람의 판단을 함께 기록해 일치율을 측정하는 것이다. 충분히 검증된 패턴부터 자동 승인 범위를 조금씩 넓힐 수 있다.
다만 원칙은 유지해야 한다.
애매하면 사람에게 넘긴다.
AI에게 맡기는 것은 판단 그 자체가 아니라, 판단이 필요 없을 만큼 명확해진 경우다. 이 구분을 놓치면 안전장치는 무너진다.
7. 어드민 콘솔: 사람이 예외 케이스를 처리하는 자리
claude-judge가 review로 분류한 요청은 어드민 콘솔(/admin/requests)로 들어온다.
이 화면은 코어플랫폼팀 DevSecOps가 실제로 사용하는 작업 공간이다. 모든 신청의 생애주기가 하나의 상태 흐름으로 관리된다.
Press enter or click to view image in full size


한눈에 보고, 펼쳐서 판단한다
목록에는 다음 정보가 표시된다.
신청 시간
신청자
요청 유형과 대상
AI 판단 결과와 confidence
현재 상태
연결된 Jira 이슈
기간(1일~1년), 상태, 신청자 이메일로 필터링할 수 있다. 상태는 색으로 구분한다.
검토 필요
자동 적용
거부
롤백됨
적용 실패
행을 펼치면 신청 사유, AI의 판단 근거(judgment_reason), 자동 처리 유형(action_kind), 실행 결과, 오류, 완료 시각까지 볼 수 있다.
이 화면의 핵심 가치는 담당자가 0부터 판단하지 않아도 된다는 점이다. AI가 이미 “이 요청은 어떤 이유로 검토가 필요하다”고 정리해 둔 상태에서, 사람은 마지막 결정을 내린다.
액션은 상태에 따라 제한한다
버튼은 신청 상태에 따라 조건부로만 노출된다. 더 중요한 것은 서버에서도 상태를 다시 검증한다는 점이다.
클라이언트만 믿지 않는다. 맞지 않는 상태에서 액션을 보내면 서버가 409로 거부한다.
어드민 콘솔에서 할 수 있는 주요 액션은 다음과 같다.
승인 + 실행
상태가 review_required이고 AI가 계획한 action_kind가 있을 때만 가능하다. AI가 준비한 자동 조치를 그대로 실행한다.
예외 승인
AI가 reject 또는 review로 막았더라도 어드민 권한으로 강제 승인하고 실행할 수 있다. 이때 사유 입력은 필수다. Slack과 Jira에는 직전 AI 판단과 함께 override 표시가 남는다. 규칙을 넘는 행위 자체를 감사 대상으로 만든 것이다.
거부
거부 사유를 judgment_reason에 누적 기록한다.
롤백
applied 상태에서 실행된 조치를 되돌린다.
이 흐름의 중요한 안전장치는 실행과 되돌리기를 한 곳에서 짝으로 관리한다는 점이다. 승인은 executeForward가 처리하고, 롤백은 executeReverse가 처리한다.
// 승인 시 AI가 계획한 action_kind를 그대로 디스패치한다.
async function executeForward(actionKind, params, requesterEmail, target, meta) {
if (actionKind === 'okta-add-group') {
// Okta 그룹 추가
}
if (actionKind === 'tailscale-authorize') {
// 디바이스 승인
}
if (actionKind === 'device-isolate') {
// 세션 종료, MFA reset, Tailscale revoke
}
if (actionKind === 'manual-handled') {
return `수동 처리 완료 (admin: ${requesterEmail} 신청)`;
}
if (actionKind.startsWith('github-')) {
return executeGitHubAction(actionKind, meta, ...);
}
throw new Error(`unsupported action: ${actionKind}`);
}롤백에는 두 가지 원칙을 적용했다.
첫째, 되돌릴 수 없는 작업은 정직하게 막는다. MFA reset, 단말 격리, 러너 토큰 발급처럼 되돌릴 수 없는 조치는 롤백을 시도하면 예외를 던진다. 조용히 성공한 척하지 않는다.
둘째, 파괴적인 작업은 회복 가능한 형태로 되돌린다. 예를 들어 GitHub 레포지토리 생성 롤백은 삭제가 아니라 아카이브로 처리한다. 실수로 롤백해도 데이터가 사라지지 않게 하기 위해서다.
async function executeReverse(actionKind, ...) {
if (actionKind === 'okta-reset-factors') {
throw new Error('MFA factor reset은 되돌릴 수 없습니다 (이미 재등록했을 수 있음)');
}
if (actionKind === 'github-create-repo') {
await archiveRepo(repoName); // 삭제가 아니라 아카이브
return `GitHub repo archived (영구 삭제는 별도 요청 필요)`;
}
// ...
}모든 결정은 세 곳에 남긴다
승인, 거부, 예외 승인, 롤백 중 어떤 액션이든 끝나면 세 가지가 함께 일어난다.
첫째, DB 상태가 전이된다. action_executed와 judgment_reason에 누가, 언제, 왜 처리했는지가 쌓인다.
둘째, Slack 알림이 남는다. 어드민, 신청자, 대상, 실행 결과가 채널에 기록된다.
셋째, Jira 코멘트와 상태 전이가 이뤄진다. 신청자에게는 요청 유형별 다음 단계 안내 DM도 발송된다. 예를 들어 MFA 재등록이면 재로그인 절차를 안내하고, 러너 토큰이면 60분 후 만료된다는 점을 알려준다.
자동화가 처리하든 사람이 예외 처리하든, 추적 가능성은 동일하게 유지된다. 언제든 다시 들여다볼 수 있게 만드는 것이 흐름의 일부다.
같은 콘솔에는 챗봇 품질 대시보드(/admin/quality)도 붙였다. 봇별 helpful/unhelpful, 후속 질문(clarify/retry) 통계를 보고, 실제 대화에서 새 지식을 추출하는 버튼까지 있다.
운영 데이터가 다시 보안 지식 개선으로 돌아가는 피드백 루프다.
8. 취약점 점검을 정기적으로 자동화한다
권한과 지식을 잘 다뤄도 보안은 끝나지 않는다.
마지막 질문이 남는다.
우리가 만든 서비스 자체는 안전한가?
그리고 이 질문은 한 번 묻고 끝낼 수 없다. 일회성 점검은 점검한 그날 이후 들어온 코드를 보지 못한다.
그래서 취약점 점검도 정기 작업으로 만들었다.
스캔, 담당자 식별, Jira 동기화
세 개의 cron job이 하나의 파이프라인을 이룬다.
vulnerability-ingest : 코드/컨테이너/IaC/CVE 스캔 결과 수집 → 심각건은 Slack + Jira 티켓
vulnerability-blame : 취약 파일:라인을 GitHub blame(GraphQL)으로 책임자 식별 (비동기 백필)
vulnerability-jira-sync : Jira 티켓 상태를 주기적으로 동기화설계에서 신경 쓴 부분은 세 가지다.
정기적으로 실행한다
cron으로 돌려 항상 최신 코드를 본다. “한 번 훑었으니 됐다”가 아니라, 어제 머지된 코드를 오늘 다시 본다.
담당자를 연결한다
취약점만 찾아서 던져두면 아무도 고치지 않는다.
GitHub blame으로 담당자를 찾고 Jira 티켓을 만들어 실제 수정 워크플로에 태운다. 발견과 수정 사이의 끊어진 흐름을 자동으로 이어준다.
중복 티켓을 막는다
같은 취약점에 대해 시간차로 중복 티켓이 쌓이면 알림 피로가 생긴다. 그래서 fingerprint를 PK로 두고, 이미 등록된 finding이면 새 Jira 티켓을 만들지 않는다.
-- 같은 finding(fingerprint)당 Jira 티켓은 단 하나.
-- 재스캔돼도 새 티켓이 생기지 않는다.
INSERT INTO vulnerability_jira_links (fingerprint, jira_issue_key, ...)
VALUES (...) ON CONFLICT (fingerprint) DO NOTHING;자동화에서는 “찾는 것”만큼이나 “노이즈를 만들지 않는 것”이 중요하다. 알림이 너무 많아지면 사람들은 결국 알림을 보지 않게 된다.
UX에서도 작은 원칙을 지켰다.

사내 도구의 내부 코드명 대신 “코드 스캔”처럼 평이한 한글 라벨을 앞세웠다. 비개발 직군도 취약점 출처를 한눈에 이해할 수 있게 하기 위해서다.
무엇이 좋아졌나
“우리 코드 어딘가에 취약점이 있는데 아무도 모르는 상태”에서 “발견되면 티켓이 만들어지고 담당자에게 연결되는 상태”로 바뀌었다.
DevSecOps가 사람을 일일이 찾아다니며 “이거 고쳐 주세요”라고 부탁하는 일이 줄었다. 취약점 관리는 “발견하면 부탁”이 아니라 “발견되면 자동 할당”되는 흐름이 됐다.
앞으로 하고 싶은 일
지금은 이미 들어온 코드의 취약점을 사후에 찾는다. 다음 단계는 더 앞단에서 막는 것이다.
취약한 코드가 머지되기 전에, 위험한 권한 요청이 승인되기 전에, 그 자리에서 맥락과 함께 경고하고 싶다.
다만 개발자의 흐름을 불필요하게 막지 않는 선을 찾아야 한다. 보안이 업무를 방해한다고 느껴지는 순간, 사람들은 보안을 우회하려고 한다. 이 글이 처음에 다룬 문제로 다시 돌아가게 되는 것이다.
9. 마무리: 보안 담당자가 아니라 보안 플랫폼
이 글의 이야기를 한 문장으로 줄이면 이렇다.
보안 담당자의 일은 모든 요청을 직접 처리하는 것이 아니라, 안전하게 처리되는 흐름을 만드는 것이다.
모든 요청이 한 사람에게 몰리면 그 사람은 병목이 된다. 바쁘면 중요한 판단을 놓치고, 자리를 비우면 처리가 멈춘다.
플랫폼은 다르게 동작한다. 명확한 요청은 24시간 셀프서비스로 처리한다. AI는 요청을 먼저 정리하고, 코드는 반드시 지켜야 할 안전장치를 적용한다. 사람은 정말 판단이 필요한 예외 케이스만 본다. 동시에 시스템은 우리 서비스의 취약점을 정기적으로 들여다보고, 발견된 문제를 실제 수정 워크플로로 연결한다.
소수의 인력으로 조직 전체의 보안을 감당하는 방법은 더 빨리 뛰는 것이 아니었다. 뛰지 않아도 되는 길을 코드로 만드는 것이었다.
전담 보안팀이 있는 조직이든, 우리처럼 플랫폼팀의 DevSecOps가 보안을 함께 맡는 조직이든 비슷한 고민을 할 수 있다. 그런 팀에게 이 네 가지 구성이 하나의 참고 패턴이 되면 좋겠다.
하나의 기준 문서로 보안 지식 관리
안전장치가 있는 권한 셀프서비스
LLM과 코드 기반 규칙을 함께 쓰는 AI 1차 검토
정기 취약점 점검과 담당자 연결
다음 글에서는 claude-judge의 안전장치 설계를 더 깊게 다뤄보려고 한다. AI 자동화에 어떤 규칙을 코드로 덧씌워야 하는지, 그리고 "AI에게 어디까지 맡기고 어디서 반드시 멈춰야 하는지"를 코드 레벨에서 정리해볼 예정이다.
