의존 역전 원칙(DIP, Dependency Inversion Principle)은 구체적인 것에 의존하지 말고 추상적인 것에 의존하라는 원칙이다. 여기서 의존이란 한 클래스가 다른 클래스를 직접 만들어 쓰거나 그 이름을 코드에 박아 두는 것을 말한다. 구체 클래스에 직접 묶이면 그것을 바꿀 때마다 이쪽 코드까지 함께 고쳐야 한다. 추상(구현을 강제하는 인터페이스 역할의 약속)에 의존하고 구체는 밖에서 주입하면, 세부 구현을 자유롭게 갈아 끼울 수 있다.
이 원칙이 필요한 이유는 상위 로직과 세부 구현의 운명을 떼어 놓기 위해서다. 사용자를 등록하는 비즈니스 로직이 특정 데이터베이스(DB) 종류에 직접 묶여 있으면, DB를 바꾸는 순간 비즈니스 로직까지 흔들린다. 테스트할 때 진짜 DB를 띄워야 하는 것도 이 결합 때문이다.
이름에 붙은 역전이라는 말은 의존의 방향이 뒤집힌다는 뜻이다. 보통은 중요한 상위 로직이 사소한 세부 구현을 직접 가리키며 아래로 의존한다. 의존 역전을 적용하면 상위 로직과 세부 구현이 둘 다 가운데에 놓인 추상을 바라보게 된다. 상위 로직이 세부 구현에 끌려다니던 방향이 뒤집혀, 세부 구현이 상위 로직이 정한 약속을 따르게 되는 것이다. 이 뒤집힘이 원칙의 이름이 되었다.
14.1 구체 클래스에 직접 의존
아래 UserServiceBad는 생성자 안에서 직접 MySQLDatabase를 만들어 쓴다. 사용자 서비스라는 상위 로직 속에 특정 DB 이름이 박혀 있는 것이다.
class MySQLDatabase:
def save(self, data):
return f"MySQL에 {data} 저장"
class UserServiceBad:
def __init__(self):
self.db = MySQLDatabase()
def register(self, name):
return self.db.save(name)
print("before:", UserServiceBad().register("철수"))
실행 결과는 다음과 같다.
before: MySQL에 철수 저장
UserServiceBad 안에 MySQLDatabase()가 박혀 있어서, DB를 다른 종류로 바꾸거나 테스트용 가짜 DB를 쓰려면 이 클래스를 직접 열어 고쳐야 한다. 상위 로직이 세부 구현을 손에 쥐고 있으니 둘의 운명이 하나로 묶였다.
14.2 추상에 의존하고 주입받기
이제 Database라는 추상을 두고, UserService는 그 추상만 알게 한다. 실제 DB는 밖에서 생성자로 넣어 준다. 이렇게 필요한 부품을 밖에서 넣어 주는 방식을 의존성 주입(dependency injection)이라 한다.
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def save(self, data): ...
class MySQL(Database):
def save(self, data):
return f"MySQL에 {data} 저장"
class Postgres(Database):
def save(self, data):
return f"Postgres에 {data} 저장"
class MemoryDB(Database):
def save(self, data):
return f"메모리에 {data} 저장"
class UserService:
def __init__(self, db: Database):
self.db = db
def register(self, name):
return self.db.save(name)
print("after MySQL:", UserService(MySQL()).register("철수"))
print("after Postgres:", UserService(Postgres()).register("영희"))
print("테스트용 메모리:", UserService(MemoryDB()).register("테스트"))
실행 결과는 다음과 같다.
after MySQL: MySQL에 철수 저장
after Postgres: Postgres에 영희 저장
테스트용 메모리: 메모리에 테스트 저장
UserService는 Database라는 약속만 알 뿐 구체 종류를 모른다. 생성자로 주입하니 DB를 갈아 끼워도 UserService는 한 글자도 바뀌지 않는다. DB 교체가 코드 수정이 아니라 인자 교체로 끝난다. 테스트할 때 진짜 DB 없이 MemoryDB를 넣으면 되니 테스트가 쉽고 빨라진다. 이것이 무엇이 좋아졌는지의 핵심이다.
14.3 같은 원칙, 로거 주입
의존 역전은 DB만의 이야기가 아니다. 주문 처리기가 로그를 남길 때, 콘솔에 남길지 파일에 남길지를 밖에서 정하게 만든다. 주문 처리기는 어딘가에 write한다는 것만 알면 된다.
class LoggerABC(ABC):
@abstractmethod
def write(self, msg): ...
class ConsoleLogger(LoggerABC):
def write(self, msg): return f"콘솔: {msg}"
class FileLogger(LoggerABC):
def write(self, msg): return f"파일: {msg}"
class OrderProcessor:
def __init__(self, logger: LoggerABC):
self.logger = logger
def process(self, order):
return self.logger.write(f"주문 {order} 처리")
print(OrderProcessor(ConsoleLogger()).process("A100"))
print(OrderProcessor(FileLogger()).process("A100"))
실행 결과는 다음과 같다.
콘솔: 주문 A100 처리
파일: 주문 A100 처리
OrderProcessor는 로그를 어딘가에 write한다는 것만 알고, 그곳이 콘솔인지 파일인지는 주입받는다. 상위 로직과 세부 구현이 추상을 사이에 두고 분리된 것이다. 로그 대상을 바꿔도 주문 처리 로직은 그대로다. 클라우드 로거를 새로 만들어 끼우기만 하면 기능이 확장된다.
14.4 정리
의존 역전 원칙은 상위 로직이 세부 구현을 직접 붙잡지 않게 만든다. 구체 클래스를 코드 안에서 생성하는 대신, 추상을 두고 그 추상에만 의존하며, 실제 구현은 생성자로 주입받는다. 그러면 DB든 로거든 세부 구현을 갈아 끼워도 상위 로직은 흔들리지 않고, 테스트용 가짜 구현을 넣어 손쉽게 검증할 수 있다. 어떤 클래스 안에서 다른 구체 클래스를 직접 만들어 쓰는 코드가 보이면, 그 자리에 추상을 세우고 주입으로 바꿀 수 있는지 살펴야 한다.