DIP: 의존성 역전 원칙
의존성 역전 원칙에서 말하는 '유연성이 극대화된 시스템'이란 소스 코드 의존성이 추상(abstraction)에 의존하며 구체(concretion)에는 의존하지 않는 시스템이다.
자바와 같은 정적 타입 언어에서 이 말은 use, import, include 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만을 참조해야 한다는 뜻이다. 구체적인 대상에 절대로 의존해서는 안 된다.
루비나 파이썬과 같은 동적 타입 언어에도 동일한 규칙이 적용된다. 소스 코드 의존 관계에서 구체 모듈은 참조해서는 안 된다. 하지만 이들 언어의 경우 구체 모듈이 무엇인지 정의하기가 다소 어렵다. 호출할 함수가 구현된 모듈이라면 참조하지 않기가 특히 어렵다.
이 아이디어를 규칙으로 보기엔 확실히 비현실적이다. 소프트웨어 시스템이라면 구체적인 많은 장치에 반드시 의존하기 때문이다. 자바에서 String은 구체 클래스이며, 이를 애써 추상 클래스로 만들려는 시도는 현실성이 없다.
그러나 String 클래스는 매우 안정적이다. 변경되는 일은 거의 없으며, 있더라도 엄격히 통제된다. 프로그래머들은 String 클래스가 변경이 자주 발생하리라고는 염려할 필요가 없다.
이러한 이유로 DIP를 논할 때 운영체제나 플랫폼 같이 안정성이 보장된 환경에 대해서는 무시하는 편이다. 우리가 의존하지 않도록 피하고자 하는 것은 바로 변동성이 큰(volatile) 구체적인 요소다.
안정된 추상화
추상 인터페이스에 변경이 생기면 이를 구체화한 구현체들도 따라서 수정해야 한다. 반대로 구체적인 구현체에 변경이 생기더라도 그 구현체가 구현하는 인터페이스는 대부분의 경우 변경될 필요가 없다. 따라서 인터페이스는 구현체보다 변동성이 낮다.
인터페이스를 변경하지 않고도 구현체에 기능을 추가할 수 있는 방법을 찾기 위해 노력하는 것은 설계의 기본이다.
즉, 안정된 소프트웨어 아키텍처란 변동성이 큰 구현체에 의존하는 일은 지양하고, 안정된 추상 인터페이스를 선호하는 아키텍처란 뜻이다.
변동성이 큰 구체 클래스를 참조하지 말라.
대신 추상 인터페이스를 참조하자. 언어가 정적 타입이든 동적 타입이든 관계 없다. 이 규칙은 객체 생성 방식을 강하게 제약하며, 일반적으로 추상 팩토리(Abstract Factory)를 사용하도록 강제한다.
변동성이 큰 구체 클래스로부터 파생하지 말라.
이 규칙은 이전 규칙의 따름정리지만, 별도로 언급할 가치가 있다. 정적 타입 언어에서 상속은 소스 코드에 존재하는 모든 관계 중에서 가장 강력한 동시에 뻣뻣해서 변경이 어렵다.
따라서 상속은 아주 신중하게 사용해야 한다. 동적 타입 언어라면 문제가 덜 하지만 의존성을 가진다는 사실에는 변함이 없다.
구체 함수를 오버라이드 하지 말라.
대체로 구체 함수는 소스 코드 의존성을 필요로 한다. 구체 함수를 오버라이드한다면 의존성을 제거할 수 없게 되며, 실제로는 그 의존성을 상속하게 된다.
이런 의존성을 제거하려면, 차라리 추상 함수로 선언하고 구현체들에서 각자 용도에 맞게 구현해야 한다.
구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라.
이 방법은 DIP를 다른 방식으로 풀어쓴 것과 다름 없다.
팩토리
이 규칙들을 준수하려면 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 한다.
자바 등의 대다수의 객체 지향 언어에서 바람직하지 못한 의존성을 처리할 때 추상 팩토리를 사용하곤 한다.
Application은 Service 인터페이스를 통해서 ConcreteImpl을 사용하지만, Application에서는 어떤 식으로든 ConcreteImpl의 인스턴스를 생성해야 한다.
ConcreteImpl에 대한 의존성을 만들지 않으면서 이 목적을 이루기 위해서는 Application은 ServiceFactory 인터페이스의 makeSvc 메소드를 호출한다. 이 메소드는 ServiceFactory 인터페이스를 구현한 ServiceFactoryImpl에서 구현된다.
ServiceFactoryImpl의 구현체가 ConcreteImpl의 인스턴스를 생성한 후 Service 타입으로 반환한다.

위 이미지의 곡선은 아키텍처 경계를 의미한다. 구체적인 것들로부터 추상적인 것들을 분리한다. 소스 코드의 의존성은 해당 곡선과 교차할 때 모두 한 방향, 즉 추상적인 쪽을 향한다.
곡선은 시스템을 추상 컴포넌트와 구체 컴포넌트로 분리한다. 추상 컴포넌트는 애플리케이션의 모든 고수준 비즈니스 룰을 포함한다. 구체 컴포넌트는 비즈니스 룰을 다루기 위해 필요한 모든 세부사항을 포함한다.
제어흐름은 소스 코드 의존성과는 정반대 방향으로 곡선을 가로지른다. 그렇기 때문에 이 원칙을 의존성 역전이라고 부른다.
Application
├─ Service (interface) ← 고수준 정책
└─ ServiceFactory (interface) ← Service를 어떻게 만들지는 모름
ServiceFactoryImpl ← 저수준 세부 구현
└─ ConcreteServiceImpl ← 실제 서비스 구현
// Service (Interface)
public interface Service {
void execute();
}
// Concrete Impl
public class ConcreteService implements Service {
@Override
public void execute() {
System.out.println("실제 서비스 로직 실행");
}
}
// Service Factory (Interface)
public interface ServiceFactory {
Service makeSvc();
}
// Service Factory Impl
public class ServiceFactoryImpl implements ServiceFactory {
@Override
public Service makeSvc() {
return new ConcreteService();
}
}
// Application
public class Application {
private final ServiceFactory factory;
public Application(ServiceFactory factory) {
this.factory = factory;
}
public void run() {
Service service = factory.makeSvc();
service.execute();
}
}다이어그램 이미지 예시를 코드로 작성한다면 위와 같겠다.
이미 Service Interface를 통해서 의존성 역전은 이뤄졌지만 왜 Factory를 쓰는가에 대한 의문이 생겨났는데, 이는 DIP가 위배되는 경우가 보통 객체의 생성과 조립이라서 그것을 통제하려고 하기 떄문인 것으로 보인다.
class Application {
void run() {
Service s = new ConcreteService();
s.execute();
}
}팩토리가 없는 케이스의 Application 코드이다. 변수의 타입은 추상(Service)이지만 의존성 자체는 ConcreteService라는 구체에 생긴다.
즉, Application이 구체 클래스를 import하여 생성 시점엔 구체에 묶이게 된다.
이는 처음 설명했던 use, import, include와 같은 구문이 인터페이스나 추상 클래스에 의존해야한다는 것과 반대된다.
한 가지 예를 더 보도록 하자.
class OrderService {
void process() {
PaymentService payment = new CardPaymentService(); // ❌
payment.pay();
}
}위 코드는 OrderService가 어떤 결제 수단이 있는지 알아야 하고, 다른 결제 수단이 추가되면 수정이 불가피하다. 결제 수단 구성이 바뀌는 것으로 주문 서비스에 영향을 주므로 SRP도 어긋나게 된다.
고수준의 코드는 추상에 의존해야 한다. 그렇다면 그 추상의 구현은 어디서 해야하는걸까? 여기서 팩토리 패턴이 온다. 고수준 정책이 변하기 쉬운 세부사항으로부터 독립하기 위해서 구체에 의존하는 것을 팩토리에게 외주를 던졌다고 볼 수 있다.
즉, 구체의 생성 책임을 다 팩토리가 가져가게 된다.
class PaymentServiceFactory {
PaymentService create() {
return new CardPaymentService();
}
}
class OrderService {
private final PaymentServiceFactory factory; // 구체 타입
OrderService(PaymentServiceFactory factory) {
this.factory = factory;
}
void process() {
PaymentService payment = factory.create();
payment.pay();
}
}팩토리로 생성 책임을 분리하면 구체 서비스의 변경/추가가 OrderService로 전파되는 것을 줄일 수 있다.
하지만 이 팩토리 또한 구체이다. 팩토리는 어떤 구현을 쓸지, 어떻게 객체를 생성할지를 아는 저수준의 세부사항이다.
OrderService는 팩토리 구현 타입을 알고 있고 그래서 결국 구체에 의존하고 있다. 생성 정책이 바뀌거나 팩토리를 교체하고 싶다면 코드가 OrderService도 영향을 받게 된다.
그래서 팩토리도 추상화하고 구현은 바깥으로 밀어낸다.
interface PaymentService {
void pay(int amount);
}
class CardPaymentService implements PaymentService {
public void pay(int amount) {
System.out.println("CARD pay: " + amount);
}
}
class BankTransferPaymentService implements PaymentService {
public void pay(int amount) {
System.out.println("BANK transfer: " + amount);
}
}
interface PaymentServiceFactory {
PaymentService create(PaymentMethod method);
}
class PaymentServiceFactoryImpl implements PaymentServiceFactory {
public PaymentService create(PaymentMethod method) {
return switch (method) {
case CARD -> new CardPaymentService();
case BANK -> new BankTransferPaymentService();
};
}
}
class OrderService {
private final PaymentServiceFactory factory;
OrderService(PaymentServiceFactory factory) {
this.factory = factory;
}
void checkout(PaymentMethod method, int amount) {
PaymentService payment = factory.create(method);
payment.pay(amount);
}
}이제 OrderService는 아까와 같은 이름의 PaymentServiceFactory 타입에 의존하고 있지만 이것은 구체가 아니라 추상 타입이다.
OrderService는 이제 팩토리의 종류에도 무관하며 생성 정책이 어떻게 바뀌든, 팩토리 내부 구현이 바뀌든 영향을 받지 않는다.
구체 컴포넌트
위 이미지에서 ServiceFactoryImpl 구체 클래스가 ConcreteImpl 구체 클래스에 의존한다. 따라서 DIP에 위배되며 이는 일반적인 일이다.
DIP 위배를 모두 없앨 수는 없다. 하지만 DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내로 모을 수 있고, 이르 통해 시스템의 나머지 부분과는 분리할 수 있다.
대다수의 시스템은 이런 구체 컴포넌트를 최소한 하나는 포함할 것이다. 흔히 이 컴포넌트를 메인(Main)이라 부르는데, main 함수를 포함하기 떄문이다.
위 이미지에서 main 함수는 ServiceFactoryImpl의 인스턴스를 생성 후, 이 인스턴스를 ServiceFactory 타입으로 전역 변수에 저장할 것이다. 그 다음 Application은 이 전역 변수를 이용해서 ServiceFactoryImpl의 인스턴스에 접근할 것이다.
public class Main {
public static void main(String[] args) {
ServiceFactory factory = new ServiceFactoryImpl(); // ServiceFactoryImpl 인스턴스 생성
Application app = new Application(factory); // Application에 인스턴스 주입
app.run();
}
}결론
앞으로 고수준의 아키텍처 원칙을 다루게 되면서 DIP는 몇 번이고 계속 등장할 것이다. 그리고 아키텍처 다이어그램에서 가장 눈에 드러나는 원칙이 될 것이다.
이미지의 곡선은 아키텍처 경계이며, 의존성은 이 곡선을 경계로 하여 더 추상적인 엔티티가 있는 쪽으로만 향한다. 추후 이 규칙은 의존성 규칙이라 부를 것이다.