BackEnd/자바

디폴트 메서드 - 모던 자바 인 액션

ssseung 2022. 4. 10. 00:33

자바 8 인터페이스는 왜 디폴트 메서드와 정적 메서드를 도입했을까?

자바 8 이전까지 인터페이스는 오직 추상 메서드만 정의할 수 있었고, 실제 구현은 구현 클래스가 맡아야 했다. 그런데 인터페이스에 새로운 메서드를 추가하고 싶을 때, 기존 구현 클래스에 전부 영향을 미치게 되면서 많은 문제가 발생했다.

이를 해결하기 위해 자바 8에서는 디폴트 메서드(default method)와 정적 메서드(static method)를 인터페이스에 정의할 수 있게 바뀌었다.

 

즉, 메서드 구현을 포함하는 인터페이스를 정의할 수 있는데, 결과적으로 기존 인터페이스를 구현하는 클래스는 자동으로 인터페이스에 추가된 새로운 메서드의 디폴트 메서드를 상속받게 된다. 

이는 추상클래스와 인터페이스가 동일한 것처럼 보인다.

 

디폴트 메서드의 등장 배경

인터페이스에 메서드를 하나 추가하면, 이를 구현하는 모든 클래스가 컴파일 에러를 피하려면 그 메서드를 구현해야 한다. 이런 상황에서 기존 코드를 건드리지 않고 인터페이스를 확장하려면 어떻게 해야 할까?

자바 8의 디폴트 메서드는 이런 문제를 해결하기 위한 방법이다. 인터페이스에 메서드의 기본 구현을 넣으면, 그걸 상속하는 클래스들은 굳이 새 메서드를 구현하지 않아도 된다. 기존 코드를 유지하면서 인터페이스를 진화시킬 수 있는 방법인 셈이다.

디폴트 메서드 덕분에 인터페이스도 일종의 동작 다중 상속이 가능해졌다. 서로 다른 인터페이스에 디폴트 메서드가 있어도 충돌하지 않는 한, 클래스에서 모두 상속받을 수 있다.

 

디폴트 메서드는 다중 상속 동작이라는 유연성을 제공하면서 프로그램 구성에도 도움을 준다.(클래스가 여러 디폴트 메서드를 상속받을 수 있으므로)

 

*정적메서드와 인터페이스

자바에서는 인터페이스 그리고 인터페이스의 인스턴스를 활욜할 수 있는 다양한 정적 메서드를 정의하는 유틸리티 클래스를 활용한다, 예를 들어 Collections 는 Collection 객체를 활용할 수 있는 유틸리티 클래스다. 

자바 8에서는 인터페이스에 직접 정적 메서드를 선의할 수 있으므로 유틸리티 클래스를 없애고 직접 인터페이스 내부에 정적 메서드를 구현할 수 있다. 그럼에도 불구하고 과거 버전과 호환성을 유지할 수 있도록 자바 API 에 유틸리티 클래스가 남아있다.

 

*호환성의 종류

바이너리 호환성 :  뭔가 바꾼 이후에도 에러 없이 기존 바이너리가 실행될 수 있는 상황을 바이너리 호환성이라고 한다.

바이너리 실행에는 인증(verification), 준비(preparation),해석(resolution) 등의 과정이 포함된다. 예를 들어 인터페이스에 메서드를 추가했을 때 추가된 메서드를 호출하지 않는 한 문제가 일어나지 않는데 이를 바이너리 호환성이라고 한다.

 

소스 호환성 : 코드를 고쳐도 기존 프로그램을 성공적으로 재컴파일할 수 있음을 의미.

예를 들어 인터페이스에 메서드를 추가하면 소스 호환성이 아니다. 추가된 메서드를 구현하도록 클래스를 고쳐야하기 때문이다.

 

동작 호환성 : 코드를 바꾼 다음에도 같은 입력값이 주어지면 프로그램이 같은 동작을 실행한다는 의미다. 예를 들어 인터페이스에 메서드를 추가하더라도 프로그램에서 추가된 메서드를 호출할 일은 없으므로(혹은 우현히 구현클래스가 이를 오버라이드) 동작 호환성은 유지된다.

 

*추상클래스와 자바8 인터페이스의 차이 

둘 다 추상 메서드와 바디를 포함하는 메서드를 정의할 수 있다.

1. 클래스는 하나의 추상 클래스만 상속받을 수 있지만 인터페이스를 여러 개 구현할 수 있다.

2. 추상클래스는 인스턴스 변수로 공통 상태를 가질 수 있다. 하지만 인터페이스는 인스턴스 변수를 가질 수 없다.


디폴트 메서드를 이용하는 두 가지 방식, 선택형 메서드(optional method)와 동작 다중 상속(multiple inheritance of behavior)

1.선택형 메서드

Iterator 인터페이스의 remove 기능은 많이 사용되지 않는다.

아래와 같이 인터페이스에서 빈 구현을 제공하면 클래스에서 빈 구현을 제공할 필요가 없어 불필요한 코드를 줄일 수 있다.

default void remove() {
    throw new UnsupportedOperationException("remove");
}

2. 동작 다중 상속

기능이 중복되지 않는 최소의 인터페이스를 유지한다면 코드에서 동작을 쉽게 재사용하고 조합할 수 있다.

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{}

 

아래 인터페이스는 구현해야할 다른 메서드에 따라 뼈대 알고리즘이 결정되는 템플릿 디자인 패턴과 비슷하다.

각 클래스에서 setRotationAngle 를 구현하고 이를 rotateBy 에서 호출해 사용한다.

public interface Rotatable {

    void setRotationAngle(int angleInDegrees);
    int getRotationAngle();
    
    default void rotateBy(int angleInDegrees){
        setRotationAngle(getRotationAngle()+angleInDegrees);
    }
}

 

* 템플릿 메서드 패턴

어떤 작업을 처리하는 일부분을 서브 클래스로 캡슐화해 전체 일을 수행하는 구조는 바꾸지 않으면서 특정 단계에서 수행하는 내역을 바꾸는 패턴.

전체적으로 동일하며 부분적으로 다른 구분으로 구성된 메서드의 코드 중복을 최소화할 때 유용하다.

다른 관점에서 보면 동일 기능을 상위 클래스에 정의하고 확장/변화가 필요한 부분만 서브 클래스에서 구현할 수 있도록 한다.

상위 클래스에서 템플릿 메서드를 정의한다. 하위 클래서에서 구현될 기능을 primitive 메서드 또는 hook 메서드로 정의 하고, 하위 클래스에서 이를 오버라이드해 구현한다.

 

*옳지 못한 상속

상속으로 코드 재사용을 모두 해결할 수는 없다. 한 개의 메서드를 사용하려고 100개의 메서드와 필드가 정의된 클래스를 상속받는 것은 좋은 생각이 아니다. 이럴 때는 delegaton, 멤버 변수를 이용해 클래스에서 필요 메서드를 직접 호출하는 메서드를 작성하는 것이 좋다.

종종 final 로 선언된 클래스를 볼 수 있는데 이는 다른 클래스가 이를 상속받아 수정하기를 원하지 않기 때문이다.

디폴트 메서드에도 이 규칙을 적용할 수 있다. 필요한 기능만 포함하도록 인터페이스를 최소한으로 유지한다면 필요한 기능만 선택할 수 있으므로 쉽게 기능을 조립할 수 있다.

 

*해석 규칙 

다른 클래스나 인터페이스로부터 같은 시그니처를 갖는 메서드를 상속받을 때 

1. 클래스가 항상 이긴. 클래스나 슈퍼클래스에서 정의한 메서드가 디폴트 메서드보다 우선권을 갖는다.

2. 1번 규칙 이외의 상황에서는 서브 인터페이스가 이긴다.

상속관계를 갖는 인터페이스에서 같은 시그니처를 갖는 메서드를 정의할 때에는 서브인터페이스가 있다.

즉 B가 A를 상속받는다면 B가 A를 이긴다.

3. 여전히 디폴트메서드의 우선순위가 결정되지 않았다면 여러 인터페이스를 상속받는 클래스가 명시적으로 디폴트 메서드를 오버라이드하고 호출해야 한다.

public interface A {
    default void hello(){
        System.out.println("A");
    }
}
public interface B extends A{
    default void hello(){
        System.out.println("B");
    }
}
public class C implements A,B{
    public static void main(String[] args) {
        new C().hello(); //B
    }
}

위에서 B가 A를 상속하기 때문에 C에서 호출하면 B의 hello 가 출력된다.

 

public class D implements A{
}
public class C extends D implements A,B{
    public static void main(String[] args) {
        new C().hello();
    }
}

위에서는 C가 D를 상속하고 D가 A를 상속했지만 D에서 hello 구현을 하지 않았다.
A를 상속한 B에서 구현되어있어 여전히 B가 출력된다.

만약 D에서 hello 구현이 되어있다면 D가 출력될 것이다.

 

 

그리고 만약 B가 A를 상속받지 않았다면 C에서 A,B를 모두 받으려고 할 때 

types iface.B and iface.A are incompatible; 라는 에러가 발생한다. 상속관계가 없어 누구를 호출해야할지 알 수 없기 때문이다.

 

X.super.m 으로 (X는 m의 슈퍼인터페이스) 명시적으로 호출할 수도 있다.

C가 A,B를 다 받은 상태에서는 B만 저런 방식으로 호출되고 A는 불가한다.

C가 A만 상속받았다면 A.super.m 으로 호출할 수 있다.

public class C implements A,B{
    public static void main(String[] args) {
        new C().hello();
        new C().print();

    }
    void print(){
        B.super.hello();
    }
}

 

 

C++의 다이아몬드 문제와의 차이

C++은 클래스의 다중 상속을 허용하지만, A라는 공통 수퍼클래스를 상속하는 B와 C를 동시에 상속받을 경우 D는 A의 복사본을 두 개 갖게 된다. 이게 바로 다이아몬드 문제다. 멤버 상태를 공유하지 못해 혼란이 발생한다.

자바는 상태를 갖는 클래스 다중 상속을 허용하지 않아서 이런 문제를 피한다. 디폴트 메서드는 동작만 공유하기 때문에 안정적으로 다중 상속의 장점을 살릴 수 있다.

class D extends B,C {}

class B extends A {} 

class C extends A {}

 

 

 

 

 

 

반응형