본문 바로가기

기본 메서드와 호환성

구현 클래스가 많은 인터페이스에 default 메서드로 기능을 더하는 방법, 한 메서드를 여러 곳에서 물려받을 때 구현이 정해지는 규칙, 소스·바이너리 호환성의 차이를 예제로 확인합니다.

알림을 보내는 Notifier 인터페이스를 만들어 배포했고, 여러 팀이 이 인터페이스를 구현해 이메일 알림, 문자 알림, 로그 알림을 쓰고 있다고 해 봅시다. 이제 모든 알림에 '긴급 전송' 기능을 넣고 싶습니다. 인터페이스에 메서드 하나만 추가하면 될 것 같지만, 그 순간 다른 팀의 구현 클래스가 모두 컴파일되지 않습니다. 추상 클래스와 인터페이스에서 보았듯이 default 메서드(기본 메서드)는 바로 이 문제를 풀려고 Java 8에 도입되었습니다.

이 글에서는 default 메서드로 인터페이스를 넓히는 과정을 직접 따라가 보고, 인터페이스 다중 구현에서 간단히 본 충돌 해결 규칙을 규칙만으로는 헷갈리기 쉬운 경우까지 넓혀 살펴봅니다. 마지막으로 '기존 코드와 호환된다'는 말이 소스 코드와 컴파일된 클래스 파일, 실행 결과에 대해 각각 무엇을 뜻하는지 정리합니다.

추상 메서드를 추가하면 기존 구현이 컴파일되지 않는다

먼저 default 없이 추상 메서드를 추가하면 무슨 일이 생기는지 보겠습니다. 아래 코드는 원래 send만 있던 Notifier에 sendUrgent를 추상 메서드로 추가한 상황입니다. ConsoleNotifier는 그 전에 만든 구현이라 send만 구현합니다.

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

        void sendUrgent(String 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) {
        new ConsoleNotifier().send("normal");
    }
}
컴파일 오류
lab/AbstractMethodAddedFailure.java:8: error: ConsoleNotifier is not abstract and does not override abstract method sendUrgent(String) in Notifier
    private static final class ConsoleNotifier implements Notifier {
                         ^
1 error

추상 메서드는 구현 클래스가 반드시 채워야 하는 메서드이므로, 인터페이스에 하나가 늘면 그것을 구현하지 않은 클래스는 모두 오류가 납니다. 같은 프로젝트 안의 코드라면 함께 고치면 되지만, 인터페이스를 라이브러리로 배포했다면 구현 클래스는 다른 팀이나 다른 회사의 코드에 있습니다. 라이브러리를 새 버전으로 올리는 순간 그쪽 빌드가 깨지는 것입니다.

다만 모든 구현 클래스가 영향을 받지는 않습니다. 알맞은 메서드를 이미 직접 선언했거나 부모 클래스에서 물려받은 클래스는 그대로 컴파일됩니다. 추상 클래스도 구현을 하위 클래스로 미룰 수 있으므로 오류가 나지 않습니다. 오류는 새 메서드의 구현이 어디에도 없는 구체 클래스에서 납니다.

default 메서드로 기능 추가하기

같은 기능을 default 메서드로 추가하면 다음과 같습니다. 긴급 전송은 결국 메시지 앞에 URGENT:를 붙여 보내는 일이므로, 이미 있는 추상 메서드 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");
    }
}

main은 같은 notifier로 원래 있던 send와 새로 생긴 sendUrgent를 한 번씩 부릅니다. 두 줄이 모두 console=로 시작한다면 두 호출 모두 결국 ConsoleNotifier.send에 도착했다는 뜻입니다.

console=normal
console=URGENT: disk full

ConsoleNotifier는 sendUrgent를 선언하지 않았는데도 둘째 줄이 출력됩니다. notifier.sendUrgent("disk full")을 부르면 인터페이스의 default 구현이 실행되고, 그 안의 send("URGENT: " + message)는 this.send(...)와 같습니다. 이때 this는 ConsoleNotifier 객체이므로 실제로 실행되는 것은 ConsoleNotifier.send이고, 그래서 console=URGENT: disk full이 찍힙니다. 인터페이스는 무엇을 어떤 순서로 부를지만 정하고, 실제 전송은 각 구현에 맡긴 것입니다. 이메일 구현이라면 같은 default 메서드가 긴급 메일을 보내게 됩니다.

이처럼 좋은 default 메서드는 그 인터페이스의 추상 메서드만으로 구현됩니다. 특정 구현 클래스로 형변환하면 그 클래스가 아닌 구현에서 깨지고, static 필드 같은 공유 상태에 기대면 모든 구현 객체가 그 상태를 함께 써서 서로 영향을 주게 됩니다. 어떤 구현에 더 나은 방법이 있다면 그 클래스가 오버라이딩하면 됩니다. 예를 들어 긴급 전용 API를 제공하는 문자 서비스라면 sendUrgent를 직접 구현해 그 API를 쓰게 할 수 있습니다.

default 메서드는 본문을 가질 수 있을 뿐, 클래스의 메서드와 같지는 않습니다. 인터페이스에는 인스턴스 필드가 없고, 필드를 선언하면 모두 public static final 상수가 됩니다. 그래서 default 메서드는 객체마다 다른 상태를 직접 저장할 수 없고, 상태가 필요하면 추상 메서드를 통해 구현 클래스에서 빌려 와야 합니다. 또 인터페이스의 메서드는 public이나 private만 될 수 있으므로, 하위 타입에만 공개하는 protected 도우미는 둘 수 없습니다. 여러 default 메서드가 함께 쓰는 코드는 Java 9부터 쓸 수 있는 private 메서드로 묶습니다.

같은 메서드를 여러 곳에서 물려받을 때

클래스는 부모 클래스 하나와 인터페이스 여러 개를 함께 물려받으므로, 같은 시그니처의 메서드가 여러 곳에서 올 수 있습니다. 인터페이스 다중 구현에서 본 것처럼 자바는 다음 순서로 어느 구현을 쓸지 정합니다.

  1. 클래스가 이깁니다. 클래스 자신이나 부모 클래스에 그 메서드가 있으면 인터페이스의 default 구현보다 우선합니다.
  2. 더 구체적인 인터페이스가 이깁니다. 하위 인터페이스가 다시 정의한 구현이 상위 인터페이스의 구현보다 우선합니다.
  3. 그래도 하나로 정해지지 않으면 클래스가 직접 오버라이딩해서 정해야 합니다.

여기서는 규칙마다 예제를 하나씩 보고, 규칙을 외우기만 해서는 틀리기 쉬운 경우를 함께 살펴봅니다.

규칙 1: 클래스의 메서드가 이긴다

다음 코드의 Child는 부모 클래스와 인터페이스 양쪽에서 같은 name()을 물려받습니다. Child 자신은 본문이 비어 있어 name()을 따로 선언하지 않습니다.

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와 interface를 돌려주므로, 출력된 문자열이 곧 선택된 구현입니다. 실행 결과는 다음과 같습니다.

class

Child는 부모 클래스 Base에서 public String name()을, 인터페이스 Named에서 같은 시그니처의 default 메서드를 물려받습니다. 클래스의 메서드가 우선하므로 출력은 class입니다. 이때 Base의 name()은 Named의 메서드를 구현하는 역할도 하므로 public이어야 합니다.

이 규칙은 기존 클래스 계층의 동작을 지키려고 있습니다. 어떤 인터페이스에 default 메서드가 새로 생겼다고 해서, 이미 잘 동작하던 클래스의 메서드가 다른 구현으로 바뀌면 곤란하기 때문입니다. 이 규칙 때문에 생기는 제약도 있습니다. toString, equals, hashCode처럼 Object의 public 메서드는 default 메서드로 정의할 수 없습니다.

lab/ObjectMethodDefaultFailure.java
public final class ObjectMethodDefaultFailure {
    private interface Describable {
        String title();

        default String toString() {
            return "Describable[" + title() + "]";
        }
    }

    public static void main(String[] args) {
        Describable item = () -> "stream";
        System.out.println(item);
    }
}
컴파일 오류
lab/ObjectMethodDefaultFailure.java:5: error: default method toString in interface Describable overrides a member of java.lang.Object
        default String toString() {
                       ^
1 error

모든 클래스는 Object를 상속하므로, 이런 default 메서드를 허용하더라도 규칙 1에 따라 언제나 Object나 그 하위 클래스의 메서드가 이겨서 실제로 쓰일 일이 없습니다. 그래서 Java 언어 명세는 이것을 아예 컴파일 오류로 정해 두었습니다(JLS §9.4.1.2).

규칙 1을 '클래스 쪽에 그 메서드가 있기만 하면 이긴다'로 외우면 틀리는 경우가 있습니다. 부모 클래스가 같은 메서드를 추상 메서드로 선언했다면, 인터페이스에 default 구현이 있어도 그 빈자리를 채워 주지 않습니다.

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

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

    private static final class Child extends Base implements Named {}

    public static void main(String[] args) {
        System.out.println(new Child().name());
    }
}
컴파일 오류
lab/AbstractBaseBlocksDefault.java:12: error: Child is not abstract and does not override abstract method name() in Base
    private static final class Child extends Base implements Named {}
                         ^
1 error

Base의 추상 메서드는 '하위 클래스가 구현하라'는 요구입니다. 클래스 쪽 선언이 인터페이스보다 우선하므로 이 요구가 그대로 살아 있고, 그래서 구체 클래스인 Child가 name()을 직접 구현해야 합니다. Named의 구현을 그대로 쓰고 싶다면 Child에서 name()을 오버라이딩하고 그 안에서 Named.super.name()을 부르면 됩니다.

규칙 2: 더 구체적인 인터페이스가 이긴다

이번에는 상속 관계가 있는 두 인터페이스가 같은 format의 구현을 각각 가진 경우입니다. ReportFormatter는 UpperFormatter만 구현하지만, UpperFormatter가 BasicFormatter를 상속하므로 두 구현을 모두 물려받을 수 있는 자리에 있습니다.

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"));
    }
}

BasicFormatter의 구현은 값을 그대로, UpperFormatter의 구현은 대문자로 돌려줍니다. 그러니 출력이 대문자인지만 보면 어느 구현이 선택됐는지 알 수 있습니다.

STREAM

UpperFormatter는 BasicFormatter를 상속하면서 format을 다시 정의했습니다. ReportFormatter가 물려받을 수 있는 두 구현 가운데 UpperFormatter의 것이 더 구체적이므로 결과는 STREAM입니다. ReportFormatter가 implements BasicFormatter, UpperFormatter처럼 두 인터페이스를 모두 적어도 결과는 같습니다. 하위 인터페이스가 상위 인터페이스의 구현을 이미 덮어썼기 때문에, 상위 인터페이스를 따로 적었다고 해서 그 구현이 되살아나지는 않습니다.

하위 인터페이스는 상위의 default 메서드를 다시 추상 메서드로 선언할 수도 있습니다. 이 하위 인터페이스를 구현하는 클래스는 그 메서드를 반드시 직접 구현하라는 뜻입니다.

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

    private interface StrictFormatter extends BasicFormatter {
        @Override
        String format(String value); // default를 다시 추상 메서드로 선언
    }

    private static final class ReportFormatter implements StrictFormatter {}

    public static void main(String[] args) {
        System.out.println(new ReportFormatter().format("stream"));
    }
}
컴파일 오류
lab/ReabstractedFormatterFailure.java:13: error: ReportFormatter is not abstract and does not override abstract method format(String) in StrictFormatter
    private static final class ReportFormatter implements StrictFormatter {}
                         ^
1 error

규칙 2에 따라 더 구체적인 StrictFormatter의 선언이 이기는데, 그 선언이 추상 메서드입니다. 그래서 BasicFormatter의 default 구현은 쓰이지 않고 ReportFormatter가 format을 구현해야 합니다. 규칙 1의 추상 부모 클래스와 같은 원리입니다. 먼저 어느 선언이 이기는지를 정하고, 이긴 선언이 추상 메서드라면 구체 클래스가 구현해야 합니다.

규칙 3: 정해지지 않으면 클래스가 고른다

마지막은 서로 관련 없는 두 인터페이스가 같은 default 메서드를 가진 경우입니다. 클래스가 두 인터페이스를 함께 구현하기만 하고 아무것도 정하지 않으면 다음과 같은 오류가 납니다.

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"));
    }
}
컴파일 오류
lab/ConflictingDefaultMethodsFailure.java:13: error: types EmailChannel and SmsChannel are incompatible;
public final class ConflictingDefaultMethodsFailure implements EmailChannel, SmsChannel {
             ^
  class ConflictingDefaultMethodsFailure inherits unrelated defaults for notifyMessage(String) from types EmailChannel and SmsChannel
1 error

EmailChannel과 SmsChannel은 서로 상속 관계가 없으므로 어느 쪽도 더 구체적이지 않고, 클래스 자신에게도 notifyMessage가 없습니다. 규칙 1과 2로는 답이 나오지 않으니, 컴파일러는 둘 중 하나를 임의로 고르지 않고 오류를 냅니다. 덕분에 한쪽 인터페이스에 생긴 변경이 다른 쪽 구현을 몰래 밀어내는 일 없이, 충돌이 생긴 그 자리에서 드러납니다.

해결하려면 클래스가 메서드를 오버라이딩합니다. 완전히 새로 구현해도 되고, 인터페이스이름.super.메서드() 문법으로 특정 인터페이스의 default 구현을 불러 쓸 수도 있습니다.

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"));
    }
}

이번에는 Both가 send를 직접 구현했으므로 컴파일 오류 없이 실행됩니다. 출력 한 줄에 두 인터페이스의 결과가 모두 들어 있는지 보세요.

email:release | sms:release

Both의 send는 Email.super.send(value)로 email:release를, Sms.super.send(value)로 sms:release를 얻은 뒤 문자열 " | "로 잇습니다. 충돌을 어떤 방식으로 풀었는지가 클래스 코드에 그대로 드러나므로, 읽는 사람은 두 채널이 모두 쓰인다는 것을 바로 알 수 있습니다. 두 default 구현이 실제로 메시지를 보내는 것처럼 부수 효과가 있다면, 어느 쪽을 먼저 부를지와 첫 번째가 실패했을 때 두 번째를 부를지도 이 자리에서 정해야 합니다.

X.super 문법에는 제한이 있습니다. X는 클래스가 implements 뒤에 직접 적은 인터페이스여야 하고, X의 구현을 이미 덮어쓴 더 구체적인 인터페이스가 함께 있으면 쓸 수 없습니다. 다음 코드는 UpperFormatter를 건너뛰고 조상 BasicFormatter의 구현을 부르려다 실패합니다.

lab/SuperCallSkipFailure.java
public final class SuperCallSkipFailure {
    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 RawFormatter implements BasicFormatter, UpperFormatter {
        @Override
        public String format(String value) {
            return BasicFormatter.super.format(value);
        }
    }

    public static void main(String[] args) {
        System.out.println(new RawFormatter().format("stream"));
    }
}
컴파일 오류
lab/SuperCallSkipFailure.java:18: error: bad type qualifier BasicFormatter in default super call
            return BasicFormatter.super.format(value);
                                 ^
  redundant interface BasicFormatter is extended by UpperFormatter
1 error

RawFormatter는 BasicFormatter를 직접 구현한다고 적었지만, 함께 적은 UpperFormatter가 BasicFormatter를 상속하면서 format을 덮어썼습니다. 오류 메시지의 마지막 줄처럼 컴파일러는 이때의 BasicFormatter를 UpperFormatter가 이미 상속한 중복 인터페이스로 보고, 덮어쓴 구현을 건너뛰어 부르는 것을 막습니다. 하위 인터페이스가 바꿔 놓은 동작을 구현 클래스가 몰래 되돌리지 못하게 하는 규칙입니다.

같은 메서드 이름의 네 가지 해결

같은 메서드 이름의 네 가지 해결

선언 관계가 정하는 구현

같은 메서드 이름의 네 가지 해결

상위 클래스의 구체 메서드와 더 구체적인 인터페이스 구현이 우선합니다. 무관한 두 기본 구현의 충돌은 클래스가 해결해야 합니다.

구현 후보의 선언 관계와 클래스의 최종 선택을 대조한다세서로다른원예제의9노드6관계이다. Base와Named가Child에구현후보를제공하되구체Base클래스가우선한다. Basic에서Upper의더구체적인default를거쳐Report가선택한다. 무관한Email/Sms의Both는스스로재정의하여두super호출을조합한다. 연결선은선언관계가구현선택에기여한다는뜻이지implements화살표방향이나런타임전송순서를일괄뜻하지않는다. Both재정의가없으면컴파일충돌이며상위추상메서드예외는본문을따른다. mobile동일세경우이다.Base 클래스public name 구현Named 인터페이스default name 후보Child의 nameBase 구현: classBasicFormatterdefault formatUpperFormatter기본 구현 재정의ReportFormatter더 구체적인 구현Email 인터페이스default sendSms 인터페이스default sendBoth 직접 재정의Email.super →Sms.super 조합
구현 후보의 선언 관계와 클래스의 최종 선택을 대조한다세서로다른원예제의9노드6관계이다. Base와Named가Child에구현후보를제공하되구체Base클래스가우선한다. Basic에서Upper의더구체적인default를거쳐Report가선택한다. 무관한Email/Sms의Both는스스로재정의하여두super호출을조합한다. 연결선은선언관계가구현선택에기여한다는뜻이지implements화살표방향이나런타임전송순서를일괄뜻하지않는다. Both재정의가없으면컴파일충돌이며상위추상메서드예외는본문을따른다. mobile동일세경우이다.Base 클래스public name 구현Named 인터페이스default name 후보Child의 nameBase 구현: classBasicFormatterdefault formatUpperFormatter기본 구현 재정의ReportFormatter더 구체적인 구현Email 인터페이스default sendSms 인터페이스default sendBoth 직접 재정의Email.super →Sms.super 조합

상위 클래스에 구현

선택 또는 처리
상속한 public 메서드 사용
원문에서 도출한 결과
name(): class

하위 인터페이스가 재정의

선택 또는 처리
더 구체적인 기본 구현 사용
원문에서 도출한 결과
format(): STREAM

서로 무관한 기본 구현

선택 또는 처리
구현 클래스의 해결 필요
원문에서 도출한 결과
버그 예제: 컴파일 충돌

클래스가 두 구현 조합

선택 또는 처리
Email.super 뒤 Sms.super 호출
원문에서 도출한 결과
email:release | sms:release

각 행은 서로 다른 원문 클래스입니다. 출력 문자열과 컴파일 충돌은 소스와 언어 규칙으로 판정했으며, 이번 배치에서 컴파일하거나 실행해 관측한 결과가 아닙니다.

그림은 이 절의 정상 예제 세 개와 충돌 예제를 한 장에 모은 것입니다. Child는 부모 클래스 Base의 구현을, ReportFormatter는 더 구체적인 UpperFormatter의 구현을 고릅니다. 서로 관련 없는 Email과 Sms의 구현이 겹치면 클래스가 오버라이딩하지 않는 한 컴파일 오류가 나고, Both처럼 직접 오버라이딩하면 두 구현을 조합할 수 있습니다. 그림의 선은 런타임에 메서드를 부르는 순서가 아니라, 어떤 선언 관계가 최종 선택에 영향을 주는지를 나타냅니다. 정확한 규칙은 Java 언어 명세의 클래스의 메서드 상속과 인터페이스의 같은 시그니처 메서드 상속에 있습니다.

default 메서드가 어울리는 곳과 어울리지 않는 곳

default 메서드가 가장 잘 맞는 곳은 이미 있는 추상 메서드를 조합해서 만들 수 있는 편의 기능입니다. 표준 라이브러리에도 예가 많습니다. Java 8은 Iterable에 forEach를, Collection에 stream과 removeIf를, List에 sort를, Map에 getOrDefault를 default 메서드로 추가했습니다. 예를 들어 removeIf의 기본 구현은 iterator()로 원소를 하나씩 보면서 조건에 맞는 원소를 지웁니다. 그래서 오래전에 만든 컬렉션 구현도 아무것도 고치지 않고 이 메서드를 쓸 수 있고, 더 빠른 방법이 있는 ArrayList 같은 구현은 이 메서드를 오버라이딩합니다.

반대로 어울리지 않는 경우도 있습니다. 가장 흔한 실수는 모든 구현에 반드시 필요한 기능을 아무 일도 하지 않는 default 메서드로 추가하는 것입니다. 예를 들어 모든 알림이 감사 로그를 남겨야 한다는 규정이 생겨 default void audit(String message)를 빈 본문으로 추가하면, 기존 구현은 모두 컴파일되지만 실제로 감사 로그를 남기는 구현은 하나도 없습니다. 빠뜨린 구현이 오류로 드러나지 않고 조용히 묻히는 것입니다. 이런 기능은 추상 메서드로 추가해서 구현하지 않은 곳을 모두 컴파일 오류로 드러내고, 라이브러리의 주 버전을 올려 마이그레이션하는 편이 정직합니다. 호환성을 지키는 것보다 정확성이 더 중요한 변경도 있습니다.

객체마다 다른 상태가 필요한 핵심 동작도 default 메서드로 만들기 어렵습니다. 인터페이스에는 인스턴스 필드가 없으므로, 상태와 그 상태가 늘 지켜야 할 조건은 구현 클래스가 관리해야 합니다. 또 여러 클래스의 코드 중복을 줄이려는 목적만으로 default 메서드를 잔뜩 넣으면, 인터페이스가 '무엇을 할 수 있는가'를 정하는 역할에서 벗어나 구현을 모아 둔 곳처럼 변합니다. 공통 구현을 나누는 것이 목적이라면 추상 클래스나 별도의 도우미 클래스가 더 맞는지도 함께 따져 봅니다.

더 깊이: 소스 호환성, 바이너리 호환성, 동작 호환성

'호환된다'는 말은 적어도 세 가지 뜻으로 쓰입니다. 소스 호환성은 기존 소스 코드를 새 버전에 맞춰 다시 컴파일해도 그대로 컴파일되는가입니다. 바이너리 호환성은 이미 컴파일해 둔 클래스 파일을 다시 컴파일하지 않고 새 버전과 함께 실행해도 링크되고 실행되는가입니다. 동작 호환성은 실행 결과가 예전과 같은가입니다. 이 글 첫머리의 컴파일 오류는 추상 메서드 추가가 소스 호환성을 깨뜨린다는 것을 보여 주었습니다. 바이너리 호환성은 클래스를 따로 컴파일해 봐야 확인할 수 있습니다.

다음 실험에서는 Notifier의 1판과 2판을 따로 두고, ConsoleNotifier는 1판에 맞춰 한 번만 컴파일한 클래스 파일을 계속 씁니다. 다른 팀이 예전에 빌드해 둔 구현을 새 라이브러리와 함께 실행하는 상황입니다. 클래스를 따로 컴파일해야 하므로 이번에는 지금까지와 달리 타입마다 파일을 나눴습니다. 2판은 추상 메서드를 추가한 판과 default 메서드를 추가한 판 두 가지를 준비합니다.

compat/v1/Notifier.java
public interface Notifier {
    void send(String message);
}
compat/v2-abstract/Notifier.java
public interface Notifier {
    void send(String message);

    void sendUrgent(String message);
}
compat/v2-default/Notifier.java
public interface Notifier {
    void send(String message);

    default void sendUrgent(String message) {
        send("URGENT: " + message);
    }
}
compat/ConsoleNotifier.java
public class ConsoleNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("console=" + message);
    }
}
compat/Main.java
public class Main {
    public static void main(String[] args) {
        Notifier notifier = new ConsoleNotifier();
        notifier.send("normal");
        notifier.sendUrgent("disk full");
    }
}

compat 폴더에서 1판 인터페이스에 맞춰 ConsoleNotifier를 컴파일합니다. 그다음 인터페이스만 추상 메서드가 추가된 2판으로 바꿔 Main과 함께 컴파일하고 실행합니다.

javac -d out v1/Notifier.java ConsoleNotifier.java
javac -d out -cp out v2-abstract/Notifier.java Main.java
java -cp out Main
console=normal
Exception in thread "main" java.lang.AbstractMethodError: Receiver class ConsoleNotifier does not define or inherit an implementation of the resolved method 'abstract void sendUrgent(java.lang.String)' of interface Notifier.
	at Main.main(Main.java:5)

둘째 명령은 -cp out으로 이미 컴파일된 ConsoleNotifier.class를 찾아 쓰므로 ConsoleNotifier를 다시 컴파일하지 않습니다. 그런데도 프로그램은 시작되고 첫 줄 console=normal까지 출력됩니다. 옛 클래스 파일이 새 인터페이스와 문제없이 연결되었다는 뜻이고, Java 언어 명세가 인터페이스에 추상 메서드를 추가하는 것을 기존 바이너리와 호환되는 변경으로 분류하는 이유도 여기에 있습니다. 문제는 새 메서드를 실제로 부르는 순간에 드러납니다. ConsoleNotifier에는 sendUrgent의 구현이 없으므로 AbstractMethodError가 납니다.

이번에는 인터페이스를 default 메서드가 추가된 2판으로 바꿔 같은 방식으로 실행합니다. ConsoleNotifier.class는 여전히 1판에 맞춰 컴파일한 그 파일입니다.

javac -d out -cp out v2-default/Notifier.java Main.java
java -cp out Main
console=normal
console=URGENT: disk full

ConsoleNotifier를 다시 컴파일하지 않았는데도 sendUrgent가 동작합니다. JVM이 ConsoleNotifier에서 sendUrgent의 구현을 찾지 못하자 인터페이스의 default 구현을 골랐기 때문입니다. 이렇게 소스 호환성과 바이너리 호환성을 함께 지키면서 기능을 더할 수 있다는 것이 default 메서드의 존재 이유입니다.

그렇다고 default 메서드 추가가 언제나 안전하지는 않습니다. Java 언어 명세는 default 메서드 추가를 바이너리 호환 변경으로 분류하면서도, 기존 바이너리가 그 메서드를 부를 때 IncompatibleClassChangeError가 날 수 있다고 적어 두었습니다(JLS §13.5.7). 위 실험을 조금 바꾸면 이 경우도 볼 수 있습니다. ConsoleNotifier가 default 메서드가 있는 2판 Notifier와 함께 Pager라는 다른 인터페이스도 구현하도록 컴파일해 둡니다. 그 뒤 Pager에도 같은 시그니처의 default void sendUrgent(String message)를 추가하고 Pager만 다시 컴파일합니다. 그러면 ConsoleNotifier 타입의 변수로 sendUrgent를 부르는 순간 다음 오류가 납니다.

Exception in thread "main" java.lang.IncompatibleClassChangeError: Conflicting default methods: Notifier.sendUrgent Pager.sendUrgent

소스를 다시 컴파일했다면 규칙 3에 따라 컴파일 오류가 났을 충돌이, 다시 컴파일하지 않은 클래스 파일에서는 실행 중에 드러난 것입니다. 같은 호출을 Notifier 타입의 변수로 하면 HotSpot JVM은 이 오류의 하위 클래스인 AbstractMethodError를 던집니다.

동작 호환성은 알아차리기가 더 어렵습니다. 새 default 메서드와 이름과 매개변수가 같은 메서드를 이미 가진 구현 클래스가 있다고 해 봅시다. 반환 타입까지 맞으면 그 메서드가 새 default 메서드를 조용히 오버라이딩합니다. 그러면 인터페이스를 통해 부르는 코드는 인터페이스 문서가 설명하는 동작이 아니라, 그 클래스가 원래 하던 일을 실행합니다. 반환 타입이 다르면 다시 컴파일할 때 오류가 납니다. 예를 들어 SmsNotifier에 예전부터 public boolean sendUrgent(String message)가 있었다면 sendUrgent(String) in SmsNotifier cannot implement sendUrgent(String) in Notifier와 return type boolean is not compatible with void라는 컴파일 오류가 납니다.

그래서 공개 라이브러리의 인터페이스에 default 메서드를 추가할 때는 흔한 이름을 피하고, 구현 클래스들이 같은 이름의 메서드를 이미 쓰고 있지 않은지, 제네릭 타입 소거 뒤에 시그니처가 겹치지 않는지 살펴봅니다. 가능하다면 그 인터페이스를 구현하는 주요 라이브러리와 함께 실제로 빌드하고 테스트해 보는 것이 가장 확실합니다.

정리

  • 인터페이스에 추상 메서드를 추가하면 그 메서드를 구현하지 않은 기존 구체 클래스가 모두 컴파일되지 않습니다. default 메서드는 구현을 함께 제공하므로, 기존 구현을 고치지 않고 기능을 더할 수 있습니다.
  • 좋은 default 메서드는 인터페이스의 추상 메서드만으로 구현합니다. 인터페이스에는 인스턴스 필드가 없고, Object의 public 메서드는 default로 정의할 수 없습니다.
  • 같은 메서드를 여러 곳에서 물려받으면 클래스가 이기고, 그다음으로 더 구체적인 인터페이스가 이기며, 그래도 정해지지 않으면 클래스가 오버라이딩해서 정합니다. 이긴 선언이 추상 메서드라면 구체 클래스가 구현해야 합니다.
  • X.super.m()은 직접 구현한 인터페이스의 default 구현을 부르며, 더 구체적인 인터페이스가 덮어쓴 조상의 구현은 부를 수 없습니다.
  • 모든 구현에 반드시 필요한 기능을 빈 default 메서드로 추가하면 누락이 감춰집니다. 그런 기능은 추상 메서드로 추가하고 마이그레이션합니다.

연습 문제

  1. Exporter 인터페이스에는 추상 메서드 boolean export(String value)가 있습니다. export를 최대 세 번까지 시도하는 default 메서드 exportWithRetry를 추가하세요. 기존 구현 FlakyExporter는 아무것도 고치지 않아도 새 메서드를 쓸 수 있어야 합니다. FlakyExporter는 두 번째 호출부터 성공하며, 호출될 때마다 몇 번째 호출인지 출력합니다.
  2. 1번 풀이의 main에서 같은 exporter로 exportWithRetry("report")를 한 번 더 부르면 무엇이 출력될까요? 또 export가 첫 호출에서 비검사 예외를 던진다면 exportWithRetry는 세 번을 채워 시도할까요?
  3. Notifier에 감사 로그를 남기는 audit(String message)를 추가하려고 합니다. 규정상 모든 알림이 반드시 감사 로그를 남겨야 합니다. 빈 본문의 default 메서드로 추가하는 것과 추상 메서드로 추가하는 것 가운데 무엇이 맞을까요?
1번 풀이

exportWithRetry는 추상 메서드 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"));
    }
}

실행 결과:

attempt=1, value=report
attempt=2, value=report
success=true

새로 만든 FlakyExporter의 첫 export는 calls가 1이라 false를 돌려주고, 두 번째 호출에서 calls가 2가 되어 true를 돌려줍니다. exportWithRetry는 true를 받는 즉시 반환하므로 세 번째 시도는 하지 않고, 마지막 줄에 success=true가 찍힙니다.

이 default 메서드는 false 결과만 다시 시도합니다. 실제 서비스의 재시도라면 시도 사이의 대기 시간(백오프), 같은 요청을 여러 번 보내도 안전한지(멱등성), 다시 시도해도 소용없는 실패의 구분이 더 필요합니다. 이런 정보는 boolean 하나로 전할 수 없으므로, 운영에 쓸 API라면 결과 타입이나 예외로 실패의 종류를 나눠 돌려줘야 합니다.

2번 풀이

1번 풀이의 main에 System.out.println("success=" + exporter.exportWithRetry("report"));를 한 줄 더 넣고 실행하면 다음과 같습니다.

attempt=1, value=report
attempt=2, value=report
success=true
attempt=3, value=report
success=true

두 번째 호출에서 반복문의 지역 변수 attempt는 다시 1부터 시작하지만, FlakyExporter 객체의 필드 calls는 앞의 호출에서 이미 2가 되어 있습니다. 그래서 첫 시도에서 calls가 3이 되고 곧바로 true를 돌려줍니다. default 메서드의 지역 변수는 호출할 때마다 새로 만들어지고, 호출과 호출 사이에 이어지는 상태는 구현 객체의 필드에만 있다는 것을 보여 줍니다.

export가 예외를 던지면 exportWithRetry는 그 예외를 잡지 않으므로 예외가 그대로 호출자에게 전달되고, 나머지 시도는 하지 않습니다. '최대 세 번'은 false를 돌려받았을 때의 반복 조건일 뿐, 모든 실패를 자동으로 복구한다는 뜻이 아닙니다. 예외가 난 경우도 다시 시도하려면 어떤 예외를 다시 시도할지 정해서 try-catch를 넣어야 합니다.

3번 풀이

추상 메서드로 추가해야 합니다. 빈 default 메서드를 추가하면 기존 구현이 모두 컴파일되므로 당장은 편하지만, 감사 로그를 남기지 않는 구현이 하나도 드러나지 않습니다. 규정을 지키지 못하는 코드가 오류 없이 배포되는 것입니다. 추상 메서드로 추가하면 구현하지 않은 클래스마다 컴파일 오류가 나므로 고칠 곳이 모두 드러납니다. 소스 호환성을 깨는 변경이므로 라이브러리의 주 버전을 올리고 변경 사항을 알려야 합니다.

default 메서드가 맞는 경우는 모든 구현에 의미 있는 기본 동작이 있을 때입니다. 예를 들어 감사 로그를 공용 로거에 남기는 것으로 모든 구현이 충분하다면, 그 동작을 default 메서드로 제공할 수 있습니다. 판단 기준은 아무것도 하지 않는 기본값을 둘 수 있느냐가 아니라, 모든 구현에 맞는 기본 동작이 있느냐입니다.

다음 장

이것으로 33장을 마칩니다. 다음 장에서는 병렬 스트림과 Fork/Join으로 넘어가, 큰 작업을 나눠 여러 스레드에서 처리하는 방법을 다룹니다.