Devin.KR

상속·인터페이스·다형성

개발자KR 조회 2

이 장에서 배우는 것

앞 장에서 값처럼 다루는 타입을 살펴보았다. 이번에는 여러 종류의 객체를 같은 방식으로 사용하는 방법을 배운다. 카페의 커피와 쿠키는 이름과 가격을 갖지만, 금액을 계산하거나 주문서에 표시하는 규칙은 다를 수 있다. 주문을 처리하는 코드는 이런 차이를 모두 알아야 할까. 상품이 제공할 동작을 먼저 정하면 구체적인 상품 종류를 몰라도 주문을 처리할 수 있다.

이 장의 중심은 상속 문법 자체보다 책임을 나누는 기준이다. 상품의 공통 틀은 상속으로 표현하고, 주문마다 바뀌는 할인 정책은 별도 객체로 연결한다. 두 방법을 함께 사용하면서 변경이 어디에 머무르는지 확인한다.

  • abstract로 파생 클래스가 채워야 할 동작을 정한다.
  • virtual과 override로 기본 동작을 제공하고 필요한 부분을 바꾼다.
  • 인터페이스와 기본 구현의 호출 규칙을 이해한다.
  • 실제 객체에 따라 동작이 선택되는 다형성을 설명한다.
  • 독립적으로 바뀌는 기능은 합성을 먼저 검토한다.

문제 상황

동네 카페에서 아메리카노와 버터 쿠키를 판매한다. 아메리카노는 주문서에 뜨거운 음료라는 안내를 붙인다. 버터 쿠키는 세 개 이상 주문하면 해당 주문 항목의 상품 금액에서 500원을 뺀다. 여기에 상품 종류와 무관하게 사용할 수 있는 1,000원 할인권이 추가되었다.

주문서 출력 메서드 안에서 상품 이름을 비교하면 처음에는 구현하기 쉽다. 하지만 새 상품이 생길 때마다 이름 비교와 계산 분기가 늘어난다. 상품 이름을 바꾸는 일조차 계산 규칙에 영향을 줄 수 있다. 표시 이름과 상품의 동작을 연결해 둔 것이 문제다.

상품별 클래스를 만드는 것만으로 해결되지도 않는다. 할인권을 사용하는 커피, 할인이 없는 커피, 할인권을 사용하는 쿠키를 각각 다른 클래스로 만들면 상품과 할인 조합마다 타입이 늘어난다. 할인 규칙 하나를 추가했는데 여러 상품 클래스를 손봐야 한다.

이번 프로그램은 상품이 자신의 표시 문구와 상품 금액을 결정하게 한다. 할인 정책은 그 금액을 받아 결제 금액을 계산한다. 주문 항목은 두 객체를 연결하고 결과를 출력한다. 재고 차감과 입력 처리는 다루지 않으며, 수량과 가격에는 코드에 적은 양의 정수만 사용한다. 계산 과정과 호출 대상이 드러나도록 실행 입력을 고정한다.

상속으로 공통 틀과 변경 지점을 정한다

상속(inheritance)은 기존 클래스의 멤버를 바탕으로 새로운 클래스를 정의하는 관계다. 공통 틀을 제공하는 쪽을 기반 클래스, 그 틀을 이어받는 쪽을 파생 클래스라고 부른다. 이 예제에서 CafeItem은 카페 상품의 기반 클래스이며, Coffee와 Cookie는 파생 클래스다.

상속 관계는 “이것은 저것의 한 종류다”라는 설명과 맞아야 한다. 커피는 카페 상품의 한 종류이므로 자연스럽다. 할인 정책은 카페 상품의 한 종류가 아니다. 코드를 재사용할 수 있다는 이유만으로 두 대상을 상속 관계로 묶으면 의미와 구조가 어긋난다.

abstract는 구현을 요구한다

추상 클래스(abstract class)는 직접 객체를 만들 수 없는 클래스다. abstract class CafeItem이라고 선언하면 new CafeItem(...)은 허용되지 않는다. 하지만 추상 클래스도 필드, 속성, 생성자와 구현이 있는 메서드를 가질 수 있다. 상품 이름과 단가는 기반 클래스가 보관하고, 그 값을 설정하는 생성자도 제공할 수 있다.

추상 메서드(abstract method)는 본문 없이 선언한다. public abstract int GetTotal(int quantity);는 모든 구체적인 상품이 수량에 따른 금액 계산을 제공해야 한다는 뜻이다. 직접 객체를 만들 수 있는 파생 클래스는 이 메서드를 override로 구현해야 한다. 구현을 미루는 파생 클래스라면 그 클래스도 추상 클래스여야 한다.

이 예제는 상품마다 계산 규칙을 검토하도록 금액 계산을 추상 메서드로 둔다. 커피는 단가와 수량을 곱하고, 쿠키는 그 결과에 묶음 구매 규칙을 적용한다. 실제 업무에서 모든 상품의 계산법이 같다면 기반 클래스에 구현을 두는 편이 더 단순할 수 있다. 추상 메서드는 공통 구현을 쓸 수 없거나, 각 파생 타입에 구현 책임을 명시하려는 지점에 사용한다.

virtual은 기본 동작을 제공한다

가상 메서드(virtual method)는 기반 클래스가 구현을 제공하면서 파생 클래스의 변경을 허용하는 메서드다. CafeItem.GetLabel()은 기본적으로 상품 이름을 반환한다. 쿠키에는 이 동작이면 충분하다. 커피는 같은 메서드를 재정의(override)하여 이름 뒤에 뜨거운 음료라는 안내를 붙인다.

override는 기반 클래스의 재정의 가능한 멤버를 이어받아 구현한다는 표시다. 이름이 같은 메서드를 새로 적는 것과 다르다. 이 예제처럼 매개변수가 없는 문자열 반환 메서드를 재정의할 때는 접근 수준과 반환 형식을 그대로 유지한다. 기반 메서드에 virtual이나 abstract 같은 재정의 근거가 없다면 파생 클래스에서 임의로 override를 붙일 수 없다.

abstract와 virtual은 구현 책임을 다르게 나눈다
선언기반 클래스의 본문구체적인 파생 클래스의 책임
abstract 메서드없다override로 구현해야 한다
virtual 메서드있다그대로 사용하거나 재정의한다
일반 메서드있다override로 바꿀 수 없다

변수의 타입과 실제 객체를 구분한다

다형성(polymorphism)은 공통 타입으로 여러 객체를 다루면서 실제 객체에 맞는 동작을 실행하는 성질이다. CafeItem item = new Coffee("아메리카노", 3500);에서 변수의 선언 타입은 CafeItem이고 실제 객체의 타입은 Coffee다. 변수에 기반 타입을 사용해도 객체가 기반 클래스 객체로 바뀌거나 일부가 잘려 나가지 않는다.

선언 타입은 코드에서 어떤 멤버를 사용할 수 있는지 결정한다. 이 변수로는 CafeItem이 공개한 GetLabel()과 GetTotal()을 호출할 수 있다. 그 호출에서 어느 재정의 구현을 실행할지는 실제 객체가 결정한다. 따라서 item.GetLabel()은 커피의 안내 문구를 반환한다. 호출하는 쪽에서 상품 종류를 비교하지 않아도 된다.

CafeItem 변수로 가상 메서드를 호출해도 실제 Coffee 객체의 재정의 구현이 실행된다

이 설명은 모든 같은 이름의 메서드에 적용되는 규칙이 아니다. 클래스에서는 가상 메서드와 재정의의 연결이 중요하다. 일반 메서드를 숨기는 경우에는 변수의 선언 타입에 따라 다른 메서드가 선택될 수 있다. 뒤에서 이 차이를 실수 사례로 확인한다.

인터페이스로 계약과 기본 동작을 나눈다

인터페이스(interface)는 객체가 제공할 동작을 계약으로 표현하는 타입이다. 이번 예제의 IDiscountPolicy는 상품 금액을 받아 할인 후 금액을 반환하는 Apply()와 할인 설명을 반환하는 GetLabel()을 정의한다. 주문 항목은 어떤 할인 클래스가 연결되었는지 몰라도 이 두 동작을 사용할 수 있다.

C# 클래스는 직접 기반 클래스를 하나만 지정할 수 있지만 인터페이스는 여러 개 구현할 수 있다. 클래스 상속은 공통 상태와 구현까지 연결하는 관계다. 인터페이스는 서로 다른 클래스에 같은 사용 방법을 제공하는 데 적합하다. 인터페이스에도 구현을 둘 수 있으므로 단순히 “코드가 없는 클래스”라고 이해하면 호출 규칙을 놓치게 된다.

기본 구현은 생략 가능한 동작을 제공한다

인터페이스 기본 구현(default interface implementation)은 인터페이스 멤버에 본문을 제공하는 기능이다. 여기서는 GetLabel()의 기본 결과를 "할인 없음"으로 정한다. NoDiscount는 Apply()만 구현하고 이 설명을 그대로 사용한다. FixedDiscount는 자신의 할인액을 보여 주는 GetLabel()도 구현한다.

중요한 점은 기본 구현이 클래스의 공개 멤버로 추가되는 것은 아니라는 사실이다. NoDiscount 객체를 만들었다고 해서 NoDiscount 타입의 변수로 GetLabel()을 호출할 수 있는 것은 아니다. 그 클래스에는 해당 메서드 선언이 없다. 기본 구현을 사용하려면 IDiscountPolicy 타입의 참조로 호출해야 한다.

FixedDiscount가 인터페이스의 GetLabel()을 직접 구현할 때는 override를 붙이지 않는다. 기반 클래스의 가상 메서드를 재정의하는 상황이 아니기 때문이다. 인터페이스 계약에 맞는 공개 메서드를 작성하면 이 예제의 인터페이스 호출은 그 클래스 구현으로 연결된다.

기본 구현을 추가하면 기존 구현 클래스들이 공통 동작을 이용할 수 있지만, 그 동작이 모든 구현에 적절한지는 따로 판단해야 한다. 예를 들어 실제로 할인하면서 설명만 기본값인 “할인 없음”을 남기면 계산과 안내가 어긋난다. 컴파일러는 설명 문구의 업무상 의미까지 검증하지 않는다. 기본 구현은 생략해도 의미가 맞는 동작에 두어야 한다.

언어 규칙을 더 확인하려면 Microsoft의 interface 참조와 override 참조를 볼 수 있다.

독립적으로 바뀌는 기능은 합성을 먼저 검토한다

합성(composition)은 필요한 객체를 멤버로 보관하고 그 객체에 일을 맡기는 설계 방식이다. 여기서 합성이라는 말은 객체를 필드로 연결해 사용하는 넓은 의미로 쓴다. 연결한 객체의 생성과 수명을 반드시 보유 객체 하나가 전부 관리한다는 뜻은 아니다.

OrderLine은 상품 하나와 할인 정책 하나를 보관한다. 상품은 상품 금액을 계산하고, 할인 정책은 그 금액에 할인을 적용한다. 주문 항목은 두 결과를 이어서 출력한다. 할인 정책을 바꾸기 위해 커피의 파생 클래스를 더 만들 필요가 없다. 주문 항목을 만들 때 다른 정책 객체를 전달하면 된다.

이번 예제에서 “합성 우선”은 상속을 사용하지 말라는 규칙이 아니다. 먼저 기능이 독립적으로 바뀌는지 살펴보고, 그렇다면 별도 객체로 분리하라는 판단 기준이다. 커피와 쿠키는 공통 상품 계약을 만족하는 종류 관계다. 상품과 할인은 서로 다른 방향으로 바뀌므로 객체 연결로 조합한다.

OrderLine은 상품과 할인 정책을 각각 참조하므로 두 기능을 독립적으로 조합할 수 있다

책임을 나누어도 호출 순서는 업무 규칙에 맞아야 한다. 이 프로그램은 상품 자체의 금액 규칙을 먼저 적용하고 주문 할인을 나중에 적용한다. 쿠키 세 개의 금액은 5,400원에서 500원을 뺀 4,900원이다. 여기에 1,000원 할인 정책을 연결한다면 결제 금액은 3,900원이 된다. 순서가 달라지는 정책을 도입할 때는 이 연결 규칙부터 검토해야 한다.

또한 기반 타입을 받는 코드는 파생 객체가 계약을 지킨다고 기대한다. GetTotal()이 어떤 상품에서는 총액을, 다른 상품에서는 단가를 반환하면 같은 호출 방식만 갖추었을 뿐 의미 있는 다형성은 성립하지 않는다. 이 예제의 계약은 “전달받은 수량 전체의 상품 금액을 정수 원 단위로 반환한다”이다.

완성 코드

.NET 10 콘솔 프로젝트의 Program.cs를 다음 내용으로 교체한다. 최상위 문을 타입 선언보다 앞에 두고, 필요한 네임스페이스를 명시했다. 아래 프로그램은 한 파일로 완성되어 있다. 이후 실수 사례와 연습 문제에 나오는 짧은 코드는 이 프로그램의 일부를 교체하거나 추가하는 조각이다.

using System;

CafeItem coffee = new Coffee("아메리카노", 3500);
CafeItem cookie = new Cookie("버터 쿠키", 1800);

IDiscountPolicy coupon = new FixedDiscount(1000);
IDiscountPolicy regular = new NoDiscount();

OrderLine coffeeOrder = new OrderLine(coffee, 2, coupon);
OrderLine cookieOrder = new OrderLine(cookie, 3, regular);

coffeeOrder.Print();
Console.WriteLine();
cookieOrder.Print();

abstract class CafeItem
{
    public string Name { get; }
    protected int UnitPrice { get; }

    protected CafeItem(string name, int unitPrice)
    {
        Name = name;
        UnitPrice = unitPrice;
    }

    public abstract int GetTotal(int quantity);

    public virtual string GetLabel()
    {
        return Name;
    }
}

sealed class Coffee : CafeItem
{
    public Coffee(string name, int unitPrice)
        : base(name, unitPrice)
    {
    }

    public override int GetTotal(int quantity)
    {
        return UnitPrice * quantity;
    }

    public override string GetLabel()
    {
        return $"{base.GetLabel()} / 뜨거운 음료";
    }
}

sealed class Cookie : CafeItem
{
    public Cookie(string name, int unitPrice)
        : base(name, unitPrice)
    {
    }

    public override int GetTotal(int quantity)
    {
        int total = UnitPrice * quantity;
        return quantity >= 3 ? total - 500 : total;
    }
}

interface IDiscountPolicy
{
    int Apply(int amount);

    string GetLabel()
    {
        return "할인 없음";
    }
}

sealed class NoDiscount : IDiscountPolicy
{
    public int Apply(int amount)
    {
        return amount;
    }
}

sealed class FixedDiscount : IDiscountPolicy
{
    private readonly int discountAmount;

    public FixedDiscount(int discountAmount)
    {
        this.discountAmount = discountAmount;
    }

    public int Apply(int amount)
    {
        return amount >= discountAmount
            ? amount - discountAmount
            : 0;
    }

    public string GetLabel()
    {
        return $"{discountAmount}원 할인";
    }
}

sealed class OrderLine
{
    private readonly CafeItem item;
    private readonly int quantity;
    private readonly IDiscountPolicy discount;

    public OrderLine(
        CafeItem item,
        int quantity,
        IDiscountPolicy discount)
    {
        this.item = item;
        this.quantity = quantity;
        this.discount = discount;
    }

    public void Print()
    {
        int subtotal = item.GetTotal(quantity);
        int payable = discount.Apply(subtotal);

        Console.WriteLine($"{item.GetLabel()} x {quantity}");
        Console.WriteLine($"상품 금액: {subtotal}원");
        Console.WriteLine($"할인: {discount.GetLabel()}");
        Console.WriteLine($"결제 금액: {payable}원");
    }
}

줄별 해설

using System;은 Console을 짧은 이름으로 사용하게 한다. 이어지는 두 상품 생성문은 실제로는 서로 다른 클래스의 객체를 만들지만 변수에는 모두 CafeItem 타입을 사용한다. 이 시점부터 호출하는 코드는 공통 상품 계약에 의존한다.

coupon과 regular의 선언 타입은 IDiscountPolicy다. 실제 객체는 각각 FixedDiscount와 NoDiscount다. 특히 regular를 인터페이스 타입으로 선언했으므로 기본 구현인 GetLabel()에 접근할 수 있다.

두 new OrderLine(...) 호출은 상품, 수량, 할인 정책을 한 주문 항목에 연결한다. 커피 두 잔에는 할인권을, 쿠키 세 개에는 할인 없는 정책을 전달한다. 두 번의 Print() 호출 사이에 있는 인수 없는 Console.WriteLine()은 빈 줄 하나를 출력한다.

abstract class CafeItem은 미완성 상품 틀을 선언한다. Name은 외부에서 읽을 수 있고, UnitPrice는 protected이므로 기반 클래스와 파생 클래스 내부에서 접근할 수 있다. 둘 다 읽기 전용 자동 속성이며 이 코드에서는 기반 클래스 생성자가 값을 설정한다. 주문 항목이 직접 단가를 읽어 계산하지 않도록 접근 범위를 나누었다.

protected CafeItem(...)은 파생 클래스가 공통 상태를 초기화할 때 사용하는 생성자다. 생성자는 상속되는 멤버가 아니다. 각 파생 클래스의 생성자가 base(name, unitPrice)로 기반 생성자를 호출하면 기반 부분의 초기화가 먼저 수행된다. 파생 생성자의 본문이 비어 있어도 이름과 단가는 이미 설정된다.

GetTotal() 선언 끝의 세미콜론은 이 추상 메서드에 본문이 없음을 보여 준다. 이어지는 GetLabel()에는 본문과 virtual이 있으므로 기본 동작을 그대로 사용할 수도, 바꿀 수도 있다.

sealed class Coffee : CafeItem의 콜론은 기반 클래스를 지정한다. sealed는 이 클래스에서 다시 파생하는 것을 막는다. 이 예제는 구체적인 상품을 상속의 끝으로 두며, sealed 자체가 객체의 모든 상태를 변경 불가능하게 만드는 것은 아니다.

커피의 GetTotal()은 상속받은 단가에 수량을 곱한다. GetLabel()의 base.GetLabel()은 기반 클래스의 구현을 직접 사용한다. 여기서는 상품 이름을 얻은 뒤 안내 문구를 덧붙인다. 같은 자리에서 GetLabel()만 호출하면 현재 메서드가 자신을 다시 호출하므로 의도한 기반 호출이 되지 않는다.

Cookie도 기반 생성자로 공통 상태를 설정한다. 금액 계산의 조건식은 수량이 세 개 이상일 때 주문 항목 전체에서 500원을 한 번 뺀다. 개당 500원을 빼는 규칙이 아니다. 쿠키에는 GetLabel() 재정의가 없으므로 기반 클래스가 반환하는 상품 이름을 사용한다.

IDiscountPolicy.Apply()는 본문 없는 계약이다. 이 인터페이스 멤버들은 외부에서 호출할 수 있는 공개 계약이며, 구현 클래스의 대응 메서드도 public으로 작성했다. 반면 GetLabel()에는 본문이 있어 구현 클래스가 생략할 수 있다.

NoDiscount.Apply()는 전달받은 금액을 그대로 반환한다. 별도의 조건 없이 “할인 없음”도 하나의 정책 객체로 표현한 것이다. 덕분에 주문 항목은 할인이 없는 경우를 알아내는 분기를 두지 않고도 늘 Apply()를 호출할 수 있다.

FixedDiscount의 discountAmount 필드는 생성자에서 설정한다. readonly는 생성자 밖에서 이 필드에 새 값을 대입하지 못하게 한다. this.discountAmount는 필드를, 오른쪽의 discountAmount는 매개변수를 가리킨다. Apply()는 할인액이 상품 금액보다 크면 0을 반환하여 음수 결제 금액을 막는다.

OrderLine의 세 필드는 연결된 상품, 주문 수량, 할인 정책을 보관한다. 참조 필드의 readonly는 다른 객체로 참조를 바꾸지 못하게 할 뿐, 참조 대상의 내부 상태까지 고정하지는 않는다. 이 프로그램에서는 생성자가 받은 참조를 그대로 보관하며 객체를 복사하지 않는다.

Print()의 첫 줄은 실제 상품 객체의 GetTotal()을 호출한다. 둘째 줄은 그 금액을 실제 할인 정책에 전달한다. 이어지는 네 출력문은 상품 설명, 상품 금액, 할인 설명, 결제 금액을 순서대로 표시한다. 상품은 클래스의 재정의를 통해, 할인은 인터페이스의 구현 선택을 통해 각자 알맞은 동작을 수행한다.

실행 결과

macOS 또는 Linux에서 다음 명령으로 프로젝트를 만든다. 생성된 Program.cs를 완성 코드로 교체한 뒤 실행한다.

dotnet new console --framework net10.0 -n CafeInheritance
cd CafeInheritance
dotnet run

프로그램의 예상 출력은 다음과 같다.

아메리카노 / 뜨거운 음료 x 2
상품 금액: 7000원
할인: 1000원 할인
결제 금액: 6000원

버터 쿠키 x 3
상품 금액: 4900원
할인: 할인 없음
결제 금액: 4900원

커피 금액은 3,500원에 두 잔을 곱한 7,000원이며, 할인권 적용 후 6,000원이다. 쿠키는 1,800원에 세 개를 곱한 값에서 묶음 구매 할인 500원을 뺀다. NoDiscount는 이 금액을 그대로 반환한다. 사용자 입력, 현재 시간, 임의의 값에 의존하지 않으므로 같은 코드의 출력은 일정하다.

실무에서 자주 틀리는 것

new로 메서드를 숨기고 재정의했다고 생각한다

다음은 Coffee의 GetLabel() 자리에 넣으면 의도와 다르게 동작하는 코드 조각이다. new는 기반 멤버를 숨기겠다는 명시적인 선언이므로 그 자체로 경고가 나지는 않는다. 하지만 기존 가상 호출의 구현을 바꾸지는 않는다.

public new string GetLabel()
{
    return $"{Name} / 뜨거운 음료";
}

Coffee 타입으로 직접 호출하면 새 메서드가 보이지만, 완성 코드처럼 CafeItem 참조로 호출하면 기반 구현이 실행되어 상품 이름만 나온다. 기반 타입을 사용하는 주문 항목에도 변경을 적용하려면 다음처럼 고친다.

public override string GetLabel()
{
    return $"{base.GetLabel()} / 뜨거운 음료";
}

new와 override는 경고를 없애기 위한 대체 문법이 아니다. 호출 규칙을 다르게 만드는 선택이다. 기반 타입으로 사용해도 새 동작이 필요하다면 재정의가 맞는지 확인해야 한다.

인터페이스 기본 구현을 클래스 멤버로 호출한다

다음 코드는 컴파일 오류가 난다. var가 추론한 변수 타입은 NoDiscount이고, 이 클래스에는 GetLabel() 선언이 없다.

var policy = new NoDiscount();
Console.WriteLine(policy.GetLabel());

기본 구현을 제공하는 인터페이스 타입으로 변수를 선언하면 호출할 수 있다.

IDiscountPolicy policy = new NoDiscount();
Console.WriteLine(policy.GetLabel());

고친 조각의 출력은 할인 없음이다. 인터페이스 타입을 선택하는 것은 단순히 변수 이름 앞의 표기를 바꾸는 일이 아니다. 그 변수를 통해 사용할 계약을 선택하는 일이다. 클래스 자체에서도 같은 메서드를 공개해야 한다면 클래스에 구현을 명시하는 방법을 검토한다.

전달받은 정책을 사용하지 않고 내부에서 다시 만든다

다음은 OrderLine.Print() 앞부분을 잘못 바꾼 코드다. 주문을 만들 때 할인권을 전달해도 메서드 안에서 할인 없는 정책을 새로 만들기 때문에 항상 할인이 사라진다.

int subtotal = item.GetTotal(quantity);
IDiscountPolicy selectedPolicy = new NoDiscount();
int payable = selectedPolicy.Apply(subtotal);

나머지 출력문을 그대로 두면 할인 설명은 1,000원 할인이라고 나오는데 결제 금액은 7,000원이 되는 불일치까지 생긴다. 생성자가 받은 정책을 보관하는 필드를 사용하도록 고친다.

int subtotal = item.GetTotal(quantity);
int payable = discount.Apply(subtotal);

합성의 목적은 객체를 여러 개 만드는 데 있지 않다. 바깥에서 선택한 객체에 책임을 맡길 수 있어야 한다. 계산과 설명도 같은 정책 객체에 요청해야 정책 선택의 의미가 유지된다.

한눈에 보기

변경하려는 내용에 따라 사용할 장치를 고른다
장치핵심 의미이 예제의 역할주의점
abstract구체적인 타입에 구현을 요구한다상품별 총액 계산추상 클래스는 직접 생성할 수 없다
virtual바꿀 수 있는 기본 동작을 제공한다상품 이름 표시재정의가 없으면 기본 구현을 사용한다
override기존 가상 호출의 구현을 바꾼다커피 안내 문구멤버 숨김과 호출 규칙이 다르다
인터페이스사용할 동작의 계약을 정한다할인 적용과 설명동작의 의미도 일관되어야 한다
인터페이스 기본 구현생략 가능한 공통 동작을 제공한다할인 없음이라는 설명클래스 멤버로 자동 추가되지 않는다
합성보관한 객체에 일을 맡긴다상품과 할인 정책 연결전달받은 객체를 실제로 사용해야 한다

기반 클래스에 넣을 멤버는 모든 파생 상품에 의미가 있어야 한다. 특정 상품만 쓰는 기능이 계속 기반 클래스에 추가된다면 공통 틀이 적절한지 다시 살펴본다. 반대로 주문 항목이 구체적인 상품 타입을 계속 확인한다면 상품에 맡겨야 할 책임이 호출하는 쪽에 남아 있는지 검토한다.

연습 문제

  1. CafeItem item = new Coffee("라테", 4200); 다음에 item.GetLabel()을 출력하면 무엇이 나오는지 쓰고, 변수 타입과 실제 객체 타입의 역할을 각각 설명한다.
  2. 완성 코드의 타입 선언 아래에 Tea 클래스를 추가한다. CafeItem을 상속하고, 금액은 단가와 수량의 곱으로 계산하며, 표시 문구는 기반 구현을 사용한다. 최상위 문 영역에서 3,000원짜리 캐모마일 두 잔에 할인 없는 정책을 적용하여 출력한다.
  3. IDiscountPolicy를 구현하는 ThresholdDiscount를 추가한다. 전달받은 상품 금액이 8,000원 이상이면 700원을 빼고, 그렇지 않으면 그대로 반환한다. 설명은 8000원 이상이면 700원 할인으로 정한다. 7,999원과 8,000원을 전달한 결과도 확인한다.
  4. 완성 코드에서 쿠키 주문에 regular 대신 coupon을 전달했을 때 네 줄의 출력이 어떻게 바뀌는지 쓴다. 상품 클래스의 수정이 필요한지도 설명한다.

정답과 해설

1. 실제 객체의 재정의가 실행된다

출력은 라테 / 뜨거운 음료다. 변수의 선언 타입인 CafeItem은 GetLabel()을 호출할 수 있는 근거를 제공한다. 실제 객체인 Coffee는 그 가상 메서드를 재정의했으므로 커피의 구현이 실행된다. 이 프로그램의 커피는 모두 뜨거운 음료로 표시한다는 단순한 규칙을 사용한다.

2. 바꾸지 않는 동작은 다시 작성하지 않는다

다음 타입을 완성 코드의 타입 선언 뒤에 추가한다. 추상 메서드는 구현하지만 기본 표시 문구는 그대로 사용하므로 GetLabel()을 작성하지 않는다.

sealed class Tea : CafeItem
{
    public Tea(string name, int unitPrice)
        : base(name, unitPrice)
    {
    }

    public override int GetTotal(int quantity)
    {
        return UnitPrice * quantity;
    }
}

다음 실행문은 첫 타입 선언인 abstract class CafeItem보다 앞에 추가한다. 최상위 문은 타입 선언 뒤에 둘 수 없다.

CafeItem tea = new Tea("캐모마일", 3000);
OrderLine teaOrder = new OrderLine(tea, 2, new NoDiscount());
Console.WriteLine();
teaOrder.Print();

기존 출력 뒤에 빈 줄과 다음 내용이 추가된다.

캐모마일 x 2
상품 금액: 6000원
할인: 할인 없음
결제 금액: 6000원

3. 새 정책은 기존 상품을 수정하지 않는다

다음 타입을 추가한다. 조건의 기준은 상품 객체가 계산해 전달한 금액이다. 수량이나 상품 종류를 정책 안에서 다시 알아낼 필요가 없다.

sealed class ThresholdDiscount : IDiscountPolicy
{
    public int Apply(int amount)
    {
        return amount >= 8000 ? amount - 700 : amount;
    }

    public string GetLabel()
    {
        return "8000원 이상이면 700원 할인";
    }
}

최상위 문 영역에 다음 확인 코드를 추가할 수 있다. 인터페이스의 설명 메서드를 직접 구현하므로 override를 붙이지 않는다.

IDiscountPolicy threshold = new ThresholdDiscount();
Console.WriteLine(threshold.Apply(7999));
Console.WriteLine(threshold.Apply(8000));

추가한 두 출력문은 차례로 다음 값을 출력한다. 기준 금액의 바로 아래와 기준 금액 자체를 비교하면 조건에 등호가 포함되었는지 확인할 수 있다.

7999
7300

4. 연결할 객체만 바꾸면 된다

쿠키 주문 생성문을 다음과 같이 바꾼다.

OrderLine cookieOrder = new OrderLine(cookie, 3, coupon);

쿠키 주문의 출력은 다음과 같다.

버터 쿠키 x 3
상품 금액: 4900원
할인: 1000원 할인
결제 금액: 3900원

쿠키가 자신의 규칙으로 계산한 4,900원에 할인 정책이 1,000원을 추가로 뺀다. 상품 금액과 표시 문구를 결정하는 책임은 그대로이므로 Cookie를 수정할 필요가 없다. 같은 할인 객체를 두 주문이 참조해도 이 예제에서는 할인액이 바뀌거나 할인권이 소진되지 않는다. 일회용 할인권을 구현하려면 사용 상태에 대한 별도 규칙이 필요하다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.