『오브젝트』 의존성 관리하기(chap 8)

KwonMoYang·2025년 6월 11일

💡 "설계란 의존성을 관리하는 것이고, 객체지향 설계란 객체 간의 의존성을 관리하는 것이다"

📚 들어가며: 왜 의존성 관리인가?

협력하는 객체들의 공동체를 구축하기 위해서는 객체들이 서로 의존해야 합니다. 하지만 과도한 의존성은 변경을 어렵게 만들죠. 따라서 필요한 의존성은 유지하되, 변경을 방해하는 의존성은 제거해야 합니다.

// 의존성이 없다면? - 협력 불가능
public class Movie {
    // 할인 정책 없이는 영화 예매 시스템이 작동하지 않음
}

// 잘못된 의존성이라면? - 변경 어려움
public class Movie {
    private AmountDiscountPolicy discountPolicy = new AmountDiscountPolicy();
    // 정책을 바꾸려면 Movie 코드를 수정해야 함
}

// 올바른 의존성이라면? - 협력 가능하면서도 유연함
public class Movie {
    private DiscountPolicy discountPolicy;
    
    public Movie(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

🎯 의존성 이해하기

의존성이란?

의존성은 실행 시점과 구현 시점에 서로 다른 의미를 가진다

public class PeriodCondition implements DiscountCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;
    
    public boolean isSatisfiedBy(Screening screening) {
        return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
               startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
               endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
    }
}

위 코드에서 PeriodCondition은 다음에 의존합니다:

  • DayOfWeek - 요일 정보
  • LocalTime - 시간 정보
  • Screening - 상영 정보
  • DiscountCondition - 인터페이스

의존성의 종류

1. 클래스 의존성

// 직접적인 클래스 참조
public class Movie {
    private AmountDiscountPolicy discountPolicy;  // 구체 클래스에 의존
}

2. 인터페이스 의존성

// 추상화에 의존
public class Movie {
    private DiscountPolicy discountPolicy;  // 인터페이스에 의존
}

3. 파라미터 의존성

public Money calculateMovieFee(Screening screening) {
    // screening에 일시적으로 의존
    return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}

4. 로컬 변수 의존성

public void someMethod() {
    DiscountPolicy policy = new AmountDiscountPolicy(...);  // 로컬 변수로 의존
}

5. 정적 변수/메서드 의존성

public boolean isSatisfiedBy(Screening screening) {
    return DayOfWeek.SATURDAY.equals(screening.getWhenScreened().getDayOfWeek());
    // DayOfWeek 클래스의 정적 변수에 의존
}

UML과 의존성

// UML 표기법
Movie -----> DiscountPolicy    // 연관관계 (인스턴스 변수)
Movie - - -> Screening         // 의존관계 (파라미터, 로컬 변수)
Movie --|> DiscountPolicy      // 실체화 관계 (인터페이스 구현)
RegularEmployee --|> Employee  // 일반화 관계 (상속)

🔄 런타임 의존성과 컴파일타임 의존성

두 가지 관점의 의존성

// 컴파일타임 의존성 - 코드 상의 의존성
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(Money.wons(800), ...));  // 실제 객체

Movie starWars = new Movie("스타워즈", 
    Duration.ofMinutes(210), 
    Money.wons(10000),
    new PercentDiscountPolicy(0.1, ...));  // 다른 객체

의존성의 양면성

// 컴파일타임 - Movie는 DiscountPolicy만 안다
public class Movie {
    private DiscountPolicy discountPolicy;
}

// 런타임 - 실제로는 구체적인 객체들과 협력
Movie ---> AmountDiscountPolicy (avatar)
Movie ---> PercentDiscountPolicy (starWars)
Movie ---> NoneDiscountPolicy (specialMovie)

🌟 핵심: 유연한 설계를 위해서는 컴파일타임 의존성과 런타임 의존성이 달라야 한다!


🎨 컨텍스트 독립성

컨텍스트란?

객체가 사용되는 특정한 문맥을 의미합니다.

// 컨텍스트에 강하게 결합된 설계 - 나쁜 예
public class Movie {
    private DiscountPolicy discountPolicy;
    
    public Movie(String title, Duration runningTime, Money fee) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        // 특정 컨텍스트(10% 할인)에 결합
        this.discountPolicy = new PercentDiscountPolicy(0.1, 
            new PeriodCondition(DayOfWeek.MONDAY, ...),
            new SequenceCondition(10));
    }
}

컨텍스트 독립적인 설계

// 컨텍스트 독립적인 설계 - 좋은 예
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 movie1 = new Movie("영화1", duration, fee, new AmountDiscountPolicy(...));
Movie movie2 = new Movie("영화2", duration, fee, new PercentDiscountPolicy(...));
Movie movie3 = new Movie("영화3", duration, fee, new OverlappedDiscountPolicy(...));

🔧 의존성 해결하기

의존성 해결의 세 가지 방법

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(...));

장점:

  • 객체 생성 시점에 모든 의존성을 해결
  • 완전한 상태의 객체 생성 보장
  • 의존성 불변성 보장 가능

2. setter 주입 (Setter Injection)

public class Movie {
    private DiscountPolicy discountPolicy;
    
    // setter를 통해 의존성 주입
    public void setDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
    
    public Money calculateMovieFee(Screening screening) {
        if (discountPolicy == null) {
            throw new IllegalStateException("discountPolicy가 설정되지 않았습니다");
        }
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

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

장점:

  • 의존성 선택적 주입 가능
  • 런타임에 의존성 변경 가능

단점:

  • 불완전한 객체 생성 가능
  • NPE 위험

3. 메서드 파라미터 주입 (Method Parameter Injection)

public class Movie {
    // 의존성을 저장하지 않고 메서드 실행 시 전달
    public Money calculateMovieFee(Screening screening, DiscountPolicy discountPolicy) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

// 사용
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000));
Money fee = avatar.calculateMovieFee(screening, new AmountDiscountPolicy(...));

장점:

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

혼합 사용 예제

public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private DiscountPolicy discountPolicy;
    private List<Review> reviews = new ArrayList<>();
    
    // 필수 의존성은 생성자로
    public Movie(String title, Duration runningTime, Money fee) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountPolicy = new NoneDiscountPolicy();  // 기본값
    }
    
    // 선택적 의존성은 setter로
    public void changeDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
    
    // 일시적 의존성은 메서드 파라미터로
    public void addReview(Review review, ReviewValidator validator) {
        if (validator.validate(review)) {
            reviews.add(review);
        }
    }
}

🏗️ 유연한 설계

의존성과 결합도

바람직한 의존성 vs 바람직하지 못한 의존성

// 바람직하지 못한 의존성 - 구체 클래스에 의존
public class Movie {
    private AmountDiscountPolicy discountPolicy;  // 결합도 높음
}

// 바람직한 의존성 - 추상화에 의존
public class Movie {
    private DiscountPolicy discountPolicy;  // 결합도 낮음
}

지식이 결합을 낳는다

// 너무 많이 아는 경우 - 높은 결합도
public class Movie {
    private AmountDiscountPolicy discountPolicy;
    
    public Movie(Money discountAmount, DiscountCondition... conditions) {
        // Movie가 AmountDiscountPolicy의 생성자 인자까지 알아야 함
        this.discountPolicy = new AmountDiscountPolicy(discountAmount, conditions);
    }
}

// 적게 아는 경우 - 낮은 결합도
public class Movie {
    private DiscountPolicy discountPolicy;
    
    public Movie(DiscountPolicy discountPolicy) {
        // Movie는 DiscountPolicy 인터페이스만 알면 됨
        this.discountPolicy = discountPolicy;
    }
}

추상화에 의존하라

추상화 수준에 따른 결합도

// 1. 구체 클래스 의존 - 가장 높은 결합도
class Movie {
    private AmountDiscountPolicy discountPolicy;
}

// 2. 추상 클래스 의존 - 중간 결합도
abstract class DiscountPolicy {
    private List<DiscountCondition> conditions;
    
    public Money calculateDiscountAmount(Screening screening) {
        for(DiscountCondition condition : conditions) {
            if (condition.isSatisfiedBy(screening)) {
                return getDiscountAmount(screening);
            }
        }
        return Money.ZERO;
    }
    
    abstract protected Money getDiscountAmount(Screening screening);
}

// 3. 인터페이스 의존 - 가장 낮은 결합도
interface DiscountPolicy {
    Money calculateDiscountAmount(Screening screening);
}

💡 명시적인 의존성

숨겨진 의존성의 문제

// 숨겨진 의존성 - 나쁜 예
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)
        );
    }
}

문제점들:

  1. 의존성 파악 어려움
// Movie를 생성할 때 AmountDiscountPolicy에 의존한다는 사실을 알 수 없음
Movie movie = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000));
// 어? 할인 정책은 어떻게 되는거지?
  1. 테스트 어려움
@Test
public void 할인정책_테스트() {
    // 특정 할인 정책으로 테스트하고 싶은데...
    Movie movie = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000));
    // AmountDiscountPolicy로 고정되어 있어서 테스트 불가능!
}
  1. 재사용성 저하
// 다른 할인 정책을 사용하고 싶어도 불가능
Movie movie1 = new Movie(...);  // 무조건 AmountDiscountPolicy
Movie movie2 = new Movie(...);  // 이것도 AmountDiscountPolicy
// PercentDiscountPolicy를 쓰고 싶다면? Movie 클래스 수정 필요

명시적인 의존성으로 개선

// 명시적인 의존성 - 좋은 예
public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    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 movie1 = new Movie("아바타", duration, fee, new AmountDiscountPolicy(...));
Movie movie2 = new Movie("스타워즈", duration, fee, new PercentDiscountPolicy(...));

🚫 new는 해롭다

new가 해로운 이유

1. 구체 클래스 이름을 직접 사용

public class Movie {
    private DiscountPolicy discountPolicy;
    
    public Movie(...) {
        // new는 구체 클래스 이름을 직접 사용하게 만든다
        this.discountPolicy = new AmountDiscountPolicy(...);
        //                         ^^^^^^^^^^^^^^^^^^^^ 구체 클래스!
    }
}

2. 구체 클래스의 생성자 인자까지 알아야 함

public class Movie {
    public Movie(...) {
        // AmountDiscountPolicy의 모든 생성자 인자를 알아야 함
        this.discountPolicy = new AmountDiscountPolicy(
            Money.wons(800),                    // 할인 금액
            new SequenceCondition(1),           // 조건 1
            new SequenceCondition(10),          // 조건 2
            new PeriodCondition(...)            // 조건 3
        );
    }
}

해결 방법: 사용과 생성의 분리

// Client - 사용하는 책임
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));
    }
}

// Factory - 생성하는 책임
public class MovieFactory {
    public Movie createAvatarMovie() {
        return new Movie(
            "아바타",
            Duration.ofMinutes(120),
            Money.wons(10000),
            // 생성은 Factory가 담당
            new AmountDiscountPolicy(
                Money.wons(800),
                new SequenceCondition(1),
                new SequenceCondition(10)
            )
        );
    }
}

✅ 가끔은 생성해도 무방하다

표준 클래스에 대한 의존

public class Movie {
    private List<Review> reviews;  // 표준 클래스
    private Money fee;             // 값 객체
    
    public Movie(...) {
        this.reviews = new ArrayList<>();  // OK! 표준 클래스
        this.fee = Money.ZERO;            // OK! 불변 값 객체
    }
    
    public LocalDateTime getScreeningTime() {
        return LocalDateTime.now();       // OK! 표준 클래스
    }
}

안정적인 클래스에 대한 의존

// 변경 가능성이 없는 클래스들
public class Order {
    private Long id;
    private List<OrderItem> items = new ArrayList<>();
    private Money totalAmount = Money.ZERO;
    private OrderStatus status = OrderStatus.PENDING;  // Enum
    
    public void addItem(Product product, int quantity) {
        // 값 객체는 직접 생성해도 무방
        OrderItem item = new OrderItem(product, quantity);
        items.add(item);
        totalAmount = totalAmount.plus(item.getAmount());
    }
}

표준 클래스를 확장한 경우

// ArrayList를 상속한 경우 - 이제는 표준 클래스가 아님!
public class DiscountConditions extends ArrayList<DiscountCondition> {
    // 커스텀 동작 추가
}

// 이런 경우는 추상화해야 함
public class Movie {
    // private DiscountConditions conditions;  // 구체 클래스 의존 X
    private List<DiscountCondition> conditions;  // 인터페이스 의존 O
}

🔄 의존성 역전 원칙 (DIP)

전통적인 의존성 방향

// 상위 수준 모듈이 하위 수준 모듈에 의존
// Movie(상위) → AmountDiscountPolicy(하위)

public class Movie {  // 상위 수준 (비즈니스 로직)
    private AmountDiscountPolicy discountPolicy;  // 하위 수준 (구체적 구현)
}

의존성 역전

// Movie(상위) → DiscountPolicy(추상) ← AmountDiscountPolicy(하위)

// 추상화 (상위 수준 모듈이 소유)
public interface DiscountPolicy {
    Money calculateDiscountAmount(Screening screening);
}

// 상위 수준 모듈
public class Movie {
    private DiscountPolicy discountPolicy;  // 추상화에 의존
}

// 하위 수준 모듈
public class AmountDiscountPolicy implements DiscountPolicy {
    // 추상화에 의존
}

public class PercentDiscountPolicy implements DiscountPolicy {
    // 추상화에 의존
}

패키지 구조로 보는 DIP

// 잘못된 구조 - 순환 의존성
com.theater.movie
    ├── Movie.java
    └── discount
        ├── DiscountPolicy.java
        ├── AmountDiscountPolicy.java
        └── PercentDiscountPolicy.java

// 올바른 구조 - 의존성 역전
com.theater.domain  // 상위 수준
    ├── Movie.java
    └── DiscountPolicy.java  // 인터페이스는 상위 수준에 위치

com.theater.infrastructure.discount  // 하위 수준
    ├── AmountDiscountPolicy.java
    └── PercentDiscountPolicy.java

🔥 실전 예제: 의존성 관리 리팩토링

Step 1: 문제가 있는 설계

// 강한 결합, 숨겨진 의존성
public class OrderService {
    private MySQLOrderRepository orderRepository;
    private EmailNotificationService notificationService;
    
    public OrderService() {
        // new의 남용, 구체 클래스 의존
        this.orderRepository = new MySQLOrderRepository(
            "localhost", 3306, "user", "password"
        );
        this.notificationService = new EmailNotificationService(
            "smtp.gmail.com", 587
        );
    }
    
    public void placeOrder(Order order) {
        // 구체적인 구현에 의존
        orderRepository.save(order);
        notificationService.sendEmail(
            order.getCustomerEmail(),
            "주문 완료",
            "주문이 완료되었습니다."
        );
    }
}

Step 2: 추상화 도입

// 인터페이스 정의
public interface OrderRepository {
    void save(Order order);
    Order findById(Long id);
    List<Order> findByCustomer(Customer customer);
}

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

// 구현체들
public class MySQLOrderRepository implements OrderRepository {
    private final DataSource dataSource;
    
    public MySQLOrderRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    // 구현...
}

public class MongoOrderRepository implements OrderRepository {
    private final MongoClient mongoClient;
    
    public MongoOrderRepository(MongoClient mongoClient) {
        this.mongoClient = mongoClient;
    }
    // 구현...
}

Step 3: 의존성 주입

// 명시적 의존성, 생성자 주입
public class OrderService {
    private final OrderRepository orderRepository;
    private final NotificationService notificationService;
    
    public OrderService(OrderRepository orderRepository, 
                       NotificationService notificationService) {
        this.orderRepository = orderRepository;
        this.notificationService = notificationService;
    }
    
    public void placeOrder(Order order) {
        orderRepository.save(order);
        notificationService.notify(
            order.getCustomerEmail(),
            "주문 완료",
            "주문이 완료되었습니다."
        );
    }
}

Step 4: 설정과 사용의 분리

// 설정 (Configuration)
@Configuration
public class AppConfig {
    
    @Bean
    public OrderRepository orderRepository(DataSource dataSource) {
        return new MySQLOrderRepository(dataSource);
        // 또는 프로파일에 따라
        // return new MongoOrderRepository(mongoClient);
    }
    
    @Bean
    public NotificationService notificationService() {
        if (isProduction()) {
            return new EmailNotificationService(emailConfig());
        } else {
            return new MockNotificationService();  // 테스트용
        }
    }
    
    @Bean
    public OrderService orderService(OrderRepository orderRepository,
                                   NotificationService notificationService) {
        return new OrderService(orderRepository, notificationService);
    }
}

// 사용
public class OrderController {
    private final OrderService orderService;
    
    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }
    
    @PostMapping("/orders")
    public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
        Order order = new Order(request);
        orderService.placeOrder(order);
        return ResponseEntity.ok(order);
    }
}

Step 5: 테스트 가능한 설계

@Test
class OrderServiceTest {
    
    @Test
    void 주문_생성_시_알림을_보낸다() {
        // Given - Mock 객체로 쉽게 교체
        OrderRepository mockRepository = mock(OrderRepository.class);
        NotificationService mockNotification = mock(NotificationService.class);
        
        OrderService orderService = new OrderService(
            mockRepository, 
            mockNotification
        );
        
        Order order = new Order(/* ... */);
        
        // When
        orderService.placeOrder(order);
        
        // Then
        verify(mockRepository).save(order);
        verify(mockNotification).notify(
            eq(order.getCustomerEmail()),
            eq("주문 완료"),
            anyString()
        );
    }
}

🎯 핵심 정리와 실무 가이드

의존성 관리 원칙

  1. 추상화에 의존하라

    • 인터페이스 > 추상 클래스 > 구체 클래스
  2. 명시적으로 의존하라

    • 숨겨진 의존성은 이해하기 어렵고 테스트하기 어렵다
  3. 생성과 사용을 분리하라

    • 객체를 생성하는 책임과 사용하는 책임을 분리
  4. 의존성 방향을 관리하라

    • 상위 수준 정책이 하위 수준 구현에 의존하지 않도록

실무 체크리스트

// ❌ 피해야 할 패턴
public class BadExample {
    private ConcreteClass dependency = new ConcreteClass();  // 1. new 직접 사용
    private HiddenDependency hidden;                         // 2. 숨겨진 의존성
    
    public BadExample() {
        hidden = ServiceLocator.getDependency();             // 3. 서비스 로케이터
    }
}

// ✅ 권장하는 패턴
public class GoodExample {
    private final AbstractType dependency;                   // 1. 추상화 의존
    
    public GoodExample(AbstractType dependency) {            // 2. 명시적 주입
        this.dependency = dependency;                        // 3. 생성자 주입
    }
}

의존성 주입 프레임워크 활용

// Spring을 활용한 의존성 관리
@Component
public class PaymentService {
    private final PaymentGateway paymentGateway;
    private final OrderRepository orderRepository;
    private final NotificationService notificationService;
    
    // Spring이 자동으로 의존성 주입
    public PaymentService(PaymentGateway paymentGateway,
                         OrderRepository orderRepository,
                         NotificationService notificationService) {
        this.paymentGateway = paymentGateway;
        this.orderRepository = orderRepository;
        this.notificationService = notificationService;
    }
}

💭 정리

Chapter 8을 공부하면서 느낀 점은, 의존성 관리가 단순히 "결합도를 낮추는 것"이 아니라 "협력을 가능하게 하면서도 변경을 수용할 수 있게 하는 것" 이라는 점입니다.

마치 레고 블록처럼, 각 객체는 표준화된 인터페이스(블록의 돌기와 홈)를 통해 연결되어야 합니다. 구체적인 모양이나 색깔(구현)은 다를 수 있지만, 연결 방식(인터페이스)은 동일해야 하죠.

특히 스프링 같은 DI 프레임워크가 왜 필요한지, 왜 @Autowired보다 생성자 주입을 권장하는지, 왜 인터페이스를 사용해야 하는지가 명확해집니다.

🚀 핵심: 의존성은 협력을 위해 필요하지만, 올바르게 관리하지 않으면 변경의 족쇄가 된다. 추상화에 의존하고, 명시적으로 표현하며, 방향을 관리하라.

profile
Dot Your moment.

0개의 댓글