Skip to content

Latest commit

 

History

History
2426 lines (1854 loc) · 60.7 KB

File metadata and controls

2426 lines (1854 loc) · 60.7 KB

Chapter 09. 유연한 설계

"유연한 설계는 단순성과 명확성의 희생 위에서 자라난다"

📌 핵심 개념

이 장에서는 8장의 의존성 관리 기법들을 원칙이라는 관점에서 재정리합니다.

🎯 학습 목표

  • 개방-폐쇄 원칙(OCP)의 본질 이해하기
  • 생성과 사용의 분리를 통한 유연성 확보하기
  • 의존성 주입과 SERVICE LOCATOR 패턴 비교하기
  • 의존성 역전 원칙(DIP)으로 재사용성 높이기
  • 유연성과 단순성 사이의 트레이드오프 판단하기

📖 목차

  1. 개방-폐쇄 원칙
  2. 생성 사용 분리
  3. 의존성 주입
  4. 의존성 역전 원칙
  5. 유연성에 대한 조언
  6. 핵심 정리

1. 개방-폐쇄 원칙

📂 코드: Movie.java | DiscountPolicy.java

1.1 OCP의 정의

🎯 Open-Closed Principle

소프트웨어 개체(클래스, 모듈, 함수 등)는
확장에 대해 열려 있어야 하고,
수정에 대해서는 닫혀 있어야 한다.

두 가지 관점:

관점 의미 설명
확장에 열려 있다 동작의 관점 애플리케이션의 요구사항이 변경될 때 새로운 **'동작'**을 추가해서 기능을 확장할 수 있다
수정에 닫혀 있다 코드의 관점 기존의 **'코드'**를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있다

🤔 모순처럼 보이는 원칙

"코드를 수정하지 않고 어떻게 동작을 추가할 수 있을까?"

이것이 OCP를 이해하는 핵심 질문이다.

1.2 컴파일타임 의존성을 고정시키고 런타임 의존성을 변경하라

💡 OCP의 본질

OCP는 사실 런타임 의존성과 컴파일타임 의존성에 관한 이야기다.

핵심 개념:

// 컴파일타임: Movie는 DiscountPolicy 추상화에만 의존
public class Movie {
    private DiscountPolicy discountPolicy;  // 추상화 의존
    
    public Movie(String title, Duration runningTime, Money fee,
                 DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
    
    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}
// 런타임: 다양한 구체 클래스와 협력
Movie avatar1 = new Movie("아바타",
    Duration.ofMinutes(120),
    Money.wons(10000),
    new AmountDiscountPolicy(...)  // 금액 할인
);

Movie avatar2 = new Movie("아바타",
    Duration.ofMinutes(120),
    Money.wons(10000),
    new PercentDiscountPolicy(...)  // 비율 할인
);

Movie avatar3 = new Movie("아바타",
    Duration.ofMinutes(120),
    Money.wons(10000),
    new NoneDiscountPolicy()  // 할인 없음
);

📊 OCP 작동 방식

컴파일타임 구조 (고정)
    Movie → DiscountPolicy (추상화)
            ↑
            |
    +-------+-------+
    |               |
AmountDiscount  PercentDiscount


런타임 구조 (변경)
    avatar1(Movie) → AmountDiscountPolicy 인스턴스
    avatar2(Movie) → PercentDiscountPolicy 인스턴스
    avatar3(Movie) → NoneDiscountPolicy 인스턴스


결론:
컴파일타임 의존성은 수정하지 않고 (Movie 코드 변경 없음)
런타임 의존성을 쉽게 변경할 수 있다 (다양한 정책 적용)

✅ Movie 클래스의 OCP 준수

// ✅ 확장에 열려 있다
// - 새로운 할인 정책을 추가해서 기능 확장 가능
public class OverlappedDiscountPolicy extends DiscountPolicy {
    // 새로운 정책 추가
}

// ✅ 수정에 닫혀 있다
// - Movie 클래스 코드는 전혀 수정하지 않음
// - 새로운 클래스만 추가하면 됨

1.3 추상화가 핵심이다

🎨 추상화의 역할

추상화 (Abstraction):

핵심적인 부분만 남기고 불필요한 부분은 생략함으로써
복잡성을 극복하는 기법

OCP와 추상화:

public abstract class DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>();

    public DiscountPolicy(DiscountCondition... conditions) {
        this.conditions = Arrays.asList(conditions);
    }

    // ✅ 변하지 않는 부분 (공통점)
    public Money calculateDiscountAmount(Screening screening) {
        for(DiscountCondition each : conditions) {
            if (each.isSatisfiedBy(screening)) {
                return getDiscountAmount(screening);
            }
        }
        return screening.getMovieFee();
    }

    // ✅ 변하는 부분 (차이점) - 생략됨
    abstract protected Money getDiscountAmount(Screening screening);
}

💎 추상화의 두 가지 역할

1. 수정에 대해 닫혀 있다 (변하지 않는 부분)

공통점을 반영한 추상화의 결과물은
문맥이 바뀌더라도 변하지 않아야 한다.

DiscountPolicy의 calculateDiscountAmount 메서드는
어떤 할인 정책이 추가되어도 변경되지 않는다.

2. 확장에 대해 열려 있다 (생략된 부분)

추상화를 통해 생략된 부분은 확장의 여지를 남긴다.

getDiscountAmount 메서드를 구현함으로써
언제라도 새로운 문맥에 맞게 기능을 확장할 수 있다.

⚠️ 추상화만으로는 부족하다

// ❌ 추상화했지만 OCP를 만족하지 못하는 경우
public abstract class DiscountPolicy {
    public Money calculateDiscountAmount(Screening screening) {
        // 할인 조건을 체크하는 로직
        // 하지만 변경될 가능성이 있는 부분을 제대로 생략하지 못함
    }
    
    abstract protected Money getDiscountAmount(Screening screening);
}

중요한 점:

추상화가 수정에 대해 닫혀 있을 수 있는 이유는
변경되지 않을 부분을 신중하게 결정하고
올바른 추상화를 주의 깊게 선택했기 때문이다.

OCP를 만족하는 설계는 공짜로 얻어지지 않는다.

1.4 OCP 적용 사례

📊 확장 시나리오

시나리오 1: 할인 없는 영화
→ NoneDiscountPolicy 추가
→ Movie 코드 수정 없음 ✅

시나리오 2: 중복 할인 정책
→ OverlappedDiscountPolicy 추가
→ Movie 코드 수정 없음 ✅

시나리오 3: 시간대별 할인 정책
→ TimeDiscountPolicy 추가
→ Movie 코드 수정 없음 ✅

💡 핵심 패턴

1. 추상화 식별
   - 변하는 것과 변하지 않는 것을 구분
   
2. 변하지 않는 부분을 추상화에 정의
   - 템플릿 메서드 패턴 활용
   
3. 변하는 부분은 추상 메서드로 남김
   - 서브클래스에서 구현
   
4. 클라이언트는 추상화에만 의존
   - 구체 클래스는 런타임에 주입

2. 생성 사용 분리

📂 코드: Client.java | Factory.java

2.1 문제: 생성과 사용의 혼재

❌ OCP를 위반하는 코드

public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private DiscountPolicy discountPolicy;

    public Movie(String title, Duration runningTime, Money fee) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        // ❌ 구체 클래스를 직접 생성
        this.discountPolicy = new AmountDiscountPolicy(
            Money.wons(800),
            new SequenceCondition(1),
            new SequenceCondition(10)
        );
    }

    public Money calculateMovieFee(Screening screening) {
        // 사용
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

문제점:

1. OCP 위반
   - 동작을 추가하거나 변경하려면 Movie 코드를 수정해야 함
   - PercentDiscountPolicy로 바꾸려면? → Movie 수정 필요

2. 책임의 혼재
   - 객체 생성 책임 + 객체 사용 책임
   - 두 가지 이질적인 목적이 한 클래스에 공존

3. 결합도 증가
   - Movie가 AmountDiscountPolicy에 강하게 결합
   - 재사용성 저하

2.2 해결책: 생성 책임을 클라이언트로 이동

✅ 생성과 사용의 분리

// ✅ Movie: 사용만 담당
public class Movie {
    private DiscountPolicy discountPolicy;

    // 생성된 객체를 전달받음
    public Movie(String title, Duration runningTime, Money fee,
                 DiscountPolicy discountPolicy) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountPolicy = discountPolicy;
    }

    // 오직 사용만
    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}
// ✅ Client: 생성을 담당
public class Client {
    public Money getAvatarFee() {
        // 생성 책임
        Movie avatar = new Movie("아바타",
                Duration.ofMinutes(120),
                Money.wons(10000),
                new AmountDiscountPolicy(
                    Money.wons(800),
                    new SequenceCondition(1),
                    new SequenceCondition(10)
                ));
        
        // 사용
        return avatar.getFee();
    }
}

📊 책임 분리 효과

Before:
┌─────────────────────────────┐
│          Movie              │
│                             │
│  - 생성 책임 (new)            │
│  - 사용 책임 (calculate)      │
│                             │
│  결합도: 높음                  │
│  재사용성: 낮음                │
└─────────────────────────────┘


After:
┌──────────────┐          ┌──────────────┐
│    Client    │          │    Movie     │
│              │          │              │
│  - 생성 책임   │ -------> │  - 사용 책임   │
│    (new)     │  주입     │  (calculate) │
│              │          │              │
└──────────────┘          └──────────────┘

결합도: 낮음
재사용성: 높음

2.3 FACTORY 패턴

🏭 더 나은 분리: Factory 도입

상황:

Client도 생성과 사용 책임을 함께 가지고 있다.
Client를 사용하는 상위 클라이언트에게
Movie 생성 지식이 새어나가는 것을 원하지 않는다면?

해결책:

// ✅ Factory: 오직 생성만 담당
public class Factory {
    public Movie createAvatarMovie() {
        return new Movie("아바타",
                Duration.ofMinutes(120),
                Money.wons(10000),
                new AmountDiscountPolicy(
                    Money.wons(800),
                    new SequenceCondition(1),
                    new SequenceCondition(10)
                ));
    }
}
// ✅ Client: 생성 지식 없이 사용만
public class Client {
    private Factory factory;

    public Client(Factory factory) {
        this.factory = factory;
    }

    public Money getAvatarFee() {
        // Factory에게 생성 위임
        Movie avatar = factory.createAvatarMovie();
        // 사용만
        return avatar.getFee();
    }
}

📊 3단계 책임 분리

┌─────────────┐
│   Factory   │  ← 생성 전문가
│             │
│  - 생성만     │
└──────┬──────┘
       │ 생성된 객체
       ↓
┌─────────────┐
│   Client    │  ← 사용 조정자
│             │
│  - 조율만     │
└──────┬──────┘
       │ 사용
       ↓
┌─────────────┐
│    Movie    │  ← 비즈니스 로직
│             │
│  - 계산만     │
└─────────────┘

각자의 책임이 명확하게 분리됨!

2.4 순수한 가공물 (PURE FABRICATION)

🎭 도메인 vs 인공물

FACTORY는 도메인 개념이 아니다:

도메인 모델:
- 영화 (Movie) ✅
- 상영 (Screening) ✅
- 할인 정책 (DiscountPolicy) ✅
- 팩토리 (Factory) ❌ ← 도메인에 존재하지 않음!

FACTORY는 순수한 가공물:

FACTORY를 추가한 이유는 순수하게 기술적인 결정이다.

도메인 개념에게 할당되어 있던 객체 생성 책임을
도메인 개념과는 아무 상관이 없는
가공의 객체로 이동시킨 것이다.

🎯 객체 분해의 두 가지 방식

1. 표현적 분해 (Representational Decomposition)

도메인에 존재하는 사물 또는 개념을 표현하는 객체들을
이용해 시스템을 분해하는 것

예시:
- Movie (영화)
- Screening (상영)
- Reservation (예매)
- Customer (고객)

장점: 도메인과 소프트웨어 사이의 표현적 차이 최소화
기본: 객체지향 설계의 가장 기본적인 접근법

2. 행위적 분해 (Behavioral Decomposition)

설계자가 편의를 위해 임의로 만들어낸 가공의 객체에게
책임을 할당해서 문제를 해결

예시:
- Factory (객체 생성)
- Repository (데이터 접근)
- Service (비즈니스 로직 조율)

필요성: 모든 책임을 도메인 객체에만 할당하면
        낮은 응집도, 높은 결합도 문제 발생

💡 PURE FABRICATION 패턴

정의:

책임을 할당하기 위해 창조되는
도메인과 무관한 인공적인 객체

사용 시점:

INFORMATION EXPERT 패턴에 따라 책임을 할당한 결과가
바람직하지 않을 경우 대안으로 사용

체크리스트:
□ 어떤 객체가 책임을 수행하는 데 필요한 정보를 가졌는가?
□ 하지만 그 객체에 책임을 할당하면 응집도가 낮아지는가?
□ 결합도가 높아지는가?

→ YES라면 PURE FABRICATION 고려

🎨 설계 전략

1. 먼저 도메인의 본질적인 개념을 표현하는
   추상화를 이용해 애플리케이션 구축 시작
   
2. 도메인 개념이 만족스럽지 못하다면
   인공적인 객체를 창조하라
   
3. FACTORY는 객체의 생성 책임을 할당할 만한
   도메인 객체가 존재하지 않을 때 선택하는
   PURE FABRICATION이다

💎 객체지향의 본질

"객체지향이 실세계의 모방이라는 말은 옳지 않다"

실제 애플리케이션:
- 도메인 개념을 반영하는 객체들
- 설계자가 임의로 창조한 인공적인 추상화들

→ 후자가 더 많은 비중을 차지하는 것이 일반적!

우리가 애플리케이션을 구축하는 이유는
사용자들이 원하는 기능을 제공하기 위해서지
실세계를 모방하거나 시뮬레이션하기 위한 것이 아니다.

3. 의존성 주입

📂 코드: Movie.java (step01) | ServiceLocator.java (step03)

3.1 의존성 주입이란?

🎯 Dependency Injection (DI)

정의:

사용하는 객체가 아닌 외부의 독립적인 객체가
인스턴스를 생성한 후 이를 전달해서
의존성을 해결하는 방법

핵심 아이디어:

의존성을 객체의 퍼블릭 인터페이스에 명시적으로 드러내서
외부에서 필요한 런타임 의존성을 전달할 수 있도록 만드는 방법

3.2 의존성 주입의 세 가지 방법

1️⃣ 생성자 주입 (Constructor Injection)

public class Movie {
    private DiscountPolicy discountPolicy;
    
    // ✅ 생성자를 통한 의존성 주입
    public Movie(String title, Duration runningTime, Money fee,
                 DiscountPolicy discountPolicy) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountPolicy = discountPolicy;
    }
}

// 사용
Movie avatar = new Movie("아바타",
    Duration.ofMinutes(120),
    Money.wons(10000),
    new AmountDiscountPolicy(...)  // 생성 시점에 주입
);

장점:

  • 객체 생성 시점에 모든 의존성 해결
  • 객체의 상태가 항상 완전함 (불완전한 상태 방지)
  • 불변성 보장 (final 키워드 사용 가능)

추천:

  • 가장 권장되는 방식
  • 필수 의존성에 사용

2️⃣ Setter 주입 (Setter Injection)

public class Movie {
    private DiscountPolicy discountPolicy;
    
    // ✅ Setter를 통한 의존성 주입
    public void setDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

// 사용
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000));
avatar.setDiscountPolicy(new AmountDiscountPolicy(...));  // 생성 후 주입

// 런타임에 변경 가능
avatar.setDiscountPolicy(new PercentDiscountPolicy(...));

장점:

  • 실행 시점에 의존 대상 변경 가능
  • 유연성 향상

단점:

  • 객체가 생성된 후 setter 호출 전까지 상태 불완전
  • NullPointerException 위험
  • setter 메서드 호출 누락 가능성

사용 시기:

  • 선택적 의존성
  • 런타임에 의존성 변경이 필요한 경우

3️⃣ 메서드 주입 (Method Injection)

public class Movie {
    // ✅ 메서드 인자를 통한 의존성 주입
    public Money calculateMovieFee(Screening screening, 
                                    DiscountPolicy discountPolicy) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

// 사용
Money fee = avatar.calculateMovieFee(screening, new AmountDiscountPolicy(...));

장점:

  • 메서드 호출마다 다른 의존성 사용 가능
  • 일시적인 의존성에 적합

사용 시기:

  • 협력 대상에 대해 지속적인 의존 관계가 필요 없을 때
  • 메서드 실행 시마다 의존 대상이 달라져야 할 때
  • 한두 개의 메서드에서만 의존성이 필요할 때

💡 최선의 조합

public class Movie {
    private DiscountPolicy discountPolicy;
    
    // ✅ 생성자: 필수 의존성, 상태 완전성 보장
    public Movie(String title, Duration runningTime, Money fee,
                 DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
    
    // ✅ Setter: 선택적 변경 가능성
    public void setDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

이점:

  • 생성자로 기본 의존성 설정 → 상태 완전성
  • Setter로 유연성 제공 → 런타임 변경

3.3 숨겨진 의존성은 나쁘다

❌ SERVICE LOCATOR 패턴

// ❌ 숨겨진 의존성
public class Movie {
    private DiscountPolicy discountPolicy;

    public Movie(String title, Duration runningTime, Money fee) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        // ❌ 내부에서 ServiceLocator 사용
        this.discountPolicy = ServiceLocator.discountPolicy();
    }

    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}
// SERVICE LOCATOR 구현
public class ServiceLocator {
    private static ServiceLocator soleInstance = new ServiceLocator();
    private DiscountPolicy discountPolicy;

    public static DiscountPolicy discountPolicy() {
        return soleInstance.discountPolicy;
    }

    public static void provide(DiscountPolicy discountPolicy) {
        soleInstance.discountPolicy = discountPolicy;
    }

    private ServiceLocator() {
    }
}
// 사용
ServiceLocator.provide(new AmountDiscountPolicy(...));
Movie avatar = new Movie("아바타",
                        Duration.ofMinutes(120),
                        Money.wons(10000));

🚨 SERVICE LOCATOR의 문제점

1. 의존성을 감춘다

// Movie의 퍼블릭 인터페이스를 봐도
// DiscountPolicy에 의존한다는 것을 알 수 없음
public Movie(String title, Duration runningTime, Money fee) {
    // 의존성이 내부에 숨겨져 있음
}

2. 런타임에 가서야 문제 발견

// ServiceLocator에 정책을 등록하지 않으면?
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000));

// 실행 시점에 NullPointerException!
Money fee = avatar.calculateMovieFee(screening);  // 💥

3. 이해하기 어렵고 디버깅하기 어려움

클라이언트 코드:
1. ServiceLocator에 정책 등록
2. Movie 생성
3. Movie 사용

→ 의존성 설정 시점과 해결 시점이 멀리 떨어져 있음
→ 코드 흐름을 따라가기 어려움

4. 캡슐화 위반

클래스의 사용법을 익히기 위해
구현 내부를 샅샅이 뒤져야 한다면
그 클래스의 캡슐화는 무너진 것이다.

Movie를 사용하려면:
1. ServiceLocator를 알아야 함
2. provide 메서드를 호출해야 함
3. Movie 생성 전에 등록해야 함

→ 모두 구현 내부를 알아야만 가능!

5. 테스트하기 어려움

@Test
void test1() {
    ServiceLocator.provide(new AmountDiscountPolicy(...));
    Movie movie = new Movie(...);
    // 테스트
}

@Test
void test2() {
    // test1에서 설정한 정책이 그대로 남아있음!
    // ServiceLocator의 상태가 공유됨
    Movie movie = new Movie(...);
    // 테스트 실패 가능
}

✅ 의존성 주입이 해결하는 방법

// ✅ 명시적인 의존성
public class Movie {
    private DiscountPolicy discountPolicy;
    
    // 의존성이 퍼블릭 인터페이스에 드러남
    public Movie(String title, Duration runningTime, Money fee,
                 DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

// 사용
Movie avatar = new Movie("아바타",
    Duration.ofMinutes(120),
    Money.wons(10000),
    new AmountDiscountPolicy(...)  // 명확하게 전달
);

해결되는 문제들:

문제 SERVICE LOCATOR 의존성 주입
가시성 내부에 숨김 명시적으로 드러남
오류 발견 런타임 컴파일타임
이해도 구현 내부 봐야 함 시그니처만 봐도 알 수 있음
테스트 상태 공유 문제 독립적으로 테스트 가능
캡슐화 위반 준수

💎 핵심 원칙

명시적인 의존성이 숨겨진 의존성보다 좋다.

가급적 의존성을 객체의 퍼블릭 인터페이스에 노출하라.
의존성을 구현 내부에 숨기면 숨길수록
코드를 이해하기도, 수정하기도 어려워진다.

요점은 명시적인 의존성이다.
이것이 유연성을 향상시키는 가장 효과적인 방법이다.

⚠️ SERVICE LOCATOR를 사용해야 하는 경우

1. 의존성 주입을 지원하는 프레임워크를 사용하지 못하는 경우
2. 깊은 호출 계층에 걸쳐 동일한 객체를 계속 전달해야 하는
   고통을 견디기 어려운 경우

→ 어쩔 수 없이 SERVICE LOCATOR 패턴 고려

하지만 가능하다면 의존성을 명시적으로 표현할 수 있는
기법을 사용하는 것이 좋다.

4. 의존성 역전 원칙

📂 코드: Movie.java | DiscountPolicy.java

4.1 추상화와 의존성 역전

🎯 Dependency Inversion Principle (DIP)

정의:

1. 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다.
   둘 모두 추상화에 의존해야 한다.

2. 추상화는 구체적인 사항에 의존해서는 안 된다.
   구체적인 사항은 추상화에 의존해야 한다.

🤔 "역전"의 의미

전통적인 절차형 프로그래밍:
상위 수준 → 하위 수준 (일반적인 의존 방향)

의존성 역전 원칙:
상위 수준 → 추상화 ← 하위 수준 (의존 방향 역전!)

4.2 상위 수준과 하위 수준

📊 개념 이해

상위 수준 클래스:

애플리케이션의 중요한 정책이나 비즈니스 로직을 담당
더 추상적이고 일반적인 개념

예시: Movie (영화 예매 정책)

하위 수준 클래스:

상위 수준 클래스의 기능을 구현하기 위한 세부사항
더 구체적이고 특수한 개념

예시: AmountDiscountPolicy, PercentDiscountPolicy

❌ 문제 상황: 상위 수준이 하위 수준에 의존

// ❌ 상위 수준이 하위 수준에 의존
public class Movie {
    private AmountDiscountPolicy discountPolicy;  // 구체 클래스
    
    public Movie(String title, Duration runningTime, Money fee) {
        this.discountPolicy = new AmountDiscountPolicy(...);
    }
}

문제점:

1. 재사용성 저하
   Movie(상위)를 재사용하려면
   AmountDiscountPolicy(하위)도 필요
   
2. 변경에 취약
   AmountDiscountPolicy 변경 시
   Movie도 영향받음
   
3. 확장 어려움
   새로운 정책 추가 시
   Movie 수정 필요

✅ 해결책: 모두 추상화에 의존

// ✅ 상위 수준과 하위 수준 모두 추상화에 의존
public class Movie {
    private DiscountPolicy discountPolicy;  // 추상화
    
    public Movie(String title, Duration runningTime, Money fee,
                 DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

public class AmountDiscountPolicy extends DiscountPolicy {
    // 하위 수준도 추상화에 의존
}

public class PercentDiscountPolicy extends DiscountPolicy {
    // 하위 수준도 추상화에 의존
}

📊 의존성 방향 비교

Before: 전통적인 의존성

[상위 수준]
   Movie ──────────┐
                   ↓
              [하위 수준]
       AmountDiscountPolicy

문제: Movie를 재사용하려면 AmountDiscountPolicy 필요

After: 역전된 의존성

[상위 수준]
   Movie ──────────┐
                   ↓
              [추상화]
           DiscountPolicy
                   ↑
                   └─────────────┐
                                 │
                          [하위 수준]
                   AmountDiscountPolicy
                   PercentDiscountPolicy

해결: Movie와 구체 클래스 모두 추상화에만 의존

4.3 협력의 본질

💡 핵심 통찰

객체 사이의 협력이 존재할 때
그 협력의 본질을 담고 있는 것은 상위 수준의 클래스다.

Movie와 DiscountPolicy 사이의 협력:
- "영화 요금을 계산하기 위해 할인 정책에 따라 할인 금액을 계산한다"
- 이 협력의 본질은 Movie에 있음
- 할인 금액을 어떻게 계산하는지는 세부사항

🎯 재사용의 대상

대부분의 경우 우리가 재사용하려는 대상은
상위 수준의 클래스이다.

상위 수준의 클래스가 하위 수준의 클래스에 의존하면
상위 수준의 클래스를 재사용할 때
하위 수준의 클래스도 필요하기 때문에
재사용하기가 어려워진다.

4.4 의존성 역전과 패키지

📦 인터페이스 소유권의 역전

잘못된 패키지 구조:

movie
├── Movie.java
└── pricing
    ├── DiscountPolicy.java (추상화)
    ├── AmountDiscountPolicy.java
    └── PercentDiscountPolicy.java

문제:
Movie가 pricing 패키지에 의존함
pricing 패키지 변경 시 Movie 패키지 재컴파일 필요

올바른 패키지 구조 (SEPARATED INTERFACE 패턴):

movie
├── Movie.java
├── DiscountPolicy.java (추상화)  ← Movie와 함께!
└── pricing
    ├── AmountDiscountPolicy.java
    └── PercentDiscountPolicy.java

장점:
Movie와 DiscountPolicy가 같은 패키지
pricing 패키지가 movie 패키지에 의존
의존성 방향이 역전됨!

💎 패키지 의존성 역전

Before:

┌──────────────┐
│    movie     │
│   Movie.java │
└──────┬───────┘
       │ 의존
       ↓
┌────────────────────┐
│      pricing       │
│ DiscountPolicy     │
│ AmountDiscount...  │
│ PercentDiscount... │
└────────────────────┘

movie 패키지가 pricing 패키지에 의존

After:

┌──────────────────────┐
│        movie         │
│    Movie.java        │
│    DiscountPolicy    │ ← 추상화가 여기에!
└──────────┬───────────┘
           ↑ 의존
           │
┌──────────────────────┐
│       pricing        │
│ AmountDiscount...    │
│ PercentDiscount...   │
└──────────────────────┘

pricing 패키지가 movie 패키지에 의존
의존성 역전!

🎯 핵심 원칙

추상화를 별도의 독립적인 패키지가 아니라
클라이언트가 속한 패키지에 포함시켜야 한다.

함께 재사용될 필요가 없는 클래스들은
별도의 독립적인 패키지에 모아야 한다.

의존성 역전 원칙에 따라 상위 수준의 협력 흐름을 재사용하기 위해서는
추상화가 제공하는 인터페이스의 소유권 역시 역전시켜야 한다.

4.5 DIP의 효과

✅ 달성되는 것들

1. 재사용성 향상

// Movie를 다른 프로젝트에서 재사용
// DiscountPolicy도 함께 가져가면 됨
// 구체 클래스들은 필요한 것만 구현

2. 변경에 유연함

// 새로운 정책 추가
public class SeasonDiscountPolicy extends DiscountPolicy {
    // Movie 수정 없이 추가
}

3. 테스트 용이성

@Test
void testMovieFee() {
    // Mock 객체로 쉽게 테스트
    DiscountPolicy mock = mock(DiscountPolicy.class);
    Movie movie = new Movie("Test", Duration.ofMinutes(120), 
                           Money.wons(10000), mock);
}

5. 유연성에 대한 조언

5.1 유연한 설계는 유연성이 필요할 때만 옳다

⚖️ 단순함 vs 유연함

유연한 설계의 대가:

단순함과 명확함을 희생한다.

유연한 설계 = 복잡한 설계

💡 복잡성의 원인

1. 클래스 구조와 객체 구조의 괴리

// 클래스 구조 (코드에 보이는 것)
public class Movie {
    private DiscountPolicy discountPolicy;  // 추상화
}

// 객체 구조 (실행 시 실제 모습)
Movie avatar1AmountDiscountPolicy 인스턴스
Movie avatar2PercentDiscountPolicy 인스턴스
Movie avatar3OverlappedDiscountPolicy 인스턴스코드만 봐서는 실행 구조를   없음!

2. 간접 참조의 복잡성

// 단순한 설계
public class Movie {
    private AmountDiscountPolicy policy;  // 직접 참조
    // 누가 봐도 명확함
}

// 유연한 설계
public class Movie {
    private DiscountPolicy policy;  // 간접 참조
    // 실제 타입은? 실행 시점에 결정됨
    // 클라이언트 코드를 봐야 알 수 있음
}

🎯 유연성의 판단 기준

언제 유연한 설계를 선택할 것인가?

✅ 유연성이 필요한 경우:
   - 요구사항이 자주 변경됨
   - 다양한 컨텍스트에서 재사용 필요
   - 확장이 예상됨

❌ 유연성이 불필요한 경우:
   - 요구사항이 안정적임
   - 특정 컨텍스트에서만 사용
   - 단순성이 더 중요함

💎 설계 원칙

변경은 예상이 아니라 현실이어야 한다.

미래에 변경이 일어날지도 모른다는 막연한 불안감은
불필요하게 복잡한 설계를 낳는다.

아직 일어나지 않은 변경은 변경이 아니다.

5.2 트레이드오프

⚖️ 균형 잡기

단순하고 명확한 설계:

// ✅ 읽기 쉽고 이해하기 쉬움
public class Movie {
    private AmountDiscountPolicy discountPolicy;
    
    public Movie(String title, Duration runningTime, Money fee) {
        this.discountPolicy = new AmountDiscountPolicy(
            Money.wons(800),
            new SequenceCondition(1)
        );
    }
}

장점:
- 코드를 보는 것만으로 동작 파악 가능
- 디버깅 쉬움
- 학습 곡선 낮음

단점:
- 확장 어려움
- 재사용성 낮음

유연하고 복잡한 설계:

// ✅ 확장 가능하고 재사용 가능
public class Movie {
    private DiscountPolicy discountPolicy;
    
    public Movie(String title, Duration runningTime, Money fee,
                 DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

장점:
- 확장 쉬움
- 재사용성 높음
- 테스트 용이

단점:
- 실행 구조를 파악하기 어려움
- 클라이언트 코드를 봐야 이해 가능
- 학습 곡선 높음

💡 의사결정 가이드

단순한 설계를 선택하는 경우:
□ 그런대로 만족스러운 해법이 존재하는가?
□ 요구사항이 안정적인가?
□ 팀원들이 복잡성을 수용하기 어려운가?

유연한 설계를 선택하는 경우:
□ 변경이 예상되는가?
□ 재사용이 필요한가?
□ 복잡성을 감당할 수 있는가?
□ 유연성의 필요성이 복잡성 걱정보다 큰가?

5.3 협력과 책임이 중요하다

🎯 설계의 우선순위

1순위: 역할, 책임, 협력
2순위: 의존성 관리
3순위: 객체 생성 메커니즘

순서를 바꾸면 안 된다!

❌ 초보자의 실수

너무 성급한 객체 생성 고민:

// ❌ 책임 할당 전에 생성부터 고민
public class MovieFactory {
    public Movie create() {
        return new Movie(...);
    }
}

// 문제: Movie의 책임이 명확하지 않은 상태에서
//      생성 메커니즘부터 결정하면
//      불필요한 복잡성 증가

✅ 올바른 설계 순서

Step 1: 협력과 책임 설계

// 먼저 협력을 설계하고 책임을 할당
public class Movie {
    public Money calculateMovieFee(Screening screening) {
        // 어떤 책임을 가질 것인가?
        // 어떤 메시지를 주고받을 것인가?
    }
}

Step 2: 의존성 관리

// 책임이 명확해진 후 의존성 관리
public class Movie {
    private DiscountPolicy discountPolicy;  // 추상화 의존
    
    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

Step 3: 객체 생성

// 마지막으로 생성 책임 할당
public class Factory {
    public Movie createMovie() {
        return new Movie(..., new AmountDiscountPolicy(...));
    }
}

💎 핵심 원칙

"객체가 무엇이 되고 싶을지를 알게 될 때까지
 객체들을 어떻게 인스턴스화할 것인지에 대해
 전혀 신경쓰지 않는다.
 
 마치 객체가 이미 존재하는 것처럼
 이들 간의 관계를 신경 쓰는 일이다."

🎯 설계 체크리스트

□ 역할이 명확한가?
□ 책임이 적절히 분배되었는가?
□ 협력이 자연스러운가?
   ↓
□ 의존성이 추상화를 향하는가?
□ 결합도가 낮은가?
   ↓
□ 객체 생성 책임이 적절히 분리되었는가?
□ FACTORY가 필요한가?

⚠️ 중요한 경고

역할, 책임, 협력의 모습이 선명하게 그려지지 않는다면
의존성을 관리하는 데 들이는 모든 노력이
물거품이 될 수도 있다.

핵심은 협력과 책임이다.
의존성 관리는 그 다음이다.

6. 핵심 정리

🎯 5가지 원칙과 기법

1. 개방-폐쇄 원칙 (OCP)

확장에는 열려 있고, 수정에는 닫혀 있어야 한다.

핵심:
- 컴파일타임 의존성은 고정
- 런타임 의존성은 변경
- 추상화가 핵심

적용:
→ 새로운 기능 추가 시 기존 코드 수정 없음

2. 생성 사용 분리

객체 생성 책임과 사용 책임을 분리하라.

방법:
- 생성 책임을 클라이언트로
- FACTORY 패턴 활용
- PURE FABRICATION 고려

효과:
→ 낮은 결합도, 높은 응집도

3. 의존성 주입 (DI)

외부에서 의존성을 주입하라.

방법:
- 생성자 주입 (권장)
- Setter 주입 (선택적)
- 메서드 주입 (일시적)

피할 것:
→ SERVICE LOCATOR (숨겨진 의존성)

4. 의존성 역전 원칙 (DIP)

상위 수준과 하위 수준 모두 추상화에 의존하라.

핵심:
- 추상화를 상위 수준 패키지에
- 인터페이스 소유권 역전
- SEPARATED INTERFACE 패턴

효과:
→ 상위 수준 재사용 가능

5. 유연성과 단순성의 균형

유연성은 필요할 때만 추구하라.

판단 기준:
- 변경이 현실인가?
- 복잡성을 수용할 수 있는가?
- 협력과 책임이 명확한가?

우선순위:
→ 협력 > 책임 > 의존성 > 생성

📊 원칙들의 관계

               OCP (개방-폐쇄 원칙)
                      ↓
                  추상화가 핵심
                      ↓
        ┌─────────────┴─────────────┐
        ↓                           ↓
   생성 사용 분리                DIP (의존성 역전)
        ↓                           ↓
   FACTORY 패턴                  추상화 재사용
        ↓                           ↓
        └─────────────┬─────────────┘
                      ↓
                 의존성 주입 (DI)
                      ↓
                  명시적 의존성
                      ↓
            유연하고 재사용 가능한 설계

💡 실천 가이드

설계 시작 단계

1. 협력 설계
   "객체들이 어떻게 협력할 것인가?"
   
2. 책임 할당
   "누가 무엇을 알고 있고, 무엇을 할 것인가?"
   
3. 역할 추출
   "대체 가능한 객체들을 역할로 묶을 수 있는가?"

의존성 관리 단계

4. 추상화 도출
   "변하는 것과 변하지 않는 것을 구분했는가?"
   
5. 의존성 방향
   "모두 추상화를 향하는가?"
   
6. 의존성 주입
   "의존성이 명시적인가?"

생성 관리 단계

7. 생성 책임 분리
   "생성과 사용이 분리되었는가?"
   
8. FACTORY 고려
   "PURE FABRICATION이 필요한가?"

🎓 설계 품질 체크리스트

□ 새로운 기능 추가 시 기존 코드를 수정하지 않는가? (OCP)
□ 생성 책임과 사용 책임이 분리되었는가? (생성 사용 분리)
□ 의존성이 퍼블릭 인터페이스에 명시적으로 드러나는가? (DI)
□ 상위 수준과 하위 수준 모두 추상화에 의존하는가? (DIP)
□ 추상화가 클라이언트 패키지에 위치하는가? (SEPARATED INTERFACE)
□ 유연성이 정말 필요한가? (트레이드오프)
□ 협력과 책임이 명확한가? (우선순위)
□ FACTORY가 적절히 사용되었는가? (PURE FABRICATION)

🏆 좋은 설계의 지표

1. 확장 가능성
   - 새로운 클래스 추가만으로 기능 확장
   
2. 재사용성
   - 상위 수준 클래스를 다양한 컨텍스트에서 재사용
   
3. 테스트 용이성
   - Mock 객체로 쉽게 테스트
   
4. 이해 가능성
   - 협력과 책임이 명확
   
5. 유지보수성
   - 변경의 영향이 제한적

💎 마지막 조언

설계의 핵심은 의존성 관리다.

하지만 의존성 관리의 목적은
역할, 책임, 협력의 관점에서
설계가 유연하고 재사용 가능해야 하기 때문이다.

따라서:
1. 먼저 역할, 책임, 협력에 집중하라
2. 그 다음 의존성을 관리하라
3. 마지막에 객체 생성을 고민하라

순서를 바꾸지 마라.

7. 실전 예제

예제 1: 알림 시스템 - OCP 적용

❌ Before: OCP 위반

public class NotificationService {
    public void send(String message, String type) {
        // ❌ 타입에 따라 분기 처리
        if (type.equals("EMAIL")) {
            // 이메일 전송 로직
            EmailSender emailSender = new EmailSender();
            emailSender.sendEmail(message);
        } else if (type.equals("SMS")) {
            // SMS 전송 로직
            SmsSender smsSender = new SmsSender();
            smsSender.sendSms(message);
        } else if (type.equals("PUSH")) {
            // 푸시 알림 로직
            PushSender pushSender = new PushSender();
            pushSender.sendPush(message);
        }
    }
}

문제점:

1. 새로운 알림 타입 추가 시 기존 코드 수정 필요
   - Slack 알림 추가하려면? → if-else 추가
   
2. 컴파일타임 의존성 = 런타임 의존성
   - NotificationService가 모든 구체 클래스에 의존
   
3. OCP 위반
   - 확장을 위해 수정이 필요함

✅ After: OCP 준수

// ✅ 추상화
public interface MessageSender {
    void send(String message);
}

// ✅ 구체 클래스들
public class EmailSender implements MessageSender {
    @Override
    public void send(String message) {
        // 이메일 전송
    }
}

public class SmsSender implements MessageSender {
    @Override
    public void send(String message) {
        // SMS 전송
    }
}

public class PushSender implements MessageSender {
    @Override
    public void send(String message) {
        // 푸시 알림
    }
}

// ✅ 확장: 새로운 타입 추가 시 기존 코드 수정 없음
public class SlackSender implements MessageSender {
    @Override
    public void send(String message) {
        // Slack 전송
    }
}

// ✅ 서비스는 추상화에만 의존
public class NotificationService {
    private MessageSender sender;
    
    public NotificationService(MessageSender sender) {
        this.sender = sender;
    }
    
    public void send(String message) {
        sender.send(message);
    }
}

개선 효과:

측면 Before After
확장성 if-else 추가 필요 새로운 클래스만 추가
컴파일타임 의존성 모든 구체 클래스에 의존 MessageSender에만 의존
런타임 의존성 고정됨 유연하게 변경 가능
테스트 모든 Sender 필요 Mock 객체로 쉽게 테스트

예제 2: 로깅 시스템 - 생성 사용 분리

❌ Before: 생성과 사용이 혼재

public class OrderProcessor {
    private Logger logger;
    
    public void processOrder(Order order) {
        // ❌ 사용하는 곳에서 생성
        this.logger = new FileLogger("/logs/order.log");
        
        logger.log("Processing order: " + order.getId());
        
        // 주문 처리 로직
        order.process();
        
        logger.log("Order processed: " + order.getId());
    }
}

문제점:

1. OrderProcessor가 FileLogger에 강하게 결합
2. 로깅 방식 변경 시 OrderProcessor 수정 필요
3. 테스트 시 실제 파일 시스템 필요
4. 생성과 사용 책임이 혼재

✅ After: FACTORY 패턴으로 분리

// ✅ 추상화
public interface Logger {
    void log(String message);
}

public class FileLogger implements Logger {
    private String filePath;
    
    public FileLogger(String filePath) {
        this.filePath = filePath;
    }
    
    @Override
    public void log(String message) {
        // 파일에 로그 기록
    }
}

public class ConsoleLogger implements Logger {
    @Override
    public void log(String message) {
        System.out.println(message);
    }
}

public class CloudLogger implements Logger {
    @Override
    public void log(String message) {
        // 클라우드에 로그 전송
    }
}

// ✅ FACTORY: 생성 책임 전담
public class LoggerFactory {
    private String environment;
    
    public LoggerFactory(String environment) {
        this.environment = environment;
    }
    
    public Logger createLogger() {
        switch (environment) {
            case "production":
                return new CloudLogger();
            case "development":
                return new ConsoleLogger();
            case "test":
                return new FileLogger("/logs/test.log");
            default:
                return new ConsoleLogger();
        }
    }
}

// ✅ OrderProcessor: 사용만 담당
public class OrderProcessor {
    private Logger logger;
    
    public OrderProcessor(Logger logger) {
        this.logger = logger;
    }
    
    public void processOrder(Order order) {
        logger.log("Processing order: " + order.getId());
        order.process();
        logger.log("Order processed: " + order.getId());
    }
}

// ✅ 조합
public class Application {
    public static void main(String[] args) {
        LoggerFactory factory = new LoggerFactory("production");
        Logger logger = factory.createLogger();
        
        OrderProcessor processor = new OrderProcessor(logger);
        processor.processOrder(new Order());
    }
}

책임 분리 효과:

┌─────────────────┐
│ LoggerFactory   │  ← 생성 전문가
│                 │
│ - createLogger()│
└────────┬────────┘
         │ Logger 인스턴스 생성
         ↓
┌─────────────────┐
│ Application     │  ← 조율자
│                 │
│ - main()        │
└────────┬────────┘
         │ Logger 주입
         ↓
┌─────────────────┐
│ OrderProcessor  │  ← 사용 전문가
│                 │
│ - processOrder()│
└─────────────────┘

각 객체의 책임이 명확하게 분리됨!

예제 3: 인증 시스템 - DI vs SERVICE LOCATOR

❌ SERVICE LOCATOR 패턴

// ❌ SERVICE LOCATOR
public class ServiceLocator {
    private static ServiceLocator instance = new ServiceLocator();
    private AuthenticationProvider authProvider;
    
    public static AuthenticationProvider getAuthProvider() {
        return instance.authProvider;
    }
    
    public static void register(AuthenticationProvider provider) {
        instance.authProvider = provider;
    }
    
    private ServiceLocator() {}
}

// ❌ 숨겨진 의존성
public class LoginController {
    public void login(String username, String password) {
        // ❌ 의존성이 숨겨져 있음
        AuthenticationProvider auth = ServiceLocator.getAuthProvider();
        
        if (auth.authenticate(username, password)) {
            // 로그인 성공
        }
    }
}

// 사용
public class Application {
    public static void main(String[] args) {
        // ❌ 초기화를 잊으면 런타임 에러
        ServiceLocator.register(new JwtAuthProvider());
        
        LoginController controller = new LoginController();
        controller.login("user", "pass");
    }
}

SERVICE LOCATOR의 문제:

@Test
void testLogin() {
    // ❌ 문제 1: 테스트마다 ServiceLocator 상태 공유
    ServiceLocator.register(new MockAuthProvider());
    
    LoginController controller = new LoginController();
    controller.login("test", "test");
    
    // 다른 테스트에 영향을 줄 수 있음!
}

@Test
void testLoginFailure() {
    // ❌ 문제 2: 이전 테스트의 MockAuthProvider가 남아있을 수 있음
    LoginController controller = new LoginController();
    // 예상치 못한 동작 발생 가능
}

✅ 의존성 주입 (DI)

// ✅ 추상화
public interface AuthenticationProvider {
    boolean authenticate(String username, String password);
}

public class JwtAuthProvider implements AuthenticationProvider {
    @Override
    public boolean authenticate(String username, String password) {
        // JWT 인증 로직
        return true;
    }
}

public class OAuthProvider implements AuthenticationProvider {
    @Override
    public boolean authenticate(String username, String password) {
        // OAuth 인증 로직
        return true;
    }
}

// ✅ 명시적인 의존성
public class LoginController {
    private AuthenticationProvider authProvider;
    
    // ✅ 의존성이 퍼블릭 인터페이스에 명시적으로 드러남
    public LoginController(AuthenticationProvider authProvider) {
        this.authProvider = authProvider;
    }
    
    public void login(String username, String password) {
        if (authProvider.authenticate(username, password)) {
            // 로그인 성공
        }
    }
}

// 사용
public class Application {
    public static void main(String[] args) {
        // ✅ 의존성이 명확하게 전달됨
        AuthenticationProvider auth = new JwtAuthProvider();
        LoginController controller = new LoginController(auth);
        
        controller.login("user", "pass");
    }
}

테스트 비교:

// ✅ DI: 테스트가 쉽고 독립적
@Test
void testLoginSuccess() {
    // Mock 객체를 직접 주입
    AuthenticationProvider mockAuth = mock(AuthenticationProvider.class);
    when(mockAuth.authenticate("test", "test")).thenReturn(true);
    
    LoginController controller = new LoginController(mockAuth);
    controller.login("test", "test");
    
    // 검증
    verify(mockAuth).authenticate("test", "test");
}

@Test
void testLoginFailure() {
    // 각 테스트가 독립적
    AuthenticationProvider mockAuth = mock(AuthenticationProvider.class);
    when(mockAuth.authenticate("wrong", "wrong")).thenReturn(false);
    
    LoginController controller = new LoginController(mockAuth);
    controller.login("wrong", "wrong");
    
    // 다른 테스트에 영향 없음
}

비교표:

측면 SERVICE LOCATOR 의존성 주입
의존성 가시성 숨겨짐 명시적
오류 발견 런타임 컴파일타임
테스트 격리 어려움 (상태 공유) 쉬움 (독립적)
이해도 코드 추적 필요 시그니처만 봐도 명확
캡슐화 위반 준수

예제 4: 보고서 생성 - DIP 적용

❌ Before: 상위 수준이 하위 수준에 의존

// ❌ 하위 수준 클래스
public class ExcelReportGenerator {
    public byte[] generate(List<Data> data) {
        // Excel 생성 로직
        return excelBytes;
    }
}

// ❌ 상위 수준이 하위 수준에 직접 의존
public class ReportService {
    private ExcelReportGenerator generator;
    
    public ReportService() {
        // ❌ 구체 클래스에 의존
        this.generator = new ExcelReportGenerator();
    }
    
    public byte[] createReport(List<Data> data) {
        return generator.generate(data);
    }
}

문제점:

패키지 구조:
report/
├── ReportService.java (상위 수준)
└── excel/
    └── ExcelReportGenerator.java (하위 수준)

의존성 방향: ReportService → ExcelReportGenerator

문제:
1. ReportService를 재사용하려면 ExcelReportGenerator 필요
2. PDF 리포트로 변경하려면 ReportService 수정 필요
3. 상위 수준이 하위 수준에 종속됨

✅ After: DIP 적용

// ✅ 추상화를 상위 수준 패키지에 배치
package report;

public interface ReportGenerator {
    byte[] generate(List<Data> data);
}

// ✅ 상위 수준 클래스
package report;

public class ReportService {
    private ReportGenerator generator;
    
    // ✅ 추상화에 의존
    public ReportService(ReportGenerator generator) {
        this.generator = generator;
    }
    
    public byte[] createReport(List<Data> data) {
        return generator.generate(data);
    }
}

// ✅ 하위 수준 클래스들 (별도 패키지)
package report.excel;

public class ExcelReportGenerator implements ReportGenerator {
    @Override
    public byte[] generate(List<Data> data) {
        // Excel 생성
    }
}

package report.pdf;

public class PdfReportGenerator implements ReportGenerator {
    @Override
    public byte[] generate(List<Data> data) {
        // PDF 생성
    }
}

package report.csv;

public class CsvReportGenerator implements ReportGenerator {
    @Override
    public byte[] generate(List<Data> data) {
        // CSV 생성
    }
}

패키지 구조 비교:

Before (DIP 위반):
report/
├── ReportService.java  ─────┐
└── excel/                   │ 의존
    └── ExcelReportGenerator.java ←┘

After (DIP 준수):
report/
├── ReportService.java  ─────┐
├── ReportGenerator.java ←───┤ 모두 추상화에 의존
├── excel/                   │
│   └── ExcelReportGenerator.java ─┘
├── pdf/
│   └── PdfReportGenerator.java
└── csv/
    └── CsvReportGenerator.java

의존성 역전:
- excel 패키지가 report 패키지에 의존
- ReportService 재사용 시 ReportGenerator만 필요
- 구체 클래스들은 선택적으로 포함

사용 예시:

// Excel 리포트
ReportService service1 = new ReportService(
    new ExcelReportGenerator()
);

// PDF 리포트
ReportService service2 = new ReportService(
    new PdfReportGenerator()
);

// CSV 리포트
ReportService service3 = new ReportService(
    new CsvReportGenerator()
);

// ReportService 코드는 전혀 수정하지 않음!

8. 패턴별 실전 가이드

1. OCP 적용 체크리스트

Step 1: OCP 위반 징후 찾기

// 체크리스트if-else 또는 switch 문으로 타입 분기하는가?
□ 새로운 기능 추가  기존 클래스를 수정하는가?
□ 구체 클래스에 직접 의존하는가?

Step 2: 추상화 도출

// 공통점 찾기
변하지 않는 부분: 메시지 전송 행위
변하는 부분: 전송 방법 (Email, SMS, Push, Slack...)

// 추상화 정의
public interface MessageSender {
    void send(String message);  // 공통 인터페이스
}

Step 3: 구체 클래스 분리

// 각 변형을 별도 클래스로
public class EmailSender implements MessageSender { }
public class SmsSender implements MessageSender { }
public class PushSender implements MessageSender { }

Step 4: 클라이언트 수정

// Before
private EmailSender sender = new EmailSender();

// After
private MessageSender sender;  // 추상화에 의존

public Client(MessageSender sender) {
    this.sender = sender;
}

2. 생성 사용 분리 가이드

결정 트리

생성과 사용이 같은 클래스에 있는가?
    ├─ YES → 분리 필요
    │   │
    │   ├─ 생성 지식을 클라이언트가 알아도 되는가?
    │   │   ├─ YES → 클라이언트로 이동
    │   │   └─ NO → FACTORY 도입
    │   │
    │   └─ 도메인 개념으로 표현 가능한가?
    │       ├─ YES → INFORMATION EXPERT 패턴
    │       └─ NO → PURE FABRICATION (Factory)
    │
    └─ NO → 현재 설계 유지

FACTORY 도입 시점

// FACTORY가 필요한 경우생성 로직이 복잡한가?
□ 여러 객체를 조합해야 하는가?
□ 환경에 따라 다른 객체를 생성하는가?
□ 생성 지식을 캡슐화하고 싶은가?

// 예시
public class DatabaseConnectionFactory {
    public Connection create(String environment) {
        if (environment.equals("production")) {
            return new ProductionConnection(/* 복잡한 설정 */);
        } else if (environment.equals("development")) {
            return new DevelopmentConnection(/* 다른 설정 */);
        }
        // 복잡한 생성 로직을 한 곳에 모음
    }
}

3. 의존성 주입 방법 선택

의사결정 플로우

의존성이 필수인가?
    ├─ YES → 생성자 주입 사용
    │   │
    │   └─ 런타임에 변경이 필요한가?
    │       ├─ YES → 생성자 + Setter 조합
    │       └─ NO → 생성자만 사용
    │
    └─ NO → 선택적 의존성
        │
        ├─ 객체 생명주기 내내 필요한가?
        │   ├─ YES → Setter 주입
        │   └─ NO → 메서드 주입
        │
        └─ 한두 개 메서드에만 필요한가?
            └─ YES → 메서드 주입

예제 코드

public class EmailService {
    // 필수 의존성
    private final MessageFormatter formatter;
    
    // 선택적 의존성 (기본값 제공)
    private Logger logger = new NullLogger();
    
    // ✅ 생성자: 필수 의존성
    public EmailService(MessageFormatter formatter) {
        this.formatter = formatter;
    }
    
    // ✅ Setter: 선택적 의존성
    public void setLogger(Logger logger) {
        this.logger = logger;
    }
    
    // ✅ 메서드 주입: 일시적 의존성
    public void send(String message, EmailValidator validator) {
        if (validator.isValid(message)) {
            String formatted = formatter.format(message);
            logger.log("Sending: " + formatted);
            // 전송 로직
        }
    }
}

4. DIP 적용 가이드

Step 1: 상위/하위 수준 식별

질문:
□ 이 클래스는 비즈니스 정책을 담당하는가? → 상위 수준
□ 이 클래스는 구현 세부사항을 담당하는가? → 하위 수준

예시:
OrderService (상위) - 주문 처리 정책
EmailSender (하위) - 이메일 전송 구현

Step 2: 의존성 방향 확인

// ❌ 잘못된 의존성 방향
public class OrderService {
    private EmailSender sender;  // 상위 → 하위
}

// ✅ 올바른 의존성 방향
public class OrderService {
    private NotificationSender sender;  // 상위 → 추상화
}

public class EmailSender implements NotificationSender {
    // 하위 → 추상화
}

Step 3: 패키지 구조 조정

Before:
service/
├── OrderService.java
└── notification/
    ├── NotificationSender.java
    └── EmailSender.java

After (SEPARATED INTERFACE):
service/
├── OrderService.java
├── NotificationSender.java  ← 상위 패키지로 이동
└── notification/
    └── EmailSender.java  ← 하위 패키지

5. 유연성 판단 기준

유연한 설계가 필요한 경우

✅ 다음 중 하나라도 해당하면 유연한 설계 고려:

1. 요구사항 변경이 잦은가?
   - 주기: 월 1회 이상

2. 다양한 컨텍스트에서 재사용되는가?
   - 3개 이상의 다른 프로젝트/모듈에서 사용

3. 런타임에 동작을 변경해야 하는가?
   - 설정에 따라 다른 동작

4. 테스트 가능성이 중요한가?
   - 단위 테스트 커버리지 > 80%

단순한 설계로 충분한 경우

✅ 다음의 경우 단순한 설계 유지:

1. 요구사항이 안정적인가?
   - 1년 이상 변경 없음

2. 특정 컨텍스트에서만 사용되는가?
   - 한 프로젝트 내에서만 사용

3. 팀의 숙련도가 낮은가?
   - 주니어 개발자 비율 > 50%

4. 복잡성 비용이 이익보다 큰가?
   - 코드 이해/유지보수 어려움

트레이드오프 평가표

기준 단순한 설계 유연한 설계
이해도 ⭐⭐⭐⭐⭐ ⭐⭐
변경 용이성 ⭐⭐ ⭐⭐⭐⭐⭐
초기 개발 속도 ⭐⭐⭐⭐⭐ ⭐⭐⭐
장기 유지보수 ⭐⭐ ⭐⭐⭐⭐
테스트 용이성 ⭐⭐⭐ ⭐⭐⭐⭐⭐
재사용성 ⭐⭐ ⭐⭐⭐⭐⭐

🔗 연결고리

이전 장과의 연결

  • Chapter 08: 의존성 관리 기법 학습 → 원칙으로 승화
  • 컴파일타임/런타임 의존성 → OCP의 핵심
  • 생성과 사용 분리 → FACTORY 패턴

다음 장 예고

  • Chapter 10: 상속과 코드 재사용
    • 상속의 문제점
    • 합성의 장점
    • 믹스인