본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
33장 : Optional과 인터페이스 확장

기본 메서드와 호환성

기존 구현을 깨지 않고 동작을 추가하는 기본 메서드와 `class` 우선·하위 인터페이스·diamond 충돌 해결 규칙을 실행합니다.

인터페이스에 abstract 메서드를 추가하면 기존의 모든 구현 클래스가 컴파일되지 않습니다. 기본 메서드는 인터페이스가 기본 구현을 제공해 기존 바이너리와 소스의 호환성을 높입니다. 구현을 공유하기에는 편리하지만 상태와 불변식이 필요한 핵심 동작까지 무분별하게 넣으면 규칙이 흐려집니다.

기본 메서드 충돌

서로 독립적인 두 인터페이스가 같은 notifyMessage 기본 메서드를 제공하면 Java는 어느 구현을 사용할지 결정할 수 없습니다. 아래 클래스는 실제로 컴파일 오류가 발생합니다.

lab/ConflictingDefaultMethodsFailure.java
interface EmailChannel {
    default String notifyMessage(String value) {
        return "email:" + value;
    }
}

interface SmsChannel {
    default String notifyMessage(String value) {
        return "sms:" + value;
    }
}

public final class ConflictingDefaultMethodsFailure implements EmailChannel, SmsChannel {
    public static void main(String[] args) {
        System.out.println(new ConflictingDefaultMethodsFailure().notifyMessage("hello"));
    }
}

구현 클래스가 메서드를 재정의해 하나를 선택하거나 두 기본 구현을 조합해야 합니다. 충돌한 구현 가운데 하나를 암묵적으로 선택하지 않으므로 인터페이스 규칙의 변경이 안전하게 드러납니다.

default가 인터페이스 진화를 돕는 과정

기존 Notifier에 추상 메서드 send만 있고 EmailNotifierLogNotifier가 이를 구현한다고 가정합니다. 새 sendUrgent를 추상 메서드로 추가하면 두 구현 클래스를 모두 수정해야 합니다. send를 조합한 기본 메서드로 추가하면 기존 구현을 수정하지 않고도 긴급 전송 기능을 제공할 수 있습니다.

src/EvolvingNotifierInterface.java
public final class EvolvingNotifierInterface {
    private interface Notifier {
        void send(String message);

        default void sendUrgent(String message) {
            send("URGENT: " + message);
        }
    }

    private static final class ConsoleNotifier implements Notifier {
        @Override
        public void send(String message) {
            System.out.println("console=" + message);
        }
    }

    public static void main(String[] args) {
        Notifier notifier = new ConsoleNotifier();
        notifier.send("normal");
        notifier.sendUrgent("disk full");
    }
}

기본 메서드는 기존의 추상 메서드 계약만 사용해 구현해야 합니다. 특정 구현 타입으로의 형변환이나 숨겨진 가변 상태에 의존하면 다른 구현에서 깨집니다. 새 기능이 모든 구현에 타당한 기본 의미를 가질 때 적합합니다.

클래스 우선 규칙

상위 클래스의 구체 메서드와 인터페이스의 기본 메서드가 같은 시그니처라면 클래스 메서드가 우선합니다. 인터페이스의 기본 메서드가 Object 계열 동작을 덮어 클래스 계층의 의미를 바꾸지 못하게 하는 규칙입니다.

src/ClassWinsDefaultResolution.java
public final class ClassWinsDefaultResolution {
    private interface Named {
        default String name() {
            return "interface";
        }
    }

    private static class Base {
        public String name() {
            return "class";
        }
    }

    private static final class Child extends Base implements Named {}

    public static void main(String[] args) {
        System.out.println(new Child().name());
    }
}

출력은 class입니다. 상위 클래스의 메서드가 추상 메서드인 경우 등 세부 규칙은 컴파일 결과와 명세로 확인합니다. equals, hashCode, toString 같은 Object 메서드는 기본 메서드로 제공할 수 없습니다.

하위 인터페이스 우선 규칙

Parent를 상속한 Child가 같은 메서드를 재정의하면 더 구체적인 Child의 구현이 우선합니다. 구현 클래스가 ParentChild를 모두 implements에 적더라도 이 관계는 바뀌지 않습니다. 서로 상속 관계가 없는 두 인터페이스의 기본 메서드가 충돌할 때만 구현 클래스가 직접 해결해야 합니다.

src/SpecificDefaultResolution.java
public final class SpecificDefaultResolution {
    private interface BasicFormatter {
        default String format(String value) {
            return value;
        }
    }

    private interface UpperFormatter extends BasicFormatter {
        @Override
        default String format(String value) {
            return value.toUpperCase();
        }
    }

    private static final class ReportFormatter implements UpperFormatter {}

    public static void main(String[] args) {
        System.out.println(new ReportFormatter().format("stream"));
    }
}

결과는 STREAM입니다. 인터페이스 계층 자체가 기능의 구체화를 나타낼 때 이 규칙이 자연스럽습니다.

충돌 해결과 super 호출

구현 클래스는 EmailChannel.super.notifyMessage(value)처럼 원하는 인터페이스의 기본 구현을 호출할 수 있습니다. 두 구현을 합치거나 완전히 새로운 정책을 제공할 수도 있습니다. 이 문법은 해당 인터페이스를 직접 구현한 클래스의 문맥에서만 사용할 수 있습니다.

app/ResolvedNotificationChannels.java
public final class ResolvedNotificationChannels {
    private interface Email {
        default String send(String value) {
            return "email:" + value;
        }
    }

    private interface Sms {
        default String send(String value) {
            return "sms:" + value;
        }
    }

    private static final class Both implements Email, Sms {
        @Override
        public String send(String value) {
            return Email.super.send(value) + " | " + Sms.super.send(value);
        }
    }

    public static void main(String[] args) {
        System.out.println(new Both().send("release"));
    }
}

충돌 해결 방법이 클래스에 명시되므로 API를 읽는 사람이 다중 채널 정책을 확인할 수 있습니다. 두 기본 구현에 부수 효과가 있다면 호출 순서와 부분 실패 정책을 별도로 설계해야 합니다.

기본 메서드를 올바르게 쓰는 범위

기본 메서드는 기존 추상 메서드를 조합한 편의 기능, 호환 가능한 작은 기능 확장, 구현 클래스와 무관한 공통 알고리즘에 적합합니다. 필드를 가질 수 없으므로 상태가 필요한 불변식을 숨길 수 없고 protected 도우미도 둘 수 없습니다. private 인터페이스 메서드를 사용하면 기본 메서드 사이의 구현 중복을 줄일 수 있습니다.

모든 구현에 아무 동작도 하지 않는 기본 메서드를 제공하면 새 기능의 누락이 감춰집니다. 예를 들어 감사 기능을 반드시 구현해야 한다면 추상 메서드를 추가하고 기존 구현을 마이그레이션하는 편이 더 정직합니다. 호환성을 깨더라도 정확성을 우선해야 하는 버전 변경도 있습니다.

API 진화의 소스·바이너리·동작 호환성

기본 메서드를 추가해 컴파일 호환성을 유지하더라도 기존 구현 클래스에 우연히 같은 메서드가 있으면 선택 결과가 달라질 수 있습니다. 하위 라이브러리와의 통합 검증도 수행합니다. 새 기본 메서드가 기존 메서드의 호출 횟수나 예외를 바꾸면 동작 호환성이 깨집니다.

인터페이스를 공개 라이브러리로 배포한다면 메서드 이름, 제네릭 타입 소거, 향후 이름 충돌 가능성을 검토합니다. 단순히 구현 중복을 줄이는 믹스인 용도로 기본 메서드를 남발하지 않습니다.

연습 문제

Exporter의 추상 메서드 export(String)을 이용해 최대 세 번 시도하는 기본 메서드 exportWithRetry를 만드세요. 기존 FileExporter는 새 메서드를 따로 구현하지 않아도 동작해야 합니다. 실패 횟수와 최종 성공을 재현하세요.

정답과 기본 구현의 한계

기본 메서드는 export 결과만 조합하고 구현 상태를 가정하지 않습니다. 실제 재시도에는 백오프, 멱등성, 예외 분류가 더 필요합니다.

exercise/DefaultRetryExporterSolution.java
public final class DefaultRetryExporterSolution {
    private interface Exporter {
        boolean export(String value);

        default boolean exportWithRetry(String value) {
            for (int attempt = 1; attempt <= 3; attempt++) {
                if (export(value)) {
                    return true;
                }
            }
            return false;
        }
    }

    private static final class FlakyExporter implements Exporter {
        private int calls;

        @Override
        public boolean export(String value) {
            calls++;
            System.out.println("attempt=" + calls + ", value=" + value);
            return calls >= 2;
        }
    }

    public static void main(String[] args) {
        Exporter exporter = new FlakyExporter();
        System.out.println("success=" + exporter.exportWithRetry("report"));
    }
}

두 번째 시도에서 successtrue입니다. 인터럽트 예외와 재시도할 수 없는 실패를 boolean 하나로 뭉개지 않는 운영 API가 필요합니다.

기본 메서드는 인터페이스를 발전시키면서 호환성을 유지하는 도구입니다. 클래스 메서드 우선, 더 구체적인 인터페이스 우선, 무관한 인터페이스의 충돌은 명시적 재정의로 해결한다는 규칙을 이해하고 모든 구현에 타당한 기본 동작에만 사용해야 합니다.