인터페이스 vs 추상클래스

채상엽·2022년 5월 24일
2

Java

목록 보기
5/5
post-thumbnail

자바 8 인터페이스의 특징

public interface A {
  void abstractMethod();
  
  default defaultMethod() {
    System.out.println("디폴트 메서드");
  }
  
  static staticMethod() {
    System.out.println("스태틱 메서드");
  }
}
  • 인스턴스 생성 불가능

  • 상수만 가질 수 있음

  • body가 없는 추상 메서드를 가진다

  • +자바8) default, static 메서드를 가질 수 있다

default 메서드란?

인터페이스에서 메서드 body를 가지는 메서드

접근제한자는 public 이며 (접근 제한자의 default가 아님에 주의하자), 반드시 override 할 필요는 없다.

왜 등장 했지?

이미 작성된 인터페이스에서 기능을 추가하려 한다고 가정했을때, 디폴트 메서드 없이는 구현체 클래스들이 전부 Override를 해야한다. 그러나 디폴트 메서드에서 메서드 body가 정의되어 있다면 개별적으로 구현하는 과정없이 하위 호환이 가능해진다.

다음과 같은 동물 인터페이스가 있다고 가정하자.

public interface 동물 {
  public void 종족();
}

그리고 동물 인터페이스를 구현하는 구현체로 사람과, 개 클래스가 있다고 가정하자.

public class 사람 implements 동물 {
  @Override
  public void 종족() {
    System.out.println("저는 사람입니다");
  }
}
public classimplements 동물 {
  @Override
  public void 종족() {
    System.out.println("저는 개입니다");
  }
}

두 클래스를 보면 사람과 개 클래스는 서로 다른 종족이므로, 종족() 메서드의 바디는 각각의 구현 클래스에서 재정의하여 사용하는 모습을 볼 수 있다.

만약 여기에 먹다()라는 기능을 추가하려 하는데, 이 메서드는 저는 입으로 먹습니다를 출력한다고 가정하자. 개와 사람은 모두 음식을 입으로 먹는다.

이 경우 만약 추상 메서드로 정의하게되면 각각의 구현 클래스에서 똑같은 내용의 바디를 두 번 작성해야하는 번거로움이 생긴다. 이 때 사용할 수 있는 것이 default 메서드 이다. default 메서드를 사용하여 다음과 같이 구현이 가능하다.

public interface 동물 {
  public void 종족();
  default void 먹다() {
    System.out.println("저는 입으로 먹습니다");
  }
}

구현체에서는 별도로 먹다() 메서드를 재정의할 필요 없이 인터페이스의 바디에 정의된 기능을 사용할 수 있게 되었다.

인터페이스를 써야하는 이유

다중 상속이 가능하다

사실 자바에서는 다중 상속을 지원하지 않는다. 그러나 일단 한번 봐보자. 다음은 추상클래스로 구현된 Singer, SongWriter, SingerSongWriter 클래스이다.

public abstract class Singer {
  public abstract void sing();
}
public abstract class SongWriter {
  public abstract void writeSong();
}
public class SingerSongWriter extends Singer {
  public void sing() {
    System.out.println("노래 부르기");
  }
}

SingerSongWriter가 노래도 부르게 하고 작곡도 하게 하고 싶지만, 추상클래스로 구현했을 경우 다중 상속이 되지 않기 때문에 SongWriter의 기능을 구현할 수 없게 된다. 다음과 같이 SingerSongWriter가 노래와 작곡을 할 수 있도록 구현을 할 수 있기는 하다.

public abstract class Singer {
  public abstract void sing();
}
public abstract class SongWriter extends Singer{
  public abstract void writeSong();
}
public class SingerSongWriter extends SongWriter {
  
  @Override
  public void sing() {
    System.out.println("노래 부르기");
  }
  
  @Override
  public void writeSong() {
    System.out.println("작곡하기");
  }
}

Singer -> SongWriter -> SingerSongWriter 의 구조가 되는데, 물론 가능하지만 SongWriterSinger을 확장하게 됨으로써, 작곡가는 노래 부를 필요가 없지만 노래 부르는 메서드를 상속받게 되어 불필요한 책임을 가지게 된다. 즉 작곡의 기능만 하는 독자적인 작곡가 클래스를 생성할 수 없게 된 것이다.

이러한 문제점을 인터페이스의 다중 상속. 다시 말하면 다중 구현을 통해 해결할 수 있다.

public interface Singer {
  public void sing();
}
public interface SongWriter {
  public void writeSong();
}
public class SingerSongWriter implements Singer, SongWriter {
  
  @Override
  public void sing() {
    System.out.println("노래 부르기");
  }
  
  @Override
  public void writeSong() {
    System.out.println("작곡하기");
  }
}

다중 상속은 다이아몬드 문제가 있지 않나?

다이아몬드 문제

다중 상속 시, 부모 클래스에 같은 시그니처의 메서드가 있을 때, 어떤 메서드를 상속받아야 하는지 판별할 수 없는 문제

image

인터페이스의 추상메서드의 경우에는 메서드 구현부 즉, body가 비어있으므로 모호함이 발생하지 않는다. 쉽게 말하면 어쨌든 같은 반환값과 같은 메서드명을 가지는데, body가 비어있으므로 두 인터페이스 중 어떤 메서드를 가져와 재정의 하더라도 상관이 없다는 의미이다.

그렇다면 default 메서드는 바디를 갖는데, 다아이몬드 문제가 일어나지 않을까?

다음의 우선순위에 따라 메서드가 선택이 된다.

  1. 구현하는 클래스나 슈퍼클래스

    위 그림 처럼, B와 C의 someMethod()가 default이고 A가 someMethod()를 재정의할 때, A의 재정의가 우선권을 가지게 된다.

  2. 상속받는 인터페이스

    위 그림이 아닌, A->B->C 구조로 B인터페이스가 A를 확장하고, C가 B를 구현한다면, 상속받는 인터페이스인 B인터페이스에서 재정의된 B의 기능이 우선권을 갖게된다.

  3. 명시적 선언

    위 그림 처럼, 상속받는 구조가 없거나, A에서도 재정의를 하지 않고 인터페이스의 default 메서드의 기능을 사용하려고 한다면 A의 구현부에서 B.super().someMethod(); , C.super().someMethod(); 와 같이 명시적으로 사용하고하는 메서드를 선언해준다.

위와 같은 우선순위를 따름으로써, 다중 상속으로 인한 다이아몬드 문제를 회피할 수 있다.

공통의 조상이 없는 여러 클래스들과 관계를 맺을 수 있다.

기존에 클래스를 이용한 확장(extends) 관계의 경우에는 자식이 부모의 필드나 메서드를 참조할 수 있기 때문에, 부모는 자식과 동일하면서 자식은 부모의 기능을 확장하는 구조로 이루어진다. 때문에 부모-자식 관계 뿐 아니라, 같은 부모에서 확장된 자식 클래스들 끼리도 어느정도 역할에 있어서 공통점이 존재해야 했다.

그러나 인터페이스의 구현체의 경우, 자식들간에 전혀 아무런 관계가 없더라도 인터페이스와 관계를 맺을 수 있다.

다음은 모기 클래스와 악당 클래스에 대한 예제이다. 둘은 서로 아무런 관계도 없지만, 공통적으로 해롭다는 특징을 갖는다. 이를 다음과 같이 구현할 수 있다.

public interface Evil {
  void feelHarmful();
}
public class Mosquito implements Evil {
  private void eat() {
    System.out.println("피를 빨아먹다");
  }
  
  @Override
  public void feelHarmful() {
    System.out.println("질병을 퍼뜨리므로 해롭다");
  }
}
public class Villain implements Evil {
  private void doBadThings() {
    System.out.println("나쁜 짓을 하다");
  }
  
  @Override
  public void feelHarmful() {
    System.out.println("사람들을 괴롭히므로 해롭다");
  }
}

위 처럼 구성하게 되면 각각의 구현체들은 나름의 방식으로 feelHarmful()을 구현하게 된다.

이와 같이 구성함으로써, 사용자 입장에서는 내부적 구조에 대한 이해가 없더라도 feelHarmful이라는 약속된 행위가 보장된다.

public class Main() {
  public static void main(String[] args) {
    Evil mosquito = new Mosquito();
    Evil villian = new Villian();
    
    mosquito.feelHarmful();
    villian.feelHarmful();
  }
}

결론적으로는 추상 클래스도 이러한 구현이 가능하지만, 인터페이스로 구현해야 하는 이유는 추상클래스의 경우에는 상속 관계에 한정해서만 다형성이 적용되기 때문에, 인터페이스가 더 유연하게 다형성을 적용할 수 있게 된다.

언제 추상클래스를 쓰고 언제 인터페이스를 써야 하나?

추상 클래스

  • 굉장히 밀접하게 관련된 클래스들끼리 코드를 공유해야 할 때
  • 추상클래스의 하위 구현체 클래스들이 공통된 필드나 메서드를 많이 공유하고, 접근제한자가 public 이 아닌경우
  • Non-static 또는 non-final 필드로 객체의 상태를 바꿔야 하는 경우

인터페이스

  • 관련이 없는 클래스들끼리 관계를 맺어줄 때
  • 특정 데이터 타입의 동작을 지정하려고 하지만 해당 동작을 누가 구현하는지 중요하지 않을 경우
  • 다중 상속이 필요한 경우

[참고]

https://www.youtube.com/watch?v=T1BJzC9xb0g

profile
프로게이머 연습생 출신 주니어 서버 개발자 채상엽입니다.

0개의 댓글