Java 26

상속의 본질과 Java 키워드 분리 이해하기

부모-자식 관계, 파일 분리, public class, extends, super, protected가 한꺼번에 나오면 무엇이 상속의 핵심이고 무엇이 Java 파일 규칙인지 섞인다.

근거 · 교안 p182-p193

상속의 본질과 Java 키워드 분리 이해하기 대표 이미지

1장: 상속의 본질 (1/5): 반복되는 코드를 모아 재사용하기

1. 상속의 본질 (1/5): 반복되는 코드를 모아 재사용하기

솔라의 손가락이 키보드 위에서 멈칫했다. 모니터에는 세 개의 파일이 나란히 열려 있었다. Car.java, Truck.java, 그리고 방금 막 완성한 SportsCar.java. 무언가 해냈다는 뿌듯함보다는 미간을 찌푸리게 하는 찝찝함이 더 컸다.

“언니, 이거 좀 이상하지 않아?”

거실에서 책을 읽던 루나가 고개를 들었다. 솔라는 의자를 뒤로 쭉 밀며 화면을 가리켰다.

Car 클래스를 만들고, 그걸 복사해서 Truck 클래스를 만들었어. 트럭은 짐을 싣는 기능(load())이 있으니까 그것만 추가했지. 그리고 또 Car를 복사해서 SportsCar를 만들고, 터보 기능(turbo())만 넣었거든. 그런데…”

솔라는 말끝을 흐리며 세 개의 파일을 번갈아 클릭했다. 화면에 나타나는 코드는 대부분 똑같았다.

// Car.java
public class Car {
    String color;
    int maxSpeed;

    void start() {
        System.out.println("차가 출발합니다.");
    }

    void stop() {
        System.out.println("차가 정지합니다.");
    }
}
// Truck.java
public class Truck {
    String color;
    int maxSpeed;

    void start() {
        System.out.println("차가 출발합니다.");
    }

    void stop() {
        System.out.println("차가 정지합니다.");
    }

    void load() {
        System.out.println("짐을 싣습니다.");
    }
}
// SportsCar.java
public class SportsCar {
    String color;
    int maxSpeed;

    void start() {
        System.out.println("차가 출발합니다.");
    }

    void stop() {
        System.out.println("차가 정지합니다.");
    }

    void turbo() {
        System.out.println("터보 모드를 켭니다.");
    }
}

“봐봐. color, maxSpeed 필드랑 start(), stop() 메서드는 세 클래스에 전부 똑같이 들어있어. 그냥 복사해서 붙여넣기만 했어. 만약에 start() 메서드 안의 문구를 ‘부릉! 출발!’로 바꾸려면, 세 군데를 전부 찾아서 고쳐야 하는 거잖아. 자동차 종류가 백 개면 백 번 고쳐야 하고. 이건 너무 비효율적인 것 같아.”

솔라의 목소리에는 코드를 작성하며 느꼈던 답답함이 묻어났다. 단순히 코드를 ‘물려받는다’는 상속의 사전적 정의를 떠올려 봤지만, 지금 이 불편함과 어떻게 연결되는지는 명확히 그려지지 않았다.

루나가 조용히 다가와 솔라의 어깨너머로 화면을 들여다보았다. 루나는 정답을 말해주는 대신, 솔라가 짚어낸 문제를 가만히 따라갔다.

“정말 그렇네. 세 클래스 모두 공통으로 가지고 있는 속성과 동작이 있구나.”

루나는 책상 위 포스트잇 한 장을 떼어내 펜을 들었다. 그리고 솔라가 말한 공통점들을 하나씩 적기 시작했다.

- 색상 (color) - 최고 속도 (maxSpeed) - 출발하기 (start) - 멈추기 (stop)

“자동차, 트럭, 스포츠카. 이 셋의 정체성은 다르지만, 방금 솔라 네가 말한 이 기능들은 전부 공유하고 있어. 이 셋을 한 단어로 묶는다면 뭐라고 부를 수 있을까?”

“음… ‘탈것’? 아니면 ‘차량’?”

“좋아. 그럼 이 포스트잇에 적힌 기능들은 ‘차량’이라면 공통으로 갖는 기능이라고 말할 수 있겠네.”

루나는 포스트잇 맨 위에 ‘차량 (Vehicle)’이라고 적었다. 솔라는 루나가 만든 메모와 자신의 모니터 속 반복되는 코드 덩어리들을 번갈아 보았다. 그제야 흩어져 있던 생각의 조각들이 맞춰지는 느낌이 들었다.

“아! 그럼… 저 공통 기능들을 ‘차량’이라는 대표 클래스 하나에만 만들어 두는 거야. 그리고 자동차, 트럭, 스포츠카는 각자 다른 점만 가지고 있고, 공통 기능은 저 ‘차량’ 클래스에서 빌려 쓰거나… 가져다 쓰는 거지!”

솔라의 눈이 반짝였다. 막연했던 ‘물려받는다’는 말이 구체적인 해결책으로 보이기 시작했다.

“맞아. 그렇게 할 수 있다면 start() 메서드를 수정할 때도 ‘차량’ 클래스 하나만 고치면 되겠지. 자동차든, 트럭이든, 스포츠카든 모두 그 변경 사항을 똑같이 적용받을 거고.”

루나의 말에 솔라는 고개를 힘차게 끄덕였다. 비효율적이라고 느꼈던 코드 중복 문제를 해결할 실마리를 찾은 것이다.

“상속이 그냥 코드를 복사해 주는 편리한 기능 같은 건 줄 알았어. 그런데 그게 아니라, 이런 중복을 없애고 공통된 걸 한곳에서 관리하기 위한 거였구나. ‘공통 기능 모으기’라고 생각하니까 왜 필요한지 확실히 알겠어.”

솔라는 방금 전의 답답함은 잊고 의욕에 차서 키보드에 손을 올렸다. 당장이라도 Vehicle.java 파일을 만들어 공통 코드를 옮겨 담고 싶었다.

새로운 파일을 만들고, 세 클래스에 흩어져 있던 color, maxSpeed, start(), stop() 코드를 Vehicle 클래스로 깔끔하게 옮겼다. 그리고 Car, Truck, SportsCar 클래스에서는 중복되던 부분을 시원하게 지워버렸다. 코드가 훨씬 간결해졌다.

// Vehicle.java (신규)
public class Vehicle {
    String color;
    int maxSpeed;

    void start() {
        System.out.println("차가 출발합니다.");
    }

    void stop() {
        System.out.println("차가 정지합니다.");
    }
}
// Car.java (수정)
public class Car {
    // 이제 Car만의 고유한 기능만 남기면 되는데...
    // 지금은 없네.
}
// Truck.java (수정)
public class Truck {
    void load() {
        System.out.println("짐을 싣습니다.");
    }
}

만족스럽게 코드를 정리하던 솔라의 손가락이 다시 멈췄다.

“자, ‘차량’이라는 부모 역할 클래스에 공통 기능을 다 모았어. 그리고 CarTruck은 자식 역할이 될 거고. 그런데… 이제 뭘 어떻게 해야 하지? Car.java 파일이랑 Vehicle.java 파일이 그냥 따로 있는데, 자바가 어떻게 CarVehicle의 자식이라는 걸 알고 저 기능들을 가져다 쓰게 해줄까? 그냥 이렇게 파일을 만들어두기만 하면 저절로 연결되지는 않을 거 아냐.”

2장: 상속의 본질 (2/5): extends로 부모-자식 관계 선언

솔라는 의기양양하게 새로운 테스트 파일을 만들었다. Vehicle이라는 대표 클래스에 공통 기능을 모아두었으니, 이제 Car 클래스는 군더더기 없이 깔끔했다. Vehicle의 기능을 정말 물려받아 쓸 수 있는지 확인해 볼 차례였다.

솔라는 main 메서드 안에 Car 객체를 생성하고, 망설임 없이 start() 메서드를 호출하는 코드를 작성했다.

public class Main {
    public static void main(String[] args) {
        Car myCar = new Car();
        myCar.start(); // Vehicle에 있는 start()를 호출할 수 있을까?
    }
}

결과는 냉정했다. 코드를 실행하기도 전에, 편집기 화면의 myCar.start(); 라인에 붉은 밑줄이 그어졌다. 컴파일 에러였다. 솔라는 마우스를 에러 메시지 위로 가져갔다.

'start()' 메서드를 'Car' 타입에서 찾을 수 없습니다.

솔라의 미간이 다시 좁혀졌다.

“아니, 왜? Vehicle 클래스에 start()를 분명히 만들어 뒀는데. Car는 차량이니까 당연히 Vehicle의 기능을 쓸 수 있어야 하는 거 아니야?”

솔라의 혼잣말을 들은 루나가 다가와 모니터를 들여다보았다. 솔라가 정리해 둔 Vehicle.javaCar.java 파일, 그리고 에러가 난 Main.java 파일이 화면에 보였다.

루나는 솔라의 책상에 놓인 빈 노트 페이지에 두 개의 네모 상자를 그렸다. 그리고 각각 ‘Vehicle’과 ‘Car’라고 이름을 붙였다.

“지금 솔라 네 컴퓨터에는 ‘Vehicle’이라는 설계도 한 장과 ‘Car’라는 설계도 한 장이 그냥 따로 놓여있는 상태야. 자바 입장에서는 이 둘이 아무 관계도 없는, 완전히 남남인 거지.”

루나는 두 네모 상자를 톡톡 가리켰다.

“우리는 머릿속으로 ‘자동차는 차량의 한 종류다’라고 알고 있지만, 코드에게는 그걸 명확하게 알려줘야 해. ‘Car’ 설계도가 ‘Vehicle’ 설계도를 바탕으로 만들어졌다는 관계 선언이 필요해.”

“관계 선언?”

“응. ‘Car는 Vehicle을 확장해서 만든다’ 혹은 ‘Car는 Vehicle을 상속받는다’고 직접 말해주는 거지.”

루나는 ‘Vehicle’ 상자에서 ‘Car’ 상자로 향하는 화살표를 그렸다. 솔라는 잠시 그 화살표를 응시했다. 따로따로 존재하던 두 클래스 파일 사이에 연결고리가 필요하다는 말이었다.

“알겠어. 둘이 그냥 별개의 파일로 존재하니까 컴퓨터가 관계를 모르는 건 당연하네. 그럼 그 관계 선언, 그 화살표는 코드로 어떻게 그려?”

Car 클래스를 선언하는 곳을 다시 봐봐.”

루나의 말에 솔라는 Car.java 파일로 시선을 돌렸다. 파일의 첫 줄에는 이렇게 쓰여 있었다.

public class Car {
    // 지금은 비어 있음
}

“바로 저기에, Car라는 이름 뒤에 ‘이 클래스는 Vehicle로부터 확장되었습니다’라는 의미의 키워드를 붙여주면 돼.”

솔라는 루나의 힌트를 따라 public class Car 뒤에 커서를 놓고 잠시 고민했다. 확장… 영어로 ‘extends’.

솔라는 키보드를 두드려 extends Vehicle을 추가했다.

// Car.java (수정 후)
public class Car extends Vehicle {
    // 이제 Car는 Vehicle의 자식 클래스가 됨
}

마찬가지로 Truck.java 파일에도 같은 작업을 해주었다.

// Truck.java (수정 후)
public class Truck extends Vehicle {
    void load() {
        System.out.println("짐을 싣습니다.");
    }
}

변화는 즉각적이었다. Car.java 파일을 수정하자마자, Main.java 파일에 떠 있던 붉은 밑줄이 마법처럼 사라졌다. 솔라는 조심스럽게 Main 프로그램을 실행했다.

차가 출발합니다.

콘솔에 성공적으로 메시지가 출력되었다. Car 클래스 안에는 start() 메서드가 없었지만, Vehicle 클래스로부터 그 기능을 성공적으로 가져와 사용한 것이다.

“아! extends가 바로 그 화살표였구나!”

솔라의 목소리에 깨달음의 기쁨이 묻어났다.

“단순히 ‘물려받는다’는 추상적인 개념이 아니라, extends라는 키워드로 ‘얘는 얘의 자식이야’라고 컴퓨터에게 명백하게 알려주는 선언이었어. 이 선언이 없었으니 아까는 둘이 남남이라 서로의 코드를 볼 수 없었던 거고.”

이제 솔라는 상속의 본질이 ‘공통 코드 재사용’이라는 것과, 자바에서 그 관계를 맺어주는 역할은 extends 키워드가 한다는 것을 명확히 구분할 수 있었다. 하나는 목적이고, 다른 하나는 그 목적을 달성하기 위한 구체적인 문법 규칙이었다.

개념의 역할 분리가 명확해지자, 새로운 궁금증이 고개를 들었다. 솔라는 Vehicle.java, Car.java, Truck.java 파일 탭을 차례로 눌러보았다.

“잠깐만, 언니. 그럼 이건 뭐야? extends로 관계를 맺어주는 건 알겠어. 그런데 왜 클래스마다 꼭 파일을 따로 만들어야 해? 그리고 파일 이름이랑 public class 이름은 항상 똑같아야 하고. 이것도 상속의 일부인 거야, 아니면 그냥 자바의 또 다른 규칙이야? 자꾸 이것저것 섞여서 나오니까 뭐가 뭔지 헷갈려.”

3장: 상속의 본질 (3/5): public class와 Java 파일 규칙 구분하기

솔라의 손가락이 망설임 없이 움직였다. Car.java 파일 안의 public class Car 뒤에 extends Vehicle을 붙이고, Truck.java 파일에도 같은 작업을 했다. 에러가 사라지고 코드가 정상적으로 작동하는 것을 확인하자, 얽혔던 실타래 하나가 풀린 것처럼 시원했다. 하지만 마음 한구석의 찝찝함은 가시지 않았다.

솔라는 Car.java 파일의 코드를 전부 선택해서 복사했다. 그리고는 Vehicle.java 파일로 돌아가, Vehicle 클래스 아래쪽에 그대로 붙여넣었다.

// Vehicle.java (실험)
public class Vehicle {
    String color;
    int maxSpeed;

    void start() {
        System.out.println("차가 출발합니다.");
    }

    void stop() {
        System.out.println("차가 정지합니다.");
    }
}

public class Car extends Vehicle {
    // Car만의 고유한 기능 (지금은 없음)
}

파일을 저장하자마자, 편집기는 public class Car extends Vehicle 라인에 빨간 줄을 그으며 단호하게 에러를 표시했다.

클래스 'Car'은(는) public이며, 'Car.java' 파일에서 선언되어야 합니다.

“어? 이게 왜? extends로 부모-자식 관계라고 선언까지 했는데, 굳이 파일을 따로 둘 필요가 있나? 오히려 한 파일에 같이 있으면 관리하기 더 편한 거 아니야?”

솔라의 혼잣말에, 옆에서 지켜보던 루나가 조용히 입을 열었다.

“네가 방금 한 실험이 그 질문에 대한 답을 보여주고 있어. 상속이라는 개념과 자바의 파일 규칙이 한꺼번에 등장해서 혼란스러운 거지?”

루나는 책상 위 메모 패드를 가져와 두 개의 칸으로 나누었다. 왼쪽 칸에는 ‘개념 (목적)’, 오른쪽 칸에는 ‘문법/규칙 (도구)’라고 적었다.

“우리가 첫 번째로 발견한 상속의 목적이 뭐였지?”

“음… ‘공통 기능 모으기’. 중복을 줄이는 거.”

루나는 ‘개념’ 칸에 ‘공통 기능 재사용’이라고 적었다.

“그리고 그 관계를 자바 코드에 알려주는 도구는?”

extends 키워드.”

루나는 ‘문법/규칙’ 칸에 extends라고 적었다. 그리고 솔라를 바라봤다.

“자, 그럼 방금 네가 마주한 ‘public 클래스는 자신과 이름이 같은 .java 파일에 있어야 한다’는 규칙은 어느 칸에 들어가야 할까? 이게 ‘공통 기능을 재사용한다’는 목적과 직접적인 관련이 있을까?”

루나의 질문에 솔라는 잠시 생각에 잠겼다. 컴파일러가 뱉어낸 에러 메시지를 다시 떠올려봤다. 거기엔 extends나 상속에 대한 언급은 한마디도 없었다. 오직 public, 클래스 이름, 그리고 파일 이름에 대해서만 이야기하고 있었다.

“…아니. 관련 없는 것 같아. 만약 CarVehicle을 상속하지 않았더라도, public class CarVehicle.java 파일에 넣으려고 하면 똑같은 에러가 났을 거야.”

“맞아. 그 규칙은 상속과는 아무런 관련이 없어.”

루나는 솔라가 머뭇거리자, ‘문법/규칙’ 칸에 ‘public 클래스 = 파일 이름’ 이라고 적어 넣었다.

개념 (목적)문법/규칙 (도구)
• 공통 기능 재사용extends (클래스 간 관계 선언)
public class는 파일명과 일치 (코드 구조화 약속)

“자바는 코드를 정리하는 하나의 약속으로, ‘세상에 공개할 만큼 중요한(public) 클래스는, 그 클래스의 이름과 똑같은 이름표가 붙은 파일 서랍(.java 파일)에 단독으로 보관해야 한다’고 정해둔 거야. 그래야 컴파일러든 다른 개발자든 수많은 파일 속에서 Car라는 클래스를 찾고 싶을 때, 헤매지 않고 바로 Car.java 파일만 열어볼 수 있으니까.”

루나의 설명은 명쾌했다. 상속은 클래스 설계도들 사이의 내용적인 관계를 다루는 개념이고, 파일 규칙은 그 설계도들을 어떻게 정리하고 보관할지에 대한 물리적인 약속이었다. 전혀 다른 차원의 이야기였던 것이다.

“아하! 나는 extends를 배우면서 파일 분리 규칙이 따라오길래, 이것도 상속의 일부라고 생각했어. ‘상속을 하려면 파일을 꼭 분리해야 한다’ 이런 식으로. 그게 아니라 그냥 자바의 기본 파일 정리 규칙이었구나.”

솔라는 그제야 퍼즐 조각이 제자리를 찾는 기분을 느꼈다. 상속의 목적(재사용), 관계 선언 문법(extends), 그리고 파일 구조 규칙이 각각 독립된 역할과 정체성을 가지고 있다는 사실을 깨달았다. 뒤섞여 있던 개념들이 분리되자 머릿속이 한결 가벼워졌다.

솔라는 방금 Vehicle.java에 붙여넣었던 Car 클래스 코드를 다시 지우고, 원래대로 Vehicle.java, Car.java, Truck.java 세 개의 파일로 깔끔하게 정리했다. 이제 이 구조가 왜 이렇게 생겼는지 명확히 이해할 수 있었다.

구조에 대한 의문이 풀리자, 솔라의 시선은 다시 클래스의 내용으로 향했다.

“좋아, 이제 구조는 알겠어. 그럼 Vehicle 클래스에 있는 colormaxSpeed 같은 필드들은 언제 값을 정해주는 거지? 차를 만들 때부터 색상이랑 최고 속도를 딱 정해주고 싶은데… 아, 생성자를 만들면 되겠다.”

솔라는 Vehicle.java 파일에 생성자를 추가했다. 차량이 만들어질 때 색상과 최고 속도를 반드시 입력받도록 하는 코드였다.

// Vehicle.java (생성자 추가)
public class Vehicle {
    String color;
    int maxSpeed;

    public Vehicle(String color, int maxSpeed) {
        this.color = color;
        this.maxSpeed = maxSpeed;
    }

    void start() { /* ... */ }
    void stop() { /* ... */ }
}

코드를 저장하는 순간, 예상치 못한 곳에서 새로운 에러가 발생했다. 이번에는 Car.javaTruck.java 파일에 빨간 밑줄이 그어졌다.

“어? 이번엔 또 뭐야? Car 클래스는 건드리지도 않았는데 왜 갑자기 에러가 나는 거지?”

4장: 상속의 본질 (4/5): super로 부모 생성자 호출 및 멤버 접근

솔라의 키보드에서 경쾌한 소리가 났다. Vehicle 클래스에 색상과 최고 속도를 받는 생성자를 추가하고 뿌듯하게 파일을 저장했다. 하지만 그 만족감은 잠시뿐이었다. 파일 탭에 작게 표시된 Car.javaTruck.java의 이름 옆으로 붉은색 에러 아이콘이 떠올랐다.

솔라는 Car.java 탭을 클릭했다. 코드는 한 줄도 수정하지 않았는데, 텅 비어있던 클래스 선언부, public class Car extends Vehicle 라인 전체에 붉은 밑줄이 그어져 있었다.

// Car.java
public class Car extends Vehicle { // Error: There is no default constructor available in 'Vehicle'
    // 아무것도 없는데 왜 에러가...?
}

솔라는 에러 메시지 위로 마우스를 가져갔다. 'Vehicle'에 기본 생성자가 없습니다. 라는, 언뜻 이해하기 어려운 메시지가 나타났다. 혼란스러웠다. Vehicle에 생성자를 추가한 것이 왜 자식인 Car 클래스에 영향을 미치는 걸까?

“언니, 이거 완전 이상해. 부모 클래스에 생성자를 만들었더니, 그걸 상속받는 자식 클래스에서 에러가 나. Car는 잘못한 게 없는데.”

옆에서 지켜보던 루나가 솔라의 화면을 가리켰다.

“잘못한 게 없는 게 아니라, 해야 할 일을 하지 않은 거야. Car를 만든다는 건, 그 안에 들어갈 Vehicle 부품도 함께 만들어야 한다는 뜻이거든.”

루나는 포스트잇 한 장을 가져와 상자를 두 개 그렸다. 바깥쪽의 큰 상자에는 Car, 그 안에 쏙 들어가는 작은 상자에는 Vehicle이라고 썼다.

Car 객체를 생성(new Car())하면, 자바는 먼저 그 안에 들어갈 부모인 Vehicle 객체부터 만들려고 해. 그런데 방금 네가 Vehicle 클래스에 public Vehicle(String color, int maxSpeed) 생성자를 만들었잖아. 이제 Vehicle 부품을 만들려면 색상과 최고 속도 값을 반드시 넘겨줘야만 하는 규칙이 생긴 거야.”

루나의 설명을 들으며 솔라는 고개를 끄덕였다. Vehicle을 만들려면 이제 인자 두 개가 꼭 필요하다는 건 이해했다.

“그런데 Car를 만들 땐 아무것도 안 넘겨주잖아. new Car() 이렇게. 그러니까 자바가 Vehicle 부품을 어떻게 만들어야 할지 몰라서 ‘나 못 만들겠어!’ 하고 에러를 내는 거구나.”

“정확해. 그럼 Car 클래스는 어떻게 해야 할까? Vehicle 부품을 만들 책임을 져야 해.”

“책임… 아! 그럼 Car에도 생성자를 만들어서, 거기서 Vehicle에 필요한 값들을 대신 넘겨주면 되겠다!”

솔라는 해결책을 찾았다는 생각에 신나게 코드를 수정했다. ‘자식인 Car가 부모의 필드를 초기화해주면 되겠지’라고 생각했다.

// Car.java (솔라의 첫 번째 시도)
public class Car extends Vehicle {
    public Car(String color, int maxSpeed) {
        // 부모에게서 물려받은 필드를 직접 초기화 시도
        this.color = color;
        this.maxSpeed = maxSpeed;
    }
}

하지만 코드를 작성하자마자, 편집기는 this.color = color; 라는 줄에 새로운 에러를 표시했다. 여전히 Car 클래스 선언부의 에러도 사라지지 않았다.

“아니, 이것도 아니네. 부모 필드니까 자식이 초기화할 수 있는 거 아니었어?”

솔라의 미간이 다시 좁혀졌다. 루나는 솔라가 그린 그림을 살짝 수정했다. Vehicle이라는 작은 상자 안에 자물쇠 그림을 그려 넣었다.

Vehicle의 필드를 초기화하는 것은 Vehicle 생성자만의 고유한 역할이자 책임이야. 자식 클래스가 부모의 필드를 마음대로 주무르면, 부모 클래스가 만들어놓은 초기화 로직이나 상태 보호 규칙이 깨질 수 있거든. 그래서 자바는 자식에게 직접적인 초기화 권한을 주지 않아. 대신, 자식은 부모의 생성자를 ‘호출’해서 초기화를 ‘요청’해야 해.”

“호출해서 요청한다고? 어떻게?”

“‘나의 부모’를 가리키는 특별한 키워드가 있어. ‘부모님의 생성자를 불러주세요’라고 말하는 거지.”

‘부모님’… ‘위쪽’… 솔라는 잠시 고민하다가 키워드 하나를 떠올렸다. super.

솔라는 자신의 코드를 지우고 루나의 조언에 따라 다시 작성했다. Car 생성자 안에서 this 대신 super를 사용해 괄호를 열고 값을 넘겨주었다.

// Car.java (수정 후)
public class Car extends Vehicle {
    public Car(String color, int maxSpeed) {
        super(color, maxSpeed); // "부모님의 생성자를 이 값들로 호출해주세요!"
    }
}

놀랍게도, super(color, maxSpeed); 코드를 입력하자마자 Car 클래스를 뒤덮고 있던 모든 붉은 밑줄이 거짓말처럼 사라졌다.

“아! super()가 부모 생성자를 부르는 전화기 같은 거구나!”

솔라는 드디어 깨달았다. new Car("파랑", 200)을 하면,

  1. Car의 생성자가 호출된다.
  2. Car 생성자 안의 super("파랑", 200)가 가장 먼저 실행된다.
  3. Vehicle의 생성자가 "파랑", 200을 받아 colormaxSpeed 필드를 초기화한다.
  4. 부모의 초기화가 끝나면, 다시 Car 생성자로 돌아와 나머지 코드를 실행한다.

객체가 생성되는 과정이 하나의 잘 짜인 연쇄 반응이라는 것을 알게 된 것이다. super()는 그 연쇄 반응의 첫 고리를 채우는, 부모에 대한 초기화 책임을 다하는 명시적인 명령이었다.

“상속의 본질, 파일 규칙, 그리고 extendssuper 키워드까지… 전부 다른 역할이었네. extends가 관계를 선언한다면, super는 그 관계 위에서 ‘부모 초기화’라는 구체적인 책임을 다하는 거였어.”

개념들이 제자리를 찾자, 솔라의 머릿속이 시원해졌다. 이제 Truck 클래스에도 super()를 이용해 생성자를 깔끔하게 추가할 수 있었다. 그런데 문득 새로운 아이디어가 떠올랐다.

“언니, 만약에 SportsCar 클래스가 터보 모드를 켜면 잠깐 동안 최고 속도를 50만큼 올리고 싶으면 어떡하지? turbo() 메서드 안에서 부모한테 물려받은 maxSpeed 값을 직접 바꿔야 하잖아.”

솔라는 Vehicle 클래스의 maxSpeed 필드를 private으로 바꾸는 상상을 해봤다. 외부에서는 함부로 값을 바꾸지 못하게 막는 건 좋은데, 그러면 자식인 SportsCar조차도 maxSpeed에 접근할 수 없게 된다.

private으로 만들면 자식도 못 쓰고… 그렇다고 아무나 막 바꾸게 public으로 열어놓을 수도 없고… 상속받는 자식한테만 살짝 열어주는 방법은 없을까?”

5장: 상속의 본질 (5/5): protected로 자식에게만 접근 허용하기

솔라의 손가락이 키보드 위를 자신감 있게 넘나들었다. 이전의 혼란은 씻은 듯 사라지고, 이제 각 키워드가 제자리에서 어떤 역할을 하는지 명확히 보였다. 솔라는 마지막 퍼즐 조각을 맞추기 위해 새로운 클래스 파일을 만들었다. 바로 SportsCar.java였다. Vehicle을 상속받고, Car와는 다른 자신만의 특별한 기능을 가지게 될 클래스다.

솔라는 SportsCar 클래스 안에 turbo() 메서드를 추가하고, 그 안에서 최고 속도를 일시적으로 50만큼 올리는 코드를 작성했다. 부모인 Vehicle로부터 물려받은 maxSpeed 필드를 사용하는 것은 당연해 보였다.

// SportsCar.java
public class SportsCar extends Vehicle {
    // ... 생성자 생략 ...

    void turbo() {
        System.out.println("터보 모드를 켭니다. 최고 속도가 증가합니다!");
        this.maxSpeed = this.maxSpeed + 50; // 부모의 maxSpeed 필드에 접근
    }
}

코드를 작성하고 나니, 한 가지 찜찜한 점이 마음에 걸렸다. ‘객체의 상태는 외부에서 함부로 바꾸지 못하도록 보호해야 한다.’ 객체 지향 설계의 중요한 원칙이었다. 솔라는 Vehicle.java 파일로 돌아가 필드 선언부를 수정했다. 외부의 직접적인 접근을 막기 위해 private 키워드를 붙였다.

// Vehicle.java (수정)
public class Vehicle {
    private String color;
    private int maxSpeed;

    // ... 생성자와 메서드 ...
}

스스로의 꼼꼼함에 만족하며 파일을 저장하는 순간, SportsCar.java 파일 탭에 다시 붉은 에러 표시가 나타났다. turbo() 메서드 안의 this.maxSpeed 라인에 밑줄이 그어져 있었다.

'maxSpeed' 필드는 'Vehicle'에서 private 접근 제어자를 갖습니다.

“아… 또?”

솔라는 허탈한 한숨을 내쉬었다. 데이터를 보호하려고 private으로 잠갔더니, 상속을 받은 자식 클래스조차 그 문을 열 수 없게 된 것이다.

“언니, 이건 좀 모순 아니야? 부모의 재산을 외부인한테는 숨기는 게 맞는데, 자식한테까지 비밀로 하면 어떡해. SportsCarVehicle의 기능을 물려받았으니까 가족이나 마찬가지잖아. turbo() 기능을 만들려면 maxSpeed를 꼭 써야 하는데. 그렇다고 이걸 다시 public으로 열어버리면 아무나 와서 속도를 마음대로 바꿔버릴 거고. 중간이 없어, 중간이.”

솔라의 불평을 듣고 있던 루나가 조용히 다가왔다. 루나는 솔라의 화면에 나타난 VehicleSportsCar의 관계, 그리고 에러 메시지를 가만히 들여다보았다.

“솔라 네 말대로 ‘가족’이라는 관계가 중요해 보이네. 지금 네가 마주한 문제는 접근을 허용할 대상을 어떻게 구분하느냐의 문제야.”

루나는 솔라의 책상 위에 놓인 메모 패드에 세 개의 동심원을 그렸다.

가장 안쪽 원에는 ‘나 자신 (Vehicle)’이라고 썼다. “private은 이 원 안에서만 유효한 열쇠야. 오직 Vehicle 클래스 자기 자신만이 필드에 접근할 수 있지.”

루나는 가장 바깥쪽 원을 가리켰다. “‘세상 모든 곳 (public)’은 아무런 제한이 없는 거고. Main이든 어디든, 누구에게나 열려 있는 대문 같은 것.”

그리고 루나는 중간 원을 톡톡 쳤다. 안쪽 원과 바깥쪽 원 사이에 끼어 있는 공간이었다. “네가 지금 필요한 건 바로 여기야. ‘나 자신’보다는 넓지만 ‘세상 모든 곳’보다는 좁은 범위. 즉, ‘나와 내 자식들’에게만 허용하는 특별한 문이 필요하지 않을까?”

솔라는 루나가 그린 동심원을 쳐다봤다. privatepublic 사이의 모호한 공간이 아니라, ‘상속 관계’라는 명확한 조건이 붙은 새로운 영역이었다.

“가족에게만 열어주는 문… 아! protected!”

솔라는 이전에 스치듯 배웠던 키워드를 떠올렸다. 그저 publicprivate 사이의 어중간한 제어자라고만 생각했는데, 상속이라는 맥락 안에서 보니 그 역할이 완전히 새롭게 보였다.

솔라는 곧장 Vehicle.java 파일로 돌아가 maxSpeed 필드의 접근 제어자를 protected로 수정했다.

// Vehicle.java (최종 수정)
public class Vehicle {
    protected String color;
    protected int maxSpeed;

    // ... 생성자와 메서드 ...
}

그러자 마법처럼 SportsCar.javaturbo() 메서드에 있던 붉은 에러가 말끔히 사라졌다. 자식 클래스가 부모의 필드에 정상적으로 접근할 수 있게 된 것이다. 하지만 외부에서는 어떨까? 확인을 위해 Main 클래스에서 maxSpeed에 직접 접근을 시도했다.

// Main.java (테스트)
public class Main {
    public static void main(String[] args) {
        SportsCar sc = new SportsCar("빨강", 300);
        // sc.maxSpeed = 500; // 컴파일 에러! protected 필드는 외부에서 직접 접근 불가
    }
}

예상대로 컴파일 에러가 발생했다. protected는 외부에는 빗장을 걸어 잠그고, 상속 관계에 있는 자식에게만 문을 열어주는 정확하고 섬세한 도구였다.

“이제 알겠어. protected는 그냥 ‘중간’이 아니었어. ‘상속’이라는 관계 속에서만 의미를 갖는, 자식을 위한 특별한 통로였구나.”

솔라는 지난 며칠간의 여정을 돌아보았다. 중복 코드의 불편함에서 시작해, 상속이라는 재사용의 개념을 발견했다. 그리고 그 개념을 구현하기 위해 자바가 제공하는 여러 장치들을 하나씩 분해하고 다시 조립했다.

솔라는 루나가 이전에 그려주었던 메모를 찾아왔다.

개념 (목적)문법/규칙 (도구)
• 공통 기능 재사용extends (클래스 간 관계 선언)
public class는 파일명과 일치 (코드 구조화 약속)

솔라는 펜을 들어, 이제는 자신의 것이 된 지식을 직접 표에 추가하기 시작했다. ‘문법/규칙’ 칸에 두 줄을 더했다.

개념 (목적)문법/규칙 (도구)
• 공통 기능 재사용extends (클래스 간 관계 선언)
public class는 파일명과 일치 (코드 구조화 약속)
super() (부모의 생성자 호출 및 초기화 책임)
protected (자식 클래스에게만 접근 허용)

완성된 표를 바라보는 솔라의 얼굴에 환한 미소가 번졌다. 처음에는 한데 뒤섞여 자신을 혼란스럽게 했던 개념과 키워드들이, 이제는 각자의 역할과 정체성을 가지고 명확하게 정렬되어 있었다. 상속이라는 강력한 도구를 어떻게 사용해야 하는지, 이제는 자신 있게 설명할 수 있었다. 루나는 그런 솔라의 모습을 조용히 지켜보며 따뜻하게 고개를 끄덕여 주었다.