Spring

의존성 역전 원칙(DIP)과 인터페이스 소유권의 역전

천방지축 개발노트 2024. 11. 17. 22:52

'의존성 역전 원칙(DIP)'은 '의존성 주입'의 약자인

DI(Dependency Injection)와 비슷하게 생겼기에 유사한 개념이라고 혼동할 수 있다.

연관이 아예 없는 것은 아니지만 어쨌든 ' SOLID'라고 객체지향 설계 원칙에서

제일 마지막에 등장하는 원칙인 'Dependency Inversion Principle' 과

이로 인해 생각해 볼 Spring Web MVC 구조에 대해서도 정리해 봤다.


 

의존성 역전 원칙(Dependency Inversion Principle) 이란?

DIP의 정의는 아래와 같다. 이해하기 힘든 문장들을 코드와 함께 하나하나 파헤쳐 보자.

"상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다. 또한, 추상화는 구체적인 사항에 의존해서는 안 되며, 구체적인 사항은 추상화에 의존해야 한다."

 

1. "상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 된다."

class PaymentService {
    private KakaoPayment kakaoPayment;
    
    public PaymentService() {
        this.kakaoPayment = new KakaoPayment(); // 클래스에 직접적으로 의존하고 있다.
    }
    
    public void processPayment() {
        kakaoPayment.pay();
    }
}
구분 의미
상위 모듈 애플리케이션의 비즈니스 로직이나 핵심 기능을 담당하는 모듈을 의미한다.
흔히 '의존하는 객체'를 담당하며, 위 코드에서는 PaymentService와 같은 클래스가 결제 처리라는 핵심 기능을 담당하므로 상위 수준 모듈이라고 할 수 있다.
하위 모듈 실제 구체적인 구현을 담당하는 모듈을 의미한다.
흔히 '의존되는 객체'를 담당하며, 위 코드에서는 KakaoPayment와, NaverPayment와 같은 결제 방식의 구체적인 구현이 하위 수준 모듈이라고 할 수 있다.
하위 수준 모듈의 가장 큰 특징은 기술적인 세부 사항을 다루며, 자주 변경될 가능성이 크다는 것.

즉, 상위 모듈(비즈니스 로직)은 하위 모듈(구체적인 결제 방식)에 의존하면 안 된다는 의미이다.

예를 들어, PaymentService가 KakaoPayment라는 구체적인 결제 방식을 직접적으로 사용한다면, 추후에 결제 방식을 NaverPayment로 변경 시 상위 모듈인 PaymentService를 수정해야 한다. 따라서 Class를 직접적으로 의존하게 하면 시스템의 결합도를 높이고, 유지 보수를 어렵게 만들 수 있다. 참고로 여기서 모듈이라는 건 애플리케이션 내에서 응집도가 높고 결합도가 낮은 그런 기능들을 쪼개놓은 것들을 나타내며, Java에서는 패키지 단위일 수도 있다.

interface Payment {
    void pay();
}

class KakaoPayment implements Payment {
    public void pay() {
        System.out.println("Kakao payment processing...");
    }
}

class NaverPayment implements Payment {
    public void pay() {
        System.out.println("Naver payment processing...");
    }
}

class PaymentService {
    private Payment Payment;

    public PaymentService(Payment Payment) {
        this.Payment = Payment; // 추상화에 의존
    }

    public void processPayment() {
        paymentMethod.pay();
    }
}

 

2. "둘 모두 추상화에 의존해야 한다."

'둘 모두'라는 단어를 구체적으로 풀이해 보자.

상위 모듈 입장에서 보면 "상위 모듈은 하위 모듈의 추상화(인터페이스 또는 추상 클래스)에 의존해야 한다"라는 것이고, 두 번째로 하위 모듈 입장에서는 "하위 모듈은 추상화를 구현해야 한다"는 것. 즉, 추상화된 인터페이스나 추상 클래스를 구현하는 역할로 하위 모듈을 개발해야 함을 말한다.

한 줄 정리하자면 상위 수준 모듈은 추상화에 의존해야 하고, 하위 수준 모듈은 추상화를 구현해야 함을 뜻한다.

 

3. "추상화는 구체적인 사항에 의존해서는 안 되며, 구체적인 사항은 추상화에 의존해야 한다."

쉽게 말해, 추상화(인터페이스, 추상 클래스)는 구체적인 구현인 클래스를 의존하지 않으며, 클래스는 추상화에 의존해야 한다는 것을 의미한다. 위 코드로 설명하자면 추상화(Payment 인터페이스)는 클래스(KakaoPayment, NaverPayment)에 의존하지 않고 있다(당연하게도..)

반대로 클래스는 인터페이스(Payment)를 구현하고 있기에, 추상화에 의존하는 형태라고 할 수 있다. 이로써 결제 방식이 무엇이든 상관없이 PaymentService는 바뀌지 않게 된다.

 

어렵게 풀이했지만 결국 2번과 3번의 문장은 같은 의미이다.

결과적으로 이런 원칙을 지킴으로써, 구체적인 구현을 변경하더라도 시스템의 다른 부분, 특히 상위 모듈에는 영향을 미치지 않게 된다. 상위 모듈은 여전히 추상화에 의존하고, 구체적인 세부 사항은 하위 모듈에서 변경될 수 있기 때문에 시스템의 유연성과 확장성이 높아진다.

 

이 DIP를 적용하려면 스프링 컨테이너라는 제3의 존재에 의한 '의존성 주입(Dependency Injection, DI)'이 필요하며, 이과 함께 사용되어 더욱 강력한 설계 패턴이 된다.

 

 

의존성 역전 원칙(DIP)과 인터페이스 소유권의 역전(Interface Ownership Inversion)

위의 DIP 설명에서 A가 B를 의존하여 사용한다면 A가 상위 모듈 B가 하위 모듈이고, 이때 B가 바뀌면 A도 수정해야 하는 결과를 낳기 때문에 결국 '추상화'. 즉, Interface를 이용하여 의존하라는 것이라고 했다. 근데 사실 이런 코드 레벨에서의 의존은 당연하고 자연스러운 것이다.

그리고 Interface를 만들어서 추상화에 의존하도록 하는 건 맞다고 하더라도, 결국 모듈인 패키지 수준으로 보자면 그 Interface 자체를 의존하고 있는 것이기 때문에, 여전히 상위 모듈이 Interface인 하위 모듈을 의존하고 있다고 볼 수 있다.

 

이때 '인터페이스 소유권의 역전'과 '분리 인터페이스 패턴(Separated Interface Pattern)'이 필요하다. 이 개념은, 인터페이스는 어느 패키지에 들어 있어야 되는가?에 대한 개념이다.

인터페이스 소유권의 역전

보통 인터페이스를 만들면, 이것을 구현한 클래스와 같은 패키지에 있는 것이 맞아 보일 수 있지만, 많은 경우에 Interface는 자신을 구현한 클래스 쪽이 아니라 자신을 사용하는 쪽에 있는 게 더 자연스러운 경우가 많다고 한다. 그래서 '인터페이스의 소유권을 역전'시켜야 한다는 것이다.

 

위와 같이 작업함으로써 소프트웨어 설계에서 "상위 계층이 하위 계층에 의존한다"라는 전통적인 의존성 방향이 역전(Invert)됐다. 따라서 하위 모듈(구현체)이 변경되더라도 상위 모듈은 영향을 받지 않는 구조가 됨을 위 그림으로 알 수 있다.

 

의존성 역전 원칙을 잘 따르는 코드를 만들 때의 작업은 다음과 같다.

1) 첫 번째 작업: Interface를 만들어내고 추상화를 한 다음에 모든 코드가 추상화에만 의존하도록 만듦.

2) 두 번째 작업: Interface를 구현한 클래스, 이런 클래스가 있는 모듈에 두는 게 아니라 이를 사용하는 클라이언트(코드)가 있는 모듈(패키지)로 이전시키는 것.

 

 

왜 Web Controller와 Service 계층 간에는 DIP를 적용하지 않는가?

보통 Spring Web MVC의 구조는 아래와 같다. 웹 계층(Web Controller)과 서비스 계층(Service Layer) 간의 의존성으로 보아 DIP를 적용한다면,  서비스 계층의 interface도 Controller 패키지에 위치해야 하지 않을까 싶다. 하지만 왜 그렇게 설계하지 않는 걸까?

com.sample.app
    ├── controller
    │   └── PaymentController.java
    ├── service
    │   ├── PaymentService.java (인터페이스)
    │   └── impl
    │       └── PaymentServiceImpl.java
    └── mapper
        └── PaymentMapper.java

 

의존성 역전을 하는 이유는 애플리케이션의 중심이 되는 도메인/비즈니스 로직을 가진 상위 모듈이 기술적인 메커니즘을 다루는 변경 가능성이 높은 하위 모듈에 의존하지 않게 만드는 것이 목적이다.

그리고 Web MVC 구조에서는 변경 가능성이 높은 Web과 UI, Data 그리고 각종 Infra 기술들이 하위 모듈로 취급되며, 비즈니스 로직을 다루는 서비스 계층과 이 안에서 다루는 도메인 오브젝트가 가장 중심이 되는 상위 모듈이라고 한다.

 

생각해보면 Controller는 단순히 Web 요청을 받아서 서비스 계층에 전달하고, 그 결과를 UI에 반환하는 역할에 초점이 맞춰져 있기도 하다. 따라서, 상위 모듈인 서비스 계층을 중심으로 모든 의존성이 형성되는 것이다. 이렇게 되면 중요한 애플리케이션 로직이 웹 요청을 어떤 식으로 받아서 처리하는지? 혹은 뒤에서 데이터를 어떻게 읽어오는지? 혹은 어떠한 인프라 기술을 사용하는지에 영향을 받지 않는 안정적인 구조가 된다.