1. 소개
다형성은 상속 및 캡슐화와 함께 객체 지향 프로그래밍을 구성하는 세 가지 핵심 개념 중 하나입니다. 어원적으로는 그리스어 “poly”(많다)와 “morph”(형태)에서 왔습니다. 프로그래밍에서 이는 하나의 인터페이스 — 여러 구현을 의미합니다.
정의
다형성은 서로 다른 클래스의 객체들이 동일한 메시지(메서드 호출)에 서로 다르게 반응하는 능력입니다.
예를 들어 makeSound()라는 메서드가 있다면, 어떤 동물에게든 이를 호출할 수 있습니다. 하지만 고양이는 야옹하고, 개는 멍하고, 소는 음메 할 것입니다. 프로그래머 입장에서는 그냥 animal.makeSound()를 호출하는 것이지만, 실제로 어떤 일이 벌어지는지는 그 변수 뒤에 어떤 객체가 있는지에 따라 달라집니다.
생활 속 비유
집에 TV 리모컨이 하나 있는데, 그 리모컨으로 스피커, 프로젝터, 심지어 커피머신까지 조작할 수 있다고 상상해 보세요. 당신이 “켜기” — turnOn() 버튼을 누르면 각 기기가 자기 방식대로 반응합니다. 중요한 건 모두에게 “전원” 버튼이 있지만, 구현은 각자 다르다는 점입니다.
Java 예제
class Animal {
void makeSound() {
System.out.println("어떤 소리...");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("멍!");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("야옹!");
}
}
class Cow extends Animal {
@Override
void makeSound() {
System.out.println("음메!");
}
}
이제 이렇게 할 수 있습니다:
Animal animal1 = new Dog();
Animal animal2 = new Cat();
Animal animal3 = new Cow();
animal1.makeSound(); // 멍!
animal2.makeSound(); // 야옹!
animal3.makeSound(); // 음메!
주의: 모든 변수의 타입은 Animal이지만, 호출 결과는 객체의 실제 타입에 따라 달라집니다.
2. 다형성의 종류
Java(및 대부분의 OOP 언어)에서는 두 가지 주요 형태의 다형성을 구분합니다:
컴파일 시(정적) 다형성 — 메서드 오버로딩 (overloading)
하나의 클래스에 동일한 이름이지만 매개변수가 다른 메서드가 여러 개 있을 때를 말합니다. 컴파일러는 전달된 인수에 따라 어떤 메서드를 호출할지 결정합니다.
예시(미리 보기 — 자세한 내용은 다음 강의에서 다룹니다):
class Printer {
void print(int x) {
System.out.println("숫자: " + x);
}
void print(String s) {
System.out.println("문자열: " + s);
}
}
실행 시(동적) 다형성 — 메서드 오버라이딩 (overriding)
기본 클래스에 정의된 메서드를 하위 클래스에서 재정의하는 경우입니다. 어떤 메서드가 호출될지는 프로그램 실행 시간(runtime)에, 즉 객체의 실제 타입에 따라 결정됩니다.
예시는 위의 동물 예제를 참조하세요.
3. 다형성이 필요한 이유는?
다형성은 면접에서 쓰는 멋진 단어가 아닙니다. 코드를 유연하고 확장 가능하며 유지 보수하기 쉽게 만드는 도구입니다.
코드의 범용성
구현 세부사항에 신경 쓰지 않고 기본 타입의 객체들과 작업하는 코드를 작성할 수 있습니다. 예를 들어 동물 목록이 있다면, 그 목록을 순회하면서 각 객체에 makeSound()를 호출하면 됩니다. 고양이인지 개인지 신경 쓸 필요가 없습니다.
Animal[] animals = { new Dog(), new Cat(), new Cow() };
for (Animal animal : animals) {
animal.makeSound(); // 매번 “올바른” 메서드가 호출됩니다
}
확장 용이성
다음 날 상사가 “앵무새를 추가하자!”라고 말하더라도, 새로운 클래스 Parrot extends Animal을 작성해 배열에 추가하기만 하면 됩니다. 나머지 코드는 그대로 둡니다. 이는 확장에는 열려 있고 변경에는 닫혀 있음(SOLID의 OCP 원칙)을 의미합니다.
아키텍처 단순화
구체적인 구현을 신경 쓰지 않고 추상화(기본 클래스 또는 인터페이스)를 통해 상호 작용하는 복잡한 시스템을 구축할 수 있습니다. 이는 시간과 신경, 그리고 커피를 아껴 줍니다.
4. 핵심 개념: 참조 타입과 실제 타입
변수의 참조 타입
Animal animal = new Dog();라고 작성하면, 변수 animal의 참조 타입은 Animal입니다. 즉, 컴파일러는 이것을 동물이라고 “생각”하고, Animal 클래스에 선언된 메서드만 허용합니다.
객체의 실제 타입
하지만 메모리에는 실제로 Dog 타입의 객체가 존재합니다. 바로 이 실제 타입이 makeSound() 호출 시 어떤 메서드가 실행될지를 결정합니다.
예시
Animal animal = new Dog();
animal.makeSound(); // Animal.makeSound()가 아니라 Dog.makeSound()가 호출됩니다
중요! 기본 타입(Animal)의 참조를 통해서는 Dog에만 존재하고 기본 클래스에 선언되지 않은 메서드를 호출할 수 없습니다.
지연(동적) 바인딩
이는 실행 시간에 벌어지는 “마법 같은” 동작입니다. 기본 타입 참조로 메서드를 호출하면 JVM이 객체의 실제 타입을 보고 “올바른” 구현을 호출합니다. 이것이 바로 작동하는 다형성입니다.
5. 실용 예제: 애플리케이션에서의 다형성
우리의 학습용 애플리케이션을 계속 발전시켜 봅시다. 간단한 동물원 시뮬레이션을 만든다고 가정하겠습니다. 기본 클래스 Animal과 그 하위 클래스들이 있고, 모든 동물이 “소리를 낼” 수 있기를 원하지만, 동물의 종류마다 별도의 코드를 매번 작성하고 싶지는 않습니다.
1단계: 기본 클래스와 하위 클래스
class Animal {
void makeSound() {
System.out.println("어떤 소리...");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("멍!");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("야옹!");
}
}
2단계: 동물 배열
Animal[] zoo = { new Dog(), new Cat(), new Animal() };
3단계: 순회 및 메서드 호출
for (Animal animal : zoo) {
animal.makeSound();
}
실행 결과:
멍!
야옹!
어떤 소리...
유의: 배열에 누가 들어 있는지 미리 알지 못해도, 프로그램이 알아서 적절한 메서드를 호출합니다.
6. 다형성의 도식화
. Animal (makeSound)
/ \
Dog Cat
(makeSound) (makeSound)
Animal animal = new Dog();
animal.makeSound(); // --> Dog.makeSound()
Animal animal = new Cat();
animal.makeSound(); // --> Cat.makeSound()
7. 또 다른 예: 실제 과제에서의 다형성
회사 직원 관리를 위한 프로그램을 작성한다고 가정해 봅시다. 기본 클래스 Employee와 두 개의 하위 클래스 Manager, Developer가 있습니다. 모든 직원은 일을 — work() — 하지만 방식은 서로 다릅니다.
class Employee {
void work() {
System.out.println("직원이 일합니다.");
}
}
class Manager extends Employee {
@Override
void work() {
System.out.println("매니저가 회의를 진행합니다.");
}
}
class Developer extends Employee {
@Override
void work() {
System.out.println("개발자가 코드를 작성합니다.");
}
}
이제 이렇게 할 수 있습니다:
Employee[] staff = { new Manager(), new Developer(), new Employee() };
for (Employee emp : staff) {
emp.work();
}
결과:
매니저가 회의를 진행합니다.
개발자가 코드를 작성합니다.
직원이 일합니다.
8. 다형성이 작동하지 않는 경우
다형성은 기본 클래스에 선언된 메서드에 대해서만 동작합니다. 하위 클래스에만 있는 고유한 메서드는 기본 타입 참조로는 보이지 않습니다.
class Dog extends Animal {
void fetchStick() {
System.out.println("개가 막대를 물어옵니다!");
}
}
Animal animal = new Dog();
// animal.fetchStick(); // 컴파일 오류! Animal을 통해서는 이런 메서드가 보이지 않습니다
특정 메서드를 호출하려면 변수를 필요한 타입으로 캐스팅해야 합니다:
if (animal instanceof Dog) {
((Dog) animal).fetchStick();
}
하지만 이것은 또 다른 이야기입니다 — 핵심은 다음과 같습니다: 다형성을 통해서는 기본 클래스에 선언된 메서드만 사용할 수 있습니다.
9. 다형성을 사용할 때 흔한 실수
오류 № 1: 기본 타입 참조를 통해 하위 클래스의 모든 메서드를 사용할 수 있다고 기대하는 것. 실제로는 기본 클래스에 선언된 메서드만 사용할 수 있습니다.
오류 № 2: 메서드를 재정의하면서 @Override 애노테이션을 사용하지 않는 것. 이를 생략하면 시그니처를 잘못 작성하더라도 컴파일러가 잡아주지 않아 재정의가 되지 않고(기본 클래스의 메서드가 대체되지 않아) 다형성이 동작하지 않을 수 있습니다.
오류 № 3: 캐스팅 없이 하위 클래스에만 있는 특수 메서드를 호출하려는 시도. 컴파일러는 기본 타입 참조 뒤에 누가 있는지 확신할 수 없기 때문에 허용하지 않습니다.
오류 № 4: 오버로딩 (overloading)과 오버라이딩 (overriding)을 혼동하는 것. 오버로딩은 하나의 클래스 안에서 동일한 이름과 서로 다른 매개변수를 가진 여러 메서드를 두는 것이고, 오버라이딩은 하위 클래스에서 메서드의 동작을 변경하는 것입니다.
GO TO FULL VERSION