객체지향 프로그래밍

2023. 9. 30. 16:17북리뷰/오브젝트

728x90

# 영화 예매 시스템

영화관 예매 시스템을 만든다고 가정해보자.

영화표를 구매할 때 할인을 받을 수 있다.

할인액을 결정하는 데 두 가지 규칙이 존재하는데, 하나는 할인 조건(discount condition), 다른 하나는 할인 정책(discount policy)가 있다고 보자.

할인 조건은 할인 여부를 결정하며 '순서 조건'과 '기간 조건'이 있다.

순서 조건은 상영 순번이 일치하면 할인을 해주는 것이고, 기간 조건은 상영 시작 시간이 해당 기간 안에 포함하면 할인해주는 것이다.

할인 정책은 '금액 할인 정책' 과 '비율 할인 정책'이 있다.

금액 할인 정책은 예매 요금에서 일정 금액을 할인해주는 방식이며, 비율 할인 정책은 일정 비율만큼 할인해주는 방식이다.

영화별로 하나의 할인 정책만 할당 가능하고, 할인 정책을 지정하지 않을 수도 있다.

할인 조건은 순번 조건과 기간조건을 혼합하여 여러 개 적용할 수 있다.

다음은 할인 정책과 조건이 지정된 예시이다

## 도메인의 구조를 따르는 프로그램 구조

 

도메인 : 문제 해결하기 위해 사용자가 프로그램에 사용하는 분야.

객체지향이 강력한 이유는 요구사항을 분석부터 구현까지 객체라는 동일한 추상화 기법을 사용할 수 있다.

즉, 도메인 구성 개념이 같기 때문에 프로그램의 객체와 클래스로 매끄럽게 연결할 수 있다.

따라서 위에 설명한 구조를 아래 다이어그램으로 매핑이 가능한 것이다.

 

아래는 위 다이어그램대로 구현한 예시이다

 

### 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(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

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 calculateMoneyFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

### DiscountPolicy

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 condition : conditions) {
            if (condition.isSatisfiedBy(screening)) {
                return getDiscountAmount(screening);
            }
        }

        return Money.ZERO;
    }

    abstract protected Money getDiscountAmount(Screening screening);
}

### AmountDiscountPolicy

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;
    }
}

### PercentDiscountPolicy

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

public interface DiscountCondition {
    boolean isSatisfiedBy(Screening screening);
}

### SequenceCondition

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);
    }
}

### PeriodCondition

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;
    }
}

 

# 객체지향 프로그래밍을 향해

 

객체지향 언어에 익숙한 사람이라면 가장 먼저 어떤 클래스가 필요한지 고민할 것이다.

클래스를 결정한 후, 어떤 속성과 메서드가 필요한지 고민한다.

허나, 이런 방식은 객체지향 본질과 거리가 멀다.

객체지향은 클래스가 아닌 객체에 초점을 맞춰야 한다.

 

그럼 어떻게 객체에 집중할 수 있을까?

첫번째로 어떤 클래스가 필요한 지 고민하기 전에 어떤 객체가 필요한지 고민하는 것이다.

클래스는 객체의 공통적인 상태나 행동을 추상화한 것이다.

따라서 클래스를 갖추기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지 먼저 결정해야 한다.

 

둘째로 객체는 독립적 존재가 아닌, 기능 구현을 위한 협력하는 공동체의 일원으로 봐야한다.

객체는 홀로 존재하는 것이 아닌, 협력적 존재이다.

 

객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고, 그걸 기반으로 클래스를 구현해야 한다.

 

## 자율적인 객체

 

객체는 상태와 행동을 함께 가지는 복합적이 존재다.

그리고 객체는 스스로 판단하고 행동하는 자율적인 존재여야 한다.

따라서 외부에 접근을 통제하여 객체를 자율적인 존재로 만들어야 한다.

외부에서 접근한 부분을 퍼블릭 인터페이스,

오직 내부에서만 접근 가능한 부분은 구현이라 부른다

인터페이스와 구현을 분리하여야 훌륭한 객체지향을 완성시킬 수 있다.

 

일반적으로 객체 상태는 숨기고, 행동만 외부에 공개해야 한다.

 

## 협력

 

객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청을 보낸다.

요청 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답한다.

객체는 다른 객체와 상호작용 하기 위헤 메시지를 전송한다.

메시지를 수신한 객체는 자신만의 방법으로 처리한다. 이 방법을 메서드라고 부른다.

 

위 예시 코드를 보면 DiscountPolicy는 전체적인 흐름은 정의하지만, 실제로 요금을 계산하는 부분은 추상 메서드인 getDiscountAmount 메서드에 위임한다. DiscountPolicy를 상속받은 자식클래스에서 오버라이딩한 메서드를 실행될 것이다.

이처럼 부모 클래스에 기본적인 알고리즘 흐름을 구현하고, 중간에 필요한 처리를 자식 클래스에 위임하는 디자인 패턴을 템플릿 메서드 패턴이라고 부른다.

 

## 상속 

 

Movie 클래스에서는 어디에도 할인정책이 무엇인지 명시되어 있지 않는다. 그럼 어떻게 할인 정책을 선택할 수 있을까?

다음과 같이 실행 시점에 인스턴스에 의존 시키는 것이다.

즉, 코드의 의존성과 실행 시점의 의존성은 서로 다를 수 있다.

이런 식으로 상속을 통해 의존성을 분리시킬 수 있다. 

 

코드의 의존성과 실행 시점의 의존성을 다를수록 코드는 더 유연해지고 확장 가능해진다.

허나, 코드를 이해하기 어려워진다는 단점이 있다.

 

그래서 우리는 유연성과 가독성 사이에서 항상 고민을 해야한다.

 

또한 상속은 재사용에 용이하게 할 수 있다.

부모 클래스의 다른 부분만 추가해서 새로운 클래스를 쉽고 빠르게 만들 수 있다.

허나, 상속은 구현 상속이 아닌, 인터페이스 상속을 위해 사용해야 한다.

코드 재사용은 상속의 주된 목적이 아니다!

구현을 재사용할 목적으로 사용하면 변경에 취약한 코드를 낳게 된다.

 

동일한 메시지를 전송하지만, 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 갤체의 클래스가 무엇이냐에 따라 달라진다. 이를 다형성이라고 부른다.

즉, 다형성은 객체 타입에 따라 다르게 응답할 수 있는 능력을 의미한다.

 

## 추상화

우리는 추상화를 통해 Movie를 다음과 같이 표현했다.

 

추상화를 이용하면 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다.

또한 협력의 흐름을 쉽게 파악할 수 있다.

디자인 패턴과 프레임워크 모두 추상화를 통한 메커니즘을 이용하고 있다.

 

## 유연한 설계

위 예시에 추가하여 할인 정책이 없는 경우에 대한 요구사항을 추가하자

Movie를 다음과 같이 수정한다고 가정하자

 

이런 방식의 문제점은 지금까지의 일관성 있던 협력 방식이 무너지게 된다는 것이다.

책임 위치를 결정하기 위해 조건문을 사용하는 것은 좋지 않다.

항상 예외 케이스를 최소화하고 일관성을 유지하는 방법을 선택하자.

 

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

이런 식으로 할인 요금이 0원이라는 정책을 추가하여 일관성을 유지할 수 있다.

 

여기서 중요한 점은 기존에 Movie나 DiscountPolicy는 수정하지 않고 새로운 클래스를 추가하는 것만으로 애플리케이션의 기능을 확장했다는 것이다.

## 추상화의 트레이드오프

 

NoneDiscountPolicy를 자세히 보면 getDiscountAmount가 어떤 값을 반환하더라도 상관 없다는 것을 알 수 있다. 상위 클래스에서는 할인 조건이 없을 경우 getDiscountAmount를 호출하지 않기 떄문이다. 

 

이를 해결하는 방법은 DiscountPolicy를 인터페이스로 바꾸고, 기존 할인 정책 사이에 DefaultPolicy를 추가하는 방안으로 해결할 수 있다.

 

 

수정한 이후에 다이어그램을 살펴보면 다음과 같다.

뭔가 더 유연해졌으나, 설계는 더 복잡해졌다.

즉, 추상화 설계 또한 트레이드 오프의 대상이 될 수 있다. 작성한 코드에대한 변경에는 합당한 이유가 있어야 한다.

 

## 코드 재사용

코드 재사용을 위해서는 상속보다 합성이 더 좋은 방식이다.

합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함하는 방식이다. 

그 이유는 상속이 캡슐화를 위반하기 때문이다.

하위 클래스는 상위 클래스의 내부 구조를 잘 알고 있어야 한다.

즉, 상위 클래스가 하위 클래스에게 노출되기 때문에 캡슐화가 약화된다.

또한, 하위 클래스와 상위 클래스가 강하게 결합되기 때문에 상위 클래스가 변경시 하위 클래스도 함께 변경될 확률을 높인다.

그래서 상속을 과도하게 사용하면 변경하기도 어려워진다.

 

또한 상속을 통한 설계는 유연하지 않다.

상위 클래스와 하위 클래스의 관계는 컴파일 시점에 결정되기에, 런타임 시점에 변경하는 것은 불가능하다.

 

따라서 재사용을 위해서는 합성이 효과적이다.

합성을 통해 구현을 효과적으로 사용하며, 설계를 유연하게 만들 수 있다.

 

그렇다고 상속을 사용하지 말라는 말은 아니다. 대부분 설계에서는 상속과 합성을 함께 사용해야 한다.

다형성을 위해 인터페이스를 재사용 하는 경우는 상속과 함성을 함꼐 조합할 수 밖에 없다.

 

 

728x90

'북리뷰 > 오브젝트' 카테고리의 다른 글

설계 품질과 트레이드오프  (0) 2023.10.01
역할, 책임, 협력  (1) 2023.09.30
객체, 설계  (0) 2023.09.29