-
이코에코(Eco²) Observability #3: 도메인별 ECS 구조화 로깅이코에코(Eco²)/Observability 2025. 12. 19. 02:26
개요
이번 글에서는 Python/FastAPI 서비스에 ECS(Elastic Common Schema) 기반 구조화된 로깅을 구현하는 전체 과정을 다룹니다. OpenTelemetry와의 연동으로 trace_id를 자동 주입하고, 민감 정보 자동 마스킹을 통해 OWASP 보안 가이드라인을 준수합니다.
목표
- JSON 구조화 로그 출력
- ECS 8.11 스키마 준수
- OpenTelemetry trace_id/span_id 자동 주입
- 민감 정보 자동 마스킹 (OWASP 준수)
- 환경별 로그 포맷 전환 (JSON/Text)
- 외부 라이브러리 로그 레벨 제어
아키텍처
로그 처리 흐름

핵심 컴포넌트
컴포넌트 역할 파일 ECSJsonFormatterECS 스키마 JSON 포맷터 core/logging.pymask_sensitive_data()재귀적 PII 마스킹 core/logging.pyconstants.py상수 정의 (Single Source of Truth) core/constants.pyconfigure_logging()로거 초기화 core/logging.py
🔧 구현: constants.py (Single Source of Truth)
왜 constants.py를 분리했는가?

이유:
- 버전 동기화: 로깅, 트레이싱, FastAPI 모두 동일 버전 사용
- 12-Factor App: 설정을 코드에서 분리
- 타입 안전성: IDE 자동완성, 오타 방지
전체 코드
# domains/auth/core/constants.py """ Service Constants (Single Source of Truth) 정적 상수 정의 - 빌드 타임에 결정되며 환경변수로 변경되지 않음 """ # ============================================================================= # Service Identity # ============================================================================= SERVICE_NAME = "auth-api" SERVICE_VERSION = "1.0.7" # ============================================================================= # Logging Constants (12-Factor App Compliance) # ============================================================================= # Environment variable keys ENV_KEY_ENVIRONMENT = "ENVIRONMENT" ENV_KEY_LOG_LEVEL = "LOG_LEVEL" ENV_KEY_LOG_FORMAT = "LOG_FORMAT" # Default values DEFAULT_ENVIRONMENT = "dev" DEFAULT_LOG_LEVEL = "DEBUG" DEFAULT_LOG_FORMAT = "json" # ECS (Elastic Common Schema) version ECS_VERSION = "8.11.0" # LogRecord attributes to exclude from extra fields # Reference: https://docs.python.org/3/library/logging.html#logrecord-attributes EXCLUDED_LOG_RECORD_ATTRS = frozenset({ "name", "msg", "args", "created", "filename", "funcName", "levelname", "levelno", "lineno", "module", "msecs", "pathname", "process", "processName", "relativeCreated", "stack_info", "exc_info", "exc_text", "thread", "threadName", "taskName", "message", }) # ============================================================================= # PII Masking Configuration (OWASP Logging Cheat Sheet) # ============================================================================= SENSITIVE_FIELD_PATTERNS = frozenset({ "password", # 사용자 비밀번호 "secret", # jwt_secret_key, client_secret "token", # JWT, OAuth tokens "api_key", # External API keys "authorization", # HTTP Authorization header }) MASK_PLACEHOLDER = "***REDACTED***" MASK_PRESERVE_PREFIX = 4 # 앞 4자리 표시 MASK_PRESERVE_SUFFIX = 4 # 뒤 4자리 표시 MASK_MIN_LENGTH = 10 # 부분 마스킹 최소 길이
구현: ECSJsonFormatter
왜 커스텀 Formatter인가?
옵션 장점 단점 python-json-logger 쉬운 설정 ECS 필드명 커스텀 어려움 ecs-logging-python 공식 ECS 라이브러리 OTEL 통합 부족 커스텀 Formatter ✅ 완전한 제어, OTEL 통합 직접 구현 필요 선택 이유:
- OpenTelemetry
trace.get_current_span()직접 호출 필요 - 민감 정보 마스킹 로직 통합
- ECS 필드명 정확히 맞추기 (
trace.idnottrace_id)
전체 코드
# domains/auth/core/logging.py """ Structured Logging Configuration (ECS-based) Elastic Common Schema (ECS) 기반 JSON 로깅 설정 OpenTelemetry trace_id 자동 연동 Log Collection Protocol: - Fluent Bit → Elasticsearch: HTTP (9200) - OpenTelemetry → Jaeger: gRPC OTLP (4317) """ import json import logging import os import sys from datetime import datetime, timezone from typing import Any from domains.auth.core.constants import ( DEFAULT_ENVIRONMENT, DEFAULT_LOG_FORMAT, DEFAULT_LOG_LEVEL, ECS_VERSION, ENV_KEY_ENVIRONMENT, ENV_KEY_LOG_FORMAT, ENV_KEY_LOG_LEVEL, EXCLUDED_LOG_RECORD_ATTRS, MASK_MIN_LENGTH, MASK_PLACEHOLDER, MASK_PRESERVE_PREFIX, MASK_PRESERVE_SUFFIX, SENSITIVE_FIELD_PATTERNS, SERVICE_NAME, SERVICE_VERSION, ) try: from opentelemetry import trace HAS_OPENTELEMETRY = True except ImportError: HAS_OPENTELEMETRY = False # ============================================================================= # PII Masking (OWASP Compliance) # ============================================================================= def _is_sensitive_key(key: str) -> bool: """Check if a key matches sensitive field patterns (case-insensitive).""" key_lower = key.lower() return any(pattern in key_lower for pattern in SENSITIVE_FIELD_PATTERNS) def _mask_value(value: Any) -> str: """Mask a sensitive value with partial visibility for debugging.""" if value is None: return MASK_PLACEHOLDER str_value = str(value) if len(str_value) <= MASK_MIN_LENGTH: return MASK_PLACEHOLDER # Partial masking: show prefix...suffix (예: eyJh...4fQk) return f"{str_value[:MASK_PRESERVE_PREFIX]}...{str_value[-MASK_PRESERVE_SUFFIX:]}" def mask_sensitive_data(data: dict[str, Any]) -> dict[str, Any]: """ Recursively mask sensitive fields in a dictionary. Args: data: Dictionary that may contain sensitive information Returns: Dictionary with sensitive values masked """ if not isinstance(data, dict): return data result = {} for key, value in data.items(): if _is_sensitive_key(key): result[key] = _mask_value(value) elif isinstance(value, dict): result[key] = mask_sensitive_data(value) elif isinstance(value, list): result[key] = [ mask_sensitive_data(item) if isinstance(item, dict) else item for item in value ] else: result[key] = value return result # ============================================================================= # ECS JSON Formatter # ============================================================================= class ECSJsonFormatter(logging.Formatter): """ Elastic Common Schema (ECS) 기반 JSON 포매터 Features: - ECS 8.11 스키마 준수 - OpenTelemetry trace.id/span.id 자동 주입 - 민감 정보 자동 마스킹 """ def __init__( self, service_name: str = SERVICE_NAME, service_version: str = SERVICE_VERSION, environment: str = DEFAULT_ENVIRONMENT, ): super().__init__() self.service_name = service_name self.service_version = service_version self.environment = environment def format(self, record: logging.LogRecord) -> str: # 1. 기본 ECS 필드 log_obj: dict[str, Any] = { "@timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds"), "message": record.getMessage(), "log.level": record.levelname.lower(), "log.logger": record.name, "ecs.version": ECS_VERSION, "service.name": self.service_name, "service.version": self.service_version, "service.environment": self.environment, } # 2. OpenTelemetry trace context 주입 if HAS_OPENTELEMETRY: span = trace.get_current_span() ctx = span.get_span_context() if ctx.is_valid: log_obj["trace.id"] = format(ctx.trace_id, "032x") log_obj["span.id"] = format(ctx.span_id, "016x") # 3. 에러 정보 (ECS error.* 필드) if record.exc_info: log_obj["error.type"] = record.exc_info[0].__name__ if record.exc_info[0] else None log_obj["error.message"] = str(record.exc_info[1]) if record.exc_info[1] else None log_obj["error.stack_trace"] = self.formatException(record.exc_info) # 4. extra 필드 → labels (마스킹 적용) extra_fields = { key: value for key, value in record.__dict__.items() if key not in EXCLUDED_LOG_RECORD_ATTRS } if extra_fields: log_obj["labels"] = mask_sensitive_data(extra_fields) return json.dumps(log_obj, ensure_ascii=False, default=str) # ============================================================================= # Logger Configuration # ============================================================================= def configure_logging( service_name: str = SERVICE_NAME, service_version: str = SERVICE_VERSION, log_level: str | None = None, json_format: bool | None = None, ) -> None: """애플리케이션 로깅 설정""" environment = os.getenv(ENV_KEY_ENVIRONMENT, DEFAULT_ENVIRONMENT) level = log_level or os.getenv(ENV_KEY_LOG_LEVEL, DEFAULT_LOG_LEVEL) use_json = ( json_format if json_format is not None else os.getenv(ENV_KEY_LOG_FORMAT, DEFAULT_LOG_FORMAT) == "json" ) numeric_level = getattr(logging, level.upper(), logging.DEBUG) root_logger = logging.getLogger() root_logger.setLevel(numeric_level) # 기존 핸들러 제거 for handler in root_logger.handlers[:]: root_logger.removeHandler(handler) handler = logging.StreamHandler(sys.stdout) handler.setLevel(numeric_level) if use_json: handler.setFormatter(ECSJsonFormatter( service_name=service_name, service_version=service_version, environment=environment, )) else: # 로컬 개발: 가독성 좋은 텍스트 포맷 handler.setFormatter(logging.Formatter( "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S", )) root_logger.addHandler(handler) # 외부 라이브러리 로그 레벨 억제 for logger_name in ("uvicorn", "uvicorn.access", "uvicorn.error", "httpx", "httpcore", "asyncio"): logging.getLogger(logger_name).setLevel(logging.WARNING)
FastAPI 적용
main.py 구조

순서가 중요한 이유
- 로깅이 먼저 설정되어야 트레이싱 초기화 로그가 기록됨
- instrumentation은 app 생성 전에 호출해야 자동 계측됨
실제 코드
# domains/auth/main.py from domains.auth.core.constants import ( DEFAULT_ENVIRONMENT, ENV_KEY_ENVIRONMENT, SERVICE_NAME, SERVICE_VERSION, ) from domains.auth.core.logging import configure_logging from domains.auth.core.tracing import ( configure_tracing, instrument_fastapi, instrument_httpx, instrument_redis, shutdown_tracing, ) # 1. 구조화된 로깅 설정 (ECS JSON 포맷) configure_logging() # 2. OpenTelemetry 분산 트레이싱 설정 environment = os.getenv(ENV_KEY_ENVIRONMENT, DEFAULT_ENVIRONMENT) configure_tracing( service_name=SERVICE_NAME, service_version=SERVICE_VERSION, environment=environment, ) # 3. 글로벌 instrumentation (앱 생성 전) instrument_httpx() instrument_redis(None) @asynccontextmanager async def lifespan(app: FastAPI): # Startup KeyManager.ensure_keys() yield # Shutdown shutdown_tracing() def create_app() -> FastAPI: app = FastAPI( title="Auth API", version=SERVICE_VERSION, lifespan=lifespan, ) # OpenTelemetry FastAPI instrumentation instrument_fastapi(app) return app app = create_app()
사용 예시
기본 로깅
import logging logger = logging.getLogger(__name__) # 기본 로그 logger.info("User login successful") # extra 필드 추가 logger.info("OAuth callback received", extra={ "provider": "kakao", "user_id": "usr-123", "response_time_ms": 45.2 })출력 결과
{ "@timestamp": "2025-12-17T10:00:00.123Z", "message": "OAuth callback received", "log.level": "info", "log.logger": "domains.auth.services.auth", "ecs.version": "8.11.0", "service.name": "auth-api", "service.version": "1.0.7", "service.environment": "dev", "trace.id": "4bf92f3577b34da6a3ce929d0e0e4736", "span.id": "00f067aa0ba902b7", "labels": { "provider": "kakao", "user_id": "usr-123", "response_time_ms": 45.2 } }민감 정보 자동 마스킹
# ⚠️ 실수로 토큰을 로깅해도 자동 마스킹됨 logger.info("Token issued", extra={ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "user_id": "usr-123" })마스킹된 출력
{ "message": "Token issued", "labels": { "access_token": "eyJh...J9...", // ← 자동 마스킹 "user_id": "usr-123" // ← 그대로 유지 } }에러 로깅
try: await external_api.call() except Exception as e: logger.error("External API call failed", extra={ "service": "kakao-oauth", "endpoint": "/oauth/token" }, exc_info=True)에러 출력 결과
{ "@timestamp": "2025-12-17T10:00:00.456Z", "message": "External API call failed", "log.level": "error", "log.logger": "domains.auth.services.oauth", "service.name": "auth-api", "service.version": "1.0.7", "error.type": "ConnectionError", "error.message": "Connection refused", "error.stack_trace": "Traceback (most recent call last):\n...", "labels": { "service": "kakao-oauth", "endpoint": "/oauth/token" } }
OpenTelemetry 연동
Dockerfile
# 기존 CMD ["uvicorn", "domains.auth.main:app", "--host", "0.0.0.0", "--port", "8000"] # OpenTelemetry 자동 계측 적용 CMD ["opentelemetry-instrument", "uvicorn", "domains.auth.main:app", "--host", "0.0.0.0", "--port", "8000"]trace.id 주입 원리

도메인별 적용 방식
파일 구조
domains/ ├── auth/ │ ├── core/ │ │ ├── logging.py ✅ ECSJsonFormatter + 마스킹 │ │ └── constants.py ✅ SERVICE_NAME, VERSION, 마스킹 패턴 │ └── main.py ✅ configure_logging() ├── character/ │ ├── core/ │ │ ├── logging.py ✅ 동일 구조 │ │ └── constants.py ✅ SERVICE_NAME = "character-api" │ └── main.py ├── chat/ │ └── core/... ✅ 동일 구조 ├── scan/ │ └── core/... ✅ 동일 구조 ├── my/ │ └── core/... ✅ 동일 구조 ├── location/ │ └── core/... ✅ 동일 구조 └── image/ └── core/... ✅ 동일 구조왜 도메인별 독립 구현인가?
방식 장점 단점 공통 모듈 코드 중복 없음 배포 의존성, 버전 충돌 독립 구현 ✅ 배포 독립, 커스터마이징 ~200줄 복사 선택 이유:
- 마이크로서비스 원칙: 각 서비스 독립 배포
- 도메인별 민감 필드 커스터마이징 가능
- 공통 모듈 변경 시 전체 서비스 재배포 불필요
🔧 환경 변수 설정
ConfigMap
# workloads/domains/auth/base/configmap.yaml apiVersion: v1 kind: ConfigMap metadata: name: auth-config data: ENVIRONMENT: "dev" LOG_LEVEL: "INFO" LOG_FORMAT: "json"환경별 설정
환경 LOG_LEVEL LOG_FORMAT 용도 local DEBUG text 로컬 개발 (가독성) dev DEBUG json 개발 클러스터 staging INFO json 스테이징 production INFO json 프로덕션
✅ Kibana에서 확인
Index Pattern 생성
- Kibana → Stack Management → Index Patterns
logs-*패턴 생성@timestamp필드 선택
유용한 검색 쿼리
# 서비스별 필터 service.name: "auth-api" # 에러만 검색 log.level: "error" # trace_id로 전체 요청 흐름 조회 trace.id: "4bf92f3577b34da6a3ce929d0e0e4736" # 특정 사용자 검색 labels.user_id: "usr-123" # OAuth 관련 로그 labels.provider: "kakao" AND message: *callback*
트러블슈팅
Issue 1: OpenTelemetry 모듈 없음
ModuleNotFoundError: No module named 'opentelemetry'원인: 로컬 개발 환경에서 opentelemetry 미설치
해결:
try-except로 조건부 importtry: from opentelemetry import trace HAS_OPENTELEMETRY = True except ImportError: HAS_OPENTELEMETRY = FalseIssue 2: Protobuf 버전 충돌
ImportError: cannot import name 'InstrumentationScope'원인: protobuf 6.x와 OpenTelemetry 비호환
해결:
protobuf==5.29.2로 다운그레이드Issue 3: trace.id가 로그에 없음
원인:
- OpenTelemetry instrumentation 미적용
- 로깅이 span 외부에서 발생
해결:
- Dockerfile에서
opentelemetry-instrument확인 - 로깅이 요청 컨텍스트 내에서 발생하는지 확인
Reference
'이코에코(Eco²) > Observability' 카테고리의 다른 글
이코에코(Eco²) Observability #5: 인덱스 전략 및 라이프사이클 관리 (0) 2025.12.19 이코에코(Eco²) Observability #4: 분산 트레이싱 통합 (0) 2025.12.19 이코에코(Eco²) Observability #2: 로깅 정책 수립 (0) 2025.12.19 이코에코(Eco²) Observability #1: EFK 파이프라인 구축 (0) 2025.12.19 이코에코(Eco²) Observability #0: 로깅 파이프라인 아키텍처 설계 (0) 2025.12.18