GYUD-TECH

[스터디: 오브젝트] 타입계층의 구현 방법 본문

스터디

[스터디: 오브젝트] 타입계층의 구현 방법

GYUD 2024. 3. 21. 17:18

 

앞서 서브클래싱과 서브타이핑에 대해 공부하면서 타입에 대해서 공부했다.

앞에서는 타입계층을 구현하는 방식으로 상속을 소개하고, 상속의 목적은 코드의 중복제거가 아닌 타입계층의 구현이 되어야 한다고 강조했다.

<13장 링크>

 

이번 장에서는 상속을 포함한 타입 계층을 구현하기 위한 다양한 방법들을 알아본다.

다양한 방법을 공부하면서 결국 타입은 객체의 행동에 의해서 결정된다는 것을 느낄 수 있었다.

 


타입계층의 구현

클래스를 이용한 타입계층 구현

타입은 객체가 반응할 수 있는 오퍼레이션의 집합인 인터페이스만을 정의하는 것이다.

따라서 타입이 같더라도 구현은 다를 수 있다.

 

하지만 클래스를 이용하면 구현을 하는 것이기 때문에 타입 뿐만 아니라 세부 구현까지 구현하는 것이다.

 

객체지향은 객체들의 협력을 중요시하는 패러다임이다.

객체지향이라고 하면 흔히들 클래스를 떠올리지만,  협력은 객체가 외부에 제공하는 행동에 따라 결정되기 때문에 결국 클래스가 아닌 타입으로 객체지향의 설계가 결정된다는 것을 다시 한번 강조한다.

 

 

추상 클래스를 이용한 타입계층 구현

추상 클래스를 사용함으로써, 상속을 통해 구현과 타입을 정의함과 동시에 결합도로 인한 부작용을 피하는 방법이다.

 

 

인터페이스를 이용한 타입계층 구현

상속을 사용하면 하나의 타입밖에 구현하지 못하지만, 인터페이스를 사용하면 하나의 클래스가 여러 타입을 구현할 수 있다.

 

 

추상클래스와 인터페이스를 함께 사용하여 타입계층 구현

추상 클래스 위에 상위 인터페이스를 하나 더 두어서 계층을 구성하여 더 유연한 타입 계층을 설계하는 방식이다.

 

아래와 같이 추상 클래스를 활용하여 할인 정책을 구현했다고 가정하자.

DiscountPolicy에서 일반 메서드로 calculateDiscountAmount() 메서드를 선언하여 해당 메서드의 구현을 공유하고 있다.

 

만약 DiscountPolicy와 calculateDiscountAmount()를 공유하지 않는 다른 할인 정책이 추가된다면 어떻게 해야할까?

이때는 공통적인 부분만 남기고 둘이 다른 부분을 하위 클래스로 내려야하기 때문에 코드의 수정이 필요하다.

 

코드의 수정 없이 변경사항을 반영하기 위해서는 추상 클래스 위에 인터페이스를 추가하여 client는 인터페이스에 의존하도록 설계해야한다.

이렇게 하면 client는 오직 인터페이스에만 의존하기 때문에 세부 구현은 전혀 모른다.

또한 인터페이스의 하위 클래스들은 자신의 로직을 캡슐화하기 때문에 새로운 정책이 추가되더라도 인터페이스를 구현한 새로운 클래스를 추가해주기만 하면 된다.

 

인터페이스를 활용해서 타입을 정의하면 상속 계층에 얽매이지 않고 서비스를 유연하게 설계할 수 있다.

또한 추상 클래스를 사용해서 중복되는 구현 코드를 줄일 수도 있다.

 

하지만 장점만 존재하는 것은 아니다.

추상 클래스와 인터페이스를 모두 사용하기 때문에 클래스간의 관계를 복잡하게 만든다.

 

따라서 타입의 구현 방법이 한가지거나, 단일 상속 계층 만으로 타입계층을 구현하는데 무리가 없다면, 클래스나 추상클래스를 활용하는 것이 더 좋은 방법이다.

 

 

덕 타이핑

객체의 행동이 같다면 별다른 타입에 대한 명세가 없어도 같은 타입으로 분류한다는 의미이다.

public interface Employee {
    Money calculatePay(double taxRate);
}


public class SalariedEmployee {
    private String name;
    private Money basePay;
    
    public SalariedEmployee(String name, Money basePay) {
        this.name = name;
        this.basePay = basePay;
    }
    
    public Money calculatePay(double taxRate) {
        return basePay.minus(basePay.times(taxRate));
    }
}


public class HourlyEmployee {
    private String name;
    private Money basePay;
    private int timeCard;
    
    public HourlyEmployee(String name, Money basePay, int timeCard) {
        this.name = name;
        this.basePay = basePay;
        this.timeCard = timeCard;
    }
    
    public Money calculatePay(double taxRate) {
        return basePay.times(timeCard).minus(basePay.times(timeCard).times(taxRate));
    }
}

자바와 같은 정적타입 언어에서는 SalariedEmployee 와 HourlyEmployee가 완전히 같은 퍼블릭 메서드를 가지고 있지만, 같은 타입이라고 명시하지 않았기 때문에 같은 타입으로써 사용할 수 없다.

 

반면 동적 언어의 경우에는 이 둘을 같은 타입으로 취급하는데 이를 덕 타이핑이라고 한다.

 

덕 타이핑을 사용하면 객체 간 결합도를 메시지 수준으로 낮출 수 있기 때문에 유연한 설계가 가능하다.

하지만 반대로 컴파일 시점에 발견할 수 있는 오류를 실행시점이 되어야지 발견할 수 있어 타입 안정성 측면에서 단점도 존재한다.

 

이 단점을 보안하기 위해서 C++의 템플릿에서는 컴파일타임에 체크를 통해 타입 안정성을 보장하면서 덕 타이핑의 장점도 제공한다.

class SalariedEmployee
{
private:
    string name;
    long base_pay;
    
public:
    SalariedEmployee(string name, long base_pay);
    long calculate_pay(double tax_rate);
};

SalariedEmployee::SalariedEmployee(string name, long base_pay)
{
    this->name = name;
    this->base_pay = base_pay;
}

long SalariedEmployee::calculate_pay(double tax_rate)
{
    return base_pay - (base_pay * tax_rate);
}

class HourlyEmployee
{
private:
    string name;
    long base_pay;
    int time_card;

public:
    HourlyEmployee(string name, long base_pay, int timeCard);
    long calculate_pay(double tax_rate);
};

HourlyEmployee::HourlyEmployee(string name, long base_pay, int time_card)
{
    this->name = name;
    this->base_pay = base_pay;
    this->time_card = time_card;
}

long HourlyEmployee::calculate_pay(double tax_rate)
{
    return (base_pay * time_card) - (base_pay * time_card) * tax_rate;
}
template <typename T>
long calculate(T employee, double tax_rate)
{
    return employee.calculate_pay(tax_rate);
}

위와 같이 template을 통해 메서드를 호출하면 calculate_pay에 응답할 수 있는 객체라면 어떤 것이든 T에 담겨서 메시지를 수행할 수 있다.

이때 T가 calculate_pay에 응답할 수 있는지를 컴파일 타임에 체크하기 때문에 타입 안정성도 보장된다.

 

어떻게 C++은 컴파일 타임에 객체의 응답 가능 여부를 확인할 수 있을까?

 

그 이유는 C++ 컴파일러가 컴파일을 하면서 calculate_pay를 수행할 수 있는 HourlyEmployee와 SalariedEmployee 객체를 T에 대입한 2가지 버전의 calculate() 메서드를 미리 생성해 놓기 떄문이다.

long calculate(HourlyEmployee employee, double tax_rate)
{
    return employee.calculate_pay(tax_rate);
}

long calculate(SalariedEmployee employee, double tax_rate)
{
    return employee.calculate_pay(tax_rate);
}

이런 방식을 사용하기 떄문에 만약 calculate_pay를 수행할 수 있는 객체가 없다면 컴파일 타임에 오류를 알려줄 수 있다.

 

이 방법은 덕 타이핑과 타입 안정성을 동시에 챙겼다는 장점이 있다.

하지만 같은 메서드를 복사해서 여러번 생성하기 때문에 프로그램의 비효율성을 감수해야 한다는 단점도 존재하는 것을 명심하고 주의깊게 사용해야 한다.

 

자바와 덕 타이핑

덕 타이핑은 객체의 행동이 타입을 결정한다는 것을 잘 보여주는 개념이다.

자바에서 덕 타이핑이 가능하지는 않지만, 객체의 행동이 타입을 결정한다는 사실은 같기 때문에 행동이 핵심이라는 것을 다시 한번 느낄 수 있었다.

 

 

믹스인

이전에 믹스인에 대해서 소개한 적이 있다.

https://gyuwon-tech.tistory.com/38

 

[스터디: 오브젝트] 합성과 믹스인

앞선 장의 마지막에 상속보다는 합성을 사용하자고 강조했다. 이번 장에서는 상속과 합성의 차이를 비교하고 왜 상속보다 합성을 사용해야 하는지에 초점을 맞추어서 공부하였다. 또한 중복 코

gyuwon-tech.tistory.com

해당 장에서는 scala 언어의 trait와 with 키워드를 사용하여 계층을 쌓는 방식으로 trait를 사용했다.

 

with가 아니라 trait를 extends하여 trait의 구현을 상속받는것도 가능하다.

추상 클래스와 똑같다고 생각할 수 있지만, trait는 다중 상속을 지원한다는 점이 추상 클래스와의 가장 큰 차이점이다.

 

자바에서 trait라는 개념은 없지만 인터페이스의 default 메서드를 사용하여 다중 상속을 지원하면서 구현까지 상속할 수 있다.

앞에서 했던 할인 정책 예시에서 추상 클래스가 아닌 default 메서드를 사용하면 아래와 같이 구현할 수 있다.

public interface DiscountPolicy {
    default Money calculateDiscountAmount(Screening screening) {
        ...
    }
    
    List<DiscountCondition> getConditions();
    Money getDiscountAmount(Screening screening);
}

추상클래스에 구현했던 calculateDiscountAmount() 메서드를 인터페이스에서 default 메서드로 구현하고, 이 메서드에서 호출하는 메서드들도 인터페이스에 선언해줘야 한다.

 

추상 클래스를 사용했을 때와 다른점은 기존에 protected였던 getDiscountAmount() 메서드가 public으로 선언됐다는 점과, getConditioins()라는 메서드가 public으로 추가되었다는 점이다.

 

인터페이스에서 자식에게 메서드를 상속하기 위해서는 public 메서드만을 사용해야 하기 떄문에 getConditions()와 getDiscountAmount()가 인터페이스로 노출되는 문제가 발생했다.

결국 불필요한 인터페이스가 외부로 노출되어 캡슐화가 약화되는 문제로 이어졌다.

 

또한 default 메서드를 사용함으로써 추상 클래스를 사용했을 때보다 중복도 많이 발생했다.

 

추상 클래스에서는 클래스 내부에 List<DiscountCondition> 타입의 필드를 선언하여 중복을 제거하였지만, 이를 인터페이스로 바꾸면 DiscountPolicy를 구현한 모든 클래스에서 List<DiscountCondition> 타입의 인스턴스 변수를 선언해야 한다.

getConditions() 역시 내부 구현은 같지만 모든 클래스에서 같은 내용을 반복해서 구현해야한다.

 

이런 문제점이 발생한 이유는 default를 추가한 목적이 추상클래스를 대체하기 위함이 아니기 때문이다.

default 메서드가 자바 8부터 추가된 이유는 인터페이스의 수정을 용이하게 하기 위함이다.

 

default 메서드가 아닌 인터페이스의 메서드를 수정해야 하는 경우에는 인터페이스를 구현한 하위 클래스를 모두 수정해 줘야한다.

하지만 default 메서드를 사용하면 구현을 공유하기 때문에 인터페이스의 default 메서드만 수정하면 돼서 수정에 용이하다는 장점이 있다.

 

따라서 추상 메서드를 대체하는 경우가 아닌, 인터페이스의 메서드의 잦은 수정이 예상될 때 default 메서드를 사용하는 것이라고 저자는 언급한다.

 


마지막 부분을 읽고, 인터페이스의 편리한 수정을 위해서 default 메서드를 사용하는 것에 의문을 품었다.

인터페이스가 수정되는 것은 객체의 협력자체가 수정되는 것이기 때문에 많은 변경은 불가피하다고 생각한다.

 

이 변경을 줄이기 위해서 default 메서드를 도입할 수는 있지만, 캡슐화가 깨진다는 치명적인 단점이 존재한다.

개인적인 생각으로는 인터페이스의 잦은 변경이 예상된다하더라도, default 메서드를 사용하지 않는것이 좋다고 생각한다.

변경이 예상된다면 변경이 최대한 없는 부분만을 인터페이스로 제공하는 것이 이제까지 공부한 객체지향 설계의 핵심이라고 생각한다.

 

<스터디 이후 다시 생각한 내용>

하지만 만약, default 메서드를 추가하더라도 캡슐화가 깨지지 않고, 인터페이스를 구현한 모든 클래스에 새로운 인터페이스를 추가해야 한다면 유용하게 사용할 수 있을 것 같다.

default 메서드를 사용하지 않는다면, 하위 모든 객체에 같은 내용을 override 한 메서드로 작성해야 하기 때문이다.

 

처음부터 이런 변화가 예상된 경우에는 추상클래스를 사용하면 좋겠지만, 갑작스럽게 인터페이스를 변경해야 한다면 default 메서드를 사용하는 것이 효율적인 것 같다.

 

실무에서 자주 쓰일 것 같지는 않지만, 인터페이스에 새로운 public 메서드를 추가해야할 때, default 메서드를 사용할 수도 있다는 것을 기억해두자.