[이펙티브자바 : 아이템19] 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라!

cchoijjinyoung·2023년 9월 4일
0

이펙티브자바

목록 보기
5/5

들어가기 앞서

이전 아이템에서는 '상속보다는 컴포지션을 사용하라' 라고 얘기하지만, 상속은 객체지향에서 가장 큰 특징이자, 코드를 재사용할 수 있는 가장 좋은 방법 중 하나이기도 하다.
'그럼 어떻게 하면 상속을 잘 사용할 수 있을까?' 에 대한 내용이 이번 아이템의 핵심이다.

아래는 이번 아이템의 내용 중에 저자가 언급한 격언이다.

"좋은 API 문서란 '어떻게'가 아닌 '무엇''을 하는지를 설명해야 한다"

예를 들어 샤워()라는 메서드가 있다고 하면,
"이거 목욕하는 거야" 라고 문서를 작성하는 방법과,
"이거 샴푸로 머리 감고, 바디워시로 닦고, 클렌징폼으로 세수하고, 양치도 해." 와 같이 구체적으로 작성할 수도 있을 것이다.

하지만 두번째처럼 구체적으로 기술한 문서는 '좋은 API 문서'라고는 하지 못한다.
왜냐하면, 문서를 보고 해당 API가 어떤 역할을 하는 지 파악할 수 있어야하는데, 내부 동작 원리를 구체적으로 설명해놓으면 내부 동작 원리에 묶이게 되기 때문이다.

⚠️ 하지만! 이번 아이템에서는 격언과는 반대로,

"재정의(Override) 가능 메서드구체적으로 문서화하라." 라고 얘기한다.

하위 클래스가 사용할 재정의 메서드가 해당 메서드의 내부 동작을 알아야하기 때문이다.

무슨 뜻 인지 알아가보자.


1. 왜 내부 동작을 알아야할까?

Beginner.class : 초보자 클래스
Wizard.class : 초보자를 상속받은 마법사 클래스, attack()attackAll()을 재정의해서 사용할 것이다.

// 초보자 클래스
public class Beginner extends Person {
    public void attack(Enemy enemy) {
        System.out.println(enemy + "는(은) 쓰러졌습니다.");
    }

    public void attackAll(List<Enemy> enemyList) {
        for (Enemy enemy : enemyList) {
            targeting(enemy);
        }
    }
}
// 전직 : 마법사 클래스
public class Wizard extends Beginner {
    private int 기력 = 100;

    @Override
    public void attack(Enemy enemy) { // attack() 재정의
        기력 -= 10;
        super.attack(enemy);
    }

    @Override
    public void attackAll(List<Enemy> enemyList) { // attackAll() 재정의
        기력 -= enemyList.size() * 10; // 적 인원만큼 기력 감소
        super.attackAll(enemyList);
    }

    public String get기력() {
        return "현재 남아있는 기력은 " + 기력 + "입니다.";
    }
}

아래는 실행 코드다.

public class Exam {
    public static void main(String[] args) {
        Wizard 진영 = new Wizard();

        진영.attackAll(List.of(
                new Enemy("도희"),
                new Enemy("지은"),
                new Enemy("태희")));

        System.out.println(진영.get기력());
    } // '진영' 은(는) 적들을 공격했다. 이 때 남은 기력은?
}

총 3명의 적을 공격했다. 기력을 30 사용했을것이라 기대하겠지만,
결과는 아래와 같다.

'도희'() 쓰러졌습니다.
'지은'() 쓰러졌습니다.
'태희'() 쓰러졌습니다.
현재 남아있는 기력은 40입니다.

총 60을 사용했다. 왜?
코드를 다시 봤더니 Beginner.classPerson.class를 상속받고 있다.

// 초보자 클래스
public class Beginner extends Person { // Person을 상속받고 있다.
    public void attack(Enemy enemy) {
        System.out.println(enemy + "는(은) 쓰러졌습니다.");
    }

    public void attackAll(List<Enemy> enemyList) {
        for (Enemy enemy : enemyList) {
            targeting(enemy); // targeting()은 Person 클래스의 메서드이다.
        }
    }
}

그러면 숨겨져 있던 Person.class를 보자.

// 추상클래스 - Person
public abstract class Person {
    public void targeting(Enemy enemy) {
        attack(enemy); 
        // 이 때 호출되는 attack()은 Wizard에서 재정의한 attack()을 호출하기 때문에
        // attackAll() 에서 +30, attack() 이 세 번 더 호출되서 +30이 된 것.
    }

    public void attack(Enemy enemy) {
    }
}

주석에 적어 놓았 듯이 attackAll 메서드가 내부적으로 attack메서드를 이용해 구현했기 때문에 발생한 결과다.

사실 Wizard.classattackAll()를 재정의하지 않는다면,
내부적으로 attack()만 세 번 호출되므로 정상적으로 동작할 것이다.

하지만, 그 때 뿐이다. 만약 상위클래스의 attackAll()이 다른 방식으로 구현이 바뀌게 된다면?
해당 프로그램은 오동작하게 될 것이다. 또한, 다음 릴리스에서 이 코드가 유지될지는 장담할 수 없다.

그래서 우리는 상속용 클래스를 정의할 때, (상위 클래스 자신이) 재정의 가능 메서드들을 내부적으로 어떻게 이용하는 지 문서로 남겨야한다.


2. 문서화는 어떻게 해야할까?

"@implSpec"

우리는 API 문서를 보게되면 아래 사진과 같이 Implementation Requirements 을 볼 수 있다.

Implementation Requirements : 그 메서드의 내부 동작 방식을 설명하는 곳이다.

이 부분은 메서드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다.

/**
* @implSpec
* 이 메서드는 내부적으로 
* 샴푸를 이용해서 머리 감고,
* 바디워시를 이용해서 몸을 닦고,
* 클렌징폼으로 세수하고, 마지막으로 양치를 실행합니다.
/
public void shower() {
	// 내부 동작
}

@implSpec 태그라는 정해진 태그가 있는 것은 아니다. 커스텀 태그 기능을 사용해 자바 개발팀에서 내부적으로 사용하는 규약이다. 자바 8에서 처음 도입되어 자바 9부터 본격적으로 사용되기 시작했다. 이 태그가 기본값으로 활성화되어야 바람직하다고 생각하지만, 자바 11의 자바독에서도 선택사항으로 남겨져 있다. 이 태그를 활성화하려면 명령줄 매개변수로 -tag "implSpec:a:Implementation Requirements:" 를 지정해주면 된다.
이 때, @implSpec 대신 @구현 이라고 하고, Implementation Requirements: 대신 구현 요구 사항: 으로 바꿔도 똑같이 동작한다. (-tag "구현:a:구현 요구 사항:")
*다만, 언젠가 표준 태그로 정의될지도 모르니 이왕이면 자바 개발팀과 같은 방식으로 사용하는 편이 좋을 것이다.

아래는 예시이다. 보기 편하게 두 줄로 작성한 것이니 참고하길 바란다.

$ javadoc -encoding UTF-8 -d target/apidoc -sourcepath 
src/main/java item19.shower_exam_package -tag "implSpec:a:Implementation Requirements:"

// 혹은

$ javadoc -encoding UTF-8 -d target/apidoc -sourcepath 
src/main/java item19.shower_exam_package -tag "구현:a:구현 요구 사항:"


하지만 이 처럼 내부 매커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아니다.

효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면,
클래스의 내부 동작 과정 중간에 끼어들 수 있는 '훅(Hook)'을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다.

"클래스의 내부 동작 중 '훅(Hook)'을 잘 선별하여 protected 메서드로 공개"

🤔 무슨 말 인지 어렵다. 아래 내용을 보며 이해해보자.
.

아래 내용은 이펙티브 자바에서 발췌했다. 색깔로 표시된 부분만 주의 깊게 보면된다!

// java.util.AbstractList의 removeRange 메서드를 예로 살펴보자.
protected void removeRange(int fromindex, int toIndex)
.
fromIndex(포함)부터 toIndex(미포함)까지의 모든 원소를 이 리스트에서 제거한다.
toIndex 이후의 원소들은 앞으로 (index만큼씩) 당겨진다. 이 호출로 리스트는 'toIndex - fromIndex' 만큼 짧아진다. (toIndex == fromIndex라면 아무런 효과가 없다.)
이 리스트 혹은 이 리스트의 부분리스트에 정의된 "clear 메서드" 가 이 메서드를 호출한다.
리스트 구현의 내부 구조를 활용하도록 이 메서드를 재정의하면 이 리스트와 부분리스트의 clear 연산 성능을 크게 개선할 수 있다.
.
Implementation Requirements: 이 메서드는 fromIndex에서 시작하는 리스트 반복자를 얻어 모든 원소를 제거할 때까지 ListIterator.next 와 ListIterator.remove를 반복 호출하도록 구현되었다. 주의: ListIterator.remove가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다.

! 우리는 이제 위의 내용이 재정의 가능한 메서드를 문서화 했다는 것을 알 것이다.
.

적혀있다시피 removeRange 메서드는 "clear 연산"을 수행할 때 사용한다.
이 시점에서 removeRange 를 '왜 protected 메서드로 공개했을까?' 를 다시 한번 생각해보면,

어떤 개발자가 AbstractList.class를 상속하여 하위 클래스를 작성했다고 했을 때,
(여기서는 그 하위 클래스를 MySubList.class 라고 하겠다.)

mySubList.clear(); // 이 처럼 해당 객체를 비우기 위해 clear()를 호출할 수 있을 것이다.

근데, AbstractList.class를 작성한 개발자가 만약 removeRange 를 비공개했다면,
MySubList.class 를 작성한 개발자는 clear() 메서드를 밑바닥부터 작업을 해야할 것이다.

여기서 removeRangeclear()*훅(Hook) 이라고 볼 수 있다.

그래서 상속을 설계할 때는 이런 부분까지 고려해서 작성해야한다는 내용이다.


그렇다면, 어떤 메서드를 protected로 노출해야 할까?

아쉽게도, 이걸 알아내는 방법은 '직접 하위클래스를 만들어보는 것이 유일' 하다.

  • 꼭 필요한 protected 멤버를 놓쳤다면, 하위클래스를 만들 때 그 빈자리가 느껴질 것이다.
  • 거꾸로, 하위클래스를 여러 개 만들 때까지 전혀 쓰이지 않는 protected 메서드는 사실 private 메서드이었어야 할 것이다.

저자는 경험 상 하위클래스는 3개 정도가 적당하다고 말한다. 그리고 그 중 하나는 지인이나 동료개발자처럼 제 3자가 작성해봐야한다고 얘기한다.


상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안된다!

예시코드로 바로 확인해보자.

// 상위클래스 : Super.class
public class Super {
	// 잘못된 예 - 생성자가 재정의 가능 메서드를 호출한다.
	public Super() {
    	overrideMe();
    }
    
    public void overrideMe() {}
}
// 하위클래스 : Sub.class
public final class Sub extends Super {
	// 초기화되지 않은 final 필드. 생성자에서 초기화한다.
	private final Instant instant;
    
    Sub() {
        instant = Instant.now();
    }  
    
    // 재정의 가능 메서드. 상위 클래스의 생성자가 호출한다.
    @Override public void overrideMe() {
        System.out.println(instant);
    }
    
    // 예제
    public static void main(String[] args) {
        Sub sub = new Sub();
        // Sub() -> Super() -> overrideMe() -> instant = Instant.now();
        sub.overrideMe();
    }
}

주석에 적어놨듯이 instant 필드를 초기화 하기 전에 override를 호출하게 된다.
그래서 결과는 아래와 같다.

null // instant가 null일 때 overrideMe()

2023-09-05T11:14:00.262020100Z // instant가 Instant.now()로 초기화된 뒤 overrideMe()

* 이 에제에서는 override()의 내부에 System.out.println 이기에 null을 허용했을 뿐, 다른 상황에는 NullPointerException을 던졌을 것이다.


Cloneable과 Serializable 인터페이스

  • Cloneable의 clone()
  • Serializable의 readObject()

두 메서드 모두 새로운 객체를 만든다는 점에서 생성자와 유사하다.
따라서 clone()readObject() 모두 재정의 가능 메서드를 호출해서는 안된다.

* 또한 Serializable을 구현한 상속용 클래스같은 경우 readObjectwriteReplace를 protected로 선언해야한다.
이 역시 상속을 허용하기 위해 '훅(Hook)'을 공개하는 예시 중 하나이다.


상속을 고려하지 않았다면, 상속을 금지하라

추상 클래스나 인터페이스처럼, 상속을 허용하는게 명백히 정당한 상황이 있고,
불변 클래스처럼, 상속을 허용하는게 명백히 잘못된 상황이 있다.

그렇다면 * 일반 구체 클래스는 어떨까?

* 여기서 "일반 구체 클래스"는 추상 클래스나 인터페이스와 대조되는 용어로 사용되며, 그 자체로 인스턴스화될 수 있는 일반적인 클래스를 의미한다.

전통적으로 이런 클래스는 final도 아니고, 문서화되지도 않았으며, 상속용으로 설계되지도 않았다.
이렇게 상속용으로 설계되지 않은 클래스들은 상속을 금지하는게 가장 좋은 방법이다.
상속을 금지하는 방법은 아래와 같이 두 가지가 있다.

  • 클래스를 final로 선언하는 방법
  • 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩터리를 만들어주는 방법

위와 같이 상속을 금지하는 방법은 논란의 여지가 있다고 한다.
그동안 수많은 프로그래머가 일반적인 구체 클래스를 상속해 여러 기능들을 추가해왔기 때문이다.
하지만, 저자는 핵심 기능을 정의한 인터페이스가 있고, 클래스가 해당 인터페이스를 구현했다면 개발하는데 아무런 어려움이 없을 것이라고 한다.
그래서, 핵심 인터페이스를 구현한 구체 클래스라면, 상속을 금지해도 된다. 라고 얘기한다.

만에 하나, 기능을 확장하거나 변경하고 싶은데, 그러기엔 까다로운 클래스가 존재한다고 치자.
이런 클래스라도 굳이 꼭 상속을 허용해야겠다면 클래스 내부에서는 재정의 가능 메서드를 사용하지 않게 만들고 이 사실을 문서로 남겨야한다. 재정의 가능 메서드를 호출하는 자기 사용 코드를 완벽히 제거하라는 말이다.

아래는 재정의 가능 메서드를 사용하는 코드를 제거하는 기계적인 방법이라고 한다.

  • 재정의 가능 메서드는 각각 private '도우미 메서드' 생성.
  • 자신의 본문 코드를 자신의 도우미 메서드로 옮기고, 자신은 도우미 메서드를 호출.
  • 재정의 가능 메서드를 호출하는 다른 코드들도 모두 도우미 메서드를 직접 호출하도록 변경.

아래는 예제코드를 작성해보았다. 글로만 이해하기에는 쉽지가 않다.

public class SoundProgram {
    private int volume;
// --------------- 재정의 가능 메서드 ----------------

	// 이전 코드 
//    public void soundUpPrint () {
//        System.out.println("소리가 1 증가했습니다. ");
//    }
//    public void PlusButton() {
//		  soundUpPrint(); // 클래스 내부에서 재정의 가능 메서드를 호출하고 있다.
//        volume++;
//    }

    public void soundUpPrint () {
        makeSoundUpPrint();
    }

    public void soundDownPrint () {
        makeSoundDownPrint();
    }
	
    public void plusButton() {
        makeSoundUpPrint();
        pressPlusButton();
    }

    public void minusButton() {
        makeSoundDownPrint();
        pressMinusButton();
    }
// ------------ private 도우미 메서드 ----------------
    private void makeSoundUpPrint() {
        System.out.println("소리가 1 증가했습니다. ");
    }

    private void makeSoundDownPrint() {
        System.out.println("소리가 1 감소했습니다. ");
    }

    private void pressPlusButton() {
        volume++;
    }

    private void pressMinusButton() {
        volume--;
    }
}

"구체 클래스가 표준 인터페이스를 구현하지 않았는데 상속을 금지하면 사용하기에 상당히 불편해진다."

책의 한 구절인데, 이해하기 쉽지 않아서 가져왔다.

아래 세 가지 경우를 보겠다.

1. 표준 인터페이스를 구현했고, 상속을 금지한 상태

  • 상속을 금지했기 때문에 직접적인 확장은 불가하지만, 인터페이스를 다시 구현하여 확장하는 것은 가능하다.
  • '컴포지션'이나 '래퍼클래스'와 같은 방법을 사용할 수 있다. (아이템18)

2. 표준 인터페이스를 구현하지 않았고, 상속을 금지하지 않은 상태

  • 상속을 금지하지 않았기 때문에 위험도가 상당히 높다.
  • 표준 인터페이스를 구현하지 않았기 때문에 내부 코드가 어떻게 동작할지 정확히 예측하기 어려울 수 있다. 예를 들어, Bag.class가 표준 인터페이스인 List를 구현했다고 하자, 우리는 Bag클래스가 어떤 메서드가 구현될 것이며, 내부가 어떻게 동작할 지 예측이 가능하다. 하지만 표준 인터페이스를 구현하지 않은 Bag 클래스라면, 내부 동작을 예측하기란 어려울 것이다.

3. 표준 인터페이스도 구현하지 않았고, 상속도 금지한 상태

  • 마찬가지로 표준 인터페이스를 구현하지 않았기 때문에 동작을 정확히 예측하기 어렵다.
  • 클래스의 내부 구현이 완전히 숨겨져 있으므로 변경에 안전하다. 반대로 말하면 변경이나 확장을 하기에는 불편할 수 있다.

만약 2번의 경우처럼 표준 인터페이스를 구현하지 않았고, 상속을 금지하지 않았으면, 개발자가 해당 클래스를 확장하기위해 상속을 고려해볼 것이다.
하지만 우리가 지금까지 알아왔듯이, 상속은 위험한 변수가 많기에, 상속을 고려하되, 클래스를 확장해야할 명확한 이유가 없다면 되도록 금지시킬 것이다.
상속을 금지했을 때를 생각해보면, 이 구체클래스를 재사용하거나 확장하기가 많이 복잡해진다.
즉, "사용하기에 상당히 불편하다." 라는 뜻은 해당 클래스가 표준 인터페이스도 구현하지 않았고, 상속도 할 수 없는 상황이므로 가능을 확장하거나 변경하기에는 불편하다라는 것을 얘기하고 있다.
그러므로 위와 같은 불편함이 싫어서 상속을 허용해야겠다면, 클래스 내부에서는 재정의 가능 메서드를 호출하는 자기 사용 코드를 완벽히 제거해라! ('private 도우미 메서드' 방법이 있다!)


핵심 정리

  • 아이템18 - 상속보다는 컴포지션을 고려하라.
  • 상속용 클래스를 설계하기란 엄청난 노력이 들고, 제약이 상당하다.
  • 클래스 내부에서 스스로를 어떻게 사용하는지(자기사용 패턴) 모두 문서로 남겨야한다.
  • 문서화한 것은 클래스가 쓰이는 한 반드시 지켜야한다.
  • 효율 좋은 하위클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야 할 수도 있다.
  • 상속용 클래스의 생성자는 재정의 가능 메서드를 호출하면 안된다.(clone, readObject도 동일)
  • 클래스를 확장해야 할 명확한 이유가 없다면 금지하라.
profile
반갑습니다 :)

0개의 댓글

관련 채용 정보