Java/모던 자바 인 액션

[Java] 모던 자바 인 액션(7) - default 메소드

ready-go 2023. 3. 4. 13:54

인터페이스의 제약

우리가 어떤 인터페이스에 새로운 기능을 추가하고 싶다고 하자. 우리가 만든 인터페이스를 나 혼자 사용한다면 문제없다. 인터페이스에 메소드를 추가하는 순간, IDE에서 수정이 필요한 곳을 모두 알려줄 것이기 때문에 조금 귀찮지만 감당할 수 있는 정도다.

 

그러나 회사 내부적으로 공유하고 있는 라이브러리의 인터페이스라면? 수 많은 개발자들이 라이브러리의 버전을 바꿨다가 수백개의 에러를 만나게 되고, 메소드를 하나하나 구현하느라 밤을 샐 수도 있다. 이렇게 컴파일 타임에 에러를 발견할 수 있으면 그나마 낫다. 만약 라이브러리 업데이트를 위해 운영중인 서버의 classpath에 있는 jar파일만 바꾸게 된다면 더 큰 문제다. 새롭게 추가된 메소드를 호출하기 전까지 문제를 알 수 없으며, 런타임에 심각한 문제를 내며 서비스가 중단될 것이다.

 

새로운 기능 추가를 위해서 새로운 인터페이스를 만드는 방법도 있을 것이다. 그러나 같은 책임을 갖는 메소드를 다른 인터페이스에 정의하는 것은 OOP의 관점에서 적절하지 않다. 단일 책임 원칙을 위배하며, 인터페이스 사이의 암묵적인 의존관계가 생기는 이상한 상황이 벌어지기 때문이다.

 

이 모든 문제는 근본적으로 인터페이스의 제약 때문이다. 인터페이스가 정의한 메소드는 반드시 클래스에서 구현해야만 한다. Java 8에서는 이러한 제약을 default 메소드를 통해 조금은 풀어준다.

 

default 메소드

언제나 그렇듯, 이름이 모든 것을 말해준다. 사용자가 오버라이드하지 않아도 인터페이스가 기본적으로 제공하는 메소드라는 뜻이다. 어떤 기능을 제공하려면 당연히 구현이 되어있어야 한다. 인터페이스에서 default 메소드를 구현하는 것은 클래스에서 메소드를 구현하는 것과 똑같고, default라는 예약어만 붙여주면 된다.

public interface Car {
	int getGas();
	void drive();
	default void printCarInfo() {
		System.out.println("Car");
	}
}

이제 Car를 구현한 구체 클래스들은 printCarInfo를 오버라이드하지 않아도 printCarInfo를 사용할 수 있다. printCarInfo를 오버라이드하지 않은 클래스를 만들어보자.

public class Truck implements Car {
	private int gas;
	private static int GAS_PER_DRIVE = 100;

	public Truck(int gas) {
		this.gas = gas;
	}

	@Override
	public int getGas() {
		return this.gas;
	}

	@Override
	public void drive() {
		this.gas -= GAS_PER_DRIVE;
	}
}

비교를 위해 printCar를 오버라이드한 클래스도 만들어준다.

public class SportsCar implements Car {
	private int gas;
	private static final int GAS_PER_DRIVE = 500;

	public SportsCar(int gas) {
		this.gas = gas;
	}

	@Override
	public int getGas() {
		return this.gas;
	}

	@Override
	public void drive() {
		System.out.println("skrrrrrrr");
		this.gas -= GAS_PER_DRIVE;
	}

	@Override
	public void printCarInfo() {
		System.out.println("SportsCar");
	}
}

이제 인스턴스를 만들어 실행해보자. 아래 코드는 잘 실행된다.

Truck truck = new Truck(1000);
SportsCar sportsCar = new SportsCar(500);

truck.printCar();
sportsCar.printCar();

 

default 메소드 활용

선택형 메소드

default 메소드를 이용하면 인터페이스의 메소드를 선택적으로 정의할 수 있다. 지원하지 않는 메소드를 인터페이스에 정의한다는 것이 이상하게 느껴질 수도 있다. 그러나 언제나 예외적인 상황이 있다. 예를 들어 List를 읽기 전용으로 사용하고 싶은 경우가 있다. 이러한 경우에는 add나 remove같은 메소드를 사용할 수 없도록 해야 한다.

 

만약 아래와 같이 빈 메소드로 두면 어떨까?

public ReadOnlyList<E> implements List<E> {
	/* ~~ 생략 ~~ */
	public void add(E element) {
	}

	public boolean remove(Object o) {
		return false;
	}
	/* ~~ 생략 ~~ */
}

remove같은 경우 그나마 false를 리턴하기 때문에 삭제가 되지 않았다는 것을 알 수 있다. 그러나 add는 요소가 추가되었는지 알 방법이 없다. 정상적으로 추가되었다고 생각하고 어디선가 요소를 꺼내면 에러가 터질 것이다. 따라서 아래와 같이 명시적으로 지원하지 않는다는 것을 알려주는 것이 좋다.

public ReadOnlyList<E> implements List<E> {
	/* ~~ 생략 ~~ */
	public void add(E element) {
		throw new UnsupportedOperationException();
	}

	public boolean remove(Object o) {
		throw new UnsupportedOperationException();
	}
	/* ~~ 생략 ~~ */
}

 

다중 상속

Java는 기본적으로 다중 상속을 지원하지 않는다. 그러나 Java 8의 default 메소드를 이용하면 다중 상속을 사용할 수 있다. 예를 들어, 비행기와 배 인터페이스가 default 메소드를 지원한다고 하자.

public interface Airplane {
	default void fly() {
		System.out.println("Fly over Pacific Ocean");
	}
}
public interface Ship {
	default void sail() {
		System.out.println("Sailing across Pacific Ocean");
	}
}

말이 안되지만, 비행기와 배를 합친 이동수단이 있다고 하면 아래와 같이 fly와 sail을 모두 사용할 수 있다.

public class SuperVehicle implements Airplane, Ship {
}
SuperVehicle vehicle = new SuperVehicle();
vehicle.fly();
vehicle.sail();
Fly over Pacific Ocean
Sailing across Pacific Ocean

그런데 위의 예시에서 fly와 sail을 모두 move라는 이름으로 바꾼다면 무엇이 실행되어야 할까? 이것이 다중 상속의 충돌 문제다. 위의 경우와 같이 서로 관련이 없는 인터페이스에서 상속받은 메소드들은 이름이 같더라도 완전히 다른 역할을 하는 메소드일 확률이 높다. 따라서 구체 클래스에서 명시적으로 어떤 메소드를 사용할 지 정해야 한다.

public interface Airplane {
	default void move() {
		System.out.println("Fly over Pacific Ocean");
	}
}
public interface Ship {
	default void move() {
		System.out.println("Sailing across Pacific Ocean");
	}
}
public class SuperVehicle implements Airplane, Ship {
	@Override
	public void move() {
		Airplane.super.move();
	}
}

그러나 Airplane과 Ship이 같은 인터페이스를 확장하는 인터페이스라면 이야기가 달라진다.

public interface Vehicle {
	default void move() {
		System.out.println("Move");
	}
}
public interface Airplane extends Vehicle {
}
public interface Ship extends Vehicle {
}
public class SuperVehicle implements Airplane, Ship {
}
SuperVehicle vehicle = new SuperVehicle();
vehicle.move();
Move

분명 Airplane과 Ship은 move를 default 메소드로 제공한다. 그러나 Airplane과 Ship을 구현한 SuperVehicle에서 충돌이 일어나지 않는다. 생각해보면 어차피 같은 메소드를 사용하기 때문에 당연한 결과다.

 

조금 더 복잡한 경우도 생각해볼 수 있다. Airplane에 default 메소드를 추가해보자.

public interface Airplane extends Vehicle {
	default void move() {
		System.out.println("Fly over Pacific Ocean");
	}
}
SuperVehicle vehicle = new SuperVehicle();
vehicle.move();
Fly over Pacific Ocean

Ship의 default 메소드는 "Move"를 출력하고, Airplane은 "Fly over Pacific Ocean"을 출력한다. 이렇게 다른 두 메소드가 충돌했지만 컴파일 에러가 나지 않았다. Airplane의 move는 Vehicle의 move를 오버라이드했기 때문에 이것도 꽤 합리적인 것 같다.

 

Ship이 인터페이스가 아닌 구체 클래스인 경우도 마찬가지다. 다시 Airplane을 원래 상태로 되돌리고, Ship을 구체 클래스로 만들어서 오버라이드한다.

public interface Airplane extends Vehicle {
}
public class Ship implements Vehicle {
	@Override
	public void move() {
		System.out.println("Sailing across Pacific Ocean");
	}
}
public class SuperVehicle extends Ship implements Airplane {
}
SuperVehicle vehicle = new SuperVehicle();
vehicle.move();
Sailing across Pacific Ocean

그러나 역시 인터페이스, 구체 클래스 상관없이 Airplane, Ship을 둘 다 오버라이드하는 경우는 충돌이 발생하므로 오버라이드해야 한다. 따라서 복잡하고 애매한 Java의 해석을 따르기 보다 다중 상속을 사용하는 경우에는 항상 명시적으로 오버라이드해서 사용하는 것이 더 나은 방법이다.

 

3줄 요약

  1. 인터페이스는 모든 메소드를 구현해야 한다는 제약이 있다
  2. 인터페이스를 수정하면 생각치도 못한 곳에서 에러를 발생시킬 수 있다
  3. default 메소드로 추가 구현 없이 기능을 추가할 수 있다