본문 바로가기

Java/Java SE, EE

[Java/Java SE, EE] 인터페이스(Interface)

인터페이스(Interface)

자바의 인터페이스란 객체의 사용 방법을 정의한 참조(Reference) 타입을 의미합니다. 인터페이스는 그 자체만으로도 OOP의 다형성(Polymorphism)의 사용성을 높여주기 때문에 굉장히 유용하고, 자주 사용됩니다. 특히 자바 8에서부터 함수형 인터페이스의 지원으로 인해 중요성이 더욱 높아졌습니다.

인터페이스를 선언하는 방법은 클래스의 선언과 유사합니다.

[접근 제한자 public|protected|default|private] interface [인터페이스 이름] { }

인터페이스는 메소드(추상 메소드, 디폴트 메소드, 정적 메소드)와 정적 필드(상수)로만 구성되어 있는 추상 클래스로 볼 수 있습니다. 인터페이스는 객체를 생성 할 수 없기 때문에 생성자를 가지지 않습니다. 

상수 필드(Constant field)

상수 필드는 정적(static) 필드로, 인터페이스가 가질 수 있는 멤버 중 하나입니다. 정적 필드는 클래스 로더에 의해 로드되는 시점에 값이 할당되며, 런타임에 값을 수정 할 수 없습니다.

public [타입] [필드 이름] = [초기 값]

앞서 인터페이스는 메소드를 직접 구현하지 않고, 추상 메소드를 사용한다고 소개했습니다. 따라서 인터페이스의 멤버는 protected, private 접근 제한자를 가질 수 없습니다. 접근 제한자를 통해 멤버를 외부에 숨기게 되면, 이를 내/외부에서 모두 참조 할 수 있는 수단이 없어 무의미한 선언이 되기 때문입니다. 인터페이스의 멤버는 항상 public 접근 제한자를 갖게되어 있으며, default 접근 제한자를 사용하면 public 접근 제한자로 빌드됩니다.

추상 메소드(Abstract method)

추상 메소드는 접근 제한자, 리턴 타입, 메소드 이름, 파라미터의 시그니쳐(Signature)만을 갖는 메소드입니다.

public [리턴 타입] [메소드 이름] ([파라미터])

앞서 소개한 것처럼 인터페이스의 멤버는 항상 public 접근 제한자를 갖게되어 있으며, 인터페이스가 정의하는 추상 클래스 역시 public 접근 제한자를 사용합니다.

정적 메소드(Static method)

인터페이스가 가질 수 있는 또 다른 메소드입니다. 정적 메소드는 클래스처럼 객체를 생성하지 않더라도 호출 할 수 있으며, 인터페이스에서도 마찬가지로 동작합니다. 따라서 인터페이스가 정의하는 정적 메소드는 내부 로직을 직접 구현해야 합니다.

public static [리턴 타입] [메소드 이름] ([파라미터]) { }

정적 메소드 또한 인터페이스에서는 public 접근 제한자로 정의됩니다. 정적 메소드는 자바 8에서부터 제공합니다.

디폴트 메소드(Default method)

정적 메소드와 달리 디폴트 메소드는 객체를 통해 호출 할 수 있습니다. 따라서 부모 클래스가 정의하고, 내부 로직까지 구현하는 일반 메소드와 같다고 볼 수 있습니다.

public default [리턴 타입] [메소드 이름] ([파라미터]) { }

디폴트 메소드 또한 public 접근 제한자로 정의됩니다. 디폴트 메소드는 자바 8에서부터 제공됩니다. 디폴트 메소드는 인터페이스를 구현하는 클래스로부터 오버라이딩 될 수 있습니다. 디폴트 메소드의 오버라이딩-재정의 방법은 클래스 상속 관계에서의 오버라이딩과 동일합니다.

인터페이스 구현(Implement)

인터페이스는 단독으로 사용 될 수 없습니다. 추상 클래스가 단독으로 사용 될 수 없고 파생 클래스에 의해 상속되듯이, 인터페이스는 구현 클래스(Implement class)에 의해서 구현됩니다.

[접근 제한자 public|protected|default|private] class [클래스 이름] implements [인터페이스 이름] { }

클래스의 상속과 달리 인터페이스는 하나의 구현 클래스가 여러 개의 인터페이스를 구현 할 수 있습니다. 이때 구현하는 인터페이스는 콤마(,)를 사용하여 나열합니다. 이를 다중 구현이라고 부릅니다.

[접근 제한자 public|protected|default|private] class [클래스 이름] implements [인터페이스 이름], [인터페이스 이름], ... { }

일단 인터페이스를 구현하게 되면, 구현 클래스는 인터페이스가 정의하고 있는 추상 메소드를 전부 오버라이딩해야 합니다. 이를 실체 메소드의 구현이라고 부릅니다. 만약 인터페이스를 구현하는 구현 클래스가 추상 클래스(Abstract class)인 경우, 인터페이스가 정의하는 추상 메소드를 직접 구현할 필요는 없습니다. 이 경우 추상 클래스를 다시 상속받는 파생 클래스가 실체 메소드를 구현하게 됩니다.

구현 클래스는 인터페이스를 구현(implements)하는 동시에, 어떤 클래스를 상속(extends)받는 파생 클래스일 수 있습니다. 다음과 같이 구현과 상속 클래스를 동시에 사용합니다.

[접근 제한자 public|protected|default|private] class [클래스 이름] extends [클래스 이름] implements [인터페이스 이름], [인터페이스 이름], ... { }

다형성(Polymorphism)

인터페이스의 가장 큰 특징은 다형성에 대한 확장입니다. 클래스에 대한 상속으로 다형성을 획득하게 되면, 파생 클래스가 부모 클래스 또는 조상 클래스(부모 클래스의 부모 클래스)처럼 계층 관계에 있는 클래스로 형 변환이 가능해집니다.

반면 인터페이스는 다중 구현이 가능하며, 다형성에 의해 형 변환이 발생하는 객체 타입들이 계층 관계일 필요가 없습니다. 예를 들어, 다음과 같이 두 개의 인터페이스(Bird, Animal)와 이를 구현하는 구현 클래스(Pigeon)이 있습니다.

public interface Bird { }
public interface Animal { }
public class Pigeon implements Bird, Animal { }

두 인터페이스는 서로 연관성이 없음에도, 이들을 구현하는 Pigeon에 의해서 다형성이 성립됩니다.

Pigeon pigeon = new Pigeon();
Bird bird = pigeon;
Animal animal = pigeon;
bird = pigeon;

익명 구현 객체, 함수형 인터페이스(Functional interface)

인터페이스를 사용 할 때 구현 클래스를 만들어 사용하는 것이 일반적입니다. 하지만 일회성의 구현 클래스를 위해서 인터페이스를 작성하고, 이를 구현하는 소스 파일을 만드는 것은 비효율적입니다.

자바 8부터는 이같은 과정을 거치지 않고도 구현 객체를 만들 수 있는 방법을 제공하는데, 익명 구현 객체라고 부르는 람다(Lambda)식의 하나가 이에 해당합니다.

[인터페이스 타입] [변수 이름] = new [인터페이스 타입]() { };

익명 구현 객체의 사용에 주의할 점은, 객체의 내부를 구성하는 코드 블록({})의 종료에 세미 클론(;)을 반드시 붙여야 한다는 점입니다. 이같은 문법은 다소 생소할 수 있습니다. 앞서 인터페이스는 생성자를 정의 할 수 없고, 구현되지 않고 직접 사용 될 수 없다고 했는데 이러한 규칙을 모두 무시하는 것이 어떻게 가능할까요?

람다의 일종인 함수형 인터페이스(Functional interface)는 인터페이스의 구현을 위한 구현 클래스를 런타임에 처리하는 문법입니다. 실제로는 인터페이스의 new 생성자 문법과 이어지는 코드 블록({})이 구현 클래스의 내용을 대신하고 있는 셈이죠.

인터페이스를 구현하는 구현 클래스의 일반적인 사용 방법은 다음과 같습니다. 구현 클래스는 인터페이스가 정의하는 추상 클래스를 오버라이딩 해야하는 의무가 있습니다.

public interface Bird {
   public void SomeLambdaFunction();
}

public class Pigeon implements Bird {
   @Override
   public void SomeLambdaFunction() { }
}

예시의 코드를 함수형 인터페이스로 처리하게 되면, 변경된 코드는 다음과 같습니다. new 연산자 이후의 코드 블록({})이 구현하는 내용은 일반적인 방식의 구현 클래스를 구현하는 방법과 완전히 동일합니다.

Bird pigeon = new Bird() {
   @Override
   public void SomeLambdaFunction() {
   
   }
};

문법의 사용법은 다르지만, 구현 클래스를 사용하는 것과 함수형 인터페이스를 사용하는 것에는 차이가 없습니다. 컴파일러가 함수형 인터페이스를 발견하면, 자동으로 다음과 같은 이름의 클래스 파일을 생성하기 때문입니다.

[소스 파일 이름]$[함수형 인터페이스 번호]

함수형 인터페이스-익명 구현 객체를 사용하면 코드의 양이 줄어들고, 일회성 구현 클래스를 작성할 필요가 없으며, 인터페이스를 빠르게 구현 할 수 있다는 가능하다는 장점이 있습니다. 하지만 동일한 함수형 인터페이스를 여러 번 반복하여 정의하는 것은 동일한 클래스 파일을 여러 번 생성하는 것과 같으므로, 중복되는 함수형 인터페이스는 지양하는 것이 좋습니다.

인터페이스의 상속

클래스처럼, 인터페이스 역시 다른 인터페이스를 상속 받을 수 있습니다. 클래스와의 차이점은 다중 상속을 허용한다는 점입니다.

[접근 제한자 public|protected|default|private] interface [인터페이스 이름] extends [인터페이스 이름], [인터페이스 이름], ... { }

자식 클래스가 부모 클래스의 추상 클래스를 오버라이딩 하듯이, 인터페이스 역시 상속받은 인터페이스의 추상 클래스를 오버라이딩 할 수 있습니다. 이때 인터페이스는 실체 메소드를 가질 수 없으므로, 다음과 같이 디폴트 메소드를 사용하여 오버라이딩합니다(인터페이스는 그 자체로 추상 클래스와 같으므로, 상속받은 인터페이스의 추상 메소드를 반드시 구현해야 할 필요는 없습니다).

public interface Bird {
   public void SomeFunction();
}

public interface Pigeon extends Bird {
   @Override
   public default void SomeFunction() { }
}

어떤 부모 인터페이스를 상속 받은 자식 인터페이스를 구현하는 구현 클래스를 작성한다면, 계층 관계에 있는 모든 인터페이스의 추상 클래스를 구현 클래스가 구현해야만 합니다. 이는 추상 클래스의 계층적인 상속 관계와 마찬가지입니다.

public interface Bird {
   public void SomeFunction1();
}

public interface Pigeon extends Bird {
   public void SomeFunction2();
}

public class BigPigeon implements Pigeon {
   @Override
   public void SomeFunction1() {
      
   }
   
   @Override
   public void SomeFunction2() {
      
   }
}

디폴트 메소드의 사용 목적

앞서 디폴트 메소드는 자바 8에서부터 지원한다고 소개했습니다. 디폴트 메소드가 자바의 초기 설계에 존재하지 않고, 업데이트를 통해 추가된 이유는 인터페이스의 사용성을 확장하기 위함입니다.

예를 들어, A 인터페이스를 구현하는 구현 클래스 B와 C가 있다고 가정해봅시다. 서비스가 유지되며, 구현 클래스 C에 필요한 함수를 A 인터페이스에 추상 메소드로 추가해야하는 상황이 발생했습니다. 이때 구현 클래스 B는 새로 추가되는 추상 메소드를 사용할 필요가 없음에도 오버라이딩해야만 합니다.

반면 이와 같은 상황에서 A 인터페이스에 디폴트 메소드를 추가하였다면 구현 클래스 B와 C의 수정 없이 유지보수 작업이 완료됩니다. 또는 구현 클래스 B와 C가 동시에 사용하고자 하는 메소드를 A 인터페이스에 추가할때도 마찬가지입니다. 이처럼 디폴트 메소드는 서비스의 유지보수 단계에서 개발 편의성을 위해 추가되었습니다.

인터페이스 상속 관계에서의 디폴트 메소드

앞서 인터페이스를 상속 할 때 부모 인터페이스의 추상 메소드를 오버라이딩하는 방법에 대해 소개했습니다. 부모 클래스가 갖는 디폴트 메소드도 자식 인터페이스에 상속되며, 크게 3가지 방식을 허용합니다.

  • 디폴트 메소드를 단순 상속
  • 디폴트 메소드를 자식 인터페이스가 오버라이딩하여 내용을 변경
  • 디폴트 메소드를 자식 인터페이스가 추상 메소드로 재선언하여, 자식 인터페이스를 상속 받거나 구현하는 객체에 오버라이딩을 다시 요청

디폴트 메소드를 자식 인터페이스가 오버라이딩하여 내용을 변경하는 방법은 다음과 같습니다. 클래스의 상속 관계에서 메소드의 재정의와 크게 다를바가 없습니다.

public interface Bird {
   public default void SomeFunction() { }
}

public interface Pigeon extends Bird {
   @Override
   public default void SomeFunction() { }
}

디폴트 메소드를 자식 인터페이스가 추상 메소드로 재선언 할 수 있는데, 클래스의 상속 관계에서는 허용되지 않는 특이한 문법입니다. 이때 자식 인터페이스를 상속 받거나 구현하는 다른 객체는 추상 메소드를 오버라이딩 해야하는 의무가 있습니다.

public interface Bird {
   public default void SomeFunction() { }
}

public interface Pigeon extends Bird {
   public void SomeFunction();
}

public interface BigPigeon extends Pigeon {
   @Override
   public default void SomeFunction() { }
}