기본 메서드와 호환성
기존 구현을 깨지 않고 동작을 추가하는 기본 메서드와 class 우선·하위 인터페이스·diamond 충돌 해결 규칙을 실행합니다.
인터페이스에 abstract 메서드를 추가하면 새 계약을 충족하는 메서드가 없는 구체 클래스는 재컴파일 때 구현을 추가해야 합니다. 이미 적합한 메서드를 갖거나 상속받는 클래스와 추상 클래스는 구분합니다.
기본 메서드는 인터페이스가 기본 구현을 제공해 기존 바이너리와 소스의 호환성을 높입니다.
구현을 공유하기에는 편리하지만 상태와 불변식이 필요한 핵심 동작까지 무분별하게 넣으면 규칙이 흐려집니다.
기본 메서드 충돌
서로 독립적인 두 인터페이스가 같은 notifyMessage 기본 메서드를 제공하면 Java는 어느 구현을 사용할지 결정할 수 없습니다.
아래 선언은 두 기본 메서드의 충돌을 해결하지 않아 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만 있고 EmailNotifier와 LogNotifier가 이를 구현한다고 가정합니다.
두 구현 클래스에 sendUrgent를 충족하는 구현이 없다면, 추상 메서드 추가 후 재컴파일할 때 이를 구현해야 합니다.
send를 조합한 기본 메서드로 추가하면 기존 구현을 수정하지 않고도 긴급 전송 기능을 제공할 수 있습니다.
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 계열 동작을 덮어 클래스 계층의 의미를 바꾸지 못하게 하는 규칙입니다.
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의 구현이 우선합니다.
구현 클래스가 Parent와 Child를 모두 implements에 적더라도 이 관계는 바뀌지 않습니다.
서로 상속 관계가 없는 두 기본 메서드가 충돌하는 경우에는 구현 클래스가 직접 해결해야 합니다. 하위 인터페이스가 메서드를 다시 추상화한 경우에도 구체 클래스의 구현이 필요할 수 있습니다.
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)처럼 원하는 인터페이스의 기본 구현을 호출할 수 있습니다.
두 구현을 합치거나 완전히 새로운 정책을 제공할 수도 있습니다.
Interface.super는 현재 클래스 또는 인터페이스의 직접 상위 인터페이스를 대상으로 합니다. 더 구체적인 직접 상위 클래스·인터페이스가 있으면 이를 건너뛰어 조상 기본 구현을 선택할 수 없습니다.
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를 읽는 사람이 다중 채널 정책을 확인할 수 있습니다.
두 기본 구현에 부수 효과가 있다면 호출 순서와 부분 실패 정책을 별도로 설계해야 합니다.
같은 메서드 이름의 네 가지 해결
| 선언 관계 | 선택 또는 처리 | 원문에서 도출한 결과 |
|---|---|---|
| 상위 클래스에 구현 | 상속한 public 메서드 사용 | name(): class |
| 하위 인터페이스가 재정의 | 더 구체적인 기본 구현 사용 | format(): STREAM |
| 서로 무관한 기본 구현 | 구현 클래스의 해결 필요 | 버그 예제: 컴파일 충돌 |
| 클래스가 두 구현 조합 | Email.super 뒤 Sms.super 호출 | email:release | sms:release |
- 상위 클래스에 구현
- 선택 또는 처리: 상속한 public 메서드 사용원문에서 도출한 결과: name(): class
- 하위 인터페이스가 재정의
- 선택 또는 처리: 더 구체적인 기본 구현 사용원문에서 도출한 결과: format(): STREAM
- 서로 무관한 기본 구현
- 선택 또는 처리: 구현 클래스의 해결 필요원문에서 도출한 결과: 버그 예제: 컴파일 충돌
- 클래스가 두 구현 조합
- 선택 또는 처리: Email.super 뒤 Sms.super 호출원문에서 도출한 결과: email:release | sms:release
각 행은 서로 다른 원문 클래스입니다. 출력 문자열과 컴파일 충돌은 소스와 언어 규칙으로 판정했으며, 이번 배치에서 컴파일하거나 실행해 관측한 결과가 아닙니다.
기본 메서드를 올바르게 쓰는 범위
기본 메서드는 기존 추상 메서드를 조합한 편의 기능, 호환 가능한 작은 기능 확장, 구현 클래스와 무관한 공통 알고리즘에 적합합니다.
인터페이스에는 인스턴스 필드가 없으며 선언한 필드는 public static final입니다. 객체별 상태가 필요한 불변식은 구현 클래스가 맡고, 인터페이스에는 protected 도우미도 둘 수 없습니다.
private 인터페이스 메서드를 사용하면 기본 메서드 사이의 구현 중복을 줄일 수 있습니다.
모든 구현에 아무 동작도 하지 않는 기본 메서드를 제공하면 새 기능의 누락이 감춰집니다.
예를 들어 감사 기능을 반드시 구현해야 한다면 추상 메서드를 추가하고 기존 구현을 마이그레이션하는 편이 더 정직합니다.
호환성을 깨더라도 정확성을 우선해야 하는 버전 변경도 있습니다.
API 진화의 소스·바이너리·동작 호환성
JLS는 기본 메서드 추가를 기존 바이너리와 호환되는 변경으로 분류하지만, 재컴파일 시 충돌하거나 기존 바이너리의 메서드 호출 시 IncompatibleClassChangeError가 발생하는 경우까지 배제하지는 않습니다. 기존 클래스의 같은 시그니처 메서드가 선택되는지도 확인합니다.
하위 라이브러리와의 통합 검증도 수행합니다.
새 기본 메서드가 기존 메서드의 호출 횟수나 예외를 바꾸면 동작 호환성이 깨집니다.
인터페이스를 공개 라이브러리로 배포한다면 메서드 이름, 제네릭 타입 소거, 향후 이름 충돌 가능성을 검토합니다.
단순히 구현 중복을 줄이는 믹스인 용도로 기본 메서드를 남발하지 않습니다.
연습 문제
Exporter의 추상 메서드 export(String)을 이용해 최대 세 번 시도하는 기본 메서드 exportWithRetry를 만드세요.
기존 FlakyExporter는 새 메서드를 따로 구현하지 않아도 동작해야 합니다.
실패 횟수와 최종 성공을 재현하세요.
정답과 기본 구현의 한계
기본 메서드는 export 결과만 조합하고 구현 상태를 가정하지 않습니다.
실제 재시도에는 백오프, 멱등성, 예외 분류가 더 필요합니다.
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"));
}
}원문 main의 새 인스턴스는 두 번째 export에서 true를 반환하므로 전체 결과도 success=true입니다. 이 기본 메서드는 false 결과만 재시도하며, export가 던진 비검사 예외는 잡지 않고 전파합니다.
인터럽트 예외와 재시도할 수 없는 실패를 boolean 하나로 뭉개지 않는 운영 API가 필요합니다.
기본 메서드는 인터페이스를 발전시키면서 호환성을 유지하는 도구입니다.
클래스 메서드 우선, 더 구체적인 인터페이스 우선, 무관한 인터페이스의 충돌은 명시적 재정의로 해결한다는 규칙을 이해하고 모든 구현에 타당한 기본 동작에만 사용해야 합니다.