
의존성 역전 원칙, 정확히 말하면 DIP (Dependency Inversion Principle)는 이름부터 어렵게 느껴집니다. 그래서 개념은 아는 것 같은데, 실무 코드로 들어가면 다시 흐려지는 경우가 많습니다.
결론부터 말하면 DIP의 핵심은 인터페이스를 많이 만드는 데 있지 않습니다. 핵심 정책 코드가 세부 구현 변화에 덜 흔들리게 만드는 것이 더 본질에 가깝습니다.
이 글에서는 DIP를 교과서식 문장으로만 반복하지 않고, 의존 방향, 변경 비용, 구현 교체, 테스트 더블 관점에서 다시 풀어보겠습니다.
의존성 역전 원칙 핵심
교과서식 정의는 보통 이렇습니다. 높은 수준 모듈은 낮은 수준 모듈에 직접 의존하지 않고, 둘 다 추상화에 의존해야 하며, 추상화는 detail에 의존하지 않고 detail이 추상화에 의존해야 한다는 것입니다.
문장만 읽으면 그럴듯하지만 실무에서는 코드 그림이 먼저 떠올라야 이해가 됩니다. 실무적으로 번역하면, 주문 처리의 핵심 흐름이 DB 종류, 외부 결제사 SDK, 메일 발송 라이브러리 같은 기술 상세 때문에 계속 같이 바뀌면 곤란하다는 뜻입니다.
- high-level policy: 주문 검증 순서, 결제 성공 판단, 실패 시 보상 로직 같은 핵심 흐름
- detail: 특정 DB 구현, 외부 SDK, 메일/슬랙 알림 라이브러리 같은 기술 선택
- DIP의 목표: 둘이 같은 속도로 흔들리지 않게 경계를 세우는 것
즉, DIP는 추상화 자체를 숭배하라는 말이 아니라, 지키고 싶은 정책과 자주 흔들리는 detail을 분리하라는 말에 더 가깝습니다.
의존 방향
DIP가 어렵게 느껴지는 가장 큰 이유는 호출 방향과 의존 방향을 같은 것으로 보기 때문입니다. `OrderService`가 `PaymentGateway`를 호출한다고 해서, 설계 의존도 반드시 특정 SDK 클래스 쪽으로 내려가야 하는 것은 아닙니다.

`OrderService`가 정말 필요로 하는 것은 결제를 요청하고 결과를 받는 역할입니다. 특정 결제사의 응답 DTO, 클라이언트 초기화 규칙, 예외 타입까지 아는 순간 정책 코드가 detail에 묶이기 시작합니다.
- 나쁜 신호: `OrderService` 안에서 `new KakaoPayClient()`를 만든다
- 나쁜 신호: SDK 응답 모델을 정책 코드가 직접 해석한다
- 좋은 방향: 정책이 필요한 역할만 추상화하고, 구현체는 뒤에서 그 계약을 맞춘다
여기서 중요한 것은 추상화의 소유권입니다. 추상화가 외부 SDK 편의에 맞춰져 있으면 detail이 그대로 새어 들어옵니다. 반대로 정책 코드가 필요로 하는 언어로 계약을 만들면 구현체가 바뀌어도 핵심 흐름은 덜 흔들립니다.
변경 비용
DIP를 판단할 때 가장 현실적인 기준은 미학이 아니라 변경 비용입니다. 더 우아해 보이는 구조냐보다, 무언가 바뀔 때 어디까지 같이 수정해야 하느냐를 먼저 보는 편이 실무에 맞습니다.
예를 들어 주문 완료 흐름이 주문 검증, 결제 요청, 주문 저장, 완료 알림 발송으로 이어진다고 해보겠습니다. 결제사나 알림 채널이 바뀔 때마다 핵심 메서드 전체를 뜯어야 한다면 정책이 detail에 붙잡혀 있다는 뜻입니다.
- 결제사 교체: 구현체 추가와 조립 설정 변경으로 끝나는가
- 저장소 교체: 정책 메서드를 열지 않고도 바꿀 수 있는가
- 알림 방식 변경: 메일에서 슬랙으로 바뀌어도 핵심 흐름이 그대로인가
물론 계약 자체가 바뀌면 정책 코드도 수정됩니다. 하지만 적어도 기술 선택의 흔들림이 정책 코드를 습관적으로 오염시키는 상황은 크게 줄일 수 있습니다.
전후 코드
아래 예시는 DIP가 없는 쪽과 있는 쪽의 차이를 아주 단순하게 보여줍니다. 예시는 Java로 적었지만 요지는 언어보다 경계 설정에 있습니다.
나쁜 예: 정책이 구현에 묶인 경우
public class OrderService {
private final KakaoPayClient kakaoPayClient = new KakaoPayClient();
private final JdbcOrderRepository orderRepository = new JdbcOrderRepository();
private final SmtpEmailSender emailSender = new SmtpEmailSender();
public void place(Order order) {
PaymentResponse response = kakaoPayClient.pay(order.getOrderId(), order.getAmount());
if (!response.isSuccess()) {
throw new IllegalStateException("결제 실패");
}
orderRepository.save(order);
emailSender.send(order.getEmail(), "주문이 완료되었습니다.");
}
}이 구조는 처음에는 빠릅니다. 하지만 결제사 응답 모델, 저장소 구현, 알림 방식이 바뀔 때마다 `OrderService`가 같이 흔들리고, 테스트에서는 실제 외부 객체를 피하기도 어려워집니다.
개선 예: 정책이 역할에 의존하는 경우
public interface PaymentPort {
PaymentResult pay(String orderId, BigDecimal amount);
}
public interface OrderRepository {
void save(Order order);
}
public interface NotificationSender {
void sendOrderCompleted(String target);
}
public class OrderService {
private final PaymentPort paymentPort;
private final OrderRepository orderRepository;
private final NotificationSender notificationSender;
public OrderService(
PaymentPort paymentPort,
OrderRepository orderRepository,
NotificationSender notificationSender
) {
this.paymentPort = paymentPort;
this.orderRepository = orderRepository;
this.notificationSender = notificationSender;
}
public void place(Order order) {
PaymentResult result = paymentPort.pay(order.getOrderId(), order.getAmount());
if (!result.isSuccess()) {
throw new IllegalStateException("결제 실패");
}
orderRepository.save(order);
notificationSender.sendOrderCompleted(order.getEmail());
}
}이제 `OrderService`는 특정 결제사 SDK, JDBC 구현, SMTP 세부사항을 직접 알지 않습니다. 자신이 필요한 역할만 알고, detail은 뒤에서 그 계약을 구현합니다. 이 차이가 DIP를 체감하게 만드는 지점입니다.
인터페이스 오해
여기서 가장 흔한 오해가 나옵니다. 인터페이스를 만들었으니 DIP를 적용했다고 생각하는 경우입니다. 하지만 실제로는 그렇지 않은 경우가 훨씬 많습니다.
예를 들어 `UserService`와 `UserServiceImpl`이 있는데, 구현은 하나뿐이고 인터페이스가 메서드 시그니처를 그대로 복사한 수준이라면 결합도는 거의 줄지 않습니다. 독자는 파일을 한 번 더 열어야 하고, 실제 교체 가능성도 생기지 않습니다.
- 질문 1: 이 추상화는 누가 필요로 해서 생겼는가
- 질문 2: 정책 코드가 실제로 보호받는가
- 질문 3: 구현 교체나 테스트 대역 투입이 자연스러워졌는가
- 질문 4: detail의 용어가 계약에 그대로 새어 들어오지 않았는가
즉, 중요한 것은 인터페이스의 존재가 아니라 추상화의 기준입니다. 이 지점은 인터페이스는 왜 필요할까 글과도 자연스럽게 이어집니다.
테스트 더블
DIP를 설명할 때 테스트 이야기가 자주 함께 나오는 이유는, 정책 코드를 검증할 때 외부 세부사항을 치워내기 쉬워지기 때문입니다. Martin Fowler가 정리한 것처럼 테스트 더블은 dummy, fake, stub, spy, mock 같은 대체물을 묶는 일반 용어입니다.
실무적으로 중요한 질문은 하나입니다. 우리가 검증하고 싶은 것이 정책인지, 외부 시스템 연동 자체인지를 구분할 수 있느냐입니다. 주문 정책을 검증하는 테스트라면 진짜 결제사 API를 부를 이유가 없습니다.
class FakePaymentPort implements PaymentPort {
private final boolean success;
FakePaymentPort(boolean success) {
this.success = success;
}
@Override
public PaymentResult pay(String orderId, BigDecimal amount) {
return success ? PaymentResult.success() : PaymentResult.failure();
}
}이렇게 하면 테스트는 외부 네트워크나 SDK 초기화보다, 결제 실패 시 저장하지 않는지 같은 정책 검증에 집중할 수 있습니다. 다만 순서를 거꾸로 보면 안 됩니다. DIP의 목적이 테스트를 위해 인터페이스를 무조건 늘리자는 뜻은 아니고, 정책을 detail에서 떼어냈더니 그 결과로 테스트 더블도 쉬워진 것입니다.
DI와 DIP
DIP와 DI는 자주 같이 나오지만 같은 말은 아닙니다. DIP는 왜 그렇게 의존을 설계해야 하는지에 대한 원칙이고, DI는 그 설계를 실제 객체 연결로 구현하는 한 가지 방법입니다.
프레임워크의 DI 컨테이너를 쓴다고 자동으로 DIP가 달성되는 것은 아닙니다. 컨테이너로 객체를 주입해도, 추상화가 외부 SDK 용어에 꽉 묶여 있거나 정책 코드가 detail 규칙을 그대로 안고 있으면 설계 품질은 크게 나아지지 않습니다.
반대로 작은 프로그램에서는 컨테이너 없이 생성자 주입만으로도 DIP의 이점을 꽤 얻을 수 있습니다. Martin Fowler가 설명한 Dependency Injection의 핵심도 결국 객체가 직접 구현을 만들지 않고 바깥의 조립자가 관계를 연결하는 데 있습니다.
적용 경계
그렇다면 DIP는 어디에 쓰는 게 좋을까요. 실무에서는 보통 경계가 있는 곳에서 효과가 큽니다. 저장소, 외부 API 클라이언트, 결제, 인증, 메시징, 파일 시스템, 프레임워크 의존성이 강한 어댑터 레이어가 대표적입니다.
- DIP가 잘 먹히는 곳: 구현 교체 가능성이 있고, 기술 변화가 정책을 자주 흔드는 경계
- 과할 수 있는 곳: 구현이 하나뿐이고 바뀔 이유가 희박한 내부 계산 로직
- 실무 기준: 변경 축이 뚜렷한가, 그 변화가 정책까지 전파될 때 비용이 큰가
반대로 단순하고 안정적인 내부 계산 로직까지 무조건 추상화하면 얻는 것보다 잃는 것이 커질 수 있습니다. 이럴 때는 concrete class를 그대로 쓰는 편이 더 읽기 쉽고 정직합니다. 추상 클래스와 인터페이스 차이 글도 함께 보면 추상화 도구를 고르는 감각을 보완할 수 있습니다.
판단 체크
- 이 구현 세부사항이 앞으로 바뀔 가능성이 실제로 있는가
- 바뀐다면 핵심 정책 코드까지 같이 열어야 하는가
- 지금 추상화가 정책이 필요한 언어로 정의되어 있는가
- 테스트에서 대역을 넣는 일이 자연스러워지는가
- 인터페이스가 detail의 복사본에 그치지 않는가
이 질문에 여러 개가 예라면 DIP를 검토할 이유가 있습니다. 반대로 거의 모두가 아니오라면, 아직은 추상화보다 단순함이 더 중요할 수 있습니다.
마무리
DIP가 어렵게 느껴지는 이유는 원칙이 과해서라기보다, 자꾸 형식으로 먼저 배우기 때문입니다. 인터페이스를 만들고, 컨테이너를 붙이고, 구현을 주입하는 순서로만 보면 복잡한 의식처럼 보입니다.
하지만 실무 기준으로 다시 보면 질문은 더 단순합니다. 내 핵심 정책 코드는 무엇에 흔들리면 안 되는가. 이 질문으로 경계를 잡기 시작하면 DIP는 추상적인 교훈이 아니라 현실적인 설계 기준이 됩니다.
외부 참고로는 Martin Fowler의 Dependency Injection 정리, Microsoft Learn의 DI 개요, Test Double 정리를 함께 보면 DIP와 DI, 테스트 대역의 관계를 더 선명하게 이해할 수 있습니다.