Programming/Java

[Effective Java] item20 - 추상 클래스보다는 인터페이스를 우선하라!

VSFe 2021. 3. 8. 00:00
추상 클래스보다는 인터페이스를 우선하라!

자바가 기본적으로 제공하는 다중 구현 메커니즘은 인터페이스와 추상 클래스로 나뉜다.

일반적인 자바 입문서를 보면 인터페이스는 메소드 구현이 불가하다고 나와있지만 정말일까? 사실 인터페이스도 default method를 갖고 있다!

public interface Calculator {
    public int plus(int x, int y);
    public int minus(int x, int y);
    default int defaultPlus(int x, int y) {
        return x + y;
    }
    public static int staticMinus(int x, int y) {
        return x - y;
    }
}

추상 클래스의 경우 extends를 사용해야 하고, 인터페이스의 경우 implements를 사용하는데, 상속의 경우 단 한개만 가능하기 때문에 새로운 타입을 정의하는 입장에선 걸림돌이 된다.

또한, 인터페이스는 기존에 존재하던 클래스에 새로운 기능을 추가할 때도 좋다. 앞에서 배웠던 많은 인터페이스인 Comparable, AutoCloseable 같은 인터페이스는 나중에 추가 되었는데, 기존 클래스에 implements만 추가하고 요구하는 메소드만 추가로 구현해준 상태로 릴리즈되었다. 반면 추상 클래스의 경우 이미 해당 클래스가 다른 클래스를 상속한다면 올라가서 구현해줘야 하는데, 이렇게 되면 원치 않은 클래스가 해당 추상 클래스도 덩달아 상속하는 꼴이 되어서 복잡해진다.

인터페이스의 다른 장점이라면 믹스인 (mixin)을 정의할 때 좋다는 것이다. 믹스인이 무엇일까?

클래스가 선택 가능한 타입으로, 원래의 주된 타입 외에도 특정 선택적 행위를 제공한다고 한다. 당연히 이 말만 들으면 무슨 말을 하는지 이해가 안 되겠지만...

우리는 부모님에 의해 태어났고, 많은 것을 유전 받았다. 어떤 특성은 엄마쪽 유전자로 받았고, 다른 특성은 아빠쪽 유전자에 의해 받았다고 가정하면 결국 두 특성을 모두 받아야 한다. 이런식으로 특정 선택적 기능을 혼합하는 것을 믹스인이라고 부르는데, 추상 클래스의 경우 단 하나만 상속받을 수 있기 때문에 믹스인을 구현하기 상당히 어렵다.

더불어, 계층구조가 없는 타입 프레임워크를 만들 수 있다. 타입을 계층적으로 정의하면 구조적인 느낌이 나지만, 현실은 계층이 아닌 것들이 많다!

치킨집에 가면 핫후라이드나, 불닭치킨 등등 다양한 매운 치킨들이 많고, 양념치킨 중에선 안 매운 치킨이 많다.

public interface hotChicken {
    int getScovile();
}

public interface sourcedChicken {
    String getSource();
}

그러나 불닭치킨 마냥 매우면서도 양념이 끼얹어진 치킨이 있는 법. 이런 경우엔 두 인터페이스를 모두를 확장한 클래스를 만들 수 있고, 인터페이스를 만들어도 된다!

만약 클래스 상속으로 구현하게 된다면 N^2 개의 클래스를 구성해야 해결할 수 있기 때문에 조합이 너무 많아진다...

앞에서 배웠던 래퍼 클래스 관용구를 함께 사용한다면 인터페이스의 역할은 더 커진다! 만약 타입을 추상 클래스로 정의해두면 타입에 기능을 추가하기 위해 상속을 사용해야 하지만, 인터페이스는 그럴 필요가 없으니까!

다만 구현 방법이 명백한게 있다면, 앞에서 설명했던 디폴트 메소드를 활용하면 좋을 것이다! 이럴 땐 사용자가 혼란스럽지 않도록 @ImplSpec 자바독 태그를 붙여 문서화 해야 한다. 유의해야 할 점이라면, equalshashCode 같은 Object의 메소드를 구현하는 것은 금지되어 있고, 인스턴스 필드와 public이 아닌 정적 멤버를 가질 수 없다는 점을 유의하자!

인터페이스와 추상 골격 구현 (skeletal implementation) 클래스를 함께 제공하는 식으로 인터페이스와 추상 클래스의 장점을 모두 취하는 방법도 있다. 이 경우 인터페이스로는 타입을 정의하고, 디폴트 메소드도 필요하다면 몇 개 정도 제공한다. 골격 구현 클래스는 나머지 메소드를 구현한다. 이런 경우 골격 구현을 확장하는 것 만으로도 인터페이스를 구현할 수 있다! (어찌보면 템플릿 메소드 패턴의 일종이라고 할 수 있다.)

JDK를 살짝 보면 알겠지만, AbstrctCollection, AbstractSet, AbstactList, AbstarctMap 등의 이름이 붙여져 있는 것을 기억할텐데, 이것은 바로 핵심 컬렉션 인터페이스의 골격 구현이다. 이런식으로 인터페이스의 이름이 Interface라면 그 골격 구현 클래스의 이름은 AbstractInterface로 짓는다.

static List<Integer> intArrayAsList(int[] a) {
	Object.requireNotNull(a);

	return new AbstractList<>() {
		@Override pubvlic Integer get(int i) {
			return a[i];
		}
		
		@Override public Integer set(int i, Integer val) {
			int oidVal = a[i];
			a[i] = val;
			return oldVal;
		}

		@Override public int size() {
			return a.length;
		}
	};
}

int array를 Integer 형태의 List로 만들어주는 코드이다. 그렇지만 오토박싱이 발생하므로 성능이 썩 좋진 않다. 그리고 이 코드는 AbstractList를 사용하면서 필요한 일부만 재정의하는 방식으로 구현하였기에, 다른 잡다한 부분을 고려하지 않아도 되는 골격 구현의 힘을 보여준다.

다만 구조상 골격 구현을 확장하지 못한다면 인터페이스를 직접 구현해야 한다. 그래도 이런식으로 하면 인터페이스가 제공하는 디폴트 메소드의 이점을 누릴 수 있다.

그럼 직접 골격 구현을 작성해보는 것은 어떨까? 일단 인터페이스 중 다른 메소드들의 구현에 사용되는 기반 메소드들을 선정한다. 이 메소드들은 구현 과정에서 추상 메소드의 역할을 할 것이다. 만약 기반 메소드들을 활용해 구현할 수 있는 메소드가 있다면, 이 친구들을 모두 디폴트 메소드로 제공한다. 만약 디폴드 메소드로 모든 메소드를 다 만들 수 있다면, 골격 구현 클래스를 굳이 만들 필요가 없지만, 만들지 못한 것이 있다면 골격 구현 클래스에 역할을 떠넘긴다.