1. 영화 예매 시스템
요구사항 살펴보기
영화 예매 시스템을 살펴보자.
여기에는 "영화"와 "상영"이라는 용어가 등장한다. 영화는 제목, 가격 등과 같이 영화에 대한 정보를 나타내고, 상영은 실제로 관객들이 영화를 관람하는 사건을 표현한다. 관객이 실제로 예매를 하는 대상은 특정 시간에 상영되는 영화이다.
특정한 조건을 만족하는 예매자는 요금을 할인받을 수 있다. 이때 필요한 것은 할인 조건(discount condition)과 할인 정책(discount policy)이다.
할인 조건은 '순서 조건'과 '기간 조건'으로 나뉘는데 순서 조건은 특정한 상영 순번을 가진 영화에 대해, 기간 조건은 요일, 시작 시간, 종료 시간을 정하여 해당 기간에 포함되는 영화에 대해 할인 혜택을 제공하는 것이다.
할인 정책은 '금액 할인 정책'과 '비율 할인 정책'이 있는데, 금액 할인 정책은 예매 요금에서 일정 금액을 할인해주는 방식이며, 비율 할인 정책은 정가에서 일정 비율의 요금을 할인해주는 방식이다.
영화별로 하나의 할인 정책만 할당할 수 있다. 다만 할인 조건은 다수의 할인 조건을 설정할 수 있다.
이러한 요구사항을 객체지향 프로그래밍 언어를 이용해 구현해보자.
2. 객체지향 프로그래밍을 향해
협력, 객체, 클래스
어떤 클래스(class)가 필요한지 먼저 고민하고, 클래스 결정 후 클래스의 속성과 메서드를 정하는 것은 객체지향 프로그래밍의 본질과는 거리가 있다.
객체지향 프로그래밍을 향하기 위해서는 프로그래밍을 하면서 아래 두 가지에 집중해야 한다.
첫째, 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라. 어떤 책임을 가지고 있는 객체를 만들지 먼저 결정해야 한다.
둘째, 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다. 객체는 서로 도움을 주고 받는 협력적인 존재이다.
도메인의 구조를 따르는 프로그램 구조
소프트웨어는 사용자가 원하는 어떤 문제를 해결하기 위해 만들어진다. 영화 예매 시스템의 목적은 영화를 좀 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결하는 것이다. 이처럼 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야를 도메인(domain)이라고 한다.
객제지향 패러다임에서는 요구사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있다.
그림 2.3은 영화 예매 도메인을 구성하는 개념과 관계를 표현한 것이고, 그림 2.4는 이 도메인 개념들을 클래스로 표현한 것이다. 도메인의 개념과 관계를 반영하도록 프로그램을 구조화해야 하기에 클래스의 구조는 도메인의 구조와 유사한 형태를 띠어야 한다.
클래스 구현하기
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | 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(); } } | cs |
Screening 클래스는 관람객의 예매 대상인 '상영'을 구현한다.
인스턴스 변수의 가시성은 private이고, 메서드의 가시성은 public이다. 클래스 구현시 중요한 것은 클래스의 경계를 구분하는 것이다. 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 감출지를 결정하는 것이다. 내부와 외부를 구분하는 이유는 경계의 명확성이 객체의 자율성을 보장하기 때문이다. 그리고 나아가 프로그래머에게 구현의 자유를 제공한다.
자율적인 객체
- 객체는 상태(state)와 행동(behavior)을 가지는 복합적인 존재
- 객체가 스스로 판단하고 행동하는 자율적인 존재
객체지향은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶음으로써 문제 영역의 아이디어를 적절하게 표현한다. 이렇게 데이터와 기능을 객체 내부로 묶는 것을 캡슐화라고 한다. 캡슐화에서 한 단계 더 나아가 접근 제어(access control) 매커니즘을 제공하는데, 이는 public, protected, private와 같이 접근 수정자를 통해 외부에서의 접근을 통제한다.
외부에서 접근 가능한 부분은 퍼블릭 인터페이스, 외부에서는 접근 불가하고 오직 내부에서만 접근 가능한 부분을 구현이라고 한다. 일반적으로 객체의 상태를 숨기고 행동만 외부에 공개해야 한다. 그렇게 객체에게 원하는 것을 요청하고는 객체가 스스로 최선의 방법을 결정할 수 있을 것이라는 점을 믿고 기다려야 한다.
프로그래머의 자유
프로그래머의 역할, 구현 은닉
- 클래스 작성자(class creator): 새로운 데이터 타입을 프로그램에 추가
- 클라이언트 프로그래머(client programmer): 클래스 작성자가 추가한 데이터 타입을 사용
클라이언트 프로그래머는 필요한 클래스를 엮어 애플리케이션을 구축하는 것이 목표이고, 클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 숨겨야 한다.
이렇게 객체의 외부와 내부를 구분한다면 클라이언트 프로그래머가 알아야 할 지식의 양은 줄어들고 클래스 작성자가 자유롭게 구현을 변경할 수 있는 폭이 넓어진다.
협력하는 객체들의 공동체
영화를 예매하기 위해 Screening, Movie, Reservation 인스턴스들은 서로의 메서드를 호출하며 상호작용한다. 이처럼 시스템의 어떤 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용을 협력(Collaboration)이라고 한다.
객체지향 프로그램을 작성할 때는 먼저 협력의 관점에서 어떤 객체가 필요한지를 결정하고, 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성한다.
협력에 관한 짧은 이야기
객체가 서로 상호작용할 수 있는 유일한 방법은 메시지이다. 요청을 메시지를 통해 전송하고, 메시지를 수신받은 객체는 자신만의 방법을 담은 메서드를 통해 메시지를 처리하고 응답한다.
3. 할인 요금 구하기
할인 요금 계산을 위한 협력 시작하기
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | 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)); } } | cs |
Movie 클래스에는 할인 정책을 결정하는 메서드는 존재하지 않고, discountPolicy에게 할인 요금을 계산해달라는 메시지를 보낼 뿐이다.
할인 정책과 할인 조건
부모 클래스로 DiscountPolicy를 두고 AmountDiscountPolicy와 PercentDiscountPolicy가 이 클래스를 상속받게 구현한다. 그리고 실제 애플리케이션에서는 DiscountPolicy의 인스턴스를 생성할 필요가 없기 때문에 추상 클래스를 사용한다.
이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 템플릿 메서드 패턴이라고 부른다.
하나의 영화에 대해 단 하나의 할인 정책만 설정가능하다는 조건과 할인 조건의 경우 여러 개 적용이 가능하다는 제약조건은 생성자의 파라미터로 강제할 수 있다.
4. 상속과 다형성
컴파일 시간 의존성과 실행 시간 의존성
Movie의 코드만을 봤을 때에는 할인 정책이 금액 할인(AmountDiscountPolicy)인지 비율 할인(PercentDiscountPolidy)인지 알 수 없고, 오직 추상 클래스인 DiscountPolicy에만 의존하고 있다.
하지만 실행 시점에서는 Movie의 인스턴스는 AmountDiscountPolicy나 PercentDiscountPolicy의 인스턴스에 의존하게 된다.
유연하고, 쉽게 재사용할 수 있으며, 확장 가능한 객체지향 설계의 특징은 코드의 의존성과 실행 시점의 의존성이 다르다는 것이다. 단, 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기 어려워진다는 것이다. 이와 같은 의존성의 양면성은 설계가 트레이드오프의 산물이라는 사실을 잘 보여준다.
설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 더 어려워진다는 사실과 유연성을 억제하면 코드를 이해하고 디버깅하기는 쉬워지지만 재사용과 확장 가능성은 낮아진다는 사실을 기억하자.
무조건 유연한 설계도, 무조건 읽기 쉬운 코드도 정답이 아니다. 항상 유연성과 가독성 사이에 고민하여 멋진 객체지향 설계를 해보자.
상속과 인터페이스
상속이 가치 있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다.
인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의한다는 것을 기억하자.
컴파일 시점과 실행 시점에 의존성의 차이를 구현할 수 있던 것도 이 상속 덕분이다.
다형성
메시지와 메서드는 다른 개념이다. Movie에서 DiscoutPolicy의 인스턴스에게 calculateDiscountAmount 메시지를 전송하면 실행되는 메서드는 Movie와 상호작용하는 객체의 클래스에 따라 달라진다.
다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있는 사실을 기반으로 한다.
- 지연 바인딩(lazy binding), 동적 바인딩(dynamic binding): 컴파일 시점과 런타임 시점에 의존성이 다른 상태를 말한다.
- 초기 바인딩(early binding), 정적 바인딩(static binding): 컴파일 시점과 런타임 시점에 의존성이 동일한 상태를 말한다.
상속을 이용하면 동일한 인터페이스를 공유하는 클래스들을 하나의 타입 계층으로 묶을 수 있기 때문에 대부분의 사람들은 다형성을 이야기할 때 상속을 함께 언급한다. 하지만 클래스를 상속받는 것만이 다형성을 구현할 수 있는 유일한 방법이 아니다.
인터페이스와 다형성
자바의 인터페이스는 말 그대로 구현에 대한 고려 없이 다형적인 협력에 참여하는 클래스들이 공유 가능한 외부 인터페이스를 정의한 것이다. 추상 클래스를 이용해 다형성을 구현했던 할인 정책과 달리 할인 조건은 구현을 공유할 필요가 없기 때문에 자바의 인터페이스를 이용해 타입 계층을 구현할 수 있다.
5. 추상화와 유연성
추상화의 힘
추상화 사용시 장점을 살펴보자
- 추상화의 계층만 따로 떼어 놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술할 수 있다
- 추상화를 이용하면 설계가 좀 더 유연해진다.
추상화를 사용하면 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다. 추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술한다는 것을 의미한다.
유연한 설계
현재 구조에서 기존에 있던 코드의 변경없이 할인 정책을 받지 못하는 경우를 표현하고 싶다면 NoneDiscountPolicy를 사용하여 구현하면 된다. 이런 방법을 선택한다면 기존 코드의 코드와 기존의 책임 위치를 변경하지 않아도 된다는 장점이 있다.
1 2 3 4 5 6 7 | public class NoneDiscountPolicy extends DiscountPolicy { @Override protected Money getDiscountAmount(Screening screening) { return Money.ZERO; } } | cs |
추상 클래스와 인터페이스 트레이드오프
NoneDiscountPolicy 클래스의 코드를 자세히 살펴보면 getDiscountAmount() 메서드가 어떤 값을 반환하더라고 상관이 없다. 부모 클래스인 DiscountPolicy에서 할인 조건이 없을 경우 이 메서드를 호출하지 않기 때문이다. 이는 부모 클래스인 DiscountPolicy와 NoneDiscountPolicy를 개념적으로 결합시킨다. NoneDiscountPolicy의 개발자는 getDiscountAmount()가 호출되지 않을 경우 DiscountPolicy가 0원을 반환할 것이라는 사실을 가정하고 있기 때문이다.
이 문제를 해결하는 방법은 DiscountPolicy를 인터페이스로 바꾸고 NoneDiscountPolicy가 DiscountPolicy의 getDiscountAmount() 메서드가 아닌 calculateDiscountAmount()를 오버라이딩하도록 변경하는 것이다.
구현과 관련된 모든 것들은 트레이드오프의 대상이 될 수 있다. 작성하는 모든 코드에는 합당한 이유가 있어야 한다. 고민하고 트레이드오프하자
상속
상속은 객체지향에서 코드 재사용을 제공하지만 두가지 문제점이 있다.
상속의 가장 큰 문제점은 캡슐화를 위반한다는 것이다. 상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야 한다. 결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다. 캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다. 결과적으로 상속을 과도하게 사용한 코드는 변경하기도 어려워진다.
합성을 사용하면 이를 해결할 수 있다.
1 2 3 4 5 6 7 8 | public class Movie { private DiscountPolicy discountpolicy; public void changeDiscountPolicy(DiscountPolicy discountPolicy) { this.discountPolicy = discountPolicy; } } | cs |
합성
느낀점
무조건 유연한 설계도, 무조건 읽기 쉬운 코드도 정답이 아니라는 부분이 와닿았다.
고민하고 트레이드오프 하자.
'OOP' 카테고리의 다른 글
[OOP] 객체지향 (0) | 2024.01.28 |
---|---|
[오브젝트] 5장 책임 할당하기 (0) | 2023.12.09 |
[오브젝트] 4장 설계 품질과 트레이드오프 (0) | 2023.12.02 |
[오브젝트] 3장 역할, 책임, 협력 (0) | 2023.11.28 |
[오브젝트] 1장 객체, 설계 (1) | 2023.11.21 |