TL;DR: DIP는 코드를 위한 원칙이 아니라 팀의 속도를 위한 전략이다.
레거시 코드베이스에서 가장 흔하게 마주하는 문제는 "동작하지 않는 코드"가 아니다. 코드는 정상적으로 동작한다. 진짜 문제는 그 이후에 드러난다. 기능 하나를 수정하면 예측하지 못한 지점에서 사이드이펙트가 발생하고, 새로운 요구사항이 들어올 때마다 확장이 아닌 리팩터링부터 논의해야 하며, 같은 Service 파일을 여러 명이 동시에 건드리면서 Git 충돌과 코드 리뷰 비용이 선형적으로 증가한다. 결국 팀의 배포 속도는 코드가 늘어날수록 느려진다.
이 현상의 근본 원인은 대부분 동일하다. 비즈니스 규칙(정책)과 기술적 구현(DB, 외부 API, 프레임워크)이 하나의 클래스 안에서 강하게 결합되어 있기 때문이다. 결제 로직을 수정하려 했을 뿐인데 JPA 엔티티 매핑까지 신경 써야 하고, 알림 채널을 추가하려 했을 뿐인데 Service 전체의 테스트가 깨진다.
DIP(Dependency Inversion Principle)는 이 문제를 "코드 품질"이나 "설계의 깔끔함" 차원이 아니라, 팀이 동시에 작업하고, 변경을 안전하게 배포하고, 확장 비용을 최소화하는 협업 전략의 관점에서 해결한다. 이 글에서는 주문 도메인의 실제 코드를 기반으로, DIP 적용 전후의 구조 변화와 그것이 팀 생산성에 미치는 구체적인 차이를 다룬다.
- “협업 도구”로서의 DIP
- "분리": 정책(Policy) vs 구현(Detail)
- "확장": 결제 수단 추가가 “기능 추가”로 끝나게 만들기
- "테스트": Fake Adapter로 인프라 없이 검증하기
- 현업에서 자주 발생하는 반례와 실수
1) “협업 도구”로서의 DIP
DIP의 핵심은 변경이 잦은 정책(비즈니스 규칙·유스케이스)이, 변경이 잦은 구현 세부사항(DB·외부 API·프레임워크)에 끌려다니지 않도록 의존 방향을 역전시키는 것이다.
대부분의 레거시 코드에서 의존 방향은 "정책 → 구현"으로 흐른다. Service가 JpaRepository를 직접 참조하고, 외부 결제 클라이언트를 직접 호출하며, 알림 모듈을 직접 의존한다. 이 구조에서는 구현 세부사항이 변경될 때마다 정책 코드가 함께 수정되어야 한다. DIP는 이 방향을 뒤집어, 구현이 정책이 정의한 추상화(Port)에 의존하게 만든다.
이것을 "설계 원칙"이 아닌 "협업 전략"으로 읽으면, 세 가지 구조적 변화가 선명하게 드러난다.
첫째, 분업의 단위가 명확해진다. Port(인터페이스)가 팀 간 계약 역할을 수행한다. 도메인·유스케이스를 담당하는 팀은 Port의 시그니처만 보고 비즈니스 흐름을 개발하며, 인프라·외부 연동을 담당하는 팀은 해당 Port의 Adapter만 구현하면 된다. 양쪽이 서로의 구현 세부사항을 알 필요가 없으므로, 병렬 개발이 구조적으로 가능해진다.
둘째, 코드 충돌이 구조적으로 감소한다. 레거시 구조에서는 하나의 거대한 Service 클래스에 영속성·결제·알림 로직이 모두 집중되어 있기 때문에, 서로 다른 기능을 수정하는 개발자들이 동일한 파일을 건드릴 수밖에 없다. DIP를 적용하면 각 관심사가 독립된 Adapter 파일로 분리되므로, Git 충돌 빈도와 코드 리뷰의 인지 부하가 동시에 줄어든다.
셋째, 확장 비용이 "수정"에서 "추가"로 전환된다. 레거시 구조에서 새로운 결제사나 알림 채널을 도입하려면 기존 Service의 if/else 분기를 수정하고, 관련 테스트를 전면 재작성해야 한다. DIP가 적용된 구조에서는 새로운 Adapter 파일 하나를 추가하는 것으로 확장이 완료된다. 기존 코드는 변경되지 않으므로 기존 기능의 안정성이 보장되며, OCP까지 자연스럽게 달성된다.
2) "분리": 정책(Policy) vs 구현(Detail)
DIP를 적용하려면, 먼저 코드베이스 안에서 "정책"과 "구현"을 구분할 수 있어야 한다. 이 구분이 모호한 상태에서 Port/Adapter를 도입하면, 의미 없는 인터페이스만 늘어나고 실질적인 구조 개선은 이루어지지 않는다.
정책(Policy)은 시스템이 "무엇을 하는가"에 해당하는 영역이다. 주문을 생성한다, 재고를 차감한다, 결제를 시도한다, 실패하면 롤백한다 — 이러한 유스케이스의 흐름과 비즈니스 규칙이 정책에 속한다. 정책은 비즈니스 요구사항이 변경될 때 함께 변경되며, 시스템의 존재 이유 그 자체이다.
구현(Detail)은 정책을 실현하기 위한 기술적 수단이다. JPA, Redis, Toss/Nice 결제 API, 메시지 큐, Slack/Email 알림, Spring 어노테이션 등이 여기에 해당한다. 구현은 기술 스택 교체, 외부 서비스 변경, 인프라 마이그레이션 등 정책과는 무관한 이유로 변경된다.
문제는 이 둘의 변경 원인이 서로 다름에도, 레거시 코드에서는 하나의 클래스 안에 결합되어 있다는 점이다. Service가 JpaRepository를 직접 참조하고 외부 API 클라이언트를 직접 호출하는 구조에서는, 기술적 세부사항이 바뀔 때마다 비즈니스 로직이 담긴 코드까지 함께 수정해야 한다. 변경 원인이 다른 코드가 같은 파일에 존재하므로, 한쪽의 변경이 다른 쪽의 안정성을 위협하는 구조가 된다.
DIP는 이 문제를 추상화(Port)의 소유권으로 해결한다. Port를 정책(Application) 쪽에서 정의하고 소유하게 만들며, 구현(Detail)은 그 Port를 구현하는 Adapter로 분리한다. 이렇게 하면 정책은 자신이 정의한 추상화에만 의존하고, 구현 세부사항은 그 추상화를 따르는 방향으로 의존이 역전된다. 결과적으로 정책 코드는 DB가 JPA에서 MyBatis로 바뀌든, 결제사가 Toss에서 Kakao로 교체되든 영향을 받지 않는다.
DIP 적용 전의 전형적인 구조를 주문 도메인으로 살펴보자.
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping("/orders")
public ResponseEntity<OrderResponse> place(@RequestBody PlaceOrderRequest req) {
Long orderId = orderService.placeOrder(req.getUserId(), req.getItems(), req.getPaymentType());
return ResponseEntity.ok(new OrderResponse(orderId));
}
}
Controller는 단순하다. 요청을 받아 OrderService의 placeOrder 메서드를 호출하고, 결과를 응답으로 반환하는 것이 전부이다. 문제는 그 안쪽, OrderService에 집중되어 있다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final MemberJpaRepository memberRepo;
private final ProductJpaRepository productRepo;
private final OrderJpaRepository orderRepo;
private final TossPaymentClient tossPaymentClient;
private final NicePaymentClient nicePaymentClient;
private final NotificationService notificationService;
@Transactional
public Long placeOrder(Long userId, List<OrderItemDto> items, String paymentType) {
Member member = memberRepo.findById(userId).orElseThrow();
List<Product> products = productRepo.findAllById(
items.stream().map(OrderItemDto::productId).toList()
);
// 가격 계산 + 재고 차감 + 검증이 여기저기 섞임
int total = 0;
for (OrderItemDto item : items) {
Product p = products.stream().filter(x -> x.getId().equals(item.productId())).findFirst().orElseThrow();
if (p.getStock() < item.qty()) throw new IllegalStateException("재고 부족");
p.decreaseStock(item.qty());
total += p.getPrice() * item.qty();
}
// 결제사 분기(확장할수록 if/else 증가)
boolean paid;
if ("TOSS".equals(paymentType)) {
paid = tossPaymentClient.pay(member.getId(), total);
} else if ("NICE".equals(paymentType)) {
paid = nicePaymentClient.pay(member.getId(), total);
} else {
throw new IllegalArgumentException("지원하지 않는 결제");
}
if (!paid) throw new IllegalStateException("결제 실패");
Order order = new Order(member.getId(), total);
orderRepo.save(order);
notificationService.sendOrderPlaced(member.getEmail(), order.getId());
return order.getId();
}
}
이 Service 클래스 하나가 담당하는 범위를 보면 문제의 본질이 드러난다. 회원 조회를 위한 MemberJpaRepository, 상품 조회를 위한 ProductJpaRepository, 주문 저장을 위한 OrderJpaRepository — 영속성 계층에 대한 의존이 세 개이다. 여기에 TossPaymentClient와 NicePaymentClient라는 외부 결제 클라이언트 두 개가 직접 주입되어 있고, 알림 발송을 위한 NotificationService까지 포함되어 있다. 하나의 클래스가 영속성, 외부 결제, 알림이라는 세 가지 완전히 다른 관심사를 동시에 직접 의존하고 있는 구조이다.
메서드 내부의 흐름도 마찬가지이다. 회원 조회, 상품 조회, 재고 검증, 가격 계산, 재고 차감이 하나의 메서드 안에서 순차적으로 섞여 있다. 결제 처리 부분에서는 paymentType이라는 문자열을 기준으로 if/else 분기가 발생한다. Toss면 TossPaymentClient를 호출하고, Nice면 NicePaymentClient를 호출하며, 그 외에는 예외를 던진다. 결제가 완료되면 주문을 저장하고, 마지막으로 알림을 발송한다. 비즈니스 규칙, 인프라 호출, 외부 API 연동, 알림 발송이 단일 메서드 안에서 분리 없이 수행되는 구조이다.
이 구조가 협업에서 구체적으로 어떤 비용을 발생시키는지 세 가지로 정리할 수 있다.
첫째, 변경 파급 범위가 과도하게 넓다. DB 스키마가 변경되어도, 외부 결제 API의 인터페이스가 바뀌어도, 알림 채널이 추가되어도 — 수정해야 하는 파일은 항상 동일한 OrderService이다. 변경 원인이 완전히 다른 세 가지 관심사가 하나의 클래스에 결합되어 있기 때문에, 어떤 변경이든 이 파일을 거치게 된다. 이는 곧 해당 파일을 담당하는 모든 개발자의 작업이 서로 충돌할 가능성이 높다는 의미이다.
둘째, 확장 비용이 선형적으로 증가한다. 새로운 결제사를 도입하는 상황을 생각해보면, 이 구조에서는 결제 클라이언트 필드를 추가하고, if/else 분기를 하나 더 늘리고, 기존 테스트를 전면적으로 수정해야 한다. 결제사가 3개, 5개, 10개로 늘어날수록 분기의 복잡도와 테스트 유지보수 비용은 함께 증가한다. 확장이 "Adapter 추가"가 아니라 "기존 Service 수정 + 테스트 대공사"가 되는 구조이다.
셋째, 단위 테스트가 사실상 불가능하다. 이 Service를 테스트하려면 JpaRepository 세 개, 외부 결제 클라이언트 두 개, 알림 서비스 하나를 모두 목(Mock)으로 구성하거나 실제 인프라에 연결해야 한다. 테스트가 DB 상태와 외부 API의 가용성에 물리적으로 결합되어 있으므로, 실행 속도는 느리고 결과는 불안정하다. 외부 결제 서버가 점검 중이면 관련 없는 비즈니스 로직의 테스트까지 실패하는 상황이 발생한다.
결국 이 구조의 근본적인 문제는, 정책(비즈니스 흐름)이 구현(JPA, Toss, Nice, Email)에 직접 의존하고 있다는 점이다. 정책이 구현을 알고 있기 때문에, 구현이 변경될 때마다 정책이 함께 흔들린다. DIP는 바로 이 의존 방향을 역전시켜, 정책이 자신이 정의한 추상화에만 의존하도록 만드는 것이다.
DIP 적용: UseCase + Port/Adapter로 책임 분리
order/
presentation/
OrderController
dto/
application/
port/in/
PlaceOrderUseCase
port/out/
LoadMemberPort
LoadProductsPort
SaveOrderPort
PaymentPort
NotifyPort
service/
PlaceOrderService
domain/
Order
OrderItem
Money
infrastructure/
persistence/
MemberJpaAdapter
ProductJpaAdapter
OrderJpaAdapter
payment/
TossPaymentAdapter
NicePaymentAdapter
notification/
EmailNotifyAdapter
DIP 적용 후의 핵심 구조는 단순하다. 유스케이스(정책)가 Port(추상화)에 의존하고, DB·외부 API는 Adapter(구현)로 분리되는 것이다. 이 구조 변경만으로 앞서 살펴본 세 가지 문제 — 변경 파급, 확장 비용, 테스트 불안정 — 가 구조적으로 해소된다.
최상위에는 네 개의 계층이 존재한다. presentation, application, domain, infrastructure이다. 레거시 구조에서 하나의 Service 클래스에 뒤섞여 있던 관심사들이, 각각의 계층으로 물리적으로 분리된 것이다.
presentation 계층에는 OrderController와 DTO가 위치한다. 이 계층의 책임은 HTTP 요청을 수신하고, 입력을 UseCase가 이해할 수 있는 Command 객체로 변환하여 전달하는 것까지이다. 비즈니스 흐름이나 트랜잭션 제어는 이 계층의 관심사가 아니다.
application 계층은 DIP 적용의 핵심이 집중되는 영역이다. 이 계층 안에는 두 종류의 Port가 정의되어 있다. 첫째는 In Port로, 외부(Controller 등)가 Application 계층에 진입하기 위한 계약이다. PlaceOrderUseCase 인터페이스가 이에 해당하며, "주문을 생성한다"는 유스케이스의 존재를 선언한다. 둘째는 Out Port로, Application 계층이 외부 세계(DB, 결제, 알림)와 소통하기 위한 계약이다. LoadMemberPort, LoadProductsPort, SaveOrderPort, PaymentPort, NotifyPort가 여기에 위치한다. 그리고 이 Port들을 조합하여 실제 유스케이스 흐름을 구현하는 PlaceOrderService가 service 패키지에 존재한다.
여기서 반드시 주목해야 할 점이 있다. Port는 Application(정책) 쪽에서 소유한다는 것이다. PaymentPort는 "결제를 요청한다"는 정책의 필요를 선언할 뿐, Toss인지 Nice인지 어떤 기술로 처리되는지는 전혀 언급하지 않는다. 이것이 JpaRepository를 그대로 Port로 사용하는 것과의 결정적인 차이이다. JpaRepository는 JPA라는 구현 기술이 이미 이름과 시그니처에 스며들어 있으므로, 이를 Port로 사용하면 정책이 다시 구현 세부사항에 종속된다.
domain 계층에는 Order, OrderItem, Money 같은 도메인 객체가 위치한다. 이 계층은 프레임워크 의존 없이 순수한 비즈니스 규칙과 불변식만을 표현한다. "주문 항목은 최소 1개 이상이어야 한다", "수량은 양수여야 한다"와 같은 규칙이 이 계층에 속한다. Spring 어노테이션이나 JPA 매핑이 이 계층에 침투하는 순간, 도메인의 테스트 용이성과 재사용성이 동시에 훼손된다.
infrastructure 계층은 Application이 정의한 Out Port를 실제로 구현하는 Adapter들이 위치하는 영역이다. persistence 패키지에는 MemberJpaAdapter, ProductJpaAdapter, OrderJpaAdapter가, payment 패키지에는 TossPaymentAdapter와 NicePaymentAdapter가, notification 패키지에는 EmailNotifyAdapter가 존재한다. 각 Adapter는 자신이 구현하는 Port의 계약을 충족시키는 것이 유일한 책임이다.
이 구조에서 의존 방향을 다시 확인해보면, DIP가 정확히 작동하고 있음을 알 수 있다. Controller는 PlaceOrderUseCase(In Port)에 의존하고, PlaceOrderService는 PaymentPort·NotifyPort 등 Out Port에 의존한다. TossPaymentAdapter·EmailNotifyAdapter 같은 infrastructure의 구현체들이 application의 Port를 바라보며 의존한다. 의존 방향이 항상 안쪽(정책)을 향하고 있으며, 정책은 바깥(구현)의 존재를 알지 못한다. 이것이 DIP가 의도하는 의존 역전의 구체적인 형태이다.
3) 확장: 결제 수단 추가가 "기능 추가"로 끝나기 만들기
DIP 적용의 실질적인 효과가 가장 선명하게 드러나는 순간은 확장 요구사항이 들어올 때이다. 새로운 결제사를 도입해야 하는 상황을 통해, 레거시 구조와 DIP 적용 구조의 차이를 비교해보자.
레거시 구조에서 결제사를 추가하는 과정은 다음과 같다. 먼저 새로운 결제 클라이언트를 Service에 필드로 추가한다. 그다음 placeOrder 메서드 내부의 if/else 분기에 새로운 조건을 하나 더 끼워 넣는다. 여기서 끝이 아니다. 기존 테스트 코드가 Service의 생성자에 직접 결합되어 있으므로, 새로 추가된 의존성을 반영하기 위해 기존 테스트를 전면적으로 수정해야 한다. 결제사가 3개, 5개, 10개로 늘어날수록 분기의 복잡도는 선형적으로 증가하고, 하나의 분기를 수정할 때 다른 분기에 영향을 주지 않는다는 보장이 없다. 확장이 곧 기존 코드의 수정을 의미하는 구조이다.
DIP가 적용된 구조에서는 접근 방식 자체가 달라진다. 핵심은 PaymentPort를 한 단계 더 분리하여 전략 패턴(Strategy Pattern)으로 발전시키는 것이다. PaymentGateway라는 인터페이스를 정의하고, 각 결제사 Adapter가 이를 구현하도록 한다. 이 인터페이스는 두 가지만 선언한다 — 자신이 어떤 결제 수단을 지원하는지(supports), 그리고 실제 결제를 수행하는 행위(pay)이다.
유스케이스(PlaceOrderService)는 "결제를 요청한다"는 행위만 알 뿐, 어떤 게이트웨이가 선택되는지는 관여하지 않는다. 결제 수단에 따라 적절한 게이트웨이를 선택하는 책임은 PaymentGatewayResolver와 같은 별도의 구성요소가 담당한다. Resolver는 등록된 PaymentGateway 목록 중에서 요청된 결제 수단을 지원하는 구현체를 찾아 반환하는 단순한 역할만 수행한다.
이 구조에서 새로운 결제사를 추가하는 과정은 극적으로 단순해진다. PaymentGateway를 구현하는 새로운 클래스 파일 하나를 작성하고, supports 메서드에서 자신이 지원하는 결제 수단을 반환하며, pay 메서드에서 해당 결제사의 API를 호출하면 된다. 기존 유스케이스 코드는 변경되지 않는다. 기존 Adapter 코드도 변경되지 않는다. 기존 테스트도 깨지지 않는다. 확장이 "기존 코드 수정"이 아닌 "새 파일 추가"로 완결되는 것이다.
이것이 단순히 코드 구조의 개선에 그치지 않는 이유가 있다. 레거시 구조에서 결제사 추가는 기존 Service를 수정하는 작업이므로, 해당 Service를 담당하는 다른 개발자의 작업과 충돌할 가능성이 존재한다. 반면 DIP + 전략 패턴 구조에서는 새 결제사를 담당하는 개발자가 독립된 파일에서 작업하므로, 다른 팀원의 코드에 영향을 주지 않고 병렬로 개발이 진행된다. OCP(Open-Closed Principle)가 자연스럽게 달성되는 동시에, 팀 단위의 병렬 작업이 구조적으로 보장되는 것이다.
4) 테스트: Fake Adapter로 인프라 없이 검증하기
DIP가 테스트에 미치는 영향은 구조적이다. Port가 인터페이스로 정의되어 있기 때문에, 테스트 시 실제 인프라를 연결할 필요 없이 람다 혹은 Fake 구현체로 즉시 대체할 수 있다.
위 테스트 코드의 구조를 살펴보면 이 차이가 명확하게 드러난다. LoadProductsPort는 상품 ID 목록을 받으면 고정된 스냅샷을 반환하는 람다이다. SaveOrderPort는 어떤 주문이 들어오든 99L이라는 ID를 반환한다. PaymentPort는 항상 성공 결과를 반환하고, NotifyPort는 아무 동작도 수행하지 않는다. 이 네 개의 Fake Adapter를 PlaceOrderService의 생성자에 주입하면, DB 커넥션도, 외부 결제 서버도, 알림 인프라도 없이 유스케이스의 비즈니스 흐름을 완전하게 검증할 수 있다.
레거시 구조에서의 테스트와 비교하면 차이가 극명하다. 레거시 구조에서 OrderService를 테스트하려면 JpaRepository 세 개, 외부 결제 클라이언트 두 개, 알림 서비스 하나를 모두 Mock으로 구성해야 한다. Mockito의 when/thenReturn 체이닝이 수십 줄에 걸쳐 나열되고, 테스트가 검증하려는 대상이 "비즈니스 흐름"인지 "Mock 설정의 정확성"인지 경계가 모호해진다. 외부 결제 서버의 응답 구조가 바뀌면 비즈니스 로직과 무관한 Mock 설정이 깨지고, DB 스키마가 변경되면 Repository Mock을 전면적으로 재구성해야 한다.
반면 DIP가 적용된 구조에서는 각 Port가 정책의 언어로 정의된 인터페이스이기 때문에, Fake 구현이 극도로 단순하다. 람다 한 줄로 원하는 동작을 정의할 수 있고, 테스트가 검증하는 대상이 오직 "유스케이스 흐름이 올바른가"로 한정된다. 인프라의 변경이 테스트를 깨뜨리지 않으므로 테스트의 안정성이 보장되며, 외부 의존이 없으므로 실행 속도는 밀리초 단위이다.
이것이 협업 관점에서 갖는 의미는 두 가지이다.
첫째, 병렬 개발이 테스트 수준에서도 가능해진다. 인프라 팀이 아직 Adapter 구현을 완료하지 않은 상태에서도, 유스케이스 팀은 Fake Adapter를 활용하여 자신이 작성한 비즈니스 흐름을 독립적으로 개발하고 검증할 수 있다. Port라는 계약만 합의되면, 양쪽은 서로의 완료 시점을 기다리지 않고 동시에 작업을 진행할 수 있다.
둘째, PR 리뷰의 품질과 속도가 동시에 올라간다. 테스트가 빠르게 실행되므로 CI 파이프라인에서의 피드백 루프가 짧아지고, 테스트 코드 자체가 Mock 설정의 복잡성 없이 유스케이스의 의도를 명확하게 드러내기 때문에 리뷰어가 비즈니스 로직의 정확성에 집중할 수 있다. 테스트가 "인프라가 올바르게 연결되었는가"를 검증하는 것이 아니라 "비즈니스 규칙이 올바르게 수행되는가"를 검증하게 되는 것이다.
5) 현업에서 자주 발생하는 반례와 실수
DIP의 개념을 이해하는 것과 실제 코드베이스에 올바르게 적용하는 것 사이에는 상당한 간극이 존재한다. 아래 여섯 가지는 현업에서 반복적으로 관찰되는 대표적인 실수 패턴이다.
첫째, "인터페이스만 만들면 DIP다"라는 착각이다. IOrderService를 정의하고 OrderServiceImpl이 이를 구현하는 구조를 만들어 놓고 DIP를 적용했다고 판단하는 경우가 빈번하다. 그러나 이것은 단순한 인터페이스 추출일 뿐, DIP가 아니다. 구현체가 하나뿐인 인터페이스는 대부분 의미 없는 간접 참조만 추가할 뿐, 의존 방향을 역전시키지 않는다. DIP의 핵심은 인터페이스의 존재 여부가 아니라, 정책이 구현 세부사항에 끌려다니지 않도록 경계(Port)를 어디에, 어떤 의도로 두느냐에 있다. Port는 정책의 필요에 의해 정의되어야 하며, 구현의 편의를 위해 추출된 인터페이스와는 본질적으로 다르다.
둘째, Port를 Infrastructure 계층이 소유하는 실수이다. Port는 반드시 Application(정책) 패키지에 위치해야 한다. 그런데 실제 프로젝트에서는 JpaRepository 인터페이스를 그대로 Port처럼 사용하는 경우가 적지 않다. 이 경우 Port의 시그니처에 JPA의 개념이 이미 스며들어 있으므로, 정책이 DB라는 구현 세부사항에 다시 종속된다. findById, findAllById 같은 메서드명은 JPA의 언어이지, 정책의 언어가 아니다. 정책은 "회원을 조회한다", "상품 목록을 가져온다"는 자신의 필요를 자신의 언어로 선언해야 하며, 그 선언이 곧 Port가 되어야 한다.
셋째, Domain 계층에 Spring이나 JPA 어노테이션이 침투하는 것이다. @Entity, @Column, @Id 같은 JPA 매핑 어노테이션이 도메인 객체에 직접 부착되는 순간, 해당 도메인은 JPA라는 프레임워크에 물리적으로 결합된다. 이렇게 되면 도메인 객체를 테스트하기 위해 JPA 컨텍스트를 띄워야 하고, 다른 모듈에서 재사용하려면 JPA 의존성을 함께 가져가야 하며, 영속성 전략을 변경할 때 도메인 코드까지 수정해야 한다. 테스트 용이성, 재사용성, 확장성이 동시에 훼손되는 것이다. Domain 계층은 어떤 프레임워크에도 의존하지 않는 순수한 Java로 유지하는 것이 원칙이다.
넷째, Application 계층이 모든 비즈니스 규칙을 if/else로 흡수해버리는 빈혈 도메인(Anemic Domain) 패턴이다. Application 계층의 역할은 오케스트레이션, 즉 "어떤 순서로 무엇을 호출한다"는 흐름을 제어하는 것이다. "주문 항목은 최소 1개 이상이어야 한다", "수량은 양수여야 한다"와 같은 비즈니스 규칙과 불변식은 Domain 객체가 스스로 보장해야 한다. 이 규칙들이 전부 UseCase 메서드 안에 if/else로 나열되어 있다면, Domain은 단순한 데이터 컨테이너로 전락하고 UseCase는 다시 레거시의 "거대한 Service"와 동일한 형태로 회귀한다. 흐름은 Application이, 규칙은 Domain이 담당한다는 책임 분리를 명확히 지켜야 한다.
다섯째, 과도한 추상화이다. DIP의 효과를 경험하고 나면, 모든 의존성을 Port로 감싸고 싶은 유혹이 생긴다. 그러나 변경 가능성이 낮은 것까지 Port로 추상화하면 오히려 코드 탐색 비용과 간접 참조의 복잡도만 증가한다. Port 도입의 기준은 명확해야 한다. 팀 간 협업 경계에 해당하거나, 교체 가능성이 실제로 높은 구현 세부사항 — DB, 외부 API, 알림 채널, 파일 저장소, 메시징 시스템 — 부터 시작하는 것이 실용적이다. 추상화는 필요한 시점에 필요한 만큼만 도입하는 것이 원칙이며, 미래의 불확실한 변경을 위해 현재의 복잡도를 높이는 것은 과잉 설계이다.
여섯째, Controller에서 트랜잭션이나 비즈니스 로직을 직접 처리하는 실수이다. Presentation 계층의 책임은 HTTP 요청 수신, 인증/인가 확인, 입력 DTO를 Command로 변환하여 UseCase에 전달하는 것까지이다. Controller 안에서 트랜잭션을 선언하거나 비즈니스 분기를 수행하면, 해당 로직은 UseCase를 통하지 않고 Presentation에 직접 결합되므로 테스트가 어려워지고 재사용이 불가능해진다. 비즈니스 흐름은 반드시 UseCase로 모아야 하며, 이것이 리뷰·테스트·확장 모든 측면에서 유리한 구조이다.
DIP의 본질적인 가치는 팀이 동시에 작업할 수 있는 구조를 만드는 것에 있다. Port가 정의되는 순간, 그것은 팀 간의 계약이 된다. 유스케이스 팀은 Port의 시그니처만 보고 비즈니스 흐름을 개발하며, 인프라 팀은 해당 Port를 구현하는 Adapter를 독립적으로 작업한다. 양쪽은 서로의 구현 세부사항을 알 필요가 없고, 서로의 완료 시점을 기다릴 필요도 없다. 변경이 발생했을 때 수정 범위는 해당 Adapter 하나로 한정되며, 기존 코드의 안정성은 구조적으로 보장된다. 새로운 결제사, 새로운 알림 채널, 새로운 저장소가 추가되더라도 기존 유스케이스는 변경되지 않는다.
정리하면, DIP가 팀에 제공하는 것은 세 가지이다. 동시 작업이 가능한 분업 구조, 변경을 안전하게 배포할 수 있는 격리 경계, 그리고 확장을 최소 비용으로 수행할 수 있는 플러그인 구조이다.
실무에서의 적용 전략도 명확하다. 처음부터 모든 의존성을 Port로 감쌀 필요는 없다. Port(계약)를 먼저 정의하고, Adapter는 필요한 시점에 붙이면서 확장하는 것이 가장 현실적인 접근이다. 팀 규모가 작을 때는 핵심 외부 의존성(영속성, 결제, 알림) 세 개의 Port만으로 시작하고, 팀이 성장하고 도메인이 복잡해지는 시점에 Resolver, 전략 패턴, 도메인 이벤트를 단계적으로 도입하면 된다. 중요한 것은 Port의 수가 아니라, 정책이 구현에 끌려다니지 않는 의존 방향이 확보되어 있느냐이다.
'Programming > Technical Writing' 카테고리의 다른 글
| 외부 결제 시스템(PG) 연동에서 Circuit Breaker가 필요한 이유 (0) | 2026.03.20 |
|---|---|
| 조회 병목 해소 과정: 쿼리, 인덱스, 비정규화, Redis (0) | 2026.03.13 |
| 동시성 제어: 트랜잭션 락은 언제 쓰는가 (0) | 2026.03.06 |
| 주니어는 모르는 ERD 설계의 함정들 (1) | 2026.02.13 |
| AI로 테스트 코드를 짜기 전에, 내가 먼저 정리한 것들 (0) | 2026.02.05 |