release
This commit is contained in:
@@ -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에서 상세 확인
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Executable
+214
@@ -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)"
|
||||
Executable
+251
@@ -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)"
|
||||
Executable
+237
@@ -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 설정이 완료되었습니다!"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
Executable
+212
@@ -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 "✅ 환경 설정이 완료되었습니다!"
|
||||
Reference in New Issue
Block a user