AWS Bedrock을 라우터로만 쓰고 Tencent VOD Text LLM으로 답변하는 챗봇 만들기
들어가기 전에
AWS에서 AI 챗봇을 만든다고 하면 보통 Bedrock을 떠올립니다. Claude, Nova, Llama 같은 모델을 Bedrock에서 호출하고, Lambda나 API Gateway를 붙여서 서비스화하는 구조입니다. 가장 정석적인 방향입니다.
그런데 이번에는 조금 다르게 해봤습니다.
Bedrock은 답변을 생성하지 않습니다. Bedrock은 오직 "이 질문을 어떤 모델에 보낼지" 판단하는 라우터 역할만 합니다. 실제 답변 생성은 Tencent Cloud VOD Text LLM API가 담당합니다.
구조만 보면 약간 이상해 보일 수 있습니다. AWS 안에 Bedrock이라는 모델 서비스가 있는데 왜 굳이 외부 LLM을 부르냐는 생각이 먼저 듭니다. 그런데 실무적으로 보면 꽤 의미가 있습니다. AWS는 호스팅과 라우팅, API 노출 같은 껍데기를 담당하고, 무거운 추론 비용은 외부 LLM 쪽으로 빼는 구조입니다.
이번 글에서는 자판기(Japangi)라는 간단한 웹 챗봇을 만들면서 이 구조를 끝까지 배포해봤습니다. 그리고 중요한 건, 이론대로 한 번에 잘 되지 않았다는 점입니다. IAM에서 막히고, Bedrock inference profile에서 막히고, Lambda Function URL은 조직 정책에 막혔습니다. 역시 실습은 PPT처럼 얌전하게 흘러가지 않습니다. 원래 이런 건 콘솔 켜는 순간 물리법칙이 바뀝니다.
[스크린샷: 최종 자판기 챗봇 UI]
무엇을 만들었나
최종 결과물은 일반적인 웹 챗봇입니다. 사용자가 브라우저에서 질문을 입력하면 API Gateway를 거쳐 Lambda가 호출되고, Lambda가 질문을 분류한 뒤 적절한 VOD Text LLM 모델로 요청을 보냅니다.
핵심 아이디어는 이렇습니다.
AWS Bedrock은 질문 분류만 하고, 실제 답변 추론은 Tencent Cloud VOD Text LLM API가 담당합니다.
이 구조에서 Bedrock은 무거운 답변 생성 모델이 아니라 라우터입니다. Nova Micro 같은 가벼운 모델에게 질문을 던지고, casual, coding, complex, general 중 하나로 분류하게 합니다. 그리고 분류 결과에 따라 VOD Text LLM의 모델을 선택합니다.
이렇게 하면 AWS 쪽 비용은 거의 라우팅과 호스팅 비용만 남습니다. 실제 토큰을 많이 쓰는 답변 생성은 VOD Text LLM API가 처리합니다. ENABLE_BEDROCK_ROUTER=false로 두면 Bedrock조차 호출하지 않고 정규식 기반 룰로 분류할 수 있으니, AWS 추론 비용은 완전히 0에 가깝게 만들 수도 있습니다.
전체 아키텍처
최종 구조는 아래처럼 잡았습니다.
사용한 AWS 리소스는 아래 정도입니다.
| 서비스 | 리소스 | 역할 |
|---|---|---|
| Lambda | japangi-chat-orchestrator | 라우팅과 VOD LLM 호출을 담당하는 오케스트레이터 |
| API Gateway | HTTP API, POST /chat | 브라우저 요청을 Lambda로 전달 |
| IAM | Lambda 실행 Role, Bedrock 호출 정책 | Lambda 실행 권한과 Bedrock 호출 권한 |
| CloudWatch Logs | /aws/lambda/japangi-chat-orchestrator | Lambda 로그 저장 |
| S3 | japangi-frontend-<accountId> | 정적 프론트엔드 파일 저장 |
| CloudFront | Distribution + OAC | HTTPS CDN, S3 비공개 접근 |
| Bedrock | Nova Micro inference profile | 질문 분류용 라우터 |
여기서 중요한 건 S3를 공개 버킷으로 열지 않았다는 점입니다. S3는 비공개로 두고 CloudFront OAC만 접근하게 했습니다. 데모라고 버킷을 public으로 열어두면 당장은 편한데, 나중에 보안 리뷰에서 모두가 조용해집니다.
STEP 1. 준비물
필요한 것은 많지 않습니다.
| 항목 | 설명 |
|---|---|
| AWS 계정 | Lambda, API Gateway, S3, CloudFront, Bedrock 사용 |
| AWS CLI | 권한 확인, 배포 검증, CloudFront invalidation |
| Terraform | 전체 인프라 배포 |
| Tencent VOD Text LLM API 토큰 | https://text-aigc.vod-qcloud.com/v1 호출용 |
| Node.js | Lambda 코드 문법 확인용. 배포 자체는 Terraform이 처리 |
토큰이나 키는 절대 코드에 직접 넣지 않습니다. 예제에서는 전부 <YOUR_...> 형태로 적습니다.
export AWS_ACCESS_KEY_ID="<YOUR_AWS_ACCESS_KEY_ID>"
export AWS_SECRET_ACCESS_KEY="<YOUR_AWS_SECRET_ACCESS_KEY>"
export AWS_DEFAULT_REGION="ap-northeast-2"
export AWS_PAGER=""AWS_PAGER=""는 별거 아닌 것 같지만 은근 중요합니다. AWS CLI가 less 같은 페이저를 띄우면 자동화 중에 멈춰 있는 것처럼 보입니다. 이거 처음 보면 CLI가 죽은 줄 알고 괜히 다른 명령어를 한 번 더 치게 됩니다.
STEP 2. IAM 권한에서 먼저 막힌다
처음 받은 IAM 사용자는 권한이 거의 없었습니다. S3 정도만 가능하고 Lambda, IAM, API Gateway, CloudFront, Bedrock은 전부 AccessDenied가 났습니다.
더 귀찮은 건 본인에게 권한을 추가하는 것도 막혀 있었다는 점입니다. iam:PutUserPolicy도 거부되면 사용자가 직접 해결할 방법이 없습니다. 이건 그냥 관리자에게 필요한 권한을 요청해야 하는 상황입니다.
데모 배포에 필요한 권한은 대략 아래 범위입니다.
| 권한 | 이유 |
|---|---|
lambda:* | Lambda 함수 생성, 업데이트, permission 연결 |
apigateway:* | HTTP API, route, stage 구성 |
cloudfront:* | Distribution, OAC 생성 |
s3:* | 프론트엔드 버킷, 객체 업로드, 버킷 정책 |
logs:* | CloudWatch Log Group 생성 |
iam:CreateRole, iam:PutRolePolicy, iam:AttachRolePolicy | Lambda 실행 Role 생성 |
iam:PassRole | Lambda에 실행 Role 전달 |
bedrock:InvokeModel, bedrock:ListFoundationModels | Bedrock Nova Micro 호출과 모델 확인 |
iam:PassRole은 특히 조심해야 합니다. 와일드카드로 열면 동작은 하지만 AWS가 보안 경고를 띄웁니다. 데모라면 넓게 열 수도 있지만, 운영 환경이면 iam:PassedToService 조건으로 Lambda에만 제한하는 게 맞습니다.
넓게 여는 데모용 예시는 아래와 같습니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"lambda:*",
"apigateway:*",
"cloudfront:*",
"s3:*",
"logs:*",
"bedrock:InvokeModel",
"bedrock:ListFoundationModels"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:DeleteRole",
"iam:GetRole",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PassRole"
],
"Resource": "*"
}
]
}조금 더 좁히면 iam:PassRole에는 조건을 붙입니다.
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::<YOUR_AWS_ACCOUNT_ID>:role/japangi-chat-orchestrator-role",
"Condition": {
"StringEquals": {
"iam:PassedToService": "lambda.amazonaws.com"
}
}
}IAM은 보통 실습의 첫 번째 벽입니다. 코드 한 줄 쓰기도 전에 권한부터 막힙니다. 클라우드 실습의 전통 같은 겁니다. 개발자 수명은 보통 여기서 1차로 깎입니다.
[스크린샷: IAM AccessDenied 또는 권한 확인 결과]
STEP 3. Bedrock 모델 액세스와 inference profile 함정
Bedrock 쪽에서도 함정이 하나 있었습니다.
예전에는 Bedrock 콘솔에 들어가서 Model access 메뉴에서 모델을 활성화하는 흐름이 있었습니다. 그런데 지금은 서버리스 foundation model의 경우 첫 호출 시 자동 활성화되는 방향으로 바뀌었습니다. 콘솔에서 모델이 자물쇠처럼 보여도, IAM에 bedrock:InvokeModel 권한이 있으면 호출이 가능할 수 있습니다.
문제는 서울 리전입니다.
서울 리전에서 amazon.nova-micro-v1:0을 직접 호출하면 아래 같은 오류를 만날 수 있습니다.
ValidationException: Invocation of model ID amazon.nova-micro-v1:0 with on-demand throughput isn't supported.처음 보면 권한 문제처럼 보이지만, 실제로는 호출 방식 문제입니다. 서울/APAC 환경에서는 on-demand model id를 직접 쓰는 대신 inference profile을 써야 합니다.
이번 실습에서는 아래 값을 사용했습니다.
apac.amazon.nova-micro-v1:0확인은 이렇게 합니다.
aws bedrock list-inference-profiles \
--region ap-northeast-2그리고 IAM Resource도 한 리전으로만 좁히면 또 터질 수 있습니다. APAC inference profile은 내부적으로 여러 리전의 foundation model로 라우팅될 수 있기 때문입니다. 그래서 리소스는 모델은 제한하되 리전은 와일드카드로 열었습니다.
{
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel"
],
"Resource": [
"arn:aws:bedrock:*:<YOUR_AWS_ACCOUNT_ID>:inference-profile/apac.amazon.nova-micro-v1:0",
"arn:aws:bedrock:*::foundation-model/amazon.nova-micro-v1:0"
]
}이게 이번 실습에서 가장 헷갈렸던 부분 중 하나입니다. 모델은 Nova Micro 하나만 쓰는데 ARN은 여러 리전을 고려해야 합니다. 표면상으론 간단한 모델 호출인데 내부 라우팅은 이미 다른 세계로 가 있는 상태입니다.
STEP 4. 질문 라우팅 설계
라우팅은 4개 카테고리로 나눴습니다.
| 카테고리 | 배정 모델 | 기준 |
|---|---|---|
casual | gemini-3.5-flash | 짧은 일상 대화, 인사, 단순 질문 |
coding | deepseek-v3.2 | 코드, 디버깅, 알고리즘, 에러 키워드 |
complex | cd-sonnet-4.6 | 긴 질문, 분석, 비교, 설계형 질문 |
general | gpt-5.1 | 그 외 범용 고난도 질문 |
설계 철학은 단순합니다.
싼 모델을 기본으로, 어려운 질문만 비싼 모델로 승급합니다.
모든 질문을 제일 비싼 모델에 보내면 품질은 좋을 수 있지만 비용이 아깝습니다. 반대로 전부 싼 모델에 보내면 복잡한 질문에서 품질이 떨어집니다. 그래서 질문을 먼저 가볍게 분류하고, 필요한 경우에만 더 강한 모델로 보냅니다.
라우팅 모드는 두 가지입니다.
| 모드 | 설명 | 비용 |
|---|---|---|
| Bedrock Nova Micro | Nova Micro가 {category, reason} JSON 반환 | 질문당 아주 적은 Bedrock 토큰 |
| Rule-based fallback | 정규식과 길이 기반 분류 | Bedrock 비용 0 |
Bedrock 호출이 실패하면 자동으로 룰 기반으로 떨어집니다. 데모 환경에서 권한이나 리전 문제로 Bedrock이 잠깐 막혀도 전체 챗봇이 죽지 않게 만든 장치입니다. 실제로는 이런 fallback이 더 중요합니다. AI보다 retry logic이 더 열심히 일하는 경우가 많습니다.
라우터 핵심 코드는 아래처럼 생겼습니다.
const ROUTER_MODEL_ID =
process.env.ROUTER_MODEL_ID || "apac.amazon.nova-micro-v1:0";
const ENABLE_BEDROCK =
(process.env.ENABLE_BEDROCK_ROUTER || "true").toLowerCase === "true";
const CODE_HINT =
/```|def\s|class\s|import\s|function\s|=>|;\s*$|\bSELECT\b|\bbug\b|에러|디버그|코드|버그|컴파일|exception|async|await/i;
const COMPLEX_HINT =
/왜|분석|비교|단계별|차이|전략|설계|아키텍처|장단점|평가|정리해|why|analyze|compare|design|architecture|trade.?off/i;
function ruleBased(text) {
if (CODE_HINT.test(text)) {
return ["coding", "코드/에러 관련 키워드가 감지되어 코딩 특화 모델로 배정했어요."];
}
if (text.length > 280) {
return ["complex", `질문이 길어(${text.length}자) 장문/정밀 분석 모델로 배정했어요.`];
}
if (COMPLEX_HINT.test(text)) {
return ["complex", "분석/비교/설계형 질문으로 판단되어 정밀 추론 모델로 배정했어요."];
}
if (text.length < 40) {
return ["casual", "짧고 가벼운 질문이라 빠른 기본 모델로 배정했어요."];
}
return ["general", "특정 유형에 안 걸리는 범용 질문이라 만능 모델로 배정했어요."];
}Bedrock 쪽은 ConverseCommand로 호출합니다.
const resp = await bedrock.send(
new ConverseCommand({
modelId: ROUTER_MODEL_ID,
system: [{ text: ROUTER_SYSTEM }],
messages: [{ role: "user", content: [{ text: text.slice(0, 1500) }] }],
inferenceConfig: { maxTokens: 120, temperature: 0 },
})
);temperature: 0으로 둔 이유는 라우팅 결과가 흔들리면 안 되기 때문입니다. 라우터가 기분 따라 coding과 general을 왔다 갔다 하면 운영자가 로그 보면서 인생을 되돌아보게 됩니다.
STEP 5. Lambda 백엔드 작성
백엔드는 Node.js 20 런타임으로 만들었습니다. 의존성은 0입니다.
Node.js 20에서는 fetch가 기본 내장되어 있고, Lambda 런타임에는 AWS SDK가 포함되어 있습니다. 그래서 node_modules 없이 소스 파일만 zip으로 묶어 배포할 수 있습니다. 이건 데모 배포에서는 꽤 편합니다. 의존성 패키징이 꼬이면 진짜 별것도 아닌 zip 하나 때문에 표정이 사라집니다.
파일 구성은 아래와 같습니다.
| 파일 | 역할 |
|---|---|
index.js | Lambda 핸들러. 요청 파싱, 라우팅, VOD 호출, 응답 반환 |
router.js | Bedrock Nova Micro 분류와 룰 기반 fallback |
vod.js | VOD Text LLM API 호출 |
models.js | 모델 카탈로그와 카테고리별 매핑 |
Lambda 핸들러는 오케스트레이터에 가깝습니다.
exports.handler = async (event) => {
const method = event?.requestContext?.http?.method || "POST";
if (method === "OPTIONS") return resp(200, { ok: true });
let body = {};
try {
const raw = event.isBase64Encoded
? Buffer.from(event.body || "", "base64").toString("utf-8")
: event.body || "{}";
body = JSON.parse(raw);
} catch (_) {
return resp(400, { error: "invalid JSON body" });
}
const question = (body.message || "").trim;
if (!question) return resp(400, { error: "message is required" });
const routing = await route(question);
const meta = modelMeta(routing.model);
let answer;
try {
answer = await callVod(routing.model, meta.protocol, question);
} catch (e) {
return resp(502, {
error: "VOD API 호출 실패",
detail: String(e?.message || e).slice(0, 500),
model: routing.model,
});
}
return resp(200, {
answer,
model_id: routing.model,
model_label: meta.label,
blurb: meta.blurb,
category: routing.category,
reason: routing.reason,
router: routing.router,
});
};응답에는 답변뿐 아니라 모델명과 카테고리, 선정 이유도 같이 내려줍니다. 프론트에서는 이 값을 배지처럼 보여줍니다.
[스크린샷: 응답모델, 카테고리, 선정 이유가 표시된 UI]
STEP 6. VOD Text LLM API 호출
Tencent VOD Text LLM API는 OpenAI 호환 엔드포인트와 Anthropic 호환 엔드포인트를 같이 사용할 수 있습니다.
Base URL은 아래처럼 잡았습니다.
https://text-aigc.vod-qcloud.com/v1OpenAI 스타일 모델은 /chat/completions로 보냅니다.
async function callOpenAI(model, userText) {
const resp = await fetch(`${VOD_BASE}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${VOD_TOKEN}`,
},
body: JSON.stringify({
model,
stream: false,
max_tokens: 1500,
temperature: 0.7,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userText },
],
}),
});
await ensureOk(resp);
const json = await resp.json;
return json.choices?.[0]?.message?.content || "(빈 응답)";
}Anthropic 스타일 모델은 /messages로 보냅니다.
async function callAnthropic(model, userText) {
const resp = await fetch(`${VOD_BASE}/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": VOD_TOKEN,
},
body: JSON.stringify({
model,
stream: false,
max_tokens: 1500,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: [{ type: "text", text: userText }] }],
}),
});
await ensureOk(resp);
const json = await resp.json;
return (json.content || )
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("") || "(빈 응답)";
}이 구조의 장점은 모델별 프로토콜 차이를 vod.js 안에 숨길 수 있다는 점입니다. Lambda 핸들러는 그냥 callVod(model, protocol, question)만 호출하면 됩니다. 실제 서비스에서는 여기서 모델별 파라미터 화이트리스트를 두는 게 좋습니다. 모델마다 허용하는 옵션이 다르기 때문입니다.
STEP 7. Terraform으로 인프라 코드화
처음에는 콘솔에서 하나씩 만들 수도 있습니다. 그런데 Lambda, API Gateway, S3, CloudFront, IAM을 손으로 만들기 시작하면 나중에 무엇을 눌렀는지 기억이 안 납니다. 클라우드 콘솔 수공업은 처음엔 빠른데, 두 번째 재현부터 인간성이 사라집니다.
그래서 Terraform으로 전체 인프라를 코드화했습니다.
구성은 크게 네 덩어리입니다.
| Terraform 구성 | 내용 |
|---|---|
main.tf | provider, region, account, 공통 local |
backend.tf | Lambda, IAM, CloudWatch Logs, API Gateway |
frontend.tf | S3, CloudFront, OAC, 프론트 파일 업로드 |
variables.tf, outputs.tf | 입력 변수와 출력값 |
배포 명령은 단순합니다.
cd japangi-chatbot/terraform
terraform init
terraform apply -var "vod_api_token=<YOUR_VOD_TOKEN>"VOD 토큰은 Terraform 변수에서 sensitive = true로 처리합니다.
variable "vod_api_token" {
description = "VOD Text LLM API Bearer Token"
type = string
sensitive = true
}Lambda에는 환경변수로 전달합니다.
environment {
variables = {
VOD_API_TOKEN = var.vod_api_token
VOD_BASE_URL = var.vod_base_url
ROUTER_MODEL_ID = var.router_model_id
ENABLE_BEDROCK_ROUTER = var.enable_bedrock_router
}
}프론트엔드는 S3에 업로드하고, config.js는 Terraform이 API Gateway 주소를 넣어 동적으로 만듭니다.
resource "aws_s3_object" "config" {
bucket = aws_s3_bucket.frontend.id
key = "config.js"
content_type = "application/javascript"
content = <<-EOT
window.JAPANGI_CONFIG = {
API_URL: "${local.api_url}",
MOCK_MODE: false
};
EOT
}이게 은근 편합니다. API Gateway 주소는 배포할 때마다 달라질 수 있는데, 프론트 코드에 하드코딩하지 않아도 됩니다. 배포 끝나면 config.js가 자동으로 맞춰집니다.
배포 후 출력값은 아래처럼 확인합니다.
terraform output예상 출력은 이런 식입니다.
chat_api_url = "https://<api-id>.execute-api.ap-northeast-2.amazonaws.com/prod/chat"
cloudfront_url = "https://<distribution>.cloudfront.net"
frontend_bucket = "japangi-frontend-<accountId>"
cloudfront_distribution_id = "<distribution-id>"[터미널 출력 캡처: terraform apply 완료 후 output]
STEP 8. Lambda Function URL 스트리밍 시도와 실패
처음 설계는 API Gateway가 아니었습니다. Lambda Function URL과 RESPONSE_STREAM을 써서 토큰이 실시간으로 흘러나오는 스트리밍 응답을 만들려고 했습니다.
그런데 계속 403 Forbidden이 났습니다.
authorization_type=NONE으로 해도 안 되고, SigV4로 서명해도 안 됐습니다. 일반적인 IAM 문제라면 정책을 고치면 되는데, 이번에는 그게 아니었습니다.
원인은 조직 정책(SCP)이었습니다. 계정 상위 조직에서 Lambda Function URL 사용을 막고 있었습니다. 이건 개별 IAM 사용자나 Terraform 코드에서 해결할 수 있는 영역이 아닙니다. 개발자가 아무리 로그를 봐도 못 푸는 타입입니다. 개발자 수명 갈아넣어도 조직 정책은 안 녹습니다.
그래서 방향을 바꿨습니다.
| 원래 계획 | 변경 후 |
|---|---|
| Lambda Function URL + RESPONSE_STREAM | API Gateway HTTP API |
| 토큰 스트리밍 가능 | 응답 버퍼링 |
| Function URL SCP에 막힘 | 일반적인 API Gateway 경로 |
| 더 자연스러운 챗봇 UX | 29초 timeout 고려 필요 |
API Gateway HTTP API로 전환하면서 스트리밍은 포기했습니다. 대신 배포 안정성을 선택했습니다.
여기서 중요한 trade-off가 생깁니다. API Gateway는 응답을 버퍼링하고, timeout도 짧습니다. HTTP API는 29초 제한을 고려해야 합니다. 즉, 느린 모델은 라인업에서 빼거나 파라미터를 조정해야 합니다.
STEP 9. 모델 속도와 reasoning_effort 함정
API Gateway 29초 제한 때문에 모델별 응답 속도를 실제로 재봤습니다.
| 모델 | 관측 응답 시간 | 판단 |
|---|---|---|
gemini-3.5-flash | 약 4초 | OK |
gpt-5.1 | 약 5~6초 | OK |
cd-sonnet-4.6 | 약 11~23초 | OK, 그래도 여유는 많지 않음 |
deepseek-v3.2 | 초기 약 26초, 조정 후 8~18초 | 조정 후 OK |
glm-5.1 | 약 50초 | API Gateway 구조에서는 보류 |
처음에는 deepseek-v3.2도 아슬아슬했습니다. 그런데 파라미터를 보니 reasoning_effort가 문제였습니다.
VOD API에서 reasoning_effort는 none, low, medium, high 같은 값만 허용합니다. minimal 같은 값을 보내면 400 에러가 납니다.
400 literal_error: reasoning_effort must be one of none, low, medium, high그리고 deepseek 쪽은 reasoning_effort를 아예 빼는 편이 더 안정적으로 들어왔습니다. 조정 후에는 8~18초 정도로 응답이 들어오면서 API Gateway 29초 제한 안에 들어왔습니다.
이건 꽤 중요한 포인트입니다. OpenAI 호환 API라고 해서 모든 모델이 같은 파라미터를 똑같이 받아준다는 뜻은 아닙니다. 겉으로는 같은 /chat/completions인데 내부 모델마다 옵션 허용 범위가 다릅니다. 여기서 대충 공통 파라미터를 밀어 넣으면 에러가 납니다. 표면상으론 호환인데 내부는 각자 자기 성격이 있습니다. 결국 운영자는 모델별 옵션표를 들고 살아야 합니다.
최종 모델 매핑은 이렇게 정리했습니다.
const CATEGORY_TO_MODEL = {
casual: "gemini-3.5-flash",
coding: "deepseek-v3.2",
complex: "cd-sonnet-4.6",
general: "gpt-5.1",
};STEP 10. 배포 후 검증
배포가 끝나면 API부터 확인합니다.
curl -X POST "<YOUR_CHAT_API_URL>" \
-H "Content-Type: application/json" \
-d '{"message":"안녕"}'예상 결과는 casual 카테고리와 gemini-3.5-flash입니다.
{
"answer": "...",
"model_id": "gemini-3.5-flash",
"model_label": "gemini 3.5 flash",
"category": "casual",
"reason": "짧고 가벼운 질문이라 빠른 기본 모델로 배정했어요.",
"router": "bedrock-nova-micro"
}코딩 질문도 던져봅니다.
curl -X POST "<YOUR_CHAT_API_URL>" \
-H "Content-Type: application/json" \
-d '{"message":"파이썬으로 퀵소트 코드 작성해줘"}'예상 결과는 coding과 deepseek-v3.2입니다.
복잡한 분석형 질문은 complex로 가야 합니다.
curl -X POST "<YOUR_CHAT_API_URL>" \
-H "Content-Type: application/json" \
-d '{"message":"마이크로서비스와 모놀리식 아키텍처의 장단점을 운영 관점에서 비교해줘"}'이 경우 cd-sonnet-4.6으로 배정되는지 확인합니다.
프론트는 CloudFront URL로 접속해서 확인합니다.
terraform output cloudfront_url그리고 프론트 수정 후에는 CloudFront 캐시를 무효화합니다.
aws cloudfront create-invalidation \
--distribution-id <YOUR_CLOUDFRONT_DISTRIBUTION_ID> \
--paths "/*"[스크린샷: curl 검증 결과]
[스크린샷: CloudFront 접속 후 챗봇 응답 화면]
비용 구조
이 구조의 비용 포인트는 명확합니다.
| 서비스 | 과금 | 데모 기준 |
|---|---|---|
| S3 | 저장, 요청 | 거의 무료 티어 내 |
| CloudFront | 전송량 | 데모 트래픽이면 거의 무료 |
| API Gateway HTTP API | 요청당 과금 | 소량이면 거의 무료 |
| Lambda arm64 | 실행 시간, 호출 수 | 소량이면 무료 티어 내 |
| Bedrock Nova Micro | 라우팅 토큰 | 질문당 수십 토큰 수준 |
| VOD Text LLM | 실제 답변 토큰 | 주 비용이 여기에 집중 |
핵심은 AWS가 두뇌 역할을 하지 않는다는 점입니다. AWS는 정적 호스팅과 API, Lambda orchestration, 라우팅 판단 정도만 맡습니다. 무거운 답변 생성은 Tencent VOD Text LLM이 처리합니다.
즉 AWS는 껍데기와 라우터, VOD LLM은 두뇌입니다.
이 구조가 항상 정답이라는 뜻은 아닙니다. 이미 Bedrock 안에서 모델을 잘 쓰고 있고 비용도 괜찮다면 굳이 외부 LLM으로 뺄 이유는 없습니다. 하지만 외부 LLM 게이트웨이를 AWS 워크플로우에 붙일 수 있는지, 비용을 어디로 몰 수 있는지, 라우터와 추론기를 분리할 수 있는지 보여주는 데모로는 꽤 괜찮습니다.
보안과 운영 주의사항
이번 데모는 의도적으로 단순하게 만들었습니다. 그래서 운영에 넣으려면 반드시 손봐야 할 부분이 있습니다.
| 항목 | 현재 데모 | 운영 권장 |
|---|---|---|
| API 인증 | 없음 | 간단 토큰, Cognito, Lambda authorizer 등 추가 |
| 호출 제한 | 기본값 | API Gateway throttling 설정 |
| VOD 토큰 | Lambda 환경변수 | Secret Manager 또는 KMS 연동 고려 |
| CORS | 전체 허용 | 실제 도메인으로 제한 |
| 로그 | CloudWatch | RequestId, 모델, latency 기준으로 구조화 |
| 데모 종료 | 수동 | terraform destroy로 정리 |
현재 API Gateway는 URL만 알면 누구나 호출할 수 있습니다. 즉 누군가 API 주소를 알면 내 VOD 토큰으로 LLM을 호출할 수 있습니다. 데모에서는 괜찮을 수 있지만 운영에서는 바로 비용 사고로 이어질 수 있습니다.
최소한 API Gateway throttling은 걸어두는 게 좋습니다. 더 안전하게 가려면 간단한 인증 헤더나 Lambda authorizer를 붙입니다.
terraform destroy -var "vod_api_token=<YOUR_VOD_TOKEN>"데모가 끝났으면 지우는 것도 운영입니다. 안 쓰는 데모 리소스는 보통 비용보다 보안 쪽에서 뒤통수를 칩니다.
이번 실습에서 만난 벽
정리하면 이번 실습에서 만난 벽은 다섯 개였습니다.
| 문제 | 원인 | 해결 |
|---|---|---|
| IAM 권한 부족 | 초기 사용자 권한이 거의 없음 | 관리자에게 최소 권한 요청 |
| AWS profile 충돌 | 배포 계정과 기본 profile이 다름 | 환경변수로 자격증명 주입 |
| Bedrock 직접 호출 실패 | 서울 리전 on-demand 미지원 | apac.amazon.nova-micro-v1:0 inference profile 사용 |
| Lambda Function URL 403 | 조직 SCP 차단 | API Gateway HTTP API로 전환 |
| 모델 응답 지연 | 29초 제한과 모델별 파라미터 차이 | 느린 모델 제외, reasoning_effort 조정 |
이런 부분이 이 글의 핵심입니다. 단순히 "Terraform apply 하면 됩니다"로 끝낼 수도 있지만, 실제로는 여기서 다 터집니다. 실습 글의 가치는 성공 코드보다 어디서 막혔는지에 있습니다.
마치며
이번 실습에서 증명한 건 단순히 챗봇 하나를 만든 게 아닙니다.
AWS Bedrock을 전체 추론 엔진으로 쓰지 않고도, AWS 워크플로우 안에 외부 LLM을 넣을 수 있다는 걸 확인했습니다. Bedrock은 질문 분류용 라우터로만 쓰고, 실제 답변 생성은 Tencent Cloud VOD Text LLM API가 담당했습니다.
구조적으로 보면 꽤 실용적입니다. AWS는 API, Lambda, S3, CloudFront 같은 인프라 계층을 제공하고, LLM 추론은 외부 게이트웨이로 분리합니다. 덕분에 비용과 모델 선택권을 따로 가져갈 수 있습니다.
물론 한계도 있습니다. API Gateway 29초 제한 때문에 느린 모델은 쓰기 어렵고, 스트리밍 UX도 포기해야 했습니다. Lambda Function URL이 조직 정책에 막히는 것처럼 계정 환경에 따라 설계가 바뀔 수도 있습니다.
그래도 이 정도면 PoC로는 충분합니다. 비개발자에게도 "AWS 화면에서 챗봇이 열리는데 실제 두뇌는 Tencent VOD LLM이구나"를 보여줄 수 있습니다. 이런 데모는 말로 설명하는 것보다 화면 하나가 빠릅니다.
다음 단계로 간다면 인증과 rate limit, 모델별 latency logging, 비용 추적을 붙이면 됩니다. 여기까지 붙이면 단순 데모가 아니라 꽤 그럴듯한 LLM gateway 운영 샘플이 됩니다. 물론 그때부터는 챗봇이 아니라 운영 시스템입니다. 그리고 운영 시스템은 언제나 개발자를 겸손하게 만듭니다.