Hyun's Wonderwall

[Java] 이펙티브 자바 - 4장(2/2), 5장(1/2) (아이템 19~27) 본문

Study/Java

[Java] 이펙티브 자바 - 4장(2/2), 5장(1/2) (아이템 19~27)

Hyun_! 2025. 8. 2. 17:41

4장 클래스와 인터페이스 (2/2)

아이템 19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라.

상속용 클래스재정의할 수 있는 메서드들을 내부적으로 어떻게 사용하는지(자기사용 패턴) 내부 구현 방식을 설명해 문서를 남겨야 한다.

- @implSpec 태그 붙이면 javadoc이 Implementation Requirements 생성해준다 (메서드의 내부 동작 방식 설명.)

 

효율적인 하위 클래스를 만들 수 있도록 (클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅을 잘 선별하여) 일수 메서드를 protected로 제공해야 할 수도 있다. (드물게는 protected 필드도)

 

문서화한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다. 그러지 않으면 내부 구현 방식을 믿고 활용하던 하위 클래스들을 오동작하게 만들 수 있다. (널리 쓰일 클래스를 상속용으로 설계한다면, 문서화한 내부 사용 패턴과 protected 메서드와 필드를 구현하면서 선택한 결정들이 그 클래스의 성능과 기능에 영원히 영향을 미친다.)

-> 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야한다.

 

상속용 클래스의 생성자는 재정의 가능 메서드를 호출해서는 안 된다.

- 만약 그렇다면 => 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되어서 => 하위 클래스에서 재정의한 메서드가 하위 클래스의 생성자보다 먼저 호출되는데, 그 재정의한 메서드가 하위 클래스의 생성자에서 초기화되는 필드를 참조한다면 예상치 못한 동작이나 오류가 발생할 수 있다. (ex.하위 클래스의 생성자가 필드를 초기화하기도 전에 오버라이드한 메서드에서 하위 클래스의 필드를 사용...)

 

Clonable, Serializable 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것 비추천. clone과 readObject 는 생성자와 비슷하게 새로운 객체를 만드는 효과를 내서, 그 안에서 재정의 가능 메서드를 호출하면 안 된다.

 

상속용으로 설계하지 않은 클래스는 상속을 금지하자. (일반적인 구체 클래스)

- 클래스를 final로 선언

- 모든 생성자를 private이나 package-private으로 선언해 외부에서 접근할 수 없게 하고, public 정적 팩터리를 제공

 

핵심 기능을 정의해놓은 인터페이스가 있고, 클래스가 그 인터페이스를 구현했다면 상속을 금지해도 개발하는 데 아무런 어려움이 없다. (Set, List, Map)

그러나 편의성 위해 표준 인터페이스를 구현하지 않은 구체 클래스를 상속해 사용해야겠다면, 클래스 내부에서 재정의 가능 메서드를 사용하지 않게 만들고 이 사실을 문서로 남겨라. 재정의 가능 메서드를 호출하는 자기 사용 코드를 완벽히 제거하면 -> 클래스를 상속해도 그리 위험하지 않다.

- 자기 사용 제거 방법: 재정의 가능 메서드들의 본문 코드를 private 도우미 메서드로 옮기고, 이 도우미 메서드를 호출하도록 수정한다. 그리고 재정의 가능 메서드를 호출하는 다른 코드들도 그 도우미 메서드를 직접 호출하도록 수정한다.


아이템 20. 추상 클래스보다는 인터페이스를 우선하라

자바에서 구현 의무를 부여할 수 있는 메커니즘: 인터페이스(다중 구현 가능), 추상 클래스(단일 상속)

- 자바 8부터 인터페이스에서도 default 메소드 제공 -> 인스턴스 메서드를 구현 형태로 제공 가능

- 클래스 상속에서 차이 발생: 추상 클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 하위 클래스가 되어야 한다(단일 상속). 반면 인터페이스를 구현한 클래스는 상속중인 클래스와 관련없이 (인터페이스 타입에 의해) 같은 타입으로 취급된다.

 

인터페이스는 유연성이 높아 다음과 같은 이점을 가진다.

-기존 클래스에도 손쉽게 새로운 인터페이스를 구현해넣을 수 있다.

  - 기존 클래스를 수정하고자 할 때 추상클래스는 클래스 계층구조상 제약이 많다(하위 클래스들이 모두 그 추상클래스를 공통 조상으로 가져야 함).

- 인터페이스는 mixin 정의에 안성맞춤이다.  (ex. Comparable)

- 인터페이스는 계층구조가 없는 타입프레임워크를 만들 수 있다. (계층을 엄격히 구분하기 어려운 개념 표현 용이)

- 래퍼 클래스 관용구와 함께 사용하면 인터페이스는 기능을 향상시키는 안전하고 강력한 수단이 된다.

  - 구현 방법이 명백한 것을 디폴트 메서드로 제공 (문서화 필수) (디폴트로 제공하면 안 되는 경우 주의)

 

인터페이스와 추상 골격 구현 클래스를 함께 제공해 둘의 장점을 모두 취할 수 있다.

- 인터페이스: 타입을 정의, 필요하면 디폴트 메서드로 구현 제공 (but 구현 제약 존재)

- 골격 구현 클래스(추상 클래스): 나머지 메서드들까지 구현 (=> 템플릿 메서드 패턴)

- 예시: Collection 프레임워크의 AbstractSet, AbstractCollection, AbstractList, AbstractMap

 

단순 구현(simple implementation): 상속을 위해 인터페이스를 구현한 클래스 (골격 구현과 비슷한데 추상 클래스 X)


아이템 21. 인터페이스는 구현하는 쪽을 생각해 설계하라

디폴트 메서드를 선언하면, 그 인터페이스를 구현하고 디폴트 메서드를 재정의하지 않은 모든 클래스에서 디폴트 구현이 쓰임.

-> 기존 사용하던 인터페이스에 디폴트 메서드 추가 시 연동 호환성 발생 가능. 컴파일에 성공하더라도 기존 구현체에 런타임 오류를 일으킬 수 있다.

-> 꼭 필요한 경우가 아니면, 기존 인터페이스에 디폴트 메서드로 새 메서드 추가하지 X.

핵심: 인터페이스 설계시엔 세심한 주의를 기울여야 한다. 새로운 인터페이스라면 릴리스 전에 반드시 테스트를 거쳐야 한다. (다양한 방법으로 인터페이스 직접 구현해보고, 인터페이스를 활용하는 클라이언트도 여러 개 만들어보고)


아이템 22. 인터페이스는 타입을 정의하는 용도로만 사용하라

인터페이스: 자신을 구현한 클래스의 인스턴스를 참조할 수 있는 타입 역할.

('클래스가 인터페이스를 구현한다': 자신의 인스턴스로 무엇을 할 수 있는지를 클라이언트에 알려주는 것)

- 이 지침에 맞지 않는 안티패턴 예시: 상수 인터페이스 (메서드 없이 static final 상수 필드로만 구성하는 경우 - 인터페이스를 상수 공개용 수단으로 사용하면 X)

 

상수를 공개하고 싶다면

- 클래스나 인터페이스 자체에 추가

- 열거 타입

- 인스턴스화할 수 없는 유틸리티 클래스(아이템 4)에 담아 공개

  - 유틸리티 클래스에 정의된 상수를 클라이언트에서 사용하려면 클래스이름.상수이름 형태로 명시(임포트 필요x. / 또는, 정적 임포트해 클래스 이름 생략)


아이템 23. 태그 달린 클래스보다는 클래스 계층구조를 활용하라

(안 좋은 클래스 사용 예) '태그 달린 클래스': 두 가지 이상의 의미를 표현할 수 있고, 그중 현재 표현하는 의미를 태그 값으로 알려줌

=> 단점 많음: 코드가 장황하고 메모리 많이 차지해 비효율적.

=> 이 대신 클래스 계층구조를 사용하자.

 

클래스 계층구조 활용 방법

1. 계층구조의 루트가 될 추상 클래스를 정의, 태그 값에 따라 동작이 달라지는 메서드들을 루트 클래스의 추상 메서드로 선언.

2. 루트 클래스를 확장한 구체 클래스를 의미별로 하나씩 정의.

3. 루트 클래스가 정의한 추상 메서드를 각자의 의미에 맞게 구현.

=> 간결, 명확, 오류 발생 적고 컴파일타임 타입 검사 능력도 높임.


아이템 24. 멤버 클래스는 되도록 static으로 만들라

중첩 클래스(nested class): 다른 클래스 안에 정의된 클래스

- 자신을 감싼 바깥 클래스에서만 쓰여야 한다.

- 종류: 정적 멤버 클래스, (비정적) 멤버 클래스, 익명 클래스, 지역 클래스

  - 정적 멤버 클래스 외엔 내부 클래스(inner class)

 

1. 정적 멤버 클래스

- 다른 클래스 안에 선언되고, 바깥 클래스의 private 멤버에 접근 가능하다. 다른 정적 멤버와 똑같은 규칙을 적용받는다(접근제한자).

- 흔히 public 도우미 클래스로 쓰인다. (바깥 클래스와 함께 쓰일 때만 유용한)
- 중첩 클래스가 바깥 인스턴스와 독립적으로 존재할 수 있다면 정적 멤버 클래스로 만들어야 한다.

- 멤버 클래스에서 바깥 인스턴스에 접근할 일이 없다면 무조건 static을 붙여 정적 멤버 클래스로 만들자.

  - 비정적 멤버 클래스는 바깥 인스턴스로의 숨은 외부 참조를 가져야 해서 시간과 공간을 소비하며, GC가 바깥 클래스의 인스턴스를 수거하지 못하는 메모리 누수가 발생할 수 있다. (참조가 눈에 보이지 않아 문제 원인 찾기도 어렵)

- Private 정적 멤버 클래스는 흔히 바깥 클래스가 표현하는 객체의 한 구성요소를 나타낼 때 쓴다. 

  - ex. Map 인터페이스의 구현체들은 각각의 키-값 쌍을 표현하는 엔트리(Entry)를 private 정적 멤버 클래스로 표현한다. (엔트리의 메서드들-getKey, getValue, setValue에서 맵을 직접 사용하지 않아, 바깥 맵으로의 참조가 필요없음.)

 

2. 비정적 멤버 클래스

- 바깥 인스턴스 없이는 생성될 수 없다. (독립적 존재 불가)

- 바깥 클래스의 인스턴스와 암묵적으로 연결된다. -> 비정적 멤버 클래스의 인스턴스 메서드에서 정규화된 this를 사용해 바깥 인스턴스의 메서드를 호출하거나 바깥 인스턴스의 참조를 가져올 수 있다.

  - 정규화된 this: 클래스명.this 형태로 바깥 클래스의 이름을 명시하는 용법)

- 어댑터를 정의할 때 자주 쓰인다. (어떤 클래스의 인스턴스를 감싸 마치 다른 클래스의 인스턴스처럼 보이게 하는 뷰로 사용)

  - ex. Map 인터페이스의 구현체들은 보통 자신의 컬렉션 뷰를 구현할 때 비정적 멤버 클래스를 사용한다. 

  - ex. Set, List도 Iterator를 구현할 때 비정적 멤버 클래스를 주로 사용한다.

  

3. 익명 클래스

- 이름이 없고, 바깥 클래스의 멤버가 아니다. 쓰이는 시점에 선언과 동시에 인스턴스가 만들어진다.

- 한 메서드 안에서만 쓰이면서 그 인스턴스를 생성하는 지점이 단 한 곳이고 해당 타입으로 쓰기 적합한 클래스나 인터페이스가 이미 있는 경우 사용한다.

- 정적/비정적 문맥 모두에서 사용할 수 있지만, 비정적 문맥에서만 바깥 클래스의 인스턴스를 참조 가능하다.
- static final 상수 외의 static 멤버 사용이 불가능하다.

- 불가능한 것들: instanceof 검사, 클래스명이 필요한 작업, 여러 인터페이스 구현, 인터페이스를 구현하며 동시에 다른 클래스를 상속

- 익명 클래스를 사용하는 클라이언트는 그 익명 클래스가 상위 타입에서 상속한 멤버 외에는 호출할 수 없다.

- 주로 정적 팩터리 메서드 구현 시 사용

자바가 람다를 지원하기 전에는 작은 함수 객체나 처리 객체를 만들기 위해 익명 클래스를 주로 사용했는데, 이제는 람다를 주로 사용한다. 

 

4. 지역 클래스

- 지역 변수를 선언할 수 있는 곳에서 선언할 수 있는 클래스로, 유효 범위도 지익변수와 같다.

- 이름이 있어 반복해서 사용할 수 있고, 비정적 문맥에서 사용될 때만 바깥 인스턴스를 참조할 수 있고, 정적 멤버는 가질 수 없고, 가독성 위해 짧게 작성해야 한다.

 


아이템 25. 톱 레벨 클래스는 한 파일에 하나만 담으라

소스 파일 하나에는 반드시 톱레벨 클래스(혹은 톱레벨 인터페이스)를 하나만 담자. 

- 그렇지 않으면, 컴파일러에 어느 소스 파일을 먼저 건네느냐에 따라 동작이 달라질 수 있다.

- 한 클래스가 다른 클래스에 딸린 부차적인 클래스라면 정적 멤버 클래스로 만들어 사용하는 것도 좋다.

 


5장 제네릭 (1/2)

제네릭을 지원하기 전에는 컬렉션에서 객체를 꺼낼 때마다 형변환을 해야 했다.

제네릭을 사용하면 컬렉션이 담을 수 있는 타입을 컴파일러에 알려주게 된다.

=> 컴파일러가 알아서 형변환 코드를 추가할 수 있게 되어, 엉뚱한 타입의 객체 삽입 시도를 컴파일 과정에서 차단한다.


아이템 26. 로(raw) 타입은 사용하지 말라

클래스/인터페이스 선언에 타입 매개변수(type parameter)가 쓰이면 제네릭 클래스/제네릭 인터페이스.

제네릭 클래스+제네릭 인터페이스= '제네릭 타입'

 

각각의 제네릭 타입은 일련의 매개변수화 타입(parameterized type)을 정의한다. (ex. List<E>)

1) 먼저 클래스/인터페이스 이름

2) 꺾쇠괄호 안에 실제 타입 매개변수를 나열

List<String>에서 String: 실제(actual) 타입 매개변수로, 정규(formal) 타입 매개변수 E에 해당함

 

제네릭 타입 정의시 로 타입도 함께 정의된다(제네릭 도입 이전과의 호환성 때문에). 그러나 쓰면 안 된다.

- 로 타입(raw type): 타입 매개변수를 전혀 사용하지 않을 때를 의미 (ex. List, Collection)

- 로 타입 사용 시 문제: 코드 중간에 다른 타입을 넣어도 컴파일러가 알아채지 못하고, 런타임에 예외 발생.

- 다만 예외적으로 로 타입을 써야 하는 경우:

  1) class 리터럴 (ex. List.class, String[].class, int.class)

  2) instanceof 뒤쪽 (if (o instanceof Set))

 

제네릭을 활용하면 이 정보가 타입 선언 자체에 녹아드므로 컴파일러가 컴파일 오류를 발생시켜 문제를 명확히 알려준다.

- 원리: 컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변화를 추가하여 절대 실패하지 않음을 보장한다.

 

제네릭 타입을 쓰고 싶지만 실제 타입 매개변수가 무엇인지 신경쓰고 싶지 않다면 와일드카드 '?'를 사용하자.

 

Set<Object>: 모든 타입 객체 저장 가능한 매개변수화 타입 (모든 참조형이 Object를 상속)

Set<?>: 모든 구체적 Set<T> 타입을 참조 가능(유연), 내부 요소 타입을 알 수 없으므로 원소 추가/변경 불가(null만 추가/변경 가능)

Set: 제네릭 아님, 안전 x

제네릭 관련 용어


아이템 27. 비검사 경고를 제거하라 (런타임 예외 관련 경고)

컴파일러는 Checked Exception(Exception의 하위 클래스 중 RuntimeException 계열을 제외한 예외)에 대해 예외 처리를 강제한다.

런타임 중 발생할 수 있는 Unchecked Exception(RuntimeException 하위 클래스)은 예외 처리를 강제하지 않으나 컴파일러는 타입 불일치, raw 타입 등에서 비검사 경고를 발생시켜준다.

 

비검사 경고는 런타임에 ClassCastException 등 치명적인 오류로 이어질 수 있으므로 반드시 확인하고 제거하자.

타입 안전하다고 확신할 수 있다면 @SuppressWarning("unchecked") 애너테이션으로 경고를 숨길 수 있다.

 

+ 경험했던 사례가 떠올랐다...

String str = "123";
Integer i = (Integer) str; // 컴파일러 경고 + 런타임 예외 발생 (ClassCastException)

 

String과 Integer는 상속관계가 전혀 없으므로 캐스팅이 불가하지만, 컴파일 시점에서는 오류로 표시되지는 않는다(-> 런타임에 ClassCastException 발생). 문자열을 정수로 변환하려면 Integer.parseInt(str) 또는 Integer.valueOf(str)와 같은 변환 메서드를 사용해야 한다.