ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 왜 우리는 Service를 new 하지 않을까? — DI의 진짜 이유( DIP를 했다고 착각하기 쉬운 지점 알아보기 )
    시스템설계 2026. 2. 25. 09:34
    728x90
    반응형

    💡 왜 우리는 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
    반응형
Designed by Tistory.