🎯1주차 Unit 3.5 — DIP (의존 역전 원칙)

Psj·2026년 5월 7일

F-lab

목록 보기
34/142

🎯 Unit 3.5 — DIP (의존 역전 원칙) ★★★

F-lab Java 1주차 / Phase 3 / Unit 3.5 본격 학습 자료 — Phase 3 마지막!
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.

선수 지식: Unit 3.4 (ISP), Unit 2.4 (다형성)
다음 Phase: Phase 4 — JVM 메모리 모델

이 Unit의 의미: SOLID의 정점. Spring DI의 진짜 의미.
Hexagonal Architecture, Clean Architecture, MSA — 모든 현대 아키텍처가 DIP 위에 있다.
DIP를 정확히 이해하면 5주차 Spring, 11-12주차 JPA, 17주차 MSA 가 자연스럽게 흡수된다.


🌍 1. 세상 속 비유

DIP = "전기 콘센트와 가전제품"

집에 콘센트가 있습니다. 이 콘센트에 다양한 가전을 꽂을 수 있어요:

  • 냉장고
  • 청소기
  • 노트북 충전기
  • 헤어드라이어

누가 누구에게 의존할까요?

상식적 사고 (의존의 자연스러운 방향):

  • "가전제품이 콘센트에 의존하는 것 같은데?"
  • 가전이 와서 콘센트에 꽂으니까

그러나 진짜 본질:

  • 가전제품도 표준 220V 플러그 규격 을 따름
  • 콘센트도 표준 220V 규격 을 따름
  • 둘 다 '표준' 이라는 추상화에 의존

만약 콘센트가 가전마다 다르다면?:

  • 냉장고용 콘센트
  • 청소기용 콘센트
  • → 집 벽이 콘센트로 가득

만약 표준 없이 직접 연결된다면?:

  • 가전을 바꿀 때마다 벽 공사
  • 집(상위)이 가전(하위)에 의존하는 셈

이게 DIP의 정신. 상위와 하위 모두 추상화 (표준) 에 의존 하면 자유롭게 교체 가능.


더 직관적인 비유 — "USB-C 표준"

USB-C 등장 전:

  • 안드로이드: 마이크로 USB
  • 아이폰: 라이트닝
  • 노트북: 다양한 충전 포트
  • → 각 기기마다 전용 충전기 필요

USB-C 등장 후:

  • 모든 기기가 USB-C 포트 표준 채택
  • 충전기 1개로 휴대폰, 노트북, 태블릿 모두 충전
  • 기기와 충전기 둘 다 USB-C 표준에 의존

DIP 관점:

  • 충전기 (상위) 도, 기기 (하위) 도
  • USB-C 표준 (추상화) 에 의존
  • → 기기 종류가 바뀌어도 충전기 그대로

"양쪽이 표준에 의존" = DIP 의 본질.


핵심 한 문장

"구체에 의존하지 말고 추상화에 의존하라."

DIP의 두 측면:

  • 상위 모듈은 하위 모듈에 의존하면 안 됨
  • 둘 다 추상화에 의존해야 함

비유 정리:

비유 요소DIP 적용
가전제품상위 모듈
콘센트하위 모듈
220V 표준추상화 (인터페이스)
가전이 콘센트에 직접 의존DIP 위반
둘 다 표준에 의존DIP 만족

🔥 2. 탄생 배경

Robert C. Martin (Uncle Bob) 의 정의

DIP (Dependency Inversion Principle) — Uncle Bob 이 1996년 제안.

원래 정의 (두 가지 진술):

"A. High-level modules should not depend on low-level modules. Both should depend on abstractions."
("상위 모듈은 하위 모듈에 의존하지 않아야 한다. 둘 다 추상화에 의존해야 한다.")

"B. Abstractions should not depend on details. Details should depend on abstractions."
("추상화는 세부사항에 의존하지 않아야 한다. 세부사항이 추상화에 의존해야 한다.")

핵심 키워드: "역전(Inversion)" — 의존 방향을 뒤집는다.


등장 배경 — "전통적 의존 방향의 함정"

전통적인 프로그래밍에서 자연스러운 의존 방향:

[고수준 정책] (비즈니스 로직)
        ↓ 의존
[저수준 세부] (DB, 외부 API, 파일 시스템)

예시:

public class OrderService {  // 고수준
    private MySQLOrderRepository repository;  // ← 저수준에 직접 의존
    private SmtpEmailSender emailSender;       // ← 저수준에 직접 의존
}

문제:

  • DB를 PostgreSQL로 바꾸면? OrderService 수정
  • 이메일을 AWS SES로 바꾸면? OrderService 수정
  • 고수준 정책이 저수준 세부의 변경에 끌려다님

Uncle Bob의 통찰

"비즈니스 로직 (고수준) 이 인프라 세부사항 (저수준) 에 의존하는 것은 비정상이다.
반대여야 한다."

비즈니스의 본질:

  • 운임 계산 로직 → 핵심
  • DB가 무엇이든 → 무관
  • 이메일 라이브러리가 무엇이든 → 무관

그런데 현실:

  • 비즈니스 코드가 DB 라이브러리에 의존
  • 비즈니스 코드가 이메일 SDK에 의존
  • 본말전도

의존 역전 — 의존 방향 뒤집기

Before (전통):

[OrderService] → [MySQLOrderRepository]
   고수준              저수준
   
   (고수준이 저수준에 의존 ← 위험)

After (DIP):

[OrderService] → [OrderRepository] ← [MySQLOrderRepository]
   고수준           추상화                저수준
   
   (양쪽이 추상화에 의존 ← 안전)
   (저수준이 추상화에 의존 ← 역전!)

핵심 변화:

  • 인터페이스 OrderRepository 도입
  • OrderService 는 인터페이스에 의존
  • MySQLOrderRepository 가 인터페이스를 구현
  • 저수준이 추상화를 따른다

→ 의존 방향이 거꾸로 (역전) 된 셈.


Spring 의 등장 — DIP 의 결정적 응용

2000년대 초 Spring Framework 가 등장하면서 DIP가 자바 표준으로 자리잡음.

Spring DI = DIP의 직접적 구현:

@Service
public class OrderService {
    private final OrderRepository repository;  // 인터페이스
    
    public OrderService(OrderRepository repository) {
        this.repository = repository;  // Spring이 알아서 구현체 주입
    }
}

@Repository
public class JpaOrderRepository implements OrderRepository { ... }

Spring 이 한 일:

  • 의존성을 외부에서 주입 (Inversion of Control)
  • 비즈니스 코드는 추상화에만 의존
  • 구현체 교체 시 비즈니스 코드 안 건드림

Spring 자체가 DIP의 거대한 응용.


Hexagonal Architecture / Clean Architecture

DIP가 더 발전된 형태:

Hexagonal Architecture (Alistair Cockburn, 2005):

  • 비즈니스 로직 (도메인) 이 중심
  • 외부 (DB, UI, API) 는 포트(인터페이스) 를 통해 연결
  • → DIP의 아키텍처 레벨 적용

Clean Architecture (Uncle Bob, 2017):

  • Hexagonal의 진화
  • 의존성 규칙: "내부는 외부를 모른다"
  • → DIP의 시스템 전체 적용

현대 백엔드 아키텍처의 토대. 17주차에서 본격.


핵심 통찰

"DIP는 '의존 방향을 통제' 하는 원칙이다."

자연스러운 의존 방향 (고수준 → 저수준) 은 비즈니스 코드를 인프라에 종속시킨다. 인터페이스(추상화)를 사이에 두어 양쪽이 추상화에 의존하게 만들면, 고수준은 저수준의 변경으로부터 독립 한다.

이게 Spring DI, JPA Repository, Hexagonal/Clean Architecture, MSA 의 모든 토대. 현대 자바 아키텍처의 출발점이자 정점.


💣 3. 없으면 생기는 문제

DIP를 위반했을 때의 구체적 문제를 ILIC 시나리오로 보겠습니다.

시나리오: ILIC 운임 등록 시스템

운임 등록 시:
1. DB에 저장
2. 이메일로 고객 통지
3. 통계 집계
4. 외부 ERP 연동


DIP 위반 — 구체에 직접 의존

// ❌ DIP 위반
public class FareService {
    
    // 구체 클래스에 직접 의존
    private final MySQLFareRepository repository = new MySQLFareRepository();
    private final SmtpEmailSender emailSender = new SmtpEmailSender();
    private final RedisStatsAggregator statsAggregator = new RedisStatsAggregator();
    private final SapErpClient erpClient = new SapErpClient();
    
    public void registerFare(FareRequest request) {
        // 1. 저장
        Fare fare = new Fare(request);
        repository.save(fare);
        
        // 2. 이메일
        emailSender.send(request.getCustomerEmail(), 
                          "운임 등록", 
                          "운임이 등록되었습니다");
        
        // 3. 통계
        statsAggregator.increment("fare.registered");
        
        // 4. ERP
        erpClient.syncFare(fare);
    }
}

DIP 위반의 7가지 심각한 문제

1. 변경의 폭발

DB를 MySQL → PostgreSQL 변경 시:

// 모든 의존 코드 수정
private final PostgreSQLFareRepository repository = ...;  // ❌

FareService 직접 수정. 비즈니스 코드를 인프라 변경 때문에 건드림.


2. 테스트 불가

@Test
void 운임_등록_테스트() {
    FareService service = new FareService();
    
    service.registerFare(request);
    
    // 그런데 이 테스트는:
    // - 진짜 MySQL에 데이터 INSERT ❌
    // - 진짜 SMTP로 이메일 발송 ❌
    // - 진짜 Redis에 데이터 ❌
    // - 진짜 SAP ERP에 호출 ❌
    // → 단위 테스트 불가능 ❌
}

단위 테스트 자체가 불가능. 통합 테스트만 가능 → 느림 + 깨지기 쉬움.


3. 동시 개발 불가

A 개발자: 운임 비즈니스 로직 작성
B 개발자: DB 스키마 결정 안 됨

A는 B를 기다려야 함. 비즈니스 작업이 인프라 결정을 기다림


4. 외부 라이브러리에 종속

import com.smtp.SmtpEmailSender;  // 특정 라이브러리에 종속

→ 라이브러리 변경 시 전체 시스템 영향.


5. Mock 불가

@Test
void 테스트() {
    // 의존성이 new로 생성됨
    // → Mockito로 mock 불가
    // → 테스트 통제 불가
}

6. 비즈니스 로직과 인프라 코드 혼재

public void registerFare(FareRequest request) {
    // 검증 (비즈니스)
    if (request.getAmount() < 0) throw ...;
    
    // SQL 만들기 (인프라)
    String sql = "INSERT INTO fares ...";
    PreparedStatement stmt = connection.prepareStatement(sql);
    stmt.setInt(1, request.getAmount());
    
    // 비즈니스 → 인프라 → 비즈니스 → 인프라 ❌
    // 코드 가독성 ↓
}

7. 환경별 구현 교체 불가

  • 개발 환경: Mock DB
  • 테스트 환경: H2
  • 운영 환경: PostgreSQL

→ DIP 위반 시 환경별 구현 교체 어려움.


DIP 적용 — 추상화 + 의존성 주입

// 1. 추상화 정의 (인터페이스)
public interface FareRepository {
    void save(Fare fare);
    Optional<Fare> findById(Long id);
}

public interface NotificationService {
    void send(String recipient, String subject, String message);
}

public interface StatsAggregator {
    void increment(String metric);
}

public interface ErpSyncClient {
    void syncFare(Fare fare);
}

// 2. 비즈니스 로직 — 추상화에만 의존
@Service
@RequiredArgsConstructor
public class FareService {
    private final FareRepository repository;        // 인터페이스
    private final NotificationService notification; // 인터페이스
    private final StatsAggregator stats;            // 인터페이스
    private final ErpSyncClient erpClient;          // 인터페이스
    
    public void registerFare(FareRequest request) {
        // 검증 (비즈니스)
        if (request.getAmount() < 0) throw new IllegalArgumentException();
        
        Fare fare = new Fare(request);
        
        // 추상화를 통한 작업 — 구체 모름
        repository.save(fare);
        notification.send(request.getCustomerEmail(), "운임 등록", "...");
        stats.increment("fare.registered");
        erpClient.syncFare(fare);
    }
}

// 3. 구현체 — 추상화를 따름 (역전)
@Repository
public class JpaFareRepository implements FareRepository { ... }

@Component
public class SmtpNotificationService implements NotificationService { ... }

@Component
public class RedisStatsAggregator implements StatsAggregator { ... }

@Component
public class SapErpSyncClient implements ErpSyncClient { ... }

7가지 문제가 어떻게 해결됐나?

문제해결
변경의 폭발구현체 교체만
테스트 불가Mock 가능
동시 개발 불가인터페이스만 정해지면 OK
라이브러리 종속인터페이스 뒤로 격리
Mock 불가의존성 주입 → Mock OK
코드 혼재비즈니스만 남음
환경별 교체Profile/조건별 빈

이게 DIP의 진짜 가치.


✅ 4. 해결책 — DIP를 적용하는 방법

핵심 원칙 — "구체 대신 추상"

DIP 적용 단계 ⭐ :

단계 1: 의존성 식별

"이 클래스가 무엇에 의존하나?"
- DB
- 외부 API
- 파일 시스템
- 메시징
- ...

변경 가능성이 있는 것 들이 모두 후보.


단계 2: 추상화 정의

// 비즈니스가 필요한 능력을 인터페이스로
public interface FareRepository {
    void save(Fare fare);
    Optional<Fare> findById(Long id);
    List<Fare> findByCustomer(Long customerId);
}

중요한 원칙: 인터페이스를 누가 정의하나?

[비즈니스 영역]                    [인프라 영역]
- FareRepository (인터페이스) ◄── 인프라가 따름
- FareService (사용)
                                   - JpaFareRepository (구현)

비즈니스가 인터페이스를 정의, 인프라가 따름. 이게 진짜 의존 역전.


단계 3: 비즈니스 로직은 추상화에만 의존

@Service
public class FareService {
    private final FareRepository repository;  // 인터페이스
    
    public FareService(FareRepository repository) {  // 외부 주입
        this.repository = repository;
    }
}

new 사용 X. 의존성을 외부에서 받음.


단계 4: 구현체는 추상화를 따름

@Repository
public class JpaFareRepository implements FareRepository {
    @Override
    public void save(Fare fare) { ... }
}

단계 5: Spring이 연결

@Configuration
public class AppConfig {
    @Bean
    public FareService fareService(FareRepository repository) {
        return new FareService(repository);
    }
}

// 또는 @Service + @Repository 자동 인식

Spring이 알아서 구현체를 주입.


IoC (Inversion of Control) ⭐

IoC = "제어 역전"
DI = "의존성 주입" (IoC의 한 형태)

전통적 제어 흐름:

public class FareService {
    public FareService() {
        this.repository = new MySQLFareRepository();
        // → 내가 직접 만들고 제어
    }
}

IoC 적용:

public class FareService {
    public FareService(FareRepository repository) {  // 외부에서 받음
        this.repository = repository;
    }
}

// 외부 (Spring) 가 만들고 주입

제어가 역전됨:

  • Before: FareServiceMySQLFareRepository 생성 제어
  • After: 외부 (Spring) 가 생성 제어, FareService 는 사용만

IoC = 객체 생성/관리 제어를 외부에 위임.

IoC 컨테이너 = Spring (5주차에서 본격).


의존성 주입 (DI) 의 3가지 방법

1. 생성자 주입 (Constructor Injection) ⭐ 가장 권장

@Service
@RequiredArgsConstructor  // Lombok
public class FareService {
    private final FareRepository repository;
    private final NotificationService notification;
}

장점:

  • final 가능 → 불변성
  • 누락 시 컴파일 에러
  • 테스트 시 명시적
  • Spring 4.3+ 권장 방식

2. Setter 주입 (Setter Injection)

@Service
public class FareService {
    private FareRepository repository;
    
    @Autowired
    public void setRepository(FareRepository repository) {
        this.repository = repository;
    }
}

언제: 선택적 의존성. 거의 안 씀.

3. 필드 주입 (Field Injection) ⚠️ 비권장

@Service
public class FareService {
    @Autowired
    private FareRepository repository;
}

문제:

  • final 불가
  • 테스트 어려움
  • 의존성이 숨겨짐
  • 현재 비권장

"Repository 패턴" 과 DIP

// 도메인 영역 — 인터페이스 정의
public interface FareRepository {
    void save(Fare fare);
    Optional<Fare> findById(Long id);
}

// 인프라 영역 — 구현
@Repository
public class JpaFareRepository implements FareRepository {
    private final FareJpaRepo jpaRepo;  // Spring Data JPA
    
    @Override
    public void save(Fare fare) {
        jpaRepo.save(fare);
    }
}

장점:

  • 도메인은 JPA를 모름
  • DB를 MongoDB로 변경 시 → 새 구현체만
  • 테스트 시 Mock Repository 사용 가능

JPA 사용해도 DIP는 별개로 적용.


Hexagonal Architecture (Ports and Adapters) ⭐

DIP의 아키텍처 레벨 적용:

        [도메인 (비즈니스)]
              ↑ ↑
      ┌───────┘ └───────┐
[Inbound Port]    [Outbound Port]
   (인터페이스)      (인터페이스)
      ↑                  ↑
[Adapters]         [Adapters]
   - Web Controller    - JPA Repository
   - Kafka Listener    - REST Client
   - CLI               - File System

핵심:

  • 도메인은 포트 (인터페이스) 만 알고
  • 어댑터 (구현) 가 포트를 구현
  • → 도메인이 외부 기술과 무관

ILIC 적용:

// 도메인
public interface FareNotificationPort {  // 인바운드 포트
    void notifyRegistered(Fare fare);
}

// 어댑터
@Component
public class EmailFareNotificationAdapter implements FareNotificationPort { ... }

@Component
public class SmsFareNotificationAdapter implements FareNotificationPort { ... }

17주차에서 본격 학습.


🏗️ 5. 내부 동작 원리

DIP는 설계 원칙이지만, Spring 의 IoC 컨테이너 동작 을 이해하면 더 깊어집니다.

Spring IoC 컨테이너의 동작 ⭐

@Service
public class FareService {
    private final FareRepository repository;
    
    public FareService(FareRepository repository) {  // ① 의존성 선언
        this.repository = repository;
    }
}

@Repository
public class JpaFareRepository implements FareRepository { ... }  // ② 구현체

Spring 시작 시 흐름:

1. ApplicationContext 생성
        ↓
2. 모든 @Component, @Service, @Repository 스캔
   - FareService 발견
   - JpaFareRepository 발견
        ↓
3. 각 빈의 의존성 분석
   - FareService: FareRepository 필요
   - JpaFareRepository: 의존성 없음
        ↓
4. 의존성 그래프 생성 (DAG)
   FareService → FareRepository
        ↓
5. 의존 순서대로 빈 생성
   - 먼저 JpaFareRepository 인스턴스화
   - 그 다음 FareService 인스턴스화 (생성자에 JpaFareRepository 주입)
        ↓
6. ApplicationContext에 빈 등록
        ↓
7. 사용 준비 완료

개발자는 인터페이스만 선언, Spring이 구현체 자동 주입.


의존성 그래프와 순환 의존 감지

FareService → FareRepository ✅
NotificationService → EmailSender ✅
EmailSender → NotificationService ❌ 순환!

Spring이 시작 시 순환을 감지하면 에러:

The dependencies of some of the beans in the application context form a cycle

DIP가 잘 적용되면 순환 자체가 안 일어남. 단방향 의존.


컴파일 의존성 vs 런타임 의존성 ⭐

컴파일 시점:

public class FareService {
    private final FareRepository repository;  // FareRepository 인터페이스만 의존
}

컴파일 의존성:

  • FareServiceFareRepository (인터페이스)

JpaFareRepository를 모름. 컴파일 시 무관.

런타임 시점:

FareService service = applicationContext.getBean(FareService.class);
service.registerFare(request);
// → 내부의 repository는 실제로 JpaFareRepository 인스턴스

런타임 의존성:

  • FareServiceJpaFareRepository (인스턴스)

컴파일과 런타임 의존성 분리. 이게 DIP의 본질.


Adapter Pattern 으로 DIP 응용

기존 라이브러리를 DIP에 맞게 감싸기:

// 외부 라이브러리 (변경 불가)
public class ExternalEmailSDK {
    public void sendMail(String to, String body) { ... }
}

// 우리의 추상화
public interface NotificationService {
    void send(String recipient, String subject, String message);
}

// 어댑터 — 외부 라이브러리를 우리 인터페이스에 맞춤
@Component
public class ExternalEmailAdapter implements NotificationService {
    private final ExternalEmailSDK sdk = new ExternalEmailSDK();
    
    @Override
    public void send(String recipient, String subject, String message) {
        sdk.sendMail(recipient, subject + "\n" + message);
    }
}

외부 라이브러리에 직접 의존 X. 어댑터를 통해 격리.


Spring Profile 로 환경별 구현 교체

public interface FareRepository { ... }

@Profile("dev")
@Repository
public class InMemoryFareRepository implements FareRepository { ... }

@Profile("test")
@Repository
public class H2FareRepository implements FareRepository { ... }

@Profile("prod")
@Repository
public class PostgreSQLFareRepository implements FareRepository { ... }
# application.yml
spring:
  profiles:
    active: prod  # 또는 dev, test

DIP 덕분에 환경별 구현 교체 가능. 비즈니스 코드 안 건드림.


💻 6. 실전 코드 예시

예시 1: ILIC 운임 시스템 — DIP 적용 ⭐

// ===== 도메인 영역 =====
// 1. 도메인 객체
public class Fare {
    private Long id;
    private int amount;
    private FareStatus status;
    private Long customerId;
    // 도메인 로직...
}

// 2. Repository 추상화 (도메인이 정의)
public interface FareRepository {
    void save(Fare fare);
    Optional<Fare> findById(Long id);
    List<Fare> findByCustomer(Long customerId);
}

// 3. Notification 추상화
public interface FareNotificationPort {
    void notifyRegistered(Fare fare, Customer customer);
}

// 4. ERP 연동 추상화
public interface ErpSyncPort {
    void syncFare(Fare fare);
}

// 5. 비즈니스 로직 — 추상화에만 의존
@Service
@RequiredArgsConstructor
public class FareService {
    private final FareRepository repository;
    private final FareNotificationPort notification;
    private final ErpSyncPort erpSync;
    
    @Transactional
    public Fare registerFare(FareRequest request, Customer customer) {
        // 비즈니스 로직만
        Fare fare = Fare.create(request, customer);
        repository.save(fare);
        notification.notifyRegistered(fare, customer);
        erpSync.syncFare(fare);
        return fare;
    }
}

// ===== 인프라 영역 =====
// 6. JPA 구현
@Repository
@RequiredArgsConstructor
public class JpaFareRepositoryAdapter implements FareRepository {
    private final FareJpaRepository jpaRepo;
    
    @Override
    public void save(Fare fare) {
        jpaRepo.save(fare);
    }
    
    @Override
    public Optional<Fare> findById(Long id) {
        return jpaRepo.findById(id);
    }
    
    @Override
    public List<Fare> findByCustomer(Long customerId) {
        return jpaRepo.findByCustomerId(customerId);
    }
}

// 7. 이메일 알림 구현
@Component
@RequiredArgsConstructor
public class EmailFareNotificationAdapter implements FareNotificationPort {
    private final EmailSender emailSender;
    
    @Override
    public void notifyRegistered(Fare fare, Customer customer) {
        String message = String.format("운임 #%d 등록 완료 (%d원)", 
                                         fare.getId(), fare.getAmount());
        emailSender.send(customer.getEmail(), "운임 등록", message);
    }
}

// 8. ERP 연동 구현
@Component
@RequiredArgsConstructor
public class SapErpSyncAdapter implements ErpSyncPort {
    private final SapErpClient sapClient;
    
    @Override
    public void syncFare(Fare fare) {
        sapClient.callSapApi(fare.toSapFormat());
    }
}

효과:

  • 비즈니스 코드는 JPA, Email, SAP 를 모름
  • DB를 MongoDB로 → 새 어댑터 만들면 끝
  • 이메일을 Slack으로 → 새 어댑터만
  • 테스트 시 Mock 어댑터 자유 사용

예시 2: 테스트 가능한 코드

// 테스트 코드
@Test
void 운임_등록_시_알림이_발송된다() {
    // Given - Mock 의존성
    FareRepository mockRepo = mock(FareRepository.class);
    FareNotificationPort mockNotification = mock(FareNotificationPort.class);
    ErpSyncPort mockErp = mock(ErpSyncPort.class);
    
    FareService service = new FareService(mockRepo, mockNotification, mockErp);
    
    FareRequest request = new FareRequest(50000, 100L);
    Customer customer = new Customer("alice@example.com");
    
    // When
    Fare result = service.registerFare(request, customer);
    
    // Then
    verify(mockRepo).save(any(Fare.class));
    verify(mockNotification).notifyRegistered(eq(result), eq(customer));
    verify(mockErp).syncFare(eq(result));
}

DB, 이메일, ERP 없이 비즈니스 로직 검증.


예시 3: 시간 의존성 추상화

// ❌ 시간에 직접 의존
public class FareService {
    public void register() {
        Fare fare = new Fare();
        fare.setCreatedAt(LocalDateTime.now());  // 테스트 시 통제 불가
    }
}

해결:

// 추상화
public interface Clock {
    LocalDateTime now();
}

@Component
public class SystemClock implements Clock {
    public LocalDateTime now() { return LocalDateTime.now(); }
}

@TestConfiguration
public class TestClockConfig {
    @Bean
    public Clock testClock() {
        return () -> LocalDateTime.of(2024, 1, 1, 12, 0);  // 고정
    }
}

// 사용
@Service
@RequiredArgsConstructor
public class FareService {
    private final Clock clock;
    
    public void register() {
        Fare fare = new Fare();
        fare.setCreatedAt(clock.now());  // 테스트 시 고정 시간
    }
}

시간도 의존성. DIP로 통제 가능.


예시 4: 외부 API 호출의 DIP

// 추상화
public interface PaymentGateway {
    PaymentResult process(PaymentRequest request);
}

// 토스 페이먼츠 구현
@Component("tossPayment")
public class TossPaymentAdapter implements PaymentGateway {
    private final TossClient tossClient;
    
    @Override
    public PaymentResult process(PaymentRequest request) {
        TossResponse response = tossClient.requestPayment(...);
        return PaymentResult.from(response);
    }
}

// 카카오 페이 구현
@Component("kakaoPayment")
public class KakaoPaymentAdapter implements PaymentGateway {
    private final KakaoClient kakaoClient;
    
    @Override
    public PaymentResult process(PaymentRequest request) {
        KakaoResponse response = kakaoClient.requestPayment(...);
        return PaymentResult.from(response);
    }
}

// 사용
@Service
@RequiredArgsConstructor
public class CheckoutService {
    private final Map<String, PaymentGateway> gateways;  // Spring이 자동 주입
    
    public void checkout(String gatewayName, PaymentRequest request) {
        PaymentGateway gateway = gateways.get(gatewayName);
        gateway.process(request);
    }
}

새 결제 게이트웨이 추가 시 새 어댑터만. 기존 코드 수정 X.


예시 5: ILIC 의 SOLID 통합

5가지 원칙이 모두 적용된 ILIC 시스템:

// SRP — 한 책임
@Service
public class FareRegistrationService {  // 운임 등록만
    private final FareRepository repository;       // DIP — 추상화 의존
    private final List<FareValidator> validators; // OCP — 검증 규칙 확장 가능
    private final FareNotificationPort notification; // ISP — 알림만
    
    @Transactional
    public Fare register(FareRequest request) {
        // 검증 (LSP — 모든 validator가 같은 약속 지킴)
        validators.forEach(v -> v.validate(request));
        
        // 저장 (DIP — 인터페이스에 위임)
        Fare fare = Fare.create(request);
        repository.save(fare);
        
        // 알림 (DIP)
        notification.notifyRegistered(fare);
        
        return fare;
    }
}

SOLID가 모두 적용된 모범.


⚠️ 7. 주의사항 & 흔한 실수

실수 1: 구현체에 직접 의존 ❌

@Service
public class FareService {
    private final JpaFareRepository repository;  // ❌ 구체 클래스
    
    public FareService(JpaFareRepository repository) {  // ❌
        this.repository = repository;
    }
}

해결 — 인터페이스에 의존:

@Service
public class FareService {
    private final FareRepository repository;  // ✅ 인터페이스
}

실수 2: 인터페이스 위치를 잘못 둠

[infrastructure/jpa/]
  - FareRepository (인터페이스)
  - JpaFareRepository (구현)
  
[domain/]
  - FareService → infrastructure 패키지에 의존 ❌

해결 — 인터페이스를 도메인에:

[domain/]
  - FareRepository (인터페이스)  ← 도메인이 정의
  - FareService (사용)
  
[infrastructure/jpa/]
  - JpaFareRepository implements FareRepository  ← 구현

도메인이 인프라를 모르도록. 의존 방향 역전.


실수 3: 추상화가 구체에 의존

// ❌ 인터페이스가 JPA 의존
public interface FareRepository {
    void save(Fare fare);
    Page<Fare> findAll(Pageable pageable);  // ← Spring Data 의존
}

→ 인터페이스가 Spring Data 라이브러리에 종속.

해결:

public interface FareRepository {
    void save(Fare fare);
    List<Fare> findAll(int page, int size);  // 도메인 타입만
}

실수 4: new 로 직접 생성

@Service
public class FareService {
    private final FareRepository repository = new JpaFareRepository();  // ❌
}

→ DIP 위반 + 테스트 불가.

해결 — 생성자 주입:

@Service
@RequiredArgsConstructor
public class FareService {
    private final FareRepository repository;  // 외부 주입
}

실수 5: 필드 주입 사용

@Service
public class FareService {
    @Autowired
    private FareRepository repository;  // ❌ 비권장
}

해결 — 생성자 주입:

@Service
@RequiredArgsConstructor
public class FareService {
    private final FareRepository repository;  // ✅
}

실수 6: 너무 많은 추상화

// ❌ 단순한 것까지 추상화
public interface NameProvider {
    String getName();
}

public class CustomerNameProvider implements NameProvider {
    public String getName() { return "Alice"; }
}

변화 가능성 없는 곳까지 추상화. 과도한 설계.

원칙: 변화 가능성이 있는 곳만 추상화.


실수 7: 인터페이스 = 구현 1:1 매핑

public interface FareRepository { ... }
public class FareRepositoryImpl implements FareRepository { ... }
// 1:1 매핑 — 의미 없을 수 있음 ⚠️

판단 기준:

  • 테스트용 mock 필요 → OK
  • 여러 구현 가능성 → OK
  • 단순 1:1 매핑, 변경 가능성 X → 인터페이스 불필요

→ "인터페이스가 진짜 가치가 있는가" 자문.


실수 8: DIP 위반의 흔적 — instanceof

@Service
public class FareService {
    private final FareRepository repository;
    
    public void process(Fare fare) {
        if (repository instanceof JpaFareRepository) {  // ❌
            ((JpaFareRepository) repository).flushDb();
        }
    }
}

추상화에 의존하지 않고 구체에 의존. DIP 위반.

해결 — 추상화에 메서드 추가:

public interface FareRepository {
    void save(Fare fare);
    void flush();  // 인터페이스에 추가
}

🔗 8. 연관 개념 맵

Phase 3 (SOLID) 완성

[SRP] — 한 책임 ✓
   ↓
[OCP] — 확장 가능 ✓
   ↓
[LSP] — 안전한 다형성 ✓
   ↓
[ISP] — 인터페이스 분리 ✓
   ↓
[DIP] ★ ← 지금 여기 — 의존 역전 (SOLID 마지막)

SOLID 5원칙 완성. Phase 3 완료!


SOLID 통합 흐름

[SRP] 책임 분리
  ↓
[OCP] 추상화 추가 (다형성)
  ↓
[LSP] 자식이 약속 지킴
  ↓
[ISP] 인터페이스 분리
  ↓
[DIP] 의존 방향 통제
  ↓
=> 좋은 객체지향 설계 ✅

SOLID는 한 사고의 5가지 표현.


Phase 2와의 통합

Phase 2 학습DIP 적용
Unit 2.4 (다형성)DIP의 토대
Unit 2.5 (instanceof)남발하면 DIP 위반
Unit 2.6 (Anonymous)즉석 추상화 (콜백)

다형성 없는 DIP는 불가능.


미래 주차와의 연결 ⭐

DIP가 모든 미래 학습의 토대:

3주차 (제네릭/람다):

  • Function, Consumer, Supplier = DIP의 함수형 표현

5주차 (Spring DI/IoC) ★:

  • DIP의 직접적 구현
  • @Autowired, @Component, @Service
  • 이 Unit 학습 후 5주차가 자연스러움

5주차 (디자인 패턴):

  • Strategy, Factory, Adapter — 모두 DIP 응용

8-9주차 (AOP):

  • 프록시 객체로 의존성 처리

11-12주차 (JPA):

  • Repository 패턴 = DIP

13-14주차 (DB):

  • DataSource 추상화

15주차 (Spring MVC):

  • HandlerMapping, ViewResolver 등 모든 추상화

17주차 (MSA):

  • 서비스 간 인터페이스 = DIP의 분산 버전
  • Hexagonal/Clean Architecture = DIP의 시스템 적용

18주차 (Security):

  • UserDetailsService, AuthenticationProvider 추상화

19주차 (테스트):

  • DIP 덕분에 Mock 테스트 가능

DIP는 미래 모든 학습의 통로.


거장들의 격언

Robert C. Martin (Uncle Bob):

"Source code dependencies must point only inward, toward higher-level policies."
("소스 코드 의존성은 안쪽, 즉 더 높은 수준의 정책을 향해야 한다.")

Alistair Cockburn (Hexagonal):

"Allow an application to equally be driven by users, programs, automated test or batch scripts."
("애플리케이션이 사용자, 프로그램, 자동화 테스트, 배치 스크립트에 의해 동등하게 구동되도록.")

Eric Evans (DDD):

"The domain is the heart of the software."
("도메인이 소프트웨어의 심장이다.")

모두 DIP의 본질을 다른 표현으로.


면접 단골 질문 매핑

질문이 Unit에서의 답
"DIP가 뭔가요?"구체에 의존하지 말고 추상화에 의존하라
"IoC와 DI?"IoC는 제어 역전, DI는 그 한 형태
"왜 생성자 주입?"final 가능, 누락 시 컴파일 에러, 테스트 명시적
"DIP 적용 시 주의?"인터페이스를 도메인에 둠, 추상화가 구체에 의존 X
"Spring DI와 DIP?"Spring DI가 DIP의 직접적 구현
"Hexagonal Architecture?"DIP의 아키텍처 레벨 적용

📝 9. 핵심 요약 — 3줄 정리

1️⃣ DIP는 "구체 대신 추상에 의존하라" 의 원칙이다.

Uncle Bob 정의: "상위 모듈은 하위 모듈에 의존하지 않아야 한다. 둘 다 추상화에 의존해야 한다." 핵심은 의존 방향의 역전 — 자연스러운 방향 (고수준 → 저수준) 을 인터페이스를 사이에 두어 양쪽이 추상화에 의존하게 만든다. 콘센트 표준, USB-C 표준 처럼 양쪽이 표준에 의존하는 구조.

2️⃣ DIP는 Spring DI, Hexagonal/Clean Architecture 의 토대다.

Spring 의 IoC 컨테이너가 DIP의 직접적 구현 — 비즈니스는 인터페이스만 선언하고, Spring 이 구현체를 주입. 인터페이스를 도메인이 정의, 인프라가 따름 이 진짜 의존 역전. Hexagonal Architecture (Ports and Adapters) 와 Clean Architecture 모두 DIP의 시스템 레벨 적용. 현대 백엔드 아키텍처의 출발점이자 정점.

3️⃣ DIP는 모든 SOLID 원칙의 통합점이자 자바 학습의 통로다.

SOLID 5원칙이 DIP에서 만남: SRP의 책임 분리, OCP의 다형성, LSP의 안전한 대체, ISP의 인터페이스 분리가 DIP의 추상화로 통합. Phase 4부터의 모든 학습 (Spring, JPA, AOP, MVC, MSA, Security, Test) 이 DIP 위에 서 있음. 단, 변화 가능성 있는 곳에만 추상화 — 모든 곳에 인터페이스는 과도한 설계.


🎓 학습 자기 점검

기본 이해

  • DIP의 정의를 한 문장으로 설명할 수 있다
  • IoC와 DI의 관계를 안다
  • 의존 역전의 의미를 이해한다 (의존 방향 뒤집기)
  • 생성자/Setter/필드 주입의 차이를 안다

실전 적용

  • ILIC 코드의 DIP 위반을 식별할 수 있다
  • 인터페이스를 도메인 영역에 두는 패턴을 안다
  • Repository 패턴으로 DIP를 적용할 수 있다
  • 테스트 시 Mock 으로 의존성을 통제할 수 있다

면접 대비 (5분 답변)

  • "DIP가 뭔가요?" 답변 가능
  • "왜 생성자 주입?" 답변 가능
  • "Spring DI 와 DIP 관계?" 답변 가능
  • "Hexagonal Architecture 와 DIP?" 답변 가능
  • "ILIC 에서 DIP 어떻게 적용?" 답변 가능

자기 점검 — ILIC 적용

박승제님의 ILIC 코드 점검:

DIP 위반 신호 ⚠️:

  • Service 가 구체 클래스에 의존 (예: JpaXxxRepository)
  • 비즈니스 로직에 new 로 인프라 객체 생성
  • 필드 주입 (@Autowired private) 사용
  • 인터페이스가 인프라 패키지에 위치
  • 테스트 시 진짜 DB/외부 API 호출

3개 이상 해당 = DIP 적용 가치 큼.


🎉 Phase 3 완료 축하!

학습한 5개 Unit (SOLID)

Unit원칙핵심
3.1SRP한 클래스, 한 변경 이유
3.2OCP ★★★확장 열림, 수정 닫힘
3.3LSP자식이 부모 대체 가능
3.4ISP인터페이스 분리
3.5DIP ★★★추상화에 의존

Phase 3의 핵심 통찰

"SOLID 는 5가지 따로 원칙이 아닌 1가지 사고의 5가지 표현이다."

책임을 분리하고 (SRP) → 확장 가능하게 만들고 (OCP) → 안전한 다형성으로 (LSP) → 인터페이스를 분리하고 (ISP) → 의존을 역전시키면 (DIP) → 좋은 객체지향 설계.

Phase 4 미리보기 — JVM 메모리 모델

이제 추상화의 세계에서 물리적 실행의 세계 로:

  • Heap, Stack, Method Area
  • Pass by Value 의 진실
  • 객체가 메모리에 어떻게 살고 죽는지
  • 동시성 문제의 토대

Phase 1~3이 '어떻게 설계할까', Phase 4~5가 '어떻게 동작할까'.


다음 학습으로

  • Phase 4 (JVM 메모리 모델) 학습 준비 완료
  • 객체가 메모리에 살고 죽는 메커니즘이 궁금하다
  • Pass by Value 의 진실을 만날 준비 완료
profile
Software Developer

0개의 댓글