4장은 클래스와 인터페이스를 사용할 때 주의할 점과 유용한 팁을 알려준다.
15. 클래스와 멤버 접근 권한 최소화
잘 설계된 컴포넌트는 내부 구현 정보를 완벽하게 숨겨, 구현과 API를 깔끔하게 분리한다. 다른 컴포넌트와 소통할 때에는 오직 API만 사용하고 서로의 내부 동작 방식은 알 필요가 없다. 이 개념은 정보 은닉 또는 캡슐화로, 소프트웨어 설계의 근간이 되는 원리다.
정보 은닉 장점
정보 은닉을 잘 하면 컴포넌트들을 서로 분리해 개발, 테스트, 최적화, 적용, 분석, 수정을 개별적으로 할 수 있게 해준다.
- 여러 컴포넌트 병렬로 개발해 개발 속도 높임
- 컴포넌트 파악이 쉽고 교체 부담도 적어 시스템 관리 비용 낮춤
- 성능 최적화에 도움을 줌
- 소프트웨어 재사용성 높임
- 개별 컴포넌트 동작을 검증할 수 있어 큰 시스템 제작의 난이도를 낮춤
접근 제한자
자바에서는 정보 은닉을 위한 다양한 장치를 제공하는데, 그중 클래스, 인터페이스, 멤버의 접근성을 명시하는 접근 제어 메커니즘이 있다. 각 요소의 접근성은 선언된 위치와 접근제한자로 정해진다.
접근 제한자를 제대로 활용하기 위해서는 모든 클래스와 멤버의 접근성을 최대한 좁혀야 한다.
톱레벨 클래스와 인터페이스
- public
- 공개 API
- 하위 호환을 위해 영원히 관리해야함
- package-private (default)
- 해당 패키지 안에서만 사용 가능
- 내부 구현
- 한 클래스에서만 사용한다면 사용 클래스 안에 private static으로 중첩
- public일 필요 없는 클래스를 package-private 톱레벨 클래스로 좁히기
멤버 (필드, 메서드, 중첩 클래스, 중첩 인터페이스)
- private
- package-private
- protected (적을수록 좋다)
- public
주의사항
- Serializable을 구현한 클래스에서는 private, package-private 필드들도 의도치 않게 공개 API가 될 수 있다.
- 테스트만을 위해 클래스, 인터페이스, 멤버를 공개 API로 만들어서는 안된다.
- public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다. (불변성 보장 위해)
- 꼭 필요한 상수라면 public static final 필드로 공개 (대문자, 단어 사이에 _ 넣기, 반드시 기본 타입 값이나 불변 객체 참조 필요)
- public static final 배열 필드를 두거나 필드 반환하는 접근자 메서드 제공 금지 (길이가 0이 아닌 배열은 모두 변경 가능)
- private 배열, public 불변 리스트 추가
- private 배열, 방어적 복사
- 자바 9에서 추가된 모듈 시스템의 접근 수준은 주의해서 사용해야 한다. (사용 지양)
16. public 클래스에서는 접근자 메서드 사용
java.awt.package의 Point, Dimension처럼 public 클래스의 필드를 직접 노출하면 캡슐화의 이점을 제공하지 못한다.
- API를 수정해야만 내부 표현 바꿀 수있음
- 불변식 보장 X
- 외부에서 필드 접근 시 부수 작업을 수행할 수 없음
패키지 바깥에서 접근 가능한 클래스면 필드를 private로 만들어 캡슐화의 이점을 제공하고 getter와 setter와 같은 접근자를 제공해 유연성을 얻을 수 있다.
하지만 package-private나 private 중첩 클래스라면 데이터 필드를 노출해도 된다. 오히려 데이터 필드를 노출하는 것이 더 깔끔하다.
public 은 절대 가변 필드를 노출해서는 안된다. 불변 필드로 변경한다면 덜 위험하지만 안심해서는 안된다.
17. 변경 가능성 최소화
불변 클래스란 인스턴스의 내부 값을 수정할 수 없는 클래스로, 가변 클래스보다 설계/구현/사용이 수비고 오류가 생길 여지도 적으며 안전하다.
클래스를 불변으로 만드는 규칙
- 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다.
- 클래스를 확장할 수 없도록 한다. (대표적인 방법은 클래스 final로 선언)
- 모든 필드를 final로 선언한다. (= 어떤 메서드도 객체의 상태 중 외부에 비치는 값을 변경할 수 없다.)
- 모든 필드를 private으로 선언한다.
- 기술적으로는 public final로만 선언해도 불변 객체가 되지만 다음 릴리스에서 내부 표현을 바꾸지 못해 권하지 않음
- 자신 외에는 내부의 가변 컴포넌트에 접근할 수없도록 한다.
- 절대 클라이언트가 제공한 객체 참조를 가리키게 하면 안됨
- 접근자 메서드가 필드 그대로 반환하면 안됨 (생성자, 접근자, readObject 메서드 모두에서 방어적 복사 해야함)
함수형 프로그래밍
피연산자에 함수를 적용해 그 결과를 반환하지만 피연산자 자체는 그대로인 프로그래밍 패턴이다. 메서드 이름으로는 동사 대신 전치사를 사용하는데 (add 대신 plus), 이는 해당 메서드가 객체의 값을 변경하지 않음을 강조한다.
public Complex plus(Complex c){
return new Complex(re + c.re, im + c.im);
}
불변 객체 특성
- 불변 객체는 단순하다.
- 생성된 시점의 상태를 파괴될 때까지 그대로 간직함
- 불변 객체는 근본적으로 스레드 안전해서 따로 동기화할 필요가 없다.
- 클래스를 스레드 안전하게 만드는 가장 쉬운 방법
- 불변 객체는 안심하고 공유 가능
- 한 번 만든 인스턴스를 최대한 재활용하는 것을 권함
- 자주 쓰이는 값들을 상수로 제공 (public static final)
- 정적 팩터리 제공 (자주 사용되는 인스턴스 캐싱)
- 방어적 복사가 필요 없음
- 한 번 만든 인스턴스를 최대한 재활용하는 것을 권함
- 불변 객체끼리 내부 데이터를 공유할 수 있다.
- 객체를 만들 때 다른 불변 객체들을 구성요소로 사용하면 이점이 많다.
- 불변 구성요소로 이뤄진 객체면 불변식을 유지하기 수월함
- 맵의 키, 집합의 원소로 쓰기 좋음
- 불변 객체는 그 자체로 실패 원자성을 제공한다.
- 값이 다르면 반드시 독립된 객체로 만들어야 한다.
불변 클래스에서 값이 다르면 반드시 독립된 객체로 만들 때 성능 문제
값의 가짓수가 많다면 이를 모두 만드는 데 큰 비용이 필요하다. 이런 문제에 대처하는 방법은 두 가지다.
- 다단계 연산들을 예측해 기본 기능으로 제공
- 각 단계마다 객체를 생성하지 않아도 됨
- 클라이언트들이 원하는 복잡한 연산을 정확히 예측 가능하면 package-private의 가변 동반 클래스로 충분함
- String 클래스, String의 가변 동반 클래스인 StringBuilder
불변 클래스 설계 방법
클래스가 불변임을 보장하기 위해서는 자신을 상속하지 못하게 해야한다. 이를 위한 가장 쉬운 방법은 클래스를 final로 선언하는 것인데, 더 유연한 방법이 있다.
모든 생성자를 private 혹은 package-private로 만들고 public 정적 팩터리 제공
- 패키지 밖에서 바라본 불변 객체는 사실상final이다.
- 다수의 구현 클래스를 활용한 유연성 제공
- 다음 릴리스에서 객체 캐싱 기능 추가해 성능 높일 수 있음
BigInteger와 BigDecimal은 재정의할 수 있게 설계되어 (가변) 신뢰할 수 없는 하위 클래스의 인스턴스의 인수로 받았다면 가변이라고 가정하고 방어적으로 복사해서 사용해야 한다.
주의 사항
- 클래스는 꼭 필요한 경우가 아니면 불변이어야 한다.
- 단순한 값 객체는 항상 불변으로 만들 것.
- 성능 때문에 어쩔 수 없다면 불변 클래스와 쌍을 이루는 가변 동반 클래스를 public 클래스로 제공할 것.
- 불변으로 만들 수 없는 클래스의 변경 부분을 최소한으로 줄이자.
- 모든 필드는 private final (합당한 이유 없다면)
- 생성자는 불변식 설정이 모두 완료된 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.
18. 상속보다 컴포지션 사용
상위 클래스와 하위 클래스 모두를 같은 프로그래머가 통제하는 패키지 안, 혹은 확장할 목적으로 설계되고 문서화도 잘 된 클래스에서는 상속이 안전하다. 하지만 다른 패키지의 구체 클래스를 상속하는 것은 위험하다. (extends)
캡슐화를 깨뜨리는 상속
상속을 하면 상위 클래스가 어떻게 구현되냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
- 상위 클래스가 릴리스마다 내부 구현이 달라짐 → 하위 클래스도 수정 필요
- 상위 클래스의 메서드 재정의 → 어렵고 시간 많이 들고, 오류 내거나 성능 떨어뜨릴 수 있음, private 필드 써야 한다면 구현 불가능
- 다음 릴리스에서 상위 클래스에 새로운 메서드 추가
- 하위 클래스에서 재정의 → 상위 클래스에서 또다른 메서드 추가되면 보안 구멍 생김
- 새로운 메서드 추가 → 다음 릴리스에 상위 클래스에서 추가한 메서드와 시그니처 같고 반환 타입 다르면 컴파일 안됨, 반환 타입 같다면 위의 문제와 동일
컴포지션
상속을 하게 되면 위와 같은 문제가 발생한다. 이런 문제를 모두 피해가기 위해 컴포지션 설계를 사용할 수 있다.
컴포지션이란 기존 클래스를 확장하는 대신 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 해서 기존 클래스가 새로운 클래스의 구성 요소로 쓰이게 하는 설계 방식이다.
전달
새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 결과를 반환하며 이를 전달이라 한다.
새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나고, 기존 클래스에 새로운 메서드가 추가돼도 영향받지 않는다.
- 전달 메서드 : 새 클래스의 메서드
- 전달 클래스 : 전달 메서드만으로 이뤄진 재사용 가능한 전달 클래스
- 래퍼 클래스 : 다른 인스턴스를 감싸 기능을 덧씌우는 클래스 (원본 클래스를 래핑함)
- 다른 클래스에 기능 덧씌운다는 뜻에서 데코레이터 패턴이라 함
- 하위 클래스보다 견고하고 강력함
컴포지션과 전달의 조합은 위임이라 부른다. (래퍼 객체가 내부 객체에 자기 자신의 참조를 넘길 때만 위임에 해당)
주의사항
- 래퍼 클래스는 콜백 프레임워크와 어울리지 않는다. (SELF 문제)
- 상속은 반드시 is-a 관계일 때에만 사용해야 한다.
- 컴포지션을 사용할 상황에 상속을 사용한다면 내부 구현을 불필요하게 노출하는 것
- 확장하려는 클래스의 API에 아무 결함이 없는지, 결함이 있다면 클래스의 API까지 전파돼도 괜찮은지 고려
19. 상속 고려해 설계, 문서화
상속을 고려한 설계와 문서화
- 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 (자기 사용) 문서로 남겨야 한다.
- 호출되는 메서드가 재정의 가능 메서드면 이를 호출하는 메서드 API 설명에 적시해야 함
- 어떤 순서로 호출하는지, 각각의 호출 결과가 어떤 영향 주는지
- 모든 상황을 문서로 남겨야 함 (백그라운드 스레드, 정적 초기화 과정에서도 호출 일어날 수 있음)
- 메서드의 내부 동작 방식, 구현 방식을 설명해야 한다.
- Implementation Requirements (@implSpec)
- @implSpec 태그 활성화하려면 명령줄 매개변수로 -tag "implSpec:a:Implementation Requirements:" 지정
- 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅을 잘 선별해 protected 메서드(혹은 필드)로 공개
- protected 메서드는 내부 구현에 해당하기 때문에 가능한 적어야 함
- 상속용 클래스의 생성자는 직간접적으로 재정의 가능 메서드 호출하면 안됨
- Cloneable, Serializable 중 하나라도 구현한 클래스 상속하게 설계하는 것은 좋지 않은 생각
- clone, readObject 메서드는 생성자와 비슷한 효과를 냄
→ 직간접적으로 재정의 가능 메서드 호출하면 안됨 - readObject : 하위 클래스 상태가 다 역직렬화되기 전에 재정의한 메서드부터 호출
- clone : 하위 클래스의 clone 메서드가 복제본의 상태를 수정하기 전에 재정의한 메서드 호출
- clone, readObject 메서드는 생성자와 비슷한 효과를 냄
- Serializable 구현한 상속용 클래스가 readResolve, writeReplace 메서드 가진다면 protected로 선언해야 함
상속용 클래스 테스트
상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어 보는 것이다.
- 꼭 필요한 protected 멤버를 놓쳤다면 하위 클래스 작성 시 빈자리가 드러남
- 하위 클래스를 여러개 만들 때까지 사용하지 않은 protected 멤버는 private이었어야 할 가능성 큼
- 하위 클래스 3개 정도가 검증에 적당함
상속 금지
일반적인 구체 클래스는 final도 아니고 상속용으로 설계되거나 문서화되지 않았지만 이대로 두면 위험하다. 이를 해결하기 위해 가장 좋은 방법은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이다.
- 클래스를 final 선언
- 모든 생성자를 private이나 package-private으로 선언, public 정적 팩터리 만들기
구체 클래스가 표준 인터페이스를 구현하지 않았을 때 상속을 금지하면 사용이 불편해진다. 이런 클래스에 상속을 꼭 허용해야 한다면 클래스 내부에서 재정의 가능 메서드 사용하지 않게 하고 이를 문서로 남겨야 한다.
20. 추상 클래스보다 인터페이스 우선
추상 클래스와 인터페이스의 가장 큰 차이점은 추상 클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 하위 클래스가 되어야 한다는 것이다. 이는 새로운 타입 정의에 큰 제약이 된다.
인터페이스 장점
- 기존 클래스에도 손쉽게 새로운 인터페이스를 구현해 넣을 수 있다.
- 믹스인(mixin) 정의에 안성맞춤이다.
- 믹스인 : 클래스가 구현할 수 있는 타입
- 믹스인을 구현한 클래스에 원래의 주된 타입 외에도 특정 선택적 행위 제공한다고 선언하는 효과를 줌 (Comparable)
- 인터페이스로는 계층구조가 없는 타입 프레임워크를 만들 수 있다.
- 래퍼클래스 관용구와 함께 사용하면 인터페이스는 기능을 향상시키는 안전하고 강력한 수단이 된다.
- 인터페이스의 메서드 중 구현 방법이 명백한 것이 있다면 디폴트 메서드로 제공할 수있다.
- @implSpec 자바독 태그를 붙여 문서화 필요
- Object의 메서드를 디폴트 메서드로 제공하면 안됨 (equals, hashCode)
- 인터페이스는 인스턴스 필드 가질 수 없음
- 인터페이스는 public이 아닌 정적 멤버 가질 수 없음 (private 정적 메서드는 예외)
- 자신이 만들지 않은 인터페이스에는 디폴트 메서드 추가할 수 없음
추상 골격 구현
인터페이스와 추상 골격 구현 클래스를 함께 제공해 인터페이스와 추상 클래스의 장점을 모두 취할 수있다.
인터페이스로는 타입을 정의하고, 디폴트 메서드 몇 개도 함께 제공한다. 그리고 골격 구현 클래스는 나머지 메서드들까지 구현한다. 단순히 골격 구현을 확장하기만 해도 인터페이스를 구현하는 데 필요한 일이 대부분 완료되며 이는 템플릿 메서드 패턴이다.
인터페이스의 이름이 Interface면 골격 구현 클래스의 이름은 AbstractInterface로 짓는다.
시뮬레이트한 다중 상속
골격 구현 클래스를 우회적으로 이용해, 인터페이스를 구현한 클래스에서 해당 골격 구현을 확장한 private 내부 클래스를 정의하고 각 메서드 호출을 내부 클래스의 인스턴스에 전달한다. 래퍼 클래스와 비슷하다.
골격 구현 작성
- 다른 메서드들의 구현에 사용되는 기반 메서드들 선정
- 골격 구현에서는 추상 메서드가 됨
- 기반 메서드들을 사용해 직접 구현할 수 있는 메서드를 모두 디폴트 메서드로 제공
- Object의 메서드는 디폴트 메서드로 제공하면 안됨
- 메서드가 남았다면 인터페이스를 구현하는 골격 구현 클래스를 하나 만들어 남은 메서드들을 작성해 넣는다.
골격 구현은 기본적으로 상속해 사용하는 것을 가정하기 때문에 문서로 정리해야 한다.
21. 인터페이스는 구현하는 쪽을 생각해 설계
인터페이스를 설계할 때 세심한 주의를 기울여야 하며, 릴리스한 후에 수정할 수 있는 가능성에 기대면 안된다. (디폴트 메서드)
디폴트 메서드는 기존 구현체에 런타임 오류를 일으킬 수 있다. 그렇기 때문에 기존 인터페이스에 디폴트 메서드로 새 메서드를 추가하는 일은 꼭 필요하지 않으면 피해야 한다. 또, 디폴트 메서드는 인터페이스로부터 메서드를 제거하거나 기존 메서드의 시그니처를 수정하는 용도가 아님을 명심해야 한다.
22. 인터페이스는 타입을 정의하는 용도로만 사용
인터페이스는 타입을 정의하는 용도로만 사용해야 한다.
상수 인터페이스 안티패턴은 인터페이스를 잘못 사용한 예다. 상수를 공개하고 싶다면 다른 방법을 사용해야 한다.
- 특정 클래스나 인터페이스와 강하게 연관된 상수라면 클래스나 인터페이스 자체에 추가
- 모든 숫자 기본 타입의 박싱 클래스의 MIN_VALUE, MAX_VALUE
- 열거 타입으로 나타내기 적합한 상수라면 열거 타입으로 만들어 공개
- 인스턴스화할 수 없는 유틸리티 클래스에 담아 공개
- 클래스 이름도 함께 명시해야 함 (Class.constant)
23. 클래스 계층구조 활용
태그 달린 클래스
두 가지 이상의 의미를 표현할 수 있고, 현재 표현하는 의미를 태그 값으로 알려주는 클래스를 말한다. 이는 장황하고 오류를 내기 쉽고 비효율적이다. 또, 태그 달린 클래스는 클래스 계층구조를 어설프게 흉내낸 아류이다.
태그 달린 클래스 → 클래스 계층구조
- 계층구조의 root가 될 추상 클래스 정의
- 태그 값에 따라 동작이 달라지는 메서드들을 루트 클래스의 추상 메서드로 선언
- 태그 값에 상관없이 동작이 일정한 메서드들을 루트 클래스에 일반 메서드로 추가
- 모든 하위 클래스에서 공통으로 사용하는 데이터 필드들도 루트 클래스로 올림
- 루트 클래스를 확장한 구체 클래스를 의미별로 하나씩 정의
클래스 계층구조 장점
- 간결하고 명확함
- 쓸데없는 코드 사라짐
- 관련 없는 데이터 필드 사라짐
- 컴파일러가 생성자가 모든 필드를 초기화하고 추상 메서드 모두 구현했는지 확인해줌
- 다른 프로그래머들이 독립적으로 계층구조 확장하고 함께 사용 가능
- 변수의 의미 명시하거나 제한 가능
- 특정 의미만 매개변수로 받을 수 있음
- 유연성 높음
- 컴파일타임 타입 검사 능력 높여줌
24. 멤버 클래스는 static으로
중첩 클래스란 다른 클래스 안에 정의된 클래스를 말한다. 정적 멤버 클래스를 제외한 나머지는 내부 클래스에 해당한다.
1. 정적 멤버 클래스
- 다른 클래스 안에 선언되고, 바깥 클래스의 private 멤버에도 접근할 수 있다는 점 빼고는 일반 클래스와 똑같다.
- 다른 정적 멤버와 같은 접근 규칙을 적용받는다.
- 흔히 바깥 클래스와 함께 쓰일 때에만 유용한 public 도우미 클래스로 사용된다.
private 정적 멤버 클래스 : 바깥 클래스가 표현하는 객체의 한 부분(구성요소)을 나타낼 때 사용한다.
2. (비정적) 멤버 클래스
- 정적 멤버 클래스와의 구문상 차이는 static이 없다는 것 뿐이다.
- 비정적 멤버 클래스는 어댑터를 정의할 때 자주 쓰인다.
- 비정적 멤버 클래스의 인스턴스는 바깥 클래스의 인스턴스와 암묵적으로 연결된다.
- this를 사용해 바깥 인스턴스의 메서드를 호출하거나 바깥 인스턴스의 참조를 가져올 수 있다.
- 비정적 멤버 클래스는 바깥 인스턴스 없이 생성할 수 없다.
- 독립적으로 존재 가능하다면 정적 멤버 클래스로 만들어야 한다.
- 비정적 멤버 클래스의 인스턴스와 바깥 인스턴스 사이의 관계는 멤버 클래스가 인스턴스화될 때 확립되고 이 관계 정보는 메모리 공간을 차지하고 생성 시간도 더 걸린다.
멤버 클래스에서 바깥 인스턴스에 접근할 일이 없다면 무조건 static을 붙여 정적 멤버 클래스로 만들어야 한다.
- static 생략 시 바깥 인스턴스로의 숨은 외부 참조를 가진다.
- 참조를 저장하기 위해 시간과 공간이 소비된다.
- 가비지 컬렉션이 바깥 클래스의 인스턴스를 수거하지 못해 메모리 누수가 생길 수 있다.
3. 익명 클래스
- 이름이 없고 바깥 클래스의 멤버가 아니다.
- 멤버와 다르게 쓰이는 시점에 선언과 동시에 인스턴스가 만들어진다.
- 코드의 어디서든 만들 수 있다.
- 비정적인 문맥에서 사용될 때만 바깥 클래스의 인스턴스를 참조할 수 있다.
- 상수 표현을 위해 초기화된 final 기본 타입과 문자열 필드만 가질 수 있다.
- 람다를 지원하기 전에는 작은 함수 객체나 처리 객체를 만드는 데 주로 사용했다. (지금은 람다)
- 정적 팩터리 메서드 구현 시 사용한다.
제약
- 선언한 지점에서만 인스턴스를 만들 수 있음
- instanceof 검사나 클래스 이름이 필요한 작업 수행 불가능
- 여러 인터페이스 구현 불가능, 인터페이스 구현하면서 다른 클래스 상속 불가능
- 익명클래스 사용하는 클라이언트는 그 익명 클래스가 상위 타입에서 상속한 멤버 외에 호출 불가능
- 표현식 중간에 등장하기 때문에 짧지 않으면 가독성 떨어짐
4. 지역 클래스
- 가장 드물게 사용된다.
- 지역변수를 선언할 수 있는 곳이면 어디서든 선언할 수있고 유효 범위도 지역변수와 같다.
- 멤버 클래스처럼 이름이 있고 반복해서 사용 가능, 익명 클래스처럼 비정적 문맥에서 사용될 때만 바깥 인스턴스 참조 가능, 정적 멤버 가질 수 없고 가독성을 위해 짧게 작성해야 한다.
25. 톱레벨 클래스는 한 파일에 하나만
소스 파일 하나에 톱레벨 클래스(톱레벨 인터페이스)가 여러개 선언돼도 자바 컴파일러에서는 오류를 내지 않는다. 한 클래스를 여러 가지로 정의할 수 있고, 어느 소스 파일을 먼저 컴파일하는지에 따라 어느 소스 파일을 사용할지가 달라진다. 컴파일러에게 어느 소스 파일을 먼저 주냐에 따라 동작이 달라지기 때문에 이는 큰 문제다.
톱 레벨 클래스들은 서로 다른 소스 파일로 분리하고, 여러 톱레벨 클래스를 하나의 파일에 담고 싶다면 정적 멤버 클래스를 사용하는 것을 고려해야 한다.