들어가며

객체지향 프로그래밍에서 코드를 재사용 하는 대표적인 방법은 두 가지가 있다.

첫 째는 바로 상속이고 둘 째는 합성이다.

이번 장에서는 코드 재사용을 위한 상속에 대해 알아보면서 상속이 갖는 장단점을 살펴본다.


상속과 코드 재사용

객체지향 프로그래밍의 장점 중 하나는 코드 재사용이 용이하다는 것이다. 전통적인 패러다임에서 코드를 재사용하는 방법은 코드를 복사한 후 수정하는 것이다. 객체지향은 조금 다른 방법을 취한다. 객체지향에서는 코드를 재사용하기 위해 새로운 코드를 추가한다. 객체지향에서 코드는 일반적으로 클래스 안에 작성되기 때문에 객체지향에서 클래스를 재사용하는 전통적인 방법은 새로운 클래스를 추가하는 것이다.

상속과 중복 코드

중복 코드는 변경을 방해한다. 이것이 중복 코드를 제거해야 하는 가장 큰 이유다.

중복 코드가 가지는 가장 큰 문제는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다는 것이다.

모든 중복코드를 개별적으로 테스트해서 동일한 결과를 내놓는지 확인해야만 한다. 즉, 중복 코드는 수정과 테스트에 드는 비용을 증가시킬 뿐 아니라 시스템과 개발자를 공황상태로 몰아넣는다.

중복 여부를 판단하는 기준은 변경이다. 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면(의존성과 상관 없이) 이 코드는 중복이다. 함께 수정할 필요가 없다면 중복이 아니다. 중복 코드를 결정하는 기준은 코드의 모양이 아니다. 모양이 유사하다는 것은 단지 중복의 징후일 뿐이다. 중복 여부를 결정하는 기준은 코드가 변경에 반응하는 방식이다.


중복과 변경

중복 코드의 문제점을 이해하기 위해 간단한 애플리케이션을 개발해보자.

  1. 한 달에 한 번씩 가입자 별로 전화 요금을 계산한다.

  2. 전화 요금을 계산하는 규칙은 (통화 시간)/(단위 시간당 요금)으로 계산한다.

  3. 10초당 5원의 통화료를 부과하는 요금제에 가입되어있는 가입자가 100초동안 통화를 했다면 (100/10) * 5 = 50원이 부과된다.

먼저 개별 통화 기간을 저장하는 Call 클래스가 필요하다. Call은 통화 시작 시간(from)과 통화 종료 시간(to)을 인스턴스 변수로 포함한다.

이제 통화 요금을 계산하는 객체가 필요하다. 언제나 그런 것처럼 전체 통화 목록에 대해 알고 있는 정보 전문가에게 요금을 계산할 책임을 할당해야 한다. 일반적으로 통화 목록은 전화기 안에 보관된다. 따라서 Call의 목록을 관리할 정보 전문가는 Phone이다.

Phone 인스턴스는 요금 계산에 필요한 세 가지 인스턴스 변수를 포함한다. 첫 번째는 단위 요금을 저장하는 amount이고, 두 번째는 단위 시간을 저장하는 seconds다. 사용자가 10초당 5원씩 부과되는 요금제에 가입돼 있을 경우 amount의 값은 5원이 되고 seconds의 값은 10초가 된다. 세 번째 인스턴스 변수인 calls는 전체 통화 목록을 저장하고 있는 Call의 리스트다. calculateFee() 메서드는 amount, seconds, calls를 이용하여 전체 통화 요금을 계산한다.

다음은 Phone을 이용해 10초당 5원씩 부과되는 요금제에 가입한 사용자가 각각 1분 동안 두 번 통화를 한 경우의 통화 요금을 계싼하는 방법을 코드로 나타낸 것이다.

여기서 부터가 재미있는 부분이다.

요구 사항은 항상 변한다. 그리고 우리의 애플리케이션 역시 예외일 수는 없다. 시간이 흘러 심야 요금 할인제라는 새로운 요금 방식을 추가해야 한다는 요구사항이 접수됐다. 심야 할인 요금제는 밤 10시 이후의 통화에 대해 요금을 할인 해주는 방식이다.

이 요구사항을 해결할 수 있는 쉽고도 가장 빠른 방법은 Phone의 코드를 복사해서 NightlyDiscountPhone이라는 새로운 클래스를 만든 후 수정하는 것이다.

심야 할인 요금제를 구현하는 NightlyDiscountPhone은 밤 10시 이전에 적용할 통화 요금(regularAmount)과 밤 10시 이후에 적용할 통화요금(nightlyAmount), 단위 시간(seconds)을 인스턴스 변수로 포함한다. 예를 들어, 심야 할인 요금제가 10시 이전에는 10초당 5원이고 10시 이후에는 10초당 2원이라면 seconds는 10초, regularAmount는 5원, nightlyAmount는 2원의 값을 저장하고 있을 것이다.

NightlyDiscountPhone은 밤 10시를 기준으로 regularAmount와 nightlyAmount 중에서 기준 요금을 결정한다는 점을 제외하고는 Phone과 거의 유사하다. Phone의 코드를 복사해서 NightlyDiscountPhone을 추가하는 방법은 심야 시간에 요금을 할인해야 한다는 요구사항을 아주 짧은 시간 안에 구현할 수 있게 해준다.

하지만 구현 시간을 절약한 대가로 지불해야 하는 비용은 예상보다 크다. 사실 PhoneNightlyDiscountPhone 사이에는 중복 코드가 존재하기 때문에 언제 터질지 모르는 시한폭탄을 안고 있는 것과 같다.


중복 코드 수정하기

중복 코드가 코드 수정에 미치는 영향을 살펴보기 위해 새로운 요구사항을 추가해보자. 이번에 추가할 기능은 통화 요금에 부과할 세금을 계산하는 것이다. 부과되는 세율은 가입자의 핸드폰마다 다르다고 가정할 것이다. 현재 통화 요금을 계산하는 로직은 PhoneNightlyDiscountPhone 양쪽 모두에 구현돼있기 때문에 세금을 추가하기 위해서는 두 클래스를 함께 수정해야 한다.

Phone 클래스 먼저 수정하자. 가입자의 핸드폰별로 세율이 서로 달라야 하기 때문에 Phone은 세율을 저장할 인스턴스 변수인 taxRate를 포함해야 한다. taxRate의 값을 이용해 통화 요금에 세금을 부과하도록 PhonecalculateFee() 메서드를 수정하자.

NightlyDiscountPhone도 동일한 방식으로 수정하자.

이 예제는 중복 코드가 가지는 단점을 잘 보여준다. 많은 코드 더미속에서 어떤 코드가 중복 인지를 파악하는 일은 쉬운 일이 아니다. 중복 코드는 항상 함께 수정해야 하기 때문에 수정할 때 하나라도 빠트린다면 버그로 이어질 것이다. Phone은 수정했지만 NightlyDiscountPhone은 수정하지 않은 채 배포했다면 심야 할인 요금제의 모든 가입자에게 세금이 부과되지 않는 장애가 발생할 것이다.

더 큰 문제는 중복 코드를 서로 다르게 수정하기가 쉽다는 것이다. PhonecalculateFee() 메서드는 반환시에 result에 plus 메서드를 호출해서 세금을 더했지만 NightlyDiscountPhonecalculateFee() 메서드에는 plus 대신 minus 메서드를 호출하고 있다.

지금 살펴본 것처럼 중복 코드는 새로운 중복 코드를 부른다. 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것 뿐이다. 새로운 중복 코드를 추가하는 과정에서 코드의 일관성이 무너질 위험이 항상 도사리고 있다. 더 큰 문제는 중복 코드가 늘어갈수록 애플리케이션은 변경에 취약해지고 버그가 발생할 가능성이 높아진다는 것이다.


타입 코드 사용하기

두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다. 다음과 같이 요금제를 구분하는 타입 코드를 추가하고 타입 코드의 값에 따라 로직을 분기시켜 PohneNightlyDiscountPhone을 하나로 합칠 수 있다. 하지만 계속 강조했던 것 처럼 타입 코드를 사용하는 클래스는 낮은 응집도와 높은 결합도라는 문제에 시달리게 된다.

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


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

상속의 기본 아이디어는 매우 간단하다. 이미 존재하는 클래스와 유사한 클래스가 필요하다면 코드를 복사하지 말고 상속을 이용해 코드를 재사용 하라는 것이다. 앞에서 살펴본 것 처럼 NightlyDiscountPhone 클래스의 코드 대부분은 Phone 클래스의 코드와 거의 유사하다. 따라서 NightlyDiscountPhone 클래스가 Phone 클래스를 상속받게 만들면 코드를 중복시키지 않고도 Phone 클래스의 코드 대부분을 재사용 할 수 있다.

NightlyDiscountPhone 클래스의 calculateFee() 메서드를 자세히 살펴보면 이상한 부분이 눈에 띌 것이다.

  1. super 참조를 통해 부모 클래스인 PhonecalculateFee() 메서드를 호출해서 일반 요금제에 따라 통화 요금을 계산

  2. 위 값에서 통화 시작 시간이 10시 이후인 통화의 요금을 빼준다.

이렇게 구현된 이유를 이해하기 위해서는 개발자가 Phone의 코드를 재사용하기 위해 세운 가정을 이해하는 것이 중요하다. 즉, 개발자의 가정을 이해하기 전에는 코드를 이해하기가 어렵다.

위 예를 통해 알 수 있는 것처럼 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 생각처럼 쉽지 않다.

개발자는 재사용을 위해 상속 계층 사이에 무수히 많은 가정을 세웠을지도 모른다. 그리고 그 가정은 코드를 이해하기 어렵게 만들뿐만 아니라 직관에도 어긋날 수 있다.

우리가 기대한 것은 10시 이전의 요금에서 10시 이후의 요금을 차감하는 것이 아니라 10시 이전의 요금과 10시 이후의 요금을 더하는 것이다. 요구사항과 구현 사이의 차이가 크면 클수록 코드를 이해하기 어려워진다. 잘못 사용된 상속은 이 차이를 더 크게 벌린다.

4장에서 결합도를 하나의 모듈이 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 정도로 정의했다. 이 예제에서 볼 수 있는 것처럼 상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다. 이것은 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 함을 의미한다.

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


강하게 결합된 Phone과 NightlyDiscountPhone

부모 클래스와 자식 클래스 사이의 결합이 문제인 이유를 살펴보자. NightlyDiscountPhone은 부모 클래스인 PhonecalculateFee() 메서드를 오버라이딩 한다. 또한 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 호출한다.

NightlyDiscountPhonecalculateFee() 메서드는 자신이 오버라이딩한 PhonecalculateFee() 메서드가 모든 통화에 대한 요금의 총합을 반환한다는 사실에 기반하고 있다.

하지만 앞에서 설명했던 세금을 부과하는 요구사항이 추가된다면 어떻게 될까? Phone은 앞에서 구현했던 것처럼 세율(taxRate)을 인스턴스 변수로 포함하고 calculateFee 메서드에서 값을 반환할 때 taxRate를 이용해 세금을 부과해야 한다.

NightlyDiscountPhone은 생성자에서 전달받은 taxRate를 부모 클래스인 Phone의 생성자로 전달해야 한다. 또한 Phone과 동일하게 값을 반환할 때 taxRate를 이용해 세금을 부과해야 한다.

이제 PhoneNightlyDiscountPhone의 상속 계층이 가지는 문제점이 또렷해졌을 것이다. NightlyDiscountPhonePhone의 자식 클래스로 만든 이유는 Phone의 코드를 재사용하고 중복 코드를 제거하기 위해서다. 하지만 세금을 부과하는 로직을 추가하기 위해 Phone을 수정할 때 유사한 코드를 NightlyDiscountPhone에도 추가해야 한다.

다시말해서 코드의 중복을 제거하기 위해 상속을 사용했음에도 세금을 계산하는 로직을 추가하기 위해 새로운 중복 코드를 만들어야 하는 것이다.

이것은 NightlyDiscountPhonePhone의 구현에 너무 강하게 결합되어 있기 때문에 발생하는 문제다. 따라서 우리는 상속을 사용할 때 다음과 같은 경고에 귀를 기울일 필요가 있다.

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

지금까지 살펴본 예제들은 자식 클래스가 부모 클래스의 구현에 강하게 결합될 경우 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는다. 는 사실을 잘 보여준다.

상속을 사용하면 적은 노력으로도 새로운 기능을 쉽고 빠르게 추가할 수 있다. 하지만 그로 인해 커다란 대가를 치러야 할 수도 있다.

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

먼저 취약한 기반 클래스 문제가 발생하는 몇 가지 사례를 살펴본 후 다시 NightlyDiscountPhone 문제로 돌아오자.


취약한 기반 클래스 문제

지금까지 살펴본 것 처럼 상속은 부모 클래스와 자식 클래스의 결합도를 높인다. 이 강한 결합도로 인해 자식 클래스는 부모 클래스의 불필요한 세부 사항에 엮이게 된다. 부모 클래스의 작은 변경에도 자식 클래스는 컴파일 오류와 실행 에러라는 고통에 시달려야 할 수도 있다.

이처럼 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제라고 부른다.

이 문제는 상속을 사용한다면 피할 수 없는 객체지향 프로그래밍의 근본적인 취약성이다.

구현을 상속한 경우(extends를 사용한 경우) 자식 클래스는 기반 클래스에 강하게 결합되며, 이 둘 사이의 밀접한 연결은 바람직하지 않다. 설계자들은 이런 현상에 대해 "취약한 기반 클래스 문제"라는 명칭을 붙였다. 겉으로 보기에는 안전한 방식으로 기반 클래스를 수정한 것처럼 보이더라도 이 새로운 행동이 자식 클래스에게 상속될 경우 자식 클래스의 잘못된 동작을 초래할 수 있기 때문에 기반 클래스는 "취약하다." 단순히 기반 클래스의 메서드들만을 조사하는 것만으로는 기반 클래스를 변경하는 것이 안전하다고 확신할 수 없다. 모든 파생 클래스들을 살펴보고 테스트까지 해야 한다. 나아가 기반 클래스와 파생 클래스를 사용하는 모든 코드가 새로운 코드로 인해 영향을 받지 않았는지 점검해야 한다. 핵심적인 기반 클래스에 대한 단순한 변경이 전체 프로그램을 불안정한 상태로 만들어버릴 수도 있다.

취약한 기반 클래스 문제는 상속이라는 문맥 안에서 결합도가 초래하는 문제점을 가리키는 용어다. 상속 관계를 추가할수록 전체 시스템의 결합도가 높아진다는 사실을 알고있어야 한다. 상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는데는 용이하지만 높은 결합도로 인해 부모클래스를 점진적으로 개선하는 것은 어렵게 만든다.

취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높인다. 상속은 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만들기 때문에 캡슐화를 약화시킨다. 이것이 상속이 위험한 이유인 동시에 상속을 피해야 하는 첫 번째 이유다.

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

안타깝게도 상속을 사용하면 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경할지라도 자식 클래스가 영향을 받기 쉬워진다. 상속 계층의 상위에 위치한 클래스에 가해지는 작은 변경만으로도 상속 계층에 속한 모든 자손들이 급격하게 요동칠 수 있다.

객체지향의 기반은 캡슐화를 통한 변경의 통제다. 상속은 코드 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다.

이제 몇 가지 예제를 통해 상속이 가지는 문제점을 구체적으로 살펴보자.


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

자바의 초기 버전에서 상속을 잘못 사용한 대표적인 사례는 java.util.Propertiesjava.util.Stack이다. 두 클래스의 공통점은 부모 클래스에서 상속받은 메서드를 사용할 경우 자식 클래스의 규칙이 위반될 수 있다는 것이다. 먼저 Stack을 살펴보자.

Stack은 선입 선출 자료구조인 스택을 구현한 클래스다. Vector는 임의의 위치에서 요소를 추출하고 삽입할 수 있는 리스트 자료구조의 구현체로써 java.util.List의 초기 버전이라고 할 수 있다.

자바의 초기 컬렉션 프레임워크의 개발자들은 요소의 추가, 삭제 오퍼레이션을 제공하는 Vector를 재사용하기 위해 StackVector의 자식 클래스로 구현했다.

위 그림의 퍼블릭 인터페이스를 살펴보면 이 상속 관계가 가지는 문제점을 잘 알 수 있다. Vector는 임의의 위치(index)에서 요소를 조회하고, 추가하고, 삭제할 수 있는 get, add, remove 오퍼레이션을 제공한다. 이에 비해 Stack은 맨 마지막 위치에서만 요소를 추가하거나 제거할 수 있는 push, pop 오퍼레이션을 제공한다.

안타깝게도 StackVector를 상속받기 때문에 Stack의 퍼블릭 인터페이스에 Vector의 퍼블릭 인터페이스가 합쳐진다. 따라서 Stack에게 상속된 Vector의 퍼블릭 인터페이스를 이용하면 임의의 위치에서 요소를 추가하거나 삭제할 수 있다. 따라서 맨 마지막 위치에서만 요소를 추가하거나 삭제할 수 있도록 허용하는 Stack의 규칙을 쉽게 위반할 수 있다.

무엇이 문제일까? 바로 Stack의 규칙을 무너뜨릴 여지가 있는 위험한 Vector의 퍼블릭 인터페이스 까지도 함께 상속받았기 때문이다. 물론 Stack을 사용하는 개발자들이 Vector에서 상속받은 add() 메서드를 사용하지 않으면 된다고 생각할 수 있다. 하지만 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 만들어야 한다.

java.util.Properties 클래스는 잘못된 유산을 물려받는 또 다른 클래스다. Properties 클래스는 KEY : VALUE 쌍을 보관한다는 점에서는 Map과 유사하지만 다양한 타입을 저장할 수 있는 Map과 달리 키와 값의 타입으로 오직 String만 가질 수 있다.

이 클래스는 Map의 조상인 Hashtable을 상속받는데 자바에 제네릭이 도입되기 이전에 만들어졌기 때문에 컴파일러가 키와 값의 타입이 String인지 여부를 체크할 수 있는 방법이 없었다. 따라서 Hashtable의 인터페이스에 포함되어 있는 put() 메서드를 이용하면 String 타입 이외의 키와 값이라도 Properties에 저장할 수 있다.

위 코드를 실행해 보면 “Dennis Ritchie”를 키로 검색할 경우 null이 반환된다는 사실을 알 수 있다. 그 이유는 PropertiesgetProperty 메서드가 반환할 값의 타입이 String이 아닌 경우 null을 반환하도록 구현돼 있기 때문이다. 분명히 “Dennis Ritche” 라는 키의 값으로 67을 넣는 데 성공했는데도 말이다.

StackProperties의 예는 퍼블릭 인터페이스에 대한 고려 없이 단순히 코드 재사용을 위해 상속을 이용하는 것이 얼마나 위험한지를 잘 보여준다. 객체지향의 핵심은 객체들의 협력이다. 단순히 코드를 재사용하기 위해 불필요한 오퍼레이션이 인터페이스에 포함되도록 해서는 안된다.

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


메서드 오버라이딩의 오작동 문제

조슈아 블로치는 HashSet의 구현에 강하게 결합된 InstrumentedHashSet 클래스를 소개한다. InstrumentedHashSetHashSet의 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 클래스로서 HashSet의 자식 클래스로 구현돼 있다.

InstrumentedHashSet은 요소를 추가한 횟수를 기록하기 위해 addCount라는 인스턴스 변수를 포함한다. InstrumentedHashSet은 요소가 추가될 때마다 추가되는 요소의 개수만큼 addCount의 값을 증가시키기 위해 하나의 요소를 추가하는 add 메서드와 다수의 요소를 한 번에 추가하는 addAll 메서드를 오버라이딩 한다. add 메서드와 addAll 메서드는 먼저 addCount를 증가시킨 뒤 super 참조를 이용하여 부모 클래스의 메서드를 호출해서 요소를 추가한다는 것을 알 수 있다.

InstrumentedHashSet 구현에는 아무 문제가 없어보인다. 적어도 다음과 같은 코드를 실행하기 전 까지는 말이다.

대부분의 사람들은 위 코드를 실행한 후에 addCount의 값이 3이 될 거라고 예상할 것이다. 하지만 실제로 실행한 후의 addCount의 값은 6이다. 그 이유는 부모 클래스인 HashSetaddAll 메서드 안에서 add 메서드를 호출하기 때문이다. 불행하게도 HashSet은 각각의 요소를 추가하기 위해 내부적으로 add 메서드를 호출하기 때문에 오버라이딩된 InstrumentedHashSetadd 메서드가 3번 호출되어 addCount에 3이 더해지는 것이다 따라서 최종 결과는 6이 된다.

이 문제를 해결할 수 있는 방법은 InstrumentedHashSetaddAll 메서드를 제거하는 것이다. 하지만 이 방법 역시 문제가 될 수 있다. 나중에 HashSetaddAll 메서드가 add 메시지를 전송하지 않도록 수정된다면 addAll 메서드를 이용해 추가되는 요소들에 대한 카운트가 누락될 것이기 때문이다.

미래의 수정까지 감안한 더 좋은 해결책은 InstrumentedHashSetaddAll 메서드를 오버라이딩 하고 추가되는 각 요소에 대해 한 번씩 add 메서드를 호출하는 것이다. 이제 미래의 HashSetaddAll 메서드가 add 메시지를 전송하지 않도록 수정되더라도 InstrumentedHashSet의 행동에는 아무런 영향도 없을 것이다.

하지만 이 방법에도 문제가 없는 것은 아니다. 바로 오버라이딩된 addAll 메서드의 구현이 HashSet의 것과 동일하다는 것이다. 즉, 미래에 발생할지 모르는 위험을 방지하기 위해 코드를 중복시킨 것이다. 게다가 부모 클래스의 코드를 그대로 가져오는 방법이 항상 가능한 것도 아니다. 소스코드에 대한 접근 권한이 없을 수도 있고 부모 클래스의 메서드에서 private 변수나 메서드를 사용하고 있을수도 있다.

자식 클래스가 부모 클래스의 메서드를 오버라이딩 할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.

다시말하자면 부모 클래스의 메서드를 오버라이딩 할 때 자식 클래스 개발자는 단순히 부모 클래스의 메서드를 오버라이딩 할 뿐 아니라 부모 클래스가 해당 메서드를 사용하는 부분을 모두 파악해야 한다. _즉 부모 클래스의 내부 구현을 상세히 파악하지 않으면 오버라이딩 오작동 문제가 발생할 수 있다.__

조슈아 블로치는 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화 해야하며 그렇지 않은 경우에는 상속을 금지시켜야 한다고 주장한다.

메서드 오버라이딩으로 인한 파급 효과를 분명하게 문서화 해야한다. 달리말해 오버라이딩 가능한 메서드들의 자체 사용(self-use), 즉, 오버라이딩 하려는 메서드들이 같은 클래스의 다른 메서드를 호출하거나 호출받는지에 대해 반드시 문서화 해야한다. 더 일반적으로 말하면 오버라이딩 하려는 메서드를 호출할 수 있는 어떤 상황에 대해서도 문서화 해야한다는 것이다.

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


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

음악 목록을 추가할 수 있는 플레이리스트를 구현한다고 가정하자. 필요한 것은 음악 정보를 저장할 Song 클래스와 음악 목록을 저장할 Playlist 클래스다. 먼저 Song 클래스는 가수의 이름(singer)과 노래 제목(title)을 인스턴스 변수로 포함한다.

Playlist는 트랙에 노래를 추가할 수 있는 append 메서드를 구현한다.

이제 플레이리스트에서 노래를 삭제할 수 있는 기능이 추가된 PersonalPlaylist가 필요하다고 가정하자. PersonalPlaylist를 구현하는 가장 빠른 방법은 상속을 통해 Playlist의 코드를 재사용 하는 것이다.

문제는 지금부터다. 요구사항이 변경돼서 Playlist에서 노래의 목록뿐만 아니라 가수 별 노래의 제목도 함께 관리해야 한다고 가정하자. 다음과 같이 노래를 추가한 후에 가수의 이름을 키로 노래의 제목을 추가하도록 Playlistappend 메서드를 수정해야 할 것이다.

안타깝게도 위 수정 내용이 정상적으로 동작하려면 PersonalPlaylistremove 메서드도 함께 수정해야 한다. 만약 PersonalPlaylist를 수정하지 않는다면 Playlisttracks에서는 노래가 제거되지만 singers에는 남아있을 것이기 때문이다. 따라서 Playlist와 함께 PersonalPlaylist를 수정해야 한다.

이 예는 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않았음에도 부모 클래스를 수정할 때 자식 클래스를 함께 수정해야 할 수도 있다는 사실을 잘 보여준다. 상속을 사용하면 자식 클래스가 부모 클래스의 구현에 강하게 결합되기 때문에 이 문제를 피하기는 어렵다.

결합도란 다른 대상에 대해 알고 있는 지식의 양이다. 상속은 기본적으로 부모 클래스의 구현을 재사용한다는 기본 전제를 따르기 때문에 자식 클래스가 부모 클래스의 내부에 대해 속속들이 알도록 강요한다. 따라서 코드 재사용을 위한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할 수 밖에 없다.

서브 클래스는 올바른 기능을 위해 슈퍼 클래스의 세부적인 구현에 의존한다. 슈퍼 클래스의 구현은 릴리스를 거치면서 변경될 수 있고, 그에 따라 서브클래스의 코드를 변경하지 않더라도 깨질 수 있다. 결과적으로 슈퍼클래스의 작성자가 확장될 목적으로 특별히 그 클래스를 설계하지 않았다면 서브클래스는 슈퍼클래스와 보조를 맞춰서 진화해야 한다.(함께 수정되어야 한다.)

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


지금까지 상속으로 인해 발생하는 취약한 기반 클래스 문제의 다양한 예를 살펴보았다. 이제 다시 PhoneNightlyDiscountPhone의 문제로 돌아와 상속의 피해를 최소화 할 수 있는 방법에 대해 알아보자. 취약한 기반 클래스 문제를 완전히 없앨 수는 없지만 어느 정도 까지 위험을 완화시키는 것은 가능하다. 문제 해결의 열쇠는 바로 추상화다.

추상화에 의존하자.

NightlyDiscountPhone의 가장 큰 문제점은 Phone에 강하게 결합돼 있기 때문에 Phone이 변경될 경우 함께 변경될 가능성이 높다는 것이다. 이 문제를 해결하는 가장 일반적인 방법은 자식 클래스가 부모 클래스의 구현이 아닌 추상화에 의존하도록 만드는 것이다. 정확하게 말하면 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정해야 한다.

개인적으로 코드 중복 제거를 위해 상속을 도입할 때 따르는 두 가지 원칙이 있다.

  • 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.

  • 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것 보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있다.


차이를 메서드로 추출하라

추상화에 의존하기 위해 가장 먼저 해야할 일은 중복 코드 안에서 차이점을 별도의 메서드로 추출하는 것이다. 이것은 흔히 말하는 변하는 것으로 부터 변하지 않는 것을 분리하라 또는 변하는 부분을 찾고 이를 캡슐화 하라 라는 조언을 메서드 수준에서 적용한 것이다.

중복 코드를 가진 PhoneNightlyDiscountPhone 클래스에서 시작하자. Phone 클래스의 현재 모습은 다음과 같다.

NightlyDiscountPhone 클래스는 Phone과 유사하지만 calculateFee 메서드의 구현 일부와 인스턴스 변수의 목록이 조금 다르다.

먼저 할 일은 두 클래스의 메서드에서 다른 부분을 별도의 메서드로 추출하는 것이다. 이 경우에는 calculateFee의 for 문 안에 구현된 요금 계산 로직이 서로 다르다는 사실을 알 수 있다. 이 부분을 동일한 이름을 가진 메서드로 추출하자. 이 메서드는 하나의 Call에 대한 통화 요금을 계산하는 것이므로 메서드의 이름으로는 calculateCallFee가 좋겠다.

먼저 Phone에서 메서드를 추출하자.

NightlyDiscountPhone의 경우에도 동일한 방식으로 메서드를 추출하자.

두 클래스의 calculateFee 메서드는 완전히 동일해졌고 추출한 calculateCallFee 메서드 안에 서로 다른 부분을 격리시켜 놓았다. 이제 같은 코드를 부모 클래스로 올리는 일만 남았다.


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

부모 클래스를 추가하자. 목표는 모든 클래스들이 추상화에 의존하도록 만드는 것이기 때문에 이 클래스는 추상 클래스로 구현하는 것이 적합할 것이다. 새로운 부모 클래스의 이름은 AbstractPhone으로 하고 PhoneNightlyDiscountPhoneAbstractPhone을 상속받도록 수정하자.

이제 PhoneNightlyDiscountPhone공통 부분을 부모 클래스로 이동시키자.

두 클래스 사이에서 완전히 동일한 코드는 calculateFee 메서드이므로 calculateFee 메서드를 AbstractPhone으로 이동시키고 PhoneNightlyDiscountPhone에서 이 메서드를 제거하자.

그리고 PhoneNightlyDiscountPhone에서 구현 부분이 다른 calculateCallFee 메서드의 시그니처 부분만 AbstractPhone으로 이동시킨다.

이렇게 공통 코드를 모두 AbstractPhone으로 옮겼다. 이제 Phone에는 일반 요금제를 처리하는 데 필요한 인스턴스 변수와 메서드만 존재한다.

NightlyDiscountPhone에는 심야 요금 할인제와 관련된 인스턴수 변수와 메서드만 존재하게 된다.

지금까지 살펴본 것처럼 자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성할 수 있다. 이제 우리의 설계는 추상화에 의존하게 된다.

“위로 올리기 전략”은 실패했더라도 수정하기 쉬운 문제를 발생시킨다. 문제는 쉽게 찾을 수 있고 쉽게 고칠 수 있다. 추상화하지 않고 빼먹은 코드가 있더라도 하위 클래스가 해당 행동을 필요로 할 때가 오면 이 문제는 바로 눈에 띈다. 모든 하위 클래스가 이 행동을 할 수 있게 만들려면 여러 개의 중복 코드를 양산하거나 이 행동을 상위 클래스로 올리는 수 밖에 없다.


추상화가 핵심이다.

공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다는 것에 주목하라. AbstractPhone은 전체 통화 목록을 계산하는 방법이 바뀔 경우에만(calculateFee 구현이 변경될 때에만) 변경된다.

Phone은 일반 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만(calculateCallFee) 변경된다. NightlyDiscountPhone은 야 할인 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만(calculateCallFee) 변경된다.

세 클래스는 각각 하나의 변경 이유만을 가진다. 이 클래스들은 단일 책임 원칙을 준수하기 때문에 응집도가 높다.

설계를 변경하기 전에는 자식 클래스인 NightlyDiscountPhone이 부모 클래스인 Phone구현에 강하게 결합되어 있었기 때문에 Phone의 구현을 변경하더라도 NightlyDiscountPhone도 함께 영향을 받았었다. 변경 후에 자식 클래스인 PhoneNightlyDiscountPhone은 부모 클래스인 AbstractPhone의 구체적인 구현에 의존하지 않는다. 오직 추상화에만 의존한다. 정확하게는 부모 클래스에서 정의한 추상 메서드인 calculateCallFee에만 의존한다. calculateCallFee 메서드의 시그니처가 변경되지 않는 한 부모 클래스의 내부 구현이 변경되더라도 자식 클래스는 영향을 받지 않는다.

사실 부모 클래스 역시 자신의 내부에 구현된 추상 메서드를 호출하기 때문에 추상화에 의존한다고 말할 수 있다. 또한 의존성 역전 원칙도 준수하는데, 요금 계산과 관련된 상위 수준의 정책을 구현하는 AbstractPhone이 세부적인 요금 계산 로직을 구현하는 PhoneNightlyDiscountPhone에 의존하지 않고 그 반대로 PhoneNightlyDiscountPhone이 추상화인 AbstractPhone에 의존하기 때문이다.

새로운 요금제를 추가하기도 쉽다는 사실 역시 주목하라. 새로운 요금제가 필요하다면 AbstractPhone을 상속받는 새로운 클래스를 추가한 후 calculateCallFee 메서드만 오버라이딩하면 된다. 다른 클래스를 수정할 필요가 없다. 현재의 설계는 확장에는 열려있고 수정에는 닫혀있기 때문에 개방-폐쇄 원칙 역시 준수한다.

지금까지 살펴본 모든 장점들은 클래스들이 추상화에 의존하기 때문에 얻어지는 장점이다. 상속 계층이 코드를 진화시키는 데 걸림돌이 된다면 추상화를 찾아내고 상속 계층 안의 클래스들이 그 추상화에 의존하도록 코드를 리팩터링 하라. 차이점을 메서드로 추출하고 공통적인 부분은 부모 클래스로 이동하라.


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

한 가지 아쉬운 점이 있다. 바로 클래스의 이름과 관련된 부분이다. 따라서 다음과 같이 변경하자.


요구사항 추가하기 (세금 추가)

수정된 코드는 이전 코드보다 더 쉽게 변경할 수 있을까? 실제로 해보기 전까지는 장담할 수 없다. 통화 요금에 세금을 부과하는 요구사항을 반영해 보고 효과를 판단해보자.

세금은 모든 요금제에 공통으로 적용되어야 하는 요구사항이다. 따라서 공통 코드를 담고 있는 추상 클래스인 Phone을 수정하면 모든 자식 클래스 간에 수정 사항을 공유할 수 있을 것이다.

인스턴스 변수인 taxRate를 추가하고 요금에 세금이 부과되도록 calculateFee 메서드를 수정하자.

자 이것으로 끝난 걸까? 안타깝게도 그렇지 않다. 우리는 Phone의 인스턴스 변수인 taxRate를 추가했고 두 인스턴스 변수의 값을 초기화하는 생성자를 추가했다. 이로 인해 Phone의 자식 클래스인 RegularPhoneNightlyDiscountPhone의 생성자 역시 taxRate를 초기화 하기 위해 수정해야 한다.

클래스는 메서드 뿐 아니라 인스턴스 변수도 함께 포함한다. 따라서 클래스 사이의 상속은 자식 클래스가 부모 클래스가 구현한 행동 뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다. 인스턴스 변수의 목록이 변하지 않는 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스들을 독립적으로 진화시킬 수 있다. 하지만 인스턴스 변수가 추가되는 경우는 다르다. 자식 클래스는 자신의 인스턴스를 생성할 때 부모 클래스에 정의된 인스턴스 변수를 초기화 해야 하기 때문에 자연스럽게 부모 클래스에 추가된 인스턴스 변수는 자식 클래스의 초기화 로직에 영향을 미치게 된다. 결과적으로 책임을 아무리 잘 분리하더라도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸쳐 변경을 유발한다.

하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를 중복시키는 것 보다는 현명한 선택이다. 8장을 주의깊게 읽었다면 객체 생성 로직이 변경됐을 때 영향을 받는 부분을 최소화 하기 위해 노력해야 한다는 사실을 잘 알고 있을 것이다. 객체 생성 로직의 변경에 유연하게 대응 할 수 있는 다양한 방법이 존재한다. 따라서 객체 생성 로직에 대한 변경을 막기 보다 핵심 로직의 중복을 막아라. 핵심 로직은 한 곳에 모아놓고 조심스럽게 캡슐화 해야 한다. 그리고 공통적인 핵심 로직은 최대한 추상화 해야한다.


차이에 의한 프로그래밍

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

이처럼 기존의 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍이라고 부른다. 상속을 이용하면 이미 존재하는 클래스의 코드를 쉽게 재사용 할 수 있기 떄문에 애플리케이션의 점진적인 정의가 가능해진다.

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

코드를 재사용 하는 것은 단순히 문자를 타이핑 하는 수고를 덜어주는 수준의 문제가 아니다. 재사용 가능한 코드란 심각한 버그가 존재하지 않는 코드다. 따라서 코드를 재사용하면 코드의 품질은 유지하면서도 코드를 작성하는 노력과 테스트는 줄일 수 있다.

객체지향 세계에서 중복 코드를 제거하고 코드를 재사용 할 수 있는 가장 유명한 방법은 상속이다. 기본 아이디어는 간단하다. 여러 클래스에 공통적으로 포함되어 있는 중복 코드를 하나의 클래스로 모은다. 원래 클래스들에서 중복 코드를 제거한 후 중복 코드가 옮겨진 클래스를 상속 관계로 연결한다. 코드를 컴파일 하면 무대 뒤에서 마법이 일어나 상속 관계로 연결된 코드들이 하나로 합쳐진다. 따라서 상속을 사용하면 여러 클래스들 사이에서 재사용 가능한 코드를 하나의 클래스 안으로 모을 수 있다.

상속은 강력한 도구다. 상속을 이용하면 새로운 기능을 추가하기 위해 직접 구현해야 하는 코드의 양을 최소화 할 수 있다. 상속은 너무나도 매력적이기 때문에 객체지향 프로그래밍에 갓 입문한 프로그래머들은 상속의 매력에 도취된 나머지 모든 설계에 상속을 적용하려고 시도한다.

시간이 흐르고 객체지향에 대한 이해가 깊어지면서 사람들은 코드를 재사용 하기 위해 맹목적으로 상속을 사용하는 것이 위험하다는 사실을 깨닫기 시작했다. 상속이 코드 재사용이라는 측면에서 매우 강력한 도구인 것은 사실이지만 강력한 만큼 잘못 사용할 경우에 돌아오는 피해 역시 크다는 사실을 뼈저리게 경험한 것이다. 상속의 오용과 남용은 애플리케이션을 이해하고 확장하기 어렵게 만든다. 정말로 필요한 경우에만 상속을 사용하라.

상속은 코드 재사용과 관련된 대부분의 경우에 우아한 해결 방법이 아니다. 객체지향에 능숙한 개발자들은 상속의 단점을 피하면서도 코드를 재사용 할 수 있는 더 좋은 방법이 있다는 사실을 알 고 있다. 바로 합성이다.

참고 및 출처

  • 오브젝트