Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
JAVA/Java Intermediate(OOP)

10. 다형성 - 캐스팅과 오버라이딩

by Toughie 2024. 3. 17.

 

 

다형성(Polymorphism)은 다양한 형태를 의미한다.

프로그래밍에서 다형성은 한 객체가 여러 타입의 객체로 취급될 수 있음을 의미한다.

(기본 타입은 해당 타입으로 고정되어 있음을 생각해보자_ int a, String b)

 

크게는 다형적 참조, 메서드 오버라이딩이 대표적인 다형성의 예시이다.


다형적 참조

객체를 다양한 타입으로 참조할 수 있음을 의미한다. 

(인터페이스를 통한 다형적 참조도 있지만 여기서는 상속관계의 다형적 참조만 다룬다.)

 

지난 시간 상속을 배우면서 핵심 내용을 기억하면 쉽게 이해할 수 있다.

'부모를 상속한 자식 클래스 인스턴스가 생성되면 자식뿐만 아니라 부모의 객체도 함께 생성된다.'

 

또한 c언어의 포인터의 개념을 안다면, 좀 더 잘 이해할 수 있다.

결국 클래스 객체의 참조변수는 포인터로 바라볼 수 있기 때문이다. 

- int타입의 포인터는 메모리의 시작 주소로부터 4바이트만큼의 데이터를 읽는다.

- char 타입의 포인터는 메모리의 시작 주소로부터 1바이트만큼의 데이터를 읽는다.

참조변수는 시작 주소를 알고 있고, 참조 변수의 타입은 '어디까지 읽을까?'에 대한 정보를 담고 있다고 볼 수 있다. 

이 개념은 다형적 참조에서 필요한 업캐스팅과 다운캐스팅 개념을 이해할 때 도움이 될 것이다. 

 

먼저 간단하게 부모 클래스와 자식 클래스를 만들고 상속 관계를 파악해 보자.

class Parent {

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

class Child extends Parent {

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

 

다른 곳에서 Child 객체를 생성한다 생각해 보자.

이때 참조 변수를 Child 타입으로 만들 수도 있지만, Parent 타입으로 만들 수도 있다.

왜 이게 가능한가? 자식 객체를 생성하면 부모 객체도 무조건 함께 생성되기 때문이다.(부모의 생성자가 무조건 호출된다.)

즉 자식 객체의 참조변수의 타입을 부모 타입으로도 설정할 수 있다는 것이다.

 

기본 타입의 경우에는 int, String, double 등으로 고정되어 있었다면, 상속관계가 있는 클래스 객체는

다양한 타입으로 참조할 수 있구나? 이것이 바로 다형성의 한 예시이다.


업캐스팅(Up Casting)

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

캐스팅은 타입을 변경한다는 것을 의미한다. (int를 double로 변경하는 것도 타입캐스팅이었음을 생각해 보자.)

 

Parent upCasting = new Child();
upCasting.parentMethod();

 

위와 같이 업캐스팅이 되어 있는 상황에서, 멤버 변수에 접근하거나 메서드를 호출하면 어떻게 될까?

'부모 클래스'에 존재하는 멤버 변수, 메서드에만 접근이 가능하다.

왜? 현재 참조변수 upCasting은 Parent타입이다. 즉 new Child()를 통해서 할당받은 메모리의 주소를 알고 있지만,

거기서 Parent 객체의 데이터까지만 읽을 수 있는 것이다. (Parent 클래스에는 자식 클래스에 대한 정보가 전혀 없다.)

 

그런데 코드를 보면 명시적으로 캐스팅을 해주지 않고 있다. 

원래 아래와 같이 해줘야 하지 않나?

Parent upCasting = (Parent) new Child();

 

자식 객체를 부모 타입으로 참조하는 경우는 자동으로 업캐스팅이 되기에, 생략하는 것이 권장된다.

왜? 이것은 무조건 가능하기 때문이다.

자식 클래스 객체를 생성하면 부모 객체도 무조건 같이 생성되기 때문에 가리킬 대상이 항상 존재하는 것이다.

 

ok. 그러면 이제 자식 객체의 멤버 변수와 메서드를 활용하고 싶어졌다.

그러면 자식 객체를 자식 타입으로 참조해야 한다. 

이를 위해서는 다운캐스팅을 해줘야 한다. (부모 타입 -> 자식 타입)

 

다운 캐스팅(Down Casting)

Parent upCasting = new Child();
Child downCasting = (Child) upCasting;

downCasting.childMethod();

 

기껏 자식 객체를 만들어 놨지만, 참조를 부모타입으로 해서 (upCasting 변수) 부모 객체에만 접근할 수 있었다.

그래서 이를 자식 타입으로 참조할 수 있도록 명시적으로 타입 캐스팅을 해주었다.

이제 downCasting이라는 참조변수는 Child타입이기에 온전히 자식 객체에 접근할 수 있다. (물론 부모 객체도!)

 

위와 같은 과정이 번거로우면 아래와 같이 일시적으로 다운캐스팅 해주는 방법도 있다.

Parent upCasting = new Child();
((Child) upCasting).childMethod();

 

괄호가 왜 두 개나 붙어 있을까?

캐스팅 보다. 문법의 우선순위가 더 높기 때문에 연산 순위를 바꾸기 위해서 괄호로 다시 감싸준 것이다.

이 경우 upCasting의 타입이 Child로 바뀌는 것은 아니다.(그대로 Parent 타입이다.) 

말 그대로 일시적으로 자식 객체에 접근이 필요한 경우 위와 같이 할 수 있다는 말이다.

 

그런데 업캐스팅은 자동으로 되고, 생략해도 된다고 했는데

왜 다운캐스팅은 괄호를 통해서 꼭 명시적으로 해줘야 할까?

 

다운캐스팅이 될 수도 있고 안 될 수도 있기 때문이다..!

 

public class MyMain {
    public static void main(String[] args) {
        Parent parent1 = new Child();
        Child child1 = (Child) parent1;

        Parent parent2 = new Parent();
        Child child2 = (Child) parent2;
        child2.childMethod(); //런타임 에러 ClassCastException
        //당연히 child의 인스턴스가 없기 때문에..
    }
}

 

Parent 타입의 참조변수는 Parent 객체를 참조할 수도, Child 객체를 참조할 수도 있다.

이것을 컴파일러는 모른다.. 런타임에 메모리 할당을 받고 참조를 해봐야 안다. 

 

먼저 parent1 참조변수를 보자.

Child객체를 참조하고 있고, child1로 명시적 다운캐스팅을 해주고 있다.

이 경우에는 자식 객체를 참조하는 방식을 바꿨기에 아무런 문제가 없다. (메모리에 이미 부모와 자식 객체가 둘 다 존재한다.)

 

하지만 parent2를 보자.

Parent 타입으로 Parent 객체를 참조하고 있다가,

자식 타입으로 명시적 다운캐스팅을 시도했다.

컴파일러는 런타임이 되기 전까지 명확히 부모, 자식 어떤 객체를 참조하는지 모르기 때문에

프로그램이 실행된 후 부모타입의 객체를 Child타입으로 참조하려고 할 것이다.

그러면 당연히 어떻게 되겠는가.. child의 인스턴스가 메모리에 없는데 이를 참조하려고 하니 런타임 에러가 발생하는 것이다.

즉 ClassCastException을 던지게 된다. 

 

위와 같이 다운캐스팅은 업캐스팅과 달리 제대로 될 수도 안 될 수도 있는 위험성이 존재한다.

컴파일 차원에서 거를 수가 없기 때문에, 개발자가 명시적으로 적어줌으로써 위험을 인지하고 책임을 지도록 하는 것이다.

물론 조금 더 안전하게 다운캐스팅을 하는 방법도 있다.


instanceof

a instance of b와 같은 형태로 사용하며, a가 b의 인스턴스인가요?로 해석하면 된다.

자식은 부모타입으로 참조할 수 있지만 , 부모는 자식타입으로 참조할 수 없다. 

    private static void call(Parent parent) {

        //오른쪽 타입에 왼쪽의 인스턴스가 들어갈 수 있는가?
        if (parent instanceof Child) {
            Child child = (Child) parent;
            child.childMethod();
        } else {
            System.out.println("다운캐스팅 조심하세요");
        }
    }

 

위와 같은 함수가 있다. 먼저 가장 상위 클래스인 Parent 참조변수를 파라미터로 받는다.

조건문을 통해서 파라미터가 참조하고 있는 객체가 Child의 객체인 경우에만 다운캐스팅을 통한 작업을 수행한다.

 

Parent param1 = new Child(); 

Parent param2 = new Parent();

 

여기서 param1을 함수에 넘기면 정상적으로 다운캐스팅이 될 것이고,

param2를 함수에 넘기면 else문이 실행될 것이다.

Parent는 Child의 객체가 아니기 때문이다. 

Child param3 = (Child) param2;와 같은 것이 안 되는 것과 완전 동일한 개념이다.

 

a instanceof b는 a가 b로 다운캐스팅 됩니까?라고 쉽게 이해하면 된다.

 

추가적으로 다운캐스팅 코드를 축약해서 아래와 같이 변수를 선언할 수도 있는데,

이는 자바 16부터만 지원하기 때문에 그냥 참고로만 알고 있자.

 

    private static void call(Parent parent) {

        if (parent instanceof Child child) {
            child.childMethod();
        } else {
            System.out.println("다운캐스팅 조심하세요");
        }
    }

 


메서드 오버라이딩

이렇게 업캐스팅과 다운캐스팅을 통한 다형적 참조를 알아봤다. 이제는 메서드 오버라이딩을 알아보자.

메서드 오버라이딩은 상위 클래스의 메서드를 재정의 하는 것이다. 

(부모 클래스의 메서드를 그대로 사용하는 것이 아니라, 필요에 따라 바꿔서 다양하게 사용할 수 있다.)

 

여기서 핵심은 항상 '오버라이딩 된 메서드가 최우선 순위'라는 것이다. 

class Parent {

    public String value = "Parent";

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

class Child extends Parent {
    public String value = "Child";

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

 

위와 같이 부모 클래스를 상속하고 부모의 메서드를 자식에서 오버라이딩을 해줬다.

 

    public static void main(String[] args) {
        Child child = new Child();
        System.out.println(child.value); //child

        Parent parent = new Parent();
        System.out.println(parent.value);

        Parent poly = new Child();
        System.out.println(poly.value); //parent 변수는 오버라이딩 안 됨
        poly.method(); //Child.method //메서드는 오버라이딩 된 것이 절대적 우선순위!!
    }

 

멤버 변수에 접근할 때는, 참조 타입이 우선순위가 된다. (멤버 변수는 오버라이딩도 되지 않는다.) 

즉 Child 객체를 Child 타입으로 참조하는 경우, Child 객체에 먼저 가서 찾고, 없으면 부모 객체로 가서 찾는다.

 

하지만 메서드는 다를 수 있다.

부모 타입으로 자식 객체를 참조하면 부모 클래스의 메서드를 호출할 것 같지만,

(물론 자식 객체에 해당 메서드가 없다면 부모 객체로 찾아가서 해당 메서드를 호출하겠지만)

해당 메서드가 오버라이딩 된 경우 오버라이딩 된 메서드가 호출된다. 

 

즉 메서드는 오버라이딩 된 것이 절대적 우선순위를 가지고 호출된다는 것을 기억하자.

(애초에 오버라이딩 했다는 것이 기존 메서드를 쓰는 대신 입맛에 맞게 변경했다는 뜻이니 납득이 된다.)

혹 오버라이딩 한 함수 내부에서 원본 메서드도 호출하고 싶다면 super를 사용하면 될 것이다.

 


 

이렇게 다형성의 기초 개념인 다형적 참조와 오버라이딩에 대해 알아보았다.

이 개념을 처음 접했다면 불편하게 그냥 본인 타입으로 참조하면 되지 왜 업캐스팅 하고 다운캐스팅 하고 하는 거지?라는 의문이 들 수 있다.

하지만 이는 프로그래밍을 굉장히 유연하고 확장성 있게 만드는데 핵심 개념이 된다.

즉 한 가지 타입에만 의존하지 않고, 다양한 타입을 담을 수 있는 그릇을 만들 수 있다 정도로 생각해도 좋다.

(ios 개발을 했을 때 프레임워크를 살펴보면 정말 다양한 상속 관계와 프로토콜을 통한 다형적 참조가 이루어지고 있었다.)

 

이는 쉽게 말하면 변수 하나로 수많은 객체를 참조할 수 있다는 의미이다.

만약 상속의 상속의 상속의 상속.. 이 이루어져서 수많은 클래스들이 있는 경우 

여기서 어떤 객체가 올 지 모르겠다면? 그냥 Parent 변수를 선언해 두면 될 것이다. 그럼 모든 자식 객체를 참조할 수 있을 것이고,

필요에 따라 다운캐스팅을 하면 되기 때문이다.

 

하지만 자바에서 상속은 단일상속만 지원되기 때문에, 다형성을 더 파워풀하게 사용하려면 

인터페이스 개념을 알아야 한다. (인터페이스는 Swift에서 프로토콜과 동일한 개념이라고 보면 된다.)

인터페이스는 다중 상속 (다중 구현)이 가능하다. 

 

본 포스팅에서 Parent 타입의 참조변수는 Parent를 상속한 수많은 자식 객체들을 참조할 수 있었던 것처럼

특정 인터페이스 타입의 참조변수는 해당 인터페이스를 구현한 수많은 객체들을 참조할 수 있다.

이는 인터페이스를 배우면서 더 자세히 알아보도록 하자.


학습 출처

김영한의 실전 자바 - 기본편

 

'JAVA > Java Intermediate(OOP)' 카테고리의 다른 글

11. 추상 클래스와 인터페이스  (0) 2024.03.19
8. 상속, 오버라이딩, super  (0) 2024.03.11
7. final  (0) 2024.03.07
6. static 정적  (3) 2024.03.07
5. 접근 제어자와 캡슐화  (0) 2024.03.03