[FEAT] (프로젝트 구성): 초기 프로젝트 구성

v0.1 (2025-11-13)
- Flask, Gunicorn, gevent, MariaDB, Redis, Swagger, logging 기본 구성 완료;
This commit is contained in:
2025-11-13 14:14:52 +09:00
commit 422c0638fd
19 changed files with 673 additions and 0 deletions

37
.env.example Normal file
View File

@@ -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

65
.gitignore vendored Normal file
View File

@@ -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/

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# NuriQ
Flask 기반 실시간 퀴즈 플랫폼 백엔드 API (Gunicorn + gevent)

85
src/app.py Normal file
View File

@@ -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()

View File

@@ -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']

6
src/constants.py Normal file
View File

@@ -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"

28
src/extensions.py Normal file
View File

@@ -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)}")

53
src/gunicorn_config.py Normal file
View File

@@ -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} 초기화 완료")

0
src/models/__init__.py Normal file
View File

0
src/modules/__init__.py Normal file
View File

View File

@@ -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()

View File

35
src/requirements.txt Normal file
View File

@@ -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

67
src/run_server.sh Normal file
View File

@@ -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

0
src/services/__init__.py Normal file
View File

80
src/settings.py Normal file
View File

@@ -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')

0
src/test/__init__.py Normal file
View File

100
src/utils/logger_manager.py Normal file
View File

@@ -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="<stderr>",
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

View File

@@ -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 <token>)'
}
},
security='Bearer',
validate=True
)
# 네임 스페이스 정의 및 API에 추가
__all__ = ['api_blueprint', 'api']