Code/OOP

[오브젝트] 5. 책임 할당하기

noahkim_ 2025. 4. 3. 18:28

조영호 님의 "오브젝트" 책을 정리한 글입니다.


1. 책임 주도 설계를 향해

데이터보다 행동을 먼저 결정하라

  • 객체에게 중요한 것은 데이터가 아니라 행동 (곧 객체의 책임을 의미함)
  • 객체는 협력을 위한 존재이기 때문

 

협력이라는 문맥 안에서 책임을 결정하라

개념 설명
책임 할당 원칙 - 메시지를 먼저 결정한 후 객체를 선택해야 함
- 즉, 메시지가 객체를 결정함
설계 순서
✅ 메시지 → ✅ 행동(책임) → ✅ 상태(데이터)
장점 - 캡슐화를 지키기 훨씬 쉬움

 

책임 주도 설계

  • 책임부터 정한 후, 책임을 담당할 객체를 결정하는 것
  • 책임이 어느정도 정리될 때까지 내부 상태에 대해 관심을 가지지 않음

 

2. 책임 할당을 위한 GRASP 패턴

도메인 개념에서 출발하기

  • 설계 시작 전, 도메인에 대한 개략적인 모습을 그려 보기
항목 내용
방법 도메인 개념들을 책임 할당의 대상으로 사용
목표 책임을 할당받을 객체들의 종류와 관계에 대한 유용한 정보 제공
장점
도메인의 모습을 코드에 자연스럽게 반영할 수 있음

 

정보 전문가에게 책임을 할당하라

단계 설명
1. 기능을 책임으로 인식 애플리케이션이 제공할 기능을 책임으로 정의
2. 메시지로 표현 책임을 메시지 전달로 나타냄
3. 메시지 수신 객체 결정
송신자의 의도를 반영하여 수신 객체 결정
4. 정보 전문가에게 할당
정보를 가장 잘 알고 있는 객체에게 책임을 부여
5. 외부 도움 요청
스스로 처리할 수 없는 작업은 다른 객체에 요청
6. 반복적인 과정 위 과정을 반복하면서 책임을 적절히 분배

 

높은 응집도와 낮은 결합도

  • 책임 할당을 위해 정보 전문가 패턴 뿐만 아니라 여러 무수한 패턴이 존재함
  • 여러 설계 패턴중 높은 응집도와 낮은 결합도를 목표로 책임을 할당

 

창조자에게 객체 생성 책임을 할당하라

창조자 패턴
  • 객체를 생성 책임을 할당할 때 기준
기준 객체 생성 책임을 가질 수 있는 대상
포함 관계 최종 결과물 객체를 포함하는 객체
사용 관계 최종 결과물 객체를 사용하는 객체
초기화 데이터 보유
최종 결과물 객체를 초기화하는데 필요한 데이터를 가지는 객체

 

 

적용
  • 영화 예매 시스템의 최종 결과물은 Reservation 인스턴스를 생성하는 것
  • Screening 객체는 Reservation 객체를 생ㅅ어하는데 필요한 데이터에 대한 전문가 (영화, 상영 시간, 상영 순번 등)

 

3. 구현을 통한 검증

더보기
public class Screening {
    private Movie movie;
    private int sequence;
    private LocalDateTime whenScreened;

    // constructor, getter..

    public Reservation reserve(Customer customer, int audienceCount) {
        return new Reservation(customer, this, calcuateFee(audienceCount), audienceCount);
    }

    private Money calcuateFee(int audienceCount) {
        return movie.calcuateMovieFee(this).times(audienceCount);
    }
}
public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private List<DiscountCondition> discountConditions;

    private MovieType movieType;
    private Money discountAmount;
    private double discountPercent;

    // constructor, getter..

    public Money calcuateMovieFee(Screening screening) {
        if (isDiscountable(screening)) return fee.minus(calculateDiscountAmount());

        return fee;
    }

    private Money calculateDiscountAmount() {
        switch (movieType) {
            case AMOUNT_DISCOUNT: return calculateAmountDiscountAmount();
            case PERCENT_DISCOUNT: return calculatePercentDiscountAmount();
            case NONE_DISCOUNT: return calculateNoneDiscountAmount();
        }

        throw new IllegalArgumentException();
    }


    private Money calculateAmountDiscountAmount() {
        return discountAmount;
    }

    private Money calculatePercentDiscountAmount() {
        return fee.times(discountPercent);
    }

    private Money calculateNoneDiscountAmount() {
        return Money.ZERO;
    }

    private boolean isDiscountable(Screening screening) {
        return discountConditions.stream().anyMatch(c -> c.isSatisfiedBy(screening));
    }
}
public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;

    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    // constructor, getter...

    public boolean isSatisfiedBy(Screening screening) {
        if (type == DiscountConditionType.PERIOD) return isSatisfiedByPeriod(screening);

        return isSatisfiedBySequence(screening);
    }

    private boolean isSatisfiedByPeriod(Screening screening) {
        return this.dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
                this.startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                this.endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
    }

    private boolean isSatisfiedBySequence(Screening screening) {
        return this.sequence == screening.getSequence();
    }
}
변경 대상 변경 내용 설명
Screening
reserve() 예매 기능 추가
calculateFee() Movie에게 요금 계산 위임

Movie
 
calculateMovieFee() 요금 계산 담당
calculateXXXDiscountFee() 할인 방식별 개별 메서드 통합
isDiscountable() 할인 여부 확인
DiscountCondition isSatisfiedBy() 기존 isSatisfiedByXXX() 통합
Screening을 전달받아 할인 조건 확인

 

DiscountCondition 개선하기

문제점: 변경에 취약함
기존 메서드 변경에 대한 문제점
isSatisfiedBy()
새로운 할인 조건 추가 시 분기문 수정 및 상태 변수 추가 가능성 존재
isSatisfiedBySequence()
순번 조건 판단 로직 변경 시 구현 수정 및 상태 변수 변경 가능성 존재
isSatisfiedByPeriod()
기간 조건 판단 로직 변경 시 구현 수정 및 상태 변수 변경 가능성 존재
  • 응집도가 낮음
    • 서로 다른 이유로 변경이 발생
    • 각 변경이 코드에 영향을 미치는 시점이 서로 다를 수 있음

 

문제 해결 방법
  • 변경의 이유에 따라 각각의 클래스로 분리해야 함

 

변경의 이유가 하나 이상인 클래스 찾는 방법
판단 기준 설명 예시 해결 방법
인스턴스 변수가 초기화되는 시점 응집도가 높은 클래스는 모든 속성을 함께 초기화 조건: dayOfWeek, startTime, endTime
순번: sequence
함께 초기화되는 속성을 기준으로 코드 분리
인스턴스 변수를 사용하는 방식 응집도가 높은 클래스는 모든 메서드가 모든 속성을 사용 isSatisfiedByPeriod()
→ dayOfWeek, startTime, endTime

isSatisfiedBySequence()
→ sequence
속성 그룹과 해당 그룹을 사용하는 메서드를 기준으로 코드 분리

 

타입 분리하기

두 타입을 개별 클래스로 분리
더보기
public class PeriodCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
        this.dayOfWeek = dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
    }

    public boolean isSatisfiedBy(Screening screening) {
        return this.dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
                this.startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                this.endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
    }
}
public class SequenceCondition {

    private int sequence;

    public SequenceCondition(int sequence) {
        this.sequence = sequence;
    }

    public boolean isSatisfiedBy(Screening screening) {
        return this.sequence == screening.getSequence();
    }
}
  • 문제 해결: 높은 응집성 달성
  • 새로운 문제 발생: 결합도 높아짐 (클라이언트 코드인 Movie에서 각 클래스들의 리스트들을 가지게 됨)

 

다형성을 통해 분리하기

더보기
public interface DiscountCondition {
    boolean isSatisfiedBy(Screening screening);
}
public class PeriodCondition implements DiscountCondition { ... }

public class SequenceCondition implements DiscountCondition { ... }
  • 두 클래스는 할인 여부를 판단하는 동일한 책임을 수행 (할인 여부를 판단하는 방법만 다를 뿐)
    • 클라이언트가 역할에 결합되므로 구체적인 클래스는 알 필요가 없어짐
  • 역할은 객체의 구체적인 타입을 추상화 할 수 있음
    • 구현을 공유할 필요에 따라 추상 클래스 혹은 인터페이스 타입을 선택하여 사용하기

 

변경으로부터 보호하기

변경 보호 패턴
개념 설명 효과
추상화가 구체적인 타입을 캡슐화 클라이언트 코드가 특정 구현에 의존하지 않도록 인터페이스나 추상 클래스를 사용 새로운 할인 조건을 추가하더라도 클라이언트 코드에 영향을 주지 않음
구현체를 추가하는 방식으로 확장 가능 새로운 할인 조건을 추가할 때 기존 코드를 수정하는 대신, 새로운 클래스를 구현하면 됨 OCP(개방-폐쇄 원칙) 준수
유지보수 용이


Movie 클래스 개선하기

변경 보호 패턴을 활용
더보기
public abstract class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private List<DiscountCondition> discountConditions;
    
    public Movie(String title, Duration runningTime, Money fee, DiscountCondition... discountConditions) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountConditions = Arrays.asList(discountConditions);
    }

    protected Money getFee() { return fee; }
    public List<DiscountCondition> getDiscountConditions() { return discountConditions; }
    
    public Money calcuateMovieFee(Screening screening) {
        if (isDiscountable(screening)) return fee.minus(calculateDiscountAmount());

        return fee;
    }
    
    private boolean isDiscountable(Screening screening) {
        return discountConditions.stream().anyMatch(c -> c.isSatisfiedBy(screening));
    }

    abstract protected Money calculateDiscountAmount();
}
public class AmountDiscountMovie extends Movie {
    private Money discountAmount;

    public AmountDiscountMovie(String title, Duration runningTime, Money fee, Money discountAmount, DiscountCondition... discountConditions) {
        super(title, runningTime, fee, discountConditions);
        this.discountAmount = discountAmount;
    }

    @Override
    protected Money calculateDiscountAmount() {
        return discountAmount;
    }
}
public class PercentDiscountMovie extends Movie {
    private double percent;

    public PercentDiscountMovie(String title, Duration runningTime, Money fee, double percent, DiscountCondition... discountConditions) {
        super(title, runningTime, fee, discountConditions);
        this.percent = percent;
    }

    @Override
    protected Money calculateDiscountAmount() {
        return getFee().times(percent);
    }
}
public class NoneDiscountMovie extends Movie {
    public NoneDiscountMovie(String title, Duration runningTime, Money fee, DiscountCondition... discountConditions) {
        super(title, runningTime, fee, discountConditions);
    }

    @Override
    protected Money calculateDiscountAmount() {
        return Money.ZERO;
    }
}
개념 설명 효과
각 메서드를 각 클래스로 분리 영화 타입별로 개별 클래스로 분리하여 책임을 명확히 함 코드 응집도 증가
유지보수 용이
공유할 구현을 추상 클래스로 추상화 공통 기능을 상위 추상 클래스로 정의
세부 사항을 하위 클래스에서 구현
코드 중복 제거
일관성 유지
Movie.getFee() 가시성을 protected로 제한 해당 메서드는 하위 클래스에서만 사용하도록 제한 캡슐화 강화
불필요한 외부 접근 차단

 

변경과 유연성

개념 상속 합성
구현 방식 하위 클래스가 상위 클래스를 확장 (extends) 기존 객체가 다른 객체를 포함 (has-a)
객체 생성 새로운 하위 클래스 인스턴스 생성 필수 생성자에서 필요한 구현체를 인자로 전달
유연성 실행 시점에서 어떤 구현체가 사용될지 한눈에 파악 어려움 런타임에 객체 교체 가능, 유연성 증가
코드 변경 영향 하위 클래스 추가 시, 기존 코드에 영향 가능성 있음 구현체 추가하더라도 추가 코드 변경 없음
캡슐화 상속 구조가 깊어지면 캡슐화가 약해질 수 있음 캡슐화를 유지하면서 기능 확장 가능
  • 상속은 부모 자식간 결합이 존재함 (부모의 변경에 자식이 영향을 받음)
  • 합성이 캡슐화와 유연성에 더 좋음

 

4. 책임 주도 설계의 대안

메서드 응집도

더보기
public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        boolean discountable = checkDiscountable(screening);
        Money fee = calculateFee(screening, discountable, audienceCount);
        return createReservation(screening, customer, audienceCount, fee);
    }

    private Money calculateFee(Screening screening, boolean discountable, int audienceCount) {
        if (discountable) return screening.getMovie().getFee().minus(calculateDiscountedFee(screening.getMovie())).times(audienceCount);
        
        return screening.getMovie().getFee().times(audienceCount);
    }

    private Money calculateDiscountedFee(Movie movie) {
        switch (movie.getMovieType()) {
            case AMOUNT_DISCOUNT: return calcuateAmountDiscountedFee(movie);
            case PERCENT_DISCOUNT: return calcuatePercentDiscountedFee(movie);
            case NONE_DISCOUNT: return calcuateNoneDiscountedFee(movie);
        }
        
        throw new IllegalArgumentException();
    }

    private Money calcuateAmountDiscountedFee(Movie movie) { return movie.getDiscountAmount(); }
    private Money calcuatePercentDiscountedFee(Movie movie) { return movie.getFee().times(movie.getDiscountPercent()); }
    private Money calcuateNoneDiscountedFee(Movie movie) { return Money.ZERO; }
    private Reservation createReservation(Screening screening, Customer customer, int audienceCount, Money fee) { return new Reservation(customer, screening, fee, audienceCount); }
    private boolean checkDiscountable(Screening screening) { return screening.getMovie().getDiscountConditions().stream().anyMatch(c -> c.isSatisfiedBy(screening)); }
}
  • 긴 메서드를 각각 책임에 따라 내부 구현으로 나누어 호출하기

 

객체를 자율적으로 만들자

  • 자율적인 객체를 만드는 방법은 자신이 소유하고 있는 데이터를 자기 스스로 처리하는 것
  • 메서드가 사용하는 데이터를 저장하고 있는 클래스로 메서드 이동시키기