10장 상속과 코드 재사용

객체지향 프로그래밍 장점 중 하나는 코드를 재사용하기가 용이하다는 것이다. 코드를 복사하여 재사용하는 전통적인 방법과 다르게 객체지향은 코드를 재사용하기 위해 ‘새로운’코드를 추가한다. 객체지향에서 코드는 일반적으로 클래스 안에 작성되기 때문에 객체지향에서 클래스를 재사용하는 방법은 새로운 클래스를 추가하는 것이다.

만약 모든 코드가 객체지향적이라면 항상 새로운 코드를 추가함으로써 잘 동작하는 소프트웨어를 만들 수 있을 것 같다. 새로운 도메인이 추가되더라도 의존성 전이가 발생하지 않게 이미 잘 끊어놓은 상태의 코드라면 두개의 파일만 컴파일해도 되기에? or 다형성이 구현된 상태라면 한개의 클래스를 추가함으로써 기능의 확장이 가능하다.

이번 장은 코드의 재사용이 가능한 상속합성중에서 상속을 중점적으로 다룬다. 다루면서 상속의 고질적인 문제점과 상속과 합성의 각각의 장단점을 비교해보자.

절대로 양분되지 않는 내용이라고 생각한다. 상속이 절대적으로 유리한 부분도 있지만 우리가 흔히 애플리케이션을 개발할 때는 불확실성이 높고 확장성을 생각해야 하기에 합성이 더 유리하다고 하는 것이라 생각한다. 엔진을 예로 엔진은 게임 프레임워크를 어느정도 강제해야 하기 때문에 GameObject와 같이 상속 구조를 가져간다. 하지만 이는 우리가 사용하는 도구에 불과하다. 따라서 해당 구조에 탑승하여 이를 활용하는 것(게임 프레임워크를 활용)과 실제 동적모델을 설계하는 과정이 동일해서는 안된다고 생각한다.

상속과 중복 코드

중복 코드는 사람들의 마음속에 의심과 불신의 씨앗을 뿌린다. 중복 코드를 발견하면 두 코드가 정말 동일한지에 대한 의심이 발생하고 이미 유사한 코드가 있지만 왜 새로운 코드를 만드는지에 대한 의문이 발생한다. 이런 의심과 불신이 협업에서는 큰 취약점으로 자리 잡을 수 있지만 가장 큰 문제점은 바로 변경에 대한 문제이다.

DRY원칙

기본적으로 중복 코드는 변경을 방해한다. 이것이 우리가 객체지향을 공부하는 이유이기도 하며 프로그램의 본질이기도 하다. 요구사항은 항상 변화하는데 코드는 변화할 수 없다는 것은 말이 안되기에 코드 역시 변경에 유연해야 한다. 우리가 개발해야 하는 소프트웨어는 그렇다.

중복코드는 코드를 수정하는데 필요한 노력이 몇배로 증가하게 된다. 중복 코드의 수정은 해당 코드에만 영향을 주는 것이 아니기 때문에 관련된 모든 코드에게도 영향을 준다. 따라서 효과적으로 중복을 제거하기 위한 DAY원칙(Don’t Repeat yourself)을 따라야한다.

모든 시스템내에서 단일하고, 예매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다. 한 번, 단 한번 또는 단일 지점 제어 원칙이라고도 부른다.

중복과 변경

중복 코드 살펴보기

중복 코드의 문제점을 이해하기 위해 간단한 요금을 계산하는 애플리케이션이 있다.

  • 10초당 5원의 통화료를 부과하는 요금제에 가입되어 있다
    • 100초의 통화는 50원으로 계산된다.
    • 이를 Call이라는 동적 모델로 만들면 fromto라는 두개의 인스턴스 변수를 가진다.
  • 통화 요금을 계산할 객체는 전체 통화 목록을 알고 있는 정보 전문가인 Phone이다.
    • PhoneCall의 목록을 가지고 있으며 calculateFee라는 메서드를 가지고 있다. 이를 통해 전체 통화 요금을 계산한다.
    • 계산을 위해 단위 요금인 amountseconds를 인스턴스 변수로 가진다.
Phone phone = new Phone(Money.wons(5), Duration.seconds(10));
phone.Call(new Call(LocalDateTime.of(2018, 1, 1, 12, 10, 0), LocalDateTime.of(2018, 1, 1, 12, 11, 0)));
phone.Call(new Call(LocalDateTime.of(2018, 1, 1, 12, 13, 0), LocalDateTime.of(2018, 1, 1, 12, 15, 0)));
phone.calculateFee();

위 코드는 정상동작하고 도메인에 맞게 설계되었다고 말할 수 있다. 하지만 요구사항은 항상 변한다. 애플리케이션 역시 예외일수는 없고 만약 요구사항으로 심야 할인 요금제가 추가된다면 어떻게 해야할까? 여기선 일단 단점을 봐야하기에 상속 구조를 사용해본다.

심야 할인이 아니라 심야 할증이 되어야 하는게 아닌가?

중복 코드 수정하기

중복 코드가 코드 수정에 미치는 영향을 살펴보기 위해 샤로운 요구사항을 추가한다. 지금은 통화 요금을 계산하는 로직은 PhoneNightlyDiscountPhone 양쪽 모두에 구현돼있기 때문에 세금을 추가하기 위해서는 두 클래스를 함께 수정해야 한다.

중복 코드를 제거하지 않는 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것뿐이다. 새로운 코드를 수정할 수 있는 방법은 새로운 중복 코드를 추가하는 것뿐이다. 새로운 코드를 추가하는 과정에서 코드의 일관성이 무너질 위험이 항상 도사리고 있다. 중복 코드가 늘어날수록 애플리케이션은 변경에 취약하고 버그가 발생할 확률이 높아진다.

민첩하게 변경하기 위해서는 중복 코드를 추가하는 대신 제거해야 한다. 기회가 생길 때마다 코드를 DRY하게 만들어야 한다.

타입 코드 사용하기

두 클래스 사이의 중복 코드를 제거하는 방법은 클래스를 하나로 합치는 것이다. 다형성이 필요한 코드에서 타입 코드(enum)으로 구분하여 로직을 분기시켜서 하나로 합치는 것은 가능하지만, 이는 낮은 응집도와 높은 결합도를 가져온다.

객체지향 프로그래밍 언어는 타입 코드를 사용하지 않고도 중복 코드를 관리할 수 있는 효과적인 방법을 제공한다. 이 방법은 객체지향 프로그래밍을 대표하는 기법으로 일컬어지기도 하는 상속이다.

상속을 이용해서 중복 코드 제거하기

상속은 이미 존재하는 클래스와 유사한 클래스가 필요하다면 코드를 복사하지 말고 상속을 이용해 코드를 재사용하는 것이다. 상속을 사용할 때는 상속을 염두에 두고 설계되지 않은 이상 재사용하는 것은 생각보다 매우 어렵다. 계층 사이에 수많은 가정이 존재하고 그런 가정들은 코드를 이해하기 더욱 어렵게 만든다.

상속이 적절한 경우는 어떤 경우인지 조금 생각해본다면, 게임엔진의 경우를 예로 들 수 있을 것 같다. 매우 복잡한 구조를 상속이라는 강력한 기능을 이용해 재사용성을 줄이기 위해 계층을 미리 설계해놓고 이를 사용자로 하여금 이용할 수 있게 만드는 것이다. 그 구조를 따라가며 개발하되, 실제 개발에선 불확실성을 제어하기 위해 더욱 유연해져야 한다. 결국 인터페이스의 합성형태가 그 유연성을 제어하게 된다.

좀 더 정리하자면, 게임 엔진은 같은 객체를 쉽게 공유하기 위해서 강제해야 하는 부분이 존재하고 상속이라는 강한 결합을 오히려 이용하게 되는 것이다. (같은 프레임 워크안에 집어 넣기 위해 유니티Mono, 언리얼 UObject 등)

따라서 상속은 결합도를 높인다. 그리고 상속이 초래하는 부모 클래스와 자식 클래스 사이의 강한 결합이 수정하기 어렵게 만든다.

강하게 결합된 Phone과 NightlyDiscountPhone

부모 클래스와 자식 클래스 사이의 결합이 문제인 이유를 살펴보면, 그 계층 사이에 추가적인 로직이 생성될 때 쉽게 알 수 있다.

자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하자.

상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제라고 부른다. 취약한 기반 클래스 문제는 코드 재사용을 목적으로 상속을 사용할 때 발생하는 가장 대표적인 문제다.

취약한 기반 클래스 문제

앞서 살펴본 내용으로 상속은 자식과 부모가 강하게 결합되고 이로 인해서 불필요한 세부사항까지 엮이게 된다. 부모 클래스의 작은 변경에도 자식 클래스는 컴파일 오류와 실행 에러라는 고통에 시달리게 된다. 이를 앞서 설명한 취약한 기반 클래스 문제라고 부른다.

취약한 기반 클래스가 문제가 된다고 말하는데, 상속을 조금만 사용해봐도 완벽한 기반 클래스는 만들 수 없음을 잘 알 수 있다.

상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는 데는 용이하지만 높은 결합도로 인해 부모 클래스를 점진적으로 개선하는 것은 어렵게 만든다. 최악의 경우에는 모든 자식 클래스를 동시에 수정하고 테스트해야 할 수도 있다.

객체를 사용하는 이유는 구현과 관련된 세부사항을 퍼블릭 인터페이스 뒤로 캡슐화할 수 있기 때문이다. 캡슐화는 변경에 의한 파급효과를 제어할 수 있기 때문에 가치가 있다. 객체는 변경될지도 모르는 불안정한 요소를 캡슐화함으로써 파급 효과를 걱정하지 않고도 내부를 변경할 수 있다.

객체지향의 기반은 캡슐화를 통한 변경의 통제인 반면, 상속은 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스의 영향을 받기 쉬워진다.

불필요한 인터페이스 상속 문제

실제로 상속 문제로 인한 사례는 매우 많으며, 대표적인 사례로 자바의 Vector가 있다. 이 클래스는 Stack의 부모 클래스로 Vector부모 클래스의 인터페이스를 상속받아서 오버라이딩하여 구현하였다. 스택에 Push라는 명확한 메서드도 있지만 Vector로 부터 물려받은 Add라는 메서드는 많은 개발자들을 오용하게 만드는 요소로 작용된다.

이런 문제는 게임엔진에서도 애트리뷰트로 사용하지 않도록 경고를 주거나 상속 과정에서 엔진이 버전이 올라가며 레거시가 된 메서드도 매우 많다. 하지만 상속의 문제점이 드러나듯 전 버전을 사용한 엔진도 돌아가야 하기에 구현되어 있거나 레퍼로 둘러져 있거나 확장 메서드로 되어 있다.

결국 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.

메서드 오버라이딩 오작용 문제

메서드를 오버라이딩도 마찬가지로 많은 오용성을 불러일으킨다. 객체 자체에 대한 자율성보단 부모에 대한 강제성이 더 크기 때문에 결국 부모와 자식이 강하게 결합되게 되고 이는 앞에서 언급한 강한 결합성에 대한 여러 문제점들이 드러난다.

설계는 트레이드 오프 활동이고, 상속 코드 재사용을 위해 캡슐화를 희생한다. 완벽한 캡슐화를 원한다면 코드 재사용을 포기하고 상속 이외의 다른 방법을 사용해야 한다.

부모 클래스와 자식 클래스의 동시 수정 문제

간단한 예제로 플레이 리스트에서 노래를 지우는 기능이 추가된 다른 플레이리스트를 구현한다고 할 때, 기존 플레이 리스트 클래스를 상속받아 구현해두면 문제는 없지만, 요구사항이 변경되어 노래뿐만 아니라 가수까지 저장된다고 한다면 기존 플레이 리스트의 수정뿐만 아니라 새로운 플레이 리스트도 수정해야 한다.

이는 이미 여러번 언급한 부모와 자식의 강한 결합성을 가지기 때문에 무조건 발생하는 문제이다. 결합도는 결국 다른 대상에 대해 알고 있는 지식의 양으로 상속은 기본적으로 부모 클래스의 구현을 재사용한다는 기본 전제를 따르기 때문에 자식 클래스가 부모 클래스의 내부에 대해 속속들이 알도록 강요한다.

따라서 코드 재사용을 위한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할 수밖에 없는 것이다.

결국 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.

Phone 다시 살펴보기

상속으로 인해 발생하는 문제점을 살펴봤다면 이제는 상속으로 인한 피해를 최소화하는 방법을 알아본다. (추상화)

추상화에 의존하자

결국 부모와 자식이 강하게 결합되어 있는 이유는 상속을 받았기 때문인데, 이를 해결하기 위해선 자식 클래스가 부모 클래스의 구현이 아닌 추상화에 의존하도록 만드는 것이다. 정확하게는 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정해야 한다.

코드 중복을 제거하기 위해 상속을 도입할 때는 두 가지 원칙이 있다.

  • 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.
  • 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있다.

차이를 메서드로 추출하라

가장 먼저 할 일은 중복 코드 안에서 차이점을 별도의 메서드로 추출하는 것이다.

Phone 클래스와 NightlyDiscountPhone 클래스의 중복 코드를 제거하기 위해 두 클래스의 차이점을 calculateFee 메서드로 추출한다. 여기서 좀 더 생각해볼 점은 이 코드는 처음부터 상속 구조가 아닌 일단 중복성을 생각하지 않고 코드를 짰다는 점이다.

이 방법은 책 앞쪽에서 나오는 추상화 수준을 이해하는데 도움이 되는 전략이다.

중복 코드를 부모 클래스로 올려라

부모 클래스를 추가한다. 목표는 클래스들이 추상화에 의존하도록 하는 것이 때문에 추상 클래스로 구현하는 것이 적합할 것이다. AbstarctPhone 클래스를 추가하고 PhoneNightlyDiscountPhone 클래스를 상속받도록 한다.

책의 순서와 이 책에서 지속적으로 말하는 행위에 집중하여 가장 먼저 CalculateFee 메서드를 추출하고 이후에 필요한 데이터인 Calls를 올린다.

매우 간단한 코드이고, 많이 써온 코드일지라도 책에서 말하고자 하는 바에 집중하자. 이 코드는 상속의 고질적인 문제점을 해결하는 과정을 보여주는 코드이지 결과적인 코드가 아니다.

추상화가 핵심이다

공통 코드로 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다는 것을 보자. 3개의 클래스 모두 각각 하나의 변경 이유만을 가진다. 즉 SRP를 준수하기 때문에 응집도가 높다. 또한 오직 추상화에만 의존하기 때문에 낮은 결합도를 유지한다.

결국 이런 장점들은 추상화에 의존하기 때문에 얻어지는 장점이다.

의도를 드러내는 이름 선택하기

이 부분은 네이밍에 관련된 내용으로 추상 클래스가 생기게 되면서 각 객체가 담당하게 될 책임을 명확하게 해주는 것이 중요하다.

AbstractPhone 클래스의 이름은 PhoneNightlyDiscountPhone 클래스의 책임을 명확하게 드러내지 않는다. 이를 해결하기 위해 AbstractPhone 클래스를 Phone 클래스로 변경하고 Phone 클래스를 RegularPhone 클래스로 변경한다.

세금 추가하기

세금이라는 모든 클래스에 공통적으로 적용되어야 하는 요구사항이 있다면 현재 구조에서 추상 클래스에 추가한다면?

이 경우엔 모든 클래스에 taxRate라는 변수를 초기화는 생성자가 추가된다. 클래스라는 도구는 메서드뿐만 아니라 인스턴스 변수도 함께 포함된다. 따라서 클래스 사이의 상속은 자식 클래스가 부모 클래스가 구현한 행동뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다.

인스턴스 변수의 목록이 변하지 않는 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스들을 독립적으로 진화시킬 수 있다. 하지만 인스턴스 변수가 추가되는 상황에서는 다르다. 자식 클래스는 자신의 인스턴스를 생성할 때 부모 클래스에 정의된 인스턴스 변수를 초기화해야 하기 때문에 자연스럽게 부모 클래스에 추가된 인스턴스 변수는 자식 클래스의 초기화 로직에 영향을 미치게 된다.

결과적으로 책임을 아무리 잘 분리하더라도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발한다. 하지만 동일한 세금 계산 코드를 중복 시키는 것보다는 현명한 선택이다. (균형잡기)

8장에서 객체 생성 로직이 변경됐을 때 영향을 받는 부분을 최소화하기 위해 노력해야 한다는 사실을 배웠다. 생성 로직에 대해서는 유연하게 대처가 가능한 방법들이 존재한다. (DI, 팩터리 등등) 따라서 생성 로직에 대한 변경을 막기보다는 핵심 로직의 중복을 막아라.

핵심 로직은 한 곳에 모아 놓고 조심스럽게 캡슐화해야 한다. 그리고 공통적인 핵심 로직은 최대한 추상화해야 한다.

결국 상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없다. 상속은 어떤 방식으로든 부모 클래스와 자식 클래스를 결합시킨다. 메서드 구현에 대한 결합은 추상 메서드를 추가함으로써 어느 정도 완화할 수 있지만 인스턴스 변수에 대한 잠재적인 결합을 제거할 수 있는 방법은 없다.

차이에 의한 프로그래밍

상속을 이용하면 이미 존재하는 클래스의 코드를 기반으로 다른 부분을 구현함으로써 새로운 기능을 빠르고 쉽게 추가할 수 있다. 상속이 강력한 이유는 익숙한 개념을 사용해서 새로운 개념을 빠르게 추가할 수 있기 때문이다.

이처럼 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍(programming by difference)이라고 한다.

차이에 의한 프로그래밍의 목표는 중복 코드를 제거하고 코드를 재사용하는 것으로 사실 중복 코드 제거와 코드 재사용은 동일한 행동을 가리키는 서로 다른 단어다. 중복을 제거하기 위해서는 코드를 재사용 가능한 단위로 분해하고 재구성해야 한다. 코드를 재사용하기 위해서는 중복 코드를 제거해서 하나의 모듈로 모아야 한다. 프로그래밍에서 중복 코드는 악의 근원이다.

객체지향에서 중복 코드를 제거하고 코드를 재사용하는 가장 유명한 방법은 상속이다. 여러 클래스에 공통적으로 포함되어 있는 코드를 하나의 클래스로 모아서 원래 클래스들에서 중복 코드를 제거한 후 중복 코드가 옮겨진 클래스를 상속 관계로 연결한다.

상속은 강력한 도구이며 매력적이기 때문에 갓 입문한 프로그래머일수록 쉽게 의존하게 된다. 사용하다 보면 누구나 문제점을 실감하지만, 쉽게 빠져나오기 어렵다.

결론은 필요한 경우에만 사용하고 합성을 사용하자.

느낀점

책에서는 되게 작은 사례로 다루었기 때문에 상속의 문제점을 몸으로 체감하기엔 부족하다는 생각이 든다. 이 책을 읽고 상속의 단점을 알게 되는 것도 좋지만, 스스로 코드에서 몸으로 느끼는 것은 다른 영역이라는 생각이다.

게임엔진을 사용해서 그런지 중간 중간 상속 구조를 사용하는 게임엔진에 대한 말이 있었는데, 결론적으로 게임엔진은 결국 동작하는 소프트웨어이고 프로그래머는 그 위에서 맞춰진 형태로 코드를 짜기 때문에 오히려 결합도가 필요하다.

논의사항

  • 책에서 나온 상속의 문제점 말고도 경험적인, 사례가 있을까요?

저는 상속이라는 달콤한 말에 많이 속은 것 같습니다. 상속이라는 익숙한 개념이 되게 이해하기 편했고, 실세계와 크게 다르지 않다고 생각하여 완벽한 기반 클래스를 만들고 싶어했습니다. 각 계층마다 최소한으로 코드를 짜고 다 상속받아서 코드를 짰는데, 깊이가 6~7까지 내려가니 이름 짓기도 힘들고, 코드 구조 자체가 더 이해하기 힘들었던 경험이 있네요

댓글남기기