다형성

김헌규·2025년 6월 17일
post-thumbnail


🔁 다형성이란?

사용 방법은 동일하지만 다양한 객체를 이용해서 다양한 실행 결과가 나오도록 하는 성질이다. 다시 말하자면 하나의 타입에 여러 객체를 대입함으로써 다양한 기능을 수행할 수 있다는 것이다.


그러면 어떤 방식으로 자바에서는 다형성을 구현하는지 알아보자.


🔄 자동 타입 변환

우선 다형성을 공부하기 위해서는 자동 타입 변환이라는 개념을 이해해야 한다. 자동 타입 변환이란, 프로그램 실행 도중에 자동적으로 타입 변환이 일어나는 것을 말한다. 타입 변환이라고 하면 자바를 배우면서 기본 타입 변환에 대해서 배운 적이 있을 것이다. 이와 마찬가지로 클래스도 타입 변환을 할 수 있는데 그것은 상속 관계를 통해 타입 변환을 할 수 있다.

자동 타입 변환은 상속 관계에서 부모 타입 변수에 자식 타입을 할당하게 되면 발생한다.

public class Parent {
	...
}

public class Child extends Parent {
	...
}

public class Example {
    public static void main(String[] args) {

        Child child = new Child();
        Parent parent = child;

        System.out.println(child == parent); // true
    }
}

위의 코드에서처럼 자식 타입 변수 child에는 Child 객체의 주소 값이 할당되어 있고 이러한 주소 값을 부모 타입에 할당하면 자동 타입 변환이 일어나게 된다. 그래서 부모 타입 변수 parent에 Child 객체의 주소 값이 할당되게 되어 둘이 같은 주소 값을 가지게 된다는 것이다.

그림으로 보자면 아래와 같다.

여기까지 왔으면 자동 타입 변환이라는 게 상속 관계에서 작동하는 것이라면 부모뿐만이 아닌 조상 클래스와의 관계에서도 가능한지 궁금할 것이다. 결론부터 말하자면 가능하다.

말로 설명하면 이해가 잘 안 갈 수 있으니 코드로 먼저 보여주겠다.


public class GrandParent {
	...    
}

public class Parent extends GrandParent {
	...
}

public class OtherParent extends GrandParent {
	...
}

public class Child extends Parent {
	...
}

public class OtherChild extends OtherParent {
	...
}

public class Example {
    public static void main(String[] args) {

        Parent parent = new Parent();
        OtherParent otherParent = new OtherParent();
        Child child = new Child();
        OtherChild otherChild = new OtherChild();

        GrandParent g1 = parent;
        GrandParent g2 = otherParent;
        GrandParent g3 = child;
        GrandParent g4 = otherChild;

        Parent parent2 = child;
        OtherParent otherParent2 = otherChild;

        // 상속 관계에 있지 않아서 컴파일 에러 발생
        // Parent parent3 = otherChild;
        // OtherParent otherParent3 = child;

    }
}

이 코드에서는 GrandParent 클래스를 기준으로 두 갈래로 상속이 이루어진다.

GrandParent는 모든 클래스의 상위에 있기 때문에 아래에 상속받는 모든 클래스의 객체 주소 값을 GrandParent 타입의 변수에 할당할 수 있다. 또한 상속 관계에 있는 Parent - Child, OtherParent - OtherChild의 경우에도 자동 타입 변환이 가능하다. 하지만 Parent - OtherChild, OtherParent - Child 간의 관계는 상속 관계가 아니기 때문에 자동 타입 변환이 불가능하다.


그렇다면 이렇게 자동 타입 변환으로 부모 타입에 자식 객체를 할당할 경우에는 부모와 자식의 모든 필드와 메소드에 접근이 가능할까? 코드로 알아보자.

public class Parent {

    String name;
    int age;

    public Parent(String name, int age) {
        this.name = name;
        this.age = age;
    }

    void method1() {
        System.out.println("부모 메소드1");
    }

    void method2() {
        System.out.println("부모 메소드2");
    }
    
    void method3() {
    	System.out.println("부모 메소드3");
    }
}

public class Child extends Parent {

    String name;
    int age;
    int height;

    public Child(String parentName, int parentAge, String name, int age, int height) {
        super(parentName, parentAge);
        this.name = name;
        this.age = age;
        this.height = height;
    }


    @Override
    void method2() {
        System.out.println("자식 메소드2");
    }

    void method3() {
        System.out.println("자식 메소드3");
    }
}

public class Example {
    public static void main(String[] args) {

        Child child = new Child("철수 부모", 42, "철수", 18, 178);

        Parent parent = child;

        System.out.println(parent.name); // 철수 부모
        System.out.println(parent.age); // 42
        // System.out.println(parent.height); 참조 불가능
        System.out.println(child.height); // 178

        parent.method1(); // 부모 메소드1
        parent.method2(); // 자식 메소드2
        parent.method3(); // 자식 메소드3
        // parent.method4(); 호출 불가능
    }
}

코드로 작성해 보니, 자식 객체를 참조하고 있어도 자동 타입 변환을 통해 부모 타입의 변수로는 부모 클래스에 정의된 필드와 메소드에만 직접 접근할 수 있다. 즉, 참조 변수의 타입이 접근 가능한 멤버의 범위를 결정한다.

하지만 여기서 한 가지 흥미로운 점은, parent 참조 변수로 method2()method3()를 호출했을 때 출력 결과는 자식 클래스의 메소드가 실행된다는 것이다. 이는 동적 바인딩 때문이다.

동적 바인딩(Dynamic Binding)이란, 자바는 메소드 호출 시점에 참조 변수의 타입이 아니라 실제 객체의 타입을 기준으로 어떤 메소드를 실행할지 결정하는 데 이를 동적 바인딩이라고 한다. 동적 바인딩은 객체지향을 지원하는 언어에서는 대부분 지원하는 기능이다.

이어서 method2()의 경우, 자식 클래스에서 @Override 애너테이션을 사용하여 부모의 메소드를 재정의했고, 자바는 이처럼 오버라이딩된 메소드에 대해 동적 바인딩을 적용하므로, 실행 시점에 실제 객체가 자식 클래스일 경우 자식 클래스의 메소드가 호출된다.

또한 method3()도 마찬가지다. @Override 애너테이션은 생략되었지만, 자식 클래스에서 부모와 같은 시그니처(메소드 이름, 매개변수, 반환형)로 정의되었기 때문에 자동으로 오버라이딩 관계가 성립하고, 동적 바인딩에 의해 자식 클래스의 method3()가 호출된다.

여기서 메소드 재정의를 할 때 @Override를 사용해도 재정의가 되고 사용하지 않고 같은 시그니처로 정의하면 재정의가 가능하다는 것을 알게 되었다.

그러면 굳이 @Override를 사용하지 않아도 되지 않나라고 생각이 들 것이다. @Override를 사용해서 재정의를 하게 된다면 해당 메소드가 정확히 부모 클래스의 메소드를 재정의하고 있는지 컴파일러가 검사를 한다. 검사를 통해 개발자가 메소드를 재정의하면서 오타를 내어도 새로운 메소드로 인식하지 않고 오류를 발생시켜 개발자의 실수를 줄여준다.

또한 협업 시에는 수많은 메소드 중 어떤 것이 재정의된 메소드인지 바로 확인하는 것이 중요하다. @Override가 붙어 있으면 해당 메소드가 부모 클래스의 메소드를 오버라이드 한 것임을 명확히 알 수 있어 리팩토링이나 디버깅 시 큰 도움이 된다.

따라서 @Override는 단순한 문법 장식이 아니라, 코드의 안전성과 가독성을 높여주는 중요한 도구이기 때문에 사용하는 것이 좋다.



🧪 필드의 다형성

앞서 자동 타입 변환에 대해서 알아보았다. 자식 객체를 직접 사용해도 되는데, 왜 굳이 부모 타입으로 선언할까? 그것은 다형성을 구현하기 위해서이다. 필드의 타입을 부모 타입으로 선언하면 변수에 다양한 자식 객체가 올 수 있기 때문에 결과가 달라진다.

이를 한 번 코드로 작성해 보았다.

// 장난감 클래스
public class Toy {

    Battery leftBattery = new Battery("왼쪽", 3);
    Battery rightBattery = new Battery("오른쪽", 4);

    int work() {
        System.out.println("장난감 실행");
        if (!leftBattery.consumption()) { stop(); return 1;}
        if (!rightBattery.consumption()) { stop(); return 2;}

        return 0;
    }

    void stop() {
        System.out.println("장난감 작동 중지");
    }
}

// 배터리 클래스
public class Battery {

    String location;
    int maxLife;
    int currentLife;


    public Battery(String location, int maxLife) {
        this.location = location;
        this.maxLife = maxLife;
    }


    public boolean consumption() {
        ++currentLife;

        if (maxLife > currentLife) {
            System.out.println(location + "남은 배터리 수명 : " + (maxLife - currentLife));
            return true;
        } else {
            System.out.println(location + "배터리 수명이 다됨");
            return false;
        }
    }
}

// 배터리 자식 클래스1
public class DuraCellBattery extends Battery {

    public DuraCellBattery(String location, int maxLife) {
        super(location, maxLife);
    }

    @Override
    public boolean consumption() {
        ++currentLife;

        if (maxLife > currentLife) {
            System.out.println(location + "듀라셀 배터리 남은 수명 : " + (maxLife - currentLife));
            return true;
        } else {
            System.out.println(location + "듀라셀 배터리 수명이 다됨");
            return false;
        }

    }
}

// 배터리 자식 클래스2
public class BexelBattery extends Battery {

    public BexelBattery(String location, int maxLife) {
        super(location, maxLife);
    }


    @Override
    public boolean consumption() {
        ++currentLife;

        if (maxLife > currentLife) {
            System.out.println(location + "벡셀 배터리 남은 수명 : " + (maxLife - currentLife));
            return true;
        } else {
            System.out.println(location + "벡셀 배터리 수명이 다됨");
            return false;
        }
    }
}

// 실행 클래스
public class Example {
    public static void main(String[] args) {
        Toy toy = new Toy();

        for (int i = 1; i < 6; i++) {
            int problemLocation = toy.work();

            switch (problemLocation) {
                case 1:
                    System.out.println("왼쪽 배터리 듀라셀 배터리로 교체");
                    toy.leftBattery = new DuraCellBattery("왼쪽", 6);
                    break;

                case 2:
                    System.out.println("오른쪽 배터리 벡셀 배터리로 교체");
                    toy.rightBattery = new BexelBattery("오른쪽", 5);
                    break;
            }

            System.out.println("------------");
        }
    }
}
// 출력 결과

장난감 실행
왼쪽남은 배터리 수명 : 2
오른쪽남은 배터리 수명 : 3
------------
장난감 실행
왼쪽남은 배터리 수명 : 1
오른쪽남은 배터리 수명 : 2
------------
장난감 실행
왼쪽배터리 수명이 다됨
장난감 작동 중지
왼쪽 배터리 듀라셀 배터리로 교체
------------
장난감 실행
왼쪽듀라셀 배터리 남은 수명 : 5
오른쪽남은 배터리 수명 : 1
------------
장난감 실행
왼쪽듀라셀 배터리 남은 수명 : 4
오른쪽배터리 수명이 다됨
장난감 작동 중지
오른쪽 배터리 벡셀 배터리로 교체
------------

이 코드에서는 다형성을 표현하기 위해 Toy 클래스 내의 왼쪽 배터리와 오른쪽 배터리를 Battery 타입으로 선언했다. 그리고 Toy 객체가 생성될 때는 각각 기본 Battery 객체를 할당했다.

이후 실행 코드에서는 Toy 객체의 work() 메서드를 반복적으로 호출함으로써 배터리 수명이 소모되도록 하였고 수명이 다한 경우에는 각각 DuraCellBattery, BexelBattery 객체로 교체해 주었다. 그 결과, 실행 초반에는 기본 Battery 객체가 사용되다가 중간부터는 자식 클래스인 DuraCellBatteryBexelBattery의 오버라이딩된 consumption() 메서드가 실행되는 것을 확인할 수 있다.

이처럼 부모 타입의 필드에 자식 객체를 할당할 수 있는 것은 자동 타입 변환(업캐스팅) 덕분이며 그로 인해 런타임 시점에 실제 객체 타입에 따라 다른 동작이 발생하는 다형성이 구현된다.

이것이 바로 필드의 다형성이다.



🎯 매개변수의 다형성

자동 타입 변환은 필드의 다형성뿐만 아니라 메소드의 매개변수의 다형성에도 활용이 된다.

아래의 예제를 통해 매개변수의 다형성을 살펴보자.


// 군인 클래스
public class Soldier {

    void fight(Gun gun) {
        gun.shoot();
    }
}

// Gun 클래스
public class Gun {
    String name;

    public Gun(String name) {
        this.name = name;
    }

    void shoot() {
        System.out.println("총을 쐈습니다!");
    }

}

// AK47 클래스(자식 클래스)
public class AK47 extends Gun {

    public AK47() {
        super("AK47");
    }

    @Override
    void shoot() {
        System.out.println("AK47로 쐈습니다!");
    }
}

// M16 클래스(자식 클래스)
public class M16 extends Gun {

    public M16() {
        super("M16");
    }

    @Override
    void shoot() {
        System.out.println("M16으로 쐈습니다!");
    }
}

// 실행 코드
public class Example {
    public static void main(String[] args) {
        Soldier soldier = new Soldier();

        AK47 ak47 = new AK47();
        M16 m16 = new M16();

        soldier.fight(ak47);
        soldier.fight(m16);
    }
}
// 실행 결과
AK47로 쐈습니다!
M16으로 쐈습니다!

코드를 보면 Soldier 클래스의 fight() 메서드의 매개변수를 Gun 타입으로 선언했다. 이는 Gun 클래스의 자식 클래스인 AK47M16 객체도 전달할 수 있다는 의미이다. 이처럼 부모 타입으로 매개변수를 정의하면 다양한 자식 객체를 전달할 수 있어 동일한 메서드 호출이지만 실행 결과는 객체의 실제 타입에 따라 다르게 동작하게 된다.

이것이 바로 매개변수의 다형성이다.





이때까지 다형성에 대해서 알아보았다. 하지만 다형성과 관련해서는 주의해야 할 점도 있다. 바로 강제 타입 변환이다.

🛠️ 강제 타입 변환

강제 타입 변환은 부모 타입을 자식 타입으로 변환하는 것을 말한다. 즉, 다운 캐스팅이라고 보면 된다.

하지만 모든 부모 타입이 자식 타입으로 강제 타입 변환을 할 수는 없으며 자식 타입이 부모 타입으로 자동 타입 변환한 후 다시 자식 타입으로 강제 타입 변환할 때 가능하다.

자식 타입으로 강제 타입 변환하는 방법은 아래와 같다.

자식타입 변수 = (자식타입) 부모타입;

이 문법은 기본 타입에서 큰 범위에서 작은 범위로 강제 변환할 때 사용했던 방식과 유사하다. 하지만 객체 다형성에서는 오히려 작은 범위(부모 타입)에서 큰 범위(자식 타입)로 변환된다는 점에서 차이가 있다. 하지만 다형성에서의 강제 타입 변환은 단순한 타입 크기의 개념보다는 객체의 실제 타입에 맞게 명시적으로 변환한다는 점이 더 중요하다.

다음으로 강제 타입 변환이 어떻게 쓰이는지 코드로 한 번 살펴보자.

public class Parent {

    String name;

    public Parent(String name) {
        this.name = name;
    }

    public void method1() {
        System.out.println("Parent-method1");
    }
}

public class Child extends Parent {

    String name;

    public Child(String parentName, String childName) {
        super(parentName);
        this.name = childName;
    }

    @Override
    public void method1() {
        System.out.println("Child-method1");
    }

    public void method2() {
        System.out.println("Child-method2");
    }
}

public class Example {
    public static void main(String[] args) {
        Parent parent = new Child("부모", "자식");
        System.out.println(parent.name); // 부모
        parent.method1(); // Child-method1;
        // parent.method2(); 접근 불가

        // 강제 타입 변환
        Parent parent2 = new Child("부모2", "자식2");
        Child child2 = (Child) parent2;
        child2.method1(); // Child-method1
        child2.method2(); // Child-method2 - 접근 가능
        
        // 처음부터 자식 타입에 부모를 다운캐스팅하여 할당하려고 하면 ClassCastException이 발생.
        // Child child = (Child) new Parent("마더");
        // System.out.println(child.name);
        // child.method1();
        // child.method2();
    }
}

코드를 보면 부모 타입에 자식 객체를 할당하면 부모 타입에 있는 필드와 메소드에만 접근이 가능하다. 그렇기 때문에 parent.method2();로 자식 메소드에 접근할 수 없다. 하지만 다시 다운 캐스팅으로 강제 타입 변환을 한다면 자식 메소드에 접근 가능하다.

반면, 처음부터 자식 타입에 부모를 다운캐스팅하여 할당한 후 자식 클래스의 필드와 메소드에 접근하려고 하면 ClassCastException이 발생하게 된다. 왜냐하면 new Parent로 생성된 객체는 자식 객체가 아닌 부모 객체이다. 그러므로 자식 객체는 존재하지 않기 때문에 예외가 발생하는 것이다.

이러한 점을 주의해서 다형성을 구현할 때 강제 타입 변환을 잘 사용해야 한다.



🔍 객체 타입 확인

앞서 강제 타입 변환은 객체가 실제로 자식 타입인 경우에만 안전하게 사용할 수 있다는 점을 배웠다. 그렇다면 부모 타입 변수가 참조하는 객체가 실제로 자식 객체인지 확인하는 방법은 없을까? 당연히 java와 같은 객체지향 언어에서는 이러한 기능을 가지고 있다. 그것은 instanceof 연산자를 사용하는 것이다.

instanceof 연산자를 사용하는 방법은 다음과 같다.

boolean result = 좌항(객체) instanceof 우항(타입)

이렇게 좌항의 객체가 우항의 인스턴스이면, 즉 우항의 타입으로 객체가 생성되었다면 true를 리턴하고 그렇지 않으면 false를 리턴하는 방식으로 사용한다.

instanceof 연산자는 주로 매개값의 타입을 조사할 때 사용된다. 메소드 내에서 강제 타입 변환이 필요할 경우 반드시 매개 값이 어떤 객체인지 instanceof 연산자로 확인하고 안전하게 강제 타입 변환을 할 수 있다.

다음 예제를 통해 instanceof 연산자가 어떻게 강제 타입 변환 전에 안전성을 확보해주는지 확인해 보자.

// 부모 클래스
public class Parent {

    String name;

    public Parent(String name) {
        this.name = name;
    }

    public void method1() {
        System.out.println("Parent-method1");
    }
}

// 자식 클래스
public class Child extends Parent {

    String name;

    public Child(String parentName, String childName) {
        super(parentName);
        this.name = childName;
    }

    @Override
    public void method1() {
        System.out.println("Child-method1");
    }

    public void method2() {
        System.out.println("Child-method2");
    }
}

// 실행 클래스
public class Example {
	// instanceof 연산자 메소드
    public static void method1(Parent parent) {
        if (parent instanceof Child) {
            Child child = (Child) parent;
            System.out.println(child.name);
            System.out.println("변환 성공");
        } else {
            System.out.println("변환 실패");
        }
    }

    public static void main(String[] args) {
        Parent parent = new Child("부모", "자식");
        method1(parent); // 변환 성공
    }
}

이처럼 instanceof 연산자는 객체의 실제 타입을 확인할 수 있게 해주며 강제 타입 변환이 필요한 상황에서 안전성을 확보하는 중요한 도구이다.

특히, 다형성이 적용된 코드에서는 부모 타입의 매개변수나 변수에 자식 객체가 들어올 수 있기 때문에 잘못된 다운 캐스팅으로 인한 ClassCastException을 방지하기 위해 반드시 사용해야 할 연산자이다.

즉, instanceof를 통해 타입을 확인하고 나서 강제 타입 변환을 수행하는 것이 객체지향 프로그래밍에서 안정적이고 견고한 코드를 작성하는 방법이다.



참조
https://kephilab.tistory.com/59
혼자 공부하는 자바 - 신용권 (한빛미디어)

profile
꾸준하게 가자

0개의 댓글