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

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

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


아이템 20. 핵심 정리 - 인터페이스의 장점

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

객체지향 설계에서 추상 클래스와 인터페이스는 자주 비교되는 개념이다.

둘 다 공통적인 타입을 정의하고 구현체가 따라야 할 규칙을 제공할 수 있지만, 역할과 확장 방식에는 큰 차이가 있다.

일반적으로 인터페이스는 타입과 규약을 정의하는 역할에 가깝고, 추상 클래스는 공통 구현이나 상태를 일부 제공하면서 하위 클래스의 구현을 유도하는 역할에 가깝다.

하지만 새로운 확장 가능한 타입을 설계할 때는 가능하면 추상 클래스보다 인터페이스를 우선적으로 고려하는 것이 좋다.

인터페이스는 추상 클래스보다 훨씬 유연하게 타입을 확장할 수 있고, 여러 역할을 하나의 클래스에 조합할 수도 있으며, Java 8 이후에는 default 메서드를 통해 일부 기본 구현까지 제공할 수 있기 때문이다.


추상 클래스의 가장 큰 제약은 단일 상속이다

Java 클래스는 하나의 클래스만 상속할 수 있다.

public class Child extends Parent {
}

이미 어떤 클래스를 상속하고 있다면 다른 클래스를 추가로 상속할 수 없다.

public class Child extends Parent, AnotherParent {
}

이런 코드는 Java에서 허용되지 않는다.

따라서 어떤 기능을 추상 클래스로 제공하면 그 기능을 사용하는 클래스는 자신의 상속 슬롯 하나를 소비해야 한다.

예를 들어 다음과 같은 추상 클래스가 있다고 가정해보자.

public abstract class AbstractLogger {

    public abstract void log(String message);
}

그리고 어떤 클래스가 이미 다른 클래스를 상속하고 있다.

public class OrderService extends BaseService {
}

OrderServiceAbstractLogger의 기능까지 추가하고 싶어도 다음과 같이 작성할 수 없다.

public class OrderService
        extends BaseService, AbstractLogger {
}

이것이 추상 클래스를 기능 확장 수단으로 사용할 때의 가장 큰 제약이다.


인터페이스는 기존 상속 구조와 관계없이 추가할 수 있다

반면 인터페이스는 여러 개를 구현할 수 있다.

public class OrderService
        extends BaseService
        implements Logger, Auditable, AutoCloseable {
}

기존에 어떤 클래스를 상속하고 있더라도 인터페이스를 추가하는 데는 문제가 없다.

즉 인터페이스는 기존 클래스 계층 구조를 거의 건드리지 않고 새로운 역할을 추가할 수 있다.

구조를 비교하면 차이가 명확하다.

추상 클래스

BaseService
     ↑
OrderService

다른 추상 클래스 추가 불가능

반면 인터페이스는 다음과 같다.

             Logger
               ↑
               │
BaseService ← OrderService → Auditable
               │
               ↓
          AutoCloseable

하나의 클래스가 여러 가지 역할을 동시에 표현할 수 있다.


인터페이스는 기존 클래스에도 새로운 타입을 쉽게 부여할 수 있다

인터페이스의 또 다른 장점은 이미 만들어진 클래스라도 쉽게 새로운 타입으로 확장할 수 있다는 것이다.

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

public interface Printable {

    void print();
}

기존 클래스는 단순히 인터페이스를 구현하면 된다.

public class Invoice implements Printable {

    @Override
    public void print() {
        System.out.println("Invoice 출력");
    }
}

추상 클래스를 사용했다면 해당 클래스의 기존 상속 관계를 고려해야 하지만, 인터페이스는 비교적 자유롭게 추가할 수 있다.

이러한 특성 때문에 역할을 표현하는 타입을 설계할 때 인터페이스가 훨씬 유연하다.


Java 8부터 인터페이스도 기본 구현을 제공할 수 있다

과거에는 인터페이스와 추상 클래스의 차이가 더욱 뚜렷했다.

인터페이스는 거의 메서드 선언만 제공했고,

public interface TimeClient {

    void setTime(int hour, int minute, int second);
}

구현 클래스가 모든 메서드를 직접 구현해야 했다.

반면 추상 클래스는 메서드 구현을 제공할 수 있었다.

하지만 Java 8부터 인터페이스에 default method가 추가되면서 상황이 많이 달라졌다.

public interface TimeClient {

    void setTime(
            int hour,
            int minute,
            int second
    );

    default void printTime() {
        System.out.println("현재 시간을 출력한다.");
    }
}

default 키워드를 사용하면 인터페이스가 직접 메서드 구현을 제공할 수 있다.


default method가 필요한 이유

인터페이스가 이미 여러 클래스에 공개되어 있다고 가정해보자.

public interface TimeClient {

    LocalDateTime getLocalDateTime();
}

그리고 여러 구현체가 존재한다.

public class SimpleTimeClient
        implements TimeClient {

    @Override
    public LocalDateTime getLocalDateTime() {
        return LocalDateTime.now();
    }
}

시간이 지나면서 TimeClient에 새로운 기능이 필요해졌다.

예를 들어 특정 시간대를 적용한 시간을 얻는 기능을 추가하고 싶다고 해보자.

ZonedDateTime getZonedDateTime(String zoneString);

이를 그냥 추상 메서드로 추가하면 기존 구현 클래스들은 모두 영향을 받는다.

public interface TimeClient {

    LocalDateTime getLocalDateTime();

    ZonedDateTime getZonedDateTime(
            String zoneString
    );
}

기존 SimpleTimeClient는 새로운 메서드를 구현하지 않았기 때문에 컴파일 오류가 발생한다.

TimeClient 변경

        ↓

새 추상 메서드 추가

        ↓

기존 구현체들이 모두 새로운 메서드를 구현해야 함

공개된 인터페이스라면 어떤 클래스가 이 인터페이스를 구현하고 있는지 전부 알 수 없을 수도 있다.

이런 상황에서 인터페이스 변경은 큰 호환성 문제가 된다.


default method를 사용하면 기존 구현체를 깨뜨리지 않을 수 있다

새로운 기능에 합리적인 기본 구현을 제공할 수 있다면 default 메서드를 사용할 수 있다.

public interface TimeClient {

    LocalDateTime getLocalDateTime();

    default ZonedDateTime getZonedDateTime(
            String zoneString
    ) {
        return ZonedDateTime.of(
                getLocalDateTime(),
                getZoneId(zoneString)
        );
    }
}

이제 기존 구현체는 새로운 메서드를 직접 구현하지 않아도 된다.

public class SimpleTimeClient
        implements TimeClient {

    @Override
    public LocalDateTime getLocalDateTime() {
        return LocalDateTime.now();
    }
}

하지만 다음 코드는 사용할 수 있다.

TimeClient client =
        new SimpleTimeClient();

client.getZonedDateTime(
        "..."
);

즉 인터페이스에 새로운 기능을 추가하면서 기존 구현체에 기본 동작을 제공할 수 있다.


인터페이스의 static method

인터페이스에는 static 메서드도 정의할 수 있다.

예를 들어 문자열을 받아 ZoneId를 구하는 로직을 인터페이스 내부에 둘 수 있다.

public interface TimeClient {

    LocalDateTime getLocalDateTime();

    default ZonedDateTime getZonedDateTime(
            String zoneString
    ) {
        return ZonedDateTime.of(
                getLocalDateTime(),
                getZoneId(zoneString)
        );
    }

    static ZoneId getZoneId(
            String zoneString
    ) {
        try {
            return ZoneId.of(zoneString);
        } catch (DateTimeException e) {
            System.err.println(
                    "잘못된 ZoneId: "
                            + zoneString
            );

            return ZoneId.systemDefault();
        }
    }
}

default 메서드는 구현 객체가 사용할 기본 동작을 제공하고, static 메서드는 인터페이스와 관련된 보조 기능을 제공하는 식으로 사용할 수 있다.


default method 덕분에 인터페이스도 진화할 수 있다

과거에는 공개 인터페이스에 새로운 메서드를 추가하는 것이 매우 부담스러웠다.

인터페이스 변경

↓

모든 구현체 변경

하지만 기본 구현이 가능한 기능이라면 default 메서드를 통해 다음 구조로 만들 수 있다.

인터페이스 변경

↓

default method 추가

↓

기존 구현체 유지

↓

필요한 구현체만 직접 재정의

예를 들어 특별한 동작이 필요한 구현체만 다시 구현하면 된다.

public class CustomTimeClient
        implements TimeClient {

    @Override
    public LocalDateTime getLocalDateTime() {
        return LocalDateTime.now();
    }

    @Override
    public ZonedDateTime getZonedDateTime(
            String zoneString
    ) {
        // 특별한 로직
        return ...;
    }
}

즉 기본 동작을 제공하면서 필요한 구현체에는 확장 가능성을 열어둘 수 있다.


default method가 추상 클래스를 완전히 대체하는 것은 아니다

인터페이스에 기본 구현을 넣을 수 있다고 해서 추상 클래스가 필요 없어지는 것은 아니다.

인터페이스에는 구현상의 한계가 존재한다.

대표적인 것이 인스턴스 필드를 사용할 수 없다는 점이다.

예를 들어 다음과 같이 상태를 가지고 싶다고 해보자.

private int count;

인터페이스에는 이런 인스턴스 상태를 가질 수 없다.

따라서 다음처럼 상태에 의존하는 공통 구현이 필요하다면 인터페이스의 default 메서드만으로 해결하기 어렵다.

public abstract class AbstractCounter {

    private int count;

    protected void increase() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

이런 경우에는 추상 클래스가 여전히 유용하다.


인터페이스와 추상 골격 구현을 함께 사용할 수 있다

인터페이스의 장점과 추상 클래스의 장점을 동시에 가져가는 방법도 있다.

개념적으로 다음 구조다.

Interface
    ↑
    │
Abstract Implementation
    ↑
    │
Concrete Implementation

인터페이스는 타입과 계약을 제공하고,

public interface MyInterface {
}

추상 클래스는 반복되는 구현을 제공한다.

public abstract class AbstractMyInterface
        implements MyInterface {
}

실제 구현체는 필요한 경우 추상 골격 클래스를 상속할 수 있다.

public class MyImplementation
        extends AbstractMyInterface {
}

이 방식은 인터페이스가 제공하는 유연성을 유지하면서도 공통 구현을 재사용할 수 있는 방법이다.


인터페이스는 믹스인 정의에 적합하다

인터페이스가 추상 클래스보다 유리한 또 하나의 이유는 믹스인(Mixin) 을 정의하기 좋다는 것이다.

믹스인은 어떤 클래스의 주된 역할과 별개로 추가적인 능력이나 특성을 부여하는 타입이라고 이해할 수 있다.

예를 들어 어떤 클래스의 본래 역할이 파일 처리라고 해보자.

public class FileResource {
}

이 클래스가 자원을 닫을 수 있다는 추가적인 특성을 표현하고 싶다면 AutoCloseable을 구현할 수 있다.

public class FileResource
        implements AutoCloseable {

    @Override
    public void close() {
        // 자원 정리
    }
}

AutoCloseable은 이 클래스의 본질적인 부모 타입이라기보다

"이 객체는 닫을 수 있다."

라는 추가적인 능력을 표현한다.


하나의 클래스에 여러 믹스인을 추가할 수 있다

인터페이스를 이용하면 다음과 같은 설계가 가능하다.

public class Resource
        implements AutoCloseable,
                   Serializable,
                   Comparable<Resource> {

    @Override
    public void close() {
    }

    @Override
    public int compareTo(Resource other) {
        return 0;
    }
}

하나의 클래스에 여러 역할을 조합할 수 있다.

Resource의 주 역할

+

AutoCloseable

+

Serializable

+

Comparable

추상 클래스는 단일 상속 제한 때문에 이런 용도로 사용하기 어렵다.


추상 클래스를 믹스인으로 사용하기 어려운 이유

다음과 같은 추상 클래스가 있다고 해보자.

public abstract class AbstractCloseable {

    public abstract void close();
}

어떤 클래스가 이미 다른 클래스를 상속하고 있다면

public class Resource
        extends BaseResource {
}

AbstractCloseable을 추가로 상속할 수 없다.

따라서

부가 능력

을 표현하는 타입에는 인터페이스가 훨씬 자연스럽다.


계층 구조가 명확하지 않은 타입도 인터페이스로 표현할 수 있다

객체들의 관계가 항상 깔끔한 상속 계층으로 표현되는 것은 아니다.

예를 들어 다음과 같은 관계는 비교적 명확할 수 있다.

Shape
  ↑
Rectangle

하지만 현실의 역할들은 항상 이렇게 수직적인 구조를 가지지는 않는다.

대표적인 예가 다음 두 역할이다.

Singer
Songwriter

노래를 부르는 사람과 노래를 만드는 사람은 서로 상하 관계가 아니다.

Singer is-a Songwriter

도 아니고

Songwriter is-a Singer

도 아니다.

하지만 어떤 사람은 두 역할을 모두 수행할 수 있다.


Singer와 Songwriter를 인터페이스로 표현해보자

public interface Singer {

    void sing();

    String sing(String song);
}
public interface Songwriter {

    String compose(int chartPosition);
}

두 인터페이스 사이에는 직접적인 상속 관계가 없다.

하지만 두 능력을 모두 가진 타입을 만들 수 있다.

public interface SingerSongwriter
        extends Singer, Songwriter {

    void strum();

    void actSensitive();
}

하나의 인터페이스가 여러 인터페이스를 상속할 수 있기 때문이다.


인터페이스는 역할을 조합하기 좋다

이 구조를 보면 인터페이스가 왜 계층 구조가 불분명한 타입을 표현하는 데 유리한지 알 수 있다.

Singer          Songwriter
    \              /
     \            /
      SingerSongwriter

두 타입이 수직적인 관계는 아니지만 새로운 타입에서 둘을 결합할 수 있다.

추상 클래스만으로 표현하려고 하면 어느 클래스를 상속해야 하는지부터 문제가 생긴다.

AbstractSinger

AbstractSongwriter

Java는 두 클래스를 동시에 상속할 수 없기 때문이다.


인터페이스는 타입 조합의 자유도를 높인다

필요하다면 더 많은 인터페이스를 조합할 수도 있다.

public interface Performer {
    void perform();
}
public interface Producer {
    void produce();
}

그리고 다음과 같이 만들 수 있다.

public interface Artist
        extends Singer,
                Songwriter,
                Performer,
                Producer {
}

인터페이스에서는 이런 조합이 자연스럽다.

즉 클래스 계층을 억지로 만들어내지 않고 능력과 역할을 중심으로 타입을 설계할 수 있다.


인터페이스는 래퍼 클래스와도 잘 어울린다

아이템 18에서 살펴봤던 래퍼 클래스와 합성 구조 역시 인터페이스의 강점을 잘 보여준다.

대표적인 예가 ForwardingSet이다.

public class ForwardingSet<E>
        implements Set<E> {

    private final Set<E> set;

    public ForwardingSet(Set<E> set) {
        this.set = set;
    }

    @Override
    public boolean add(E e) {
        return set.add(e);
    }

    @Override
    public boolean addAll(
            Collection<? extends E> c
    ) {
        return set.addAll(c);
    }

    // 나머지 Set 메서드도 전달
}

여기서 핵심은 구체 클래스가 아니라

Set<E>

인터페이스를 사용한다는 것이다.


래퍼를 이용해 새로운 기능을 추가할 수 있다

ForwardingSet 위에 새로운 기능을 추가한다.

public class InstrumentedSet<E>
        extends ForwardingSet<E> {

    private int addCount;

    public InstrumentedSet(Set<E> set) {
        super(set);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(
            Collection<? extends E> c
    ) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

사용할 때는 다음과 같다.

Set<String> set =
        new InstrumentedSet<>(
                new HashSet<>()
        );

HashSet을 상속하지 않고 내부 객체로 사용한다.


구현 상속과 비교해보자

상속을 직접 사용하면 다음처럼 될 수 있다.

public class InstrumentedHashSet<E>
        extends HashSet<E> {

    private int addCount;

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(
            Collection<? extends E> c
    ) {
        addCount += c.size();
        return super.addAll(c);
    }
}

이 경우 상위 클래스의 내부 구현 방식에 따라 하위 클래스의 동작이 영향을 받을 수 있다.

예를 들어 HashSet.addAll()이 내부에서 add()를 호출한다면 카운트가 중복으로 증가하는 문제가 생길 수 있다.

addAll(3개)

↓

addCount += 3

↓

super.addAll()

↓

내부 add() 3회 호출

↓

addCount += 3

↓

최종 6

상위 클래스의 구현에 의존하기 때문에 캡슐화가 약해진다.


인터페이스 기반 래퍼는 구현에 덜 의존한다

반면 ForwardingSet은 다음 관계를 가진다.

InstrumentedSet
      ↓
ForwardingSet
      ↓
Set 인터페이스
      ↓
HashSet

InstrumentedSet이 직접 의존하는 것은 HashSet 내부 구현이 아니다.

Set 인터페이스가 제공하는 계약이다.

return set.addAll(c);

따라서 내부 HashSetaddAll()을 어떻게 구현하고 있는지 알아야 할 필요가 줄어든다.


구현체를 자유롭게 교체할 수도 있다

인터페이스를 기준으로 설계했기 때문에 다양한 구현체를 사용할 수 있다.

new InstrumentedSet<>(
        new HashSet<>()
);
new InstrumentedSet<>(
        new TreeSet<>()
);
new InstrumentedSet<>(
        new LinkedHashSet<>()
);

InstrumentedSet의 코드는 바뀌지 않는다.

InstrumentedSet
        ↓
      Set
     / | \
HashSet TreeSet LinkedHashSet

이것이 인터페이스 기반 설계의 큰 장점이다.


상위 클래스에 새로운 메서드가 추가되는 경우

구현 상속에서는 상위 클래스에 새로운 메서드가 추가되더라도 하위 클래스가 그 사실을 명확하게 알아차리지 못할 수 있다.

예를 들어 하위 클래스가 모든 요소 추가 연산을 검사하도록 설계했다고 하자.

요소 추가
   ↓
검사
   ↓
실제 추가

그런데 미래에 상위 클래스에 새로운 요소 추가 메서드가 생긴다면 새 메서드는 기존 검사 로직을 우회할 수 있다.

새로운 추가 메서드
        ↓
검사 로직을 거치지 않음
        ↓
원하지 않는 상태 발생 가능

이것이 구현 상속의 위험 중 하나다.


인터페이스 변경은 구현체에게 더 명시적으로 드러난다

인터페이스에 새로운 추상 메서드가 추가된다면 구현 클래스는 그 메서드를 구현해야 한다.

public interface SetLike {

    void add(Object value);

    void addSafely(Object value);
}

기존 구현체가 addSafely()를 구현하지 않았다면 컴파일 단계에서 바로 문제가 드러난다.

인터페이스 계약 변경

↓

구현체 컴파일 오류

↓

변경 사실 즉시 인지

즉 문제를 조용히 숨기기보다는 개발자에게 변경 사실을 드러낼 수 있다.

물론 default 메서드를 추가하는 경우에는 기존 구현체가 자동으로 기본 구현을 물려받기 때문에 별도의 검토가 필요할 수 있다.


추상 클래스와 인터페이스 비교

구분추상 클래스인터페이스
클래스에서 사용extendsimplements
다중 사용불가능가능
기존 상속 구조와 조합제약이 큼자유로움
인스턴스 필드가능불가능
생성자가능없음
기본 메서드 구현가능default로 가능
정적 메서드가능가능
믹스인부적합한 경우가 많음적합
타입 조합제약이 큼매우 유연
공통 상태 공유가능직접 불가능
래퍼와 조합가능특히 유연함

둘 중 하나가 항상 절대적으로 좋다는 의미는 아니다.

중요한 것은 새로운 타입의 기본 계약을 설계할 때는 인터페이스를 먼저 고려하는 것이다.


default method는 언제 적합할까?

default 메서드는 다음처럼 인터페이스에 자연스럽게 정의할 수 있는 동작에 적합하다.

public interface Calculator {

    int calculate(int a, int b);

    default int twice(int value) {
        return calculate(value, value);
    }
}

기존 추상 메서드만 이용해 구현할 수 있기 때문에 인터페이스 자체에서 합리적인 기본 동작을 제공할 수 있다.


default method가 적합하지 않은 경우

반대로 객체마다 개별적인 상태를 관리해야 한다면 어렵다.

다음과 같은 상태가 필요하다고 해보자.

private int requestCount;

그리고 메서드가 이 값을 계속 수정해야 한다.

requestCount++;

인터페이스에서는 이런 인스턴스 필드를 가질 수 없기 때문에 default 메서드만으로 구현을 제공하기 어렵다.

이런 경우에는 추상 골격 클래스 같은 방법을 고려할 수 있다.

인터페이스

+

추상 골격 구현

+

구체 구현

인터페이스의 유연성과 추상 클래스의 구현 재사용 능력을 함께 사용할 수 있다.


인터페이스를 우선적으로 고려해야 하는 이유

지금까지 내용을 정리하면 인터페이스에는 다음과 같은 강점이 있다.

기존 클래스 계층에 쉽게 추가할 수 있다

이미 다른 클래스를 상속하고 있어도 상관없다.

class A extends B
        implements C, D, E {
}

여러 역할을 동시에 표현할 수 있다

class Resource
        implements AutoCloseable,
                   Comparable<Resource> {
}

믹스인을 정의하기 좋다

클래스의 주요 타입과 별개로 추가적인 능력을 부여할 수 있다.


계층 구조가 불명확한 타입을 조합할 수 있다

Singer + Songwriter

처럼 수직적인 상속 관계가 아닌 역할도 조합할 수 있다.


Java 8 이후 기본 구현도 제공할 수 있다

default void doSomething() {
}

기본 구현이 명확하다면 추상 클래스 없이도 상당한 공통 동작을 제공할 수 있다.


래퍼 클래스와 조합하기 좋다

구체 구현을 상속하지 않고 인터페이스의 계약을 기준으로 기능을 확장할 수 있다.


그렇다고 추상 클래스를 사용하지 말라는 것은 아니다

이번 아이템의 제목은

추상 클래스를 사용하지 마라

가 아니다.

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

인터페이스만으로 충분하다면 인터페이스를 사용하는 것이 좋다.

하지만 다음과 같은 요구가 있다면 추상 클래스가 필요할 수 있다.

공통 인스턴스 상태가 필요하다.

생성 과정을 공통화해야 한다.

protected 멤버를 통해 구현을 제공해야 한다.

default method만으로 구현하기 어려운 복잡한 공통 로직이 있다.

이런 경우에는 인터페이스를 공개 타입으로 제공하고 별도의 추상 골격 구현을 함께 제공하는 방법을 고려할 수 있다.


핵심 정리

  • 추상 클래스와 인터페이스는 모두 공통 타입과 구현을 표현하는 데 사용될 수 있다.
  • 새로운 타입을 설계할 때는 가능하면 추상 클래스보다 인터페이스를 우선적으로 고려하는 것이 좋다.
  • Java 클래스는 하나의 클래스만 상속할 수 있기 때문에 추상 클래스는 기존 상속 구조에 큰 제약을 준다.
  • 인터페이스는 여러 개를 동시에 구현할 수 있고 기존 상속 관계와도 함께 사용할 수 있다.
  • Java 8부터 인터페이스에 default 메서드를 정의할 수 있어 일부 기본 구현도 제공할 수 있다.
  • default 메서드를 활용하면 기존 구현 클래스를 바로 깨뜨리지 않으면서 인터페이스를 확장할 수 있는 경우가 있다.
  • 인터페이스에는 static 메서드도 정의할 수 있다.
  • 인터페이스에는 인스턴스 필드를 둘 수 없기 때문에 상태가 필요한 기본 구현에는 한계가 있다.
  • 이런 경우 인터페이스와 추상 골격 클래스를 함께 사용하는 방법을 고려할 수 있다.
  • 인터페이스는 AutoCloseable처럼 클래스의 주요 역할과 별개인 부가 능력을 표현하는 믹스인에 적합하다.
  • 인터페이스는 SingerSongwriter처럼 수직적인 계층 관계가 없는 역할도 자유롭게 조합할 수 있다.
  • 인터페이스는 래퍼 클래스와 함께 사용하면 구현 상속보다 안전하게 기능을 확장할 수 있다.
  • 구현 상속은 상위 클래스의 내부 구현 변경에 하위 클래스가 영향을 받을 수 있지만 인터페이스 기반 래퍼는 인터페이스의 계약을 중심으로 동작한다.
  • 인터페이스에 새로운 추상 메서드가 추가되면 구현체가 컴파일 단계에서 변경 사실을 알 수 있다.
  • 인터페이스가 항상 추상 클래스보다 우월한 것은 아니며 상태 공유나 복잡한 공통 구현이 필요하면 추상 클래스가 적절할 수 있다.
  • 중요한 것은 타입의 공개 계약은 인터페이스를 우선하고 필요한 경우 구현 재사용을 위한 추상 클래스를 함께 제공하는 방향으로 설계하는 것이다.

한 줄 요약

인터페이스는 단일 상속이라는 클래스의 제약에서 자유롭고 여러 역할을 조합할 수 있으며 default 메서드를 통해 기본 구현까지 제공할 수 있으므로, 새로운 확장 가능한 타입을 설계할 때는 추상 클래스보다 인터페이스를 먼저 고려하는 것이 더 유연하고 안전하다.


© 2020. All rights reserved.

SIKSIK