‘인터페이스와 추상클래스의 차이’는 흔한 질문같지만 표면적인 차이 이상으로 들어가다보니 꽤 다양한 생각들을 만날 수 있었다.

Java8에서 인터페이스에 ‘디폴트 메서드’를 추가할 수 있게 되어서 둘의 차이점이 많지 않아졌다. 언제 무엇을 쓰는게 바람직한가를 좀더 자세히 살펴봤다.

특징

먼저 기본적인 특징들을 살펴보자.

인터페이스

  • 상수(static final ~) 와 추상메서드를 포함할 수 있다

    1
    2
    3
    4
    
    interface Barkable {
      public static final int BLABLA_CONSTANT = 1;
      public abstract void bark();
    }
    
  • Java8부터 디폴트 메서드 (안에 구현까지 작성 가능)를 포함할 수 있다

    • 디폴트 메서드: 인터페이스를 상속한 구현체에 공통으로 들어갈 코드를 디폴트 메서드에 작성하여 반복을 줄인다
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    // java.util.List에 있는 디폴트 메서드
    
    **default void replaceAll(UnaryOperator<E> operator) {
            Objects.requireNonNull(operator);
            final ListIterator<E> li = this.listIterator();
            while (li.hasNext()) {
                li.set(operator.apply(li.next()));
            }
    }**
    
    • default method 제약사항
      • 구현체의 state를 참조할 수 없다
      • equalshashCode 같은 Object의 메서드 정의한건 디폴트 메서드로 제공해서는 안된다.
  • 스태틱 메서드를 가질 수 있다

    • 디폴트 메서드처럼 인터페이스에서 구현이 가능하다
      • 디폴트 메서드와 차이점은, overriding할 수 없다는 것
    • 스태틱 메서드이므로 클래스.메서드() 로 호출
  • 하나의 구현체가 여러개의 인터페이스를 구현(implement)할 수 있다

추상 클래스

  • abstract 키워드와 함께 선언한다.

    1
    2
    
    abstract class Animal {
    }
    
  • 추상 메서드를 가질 수 있다.

    • 추상 메서드는 구현부가 없는 메서드이다.
    • 추상 클래스에 추상 메서드가 반드시 필요하진 않지만, 추상메서드를 포함하는 클래스는 추상 클래스로 선언되어야 한다.
  • 그 자체로 인스턴스화 할 수 없다.

    • 인스턴스화하려면 추상 클래스를 상속한 클래스를 만들어야한다
  • 추상클래스는 생성자가 있고, state를 들고있을 수 있다.

Java8 interface의 default method

디폴트 메서드가 생긴 이유

  • stackoverflow답변들을 보다보니 ‘backward compatibility’~~ 란 말이 있었다.
    • 무슨말인가 보니 JDK 개발자들이 코드를 수정할 때 인터페이스 하나에 메서드를 추가하면 줄줄이 그것을 구현하고 있는 클래스들 코드가 깨져서, 호환성을 유지하기 위해 디폴트 메서드를 만들었다고 한다.
      • 이미 있는 인터페이스에 새로운 메서드를 추가할 때 인터페이스 안에 구현이 있게되면 구현 클래스들에 영향을 받지 않기 때문.

      • 대표적인 예로 collection 인터페이스에 forEach 메서드가 디폴트 메서드로 추가되었는데, 이렇게 해서 기존에 있던 인터페이스의 파라미터에 lambda expression을 넣을 수 있게 되었다.

        1
        2
        3
        4
        5
        6
        7
        
        public interface Iterable<T> {
            public default void forEach(Consumer<? super T> consumer) {
                for (T t : this) {
                    consumer.accept(t);
                }
            }
        }
        
      • (디폴트 메서드를 처음부터 의도가 있어서 만들었다기보다 개발하다보니 필요해져서(?) 만든 것에 가까워서 ‘backward’라는 표현을 쓴 것 같다.)

언제 무엇을 쓸까

추상클래스를 쓰는 것이 좋은 경우

  • 명확하게 계층구조가 필요하여 상속관계로 만들고, 공통된 기능 구현이 필요할 때

  • 코드 예시를 든 좋은 글이 있어서 간단히 요약:

    • 대용량 SMS sender를 구현 - 여러 나라의 통신사들이 다른 tower를 갖고 있어서 각각 다른 구현이 필요 + 공통으로 지켜야할 규칙(DoNotDisturb모드인지 확인) 도 있는 상황일 때
    • SMS를 보내는 추상화된 코드:
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    public void sendSMS(){
       establishConnectionWithYourTower();
       checkIfDoNotDisturbMode();
       // -- SMS 보내기 --
       destroyConnectionWithYourTower();
    }
    
    public void establishConnectionWithYourTower(){
    	// 통신사마다 다르다
    }
    
    public void checkIfDoNotDisturbMode(){
    }
    
    public void destroyConnectionWithYourTower(){
    	// 통신사마다 다르다
    }
    
    • 구현
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    
    abstract class SMSSender{
    
     abstract public void establishConnectionWithYourTower();
    
     public void sendSMS(){
       establishConnectionWithYourTower();
       checkIfDoNotDisturbMode();
       // -- SMS 보내기 --
       destroyConnectionWithYourTower();
     }
    
     abstract public void destroyConnectionWithYourTower();
    
     public void checkIfDoNotDisturbMode(){
    	 // 추상클래스 안에서 구현
     }
    }
    
    /* SMSSender를 통신사 클래스들이 상속 */ 
    class Vodafone extends SMSSender{
     @Override
     public void establishConnectionWithYourTower() {
    	// Vodafone 방식으로 커넥션 맺기
     }
    
     @Override
     public void destroyConnectionWithYourTower() {
    	// Vodafone 방식으로 커넥션 종료
     } 
    }
    
    class Airtel extends SMSSender{
     @Override
     public void establishConnectionWithYourTower() {
     }
    
     @Override
     public void destroyConnectionWithYourTower() {
     }
    }
    

인터페이스를 쓰는 것이 좋은 경우

책 ‘이펙티브 자바’에서는 ‘추상클래스보다 인터페이스를 우선하라’고 하며 여러 이유와 예시 케이스들을 든다.

  1. 기존 클래스에 새로운 인터페이스 구현이 쉽다
    • 인터페이스가 요구하는 메서드들을 추가하고, implements하면 끝.
    • 추상클래스로는 단일 상속만 가능하므로, 새로 추상클래스를 끼워 넣으면 적절하지 않은 상황에서 서브클래스들이 추상클래스를 상속받는다.
  2. mixin 정의 시 적절하다
    • 클래스의 원래의 주된 타입 외에 특정한 다른 선택적 기능을 ‘mix in’하여 제공한다고 선언하는 효과를 줄 수 있다.
    • ex. 어떤 클래스가 Comparable 을 구현하면, Comparable 을 구현한 인스턴스들끼리는 순서를 정할 수 있다고 선언하는 것.
  3. 계층구조가 없는 타입 프레임워크를 만들 수 있다
    • 계층을 엄격하게 구분하기 어려운 개념들도 있다
      • ex. Singer, Songwriter - 둘 다를 구현하는 클래스가 필요할 수 있다. 이걸 인터페이스를 쓰지 않는다면 조합마다 매번 새로운 abstract클래스를 사용하게 됨. 이런 계층이 쌓이고 쌓이면 조합 폭발…

         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        
        // interface
        public interface SingerSongWriter extends Singer, SongWriter {
        	void actSensitive();
        }
        
        // abstract class
        public abstract class SingerSongWriter {
        	abstract AudioClip sing(Song s);
        	abstract Song compose(int chartPosition);
        	void actSensitive();
        }
        

인터페이스와 추상클래스의 장점을 모두 취하는 방법

⇒ 인터페이스와 추상 골격 구현 (skeletal implementation) 클래스를 함께 제공하기

  • 인터페이스로는 타입을 정의하고, 필요 시 디폴트 메서드 제공
  • 골격 구현 클래스는 나머지 메서드들 구현
  • ⇒ 디자인패턴 중 ‘템플릿 메서드 패턴’을 따르는 방법이다.
  • ex 1) Java Collection framework의 AbstractList, AbstractMap, etc.
    • interface로 Map이 있고 골격구현 클래스로 AbstractMap이 있다.
    • HashMap, TreeMap 등은 AbstractMap을 상속하지만 SortedMap은 AbstractMap을 상속하지 않고 Map을 구현한다.
  • ex 2) 조영호 - ‘오브젝트’ 책에도 추상클래스를 이 방식으로 리팩토링하는 과정이 나온다. 글이 너무 길어질 듯 하여 잘 정리된 링크 참조 :-)
  • 이 방법의 장점 : 추상클래스처럼 구현을 도와주면서 + 추상클래스로 타입을 정의할 때 생기는 제약에서 자유롭다.
  • 다만 현실적으로 인터페이스를 추가하는 것이 과할 때가 있다. 트레이드오프이므로 상황에 맞게!

References