![[Java 07] 인터페이스와 추상 클래스](https://img.thenullpage.com/posts/6451/6451_1_dcc1d5.webp)
인터페이스는 무엇을 할 수 있는지에 대한 약속이다. 상속으로도 다형성을 얻지만, 자바에서 더 중요한 축은 인터페이스다. 인터페이스는 "이런 일들을 할 수 있어야 한다"는 계약만 정하고 방법은 정하지 않는다. 결제 인터페이스는 "금액을 받아 결제한다"는 메서드가 있어야 한다고 선언할 뿐, 카드로 하든 계좌이체로 하든 그 방법은 구현하는 쪽에 맡긴다. 무엇을 할지와 어떻게 할지를 갈라 무엇만 약속하는 게 인터페이스다.
인터페이스는 implements로 구현한다. 클래스는 인터페이스를 implements로 받아들이고 선언된 메서드를 모두 채워야 한다. 하나라도 빠뜨리면 컴파일이 안 된다. 이 강제가 인터페이스의 힘이다. 아래에서 카드 결제와 계좌이체는 같은 계약을 저마다 다르게 이행한다.
public interface Payment {
void pay(long amount); // 몸통 없는 약속
}
public class CardPayment implements Payment {
@Override
public void pay(long amount) {
System.out.println("카드로 " + amount + "원 결제");
}
}
public class BankTransfer implements Payment {
@Override
public void pay(long amount) {
System.out.println("계좌이체로 " + amount + "원 결제");
}
}
인터페이스 타입으로 여러 구현을 하나처럼 다룬다. 변수의 타입을 인터페이스로 잡으면 그 계약을 지키는 어떤 구현이든 담을 수 있다. 결제 코드는 그게 카드인지 계좌이체인지 몰라도 pay만 부르면 된다. 나중에 간편결제가 추가돼도 이 코드는 그대로다. 구체적인 것에 매이지 않고 약속에만 기대는 방식이 유연한 설계의 핵심이다.
public void checkout(Payment method, long amount) {
method.pay(amount); // 무엇이 오든 계약대로 동작
}
checkout(new CardPayment(), 5000);
checkout(new BankTransfer(), 3000);
한 클래스가 여러 인터페이스를 구현할 수 있다. 자바는 클래스 상속을 하나만 허용한다. 부모는 한 명뿐이다. 하지만 인터페이스는 여러 개를 한꺼번에 구현할 수 있다. 어떤 클래스가 Comparable이면서 동시에 Serializable일 수 있다. 상속이 하나의 정체성을 물려받는 것이라 하나뿐인 반면, 인터페이스는 능력을 겸비하는 것이라 여럿이 가능하다.
public class Product
implements Comparable<Product>, Serializable {
// 두 계약을 모두 이행
}
default 메서드는 인터페이스에 기본 구현을 준다. 원래 인터페이스는 몸통 없는 약속만 담았다. JDK 8부터는 default 키워드로 기본 구현을 가진 메서드를 둘 수 있다. 구현 클래스가 굳이 채우지 않아도 기본 동작을 물려받는다. 이미 널리 쓰이는 인터페이스에 새 메서드를 추가하면 기존 구현이 전부 깨지는데, default로 기본 구현을 함께 주면 옛 코드를 안 깨고 기능을 더할 수 있다. 표준 라이브러리의 List가 이 방식으로 새 기능을 받아들였다.
public interface Payment {
void pay(long amount);
default void refund(long amount) { // 기본 구현 제공
System.out.println(amount + "원 환불");
}
}
추상 클래스는 클래스와 인터페이스의 중간이다. 추상 클래스는 abstract를 붙인 클래스로, 몸통 없는 추상 메서드와 몸통 있는 일반 메서드를 함께 가진다. 공통된 뼈대와 상태는 채워 주고, 저마다 달라야 하는 부분만 빈칸으로 남겨 자식이 메우게 한다. 그 자체로는 객체를 만들 수 없고 자식이 빈칸을 채운 뒤에야 쓸 수 있다. 부모가 흐름을 정하고 자식이 세부를 메우는 구조가 이것이다.
public abstract class Shape {
private String name;
public Shape(String name) { this.name = name; }
public abstract double area(); // 빈칸: 자식이 채운다
public void describe() { // 공통: 부모가 채운다
System.out.println(name + "의 넓이는 " + area());
}
}
public class Circle extends Shape {
private double r;
public Circle(double r) { super("원"); this.r = r; }
@Override
public double area() { return Math.PI * r * r; }
}
추상 클래스와 인터페이스는 쓰임이 갈린다. 둘 다 직접 객체를 못 만들고 다형성의 바탕이 된다. 차이는 이렇다. 추상 클래스는 필드, 곧 상태를 가지고 공통 코드를 나누기에 좋지만 하나만 상속된다. 인터페이스는 여러 개를 겸해 능력을 조합하기 좋지만 원래 상태를 가지지 않는다. "이것은 무엇이다"라는 정체성과 공통 뼈대를 나눌 때는 추상 클래스, "무엇을 할 수 있다"는 능력을 붙일 때는 인터페이스가 어울린다.
인터페이스에 기대는 설계가 변화에 강하다. 좋은 코드는 구체 클래스가 아니라 인터페이스에 의존하도록 짠다. 결제 코드가 CardPayment를 직접 알면 수단이 바뀔 때마다 고쳐야 하지만, Payment에만 기대면 어떤 구현이 와도 안 바뀐다. 구체가 아니라 추상에 의존하게 뒤집는 이 원칙을 의존성 역전이라 부르며, 스프링을 비롯한 현대 프레임워크의 바탕에 깔려 있다. 덤으로 테스트도 쉬워진다. 실제 결제 서버 대신 가짜 구현을 끼우면 돈이 오가지 않아도 로직만 검증할 수 있다.
작은 인터페이스가 큰 인터페이스보다 낫다. 메서드를 잔뜩 몰아넣기보다 꼭 필요한 것만 담아 작게 쪼갠다. 뚱뚱한 인터페이스를 구현하려면 몇 개만 필요한 클래스도 나머지를 억지로 빈껍데기로 채워야 한다. 역할별로 잘게 나눠 두면 각 클래스가 필요한 계약만 골라 구현한다. Runnable이나 Comparable처럼 메서드 하나짜리 인터페이스가 그 모범이다. 반대로 구현이 하나뿐이고 바뀔 일 없는데 짝이 되는 인터페이스를 기계적으로 만드는 것도 과하다.
함수형 인터페이스는 다음 이야기로 이어진다. 메서드가 딱 하나뿐인 인터페이스를 함수형 인터페이스라 부른다. 자바 8부터는 이런 인터페이스를 람다라는 짧은 문법으로 즉석에서 구현해, 동작 하나를 통째로 값처럼 넘길 수 있다. 이 함수형 프로그래밍은 뒤에서 본격적으로 다룬다.
다음 편에서는 record와 불변 데이터를 다룬다. 데이터를 담는 클래스를 만들 때 필드와 생성자와 getter와 toString과 equals를 일일이 손으로 적어야 했다. 이 지겨운 반복을 한 줄로 없애 주는 것이 record다. 무엇을 자동으로 만들어 주는지, 왜 불변인지, 언제 일반 클래스 대신 쓰는지를 코드로 살핀다. 이번 편에서 본 인터페이스를 record가 구현할 수도 있어 둘은 잘 어울린다.