[소프트웨어 아키텍처] 의존성 역전 원칙(DIP)에 대해서 알아보자.

의존성 역전 원칙(DIP)에서 말하는 ‘유연성이 극대화된 시스템’이란? 소스코드 의존성이 추상에 의존하며 구체에는 의존하지 않는 시스템 이다.

자바에서 말하는 정적 타입 언어에서 이 말은 use, import, include 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만 참조해야 한다는 뜻이다. 구체적인 대상에는 절대로 의존해서는 안된다. 단, 안정적인 클래스(예 : 엄격하게 통제되는 String 클래스 같은 경우)는 코드 의존성이 발생 한다. 그래서 운영체제나 플랫폼 같이 안정성이 보장된 환경에서는 의존성을 무시해도 된다. 우리가 의존하지 않도록 피하고자 하는 것은 바로 변동성이 큰 구체적인 요소이다.

안정된 추상화

추상 인터페이스에 변경이 생기면 이를 구체화한 구현체들도 따라서 수정을 해야 한다. 반대로 구체적인 구현체에 변경이 생기더라도 그 구현체가 구현하는 인터페이스는 항상 좀 더 정확히 말하면 대다수의 경우 변경될 필요가 없다. 따라서 인터페이스는 구현체보다 변동성이 낮다.

안정된 소프트웨어 아키텍처를 위해서는 변동성이 큰 구현체에 의존하는 일은 지양하고 안정된 추상 인터페이스를 선호하는 아키텍처를 지향해야 한다. 이 원칙을 지키려면 아래와 같은 개발 기법을 준수해야 한다.

변동성이 큰 구체 클래스를 참조하지 말라

추상 인터페이스를 참조하라. 이 규칙은 정적 타입이든 동적 타입이든 관계없이 모두 적용된다. 또한 이 규칙은 객체 생성 방식을 강하게 제약하며, 일반적으로 추상 팩토리를 사용하도록 강제한다.

변동성이 큰 구체 클래스로부터 파생하지 말라

정적 타입 언어에서 상속은 소스 코드에 존재하는 모든 관계 중에서 가장 강력한 동시에 뻣뻣해서 변경하기 어렵다. 따라서 상속은 아주 신중하게 사용해야 한다. 동적 타입 언어라면 문제가 덜 되지만 의존성을 가진다는 사실에는 변함이 없다. 따라서 신중에 신중을 거듭하는 게 가장 현명한 선택이다.

구체 함수를 오버라이드 하지 말라

대체로 구체 함수는 소스 코드 의존성을 필요로 한다. 따라서 구체 함수를 오버라이드 하면 이러한 의존성을 제거할 수 없게 되며, 실제로는 그 의존성을 상속하게 된다. 이러한 의존성을 제거하려면 차라리 추상 함수로 선언하고 구현체들에서 각자 용도에 맞게 구현해야 한다.

팩토리

자바 등 대다수의 객체 지향 언어에서 바람직하지 못한 의존성을 처리할 때 추상 팩토리를 사용하곤 한다. Application은 Service 인터페이스를 통해 ConcreteImpl을 사용하지만, Application에서는 어떤 식으로든 ConcreteImpl의 인스턴스를 생성해야 한다. Concretelmpl에 대해 소스 코드 의존성을 만들지 않으면서 이 목적을 이루기 위해 Application은 ServiceFactory 인터페이스의 makeSvc 매서드를 호출 한다. 이 메서드는 ServiceFactory로부터 파생된 ServiceFactoryImpl에서 구현된다. 그리고 ServiceFactoryImpl 구현체가 ConcreteImpl의 인스턴스를 생성 후 Service 타입으로 변환한다.

의존성 관리를 위해 추상 팩토리 패턴을 사용한다.

곡선은 아키텍처의 경계를 뜻한다. 이 곡선은 구체적인 것들로부터 추상적인 것들을 분리한다. 소스코드 의존성은 해당 곡선화 교차할 떄 모두 한방향, 즉 추상적인 쪽으로 향한다.

곡선은 두 가지 컴포넌트를 구분한다. 하나는 추상 컴포넌트, 다른 하나는 구체 컴포넌트 이다. 추상 컴포넌트는 애플리케이션의 모든 고수준 업무 규칙을 포함한다. 구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함한다.

제어 흐름은 소스 코드 의존성과 정반대 방향으로 곡선을 가로지른다는 점에 주목하자. 다시 말해 소스코드 의존성은 제어 흐름과 반대 방향으로 역전된다. 이러한 이유로 이 원칙을 의존성 역전이라고 부른다.

구체 컴포넌트

대다수의 시스템은 구체 컴포넌트를 최소한 하나는 포함할 것이다. 흔히 이 컴포넌트를 메인이라고 부르는데 main 함수를 포함하기 때문이다. 위에 그림의 경우 main 함수는 ServiceFactoryImpl의 인스턴스 생성 후 이 인스턴스를 ServiceFactory 타입으로 전역 변수에 저장할 것이다. 그런 다음 Application은 이 전역 변수를 이용해서 Service FactoryImpl의 인스턴스에 접근할 것이다.

소스코드

의존성 방향 (Dependency Direction)

  • 소스 코드: ServiceFactoryImpl (구체) -> ServiceFactory (추상). 의존성 화살표가 구체적인 것에서 추상적인 것으로 향합니다.Application은 ConcreteImpl을 전혀 알지 못합니다. (import조차 필요 없음)

제어 흐름과 의존성의 역전 (Inversion of Control)

  • 제어 흐름 (Runtime): Application이 makeSvc()를 호출하면 -> ServiceFactoryImpl이 실행되어 -> ConcreteImpl이 생성됩니다.소스 코드 의존성 (Compile time): 제어 흐름과 반대로, 구체적인 구현체들이 추상적인 인터페이스를 바라보고 있습니다. 이것이 바로 의존성 역전(DIP)입니다.

팩토리의 역할

  • Application이 ConcreteImpl을 직접 new 키워드로 생성했다면, 소스 코드 의존성이 구체 컴포넌트 쪽으로 생겨버립니다.
  • ServiceFactory 인터페이스를 둠으로써 이 의존성을 끊어내고 아키텍처 경계를 명확히 했습니다.
// ==========================================
// 1. 추상 컴포넌트 (Abstract Component)
// - 애플리케이션의 고수준 업무 규칙을 포함
// - 구체적인 구현체(Concrete)에 의존하지 않음
// ==========================================

// [Interface] Service
// Application이 사용할 기능의 명세
interface Service {
    void operation();
}

// [Interface] ServiceFactory
// 객체 생성을 추상화한 인터페이스 (Abstract Factory)
interface ServiceFactory {
    Service makeSvc();
}

// [Class] Application
// 업무 로직을 수행하는 메인 클래스
// 중요: 이 클래스는 절대 'ConcreteImpl'을 import 하지 않습니다.
class Application {
    private ServiceFactory factory;

    // 생성자 주입을 통해 구체적인 팩토리를 받음 (DIP 준수)
    public Application(ServiceFactory factory) {
        this.factory = factory;
    }

    public void run() {
        // 팩토리를 통해 객체를 생성하지만, 반환 타입은 인터페이스(Service)임
        Service service = factory.makeSvc(); 
        service.operation();
    }
}

// ==========================================
// ----------------- 경계 (The Curve) -----------------
// 위쪽은 추상적(고수준), 아래쪽은 구체적(저수준)
// 소스 코드 의존성은 오직 위쪽(추상)으로만 향함
// ==========================================

// ==========================================
// 2. 구체 컴포넌트 (Concrete Component)
// - 업무 규칙을 다루기 위한 세부 사항 포함
// - 추상 컴포넌트의 인터페이스를 구현
// ==========================================

// [Class] ConcreteImpl
// 실제 기능을 수행하는 구현체
class ConcreteImpl implements Service {
    @Override
    public void operation() {
        System.out.println("ConcreteImpl: 실제 구체적인 로직이 실행됩니다.");
    }
}

// [Class] ServiceFactoryImpl
// 구체적인 객체 생성을 담당하는 구현체
class ServiceFactoryImpl implements ServiceFactory {
    @Override
    public Service makeSvc() {
        // 여기서 구체적인 클래스(ConcreteImpl)의 인스턴스를 생성
        return new ConcreteImpl();
    }
}

// [Main Component]
// 시스템의 진입점. 의존성을 주입하고 실행하는 역할
public class Main {
    public static void main(String[] args) {
        // 1. 구체적인 팩토리 생성 (Concrete Component 영역)
        ServiceFactory factory = new ServiceFactoryImpl();

        // 2. Application 생성 및 주입
        // Application은 ServiceFactoryImpl의 존재를 모르고 ServiceFactory만 앎
        Application app = new Application(factory);

        // 3. 실행
        // 제어 흐름: Application -> ServiceFactoryImpl -> ConcreteImpl 생성
        // 소스 의존성: Application -> ServiceFactory (Interface) <- ServiceFactoryImpl
        app.run();
    }
}

관련 글 보기