당신의 도메인은 PostgreSQL을 알지 못합니다 (그리고 그래서는 안 됩니다)

발행: (2026년 3월 25일 PM 08:32 GMT+9)
6 분 소요
원문: Dev.to

Source: Dev.to

문제: 비즈니스 로직이 인프라에 결합됨

비즈니스 로직은 PostgreSQL이든 MySQL이든 텍스트 파일이 가득 든 폴더이든 신경 쓸 필요가 없습니다. 만약 신경을 쓴다면, 이는 사소한 코드 냄새가 아니라 아키텍처 문제입니다.

얽힌 예시

from sqlalchemy.orm import Session
from app.models import Order, Customer
from app.db import get_db
from fastapi import HTTPException
import os

class OrderService:
    def place_order(self, customer_id: int, items: list, db: Session):
        customer = db.query(Customer).filter(Customer.id == customer_id).first()
        if not customer:
            raise HTTPException(status_code=404, detail="Customer not found")

        total = sum(item["price"] * item["qty"] for item in items)

        if customer.loyalty_points > 500:
            total *= 0.9  # 10% discount

        order = Order(
            customer_id=customer_id,
            total=total,
            status="pending",
            items=items
        )
        db.add(order)
        db.commit()
        db.refresh(order)

        if os.getenv("NOTIFY_SERVICE_ENABLED") == "true":
            # fire and forget
            pass

        return {"order_id": order.id, "total": total, "status": "pending"}

이 함수는 다음을 알고 있습니다:

  • SQLAlchemy 세션
  • FastAPI HTTP 응답 및 상태 코드
  • ORM 모델 내부
  • 환경 변수
  • 원시 JSON 딕셔너리 구조

유일한 비즈니스 책임은 주문이 유효한지와 가격을 결정하는 것입니다. 하지만 동시에 다섯 가지 다른 작업을 수행하므로 테스트가 어려워집니다.

def test_loyalty_discount_applied():
    service = OrderService()
    # ...now what?
    # We need a real db Session
    # We need real Customer rows in that database
    # We need FastAPI's exception handling wired up
    # We need environment variables set correctly

전형적인 결과:

  1. 테스트 데이터베이스를 띄우고 할인 로직 몇 줄을 테스트하기 위해 긴 fixture를 작성한다.
  2. SQLAlchemy를 과도하게 모킹하여 실제 동작을 검증하지 못하는 테스트가 된다.
  3. 테스트 자체를 건너뛴다 (대부분의 팀).

마지막 옵션은 게으름이 아니라, 테스트하기 너무 어려운 코드의 증상입니다.

순수 비즈니스 규칙 추출

def calculate_order_total(items: list, loyalty_points: int) -> float:
    total = sum(item["price"] * item["qty"] for item in items)
    if loyalty_points > 500:
        total *= 0.9
    return total

이것이 비즈니스에 중요한 도메인 로직입니다. 주변의 데이터베이스 호출, HTTP 처리, 환경 변수 검사는 모두 인프라스트럭처 관점이며, 계산 로직에서는 보이지 않아야 합니다.

깨끗한 도메인 모델

from dataclasses import dataclass
from typing import List

@dataclass
class OrderItem:
    product_id: str
    price: float
    quantity: int

@dataclass
class Order:
    customer_id: str
    items: List[OrderItem]
    loyalty_points: int

    def calculate_total(self) -> float:
        total = sum(item.price * item.quantity for item in self.items)
        if self.loyalty_points > 500:
            total *= 0.9
        return total

    def is_valid(self) -> bool:
        return len(self.items) > 0 and all(item.quantity > 0 for item in self.items)

SQLAlchemy, FastAPI, os.getenv, 혹은 원시 딕셔너리를 사용하지 않고—순수한 도메인 개념만 사용합니다.

도메인 로직 테스트

def test_loyalty_discount():
    order = Order(
        customer_id="c-1",
        items=[OrderItem("p-1", 100.0, 2)],
        loyalty_points=600
    )
    assert order.calculate_total() == 180.0  # 200 * 0.9

세 줄, 고정값 없음, 목(mock) 없음, 데이터베이스 없음. 테스트는 밀리초 단위로 실행됩니다.

왜 분리가 중요한가

대부분의 백엔드 코드베이스는 두 가지 관심사를 혼합합니다:

  1. 소프트웨어가 하는 일 – 제품을 정의하는 규칙과 계산.
  2. 소프트웨어가 그것을 수행하는 방법 – 데이터베이스, HTTP 프레임워크, 큐 등.

이러한 관심사가 얽혀 있을 때:

  • 비즈니스 규칙을 건드리지 않고는 PostgreSQL을 교체할 수 없습니다.
  • 데이터베이스를 실행하지 않고는 비즈니스 규칙을 테스트할 수 없습니다.
  • 전체 인프라 계층을 끌어당기지 않고는 서비스 간에 로직을 공유할 수 없습니다.

도메인은 PostgreSQL(또는 특정 저장소)에 대해 알 필요가 없습니다. 만약 알게 된다면, 시간이 지남에 따라 비용이 발생하는 암묵적인 아키텍처 결정을 내린 것입니다.

Maintaining the Separation

Ports and adapters (a.k.a. Hexagonal Architecture) provide formal boundaries that are explicitly named and consistently enforced. They keep the domain pure while allowing the outer layers to change independently.

Actionable step: Open any service class in your codebase (e.g., OrderService, UserService). Count how many external dependencies it imports that have nothing to do with business rules. If the count is greater than zero, you have the problem described here. You’re in good company, and there’s a clean way out.

0 조회
Back to Blog

관련 글

더 보기 »