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
+415
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
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
)
+6
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
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
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)"
+237
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 설정이 완료되었습니다!"
@@ -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
+212
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 "✅ 환경 설정이 완료되었습니다!"