1. 다형성: 무엇이며 왜 필요한가
다형성이 Marvel의 변이인간 이야기라고 생각하셨다면 실망하실 수도 있습니다. 프로그래밍에서의 다형성은 훨씬 차분하지만 그만큼 마법 같습니다. 다형성은 서로 다른 구현을 가진 객체들이 동일한 메서드 호출에 서로 다르게 반응하는 능력을 말합니다.
실생활의 예:
Book 클래스와 Magazine 클래스가 있고, 둘 다 추상 클래스 LibraryItem을 상속한다고 합시다. 어떤 라이브러리 항목이든 printInfo() 메서드를 호출하면 책은 저자와 제목을, 잡지는 발행 호수를 출력하길 원합니다.
코드 예시:
abstract class LibraryItem {
String title;
LibraryItem(String title) {
this.title = title;
}
abstract void printInfo();
}
class Book extends LibraryItem {
String author;
Book(String title, String author) {
super(title);
this.author = author;
}
@Override
void printInfo() {
System.out.println("책: " + title + ", 저자: " + author);
}
}
class Magazine extends LibraryItem {
int issueNumber;
Magazine(String title, int issueNumber) {
super(title);
this.issueNumber = issueNumber;
}
@Override
void printInfo() {
System.out.println("잡지: " + title + ", 호: " + issueNumber);
}
}
이제 서로 다른 요소로 배열을 만들고 각각에 대해 printInfo()를 호출할 수 있습니다:
LibraryItem[] items = {
new Book("파리대왕", "윌리엄 골딩"),
new Magazine("과학과 삶", 5)
};
for (LibraryItem item : items) {
item.printInfo();
}
// 출력:
// 책: 파리대왕, 저자: 윌리엄 골딩
// 잡지: 과학과 삶, 호: 5
이렇게 다형성이 동작합니다!
2. 다형성에서 흔한 실수
기본 타입에 없는 메서드를 호출하려는 시도
가장 흔한 실수 중 하나는 기본 타입 참조로는 보이지 않는, 하위 클래스에만 선언된 메서드를 호출하려는 것입니다.
LibraryItem item = new Book("해리 포터", "J. K. 롤링");
// item.getAuthor(); // 컴파일 오류! LibraryItem에는 getAuthor() 메서드가 없습니다
Java는 변수의 타입(LibraryItem)에 기반해 코드를 컴파일하지, 실제 객체(Book)에 기반해 컴파일하지 않습니다. 따라서 책에 특화된 메서드를 호출해야 한다면 타입을 변환해야 합니다:
if (item instanceof Book) {
Book book = (Book) item;
// 이제 book.getAuthor()를 호출할 수 있습니다
}
검사 없이 타입 캐스팅
객체가 Book이라고 확신했는데 실제로는 그렇지 않다면, 런타임에 ClassCastException이 발생합니다. 예를 들어:
LibraryItem item = new Magazine("Forbes", 12);
Book book = (Book) item; // 붐! ClassCastException
올바른 방법 — 항상 타입을 확인하세요:
if (item instanceof Book) {
Book book = (Book) item;
// OK
} else {
System.out.println("이건 책이 아닙니다!");
}
다형성의 장점을 활용하지 않음
가끔 개발자들은 추상화를 사용할 수 있음에도 코드가 특정 타입에 강하게 묶이게 작성합니다. 예를 들어 다음처럼 쓴다면:
Book[] books = ...;
for (Book book : books) {
book.printInfo();
}
이 코드는 책에만 동작합니다. 내일 잡지, 신문, 만화가 추가되면 어떻게 할까요? LibraryItem[] 배열을 사용하고, 기본 클래스나 인터페이스의 메서드로 작업하는 것이 좋습니다.
3. 추상화: 왜 필요하며 어떻게 망치지 않을 것인가
추상 클래스와 인터페이스
추상화는 핵심을 뽑아내고 세부를 감추는 기술입니다. Java에는 이를 위한 추상 클래스와 인터페이스가 있습니다.
- 추상 클래스 — 직접 인스턴스화할 수 없고 상속해서만 사용할 수 있는 클래스입니다.
- 인터페이스 — 클래스가 무엇을 할 수 있어야 하는지에 대한 계약이며, 어떻게 하는지는 규정하지 않습니다.
오류 1: 추상 메서드가 없는 추상 클래스 만들기
추상 클래스에 추상 메서드가 하나도 없다면, 정말 추상 클래스로 만들어야 하는지 고민해 보세요. 일반 클래스로 만드는 편이 더 간단할 수 있습니다.
abstract class UselessAbstract {
void sayHello() {
System.out.println("Hello!");
}
}
// 추상 메서드가 없다면 일반 클래스로 만드는 편이 낫습니다
오류 2: 하위 클래스에서 필수 메서드를 구현하지 않음
클래스가 추상 클래스를 상속하거나 인터페이스를 구현하면 모든 추상 메서드를 반드시 구현해야 합니다. 까먹으면 컴파일러가 알려 주지만, 형식적으로만 구현하고 아무것도 하지 않게 두는 경우가 있습니다. 이는 유지보수에 좋지 않습니다.
class Magazine extends LibraryItem {
Magazine(String title, int issueNumber) {
super(title);
// ...
}
@Override
void printInfo() {
// 비어 있음! 좋지 않습니다!
}
}
오류 3: 너무 깊거나 혼란스러운 추상화 계층 구조
클래스가 다섯에서 열 단계까지 계속 상속된다면 이해하기 매우 어려워집니다. 가능한 한 “평평한” 계층을 만들어 모두가 이해하기 쉽게 하세요.
나쁜 예:
LibraryItem
|
BookItem
|
PrintedBook
|
IllustratedBook
|
ChildrenIllustratedBook
어렵죠? 두세 단계로 제한하는 것이 좋습니다.
4. 실습: 교육용 애플리케이션에서 다형성과 추상화 적용
여러분의 도서관 교육용 애플리케이션을 발전시켜 봅시다. 이전에는 책만 있었죠. 이제 잡지를 추가하고, 인쇄물을 위한 공통 인터페이스를 구현해 봅시다.
추상 클래스를 선언해 봅시다:
abstract class LibraryItem {
protected String title;
public LibraryItem(String title) {
this.title = title;
}
public abstract void printInfo();
}
자식 클래스를 추가합니다:
class Book extends LibraryItem {
private String author;
public Book(String title, String author) {
super(title);
this.author = author;
}
@Override
public void printInfo() {
System.out.println("책: " + title + ", 저자: " + author);
}
}
class Magazine extends LibraryItem {
private int issueNumber;
public Magazine(String title, int issueNumber) {
super(title);
this.issueNumber = issueNumber;
}
@Override
public void printInfo() {
System.out.println("잡지: " + title + ", 호: " + issueNumber);
}
}
다형성을 사용해 봅시다:
LibraryItem[] items = {
new Book("클린 코드", "로버트 마틴"),
new Magazine("Java World", 3)
};
for (LibraryItem item : items) {
item.printInfo();
}
전자 간행물을 위한 인터페이스 추가
일부 간행물은 온라인으로 읽을 수 있다고 가정해 봅시다. 인터페이스를 도입합니다:
interface ReadableOnline {
void openOnline();
}
class EBook extends Book implements ReadableOnline {
private String url;
public EBook(String title, String author, String url) {
super(title, author);
this.url = url;
}
@Override
public void openOnline() {
System.out.println("전자책을 다음 주소에서 엽니다: " + url);
}
}
이제 인터페이스를 통해 전자책을 다룰 수 있습니다:
ReadableOnline ebook = new EBook("자바 포 더미즈", "배리 버드", "https://example.com/java");
ebook.openOnline();
5. 다형성과 추상화 문제를 피하는 방법: 모범 사례
- 행동을 설명할 때 인터페이스와 추상 클래스를 사용하고, 상태를 담지 마세요.
예를 들어 Printable 인터페이스는 “출력 가능”이라는 능력을 잘 설명하지만, 인터페이스에 String title 같은 필드를 두는 것은 좋은 생각이 아닙니다. - 캐스팅 전에 instanceof 로 타입을 확인하세요.
객체가 여러 타입일 수 있을 때 특히 중요합니다. 이는 ClassCastException을 예방합니다. - “평평하고” 이해하기 쉬운 계층을 지향하세요.
상속 트리가 단순할수록 유지보수와 확장이 쉽습니다. - “의미 없는” 추상화를 만들지 마세요.
클래스에 추상 메서드가 없고 상속을 목적으로 하지 않는다면 — 추상 클래스로 만들지 마세요. - 메서드를 재정의할 때는 @Override 애너테이션을 항상 사용하세요.
시그니처 오류를 컴파일러가 잡아내는 데 도움이 됩니다.
6. 다형성과 추상화를 사용할 때의 흔한 오류
오류 № 1: 검사 없이 타입 캐스팅
때로는 “지름길”을 택해 검증 없이 캐스팅하고 싶을 수 있습니다. 운 좋게 동작할 수도 있지만, 예기치 않게 프로그램이 종료될 수도 있습니다. 항상 instanceof를 사용하세요:
if (item instanceof Book) {
Book book = (Book) item;
// ...
}
오류 № 2: 기본 타입 참조로 하위 클래스의 메서드 호출 시도
LibraryItem item = new Book("Java", "저자");
item.getAuthor(); // 컴파일 오류: LibraryItem에는 그런 메서드가 없습니다!
해결책 — 타입을 변환하거나, 논리적으로 맞는 경우 필요한 메서드를 기본 클래스에 추가하세요.
오류 № 3: 인터페이스 또는 추상 클래스의 불완전한 구현
인터페이스의 모든 메서드를 구현하지 않으면 — 컴파일러가 프로젝트 빌드를 허용하지 않습니다. 하지만 아무것도 하지 않는 “스텁”으로 구현해 두면 예상치 못한 동작을 초래합니다.
오류 № 4: 지나치게 깊은 상속 계층
상속 단계가 셋을 넘어간다면 — 아키텍처를 단순화할 수 없는지 고민해 보세요.
오류 № 5: 단일 책임 원칙 위반
추상화가 너무 많은 책임을 담고 있다면 유지보수가 어려워집니다. 여러 인터페이스나 클래스로 분리하는 것이 좋습니다.
GO TO FULL VERSION