Programming/Java

[Effective Java] item19 - 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라!

VSFe 2021. 3. 8. 00:00
상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라!

개발을 할 때 상속이 편리한 친구이긴 하지만, 외부 클래스를 상속하는 과정에서 발생하는 문제들도 분명 있다. 그렇기에 상속을 할 가능성을 조금이라도 열어놓는다면 문서화를 해 두는 것을 권장한다.

일반적으로 상속을 하면 가장 먼저 하는 것 중에 하나가 메소드 재정의일 것이다. 그러나 상속을 하는 사람들이 이 메소드의 구체적인 역할이 무엇인지 판단해야 하므로, 상속용 클래스는 재정의할 수 있는 메소드들을 내부적으로 어떻게 이용하는지 문서로 남길 필요가 있다. 구체적으로 어떤 순서로 내부 과정이 이뤄지는지, 각각의 결과가 어떤 결과를 만들어내는지 까지 작성하면 좋다.

자바 API 문서의 메소드 설명을 보면, 가끔 특이한 문구가 붙어있다.

Implementation Requirements: This implementation iterates over the collection looking for the specified element. If it finds the element, it removes the element from the collection using the iterator's remove method. Note that this implementation throws an UnsupportedOperationException if the iterator returned by this collection's iterator method does not implement the remove method and this collection contains the specified object.

Implementation Requirement라는 문구가 적혀있는데, 이것은 메소드의 내부 동작 방식을 설명하는 곳이다. (@implSpec 이라는 어노테이션을 붙이면 자바독 도구가 생성해준다.)

보다보면 음... 뭔가 번거로운 것 같은데, 상속이 캡슐화를 해치기 때문에 어쩔 수 없이 발생하는 일이다. 결국 클래스의 상속을 안전하게 하기 위해선 어쩔 수 없이 내부 구현 방식을 설명해야 한다.

다만 @implSpec 태그는 아직까지도 선택사항이다. 즉 필요할 때 마다 붙여줘야 한다.

또한, 효율적인 하위 클래스를 위해선 클래스의 내부 동작 과정 중간에 끼어들 수 있는 메소드를 잘 선별하여 protected 형태로 공개해야 할 수도 있다. 예를 들어, AbstractList.removeRange()의 경우 일반 사용자는 거의 쓰지 않지만, clear() 메소드의 성능을 높이기 위해선 removeRange()를 활용할 수도 있기 때문이다.

그러나 어떤 메소드를 protected로 지정할 것인지는 어려운 일이다. 결국 하위 클래스를 직접 만들어보면서 테스트 하는 것이 제일 낫다. 하위 클래스를 만들어보면 어떤 메소드를 활용해야 할지 나올테고, 그것을 protected로 지정하는 것이 좋을 것이다.

특히 작성할 클래스가 배포용이나 API 용이라면, 문서화한 내용과 protected 메소드의 선택 사항을 매우 신중하게 할 필요가 있다!

자 그럼 이제 끝? 그럴리가....

일단 상속용 클래스의 생성자는 직간접적으로 재정의 가능 메소드를 호출하면 안 된다. 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메소드가 먼저 실행될 것이다! (참고로 private, final, static은 재정의가 불가능하니 이건 안심하고 사용해도 된다!)

앞에서 배웠던 Clonable과, Serializable 인터페이스는 상속의 난이도를 극악으로 만드는 주범이다. Cloneable에 대해서는 앞에서 배우면서 그 고통을 느껴봤을 것이다. Serializable의 경우 readObject가 생성자와 비슷한 제약이 있기 때문에 직간접적으로 재정의 가능 메서드를 사용하면 큰일난다!

Serializable을 구현한 상속용 클래스가 readResolvewriteReplace를 갖게 된다면 하위 클래스에서 무시될 수 있으므로 protected로 선언하는 것도 잊지 말자.

어우... 딱 보면 알겠지만 그냥 쓰기 싫을 것이다. 정말 보다시피 단순히 '아 클래스 만들거면 상속은 해야겠지?'라는 가벼운 생각으로 접근하기엔 매우 복잡하고 많은 비용이 든다.

일반적인 구체 클래스의 경우 final도 아니고 상속용으로 설계되지도 않았기 때문에 상당히 위험하다. 그렇다면 자연스럽게 '상속을 안 할거면 final로 해야하는거 아닌가?' 라는 결론에 도달할 수 있는데, 그것은 아주 좋은 판단이다! 참고로 final 이외에도 생성자를 private나 package-private로 선언하고 public 정적 팩터리 메소드를 만들어주는 방법도 나쁘지 않다.

다만 구체 클래스가 표준 인터페이스를 구현하지 않았는데 상속을 막으면 또 애매해진다. 이런 경우엔 클래스 내부에 재정의 가능 메소드를 사용하지 않도록 만들어버리고 문서화 시키는 것이다. 이렇게 하면 메소드의 재정의가 아예 발생하지 않으므로 비교적 안전하게 상속을 수행할 수 있을 것이다.

물론 그동안 상속을 편하게 생각했던 사람들이 많았기에 굉장히 낯설게 들릴 주제일 것이다. 다만 상속 자체가 캡슐화를 해치는 요인이니, 앞으로는 잘 신경쓰면서 설계하도록 하자.