-
왜 우리는 Service를 new 하지 않을까? — DI의 진짜 이유( DIP를 했다고 착각하기 쉬운 지점 알아보기 )시스템설계 2026. 2. 25. 09:34728x90반응형
💡 왜 우리는 Service를 new 하지 않을까? — DI의 진짜 이유 ? 그리고 “DIP를 했다고 착각하기 쉬운 지점(패키지/모듈 의존성)”까지 한 번에 정리
“구현을 직접 주입하는 것” vs “인터페이스 + 외부 주입” 완전 쉽게 이해하기
스프링을 공부하다 보면 항상 나오는 말이 있다.
“구현체에 직접 의존하지 말고, 인터페이스에 의존하라.”
그런데 막상 보면 이런 생각이 든다.
- 🤔 TossPaymentClient 고치면 되는 거 아니야?
- 🤔 왜 OrderService까지 수정해야 한다는 거지?
- 🤔 mock으로 바꾸기 어렵다는 건 무슨 의미야?
이 글에서는 아주 단순한 코드 예시로 이 차이를 정리해본다.
1️⃣ 직접 구현을 박아버린 경우 (new로 연결)
class TossPaymentClient { public void pay(int amount) { System.out.println("Toss 결제: " + amount); } } class OrderService { private final TossPaymentClient client = new TossPaymentClient(); // 여기서 확정 public void order(int amount) { client.pay(amount); } }✔ 이 구조의 특징
- OrderService가 TossPaymentClient에 직접 의존
- 어떤 결제 구현을 쓸지 OrderService가 직접 결정
- 구현 교체 시 OrderService 코드 수정 필요
"TossPaymentClient 수정하면 되는 거 아니야?"
맞다. 하지만 그건 동작 수정일 때만 해당된다.
예:
- 버그 수정
- API 파라미터 변경
- 로그 추가
이건 TossPaymentClient 내부만 고치면 된다.
🚨 하지만 “구현을 바꾸는 상황”은 다르다
예를 들어:
- Toss → KakaoPay로 변경
- 개발환경에서는 StubPaymentClient 사용
- 장애 대비용 Fallback 구현 추가
이 경우는 결제 방식 자체를 교체하는 것이다.
class KakaoPayClient { public void pay(int amount) { System.out.println("KakaoPay 결제: " + amount); } }이제 OrderService를 이렇게 수정해야 한다:
class OrderService { private final KakaoPayClient client = new KakaoPayClient(); // 코드 수정 발생 }👉 구현 선택이 OrderService 내부에 하드코딩돼 있기 때문
2️⃣ 인터페이스 + 외부에서 주입 (DI 방식)
interface PaymentClient { void pay(int amount); } class TossPaymentClient implements PaymentClient { public void pay(int amount) { System.out.println("Toss 결제: " + amount); } } class OrderService { private final PaymentClient client; public OrderService(PaymentClient client) { // 외부에서 주입 this.client = client; } public void order(int amount) { client.pay(amount); } }
사용할 때:
OrderService service = new OrderService(new TossPaymentClient()); service.order(1000);KakaoPay로 바꾸고 싶으면?
OrderService service = new OrderService(new KakaoPayClient());✅ OrderService는 전혀 수정하지 않음
3️⃣ “mock으로 바꾸기 어렵다”는 의미
❌ 직접 new 박아둔 경우
class OrderService { private final TossPaymentClient client = new TossPaymentClient(); }테스트에서 가짜 결제 객체를 쓰고 싶어도:
- 이미 내부에서 new로 생성됨
- 테스트가 끼어들 자리가 없음
- 리플렉션/PowerMockito 같은 복잡한 도구 필요
👉 가능은 하지만 지저분하고 번거롭다
✅ DI 구조에서는 그냥 넣으면 끝
class FakePaymentClient implements PaymentClient { public int called = 0; public void pay(int amount) { called++; } }테스트 코드:
FakePaymentClient fake = new FakePaymentClient(); OrderService service = new OrderService(fake); service.order(1000); assert fake.called == 1;👉 테스트가 매우 단순해짐
👉 실제 외부 API 호출도 안 나감
2. “DIP를 했다고 착각하기 쉬운 지점(패키지/모듈 의존성)”까지 한 번에 정리
많은 사람이 이렇게 결론낸다.
✅ “아, 인터페이스 만들고 구현체 만들고 주입하면 DIP 끝이네!”
그런데 실제로는 여기서 딱 한 번 더 생각해야 한다.
- 인터페이스를 어느 패키지(모듈)에 두었는가?
- 결과적으로 의존성 화살표가 어디로 향하는가?
이걸 놓치면 겉으로는 DI를 했는데 DIP는 깨진 상태가 된다.
0️⃣ 먼저 결론: DI ≠ DIP, “인터페이스만 만들었다” ≠ “DIP를 지켰다”
- DI(주입): 객체를 밖에서 넣어주는 방식(테크닉)
- DIP(원칙): 시스템 중심(도메인 정책)이 기술(인프라)에 휘둘리지 않게 “의존성 방향”을 설계하는 원칙
즉,
DI는 DIP를 구현하는 도구일 수 있지만,
DI만 했다고 DIP가 자동으로 지켜지진 않는다.1️⃣ (많이 하는 오해) “infra에 인터페이스 두고 주입하면 DIP 아닌가요?”
처음에 대부분 이렇게 설계한다.
❌ 잘못된 DIP(처럼 보이는) 구조: 인터페이스가 infra에 있음
infra에 Port(인터페이스)
// infra/PaymentPort.java package infra; public interface PaymentPort { void pay(int amount); }infra에 구현체도 있음
// infra/TossPaymentAdapter.java package infra; public class TossPaymentAdapter implements PaymentPort { @Override public void pay(int amount) { System.out.println("Toss 결제: " + amount); } }domain이 그 인터페이스를 import 해서 사용
// domain/OrderService.java package domain; import infra.PaymentPort; // 🚨 domain -> infra 의존 발생 public class OrderService { private final PaymentPort payment; public OrderService(PaymentPort payment) { this.payment = payment; } public void order(int amount) { payment.pay(amount); } }겉으로는 분명히
- 인터페이스 있음 ✅
- 구현체 분리됨 ✅
- 주입도 함 ✅
그래서 “DIP 지켰다”고 착각하기 쉬워.
하지만 의존성 화살표를 보면 이렇게 된다.
domain -----> infra✅ 고수준(domain)이 저수준(infra)에 의존하고 있다.
이건 DIP의 핵심 문장과 충돌한다.High-level modules should not depend on low-level modules.
2️⃣ “패키지만 옮긴 것 같은데 왜 이게 그렇게 중요해요?”
맞아. 겉으로 보면 패키지 옮긴 것처럼 보인다.
하지만 DIP에서 중요한 건 파일 위치가 아니라:
누가 누구를 import 하느냐(컴파일 의존성 방향)
이 한 줄이 진짜 차이를 만든다.
- domain이 infra.PaymentPort를 import하면
→ domain은 infra 없이는 컴파일이 안 됨 - 즉 domain은 독립적인 “비즈니스 중심”이 아니라
→ infra를 끌고 다니는 모듈이 됨
✅ 제대로 된 DIP: 인터페이스(Port)는 “도메인(정책)”에 둔다
DIP를 제대로 지키려면 보통 이렇게 한다.
✅ 올바른 구조: Port(추상)는 domain에, 구현(디테일)은 infra에
domain에 Port(인터페이스)
// domain/PaymentPort.java package domain; public interface PaymentPort { void pay(int amount); }domain은 Port만 알고 사용
// domain/OrderService.java package domain; public class OrderService { private final PaymentPort payment; public OrderService(PaymentPort payment) { this.payment = payment; } public void order(int amount) { payment.pay(amount); } }infra가 domain의 Port를 구현 (디테일이 추상을 따라옴)
// infra/TossPaymentAdapter.java package infra; import domain.PaymentPort; // ✅ infra -> domain 의존 public class TossPaymentAdapter implements PaymentPort { @Override public void pay(int amount) { System.out.println("Toss 결제: " + amount); } }infra -----> domain이게 “역전(Inversion)”이다.
- 원래는 보통 domain이 infra를 끌고 가는데
- DIP를 적용하면 infra가 domain이 정한 계약(Port)을 따라온다
4️⃣ 왜 Port는 domain에 있어야 할까? (초보자용 핵심 이유 3개)
(1) Port는 “인프라를 위한 인터페이스”가 아니라 “도메인이 원하는 요구사항(계약)”이기 때문
PaymentPort는 사실 “토스/카카오페이의 인터페이스”가 아니라
도메인이 “결제라는 능력이 필요해”라고 선언한 계약이다.
즉 주인이 도메인이다.
주인이 도메인인데 infra에 두면 관점이 뒤집힌다.
(2) domain이 infra를 import하는 순간, 도메인이 기술에 휘둘리기 시작한다
처음엔 PaymentPort 하나만 import하니까 괜찮아 보인다.
근데 시간이 지나면 이런 일이 생긴다.
- domain이 infra에 있는 예외 타입을 쓰기 시작함
- domain이 infra에 있는 DTO/클라이언트 타입을 알게 됨
- “편하니까” 점점 더 가져다 쓰게 됨
결국 도메인이 인프라에 오염된다.
(3) “domain 단독 빌드/테스트/재사용”이 가능해진다
- Port가 domain에 있으면 domain은 infra 없이도 컴파일/테스트 가능
- infra는 교체 가능한 부품이 된다
이게 Clean Architecture / Hexagonal Architecture가 말하는 핵심이기도 하다.
3. 진짜 중요한 건 이것이다 (Spring 관점)
많은 사람들이 간과하는 부분이 있다.
Spring에서 Service를 new로 만들면
스프링 컨테이너가 관리하지 않는 객체가 된다.이게 왜 중요할까?
🔥 @Transactional이 동작하지 않을 수 있다
@Service public class OrderService { @Transactional public void order() { // DB 작업 } }스프링은 이 클래스에 프록시 객체를 생성해서 트랜잭션을 붙인다.
하지만 컨트롤러에서 이렇게 하면:
private final OrderService orderService = new OrderService();이 객체는:
- 스프링 빈이 아님
- 프록시가 아님
- AOP가 적용되지 않음
- @Transactional이 동작하지 않을 수 있음
즉,
DI는 단순히 “코드 예쁘게 만들기”가 아니라
Spring 기능을 제대로 사용하기 위한 전제 조건이다.728x90반응형'시스템설계' 카테고리의 다른 글
실무에서 Service → Manager → Processor 구조로 설계하는 이유 (0) 2026.02.05