Chapter 2. 객체지향 프로그래밍

Minjae An·2023년 11월 18일

오브젝트

목록 보기
2/15

🎥 영화 예매 시스템

요구사항 살펴보기

'영화'와 '상영'을 구분할 필요가 있다. '영화'는 영화에 대한 기본 정보를 나타내고
'상영'은 실제로 관객이 영화를 관람하는 사건으로, 관객은 이를 위해 돈을 지불하기
때문에 분리하여 생각하는 것이 중요하다.

특정 조건을 만족하는 예매자는 요금을 할인받을 수 있다. 할인액을 결정하는 두 가지
규칙으로 할인 조건, 할인 정책 이 있다. '할인 조건'은 가격의 할인 여부를
결정하며 '순서 조건'과 '기간 조건'으로 나눌 수 있고, '할인 정책'은 '금액 할인
정책'과 '비율 할인 정책'이 존재한다.

영화별로 하나의 할인 정책만 할당할 수 있다. 물론 할인 정책을 할당하지 않는 것도
가능하다. 할인 조건은 섞거나, 중복하여 지정할 수 있다.
할인을 적용하기 위해 할인 조건과 할인 정책을 조합하여 사용한다. 예메 정보를
확인한 후 할인 조건을 만족하면 할인 정책을 이용하여 요금을 계산한다.

이 요구사항을 OOP를 이용하여 구현해보자.

🚣‍♀️ 객체지향 프로그래밍을 향해

협력, 객체, 클래스

대부분의 경우 클래스를 결정한 후에 클래스에 어떤 속성과 메서드가 필요한 지
고민한다. 하지만 OOP는 객체를 지향하는 패러다임이다. 따라서 다음 두 가지에 집중해야 한다.

어떤 클래스가 필요한지 고민하기 전에 어떤 객체들이 필요한지 고민하라

공통적인 상태와 행동을 공유하는 객체들을 추상화한 것이 클래스이다. 객체를 먼저
고려하고 클래스를 결정해야 한다.

객체를 독립적 존재가 아닌 기능 구현을 위해 협력하는 공동체의 일원으로 봐라

객체는 서로 의존하며 도움을 주는 협력적인 존재이다. 객체를 고립적인 존재로
바라보지 말자.

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

도메인 은 사용자가 프로그램을 사용하는 분야를 의미한다. 도메인을 구성하는
개념들과 그것들의 관계를 객체와 클래스로 연결하면 설계가 매끄러워진다.

클래스 구현하기

package chap2;

import java.time.LocalDateTime;

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

훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고, 어떤 부분은
감출 지 결정하는 것이다. Screening에서는 객체의 속성을 직접 접근하지 못하게
막고 적절한 public 메서드를 통해서만 내부 상태를 변경할 수 있게 하고 있다.

클래스의 내부, 외부를 명확하게 구분해야 하는 이유는 경계의 명확성이 객체의 자율성을
보장하기 때문이다. 그리고 프로그래머에게 구현의 자유를 제공한다.

자율적인 객체

  • 객체는 상태와 행동을 함께 가지는 복합적 존재이다.
  • 객체가 스스로 판단하고 행동하는 자율적인 존재이다.

데이터와 기능을 객체 내부로 함께 묶는 것을 캡슐화 라고 부른다.
한편 대부분의 OOP 언어들은 외부에서의 접근을 통제할 수 있는 접근 제어
메커니즘도 함께 제공한다. 접근 제어를 위해 접근 수정자 를 제공한다.

객체의 내부에 대한 접근을 통제하는 이유는 외부의 간섭을 최소하여 하여 객체를
자율적인 존재로 만들기 위함이다. 객체에게 원하는 것을 요청하고 객체가 최선의
방법으로 작업을 수행할 것을 믿어야 한다.

캡슐화와 접근 제어는 객체를 외부에서 접근 가능한 퍼블릭 인터페이스
오직 내부에서만 접근 가능한 구현 으로 나눈다.

일반적으로 객체의 상태는 숨기고 행동만 외부에 공개해야 한다. 속성은 private으로
감추고 외부에 제공해야 하는 메서드는 public으로 선언한다. 이 메서드들이
퍼블릭 인터페이스에 속한다.

프로그래머의 자유
프로그래머의 역할을 클래스 작성자클라이언트 프로그래머 로 구분하는
것이 유용하다. 클라이언트 프로그래머는 필요한 클래스들을 통해 빠르게 어플리케이션을
구축하고 클래스 작성자는 필요한 부분만 공개하고 클라이언트에 대한 영향을 걱정하지
않고 내부 구현을 변경한다. 이를 구현 은닉 이라고 부른다.

접근 제어를 활용하여 내부 구현을 은닉해 객체의 외부, 내부를 분리하면 클라이언트가
알아야 할 지식의 양이 줄어들고 클래스 작성자의 구현의 자유도가 높아진다.
설계가 필요한 이유는 변경을 관리하기 위해서라는 것을 명심하자.

협력하는 객체들의 공동체

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

영화를 예매하는 기능을 구현하는 메서드를 알아보자.

public class Screening {
	    private Money calculateFee(int audienceCount){
        	return movie.calculateMovieFee(this).times(audienceCount);
		}
}

MoviecalculateMovieFee 메서드 반환 값은 인당 예매 요금이다. 전체
예매 요금을 구하기 위해 해당 반환 값에 audienceCount를 곱한다.

package chap2;

import java.math.BigDecimal;

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

Money는 금액과 관련된 다양한 계산을 구현하는 클래스다. Long 타입을 사용하여
금액을 나타내는 것보다 Money 클래스를 정의하여 사용하는 것이 금액과 관련되어
있다는 의미를 더 명백히 전달할 수 있다. 또한 금액 관련 로직이 서로 다른 곳에
중복되어 구현되는 것을 막을 수 있다.

의미를 좀 더 명시적이고 분명하게 표현할 수 있다면 비록 하나의 인스턴스 변수만
포함하더라도 객체를 사용하여 구현하는 것이 좋다.

package chap2;

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

영화를 예매하기 위해 각 인스턴스들은 서로의 메서드를 호출하며 상호 작용한다.
이런 객체 간의 상호작용을 협력 이라고 부른다.

협력에 관한 짧은 이야기

객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청할 수 있다.
요청을 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답한다.

객체가 다른 객체와 상호작용하는 유일한 방법은 메시지를 전송하는 것뿐이다.
다른 객체에게 요청이 도착할 때 해당 객체가 메시지를 수신했다고 이야기한다.
수신된 메시지를 처리하기 위한 객체만의 방법을 메서드라고 부른다.

메시지와 메서드를 구분하는 것은 매우 중요하다. 이 구분에서부터 다형성
개념이 출발한다.

💸 할인 요금 구하기

할인 요금 계산을 위한 협력 시작하기

package chap2;

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

Movie의 코드를 보면 코드 어디에도 할인 정책을 판단하는 코드가 존재하지
않는다는 사실을 발견할 수 있다. 이 코드에는 상속다형성의 의미가
숨겨져 있다. 그리고 그 추상화 원리가 그 기반이 된다.

할인 정책과 할인 조건

두 가지 할인 정책을 두 클래스로 구현하되 중복 코드를 제거하기 위해
DiscountPolicy 클래스를 정의한다. 실제 인스턴스를 생성하지 않기에 추상
클래스를 활용했다.

package chap2;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

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

하나의 할인 조건이라도 만족하는 경우 추상 메서드getDiscountAmount
호출해 할인 요금을 계산한다.

DiscountPolicy는 할인 여부, 요금 계산에 필요한 전체 흐름은 정의하지만
실제 요금을 계산하는 작업은 getDiscountAmount에 위임한다. 따라서
자식 클래스의 오버라이딩된 메서드가 실행될 것이다. 이러한 디자인 패턴을
TEMPLATE METHOD PATTERN 이라고 부른다.

package chap2;

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

DiscountCondition은 자바의 인터페이스를 활용하여 선언한다.

package chap2;

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

SequenceCondition은 할인 여부를 판단하기 위해 사용할 순번을 인스턴스 변수로
포함한다.

package chap2;

import java.time.DayOfWeek;
import java.time.LocalTime;

public class PeriodDiscountCondition implements DiscountCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public PeriodDiscountCondition(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;
    }
}

PeriodCondition은 상영 시작 시간이 특정 기간 내에 포함되는지 여부를 판단해
할인 여부를 결정한다.

package chap2;

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

AmountDiscountPolicy는 할인 조건을 만족할 경우 일정 금액을 할인해주는 정책을
구현한다.

package chap2;

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

PercentDiscountPolicy는 일정 비율을 차감하는 할인 정책을 구현한다.

할인 정책 구성하기

Movie의 생성자는 오직 하나의 DiscountPolicy 인스턴스만 받을 수 있다.

public class Movie {
	public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
    ...
    this.discountPolicy = discountPolicy;
    }
}

반면 DiscountPolicy는 여러 DiscountCondition을 가질 수 있다.

public abstract class DiscountPolicy {
	public DiscountPolicy(DiscountCondition...conditions) {
    	this.conditions = Arrays.asList(conditions);
    }
}

이처럼 생성자의 파라미터 목록에 초기화에 필요한 정보를 전달하도록 강제하면 올바른
상태를 가진 객체의 생성을 보장할 수 있다.

👨‍🍼상속과 다형성

Movie 내부에 할인 정책을 결정하는 조건문이 없는데도 불구하고 어떻게
요금을 계산할 때 정책을 선택할 수 있을까?

컴파일 시간 의존성과 실행 시간 의존성


어떤 클래스가 다른 클래스에 접근 가능한 경로를 가지거나 해당 클래스 객체의 메서드를
호출할 경우 두 클래스 사이 의존성이 존재한다고 말한다.

위 다이어그램에서 눈여겨봐야 할 부분은 Movie가 오직 추상 클래스인
DiscountPolicy에 의존한다는 점이다.

Movie avatar = new Movie("아바타",
                Duration.ofMinutes(120),
                Money.wons(10_000),
                new AmountDiscountPolicy(Money.wons(800)));
Movie avatar = new Movie("아바타",
                Duration.ofMinutes(120),
                Money.wons(10_000),
                new PercentDiscountPolicy(0.1));

위 같은 코드에서 실행 시점에는 직접적인 DiscountPolicy의 구현체들에
의존하는 것을 확인할 수 있다.

여기서 알 수 있는 것은 코드의 의존성과 실행 시점의 의존성이 다를 수 있다는
점이다. 두 시점의 의존성이 달라지면 코드를 이해하기 위해 객체를 생성하고
연결하는 부분을 찾아야 하기 때문에 코드의 이해가 어려워진다. 하지만, 코드가
더 유연하고 확장 가능해진다는 막강한 강점이 가진다. 이런 트레이드-오프의
산물이 설계라는 점을 인지하자.

차이에 의한 프로그래밍

새로 추가하려는 클래스가 기존 클래스와 매우 흡사할 때 이를 가능케 해주는
좋은 방법이 상속이다. DiscountPolicy에 정의된 모든 속성과 메서드를
물려받는 AmountDiscountPolicy, PercentDiscountPolicy가 그 예시이다.

이와 같이 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게
만드는 방법을 차이에 의한 프로그래밍이라고 부른다.

상속과 인터페이스

상속이 가치 있는 이유는 메서드나 인스턴스 변수의 재사용이 아닌 부모 클래스가
제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다.

인터페이스가 객체가 이해할 수 있는 메시지의 목록을 정의하는 것이기 때문에
외부 객체는 자식 클래스를 부모 클래스와 동일하게 간주할 수 있다.

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

이를테면, 위 코드에서 Movie는 협력 객체가 calculateDiscountAmount라는
메시지를 이해할 수만 있다면 어떤 인스턴스인지는 상관하지 않는다.
이와 같이 부모 클래스를 대신하여 자식 클래스를 업캐스팅하여 사용할 수 있다.

다형성

MovieDiscountPolicycalculateDiscountAmount 메시지를 전송할 때
연결된 객체의 클래스가 무엇인지에 따라 다른 연산이 수행될 것이다. 이와 같이
동일한 메시지를 전송하지만 메시지를 수신하는 객체에 따라 실행되는 메서드가 달라지는
것을 다형성이라고 불는다.

다형성이란 동일 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는
능력을 의미한다. 따라서 다형적 협력에 참여하는 모든 객체들은 메시지를 이해할 수
있어야 한다. 앞선 두 할인 정책 클래스의 인터페이스를 통일하기 위해 상속이
활용되었다.

다형성을 구현하는 방법에서 메시지에 응답하기 위한 메서드를 실행 시점에 결정한다는
공통점이 있다. 메시지와 메서드를 실행 시점에 바인딩하는 것을 지연 바인딩 또는
동적 바인딩이라고 부른다.

참고 : 구현 상속과 인터페이스 상속
상속은 구현 상속과 인터페이스 상속으로 구분할 수 있다. 순수하게 코드를 재사용하기
위한 목적의 상속을 구현 상속이라 부르고, 다형적 협력을 위한 상속을 인터페이스
상속이라 부른다. 구현 상속은 변경에 취약한 코드를 생성할 수 있기에 상속은
인터페이스 상속을 목적으로 활용하는 것을 권장한다.

🎪 추상화와 유연성

추상화의 힘

추상화를 사용할 경우 강점은 다음과 같다.

  • 추상화 계층만 따로 떼어 놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술할 수 있다. (영화 예매 요금은 최대 하나의 '할인 정책'과 다수의 '할인 조건'을 이용해 계산할 수 있다)
  • 추상화를 사용하면 설계가 유연해진다.

추상화를 사용하면 세부 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다.
이를 통해 상위 개념만으로 도메인의 중요 개념을 설명할 수 있게 된다.

추상화를 이용해 상위 정책을 기술한다는 것은 어플리케이션의 협력 흐름을 기술한다는
것을 의미한다. 이는 설계를 유연하게 만든다.

유연한 설계

할인 정책이 적용되지 않는 경우 어떻게 코드를 구성할 수 있을까?

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

위 방식은 할인 정책이 없는 경우를 예외 케이스 취급하여 기존의 일관성 있는 협력
방식이 무너지게 한다. 일관성을 지키기 위해 할인 요금을 계산할 책임을
DiscountPolicy 계층에 유지시키는 방법을 사용하자.

package chap2;

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

중요한 것은 MovieDiscountPolicy는 수정하지 않고 NoneDiscountPolicy
추가하여 기능을 확장했다는 것이다.

추상 클래스와 인터페이스 트레이드오프

NoneDiscountPolicy의 코드를 살펴보면 getDiscountAmount가 어떤 값을
반환하더라도 상관 없다는 것을 알 수 있다. 부모 클래스인 DiscountPolicy에서
할인 조건이 없을 경우 getDiscountAmount를 호출하지 않기 때문이다.
이것은 부모 클래스와 서브 클래스를 개념적으로 결합시킨다.

이 문제를 해결하기 위해선 DiscountPolicy를 인터페이스로 바꾸고 NoneDiscountPolicy
DiscountPolicycalculateDiscountAmount를 오버라이딩하도록 변경하면 된다.

package chap2;

public interface DiscountPolicy {
    Money calculateDiscountAmount(Screening screening);
}

기존 DiscountPolicy를 대체할 기본 DefaultDiscountPolicy 클래스를 생성하자.

public abstract class DefaultDiscountPolicy implements DiscountPolicy { ... }

이제 NoneDiscountPolicyDiscountPolicy를 구현하도록 하면 개념적인 혼란과
결합을 제거할 수 있다.

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

이상적으로는 인터페이스를 사용하도록 변경한 설계가 좋다. 하지만, 구현과 관련된
모든 것들이 항상 트레이드오프의 대상이 될 수 있다는 점을 유의하자.

상속

상속은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법이다. 하지만
상속은 캡슐화를 위반하고, 설계를 유연하지 못하게 만든다.

상속을 이용하기 위해선 부모 클래스 내부를 잘 알고 있어야 하는데 결과적으로
부모 클래스의 구현이 자식 클래스에 노출되기 때문에 캡슐화가 약화된다.
자식 클래스가 부모 클래스에 강하게 결합되기 때문에 부모 클래스 변경시
자식 클래스에도 영향이 갈 확률이 높다.

상속은 부모 클래스와 자식 클래스의 관계를 컴파일 시점에 결정하기 때문에
실행 시점에 객체의 종류를 변경하는 것이 불가하다.

합성

합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는
방법을 말한다. MovieDiscountPolicycalculateDiscountAmount
메서드를 제공하다는 사실외 내부 구현에 대해서는 모른다. 이처럼 인터페이스에
정의된 메시지를 통해서만 코드를 재사용하는 방법이 합성이다.

합성은 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을
효과적으로 캡슐화할 수 있으며, 의존하는 인스턴스를 교체하는 것이 쉽기 때문에
설계를 느슨하게 만든다. 따라서 코드 재사용을 위해선 상속보단 합성을 선호하는
것이 좋다.

하지만 이것이 상속을 절대 사용하지 말라는 의미는 아니다. MovieDiscountPolicy
합성 관계이지만 DiscountPolicy와 그 구현체들은 상속 관계이다.
다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께
조합해서 사용해야 한다.

profile
도전을 성과로

0개의 댓글