This commit is contained in:
hiondal 2025-06-15 13:52:26 +00:00
commit 6a5c411800
53 changed files with 15785 additions and 0 deletions

120
.gitignore vendored Normal file
View File

@ -0,0 +1,120 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Virtual environments
venv/
env/
ENV/
env.bak/
venv.bak/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
*.log
logs/
# Vector DB and ML Models
vector_db/
*.pkl
*.model
*.bin
models/
embeddings/
checkpoints/
# Data files
data/
*.csv
*.json
*.xlsx
*.parquet
# Temporary files
tmp/
temp/
*.tmp
*.temp
# Docker
.dockerignore
# Node.js (if applicable)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Database
*.db
*.sqlite
*.sqlite3
# Backup files
*.bak
*.backup

415
restaurant/README.md Normal file
View File

@ -0,0 +1,415 @@
# 카카오 API 기반 음식점 수집 서비스
카카오 로컬 API를 활용하여 음식점 정보를 수집하고 관리하는 RESTful API 서비스입니다.
## 📋 프로젝트 개요
### 주요 기능
- 🔍 **카카오 로컬 API 연동**: 키워드 및 지역 기반 음식점 검색
- 📊 **다중 페이지 수집**: 최대 45페이지까지 데이터 수집 지원
- 💾 **자동 JSON 저장**: 수집된 데이터를 JSON 파일로 자동 저장
- 🚀 **RESTful API 제공**: FastAPI 기반의 완전한 API 서비스
- 📚 **Swagger UI 지원**: 자동 생성되는 API 문서
- ☸️ **Kubernetes 배포**: 완전한 컨테이너 오케스트레이션 지원
- 🔧 **환경변수 설정**: ConfigMap과 Secret을 통한 유연한 설정 관리
### 기술 스택
- **Backend**: Python 3.11, FastAPI, aiohttp
- **API**: Kakao Local API
- **Container**: Docker, Multi-stage build
- **Orchestration**: Kubernetes (AKS)
- **Registry**: Azure Container Registry (ACR)
- **Documentation**: Swagger UI, ReDoc
## 🏗️ 프로젝트 구조
```
review-api/restaurant/
├── app/ # 애플리케이션 소스
│ ├── main.py # 메인 애플리케이션
│ └── requirements.txt # Python 의존성
├── deployment/ # 배포 관련 파일
│ ├── container/ # 컨테이너 이미지 빌드
│ │ ├── Dockerfile # 서비스 이미지 빌드
│ │ └── Dockerfile-base # 베이스 이미지 빌드
│ └── manifests/ # Kubernetes 매니페스트
│ ├── configmap.yaml # 환경 설정
│ ├── secret.yaml # 민감 정보 (API 키)
│ ├── deployment.yaml # 애플리케이션 배포
│ ├── service.yaml # 서비스 노출
│ └── ingress.yaml # 외부 접근
├── build-base.sh # 베이스 이미지 빌드 스크립트
├── build.sh # 서비스 이미지 빌드 스크립트
├── create-imagepullsecret.sh # ACR 인증 설정 스크립트
├── setup.sh # 로컬 환경 설정 스크립트
└── README.md # 프로젝트 문서
```
## 📋 사전 작업
### 카카오 API 설정
카카오 developers 포탈에서 애플리케이션 등록이 필요합니다.
1. **포탈 접속**: https://developers.kakao.com/console/app
2. **애플리케이션 등록**:
- 앱 이름: `RestaurantCollector`
- 회사명: `{회사명}`
- 카테고리: `식음료`
3. **카카오맵 활성화**: 등록한 애플리케이션에서 좌측 '카카오맵' 메뉴 클릭하여 활성화
## 🚀 빠른 시작
### 1. 로컬 개발 환경 설정
```bash
# 저장소 클론 (review-api/restaurant 디렉토리로 이동)
cd review-api/restaurant
# 환경 설정 스크립트 실행
chmod +x setup.sh
./setup.sh
# 가상환경 활성화
source venv/bin/activate
# 애플리케이션 실행
python app/main.py
```
### 2. 로컬 웹 브라우저 접속
```bash
# 애플리케이션이 정상 실행된 후 아래 URL로 접속
```
- **메인 페이지**: http://localhost:18000
- **Swagger UI**: http://localhost:18000/docs
- **ReDoc**: http://localhost:18000/redoc
- **헬스체크**: http://localhost:18000/health
### 3. API 테스트
```bash
# 기본 음식점 수집
curl -X POST "http://localhost:18000/collect" \
-H "Content-Type: application/json" \
-d '{
"query": "치킨",
"region": "서울",
"size": 15,
"pages": 3,
"save_to_file": true
}'
# 수집된 파일 목록 조회
curl "http://localhost:18000/list-files"
# 파일 다운로드 (예시)
curl "http://localhost:18000/download/restaurant.json" -o downloaded_restaurants.json
```
## 🐳 Docker 컨테이너 실행
### 베이스 이미지 빌드
```bash
# ACR에 베이스 이미지 빌드 및 푸시
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
```
### 서비스 이미지 빌드
```bash
# ACR에 서비스 이미지 빌드 및 푸시
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
```
### 로컬 Docker 실행
```bash
# 컨테이너 실행
docker run -p 18000:8000 \
-e KAKAO_API_KEY=5cdc24407edbf8544f3954cfaa4650c6 \
-e PORT=8000 \
restaurant-api:latest
# 백그라운드 실행
docker run -d -p 18000:8000 \
--name restaurant-api \
-e KAKAO_API_KEY=5cdc24407edbf8544f3954cfaa4650c6 \
-e PORT=8000 \
restaurant-api:latest
```
## ☸️ Kubernetes 배포
### 1. ACR Image Pull Secret 생성
```bash
# Image Pull Secret 생성
./create-imagepullsecret.sh acrdigitalgarage03 rg-digitalgarage-03
```
### 2. Kubernetes 리소스 배포
```bash
# ConfigMap 및 Secret 적용
kubectl apply -f deployment/manifests/configmap.yaml
kubectl apply -f deployment/manifests/secret.yaml
# 애플리케이션 배포
kubectl apply -f deployment/manifests/deployment.yaml
kubectl apply -f deployment/manifests/service.yaml
kubectl apply -f deployment/manifests/ingress.yaml
```
### 3. 배포 상태 확인
```bash
# Pod 상태 확인
kubectl get pods -l app=restaurant-api
# 서비스 상태 확인
kubectl get svc restaurant-api-service
# Ingress 상태 확인
kubectl get ingress restaurant-api-ingress
# 로그 확인
kubectl logs -l app=restaurant-api -f
```
### 4. 🌐 외부 브라우저에서 접속하기
#### Ingress 주소 확인 방법
```bash
# 1. Ingress 설정된 호스트 확인
kubectl get ingress restaurant-api-ingress -o jsonpath='{.spec.rules[0].host}'
# 2. Ingress External IP 확인 (LoadBalancer 타입인 경우)
kubectl get ingress restaurant-api-ingress
# 3. Ingress Controller의 External IP 확인
kubectl get svc -n ingress-nginx ingress-nginx-controller
# 4. 현재 설정된 ingress 주소 확인
INGRESS_HOST=$(kubectl get ingress restaurant-api-ingress -o jsonpath='{.spec.rules[0].host}')
echo "🌐 Restaurant API URL: http://${INGRESS_HOST}"
```
#### 브라우저 접속 주소
현재 설정된 주소로 접속하세요:
```bash
# 현재 설정된 기본 주소 (환경에 따라 다를 수 있음)
INGRESS_URL="http://restaurant-api.20.249.191.180.nip.io"
echo "브라우저에서 접속: ${INGRESS_URL}"
```
**주요 접속 페이지:**
- **🏠 메인 페이지**: http://restaurant-api.20.249.191.180.nip.io
- **📖 Swagger UI**: http://restaurant-api.20.249.191.180.nip.io/docs
- **📄 ReDoc**: http://restaurant-api.20.249.191.180.nip.io/redoc
- **❤️ 헬스체크**: http://restaurant-api.20.249.191.180.nip.io/health
#### 접속 테스트
```bash
# API 접속 테스트
curl "http://restaurant-api.20.249.191.180.nip.io/health"
# 설정 정보 확인
curl "http://restaurant-api.20.249.191.180.nip.io/config"
# Swagger UI 접속 확인
curl -I "http://restaurant-api.20.249.191.180.nip.io/docs"
```
## ⚙️ 환경 설정
### 환경 변수
| 변수명 | 기본값 | 설명 |
|--------|--------|------|
| `KAKAO_API_KEY` | `5cdc24407edbf8544f3954cfaa4650c6` | 카카오 API 키 |
| `APP_TITLE` | `카카오 API 기반 음식점 수집 서비스` | 애플리케이션 제목 |
| `HOST` | `0.0.0.0` | 서버 호스트 |
| `PORT` | `8000` | 서버 포트 (컨테이너 내부) |
| `DEFAULT_QUERY` | `음식점` | 기본 검색 키워드 |
| `DEFAULT_REGION` | `서울` | 기본 검색 지역 |
| `DEFAULT_SIZE` | `15` | 페이지당 결과 수 |
| `MAX_PAGES` | `45` | 최대 페이지 수 |
| `DATA_DIR` | `/app/data` | 데이터 저장 디렉토리 |
| `REQUEST_DELAY` | `0.1` | API 요청 간 지연시간(초) |
## 📊 API 엔드포인트
### 주요 엔드포인트
| Method | Endpoint | 설명 |
|--------|----------|------|
| `GET` | `/` | 메인 페이지 |
| `GET` | `/docs` | Swagger UI 문서 |
| `GET` | `/health` | 헬스체크 |
| `POST` | `/collect` | 음식점 정보 수집 |
| `POST` | `/collect-background` | 백그라운드 수집 |
| `GET` | `/list-files` | 저장된 파일 목록 |
| `GET` | `/download/{filename}` | 파일 다운로드 |
| `GET` | `/config` | 환경 설정 확인 |
### 수집 API 예시
```json
POST /collect
{
"query": "피자",
"region": "강남구",
"size": 15,
"pages": 5,
"save_to_file": true
}
```
**응답:**
```json
{
"success": true,
"message": "음식점 정보 수집이 완료되었습니다. (총 67개)",
"metadata": {
"collection_date": "2024-06-12T10:30:00",
"query": "피자",
"region": "강남구",
"total_count": 67,
"pages_collected": 5,
"execution_time": 12.5
},
"restaurants": [...],
"file_saved": true,
"file_path": "/app/data/restaurants_피자_강남구_20240612_103000.json"
}
```
## 🔧 개발 및 확장
### 로컬 개발
```bash
# 개발 모드로 실행 (자동 재시작)
uvicorn app.main:app --host 0.0.0.0 --port 18000 --reload
# 의존성 추가
pip install 새패키지명
pip freeze > app/requirements.txt
# 코드 포맷팅
black app/main.py
flake8 app/main.py
```
### Ingress 호스트 변경
현재 환경에 맞게 Ingress 호스트를 변경하려면:
```bash
# 1. 현재 External IP 확인
kubectl get svc -n ingress-nginx ingress-nginx-controller
# 2. deployment/manifests/ingress.yaml 파일에서 host 수정
# 예: restaurant-api.{YOUR_EXTERNAL_IP}.nip.io
# 3. 변경사항 적용
kubectl apply -f deployment/manifests/ingress.yaml
# 4. 새로운 주소 확인
kubectl get ingress restaurant-api-ingress
```
## 🐛 문제 해결
### 일반적인 문제
**1. API 키 인증 실패**
```bash
# API 키 확인
curl -H "Authorization: KakaoAK YOUR_API_KEY" \
"https://dapi.kakao.com/v2/local/search/keyword.json?query=테스트&size=1"
# 환경변수 확인
echo $KAKAO_API_KEY
```
**2. Kubernetes 배포 실패**
```bash
# Pod 로그 확인
kubectl logs -l app=restaurant-api
# ConfigMap 확인
kubectl get configmap restaurant-api-config -o yaml
# Secret 확인
kubectl get secret restaurant-api-secret -o yaml
```
**3. Ingress 접속 실패**
```bash
# Ingress Controller 상태 확인
kubectl get pods -n ingress-nginx
# Ingress 규칙 확인
kubectl describe ingress restaurant-api-ingress
# Service 연결 확인
kubectl get endpoints restaurant-api-service
```
**4. 포트 관련 문제**
- 로컬 개발: 18000번 포트 사용
- Docker 컨테이너: 8000번 포트로 실행 (외부 18000번으로 매핑)
- Kubernetes: 8000번 포트로 실행 (Service에서 80번으로 노출, Ingress를 통해 외부 접근)
## 🎯 성능 최적화
### 권장 설정
```yaml
# deployment.yaml에서 리소스 최적화
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
```
### 모니터링
```bash
# 리소스 사용량 확인
kubectl top pods -l app=restaurant-api
# 메트릭 수집 (Prometheus 설정 시)
kubectl get --raw /metrics
```
## 📈 향후 확장 계획
- [ ] **전국 확대**: 지역별 음식점 수집 기능 확장
- [ ] **데이터베이스 연동**: PostgreSQL, MongoDB 지원
- [ ] **비동기 작업 큐**: Celery, Redis 활용
- [ ] **데이터 분석**: 음식점 트렌드 분석 기능
- [ ] **알림 시스템**: 새로운 음식점 알림 기능
- [ ] **사용자 인증**: JWT 기반 인증 시스템
- [ ] **API 레이트 리미팅**: 요청 제한 기능
- [ ] **GraphQL 지원**: 유연한 쿼리 인터페이스
- [ ] **실시간 모니터링**: Grafana, Prometheus 연동
---
## 📞 지원 및 문의
- **이슈 리포트**: GitHub Issues
- **기술 문의**: 개발팀 Slack
- **API 문서**: Swagger UI에서 상세 확인

741
restaurant/app/main.py Normal file
View File

@ -0,0 +1,741 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
카카오 API 기반 음식점 수집 서비스
review-api/restaurant/app/main.py
"""
import os
import json
import asyncio
import aiohttp
import logging
import sys
from datetime import datetime
from typing import Optional, List, Dict, Any
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel, Field
import uvicorn
# =============================================================================
# .env 파일 로딩 (다른 import보다 먼저)
# =============================================================================
from dotenv import load_dotenv
# .env 파일에서 환경변수 로드
load_dotenv()
# =============================================================================
# 로깅 설정
# =============================================================================
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger(__name__)
# =============================================================================
# 환경 변수 설정
# =============================================================================
class Config:
"""환경 변수 기반 설정 클래스"""
# 애플리케이션 메타데이터
APP_TITLE = os.getenv("APP_TITLE", "카카오 API 기반 음식점 수집 서비스")
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
APP_DESCRIPTION = os.getenv("APP_DESCRIPTION", "카카오 로컬 API를 활용한 음식점 정보 수집 시스템")
# 서버 설정
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))
LOG_LEVEL = os.getenv("LOG_LEVEL", "info")
# 카카오 API 설정
KAKAO_API_KEY = os.getenv("KAKAO_API_KEY", "5cdc24407edbf8544f3954cfaa4650c6")
KAKAO_API_URL = "https://dapi.kakao.com/v2/local/search/keyword.json"
# 검색 설정
DEFAULT_QUERY = os.getenv("DEFAULT_QUERY", "음식점")
DEFAULT_REGION = os.getenv("DEFAULT_REGION", "서울")
DEFAULT_SIZE = int(os.getenv("DEFAULT_SIZE", "15"))
MAX_SIZE = int(os.getenv("MAX_SIZE", "15")) # 카카오 API 최대값
MAX_PAGES = int(os.getenv("MAX_PAGES", "45")) # 카카오 API 최대 페이지 (45페이지 * 15 = 675개)
# 파일 설정
OUTPUT_FILE = os.getenv("OUTPUT_FILE", "restaurant.json")
DATA_DIR = os.getenv("DATA_DIR", "./data")
# 요청 제한 설정
REQUEST_DELAY = float(os.getenv("REQUEST_DELAY", "0.1")) # API 요청 간 지연시간(초)
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30"))
# 헬스체크 설정
HEALTH_CHECK_TIMEOUT = int(os.getenv("HEALTH_CHECK_TIMEOUT", "10"))
config = Config()
# 데이터 디렉토리 생성
os.makedirs(config.DATA_DIR, exist_ok=True)
# FastAPI 앱 초기화
app = FastAPI(
title=config.APP_TITLE,
description=f"""
{config.APP_DESCRIPTION}
**주요 기능:**
- 카카오 로컬 API를 활용한 음식점 정보 수집
- 지역별, 키워드별 검색 지원
- JSON 파일 자동 저장 기능
- RESTful API 제공
- Swagger UI 문서 제공
**API :** {config.KAKAO_API_KEY[:10]}...
**버전:** {config.APP_VERSION}
""",
version=config.APP_VERSION,
contact={
"name": "관리자",
"email": "admin@example.com"
}
)
# =============================================================================
# Pydantic 모델 정의
# =============================================================================
class RestaurantSearchRequest(BaseModel):
"""음식점 검색 요청 모델"""
query: str = Field(
default=config.DEFAULT_QUERY,
description="검색 키워드 (예: 치킨, 피자, 한식)",
example="치킨"
)
region: str = Field(
default=config.DEFAULT_REGION,
description="검색 지역 (예: 서울, 부산, 대구)",
example="서울"
)
size: int = Field(
default=config.DEFAULT_SIZE,
description=f"페이지당 결과 수 (1-{config.MAX_SIZE})",
example=15,
ge=1,
le=config.MAX_SIZE
)
pages: int = Field(
default=5,
description=f"검색할 페이지 수 (1-{config.MAX_PAGES})",
example=5,
ge=1,
le=config.MAX_PAGES
)
save_to_file: bool = Field(
default=True,
description="결과를 JSON 파일로 저장할지 여부",
example=True
)
class RestaurantInfo(BaseModel):
"""음식점 정보 모델"""
id: str = Field(description="카카오 장소 ID")
place_name: str = Field(description="장소명")
category_name: str = Field(description="카테고리명")
category_group_code: str = Field(description="카테고리 그룹 코드")
category_group_name: str = Field(description="카테고리 그룹명")
phone: str = Field(description="전화번호")
address_name: str = Field(description="전체 지번 주소")
road_address_name: str = Field(description="전체 도로명 주소")
place_url: str = Field(description="장소 상세페이지 URL")
distance: str = Field(description="중심좌표까지의 거리 (meter)")
x: str = Field(description="X 좌표값, 경위도인 경우 longitude")
y: str = Field(description="Y 좌표값, 경위도인 경우 latitude")
class CollectionMetadata(BaseModel):
"""수집 메타데이터"""
collection_date: str = Field(description="수집 날짜시간")
query: str = Field(description="검색 키워드")
region: str = Field(description="검색 지역")
total_count: int = Field(description="총 수집된 음식점 수")
pages_collected: int = Field(description="수집된 페이지 수")
api_key_used: str = Field(description="사용된 API 키 (마스킹)")
execution_time: float = Field(description="실행 시간(초)")
class RestaurantSearchResponse(BaseModel):
"""음식점 검색 응답 모델"""
success: bool = Field(description="검색 성공 여부")
message: str = Field(description="응답 메시지")
metadata: CollectionMetadata
restaurants: List[RestaurantInfo]
file_saved: bool = Field(description="파일 저장 여부")
file_path: Optional[str] = Field(description="저장된 파일 경로")
class ErrorResponse(BaseModel):
"""에러 응답 모델"""
success: bool = False
error: str
message: str
timestamp: str
# =============================================================================
# 카카오 API 클라이언트
# =============================================================================
class KakaoRestaurantCollector:
"""카카오 API를 활용한 음식점 수집기"""
def __init__(self):
self.api_key = config.KAKAO_API_KEY
self.api_url = config.KAKAO_API_URL
self.session = None
logger.info("KakaoRestaurantCollector 초기화 완료")
async def __aenter__(self):
"""비동기 컨텍스트 매니저 진입"""
self.session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=config.REQUEST_TIMEOUT),
headers={
'Authorization': f'KakaoAK {self.api_key}',
'Content-Type': 'application/json'
}
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""비동기 컨텍스트 매니저 종료"""
if self.session:
await self.session.close()
async def search_restaurants(self, query: str, region: str, size: int = 15, pages: int = 5) -> Dict[str, Any]:
"""음식점 검색 실행 (개선된 버전)"""
logger.info(f"음식점 검색 시작: query='{query}', region='{region}', size={size}, pages={pages}")
start_time = datetime.now()
all_restaurants = []
collected_pages = 0
empty_page_count = 0 # 연속 빈 페이지 카운터 추가
try:
for page in range(1, pages + 1):
logger.info(f"페이지 {page}/{pages} 검색 중...")
# API 요청 파라미터 - 더 구체적인 검색
params = {
'query': f"{query} {region}",
'category_group_code': 'FD6', # 음식점 카테고리
'page': page,
'size': size,
'sort': 'accuracy'
}
try:
async with self.session.get(self.api_url, params=params) as response:
if response.status == 200:
data = await response.json()
documents = data.get('documents', [])
if not documents:
empty_page_count += 1
logger.warning(f"페이지 {page}에서 결과 없음 (연속 빈 페이지: {empty_page_count})")
# 연속 3페이지가 비어있으면 종료 (개선)
if empty_page_count >= 3:
logger.info(f"연속 {empty_page_count}페이지 빈 결과로 검색 종료")
break
# 빈 페이지여도 계속 진행
await asyncio.sleep(config.REQUEST_DELAY)
continue
# 결과가 있으면 빈 페이지 카운터 리셋
empty_page_count = 0
# 음식점 정보 추출 및 저장
page_restaurants = []
for doc in documents:
restaurant = self._extract_restaurant_info(doc)
if restaurant:
page_restaurants.append(restaurant)
all_restaurants.extend(page_restaurants)
collected_pages += 1
logger.info(f"페이지 {page} 완료: {len(page_restaurants)}개 음식점 수집 (총: {len(all_restaurants)}개)")
# API 요청 제한을 위한 지연
if page < pages:
await asyncio.sleep(config.REQUEST_DELAY)
elif response.status == 400:
error_data = await response.json()
logger.warning(f"API 요청 오류 (페이지 {page}): {error_data}")
# 400 오류여도 계속 진행 (다른 페이지는 성공할 수 있음)
empty_page_count += 1
if empty_page_count >= 5:
break
continue
elif response.status == 401:
logger.error("API 키 인증 실패")
raise HTTPException(status_code=401, detail="카카오 API 키 인증 실패")
elif response.status == 429:
logger.warning("API 요청 한도 초과 - 2초 대기 후 재시도")
await asyncio.sleep(2)
continue
else:
logger.error(f"API 요청 실패: HTTP {response.status}")
empty_page_count += 1
if empty_page_count >= 5:
break
continue
except asyncio.TimeoutError:
logger.error(f"페이지 {page} 요청 타임아웃")
empty_page_count += 1
if empty_page_count >= 5:
break
continue
except Exception as e:
logger.error(f"페이지 {page} 요청 중 오류: {e}")
empty_page_count += 1
if empty_page_count >= 5:
break
continue
execution_time = (datetime.now() - start_time).total_seconds()
# 중복 제거
logger.info(f"중복 제거 시작: {len(all_restaurants)}")
# 중복 제거 통계
stats = {
'total': len(all_restaurants),
'by_place_url': 0,
'by_place_id': 0,
'by_coordinates': 0,
'duplicates': 0
}
unique_restaurants = {}
for restaurant in all_restaurants:
place_url = restaurant.get('place_url', '').strip()
place_id = restaurant.get('id', '').strip()
place_name = restaurant.get('place_name', '').strip()
x = restaurant.get('x', '').strip()
y = restaurant.get('y', '').strip()
# 고유 키 생성 (다중 전략)
unique_key = None
if place_url:
unique_key = f"url:{place_url}"
stats['by_place_url'] += 1
elif place_id:
unique_key = f"id:{place_id}"
stats['by_place_id'] += 1
elif place_name and x and y:
unique_key = f"coord:{place_name}:{x}:{y}"
stats['by_coordinates'] += 1
else:
# 마지막 수단: 이름만으로
unique_key = f"name:{place_name}"
if unique_key:
if unique_key not in unique_restaurants:
unique_restaurants[unique_key] = restaurant
else:
stats['duplicates'] += 1
final_restaurants = list(unique_restaurants.values())
logger.info(f"중복 제거 완료:")
logger.info(f" 총 입력: {stats['total']}")
logger.info(f" URL 기준: {stats['by_place_url']}")
logger.info(f" ID 기준: {stats['by_place_id']}")
logger.info(f" 좌표 기준: {stats['by_coordinates']}")
logger.info(f" 중복 제거: {stats['duplicates']}")
logger.info(f" 최종 결과: {len(final_restaurants)}")
logger.info(f" 중복률: {(stats['duplicates']/stats['total']*100):.1f}%")
logger.info(f"검색 완료: 총 {len(final_restaurants)}개 음식점 수집 (중복 제거 후), 수집된 페이지: {collected_pages}")
return {
'restaurants': final_restaurants,
'metadata': {
'collection_date': start_time.isoformat(),
'query': query,
'region': region,
'total_count': len(final_restaurants),
'pages_collected': collected_pages,
'pages_requested': pages,
'api_key_used': f"{self.api_key[:10]}...",
'execution_time': execution_time
}
}
except Exception as e:
logger.error(f"음식점 검색 중 오류: {e}")
raise
def _extract_restaurant_info(self, document: Dict) -> Dict[str, Any]:
"""카카오 API 응답에서 음식점 정보 추출"""
try:
return {
'id': document.get('id', ''),
'place_name': document.get('place_name', ''),
'category_name': document.get('category_name', ''),
'category_group_code': document.get('category_group_code', ''),
'category_group_name': document.get('category_group_name', ''),
'phone': document.get('phone', ''),
'address_name': document.get('address_name', ''),
'road_address_name': document.get('road_address_name', ''),
'place_url': document.get('place_url', ''),
'distance': document.get('distance', ''),
'x': document.get('x', ''),
'y': document.get('y', '')
}
except Exception as e:
logger.warning(f"음식점 정보 추출 실패: {e}")
return None
# =============================================================================
# 파일 관리 유틸리티
# =============================================================================
def save_restaurants_to_file(data: Dict[str, Any], filename: str = None) -> str:
"""음식점 데이터를 JSON 파일로 저장"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"restaurants_{timestamp}.json"
file_path = os.path.join(config.DATA_DIR, filename)
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"음식점 데이터 저장 완료: {file_path}")
return file_path
except Exception as e:
logger.error(f"파일 저장 실패: {e}")
raise
# =============================================================================
# API 엔드포인트
# =============================================================================
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def root():
"""메인 페이지"""
return f"""
<html>
<head>
<title>{config.APP_TITLE}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; }}
.header {{ background: #FEE500; color: #3C1E1E; padding: 20px; border-radius: 5px; }}
.info {{ background: #f8f9fa; border: 1px solid #dee2e6; padding: 15px; margin: 15px 0; border-radius: 5px; }}
.link {{ display: inline-block; background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin: 10px 5px; }}
.config {{ background: #e8f4fd; padding: 15px; margin: 15px 0; border-radius: 5px; }}
</style>
</head>
<body>
<div class="header">
<h1>🍽 {config.APP_TITLE}</h1>
<p>카카오 로컬 API를 활용한 음식점 정보 수집 시스템</p>
<p>버전: {config.APP_VERSION}</p>
</div>
<div class="info">
<h2>📋 서비스 정보</h2>
<ul>
<li><strong>API :</strong> {config.KAKAO_API_KEY[:10]}... (카카오 로컬 API)</li>
<li><strong>기본 검색어:</strong> {config.DEFAULT_QUERY}</li>
<li><strong>기본 지역:</strong> {config.DEFAULT_REGION}</li>
<li><strong>최대 페이지:</strong> {config.MAX_PAGES}</li>
<li><strong>저장 경로:</strong> {config.DATA_DIR}</li>
</ul>
</div>
<div class="config">
<h2> 환경 설정</h2>
<ul>
<li><strong>요청 지연:</strong> {config.REQUEST_DELAY}</li>
<li><strong>요청 타임아웃:</strong> {config.REQUEST_TIMEOUT}</li>
<li><strong>페이지당 결과:</strong> 최대 {config.MAX_SIZE}</li>
</ul>
</div>
<h2>📚 API 문서</h2>
<a href="/docs" class="link">Swagger UI 문서</a>
<a href="/redoc" class="link">ReDoc 문서</a>
<a href="/health" class="link">헬스 체크</a>
<h2>🛠 사용 방법</h2>
<p><strong>POST /collect</strong> - 음식점 정보 수집</p>
<pre>
{{
"query": "치킨",
"region": "서울",
"size": 15,
"pages": 5,
"save_to_file": true
}}
</pre>
<p><strong>GET /download/{filename}</strong> - 저장된 파일 다운로드</p>
</body>
</html>
"""
@app.post(
"/collect",
response_model=RestaurantSearchResponse,
summary="음식점 정보 수집",
description="""
카카오 로컬 API를 사용하여 지정된 조건의 음식점 정보를 수집합니다.
**주요 기능:**
- 키워드 지역 기반 음식점 검색
- 페이지네이션 지원 (최대 45페이지)
- JSON 파일 자동 저장
- 중복 음식점 제거
**응답 시간:** 페이지 수에 따라 5-60 소요
""",
responses={
200: {"description": "수집 성공", "model": RestaurantSearchResponse},
400: {"description": "잘못된 요청", "model": ErrorResponse},
401: {"description": "API 키 인증 실패", "model": ErrorResponse},
500: {"description": "서버 오류", "model": ErrorResponse}
}
)
async def collect_restaurants(request: RestaurantSearchRequest):
"""음식점 정보 수집 API"""
logger.info(f"음식점 수집 요청: {request}")
try:
async with KakaoRestaurantCollector() as collector:
# 음식점 검색 실행
result = await collector.search_restaurants(
query=request.query,
region=request.region,
size=request.size,
pages=request.pages
)
# 파일 저장
file_saved = False
file_path = None
if request.save_to_file:
try:
# 파일명 생성 (쿼리와 지역 포함)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_query = "".join(c for c in request.query if c.isalnum() or c in (' ', '_')).strip()
safe_region = "".join(c for c in request.region if c.isalnum() or c in (' ', '_')).strip()
filename = f"restaurants_{safe_query}_{safe_region}_{timestamp}.json"
file_path = save_restaurants_to_file(result, filename)
file_saved = True
# 기본 파일명으로도 저장 (최신 결과)
default_path = save_restaurants_to_file(result, config.OUTPUT_FILE)
logger.info(f"기본 파일로도 저장: {default_path}")
except Exception as save_error:
logger.error(f"파일 저장 실패: {save_error}")
file_saved = False
# 응답 데이터 구성
response_data = RestaurantSearchResponse(
success=True,
message=f"음식점 정보 수집이 완료되었습니다. (총 {len(result['restaurants'])}개)",
metadata=CollectionMetadata(**result['metadata']),
restaurants=[RestaurantInfo(**r) for r in result['restaurants']],
file_saved=file_saved,
file_path=file_path
)
logger.info(f"수집 완료: {len(result['restaurants'])}개 음식점")
return response_data
except HTTPException:
raise
except Exception as e:
logger.error(f"음식점 수집 실패: {str(e)}")
raise HTTPException(
status_code=500,
detail={
"success": False,
"error": "COLLECTION_FAILED",
"message": f"음식점 수집 중 오류가 발생했습니다: {str(e)}",
"timestamp": datetime.now().isoformat()
}
)
@app.get("/list-files", summary="저장된 파일 목록", description="저장된 음식점 데이터 파일 목록을 반환합니다.")
async def list_files():
"""저장된 파일 목록 조회"""
try:
files = []
if os.path.exists(config.DATA_DIR):
for filename in os.listdir(config.DATA_DIR):
if filename.endswith('.json'):
file_path = os.path.join(config.DATA_DIR, filename)
file_stat = os.stat(file_path)
files.append({
'filename': filename,
'size': file_stat.st_size,
'created': datetime.fromtimestamp(file_stat.st_ctime).isoformat(),
'modified': datetime.fromtimestamp(file_stat.st_mtime).isoformat(),
'download_url': f"/download/{filename}"
})
files.sort(key=lambda x: x['modified'], reverse=True)
return {
'success': True,
'total_files': len(files),
'files': files,
'data_directory': config.DATA_DIR
}
except Exception as e:
logger.error(f"파일 목록 조회 실패: {e}")
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
@app.get("/download/{filename}", summary="파일 다운로드", description="저장된 음식점 데이터 파일을 다운로드합니다.")
async def download_file(filename: str):
"""파일 다운로드"""
file_path = os.path.join(config.DATA_DIR, filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
if not filename.endswith('.json'):
raise HTTPException(status_code=400, detail="JSON 파일만 다운로드 가능합니다.")
return FileResponse(
path=file_path,
filename=filename,
media_type='application/json'
)
@app.get("/health", summary="헬스 체크", description="API 서버 상태를 확인합니다.")
async def health_check():
"""헬스 체크"""
return {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"version": config.APP_VERSION,
"api_key_configured": bool(config.KAKAO_API_KEY),
"data_directory_exists": os.path.exists(config.DATA_DIR),
"message": f"{config.APP_TITLE}이 정상 작동 중입니다."
}
@app.get("/config", summary="환경 설정 확인", description="현재 적용된 환경 변수 설정을 확인합니다.")
async def get_config():
"""환경 설정 확인"""
return {
"app_info": {
"title": config.APP_TITLE,
"version": config.APP_VERSION,
"description": config.APP_DESCRIPTION
},
"server_config": {
"host": config.HOST,
"port": config.PORT,
"log_level": config.LOG_LEVEL
},
"api_config": {
"kakao_api_url": config.KAKAO_API_URL,
"api_key_configured": bool(config.KAKAO_API_KEY),
"api_key_preview": f"{config.KAKAO_API_KEY[:10]}..." if config.KAKAO_API_KEY else None
},
"search_defaults": {
"default_query": config.DEFAULT_QUERY,
"default_region": config.DEFAULT_REGION,
"default_size": config.DEFAULT_SIZE,
"max_size": config.MAX_SIZE,
"max_pages": config.MAX_PAGES
},
"file_config": {
"output_file": config.OUTPUT_FILE,
"data_dir": config.DATA_DIR,
"data_dir_exists": os.path.exists(config.DATA_DIR)
},
"request_config": {
"request_delay": config.REQUEST_DELAY,
"request_timeout": config.REQUEST_TIMEOUT,
"health_check_timeout": config.HEALTH_CHECK_TIMEOUT
},
"timestamp": datetime.now().isoformat()
}
# 백그라운드 작업을 위한 예제 (향후 확장용)
@app.post("/collect-background", summary="백그라운드 수집", description="음식점 정보를 백그라운드에서 수집합니다.")
async def collect_restaurants_background(background_tasks: BackgroundTasks, request: RestaurantSearchRequest):
"""백그라운드 음식점 수집"""
async def background_collect():
try:
logger.info("백그라운드 수집 시작")
async with KakaoRestaurantCollector() as collector:
result = await collector.search_restaurants(
query=request.query,
region=request.region,
size=request.size,
pages=request.pages
)
if request.save_to_file:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"restaurants_bg_{timestamp}.json"
save_restaurants_to_file(result, filename)
logger.info(f"백그라운드 수집 완료: {len(result['restaurants'])}")
except Exception as e:
logger.error(f"백그라운드 수집 실패: {e}")
background_tasks.add_task(background_collect)
return {
"success": True,
"message": "백그라운드 수집이 시작되었습니다.",
"timestamp": datetime.now().isoformat()
}
if __name__ == "__main__":
print("🍽️ " + "="*60)
print(f" {config.APP_TITLE} 서버 시작")
print("="*64)
print(f"📊 설정 정보:")
print(f" - API 키: {config.KAKAO_API_KEY[:10]}...")
print(f" - 기본 검색어: {config.DEFAULT_QUERY}")
print(f" - 기본 지역: {config.DEFAULT_REGION}")
print(f" - 데이터 저장 경로: {config.DATA_DIR}")
print()
print(f"📚 문서:")
print(f" - Swagger UI: http://{config.HOST}:{config.PORT}/docs")
print(f" - ReDoc: http://{config.HOST}:{config.PORT}/redoc")
print(f" - 메인 페이지: http://{config.HOST}:{config.PORT}/")
print()
uvicorn.run(
app,
host=config.HOST,
port=config.PORT,
log_level=config.LOG_LEVEL
)

View File

@ -0,0 +1,6 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
aiohttp==3.9.1
pydantic==2.5.0
python-multipart==0.0.6
python-dotenv==1.0.0

214
restaurant/build-base.sh Executable file
View File

@ -0,0 +1,214 @@
#!/bin/bash
# build-base.sh - Base Image 빌드 스크립트
set -e
# 변수 설정
BASE_IMAGE_NAME="restaurant-api-base"
BASE_IMAGE_TAG="${1:-latest}"
ACR_NAME="${2:-acrdigitalgarage03}"
RESOURCE_GROUP="${3:-rg-digitalgarage-03}"
# ACR URL 자동 구성
if [ -n "${ACR_NAME}" ]; then
REGISTRY="${ACR_NAME}.azurecr.io"
FULL_BASE_IMAGE_NAME="${REGISTRY}/${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
else
FULL_BASE_IMAGE_NAME="${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
fi
# 고정된 Dockerfile 경로
BASE_DOCKERFILE_PATH="deployment/container/Dockerfile-base"
BUILD_CONTEXT="."
echo "====================================================="
echo " 카카오 API 음식점 수집 서비스 Base Image 빌드"
echo "====================================================="
echo "Base 이미지명: ${FULL_BASE_IMAGE_NAME}"
if [ -n "${ACR_NAME}" ]; then
echo "ACR 이름: ${ACR_NAME}"
echo "리소스 그룹: ${RESOURCE_GROUP}"
fi
echo "빌드 시작: $(date)"
echo ""
# 사용법 표시 함수
show_usage() {
echo "사용법:"
echo " $0 [BASE_IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP]"
echo ""
echo "파라미터:"
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
echo " ACR_NAME : Azure Container Registry 이름"
echo " RESOURCE_GROUP: Azure 리소스 그룹"
echo ""
echo "예시:"
echo " $0 v1.0.0 # 로컬 빌드만"
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
}
# ACR 로그인 함수
acr_login() {
local acr_name="$1"
local resource_group="$2"
echo "🔐 Azure Container Registry 로그인 중..."
if ! command -v az &> /dev/null; then
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "❌ jq가 설치되지 않았습니다."
exit 1
fi
if ! az account show &> /dev/null; then
echo "❌ Azure에 로그인되지 않았습니다."
echo "로그인 명령: az login"
exit 1
fi
local credential_json
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
if [ $? -ne 0 ]; then
echo "❌ ACR credential 조회 실패"
exit 1
fi
local username
local password
username=$(echo "${credential_json}" | jq -r '.username')
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
echo "❌ ACR credential 파싱 실패"
exit 1
fi
echo "🔐 Docker 로그인 실행 중..."
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
if [ $? -eq 0 ]; then
echo "✅ ACR 로그인 성공!"
return 0
else
echo "❌ ACR 로그인 실패"
exit 1
fi
}
# 파라미터 검증
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_usage
exit 0
fi
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
echo "❌ ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
echo ""
show_usage
exit 1
fi
# 필수 파일 확인
echo "📁 필수 파일 확인 중..."
if [ ! -f "${BASE_DOCKERFILE_PATH}" ]; then
echo "${BASE_DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
exit 1
fi
echo "✅ Base Dockerfile 확인 완료"
echo "📄 Base Dockerfile: ${BASE_DOCKERFILE_PATH}"
# ACR 로그인 수행
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
echo ""
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
echo ""
fi
# Docker 빌드
echo "🔨 Base Image 빌드 시작..."
echo "명령어: docker build -t \"${FULL_BASE_IMAGE_NAME}\" -f \"${BASE_DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
docker build -t "${FULL_BASE_IMAGE_NAME}" -f "${BASE_DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
if [ $? -eq 0 ]; then
echo "✅ Base Image 빌드 완료!"
echo "이미지명: ${FULL_BASE_IMAGE_NAME}"
# 이미지 정보 표시
echo ""
echo "📊 Base Image 정보:"
docker images "${FULL_BASE_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
# latest 태그 추가 생성
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo ""
echo "🏷️ latest 태그 생성 중..."
docker tag "${FULL_BASE_IMAGE_NAME}" "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
echo "✅ latest 태그 생성 완료: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
fi
# ACR 푸시
if [ -n "${ACR_NAME}" ]; then
echo ""
echo "🚀 ACR에 Base Image 푸시 중..."
echo "📤 푸시 중: ${FULL_BASE_IMAGE_NAME}"
docker push "${FULL_BASE_IMAGE_NAME}"
if [ $? -eq 0 ]; then
echo "✅ Base Image 푸시 성공"
if [ "${BASE_IMAGE_TAG}" != "latest" ]; then
echo "📤 푸시 중: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
docker push "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
if [ $? -eq 0 ]; then
echo "✅ latest 태그 푸시 성공"
fi
fi
else
echo "❌ Base Image 푸시 실패"
exit 1
fi
fi
echo ""
echo "🎉 Base Image 빌드 완료!"
echo ""
echo "📋 완료된 작업:"
echo " ✅ Base Image 빌드: ${FULL_BASE_IMAGE_NAME}"
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo " ✅ latest 태그: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
fi
if [ -n "${ACR_NAME}" ]; then
echo " ✅ ACR 푸시 완료"
fi
echo ""
echo "🧪 테스트 명령어:"
echo " docker run --rm ${FULL_BASE_IMAGE_NAME} python --version"
echo ""
echo "📝 다음 단계:"
echo " 이제 Service Image를 빌드하세요:"
if [ -n "${ACR_NAME}" ]; then
echo " ./build.sh v1.0.0 ${ACR_NAME} ${RESOURCE_GROUP}"
else
echo " ./build.sh v1.0.0"
fi
else
echo "❌ Base Image 빌드 실패!"
exit 1
fi
echo ""
echo "🏁 Base Image 빌드 프로세스 완료 - $(date)"

251
restaurant/build.sh Executable file
View File

@ -0,0 +1,251 @@
#!/bin/bash
# build.sh - Service Image 빌드 스크립트 (Base Image 활용)
set -e
# 변수 설정
IMAGE_NAME="restaurant-api"
IMAGE_TAG="${1:-latest}"
ACR_NAME="${2:-acrdigitalgarage03}"
RESOURCE_GROUP="${3:-rg-digitalgarage-03}"
BASE_IMAGE_TAG="${4:-latest}"
# ACR URL 자동 구성
if [ -n "${ACR_NAME}" ]; then
REGISTRY="${ACR_NAME}.azurecr.io"
FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
BASE_IMAGE="${REGISTRY}/restaurant-api-base:${BASE_IMAGE_TAG}"
else
FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}"
BASE_IMAGE="restaurant-api-base:${BASE_IMAGE_TAG}"
fi
# 고정된 Dockerfile 경로
DOCKERFILE_PATH="deployment/container/Dockerfile"
BUILD_CONTEXT="."
echo "====================================================="
echo " 카카오 API 음식점 수집 서비스 Service Image 빌드"
echo "====================================================="
echo "Service 이미지명: ${FULL_IMAGE_NAME}"
echo "Base 이미지: ${BASE_IMAGE}"
if [ -n "${ACR_NAME}" ]; then
echo "ACR 이름: ${ACR_NAME}"
echo "리소스 그룹: ${RESOURCE_GROUP}"
fi
echo "빌드 시작: $(date)"
echo ""
# 사용법 표시 함수
show_usage() {
echo "사용법:"
echo " $0 [IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP] [BASE_IMAGE_TAG]"
echo ""
echo "파라미터:"
echo " IMAGE_TAG : Service 이미지 태그 (기본값: latest)"
echo " ACR_NAME : Azure Container Registry 이름"
echo " RESOURCE_GROUP: Azure 리소스 그룹"
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
echo ""
echo "예시:"
echo " $0 v1.0.0 # 로컬 빌드만"
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 v2.0.0 # 특정 Base Image 사용"
echo ""
echo "전제조건:"
echo " Base Image가 먼저 빌드되어 있어야 합니다:"
echo " ./build-base.sh ${BASE_IMAGE_TAG} [ACR_NAME] [RESOURCE_GROUP]"
}
# ACR 로그인 함수
acr_login() {
local acr_name="$1"
local resource_group="$2"
echo "🔐 Azure Container Registry 로그인 중..."
if ! command -v az &> /dev/null; then
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "❌ jq가 설치되지 않았습니다."
exit 1
fi
if ! az account show &> /dev/null; then
echo "❌ Azure에 로그인되지 않았습니다."
echo "로그인 명령: az login"
exit 1
fi
local credential_json
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
if [ $? -ne 0 ]; then
echo "❌ ACR credential 조회 실패"
exit 1
fi
local username
local password
username=$(echo "${credential_json}" | jq -r '.username')
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
echo "❌ ACR credential 파싱 실패"
exit 1
fi
echo "🔐 Docker 로그인 실행 중..."
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
if [ $? -eq 0 ]; then
echo "✅ ACR 로그인 성공!"
return 0
else
echo "❌ ACR 로그인 실패"
exit 1
fi
}
# 파라미터 검증
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_usage
exit 0
fi
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
echo "❌ ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
echo ""
show_usage
exit 1
fi
# 필수 파일 확인
echo "📁 필수 파일 확인 중..."
if [ ! -f "app/main.py" ]; then
echo "❌ app/main.py 파일을 찾을 수 없습니다."
exit 1
fi
if [ ! -f "app/requirements.txt" ]; then
echo "❌ app/requirements.txt 파일을 찾을 수 없습니다."
exit 1
fi
if [ ! -f "${DOCKERFILE_PATH}" ]; then
echo "${DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
exit 1
fi
echo "✅ 모든 필수 파일이 확인되었습니다."
echo "📄 Dockerfile: ${DOCKERFILE_PATH}"
echo "🏗️ 빌드 컨텍스트: ${BUILD_CONTEXT}"
# Base Image 존재 확인
echo ""
echo "🔍 Base Image 확인 중..."
if docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
echo "✅ Base Image 확인됨: ${BASE_IMAGE}"
else
echo "❌ Base Image를 찾을 수 없습니다: ${BASE_IMAGE}"
echo ""
echo "Base Image를 먼저 빌드하세요:"
if [ -n "${ACR_NAME}" ]; then
echo " ./build-base.sh ${BASE_IMAGE_TAG} ${ACR_NAME} ${RESOURCE_GROUP}"
else
echo " ./build-base.sh ${BASE_IMAGE_TAG}"
fi
exit 1
fi
# ACR 로그인 수행
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
echo ""
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
echo ""
fi
# Docker 빌드
echo "🔨 Service Image 빌드 시작... (빠른 빌드 예상)"
echo "명령어: docker build --build-arg BASE_IMAGE=\"${BASE_IMAGE}\" -t \"${FULL_IMAGE_NAME}\" -f \"${DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
docker build --build-arg BASE_IMAGE="${BASE_IMAGE}" -t "${FULL_IMAGE_NAME}" -f "${DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
if [ $? -eq 0 ]; then
echo "✅ Service Image 빌드 완료!"
echo "이미지명: ${FULL_IMAGE_NAME}"
# 이미지 정보 표시
echo ""
echo "📊 Service Image 정보:"
docker images "${FULL_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
# latest 태그 추가 생성
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo ""
echo "🏷️ latest 태그 생성 중..."
docker tag "${FULL_IMAGE_NAME}" "${REGISTRY}/${IMAGE_NAME}:latest"
echo "✅ latest 태그 생성 완료: ${REGISTRY}/${IMAGE_NAME}:latest"
fi
# ACR 푸시
if [ -n "${ACR_NAME}" ]; then
echo ""
echo "🚀 ACR에 Service Image 푸시 중..."
echo "📤 푸시 중: ${FULL_IMAGE_NAME}"
docker push "${FULL_IMAGE_NAME}"
if [ $? -eq 0 ]; then
echo "✅ Service Image 푸시 성공"
if [ "${IMAGE_TAG}" != "latest" ]; then
echo "📤 푸시 중: ${REGISTRY}/${IMAGE_NAME}:latest"
docker push "${REGISTRY}/${IMAGE_NAME}:latest"
if [ $? -eq 0 ]; then
echo "✅ latest 태그 푸시 성공"
fi
fi
else
echo "❌ Service Image 푸시 실패"
exit 1
fi
fi
echo ""
echo "🎉 Service Image 빌드 완료!"
echo ""
echo "📋 완료된 작업:"
echo " ✅ Service Image 빌드: ${FULL_IMAGE_NAME}"
echo " ✅ 사용된 Base Image: ${BASE_IMAGE}"
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo " ✅ latest 태그: ${REGISTRY}/${IMAGE_NAME}:latest"
fi
if [ -n "${ACR_NAME}" ]; then
echo " ✅ ACR 푸시 완료"
fi
echo ""
echo "🧪 테스트 명령어:"
echo " docker run -p 8000:8000 ${FULL_IMAGE_NAME}"
echo " curl http://localhost:8000/health"
if [ -n "${ACR_NAME}" ]; then
echo ""
echo "🔍 ACR 이미지 확인:"
echo " az acr repository show-tags --name ${ACR_NAME} --repository ${IMAGE_NAME}"
fi
else
echo "❌ Service Image 빌드 실패!"
exit 1
fi
echo ""
echo "🏁 Service Image 빌드 프로세스 완료 - $(date)"

View File

@ -0,0 +1,237 @@
#!/bin/bash
# create-imagepullsecret.sh - ACR Image Pull Secret 생성 스크립트
set -e
# 변수 설정
ACR_NAME="${1:-acrdigitalgarage03}"
RESOURCE_GROUP="${2:-rg-digitalgarage-03}"
SECRET_NAME="${3:-acr-secret}"
echo "====================================================="
echo " ACR Image Pull Secret 생성 (Restaurant API)"
echo "====================================================="
# 사용법 표시 함수
show_usage() {
echo "사용법:"
echo " $0 [ACR_NAME] [RESOURCE_GROUP] [SECRET_NAME]"
echo ""
echo "파라미터:"
echo " ACR_NAME : Azure Container Registry 이름 (필수)"
echo " RESOURCE_GROUP: Azure 리소스 그룹 (필수)"
echo " SECRET_NAME : Secret 이름 (기본값: acr-secret)"
echo ""
echo "예시:"
echo " $0 acrdigitalgarage01 rg-digitalgarage-03"
echo " $0 acrdigitalgarage01 rg-digitalgarage-03 acr-prod-secret"
echo ""
}
# 파라미터 검증
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_usage
exit 0
fi
if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ]; then
echo "❌ ACR_NAME과 RESOURCE_GROUP는 필수 파라미터입니다."
echo ""
show_usage
exit 1
fi
# 필수 도구 확인
echo "🔧 필수 도구 확인 중..."
if ! command -v az &> /dev/null; then
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
echo "설치 방법: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli"
exit 1
fi
if ! command -v kubectl &> /dev/null; then
echo "❌ kubectl이 설치되지 않았습니다."
echo "설치 방법: https://kubernetes.io/docs/tasks/tools/"
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "❌ jq가 설치되지 않았습니다."
echo "설치 방법: sudo apt-get install jq"
exit 1
fi
echo "✅ 필수 도구 확인 완료"
# Azure 로그인 확인
echo ""
echo "🔐 Azure 로그인 상태 확인 중..."
if ! az account show &> /dev/null; then
echo "❌ Azure에 로그인되지 않았습니다."
echo "로그인 명령: az login"
exit 1
fi
CURRENT_SUBSCRIPTION=$(az account show --query name -o tsv)
echo "✅ Azure 로그인 확인됨"
echo " 현재 구독: ${CURRENT_SUBSCRIPTION}"
# Kubernetes 클러스터 연결 확인
echo ""
echo "☸️ Kubernetes 클러스터 연결 확인 중..."
if ! kubectl cluster-info &> /dev/null; then
echo "❌ Kubernetes 클러스터에 연결되지 않았습니다."
echo "클러스터 연결 방법:"
echo " az aks get-credentials --resource-group ${RESOURCE_GROUP} --name <AKS_CLUSTER_NAME>"
exit 1
fi
CURRENT_CONTEXT=$(kubectl config current-context)
echo "✅ Kubernetes 클러스터 연결 확인됨"
echo " 현재 컨텍스트: ${CURRENT_CONTEXT}"
# ACR 정보 설정
REGISTRY_URL="${ACR_NAME}.azurecr.io"
echo ""
echo "📋 설정 정보:"
echo " ACR 이름: ${ACR_NAME}"
echo " 레지스트리 URL: ${REGISTRY_URL}"
echo " 리소스 그룹: ${RESOURCE_GROUP}"
echo " Secret 이름: ${SECRET_NAME}"
# ACR 존재 확인
echo ""
echo "🏪 ACR 존재 확인 중..."
if ! az acr show --name "${ACR_NAME}" --resource-group "${RESOURCE_GROUP}" &> /dev/null; then
echo "❌ ACR을 찾을 수 없습니다."
echo "확인 사항:"
echo " - ACR 이름: ${ACR_NAME}"
echo " - 리소스 그룹: ${RESOURCE_GROUP}"
echo " - ACR이 해당 리소스 그룹에 존재하는지 확인"
exit 1
fi
echo "✅ ACR 존재 확인됨: ${ACR_NAME}"
# ACR credential 조회
echo ""
echo "🔑 ACR credential 조회 중..."
credential_json=$(az acr credential show --name "${ACR_NAME}" --resource-group "${RESOURCE_GROUP}" 2>/dev/null)
if [ $? -ne 0 ]; then
echo "❌ ACR credential 조회 실패"
echo "확인 사항:"
echo " - ACR 이름: ${ACR_NAME}"
echo " - 리소스 그룹: ${RESOURCE_GROUP}"
echo " - ACR에 대한 권한이 있는지 확인"
exit 1
fi
# JSON에서 username과 password 추출
username=$(echo "${credential_json}" | jq -r '.username')
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
echo "❌ ACR credential 파싱 실패"
echo "credential JSON:"
echo "${credential_json}"
exit 1
fi
echo "✅ ACR credential 조회 성공"
echo " 사용자명: ${username}"
echo " 비밀번호: ${password:0:10}..."
# 기존 Secret 확인 및 삭제
echo ""
echo "🔍 기존 Secret 확인 중..."
if kubectl get secret "${SECRET_NAME}" &> /dev/null; then
echo "🗑️ 기존 Secret 삭제 중..."
kubectl delete secret "${SECRET_NAME}"
echo "✅ 기존 Secret 삭제 완료"
else
echo "✅ 기존 Secret 없음"
fi
# Image Pull Secret 생성
echo ""
echo "🔐 Image Pull Secret 생성 중..."
kubectl create secret docker-registry "${SECRET_NAME}" \
--docker-server="${REGISTRY_URL}" \
--docker-username="${username}" \
--docker-password="${password}"
if [ $? -eq 0 ]; then
echo "✅ Image Pull Secret 생성 성공!"
else
echo "❌ Image Pull Secret 생성 실패"
exit 1
fi
# Secret 정보 확인
echo ""
echo "📊 생성된 Secret 정보:"
kubectl describe secret "${SECRET_NAME}"
echo ""
echo "🧪 Secret 테스트 중..."
# Secret이 올바르게 생성되었는지 확인
SECRET_TYPE=$(kubectl get secret "${SECRET_NAME}" -o jsonpath='{.type}')
if [ "${SECRET_TYPE}" = "kubernetes.io/dockerconfigjson" ]; then
echo "✅ Secret 타입 확인됨: ${SECRET_TYPE}"
else
echo "❌ Secret 타입 불일치: ${SECRET_TYPE}"
fi
# Registry URL 확인
SECRET_REGISTRY=$(kubectl get secret "${SECRET_NAME}" -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq -r ".auths | keys[0]")
if [ "${SECRET_REGISTRY}" = "${REGISTRY_URL}" ]; then
echo "✅ Registry URL 확인됨: ${SECRET_REGISTRY}"
else
echo "❌ Registry URL 불일치: ${SECRET_REGISTRY}"
fi
echo ""
echo "🎉 ACR Image Pull Secret 생성 완료!"
echo ""
echo "📋 사용 방법:"
echo ""
echo "1. Deployment에서 imagePullSecrets 사용:"
echo " spec:"
echo " template:"
echo " spec:"
echo " imagePullSecrets:"
echo " - name: ${SECRET_NAME}"
echo ""
echo "2. ServiceAccount에 Secret 연결:"
echo " kubectl patch serviceaccount default -p '{\"imagePullSecrets\": [{\"name\": \"${SECRET_NAME}\"}]}'"
echo ""
echo "3. Secret 확인:"
echo " kubectl get secret ${SECRET_NAME}"
echo " kubectl describe secret ${SECRET_NAME}"
echo ""
echo "4. Secret 삭제 (필요시):"
echo " kubectl delete secret ${SECRET_NAME}"
echo ""
echo "🔧 다음 단계:"
echo "1. Deployment 매니페스트에 imagePullSecrets 추가"
echo "2. kubectl apply -f deployment/manifests/deployment.yaml"
echo "3. Pod 상태 확인: kubectl get pods"
echo ""
echo "💡 문제 해결:"
echo "- ErrImagePull 오류 시: Secret 이름과 레지스트리 URL 확인"
echo "- 권한 오류 시: ACR에 대한 적절한 권한 확인"
echo "- 네트워크 오류 시: 클러스터에서 ACR로의 네트워크 연결 확인"
echo ""
echo "✅ Image Pull Secret 설정이 완료되었습니다!"

View File

@ -0,0 +1,46 @@
# deployment/container/Dockerfile
# Restaurant Collection Service Image
ARG BASE_IMAGE=restaurant-api-base:latest
FROM ${BASE_IMAGE}
# 메타데이터
LABEL maintainer="admin@example.com"
LABEL version="1.0.0"
LABEL description="카카오 API 기반 음식점 수집 서비스"
# root로 전환 (패키지 설치용)
USER root
# 환경 변수 설정
ENV HOME=/home/appuser \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Python 의존성 파일 복사 및 설치
COPY app/requirements.txt /app/
RUN pip install --no-cache-dir -r /app/requirements.txt
# 애플리케이션 소스 복사
COPY app/main.py /app/
# 데이터 디렉토리 생성 및 권한 설정
RUN mkdir -p /app/data \
&& chown -R appuser:appuser /app \
&& chmod -R 755 /app
# 비root 사용자로 전환
USER appuser
# 작업 디렉토리 설정
WORKDIR /app
# 포트 노출
EXPOSE 18000
# 헬스체크
HEALTHCHECK --interval=30s --timeout=15s --start-period=30s --retries=3 \
CMD curl -f http://localhost:18000/health || exit 1
# 애플리케이션 실행
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "18000", "--log-level", "info"]

View File

@ -0,0 +1,37 @@
# deployment/container/Dockerfile-base
FROM python:3.11-slim
# 메타데이터
LABEL maintainer="admin@example.com"
LABEL description="카카오 API 기반 음식점 수집 서비스 - Base Image"
LABEL version="base-1.0.0"
# 환경 변수 설정
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DEBIAN_FRONTEND=noninteractive
# 필수 패키지 설치
RUN apt-get update && apt-get install -y \
curl \
wget \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# 비root 사용자 생성
RUN groupadd -r appuser && useradd -r -g appuser -d /home/appuser -s /bin/bash appuser \
&& mkdir -p /home/appuser \
&& chown -R appuser:appuser /home/appuser
# 작업 디렉토리 생성
WORKDIR /app
RUN chown appuser:appuser /app
# pip 업그레이드
RUN pip install --no-cache-dir --upgrade pip
# 포트 노출
EXPOSE 8000
# 기본 명령어 (오버라이드 가능)
CMD ["python", "--version"]

View File

@ -0,0 +1,34 @@
# deployment/manifests/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: restaurant-api-config
data:
# 애플리케이션 설정
APP_TITLE: "카카오 API 기반 음식점 수집 서비스"
APP_VERSION: "1.0.0"
APP_DESCRIPTION: "카카오 로컬 API를 활용한 음식점 정보 수집 시스템"
# 서버 설정
HOST: "0.0.0.0"
PORT: "18000"
LOG_LEVEL: "info"
# 카카오 API 설정
KAKAO_API_URL: "https://dapi.kakao.com/v2/local/search/keyword.json"
# 검색 기본값
DEFAULT_QUERY: "음식점"
DEFAULT_REGION: "서울"
DEFAULT_SIZE: "15"
MAX_SIZE: "15"
MAX_PAGES: "45"
# 파일 설정
OUTPUT_FILE: "restaurant.json"
DATA_DIR: "/app/data"
# 요청 제한 설정
REQUEST_DELAY: "0.1"
REQUEST_TIMEOUT: "30"
HEALTH_CHECK_TIMEOUT: "10"

View File

@ -0,0 +1,81 @@
# deployment/manifests/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: restaurant-api
labels:
app: restaurant-api
version: v1
spec:
replicas: 1
selector:
matchLabels:
app: restaurant-api
template:
metadata:
labels:
app: restaurant-api
version: v1
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: api
image: acrdigitalgarage03.azurecr.io/restaurant-api:latest
imagePullPolicy: Always
ports:
- containerPort: 18000
name: http
# ConfigMap 환경 변수
envFrom:
- configMapRef:
name: restaurant-api-config
# Secret 환경 변수
env:
- name: KAKAO_API_KEY
valueFrom:
secretKeyRef:
name: restaurant-api-secret
key: KAKAO_API_KEY
# 리소스 제한
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
# 헬스 체크
livenessProbe:
httpGet:
path: /health
port: 18000
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 18000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# 볼륨 마운트 (데이터 저장용)
volumeMounts:
- name: data-volume
mountPath: /app/data
# 볼륨 정의
volumes:
- name: data-volume
emptyDir: {}
restartPolicy: Always

View File

@ -0,0 +1,38 @@
# deployment/manifests/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: restaurant-api-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "false"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
# 타임아웃 설정 (API 수집 시간 고려)
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
nginx.ingress.kubernetes.io/client-body-timeout: "300"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
# CORS 설정
nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
spec:
ingressClassName: nginx
rules:
# 환경에 맞게 호스트명 수정 필요
- host: restaurant-api.20.249.191.180.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: restaurant-api-service
port:
number: 80
# TLS 설정 (HTTPS 필요시 주석 해제)
# tls:
# - hosts:
# - restaurant-api.example.com
# secretName: restaurant-api-tls

View File

@ -0,0 +1,10 @@
# deployment/manifests/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: restaurant-api-secret
type: Opaque
data:
# 카카오 API 키 (Base64 인코딩 필요)
# echo -n "5cdc24407edbf8544f3954cfaa4650c6" | base64
KAKAO_API_KEY: NWNkYzI0NDA3ZWRiZjg1NDRmMzk1NGNmYWE0NjUwYzY=

View File

@ -0,0 +1,16 @@
# deployment/manifests/service.yaml
apiVersion: v1
kind: Service
metadata:
name: restaurant-api-service
labels:
app: restaurant-api
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 18000
protocol: TCP
name: http
selector:
app: restaurant-api

212
restaurant/setup.sh Executable file
View File

@ -0,0 +1,212 @@
#!/bin/bash
# setup.sh - Restaurant API 환경 설정 스크립트
set -e
echo "🍽️ Restaurant API 환경 설정 시작..."
# 시스템 정보 확인
echo "📊 시스템 정보:"
echo " - OS: $(lsb_release -d | cut -f2)"
echo " - Python: $(python3 --version 2>/dev/null || echo 'Python3 미설치')"
echo " - pip: $(pip3 --version 2>/dev/null || echo 'pip3 미설치')"
# 필수 패키지 설치
echo ""
echo "📦 필수 패키지 설치 중..."
sudo apt update
sudo apt install -y python3-pip python3-venv curl wget jq
# Python 버전 확인
PYTHON_VERSION=$(python3 -c "import sys; print('.'.join(map(str, sys.version_info[:2])))")
echo "📍 Python 버전: ${PYTHON_VERSION}"
if [ "$(echo "${PYTHON_VERSION} < 3.8" | bc)" -eq 1 ]; then
echo "⚠️ Python 3.8 이상이 권장됩니다."
fi
# Python 가상환경 설정
echo ""
echo "🐍 Python 가상환경 설정 중..."
if [ ! -d "venv" ]; then
python3 -m venv venv
echo "✅ 가상환경 생성 완료"
else
echo "✅ 기존 가상환경 발견"
fi
# 가상환경 활성화 및 라이브러리 설치
echo "📚 Python 라이브러리 설치 중..."
source venv/bin/activate
# pip 업그레이드
pip install --upgrade pip
# 필요한 라이브러리 설치
if [ -f "app/requirements.txt" ]; then
pip install -r app/requirements.txt
echo "✅ requirements.txt에서 라이브러리 설치 완료"
else
echo "⚠️ app/requirements.txt 파일을 찾을 수 없습니다."
echo "🔧 기본 라이브러리들을 직접 설치합니다..."
pip install fastapi uvicorn aiohttp pydantic python-multipart python-dotenv
fi
# 데이터 디렉토리 생성
echo ""
echo "📁 데이터 디렉토리 설정 중..."
mkdir -p data
chmod 755 data
echo "✅ 데이터 디렉토리 생성: $(pwd)/data"
# 환경변수 파일 생성 (예시)
echo ""
echo "⚙️ 환경변수 파일 생성 중..."
cat > .env << EOF
# 카카오 API 설정
KAKAO_API_KEY=5cdc24407edbf8544f3954cfaa4650c6
# 서버 설정
HOST=0.0.0.0
PORT=18000
LOG_LEVEL=info
# 기본 검색 설정
DEFAULT_QUERY=음식점
DEFAULT_REGION=서울
DEFAULT_SIZE=15
MAX_PAGES=10
# 파일 설정
OUTPUT_FILE=restaurant.json
DATA_DIR=./data
# 요청 설정
REQUEST_DELAY=0.1
REQUEST_TIMEOUT=30
EOF
echo "✅ .env 파일 생성 완료"
# 네트워크 연결 테스트
echo ""
echo "🌐 네트워크 연결 테스트 중..."
# 카카오 API 연결 테스트
if curl -s --connect-timeout 5 "https://dapi.kakao.com" > /dev/null; then
echo "✅ 카카오 API 서버 연결 가능"
else
echo "❌ 카카오 API 서버 연결 실패"
echo " 인터넷 연결 상태를 확인해주세요."
fi
# API 키 유효성 간단 테스트
echo ""
echo "🔑 API 키 유효성 테스트 중..."
API_KEY="5cdc24407edbf8544f3954cfaa4650c6"
TEST_RESPONSE=$(curl -s -w "%{http_code}" -o /dev/null \
-H "Authorization: KakaoAK ${API_KEY}" \
--data-urlencode "query=카카오프렌즈&size=1" \
"https://dapi.kakao.com/v2/local/search/keyword.json")
if [ "${TEST_RESPONSE}" = "200" ]; then
echo "✅ API 키 유효성 확인 완료"
elif [ "${TEST_RESPONSE}" = "401" ]; then
echo "❌ API 키 인증 실패"
echo " .env 파일의 KAKAO_API_KEY를 확인해주세요."
elif [ "${TEST_RESPONSE}" = "000" ]; then
echo "⚠️ 네트워크 연결 문제로 API 키 테스트 불가"
else
echo "⚠️ API 키 테스트 실패 (HTTP ${TEST_RESPONSE})"
fi
# .env 파일 로딩 테스트
echo ""
echo "🔧 .env 파일 로딩 테스트 중..."
python3 -c "
from dotenv import load_dotenv
import os
load_dotenv()
port = os.getenv('PORT', '8000')
data_dir = os.getenv('DATA_DIR', './data')
print(f'✅ .env 파일 로딩 성공')
print(f' - PORT: {port}')
print(f' - DATA_DIR: {data_dir}')
" 2>/dev/null && echo "✅ python-dotenv 동작 확인" || echo "❌ python-dotenv 설치 필요: pip install python-dotenv"
# Docker 설치 확인 (선택사항)
echo ""
echo "🐳 Docker 설치 확인 중..."
if command -v docker &> /dev/null; then
DOCKER_VERSION=$(docker --version)
echo "✅ Docker 설치됨: ${DOCKER_VERSION}"
# Docker 권한 확인
if docker ps &> /dev/null; then
echo "✅ Docker 권한 확인됨"
else
echo "⚠️ Docker 권한 없음. 다음 명령어로 권한을 추가하세요:"
echo " sudo usermod -aG docker $USER"
echo " newgrp docker"
fi
else
echo "⚠️ Docker가 설치되지 않음 (컨테이너 배포 시 필요)"
echo " 설치 방법: https://docs.docker.com/engine/install/ubuntu/"
fi
# 방화벽 설정 확인
echo ""
echo "🔥 방화벽 설정 확인 중..."
if command -v ufw &> /dev/null; then
UFW_STATUS=$(sudo ufw status | head -n1 | awk '{print $2}')
if [ "${UFW_STATUS}" = "active" ]; then
echo "🔥 UFW 방화벽 활성화됨"
if sudo ufw status | grep -q "18000"; then
echo "✅ 포트 18000 방화벽 규칙 확인됨"
else
echo "⚠️ 포트 18000 방화벽 규칙 없음"
echo " 다음 명령어로 포트를 열어주세요:"
echo " sudo ufw allow 18000"
fi
else
echo "✅ UFW 방화벽 비활성화됨"
fi
else
echo "✅ UFW 방화벽 미설치"
fi
echo ""
echo "🎉 환경 설정 완료!"
echo ""
echo "📋 다음 단계:"
echo "1. 가상환경 활성화:"
echo " source venv/bin/activate"
echo ""
echo "2. 애플리케이션 실행:"
echo " python app/main.py"
echo " 또는"
echo " uvicorn app.main:app --host 0.0.0.0 --port 18000 --reload"
echo ""
echo "3. 웹 브라우저에서 접속:"
echo " http://localhost:18000 (메인 페이지)"
echo " http://localhost:18000/docs (Swagger UI)"
echo " http://localhost:18000/health (헬스체크)"
echo ""
echo "4. API 테스트:"
echo " curl -X POST \"http://localhost:18000/collect\" \\"
echo " -H \"Content-Type: application/json\" \\"
echo " -d '{\"query\":\"치킨\",\"region\":\"서울\",\"pages\":2}'"
echo ""
echo "💡 문제 발생 시:"
echo " - 로그 확인: tail -f 로그파일"
echo " - 환경변수 확인: cat .env"
echo " - .env 로딩 확인: python -c \"from dotenv import load_dotenv; load_dotenv(); import os; print(os.getenv('PORT'))\""
echo " - 네트워크 확인: curl https://dapi.kakao.com"
echo ""
echo "🔧 설정 파일 위치:"
echo " - 환경변수: $(pwd)/.env"
echo " - 데이터 저장: $(pwd)/data/"
echo " - 애플리케이션: $(pwd)/app/"
deactivate 2>/dev/null || true
echo ""
echo "✅ 환경 설정이 완료되었습니다!"

480
review/README.md Normal file
View File

@ -0,0 +1,480 @@
# 카카오맵 리뷰 분석 서비스 (교육용)
AI 기반 리뷰 분석을 통해 소상공인의 고객 피드백 관리를 지원하는 RESTful API 서비스입니다.
## ⚠️ 중요 법적 경고사항
**이 서비스는 교육 목적으로만 제작되었습니다.**
### 법적 위험
- 카카오 이용약관 위반 → 계정 정지, 법적 조치
- 개인정보보호법(PIPA) 위반 → 과태료 최대 3억원
- 저작권 및 데이터베이스권 침해 → 손해배상
- 업무방해죄 → 5년 이하 징역 또는 1천500만원 이하 벌금
### 합법적 대안
- **카카오 공식 API 활용** ([developers.kakao.com](https://developers.kakao.com))
- 점주 대상 자체 가게 관리 서비스 개발
- 사용자 동의 기반 데이터 수집 앱
- 카카오와 정식 파트너십 체결
**실제 서비스 사용을 금지합니다!**
---
## 📋 프로젝트 개요
### 주요 기능
- 🔍 **카카오맵 리뷰 분석**: HTML 파싱 및 Selenium 기반 동적 수집
- 🤖 **AI 기반 피드백**: Claude API 연동을 통한 맞춤형 개선 방안 제시
- 📊 **감정 분석**: 긍정/부정/중립 감정 분류 및 통계
- 💾 **Vector DB 연동**: 유사 케이스 검색 및 템플릿 활용
- 🚀 **RESTful API 제공**: FastAPI 기반의 완전한 API 서비스
- 📚 **Swagger UI 지원**: 자동 생성되는 API 문서
- ☸️ **Kubernetes 배포**: 완전한 컨테이너 오케스트레이션 지원
- 🔧 **환경변수 설정**: ConfigMap과 Secret을 통한 유연한 설정 관리
### 기술 스택
- **Backend**: Python 3.11, FastAPI, aiohttp
- **Web Scraping**: Selenium, BeautifulSoup4, Chrome WebDriver
- **AI/ML**: Claude AI API, Vector DB (Chroma)
- **Database**: PostgreSQL (향후), Vector DB
- **Container**: Docker, Multi-stage build
- **Orchestration**: Kubernetes (AKS)
- **Registry**: Azure Container Registry (ACR)
- **Documentation**: Swagger UI, ReDoc
## 🏗️ 프로젝트 구조
```
review-api/review/
├── app/ # 애플리케이션 소스
│ ├── main.py # 메인 애플리케이션
│ └── requirements.txt # Python 의존성
├── deployment/ # 배포 관련 파일
│ ├── container/ # 컨테이너 이미지 빌드
│ │ ├── Dockerfile # 서비스 이미지 빌드
│ │ └── Dockerfile-base # 베이스 이미지 빌드
│ └── manifests/ # Kubernetes 매니페스트
│ ├── configmap.yaml # 환경 설정
│ ├── secret.yaml # 민감 정보 (API 키)
│ ├── deployment.yaml # 애플리케이션 배포
│ ├── service.yaml # 서비스 노출
│ └── ingress.yaml # 외부 접근
├── build-base.sh # 베이스 이미지 빌드 스크립트
├── build.sh # 서비스 이미지 빌드 스크립트
├── create-imagepullsecret.sh # ACR 인증 설정 스크립트
├── setup.sh # 로컬 환경 설정 스크립트
└── README.md # 프로젝트 문서
```
## 🚀 빠른 시작
### 1. 로컬 개발 환경 설정
```bash
# 저장소 클론 (review-api/review 디렉토리로 이동)
cd review-api/review
# 환경 설정 스크립트 실행
chmod +x setup.sh
./setup.sh
# 가상환경 활성화
source venv/bin/activate
# 애플리케이션 실행
python app/main.py
```
### 2. 로컬 웹 브라우저 접속
```bash
# 애플리케이션이 정상 실행된 후 아래 URL로 접속
```
- **메인 페이지**: http://localhost:19000
- **Swagger UI**: http://localhost:19000/docs
- **ReDoc**: http://localhost:19000/redoc
- **헬스체크**: http://localhost:19000/health
- **진단 정보**: http://localhost:19000/diagnostic
- **법적 경고**: http://localhost:19000/legal-warning
### 3. API 테스트 (교육용)
```bash
# 기본 리뷰 분석 (교육용 - 실제 사용 금지)
curl -X POST "http://localhost:19000/analyze" \
-H "Content-Type: application/json" \
-d '{
"store_id": "501745730",
"days_limit": 7,
"max_time": 300
}'
# 환경 설정 확인
curl "http://localhost:19000/config"
# 법적 경고사항 확인
curl "http://localhost:19000/legal-warning"
```
## 🐳 Docker 컨테이너 실행
### 베이스 이미지 빌드
```bash
# ACR에 베이스 이미지 빌드 및 푸시
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
```
### 서비스 이미지 빌드
```bash
# ACR에 서비스 이미지 빌드 및 푸시
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
```
### 로컬 Docker 실행
```bash
# 컨테이너 실행
docker run -p 19000:19000 \
-e APP_TITLE="카카오맵 리뷰 분석 API" \
-e PORT=19000 \
kakao-review-api:latest
# 백그라운드 실행
docker run -d -p 19000:19000 \
--name kakao-review-api \
-e PORT=19000 \
kakao-review-api:latest
```
## ☸️ Kubernetes 배포
### 1. ACR Image Pull Secret 생성
```bash
# Image Pull Secret 생성
./create-imagepullsecret.sh acrdigitalgarage03 rg-digitalgarage-03
```
### 2. Kubernetes 리소스 배포
```bash
# ConfigMap 및 Secret 적용
kubectl apply -f deployment/manifests/configmap.yaml
kubectl apply -f deployment/manifests/secret.yaml
# 애플리케이션 배포
kubectl apply -f deployment/manifests/deployment.yaml
kubectl apply -f deployment/manifests/service.yaml
kubectl apply -f deployment/manifests/ingress.yaml
```
### 3. 배포 상태 확인
```bash
# Pod 상태 확인
kubectl get pods -l app=kakao-review-api
# 서비스 상태 확인
kubectl get svc kakao-review-api-service
# Ingress 상태 확인
kubectl get ingress kakao-review-api-ingress
# 로그 확인
kubectl logs -l app=kakao-review-api -f
```
### 4. 🌐 외부 브라우저에서 접속하기
#### Ingress 주소 확인 방법
```bash
# 1. Ingress 설정된 호스트 확인
kubectl get ingress kakao-review-api-ingress -o jsonpath='{.spec.rules[0].host}'
# 2. Ingress External IP 확인 (LoadBalancer 타입인 경우)
kubectl get ingress kakao-review-api-ingress
# 3. Ingress Controller의 External IP 확인
kubectl get svc -n ingress-nginx ingress-nginx-controller
# 4. 현재 설정된 ingress 주소 확인
INGRESS_HOST=$(kubectl get ingress kakao-review-api-ingress -o jsonpath='{.spec.rules[0].host}')
echo "🌐 Review API URL: http://${INGRESS_HOST}"
```
#### 브라우저 접속 주소
현재 설정된 주소로 접속하세요:
```bash
# 현재 설정된 기본 주소 (환경에 따라 다를 수 있음)
INGRESS_URL="http://kakao-review-api.20.249.191.180.nip.io"
echo "브라우저에서 접속: ${INGRESS_URL}"
```
**주요 접속 페이지:**
- **🏠 메인 페이지**: http://kakao-review-api.20.249.191.180.nip.io
- **📖 Swagger UI**: http://kakao-review-api.20.249.191.180.nip.io/docs
- **📄 ReDoc**: http://kakao-review-api.20.249.191.180.nip.io/redoc
- **❤️ 헬스체크**: http://kakao-review-api.20.249.191.180.nip.io/health
- **🔧 진단 정보**: http://kakao-review-api.20.249.191.180.nip.io/diagnostic
- **⚠️ 법적 경고**: http://kakao-review-api.20.249.191.180.nip.io/legal-warning
#### 접속 테스트
```bash
# API 접속 테스트
curl "http://kakao-review-api.20.249.191.180.nip.io/health"
# 설정 정보 확인
curl "http://kakao-review-api.20.249.191.180.nip.io/config"
# Swagger UI 접속 확인
curl -I "http://kakao-review-api.20.249.191.180.nip.io/docs"
# 법적 경고 확인
curl "http://kakao-review-api.20.249.191.180.nip.io/legal-warning"
```
## ⚙️ 환경 설정
### 환경 변수
| 변수명 | 기본값 | 설명 |
|--------|--------|------|
| `APP_TITLE` | `카카오맵 리뷰 분석 API` | 애플리케이션 제목 |
| `APP_VERSION` | `1.0.1` | 애플리케이션 버전 |
| `HOST` | `0.0.0.0` | 서버 호스트 |
| `PORT` | `19000` | 서버 포트 |
| `DEFAULT_MAX_TIME` | `300` | 기본 최대 스크롤 시간(초) |
| `DEFAULT_DAYS_LIMIT` | `60` | 기본 날짜 제한(일) |
| `MAX_DAYS_LIMIT` | `365` | 최대 날짜 제한(일) |
| `CHROME_OPTIONS` | `--headless...` | Chrome 브라우저 옵션 |
| `LEGAL_WARNING_ENABLED` | `true` | 법적 경고 표시 여부 |
| `CONTACT_EMAIL` | `admin@example.com` | 연락처 이메일 |
### Chrome 및 Selenium 설정
현재 서비스는 Chrome WebDriver를 사용합니다:
```bash
# Chrome 설치 확인
google-chrome --version
# ChromeDriver 설치 확인
chromedriver --version
# Selenium 테스트
python -c "from selenium import webdriver; print('Selenium 설치됨')"
```
## 📊 API 엔드포인트
### 주요 엔드포인트
| Method | Endpoint | 설명 |
|--------|----------|------|
| `GET` | `/` | 메인 페이지 |
| `GET` | `/docs` | Swagger UI 문서 |
| `GET` | `/health` | 헬스체크 |
| `GET` | `/diagnostic` | 시스템 진단 정보 |
| `POST` | `/analyze` | 리뷰 분석 수행 (교육용) |
| `GET` | `/config` | 환경 설정 확인 |
| `GET` | `/legal-warning` | 법적 경고사항 |
### 리뷰 분석 API 예시 (교육용)
```json
POST /analyze
{
"store_id": "501745730",
"days_limit": 7,
"max_time": 300
}
```
**응답:**
```json
{
"success": true,
"message": "분석이 성공적으로 완료되었습니다.",
"store_info": {
"id": "501745730",
"name": "맛있는 식당",
"category": "한식",
"rating": "4.2",
"review_count": "1,234"
},
"reviews": [...],
"analysis_date": "2024-06-12T10:30:00",
"total_reviews": 25,
"execution_time": 45.2
}
```
## 🔧 개발 및 확장
### 로컬 개발
```bash
# 개발 모드로 실행 (자동 재시작)
uvicorn app.main:app --host 0.0.0.0 --port 19000 --reload
# 의존성 추가
pip install 새패키지명
pip freeze > app/requirements.txt
# 코드 포맷팅
black app/main.py
flake8 app/main.py
```
### Ingress 호스트 변경
현재 환경에 맞게 Ingress 호스트를 변경하려면:
```bash
# 1. 현재 External IP 확인
kubectl get svc -n ingress-nginx ingress-nginx-controller
# 2. deployment/manifests/ingress.yaml 파일에서 host 수정
# 예: kakao-review-api.{YOUR_EXTERNAL_IP}.nip.io
# 3. 변경사항 적용
kubectl apply -f deployment/manifests/ingress.yaml
# 4. 새로운 주소 확인
kubectl get ingress kakao-review-api-ingress
```
## 🐛 문제 해결
### 일반적인 문제
**1. Chrome WebDriver 관련 문제**
```bash
# Chrome 설치 상태 확인
docker run --rm kakao-review-api:latest google-chrome --version
# ChromeDriver 버전 확인
docker run --rm kakao-review-api:latest chromedriver --version
# 헤드리스 모드 테스트
curl -X POST "http://localhost:19000/analyze" \
-H "Content-Type: application/json" \
-d '{"store_id": "test", "days_limit": 1, "max_time": 30}'
```
**2. Kubernetes 배포 실패**
```bash
# Pod 로그 확인 (Chrome 에러 확인)
kubectl logs -l app=kakao-review-api
# ConfigMap 확인
kubectl get configmap kakao-review-api-config -o yaml
# 리소스 사용량 확인 (Chrome이 메모리를 많이 사용함)
kubectl top pods -l app=kakao-review-api
```
**3. Ingress 접속 실패**
```bash
# Ingress Controller 상태 확인
kubectl get pods -n ingress-nginx
# Ingress 규칙 확인
kubectl describe ingress kakao-review-api-ingress
# Service 연결 확인
kubectl get endpoints kakao-review-api-service
# 타임아웃 설정 확인 (Chrome 분석은 시간이 오래 걸림)
kubectl get ingress kakao-review-api-ingress -o yaml | grep timeout
```
**4. 포트 관련 문제**
- 로컬 개발: 19000번 포트 사용
- Docker 컨테이너: 19000번 포트로 실행
- Kubernetes: 19000번 포트로 실행 (Service에서 80번으로 노출, Ingress를 통해 외부 접근)
**5. 법적 문제 회피**
```bash
# 법적 경고 항상 확인
curl "http://kakao-review-api.20.249.191.180.nip.io/legal-warning"
# 교육용임을 명시하는 환경변수 설정
export LEGAL_WARNING_ENABLED=true
export EDUCATIONAL_USE_ONLY=true
```
## 🎯 성능 최적화
### Chrome 최적화 설정
```yaml
# deployment.yaml에서 Chrome 최적화를 위한 리소스 설정
resources:
requests:
memory: "1Gi" # Chrome은 메모리를 많이 사용
cpu: "500m"
limits:
memory: "2Gi" # Chrome 분석을 위한 충분한 메모리
cpu: "1000m"
```
### 타임아웃 설정
```yaml
# ingress.yaml에서 긴 분석 시간을 고려한 타임아웃
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800" # 30분
nginx.ingress.kubernetes.io/proxy-send-timeout: "1800" # 30분
```
## 📈 향후 확장 계획
- [ ] **Claude AI 연동**: 리뷰 분석 및 개선 방안 자동 생성
- [ ] **Vector DB 구축**: 유사 케이스 검색 및 템플릿 관리
- [ ] **PostgreSQL 연동**: 분석 결과 영구 저장
- [ ] **다중 플랫폼 지원**: 네이버, 구글 등 추가 플랫폼 (합법적 방법으로)
- [ ] **실시간 분석**: WebSocket 기반 실시간 피드백
- [ ] **사용자 인증**: JWT 기반 인증 시스템
- [ ] **대시보드**: 분석 결과 시각화
- [ ] **알림 시스템**: 새로운 리뷰 알림 기능
- [ ] **감정 분석 고도화**: 더 정확한 감정 분류
- [ ] **비즈니스 통찰**: AI 기반 개선 방안 제시
## ⚖️ 법적 준수 사항
### 필수 확인 사항
1. **교육용 목적으로만 사용**
2. **실제 서비스 환경에서 사용 금지**
3. **카카오 이용약관 준수**
4. **개인정보보호법 준수**
5. **저작권 침해 금지**
### 권장 대안
1. **카카오 공식 API 사용**
2. **자체 플랫폼 개발**
3. **사용자 동의 기반 수집**
4. **정식 파트너십 체결**
---
## 📞 지원 및 문의
- **이슈 리포트**: GitHub Issues
- **기술 문의**: 개발팀 Slack
- **법적 문의**: 법무팀
- **API 문서**: Swagger UI에서 상세 확인
---
**⚠️ 재고지: 이 서비스는 교육용으로만 제작되었으며, 실제 운영 환경에서의 사용을 엄격히 금지합니다.**

1626
review/app/main.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
beautifulsoup4==4.12.2
selenium==4.15.2
webdriver-manager==4.0.1
pydantic==2.5.0
lxml==4.9.3
python-multipart==0.0.6
python-dotenv==1.0.0

217
review/build-base.sh Executable file
View File

@ -0,0 +1,217 @@
#!/bin/bash
# build-base.sh - Base Image 빌드 스크립트
set -e
# 변수 설정
BASE_IMAGE_NAME="kakao-review-api-base"
BASE_IMAGE_TAG="${1:-latest}"
ACR_NAME="${2:-acrdigitalgarage03}" # ACR 이름 (예: acrdigitalgarage01)
RESOURCE_GROUP="${3:-rg-digitalgarage-03}" # 리소스 그룹
# ACR URL 자동 구성
if [ -n "${ACR_NAME}" ]; then
REGISTRY="${ACR_NAME}.azurecr.io"
FULL_BASE_IMAGE_NAME="${REGISTRY}/${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
else
FULL_BASE_IMAGE_NAME="${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
fi
# 고정된 Dockerfile 경로
BASE_DOCKERFILE_PATH="deployment/container/Dockerfile-base"
BUILD_CONTEXT="."
echo "====================================================="
echo " 카카오맵 리뷰 분석 API Base Image 빌드"
echo "====================================================="
echo "Base 이미지명: ${FULL_BASE_IMAGE_NAME}"
if [ -n "${ACR_NAME}" ]; then
echo "ACR 이름: ${ACR_NAME}"
echo "리소스 그룹: ${RESOURCE_GROUP}"
fi
echo "빌드 시작: $(date)"
echo ""
# 사용법 표시 함수
show_usage() {
echo "사용법:"
echo " $0 [BASE_IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP]"
echo ""
echo "파라미터:"
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
echo " ACR_NAME : Azure Container Registry 이름"
echo " RESOURCE_GROUP: Azure 리소스 그룹"
echo ""
echo "예시:"
echo " $0 v1.0.0 # 로컬 빌드만"
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
echo ""
echo "주의: Base Image는 자주 빌드할 필요가 없습니다."
echo " Chrome 업데이트나 기본 패키지 변경 시에만 재빌드하세요."
}
# ACR 로그인 함수
acr_login() {
local acr_name="$1"
local resource_group="$2"
echo "🔐 Azure Container Registry 로그인 중..."
if ! command -v az &> /dev/null; then
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "❌ jq가 설치되지 않았습니다."
exit 1
fi
if ! az account show &> /dev/null; then
echo "❌ Azure에 로그인되지 않았습니다."
echo "로그인 명령: az login"
exit 1
fi
local credential_json
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
if [ $? -ne 0 ]; then
echo "❌ ACR credential 조회 실패"
exit 1
fi
local username
local password
username=$(echo "${credential_json}" | jq -r '.username')
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
echo "❌ ACR credential 파싱 실패"
exit 1
fi
echo "🔐 Docker 로그인 실행 중..."
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
if [ $? -eq 0 ]; then
echo "✅ ACR 로그인 성공!"
return 0
else
echo "❌ ACR 로그인 실패"
exit 1
fi
}
# 파라미터 검증
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_usage
exit 0
fi
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
echo "❌ ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
echo ""
show_usage
exit 1
fi
# 필수 파일 확인
echo "📁 필수 파일 확인 중..."
if [ ! -f "${BASE_DOCKERFILE_PATH}" ]; then
echo "${BASE_DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
exit 1
fi
echo "✅ Base Dockerfile 확인 완료"
echo "📄 Base Dockerfile: ${BASE_DOCKERFILE_PATH}"
# ACR 로그인 수행
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
echo ""
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
echo ""
fi
# Docker 빌드
echo "🔨 Base Image 빌드 시작... (시간이 다소 소요됩니다)"
echo "명령어: docker build -t \"${FULL_BASE_IMAGE_NAME}\" -f \"${BASE_DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
docker build -t "${FULL_BASE_IMAGE_NAME}" -f "${BASE_DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
if [ $? -eq 0 ]; then
echo "✅ Base Image 빌드 완료!"
echo "이미지명: ${FULL_BASE_IMAGE_NAME}"
# 이미지 정보 표시
echo ""
echo "📊 Base Image 정보:"
docker images "${FULL_BASE_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
# latest 태그 추가 생성
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo ""
echo "🏷️ latest 태그 생성 중..."
docker tag "${FULL_BASE_IMAGE_NAME}" "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
echo "✅ latest 태그 생성 완료: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
fi
# ACR 푸시
if [ -n "${ACR_NAME}" ]; then
echo ""
echo "🚀 ACR에 Base Image 푸시 중..."
echo "📤 푸시 중: ${FULL_BASE_IMAGE_NAME}"
docker push "${FULL_BASE_IMAGE_NAME}"
if [ $? -eq 0 ]; then
echo "✅ Base Image 푸시 성공"
if [ "${BASE_IMAGE_TAG}" != "latest" ]; then
echo "📤 푸시 중: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
docker push "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
if [ $? -eq 0 ]; then
echo "✅ latest 태그 푸시 성공"
fi
fi
else
echo "❌ Base Image 푸시 실패"
exit 1
fi
fi
echo ""
echo "🎉 Base Image 빌드 완료!"
echo ""
echo "📋 완료된 작업:"
echo " ✅ Base Image 빌드: ${FULL_BASE_IMAGE_NAME}"
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo " ✅ latest 태그: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
fi
if [ -n "${ACR_NAME}" ]; then
echo " ✅ ACR 푸시 완료"
fi
echo ""
echo "🧪 테스트 명령어:"
echo " docker run --rm ${FULL_BASE_IMAGE_NAME} google-chrome --version"
echo ""
echo "📝 다음 단계:"
echo " 이제 Service Image를 빌드하세요:"
if [ -n "${ACR_NAME}" ]; then
echo " ./scripts/build.sh v1.0.0 ${ACR_NAME} ${RESOURCE_GROUP}"
else
echo " ./scripts/build.sh v1.0.0"
fi
else
echo "❌ Base Image 빌드 실패!"
exit 1
fi
echo ""
echo "🏁 Base Image 빌드 프로세스 완료 - $(date)"

257
review/build.sh Executable file
View File

@ -0,0 +1,257 @@
#!/bin/bash
# build.sh - Service Image 빌드 스크립트 (Base Image 활용)
set -e
# 변수 설정
IMAGE_NAME="kakao-review-api"
IMAGE_TAG="${1:-latest}"
ACR_NAME="${2:-acrdigitalgarage03}" # ACR 이름 (예: acrdigitalgarage01)
RESOURCE_GROUP="${3:-rg-digitalgarage-03}" # 리소스 그룹
BASE_IMAGE_TAG="${4:-latest}" # Base Image 태그
# ACR URL 자동 구성
if [ -n "${ACR_NAME}" ]; then
REGISTRY="${ACR_NAME}.azurecr.io"
FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
BASE_IMAGE="${REGISTRY}/kakao-review-api-base:${BASE_IMAGE_TAG}"
else
FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}"
BASE_IMAGE="kakao-review-api-base:${BASE_IMAGE_TAG}"
fi
# 고정된 Dockerfile 경로
DOCKERFILE_PATH="deployment/container/Dockerfile"
BUILD_CONTEXT="."
echo "====================================================="
echo " 카카오맵 리뷰 분석 API Service Image 빌드"
echo "====================================================="
echo "Service 이미지명: ${FULL_IMAGE_NAME}"
echo "Base 이미지: ${BASE_IMAGE}"
if [ -n "${ACR_NAME}" ]; then
echo "ACR 이름: ${ACR_NAME}"
echo "리소스 그룹: ${RESOURCE_GROUP}"
fi
echo "빌드 시작: $(date)"
echo ""
# 사용법 표시 함수
show_usage() {
echo "사용법:"
echo " $0 [IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP] [BASE_IMAGE_TAG]"
echo ""
echo "파라미터:"
echo " IMAGE_TAG : Service 이미지 태그 (기본값: latest)"
echo " ACR_NAME : Azure Container Registry 이름"
echo " RESOURCE_GROUP: Azure 리소스 그룹"
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
echo ""
echo "예시:"
echo " $0 v1.0.0 # 로컬 빌드만"
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 v2.0.0 # 특정 Base Image 사용"
echo ""
echo "전제조건:"
echo " Base Image가 먼저 빌드되어 있어야 합니다:"
echo " ./scripts/build-base.sh ${BASE_IMAGE_TAG} [ACR_NAME] [RESOURCE_GROUP]"
}
# ACR 로그인 함수
acr_login() {
local acr_name="$1"
local resource_group="$2"
echo "🔐 Azure Container Registry 로그인 중..."
if ! command -v az &> /dev/null; then
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "❌ jq가 설치되지 않았습니다."
exit 1
fi
if ! az account show &> /dev/null; then
echo "❌ Azure에 로그인되지 않았습니다."
echo "로그인 명령: az login"
exit 1
fi
local credential_json
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
if [ $? -ne 0 ]; then
echo "❌ ACR credential 조회 실패"
exit 1
fi
local username
local password
username=$(echo "${credential_json}" | jq -r '.username')
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
echo "❌ ACR credential 파싱 실패"
exit 1
fi
echo "🔐 Docker 로그인 실행 중..."
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
if [ $? -eq 0 ]; then
echo "✅ ACR 로그인 성공!"
return 0
else
echo "❌ ACR 로그인 실패"
exit 1
fi
}
# 파라미터 검증
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_usage
exit 0
fi
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
echo "❌ ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
echo ""
show_usage
exit 1
fi
# 법적 경고
echo "⚠️ 중요 법적 경고사항 ⚠️"
echo "이 이미지는 교육 목적으로만 사용하세요!"
echo "실제 서비스 사용 시 법적 책임을 질 수 있습니다."
echo ""
# 필수 파일 확인
echo "📁 필수 파일 확인 중..."
if [ ! -f "app/main.py" ]; then
echo "❌ app/main.py 파일을 찾을 수 없습니다."
exit 1
fi
if [ ! -f "app/requirements.txt" ]; then
echo "❌ app/requirements.txt 파일을 찾을 수 없습니다."
exit 1
fi
if [ ! -f "${DOCKERFILE_PATH}" ]; then
echo "${DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
exit 1
fi
echo "✅ 모든 필수 파일이 확인되었습니다."
echo "📄 Dockerfile: ${DOCKERFILE_PATH}"
echo "🏗️ 빌드 컨텍스트: ${BUILD_CONTEXT}"
# Base Image 존재 확인
echo ""
echo "🔍 Base Image 확인 중..."
if docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
echo "✅ Base Image 확인됨: ${BASE_IMAGE}"
else
echo "❌ Base Image를 찾을 수 없습니다: ${BASE_IMAGE}"
echo ""
echo "Base Image를 먼저 빌드하세요:"
if [ -n "${ACR_NAME}" ]; then
echo " ./scripts/build-base.sh ${BASE_IMAGE_TAG} ${ACR_NAME} ${RESOURCE_GROUP}"
else
echo " ./scripts/build-base.sh ${BASE_IMAGE_TAG}"
fi
exit 1
fi
# ACR 로그인 수행
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
echo ""
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
echo ""
fi
# Docker 빌드
echo "🔨 Service Image 빌드 시작... (빠른 빌드 예상)"
echo "명령어: docker build --build-arg BASE_IMAGE=\"${BASE_IMAGE}\" -t \"${FULL_IMAGE_NAME}\" -f \"${DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
docker build --build-arg BASE_IMAGE="${BASE_IMAGE}" -t "${FULL_IMAGE_NAME}" -f "${DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
if [ $? -eq 0 ]; then
echo "✅ Service Image 빌드 완료!"
echo "이미지명: ${FULL_IMAGE_NAME}"
# 이미지 정보 표시
echo ""
echo "📊 Service Image 정보:"
docker images "${FULL_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
# latest 태그 추가 생성
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo ""
echo "🏷️ latest 태그 생성 중..."
docker tag "${FULL_IMAGE_NAME}" "${REGISTRY}/${IMAGE_NAME}:latest"
echo "✅ latest 태그 생성 완료: ${REGISTRY}/${IMAGE_NAME}:latest"
fi
# ACR 푸시
if [ -n "${ACR_NAME}" ]; then
echo ""
echo "🚀 ACR에 Service Image 푸시 중..."
echo "📤 푸시 중: ${FULL_IMAGE_NAME}"
docker push "${FULL_IMAGE_NAME}"
if [ $? -eq 0 ]; then
echo "✅ Service Image 푸시 성공"
if [ "${IMAGE_TAG}" != "latest" ]; then
echo "📤 푸시 중: ${REGISTRY}/${IMAGE_NAME}:latest"
docker push "${REGISTRY}/${IMAGE_NAME}:latest"
if [ $? -eq 0 ]; then
echo "✅ latest 태그 푸시 성공"
fi
fi
else
echo "❌ Service Image 푸시 실패"
exit 1
fi
fi
echo ""
echo "🎉 Service Image 빌드 완료!"
echo ""
echo "📋 완료된 작업:"
echo " ✅ Service Image 빌드: ${FULL_IMAGE_NAME}"
echo " ✅ 사용된 Base Image: ${BASE_IMAGE}"
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo " ✅ latest 태그: ${REGISTRY}/${IMAGE_NAME}:latest"
fi
if [ -n "${ACR_NAME}" ]; then
echo " ✅ ACR 푸시 완료"
fi
echo ""
echo "🧪 테스트 명령어:"
echo " docker run -p 8000:8000 ${FULL_IMAGE_NAME}"
echo " curl http://localhost:8000/health"
if [ -n "${ACR_NAME}" ]; then
echo ""
echo "🔍 ACR 이미지 확인:"
echo " az acr repository show-tags --name ${ACR_NAME} --repository ${IMAGE_NAME}"
fi
else
echo "❌ Service Image 빌드 실패!"
exit 1
fi
echo ""
echo "🏁 Service Image 빌드 프로세스 완료 - $(date)"

103
review/create-imagepullsecret.sh Executable file
View File

@ -0,0 +1,103 @@
#!/bin/bash
# create-image-pull-secret.sh - ACR Image Pull Secret 생성 스크립트
set -e
# 변수 설정
ACR_NAME="${1:-acrdigitalgarage03}"
RESOURCE_GROUP="${2:-rg-digitalgarage-03}"
SECRET_NAME="${3:-acr-secret}"
echo "====================================================="
echo " ACR Image Pull Secret 생성"
echo "====================================================="
# 사용법 표시 함수
show_usage() {
echo "사용법:"
echo " $0 [ACR_NAME] [RESOURCE_GROUP] [SECRET_NAME]"
echo ""
echo "파라미터:"
echo " ACR_NAME : Azure Container Registry 이름 (필수)"
echo " RESOURCE_GROUP: Azure 리소스 그룹 (필수)"
echo " SECRET_NAME : Secret 이름 (기본값: acr-secret)"
echo ""
echo "예시:"
echo " $0 acrdigitalgarage01 rg-digitalgarage-03"
echo " $0 acrdigitalgarage01 rg-digitalgarage-03 acr-prod-secret"
echo ""
}
# 파라미터 검증
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_usage
exit 0
fi
if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ]; then
echo "❌ ACR_NAME과 RESOURCE_GROUP는 필수 파라미터입니다."
echo ""
show_usage
exit 1
fi
# ACR 정보 설정
REGISTRY_URL="${ACR_NAME}.azurecr.io"
echo ""
echo "📋 ACR 정보:"
echo " ACR 이름: ${ACR_NAME}"
echo " 레지스트리 URL: ${REGISTRY_URL}"
echo " 리소스 그룹: ${RESOURCE_GROUP}"
echo " Secret 이름: ${SECRET_NAME}"
# ACR credential 조회
echo ""
echo "🔑 ACR credential 조회 중..."
credential_json=$(az acr credential show --name "${ACR_NAME}" --resource-group "${RESOURCE_GROUP}" 2>/dev/null)
if [ $? -ne 0 ]; then
echo "❌ ACR credential 조회 실패"
echo "확인 사항:"
echo " - ACR 이름: ${ACR_NAME}"
echo " - 리소스 그룹: ${RESOURCE_GROUP}"
echo " - Azure 권한"
exit 1
fi
# JSON에서 username과 password 추출
username=$(echo "${credential_json}" | jq -r '.username')
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
echo "❌ ACR credential 파싱 실패"
exit 1
fi
echo "✅ ACR credential 조회 성공"
echo " 사용자명: ${username}"
echo " 비밀번호: ${password:0:10}..."
# 기존 Secret 삭제 (있는 경우)
if kubectl get secret "${SECRET_NAME}" &> /dev/null; then
echo ""
echo "🗑️ 기존 Secret 삭제 중..."
kubectl delete secret "${SECRET_NAME}"
fi
# Image Pull Secret 생성
echo ""
echo "🔐 Image Pull Secret 생성 중..."
kubectl create secret docker-registry "${SECRET_NAME}" \
--docker-server="${REGISTRY_URL}" \
--docker-username="${username}" \
--docker-password="${password}"
if [ $? -eq 0 ]; then
echo "✅ Image Pull Secret 생성 성공!"
else
echo "❌ Image Pull Secret 생성 실패"
exit 1
fi

View File

@ -0,0 +1,72 @@
# deployment/container/Dockerfile
# Service Image - Base Image 위에 애플리케이션만 추가 (AKS 최적화)
ARG BASE_IMAGE=kakao-review-api-base:latest
FROM ${BASE_IMAGE}
# 메타데이터
LABEL maintainer="admin@example.com"
LABEL version="1.0.1"
LABEL description="카카오맵 리뷰 분석 API - Service Image (AKS 최적화)"
# root로 전환 (패키지 설치용)
USER root
# 🔧 Chrome 실행을 위한 환경 변수 설정 (VM 환경과 동일)
ENV HOME=/home/appuser \
WDM_LOCAL=1 \
WDM_LOG_LEVEL=0 \
CHROME_BIN=/usr/bin/google-chrome \
CHROMEDRIVER_BIN=/usr/local/bin/chromedriver \
DISPLAY=:99 \
DBUS_SESSION_BUS_ADDRESS=/dev/null
# Python 의존성 파일 복사 및 설치
COPY app/requirements.txt /app/
RUN pip install --no-cache-dir -r /app/requirements.txt
# 애플리케이션 소스 복사
COPY app/main.py /app/
# 🔧 Chrome 실행을 위한 디렉토리 및 권한 설정 (VM 환경과 동일)
RUN mkdir -p /home/appuser/.cache/selenium \
&& mkdir -p /home/appuser/.wdm \
&& mkdir -p /home/appuser/.local/share \
&& mkdir -p /tmp/chrome-user-data \
&& mkdir -p /tmp/.wdm \
&& chown -R appuser:appuser /home/appuser \
&& chown -R appuser:appuser /tmp/chrome-user-data \
&& chown -R appuser:appuser /tmp/.wdm \
&& chmod -R 755 /home/appuser \
&& chmod -R 777 /tmp/chrome-user-data \
&& chmod -R 777 /tmp/.wdm
# /app 디렉토리 권한 설정
RUN chown -R appuser:appuser /app
# 🔧 ChromeDriver 접근 권한 확인 및 테스트
RUN echo "=== ChromeDriver 정보 ===" \
&& ls -la /usr/local/bin/chromedriver \
&& chromedriver --version \
&& echo "=== Chrome 정보 ===" \
&& google-chrome --version \
&& echo "=== 권한 테스트 ===" \
&& su - appuser -c "chromedriver --version" || echo "appuser ChromeDriver 접근 확인"
# 🔧 간단한 Selenium import 테스트만 수행
RUN python3 -c "from selenium import webdriver; from selenium.webdriver.chrome.options import Options; from selenium.webdriver.chrome.service import Service; print('✅ Selenium 모듈 import 성공')"
# 🔧 컨테이너 내 사용자를 appuser로 변경 (하지만 Chrome은 root 권한 필요)
USER appuser
# 작업 디렉토리 설정
WORKDIR /app
# 포트 노출 (review 서비스용)
EXPOSE 19000
# 🔧 헬스체크 (개선된 타임아웃)
HEALTHCHECK --interval=30s --timeout=15s --start-period=60s --retries=3 \
CMD curl -f http://localhost:19000/health || exit 1
# 🔧 애플리케이션 실행 (로그 레벨 설정)
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "19000", "--log-level", "info"]

View File

@ -0,0 +1,103 @@
# deployment/container/Dockerfile-base
FROM python:3.11-slim
# 메타데이터
LABEL maintainer="admin@example.com"
LABEL description="카카오맵 리뷰 분석 API - Base Image with Chrome (AKS 최적화)"
LABEL version="base-1.0.2"
# 환경 변수 설정
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DEBIAN_FRONTEND=noninteractive \
CHROME_BIN=/usr/bin/google-chrome \
CHROMEDRIVER_BIN=/usr/local/bin/chromedriver
# 🔧 필수 패키지 설치 (VM setup.sh와 동일한 패키지들)
RUN apt-get update && apt-get install -y \
python3-pip \
python3-venv \
unzip \
wget \
curl \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libdrm2 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libxss1 \
libnss3 \
libxext6 \
libxfixes3 \
libxi6 \
libxrender1 \
libcairo-gobject2 \
libgtk-3-0 \
libgdk-pixbuf2.0-0 \
libgtk-3-dev \
libgconf-2-4 \
&& rm -rf /var/lib/apt/lists/*
# 🔧 Google Chrome 설치 (VM setup.sh와 동일한 방식)
RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update \
&& apt-get install -y google-chrome-stable \
&& rm -rf /var/lib/apt/lists/*
# Chrome 설치 확인
RUN google-chrome --version
# 🔧 ChromeDriver 설치 (VM setup.sh와 동일한 방식)
RUN CHROME_VERSION=$(google-chrome --version | grep -oP '\d+\.\d+\.\d+\.\d+' | cut -d'.' -f1) \
&& echo "Chrome 주 버전: $CHROME_VERSION" \
&& CHROMEDRIVER_VERSION=$(curl -s "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_$CHROME_VERSION") \
&& echo "ChromeDriver 버전: $CHROMEDRIVER_VERSION" \
&& wget -q "https://storage.googleapis.com/chrome-for-testing-public/$CHROMEDRIVER_VERSION/linux64/chromedriver-linux64.zip" -O /tmp/chromedriver.zip \
&& unzip -q /tmp/chromedriver.zip -d /tmp/ \
&& mv /tmp/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver \
&& chmod +x /usr/local/bin/chromedriver \
&& rm -rf /tmp/chromedriver* \
&& chromedriver --version
# Chrome 및 ChromeDriver 최종 확인
RUN echo "=== Chrome 정보 ===" \
&& google-chrome --version \
&& echo "=== ChromeDriver 정보 ===" \
&& chromedriver --version \
&& echo "=== 설치 경로 확인 ===" \
&& ls -la /usr/bin/google-chrome* \
&& ls -la /usr/local/bin/chromedriver
# 🔧 Chrome 실행을 위한 디렉토리 생성
RUN mkdir -p /tmp/chrome-user-data \
&& mkdir -p /home/appuser/.cache/selenium \
&& mkdir -p /home/appuser/.wdm \
&& mkdir -p /home/appuser/.local/share \
&& chmod 777 /tmp/chrome-user-data
# 🔧 비root 사용자 생성 (하지만 Chrome은 root로 실행)
RUN groupadd -r appuser && useradd -r -g appuser -d /home/appuser -s /bin/bash appuser \
&& mkdir -p /home/appuser \
&& chown -R appuser:appuser /home/appuser
# 작업 디렉토리 생성
WORKDIR /app
RUN chown appuser:appuser /app
# pip 업그레이드
RUN pip install --no-cache-dir --upgrade pip
# Chrome 실행 테스트 (빌드 시 검증)
RUN google-chrome --headless --no-sandbox --disable-dev-shm-usage --virtual-time-budget=1000 --run-all-compositor-stages-before-draw data:text/html,\<html\>\<body\>\<h1\>Test\</h1\>\</body\>\</html\> || echo "Chrome 테스트 완료"
# 포트 노출
EXPOSE 8000
# 기본 명령어 (오버라이드 가능)
CMD ["python", "--version"]

View File

@ -0,0 +1,78 @@
# deployment/manifests/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: kakao-review-api-config
data:
# 애플리케이션 설정
APP_TITLE: "카카오맵 리뷰 분석 API"
APP_VERSION: "1.0.1"
APP_DESCRIPTION: "교육 목적 전용 - 실제 서비스 사용 금지"
# 서버 설정
HOST: "0.0.0.0"
PORT: "8000"
WORKERS: "1"
LOG_LEVEL: "info"
# API 기본값 설정 (AKS 환경에 최적화)
DEFAULT_MAX_TIME: "300"
DEFAULT_DAYS_LIMIT: "60"
MAX_DAYS_LIMIT: "365"
MIN_MAX_TIME: "60"
MAX_MAX_TIME: "600"
# 🔧 Chrome 브라우저 설정 (AKS 환경 최적화)
CHROME_OPTIONS: |
--headless=new
--no-sandbox
--disable-dev-shm-usage
--disable-gpu
--disable-software-rasterizer
--window-size=1920,1080
--disable-extensions
--disable-plugins
--disable-usb-keyboard-detect
--no-first-run
--no-default-browser-check
--disable-logging
--log-level=3
--disable-background-timer-throttling
--disable-backgrounding-occluded-windows
--disable-renderer-backgrounding
--disable-features=TranslateUI,VizDisplayCompositor
--disable-ipc-flooding-protection
--memory-pressure-off
--max_old_space_size=4096
--no-zygote
--disable-setuid-sandbox
--disable-background-networking
--disable-default-apps
--disable-sync
--metrics-recording-only
--safebrowsing-disable-auto-update
--disable-prompt-on-repost
--disable-hang-monitor
--disable-client-side-phishing-detection
--disable-component-update
--disable-domain-reliability
--user-data-dir=/tmp/chrome-user-data
--data-path=/tmp/chrome-user-data
--disk-cache-dir=/tmp/chrome-cache
--aggressive-cache-discard
--disable-web-security
--allow-running-insecure-content
--disable-blink-features=AutomationControlled
# 스크롤링 설정 (AKS 환경에 맞게 조정)
SCROLL_CHECK_INTERVAL: "5"
SCROLL_NO_CHANGE_LIMIT: "6"
SCROLL_WAIT_TIME_SHORT: "2.0"
SCROLL_WAIT_TIME_LONG: "3.0"
# 법적 경고 메시지
LEGAL_WARNING_ENABLED: "true"
CONTACT_EMAIL: "admin@example.com"
# 건강 체크 설정
HEALTH_CHECK_TIMEOUT: "10"

View File

@ -0,0 +1,130 @@
# deployment/manifests/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: kakao-review-api
labels:
app: kakao-review-api
version: v1
spec:
replicas: 1
selector:
matchLabels:
app: kakao-review-api
template:
metadata:
labels:
app: kakao-review-api
version: v1
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: api
image: acrdigitalgarage03.azurecr.io/kakao-review-api:latest
imagePullPolicy: Always
ports:
- containerPort: 19000
name: http
# 🔧 ConfigMap 환경 변수
envFrom:
- configMapRef:
name: kakao-review-api-config
# 🔧 Secret 환경 변수
env:
- name: EXTERNAL_API_KEY
valueFrom:
secretKeyRef:
name: kakao-review-api-secret
key: EXTERNAL_API_KEY
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: kakao-review-api-secret
key: DB_USERNAME
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: kakao-review-api-secret
key: DB_PASSWORD
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: kakao-review-api-secret
key: JWT_SECRET
# 🔧 Chrome/ChromeDriver 환경 변수 (VM과 동일)
- name: WDM_LOCAL
value: "/tmp/.wdm"
- name: WDM_LOG_LEVEL
value: "0"
- name: CHROME_BIN
value: "/usr/bin/google-chrome"
- name: CHROMEDRIVER_BIN
value: "/usr/local/bin/chromedriver"
- name: DISPLAY
value: ":99"
- name: DBUS_SESSION_BUS_ADDRESS
value: "/dev/null"
# 🔧 리소스 제한 (Chrome 실행에 충분한 리소스)
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "4Gi"
cpu: "2000m"
# 🔧 헬스 체크 (타임아웃 증가)
livenessProbe:
httpGet:
path: /health
port: 19000
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 15
failureThreshold: 5
readinessProbe:
httpGet:
path: /health
port: 19000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 5
# 🔧 간소화된 보안 컨텍스트 (AKS 호환)
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: true
readOnlyRootFilesystem: false
capabilities:
add:
- SYS_ADMIN
drop: []
# 🔧 볼륨 마운트 (Chrome 실행 최적화)
volumeMounts:
- name: tmp-volume
mountPath: /tmp
- name: dev-shm
mountPath: /dev/shm
# 🔧 볼륨 정의 (간소화)
volumes:
- name: tmp-volume
emptyDir: {}
- name: dev-shm
emptyDir:
medium: Memory
sizeLimit: 2Gi
restartPolicy: Always
# 🔧 Pod 레벨 보안 설정 제거 (AKS 호환을 위해)
# securityContext: 제거

View File

@ -0,0 +1,39 @@
# deployment/manifests/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kakao-review-api-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "false"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
# 🔧 타임아웃 설정 (Chrome 분석 시간 고려)
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800"
nginx.ingress.kubernetes.io/proxy-send-timeout: "1800"
nginx.ingress.kubernetes.io/client-body-timeout: "1800"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
# 🔧 CORS 설정 (필요시)
nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
spec:
ingressClassName: nginx
rules:
# 🔧 환경에 맞게 호스트명 수정 필요
- host: kakao-review-api.20.249.191.180.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kakao-review-api-service
port:
number: 80
# 🔧 TLS 설정 (HTTPS 필요시 주석 해제)
# tls:
# - hosts:
# - kakao-review-api.example.com
# secretName: kakao-review-api-tls

View File

@ -0,0 +1,21 @@
# deployment/manifests/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: kakao-review-api-secret
type: Opaque
data:
# 🔧 현재 사용하지 않지만 향후 확장을 위한 플레이스홀더
# 실제 값 설정 시: echo -n "your-value" | base64
# API 키 (향후 카카오 공식 API 연동용)
EXTERNAL_API_KEY: ""
# 데이터베이스 연결 정보 (향후 DB 연동용)
DB_USERNAME: ""
DB_PASSWORD: ""
DB_HOST: ""
# JWT 시크릿 (향후 인증 기능용)
JWT_SECRET: ""

View File

@ -0,0 +1,17 @@
# deployment/manifests/service.yaml
apiVersion: v1
kind: Service
metadata:
name: kakao-review-api-service
labels:
app: kakao-review-api
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 19000
protocol: TCP
name: http
selector:
app: kakao-review-api

280
review/setup.sh Executable file
View File

@ -0,0 +1,280 @@
#!/bin/bash
# setup.sh - Review Analysis API 환경 설정 스크립트
set -e
echo "🔍 Review Analysis API 환경 설정 시작..."
# 시스템 정보 확인
echo "📊 시스템 정보:"
echo " - OS: $(lsb_release -d | cut -f2)"
echo " - Python: $(python3 --version 2>/dev/null || echo 'Python3 미설치')"
echo " - pip: $(pip3 --version 2>/dev/null || echo 'pip3 미설치')"
# 필수 패키지 설치
echo ""
echo "📦 필수 패키지 설치 중..."
sudo apt update
sudo apt install -y python3-pip python3-venv unzip wget curl jq
# Chrome 브라우저 설치 (리뷰 분석용)
echo ""
echo "🌐 Chrome 브라우저 설치 확인 중..."
if ! command -v google-chrome &> /dev/null; then
echo "Chrome 브라우저 설치 중..."
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list
sudo apt update
sudo apt install -y google-chrome-stable
echo "✅ Chrome 브라우저 설치 완료"
else
CHROME_VERSION=$(google-chrome --version)
echo "✅ Chrome 브라우저 이미 설치됨: ${CHROME_VERSION}"
fi
# ChromeDriver 설치
echo ""
echo "🚗 ChromeDriver 설치 중..."
if [ -f "/usr/local/bin/chromedriver" ]; then
echo "기존 ChromeDriver 제거 중..."
sudo rm -f /usr/local/bin/chromedriver
fi
CHROME_VERSION=$(google-chrome --version | grep -oP '\d+\.\d+\.\d+\.\d+' | cut -d'.' -f1)
echo "Chrome 주 버전: ${CHROME_VERSION}"
CHROMEDRIVER_VERSION=$(curl -s "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_${CHROME_VERSION}")
echo "ChromeDriver 버전: ${CHROMEDRIVER_VERSION}"
TEMP_DIR=$(mktemp -d)
cd "$TEMP_DIR"
wget -q "https://storage.googleapis.com/chrome-for-testing-public/${CHROMEDRIVER_VERSION}/linux64/chromedriver-linux64.zip"
unzip -q chromedriver-linux64.zip
sudo mv chromedriver-linux64/chromedriver /usr/local/bin/
sudo chmod +x /usr/local/bin/chromedriver
cd - > /dev/null
rm -rf "$TEMP_DIR"
echo "✅ ChromeDriver 설치 완료: $(chromedriver --version)"
# Python 버전 확인
PYTHON_VERSION=$(python3 -c "import sys; print('.'.join(map(str, sys.version_info[:2])))")
echo ""
echo "📍 Python 버전: ${PYTHON_VERSION}"
if [ "$(echo "${PYTHON_VERSION} < 3.8" | bc)" -eq 1 ]; then
echo "⚠️ Python 3.8 이상이 권장됩니다."
fi
# Python 가상환경 설정
echo ""
echo "🐍 Python 가상환경 설정 중..."
if [ ! -d "venv" ]; then
python3 -m venv venv
echo "✅ 가상환경 생성 완료"
else
echo "✅ 기존 가상환경 발견"
fi
# 가상환경 활성화 및 라이브러리 설치
echo "📚 Python 라이브러리 설치 중..."
source venv/bin/activate
# pip 업그레이드
pip install --upgrade pip
# 필요한 라이브러리 설치
if [ -f "app/requirements.txt" ]; then
pip install -r app/requirements.txt
echo "✅ requirements.txt에서 라이브러리 설치 완료"
else
echo "⚠️ app/requirements.txt 파일을 찾을 수 없습니다."
echo "🔧 기본 라이브러리들을 직접 설치합니다..."
pip install fastapi uvicorn beautifulsoup4 selenium webdriver-manager pydantic python-multipart lxml
fi
# 데이터 디렉토리 생성
echo ""
echo "📁 데이터 디렉토리 설정 중..."
mkdir -p data
chmod 755 data
echo "✅ 데이터 디렉토리 생성: $(pwd)/data"
# 환경변수 파일 생성 (예시)
echo ""
echo "⚙️ 환경변수 파일 생성 중..."
cat > .env << EOF
# 애플리케이션 설정
APP_TITLE=카카오맵 리뷰 분석 API
APP_VERSION=1.0.1
APP_DESCRIPTION=교육 목적 전용 - 실제 서비스 사용 금지
# 서버 설정
HOST=0.0.0.0
PORT=19000
LOG_LEVEL=info
# API 기본값 설정
DEFAULT_MAX_TIME=300
DEFAULT_DAYS_LIMIT=60
MAX_DAYS_LIMIT=365
MIN_MAX_TIME=60
MAX_MAX_TIME=600
# Chrome 브라우저 설정
CHROME_OPTIONS=--headless=new --no-sandbox --disable-dev-shm-usage --disable-gpu --window-size=1920,1080
SCROLL_CHECK_INTERVAL=5
SCROLL_NO_CHANGE_LIMIT=6
SCROLL_WAIT_TIME_SHORT=2.0
SCROLL_WAIT_TIME_LONG=3.0
# 법적 경고 메시지
LEGAL_WARNING_ENABLED=true
CONTACT_EMAIL=admin@example.com
# 건강 체크 설정
HEALTH_CHECK_TIMEOUT=10
# AI API 설정 (향후 Claude API 연동용)
# CLAUDE_API_KEY=your_claude_api_key_here
# 데이터베이스 설정 (향후 PostgreSQL 연동용)
# DB_HOST=localhost
# DB_PORT=5432
# DB_NAME=review_analysis
# DB_USERNAME=your_db_username
# DB_PASSWORD=your_db_password
EOF
echo "✅ .env 파일 생성 완료"
# 네트워크 연결 테스트
echo ""
echo "🌐 네트워크 연결 테스트 중..."
# 카카오맵 연결 테스트
if curl -s --connect-timeout 5 "https://place.map.kakao.com" > /dev/null; then
echo "✅ 카카오맵 서버 연결 가능"
else
echo "❌ 카카오맵 서버 연결 실패"
echo " 인터넷 연결 상태를 확인해주세요."
fi
# Selenium 및 Chrome 테스트
echo ""
echo "🧪 Selenium 및 Chrome 테스트 중..."
source venv/bin/activate
python3 -c "
try:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
print('✅ Selenium 모듈 import 성공')
options = Options()
options.add_argument('--headless')
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
try:
service = Service('/usr/local/bin/chromedriver')
driver = webdriver.Chrome(service=service, options=options)
driver.get('data:text/html,<html><body><h1>Test</h1></body></html>')
title = driver.title
driver.quit()
print('✅ Chrome WebDriver 테스트 성공')
except Exception as e:
print(f'❌ Chrome WebDriver 테스트 실패: {e}')
except ImportError as e:
print(f'❌ Selenium import 실패: {e}')
except Exception as e:
print(f'❌ 테스트 중 오류: {e}')
"
# Docker 설치 확인 (선택사항)
echo ""
echo "🐳 Docker 설치 확인 중..."
if command -v docker &> /dev/null; then
DOCKER_VERSION=$(docker --version)
echo "✅ Docker 설치됨: ${DOCKER_VERSION}"
# Docker 권한 확인
if docker ps &> /dev/null; then
echo "✅ Docker 권한 확인됨"
else
echo "⚠️ Docker 권한 없음. 다음 명령어로 권한을 추가하세요:"
echo " sudo usermod -aG docker $USER"
echo " newgrp docker"
fi
else
echo "⚠️ Docker가 설치되지 않음 (컨테이너 배포 시 필요)"
echo " 설치 방법: https://docs.docker.com/engine/install/ubuntu/"
fi
# 방화벽 설정 확인
echo ""
echo "🔥 방화벽 설정 확인 중..."
if command -v ufw &> /dev/null; then
UFW_STATUS=$(sudo ufw status | head -n1 | awk '{print $2}')
if [ "${UFW_STATUS}" = "active" ]; then
echo "🔥 UFW 방화벽 활성화됨"
if sudo ufw status | grep -q "19000"; then
echo "✅ 포트 19000 방화벽 규칙 확인됨"
else
echo "⚠️ 포트 19000 방화벽 규칙 없음"
echo " 다음 명령어로 포트를 열어주세요:"
echo " sudo ufw allow 19000"
fi
else
echo "✅ UFW 방화벽 비활성화됨"
fi
else
echo "✅ UFW 방화벽 미설치"
fi
echo ""
echo "🎉 환경 설정 완료!"
echo ""
echo "📋 다음 단계:"
echo "1. 가상환경 활성화:"
echo " source venv/bin/activate"
echo ""
echo "2. 환경변수 설정:"
echo " export DATA_DIR=\"./data\""
echo ""
echo "3. 애플리케이션 실행:"
echo " python app/main.py"
echo " 또는"
echo " uvicorn app.main:app --host 0.0.0.0 --port 19000 --reload"
echo ""
echo "4. 웹 브라우저에서 접속:"
echo " http://localhost:19000 (메인 페이지)"
echo " http://localhost:19000/docs (Swagger UI)"
echo " http://localhost:19000/health (헬스체크)"
echo ""
echo "5. API 테스트:"
echo " curl -X POST \"http://localhost:19000/analyze\" \\"
echo " -H \"Content-Type: application/json\" \\"
echo " -d '{\"store_id\":\"501745730\",\"days_limit\":7,\"max_time\":300}'"
echo ""
echo "⚠️ 중요 법적 경고사항:"
echo " 이 서비스는 교육 목적으로만 사용하세요!"
echo " 실제 웹사이트 크롤링은 법적 위험이 있습니다."
echo " 카카오 공식 API를 사용하는 것을 권장합니다: developers.kakao.com"
echo ""
echo "💡 문제 발생 시:"
echo " - 로그 확인: tail -f 로그파일"
echo " - 환경변수 확인: cat .env"
echo " - Chrome 확인: google-chrome --version"
echo " - ChromeDriver 확인: chromedriver --version"
echo ""
echo "🔧 설정 파일 위치:"
echo " - 환경변수: $(pwd)/.env"
echo " - 데이터 저장: $(pwd)/data/"
echo " - 애플리케이션: $(pwd)/app/"
deactivate 2>/dev/null || true
echo ""
echo "✅ Review Analysis API 환경 설정이 완료되었습니다!"

614
vector/README.md Normal file
View File

@ -0,0 +1,614 @@
# 음식점 Vector DB 구축 서비스
카카오 로컬 API를 활용하여 음식점 정보를 수집하고 Vector DB로 구축한 후, Claude AI와 연동하여 점주의 비즈니스 개선 요청을 처리하는 서비스입니다.
## 📋 프로젝트 개요
### 주요 기능
- 🔍 **카카오 로컬 API 연동**: 키워드 및 지역 기반 음식점 검색
- 🤖 **Vector DB 구축**: Sentence Transformer를 이용한 임베딩 및 Chroma DB 저장
- 🔄 **중복 처리 개선**: 기존 데이터 존재 여부 확인 및 업데이트/신규 추가 분리
- 🧠 **Claude AI 연동**: 점주 액션 요청에 대한 맞춤형 조언 생성
- 🎯 **유사도 검색**: Vector DB를 활용한 유사 음식점 검색
- 📊 **처리 통계**: 신규 추가, 업데이트, 중복 등 상세 통계 제공
- 🚀 **RESTful API 제공**: FastAPI 기반의 완전한 API 서비스
- 📚 **Swagger UI 지원**: 자동 생성되는 API 문서
- ☸️ **Kubernetes 배포**: 완전한 컨테이너 오케스트레이션 지원
- 🔧 **환경변수 설정**: ConfigMap과 Secret을 통한 유연한 설정 관리
### 기술 스택
- **Backend**: Python 3.11, FastAPI, aiohttp
- **Vector DB**: Chroma DB
- **ML**: Sentence Transformers, torch
- **AI**: Anthropic Claude API
- **External API**: Kakao Local API
- **Container**: Docker, Multi-stage build
- **Orchestration**: Kubernetes (AKS)
- **Registry**: Azure Container Registry (ACR)
- **Documentation**: Swagger UI, ReDoc
## 🏗️ 프로젝트 구조
```
review-api/vector/
├── app/ # 애플리케이션 소스
│ ├── main.py # 메인 애플리케이션
│ ├── requirements.txt # Python 의존성
│ ├── config/ # 설정 관리
│ │ └── settings.py # 환경 설정
│ ├── models/ # 데이터 모델
│ ├── services/ # 비즈니스 로직
│ │ ├── vector_service.py # Vector DB 서비스
│ │ ├── claude_service.py # Claude AI 서비스
│ │ └── kakao_service.py # 카카오 API 서비스
│ └── utils/ # 유틸리티 함수
│ └── data_utils.py # 데이터 처리 유틸
├── deployment/ # 배포 관련 파일
│ ├── container/ # 컨테이너 이미지 빌드
│ │ ├── Dockerfile # 서비스 이미지 빌드
│ │ └── Dockerfile-base # 베이스 이미지 빌드
│ └── manifests/ # Kubernetes 매니페스트
│ ├── configmap.yaml # 환경 설정
│ ├── secret.yaml # 민감 정보 (API 키)
│ ├── deployment.yaml # 애플리케이션 배포
│ ├── service.yaml # 서비스 노출
│ ├── ingress.yaml # 외부 접근
│ └── pvc.yaml # 영구 저장소 (Vector DB)
├── build-base.sh # 베이스 이미지 빌드 스크립트
├── build.sh # 서비스 이미지 빌드 스크립트
├── create-imagepullsecret.sh # ACR 인증 설정 스크립트
├── setup.sh # 로컬 환경 설정 스크립트
└── README.md # 프로젝트 문서
```
## 📋 사전 작업
### 카카오 API 설정
카카오 developers 포탈에서 애플리케이션 등록이 필요합니다.
1. **포탈 접속**: https://developers.kakao.com/console/app
2. **애플리케이션 등록**:
- 앱 이름: `VectorDBBuilder`
- 회사명: `{회사명}`
- 카테고리: `식음료`
3. **카카오맵 활성화**: 등록한 애플리케이션에서 좌측 '카카오맵' 메뉴 클릭하여 활성화
### Claude API 설정
Anthropic Claude API 키가 필요합니다.
1. **포탈 접속**: https://console.anthropic.com/
2. **API 키 발급**: Console에서 API Keys 메뉴에서 발급
3. **모델 확인**: `claude-sonnet-4-20250514` 사용
## 🚀 빠른 시작
### 1. 로컬 개발 환경 설정
```bash
# 저장소 클론 (review-api/vector 디렉토리로 이동)
cd review-api/vector
# 환경 설정 스크립트 실행 (시간이 오래 걸림)
chmod +x setup.sh
./setup.sh
# 가상환경 활성화
source venv/bin/activate
# Claude API 키 설정 (.env 파일 생성/수정)
cat > .env << EOF
CLAUDE_API_KEY=your-actual-claude-api-key
KAKAO_API_KEY=your-kakao-api-key
EOF
# 애플리케이션 실행
python app/main.py
```
### 2. 로컬 웹 브라우저 접속
```bash
# 애플리케이션이 정상 실행된 후 아래 URL로 접속
```
- **메인 페이지**: http://localhost:8000
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
- **헬스체크**: http://localhost:8000/health
- **Vector DB 상태**: http://localhost:8000/vector-status
- **환경 설정**: http://localhost:8000/config
### 3. API 테스트
```bash
# 1. Vector DB 구축 (지역과 가게명으로 동종업체 분석)
curl -X POST "http://localhost:8000/build-vector-db" \
-H "Content-Type: application/json" \
-d '{
"region": "서울특별시 강남구 역삼동",
"store_name": "맛있는 치킨집",
"force_rebuild": false
}'
# 2. 점주 액션 요청 (Claude AI 기반 조언)
curl -X POST "http://localhost:8000/action-request" \
-H "Content-Type: application/json" \
-d '{
"store_id": "12345",
"context": "매출이 감소하고 있어서 메뉴 개선이 필요합니다."
}'
# 3. Vector DB 상태 확인
curl "http://localhost:8000/vector-status"
# 4. 환경 설정 확인
curl "http://localhost:8000/config"
# 5. 유사 음식점 검색
curl -X POST "http://localhost:8000/search-similar" \
-H "Content-Type: application/json" \
-d '{
"query": "분위기 좋은 카페",
"business_type": "카페",
"limit": 5
}'
```
## 🐳 Docker 컨테이너 실행
### 베이스 이미지 빌드
```bash
# ACR에 베이스 이미지 빌드 및 푸시
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
# 로컬 베이스 이미지 빌드
./build-base.sh latest
```
### 서비스 이미지 빌드
```bash
# ACR에 서비스 이미지 빌드 및 푸시
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
# 로컬 서비스 이미지 빌드
./build.sh latest
```
### 로컬 Docker 실행
```bash
# 컨테이너 실행 (환경변수 설정 필요)
docker run -p 8000:8000 \
-e KAKAO_API_KEY="your-kakao-api-key" \
-e CLAUDE_API_KEY="your-claude-api-key" \
-e PORT=8000 \
-v $(pwd)/vector_db:/app/vectordb \
vector-api:latest
# 백그라운드 실행 (영구 볼륨 사용)
docker volume create vector_db_vol
docker run -d -p 8000:8000 \
--name vector-api \
-e KAKAO_API_KEY="your-kakao-api-key" \
-e CLAUDE_API_KEY="your-claude-api-key" \
-e PORT=8000 \
-v vector_db_vol:/app/vectordb \
vector-api:latest
# 컨테이너 로그 확인
docker logs vector-api -f
# Vector DB 데이터 확인
docker exec -it vector-api ls -la /app/vectordb/
```
## ☸️ Kubernetes 배포
### 1. ACR Image Pull Secret 생성
```bash
# Image Pull Secret 생성
./create-imagepullsecret.sh acrdigitalgarage03 rg-digitalgarage-03
```
### 2. Kubernetes 리소스 배포
```bash
# 영구 볼륨 생성 (Vector DB 저장용)
kubectl apply -f deployment/manifests/pvc.yaml
# ConfigMap 및 Secret 적용
kubectl apply -f deployment/manifests/configmap.yaml
kubectl apply -f deployment/manifests/secret.yaml
# 애플리케이션 배포
kubectl apply -f deployment/manifests/deployment.yaml
kubectl apply -f deployment/manifests/service.yaml
kubectl apply -f deployment/manifests/ingress.yaml
```
### 3. 배포 상태 확인
```bash
# Pod 상태 확인
kubectl get pods -l app=vector-api
# 서비스 상태 확인
kubectl get svc vector-api-service
# Ingress 상태 확인
kubectl get ingress vector-api-ingress
# PVC 상태 확인 (Vector DB 저장소)
kubectl get pvc vector-db-pvc
# 로그 확인
kubectl logs -l app=vector-api -f
# Vector DB 디렉토리 확인
kubectl exec -it deployment/vector-api -- ls -la /app/vectordb/
```
### 4. 🌐 외부 브라우저에서 접속하기
#### Ingress 주소 확인 방법
```bash
# 1. Ingress 설정된 호스트 확인
kubectl get ingress vector-api-ingress -o jsonpath='{.spec.rules[0].host}'
# 2. Ingress External IP 확인 (LoadBalancer 타입인 경우)
kubectl get ingress vector-api-ingress
# 3. Ingress Controller의 External IP 확인
kubectl get svc -n ingress-nginx ingress-nginx-controller
# 4. 현재 설정된 ingress 주소 확인
INGRESS_HOST=$(kubectl get ingress vector-api-ingress -o jsonpath='{.spec.rules[0].host}')
echo "🌐 Vector API URL: http://${INGRESS_HOST}"
```
#### 브라우저 접속 주소
현재 설정된 주소로 접속하세요:
```bash
# 현재 설정된 기본 주소 (환경에 따라 다를 수 있음)
INGRESS_URL="http://vector-api.20.249.191.180.nip.io"
echo "브라우저에서 접속: ${INGRESS_URL}"
```
**주요 접속 페이지:**
- **🏠 메인 페이지**: http://vector-api.20.249.191.180.nip.io
- **📖 Swagger UI**: http://vector-api.20.249.191.180.nip.io/docs
- **📄 ReDoc**: http://vector-api.20.249.191.180.nip.io/redoc
- **❤️ 헬스체크**: http://vector-api.20.249.191.180.nip.io/health
- **🗂️ Vector DB 상태**: http://vector-api.20.249.191.180.nip.io/vector-status
- **⚙️ 환경 설정**: http://vector-api.20.249.191.180.nip.io/config
#### 접속 테스트
```bash
# API 접속 테스트
curl "http://vector-api.20.249.191.180.nip.io/health"
# Vector DB 상태 확인
curl "http://vector-api.20.249.191.180.nip.io/vector-status"
# 설정 정보 확인
curl "http://vector-api.20.249.191.180.nip.io/config"
# Swagger UI 접속 확인
curl -I "http://vector-api.20.249.191.180.nip.io/docs"
```
## ⚙️ 환경 설정
### 필수 환경변수
| 변수명 | 설명 | 기본값 | 예시 |
|--------|------|--------|------|
| `KAKAO_API_KEY` | 카카오 API 키 | - | `your-kakao-api-key` |
| `CLAUDE_API_KEY` | Claude API 키 | - | `sk-ant-api03-...` |
| `CLAUDE_MODEL` | Claude 모델명 | `claude-sonnet-4-20250514` | `claude-sonnet-4-20250514` |
### 선택적 환경변수
| 변수명 | 설명 | 기본값 | 예시 |
|--------|------|--------|------|
| `HOST` | 서버 호스트 | `0.0.0.0` | `localhost` |
| `PORT` | 서버 포트 | `8000` | `8000` |
| `LOG_LEVEL` | 로그 레벨 | `info` | `debug` |
| `VECTOR_DB_PATH` | Vector DB 경로 | `/app/vectordb` | `/data/vectordb` |
| `VECTOR_DB_COLLECTION` | 컬렉션명 | `restaurant_reviews` | `stores` |
| `EMBEDDING_MODEL` | 임베딩 모델 | `sentence-transformers/all-MiniLM-L6-v2` | - |
| `MAX_RESTAURANTS_PER_CATEGORY` | 카테고리별 최대 음식점 수 | `50` | `100` |
| `MAX_REVIEWS_PER_RESTAURANT` | 음식점별 최대 리뷰 수 | `100` | `200` |
## 📊 API 엔드포인트
### 주요 엔드포인트
| Method | Endpoint | 설명 |
|--------|----------|------|
| `GET` | `/` | 메인 페이지 |
| `GET` | `/docs` | Swagger UI 문서 |
| `GET` | `/health` | 헬스체크 |
| `GET` | `/vector-status` | Vector DB 상태 확인 |
| `GET` | `/config` | 환경 설정 확인 |
| `POST` | `/build-vector-db` | Vector DB 구축 |
| `POST` | `/action-request` | AI 액션 요청 |
| `POST` | `/search-similar` | 유사 음식점 검색 |
| `GET` | `/collections` | 컬렉션 목록 조회 |
### Vector DB 구축 API 예시
```json
POST /build-vector-db
{
"region": "서울특별시 강남구 역삼동",
"store_name": "맛있는 치킨집",
"force_rebuild": false
}
```
**응답:**
```json
{
"success": true,
"message": "Vector DB 구축이 완료되었습니다.",
"statistics": {
"total_processed": 150,
"newly_added": 120,
"updated": 25,
"duplicates": 5,
"total_vectors": 1450
},
"execution_time": 245.8,
"store_info": {
"name": "맛있는 치킨집",
"region": "서울특별시 강남구 역삼동",
"business_type": "치킨",
"similar_stores_found": 15
}
}
```
### AI 액션 요청 API 예시
```json
POST /action-request
{
"store_id": "12345",
"context": "매출이 감소하고 있어서 메뉴 개선이 필요합니다."
}
```
**응답:**
```json
{
"success": true,
"recommendations": [
{
"category": "메뉴 개선",
"priority": "high",
"action": "시즌 한정 메뉴 도입",
"description": "고객 선호도가 높은 트렌디한 메뉴를 계절별로 출시하여 재방문율을 높입니다.",
"timeframe": "1-2주",
"expected_impact": "매출 15-20% 증가 예상"
}
],
"similar_cases": 3,
"analysis_date": "2024-06-12T10:30:00"
}
```
## 🔧 개발 및 확장
### 로컬 개발
```bash
# 개발 모드로 실행 (자동 재시작)
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# 의존성 추가
pip install 새패키지명
pip freeze > app/requirements.txt
# Vector DB 디렉토리 확인
ls -la ./vector_db/
# 코드 포맷팅
black app/
flake8 app/
```
### Ingress 호스트 변경
현재 환경에 맞게 Ingress 호스트를 변경하려면:
```bash
# 1. 현재 External IP 확인
kubectl get svc -n ingress-nginx ingress-nginx-controller
# 2. deployment/manifests/ingress.yaml 파일에서 host 수정
# 예: vector-api.{YOUR_EXTERNAL_IP}.nip.io
# 3. 변경사항 적용
kubectl apply -f deployment/manifests/ingress.yaml
# 4. 새로운 주소 확인
kubectl get ingress vector-api-ingress
```
## 🐛 문제 해결
### 일반적인 문제
**1. Claude API 키 관련 문제**
```bash
# API 키 유효성 확인
curl -X POST "https://api.anthropic.com/v1/messages" \
-H "x-api-key: your-claude-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4-20250514",
"max_tokens": 10,
"messages": [{"role": "user", "content": "Hi"}]
}'
# 환경변수 확인
echo $CLAUDE_API_KEY
```
**2. Vector DB 관련 문제**
```bash
# Vector DB 디렉토리 권한 확인
ls -la /app/vectordb/
# Vector DB 초기화
curl -X POST "http://localhost:8000/reset-vector-db"
# Collection 상태 확인
curl "http://localhost:8000/vector-status"
```
**3. Kubernetes 배포 실패**
```bash
# Pod 로그 확인 (Vector DB 초기화 에러)
kubectl logs -l app=vector-api
# PVC 상태 확인
kubectl get pvc vector-db-pvc
kubectl describe pvc vector-db-pvc
# ConfigMap 확인
kubectl get configmap vector-api-config -o yaml
# Secret 확인
kubectl get secret vector-api-secret -o yaml
```
**4. 메모리 부족 문제**
```bash
# Vector DB 및 ML 모델이 메모리를 많이 사용
kubectl top pods -l app=vector-api
# 리소스 제한 확인
kubectl describe pod -l app=vector-api
```
**5. 포트 관련 문제**
- 로컬 개발: 8000번 포트
- Docker 컨테이너: 8000번 포트
- Kubernetes: Service 80번 → Ingress 외부 접근
## 🎯 성능 최적화
### Vector DB 최적화 설정
```yaml
# deployment.yaml에서 Vector DB 최적화를 위한 리소스 설정
resources:
requests:
memory: "2Gi" # Vector DB와 ML 모델이 메모리를 많이 사용
cpu: "1000m" # 임베딩 계산을 위한 CPU
limits:
memory: "4Gi" # Vector 계산 및 Claude API 호출을 위한 충분한 메모리
cpu: "2000m" # 병렬 처리를 위한 CPU
```
### 영구 저장소 설정
```yaml
# pvc.yaml에서 Vector DB 저장소 설정
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi # Vector 데이터 저장을 위한 충분한 공간
```
### 타임아웃 설정
```yaml
# ingress.yaml에서 Vector DB 구축 시간을 고려한 타임아웃
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800" # 30분
nginx.ingress.kubernetes.io/proxy-send-timeout: "1800" # 30분
```
## 📈 Vector DB 워크플로우
### 1. Vector DB 구축 과정
```mermaid
graph TD
A[점주 요청] --> B[지역 및 업종 분석]
B --> C[카카오 API 음식점 수집]
C --> D[리뷰 데이터 수집]
D --> E[텍스트 전처리]
E --> F[Sentence Transformer 임베딩]
F --> G[Chroma DB 저장]
G --> H[중복 제거 및 메타데이터 구성]
H --> I[Vector DB 완성]
```
### 2. AI 액션 추천 과정
```mermaid
graph TD
A[점주 액션 요청] --> B[컨텍스트 분석]
B --> C[Vector 유사도 검색]
C --> D[관련 케이스 추출]
D --> E[Claude AI 프롬프트 구성]
E --> F[Claude API 호출]
F --> G[맞춤형 액션 플랜 생성]
G --> H[우선순위 및 실행 방안 제시]
```
## 📈 향후 확장 계획
- [ ] **다중 Vector DB 지원**: FAISS, Pinecone 추가 지원
- [ ] **실시간 업데이트**: 새로운 리뷰 자동 임베딩 및 추가
- [ ] **감정 분석 고도화**: 더 정교한 감정 및 의도 분류
- [ ] **업종별 특화**: 업종별 맞춤형 Vector 모델 개발
- [ ] **A/B 테스트**: 추천 효과 측정 및 개선
- [ ] **대시보드**: Vector DB 현황 및 성능 모니터링
- [ ] **배치 처리**: 대규모 데이터 처리 최적화
- [ ] **API 버전 관리**: 하위 호환성 보장
- [ ] **멀티모달 지원**: 이미지, 텍스트 통합 임베딩
- [ ] **비즈니스 인텔리전스**: 시장 트렌드 분석 기능
## 🧠 AI/ML 구성 요소
### Sentence Transformers
- **모델**: `all-MiniLM-L6-v2` (다국어 지원, 384차원)
- **용도**: 한국어 텍스트 임베딩
- **특징**: 빠른 속도, 합리적인 정확도
### Chroma Vector DB
- **저장 방식**: 영구 저장 (PVC)
- **인덱싱**: HNSW 알고리즘
- **메타데이터**: 필터링 및 검색 최적화
### Claude AI
- **모델**: `claude-sonnet-4-20250514`
- **용도**: 비즈니스 액션 추천
- **특징**: 높은 품질의 한국어 분석
---
## 📞 지원 및 문의
- **이슈 리포트**: GitHub Issues
- **기술 문의**: 개발팀 Slack
- **API 문의**: Anthropic Support
- **API 문서**: Swagger UI에서 상세 확인
---
**💡 팁: Vector DB 구축에는 시간이 오래 걸릴 수 있으니 충분한 타임아웃을 설정하고 진행 상황을 모니터링하세요.**

View File

@ -0,0 +1,80 @@
# app/config/settings.py
import os
from typing import Optional
class Settings:
"""환경 변수 기반 설정 클래스"""
# 애플리케이션 메타데이터
APP_TITLE = os.getenv("APP_TITLE", "음식점 Vector DB 구축 서비스")
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
APP_DESCRIPTION = os.getenv("APP_DESCRIPTION", "소상공인을 위한 AI 기반 경쟁업체 분석 및 액션 추천 시스템")
# 서버 설정
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))
LOG_LEVEL = os.getenv("LOG_LEVEL", "info")
# Restaurant API 설정
RESTAURANT_API_HOST = os.getenv("RESTAURANT_API_HOST", "4.217.217.207")
RESTAURANT_API_PORT = os.getenv("RESTAURANT_API_PORT", "18000")
@property
def RESTAURANT_API_URL(self) -> str:
return f"http://{self.RESTAURANT_API_HOST}:{self.RESTAURANT_API_PORT}"
# Review API 설정
REVIEW_API_HOST = os.getenv("REVIEW_API_HOST", "4.217.217.207")
REVIEW_API_PORT = os.getenv("REVIEW_API_PORT", "19000")
@property
def REVIEW_API_URL(self) -> str:
return f"http://{self.REVIEW_API_HOST}:{self.REVIEW_API_PORT}"
# Claude API 설정
CLAUDE_API_KEY = os.getenv("CLAUDE_API_KEY", "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")
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-20250514")
# Vector DB 설정
VECTOR_DB_PATH = os.getenv("VECTOR_DB_PATH", "/app/vectordb")
VECTOR_DB_COLLECTION = os.getenv("VECTOR_DB_COLLECTION", "restaurant_reviews")
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
# 데이터 수집 설정
MAX_RESTAURANTS_PER_CATEGORY = int(os.getenv("MAX_RESTAURANTS_PER_CATEGORY", "50"))
MAX_REVIEWS_PER_RESTAURANT = int(os.getenv("MAX_REVIEWS_PER_RESTAURANT", "100"))
REQUEST_DELAY = float(os.getenv("REQUEST_DELAY", "0.1"))
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "600"))
# 환경 감지
@property
def IS_K8S_ENV(self) -> bool:
"""Kubernetes 환경인지 확인"""
return (
os.getenv("KUBERNETES_SERVICE_HOST") is not None or
self.RESTAURANT_API_HOST in ["restaurant-api-service", "kakao-review-api-service"] or
self.REVIEW_API_HOST in ["restaurant-api-service", "kakao-review-api-service"]
)
def get_restaurant_api_url(self) -> str:
"""환경에 따른 Restaurant API URL 반환"""
if self.IS_K8S_ENV:
host = "restaurant-api-service"
port = "80"
else:
host = self.RESTAURANT_API_HOST
port = self.RESTAURANT_API_PORT
return f"http://{host}:{port}"
def get_review_api_url(self) -> str:
"""환경에 따른 Review API URL 반환"""
if self.IS_K8S_ENV:
host = "kakao-review-api-service"
port = "80"
else:
host = self.REVIEW_API_HOST
port = self.REVIEW_API_PORT
return f"http://{host}:{port}"
# 설정 인스턴스
settings = Settings()

917
vector/app/main.py Normal file
View File

@ -0,0 +1,917 @@
# vector/app/main.py
import os
import sys
import logging
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Optional
# 현재 디렉토리를 Python 경로에 추가
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# =============================================================================
# .env 파일 로딩 (다른 import보다 먼저)
# =============================================================================
from dotenv import load_dotenv
# .env 파일에서 환경변수 로드
load_dotenv()
import asyncio
import fastapi
from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
# 프로젝트 모듈 import
from app.config.settings import settings
from app.models.restaurant_models import RestaurantSearchRequest, ErrorResponse
from app.models.vector_models import (
VectorBuildRequest, VectorBuildResponse,
ActionRecommendationRequest, ActionRecommendationResponse,
VectorDBStatusResponse, VectorDBStatus
)
from app.services.restaurant_service import RestaurantService
from app.services.review_service import ReviewService
from app.services.vector_service import VectorService
from app.services.claude_service import ClaudeService
from app.utils.category_utils import extract_food_category
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger(__name__)
# 🔧 전역 변수 대신 애플리케이션 상태로 관리
app_state = {
"vector_service": None,
"restaurant_service": None,
"review_service": None,
"claude_service": None,
"initialization_errors": {},
"startup_completed": False
}
# 추가 모델 정의 (find-reviews API용)
class FindReviewsRequest(BaseModel):
"""리뷰 검색 요청 모델"""
region: str = Field(
...,
description="지역 (시군구 + 읍면동)",
example="서울특별시 강남구 역삼동"
)
store_name: str = Field(
...,
description="가게명",
example="맛있는 한식당"
)
class RestaurantInfo(BaseModel):
"""음식점 정보 모델"""
id: str = Field(description="카카오 장소 ID")
place_name: str = Field(description="장소명")
category_name: str = Field(description="카테고리명")
address_name: str = Field(description="전체 주소")
phone: str = Field(description="전화번호")
place_url: str = Field(description="장소 상세페이지 URL")
x: str = Field(description="X 좌표값 (경도)")
y: str = Field(description="Y 좌표값 (위도)")
class FindReviewsResponse(BaseModel):
"""리뷰 검색 응답 모델"""
success: bool = Field(description="검색 성공 여부")
message: str = Field(description="응답 메시지")
target_store: RestaurantInfo = Field(description="대상 가게 정보")
total_stores: int = Field(description="수집된 총 가게 수")
total_reviews: int = Field(description="수집된 총 리뷰 수")
food_category: str = Field(description="추출된 음식 카테고리")
region: str = Field(description="검색 지역")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""🔧 애플리케이션 생명주기 관리 - 안전한 서비스 초기화"""
# 🚀 Startup 이벤트
logger.info("🚀 Vector API 서비스 시작 중...")
startup_start_time = datetime.now()
# 각 서비스 안전하게 초기화
services_to_init = [
("restaurant_service", RestaurantService, "Restaurant API 서비스"),
("review_service", ReviewService, "Review API 서비스"),
("claude_service", ClaudeService, "Claude AI 서비스"),
("vector_service", VectorService, "Vector DB 서비스") # 마지막에 초기화
]
initialized_count = 0
for service_key, service_class, service_name in services_to_init:
try:
logger.info(f"🔧 {service_name} 초기화 중...")
app_state[service_key] = service_class()
logger.info(f"{service_name} 초기화 완료")
initialized_count += 1
except Exception as e:
logger.error(f"{service_name} 초기화 실패: {e}")
app_state["initialization_errors"][service_key] = str(e)
# 🔧 중요: 서비스 초기화 실패해도 앱은 시작 (헬스체크에서 확인)
continue
startup_time = (datetime.now() - startup_start_time).total_seconds()
app_state["startup_completed"] = True
logger.info(f"✅ Vector API 서비스 시작 완료!")
logger.info(f"📊 초기화 결과: {initialized_count}/{len(services_to_init)}개 서비스 성공")
logger.info(f"⏱️ 시작 소요시간: {startup_time:.2f}")
if app_state["initialization_errors"]:
logger.warning(f"⚠️ 초기화 실패 서비스: {list(app_state['initialization_errors'].keys())}")
yield
# 🛑 Shutdown 이벤트
logger.info("🛑 Vector API 서비스 종료 중...")
# 리소스 정리
for service_key in ["vector_service", "restaurant_service", "review_service", "claude_service"]:
if app_state[service_key] is not None:
try:
# 서비스별 정리 작업이 있다면 여기서 수행
logger.info(f"🔧 {service_key} 정리 중...")
except Exception as e:
logger.warning(f"⚠️ {service_key} 정리 실패: {e}")
finally:
app_state[service_key] = None
app_state["startup_completed"] = False
logger.info("✅ Vector API 서비스 종료 완료")
# 🔧 FastAPI 앱 초기화 (lifespan 이벤트 포함)
app = FastAPI(
title=settings.APP_TITLE,
description=f"""
{settings.APP_DESCRIPTION}
**주요 기능:**
- 지역과 가게명으로 대상 가게 찾기
- 동종 업체 리뷰 수집 분석
- Vector DB 구축 관리
- Claude AI 기반 액션 추천
- 영속적 Vector DB 저장
**API 연동:**
- Restaurant API: {settings.get_restaurant_api_url() if hasattr(settings, 'get_restaurant_api_url') else 'N/A'}
- Review API: {settings.get_review_api_url() if hasattr(settings, 'get_review_api_url') else 'N/A'}
- Claude AI API: {settings.CLAUDE_MODEL}
**Vector DB:**
- 경로: {settings.VECTOR_DB_PATH}
- 컬렉션: {settings.VECTOR_DB_COLLECTION}
- 임베딩 모델: {settings.EMBEDDING_MODEL}
**버전:** {settings.APP_VERSION}
""",
version=settings.APP_VERSION,
contact={
"name": "개발팀",
"email": "admin@example.com"
},
lifespan=lifespan # 🔧 lifespan 이벤트 등록
)
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 🔧 Dependency Injection - 서비스 제공자들
def get_vector_service() -> VectorService:
"""VectorService 의존성 주입"""
if app_state["vector_service"] is None:
error_msg = app_state["initialization_errors"].get("vector_service")
if error_msg:
raise HTTPException(
status_code=503,
detail=f"Vector service not available: {error_msg}"
)
# 런타임에 재시도
try:
logger.info("🔧 VectorService 런타임 초기화 시도...")
app_state["vector_service"] = VectorService()
logger.info("✅ VectorService 런타임 초기화 성공")
except Exception as e:
logger.error(f"❌ VectorService 런타임 초기화 실패: {e}")
raise HTTPException(
status_code=503,
detail=f"Vector service initialization failed: {str(e)}"
)
return app_state["vector_service"]
def get_restaurant_service() -> RestaurantService:
"""RestaurantService 의존성 주입"""
if app_state["restaurant_service"] is None:
error_msg = app_state["initialization_errors"].get("restaurant_service")
if error_msg:
raise HTTPException(
status_code=503,
detail=f"Restaurant service not available: {error_msg}"
)
try:
app_state["restaurant_service"] = RestaurantService()
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
return app_state["restaurant_service"]
def get_review_service() -> ReviewService:
"""ReviewService 의존성 주입"""
if app_state["review_service"] is None:
error_msg = app_state["initialization_errors"].get("review_service")
if error_msg:
raise HTTPException(
status_code=503,
detail=f"Review service not available: {error_msg}"
)
try:
app_state["review_service"] = ReviewService()
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
return app_state["review_service"]
def get_claude_service() -> ClaudeService:
"""ClaudeService 의존성 주입"""
if app_state["claude_service"] is None:
error_msg = app_state["initialization_errors"].get("claude_service")
if error_msg:
raise HTTPException(
status_code=503,
detail=f"Claude service not available: {error_msg}"
)
try:
app_state["claude_service"] = ClaudeService()
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
return app_state["claude_service"]
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def root():
"""메인 페이지"""
# 🔧 안전한 DB 상태 조회
try:
vector_service = app_state.get("vector_service")
if vector_service:
db_status = vector_service.get_db_status()
else:
db_status = {
'collection_name': settings.VECTOR_DB_COLLECTION,
'total_documents': 0,
'total_stores': 0,
'db_path': settings.VECTOR_DB_PATH,
'status': 'not_initialized'
}
except Exception as e:
logger.warning(f"DB 상태 조회 실패: {e}")
db_status = {'status': 'error', 'error': str(e)}
return f"""
<html>
<head>
<title>{settings.APP_TITLE}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }}
h1, h2 {{ color: #2c3e50; }}
.status {{ background: #e8f5e8; padding: 15px; border-radius: 5px; margin: 20px 0; }}
.error {{ background: #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0; }}
.info {{ background: #74b9ff; color: white; padding: 15px; border-radius: 5px; margin: 20px 0; }}
.link {{ display: inline-block; margin: 10px 15px 10px 0; padding: 10px 20px; background: #0984e3; color: white; text-decoration: none; border-radius: 3px; }}
.link:hover {{ background: #74b9ff; }}
pre {{ background: #f1f2f6; padding: 15px; border-radius: 5px; overflow-x: auto; }}
</style>
</head>
<body>
<h1>🍽 {settings.APP_TITLE}</h1>
<p>{settings.APP_DESCRIPTION}</p>
<div class="status">
<h2>📊 Vector DB 상태</h2>
<ul>
<li><strong>컬렉션:</strong> {db_status.get('collection_name', 'N/A')}</li>
<li><strong> 문서 :</strong> {db_status.get('total_documents', 0)}</li>
<li><strong>가게 :</strong> {db_status.get('total_stores', 0)}</li>
<li><strong>DB 경로:</strong> {db_status.get('db_path', 'N/A')}</li>
<li><strong>상태:</strong> {db_status.get('status', 'Unknown')}</li>
</ul>
{f'''
<div class="error">
<h3> 초기화 실패 서비스</h3>
<ul>
{"".join([f"<li><strong>{k}:</strong> {v}</li>" for k, v in app_state["initialization_errors"].items()])}
</ul>
</div>
''' if app_state["initialization_errors"] else ''}
</div>
<div class="info">
<h2>🔧 시스템 구성</h2>
<ul>
<li><strong>Claude Model:</strong> {settings.CLAUDE_MODEL}</li>
<li><strong>Embedding Model:</strong> {settings.EMBEDDING_MODEL}</li>
<li><strong>Vector DB Path:</strong> {settings.VECTOR_DB_PATH}</li>
<li><strong>환경:</strong> {'Kubernetes' if hasattr(settings, 'IS_K8S_ENV') and settings.IS_K8S_ENV else 'Local'}</li>
</ul>
</div>
<h2>📚 API 문서</h2>
<a href="/docs" class="link">Swagger UI 문서</a>
<a href="/redoc" class="link">ReDoc 문서</a>
<a href="/health" class="link">헬스 체크</a>
<a href="/vector-status" class="link">Vector DB 상태</a>
<h2>🛠 사용 방법</h2>
<p><strong>POST /find-reviews</strong> - 리뷰 검색 Vector DB 저장 (본인 가게 우선)</p>
<pre>
{{
"region": "서울특별시 강남구 역삼동",
"store_name": "맛있는 한식당"
}}
</pre>
<p><strong>POST /build-vector</strong> - Vector DB 구축</p>
<pre>
{{
"region": "서울특별시 강남구 역삼동",
"store_name": "맛있는 한식당",
"force_rebuild": false
}}
</pre>
<p><strong>POST /action-recommendation</strong> - 액션 추천 요청</p>
<pre>
{{
"store_id": "12345",
"context": "매출이 감소하고 있어서 개선이 필요합니다"
}}
</pre>
</body>
</html>
"""
@app.post("/find-reviews", response_model=FindReviewsResponse)
async def find_reviews(
request: FindReviewsRequest,
vector_service: VectorService = Depends(get_vector_service),
restaurant_service: RestaurantService = Depends(get_restaurant_service),
review_service: ReviewService = Depends(get_review_service)
):
"""
지역과 가게명으로 리뷰를 찾아 Vector DB에 저장합니다.
🔥 본인 가게 리뷰는 반드시 포함됩니다. (수정된 버전)
"""
start_time = datetime.now()
logger.info(f"🔍 리뷰 검색 요청: {request.region} - {request.store_name}")
try:
# 1단계: 본인 가게 검색
logger.info("1단계: 본인 가게 검색 중... (최우선)")
target_restaurant = await restaurant_service.find_store_by_name_and_region(
request.region, request.store_name
)
if not target_restaurant:
logger.error(f"❌ 본인 가게를 찾을 수 없음: {request.store_name}")
raise HTTPException(
status_code=404,
detail=f"'{request.store_name}' 가게를 찾을 수 없습니다. 가게명과 지역을 정확히 입력해주세요."
)
logger.info(f"✅ 본인 가게 발견: {target_restaurant.place_name} (ID: {target_restaurant.id})")
# 2단계: 동종 업체 검색
logger.info("2단계: 동종 업체 검색 중...")
similar_restaurants = []
food_category = "기타" # 기본값
try:
food_category = extract_food_category(target_restaurant.category_name)
logger.info(f"추출된 음식 카테고리: {food_category}")
similar_restaurants = await restaurant_service.find_similar_stores(
request.region, food_category, settings.MAX_RESTAURANTS_PER_CATEGORY
)
logger.info(f"✅ 동종 업체 {len(similar_restaurants)}개 발견")
except Exception as e:
logger.warning(f"⚠️ 동종 업체 검색 실패 (본인 가게는 계속 진행): {e}")
# 3단계: 전체 가게 목록 구성 (본인 가게 우선 + 중복 제거)
logger.info("3단계: 전체 가게 목록 구성 중...")
# 본인 가게를 첫 번째로 배치
all_restaurants = [target_restaurant]
# 동종 업체 추가 (개선된 중복 제거)
for restaurant in similar_restaurants:
if not _is_duplicate_restaurant(target_restaurant, restaurant):
all_restaurants.append(restaurant)
logger.info(f"✅ 전체 가게 목록 구성 완료: {len(all_restaurants)}개 (본인 가게 포함)")
# 4단계: 전체 리뷰 수집 (본인 가게 우선 처리)
logger.info("4단계: 리뷰 수집 중... (본인 가게 우선)")
# 본인 가게 우선 처리를 위한 특별 로직
review_results = []
# 4-1: 본인 가게 리뷰 수집 (실패 시 전체 중단)
try:
logger.info("본인 가게 리뷰 우선 수집 중... (필수)")
target_store_info, target_reviews = await review_service.collect_store_reviews(
target_restaurant.id,
max_reviews=settings.MAX_REVIEWS_PER_RESTAURANT * 2 # 본인 가게는 더 많이
)
if not target_store_info or not target_reviews:
logger.error(f"❌ 본인 가게 리뷰 수집 실패: {target_restaurant.place_name}")
raise HTTPException(
status_code=422,
detail=f"본인 가게 '{target_restaurant.place_name}'의 리뷰를 수집할 수 없습니다."
)
# 본인 가게 결과를 첫 번째로 설정
review_results.append((target_restaurant.id, target_store_info, target_reviews))
logger.info(f"✅ 본인 가게 리뷰 수집 성공: {len(target_reviews)}")
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ 본인 가게 리뷰 수집 중 오류: {e}")
raise HTTPException(
status_code=500,
detail=f"본인 가게 리뷰 수집 중 오류가 발생했습니다: {str(e)}"
)
# 4-2: 동종 업체 리뷰 수집 (실패해도 본인 가게는 유지)
if len(all_restaurants) > 1: # 본인 가게 외에 다른 가게가 있는 경우
try:
logger.info(f"동종 업체 리뷰 수집 중... ({len(all_restaurants) - 1}개)")
# 본인 가게 제외한 동종 업체만 수집
similar_restaurants_only = all_restaurants[1:]
similar_results = await review_service.collect_multiple_stores_reviews(similar_restaurants_only)
# 동종 업체 결과 추가
review_results.extend(similar_results)
similar_reviews_count = sum(len(reviews) for _, _, reviews in similar_results)
logger.info(f"✅ 동종 업체 리뷰 수집 완료: {len(similar_results)}개 가게, {similar_reviews_count}개 리뷰")
except Exception as e:
logger.warning(f"⚠️ 동종 업체 리뷰 수집 실패 (본인 가게는 유지): {e}")
else:
logger.info("동종 업체가 없어 본인 가게 리뷰만 사용")
# 5단계: Vector DB 구축
logger.info("5단계: Vector DB 구축 중...")
try:
# 대상 가게 정보를 딕셔너리로 변환
target_store_info_dict = {
'id': target_restaurant.id,
'place_name': target_restaurant.place_name,
'category_name': target_restaurant.category_name,
'address_name': target_restaurant.address_name,
'phone': target_restaurant.phone,
'place_url': target_restaurant.place_url,
'x': target_restaurant.x,
'y': target_restaurant.y
}
# Vector DB에 저장
vector_result = await vector_service.build_vector_store(
target_store_info_dict, review_results, food_category, request.region
)
if not vector_result.get('success', False):
raise Exception(f"Vector DB 저장 실패: {vector_result.get('error', 'Unknown error')}")
logger.info("✅ Vector DB 구축 완료")
except Exception as e:
logger.error(f"❌ Vector DB 구축 실패: {e}")
raise HTTPException(
status_code=500,
detail=f"Vector DB 구축 중 오류가 발생했습니다: {str(e)}"
)
# 최종 검증: 본인 가게가 첫 번째에 있는지 확인
if not review_results or review_results[0][0] != target_restaurant.id:
logger.error("❌ 본인 가게가 첫 번째에 없음")
raise HTTPException(
status_code=500,
detail="본인 가게 리뷰 처리 순서 오류가 발생했습니다."
)
# 성공 응답
total_reviews = sum(len(reviews) for _, _, reviews in review_results)
execution_time = (datetime.now() - start_time).total_seconds()
return FindReviewsResponse(
success=True,
message=f"✅ 본인 가게 리뷰 포함 보장 완료! (총 {len(review_results)}개 가게, {total_reviews}개 리뷰)",
target_store=RestaurantInfo(
id=target_restaurant.id,
place_name=target_restaurant.place_name,
category_name=target_restaurant.category_name,
address_name=target_restaurant.address_name,
phone=target_restaurant.phone,
place_url=target_restaurant.place_url,
x=target_restaurant.x,
y=target_restaurant.y
),
total_stores=len(review_results),
total_reviews=total_reviews,
food_category=food_category,
region=request.region
)
except HTTPException:
raise
except Exception as e:
execution_time = (datetime.now() - start_time).total_seconds()
logger.error(f"❌ 전체 프로세스 실패: {e}")
raise HTTPException(
status_code=500,
detail=f"서비스 처리 중 예상치 못한 오류가 발생했습니다: {str(e)}"
)
def _is_duplicate_restaurant(restaurant1: RestaurantInfo, restaurant2: RestaurantInfo) -> bool:
"""
음식점이 중복인지 확인 (개선된 로직)
Args:
restaurant1: 번째 음식점
restaurant2: 번째 음식점
Returns:
중복 여부
"""
# 1. ID 기준 확인
if restaurant1.id == restaurant2.id:
return True
# 2. place_url에서 추출한 store_id 기준 확인
store_id1 = _extract_store_id_from_place_url(restaurant1.place_url)
store_id2 = _extract_store_id_from_place_url(restaurant2.place_url)
if store_id1 and store_id2 and store_id1 == store_id2:
return True
# 3. restaurant.id와 place_url store_id 교차 확인
if restaurant1.id == store_id2 or restaurant2.id == store_id1:
return True
# 4. 이름 + 주소 기준 확인 (최후 방법)
if (restaurant1.place_name == restaurant2.place_name and
restaurant1.address_name == restaurant2.address_name):
return True
return False
def _extract_store_id_from_place_url(place_url: str) -> Optional[str]:
"""
카카오맵 URL에서 store_id를 추출합니다.
Args:
place_url: 카카오맵 장소 URL
Returns:
추출된 store_id 또는 None
"""
try:
if not place_url:
return None
import re
# URL 패턴: https://place.map.kakao.com/123456789
pattern = r'/(\d+)(?:\?|$|#)'
match = re.search(pattern, place_url)
if match:
return match.group(1)
else:
return None
except Exception:
return None
@app.post(
"/action-recommendation",
response_model=ActionRecommendationResponse,
summary="액션 추천 요청",
description="점주가 Claude AI에게 액션 추천을 요청합니다."
)
async def action_recommendation(
request: ActionRecommendationRequest,
claude_service: ClaudeService = Depends(get_claude_service),
vector_service: VectorService = Depends(get_vector_service)
):
"""🧠 Claude AI 액션 추천 API"""
try:
logger.info(f"액션 추천 요청: store_id={request.store_id}")
start_time = datetime.now()
# 1단계: Vector DB에서 컨텍스트 조회
try:
db_status = vector_service.get_db_status()
if db_status.get('total_documents', 0) == 0:
raise HTTPException(
status_code=404,
detail={
"success": False,
"error": "NO_VECTOR_DATA",
"message": "Vector DB에 데이터가 없습니다. 먼저 /build-vector API를 호출하여 데이터를 구축해주세요.",
"timestamp": datetime.now().isoformat()
}
)
# Vector DB에서 유사한 케이스 검색
context_data = vector_service.search_similar_cases(request.store_id, request.context)
except HTTPException:
raise
except Exception as e:
logger.error(f"Vector DB 조회 실패: {e}")
# Vector DB 조회 실패해도 일반적인 추천은 제공
context_data = None
# 2단계: Claude AI 호출 (프롬프트 구성부터 파싱까지 모두 포함)
try:
# 컨텍스트 구성
full_context = f"가게 ID: {request.store_id}\n점주 요청: {request.context}"
additional_context = context_data if context_data else None
# Claude AI 액션 추천 생성 (완전한 처리)
claude_response, parsed_response = await claude_service.generate_action_recommendations(
context=full_context,
additional_context=additional_context
)
if not claude_response:
raise Exception("Claude AI로부터 응답을 받지 못했습니다")
logger.info(f"Claude 응답 길이: {len(claude_response)} 문자")
json_parse_success = parsed_response is not None
except Exception as e:
logger.error(f"Claude AI 호출 실패: {e}")
raise HTTPException(
status_code=500,
detail=f"AI 추천 생성 중 오류: {str(e)}"
)
# 3단계: 응답 구성
claude_execution_time = (datetime.now() - start_time).total_seconds()
# 가게 정보 추출 (Vector DB에서)
store_name = request.store_id # 기본값
food_category = "기타" # 기본값
try:
store_context = vector_service.get_store_context(request.store_id)
if store_context:
store_name = store_context.get('store_name', request.store_id)
food_category = store_context.get('food_category', '기타')
except Exception as e:
logger.warning(f"가게 정보 추출 실패: {e}")
response = ActionRecommendationResponse(
success=True,
message=f"액션 추천이 완료되었습니다. (실행시간: {claude_execution_time:.1f}초, JSON 파싱: {'성공' if json_parse_success else '실패'})",
claude_input=full_context + (f"\n--- 동종 업체 분석 데이터 ---\n{additional_context}" if additional_context else ""),
claude_response=claude_response,
parsed_response=parsed_response,
store_name=store_name,
food_category=food_category,
similar_stores_count=len(context_data.split("---")) if context_data else 0,
execution_time=claude_execution_time,
json_parse_success=json_parse_success
)
logger.info(f"✅ 액션 추천 완료: Claude 응답 {len(claude_response) if claude_response else 0} 문자, JSON 파싱 {'성공' if json_parse_success else '실패'}, {claude_execution_time:.1f}초 소요")
return response
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ 액션 추천 요청 실패: {str(e)}")
raise HTTPException(
status_code=500,
detail={
"success": False,
"error": "RECOMMENDATION_FAILED",
"message": f"액션 추천 중 오류가 발생했습니다: {str(e)}",
"timestamp": datetime.now().isoformat()
}
)
@app.get(
"/vector-status",
response_model=VectorDBStatusResponse,
summary="Vector DB 상태 조회",
description="Vector DB의 현재 상태를 조회합니다."
)
async def get_vector_db_status(vector_service: VectorService = Depends(get_vector_service)):
"""Vector DB 상태 조회 API"""
try:
status_info = vector_service.get_db_status()
status = VectorDBStatus(
collection_name=status_info['collection_name'],
total_documents=status_info['total_documents'],
total_stores=status_info['total_stores'],
db_path=status_info['db_path'],
last_updated=datetime.now().isoformat()
)
return VectorDBStatusResponse(
success=True,
status=status,
message="Vector DB 상태 조회가 완료되었습니다."
)
except Exception as e:
logger.error(f"Vector DB 상태 조회 실패: {e}")
raise HTTPException(
status_code=500,
detail={
"success": False,
"error": "STATUS_CHECK_FAILED",
"message": f"Vector DB 상태 조회 중 오류가 발생했습니다: {str(e)}",
"timestamp": datetime.now().isoformat()
}
)
@app.get("/health", summary="헬스 체크", description="API 서버 및 외부 서비스 상태를 확인합니다.")
async def health_check():
"""🏥 헬스체크 API"""
health_result = {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"services": {},
"app_info": {
"name": settings.APP_TITLE,
"version": settings.APP_VERSION,
"startup_completed": app_state["startup_completed"]
}
}
# 서비스별 헬스체크
services_to_check = [
("restaurant_service", "restaurant_api"),
("review_service", "review_api"),
("claude_service", "claude_ai"),
("vector_service", "vector_db")
]
healthy_count = 0
total_checks = len(services_to_check)
for service_key, health_key in services_to_check:
try:
service = app_state.get(service_key)
if service is None:
health_result["services"][health_key] = "not_initialized"
continue
# 서비스별 헬스체크 메서드 호출
if hasattr(service, 'health_check'):
status = await service.health_check()
else:
status = True # 헬스체크 메서드가 없으면 초기화됐다고 가정
# Vector DB의 경우 상세 정보 추가
if health_key == "vector_db" and status:
try:
db_status = service.get_db_status()
health_result["vector_db_info"] = {
"total_documents": db_status.get('total_documents', 0),
"total_stores": db_status.get('total_stores', 0),
"db_path": db_status.get('db_path', '')
}
except:
pass
health_result["services"][health_key] = "healthy" if status else "unhealthy"
if status:
healthy_count += 1
except Exception as e:
logger.warning(f"헬스체크 실패 - {service_key}: {e}")
health_result["services"][health_key] = f"error: {str(e)}"
# 전체 상태 결정
if healthy_count == total_checks:
health_result["status"] = "healthy"
elif healthy_count > 0:
health_result["status"] = "degraded"
else:
health_result["status"] = "unhealthy"
# 요약 정보
health_result["summary"] = {
"healthy_services": healthy_count,
"total_services": total_checks,
"health_percentage": round((healthy_count / total_checks) * 100, 1)
}
# 초기화 에러가 있으면 포함
if app_state["initialization_errors"]:
health_result["initialization_errors"] = app_state["initialization_errors"]
# 환경 정보
health_result["environment"] = {
"python_version": sys.version.split()[0],
"fastapi_version": fastapi.__version__,
"is_k8s": hasattr(settings, 'IS_K8S_ENV') and settings.IS_K8S_ENV,
"claude_model": settings.CLAUDE_MODEL
}
# HTTP 상태 코드 결정
if health_result["status"] == "healthy":
return health_result
elif health_result["status"] == "degraded":
return JSONResponse(status_code=200, content=health_result) # 부분 장애는 200
else:
return JSONResponse(status_code=503, content=health_result) # 전체 장애는 503
# 🔧 전역 예외 처리
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
"""전역 예외 처리"""
logger.error(f"Unhandled exception: {exc}")
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"detail": str(exc) if settings.LOG_LEVEL.lower() == "debug" else "An unexpected error occurred",
"timestamp": datetime.now().isoformat(),
"path": str(request.url)
}
)
if __name__ == "__main__":
import uvicorn
print("🍽️ " + "="*60)
print(f" {settings.APP_TITLE} 서버 시작")
print("="*64)
print(f"📊 구성 정보:")
print(f" - Python 버전: {sys.version.split()[0]}")
print(f" - FastAPI 버전: {fastapi.__version__}")
print(f" - Vector DB Path: {settings.VECTOR_DB_PATH}")
print(f" - Claude Model: {settings.CLAUDE_MODEL}")
print(f" - 환경: {'Kubernetes' if hasattr(settings, 'IS_K8S_ENV') and settings.IS_K8S_ENV else 'Local'}")
print()
print(f"📚 문서:")
print(f" - Swagger UI: http://{settings.HOST}:{settings.PORT}/docs")
print(f" - ReDoc: http://{settings.HOST}:{settings.PORT}/redoc")
print(f" - 메인 페이지: http://{settings.HOST}:{settings.PORT}/")
print()
try:
uvicorn.run(
"app.main:app", # 🔧 문자열로 지정 (리로드 지원)
host=settings.HOST,
port=settings.PORT,
log_level=settings.LOG_LEVEL.lower(),
reload=False, # 프로덕션에서는 False
access_log=True,
loop="uvloop" if sys.platform != "win32" else "asyncio"
)
except KeyboardInterrupt:
print("\n🛑 서버가 사용자에 의해 중단되었습니다.")
except Exception as e:
print(f"\n❌ 서버 시작 실패: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@ -0,0 +1,67 @@
# app/requirements.txt - 안정화된 Vector DB 서비스용
# ==========================================
# 기본 웹 프레임워크 (안정 버전)
# ==========================================
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
python-dotenv==1.0.0
python-multipart==0.0.6
# ==========================================
# HTTP 클라이언트
# ==========================================
aiohttp==3.9.1
requests==2.31.0
# ==========================================
# 데이터 처리 (안정 버전)
# ==========================================
numpy==1.24.3
pandas==2.1.4
# ==========================================
# AI/ML 라이브러리 (호환성 검증된 버전)
# ==========================================
# PyTorch CPU 버전 (안정화)
torch==2.1.0+cpu --index-url https://download.pytorch.org/whl/cpu
torchvision==0.16.0+cpu --index-url https://download.pytorch.org/whl/cpu
torchaudio==2.1.0+cpu --index-url https://download.pytorch.org/whl/cpu
# Transformer 라이브러리들
tokenizers==0.15.2
transformers==4.35.2
huggingface-hub==0.19.4
# Sentence Transformers (안정 버전)
sentence-transformers==2.2.2
# ==========================================
# Vector DB - ChromaDB (안정 버전)
# ==========================================
# ChromaDB 0.4.24 사용 (호환성 검증됨)
chromadb==0.4.24
# ChromaDB 의존성 (호환 버전 고정)
hnswlib==0.7.0
duckdb==0.9.2
# ==========================================
# Claude API (최신 안정 버전)
# ==========================================
anthropic>=0.40.0,<1.0.0
# ==========================================
# 기타 필수 라이브러리
# ==========================================
typing-extensions==4.8.0
sqlalchemy==2.0.23
# ==========================================
# 개발/디버깅 도구 (선택사항)
# ==========================================
# pytest==7.4.3
# black==23.11.0
# isort==5.12.0

View File

@ -0,0 +1,336 @@
# app/services/claude_service.py
import json
import logging
from typing import Optional, Dict, Any, Tuple
import anthropic
from ..config.settings import settings
logger = logging.getLogger(__name__)
class ClaudeService:
"""Claude API 연동 서비스"""
def __init__(self):
try:
# API 키 유효성 검사
if not settings.CLAUDE_API_KEY or settings.CLAUDE_API_KEY.strip() == "":
raise ValueError("CLAUDE_API_KEY가 설정되지 않았습니다")
# Claude 클라이언트 초기화
self.client = anthropic.Anthropic(api_key=settings.CLAUDE_API_KEY)
self.model = settings.CLAUDE_MODEL
self.initialization_error = None
logger.info(f"✅ ClaudeService 초기화 완료 (모델: {self.model})")
except Exception as e:
self.initialization_error = str(e)
self.client = None
logger.error(f"❌ ClaudeService 초기화 실패: {e}")
def is_ready(self) -> bool:
"""서비스 준비 상태 확인"""
return self.client is not None and self.initialization_error is None
def get_initialization_error(self) -> Optional[str]:
"""초기화 에러 메시지 반환"""
return self.initialization_error
async def test_api_connection(self) -> Tuple[bool, Optional[str]]:
"""Claude API 연결을 테스트합니다."""
if not self.is_ready():
return False, self.initialization_error
try:
logger.info("🔍 Claude API 연결 테스트 시작...")
response = self.client.messages.create(
model=self.model,
max_tokens=50,
messages=[
{
"role": "user",
"content": "안녕하세요. 연결 테스트입니다. '연결 성공'이라고 답변해주세요."
}
]
)
if response.content and len(response.content) > 0:
logger.info("✅ Claude API 연결 테스트 성공")
return True, None
else:
error_msg = "Claude API 응답이 비어있음"
logger.warning(f"⚠️ {error_msg}")
return False, error_msg
except Exception as e:
error_msg = f"Claude API 연결 테스트 실패: {str(e)}"
logger.error(f"{error_msg}")
return False, error_msg
async def generate_action_recommendations(self, context: str, additional_context: Optional[str] = None) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
"""점주를 위한 액션 추천을 생성합니다."""
if not self.is_ready():
logger.error("ClaudeService가 준비되지 않음")
return None, None
try:
logger.info("🤖 Claude API를 통한 액션 추천 생성 시작")
prompt = self._build_action_prompt(context, additional_context)
response = self.client.messages.create(
model=self.model,
max_tokens=4000,
temperature=0.7,
messages=[
{
"role": "user",
"content": prompt
}
]
)
if response.content and len(response.content) > 0:
raw_response = response.content[0].text
logger.info(f"✅ 액션 추천 생성 완료: {len(raw_response)} 문자")
parsed_response = self._parse_json_response(raw_response)
return raw_response, parsed_response
else:
logger.warning("⚠️ Claude API 응답이 비어있음")
return None, None
except Exception as e:
logger.error(f"❌ 액션 추천 생성 중 오류: {e}")
return None, None
def _parse_json_response(self, raw_response: str) -> Optional[Dict[str, Any]]:
"""Claude의 원본 응답에서 JSON을 추출하고 파싱합니다."""
try:
import re
# JSON 블록 찾기
json_match = re.search(r'```json\s*\n(.*?)\n```', raw_response, re.DOTALL)
if json_match:
json_str = json_match.group(1).strip()
else:
brace_start = raw_response.find('{')
brace_end = raw_response.rfind('}')
if brace_start != -1 and brace_end != -1 and brace_end > brace_start:
json_str = raw_response[brace_start:brace_end + 1]
else:
logger.warning("⚠️ JSON 패턴을 찾을 수 없음")
return None
parsed_json = json.loads(json_str)
logger.info("✅ JSON 파싱 성공")
return parsed_json
except json.JSONDecodeError as e:
logger.warning(f"⚠️ JSON 파싱 실패: {e}")
return None
except Exception as e:
logger.warning(f"⚠️ JSON 추출 실패: {e}")
return None
def _build_action_prompt(self, context: str, additional_context: Optional[str] = None) -> str:
"""액션 추천을 위한 프롬프트를 구성합니다."""
base_prompt = f"""당신은 소상공인을 위한 경영 컨설턴트입니다. 아래 정보를 바탕으로 실질적이고 구체적인 액션 추천을 해주세요.
**분석 데이터:**
{context}
**추천 요구사항:**
1. 단기 계획 (1-3개월): 즉시 실행 가능한 개선사항
2. 중기 계획 (3-6개월): 점진적 개선 방안
3. 장기 계획 (6개월-1): 전략적 발전 방향
**응답 형식:**
반드시 아래 JSON 형식으로만 응답해주세요:
```json
{{
"summary": {{
"current_situation": "현재 상황 요약",
"key_insights": ["핵심 인사이트 1", "핵심 인사이트 2", "핵심 인사이트 3"],
"priority_areas": ["우선 개선 영역 1", "우선 개선 영역 2"]
}},
"action_plans": {{
"short_term": [
{{
"title": "액션 제목",
"description": "구체적인 실행 방법",
"expected_impact": "예상 효과",
"timeline": "실행 기간",
"cost": "예상 비용"
}}
],
"mid_term": [
{{
"title": "액션 제목",
"description": "구체적인 실행 방법",
"expected_impact": "예상 효과",
"timeline": "실행 기간",
"cost": "예상 비용"
}}
],
"long_term": [
{{
"title": "액션 제목",
"description": "구체적인 실행 방법",
"expected_impact": "예상 효과",
"timeline": "실행 기간",
"cost": "예상 비용"
}}
]
}},
"implementation_tips": [
"실행 팁 1",
"실행 팁 2",
"실행 팁 3"
]
}}
```
**응답은 반드시 유효한 JSON 형식으로만 작성하고, JSON 앞뒤에 다른 텍스트는 포함하지 마세요.**"""
if additional_context:
base_prompt += f"\n\n**점주 추가 요청사항:**\n{additional_context}\n"
return base_prompt
# =============================================================================
# 호환성을 위한 메서드들 (기존 코드가 사용할 수 있도록)
# =============================================================================
async def get_recommendation(self, prompt: str) -> Optional[str]:
"""Claude API를 호출하여 추천을 받습니다. (호환성용)"""
if not self.is_ready():
logger.error("ClaudeService가 준비되지 않음")
return None
try:
logger.info("🤖 Claude API 호출 시작")
response = self.client.messages.create(
model=self.model,
max_tokens=4000,
temperature=0.7,
messages=[
{
"role": "user",
"content": prompt
}
]
)
if response.content and len(response.content) > 0:
raw_response = response.content[0].text
logger.info(f"✅ Claude API 응답 성공: {len(raw_response)} 문자")
return raw_response
else:
logger.warning("⚠️ Claude API 응답이 비어있음")
return None
except Exception as e:
logger.error(f"❌ Claude API 호출 실패: {e}")
return None
def parse_recommendation_response(self, raw_response: str) -> Optional[Dict[str, Any]]:
"""Claude 응답에서 JSON을 추출하고 파싱합니다. (호환성용)"""
return self._parse_json_response(raw_response)
def build_recommendation_prompt(self, store_id: str, context: str, vector_context: Optional[str] = None) -> str:
"""액션 추천용 프롬프트를 구성합니다. (호환성용)"""
prompt_parts = [
"당신은 소상공인을 위한 경영 컨설턴트입니다.",
f"가게 ID: {store_id}",
f"점주 요청: {context}"
]
if vector_context:
prompt_parts.extend([
"\n--- 동종 업체 분석 데이터 ---",
vector_context,
"--- 분석 데이터 끝 ---\n"
])
prompt_parts.extend([
"\n위 정보를 바탕으로 실질적이고 구체적인 액션 추천을 해주세요.",
"응답은 반드시 아래 JSON 형식으로만 작성해주세요:",
"",
"```json",
"{",
' "summary": {',
' "current_situation": "현재 상황 요약",',
' "key_insights": ["핵심 인사이트 1", "핵심 인사이트 2"],',
' "priority_areas": ["우선 개선 영역 1", "우선 개선 영역 2"]',
' },',
' "action_plans": {',
' "short_term": [',
' {',
' "title": "즉시 실행 가능한 액션",',
' "description": "구체적인 실행 방법",',
' "expected_impact": "예상 효과",',
' "timeline": "1-2주",',
' "cost": "예상 비용"',
' }',
' ],',
' "mid_term": [',
' {',
' "title": "중기 개선 방안",',
' "description": "구체적인 실행 방법",',
' "expected_impact": "예상 효과",',
' "timeline": "1-3개월",',
' "cost": "예상 비용"',
' }',
' ]',
' },',
' "implementation_tips": ["실행 팁 1", "실행 팁 2"]',
"}",
"```"
])
return "\n".join(prompt_parts)
def create_prompt_for_api_response(self, context: str, additional_context: Optional[str] = None) -> str:
"""API 응답용 프롬프트를 생성합니다. (호환성용)"""
return self._build_action_prompt(context, additional_context)
# =============================================================================
# 헬스체크 및 상태 확인 메서드들
# =============================================================================
def get_health_status(self) -> Dict[str, Any]:
"""ClaudeService 상태 확인"""
try:
status = {
"service": "claude_api",
"status": "healthy" if self.is_ready() else "unhealthy",
"model": self.model,
"api_key_configured": bool(settings.CLAUDE_API_KEY and settings.CLAUDE_API_KEY.strip()),
"timestamp": self._get_timestamp()
}
if self.initialization_error:
status["initialization_error"] = self.initialization_error
status["status"] = "error"
return status
except Exception as e:
return {
"service": "claude_api",
"status": "error",
"error": str(e),
"timestamp": self._get_timestamp()
}
def _get_timestamp(self) -> str:
"""현재 시간 문자열 반환"""
from datetime import datetime
return datetime.now().isoformat()

View File

@ -0,0 +1,235 @@
# app/services/restaurant_service.py (수정된 버전)
import aiohttp
import asyncio
import logging
from typing import List, Optional, Dict, Any
from ..config.settings import settings
from ..models.restaurant_models import RestaurantInfo
from ..utils.category_utils import extract_food_category
logger = logging.getLogger(__name__)
class RestaurantService:
"""음식점 API 연동 서비스"""
def __init__(self):
self.base_url = settings.get_restaurant_api_url()
self.timeout = aiohttp.ClientTimeout(total=settings.REQUEST_TIMEOUT)
async def find_store_by_name_and_region(self, region: str, store_name: str) -> Optional[RestaurantInfo]:
"""
지역과 가게명으로 가게를 찾습니다.
Args:
region: 지역 (시군구 + 읍면동)
store_name: 가게명
Returns:
찾은 가게 정보 ( 번째 결과)
"""
try:
logger.info(f"가게 검색 시작: region={region}, store_name={store_name}")
async with aiohttp.ClientSession(timeout=self.timeout) as session:
# Restaurant API 호출
url = f"{self.base_url}/collect"
payload = {
"query": store_name,
"region": region,
"size": 15,
"pages": 3,
"save_to_file": False
}
logger.info(f"Restaurant API 호출: {url}")
async with session.post(url, json=payload) as response:
if response.status == 200:
data = await response.json()
restaurants = data.get('restaurants', [])
if restaurants:
# 첫 번째 결과 반환
restaurant_data = restaurants[0]
restaurant = RestaurantInfo(**restaurant_data)
logger.info(f"가게 찾기 성공: {restaurant.place_name}")
return restaurant
else:
logger.warning(f"가게를 찾을 수 없음: {store_name}")
return None
else:
logger.error(f"Restaurant API 호출 실패: HTTP {response.status}")
error_text = await response.text()
logger.error(f"Error response: {error_text}")
return None
except asyncio.TimeoutError:
logger.error("Restaurant API 호출 타임아웃")
return None
except Exception as e:
logger.error(f"가게 검색 중 오류: {str(e)}")
return None
def _clean_food_category(self, food_category: str) -> str:
"""
음식 카테고리를 정리하여 검색 키워드로 변환합니다.
Args:
food_category: 원본 음식 카테고리 (: "육류,고기")
Returns:
정리된 검색 키워드 (: "육류 고기")
"""
if not food_category:
return "음식점"
# 콤마와 슬래시를 공백으로 변경
cleaned = food_category.replace(',', ' ').replace('/', ' ')
# 불필요한 단어 제거
stop_words = ['음식점', '요리', '전문점', '맛집']
keywords = []
for keyword in cleaned.split():
keyword = keyword.strip()
if keyword and keyword not in stop_words:
keywords.append(keyword)
# 키워드가 없으면 기본 검색어 사용
if not keywords:
return "음식점"
# 🔧 지역 정보는 포함하지 않고 음식 카테고리만 반환
return ' '.join(keywords)
async def find_similar_stores(self, region: str, food_category: str, max_count: int = 50) -> List[RestaurantInfo]:
"""
동종 업체를 찾습니다.
Args:
region: 지역
food_category: 음식 카테고리
max_count: 최대 검색 개수
Returns:
동종 업체 목록
"""
try:
logger.info(f"동종 업체 검색 시작: region={region}, food_category={food_category}")
# 🔧 검색 쿼리 생성 (음식 카테고리만 포함)
search_query = self._clean_food_category(food_category)
logger.info(f"음식점 수집 요청: query='{search_query}' region='{region}' size=15 pages=1 save_to_file=False")
similar_stores = []
async with aiohttp.ClientSession(timeout=self.timeout) as session:
# 페이지별로 검색 (최대 5페이지)
max_pages = min(5, (max_count // 15) + 1)
for page in range(1, max_pages + 1):
if len(similar_stores) >= max_count:
break
url = f"{self.base_url}/collect"
# 🔧 수정된 payload - query에는 음식 카테고리만, region은 분리
payload = {
"query": search_query, # 🔧 음식 카테고리만 포함
"region": region, # 🔧 지역 정보는 별도 파라미터
"size": 15,
"pages": 1, # 페이지별로 하나씩 호출
"save_to_file": False
}
logger.info(f"동종 업체 검색 페이지 {page}: query='{search_query}' region='{region}'")
try:
async with session.post(url, json=payload) as response:
if response.status == 200:
data = await response.json()
restaurants = data.get('restaurants', [])
for restaurant_data in restaurants:
if len(similar_stores) >= max_count:
break
try:
restaurant = RestaurantInfo(**restaurant_data)
# 카테고리 필터링
restaurant_category = extract_food_category(restaurant.category_name)
if self._is_similar_food_category(food_category, restaurant_category):
similar_stores.append(restaurant)
logger.debug(f"유사 카테고리 매치: {restaurant.place_name} ({restaurant_category})")
except Exception as e:
logger.warning(f"음식점 데이터 파싱 실패: {e}")
continue
logger.info(f"페이지 {page} 완료: {len(restaurants)}개 음식점 수집")
else:
logger.warning(f"동종 업체 검색 실패 (페이지 {page}): HTTP {response.status}")
continue
except Exception as e:
logger.warning(f"페이지 {page} 검색 중 오류: {e}")
continue
# API 요청 제한을 위한 지연
await asyncio.sleep(settings.REQUEST_DELAY)
logger.info(f"동종 업체 검색 완료: 총 {len(similar_stores)}")
return similar_stores
except Exception as e:
logger.error(f"동종 업체 검색 중 오류: {str(e)}")
return []
def _is_similar_food_category(self, target_category: str, restaurant_category: str) -> bool:
"""
음식 카테고리가 유사한지 확인합니다.
Args:
target_category: 대상 카테고리
restaurant_category: 음식점 카테고리
Returns:
유사성 여부
"""
if not target_category or not restaurant_category:
return False
# 정규화
target_lower = target_category.lower().strip()
restaurant_lower = restaurant_category.lower().strip()
# 완전 일치
if target_lower == restaurant_lower:
return True
# 키워드 기반 매칭
target_keywords = set(target_lower.replace(',', ' ').replace('/', ' ').split())
restaurant_keywords = set(restaurant_lower.replace(',', ' ').replace('/', ' ').split())
# 교집합이 있으면 유사한 것으로 판단
common_keywords = target_keywords.intersection(restaurant_keywords)
return len(common_keywords) > 0
async def health_check(self) -> bool:
"""
Restaurant API 상태를 확인합니다.
Returns:
API 상태 (True: 정상, False: 비정상)
"""
try:
async with aiohttp.ClientSession(timeout=self.timeout) as session:
url = f"{self.base_url}/health"
async with session.get(url) as response:
return response.status == 200
except Exception as e:
logger.error(f"Restaurant API 헬스체크 실패: {e}")
return False

View File

@ -0,0 +1,467 @@
# vector/app/services/review_service.py
import aiohttp
import asyncio
import logging
from typing import List, Dict, Any, Tuple, Optional
from ..config.settings import settings
from ..models.review_models import ReviewAnalysisResponse, StoreInfo, ReviewData
from ..models.restaurant_models import RestaurantInfo
logger = logging.getLogger(__name__)
class ReviewService:
"""리뷰 API 연동 서비스 (본인 가게 우선 처리 강화)"""
def __init__(self):
self.base_url = settings.get_review_api_url()
self.timeout = aiohttp.ClientTimeout(total=settings.REQUEST_TIMEOUT)
async def collect_store_reviews(self, store_id: str, max_reviews: int = 100) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
"""
단일 가게의 리뷰를 수집합니다. (본인 가게용 강화 처리)
Args:
store_id: 카카오맵 가게 ID
max_reviews: 최대 수집할 리뷰
Returns:
(가게 정보, 리뷰 목록) 튜플
"""
try:
logger.info(f"🏪 가게 리뷰 수집 시작: store_id={store_id} (최대 {max_reviews}개)")
# 본인 가게는 더 관대한 타임아웃 설정
timeout = aiohttp.ClientTimeout(total=900) # 15분
async with aiohttp.ClientSession(timeout=timeout) as session:
url = f"{self.base_url}/analyze"
payload = {
"store_id": store_id,
"days_limit": None, # 모든 날짜의 리뷰 수집
"max_time": min(600, max_reviews * 3) # 리뷰 수에 따라 시간 조정, 최대 10분
}
logger.info(f"Review API 호출: {url} (타임아웃: {payload['max_time']}초)")
async with session.post(url, json=payload) as response:
if response.status == 200:
data = await response.json()
if data.get('success', False):
store_info = data.get('store_info')
reviews = data.get('reviews', [])
logger.info(f"📊 원본 리뷰 수집: {len(reviews)}")
# 리뷰 품질 필터링
filtered_reviews = self._filter_quality_reviews(reviews)
logger.info(f"📊 품질 필터링 후: {len(filtered_reviews)}")
# 리뷰 개수 제한
if len(filtered_reviews) > max_reviews:
filtered_reviews = filtered_reviews[:max_reviews]
logger.info(f"📊 최종 리뷰 수: {len(filtered_reviews)}개 (제한 적용)")
# 가게 정보와 리뷰 변환
converted_store_info = self._convert_store_info(store_info)
converted_reviews = self._convert_reviews(filtered_reviews)
if converted_store_info and converted_reviews:
logger.info(f"✅ 리뷰 수집 성공: {len(converted_reviews)}")
return converted_store_info, converted_reviews
else:
logger.warning("⚠️ 변환된 데이터가 비어있음")
return None, []
else:
error_msg = data.get('message', 'Unknown error')
logger.error(f"❌ 리뷰 분석 실패: {error_msg}")
return None, []
else:
logger.error(f"❌ Review API 호출 실패: HTTP {response.status}")
error_text = await response.text()
logger.error(f"Error response: {error_text}")
return None, []
except asyncio.TimeoutError:
logger.error("❌ Review API 호출 타임아웃")
return None, []
except Exception as e:
logger.error(f"❌ 리뷰 수집 중 오류: {str(e)}")
return None, []
def _filter_quality_reviews(self, reviews: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""리뷰 품질 필터링"""
try:
filtered = []
for review in reviews:
content = review.get('content', '').strip()
rating = review.get('rating', 0)
# 품질 기준
if (len(content) >= 10 and # 최소 10자 이상
rating > 0 and # 별점이 있어야 함
not self._is_spam_review(content)): # 스팸 제외
filtered.append(review)
logger.debug(f"품질 필터링: {len(reviews)}{len(filtered)}")
return filtered
except Exception as e:
logger.warning(f"⚠️ 리뷰 필터링 실패: {e}")
return reviews # 실패 시 원본 반환
def _is_spam_review(self, content: str) -> bool:
"""스팸 리뷰 판별"""
try:
spam_keywords = [
"추천추천", "최고최고", "맛있어요맛있어요",
"좋아요좋아요", "ㅎㅎㅎㅎ", "ㅋㅋㅋㅋ",
"굿굿굿", "Nice", "Good"
]
content_lower = content.lower()
# 스팸 키워드 확인
for keyword in spam_keywords:
if keyword.lower() in content_lower:
return True
# 너무 짧거나 반복 문자 확인
if len(set(content.replace(' ', ''))) < 3: # 고유 문자 3개 미만
return True
return False
except Exception as e:
logger.warning(f"⚠️ 스팸 판별 실패: {e}")
return False
def _convert_store_info(self, store_info):
"""가게 정보 변환 (강화된 버전)"""
if not store_info:
logger.warning("⚠️ 가게 정보가 비어있음")
return None
try:
converted = {
'id': str(store_info.get('id', '')),
'name': str(store_info.get('name', '')),
'category': str(store_info.get('category', '')),
'rating': str(store_info.get('rating', '')),
'review_count': str(store_info.get('review_count', '')),
'status': str(store_info.get('status', '')),
'address': str(store_info.get('address', ''))
}
# 필수 필드 확인 (ID와 이름은 반드시 있어야 함)
if not converted['id'] or not converted['name']:
logger.warning(f"⚠️ 필수 가게 정보 누락: ID={converted['id']}, Name={converted['name']}")
return None
logger.debug(f"가게 정보 변환 성공: {converted['name']}")
return converted
except Exception as e:
logger.error(f"❌ 가게 정보 변환 실패: {e}")
return None
def _convert_reviews(self, reviews):
"""리뷰 목록 변환 (강화된 버전)"""
if not reviews:
logger.warning("⚠️ 리뷰 목록이 비어있음")
return []
converted_reviews = []
for i, review in enumerate(reviews):
try:
# 안전한 형변환
rating = 0
try:
rating = int(review.get('rating', 0))
except (ValueError, TypeError):
rating = 0
likes = 0
try:
likes = int(review.get('likes', 0))
except (ValueError, TypeError):
likes = 0
photo_count = 0
try:
photo_count = int(review.get('photo_count', 0))
except (ValueError, TypeError):
photo_count = 0
converted_review = {
'reviewer_name': str(review.get('reviewer_name', 'Anonymous')),
'rating': rating,
'date': str(review.get('date', '')),
'content': str(review.get('content', '')).strip(),
'badges': list(review.get('badges', [])),
'likes': likes,
'photo_count': photo_count,
'has_photos': bool(photo_count > 0)
}
# 기본 검증
if converted_review['content'] and converted_review['rating'] > 0:
converted_reviews.append(converted_review)
else:
logger.debug(f"⚠️ 리뷰 {i+1} 품질 미달로 제외")
except Exception as e:
logger.warning(f"⚠️ 리뷰 {i+1} 변환 실패: {e}")
continue
logger.info(f"리뷰 변환 완료: {len(reviews)}{len(converted_reviews)}")
return converted_reviews
async def collect_multiple_stores_reviews(self, restaurants: List[RestaurantInfo]) -> List[Tuple[str, Dict[str, Any], List[Dict[str, Any]]]]:
"""
여러 가게의 리뷰를 수집합니다.
Args:
restaurants: 음식점 목록
Returns:
(store_id, 가게 정보, 리뷰 목록) 튜플의 리스트
"""
try:
logger.info(f"다중 가게 리뷰 수집 시작: {len(restaurants)}개 가게")
results = []
# 동시성 제한을 위해 세마포어 사용
semaphore = asyncio.Semaphore(3) # 최대 3개 동시 요청
async def collect_single_store(restaurant: RestaurantInfo):
async with semaphore:
try:
# 카카오맵 place_url에서 store_id 추출 시도
store_id = self._extract_store_id_from_url(restaurant.place_url)
if not store_id:
# URL에서 추출 실패 시 restaurant.id 사용
store_id = restaurant.id
if not store_id:
logger.warning(f"Store ID를 찾을 수 없음: {restaurant.place_name}")
return None
logger.info(f"가게 {restaurant.place_name} 리뷰 수집 중...")
# 리뷰 수집 (최대 50개로 제한)
store_info, reviews = await self.collect_store_reviews(
store_id,
max_reviews=settings.MAX_REVIEWS_PER_RESTAURANT
)
if store_info and reviews:
return (store_id, store_info, reviews)
else:
logger.warning(f"가게 {restaurant.place_name}의 리뷰 수집 실패")
return None
except Exception as e:
logger.error(f"가게 {restaurant.place_name} 리뷰 수집 중 오류: {e}")
return None
finally:
# API 요청 제한을 위한 지연
await asyncio.sleep(settings.REQUEST_DELAY)
# 병렬 처리
tasks = [collect_single_store(restaurant) for restaurant in restaurants]
results_raw = await asyncio.gather(*tasks, return_exceptions=True)
# 성공한 결과만 필터링
for result in results_raw:
if result and not isinstance(result, Exception):
results.append(result)
logger.info(f"다중 가게 리뷰 수집 완료: {len(results)}개 성공")
return results
except Exception as e:
logger.error(f"다중 가게 리뷰 수집 중 오류: {str(e)}")
return []
def _extract_store_id_from_url(self, place_url: str) -> Optional[str]:
"""
카카오맵 URL에서 store_id를 추출합니다.
Args:
place_url: 카카오맵 장소 URL
Returns:
추출된 store_id 또는 None
"""
try:
if not place_url:
return None
# URL 패턴: https://place.map.kakao.com/123456789
import re
pattern = r'/(\d+)(?:\?|$|#)'
match = re.search(pattern, place_url)
if match:
store_id = match.group(1)
logger.debug(f"URL에서 store_id 추출: {store_id}")
return store_id
else:
logger.debug(f"URL에서 store_id 추출 실패: {place_url}")
return None
except Exception as e:
logger.warning(f"store_id 추출 중 오류: {e}")
return None
def _build_store_info_from_restaurant(self, restaurant: RestaurantInfo) -> Dict[str, Any]:
"""
RestaurantInfo를 store_info 형식으로 변환합니다.
Args:
restaurant: RestaurantInfo 객체
Returns:
store_info 딕셔너리
"""
try:
return {
'id': restaurant.id,
'name': restaurant.place_name,
'category': restaurant.category_name,
'rating': '', # API에서 제공되지 않음
'review_count': '', # API에서 제공되지 않음
'status': '', # API에서 제공되지 않음
'address': restaurant.address_name
}
except Exception as e:
logger.error(f"RestaurantInfo 변환 실패: {e}")
return {
'id': '',
'name': '',
'category': '',
'rating': '',
'review_count': '',
'status': '',
'address': ''
}
def _convert_single_review_data(self, review_data: dict) -> dict:
"""
단일 리뷰 데이터 변환
Args:
review_data: API 응답의 단일 리뷰 데이터
Returns:
변환된 리뷰 데이터
"""
try:
return {
'reviewer_name': review_data.get('reviewer_name', ''),
'reviewer_level': review_data.get('reviewer_level', ''),
'reviewer_stats': review_data.get('reviewer_stats', {}),
'rating': int(review_data.get('rating', 0)),
'date': review_data.get('date', ''),
'content': review_data.get('content', ''),
'badges': review_data.get('badges', []),
'likes': int(review_data.get('likes', 0)),
'photo_count': int(review_data.get('photo_count', 0)),
'has_photos': bool(review_data.get('has_photos', False))
}
except Exception as e:
logger.warning(f"단일 리뷰 데이터 변환 실패: {e}")
return {
'reviewer_name': 'Unknown',
'reviewer_level': '',
'reviewer_stats': {},
'rating': 0,
'date': '',
'content': '',
'badges': [],
'likes': 0,
'photo_count': 0,
'has_photos': False
}
async def health_check(self) -> bool:
"""
Review API 상태를 확인합니다.
Returns:
API 상태 (True: 정상, False: 비정상)
"""
try:
async with aiohttp.ClientSession(timeout=self.timeout) as session:
url = f"{self.base_url}/health"
async with session.get(url) as response:
is_healthy = response.status == 200
if is_healthy:
logger.debug("Review API 헬스체크 성공")
else:
logger.warning(f"Review API 헬스체크 실패: HTTP {response.status}")
return is_healthy
except Exception as e:
logger.error(f"Review API 헬스체크 실패: {e}")
return False
def get_api_info(self) -> Dict[str, Any]:
"""
Review API 정보를 반환합니다.
Returns:
API 정보 딕셔너리
"""
return {
"service_name": "Review API Service",
"base_url": self.base_url,
"timeout": self.timeout.total,
"max_reviews_per_restaurant": settings.MAX_REVIEWS_PER_RESTAURANT,
"request_delay": settings.REQUEST_DELAY
}
async def test_connection(self) -> Dict[str, Any]:
"""
Review API 연결을 테스트합니다.
Returns:
테스트 결과
"""
test_result = {
"service": "Review API",
"base_url": self.base_url,
"status": "unknown",
"response_time": None,
"error": None
}
try:
import time
start_time = time.time()
is_healthy = await self.health_check()
response_time = time.time() - start_time
test_result["response_time"] = round(response_time, 3)
if is_healthy:
test_result["status"] = "healthy"
logger.info(f"Review API 연결 테스트 성공: {response_time:.3f}")
else:
test_result["status"] = "unhealthy"
test_result["error"] = "Health check failed"
logger.warning("Review API 연결 테스트 실패: 헬스체크 실패")
except Exception as e:
test_result["status"] = "error"
test_result["error"] = str(e)
logger.error(f"Review API 연결 테스트 오류: {e}")
return test_result

View File

@ -0,0 +1,728 @@
# app/services/vector_service.py (개선된 버전)
import os
import json
import logging
import time
import shutil
import signal
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple
import chromadb
from chromadb.config import Settings as ChromaSettings
from sentence_transformers import SentenceTransformer
from ..config.settings import settings
from ..utils.data_utils import (
create_store_hash, combine_store_and_reviews, generate_review_summary,
extract_text_for_embedding, create_metadata, is_duplicate_store
)
logger = logging.getLogger(__name__)
class VectorService:
"""Vector DB 서비스 (개선된 초기화 로직)"""
def __init__(self):
self.db_path = settings.VECTOR_DB_PATH
self.collection_name = settings.VECTOR_DB_COLLECTION
self.embedding_model_name = settings.EMBEDDING_MODEL
# 상태 변수
self.client = None
self.collection = None
self.embedding_model = None
self.initialization_error = None
# 안전한 초기화 시도
self._safe_initialize()
def _safe_initialize(self):
"""안전한 초기화 - 개선된 로직"""
try:
logger.info("🔧 VectorService 초기화 시작...")
# 1단계: 디렉토리 권한 확인
self._ensure_directory_permissions()
# 2단계: ChromaDB 초기화 (호환성 확인 포함)
self._initialize_chromadb_with_compatibility_check()
# 3단계: 임베딩 모델 로드
self._initialize_embedding_model()
logger.info("✅ VectorService 초기화 완료")
except Exception as e:
self.initialization_error = str(e)
logger.error(f"❌ VectorService 초기화 실패: {e}")
logger.info("🔄 서비스는 런타임에 재시도 가능합니다")
def _ensure_directory_permissions(self):
"""Vector DB 디렉토리 권한을 확인하고 생성합니다"""
try:
logger.info(f"📁 Vector DB 디렉토리 설정: {self.db_path}")
# 절대 경로로 변환
abs_path = os.path.abspath(self.db_path)
# 디렉토리 생성
os.makedirs(abs_path, mode=0o755, exist_ok=True)
# 권한 확인
if not os.access(abs_path, os.W_OK):
logger.warning(f"⚠️ 쓰기 권한 없음: {abs_path}")
# 권한 변경 시도
try:
os.chmod(abs_path, 0o755)
logger.info("✅ 디렉토리 권한 변경 성공")
except Exception as chmod_error:
logger.warning(f"⚠️ 권한 변경 실패: {chmod_error}")
# 임시 디렉토리로 대체
import tempfile
temp_dir = tempfile.mkdtemp(prefix="vectordb_")
logger.info(f"🔄 임시 디렉토리 사용: {temp_dir}")
self.db_path = temp_dir
abs_path = temp_dir
# 테스트 파일 생성/삭제로 권한 확인
test_file = os.path.join(abs_path, "test_permissions.tmp")
try:
with open(test_file, 'w') as f:
f.write("test")
os.remove(test_file)
logger.info("✅ 디렉토리 권한 확인 완료")
except Exception as test_error:
raise Exception(f"디렉토리 권한 테스트 실패: {test_error}")
except Exception as e:
logger.error(f"❌ 디렉토리 설정 실패: {e}")
raise
def _initialize_chromadb_with_compatibility_check(self):
"""ChromaDB 초기화 (호환성 확인 포함)"""
max_retries = 3
retry_delay = 2
for attempt in range(max_retries):
try:
logger.info(f"🔄 ChromaDB 초기화 시도 {attempt + 1}/{max_retries}")
# 1단계: 기존 DB 호환성 확인
existing_db_valid = self._check_existing_db_compatibility()
# 2단계: ChromaDB 클라이언트 생성
self._create_chromadb_client()
# 3단계: 컬렉션 초기화
self._initialize_collection(existing_db_valid)
logger.info("✅ ChromaDB 초기화 완료")
return # 성공 시 루프 종료
except Exception as e:
logger.error(f"❌ ChromaDB 초기화 실패 (시도 {attempt + 1}): {e}")
if attempt < max_retries - 1:
logger.info(f"🔄 {retry_delay}초 후 재시도...")
time.sleep(retry_delay)
retry_delay *= 2 # 지수 백오프
else:
raise Exception(f"ChromaDB 초기화 최종 실패: {e}")
def _check_existing_db_compatibility(self):
"""기존 DB 호환성 확인"""
try:
if not os.path.exists(self.db_path):
logger.info("📁 새 DB 디렉토리 - 스키마 확인 불필요")
return False
db_files = [f for f in os.listdir(self.db_path) if not f.startswith('.')]
if not db_files:
logger.info("📁 빈 DB 디렉토리 - 스키마 확인 불필요")
return False
logger.info(f"📁 기존 DB 파일 발견: {db_files}")
# 실제 호환성 테스트
logger.info("🔍 기존 DB 호환성 테스트 중...")
try:
# 테스트용 클라이언트 생성
test_client = chromadb.PersistentClient(path=self.db_path)
test_client.heartbeat()
# 컬렉션 접근 시도
try:
test_collection = test_client.get_collection(name=self.collection_name)
count = test_collection.count()
logger.info(f"✅ 기존 DB 호환성 확인 완료: {count}개 벡터 존재")
return True
except Exception as collection_error:
logger.info(f"📝 기존 컬렉션 없음 (정상): {collection_error}")
return True # DB는 정상, 컬렉션만 새로 생성하면 됨
except Exception as compatibility_error:
# 실제 호환성 문제 발견
logger.warning(f"⚠️ 실제 호환성 문제 발견: {compatibility_error}")
self._backup_incompatible_db()
return False
except Exception as e:
logger.warning(f"⚠️ 호환성 확인 중 오류: {e}")
return False
def _backup_incompatible_db(self):
"""호환성 문제가 있는 DB 백업"""
try:
backup_path = f"{self.db_path}_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
logger.warning(f"🔄 호환성 문제로 기존 DB 백업: {backup_path}")
shutil.move(self.db_path, backup_path)
logger.info(f"✅ 기존 DB 백업 완료: {backup_path}")
# 새 디렉토리 생성
os.makedirs(self.db_path, exist_ok=True)
# 오래된 백업 정리 (7일 이상)
self._cleanup_old_backups()
except Exception as backup_error:
logger.warning(f"⚠️ 백업 실패, 강제 삭제 진행: {backup_error}")
shutil.rmtree(self.db_path, ignore_errors=True)
os.makedirs(self.db_path, exist_ok=True)
def _cleanup_old_backups(self):
"""오래된 백업 파일 정리 (7일 이상)"""
try:
base_path = os.path.dirname(self.db_path)
backup_pattern = f"{os.path.basename(self.db_path)}_backup_"
cutoff_time = time.time() - (7 * 24 * 3600) # 7일 전
for item in os.listdir(base_path):
if item.startswith(backup_pattern):
backup_path = os.path.join(base_path, item)
if os.path.isdir(backup_path) and os.path.getctime(backup_path) < cutoff_time:
shutil.rmtree(backup_path, ignore_errors=True)
logger.info(f"🗑️ 오래된 백업 삭제: {backup_path}")
except Exception as e:
logger.warning(f"⚠️ 백업 정리 중 오류: {e}")
def _create_chromadb_client(self):
"""ChromaDB 클라이언트 생성"""
try:
# 최신 버전 호환 설정
chroma_settings = ChromaSettings(
anonymized_telemetry=False,
allow_reset=True,
is_persistent=True
)
self.client = chromadb.PersistentClient(
path=self.db_path,
settings=chroma_settings
)
logger.info("✅ ChromaDB 클라이언트 생성 성공")
except Exception as modern_error:
logger.warning(f"⚠️ 최신 설정 실패, 간단한 설정으로 재시도: {modern_error}")
# 간단한 설정으로 재시도
self.client = chromadb.PersistentClient(path=self.db_path)
logger.info("✅ ChromaDB 간단 설정 클라이언트 생성 성공")
# 연결 테스트
try:
self.client.heartbeat()
logger.info("✅ ChromaDB 연결 테스트 성공")
except Exception as heartbeat_error:
logger.warning(f"⚠️ Heartbeat 실패 (무시): {heartbeat_error}")
def _initialize_collection(self, existing_db_valid: bool):
"""컬렉션 초기화"""
try:
if existing_db_valid:
# 기존 컬렉션 로드 시도
try:
self.collection = self.client.get_collection(name=self.collection_name)
count = self.collection.count()
logger.info(f"✅ 기존 컬렉션 로드 성공: {self.collection_name} ({count}개 벡터)")
return
except Exception as get_error:
logger.info(f"📝 기존 컬렉션 없음, 새로 생성: {get_error}")
# 새 컬렉션 생성
self.collection = self.client.create_collection(
name=self.collection_name,
metadata={
"description": "Restaurant reviews vector store",
"created_at": datetime.now().isoformat(),
"version": "1.0"
}
)
logger.info(f"✅ 새 컬렉션 생성 성공: {self.collection_name}")
except Exception as create_error:
logger.error(f"❌ 컬렉션 초기화 실패: {create_error}")
# 대체 컬렉션명으로 재시도
fallback_name = f"{self.collection_name}_{int(time.time())}"
logger.info(f"🔄 대체 컬렉션명으로 재시도: {fallback_name}")
self.collection = self.client.create_collection(
name=fallback_name,
metadata={"description": "Restaurant reviews (fallback)"}
)
self.collection_name = fallback_name
logger.info(f"✅ 대체 컬렉션 생성 성공: {fallback_name}")
def _initialize_embedding_model(self):
"""임베딩 모델 초기화"""
try:
logger.info(f"🤖 임베딩 모델 로드 시작: {self.embedding_model_name}")
# 캐시 디렉토리 설정
cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "sentence_transformers")
os.makedirs(cache_dir, exist_ok=True)
# 권한 확인
if not os.access(cache_dir, os.W_OK):
import tempfile
cache_dir = tempfile.mkdtemp(prefix="st_cache_")
logger.info(f"🔄 임시 캐시 디렉토리 사용: {cache_dir}")
# 모델 로드 (타임아웃 설정)
def timeout_handler(signum, frame):
raise TimeoutError("임베딩 모델 로드 타임아웃")
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(300) # 5분 타임아웃
try:
self.embedding_model = SentenceTransformer(
self.embedding_model_name,
cache_folder=cache_dir,
device='cpu' # CPU 사용 명시
)
signal.alarm(0) # 타임아웃 해제
# 모델 테스트
test_embedding = self.embedding_model.encode(["테스트 문장"])
logger.info(f"✅ 임베딩 모델 로드 성공: {test_embedding.shape}")
except TimeoutError:
signal.alarm(0)
raise Exception("임베딩 모델 로드 타임아웃 (5분)")
except Exception as e:
logger.error(f"❌ 임베딩 모델 로드 실패: {e}")
raise
def is_ready(self) -> bool:
"""서비스 준비 상태 확인"""
return (
self.client is not None and
self.collection is not None and
self.embedding_model is not None and
self.initialization_error is None
)
def get_initialization_error(self) -> Optional[str]:
"""초기화 에러 메시지 반환"""
return self.initialization_error
def retry_initialization(self) -> bool:
"""초기화 재시도"""
try:
logger.info("🔄 VectorService 초기화 재시도...")
# 상태 초기화
self.client = None
self.collection = None
self.embedding_model = None
self.initialization_error = None
# 재초기화
self._safe_initialize()
return self.is_ready()
except Exception as e:
self.initialization_error = str(e)
logger.error(f"❌ 초기화 재시도 실패: {e}")
return False
def reset_vector_db(self) -> Dict[str, Any]:
"""Vector DB 완전 리셋"""
try:
logger.info("🔄 Vector DB 완전 리셋 시작...")
# 기존 클라이언트 정리
self.client = None
self.collection = None
# DB 디렉토리 완전 삭제
if os.path.exists(self.db_path):
shutil.rmtree(self.db_path, ignore_errors=True)
logger.info(f"✅ 기존 DB 디렉토리 삭제: {self.db_path}")
# 새 디렉토리 생성
os.makedirs(self.db_path, exist_ok=True)
logger.info(f"✅ 새 DB 디렉토리 생성: {self.db_path}")
# 재초기화
success = self.retry_initialization()
if success:
return {
"success": True,
"message": "Vector DB가 성공적으로 리셋되었습니다",
"collection_name": self.collection_name,
"db_path": self.db_path
}
else:
return {
"success": False,
"error": self.initialization_error or "재초기화 실패"
}
except Exception as e:
logger.error(f"❌ Vector DB 리셋 실패: {e}")
return {
"success": False,
"error": str(e)
}
def get_health_status(self) -> Dict[str, Any]:
"""서비스 상태 확인"""
try:
status = {
"service": "vector_db",
"status": "healthy" if self.is_ready() else "unhealthy",
"db_path": self.db_path,
"collection_name": self.collection_name,
"embedding_model": self.embedding_model_name,
"timestamp": datetime.now().isoformat()
}
if self.initialization_error:
status["initialization_error"] = self.initialization_error
status["status"] = "error"
# 상세 상태
status["components"] = {
"client": "connected" if self.client else "disconnected",
"collection": "ready" if self.collection else "not_ready",
"embedding": "loaded" if self.embedding_model else "not_loaded"
}
# 컬렉션 정보
if self.collection:
try:
status["collection_count"] = self.collection.count()
except Exception as e:
status["collection_error"] = str(e)
return status
except Exception as e:
return {
"service": "vector_db",
"status": "error",
"error": str(e),
"timestamp": datetime.now().isoformat()
}
def get_store_context(self, store_id: str) -> Optional[str]:
"""스토어 ID로 컨텍스트 조회"""
try:
if not self.is_ready():
logger.warning("VectorService가 준비되지 않음")
return None
# 스토어 ID로 검색
results = self.collection.get(
where={"store_id": store_id}
)
if not results or not results.get('documents'):
logger.warning(f"스토어 ID '{store_id}'에 대한 데이터 없음")
return None
# 컨텍스트 생성
documents = results['documents']
metadatas = results.get('metadatas', [])
context_parts = []
for i, doc in enumerate(documents):
metadata = metadatas[i] if i < len(metadatas) else {}
# 메타데이터 정보 추가
if metadata:
context_parts.append(f"[{metadata.get('store_name', 'Unknown')}]")
context_parts.append(doc)
context_parts.append("---")
return "\n".join(context_parts)
except Exception as e:
logger.error(f"스토어 컨텍스트 조회 실패: {e}")
return None
async def build_vector_store(self, store_info: Dict[str, Any], similar_stores_data: List[Tuple[str, Dict[str, Any], List[Dict[str, Any]]]], food_category: str, region: str) -> Dict[str, Any]:
"""Vector Store 구축 (완전 수정된 버전)"""
try:
if not self.is_ready():
# 재시도 한 번 더
if not self.retry_initialization():
raise Exception("Vector DB가 초기화되지 않았습니다")
logger.info(f"Vector Store 구축 시작: {len(similar_stores_data)}개 스토어")
# 통계 초기화
stats = {
"total_processed": 0,
"newly_added": 0,
"updated": 0,
"duplicates": 0,
"errors": 0
}
# 배치 처리용 리스트
all_documents = []
all_embeddings = []
all_metadatas = []
all_ids = []
# 각 스토어 처리
for store_id, store_data, reviews in similar_stores_data:
try:
# 데이터 검증
if not store_data or not reviews:
logger.warning(f"스토어 '{store_id}' 데이터 부족: store_data={bool(store_data)}, reviews={len(reviews) if reviews else 0}")
stats["errors"] += 1
continue
# 올바른 create_store_hash 호출
store_hash = create_store_hash(
store_id=store_id,
store_name=store_data.get('place_name', ''),
region=region
)
# ChromaDB에서 직접 중복 확인 (is_duplicate_store 함수 사용하지 않음)
try:
# 같은 store_id로 이미 저장된 데이터가 있는지 확인
existing_data = self.collection.get(
where={"store_id": store_id},
limit=1
)
if existing_data and len(existing_data.get('ids', [])) > 0:
logger.debug(f"중복 스토어 건너뛰기: {store_id}")
stats["duplicates"] += 1
continue
except Exception as dup_check_error:
# 중복 확인 실패는 로그만 남기고 계속 진행
logger.warning(f"중복 확인 실패 (계속 진행): {dup_check_error}")
# 올바른 extract_text_for_embedding 호출
embedding_text = extract_text_for_embedding(
store_info=store_data,
reviews=reviews
)
# 임베딩 생성
try:
embedding = self.embedding_model.encode(embedding_text)
embedding = embedding.tolist() # numpy array를 list로 변환
except Exception as embed_error:
logger.error(f"임베딩 생성 실패 (store_id: {store_id}): {embed_error}")
stats["errors"] += 1
continue
# 올바른 create_metadata 호출
metadata = create_metadata(
store_info=store_data,
food_category=food_category,
region=region
)
# 배치에 추가
all_documents.append(embedding_text)
all_embeddings.append(embedding)
all_metadatas.append(metadata)
all_ids.append(f"{store_id}_{store_hash}")
stats["total_processed"] += 1
stats["newly_added"] += 1
if stats["total_processed"] % 10 == 0:
logger.info(f"처리 진행률: {stats['total_processed']}/{len(similar_stores_data)}")
except Exception as store_error:
logger.error(f"음식점 처리 중 오류 (store_id: {store_id}): {store_error}")
stats["errors"] += 1
continue
# 배치로 벡터 저장
if all_documents:
logger.info(f"벡터 배치 저장 시작: {len(all_documents)}")
try:
self.collection.add(
documents=all_documents,
embeddings=all_embeddings,
metadatas=all_metadatas,
ids=all_ids
)
logger.info("✅ 벡터 배치 저장 성공")
except Exception as save_error:
logger.error(f"❌ 벡터 저장 실패: {save_error}")
return {
"success": False,
"error": f"벡터 저장 실패: {str(save_error)}",
"statistics": stats
}
else:
logger.warning("⚠️ 저장할 벡터 데이터가 없음")
# 최종 통계
try:
total_vectors = self.collection.count()
logger.info(f"✅ Vector Store 구축 완료: 총 {total_vectors}개 벡터")
except Exception as count_error:
logger.warning(f"벡터 개수 확인 실패: {count_error}")
total_vectors = len(all_documents)
return {
"success": True,
"message": "Vector Store 구축 완료",
"statistics": stats,
"total_vectors": total_vectors,
"store_info": store_info
}
except Exception as e:
logger.error(f"Vector Store 구축 전체 실패: {e}")
return {
"success": False,
"error": str(e),
"statistics": stats if 'stats' in locals() else {
"total_processed": 0,
"newly_added": 0,
"updated": 0,
"duplicates": 0,
"errors": 1
}
}
def get_db_status(self) -> Dict[str, Any]:
"""Vector DB 상태 정보를 반환합니다."""
try:
if not self.is_ready():
return {
'collection_name': self.collection_name,
'total_documents': 0,
'total_stores': 0,
'db_path': self.db_path,
'status': 'not_ready',
'initialization_error': self.initialization_error
}
# 문서 개수 확인
try:
total_documents = self.collection.count()
except Exception as e:
logger.warning(f"문서 개수 확인 실패: {e}")
total_documents = 0
# 고유 가게 수 확인 (store_id 기준)
try:
# 모든 메타데이터에서 고유 store_id 추출
all_metadata = self.collection.get()
store_ids = set()
if all_metadata.get('metadatas'):
for metadata in all_metadata['metadatas']:
store_id = metadata.get('store_id')
if store_id:
store_ids.add(store_id)
total_stores = len(store_ids)
except Exception as e:
logger.warning(f"가게 수 확인 실패: {e}")
total_stores = 0
return {
'collection_name': self.collection_name,
'total_documents': total_documents,
'total_stores': total_stores,
'db_path': self.db_path,
'status': 'ready'
}
except Exception as e:
logger.error(f"DB 상태 확인 실패: {e}")
return {
'collection_name': self.collection_name,
'total_documents': 0,
'total_stores': 0,
'db_path': self.db_path,
'status': 'error',
'error': str(e)
}
def search_similar_cases(self, store_id: str, context: str) -> Optional[str]:
"""유사한 케이스를 검색합니다."""
try:
if not self.is_ready():
logger.warning("VectorService가 준비되지 않음")
return None
# 컨텍스트 기반 유사 검색
try:
# 검색 쿼리를 임베딩으로 변환
query_embedding = self.embedding_model.encode(context)
query_embedding = query_embedding.tolist()
# 유사한 문서 검색 (상위 5개)
results = self.collection.query(
query_embeddings=[query_embedding],
n_results=5,
include=['documents', 'metadatas']
)
if not results or not results.get('documents') or not results['documents'][0]:
logger.info("유사 케이스를 찾을 수 없음")
return None
# 컨텍스트 조합
context_parts = []
documents = results['documents'][0]
metadatas = results.get('metadatas', [[]])[0]
for i, doc in enumerate(documents):
metadata = metadatas[i] if i < len(metadatas) else {}
# 가게 정보 추가
store_name = metadata.get('store_name', 'Unknown')
food_category = metadata.get('food_category', 'Unknown')
context_parts.append(f"[{food_category} - {store_name}]")
context_parts.append(doc[:500] + "..." if len(doc) > 500 else doc)
context_parts.append("---")
return "\n".join(context_parts)
except Exception as search_error:
logger.warning(f"벡터 검색 실패: {search_error}")
return None
except Exception as e:
logger.error(f"유사 케이스 검색 실패: {e}")
return None

View File

@ -0,0 +1,161 @@
# app/utils/category_utils.py (수정된 버전)
import re
from typing import Optional
def extract_food_category(category_name: str) -> str:
"""
카테고리명에서 음식 종류를 추출합니다.
'음식점 > 한식 > 육류,고기'에서 '한식' 추출
Args:
category_name: 전체 카테고리명
Returns:
추출된 음식 종류
"""
if not category_name:
return ""
# '>' 기준으로 분할하고 마지막 바로 전 요소 반환
parts = category_name.split('>')
if len(parts) >= 2:
food_category = parts[-2].strip() # 마지막 바로 전 값 사용
return food_category
elif len(parts) == 1:
return parts[0].strip() # 하나밖에 없으면 그것을 반환
return category_name.strip()
def normalize_category(category: str) -> str:
"""
카테고리를 정규화합니다.
Args:
category: 원본 카테고리
Returns:
정규화된 카테고리
"""
if not category:
return ""
# 공백 제거 및 소문자 변환
normalized = category.strip().lower()
# 특수문자 제거 (콤마, 슬래시 등은 유지)
normalized = re.sub(r'[^\w가-힣,/\s]', '', normalized)
return normalized
def is_similar_category(category1: str, category2: str) -> bool:
"""
카테고리가 유사한지 판단합니다.
Args:
category1: 번째 카테고리
category2: 번째 카테고리
Returns:
유사 여부
"""
if not category1 or not category2:
return False
# 정규화
norm1 = normalize_category(category1)
norm2 = normalize_category(category2)
# 완전 일치
if norm1 == norm2:
return True
# 키워드 기반 유사성 검사
keywords1 = set(norm1.replace(',', ' ').replace('/', ' ').split())
keywords2 = set(norm2.replace(',', ' ').replace('/', ' ').split())
# 교집합이 하나 이상 있으면 유사한 것으로 판단
common_keywords = keywords1.intersection(keywords2)
return len(common_keywords) > 0
def extract_main_category(category_name: str) -> str:
"""
메인 카테고리를 추출합니다. (음식점 > 한식 에서 '한식' 추출)
Args:
category_name: 전체 카테고리명
Returns:
메인 카테고리
"""
if not category_name:
return ""
parts = category_name.split('>')
if len(parts) >= 2:
return parts[1].strip()
elif len(parts) == 1:
return parts[0].strip()
return ""
def build_search_query(region: str, food_category: str) -> str:
"""
검색 쿼리를 구성합니다. (수정된 버전 - 지역 정보 제외)
Args:
region: 지역 (사용하지 않음)
food_category: 음식 카테고리
Returns:
검색 쿼리 문자열 (음식 카테고리만 포함)
"""
# 콤마와 슬래시를 공백으로 변경하여 검색 키워드 생성
search_keywords = food_category.replace(',', ' ').replace('/', ' ')
# 불필요한 단어 제거
stop_words = ['음식점', '요리', '전문점', '맛집']
keywords = []
for keyword in search_keywords.split():
keyword = keyword.strip()
if keyword and keyword not in stop_words:
keywords.append(keyword)
# 키워드가 없으면 기본 검색어 사용
if not keywords:
keywords = ['음식점']
# 🔧 지역 정보는 포함하지 않고 음식 키워드만 반환
query = ' '.join(keywords)
return query.strip()
def clean_food_category_for_search(food_category: str) -> str:
"""
음식 카테고리를 검색용 키워드로 정리합니다.
Args:
food_category: 원본 음식 카테고리
Returns:
정리된 검색 키워드
"""
if not food_category:
return "음식점"
# 콤마와 슬래시를 공백으로 변경
cleaned = food_category.replace(',', ' ').replace('/', ' ')
# 불필요한 단어 제거
stop_words = ['음식점', '요리', '전문점', '맛집']
keywords = []
for keyword in cleaned.split():
keyword = keyword.strip()
if keyword and keyword not in stop_words:
keywords.append(keyword)
# 키워드가 없으면 기본 검색어 사용
if not keywords:
return "음식점"
return ' '.join(keywords)

View File

@ -0,0 +1,194 @@
# app/utils/data_utils.py
import json
import hashlib
from datetime import datetime
from typing import Dict, List, Any, Optional
def create_store_hash(store_id: str, store_name: str, region: str) -> str:
"""
가게의 고유 해시를 생성합니다.
Args:
store_id: 가게 ID
store_name: 가게명
region: 지역
Returns:
생성된 해시값
"""
combined = f"{store_id}_{store_name}_{region}"
return hashlib.md5(combined.encode('utf-8')).hexdigest()
def combine_store_and_reviews(store_info: Dict[str, Any], reviews: List[Dict[str, Any]]) -> str:
"""
가게 정보와 리뷰를 결합하여 JSON 문자열을 생성합니다.
Args:
store_info: 가게 정보
reviews: 리뷰 목록
Returns:
결합된 JSON 문자열
"""
combined_data = {
"store_info": store_info,
"reviews": reviews,
"review_summary": generate_review_summary(reviews),
"combined_at": datetime.now().isoformat()
}
return json.dumps(combined_data, ensure_ascii=False, separators=(',', ':'))
def generate_review_summary(reviews: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
리뷰 목록에서 요약 정보를 생성합니다.
Args:
reviews: 리뷰 목록
Returns:
리뷰 요약 정보
"""
if not reviews:
return {
"total_reviews": 0,
"average_rating": 0.0,
"rating_distribution": {},
"common_keywords": [],
"sentiment_summary": {
"positive": 0,
"neutral": 0,
"negative": 0
}
}
# 기본 통계
total_reviews = len(reviews)
ratings = [review.get('rating', 0) for review in reviews if review.get('rating', 0) > 0]
average_rating = sum(ratings) / len(ratings) if ratings else 0.0
# 별점 분포
rating_distribution = {}
for rating in ratings:
rating_distribution[str(rating)] = rating_distribution.get(str(rating), 0) + 1
# 키워드 추출 (badges 기반)
keyword_counts = {}
for review in reviews:
badges = review.get('badges', [])
for badge in badges:
keyword_counts[badge] = keyword_counts.get(badge, 0) + 1
# 상위 키워드 추출
common_keywords = sorted(keyword_counts.items(), key=lambda x: x[1], reverse=True)[:10]
common_keywords = [keyword for keyword, count in common_keywords]
# 감정 분석 (간단한 별점 기반)
sentiment_summary = {
"positive": len([r for r in ratings if r >= 4]),
"neutral": len([r for r in ratings if r == 3]),
"negative": len([r for r in ratings if r <= 2])
}
return {
"total_reviews": total_reviews,
"average_rating": round(average_rating, 2),
"rating_distribution": rating_distribution,
"common_keywords": common_keywords,
"sentiment_summary": sentiment_summary,
"has_recent_reviews": any(
review.get('date', '') >= datetime.now().strftime('%Y.%m.%d')
for review in reviews[-10:] # 최근 10개 리뷰 확인
)
}
def extract_text_for_embedding(store_info: Dict[str, Any], reviews: List[Dict[str, Any]]) -> str:
"""
임베딩을 위한 텍스트를 추출합니다.
Args:
store_info: 가게 정보
reviews: 리뷰 목록
Returns:
임베딩용 텍스트
"""
# 가게 기본 정보
store_text = f"가게명: {store_info.get('place_name', '')}\n"
store_text += f"카테고리: {store_info.get('category_name', '')}\n"
store_text += f"주소: {store_info.get('address_name', '')}\n"
# 리뷰 내용 요약
review_contents = []
review_keywords = []
for review in reviews[:20]: # 최근 20개 리뷰만 사용
content = review.get('content', '').strip()
if content:
review_contents.append(content)
badges = review.get('badges', [])
review_keywords.extend(badges)
# 리뷰 텍스트 조합
if review_contents:
store_text += f"리뷰 내용: {' '.join(review_contents[:10])}\n" # 최대 10개 리뷰
# 키워드 조합
if review_keywords:
unique_keywords = list(set(review_keywords))
store_text += f"키워드: {', '.join(unique_keywords[:15])}\n" # 최대 15개 키워드
return store_text.strip()
def create_metadata(store_info: Dict[str, Any], food_category: str, region: str) -> Dict[str, Any]:
"""
Vector DB용 메타데이터를 생성합니다.
Args:
store_info: 가게 정보
food_category: 음식 카테고리
region: 지역
Returns:
메타데이터 딕셔너리
"""
return {
"store_id": store_info.get('id', ''),
"store_name": store_info.get('place_name', ''),
"food_category": food_category,
"region": region,
"category_name": store_info.get('category_name', ''),
"address": store_info.get('address_name', ''),
"phone": store_info.get('phone', ''),
"place_url": store_info.get('place_url', ''),
"x": store_info.get('x', ''), # 좌표를 개별 키로 분리
"y": store_info.get('y', ''), # 좌표를 개별 키로 분리
"last_updated": datetime.now().isoformat()
}
def is_duplicate_store(metadata1: Dict[str, Any], metadata2: Dict[str, Any]) -> bool:
"""
가게가 중복인지 확인합니다.
Args:
metadata1: 번째 가게 메타데이터
metadata2: 번째 가게 메타데이터
Returns:
중복 여부
"""
# Store ID 기준 확인
if metadata1.get('store_id') and metadata2.get('store_id'):
return metadata1['store_id'] == metadata2['store_id']
# 가게명 + 주소 기준 확인
name1 = metadata1.get('store_name', '').strip()
name2 = metadata2.get('store_name', '').strip()
addr1 = metadata1.get('address', '').strip()
addr2 = metadata2.get('address', '').strip()
if name1 and name2 and addr1 and addr2:
return name1 == name2 and addr1 == addr2
return False

282
vector/build-base.sh Executable file
View File

@ -0,0 +1,282 @@
#!/bin/bash
# build-base.sh - Poetry 기반 Vector DB API Base Image 빌드 스크립트
set -e
# 변수 설정
BASE_IMAGE_NAME="vector-api-base"
BASE_IMAGE_TAG="${1:-latest}"
ACR_NAME="${2:-acrdigitalgarage03}"
RESOURCE_GROUP="${3:-rg-digitalgarage-03}"
# ACR URL 자동 구성
if [ -n "${ACR_NAME}" ]; then
REGISTRY="${ACR_NAME}.azurecr.io"
FULL_BASE_IMAGE_NAME="${REGISTRY}/${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
else
FULL_BASE_IMAGE_NAME="${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
fi
# 고정된 파일 경로
BASE_DOCKERFILE_PATH="deployment/container/Dockerfile-base"
BUILD_CONTEXT="."
# 색상 설정
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
log_info() { echo -e "${CYAN} $1${NC}"; }
log_success() { echo -e "${GREEN}$1${NC}"; }
log_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
log_error() { echo -e "${RED}$1${NC}"; }
echo "========================================================"
echo "🚀 Poetry 기반 Vector DB API Base Image 빌드"
echo "========================================================"
echo "Base 이미지명: ${FULL_BASE_IMAGE_NAME}"
if [ -n "${ACR_NAME}" ]; then
echo "ACR 이름: ${ACR_NAME}"
echo "리소스 그룹: ${RESOURCE_GROUP}"
fi
echo "빌드 시작: $(date)"
echo ""
echo "📦 포함될 구성요소:"
echo " ✅ Python 3.11 + Poetry"
echo " ✅ 모든 AI/ML 의존성 (NumPy, PyTorch, Transformers)"
echo " ✅ Vector DB (ChromaDB, Chroma)"
echo " ✅ 웹 프레임워크 (FastAPI, Uvicorn)"
echo " ✅ Claude API (Anthropic)"
echo ""
# 시작 시간 기록
BUILD_START=$(date +%s)
# 사용법 표시 함수
show_usage() {
echo "사용법:"
echo " $0 [BASE_IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP]"
echo ""
echo "파라미터:"
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
echo " ACR_NAME : Azure Container Registry 이름"
echo " RESOURCE_GROUP: Azure 리소스 그룹"
echo ""
echo "예시:"
echo " $0 v2.0.0 # 로컬 빌드만"
echo " $0 v2.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
echo ""
echo "💡 참고:"
echo " - Base Image는 한 번만 빌드하면 재사용 가능"
echo " - 모든 Python 의존성이 포함되어 Service Image 빌드 시간 단축"
echo " - Poetry 환경으로 패키지 관리"
}
# ACR 로그인 함수
acr_login() {
local acr_name="$1"
local resource_group="$2"
log_info "Azure Container Registry 로그인 중..."
if ! command -v az &> /dev/null; then
log_error "Azure CLI (az)가 설치되지 않았습니다."
exit 1
fi
if ! command -v jq &> /dev/null; then
log_error "jq가 설치되지 않았습니다."
exit 1
fi
if ! az account show &> /dev/null; then
log_error "Azure에 로그인되지 않았습니다."
echo "로그인 명령: az login"
exit 1
fi
local credential_json
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
if [ $? -ne 0 ]; then
log_error "ACR credential 조회 실패"
exit 1
fi
local username
local password
username=$(echo "${credential_json}" | jq -r '.username')
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
log_error "ACR credential 파싱 실패"
exit 1
fi
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
if [ $? -eq 0 ]; then
log_success "ACR 로그인 성공!"
return 0
else
log_error "ACR 로그인 실패"
exit 1
fi
}
# 파라미터 검증
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_usage
exit 0
fi
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
log_error "ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
echo ""
show_usage
exit 1
fi
# 필수 파일 확인
log_info "필수 파일 확인 중..."
if [ ! -f "${BASE_DOCKERFILE_PATH}" ]; then
log_error "${BASE_DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
exit 1
fi
if [ ! -f "setup.sh" ]; then
log_error "setup.sh 파일을 찾을 수 없습니다."
log_error "Poetry 설치 스크립트(setup_poetry_vector.sh)를 setup.sh로 저장해주세요."
exit 1
fi
log_success "모든 필수 파일 확인 완료"
echo "📄 Base Dockerfile: ${BASE_DOCKERFILE_PATH}"
echo "📄 Poetry 설치 스크립트: setup.sh"
# setup.sh 실행 가능 확인
if [ ! -x "setup.sh" ]; then
log_warning "setup.sh 파일에 실행 권한이 없습니다. 실행 권한 추가 중..."
chmod +x setup.sh
log_success "실행 권한 추가 완료"
fi
# 시스템 정보 확인
log_info "시스템 정보 확인..."
echo " - OS: $(lsb_release -d 2>/dev/null | cut -f2 || echo 'Unknown')"
echo " - CPU Cores: $(nproc)"
echo " - Available Memory: $(free -h | awk '/^Mem:/ {print $7}' 2>/dev/null || echo 'Unknown')"
echo " - Docker Version: $(docker --version)"
echo " - Build Context: ${BUILD_CONTEXT}"
# ACR 로그인 수행
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
echo ""
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
echo ""
fi
# Docker 빌드
log_info "Poetry 기반 Base Image 빌드 시작..."
echo " - 예상 빌드 시간: 15-25분 (Poetry 의존성 해결 포함)"
echo " - 모든 Python 패키지가 Poetry로 설치됨"
echo " - 멀티스테이지 빌드로 크기 최적화"
echo ""
echo "🔨 빌드 명령어:"
echo "docker build -t \"${FULL_BASE_IMAGE_NAME}\" -f \"${BASE_DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
echo ""
docker build -t "${FULL_BASE_IMAGE_NAME}" -f "${BASE_DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
if [ $? -eq 0 ]; then
# 빌드 종료 시간 계산
BUILD_END=$(date +%s)
BUILD_TIME=$((BUILD_END - BUILD_START))
BUILD_MINUTES=$((BUILD_TIME / 60))
BUILD_SECONDS=$((BUILD_TIME % 60))
log_success "Poetry 기반 Base Image 빌드 완료!"
echo ""
echo "⏱️ 총 빌드 시간: ${BUILD_MINUTES}${BUILD_SECONDS}"
# 이미지 정보 표시
echo ""
log_info "Base Image 정보:"
docker images "${FULL_BASE_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
# latest 태그 추가 생성
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo ""
log_info "latest 태그 생성 중..."
docker tag "${FULL_BASE_IMAGE_NAME}" "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
log_success "latest 태그 생성 완료: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
fi
# ACR 푸시
if [ -n "${ACR_NAME}" ]; then
echo ""
log_info "ACR에 Base Image 푸시 중..."
echo "📤 푸시 중: ${FULL_BASE_IMAGE_NAME}"
docker push "${FULL_BASE_IMAGE_NAME}"
if [ $? -eq 0 ]; then
log_success "Base Image 푸시 성공"
if [ "${BASE_IMAGE_TAG}" != "latest" ]; then
echo "📤 푸시 중: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
docker push "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
if [ $? -eq 0 ]; then
log_success "latest 태그 푸시 성공"
fi
fi
else
log_error "Base Image 푸시 실패"
exit 1
fi
fi
echo ""
log_success "🎉 Poetry 기반 Base Image 빌드 완료!"
echo ""
echo "📋 완료된 작업:"
echo " ✅ Base Image 빌드: ${FULL_BASE_IMAGE_NAME}"
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo " ✅ latest 태그: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
fi
if [ -n "${ACR_NAME}" ]; then
echo " ✅ ACR 푸시 완료"
fi
echo " ✅ Poetry 환경 구성 완료"
echo " ✅ 모든 AI/ML 의존성 설치 완료"
echo ""
echo "📝 다음 단계:"
echo " 이제 Service Image를 빌드하세요:"
if [ -n "${ACR_NAME}" ]; then
echo " ./build.sh v1.0.0 ${ACR_NAME} ${RESOURCE_GROUP}"
else
echo " ./build.sh v1.0.0"
fi
echo ""
echo "💡 최적화 포인트:"
echo " ✅ Base Image: 한 번만 빌드 (주기적 업데이트)"
echo " ✅ Service Image: 매번 빠른 빌드 (앱 코드만)"
echo " ✅ Poetry 환경: 의존성 충돌 자동 해결"
echo " ✅ 개발 생산성: 빌드 대기시간 95% 단축"
else
log_error "Base Image 빌드 실패!"
exit 1
fi
echo ""
echo "🏁 Base Image 빌드 프로세스 완료 - $(date)"

337
vector/build.sh Executable file
View File

@ -0,0 +1,337 @@
#!/bin/bash
# build.sh - Poetry 기반 Vector DB API Service Image 빌드 스크립트
set -e
# 변수 설정
IMAGE_NAME="vector-api"
IMAGE_TAG="${1:-latest}"
ACR_NAME="${2:-acrdigitalgarage03}"
RESOURCE_GROUP="${3:-rg-digitalgarage-03}"
BASE_IMAGE_TAG="${4:-latest}"
# ACR URL 자동 구성
if [ -n "${ACR_NAME}" ]; then
REGISTRY="${ACR_NAME}.azurecr.io"
FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
BASE_IMAGE="${REGISTRY}/vector-api-base:${BASE_IMAGE_TAG}"
else
FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}"
BASE_IMAGE="vector-api-base:${BASE_IMAGE_TAG}"
fi
# 고정된 파일 경로
DOCKERFILE_PATH="deployment/container/Dockerfile"
BUILD_CONTEXT="."
# 색상 설정
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
log_info() { echo -e "${CYAN} $1${NC}"; }
log_success() { echo -e "${GREEN}$1${NC}"; }
log_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
log_error() { echo -e "${RED}$1${NC}"; }
echo "========================================================"
echo "🚀 Poetry 기반 Vector DB API Service Image 빌드"
echo "========================================================"
echo "Service 이미지명: ${FULL_IMAGE_NAME}"
echo "Base 이미지: ${BASE_IMAGE}"
if [ -n "${ACR_NAME}" ]; then
echo "ACR 이름: ${ACR_NAME}"
echo "리소스 그룹: ${RESOURCE_GROUP}"
fi
echo "빌드 시작: $(date)"
echo ""
# 시작 시간 기록
BUILD_START=$(date +%s)
# 사용법 표시 함수
show_usage() {
echo "사용법:"
echo " $0 [IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP] [BASE_IMAGE_TAG]"
echo ""
echo "파라미터:"
echo " IMAGE_TAG : Service 이미지 태그 (기본값: latest)"
echo " ACR_NAME : Azure Container Registry 이름"
echo " RESOURCE_GROUP: Azure 리소스 그룹"
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
echo ""
echo "예시:"
echo " $0 v1.0.0 # 로컬 빌드만"
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 v2.0.0 # 특정 Base Image 사용"
echo ""
echo "전제조건:"
echo " Poetry 기반 Base Image가 먼저 빌드되어 있어야 합니다:"
echo " ./build-base.sh ${BASE_IMAGE_TAG} [ACR_NAME] [RESOURCE_GROUP]"
echo ""
echo "💡 장점:"
echo " - 빠른 빌드: 앱 코드만 복사 (30초~2분)"
echo " - Poetry 환경: 의존성 관리 최적화"
echo " - 캐시 활용: Base Image 재사용으로 효율성 극대화"
}
# ACR 로그인 함수
acr_login() {
local acr_name="$1"
local resource_group="$2"
log_info "Azure Container Registry 로그인 중..."
if ! command -v az &> /dev/null; then
log_error "Azure CLI (az)가 설치되지 않았습니다."
exit 1
fi
if ! command -v jq &> /dev/null; then
log_error "jq가 설치되지 않았습니다."
exit 1
fi
if ! az account show &> /dev/null; then
log_error "Azure에 로그인되지 않았습니다."
echo "로그인 명령: az login"
exit 1
fi
local credential_json
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
if [ $? -ne 0 ]; then
log_error "ACR credential 조회 실패"
exit 1
fi
local username
local password
username=$(echo "${credential_json}" | jq -r '.username')
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
log_error "ACR credential 파싱 실패"
exit 1
fi
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
if [ $? -eq 0 ]; then
log_success "ACR 로그인 성공!"
return 0
else
log_error "ACR 로그인 실패"
exit 1
fi
}
# 파라미터 검증
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_usage
exit 0
fi
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
log_error "ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
echo ""
show_usage
exit 1
fi
# 필수 파일 확인
log_info "필수 파일 확인 중..."
if [ ! -f "app/main.py" ]; then
log_error "app/main.py 파일을 찾을 수 없습니다."
exit 1
fi
if [ ! -f "${DOCKERFILE_PATH}" ]; then
log_error "${DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
exit 1
fi
log_success "모든 필수 파일이 확인되었습니다."
echo "📄 Service Dockerfile: ${DOCKERFILE_PATH}"
echo "📄 메인 애플리케이션: app/main.py"
echo "🏗️ 빌드 컨텍스트: ${BUILD_CONTEXT}"
# Base Image 존재 확인
echo ""
log_info "Poetry 기반 Base Image 확인 중..."
if docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
log_success "Base Image 확인됨: ${BASE_IMAGE}"
# Base Image에서 Poetry 환경 확인
log_info "Base Image Poetry 환경 검증 중..."
if docker run --rm "${BASE_IMAGE}" poetry --version >/dev/null 2>&1; then
POETRY_VERSION=$(docker run --rm "${BASE_IMAGE}" poetry --version 2>/dev/null)
log_success "Poetry 환경 확인됨: ${POETRY_VERSION}"
else
log_warning "Base Image에서 Poetry를 찾을 수 없습니다"
fi
# Base Image 크기 확인
BASE_SIZE=$(docker images "${BASE_IMAGE}" --format "{{.Size}}")
echo "📊 Base Image 크기: ${BASE_SIZE}"
else
log_error "Base Image를 찾을 수 없습니다: ${BASE_IMAGE}"
echo ""
echo "📋 해결 방법:"
echo " Base Image를 먼저 빌드하세요:"
if [ -n "${ACR_NAME}" ]; then
echo " ./build-base.sh ${BASE_IMAGE_TAG} ${ACR_NAME} ${RESOURCE_GROUP}"
else
echo " ./build-base.sh ${BASE_IMAGE_TAG}"
fi
echo ""
echo " 또는 ACR에서 Base Image를 풀하세요:"
if [ -n "${ACR_NAME}" ]; then
echo " docker pull ${BASE_IMAGE}"
fi
exit 1
fi
# ACR 로그인 수행
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
echo ""
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
echo ""
fi
# Docker 빌드
log_info "Poetry 기반 Service Image 빌드 시작..."
echo " - 예상 빌드 시간: 30초~2분 (앱 코드만 복사)"
echo " - Base Image 재사용으로 빠른 빌드"
echo " - Poetry 환경 상속"
echo ""
echo "🔨 빌드 명령어:"
echo "docker build --build-arg BASE_IMAGE=\"${BASE_IMAGE}\" -t \"${FULL_IMAGE_NAME}\" -f \"${DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
echo ""
docker build --build-arg BASE_IMAGE="${BASE_IMAGE}" -t "${FULL_IMAGE_NAME}" -f "${DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
if [ $? -eq 0 ]; then
# 빌드 종료 시간 계산
BUILD_END=$(date +%s)
BUILD_TIME=$((BUILD_END - BUILD_START))
BUILD_MINUTES=$((BUILD_TIME / 60))
BUILD_SECONDS=$((BUILD_TIME % 60))
log_success "Service Image 빌드 완료!"
echo ""
echo "⏱️ 총 빌드 시간: ${BUILD_MINUTES}${BUILD_SECONDS}"
# 이미지 정보 표시
echo ""
log_info "Service Image 정보:"
docker images "${FULL_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
# 🧪 빌드된 이미지 검증
log_info "Service Image 검증 중..."
echo "🔍 Poetry 환경 및 앱 코드 확인:"
docker run --rm "${FULL_IMAGE_NAME}" sh -c "
echo '✅ Poetry 버전:' && poetry --version &&
echo '✅ Python 버전:' && poetry run python --version &&
echo '✅ 설치된 패키지 수:' && poetry show | wc -l &&
echo '✅ 앱 코드 확인:' && ls -la /app/app/ 2>/dev/null | head -5
" 2>/dev/null || log_warning "일부 검증에 실패했습니다."
# latest 태그 추가 생성
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo ""
log_info "latest 태그 생성 중..."
docker tag "${FULL_IMAGE_NAME}" "${REGISTRY}/${IMAGE_NAME}:latest"
log_success "latest 태그 생성 완료: ${REGISTRY}/${IMAGE_NAME}:latest"
fi
# ACR 푸시
if [ -n "${ACR_NAME}" ]; then
echo ""
log_info "ACR에 Service Image 푸시 중..."
echo "📤 푸시 중: ${FULL_IMAGE_NAME}"
docker push "${FULL_IMAGE_NAME}"
if [ $? -eq 0 ]; then
log_success "Service Image 푸시 성공"
if [ "${IMAGE_TAG}" != "latest" ]; then
echo "📤 푸시 중: ${REGISTRY}/${IMAGE_NAME}:latest"
docker push "${REGISTRY}/${IMAGE_NAME}:latest"
if [ $? -eq 0 ]; then
log_success "latest 태그 푸시 성공"
fi
fi
else
log_error "Service Image 푸시 실패"
exit 1
fi
fi
echo ""
log_success "🎉 Poetry 기반 Service Image 빌드 완료!"
echo ""
echo "📋 완료된 작업:"
echo " ✅ Service Image 빌드: ${FULL_IMAGE_NAME}"
echo " ✅ Base Image 활용: ${BASE_IMAGE}"
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
echo " ✅ latest 태그: ${REGISTRY}/${IMAGE_NAME}:latest"
fi
if [ -n "${ACR_NAME}" ]; then
echo " ✅ ACR 푸시 완료"
fi
echo " ✅ Poetry 환경 상속"
echo ""
echo "🧪 테스트 명령어:"
echo " # 로컬 실행 테스트"
echo " docker run --rm -p 8000:8000 ${FULL_IMAGE_NAME}"
echo ""
echo " # Poetry 환경 확인"
echo " docker run --rm ${FULL_IMAGE_NAME} poetry show"
echo ""
echo " # 앱 헬스체크"
echo " docker run --rm ${FULL_IMAGE_NAME} poetry run python -c \"import app.main; print('✅ 앱 로드 성공')\""
echo ""
echo "📝 다음 단계:"
echo " 1. 로컬 테스트:"
echo " docker run --rm -p 8000:8000 ${FULL_IMAGE_NAME}"
echo ""
echo " 2. Kubernetes 배포:"
echo " kubectl apply -f deployment/manifests/"
echo ""
echo " 3. 스케일링:"
echo " kubectl scale deployment vector-api --replicas=3"
echo ""
echo "💡 성능 최적화:"
echo " ✅ 빠른 빌드: Base Image 재사용으로 빌드 시간 95% 단축"
echo " ✅ 효율적 캐시: Poetry 환경은 Base Image에서 관리"
echo " ✅ 작은 이미지: 앱 코드만 포함하여 배포 이미지 최소화"
echo " ✅ 개발 친화적: Poetry로 일관된 의존성 관리"
else
log_error "Service Image 빌드 실패!"
echo ""
echo "🔍 문제 해결:"
echo " 1. Base Image 확인: docker images | grep vector-api-base"
echo " 2. Dockerfile 확인: cat ${DOCKERFILE_PATH}"
echo " 3. 앱 코드 확인: ls -la app/"
echo " 4. 빌드 로그 확인: 위의 에러 메시지 참조"
exit 1
fi
echo ""
echo "🏁 Service Image 빌드 프로세스 완료 - $(date)"

236
vector/create-imagepullsecret.sh Executable file
View File

@ -0,0 +1,236 @@
#!/bin/bash
# create-imagepullsecret.sh - ACR Image Pull Secret 생성 스크립트
set -e
# 변수 설정
ACR_NAME="${1:-acrdigitalgarage03}"
RESOURCE_GROUP="${2:-rg-digitalgarage-03}"
SECRET_NAME="${3:-acr-secret}"
echo "====================================================="
echo " ACR Image Pull Secret 생성 (Vector API)"
echo "====================================================="
# 사용법 표시 함수
show_usage() {
echo "사용법:"
echo " $0 [ACR_NAME] [RESOURCE_GROUP] [SECRET_NAME]"
echo ""
echo "파라미터:"
echo " ACR_NAME : Azure Container Registry 이름 (필수)"
echo " RESOURCE_GROUP: Azure 리소스 그룹 (필수)"
echo " SECRET_NAME : Secret 이름 (기본값: acr-secret)"
echo ""
echo "예시:"
echo " $0 acrdigitalgarage01 rg-digitalgarage-03"
echo " $0 acrdigitalgarage01 rg-digitalgarage-03 acr-prod-secret"
echo ""
}
# 파라미터 검증
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_usage
exit 0
fi
if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ]; then
echo "❌ ACR_NAME과 RESOURCE_GROUP는 필수 파라미터입니다."
echo ""
show_usage
exit 1
fi
# 필수 도구 확인
echo "🔧 필수 도구 확인 중..."
if ! command -v az &> /dev/null; then
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
echo "설치 방법: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli"
exit 1
fi
if ! command -v kubectl &> /dev/null; then
echo "❌ kubectl이 설치되지 않았습니다."
echo "설치 방법: https://kubernetes.io/docs/tasks/tools/"
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "❌ jq가 설치되지 않았습니다."
echo "설치 방법: sudo apt-get install jq"
exit 1
fi
echo "✅ 필수 도구 확인 완료"
# Azure 로그인 확인
echo ""
echo "🔐 Azure 로그인 상태 확인 중..."
if ! az account show &> /dev/null; then
echo "❌ Azure에 로그인되지 않았습니다."
echo "로그인 명령: az login"
exit 1
fi
CURRENT_SUBSCRIPTION=$(az account show --query name -o tsv)
echo "✅ Azure 로그인 확인됨"
echo " 현재 구독: ${CURRENT_SUBSCRIPTION}"
# Kubernetes 클러스터 연결 확인
echo ""
echo "☸️ Kubernetes 클러스터 연결 확인 중..."
if ! kubectl cluster-info &> /dev/null; then
echo "❌ Kubernetes 클러스터에 연결되지 않았습니다."
echo "클러스터 연결 방법:"
echo " az aks get-credentials --resource-group ${RESOURCE_GROUP} --name <AKS_CLUSTER_NAME>"
exit 1
fi
CURRENT_CONTEXT=$(kubectl config current-context)
echo "✅ Kubernetes 클러스터 연결 확인됨"
echo " 현재 컨텍스트: ${CURRENT_CONTEXT}"
# ACR 정보 설정
REGISTRY_URL="${ACR_NAME}.azurecr.io"
echo ""
echo "📋 설정 정보:"
echo " ACR 이름: ${ACR_NAME}"
echo " 레지스트리 URL: ${REGISTRY_URL}"
echo " 리소스 그룹: ${RESOURCE_GROUP}"
echo " Secret 이름: ${SECRET_NAME}"
# ACR 존재 확인
echo ""
echo "🏪 ACR 존재 확인 중..."
if ! az acr show --name "${ACR_NAME}" --resource-group "${RESOURCE_GROUP}" &> /dev/null; then
echo "❌ ACR을 찾을 수 없습니다."
echo "확인 사항:"
echo " - ACR 이름: ${ACR_NAME}"
echo " - 리소스 그룹: ${RESOURCE_GROUP}"
echo " - ACR이 해당 리소스 그룹에 존재하는지 확인"
exit 1
fi
echo "✅ ACR 존재 확인됨: ${ACR_NAME}"
# ACR credential 조회
echo ""
echo "🔑 ACR credential 조회 중..."
credential_json=$(az acr credential show --name "${ACR_NAME}" --resource-group "${RESOURCE_GROUP}" 2>/dev/null)
if [ $? -ne 0 ]; then
echo "❌ ACR credential 조회 실패"
echo "확인 사항:"
echo " - ACR 이름: ${ACR_NAME}"
echo " - 리소스 그룹: ${RESOURCE_GROUP}"
echo " - ACR에 대한 권한이 있는지 확인"
exit 1
fi
# JSON에서 username과 password 추출
username=$(echo "${credential_json}" | jq -r '.username')
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
echo "❌ ACR credential 파싱 실패"
echo "credential JSON:"
echo "${credential_json}"
exit 1
fi
echo "✅ ACR credential 조회 성공"
echo " 사용자명: ${username}"
echo " 비밀번호: ${password:0:10}..."
# 기존 Secret 확인 및 삭제
echo ""
echo "🔍 기존 Secret 확인 중..."
if kubectl get secret "${SECRET_NAME}" &> /dev/null; then
echo "🗑️ 기존 Secret 삭제 중..."
kubectl delete secret "${SECRET_NAME}"
echo "✅ 기존 Secret 삭제 완료"
else
echo "✅ 기존 Secret 없음"
fi
# Image Pull Secret 생성
echo ""
echo "🔐 Image Pull Secret 생성 중..."
kubectl create secret docker-registry "${SECRET_NAME}" \
--docker-server="${REGISTRY_URL}" \
--docker-username="${username}" \
--docker-password="${password}"
if [ $? -eq 0 ]; then
echo "✅ Image Pull Secret 생성 성공!"
else
echo "❌ Image Pull Secret 생성 실패"
exit 1
fi
# Secret 정보 확인
echo ""
echo "📊 생성된 Secret 정보:"
kubectl describe secret "${SECRET_NAME}"
echo ""
echo "🧪 Secret 테스트 중..."
# Secret이 올바르게 생성되었는지 확인
SECRET_TYPE=$(kubectl get secret "${SECRET_NAME}" -o jsonpath='{.type}')
if [ "${SECRET_TYPE}" = "kubernetes.io/dockerconfigjson" ]; then
echo "✅ Secret 타입 확인됨: ${SECRET_TYPE}"
else
echo "❌ Secret 타입 불일치: ${SECRET_TYPE}"
fi
# Registry URL 확인
SECRET_REGISTRY=$(kubectl get secret "${SECRET_NAME}" -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq -r ".auths | keys[0]")
if [ "${SECRET_REGISTRY}" = "${REGISTRY_URL}" ]; then
echo "✅ Registry URL 확인됨: ${SECRET_REGISTRY}"
else
echo "❌ Registry URL 불일치: ${SECRET_REGISTRY}"
fi
echo ""
echo "🎉 ACR Image Pull Secret 생성 완료!"
echo ""
echo "📋 사용 방법:"
echo ""
echo "1. Deployment에서 imagePullSecrets 사용:"
echo " spec:"
echo " template:"
echo " spec:"
echo " imagePullSecrets:"
echo " - name: ${SECRET_NAME}"
echo ""
echo "2. ServiceAccount에 Secret 연결:"
echo " kubectl patch serviceaccount default -p '{\"imagePullSecrets\": [{\"name\": \"${SECRET_NAME}\"}]}'"
echo ""
echo "3. Secret 확인:"
echo " kubectl get secret ${SECRET_NAME}"
echo " kubectl describe secret ${SECRET_NAME}"
echo ""
echo "4. Secret 삭제 (필요시):"
echo " kubectl delete secret ${SECRET_NAME}"
echo ""
echo "🔧 다음 단계:"
echo "1. Deployment 매니페스트에 imagePullSecrets 추가"
echo "2. kubectl apply -f deployment/manifests/deployment.yaml"
echo "3. Pod 상태 확인: kubectl get pods"
echo ""
echo "💡 문제 해결:"
echo "- ErrImagePull 오류 시: Secret 이름과 레지스트리 URL 확인"
echo "- 권한 오류 시: ACR에 대한 적절한 권한 확인"
echo "- 네트워크 오류 시: 클러스터에서 ACR로의 네트워크 연결 확인"
echo ""
echo "✅ Image Pull Secret 설정이 완료되었습니다!"

View File

@ -0,0 +1,100 @@
# deployment/container/Dockerfile
# Poetry 기반 Vector DB API Service Image - PVC 마운트 충돌 해결
# Base Image에서 상속 (Poetry 환경 포함)
ARG BASE_IMAGE=vector-api-base:latest
FROM ${BASE_IMAGE}
# 메타데이터
LABEL maintainer="admin@example.com"
LABEL version="1.0.2"
LABEL description="Vector DB API Service with Poetry - PVC Mount Fixed"
# 환경 변수 설정 - Poetry 가상환경 경로 유지
ENV HOME=/home/appuser \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
POETRY_VENV_IN_PROJECT=false \
POETRY_VIRTUALENVS_CREATE=true \
POETRY_VIRTUALENVS_PATH=/opt/pypoetry/venvs \
POETRY_CACHE_DIR=/opt/pypoetry/cache \
POETRY_NO_INTERACTION=1 \
PATH="/home/appuser/.local/bin:/opt/pypoetry/venvs/vector-api/bin:/usr/local/bin:/usr/bin:/bin"
# root로 전환 (파일 복사 및 권한 설정용)
USER root
# 🔧 Poetry 설정 파일들 복사 (의존성 정보)
COPY pyproject.toml poetry.lock* /app/
# 🚀 애플리케이션 소스 코드 복사
COPY app/ /app/app/
# 📦 Poetry 의존성 설치 (가상환경이 /opt에 생성됨)
RUN cd /app && \
# Poetry 설정 확인 및 재설정
poetry config virtualenvs.in-project false && \
poetry config virtualenvs.create true && \
poetry config virtualenvs.path /opt/pypoetry/venvs && \
poetry config cache-dir /opt/pypoetry/cache && \
echo "🔧 Poetry 설정 확인:" && \
poetry config --list && \
echo "📦 의존성 설치 시작..." && \
poetry install --no-dev --no-interaction && \
echo "✅ 의존성 설치 완료" && \
# 설치된 패키지 확인
poetry show | head -10 && \
# 가상환경 위치 확인
poetry env info && \
# 캐시 정리
rm -rf $POETRY_CACHE_DIR/cache && \
rm -rf /tmp/*
# 📁 데이터 디렉토리 생성 및 권한 설정
RUN mkdir -p /app/data /app/logs /app/vectordb \
&& chmod -R 755 /app/data /app/logs /app/vectordb
# 👤 사용자 및 권한 설정
RUN if id "appuser" &>/dev/null; then \
chown -R appuser:appuser /app; \
chown -R appuser:appuser /opt/pypoetry; \
else \
echo "appuser가 없어서 root로 실행됩니다"; \
fi
# 🔧 실행 스크립트 생성 (Poetry 가상환경 자동 활성화)
RUN cat > /app/start.sh << 'EOF'
#!/bin/bash
echo "🚀 Vector API 시작 중..."
echo "📍 현재 디렉토리: $(pwd)"
echo "🐍 Python 위치: $(which python)"
echo "📦 Poetry 위치: $(which poetry)"
echo "🔧 Poetry 가상환경 정보:"
poetry env info
echo "📋 설치된 패키지 (일부):"
poetry show | head -5
echo "🔍 dotenv 모듈 테스트:"
poetry run python -c "from dotenv import load_dotenv; print('✅ dotenv 모듈 정상 로드')"
echo "🚀 애플리케이션 실행..."
exec poetry run python app/main.py
EOF
RUN chmod +x /app/start.sh && \
chown appuser:appuser /app/start.sh
# 🏥 헬스체크 (의존성 확인 포함)
HEALTHCHECK --interval=30s --timeout=15s --start-period=60s --retries=3 \
CMD poetry run python -c "from dotenv import load_dotenv; import app.main; print('✅ 앱 헬스체크 성공')" || exit 1
# 🚀 포트 노출
EXPOSE 8000
# 📁 작업 디렉토리 설정
WORKDIR /app
# 👤 실행 사용자 설정
USER appuser
# 🎯 애플리케이션 실행 - 스크립트 사용
CMD ["/app/start.sh"]

View File

@ -0,0 +1,138 @@
# deployment/container/Dockerfile-base
# Poetry 기반 Vector DB API Base Image - 홈 디렉토리 사용 (안전한 방식)
FROM python:3.11-slim
# 메타데이터
LABEL description="Vector DB API Base Image with Poetry - Home Directory"
LABEL version="poetry-home-v1.0"
LABEL maintainer="admin@example.com"
# 환경 변수 설정 - Poetry 가상환경을 홈 디렉토리로 이동
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
POETRY_NO_INTERACTION=1 \
POETRY_VENV_IN_PROJECT=false \
POETRY_VIRTUALENVS_CREATE=true \
POETRY_VIRTUALENVS_PATH=/home/appuser/.cache/pypoetry/venvs \
POETRY_CACHE_DIR=/home/appuser/.cache/pypoetry/cache \
HF_HUB_CACHE=/app/.cache/huggingface \
TRANSFORMERS_CACHE=/app/.cache/transformers \
SENTENCE_TRANSFORMERS_HOME=/app/.cache/sentence_transformers
# 🔧 시스템 패키지 설치
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gcc \
g++ \
python3-dev \
curl \
wget \
ca-certificates \
git \
sudo \
lsb-release \
bc \
python3.11 \
python3.11-venv \
python3.11-dev \
python3.11-distutils \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# 📦 pip 업그레이드
RUN python3.11 -m pip install --no-cache-dir --upgrade pip setuptools wheel
# 👤 비root 사용자 생성 (Poetry 설치 전에)
RUN groupadd -r appuser && \
useradd -r -g appuser -d /home/appuser -s /bin/bash appuser && \
mkdir -p /home/appuser && \
chown -R appuser:appuser /home/appuser
# 🔧 Poetry 가상환경 디렉토리 생성 (홈 디렉토리 사용)
RUN mkdir -p /home/appuser/.cache/pypoetry/venvs \
/home/appuser/.cache/pypoetry/cache && \
chown -R appuser:appuser /home/appuser/.cache && \
chmod -R 755 /home/appuser/.cache
# 🐍 Poetry를 appuser로 설치
USER appuser
ENV PATH="/home/appuser/.local/bin:$PATH"
# appuser 홈 디렉토리에 Poetry 설치
RUN curl -sSL https://install.python-poetry.org | python3.11 -
# Poetry 실행 권한 및 심볼릭 링크 (root 권한 필요)
USER root
RUN chmod +x /home/appuser/.local/bin/poetry && \
ln -sf /home/appuser/.local/bin/poetry /usr/local/bin/poetry && \
chown appuser:appuser /home/appuser/.local/bin/poetry
# appuser로 다시 전환
USER appuser
# 🔧 Poetry 설정 - 가상환경을 홈 디렉토리로 이동
RUN poetry config virtualenvs.in-project false && \
poetry config virtualenvs.create true && \
poetry config virtualenvs.path /home/appuser/.cache/pypoetry/venvs && \
poetry config cache-dir /home/appuser/.cache/pypoetry/cache
# Poetry 버전 확인 및 설정 검증
RUN poetry --version && \
poetry config --list && \
ls -la /home/appuser/.local/bin/poetry && \
which poetry
# 🏗️ 작업 디렉토리 설정 및 권한 조정
WORKDIR /app
# root로 전환하여 디렉토리 소유권 설정
USER root
RUN chown -R appuser:appuser /app
# 📋 Poetry 설치 스크립트 복사 및 권한 설정
COPY setup.sh /app/setup.sh
RUN chmod +x /app/setup.sh && \
chown appuser:appuser /app/setup.sh
# appuser로 전환하여 Poetry 환경 설정
USER appuser
# 🚀 Poetry 환경 설정 및 의존성 설치
RUN cd /app && \
export DEBIAN_FRONTEND=noninteractive && \
./setup.sh --skip-poetry-install --skip-python311-check --force-reinstall
# 🗂️ 필요한 디렉토리 생성 및 권한 설정
USER root
RUN mkdir -p /app/.cache/huggingface \
/app/.cache/transformers \
/app/.cache/sentence_transformers \
/app/vectordb \
/app/data \
/app/logs && \
chmod -R 755 /app/.cache /app/vectordb /app/data /app/logs && \
chown -R appuser:appuser /app && \
# Poetry 가상환경 디렉토리 권한 재확인
chown -R appuser:appuser /home/appuser/.cache && \
chmod -R 755 /home/appuser/.cache
# 🧹 캐시 정리
RUN rm -rf /tmp/* /var/tmp/*
# 🚀 포트 노출
EXPOSE 8000
# 🏥 간단한 헬스체크 (appuser 권한으로 실행)
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD su -c "poetry --version && poetry config virtualenvs.path" appuser || exit 1
# 👤 최종 사용자 설정
USER appuser
# 🎯 기본 명령어
CMD ["poetry", "--version"]

View File

@ -0,0 +1,63 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: vector-api-config
data:
# 🔧 기존 애플리케이션 설정 (유지)
APP_TITLE: "음식점 Vector DB 구축 서비스"
APP_VERSION: "1.0.0"
APP_DESCRIPTION: "소상공인을 위한 AI 기반 경쟁업체 분석 및 액션 추천 시스템"
# 🔧 기존 서버 설정 (유지)
HOST: "0.0.0.0"
PORT: "8000"
LOG_LEVEL: "debug" # 디버깅을 위해 debug로 변경
# 🔧 기존 Restaurant API 설정 (K8s 환경, 유지)
RESTAURANT_API_HOST: "restaurant-api-service"
RESTAURANT_API_PORT: "80"
# 🔧 기존 Review API 설정 (K8s 환경, 유지)
REVIEW_API_HOST: "kakao-review-api-service"
REVIEW_API_PORT: "80"
# 🔧 기존 Claude API 설정 (유지)
CLAUDE_MODEL: "claude-sonnet-4-20250514"
# 🔧 기존 Vector DB 설정 (유지)
VECTOR_DB_PATH: "/app/vectordb"
VECTOR_DB_COLLECTION: "restaurant_reviews"
EMBEDDING_MODEL: "sentence-transformers/all-MiniLM-L6-v2"
# 🔧 기존 데이터 수집 설정 (유지)
MAX_RESTAURANTS_PER_CATEGORY: "50"
MAX_REVIEWS_PER_RESTAURANT: "100"
REQUEST_DELAY: "0.1"
REQUEST_TIMEOUT: "600"
# 🆕 ChromaDB 최신 버전 호환 설정 추가
CHROMA_DB_IMPL: "duckdb+parquet" # SQLite 대신 DuckDB 사용
ALLOW_RESET: "True"
ANONYMIZED_TELEMETRY: "False"
# 🆕 Python 최적화 설정
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
# 🆕 캐시 디렉토리 설정
HF_HUB_CACHE: "/app/.cache/huggingface"
TRANSFORMERS_CACHE: "/app/.cache/transformers"
# 🆕 FastAPI 설정
FASTAPI_ENV: "production"
# 🆕 Uvicorn 설정
UVICORN_HOST: "0.0.0.0"
UVICORN_PORT: "8000"
UVICORN_LOG_LEVEL: "debug"
UVICORN_ACCESS_LOG: "true"
# 🆕 타임아웃 설정
STARTUP_TIMEOUT: "300" # 5분
SHUTDOWN_TIMEOUT: "30" # 30초

View File

@ -0,0 +1,164 @@
# deployment/manifests/deployment.yaml.fixed
apiVersion: apps/v1
kind: Deployment
metadata:
name: vector-api
labels:
app: vector-api
spec:
replicas: 1
selector:
matchLabels:
app: vector-api
template:
metadata:
labels:
app: vector-api
spec:
# 🔧 볼륨 권한 설정을 위한 initContainer
initContainers:
- name: volume-permissions
image: busybox:1.35
command:
- /bin/sh
- -c
- |
echo "=== 볼륨 권한 설정 시작 ==="
mkdir -p /app/vectordb
chown -R 1000:1000 /app/vectordb
chmod -R 755 /app/vectordb
echo "=== 볼륨 권한 설정 완료 ==="
volumeMounts:
- name: vector-db-storage
mountPath: /app/vectordb
securityContext:
runAsUser: 0
containers:
- name: vector-api
image: acrdigitalgarage03.azurecr.io/vector-api:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
# 🔧 보안 컨텍스트
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
# 🔧 리소스 설정
resources:
requests:
memory: "4Gi"
cpu: "1000m"
limits:
memory: "8Gi"
cpu: "2000m"
# 🏥 헬스체크 설정
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 120
periodSeconds: 30
timeoutSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 3
# 📂 볼륨 마운트
volumeMounts:
- name: vector-db-storage
mountPath: /app/vectordb
# ConfigMap 환경 변수
envFrom:
- configMapRef:
name: vector-api-config
# 🌍 환경변수 설정 (인증 필드 제거)
env:
- name: PYTHONUNBUFFERED
value: "1"
- name: PYTHONDONTWRITEBYTECODE
value: "1"
# 🔧 ChromaDB 기본 설정 (인증 필드 제거)
- name: ANONYMIZED_TELEMETRY
value: "False"
- name: CHROMA_DB_IMPL
value: "duckdb+parquet"
- name: ALLOW_RESET
value: "True"
# 🔧 로그 레벨
- name: LOG_LEVEL
value: "info"
# 🔧 Claude API (ConfigMap에서 가져오기)
- name: CLAUDE_API_KEY
valueFrom:
secretKeyRef:
name: vector-api-secret
key: CLAUDE_API_KEY
- name: CLAUDE_MODEL
valueFrom:
configMapKeyRef:
name: vector-api-config
key: CLAUDE_MODEL
# 🔧 기타 설정 (ConfigMap에서 가져오기)
- name: APP_TITLE
valueFrom:
configMapKeyRef:
name: vector-api-config
key: APP_TITLE
- name: APP_VERSION
valueFrom:
configMapKeyRef:
name: vector-api-config
key: APP_VERSION
# 📦 볼륨 설정
volumes:
- name: vector-db-storage
persistentVolumeClaim:
claimName: vector-db-pvc
# 🔐 이미지 Pull Secret
imagePullSecrets:
- name: acr-secret
# 🎯 노드 선택 및 배치 설정
nodeSelector:
agentpool: aipool
tolerations:
- key: "dedicated"
operator: "Equal"
value: "aipool"
effect: "NoSchedule"
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- vector-api
topologyKey: kubernetes.io/hostname
restartPolicy: Always
dnsPolicy: ClusterFirst

View File

@ -0,0 +1,39 @@
# deployment/manifests/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vector-api-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "false"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
# Vector DB 구축 시간을 고려한 긴 타임아웃 설정
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800"
nginx.ingress.kubernetes.io/proxy-send-timeout: "1800"
nginx.ingress.kubernetes.io/client-body-timeout: "1800"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
# CORS 설정
nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
spec:
ingressClassName: nginx
rules:
# 환경에 맞게 호스트명 수정 필요
- host: vector-api.20.249.191.180.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vector-api-service
port:
number: 80
# TLS 설정 (HTTPS 필요시 주석 해제)
# tls:
# - hosts:
# - vector-api.example.com
# secretName: vector-api-tls

View File

@ -0,0 +1,18 @@
# deployment/manifests/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: vector-db-pvc
labels:
app: vector-api
component: storage
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: managed
# 선택적: 특정 PV에 바인딩하려는 경우
# volumeName: vector-db-pv

View File

@ -0,0 +1,11 @@
# deployment/manifests/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: vector-api-secret
type: Opaque
data:
# Claude API 키 (Base64 인코딩 필요)
# echo -n "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" | base64
CLAUDE_API_KEY: c2stYW50LWFwaTAzLUVGM1ZocXJJUkVmY3hOa1V3ZkdENDluZ0k1SGZhcTUwd3c4WGZMd0psckR6akczdzNPSHRYTzFBZEltczJuRng2cmc4bk84cWhnMnFwUU01WFJnLTQ1SDdIQUFB

View File

@ -0,0 +1,17 @@
# deployment/manifests/service.yaml
apiVersion: v1
kind: Service
metadata:
name: vector-api-service
labels:
app: vector-api
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 8000
protocol: TCP
name: http
selector:
app: vector-api

4274
vector/poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

41
vector/pyproject.toml Normal file
View File

@ -0,0 +1,41 @@
[tool.poetry]
name = "vector-api"
version = "1.0.0"
description = "Vector DB API with AI/ML capabilities"
authors = ["Developer <dev@example.com>"]
packages = [{include = "app"}]
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "0.115.9"
uvicorn = {extras = ["standard"], version = "^0.34.3"}
pydantic = "^2.11.7"
python-dotenv = "^1.1.0"
python-multipart = "^0.0.20"
aiohttp = "^3.12.13"
requests = "^2.32.4"
numpy = "^2.3.0"
pandas = "^2.3.0"
tokenizers = "^0.21.1"
transformers = "^4.52.4"
huggingface-hub = "^0.33.0"
sentence-transformers = "^4.1.0"
chromadb = "^1.0.12"
hnswlib = "^0.8.0"
duckdb = "^1.3.0"
anthropic = "^0.54.0"
typing-extensions = "^4.14.0"
sqlalchemy = "^2.0.41"
torch = {version = "^2.7.1+cpu", source = "pytorch-cpu"}
torchvision = {version = "^0.22.1+cpu", source = "pytorch-cpu"}
torchaudio = {version = "^2.7.1+cpu", source = "pytorch-cpu"}
starlette = ">=0.40.0,<0.46.0"
[[tool.poetry.source]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
priority = "supplemental"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

376
vector/setup.sh Executable file
View File

@ -0,0 +1,376 @@
#!/bin/bash
# setup.sh - Poetry 기반 Vector DB API 의존성 설치 스크립트 (홈 디렉토리 사용)
set -e
# 색상 설정
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# 옵션 기본값
SKIP_POETRY_INSTALL=false
SKIP_PYTHON311_CHECK=false
FORCE_REINSTALL=false
RUN_AFTER_INSTALL=false
# 함수 정의
log_info() { echo -e "${CYAN} $1${NC}"; }
log_success() { echo -e "${GREEN}$1${NC}"; }
log_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
log_error() { echo -e "${RED}$1${NC}"; }
log_step() { echo -e "\n${BLUE}🔧 $1${NC}"; }
# 사용법 표시
show_usage() {
echo "사용법: $0 [옵션]"
echo ""
echo "옵션:"
echo " --skip-poetry-install Poetry 설치 건너뛰기"
echo " --skip-python311-check Python 3.11 확인 건너뛰기"
echo " --force-reinstall 기존 환경 제거 후 재설치"
echo " --run-after-install 설치 후 앱 자동 실행"
echo " --help 이 도움말 표시"
}
# 파라미터 파싱
while [[ $# -gt 0 ]]; do
case $1 in
--skip-poetry-install)
SKIP_POETRY_INSTALL=true
shift
;;
--skip-python311-check)
SKIP_PYTHON311_CHECK=true
shift
;;
--force-reinstall)
FORCE_REINSTALL=true
shift
;;
--run-after-install)
RUN_AFTER_INSTALL=true
shift
;;
--help)
show_usage
exit 0
;;
*)
log_error "알 수 없는 옵션: $1"
show_usage
exit 1
;;
esac
done
echo "========================================================"
echo "🚀 Poetry 기반 Vector DB API 의존성 설치 (홈 디렉토리)"
echo "========================================================"
echo "설치 시작: $(date)"
echo ""
INSTALL_START=$(date +%s)
# =============================================================================
# 1단계: Poetry 설치 확인
# =============================================================================
log_step "1단계: Poetry 설치 확인"
if [ "$SKIP_POETRY_INSTALL" = true ]; then
log_info "Poetry 설치 확인을 건너뜁니다."
elif command -v poetry &> /dev/null; then
POETRY_VERSION=$(poetry --version)
log_success "Poetry 이미 설치됨: $POETRY_VERSION"
else
if [ -f /.dockerenv ]; then
log_error "Docker 환경에서 Poetry를 찾을 수 없습니다."
log_error "--skip-poetry-install 옵션을 제거하세요."
exit 1
fi
log_info "Poetry 설치 중..."
sudo apt update
sudo apt install -y python3-poetry
# PATH 확인
export PATH="$HOME/.local/bin:$PATH"
if command -v poetry &> /dev/null; then
log_success "Poetry 설치 완료: $(poetry --version)"
else
log_error "Poetry 설치 실패"
exit 1
fi
fi
# =============================================================================
# 2단계: Python 3.11 확인
# =============================================================================
log_step "2단계: Python 3.11 환경 확인"
if [ "$SKIP_PYTHON311_CHECK" = false ]; then
if command -v python3.11 &> /dev/null; then
PYTHON311_VERSION=$(python3.11 --version)
log_success "Python 3.11 발견: $PYTHON311_VERSION"
else
log_warning "Python 3.11이 설치되어 있지 않습니다."
log_info "Python 3.11 설치 중..."
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3.11-dev
if command -v python3.11 &> /dev/null; then
log_success "Python 3.11 설치 완료: $(python3.11 --version)"
else
log_error "Python 3.11 설치 실패"
exit 1
fi
fi
fi
# =============================================================================
# 3단계: Poetry 프로젝트 초기화
# =============================================================================
log_step "3단계: Poetry 프로젝트 초기화"
# 기존 환경 제거 (강제 재설치 옵션)
if [ "$FORCE_REINSTALL" = true ]; then
log_info "기존 Poetry 환경 제거 중..."
poetry env remove --all 2>/dev/null || true
rm -f poetry.lock pyproject.toml 2>/dev/null || true
fi
# pyproject.toml이 없는 경우 초기화
if [ ! -f "pyproject.toml" ]; then
log_info "Poetry 프로젝트 초기화 중..."
# 비대화형 초기화
cat > pyproject.toml << 'EOF'
[tool.poetry]
name = "vector-api"
version = "1.0.0"
description = "Vector DB API with AI/ML capabilities"
authors = ["Developer <dev@example.com>"]
packages = [{include = "app"}]
[tool.poetry.dependencies]
python = "^3.11"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
EOF
log_success "pyproject.toml 생성 완료"
else
log_success "기존 pyproject.toml 사용"
fi
# =============================================================================
# 4단계: Poetry 가상환경 설정 (홈 디렉토리 사용)
# =============================================================================
log_step "4단계: Poetry 가상환경 설정 (홈 디렉토리 사용)"
log_info "Poetry 가상환경을 홈 디렉토리로 설정 중..."
# 홈 디렉토리에 Poetry 관련 디렉토리 생성
mkdir -p ~/.cache/pypoetry/venvs ~/.cache/pypoetry/cache
# Poetry 설정 - 가상환경을 홈 디렉토리로 이동
poetry config virtualenvs.in-project false
poetry config virtualenvs.create true
poetry config virtualenvs.path ~/.cache/pypoetry/venvs
poetry config cache-dir ~/.cache/pypoetry/cache
# 설정 확인
log_info "Poetry 설정 확인:"
poetry config --list | grep -E "(virtualenvs|cache)"
# 권한 확인
log_info "디렉토리 권한 확인:"
ls -la ~/.cache/pypoetry/ 2>/dev/null || log_info "디렉토리가 아직 생성되지 않음"
log_success "Poetry 가상환경 설정 완료"
# =============================================================================
# 5단계: Python 3.11 환경 설정
# =============================================================================
log_step "5단계: Python 3.11 환경 설정"
log_info "Poetry가 Python 3.11을 사용하도록 설정 중..."
poetry env use python3.11
# 환경 정보 확인
POETRY_ENV_INFO=$(poetry config virtualenvs.path)
log_success "Poetry 가상환경 기본 경로: $POETRY_ENV_INFO"
# =============================================================================
# 6단계: 소스 우선순위 사전 설정 (중요!)
# =============================================================================
log_step "6단계: 소스 우선순위 사전 설정"
log_info "PyPI를 기본 소스로 확인 중..."
# PyPI는 기본적으로 존재하므로 확인만 함
if poetry source show | grep -q "pypi"; then
log_success "PyPI 소스 확인됨"
else
log_warning "PyPI 소스가 없습니다 (비정상)"
fi
log_success "소스 우선순위 사전 설정 완료"
# =============================================================================
# 7단계: 기본 웹 프레임워크 설치
# =============================================================================
log_step "7단계: 기본 웹 프레임워크 설치"
log_info "FastAPI 및 관련 패키지 설치 중..."
poetry add "fastapi==0.115.9"
poetry add uvicorn[standard] pydantic python-dotenv python-multipart
log_success "웹 프레임워크 설치 완료"
# =============================================================================
# 8단계: HTTP 클라이언트 설치
# =============================================================================
log_step "8단계: HTTP 클라이언트 설치"
log_info "HTTP 클라이언트 설치 중..."
poetry add aiohttp requests
log_success "HTTP 클라이언트 설치 완료"
# =============================================================================
# 9단계: 데이터 처리 라이브러리 설치
# =============================================================================
log_step "9단계: 데이터 처리 라이브러리 설치"
log_info "NumPy, Pandas 설치 중..."
poetry add numpy pandas
log_success "데이터 처리 라이브러리 설치 완료"
# =============================================================================
# 10단계: AI/ML 기초 라이브러리 설치
# =============================================================================
log_step "10단계: AI/ML 기초 라이브러리 설치"
log_info "Tokenizers, Transformers 설치 중..."
poetry add tokenizers transformers huggingface-hub
log_success "AI/ML 기초 라이브러리 설치 완료"
# =============================================================================
# 11단계: Sentence Transformers 설치
# =============================================================================
log_step "11단계: Sentence Transformers 설치"
log_info "Sentence Transformers 설치 중..."
poetry add sentence-transformers
log_success "Sentence Transformers 설치 완료"
# =============================================================================
# 12단계: Vector DB 라이브러리 설치
# =============================================================================
log_step "12단계: Vector DB 라이브러리 설치"
log_info "ChromaDB, HNSW, DuckDB 설치 중..."
poetry add chromadb hnswlib duckdb
log_success "Vector DB 라이브러리 설치 완료"
# =============================================================================
# 13단계: Claude API 라이브러리 설치
# =============================================================================
log_step "13단계: Claude API 라이브러리 설치"
log_info "Anthropic Claude API 라이브러리 설치 중..."
poetry add anthropic
log_success "Claude API 라이브러리 설치 완료"
# =============================================================================
# 14단계: 기타 필수 라이브러리 설치
# =============================================================================
log_step "14단계: 기타 필수 라이브러리 설치"
log_info "기타 필수 라이브러리 설치 중..."
poetry add typing-extensions sqlalchemy
log_success "기타 필수 라이브러리 설치 완료"
# =============================================================================
# 15단계: PyTorch CPU 버전 설치
# =============================================================================
log_step "15단계: PyTorch CPU 버전 설치"
log_info "PyTorch CPU 버전 소스 추가 중..."
poetry source add pytorch-cpu https://download.pytorch.org/whl/cpu --priority=supplemental
log_info "PyTorch CPU 버전 설치 중..."
poetry add torch --source pytorch-cpu
poetry add torchvision --source pytorch-cpu
poetry add torchaudio --source pytorch-cpu
log_success "PyTorch CPU 버전 설치 완료"
# =============================================================================
# 16단계: Starlette 버전 호환성 확인
# =============================================================================
log_step "16단계: Starlette 버전 호환성 확인"
log_info "Starlette 호환 버전 설치 중..."
poetry add "starlette>=0.40.0,<0.46.0"
log_success "Starlette 호환성 확인 완료"
# 종료 시간 계산
INSTALL_END=$(date +%s)
INSTALL_TIME=$((INSTALL_END - INSTALL_START))
MINUTES=$((INSTALL_TIME / 60))
SECONDS=$((INSTALL_TIME % 60))
echo ""
echo "🎉 Poetry 기반 Vector DB API 설치 완료!"
echo "========================================================"
echo "⏱️ 총 설치 시간: ${MINUTES}${SECONDS}"
echo "📦 설치된 패키지 수: $(poetry show | wc -l)"
echo "🐍 Python 환경: $(poetry run python --version)"
echo "📁 환경 경로: $(poetry env info --path)"
echo ""
echo "🚀 다음 단계:"
echo " 1. 가상환경 활성화:"
echo " poetry shell"
echo ""
echo " 2. 애플리케이션 실행:"
echo " poetry run python app/main.py"
echo ""
echo " 3. 개발 서버 실행:"
echo " poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
echo ""
echo "💡 유용한 명령어:"
echo " - poetry show # 설치된 패키지 확인"
echo " - poetry show --tree # 의존성 트리 확인"
echo " - poetry add [패키지명] # 새 패키지 추가"
echo " - poetry update # 패키지 업데이트"
echo " - poetry env info # 환경 정보 확인"
echo ""
# 자동 실행 옵션
if [ "$RUN_AFTER_INSTALL" = true ]; then
if [ -f "app/main.py" ]; then
log_info "앱 자동 실행 중..."
poetry run python app/main.py
else
log_warning "app/main.py 파일이 없어 자동 실행을 건너뜁니다."
fi
fi
log_success "설치 스크립트 완료! 🎊"