[Effective Java 3/E] 04. 클래스와 인터페이스 By starseat 2022-02-03 13:06:52 java/spring Post Tags # 클래스와 멤버의 접근 권한을 최소화하라 잘 설계된 컴포턴트는 내부 구현 정보를 얼마나 잘 숨겼느냐로 판단되고, 모든 내부 구현을 완벽히 숨겨 구현과 API를 깔끔하게 분리하고 오직 API를 통해서만 다른 컴포넌트와 소통하며 서로의 내부 동작 방식에는 전혀 개의치 않는다. (정보은닉, 캡슐화) ## 정보 은닉의 장점 - 시스템 개발 속도를 높인다. (여러 컴포넌트를 병렬로 개발 가능) - 시스템 관리 비용을 낮춘다. (각 컴포넌트를 보다 빨리 파악하여 디버깅 가능, 다른 컴포넌트로 교체하는 부담도 적기 때문) - 정보 은닉 자체가 성능을 높여주지는 않지만, 성능 최적화에 도움을 준다. (다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화 가능) - 소프트웨어 재사용성을 높인다. - 큰 시스템을 제작하는 난이도를 낮춘다. (개별 컴포넌트의 동작 검증 가능) ## 접근 제어 접근 제어 메커니즘은 클래스, 인터페이스, 멤버의 접근성(접근 허용 범위)을 명시한다. 접근 제한자(private, protected, public)를 제대로 활용하는 것이 정보 은닉의 핵심이다. 기본 원칙은 **모든 클래스와 멤버의 접근성을 가능한 한 좁혀햐 하는 것**이다. (책에서는 최상위 클래스를 public 으로 할 경우 API로 생각하는 듯 하다. ) 한 클래스에서만 사용하는 클래스일 경우 클래스 안에 `private static 으로 중첩` 시켜 바깥 클래스 하나마에서만 접근 가능하게 한다. public 일 필요가 없는 클래스의 접근 수준을 package-private 로 좁히자. ### 멤버(필드, 메서드, 중첩 클래스, 중접 인터페이스)에 부여할 수 있는 접근 수준 - **`private`**: 멤버를 선언한 톱레벨 클래스에서만 접근 가능 - **`package-private`**: 멤버가 소속된 패키지 안의 모든 클래스에서 접근 가능. **접근제 한자를 명시하지 않았을 떄 적용되는 패키지 접근 수준**. (단, 인터페이스의 멤버는 기본적으로 public이 적용된다.) - **`protected`**: package-private의 접근 범위를 포함하며, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근 가능. - **`public`**: 모든 곳에서 접근 가능 ### 접근 제한자 설계 1. 공개 API 를 설계한 후 그 외의 모든 멤버는 `private`으로 만들자. 2. 오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여 (private 제한자를 제거) `package-private`으로 풀어주자. 3. `package-private`에서 `protected`로 바꾸는 순간 그 멤버에 접근할 수 있는 대상 범위가 엄청나게 넓어지므로 잘 생각해보자. (public 클래스의 protected 멤버는 공개 API이므로 영원히 지원돼야 한다.) 4. **public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다.** (그 필드와 관련된 모든 것은 불변식을 보장할 수 없게 됨.) 5. 예외로 해당 클래스가 표현하는 추상 개념을 완성하는 데 꼭 필요한 구성요소로써의 상수라면 `public static final` 필드로 공개해도 좋다. (관례상 대문자 알파벳으로 쓰며, 각 단어 사이에 밑줄(_)을 넣는다. ex. MAX_COUNT) 6. 5번의 예외 상수 사용시 **기본 타입 값이나 불변 객체를 참조**해야 한다. 7. **클래스에서 `public static final 배열 필드`를 두거나 이 필드를 반환하는 접근자 메서드를 제공해서는 안된다.** ```java // public static final ㅇ의 보안 허점 코드 public static final Thing[] VALUES = { ... }; // 해결책 1. public 을 private 으로 만들고 public 불변 리스트 추가 private static final Thing[] PRIVATE_VALUES = { ... }; public static final List VALUES = Collections.unmodifiableList(Arrays,asList(PRIVATE_VALUES)); // 해결책 2. 배열을 private으로 만들고 그 복사본을 반환하는 public 메서드 추가 (방어적 복사) private static final Thing[] PRIVATE_VALUES = { ... }; public static final Thing[] values() { return PRIVATE_VALUES.clone(); } ``` ## 모듈 시스템 자바 9 에서 `모듈 시스템` 이라는 개념이 도입되면서 두가지 암묵적 접근 수준이 추가되었다. 패키지가 클래스들의 묶음이듯, 모듈은 패키지들의 묶음이다. 책에 설명이 나와있으나 저자는 꼭 필요한 경우가 아니라면 사용하지 않는게 좋을 것 같다고 한다. # public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 vo, bean, entity, dto 등 멤버필드에 대한 정의만 있는 클래스를 만들떄가 있다. ```java class Point { private int x; private int y; public int getX() { return x; } public int getY() { return y; } public void setX(int x) { this.x = x; } public void setY(int y) { this.y = y; } } ``` package-private 클래스 혹은 private 중첩 클래스라면 데이터 필드를 노출해도 하등 문제가 없다. ```java class Point { public int x; public int y; } ``` 위 코드는 해당 클래스가 표현하려는 추상 개념만 올바르게 표현해주면 클래스 선언 면에서나 이를 사용하려는 클라이언트 코드 면에서나 접근자 방식보다 훨씬 깔끔하다. 자바 플랫폼 라이브러리에도 public 클래스의 필드를 직접 노출하지 말라는 규칙을 어기는 사례가 종종 있다. (`java.awt.package` 패키지의 `Point`와 `Dimension` 클래스) 하지만 이 클래스들을 흉ㄴ애 내지 말고 타산지석으로 삼아야 한다. 정리하자면, public 클래스는 절대 가변 필드를 직접 노출해서는 안된다. 하지만` package-private` 클래스나 `private 중첩 클래스`에서는 종종 (불변이든 가변이든) 필드를 노출하는 편이 나을 떄도 있다. # 변경 가능성을 최소화하라 `불변 클래스`: 인스턴스의 내부 값을 수정할 수 없는 클래스 자바 플랫폼 라이브러리에도 다양한 불변 클래스가 있다. String, 기본 타입의 박싱 클래스들 (BigInteger, BigDecimal 등) 불변 클래스는 가변 클래스보다 설계하고 구현하고 사용하기 쉬우며, 오류 발생 여지도 적고 안전하다. ## 불변 클래스 생성 규칙 1. 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다. 2. 클래스를 확장할 수 없도록 한다. 3. 모든 필드를 final 로 선언한다. 4. 모든 필드를 private 으로 선언한다. 5. 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다. ## 불변 클래스 예제 (불변을 설명하기 위한 예일뿐 실무에서 쓸 정도는 아님. 반올림 처리, 복소수 Nan과 무한대도 다루지 못함.) ```java // 복소수 public final class Complex { private final double re; // 실수부(real part) private final double im; // 허수부(imaginary part) // 제공 가능한 상수 public static final Complex ZERO = new Complex(0, 0); public static final Complex ONE = new Complex(1, 0); public static final Complex I = new Complex(0, 1); public Complex(double re, double im) { this.re = re; this.im = im; } // 접근자 메서드 public double realPart() { return re; } public double imaginaryPart() { return im; } // 사칙연산 메서드 - 인스턴스 자신은 수정하지 않고 새로운 Complex 인스턴스를 만들어 반환 public Complex plus(Complex c) { return new Complex(re + c.re, im + c.im); } public Complex minus(Complex c) { return new Complex(re - c.re, im - c.im); } public Complex times(Complex c) { return new Complex(re * c.re - im * c.im, re * c.im + im * c.re); } public Complex dividedBy(Complex c) { double tmp = c.re * c.re + c.im * c.im; return new Complex( (re * c.re + im * c.im) / tmp, (im * c.re - re * c.im) / tmp); } @Override public boolean equals(Object o) { if(o == this) return true; if( !(o instanceof Complex) ) return false; Complex c = (Complex) o; return Double.compare(c.re, re) == 0 && Double.compare(c.im, im) == 0; } @Override public int hashCode() { return 31 * Double.hashCode(re) + Double.hashCode(im); } @Override public String toString() { return "(" + re + " + " + im + "i)"; } } ``` - `함수형 프로그래밍`: 피연산자에 함수를 적용해 그 결과를 반환하지만 피연산자 자체는 그대로인 프로그래밍 패턴 (위 예제의 사칙연산 메서드) - `절차적(명령형) 프로그래밍`: 메서드에서 피연산자인 자신을 수정해 자신의 상태가 변함. - **사칙연산 메서드를 (add 같은) 동사 대신 (plus 같은) 전치사를 사용한 이유는 해당 메서드가 객체의 값을 변경하지 않는다는 사실을 강조하려는 의도** - 이 방식으로 프로그래밍하면 코드에서 불변이 되는 영역의 비율이 높아지는 장점이 있음. ## 불변 클래스(객체)를 만드는 방법과 장단점 - 불변 객체는 단순하고 생성된 시점의 상태를 파괴될 떄까지 그대로 간직한다. - 근본적으로 스레드 안전(thread safety)하여 따로 동기화할 필요 없다. - 불변 객체에 대해서는 그 어떤 스레드도 다른 스레드에 영향을 줄 수 없어 안심하고 공유할 수 있다. - 불변 클래스라면 한번 만든 인스턴스를 최대한 재활용하는걸 권하며 가장 쉬운 재활용 방법은 자주 쓰이는 값들을 상수(`public static final`)로 제공하는 것이다. - 불변 클래스는 자주 사용되는 인스턴스를 캐싱하여 같은 인스턴스를 중복 생성하지 않게 해주는 정적 팩터리를 제공할 수 있다.(박싱된 기본 타입 클래스 전부와 BigInteger 클래스) - 정적 팩터리를 사용하면 여러 클라이언트가 인스턴스를 공유하여 메모리 사용량과 가비지 컬렉션 비용이 줄어든다. - 방어적 복사도 필요 없어 `clone` 메서드나 `복사생성자`를 제공하지 않는게 좋다. - `String` 클래스의 복사 생성자는 자바 초창기 때 (잘 이해하지 못하고) 만들어진 것으로, 되도록 사용하지 말자. - 자유롭게 공유할 수 있음은 물론, 불변 객체끼리는 내부 데이터를 공유할 수 있다. - 객체를 만들 때 다른 불변 객체들을 구성요소로 사용하면 이점이 많다. - 불변 객체는 그 자체로 실패 원자성(failure atomicity)을 제공한다. (상태가 절대 변하지 않으니 잠깐이라도 불일치 상태에 빠질 가능성이 없다.) - 실패 원자성: '메서드에서 예외가 발생한 후에도 그 객체는 여전히 (메서드 호출 전과 똑같은) 유효한 상태여야 한다.'는 성질 - **값이 다르면 반드시 독립된 객체로 만들어야 한다는 단점이 있다.** - 이를 대처하는 방법으로는 다단계 연산(multistep operation)들을 예측하여 기본 기능으로 제공하는 것이다. - 대표적인 예: String 의 가변 동반 클래스인 `StringBuilder`(구닥다리 전임자인 `StringBuffer` 도 있음) ## 불변 클래스를 만드는 또 다른 설계 방법 - 모든 생성자를 private 혹은 package-private으로 만들고 public 정적 팩터리를 제공하는 방법 ```java public final class Complex { private final double re; // 실수부(real part) private final double im; // 허수부(imaginary part) // 생성자 대신 정적 팩터리를 사용할 경우 private Complex(double re, double im) { this.re = re; this.im = im; } // 패키지 바깥의 클라이언트에서 볼 경우 사실상 final 메서드 public static Complex valueOf(double re, double im) { return new Complex(re, im); } ... ``` ## 정리 - `Getter`가 있다고 해서 무조건 `Setter`를 만들지는 말자 - 클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다. (장점이 많고, 단점이라곤 특정 상황에서의 잠재적 성능 저하 뿐) - 단순한 값 객체는 항상 불변으로 만들자 - 불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분을 최소한으로 줄이자. - 객체가 가질 수 있는 상태의 수를 줄이면 그 객체를 예측하기 쉬워지고 오류가 생길 가능성이 줄어든다. - 꼭 변경해야 할 필드를 뺸 나머지 모두를 final로 선언하자. - 합당한 이유가 없다면 모든 필드는 private final 이어야 한다. - 생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 햔다. - # 상속보다는 컴포지션을 사용하라 메서드 호출과 달리 상속은 캡슐화를 꺠뜨린다. 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 이에 대한 해법이 `컴포지션(composition; 구성) 설계` 이다. - `컴포지션(composition; 구성) 설계`: 기존 클래스가 새로운 클래스의 구성요소로 쓰는 설계 (새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스 참조) - `전달 메서드(forwarding method)`: 새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과 반환 이는 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않는다. - 잘못된 예 - 상속을 잘못 사용 ```java public class InstrumentedHashSet extends HashSet { // 추가된 원소의 수 private int addCount = 0; public InstrumentedHashSet() { } public InstrumentedHashSet(int initCap, float loadFactor) { super(initCap, loadFactor); } @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(Collection extneds E> c) { addCount += c.size(); return super.addAll(c); // <<< 상위 클래스에서 add() 사용. addCount 값이 중복으로 더해지는 문제 존재. } public int getAddCount() { return addCount; } } ``` - 래퍼 클래스 - 상속 대신 컴포지션 사용 ```java public class InstrumentedSet extneds ForwardingSet { private int addCount = 0; public InstrumentedSet(Set s) { super(s); } @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(Collection extneds E> c) { addCount += c.size(); return super.addAll(c); } public int getAddCount() { return addCount; } } ``` - 재사용할 수 있는 전달 클래스 ```java public class ForwardingSet implements Set { private final Set s; public ForwardingSet(Set s) { this.s = s; } publc void clear() { s.clear(); } public booelan contains(Object o) { return s.contains(o); } public boolean isEmpty() { return s.isEmpty(); } public int size() { return s.size(); } public Iterator iterator() { return s.iterator(); } public boolean add(E e) { return s.add(e); } public boolean remove(Object o) { return s.remove(o); } public boolean containsAll(Collection> c) { return s.containsAll(c); } public boolean addAll(Collection extends E> c) { return s.addAll(c); } public boolean removeAll(Collection> c) { return s.removeAll(c); } public boolean retainAll(Collection> c) { return s.retainAll(c); } public Object[] toArray() { return s.toArray(); } public T[] toArray(T[] t) { return s.toArray(t); } @Override public boolean equals(Object o) { return s.equals(o); } @Override public int hashCode() { return s.hashCode(); } @Override public String toString() { return s.toString(); } } ``` # 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 메서드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 한다. 즉, 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다. 덧붙여서 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 한다. API 문서의 메서드 설명 끝에서 종종 `Implementation Requirements`로 시작하는 절은 그 메서드의 내부 동작 방식을 설명하는 곳이다. 이 절은 메서드 주석에 `@implSpec` 태그를 붙여주면 자바독 도구가 생성해 준다. - `@implSepc` 태그는 자바 8에 처음 도입되어 자바 9부터 본격적으로 사용되기 시작함. - 이 태그를 활성화하려면 명령줄 매개변수로 `-tag "implSpec:a:Implementation Requirements:"`를 지정해주면 됨. 효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 **클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다.** 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다. 상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안된다. `clone`과 `readObject` 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안된다. - `clone`의 경우 하위 클래스의 clone 메서드가 복제본의 상태를 (올바른 상태로) 수정하기 전에 재정의한 메서드를 호출한다. - `readObject`의 경우 하위 클래스의 상태가 미처 다 역직렬화디기 전에 재정의한 메서드부터 호출하게 된다. # 추상 클래스보다는 인터페이스를 우선하라 자바가 제공하는 다중 구현 메커니즘은 `인터페이스`와 `추상 클래스` 이지만 자바 8부터 인터페이스도 `디폴트 메서드(default method)`를 제공할 수 있게 되어 인스턴스 메서드를 구현 형태로 제공할 수 있다. `인터페이스`와 `추상 클래스`의 가장 큰 차이는 추상 클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 하위 클래스가 되어야 한다는 점이다. 인터페이스는 `믹스인(mixin)`정의에 안성맞춤이다. - 믹스인이란 클래스가 구현할 수 있는 타입으로 믹스인을 구현한 클래스에 원래의 '주된 타입'외에도 특정 선택적 행위를 제공한다고 선언하는 효과를 준다. - `Comparable`은 자신을 구현한 클래스의 인스턴스끼리는 순서를 정할 수 있다고 선언하는 믹스인 인터페이스다. 인터페이스로는 계층구조가 없는 타입 프레임워크를 만들 수 있다. ```java // 인터페이스를 상속받아 새로운 인터페이스 생성 예 public interface Singer { AudioClip sing(Song s); } public interface Songwriter { Song compose(int chartPosition); } public interface SingerSongwriter extends Singer, Songwriter { AudioClip strum(); void actSensitive(); } ``` ## `템플릿 메서드 패턴` - 인터페이스와 추상 골격 구현(skeletal implementation) 클래스를 함께 제공하는 식으로 인터페이스와 추상 클래스의 장점을 모두 취하는 방법 - 인터페이스로는 타입을 정의하고 필요하면 디폴트 메서드 몇 개도 함꼐 제공 - 그리고 골격 구현 클래스는 나머지 메서드들까지 구현 ## 골격 구현 예제 ### 골격 구현을 사용해 완성한 구체 클래스 다음 예제는 List 구현체를 반환하는 정적 팩터리 메서드 이며 AbstractList 골격 구현 활용하였다. int 배열을 받아 Integer 인스턴스의 리스트 형태로 보여주는 어댑터(Adapter) 이기도 하다. int 와 Integer 사이의 변환(박싱과 언박싱) 때문에 성능은 그리 좋지 않지만 익명 클래스 형태 사용을 주목! ```java static List intArrayAsList(int[] a) { Objects.requireNonNull(a); // 다이아몬드 연산자를 이렇게 사용하는건(AbstractList<>) java 9 부터 가능 // java 8 이하 버전은 로 사용 return new AbstractList<>() { @Override public Integer get(int i) { return a[i]; // 오토 박싱 } @Override public Integer set(int i, Integer val) { int oldVal = a[i]; a[i] = val; // 오토 언박싱 return oldVal; // 오토 박싱 } @Override public int size() { return a.length; } }; } ``` ### Map 기반 골격 구현 클래스 Map.Entry 인터페이스를 보면 getKey, getValue 는 확실히 기반 메서드이며 선택적으로 setValue 도 포함할 수 있다. **Object 메서드들은 디폴트 메서드로 제공해서는 안되므로**, 해당 메서드 들은 모두 골격 구현 클래스에서 구현한다. (toString도 기반 메서드를 사용해 구현함.) ```java // 참고로 // Map.Entry 인터페이스나 그 하위 인터페이스로는 이 골격 구현을 제공할 수 없다. // 디폴트 메서드 equals, hashCode, toString 같은 Object 메서드를 재정의 할 수 없기 떄문이다. public abstract class AbstractMapEntry implements Map.Entry { // 변경 가능한 엔트리는 이 메서드를 반드시 재정의해야 한다. @Override public V setValue(V value) { throw new UnsupportedOperationException(); } // Map.Entry.equals 의 일반 규약을 구현한다. @Override public boolean equals(Object o) { if (o == this) return true; if ( !(o instanceof Map.Entry) ) return false; Map.Entry, ?> e = (Map.Entry) o; return Objects.equals(e.getKey(), getKey()) && Objects.equals(e.getValue(), getValue()); } // Map.Entry.hashCode 의 일반 규약을 구현한다. @Override public int hashCode() { return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue()); } @Override public String toString() { return getKey() + "=" + getValue(); } } ``` `단순 구현(simple implementation)`은 골격 구현의 작은 변종으로 abstract Map.SimpleEntry 가 좋은 예 이다. ## 정리 - 일반적으로 다중 구현용 타입으로는 인터페이스가 가장 적합하다. - 복잡한 인터페이스라면 구현하는 수고를 덜어주는 골격 구현을 함께 제공하는 방법을 고려해보자. - 골격 구현은 '가능한 한' 인터페이스의 디폴트 메서드로 제공하여 그 인터페이스를 구현한 모든 곳에서 활용하도록 하는 것이 좋다. - '가능한 한'이라고 한 이유는 인터페이스에 걸려 있는 구현상의 제약 때문에 골격 구현을 추상 클래스로 제공하는 경우가 더 흔하기 떄문이다. # 인터페이스는 구현하는 쪽을 생각해 설계하라 # 인터페이스는 타입을 정의하는 용도로만 사용하라 # 태그 달린 클래스보다는 클래스 계층구조를 활용하라 # 멤버 클래스는 되도록 static으로 만들라 # 톱레벨 클래스는 한 파일에 하나만 담으라 Previous Post [Effective Java 3/E] 02. 객체 생성과 파괴 Next Post [spring] custom validation annotation 만들기