diff --git a/run.sh b/run.sh index 7425a23..f97c469 100755 --- a/run.sh +++ b/run.sh @@ -33,11 +33,12 @@ check_docker() { # 서비스 중지 stop_services() { - print_step "0. 현재 사용 중 이미지 stop, down" + print_step "0. 현재 사용 중인 서비스 중지" docker compose stop || true docker compose down || true } +# Docker 리소스 정리 cleanup_docker() { print_step "0.5. 사용하지 않는 Docker 리소스 정리" @@ -53,37 +54,82 @@ cleanup_docker() { echo -e "${GREEN}Docker 정리 완료${NC}" } -# 이미지 업데이트 +# 모든 이미지 업데이트 (API 포함) update_images() { - print_step "1. 외부 이미지 업데이트 (fe, nginx)..." - docker compose pull fe nginx -} - -# API 빌드 -build_api() { - print_step "2. 로컬 이미지 빌드 (api)..." - docker compose build api + print_step "1. Docker Hub에서 최신 이미지 다운로드 중..." + + # 모든 서비스의 이미지를 Docker Hub에서 최신 버전으로 pull + docker compose pull + + echo -e "${GREEN}모든 이미지 업데이트 완료${NC}" } # 서비스 시작 start_services() { - print_step "3. 서비스 재시작..." + print_step "2. 서비스 시작 중..." docker compose up -d + + echo -e "${GREEN}모든 서비스가 시작되었습니다${NC}" +} + +# 서비스 상태 확인 +check_services() { + print_step "3. 서비스 상태 확인" + + # 잠시 대기 (서비스 시작 시간 확보) + sleep 5 + + # 실행 중인 컨테이너 확인 + echo -e "${YELLOW}실행 중인 컨테이너:${NC}" + docker compose ps + + # 각 서비스 헬스체크 + echo -e "\n${YELLOW}서비스 헬스체크:${NC}" + + # API 서비스 확인 + if curl -f http://localhost:8080/health &>/dev/null; then + echo -e "✅ API 서비스: ${GREEN}정상${NC}" + else + echo -e "❌ API 서비스: ${RED}응답 없음${NC}" + fi + + # Frontend 서비스 확인 (포트 3000) + if curl -f http://localhost:3000 &>/dev/null; then + echo -e "✅ Frontend 서비스: ${GREEN}정상${NC}" + else + echo -e "❌ Frontend 서비스: ${RED}응답 없음${NC}" + fi + + # Nginx 서비스 확인 (포트 80) + if curl -f http://localhost &>/dev/null; then + echo -e "✅ Nginx 서비스: ${GREEN}정상${NC}" + else + echo -e "❌ Nginx 서비스: ${RED}응답 없음${NC}" + fi } # 메인 실행 로직 main() { set -e # 스크립트 실행 중 오류 발생 시 종료 + print_step "Velog Dashboard V2 배포 스크립트 시작" + check_docker stop_services cleanup_docker update_images - build_api start_services + check_services - print_step "모든 작업이 완료되었습니다! 로그 모니터링을 시작합니다." - sleep 1 + print_step "🎉 모든 작업이 완료되었습니다!" + echo -e "${GREEN}서비스 접속 정보:${NC}" + echo -e "• 메인 사이트: ${YELLOW}http://localhost${NC}" + echo -e "• API 서버: ${YELLOW}http://localhost:8080${NC}" + echo -e "• Frontend: ${YELLOW}http://localhost:3000${NC}" + echo -e "• API Health Check: ${YELLOW}http://localhost:8080/health${NC}" + + echo -e "\n${YELLOW}로그 모니터링을 시작합니다... (Ctrl+C로 종료)${NC}" + sleep 2 docker compose logs -f } diff --git a/src/app.ts b/src/app.ts index d3142bb..d7d8548 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,15 +11,12 @@ import router from '@/routes'; import { NotFoundError } from '@/exception'; import { options } from '@/configs/swagger.config'; -import { initSentry, getSentryStatus } from '@/configs/sentry.config'; -import { initCache, getCacheStatus } from '@/configs/cache.config'; +import { getSentryStatus } from '@/configs/sentry.config'; +import { getCacheStatus } from '@/configs/cache.config'; import { errorHandlingMiddleware } from '@/middlewares/errorHandling.middleware'; dotenv.config(); -initSentry(); // Sentry 초기화 -initCache(); // Redis 캐시 초기화 - const app: Application = express(); // 실제 클라이언트 IP를 알기 위한 trust proxy 설정 @@ -33,9 +30,10 @@ app.use(express.urlencoded({ extended: true, limit: '10mb' })); app.use( cors({ - origin: process.env.NODE_ENV === 'production' - ? process.env.ALLOWED_ORIGINS?.split(',').map(origin => origin.trim()) - : 'http://localhost:3000', + origin: + process.env.NODE_ENV === 'production' + ? process.env.ALLOWED_ORIGINS?.split(',').map((origin) => origin.trim()) + : 'http://localhost:3000', methods: ['GET', 'POST'], allowedHeaders: ['Content-Type', 'Authorization', 'Cookie', 'access_token', 'refresh_token'], credentials: true, @@ -52,8 +50,8 @@ app.get('/health', async (req: Request, res: Response) => { environment: process.env.NODE_ENV, services: { sentry: false, - cache: false - } + cache: false, + }, }; // Sentry 상태 확인 @@ -89,4 +87,4 @@ app.use((req: Request, res: Response, next: NextFunction) => { app.use(errorHandlingMiddleware); -export default app; \ No newline at end of file +export default app; diff --git a/src/configs/db.config.ts b/src/configs/db.config.ts index 6a6e954..c323f73 100644 --- a/src/configs/db.config.ts +++ b/src/configs/db.config.ts @@ -16,7 +16,7 @@ const poolConfig: pg.PoolConfig = { port: Number(process.env.POSTGRES_PORT), max: 10, // 최대 연결 수 idleTimeoutMillis: 30000, // 연결 유휴 시간 (30초) - connectionTimeoutMillis: 5000, // 연결 시간 초과 (5초) + connectionTimeoutMillis: 10000, // 연결 시간 초과 (10초) }; if (process.env.NODE_ENV === 'production') { @@ -27,15 +27,65 @@ if (process.env.NODE_ENV === 'production') { const pool = new Pool(poolConfig); -(async () => { - const client = await pool.connect(); +/** + * 데이터베이스 연결을 초기화하고 TimescaleDB 확장을 보장 + * 최대 3회 재시도하며, 실패 시 서버를 종료 + * + * @throws 연결에 3회 실패하면 서버가 종료 + * @returns {Promise} 연결 및 확장 완료 시 resolve되는 프로미스 + */ +export async function initializeDatabase(): Promise { + const maxRetries = 3; + let delay = 800; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + logger.info(`데이터베이스 연결 시도 ${attempt}/${maxRetries}`); + + const client = await pool.connect(); + + try { + // 연결 테스트 + await client.query('SELECT 1'); + logger.info('데이터베이스 연결 성공'); + + // TimescaleDB 확장 (필수) + await client.query('CREATE EXTENSION IF NOT EXISTS timescaledb;'); + logger.info('TimescaleDB 확장 성공'); + + return; // 성공 + } finally { + client.release(); + } + } catch (error) { + logger.error(`데이터베이스 연결 실패 (시도 ${attempt}/${maxRetries}):`, error); + + if (attempt === maxRetries) { + logger.error('데이터베이스 연결에 완전히 실패했습니다. 서버를 종료합니다.'); + process.exit(1); // 연결 실패시 서버 종료 + } + + logger.info(`${delay}ms 후 재시도...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + delay = Math.floor(delay * 1.5); + } + } +} + +/** + * 데이터베이스 커넥션 풀을 안정적으로 직접 종료하는 함수 + * + * @throws 데이터베이스 연결 종료에 실패할 경우 에러 + * @returns {Promise} 데이터베이스 연결이 종료되면 resolve되는 프로미스 + */ +export async function closeDatabase(): Promise { try { - await client.query('CREATE EXTENSION IF NOT EXISTS timescaledb;'); - logger.info('TimescaleDB 확장 성공'); + await pool.end(); + logger.info('데이터베이스 연결이 정상적으로 종료되었습니다.'); } catch (error) { - logger.error('TimescaleDB 초기화 실패 : ', error); - } finally { - client.release(); + logger.error('데이터베이스 연결 종료 중 오류 발생:', error); + throw error; } -})(); +} + export default pool; diff --git a/src/index.ts b/src/index.ts index 44a2252..f876207 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,45 +1,189 @@ import app from '@/app'; import logger from '@/configs/logger.config'; -import { closeCache } from './configs/cache.config'; +import { closeCache, initCache } from './configs/cache.config'; +import { closeDatabase, initializeDatabase } from './configs/db.config'; +import { Server } from 'http'; +import { initSentry } from './configs/sentry.config'; -const port = parseInt(process.env.PORT || '8080', 10); +interface ShutdownHandler { + cleanup(): Promise; +} -const server = app.listen(port, () => { - logger.info(`Server running on port ${port}`); - logger.info(`Environment: ${process.env.NODE_ENV}`); - if (process.env.NODE_ENV !== 'production') { - logger.info(`API Docs: http://localhost:${port}/api-docs`); +/** + * 서버의 graceful shutdown을 담당하는 매니저 클래스 + */ +class GracefulShutdownManager implements ShutdownHandler { + private isShuttingDown = false; + private readonly shutdownTimeout = 10000; // 10초 강제 종료 타이머 + + constructor(private server: Server) {} + + /** + * 모든 연결을 안전하게 정리하고 서버를 종료 + */ + async cleanup(): Promise { + if (this.isShuttingDown) { + return; + } + + this.isShuttingDown = true; + + // 강제 종료 타이머 설정 (데드락 방지) + const forceExitTimer = setTimeout(() => { + logger.error('Could not close connections in time, forcefully shutting down'); + process.exit(1); + }, this.shutdownTimeout); + + try { + // HTTP 서버 종료 + await new Promise((resolve) => { + this.server.close(() => { + logger.info('HTTP server closed'); + resolve(); + }); + }); + + // 데이터베이스 연결 종료 + await closeDatabase(); + logger.info('Database connections closed'); + + // 캐시 연결 종료 + await closeCache(); + logger.info('Cache connections closed'); + + clearTimeout(forceExitTimer); // 정상 종료 시 강제 타이머 해제 + logger.info('Graceful shutdown completed'); + } catch (error) { + clearTimeout(forceExitTimer); + throw error; + } } - logger.info(`Health Check: http://localhost:${port}/health`); -}); -// 기본적인 graceful shutdown 추가 -const gracefulShutdown = async (signal: string) => { - logger.info(`${signal} received, shutting down gracefully`); - await closeCache(); - - server.close(() => { - logger.info('HTTP server closed'); - process.exit(0); - }); - - // 강제 종료 타이머 (10초) - setTimeout(() => { - logger.error('Could not close connections in time, forcefully shutting down'); - process.exit(1); - }, 10000); -}; - -process.on('SIGTERM', async () => gracefulShutdown('SIGTERM')); -process.on('SIGINT', async () => gracefulShutdown('SIGINT')); - -// 예상치 못한 에러 처리 -process.on('uncaughtException', async (error) => { - logger.error('Uncaught Exception:', error); - await gracefulShutdown('UNCAUGHT_EXCEPTION'); -}); + /** + * 시그널을 받아 graceful shutdown을 시작 + */ + handleShutdown(signal: string): void { + if (this.isShuttingDown) { + logger.info(`Already shutting down, ignoring ${signal}`); + return; + } + + logger.info(`${signal} received, shutting down gracefully`); + + // 비동기 cleanup 실행 후 프로세스 종료 + this.cleanup() + .then(() => process.exit(0)) + .catch((error) => { + logger.error('Error during graceful shutdown:', error); + process.exit(1); + }); + } +} + +/** + * Express 서버의 시작과 lifecycle을 관리하는 메인 클래스 + */ +class ServerManager { + private server?: Server; + private shutdownManager?: GracefulShutdownManager; + private readonly port = parseInt(process.env.PORT || '8080', 10); + + /** + * 서버를 초기화하고 시작 + */ + async start(): Promise { + try { + await this.initializeServices(); + this.server = this.createServer(); + this.setupShutdownHandlers(); // 시그널 핸들러 등록 + + logger.info('Server started successfully'); + } catch (error) { + logger.error('Failed to start server:', error); + process.exit(1); + } + } + + /** + * 데이터베이스 등 필요한 서비스들을 초기화 + */ + private async initializeServices(): Promise { + // Sentry 초기화 (에러 모니터링을 위해 가장 먼저) + initSentry(); + logger.info('Sentry initialized successfully'); + + // Cache 초기화 + initCache(); + logger.info('Cache initialized successfully'); + + // 데이터베이스 초기화 + await initializeDatabase(); + logger.info('Database initialized successfully'); + } + + /** + * Express 서버 인스턴스를 생성하고 시작 + */ + private createServer(): Server { + const server = app.listen(this.port, () => { + logger.info(`Server running on port ${this.port}`); + logger.info(`Environment: ${process.env.NODE_ENV}`); + + // 개발 환경에서만 API 문서 URL 표시 + if (process.env.NODE_ENV !== 'production') { + logger.info(`API Docs: http://localhost:${this.port}/api-docs`); + } + + logger.info(`Health Check: http://localhost:${this.port}/health`); + }); + + return server; + } + + /** + * 프로세스 시그널 핸들러와 에러 핸들러를 설정 + */ + private setupShutdownHandlers(): void { + if (!this.server) { + throw new Error('Server not initialized'); + } + + // shutdown manager 인스턴스 생성 + this.shutdownManager = new GracefulShutdownManager(this.server); + + // Graceful shutdown 시그널 핸들러 등록 + process.on('SIGTERM', () => { + if (this.shutdownManager) { + this.shutdownManager.handleShutdown('SIGTERM'); + } + }); + + process.on('SIGINT', () => { + if (this.shutdownManager) { + this.shutdownManager.handleShutdown('SIGINT'); + } + }); + + // 치명적 에러 발생 시 즉시 종료 + process.on('uncaughtException', (error) => { + logger.error('Uncaught Exception:', error); + process.exit(1); // graceful shutdown 없이 즉시 종료 + }); + + process.on('unhandledRejection', (reason, promise) => { + logger.error('Unhandled Rejection at:', promise, 'reason:', reason); + + // 개발 환경에서는 더 엄격하게 처리 + if (process.env.NODE_ENV === 'development') { + process.exit(1); + } + }); + } +} -process.on('unhandledRejection', async (reason, promise) => { - logger.error('Unhandled Rejection at:', promise, 'reason:', reason); - await gracefulShutdown('UNHANDLED_REJECTION'); +// 애플리케이션 진입점 +const serverManager = new ServerManager(); +serverManager.start().catch((error) => { + logger.error('Fatal error during server startup:', error); + process.exit(1); });