✏️[모던 자바 인 액션, 전문가를 위한 자바 8, 9, 10 기법 가이드] 스터디 관련 책 내용을 정리한 글입니다.

📌 이 장의 내용
- 디폴트 메서드란 무엇인가?
- 진화하는 API가 호환성을 유지하는 방법
- 디폴트 메서드의 활용 패턴
- 해결 규칙
자바 8에서는 인터페이스 변경으로 인한 문제를 해결하기 위해 기본 구현을 포함하는 인터페이스를 정의할 수 있는 방법을 제공합니다. 정적 메서드와 디폴트 메서드 기능을 사용하여 인터페이스에 메서드 구현을 포함시킬 수 있습니다. 이를 통해 기존 인터페이스를 구현하는 클래스는 새로운 메서드의 디폴트 메서드를 자동으로 상속받아, 기존 코드를 변경하지 않고도 인터페이스를 수정할 수 있습니다. List 인터페이스의 sort와 Collection 인터페이스의 stream 메서드가 이러한 방식으로 추가된 예입니다.
1장에서 살펴본 List 인터페이스의 sort 메서드는 자바 8에서 새로 추가된 메서드입니다. 다음은 sort의 구현 코드입니다.
default void sort(Comparator<? super E> c) {
Collections.sort(this, c);
}
default 키워드는 메서드가 디폴트 메서드임을 나타냅니다. 이를 통해 리스트에 직접 sort 메서드를 호출할 수 있게 되며, 해당 메서드는 Collections.sort 메서드를 사용합니다. 디폴트 메서드의 도입으로 인해 인터페이스를 더 유연하게 사용할 수 있습니다.
List<Integer> numbers = Arrays.asList(3, 5, 1, 2, 6);
numbers.sort(Comparator.naturalOrder()); // sort는 List 인터페이스의 디폴트 메서드입니다.
Comparator.naturalOrder는 Comparator 인터페이스에 추가된 새로운 정적 메서드로, 자연 순서(표준 알파벳 순서)로 요소를 정렬할 수 있게 하는 Comparator 객체를 반환합니다. 이를 사용하면 간편하게 자연 순서로 정렬할 수 있습니다.
디폴트 메서드는 인터페이스에 메서드 구현을 추가할 수 있어 유연성을 제공하며, 자바 API 호환성을 유지하면서 라이브러리 변경을 가능하게 합니다. 라이브러리 설계자와 일반 개발자 모두에게 도움이 되며, 다중 상속 동작과 프로그램 구성에 영향을 줍니다. 인터페이스와 추상 클래스는 여전히 차이가 있음에도 불구하고 이 기능을 이해하고 활용하면 인터페이스 관리에 큰 도움이 됩니다.
정적 메서드와 인터페이스
자바에서는 보통 인터페이스와 정적 메서드를 활용한 유틸리티 클래스를 사용해 왔으며, Collections 등이 대표적인 예시입니다. 하지만 자바 8부터는 인터페이스 내부에서 직접 정적 메서드를 선언할 수 있게 되어 유틸리티 클래스를 없앨 수 있게 되었습니다. 그러나 이전 버전과의 호환성을 유지하기 위해 자바 API에는 여전히 유틸리티 클래스가 존재합니다.
이 장에서는 먼저 API 변경으로 인해 발생하는 문제를 확인하고, 이를 해결하기 위한 디폴트 메서드의 개념을 설명합니다. 디폴트 메서드를 사용하여 다중 상속을 구현하는 방법도 소개하며, 같은 시그니처를 가진 여러 디폴트 메서드를 상속받아 발생하는 모호성 문제를 자바 컴파일러가 해결하는 방법도 살펴봅니다.
13.1 변화하는 API
API를 바꾸는 것은 어려운 일입니다.. 예를 들어, 자바 라이브러리 설계자가 만든 Resizable 인터페이스에 몇 가지 기능이 더 필요하다는 것을 깨닫고, setRelativeSize 메서드를 추가하고 Square와 Rectangle 구현도 수정했다고 가정합니다. 그러나 이미 Resizable 인터페이스를 구현한 사용자가 있는데, 그들이 자신이 만든 클래스를 어떻게 처리할지는 설계자가 제어할 수 없다는 문제가 있습니다. 따라서 API를 변경할 때는 예기치 않은 문제가 발생할 가능성이 있으므로 신중하게 검토해야 합니다.
13.1.1 API 버전 1
Resizable 인터페이스 초기 버전은 다음과 같은 메서드를 포함합니다.
public interface Resizable extends Drawable {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAboluteSize(int width, int height);
}
사용자 구현
우리 라이브러리를 즐겨 사용하는 사용자 중 한 명은 직접 Resizable을 구현하는 Ellipse 클래스를 만들었습니다.
public class Ellipse implements Resizable {
...
}
이 사용자는 다양한 Resizable 모양을 처리하는 게임을 만들었습니다.
public class Game {
public static void main(String... args) {
List<Resizable> resizableShapes = Arrays.asList(new Square(),
new Rectangle(),
new Ellipse()); // 크기를 조절할 수 있는 모양 리스트
Utils.paint(resizableShapes);
}
}
public class Utils {
public static void paint(List<Resizable> l) {
l.forEach(r -> {r.setAbsoluteSize(42, 42); // 각 모양에 setAbsoluteSize 호출
r.draw();
});
}
}
13.1.2 API 버전 2
수많은 요청을 받아 Resizable 기능을 갖춘 Square와 Rectangle 클래스를 개선하기 위해 API 버전 2를 만들었습니다.
public interface Resizable {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
void setRelativeSize(int wFactor, int hFactor); // API 버전2에 추가된 새로운 메서드
}
사용자가 겪는 문제
Resizable을 수정하면 몇 가지 문제가 발생합니다. 첫째, Resizable을 구현하는 모든 클래스는 setRelativeSize 메서드를 구현해야 하지만, 사용자가 직접 구현한 Ellipse는 이 메서드를 구현하지 않습니다. 인터페이스에 새로운 메서드를 추가하면 바이너리 호환성이 유지되지만, 누군가가 Utils.paint에서 setRelativeSize를 사용하도록 코드를 변경하면, Ellipse 객체가 전달될 때 런타임 에러가 발생할 것입니다.
Exception in thread "main" java.lang.AbstractMethodError: lambdasinaction.chap9.
Ellipse.setRelativeSize(II)V
두 번째 문제는 사용자가 Ellipse를 포함하는 전체 애플리케이션을 재빌드할 때 컴파일 에러가 발생합니다.
lambdasinaction/chap9/Ellipse.java:6: error: Ellipse is not abstract and does not
override abstract method setRelativeSize(int, int) int Resizable
기존 API를 수정하면 호환성 문제가 발생하기 때문에 고치기 어렵습니다. 대안으로 자체 API를 만들어 관리할 수 있지만, 라이브러리 관리가 복잡해지고, 사용자가 두 가지 버전의 라이브러리를 사용해야 하며, 메모리 사용과 로딩 시간문제가 발생할 수 있습니다. 이러한 문제를 해결하기 위해 디폴트 메서드를 사용하여 인터페이스에서 자동으로 기본 구현을 제공할 수 있으며, 이를 통해 기존 코드를 수정하지 않아도 됩니다.
13.2 디폴트 메서드란 무엇인가?
새로운 메서드를 추가하면 기존 구현에 문제가 생기는데, 자바 8에서는 호환성을 유지하면서 디폴트 메서드를 제공하여 해결합니다. 인터페이스에서 구현하지 않은 메서드는 디폴트 메서드로 제공되며, 이는 default 키워드로 시작하며 메서드 바디를 포함합니다. 예를 들어 Sized 인터페이스는 size 추상 메서드와 isEmpty 디폴트 메서드를 포함합니다.
public interface Sized {
int size();
default boolean isEmpty() { // 디폴트 메서드
return size() == 0;
}
}
인터페이스에 디폴트 메서드를 추가하면 해당 인터페이스를 구현하는 모든 클래스가 해당 메서드의 구현을 상속받습니다. 이를 통해 소스 호환성을 유지하면서 새로운 기능을 추가할 수 있습니다. 예를 들어 setRelativeSize의 디폴트 구현을 제공하여 호환성을 유지하면서 라이브러리를 수정할 수 있습니다.
default void setRelativeSize(int wFactor, int hFactor) {
setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
}
인터페이스가 구현을 가질 수 있고 클래스는 여러 인터페이스를 구현할 수 있으므로, 자바에서는 인터페이스를 통해 다중 상속을 지원합니다. 인터페이스를 구현하는 클래스가 디폴트 메서드와 같은 메서드 시그니처를 정의하거나 오버라이드하는 경우에 대한 규칙이 있으며, 이는 13.4절에서 다룹니다. 자바 8 API에서는 많은 인터페이스에서 디폴트 메서드를 활용하고 있으며, Collection, List, Predicate, Function, Comparator 등 많은 인터페이스에서 다양한 디폴트 메서드를 제공하고 있습니다.
추상 클래스와 자바 8의 인터페이스
추상 클래스와 인터페이스는 추상 메서드와 바디를 포함하는 메서드를 정의할 수 있지만, 다음과 같은 차이점이 있습니다.
첫째, 추상 클래스는 하나의 추상 클래스만 상속받을 수 있지만 인터페이스는 여러 개 구현할 수 있습니다.
둘째, 추상 클래스는 인스턴스 변수를 가질 수 있어 공통 상태를 가질 수 있지만, 인터페이스는 인스턴스 변수를 가질 수 없습니다.
13.3 디폴트 메서드 활용 패턴
디폴트 메서드를 사용하면 라이브러리 변경 시 호환성을 유지할 수 있습니다. 디폴트 메서드를 활용하는 두 가지 방식은 선택형 메서드와 동작 다중 상속입니다. 이를 통해 인터페이스에 디폴트 메서드를 추가하고 확장성을 높일 수 있습니다.
13.3.1 선택형 메서드
인터페이스를 구현하는 클래스에서 메서드의 내용이 비어있는 상황을 볼 수 있습니다. 예로 Iterator 인터페이스의 remove 메서드는 사용자들이 잘 사용하지 않아 빈 구현을 제공하는 경우가 많았습니다. 디폴트 메서드를 사용하면 기본 구현을 제공할 수 있어 인터페이스를 구현하는 클래스에서 빈 구현을 제공할 필요가 없습니다. 자바 8의 Iterator 인터페이스는 이를 통해 remove 메서드에 기본 구현을 정의합니다.
interface Iterator<T> {
boolean hasNext();
T next();
default void remove() {
throw new UnsupportedOperationException();
}
}
디폴트 메서드를 사용해 기본 구현이 제공되면, Iterator 인터페이스를 구현하는 클래스에서 빈 remove 메서드를 구현할 필요가 없어지고, 이를 통해 불필요한 코드를 줄일 수 있습니다.
13.3.2 동작 다중 상속
디폴트 메서드를 활용하여 인터페이스에서 다중 상속과 같은 기능을 구현할 수 있습니다. 자바에서 클래스는 다중 상속이 불가능하지만 인터페이스는 여러 개 구현이 가능합니다. 예를 들어, 자바 API의 ArrayList 클래스는 인터페이스를 구현하여 다른 클래스에서도 활용할 수 있도록 만들어졌습니다.
public class ArrayList<E> extends AbstractList<E> // 한 개의 클래스를 상속 받는다.
implements List<E>, RandomAccess, Cloneable, Serializable { // 네 개의 인터페이스를 구현합니다.
}
다중 상속 형식
ArrayList는 클래스 하나와 여섯 개의 인터페이스를 구현하여 다중 상속을 활용합니다. 자바 8부터는 인터페이스가 구현을 포함할 수 있기 때문에 클래스는 여러 인터페이스에서 동작을 상속받을 수 있습니다. 이를 통해 코드에서 동작을 쉽게 재사용하고 조합할 수 있으며, 중복을 최소화할 수 있습니다.
기능이 중복되지 않는 최소의 인터페이스
게임에서 여러 모양을 정의할 때, 회전이 가능하고 움직일 수 있는 모양과 크기를 조절할 수 있는 모양이 있습니다. 이 기능을 최대한 기존 코드를 재사용하여 구현하려면 Rotatable 인터페이스를 정의합니다. 이 인터페이스는 setRotationAngle과 getRotationAngle 메서드를 포함하며, rotateBy 메서드도 디폴트 메서드로 구현됩니다.
public interface Rotatable {
void setRotationAngle(int angleInDegrees);
int getRotationAngle();
default void rotateBy(int angleInDegrees) { // rotateBy 메서드의 기본 구현
setRotationAngle((getRotationAngle() + angleInDegrees) % 360);
}
}
Rotatable 인터페이스는 구현해야 할 다른 메서드에 따라 뼈대 알고리즘이 결정되는 템플릿 디자인 패턴과 유사합니다. 구현 클래스는 setRotationAngle과 getRotationAngle을 구현해야 하고, rotateBy는 디폴트 구현을 제공합니다. Moveable과 Resizable 인터페이스도 정의해야 하며, 두 인터페이스 모두 디폴트 구현을 제공합니다. 다음은 Moveable, Resizble 코드입니다.
public interface Moveable {
int getX();
int getY();
void setX(int x);
void setY(int y);
default void moveHorizontally(int distance) {
setX(getX() + distance);
}
default void moveVertically(int distance) {
setY(getY() + distance);
}
}
public interface Resizable {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
default void setRelativeSize(int wFactor, int hFactor) {
setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
}
}
인터페이스 조합
Rotatable, Moveable, Resizable 인터페이스를 조합하여 Monster 클래스를 구현하면, 움직일 수 있고, 회전할 수 있으며, 크기를 조절할 수 있는 괴물 클래스를 만들 수 있습니다. Monster 클래스는 Rotatable, Moveable, Resizable 인터페이스의 디폴트 메서드를 직접 호출할 수 있으며, rotateBy, moveHorizontally, moveVertically, setRelativeSize 메서드를 사용할 수 있습니다.
public class Monster implements Rotatable, Moveable, Resizable {
... // 모든 추상 메서드의 구현을 제공해야 하지만, 디폴트 메서드의 구현은 제공할 필요가 없습니다.
}
Monster m = new Monster(); // 생성자는 내부적으로 좌표, 높이, 너비, 기본 각도를 설정합니다.
m.rotateBy(180); // Rotatable의 RotateBy 호출
m.moveVertically(10); // Moveable의 moveVertically 호출
Sun 클래스를 정의할 때 Moveable과 Rotatable 인터페이스를 구현하면서 디폴트 메서드를 자동으로 재사용할 수 있으므로, 코드를 복사&붙여 넣기 할 필요가 없습니다. Sun 클래스는 움직일 수 있고 회전할 수 있지만, 크기는 조절할 수 없습니다.
public class Sun implements Moveable, Rotatable {
... // 모든 추상 메서드의 구현은 제공해야 하지만, 디폴트 메서드의 구현은 제공할 필요가 없습니다.
}
인터페이스에 디폴트 구현을 포함시키면 해당 인터페이스를 구현하는 모든 클래스가 자동으로 변경된 코드를 상속받으므로 유지보수성이 높아집니다. 디폴트 메서드를 수정하면 구현 클래스에서도 자동으로 변경된 코드를 상속받을 수 있기 때문입니다.
옳지 못한 상속
상속으로 모든 코드 재사용 문제를 해결할 수는 없으며, 클래스를 상속받는 것이 유리하지 않은 경우 멤버 변수를 이용한 델리게이션 방식을 고려해야 합니다. final로 선언된 클래스를 통해 핵심 기능을 보호할 수 있으며, 인터페이스도 최소한으로 유지하여 필요한 기능만 선택적으로 구현할 수 있도록 하는 것이 좋습니다.
다중 인터페이스 구현 시, 같은 디폴트 메서드 시그니처를 포함하는 두 인터페이스를 구현하는 경우 어떤 인터페이스의 디폴트 메서드를 사용할지에 대한 문제가 발생할 수 있습니다. 이 문제를 자세히 다룰 예정입니다.
13.4 해석 규칙
자바에서는 클래스는 하나의 부모 클래스만 상속받을 수 있지만, 여러 인터페이스를 동시에 구현할 수 있고, 이때 같은 시그니처를 갖는 디폴트 메서드를 상속받는 경우 충돌이 발생할 수 있습니다. 이를 해결하기 위해 자바 8은 디폴트 메서드 충돌 해결 규칙을 제공합니다. 이는 C++의 다이아몬드 문제와 비슷한 문제이지만, 자바에서는 충돌을 해결하기 위한 규칙이 제공됩니다.
13.4.1 알아야 할 세 가지 해결 규칙
다른 클래스나 인터페이스로부터 같은 시그니처를 갖는 메서드를 상속받을 때는 세 가지 규칙을 따라야 합니다.
- 클래스나 슈퍼 클래스에서 정의한 메서드가 디폴트 메서드보다 우선순위가 높습니다.
- 1번 규칙 이외의 상황에서는 서브 인터페이스가 이깁니다. 같은 시그니처를 갖는 메서드를 상속받는 인터페이스 중 서브 인터페이스가 우선권을 가집니다.
- 디폴트 메서드의 우선순위가 결정되지 않았을 때, 여러 인터페이스를 상속받는 클래스가 명시적으로 디폴트 메서드를 오버라이드하고 호출해야 한다는 것입니다.
이 세 가지 규칙만 알면 모든 디폴트 메서드 해석 문제가 해결됩니다.
13.4.2 디폴트 메서드를 제공하는 서브 인터페이스가 이긴다
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B extends A {
default void hello() {
System.out.println("Hello from B");
}
}
public class C implements B, A {
public static void main(String... args) {
new C().hello();
}
}
B와 A가 같은 시그니처를 갖는 hello 디폴트 메서드를 제공하고, B는 A를 상속받았다. 2번 규칙에 따라 서브 인터페이스인 B의 hello 메서드를 선택하여 "Hello from B"를 출력한다.
다음은 C가 D를 상속받으면 어떤 결과가 나오는지 확인하는 코드입니다.
public class D implements A{ }
public class C extends D implements B, A {
public static void main(String... args) {
new C.hello();
}
}
D는 인터페이스 A의 디폴트 메서드를 상속받아 출력됩니다. 디폴트 메서드 정의는 B가 상속받은 A를 따르므로 "Hello from B"가 출력됩니다.
13.4.3 충돌 그리고 명시적인 문제 해결
지금까지 1번과 2번 규칙으로 문제를 해결할 수 있었습니다. 이번에는 B가 A를 상속받지 않는 상황이라고 가정합니다.
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B {
default void hello() {
System.out.println("Hello from B");
}
}
public class C implements B, A { }
인터페이스 간에 상속관계가 없으면 2번 규칙을 적용할 수 없으므로 어떤 메서드를 호출해야 할지 알 수 없는데, 이때 "Error: class C inherits unrelated defaults for hello() from types B and A."같은 에러가 발생한다.
충돌 해결
클래스와 메서드 관계로 디폴트 메서드를 선택할 수 없는 상황에서는 개발자가 직접 명시적으로 선택해야 합니다. 자바 8에서는 X.super.m(...) 형태의 문법을 제공하며, 여기서 X는 호출하려는 메서드 m의 슈퍼 인터페이스를 가리킵니다. 예를 들어 다음처럼 C에서 B의 인터페이스를 호출할 수 있습니다.
public class C implements B, A {
void hello() {
B.super.hello(); // 명시적으로 인터페이스 B의 메서드를 선택합니다.
}
}
13.4.4 다이아몬드 문제
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B extends A { }
public interface C extends A { }
public class D implements B, C {
public static void main(String... args) {
new D().hello();
}
}
아래 그림은 위 코드의 UML 다이어그램은 다이아몬드 문제를 보여줍니다. D는 B와 C 중 어떤 디폴트 메서드의 정의를 상속받을지 선택할 수 없습니다. 하지만 A만 디폴트 메서드를 정의하고 있으므로 "Hello from A"가 출력됩니다.

인터페이스 C에 추상 메서드 hello가 추가되면 클래스 D가 인터페이스 C의 hello 메서드를 구현하거나 인터페이스 C를 구현하는 다른 클래스를 사용해야 합니다. 그렇지 않으면 D는 컴파일 오류를 일으킵니다. 디폴트 메서드와 추상 메서드가 함께 존재하는 경우 디폴트 메서드는 항상 추상 메서드보다 우선합니다. 따라서 D가 인터페이스 C의 hello 메서드를 구현하지 않으면 B의 디폴트 메서드가 선택됩니다.
public interface C extends A {
void hello();
}
클래스 D에서 B와 C의 hello 메서드가 동시에 상속되는 다이아몬드 문제에서, B에도 같은 시그니처의 디폴트 메서드 hello가 있다면, 2번 규칙에 따라 B가 선택됩니다. 그러나 만약 C에 추상 메서드 hello를 추가한다면, C의 hello 메서드가 A의 디폴트 메서드 hello보다 우선권을 갖기 때문에 컴파일 에러가 발생합니다. 따라서 클래스 D에서는 어떤 hello 메서드를 사용할지 명시적으로 선택해서 에러를 해결해야 합니다.
'📚 서적 및 학습자료 > 모던 자바 인 액션[2회독]' 카테고리의 다른 글
[모던 자바 인 액션] 15장 CompletableFuture와 리액티브 프로그래밍 컨셉의 기초 (0) | 2023.03.29 |
---|---|
[모던 자바 인 액션] 14장 자바 모듈 시스템 (0) | 2023.03.27 |
[모던 자바 인 액션] 12장 새로운 날짜와 시간 API (0) | 2023.03.26 |
[모던 자바 인 액션] 11장 null 대신 Optional 클래스 (0) | 2023.03.25 |
[모던 자바 인 액션] 10장 람다를 이용한 도메인 전용 언어 (0) | 2023.03.23 |
✏️[모던 자바 인 액션, 전문가를 위한 자바 8, 9, 10 기법 가이드] 스터디 관련 책 내용을 정리한 글입니다.

📌 이 장의 내용
- 디폴트 메서드란 무엇인가?
- 진화하는 API가 호환성을 유지하는 방법
- 디폴트 메서드의 활용 패턴
- 해결 규칙
자바 8에서는 인터페이스 변경으로 인한 문제를 해결하기 위해 기본 구현을 포함하는 인터페이스를 정의할 수 있는 방법을 제공합니다. 정적 메서드와 디폴트 메서드 기능을 사용하여 인터페이스에 메서드 구현을 포함시킬 수 있습니다. 이를 통해 기존 인터페이스를 구현하는 클래스는 새로운 메서드의 디폴트 메서드를 자동으로 상속받아, 기존 코드를 변경하지 않고도 인터페이스를 수정할 수 있습니다. List 인터페이스의 sort와 Collection 인터페이스의 stream 메서드가 이러한 방식으로 추가된 예입니다.
1장에서 살펴본 List 인터페이스의 sort 메서드는 자바 8에서 새로 추가된 메서드입니다. 다음은 sort의 구현 코드입니다.
default void sort(Comparator<? super E> c) {
Collections.sort(this, c);
}
default 키워드는 메서드가 디폴트 메서드임을 나타냅니다. 이를 통해 리스트에 직접 sort 메서드를 호출할 수 있게 되며, 해당 메서드는 Collections.sort 메서드를 사용합니다. 디폴트 메서드의 도입으로 인해 인터페이스를 더 유연하게 사용할 수 있습니다.
List<Integer> numbers = Arrays.asList(3, 5, 1, 2, 6);
numbers.sort(Comparator.naturalOrder()); // sort는 List 인터페이스의 디폴트 메서드입니다.
Comparator.naturalOrder는 Comparator 인터페이스에 추가된 새로운 정적 메서드로, 자연 순서(표준 알파벳 순서)로 요소를 정렬할 수 있게 하는 Comparator 객체를 반환합니다. 이를 사용하면 간편하게 자연 순서로 정렬할 수 있습니다.
디폴트 메서드는 인터페이스에 메서드 구현을 추가할 수 있어 유연성을 제공하며, 자바 API 호환성을 유지하면서 라이브러리 변경을 가능하게 합니다. 라이브러리 설계자와 일반 개발자 모두에게 도움이 되며, 다중 상속 동작과 프로그램 구성에 영향을 줍니다. 인터페이스와 추상 클래스는 여전히 차이가 있음에도 불구하고 이 기능을 이해하고 활용하면 인터페이스 관리에 큰 도움이 됩니다.
정적 메서드와 인터페이스
자바에서는 보통 인터페이스와 정적 메서드를 활용한 유틸리티 클래스를 사용해 왔으며, Collections 등이 대표적인 예시입니다. 하지만 자바 8부터는 인터페이스 내부에서 직접 정적 메서드를 선언할 수 있게 되어 유틸리티 클래스를 없앨 수 있게 되었습니다. 그러나 이전 버전과의 호환성을 유지하기 위해 자바 API에는 여전히 유틸리티 클래스가 존재합니다.
이 장에서는 먼저 API 변경으로 인해 발생하는 문제를 확인하고, 이를 해결하기 위한 디폴트 메서드의 개념을 설명합니다. 디폴트 메서드를 사용하여 다중 상속을 구현하는 방법도 소개하며, 같은 시그니처를 가진 여러 디폴트 메서드를 상속받아 발생하는 모호성 문제를 자바 컴파일러가 해결하는 방법도 살펴봅니다.
13.1 변화하는 API
API를 바꾸는 것은 어려운 일입니다.. 예를 들어, 자바 라이브러리 설계자가 만든 Resizable 인터페이스에 몇 가지 기능이 더 필요하다는 것을 깨닫고, setRelativeSize 메서드를 추가하고 Square와 Rectangle 구현도 수정했다고 가정합니다. 그러나 이미 Resizable 인터페이스를 구현한 사용자가 있는데, 그들이 자신이 만든 클래스를 어떻게 처리할지는 설계자가 제어할 수 없다는 문제가 있습니다. 따라서 API를 변경할 때는 예기치 않은 문제가 발생할 가능성이 있으므로 신중하게 검토해야 합니다.
13.1.1 API 버전 1
Resizable 인터페이스 초기 버전은 다음과 같은 메서드를 포함합니다.
public interface Resizable extends Drawable {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAboluteSize(int width, int height);
}
사용자 구현
우리 라이브러리를 즐겨 사용하는 사용자 중 한 명은 직접 Resizable을 구현하는 Ellipse 클래스를 만들었습니다.
public class Ellipse implements Resizable {
...
}
이 사용자는 다양한 Resizable 모양을 처리하는 게임을 만들었습니다.
public class Game {
public static void main(String... args) {
List<Resizable> resizableShapes = Arrays.asList(new Square(),
new Rectangle(),
new Ellipse()); // 크기를 조절할 수 있는 모양 리스트
Utils.paint(resizableShapes);
}
}
public class Utils {
public static void paint(List<Resizable> l) {
l.forEach(r -> {r.setAbsoluteSize(42, 42); // 각 모양에 setAbsoluteSize 호출
r.draw();
});
}
}
13.1.2 API 버전 2
수많은 요청을 받아 Resizable 기능을 갖춘 Square와 Rectangle 클래스를 개선하기 위해 API 버전 2를 만들었습니다.
public interface Resizable {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
void setRelativeSize(int wFactor, int hFactor); // API 버전2에 추가된 새로운 메서드
}
사용자가 겪는 문제
Resizable을 수정하면 몇 가지 문제가 발생합니다. 첫째, Resizable을 구현하는 모든 클래스는 setRelativeSize 메서드를 구현해야 하지만, 사용자가 직접 구현한 Ellipse는 이 메서드를 구현하지 않습니다. 인터페이스에 새로운 메서드를 추가하면 바이너리 호환성이 유지되지만, 누군가가 Utils.paint에서 setRelativeSize를 사용하도록 코드를 변경하면, Ellipse 객체가 전달될 때 런타임 에러가 발생할 것입니다.
Exception in thread "main" java.lang.AbstractMethodError: lambdasinaction.chap9.
Ellipse.setRelativeSize(II)V
두 번째 문제는 사용자가 Ellipse를 포함하는 전체 애플리케이션을 재빌드할 때 컴파일 에러가 발생합니다.
lambdasinaction/chap9/Ellipse.java:6: error: Ellipse is not abstract and does not
override abstract method setRelativeSize(int, int) int Resizable
기존 API를 수정하면 호환성 문제가 발생하기 때문에 고치기 어렵습니다. 대안으로 자체 API를 만들어 관리할 수 있지만, 라이브러리 관리가 복잡해지고, 사용자가 두 가지 버전의 라이브러리를 사용해야 하며, 메모리 사용과 로딩 시간문제가 발생할 수 있습니다. 이러한 문제를 해결하기 위해 디폴트 메서드를 사용하여 인터페이스에서 자동으로 기본 구현을 제공할 수 있으며, 이를 통해 기존 코드를 수정하지 않아도 됩니다.
13.2 디폴트 메서드란 무엇인가?
새로운 메서드를 추가하면 기존 구현에 문제가 생기는데, 자바 8에서는 호환성을 유지하면서 디폴트 메서드를 제공하여 해결합니다. 인터페이스에서 구현하지 않은 메서드는 디폴트 메서드로 제공되며, 이는 default 키워드로 시작하며 메서드 바디를 포함합니다. 예를 들어 Sized 인터페이스는 size 추상 메서드와 isEmpty 디폴트 메서드를 포함합니다.
public interface Sized {
int size();
default boolean isEmpty() { // 디폴트 메서드
return size() == 0;
}
}
인터페이스에 디폴트 메서드를 추가하면 해당 인터페이스를 구현하는 모든 클래스가 해당 메서드의 구현을 상속받습니다. 이를 통해 소스 호환성을 유지하면서 새로운 기능을 추가할 수 있습니다. 예를 들어 setRelativeSize의 디폴트 구현을 제공하여 호환성을 유지하면서 라이브러리를 수정할 수 있습니다.
default void setRelativeSize(int wFactor, int hFactor) {
setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
}
인터페이스가 구현을 가질 수 있고 클래스는 여러 인터페이스를 구현할 수 있으므로, 자바에서는 인터페이스를 통해 다중 상속을 지원합니다. 인터페이스를 구현하는 클래스가 디폴트 메서드와 같은 메서드 시그니처를 정의하거나 오버라이드하는 경우에 대한 규칙이 있으며, 이는 13.4절에서 다룹니다. 자바 8 API에서는 많은 인터페이스에서 디폴트 메서드를 활용하고 있으며, Collection, List, Predicate, Function, Comparator 등 많은 인터페이스에서 다양한 디폴트 메서드를 제공하고 있습니다.
추상 클래스와 자바 8의 인터페이스
추상 클래스와 인터페이스는 추상 메서드와 바디를 포함하는 메서드를 정의할 수 있지만, 다음과 같은 차이점이 있습니다.
첫째, 추상 클래스는 하나의 추상 클래스만 상속받을 수 있지만 인터페이스는 여러 개 구현할 수 있습니다.
둘째, 추상 클래스는 인스턴스 변수를 가질 수 있어 공통 상태를 가질 수 있지만, 인터페이스는 인스턴스 변수를 가질 수 없습니다.
13.3 디폴트 메서드 활용 패턴
디폴트 메서드를 사용하면 라이브러리 변경 시 호환성을 유지할 수 있습니다. 디폴트 메서드를 활용하는 두 가지 방식은 선택형 메서드와 동작 다중 상속입니다. 이를 통해 인터페이스에 디폴트 메서드를 추가하고 확장성을 높일 수 있습니다.
13.3.1 선택형 메서드
인터페이스를 구현하는 클래스에서 메서드의 내용이 비어있는 상황을 볼 수 있습니다. 예로 Iterator 인터페이스의 remove 메서드는 사용자들이 잘 사용하지 않아 빈 구현을 제공하는 경우가 많았습니다. 디폴트 메서드를 사용하면 기본 구현을 제공할 수 있어 인터페이스를 구현하는 클래스에서 빈 구현을 제공할 필요가 없습니다. 자바 8의 Iterator 인터페이스는 이를 통해 remove 메서드에 기본 구현을 정의합니다.
interface Iterator<T> {
boolean hasNext();
T next();
default void remove() {
throw new UnsupportedOperationException();
}
}
디폴트 메서드를 사용해 기본 구현이 제공되면, Iterator 인터페이스를 구현하는 클래스에서 빈 remove 메서드를 구현할 필요가 없어지고, 이를 통해 불필요한 코드를 줄일 수 있습니다.
13.3.2 동작 다중 상속
디폴트 메서드를 활용하여 인터페이스에서 다중 상속과 같은 기능을 구현할 수 있습니다. 자바에서 클래스는 다중 상속이 불가능하지만 인터페이스는 여러 개 구현이 가능합니다. 예를 들어, 자바 API의 ArrayList 클래스는 인터페이스를 구현하여 다른 클래스에서도 활용할 수 있도록 만들어졌습니다.
public class ArrayList<E> extends AbstractList<E> // 한 개의 클래스를 상속 받는다.
implements List<E>, RandomAccess, Cloneable, Serializable { // 네 개의 인터페이스를 구현합니다.
}
다중 상속 형식
ArrayList는 클래스 하나와 여섯 개의 인터페이스를 구현하여 다중 상속을 활용합니다. 자바 8부터는 인터페이스가 구현을 포함할 수 있기 때문에 클래스는 여러 인터페이스에서 동작을 상속받을 수 있습니다. 이를 통해 코드에서 동작을 쉽게 재사용하고 조합할 수 있으며, 중복을 최소화할 수 있습니다.
기능이 중복되지 않는 최소의 인터페이스
게임에서 여러 모양을 정의할 때, 회전이 가능하고 움직일 수 있는 모양과 크기를 조절할 수 있는 모양이 있습니다. 이 기능을 최대한 기존 코드를 재사용하여 구현하려면 Rotatable 인터페이스를 정의합니다. 이 인터페이스는 setRotationAngle과 getRotationAngle 메서드를 포함하며, rotateBy 메서드도 디폴트 메서드로 구현됩니다.
public interface Rotatable {
void setRotationAngle(int angleInDegrees);
int getRotationAngle();
default void rotateBy(int angleInDegrees) { // rotateBy 메서드의 기본 구현
setRotationAngle((getRotationAngle() + angleInDegrees) % 360);
}
}
Rotatable 인터페이스는 구현해야 할 다른 메서드에 따라 뼈대 알고리즘이 결정되는 템플릿 디자인 패턴과 유사합니다. 구현 클래스는 setRotationAngle과 getRotationAngle을 구현해야 하고, rotateBy는 디폴트 구현을 제공합니다. Moveable과 Resizable 인터페이스도 정의해야 하며, 두 인터페이스 모두 디폴트 구현을 제공합니다. 다음은 Moveable, Resizble 코드입니다.
public interface Moveable {
int getX();
int getY();
void setX(int x);
void setY(int y);
default void moveHorizontally(int distance) {
setX(getX() + distance);
}
default void moveVertically(int distance) {
setY(getY() + distance);
}
}
public interface Resizable {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
default void setRelativeSize(int wFactor, int hFactor) {
setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
}
}
인터페이스 조합
Rotatable, Moveable, Resizable 인터페이스를 조합하여 Monster 클래스를 구현하면, 움직일 수 있고, 회전할 수 있으며, 크기를 조절할 수 있는 괴물 클래스를 만들 수 있습니다. Monster 클래스는 Rotatable, Moveable, Resizable 인터페이스의 디폴트 메서드를 직접 호출할 수 있으며, rotateBy, moveHorizontally, moveVertically, setRelativeSize 메서드를 사용할 수 있습니다.
public class Monster implements Rotatable, Moveable, Resizable {
... // 모든 추상 메서드의 구현을 제공해야 하지만, 디폴트 메서드의 구현은 제공할 필요가 없습니다.
}
Monster m = new Monster(); // 생성자는 내부적으로 좌표, 높이, 너비, 기본 각도를 설정합니다.
m.rotateBy(180); // Rotatable의 RotateBy 호출
m.moveVertically(10); // Moveable의 moveVertically 호출
Sun 클래스를 정의할 때 Moveable과 Rotatable 인터페이스를 구현하면서 디폴트 메서드를 자동으로 재사용할 수 있으므로, 코드를 복사&붙여 넣기 할 필요가 없습니다. Sun 클래스는 움직일 수 있고 회전할 수 있지만, 크기는 조절할 수 없습니다.
public class Sun implements Moveable, Rotatable {
... // 모든 추상 메서드의 구현은 제공해야 하지만, 디폴트 메서드의 구현은 제공할 필요가 없습니다.
}
인터페이스에 디폴트 구현을 포함시키면 해당 인터페이스를 구현하는 모든 클래스가 자동으로 변경된 코드를 상속받으므로 유지보수성이 높아집니다. 디폴트 메서드를 수정하면 구현 클래스에서도 자동으로 변경된 코드를 상속받을 수 있기 때문입니다.
옳지 못한 상속
상속으로 모든 코드 재사용 문제를 해결할 수는 없으며, 클래스를 상속받는 것이 유리하지 않은 경우 멤버 변수를 이용한 델리게이션 방식을 고려해야 합니다. final로 선언된 클래스를 통해 핵심 기능을 보호할 수 있으며, 인터페이스도 최소한으로 유지하여 필요한 기능만 선택적으로 구현할 수 있도록 하는 것이 좋습니다.
다중 인터페이스 구현 시, 같은 디폴트 메서드 시그니처를 포함하는 두 인터페이스를 구현하는 경우 어떤 인터페이스의 디폴트 메서드를 사용할지에 대한 문제가 발생할 수 있습니다. 이 문제를 자세히 다룰 예정입니다.
13.4 해석 규칙
자바에서는 클래스는 하나의 부모 클래스만 상속받을 수 있지만, 여러 인터페이스를 동시에 구현할 수 있고, 이때 같은 시그니처를 갖는 디폴트 메서드를 상속받는 경우 충돌이 발생할 수 있습니다. 이를 해결하기 위해 자바 8은 디폴트 메서드 충돌 해결 규칙을 제공합니다. 이는 C++의 다이아몬드 문제와 비슷한 문제이지만, 자바에서는 충돌을 해결하기 위한 규칙이 제공됩니다.
13.4.1 알아야 할 세 가지 해결 규칙
다른 클래스나 인터페이스로부터 같은 시그니처를 갖는 메서드를 상속받을 때는 세 가지 규칙을 따라야 합니다.
- 클래스나 슈퍼 클래스에서 정의한 메서드가 디폴트 메서드보다 우선순위가 높습니다.
- 1번 규칙 이외의 상황에서는 서브 인터페이스가 이깁니다. 같은 시그니처를 갖는 메서드를 상속받는 인터페이스 중 서브 인터페이스가 우선권을 가집니다.
- 디폴트 메서드의 우선순위가 결정되지 않았을 때, 여러 인터페이스를 상속받는 클래스가 명시적으로 디폴트 메서드를 오버라이드하고 호출해야 한다는 것입니다.
이 세 가지 규칙만 알면 모든 디폴트 메서드 해석 문제가 해결됩니다.
13.4.2 디폴트 메서드를 제공하는 서브 인터페이스가 이긴다
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B extends A {
default void hello() {
System.out.println("Hello from B");
}
}
public class C implements B, A {
public static void main(String... args) {
new C().hello();
}
}
B와 A가 같은 시그니처를 갖는 hello 디폴트 메서드를 제공하고, B는 A를 상속받았다. 2번 규칙에 따라 서브 인터페이스인 B의 hello 메서드를 선택하여 "Hello from B"를 출력한다.
다음은 C가 D를 상속받으면 어떤 결과가 나오는지 확인하는 코드입니다.
public class D implements A{ }
public class C extends D implements B, A {
public static void main(String... args) {
new C.hello();
}
}
D는 인터페이스 A의 디폴트 메서드를 상속받아 출력됩니다. 디폴트 메서드 정의는 B가 상속받은 A를 따르므로 "Hello from B"가 출력됩니다.
13.4.3 충돌 그리고 명시적인 문제 해결
지금까지 1번과 2번 규칙으로 문제를 해결할 수 있었습니다. 이번에는 B가 A를 상속받지 않는 상황이라고 가정합니다.
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B {
default void hello() {
System.out.println("Hello from B");
}
}
public class C implements B, A { }
인터페이스 간에 상속관계가 없으면 2번 규칙을 적용할 수 없으므로 어떤 메서드를 호출해야 할지 알 수 없는데, 이때 "Error: class C inherits unrelated defaults for hello() from types B and A."같은 에러가 발생한다.
충돌 해결
클래스와 메서드 관계로 디폴트 메서드를 선택할 수 없는 상황에서는 개발자가 직접 명시적으로 선택해야 합니다. 자바 8에서는 X.super.m(...) 형태의 문법을 제공하며, 여기서 X는 호출하려는 메서드 m의 슈퍼 인터페이스를 가리킵니다. 예를 들어 다음처럼 C에서 B의 인터페이스를 호출할 수 있습니다.
public class C implements B, A {
void hello() {
B.super.hello(); // 명시적으로 인터페이스 B의 메서드를 선택합니다.
}
}
13.4.4 다이아몬드 문제
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B extends A { }
public interface C extends A { }
public class D implements B, C {
public static void main(String... args) {
new D().hello();
}
}
아래 그림은 위 코드의 UML 다이어그램은 다이아몬드 문제를 보여줍니다. D는 B와 C 중 어떤 디폴트 메서드의 정의를 상속받을지 선택할 수 없습니다. 하지만 A만 디폴트 메서드를 정의하고 있으므로 "Hello from A"가 출력됩니다.

인터페이스 C에 추상 메서드 hello가 추가되면 클래스 D가 인터페이스 C의 hello 메서드를 구현하거나 인터페이스 C를 구현하는 다른 클래스를 사용해야 합니다. 그렇지 않으면 D는 컴파일 오류를 일으킵니다. 디폴트 메서드와 추상 메서드가 함께 존재하는 경우 디폴트 메서드는 항상 추상 메서드보다 우선합니다. 따라서 D가 인터페이스 C의 hello 메서드를 구현하지 않으면 B의 디폴트 메서드가 선택됩니다.
public interface C extends A {
void hello();
}
클래스 D에서 B와 C의 hello 메서드가 동시에 상속되는 다이아몬드 문제에서, B에도 같은 시그니처의 디폴트 메서드 hello가 있다면, 2번 규칙에 따라 B가 선택됩니다. 그러나 만약 C에 추상 메서드 hello를 추가한다면, C의 hello 메서드가 A의 디폴트 메서드 hello보다 우선권을 갖기 때문에 컴파일 에러가 발생합니다. 따라서 클래스 D에서는 어떤 hello 메서드를 사용할지 명시적으로 선택해서 에러를 해결해야 합니다.
'📚 서적 및 학습자료 > 모던 자바 인 액션[2회독]' 카테고리의 다른 글
[모던 자바 인 액션] 15장 CompletableFuture와 리액티브 프로그래밍 컨셉의 기초 (0) | 2023.03.29 |
---|---|
[모던 자바 인 액션] 14장 자바 모듈 시스템 (0) | 2023.03.27 |
[모던 자바 인 액션] 12장 새로운 날짜와 시간 API (0) | 2023.03.26 |
[모던 자바 인 액션] 11장 null 대신 Optional 클래스 (0) | 2023.03.25 |
[모던 자바 인 액션] 10장 람다를 이용한 도메인 전용 언어 (0) | 2023.03.23 |