Technology/Java

Java의 인터페이스에 대한 고찰

ikjo 2022. 9. 8. 06:42

실생활에서의 인터페이스

인터페이스란 무엇일까? 인터페이스는 객체(사람, 사물 등)간 상호작용하기 위한 행동(action)이라고 할 수 있다. 예를 들어, 어떤 고객이 식당에서 특정 메뉴를 주문한다고 가정했을 때, 고객이 식당에 특정 메뉴를 주문하기 위해서는 일종의 '수단'이 필요하다.

 

이때 이러한 수단에는 카운터 직원이나 키오스크 등이 존재할 수 있다. 즉, 가게에서는 고객의 주문을 위한 수단으로서 카운터 직원, 키오스크 등의 수단을 제공하는데, 이러한 수단들은 공통적으로 '주문 받기', '결제하기' 등의 행동으로 고객들과 상호작용한다.

 

고객 입장에서는 자신을 응대하는 수단이 무엇인지와 또한 내부 처리 과정이 어떤지에 대해서는 큰 관심이 없다. 단지 메뉴를 주문하기 위해 해당 수단이 제공하는 '주문 받기', '결제하기' 등의 행동을 통해 원하는 결과물(음식)을 얻을 수 있기만 하면 된다. 이처럼 고객과 상호작용(거래)을 위한 '주문 받기', 결제하기' 등의 다소 추상적인 행동을 '인터페이스'라고 볼 수 있다.

 

자바에서의 인터페이스

자바 프로그램이 실행될 때는 여러 객체(클래스의 인스턴스)들간 다양한 상호작용이 일어난다. 이러한 상호작용은 주로 객체별로 외부에 공개된 메서드(public method)를 통해서 이루어지는데, (같은 패키지에 속한 객체나 상속관계에 있는 객체는 예외) 어떤 객체들의 경우 동일한 기능을 제공하는 경우가 여럿 있을 것이다. 예를 들어, ArrayList 객체와 LinkedList 객체는 서로 구현 내용은 다르지만, 데이터를 순서대로 추가한다는 점에서 add라는 공통적인 기능을 가지고 있다. (실제론 이외에도 여러가지 공통적인 기능들을 가진다.)

 

이때 이러한 공통적인 기능에 대해서 메서드 이름, 반환 타입, 매개변수를 동일하게 표준화한다면 어떨까? 이 기능을 사용하는 입장에서는 ArrayList 객체를 쓰든, LinkedList 객체를 쓰든 동일한 메서드만 호출하면 되므로, 객체의 변화에 대해 코드 수정이 필요하지 않다. 자바에서는 이를 위해 interface(인터페이스) 키워드를 제공한다.

 

인터페이스의 정의

자바에서 인터페이스는 퍼블릭 추상 메서드(public abstract)퍼블릭 정적 상수(public static final)로 구성된다. 이때 public abstract 키워드와 public static final 키워드는 따로 붙이지 않아도 컴파일러가 자동으로 추가해준다.

※ 참고로 인터페이스는 자바 8부터 default 메서드와 static 메서드를, 자바 9부터 private 메서드를 사용할 수 있게 되었다. 자세한 내용은 아래에서 다루고자 한다.

 

앞서 들었던 예시를 바탕으로 주문 받기에 대한 공통적인 기능을 정의한 인터페이스를 다음과 같이 정의해볼 수 있다.

 

public interface Receivable { // 1개의 퍼블릭 정적 변수와 4개의 퍼블릭 추상 메서드로 구성

    int MINIMUM_ORDER_AMOUNT = 5000;
    
    void receiveOrder();

    void takeMoneyForPay();

    void takeCardForPay();

    void takeQRForPay();
}

 

인터페이스 구현

인터페이스는 말 그대로 인터페이스를 정의한 것으로서 추상클래스처럼 그 자체로 인스턴스를 생성할 수 없으며(추상 메서드의 몸통도 구현해주어야 하닌까!), 인터페이스를 구현(implements)한 구현체로서 사용될 수 있다. 이때 인터페이스와 해당 인터페이스를 구현한 클래스 간에는 '클래스가 (인터페이스) 할 수 있는' 관계에 있다고 볼 수 있다.

 

다음은 Receivable 인터페이스를 구현한 Cashier와 Kiosk 클래스이다. 이때 클래스명 오른쪽에는 implements 키워드와 함께 인터페이스명을 명기해준다.

 

public class Cashier implements Receivable {

    @Override
    public void receiveOrder() {
        // ...
    }

    @Override
    public void takeMoneyForPay() {
        // ...
    }

    @Override
    public void takeCardForPay() {
        // ...
    }

    @Override
    public void takeQRForPay() {
        // ...
    }
}

class Kiosk implements Receivable {
    
    @Override
    public void receiveOrder() {
        // ...
    }

    @Override
    public void takeMoneyForPay() {
        // ...
    }

    @Override
    public void takeCardForPay() {
        // ...
    }

    @Override
    public void takeQRForPay() {
        // ...
    }
}

 

인터페이스에는 추상메서드가 선언되므로, 인터페이스를 구현하는 클래스는 해당 추상메서드를 구현하도록 컴파일러에 의해 강제받는다. 참고로 인터페이스를 구현해도 또 다른 클래스를 상속받는데에는 전혀 지장이 없다. 또한 이때 어떤 클래스가 여러 개의 클래스를 다중으로 상속하지 못하지만 여러 개의 인터페이스는 다중으로 구현할 수 있다.

 

아래 예시에서는 기존 Receivable에서 제공하던 결제 관련 인터페이스를 Payment 인터페이스로 분리하여 Cashier 클래스와 Kiosk 클래스가 이 두 인터페이스를 다중으로 구현하도록 했다. 물론, Receivable 인터페이스에서 모두 관리할 수도 있겠지만, 추후 유지보수나 확장성 측면에서 기능별로 각각의 인터페이스를 분리하는 것이 유리할 수 있다.

 

public class Cashier implements Receivable, Payment {

    // ...
}

class Kiosk implements Receivable, Payment {
    
    // ...
}

 

인터페이스의 상속

또한 인터페이스는 또 다른 인터페이스를 상속받을 수 있으며 다중 상속이 불가능한 클래스와 달리 인터페이스는 다중 상속이 가능하다. 다만, 클래스는 상속받을 수 없다. 

 

아래 예시 코드를 확인해보자.

 

public interface Telephone extends Camera, MusicPlayer  {

    // ...
}

 

기존 Telephone 인터페이스는 음성 통화와 관련된 인터페이스만 제공하고 있었으나, Camera 및 MusicPlayer 인터페이스를 상속받음으로써 사진 촬영이나 음악 재생 같은 추가적인 인터페이스를 제공할 수 있게 되었다.

 

인터페이스 구현체 사용하기

이제 인터페이스를 구현한 클래스를 사용해보자. 하위 클래스가 상위 클래스를 상속받은 경우, 상위 클래스 타입의 참조변수로 하위 클래스의 인스턴스를 참조할 수 있듯이 인터페이스 타입의 참조변수로 인터페이스를 구현한 클래스의 인스턴스를 참조할 수 있다. 이 경우 해당 인터페이스에 정의된 멤버들만 호출이 가능하다.

 

        Receivable receiver = getReceiver("Kiosk");
        receiver.receiveOrder();
        
        // ...
    }

    public Receivable getReceiver(String type) {
        if (type.equals("Cashier")) {
            return new Cashier();
        } else if (type.equals("Kiosk")) {
            return new Kiosk();
        }

        throw new IllegalArgumentException("올바른 주문 접수 방법이 아닙니다.");
    }

 

이처럼 인터페이스 타입의 참조변수를 이용하면 여러가지 형태의 구현체를 나타낼 수 있으며, 인터페이스를 구현한 인스턴스가 무엇이든 사용자 입장에서 정해진 인터페이스(여기서는 receiveOrder)만 호출하면 되기 때문에 변경에 유연한 장점이 있다.

 

인터페이스에서의 default, static, private 메서드

위에서 잠시 언급했지만 자바 8부터 default 메서드와 static 메서드를, 자바 9부터 private 메서드를 사용할 수 있게 되었다. 

 

우선 static 메서드의 경우 인스턴스와 관계 없이 독립적인 메서드(유틸리티 메서드 등)이기에 인터페이스에 존재할법도 했다. 하지만 자바 8 이전에는 인터페이스에 추상 메서드만 선언할 수 있었기에, 특정 인터페이스에 static 메서드를 선언하지 못해 불편한 점이 있었다. 예를 들어, Collection 인터페이스에는 static 메서드를 선언할 수 없었기 때문에 Collections 클래스를 별도로 만들어 sort, shuffle, reverse 등의 컬렉션 관련 유틸 메서드들을 구현했다고 한다.

 

이러한 해프닝을 뒤로하고 자바 8부터는 static 메서드 선언이 가능해졌다.

 

    static void staticMethod() { // 접근제어자는 마찬가지로 public이며 생략이 가능하다.
        // 추상 메서드와 달리 몸통이 있어야 한다.
    }

 

또한, 추상 메서드만 선언할 수 있는 문제로 인해 많은 클래스들이 어떤 인터페이스를 구현하고 있는 상황에서 해당 인터페이스에 기능을 추가하는 것이 어려워졌다. 왜냐하면 컴파일러가 추상 메서드 구현을 강제하기 때문에 해당 인터페이스를 구현하는 모든 클래스에서 추가된 기능을 구현해야하기 때문이다.

 

이러한 문제를 개선하기 위해 자바 8부터는 default 메서드를 인터페이스에 추가할 수 있게 되었는데, default 메서드는 추상 메서드가 아니기 때문에 모든 클래스가 이를 구현할 필요가 없다. 이를 통해 인터페이스와 구현체 사이에 추상클래스를 추가함으로써 (추상 메서드를 빈 내용의 메서드로 오버라이딩) 특정 메서드만 구현하는 방식을 취하지 않아도 된다. 이 덕에 구현체 입장에서는 어떤 클래스를 상속할 수 있는 여지가 생겼다. (자바는 다중 상속 불가) 참고로 default 메서드를 구현하지 않을 경우 인터페이스에 default 메서드가 정의된 대로 사용할 수 있다.

 

public interface Payment {

    default void takeMoneyForPay() { // 접근제어자는 마찬가지로 public이며 생략이 가능하다.
        // 추상 메서드와 달리 몸통이 있어야 한다.
    }
}

 

default 메서드 선언 시 유의해야할 몇 가지가 있다. 우선, default 메서드는 구현체가 모르게 추가된 기능으로 리스크가 있는 만큼 해당 메서드에 대해 문서화를 하는 것이 좋다. 또한 Object가 제공하는 기능 (equals, hashCode 등)는 default 메서드로 제공할순 없다. 즉, 인터페이스를 구현하는 구현체가 오버라이딩 해야하는 것이다. 그리고 인터페이스를 상속받는 인터페이스에서는 default 메서드를 추상 메서드로 변경할 수도 있다. 마지막으로, 두 개의 인터페이스를 구현하는데, 각각의 인터페이스에 동일한 default 메서드가 선언되있을 경우 다이아몬드 문제가 발생하기 때문에 이 경우엔 이중 구현이 불가능하다.

 

(참고로 Object가 제공하는 메서드의 선언부를 접근제어자를 붙이지 않고 동일하게 선언할 순 있지만, 이는 인터페이스에서 추상 메서드로 인정되지는 않는다.)

 

이처럼 default 메서드를 사용함으로써 인터페이스에 몸통이 있는 메서드를 작성할 수 있게 되었는데, 또 하나 불편한 점이 생겼다. 보통 프로그래밍을 할 때 메서드의 라인(line)이 길어지면 작업 단위로 메서드를 분리하곤 한다. 이때 분리되는 메서드는 굳이 외부에 노출시킬 필요가 없으므로 접근 제어자를 private으로 지정(캡슐화)하는데, 문제는 인터페이스에 private 메서드를 지정할 수가 없다는 것이다.

 

이러한 문제를 개선코자 자바 9부터는 인터페이스에 private 메서드도 정의할 수 있게 되었다.

 

public interface Payment {

    default void takeMoneyForPay() {
        something();
    }

    private void something() {
        System.out.println("private 메서드 호출");
    }
}

 

 

인터페이스 vs 추상 클래스

그런데 인터페이스를 다루면서 한가지 의문이 생길 수 있다. "추상 클래스 역시 추상 메서드를 포함하고 있는 클래스인데, 그냥 추상 클래스로 통일시키면 되지않을까?"

 

우선 인터페이스와 추상 클래스의 가장 큰 차이는 '관계'에 있다. 인터페이스와 구현 클래스간에는 '~ 할 수 있는' 관계에 있는 반면 추상 클래스는 하위 클래스간에는 '~ 의 종류이다.' 관계에 있다.

 

게임 스타크래프트를 예로 들어보자. 배틀크루져, 발키리, 레이스 3개의 클래스가 있다고 했을 때 이들은 '공중 유닛'이라는 추상 클래스를 상속함으로써 구현될 수 있을 것이고, 의미적으로도 '~ 의 종류이다.' 관계를 충족한다. 이때 '공중 유닛'을 인터페이스로 선언하고 이를 구현해도 기능상 문제는 없겠지만 '~ 할 수 있는' 관계라고 보기는 힘들다.

 

 

마찬가지로 고스트, 마린, 파이어뱃 3개의 클래스가 있고 이들 모두 '지상 유닛'이라는 추상 클래스를 상속했다고 가정해보자. 현재 계층도는 다음과 같다.

 

 

이때 레이스와 고스트는 다른 종류의 유닛이지만 '클로킹'이라는 공통적인 기능을 가진다. 또한, 마린과 파이어뱃은 같은 종류의 유닛이면서 '스팀팩'이라는 공통적인 기능을 가진다. 이때 클로킹과 스팀팩이라는 인터페이스를 만들어 각각의 유닛들이 이를 구현하게 할 수 있다. 의미적으로도 '~ 할 수 있는' 관계를 충족시킨다.

 

 

지금까지 의미적으로 '~ 할 수 있는', '~ 의 종류인' 관계에 초점을 두어 인터페이스와 상속을 결정했는데, 이제부터 기능적으로 어떤 차이가 있는지 살펴보자.

 

우선 자바는 다중 상속을 지원하지 않는다. 이미 특정 클래스를 상속받은 레이스 클래스와 고스트 클래스는 클로킹이라는 공통적인 기능을 별도 추상 클래스로 분리한다고 해도 이를 상속받을 수 없는 것이다.

 

공중 유닛과 지상 유닛 클래스에 각각 클로킹 기능을 추상 메서드로 선언해보는 것도 생각해볼 수 있겠다. 하지만, 이럴 경우 클로킹 기능이 있어선 안되는 유닛들도 클로킹 기능을 가지게 되는 문제가 발생한다.

 

이때 클로킹이라는 공통적인 기능을 인터페이스로 분리한다면, 선택적으로 클래스들(레이스, 고스트 등)을 선별하여 클로킹 기능을 구현하도록 강제할 수 있다.

 

추상 클래스인 공중 유닛 클래스와 지상 유닛 클래스는 여러 유닛들을 아우르는 공통적인 클래스로서 하위 클래스들이 재사용하고 확장(오버라이딩)할 수 있는 멤버들이 많을수록 좋다. 이는 하위 클래스의 인스턴스를 상위 클래스 타입의 참조 변수에 대입했을 때 상위 클래스의 인스턴스 역할을 하는 데 문제가 없어야 하기 때문이다. (객체 지향 설계 5원칙 中 리스코프 치환 원칙)

 

하지만 특정 유닛들에 한정해서 기능을 적용할 수 있는 인터페이스는 구현을 강제할 추상 메서드를 최소화하는 게 좋다. 이는 인터페이스를 구현하는 클래스가 자신이 사용하지 않는 메서드에까지 의존 관계를 맺지 않도록 하기 위함이다. (객체 지향 설계 5원칙 中 인터페이스 분리 원칙)

 

 

참고자료

  • 도우출판 "자바의 정석"
  • 위키북스 "스프링 입문을 위한 자바 객체 지향의 원리와 이해"
  • 인프런 "더 자바, Java 8"
  • https://www.baeldung.com/java-interface-private-methods