Kotlin이 상속을 기본적으로 닫아놓은 이유, 상속의 단점

홍성덕·2025년 1월 15일

해당 글에서 논하는 상속은 클래스가 다른 클래스를 확장하는 구현 상속을 말한다.
클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속과는 무관하다.

상속의 위험성 : 상속은 캡슐화를 깨뜨린다

1. 상위 클래스의 내부 구현이 달라지면, 그 여파로 하위 클래스가 오동작할 수 있다.

public class Animal {
    protected void eat() {
        // System.out.println("동물이 먹고 있습니다.");
        System.out.println("동물이 잠자고 있습니다.");
    }
}

class Dog extends Animal {
    public void eatAndBark() {
        eat();
        System.out.println("강아지가 짖고 있습니다.");
    }
}

만약 위와 같은 상황에서 eat() 메소드의 내부 코드인 System.out.println("동물이 먹고 있습니다.");System.out.println("동물이 잠자고 있습니다.");로 바꾼다면, 하위 클래스인 Dog의 eatAndBark()에서도 메소드의 이름과 일치하지 않는 동작을 하게 된다.

이와 같이 상위 클래스의 내부 구현이 달라지면, 그 여파로 하위 클래스가 의도치 않은 동작을 할 수 있다.


이번엔 Kotlin 예시도 한번 살펴보자. 쉬운 예시로 sum() 함수를 보자.

// abstract로 선언해도 되지만, 상속에 관한 글이어서 open 키워드를 사용하였음
open class Calculator {
    fun sum(a: Int, b: Int): Int {
        // return a + b
        return a - b
    }
}

class SubCalculator : Calculator() {
    fun calculate(a: Int, b: Int): Int {
        return sum(a, b)
    }
}

fun main() {
    val subCalculator = SubCalculator()
    val result = subCalculator.calculate(3, 4)
    println(result) // 출력 : -1
}

마찬가지로 여기서도 sum() 이라는 함수는 누가봐도 더하기를 의미하는 함수인데, 상위 클래스에서 sum() 함수의 본문을 두 수를 빼는 로직으로 바꿔버리면, 하위 클래스에서 예기치 못한 동작을 하게 된다.

1-1. Kotlin에서 open 키워드를 만든 이유 (상속을 기본적으로 닫아놓은 이유)

우리는 여기서 Kotlin이 상속을 기본적으로 닫아놓고 open 키워드를 만들어 놓은 이유를 알 수 있다. 방금 위와 같은 케이스를 방지하기 위해서이다. 즉 상위 클래스를 변경하는 경우 하위 클래스의 동작이 예기치 않게 바뀔 수 있는 케이스를 방지하기 위해서이다.

변경 가능성을 최소화하는 방식으로 코드를 작성하는 것이 권장되는데, 상속에 항상 열려있으면 하위 클래스의 변경 가능성이 높아진다. 왜냐하면 하위 클래스는 상위 클래스의 세부 구현 사항에 의존하기 때문이다.

자바에서는 이러한 케이스를 방지하기 위해서 기본적으로 final이라는 키워드를 붙이라고 권장한다. 하지만 보통 코드를 작성할 때 final을 매번 붙이는 것은 쉽지 않고 언제든지 실수가 나올 수 있다.

그래서 Kotlin에서는 기본적으로 닫아놓고, 필요할 때 개발자가 open 키워드를 사용해서 열어라는 의도를 가지고 open 키워드를 만든 것이다. 그렇게 함으로써 개발자의 실수를 줄이고 의도하지 않은 다른 형태의 코드 동작을 방지한다.


예를 들어 실무에서 내가 어떤 클래스를 작성했다고 가정하자. 그리고 클래스가 기본적으로 상속이 열려있다면, 다른 개발자가 마음대로 상속받아서 수정한 다음, 의도치 않은 동작이 발생하면 나한테 이거 왜 이렇게 동작하냐고 물어보는 경우가 생길 수도 있다.

하지만 기본적으로 상속에 대해 닫혀있고 open을 통해 제한적으로 열어두면 이러한 일을 방지하여, 개발자 간의 커뮤니케이션 비용도 줄일 수 있다.

2. 하위 클래스에 새로운 메소드를 추가할 때의 문제

하위 클래스에 상위 클래스의 메소드와 관련 없는, 다른 의도를 가진 새로운 메소드를 추가할 때 상황에 따라 여러가지 문제가 발생한다.

public class Animal {
    protected void eat() {
        System.out.println("동물이 먹고 있습니다.");
    }
}

class Dog extends Animal {
    public String eat() { // 컴파일 오류 : Animal의 eat()과 충돌
        System.out.println("강아지가 먹고 있습니다.");
        return "";
    }
}

먼저 이렇게 상위 클래스의 새 메서드와 시그니처가 같고 반환 타입이 다르다면, 컴파일 오류가 발생한다.

public class Animal {
    protected void eat() {
        System.out.println("동물이 먹고 있습니다.");
    }
}

class Dog extends Animal {
    public void eatAndBark() {
        eat();
        System.out.println("강아지가 짖고 있습니다.");
    }

    public void eat() {
        System.out.println("강아지가 먹고 있습니다.");
    }
}

이렇게 시그니처와 반환 타입 모두 같을 경우, 상위 클래스의 메소드를 재정의한 것이 되어버린다. 그래서 개발자가 eatAndBarK() 메소드에서 Animal 클래스의 eat() 메소드를 호출하고 싶어도 Dog 클래스의 eat() 메소드가 호출된다.

정리하면, 상위 클래스의 메소드를 가져다 쓰고 싶어도 하위 클래스의 메소드만 사용할 수 있는 상황이 되어버린다.


"상속은 캡슐화를 깨뜨린다"라는 말에 대해 조금 더 구체적으로 설명하겠다.

상속을 사용하면 하위 클래스는 상위 클래스의 멤버에 접근할 수 있게 된다. 이는 상위 클래스의 내부 상태나 구현 세부 사항이 외부에 노출되는 결과를 초래하기 때문에 캡슐화인 핵심인 정보 은닉이 약화된다.

그리고 상위 클래스의 내부 구현이 변경되면 하위 클래스도 영향을 받기 때문에, 이는 상위 클래스와 하위 클래스 간의 강한 결합을 의미한다. 캡슐화의 목적 중 하나가 내부 구현이 변경되더라도 외부에 미치는 영향을 최소화함으로써 유지보수성을 향상시키는 목적이 있다. 그런데 이러한 강한 결합은 캡슐화의 목적을 저해한다.


대안 : 컴포지션 (Composition)

컴포지션 : 한 클래스가 다른 클래스를 인스턴스 변수로 가지고 있는 형태
→ 기존 클래스가 새로운 클래스의 구성요소(composition)로 쓰이게 되는 구조

class Engine {
    void start() {
        System.out.println("엔진이 시동되었습니다.");
    }
}

class Car {
    private Engine engine;

    Car() {
        this.engine = new Engine();
    }

    // Delegation: Car가 Engine 객체에게 start 메소드 호출을 위임
    void startEngine() {
        engine.start();
    }
}

public class Main {
    public static void main(String[] args) {
        Car car = new Car();
        car.startEngine(); // "엔진이 시동되었습니다." 출력
    }
}

컴포지션을 사용하면 일반적으로 Delegation을 같이 활용하게 된다.

Delegation : 어떤 기능을 자신이 처리하지 않고 다른 객체에 위임시켜 그 객체가 일을 처리하도록 하는 것

그리고 컴포지션에서는 아래와 같은 용어도 사용된다.
전달(Forwarding) : 새 클래스(Car)의 메소드들이 기존 클래스(Engine)에 대응하는 메서드들을 호출해 그 결과를 반환하는 것
전달 메소드(Forwarding method) : 전달(Forwarding)을 수행하는 새로운 클래스의 메서드들 (위의 예시에서는 startEngine() 메소드가 전달 메소드이다)

상속은 자식 클래스가 새로운 메소드를 추가할 때 제한이 생기지만, 컴포지션은 새로운 메소드를 자유롭게 추가 가능하다. 여기서는 Engine 클래스에 새로운 메소드를 자유롭게 추가 가능하다.


그리고 컴포지션을 활용하여 기존 클래스의 내부 구현 변경이 새로운 클래스에 영향을 미치지 않게 하려면, 인터페이스를 사용하여 서로 간의 강한 결합을 느슨하게 하는 방법이 있다.

// Engine 인터페이스 정의
public interface Engine {
    void start();
    void stop();
}

// GasolineEngine 클래스
public class GasolineEngine implements Engine {
    @Override
    public void start() {
        System.out.println("가솔린 엔진이 시동되었습니다.");
    }

    @Override
    public void stop() {
        System.out.println("가솔린 엔진이 정지되었습니다.");
    }
}

// ElectricEngine 클래스
public class ElectricEngine implements Engine {
    @Override
    public void start() {
        System.out.println("전기 엔진이 시동되었습니다.");
    }

    @Override
    public void stop() {
        System.out.println("전기 엔진이 정지되었습니다.");
    }
}

// Car 클래스가 Engine 인터페이스를 포함 (컴포지션 + 추상화)
public class Car {
    private Engine engine;

    public Car(Engine engine) {
        this.engine = engine;
    }

    public void drive() {
        engine.start();
        System.out.println("차가 주행을 시작합니다.");
    }

    public void park() {
        drive();
        engine.stop();
        System.out.println("차가 주차되었습니다.");
    }
}

// Main 클래스
public class Main {
    public static void main(String[] args) {
        Engine gasolineEngine = new GasolineEngine();
        Car gasolineCar = new Car(gasolineEngine);
        gasolineCar.drive();
        // 출력:
        // 가솔린 엔진이 시동되었습니다.
        // 차가 주행을 시작합니다.

        gasolineCar.park();
        // 출력:
        // 가솔린 엔진이 시동되었습니다.
        // 차가 주행을 시작합니다.
        // 가솔린 엔진이 정지되었습니다.
        // 차가 주차되었습니다.

        Engine electricEngine = new ElectricEngine();
        Car electricCar = new Car(electricEngine);
        electricCar.drive();
        // 출력:
        // 전기 엔진이 시동되었습니다.
        // 차가 주행을 시작합니다.

        electricCar.park();
        // 출력:
        // 전기 엔진이 시동되었습니다.
        // 차가 주행을 시작합니다.
        // 전기 엔진이 정지되었습니다.
        // 차가 주차되었습니다.
    }
}

상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 사용해야한다.

클래스 간에 진짜 'is-a' 관계 일때인 경우(예를 들어 Dog is Animal)에만 상속을 사용하자.
만약 아닌경우, 컴포지션을 사용하자 (private 인스턴스, API)


참고자료

profile
안드로이드 주니어 개발자

0개의 댓글