개발자로 후회없는 삶 살기

[문법] 상속은 코드의 재활용이 아니다. 본문

[백엔드]/[Java | 학습기록]

[문법] 상속은 코드의 재활용이 아니다.

몽이장쥰 2024. 5. 18. 20:59

서론

※ 과거에 기록한 내용에서 중요한 부분만 발췌하여 모두가 이해하기 쉽게 다시 서술한다.

 

본론

- 상속이란?

부모 클래스의 멤버를 자식 클래스에서 사용할 수 있도록 물려 주는 것으로, 단어 뜻과 유사한 방식으로 동작한다. 실제로, 부모 클래스에 작성한 필드와 메서드를 자식 클래스에서 작성한 것처럼 사용할 수 있다.

 

public class Animal {
    protected String color;
    private String name;

    protected void speak() {
        System.out.println("동물이 어떻게 말한다.");
    }
}

public class Lion extends Animal {

}

자식인 Lion이 Animal을 상속 받는 코드이다.

 

Lion에는 코드를 작성하지 않았지만, 부모 클래스에 있는 메서드를 사용할 수 있다.

 

✅ 실제로 부모 클래스의 멤버가 자식에게 들어오는 것인가?

만약, 자식 클래스의 메모리 공간에 추가로 할당하여 자식 클래스 내부에 부모 클래스의 멤버가 들어오는 것이라면, 부모 클래스의 private 멤버도 자식 클래스에서 사용할 수 있을 것이다.

 

하지만, 클래스 내부에서만 접근 가능한 private 필드를 자식 클래스에서 접근할 수 없고 부모 클래스 멤버가 자식으로 들어오는 것은 아니다.

 

실제로는 힙 공간에 부모와 자식 객체가 함께 생기고 하나의 레퍼런스로 참조되고 있다. 참조값은 하나지만, 실제 그 안에는 두가지 클래스 정보가 존재하며, 하나의 레퍼런스가 부모와 자식 객체 중에서 하나를 선택에서 동작한다.

 

public class Animal {
    protected String color;
    private String name;

    public Animal(String color, String name) {
        this.color = color;
        this.name = name;
    }
}

자식의 생성자 내부에는 super()라는 부모의 생성자를 호출하는 메서드가 생략되어 있어, 자식을 생성하면 부모가 먼저 생성된다.

 

만약 부모의 생성자에 인자가 있을 경우 super()에 인자를 넣고 작성해주지 않으면 컴파일 오류가 발생한다. 생성자로도 자식의 객체에 부모의 필드가 들어와서, 자식 객체만 생기는 것이 아닌 자식과 부모 객체가 둘 다 생성이 되는 것을 알 수 있다.

 

-> ✅ 상속이 필요한 이유

상속을 받으면 부모에 작성한 코드를 자식에 작성하지 않아도 재사용할 수 있어, 유지보수 하기 편리하며 중복 코드 작성을 막는다. 하지만, 코드 중복 방지를 상속의 목적으로 단정 지으면 안 된다. 상속은 일반화와 구체화로 봐야 한다.

 

Person 이라는 일반화된 클래스를 상속 받아 Student, Teacher 라는 구체적인 클래스를 만들 수 있고 Animal 이라는 일반화된 클래스를 상속 받아 Lion, Dog라는 구체적인 클래스를 만들 때 상속을 목적에 맞게 사용하는 것이다. 그러면서, 코드의 구조화와 클래스 분리가 함께 오는 것이다.

 

- 상속을 활용하는 경우를 예제로 알아보자

public class Customer {
    protected String grade;
    protected int bonusPoint;
    protected double bonusRatio;

    public Customer() {
        grade = "SILVER";
        bonusRatio = 0.01;
    }

    public void calculateBonusPrice(int price) {
        bonusPoint += price * bonusRatio;
    }
}

백화점에서 고객을 관리하기 위해 고객 클래스를 만들었다. 처음엔 고객 등급 제도가 없어서 모든 고객에게 SILVER 등급을 주고 1%의 보너스 비율을 주었는데, VIP 고객과 GOLD 고객이 생겨 수정이 필요한 상황이다.

 

-> 상속을 사용하지 않았을 경우

public void calculateBonusPrice(int price) {
    if (grade == "SILVER") {
        bonusPoint += price * bonusRatio;
    } else if (grade == "VIP") {
        bonusPoint += price * (bonusRatio+1);
    }
}

하나의 클래스로 여러 등급을 나타낼 수록 포인트를 계산하는 과정에서 if문이 늘어난다. 만약 등급에 따라 결과가 달라지는 메서드가 여러 개 있다면 더 복잡해질 것이다.

 

또한, VIP 손님에게 필요하지만 SILVER 고객에게는 필요 없는 필드들이 있다면, 안 쓰는 필드가 생기게 된다. 즉, 필요 없는 기능들이 점점 늘어나고 유지보수하기 어렵다.

 

-> 상속을 사용하자 ✅

public class VipCustomer extends Customer {
    private int agentId;
    private double saleRatio;

    public VipCustomer() {
        grade = "VIP";
        bonusRatio = 0.05;
    }

    @Override
    public void calculateBonusPrice(int price) {
        bonusPoint += price * (bonusRatio + 1);
    }
}

VIP에는 SILVER에게는 필요 없는 필드만을 선언하고 불필요한 메서드도 제거한다. 만약 코드를 작성하는데 불필요한 if 문이 늘어난다면 잘못된 코드를 작성하는 것으로 상속을 고려해 봐야 한다.

 

- 다형성

다형성은 쉽게 말해, 동일 코드가 다르게 동작하는 것을 의미한다. 부모 클래스는 여러 자식을 참조할 수 있으며 이를 다형적 참조라고 한다.

 

위 코드는 동일하게 speak() 메서드를 호출했고 같은 Animal 클래스 타입을 가지지만, 다르게 동작한다. 이러한 다형성이 가능한 이유는 상속 때문이다.

 

Animal animal = new Lion()

우선 자식 클래스는 부모를 내포하기 때문에 부모 타입으로 선언할 수 있다. 따라서 Lion과 Dog이 Animal 클래스 타입으로 선언될 수 있다.

 

public class Lion extends Animal {

    public Lion(String color, String name) {
        super(color, name);
    }

    @Override
    protected void speak() {
        System.out.println("사자는 으르렁");
    }
}

또한 각각의 자식 클래스에서 다르게 메서드를 구현했기 때문에 동일 코드라도 다르게 동작한다. 이제부터 그 원리에 대해 알아보자

 

-> 오버라이딩과 동적 바인딩

1. 오버라이딩

자식 클래스에서 부모와 동일한 함수 원형의 메서드를 코드 블럭 내부만 다르게 재정의하는 것을 의미한다.

 

부모 객체를 생성하고 메서드를 호출하면 부모에 정의된 대로 동작한다. 이는 당연히 생성한 클래스 타입에 맞게 힙 메모리에 할당되고 호출했기 때문이다. 하지만 자식 객체로 생성하면 부모도 함께 메모리에 할당되므로 차이점이 있다.

 

1) 자식 타입으로 자식 객체 생성

자식 객체를 자식 타입으로 선언하면 부모와 마찬가지로 동일 타입만 사용했기 때문에, 자식에 정의된 대로 동작하는 것 같지만 이 경우에는 두 객체 중에서 어떤 객체의 멤버를 호출할지 객체 선택의 시간이 필요하며, 이 둘을 구분하는 방법은 선언한 타입으로 객체를 선택한다.

 

위 예제의 경우, 자식 타입이므로 자식 객체를 선택하고 자식에 재정의한 멤버를 실행한다.

 

🚨 만약, 부모의 메서드를 호출하면?

상속을 하면 자식 클래스에 부모의 멤버가 들어오는 것이 아니라, 두 객체가 하나의 참조값으로 동작한다고 했다. 따라서, 자식 객체를 선택해도 부모의 메서드가 없다. 그러면 상속 관계에서는 부모 객체로 올라가 부모의 메서드를 찾고 호출하게 된다.

 

2) 부모 타입으로 자식 객체 생성

부모 타입으로 자식 객체를 생성하면 두 개의 객체 중 부모 타입 객체를 선택하여 동작한다.

 

public class Dog extends Animal {
    public Dog(String color, String name) {
        super(color, name);
    }

    public void eat() {
        System.out.println("밥을 먹는다.");
    }
}

이처럼 선언한 클래스 타입을 선택하는 방식으로, 동작하기에 선택된 클래스에 없는 멤버는 사용할 수 없고 부모 클래스에 있는 멤버만 가능하며

 

자식 클래스에만 있는 멤버를 호출할 시 컴파일 오류가 발생한다.

 

🚨 하지만 자식 객체에서 재정의한 부모의 메서드는 어떻게 동작할까?

부모 클래스 타입이기 때문에 해당 레퍼런스는 부모 클래스에 작성한 멤버만 접근 가능하다. 하지만 실제로 일어나는 일은 자식 클래스에 재정의한 대로 동작한다. 이는 동적 바인딩 때문이다.

 

2. 동적 바인딩

동적 바인딩은 런타임에 객체의 실제 타입을 기반으로 실행할 메서드를 결정한다.

 

부모 타입으로 선언되어 실행한 부모 클래스의 메서드를 실행 시점에 확인해보니 자식 객체라서 자식 클래스의 메서드를 호출한다. 따라서 부모 타입의 레퍼런스라도, 자식 객체를 생성했기에, 런타임에 자식에서 재정의한 메서드를 실행한다.

 

자바는 메서드 오바라이딩과 동적 바인딩으로 인해 동일 코드로 다른 결과를 즉, 다형성을 만족할 수 있다.

 

🚨 자식 메서드를 사용하고 싶을 때

부모 클래스 타입으로 선언한 자식 객체가 있을 때 해당 레퍼런스로는 부모 클래스에 작성한 멤버만 접근할 수 있다. 만약, 자식 클래스에만 작성한 멤버에 접근하고 싶으면 어떻게 해야할까?

 

Animal animal = new Dog("", "");
Dog dog = (Dog) animal;

이때, 필요한 것이 다운캐스팅이다. 상위 레벨로 선언한 것을 하위 레벨로 낮춘다는 의미로 다운 캐스팅이라고 표현한다. 이때 주의할 점은 다운 캐스팅하려는 레퍼런스가 바라보는 객체가 어떠한 타입인지 반드시 확인해야 한다는 것이다. 이처럼 하나의 코드 블록에 생성과 선언, 다운 캐스팅이 있다면 확인할 필요가 없다.

 

public class Main {
    public static void main(String[] args) {
        Animal animal = new Lion("", "");
        speak(animal);
    }

    static void speak(Animal animal) {
        Dog dog = (Dog) animal;
        dog.speak();
    }
}

하지만 무수한 메서드 호출이 이뤄지는 상황에서는 현재 인자로 들어온 레퍼런스가 어떤 객체를 바라보고 있는지 알 수 없고

 

만약 다른 타입의 객체를 바라보는 레퍼런스를 잘 못 다운 케스팅하면 ClassCastException 에러가 발생하고, 이는 런타임 에러로 치명적인 에러이다.

 

static void speak(Animal animal) {
    if (animal instanceof Dog) {
        Dog dog = (Dog) animal;
        dog.speak();
    } else {
        System.out.println("올바른 타입 변환이 아닙니다.");
    }
}

따라서 다운 캐스팅과 같은 형변환을 할 때는 instanceof로 항상 검사하는 습관이 필요하다.

Comments