From dcaab49c6ae23e2ce690d50d6bf8a4dc09e5ed00 Mon Sep 17 00:00:00 2001 From: kakusiA Date: Fri, 22 Aug 2025 14:18:28 +0900 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20FastApi=20logging=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/app/api/router.py | 15 ++++++ .../app/core/decorators.py | 53 ------------------ .../app/decorators/__init__.py | 0 .../app/decorators/logging.py | 54 +++++++++++++++++++ apps/pre-processing-service/app/main.py | 24 +++++---- .../app/middleware/__init__.py | 0 6 files changed, 83 insertions(+), 63 deletions(-) delete mode 100644 apps/pre-processing-service/app/core/decorators.py create mode 100644 apps/pre-processing-service/app/decorators/__init__.py create mode 100644 apps/pre-processing-service/app/decorators/logging.py create mode 100644 apps/pre-processing-service/app/middleware/__init__.py diff --git a/apps/pre-processing-service/app/api/router.py b/apps/pre-processing-service/app/api/router.py index e69de29b..9f2572e1 100644 --- a/apps/pre-processing-service/app/api/router.py +++ b/apps/pre-processing-service/app/api/router.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter +from app.decorators.logging import log_api_call + +router = APIRouter() + +@router.get("/") +async def root(): + return {"message": "Hello World"} + + +@router.get("/hello/{name}") +# @log_api_call +async def say_hello(name: str): + return {"message": f"Hello {name}"} + diff --git a/apps/pre-processing-service/app/core/decorators.py b/apps/pre-processing-service/app/core/decorators.py deleted file mode 100644 index 6ded6f9d..00000000 --- a/apps/pre-processing-service/app/core/decorators.py +++ /dev/null @@ -1,53 +0,0 @@ -from fastapi import FastAPI, Request -from loguru import logger -import functools -import time - - -# 2. 로그를 위한 커스텀 데코레이터 정의 -def log_api_call(func): - """ - FastAPI 라우트 함수에 대한 로깅을 수행하는 데코레이터입니다. - 함수 호출 시간, 인자, 반환값, 발생한 예외 등을 기록합니다. - """ - - @functools.wraps(func) - async def wrapper(*args, **kwargs): - # 데코레이터가 적용된 함수에 대한 정보를 가져옵니다. - # FastAPI 라우트 핸들러의 경우, 첫 번째 인자는 Request 객체입니다. - request: Request = kwargs.get('request', None) or args[0] - - # 함수 실행 전 로그 기록 - logger.info( - "API 호출 시작: URL='{}' 메서드='{}' 함수='{}'", - request.url, request.method, func.__name__ - ) - - start_time = time.time() - result = None - - try: - # 원래 함수 실행 - result = await func(*args, **kwargs) - return result - - except Exception as e: - # 예외 발생 시 로그 기록 - elapsed_time = time.time() - start_time - logger.error( - "API 호출 실패: URL='{}' 메서드='{}' 함수='{}' 예외='{}' ({:.4f}s)", - request.url, request.method, func.__name__, e, elapsed_time - ) - # 예외를 다시 발생시켜 FastAPI의 기본 예외 핸들러가 처리하도록 함 - raise - - finally: - # 함수 실행 완료(성공 또는 실패) 후 로그 기록 - if result is not None: - elapsed_time = time.time() - start_time - logger.success( - "API 호출 성공: URL='{}' 메서드='{}' 함수='{}' 반환값='{}' ({:.4f}s)", - request.url, request.method, func.__name__, result, elapsed_time - ) - - return wrapper \ No newline at end of file diff --git a/apps/pre-processing-service/app/decorators/__init__.py b/apps/pre-processing-service/app/decorators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/pre-processing-service/app/decorators/logging.py b/apps/pre-processing-service/app/decorators/logging.py new file mode 100644 index 00000000..29eb2204 --- /dev/null +++ b/apps/pre-processing-service/app/decorators/logging.py @@ -0,0 +1,54 @@ +# app/decorators/logging.py + +from fastapi import Request +from loguru import logger +import functools +import time + + +def log_api_call(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + # 1. request 객체를 안전하게 가져옵니다. + # kwargs에서 'request'를 찾고, 없으면 args가 비어있지 않은 경우에만 args[0]을 시도합니다. + request: Request | None = kwargs.get('request') + if request is None and args and isinstance(args[0], Request): + request = args[0] + + # 요청 정보를 로그로 기록 (request 객체가 있는 경우에만) + if request: + logger.info( + "API 호출 시작: URL='{}' 메서드='{}' 함수='{}'", + request.url, request.method, func.__name__ + ) + else: + logger.info("API 호출 시작: 함수='{}'", func.__name__) + + start_time = time.time() + result = None + + try: + result = await func(*args, **kwargs) + return result + except Exception as e: + elapsed_time = time.time() - start_time + if request: + logger.error( + "API 호출 실패: URL='{}' 메서드='{}' 예외='{}' ({:.4f}s)", + request.url, request.method, e, elapsed_time + ) + else: + logger.error("API 호출 실패: 함수='{}' 예외='{}' ({:.4f}s)", func.__name__, e, elapsed_time) + raise + finally: + if result is not None: + elapsed_time = time.time() - start_time + if request: + logger.success( + "API 호출 성공: URL='{}' 메서드='{}' ({:.4f}s)", + request.url, request.method, elapsed_time + ) + else: + logger.success("API 호출 성공: 함수='{}' ({:.4f}s)", func.__name__, elapsed_time) + + return wrapper \ No newline at end of file diff --git a/apps/pre-processing-service/app/main.py b/apps/pre-processing-service/app/main.py index 6d7c6d96..85d86e27 100644 --- a/apps/pre-processing-service/app/main.py +++ b/apps/pre-processing-service/app/main.py @@ -1,13 +1,17 @@ -from fastapi import FastAPI - -app = FastAPI() +# main.py +from fastapi import FastAPI -@app.get("/") -async def root(): - return {"message": "Hello World"} - +# app/api/v1/items.py에서 라우터를 임포트합니다. +from app.api.router import router as api_router +from app.middleware.logging import LoggingMiddleware -@app.get("/hello/{name}") -async def say_hello(name: str): - return {"message": f"Hello {name}"} +# FastAPI 애플리케이션 인스턴스를 생성합니다. +app = FastAPI( + title="My FastAPI Project", + description="A demonstration of a well-structured FastAPI project.", + version="1.0.0" +) +app.add_middleware(LoggingMiddleware) +# APIRouter를 메인 앱에 포함시킵니다. +app.include_router(api_router, prefix="", tags=["api"]) \ No newline at end of file diff --git a/apps/pre-processing-service/app/middleware/__init__.py b/apps/pre-processing-service/app/middleware/__init__.py new file mode 100644 index 00000000..e69de29b From ec102d563c6f5396ad5806a441a2fd97d941961d Mon Sep 17 00:00:00 2001 From: kakusiA Date: Fri, 22 Aug 2025 14:19:46 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20FastApi=20logging=20middleware=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/middleware/logging.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/pre-processing-service/app/middleware/logging.py diff --git a/apps/pre-processing-service/app/middleware/logging.py b/apps/pre-processing-service/app/middleware/logging.py new file mode 100644 index 00000000..29cbe738 --- /dev/null +++ b/apps/pre-processing-service/app/middleware/logging.py @@ -0,0 +1,38 @@ + +import time +from fastapi import Request +from loguru import logger +from starlette.middleware.base import BaseHTTPMiddleware + + +class LoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + start_time = time.time() + + # 1. 요청 시작 로그 + logger.info( + "요청 시작: IP='{}' 메서드='{}' URL='{}'", + request.client.host, request.method, request.url.path + ) + + try: + # 2. 다음 미들웨어 또는 최종 엔드포인트 함수 실행 + response = await call_next(request) + + # 3. 요청 성공 시 로그 + process_time = time.time() - start_time + logger.info( + "요청 성공: 메서드='{}' URL='{}' 상태코드='{}' (처리 시간: {:.4f}s)", + request.method, request.url.path, response.status_code, process_time + ) + return response + + except Exception as e: + # 4. 예외 발생 시 로그 + process_time = time.time() - start_time + logger.error( + "요청 실패: IP='{}' 메서드='{}' URL='{}' 예외='{}' (처리 시간: {:.4f}s)", + request.client.host, request.method, request.url.path, e, process_time + ) + # 예외를 다시 발생시켜 FastAPI의 기본 핸들러가 처리하도록 함 + raise \ No newline at end of file From 2bb6e2fa6cf385871289d643f9e06bc9db00fff7 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 23 Aug 2025 17:43:08 +0900 Subject: [PATCH 3/8] =?UTF-8?q?update=20:=20Service,=20Repository=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=EC=9D=98=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(=EC=95=A0=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=B0=A9=EC=8B=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 패턴 : [작업타입_상태] trace_id=추적ID(UUID) operation=작업명 파라미터(변경가능) [결과정보] 예시 : [CHUNKING_START] trace_id=abc123 operation=CHUNKING_CHUNK_DOCUMENTS chunk_size=1000 overlap=100 documents_count=50 [RDB_START] trace_id=def456 operation=RDB_SELECT_GET_USERS limit=100 offset=0 --- .../app/config/logging/DatabaseLogger.py | 131 ++++++++++++++++++ .../app/config/logging/ServiceLogger.py | 122 ++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 apps/pre-processing-service/app/config/logging/DatabaseLogger.py create mode 100644 apps/pre-processing-service/app/config/logging/ServiceLogger.py diff --git a/apps/pre-processing-service/app/config/logging/DatabaseLogger.py b/apps/pre-processing-service/app/config/logging/DatabaseLogger.py new file mode 100644 index 00000000..a7003fec --- /dev/null +++ b/apps/pre-processing-service/app/config/logging/DatabaseLogger.py @@ -0,0 +1,131 @@ +import functools +import inspect +import time +from typing import Optional, Dict, Any, List +from loguru import logger +from contextvars import ContextVar + +# 추적 ID를 저장할 ContextVar +trace_id_context: ContextVar[Optional[str]] = ContextVar('trace_id', default=None) + + +class DatabaseLogger: + """통합 데이터베이스 로깅을 관리하는 클래스 (벡터DB + RDB)""" + + def set_trace_id(self, trace_id: str): + """스프링에서 받은 추적 ID 설정""" + trace_id_context.set(trace_id) + + def get_trace_id(self) -> str: + """현재 추적 ID 반환""" + return trace_id_context.get() or "NO_TRACE" + + def _extract_params(self, func, args, kwargs, param_names: List[str]) -> Dict[str, Any]: + """함수의 실제 파라미터 값들을 추출""" + params = {} + + # 함수의 파라미터 이름들 가져오기 + sig = inspect.signature(func) + param_list = list(sig.parameters.keys()) + + # kwargs에서 직접 찾기 + for param_name in param_names: + if param_name in kwargs: + params[param_name] = kwargs[param_name] + + # args에서 찾기 + for i, arg_value in enumerate(args): + if i < len(param_list): + param_name = param_list[i] + if param_name in param_names and param_name not in params: + if isinstance(arg_value, list): + params[f"{param_name}_count"] = len(arg_value) + elif isinstance(arg_value, str) and len(arg_value) > 50: + params[f"{param_name}_length"] = len(arg_value) + else: + params[param_name] = arg_value + + return params + + def _format_result_info(self, result: Any) -> str: + """결과 정보를 포맷팅""" + if isinstance(result, list): + return f" result_count={len(result)}" + elif isinstance(result, dict): + return f" result_keys={len(result.keys())}" + elif isinstance(result, str): + return f" result_length={len(result)}" + elif hasattr(result, '__len__'): + return f" result_count={len(result)}" + else: + return "" + + def _log_db_operation(self, db_type: str, operation: str, track_params: List[str] = None): + """ + 데이터베이스 작업 로깅 데코레이터의 핵심 로직 + + Args: + db_type: 데이터베이스 타입 (VECTOR_DB, RDB) + operation: 작업 타입 + track_params: 추적할 파라미터 이름들 + """ + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + trace_id = self.get_trace_id() + full_operation = f"{db_type}_{operation}_{func.__name__.upper()}" + + # 동적으로 파라미터 추출 + tracked_params = {} + if track_params: + tracked_params = self._extract_params(func, args, kwargs, track_params) + + # 파라미터를 문자열로 변환 + param_str = "" + if tracked_params: + param_strs = [f"{k}={v}" for k, v in tracked_params.items()] + param_str = " " + " ".join(param_strs) + + start_time = time.time() + + # 작업 시작 로그 + logger.info(f"[{db_type}_START] trace_id={trace_id} operation={full_operation}{param_str}") + + try: + # 실제 함수 실행 + result = func(*args, **kwargs) + + # 실행 시간 계산 + execution_time = time.time() - start_time + + # 결과 정보 + result_str = self._format_result_info(result) + + # 성공 로그 + logger.info( + f"[{db_type}_SUCCESS] trace_id={trace_id} operation={full_operation} execution_time={execution_time:.4f}s{param_str}{result_str}") + + return result + + except Exception as e: + # 실행 시간 계산 + execution_time = time.time() - start_time + + # 실패 로그 + logger.error( + f"[{db_type}_ERROR] trace_id={trace_id} operation={full_operation} execution_time={execution_time:.4f}s{param_str} error={type(e).__name__}: {str(e)}") + + raise + + return wrapper + + return decorator + + def custom_db_operation(self, db_type: str, operation: str, track_params: List[str] = None): + """커스텀 데이터베이스 작업 로깅 - 사용자 정의 용""" + return self._log_db_operation(db_type, operation, track_params) + + +# 전역 DatabaseLogger 싱글톤 +db_logger = DatabaseLogger() \ No newline at end of file diff --git a/apps/pre-processing-service/app/config/logging/ServiceLogger.py b/apps/pre-processing-service/app/config/logging/ServiceLogger.py new file mode 100644 index 00000000..61688858 --- /dev/null +++ b/apps/pre-processing-service/app/config/logging/ServiceLogger.py @@ -0,0 +1,122 @@ +import functools +import inspect +import time +from typing import Optional, Dict, Any, List +from loguru import logger +from contextvars import ContextVar + +# 추적 ID를 저장할 ContextVar +trace_id_context: ContextVar[Optional[str]] = ContextVar('trace_id', default=None) + + +class ServiceLogger: + """서비스 레이어 로깅을 관리하는 클래스 어노테이션으로 적용 가능""" + + + def set_trace_id(self, trace_id: str): + """스프링에서 받은 추적 ID 설정""" + trace_id_context.set(trace_id) + + def get_trace_id(self) -> str: + """현재 추적 ID 반환""" + return trace_id_context.get() or "NO_TRACE" + + def _extract_params(self, func, args, kwargs, param_names: List[str]) -> Dict[str, Any]: + """함수의 실제 파라미터 값들을 추출""" + params = {} + + # 함수의 파라미터 이름들 가져오기 + sig = inspect.signature(func) + param_list = list(sig.parameters.keys()) + + # kwargs에서 직접 찾기 + for param_name in param_names: + if param_name in kwargs: + params[param_name] = kwargs[param_name] + + # args에서 찾기 + for i, arg_value in enumerate(args): + if i < len(param_list): + param_name = param_list[i] + if param_name in param_names and param_name not in params: + if isinstance(arg_value, list): + params[f"{param_name}_count"] = len(arg_value) + elif isinstance(arg_value, str) and len(arg_value) > 50: + params[f"{param_name}_length"] = len(arg_value) + else: + params[param_name] = arg_value + + return params + + def _log_service(self, service_type: str = "SERVICE", track_params: List[str] = None): + """ + 서비스 레이어 로깅 데코레이터 + + Args: + service_type: 서비스 타입 + track_params: 추적할 파라미터 이름들 (함수 실행시 자동으로 값 추출) + """ + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + trace_id = self.get_trace_id() + operation = f"{service_type}_{func.__name__.upper()}" + + # 동적으로 파라미터 추출 + tracked_params = {} + if track_params: + tracked_params = self._extract_params(func, args, kwargs, track_params) + + # 파라미터를 문자열로 변환 + param_str = "" + if tracked_params: + param_strs = [f"{k}={v}" for k, v in tracked_params.items()] + param_str = " " + " ".join(param_strs) + + start_time = time.time() + + # 서비스 시작 로그 + logger.info(f"[{service_type}_START] trace_id={trace_id} operation={operation}{param_str}") + + try: + # 실제 함수 실행 + result = func(*args, **kwargs) + + # 실행 시간 계산 + execution_time = time.time() - start_time + + # 결과 정보 추가 + result_str = "" + if isinstance(result, list): + result_str = f" result_count={len(result)}" + elif isinstance(result, str): + result_str = f" result_length={len(result)}" + + # 서비스 성공 로그 + logger.info( + f"[{service_type}_SUCCESS] trace_id={trace_id} operation={operation} execution_time={execution_time:.4f}s{param_str}{result_str}") + + return result + + except Exception as e: + # 실행 시간 계산 + execution_time = time.time() - start_time + + # 서비스 실패 로그 + logger.error( + f"[{service_type}_ERROR] trace_id={trace_id} operation={operation} execution_time={execution_time:.4f}s{param_str} error={type(e).__name__}: {str(e)}") + + raise + + return wrapper + + return decorator + + def chunking(self): + """청킹 서비스 로깅 - chunk_size, overlap, documents 추적""" + return self._log_service("CHUNKING", ["chunk_size", "overlap", "overlap_ratio", "documents"]) + + +# 전역 ServiceLogger 싱글톤 +service_logger = ServiceLogger() \ No newline at end of file From 51c2fc27202fb9209138aca1fe66ec4731954af6 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Tue, 26 Aug 2025 11:00:46 +0900 Subject: [PATCH 4/8] =?UTF-8?q?chore=20:=20=ED=99=98=EA=B2=BD=EB=B3=84=20p?= =?UTF-8?q?oetry=20=EC=85=8B=ED=8C=85=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80,=20loguru=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=EA=B0=84=EB=8B=A8=ED=95=9C=20test=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/middleware/logging.py | 1 - .../app/test/test_service_logger.py | 239 ++++++++++++++++++ apps/pre-processing-service/poetry.lock | 104 +++++++- apps/pre-processing-service/pyproject.toml | 23 +- 4 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 apps/pre-processing-service/app/test/test_service_logger.py diff --git a/apps/pre-processing-service/app/middleware/logging.py b/apps/pre-processing-service/app/middleware/logging.py index 29cbe738..52df693c 100644 --- a/apps/pre-processing-service/app/middleware/logging.py +++ b/apps/pre-processing-service/app/middleware/logging.py @@ -4,7 +4,6 @@ from loguru import logger from starlette.middleware.base import BaseHTTPMiddleware - class LoggingMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): start_time = time.time() diff --git a/apps/pre-processing-service/app/test/test_service_logger.py b/apps/pre-processing-service/app/test/test_service_logger.py new file mode 100644 index 00000000..76a2134e --- /dev/null +++ b/apps/pre-processing-service/app/test/test_service_logger.py @@ -0,0 +1,239 @@ +import pytest +import time +from unittest.mock import patch +from contextvars import copy_context +from app.config.logging.ServiceLogger import service_logger + + +class TestServiceLogger: + """ServiceLogger 테스트""" + + def test_get_trace_id_default(self): + """기본 trace_id 테스트 - 첫 번째로 실행""" + # 강제로 trace_id 초기화 + from app.config.logging.ServiceLogger import trace_id_context + trace_id_context.set(None) + + # 기본값 테스트 + assert service_logger.get_trace_id() == "NO_TRACE" + + # 다시 초기화 (다른 테스트에 영향 안 주도록) + trace_id_context.set(None) + + def setup_method(self): + """각 테스트 전에 컨텍스트 초기화""" + from app.config.logging.ServiceLogger import trace_id_context + trace_id_context.set(None) + + def test_set_and_get_trace_id(self): + """trace_id 설정 및 조회 테스트""" + test_id = "test-trace-123" + service_logger.set_trace_id(test_id) + assert service_logger.get_trace_id() == test_id + + def test_get_trace_id_default(self): + """기본 trace_id 테스트""" + # 완전히 새로운 컨텍스트 생성 (기존 컨텍스트와 격리) + import contextvars + + # 새로운 빈 컨텍스트 생성 + new_context = contextvars.copy_context() + + def check_default_in_new_context(): + # 새 컨텍스트에서 trace_id_context는 기본값을 가져야 함 + from app.config.logging.ServiceLogger import trace_id_context + value = trace_id_context.get() + return value or "NO_TRACE" + + # 완전히 새로운 컨텍스트에서 실행 + result = new_context.run(check_default_in_new_context) + assert result == "NO_TRACE" + + @patch('app.config.logging.ServiceLogger.logger') # 올바른 모듈 경로 + def test_chunking_decorator_success(self, mock_logger): + """청킹 서비스 성공 테스트""" + service_logger.set_trace_id("test-123") + + @service_logger.chunking() + def test_chunk_function(documents, chunk_size=1000, overlap=200): + time.sleep(0.01) # 실행 시간 시뮬레이션 + return ["chunk1", "chunk2", "chunk3"] + + # 함수 실행 + result = test_chunk_function(["doc1", "doc2"], chunk_size=500, overlap=100) + + # 결과 검증 + assert result == ["chunk1", "chunk2", "chunk3"] + + # 로그 호출 검증 + assert mock_logger.info.call_count == 2 # START, SUCCESS + + # START 로그 검증 + start_call = mock_logger.info.call_args_list[0][0][0] + assert "[CHUNKING_START]" in start_call + assert "trace_id=test-123" in start_call + assert "chunk_size=500" in start_call + assert "overlap=100" in start_call + assert "documents_count=2" in start_call + + # SUCCESS 로그 검증 + success_call = mock_logger.info.call_args_list[1][0][0] + assert "[CHUNKING_SUCCESS]" in success_call + assert "execution_time=" in success_call + assert "result_count=3" in success_call + + @patch('app.config.logging.ServiceLogger.logger') # 올바른 모듈 경로 + def test_chunking_decorator_error(self, mock_logger): + """청킹 서비스 에러 테스트""" + service_logger.set_trace_id("error-test-456") + + @service_logger.chunking() + def failing_chunk_function(documents, chunk_size=1000): + raise ValueError("청킹 실패") + + # 에러 발생 확인 + with pytest.raises(ValueError, match="청킹 실패"): + failing_chunk_function(["doc1"], chunk_size=500) + + # 로그 호출 검증 + assert mock_logger.info.call_count == 1 # START만 + assert mock_logger.error.call_count == 1 # ERROR + + # ERROR 로그 검증 + error_call = mock_logger.error.call_args[0][0] + assert "[CHUNKING_ERROR]" in error_call + assert "trace_id=error-test-456" in error_call + assert "error=ValueError: 청킹 실패" in error_call + + def test_extract_params(self): + """파라미터 추출 테스트""" + + def sample_function(docs, chunk_size, overlap=200, model="test"): + pass + + # 테스트 데이터 + args = (["doc1", "doc2"], 1000) + kwargs = {"overlap": 150, "model": "custom"} + track_params = ["docs", "chunk_size", "overlap", "model"] # 'documents' -> 'docs' + + # 파라미터 추출 + params = service_logger._extract_params(sample_function, args, kwargs, track_params) + + # 결과 검증 + assert params["docs_count"] == 2 # list는 개수로 (docs_count로 변경) + assert params["chunk_size"] == 1000 # 숫자는 그대로 + assert params["overlap"] == 150 # kwargs 우선 + assert params["model"] == "custom" # kwargs 우선 + + def test_extract_params_string_length(self): + """긴 문자열 파라미터 추출 테스트""" + + def sample_function(text): + pass + + long_text = "a" * 100 # 50자 초과 + args = (long_text,) + kwargs = {} + track_params = ["text"] + + params = service_logger._extract_params(sample_function, args, kwargs, track_params) + assert params["text_length"] == 100 + + @patch('app.config.logging.ServiceLogger.logger') # 올바른 모듈 경로 + def test_log_service_generic(self, mock_logger): + """범용 log_service 데코레이터 테스트""" + service_logger.set_trace_id("generic-test") + + @service_logger.log_service("CUSTOM", ["param1", "param2"]) + def custom_function(param1, param2=42): + return "success" + + result = custom_function("value1", param2=99) + + assert result == "success" + assert mock_logger.info.call_count == 2 + + start_call = mock_logger.info.call_args_list[0][0][0] + assert "[CUSTOM_START]" in start_call + assert "param1=value1" in start_call + assert "param2=99" in start_call + + +# === 통합 테스트 === +class TestServiceLoggerIntegration: + """ServiceLogger 통합 테스트""" + + @patch('app.config.logging.ServiceLogger.logger') # 올바른 모듈 경로 + def test_real_world_scenario(self, mock_logger): + """실제 사용 시나리오 테스트""" + # 요청 시작 + service_logger.set_trace_id("req-789") + + @service_logger.chunking() + def chunk_documents(documents, chunk_size=1000, overlap=200): + # 실제 청킹 로직 시뮬레이션 + chunks = [] + for doc in documents: + num_chunks = len(doc) // chunk_size + 1 + chunks.extend([f"{doc[:10]}_chunk_{i}" for i in range(num_chunks)]) + return chunks + + # 웹에서 받은 파라미터 + user_docs = ["문서1 내용입니다" * 50, "문서2 내용입니다" * 30] + user_chunk_size = 800 + user_overlap = 150 + + # 함수 실행 + result = chunk_documents(user_docs, chunk_size=user_chunk_size, overlap=user_overlap) + + # 결과 검증 + assert len(result) > 0 + assert all("chunk_" in chunk for chunk in result) + + # 로깅 검증 + assert mock_logger.info.call_count == 2 # START, SUCCESS + + start_log = mock_logger.info.call_args_list[0][0][0] + assert "chunk_size=800" in start_log + assert "overlap=150" in start_log + assert "documents_count=2" in start_log + + success_log = mock_logger.info.call_args_list[1][0][0] + assert f"result_count={len(result)}" in success_log + + +# === 픽스처 === +@pytest.fixture +def clean_context(): + """각 테스트마다 깨끗한 컨텍스트 제공""" + ctx = copy_context() + return ctx + + +# === Context 격리 테스트 (추가) === +class TestContextIsolation: + """ContextVar 격리 테스트""" + + def test_context_isolation(self): + """각 컨텍스트가 독립적인지 테스트""" + + def context_test(trace_id, expected): + service_logger.set_trace_id(trace_id) + assert service_logger.get_trace_id() == expected + return service_logger.get_trace_id() + + # 서로 다른 컨텍스트에서 실행 + ctx1 = copy_context() + ctx2 = copy_context() + + result1 = ctx1.run(context_test, "context-1", "context-1") + result2 = ctx2.run(context_test, "context-2", "context-2") + + assert result1 == "context-1" + assert result2 == "context-2" + assert result1 != result2 + + +# === 실행 === +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/apps/pre-processing-service/poetry.lock b/apps/pre-processing-service/poetry.lock index 7a1d9857..f282fdc1 100644 --- a/apps/pre-processing-service/poetry.lock +++ b/apps/pre-processing-service/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -53,12 +53,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +groups = ["main", "dev", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\"", test = "sys_platform == \"win32\""} [[package]] name = "fastapi" @@ -109,6 +109,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "test"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + [[package]] name = "loguru" version = "0.7.3" @@ -128,6 +140,34 @@ win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "test"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev", "test"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "pydantic" version = "2.11.7" @@ -262,6 +302,62 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "test"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev", "test"] +files = [ + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["dev", "test"] +files = [ + {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, + {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "sniffio" version = "1.3.1" @@ -358,4 +454,4 @@ dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<4.0" -content-hash = "f502f0b2a368d47796eadbf214561cf67b4bb1963815da5b09cb539ba2ff8371" +content-hash = "a1e96f9cb40bd5323a6f9a3235b22ca67e7fb00117aa10d550c54255395e4cc3" diff --git a/apps/pre-processing-service/pyproject.toml b/apps/pre-processing-service/pyproject.toml index b6afdde3..2a95075f 100644 --- a/apps/pre-processing-service/pyproject.toml +++ b/apps/pre-processing-service/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + [project] name = "pre-processing-service" version = "0.1.0" @@ -5,15 +9,24 @@ description = "" authors = [ {name = "skip"} ] -readme = "README.md" + requires-python = ">=3.11,<4.0" dependencies = [ + # FastAPI: 비동기 웹 프레임워크, REST API 서버 구현에 사용 "fastapi (>=0.116.1,<0.117.0)", + # Uvicorn: ASGI 서버, FastAPI 앱 실행에 필요 "uvicorn (>=0.35.0,<0.36.0)", - "loguru (>=0.7.3,<0.8.0)" + # Loguru: 간편하고 강력한 로깅 라이브러리, 로그 관리에 사용 + "loguru (>=0.7.3,<0.8.0)", + # pytest: 테스트 프레임워크, 단위 테스트 및 통합 테스트에 사용 + "pytest (>=8.4.1,<9.0.0)" ] +[tool.poetry.group.dev.dependencies] +pytest-asyncio = "^1.1.0" -[build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" +[tool.poetry.group.test.dependencies] +pytest-asyncio = "^1.1.0" + +[tool.poetry] +packages = [{include = "app"}] \ No newline at end of file From 81b8c1b7cf43a68553611872c3bbb21ab53d75b2 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Tue, 26 Aug 2025 22:02:32 +0900 Subject: [PATCH 5/8] =?UTF-8?q?update=20:=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?&=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=A0=88=EB=B2=A8=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85=20=EC=A0=81=EC=9A=A9=20(=EC=95=A0=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=EC=9C=BC=EB=A1=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9)=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/app/api/router.py | 7 +- ...{DatabaseLogger.py => RepositoryLogger.py} | 82 ++++-- .../app/config/logging/ServiceLogger.py | 54 +++- .../app/services/ChunkingService.py | 16 ++ .../app/test/test_service_logger.py | 239 ------------------ 5 files changed, 131 insertions(+), 267 deletions(-) rename apps/pre-processing-service/app/config/logging/{DatabaseLogger.py => RepositoryLogger.py} (56%) create mode 100644 apps/pre-processing-service/app/services/ChunkingService.py delete mode 100644 apps/pre-processing-service/app/test/test_service_logger.py diff --git a/apps/pre-processing-service/app/api/router.py b/apps/pre-processing-service/app/api/router.py index 9f2572e1..0b4f6da1 100644 --- a/apps/pre-processing-service/app/api/router.py +++ b/apps/pre-processing-service/app/api/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.decorators.logging import log_api_call +from app.services.ChunkingService import ChunkingService router = APIRouter() @@ -13,3 +13,8 @@ async def root(): async def say_hello(name: str): return {"message": f"Hello {name}"} +@router.post("/chunk") +async def chunk_text(text: str, chunk_size: int = 100, overlap: int = 20): + service = ChunkingService() + chunks = service.chunk_text(text, chunk_size, overlap) + return {"chunks": chunks, "count": len(chunks)} \ No newline at end of file diff --git a/apps/pre-processing-service/app/config/logging/DatabaseLogger.py b/apps/pre-processing-service/app/config/logging/RepositoryLogger.py similarity index 56% rename from apps/pre-processing-service/app/config/logging/DatabaseLogger.py rename to apps/pre-processing-service/app/config/logging/RepositoryLogger.py index a7003fec..99b80e74 100644 --- a/apps/pre-processing-service/app/config/logging/DatabaseLogger.py +++ b/apps/pre-processing-service/app/config/logging/RepositoryLogger.py @@ -10,7 +10,7 @@ class DatabaseLogger: - """통합 데이터베이스 로깅을 관리하는 클래스 (벡터DB + RDB)""" + """통합 데이터베이스 로깅을 관리하는 클래스 애노테이션으로 적용 가능""" def set_trace_id(self, trace_id: str): """스프링에서 받은 추적 ID 설정""" @@ -60,21 +60,20 @@ def _format_result_info(self, result: Any) -> str: else: return "" - def _log_db_operation(self, db_type: str, operation: str, track_params: List[str] = None): + def _log_database(self, db_type: str = "DATABASE", track_params: List[str] = None): """ - 데이터베이스 작업 로깅 데코레이터의 핵심 로직 + 데이터베이스 레이어 로깅 데코레이터 Args: - db_type: 데이터베이스 타입 (VECTOR_DB, RDB) - operation: 작업 타입 - track_params: 추적할 파라미터 이름들 + db_type: 데이터베이스 타입 + track_params: 추적할 파라미터 이름들 (함수 실행시 자동으로 값 추출) """ def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): trace_id = self.get_trace_id() - full_operation = f"{db_type}_{operation}_{func.__name__.upper()}" + operation = f"{db_type}_{func.__name__.upper()}" # 동적으로 파라미터 추출 tracked_params = {} @@ -89,32 +88,27 @@ def wrapper(*args, **kwargs): start_time = time.time() - # 작업 시작 로그 - logger.info(f"[{db_type}_START] trace_id={trace_id} operation={full_operation}{param_str}") + logger.info(f"[{db_type}_START] trace_id={trace_id} operation={operation}{param_str}") try: # 실제 함수 실행 result = func(*args, **kwargs) - # 실행 시간 계산 execution_time = time.time() - start_time - # 결과 정보 + # 결과 정보 추가 result_str = self._format_result_info(result) - # 성공 로그 logger.info( - f"[{db_type}_SUCCESS] trace_id={trace_id} operation={full_operation} execution_time={execution_time:.4f}s{param_str}{result_str}") + f"[{db_type}_SUCCESS] trace_id={trace_id} operation={operation} execution_time={execution_time:.4f}s{param_str}{result_str}") return result except Exception as e: - # 실행 시간 계산 execution_time = time.time() - start_time - # 실패 로그 logger.error( - f"[{db_type}_ERROR] trace_id={trace_id} operation={full_operation} execution_time={execution_time:.4f}s{param_str} error={type(e).__name__}: {str(e)}") + f"[{db_type}_ERROR] trace_id={trace_id} operation={operation} execution_time={execution_time:.4f}s{param_str} error={type(e).__name__}: {str(e)}") raise @@ -122,10 +116,60 @@ def wrapper(*args, **kwargs): return decorator - def custom_db_operation(self, db_type: str, operation: str, track_params: List[str] = None): - """커스텀 데이터베이스 작업 로깅 - 사용자 정의 용""" - return self._log_db_operation(db_type, operation, track_params) + def log_database_class(self, db_type: str = "DATABASE", + method_params: Dict[str, List[str]] = None, + exclude_methods: List[str] = None): + """ + 클래스 전체에 데이터베이스 로깅을 적용하는 데코레이터 + + Args: + db_type: 데이터베이스 타입 + method_params: 메서드별 추적할 파라미터 {메서드명: [파라미터명들]} + exclude_methods: 로깅에서 제외할 메서드명들 + """ + + def class_decorator(cls): + exclude_list = exclude_methods or ['__init__', '__new__'] + + for name, method in inspect.getmembers(cls, predicate=inspect.isfunction): + if name.startswith('_') or name in exclude_list: + continue + + # 메서드별 파라미터 설정 + track_params = method_params.get(name) if method_params else None + + # 기존 _log_database 데코레이터 적용 + wrapped_method = self._log_database(db_type, track_params)(method) + setattr(cls, name, wrapped_method) + + return cls + + return class_decorator + + """ 메서드 데코레이터 + @log_database 데코레이터들을 여기에 추가 + db_type : 데이터베이스 타입 (예: VECTOR_DB, RDB) + track_params : 추적할 파라미터 이름들 + """ + + def vector_db(self): + return self._log_database("VECTOR_DB", ["query", "embeddings", "top_k", "collection", "filters"]) + + """ 클래스 데코레이터 + @log_database_class 데코레이터들을 여기에 추가 + db_type : 데이터베이스 타입 (예: VECTOR_DB, RDB) + method_params : {메서드명: [추적할 파라미터 이름들]} + exclude_methods : 로깅에서 제외할 메서드명들 + """ + def rdb_class(self): + """관계형 데이터베이스 클래스 로깅""" + return self.log_database_class("RDB", { + "select": ["table", "where_clause", "limit"], + "insert": ["table", "data"], + "update": ["table", "data", "where_clause"], + "delete": ["table", "where_clause"] + }) # 전역 DatabaseLogger 싱글톤 db_logger = DatabaseLogger() \ No newline at end of file diff --git a/apps/pre-processing-service/app/config/logging/ServiceLogger.py b/apps/pre-processing-service/app/config/logging/ServiceLogger.py index 61688858..e2a8f0d8 100644 --- a/apps/pre-processing-service/app/config/logging/ServiceLogger.py +++ b/apps/pre-processing-service/app/config/logging/ServiceLogger.py @@ -10,8 +10,7 @@ class ServiceLogger: - """서비스 레이어 로깅을 관리하는 클래스 어노테이션으로 적용 가능""" - + """서비스 레이어 로깅을 관리하는 클래스 애노테이션으로 적용 가능""" def set_trace_id(self, trace_id: str): """스프링에서 받은 추적 ID 설정""" @@ -76,14 +75,12 @@ def wrapper(*args, **kwargs): start_time = time.time() - # 서비스 시작 로그 logger.info(f"[{service_type}_START] trace_id={trace_id} operation={operation}{param_str}") try: # 실제 함수 실행 result = func(*args, **kwargs) - # 실행 시간 계산 execution_time = time.time() - start_time # 결과 정보 추가 @@ -93,17 +90,14 @@ def wrapper(*args, **kwargs): elif isinstance(result, str): result_str = f" result_length={len(result)}" - # 서비스 성공 로그 logger.info( f"[{service_type}_SUCCESS] trace_id={trace_id} operation={operation} execution_time={execution_time:.4f}s{param_str}{result_str}") return result except Exception as e: - # 실행 시간 계산 execution_time = time.time() - start_time - # 서비스 실패 로그 logger.error( f"[{service_type}_ERROR] trace_id={trace_id} operation={operation} execution_time={execution_time:.4f}s{param_str} error={type(e).__name__}: {str(e)}") @@ -113,10 +107,54 @@ def wrapper(*args, **kwargs): return decorator + def log_service_class(self, service_type: str = "SERVICE", + method_params: Dict[str, List[str]] = None, + exclude_methods: List[str] = None): + """ + 클래스 전체에 로깅을 적용하는 데코레이터 + + Args: + service_type: 서비스 타입 + method_params: 메서드별 추적할 파라미터 {메서드명: [파라미터명들]} + exclude_methods: 로깅에서 제외할 메서드명들 + """ + + def class_decorator(cls): + exclude_list = exclude_methods or ['__init__', '__new__'] + + for name, method in inspect.getmembers(cls, predicate=inspect.isfunction): + if name.startswith('_') or name in exclude_list: + continue + + # 메서드별 파라미터 설정 + track_params = method_params.get(name) if method_params else None + + # 기존 _log_service 데코레이터 적용 + wrapped_method = self._log_service(service_type, track_params)(method) + setattr(cls, name, wrapped_method) + + return cls + + return class_decorator + + """ 메서드 데코레이터 + @log_service 데코레이터들을 여기에 추가 + service_type : 서비스 이름 (예: CHUNKING) + track_params : 추적할 파라미터 이름들 + """ def chunking(self): - """청킹 서비스 로깅 - chunk_size, overlap, documents 추적""" return self._log_service("CHUNKING", ["chunk_size", "overlap", "overlap_ratio", "documents"]) + """ 클래스 데코레이터 + @log_service_class 데코레이터들을 여기에 추가 + service_type : 서비스 이름 (예: CHUNKING) + method_params : {메서드명: [추적할 파라미터 이름들]} + exclude_methods : 로깅에서 제외할 메서드명들 + """ + def chunking_class(self): + return self.log_service_class("CHUNKING", { + "chunk_text": ["chunk_size", "overlap"] + }) # 전역 ServiceLogger 싱글톤 service_logger = ServiceLogger() \ No newline at end of file diff --git a/apps/pre-processing-service/app/services/ChunkingService.py b/apps/pre-processing-service/app/services/ChunkingService.py new file mode 100644 index 00000000..a2d20e1d --- /dev/null +++ b/apps/pre-processing-service/app/services/ChunkingService.py @@ -0,0 +1,16 @@ +from app.config.logging.ServiceLogger import service_logger + + +@service_logger.chunking_class() +class ChunkingService: + + def chunk_text(self, text: str, chunk_size: int = 100, overlap: int = 20): + """텍스트를 청크로 분할""" + chunks = [] + + for i in range(0, len(text), chunk_size - overlap): + chunk = text[i:i + chunk_size] + if chunk.strip(): + chunks.append(chunk.strip()) + + return chunks \ No newline at end of file diff --git a/apps/pre-processing-service/app/test/test_service_logger.py b/apps/pre-processing-service/app/test/test_service_logger.py deleted file mode 100644 index 76a2134e..00000000 --- a/apps/pre-processing-service/app/test/test_service_logger.py +++ /dev/null @@ -1,239 +0,0 @@ -import pytest -import time -from unittest.mock import patch -from contextvars import copy_context -from app.config.logging.ServiceLogger import service_logger - - -class TestServiceLogger: - """ServiceLogger 테스트""" - - def test_get_trace_id_default(self): - """기본 trace_id 테스트 - 첫 번째로 실행""" - # 강제로 trace_id 초기화 - from app.config.logging.ServiceLogger import trace_id_context - trace_id_context.set(None) - - # 기본값 테스트 - assert service_logger.get_trace_id() == "NO_TRACE" - - # 다시 초기화 (다른 테스트에 영향 안 주도록) - trace_id_context.set(None) - - def setup_method(self): - """각 테스트 전에 컨텍스트 초기화""" - from app.config.logging.ServiceLogger import trace_id_context - trace_id_context.set(None) - - def test_set_and_get_trace_id(self): - """trace_id 설정 및 조회 테스트""" - test_id = "test-trace-123" - service_logger.set_trace_id(test_id) - assert service_logger.get_trace_id() == test_id - - def test_get_trace_id_default(self): - """기본 trace_id 테스트""" - # 완전히 새로운 컨텍스트 생성 (기존 컨텍스트와 격리) - import contextvars - - # 새로운 빈 컨텍스트 생성 - new_context = contextvars.copy_context() - - def check_default_in_new_context(): - # 새 컨텍스트에서 trace_id_context는 기본값을 가져야 함 - from app.config.logging.ServiceLogger import trace_id_context - value = trace_id_context.get() - return value or "NO_TRACE" - - # 완전히 새로운 컨텍스트에서 실행 - result = new_context.run(check_default_in_new_context) - assert result == "NO_TRACE" - - @patch('app.config.logging.ServiceLogger.logger') # 올바른 모듈 경로 - def test_chunking_decorator_success(self, mock_logger): - """청킹 서비스 성공 테스트""" - service_logger.set_trace_id("test-123") - - @service_logger.chunking() - def test_chunk_function(documents, chunk_size=1000, overlap=200): - time.sleep(0.01) # 실행 시간 시뮬레이션 - return ["chunk1", "chunk2", "chunk3"] - - # 함수 실행 - result = test_chunk_function(["doc1", "doc2"], chunk_size=500, overlap=100) - - # 결과 검증 - assert result == ["chunk1", "chunk2", "chunk3"] - - # 로그 호출 검증 - assert mock_logger.info.call_count == 2 # START, SUCCESS - - # START 로그 검증 - start_call = mock_logger.info.call_args_list[0][0][0] - assert "[CHUNKING_START]" in start_call - assert "trace_id=test-123" in start_call - assert "chunk_size=500" in start_call - assert "overlap=100" in start_call - assert "documents_count=2" in start_call - - # SUCCESS 로그 검증 - success_call = mock_logger.info.call_args_list[1][0][0] - assert "[CHUNKING_SUCCESS]" in success_call - assert "execution_time=" in success_call - assert "result_count=3" in success_call - - @patch('app.config.logging.ServiceLogger.logger') # 올바른 모듈 경로 - def test_chunking_decorator_error(self, mock_logger): - """청킹 서비스 에러 테스트""" - service_logger.set_trace_id("error-test-456") - - @service_logger.chunking() - def failing_chunk_function(documents, chunk_size=1000): - raise ValueError("청킹 실패") - - # 에러 발생 확인 - with pytest.raises(ValueError, match="청킹 실패"): - failing_chunk_function(["doc1"], chunk_size=500) - - # 로그 호출 검증 - assert mock_logger.info.call_count == 1 # START만 - assert mock_logger.error.call_count == 1 # ERROR - - # ERROR 로그 검증 - error_call = mock_logger.error.call_args[0][0] - assert "[CHUNKING_ERROR]" in error_call - assert "trace_id=error-test-456" in error_call - assert "error=ValueError: 청킹 실패" in error_call - - def test_extract_params(self): - """파라미터 추출 테스트""" - - def sample_function(docs, chunk_size, overlap=200, model="test"): - pass - - # 테스트 데이터 - args = (["doc1", "doc2"], 1000) - kwargs = {"overlap": 150, "model": "custom"} - track_params = ["docs", "chunk_size", "overlap", "model"] # 'documents' -> 'docs' - - # 파라미터 추출 - params = service_logger._extract_params(sample_function, args, kwargs, track_params) - - # 결과 검증 - assert params["docs_count"] == 2 # list는 개수로 (docs_count로 변경) - assert params["chunk_size"] == 1000 # 숫자는 그대로 - assert params["overlap"] == 150 # kwargs 우선 - assert params["model"] == "custom" # kwargs 우선 - - def test_extract_params_string_length(self): - """긴 문자열 파라미터 추출 테스트""" - - def sample_function(text): - pass - - long_text = "a" * 100 # 50자 초과 - args = (long_text,) - kwargs = {} - track_params = ["text"] - - params = service_logger._extract_params(sample_function, args, kwargs, track_params) - assert params["text_length"] == 100 - - @patch('app.config.logging.ServiceLogger.logger') # 올바른 모듈 경로 - def test_log_service_generic(self, mock_logger): - """범용 log_service 데코레이터 테스트""" - service_logger.set_trace_id("generic-test") - - @service_logger.log_service("CUSTOM", ["param1", "param2"]) - def custom_function(param1, param2=42): - return "success" - - result = custom_function("value1", param2=99) - - assert result == "success" - assert mock_logger.info.call_count == 2 - - start_call = mock_logger.info.call_args_list[0][0][0] - assert "[CUSTOM_START]" in start_call - assert "param1=value1" in start_call - assert "param2=99" in start_call - - -# === 통합 테스트 === -class TestServiceLoggerIntegration: - """ServiceLogger 통합 테스트""" - - @patch('app.config.logging.ServiceLogger.logger') # 올바른 모듈 경로 - def test_real_world_scenario(self, mock_logger): - """실제 사용 시나리오 테스트""" - # 요청 시작 - service_logger.set_trace_id("req-789") - - @service_logger.chunking() - def chunk_documents(documents, chunk_size=1000, overlap=200): - # 실제 청킹 로직 시뮬레이션 - chunks = [] - for doc in documents: - num_chunks = len(doc) // chunk_size + 1 - chunks.extend([f"{doc[:10]}_chunk_{i}" for i in range(num_chunks)]) - return chunks - - # 웹에서 받은 파라미터 - user_docs = ["문서1 내용입니다" * 50, "문서2 내용입니다" * 30] - user_chunk_size = 800 - user_overlap = 150 - - # 함수 실행 - result = chunk_documents(user_docs, chunk_size=user_chunk_size, overlap=user_overlap) - - # 결과 검증 - assert len(result) > 0 - assert all("chunk_" in chunk for chunk in result) - - # 로깅 검증 - assert mock_logger.info.call_count == 2 # START, SUCCESS - - start_log = mock_logger.info.call_args_list[0][0][0] - assert "chunk_size=800" in start_log - assert "overlap=150" in start_log - assert "documents_count=2" in start_log - - success_log = mock_logger.info.call_args_list[1][0][0] - assert f"result_count={len(result)}" in success_log - - -# === 픽스처 === -@pytest.fixture -def clean_context(): - """각 테스트마다 깨끗한 컨텍스트 제공""" - ctx = copy_context() - return ctx - - -# === Context 격리 테스트 (추가) === -class TestContextIsolation: - """ContextVar 격리 테스트""" - - def test_context_isolation(self): - """각 컨텍스트가 독립적인지 테스트""" - - def context_test(trace_id, expected): - service_logger.set_trace_id(trace_id) - assert service_logger.get_trace_id() == expected - return service_logger.get_trace_id() - - # 서로 다른 컨텍스트에서 실행 - ctx1 = copy_context() - ctx2 = copy_context() - - result1 = ctx1.run(context_test, "context-1", "context-1") - result2 = ctx2.run(context_test, "context-2", "context-2") - - assert result1 == "context-1" - assert result2 == "context-2" - assert result1 != result2 - - -# === 실행 === -if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file From 73280f6c608e8e9420f24f5d9abe2bf63bcb1a9e Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 27 Aug 2025 10:22:10 +0900 Subject: [PATCH 6/8] =?UTF-8?q?update=20:=20Service=20&=20Repository=20Lay?= =?UTF-8?q?er=20=EB=A1=9C=EA=B9=85=20AOP=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pre-processing-service/app/__init__.py | 0 .../app/api/__init__.py | 0 apps/pre-processing-service/app/api/router.py | 20 - .../app/config/logging/RepositoryLogger.py | 175 ------- .../logging/RepositoryLoggerMiddleware.py | 117 +++++ .../app/config/logging/ServiceLogger.py | 160 ------ .../config/logging/ServiceLoggerMiddleware.py | 109 +++++ .../app/core/__init__.py | 0 .../pre-processing-service/app/core/config.py | 0 .../pre-processing-service/app/db/__init__.py | 0 .../app/db/db_connecter.py | 0 .../app/decorators/__init__.py | 0 .../app/decorators/logging.py | 54 --- apps/pre-processing-service/app/main.py | 17 - .../app/middleware/__init__.py | 0 .../app/middleware/logging.py | 37 -- .../app/services/ChunkingService.py | 16 - .../app/services/__init__.py | 0 .../app/services/preprocessing_service.py | 0 apps/pre-processing-service/poetry.lock | 457 ------------------ apps/pre-processing-service/pyproject.toml | 32 -- 21 files changed, 226 insertions(+), 968 deletions(-) delete mode 100644 apps/pre-processing-service/app/__init__.py delete mode 100644 apps/pre-processing-service/app/api/__init__.py delete mode 100644 apps/pre-processing-service/app/api/router.py delete mode 100644 apps/pre-processing-service/app/config/logging/RepositoryLogger.py create mode 100644 apps/pre-processing-service/app/config/logging/RepositoryLoggerMiddleware.py delete mode 100644 apps/pre-processing-service/app/config/logging/ServiceLogger.py create mode 100644 apps/pre-processing-service/app/config/logging/ServiceLoggerMiddleware.py delete mode 100644 apps/pre-processing-service/app/core/__init__.py delete mode 100644 apps/pre-processing-service/app/core/config.py delete mode 100644 apps/pre-processing-service/app/db/__init__.py delete mode 100644 apps/pre-processing-service/app/db/db_connecter.py delete mode 100644 apps/pre-processing-service/app/decorators/__init__.py delete mode 100644 apps/pre-processing-service/app/decorators/logging.py delete mode 100644 apps/pre-processing-service/app/main.py delete mode 100644 apps/pre-processing-service/app/middleware/__init__.py delete mode 100644 apps/pre-processing-service/app/middleware/logging.py delete mode 100644 apps/pre-processing-service/app/services/ChunkingService.py delete mode 100644 apps/pre-processing-service/app/services/__init__.py delete mode 100644 apps/pre-processing-service/app/services/preprocessing_service.py delete mode 100644 apps/pre-processing-service/poetry.lock delete mode 100644 apps/pre-processing-service/pyproject.toml diff --git a/apps/pre-processing-service/app/__init__.py b/apps/pre-processing-service/app/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/app/api/__init__.py b/apps/pre-processing-service/app/api/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/app/api/router.py b/apps/pre-processing-service/app/api/router.py deleted file mode 100644 index 0b4f6da1..00000000 --- a/apps/pre-processing-service/app/api/router.py +++ /dev/null @@ -1,20 +0,0 @@ -from fastapi import APIRouter -from app.services.ChunkingService import ChunkingService - -router = APIRouter() - -@router.get("/") -async def root(): - return {"message": "Hello World"} - - -@router.get("/hello/{name}") -# @log_api_call -async def say_hello(name: str): - return {"message": f"Hello {name}"} - -@router.post("/chunk") -async def chunk_text(text: str, chunk_size: int = 100, overlap: int = 20): - service = ChunkingService() - chunks = service.chunk_text(text, chunk_size, overlap) - return {"chunks": chunks, "count": len(chunks)} \ No newline at end of file diff --git a/apps/pre-processing-service/app/config/logging/RepositoryLogger.py b/apps/pre-processing-service/app/config/logging/RepositoryLogger.py deleted file mode 100644 index 99b80e74..00000000 --- a/apps/pre-processing-service/app/config/logging/RepositoryLogger.py +++ /dev/null @@ -1,175 +0,0 @@ -import functools -import inspect -import time -from typing import Optional, Dict, Any, List -from loguru import logger -from contextvars import ContextVar - -# 추적 ID를 저장할 ContextVar -trace_id_context: ContextVar[Optional[str]] = ContextVar('trace_id', default=None) - - -class DatabaseLogger: - """통합 데이터베이스 로깅을 관리하는 클래스 애노테이션으로 적용 가능""" - - def set_trace_id(self, trace_id: str): - """스프링에서 받은 추적 ID 설정""" - trace_id_context.set(trace_id) - - def get_trace_id(self) -> str: - """현재 추적 ID 반환""" - return trace_id_context.get() or "NO_TRACE" - - def _extract_params(self, func, args, kwargs, param_names: List[str]) -> Dict[str, Any]: - """함수의 실제 파라미터 값들을 추출""" - params = {} - - # 함수의 파라미터 이름들 가져오기 - sig = inspect.signature(func) - param_list = list(sig.parameters.keys()) - - # kwargs에서 직접 찾기 - for param_name in param_names: - if param_name in kwargs: - params[param_name] = kwargs[param_name] - - # args에서 찾기 - for i, arg_value in enumerate(args): - if i < len(param_list): - param_name = param_list[i] - if param_name in param_names and param_name not in params: - if isinstance(arg_value, list): - params[f"{param_name}_count"] = len(arg_value) - elif isinstance(arg_value, str) and len(arg_value) > 50: - params[f"{param_name}_length"] = len(arg_value) - else: - params[param_name] = arg_value - - return params - - def _format_result_info(self, result: Any) -> str: - """결과 정보를 포맷팅""" - if isinstance(result, list): - return f" result_count={len(result)}" - elif isinstance(result, dict): - return f" result_keys={len(result.keys())}" - elif isinstance(result, str): - return f" result_length={len(result)}" - elif hasattr(result, '__len__'): - return f" result_count={len(result)}" - else: - return "" - - def _log_database(self, db_type: str = "DATABASE", track_params: List[str] = None): - """ - 데이터베이스 레이어 로깅 데코레이터 - - Args: - db_type: 데이터베이스 타입 - track_params: 추적할 파라미터 이름들 (함수 실행시 자동으로 값 추출) - """ - - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - trace_id = self.get_trace_id() - operation = f"{db_type}_{func.__name__.upper()}" - - # 동적으로 파라미터 추출 - tracked_params = {} - if track_params: - tracked_params = self._extract_params(func, args, kwargs, track_params) - - # 파라미터를 문자열로 변환 - param_str = "" - if tracked_params: - param_strs = [f"{k}={v}" for k, v in tracked_params.items()] - param_str = " " + " ".join(param_strs) - - start_time = time.time() - - logger.info(f"[{db_type}_START] trace_id={trace_id} operation={operation}{param_str}") - - try: - # 실제 함수 실행 - result = func(*args, **kwargs) - - execution_time = time.time() - start_time - - # 결과 정보 추가 - result_str = self._format_result_info(result) - - logger.info( - f"[{db_type}_SUCCESS] trace_id={trace_id} operation={operation} execution_time={execution_time:.4f}s{param_str}{result_str}") - - return result - - except Exception as e: - execution_time = time.time() - start_time - - logger.error( - f"[{db_type}_ERROR] trace_id={trace_id} operation={operation} execution_time={execution_time:.4f}s{param_str} error={type(e).__name__}: {str(e)}") - - raise - - return wrapper - - return decorator - - def log_database_class(self, db_type: str = "DATABASE", - method_params: Dict[str, List[str]] = None, - exclude_methods: List[str] = None): - """ - 클래스 전체에 데이터베이스 로깅을 적용하는 데코레이터 - - Args: - db_type: 데이터베이스 타입 - method_params: 메서드별 추적할 파라미터 {메서드명: [파라미터명들]} - exclude_methods: 로깅에서 제외할 메서드명들 - """ - - def class_decorator(cls): - exclude_list = exclude_methods or ['__init__', '__new__'] - - for name, method in inspect.getmembers(cls, predicate=inspect.isfunction): - if name.startswith('_') or name in exclude_list: - continue - - # 메서드별 파라미터 설정 - track_params = method_params.get(name) if method_params else None - - # 기존 _log_database 데코레이터 적용 - wrapped_method = self._log_database(db_type, track_params)(method) - setattr(cls, name, wrapped_method) - - return cls - - return class_decorator - - """ 메서드 데코레이터 - @log_database 데코레이터들을 여기에 추가 - db_type : 데이터베이스 타입 (예: VECTOR_DB, RDB) - track_params : 추적할 파라미터 이름들 - """ - - def vector_db(self): - return self._log_database("VECTOR_DB", ["query", "embeddings", "top_k", "collection", "filters"]) - - """ 클래스 데코레이터 - @log_database_class 데코레이터들을 여기에 추가 - db_type : 데이터베이스 타입 (예: VECTOR_DB, RDB) - method_params : {메서드명: [추적할 파라미터 이름들]} - exclude_methods : 로깅에서 제외할 메서드명들 - """ - - def rdb_class(self): - """관계형 데이터베이스 클래스 로깅""" - return self.log_database_class("RDB", { - "select": ["table", "where_clause", "limit"], - "insert": ["table", "data"], - "update": ["table", "data", "where_clause"], - "delete": ["table", "where_clause"] - }) - -# 전역 DatabaseLogger 싱글톤 -db_logger = DatabaseLogger() \ No newline at end of file diff --git a/apps/pre-processing-service/app/config/logging/RepositoryLoggerMiddleware.py b/apps/pre-processing-service/app/config/logging/RepositoryLoggerMiddleware.py new file mode 100644 index 00000000..703834a6 --- /dev/null +++ b/apps/pre-processing-service/app/config/logging/RepositoryLoggerMiddleware.py @@ -0,0 +1,117 @@ +import time +from typing import Dict, Any, List +from fastapi import Request +from loguru import logger +from contextvars import ContextVar + +trace_id_context: ContextVar[str] = ContextVar('trace_id', default="NO_TRACE_ID") + +class RepositoryLoggingDependency: + """ + 레포지토리 로깅을 위한 의존성 클래스 + :param repository_type: 레포지토리 유형 (예: "VECTOR_DB", "RDB", "REDIS") + :param track_params: 추적할 매개변수 이름 목록 + """ + + def __init__(self, repository_type: str, track_params: List[str] = None): + self.repository_type = repository_type + self.track_params = track_params or [] + + async def __call__(self, request: Request): + """ + 의존성 주입 시 호출되는 메서드 + :param request: FastAPI Request 객체 + :return: 레포지토리 유형과 추출된 매개변수 딕셔너리 + """ + trace_id = trace_id_context.get("NO_TRACE_ID") + start_time = time.time() + + # 파라미터 추출 + params = await self._extract_params(request) + param_str = "" + if params: + param_strs = [f"{k}={v}" for k, v in params.items()] + param_str = " " + " ".join(param_strs) + + logger.info(f"[{self.repository_type}_START] trace_id={trace_id}{param_str}") + + # 응답 시 사용할 정보를 request.state에 저장 + request.state.repository_type = self.repository_type + request.state.start_time = start_time + request.state.param_str = param_str + + return {"repository_type": self.repository_type, "params": params} + + async def _extract_params(self, request: Request) -> Dict[str, Any]: + """ + 요청에서 추적 파라미터 추출 + :param request: FastAPI Request 객체 + :return: 추출된 매개변수 딕셔너리 + """ + params = {} + + try: + # Query Parameters 추출 + for key, value in request.query_params.items(): + if key in self.track_params: + params[key] = value + + # JSON Body 추출 + try: + json_body = await request.json() + if json_body: + for key, value in json_body.items(): + if key in self.track_params: + if isinstance(value, str) and len(value) > 50: + params[f"{key}_length"] = len(value) + elif isinstance(value, list): + params[f"{key}_count"] = len(value) + else: + params[key] = value + except: + pass + except: + pass + + return params + + +# 레포지토리별 의존성 인스턴스 생성 +vector_db_dependency = RepositoryLoggingDependency("VECTOR_DB", ["query", "embeddings", "top_k", "collection", "filters"]) +rdb_dependency = RepositoryLoggingDependency("RDB", ["table", "where_clause", "limit", "data"]) +redis_dependency = RepositoryLoggingDependency("REDIS", ["key", "value", "ttl", "pattern"]) +elasticsearch_dependency = RepositoryLoggingDependency("ELASTICSEARCH", ["index", "query", "size", "document"]) + + +# 응답 로깅을 위한 의존성 +async def log_repository_response(request: Request): + """ + 레포지토리 응답 시 성공 로그 기록 + :param request: FastAPI Request 객체 + """ + if hasattr(request.state, 'repository_type'): + trace_id = trace_id_context.get("NO_TRACE_ID") + duration = time.time() - request.state.start_time + logger.info( + f"[{request.state.repository_type}_SUCCESS] trace_id={trace_id} execution_time={duration:.4f}s{request.state.param_str}") + return None + + +""" +라우터 예시 +@router.post("/search") +async def vector_search( + query: str, + top_k: int = 10, + request: Request = None, + _: None = Depends(vector_db_dependency), # 직접 의존성 주입 + __: None = Depends(log_repository_response) +): + +또는 라우터 레벨에서: +vector_router = APIRouter( + prefix="/vector", + tags=["vector"], + dependencies=[Depends(vector_db_dependency)] +) +""" \ No newline at end of file diff --git a/apps/pre-processing-service/app/config/logging/ServiceLogger.py b/apps/pre-processing-service/app/config/logging/ServiceLogger.py deleted file mode 100644 index e2a8f0d8..00000000 --- a/apps/pre-processing-service/app/config/logging/ServiceLogger.py +++ /dev/null @@ -1,160 +0,0 @@ -import functools -import inspect -import time -from typing import Optional, Dict, Any, List -from loguru import logger -from contextvars import ContextVar - -# 추적 ID를 저장할 ContextVar -trace_id_context: ContextVar[Optional[str]] = ContextVar('trace_id', default=None) - - -class ServiceLogger: - """서비스 레이어 로깅을 관리하는 클래스 애노테이션으로 적용 가능""" - - def set_trace_id(self, trace_id: str): - """스프링에서 받은 추적 ID 설정""" - trace_id_context.set(trace_id) - - def get_trace_id(self) -> str: - """현재 추적 ID 반환""" - return trace_id_context.get() or "NO_TRACE" - - def _extract_params(self, func, args, kwargs, param_names: List[str]) -> Dict[str, Any]: - """함수의 실제 파라미터 값들을 추출""" - params = {} - - # 함수의 파라미터 이름들 가져오기 - sig = inspect.signature(func) - param_list = list(sig.parameters.keys()) - - # kwargs에서 직접 찾기 - for param_name in param_names: - if param_name in kwargs: - params[param_name] = kwargs[param_name] - - # args에서 찾기 - for i, arg_value in enumerate(args): - if i < len(param_list): - param_name = param_list[i] - if param_name in param_names and param_name not in params: - if isinstance(arg_value, list): - params[f"{param_name}_count"] = len(arg_value) - elif isinstance(arg_value, str) and len(arg_value) > 50: - params[f"{param_name}_length"] = len(arg_value) - else: - params[param_name] = arg_value - - return params - - def _log_service(self, service_type: str = "SERVICE", track_params: List[str] = None): - """ - 서비스 레이어 로깅 데코레이터 - - Args: - service_type: 서비스 타입 - track_params: 추적할 파라미터 이름들 (함수 실행시 자동으로 값 추출) - """ - - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - trace_id = self.get_trace_id() - operation = f"{service_type}_{func.__name__.upper()}" - - # 동적으로 파라미터 추출 - tracked_params = {} - if track_params: - tracked_params = self._extract_params(func, args, kwargs, track_params) - - # 파라미터를 문자열로 변환 - param_str = "" - if tracked_params: - param_strs = [f"{k}={v}" for k, v in tracked_params.items()] - param_str = " " + " ".join(param_strs) - - start_time = time.time() - - logger.info(f"[{service_type}_START] trace_id={trace_id} operation={operation}{param_str}") - - try: - # 실제 함수 실행 - result = func(*args, **kwargs) - - execution_time = time.time() - start_time - - # 결과 정보 추가 - result_str = "" - if isinstance(result, list): - result_str = f" result_count={len(result)}" - elif isinstance(result, str): - result_str = f" result_length={len(result)}" - - logger.info( - f"[{service_type}_SUCCESS] trace_id={trace_id} operation={operation} execution_time={execution_time:.4f}s{param_str}{result_str}") - - return result - - except Exception as e: - execution_time = time.time() - start_time - - logger.error( - f"[{service_type}_ERROR] trace_id={trace_id} operation={operation} execution_time={execution_time:.4f}s{param_str} error={type(e).__name__}: {str(e)}") - - raise - - return wrapper - - return decorator - - def log_service_class(self, service_type: str = "SERVICE", - method_params: Dict[str, List[str]] = None, - exclude_methods: List[str] = None): - """ - 클래스 전체에 로깅을 적용하는 데코레이터 - - Args: - service_type: 서비스 타입 - method_params: 메서드별 추적할 파라미터 {메서드명: [파라미터명들]} - exclude_methods: 로깅에서 제외할 메서드명들 - """ - - def class_decorator(cls): - exclude_list = exclude_methods or ['__init__', '__new__'] - - for name, method in inspect.getmembers(cls, predicate=inspect.isfunction): - if name.startswith('_') or name in exclude_list: - continue - - # 메서드별 파라미터 설정 - track_params = method_params.get(name) if method_params else None - - # 기존 _log_service 데코레이터 적용 - wrapped_method = self._log_service(service_type, track_params)(method) - setattr(cls, name, wrapped_method) - - return cls - - return class_decorator - - """ 메서드 데코레이터 - @log_service 데코레이터들을 여기에 추가 - service_type : 서비스 이름 (예: CHUNKING) - track_params : 추적할 파라미터 이름들 - """ - def chunking(self): - return self._log_service("CHUNKING", ["chunk_size", "overlap", "overlap_ratio", "documents"]) - - """ 클래스 데코레이터 - @log_service_class 데코레이터들을 여기에 추가 - service_type : 서비스 이름 (예: CHUNKING) - method_params : {메서드명: [추적할 파라미터 이름들]} - exclude_methods : 로깅에서 제외할 메서드명들 - """ - def chunking_class(self): - return self.log_service_class("CHUNKING", { - "chunk_text": ["chunk_size", "overlap"] - }) - -# 전역 ServiceLogger 싱글톤 -service_logger = ServiceLogger() \ No newline at end of file diff --git a/apps/pre-processing-service/app/config/logging/ServiceLoggerMiddleware.py b/apps/pre-processing-service/app/config/logging/ServiceLoggerMiddleware.py new file mode 100644 index 00000000..5e4816a1 --- /dev/null +++ b/apps/pre-processing-service/app/config/logging/ServiceLoggerMiddleware.py @@ -0,0 +1,109 @@ +import time +from typing import Dict, Any, List +from fastapi import Request +from loguru import logger +from contextvars import ContextVar + +trace_id_context: ContextVar[str] = ContextVar('trace_id', default="NO_TRACE_ID") + + +class ServiceLoggingDependency: + """ + 서비스 로깅을 위한 의존성 클래스 + :param service_type: 서비스 유형 (예: "CHUNKING", "PARSING", "EMBEDDING") + :param track_params: 추적할 매개변수 이름 목록 + """ + + def __init__(self, service_type: str, track_params: List[str] = None): + self.service_type = service_type + self.track_params = track_params or [] + + async def __call__(self, request: Request): + """ + 의존성 주입 시 호출되는 메서드 + :param request: FastAPI Request 객체 + :return: 서비스 유형과 추출된 매개변수 딕셔너리 + """ + trace_id = trace_id_context.get("NO_TRACE_ID") + start_time = time.time() + + # 파라미터 추출 + params = await self._extract_params(request) + param_str = "" + if params: + param_strs = [f"{k}={v}" for k, v in params.items()] + param_str = " " + " ".join(param_strs) + + logger.info(f"[{self.service_type}_START] trace_id={trace_id}{param_str}") + + # 응답 시 사용할 정보를 request.state에 저장 + request.state.service_type = self.service_type + request.state.start_time = start_time + request.state.param_str = param_str + + return {"service_type": self.service_type, "params": params} + + async def _extract_params(self, request: Request) -> Dict[str, Any]: + """ + 요청에서 추적 파라미터 추출 + :param request: FastAPI Request 객체 + :return: 추출된 매개변수 딕셔너리 + """ + params = {} + + try: + # Query Parameters 추출 + for key, value in request.query_params.items(): + if key in self.track_params: + params[key] = value + + # JSON Body 추출 + try: + json_body = await request.json() + if json_body: + for key, value in json_body.items(): + if key in self.track_params: + if isinstance(value, str) and len(value) > 50: + params[f"{key}_length"] = len(value) + elif isinstance(value, list): + params[f"{key}_count"] = len(value) + else: + params[key] = value + except: + pass + except: + pass + + return params + + +# 서비스별 의존성 인스턴스 생성 +chunking_dependency = ServiceLoggingDependency("CHUNKING", ["text", "chunk_size", "overlap"]) +parsing_dependency = ServiceLoggingDependency("PARSING", ["file_path", "file_type", "document"]) +embedding_dependency = ServiceLoggingDependency("EMBEDDING", ["chunks", "model_name", "batch_size"]) + +# 응답 로깅을 위한 의존성 +async def log_service_response(request: Request): + """ + 서비스 응답 시 성공 로그 기록 + :param request: FastAPI Request 객체 + """ + if hasattr(request.state, 'service_type'): + trace_id = trace_id_context.get("NO_TRACE_ID") + duration = time.time() - request.state.start_time + logger.info( + f"[{request.state.service_type}_SUCCESS] trace_id={trace_id} execution_time={duration:.4f}s{request.state.param_str}") + return None + +""" +라우터 예시 +@router.post("/chunk") +async def chunk_text( + text: str, + chunk_size: int = 100, + overlap: int = 20, + request: Request = None, + _: None = Depends(chunking_dependency), # 직접 의존성 주입 + __: None = Depends(log_service_response) +): +""" \ No newline at end of file diff --git a/apps/pre-processing-service/app/core/__init__.py b/apps/pre-processing-service/app/core/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/app/core/config.py b/apps/pre-processing-service/app/core/config.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/app/db/__init__.py b/apps/pre-processing-service/app/db/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/app/db/db_connecter.py b/apps/pre-processing-service/app/db/db_connecter.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/app/decorators/__init__.py b/apps/pre-processing-service/app/decorators/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/app/decorators/logging.py b/apps/pre-processing-service/app/decorators/logging.py deleted file mode 100644 index 29eb2204..00000000 --- a/apps/pre-processing-service/app/decorators/logging.py +++ /dev/null @@ -1,54 +0,0 @@ -# app/decorators/logging.py - -from fastapi import Request -from loguru import logger -import functools -import time - - -def log_api_call(func): - @functools.wraps(func) - async def wrapper(*args, **kwargs): - # 1. request 객체를 안전하게 가져옵니다. - # kwargs에서 'request'를 찾고, 없으면 args가 비어있지 않은 경우에만 args[0]을 시도합니다. - request: Request | None = kwargs.get('request') - if request is None and args and isinstance(args[0], Request): - request = args[0] - - # 요청 정보를 로그로 기록 (request 객체가 있는 경우에만) - if request: - logger.info( - "API 호출 시작: URL='{}' 메서드='{}' 함수='{}'", - request.url, request.method, func.__name__ - ) - else: - logger.info("API 호출 시작: 함수='{}'", func.__name__) - - start_time = time.time() - result = None - - try: - result = await func(*args, **kwargs) - return result - except Exception as e: - elapsed_time = time.time() - start_time - if request: - logger.error( - "API 호출 실패: URL='{}' 메서드='{}' 예외='{}' ({:.4f}s)", - request.url, request.method, e, elapsed_time - ) - else: - logger.error("API 호출 실패: 함수='{}' 예외='{}' ({:.4f}s)", func.__name__, e, elapsed_time) - raise - finally: - if result is not None: - elapsed_time = time.time() - start_time - if request: - logger.success( - "API 호출 성공: URL='{}' 메서드='{}' ({:.4f}s)", - request.url, request.method, elapsed_time - ) - else: - logger.success("API 호출 성공: 함수='{}' ({:.4f}s)", func.__name__, elapsed_time) - - return wrapper \ No newline at end of file diff --git a/apps/pre-processing-service/app/main.py b/apps/pre-processing-service/app/main.py deleted file mode 100644 index 85d86e27..00000000 --- a/apps/pre-processing-service/app/main.py +++ /dev/null @@ -1,17 +0,0 @@ -# main.py - -from fastapi import FastAPI - -# app/api/v1/items.py에서 라우터를 임포트합니다. -from app.api.router import router as api_router -from app.middleware.logging import LoggingMiddleware - -# FastAPI 애플리케이션 인스턴스를 생성합니다. -app = FastAPI( - title="My FastAPI Project", - description="A demonstration of a well-structured FastAPI project.", - version="1.0.0" -) -app.add_middleware(LoggingMiddleware) -# APIRouter를 메인 앱에 포함시킵니다. -app.include_router(api_router, prefix="", tags=["api"]) \ No newline at end of file diff --git a/apps/pre-processing-service/app/middleware/__init__.py b/apps/pre-processing-service/app/middleware/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/app/middleware/logging.py b/apps/pre-processing-service/app/middleware/logging.py deleted file mode 100644 index 52df693c..00000000 --- a/apps/pre-processing-service/app/middleware/logging.py +++ /dev/null @@ -1,37 +0,0 @@ - -import time -from fastapi import Request -from loguru import logger -from starlette.middleware.base import BaseHTTPMiddleware - -class LoggingMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - start_time = time.time() - - # 1. 요청 시작 로그 - logger.info( - "요청 시작: IP='{}' 메서드='{}' URL='{}'", - request.client.host, request.method, request.url.path - ) - - try: - # 2. 다음 미들웨어 또는 최종 엔드포인트 함수 실행 - response = await call_next(request) - - # 3. 요청 성공 시 로그 - process_time = time.time() - start_time - logger.info( - "요청 성공: 메서드='{}' URL='{}' 상태코드='{}' (처리 시간: {:.4f}s)", - request.method, request.url.path, response.status_code, process_time - ) - return response - - except Exception as e: - # 4. 예외 발생 시 로그 - process_time = time.time() - start_time - logger.error( - "요청 실패: IP='{}' 메서드='{}' URL='{}' 예외='{}' (처리 시간: {:.4f}s)", - request.client.host, request.method, request.url.path, e, process_time - ) - # 예외를 다시 발생시켜 FastAPI의 기본 핸들러가 처리하도록 함 - raise \ No newline at end of file diff --git a/apps/pre-processing-service/app/services/ChunkingService.py b/apps/pre-processing-service/app/services/ChunkingService.py deleted file mode 100644 index a2d20e1d..00000000 --- a/apps/pre-processing-service/app/services/ChunkingService.py +++ /dev/null @@ -1,16 +0,0 @@ -from app.config.logging.ServiceLogger import service_logger - - -@service_logger.chunking_class() -class ChunkingService: - - def chunk_text(self, text: str, chunk_size: int = 100, overlap: int = 20): - """텍스트를 청크로 분할""" - chunks = [] - - for i in range(0, len(text), chunk_size - overlap): - chunk = text[i:i + chunk_size] - if chunk.strip(): - chunks.append(chunk.strip()) - - return chunks \ No newline at end of file diff --git a/apps/pre-processing-service/app/services/__init__.py b/apps/pre-processing-service/app/services/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/app/services/preprocessing_service.py b/apps/pre-processing-service/app/services/preprocessing_service.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/poetry.lock b/apps/pre-processing-service/poetry.lock deleted file mode 100644 index f282fdc1..00000000 --- a/apps/pre-processing-service/poetry.lock +++ /dev/null @@ -1,457 +0,0 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anyio" -version = "4.10.0" -description = "High-level concurrency and networking framework on top of asyncio or Trio" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, - {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, -] - -[package.dependencies] -idna = ">=2.8" -sniffio = ">=1.1" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} - -[package.extras] -trio = ["trio (>=0.26.1)"] - -[[package]] -name = "click" -version = "8.2.1" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, - {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev", "test"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\"", test = "sys_platform == \"win32\""} - -[[package]] -name = "fastapi" -version = "0.116.1" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"}, - {file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"}, -] - -[package.dependencies] -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.40.0,<0.48.0" -typing-extensions = ">=4.8.0" - -[package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] -standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] - -[[package]] -name = "h11" -version = "0.16.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "iniconfig" -version = "2.1.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev", "test"] -files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, -] - -[[package]] -name = "loguru" -version = "0.7.3" -description = "Python logging made (stupidly) simple" -optional = false -python-versions = "<4.0,>=3.5" -groups = ["main"] -files = [ - {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, - {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} -win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} - -[package.extras] -dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] - -[[package]] -name = "packaging" -version = "25.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev", "test"] -files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev", "test"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "pydantic" -version = "2.11.7" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, - {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.33.2" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev", "test"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pytest" -version = "8.4.1" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev", "test"] -files = [ - {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, - {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "1.1.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.9" -groups = ["dev", "test"] -files = [ - {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, - {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, -] - -[package.dependencies] -pytest = ">=8.2,<9" - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "starlette" -version = "0.47.2" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b"}, - {file = "starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8"}, -] - -[package.dependencies] -anyio = ">=3.6.2,<5" -typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} - -[package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] - -[[package]] -name = "typing-extensions" -version = "4.14.1" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, - {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, -] - -[[package]] -name = "typing-inspection" -version = "0.4.1" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[[package]] -name = "uvicorn" -version = "0.35.0" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, - {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, -] - -[package.dependencies] -click = ">=7.0" -h11 = ">=0.8" - -[package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] - -[[package]] -name = "win32-setctime" -version = "1.2.0" -description = "A small Python utility to set file creation time on Windows" -optional = false -python-versions = ">=3.5" -groups = ["main"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, - {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, -] - -[package.extras] -dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.11,<4.0" -content-hash = "a1e96f9cb40bd5323a6f9a3235b22ca67e7fb00117aa10d550c54255395e4cc3" diff --git a/apps/pre-processing-service/pyproject.toml b/apps/pre-processing-service/pyproject.toml deleted file mode 100644 index 2a95075f..00000000 --- a/apps/pre-processing-service/pyproject.toml +++ /dev/null @@ -1,32 +0,0 @@ -[build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" - -[project] -name = "pre-processing-service" -version = "0.1.0" -description = "" -authors = [ - {name = "skip"} -] - -requires-python = ">=3.11,<4.0" -dependencies = [ - # FastAPI: 비동기 웹 프레임워크, REST API 서버 구현에 사용 - "fastapi (>=0.116.1,<0.117.0)", - # Uvicorn: ASGI 서버, FastAPI 앱 실행에 필요 - "uvicorn (>=0.35.0,<0.36.0)", - # Loguru: 간편하고 강력한 로깅 라이브러리, 로그 관리에 사용 - "loguru (>=0.7.3,<0.8.0)", - # pytest: 테스트 프레임워크, 단위 테스트 및 통합 테스트에 사용 - "pytest (>=8.4.1,<9.0.0)" -] - -[tool.poetry.group.dev.dependencies] -pytest-asyncio = "^1.1.0" - -[tool.poetry.group.test.dependencies] -pytest-asyncio = "^1.1.0" - -[tool.poetry] -packages = [{include = "app"}] \ No newline at end of file From b5a3b6bac853efa99b1327ad06b39073bf56ae9c Mon Sep 17 00:00:00 2001 From: can019 Date: Wed, 27 Aug 2025 17:31:17 +0900 Subject: [PATCH 7/8] fix: username --- .github/workflows/deploy-java.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-java.yml b/.github/workflows/deploy-java.yml index 9d1e2e03..973de4b0 100644 --- a/.github/workflows/deploy-java.yml +++ b/.github/workflows/deploy-java.yml @@ -21,7 +21,7 @@ jobs: uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.SERVER_HOST }} - username: ${{ secrets.SERVER_USER }} + username: ubuntu key: ${{ secrets.SERVER_SSH_KEY }} source: "docker/production/docker-compose.yml" target: "~/app" From 67c76e49d932f6fa51a9d065e477be82a0f39585 Mon Sep 17 00:00:00 2001 From: Jihu Kim Date: Wed, 27 Aug 2025 17:36:59 +0900 Subject: [PATCH 8/8] =?UTF-8?q?LoggingFilter=20=EC=B6=94=EA=B0=80=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: postgre password 변경 - docker-compose.yml에 설정되어있는 password로 변경 - docker-compose.yml에 설정되어있는 password와 application-develop.yml에 설정되어있는 password 통일 시킴 * chore: application-test.yml 작성 - test를 위한 application-test.yml 작성 * test: DBConnectionTest - DBConnectionTest를 위한 create-schema.sql, insert-user-data.sql 작성 - DBConnectionTest 코드 작성 * feat: 다중 단계 빌드를 사용하도록 Dockerfile 업데이트 - 빌드 스테이지와 실행 스테이지를 분리하여 최종 이미지의 크기를 줄였습니다. - `openjdk:21-jdk-slim` 이미지를 빌드에 사용하고, `openjdk:21-jre-slim` 이미지를 실행에 사용하여 불필요한 JDK 종속성을 제거했습니다. - Docker 레이어 캐싱을 활용하도록 파일 복사 순서를 조정하여 빌드 속도를 개선했습니다. - 빌드 시 테스트를 건너뛰는 옵션(`-x test`)을 추가했습니다. - 최종 JAR 파일명을 `app.jar`로 간소화했습니다. * feat: Mybatis를 사용한 사용자 관리 기능 기본 구조 추가 - 사용자 정보 관리를 위한 UserDto 클래스 생성 - Mybatis Mapper 인터페이스(UserMapper) 및 XML 파일 추가 - Spring 컨텍스트에 MapperScanner 설정 - 이를 통해 사용자 데이터를 데이터베이스에서 가져올 수 있는 기반 마련 * chore: 데이터베이스 스키마 및 초기 데이터 설정 스크립트 추가 - create-schema.sql: 애플리케이션에 필요한 데이터베이스 테이블 스키마를 정의 - insert-user-data.sql: 테스트용 사용자 데이터를 초기화 시 삽입 이제 Docker Compose를 사용하여 컨테이너를 실행할 때 데이터베이스가 자동으로 초기화됩니다. * refactor: 중간 테이블의 기본키 타입을 UUID에서 BIGINT로 변경 - 인조키는 BIGINT(Auto-increment) 타입으로 변경 * refactor: code formatting * refactor: SQL문 오류 수정 * feat: 요청 추적을 위한 LoggingFilter 추가 - 모든 API 요청에 UUID 기반의 Trace ID(`X-Request-ID`)를 주입하는 Filter 구현 - MDC를 통해 모든 로그에 Trace ID가 자동으로 기록되도록 설정 * refactor: Code Formatting * refactor: Dockerfile 수정 --- apps/user-service/Dockerfile | 2 +- .../gltkorea/icebang/config/WebConfig.java | 26 +++++++++++ .../icebang/filter/LoggingFilter.java | 43 +++++++++++++++++++ .../src/main/resources/log4j2-develop.yml | 4 +- .../src/test/resources/sql/create-schema.sql | 3 +- docker/local/init-scripts/create-schema.sql | 1 - 6 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/config/WebConfig.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/filter/LoggingFilter.java diff --git a/apps/user-service/Dockerfile b/apps/user-service/Dockerfile index e3fb24a4..b9ac7b3e 100644 --- a/apps/user-service/Dockerfile +++ b/apps/user-service/Dockerfile @@ -6,4 +6,4 @@ COPY build/libs/*.jar app.jar EXPOSE 8080 -CMD ["java", "-jar", "app.jar"] +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/WebConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/WebConfig.java new file mode 100644 index 00000000..1ed10098 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/WebConfig.java @@ -0,0 +1,26 @@ +package com.gltkorea.icebang.config; + +import java.time.Duration; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class WebConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + // 1. SimpleClientHttpRequestFactory 객체를 직접 생성 + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + + // 2. 타임아웃 설정 (이 메서드들은 deprecated 아님) + requestFactory.setConnectTimeout(Duration.ofSeconds(5)); + requestFactory.setReadTimeout(Duration.ofSeconds(5)); + + // 3. 빌더에 직접 생성한 requestFactory를 설정 + return builder.requestFactory(() -> requestFactory).build(); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/filter/LoggingFilter.java b/apps/user-service/src/main/java/com/gltkorea/icebang/filter/LoggingFilter.java new file mode 100644 index 00000000..e8dda321 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/filter/LoggingFilter.java @@ -0,0 +1,43 @@ +package com.gltkorea.icebang.filter; + +import java.io.IOException; +import java.util.UUID; + +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class LoggingFilter extends OncePerRequestFilter { + + public static final String TRACE_ID_HEADER = "X-Request-ID"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // 다른 시스템에서 이미 전달한 Trace ID가 있는지 확인 + String traceId = request.getHeader(TRACE_ID_HEADER); + + // 없다면 새로 생성 (요청의 시작점) + if (traceId == null || traceId.isEmpty()) { + traceId = UUID.randomUUID().toString(); + } + + MDC.put("traceId", traceId.substring(0, 8)); + + // ⭐️ 요청 객체에 attribute로 traceId를 저장하여 컨트롤러 등에서 사용할 수 있게 함 + request.setAttribute("X-Request-ID", traceId); + + // 응답 헤더에 traceId를 넣어주면 클라이언트가 추적하기 용이 + response.setHeader(TRACE_ID_HEADER, traceId); + + filterChain.doFilter(request, response); + } +} diff --git a/apps/user-service/src/main/resources/log4j2-develop.yml b/apps/user-service/src/main/resources/log4j2-develop.yml index 4734a1ce..63f4c280 100644 --- a/apps/user-service/src/main/resources/log4j2-develop.yml +++ b/apps/user-service/src/main/resources/log4j2-develop.yml @@ -9,10 +9,10 @@ Configuration: value: "UTF-8" # 통일된 콘솔 패턴 - 모든 로그에 RequestId 포함 - name: "console-layout-pattern" - value: "%highlight{[%-5level]} [%X{id}] %d{MM-dd HH:mm:ss} [%t] %n %msg%n%n" + value: "%highlight{[%-5level]} [%X{traceId}] %d{MM-dd HH:mm:ss} [%t] %n %msg%n%n" # 파일용 상세 패턴 - RequestId 포함 - name: "file-layout-pattern" - value: "[%X{id}] %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" + value: "[%X{traceId}] %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" # 로그 파일 경로들 - name: "info-log" value: ${log-path}/user-service/info.log diff --git a/apps/user-service/src/test/resources/sql/create-schema.sql b/apps/user-service/src/test/resources/sql/create-schema.sql index 5980c3ab..115603f8 100644 --- a/apps/user-service/src/test/resources/sql/create-schema.sql +++ b/apps/user-service/src/test/resources/sql/create-schema.sql @@ -34,6 +34,7 @@ CREATE TABLE "USER" ( PRIMARY KEY ("user_id") ); + CREATE TABLE "GROUP_INFO" ( "group_info_id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" VARCHAR(255) NULL, @@ -95,4 +96,4 @@ CREATE TABLE "ROLE_PERMISSION" ( FOREIGN KEY ("role_id") REFERENCES "ROLE" ("role_id"), FOREIGN KEY ("permission_id") REFERENCES "PERMISSION" ("permission_id"), UNIQUE ("role_id", "permission_id") -); \ No newline at end of file +); diff --git a/docker/local/init-scripts/create-schema.sql b/docker/local/init-scripts/create-schema.sql index 5980c3ab..0e2467df 100644 --- a/docker/local/init-scripts/create-schema.sql +++ b/docker/local/init-scripts/create-schema.sql @@ -8,7 +8,6 @@ DROP TABLE IF EXISTS "GROUP_INFO"; DROP TABLE IF EXISTS "USER"; --- 사용자 정보 (외부 노출 가능성 높음 -> UUID) CREATE TABLE "USER" ( "user_id" VARCHAR(36) NOT NULL, "name" VARCHAR(100) NULL,