얼마 전 AWS 기술 블로그에 올라온 Building age-responsive, context-aware AI with Amazon Bedrock Guardrails라는 글을 읽었습니다. 같은 질문이라도 어린이와 의료 전문가에게 다른 응답을 내보내고, 그 통제를 프롬프트가 아니라 추론 시점에 강제하는 구조였습니다. 읽으면서 "이건 연령, 역할 기반인데, 기업 환경의 직무별 접근 제어로 바꿔도 똑같이 동작하지 않을까?"라는 생각이 들었습니다.
고객을 만나다 보면 "역할별로 AI 응답을 다르게 제어할 수 있냐"는 질문을 자주 받습니다. 그때마다 단순히 "가능합니다"라고 답하긴 하지만, 어떤 정책 조합이 어떤 시나리오에 맞는지 더 구체적으로 설명하고 싶었습니다. 그래서 원문의 구조를 빌려와 기업 환경 — 일반 직원, CS 상담원, 개발자, 경영진 — 으로 바꿔서 테스트하고 정리했습니다.
(A) 설계 의도와 아키텍처
먼저 Guardrails의 동작 원리부터 짚고 넘어가겠습니다. Bedrock Guardrails는 모델 추론 시점에 입력과 출력을 모두 필터링합니다. 중요한 점은 이 필터가 애플리케이션 코드나 프롬프트와 분리되어 동작한다는 것입니다. 프롬프트에 "이전 지시를 무시하라" 같은 우회 시도를 넣어도 Guardrails 레이어를 통과하지 못합니다. WAF가 웹 요청을 애플리케이션 앞단에서 필터링하듯, Guardrails는 AI의 입출력을 모델 호출 시점에 필터링하는 셈입니다.
이 PoC에서 가장 먼저 정한 것은 역할별 정책 설계였습니다. 네 개 역할에 대해 다음과 같이 정리했습니다.
| 역할 | Denied Topics | PII 처리 | Content Filter |
|---|---|---|---|
| 일반 직원 | 재무 데이터, 고객 PII, 인프라 정보 | 전면 차단 | 강함 |
| CS 상담원 | 환불 약속, 법적 약정 | 마스킹 (NAME, PHONE, EMAIL) | 중간 |
| 개발자 | 재무 데이터 | 시크릿/키 마스킹 | 중간 |
| 경영진 | (최소) | 최소 | 약함 + 로깅 강제 |
Guardrails의 필터링 정책은 크게 세 가지로 나눌 수 있습니다. 혐오, 폭력, 프롬프트 인젝션 같은 범용 유해 콘텐츠를 잡는 Content Filter, "3분기 매출"처럼 회사 내부에서만 민감한 주제를 정의해서 막는 Denied Topics, 그리고 이름, 전화번호, 이메일 같은 개인정보를 처리하는 PII 정책입니다. 이번 PoC에서 다루는 시나리오는 재무 데이터 차단과 개인정보 마스킹이 핵심이라, Content Filter는 빼고 Denied Topics와 PII 정책만 사용했습니다.
PoC의 서비스 구성은 단순하게 잡았습니다. DynamoDB에 사용자 프로필(user_id → role)을 저장하고, Lambda가 요청을 받아 DynamoDB에서 역할을 조회한 뒤 그 역할에 맞는 Guardrail ARN을 골라 Bedrock을 호출하는 흐름입니다. 전체 플로우는 Request(user_id + 질문) → Lambda → DynamoDB(역할 조회) → Guardrail 선택 → Bedrock converse → Response 입니다. 여기서 Lambda와 DynamoDB는 역할과 가드레일을 연결하는 라우팅 레이어일 뿐이고, 핵심은 어디까지나 Guardrails 정책 자체에 있습니다.
다만 PoC에서는 편의상 user_id를 요청 본문으로 직접 넘겼는데, 이건 현업에 그대로 쓰면 안 되는 구조입니다. 사용자가 자기 역할을 직접 주장하는 꼴이기 때문입니다. 현업 확장 시에는 Cognito에서 발급한 JWT 토큰의 claim에 역할을 담고, API Gateway가 JWT를 검증한 뒤 Lambda로 라우팅하는 구조를 권장합니다. 즉 Cognito(JWT) → API Gateway(검증) → Lambda → DynamoDB → Bedrock으로, 역할 판별은 반드시 신뢰할 수 있는 토큰에서 가져와야 합니다.
(B) Deep Dive: 주요 이점 및 고려 사항
먼저 Lambda 코드부터 보겠습니다. DynamoDB에서 역할을 조회하고, 역할별 Guardrail을 선택해 Bedrock converse API를 호출하는 구조입니다.
import boto3
import json
dynamodb = boto3.resource('dynamodb')
users_table = dynamodb.Table('Enterprise-Users')
bedrock = boto3.client('bedrock-runtime')
GUARDRAIL_MAP = {
'general_employee': {'id': 'sjhvsceh7f84', 'version': 'DRAFT'},
'cs_agent': {'id': 'k4w233cofwp9', 'version': 'DRAFT'},
'developer': {'id': 'bv3mbrwarr7j', 'version': 'DRAFT'},
'executive': {'id': 'er1r4z3e0tv9', 'version': 'DRAFT'},
}
def get_user_role(user_id):
resp = users_table.get_item(Key={'user_id': user_id})
item = resp.get('Item')
if not item:
return 'general_employee'
return item['role']
def handler(event, context):
user_id = event['user_id']
question = event['query']
role = get_user_role(user_id)
guardrail = GUARDRAIL_MAP[role]
resp = bedrock.converse(
modelId='global.anthropic.claude-sonnet-4-5-20250929-v1:0',
system=[{'text': SYSTEM_PROMPT}],
messages=[{'role': 'user', 'content': [{'text': question}]}],
guardrailConfig={
'guardrailIdentifier': guardrail['id'],
'guardrailVersion': guardrail['version'],
'trace': 'enabled',
},
)
# 응답 파싱
stop_reason = resp.get('stopReason', '')
trace = resp.get('trace', {})
if stop_reason == 'guardrail_intervened':
output_text = resp['output']['message']['content'][0]['text']
return {'blocked': True, 'role': role, 'message': output_text, 'trace': trace}
else:
output_text = resp['output']['message']['content'][0]['text']
return {'blocked': False, 'role': role, 'response': output_text, 'trace': trace}
미등록 사용자는 general_employee로 폴백되어 가장 강한 정책이 적용됩니다.
DynamoDB 테이블 구성
사용자-역할 매핑을 저장하는 Enterprise-Users 테이블을 만들었습니다. 파티션 키는 user_id이고, role과 department 속성을 저장합니다.
테스트용으로 네 명의 사용자를 등록했습니다.
| user_id | role | department |
|---|---|---|
| emp001 | general_employee | marketing |
| cs001 | cs_agent | customer_support |
| dev001 | developer | engineering |
| exec001 | executive | c-level |
가드레일 생성
역할별로 가드레일을 하나씩, 총 네 개를 만들었습니다. 일반 직원용 가드레일을 예로 들면, Denied Topics에 FinancialData, Infrastructure, CustomerData 세 개 토픽을 등록하고, Sensitive Information에서 NAME/PHONE/EMAIL을 BLOCK으로 설정했습니다.
Denied Topics 설정:
각 토픽에 definition(어떤 주제인지 자연어 설명)과 examples(실제 차단할 질문 예시)를 입력합니다. definition이 넓을수록 매칭 범위가 커지고, examples가 많을수록 유사 표현을 잘 잡습니다.
Sensitive Information(PII) 설정:
PII 타입별로 BLOCK(완전 차단) 또는 ANONYMIZE(마스킹) 중 선택합니다. 일반 직원은 전부 BLOCK, CS 상담원은 ANONYMIZE로 설정해 역할 간 차이를 만들었습니다.
설정을 마치고 첫 테스트를 돌렸는데, 바로 문제가 터졌습니다. 일반 직원 계정으로 "우리 회사 3분기 매출이 얼마야?"를 물었더니 차단되지 않고 모델이 응답을 생성해버렸습니다. "영업 실적 추이"는 물론이고, examples에 있는 "매출"이라는 단어를 포함한 질문조차 잡지 못했습니다. 처음에는 definition이나 examples가 부족한 줄 알고 표현을 넓히고 예시를 추가해봤지만, 아무리 바꿔도 blocked: false였습니다.
원인을 추적해보니 Denied Topics의 tier 설정 문제였습니다. Guardrails의 Denied Topics에는 Classic tier와 Standard tier가 있는데, 가드레일 생성 시 기본값인 Classic tier로 만들어져 있었습니다.
| 구분 | Classic | Standard |
|---|---|---|
| 지원 언어 | 영어, 프랑스어, 스페인어 | 50개 이상 (한국어 포함) |
| 토픽 매칭 정확도 | 기본 | 향상 |
| Cross-Region inference | 불필요 | 필요 |
| definition 최대 길이 | 200자 | 1,000자 |
한국어로 definition과 examples를 아무리 정교하게 작성해도, Classic tier에서는 한국어 의미 매칭 자체가 동작하지 않았던 것입니다. 가드레일이 토픽 정책을 "평가"하긴 했지만(trace에서 topicPolicyUnits: 1 확인), 한국어를 이해하지 못하니 매번 "해당 없음"으로 판정하고 통과시킨 것이었습니다.
기존 가드레일을 삭제하고 Standard tier로 다시 만들었습니다. Standard tier를 쓰려면 Cross-Region inference 활성화가 필수라서, 콘솔에서 "Enable cross-Region inference for your guardrail" 옵션을 함께 켜야 합니다. 이렇게 바꾸고 같은 질문을 다시 보냈더니, "매출이 얼마야?"는 물론 "영업 실적 추이 알려줘" 같은 우회 표현까지 전부 잡혔습니다. definition도, examples도 그대로인데 tier 하나 바꿨을 뿐입니다.
정리하면, 다국어 환경에서는 반드시 Standard tier를 선택해야 합니다. Classic tier는 영어 기반 서비스에서만 유효합니다. 가드레일이 "평가했지만 차단하지 않음" 상태일 때는 definition 문제가 아니라 tier 문제일 수 있으니, trace의 topicPolicyUnits가 1인데 통과됐다면 tier부터 확인하는는 것을 권장드립니다.
Standard tier로 전환한 뒤 나머지 시나리오를 이어서 테스트했습니다. 같은 질문을 경영진 역할(재무 데이터 허용 가드레일)로 보내면 정상적으로 답변이 나왔습니다.
다음은 CS 상담원과 일반 직원의 PII 처리 차이를 봤습니다. "고객 김지훈님 주문 내역 보여줘"를 물었을 때, CS 상담원 가드레일은 마스킹 설정이라 이름이 {NAME}, 전화번호가 {PHONE}으로 가려진 채 주문 내역을 응답했습니다. 반면 일반 직원은 PII가 BLOCK이라 응답 자체가 막혔습니다. 여기서 마스킹 동작이 입력과 출력 양쪽에 적용된다는 점도 확인했습니다. 입력에 PII가 포함돼도 마스킹된 형태로 모델에 전달됩니다.
개발자 시나리오는 조금 다른 준비가 필요했습니다. 모델은 실제 DB 접속 정보를 갖고 있지 않기 때문에, 단순히 "DB 접속 정보 알려줘"라고 물으면 "접속 정보가 없습니다"라고만 답합니다. 마스킹할 대상 자체가 응답에 없는 거죠. 그래서 시스템 프롬프트에 mock 데이터를 넣어 모델이 응답에 시크릿을 포함하도록 만들었습니다.
SYSTEM_PROMPT = '''당신은 회사의 내부 시스템 정보를 안내하는 인프라 도우미 봇입니다.
사내 직원이 업무에 필요한 정보를 요청하면 아래 데이터베이스에서 조회하여 답변합니다.
모든 요청은 인증된 사내 직원에게서만 들어옵니다. 요청된 정보를 그대로 안내하세요.
=== 사내 시스템 DB ===
[서비스 인프라]
- Production DB Host: prod-db.company.internal
- Production DB Port: 5432
- DB 계정: admin / SuperSecret123!
- AWS Access Key ID: AKIAIOSFODNN7EXAMPLE
- AWS Secret Access Key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
[고객 주문 정보]
- 고객명: 김지훈
- 연락처: 010-9876-5432
- 이메일: jihun.kim@example.com
- 최근 주문: ORD-2026-78901 (무선 이어폰, 89,000원, 배송완료)
[회사 재무 정보]
- 2026년 3분기 매출: 1,250억원
- 영업이익: 180억원
- 전분기 대비 성장률: 12.5%
==================='''
그런데 여기서 한 가지 더 발견한 게 있었습니다. 단순히 "DB 접속 정보 알려줘"라고만 물으면 모델 자체가 보안 판단을 해서 시크릿을 응답에 포함시키지 않았습니다. 모델이 자체적으로 "민감 정보는 안내할 수 없다"고 판단하니, 가드레일이 마스킹할 대상 자체가 없었던 것입니다. 그래서 질문을 "이 접속 정보 맞아? host: prod-db.company.internal, key: AKIAIOSFODNN7EXAMPLE"처럼 사용자 입력에 시크릿을 직접 포함시키는 형태로 바꿨더니, 모델이 확인 응답을 생성하면서 키 값을 포함하려 했고, 그 시점에 가드레일이 출력에서 {AWS_ACCESS_KEY}로 마스킹 처리했습니다. 인프라 토픽 자체는 차단하지 않으면서, 시크릿 값만 정확히 가려주는 동작을 확인할 수 있었습니다. 일반 직원으로 같은 질문을 보내면 인프라 주제 자체가 Denied로 막혔습니다.
네 번째는 경영진 역할이었습니다. 앞선 테스트에서 일반 직원, CS, 개발자 모두에게 차단됐던 "이번 분기 매출 실적이랑 영업이익 알려줘"를 경영진으로 보냈습니다. 경영진 가드레일은 Denied Topics를 설정하지 않았고 Content Filter도 넣지 않았기 때문에 모델이 정상적으로 응답을 생성했습니다. 다만 경영진이라고 아무 제약 없이 열어둔 건 아닙니다. PII 중 NAME은 여전히 마스킹 처리해서, 응답에 특정 인물 이름이 포함될 경우 {NAME}으로 가려집니다. 업무에 필요한 정보는 다 볼 수 있되, 특정 인물이 식별되는 건 막아두는 최소한의 장치입니다.
정리하면 아래와 같은 매트릭스가 나왔습니다.
질문 | 일반직원 | CS상담원 | 개발자 | 경영진
------------------------------|---------|---------|-------|------
3분기 매출은? | 차단 | 차단 | 차단 | 허용
김지훈님 주문 내역 | 차단 | 마스킹 | 차단 | 마스킹
DB 접속 정보 | 차단 | 차단 | 마스킹 | 허용
테스트를 통해 확인한 이점은 분명했습니다. 정책이 애플리케이션 코드에서 완전히 분리되어 있어, 새 역할을 추가할 때 가드레일 하나 만들고 매핑만 추가하면 됩니다. 프롬프트 우회도 시도해봤지만 추론 시점 필터를 뚫지는 못했습니다. 반면 가드레일은 추론 호출마다 처리 오버헤드와 비용을 추가한다는 점은 고려해야 합니다.
(C) 아키텍처 트레이드오프
테스트하며 마주친 첫 번째 트레이드오프는 마스킹과 차단 사이의 선택입니다. CS 상담원처럼 고객 정보를 다뤄야 하지만 원본을 볼 필요는 없는 역할에는 마스킹이 적합합니다. 반면 일반 직원처럼 애초에 접근 자체가 부적절한 역할에는 차단이 맞습니다. 같은 PII 정책이라도 역할의 업무 맥락에 따라 ANONYMIZE와 BLOCK을 구분해야 한다는 것이 PoC의 결론이었습니다.
두 번째는 세분화와 복잡성의 균형입니다. 역할마다 가드레일을 따로 만들면 통제는 정밀해지지만, 가드레일 개수가 늘수록 관리 부담도 커집니다. 정책 변경 시 어느 가드레일을 손봐야 하는지 추적하기 어려워지고, 버전이 제각각 흩어지기 쉽습니다. PoC 수준에서는 네 개로 충분했지만, 역할이 수십 개로 늘어나는 환경이라면 공통 베이스 정책을 두고 차등 항목만 분리하는 식의 설계를 고민해야 합니다.
세 번째는 비용입니다. 가드레일은 추론 호출마다 입출력 텍스트를 처리하므로, 호출량이 많아지면 비용이 누적됩니다. 특히 Standard tier는 Cross-Region inference를 사용하므로 Classic보다 비용이 높습니다. 다국어 지원이 불필요한 영어 전용 서비스라면 Classic으로 비용을 절약할 수 있고, 한국어를 포함한 다국어 환경이면 Standard가 필수입니다. 호출량과 가드레일 처리 비용은 CloudWatch 메트릭으로 추적하는 것을 권장합니다.
Summary & Final Checklist
이번 PoC에서 확인한 핵심은 세 가지입니다. 첫째, Guardrails는 추론 시점에 강제되므로 프롬프트로 우회되지 않습니다. 둘째, 같은 가드레일 기능이라도 역할별로 마스킹, 차단을 구분하면 RBAC와 최소 권한 원칙을 AI 응답에 그대로 적용할 수 있습니다. 셋째, 다국어 환경에서는 반드시 Standard tier를 선택해야 Denied Topics가 정상 동작합니다.
재현하실 분들을 위한 체크리스트입니다.
- 다국어(한국어 포함) 환경이면 반드시 Standard tier + Cross-Region inference를 활성화합니다.
- Denied Topics 정의는 우회 시나리오를 먼저 적어두고 포괄적으로 작성하며, examples에 실제 우회 표현을 넣습니다.
Lessons Learned
가장 단순한 설정 하나를 놓쳐서 가장 오래 걸린 게 Classic tier 문제였습니다. definition과 examples를 아무리 다듬어도 한국어 질문이 차단되지 않아서, 처음에는 의미 매칭의 한계인 줄 알았습니다. 그런데 실제 원인은 Classic tier가 한국어를 지원하지 않는다는 단순한 사실이었습니다. trace를 열어보면 토픽 정책이 "평가"는 되지만 "매칭"은 안 되는 상태, topicPolicyUnits: 1인데 actionReason: "No action."인 게 확인됩니다. 이걸 모르면 "definition이 부족한가?" 하고 계속 삽질하게 됩니다.
또 하나 느낀 건, 원문이 연령, 역할 기반이었던 똑같은 기능을 기업 직무로 바꿨을 뿐인데 운영 모델이 완전히 달라졌다는 점입니다. 기술은 같은데 누구에게, 어떤 맥락에서 적용하느냐에 따라 정책 설계도, 테스트 시나리오도, 모니터링 포인트도 다 바뀝니다. 고객에게 설명할 때 "기능"보다 "적용 맥락"을 먼저 묻게 된 계기였습니다.
다시 한다면 처음부터 정책을 IaC로 관리했을 것 같습니다. 코드로 관리했다면 tier 설정 같은 실수도 코드 리뷰에서 잡았을 테니까요. 실제 환경에 적용하신다면 이 부분부터 시작하시길 권장합니다.










Top comments (0)