기본 메서드와 호환성
기존 구현을 깨지 않고 동작을 추가하는 기본 메서드와 클래스 우선·하위 인터페이스·다이아몬드 충돌 해결 규칙을 실행합니다.
인터페이스에 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");
}
}기본 메서드는 기존의 추상 메서드 계약만 사용해 구현해야 합니다.
특정 구현 타입으로의 형변환이나 숨겨진 가변 상태에 의존하면 다른 구현에서 깨집니다.
새 기능이 모든 구현에 타당한 기본 의미를 가질 때 적합합니다.
예시 예제의 ConsoleNotifier는 sendUrgent를 선언하지 않습니다. 상속한 기본 메서드가 메시지 앞에 URGENT:를 붙이고 send를 호출하면 실제 ConsoleNotifier.send가 console=URGENT: disk full을 출력합니다. 인터페이스 기본 구현이 구현 객체의 send를 호출한 것이며 ConsoleNotifier를 새로 만들거나 상태 필드를 추가한 것이 아닙니다.
클래스 우선 규칙
상위 클래스의 구체 메서드와 인터페이스의 기본 메서드가 같은 시그니처라면 클래스 메서드가 우선합니다.
인터페이스의 기본 메서드가 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
각 행은 서로 다른 원문 클래스입니다. 출력 문자열과 컴파일 충돌은 소스와 언어 규칙으로 판정했으며, 이번 배치에서 컴파일하거나 실행해 관측한 결과가 아닙니다.
상속 후보를 찾는 일과 실행할 구현을 고르는 일을 나눠 보세요. 예제의 Child는 Base의 public name을 이미 물려받아 Named의 default와 맞더라도 class를 선택합니다. ReportFormatter는 UpperFormatter가 BasicFormatter보다 구체적인 관계라 STREAM을 반환합니다. Both는 Email·Sms 사이의 상속 우열이 없어 자체 재정의에서 두 super 호출을 순서대로 조합합니다. JLS의 인터페이스 메서드 상속 규칙과 코드의 선언 관계를 함께 대조하세요.
독립 확인: 클래스에 같은 시그니처의 메서드를 직접 선언하지 않는다고 정합니다. 첫 경우는 Base가 추상 name만 선언하고 Named는 default name을 제공합니다. 둘째는 UpperFormatter가 format을 다시 추상 선언합니다. 구체 Child·ReportFormatter가 기존 모습 그대로 컴파일되나요?
풀이 확인
둘 다 필요한 구현이 없어 컴파일되지 않습니다. 첫 경우 상위 클래스의 추상 선언 요구를 인터페이스 default가 자동으로 채우지 않고, 둘째는 더 구체적인 인터페이스가 기본 구현 대신 다시 구현 의무를 선언했습니다. 이 입력은 기존 정상 예제의 구체 메서드/default 재정의를 바꾼 별도 선언 모델입니다. default 후보가 보인다는 사실만으로 구체 클래스의 구현 의무가 모두 해소되지는 않습니다.
기본 메서드를 올바르게 쓰는 범위
기본 메서드는 기존 추상 메서드를 조합한 편의 기능, 호환 가능한 작은 기능 확장, 구현 클래스와 무관한 공통 알고리즘에 적합합니다.
인터페이스에는 인스턴스 필드가 없으며 선언한 필드는 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가 던진 비검사 예외는 잡지 않고 전파합니다.
예제의 첫 호출의 지역 attempt는 1→2에서 끝나고 객체 calls도 1→2입니다. 같은 FlakyExporter로 기본 메서드를 다시 호출하면 지역 attempt는 다시 1부터 시작하지만 객체 calls는 3이 되어 첫 시도에서 true입니다. 지역 반복 번호와 객체가 기억하는 누적 전송 횟수를 구분합니다.
인터럽트 예외와 재시도할 수 없는 실패를 boolean 하나로 뭉개지 않는 운영 API가 필요합니다.
독립 확인: 새 Exporter는 첫 세 export 호출이 모두 false이고 넷째부터 true라고 정합니다. 새 객체의 exportWithRetry를 한 번 호출하면 반환값과 누적 호출 횟수는 무엇인가요? 같은 객체로 두 번째 호출을 하면 어떤가요? 별도로 첫 export에서 예외를 던진다면 세 번을 채우나요?
풀이 확인
첫 기본 메서드는 세 번 false를 받아 false/누적 3회로 끝나며 넷째 호출을 몰래 수행하지 않습니다. 두 번째는 새 지역 반복의 첫 시도에서 객체 누적 4회가 되어 true를 반환합니다. 별도 예외 구현은 첫 호출에서 예외가 전파되어 나머지 두 시도를 하지 않습니다. 최대 세 번이라는 규칙은 false 반환의 반복 조건이지 모든 실패를 자동 복구한다는 보장이 아닙니다.
기본 메서드는 인터페이스를 발전시키면서 호환성을 유지하는 도구입니다.
클래스 메서드 우선, 더 구체적인 인터페이스 우선, 무관한 인터페이스의 충돌은 명시적 재정의로 해결한다는 규칙을 이해하고 모든 구현에 타당한 기본 동작에만 사용해야 합니다.