DI의 트레이드오프 — mock 교체와 보일러플레이트
do-we-need-a-class|대안 비교 노트에서 DI의 단점으로 언급된 두 가지를 상세히 풀어본다.
mock 교체가 어렵다 (모듈 레벨 변수의 경우)
테스트할 때 실제 DB 대신 가짜(mock)를 쓰고 싶은 상황을 생각해보자.
모듈 레벨 변수 방식:
# db.py
import sqlite3
conn = sqlite3.connect("db.sqlite") # import 시 바로 실행됨
def execute(query):
return conn.execute(query)
# order_service.py
import db # 여기서 db.py가 실행됨 — conn이 이미 생성됨
def create_order(data):
db.execute("INSERT INTO orders ...")
mock으로 교체하려면 모듈 내부 변수를 직접 패치해야 한다:
# test_order.py
import db
db.conn = MockConnection() # 모듈 변수를 직접 덮어씀
세 가지 문제가 있다:
import db를 하는 순간 이미 실제 DB 연결이 열린다. mock으로 교체하기 전에 side effect 발생- 모듈 변수를 직접 패치하는 건 테스트가 내부 구현을 알아야 한다는 뜻.
db.conn이라는 변수명이 바뀌면 테스트도 깨짐 - 패치 순서와 teardown을 직접 관리해야 함. 안 하면 다음 테스트에 영향을 줌
DI 방식:
class OrderService:
def __init__(self, db):
self.db = db
# 테스트에서
mock_db = MockDatabase()
service = OrderService(mock_db) # 실제 DB 연결 자체가 안 열림
OrderService는 db가 뭔지 몰라. 핵심은 **"의존성이 어디서 결정되냐"**다. 모듈 변수는 모듈 내부에서 결정되고, DI는 호출하는 쪽에서 결정된다. 테스트가 쉬우려면 호출하는 쪽에서 제어권을 가져야 한다.
보일러플레이트 증가 (DI의 단점)
보일러플레이트는 "기능과 무관하게 구조상 반복해서 써야 하는 코드"다.
서비스가 3개일 때는 괜찮다:
db = DatabaseManager(conn)
cache = CacheManager(redis_conn)
email = EmailService(smtp_conn)
user_service = UserService(db, cache)
order_service = OrderService(db, email)
payment_service = PaymentService(db, email, cache)
서비스가 10개, 20개가 되면 "의존성을 넘겨준다"는 행위 자체가 쌓이기 시작한다:
analytics_service = AnalyticsService(db, cache)
notification_service = NotificationService(email, cache)
report_service = ReportService(db, email, analytics_service)
search_service = SearchService(db, cache, analytics_service)
# ...
계층이 깊어지면 더 심해진다:
# A가 B를 쓰고, B가 C를 쓰고, C가 db를 쓰면
# db를 A까지 계속 전달해야 함
def create_a(db):
b = create_b(db) # A는 db 안 써도 B한테 넘겨줘야 함
return A(b)
def create_b(db):
c = create_c(db) # B도 마찬가지
return B(c)
def create_c(db):
return C(db) # 실제로 db 쓰는 건 C뿐
A는 db를 직접 안 쓰는데 C한테 전달하기 위해 받아야 한다. 이 문제 때문에 규모가 커지면 IoC 컨테이너가 등장한다 — "누가 누구한테 뭘 넘겨줄지"를 프레임워크가 대신 관리해준다.