[오브젝트] 의존성과 자율성에 대한 고찰(chap1)

KwonMoYang·2025년 5월 18일

의존성과 자율성에 대한 고찰

최근 조영호 저자의 책 '오브젝트'의 1장을 읽고 객체지향 프로그래밍에 대한 깊은 통찰을 얻었습니다. 특히 객체 간의 자율성, 협력 관계, 그리고 의존성 관리에 대한 개념들이 매우 인상적이었습니다. 이 글에서는 책의 내용을 바탕으로 객체지향의 핵심 개념들을 정리하고, 실무에 적용할 수 있는 방법에 대해 기록하고자 합니다.

1. 소극장 예제로 알아보는 객체지향 설계

'오브젝트' 책의 1장에서는 소극장 운영 이벤트라는 예제를 통해 객체지향 설계의 원칙을 설명합니다. 이 예제를 통해 잘못된 설계와 개선된 설계를 비교해 보겠습니다.

요구사항

  1. 이벤트 추첨을 통해 선정된 관람객에게 공연을 무료로 관람할 수 있는 초대장을 발송
  2. 이벤트에 당첨된 관람객과 그렇지 못한 관람객은 다른 방식으로 입장
    • 당첨자: 초대장을 티켓으로 교환 후 입장
    • 비당첨자: 티켓 구매 후 입장

도메인 설계

이 요구사항에 대한 도메인 모델은 다음과 같이 구성할 수 있습니다:

  • 소극장(Theater): 애그리거트의 루트 엔티티
  • 관객(Audience): 엔티티 (고유 식별자 존재, 상태가 변해도 동일한 객체로 인식)
  • 판매원(TicketSeller): 엔티티
  • 초대장(Invitation): 값 객체(VO) (식별자 없음, 필드가 같으면 서로 동일)
  • 티켓(Ticket): 값 객체(VO)
  • 가방(Bag): 컬렉션 VO (여러 VO나 엔티티를 담고 있는 컨테이너)

문제가 있는 설계

초기 설계에서는 다음과 같은 문제점이 있었습니다:

public class Theater {
    private TicketSeller ticketSeller;
    
    public void enter(Audience audience) {
        if (audience.getBag().hasInvitation()) {
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().setTicket(ticket);
        } else {
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}

이 설계의 문제점:

  1. 의존성 과다: Theater가 너무 많은 객체의 내부 구조에 의존하고 있습니다.
  2. 캡슐화 위반: 객체의 세부 구현이 외부에 노출되어 있습니다.
  3. 자율성 부족: 각 객체가 자신의 책임을 수행하지 않고, Theater가 모든 작업을 처리합니다.
  4. 현실과의 괴리: 실제 극장에서는 관람객이 스스로 티켓을 구매하지만, 이 코드에서는 극장이 관람객의 가방을 뒤지는 비현실적인 상황이 발생합니다.

문제 있는 설계의 UML 다이어그램

위 UML 다이어그램에서 볼 수 있듯이, Theater 클래스는 거의 모든 객체에 직접적으로 의존하고 있습니다. 특히 점선으로 표시된 의존관계(매개변수를 통한 의존)가 Theater에서 여러 객체로 뻗어나가고 있어, Theater가 많은 객체의 내부 구현을 알고 있어야 함을 보여줍니다.

객체 간 의존성 분석

생성자를 보면 떠오르는 점: 이 객체가 생겨날 때 무조건 필요로 하는 (의존하는) 개념
- 관객은 가방없이 생겨날 수 없음
- 극장은 판매원 없이 생겨날 수 없음

멤버변수를 보면 떠오르는 것:
- 가방은 초대장, 티켓, 현금을 지니고 있음
- 극장은 판매원을 알고 있음
- 판매원은 티켓 오피스를 알고 있음

의존관계 종류:
- Audience는 함수의 매개변수를 통한 의존: Theater의 enter 메서드가 실행될 때만 일시적으로 관계가 생김
- TicketSeller는 멤버변수를 통한 의존: 극장은 항상 판매원의 존재를 알고 있음

이러한 의존성 구조는 객체지향 설계의 핵심 원칙인 캡슐화와 자율성을 위반하고 있습니다. Theater가 너무 많은 객체의 내부 구현을 알고 있어야 하므로, 어떤 객체의 구현이 변경되면 Theater도 함께 변경되어야 하는 높은 결합도를 가지게 됩니다.

개선된 설계

이러한 문제점을 해결하기 위해 각 객체에게 적절한 책임을 부여하고 자율성을 높인 설계로 변경합니다:

public class Theater {
    private TicketSeller ticketSeller;
    
    public void enter(Audience audience) {
        ticketSeller.sellTo(audience);
    }
}

public class TicketSeller {
    private TicketOffice ticketOffice;
    
    public void sellTo(Audience audience) {
        ticketOffice.sellTicketTo(audience);
    }
}

public class Audience {
    private Bag bag;
    
    public Long buy(Ticket ticket) {
        return bag.hold(ticket);
    }
}

public class Bag {
    private Long amount;
    private Invitation invitation;
    private Ticket ticket;
    
    public Long hold(Ticket ticket) {
        if (hasInvitation()) {
            setTicket(ticket);
            return 0L;
        } else {
            setTicket(ticket);
            minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }
}

개선된 설계의 이점:

  1. 캡슐화 향상: 객체의 내부 구현이 외부로부터 숨겨져 있습니다.
  2. 의존성 감소: 각 객체는 필요한 최소한의 정보만 알고 있습니다.
  3. 자율성 증가: 각 객체가 자신의 책임을 스스로 수행합니다.
  4. 현실과의 일치: 객체 간의 상호작용이 현실세계의 모델과 더 유사해졌습니다.

개선된 설계의 UML 다이어그램

개선된 UML 다이어그램에서는 각 객체가 자신의 책임을 수행하며, 상위 객체가 하위 객체의 내부 구현에 직접 접근하지 않습니다. Theater는 오직 TicketSeller에만 의존하고, 나머지 객체들과의 직접적인 의존 관계가 제거되었습니다. 이는 실선(강한 연결)과 점선(약한 의존)을 적절히 사용하여 객체 간의 관계를 더 명확하게 표현합니다.

개선된 의존성 분석

// 극장 클래스
public class Theater {
    private TicketSeller ticketSeller; // 멤버변수를 통한 의존: 판매원은 극장의 필수적인 부분
    
    // 생성자를 통한 의존관계 설정 - 극장은 판매원 없이 존재할 수 없음
    public Theater(TicketSeller ticketSeller) {
        this.ticketSeller = ticketSeller;
    }
    
    public void enter(Audience audience) { // 메서드 파라미터를 통한 의존: 일시적 관계
        ticketSeller.sellTo(audience);
    }
}

개선 후 의존성의 특징:
1. 생성자를 통한 의존: 객체가 생성될 때 반드시 필요한 협력 객체를 명시합니다. 이는 강한 결합을 의미하지만, 필수적인 관계를 명확히 합니다.
2. 메서드 파라미터를 통한 의존: 일시적이고 약한 결합을 의미합니다. 메서드가 실행되는 동안만 관계가 유지됩니다.
3. 책임의 분산: 각 객체가 자신의 상태를 스스로 관리하며, 다른 객체의 내부 구현에 간섭하지 않습니다.

이러한 의존성 관리 방식은 코드 변경에 더 유연하게 대응할 수 있게 하며, 객체지향 설계의 본질인 "자율적인 객체들의 협력"을 실현합니다.

2. 객체지향 설계의 핵심 원칙

소극장 예제를 통해 배운 객체지향 설계의 핵심 원칙은 다음과 같습니다:

캡슐화: 객체의 자율성을 높이는 방법

개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 행위

캡슐화를 통해 객체의 내부 구현을 외부로부터 감추면 다음과 같은 이점이 있습니다:

  • 객체의 자율성 증가
  • 변경에 대한 영향 최소화
  • 코드의 이해도 향상

결합도: 변경의 영향을 최소화하는 방법

다른 클래스가 나를 많이 알고 있을수록 의존성이 커진다

  • 의존성은 변경에 대한 영향을 암시합니다.
  • 의존성을 완전히 없애는 것은 불가능하지만, 합리적인 수준으로 유지하는 것이 중요합니다.
  • 두 객체 간의 결합도가 낮을수록 변경에 용이합니다.

의인화: 객체에 생명을 불어넣는 방법

비록 현실에서는 수동적인 존재라고 하더라도 객체지향의 세계에서는 모든 것이 능동적이고 자율적인 존재로 바뀐다

  • 객체를 능동적이고 자율적인 존재로 설계하는 것이 의인화의 원칙입니다.
  • 이를 통해 객체 간의 협력 관계가 더 자연스럽고 이해하기 쉬워집니다.

3. 모듈 설계의 핵심 조건

좋은 모듈(클래스, 패키지, 라이브러리 등)이 갖춰야 할 필요충분조건은 다음과 같습니다:

  1. 정상 작동: 기본적으로 요구사항을 충족해야 합니다.
  2. 변경 용이: 새로운 요구사항이나 변경사항에 쉽게 대응할 수 있어야 합니다.
  3. 이해하기 쉬움: 다른 개발자가 코드를 보고 쉽게 이해할 수 있어야 합니다.

4. 실무에서의 적용

'오브젝트' 책의 내용을 실무에 적용할 때 고려해야 할 점은 다음과 같습니다:

스프링 프레임워크에서의 의존성 관리

스프링은 의존성 주입(DI)을 통해 객체 간의 결합도를 낮추는 데 도움을 줍니다.

@Service
public class OrderService {
    private final PaymentService paymentService;
    
    @Autowired
    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
    
    public void processOrder(Order order) {
        // 주문 처리 로직
        paymentService.processPayment(order);
    }
}

이 코드에서 OrderService는 PaymentService의 구현체가 아닌 인터페이스에 의존하므로, 실제 구현체가 변경되어도 OrderService의 코드는 변경되지 않습니다.

의존성 분석의 실무적 접근 방법

실무에서 클래스 설계 시 다음과 같은 의존성 분석 접근법을 활용할 수 있습니다:

  1. 생성자 분석:

    • 객체가 생성될 때 반드시 필요한 의존성인가?
    • 예: public OrderService(PaymentService paymentService)는 OrderService가 PaymentService 없이는 존재할 수 없음을 의미
  2. 멤버변수 분석:

    • 객체가 지속적으로 유지해야 하는 상태나 관계인가?
    • 예: private final PaymentService paymentService는 OrderService가 PaymentService와 지속적인 관계를 유지함을 의미
  3. 메서드 매개변수 분석:

    • 일시적인 협력 관계인가?
    • 예: public void process(Order order)는 OrderService가 Order와 일시적인 관계만 가짐을 의미

이러한 분석을 통해 얻은 인사이트를 바탕으로 다음과 같은 결정을 내릴 수 있습니다:

  • 필수적인 의존성은 생성자를 통해 주입
  • 선택적인 의존성은 setter 메서드나 메서드 매개변수로 처리
  • 객체 간의 결합 강도를 적절히 조절하여 변경에 유연하게 대응

객체 설계 시 고려사항

  1. 책임 분배: 각 객체가 적절한 책임을 갖도록 설계합니다.
  2. 캡슐화 강화: getter/setter를 무분별하게 사용하지 않고, 필요한 경우에만 제공합니다.
  3. 의존성 최소화: 인터페이스를 통한 의존성 주입으로 결합도를 낮춥니다.
  4. 테스트 용이성: 각 객체가 독립적으로 테스트 가능하도록 설계합니다.

5. 결론

'오브젝트' 책의 1장을 통해 객체지향 설계의 핵심 원칙인 의존성, 캡슐화, 자율성에 대해 깊이 있게 이해할 수 있었습니다. 이러한 원칙들은 단순히 이론적인 개념이 아니라, 실제 코드의 품질과 유지보수성에 직접적인 영향을 미치는 중요한 요소입니다.

객체지향 설계에서는 객체를 단순한 데이터 컨테이너가 아닌, 자율적이고 책임을 갖는 존재로 바라보는 관점의 전환이 필요합니다. 이러한 관점을 바탕으로 코드를 설계할 때, 더 유연하고 이해하기 쉬우며 변경에 강한 소프트웨어를 만들 수 있습니다.

의존성을 분석하는 관점에서 생성자, 멤버변수, 메서드 매개변수를 통해 객체 간의 관계를 명확히 이해하고, 이를 통해 더 나은 설계 결정을 내릴 수 있습니다. 이러한 분석 방법은 단순히 코드를 보는 것에서 나아가, 객체의 본질적인 특성과 책임을 파악하는 데 도움이 됩니다.

앞으로의 개발 과정에서도 이러한 객체지향의 원칙을 항상 염두에 두고, 더 나은 코드를 작성하기 위해 노력해야겠습니다.


부록: 객체지향 설계의 기본 개념

의존성이란 무엇인가?

객체지향 설계에서 가장 중요한 개념 중 하나는 의존성(dependency)입니다. 의존성이란 한 클래스가 다른 클래스를 사용하는 관계를 의미합니다. 쉽게 말해, A 클래스가 B 클래스를 알고 있다면 "A는 B에 의존한다"고 표현합니다.

💡 핵심: 의존성은 '사용하는 쪽 → 제공하는 쪽'으로 흐릅니다.

@Service
public class OrderService {
    
    private final PaymentService paymentService;
    
    // 의존성 주입
    @Autowired
    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

이 예시에서 OrderService는 PaymentService를 의존(사용)합니다. 즉, payment 기능을 order에 제공합니다.

의존성의 종류

객체지향 설계에서는 의존성을 더 세분화하여 다음과 같이 구분합니다:

1. 연관(Association) - 실선으로 표현

한 클래스가 다른 클래스를 멤버 변수로 참조하는 관계입니다.

class Course {
    private Professor professor; // 단순 참조
    
    public void setProfessor(Professor professor) {
        this.professor = professor;
    }
}

2. 집합(Aggregation) - 실선으로 표현

"has-a" 관계로, 전체와 부분의 관계를 나타내지만 부분이 전체에 독립적으로 존재할 수 있습니다.

class Department {
    private List<Employee> employees; // 부분(Employee)이 전체(Department) 없이도 존재 가능
    
    public Department() {
        this.employees = new ArrayList<>();
    }
    
    public void addEmployee(Employee employee) {
        employees.add(employee);
    }
    // Employee는 Department가 사라져도 계속 존재할 수 있음
}

3. 합성(Composition) - 실선으로 표현

더 강한 "has-a" 관계로, 부분의 생명주기가 전체에 종속됩니다.

class Car {
    private final Engine engine; // 부분(Engine)이 전체(Car)에 생명주기가 종속됨
    
    public Car() {
        this.engine = new Engine(); // Car가 Engine을 직접 생성
    }
    // Car가 소멸하면 Engine도 함께 소멸
}

4. 의존(Dependency) - 점선으로 표현

일시적인 관계로, 메서드 파라미터나 지역 변수로 다른 클래스를 사용하는 경우입니다.

class OrderProcessor {
    public void processOrder(Order order, PaymentGateway paymentGateway) {
        // OrderProcessor는 메서드에서 Order와 PaymentGateway에 의존함
    }
}

의존 관계의 특징:

  • 일시적(Transient)입니다 - 메서드 실행 중에만 의존 관계가 존재함
  • 멤버 변수보다 결합도가 낮습니다 - 메서드가 호출될 때만 해당 객체가 필요함
  • 다형성을 활용할 수 있습니다 - 인터페이스나 상위 클래스 타입의 매개변수를 사용하면 여러 구현체를 받을 수 있음

UML에서의 표현: 실선과 점선의 차이

UML 다이어그램에서 클래스 간의 관계를 표현할 때 실선과 점선은 다음과 같은 의미를 갖습니다:

  • 실선(Solid Line): 직접적이고 강한 연결 관계를 나타냅니다. 연관(Association), 집합(Aggregation), 합성(Composition) 관계에 사용됩니다.
  • 점선(Dashed Line): 간접적이고 약한 관계를 나타냅니다. 주로 의존(Dependency) 관계에 사용됩니다.

이러한 표기법의 차이는 단순히 시각적인 구분이 아니라, 객체 간의 결합 강도와 생명주기 관리 방식을 명확히 하기 위한 것입니다.

의존성 관리의 중요성

의존성 관리는 객체지향 설계에서 가장 중요한 측면 중 하나입니다. 잘 관리된 의존성은 다음과 같은 이점을 제공합니다:

  1. 유지보수성 향상: 변경 사항이 시스템 전체에 미치는 영향을 최소화합니다.
  2. 재사용성 증가: 낮은 결합도를 가진 객체는 다른 맥락에서도 쉽게 재사용할 수 있습니다.
  3. 테스트 용이성: 의존성이 명확하게 분리되면 단위 테스트가 더 쉬워집니다.
  4. 변경에 유연한 대응: 새로운 요구사항이나 변경사항에 더 유연하게 대응할 수 있습니다.

객체지향 설계의 목표 중 하나는 "높은 응집도와 낮은 결합도"를 달성하는 것입니다. 의존성을 잘 관리하는 것은 이 목표를 달성하는 데 핵심적인 역할을 합니다.

멤버변수와 생성자 매개변수 관계의 판단 기준

  1. 생성자 매개변수로 두어야 하는 경우:
  • 객체의 필수 속성: 객체가 제 기능을 하기 위해 반드시 필요한 정보
  • 불변성(Immutability) 보장: 생성 후 변경되지 않아야 하는 값
  • 유효성 검증 필요: 객체 생성 시점에 값의 유효성을 보장해야 하는 경우
  • 객체 식별자: 객체를 구분하는 식별자 역할을 하는 값
  1. 멤버변수로만 두는 경우(생성자 매개변수로는 두지 않음):
  • 선택적 속성: 객체 생성 시 반드시 필요하지 않은 정보
  • 초기값이 있는 상태: 기본값으로 초기화 가능한 속성
  • 생성 후 설정 가능한 값: setter나 다른 메서드로 나중에 설정해도 문제 없는 값
  • 내부 상태: 객체의 동작 중에 변경되는 내부 상태 값
public class Order {
    private final Long id;           // 필수, 식별자
    private final Customer customer; // 필수, 주문자 없는 주문은 불가능
    private final LocalDateTime orderDate; // 필수, 주문 시간
    
    private OrderStatus status;     // 멤버변수만, 상태는 변경될 수 있음
    private String deliveryAddress; // 멤버변수만, 나중에 설정 가능
    private List<OrderItem> items;  // 멤버변수만, 주문 후 아이템 추가 가능
    
    public Order(Long id, Customer customer) {
        this.id = id;
        this.customer = customer;
        this.orderDate = LocalDateTime.now(); // 자동 생성 가능
        this.status = OrderStatus.CREATED; // 기본값 설정
        this.items = new ArrayList<>(); // 빈 컬렉션으로 초기화
    }
    
    // 메서드...
}

의존성 관리의 핵심 원칙

  • 의존성 방향 고려: 의존성은 항상 안정적인 방향(추상화 또는 고수준 모듈)으로 향해야 함

    // 좋은 예: 구체적 구현체보다 인터페이스에 의존
    public Report generateReport(ReportData data, ReportFormatter formatter) {
        // formatter는 인터페이스로, 다양한 구현체가 주입될 수 있음
    }
  • 책임의 분리: 각 메서드는 단일 책임을 가져야 함

    // 나쁜 예: 큰 인터페이스에 의존
    public void saveExp(AccExp101 VO) {
        // 입력값 검증
        // 데이터 베이스에서 중복 체크
        // 품의 저장
        // 품의 거래처 저장
        // 품의 재원 저장
        // 가전표 생성
    }
    
    // 좋은 예: 작은 인터페이스에 의존
    public class ExpenseService {
        private final ExpenseValidator validator;
        private final ExpenseRepository expRepository;
        private final VendorRepository vendorRepository;
        private final FundRepository fundRepository;
        private final JournalEntryGenerator journalGenerator;
    
        public ExpenseService(ExpenseValidator validator, 
                             ExpenseRepository expRepository,
                             VendorRepository vendorRepository,
                             FundRepository fundRepository,
                             JournalEntryGenerator journalGenerator) {
            this.validator = validator;
            this.expRepository = expRepository;
            this.vendorRepository = vendorRepository;
            this.fundRepository = fundRepository;
            this.journalGenerator = journalGenerator;
        }
    
        @Transactional
        public void saveExp(AccExp101 expenseVO) {
            // 1. 입력값 검증 - 검증 책임을 validator에 위임
            validator.validate(expenseVO);
    
            // 2. 품의 저장 - 데이터 저장 책임을 repository에 위임
            Long expenseId = expRepository.save(expenseVO);
    
            // 3. 품의 거래처 저장 - 거래처 관련 책임을 vendorRepository에 위임
            vendorRepository.saveVendorForExpense(expenseId, expenseVO.getVendors());
    
            // 4. 품의 재원 저장 - 재원 관련 책임을 fundRepository에 위임
            fundRepository.saveFundsForExpense(expenseId, expenseVO.getFunds());
    
            // 5. 가전표 생성 - 가전표 생성 책임을 journalGenerator에 위임
            journalGenerator.generateJournalEntry(expenseVO);
        }
    }
    
  • 의존성 주입 원칙: 객체는 자신의 의존성을 직접 생성하지 않고 외부에서 주입받아야 함

    // 나쁜 예: 의존성을 직접 생성
    public Report generateReport(ReportData data) {
        PDFFormatter formatter = new PDFFormatter(); // 직접 생성
        return formatter.format(data);
    }
    
    // 좋은 예: 의존성 주입
    public Report generateReport(ReportData data, ReportFormatter formatter) {
        return formatter.format(data);
    }
  • 인터페이스 분리 원칙: 클라이언트가 사용하지 않는 메서드에 의존하지 않도록 함

    // 나쁜 예: 큰 인터페이스에 의존
    public void processCustomer(CustomerManager manager, Customer customer) {
        // manager는 많은 메서드를 가진 큰 인터페이스지만 여기선 한 메서드만 사용
        manager.updateProfile(customer);
    }
    
    // 좋은 예: 작은 인터페이스에 의존
    public void processCustomer(ProfileUpdater updater, Customer customer) {
        // ProfileUpdater는 updateProfile 메서드만 가진 작은 인터페이스
        updater.updateProfile(customer);
    }

참고 자료

  • 조영호, "오브젝트: 코드로 이해하는 객체지향 설계", 위키북스, 2019
  • Martin Fowler, "Refactoring: Improving the Design of Existing Code", Addison-Wesley, 2018
  • Robert C. Martin, "Clean Code: A Handbook of Agile Software Craftsmanship", Prentice Hall, 2008

profile
Dot Your moment.

0개의 댓글