[이펙티브 자바3판] 4장 클래스와 인터페이스

2021. 3. 21. 23:55 JAVA/Effective Java Book

해당 내용은 이펙티브 자바 3판 (조슈아 블로크 지음, 이복연 옮김)를 읽고 나같은 초심자의 눈으로 이해한 내용을 정리해보았다.

 

책에 있는 내용을 기반으로 썼지만, 책에 없는 내용도 조금 적었다. (guava Immutable, 템플릿메소드, 중첩클래스 등)

 

또한 이번 정리부터는 조금 더 많이 요약해서 기술할 것이며, 코드예제도 많이 뺐다.

 

참고로 책의 코드는 https://github.com/WegraLee/effective-java-3e-source-code 에서 볼 수 있다.

 

4장의 아이템 목록

  1. 클래스와 멤버의 접근 권한을 최소화하라
  2. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라
  3. 변경 가능성을 최소화하라
  4. 상속보다는 컴포지션을 사용하라
  5. 상속을 고려해 설계하고 문서화하라. 그렇지 않았다면 상속을 금지하라
  6. 추상 클래스보다는 인터페이스를 우선하라
  7. 인터페이스는 구현하는 쪽을 생각해 설계하라
  8. 인터페이스는 타입을 정의하는 용도로만 사용하라
  9. 태그 달린 클래스보다는 클래스 계층구조를 활용하라
  10. 멤버 클래스는 되도록 static 으로 만들라
  11. 톱레벨 클래스는 한 파일에 하나만 담으라

아이템15. 클래스와 멤버의 접근 권한을 최소화하라

컴포넌트가 잘 설계되고 안 되고의 차이는 클래스 내부 구현 정보를 외부 컴포넌트로부터 얼마나 잘 숨겼지에 따라 결정된다. 즉, 구현과 API를 깔끔히 분리하는 것이다. 이것은 정보은닉 혹은 캡슐화라고 흔히 불린다.

15-1. 정보은닉의 장점

  • 개발 속도가 높다. 여러 컴포넌트를 병렬로 개발할 수 있기 때문이다.
  • 관리비용이 낮다. 빨리 파악할 수 있고, 다른 컴포넌트로 교체의 비용도 적다. 같은 이유로 성능최적화에 도움이 된다.

15-2. 정보은닉을 제대로 구현하기

자바는 언어레벨에서 정보은닉을 위한 다양한 장치를 제공해주는데 클래스, 인터페이스, 접근제어자 등이 있다. 특히 접근제어자를 통해 정보은닉을 잘 구현해낼 수 있다. 핵심은 모든 클래스와 멤버의 접근성을 가능한 좁혀야 한다. 올바르게 동작하는 한 가장 낮은 접근 수준을 부여하는게 좋다. public 으로 만들어두면 영원히 하위호환성을 고려해주며 변경해야한다.

  1. 패키지 외부에서 쓸 일이 없다면 package-private(접근제어자의 default값으로 아무것도 안 붙인 상태)로 하라. 그러면 내부 구현이므로 좀 더 쉽게 교체할 수 있다.
  2. 한 클래스에서만 사용되는 package-private 클래스는 사용하는 클래스의 private static 으로 중첩시켜 써라. 이렇게 중첩하면 바깥 클래스에서만 접근할 수 있다.
  3. 테스트 코드는 같은 패키지 경로에 두면 package-private 를 테스트할 수 있으므로, 테스트를 위해 접근제어자를 푸는 것은 올바르지 않다.
  4. public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다. public 필드는 불변을 보장할 수 없기 때문이다. 또한 스레드에 안전하지 않다.
  5. public static final는 기본 타입이나 불변 객체를 참조해야한다. public static final는 다른 객체를 참조하도록 바꿀 순 없지만, 참조된 객체 자체가 수정될 수가 있기 때문이다.
    • 이런 경우 Collections.unmodifiableList(), Map 등으로 불변화해서 참조하자

멤버 접근성을 좁히지 못하게 하는 제약이 1가지 있다. 상위 클래스의 메소드를 재정의할 때는 그 접근 수준을 상위 클래스에서보다 좁게 설정할 수 없다는 것이다.

아이템16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라

public 클래스의 필드가 public이라면 이것을 사용하는 클라이언트가 생길 수가 있으므로 변경비용이 비싸진다. 또한 위에서 설명했듯이 불변식을 보장할 수도 없고, 외부에서 필드에 접근할 때 부수적인 작업(복사본 던지기, 파라미터 검증 등)을 할 수도 없다. 이런 단점들은 private 필드에 public 접근자(getter/setter)를 두면 해결된다.

public 클래스가 아닌 package-private 클래스 또는 private 중첩 클래스라면 public 필드는 전혀 문제가 되지 않는다. 오히려 선언, 사용에서 더 깔끔하기도 하다. 어차피 내부에서만 동작하기 때문이다.

여기서 private 중첩 클래스는 이해되는데, package-private 클래스는 좀 의문이 들 수 있다. 책의 저자는 아마도 한 패키지는 한 개발자(=같은 개발자)가 개발하기 때문에 오용하지 않을 것이라고 판단한 것 같다.

아이템17. 변경 가능성을 최소화하라

불변 클래스는 인스턴스의 내부 값을 수정할 수 없는 클래스이다. 객체가 파괴되는 순간까지 값이 절대 달라지지 않는다. 이런 특성으로 스레드 안전해지므로 따로 동기화할 필요도 없어진다. 불변 클래스에서는 한번 만든 인스턴스를 캐싱해서 재활용할 수도 있다. 다음 예제에서 Complex는 불변이다. public static final ZERO = new Complex(0, 0);

또한 불변 객체는 방어적 복사도 필요없다. clone 메소드나 복사생성자, 복사팩토리도 필요없다. 대표적인 실수로 String의 복사 생성자를 쓸데없이 넣은 것이다. String Docs를 보면 public String(String original) 에 이런 문구가 있다. Unless an explicit copy of original is needed, use of this constructor is unnecessary since Strings are immutable.

getter가 있다고 꼭 setter를 만들지 말자. 꼭 필요한 경우가 아니라면 불변으로 만들자. 완전히 불변으로 만들 수 없을 때는 변경할 수 있는 부분을 최소한으로 줄이자

17-1. 불변 클래스를 만드는 규칙

  1. 객체의 상태를 변경하는 메소드를 제공하지 않는다.
  2. 클래스를 확장할 수 없도록 한다. 하위 클래스에서 부주의하게 객체의 상태를 변하게 하는 것을 막을 수 있다.
    • final Class 로 선언할 수도 있지만, final을 선언안하고도 모든 멤버와 생성자를 private, package-private으로 선언하고 public static factory를 제공해서 사실상 확장할게 없게 할 수도 있다.
  3. 모든 필드를 final로 선언한다. 시스템에서 권장하는 가장 명확한 방법이다.
  4. 모든 필드를 private로 선언한다. 클라이언트가 객체에 직접 접근하여 값을 수정하는 일을 막아준다.
  5. 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다. (?)

17-2. 불변객체의 단점

  1. 값이 다르면 반드시 독립된 객체로 만들어줘야 한다. 내부 필드값 중 99개가 같고 1개만 달라도, 새로 만들어줘야하는 것이다.
  2. 객체를 완성하기까지 단계가 많고, 중간 단계의 객체들이 모두 버려지면 성능문제가 생길 수 있다.
    • 해결방법으로는 다단계 연산을 기본으로 제공하는 것이다. 가변 동반 클래스라고 한다. (불변클래스인 String으로 예를들자면, StringBuilder, StringBuffer이 있다)

17-3. [실무권장] guava의 Immutable Collections을 사용하자

  • Immutable Collections Docs
  • 이를 통해 불변 컬렉션을 손쉽게 만들 수 있다. 이 때 java의 컬렉션API의 Collections.unmodifiableList 와는 다르게 null을 허용하지 않는다.
  • 네이밍은 보통 ImmutableXXX 이다. 주요 API로 Map과 Set 계열은 of, copyOf, builder가 가능하며 List 계열은 asList가 있다. 자세한 건 문서를 참조하자
  • java 9부터는 Immutable static factory 메소드가 추가되었다. java9 이상을 사용한다면 Immutable Collections를 쉽게 사용하기 위해 guava를 사용할 필요가 없다.

아이템18. 상속보다는 컴포지션을 사용하라

상속은 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다. 메소드 호출과 달리 상속은 캡슐화를 깨트린다. 슈퍼클래스의 구현에 의존하게되며, 슈퍼클래스의 구현 내용이 바뀌었다면 서브클래스도 이에 맞춰 진화해야한다. (상속은 상위클래스의 결함까지 그대로 승계된다) 슈퍼클래스의 메소드가 아닌 새로운 메소드를 서브클래스에서 정의했는데, 운없게도 다음 배포판에서 슈퍼클래스에 새로 생긴 메소드명과 겹쳐서 컴파일이 실패할 수도 있다.

이럴 떄 해결방법으로 컴포지션(composition, 구성)은 private 필드를 만들고 상속대신 참조하는 것이다. 새 클래스의 메소드(전달 메소드)들은 참조 클래스의 메소드들을 호출하여 전달한다.

상속은 반드시 하위클래스가 상위클래스의 진짜 하위타입인 상황에만 쓰여야 한다. A를 상속하는 B를 작성하려한다면 'B가 정말 A인가?'라고 자문해보자. 맞다면 상속하고, 아니라면 컴포지션하자.

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

아이템18에서는 상속을 염두에 두지않고 설계했고 상속의 주의점을 문서화해놓지 않은 외부클래스를 상속할 떄 위험을 경고했다. 상속을 고려한 문서화는 메소드를 재정의했을때 일어나는 일을 정확히 정리하는 것이다. 재정의가능한 공개메소드에서 같은 위치(self-use)의 메소드(재정의 가능한 공개)를 호출할 수도 있기 때문이다. 이것은 상속이 캡슐화를 해치기 때문에 일어나는 현상이다.

문서에서 Implementation Requirements 로 시작하는 절이 있는데, 이게 내부 동작 방식을 설명하는 곳이다.

클래스의 내부 동작 과정 중간에 호출될 수 있는 메소드(재정의 가능한 공개, hook 메소드)를 선별하여 protected 같은 접근제어자로 공개해야할 수도 있다. (이 부분을 재정의해서 다른 메소드의 성능개선 등을 이뤄낼 수 있으니)

상속용으로 설계한 클래스의 테스트는 하위클래스를 몇 개 직접 만들어보는 수 밖에 없다.

또한 상속용 클래스의 생성자는 어떤 경우에도 재정의 가능 메소드를 호출해서는 안된다. 이는 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로, 하위 클래스에서 재정의한 메소드가 하위 클래스의 생성자보다 먼저 호출되기 때문이다. (private, final, static은 재정의가 불가능하니 호출해도 된다)

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

자바가 제공하는 다중구현 메커니즘은 인터페이스와 추상클래스로 가능하다. 자바8부터는 인터페이스도 default 메소드를 쓸 수 있게 되면서, 둘다 인스턴스 메소드를 구현한 형태로 제공할 수 있게 되었다. 인터페이스와 추상클래스의 가장 큰 차이는 추상클래스를 구현한 서브클래스는 반드시 추상클래스의 하위 타입이 되어야만 한다는 것이다. (클래스는 단일상속)

새로운 인터페이스는 클래스에 쉽게 넣을 수 있지만, 새로운 추상클래스를 끼워넣으려면 계층구조가 복잡(이미 어떤 클래스를 상속받고있는데, 또 상속받아야하니)해진다. 이런 경우처럼 인터페이스는 믹스인(mixin) 정의에 안성맞춤이다. 또한 인터페이스 간에 상속을 통해 유연하게 만들 수도 있다.

인터페이스와 추상 골격구현 클래스를 제공하여 2개의 장점을 취하는 방법도 있다. 인터페이스의 이름이 XXX라면, 보통 추상 골격구현 클래스는 AbstractXXX가 된다. 주로 이런 구조는 템플릿메소드 패턴에 많이 쓰인다.

템플릿메소드 패턴 : http://sjh836.tistory.com/140

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

자바8 이전에는 인터페이스에 새로운 메소드를 추가할 경우 보통 컴파일 오류가 난다. 구현 클래스들에서 구현을 하지 않았기 때문이다. 자바8부터 인터페이스에 default 메소드, static 메소드가 등장하면서 새로운 메소드를 모든 구현클래스의 도움없이 추가하는 방법이 생기긴 하였으나, 모든 상황에서 불변식을 해치지 않는 default 메소드를 작성하는 것은 매우 어렵다. (책에서는 예제로 apache commons의 SynchronizedCollection을 들고 있다.)

추가된 default 메소드는 제거하거나 시그니처를 수정하는 등의 행동 역시 신중히 해야한다. 사용하는 클라이언트에서 전부 깨질 수 있기때문이다.

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

인터페이스는 자신을 구현한 클래스의 인스턴스를 참조할 수 있는 타입역할을 한다. 클래스가 어떤 인터페이스를 구현한다는 것은 자신의 인스턴스로 무엇을 할 수 있는지를 사용측에 이야기해주는 것이다. 인터페이스는 오직 이 용도로만 사용해야 한다.

22-1. 인터페이스를 잘못 사용한 예

  • 상수 인터페이스 : public static final 필드들만 있는 인터페이스
    • 사용하는 쪽에서 상수용 인터페이스는 아무런 의미가 없다. 혼란만 줄 뿐이다.
    • Integer.MAX_VALUE, enum, 정적 유틸리티 클래스(XXXConstants) 등에서 쓰는게 훨씬 더 좋다.

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

태그달린 클래스는 2가지 이상의 값을 표현하는 클래스이다. 값의 의미 분기처리를 태그로 한다.

class Figure {
    enum Shape { RECTANGLE, CIRCLE };
    final Shape shape; // 태그

    // 사각형일 때만 필요한 필드
    double length;
    double width;

    // 원일 때만 필요한 필드
    double radius;

    // 원 생성자
    Figure(double radius) {
        shape = Shape.CIRCLE;
        this.radius = radius;
    }

    // 사각형 생성자
    Figure(double length, double width) {
        shape = Shape.RECTANGLE;
        this.length = length;
        this.width = width;
    }

    double area() {
        switch(shape) {
            case RECTANGLE:
                return length * width;
            case CIRCLE:
                return Math.PI * (radius * radius);
            default:
                throw new AssertionError(shape);
        }
    }
}

불필요한 코드들이 너무 많고, 장황하며, 오류를 내기 쉽고 비효율적이다.

이것은 클래스 계층구조로 바꾸는 것이 좋다.

abstract class Figure {
    abstract double area();
}

class Circle extends Figure {
    final double radius;

    Circle(double radius) { this.radius = radius; }

    @Override
    double area() { return Math.PI * (radius * radius); }
}

class Rectangle extends Figure {
    final double length;
    final double width;

    Rectangle(double length, double width) {
        this.length = length;
        this.width  = width;
    }
    
    @Override
    double area() { return length * width; }
}

위에서 열거한 단점들이 해소되었으며, 컴파일 타임의 검사를 최대한 활용(추상메소드 구현 여부 등)하게 되었다. 또한 다른 도형을 추가할때도 확장성있는 형태가 되었다.

 

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

중첩 클래스(nested class)는 다른 클래스 안에 정의된 클래스를 말한다. 중첩 클래스는 자신을 감싼 바깥 클래스에만 쓰여야하며, 그 외의 쓰임새가 있다면 톱레벨 클래스로 만들어야 한다.

중첩클래스와 멤버클래스는 동일한 말로 이해하면 된다. 중첩클래스에는 정적 멤버클래스, (비정적) 멤버클래스, 지역클래스, 익명클래스가 있는데, 자세하게 알고 싶다면 다음 포스팅을 참고하자.

중첩 클래스 : http://sjh836.tistory.com/145

중첩클래스에서 바깥 인스턴스에 접근할 일이 없다면 무조건 static을 붙여서 정적 멤버 클래스로 만들어라. 비정적(non-static)일 경우 바깥 인스턴스에 대한 숨은 참조(바깥 클래스명.this)를 가져올 수 있게 되는데 비용이 들어간다. 또한 이 참조때문에 GC가 제때 수거하지 못해서 메모리 누수가 생길 수도 있다.

비정적 멤버클래스는 바깥 인스턴스에 접근할 일이 있을 때 사용하면 된다. 컬렉션 패키지들이 이를 잘 활용하였는데, 보통 자신의 컬렉션 뷰(keySet, entrySet, values 등)를 반환할 때 사용한다. 다음은 HashMap에서 values()의 코드 일부이다.

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    public Collection<V> values() {
        Collection<V> vs = values;
        if (vs == null) {
            vs = new Values();
            values = vs;
        }
        return vs;
    }

    final class Values extends AbstractCollection<V> {
        public final void clear() {
            HashMap.this.clear();
        }
        ...
    }
    ...
}

또한 톱레벨 클래스 내부에서만 쓰인다면 private 중첩 클래스로 작성하여 접근범위를 최소화시키는 게 좋다.

 

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

소스 파일 하나에 톱레벨 클래스를 여러개 선언하여도, 자바 컴퍼일러는 문제삼지 않는다. 하지만 문제가 되는 경우는 존재한다. 예를들면 한 파일에 클래스 2개가 정의되어있는데, 다른 파일에 똑같은 클래스명으로 클래스 2개가 정의되어 있는 경우이다. 이럴 때는 컴파일이 실패하거나, 컴파일 순서에 따라 어떻게 동작할 지 예측할 수 없게 된다.

따라서 한 파일에는 하나의 톱레벨 클래스, 인터페이스만 작성하자



출처: https://sjh836.tistory.com/170?category=679845 [빨간색코딩]