메서드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 한다. 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기 사용 패턴) 문서로 남겨야 한다. 

자기 사용 패턴 : 클래스 내부의 한 메서드가 같은 클래스의 다른 메서드를 호출하는 패턴. 자기 사용 패턴이 상속과 결합될 때 문제가 발생할 수 있다. 슈퍼클래스가 자신의 메서드를 호출할 때 실제로 실행되는 메서드가 서브클래스에서 오버라이드된 메서드일 수 있다.

 

 

아래는 API 문서의 메서드 설명 예시이다.

설명 끝 부분에 "Implementation Requirements"로 시작되는 부분은 그 메서드의 내부 동작 방식을 설명하는 곳이다.

public boolean remove(Object o)
주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다(선택적 동작). 더 정확
하게 말하면, 이 컬렉션 안에 'Object.equals(o, e)가 참인 원소' e가 하나 이상 있다면 그
중 하나를 제거한다. 주어진 원소가 컬렉션 안에 있었다면(즉, 호출 결과 이 컬렉션이 변경
됐다면) true를 반환한다.
    Implementation Requirements: 이 메서드는 컬렉션을 순회하며 주어진 원소를 찾도록
구현되었다. 주어진 원소를 찾으면 반복자의 remove 메서드를 사용해 컬렉션에서 제거한다.
이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가
remove 메서드를 구현하지 않았다면 UnsupportedOperationException을 던지니 주의하자.

 

 

설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 줌을 알 수 있다. iterator 메서드로 얻은 반복자의 동작이 remove메서드의 동작에 주는 영향도 설명했다. 이런식으로 클래스를 안전하게 상속할 수 있도록 하려면 내부 구현 방식을 설명해주어야 한다.

 

하지만 내부 매커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아니다. 효율적인 하위 클래스를 만들 수 있게 하려면 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected메서드 형태로 공개해야 할 수도 있다.

훅 메서드 : 상위 클래스에서 정의되지만, 기본적으로 아무 작업도 수행하지 않거나 기본 동작만 수행하는 메서드이다. 이 메서드들을 오버라이드(재정의)함으로써, 상위 클래스의 동작 과정 중 특정 지점에서 원하는 동작을 알고리즘 흐름을 변경하지 않으면서 추가하거나 변경할 수 있다.

public abstract class Beverage {
    
    // 이 메서드는 알고리즘의 템플릿을 제공합니다.
    // 즉, 음료를 준비하는 일련의 단계를 정의합니다.
    final void prepareRecipe() {
        boilWater();
        brew();
        pourInCup();
        if (customerWantsCondiments()) { // 훅(hook). 하위 클래스에서 이 메서드를 오버라이드할 수 있습니다.
            addCondiments();
        }
    }

    abstract void brew();

    abstract void addCondiments();

    void boilWater() {
        System.out.println("물을 끓입니다.");
    }

    void pourInCup() {
        System.out.println("컵에 붓습니다.");
    }
    
    // 이것이 바로 훅 메서드입니다. 기본적으로 이 메서드는 'true'를 반환하지만,
    // 하위 클래스에서 이 메서드의 동작을 변경할 수 있습니다.
    protected boolean customerWantsCondiments() {
        return true;
    }
}

// 하위 클래스 예시: 커피 준비하기
public class Coffee extends Beverage {
    
    @Override
    void brew() {
        System.out.println("필터를 통해 커피를 우려냅니다.");
    }

    @Override
    void addCondiments() {
        System.out.println("설탕과 우유를 추가합니다.");
    }
    
    @Override
    protected boolean customerWantsCondiments() {
        // 여기서는 사용자가 조미료(설탕, 우유)를 추가하길 원하는지에 대한 로직을 구현할 수 있습니다.
        // 예를 들어, 사용자의 입력을 받아 결정할 수 있습니다.
        // 이 예제에서는 단순화를 위해 항상 'true'를 반환하도록 합니다.
        return true;
    }
}

이처럼 hook 메서드를 잘 활용하면 내부 동작 과정 중 중간에 끼어들어 수정할 수 있다. 어떤 메서드를 protected로 노출해야 할지는 그냥 심사숙고해서 잘 예측해보고, 실제로 하위 클래스를 만들어 보는것이 최선이다. 반드시 하위 클래스를 만들어 검증해야 한다. 

클래스를 상속용으로 설계하려면 엄청난 노력이 들고 그 클래스에 안기는 제약도 상당하기 때문에 절대 가볍게 생각하고 정할 문제가 아니다. 상속을 허용하는게 명확히 정당한 상황이 있고, 아닌 상황이 있다. 이런 문제를 해결하는 가장 좋은 방법은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이다.

 

결론 : 상속용 클래슬르 설계하려면 클래스 내부에서 스스로를 어떻게 사용하는지(자기 사용 패턴) 모두 문서로 남겨야 하며, 일단 문서화 한것은 그 클래스가 쓰이는 한 반드시 지켜야 한다. 그렇지 않으면 그 내부 구현 방식을 믿고 활용하던 하위 클래스를 오동작하게 만들 수 있다. 다른 이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야할 수도 있다. 그러니 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 낫다. 상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접근할 수 없도록 만들면 된다.

 

상속 : 한 클래스가 다른 클래스의 속성과 메서드를 확장 혹은 재정의할 수 있도록 해주는 매커니즘

// 상속의 예시 : 
// 부모 클래스: 동물
class Animal {
    public void eat() {
        System.out.println("이 동물은 먹는다.");
    }
}

// 자식 클래스: 개
// Dog 클래스는 Animal 클래스를 상속받음으로써 "개는 동물이다"라는 Is-a 관계를 형성합니다.
class Dog extends Animal {
    public void bark() {
        System.out.println("개는 짖는다.");
    }
}

 

컴포지션 : 하나의 클래스가 다른 클래스의 인스턴스를 포함하여, 그 인스턴스의 메서드를 활용하는 방식

// 컴포지션의 예시 : 
// 독립된 기능을 가진 클래스: 소리 생성기
class SoundMaker {
    public void makeSound(String sound) {
        System.out.println(sound);
    }
}

// 개 클래스에서 소리 생성기를 사용
class Dog {
    private SoundMaker soundMaker;

    public Dog() {
        this.soundMaker = new SoundMaker();
    }

    // 전달 메서드: Dog 클래스의 bark 메서드는 내부적으로 SoundMaker의 makeSound 메서드를 호출합니다.
    public void bark() {
        soundMaker.makeSound("멍멍!"); // 위임: Dog 객체는 짖는 기능을 SoundMaker 객체에 위임합니다.
    }
}

 

 

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다. 

상속의 문제점 :

  • 강한 결합 : 부모 클래스의 내부 변경이 자식 클래스에 영향을 줄 수 있어 유연성이 저하된다.
  • 캡슐화 위반 : 자식 클래스가 부모 클래스의 구현 세부 사항에 의존하게되면, 캡슐화가 약화된다.
  • 재사용성 저하 : 특정 구현에 강하게 결합된 상속 구조는 새로운 상황에 재사용하기 어렵다.

이러한 문제를 모두 피해가기 위해 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이런 설계를 컴포지션(compostition : 구성) 이라고 한다.

새 클래스의 인스턴스들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다. 이 방식을 전달(forwarding) 이라고 하며, 새 클래스의 메서드들을 전달 메서드라 부른다.

 

컴포지션과 전달(위임)의 장점 : 

  • 낮은 결합도 : 객체간의 결합도를 낮춰, 유연성과 확장성을 향상시킨다.
  • 캡슐화 강화 : 내부 구현을 숨기고, 필요한 인터페이스만 노출시켜 캡슐화를 강화.
  • 재사용성 향상 : 구성 요소를 쉽게 교체하거나 재사용할 수 있어, 다양한 상황에 맞게 시스템을 조정할 수 있다.

Is-a 관계는 상속을 이용하여 한 클래스가 다른 클래스의 특별한 형태임을 나타내는 관계다.

래퍼 클래스는 기본 데이터 타입의 값을 객체로 변환해야 할 때 사용된다.

// 래퍼 클래스의 예
int i = 5; // 기본 타입 int 사용

Integer integerObject = new Integer(5); // Integer 객체 생성 (래퍼 클래스 사용)
Integer autoBoxedInteger = 5; // 오토 박싱을 통한 Integer 객체 생성

// 오토 언박싱: Integer 객체에서 int 기본 타입 값으로 자동 변환
int unboxedInt = integerObject;

//오토 박싱은 기본 데이터 타입의 값을 해당하는 래퍼 클래스의 객체로 자동 변환하는 과정을, 
//언박싱은 그 반대 과정을 의미합니다.

 

 

결론 : 상속은 강력하지만 캡슐화를 해친다. 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다. is-a 관계일 때도 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다. 이를 해결하려면 컴포지션과 전달을 사용해야 한다. 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다. 

 

 

+ Recent posts