187 lines
5.0 KiB
JavaScript
187 lines
5.0 KiB
JavaScript
|
|
// src/app.js
|
||
|
|
// AI Assistant API 서버 메인 엔트리포인트
|
||
|
|
|
||
|
|
require('dotenv').config();
|
||
|
|
|
||
|
|
const express = require('express');
|
||
|
|
const cors = require('cors');
|
||
|
|
const helmet = require('helmet');
|
||
|
|
const compression = require('compression');
|
||
|
|
const rateLimit = require('express-rate-limit');
|
||
|
|
const swaggerUi = require('swagger-ui-express');
|
||
|
|
const swaggerSpec = require('./config/swagger.config');
|
||
|
|
|
||
|
|
const logger = require('./config/logger.config');
|
||
|
|
const { sequelize } = require('./models');
|
||
|
|
const routes = require('./routes');
|
||
|
|
const errorHandler = require('./middlewares/error-handler.middleware');
|
||
|
|
|
||
|
|
const app = express();
|
||
|
|
// VEXPLOR 내장 시 backend-node가 이 포트로 프록시하므로 기본 3100 사용
|
||
|
|
const PORT = process.env.PORT || 3100;
|
||
|
|
|
||
|
|
// ===========================================
|
||
|
|
// 미들웨어 설정
|
||
|
|
// ===========================================
|
||
|
|
|
||
|
|
// Trust proxy (Docker/Nginx 환경)
|
||
|
|
app.set('trust proxy', 1);
|
||
|
|
|
||
|
|
// CORS 설정 (helmet보다 먼저 설정)
|
||
|
|
app.use(cors({
|
||
|
|
origin: true, // 모든 origin 허용
|
||
|
|
credentials: true,
|
||
|
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||
|
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Preflight 요청 처리
|
||
|
|
app.options('*', cors());
|
||
|
|
|
||
|
|
// 보안 헤더 (CORS 이후에 설정)
|
||
|
|
app.use(helmet({
|
||
|
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||
|
|
crossOriginOpenerPolicy: { policy: 'unsafe-none' },
|
||
|
|
}));
|
||
|
|
|
||
|
|
// 요청 본문 파싱
|
||
|
|
app.use(express.json({ limit: '10mb' }));
|
||
|
|
app.use(express.urlencoded({ extended: true }));
|
||
|
|
|
||
|
|
// 압축
|
||
|
|
app.use(compression());
|
||
|
|
|
||
|
|
// Rate Limiting (전역)
|
||
|
|
const limiter = rateLimit({
|
||
|
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
|
||
|
|
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100,
|
||
|
|
message: {
|
||
|
|
success: false,
|
||
|
|
error: {
|
||
|
|
code: 'RATE_LIMIT_EXCEEDED',
|
||
|
|
message: '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
standardHeaders: true,
|
||
|
|
legacyHeaders: false,
|
||
|
|
});
|
||
|
|
app.use(limiter);
|
||
|
|
|
||
|
|
// 요청 로깅
|
||
|
|
app.use((req, res, next) => {
|
||
|
|
const start = Date.now();
|
||
|
|
res.on('finish', () => {
|
||
|
|
const duration = Date.now() - start;
|
||
|
|
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
|
||
|
|
});
|
||
|
|
next();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ===========================================
|
||
|
|
// 헬스 체크
|
||
|
|
// ===========================================
|
||
|
|
|
||
|
|
app.get('/health', (req, res) => {
|
||
|
|
res.json({
|
||
|
|
success: true,
|
||
|
|
data: {
|
||
|
|
status: 'healthy',
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
uptime: process.uptime(),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ===========================================
|
||
|
|
// Swagger API 문서
|
||
|
|
// ===========================================
|
||
|
|
|
||
|
|
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||
|
|
explorer: true,
|
||
|
|
customCss: '.swagger-ui .topbar { display: none }',
|
||
|
|
customSiteTitle: 'AI Assistant API 문서',
|
||
|
|
swaggerOptions: {
|
||
|
|
persistAuthorization: true,
|
||
|
|
displayRequestDuration: true,
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Swagger JSON
|
||
|
|
app.get('/api-docs.json', (req, res) => {
|
||
|
|
res.setHeader('Content-Type', 'application/json');
|
||
|
|
res.send(swaggerSpec);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ===========================================
|
||
|
|
// API 라우트
|
||
|
|
// ===========================================
|
||
|
|
|
||
|
|
app.use('/api/v1', routes);
|
||
|
|
|
||
|
|
// ===========================================
|
||
|
|
// 404 처리
|
||
|
|
// ===========================================
|
||
|
|
|
||
|
|
app.use((req, res) => {
|
||
|
|
res.status(404).json({
|
||
|
|
success: false,
|
||
|
|
error: {
|
||
|
|
code: 'NOT_FOUND',
|
||
|
|
message: `요청한 리소스를 찾을 수 없습니다: ${req.method} ${req.originalUrl}`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ===========================================
|
||
|
|
// 에러 핸들러
|
||
|
|
// ===========================================
|
||
|
|
|
||
|
|
app.use(errorHandler);
|
||
|
|
|
||
|
|
// ===========================================
|
||
|
|
// 서버 시작
|
||
|
|
// ===========================================
|
||
|
|
|
||
|
|
async function startServer() {
|
||
|
|
try {
|
||
|
|
// 데이터베이스 연결
|
||
|
|
await sequelize.authenticate();
|
||
|
|
logger.info('✅ 데이터베이스 연결 성공');
|
||
|
|
|
||
|
|
// 테이블 동기화 (테이블이 없으면 생성)
|
||
|
|
await sequelize.sync();
|
||
|
|
logger.info('✅ 데이터베이스 스키마 동기화 완료');
|
||
|
|
|
||
|
|
// 초기 데이터 설정 (관리자 계정, LLM 프로바이더)
|
||
|
|
const initService = require('./services/init.service');
|
||
|
|
await initService.initialize();
|
||
|
|
|
||
|
|
// 서버 시작
|
||
|
|
app.listen(PORT, () => {
|
||
|
|
logger.info(`🚀 AI Assistant API 서버가 포트 ${PORT}에서 실행 중입니다`);
|
||
|
|
logger.info(`📚 API 문서 (Swagger): http://localhost:${PORT}/api-docs`);
|
||
|
|
logger.info(`📚 API 엔드포인트: http://localhost:${PORT}/api/v1`);
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('❌ 서버 시작 실패:', error);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 프로세스 종료 처리
|
||
|
|
process.on('SIGTERM', async () => {
|
||
|
|
logger.info('SIGTERM 신호 수신, 서버 종료 중...');
|
||
|
|
await sequelize.close();
|
||
|
|
process.exit(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
process.on('SIGINT', async () => {
|
||
|
|
logger.info('SIGINT 신호 수신, 서버 종료 중...');
|
||
|
|
await sequelize.close();
|
||
|
|
process.exit(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
startServer();
|
||
|
|
|
||
|
|
module.exports = app;
|