

다형성(Polymorphism)은 객체지향 프로그래밍의 핵심 개념 중 하나다.
하나의 객체가 여러 형태로 동작할 수 있는 성질을 의미하며 자바에서는 부모 타입의 참조 변수로 자식 객체를 참조할 수 있는 것을 뜻한다.
이 기능을 통해 코드는 더 유연하고 확장 가능하게 된다.
다형성은 상속 관계에서 성립한다.
부모 클래스가 존재하고 그 클래스를 상속받은 자식 클래스가 있을 때 부모 타입의 참조 변수에 자식 객체를 담을 수 있다.
class Parent {
public void greet() {
System.out.println("부모의 인사입니다.");
}
}
class Child extends Parent {
@Override
public void greet() {
System.out.println("자식의 인사입니다.");
}
}
public class PolyMain {
public static void main(String[] args) {
Parent parent = new Child(); // 업캐스팅
parent.greet(); // "자식의 인사입니다."
}
}
변수의 타입은 Parent이지만 실제 인스턴스는 Child다.
이처럼 실행 시점에 실제 객체의 타입에 따라 호출되는 메서드가 결정되는 것을 동적 바인딩(Dynamic Binding)이라 한다.
부모 타입으로 자식을 참조하면 부모가 가진 기능만 사용할 수 있다.
자식 클래스의 고유한 기능을 사용하려면 다운캐스팅(Downcasting)이 필요하다.
Parent parent = new Child();
parent.greet(); // 부모 타입으로 접근 가능
// parent.childMethod(); // 불가능
Child child = (Child) parent; // 다운캐스팅
child.greet(); // 가능
반대로 자식을 부모로 바꾸는 것은 업캐스팅(Upcasting)이라 하며 자동으로 수행된다.
그러나 다운캐스팅은 명시적 형변환이 필요하다.
다운캐스팅을 수행하기 전에는 객체의 실제 타입을 확인해야 한다.
이를 위해 instanceof 연산자를 사용한다.
Parent parent = new Child();
if (parent instanceof Child) {
Child child = (Child) parent;
child.greet();
}
instanceof는 해당 객체가 특정 타입의 인스턴스인지 여부를 검사한다.
잘못된 타입으로 캐스팅하면 ClassCastException이 발생하므로 안전한 캐스팅을 위해 꼭 사용해야 한다.
오버라이딩은 부모 클래스의 메서드를 자식 클래스가 재정의하는 것이다.
다형성은 오버라이딩을 기반으로 동작한다.
class Animal {
public void sound() {
System.out.println("동물이 소리를 냅니다.");
}
}
class Dog extends Animal {
@Override
public void sound() {
System.out.println("멍멍");
}
}
class Cat extends Animal {
@Override
public void sound() {
System.out.println("냐옹");
}
}
public class SoundMain {
public static void main(String[] args) {
Animal[] animals = {new Dog(), new Cat()};
for (Animal animal : animals) {
animal.sound(); // 실제 객체의 메서드가 실행됨
}
}
}
부모 타입으로 선언된 배열이나 컬렉션을 통해 여러 자식 객체를 동일한 방식으로 다룰 수 있다.
이것이 다형성이 제공하는 가장 큰 장점이다.
다형성은 중복을 제거하고 코드 변경 없이 확장을 가능하게 한다.
새로운 동물이 추가되더라도 기존 로직을 수정할 필요가 없다.
class Cow extends Animal {
@Override
public void sound() {
System.out.println("음매");
}
}
SoundMain의 코드는 그대로 유지된다.
이처럼 다형성은 새로운 요구사항이 추가될 때 수정보다 확장을 가능하게 만든다.
이는 OCP(개방-폐쇄 원칙)을 실현하는 핵심 원리다.
모든 자식 클래스가 sound()를 반드시 구현해야 한다면 Animal을 추상 클래스로 만들어 강제할 수 있다.
public abstract class Animal {
public abstract void sound();
public void move() {
System.out.println("동물이 움직입니다.");
}
}
public class Dog extends Animal {
@Override
public void sound() {
System.out.println("멍멍");
}
}
public class Cat extends Animal {
@Override
public void sound() {
System.out.println("냐옹");
}
}
추상 클래스의 특징은 다음과 같다.
추상 클래스보다 더 순수한 형태의 규약이 필요할 때 인터페이스를 사용한다.
인터페이스는 “무엇을 할 수 있는가”에 대한 약속을 정의한다.
public interface Animal {
void sound();
}
public class Dog implements Animal {
@Override
public void sound() {
System.out.println("멍멍");
}
}
public class Cat implements Animal {
@Override
public void sound() {
System.out.println("냐옹");
}
}
public class AnimalMain {
public static void main(String[] args) {
Animal dog = new Dog();
Animal cat = new Cat();
Animal[] animals = {dog, cat};
for (Animal a : animals) {
a.sound();
}
}
}
인터페이스는 구현체와의 결합을 줄이고 다양한 객체를 동일한 방식으로 다룰 수 있게 한다.
이로써 코드의 의존성이 낮아지고 확장성이 높아진다.
추상 클래스는 공통된 성질을 상속하기 위한 수단이다.
인터페이스는 공통된 행위를 정의하기 위한 수단이다.
public interface Flyable {
void fly();
}
public abstract class Animal {
public abstract void sound();
}
public class Bird extends Animal implements Flyable {
@Override
public void sound() {
System.out.println("짹짹");
}
@Override
public void fly() {
System.out.println("새가 하늘을 납니다.");
}
}
public class AnimalMain {
public static void main(String[] args) {
Bird bird = new Bird();
bird.sound();
bird.fly();
}
}
클래스는 공통된 속성을 물려주고 인터페이스는 공통된 행동을 강제한다.
두 개념을 함께 사용하면 객체 간 협력 관계를 더욱 유연하게 설계할 수 있다.