Python 기반 다중 MySQL 연결 통합 관리 및 설정 중앙화 전략

기존 산재형 설정 방식의 한계

초기 스타트업이나 소규모 프로젝트에서는 각 데이터 접근 객체(DAO) 또는 서비스 클래스 내부에 하드코딩된 설정 딕셔너리를 배포하는 방식을 자주 사용합니다. 그러나 도메인이 분산되거나 미들웨어가 증가할 경우 다음과 같은 구조적 문제가 발생합니다.

  • 설정 중복 및 불일치: 동일한 호스트 이름이나 포트가 여러 파일에 걸쳐 수기로 기록되면 개발/테스트/운영 환경 간 값이 달라지는 버그가 빈번합니다.
  • 민감 정보 노출 위험: 비밀번호 및 접근 키가 소스 코드에 직접 포함되어 있어 버전 관리 시스템(Git)으로 의도치 않게 유출될 가능성이 높습니다.
  • 환경 격리의 부재: 로컬 디버깅용 설정과 프로덕션 클러스터 설정을 구분하려면 전역 조건문 또는 매니페스트 파일을 추가해야 하는데, 이는 진입장벽을 높입니다.
  • 유지보수 비용 증대: 계정 정보 변경 시 전역 검색 및 대체(Global Search & Replace)를 수행해야 하며, 실수로 관련 설정을 누락하거나 틀리게 수정할 여지가 큽니다.

통합 아키텍처 설계 방향

다중 데이터베이스 환경을 안정적으로 운영하기 위해서는 설정 정의, 환경별 로드, 연결 풀 관리, 실행 핸들러의 책임을 명확히 분리하는 구조가 필요합니다. 주요 디렉토리 레이아웃은 다음과 같이 구성됩니다.

project_root/
├── app_config/          # 설정 정의 및 환경 로딩 모듈
│   ├── models.py        # 데이터 클래스 및 타입 정의
│   └── loader.py        # 환경 판별 및 값 주입 엔진
├── db_layer/            # 데이터베이스 추상화 계층
│   └── pool_manager.py  # 연결 풀 생성 및 캐싱 팩토리
└── services/            # 도메인 비즈니스 로직
    ├── user_domain.py
    └── order_domain.py

핵심 구현 세부 사항

공격적인 타입 검사와 객체 지향적 팩토리 패턴을 활용하여 설정 값을 검증하고 연결 객체를 캐싱합니다. 기존 순회 방식의 디렉토리 대신 TypedDictdataclass를 결합해 컴파일 타임 가드와 런타임 유효성 검사를 동시에 확보합니다.

1. 설정 모델 및 환경 로더

# app_config/models.py
from dataclasses import dataclass
from typing import Final

@dataclass(frozen=True)
class DatabaseCredentials:
    instance_name: str
    host: str
    port: int
    username: str
    password: str
    target_db: str
    charset: str = "utf8mb4"

# app_config/loader.py
import os
from dotenv import load_dotenv
from .models import DatabaseCredentials
import threading

class ConfigRegistry:
    _credentials: dict[str, DatabaseCredentials] = {}
    _lock = threading.Lock()

    @classmethod
    def initialize(cls, env_prefix: str = "DB_") -> None:
        """로드 환경별 접두사로 설정을 읽어오거나 사전에 정의된 값으로 채웁니다."""
        env_mode = os.getenv("APP_RUNTIME_ENV", "dev").lower()
        prefix_map = {"dev": "DEV_", "prod": "PROD_", "staging": "STG_"}
        current_prefix = prefix_map.get(env_mode, env_prefix)

        # 필수 변수 확인 후 튜플로 묶어 생성자 주입
        base_params = {
            "host": os.getenv(f"{current_prefix}HOST"),
            "port": int(os.getenv(f"{current_prefix}PORT", "3306")),
            "username": os.getenv(f"{current_prefix}USER"),
            "password": os.getenv(f"{current_prefix}PASS"),
            "target_db": os.getenv(f"{current_prefix}NAME"),
        }

        cls._credentials["primary"] = DatabaseCredentials(
            instance_name="PrimaryCluster",
            **base_params
        )

        secondary_params = {k: v for k, v in base_params.items()}
        secondary_params.update({"host": os.getenv(f"{current_prefix}SECONDARY_HOST", base_params["host"])})
        
        cls._credentials["replica"] = DatabaseCredentials(
            instance_name="ReplicaCluster",
            **secondary_params
        )

    @classmethod
    def get_credentials(cls, target_key: str) -> DatabaseCredentials:
        if target_key not in cls._credentials:
            raise KeyError(f"등록되지 않은 인스턴스 키: {target_key}")
        return cls._credentials[target_key]

2. 연결 풀 관리 팩토리

# db_layer/pool_manager.py
import mysql.connector
from mysql.connector import pooling, Error as MySqlErr
from typing import Dict
from app_config.loader import ConfigRegistry

class ConnectionPoolFactory:
    _pools: Dict[str, pooling.MySQLConnectionPool] = {}

    @classmethod
    def acquire_pool(cls, key: str) -> pooling.MySQLConnectionPool:
        if key in cls._pools:
            return cls._pools[key]

        creds = ConfigRegistry.get_credentials(key)
        pool_kwargs = {
            "pool_name": f"pl_{creds.instance_name}",
            "pool_size": 5,
            "pool_reset_session": True,
            "host": creds.host,
            "port": creds.port,
            "user": creds.username,
            "password": creds.password,
            "database": creds.target_db,
            "charset": creds.charset,
            "autocommit": False,
        }

        try:
            new_pool = pooling.MySQLConnectionPool(**pool_kwargs)
            cls._pools[key] = new_pool
            return new_pool
        except MySqlErr as exc:
            raise SystemExit(f"[POOL_INIT_FAILURE] {exc.errno}: {exc.msg}")

3. 쿼리 실행 핸들러

# db_layer/query_handler.py
import mysql.connector
from typing import List, Dict, Tuple, Any, Optional
from db_layer.pool_manager import ConnectionPoolFactory

class QueryProcessor:
    def __init__(self, pool_key: str):
        self.pool = ConnectionPoolFactory.acquire_pool(pool_key)

    def fetch_rows(self, query: str, params: Optional[Tuple[Any, ...]] = None) -> List[Dict[str, Any]]:
        conn = self.pool.get_connection()
        try:
            with conn.cursor(dictionary=True) as cursor:
                cursor.execute(query, params or ())
                return cursor.fetchall()
        finally:
            conn.close()

    def execute_dml(self, query: str, params: Optional[Tuple[Any, ...]] = None, commit: bool = True) -> int:
        conn = self.pool.get_connection()
        affected = 0
        try:
            with conn.cursor() as cursor:
                cursor.execute(query, params or ())
                affected = cursor.rowcount
                if commit:
                    conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()
        return affected

    def execute_bulk(self, query: str, param_set: List[Tuple[Any, ...]], commit: bool = True) -> int:
        conn = self.pool.get_connection()
        row_count = 0
        try:
            with conn.cursor() as cursor:
                cursor.executemany(query, param_set)
                row_count = cursor.rowcount
                if commit:
                    conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()
        return row_count

비즈니스 계층 연동 예시

서비스 레이어는 더 이상 연결 파라미터를 직접 다루지 않습니다. 이미 초기화된 풀에서 레퍼런스를 가져와 트랜잭션을 실행합니다.

# services/member_service.py
from db_layer.query_handler import QueryProcessor

class MemberService:
    def __init__(self):
        self.processor = QueryProcessor("primary")

    def retrieve_by_identifier(self, member_id: int) -> Dict | None:
        rows = self.processor.fetch_rows("SELECT id, nickname, email FROM members WHERE id = %s", (member_id,))
        return rows[0] if rows else None

    def save_new_account(self, display_name: str, contact: str) -> int:
        return self.processor.execute_dml(
            "INSERT INTO members (nickname, email) VALUES (%s, %s)",
            (display_name, contact)
        )

    def create_accounts_batch(self, account_data: list[tuple]) -> int:
        sql = "INSERT INTO members (nickname, email) VALUES (%s, %s)"
        return self.processor.execute_bulk(sql, account_data)

# services/transaction_service.py
from db_layer.query_handler import QueryProcessor

class TransactionService:
    def __init__(self):
        self.processor = QueryProcessor("replica")

    def fetch_ledger_entries(self, account_id: int):
        return self.processor.fetch_rows(
            """SELECT t.txn_id, t.amount, p.product_title 
               FROM transactions t JOIN products p ON t.product_id = p.id 
               WHERE t.account_id = %s""",
            (account_id,)
        )

환경 격리 및 보안 관리

개발 서버와 운영 서버는 동일한 코드베이스를 공유하지만, 실행 환경에 따라 설정 값이 완전히 치환됩니다. 이를 위해 환경 변수 우선 규칙을 강제하며, 개인 정보는 절대 코드에 포함하지 않습니다.

.env.local (개발 전용)

# APP_RUNTIME_ENV=dev
DEV_HOST=localhost
DEV_PORT=3306
DEV_USER=local_admin
DEV_PASS=dv_pass_779
DEV_NAME=user_schema

DEV_SECONDARY_HOST=localhost
DEV_SECONDARY_NAME=report_schema

운영 환경 배포 시 (Linux/Container)

# APP_RUNTIME_ENV=prod
# 시스템 레지스트리 또는 시크릿 볼트(Vault)에서 주입받음
export PROD_HOST=db-master.internal.cluster
export PROD_PASS=$(vault kv get -field=password secret/rds/main)
export SECONDARY_HOST=db-replica.internal.cluster

이러한 구조는 .gitignore를 통해 로컬 시크릿 파일을 제외하고, 컨테이너 오케스트레이션 도구(Kubernetes Secrets, Docker Secrets) 또는 CI/CD 파이프라인의 매개변수 저장소(Parameter Store)와 자연스럽게 연동할 수 있도록 설계되었습니다.

주요 장벽과 운영 가이드라인

  • 권한 최소화 원칙: 읽기 전용 복제본에는 readonly_user만 매핑하고, 쓰기 작업 전용 계정은 네트워크 ACL로 제한합니다.
  • 모니터링 통합: 풀 크기(pool_size)와 대기열 상태(_cnx_queue)를 메트릭 에이전트에 주기적으로 푸시하여 병목 지점을 선제적으로 탐지합니다.
  • 설정 변경 추적: 실제 값은 Git에서 배제하되, 키 구조와 샘플 형식을 담은 .env.template를 버전 관리에 포함시켜 신규 개발자의 온보딩 시간을 단축합니다.
  • 재연결 로직 강화: 연결이 끊긴 상태에서도 pool_reset_session 옵션과 함께 오류 코드를 분기 처리하여 애플리케이션 다운타임을 방지합니다.

고급 확장 시나리오

아키텍처가 성숙 단계에 진입하면 단일 파일 기반 설정을 벗어나 분산 구성 요소와 실시간 동기화 기술을 도입할 수 있습니다.

1. 중앙 구성 관리소 연계

Consul 또는 etcd와 같은 K/V 스토어를 연결하여 원클릭으로 클러스터 모든 노드에 설정을广播합니다.

# consul_client.py
def pull_from_vault(service_name: str, path: str):
    import hvac
    client = hvac.Client(url="https://vault.internal", token=os.getenv("VAULT_TOKEN"))
    response = client.secrets.kv.v2.read_secret_version(path=path)
    return response['data']['data'][service_name]

2. 동적 설정 핫 리로드

애플리케이션 재시작 없이 운영 중에도 연결 풀 매개변수(예: 최대 연결 수 조정)를 반영하려면 배경 스레드가 설정 상태를 폴링하고, 변경 감지 시 구형 풀을 안전하게 해체하는 패턴을 적용합니다.

# dynamic_reloader.py
def apply_pool_tuning(target_key: str, new_max_conn: int):
    existing = ConnectionPoolFactory.acquire_pool(target_key)
    # 라이브러리 별 재구성 API 호출 또는 새 풀 전환 후 카운터 스위칭
    print(f"[TUNING] {target_key} -> max_conn={new_max_conn}")

3. 이질적 데이터베이스 추상화

후속 확장으로 PostgreSQL 또는 SQLite 마이그레이션 시, 공통 실행 인터페이스를 상속받아 하위 구현체를 교체해도 상단 서비스 레이어 코드는 무결성을 유지합니다.

# interface_base.py
from abc import ABC, abstractmethod

class IDbExecutor(ABC):
    @abstractmethod
    def fetch_rows(self, query: str, params=None) -> list: ...
    
    @abstractmethod
    def execute_dml(self, query: str, params=None) -> int: ...

태그: python MySQL Connection Pooling 설정 중앙화 다중 데이터베이스

9월 3일 06:25에 게시됨