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

영화 예매 시스템 설계

온라인 영화 예매 시스템을 설계해보자.

요구사항

  • '영화'와 '상영'의 용어 구분

    • 영화 - 영화에 대한 기본 정보 (제목, 상영시간, 가격 정보 등)

    • 상영 - 실제로 관객들이 영화를 관람하는 사건 (상영 일자, 시간, 순번 등)

      • 사용자가 실제로 예매하는(돈을 지불해 관람할 수 있는 권리를 구매하는) 대상은 영화가 아닌 상영이다.

  • 하나의 영화는 하루 중 다양한 시간대에 걸쳐 한 번 이상 상영될 수 있다.

  • 특정한 조건을 만족하는 예매자는 요금을 할인받을 수 있다. 할인액을 결정하는 두 가지 규칙이 있다.

    • 할인 조건(discount condition) - 가격의 할인 여부를 결정한다.

      • 순서 조건(sequence condition) - 상영 순번을 이용해 할인 여부를 결정하는 규칙.

      • 기간 조건(period condition) - 영화 상영 시작 시간을 이용해 할인 여부를 결정하는 규칙.

      • 다수의 할인 조건을 함께 지정할 수 있다. 순번 조건과 기간 조건을 혼합할 수 있다.

    • 할인 정책(discount policy) - 할인 요금을 결정한다.

      • 금액 할인 정책(amount discount policy) - 예매 요금에서 일정 금액을 할인해주는 방식.

      • 비율 할인 정책(percent discount policy) - 정가에서 일정 비율의 요금을 할인해주는 방식.

      • 영화별로 하나의 할인 정책만 할당할 수 있다. 아예 적용하지 않는 경우엔 기본 가격이 판매 요금이 된다.

    • 할인을 적용하기 위해서는 할인 조건과 할인 정책을 함께 조합해서 사용한다.

  • 사용자가 예매를 완료하면 시스템은 예매 정보(제목, 상영정보, 인원, 정가, 결제금액)를 생성한다.

협력, 객체, 클래스

객체지향 - "객체"를 지향하는 것. 진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 "객체"에 초점을 맞출 때 얻을 수 있다. 그러기 위해서는 다음 두 가지에 집중해야 한다.

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

    • 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 갖는지 먼저 결정해야 한다.

  2. 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다.

    • 객체는 홀로 존재하는 것이 아니고 다른 객체에게 도움을 주거나 의존하면서 살아가는 협력적인 존재이다. 이렇게 사고함으로써 설계를 유연하고 확장 가능하게 만든다.

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

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

도메인 - 영화 시스템의 목적은 영화를 좀 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결하는 것이다. 이처럼 도메인이란 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야이다.

도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결될 수 있다.

  • 일반적으로 클래스의 이름은 대응되는 도메인 개념의 이름과 동일하거나 적어도 유사하게 지어야 한다.

  • 클래스 사이의 관계도 최대한 도메인 개념 사이에 맺어진 관계와 유사하게 만들어 프로그램의 구조를 이해하고 예상하기 쉽게 해야 한다.

도메인의 개념과 관계를 반영하도록 프로그램을 구조화해야 하므로 클래스의 구조는 위와 같이 도메인의 구조와 유사한 형태를 띄어야 한다.

클래스 구현

인스턴스 변수의 가시성은 private, 메서드의 가시성은 public으로 둔다.

  • 클래스는 내부와 외부로 구분되며 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 감출지 결정하는 것이다.

    • 클래스의 내부와 외부를 구분하면, 그 경계의 명확성이 자율성을 보장해준다. 그리고 프로그래머에게 구현의 자유를 제공한다.

  • 외부에서는 객체의 속성에 직접 접근하지 못하도록 막고 적절한 public 메서드를 통해서만 내부 상태를 변경할 수 있도록 하자.

자율적인 객체

객체에 대한 중요한 사실 두 가지를 알아야 한다.

  1. 객체는 상태(state)행동(behavior)을 함께 가지는 복합적인 존재이다.

  2. 객체는 스스로 판단하고 행동하는 자율적인 존재이다.

  • 객체 지향 이전의 패러다임에서는 데이터와 기능이라는 독립적인 존재를 서로 엮어 프로그램을 구성했다. 이와 달리 객체지향은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶어 문제 영역의 아이디어를 적절히 표현할 수 있게 했다. 이처럼 데이터와 기능을 객체 내부로 함께 묶는 것을 캡슐화라고 한다.

  • 대부분의 객체지향 프로그래밍 언어들은 더 나아가 외부에서의 접근을 통제할 수 있는 접근 제어(access control) 메커니즘을 함께 제공한다. 또한 많은 언어들이 접근 제어를 위해 public, protected, private와 같은 접근 수정자(access modifier)을 제공한다. 이렇게 객체 내부에 대한 접근을 통제하는 이유는 외부의 간섭을 최소화하여 스스로의 결정에서 자유로운 존재로 만들기 위해서다.

  • 캡슐화와 접근 제어는 두 부분으로 나뉜다.

    • 퍼블릭 인터페이스(public interface) - 외부에서 접근 가능한 부분

    • 구현(implementation) - 외부에서 접근 불가능하고 오직 내부에서만 접근 가능한 부분

    • 인터페이스와 구현의 분리(separation of interface of implementation) 원칙은 훌륭한 객체지향 프로그램을 만들기 위해 따라야 하는 핵심 원칙이다.

일반적으로 클래스의 속성은 private로 선언해 감추고 외부에 제공해야 하는 일부 메서드만 public으로 선언해야 한다. 어떤 메서드들이 서브클래스나 내부에서만 접근 가능해야 한다면 protected 또는 private으로 지정하라.

퍼블릭 인터페이스에는 public으로 지정된 메서드만 포함되며, 나머지 메서드와 속성은 구현에 포함된다.

프로그래머의 자유

프로그래머의 역할을 클래스 작성자클라이언트 프로그래머로 구분하는 것이 유용하다.

  • 클래스 작성자(class creator) - 새로운 데이터 타입을 프로그램에 추가한다.

  • 클라이언트 프로그래머(client programmer) - 클래스 작성자가 추가한 데이터 타입을 사용한다.

  • 클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 숨겨야 한다. 이렇게 숨겨 놓은 부분에 접근할 수 없도록 함으로써 클라이언트 프로그래머에 대한 영향을 걱정하지 않고 내부 구현을 마음대로 변경할 수 있다. 이를 구현 은닉(implementation hiding)이라고 부른다.

객체 외부와 내부를 구분하면 클라이언트 프로그래머가 알아야 할 지식의 양이 줄어들고 클래스 작성자가 자유롭게 구현을 변경할 수 있는 폭이 넓어진다.

설계가 필요한 이유는 변경을 관리하기 위해서이다. 객체의 변경을 관리하는 기법 중 가장 대표적인 것이 접근 제어다. 변경될 가능성이 있는 세부 구현 내용을 private로 감춰 변경으로 인한 혼란을 최소화하자!

협력

프로그램에서 영화를 예매하기 위해 Reservation, Screening, Movie 등 인스턴스들은 서로의 메소드를 호출하며 상호작용한다. 이처럼 시스템의 어떤 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용을 협력(Collaboration)이라고 부른다.

협력에 대한 짧은 이야기

  • 객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청(request)할 수 있다.

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

  • 객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송(send a message)하는 것 뿐이다.

  • 다른 객체에게 요청이 도착할 때 해당 객체가 메시지를 수신(receive a message)했다고 이야기한다.

  • 메시지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정하며, 이러한 방법을 메서드(method)라고 부른다.

메시지 != 메서드. 이를 구분하는 것에서부터 다형성(polymorphism)의 개념이 출발한다.

ScreeningMoviecalculateMovieFee() '메서드를 호출한다'가 아니라, calculateMovieFee '메시지를 전송한다'고 말하는 것이 더 적절하다. ScreeningMovie 안에 그런 메소드가 있는지 알지 못하며, 단지 해당 메시지에 응답할 수 있다고 믿고 메시지를 전송할 뿐이다.

개념

추상 클래스

추상 클래스인 DiscountPolicy는 할인 여부와 요금 계산에 필요한 전체적인 흐름은 정의하지만 실제로 요금을 계산하는 부분은 추상 메서드인 getDiscountAmount() 메서드에게 위임한다. 실제로는 DiscountPolicy를 상속받은 자식 클래스에서 오버라이딩한 메서드가 실행될 것이다.

이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴이라고 부른다.

의존성

Movie 인스턴스는 코드 작성 시점에서는 AmountDiscountPolicyPercentDiscountPolicy의 존재조차 알지 못하지만 인스턴스 실행 시점에 협력이 가능하다. 생성자를 통해 구현체를 인스턴스를 전달하고 있기 때문이다.

코드의 의존성실행 시점의 의존성이 서로 다를 수 있다. 이를 통해 클래스를 유연하고 쉽게 재사용할 수 있다.

코드의 의존성과 실행 시점의 의존성이 다를수록 코드를 이해하기 어려워진다.

설계가 유연해질수록 코드를 이해하고 디버깅하기 점점 더 어려워진다.

상속과 업캐스팅(Upcasting)

어떤 클래스를 추가하고 싶은데 기존 클래스와 매우 흡사하다면, 재사용하고 싶어질 것이다. 이를 가능하게 해주는 것이 상속이다.

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

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

자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 때문에(인터페이스를 물려받기 때문에) 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다(대신할 수 있다).

이처럼 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅(upcasting)이라고 한다.

다형성과 지연(동적) 바인딩

앞에서 말했듯이 메시지 != 메서드.

메시지를 전송했을 때 실행되는 메서드는 연결된 객체의 클래스가 무엇인가에 따라 달라진다.

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

다형성을 구현하는 방법은 다양하나 메시지에 응답하기 위해 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 결정한다는 공통점이 있다.

이처럼 메시지와 메서드를 실행 시점에 바인딩하는 것을 지연 바인딩(lazy binding), 동적 바인딩(dynamic binding)이라고 부른다.

반대로 전통적인 함수 호출처럼 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것을 초기 바인딩(early binding), 정적 바인딩(static binding)이라고 부른다.

Note: 구현 상속과 인터페이스 상속

상속은 구현 상속인터페이스 상속으로 분류할 수 있다.

  • 구현 상속(implementation inheritance)

    • 서브클래싱(subclassing)이라고 부른다.

    • 순수하게 코드를 재사용하기 위한 목적으로 상속을 사용하는 것이다.

  • 인터페이스 상속(interface inheritance)

    • 서브타이핑(subtyping)이라고 부른다.

    • 다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속을 이용하는 것이다.

추상화

DiscountPolicyAmountDiscountPolicy, PercentDiscountPolicy보다 추상적이고, DiscountConditionSequenceConditionPeriodCondition보다 추상적이다.

추상화를 사용하는 데에 장점이 있다.

  1. 추상화의 계층만 따로 떼어 놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술할 수 있다.

  2. 추상화를 이용하면 설계가 더 유연해진다.

재사용 가능한 설계의 기본을 이루는 디자인 패턴이나 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 메커니즘을 활용하고 있다.

결론: 유연성이 필요한 곳에 추상화를 사용하라.

코드 재사용, 상속 대신 합

코드 재사용을 위해 상속보다는 합성(composition)이 더 좋은 방법이라는 이야기를 들었을 것이다.

합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법이다.

왜 상속 대신 합성을 선호할까?

  • 상속은 두 가지 관점에서 설계에 안 좋은 영향을 미친다.

    • 가장 큰 문제점으로, 상속은 캡슐화를 위반한다.

      • 부모 클래스의 구현이 자식 클래스에게 노출되므로 캡슐화가 약화된다. 캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합되게 만드므로, 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률이 높아진다. 결과적으로 상속을 과도하게 사용한 코드는 변경하기 어려워진다.

    • 상속을 설계를 유연하지 못하게 만든다.

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

🤔 My Thought: 여기서 스프링 IoC 컨테이너와 의존성 주입이 생각났다. 스프링은 객체지향 개발자의 노하우가 합쳐져 만들어진 결과물인데, 상속 대신 의존성 주입을 통해 합성을 만든다. 따라서 실행 시점에 어떤 클래스를 사용할지 결정할 수 있어 설계가 유연해진다.

반면 합성은 어떤가? 합성은 상속의 두 가지 문제점을 모두 해결한다.

  • 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화할 수 있다.

  • 의존하는 인스턴스를 비교적 쉽게 교체할 수 있어 설계를 유연하게 만든다.

✅ 상속은 클래스를 통해 강하게 결합되는 데 비해 합성은 메시지를 통해 느슨하게 결합된다.

따라서 코드 재사용을 위해서는 상속보다는 합성을 선호하는 것이 더 좋은 방법이다.

그렇지만 대부분 설계에서는 상속과 합성을 함께 사용해야 한다. 코드를 재사용하는 경우 상속보단 합성이 좋지만 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용할 수 밖에 없다.

Last updated