인터페이스와 추상클래스 중 무엇을 선택해야할까?

21228 단어 JavaJava

1. 개요

추상화는 객체 지향 프로그래밍의 핵심 기능 중 하나이다. 그리고 Java에서는 다음 두 가지를 사용하여 추상화를 구현한다.

  • 인터페이스
  • 추상 클래스

사실 인터페이스 와 추상 클래스의 기능은 누구나 다 알고 있지만 코드를 작성할 때 어느 것이 최선인지를 설명하기는 쉽지 않다. 인터페이스는 다중 상속이 가능하기 때문에 사용하고 추상 클래스는 다중 상속이 불가능하기 때문에 라는 답변도 어딘가 부족해 보이는 설명이다. 그리고 인터페이스 자체는 메서드의 구현이 불가능하다라는 특징도 Java 8 이후의 default 메서드가 생긴 이후부터 사라졌다.

그래서 Java 애플리케이션을 설계하는 동안 인터페이스를 사용해야 하는 경우와 추상 클래스를 사용해야하는 경우에 대해서 설명하려고 한다.

2. 인터페이스 vs 추상 클래스

먼저 인터페이스와 추상 클래스의 차이점은 다음과 같다.

  • 추상 클래스의 접근 제한자에는 제약이 없지만 인터페이스는 기본적으로 public 이다.
  • 추상 클래스는 인스턴스와 static 초기화 block을 가질 수 있지만 인터페이스는 절대 가질 수 없다.
  • 추상 클래스를 상속받은 자식 객체가 인스턴스화 중에 실행되는 생성자가 있을 수도 있다.
  • 인터페이스는 Java 8 에서 도입된 Functional 인터페이스를 사용하여 선언할 추상 메서드를 제한할 수 있지만 추상 클래스에서는 이러한 제한을 가질 수 없다.

추상 클래스와 인터페이스의 비슷한 점은 다음과 같다.

  • 둘 다 인스턴스화 할 수 없다. new 를 직접 사용하여 객체를 인스턴스화 할 때 익명 클래스를 사용하여 모든 메서드를 재정의 해야만 한다.
  • 둘 다 구현 여부에 상관없이 선언 및 정의된 메서드들을 가질 수 있다.
    • 인터페이스의 static & default 메서드
    • 추상 클래스의 일반 메서드
    • 추상 메서드는 두 곳에 모두 존재 가능하다.

2.1 인터페이스를 사용해야 할 때

1. 다중 상속을 해야하고 다른 클래스 계층으로 구성된 경우
2. 관련없는 클래스가 인터페이스를 구현해야 할 때
3. 애플리케이션 기능을 구현해야 하지만 누가 동작을 구현하는 지에 대해서 상관 없는 경우

1번은 인터페이스니까 당연하고 2번은 Comparable 과 같이 두 객체를 비교하기 위해서 반드시 재정의를 해야하는 경우를 뜻한다.

public class Member implements Comparable<Member>{
    @Override
    public int compareTo(Member o) {
        return 0;
    }
}

3번이 중요한데 좋은 객체 지향 프로그래밍을 설계하기 위해서는 모듈 내부의 응집도는 높아야하지만 모듈 간의 결합도는 낮아야한다. 그리고 인터페이스는 결합도를 낮추는데 매우 좋은 역할을 한다.

기능상 밀접한 관계의 클래스 A와 B가 있다고 가정할 때 결합도가 높으면 클래스 A를 변경하면 클래스 B의 변경을 더 자주 발생시킨다. 이는 개발자가 코드를 수정할 때, 유지보수를 어렵게 만드는 요인 중 하나이다.

다음 예제를 보자

public class Car {
    public void move(){
        System.out.println("Car is moving");
    }
}

public class Traveler {
    Car c = new Car();
    public void startJourney(){
        c.move();
    }
}

클래스 둘은 현재 강하게 결합되어 있다. Traveler 클래스에서 bike 객체를 사용하고 싶다면 startJourney() 메서드에 Car 객체의 인스턴스를 bike 객체의 인스턴스로 반드시 변경해야한다.
이것을 인터페이스를 이용해 다음과 같이 느슨한 결합으로 바꿀 수 있다.

public interface Vehicle {
    public void move();
}
public class Bike implements Vehicle{
    @Override
    public void move() {
        System.out.println("bike is moving");
    }
}
public class Car implements Vehicle{
    public void move(){
        System.out.println("Car is moving");
    }
}
public class Traveler {
    public Vehicle v;

    public void setV(Vehicle v) {
        this.v = v;
    }

    public void startJourney(){
        v.move();
    }

    public static void main(String[] args) {
        Traveler t = new Traveler();
        t.setV(new Car());
        t.startJourney();
        t.setV(new Bike());
        t.startJourney();
    }
}

Vehicle 인터페이스를 상속 받은 Bike 와 Car 클래스에 각각 다른 동작을 선언하고 이것을 사용하는 Traveler 클래스에서는 사용하고자 하는 객체만 셋팅해주면 되기 때문에 이전처럼 다른 객체를 사용하고 싶다고 코드를 변경할 일이 없어졌다. Bus 객체를 사용하고 싶다면 Vehicle 을 구현하는 Bus 객체를 생성하고 main 에 new Bus 만 선언해주면 끝나는 일이다.

마지막으로 인터페이스를 사용하는 것을 고려해야할 때는 A is capable of [doing this]" 일 때다. 번역하면 "A는 [이 일을] 할 수 있다" 가 된다.

public interface Clonable{
	void clone(Object object);
}
public class StringClone implements Clonable{
	@Override
    public void clone(Object object){
    	// TODO
    }
}

여기서 Clonable 인터페이스는 clone() 메서드가 있는 인터페이스이다. 다시 말해 "Clonable 은 객체를 복사할 수 있다" 를 인터페이스로 구현한 것이다. 그리고 StringClone 은 String 객체를 복사하기 위해 Clonable 인터페이스를 구현한다. Clonable 인터페이스를 이용해서 String 뿐만 아니라 다른 객체도 재정의를 통해 다양한 작업을 수행할 수 있도록 만들 수 있다.

	String str = "Hello";
    
    Clonable c = new StringClone();
    c.clone(str);

2.2 추상 클래스를 사용해야 할 때

추상 클래스는 다음과 같은 상황일 때 사용해야한다.

1. 서브 클래스가 상위의 공통 클래스의 메서드를 재정의함으로써 상속관계의 코드를 사용하고자 할 때
2. 특정 요구사항이 필요하지만, 구현 세부 정보의 일부만 필요한 경우
3. 접근제한자가 public 이 아닌 공통 필드나 메서드를 가지는 클래스를 상속받고자 할 때
4. 객체의 상태를 변경하기 위해 non-final 또는 non-static 필드가 필요할 때

4번째가 중요한 데 인터페이스는 동작을 중심으로 구현을 했다면 추상 클래스는 동작 뿐만이 아니라 상위 클래스의 상태 변경이 가능하다. 그리고 인터페이스와 다르게 추상 클래스는 "A is a B" 일 때 사용한다. 예를 들어 "개는 동물이다", "벤츠는 차다" 등등이 있다.

abstract class GraphicObject {
    int x, y;
    ...
    void moveTo(int newX, int newY) {
        ...
    }
    abstract void draw();
    abstract void resize();
}
class Circle extends GraphicObject {
    void draw() {
        ...
    }
    void resize() {
        ...
    }
}
class Rectangle extends GraphicObject {
    void draw() {
        ...
    }
    void resize() {
        ...
    }
}

위 코드는 GraphicObject 클래스를 확장한다. 이것을 그림으로 그려보면 아래와 같다

A is a B 관계에도 딱 맞아 떨어진다.

  • Circle is a GraphicObject
  • Rectangle is a GraphicObject

위 코드에서 인터페이스와의 큰 차이점은 int x, y 를 추상 클래스에서 선언해 "상태"에 관여한다는 점이다. 인터페이스에서는 행동을 정의할 수는 있어도 상태 변경은 불가능하다.

조금 더 자세히 알아보기 위해 마지막으로 JDK에서는 추상 클래스와 인터페이스를 어떻게 사용하는지 알아보자.

3. JDK 의 추상 클래스와 인터페이스

JDK의 추상 클래스의 예는 Collections Framework 의 일부인 AbstractMap 이다.
HashMap 의 코드는 다음과 같이 선언되어 있다.

public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable

HashMap, TreeMap 및 ConCurrentHashMap 을 포함하는 서브 클래스는 AbstractMap 이 정의하는 많은 메서드를 공유한다.

ex) get, put, isEmpty, containsKey, containsValue...

여러 인터페이스를 구현하는 JDK 클래스의 예로는 Serializable, Cloneable, Map<K,V> 가 있다. A is capable of [doing this] 에 따르면 다음과 같다.

  • HashMap 은 인스턴스를 복제(Cloneable)할 수 있다.
  • HashMap 은 직렬화 가능(Serializable)하다.
  • HashMap 은 Map 의 기능을 가지고 있다

Map<K,V> 인터페이스에는 Map 객체를 사용할 때 주로 사용하는 get()과 put()이 선언되어 있고 이를 추상 클래스인 AbstractMap 에서 구현한다. 또한 Map<K,V> 인터페이스에는 merge 및 forEach 와 같은 많은 default 메서드 를 포함하고 있기 때문에 Map 객체라면 제약없이 default 메서드 사용이 가능하다.

3. 마무리

추상 클래스와 인터페이스 모두 코드의 유지보수를 쉽게 만들어주는 요소들이다. 인터페이스는 상태를 가질 수 없지만 동작에 해당하는 메서드를 재정의함으로써 모듈 간의 느슨한 결합을 도와준다. 그리고 추상 클래스는 접근제한자에 상관없이 필드를 가질 수 있고 객체의 상태 변경과 메서드 구현 정보의 일부만 선택해서 재정의할 때 사용한다. 좋은 객체지향 애플리케이션의 설계를 위해서 어느 한 개가 무조건 낫다고 말하기는 어렵기 때문에, 자신이 현재 설계하고자 하는 코드가 is-a 관계인지 is capable of 관계인지 구분을 명확히 지어 코드를 작성하는 것이 옳은 방향이라고 생각한다.

3.1 참고 내용 출처

좋은 웹페이지 즐겨찾기