04. 클래스와 인터페이스

zwundzwzig·2023년 5월 7일
0

이펙티브 자바

목록 보기
3/4

추상화의 기본 단위인 클래스와 인터페이스는 자바 언어의 심장과 같다.

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

잘 설계된 컴포넌트는 모든 내부 구현을 완벽히 숨겨 구현과 API를 깔끔하게 분리해 외부 컴포넌트와 오직 API로만 소통한다. 즉, 소프트웨어 설계의 근간인 캡슐화가 잘 돼 있어야 한다.

진부할 수 있지만 캡슐화의 장점에 대해 나열해보자.

  • 여러 컴포넌트를 병렬적으로 개발할 수 있어 개발 속도를 높인다.
  • 각 컴포넌트를 빨리 파악해 디버깅 가능하고 컴포넌트를 교체하는 부담이 적어 관리 비용을 낮춘다.
  • 완성된 시스템을 프로파일링해 특정 컴포넌트에게만 최적화할 수 있다.
  • 의존성을 줄여 소프트웨어 재사용성을 높일 수 있다.
  • 개별 컴포넌트가 각자 동작해 프로젝트의 난이도를 낮출 수 있다.

모든 클래스와 멤버의 접근성을 가능한 가장 낮은 접근 수준을 부여해야 한다.

접근성이 낮을수록 자원은 독립적이고, 위/변조의 위험에서 안전할 것이다.

패키지 외부에서 쓸 일이 없다면(공개 API가 아니라면) package-private로 선언해야 한다. API가 아닌 내부 구현이기에 수정에서 자유로워지기 때문이다.

대신 그 이상은 안 된다. 테스트만을 위해 클래스, 인터페이스, 멤버를 공개 API로 만들면 package-private 요소에 접근할 수 있기 때문이다.

그럴 땐 private static 중첩을 이용해보자. private static으로 중첩시키면 바깥 클래스 하나에서만 접근할 수 있다.

public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다.

  • 필드가 가변 객체를 참조하거나, final이 아니면 필드 값을 제한 못해 해당 필드에 대한 모든 것은 불변식을 보장할 수 없게 된다.
  • 필드가 수정될 때 막을 수 없기에 public 가변 필드를 갖는 클래스는 일반적으로 스레드 안전하지 않다.
  • final이면서 불변 객체를 참조하더라도 내부 구현을 바꾸지 못하는 문제가 남는다.

여기엔 예외가 하나 있다. 해당 클래스의 추상 개념을 완성하는데 필요한 구성요소로써의 상수라면 public static final필드로 공개해도 좋다. (관례적으로 대문자 알파벳으로 작성하며 구분은 밑줄(_)으로 한다. )
이런 필드는 반드시 기본 타입 값이나 불변 객체를 참조해야 한다.

public class FixDiscountPolicy1000 implements DiscountPolicy{
		public static final int DISCOUNT_AMOUNT = 1000;
		...
}

⇒ 고정적인 1000원 할인을 하는 할인정책 구현체에서 1000원이라는 금액은 필수적인 구성요소이자 변경의 가능성이 없기 때문에 public static final 필드로 공개한다. 이런 필드(public static final)가 기본 타입이나 불변 객체가 아닌 가변 필드나 가변 객체를 참조할 경우 final이 아닌 필드에 적용되는 단점이 모조리 적용된다. 참조된 객체 자체가 수정될 수 있기에 문제가 크다. 그리고, 길이가 0이 아닌 배열이나 콜렉션들도 모두 변경 가능하니 주의하자.

다음 코드를 보자.

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

이 배열 필드에는 큰 보안 허점이 존재한다. final이여도 public이기 때문에 배열 내부의 값들은 얼마든지 수정이 가능하다.

// 접근제어자를 private로 만들고 public 불변 리스트를 추가하기
private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES = 
		Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));

// 배열을 private로 만들고 복사본을 반환하는 public 메서드를 추가하는 방법(방어적 복사)
private static final Thing[] PRIVATE_VALUES = { ... };
public static final Thing[] values() {
		return PRIVATE_VALUES.clone();
}

위와 같은 방법으로 해결할 수 있다.

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

패키지 바깥에서 접근할 수 있는 클래스라면 멤버 자체에 public 제어자를 두기 보단, getter와 같이 접근자 메서드를 사용해 접근하고 필드 자체는 private으로 막아두자. 불변식을 보장할 수 없으니!

즉, public 클래스는 절대 가변 필드를 직접 노출해선 안된다. 불변 필드라면 노출해도 덜 위험하지만 그렇다고 완전히 안심할 수 없다. 하지만 package-private 클래스나 private 중첩 클래스에선 종종 필드를 노출하는 게 나은 상황도 있다.

변경 가능성을 최소화하라

불변 클래스는 가변 클래스보다 설계, 구현, 사용에서 장점이 있고 오류를 방지해 안전하다.

다음은 클래스를 불변으로 만들 때 규칙이다.

  • 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다.
  • 클래스를 확장할 수 없도록 해 하위 클래스에서 객체의 상태에 변화를 주는 것을 막는다.
  • 설계자의 의도를 명확히 드러내기 위해 모든 필드를 final로 선언한다.
  • 가변 객체가 수정되지 않도록 private으로 모든 필드를 선언한다.
  • 자신 외에는 내부의 가변 컴포넌트에 접근하지 못하도록 한다.

불변 클래스의 장점

클래스의 낮은 접근성은 얼핏보면 불변 클래스에 비교해서 로직을 짤 때 데이터 접근이 편하기에 불변 클래스로 만들 경우 생기는 추가적인 고려사항들이 까다롭게 느껴질 수 있다. 그렇기 때문에 다음과 같은 장점들을 명확히 알 필요가 있다.
  • 불변 객체는 단순하다.
    ⇒ 생성된 시점의 상태를 파괴될 때까지 그대로 가지고 있기에 불변식이 보장된다면 해당 클래스를 사용하는 곳에서도 불변을 유지하기 쉽다.
  • 스레드 안전하며 따로 동기화할 필요가 없다.
    ⇒ 여러 스레드가 동시에 사용해도 절대 훼손되지 않는다. 스레드를 안전하게 만드는 가장 쉬운방법이 불변 클래스다. 이처럼 스레드가 안전하니 불변객체는 공유하는데 문제 없다.
  • 복사 생성자가 필요없다.
    ⇒ 불변 객체는 공유에서 자유롭기 때문에 방어적 복사도 필요가 없다. 그냥 공유하면 되기 때문에 불변 클래스는 clone 메서드나 복사 생성자를 제공하지 않는게 좋다.
  • 불변 객체는 그 자체로 실패 원자성(예외가 발생한 후에도 그 객체는 예외 발생전과 동일하게 유효한 상태여야 한다)을 제공한다.
    ⇒ 상태가 변하지 않기에 잠깐이라도 불일치 상태에 빠지지 않는다.
  • 객체 생성시 불변 객체를 사용할 경우 이점이 많다.
    ⇒ 생성될 객체가 복잡한 구조라도 구성요소로 불변 객체가 사용된다면 불변식을 유지하기 훨씬 수월하기 때문이다. 가령 맵의 키와 집합(Set)의 원소로 쓰기 좋다.

이러한 장점들 때문에 불변 클래스라면 한 번 만든 객체를 최대한 재활용하는게 좋다.

또한, 이런 불변 클래스에서 자주 사용되는 인스턴스를 캐싱해서 중복된 인스턴스를 생성하지 않도록 할 수 있다. (정적 팩토리)

물론, 불변 클래스에도 단점이 있다. 값이 다르면 반드시 독립된 객체로 만들어야 한다는 것이다. 값의 가짓수가 많다면 이들을 모두 만드는 데 큰 비용을 치르기 때문이다. 원하는 객체를 완성하기까지 단계가 많고 중간 단계에서 만들어진 객체가 모두 버려지면 큰 성능 문제로 연결된다.

이제 대한 해결 방안 중 하나로 다단계 연산을 예측해 기본 기능으로 제공하는 방법이다. 대표적인 예로 StringBuilder가 있다.

불변 클래스를 만드는 설계 방법

가장 쉬운 방법은 final 클래스로 선언하는 거지만, 다수의 구현 클래스를 활용해 더 유연한 방법으로 모든 생성자를 private, package-private로 만들고 public static 팩토리를 만드는 것이다. 이에 더해 다음 릴리즈에서 객체 캐싱 기능을 추가해 성능을 끌어올릴 수도 있다.

무작정 setter를 만들지 말자. 클래스는 꼭 필요한 경우가 아니면 불변이어야 한다.
불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분을 최소화하자.
다른 합당한 이유가 없다면 모든 필드는 private final이어야 한다.
생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.

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

클래스 간 상속은 코드를 재사용하는 강력한 수단이지만 오류를 범하기 쉽다. 패키지를 넘나드는 경우에 그렇다. 캡슐화를 위반하기 때문이다.

상속은 상위 클래스와 하위 클래스의 관계가 온전한 is-a 관계일 때만 써야한다. 그리고 is-a 관계여도 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려하지 않으면 문제가 생긴다.

컴포지션(Composition)을 사용하자.

새로운 클래스에서 본디 상위 클래스로 사용할 클래스를 private 필드로 작성해 참조하도록 하는 컴포지션을 사용하자.

새로운 클래스에서 기존 클래스에 대응하는 메서드를 호출하면 새로운 클래스는 기존 클래스의 메서드를 호출해서 결과를 반환하는데 이를 전달(forwarding)이라 하며, 이런 새로운 클래스의 메서드를 전달 메서드(forwarding method)라 부른다.

래퍼 클래스로 구현할 인터페이스(ex: Set, List, Map)가 있다면 더욱 그렇다.

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

상속을 염두해두지 않은 외부 클래스(프로그래머의 통제권 밖에 있어 변경시점을 알 수 없는 클래스)를 상속할 경우 여러 문제가 발생할 수 있다. 그렇기에 상속용 클래스를 설계할 때는 문서화가 필요하다.

처음부터 기능 확장및 재정의를 염두해두고 만드는 상속용 클래스를 설계한다면 해당 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.

또한 API로 공개된 메서드로부터 호출되는 재정의 메서드(public(or protected)메서드 중 final이 아닌 모든 메서드)가 호출된다는 사실과, 어떤 순서로 호출되는 지와 그 결과에 이어지는 처리에 어떤 영향을 주는지 적어야 한다.

클래스 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별해서 protected 메서드 형태로 공개해야 할 수도 있다. 실제로 하위 클래스를 만들어보면서 테스트를 해봐야하는데, protected 메서드는 내부 구현에 해당하기 때문에 수가 가능한 적어야 한다.

상속용 클래스의 생성자는 직간접적으로 재정의 가능 메서드를 호출하면 안 된다. 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의 메서드가 해당 클래스의 생성자보다 먼저 호출되기 때문이다. private, final, static 메서드는 재정의가 불가능하기에 생성자에서 호출해도 문제가 없다.

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

자바가 제공하는 두 가지 다중 구현 메커니즘.

가장 큰 차이는, 추상 클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 자식 클래스가 돼야 한다.

이러한 종속성으로 인한 많은 제약의 문제는 인터페이스의 장점으로 귀결된다.

  • 기존 클래스에도 손쉽게 새로운 인터페이스를 구현해 넣을 수 있다.
  • 인터페이스는 믹스인(mixin)정의에 안성맞춤이다.
  • 인터페이스로는 계층구조가 없는 타입 프레임워크를 만들 수 있다.
  • 래퍼 클래스와 같이 사용하면 기능 향상이 안전하고 강력하다.

인터페이스의 메서드 중 구현 방법이 명백한 게 있다면 디폴트 메서드로 제공하고 상속하려는 사람을 위해 @implSpec 자바독 태그를 붙여 문서화해야 한다.

그러나,

  • Object의 메서드(ex: equals, hashCode...)같은 메서드들은 디폴트 메서드로 제공하면 안 되고,
  • 인터페이스는 인스턴스 필드를 가질 수 없으며
  • public이 아닌 정적 멤버도 가질 수 없고 (private 정적 메서드는 예외)
  • 우리가 구현하지 않은 인터페이스에는 디폴트 메서드를 추가할 수 없다.

한편, 인터페이스와 추상 골격 구현 클래스를 함께 제공하는 식으로 인터페이스와 추상 클래스의 장점을 모두 취하는 방식도 있다.

인터페이스로는 타입을 정의하거나 필요시 디폴트 메서드 몇 개도 함께 제공한다. 그리고 골격 구현 클래스는 나머지 메서드들까지 구현하는, 템플릿 메서드 패턴을 활용해서 말이다.

추상 골격 클래스는 인터페이스에서 구현에 사용될 기반 메소드를 선정해 골격 구현 클래스 내의 추상 메소드가 된다.

그리고 기반 메서드를 통해 직접 구현이 가능한 메서드는 모두 인터페이스의 default method가 되고, 기반 메서드 / 디폴트 메서드가 되지 못한 메서드들(ex: equals, hashCode...)등을 골격 구현 클래스에서 구현하면 된다.

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

JDK 1.8 이후 인터페이스에도 정적 메서드와 디폴트 메서드가 추가되면서 인터페이스 내에도 로직을 직접 추가할 수 있게 되었다. 그래서 표준 메서드 제공의 유용한 수단이 된다.

그러나 디폴트 메서드는 구현 클래스에 대해서는 알지 못한 채 무작정 구현되기 때문에 모든 상황에서 불변식을 해치지 않도록 하는 건 쉽지 않다.

디폴트 메서드를 기존 인터페이스에 추가하는건 되도록 피하자.

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

인터페이스로는 외부에 공개해도 되는 API를 정의한 뒤 사용자가 해당 API만 사용할 수 있게 하고, 인터페이스의 구현체의 내부 구조까지 다 파악할 필요가 없게끔 해주는, 용도로만 사용을 해야 한다.

저자는 상수 공개용 수단으로 사용하면 안 된다고 강조했다. 그 이유로

  • 클래스 내부에서 사용하는 상수는 내부 구현에 해당한다.
  • 상수 인터페이스를 구현하는 것은 내부 구현을 외부로 노출하는 행위이다.
  • 클래스가 어떤 상수 인터페이스를 사용하든 사용자에게는 의미가 없다.
    ⇒ 오히려 혼란을 주거나 클라이언트 코드가 내부 구현에 해당하는 이 상수들에 종속될 수 있다.
  • 버전이 바뀌며 상수들을 사용하지 않게되더라도 바이너리 호환성(프로젝트내의 무엇을 바꾼 이후에도 기존 바이너리가 문제없이 실행될 수 있는 상황)을 위해 여전히 상수 인터페이스를 구현하고 있어야 한다.

대신 열거 타입이나 유틸리티 클래스를 활용해 상수를 제공할 수 있다.

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

두 가지 이상의 의미를 표현할 수 있으며 현재 표현하는 의미를 태그 값으로 알려주는 클래스는 지양하자.

public class Figure {
    enum Shape {
        RECTANGLE, CIRCLE
    };

    //공통 필드
    private final Shape shape;

    private double length;
    private double width;

    private double radius;

    public Figure(double radius) {
        this.radius = radius;
        shape = Shape.CIRCLE;
    }

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

하나의 클래스에서 두 타입에 대응이 가능하고, 얼핏보면 area() 메서드 하나로 타입별로 맞는 크기를 계산해 반환하기에 편해보인다.

그러나, 이 클래스는 단점 투성이다.

  • 열거 타입, 태그 필드, switch 문 부가적인 코드가 너무 많다.
  • 하나의 메서드에 여러 구현이 들어가있기에 가독성도 떨어지고 SRP 지침도 어긋난다.
  • 하나의 타입으로 정해지면 그외의 타입을 위한 코드는 모두 불필요한 코드가 되지만 항상 함께 있기에 메모리 낭비가 된다.
  • 필드가 final일 경우 필드 초기화시 매번 불필요한 필드도 초기화 해야 한다.
  • 새로운 타입이 추가될 때마다 분기가 필요한 모든 메서드에 새로운 타입에 대응하는 코드를 작성해야 한다.
  • 인스턴스의 타입만으로 의미를 알기 쉽지 않다.

클래스 계층 구조로 변환하자.

public abstract class Figure{
    abstract double area();
}

public class Circle extends Figure{
    final double radius;

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

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

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

    public Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }

    @Override
    double area() {
        return length * width;
    }
}

태그 달린 클래스가 사용되야하는 상황은 거의 없어졌고, 대부분 계층 구조로 해결이 된다. 기존 클래스가 태그 필드를 사용해 구분하고 있다면 리팩터링을 고려할 필요가 있다.

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

중첩 클래스의 종류는 네 가지이다.

  • 정적 멤버 클래스
  • (비정적) 멤버 클래스
  • 익명 클래스
  • 지역 클래스

이중 정적 멤버 클래스를 제외한 나머지는 내부 클래스에 해당한다. 이 챕터에선 각각의 중첩 클래스에 대해 알아보고, 되도록 정적 멤버 클래스로 만들도록 하자.

정적 멤버 클래스 vs 비정적 멤버 클래스

정적 멤버 클래스는 비정적 멤버 클래스와 달리 바깥 클래스의 인스턴스와의 암묵적 연결이 안 되어있다.

그래서 정적 멤버 클래스는 바깥 클래스의 인스턴스 없이 생성 가능하지만, 비정적 멤버 클래스는 바깥 인스턴스 없이는 생성할 수 없다.

비정적 멤버 클래스의 문제는, 바깥 인스턴스로의 숨은 외부 참조를 가지게 되고 이 참조를 저장하려 시간과 공간이 소비된다. 또한 가비지 컬렉션이 바깥 클래스의 인스턴스를 수거하지 못하는 메모리 누수가 발생할 수 있고, 참조가 눈에 보이지 않아 디버깅이 힘들어진다.

이외에 중첩 클래스가 한 메서드 안에만 사용되면서, 인스턴스의 생성 지점이 단 한 곳이고 해당 타입으로 쓰기 적당한 클래스나 인터페이스가 있다면 익명클래스를 만들고 그렇지 않으면 지역 클래스를 만들면 된다.

즉, 멤버 클래스에서 바깥 인스턴스를 접근해야 하는 경우가 명확히 없다면 static을 붙혀 정적 멤버 클래스로 만들어라.

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

하나의 소스파일에 여러 클래스를 작성할 경우 소스 파일명과 동일한 클래스를 제외한 나머지 클래스들의 존재가 감춰진다.

톱레벨 클래스로 하나의 소스파일에 하나의 클래스만 존재할 경우 실수로라도 동일한 클래스를 다시 정의하는 문제는 발생하지 않을 것이다.

🧷 참조 교재

  • [프로그래밍 인사이트] 이펙티브 자바 - 조슈아 블로크
profile
개발이란?

0개의 댓글