ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Java] Effective Java 3/E 정리 - 4장 클래스와 인터페이스
    Study/이펙티브 자바 2021. 6. 23. 19:22
    반응형

     

    이번 장에서는 클래스와 인터페이스를 쓰기 편하고, 견고하며, 유연하게 만드는 방법을 알아본다.

     


     

    Item15. 클래스와 멤버의 접근 권한을 최소화하라

    정보 은닉 (캡슐화)

    잘 설계된 컴포넌트는 내부 데이터와 내부 구현 정보를 외부 컴포넌트로부터 얼마나 잘 숨겼는냐가 중요하다.

    이는 정보은닉, 혹은 캡슐화라고 하는 개념으로 소프트웨어 설계의 근간이 되는 원리다.

    정보 은닉의 장점은 다음과 같이 정리할 수 있다.

    • 시스템 개발 속도를 높인다. 여러 컴포넌트를 병렬로 개발할 수 있기 때문이다.
    • 시스템 관리 비용을 낮춘다. 각 컴포넌트를 더 빨리 파악하여 디버깅할 수 있고, 다른 컴포넌트로 교체하는 부담도 적기 때문이다.
    • 정보 은닉 자체가 성능을 높여주지는 않지만, 성능 최적화에 도움을 준다. 완성된 시스템을 프로파일링해 최적화할 컴포넌트를 정한 다음, 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화할 수 있기 때문이다.
    • 소프트웨어 재사용성을 높인다. 외부에 거의 의존하지 않고 독자적으로 동작할 수 있는 컴포넌트라면 그 컴포넌트와 함께 개발되지 않은 낯선 환경에서도 유용하게 쓰일 가능성이 크기 때문이다.
    • 큰 시스템을 제작하는 난이도를 낮춰준다. 시스템 전체가 아직 완성되지 않은 상태에서도 개별 컴포넌트의 동작을 검증할 수 있기 때문이다.

    접근 제한자

    클래스, 인터페이스, 멤버의 접근성은 선언된 위치와 접근 제한자로 정해진다.

    기본 원칙은 모든 클래스와 멤버의 접근성을 가능한 좁혀야한다.

    사용할 수 있는 접근 제한자는 다음과 같다.

    • private: 멤버를 선언한 톱레벨 클래스에서만 접근할 수 있다.
    • package-private: 멤버가 소속된 패키지 안의 모든 클래스에서 접근할 수 있다. 접근 제한자를 명시하지 않았을 때 적용되는 패키지 접근 수준이다.(단, 인터페이스의 멤버는 기본적으로 public이 적용된다)
    • protected: package-private의 접근 범위를 포함하며, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근할 수 있다.
    • public: 모든 곳에서 접근할 수 있다.

    가장 바깥의 톱레벨 클래스와 인터페이스는 package-private, 혹은 public 접근 제한자가 붙는다.

    public이 붙으면 공개 API가 되며, package-private는 해당 해키지 안에서만 이용할 수 있다.

    클래스의 접근 권한을 결정하는 방법은 공개 API를 설계한 후, 나머지 모든 멤버는 private로 만든다.

    그리고 같은 패키지에서 접근해야하는 멤버에 한하여 package-private로 풀어준다.

    public static final

    public 클래스의 인스턴스 필드는 thread-safe하지 않기 때문에 되도록 public이 아니어야 한다. 그러나 상수로써 public static final 필드로 사용하는 것은 상관없다. 단 기본타입 값이나 불변 객체를 참조해야 하고, 가변 객체를 사용한다면 참조된 객체 자체가 수정가능 하기 때문에 의도치 않은 결과를 유발한다.

    만약 public static final의 배열 필드를 생성한다고 생각해보자.

    public static final Thing[] VALUES = {...};

    이 필드는 참조를 반환하여 배열을 수정할 수 있는 보안 허점이 있다.

    따라서 private필드로 변경하고, public 타입의 불변 리스트 혹은 복사본을 반환하는 메서드를 제공하는 방식을 사용해야 한다.

    private static final Thing[] PRIVATE_VALUES = { ... };
    
    //방법 1. 불변 리스트 제공
    public static final List<Thing> VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
    
    //방법 2. 복사본 제공
    public static final Thing[] values() {
        return PRIVATE_VALUES.clone();
    }

     

    모듈 시스템

    자바 9에서 도입된 모듈 시스템은 속한 패키지 중에 선택적으로 공개할 수 있다. 공개되지 않은 패키지의 protected, 혹은 public 멤버는 접근 권한과 상관없이 모듈 외부에서 접근이 불가능하다. 이를 활용한 가장 대표적은 예는 JDK로, JDK외에는 아직 모듈 개념이 받아 들여질지 예측하기 힘들다.

     

     

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

    public 클래스에서 public 필드를 두면 데이터 필드에 직접 접근할 수 있기 때문에 캡슐화의 이점이 없다.

    필드는 모두 private로 두고 public 접근자 (getter)를 추가한다.

    필드를 직접적으로 제공하지 않고 접근자를 제공한다면 클래스 내부 표현을 변경할 수 있는 유연성을 얻을 수 있다.

    그리고 만약 package-private클래스나 private 중첩 클래스라면 데이터 필드를 노출해도 문제가 없다.

    그 클래스가 표현하려는 추상 개념만 올바르게 표현해주자.

     

     

    Item17. 변경 가능성을 최소화하라

    불변 클래스

    불변클래스는 인스턴스 내부 값을 수정할 수 없는 클래스로,

    가변 클래스보다 설계하고, 구현하고, 사용하기 쉬우며, 오류가 생길 여지도 적고 안전하다.

    다음은 클래스를 불변으로 만드는 다섯가지 규칙이다.

    • 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다.
    • 클래스를 확장할 수 없도록 한다. 하위 클래스에서 부주의하게 혹은 나쁜 의도로 객체의 상태를 변하게 만드는 사태를 막아준다. 상속을 막는 대표적인 방법은 클래스를 final로 선언하는 것이지만, 다른 방법도 존재한다.
    • 모든 필드를 final로 선언한다. 시스템이 강제하는 수단을 이용해 설계자의 의도를 명확히 드러내는 방법이다. 새로 생성된 인스턴스를 동기화 없이 다른 스레드로 건네도 문제없이 동작하게끔 보장하는 데도 필요하다.
    • 모든 필드를 private으로 선언한다. 필드가 참조하는 가변 객체를 클라이언트에서 직접 접근해 수정하는 일을 막아준다. 기술적으로는 기본 타입 필드나 불변 객체를 참조하는 필드를 public final로만 선언해도 불변 객체가 되지만, 이렇게 하면 다음 릴리스에서 내부 표현을 바꾸지 못하므로 권하지는 않는다.
    • 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다. 클래스에 가변 객체를 참조하는 필드가 하나라도 있다면 클라이언트에서 그 객체의 참조를 얻을 수 없도록 해야 한다. 이런 필드는 절대 클라이언트가 제공한 객체 참조를 가리키게 해서는 안 되며, 접근자 메서드가 그 필드를 그대로 반환해서도 안 된다. 생성자, 접근자, readObject 메서드 모두에서 방어적 복사를 수행하라.

    불변 클래스의 장점으로는 다음과 같다.

    • 불변 객체는 변할 가능성이 없으니 thread-safe하며, 안심하며 공유할 수 있다.
    • 블변 객체끼리는 내부 데이터를 공유할 수 있다.
    • 객체를 만들 때 다른 불변 객체들을 구성요소로 사용하면 불변식을 유지하기 쉽다.
    • 불변 객체는 그 자체로 실패 원자성(failure atomicity)를 제공한다. 실패 원자성이란, 메서드에서 예외가 발생한 후에도 여전히 유효한 상태임을 증명하는 성질이다.

    그러나 단점도 존재한다.

    • 값이 다르면 반드시 독립된 객체로 만들어야하기 때문에 가짓수가 많을수록 성능이 떨어진다.

    불변 클래스는 불변을 보장하기 위해 상속하지 못하게 하는 방법으로 final 클래스로 만들 수 있다. 그러나 더 유연한 방법으로 모든 생성자를 private, 혹은 package-private로 만들고 public 정적 팩터리를 제공하는 방법이 있다.

     

     

    Item18. 상속보다는 컴포지션을 사용하라

    * 여기서 말하는 상속은 클래스의 인터페이스 구현, 인터페이스의 인터페이스 확장과는 다른 클래스 상속을 말한다.

    메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.  상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.

    책에서는 문제가 되는 두가지 상황을 예시로 든다.

    • 상위 클래스의 메서드를 이용시, 메서드의 내부 구현에서 예상치 못한 재정의 메서드를 호출하는 경우
    • 상위 클래스에 하위 클래스의 조건을 깨뜨리는 새로운 메서드가 추가되는 경우.
      클래스가 특정 조건을 만족해야만 하는데 새로운 메서드가 이를 만족하지 않는다면, 다시 하위 클래스에서 이를 수정해야하는 번거로움이 있다.

    컴포지션 (Composition)

    따라서 상속보다는 컴포지션을 활용해 문제점을 해결하고자 한다.

    컴포지션은 기존 클래스를 확장하는 대신, 새로운 클래스에서 private필드로 기존 클래스 인스턴스를 참조하는 설계방식을 말한다.

    그리고 private 필드의 클래스의 메서드를 호출하는 전달(forwarding) 메서드를 사용하여 기존 클래스의 내부 구현 방식에서 벗어날 수 있다.

    컴포지션을 사용해 구현한 클래스를 다른 인스턴스를 감싸고 있다는 뜻에서 래퍼 클래스라고 부르며,

    다른 기능을 덧씌운다는 듯으로 데코레이터 패턴이라고 한다.

    * 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 하는데, 정확히는 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 해당된다.

    콜백을 사용하는 경우에는 래퍼 클래스가 아닌 자신의 참조를 넘기고 내부 객체를 호출하는 SELF 문제가 발생한다.

    이렇게 콜백을 구현하는 것이 아니라면, 대부분 상속보다는 컴포지션을 구현하는 것이 더 유용하다.

     

     

    Item19. 상속을 고려해 설꼐하고 문서화하라. 그렇지 않았다면 상속을 금지하라

    상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.

    이는 Item18에서 정리한 상위 클래스의 메서드를 이용시,

    메서드의 내부 구현에서 예상치 못한 재정의 메서드를 호출하는 경우를 위한 방법이다.

    어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는 지도 담아야 한다.

     

    API 문서의 메서드 설명 끝에 “Implementation Requirements”로 시작하는 절은 그 메서드의 내부 동작 방식을 설명하는 곳이다.

    이 절은 메서드 주석에 @implSpec 태그를 붙이면 자바독 도구가 생성한다.

    '어떻게'가 아닌 '무엇'을 하는지 설명하는 API 문서의 역할과는 다르지만, 안전하게 상속하도록 하려면 꼭 필요한 작업이다.

     

    상속용 클래스의 어떤 메서드를 protected로 노출할지에 대해서는 잘 예측해서 시험해보는 것이 최선이다.

    직접 하위 클래스를 만들어 테스트해보자.

     

    그리고 반드시 상속용 클래스의 생성자는 직간접적으로 재정의 가능 메서드를 호출해서는 안된다.

    하위 클래스의 생성자가 호출되기 이전에 호출되기 때문에 오류를 발생시킨다.

    clone과 readObject도 하위 클래스가 역질렬화되기 전에 호출하거나, 원본 객체를 수정할 위험이 있기 때문에 생성자와 마찬가지로 재정의 메서드를 호출해서는 안된다.

     

    이처럼 상속용 클래스를 설계하기 위해서는 많이 노력이 필요하기 때문에 컴포지션을 대신 사용하자.

    그리고 상속용 클래스가 아닌 경우에는 final 클래스로 선언하거나 private, packge-private 생성자를 이용해 상속을 금지하자.

     

     

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

    자바가 구현하는 다중 구현 메커니즘에는 인터페이스와 추상 클래스가 있고,

    자바 8부터는 인터페이스도 디폴트 메서드(default method)를 구현할 수 있어 두가지 모두 인스턴스 메서드를 제공할 수 있다.

     

    둘의 가장 큰 차이점은 반드시 추상 클래스의 하위 클래스로만 구현이 가능하다는 점이다.

    이것때문에 추상 클래스보다 인터페이스를 구현하는 것에는 여러 이점이 생긴다.

    • 여러 인터페이스를 확장 구현할 수 있다. 자바는 단일 상속이기 때문에 추상 클래스를 상속한다면 다른 추상 클래스는 구현이 어렵다.
    • 인터페이스는 믹스인(mixin) 정의에 알맞다. 추상 클래스는 위와 마찬가지로 두 부모가 불가능하기 때문에 선택적 기능을 제공하기 힘들다.
    • 인터페이스로는 계층 구조가 없는 타입 프레임워크를 만들 수 있다. 여러 인터페이스를 조합하여 인터페이스를 만들 수 있기 때문에 유연성이 뛰어나다.
    • 래퍼 클래스 관용구와 함께 사용시 인터페이스는 기능을 향상시키는 안전하고 강력한 수단이다.

    인터페이스에서 구현이 명확한 것은 디폴트 메서드로 구현하여 제공할 수 있다. 단, 디폴트 메서드를 제공할 때는 상속하려는 사람을 위해 문서화가 필요하다.

    추상 골격 구현 클래스 (skeletal implementation class)

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

    인터페이스로는 타입과 디폴트 메서드를 제공하고, 골격 구현 클래스는 나머지 메서드를 모두 구현한다.

    이렇게 구현하여 사용하는 패턴을 템플릿 메서드 패턴이라고 한다.

    골격 구현 클래스를 사용하면 구현을 도와주는 동시에 추상 클래스로 타입을 정의할 때 따라오는 문제를 몇가지 피할 수 있다.

     

    골격 구현 클래스를 구현하기 위한 방법은 아래와 같다.

    1. 인터페이스를 잘 살펴 다른 메서드들의 구현에 사용되는 기반 메서드들을 선정한다.
      이 기반 메서드들은 골격 구현에서 추상 메서드가 된다.
    2. 기반 메서드들을 사용해 직접 구현할 수 있는 메서드를 모두 디폴트 메서드로 제공한다.
      단, equals와 hashCode 같은 Object의 메서드는 디폴트 메서드로 제공하면 안 된다.
      인터페이스의 메서드 모두가 기반 메서드와 디폴트 메서드가 된다면 골격 구현 클래스를 별도로 만들 이유는 없다.
    3. 기반 메서드나 디폴트 메서드로 만들지 못한 메서드가 남아 있다면, 이 인터페이스를 구현하는 골격 구현 클래스를 하나 만들어 남은 메서드들을 작성한다. 골격 구현 클래스에는 필요하면 public이 아닌 필드와 메서드를 추가해도 된다.

     

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

    자바 8 이전에는 기존 구현체를 깨뜨리지 않고는 인터페이스에 메서드를 추가하지 못했다.

    자바 9에 디폴트 메서드가 추가되어 기존 인터페이스에 메서드를 추가할 수 있게 되었지만,

    모든 상황에서 불변식을 해치지 않는 메서드를 작성하기는 힘들다.

     

    추가하려는 디폴트 메서드가 기존 구현체들과 충돌하는지 심사숙고해야하며, 디

    폴트 메서드가 인터페이스에서 메서드를 제거하거나 기존 메서드를 수정하려는 용도가 아님을 명심하자.

    그리고 새로운 인터페이스를 정의했다면 릴리즈 이전에 반드시 테스트를 거쳐야한다.

     

     

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

    인터페이스는 자신을 구현한 클래스의 인스턴스를 참조할 수 있는 타입 역할을 한다. 그러나 이 용도에 맞지않는 상수 인터페이스가 있다.

    상수 인터페이스란, 메서드 없이 상수를 뜻하는 staitc final 필드로만 채워진 인터페이스다.

    이 상수들을 사용하려는 클래스에서 정규화된 이름을 쓰는 걸 피하고자 인터페이스를 활용하는 것이다.

     

    그러나 클래스 내부에서 사용하는 상수는 내부 구현에 해당하고,

    상수 인터페이스를 구현한다면 내부 구현을 클래스의 API로 노출하는 형태이다.

    이는 사용자에게 혼란을 주고, 클라이언트 코드가 이 상수에 종속되는 위험이 있다.

    인터페이스는 그 용도에 맞게 타입을 정의하는 용도로만 사용하자.

     

     

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

    태그는 클래스가 두가지 이상의 의미를 표현할 때, 현재 표현하는 것을 알려주는 필드를 말한다.

    태그달린 클래스는 열거타입, 태그 필드, switch 문 등 쓸데없는 코드가 많고, 가독성도 나쁘고 메모리도 많이 차지한다.

    또한 필드들을 final로 선언하려면 불필요한 필드들가지 생성자에서 초기화해야하고,

    또 다른 의미가 추가되면 모든 switch문을 찾아 수정해야한다.

     

    이처럼 태그 달린 클래스는 장황하고 오류 발생이 쉽고 비효율적이다. 그저 클래스 계층구조를 흉내낼뿐이다.

    태그달린 클래스를 클래스 계층 구조로 바꾸는 방법은 아래와 같다.

    1. 계층구조의 루트(root)가 될 추상 클래스를 정의하고, 태그 값에 따라 동작이 달라지는 메서드들을 루트 클래스의 추상 클래스로 선언한다.
      • 태그값과 상관없이 동작이 일정한 메서드들은 일반 메서드로 구현한다.
      • 공통 데이터 필드들도 루트 클래스에 포함시킨다.
    2. 루트 클래스를 확장한 구체 클래스를 의미별로 하나씩 정의한다. 그리고 루트 클래스의 추상 클래스를 각자의 의미에 맞게 구현한다.

    이렇게 클래스 계층구조를 사용하면 태그달린 클래스보다 확실히 가독성이 좋고,
    각 필드를 초기화했는지 컴파일러가 확인할 수 있어 오류를 줄일 수 있다.

    이처럼 태그 달린 클래스를 써야할 상황은 거의 없으니 계층 구조를 활용한 방법을 사용하자.

     

     

    Item24. 멤버 클래스는 되도록 static으로 만들라

    중첩 클래스는 다른 클래스 안에 정의된 클래스를 말한다.

    중첩 클래스는 자신을 감싼 클래스에서만 쓰여야하며, 그외는 톱레벨 클래스로 구현해야 한다.

    중첩 클래스에는 정적 멤버 클래스, (비정적) 멤버 클래스, 익명 클래스, 지역 클래스 이렇게 네 가지가 있다.

     

    이번 아이템에서는 이 네 가지 클래스를 언제, 왜 사용해야하는지 알아보자.

    정적 멤버 클래스

    정적 멤버 클래스는 바깥 클래스의 private 멤버에도 접근할 수 있다는 점만 제외하면 일반 클래스 와 같다.

    정적 멤버 클래스는 다른 정적 멤버와 독같은 접근 규칙을 적용 받는다.

    정적 멤버 클래스는 주로 바깥 클래스와 함께 쓰일때만 유용한 public 도우미 클래스로 쓰인다.

     

    비정적 멤버 클래스

    비정적 멤버 클래스는 정적 멤버 클래스와 다르게 static이 붙어있지 않다는 것을 제외하고도 의미상 차이점이 크다.

    제일 큰 특징은 바깥 클래스의 인스턴스와 암묵적으로 연결된다.

    띠라서 비정적 멤버 클래스의 인스턴스 메서드에서 정규화된 this(클래스명.this)를 사용해

    바깥 클래스 인스턴스의 메서드를 호출하거나 참조를 가져올 수 있다.

     

    인스턴스화 될때 바깥 인스턴스와의 관계가 확립되며,

    이 관계 정보는 비정적 멤버 클래스의 내부에 만들어져 메모리와 생성 시간을 더 소비한다.

    비정적 멤버 클래스는 어댑터를 정의할 때 자주 쓰인다.

    즉, 어떤 클래스의 인스턴스를 감싸 마치 다른 클래스의 인스턴스처럼 보이게 하는 뷰로 사용된다.

     

    비정적 멤버 클래스에서 바깥 인스턴스에 접근할 일이 없으면 무조건 static 클래스로 만들어야한다.

    비정적 멤버 클래스는 숨은 바깥 인스턴스 참조를 갖게 되고 메모리 누수가 생길 가능성이 있다.

     

    익명 클래스

    익명 클래스는 이름도 없고 바깥 클래스의 멤버도 아니다.

    멤버 클래스들과 달리 쓰이는 시점에 선언과 동시에 인스턴스가 만들어진다. 코드의 어디서든 만들 수 있다.

    그리고 비정적인 문맥에서 사용될 때만 바깥 클래스의 인스턴스를 참조할 수 있다.

    정적 문맥에서라도 상수 변수 이외의 정적 멤버는 가질 수 없다.

    익명 클래스는 응용하는데 제약이 많다.

    • 선언한 지점에서만 인스턴스를 만들 수 있다.
    • instanceof 검사나 클래스의 이름이 필요한 작업은 수행할 수 없다,
    • 여러 인터페이스를 구현할 수 없고, 인터페이스를 구현하는 동시에 다른 클래스를 상속할 수도 없다.
    • 익명 클래스를 사용하는 클라이언트는 그 익명 클래스가 상위 타입에서 상속한 멤버 외에는 호출할 수 없다.
    • 표현식 중간에 등장하므로 10줄 이하로 짧지 않으면 가독성이 떨어진다.

    람다 이전에는 즉석에서 작은 함수 객체나 처리 객체를 만드는데 사용했지만,

    람다가 대신하여 이제 필요 없다. 그외에는 정적 팩터리 메서드를 구현할 때 사용한다.

     

    지역 클래스

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

    멤버 클래스처럼 이름이 있고 반복해서 사용할 수 있다.

    익명 클래스처럼 비정적 문맥에서 사용될 때만 바깥 인스턴스를 참조할 수 있으며, 정적 멤버는 가질수 없고, 가독성을 위해 짧게 작성해야 한다. 그러나 거의 쓰이지 않는다.

     

     

    Item25. 톱레벨 클래스는 한 파일에 하나만 담으라

    소스파일 하나에 톱레벨 클래스를 여러개 만들어도 문제는 없지만 아무런 득이없다.
    오히려 한 클래스를 여러 가지로 정의할 수 있고, 컴파일 시점에 따라 어떤 클래스를 사용할지 달라지기 때문에 위험하다.

    따라서 톱레벨 클래스들은 서로 다른 소스 파일로 분리해서 구현하자.

    굳이 한 파일에 담고 싶다면 정적 멤버 클래스를 사용하는 방법을 고려하자.

    댓글