이전 글에서는 부모 타입으로 자식 객체를 다루는 다형성의 기본 문법을 정리했습니다. 이번 글에서는 그 문법을 실제 코드 구조에 어떻게 활용하는지 살펴보겠습니다.
다형성은 단순히 “부모 타입에 자식 객체를 넣을 수 있다”에서 끝나지 않습니다. 여러 객체를 같은 기준으로 묶고, 변하는 부분과 변하지 않는 부분을 나누는 데 큰 도움이 됩니다.
아직 다형적 참조, 업캐스팅, 다운캐스팅이 헷갈린다면 자바 다형성 정리 - 부모 타입으로 자식 객체 다루기를 먼저 보는 편이 좋습니다.
다형성이 필요한 상황
쇼핑몰에서 결제 기능을 만든다고 생각해보겠습니다. 처음에는 카드 결제와 계좌 이체만 있다고 가정합니다.
class CardPay {
void pay(int amount) {
System.out.println("카드로 " + amount + "원 결제합니다.");
}
}
class BankTransferPay {
void pay(int amount) {
System.out.println("계좌 이체로 " + amount + "원 결제합니다.");
}
}
사용하는 코드는 이렇게 작성할 수 있습니다.
public class Main {
public static void main(String[] args) {
CardPay cardPay = new CardPay();
BankTransferPay bankTransferPay = new BankTransferPay();
System.out.println("결제 시작");
cardPay.pay(10000);
System.out.println("결제 종료");
System.out.println("결제 시작");
bankTransferPay.pay(10000);
System.out.println("결제 종료");
}
}
이 코드는 동작합니다. 하지만 결제 시작과 결제 종료 출력이 반복됩니다.
나중에 휴대폰 결제, 포인트 결제, 쿠폰 결제가 추가되면 같은 구조가 계속 늘어납니다. 문제는 결제 수단마다 타입이 다르기 때문에 하나의 메서드나 배열로 묶기 어렵다는 점입니다.
부모 타입으로 묶기
결제 수단들은 모두 “결제한다”는 공통 역할을 가집니다. 이 공통 역할을 부모 클래스로 올려보겠습니다.
class PaymentMethod {
void pay(int amount) {
System.out.println("기본 결제를 처리합니다.");
}
}
이제 카드 결제와 계좌 이체는 PaymentMethod를 상속합니다.
class CardPay extends PaymentMethod {
@Override
void pay(int amount) {
System.out.println("카드로 " + amount + "원 결제합니다.");
}
}
class BankTransferPay extends PaymentMethod {
@Override
void pay(int amount) {
System.out.println("계좌 이체로 " + amount + "원 결제합니다.");
}
}
이제 결제 처리 코드를 하나의 메서드로 분리할 수 있습니다.
public class Main {
public static void main(String[] args) {
PaymentMethod cardPay = new CardPay();
PaymentMethod bankTransferPay = new BankTransferPay();
processPayment(cardPay, 10000);
processPayment(bankTransferPay, 10000);
}
static void processPayment(PaymentMethod paymentMethod, int amount) {
System.out.println("결제 시작");
paymentMethod.pay(amount);
System.out.println("결제 종료");
}
}
실행 결과입니다.
결제 시작
카드로 10000원 결제합니다.
결제 종료
결제 시작
계좌 이체로 10000원 결제합니다.
결제 종료
processPayment()는 구체적인 결제 수단을 모릅니다.
그저 PaymentMethod 타입을 받아서 pay()를 호출합니다.
실제로 어떤 결제가 실행될지는 객체가 결정합니다. 이것이 다형성을 활용하는 기본 흐름입니다.
배열과 반복문으로 중복 줄이기
부모 타입으로 묶을 수 있으면 배열도 사용할 수 있습니다.
public class Main {
public static void main(String[] args) {
PaymentMethod[] paymentMethods = {
new CardPay(),
new BankTransferPay(),
new PointPay()
};
for (PaymentMethod paymentMethod : paymentMethods) {
processPayment(paymentMethod, 10000);
}
}
static void processPayment(PaymentMethod paymentMethod, int amount) {
System.out.println("결제 시작");
paymentMethod.pay(amount);
System.out.println("결제 종료");
}
}
class PointPay extends PaymentMethod {
@Override
void pay(int amount) {
System.out.println("포인트로 " + amount + "원 결제합니다.");
}
}
새 결제 수단이 추가되어도 processPayment()는 바뀌지 않습니다.
바뀌는 부분은 새 클래스를 만들고 배열에 추가하는 부분입니다.
이렇게 코드를 나누면 변하는 부분과 변하지 않는 부분이 분리됩니다.
변하는 부분: 어떤 결제 수단을 만들 것인가
변하지 않는 부분: 결제 시작, 결제 실행, 결제 종료 흐름
다형성의 장점은 바로 이 지점에서 느껴집니다.
아직 남은 문제
지금 구조에는 두 가지 아쉬운 점이 있습니다.
첫째, PaymentMethod를 직접 생성할 수 있습니다.
PaymentMethod paymentMethod = new PaymentMethod();
하지만 PaymentMethod는 실제 결제 수단이라기보다 공통 기준입니다.
직접 객체로 만드는 것은 자연스럽지 않습니다.
둘째, 자식 클래스가 pay()를 오버라이딩하지 않아도 컴파일 오류가 나지 않습니다.
그러면 부모 클래스의 기본 pay()가 실행될 수 있습니다.
결제 수단이라면 반드시 자기 방식의 pay()를 가져야 합니다.
이런 제약을 코드로 표현하려면 추상 클래스와 추상 메서드를 사용할 수 있습니다.
추상 클래스
추상 클래스는 직접 객체를 만들 수 없는 클래스입니다. 상속을 위한 부모 역할로 사용할 때 적합합니다.
abstract class PaymentMethod {
abstract void pay(int amount);
void printReceipt() {
System.out.println("영수증을 출력합니다.");
}
}
abstract가 붙은 클래스는 직접 생성할 수 없습니다.
// PaymentMethod paymentMethod = new PaymentMethod(); // 컴파일 오류
pay()에는 본문이 없습니다.
이런 메서드를 추상 메서드라고 합니다.
abstract void pay(int amount);
추상 메서드는 자식 클래스가 반드시 오버라이딩해야 합니다.
그래서 결제 수단마다 pay()를 빠뜨리는 실수를 컴파일 단계에서 막을 수 있습니다.
class CardPay extends PaymentMethod {
@Override
void pay(int amount) {
System.out.println("카드로 " + amount + "원 결제합니다.");
}
}
추상 클래스 안에는 일반 메서드도 둘 수 있습니다.
위 예시의 printReceipt()는 자식들이 그대로 사용할 수 있는 공통 기능입니다.
추상 클래스가 해결하는 것
추상 클래스는 아래 두 가지 문제를 줄여줍니다.
- 공통 부모를 실수로 직접 생성하는 문제
- 자식 클래스가 반드시 구현해야 할 메서드를 빠뜨리는 문제
예를 들어 아래 코드는 컴파일 오류가 납니다.
class CouponPay extends PaymentMethod {
// pay()를 구현하지 않으면 오류가 납니다.
}
CouponPay가 일반 클래스라면 추상 메서드 pay()를 반드시 구현해야 합니다.
만약 구현하지 않으려면 CouponPay도 추상 클래스로 선언해야 합니다.
이 제약 덕분에 코드가 더 안전해집니다.
좋은 코드는 아무거나 허용하는 코드가 아니라, 잘못된 사용을 미리 막아주는 코드입니다.
순수 추상 클래스와 인터페이스
추상 클래스 안의 모든 메서드가 추상 메서드라면, 그 클래스는 실행 로직을 거의 가지지 않습니다. 이 경우 “이 기능들을 반드시 구현하라”는 규격에 가까워집니다.
예를 들어 결제 수단에 필요한 기능을 이렇게 정할 수 있습니다.
abstract class PaymentRule {
abstract void pay(int amount);
abstract boolean supportsInstallment();
}
이런 구조는 인터페이스로 표현하는 것이 더 자연스럽습니다.
interface PaymentRule {
void pay(int amount);
boolean supportsInstallment();
}
인터페이스의 메서드는 기본적으로 public abstract입니다.
그래서 보통 public abstract를 쓰지 않고 위처럼 간단히 작성합니다.
인터페이스는 “이 규격을 구현해야 한다”는 약속에 가깝습니다.
클래스가 인터페이스를 사용할 때는 extends가 아니라 implements를 사용합니다.
class CardPay implements PaymentRule {
@Override
public void pay(int amount) {
System.out.println("카드로 " + amount + "원 결제합니다.");
}
@Override
public boolean supportsInstallment() {
return true;
}
}
인터페이스를 구현한 클래스는 인터페이스의 메서드를 모두 구현해야 합니다.
인터페이스로 다형성 사용하기
인터페이스도 타입으로 사용할 수 있습니다. 그래서 다형성을 그대로 활용할 수 있습니다.
class BankTransferPay implements PaymentRule {
@Override
public void pay(int amount) {
System.out.println("계좌 이체로 " + amount + "원 결제합니다.");
}
@Override
public boolean supportsInstallment() {
return false;
}
}
사용하는 코드는 이렇게 작성할 수 있습니다.
public class Main {
public static void main(String[] args) {
PaymentRule[] paymentRules = {
new CardPay(),
new BankTransferPay()
};
for (PaymentRule paymentRule : paymentRules) {
paymentRule.pay(10000);
System.out.println("할부 가능: " + paymentRule.supportsInstallment());
}
}
}
실행 결과입니다.
카드로 10000원 결제합니다.
할부 가능: true
계좌 이체로 10000원 결제합니다.
할부 가능: false
변수 타입은 PaymentRule입니다.
실제 객체는 CardPay, BankTransferPay입니다.
인터페이스도 부모 타입처럼 사용되며, 실제 실행은 구현 클래스의 오버라이딩 메서드가 담당합니다.
인터페이스는 여러 개 구현할 수 있습니다
자바 클래스는 부모 클래스를 하나만 상속할 수 있습니다.
class SomePay extends PaymentMethod {
}
하지만 인터페이스는 여러 개 구현할 수 있습니다.
interface Refundable {
void refund(int amount);
}
interface Receiptable {
void printReceipt();
}
카드 결제는 결제도 하고, 환불도 하고, 영수증도 출력할 수 있다고 가정해보겠습니다.
class AdvancedCardPay implements PaymentRule, Refundable, Receiptable {
@Override
public void pay(int amount) {
System.out.println("카드로 " + amount + "원 결제합니다.");
}
@Override
public boolean supportsInstallment() {
return true;
}
@Override
public void refund(int amount) {
System.out.println("카드 결제 " + amount + "원을 환불합니다.");
}
@Override
public void printReceipt() {
System.out.println("카드 영수증을 출력합니다.");
}
}
쉼표로 여러 인터페이스를 나열하면 됩니다.
implements PaymentRule, Refundable, Receiptable
이렇게 하면 하나의 클래스가 여러 역할을 가질 수 있습니다.
AdvancedCardPay
├─ PaymentRule 역할
├─ Refundable 역할
└─ Receiptable 역할
추상 클래스와 인터페이스를 함께 쓰기
추상 클래스와 인터페이스는 함께 사용할 수 있습니다.
예를 들어 모든 결제 수단은 공통으로 결제 번호를 가지고, 일부 결제 수단만 환불 기능을 제공한다고 해보겠습니다.
abstract class BasePayment {
private String paymentId;
BasePayment(String paymentId) {
this.paymentId = paymentId;
}
String getPaymentId() {
return paymentId;
}
abstract void pay(int amount);
}
interface Refundable {
void refund(int amount);
}
카드 결제는 공통 결제 정보도 가지고, 환불 기능도 구현합니다.
class CardPayment extends BasePayment implements Refundable {
CardPayment(String paymentId) {
super(paymentId);
}
@Override
void pay(int amount) {
System.out.println(getPaymentId() + " 카드 결제: " + amount + "원");
}
@Override
public void refund(int amount) {
System.out.println(getPaymentId() + " 카드 환불: " + amount + "원");
}
}
계좌 이체는 환불 인터페이스를 구현하지 않는다고 가정할 수 있습니다.
class BankPayment extends BasePayment {
BankPayment(String paymentId) {
super(paymentId);
}
@Override
void pay(int amount) {
System.out.println(getPaymentId() + " 계좌 이체: " + amount + "원");
}
}
extends와 implements를 함께 쓸 때는 extends가 먼저 나옵니다.
class CardPayment extends BasePayment implements Refundable {
}
클래스 상속은 하나만 가능하지만, 인터페이스 구현은 여러 개 가능합니다.
추상 클래스와 인터페이스 차이
둘 다 다형성을 위해 사용할 수 있습니다. 하지만 역할에는 차이가 있습니다.
| 구분 | 추상 클래스 | 인터페이스 |
|---|---|---|
| 목적 | 공통 상태와 기능을 함께 제공 | 지켜야 할 규격을 제공 |
| 필드 | 인스턴스 필드를 가질 수 있음 | 상수 중심 |
| 일반 메서드 | 가질 수 있음 | 기본적으로 추상 메서드 중심 |
| 사용 키워드 | extends | implements |
| 개수 | 하나만 상속 가능 | 여러 개 구현 가능 |
간단히 말하면 이렇습니다.
공통 상태와 기본 기능까지 묶고 싶다 → 추상 클래스
반드시 구현해야 할 역할만 정하고 싶다 → 인터페이스
처음에는 이 기준만 잡아도 충분합니다.
실수하기 쉬운 부분
다형성 활용을 배울 때는 아래를 자주 헷갈립니다.
- 부모 타입이나 인터페이스 타입으로 자식 객체를 담을 수 있습니다.
- 오버라이딩된 메서드는 실제 객체 기준으로 실행됩니다.
- 추상 클래스는 직접 객체를 만들 수 없습니다.
- 추상 메서드는 자식 클래스가 반드시 구현해야 합니다.
- 인터페이스를 구현할 때는
implements를 사용합니다. - 인터페이스 메서드는 보통
public으로 구현해야 합니다. - 클래스는 하나만 상속할 수 있지만, 인터페이스는 여러 개 구현할 수 있습니다.
extends와implements를 함께 쓰면extends가 먼저 나옵니다.
특히 인터페이스 구현 메서드의 접근 범위는 주의해야 합니다.
인터페이스 메서드는 기본적으로 public이므로 구현 클래스에서도 public으로 작성해야 합니다.
정리
다형성은 여러 객체를 같은 타입으로 다루기 위한 도구입니다. 이를 사용하면 중복 코드를 줄이고, 새로운 구현이 추가되어도 기존 처리 흐름을 크게 바꾸지 않을 수 있습니다.
추상 클래스는 직접 생성하면 안 되는 부모 개념을 표현할 때 사용합니다. 추상 메서드를 통해 자식 클래스가 반드시 구현해야 하는 기능을 강제할 수 있습니다.
인터페이스는 구현해야 할 규격을 표현합니다. 여러 클래스가 같은 인터페이스를 구현하면, 같은 타입으로 묶어서 다형성을 사용할 수 있습니다.
결국 중요한 기준은 이것입니다.
변하지 않는 흐름은 부모 타입이나 인터페이스 타입으로 처리합니다.
변하는 동작은 자식 클래스나 구현 클래스에서 오버라이딩합니다.
이 구조를 이해하면 객체 지향 코드에서 역할을 나누는 감각이 조금씩 잡히기 시작합니다.