ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 오브젝트 2 - 유연한 객체 협력 모델 만들기
    책책책 책을 읽읍시다/프로그래밍 2023. 2. 8. 22:58

    이번 예제는 영화 예매 시스템이다. 영화를 상영하는 스크린이 있고, 영화 상영 요금에 대한 할인 정책과 할인 조건이 있다. 할인 정책은 금액(예: 1000원)과 비율(예: 5%)이 있고, 한 영화에 하나만 적용 가능하다. 할인 조건은 순번(예: 조조 첫타임 할인)과 기간(예: 월요일 10:00~12:00 할인)이 있는데 정책과 달리 복수개가 적용 가능하다. 아래는 각 객체들에 대한 설명이다.

    • Screening : 영화 상영 정보에 따라 상영 회차별 예매 금액을 표시한다.
    • Reservation : 영화 상영 회차별 예약을 담당한다.
    • Movie : Screen의 요청을 받아 영화별 할인 정책에 따른 예매 금액을 계산한다. 영화별로 할인 정책을 정할 수 있다.
    • DiscountPolicy : 할인 정책으로 아래 정책 중 1개를 적용한다.
      • AmountDiscountPolicy : 금액 할인 정책
      • PercentDiscountPolicy : 비율 할인 정책
    • DiscountCondition : 할인 조건으로 아래 조건을 만족하는지 확인한다.
      • SequenceCondition : 회차별 할인 조건
      • PeriodCondition : 기간별 할인 조건

     

    각 객체들의 구현은 다음과 같다.

    Screening

    public class Screening {
        private Movie movie;
        private int sequence;
        private LocalDateTime whenScreened;
    
        public Screening(Movie movie, int sequence, LocalDateTime whenScreened) {
            this.movie = movie;
            this.sequence = sequence;
            this.whenScreened = whenScreened;
        }
    
        public LocalDateTime getStartTime() {
            return whenScreened;
        }
    
        public boolean isSequence(int sequence) {
            return this.sequence == sequence;
        }
    
        public Money getMovieFee() {
            return movie.getFee();
        }
    
        public Reservation reserve(Customer customer, int audienceCount) {
            return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
        }
    
        private Money calculateFee(int audienceCount) {
            return movie.calculateMovieFee(this).times(audienceCount);
        }
    }

    Money

    public class Money {
        public static final Money ZERO = Money.wons(0);
    
        private final BigDecimal amount;
    
        public static Money wons(long amount) {
            return new Money(BigDecimal.valueOf(amount));
        }
    
        public static Money wons(double amount) {
            return new Money(BigDecimal.valueOf(amount));
        }
    
        Money(BigDecimal amount) {
            this.amount = amount;
        }
    
        public Money plus(Money amount) {
            return new Money(this.amount.add(amount.amount));
        }
    
        public Money minus(Money amount) {
            return new Money(this.amount.subtract(amount.amount));
        }
    
        public Money times(double percent) {
            return new Money(this.amount.multiply(BigDecimal.valueOf(percent)));
        }
    
        public boolean isLessThan(Money other) {
            return amount.compareTo(other.amount) < 0;
        }
    
        public boolean isGreaterThanOrEqual(Money other) {
            return amount.compareTo(other.amount) >= 0;
        }
    }

    Reservation

    public class Reservation {
        private Customer customer;
        private Screening screening;
        private Money fee;
        private int audienceCount;
    
        public Reservation(Customer customer, Screening screening, Money fee, int audienceCount) {
            this.customer = customer;
            this.screening = screening;
            this.fee = fee;
            this.audienceCount = audienceCount;
        }
    }

    Movie

    import java.time.Duration;
    
    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;
        }
    
        public Money getFee() {
            return fee;
        }
    
        public Money calculateMovieFee(Screening screening) {
            return fee.minus(discountPolicy.calculateDiscountAmount(screening));
        }
    }

    DiscountPolicy, AmountDiscountPolicy, PercentDiscountPolicy : 편의상 한 코드블럭 안에 넣었다.

    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 Money.ZERO;
        }
    
        abstract protected Money getDiscountAmount(Screening screening);
    }
    
    public class AmountDiscountPolicy extends DiscountPolicy {
        private Money discountAmount;
    
        public AmountDiscountPolicy(Money discountAmount, DiscountCondition... conditions) {
            super(conditions);
            this.discountAmount = discountAmount;
        }
    
        @Override
        protected Money getDiscountAmount(Screening screening) {
            return discountAmount;
        }
    }
    
    public class PercentDiscountPolicy extends DiscountPolicy {
        private double percent;
    
        public PercentDiscountPolicy(double percent, DiscountCondition... conditions) {
            super(conditions);
            this.percent = percent;
        }
    
        @Override
        protected Money getDiscountAmount(Screening screening) {
            return screening.getMovieFee().times(percent);
        }
    }

    DiscountCondition, SequenceCondition, PeriodCondition : 이 역시 편의상 한 코드블럭 안에 넣었다.

    public interface DiscountCondition {
        boolean isSatisfiedBy(Screening screening);
    }
    
    public class SequenceCondition implements DiscountCondition {
        private int sequence;
    
        public SequenceCondition(int sequence) {
            this.sequence = sequence;
        }
    
        @Override
        public boolean isSatisfiedBy(Screening screening) {
            return screening.isSequence(sequence);
        }
    }
    
    public class PeriodCondition implements DiscountCondition {
        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;
        }
    
        @Override
        public boolean isSatisfiedBy(Screening screening) {
            return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
                    startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
                    endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
        }
    }

    클라이언트 코드

    Movie avatar = new Movie(
                    "아바타",
                    Duration.ofMinutes(120),
                    Money.wons(10000),
                    new AmountDiscountPolicy(
                            Money.wons(800),
                            new SequenceCondition(1),
                            new SequenceCondition(10),
                            new PeriodCondition(DayOfWeek.MONDAY, LocalTime.of(10, 0), LocalTime.of(11, 59)),
                            new PeriodCondition(DayOfWeek.THURSDAY, LocalTime.of(10, 0), LocalTime.of(20, 59))
                            )
            );

    눈에 띄는 점은 DiscountPolicy와 DiscountCondition을 추상클래스, 인터페이스로 구현하고, 세부 내용에 해당하는 것들을 별도의 클래스로 구체화한 것이다. 이러면 컴파일 시점의 클라이언트 코드에서는 해당 타입만 알 뿐이고 뒤에 어떤 concrete class가 있는지 알지 못하고 런타임 시점에서야 상황에 맞게 정해진다. 코드의 의존성과 실행 시점의 의존성이 서로 다른 것이다. 이와 같이 클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있다. 유연하고, 쉽게 재사용할 수 있으며 확장 가능한 객체지향 설계가 가지는 특징은 코드의 의존성과 실행 시점의 의존성이 다르다는 것이다.

    한 가지 간과해서는 안 되는 사실은 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기 어려워진다는 것이다. 코드를 이해하기 위해서는 코드뿐만 아니라 객체를 생성하고 연결하는 부분을 찾아야 하기 때문이다. 반면 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드는 더 유연해지고 확장 가능해진다. 이와 같은 의존성의 양면성은 설계가 트레이드 오프의 산물이라는 사실을 잘 보여준다.

    설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 더 어려워진다는 사실을 기억하라. 반면 유연성을 억제하면 코드를 이해하고 디버깅하기는 쉬워지지만 재사용성과 확장 가능성은 낮아진다는 사실도 기억하라. 우리가 훌륭한 객체지향 설게자로 성장하기 위해서는 항상 유연성과 가독성 사이에서 고민해야 한다. 무조건 유연한 설계도, 무조건 읽기 쉬운 코드도 정답이 아니다. 이것이 객체지향 설계가 어려우면서도 매력적인 이유라고 저자는 말한다.

     

    다형성(Polymorphism)

    메세지와 메서드는 다른 개념이다. Movie는 DiscountPolicy의 인스턴스에게 calculateDiscountAmount 메세지를 전송한다. 실제로 실행되는 메서드는 Movie와 상호작용하기 위해 연결된 객체의 클래스가 무엇인가에 따라 달라진다. Movie는 동일한 메세지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메세지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라지는데, 이를 다형성이라고 부른다.

    다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반으로 한다. Movie 클래스는 추상 클래스인 DiscountPolicy에 의존한다. 컴파일 시간 의존성은 Movie에서 DiscountPolicy로 향한다. 반면 실행 시점에 Movie의 인스턴스와 실제로 상호작용하는 객체는 AmountDiscountPolicy와 PercentDiscoutPolicy의 인스턴스로 실행 시간 의존성은 Movie에서 AmountDiscountPolicy나 PercentDiscoutPolicy로 향한다. 이처럼 다형성은 컴파일 시간 의존성과 실행 시간 의존성을 다르게 만들 수 있는 객체지향의 특성을 이용해 서로 다른 메서드를 실행할 수 있게 한다.

    다형성이란 동일한 메세지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력을 의미한다. 따라서 다형적인 협력에 참여하는 객체들은 모두 같은 메세지를 이해할 수 있어야 하는데, 인터페이스가 동일해야 한다는 의미이다. AmountDiscountPolicy와 PercentDiscoutPolicy가 다형적인 협력에 참여할 수 있는 이유는 이들이 DiscountPolicy로부터 동일한 인터페이스를 물려받았기 때문이다.

     

    추상화와 유연성

    그럼 할인 정책을 정책하지 않는 노세일 영화라면 어떨까? 가장 간단한 해결책은 아래처럼 if문으로 흐름을 분기하는 것이다.

    public class Movie {
        public Money calculateMovieFee(Screening screening) {
            if (discountPolicy == null) {
                return fee;
            }
            
            return fee.minus(discountPolicy.calculateDiscountAmount(screening));
        }
    }

    이 방식의 문제점은 할인 정책이 없는 경우를 예외 케이스로 취급하기 때문에 지금까지 일관성 있던 협력 방식이 무너지게 된다는 것이다. 기존 할인 정책의 경우에는 할인할 금액을 계산하는 책임이 DiscountPolicy의 자식 클래스에 있었지만 할인 정책이 없는 경우에는 할인 금액이 0원이라는 사실을 결정하는 책임이 DiscountPolicy가 아닌 Movie 쪽에 있기 때문이다. 따라서 책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않은 선택이다. 항상 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택하라.

    이 경우에 일관성을 지킬 수 있는 방법은 0원이라는 할인 요금을 계산할 책임을 그대로 DiscountPolicy 계층에 유지시키는 것이다. NoneDiscountPolicy 클래스를 추가하자.

    public class NoneDiscountPolicy extends DiscountPolicy {
        @Override
        protected Money getDiscountAmount(Screening screening) {
            return Money.ZERO;
        }
    }

    클라이언트 코드에서는 Movie 객체를 아래와 같이 생성하면 된다.

    Movie starWars = new Movie(
                    "스타워즈",
                    Duration.ofMinutes(210),
                    Money.wons(10000),
                    new NoneDiscountPolicy()
            );

     

    역할, 책임, 협력

    객체지향 패러다임의 관점에서 핵심은 역할(role), 책임(responsibility), 협력(collaboration)이다. 객체들이 애플리케이션의 기능을 구현하기 위해 수행하는 상호작용을 협력이라고 한다. 객체가 협력에 참여하기 위해 수행하는 로직은 책임이라고 부른다. 객체들이 협력 안에서 수행하는 책임들이 모여 객체가 수행하는 역할을 구성한다.

    협력

    객체지향은 객체를 중심에 놓는 프로그래밍 패러다임이다. 여기서 객체란 상태와 행동을 함께 캡슐화하는 실행 단위다. 그렇다면 객체가 가질 수 있는 상태와 행동을 어떤 기준으로 결정해야 할까? 객체를 설계할 때 어떤 행동과 상태를 할당했다면 그 이유는 무엇인가? 상태는 객체가 행동하는 데 필요한 정보에 의해 결정되고 행동은 협력 안에서 객체가 처리할 메세지로 결정된다. 결과적으로 객체가 참여하는 협력이 객체를 구성하는 행동과 상태 모두를 결정한다. 따라서 협력은 객체를 설게하는 데 필요한 일종의 문맥(context)를 제공한다.

    책임

    객체의 책임은 객체가 '무엇을 알고 있는가'와 '무엇을 할 수 있는가'로 구성된다. 크레이그 라만(Craig Larman)은 이러한 분류 체계에 따라 객체의 책임을 크게 '하는 것(doing)'과 '아는 것(knowing)'의 두 가지 범두로 나누어 세분화하고 있다.

    • 하는 것
      • 객체를 생성하거나 계산을 수행하는 등의 스스로 하는 것
      • 다른 객체의 행동을 시작시키는 것
      • 다른 객체의 활동을 제어하고 조절하는 것
    • 아는 것
      • 사적인 정보에 관해 아는 것
      • 관련된 객체에 관해 아는 것
      • 자신이 유도하거나 계산할 수 있는 것에 관해 아는 것

    영화 예매 시스템에서 Screening의 책임은 무엇인가? 영화를 예매하는 것이다. Movie의 책임은 무엇인가? 요금을 계산하는 것이다. Screening은 영화를 예매할 수 있어야 한다. 이것은 하는 것과 관련된 책임이다. Screening은 자신이 상영할 영화를 알고 있어야 한다. 이것은 아는 것과 관련된 책임이다. Movie는 예매 가격을 계산할 책임을 진다. 이것은 하는 것과 관련된 책임이다. 또한 가격과 어떤 할인 정책이 적용됐는지도 알고 있어야 한다. 이것은 아는 것과 관련된 책임이다.

    중요한 사실은 책임의 관점에서 '아는 것'과 '하는 것'이 밀접하게 연관돼 있다는 점이다. 객체는 자신이 맡은 책임을 수행하는 데 필요한 정보를 알고 있을 책임이 있다. 어떤 책임을 수행하기 위해서는 그 책임을 수행하는 데 필요한 정보도 함께 알아야 할 책임이 있는 것이다. 이것은 객체에게 책임을 할당하기 위한 가장 기본적인 원칙에 대한 힌트를 제공한다. 크레이그 라만은 "객체지향 개발에서 가장 중요한 능력은 책임을 능숙하게 소프트웨어 객체에 할당하는 것"이라는 말로 책임할당의 중요성을 강조하기도 했다. 객체지향 설계에서 가장 중요한 것은 책임이다. 객체에게 얼마나 적절한 책이을 할당하느냐가 설계의 전체적인 품질을 결정한다. 객체의 구현 방법은 상대적으로 책임보다는 덜 중요하며 책임을 결정한 다음에 고민해도 늦지 않다.

    객체에게 책임을 할당하는 데 필요한 메세지를 먼저 식별하고 메세지를 처리할 객체를 나중에 선택하는 것이 중요하다. 다시 말해 객체가 메세지를 선택하는 것이 아니라 메세지가 객체를 선택하게 했다(Metz12).

    메세지가 객체를 선택하게 해야 하는 두 가지 중요한 이유가 있다.

    첫째, 객체가 최소한의 인터페이스(minimal inerface)[Weisfeld08]를 가질 수 있게 된다. 필요한 메세지가 식별될 때까지 객체의 퍼블릭 인터페이스에 어떤 것도 추가하지 않기 때문에 객체는 애플리케이션에 크지도, 작지도 않은 꼭 필요한 크기의 퍼블릭 인터페이스를 가질 수 있다.

    둘째, 객체는 충분히 추상적인 인터페이스(abstract interface)[Weisfeld08]를 가질 수 있게 된다. 객체의 인터페이스는 무엇(what)을 하는지는 표현해야 하지만 어떻게(how) 수행하는지를 노출해서는 안 된다. 메세지는 외부의 객체가 요청하는 무언가를 의미하기 때문에 메세지를 먼저 식별하면 무엇을 수행할지에 초점을 맞추는 인터페이스를 얻을 수 있다.

    객체지향 패러다임에 갓 입문한 사람들이 가장 쉽게 빠지는 실수는 객체의 행동이 아니라 상태에 초점을 맞추는 것이다. 초보자들은 먼저 객체에 필요한 상태가 무엇인지를 결정하고, 그 후에 상태에 필요한 행동을 결정한다. 이런 방식은 객체의 내부 구현이 객체의 퍼블릭 인터페이스에 노출되도록 만들기 때문에 캡슐화를 저해한다. 객체의 내부 구현을 변경하면 퍼블릭 인터페이스도 함께 변경되고, 결국 객체에 의존하는 클라이언트로 변경의 영향이 전파된다. 레베카 워프스브록은 이와 같이 객체의 내부 구현에 초점을 맞춘 설계 방법을 데이터-주도 설계(Data-Driven Design)[Wirfs-Brock89]라고 부르기도 했다.

    역할

    객체는 협력이라는 주어진 문맥 안에서 특정한 목적을 갖게 된다. 객체의 목적은 협력 안에서 객체가 맡게 되는 책임의 집합으로 표시된다. 이처럼 객체가 어떤 특정한 협력 안에서 수행하는 책임의 집합을 역할이라고 부른다. 실제로 협력을 모델링할 때는 특정한 객체가 아니라 역할에게 책임을 할당한다고 생각하는게 좋다.

    객체에게 중요한 것은 행동이라는 사실을 기억하라. 역할이 중요한 이유는 동일한 협력을 수행하는 객체들을 추상화할 수 있기 때문이다. 역할을 사용하면 '가격 할인 정책과 함께 2개의 순번 규칙과 1개의 비율 규칙을 적용'하거나 '비율 할인 정책과 함께 3개의 순번 규칙을 적용'한다고 복잡하게 말할 필요가 없다. 간단히 '할인 정책과 여러 개의 할인 조건을 적용한다'로 줄여서 표현하면 된다.

    추상화의 두 번째 장점은 설계를 유연하게 만들 수 있다는 것이다. 역할이 다양한 종류의 객체를 끼워 넣을 수 있는 일종의 슬롯이라는 점에 착안하면 쉽게 이해할 수 있을 것이다. 협력 안에서 동일한 책임을 수행하는 객체들은 동일한 역할을 수행하기 때문에 서로 대체 가능하다. 따라서 역할은 다양한 환경에서 다양한 객체들을 수용할 수 있게 해주므로 협력을 유연하게 만든다.

    영화 예매 시스템에서 DiscountPolicy와 DiscountCondition이라는 역할을 수행할 수 있는 어떤 객체라도 예매 요금을 계산하는 협력에 참여할 수 있었다. 다시 말해서 다양한 종류의 할인 정책과 할인 조건에도 적용될 수 있는 협력을 만들었다는 것을 의미한다. 이처럼 협력 안에서 역할이라는 추상화를 이용하면 기존 코드를 수정하지 않고도 새로운 행동을 추가할 수 있다. 결과적으로 앞으로 추가될 미지의 할인 정책과 할인 조건을 수용할 수 있는 유연한 설계를 얻을 수 있다. 프레임워크나 디자인 패턴과 같이 재사용 가능한 코드나 설계 아이디어를 구성하는 핵심적인 요소가 바로 역할이다.

     

    댓글

Designed by Tistory.