-
[Effective Java] 추상 클래스보다는 인터페이스를 우선하라Java 2023. 2. 2. 11:03
자바가 제공하는 다중 구현 메커니즘은 인터페이스와 추상 클래스, 이렇게 두 가지다.
자바 8 부터 인터페이스도 디폴트 메서드를 제공할 수 있게 되어, 이제는 두 메커니즘 모두 인스턴스 메서드를 구현 형태로 제공할 수 있다.둘의 가장 큰 차이는 추상 클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 하위 클래스가 되어야 한다는 점이다.
자바는 단일 상속만 지원하니, 추상 클래스를 상속받은 클래스는 다른 클래스를 상속받을 수 없게되는 것이다.
반면 인터페이스가 선언한 메서드를 모두 정의하고 그 일반 규약을 잘 지킨 클래스라면 다른 어떤 클래스를 상속했든 같은 타입으로 취급된다.
디폴트 메서드(default method)
디폴트 메서드는 인터페이스에 있는 구현 메서드를 의미한다. 기존의 추상 메서드와 다른 점은
- 메서드 앞에 default 예약어를 붙인다.
- 구현부 {} 가 있어야 한다.
public interface Interface { // 추상 메서드 void abstractMethodA(); void abstractMethodB(); void abstractMethodC(); // default 메서드 default int defaultMethodA(){ ... } }
인터페이스에 default 메서드를 추가하게 되면 모든 구현체에 해당 메서드를 구현해야 하는 문제점을 해결하게 되었다.
인터페이스의 장점
1. 기존 클래스에도 손쉽게 새로운 인터페이스를 구현해넣을 수 있다.
- 인터페이스가 요구하는 메서드를 추가하고, 클래스 선언에 implements 구문만 추가하면 끝이다.
- 자바 플랫폼에서도 Comparable, Iterable, AutoCloseable 인터페이스가 새로 추가됐을 때 표준 라이브러리의 수많은 기존 클래스가 이 인터페이스들을 구현한 채 릴리스됐다.
- 반면에 기존 클래스에 새로운 추상 클래스를 끼워넣기는 어려운 게 일반적이다. 두 클래스가 같은 추상 클래스를 상속하길 원한다면, 그 추상 클래스는 계층구조상 두 클래스의 공통 조상이어야 한다. 이 방식은 클래스 계층구조에 커다란 혼란을 일으킨다.
2. 인터페이스는 믹스인 정의에 안성맞춤이다.
믹스인: 기존의 클래스의 주 기능에 추가적인 기능을 혼합하여 새로운 클래스를 생성하는 것
- 믹스인 인터페이스는 어떤 클래스의 주 기능이외에 믹스인 인터페이스의 기능을 추가적으로 제공하게 해주는 효과를 준다.
- 추상 클래스로는 믹스인을 정의할 수 없다. 기존 클래스에 덧씌울 수 없기 때문이다. 클래스는 두 부모를 섬길 수 없고, 부모와 자식이라는 클래스 계층에서 믹스인을 삽입하기에 합리적인 위치가 없기 때문이다.
Comparable은 자신을 구현한 클래스의 인스턴스들끼리는 순서를 정할 수 있다고 선언하는 믹스인 인터페이스이다.
public class Mixin implements Comparable{ @Override public int compareTo(Object o){ return 0; } }
3. 인터페이스로는 계층구조가 없는 타입 프레임워크를 만들 수 있다.
타입을 계층적으로 정의하여 구조적으로 잘 표현할 수 있는 개념이 있는가 하면 가수와 작곡가, 그리고 작곡도 하는 가수와 같이 계층적으로 표현하기 어려운 개념이 존재한다.
ex) 가수(Singer) 인터페이스와 작곡가(Songwriter) 인터페이스
public interface Singer{ AudioClip sing(Song s); }
public interface Songwriter{ Song compose(int chartPosition); }
작곡도 하는 가수를 인터페이스로 정의하면 가수 클래스가 Singer와 Songwriter 모두를 구현해도 전혀 문제가 없다. 심지어 Singer와 Songwriter 모두를 확장하고 새로운 메서드까지 추가한 제 3의 인터페이스를 정의할 수도 있다.
public interface SingerSongwriter extends Singer, Songwriter{ AudioClip strum(); void actSensitive(); }
이러한 구조를 추상 클래스로 만든다면,
public abstract class Singer { abstract void sing(String s); } public abstract class SongWriter { abstract void compose(int chartPosition); } public abstract class SingerSongwriter { abstract void strum(); abstract void actSensitive(); abstract void Compose(int chartPosition); abstract void sing(String s); }
Singer 클래스와 SongWriter 클래스를 둘 다 상속할 수 없어 또 다른 추상 클래스를 만들어서 클래스 계층을 표현해야 한다. 이러한 클래스 계층구조를 만들기 위해 많은 조합이 필요하고 결국 고도비만 계층구조가 만들어질 것이다. 흔히 조합 폭팔이라 부르는 현상이다.
4. 래퍼 클래스 관용구와 함께 사용하면 인터페이스는 기능을 향상시키는 안전하고 강력한 수단이 된다.
타입을 추상 클래스로 정의해두면 그 타입에 기능을 추가하는 방법은 상속뿐이다. 상속해서 만든 클래스는 래퍼클래스보다 활용도가 떨어지고 쉽게 깨진다.
- 인터페이스의 메서드 중 구현 방법이 명백한 것이 있다면, 그 구현을 디폴트 메서드로 제공할 수 있다.
- 디폴트 메서드를 제공할 때는 상속하려는 사람을 위한 설명을 @ImplSpec 자바독 태그를 붙여 문서화해야 한다.
디폴트 메서드에도 제약은 있다.
- 많은 인터페이스가 equals 와 hashCode 같은 Object의 메서드를 정의하고 있지만, 이들은 디포트 메서드로 제공해서는 안된다.
- 또한 인터페이스는 인스턴스 필드를 가질 수 없고 public이 아닌 정적 멤버도 가질 수 없다.
- 마지막으로, 본인이 만들지 않은 인터페이스에는 디폴트 메서드를 추가할 수 없다.
인터페이스와 추상 골격 구현 클래스를 함께 제공하는 식으로 인터페이스와 추상 클래스의 장점을 모두 취하는 방법도 있다.
- 인터페이스로는 타입을 정의하고 필요한 일부 디폴트 메소드를 구현한다.
- 추상 골격 구현 클래스는 나머지 메서들까지 구현한다.
이렇게 하면 추상 골격 구현 클래스를 확장하는 것 만으로 인터페이스를 구현하는 데 필요한 일이 대부분 완료된다. 이는 템플릿 메서드 패턴과 같다.
좋은 예로, 컬렉션 프레임워크의 AbstractCollection, AbstractSet, AbstractList, AbstractMap 각각이 바로 핵심 컬렉션 인터페이스의 골격 구현이다.
예) 자판기 인터페이스와 음료 자판기, 커피 자판기
자판기 인터페이스
public interface Vending{ void start(); void chooseProduct(); void stop(); void process(); }
음료 자판기
public class BaverageVending implements Vending { @Override public void start() { System.out.println("vending start"); } @Override public void chooseProduct() { System.out.println("choose menu"); System.out.println("coke"); System.out.println("energy drink"); } @Override public void stop() { System.out.println("stop vending"); } @Override public void process() { start(); chooseProduct(); stop(); } }
커피 자판기
public class CoffeeVending implements Vending { @Override public void start() { System.out.println("vending start"); } @Override public void chooseProduct() { System.out.println("choose menu"); System.out.println("americano"); System.out.println("cafe latte"); } @Override public void stop() { System.out.println("stop vending"); } @Override public void process() { start(); chooseProduct(); stop(); } }
두 구현체 모두 Vending 인터페이스를 구현하는데, 상품을 선택하는 chooseProduct 메서드를 제외하고 모두 같은 동작을 한다. 중복 코드를 제거하기 위해 인터페이스를 추상 클래스로 대체하지 않고 추상 골격 구현을 이용해 중복 코드를 제거해보자
추상 골격 구현 클래스
public abstract class AbstractVending implements Vending { @Override public void start() { System.out.println("vending start"); } @Override public void stop() { System.out.println("stop vending"); } @Override public void process() { start(); chooseProduct(); stop(); } }
음료 자판기
public class BaverageVending extends AbstractVending implements Vending { @Override public void chooseProduct() { System.out.println("choose menu"); System.out.println("coke"); System.out.println("energy drink"); } }
커피 자판기
public class CoffeeVending extends AbstractVending implements Vending { @Override public void chooseProduct() { System.out.println("choose menu"); System.out.println("americano"); System.out.println("cafe latte"); } }
디폴트 메서드를 사용하지 않고 추상 골격 구현 클래스를 만들어 중복을 제거하였다.
만약 Vending을 구현하는 구현 클래스가 VendingManuFaccturer 라는 제조사 클래스를 상속받아야 해서 추상 골격 구현을 확장하지 못하는 상황일 땐 어떻게 해야할까?
public class SnackVending extends VendingManufacturer implements Vending { InnerAbstractVending innerAbstractVending = new InnerAbstractVending(); @Override public void start() { innerAbstractVending.start(); } @Override public void chooseProduct() { innerAbstractVending.chooseProduct(); } @Override public void stop() { innerAbstractVending.stop(); } @Override public void process() { printManufacturerName(); innerAbstractVending.process(); } private class InnerAbstractVending extends AbstractVending { @Override public void chooseProduct() { System.out.println("choose product"); System.out.println("chocolate"); System.out.println("cracker"); } } }
위와 같이 인터페이스를 구현한 클래스에서 해당 골격 구현을 확장한 private 내부 클래스를 정의하고, 각 메서드 호출을 내부 클래스의 인스턴스에 전달하는 것을 시뮬레이트한 다중 상속(simulated multiple inheritance) 이라고 한다.
골격 구현은 기본적으로 상속해서 사용하는 걸 가정하므로 아이템 19에서 이야기한 설계 및 문서화 지침을 모두 따라야 한다.
핵심 정리
일반적으로 다중 구현용 타입으로는 인터페이스가 가장 적합하다. 복잡한 인터페이스라면 구현하는 수고를 덜어주는 골격 구현을 함께 제공하는 방법을 꼭 고려해보자.
골격 구현은 '가능한 한' 인터페이스의 디폴트 메서드로 제공하여 그 인터페이스를 구현한 모든 곳에서 활용하도록 하는 것이 좋다.
'가능한 한'이라고 한 이유는, 인터페이스에 걸려있는 구현상의 제약 때문에 골격 구현을 추상 클래스로 제공하는 경우가 더 흔하기 때문이다.
참고
'Java' 카테고리의 다른 글
[Effective Java] 로 타입은 사용하지 말라 (0) 2023.02.02 [Effective Java] 톱레벨 클래스는 한 파일에 하나만 담으라 (0) 2023.02.02 [Effective Java] 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 (0) 2023.02.02 [Effective Java] clone 재정의는 주의해서 진행하라 (1) 2023.02.02 [Effective Java] toString을 항상 재정의하라 (0) 2023.02.02