[Java] 개념노트 32 다형성이란 무엇인가?

[Java 개념노트 32] 다형성이란 무엇인가?

안녕하세요. Java 개념노트 시리즈 서른두 번째 글입니다.

지난 글에서는 상속 관계에서 부모 클래스의 필드, 메서드, 생성자를 가리킬 때 사용하는 super 키워드에 대해 정리했습니다. 이번 글에서는 객체지향 프로그래밍의 핵심 개념 중 하나인 다형성에 대해 알아보겠습니다.

다형성은 처음 들으면 이름부터 어렵게 느껴질 수 있습니다. 하지만 핵심은 간단합니다. 하나의 타입으로 여러 종류의 객체를 다룰 수 있는 것이 다형성입니다.


1. 다형성이란?

다형성은 하나의 타입이 여러 형태의 객체를 가리킬 수 있는 객체지향 특성입니다.

자바에서는 주로 부모 타입 변수로 자식 객체를 참조할 때 다형성이 나타납니다. 예를 들어 Animal 타입 변수로 Dog, Cat, Bird 객체를 다룰 수 있습니다.

쉽게 말하면
다형성은 부모 타입 하나로 여러 자식 객체를 다룰 수 있게 해주는 객체지향 문법입니다.

2. 다형성이 필요한 이유

상속을 사용하면 여러 자식 클래스가 같은 부모 클래스를 물려받을 수 있습니다. 이때 다형성을 사용하면 자식 클래스들을 각각 따로 다루지 않고, 부모 타입으로 묶어서 처리할 수 있습니다.

예를 들어 강아지, 고양이, 새가 모두 Animal을 상속받는다고 생각해보겠습니다. 다형성이 없다면 각각의 타입을 따로 처리해야 합니다.

Dog dog = new Dog();
Cat cat = new Cat();
Bird bird = new Bird();

dog.sound();
cat.sound();
bird.sound();

객체가 몇 개 없을 때는 괜찮지만, 종류가 많아지면 코드가 점점 복잡해집니다. 다형성을 사용하면 부모 타입으로 묶어서 더 유연하게 처리할 수 있습니다.

3. 부모 타입으로 자식 객체 참조하기

다형성의 가장 기본 형태는 부모 타입 변수에 자식 객체를 저장하는 것입니다.

Animal animal = new Dog();

위 코드는 Animal 타입 변수 animalDog 객체를 가리키는 코드입니다.

처음에는 이상해 보일 수 있지만, 상속 관계에서 DogAnimal의 한 종류입니다. 따라서 부모 타입 변수로 자식 객체를 가리킬 수 있습니다.

Dog는 Animal이다.
Cat은 Animal이다.
Bird는 Animal이다.

4. 다형성 기본 예제

다형성의 기본 예제를 보겠습니다.

public class Main {
    public static void main(String[] args) {
        Animal animal1 = new Dog();
        Animal animal2 = new Cat();

        animal1.sound();
        animal2.sound();
    }
}

class Animal {
    void sound() {
        System.out.println("동물이 소리를 냅니다.");
    }
}

class Dog extends Animal {
    @Override
    void sound() {
        System.out.println("멍멍");
    }
}

class Cat extends Animal {
    @Override
    void sound() {
        System.out.println("야옹");
    }
}

실행 결과는 다음과 같습니다.

멍멍
야옹

변수 타입은 둘 다 Animal입니다. 하지만 실제 객체는 각각 DogCat입니다. 그래서 실행할 때는 실제 객체에서 오버라이딩한 sound() 메서드가 호출됩니다.

핵심 포인트
부모 타입 변수로 자식 객체를 가리켜도, 오버라이딩된 메서드는 실제 객체 기준으로 실행됩니다.

5. 변수 타입과 실제 객체 타입

다형성을 이해하려면 변수 타입실제 객체 타입을 구분해야 합니다.

Animal animal = new Dog();
구분 내용
변수 타입 Animal
실제 객체 타입 Dog
호출 가능한 기준 변수 타입인 Animal에 있는 메서드
실행되는 메서드 실제 객체인 Dog에서 오버라이딩한 메서드

즉, 무엇을 호출할 수 있는지는 변수 타입이 결정하고, 어떤 메서드가 실행되는지는 실제 객체 타입이 결정합니다.

6. 부모 타입 변수로는 부모가 아는 기능만 호출 가능

부모 타입 변수로 자식 객체를 가리킬 수는 있지만, 그 변수로는 부모 클래스에 정의된 멤버만 바로 사용할 수 있습니다.

class Animal {
    void sound() {
        System.out.println("동물 소리");
    }
}

class Dog extends Animal {
    void bark() {
        System.out.println("멍멍 짖기");
    }
}

다음 코드를 보겠습니다.

Animal animal = new Dog();

animal.sound();
animal.bark(); // 오류 발생

실제 객체는 Dog이지만 변수 타입은 Animal입니다. Animal 클래스에는 bark() 메서드가 없기 때문에 animal.bark()는 바로 호출할 수 없습니다.

주의
부모 타입 변수로 자식 객체를 가리키면, 자식만 가진 고유 메서드는 바로 호출할 수 없습니다.

7. 오버라이딩된 메서드는 실제 객체 기준으로 실행된다

다형성에서 가장 중요한 부분은 오버라이딩된 메서드의 실행 기준입니다.

Animal animal = new Dog();

animal.sound();

animal의 변수 타입은 Animal입니다. 하지만 실제 객체는 Dog입니다. 만약 Dogsound()를 오버라이딩했다면, 실행되는 것은 Dogsound()입니다.

Animal animal = new Dog();
        ↓
animal.sound() 호출
        ↓
실제 객체는 Dog
        ↓
Dog의 sound() 실행

8. 여러 객체를 부모 타입 배열로 다루기

다형성을 사용하면 서로 다른 자식 객체들을 부모 타입 배열에 담을 수 있습니다.

public class Main {
    public static void main(String[] args) {
        Animal[] animals = {
            new Dog(),
            new Cat(),
            new Bird()
        };

        for (int i = 0; i < animals.length; i++) {
            animals[i].sound();
        }
    }
}

class Animal {
    void sound() {
        System.out.println("동물 소리");
    }
}

class Dog extends Animal {
    @Override
    void sound() {
        System.out.println("멍멍");
    }
}

class Cat extends Animal {
    @Override
    void sound() {
        System.out.println("야옹");
    }
}

class Bird extends Animal {
    @Override
    void sound() {
        System.out.println("짹짹");
    }
}

실행 결과는 다음과 같습니다.

멍멍
야옹
짹짹

배열 타입은 Animal[] 하나입니다. 하지만 배열 안에는 Dog, Cat, Bird 객체가 함께 들어갈 수 있습니다. 이것이 다형성의 큰 장점입니다.

9. 다형성으로 코드 중복 줄이기

다형성을 사용하면 여러 자식 클래스를 하나의 부모 타입으로 처리할 수 있습니다.

다형성을 사용하지 않은 경우

public static void makeDogSound(Dog dog) {
    dog.sound();
}

public static void makeCatSound(Cat cat) {
    cat.sound();
}

public static void makeBirdSound(Bird bird) {
    bird.sound();
}

각 타입마다 메서드를 따로 만들어야 해서 코드가 반복됩니다.

다형성을 사용한 경우

public static void makeSound(Animal animal) {
    animal.sound();
}

이제 Dog, Cat, Bird 객체를 모두 Animal 타입으로 받아 처리할 수 있습니다.

makeSound(new Dog());
makeSound(new Cat());
makeSound(new Bird());

메서드 하나로 여러 자식 객체를 처리할 수 있기 때문에 코드가 훨씬 유연해집니다.

10. 매개변수에서 다형성 사용하기

다형성은 메서드의 매개변수에서 자주 사용됩니다.

public class Main {
    public static void main(String[] args) {
        printAnimalSound(new Dog());
        printAnimalSound(new Cat());
    }

    public static void printAnimalSound(Animal animal) {
        animal.sound();
    }
}

class Animal {
    void sound() {
        System.out.println("동물 소리");
    }
}

class Dog extends Animal {
    @Override
    void sound() {
        System.out.println("멍멍");
    }
}

class Cat extends Animal {
    @Override
    void sound() {
        System.out.println("야옹");
    }
}

실행 결과는 다음과 같습니다.

멍멍
야옹

printAnimalSound() 메서드는 Animal 타입을 매개변수로 받습니다. 그래서 DogCat 객체를 모두 전달할 수 있습니다.

11. 반환타입에서 다형성 사용하기

다형성은 반환타입에서도 사용할 수 있습니다. 메서드의 반환타입을 부모 타입으로 선언하고, 실제로는 자식 객체를 반환할 수 있습니다.

public class Main {
    public static void main(String[] args) {
        Animal animal = createAnimal("dog");

        animal.sound();
    }

    public static Animal createAnimal(String type) {
        if (type.equals("dog")) {
            return new Dog();
        } else {
            return new Cat();
        }
    }
}

class Animal {
    void sound() {
        System.out.println("동물 소리");
    }
}

class Dog extends Animal {
    @Override
    void sound() {
        System.out.println("멍멍");
    }
}

class Cat extends Animal {
    @Override
    void sound() {
        System.out.println("야옹");
    }
}

실행 결과는 다음과 같습니다.

멍멍

createAnimal() 메서드의 반환타입은 Animal입니다. 하지만 실제로는 조건에 따라 Dog 또는 Cat 객체를 반환할 수 있습니다.

12. 업캐스팅이란?

자식 객체를 부모 타입으로 참조하는 것을 업캐스팅이라고 합니다.

Dog dog = new Dog();

Animal animal = dog;

Dog 객체를 Animal 타입 변수에 저장했습니다. 이것이 업캐스팅입니다.

업캐스팅은 자동으로 이루어질 수 있습니다. 왜냐하면 DogAnimal의 한 종류이기 때문입니다.

Animal animal = new Dog();

13. 다운캐스팅 맛보기

부모 타입으로 참조하던 객체를 다시 자식 타입으로 변환하는 것을 다운캐스팅이라고 합니다.

Animal animal = new Dog();

Dog dog = (Dog) animal;

dog.bark();

animal 변수는 Animal 타입이지만 실제 객체는 Dog입니다. 그래서 (Dog)로 다운캐스팅하면 Dog만 가진 bark() 메서드를 사용할 수 있습니다.

주의
다운캐스팅은 실제 객체 타입이 맞지 않으면 실행 중 오류가 발생할 수 있습니다. 이 부분은 다음 글에서 더 자세히 다루겠습니다.

14. 잘못된 다운캐스팅 예

다음 코드는 위험한 다운캐스팅 예입니다.

Animal animal = new Cat();

Dog dog = (Dog) animal; // 실행 중 오류 가능

변수 타입은 Animal이지만 실제 객체는 Cat입니다. 그런데 Dog로 바꾸려고 했기 때문에 문제가 발생할 수 있습니다.

그래서 다운캐스팅 전에는 실제 객체 타입을 확인해야 합니다. 이때 사용하는 문법이 instanceof입니다. 다음 글에서 이 내용을 자세히 정리하겠습니다.

15. instanceof 맛보기

instanceof는 객체가 특정 타입인지 확인할 때 사용하는 연산자입니다.

Animal animal = new Dog();

if (animal instanceof Dog) {
    Dog dog = (Dog) animal;
    dog.bark();
}

위 코드는 animal이 실제로 Dog 타입인지 확인한 뒤 다운캐스팅합니다. 이렇게 하면 잘못된 형변환으로 인한 오류를 줄일 수 있습니다.

16. 다형성과 오버라이딩의 관계

다형성은 오버라이딩과 함께 사용할 때 강력해집니다. 부모 타입으로 여러 자식 객체를 다루면서, 실제 실행은 각 자식 클래스에서 오버라이딩한 메서드가 담당하기 때문입니다.

부모 타입 변수
        ↓
여러 자식 객체 참조 가능
        ↓
같은 메서드 호출
        ↓
실제 객체의 오버라이딩 메서드 실행

즉, 코드에서는 animal.sound()처럼 같은 호출을 하지만 실제 객체가 Dog인지 Cat인지에 따라 다른 결과가 나옵니다.

17. 다형성 전체 예제

이번에는 회원 등급을 예로 들어 다형성을 사용해보겠습니다.

public class Main {
    public static void main(String[] args) {
        Member[] members = {
            new Member("일반회원"),
            new VipMember("VIP회원", 10),
            new AdminMember("관리자")
        };

        for (int i = 0; i < members.length; i++) {
            members[i].printInfo();
        }
    }
}

class Member {
    String name;

    Member(String name) {
        this.name = name;
    }

    void printInfo() {
        System.out.println(name + "입니다.");
    }
}

class VipMember extends Member {
    int discountRate;

    VipMember(String name, int discountRate) {
        super(name);
        this.discountRate = discountRate;
    }

    @Override
    void printInfo() {
        System.out.println(name + "입니다. 할인율: " + discountRate + "%");
    }
}

class AdminMember extends Member {
    AdminMember(String name) {
        super(name);
    }

    @Override
    void printInfo() {
        System.out.println(name + "입니다. 관리 권한이 있습니다.");
    }
}

실행 결과는 다음과 같습니다.

일반회원입니다.
VIP회원입니다. 할인율: 10%
관리자입니다. 관리 권한이 있습니다.

배열 타입은 Member[] 하나입니다. 하지만 배열 안에는 Member, VipMember, AdminMember 객체가 함께 들어 있습니다. 그리고 printInfo()를 호출하면 각 객체에 맞게 오버라이딩된 메서드가 실행됩니다.

18. 다형성을 사용할 때 장점

다형성을 사용하면 다음과 같은 장점이 있습니다.

  • 여러 자식 객체를 부모 타입 하나로 다룰 수 있습니다.
  • 중복 코드를 줄일 수 있습니다.
  • 새로운 자식 클래스가 추가되어도 기존 코드를 크게 바꾸지 않을 수 있습니다.
  • 오버라이딩과 함께 사용하면 객체마다 다른 동작을 자연스럽게 만들 수 있습니다.
  • 배열, 매개변수, 반환타입에서 유연한 구조를 만들 수 있습니다.

다형성은 객체지향 설계에서 매우 중요한 개념입니다. 나중에 추상 클래스와 인터페이스를 배울 때도 계속 연결됩니다.

19. 다형성 사용 시 자주 하는 실수

다형성을 처음 배울 때는 아래와 같은 실수를 자주 합니다.

실수 문제점 해결 방법
부모 타입 변수로 자식 고유 메서드 호출 부모 타입에 없는 메서드는 호출 불가 다운캐스팅이 필요한지 확인
실제 객체 타입을 무시하고 다운캐스팅 실행 중 오류 가능 instanceof로 타입 확인
변수 타입 기준으로 메서드가 실행된다고 착각 오버라이딩 메서드 실행 흐름을 오해 실제 객체 기준으로 실행된다고 기억
오버로딩과 오버라이딩 혼동 메서드 선택 기준을 헷갈림 오버라이딩은 상속 관계에서 다시 정의
상속 관계가 아닌데 다형성 사용 시도 부모-자식 관계가 아니면 참조 불가 상속 관계인지 먼저 확인

20. 직접 연습해보기

아래 코드를 직접 작성하고 실행해보세요.

public class Main {
    public static void main(String[] args) {
        Product[] products = {
            new Product("일반 상품", 10000),
            new DiscountProduct("할인 상품", 20000, 3000),
            new EventProduct("이벤트 상품", 15000)
        };

        for (int i = 0; i < products.length; i++) {
            products[i].printPrice();
        }
    }
}

class Product {
    String name;
    int price;

    Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

    void printPrice() {
        System.out.println(name + " 가격: " + price);
    }
}

class DiscountProduct extends Product {
    int discountAmount;

    DiscountProduct(String name, int price, int discountAmount) {
        super(name, price);
        this.discountAmount = discountAmount;
    }

    @Override
    void printPrice() {
        System.out.println(name + " 할인 가격: " + (price - discountAmount));
    }
}

class EventProduct extends Product {
    EventProduct(String name, int price) {
        super(name, price);
    }

    @Override
    void printPrice() {
        System.out.println(name + " 이벤트 가격: " + (price / 2));
    }
}

실행 결과는 다음과 같습니다.

일반 상품 가격: 10000
할인 상품 할인 가격: 17000
이벤트 상품 이벤트 가격: 7500

21. 이번 글 정리

이번 글에서는 객체지향 핵심 개념인 다형성에 대해 정리했습니다. 핵심 내용은 다음과 같습니다.

  • 다형성은 하나의 타입으로 여러 형태의 객체를 다룰 수 있는 특성이다.
  • 자바에서는 부모 타입 변수로 자식 객체를 참조할 수 있다.
  • Animal animal = new Dog(); 형태가 다형성의 기본 예이다.
  • 부모 타입 변수로는 부모 클래스에 정의된 멤버만 바로 호출할 수 있다.
  • 오버라이딩된 메서드는 실제 객체 타입을 기준으로 실행된다.
  • 서로 다른 자식 객체들을 부모 타입 배열에 담을 수 있다.
  • 메서드 매개변수와 반환타입에서도 다형성을 사용할 수 있다.
  • 자식 객체를 부모 타입으로 참조하는 것을 업캐스팅이라고 한다.
  • 부모 타입을 다시 자식 타입으로 변환하는 것을 다운캐스팅이라고 한다.
  • 다운캐스팅은 실제 객체 타입이 맞지 않으면 오류가 발생할 수 있다.
  • 다형성은 오버라이딩, 추상 클래스, 인터페이스와 깊게 연결된다.

한 줄 요약
다형성은 부모 타입으로 여러 자식 객체를 다루고, 실제 객체에 맞는 오버라이딩 메서드가 실행되도록 해주는 객체지향 핵심 개념입니다.

다음 글 예고
다음 글에서는 [Java 개념노트 33] 참조변수의 형변환과 instanceof 이해하기라는 주제로 업캐스팅, 다운캐스팅, 타입 확인 방법을 더 자세히 정리해보겠습니다.

GWDEVELBlog Java 개념노트 시리즈