2024. 3. 27. 20:00ㆍ백엔드/자바
자바 - 인터페이스(Interface)
그 유명한 인터페이스다. 추상 클래스를 배웠다면 다음으로 배우게 되는 것이 보통 인터페이스다. 인터페이스는 추상 클래스와 비슷한 면이 있어 혼동되는 감이 있지만, 이 기회에 인터페이스를 잘 배워두어 추상 클래스와의 차이점을 알아두는 것이 좋다.
인터페이스의 포괄적 정의
인터페이스(interface) inter(사이) + face(얼굴)을 합성해 만든 단어로 말 그대로 어떤 사물이 맞닿아있는 면을 얘기한다.
인터페이스라는 용어는 꼭 자바에만 있는 것이 아니다. 위키 백과에 따르면, 인터페이스는 서로 다른 두 개의 시스템, 장치 사이에서 정보나 신호를 주고받는 경우의 접점이나 경계면이라고 한다. 즉, 사용자가 기기를 쉽게 다루기 위해 특별히 만들어 놓은 제어 장치가 되는 것이다.
이러한 정의는 그대로 자바로 옮겨져 객체의 인스턴스 메서드를 이용하는 사용자 입장에서 "그 객체의 내부 구현이 어떻든 깊이 학습할 필요없이 원하는 메서드만 호출하고 결과값만 제대로 받게 해주는 간편한 상호작용 기능이다"라고 말할 수 있다.(추상적)
프로그래밍을 모르는 사람에게 인터페이스가 무엇이냐고 물어본다면, 아마 다음과 같은 GUI를 예시로 들며 말할 것이다.

이것이 인터페이스입네다... 이것도 인터페이스가 맞다. 이런 인터페이스는 흔히 UI(User Interface)라고 불리며 이도 결국 사용자가 프로그램을 간편하게 이용하기 위해 존재하는 인터페이스이다.
사실 우리가 항상 보는 윈도우 OS의 파일 아이콘 역시 인터페이스에 속한다고 볼 수 있다. 리눅스 OS 같은 경우 터미널 명령어로 파일을 실행하고 조작해야 하지만, 윈도우에서는 GUI를 통해 컴퓨터 지식이 없는 사람도 쉽게 프로그램을 다룰 수 있다.
이처럼 상호작용을 통해 사용자가 소프트웨어를 편하게 이용할 수 있게 해 주듯, 개발자가 프레임워크를 이용하여 웹서비스를 개발하는 데 있어 프레임워크의 내부 구성에 대한 학습 없이, 그저 지원해 주는 메서드를 이용하여 간편하게 프로젝트를 개발할 수 있게 해주는 것이 인터페이스의 첫 번째 역할이라고 할 수 있다.

두 번째 인터페이스의 역할은 일종의 `스펙(Spec)`을 지정하게 하여 소프트웨어 확장에 유리하다는 것이다.
실생활로 예를 들면, 우리는 삼성 키보드든 애플 키보드든 로지텍 키보드든 제조사에 상관없이 컴퓨터에 연결만 하면 설치 없이 바로 이용할 수 있다. 이러한 간편함은 모두 OS에서 미리 I/O 처리에 대해 인터페이스로 추상화하였기 때문에, 각 키보드 제조 회사들이 인터페이스의 구현 스펙에 따라 동작 메서드를 구현했기 때문이다.

그래서 키보드 제조사 상관없이 우리는 간편하게 키보드를 연결만 하면 이용할 수 있다.
알게 모르게 우리는 일상 생활 속에서 수많은 인터페이스의 도움을 받고 있다.
자바의 인터페이스
보통 학부생들은 인터페이스를 처음 배울 때 추상 메서드 집합이며 다중 상속이 되는 기능 정도로만 배운다. 하지만 위에서 인터페이스에 대한 친숙한 예제를 들며 소개한 이유는, 자바의 인터페이스 자체가 지닌 고유한 기능과 시스템 상호작용적인 요소로 다양한 분야에 이용될 수 있기 때문이다.
상속 및 추상 메서드 강제 구현 외에도, 다양한 프레임워크에서 클래스끼리 통신하는데 자주 사용되며, 객체 지향(OOP) 프로그래밍의 전략에서 결합도(Coupling)을 낮춰 유지보수성을 늘리는 디자인 패턴으로서의 역할도 병행한다.
우리가 반복되는 코드들을 줄이기 위해 for문이나 while문을 사용하듯이, 인터페이스를 사용하는 목적은 설계상 이점을 위해 사용하는 클래스라고 말할 수 있다.
설계 관점에서 프로그래밍을 할 때는 전체를 볼 줄 알아야 한다. 무슨 소린지 모르겠다면 바로 인터페이스를 알아보러 가자.
인터페이스 기본 문법
인터페이스 정의
- 인터페이스를 작성하는 것은 추상 클래스를 작성하는 것과 같다고 봐도 된다. (인터페이스는 추상 메서드의 집합이니까)
- 인터페이스도 필드를 선언할 수 있지만 변수가 아닌 상수(fianl)로서만 정의할 수 있다.
- `public static final`과 `public abstract 제어자는 생략이 가능하다.
- 인터페이스에 정의된 모든 멤버에 적용되는 사항이기 때문에 편의상 생략이 가능하도록 지원한다. 생략된 제어자는 컴파일 시 컴파일러가 자동으로 추가해 준다.
interface 인터페이스이름 {
public static final 타입 상수이름 = 값;
public abstract 타입 메서드이름(매개변수목록);
}
interface TV {
int MAX_VOLUME = 10; // public static final 생략 가능
int MIN_VOLUME = 10;
void turnOn(); // public abstract 생략 가능
void turnOff();
void changeVolume(int volume);
void changeChannel(int channel);
}
인터페이스 구현

- 인터페이스도 추상 클래스처럼 그 자체로는 인스턴스를 생성할 수 없으며, 추상 클래스가 상속을 통해 완성되는 것처럼 인터페이스도 구현부를 만들어주는 클래스에서 구현(상속) 되어야 한다.
- 해당 클래스에 인터페이스를 구현하고 싶다면, `implements` 키워드를 쓴 후에 인터페이스를 나열하면 된다.
- 인터페이스를 상속받았다면, 자식 클래스에서 인터페이스가 포함하고 있는 추상 메서드를 구체적으로 구현한다.
- 인터페이스의 가장 큰 특징은 다중 구현(다중 상속)이 가능하다는 것이다.
- 자식 클래스에 클래스 상속(extends)과 인터페이스 구현(implements)은 동시에 가능하다.
interface Animal {
public abstract void cry();
}
interface Pet {
public abstract void play();
}
class Tail {
// ...
}
class Cat extends Tail implements Animal, Pet { // 클래스와 인터페이스를 동시에 상속
public void cry() {
System.out.println("나는 냐옹이다옹!");
}
public void play() {
System.out.println("할퀴기");
}
}
잘 생각해 보면 인터페이스도 상속의 개념인 것 같다. 하지만 `extends` 키워드 대신 `implements`라는 '구현한다'라는 키워드를 사용하는 이유는 상속은 클래스 간의 부모 - 자식 관계를 연관시키는데 중점을 둔다면, 구현은 클래스를 확장시켜 구조를 풍부하게 만드는데 중점을 두기 때문이다.
인터페이스를 구현받고 추상 메서드를 구체적으로 구현할 때 접근제어자 설정에 주의해야 한다. 기본적으로 메서드를 오버라이딩(overriding) 할 때는 부모의 메서드보다 넓은 범위의 접근 제어자를 지정해야 한다는 규칙이 존재한다. 따라서 인터페이스의 추상 메서드는 기본적으로 `public abstract`가 생략된 상태이기 때문에 반드시 자식클래스의 메서드 구현부에서는 제어자를 `public`으로 설정해주어야 한다.
인터페이스 일부 구현 (추상 클래스)
- 만일 클래스가 구현하는 인터페이스의 메서드 중 일부만 구현한다면 `abstract`를 붙여서 추상 클래스로 선언해야 한다.
- 어찌 보면 당연하게 인터페이스의 추상 메서드 멤버를 그대로 상속받기 때문에, 인터페이스를 상속한 클래스에서 메서드 구현을 하지 않으면 추상 메서드를 가진 추상 클래스가 되기 때문이다.
interface Animal {
void wald();
void run();
void breed();
}
// Animal 인터페이스를 일부만 구현하는 포유류 추상 클래스
abstract class Mammalia implements Animal {
public void wald() { ... }
public void run() { ... }
//public void breed() 는 자식 클래스에서 구체적으로 구현하도록 일부로 구현하지 않음
}
class Lion extends Mammalia {
@Override
public void breed() { ... }
}
인터페이스가 인터페이스를 상속

- 클래스끼리 상속을 통해 확장을 하듯이, 인터페이스 자체를 확장시키고 싶다면 `extends`를 통해 인터페이스를 상속하면 된다.
- 클래스와 달리 인터페이스끼리의 상속은 다중 상속이 가능하다. (메서드 구현부가 없으니 충돌 가능성이 없음)
- 클래스의 상속과 마찬가지로 자손 인터페이스는 조상 인터페이스에 정의된 멤버를 모두 상속받는다.
- 그러나 필드의 경우 기본적으로 `static`이기 때문에 구현체를 따라가지 않게 된다. (독립 상수)
- 참고로 인터페이스에 클래스를 상속하는 행위는 불가능한데, 왜냐하면 인터페이스는 클래스와 달리 Object 클래스가 최고 조상이 아니기 때문이다.
interface Changeable {
/* 채널을 바꾸는 기능의 메서드 */
void change();
}
interface Powerable {
/* 전원을 껐다 켰다 하는 기능의 메서드 */
void power(boolean b);
}
//패널 기능과 전원 기능을 가진 인터페이스들을 하나의 인터페이스로 통합 상속
interface Contrilable extends Chanegable, Powerable {
//인터페이스끼리 다중 상속하면 그대로 추상 멤버들을 물려 받음
}
// 클래스에 통합된 인터페이스를 그대로 상속
class MyObject implements Controlable {
public void change() {
System.out.println("채널을 바꾸는 기능의 메서드");
}
public void power(boolean b) {
System.out.println("전원을 껐다 켰다 하는 기능의 메서드");
}
}
public class Main {
public static void main(String[] args) {
//인터페이스 다형성 (인터페이스를 타입으로 취급해서 업캐스팅 가능)
Controlable[] o = { new MyObject(), new MyObject() };
o[0].change();
o[0].power(true);
// 각각 단일 인터페이스로도 타입으로 사용이 가능하다 (하지만 지니고 있는 추상 메서드만 사용 가능하다)
Changeable inter1 = new Changeable();
inter1.change();
Powerable inter2 = new Powerable();
inter2.power(true);
}
}
인터페이스의 extends는 상속이 아니다.
인터페이스는 하나의 타입이나 규격일 뿐이지 그 자체가 하나의 객체가 되는 것이 아니다. 따라서 엄밀히 말하자면, 인터페이스 상속은 클래스의 상속처럼 부모의 속성과 동작을 물려받는 것이 아니다. 인터페이스의 상속은 규격이나 스펙 자체 혹은 기능 자체의 선언을 물려받은 것이다.
규격이나 스펙을 물려받아서 새로운 스펙을 만든다면 기존 여러 개의 스펙을 조합해서 하나로 묶거나 기존의 스펙을 고스란히 물려 받은 후에 다시 추가적인 기능을 가지게 하는 것이다.
인터페이스 상수 필드 상속 관계
클래스의 상속일 경우 클래스 필드 멤버끼리 상속되어 덮어 써지지만, 인터페이스의 필드들은 모두 `public static final`이기 때문에 상속을 해도 각 필드의 멤버는 독립적으로 운용된다.
interface Iflower {
int ex = 10; // 각각 public static final
}
interface IPlant extends Iflower {
int ex = 20; // 각각 public static final
}
class Tulip implements IPlant {
int ex = 30; // 그냥 인스턴스 변수
}
public class Main {
public static void main(String[] args) {
//클래스 타입 객체로 ex 멤버에 접근하면, 클래스 인스턴스 변수로 접근
Tulip t = new Tulip();
System.out.println(t.ex); // 30
// 인터페이스 타입 객체로 멤버에 접근하면, 인터페이스 static 상수로 접근
Iflower a = new Tulip();
System.out.println(a.ex); // 10 - 좋지 않은 방법 (굳이 이렇게?)
System.out.println(Iflower.ex); // 10 - 클래스 static 처럼 '인터페이스.멤버' 로 접근
IPlant b = new Tulip();
System.out.println(b.ex); // 20 - 좋지 않은 방법 (굳이 이렇게?)
System.out.println(IPlant.ex); // 20 - 클래스 static 처럼 '인터페이스.멤버' 로
}
}
인터페이스 독립 파일
- 자바에서 .class 파일을 만들 수 있는 것이 꼭 클래스만 있는 것이 아니다.
- 인터페이스 자체만으로도 클래스명으로 소스 파일을 만들 수 있다.
자바8 인터페이스 구현 메서드
본래 인터페이스의 메서드는 몸통(구현체)을 가질 수 없지만, java8부터 디폴트 메서드와 스태틱 메서드를 통해 추상 클래스처럼 구현 메서드를 정의할 수 있게 되었다.
그래서 이러한 점 때문에 추상 클래스와의 차이점이 거의 사라졌다고 말하기도 한다.(설 자리를 잃어가는 추상 클래스...)
이 기능은 이전 인터페이스를 사용하여 java8의 람다 표현식 기능을 활용할 수 있도록 이전 버전과의 호환성을 위해 추가되었다.

java8 버전부터 새로이 추가된 스트림이나 람다와 같은 함수형 프로그래밍을 컬렉션(Collection) 클래스에서 사용하기 위해선 기존에 만들었던 인터페이스를 수정해야만 했다.
그런데 만일 기존 인터페이스에 추상 메서드를 추가해버리면 이 인터페이스를 구현하고 있는 모든 구현 클래스도 변경이 필요해지기 때문에 추상 메서드 대신 디폴트 메서드를 새로 추가하여 해결했다고 할 수 있다.

default 메서드
- 디폴트 메서드 앞에 키워드 `default`를 붙이며 일반 메서드처럼 구현부가 있어야 한다.
- 디폴트 메서드 역시 접근 제어자가 public이며 생략 가능하다.
- 자식 클래스 (구현체)에서 default 메서드를 오버라이딩하여 재정의 가능하다.
- 보통 인터페이스를 구현한 이후, 수정과정에서 인터페이스 모든 구현체에게 수정 없이 광역으로 함수를 만들어주고 싶을 때 사용된다. (대신 모든 구현체가 원하는 값을 return하게 보장하기 위해 @implSpec 자바 doc 태그를 사용해 문서화해주어야 한다.)
- 주의해야 할 점은 인터페이스는 Object 클래스를 상속받지 않기 때문에, Object 클래스가 제공하는 기능(equals, hasCode)은 기본 메서드로 제공받을 수 없다. 따라서 구현체가 직접 재정의를 해주어야 한다.
interface Calculator {
int plus(int i, int j);
int multiple(int i, int j);
//default로 선언함으로써 메서드를 구현할 수 있다.
default int sub(int i, int j) {
return i - j;
}
}
// Calculator 인터페이스를 구현한 MyCalculator 클래스
class MyCalculator implements Calculator {
// 추상 메서드만 구현해줌
@Override
public int plus(int i, int j) { return i + j; }
@Override
public int multiple(int i, int j) { return i * j; }
}
public class Main {
public static void main(String[] args) {
MyCalculator mycal = new MyCalculator();
// 인터페이스 타입으로 업캐스팅
Calculator cal = (Calculator) mycal; // 괄호 생략해도 됨
//인스턴스의 인터페이스 디폴트 메서드 호출
int value = cal.sub(5, 10);
System.out.println(value); // -5
}
}
인터페이스의 디폴트 메서드를 호출하기 위해선, 객체의 타입 반드시 인터페이스 타입으로 업캐스팅 해주어야 한다.
@implSpec 주석 문서
디폴트 메서드는 보통 인터페이스를 구현한 이후, 수정과정에서 인터페이스를 implements한 모든 구현체에게 수정 없이 광역으로 함수를 만들어주고 싶을 때 사용된다.
예를 들어 A와 B 업체가 협업을 해서 어떤 인터페이스를 구현하고 있는데, 개발자가 해당 인터페이스에 기능을 추가하기 위해 추상 메서드를 추가하면, 이 인터페이스를 구현하고 있는 모든 클래스에도 수정을 가해야 한다.
그럴 때 인터페이스에 디폴트 메서드를 추가하면, 추가된 메서드를 구현체 클래스에서 굳이 구현하지 않아도 에러 없이 사용할 수 있고, 나중에 필요하면 재정의를 통해 업그레이드된 메서드 구현도 가능해진다.
대신 모든 구현체가 원하는 값의 return을 보장해 주기 위해 @implSpec 자바 doc 태그를 사용해 문서화해주어야 한다.
interface IJson {
String printJson(); // 추상 메서드
/**
*
* @impspec
* printJson()의 결과를 대문자 변환한다.
*/
default void upperString() { //default 메서드
// 구현 로직상, 추상 메서드인 printJson()의 반환 값이 정상적인 값이 될수도 있고
// null이 되서 예외 오류가 발생할 수 있으니 @impspec 문서화를 한다.
String text = printJson().toUpperCase();
}
}
default 메서드 다중 상속 문제
클래스의 다중 상속 문제점에 대해서, 동일한 메서드명이 겹치는 경우 죽음의 다이아몬드 현상이 발생한다.
그래서 다중 상속을 금지시켰더니, 이번엔 인터페이스에 디폴트 메서드라는 것이 추가되면서 인터페이스를 다중 구현할 때 클래스 다중 상속 문제와 똑같은 문제가 발생하게 되었다. 따라서 인터페이스 다중 구현에 한해저 자바는 다음과 같은 규칙을 정했다.
1. 다중 인터페이스들 간의 디폴트 메서드 충돌
- 똑같은 디폴트 메서드를 가진 두 인터페이스를 하나의 클래스에 구현하고 아무런 조치를 취하지 않으면 컴파일 자체가 되지 않는다.
- 인터페이스를 구현한 클래스에서 디폴트 메서드를 오버라이딩하여 하나로 통합하여 해결한다.
interface A1 {
public void styleA();
//메서드 시그니처가 같은 디폴트 메서드
default public void styleSame() {
System.out.println("A1 인터페이스의 디폴트 메서드입니다. ");
}
}
interface B1 {
public void styleB();
// 메서드 시그니처가 같은 디폴트 메서드
default public void styleSame() {
System.out.println("B1 인터페이스의 디폴트 메서드입니다.");
}
}
class MultiInterface implements A1, B1 {
@Override
public void styleA() {}
@Override
public void styleB() {}
// 두 인터페이스 디폴트 메서드 중 A1 인터페이스의 디폴트 메서드를 오버라이딩
default public void styleSame() {
System.out.println("A1 인터페이스의 디폴트 메서드입니다.");
}
}
public class Main {
public static void main(String[] args) {
MultiInterface m1 = new MultiInterface();
m1.styleSame(); // "A1 인터페이스의 디폴트 메서드입니다."
}
}
2. 인터페이스의 디폴트 메서드와 부모 클래스 메서드 간의 충돌
- 아래의 상황은 자식 클래스에서 인터페이스와 부모 클래스를 동시에 extends / implement 하였을 때 디폴트 메서드와 인스턴스 메서드 간의 충돌을 가정한 상황이다.
- 이때는 부모 클래스의 메서드가 상속되고 디폴트 메서드는 무시된다.
- 만일 인터페이스 쪽의 디폴트 메서드를 사용할 필요가 있다면, 필요한 쪽의 메서드와 같은 내용으로 그냥 오버라이딩하면 된다.
클래스를 상속받는 경우
interface A1 {
public void styleA();
// C1 클래스와 메서드 시그니처가 같은 디폴트 메서드
default public void styleSame() {
System.out.println("A1 인터페이스의 디폴트 메서드입니다.");
}
}
abstract class C1 {
// A1 인터페이스와 메서드 시그니처가 같은 인스턴스 메서드
public void styltSame() {
System.out.println("C1 클래스의 인스턴스 메서드입니다.");
}
}
// 메서드 시그니처가 같은 두 추상화를 동시에 상속
class MultiClassInterface extends C1 implements A1 {
@Override
public void styleA() {}
}
public class Main {
public static void main(String[] args) {
MultiClassInterface m1 = new MultiClassInterface();
// 클래스의 메서드 시그니처가 우선 적용
m1.styleSame(); // " C1 클래스의 인스턴스 메서드 입니다."
//마찬가지로 인터페이스 타입으로 다운캐스팅해도 클래스 인스턴스 메서드로 호출됨.
((A1) m1).styleSame(); // "C1 클래스의 인스턴스 메서드입니다."
}
}
인터페이스를 상속받는 경우
// 메서드 시그니처가 같은 두 추상화를 동시에 상속
class MultiClassInterface extends C1 implements A1 {
@Override
public void styleA() {}
// 클래스의 인스턴스 메서드를 무시하고 인터페이스의 디폴트 메서드를 사용하기 위해 A1을 오버라이딩
public void styleSame() {
System.out.println(" A1 인터페이스의 디폴트 메서드입니다.");
}
}
public class Main {
public static void main(String[] args) {
MultiClassInterface m1 = new MultiClassInterface();
m1.styleSame(); // " A1 인터페이스의 디폴트 메서드입니다."
}
}
3. default 메서드의 super
상위 클래스를 상속하고 상위의 메서드를 오버라이딩하여 재정의하였을 때, 만일 부모 메서드를 호출할 일이 생긴다면 `super` 예약어를 통해 부모 메서드를 호출할 수 있었다.
이와 같이 인터페이스도 디폴트 메서드를 구현한 클래스에서 오버라이딩 하였을 때, `super` 키워드를 통해 인터페이스의 원래의 디폴트 메서드를 호출 가능하다.
다만 문법이 클래스 방식과 약간 차이가 있다.
인터페이스의 super는 다음과 같은 구성으로 호출된다. `인터페이스명.super.디폴트메서드`
interface IPrint {
default void print() {
System.out.println("인터페이스의 디폴트 메서드입니다.");
}
}
class MyClass implements IPrint {
@Override
public void print() {
IPrint.super.print(); // 인터페이스의 super 메서드를 호출
System.out.println("인터페이스의 디폴트 메서드를 오버라이딩한 메서드입니다.");
}
}
public class Main {
public static void main(String[] args) {
MyClass cls = new MyClass();
cls.print();
}
}
//출력 결과
//인터페이스의 디폴트 메서드입니다.
//인터페이스의 디폴트 메서드를 오버라이딩한 메서드입니다.
static 메서드
- 인스턴스 생성과 상관없이 인터페이스 타입으로 접근해 사용할 수 있는 메서드
- 인터페이스 전용 static 메서드라 해서 일반 클래스의 static 메서드와 다를 바가 없다.
- 해당 타입 관련 헬퍼 또는 유틸리티 메서드를 제공할 때, 인터페이스에 스태틱 메서드로 제공하기도 한다.
interface Calculator {
public int plus(int i, int j);
public int multiple(int i, int j);
// 디폴트 메서드
default int sub(int i, int j) {
return i - j;
}
// 스태틱 메서드
public static void explain() {
System.out.println("interface static 메서드입니다.");
}
}
class MyCalculator implements Calculator {
@Override
public int plus(int i, int j) { return i + j; }
@Override
public int multiple(int i, int j) { return i * j; }
}
public class Main {
public static void main(String[] args) {
// 클래스처럼 static 메서드를 호출하면 된다.
Calculator.explain(); // "interface static 메서드입니다."
private 메서드
- 자바9 버전에 추가된 메서드
- 인터페이스에 default, static 메서드가 생긴 이후, 이러한 메서드들의 로직을 공통화하고 재사용하기 위해 생긴 메서드
- private 메서드도 구현부를 가져야 한다.
- 단, private 메서드는 인터페이스 내부에서만 돌아가는 코드이다. (인터페이스를 구현한 클래스에서 사용하거나 재정의 할 수 없음)
- 따라서 인터페이스 내부에서 private 메서드를 호출할 때, default 메서드 내부에서 호출해야 하며, 만일 `private static` 키워드를 붙인 메서드는 static 메서드는 static 메서드에서만 호출이 가능하다.
클래스에서도 private 접근제어자를 가진 메서드를 정의하였을 때, 호출 메서드에서 private 내부 메서드를 호출하여 사용하는 것처럼 인터페이스의 private 메서드도 똑같다고 생각하면 된다.
단, 인터페이스는 클래스가 아니기 때문에 this 예약어를 사용할 수 없다.
interface Calculator {
public int plus(int i, int j);
public int multiple(int i, int j);
//private 메서드
private void printf() {
System.out.println("private 메서드는 default 내부에서만 호출이 가능합니다.");
}
// private 스태틱 메서드
private static void printfStatic() {
System.put.println("private static 메서드는 static 메서드 내부에서만 호출이 가능합니다.");
}
// 디폴트 메서드
default void callPrivate() {
printf(); // private 메서드 호출
}
// 스태틱 메서드
static void callPrivateStatic() {
printfStatic(); // private 스태틱 메서드 호출
}
}
class MyCalculator implements Calculator {
@Override
public int plus(int i, int j) { return i + j; }
@Override
public int plus(int i, int j) { return i * j; }
}
public class Main {
public static void main(String[] args) {
// 인터페이스 디폴트 메서드를 통한 private 메서드 호출
Calculator c = new MyCalculator(); // 인터페이스 타입으로 업캐스팅
c.callPrivate(); // "private 메서드는 default 내부에서만 호출이 가능하다.
// 인터페이스 스태틱 메서드를 통한 private static 메서드 호출
Calculator.callPrivateStatic(); // "private static 메서드는 static 메서드 내부에서만 호출이 가능하다."
}
}
인터페이스의 상수는 private으로 만들 수 없다.
인터페이스는 실제 객체는 아니지만 서로 간의 약속으로 사용된다. 정해진 약속을 한쪽에서 일방적으로 수정하게 되면 문제가 발생할 수 있다. 따라서 인터페이스에 선언하는 필드들은 자동으로 `public static final` 로 완벽한 상수가 된다.
인터페이스의 활용
지금까지는 인터페이스의 다양한 문법에 대해 알아보았다.
하지만 이건 그냥 인터페이스의 사용법만 알았을 뿐 인터페이스를 어떻게 쓰는지는 아직 모른다. 인터페이스는 활용할 때 비로소 빛을 바라는데, 아마 대부분의 자바 개발자는 스프링 프레임워크를 쓰려고 할 것이다. 근데 이 스프링은 인터페이스의 집합체이다. 그러니 인터페이스의 사용법을 익히고 쓰임새를 알아두는 것은 스프링을 공부하기 위한 첫걸음이 될 것이다.
지금부터 자바에서 인터페이스를 어떻게 사용하고 그로 인해 어떤 장점을 얻을 수 있는지 알아보는 시간을 가지자.
인터페이스 다형성
부모클래스 타입으로 자식 클래스 타입을 포함할 수 있다는 리스코프 치환의 법칙(LSP)도 인터페이스에 그대로 적용이 가능하다.
클래스가 여러 개의 인터페이스를 구현하게 되면, 결과적으로 변수의 타입으로도 다양하게 쓰일 수 있다는 것을 의미하게 된다. 인터페이스 타입으로 변수를 선언하게 되면 사용하는 입장에서는 뒤에 오는 모든 객체는 간단히 인터페이스만 구현한 객체이면 되기 때문에 시스템이 더욱 유연해지는 계기를 마련한다.
interface Keyboard { }
class Logitec_Keyboard implements Keyboard { }
class Samsung_Keyboard implements Keyboard { }
class Apple_Keyboard implements Keyboard { }
public class Main {
public static void main(String[] args) {
// 인터페이스 타입 배열로 여러가지 클래스들을 한번에 타입 묶음을 할 수 있다.
Keyboard[] k = {
new Logitec_Keyboard(),
new Samsung_Keyboard(),
new Apple_Keyboard()
};
}
}
또한 세간에는(?) 인터페이스를 자료형으로 쓰는 습관을 들이면 프로그램은 훨씬 유용해진다고 전해 내려온다.
이 말의 의미는 "객체는 클래스가 아닌 인터페이스로 참조하라"라는 의미로 볼 수 있다. 적합한 인터페이스만 있다면 매개변수뿐 아니라 반환값, 변수, 필드를 전부 인터페이스 타입으로 선언하면 좋다.
- 객체는 인터페이스를 사용해 참조하라.
- 적당한 인터페이스가 있다면 매개변수뿐만 아니라 반환 값, 변수, 필드를 전부 인터페이스 타입으로 선언하라.
- 객체의 실제 클래스를 사용할 상황은 오직 생성자로 생성할 때뿐이다.
- 매개변수 타입으로 클래스보다는 인터페이스를 활용하라.
예를 들어 중복이 없는 집합 자료형을 사용하기 위해 LinkedHashSet 클래스를 초기화하려 할 때, 객체의 타입을 똑같이 LinkedHashSet으로 하지 말고 인터페이스인 Set 타입으로 설정하여 선언하는 것이다.
// 나쁜 예) 클래스를 바로 타입으로 사용함
LinkedHashSet<Object> s = new LinkedHashSet<>();
//좋은 예) 인터페이스를 타입으로 사용함
Set<Object> s = new LinkedHashSet<>();
이런 식으로 코드 구현을 해놓는다면 나중에 변수가 담긴 구현 클래스를 다른 Set 자료형 클래스로 교체하고자 할 때 그저 새 클래스의 생성자를 다시 호출해주기만 하면 되어 변경이 쉬워진다.
// 본래 LinkedHashSet으로 구현했다가 사정이 생겨 TreeSet 클래스로 변경해야한다고 하면,
// 그냥 인터페이스 타입의 변수에 재할당만 하면 된다.
Set<Object> s = new LinkedHashSet<>();
s = new TreeSet<>();
하지만 이런식으로 인터페이스 타입으로 선언하는 습관이 꼭 좋은 점만 있는 것은 아닌데, 나쁜 예의 경우 LinkedHashSet을 HashSet으로 변환하면, LinkedHashSet과 달리 HashSet은 반복자의 순회 순서를 보장하지 않기 때문에 나중에 로직상 문제가 될 수도 있다.
인터페이스는 변수 선언 타입 외에도 메서드의 파라미터나 메서드의 리턴 타입으로도 많이 사용된다.
이 역시 추상 클래스나 상속을 이용하는 것과 차이가 없지만 인터페이스는 객체의 기능을 선언하기만 하면 되기 때문에 더 확장성 있는 구조가 되고, 상속의 단점을 보완하는 방식의 설계가 가능해진다.
무엇보다 인터페이스의 다형성의 가장 큰 특징은 바로 다중 구현을 통한 자유로운 상속 관계를 만들어 클래스의 다형성보다 더욱 다채롭고 유연하게 사용이 가능하다는 것이다.
클래스의 다형성은 기본적으로 클래스의 상속에 기반을 두기 때문에, 클래스 상속은 단일 상속밖에 안 되며, 클래스끼리 의미 있는 연관 관계를 가지고 있어야 해 여러모로 제약이 많다.
하지만 인터페이스는 원할 때 `implements`를 여러 개 추가하거나 빼거나 할 수 있으니 제약이 많이 사라진다.
이를 이용한 예시가 바로 다음에 나올 클래스끼리 형제 관계를 맺게 해 주거나 상속을 넘어선 타입 제한도 가능하게 된다.
형제 관계를 맺어주는 인터페이스
기본적으로 자바의 클래스 상속 구조는 부모 - 자식 관계로만 가능하게 되어 있다.
만일 여러 자식들끼리 의미 있는 관계를 맺고 싶다면 또 다른 부모를 두어 다시 부모 - 자식 관계를 계층형으로 만들어 주어야 한다. 그러나 자바의 클래스 상속 구조는 단일 상속만 가능하기 때문에 만일 사용하는 클래스들이 각기 다른 부모 클래스를 상속하고 있는 상황이면 위의 자료형 타입 통합을 할 수 없게 된다.
즉, 아무 관계도 없는 클래스들에게 하나의 인터페이스를 공통적으로 구현하도록 함으로써 관계를 맺어줄 수 있다.
예를 들어 각기 다른 부모 클래스를 상속하고 있는 Soccer 클래스와 BassGuitar 클래스를 하나의 타입으로 묶어서 사용할 필요가 있을 때, 인터페이스를 implements 함으로써 마치 Soccer와 BassGuitar 클래스를 묶은 형제 클래스 타입 IBehavior 를 만든 것과 같다.

이렇게 형제 타입을 구성해 놓고 아래의 코드와 같이, 인터페이스를 메서드의 매개변수 타입으로 받음으로써 인터페이스에 등록된 추상 메서드를 실행하면 실제 구현 클래스의 오버라이딩한 구현 메서드를 실행하게 된다.
즉, 자유로운 인터페이스 다형성을 통해 코드 중복을 없애고 간편화를 시켜 설계상 이점을 얻을 수 있게 되는 것이다.

타입 접근 제한
이외에도 인터페이스는 일종의 접근 제한 역할도 할 수 있다.
이는 위의 형제 관계 역할과 매우 비슷한 개념인데, 만일 똑같은 부모를 상속하고 있는 3개의 자식들 중, 2개의 자식 클래스 타입만 받을 수 있는 메서드를 구현한다고 했을 때 이용된다.
다형성이랍시고 부모 클래스 타입으로 파라미터를 받아버리면, 모든 자식 클래스에게 접근을 허용하기 때문에 접근에 대한 제한이 전혀 되지 않는다.
아래의 코드를 보자.
class GroundUnit { }
class Marine extends GroundUnit { }
class SCV extends GroundUnit { }
class Tank extends GroundUnit { }
public class Main {
public static void main(String[] args) {
repait(new Marine());
}
static void repair(GroundUnit gu) {
// 마린은 기계가 아니기 때문에 수리는 불가능하다. 하지만 상속 관계상
// 마린 클래스 타입이 들어와 실행될 수 있는 위험성이 존재한다.
}
}
위처럼 테란의 지상 유닛을 만들기 위해 Marine, SCV, Tank 클래스를 만들고 이들을 공통으로 묶을 부모 클래스 GroundUnit 클래스로 상속 관계를 맺어 주었다.
그리고 `repair()` 메서드에서 중복을 줄이기 위한 다형성 기법으로 매개변수 타입을 Ground Unit 부모 클래스 타입으로 받도록 설정하였다.
하지만 잘 생각해 보면 repair 기능은 기계 유닛만 가능하여 SCV와 Tank 클래스 타입만 들어와야 하는데 생물 유닛인 Marine 클래스 타입도 상속 관계에 의해 들어올 수 있게 된다.
개발자도 사람이고, 사람은 늘 실수를 하기 때문에 시스템적으로 접근을 하지 못하도록 처음부터 막아야 한다.
따라서 별도의 Machine이라는 인터페이스를 선언하고 SCV, Tank 클래스에 implements 시킨다.
이렇게 3개의 자식 중 2개의 자식만 머신이라는 타입으로 형제 타입 관계를 맺어주면서 동시에 다른 타입의 접근 제한 역할도 해낼 수 있다.
interface Machine { } // SCV, Tank 클래스를 통합한 타입으로 이용하는 인터페이스
class GroundUnit { }
class Marine extends GroundUnit { }
class SCV extends GroundUnit implements Machine { }
class Tank extends GroundUnit implements Machine { }
public class Main {
public static void main(String[] args) {
repair(new Marine()); // ! Error !
}
static void repair(Machine gu) {
// SCV와 탱크 타입만 받을 수 있도록 Machine 인터페이스로 Marine에 접근을 제한
}
}
메서드 접근 제한
이밖에도 객체에서 사용할 수 있는 메서드를 제한하는 효과도 있는데, 예를 들어 A, B, C라는 인터페이스를 구현한 클래스를 반환할 때 A타입으로 변환하게 되면 외부에서는 A 인터페이스의 메서드만 보이게 된다. 따라서 별도의 접근 제한을 이용하지 않고도 사용할 수 있는 메서드 접근 제한과 마찬가지로 효과를 보게 하는 방법이다.
이런 이유로 오히려 거꾸로 클래스에 여러 가지 메서드를 만들어 둔 다음 인터페이스로 분리하는 작업을 진행하는 경우도 있다.
interface PlayMovie {
void play();
}
interface ViewImage {
void view();
}
interface VolumeUpDown {
void volume();
}
class MP3 implements PlayMovie, ViewImage, VolumeUpDown {
public void play() {}
public void view() {}
public void volume() {}
}
public class Main {
public static void main(String[] args) {
PlayMovie mp3 = new MP3(); // 3개의 구현한 인터페이스 중 하나로 객체 선언
mp3.play(); //play() 이외의 메서드는 제한된다.
}
}
의존성을 제거(decoupling)
의존성이란 객체 지향 설계에 아주 중요한 개념으로 객체가 어떻게 소통하는지 나타내는 것이다.
만일 코드 설계를 구체 클래스로 하게 되면 객체 간의 강한 의존성이 생기게 되어 만약 코드 변경이 필요하게 되면 개발자가 아주 성가시게 될 수 있다. 따라서 클래스 간의 관계를 구성할 때 그 관계를 느슨하게 하는 것이 중요하다.
그래서 클래스의 관계를 상속(extends)이 아닌 구현(implements)으로 인터페이스로 확장시킨다면, 반환 타입이나 매개변수 타입으로 다른 객체와 소통하는 구간에 인터페이스 타입으로 사용함으로써, 객체 간 의존성이 줄어들어 자신과 소통하는 객체의 변화에 강한 클래스를 만들 수 있게 된다.

이런 의존성은 과거부터 있었던 많은 Framework에서 발전해 MVC, MVVM 패턴등으로도 승화되었고 dependency injection 등의 기술로도 사용된다.
인터페이스 타입으로 통신
예를 들어 다음과 같은 코드가 있다고 하자.
class ServiceLogic {
//ServiceLogic 클래스의 메서드는 MapStore 클래스를 사용하기 때문에, ServiceLogic은 MapStore에 의존적이다.
public void printInt(MapStore cls) { // 지정한 클래스 타입만 받음
int num = cls.getNum() * 2;
System.out.println(num);
}
}
class MapStore {
private int num = 10;
public int getNum() {
return this.num;
}
}
ServiceLogic 클래스의 메서드 `printInt()`를 보면 파라미터로 MapStore 클래스 타입을 받아 MapStore 클래스의 메서드를 실행해 값을 얻고 출력하는 로직으로 구성되어 있다.
이러한 형태를 ServiceLogic 클래스는 MapStore 클래스에 의존적이다라고 말한다. 왜냐하면 MapStore 클래스가 잘못되면 ServiceLogic 클래스의 메서드는 동작하지 않을 것이기 때문이다.
따라서 이러한 의존성 관계를 없애기 위해 ClubStore 인터페이스를 만들고 MapStore 클래스에 implements 하여 구현한다. 그러면 ServiceLogic 클래스에서 만일 MapStore 객체를 사용하는 게 아닌 오직 ClubStore 인터페이스를 이용해 통신함으로써 클래스 간의 의존성을 없앨 수 있는 것이다.
이것을 변경에 유리한 유연한 설계라고도 한다.

interface ClubStore {
// 인터페이스 타입 상태의 객체에서 메서드를 사용하기 위해선
// 인터페이스에도 추상 메서드 형태로 넣어져 있어야 한다.
int getNum();
}
class ServiceLogic {
//ServiceLogic 클래스의 메서드는 ClubStore 인터페이스를 사용함으로써 클래스 간의 의존성을 없앴다.
public void println(ClubStore cls) {
int num = cls.getNum() * 2;
System.out.println(num);
}
}
class MapStore implements ClubStore{
private int num = 10;
public int getNum() {
return this.num;
}
}
표준화
개발 시간을 단축
인터페이스를 사용하면 클래스의 선언과 구현을 분리시킬 수 있기 때문에 실제 구현에 독립적인 프로그램을 작성하는 것이 가능하다.
예를 들어 기존의 클래스와 클래스 간의 직접적인 관계를, 인터페이스를 이용해서 간접적인 관계로 변경하면, 한 클래스의 변경이 관련된 다른 클래스에 영향을 미치지 않는 독립적인 프로그래밍이 가능하다.
따라서 메서드를 호출하는 쪽에서는 선언부만 알면 되기 때문에 인터페이스만 가지고도 프로그램을 작성할 수 있으며, 동시에 다른 한쪽에서는 인터페이스를 구현하는 클래스를 작성하면 인터페이스의 구현을 기다리지 않고 작업이 가능하다.
즉, A가 B클래스의 구성 완성을 기다리지 않고, 설계도인 인터페이스를 보고 동시에 개발을 함으로써 결과적으로 개발 시간을 단축시킬 수 있는 것이다.

사실 이는 추상 클래스 특징과 겹치는 부분이기도 한데, 인터페이스도 결국 추상화 클래스의 일종이기 때문에 가능한 것이다.
나중에 변경이 용이함
또한 프로젝트에 사용되는 기본 틀을 인터페이스로 작성한 다음 개발자들에게 인터페이스를 구현하여 프로그램을 짜도록 하면 일관되고 정형화된 프로그램의 개발이 가능하다.
다바의 데이터베이스 인터페이스인 JDBC가 대표적인데, 이 추상화된 인터페이스 규칙에 따라 다양한 종류의 데이터베이스를 사용하더라도 인터페이스 구현만 잘하면 동일하게 접속할 수 있는 이유이기도 하다.

예를 들어 어느 자바 어플리케이션이 있고 데이터베이스를 MySQL을 사용하고 있었는데, 만약 데이터베이스를 MySQL에서 Oravle로 변경한다고 가정하자.
본래라면 자바 어플리케이션의 DB 커넥션 코드를 변경해야 하는데, 이를 자바에서는 JDBC라는 인터페이스 그룹을 통해 데이터베이스와 관계 중개를 통해 통신한다. 그래서 자바 어플리케이션 입장에선 데이터베이스 종류는 모르고 오로지 인터페이스만을 기준으로 데이터를 읽고 쓰고 작업하게 된다. 그리고 각각의 데이터베이스 제조 밴더사들은 위의 인터페이스를 구현한 구현 클래스들을 따로 만들어 자바 언어와 연결되게 지원한다.
따라서 MySQL을 쓰다가 Oravle을 쓴다고 한다면, 기존에 쓰고 있는 오라클에서 제공하는 클래스 라이브러리들을 MySQL 클래스 라이브러리 파일로 바꾸고 약간 설정만 해주면 복잡한 수정 없이 변경이 완료되는 것이다.
만일 인터페이스라는 관계 중개가 없다면 하드 코딩한 것을 통째로 바꿔야 할지도 모른다.
이렇게 간단하게 변경이 가능한 이유는 자바 데이터베이스 인터페이스라는 '표준화'가 있기 때문이다.
마커 인터페이스
자바의 마커 인터페이스는 일반적인 인터페이스와 동일하지만 사실상 아무 메서드도 선언하지 않은 빈 껍데기 인터페이스를 말한다.
예를 들면 아래와 같다.
interface Untouchable {} // 아무런 내부 내용이 없는 빈 껍데기 인터페이스
이는 마치 구현체에 대한 역할을 정의하는 인터페이스의 역할을 다하지 못한 것처럼 보인다.
마커 인터페이스는 인터페이스가 다중 상속이 가능하다는 점을 이용해 그저 표시하는 용도로 쓰는 것이다.
아무 내용도 없어서 쓸모가 없어 보이지만, 마커 인터페이스의 역할은 객체의 타입과 관련된 정보만을 제공해 주는 것이다.
따라서 컴파일러와 JVM은 이 마커 인터페이스를 통해 객체에 대한 추가적인 정보를 얻을 수 있다.
다음 예시 코드를 보자.
class Animal {
public static void born(Animal a) {
if(a instanceof Lion) {
System.out.println("새끼를 낳았습니다.");
} else if(a instanceof Chicken) {
System.out.println("알을 낳았습니다.");
} else if (a instanceof Snake) {
System.out.println("알을 낳았습니다.");
}
// ...
}
}
class Lion extends Animal { }
class Chicken extends Animal { }
class Snake extends Animal { }
하지만 이러한 방식은 자식 클래스의 개수가 많을수록 코드가 난잡하고 길어진다는 단점이 있다.
따라서 아무런 내용이 없는 빈 껍데기 인터페이스를 선언하고 적절한 클래스에 implements 시킴으로써, 단순히 타입 체크용으로만 사용하는 것이다.
마커 인터페이스를 사용한 아래의 코드를 보자.
//새끼를 낳을 수 있다는 표식 역할을 해주는 마커 인터페이스
interface Breedable {}
class Animal {
public static void born(Animal a) {
if(a instanceof Breedable) {
System.out.println("새끼를 낳았습니다.");
} else {
System.out.println("알을 낳았습니다.");
}
}
}
class Lion extends Animal implements Breedable { }
class Chiken extends Animal { }
class Snake extends Animal { }
이러한 마커 인터페이스의 대표적인 자바 인터페이스로는 Serializabe, Cloneable 정도가 있다.
지금까지 interface의 사용법과 활용법을 알아보았다. 그런데 interface를 공부했다면 반드시 드는 생각이 있다. interface를 바로 배우기 전에 추상 클래스에 대해 공부했다. 추상 클래스는 자식 클래스가 부모 클래스의 메서드를 구현하는 것이고 interface도 implements 한 인터페이스를 하위 클래스에서 구현해주어야 한다.
얼핏 보기에 용도가 그리 다르지 않은 것 같은데 과연 추상 클래스와 interface는 무슨 차이가 있을까?
원본 게시글
☕ 인터페이스(Interface) 문법 & 활용 - 완벽 가이드
인터페이스 정의 (친숙하게 설명) 객체 지향 프로그래밍의 추상화를 배우고 추상 클래스를 접해봤다면 그 다음 접해보는 것이 인터페이스(Interface) 일 것이다. 인터페이스는 간단히 말하자면 프
inpa.tistory.com
'백엔드 > 자바' 카테고리의 다른 글
| 자바 - 내부 클래스(Inner Class) (0) | 2024.04.05 |
|---|---|
| 자바 - 추상 클래스 vs 인터페이스 (0) | 2024.03.28 |
| 자바 - 추상 클래스(abstract class) (0) | 2024.03.26 |
| 자바 - 상속(Inheritance) (0) | 2024.03.20 |
| 자바 - 클래스(인스턴스, 필드, 메서드, 생성자) (0) | 2024.03.19 |