[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)

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (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+/월

💡 핵심 정리

하이브리드 스케일링 전략:

  1. RunPod (베이스 라인): 평시 70% 부하 처리

    • GPU 서버 (항상 ON)
    • 안정적 성능
    • $350/월
  2. Lambda (버스트): 피크 30% 부하 처리

    • 서버리스 (피크만 ON)
    • 자동 스케일링
    • $30/월
  3. 로드 밸런서: 스마트 라우팅

    • 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

모델 정보:

테스트 결과:

한글 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/ ✅ 감지됨 ⚠️ 부분 감지
LinkedIn www.linkedin.com/... ✅ 감지됨 ⚠️ 부분 감지
Notion https://...notion.site/... ✅ 감지됨 ⚠️ 부분 감지

정확도:

  • OCR + Regex: 95%
  • Distil-PII-Llama: 30%

💡 핵심 정리

Distil-PII-Llama-3.2-1B-Instruct를 사용하지 않는 이유:

  1. 한글 지원 부족

    • 한글 이름, 주소 인식 실패
    • 영어 중심 학습 데이터
  2. Contact 영역 구분 불가

    • Experience 섹션도 마스킹 시도
    • False Positive 높음
  3. 낮은 정확도

    • 한글 PII 감지율 30%
    • OCR + Regex 대비 65% 낮음
  4. 리소스 소모

    • 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 대응 원칙:

  1. V1~V2 (MVP):

    • SPOF 허용 (비용 최소화)
    • 백업 + 빠른 복구
    • RTO: 10분, RPO: 24시간
  2. V3 (Production):

    • 핵심 서비스 이중화
    • AI 서버: RunPod + Lambda
    • Database: Multi-AZ
    • RTO: 2분, RPO: 6시간
  3. 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-056060·076080 (채용 트렌드·Tavily 별도 도입 예정), ADR-081·083·087 (SageMaker·배치 학습·파이프라인)

🎯 컨텍스트 (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는 보류하되, 채용 트렌드 등 데이터 수집이 필요해질 때 TavilyAWS 기반 크롤링 중 어떤 것을 쓸지 기준을 둡니다.

Tavily의 위치

  • 역할: 검색 API + URL 내용 추출. “크롤링”이라기보다 검색 결과·지정 URL 본문 수집에 가깝다.
  • 적합한 경우: 쿼리 기반으로 채용 트렌드 관련 기사·보고서를 찾고, URL 리스트나 본문을 가져와 S3 등에 적재하는 수준의 수집.
  • 프로젝트 내 계획: ADR-056060, 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) ✅

이제 비용 효율적이고 안정적인 인프라가 완성되었습니다! 🎉