From 5f121638a7214110202486de0fcd3718867214ad Mon Sep 17 00:00:00 2001 From: shuim Date: Sat, 28 Mar 2026 11:45:37 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- deploy/NAS-SETUP-GUIDE.md | 274 ++++++++++++++++++++++++++++++++++++++ frontend/src/lib/api.ts | 179 +++++++++++++++++++++++++ 3 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 deploy/NAS-SETUP-GUIDE.md create mode 100644 frontend/src/lib/api.ts diff --git a/.gitignore b/.gitignore index 69aa228..4823b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/deploy/NAS-SETUP-GUIDE.md b/deploy/NAS-SETUP-GUIDE.md new file mode 100644 index 0000000..35cfe54 --- /dev/null +++ b/deploy/NAS-SETUP-GUIDE.md @@ -0,0 +1,274 @@ +# Auto-Stock NAS 배포 가이드 (Synology DS1517) + +## 환경 정보 +- NAS: Synology DS1517+ (16GB RAM) +- OS: DSM 7.x (Linux 3.10.108, x86_64) +- Docker: 24.0.2 +- Docker Compose: v2.20.1 (호스트) / v2.20.3 (Jenkins 내부) +- Cloudflare Tunnel 사용 중 + +--- + +## 1. 디렉토리 구조 + +``` +/volume1/docker/ +├── autostock/ # 앱 배포 디렉토리 +│ ├── .env # 환경변수 (Git에 없음, 수동 관리) +│ ├── docker-compose.yml +│ ├── docker/ +│ ├── backend/ +│ ├── mcp-server/ +│ ├── frontend/ +│ ├── logs/ +│ └── backups/ +├── gitea/ # Gitea 데이터 +├── jenkins/ # Jenkins 데이터 +└── cicd/ # CI/CD 인프라 설정 +``` + +--- + +## 2. 초기 설정 + +### 2.1 디렉토리 생성 +```bash +sudo mkdir -p /volume1/docker/autostock +sudo mkdir -p /volume1/docker/gitea +sudo mkdir -p /volume1/docker/jenkins +sudo chown -R shuim:users /volume1/docker/autostock /volume1/docker/gitea +sudo chown -R 1000:1000 /volume1/docker/jenkins +``` + +### 2.2 Gitea 설치 +```bash +docker run -d --name gitea --restart unless-stopped -p 23000:3000 -v /volume1/docker/gitea:/data -e TZ=Asia/Seoul -e "GITEA__server__ROOT_URL=https://gitea.shium.net" gitea/gitea:latest +``` + +- Cloudflare Tunnel: `gitea.shium.net:443 → localhost:23000` +- 초기 설정: https://gitea.shium.net 접속 → SQLite3 선택 → 관리자 생성 +- 저장소 생성: `auto-stock` (Private) + +### 2.3 Jenkins 설치 + +```bash +docker run -d --name jenkins --restart unless-stopped -p 21080:8080 -p 21000:50000 -v /volume1/docker/jenkins:/var/jenkins_home -v /var/run/docker.sock:/var/run/docker.sock -v /volume1/docker/autostock:/volume1/docker/autostock -e TZ=Asia/Seoul -e JAVA_OPTS="-Xmx512m -Xms256m" --user root jenkins/jenkins:lts-jdk17 +``` + +**중요**: `-v /volume1/docker/autostock:/volume1/docker/autostock` 마운트 필수 (Jenkins에서 배포 디렉토리 접근) + +초기 비밀번호 확인: +```bash +docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword +``` + +### 2.4 Jenkins 컨테이너 내부 도구 설치 + +**Python3 + rsync + curl:** +```bash +docker exec -u root jenkins bash -c "apt-get update && apt-get install -y python3 rsync curl" +``` +- `systemd` 관련 에러 발생하지만 무시 가능 (Python3는 정상 설치됨) + +**Docker CLI (바이너리 직접 설치):** +```bash +docker exec -u root jenkins bash -c "curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz -o /tmp/docker.tgz && tar xz -C /tmp -f /tmp/docker.tgz && cp /tmp/docker/docker /usr/local/bin/docker && chmod +x /usr/local/bin/docker && rm -rf /tmp/docker /tmp/docker.tgz && docker --version" +``` +- `docker.io` apt 패키지는 Synology 커널과 호환되지 않음 (systemd 의존성 충돌) +- 바이너리 직접 설치로 해결 + +**Docker Compose (독립 바이너리):** +```bash +docker exec -u root jenkins bash -c "curl -fsSL -o /tmp/dc https://github.com/docker/compose/releases/download/v2.20.3/docker-compose-linux-x86_64 && mv /tmp/dc /usr/local/bin/docker-compose && chmod +x /usr/local/bin/docker-compose && docker-compose version" +``` +- NAS 호스트의 `docker-compose`를 `docker cp`로 복사하면 GLIBC 버전 불일치 발생 +- GitHub에서 독립 바이너리 다운로드로 해결 +- `curl -o /usr/local/bin/docker-compose` 직접 쓰면 `client returned ERROR on write` 발생 → `/tmp/dc`에 먼저 받고 `mv`로 해결 + +**Docker 소켓 권한:** +```bash +docker exec -u root jenkins bash -c "chmod 666 /var/run/docker.sock" +``` + +### 2.5 설치 검증 +```bash +docker exec jenkins bash -c "python3 --version && docker --version && docker-compose version" +``` +예상 출력: +``` +Python 3.13.x +Docker version 24.0.7, build afdd53b +Docker Compose version v2.20.3 +``` + +--- + +## 3. Jenkins 파이프라인 설정 + +### 3.1 Pipeline 프로젝트 생성 +1. Jenkins 대시보드 → **+ 새로운 Item** +2. 이름: `auto-stock`, 타입: **Pipeline** → OK + +### 3.2 Pipeline 설정 (구성 페이지) +1. Pipeline 섹션 → Definition: **Pipeline script from SCM** +2. SCM: **Git** +3. Repository URL: `https://gitea.shium.net/shuim/auto-stock.git` +4. Credentials: **Add** → Username/Password (Gitea 계정) +5. Branch: `*/main` +6. Script Path: `Jenkinsfile` +7. **Lightweight checkout: 반드시 체크 해제** (체크 시 `fatal: not in a git directory` 에러) +8. 저장 + +### 3.3 Gitea Webhook (자동 빌드) +Gitea 저장소 → Settings → Webhooks → Add Webhook: +- URL: `http://localhost:21080/gitea-webhook/post` +- Content Type: `application/json` +- Trigger: Push Events + +--- + +## 4. 로컬 Git 설정 + +```bash +cd /Users/commylife/myproject/java/workspace/auto-stock +git remote add nas https://gitea.shium.net/shuim/auto-stock.git +git push nas main +``` + +배포 흐름: +``` +로컬 코드 수정 → git push nas main → Jenkins 자동 빌드 → NAS 배포 +``` + +--- + +## 5. .env 설정 + +`/volume1/docker/autostock/.env` 파일을 수동 생성하고 비밀번호를 설정: + +```bash +# NAS SSH 접속 후 +nano /volume1/docker/autostock/.env +``` + +필수 변경 항목: +- `POSTGRES_PASSWORD`: 강력한 비밀번호 +- `REDIS_PASSWORD`: 강력한 비밀번호 +- `SECRET_KEY`: 64자 이상 랜덤 문자열 +- `API_KEY`, `MCP_API_KEY`: API 인증키 +- `ANTHROPIC_API_KEY`: Claude API 키 +- `KIS_*`: 한국투자증권 API 키 +- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`: 텔레그램 알림 + +--- + +## 6. 수동 첫 배포 + +Jenkins 파이프라인이 .env 부재로 실패할 경우: + +```bash +cd /volume1/docker/autostock +# .env 파일이 준비된 상태에서 +docker-compose up -d +``` + +--- + +## 7. 트러블슈팅 + +### 에러: `python3: not found` +**원인**: Jenkins 컨테이너에 Python 미설치 +**해결**: +```bash +docker exec -u root jenkins bash -c "apt-get update && apt-get install -y python3" +``` + +### 에러: `docker.io` 설치 시 systemd dpkg 에러 +**원인**: Synology 커널(3.10.108)이 최신 systemd와 호환되지 않음 +**해결**: `docker.io` 패키지 대신 Docker CLI 바이너리 직접 설치 +```bash +docker exec -u root jenkins bash -c "curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz -o /tmp/docker.tgz && tar xz -C /tmp -f /tmp/docker.tgz && cp /tmp/docker/docker /usr/local/bin/docker && chmod +x /usr/local/bin/docker && rm -rf /tmp/docker /tmp/docker.tgz" +``` + +### 에러: `unknown shorthand flag: 'f' in -f` (docker compose) +**원인**: Jenkins 내부 Docker CLI에 `compose` 플러그인이 없음. `docker compose`(v2 플러그인)가 아닌 `docker-compose`(독립 바이너리) 사용 필요 +**해결**: docker-compose 독립 바이너리 설치 (섹션 2.4 참조) + +### 에러: `docker-compose: not found` +**원인**: NAS 호스트의 docker-compose를 `docker cp`로 복사하면 GLIBC 버전 불일치 +**해결**: GitHub에서 독립 바이너리 직접 다운로드 +```bash +docker exec -u root jenkins bash -c "curl -fsSL -o /tmp/dc https://github.com/docker/compose/releases/download/v2.20.3/docker-compose-linux-x86_64 && mv /tmp/dc /usr/local/bin/docker-compose && chmod +x /usr/local/bin/docker-compose" +``` + +### 에러: `curl: (23) client returned ERROR on write of 1369 bytes` +**원인**: `/usr/local/bin/`에 직접 쓸 때 권한/공간 문제 +**해결**: `/tmp`에 먼저 다운로드 후 `mv`로 이동 + +### 에러: `fatal: not in a git directory` +**원인**: Pipeline 설정에서 **Lightweight checkout**이 활성화되어 있으면 git workspace 없이 Jenkinsfile을 읽으려 함 +**해결**: Pipeline 구성 → **Lightweight checkout 체크 해제** → 저장 + +### 에러: `mkdir: cannot create directory '/volume1': Permission denied` +**원인**: Jenkins 컨테이너에 NAS 볼륨이 마운트되지 않음 +**해결**: Jenkins 컨테이너 재생성 시 `-v /volume1/docker/autostock:/volume1/docker/autostock` 볼륨 마운트 추가 + +### 에러: Gitea 500 Internal Server Error +**원인**: 이전 설정 데이터가 꼬진 상태 +**해결**: +```bash +docker rm -f gitea +sudo rm -rf /volume1/docker/gitea/* +# 다시 docker run으로 재생성 +``` + +### 에러: `git push nas main` → `Failed to connect to port 3001` +**원인**: Cloudflare Tunnel 사용 시 직접 포트 접근 불가 +**해결**: HTTPS URL 사용 +```bash +git remote remove nas +git remote add nas https://gitea.shium.net/shuim/auto-stock.git +git push nas main +``` + +--- + +## 8. Jenkins 컨테이너 재시작 후 복구 + +Jenkins 컨테이너가 재시작되면 바이너리 설치된 Docker CLI와 docker-compose가 유지됩니다 (볼륨 아님, 컨테이너 레이어에 저장). 단, 컨테이너를 **삭제 후 재생성**하면 재설치 필요: + +```bash +# 전체 재설치 (컨테이너 삭제 후 재생성 시) +docker exec -u root jenkins bash -c "apt-get update && apt-get install -y python3 rsync curl && curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz -o /tmp/docker.tgz && tar xz -C /tmp -f /tmp/docker.tgz && cp /tmp/docker/docker /usr/local/bin/docker && chmod +x /usr/local/bin/docker && rm -rf /tmp/docker /tmp/docker.tgz && curl -fsSL -o /tmp/dc https://github.com/docker/compose/releases/download/v2.20.3/docker-compose-linux-x86_64 && mv /tmp/dc /usr/local/bin/docker-compose && chmod +x /usr/local/bin/docker-compose && chmod 666 /var/run/docker.sock" +``` + +--- + +## 9. 메모리 배분 (docker-compose.yml) + +| 서비스 | 메모리 제한 | +|--------|-----------| +| PostgreSQL | 1GB | +| Backend | 768MB | +| RabbitMQ | 512MB | +| Redis | 256MB (maxmemory 200MB) | +| MCP Server | 256MB | +| Frontend (Nginx) | 64MB | +| Gitea | ~200MB | +| Jenkins | ~1GB (빌드 시) | +| **합계** | ~4.1GB / 16GB | + +--- + +## 10. Cloudflare Tunnel 라우팅 + +| 도메인 | 포트 | 용도 | +|--------|------|------| +| gitea.shium.net:443 | localhost:23000 | Gitea (Git 웹 UI) | +| jenkins.shium.net:443 | localhost:21080 | Jenkins (CI/CD) | +| stock.shium.net:443 | localhost:3000 | Frontend | +| stock.shium.net/api | localhost:8000 | Backend API | + +--- + +*마지막 업데이트: 2026-03-27* diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..458f00e --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,179 @@ +import axios, { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios' +import { config } from '../config' + +// 재시도 설정 +interface RetryConfig extends InternalAxiosRequestConfig { + retryCount?: number + retryMaxCount?: number +} + +// 오프라인 상태 추적 +let isOffline = false + +// 온/오프라인 이벤트 리스너 등록 +if (typeof window !== 'undefined') { + window.addEventListener('online', () => { + isOffline = false + console.log('✅ Connection restored') + showOfflineIndicator(false) + }) + window.addEventListener('offline', () => { + isOffline = true + console.log('❌ Connection lost') + showOfflineIndicator(true) + }) +} + +// 오프라인 표시기 UI 업데이트 +function showOfflineIndicator(offline: boolean) { + // 오프라인 배너 표시 (필요시 구현) + if (offline) { + console.warn('⚠️ You are offline. Some features may not work properly.') + } +} + +// 재시도 가능 여부 판단 +function isRetryableError(error: AxiosError): boolean { + if (!error.response) { + // 네트워크 오류 (재시도 가능) + return true + } + + const status = error.response.status + // 429 (Too Many Requests), 500+, 503 (Service Unavailable) 재시도 가능 + return status === 429 || status >= 500 +} + +// 지수 백오프 계산 +function getBackoffDelay(retryCount: number): number { + // 1000ms, 2000ms, 4000ms (3회 재시도) + return Math.min(1000 * Math.pow(2, retryCount), 8000) +} + +const api = axios.create({ + baseURL: config.apiUrl, + timeout: 15000, // 기본 타임아웃 15초 + headers: { + 'Content-Type': 'application/json', + }, +}) + +// 요청 인터셉터 - 토큰 추가 및 재시도 카운트 초기화 +api.interceptors.request.use( + (config: RetryConfig) => { + // API 키가 있으면 X-API-Key 헤더에 추가 + const apiKey = localStorage.getItem('api_key') + if (apiKey && config.headers) { + config.headers['X-API-Key'] = apiKey + } + + // 재시도 카운트 초기화 (첫 요청인 경우) + if (!config.retryCount) { + config.retryCount = 0 + config.retryMaxCount = 3 + } + + return config + }, + (error: AxiosError) => { + return Promise.reject(error) + } +) + +// 응답 인터셉터 - 글로벌 에러 처리 및 재시도 로직 +api.interceptors.response.use( + (response: AxiosResponse) => response, + async (error: AxiosError) => { + const config = error.config as RetryConfig + + // 재시도 설정이 없으면 기본값 설정 + if (!config) { + return Promise.reject(error) + } + + if (!config.retryCount) { + config.retryCount = 0 + config.retryMaxCount = 3 + } + + // 재시도 가능 여부 확인 + const maxCount = config.retryMaxCount ?? 3 + const shouldRetry = + isRetryableError(error) && + config.retryCount < maxCount + + if (shouldRetry) { + config.retryCount += 1 + const delay = getBackoffDelay(config.retryCount - 1) + console.log( + `🔄 Retrying request (${config.retryCount}/${config.retryMaxCount}) ` + + `after ${delay}ms: ${config.method?.toUpperCase()} ${config.url}` + ) + + // 지수 백오프로 재시도 + await new Promise((resolve) => setTimeout(resolve, delay)) + return api(config) + } + + // 에러 상태코드별 처리 + if (error.response) { + const status = error.response.status + const message = (error.response.data as any)?.message || error.message + + switch (status) { + case 401: + // 인증 실패 - API 키 제거 후 새로고침 (로그인 화면으로) + console.warn('Authentication failed:', config.url) + localStorage.removeItem('api_key') + window.location.reload() + break + + case 403: + // 권한 부족 + console.error('🚫 Permission denied:', message) + // 필요시 권한 오류 토스트 표시 + break + + case 429: + // 요청 제한 + console.warn('⏱️ Rate limited. Please try again later.') + const retryAfter = error.response.headers['retry-after'] + if (retryAfter) { + console.log(`Retry after: ${retryAfter}s`) + } + break + + case 500: + case 502: + case 503: + case 504: + // 서버 오류 (재시도 완료됨) + console.error(`❌ Server error (${status}): ${message}`) + break + + default: + console.error(`❌ Error (${status}): ${message}`) + } + } else if (error.request) { + // 요청은 보내졌지만 응답을 받지 못함 (네트워크 오류) + if (isOffline) { + console.error('❌ No response from server. You appear to be offline.') + } else { + console.error('❌ No response from server. Network error or server is unreachable.') + } + } else { + // 요청 설정 중 오류 + console.error('❌ Error setting up request:', error.message) + } + + return Promise.reject(error) + } +) + +export const getUsStockPrice = (ticker: string) => + api.get(`/market-data/overseas/price/${ticker}`) + +export const getUsStockChart = (ticker: string, period: string = 'D') => + api.get(`/market-data/overseas/chart/${ticker}`, { params: { period } }) + +export default api