실무 시나리오 E2E: 사내 문서 질의 시스템 만들기: 9편
지금까지 8편에 걸쳐 Microsoft Foundry 에이전트를 구성하는 각 요소들을 하나씩 다뤘습니다. 에이전트 유형, 툴 연동, MCP, 관측성, 품질 평가, 보안, APIM 패턴, 멀티에이전트 오케스트레이션.
실무 시나리오 E2E: 사내 문서 질의 시스템 만들기: 9편
지금까지 8편에 걸쳐 Microsoft Foundry 에이전트를 구성하는 각 요소들을 하나씩 다뤘습니다. 에이전트 유형, 툴 연동, MCP, 관측성, 품질 평가, 보안, APIM 패턴, 멀티에이전트 오케스트레이션.
이번 글은 통합편입니다. 그 요소들을 하나의 “사내 문서 질의 시스템” 으로 연결하는 E2E 시나리오를 처음부터 끝까지 구성해 보겠습니다.
“우리 회사 내부 문서를 AI에게 물어보는 시스템”은 가장 흔하게 도입을 요청받는 유즈케이스입니다. 동시에 보안, 접근 제어, 비용, 관측성까지 챙겨야 하는 가장 복잡한 시나리오이기도 합니다. 이 글에서는 그 복잡함을 각 레이어별로 분리해서 단계적으로 구성합니다.
시나리오: 무엇을 만드는가
대상: 중견 규모 회사의 내부 직원용 문서 질의 시스템

요구사항:
-
직원이 사내 정책 문서, 제품 매뉴얼, 규정집을 자연어로 질문할 수 있어야 한다
-
부서별로 접근 가능한 문서가 다르다 (HR 정책은 전 직원, 재무 문서는 재무팀만)
-
누가 무엇을 질문했는지 감사 로그가 남아야 한다
-
1인당 하루 호출량 제한이 필요하다
-
답변 품질이 측정 가능해야 한다
구성 요소:
-
Foundry IQ — 사내 문서를 Knowledge Base로 관리, Agentic Retrieval로 검색
-
Foundry Agent — 사용자 질문을 받아 Knowledge Base에서 답변 생성
-
APIM — JWT 인증, 사용자별 Rate Limit, 감사 로그
-
Entra ID — 사용자 인증, RBAC로 문서 접근 제어
전체 아키텍처

사전 준비
필요한 Azure 리소스
-
Azure AI Foundry 리소스 + 프로젝트 :에이전트, Foundry IQ 호스팅
-
Azure OpenAI :GPT-4o 배포
-
Azure AI Search :Foundry IQ Knowledge Base 인덱스 저장
-
Azure Blob Storage :사내 문서 저장
-
Azure API Management (v2 이상) : API 게이트웨이
-
Microsoft Entra ID (Azure AD) : 앱 등록, 사용자 인증
-
Application Insights + Log Analytics : 관측성
Python 패키지 설치
# `azure-ai-projects` 2.0.0 이상에서 `MCPToolDefinition`을 지원합니다.
pip install "azure-ai-projects>=2.0.0" azure-identity requests msal python-dotenv
환경 변수
export PROJECT_ENDPOINT="https://your-hub.services.ai.azure.com/api/projects/your-project"
export PROJECT_RESOURCE_ID="/subscriptions/.../providers/Microsoft.MachineLearningServices/workspaces/.../projects/..."
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o"
export AZURE_AI_SEARCH_ENDPOINT="https://your-search.search.windows.net"
export APIM_ENDPOINT="https://your-apim.azure-api.net"
export ENTRA_TENANT_ID="your-tenant-id"
export ENTRA_CLIENT_ID="your-app-client-id"
export ENTRA_CLIENT_SECRET="your-app-client-secret"
1단계: Entra ID 앱 등록 및 RBAC 구성

앱 등록

Azure Portal → Microsoft Entra ID → 앱 등록 → 새 등록.
앱 이름: doc-qa-system
지원되는 계정 유형: 이 조직 디렉터리의 계정만
리디렉션 URI: (SPA 또는 웹앱이면 실제 주소, CLI 테스트면 빈칸)

등록 후 다음을 기록해 두세요.
- 애플리케이션(클라이언트) ID
- 디렉터리(테넌트) ID
- 클라이언트 암호 (또는 인증서)를 인증서 및 암호 메뉴에서 생성합니다.
API 사용 권한 (APIM 토큰 검증용 스코프 정의)
앱 등록 → API 표시 → 범위 추가:
범위 이름: DocQA.Read
관리자 동의 표시 이름: 사내 문서 질의 시스템 읽기 권한
설명: 사내 문서에 질문하고 답변을 받을 수 있습니다.
부서별 앱 역할 정의
앱 역할 메뉴에서 부서별 역할을 추가합니다.
[
{
"allowedMemberTypes": ["User"],
"displayName": "모든 직원",
"id": "role-guid-1",
"isEnabled": true,
"value": "Employee"
},
{
"allowedMemberTypes": ["User"],
"displayName": "재무팀",
"id": "role-guid-2",
"isEnabled": true,
"value": "Finance"
},
{
"allowedMemberTypes": ["User"],
"displayName": "개발팀",
"id": "role-guid-3",
"isEnabled": true,
"value": "Engineering"
}
]
엔터프라이즈 애플리케이션 → 사용자 및 그룹에서 각 직원에게 해당 역할을 할당합니다.
2단계: Foundry IQ Knowledge Base 구성
Foundry IQ 전체 설정은 2편(Knowledge Base 설계)과 3편(APIM 연동)에서 상세히 다뤘으므로, 여기서는 이 시스템에 맞는 구성 포인트만 정리합니다.
3개의 Knowledge Base 생성
https://ai.azure.com → Foundry IQ → Create Knowledge Bases → Azure AI Search Index 또는 Azure Blob Storage

각 Knowledge Base는 동일한 설정으로 생성합니다.
인덱싱 방식: Indexed (자동 청킹·임베딩)
임베딩 모델: text-embedding-3-large
청킹 크기: 512 토큰 (오버랩 50)
언어: 한국어 포함 다국어

Knowledge Base ID 확인
생성 후 각 Knowledge Base의 ID를 기록해 둡니다. 에이전트 코드에서 사용합니다.
# Knowledge Base ID 예시 (실제 포털에서 확인)
KB_HR = "kb-hr-policy-id-xxxxx"
KB_PRODUCT = "kb-product-manual-id-xxxxx"
KB_FINANCE = "kb-finance-doc-id-xxxxx"
3단계: 에이전트 구성 — 역할별 3개 에이전트

접근 제어를 에이전트 레벨에서 구현합니다. 역할마다 접근 가능한 Knowledge Base가 다르므로, 역할별로 에이전트를 분리합니다.
3–1. 프로젝트 연결 생성 (MCP 엔드포인트 등록)
에이전트가 Knowledge Base에 MCP로 접근하려면, 먼저 프로젝트 연결(RemoteTool 타입)을 생성해야 합니다. ARM REST API로 프로그래밍 방식으로 생성할 수 있습니다.
import os
import requests
from azure.identity import DefaultAzureCredential
credential = DefaultAzureCredential()
PROJECT_RESOURCE_ID = os.environ["PROJECT_RESOURCE_ID"]
SEARCH_ENDPOINT = os.environ["AZURE_AI_SEARCH_ENDPOINT"]
def ensure_project_connection(connection_name: str, kb_name: str):
"""프로젝트 연결(RemoteTool 타입)을 ARM API로 생성합니다."""
mcp_endpoint = f"{SEARCH_ENDPOINT.rstrip('/')}/knowledgebases/{kb_name}/mcp"
token = credential.get_token("https://management.azure.com/.default").token
url = (
f"https://management.azure.com{PROJECT_RESOURCE_ID}"
f"/connections/{connection_name}?api-version=2025-04-01-preview"
)
body = {
"properties": {
"category": "RemoteTool",
"target": mcp_endpoint,
"authType": "AAD",
}
}
resp = requests.put(
url,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json=body,
)
resp.raise_for_status()
print(f"프로젝트 연결 생성: {connection_name} → {mcp_endpoint}")
ensure_project_connection("conn-kb-hr-policy", "kb-hr-policy")
ensure_project_connection("conn-kb-product-manual", "kb-product-manual")
ensure_project_connection("conn-kb-finance-doc", "kb-finance-doc")
3–2. 에이전트 생성 (MCPToolDefinition으로 KB 연결)
azure-ai-projects v2.0.0b4에서는 PromptAgentDefinition으로 에이전트를 정의하고, agents.create_version()으로 등록합니다. 에이전트 호출은 OpenAI Responses API(openai_client.responses.create(model=에이전트이름))를 사용합니다.
import os
from azure.identity import DefaultAzureCredential
from azure.ai.projects import AIProjectClient
from azure.ai.projects.models import PromptAgentDefinition, MCPTool
project_client = AIProjectClient(
endpoint=os.environ["PROJECT_ENDPOINT"],
credential=DefaultAzureCredential(),
)
SEARCH_ENDPOINT = os.environ["AZURE_AI_SEARCH_ENDPOINT"]
MODEL = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
def mcp_url(kb_name):
return f"{SEARCH_ENDPOINT.rstrip('/')}/knowledgebases/{kb_name}/mcp"
def mcp_tool(kb_name):
return MCPTool(
server_label=f"conn-{kb_name}",
server_url=mcp_url(kb_name),
)
COMMON_INSTRUCTIONS = """
당신은 사내 문서 도우미입니다.
사용자의 질문에 대해 지식 베이스에서 관련 내용을 찾아 답변합니다.
- 답변은 반드시 지식 베이스에 있는 내용을 근거로 합니다.
- 지식 베이스에 없는 내용은 "해당 내용은 보유한 문서에서 찾을 수 없습니다"라고 안내합니다.
- 출처(문서 이름, 페이지)를 가능한 한 함께 제공합니다.
- 민감한 정보는 요약해서 제공하고, 전문을 그대로 복사하지 않습니다.
"""
# 일반 직원용 에이전트: HR KB만 접근
project_client.agents.create_version(
agent_name="doc-qa-agent-employee",
definition=PromptAgentDefinition(
model=MODEL,
instructions=COMMON_INSTRUCTIONS,
tools=[mcp_tool("kb-hr-policy")],
),
)
# 개발/영업팀 에이전트: HR + 제품 KB
project_client.agents.create_version(
agent_name="doc-qa-agent-engineering",
definition=PromptAgentDefinition(
model=MODEL,
instructions=COMMON_INSTRUCTIONS,
tools=[mcp_tool("kb-hr-policy"), mcp_tool("kb-product-manual")],
),
)
# 재무팀 에이전트: 전체 KB 접근
project_client.agents.create_version(
agent_name="doc-qa-agent-finance",
definition=PromptAgentDefinition(
model=MODEL,
instructions=COMMON_INSTRUCTIONS,
tools=[
mcp_tool("kb-hr-policy"),
mcp_tool("kb-product-manual"),
mcp_tool("kb-finance-doc"),
],
),
)
print("에이전트 3개 생성 완료")
print(" doc-qa-agent-employee / doc-qa-agent-engineering / doc-qa-agent-finance")
에이전트 이름을 환경 변수 또는 설정 파일에 저장해 둡니다. 에이전트는 한 번 생성하면 재사용합니다.
에이전트를 호출할 때는 에이전트 이름을 model 파라미터로 전달합니다:
openai_client = project_client.get_openai_client()
response = openai_client.responses.create(
model="doc-qa-agent-employee", # 에이전트 이름
input="연차 휴가가 며칠인가요?",
)
print(response.output_text)
Note: MCPTool의 server_label은 프로젝트 연결 이름과 일치해야 합니다. server_url은 KB의 MCP 엔드포인트(https://<search>.search.windows.net/knowledgebases/<kb-name>/mcp)입니다.
4단계: APIM 정책 구성
APIM에 두 개의 API를 등록합니다.

-
에이전트 API — 사용자가 호출하는 엔드포인트. JWT 검증, Rate Limit, 감사 로그 담당.
-
LLM API — 에이전트 서비스가 내부적으로 LLM을 호출할 때 경유. 토큰 제한, Content Safety 담당.
API 1: 에이전트 API (패턴 B)
APIM 인바운드 정책:
<policies>
<inbound>
<base />
<!-- 1. Entra ID JWT 검증 -->
<validate-jwt
header-name="Authorization"
failed-validation-httpcode="401"
failed-validation-error-message="유효하지 않은 토큰입니다."
require-expiration-time="true"
require-signed-tokens="true"
>
<openid-config url="https://login.microsoftonline.com/{{tenant-id}}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>{{entra-app-client-id}}</audience>
</audiences>
<issuers>
<issuer>https://sts.windows.net/{{tenant-id}}/</issuer>
<issuer>https://login.microsoftonline.com/{{tenant-id}}/v2.0</issuer>
</issuers>
<required-claims>
<claim name="roles" match="any">
<value>Employee</value>
<value>Finance</value>
<value>Engineering</value>
</claim>
</required-claims>
</validate-jwt>
<!-- 2. JWT에서 사용자 정보 추출 -->
<set-variable name="user-id"
value="@(context.Request.Headers.GetValueOrDefault("Authorization","")
.Split(' ').LastOrDefault()
.AsJwt()?.Claims.GetValueOrDefault("oid", "unknown"))" />
<set-variable name="user-roles"
value="@(context.Request.Headers.GetValueOrDefault("Authorization","")
.Split(' ').LastOrDefault()
.AsJwt()?.Claims.GetValueOrDefault("roles", "Employee"))" />
<!-- 3. 사용자별 일일 Rate Limit -->
<rate-limit-by-key
calls="100"
renewal-period="86400"
counter-key="@((string)context.Variables["user-id"])"
remaining-calls-header-name="x-ratelimit-remaining"
retry-after-header-name="x-ratelimit-reset"
/>
<!-- 4. Correlation ID 생성 -->
<set-variable name="correlation-id"
value="@(Guid.NewGuid().ToString())" />
<set-header name="x-correlation-id" exists-action="override">
<value>@((string)context.Variables["correlation-id"])</value>
</set-header>
<!-- 5. 역할에 따른 에이전트 ID 라우팅 -->
<set-variable name="target-agent-id" value="@{
var roles = (string)context.Variables["user-roles"];
if (roles.Contains("Finance")) return "{{agent-id-finance}}";
if (roles.Contains("Engineering")) return "{{agent-id-engineering}}";
return "{{agent-id-employee}}";
}" />
<!-- 6. 에이전트 서비스로 라우팅 -->
<set-backend-service
base-url="{{foundry-project-endpoint}}" />
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
<set-header name="x-correlation-id" exists-action="override">
<value>@((string)context.Variables["correlation-id"])</value>
</set-header>
</outbound>
<on-error>
<base />
</on-error>
</policies>
감사 로그 정책 (별도 log-to-eventhub 또는 Application Insights 설정):
<!-- 에이전트 API 호출 감사 로그 -->
<log-to-eventhub logger-id="appinsights-logger" partition-id="0">
@{
return new JObject(
new JProperty("timestamp", DateTime.UtcNow.ToString("o")),
new JProperty("correlation_id", (string)context.Variables["correlation-id"]),
new JProperty("user_id", (string)context.Variables["user-id"]),
new JProperty("user_roles", (string)context.Variables["user-roles"]),
new JProperty("target_agent", (string)context.Variables["target-agent-id"]),
new JProperty("client_ip", context.Request.IpAddress),
new JProperty("http_method", context.Request.Method),
new JProperty("request_url", context.Request.Url.ToString()),
new JProperty("response_code", context.Response.StatusCode),
new JProperty("response_time_ms", context.Elapsed.TotalMilliseconds)
).ToString();
}
</log-to-eventhub>
API 2: LLM API (패턴 A, BYO AI Gateway)
7편에서 다룬 BYO AI Gateway 정책을 그대로 사용합니다. 핵심 정책만 요약하면:
<inbound>
<!-- Managed Identity로 Azure OpenAI 인증 -->
<authentication-managed-identity
resource="https://cognitiveservices.azure.com"
output-token-variable-name="msi-access-token"
/>
<set-header name="Authorization" exists-action="override">
<value>@("Bearer " + (string)context.Variables["msi-access-token"])</value>
</set-header>
<!-- 에이전트별 토큰 제한 -->
<azure-openai-token-limit
counter-key="@(context.Request.Headers.GetValueOrDefault("x-agent-id", "unknown"))"
tokens-per-minute="60000"
estimate-prompt-tokens="false"
remaining-tokens-header-name="x-remaining-tokens"
remaining-tokens-variable-name="remainingTokens"
/>
<!-- Content Safety -->
<llm-content-safety
backend-id="content-safety-backend"
shield-prompt="true"
hatred-threshold="4"
violence-threshold="4"
sexual-threshold="4"
self-harm-threshold="4"
/>
<!-- 토큰 메트릭 기록 -->
<llm-emit-metric />
<set-backend-service backend-id="azure-openai-backend" />
</inbound>
5단계: 클라이언트 코드
사용자가 Entra ID로 인증하고, APIM을 통해 에이전트에 질문을 보내는 전체 흐름입니다.

인증 토큰 발급 (MSAL)
실제 서비스에서는 프론트엔드 앱이 브라우저 인증 흐름(Authorization Code Flow)을 통해 토큰을 발급받습니다. 여기서는 데몬/백엔드 테스트용 클라이언트 자격 증명 흐름을 사용합니다.
import os
import msal
def get_access_token() -> str:
"""Entra ID에서 액세스 토큰 발급 (클라이언트 자격 증명 흐름)"""
tenant_id = os.environ["ENTRA_TENANT_ID"]
client_id = os.environ["ENTRA_CLIENT_ID"]
client_secret = os.environ["ENTRA_CLIENT_SECRET"]
app_client_id = os.environ["ENTRA_APP_CLIENT_ID"] # 앱 등록한 ID
authority = f"https://login.microsoftonline.com/{tenant_id}"
app = msal.ConfidentialClientApplication(
client_id=client_id,
authority=authority,
client_credential=client_secret,
)
scopes = [f"api://{app_client_id}/.default"]
result = app.acquire_token_for_client(scopes=scopes)
if "access_token" not in result:
raise RuntimeError(f"토큰 발급 실패: {result.get('error_description')}")
return result["access_token"]
APIM을 통한 에이전트 호출
APIM이 앞에 있으므로, 클라이언트는 Foundry 에이전트 엔드포인트 대신 APIM 엔드포인트를 호출합니다. APIM이 JWT를 검증하고 적절한 에이전트로 라우팅합니다.
import os
import time
import requests
from azure.identity import DefaultAzureCredential
from azure.ai.agents import AgentsClient
from azure.ai.agents.models import MessageRole
APIM_ENDPOINT = os.environ["APIM_ENDPOINT"]
APIM_SUBSCRIPTION_KEY = os.environ["APIM_SUBSCRIPTION_KEY"]
PROJECT_ENDPOINT = os.environ["PROJECT_ENDPOINT"]
def ask_doc_qa(question: str, access_token: str) -> dict:
"""
사내 문서 질의 시스템에 질문을 보내고 답변을 받습니다.
흐름:
1. APIM에 Thread 생성 요청 → APIM이 JWT 검증 후 Foundry로 프록시
2. Thread에 메시지 추가
3. Run 생성 (APIM이 역할에 맞는 에이전트 ID를 결정)
4. Run 완료까지 대기
5. 답변 반환
"""
headers = {
"Authorization": f"Bearer {access_token}",
"Ocp-Apim-Subscription-Key": APIM_SUBSCRIPTION_KEY,
"Content-Type": "application/json",
}
# APIM을 통해 Foundry 에이전트 REST API 직접 호출
# APIM 정책에서 JWT의 역할에 따라 에이전트 ID 결정
base_url = f"{APIM_ENDPOINT}/agents/v1.0"
# 1. Thread 생성
thread_resp = requests.post(
f"{base_url}/threads",
headers=headers,
json={},
)
thread_resp.raise_for_status()
thread_id = thread_resp.json()["id"]
# 2. 사용자 메시지 추가
requests.post(
f"{base_url}/threads/{thread_id}/messages",
headers=headers,
json={"role": "user", "content": question},
).raise_for_status()
# 3. Run 생성 (APIM이 x-target-agent-id 헤더로 에이전트 ID 주입)
run_resp = requests.post(
f"{base_url}/threads/{thread_id}/runs",
headers=headers,
json={
"assistant_id": "{{placeholder}}", # APIM 정책에서 실제 에이전트 ID로 교체됨
},
)
run_resp.raise_for_status()
run_id = run_resp.json()["id"]
# 4. Run 완료 대기 (폴링)
max_wait = 120 # 초
interval = 2
elapsed = 0
status = "queued"
while status in ("queued", "in_progress", "requires_action") and elapsed < max_wait:
time.sleep(interval)
elapsed += interval
run_status_resp = requests.get(
f"{base_url}/threads/{thread_id}/runs/{run_id}",
headers=headers,
)
run_status_resp.raise_for_status()
run_data = run_status_resp.json()
status = run_data["status"]
if status != "completed":
raise RuntimeError(f"Run이 완료되지 않았습니다. 상태: {status}")
# 5. 메시지 목록에서 에이전트 답변 추출
messages_resp = requests.get(
f"{base_url}/threads/{thread_id}/messages",
headers=headers,
)
messages_resp.raise_for_status()
messages = messages_resp.json()["data"]
# 가장 최근의 assistant 메시지가 답변
answer_text = ""
for msg in messages:
if msg["role"] == "assistant":
for content_block in msg["content"]:
if content_block["type"] == "text":
answer_text = content_block["text"]["value"]
break
break
return {
"thread_id": thread_id,
"run_id": run_id,
"answer": answer_text,
"status": status,
}
if __name__ == "__main__":
token = get_access_token()
question = "연차 유급휴가 신청 절차가 어떻게 되나요?"
result = ask_doc_qa(question, token)
print(f"질문: {question}")
print(f"답변: {result['answer']}")
print(f"Thread ID: {result['thread_id']}")
Python SDK 방식 (OpenAI Responses API)
APIM REST 프록시 방식 대신, azure-ai-projects SDK에서 OpenAI Responses API로 에이전트를 직접 호출할 수도 있습니다. 에이전트 이름을 model 파라미터로 전달하면 됩니다.
import os
from azure.identity import DefaultAzureCredential
from azure.ai.projects import AIProjectClient
project_client = AIProjectClient(
endpoint=os.environ["PROJECT_ENDPOINT"],
credential=DefaultAzureCredential(),
)
openai_client = project_client.get_openai_client()
def select_agent_by_role(role: str) -> str:
"""역할에 따라 에이전트 이름 반환"""
role_to_agent = {
"Finance": "doc-qa-agent-finance",
"Engineering": "doc-qa-agent-engineering",
"Employee": "doc-qa-agent-employee",
}
return role_to_agent.get(role, "doc-qa-agent-employee")
def ask_with_sdk(question: str, user_role: str) -> str:
"""OpenAI Responses API를 사용한 에이전트 호출"""
agent_name = select_agent_by_role(user_role)
response = openai_client.responses.create(
model=agent_name,
input=question,
)
# 토큰 사용량 로깅 (4편 관측성 패턴)
if response.usage:
print(f"[사용량] 입력: {response.usage.input_tokens}, "
f"출력: {response.usage.output_tokens}, "
f"합계: {response.usage.input_tokens + response.usage.output_tokens}")
return response.output_text or "답변을 가져오지 못했습니다."
if __name__ == "__main__":
# 재무팀 직원 시나리오
answer = ask_with_sdk(
question="작년 4분기 예산 집행 현황이 어떻게 되나요?",
user_role="Finance",
)
print(f"답변: {answer}")
# 멀티턴 대화 예시
response1 = openai_client.responses.create(
model="doc-qa-agent-employee",
input="연차 휴가 일수가 몇 일인가요?",
)
response2 = openai_client.responses.create(
model="doc-qa-agent-employee",
input="그중 이월 가능한 일수는?",
previous_response_id=response1.id, # 이전 대화 연결
)
print(f"후속 답변: {response2.output_text}")
6단계: 관측성 — 운영 모니터링 구성

Application Insights에서 에이전트 트레이스 활성화
4편(관측성)에서 다룬 설정을 적용합니다.
import os
from azure.monitor.opentelemetry import configure_azure_monitor
configure_azure_monitor(
connection_string=os.environ["APPLICATIONINSIGHTS_CONNECTION_STRING"],
enable_live_metrics=True,
)
from opentelemetry.sdk.trace import TracerProvider
from azure.ai.agents.telemetry import enable_telemetry
enable_telemetry(destination=TracerProvider())
주요 KQL 쿼리
1. 일별 사용자별 호출 현황:
customEvents
| where name == "AgentRun"
| extend userId = tostring(customDimensions["user_id"])
| extend userRole = tostring(customDimensions["user_role"])
| summarize calls = count(), totalTokens = sum(toint(customDimensions["total_tokens"]))
by userId, userRole, bin(timestamp, 1d)
| order by timestamp desc, calls desc
2. 느린 Run 탐지 (3초 이상):
dependencies
| where type == "AgentRun"
| where duration > 3000
| project timestamp, name, duration, resultCode,
userId = tostring(customDimensions["user_id"]),
correlationId = tostring(customDimensions["correlation_id"])
| order by duration desc
3. APIM 감사 로그: 누가 무엇을 요청했는가:
ApiManagementGatewayLogs
| where TimeGenerated > ago(24h)
| extend userId = tostring(parse_json(RequestBody)["user_id"])
| extend userRole = tostring(parse_json(RequestBody)["user_role"])
| summarize requestCount = count() by userId, userRole, bin(TimeGenerated, 1h)
| order by TimeGenerated desc
4. Knowledge Base 검색 히트율 (출처 파악):
traces
| where message contains "file_search"
| extend kbId = tostring(customDimensions["knowledge_base_id"])
| summarize searches = count() by kbId, bin(timestamp, 1d)
| order by timestamp desc
5. 비용 추정 (일별):
customMetrics
| where name in ("PromptTokens", "CompletionTokens")
| summarize
promptTokens = sumif(value, name == "PromptTokens"),
completionTokens = sumif(value, name == "CompletionTokens")
by bin(timestamp, 1d)
| extend
promptCost = promptTokens / 1000000.0 * 2.5, // GPT-4o 기준 (USD/1M tokens)
completionCost = completionTokens / 1000000.0 * 10.0
| extend totalCostUSD = promptCost + completionCost
| order by timestamp desc
7단계: 전체 동작 흐름 확인
전체 시스템이 올바르게 연결됐는지 확인하는 시나리오 3개입니다.

시나리오 1: 일반 직원이 HR 정책 질문
사용자: 김철수 (역할: Employee)
질문: “출산 전후 휴가 기간이 얼마나 되나요?”
예상 흐름:
- APIM → JWT 검증 통과 (roles: [“Employee”])
- APIM → rate-limit 확인 (오늘 5회/100회)
- APIM → agent-id-employee로 라우팅
- 에이전트 → kb-hr-policy에서 검색
- 답변: “출산 전후 휴가는 법정 90일(한 번에 사용 시)이며, 회사 정책에 따라 추가 10일이 부여됩니다. (출처: HR_정책집_2025.pdf, 12페이지)”
확인: APIM 로그에 user-id=김철수, target-agent=employee 기록됨
시나리오 2: 재무팀 직원이 재무 문서 질문
사용자: 이재무 (역할: Finance)
질문: “작년 3분기 마케팅 예산 집행률은?”
예상 흐름:
- APIM → JWT 검증 통과 (roles: [“Finance”])
- APIM → agent-id-finance로 라우팅
- 에이전트 → kb-finance-doc에서 검색
- 답변: “2024년 3분기 마케팅 예산 집행률은 87.3%입니다. (출처: 2024Q3_예산보고서.xlsx)”
확인: agent-id-employee로 라우팅 시 kb-finance-doc 접근 불가 → 답변 없음
시나리오 3: Rate Limit 초과
사용자: 박개발 (역할: Engineering)
상황: 오늘 이미 100회 호출
예상 흐름:
- APIM → JWT 검증 통과
- APIM → rate-limit-by-key → 429 Too Many Requests
Response Headers:
x-ratelimit-remaining: 0
x-ratelimit-reset: 내일 자정까지 남은 초
- 에이전트에는 요청이 도달하지 않음
확인: APIM 로그에 response_code=429 기록됨
운영 고려사항
비용 관리
이 시스템에서 비용이 발생하는 지점은 세 곳입니다.

Agentic Retrieval은 검색 1회당 추가 LLM 호출(검색 계획 수립)이 발생합니다. 단순한 키워드 조회 수준의 질문이 많다면 retrieval_reasoning_effort=”low”로 설정하거나, Foundry IQ 대신 Azure AI Search 직접 연결을 검토하세요.

문서 업데이트 반영
Blob Storage에 새 문서를 올리거나 기존 문서를 수정하면 Knowledge Base 인덱스가 자동 갱신됩니다(Foundry IQ 기본 설정). 갱신 주기는 Knowledge Base 설정에서 조정할 수 있습니다. 즉시 반영이 필요하면 포털에서 수동으로 인덱싱을 트리거하세요.
에이전트 프롬프트 업데이트
에이전트 인스트럭션을 바꾸면 기존 에이전트를 업데이트(client.update_agent())하거나, 새 에이전트를 만들고 환경 변수의 에이전트 ID를 교체합니다. 후자가 롤백이 간단하므로 프로덕션에서는 새 에이전트 생성 방식을 권장합니다.
# 에이전트 인스트럭션 업데이트 예시
updated_agent = client.update_agent(
agent_id=os.environ["AGENT_ID_EMPLOYEE"],
instructions=NEW_INSTRUCTIONS,
)
접근 권한 변경
부서 이동이나 퇴직으로 인한 역할 변경은 Entra ID에서만 처리하면 됩니다. 에이전트 코드나 APIM 정책을 수정할 필요가 없습니다. JWT의 roles 클레임이 자동으로 업데이트됩니다.
이 시리즈에서 다룬 요소와의 연결
이 E2E 시스템에 적용된 기술 요소를 시리즈별로 매핑하면 다음과 같습니다.

정리
이번 글에서는 이 시리즈에서 다룬 요소들을 하나의 “사내 문서 질의 시스템”으로 통합했습니다.
아키텍처 핵심 원칙 세 가지:

-
접근 제어는 두 레이어에서: Entra ID(JWT)로 사용자를 인증하고, APIM 정책에서 역할에 따라 에이전트를 분리합니다. 에이전트 레벨에서 Knowledge Base 접근 범위를 한 번 더 제한합니다.
-
감사 로그는 APIM에서: 에이전트가 답변하기 전, APIM 레벨에서 누가 무엇을 요청했는지를 기록합니다. 에이전트 코드와 분리되므로 로그 누락 위험이 없습니다.
-
비용은 에이전트별로 추적: run.usage를 에이전트 실행마다 기록하고, APIM에서 토큰 상한을 에이전트 ID 기준으로 설정합니다. 어느 부서, 어느 에이전트에서 비용이 발생하는지 파악할 수 있습니다.
에이전트를 실제 운영 환경에 올릴 때 “보안을 어떻게 할지”, “누가 무엇에 접근하는지”, “비용을 어떻게 통제할지” 고민이 생긴다면, 이 아키텍처 패턴이 출발점이 될 수 있습니다.
참고
메타데이터
- post_id
- 3aaff2445bdd
- slug
- 실무-시나리오-e2e-사내-문서-질의-시스템-만들기-9편-3aaff2445bdd
- url
- https://medium.com/@junghwanbae/%EC%8B%A4%EB%AC%B4-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4-e2e-%EC%82%AC%EB%82%B4-%EB%AC%B8%EC%84%9C-%EC%A7%88%EC%9D%98-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-9%ED%8E%B8-3aaff2445bdd
- canonical_url
- https://medium.com/@junghwanbae/%EC%8B%A4%EB%AC%B4-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4-e2e-%EC%82%AC%EB%82%B4-%EB%AC%B8%EC%84%9C-%EC%A7%88%EC%9D%98-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-9%ED%8E%B8-3aaff2445bdd
- author_url
- https://medium.com/@junghwanbae
- status
- ok
- fetched_at
- 2026-06-14 17:09:17