interface에 메서드 본문이 들어간 이유 — default method와 다중 구현의 충돌

Java 8 이전의 interface는 순수한 명세였다 — 메서드 선언만 있고 본문이 없는 계약서. 그런데 라이브러리를 배포했는데, 나중에 새 메서드를 추가해야 한다면? 그 인터페이스를 구현한 모든 클래스가 컴파일 에러로 깨진다. 이 문제를 해결하기 위해 default method가 도입됐다 — interface에 본문이 있는 메서드를 넣을 수 있게 됐다. (JEP-126, Java 8) "이미 구현체를 가진 메서드를 interface에 넣을 수 있다"는 작은 변화가, Java 컬렉션 API 전체에 함수형 프로그래밍을 가능하게 했다.

하지만 두 interface가 같은 이름의 default method를 가지면 어떻게 되는가? 이 글은 interface와 abstract class의 차이, default method 충돌 해결, 그리고 "is-a" vs "can-do" 선택 기준을 풀어간다.

abstract class — 부분 구현을 가진 클래스

abstract 클래스는 인스턴스를 직접 생성할 수 없고, 하위 클래스가 상속하여 구현해야 하는 클래스다. (JLS §8.1.1.1)

// Java 25
public abstract class Shape {
    // 일반 필드 (가능)
    protected String name;

    public Shape(String name) {
        this.name = name;
    }

    // 구현된 메서드 (하위 클래스가 공유)
    public String getName() {
        return name;
    }

    // abstract 메서드 (하위 클래스가 반드시 구현)
    public abstract double area();
    public abstract double perimeter();
}

public class Circle extends Shape {
    private final double radius;

    public Circle(double radius) {
        super("Circle");
        this.radius = radius;
    }

    @Override
    public double area() { return Math.PI * radius * radius; }

    @Override
    public double perimeter() { return 2 * Math.PI * radius; }
}

// Shape s = new Shape("X");   // 컴파일 에러: abstract 클래스 인스턴스화 불가
Shape s = new Circle(5.0);
System.out.println(s.area());   // 78.539...

abstract class의 핵심:

  • 인스턴스화 불가 (new AbstractClass() 컴파일 에러)
  • 필드(상태)를 가질 수 있다 — interface는 (static final이 아닌) 인스턴스 필드를 가질 수 없다
  • 생성자를 가질 수 있다 — interface는 생성자가 없다
  • abstract 메서드와 구현된 메서드를 섞을 수 있다
  • 단일 상속만 가능

interface — 다중 구현 타입의 계약

interface구현이 없는 메서드 명세의 집합이다. Java 8 이후로 default/static/private 메서드 본문도 가질 수 있게 됐지만, 본질은 타입 계약이다. (JLS §9)

// Java 25
public interface Comparable<T> {
    int compareTo(T other);   // abstract 메서드 (구현 없음)
}

public interface Drawable {
    void draw();

    // default method (Java 8+) — 본문이 있음
    default void drawTwice() {
        draw();
        draw();
    }

    // static method (Java 8+)
    static Drawable empty() {
        return () -> {};   // 아무것도 그리지 않는 Drawable
    }

    // private method (Java 9+) — default 메서드 간 공통 로직
    private void log(String msg) {
        System.out.println("[Drawable] " + msg);
    }
}

interface가 가질 수 있는 것과 없는 것

요소 interface abstract class
인스턴스 필드 X (상수 static final만) O
생성자 X O
abstract 메서드 O O
구현된 메서드 O (default/static/private) O
다중 구현/상속 O (여러 interface 구현 가능) X (단일 상속)
접근 제어자 public (명시적 지정 불필요) public/protected/private/package-private

다중 구현 — interface의 핵심 이점

// Java 25 — 한 클래스가 여러 interface를 구현
public class TextBox implements Drawable, Comparable<TextBox> {
    private String text;

    public TextBox(String text) { this.text = text; }

    @Override
    public void draw() {
        System.out.println("[" + text + "]");
    }

    @Override
    public int compareTo(TextBox other) {
        return this.text.compareTo(other.text);
    }
}

TextBoxDrawable이기도 하고 Comparable<TextBox>이기도 하다. 이것이 interface가 해결하는 다중 타입 문제다 — 단일 상속만으로는 두 개의 다른 타입 계약을 동시에 만족시킬 수 없다.

default method — interface 진화의 열쇠

default method는 interface에 본문이 있는 메서드를 추가하는 기능이다. (JLS §9.4)

// Java 25
public interface List<E> {
    // ... 기존 abstract 메서드들 ...

    // Java 8에서 추가된 default method
    default void forEach(Consumer<? super E> action) {
        for (E e : this) {   // this = List 자신
            action.accept(e);
        }
    }

    default boolean isEmpty() {
        return size() == 0;
    }
}

List 인터페이스에 forEach를 default method로 추가하면, 이미 List를 구현한 모든 클래스(ArrayList, LinkedList 등)가 코드 변경 없이 forEach를 사용할 수 있다. 이것이 default method의 존재 이유다 — interface의 하위 호환성을 유지하면서 진화.

Java 8에서 Collection.forEach(), Stream 관련 default method들이 대거 추가됐다. Java의 기존 컬렉션 API가 함수형 프로그래밍을 지원할 수 있게 된 것이 default method 덕분이다.

default method 충돌 — 다이아몬드 문제의 귀환

두 interface가 같은 이름의 default method를 가지고, 한 클래스가 두 interface를 모두 구현하면 충돌이 발생한다.

// Java 25 — default method 충돌
interface A {
    default void hello() { System.out.println("A"); }
}
interface B {
    default void hello() { System.out.println("B"); }
}

// class C implements A, B { }   // 컴파일 에러: 충돌!
// C inherits unrelated defaults for hello() from types A and B

// 해결: 명시적으로 override
class C implements A, B {
    @Override
    public void hello() {
        A.super.hello();   // A의 hello() 선택 (또는 B.super.hello())
    }
}

충돌 해결 규칙 (JLS §9.4.1):

  1. 클래스가 이긴다 — class의 구현이 interface의 default보다 우선
  2. 더 구체적인 interface가 이긴다B extends A일 때 B의 default가 우선
  3. 충돌 시 명시적 override — 위 규칙으로 결정할 수 없으면 컴파일 에러 → 개발자가 직접 override

A.super.hello() 문법으로 특정 interface의 default method를 명시적으로 호출할 수 있다. 이것은 interface 다중 구현에서만 사용하는 특수 문법이다.

abstract class vs interface — 언제 무엇을?

기준 abstract class interface
관계 "is-a" (강한 결합) "can-do" (약한 결합)
상태(필드) 필요함 불필요 (타입 계약만)
다중 상속 불가 가능
공통 구현 필드 + 메서드 공유 메서드 본문만 공유 (default)
진화 새 메서드 추가 시 하위 클래스 영향 default method로 하위 호환 유지

실용적 가이드:

  • 공통 상태(필드)와 구현을 공유해야 한다 → abstract class
  • 타입 역할(역할, 능력)을 정의 → interface
  • 여러 타입을 한 객체에 부여해야 한다 → interface (다중 구현)
  • 라이브러리 API 설계 → interface (default method로 진화 가능)

Java 컬렉션 프레임워크의 설계가 이 원칙을 잘 보여준다: AbstractList(abstract class)가 공통 구현을 제공하고, List(interface)가 타입 계약을 정의한다. ArrayListList를 구현하면서 AbstractList의 구현을 재사용한다.

sealed interface (Java 17+, JEP-409) — 닫힌 타입 계층

interface는 기본적으로 "누구나 구현할 수 있는" 열린(open) 계약이다. 누가 구현할지 미리 알 수 없다. sealed는 이것을 제한한다 — 허용된 타입만 구현(또는 상속)할 수 있다. (JEP-409)

// Java 25 — sealed interface로 닫힌 계층 정의
public sealed interface Shape
    permits Circle, Rectangle, Triangle {}

public record Circle(double radius) implements Shape {}
public record Rectangle(double w, double h) implements Shape {}
public record Triangle(double base, double height) implements Shape {}

// 다른 패키지의 클래스가 Shape를 구현하려고 하면 컴파일 에러
// class Hexagon implements Shape {}  // 허용되지 않음

sealed의 규칙:

  • permits에 명시된 타입만 하위 타입이 될 수 있다
  • 허용된 타입은 final, sealed, 또는 non-sealed 중 하나여야 한다
  • final = 더 이상 하위 타입 없음
  • sealed = 또 다른 sealed 계층
  • non-sealed = 열림 (이 하위 트리에서는 누구나 상속 가능)

sealed interface는 switch expression의 exhaustiveness(완전성) 검사와 결합할 때 가장 강력하다 — default 없이도 컴파일러가 모든 case를 처리했는지 검증한다 (03-operators-control.md 참조).

함수형 인터페이스(functional interface) — default method의 부산물

정확히 하나의 abstract 메서드를 갖는 interface를 함수형 인터페이스라 한다. (JLS §9.8) 람다식의 타겟 타입이 된다.

// Java 25
@FunctionalInterface   // 컴파일러 검증 (abstract 메서드가 1개 초과 시 에러)
public interface Predicate<T> {
    boolean test(T t);

    // default 메서드는 abstract 카운트에 포함 안 됨
    default Predicate<T> and(Predicate<? super T> other) {
        return t -> this.test(t) && other.test(t);
    }

    default Predicate<T> negate() {
        return t -> !this.test(t);
    }
}

@FunctionalInterface는 선택적이지만, 실수로 abstract 메서드를 추가하는 것을 막아주므로 항상 붙이는 것이 좋다. 함수형 프로그래밍과 람다의 자세한 내용은 12-lambda-functional.md에서 풀어간다.

실습 — default method 충돌 직접 체험

// Java 25 — InterfaceDemo.java
interface Walker {
    default void move() { System.out.println("걷는다"); }
}
interface Swimmer {
    default void move() { System.out.println("헤엄친다"); }
}

public class InterfaceDemo implements Walker, Swimmer {
    @Override
    public void move() {
        System.out.print("양서류: ");
        Walker.super.move();   // Walker의 move() 선택
        System.out.print("양서류: ");
        Swimmer.super.move();  // Swimmer의 move()도 호출
    }

    public static void main(String[] args) {
        new InterfaceDemo().move();
    }
}
java InterfaceDemo.java
양서류: 걷는다
양서류: 헤엄친다

확인할 것: 두 interface의 같은 이름 default method가 충돌하지만, 명시적으로 override하여 둘 다 호출한다. Walker.super.move()Swimmer.super.move()로 각각의 default method를 지정한다.

요약 — 이 글의 결론

  • interface는 타입 계약이고, abstract class는 부분 구현이다. interface는 다중 구현이 가능하고 상태가 없다. abstract class는 단일 상속이지만 필드와 생성자를 가질 수 있다.
  • default method(Java 8+)는 interface의 진화를 가능하게 했다. 기존 구현체를 깨지 않고 새 메서드를 추가할 수 있다. Java 컬렉션 API의 함수형 확장이 이것 덕분이다.
  • default method 충돌은 컴파일 타임에 잡힌다. 두 interface의 같은 이름 default method를 구현할 때는 명시적으로 override하고 Interface.super.method()로 원하는 것을 선택한다.
  • "is-a"는 abstract class, "can-do"는 interface. 공통 상태와 구현이 필요하면 abstract class, 역할/능력 정의가 필요하면 interface. 실제로는 interface를 우선하고, 필요할 때 abstract class로 보완하는 것이 유연하다.
  • @FunctionalInterface를 붙여 람다 타겟을 명시한다. abstract 메서드가 1개임을 컴파일러가 검증한다.

생각해 볼 문제

  1. interface A { default void m() {} }를 구현하는 클래스에서 m()을 override하지 않으면 어떻게 되는가? m()이 abstract인지 아닌지를 어떻게 알 수 있는가?
  2. interface에 private 메서드(Java 9+)를 추가한 이유는 무엇인가? default 메서드 간 중복 코드를 어떻게 줄이는가?
  3. Comparator.comparing()은 static 메서드다. 이 메서드가 interface에 정의될 수 있는 이유를 설명하라.
  4. abstract class와 interface를 둘 다 쓰는 상황을 설계해 보자. (예: template method 패턴)
  5. sealed interface는 다중 구현에 어떤 영향을 주는가? sealed interface의 하위 타입을 switch에서 처리할 때의 이점은?

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 09. exception  (0) 2026.07.12
Java - 08. record sealed enum  (0) 2026.07.12
Java - 06. inheritance and polymorphism  (0) 2026.07.12
Java - 05. class and object  (0) 2026.07.12
Java - 04. array and string  (0) 2026.07.12