2025. 4. 30. 22:16ㆍBook/모던 자바 인 액션
목차.
1. 디폴트 메서드란 무엇인가?
2. 진화하는 API가 호환성을 유지하는 방법
3. 디폴트 메서드의 활용 패턴
4. 해결 규칙
전통적인 자바에서 인터페이스와 관련 메서드는 한 몸처럼 구성된다. 인터페이스를 구현하는 클래스는 인터페이스에서 정의하는 모든 메서드 구현을 제공하거나 아니면 슈퍼클래스의 구현을 상속받아야 한다. 만약 인터페이스에 새로운 메서드를 추가하는 등 인터페이스를 바꾸면 해당 인터페이스의 모든 구현체를 수정해야 한다.
자바 8에서는 이 문제를 해결하기 위해 두 가지 방법을 제공한다.
1. 정적 메서드
2. 디폴트 메서드
자바 8에서는 메서드 구현을 포함하는 인터페이스를 정의할 수 있다. 이렇게 하면 구현체를 수정할 필요 없이 새로운 메서드를 인터페이스에 추가할 수 있다.
인터페이스는 추상 클래스와 같은 점이 많아 졌지만 여전히 다른 점도 있다. 이 내용은 이후에 후술한다.
1. 변화하는 API
API 를 바꾸는 것은 어렵다. 예를 들어 자바 그리기 라이브러리를 설계한다고 가정하자. 모양의 크기를 조절하는 데 필요한 setHeight, setWidth, getHeight, getWidth, setAbsoluteSize 등의 메서드를 정의하는 Resizable 인터페이스가 있다. 그리고 이 인터페이스의 구현체 Rectangle, Square 도 있다. API 를 릴리스한 지 몇 개월이 지나면서 새로운 기능을 추가할 필요가 생겼다고 가정하자. 하지만 인터페이스를 수정하면 기존 라이브러리의 인터페이스를 직접 구현한 사용자들은 갑작스런 에러를 맞이할 것이다.
1.1 API 버전 1
Resizable 인터페이스 초기 버전은 아래와 같다.
public interface Resizable extends Drawble {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(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, 43);
r.draw();
});
}
}
1.2 API 버전 2
몇 개월 후 Resizable을 구현하는 Square와 Rectangle 구현을 개선해달라는 많은 요청을 받아 아래 코드처럼 API 버전 2 를 만들었다.
public interface Resizable extends Drawble {
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에 추가된 새로운 메서드
}
2. 디폴트 메서드란 무엇인가?
자바 8에서는 호환성을 유지하면서 API를 바꿀 수 있도록 새로운 기능인 디폴트 메서드를 제공한다. 인터페이스는 자신을 구현하는 클래스에서 메서드를 구현하지 않을 수 있는 새로운 메서드 시그니처를 제공한다.
default void setRelativeSize(int wFactor, int hFactor) {
setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
}
3. 디폴트 메서드 활용 패턴
3.1 선택형 메서드
인터페이스를 구현하는 클레스에서 메서드의 내용이 비어있는 경우가 있다.
public class Ellipse implements Resizable {
private int width;
private int height;
@Override
public int getWidth() {
return this.width;
}
.
.
.
@Override
public void draw() {
}
}
Resizable 인터페이스를 구현한 Ellipse 클래스가 draw 메서드를 구현하지 않아도 된다는 가정하에 불필요하게 빈 메서드를 구현해야 할 수 있다.
이런 경우 디폴트 메서드를 이용하면 인터페이스에서 기본 구현을 제공해 불필요한 코드를 줄일 수 있다.
public interface Drawble {
default void draw() {
throw new UnsupportedOperationException();
}
}
3.2 동작 다중 상속
디폴트 메서드를 이용하면 기존에는 불가능했던 동작 다중 상속 기능도 구현할 수 있다. 그림에서 보여주는 것처럼 클래스는 다중 상속을 이용해서 기존 코드를 재사용할 수 있다.
자바에서 클래스는 한 개의 다른 클래스만 상속할 수 있지만 인터페이스는 여러 개 구현할 수 있다.
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
다중 상속 형식
여기서 ArrayList는 한 개의 클래스를 상속받고, 여섯 개의 인터페이스를 구현한다. 결과적으로 ArrayList는 AbstractList, List, RandomAccess, Cloneable, Serializable, Iterable, Collection 의 서브형식이 된다. 따라서 디폴트 메서드를 사용하지 않아도 다중 상속을 활용할 수 있다.
자바 8에서는 인터페이스가 구현을 포함할 수 있으므로 클래스는 여러 인터페이스에서 동작을 상속받을 수 있다. 다중 상속 동작이 어떤 장점을 제공하는지 예제로 살펴보자.
기능이 중복되지 않는 최소의 인터페이스
우리가 만드는 게임에 다양한 특성을 갖는 여러 모양을 정의한다고 가정하자. 어떤 모양은 회전할 수 없지만 크기를 조절할 수 있다. 어떤 모양은 회전할 수 있으며 움직일 수 있지만 크기는 조절할 수 없다. 최대한 기존 코드를 재사용해서 이 기능을 구현해보자
먼저 setRotationAngle과 getRotationAngle 두 개의 추상 메서드를 포함하는 Rotatable 인터페이스를 정의한다. 인터페이스는 아래와 같이 디폴트 메서드 rotateBy 도 구현한다.
public interface Rotatable {
void setRotationAngle(int angleInDegrees);
int getRotationAngle();
default void rotateBy(int angleInDegrees) {
setRotationAngle(getRotationAngle() + angleInDegrees % 360);
}
}
위 인터페이스는 구현해야 할 다른 메서드에 따라 뼈대 알고리즘이 결정되는 템플릿 디자인 패턴과 비슷해 보인다.
마찬가지로 이전에 살펴본 두 가지 인터페이스 Moveable과 Resizable을 정의해야 한다.
public interface Movable {
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);
}
}
인터페이스 조합
이제 이들 인터페이스를 조합해서 게임에 필요한 다양한 클래스를 구현할 수 있다. 예를 들어 아래 코드처럼 움직일 수 있고, 회전할 수 있으며, 크기를 조절할 수 있는 괴물 클래스를 구현할 수 있다.
public class Monster implements Rotatable, Moveable, Resizable {
...
}
상속받은 Monster 클래스는 다양한 메서드를 직접 호출할 수 있다.
Monster monster = new Monster();
monster.rotateBy(180);
monster.moveVertically(10);
이번에는 움직일 수 없지만 회전할 수 있고, 크기를 조절할 수 있는 태양 클래스를 정의해보자
public class Sun implements Rotatable, Resizable {
...
}
모든 추상 메서드의 구현은 제공해야 하지만 디폴트 메서드의 구현은 제공할 필요가 없다.
인터페이스에 디폴트 구현을 포함시키면 또 다른 장점이 생긴다. 예를 들어 moveVertically의 구현을 더 효율적으로 고쳐야 한다고 가정하자. 디폴트 메서드 덕분에 Moveable 인터페이스를 직접 고칠 수 있고 따라서 Moveable 을 구현하는 모든 클래스도 자동으로 변경한 코드를 상속받는다.(구현 클래스에서 메서드를 재정의 하지 않았다면)
4. 해석 규칙
자바 클래스가 여러 인터페이스를 동시에 구현할 수 있다. 만약 여러 인터페이스의 디폴트 메서드중 메서드 시그니처가 겹치면 어떤 인터페이스의 디폴트 메서드를 사용하게 될까? 자주 일어나는 일은 아니지만 이를 해결할 수 있는 규칙이 필요하다.
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();
}
}
C++ 의 다이아몬드 문제, 즉 같은 시그니처를 갖는 두 메서드를 상속받는 클래스이다. 이때 어떤 메서드가 사용될 것 같은가?
4.1 알아야 할 세 가지 해결 규칙
다른 클래스나 인터페이스로부터 같은 시그니처를 갖는 메서드를 상속받을 때는 세 가지 규칙을 따라야 한다.
1. 클래스가 항상 이긴다. 클래스나 슈퍼클래스에서 정의한 메서드가 디폴트 메서드보다 우선권을 갖는다.
2. 1번 규칙 이외의 상황에서는 서브인터페이스가 이긴다. 상속관계를 갖는 인터페이스에서 같은 시그니처를 갖는 메서드를 정의할 때는 서브인터페이스가 이긴다. 즉 B가 A를 상속받는다면 B가 A를 이긴다.
3. 여전히 디폴트 메서드의 우선순위가 결정되지 않았다면 여러 인터페이스르 상속받는 클래스가 명시적으로 디폴트 메서드를 오버라이드하고 호출해야 한다.
'Book > 모던 자바 인 액션' 카테고리의 다른 글
CHAPTER16 - CompletableFuture : 안정적 비동기 프로그래밍(1) (0) | 2025.06.01 |
---|---|
CHAPTER15 - CompletableFuture 와 리액티브 프로그래밍 기초(2) (0) | 2025.05.25 |
CHAPTER9 - 리팩터링, 테스팅 (2) (0) | 2025.03.25 |
CHAPTER9 - 리팩터링, 테스팅 (1) (0) | 2025.03.25 |
CHAPTER7 - 병렬 데이터 처리와 성능(1) (0) | 2025.02.28 |