Effective Java : 아이템20. 추상 클래스보다 인터페이스를 우선하라.

    들어가기 전

    이 글은 인프런 백기선님의 이펙티브 자바 강의를 복습하며 작성한 글입니다.


    이 글의 요약

    • 인터페이스는 추상 클래스보다 더 많은 장점이 있다. 따라서 인터페이스를 기본으로 사용하되, 추상 클래스는 나중에 사용하자.
    • 인터페이스에서는 default 메서드를 이용해 구현체 변경 없이 확장 가능하다.
    • 인터페이스의 default 메서드는 구현체의 세부 구현을 깨뜨릴 소지가 있따. 
    • 인터페이스를 Wrapper 클래스와 함께 사용하면 기능 확장을 안전하게 할 수 있다. 만약 상속이었다면, 부모 클래스의 세부 구현 사항과 결합해 깨지기 쉬운 코드가 된다. 
    • 계층구조가 아닌 클래스의 관계는 상속을 통해 Composition하여 새로운 기능을 만들기 쉽다 
    • 인터페이스 + 추상 클래스를 이용한 추상 골격 클래스는 Template Method 패턴으로 동작하여, 해당 인터페이스를 구현하고자 하는 구현체를 좀 더 쉽게 구현할 수 있도록 해준다. 예시는 List / AbstractList가 있다. 

    핵심 정리 : 인터페이스의 장점

    • 자바 8부터 인터페이스도 디폴트 메서드를 제공할 수 있다. (완벽 공략 3)
    • 기존 클래스도 손쉽게 새로운 인터페이스를 구현해 넣을 수 있다. 
    • 인터페이스는 믹스인(mix-in) 정의에 안성 맞춤이다. (선택적인 기능 추가)
    • 계층구조가 없는 타입 프레임워크를 만들 수 있다. 
    • 래퍼 클래스와 함께 사용하면 인터페이스는 기능을 향상 시키는 안전하고 강력한 수단이 된다. (아이템 18)
    • 구현이 명백한 것은 인터페이스의 디폴트 메서드를 사용해 프로그래머의 일감을 덜어줄 수 있다. 

    Item20. 추상 클래스보다 인터페이스를 우선하라.

    자바 언어를 사용한다면 가장 먼저 인터페이스를 사용하고, 인터페이스로 해결되지 않는 부분이 있으면 추상 클래스를 상속하도록 한다. 인터페이스는 추상 클래스에 비해서 가지는 장점이 더욱 많기 때문이다. 

    추상 클래스는 단 하나만 상속 가능하다. 따라서 추상 클래스를 상속해서 기능을 제공하는 경우라면, 제약이 심해질 수 밖에 없다. 예를 들어 A가 B 클래스를 상속 받았고, C 추상 클래스가 제공하는 default 메서드를 제공 받고 싶은 경우를 고려해보자. 이 때 A는 C 추상 클래스를 상속 받을 수 없다. 

    반면 이런 경우 인터페이스는 자유롭게 다중 구현이 가능하기 때문에 제약에서 보다 자유롭다. 


    자바 8부터 인터페이스도 default 메서드를 제공할 수 있다.  (완벽 공략 3)

    자바 8부터 인터페이스가 default 메서드를 제공할 수 있기 때문에 추상 골격 클래스를 사용하지 않아도 되게 되었다. 

     

    코드로 예시를 들어보자. TimeClient 인터페이스가 있고, SimpleTimeClient 구현체가 있는 경우를 가정해보자. 이 때 인터페이스에도 기능을 추가하고 싶은 경우가 있는데, 이럴 때 default 메서드를 유용하게 사용할 수 있다. 인터페이스에 getZoneDateTime() 메서드를 추가한다고 가정해보자. 

    만약 getZoneDateTime() 메서드를 인터페이스에 추가하면, 인터페이스의 모든 구현체에 컴파일 에러가 발생한다. 만약 내가 모든 코드를 통제하고 있는 상황이라면 구현을 하면 된다. 반면 내가 코드를 통제하지 못하는 상황이라면 변경 정도에 따라서 넓은 범위에 컴파일 에러를 유발하게 된다. 

    public interface TimeClient {
    
        void setTime(int hour, int minute, int second);
        void setDate(int day, int month, int year);
        void setDateAndTime(int day, int month, int year,
                            int hour, int minute, int second);
        LocalDateTime getLocalDateTime();
    
        static ZoneId getZonedId(String zoneString) {
            try {
                return ZoneId.of(zoneString);
            } catch (DateTimeException e) {
                System.err.println("Invalid Time Zone: " + zoneString);
                return ZoneId.systemDefault();
            }
        }
    
    	// default 메서드 추가.
        default ZonedDateTime getZonedDateTime(String zoneString) {
            return ZonedDateTime.of(getLocalDateTime(), getZonedId(zoneString));
        }
    }

    이런 상황이라면 default 메서드를 이용해서 인터페이스의 기능을 손쉽게 확장할 수 있다. default 메서드를 사용하면 이 인터페이스의 모든 구현체에게 공통의 기능을 제공할 수 있고, 컴파일 에러 역시 발생 하지 않은 채로 인터페이스의 기능 확장이 가능해진다. 

    하지만 이런 사용성에는 몇 가지 단점이 존재한다.

    1. 인스턴스 필드를 이용해야 하는 경우에는 default 메서드를 사용할 수 없다.
    2. default 메서드를 사용하면, 특정 구현체의 기능을 파괴할 가능성도 존재한다. (동의 없이 삽입되는 기능이므로)

    1번의 경우가 필요하다면 추상 클래스를 사용해야한다. 2번의 경우라면, 코드를 읽고 문제가 없는지를 확인하고 처리해야한다. 

     


    인터페이스는 믹스인(mix-in) 정의에 안성 맞춤이다. (선택적인 기능 추가)

    Mix-in은 무엇을 의미할까? 클래스가 이미 주요한 역할을 하고 있는 상황인데, 그 클래스가 부가적인 작업을 더 할 수 있도록 해주는 것이다. 예를 들면 아래 코드가 Mix-In의 한 예시가 된다.

    // ITEM20 : Mix-In
    public class SimpleTimeClient implements TimeClient, AutoCloseable {
    	...
    }

    이미 TimeClient 인터페이스를 구현한 SimpleTimeClient가 존재한다. 이 때 SimpleTimeClient가 AutoClosable 인터페이스를 구현하도록 할 수 있는데, 이런 것을 'Mix-In'이라고 한다. 

    SimpleTimeClient가 AutoClosable 인터페이스를 구현하면 '부가적인 기능'을 추가할 수 있게 된다. 추상 클래스로는 이런 형태의 작업이 어렵다. A가 이미 B를 상속하고 있는 상태에서 A가 C를 또 상속할 수는 없기 때문이다. 


    계층구조가 없는 타입 프레임워크를 만들 수 있다. 

    계층구조가 명확한 클래스들이라면 상속 구조로 만들어도 문제가 없다. 이런 구조의 적절한 예시는 다음과 같다. 아래 관계는 누가 보더라도 계층 구조가 명확하다. 

    • 부모 : 사각형 
    • 자식 : 직사각형 / 마름모 / 정사각형 

    그렇지만 아무리 봐도 계층구조가 아닌 클래스들이 있다. 작곡가와 가수, 싱어송라이터가 있다. 이 세 클래스의 관계는 무엇으로 단정지을 수 있을까? 모르긴 몰라도 계층구조는 아니다. 오히려 Singer / Writer 인터페이스를 조합해서 SingeraSongWirter 인터페이스를 만드는 것이 더 합당하다. 

    public interface SingSongWriter extends Singer, SongWriter{
        @Override
        AudioClip sing(Song song);
    
        @Override
        Song compose(int shartPosition);
    }
    
    
    public interface Singer {
        AudioClip sing(Song song);
    }
    
    public interface SongWriter {
        Song compose(int shartPosition);
    }

    위와 같이 명확한 계층구조를 가지지 않는 클래스들을 조합해서 새로운 기능을 만들어 낼 때 인터페이스는 더욱 효과적이다. 


    인터페이스를 래퍼 클래스와 함께 사용하면 인터페이스는 기능을 향상 시키는 안전하고 강력한 수단이 된다. (아이템 18)

    인터페이스를 Wrapper 클래스와 함께 사용하는 경우는 상속에 비해 좋은 확장을 제공해준다. Wrapper 클래스는 다음과 같이 정의할 수 있을 것 같다.

    • Wrapper 클래스는 인터페이스의 구현체다. 
    • Wrapper 클래스는 인터페이스를 타겟으로 가지고, 그 타겟에게 모든 실제 구현을 deligate한다. 

    예를 들면 ForwadingSet은 인터페이스를 확장한 Wrapper 클래스다. 아래 내용과 코드를 살펴보자.

    • ForwadingSet은 Set 인터페이스의 구현체다.
    • 실제로는 Set 인터페이스를 타겟으로 가지고, 타겟에게 모든 메서드의 실제 구현을 위임한다. 
    // 코드 18-3 재사용할 수 있는 전달 클래스 (118쪽)
    public class ForwardingSet<E> implements Set<E> {
        private final Set<E> s;
        public ForwardingSet(Set<E> s) {this.s = s;}
    
        public void clear() {s.clear();}
        public boolean contains(Object o){ return s.contains(o);}
        public boolean isEmpty() {return s.isEmpty();}
        public int size() { return s.size();}
        public Iterator<E> iterator() { return s.iterator();}
        public boolean add(E e){ return s.add(e);}
        public boolean remove(Object o){return s.remove(o);}
        public boolean containsAll(Collection<?> c){ return s.containsAll(c);}
        public boolean addAll(Collection<? extends E> c) { return s.addAll(c);}
        public boolean removeAll(Collection<?> c) { return s.removeAll(c);}
        public boolean retainAll(Collection<?> c) { return s.retainAll(c);}
        public Object[] toArray() { return  s.toArray();}
        public <T> T[] toArray(T[] a) { return s.toArray(a);}
        @Override public boolean equals(Object o) {return s.equals(o);}
    
        @Override public int hashCode() {return Objects.hash(s);
        }
        @Override public String toString() { return s.toString();}
    }

    ForwadingSet은 인터페이스를 확장만 했고, Target에게 Deligation을 하기 때문에 다음 장점을 가진다. 

    • 인터페이스에 변경점이 발생하면, 컴파일 에러가 발생해서 변경점을 추적 관리할 수 있다. 
    • Target에게 모든 것을 deligation 하기 때문에 상위 클래스의 내부 구현과 차단된다. 

    그리고 이런 Wrapper 클래스를 상속받아서 필요한 기능을 추가한다면, 좀 더 안전하게 기능을 확장할 수 있다.

    반면 만약 Set 인터페이스를 구현하지 않고, HashSet 같은 것들을 상속 받아서 필요한 기능들을 구현할 경우 문제가 발생할 수 있다. 앞서 아이템 18에서 공부했던 것처럼 HashSet의 내부 구현을 알아야 하게 되고, 내부 구현이 바뀌는 경우 상속받은 클래스의 구현도 바로 깨지게 된다. 

     

    정리

    • 인터페이스를 구현한 Wrapper 클래스를 통해 상속 구조를 유지하면, 직접 구현체를 상속한 것보다 안전하게 기능을 확장할 수 있게 된다. 
    • 인터페이스 변경 시, 컴파일 에러가 발생해서 변경점을 관리할 수 있기 때문. 
    • 인터페이스 구현이 Target에게 deligate 되는 형태이기 때문에 내부 구현을 몰라도 되어서, 확장에 유연하다. 

    핵심 정리: 인터페이스와 추상 골격 (skeletal) 클래스

    • 추상 골격 클래스는 인터페이스와 추상 클래스의 장점을 모두 취할 수 있음.
      • 인터페이스로 default 메서드 구현 
      • 추상 골격 클래스로 못 구현한 나머지 메서드 구현
      • 템플릿 메서드 패턴 가능
    • 자바에서 다중 상속을 구현할 수 있음. 
    • 추상 골격 클래스는 상속용 클래스다. 따라서 아이템 19(상속용 설계)를 따라야 한다. 

    인터페이스의 default 메서드는 인스턴스의 필드를 사용해야하는 경우 사용하지 못한다는 단점이 있었다. 이런 단점은 추상 클래스를 이용하면 해결할 수 있다. 따라서 추상 클래스와 인터페이스는 서로 상보적인 작용을 할 수도 있다. 

     

    추상 클래스 장점 + 인터페이스의 장점을 모두 같이 활용할 수 있다.

    추상 골격(skeletal) 클래스는 다음과 같은 형태로 작성할 수 있다. 

    1. 인터페이스에 구현할 수 있는 부분만 default 메서드로 구현한다. 
    2. 인터페이스를 구현한 추상 클래스를 생성한다. 인터페이스의 일부만 구현하고 일부 로직은 남겨둔다.
    3. 일부 로직은 추상 클래스를 상속한 구현체에서 직접 구현한다. 

    인터페이스를 일부만 구현한 추상 클래스를 '추상 골격 클래스'라고 한다. 추상 골격 클래스는 인터페이스의 default 메서드도 사용할 수 있고, default 메서드만으로는 부족했던 메서드도 공통적으로 구현해서 사용할 수 있게 된다. 

    추상 골격 클래스의 의미는 모든 클래스들이 인터페이스의 기능을 처음부터 구현하는 것이 아니라, 추상 골격 클래스가 제공하는 기능을 활용하면서 보다 쉽게 인터페이스를 구현할 수 있다는 거라는 것이다. 이런 형태는 'Template Method' 패턴이 되기도 한다. 


    추상 골격 클래스의 한 가지 예제 → AbstractList

    추상 골격 클래스의 한 가지 예제로 코드 20-1에서 볼 수 있는 AbstractList가 존재한다. 

    • intArrayAsList는 List 타입을 반환해야한다. List는 순수한 인터페이스인데, 이 인터페이스를 하나부터 구현하려면 정말 많은 메서드를 구현해야한다. 
    • 우리는 List 전체를 구현하는 대신에 AbstractList를 익명 클래스로 상속받고, 일부만 구현해서 사용할 수 있다.
    • AbstractList는 List 인터페이스의 대부분을 구현했으며, abstract method는 get() / size()만 존재한다. 

    개발자는 AbstractList라는 추상 골격 클래스를 통해서 인터페이스의 전체 메서드를 하나씩 구현하는 것이 아니라, get() / size()만 구현하면 되었다. 또한 아래에서 set()은 필요로 해서 재정의 한 것이다. 실제로는 구현하지 않아도 된다. 

    // 코드 20-1 골격 구현을 사용해 완성한 구체 클래스 (133쪽)
    public class IntArrays {
        static List<Integer> intArrayAsList(int[] a) {
            Objects.requireNonNull(a);
    
            // 다이아몬드 연산자를 이렇게 사용하는 건 자바 9부터 가능하다.
            // 더 낮은 버전을 사용한다면 <Integer>로 수정하자.
            return new AbstractList<Integer>() {
                @Override
                public Integer get(int index) { return a[index]; }
    
                @Override
                public Integer set(int index, Integer val) {
                    int oldVal = a[index];
                    a[index] = val; // 오토 언박싱
                    return oldVal; // 오토 박싱
                }
    
                @Override
                public int size() { return a.length; }
            };
        }
    
        public static void main(String[] args) {
            int[] a = new int[10];
            for (int i = 0; i < a.length; i++)
                a[i] = i;
    
            List<Integer> list = intArrayAsList(a);
            Collections.shuffle(list);
            System.out.println(list);
        }
    }

    추상 골격 클래스를 이용하면 자바에서 다중 상속을 구현할 수 있음. 

    자바에서는 상속은 단일 상속 밖에 안되지만, 추상 골격 클래스를 이용하면 자바에서 다중 상속을 구현할 수 있게 된다. 예를 들어 다음 요구사항이 있다고 해보자. 

    • AbstractCat 추상 클래스가 존재함.
    • Flyable 인터페이스가 존재함. Flyable의 추상 구현체 AbstractFlyable이 존재함. 
    • MyCat은 AbstractCat을 상속받음.

    이 때, MyCat은 AbstractFlyable을 상속 받아서 다중 상속을 만들 수 있을까? 정확하게 다중 상속은 아니지만, 유사하게 만들 수 있다. 아래처럼 코드를 작성할 수 있다.

     

    // 인터페이스인 Flyable을 구현하도록 함. 
    // Flyable의 구현체인 MyFlyable을 내부 클래스로 가지고, 이 클래스를 통해 fly를 deligate함. 
    public class MyCat extends AbstractCat implements Flyable{ // Flyable 상속
    
    	// AbstractFlyable 추상 클래스를 상속받은 객체
        // deligation.
        private MyFlyable myFlyable = new MyFlyable();
    
        @Override
        protected String sound() {
            return "인싸 고양이 두 마리가 나가신다!";
        }
    
        @Override
        protected String name() {
            return "유미";
        }
    
        public static void main(String[] args) {
            MyCat myCat = new MyCat();
            System.out.println(myCat.sound());
            System.out.println(myCat.name());
        }
    
        @Override
        public void fly() {
            this.myFlyable.fly();
        }
    
        private class MyFlyable extends AbstractFlyable {
            @Override
            public void fly() {
                System.out.println("날아라.");
            }
        }
    }

    이렇게 작성해두면, MyCat은 AbstractCat만 상속을 받았지만 실제로는 또 다른 추상 클래스의 기능을 이용해서 다중 상속을 한 것과 같은 형태의 동작을 보여준다. 

    댓글

    Designed by JB FACTORY