[오브젝트] 10장 상속과 코드 재사용

ppparkta·2025년 6월 7일
0

오브젝트

목록 보기
10/14

학습 목표

(10-11) 상속, 합성에 대해 살펴보고 두 구현 기법의 장점과 단점을 비교한다.

객체지향의 장점 중 하나는 코드 재사용이 용이하다는 것이다.

객체지향에서는 재사용을 위해서 코드를 추가한다.

  • (재사용 관점의)상속
    • 클래스 안에 정의된 인스턴스 변수와 메서드를 자동으로 새로운 클래스에 추가하는 구현 기법이다.
  • 합성
    • 새로운 클래스의 인스턴스 안에 기존 클래스의 인스턴스를 포함하는 구현 기법이다.

이번에는 상속, 다음에는 합성에 대해 살펴보고 두 구현 기법의 장점과 단점을 비교한다.

일단은 우리를 혼란스럽게 하는 중복 코드의 문제점에 대해서 살펴본다.

상속과 중복 코드

중복 코드는 팀 내의 의심과 불신을 부추긴다.
이것만으로도 중복 코드를 제거할 이유는 충분하지만 더 큰 문제가 있다.

DRY 원칙

  • 중복 코드는 변경을 방해한다
    • 코드는 항상 변경 가능성을 갖는다.
    • 우리는 비즈니스 관련된 지식을 코드로 변환해야 한다. 그러나 이 지식은 계속해서 변한다.
    • 따라서 모든 코드는 항상 변경될 것이라고 가정해야 한다.
    • 그런데 중복 코드는 그 변경을 몇 배는 힘들게 한다.

📌 중복 코드가 변경을 방해하는 이유

  • 중복 코드가 어느 구간인지 찾아야 한다
  • 모든 코드를 일관되게 수정해야 한다
  • 중복 코드를 개별 테스트하고 동일한 결과를 반환하는지 확인해야 한다
    → 즉, 중복 코드는 수정과 테스트에 드는 비용을 증가시킨다.
  • 중복 코드 판단 기준
    • 코드가 변경되었을 때 두 코드가 함께 수정된다면 중복이다
    • 코드가 함께 수정되지 않으면 중복이 아니다

신뢰할 수 있고 수정하기 쉬운 소프트웨어를 만드는 방법 중 하나는 중복을 제거하는 것이다.

프로그래머들은 DRY 원칙을 따라야 한다 (앤드류 헌트, 데이비드 토머스)

  • DRY 원칙 (Don’t Repeat Yourself)
    • 동일한 지식을 반복하지 마라
    • 한 번, 단 한번 원칙
    • 단일 지정 제어 원칙

중복과 변경

중복 코드 살펴보기

한 달에 한 번 가입자 별로 전화 요금을 계산하는 애플리케이션으로 중복 코드에 대해 설명해본다.
전화 요금은 단위 시간 당 요금으로 나눈다 (10초 당 5원 부과)

요구사항 구현을 위해 Phone 클래스 내부에 요금을 계산하는 메서드를 만든다.

public Money calculateFee() {
    Money result = Money.ZERO;

    for (Call call : calls) {
        result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
    }
    return result;
}

이 애플리케이션에 새로운 요구사항이 추가되어 심야 할인 요금제라는 새로운 요금 방식을 추가하는 상황이 접수됐다. (밤 10시 이후의 통화에 대해 요금을 할인해주는 방식이다)

이 요구사항을 해결하는 가장 쉽고 빠른 방법은 Phone 복사 후 NightlyDiscountPhone을 만들고 수정하는 것이다.

public Money calculateFee() {
    Money result = Money.ZERO;

    for (Call call : calls) {
        if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
            result = result.plus(
                    nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
            );
        } else {
            result = result.plus(
                    regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
            );
        }
    }
    return result;
}

그러나 구현 시간을 절약한 대가로 지불해야 하는 비용이 예상보다 클 것이다. PhoneNightlyDiscountPhone 사이에 중복 코드가 존재하기 때문이다.

중복 코드 수정하기

여기에 새로운 요구사항을 추가한다.
이번에 추가하는 기능은 통화 요금에 부과할 세금을 계산하는 것이다.

부과되는 세율은 가입자의 핸드폰마다 다르다고 가정한다. 서로 다른 클래스에 모두 구현되어 있으므로 세금 추가를 위해서 두 클래스를 함께 수정해야 한다.

public Money calculateFee() {
    ...
    **return result.plus(result.times(taxRate));**
}
public Money calculateFee() {
    ...
    **return result.minus(result.times(taxRate));**
}

여기서 중복 코드가 가지는 단점이 드러난다.

많은 코드 속에서 어떤 코드가 중복인지 파악하는 것은 쉬운 일이 아니다. 이러한 중복 코드는 항상 함께 수정되어야 하기 때문에 하나라도 빠트리면 버그로 이어지게 된다.

심지어 어디에는 plus(), 어디에는 minus()를 적용하고 있다. 중복 코드를 찾아내어 수정하더라도 그 수정이 잘못된다면 눈치채기가 어렵다.

→ 즉, 중복 코드는 새로운 중복 코드를 부른다. 중복 코드를 제거하지 않고 코드를 수정하는 방법은 새로운 중복 코드를 추가하는 것 뿐이다.

  • 이 때 코드 일관성이 무너질 위험이 항상 도사리고 있다.
  • 변경에 취약해지고 버그가 발생할 가능성이 높아진다.

민첩하게 변경하기 위해서 중복 코드 추가 대신 제거를 선택하자. 기회가 생길때마다 DRY 원칙을 지켜라.

타입 코드 사용하기

  • 두 클래스 사이의 중복 코드를 제거하는 방법 중 하나는 클래스를 하나로 합치는 것이다.
    • 요금제를 구분하는 타입 코드를 추가하고 타입 코드의 값에 따라 로직을 분기시켜 PhoneNightlyDiscountPhone을 하나로 합칠 수 있다.

하지만 타입 코드를 사용하면 낮은 응집도와 높은 결합도 문제에 시달리게 된다

⇒ 그 대신 중복 코드를 관리할 수 있는 더 효과적인 방법이 상속이다.

상속으로 중복 코드 제거

  • 상속을 사용하라
    • 이미 존재하는 클래스와 유사한 클래스가 필요하다면 코드를 복사하지 말고 상속을 이용해 코드를 재사용하라.

코드 예제를 살펴보면 조금 복잡하게 구현되었다. (일반/심야 각각 나눠서 계산하는게 아니라 다 일반 요금제로 계산하고 심야 시간에 계산한 값에서 빼준다)

→ 이런 구현은 개발자의 가정을 이해하기 전에 코드를 이해하기 어렵다.

즉, 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해서 재사용하는 것은 생각처럼 쉽지 않다.

상속을 이용해서 코드를 재사용하려면 부모 클래스의 개발자가 세웠던 가정 혹은 추론을 정확히 이해해야 한다.

  • 즉 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다.
  • 따라서 상속은 결합도를 높인다.
  • +) 상속이 초래하는 부모 클래스와 자식 클래스의 강한 결합도 수정이 어렵게 만든다.

강하게 결합된 Phone과 NightDiscountPhone

부모 클래스와 자식 클래스 사이의 결합이 문제인 이유는 무엇일까?

현재의 상속 구조에서 세금을 부과하는 요구사항이 추가된다면 어떻게 될까?

Phone은 앞에서 구현했던 세율을 인스턴스 변수로 포함하고 calculateFee() 메서드에서 값을 반환할 때 taxRate를 이용해서 세금을 부과해야 한다.

그런데 NightlyDiscountPhone에도 세금을 부과하는 유사한 코드를 추가해야 한다.

→ 중복 코드를 제거하고 코드를 재사용하기 위해서 상속을 사용했는데, 결국 새로운 중복 코드를 만들게 된다.

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

위와 같이 상속 관계로 연결된 자식 클래스가 부모 클래스 변경에 취약해지는 현상을 취약한 기반 클래스 문제라고 부른다.

취약한 기반 클래스 문제는 코드 재사용을 목적으로 상속을 사용할 때 발생하는 대표적인 문제다.

취약한 기반 클래스 문제

상속은 자식 클래스와 부모 클래스 사이의 결합도를 높인다.

  • 취약한 기반 클래스 문제
    • 부모 클래스 변경에 의해 자식이 영향을 받는 현상이다.
    • 상속이라는 문맥 안에서 결합도가 가지는 문제를 가리키는 용어다.

→ 상속은 높은 결합도로 인해 부모 클래스의 점진적 개선을 어렵게 한다.

최악의 경우 모든 자식 클래스를 동시에 수정하고 다시 테스트해야 할 수 있다.

📌 즉, 취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높인다.
상속은 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만들기 때문에 캡슐화를 약화시킨다.

상속을 사용하면 부모의 퍼블릭 인터페이스 뿐만 아니라 구현이 변경되어도 자식 클래스에서 영향받기 쉬워진다.

이는 객체지향의 기반인 캡슐화를 통한 변경의 통제를 희석하고 구현에 대한 결합도를 높인다.

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

부모 클래스에서 상속받은 메서드를 사용할 때 자식 클래스의 규칙이 위반될 수 있다.

FIFO인 Stack이 Vector를 상속받고 있다. Stack은 규칙을 무너뜨릴 여지가 있는 위험한 퍼블릭 인터페이스까지 함께 상속받았다.

인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 만들어야 한다.

퍼블릭 인터페이스에 대한 고려 없이 단순히 코드 재사용을 위해서 상속을 이용하는 것은 위험하다.

객체지향의 핵심은 객체들의 협력이다.

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

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

HashSet을 상속받은 InstrumentedHashSet에서 addAll()의 문제점

→ 추가 횟수를 더할 때 중복 연산이 들어가서 의도와 다른 값이 저장된다

이 문제를 해결하기 위해서 HashSetaddAll() 구조를 빌려온다.

public calss InstrumentedHashSet<E> extends HashSet<E> {
		@Override
		public boolean add(E e) {
				addCount++;
				return super.add(e);
		}
		
		@Override
		public boolean addAll(Collection<? extends E> c) {
				boolean modified = false;
			
				for (E e : c) {
						if (add(e)) {
								modified = true;
						}
				}
				return modified;
		}
}

그러나 미래에 발생할지 모르는 위험을 방지하기 위해서 코드를 중복시켜서는 안 된다. 게다가 부모 클래스의 코드를 그대로 가져오는 방법이 항상 가능한 것도 아니다.

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

설계는 트레이드오프 활동이다 … 상속은 코드 재사용을 위해서 캡슐화를 희생한다.

완벽한 캡슐화를 적용하는 상속은 어렵다. 완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나 상속 외의 다른 방법을 사용해야 한다.

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

부모 클래스의 수정으로 인해 자식 클래스가 수정되어야 할 수 있다.

집중할 부분은 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않아도 부모 클래스 수정 시 자식 클래스를 함께 수정해야 할 수 있다는 것이다.

→ 상속을 사용하면 자식 클래스가 부모 클래스 구현에 강하게 결합되기 때문에 이 문제를 피하기 어렵다.

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

📌 상속을 위한 경고 모음
1. 자식 클래스의 메서드 안에서 super 참조를 이용해서 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.
2. 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.
3. 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.
4. 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.

Phone 다시 살펴보기

상속으로 인한 문제(취약한 기반 클래스 문제)로 발생하는 피해를 최소화하기 위한 방법은 바로 추상화다.

추상화에 의존하자

상속의 문제를 해결하는 방법은 자식 클래스가 부모 클래스가 아니라 추상화에 의존하게 만드는 것이다.

정확히는 부모 클래스와 자식 클래스가 둘 다 추상화에 의존하도록 수정해야 한다.

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

차이를 메서드로 추출하라

변하는 것으로부터 변하지 않는 것을 분리하자.

public Money calculateFee() {
    Money result = Money.ZERO;

    for (Call call : calls) {
        result = result.plus(calculateCallFee(call));
    }
    return result.plus(result.times(taxRate));
}

public Money calculateCallFee(Call call) {
    return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
}
public Money calculateFee() {
    Money result = Money.ZERO;

    for (Call call : calls) {
        result = result.plus(calculateCallFee(call));
        ;
    }
    return result;
}

public Money calculateCallFee(Call call) {
    if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
        return nightlyAmount.plus(
                nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
        );
    } else {
        return regularAmount.plus(
                regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
        );
    }
}

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

이제 공통된 코드는 부모로 올린다.

public abstract class AbstractPhone {
    protected List<Call> calls = new ArrayList<>();

    public void call(Call call) {
        calls.add(call);
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for (Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }
        return result;
    }

    abstract protected Money calculateCallFee(Call call);
}
public class Phone extends AbstractPhone {
		...
    @Override
    public Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}
public class NightlyDiscountPhone extends AbstractPhone {
    ...
    @Override
    public Money calculateCallFee(Call call) {
        if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
            return nightlyAmount.plus(
                    nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
            );
        }
        return regularAmount.plus(
                regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
        );
    }
}

→ 자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성하라 수 있다. 이제 설계는 추상화에 의존하게 된다.

추상화가 핵심이다

  • 이제 각 클래스는 서로 다른 이유로 변경된다.
    • Abstract Phone → 전체 요금제 통화 계산
    • Phone → 일반 요즘제 통화 한 건 계산
    • NightlyDiscountPhone → 심야 할인 요금제 통화 한 건 계산

→ 즉, 각 클래스는 단일 책임 원칙(SRP)을 준수하며 낮은 결합도를 유지한다.

  • 부모 클래스도 추상화에 의존하며, 새로운 요금제의 추가가 쉬워졌다.

→ 개방 폐쇄 원칙(OCP)를 준수한다.

이런 것들은 클래스들이 추상화에 의존하기 때문에 얻어지는 장점이다.

세금 추가하기

클래스는 메서드뿐만 아니라 인스턴스 변수도 함께 포함한다.

따라서 클래스 사이의 상속은 자식 클래스가 부모 클래스가 구현한 행동뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다.

즉 인스턴수가 그대로라면 행동을 변경했을 때 다른 클래스에 영향이 없지만, 부모의 인스턴스 변수가 변경되면 다른 클래스에 영향을 줄 수 있다.

  • 하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 중복 코드를 두는 것보다는 현명한 선택이다.
  • 핵심 로직은 한 데 모아놓고 조심스럽게 캡슐화해야 한다.
  • 공통적인 핵심 로직은 최대한 추상화해야 한다.

차이에 의한 프로그래밍

  • 차이에 의한 프로그래밍

    • 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법
    • 애플리케이션의 점진적인 정의가 가능하다.
  • 차이에 의한 프로그래밍의 목표

    • 중복 코드 제거
    • 코드 재사용

→ 이에 대해 가장 유명한 방법은 상속이다.

그러나 상속을 코드 재사용의 목적으로 맹목적으로 사용하는 것은 위험하다. 잘못 사용할 경우에 돌아오는 피해 역시 크기 때문이다. → 합성이라는 더 좋은 대안이 있다.

profile
겉촉속촉

0개의 댓글