commit 422c0638fd54789d1aea51a6d7b67a4fb20959fa Author: 윤영훈 <0hun.y00n@gmail.com> Date: Thu Nov 13 14:14:52 2025 +0900 [FEAT] (프로젝트 구성): 초기 프로젝트 구성 v0.1 (2025-11-13) - Flask, Gunicorn, gevent, MariaDB, Redis, Swagger, logging 기본 구성 완료; diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..01ad1f0 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# 환경 설정 +DEBUG=True + +# 서버 설정 +HOST=0.0.0.0 +PORT=5000 + +# 데이터베이스 설정 +DB_HOST=localhost +DB_PORT=3306 +DB_USER= +DB_PASSWORD= +DB_NAME= +DB_POOL_SIZE=10 +DB_MAX_OVERFLOW=20 +DB_POOL_RECYCLE=3600 + +# JWT 설정 +JWT_SECRET_KEY= +JWT_ACCESS_TOKEN_HOURS=24 +JWT_REFRESH_TOKEN_DAYS=30 + +# CORS 설정 +CORS_ORIGINS=* + +# Redis 설정 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_PASSWORD= + +# 로깅 설정 +LOG_DIR=logs +LOG_LEVEL_TEXT=DEBUG +LOG_PREFIX= +MAX_LOG_SIZE=10485760 +LOG_BACKUP_COUNT=5 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc51f0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log + +# Database +*.db +*.sqlite +*.sqlite3 + +# Flask +instance/ +.webassets-cache + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# OS +.DS_Store +Thumbs.db + +# Uploads +uploads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e906913 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# NuriQ + +Flask 기반 실시간 퀴즈 플랫폼 백엔드 API (Gunicorn + gevent) \ No newline at end of file diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..11efcfc --- /dev/null +++ b/src/app.py @@ -0,0 +1,85 @@ +""" +Flask 애플리케이션 메인 파일 +Gunicorn + gevent 구조 +""" + +import os +import sys +from typing import Optional, Dict, Any + +# non-blocking으로 변경 +from gevent import monkey +monkey.patch_all() + +# 프로젝트 루트 경로 추가 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from flask import Flask, jsonify +from flask_cors import CORS + +from extensions import db, migrate, jwt, redis_cli, custom_logger +from blueprints import register_blueprints + +import settings +import constants + +def create_app(config: Optional[Dict[str, Any]] = None) -> Flask: + """Flask 애플리케이션 팩토리""" + + app = Flask(__name__) + + app.config.from_object(settings) + + if config: + app.config.update(config) + + # 로깅 설정 + logger = custom_logger(f"{settings.LOG_PREFIX}main_app") + + # Extensions 초기화 + db.init_app(app) + migrate.init_app(app, db) + jwt.init_app(app) + + # Redis 초기화 + redis_connected = redis_cli.start() + if not redis_connected: + logger.warning("Redis 연결에 실패하였습니다. Redis 없이 실행됩니다.") + + # CORS 등록 + CORS(app, origins=settings.CORS_ORIGINS) + + # 에러 핸들러 등록 + + # 블루프린트 등록 + register_blueprints(app) + + @app.route('/api/v1/health') + def health_check(): + return jsonify({ + 'status': 'healthy', + 'database': 'connected' if db.engine else 'disconnected', + 'redis': 'connected' if redis_cli.is_connected() else 'disconnected' + }) + + return app + +def main() -> None: + """애플리케이션 실행""" + app = create_app() + + # 로그 정보 + + app.run( + host=settings.HOST, + port=settings.PORT, + debug=settings.DEBUG, + use_reloader=False + ) + +# Gunicorn이 import할 app 인스턴스 +app = create_app() + +if __name__ =='__main__': + main() + \ No newline at end of file diff --git a/src/blueprints/__init__.py b/src/blueprints/__init__.py new file mode 100644 index 0000000..1e2896b --- /dev/null +++ b/src/blueprints/__init__.py @@ -0,0 +1,14 @@ +""" +Blueprints 패키지 +Flask Blueprint 관리 +""" + +from flask import Flask + +def register_blueprints(app: Flask) -> None: + + from utils.swagger_config import api_blueprint + app.register_blueprint(api_blueprint, url_prefix='/api/v1') + + +__all__ = ['register_blueprints'] \ No newline at end of file diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..0c08bfc --- /dev/null +++ b/src/constants.py @@ -0,0 +1,6 @@ +# API INFO +API_VERSION = "v0.1" +API_DATE = 2025-11-13 + +# BASE DATE FORMAT +DATE_FORMAT = "%Y-%m-%d %H:%M:%S %z" \ No newline at end of file diff --git a/src/extensions.py b/src/extensions.py new file mode 100644 index 0000000..57ec0aa --- /dev/null +++ b/src/extensions.py @@ -0,0 +1,28 @@ +""" +모든 외부 모듈, 서비스의 초기화 +""" + +import settings + +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_jwt_extended import JWTManager + +from utils.logger_manager import * + +from modules.redis.redis_facade import redis_cli + +logger = custom_logger(f"{settings.LOG_PREFIX})extenstions") + +try: + + logging.info("Extensions 초기화 시작 .. ") + + db = SQLAlchemy() + migrate = Migrate() + jwt = JWTManager() + + logging.info("Extensions 초기화 완료 .. ") + +except Exception as e: + logging.error(f" Fail Start Extensions.py => {str(e)}") \ No newline at end of file diff --git a/src/gunicorn_config.py b/src/gunicorn_config.py new file mode 100644 index 0000000..9981a0c --- /dev/null +++ b/src/gunicorn_config.py @@ -0,0 +1,53 @@ +""" +Gunicorn 설정 파일 +""" + +import sys +import os + +# 프로젝트 루트 경로 추가 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import settings + +# Worker 설정 +workers = settings.GUNICORN_WORKERS +worker_class = settings.GUNICORN_WORKER_CLASS +worker_connections = settings.GUNICORN_WORKER_CONNECTIONS +threads = settings.GUNICORN_THREADS + +# 서버 바인딩 +bind = f'{settings.HOST}:{settings.PORT}' + +# 타임아웃 설정 +timeout = settings.GUNICORN_TIMEOUT +keepalive = settings.GUNICORN_KEEPALIVE +graceful_timeout = settings.GUNICORN_GRACEFUL_TIMEOUT + +# 재시작 설정 (메모리 누수 방지) +max_requests = settings.GUNICORN_MAX_REQUESTS +max_requests_jitter = settings.GUNICORN_MAX_REQUESTS_JITTER + +# 로깅 설정 +accesslog = settings.GUNICORN_ACCESSLOG +errorlog = settings.GUNICORN_ERRORLOG +loglevel = settings.GUNICORN_LOGLEVEL + +# 프로세스 네임 +proc_name = "nuriq_server" + +# hook 함수 +def on_starting(server): + """ + Gunicorn 마스터 프로세스가 시작될 때 한번만 실행 + 워커가 생성되기 전 실행, 전역 초기화 작업 + """ + server.log.info("[Gunicorn] 마스터 프로세스 시작 중 ...") + + # Flask 앱 컨텍스트 내에서 초기화 작업 수행 + from app import create_app + app = create_app() + +def post_worker_init(worker): + """각 워커 프로세스 초기화된 후 사용""" + worker.log.info(f"Worker {worker.pid} 초기화 완료") \ No newline at end of file diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/__init__.py b/src/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/redis/redis_facade.py b/src/modules/redis/redis_facade.py new file mode 100644 index 0000000..94ed6c8 --- /dev/null +++ b/src/modules/redis/redis_facade.py @@ -0,0 +1,66 @@ +""" +Redis 클라이언트 전체 관리 모듈 +""" + +from typing import Optional +import redis +import settings +import logging + +class RedisFacadeOpr(): + """Redis 연결 및 작업을 관리하는 Facade 클래스""" + + def __init__(self) -> None: + """Redis 설정 초기화""" + self.redis_config = { + 'host': settings.REDIS_HOST, + 'port': settings.REDIS_PORT, + 'password': settings.REDIS_PASSWORD, + 'decode_responses': True, # auto-decode response + 'db': settings.REDIS_DB + } + + self.redis_client: Optional[redis.StrictRedis] = None + + def start(self) -> bool: + """Redis 연결 시작 + + Returns: + bool: 연결 성공 시 True, 실패 시 False + """ + try: + # Redis Client 연결 + self.redis_client = redis.StrictRedis(**self.redis_config) + # 연결 테스트 + self.redis_client.ping() + logging.info("Redis connection established successfully") + return True + except Exception as e: + logging.error(f"Failed to connect to Redis: {str(e)}") + self.redis_client = None + return False + + def reset_database(self) -> None: + """Redis 데이터베이스 초기화""" + if self.redis_client: + self.redis_client.flushdb() + logging.info("Redis database flushed") + else: + logging.warning("Cannot reset database: Redis client not connected") + + def refresh_init_database(self) -> None: + """개별 데이터 초기화""" + pass + + def is_connected(self) -> bool: + """Redis 연결 상태 확인""" + if not self.redis_client: + return False + try: + self.redis_client.ping() + return True + except Exception: + return False + + +redis_cli = RedisFacadeOpr() \ No newline at end of file diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..f7ed8e3 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,35 @@ +# Flask Core +Flask +Flask-RESTful +Flask-SQLAlchemy +Flask-Migrate +Flask-CORS +Flask-JWT-Extended +flask_restx + +# Database +PyMySQL +SQLAlchemy + +# HTTP Client +requests + +# Background Tasks +redis + +# Environment +python-dotenv + +# Utilities +psutil + +# Password Hashing +bcrypt + +# Validation +marshmallow +pydantic + +# WSGI Server +gunicorn +gevent \ No newline at end of file diff --git a/src/run_server.sh b/src/run_server.sh new file mode 100644 index 0000000..88c8e4a --- /dev/null +++ b/src/run_server.sh @@ -0,0 +1,67 @@ +#!/bin/bash +############################################################## +# NuriQ 서버 실행 스크립트 +# +# 사용법: +# ./run_server.sh [모드] +# +# 모드: +# dev - 개발 모드 (Flask built-in server) +# prod - 운영 모드 (Gunicorn + gevent) +############################################################## + +set -e #에러 발생시 스크립트 중단 + +# 색상 정리 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# 현재 스크립트 디렉토리로 이동 +cd "$(dirname "$0")" + +# 모드 설정 (기본값: prod) +MODE="${1:-prod}" + +echo -e "${BLUE}=========================================" +echo -e "${BLUE} NuriQ Server Start${NC} " +echo -e "${BLUE}=========================================" + +# 가상환경 활성화 확인 +if [ -z "$VIRTUAL_ENV" ]; then + echo -e "${YELLOW} Virtual environment not activated${NC}" + echo -e "${YELLOW} Consider running: source .venv/bin/activate${NC}" + echo "" +fi + +# 모드에 따른 처리 +case "$MODE" in + dev) + echo -e "${GREEN} 개발 모드 시작 중 ...${NC}" + echo -e "${GREEN} Flask built-in server 사용 중 ...${NC}" + echo "" + export DEBUG=True + gunicorn -c ./gunicorn_config.py app:app + ;; + + prod) + echo -e "${GREEN} 운영 모드 시작 중 ...${NC}" + echo -e "${YELLOW} Gunicorn + gevent 사용 중 ...${NC}" + echo "" + gunicorn -c ./gunicorn_config.py \ + --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker \ + app:app + ;; + *) + echo -e "{$RED} 알 수 없는 모드: $MODE${NC}" + echo "" + echo "사용 가능한 모드" + echo " dev - 개발 모드 (Flask built-in server)" + echo " prod - 운영 모드 (Gunicorn + gevent)" + echo "" + echo "사용: $0 [mode]" + exit 1 + ;; +esac \ No newline at end of file diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/settings.py b/src/settings.py new file mode 100644 index 0000000..d1187d6 --- /dev/null +++ b/src/settings.py @@ -0,0 +1,80 @@ +""" +애플리케이션 설정 모듈 +환경 변수 및 전역 설정 관리 +""" + +import os +from datetime import timedelta +from dotenv import load_dotenv +from urllib.parse import quote_plus +import multiprocessing + +# .env 파일 로드 +load_dotenv() + +# 환경설정 +DEBUG = os.getenv('DEBUG', False) + +# 서버 +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', '5000')) + +# 데이터베이스 설정 +DB_HOST = os.getenv('DB_HOST', 'localhost') +DB_PORT = int(os.getenv('DB_PORT', 5000)) +DB_USER = os.getenv('DB_USER', 'root') +DB_PASSWORD = os.getenv('DB_PASSWORD', '') +DB_NAME = os.getenv('DB_NAME', 'test') + +SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4" +SQLALCHEMY_TRACK_MODIFICATIONS = False +SQLALCHEMY_ECHO = DEBUG +SQLALCHEMY_POOL_SIZE = int(os.getenv('DB_POOL_SIZE', 10)) +SQLALCHEMY_MAX_OVERFLOW = int(os.getenv('DB_MAX_OVERFLOW', 20)) +SQLALCHEMY_POOL_RECYCLE = int(os.getenv('DB_POOL_RECYCLE', 3600)) + +JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'your-secret-key-change-this-in-production') +JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=int(os.getenv('JWT_ACCESS_TOKEN_HOURS', 24))) +JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=int(os.getenv('JWT_REFRESH_TOKEN_DAYS', 30))) +JWT_TOKEN_LOCATION = ['headers'] +JWT_HEADER_NAME = 'Authorization' +JWT_HEADER_TYPE = 'Bearer' +JWT_ENCODE_JTI = True # JWT ID 포함 (블랙리스트 추적용) + +# CORS 설정 +CORS_ORIGINS = os.getenv('CORS_ORIGINS', '*').split(',') +CORS_ALLOW_HEADERS = ['Content-Type', 'Authorization'] +CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] + +# Redis 설정 +REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') +REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) +REDIS_DB = int(os.getenv('REDIS_DB', 0)) +REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', None) + +# 로깅 설정 +LOG_DIR = os.getenv('LOG_DIR', 'logs') +LOG_PREFIX = os.getenv('LOG_PREFIX', 'default.') +LOG_LEVEL_TEXT = os.getenv('LOG_LEVEL_TEXT', 'INFO' if not DEBUG else 'DEBUG') +MAX_LOG_SIZE = int(os.getenv('MAX_LOG_SIZE', 10 * 1024 * 1024)) # 10MB +LOG_BACKUP_COUNT = int(os.getenv('LOG_BACKUP_COUNT', 5)) + +# Worker 설정 +GUNICORN_WORKERS = int(os.getenv('GUNICORN_WORKERS', multiprocessing.cpu_count() * 2 + 1)) +GUNICORN_WORKER_CLASS = os.getenv('GUNICORN_WORKER_CLASS', 'gevent') # gevent for async I/O +GUNICORN_WORKER_CONNECTIONS = int(os.getenv('GUNICORN_WORKER_CONNECTIONS', 1000)) +GUNICORN_THREADS = int(os.getenv('GUNICORN_THREADS', 1)) # gevent 사용 시 무시됨 + +# 타임아웃 설정 +GUNICORN_TIMEOUT = int(os.getenv('GUNICORN_TIMEOUT', 30)) # 요청 타임아웃 (초) +GUNICORN_KEEPALIVE = int(os.getenv('GUNICORN_KEEPALIVE', 5)) # Keep-Alive 연결 유지 시간 +GUNICORN_GRACEFUL_TIMEOUT = int(os.getenv('GUNICORN_GRACEFUL_TIMEOUT', 30)) # Graceful shutdown 대기 시간 + +# 재시작 설정 (메모리 누수 방지) +GUNICORN_MAX_REQUESTS = int(os.getenv('GUNICORN_MAX_REQUESTS', 1000)) +GUNICORN_MAX_REQUESTS_JITTER = int(os.getenv('GUNICORN_MAX_REQUESTS_JITTER', 50)) + +# 로깅 설정 +GUNICORN_ACCESSLOG = os.getenv('GUNICORN_ACCESSLOG', '-') # '-' = stdout +GUNICORN_ERRORLOG = os.getenv('GUNICORN_ERRORLOG', '-') +GUNICORN_LOGLEVEL = os.getenv('GUNICORN_LOGLEVEL', 'info') \ No newline at end of file diff --git a/src/test/__init__.py b/src/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/logger_manager.py b/src/utils/logger_manager.py new file mode 100644 index 0000000..a51b9d7 --- /dev/null +++ b/src/utils/logger_manager.py @@ -0,0 +1,100 @@ +import constants + +import sys, time +import builtins, logging +import settings + +# base logging format +log_format = "[%(asctime)s] [%(levelname)s] (%(name)s) %(message)s" + +# date-time format +date_format = constants.DATE_FORMAT + +# formatter for logger +formatter = logging.Formatter(log_format, date_format) + +# class for redirect STD to Logging +class StdLogRedirect: + + def __init__(self, stream, formatter, level=logging.ERROR, name="stderr"): + self.stream = stream + self.formatter = formatter + self.level = level + self.name = name + self._buf = "" + + def _emit_line(self, line: str): + rec = logging.LogRecord( + name=self.name, + level=self.level, + pathname="", + lineno=0, + msg=line, + args=(), + exc_info=None, + ) + sys.stdout.write(self.formatter.format(rec) + "\n") + + def write(self, s): + if isinstance(s, bytes): + s = s.decode(errors="replace") + self._buf += s + while True: + idx = self._bug.find("\n") + if idx == -1: + break + line = self._buf[:idx+1:] + self._buf = self._buf[idx+1:] + if line: + self._emit_line(line) + + def flush(self): + if self._buf: + self._emit_line(self._buf.rstrip("\r")) + self._buf = "" + self.stream.flush() + + def isatty(self): + return self.stream.isatty() + +# redirect STDERR to STDOUT using usual Logging format +sys.stderr = StdLogRedirect(sys.stderr, formatter, level=logging.ERROR, name="external.stderr") + +# redirect print to Logging +_builtin_print = print +def print_as_log(*args, level=logging.NOTSET, name="unknown.print", **kwargs): + record = logging.LogRecord( + name=name, + level=level, + pathname=__file__, + lineno=0, + msg=" ".join(str(a) for a in args), + args=(), + exc_info=None, + ) + _builtin_print(formatter.format(record), **kwargs) +builtins.print = print_as_log + +# logging basic config +logging.basicConfig( + level=settings.LOG_LEVEL_TEXT, + format=log_format, + datefmt=date_format, + stream=sys.stdout +) + +# create own logger class to prevent init custom loggers by other libs +class GatekeeperLogger(logging.Logger): + def assHandler(self, h): + # only 'root', '__main__' ans own loggers will be accepted + if self.name == "root" or self.name.startswith(('LOG_PREFIX'), "__main__"): + return super().addHandler(h) + +logging.setLoggerClass(GatekeeperLogger) + +def custom_logger(log_prefix: str): + import logging + + logger = logging.getLogger(log_prefix) + logger.setLevel(settings.LOG_LEVEL_TEXT) + return logger \ No newline at end of file diff --git a/src/utils/swagger_config.py b/src/utils/swagger_config.py new file mode 100644 index 0000000..ea61573 --- /dev/null +++ b/src/utils/swagger_config.py @@ -0,0 +1,34 @@ +""" +Swagger 설정 +""" + +# Third-Party Library Imports +from flask import Blueprint, jsonify +from flask_restx import Api +from flask_jwt_extended.exceptions import JWTExtendedException, RevokedTokenError + +import constants + +# Swagger API Blueprint 및 설정 +api_blueprint = Blueprint('api', __name__) + +api = Api( + api_blueprint, + title='NuriQ API', + version=constants.API_VERSION, + description='NuriQ REST API 문서', + doc='/docs', + authorizations={ + 'Bearer': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'Authorization', + 'description': 'JWT 토큰 입력 (예: Bearer )' + } + }, + security='Bearer', + validate=True +) + +# 네임 스페이스 정의 및 API에 추가 +__all__ = ['api_blueprint', 'api'] \ No newline at end of file