Skip to content
ioob.dev
Go back

객체지향 설계 원칙 3편 — DIP

· 9분 읽기
OOP & SOLID 시리즈 (3/5)
  1. 객체지향 설계 원칙 1편 — SRP와 OCP
  2. 객체지향 설계 원칙 2편 — LSP와 ISP
  3. 객체지향 설계 원칙 3편 — DIP
  4. 객체지향 설계 원칙 4편 — TDA, 디미터 법칙, CQS
  5. 객체지향 설계 원칙 5편 — Composition over Inheritance
Table of contents

Table of contents

DIP — 고수준이 저수준에 끌려다니면 안 된다

DIP(Dependency Inversion Principle)는 Robert C. Martin이 제안한 원칙으로, 두 가지 규칙으로 구성된다.

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

번역하면 이렇다.

  1. 고수준 모듈은 저수준 모듈에 의존하면 안 된다. 둘 다 추상화에 의존해야 한다.
  2. 추상화는 세부사항에 의존하면 안 된다. 세부사항이 추상화에 의존해야 한다.

고수준이니 저수준이니 하는 말이 추상적으로 느껴진다. 구체적으로 풀어보자.

고수준 모듈이 저수준 모듈에 직접 의존하면, 저수준의 변경이 고수준을 흔든다. 핵심 비즈니스 로직이 DB 종류나 메일 라이브러리에 끌려다니는 것이다. DIP는 이 의존 방향을 뒤집으라고 말한다.

의존성이 뒤집힌다는 것

뒤집기 전 — 자연스러운 의존 방향

코드를 처음 작성할 때 가장 자연스러운 구조는 이렇다.

public class OrderService {

    private final MySqlOrderRepository repository = new MySqlOrderRepository();

    public void createOrder(Order order) {
        // 비즈니스 로직
        order.validate();
        order.calculateTotal();

        // 저장
        repository.save(order);
    }
}

public class MySqlOrderRepository {

    public void save(Order order) {
        // MySQL에 INSERT
        String sql = "INSERT INTO orders ...";
        // JDBC 연결, 실행
    }
}

의존 방향을 그려보면 고수준(OrderService)이 저수준(MySqlOrderRepository)을 직접 참조한다.

flowchart TB
    subgraph before["의존성 역전 전"]
        direction TB
        OS["OrderService<br/>고수준 — 비즈니스 로직"] -->|"직접 의존"| MySQL["MySqlOrderRepository<br/>저수준 — MySQL 구현"]
    end
    style MySQL fill:#ff6b6b,color:#fff

이 구조의 문제는 명확하다. MySQL을 PostgreSQL로 바꾸려면 OrderService를 수정해야 한다. 테스트할 때 실제 MySQL이 돌아가야 한다. 비즈니스 로직이 인프라 기술에 종속되어 있다.

뒤집은 후 — 추상화에 의존

인터페이스를 하나 도입한다. 그리고 고수준 모듈이 이 인터페이스에 의존하게 만든다.

public interface OrderRepository {
    void save(Order order);
    Order findById(Long id);
}

고수준 모듈은 인터페이스에만 의존한다.

public class OrderService {

    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public void createOrder(Order order) {
        order.validate();
        order.calculateTotal();
        repository.save(order);
    }
}

저수준 모듈이 인터페이스를 구현한다.

public class MySqlOrderRepository implements OrderRepository {

    @Override
    public void save(Order order) {
        // MySQL에 INSERT
    }

    @Override
    public Order findById(Long id) {
        // MySQL에서 SELECT
        return null;
    }
}

의존 방향을 다시 그려보면 화살표가 달라졌다.

flowchart TB
    subgraph after["의존성 역전 후"]
        direction TB
        OS2["OrderService<br/>고수준 — 비즈니스 로직"] -->|"추상화에 의존"| ORI["OrderRepository<br/>인터페이스 — 추상화"]
        MySQLImpl["MySqlOrderRepository<br/>저수준 — MySQL 구현"] -->|"추상화를 구현"| ORI
    end
    style ORI fill:#339af0,color:#fff
    style OS2 fill:#51cf66,color:#fff
    style MySQLImpl fill:#ffd43b,color:#000

OrderServiceOrderRepository 인터페이스에 의존한다. MySqlOrderRepositoryOrderRepository 인터페이스에 의존한다(구현이라는 형태로). 둘 다 추상화에 의존하게 되었다. 저수준 모듈이 고수준 모듈을 향해 의존하는 방향으로 바뀌었기 때문에 역전(Inversion)이라 부른다.

역전의 핵심 — 인터페이스는 누가 소유하는가

여기서 중요한 포인트가 있다. OrderRepository 인터페이스는 어디에 위치해야 하는가?

직관적으로는 “구현체 옆에 두면 되지 않나?”라고 생각할 수 있다. MySQL 관련 코드와 같은 패키지에 두는 것이다. 하지만 DIP에서 인터페이스의 소유자는 사용하는 쪽, 즉 고수준 모듈이다.

com.example.order/
├── OrderService.java           ← 고수준 모듈
├── OrderRepository.java        ← 인터페이스 (고수준 패키지에 위치)
└── ...

com.example.order.infrastructure/
├── MySqlOrderRepository.java   ← 저수준 모듈 (인터페이스를 구현)
└── ...

인터페이스가 고수준 패키지에 있으면, 저수준 패키지가 고수준 패키지를 의존한다. 패키지 수준에서도 의존성이 역전된다. 이 구조가 DIP의 완전한 적용이다.

flowchart LR
    subgraph domain["com.example.order — 고수준"]
        OS3["OrderService"]
        ORI2["OrderRepository<br/>인터페이스"]
        OS3 --> ORI2
    end
    subgraph infra["com.example.order.infrastructure — 저수준"]
        MySQLImpl2["MySqlOrderRepository"]
    end
    MySQLImpl2 -->|"구현"| ORI2

DI와 DIP — 이름은 비슷하지만 다르다

Spring을 사용해 본 개발자라면 DI(Dependency Injection, 의존성 주입)가 떠오를 것이다. DI와 DIP는 이름이 비슷해서 자주 혼동되지만, 서로 다른 개념이다.

DI는 DIP를 구현하는 데 자주 쓰이는 도구이지만, DIP 없이도 DI를 사용할 수 있고, DI 없이도 DIP를 적용할 수 있다.

DI 없이 DIP 적용

팩토리 패턴으로 DIP를 적용하는 예다. 프레임워크 없이도 가능하다.

public class OrderRepositoryFactory {

    public static OrderRepository create() {
        String dbType = System.getProperty("db.type");
        if ("mysql".equals(dbType)) {
            return new MySqlOrderRepository();
        }
        return new PostgresOrderRepository();
    }
}

// 사용하는 쪽
OrderRepository repository = OrderRepositoryFactory.create();
OrderService service = new OrderService(repository);

OrderService는 여전히 OrderRepository 인터페이스에만 의존한다. DIP는 지켜지고 있다. DI 컨테이너가 없을 뿐이다.

DIP 없이 DI 적용

반대로 DI를 사용하면서도 DIP를 위반할 수 있다.

public class OrderService {

    private final MySqlOrderRepository repository;

    // 생성자 주입 — DI는 적용됨
    public OrderService(MySqlOrderRepository repository) {
        this.repository = repository;
    }
}

MySqlOrderRepository를 외부에서 주입받으니 DI는 적용된 셈이다. 하지만 인터페이스가 아닌 구체 클래스에 의존하고 있으므로 DIP는 위반이다. 주입의 형태가 아니라 의존의 방향이 DIP의 관건이다.

Spring에서의 DIP + DI

Spring Boot에서는 DIP와 DI가 자연스럽게 결합된다.

인터페이스를 선언한다.

interface OrderRepository {
    fun save(order: Order)
    fun findById(id: Long): Order?
}

구현체에 @Repository를 붙인다.

@Repository
class JpaOrderRepository(
    private val jpaRepository: OrderJpaRepository
) : OrderRepository {

    override fun save(order: Order) {
        jpaRepository.save(order.toEntity())
    }

    override fun findById(id: Long): Order? {
        return jpaRepository.findByIdOrNull(id)?.toDomain()
    }
}

서비스는 인터페이스에 의존한다. Spring이 구현체를 찾아서 주입해 준다.

@Service
class OrderService(
    private val orderRepository: OrderRepository  // 인터페이스에 의존
) {

    fun createOrder(order: Order) {
        order.validate()
        order.calculateTotal()
        orderRepository.save(order)
    }
}

DIP(인터페이스에 의존)와 DI(Spring이 구현체를 주입)가 함께 동작한다. Spring의 IoC 컨테이너(Inversion of Control, 제어의 역전)는 DIP를 편리하게 적용할 수 있게 해주는 인프라인 셈이다.

계층형 아키텍처에서 DIP

전통적인 3계층 아키텍처(Presentation → Business → Data Access)에서 의존 방향은 위에서 아래로 흐른다.

flowchart TB
    subgraph traditional["전통적 계층 구조 — 의존이 아래로"]
        Pres["Presentation Layer<br/>Controller"]
        Biz["Business Layer<br/>Service"]
        Data["Data Access Layer<br/>Repository"]
        Pres --> Biz
        Biz --> Data
    end

이 구조에서는 비즈니스 계층이 데이터 접근 계층에 의존한다. DB 기술이 바뀌면 비즈니스 로직에 영향이 갈 수 있다. DIP를 적용하면 비즈니스 계층이 인터페이스를 소유하고, 데이터 접근 계층이 이를 구현하는 형태가 된다.

flowchart TB
    subgraph dip_arch["DIP 적용 — 의존이 안쪽으로"]
        Pres2["Presentation Layer"]
        Biz2["Business Layer<br/>Service + Repository 인터페이스"]
        Data2["Data Access Layer<br/>Repository 구현체"]
        Pres2 --> Biz2
        Data2 -->|"구현"| Biz2
    end
    style Biz2 fill:#339af0,color:#fff

데이터 접근 계층의 화살표 방향이 바뀌었다. 비즈니스 계층을 향해 의존한다. 이제 비즈니스 계층은 어떤 DB를 쓰는지 모르고, 알 필요도 없다. MySQL을 MongoDB로 바꿔도 비즈니스 계층의 코드는 한 줄도 바뀌지 않는다.

이 원리를 더 밀고 나간 것이 헥사고날 아키텍처(Hexagonal Architecture, 포트와 어댑터 아키텍처)나 클린 아키텍처(Clean Architecture)다. 이 아키텍처들에서 DIP는 핵심 원칙으로 자리 잡고 있다. 비즈니스 로직이 중심에 있고, 외부 기술(DB, API, UI)이 비즈니스 로직에 맞추어 플러그인처럼 연결되는 구조다.

실무에서의 DIP — 어디까지 적용할 것인가

DIP의 이론을 알면 “모든 곳에 인터페이스를 만들어야 하나?”라는 의문이 든다. 정답은 아니다. 인터페이스를 만드는 데도 비용이 있다. 파일이 늘어나고, 추적해야 할 코드가 늘어나며, 단순한 구조가 복잡해질 수 있다.

DIP를 적용하기 좋은 지점은 다음과 같다.

  1. 외부 시스템과의 경계 — DB, 메시지 큐, 외부 API, 파일 시스템. 인프라 기술은 바뀔 가능성이 높고, 테스트에서 목(mock)으로 교체해야 하는 경우가 많다
  2. 핵심 비즈니스 로직 — 도메인 로직이 프레임워크나 라이브러리에 종속되면 안 된다
  3. 변경 가능성이 높은 정책 — 할인 정책, 알림 방식 등 비즈니스 요구에 따라 바뀌는 부분

반면 다음 경우에는 직접 의존해도 무방하다.

DIP 적용 판단 기준을 정리하면 이렇다

// 인터페이스가 필요한 경우 — 외부 시스템과의 경계
interface NotificationSender {
    fun send(userId: Long, message: String)
}

class EmailNotificationSender : NotificationSender { /* ... */ }
class SlackNotificationSender : NotificationSender { /* ... */ }

// 인터페이스가 불필요한 경우 — 변경 가능성 없는 내부 유틸리티
class DateFormatter {
    fun format(date: LocalDateTime): String =
        date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
}

NotificationSender는 이메일에서 슬랙으로, 카카오톡으로 바뀔 수 있다. 인터페이스로 분리할 가치가 충분하다. DateFormatter는 날짜를 포맷하는 게 전부다. 구현체가 두 개 될 일이 없다면 직접 의존해도 된다.

핵심 정리

개념설명
DIP고수준 모듈과 저수준 모듈 모두 추상화에 의존해야 한다
의존성 역전저수준이 고수준을 향해 의존하도록 방향을 뒤집는 것
인터페이스 소유인터페이스는 사용하는 쪽(고수준)이 소유한다
DI vs DIPDI는 주입 기법, DIP는 설계 원칙. 서로 독립적이지만 함께 쓰면 강력하다
적용 기준외부 시스템 경계, 핵심 도메인, 변경 가능성이 높은 정책

SOLID 다섯 원칙을 세 편에 걸쳐 살펴봤다. SRP는 변경의 이유를 하나로 제한하고, OCP는 확장에 열린 구조를 만들며, LSP는 다형성이 올바르게 동작하도록 보장하고, ISP는 인터페이스를 클라이언트 기준으로 분리하며, DIP는 의존의 방향을 추상화를 향해 뒤집는다.

이 원칙들은 개별적으로 적용하기보다 함께 어우러질 때 진가를 발휘한다. SRP로 책임을 나누고, OCP로 확장 포인트를 만들고, LSP로 다형성의 정확성을 지키고, ISP로 인터페이스를 정돈하고, DIP로 의존 방향을 잡는다. 결국 모두 같은 목표 — 변경에 강한 코드 — 를 향한다.

다음 편에서는 SOLID 바깥에서 객체 간 커뮤니케이션을 다루는 원칙들을 살펴본다. TDA(Tell, Don’t Ask), 디미터 법칙(Law of Demeter), CQS(Command-Query Separation)가 그 주인공이다.


4편: TDA, 디미터 법칙, CQS


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
객체지향 설계 원칙 2편 — LSP와 ISP
Next Post
객체지향 설계 원칙 4편 — TDA, 디미터 법칙, CQS