Java 27

솔라, 오버라이딩-다형성의 숨겨진 원리를 꿰뚫다

부모 타입 변수에 자식 객체를 담을 수 있다는 말은 이해돼도, 실제로 어떤 메서드가 실행되고 자식 고유 기능은 어떻게 쓰는지 막힌다.

근거 · 교안 p194-p202

솔라, 오버라이딩-다형성의 숨겨진 원리를 꿰뚫다 대표 이미지

1장: 재정의? 아니면 그냥 대체?

1장. 재정의? 아니면 그냥 대체?

솔라의 손가락이 노트북 화면의 한 문장을 가리킨 채 멈춰 있었다.

오버라이딩: 부모 클래스의 메서드를 자식 클래스에서 같은 이름으로 다시 정의하는 것.

솔라는 눈을 가늘게 뜨고 ‘다시 정의’라는 단어를 몇 번이고 곱씹었다. 마치 오래된 카세트테이프 위에 새 노래를 녹음하는 장면이 떠올랐다. 원래 있던 노래는 지워지고, 그 자리에 새로운 노래가 완전히 들어서는 모습. 솔라에게 ‘재정의’는 그런 느낌이었다. 원래 것은 사라지고 새것으로 교체되는 것.

“언니.”

솔라가 거실 소파에 앉아 책을 읽고 있던 루나를 불렀다.

“이것 좀 봐봐. 자식이 부모의 행동을 ‘다시 정의’한다는 게, 그냥 부모 거를 지우고 자기 걸로 바꿔치기한다는 뜻 아니야? 그럼 부모한테 물려받은 기능은 그냥 사라지는 거잖아.”

솔라의 목소리에는 명쾌한 결론을 내렸다는 확신이 섞여 있었다.

루나는 잠시 책에서 눈을 떼고 솔라가 가리키는 화면을 보았다. 그리고는 말없이 자신의 노트북을 열어 간단한 코드를 작성하기 시작했다.

class Vehicle {
    void move() {
        System.out.println("차가 이동합니다.");
    }
}

class Car extends Vehicle {
    void move() {
        System.out.println("자동차가 달립니다.");
    }
}

“자, 여기 ‘차량’이라는 부모가 있고, 그걸 상속받은 ‘자동차’라는 자식이 있어. 둘 다 move라는 행동을 할 수 있지.”

루나가 말했다. 솔라는 코드를 보며 고개를 끄덕였다. Car 클래스가 Vehiclemove 메서드를 똑같은 이름으로 다시 만들었다. 솔라가 생각한 ‘오버라이딩’의 전형적인 모습이었다.

“그럼 이제 Car 객체를 만들어서 움직여보자. 어떤 문장이 출력될까?”

루나가 물으며 코드를 추가했다.

public class Main {
    public static void main(String[] args) {
        Car myCar = new Car();
        myCar.move();
    }
}

“당연히 ‘자동차가 달립니다’가 나오겠지. Carmove 메서드가 Vehiclemove를 덮어썼으니까.”

솔라는 자신만만하게 대답했다. 루나는 말없이 코드를 실행했다. 실행 결과는 솔라의 예상과 정확히 일치했다.

자동차가 달립니다.

“거봐. Vehicle의 ‘차가 이동합니다’는 아예 나오지도 않잖아. 흔적도 없이 사라진 거지.”

솔라가 의기양양하게 말했다. 자신의 ‘대체’ 이론이 증명되었다고 생각하는 듯했다.

루나는 솔라의 말을 잠자코 듣고 있다가, 조용히 물었다.

“정말 사라진 걸까? 아니면 그냥 Car의 새로운 행동에 가려져서 보이지 않는 건 아닐까?”

“가려졌다고? 그게 무슨 말이야?”

솔라는 이해할 수 없다는 표정을 지었다. 루나는 다시 Car 클래스의 코드를 수정했다. 아주 작은 한 줄을 추가했을 뿐이었다.

class Car extends Vehicle {
    void move() {
        super.move(); // 이 줄을 추가했어.
        System.out.println("자동차가 달립니다.");
    }
}

super?”

솔라가 처음 보는 키워드에 고개를 갸웃거렸다. 루나는 별다른 설명 없이 다시 main 메서드를 실행했다. 그러자 콘솔 창에 새로운 결과가 나타났다.

차가 이동합니다.
자동차가 달립니다.

솔라의 눈이 동그래졌다. 사라진 줄로만 알았던 부모의 메시지, ‘차가 이동합니다’가 떡하니 먼저 출력된 것이다. 그 뒤를 이어 자식의 메시지가 출력되었다. 마치 Carmove가 실행되기 직전에, Vehiclemove가 먼저 호출된 것처럼 보였다.

“어…?”

솔라는 잠시 말을 잃었다. 명쾌했던 자신의 이론에 균열이 가는 순간이었다. 부모의 메서드는 사라진 게 아니었다. 분명히 존재했고, 심지어 호출할 수도 있었다.

super는 바로 내 부모를 가리키는 특별한 이름이야. 자식 클래스 안에서 super.move()라고 부르면, 자식이 재정의한 메서드가 아니라 부모가 원래 가지고 있던 메서드를 호출할 수 있어.”

루나의 차분한 설명이 이어졌다.

“오버라이딩은 부모의 메서드를 완전히 지우고 새로 쓰는 게 아니었던 거야. 자식은 자신만의 방식으로 행동을 ‘재정의’하지만, 부모가 물려준 원래 행동도 그대로 간직하고 있었어. 다만 자식의 새로운 행동이 더 우선순위가 높아서, 평소에는 그것만 보였을 뿐이지.”

솔라는 두 개의 실행 결과를 번갈아 보았다. super.move()가 없을 때는 자식의 행동만 보였고, super.move()를 추가하자 숨어있던 부모의 행동이 드러났다. ‘대체’가 아니라 ‘덧붙이기’ 또는 ‘확장’에 가까웠다.

“아… 그렇구나. 재정의는 덮어쓰는 게 아니라, 일단 내 방식대로 행동하되 필요하면 부모의 방식도 가져다 쓸 수 있는 거였네. 완전히 다른 개념이잖아.”

솔라는 자신의 섣부른 판단이 부끄러워졌다. 동시에 새로운 사실을 깨달은 희열이 느껴졌다. 오버라이딩은 관계를 끊는 행위가 아니라, 관계를 바탕으로 새로운 층을 쌓는 작업이었다.

생각을 정리한 솔라의 눈이 다시 반짝였다. 방금 얻은 지식을 바탕으로 새로운 질문이 떠올랐기 때문이다.

“그럼 언니, 이건 어때? CarVehicle이기도 하니까, Vehicle 타입의 변수에 Car 객체를 담을 수 있잖아. 예를 들어 Vehicle myVehicle = new Car(); 이렇게. 그 다음에 myVehicle.move()를 호출하면… 대체 누가 움직이는 거야? 변수 타입은 Vehicle이니까 ‘차가 이동합니다’가 나올까? 아니면 변수 안에 담긴 실제 객체는 Car니까 ‘자동차가 달립니다’가 나올까?”

솔라는 방금 전까지의 혼란을 딛고, 더 깊은 미스터리의 문을 스스로 열어젖혔다. 변수의 껍데기와 그 안의 실제 객체, 둘 중 누구의 목소리를 따라야 할까.

2장: 부모의 껍데기, 자식의 동작: 메서드 호출의 비밀

솔라의 질문이 채 끝나기도 전에, 루나는 말없이 노트북 화면의 코드를 수정하기 시작했다. 방금 전까지 Car myCar = new Car();라고 되어 있던 main 메서드의 한 줄이, 솔라의 질문을 그대로 옮겨놓은 듯한 코드로 바뀌었다.

Vehicle myVehicle = new Car();

루나는 이 한 줄 아래에, 솔라가 제시한 두 가지 가능성을 나란히 주석으로 적었다. 마치 시험 문제를 내는 선생님처럼.

// 가능성 1: 변수 타입이 Vehicle이므로, Vehicle의 move()가 실행된다.
// 예상 결과: "차가 이동합니다."

// 가능성 2: 변수 안에 담긴 실제 객체는 Car이므로, Car의 move()가 실행된다.
// 예상 결과: "자동차가 달립니다."

화면에는 이제 명확한 갈림길이 놓여 있었다. 솔라의 머릿속에서 충돌하던 두 가설이 눈앞의 코드로 구체화된 것이다. 하나는 변수의 ‘타입’이라는 껍데기를 따르는 길, 다른 하나는 그 안에 담긴 ‘실제 객체’라는 알맹이를 따르는 길이었다.

“자, 솔라. 네가 보기에 둘 중 어느 쪽이 맞을 것 같아?”

루나의 목소리는 차분했지만, 솔라에게는 마치 정답을 고르라는 압박처럼 느껴졌다. 솔라는 신중하게 코드를 뜯어보았다.

“음… myVehicle이라는 변수는 Vehicle 타입으로 선언됐잖아. 그럼 이 변수를 통해서는 Vehicle 클래스가 가진 능력, 그러니까 move() 메서드만 호출할 수 있는 거 아니야? 마치 Vehicle이라는 리모컨으로는 Vehicle의 기능만 조작할 수 있는 것처럼. 그러니까… 변수 타입을 따라가는 1번이 맞을 것 같아.”

솔라는 자신의 논리에 확신을 가지며 대답했다. 변수의 타입이 그 변수가 할 수 있는 일의 범위를 결정한다는 생각은 꽤 합리적으로 들렸다.

루나는 솔라의 대답에 고개를 긍정도 부정도 하지 않은 채, 조용히 main 메서드의 마지막 줄을 마저 입력하고 실행 버튼을 눌렀다.

public class Main {
    public static void main(String[] args) {
        // 이전 클래스 정의는 그대로...
        
        Vehicle myVehicle = new Car();
        myVehicle.move(); // 과연 결과는?
    }
}

잠시 후, 콘솔 창에 나타난 결과는 솔라의 예상을 완전히 빗나갔다.

자동차가 달립니다.

“어?”

솔라의 입에서 짧은 탄식이 터져 나왔다. 화면에 찍힌 것은 명백히 Car 클래스의 move 메서드가 출력하는 메시지였다. 변수의 껍데기가 아닌, 그 안의 실제 객체가 자신의 목소리를 낸 것이다.

“왜… 왜 이렇게 나와? 변수는 분명 Vehicle인데, 어떻게 Car의 메서드가 실행된 거지?”

솔라는 혼란스러운 표정으로 루나를 보았다. 자신의 논리가 보기 좋게 무너진 순간이었다.

루나는 그제야 입을 열었다.

“솔라, 네 말이 반은 맞아. Vehicle 타입 변수로는 Vehicle 클래스에 정의된 메서드만 ‘호출할 수 있는 것처럼 보여’. 그래서 myVehicle.move() 코드는 아무 문제 없이 작성되지. 만약 Car 클래스에만 있는 drift() 같은 메서드를 myVehicle.drift()라고 호출하려 하면, 그건 컴파일 에러가 날 거야. Vehicle 리모컨에는 drift 버튼이 없으니까.”

루나는 잠시 말을 멈추고 솔라가 이해할 시간을 주었다.

“하지만, 일단 호출이 성공하면, 프로그램이 실행되는 그 순간(런타임)에는 다른 일이 벌어져. 자바는 변수의 타입이 아니라, 그 변수가 실제로 가리키고 있는 객체가 누구인지를 확인해. 그리고 그 실제 객체에 오버라이딩된 메서드가 있으면, 그걸 실행하는 거야.”

루나는 Vehicle myVehicle = new Car(); 코드를 가리켰다.

“이 문장은 ‘Vehicle 타입의 리모컨(myVehicle)으로 Car라는 실제 기계(new Car())를 조종하겠다’는 뜻이야. 리모컨에 move 버튼이 있으니 누를 수는 있어. 하지만 그 버튼을 눌렀을 때 ‘차가 이동’할지 ‘자동차가 달릴지’는 리모컨이 아니라 연결된 기계, 즉 Car가 결정하는 거지.”

솔라의 머릿속에서 조각들이 맞춰지기 시작했다. 변수 타입은 코드를 작성하는 시점에 ‘무슨 버튼을 누를 수 있는지’를 결정하는 제약사항이었다. 하지만 실제로 버튼을 눌렀을 때 어떤 동작이 일어나는지는, 실행 시점에 그 변수가 가리키고 있는 ‘실제 객체’의 타입에 따라 결정되는 것이었다. 같은 myVehicle.move()라는 코드가, myVehicleCar가 담기면 ‘자동차가 달립니다’를, 나중에 Boat가 담기면 ‘보트가 항해합니다’를 출력하게 될 수도 있는 것이다.

“아…! 변수 타입은 컴파일러와 하는 약속이고, 실제 실행은 런타임에 이뤄지는 거구나. myVehicle.move()라는 똑같은 코드가, 그 안에 어떤 자식 객체가 들어있느냐에 따라 전혀 다르게 행동할 수 있다는 거네. 이게… 혹시 다형성이라는 건가?”

솔라는 문득 책에서 봤던 ‘다형성’이라는 단어를 떠올렸다. ‘같은 코드가 실제 객체에 따라 다르게 동작하는 성질’. 추상적인 정의로만 보였던 문장이, 방금 겪은 구체적인 경험과 맞물리며 살아 움직이기 시작했다.

생각이 정리되자 솔라의 눈빛이 다시 장난스럽게 빛났다. 새로운 규칙을 배웠으니, 그 규칙의 한계를 시험해보고 싶어진 것이다.

“좋았어, 언니. 그럼 Car에만 있는 특별한 기능을 한번 써보자. Vehicle 리모컨으로는 안 된다고 했지? 그럼 어떻게 해야 Car만의 고유한 기능을 쓸 수 있는 거야? Vehicle 변수에 들어있는 게 Car라는 건 확실한데, 그 안에 있는 Car의 특별한 기능은 영원히 못 쓰는 건가?”

솔라는 방금 이해한 세상의 규칙 위에서, 또 다른 문을 발견하고 그 손잡이를 잡아 돌리고 있었다. 부모의 껍데기를 쓰고 있는 자식의 고유한 능력은 어떻게 꺼내 쓸 수 있을까. 새로운 질문이 또다시 고개를 들었다.

3장: 고유한 특징 찾기: 자식만의 능력은 어떻게 쓸까?

솔라의 질문에 답하는 대신, 루나는 조용히 코드를 더했다. 먼저 Car 클래스 안에 Vehicle에는 없는 새로운 메서드, openSunroof()를 추가했다. 그리고 Vehicle을 상속받는 또 다른 자식 클래스, Bicycle을 만들고 그 안에는 고유한 기능인 ringBell()을 넣었다. 마지막으로, 이 서로 다른 종류의 ‘탈것’들을 하나의 바구니에 담듯 Vehicle 타입의 배열에 함께 집어넣었다.

화면에는 이제 다양한 개성을 가진 객체들이 ‘Vehicle’이라는 공통의 이름표를 달고 한 줄로 서 있는 모습이 펼쳐졌다.

class Vehicle {
    void move() {
        System.out.println("탈것이 이동합니다.");
    }
}

class Car extends Vehicle {
    @Override
    void move() {
        System.out.println("자동차가 달립니다.");
    }

    void openSunroof() {
        System.out.println("선루프를 엽니다.");
    }
}

class Bicycle extends Vehicle {
    @Override
    void move() {
        System.out.println("자전거가 달립니다.");
    }

    void ringBell() {
        System.out.println("따르릉! 벨을 울립니다.");
    }
}

public class Main {
    public static void main(String[] args) {
        Vehicle[] vehicles = { new Car(), new Bicycle() };

        for (Vehicle v : vehicles) {
            v.move();
            // 여기에서 각자의 고유 기능을 호출하려면?
        }
    }
}

솔라는 루나가 만든 새로운 놀이터를 흥미롭게 바라보았다. ‘Vehicle’이라는 하나의 리모컨으로 CarBicycle을 모두 조종하는 상황. move 버튼을 누르면 각자 알아서 움직이는 것은 이제 이해했다. 하지만 솔라의 진짜 관심사는 그 다음이었다. 저 배열의 첫 번째 칸에 있는 게 Car라는 걸 뻔히 아는데, 어떻게 그 Car의 선루프를 열 수 있을까?

“흠, Vehicle 리모컨에는 openSunroof 버튼이 없어서 직접은 안 된다고 했지. 그럼….”

솔라는 잠시 고민하다가, 혹시나 하는 마음에 코드를 고쳐보려고 했다.

“컴파일러는 모르지만 나는 알잖아! vehicles[0]Car니까 그냥 이렇게 호출하면 되지 않을까?”

솔라는 for문 안쪽에 v.openSunroof();를 써넣으려 했다. 하지만 코드를 입력하기도 전에, 통합개발환경(IDE)이 빨간 밑줄을 그으며 에러를 표시했다. ‘Vehicle 타입에는 openSunroof라는 메서드를 찾을 수 없습니다.’ 루나가 말했던 컴파일 에러가 눈앞에 나타난 것이다.

“아, 맞다. Vehicle 리모컨으로는 안 되지… 그럼 방법이 없는 건가? Car로 만들어 놓고 Car의 기능을 못 쓰면 너무 억울하잖아!”

솔라의 목소리에 짜증이 섞였다. Vehicle이라는 껍데기 안에 갇혀버린 Car의 특별한 기능이 답답하게 느껴졌다. 분명히 존재하는데, 꺼내 쓸 수가 없었다.

루나는 솔라의 답답한 마음을 읽은 듯, 조용히 for문 안의 코드를 수정하기 시작했다. 이번에도 새로운 키워드가 등장했다.

for (Vehicle v : vehicles) {
    v.move();

    if (v instanceof Car) {
        // v가 정말로 Car 타입의 객체라면 이 안으로 들어와.
    }
}

instanceof?”

솔라가 낯선 단어를 중얼거렸다.

“응. 런타임에, 그러니까 프로그램이 실행되는 그 순간에 물어보는 거야. ‘v야, 너 혹시 Car의 인스턴스니?’ 하고. 만약 맞다면 if문 안의 코드를 실행시켜줘.”

“아, 정체를 확인하는 질문 같은 거구나!”

솔라의 눈이 반짝였다. 이제 vCar라는 것을 프로그램도 알게 된 셈이다. 솔라는 신이 나서 if문 안에 v.openSunroof();를 다시 입력했다. 하지만 여전히 빨간 밑줄이 그어졌다.

“어? Car인 걸 확인했는데 왜 아직도 안 돼?”

솔라의 기대가 다시 한번 꺾였다. 루나가 차분하게 설명을 이었다.

Car라는 걸 확인했을 뿐, v라는 변수 자체는 여전히 Vehicle 리모컨을 들고 있으니까. 이제 Car인 게 확실해졌으니, 그에 맞는 전용 리모컨으로 잠시 바꿔줘야 해.”

루나는 if문 안에 한 줄의 코드를 추가했다.

if (v instanceof Car) {
    Car car = (Car) v; // Vehicle 리모컨을 Car 전용 리모컨으로 바꿔줘!
    car.openSunroof();
}

괄호 안에 Car를 써서 v를 감싸는 모습. 마치 Vehicle이라는 껍데기를 벗겨내고 Car라는 본모습을 되찾아주는 의식처럼 보였다. 그리고 마법처럼, car.openSunroof()의 빨간 밑줄이 사라졌다.

“이걸 ‘다운캐스팅’이라고 불러. 부모 타입(Vehicle)에서 자식 타입(Car)으로 형을 변환하는 거지. instanceof로 안전하게 Car인 걸 확인한 다음에야 비로소 안심하고 할 수 있는 일이야.”

솔라는 비로소 모든 조각이 맞춰지는 것을 느꼈다. instanceof로 신분 확인, 다운캐스팅으로 능력 해제. 이 두 단계만 거치면 부모의 껍데기를 쓴 자식의 고유한 능력을 마음껏 사용할 수 있었다.

솔라는 자신감을 얻어 나머지 코드도 직접 완성했다.

public class Main {
    public static void main(String[] args) {
        Vehicle[] vehicles = { new Car(), new Bicycle() };

        for (Vehicle v : vehicles) {
            v.move(); // 각자의 오버라이딩된 메서드 실행

            if (v instanceof Car) {
                Car car = (Car) v;
                car.openSunroof();
            } else if (v instanceof Bicycle) {
                Bicycle bike = (Bicycle) v;
                bike.ringBell();
            }
        }
    }
}

실행 결과는 완벽했다.

자동차가 달립니다.
선루프를 엽니다.
자전거가 달립니다.
따르릉! 벨을 울립니다.

하나의 반복문이 Vehicle이라는 공통 분모 아래에서 각 객체의 개성을 완벽하게 존중해주었다. move라는 공통된 행동은 다형성을 통해 해결하고, openSunroofringBell 같은 고유한 행동은 instanceof와 다운캐스팅으로 안전하게 처리했다.

솔라는 뿌듯한 표정으로 완성된 코드를 바라보았다. 이제 그녀는 단순히 오버라이딩과 다형성을 아는 것을 넘어, 그것을 활용해 유연하고 확장 가능한 프로그램을 만드는 방법을 깨달은 것이다.

“언니, 그럼 만약 나중에 Drone이라는 새로운 탈것이 생겨도 문제없겠네. Vehicle을 상속받게 하고, fly() 같은 고유 기능이 있다면, 저 for문에 else if (v instanceof Drone) 블록 하나만 추가해주면 되잖아! 기존 코드는 하나도 건드릴 필요 없이.”

솔라는 더 이상 루나에게 질문하지 않았다. 스스로 새로운 상황을 가정하고, 방금 배운 지식을 적용하여 해결책을 제시하고 있었다. 부모 타입 변수를 통해 다양한 자식들을 일관되게 다루면서도, 필요할 땐 각자의 개성을 안전하게 꺼내 쓰는 방법. 다형성의 진정한 힘을 비로소 자신의 것으로 만든 순간이었다.