추상화를 달성하기 위한 또다른 방식 (Another way to achieve abstraction)
속성 없이 관련된 메서드를 묶는데 쓰이는 완전한 추상 클래스 (Completely “abstract class”that is used to group related methods with empty bodies)
출처 : https://www.w3schools.com/java/java_interface.asp
즉, 인터페이스는 추상화를 목적으로 하며, 클래스의 메서드만을 명세한다.
의존성 분리 - 인터페이스를 선언해 사용함으로써 인터페이스를 확장한 다른 클래스에 대한 의존성을 분리할 수 있다.
ex. 참가 자동차 이름과 라운드 수를 터미널을 통해서 입력받는 상황을 가정해볼 수 있다. 만약 입력을 터미널이 아닌 다른 방법으로 바꾼다면 (파일 읽기, 웹 요청 …) 이미 코드베이스는 터미널과의 의존관계를 갖기 때문에 코드베이스를 보며 다시 짜야하지만 인터페이스를 도입하면 다음과 같이 분리할 수 있다.
Interface
public interface GetGamePropertyUseCase {
String getParticipantName();
int getTurnCount();
}
구현체
public class InputTerminal implements GetGamePropertyUseCase {
public String getParticipantName() {
return Console.readLine();
}
public int getTurnCount() {
return Integer.parseInt(Console.readLine());
}
}
이렇게 구현되어있는 상황에서 만약 입력을 다른 방법으로 받고 싶다면 구현체만 갈아끼우면 된다.
인터페이스를 추상화의 방법론으로 본다면 추상클래스 역시 그 대안이 될 수 있다. 추상클래스를 간단히 표현하자면 “미완성된 클래스” 정도로 요약할 수 있다. 클래스와 유사하게 필드와 메서드를 둘 수 있을 뿐더러 접근지정자까지 설정할 수 있다. 다른 점은 선언부만을 정의하는 추상 메서드를 둘 수 있기에 구현체와는 구분되며 인터페이스와 유사하게 구현된 코드가 없으면 무용지물이 된다.
이에 연역해서 둘의 구분적인 특징을 생각해볼 수 있다. 먼저 인터페이스는 일반적으로 메서드의 선언만 존재한다. 메서드는 클래스의 행동을 정의하는 것이기에 이는 순수하게 행동을 정의하는 역할을 갖게 된다. 반대로 추상클래스의 경우 추상 메서드를 갖기도 하지만 구현부가 포함되어 있다는 점을 고려했을 때 클래스의 정체성을 갖지만 일부 추상성을 허용한 꼴이며 순수하게 행동을 정의하는 인터페이스와는 구분된다.
인터페이스는 행동을 정의한다는 점에서 클래스의 미구현체 보다는 클래스가 가져야할 행동을 선언한 것에 가깝다고 볼 수 있다. 비슷한 이유로 자바에서 클래스는 다중 상속이 불가능하지만 인터페이스는 가능하다. 이에 대한 일례로 ArrayList 컬렉션을 들 수 있다. ArrayList의 선언부는 다음과 같다.
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
ArrayList는 AbstractList라는 추상 클래스를 상속받아 구현된 구현체이며, 그 과정에서 List, RandomAccess, Cloneable, Serializable 등의 인터페이스를 상속받는다. 이를 통해 ArrayList는 리스트처럼 행동할 수 있으며, 랜덤액세스, 복제, 직렬화 등의 행동을 수행할 수 있음을 예상할 수 있다. 하지만 구현된 것이 아니기에 해당 행동들을 ArrayList에 맞게 구현할 수 있다. 이는 Interface를 통해 유연한 확장이 가능해진 사례로 볼 수 있을 것이다.
클래스 사이에 의존성을 갖는다는 것은 두 클래스가 서로 영향을 미칠 수 있다는 의미이며 단위 테스트는 프로그램을 작은 단위로 떼어내 테스트하는 것을 말한다. 의존성은 클래스가 작아질 수 없게 만든다. A와 B가 의존성을 가진다면 A와 B는 더이상 떼어낼 수 없는 관계가 되기 때문이다. 반대로 단위테스트는 작아야만 의미를 가진다. 작은 단위에서 예상대로 실행되는지를 확인하는 것이 단위테스트이기 때문이다. 그렇기에 클래스의 관계가 유연해야 단위테스트가 용이해진다.
가령 위에서는 인터페이스를 통해 터미널과 입력 로직의 의존성을 분리했다. 만약 의존성이 분리되지 않은 상황에서 단위테스트의 상황에서 입력을 한 상황은 가정할 수 없다. 매 테스트마다 직접 입력할 수 없기 때문이다. 하지만 다음과 같이 입력 로직을 구현한다면 테스트 상황에서도 마치 입력이 발생했다고 가정한 채로 테스트를 진행할 수 있다.
public class InputMock implements GetGamePropertyUseCase {
private final String name;
private final int round;
public InputMock(String name, int round) {
this.name = name;
this.round = round;
}
public String getParticipantName() {
return name;
}
public int getTurnCount() {
return round;
}
}
//이름이 Moongua이고 3라운드를 진행한다고 가정하고 테스트를 진행할 때
GetGamePropertyUseCase input = new InputMock("Moongua", 3);