이펙티브 자바 완벽 공략 2부

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

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


아이템 22. 핵심 정리

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

인터페이스의 가장 중요한 역할은 타입을 정의하는 것이다.

어떤 클래스가 특정 인터페이스를 구현한다는 것은 단순히 메서드 몇 개를 사용할 수 있다는 뜻을 넘어, 그 클래스의 인스턴스를 해당 인터페이스 타입으로 사용할 수 있다는 의미를 가진다.

예를 들어 다음과 같은 인터페이스가 있다고 하자.

public interface Flyable {

    void fly();
}

어떤 클래스가 이를 구현한다.

public class Bird implements Flyable {

    @Override
    public void fly() {
        System.out.println("날아간다.");
    }
}

이 관계는 자연스럽다.

Bird

is-a

Flyable

BirdFlyable 타입으로 취급될 수 있고, 클라이언트 역시 그 의미를 쉽게 이해할 수 있다.

Flyable flyable = new Bird();

flyable.fly();

이처럼 인터페이스는 구현 클래스가 어떤 타입이며 어떤 역할을 수행할 수 있는지 표현하는 용도로 사용하는 것이 가장 자연스럽다.


상수를 담기 위한 인터페이스

문제는 인터페이스를 타입 정의가 아닌 다른 목적으로 사용하는 경우다.

대표적인 안티패턴이 상수 인터페이스(Constant Interface) 다.

예를 들어 물리 상수들을 다음과 같이 인터페이스에 선언할 수 있다.

public interface PhysicalConstants {

    double AVOGADROS_NUMBER =
            6.022_140_857e23;

    double BOLTZMANN_CONSTANT =
            1.380_648_52e-23;

    double ELECTRON_MASS =
            9.109_383_56e-31;
}

인터페이스에 선언되는 필드는 암묵적으로 다음 특성을 가진다.

public static final

따라서 위 선언은 개념적으로 다음과 같다.

public interface PhysicalConstants {

    public static final double AVOGADROS_NUMBER =
            6.022_140_857e23;
}

여기까지는 특별한 문제가 없어 보일 수 있다.

문제는 이 상수들을 편하게 사용하기 위해 클래스로 인터페이스를 구현하는 경우다.


상수를 편하게 쓰려고 인터페이스를 구현한다

다음과 같은 클래스가 있다고 하자.

public class MyCalculator
        implements PhysicalConstants {

    public double calculate(double value) {

        return value
                * AVOGADROS_NUMBER;
    }
}

PhysicalConstants를 구현했기 때문에 다음처럼 인터페이스 이름을 생략할 수 있다.

AVOGADROS_NUMBER

인터페이스를 구현하지 않았다면 일반적으로 다음처럼 사용했을 것이다.

PhysicalConstants.AVOGADROS_NUMBER

코드를 조금 짧게 쓰기 위해

implements PhysicalConstants

를 추가한 셈이다.

기술적으로는 가능하다.

하지만 설계적으로는 좋은 방법이 아니다.


MyCalculator는 정말 PhysicalConstants 타입인가?

가장 먼저 생각해야 할 질문은 이것이다.

MyCalculator는
PhysicalConstants 타입인가?

그렇지 않다.

MyCalculator는 물리 상수 그 자체가 아니다.

단순히 계산 과정에서 물리 상수를 사용하고 있을 뿐이다.

그런데 코드에는 다음과 같이 표현되어 있다.

public class MyCalculator
        implements PhysicalConstants {
}

Java 타입 시스템의 관점에서는 다음 코드도 가능해진다.

PhysicalConstants constants =
        new MyCalculator();

문법적으로는 맞다.

하지만 의미적으로는 굉장히 이상하다.

MyCalculator

is-a

PhysicalConstants

라는 관계가 성립하는 것처럼 보이기 때문이다.

실제로 두 개념 사이에는 그런 타입 관계가 없다.


인터페이스의 의미를 오염시킨다

이것이 상수 인터페이스의 가장 큰 문제다.

인터페이스가 원래 표현해야 하는 것은

"이 객체는 어떤 타입인가?"

또는

"이 객체는 어떤 역할을 수행할 수 있는가?"

이다.

예를 들어 다음 인터페이스들은 의미가 명확하다.

Runnable
Comparable<T>
AutoCloseable

각각 다음과 같은 능력을 나타낸다.

Runnable
→ 실행할 수 있다.

Comparable
→ 비교할 수 있다.

AutoCloseable
→ 닫을 수 있다.

반면

PhysicalConstants

는 객체가 제공하는 능력이나 타입을 표현하지 않는다.

그저 상수를 모아놓은 공간일 뿐이다.

이런 인터페이스를 구현하게 되면 타입 계층에 아무런 의미가 없는 관계가 생긴다.


타입 계층은 설계 의도를 전달한다

다음 코드를 처음 보는 개발자가 있다고 생각해보자.

public class MyCalculator
        implements PhysicalConstants {
}

코드를 보는 사람은 자연스럽게 생각할 수 있다.

"PhysicalConstants라는 타입이 있구나."

"MyCalculator는 그 타입의 구현체인가?"

"이 객체를 PhysicalConstants로 다형적으로 사용해야 하나?"

하지만 실제 목적은 단순하다.

상수 이름 앞에
PhysicalConstants.
를 쓰기 싫어서
implements를 사용했다.

코드의 타입 구조와 실제 설계 의도가 완전히 어긋난다.

좋은 타입 설계라고 보기 어렵다.


구현 세부사항까지 외부에 공개한다

또 다른 문제는 캡슐화다.

어떤 클래스가 특정 상수를 사용한다고 해보자.

public class MyCalculator {

    private static final double BASE_VALUE =
            100.0;

    public double calculate(double value) {
        return value * BASE_VALUE;
    }
}

BASE_VALUEMyCalculator의 내부 구현에 필요한 값이다.

클라이언트는 이 상수를 알 필요가 없다.

Client

↓

calculate() 사용

클라이언트가 알아야 하는 것은 계산 기능이지 계산 내부에서 어떤 상수를 사용하는지가 아니다.


상수 인터페이스를 구현하면 내부 정보가 외부 계약처럼 보인다

반면 다음처럼 작성하면

public class MyCalculator
        implements PhysicalConstants {
}

PhysicalConstants라는 인터페이스가 MyCalculator의 공개 타입 계층에 포함된다.

즉 실제로는 클래스 내부 구현에 필요했던 상수들이 외부 API와 연결되어 버린다.

MyCalculator 내부 구현

↓

PhysicalConstants 사용

↓

implements를 통해
공개 타입 정보로 노출

이런 구조는 캡슐화를 약하게 만든다.


상수는 어디에 두어야 할까?

그렇다면 상수를 인터페이스에 넣지 않는다면 어디에 두어야 할까?

상수의 사용 범위에 따라 결정하면 된다.

핵심은 가능한 가장 작은 범위에 상수를 두는 것이다.


특정 클래스에서만 사용하는 상수

어떤 상수가 하나의 클래스에서만 필요하다면 그 클래스 내부에 두는 것이 가장 자연스럽다.

public class PaymentCalculator {

    private static final int MAX_RETRY_COUNT = 3;

    public void process() {

        for (
                int i = 0;
                i < MAX_RETRY_COUNT;
                i++
        ) {
            // retry
        }
    }
}

상수가 외부에 공개될 이유가 없다면 private으로 만든다.

private static final int MAX_RETRY_COUNT = 3;

이렇게 하면 사용 범위가 명확하다.

MAX_RETRY_COUNT

↓

PaymentCalculator 내부에서만 사용

상수의 접근 범위는 최소화하는 것이 좋다

상수라고 해서 무조건 public으로 공개할 필요는 없다.

다음처럼 작성하기보다

public static final int MAX_RETRY_COUNT = 3;

외부에서 사용할 이유가 없다면

private static final int MAX_RETRY_COUNT = 3;

가 더 적절하다.

이것은 일반적인 정보 은닉 원칙과 동일하다.

필요한 범위까지만 공개한다.

상수 역시 API의 일부가 될 수 있기 때문에 한번 public으로 공개하면 이후 값을 변경하거나 제거하기 어려워질 수 있다.


여러 클래스에서 공유해야 하는 상수

여러 클래스에서 공통으로 사용하는 상수라면 별도의 유틸리티 클래스로 모을 수 있다.

public final class PhysicalConstants {

    private PhysicalConstants() {
        throw new AssertionError();
    }

    public static final double AVOGADROS_NUMBER =
            6.022_140_857e23;

    public static final double BOLTZMANN_CONSTANT =
            1.380_648_52e-23;

    public static final double ELECTRON_MASS =
            9.109_383_56e-31;
}

이제 사용할 때는 클래스 이름을 명시한다.

double value =
        PhysicalConstants.AVOGADROS_NUMBER;

이 방식은 상수의 출처가 명확하다.


네임스페이스가 있는 것이 오히려 장점이다

상수 인터페이스를 구현했던 이유 중 하나는 다음 코드를

PhysicalConstants.AVOGADROS_NUMBER

다음처럼 줄이고 싶었기 때문이다.

AVOGADROS_NUMBER

하지만 PhysicalConstants.라는 이름이 붙어 있는 것은 반드시 단점이 아니다.

오히려 코드를 읽을 때 의미가 명확해진다.

double value =
        PhysicalConstants.AVOGADROS_NUMBER;

이 코드만 보더라도

AVOGADROS_NUMBER가
어디에서 정의된 상수인지

즉시 알 수 있다.

반면

double value =
        AVOGADROS_NUMBER;

만 보면 상수의 출처를 추가로 확인해야 할 수 있다.

명시적인 네임스페이스는 코드의 가독성을 높여주는 역할도 한다.


유틸리티 클래스는 인스턴스 생성을 막는다

상수만 제공하는 클래스라면 객체를 생성할 이유가 없다.

다음 코드는 의미가 없다.

PhysicalConstants constants =
        new PhysicalConstants();

따라서 생성자를 private으로 만들어 인스턴스 생성을 막는다.

private PhysicalConstants() {
}

조금 더 명시적으로 만들고 싶다면 다음처럼 작성할 수도 있다.

private PhysicalConstants() {
    throw new AssertionError();
}

의도는 명확하다.

이 클래스는 객체를 만들기 위한 클래스가 아니다.

final로 상속도 막을 수 있다

유틸리티 클래스는 일반적으로 상속할 이유도 없다.

public final class PhysicalConstants {

    private PhysicalConstants() {
    }
}

final을 붙이면 하위 클래스를 만들 수 없다.

인스턴스 생성

X

상속

X

클래스 이름을 통한 상수 접근

O

상수를 모아놓은 네임스페이스 역할에 충실한 구조가 된다.


상수 인터페이스와 유틸리티 클래스 비교

구분상수 인터페이스유틸리티 클래스
원래 목적타입 정의관련 기능이나 상수 제공
implements 가능가능불가능
잘못된 타입 관계 생성가능없음
상수 출처숨겨질 수 있음클래스 이름으로 명확
인스턴스 생성 제한해당 없음private 생성자로 가능
상속 제한구현 자체를 막기 어려움final로 명확하게 가능
캡슐화타입 계층 오염 가능역할이 명확함

여러 곳에서 공유해야 하는 단순 상수를 모으는 목적이라면 유틸리티 클래스가 훨씬 자연스럽다.


static import는 어떨까?

상수 이름 앞에 클래스명을 계속 붙이는 것이 정말 번거롭다면 Java의 static import를 사용할 수도 있다.

import static com.example.PhysicalConstants.AVOGADROS_NUMBER;

이제 다음처럼 사용할 수 있다.

double value =
        AVOGADROS_NUMBER;

상수 인터페이스를 구현하지 않고도 짧게 사용할 수 있다.

상수를 짧게 사용하고 싶다.

↓

인터페이스 구현

이라는 선택을 할 필요가 없다.

다만 static import를 너무 많이 사용하면 상수나 메서드의 출처를 파악하기 어려워질 수 있으므로 가독성을 기준으로 사용하는 것이 좋다.


인터페이스 내부의 상수가 항상 잘못된 것은 아니다

중요한 점은

인터페이스에는 절대 상수를 선언하면 안 된다.

가 아니다.

인터페이스 자체의 계약을 구성하는 상수라면 존재할 수 있다.

핵심적인 문제는 상수를 가져다 쓰기 위한 목적으로 인터페이스를 만들고 그것을 구현하는 것이다.

즉 다음 목적이 문제다.

상수를 편하게 사용하기 위해

implements Constants

인터페이스가 실제 타입이나 역할을 표현하지 않는다면 인터페이스의 본래 목적과 맞지 않는다.


인터페이스를 보면 타입의 의미를 알 수 있어야 한다

다음 인터페이스를 보자.

public interface Cacheable {
}
public interface Retryable {
}
public interface AutoCloseable {
}

인터페이스 이름만 보더라도 객체가 어떤 특성이나 역할을 가지고 있는지 어느 정도 알 수 있다.

클래스가 인터페이스를 구현한다면 다음 문장이 자연스럽게 읽혀야 한다.

A는 B다.

A는 B의 능력을 가진다.

예를 들어

FileResource는 AutoCloseable이다.

Task는 Runnable이다.

는 자연스럽다.

하지만

MyCalculator는 PhysicalConstants다.

는 자연스럽지 않다.

이런 경우 인터페이스를 타입으로 사용하는 것이 잘못되었을 가능성이 높다.


구현 편의를 타입 시스템에 노출하지 말자

상수 인터페이스 패턴의 본질적인 문제를 한 단계 더 추상화하면 다음과 같다.

구현 편의를 위해
타입 시스템을 사용했다.

클래스가 인터페이스를 구현한다는 것은 공개적인 설계 선언이다.

implements SomeInterface

는 단순한 코드 축약 기능이 아니다.

클라이언트에게 다음을 이야기하는 것이다.

이 객체는 SomeInterface 타입으로 사용할 수 있습니다.

따라서 단지 상수 이름을 짧게 쓰기 위해 implements를 사용하는 것은 타입 시스템을 잘못 활용하는 것이다.


public 상수는 API라는 것도 기억하자

상수를 별도 클래스로 옮겼다고 해서 무조건 public으로 만들 필요는 없다.

예를 들어

public static final int TIMEOUT = 3000;

을 외부 라이브러리에 공개하면 클라이언트 코드가 이 값에 의존할 수 있다.

따라서 상수를 공개하기 전에도 생각해야 한다.

외부에서 정말 사용해야 하는 값인가?

구현 세부사항은 아닌가?

앞으로 이 값을 변경해도 되는가?

클라이언트가 이 값에 의존해도 괜찮은가?

상수 역시 공개하는 순간 API가 될 수 있다.


숫자 리터럴에는 언더스코어를 사용할 수 있다

마지막으로 숫자로 된 상수를 작성할 때 알아두면 좋은 Java 문법이 있다.

Java 7부터 숫자 리터럴 내부에 언더스코어 _를 사용할 수 있다.

예를 들어 다음 숫자가 있다고 하자.

1000000000

자릿수가 많아지면 읽기 어렵다.

언더스코어를 사용하면 다음처럼 표현할 수 있다.

1_000_000_000

실제 값은 동일하다.


큰 숫자를 훨씬 읽기 쉽게 만들 수 있다

다음 코드를 비교해보자.

private static final long MAX_FILE_SIZE =
        10000000000L;

다음 코드가 훨씬 읽기 쉽다.

private static final long MAX_FILE_SIZE =
        10_000_000_000L;

바로

100억

정도의 값이라는 것을 파악하기 쉬워진다.


이진수나 16진수에서도 사용할 수 있다

언더스코어는 10진수에만 사용할 수 있는 것이 아니다.

int binary =
        0b1111_0000_1010_0101;

또는

long hex =
        0xFFFF_FFFF_FFFF_FFFFL;

처럼 자릿수를 논리적인 단위로 구분할 수도 있다.

이런 작은 문법만으로도 상수의 가독성을 높일 수 있다.


상수 설계에서 생각할 기준

상수를 정의할 때는 다음 흐름으로 생각하면 좋다.

이 상수를 한 클래스만 사용하는가?

↓

그렇다면 해당 클래스의
private static final


여러 클래스가 공유하는가?

↓

실제 공개 API인지 판단


공통 상수 집합이 필요하다면

↓

인스턴스화할 수 없는
유틸리티 클래스 고려

상수를 편하게 사용하기 위해 의미 없는 인터페이스 타입을 만드는 것은 피한다.


아이템 22가 말하는 핵심

아이템의 제목은 단순히

"상수 인터페이스를 만들지 마라."

보다 더 넓은 의미를 가진다.

중요한 것은 인터페이스라는 언어 기능의 목적을 명확하게 지키는 것이다.

인터페이스는

코드를 짧게 만드는 도구

가 아니다.

또한

상수를 모아놓는 네임스페이스

를 만들기 위한 도구도 아니다.

인터페이스의 가장 중요한 역할은

타입을 정의하는 것

이다.

어떤 클래스가 특정 인터페이스를 구현한다면 그 클래스의 인스턴스를 해당 인터페이스 타입으로 사용하는 것이 설계적으로 자연스러워야 한다.

그렇지 않다면 인터페이스를 잘못 사용하고 있는 것은 아닌지 다시 생각해볼 필요가 있다.


핵심 정리

  • 인터페이스의 가장 중요한 목적은 타입을 정의하는 것이다.
  • 클래스가 인터페이스를 구현한다면 그 객체를 해당 인터페이스 타입으로 사용하는 것이 설계적으로 자연스러워야 한다.
  • 단순히 상수를 편하게 사용하기 위해 인터페이스를 만들고 구현하는 방식을 상수 인터페이스 패턴이라고 한다.
  • 상수 인터페이스는 기술적으로 가능하지만 타입의 의미를 오염시키기 때문에 권장되지 않는다.
  • 상수 인터페이스를 구현하면 클래스가 실제로는 관계없는 인터페이스 타입의 구현체처럼 보이게 된다.
  • 구현에 필요한 상수들이 공개 타입 계층을 통해 외부에 드러나면서 캡슐화를 약하게 만들 수도 있다.
  • 특정 클래스에서만 사용하는 상수라면 가능한 private static final로 해당 클래스 안에 두는 것이 좋다.
  • 여러 클래스에서 사용하는 공통 상수라면 인스턴스를 생성할 수 없는 유틸리티 클래스를 고려할 수 있다.
  • 유틸리티 클래스는 private 생성자로 인스턴스 생성을 막고 필요하다면 final로 상속까지 제한할 수 있다.
  • PhysicalConstants.AVOGADROS_NUMBER처럼 클래스 이름을 명시하면 상수의 출처가 분명해지는 장점도 있다.
  • 이름을 반드시 짧게 써야 한다면 상수 인터페이스 대신 static import를 고려할 수 있다.
  • 인터페이스 내부에 상수가 존재하는 것 자체가 항상 잘못인 것은 아니며, 문제는 상수를 가져다 쓰기 위해 의미 없는 타입 관계를 만드는 것이다.
  • public 상수 역시 공개하는 순간 API의 일부가 될 수 있으므로 접근 범위를 최소화해야 한다.
  • Java 7부터 숫자 리터럴에 _를 사용할 수 있어 1_000_000_000처럼 큰 숫자를 읽기 쉽게 표현할 수 있다.
  • 인터페이스는 구현상의 편의를 위해 사용하는 기능이 아니라 객체의 역할과 타입을 표현하는 공개적인 설계 도구라는 점을 기억해야 한다.

한 줄 정리

인터페이스는 객체가 어떤 타입이고 어떤 역할을 수행할 수 있는지를 표현하는 용도로 사용해야 하며, 단순히 상수를 편하게 가져오기 위한 상수 인터페이스는 타입 계층과 캡슐화를 오염시키므로 상수는 필요한 최소 범위의 클래스나 인스턴스화할 수 없는 유틸리티 클래스에 두는 것이 바람직하다.


© 2020. All rights reserved.

SIKSIK