AWS 기술 블로그
Amazon Bedrock Guardrails로 Claude Enterprise에 조직의 DLP 정책 적용하기
2026년 11월 기준으로 작성한 보안 및 인프라 엔지니어 대상 구현 예제입니다.
직원이 고객 연락처나 내부 문서를 생성형 AI에 입력할 때, 조직은 개인정보와 영업 비밀을 다루는 내부 규정에 따라 그 요청을 허용할지 판단하고 근거를 남겨야 합니다. 이를 위해서는 어떤 정보를 검사하고 어떤 조건에서 요청을 거부할지 구체적으로 정해야 합니다.
하지만 이 기준을 생성형 AI 사용에 실제로 적용하기는 쉽지 않습니다. 직원이 웹 브라우저, 데스크톱과 모바일 앱, Claude Code, Slack에서 Claude를 쓰면 웹 프록시나 엔드포인트 에이전트만으로는 모든 요청을 확인하기 어렵습니다. 주민등록번호나 사업자등록번호 같은 국내 식별자와 “대외비” 같은 사내 보안 표기는 조직이 직접 검사 기준에 넣어야 하고, 미공개 인수합병 정보처럼 번호 형식이나 특정 단어로 판단할 수 없는 정보는 문맥을 함께 봐야 합니다.
정책을 적용하는 방식도 정해야 합니다. 처음부터 엄격한 기준을 적용하면 정상 업무까지 막힐 수 있으므로, 먼저 판정 결과를 관찰하며 기준을 조정합니다. 차단을 시작한 뒤에는 사용자에게 이유를 안내하고, 감사할 때 추적할 수 있도록 판정 기록을 조직의 로그에 남깁니다.
이 글에서는 Amazon Bedrock Guardrails와 자체 규칙으로 판정하는 서버를 AWS에 구축하고, Claude Enterprise의 Inference hooks에 연결해 조직의 DLP 정책을 적용하는 방법을 소개합니다. DLP(Data Loss Prevention, 데이터 유출 방지)는 정책을 위반한 요청이 Claude 모델 추론이나 툴 실행으로 이어지지 않게 막는 것을 뜻합니다. 판정 서버(AI security server)는 주민등록번호 같은 한국 고유 식별자와 사내 보안 표기를 자체 규칙으로 먼저 확인하고, 그 결과만으로 판정이 끝나지 않으면 Amazon Bedrock Guardrails로 개인정보, 거부 주제, 프롬프트 공격을 추가로 검사합니다.
Inference hooks는 Claude Enterprise 조직의 Claude 제품에서 발생하는 검사 대상 요청에 적용됩니다. 단, Claude Platform(API)과 Amazon Bedrock을 통한 Claude 호출은 대상이 아니며, Amazon Bedrock에서 Claude를 사용하는 애플리케이션에는 Amazon Bedrock Guardrails를 직접 적용할 수 있습니다. 적용 대상 제품과 기능 상태는 Claude의 기능 개선에 따라 바뀌고 있으므로, 최신 내용은 Anthropic의 Inference hooks 문서에서 확인합니다. 구성에는 조직의 Owner 또는 Primary owner 권한이 필요합니다.
검사 대상 요청의 내용은 이미 Claude 제품에 전달된 상태이며, 검사를 위해 고객 AWS 계정의 판정 서버와 Amazon Bedrock Guardrails로도 전송됩니다. 판정 서버의 리전, 네트워크 경로, 로그 보존 정책은 고객이 직접 정합니다. 도입할 때는 Claude 제품의 데이터 처리 및 보존 정책과 함께 이 검사 경로의 처리 위치도 검토해야 합니다. Guardrails의 cross-Region 평가를 사용하면 평가가 호출한 리전이 아닌 다른 리전에서 이루어질 수 있습니다.
Inference hooks 연동 방식과 판정 서버 요구 사항
검사 대상 요청은 다음 순서로 처리됩니다.
- 사용자가 Claude에 프롬프트를 보냅니다.
- Anthropic이 조직이 등록한 HTTPS 엔드포인트로
POST요청을 보냅니다. 본문에는 현재 시점까지의 대화 전체(사용자 메시지, Claude 응답, 툴 호출과 결과, 첨부 파일의 추출 텍스트)가 들어 있습니다. 시스템 프롬프트나 원본 파일 바이트는 포함되지 않습니다. - 판정 서버는 설정된 시간(기본 5초) 안에 HTTP 200과 함께 판정을 반환합니다.
- Validate tool calls가 켜져 있고 Claude의 응답에 툴 호출이 있으면, 툴을 실행하기 전에 같은 엔드포인트로 툴 호출 요청을 한 번 더 보냅니다. 이 요청에는 Claude가 방금 만든 응답만 들어 있으며, 판정 하나가 그 응답의 모든 툴 호출에 적용됩니다.
허용 응답:
{ "action": "allow" }
거부 응답:
{
"action": "deny",
"deny_reason": "카드번호로 보이는 내용이 포함되어 있습니다. 해당 내용을 제거한 뒤 다시 시도해 주세요.",
"reference_id": "vd_example01"
}
deny_reason은 사용자 화면에 표시되고(최대 500자), reference_id는 감사 기록(Activity Feed)의 거부 기록에 남아 고객 시스템의 로그와 연결하는 데 쓰입니다.

그림 1. 판정 서버가 거부했을 때의 사용자 화면. 왼쪽은 claude.ai에 PEM 개인키를 입력한 경우이고, 오른쪽은 모바일 앱에서 고객 6명의 휴대폰 번호와 이메일을 정리해 달라고 요청한 경우입니다. 오른쪽은 High 등급 기준을 5건으로 설정한 예로, 개인정보 12건을 대량 개인정보로 판단해 거부했습니다. 한국어 차단 사유는 판정 서버가 반환한 deny_reason이고, 그 아래 안내는 조직 설정의 Custom blocked prompt message입니다. 설정하지 않으면 기본 문구가 표시됩니다.
판정 서버를 운영할 때 알아야 할 규칙도 있습니다.
- 모든 요청은 Standard Webhooks 규격으로 서명됩니다. 판정 서버는 서명을 검증해 Anthropic이 보낸 요청인지 확인해야 합니다.
- claude.ai의 조직 설정(Organization settings > Data and privacy > Inference hooks)에서 Custom request headers에 정적 헤더를 등록하면 검사 요청에 함께 전송됩니다. 이 글에서는
X-Hook-Token헤더를 진입점의 사전 필터로 사용합니다. - 판정 서버가 시간 안에 응답하지 못하거나 200이 아닌 응답을 주면 webhook failure로 처리되고, 조직 설정의 Failure handling(Block the request 또는 Allow the request)이 적용됩니다. 연결 시도 자체가 실패하면 같은
webhook-id로 한 번 재시도합니다. - 판정 서버 쪽 실패가 계속되면 Anthropic 측 서킷 브레이커가 열려 서버 호출을 멈추고, 검사 대상 요청 모두에 Failure handling 설정을 적용합니다. 자동 복구 확인은 브레이커가 열린 지 10분 뒤부터 시작됩니다. 어떤 오류가 브레이커에 집계되는지는 Anthropic의 설정 문서에서 확인합니다.
- 본문은 잘리지 않고 전송됩니다. 일반적으로 10MiB 이하이지만 프로토콜상 최대 64MiB까지 올 수 있습니다.
솔루션 아키텍처
판정 서버는 Application Load Balancer로 요청을 받고 Amazon ECS on AWS Fargate에서 검사 로직을 실행합니다. 검사 로직은 자체 규칙과 Amazon Bedrock Guardrails의 ApplyGuardrail API로 이루어지며, 자세한 판정 방식은 2단계 판정 장에서 설명합니다. 판정 서버는 인터넷 경로가 없는 격리 서브넷에서 실행되며, Amazon Bedrock 등에는 AWS PrivateLink 기반 인터페이스 엔드포인트로, Amazon S3와 Amazon DynamoDB에는 게이트웨이 엔드포인트로 접근합니다. 세션 캐시는 Amazon DynamoDB에 두고, 판정 기록은 Amazon CloudWatch Logs와 Amazon SQS에 남기며, 엔드포인트 도메인은 Amazon Route 53에서 관리합니다.

그림 2. 이 글에서 제안하는 주 아키텍처입니다. 퍼블릭 서브넷에는 ALB만 두고, 판정 서버와 인터페이스 엔드포인트는 인터넷 경로가 없는 격리 서브넷에서 실행합니다. ALB 보안 그룹은 Anthropic 이그레스 대역만 허용합니다. 관리형 서비스는 VPC 밖에 있으며 엔드포인트를 통해 접근합니다. 점선으로 표시한 AWS WAF는 필요할 때 ALB에 연결하는 선택 요소입니다.
| 구성 요소 | 역할 |
|---|---|
| Application Load Balancer (internet-facing) | 하나뿐인 공개 진입점입니다. 보안 그룹으로 Anthropic 이그레스 대역의 연결만 받고, 리스너 규칙으로 토큰 헤더를 확인하며, TLS를 종료합니다. |
| Amazon Route 53 | 엔드포인트 도메인을 제공합니다. 대기 리전이 있으면 failover 레코드로 전환합니다. |
| Amazon ECS on AWS Fargate | 판정 서버(verdict-service)를 두 개 이상의 가용 영역에서 실행합니다. |
| Amazon Bedrock Guardrails | 생성 모델을 호출하지 않고 ApplyGuardrail API로 PII(Personally Identifiable Information, 개인 식별 정보), 거부 주제, 프롬프트 공격을 탐지합니다. |
| AWS PrivateLink, Gateway Endpoint | NAT 없이 AWS 서비스에 접근합니다. Bedrock, Secrets Manager, SQS 등은 인터페이스 엔드포인트로, S3와 DynamoDB는 게이트웨이 엔드포인트로 접근합니다. |
| Amazon DynamoDB, Amazon SQS | 증분 검사용 세션 캐시와 판정 메타데이터 기록을 담당합니다. |
컴퓨팅으로 Fargate를 고른 이유
처음에는 Amazon API Gateway와 AWS Lambda 조합을 검토했지만 제외했습니다. 요청 본문이 최대 64MiB까지 올 수 있는데, API Gateway와 Lambda의 요청 페이로드 한도는 이보다 작습니다. 본문을 거부하면 그 자체가 webhook failure가 되고, 실패 정책이 “검사 없이 통과”라면 큰 요청이 검사 없이 모델로 넘어갈 수 있습니다. 이 예제는 ALB의 IP 타깃으로 Fargate 태스크를 연결하고, 애플리케이션의 본문 수신 한도를 64MiB로 설정했습니다.
리전 선택
이 검증 환경은 Anthropic에서 판정 서버까지의 왕복 시간을 고려해 us-east-1에 배포했습니다. 리전을 고를 때는 사용자 위치보다 이 전체 왕복 시간을 기준으로 삼아야 합니다. 기본 5초 예산에는 연결 수립, TLS 핸드셰이크, 본문 전송, 검사, 응답 수신이 모두 포함됩니다. 조직 설정의 Test connection 결과에는 연결 수립을 포함한 왕복 시간이 표시되므로 후보 리전을 비교하는 출발점으로 쓸 수 있습니다. 다만 시험 요청 하나의 결과이므로, 긴 대화와 첨부 파일을 포함한 요청으로도 비교해야 합니다.
이 구성의 미국 Guardrail profile은 평가를 us-east-1, us-east-2, us-west-2로 라우팅할 수 있습니다. 다른 리전에 배포한다면 왕복 시간과 함께 조직의 데이터 처리 요건, Anthropic 제품의 데이터 처리 및 저장 위치, 리전별 Guardrails 처리량 쿼터를 검토하고, cross-Region 평가의 라우팅 대상 리전은 AWS의 지원 리전 목록에서 확인합니다.
진입점을 internet-facing ALB 하나로 둔 이유
Inference hooks 엔드포인트는 인터넷에서 접근 가능한 HTTPS 443 포트여야 합니다. 일반적인 웹 서비스라면 CloudFront 같은 엣지 계층을 앞에 두는 것을 먼저 고려합니다. 하지만 이 엔드포인트는 고정 IP 대역을 쓰는 Anthropic만 호출합니다. 불특정 다수가 접속하는 서비스가 아니어서 엣지 캐시나 지리적 분산의 이점이 크지 않습니다.
ALB를 직접 노출하면 보안 그룹이 Anthropic 대역 밖에서 오는 TCP 연결을 받지 않으므로, 다른 출발지는 TLS 핸드셰이크까지도 가지 못합니다. Route 53 failover 레코드는 DNS 수준에서 동작하므로 POST 요청에도 리전 페일오버를 적용할 수 있습니다(리전 장애 대응 참고). 엣지 계층이 없으므로 오리진 우회를 막는 헤더나 us-east-1에 고정 배포하는 별도 스택도 필요하지 않습니다.
퍼블릭 서브넷에는 ALB만 두고, 인터넷 게이트웨이로 향하는 라우팅도 이 서브넷에만 둡니다. 판정 서버와 인터페이스 엔드포인트는 인터넷 경로가 없는 격리 서브넷에 있습니다. 조직 정책상 internet-facing 리소스를 둘 수 없거나 엣지 보호가 필요하다면 대안 구성을 참고합니다.
Bedrock은 PrivateLink로만 호출
판정 서버의 VPC에는 NAT 게이트웨이가 없습니다. Bedrock, Secrets Manager, ECR, CloudWatch Logs, SQS는 인터페이스 엔드포인트로, S3와 DynamoDB는 게이트웨이 엔드포인트로 접근합니다. Bedrock 런타임 엔드포인트에는 엔드포인트 정책을 걸어, 이 경로로는 이 예제의 Guardrail에 대한 ApplyGuardrail만 통과하게 했습니다.
판정 서버에 닿기까지의 다층 방어
판정 서버는 인터넷에 공개된 엔드포인트이고, 이 서버의 응답에 따라 조직의 AI 사용이 허용되거나 막힙니다. 그래서 요청이 판정 로직에 닿기 전에 여러 계층에서 출발지와 진위를 확인하도록 했습니다.
위협 모델
| 위협 | 예시 | 막는 계층 |
|---|---|---|
| 위조 요청 | 공격자가 임의의 대화를 보내 판정 동작을 탐색하거나 서버 자원을 소모시킴 | 보안 그룹, 토큰 헤더, 서명 검증 |
| 다른 조직의 요청 | 다른 Claude Enterprise 조직이 실수 또는 의도적으로 이 판정 서버의 URL을 자신의 Hook 엔드포인트로 등록 | 토큰 헤더, 서명 검증 |
| 재전송 | 가로챈 정상 요청을 다시 보냄 | 서명의 타임스탬프 확인. 5분 이내 재전송은 막지 못함 |
| 내부 경로 우회 | VPC 안의 다른 리소스가 판정 서버로 직접 요청 | 보안 그룹 체인 |
| 대화 내용 노출 | 판정 로그, 네트워크 경로에서 대화 원문 유출 | TLS(ALB까지), PrivateLink, 로그 최소화 |
설계에 가장 큰 영향을 준 것은 두 번째 위협입니다. 다른 조직의 Hook 요청도 같은 Anthropic 이그레스 대역에서 출발하고, 이 대역은 Inference hooks 외의 Anthropic 아웃바운드 트래픽도 함께 씁니다. 따라서 IP 필터는 노출 범위를 줄일 뿐, 자기 조직의 Hook 요청인지는 알려 주지 않습니다. 이를 구분할 수 있는 것은 조직마다 따로 발급되는 서명 시크릿뿐입니다.
보안 그룹으로 연결 제한
ALB 보안 그룹은 Anthropic이 공개한 이그레스 대역 160.79.106.0/24에서 오는 443 포트 연결만 허용합니다. 판정 서버 태스크의 보안 그룹은 ALB 보안 그룹을 출발지로 참조해 8000 포트만 허용하고, 인터페이스 엔드포인트는 VPC 안에서 오는 443 포트만 받습니다.
# aws_security_group은 egress 블록이 없으면 AWS 기본 아웃바운드 허용 규칙을 제거합니다.
resource "aws_security_group" "alb" {
name_prefix = "ih-alb-"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "alb_from_anthropic" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "160.79.106.0/24"
ip_protocol = "tcp"
from_port = 443
to_port = 443
description = "Anthropic Inference hooks egress"
}
resource "aws_security_group" "service" {
name_prefix = "ih-service-"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "service_from_alb" {
security_group_id = aws_security_group.service.id
referenced_security_group_id = aws_security_group.alb.id
ip_protocol = "tcp"
from_port = 8000
to_port = 8000
}
resource "aws_vpc_security_group_egress_rule" "alb_to_service" {
security_group_id = aws_security_group.alb.id
referenced_security_group_id = aws_security_group.service.id
ip_protocol = "tcp"
from_port = 8000
to_port = 8000
}
| 보안 그룹 | 인바운드 | 아웃바운드 |
|---|---|---|
| ALB | 160.79.106.0/24에서 tcp/443 |
판정 서버 보안 그룹으로 tcp/8000 |
| 판정 서버 | ALB 보안 그룹에서 tcp/8000 | 엔드포인트 보안 그룹으로 tcp/443, S3 및 DynamoDB 게이트웨이 엔드포인트 |
| 인터페이스 엔드포인트 | VPC CIDR에서 tcp/443 | 없음 |
판정 서버 태스크의 아웃바운드에는 게이트웨이 엔드포인트용 443 규칙이 있지만, 격리 서브넷에는 인터넷 경로가 없어서 실제로 나갈 수 있는 곳은 VPC 엔드포인트뿐입니다. 검증 환경의 CDK 코드는 스택 간 순환 참조를 피하려고 인터페이스 엔드포인트의 인바운드를 VPC CIDR로 두었습니다. VPC에 다른 워크로드를 함께 둔다면 판정 서버 보안 그룹을 참조하도록 좁혀야 합니다.
Anthropic의 이그레스 대역이 바뀌면 모든 요청이 연결 단계에서 거부됩니다. 대역 값은 구성으로 빼 두고, Anthropic 문서의 변경 공지를 확인하는 절차를 운영 런북에 넣어 둡니다.
ALB 리스너 규칙으로 토큰 헤더 확인
HTTPS 리스너는 기본으로 403을 고정 응답하고, 조직 설정의 Custom request headers에 등록한 X-Hook-Token 값이 일치하는 요청만 판정 서버로 전달합니다.
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.hook.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate_validation.hook.certificate_arn
default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
message_body = "forbidden"
status_code = "403"
}
}
}
resource "aws_lb_listener_rule" "hook_token" {
listener_arn = aws_lb_listener.https.arn
priority = 10
condition {
http_header {
http_header_name = "X-Hook-Token"
# 값을 두 개 등록하면 OR로 평가되므로 토큰을 무중단으로 교체할 수 있습니다.
values = compact([var.current_hook_token, var.previous_hook_token])
}
}
action {
type = "forward"
target_group_arn = aws_lb_target_group.verdict.arn
}
}
토큰 헤더는 요청마다 같은 값이 실리는 정적 비밀이라 한 번 노출되면 누구나 재사용할 수 있습니다. 따라서 토큰 헤더는 인증 수단이라기보다 사전 필터에 가깝습니다. 다른 조직의 Hook 요청이나 잘못 등록된 요청을 판정 서버의 자원을 쓰기 전에 차단하는 역할입니다. ALB 리스너 규칙은 헤더 값을 대소문자 구분 없이 비교하고 *, ?를 와일드카드로 해석하므로, 토큰은 와일드카드 문자가 없는 충분히 긴 소문자 hex 값으로 생성합니다. 토큰을 바꿀 때는 새 값을 리스너 규칙에 먼저 추가하고, 조직 설정의 값을 바꾼 다음, 이전 값을 지웁니다.
웹훅 서명 검증
실제 인증은 판정 서버가 Standard Webhooks 서명을 검증할 때 이루어집니다. Anthropic은 webhook-id, webhook-timestamp, 요청 본문을 이어 붙인 값에 조직별 시크릿으로 HMAC-SHA256 서명을 만들어 webhook-signature 헤더에 담아 보냅니다.
export function verifySignature(secrets: string[], headers: IncomingHttpHeaders, body: Buffer, now = Date.now()): boolean {
const messageId = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (typeof messageId !== 'string' || typeof timestamp !== 'string' || typeof signatures !== 'string') return false;
const signedAt = Number(timestamp);
if (!Number.isFinite(signedAt) || Math.abs(now / 1000 - signedAt) > 300) return false; // 재전송 또는 시계 불일치
const payload = Buffer.concat([Buffer.from(`${messageId}.${timestamp}.`), body]);
const candidates = signatures.split(' ').filter(Boolean).map((c) => Buffer.from(c));
for (const secret of secrets) { // [current, previous]
// whsec_ 뒤의 값을 프로토콜에 맞게 표준 base64로 디코딩합니다.
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
if (key.length === 0) continue;
const expected = Buffer.from('v1,' + createHmac('sha256', key).update(payload).digest('base64'));
for (const candidate of candidates) {
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) return true;
}
}
return false;
}
서명은 수신한 원문 바이트로 검증하고, JSON 파싱은 그 뒤에 합니다. 파싱한 객체를 다시 직렬화하면 공백이나 키 순서가 달라져 서명이 맞지 않기 때문입니다. 비교에는 timingSafeEqual을 써서 응답 시간 차이로 서명을 추측하지 못하게 했고, 타임스탬프가 300초 넘게 차이 나는 요청은 재전송으로 보고 거부합니다.
Secrets Manager에는 current와 previous 두 시크릿을 저장하고, 판정 서버는 60초마다 다시 읽습니다. 조직 설정의 시크릿 교체(Rotate secret)는 즉시 전환되고 새 값은 교체할 때 한 번만 표시되므로, 새 시크릿을 Secrets Manager에 반영하고 판정 서버가 다시 읽기 전까지는 서명 검증 실패가 생길 수 있습니다. previous는 교체 직후 잠시 도착하는 이전 시크릿의 서명 요청을 받기 위한 값입니다. 교체 직후 새 시크릿을 바로 반영하고 ECS 서비스를 다시 배포해 판정 서버가 새 값을 읽게 하는 절차를 런북에 넣어 둡니다. 서킷 브레이커가 열린 상태에서 시크릿을 교체하면 자동 복구 확인이 멈추므로, 이때는 판정 서버를 복구한 뒤 Enforce verdicts를 다시 켜야 합니다. 서명이 틀린 요청에는 401로 응답해 판정 결과인 200 deny와 구분하고, 판정 기록에도 섞이지 않게 했습니다. 서명 검사를 끄는 ALLOW_UNSIGNED 옵션은 로컬 개발용이며 기본값은 false입니다.
Anthropic은 연결 시도가 실패하면 같은 webhook-id와 서명으로 한 번 재시도합니다. 이 구현은 webhook-id의 중복을 따로 확인하지 않고 같은 요청을 다시 검사해 HTTP 200으로 판정을 반환하므로, 재시도가 webhook failure로 이어지지 않습니다. 중복 요청을 거부(non-200)하도록 구현하면 오히려 정상 재시도가 webhook failure가 됩니다. 서명 허용 범위인 5분 안에 같은 요청이 다시 오면 Guardrails를 다시 호출하므로, 호출 비용을 줄이려면 webhook-id와 본문 해시를 키로 판정 결과를 저장해 두고 재사용할 수 있습니다.
시크릿과 데이터 보호
-
- 서명 시크릿은 Secrets Manager에 저장하고 판정 서버 태스크 역할에만 읽기 권한을 줍니다. 판정 서버는 인터페이스 엔드포인트로 시크릿을 조회합니다.
- 토큰 헤더 값은 ALB 리스너 규칙에 평문으로 들어가므로 CloudFormation 템플릿에도 그대로 남습니다. 리스너 규칙이 값을 직접 비교하는 구조라 피하기 어렵습니다. Terraform을 쓰면 변수에
sensitive = true를 지정해도 값이 상태 파일에 평문으로 저장됩니다. CloudFormation 템플릿, CDK 출력물, Terraform 상태 파일에 대한 접근을 제한하고, 값을 저장소에 커밋하지 않으며, 주기적으로 교체합니다. - 대화 원문은 판정 로그에 남기지 않습니다. 판정 결과, 탐지 타입과 건수, 지연 같은 메타데이터만 기록합니다.
- Bedrock 호출은 PrivateLink로만 나가며, 엔드포인트 정책이 이 예제의 Guardrail에 대한
ApplyGuardrail만 허용합니다.
계층별로 보장하는 것과 보장하지 않는 것
| 계층 | 보장하는 것 | 보장하지 않는 것 |
|---|---|---|
| 보안 그룹 | Anthropic 대역 외 출발지의 연결 차단 | 자기 조직의 요청인지 여부 |
| 토큰 헤더 | 자기 조직 설정에 등록한 토큰을 실은 요청만 판정 서버로 전달 | 토큰이 노출된 경우의 위조 방지 |
| 서명 검증 | 본문과 시각이 Anthropic이 자기 조직의 시크릿으로 서명한 그대로임 | 5분 안의 재전송 거부 |
| 보안 그룹 체인과 격리 서브넷 | ALB를 거치지 않는 판정 서버 접근 차단, 인터넷으로 나가는 경로 없음 | VPC 안에 함께 둔 다른 워크로드의 엔드포인트 접근 |
보안 그룹과 토큰 헤더는 비용과 공격 표면을 줄이는 장치이고, 인증은 서명 검증이 맡습니다. 진입점을 어떻게 구성하든 서명 검증은 반드시 수행해야 합니다.
CloudFront와 AWS WAF를 앞에 두는 대안 구성

그림 3. CloudFront와 AWS WAF를 앞에 두는 대안 구성입니다. 공개 진입점은 CloudFront이며, VPC origin으로 격리 서브넷의 internal ALB에 연결합니다. 이 글의 실측은 이 구성에서 진행했습니다.
이 구성은 CloudFront를 앞에 두고 VPC origin 기능으로 internal ALB에 연결합니다. CloudFront가 관리하는 네트워크 인터페이스가 고객 VPC 안에 생성되므로 ALB는 인터넷에 노출되지 않습니다. 조직 정책상 internet-facing 로드 밸런서를 사용할 수 없거나, 엣지에서 AWS 관리형 규칙과 요청 속도 기반 규칙을 적용하고 AWS Shield Advanced와 연동해야 한다면 이 구성을 검토할 수 있습니다. Shield Standard는 ALB에도 기본으로 적용됩니다.
이 구성에서는 접근을 세 단계로 통제합니다.
- CLOUDFRONT 스코프의 AWS WAF는 기본 동작을 차단으로 두고, Anthropic 이그레스 대역에서 오고 동시에
X-Hook-Token이 일치할 때만 허용합니다. VPC origin 구성에서 ALB가 보는 출발지 주소는 CloudFront 네트워크 인터페이스의 사설 IP라서 보안 그룹으로는 Anthropic 대역을 걸러 낼 수 없습니다. 그래서 출발지 확인을 WAF가 맡습니다. - ALB 리스너 규칙은 CloudFront가 붙이는
X-Origin-Verify헤더가 없으면 403을 고정 응답합니다. VPC 안에서 ALB에 직접 접근하는 경로를 차단합니다. - 요청 서명 검증은 주 아키텍처와 같습니다.
resource "aws_cloudfront_vpc_origin" "alb" {
vpc_origin_endpoint_config {
name = "ih-internal-alb"
arn = aws_lb.internal.arn
http_port = 80
https_port = 443
origin_protocol_policy = "http-only"
origin_ssl_protocols {
items = ["TLSv1.2"]
quantity = 1
}
}
}
data "aws_cloudfront_cache_policy" "disabled" { name = "Managed-CachingDisabled" }
data "aws_cloudfront_origin_request_policy" "all_viewer" { name = "Managed-AllViewer" }
resource "aws_cloudfront_distribution" "hook" {
enabled = true
web_acl_id = aws_wafv2_web_acl.hook.arn # CLOUDFRONT 스코프, us-east-1
origin {
origin_id = "internal-alb"
domain_name = aws_lb.internal.dns_name
connection_attempts = 2
connection_timeout = 3
vpc_origin_config {
vpc_origin_id = aws_cloudfront_vpc_origin.alb.id
}
custom_header {
name = "X-Origin-Verify" # ALB 리스너 규칙이 검사
value = var.origin_verify_token
}
}
default_cache_behavior {
target_origin_id = "internal-alb"
viewer_protocol_policy = "https-only"
allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = data.aws_cloudfront_cache_policy.disabled.id
origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer.id
compress = false
}
# aliases, viewer_certificate, restrictions 블록은 생략했습니다.
}
CloudFront는 캐시를 끄고 모든 요청을 판정 서버로 전달합니다. compress는 응답 압축 설정입니다. Anthropic은 압축되지 않은 응답만 읽으므로(최대 64KiB) 판정 응답은 압축하지 않았습니다. 요청 서명 검증과는 관계없습니다.
이 예제는 CloudFront에서 internal ALB까지를 HTTP(포트 80)로 연결했습니다. VPC origin을 통한 사설 경로이지만 평문 구간이므로, 전송 중 암호화가 필요하면 오리진 프로토콜을 HTTPS로 바꾸고 internal ALB에 인증서를 구성합니다.
구축하면서 알게 된 점도 정리해 둡니다.
- CloudFront 오리진 그룹의 자동 페일오버는 GET, HEAD, OPTIONS에만 적용되고 POST에는 적용되지 않습니다. 검증 환경에서는 대기 리전의 VPC origin을 미리 만들어 두고, 장애 시 활성 리전을 바꿔 CDN 스택을 재배포하도록 설계했습니다. 전환에는 수 분이 걸립니다.
- CloudFront 인증서와 CLOUDFRONT 스코프 WAF는 us-east-1에 만들어야 하므로, 판정 서버를 다른 리전에 두더라도 진입점 스택은 us-east-1에 남습니다.
- WAF는 허용(Allow) 규칙에 일치하면 평가를 멈추므로, 요청 속도 기반 차단 규칙은 허용 규칙보다 앞에 두어야 합니다.
- WAF 요청 샘플링은 껐습니다. 샘플에 요청 헤더가 담겨 토큰 값이 AWS WAF 콘솔에 보일 수 있기 때문입니다. WAF 요청 로깅을 켠다면 로그에서도 이 헤더를 마스킹해야 합니다.
| 관점 | internet-facing ALB (주 아키텍처) | CloudFront + WAF (대안) |
|---|---|---|
| 출발지 확인 | 보안 그룹 (L4, 연결 단계) | WAF IP set (L7, TLS 종료 후) |
| 토큰 헤더 확인 | ALB 리스너 규칙 | WAF 문자열 일치 규칙 |
| 오리진 우회 방지 | 불필요 | X-Origin-Verify 헤더 |
| 리전 페일오버 | Route 53 failover 레코드 (자동) | 활성 리전 변경 후 재배포 (수 분) |
| 엣지 기능 | 없음. 필요하면 리전 WAF를 ALB에 연결 | 관리형 규칙, 속도 기반 규칙, 엣지 DDoS 흡수 |
| 인터넷 노출 리소스 | ALB (퍼블릭 서브넷) | CloudFront. ALB는 internal |
| 배포 위치 제약 | 판정 서버와 같은 리전 | 진입점 스택은 us-east-1 고정 |
비용 차이는 크지 않습니다. 작성 시점의 공개 요금(북미 기준, 프리 티어 미반영)으로 월 100만 건, 평균 본문 100KB를 가정하면 대안 구성에는 월 약 $11이 더해집니다. CloudFront 요청 요금 약 $1(HTTPS 1만 건당 $0.01), 오리진으로 전송되는 요청 본문 약 100GB의 데이터 전송 요금 약 $2(GB당 $0.02), WAF 요금 약 $8(Web ACL당 월 $5, 규칙 2개 기준 월 $2, 요청 100만 건당 $0.60)입니다. 주 아키텍처에는 ALB의 퍼블릭 IPv4 주소 요금(주소당 시간당 $0.005, 가용 영역 2개 기준 월 약 $7)이 추가됩니다. 진입점은 비용보다 조직 정책과 운영 요건을 보고 고르는 것이 좋습니다.
Bedrock Guardrails와 자체 규칙을 묶은 2단계 판정
Bedrock Guardrails의 ApplyGuardrail API는 기반 모델을 호출하지 않고 텍스트만 평가합니다. Guardrails에 정의한 PII 탐지, 거부 주제, 콘텐츠 필터를 그대로 쓸 수 있어 외부 DLP 판정 엔진으로 활용하기에 적합합니다. 민감정보 필터는 한국어에 최적화되어 있고(Optimized and supported), Standard tier의 거부 주제와 프롬프트 공격 탐지도 한국어를 지원합니다.
그러나 Guardrails만으로는 국내 DLP 요구 사항을 모두 충족하기 어려웠습니다.
- 내장 PII 타입에 주민등록번호, 사업자등록번호 같은 한국 고유 식별자가 없습니다. 이 예제는 이런 식별자를 자체 규칙으로 검사하고, 사업자등록번호와 법인등록번호는 체크섬까지 확인합니다.
- 단어 필터의 공식 지원 언어는 영어, 프랑스어, 스페인어입니다. 한국어 보안 표기는 공식 지원 범위에 포함되지 않으므로 “대외비” 같은 키워드를 자체 규칙으로 검사했습니다.
이 두 가지를 보완하려고 판정을 두 단계로 나눴습니다.
| 단계 | 담당 | 탐지 대상 | 특징 |
|---|---|---|---|
| Stage-1 | 판정 서버 코드 | 주민등록번호 형식, 사업자등록번호와 법인등록번호 체크섬, 여권번호, 운전면허번호, 휴대폰 번호, AWS나 GitHub 같은 키 패턴, PEM 개인키, 보안 표기 키워드 | 이 결과만으로 거부 조건을 넘으면 Guardrails를 호출하지 않고 거부 |
| Stage-2 | Bedrock Guardrails | 내장 PII, 보조 정규식, 프롬프트 공격(Standard tier), 사내 거부 주제(Standard tier) | 문맥 기반 탐지. 한국어 PII와 의미 기반 주제 판단 |
요청 처리 흐름
서명 검증과 증분 검사 범위 계산이 끝나면 핸들러는 두 단계를 차례로 실행하고, 두 결과를 합쳐 판정합니다. 아래는 판정 서버의 요청 처리 코드에서 해당 부분만 옮긴 것입니다.
// 새로 검사할 메시지에서 사용자 텍스트, 툴 호출과 결과, 첨부 추출 텍스트를 꺼냅니다.
const units = flatten(messages, session.startIndex, config.scanAssistantText);
// Stage-1: 자체 규칙
const rules = runRules(units, { keywordsDeny: config.keywordsDeny, keywordsFlag: config.keywordsFlag });
deadline.check();
// Stage-1 결과만으로 거부가 확정되면 Guardrails를 호출하지 않습니다.
const stage1 = decide(webhookId, rules, skipped());
const decided = stage1.verdict.action === 'deny';
const gr = decided || units.length === 0
? skipped()
: await deadline.wait(applyGuardrail(units, deadline.expiresAt, deadline.signal));
// 확정되지 않았으면 두 단계의 탐지 결과를 합쳐 판정합니다.
const decision = decided ? stage1 : decide(webhookId, rules, gr);
send(res, 200, decision.verdict);
Guardrails 결과는 거부 사유를 더할 수만 있고 거부를 허용으로 바꾸지는 못합니다. 따라서 Stage-1 결과만으로 거부 조건을 넘으면 Stage-2를 건너뛰어 Guardrails 호출 비용과 지연을 줄입니다. Critical 등급이 하나라도 있거나, 현재 설정처럼 High 기준이 1건일 때 휴대폰 번호가 1건만 나와도 Stage-1에서 판정이 끝납니다. Stage-2는 Stage-1 결과가 기준에 못 미칠 때만 실행되며, 이때 두 단계의 건수를 합쳐 판단합니다.
Stage-1: 한국 고유 식별자와 사내 표기 검사
자체 규칙은 정규식으로 식별자를 찾고, 체크섬이 있는 번호는 검증 함수로 한 번 더 확인합니다.
const KR_RULES: Rule[] = [
// 주민등록번호는 형식(6자리, 하이픈 생략 가능, 7자리)만 확인합니다.
{ type: 'KR_RRN', severity: 'critical', re: /(?<!\d)(\d{6})-?([1-8]\d{6})(?!\d)/g },
{ type: 'KR_BIZ_NO', severity: 'high', re: /(?<!\d)\d{3}-\d{2}-\d{5}(?!\d)/g,
validate: (m) => isValidBizNo(m.replace(/-/g, '')) },
{ type: 'KR_MOBILE', severity: 'high', re: /(?<!\d)01[016789]-?\d{3,4}-?\d{4}(?!\d)/g },
// 법인등록번호, 여권번호, 운전면허번호는 생략했습니다.
];
Stage-2: Guardrail 정의와 결과 해석
Guardrails의 PII 액션은 타입별로 다르게 지정했습니다. 이 예제에서는 카드번호, 비밀번호, 액세스 키 등을 1건만 탐지해도 차단하도록 BLOCK으로 설정하고, 탐지만 수행할 타입은 NONE으로 두어 판정 서버가 타입과 건수로 허용 여부를 결정하게 했습니다. 사용자 정의 정규식은 이름 앞에 CRITICAL_, HIGH_ 같은 심각도를 붙여, 판정 서버가 이름만 보고 등급을 알 수 있게 했습니다.
locals {
pii_block = ["AWS_ACCESS_KEY", "AWS_SECRET_KEY", "PASSWORD", "CREDIT_DEBIT_CARD_NUMBER", "CREDIT_DEBIT_CARD_CVV", "PIN"]
pii_detect = ["PHONE", "EMAIL", "ADDRESS", "NAME", "AGE", "USERNAME"]
}
resource "aws_bedrock_guardrail" "dlp" {
name = "inference-hooks-dlp"
blocked_input_messaging = "Blocked by organization data policy."
blocked_outputs_messaging = "Blocked by organization data policy."
kms_key_arn = aws_kms_key.guardrail.arn
sensitive_information_policy_config {
dynamic "pii_entities_config" {
for_each = local.pii_block
content {
type = pii_entities_config.value
action = "BLOCK"
}
}
dynamic "pii_entities_config" {
for_each = local.pii_detect
content {
type = pii_entities_config.value
action = "NONE"
}
}
regexes_config {
name = "CRITICAL_KR_RRN"
pattern = "\\d{6}-?[1-8]\\d{6}"
action = "BLOCK"
}
regexes_config {
name = "HIGH_KR_MOBILE"
pattern = "01[016789]-?\\d{3,4}-?\\d{4}"
action = "NONE"
}
}
content_policy_config {
filters_config {
type = "PROMPT_ATTACK"
input_strength = "HIGH"
output_strength = "NONE"
}
}
# Standard tier, cross-Region profile, 거부 주제 설정은 생략했습니다.
}
판정 서버는 긴 텍스트를 나눠 병렬로 호출하고, 응답의 평가 결과를 Stage-1과 같은 형태의 탐지 목록으로 바꿉니다.
for (const a of res.assessments ?? []) {
for (const e of a.sensitiveInformationPolicy?.piiEntities ?? []) {
if (e.type) add(e.type, PII_SEVERITY[e.type] ?? 'medium');
if (e.action === 'BLOCKED') intervened = true;
}
for (const r of a.sensitiveInformationPolicy?.regexes ?? []) {
if (r.name) add(r.name, regexSeverity(r.name)); // 이름의 접두어로 등급을 정합니다.
if (r.action === 'BLOCKED') intervened = true;
}
for (const t of a.topicPolicy?.topics ?? []) {
if (t.name) add(`TOPIC:${t.name}`, 'critical');
if (t.action === 'BLOCKED') intervened = true;
}
for (const f of a.contentPolicy?.filters ?? []) {
if (f.type === 'PROMPT_ATTACK') add('PROMPT_ATTACK', f.confidence === 'HIGH' ? 'critical' : 'high');
if (f.action === 'BLOCKED') intervened = true;
}
}
Guardrails가 BLOCK한 항목은 intervened로 따로 표시해 판정 단계에서 바로 거부합니다. 이렇게 하면 Guardrails의 고정 차단 메시지 대신, 무엇을 지워야 하는지 알려 주는 한국어 사유를 서버가 직접 만들 수 있습니다.
Guardrail 호출 권한
한국어 거부 주제와 프롬프트 공격 탐지는 Guardrails의 Standard tier에서 지원합니다. Standard tier는 cross-Region 평가를 사용하므로, us-east-1에서 호출해도 실제 평가는 미국 지역의 여러 리전에서 이루어집니다. 판정 서버의 태스크 역할에는 다음 권한이 필요합니다.
| 필요한 권한 | 이유 |
|---|---|
Guardrail 암호화 키에 대한 kms:Decrypt (kms:ViaService 조건으로 Bedrock 경유만 허용) |
Guardrail을 고객 관리형 KMS 키로 암호화하면, Bedrock은 호출자 권한으로 Guardrail 설정을 복호화합니다. 권한이 없으면 모든 호출이 실패합니다. |
목적지 리전 전체의 Guardrail profile ARN에 대한 bedrock:ApplyGuardrail (태스크 역할과 VPC 엔드포인트 정책 모두) |
평가는 us-east-1, us-east-2, us-west-2 중 하나로 라우팅됩니다. 출발 리전의 ARN만 허용하면 일부 요청만 간헐적으로 거부됩니다. |
data "aws_caller_identity" "current" {}
locals {
destinations = ["us-east-1", "us-east-2", "us-west-2"] # us.guardrail.v1:0
profile_arns = [for r in local.destinations :
"arn:aws:bedrock:${r}:${data.aws_caller_identity.current.account_id}:guardrail-profile/us.guardrail.v1:0"]
}
data "aws_iam_policy_document" "task_guardrail" {
statement {
actions = ["bedrock:ApplyGuardrail"]
resources = concat([aws_bedrock_guardrail.dlp.guardrail_arn], local.profile_arns)
condition {
test = "StringEquals"
variable = "aws:SourceVpce"
values = [aws_vpc_endpoint.bedrock_runtime.id]
}
}
statement {
actions = ["kms:Decrypt", "kms:DescribeKey"]
resources = [aws_kms_key.guardrail.arn]
condition {
test = "StringEquals"
variable = "kms:ViaService"
values = [for r in local.destinations : "bedrock.${r}.amazonaws.com"]
}
}
}
resource "aws_iam_role_policy" "task_guardrail" {
role = aws_iam_role.task.id
policy = data.aws_iam_policy_document.task_guardrail.json
}
탐지 결과 합치기와 최종 판정
Stage-2까지 진행하면 자체 규칙과 Guardrails가 같은 값을 함께 탐지할 수 있습니다. 판정 전에 타입 이름을 하나로 맞추고, 같은 타입은 가장 높은 등급과 탐지기별 건수 중 가장 큰 값만 남깁니다. 이 방식은 탐지기마다 서로 다른 값을 찾으면 실제보다 적게 셀 수 있습니다.
const CANONICAL: Record<string, string> = { PHONE: 'PHONE', KR_MOBILE: 'PHONE', /* ... */ };
export function canonicalType(type: string): string {
const stripped = type.replace(/^(CRITICAL|HIGH|MEDIUM|INFO)_/, ''); // 정규식 이름의 등급 접두어를 뗍니다.
return CANONICAL[stripped] ?? stripped;
}
최종 판정은 decide() 함수가 합니다. 사유를 만드는 부분은 줄였습니다.
export function decide(webhookId: string, rules: Finding[], gr: GuardrailResult): Decision {
const findings = mergeFindings([...rules, ...gr.findings]);
const reasons: string[] = [];
// Guardrails를 쓸 수 없으면 기본 정책은 거부이며, 이 결과는 캐시하지 않습니다.
if (gr.status === 'unavailable' && config.guardrailUnavailablePolicy === 'deny') {
reasons.push('보안 검사를 완료할 수 없어 정책상 차단되었습니다.');
return { verdict: deny(webhookId, reasons, '잠시 후 다시 시도해 주세요.'), reasons, findings, cacheable: false };
}
const critical = findings.filter((f) => f.severity === 'critical');
const high = findings.filter((f) => f.severity === 'high');
const medium = findings.filter((f) => f.severity === 'medium');
const highCount = high.reduce((s, f) => s + f.count, 0);
const mediumCount = medium.reduce((s, f) => s + f.count, 0);
if (gr.intervened || critical.length > 0) reasons.push(/* 예: "카드번호로 보이는 내용이 포함되어 있습니다." */);
if (highCount >= config.highCountThreshold) reasons.push(/* 예: "전화번호로 보이는 개인정보 1건이 포함되어 있습니다." */);
if (medium.length >= config.mediumDistinctTypes && mediumCount >= config.mediumCountThreshold) {
reasons.push(/* 식별 가능한 조합이라는 사유 */);
}
// rules-only 대체 경로로 허용한 결과도 Guardrails가 복구되면 다시 검사하도록 캐시하지 않습니다.
const cacheable = gr.status !== 'unavailable';
if (reasons.length === 0) return { verdict: { action: 'allow' }, reasons, findings, cacheable };
return { verdict: deny(webhookId, reasons), reasons, findings, cacheable };
}
등급별 기준을 정리하면 다음과 같습니다.
| 등급 | 거부 조건 | 해당 타입 예시 |
|---|---|---|
| Critical | 1건이라도 탐지되거나 Guardrails가 BLOCK |
주민등록번호, 카드번호, 액세스 키, 개인키, 거부 주제, 신뢰도 높은 프롬프트 공격 |
| High | 건수 합계가 HIGH_COUNT_THRESHOLD 이상 |
전화번호, 이메일, 주소, 사업자등록번호 |
| Medium | 서로 다른 타입이 MEDIUM_DISTINCT_TYPES(3) 이상이고 건수 합계가 MEDIUM_COUNT_THRESHOLD(3) 이상 |
이름, 나이, 계정명 |
High 등급의 기준 건수는 조직 정책에 따라 정합니다. 기준을 낮추면 업무 문장에 흔한 이메일이나 회사 주소도 막히므로, Shadow mode에서 차단율을 확인한 뒤 정하는 것이 좋습니다.
매 턴 다시 오는 대화 전체와 증분 검사
Inference hooks는 매 턴 대화 전체를 다시 보냅니다. 실제 claude.ai에서 합성 데이터로 5턴짜리 대화를 진행하고, 검증 환경(대안 구성)의 판정 서버가 받아 서명 검증까지 마친 JSON을 모아 확인했습니다. 모두 Anthropic이 실제로 보낸 Hook 요청입니다.
대화는 가상의 정원 이름 제안, 비교 표, 소개 문구, 사계절 풍경, 요약 순으로 진행했습니다. 첨부 파일과 툴 호출은 사용하지 않았습니다. 조건은 claude-sonnet-5, us-east-1, 서버 enforce, High 기준 1건, Guardrail 버전 1이며, 한 대화에서 요청을 순차적으로 보냈습니다.
| 턴 | 실제 수신 본문(bytes) | messages 수 |
scannedFrom |
판정 |
|---|---|---|---|---|
| 1 | 736 | 1 | 0 | allow |
| 2 | 1,697 | 3 | 1 | allow |
| 3 | 2,360 | 5 | 3 | allow |
| 4 | 3,211 | 7 | 5 | allow |
| 5 | 4,399 | 9 | 7 | allow |
scannedFrom은 이번 검사를 시작한 메시지의 위치(0부터 셈)입니다. 2턴에는 이전 사용자 메시지, Claude 응답, 새 사용자 메시지까지 메시지 3개가 들어옵니다. 이후에는 두 개씩 늘어 5턴에 9개가 됩니다. 서버는 접두어 해시를 확인해 이전 메시지를 건너뜁니다. 판정 서버는 assistant의 일반 텍스트를 빼고 사용자 텍스트, 툴 호출과 결과, 첨부 파일의 추출 텍스트를 검사하므로, 이 대화에서는 새 사용자 텍스트만 정책 검사에 들어갑니다.
아래는 1턴에 실제로 받은 페이로드를 공개용으로 옮긴 것입니다. 계정, 사용자, 대화, 요청 식별자와 이메일만 예시 값으로 바꾸고 보기 좋게 정렬했으며, 대화 텍스트와 필드 구조는 그대로 두었습니다. 위 표의 바이트 수는 치환하고 정렬하기 전의 HTTP 본문 크기입니다.
{
"type": "prompt",
"tenant_id": "11111111-1111-4111-8111-111111111111",
"session_id": "22222222-2222-4222-8222-222222222222",
"request_id": "00000000-0000-4000-8000-000000000001",
"model": "claude-sonnet-5",
"actor": {
"type": "user",
"id": "33333333-3333-4333-8333-333333333333",
"email_address": "author@example.com"
},
"source": {
"application": "claude-ai"
},
"metadata": {},
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "BLOG-MEASUREMENT blog-20261005-b820cac236d0\n이 대화는 블로그용 합성 데이터 실측입니다. 실제 인물·회사 정보는 사용하지 않고 검색이나 도구도 사용하지 않습니다. 가상의 작은 정원에 어울리는 이름 세 개를 제안하고 각각 한 문장으로 설명해 주세요."
}
]
}
]
}
2턴부터는 이전 메시지가 그대로 남은 채 그 뒤에 Claude의 응답과 새 질문이 붙습니다.
이 대화에서 본문은 736바이트에서 4,399바이트로 약 6배 커졌습니다. 매번 전체 대화를 Guardrails로 보내면 이미 검사한 텍스트에도 비용과 처리량 쿼터가 다시 쓰이므로, 판정 서버는 새로 붙은 메시지만 검사합니다.
판정 서버는 세션별로 “검사를 마친 메시지 수”와 “그 메시지들의 해시”를 DynamoDB에 저장합니다. 다음 요청의 앞부분 해시가 같으면 새로 붙은 메시지만 검사합니다. 프롬프트 캐싱이 바뀌지 않은 접두어의 계산을 재사용하는 것과 같은 원리입니다.
예외가 두 가지 있습니다.
- 거부된 내용이 대화에 남아 있으면 계속 거부됩니다. claude.ai는 차단된 메시지를 대화에서 지우지 않습니다. 사용자가 새 메시지로 다시 시도하면 차단된 메시지도 함께 전송되므로, 서버는 이전 거부를 그대로 반복합니다. 사용자는 차단된 메시지를 편집하거나 새 대화를 시작해야 합니다.
- 이전 메시지를 편집하면 앞부분 해시가 달라지므로 캐시를 버리고 처음부터 다시 검사합니다. 편집으로 민감정보를 끼워 넣는 우회도 이 방식으로 막습니다.
사용자가 “지웠는데도 막힌다”고 문의하면 차단된 메시지나 첨부 파일이 전송 내용에 남아 있는지 먼저 확인합니다. 차단 후 대화를 이어 가는 방법은 앱마다 다르므로, 조직 설정의 Custom blocked prompt message에 Anthropic의 안내 문서를 연결해 둘 수 있습니다.
판정 정책이나 검사 코드가 바뀌면 캐시를 버리고 전체 대화를 다시 검사합니다.
장애 시 차단(fail-closed)을 서버 안에서 구현하기
DLP가 목적이라면 판정 서버를 쓸 수 없을 때 요청을 막는 장애 시 차단(fail-closed)을 선택할 수 있습니다. 이를 위해서는 조직 설정에서 Enforce verdicts를 켜고 Failure handling을 Block the request로 지정해야 합니다(새 구성의 기본값은 Allow the request). 의존성 장애 때문에 판정 서버가 5xx나 타임아웃을 내면 webhook failure가 쌓이고, 서킷 브레이커가 열리면 서버 호출이 멈춰 검사 대상 요청이 모두 차단됩니다. Block the request로 두더라도 Requests inspected (%) 샘플링에서 제외된 요청과 제외 역할의 요청은 검사 없이 진행됩니다.
이 예제는 서버가 처리할 수 있는 검사 실패를 HTTP 200과 거부 판정으로 반환합니다. Guardrails 오류, 호출 타임아웃, 핸들러에서 잡힌 예외가 여기에 해당합니다. 반면 DynamoDB 캐시 읽기가 실패하면 기존 검사를 생략하지 않고 전체 대화를 다시 검사합니다. 서명 실패는 401, 본문 크기 초과는 413, 지원하지 않는 메서드는 405로 응답합니다. 툴 호출 요청도 같은 방식으로 처리하므로, Guardrails 장애 시에는 툴 실행도 차단됩니다.
이렇게 하면 의존성 오류 일부가 webhook failure로 잡히는 것을 줄일 수 있지만, 네트워크 장애나 프로세스 종료까지 200 응답으로 바꿀 수는 없습니다. 판정 서버는 다음 두 가지도 처리합니다.
- 임시 거부와 정책 위반 거부를 나눴습니다. Guardrails 장애로 생긴 거부는 캐시하지 않습니다.
rules-only대체 경로로 허용한 결과도 검사 완료로 기록하지 않으므로, 복구 후 기존 내용까지 다시 검사합니다. 실제 정책 위반 거부는 해당 내용이 남아 있는 동안 재사용합니다. - 요청 전체에 데드라인을 걸었습니다. 본문 수신부터 서명 키 조회, 캐시 조회, Guardrails 호출까지 하나의 시간 예산 안에서 처리합니다. 초과하면 200 deny로 응답하고 늦게 도착한 성공 결과를 버립니다. 느린 업로드도 연결을 닫습니다.
| Anthropic 서킷 브레이커 | 판정 서버 내부 브레이커 | |
|---|---|---|
| 작동 조건 | 서버의 타임아웃이나 200이 아닌 응답이 계속됨 | Guardrails 호출 연속 실패 |
| 결과 | 검사 대상 요청에 실패 정책 적용 | Guardrails 호출을 건너뛰고 200 거부 |
| 복구 | 작동 10분 후부터 자동 확인 또는 관리자 수동 리셋 | 대기 후 시험 호출. 임시 거부는 캐시하지 않아 이후 요청에서 재검사 |
리전 장애 대응
주 아키텍처에서는 대기 리전에 같은 구성(ALB, Fargate, 엔드포인트)을 두고, DynamoDB 세션 캐시는 Global Tables로 복제합니다. 엔드포인트 도메인은 Route 53 failover 레코드로 구성해 기본 리전의 상태가 나빠지면 대기 리전의 ALB로 응답을 바꿉니다. DNS 수준의 전환이므로 POST 요청에도 적용됩니다.
ALB 보안 그룹이 Anthropic 대역만 허용하므로 Route 53 헬스 체커도 ALB에 연결하지 못하고, 엔드포인트 헬스 체크는 사용자 지정 헤더를 보낼 수 없어 토큰 리스너 규칙도 통과하지 못합니다. 다음 세 가지 방식 중에서 고를 수 있습니다.
| 방식 | 구성 | 장단점 |
|---|---|---|
| ALB alias의 대상 상태 평가 | failover 레코드를 ALB alias로 만들고 Evaluate target health를 켬 | 보안 그룹을 열 필요가 없음. Guardrails 같은 의존성 상태까지 반영되는지는 대상 그룹 헬스 체크 경로에 따라 다름 |
| CloudWatch 알람 기반 헬스 체크 (권장) | ALB의 정상 대상 수나 판정 서버의 실패 지표에 CloudWatch 알람을 만들고, Route 53 헬스 체크가 그 알람의 지표를 평가하게 함 | 보안 그룹을 열 필요가 없음. 판정 서버 내부 상태(Guardrails 실패 등)까지 반영 가능. 리전 장애로 지표가 끊겼을 때의 상태를 정해야 함 |
| 엔드포인트 헬스 체크 | Route 53 헬스 체커 IP 대역을 보안 그룹에 추가하고, 토큰 없이 접근 가능한 /healthz 리스너 규칙을 추가 |
실제 경로를 확인하지만 공격 표면이 넓어지고, 헬스 체커 대역 변경을 추적해야 함 |
전환 시간은 헬스 체크 판정 시간과 레코드 TTL, Anthropic 쪽 DNS 캐시에 따라 달라집니다. 전환 중에 타임아웃이나 5xx가 이어지면 서킷 브레이커가 열릴 수 있으므로, 대기 리전이 정상인 것을 확인한 뒤 조직 설정에서 Enforce verdicts를 다시 켜 브레이커를 리셋하는 절차를 런북에 포함합니다.
대안 구성에서는 CloudFront 오리진 그룹이 POST 요청을 페일오버하지 않으므로, 앞서 설명한 대로 활성 리전을 바꿔 CDN 스택을 재배포합니다.
테스트와 롤아웃
서버의 serviceMode와 조직 설정의 Mode는 서로 다른 설정입니다. 개인정보 검사는 판정 서버를 enforce로 바꿔야 실행됩니다. Shadow mode는 검사 결과를 관찰하되 사용자 요청을 차단하지 않는 설정입니다.
| 단계 | 서버 설정 | 조직 설정 | 확인 항목 |
|---|---|---|---|
| 연결과 수신 시간 확인 | serviceMode=probe |
Test connection | 서명 검증, 토큰 헤더, 연결, 본문 수신 시간 |
| 정책 관찰 | serviceMode=enforce |
Enforce verdicts ON + Validate tool calls ON + Shadow mode | 실제 allow/deny, 지연, 오탐. 사용자 차단 없음 |
| 정책 집행 | serviceMode=enforce |
Enforce verdicts ON + Validate tool calls ON + Block the request | deny 집행, webhook failure에도 차단 |
Allow the request에서도 서버가 정상적으로 반환한 deny는 집행됩니다. Block the request와 다른 점은 서버가 응답하지 못하는 등 webhook failure가 났을 때의 처리입니다. 적용 범위는 Requests inspected (%)와 제외 역할 설정으로 조정합니다.
진입점 접근 통제는 Anthropic 대역 밖에서 직접 시험해 볼 수 있습니다. 사무실이나 개발 환경에서 엔드포인트로 요청을 보내면, 주 아키텍처에서는 연결 자체가 수립되지 않아야 하고 대안 구성에서는 WAF가 403으로 거부해야 합니다. 정상 경로는 조직 설정의 Test connection으로 연결과 판정 응답을 확인한 뒤, 검사 대상 제품에서 합성 입력을 보내 허용과 거부 동작을 확인합니다.
아래는 High 등급 기준을 5건으로 설정하고 Shadow mode에서 합성 데이터로 확인한 결과입니다.
| 샘플 | 기대 | 결과 | 탐지 주체 |
|---|---|---|---|
| 주민등록번호 3건 | 거부 | 거부 | Stage-1 |
| AWS 액세스 키 | 거부 | 거부 | Stage-1 |
| 연락처 6명 명단 | 거부 | 거부 | Stage-1 + Guardrails PII |
| “대외비” 표기 문서 | 거부 | 거부 | Stage-1 키워드 |
| 카드번호와 CVV | 거부 | 거부 | Guardrails PII |
| PEM 개인키 | 거부 | 거부 | Stage-1 |
| 미공개 인수합병 정리 요청 | 거부 | 거부 | Guardrails 거부 주제 |
| 프롬프트 인젝션 문구 | 거부 | 거부 | Guardrails 프롬프트 공격 |
| 시크릿 없는 코드 리뷰 | 허용 | 허용 | |
| 이메일 1건 업무 질문 | 허용 | 허용 | |
| 이름과 주소가 섞인 안내문 | 허용 | 허용 | |
| 사업자등록번호 1건 | 허용 | 허용 |
앞에서 본 5개 요청을 webhookId로 판정 로그와 대조한 결과입니다. 모두 Guardrails를 한 청크로 호출했고 allow를 반환했습니다.
| 턴 | 실제 본문(bytes) | Guardrails 호출(ms) | 서버 totalMs(ms) |
검사 범위 |
|---|---|---|---|---|
| 1 | 736 | 612 | 772 | 캐시가 없는 새 대화 |
| 2 | 1,697 | 592 | 758 | 접두어 일치, 새 메시지부터 |
| 3 | 2,360 | 809 | 854 | 접두어 일치, 새 메시지부터 |
| 4 | 3,211 | 547 | 586 | 접두어 일치, 새 메시지부터 |
| 5 | 4,399 | 1,255 | 1,292 | 접두어 일치, 새 메시지부터 |
서버 처리 시간은 586~1,292ms였습니다. 한 대화에서 순서대로 보낸 요청 5개의 관측값이며 임시 캡처 계측 시간이 포함되어 있습니다. 판정 서버 내부의 처리 시간이므로 진입점 구성과 관계없이 참고할 수 있지만, Anthropic과 판정 서버 사이의 연결, 전송, 응답 구간이 빠져 있어 사용자가 체감하는 추가 지연과는 다릅니다.
이 실측은 정상 입력이 실제 Guardrails까지 연결되는 경로를 확인한 것입니다. 판정 응답 제한 시간(verdict timeout)은 큰 본문과 동시 요청을 포함한 부하 시험에서 전체 왕복 시간의 p95/p99를 확인한 뒤 정합니다.
DLP 외에 적용할 수 있는 거버넌스 정책
Hook 요청에는 대화 내용 외에도 사용자(actor), 요청이 들어온 애플리케이션(source.application), 모델(model), 툴 호출과 결과(tool_use, tool_result), 첨부 파일의 이름과 형식(attachment)도 들어 있습니다. 아래 표는 이 정보를 활용하는 정책 확장 예시이며, 이 글의 구현에는 포함하지 않았습니다.
| 사례 | 판정에 쓰는 정보 | 적용 예 |
|---|---|---|
| 부서와 직무별 차등 정책 | actor.id나 actor.email_address를 사내 디렉터리와 매핑 |
인사 담당자는 직원 정보가 담긴 요청을 허용하고, 그 외 사용자는 같은 요청을 거부 |
| 사용 경로별 안내 | source.application, actor |
개발 조직이 아닌 사용자의 Claude Code 요청에 사내 승인 절차를 안내 |
| 에이전트 작업 통제 | tool_use, tool_result |
Claude Code가 읽은 파일 내용에 .env의 키나 인증서가 들어 있으면 다음 추론을 거부하고, Validate tool calls를 켜면 운영 데이터베이스 접속 명령 같은 툴 호출을 실행 전에 거부 |
| 사내 AI 사용 지침 적용 | 메시지 내용, Guardrails 거부 주제 | 채용 후보자 평가, 인사 징계 판단처럼 사내 규정이 AI 사용을 금지한 업무 요청을 거부 |
| 산업기술과 영업비밀 보호 | 키워드, 거부 주제, 첨부 파일 이름과 형식 | 프로젝트 코드명, 국가핵심기술 관련 용어, 특정 설계 도면 파일이 포함된 요청을 거부 |
| 감사 증적 확보 | 판정 메타데이터 | 누가 어떤 경로에서 어떤 정책으로 차단되었는지를 SIEM으로 보내 정보보호 감사 자료로 활용 |
Anthropic 문서에 따르면 actor.id와 actor.email_address는 null일 수 있고, actor.email_address는 값이 있을 때만 들어옵니다. 사용자 정보가 비어 있는 요청에 어떤 판정을 내릴지 미리 정해 둡니다. source.application은 참고용 라우팅 정보이며, 문서는 이 값만으로 보안상 중요한 판정을 내리지 말라고 안내합니다. 따라서 사용 경로 정보는 안내와 기록에만 쓰고, 차단 여부는 사용자와 내용을 기준으로 정하는 것을 권장합니다.
툴 실행 전 통제는 Validate tool calls를 켰을 때 전송되는 툴 호출 요청으로 할 수 있습니다. 판정 서버가 거부하면 해당 응답의 툴 호출이 모두 중단됩니다. 일부 툴 호출은 전송되지 않을 수 있으므로, Claude Code의 권한 설정 같은 클라이언트 측 통제와 함께 쓰는 것이 좋습니다.
판정 서버의 응답은 허용과 거부 두 가지뿐입니다. 민감정보를 가린 뒤 요청을 그대로 통과시키거나, 경고만 보여 주고 진행하게 하는 동작은 할 수 없습니다. 새 정책은 Shadow mode에서 정상 요청에도 deny가 나오는지 확인하고, 그 결과에 맞춰 차단 기준을 조정합니다.
사내 디렉터리 조회처럼 외부 시스템을 거치는 판정은 5초 응답 예산 안에서 끝나야 하므로, 조회 결과를 캐시하고 조회 실패 시 어떤 판정을 내릴지 미리 정해 둡니다. 사용자별 판정 기록은 그 자체로 직원의 AI 사용 이력이 되므로, 보관 범위와 기간, 열람 권한, 직원 고지 여부도 함께 정해야 합니다.
알아 둘 한계
이 구성을 기업 DLP로 운영하려면 아래 한계도 고려해야 합니다.
- Inference hooks는 첨부 파일에서 추출한 텍스트만 보냅니다. 원본 이미지 바이트는 전달되지 않으므로 스크린샷 같은 이미지에 담긴 정보는 이 경로로 검사할 수 없습니다.
- 증분 검사는 새 메시지만 보므로, Medium 조합이나 2건 이상의 건수 기준은 여러 턴에 나눠 보내면 피할 수 있습니다.
- 공백이나 기호를 끼운 번호, 전각 숫자, base64 인코딩 같은 난독화는 정규식과 ML 모두 놓치기 쉬우므로 검사 전에 정규화가 필요합니다.
- 주민등록번호는 형식만으로 판정하므로 같은 형식의 다른 13자리 숫자도 차단될 수 있습니다.
- 5턴 실측은 툴 호출이 없는 대화로 진행했습니다. 툴 호출 검사와 캐시 처리 등 실측 이후 바꾼 판정 서버 동작은 로컬 테스트까지 확인했으며, 실제 배포 환경에서의 차단, 장애 복구, 오탐 검증은 별도로 필요합니다.
- 상용 DLP의 정확 일치(EDM)나 문서 지문 기능은 Guardrails에 없습니다. 거부 주제가 일부를 의미 기반으로 잡지만, 보안 표기가 없는 사내 문서를 확실히 식별하려면 별도 기능이 필요합니다.
- Inference hooks의 지원 범위와 이벤트 종류는 Claude의 기능 개선에 따라 바뀌고 있습니다. 판정 서버는 알 수 없는 필드와 블록 타입은 무시하고 알 수 없는 이벤트 타입에는 허용을 반환하므로, 새 이벤트가 추가되면 처리기를 넣기 전까지 그 이벤트는 검사 없이 통과합니다. 최신 사양은 Anthropic의 Inference hooks 문서에서 확인하고, 이그레스 IP 대역의 변경도 함께 확인합니다.
- 작성 시점 기준 Inference hooks 설정은 claude.ai의 조직 설정에서만 할 수 있습니다. 변경 이력과 거부 기록, 서킷 브레이커 기록은 Compliance API의 Activity Feed로 확인하고, 모드 전환이나 시크릿과 토큰 교체는 런북으로 절차를 정해 두는 것이 좋습니다.
마치며
이 글에서는 Amazon Bedrock Guardrails와 자체 규칙으로 판정하는 서버를 AWS에 구축하고, Claude Enterprise의 Inference hooks에 연결해 조직의 DLP 정책을 적용하는 방법을 살펴보았습니다. 진입점은 보안 그룹, 토큰 헤더, 웹훅 서명으로 단계적으로 보호하고, 판정은 한국 고유 식별자를 확인하는 자체 규칙과 Guardrails의 2단계로 구성했습니다. 매 턴 대화 전체가 다시 전송되는 특성에 맞춰 새 메시지만 검사하고, 장애 시 차단과 Route 53 기반 리전 페일오버도 함께 설계했습니다.
판정 기준과 판정 로그는 고객이 AWS에서 직접 관리하므로 기존 보안 운영과 감사 절차에 연결할 수 있습니다. 실제로 적용하기 전에는 검사 경로의 데이터 처리 요건과 진입점에 관한 조직 정책을 확인합니다. 그다음 Shadow mode에서 판정 결과와 오탐, 전체 왕복 지연을 관찰하고, 통제된 범위에서 Block the request를 적용해 실제 차단과 장애 복구 동작을 검증하시기 바랍니다.
참고 자료
- AWS, Use the ApplyGuardrail API in your application
- AWS, Languages supported by Amazon Bedrock Guardrails
- AWS, Supported Regions for cross-Region guardrail inference
- AWS, Permissions for using cross-Region inference with Amazon Bedrock Guardrails
- AWS, Access AWS services through AWS PrivateLink
- AWS, Security groups for your Application Load Balancer
- AWS, Condition types for listener rules
- AWS, Configuring DNS failover
- AWS, Restrict access with VPC origins
- AWS, Serve compressed files
- AWS 요금: Amazon CloudFront, AWS WAF, Amazon VPC (퍼블릭 IPv4 주소)
- Anthropic, Inference hooks
- Anthropic, Configure Inference hooks
- Anthropic, Develop an Inference hooks integration
- Standard Webhooks, Specification