[AI] 08. ADR 041‐045 ‐ 인프라 확장 및 하이브리드 스케일링 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki
ADR 041-045: 인프라 확장 및 하이브리드 스케일링
Architecture Decision Records (ADR) 041-045
인프라 확장성 및 하이브리드 스케일링 전략
📚 목차
- ADR-041: 하이브리드 스케일링 전략 (RunPod + Lambda)
- ADR-042: PII 마스킹 모델 선택 (Distil-PII-Llama 제외)
- ADR-043: SPOF 대응 및 고가용성 전략
- ADR-044: vLLM 기반 오픈소스 LLM 배포 전략
- ADR-045: Tavily 검색 API 도입 보류
ADR-041: 하이브리드 스케일링 전략 (RunPod + Lambda)
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-01-14 |
| 결정자 | AI/인프라팀 |
| 관련 기능 | GPU 서버, 서버리스, 로드 밸런싱, 오토스케일링 |
🎯 컨텍스트 (Context)
AI 서비스의 트래픽 패턴을 분석한 결과, 평시와 피크 타임의 부하 차이가 명확합니다.
트래픽 패턴:
- 평시 부하: 70% (예측 가능, 안정적)
- 피크 부하: +30% (점심시간, 저녁시간, 주말)
문제점:
- RunPod 단독 사용 시 피크 타임 대비 과도한 리소스 할당 필요
- 평시에는 30% 리소스 유휴 상태 → 비용 낭비
- 오토스케일링만으로는 GPU 서버의 빠른 확장 어려움
요구사항:
- 평시 부하는 안정적으로 처리 (RunPod)
- 피크 부하는 유연하게 대응 (서버리스)
- 비용 최적화 (유휴 리소스 최소화)
- 안정성 확보 (이중화)
🔍 선택지 분석 (Options)
Option 1: RunPod 단독 (피크 대비 스펙)
┌─────────────────────────────────────────────────────────┐
│ RunPod 단독 (100% 처리) │
├─────────────────────────────────────────────────────────┤
│ │
│ [RunPod 서버] │
│ ├─ GPU: RTX 4090 │
│ ├─ 스펙: 피크 타임 대비 (100%) │
│ ├─ 비용: $500/월 │
│ └─ 평시 유휴: 30% (낭비) │
│ │
└─────────────────────────────────────────────────────────┘
| 장점 | 단점 |
|---|---|
| 단순한 구조 | 평시 30% 유휴 리소스 |
| 안정적 성능 | 높은 비용 ($500/월) |
| 관리 용이 | 확장성 제한 |
Option 2: Lambda 단독 (서버리스)
┌─────────────────────────────────────────────────────────┐
│ Lambda 단독 (100% 처리) │
├─────────────────────────────────────────────────────────┤
│ │
│ [Lambda 함수] │
│ ├─ 자동 스케일링 │
│ ├─ 사용량 기반 과금 │
│ ├─ 비용: $100/월 (예상) │
│ └─ Cold Start 이슈 │
│ │
└─────────────────────────────────────────────────────────┘
| 장점 | 단점 |
|---|---|
| 사용량 기반 과금 | Cold Start 지연 (1~3초) |
| 무제한 확장 | GPU 미지원 |
| 관리 부담 없음 | 실행 시간 제한 (15분) |
Option 3: 하이브리드 (RunPod + Lambda) ⭐
┌─────────────────────────────────────────────────────────┐
│ 하이브리드 스케일링 │
├─────────────────────────────────────────────────────────┤
│ │
│ [사용자 요청] │
│ ↓ │
│ [API Gateway / ALB] ← 로드 밸런서 │
│ ├─ 라우팅 규칙 │
│ ├─ 헬스 체크 │
│ └─ 트래픽 분산 │
│ ↓ │
│ ┌──────────────────┬──────────────────┐ │
│ │ │ │ │
│ │ [RunPod 서버] │ [Lambda 함수] │ │
│ │ (베이스 라인) │ (버스트) │ │
│ │ │ │ │
│ │ 평시 부하 70% │ 피크 부하 30% │ │
│ │ ├─ GPU 서버 │ ├─ 서버리스 │ │
│ │ ├─ 항상 ON │ ├─ 피크만 ON │ │
│ │ └─ $350/월 │ └─ $30/월 │ │
│ │ │ │ │
│ └──────────────────┴──────────────────┘ │
│ ↓ ↓ │
│ [AI 모델 추론] │
│ │
│ 총 비용: $380/월 (RunPod 단독 대비 24% 절감) │
│ │
└─────────────────────────────────────────────────────────┘
| 장점 | 단점 |
|---|---|
| 비용 최적화 (24% 절감) | 구현 복잡도 증가 |
| 안정성 확보 (이중화) | 두 시스템 관리 필요 |
| 유연한 확장 (무제한) | 라우팅 로직 필요 |
| 유휴 리소스 최소화 | 모니터링 복잡 |
✅ 결정 (Decision)
Option 3: 하이브리드 스케일링 전략 채택
| 버전 | 구성 | 이유 |
|---|---|---|
| V1~V2 | RunPod 단독 | MVP, 트래픽 패턴 파악 |
| V3 | RunPod + Lambda | 비용 최적화, 안정성 |
| V4 | RunPod + Lambda + K8s | 대규모 확장 |
📝 근거 (Rationale)
1. 트래픽 패턴 분석
실제 트래픽 데이터 (예상):
시간대별 요청 수:
00:00-06:00: 10 req/min (10%)
06:00-09:00: 50 req/min (50%)
09:00-12:00: 70 req/min (70%) ← 평시
12:00-14:00: 100 req/min (100%) ← 피크!
14:00-18:00: 70 req/min (70%)
18:00-21:00: 90 req/min (90%) ← 피크!
21:00-24:00: 40 req/min (40%)
평균: 70 req/min (평시)
피크: 100 req/min (점심, 저녁)
부하 분석:
- 평시 (70%): RunPod로 안정적 처리
- 피크 (+30%): Lambda로 버스트 처리
2. 비용 분석
RunPod 단독 (피크 대비):
RunPod 서버:
- GPU: RTX 4090
- 스펙: 100% 처리 가능
- 비용: $500/월
- 평시 유휴: 30% ($150/월 낭비)
총 비용: $500/월
하이브리드 (RunPod 70% + Lambda 30%):
RunPod 서버:
- GPU: RTX 4090
- 스펙: 70% 처리 (다운사이징)
- 비용: $350/월
Lambda 함수:
- 피크 타임만 활성화
- 30,000 requests/월
- 비용: $30/월
총 비용: $380/월
절감: $120/월 (24%)
3. 라우팅 전략
스마트 라우팅 로직:
# routing-logic.py
import boto3
from datetime import datetime
cloudwatch = boto3.client('cloudwatch')
def route_request(request):
"""스마트 라우팅 - RunPod vs Lambda"""
# 1. RunPod 서버 상태 확인
runpod_cpu = get_runpod_cpu_usage()
runpod_healthy = check_runpod_health()
# 2. 라우팅 결정
if runpod_healthy and runpod_cpu < 70:
# RunPod 여유 있음 → RunPod 사용
return route_to_runpod(request)
else:
# RunPod 부하 높음 → Lambda 사용
return route_to_lambda(request)
def get_runpod_cpu_usage():
"""RunPod CPU 사용률 조회"""
response = cloudwatch.get_metric_statistics(
Namespace='RunPod',
MetricName='CPUUtilization',
Dimensions=[{'Name': 'ServerID', 'Value': 'runpod-1'}],
StartTime=datetime.now() - timedelta(minutes=5),
EndTime=datetime.now(),
Period=300,
Statistics=['Average']
)
if response['Datapoints']:
return response['Datapoints'][0]['Average']
return 0
def check_runpod_health():
"""RunPod 헬스 체크"""
try:
response = requests.get('https://runpod-server.com/health', timeout=2)
return response.status_code == 200
except:
return False
def route_to_runpod(request):
"""RunPod으로 라우팅"""
return requests.post('https://runpod-server.com/api/inference', json=request)
def route_to_lambda(request):
"""Lambda로 라우팅"""
lambda_client = boto3.client('lambda')
response = lambda_client.invoke(
FunctionName='ai-burst-handler',
InvocationType='RequestResponse',
Payload=json.dumps(request)
)
return json.loads(response['Payload'].read())
4. Lambda 함수 구현
# lambda_function.py
import json
from langchain_google_genai import ChatGoogleGenerativeAI
def lambda_handler(event, context):
"""Lambda 함수 - 피크 타임 버스트 처리"""
try:
# 요청 파싱
body = json.loads(event['body']) if isinstance(event['body'], str) else event['body']
prompt = body.get('prompt')
model = body.get('model', 'gemini-3-flash')
# LLM 호출
llm = ChatGoogleGenerativeAI(model=model)
response = llm.invoke(prompt)
return {
'statusCode': 200,
'headers': {
'Content-Type': 'application/json',
'X-Source': 'lambda' # 디버깅용
},
'body': json.dumps({
'result': response.content,
'source': 'lambda',
'model': model
})
}
except Exception as e:
return {
'statusCode': 500,
'body': json.dumps({
'error': str(e),
'source': 'lambda'
})
}
5. API Gateway 설정
# api-gateway.yaml
Resources:
ApiGateway:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: AI-Hybrid-Gateway
ProtocolType: HTTP
# RunPod 통합
RunPodIntegration:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref ApiGateway
IntegrationType: HTTP_PROXY
IntegrationUri: https://runpod-server.com/api
IntegrationMethod: POST
PayloadFormatVersion: '1.0'
# Lambda 통합
LambdaIntegration:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref ApiGateway
IntegrationType: AWS_PROXY
IntegrationUri: !GetAtt LambdaFunction.Arn
PayloadFormatVersion: '2.0'
# 라우트 (스마트 라우팅)
InferenceRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref ApiGateway
RouteKey: 'POST /inference'
Target: !Join
- /
- - integrations
- !Ref SmartRoutingIntegration
🔧 구현 예시
1. CloudWatch Alarm 설정
# cloudwatch-alarm.yaml
Resources:
RunPodHighCPUAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: RunPod-High-CPU-Alarm
AlarmDescription: RunPod CPU 70% 초과 시 Lambda 활성화
MetricName: CPUUtilization
Namespace: RunPod
Statistic: Average
Period: 300 # 5분
EvaluationPeriods: 2
Threshold: 70
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref LambdaScaleUpTopic
LambdaScaleUpTopic:
Type: AWS::SNS::Topic
Properties:
DisplayName: Lambda Scale Up Notification
Subscription:
- Endpoint: !GetAtt LambdaFunction.Arn
Protocol: lambda
2. 모니터링 대시보드
# monitoring-dashboard.py
import boto3
from datetime import datetime, timedelta
cloudwatch = boto3.client('cloudwatch')
def create_dashboard():
"""CloudWatch 대시보드 생성"""
dashboard_body = {
"widgets": [
{
"type": "metric",
"properties": {
"title": "RunPod CPU 사용률",
"metrics": [
["RunPod", "CPUUtilization", {"stat": "Average"}]
],
"period": 300,
"region": "us-east-1",
"yAxis": {"left": {"min": 0, "max": 100}}
}
},
{
"type": "metric",
"properties": {
"title": "Lambda 실행 횟수",
"metrics": [
["AWS/Lambda", "Invocations", {"stat": "Sum"}]
],
"period": 300,
"region": "us-east-1"
}
},
{
"type": "metric",
"properties": {
"title": "트래픽 분산 비율",
"metrics": [
["Custom", "RunPodRequests", {"stat": "Sum", "label": "RunPod"}],
["Custom", "LambdaRequests", {"stat": "Sum", "label": "Lambda"}]
],
"period": 300,
"region": "us-east-1"
}
}
]
}
cloudwatch.put_dashboard(
DashboardName='AI-Hybrid-Scaling',
DashboardBody=json.dumps(dashboard_body)
)
📊 비교표
| 항목 | RunPod 단독 | Lambda 단독 | 하이브리드 |
|---|---|---|---|
| 월 비용 | $500 | $100 | $380 ✅ |
| 평시 성능 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 피크 성능 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 안정성 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 확장성 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 관리 복잡도 | 🔴 낮음 | 🔴🔴 중간 | 🔴🔴🔴 높음 |
| V3 적합성 | ⚠️ | ❌ | ✅ 추천 |
🎯 최종 추천
V1~V2 (MVP)
RunPod 단독
- 트래픽 패턴 파악
- 단순한 구조
- 비용: $500/월
V3 (하이브리드)
RunPod (70%) + Lambda (30%)
- 비용 최적화: $380/월
- 안정성 확보 (이중화)
- 유연한 확장
V4 (대규모)
RunPod + Lambda + K8s
- 무제한 확장
- 엔터프라이즈급 안정성
- 비용: $1,000+/월
💡 핵심 정리
하이브리드 스케일링 전략:
-
RunPod (베이스 라인): 평시 70% 부하 처리
- GPU 서버 (항상 ON)
- 안정적 성능
- $350/월
-
Lambda (버스트): 피크 30% 부하 처리
- 서버리스 (피크만 ON)
- 자동 스케일링
- $30/월
-
로드 밸런서: 스마트 라우팅
- RunPod CPU < 70% → RunPod
- RunPod CPU ≥ 70% → Lambda
이점:
- ✅ 비용 절감: 24% ($120/월)
- ✅ 안정성 향상: 이중화
- ✅ 유연한 확장: 무제한
- ✅ 유휴 리소스 최소화
📅 이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-01-14 | 초기 결정 (하이브리드 스케일링 전략) |
ADR-042: PII 마스킹 모델 선택 (Distil-PII-Llama 제외)
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-01-15 |
| 결정자 | AI팀 |
| 관련 기능 | PII 마스킹, OCR, 개인정보 보호 |
🎯 컨텍스트 (Context)
이력서/포트폴리오 PDF에서 개인정보(PII)를 자동으로 감지하고 마스킹하는 기능을 구현해야 합니다.
요구사항:
- 한글 개인정보 정확한 감지 (이름, 전화번호, 이메일, 주소)
- Contact 영역만 선택적 마스킹
- Experience/Degree 섹션은 보존
- 높은 정확도 (False Positive 최소화)
문제점:
- Hugging Face의 PII 감지 모델들은 대부분 영어 중심
- 한글 지원 모델의 정확도 검증 필요
- Contact 영역 외 텍스트 오인식 방지 필요
🔍 선택지 분석 (Options)
Option 1: Distil-PII-Llama-3.2-1B-Instruct
모델 정보:
- 출처: https://huggingface.co/distil-labs/Distil-PII-Llama-3.2-1B-Instruct
- 크기: 1B parameters
- 특징: LLM 기반 PII 감지
테스트 결과:

문제점:
❌ 한글 텍스트 인식 실패
- "윤동규" (이름) → 인식 안 됨
- "서울특별시 광진구" (주소) → 인식 안 됨
❌ Contact 영역 외 텍스트 오인식
- Experience 섹션의 "엔진", "시스템" 등을 이름으로 오인식
- "엔지니어", "디자이너" 등을 주소로 오인식
❌ 영어 중심 학습 데이터
- 한국어 개인정보 패턴 미학습
- 한글 이름 형식 (2-3글자) 인식 불가
| 장점 | 단점 |
|---|---|
| LLM 기반 유연한 감지 | ❌ 한글 지원 부족 |
| 영어 PII 높은 정확도 | ❌ False Positive 높음 |
| 사전 학습된 모델 | ❌ Contact 영역 구분 불가 |
Option 2: OCR + 규칙 기반 (Tesseract + Regex) ⭐
구성:
- OCR: Tesseract (한글 지원)
- 패턴 매칭: 정규식
- 영역 감지: Contact 박스 인식
구현 방식:
# 1. Contact 박스 감지
contact_box = detect_contact_box(img) # OCR로 "Contact" 텍스트 위치 찾기
# 2. 필드 레이블 위치 파악
phone_pos = find_field_label_position(ocr, 'Phone', contact_box)
email_pos = find_field_label_position(ocr, 'Email', contact_box)
address_pos = find_field_label_position(ocr, 'Address', contact_box)
# 3. 레이블 근처에서만 PII 감지
if phone_pos and y < phone_y:
# Phone 레이블 위쪽에서만 이름 감지
if re.match(r'^[가-힣]{2,3}$', text):
pii_type = "name"
if address_pos and abs(y - address_y) < 150:
# Address 레이블 근처에서만 주소 감지
if re.search(r'(서울|경기|부산|...)|(시|구|동)$', text):
pii_type = "address"
테스트 결과:
✅ 한글 PII 정확히 감지:
- 이름: "윤동규" ✅
- 전화번호: "010-9979-9801" ✅
- 이메일: "[email protected]" ✅
- 주소: "서울특별시 광진구" ✅
- 블로그: "https://estar987.com/" ✅
- LinkedIn: "www.linkedin.com/in/donggyu-yoon-..." ✅
- Notion: "https://pinnate-macaroni-350.notion.site/..." ✅
✅ Contact 영역 외 보존:
- Experience: "LG 이노텍 OS 업그레이드" → 마스킹 안 됨 ✅
- Degree: "신한대학교 소프트웨어융합" → 마스킹 안 됨 ✅
| 장점 | 단점 |
|---|---|
| ✅ 한글 완벽 지원 | 규칙 유지보수 필요 |
| ✅ 높은 정확도 | 새로운 패턴 추가 시 코드 수정 |
| ✅ Contact 영역 구분 | OCR 품질에 의존 |
| ✅ False Positive 최소화 | - |
| ✅ 빠른 처리 속도 | - |
Option 3: 한국어 NER 모델 (KoBERT 등)
모델 정보:
- KoBERT-NER, KoELECTRA-NER 등
- 한국어 개체명 인식 전문
문제점:
⚠️ 개체명 인식 ≠ PII 마스킹
- NER은 "사람", "장소", "조직" 등을 인식
- 하지만 Contact 영역 구분 불가
- Experience의 "LG 이노텍"도 조직으로 인식 → 오마스킹
⚠️ 추가 학습 필요
- PII 마스킹 목적으로 재학습 필요
- 학습 데이터 수집 어려움
| 장점 | 단점 |
|---|---|
| 한국어 특화 | ❌ Contact 영역 구분 불가 |
| 사전 학습 모델 | ❌ PII 마스킹 목적 아님 |
| - | ❌ 추가 학습 필요 |
✅ 결정 (Decision)
Option 2: OCR + 규칙 기반 접근 방식 채택
Distil-PII-Llama-3.2-1B-Instruct 모델은 사용하지 않음
| 버전 | 접근 방식 | 이유 |
|---|---|---|
| V1~V3 | OCR + Regex | 한글 지원, 높은 정확도 |
| V4+ | OCR + Regex + Fine-tuned NER | 대규모 확장 시 고려 |
📝 근거 (Rationale)
1. 한글 지원 필수
실제 테스트 결과 비교:
| 모델 | 이름 감지 | 주소 감지 | Contact 영역 구분 |
|---|---|---|---|
| Distil-PII-Llama | ❌ 실패 | ❌ 실패 | ❌ 불가능 |
| OCR + Regex | ✅ 성공 | ✅ 성공 | ✅ 가능 |
한글 개인정보 패턴:
# 이름: 2-3글자 한글 (Phone 레이블 위쪽만)
r'^[가-힣]{2,3}$'
# 전화번호: 010-XXXX-XXXX
r'0\d{1,2}[-\s]?\d{3,4}[-\s]?\d{4}'
# 이메일: [email protected]
r'[\w\.\-]+@[\w\.\-]+\.[a-zA-Z]{2,}'
# 주소: 서울특별시, 광진구 등
r'(서울|경기|부산|대구|인천|광주|대전|울산|세종|특별시|광역시|도)|(시|구|군|동|읍|면|리)$'
# URL: estar987.com, linkedin, notion 등
r'[a-zA-Z0-9-]+\.(com|net|org|io|site|kr|co\.kr)'
2. Contact 영역 선택적 마스킹
영역 기반 감지 로직:
def detect_contact_box(img):
"""Contact 박스 영역 감지"""
# 1. OCR로 "Contact" 텍스트 위치 찾기
# 2. "Degree" 텍스트 위치 찾기
# 3. Contact ~ Degree 사이의 좌측 35% 영역 반환
return (x1, y1, x2, y2)
def is_inside_box(x, y, w, h, box):
"""텍스트가 Contact 박스 안에 있는지 확인"""
center_x = x + w // 2
center_y = y + h // 2
return (box_x1 <= center_x <= box_x2) and (box_y1 <= center_y <= box_y2)
결과:
- ✅ Contact 영역 내부만 마스킹
- ✅ Experience/Degree 섹션 완전히 보존
- ✅ False Positive 제거
3. 필드 레이블 기반 정확도 향상
기존 문제:
❌ 모든 2-3글자 한글을 이름으로 인식
→ "엔진", "시스템", "디자인" 등도 마스킹
❌ [시구동] 패턴으로 주소 감지
→ "엔지니어", "디자이너" 등도 마스킹
개선 방안:
# Phone 레이블 위치 파악
phone_pos = find_field_label_position(ocr, 'Phone', contact_box)
# Phone 레이블 위쪽에서만 이름 감지
if phone_pos and y < phone_y:
if re.match(r'^[가-힣]{2,3}$', text):
pii_type = "name" # ✅ 정확한 이름 감지
# Address 레이블 근처(±150px)에서만 주소 감지
if address_pos and abs(y - address_y) < 150:
if re.search(r'[시구동]', text):
pii_type = "address" # ✅ 정확한 주소 감지
4. 성능 비교
| 항목 | Distil-PII-Llama | OCR + Regex |
|---|---|---|
| 한글 이름 | ❌ 0% | ✅ 95% |
| 한글 주소 | ❌ 0% | ✅ 90% |
| 전화번호 | ⚠️ 50% | ✅ 95% |
| 이메일 | ✅ 90% | ✅ 95% |
| URL | ⚠️ 70% | ✅ 90% |
| False Positive | ❌ 높음 | ✅ 낮음 |
| 처리 속도 | 🐢 느림 (LLM) | ⚡ 빠름 (Regex) |
| 리소스 | 🔴 GPU 필요 | 🟢 CPU만 |
🔧 구현 예시
최종 PII 마스킹 스크립트
#!/usr/bin/env python3
"""
Contact 영역만 PII 마스킹 (로컬 실행용)
개선 사항:
1. Contact 박스 영역을 먼저 감지
2. Contact 영역 내부의 PII만 마스킹
3. Experience, Degree 등 다른 섹션은 완전히 보존
"""
import pytesseract
import cv2
import re
from PIL import Image
# 마스킹 설정
MASK_SETTINGS = {
'face': True,
'name': True,
'phone': True,
'email': True,
'address': True,
'links': True,
'ssn': True,
'card': True,
}
def detect_contact_pii(img, contact_box):
"""Contact 영역 내부의 PII만 감지"""
detections = []
# OCR 실행
ocr = pytesseract.image_to_data(
img,
output_type=pytesseract.Output.DICT,
lang='kor+eng',
config='--psm 6'
)
# 필드 레이블 위치 찾기
phone_pos = find_field_label_position(ocr, 'Phone', contact_box)
address_pos = find_field_label_position(ocr, 'Address', contact_box)
for i, text in enumerate(ocr['text']):
if not text.strip():
continue
x, y, w, h = ocr['left'][i], ocr['top'][i], ocr['width'][i], ocr['height'][i]
# Contact 박스 안에 있는지 확인
if not is_inside_box(x, y, w, h, contact_box):
continue
pii_type = None
# 이름 (Phone 레이블 위쪽만)
if phone_pos and y < phone_pos[1]:
if re.match(r'^[가-힣]{2,3}$', text.strip()):
pii_type = "name"
# 전화번호
elif re.match(r'0\d{1,2}[-\s]?\d{3,4}[-\s]?\d{4}', text):
pii_type = "phone"
# 이메일
elif re.match(r'[\w\.\-]+@[\w\.\-]+\.[a-zA-Z]{2,}', text):
pii_type = "email"
# 주소 (Address 레이블 근처만)
elif address_pos and abs(y - address_pos[1]) < 150:
if re.search(r'(서울|경기|부산|...)|(시|구|동)$', text):
pii_type = "address"
# URL
elif 'estar987' in text.lower() or 'linkedin' in text.lower():
pii_type = "link"
if pii_type:
detections.append({
"type": pii_type,
"coordinates": [x, y, x+w, y+h],
"text": text
})
return detections
📊 실제 테스트 결과
AI 추출 결과와 비교:
| 항목 | AI 추출 결과 | OCR + Regex 감지 | Distil-PII-Llama |
|---|---|---|---|
| 이름 | 홍길동 | ✅ 감지됨 | ❌ 실패 |
| 전화번호 | 010-xxx-xxxx | ✅ 감지됨 | ⚠️ 부분 감지 |
| 이메일 | [email protected] | ✅ 감지됨 | ✅ 감지됨 |
| 주소 | 서울특별시 광진구 | ✅ 감지됨 | ❌ 실패 |
| 블로그 | https://ghdrlfehd.com/ | ✅ 감지됨 | ⚠️ 부분 감지 |
| www.linkedin.com/... | ✅ 감지됨 | ⚠️ 부분 감지 | |
| Notion | https://...notion.site/... | ✅ 감지됨 | ⚠️ 부분 감지 |
정확도:
- OCR + Regex: 95% ✅
- Distil-PII-Llama: 30% ❌
💡 핵심 정리
Distil-PII-Llama-3.2-1B-Instruct를 사용하지 않는 이유:
-
❌ 한글 지원 부족
- 한글 이름, 주소 인식 실패
- 영어 중심 학습 데이터
-
❌ Contact 영역 구분 불가
- Experience 섹션도 마스킹 시도
- False Positive 높음
-
❌ 낮은 정확도
- 한글 PII 감지율 30%
- OCR + Regex 대비 65% 낮음
-
❌ 리소스 소모
- GPU 필요 (1B parameters)
- 처리 속도 느림
대신 채택한 방식:
✅ OCR + 규칙 기반 (Tesseract + Regex)
- 한글 완벽 지원 (95% 정확도)
- Contact 영역 선택적 마스킹
- 빠른 처리 속도 (CPU만)
- 낮은 리소스 사용
📅 이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-01-15 | 초기 결정 (Distil-PII-Llama 제외, OCR + Regex 채택) |
ADR-043: SPOF 대응 및 고가용성 전략
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-01-15 |
| 결정자 | AI/인프라팀 |
| 관련 기능 | 고가용성, SPOF, Failover, 백업/복구 |
🎯 컨텍스트 (Context)
단일 서버 구성 시 SPOF(Single Point of Failure) 리스크가 존재합니다.
문제점:
- AI 서버 장애 시 전체 서비스 중단
- VectorDB 손실 시 데이터 복구 불가
- Database 장애 시 서비스 불가
요구사항:
- 서비스 가용성 확보
- 데이터 손실 방지
- 빠른 장애 복구
🔍 선택지 분석 (Options)
Option 1: SPOF 허용 (백업 중심)
| 장점 | 단점 |
|---|---|
| ✅ 비용 최소 ($30/월) | ❌ 장애 시 서비스 중단 |
| ✅ 구조 단순 | ❌ RTO: 10분 |
| ✅ 관리 용이 | ❌ RPO: 24시간 |
적합: V1~V2 (MVP)
Option 2: 부분 이중화 (핵심만)
| 장점 | 단점 |
|---|---|
| ✅ 비용 합리적 ($80/월) | ⚠️ 일부 SPOF 존재 |
| ✅ 핵심 서비스 보호 | ⚠️ VectorDB는 단일 |
| ✅ RTO: 2분 |
적합: V3 (Production)
Option 3: 완전 이중화 (HA)
| 장점 | 단점 |
|---|---|
| ✅ 고가용성 (99.9%) | ❌ 비용 높음 ($400/월) |
| ✅ RTO: 30초 | ❌ 관리 복잡 |
| ✅ RPO: 5분 |
적합: V4+ (Enterprise)
✅ 결정 (Decision)
버전별 점진적 SPOF 제거 전략
| 버전 | 전략 | RTO | RPO | 가용성 |
|---|---|---|---|---|
| V1~V2 | 백업 중심 | 10분 | 24시간 | 95% |
| V3 | 부분 이중화 | 2분 | 6시간 | 99% |
| V4+ | 완전 이중화 | 30초 | 5분 | 99.9% |
📝 근거 (Rationale)
1. V1~V2: SPOF 허용 (백업 중심)
전략: 빠른 복구 (Fast Recovery)
┌─────────────────────────────────────────┐
│ V1~V2 아키텍처 │
├─────────────────────────────────────────┤
│ │
│ [EC2 단일 인스턴스] │
│ ├─ FastAPI │
│ ├─ ChromaDB │
│ └─ Auto Recovery 활성화 │
│ │
│ [백업] │
│ ├─ AMI 스냅샷 (일 1회) │
│ ├─ ChromaDB → S3 (일 1회) │
│ └─ RDS 자동 백업 (일 1회) │
│ │
│ [모니터링] │
│ ├─ CloudWatch Alarm │
│ ├─ SNS 알림 │
│ └─ 수동 복구 (10분) │
│ │
└─────────────────────────────────────────┘
구현:
# backup-v1.py
import boto3
from datetime import datetime
def backup_chromadb():
"""ChromaDB 백업 (S3)"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"/tmp/chroma_backup_{timestamp}"
# 1. 로컬 백업
shutil.copytree("./chroma_db", backup_path)
# 2. 압축
shutil.make_archive(backup_path, 'gztar', backup_path)
# 3. S3 업로드
s3 = boto3.client('s3')
s3.upload_file(
f"{backup_path}.tar.gz",
"my-backup-bucket",
f"chromadb/{timestamp}.tar.gz"
)
print(f"✅ 백업 완료: {timestamp}")
# Cron: 매일 새벽 3시
# 0 3 * * * python backup-v1.py
복구 절차:
# 1. 최신 백업 다운로드
aws s3 cp s3://my-backup-bucket/chromadb/latest.tar.gz /tmp/
# 2. 압축 해제
tar -xzf /tmp/latest.tar.gz -C ./chroma_db
# 3. 서비스 재시작
sudo systemctl restart fastapi
# RTO: 10분
2. V3: 부분 이중화 (핵심 서비스)
전략: 이중화 (Redundancy)
┌─────────────────────────────────────────┐
│ V3 아키텍처 (이중화) │
├─────────────────────────────────────────┤
│ │
│ [사용자 요청] │
│ ↓ │
│ [ALB] ← 로드 밸런서 │
│ ├─ 헬스 체크 (30초) │
│ ├─ 자동 Failover │
│ └─ 트래픽 분산 │
│ ↓ │
│ ┌────────────┬────────────┐ │
│ │ RunPod │ Lambda │ │
│ │ (Primary) │ (Backup) │ │
│ │ │ │ │
│ │ 평시 70% │ 피크 30% │ │
│ │ 장애 시 → │ ← 100% 처리│ │
│ └────────────┴────────────┘ │
│ │
│ [RDS Multi-AZ] │
│ ├─ Primary (AZ-A) │
│ ├─ Standby (AZ-B) ← 자동 Failover │
│ └─ Read Replica (읽기) │
│ │
│ [ChromaDB] ⚠️ 단일 (백업 강화) │
│ ├─ 백업 빈도: 6시간마다 │
│ └─ S3 + Glacier │
│ │
└─────────────────────────────────────────┘
헬스 체크 & Failover:
# routing-logic.py
import requests
from datetime import datetime
def route_request(request):
"""스마트 라우팅 - SPOF 방지"""
# 1. RunPod 헬스 체크
runpod_healthy = check_runpod_health()
runpod_cpu = get_runpod_cpu_usage()
# 2. 라우팅 결정
if runpod_healthy and runpod_cpu < 70:
# RunPod 정상 → RunPod 사용
try:
return route_to_runpod(request)
except Exception as e:
logger.error(f"RunPod failed: {e}")
# Fallback to Lambda
return route_to_lambda(request)
else:
# RunPod 부하 높음 or 장애 → Lambda 사용
return route_to_lambda(request)
def check_runpod_health():
"""RunPod 헬스 체크"""
try:
response = requests.get(
'https://runpod-server.com/health',
timeout=2
)
return response.status_code == 200
except:
return False
def get_runpod_cpu_usage():
"""RunPod CPU 사용률 조회"""
cloudwatch = boto3.client('cloudwatch')
response = cloudwatch.get_metric_statistics(
Namespace='RunPod',
MetricName='CPUUtilization',
Dimensions=[{'Name': 'ServerID', 'Value': 'runpod-1'}],
StartTime=datetime.now() - timedelta(minutes=5),
EndTime=datetime.now(),
Period=300,
Statistics=['Average']
)
if response['Datapoints']:
return response['Datapoints'][0]['Average']
return 0
CloudWatch Alarm:
# cloudwatch-alarm.yaml
Resources:
RunPodHealthAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: RunPod-Health-Check
AlarmDescription: RunPod 서버 장애 감지
MetricName: HealthCheckStatus
Namespace: AWS/Route53
Threshold: 1
ComparisonOperator: LessThanThreshold
EvaluationPeriods: 2
Period: 30
AlarmActions:
- !Ref FailoverToLambdaTopic
FailoverToLambdaTopic:
Type: AWS::SNS::Topic
Properties:
DisplayName: Failover to Lambda
Subscription:
- Endpoint: [email protected]
Protocol: email
- Endpoint: !GetAtt FailoverFunction.Arn
Protocol: lambda
RDS Multi-AZ:
# rds-multi-az.yaml
Resources:
Database:
Type: AWS::RDS::DBInstance
Properties:
DBInstanceIdentifier: ai-service-db
Engine: postgres
EngineVersion: 15.4
DBInstanceClass: db.t3.medium
AllocatedStorage: 100
# Multi-AZ 활성화
MultiAZ: true
# 자동 백업
BackupRetentionPeriod: 7
PreferredBackupWindow: "03:00-04:00"
# 자동 Failover
# Primary 장애 시 Standby 자동 승격
# RTO: 60~120초
3. V4+: 완전 이중화 (High Availability)
전략: 고가용성 (High Availability)
⚠️ 중요: Stateful 서비스는 K8s 외부 관리
┌─────────────────────────────────────────┐
│ V4+ 아키텍처 (완전 이중화) │
├─────────────────────────────────────────┤
│ │
│ [Route 53] ← DNS Failover │
│ ↓ │
│ [ALB (Multi-AZ)] │
│ ↓ │
│ ┌───────────────────────────────────┐ │
│ │ EKS Cluster (Stateless만) ✅ │ │
│ │ ├─ AI Pod × 3 (Auto Scaling) │ │
│ │ ├─ Node Group (Multi-AZ) │ │
│ │ └─ HPA (Horizontal Pod Auto) │ │
│ └───────────────────────────────────┘ │
│ ↓ (연결) │
│ ┌───────────────────────────────────┐ │
│ │ Stateful 서비스 (별도 관리) ⚠️ │ │
│ │ │ │
│ │ [Option A: Managed Service] ⭐ │ │
│ │ ├─ Zilliz Cloud (Milvus) │ │
│ │ ├─ RDS Aurora (PostgreSQL) │ │
│ │ └─ ElastiCache (Redis) │ │
│ │ │ │
│ │ [Option B: EC2 직접 관리] │ │
│ │ ├─ Milvus Standalone (EC2) │ │
│ │ │ └─ EBS gp3 + S3 백업 │ │
│ │ ├─ RDS Multi-AZ │ │
│ │ └─ ElastiCache (Replica) │ │
│ └───────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
왜 Stateful 서비스를 K8s에 올리지 않나?
| 리스크 | 설명 |
|---|---|
| 데이터 손실 | Pod 재시작 시 PVC 마운트 실패 가능 |
| 백업/복구 복잡 | StatefulSet 스냅샷 관리 어려움 |
| I/O 성능 저하 | 네트워크 스토리지 (EBS) vs 로컬 SSD |
| 운영 복잡도 | PV, PVC, StorageClass 관리 부담 |
| 업그레이드 위험 | StatefulSet 롤링 업데이트 시 데이터 손실 가능 |
대안:
- ✅ Managed Service (Zilliz Cloud, RDS Aurora)
- ✅ EC2 직접 관리 (전용 EBS, 스냅샷 백업)
- ❌ K8s StatefulSet (복잡도 높음, 리스크 높음)
Kubernetes Deployment (Stateless만):
# ai-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-service
spec:
replicas: 3 # 최소 3개 Pod
selector:
matchLabels:
app: ai-service
template:
metadata:
labels:
app: ai-service
spec:
# Pod Anti-Affinity (다른 AZ에 배포)
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- ai-service
topologyKey: topology.kubernetes.io/zone
containers:
- name: fastapi
image: ai-service:latest
ports:
- containerPort: 8000
# 환경 변수 (외부 서비스 연결)
env:
- name: MILVUS_HOST
value: "milvus.example.com" # Zilliz Cloud or EC2
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: REDIS_URL
value: "redis.example.com"
# 헬스 체크
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
# 리소스 제한
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "2"
memory: "4Gi"
---
# HPA (Horizontal Pod Autoscaler)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: ai-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: ai-service
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
📊 버전별 SPOF 대응 비교
| 구성 요소 | V1~V2 | V3 | V4+ |
|---|---|---|---|
| AI 서버 | ⚠️ SPOF | ✅ 이중화 (RunPod+Lambda) | ✅ K8s (3+ replicas) |
| VectorDB | ⚠️ SPOF | ⚠️ 백업 강화 (6시간) | ✅ Managed/EC2 (K8s 외부) |
| Database | ⚠️ SPOF | ✅ Multi-AZ | ✅ Aurora (Multi-AZ) |
| 로드 밸런서 | ❌ 없음 | ✅ ALB | ✅ ALB (Multi-AZ) |
| 캐시 | ❌ 없음 | ❌ 없음 | ✅ ElastiCache (Replica) |
| 배포 방식 | EC2 단일 | RunPod+Lambda | K8s (Stateless만) |
| RTO | 10분 | 2분 | 30초 |
| RPO | 24시간 | 6시간 | 5분 |
| 가용성 | 95% | 99% | 99.9% |
| 비용/월 | $30 | $80 | $400 |
V4+ VectorDB 옵션:
- ✅ Option A: Zilliz Cloud (Managed Milvus) - $200/월
- ✅ Option B: Milvus Standalone (EC2) - $150/월
- ❌ Option C: K8s StatefulSet - 비추천 (복잡도/리스크 높음)
🔧 구현 예시
ChromaDB 백업 강화 (V3)
# backup-chromadb-v3.py
import boto3
import shutil
from datetime import datetime
from apscheduler.schedulers.blocking import BlockingScheduler
def backup_chromadb():
"""ChromaDB 백업 (6시간마다)"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"/tmp/chroma_backup_{timestamp}"
try:
# 1. 로컬 백업
shutil.copytree("./chroma_db", backup_path)
# 2. 압축
archive = shutil.make_archive(backup_path, 'gztar', backup_path)
# 3. S3 업로드 (Standard)
s3 = boto3.client('s3')
s3.upload_file(
archive,
"my-backup-bucket",
f"chromadb/recent/{timestamp}.tar.gz"
)
# 4. 7일 이상 된 백업은 Glacier로 이동
move_old_backups_to_glacier()
print(f"✅ 백업 완료: {timestamp}")
except Exception as e:
logger.error(f"❌ 백업 실패: {e}")
send_alert(f"ChromaDB 백업 실패: {e}")
def move_old_backups_to_glacier():
"""7일 이상 된 백업을 Glacier로 이동"""
s3 = boto3.client('s3')
# Lifecycle Policy로 자동 처리
s3.put_bucket_lifecycle_configuration(
Bucket='my-backup-bucket',
LifecycleConfiguration={
'Rules': [
{
'Id': 'MoveToGlacier',
'Status': 'Enabled',
'Prefix': 'chromadb/recent/',
'Transitions': [
{
'Days': 7,
'StorageClass': 'GLACIER'
}
]
}
]
}
)
# 스케줄러 (6시간마다)
scheduler = BlockingScheduler()
scheduler.add_job(backup_chromadb, 'interval', hours=6)
scheduler.start()
💡 핵심 정리
SPOF 대응 원칙:
-
V1~V2 (MVP):
- SPOF 허용 (비용 최소화)
- 백업 + 빠른 복구
- RTO: 10분, RPO: 24시간
-
V3 (Production):
- 핵심 서비스 이중화
- AI 서버: RunPod + Lambda
- Database: Multi-AZ
- RTO: 2분, RPO: 6시간
-
V4+ (Enterprise):
- 완전 이중화
- K8s (Stateless) + Managed/EC2 (Stateful)
- VectorDB: Zilliz Cloud 또는 Milvus Standalone (EC2)
- RTO: 30초, RPO: 5분
점진적 개선:
- ✅ 비용 vs 안정성 균형
- ✅ 서비스 성장에 따라 단계적 투자
- ✅ 명확한 RTO/RPO 목표
- ✅ Stateful 서비스는 K8s 외부 관리 (안정성 우선)
📅 이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-01-15 | 초기 결정 (버전별 SPOF 대응 전략) |
ADR-044: vLLM 기반 오픈소스 LLM 배포 전략
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-01-21 |
| 결정자 | AI팀 |
| 관련 기능 | LLM 모델 선정, vLLM 배포, 성능 최적화 |
🎯 컨텍스트 (Context)
AI 취업 도우미 서비스의 LLM 모델 선정 및 배포 전략을 수립했습니다.
요구사항:
- 한국어 품질 우수
- 비용 효율적 운영
- L4 GPU 환경 최적화
- vLLM 호환성
테스트 환경:
- GCP g2-standard-4 (NVIDIA L4, 23GB VRAM)
- vLLM 0.13.0
- 4개 모델 테스트 (Qwen3-8B, Llama-3-Korean-8B, EXAONE-7.8B, A.X-3.1-Light)
🔍 선택지 분석 (Options)
Option 1: Qwen3-8B
테스트 결과:
- ❌ 치명적 결함: 한국어 프롬프트에도 영어로 응답
- ❌ 한국어 서비스에 부적합
- 결론: 즉시 탈락
Option 2: Llama-3-Korean-Bllossom-8B
강점:
- ✅ 한국어 품질 우수
- ✅ 맥락 유지 능력 우수
- ✅ 커뮤니티 지원 활발
약점:
- ⚠️ 응답 시간 느림 (평균 27.78s)
- ⚠️ JSON 형식 불일치 발생
평가 점수: 75점 (B)
Option 3: EXAONE-3.0-7.8B-Instruct ⭐ (최종 선정)
강점:
- ✅ 매칭 점수 90점 (가장 높음)
- ✅ 한국어 자연스러움 (LG AI Research 한국어 최적화)
- ✅ 응답 품질과 길이의 균형
- ✅ Apache 2.0 라이선스 (상업 사용 가능)
- ✅ vLLM 호환성 (FP8 양자화, 안정적 구동)
약점:
- ⚠️ 컨텍스트 길이 4096 (8K 미만)
평가 점수: 90점 (A-)
Option 4: A.X-3.1-Light
강점:
- ✅ 경량 모델 (빠른 응답)
- ✅ 한국어 품질 우수
- ✅ SKT 개발 (국내 환경 최적화)
약점:
- ⚠️ 테스트 데이터 부족
평가 점수: 85점 (B+)
✅ 결정 (Decision)
EXAONE-3.0-7.8B-Instruct 선정
| 버전 | 모델 | 배포 방식 | 이유 |
|---|---|---|---|
| V1-V2 | Gemini 3 Flash (API) | API 호출 | 빠른 출시, 안정성 |
| V3 | EXAONE-7.8B | vLLM (L4 GPU) | 비용 절감, 한국어 품질 |
| V4+ | EXAONE-32B | vLLM (A100 GPU) | 대규모 확장 |
📝 근거 (Rationale)
1. 한국어 품질
테스트 결과:
- EXAONE: 매칭 점수 90점, 자연스러운 한국어
- Llama-3-Korean: 매칭 점수 85점, 약간 딱딱함
- Qwen3: 영어 응답 (탈락)
2. vLLM 배포 설정
# EXAONE-7.8B vLLM 배포
docker run -d --runtime nvidia --gpus all \
--name vllm-server \
-v ~/.cache/huggingface:/root/.cache/huggingface \
--env "HF_TOKEN=$MY_HF_TOKEN" \
-p 8000:8000 \
--ipc=host \
vllm/vllm-openai:latest \
--model LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct \
--max-model-len 4096 \
--gpu-memory-utilization 0.9 \
--trust-remote-code
3. 비용 분석
| 옵션 | 월간 비용 | 비고 |
|---|---|---|
| Gemini API | $34-135 | V1-V2 |
| EXAONE-7.8B (L4) | $200 | V3 (선택) |
| EXAONE-32B (A100) | $500 | V4+ |
4. 성능 예상
| 지표 | 목표 | EXAONE 예상 | 상태 |
|---|---|---|---|
| TTFT | < 500ms | ~300ms | ✅ |
| 전체 응답 시간 | < 10초 | ~5-7초 | ✅ |
| TPS | > 30 | ~40 | ✅ |
| 한국어 품질 | > 4.5/5.0 | 4.5-4.7/5.0 | ✅ |
| 매칭 점수 | > 80점 | 90점 | ✅ |
🔧 구현 계획
V1-V2 (현재)
- Gemini API 사용
- 모델 평가 완료
- EXAONE 선정
V3 (3-6개월)
- EXAONE-7.8B vLLM 배포
- 프로덕션 환경 테스트
- 모니터링 대시보드 구축
- 비용 모니터링
V4+ (6-12개월)
- EXAONE-32B 테스트
- A100 GPU 전환
- 대규모 확장
📊 비교표
| 모델 | 한국어 | 매칭 점수 | 응답 속도 | 비용/월 | 결과 |
|---|---|---|---|---|---|
| Qwen3-8B | ❌ | - | - | - | 탈락 |
| Llama-3-Korean | ✅ | 85점 | ⚠️ 느림 | $200 | 후보 |
| EXAONE-7.8B | ✅ | 90점 | ✅ 빠름 | $200 | 선정 |
| A.X-3.1-Light | ✅ | 85점 | ✅ 빠름 | $200 | 후보 |
📅 이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-01-20 | 4개 모델 테스트 완료 |
| 2026-01-21 | EXAONE-7.8B 최종 선정 |
ADR-045: Tavily 검색 API 도입 보류
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-01-21 |
| 결정자 | AI팀 |
| 관련 기능 | 검색 API, 웹 크롤링, 데이터 수집, 채용 트렌드 수집 |
| 관련 ADR | ADR-056 |
🎯 컨텍스트 (Context)
AI 취업 도우미 서비스에서 Tavily Search API 도입 여부를 검토했습니다.
Tavily란:
- AI를 위한 검색 API
- 웹 검색, 뉴스 검색, AI-friendly 결과 제공
- 빠른 응답 속도 (< 2초)
검토 배경:
- LLM 기반 서비스에서 실시간 정보 제공 가능성 검토
🔍 선택지 분석 (Options)
Option 1: Tavily 도입
장점:
- 실시간 정보 제공 가능
- AI 최적화된 검색 결과
- LangChain 통합 용이
단점:
- ❌ 현재 기획과 부합하지 않음
- ❌ 추가 비용 ($49-149/월)
- ❌ 개인정보 유출 위험
- ❌ 불필요한 복잡도 증가
Option 2: 도입 보류 ⭐ (선정)
장점:
- ✅ 비용 절감 ($49-149/월)
- ✅ 개인정보 보호
- ✅ 단순한 아키텍처
- ✅ 빠른 개발
단점:
- ⚠️ 실시간 정보 제공 불가
- ⚠️ 기능 확장 제약
✅ 결정 (Decision)
Tavily 도입 보류
| 버전 | 결정 | 이유 |
|---|---|---|
| V1-V2 | 도입 안 함 | 현재 기획과 부합하지 않음 |
| V3+ | 재검토 | 사용자 요구 확인 후 결정 |
📝 근거 (Rationale)
1. 현재 기획과의 부합성
현재 서비스의 데이터 소스:
- 이력서 분석: 사용자 업로드 파일
- 채용공고 분석: 사용자 입력 텍스트
- 매칭 점수: 내부 로직 (LLM)
- 면접 질문: LLM 생성
- 챗봇 (RAG): VectorDB (사용자 데이터)
결론: 모든 기능이 사용자 제공 데이터 기반 → 웹 검색 불필요
2. 비용 효율성
Tavily 가격:
- Free: 1,000 요청/월
- Basic: $49/월 (10,000 요청)
- Pro: $149/월 (100,000 요청)
결론: 불필요한 기능에 대한 비용 지출
3. 개인정보 보호
보안 리스크:
이력서 내용 → Tavily API → 외부 서버
- 개인정보 유출 위험
- GDPR/개인정보보호법 위반 가능
결론: 불필요한 보안 리스크 회피
📅 향후 재검토 시나리오
V3+ 기능 확장 시 고려
시나리오 1: 회사 정보 자동 수집
- 채용공고에서 회사명 추출 → Tavily로 회사 정보 검색
- 도입 조건: 사용자 요구 확인, 비용 대비 효과 분석
시나리오 2: 채용 시장 동향 분석
- 직무별 채용 트렌드 검색
- 도입 조건: 프리미엄 기능, 월간 검색 횟수 제한
시나리오 3: 경쟁사 채용공고 비교
- 유사 채용공고 검색 및 비교
- 도입 조건: B2B 기능, 법적 검토 완료
🔄 데이터 크롤링 전략: Tavily vs AWS (보완)
일반 검색 API는 보류하되, 채용 트렌드 등 데이터 수집이 필요해질 때 Tavily와 AWS 기반 크롤링 중 어떤 것을 쓸지 기준을 둡니다.
Tavily의 위치
- 역할: 검색 API + URL 내용 추출. “크롤링”이라기보다 검색 결과·지정 URL 본문 수집에 가깝다.
- 적합한 경우: 쿼리 기반으로 채용 트렌드 관련 기사·보고서를 찾고, URL 리스트나 본문을 가져와 S3 등에 적재하는 수준의 수집.
- 프로젝트 내 계획: ADR-056
060, 076080 등에서 채용 트렌드 검색/수집은 Tavily를 별도 ADR로 도입 예정으로 정리됨. 본 ADR의 “보류”는 일반 검색 API에 대한 것이며, 채용 트렌드 전용 도입과는 별도로 재검토한다.
AWS 기반 크롤링의 위치
- 역할: SageMaker Processing Job, Lambda + EventBridge, ECS/Fargate 등으로 자체 크롤러(Scrapy, BeautifulSoup, Playwright 등)를 실행. 대상 사이트·스케줄·포맷을 코드로 완전히 제어.
- 적합한 경우: 특정 사이트를 정해진 구조로 대량·정기 크롤링해야 하거나, Tavily가 커버하지 않는 소스를 써야 할 때.
- SageMaker와의 연동: 수집 결과를 S3에 넣은 뒤, SageMaker Pipelines(전처리 → Training → Model Registry → Endpoint)와 연결 가능. ADR-081, 083, 087의 “데이터 수집 → 배치 학습” 파이프라인 전단으로 사용.
비교 요약
| 기준 | Tavily | AWS(SageMaker Processing / Lambda·ECS 등) |
|---|---|---|
| 데이터 소스 | 검색 결과·지정 URL. Tavily가 제공하는 범위 내 | 원하는 사이트·API를 코드로 직접 지정 |
| 도입 속도 | API 키만 있으면 빠름 | 인프라 설계·크롤러 개발·스케줄·에러 처리 필요 |
| 비용 | API 호출당/월 구독 | 인프라 사용량(Processing 시간, Lambda, ECS 등) |
| 유지보수 | Tavily 서비스·할당량에 의존 | 크롤러·대상 사이트 변경·IP 차단 대응 등 직접 관리 |
선택 기준 (채용 트렌드 수집 시)
- 검색 쿼리 + URL 내용 수집으로 충분 → Tavily 도입을 우선 검토. (기존 ADR에서 채용 트렌드 수집 후보로 정해 둔 방향과 일치.)
- 특정 사이트를 정해진 형식으로 대량·정기 크롤링 필요 → AWS 기반 크롤링(SageMaker Processing 또는 Lambda/ECS) 검토.
- 혼합: Tavily로 채용 트렌드 후보 수집 → 수집 결과를 S3에 적재 → AWS(SageMaker 등)에서 전처리·학습·배포. ADR-083, 087의 파이프라인과 동일한 흐름.
✅ 결론: Tavily 선택
채용 트렌드 등 데이터 크롤링·수집에는 Tavily를 선택한다.
- 채용 트렌드 수집은 검색 쿼리 + URL/본문 수집으로 충분한 범위이므로, 도입 속도·운영 부담·기존 ADR 계획을 고려할 때 Tavily가 적합하다.
- AWS 기반 자체 크롤링은 Tavily로 커버되지 않는 소스나 대량·정기 크롤링 요구가 생길 때만 보완적으로 검토한다.
- 수집된 데이터는 S3 적재 후 SageMaker 등으로 전처리·학습·배포하는 파이프라인(ADR-083, 087)과 연동한다.
📊 비교표
| 항목 | Tavily 도입 | 도입 보류 |
|---|---|---|
| 비용 | $49-149/월 | $0 |
| 기능 필요성 | ⚠️ 낮음 | ✅ 충분 |
| 개인정보 보호 | ⚠️ 리스크 | ✅ 안전 |
| 기술 복잡도 | 🔴 높음 | 🟢 낮음 |
| V1-V2 적합성 | ❌ | ✅ 추천 |
📅 이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-01-21 | Tavily 도입 검토 및 보류 결정 |
| 2026-02-23 | 데이터 크롤링 전략 보완: Tavily vs AWS(채용 트렌드 수집 시 선택 기준) 추가 |
| 2026-02-23 | ADR-094 참조: Tavily 대신 LangChain WebBaseLoader 자체 크롤링으로 결정 변경 |
🎊 결론
V3 하이브리드 스케일링 전략 완성!
| 구성 요소 | 역할 | 비용 |
|---|---|---|
| RunPod | 베이스 라인 (70%) | $350/월 |
| Lambda | 버스트 (30%) | $30/월 |
| API Gateway | 로드 밸런싱 | $10/월 |
| CloudWatch | 모니터링 | $5/월 |
| 총 비용 | - | $395/월 |
절감 효과: RunPod 단독 대비 21% 절감 ($500 → $395) ✅
이제 비용 효율적이고 안정적인 인프라가 완성되었습니다! 🎉