안정적인 설계를 도와줄 의존성 역전 원칙

박세리·2025년 6월 30일
0

의존성 역전 원칙

의존성 역전 원칙(Dependency Inversion Principle, DIP)이란 SOLID 원칙 중 마지막 원칙으로, 소프트웨어 설계 시 중요한 개념이다. 이는 로버트 마틴이 제시한 원칙으로, 고수준의 모듈이 저수준 모듈에 의존하면 안 된며, 두 모듈 모두 추상화된 것에 의존해야 한다는 것이다. 클래스가 특정한 다른 클래스를 직접적으로 의존하면 안 되고, 그 대상의 상위 요소인 추상 클래스나 인터페이스에 의존해야 한다.

고수준 모듈(High-level modules)

저수준 모듈을 조합하여 큰 기능을 수행하는 모듈로 높은 수준의 추상화와 비즈니스 로직을 담당한다. 주로 사용자 인터페이스, 애플리케이션의 로직과 같은 것을 다뤄 애플리케이션의 비즈니스 요구사항을 충족시킨다.

저수준 모듈(Low-level modules)

시스템의 작은 부분을 담당하여 구체적이고 상세한 작업을 처리한다. 주로 하위 컴포넌트나 작은 단위의 기능을 제공하며 다른 모듈과 상호작용 할 수 있는 인터페이스를 제공한다. 독립적으로 사용 가능하며, 고수준 모듈로 조합되어 더 큰 기능을 구현하기 위해 사용된다.

왜 직접적으로 의존하면 안 될까?

왜 직접적으로 다른 클래스에 의존하면 안 된다고 할까? 만약 하위 모듈의 인스턴스를 직접 가져가 사용하게 되면 하위 모듈의 구체적인 내용에 의존하게 되기 때문에, 하위 모듈에 변경 사항이 있을 경우 상위 모듈의 코드를 수정해야 하는 일이 빈번하게 발생할 수 있기 때문이다.

구체적인 구현에 의존하는 것보다 추상화된 인터페이스에 의존하는 것이 자주 변경되지 않기 때문에 더 안정적이다.

// 알림 기능
class EmailSender {
  send(message: string) {
    console.log(`📧 이메일 발송: ${message}`);
  }
}

// 주문 처리 컴포넌트
function OrderButton() {
  const handleOrder = () => {
    // ❌ 이메일에만 의존
    const emailSender = new EmailSender();
    emailSender.send("주문이 완료되었습니다!");
  };

  return <button onClick={handleOrder}>주문하기</button>;
}

다음과 같은 코드가 있을 때 어떤 문제가 발생할 수 있을까?

주문 처리가 완료 되었을 때 이메일을 발송하는데, 만약 이메일이 아닌 문자를 보내기로 기획이 변경되었다고 하면 이메일에서 SMS로 변경하기 위해 관련 코드들을 컴포넌트 내에서 전부 수정해야 한다.

컴포넌트가 구체적인 구현체인 EmailSender라는 구체적인 클래스에 강하게 결합되어 있어서, 해당 컴포넌트는 기획에 따라 자주 변경될 수 있는 컴포넌트가 되어 불안정해진 것이다.

// 1. 추상화된 인터페이스
interface NotificationSender {
  send(message: string): void;
}

// 2. 구현체
class EmailSender implements NotificationSender {
  send(message: string) {
    console.log(`📧 이메일: ${message}`);
  }
}

class SMSSender implements NotificationSender {
  send(message: string) {
    console.log(`📱 SMS: ${message}`);
  }
}

// 3. 컴포넌트
function OrderButton({ sender }: { sender: NotificationSender }) {
  const handleOrder = () => {
    sender.send("주문이 완료되었습니다!");
  };

  return <button onClick={handleOrder}>주문하기</button>;
}

// 4. 사용
function App() {
  const sender = new SMSSender();
  
  return <OrderButton sender={sender} />;
}

이는 의존성 역전 원칙에 따라 개선한 코드다. EmailSender, SMSSender라는 구체적인 클래스는 send라는 인터페이스를 각각 구현하여 클래스들이 모두 같은 인터페이스를 따르지만 각각의 역할에 맞게 이메일이나 SMS를 전송한다.

컴포넌트는 직접적인 구현체인 EmailSender에 의존하고 있지 않고, 외부에서 주입 받은NotificationSender라는 중간 단계의 추상화된 인터페이스에 의존하게 되기 때문에, 이제 컴포넌트는 EmailSenderSMSSender 중 어떤 구현체가 주입되었는지 관심사로 두지 않아도 send라는 메서드만 호출하면 된다.

따라서 이제 기획이 변경되어도 안정적인 컴포넌트로 개선이 되었다.

의존성 '역전'이란 이름

그렇다면 왜 의존성 '역전'이라는 이름이 붙었을까?

OrderButton(상위) -> EmailSender(하위)

원래는 상위 모듈이 하위 모듈에 의존하는 방향으로, 컴포넌트 OrderButtonEmailSender라는 구체적인 구현체를 직접적으로 알고 의존하고 사용해야 했다.

OrderButton(상위) -> NotificationSender(추상화) <- EmailSender(하위)

의존성 역전 원칙을 지켜 개선한 후에는 상위 모듈인 OrderButton에서 필요한 기능을 NotificationSender라는 인터페이스로 정의하고 하위 모듈이 그 인터페이스를 구현하도록 구조를 변경했다. 따라서 반대로 이제 하위 모듈인 EmailSender가 상위 모듈(OrderButton)이 정의한 요구사항인 NotificationSender 인터페이스에 의존하게 되는 의존성 역전 현상이 발생하게 된 것이다.

따라서 이렇게 의존성이 역전되었다하여 의존성 '역전' 원칙으로 부른다.

개방-폐쇄 원칙을 실현하는 메커니즘

의존성 역전 원칙은 개방-폐쇄 원칙을 실현하는 메커니즘이다. 개방-폐쇄 원칙은 확장에는 열려있고, 수정에는 닫혀있어야 한다. 즉, 새로운 기능 추가 시 기존 코드를 수정하지 않아야 하는데 구체적인 구현체에 직접적으로 의존하면 새로운 기능을 추가할 때마다 기존 코드를 수정하게 된다. 하지만, 위에서 확인했던 것처럼 추상화된 인터페이스에 의존하면 새로운 구현체를 추가할 때 기존 코드를 건드리지 않아도 된다.

OrderButtonEmailSender에 직접 의존한다면 SMS 알림을 추가할 때 OrderButton 코드를 수정해야 하는데 NotificationSender 인터페이스에 의존한다면, SMSSender와 같은 새로운 구현체를 얼마든지 추가할 수 있으면서도 OrderButton은 전혀 변경되지 않는다.

1개의 댓글

comment-user-thumbnail
2025년 6월 30일

세리님 좋은 글 감사합니다 :-)

처음에는 내용이 조금 어려웠는데, "상위 모듈에서 필요한 기능을 인터페이스로 정의하고, 하위 모듈이 그 인터페이스를 구현하도록 구조를 변경했다"는 문장을 보고 나니 이해가 됐습니다!

만약 아래와 같이 코드를 수정할 경우, OrderButton 컴포넌트가 SMSSender 모듈에 직접적으로 의존하지 않게 되는데, 이런 구조도 의존성 역전 원칙(DIP)을 지킨다고 볼 수 있는지 궁금합니다!

function App() {
  const sender = new SMSSender();

  const handleOrder = () => {
      sender.send();
  }
  
  return <OrderButton onOrder={handleOrder} />;
}
답글 달기