개발 이야기/OOP

[OOP] 상속(Inheritance)이란?

제이온 (Jayon) 2021. 5. 31.

안녕하세요? 제이온입니다.

 

이번 시간에는 저번 포스팅인 캡슐화에 이어서 다형성에 대해 알아보겠습니다.

 

 

상속이란?

위키피디아에 따르면, 상속을 아래와 같이 정의하고 있습니다.

 

 

상속은 객체들 간의 관계를 구축하는 방법이다.

 

 

아주 심플하죠? 하지만, 이렇게만 말하면 너무 추상적으로 이해할 수 밖에 없습니다. OOP에서의 상속보다는 우리가 실생활에서 쓰는 상속을 생각해 봅시다. 실생활에서의 상속은 부모가 자식에게 무언가 물려준다는 의미가 있습니다. 사실, OOP에서의 상속도 크게 다르지 않습니다. 부모 클래스의 메소드 혹은 필드를 자식 클래스에게 물려주는 것이니까요.

 

그래서 저는 상속을 위키피디아 설명에 덧붙여서, '기존 클래스에 기능을 추가하거나 재정의하여 새로운 클래스를 정의한다.'라고 하고 싶습니다. 하위 클래스에서 이용할 공통된 메소드를 상위 클래스가 제공해 주고, 하위 클래스는 상위 클래스에서 없는 기능을 추가하거나 수정하는 것이죠.

 

상속은 코드를 보면 이해하기 더 쉽습니다. 장점을 소개하면서 예제 코드를 살펴보겠습니다.

 

 

상속의 장점

(1) 코드를 재사용할 수 있다.

이 장점은 실생활에서의 상속의 의미를 가장 잘 살린다고 생각합니다. 앞서 말했듯, 하위 클래스는 상위 클래스의 메소드나 필드를 이용할 수 있습니다. 다만, protected 혹은 public 접근 제한자를 주어야 합니다. 아무리 하위 클래스라도 상위 클래스의 해당 기능에 private을 설정한다면 접근할 수 없습니다. 우선, 저는 상속을 적용하지 않은 비효율적인 코드부터 작성해 보겠습니다.

 

 

public class Boy {

    private final String name;

    public Boy(String name) {
        this.name = name;
    }

    public void study() {
        System.out.println("[" + name + "] 공부합니다.");
    }

    public void breathe() {
        System.out.println("[" + name + "] 숨 쉬고 있습니다.");
    }

    public void run() {
        System.out.println("[" + name + "] 뛰고 있습니다.");
    }
}

public class Girl {

    private final String name;

    public Girl(String name) {
        this.name = name;
    }

    public void study() {
        System.out.println("[" + name + "] 공부합니다.");
    }

    public void breathe() {
        System.out.println("[" + name + "] 숨 쉬고 있습니다.");
    }

    public void walk() {
        System.out.println("[" + name + "] 걷고 있습니다.");
    }
}

 

 

위는 어떤 소년과 소녀 객체입니다. 몇 가지 행위를 정의해 놓았고, 해당 기능을 사용하는 코드는 다음과 같습니다.

 

 

public class Main {

    public static void main(String[] args) {
        final Boy boy = new Boy("철수");
        final Girl girl = new Girl("영희");

        boy.study();
        girl.study();
        System.out.println();

        boy.breathe();
        girl.breathe();
        System.out.println();

        boy.run();
        girl.walk();
    }
}

 

 

 

 

실행하면 위와 같이 출력됩니다. 아주 단순한 기능이지만, 공부하는 것과 숨쉰다는 행위가 중복이 되는 것을 알 수 있습니다. 이를 상속을 통해 해결할 수 있습니다.

 

공부한다는 특성을 살려서 학생 상위 클래스를 정의해 보겠습니다.

 

 

public class Student {

    protected final String name;

    public Student(String name) {
        this.name = name;
    }

    public void study() {
        System.out.println("[" + name + "] 공부합니다.");
    }

    public void breathe() {
        System.out.println("[" + name + "] 숨 쉬고 있습니다.");
    }
}

 

 

공통된 특성을 위와 같이 정의하였습니다. 그리고 위 상위 클래스를 상속받은 하위 클래스들을 보겠습니다.

 

 

public class Boy extends Student {

    public Boy(String name) {
        super(name);
    }

    public void run() {
        System.out.println("[" + name + "] 뛰고 있습니다.");
    }
}

public class Girl extends Student {

    public Girl(String name) {
        super(name);
    }

    public void walk() {
        System.out.println("[" + name + "] 걷고 있습니다.");
    }
}

 

 

어떤가요? 중복된 코드가 확 줄지 않았나요? 공부를 하는 것과 숨 쉬는 것의 기능을 재사용하고, name 필드도 상위 클래스꺼를 재사용함으로써 하위 클래스만의 기능에 집중할 수 있게 되었습니다. 그리고 여기서 Boy가 공부한다의 특징을 바꾸고 싶으면 아래처럼 재정의할 수 있습니다.

 

 

public class Boy extends Student {

    public Boy(String name) {
        super(name);
    }

    @Override
    public void study() {
        System.out.println("[" + name + "] 열심히 공부합니다.");
    }

    public void run() {
        System.out.println("[" + name + "] 뛰고 있습니다.");
    }
}

 

 

 

 

다음과 같이 철수만의 공부 행위가 바뀐 것을 알 수 있습니다. 영희도 철수와 마찬가지로 이미 있던 기능을 재정의할 수도 있고, 새롭게 추가할 수도 있을 것입니다.

 

또한, Student에서도 숨 쉰다와 같이 인간이 공통적인 하는 행위가 많아진다면, Person과 같은 객체를 상위 클래스로 두어 똑같이 공통된 메소드를 추출할 수도 있습니다.

 

 

(2) 다형성 구현

이 장점은 위키피디아의 정의에 부합한다고 생각합니다. 객체와의 관계를 만들어 준다는 것은 다시 말해서 다형성을 살린다는 의미이기 때문이죠. 앞서 포스팅에서는 다형성 구현 방식으로 오버로딩, 오버라이딩, 함수형 인터페이스를 이야기하였지만, 사실 오버라이딩 없는 상속만 사용해도 다형성의 의미를 살릴 수 있습니다.

 

위의 예시만 보더라도 Student 타입에는 Boy 또는 Girl 타입을 대입할 수 있기 때문이죠.

 

 

public class Main {

    public static void main(String[] args) {
        final Boy boy = new Boy("철수");
        final Girl girl = new Girl("영희");

        boy.study();
        girl.study();
        System.out.println();

        boy.breathe();
        girl.breathe();
    }
}

 

 

다형성이 없다면, 위와 같이 Student로 추출한 메소드들을 하나 하나 코드를 작성해야 합니다.

 

 

public class Main {

    public static void main(String[] args) {
        final Boy boy = new Boy("철수");
        final Girl girl = new Girl("영희");
        final List<Student> students = Arrays.asList(boy, girl);

        for (final Student student : students) {
            student.study();
            student.breathe();
            System.out.println();
        }
    }
}

 

 

대신 이렇게 상속을 통해 다형성을 구현하면, 반복문을 통해 공통된 메소드들을 딱 한 번씩만 코드로 작성하면 됩니다.

 

 

상속의 문제점

위의 예시를 보면 상속은 코드의 재사용을 통해 개발 시간을 단축시켜주고, 다형성까지 구현이 가능하므로 마구 마구 사용해야할 것처럼 보입니다. 하지만, 상속은 다양한 문제점을 갖고 있습니다.

 

 

상위 클래스에 강하게 결합한다.

문제점을 사실 여러 개 쪼개려면 쪼갤 수 있지만, 가장 핵심은 하위 클래스가 상위 클래스에 강하게 결합한다는 것입니다. 하위 클래스는 상위 클래스의 해당 메소드나 필드를 사용할 수 있습니다. 다르게 보면, 하위 클래스는 상위 클래스와 강하게 결합하므로 응집력이 낮아져서 수동적인 객체가 됩니다. 수동적인 객체가 되면 결합된 객체에 영향을 크게 받으므로 변화에 대처하기 어려워집니다.

 

 

public class Document {
    public int length() {
        return this.content().length();
    }

    public String content() {  
    	// ...
    }
}

 

 

예를 들어, Document 라는 상위 클래스가 있습니다. 여기서 content() 메소드의 리턴 타입이 String이 아니고 char[]로 바뀐다면 어떨까요? 하위 클래스에서 해당 메소드를 사용하고 있다면 모조리 고쳐야 합니다.

 

 

그리고 기능 확장을 위해 잘 정의된 상위 클래스의 메소드를 오버라이딩하면 캡슐화를 깨뜨립니다. 캡슐화를 위해서는 외부에서 메소드를 가져다만 써야하는데, 오버라이딩은 구현을 수정하는 일이기 때문입니다.

 

또한, 상위 클래스의 내부 구현을 알아야 한다는 사실 자체가 캡슐화를 깨뜨립니다. 왜냐하면, 하위 클래스가 상위 클래스의 정의된 메소드를 오버라이딩할 때 문제가 없는 상위 클래스의 내부 구현을 반드시 확인해야하기 때문이죠. 만약 상위 클래스의 내부 구현을 모른채 이미 정의된 메소드를 재정의한다면 문제가 발생할 수 있습니다.

 

대표적으로 '직사각형 - 정사각형 문제'가 있습니다. 이것은 제가 과거의 LSP 원칙을 다룰 때 사용하였던 예시인데, 한 번 더 가져오겠습니다. 

 

우리는 직사각형은 정사각형이 아니지만, 정사각형은 직사각형이라는 사실을 학창 시절때 배웠습니다.

 

 

public class Rectangle {
    private int width;
    private int height;

    public void setWidth(final int width) {
        this.width = width;
    }

    public void setHeight(final int height) {
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }
}

public class Square extends Rectangle {

    @Override
    public void setWidth(final int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(final int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

 

 

따라서, 직사각형의 상속을 받은 정사각형을 객체를 정의할 수 있습니다. 다만, 정사각형은 가로와 세로의 길이가 같으므로 setWidth()나 setHeight()를 호출하면 가로와 세로를 모두 값을 바꿔줘야해서 메소드를 재정의해야 합니다.

 

 

   public void increaseHeight(final Rectangle rectangle) {
        if (rectangle.getHeight() <= rectangle.getWidth()) {
            rectangle.setHeight(rectangle.getWidth() + 1);
        }
    }

 

 

이제, 사용자 입장에서 위와 같이 직사각형의 가로와 세로를 비교한 다음에, 세로가 가로보다 짧거나 같다면 가로의 길이에 1을 더한 만큼의 길이를 갖게 만드는 메소드를 정의했다고 합시다. 정사각형이 아닌 직사각형에 대해서는 위 메소드가 올바르게 작동합니다. 예를 들어, 가로가 3, 세로가 2라고 한다면, 세로의 길이는 4가 되는 것이죠.

 

하지만, 정사각형의 경우는 다릅니다. 정사각형은 항상 가로와 세로의 길이가 같으므로 위 메소드를 실행하게 되면 가로와 세로의 길이가 모두 1씩 증가하게 됩니다. 즉, 우리가 원하는 "메소드 실행 후, 직사각형의 길이는 가로보다 세로가 길어야 한다."는 가정이 깨지게 되는 것입니다.

 

이것은 사용자로 하여금 혼란을 불러일으킵니다. 그리고 이러한 문제는 상위 객체의 내부 구현을 제대로 확인하지 않고 대충 메소드명으로만 판단하였기 때문입니다.

 

상위 객체의 setHeight() 메소드는 높이 값을 파라미터로 받은 값으로 변경하되, 너비 값은 변경하지 않는다는 명세를 제공합니다. 그리고 이러한 명세를 확인해야하는 것 자체가 내부 구현을 알아야 한다는 것이므로 결과적으로 캡슐화를 깨뜨리게 됩니다.

 

이렇듯, 상속은 상위 클래스의 강하게 결합하여 변경에 취약하며 캡슐화를 깨뜨리는 프로그램을 만들 여지가 있습니다. 추가로 더 이야기하자면, 상위 클래스의 결함이 있다면 하위 클래스도 그 결함을 그대로 넘겨 받게 됩니다. (ex. Vector에 상속을 받은 Stack). Vector가 왜 결함이 있는 클래스인지 궁금하시다면, 이곳을 참고하시길 바랍니다.

 

 

그럼 상속은 도대체 언제 사용하는가?

처음에는 상속 찬양을 하다가 이제는 상속 비난을 하는 글을 보시면서, "아니, 그러면 상속은 쓰라는 거야 쓰지 말라는 거야?" 라는 생각이 드실 수 있습니다. 제가 말씀드리고 싶은 것은 상속은 써야 한다는 것입니다. 이 잘 쓰기 위한 전제 조건을 지금부터 설명드리겠습니다.

 

우선, 상속이 적절한 경우부터 짚고 넘어가겠습니다. 상속은 클래스의 행동을 확장할 때가 아니라 정제할 때 사용하는 편이 좋습니다. 확장이란 새로운 행동을 덧붙여 기존의 행동을 부분적으로 보완하는 것을 의미하고 정제란 부분적으로 불완전한 행동을 완전하게 만드는 것을 의미합니다. 객체 지향 초기에 가장 중요시 여기는 개념은 재사용성이었지만, 지금은 워낙 시스템이 방대해지고 잦은 변화가 발생하다 보니 유연성이 더 중요한 개념이 되었습니다. (물론, 우리가 커스텀 예외를 사용하기 위하여 런타임 익셉션을 상속받는 경우가 받다 보니, 아예 정제할 때만 사용하라는 것은 아닙니다!)

 

즉, 상속은 정제할 때 사용하는 것이 바람직하다는 것인데, 코드로 정제를 어떻게 하는 지 도무지 감이 안 잡히실 겁니다. 잠시, 대학교 수업 시간을 돌아가 봅시다. 교수님이 클래스와 상속을 설명하실 때, 곁다리처럼 인터페이스와 추상 클래스를 설명하신 적이 있으실 겁니다. 그 당시에는 별로 필요도 없어 보이는 인터페이스나 추상 클래스를 왜 배운지도 몰랐고, 실제로 프로젝트에도 거의 적용하지 않은 경우가 많을 것으로 예상됩니다.

 

하지만, 이제는 감이 좀 오시지 않나요? 추상 클래스나 인터페이스는 추상 메소드가 존재하며, 하위 클래스에서는 해당 추상 메소드를 재정의해야 합니다. 추상 메소드는 불완전한 것이고, 하위 클래스가 불완전한 것을 재정의함으로써 완전한 것으로 탈바꿈시킵니다. 즉, 상속은 추상 클래스와 인터페이스로 구현하라는 것입니다.

 

다만, 추상 클래스와 인터페이스로 상속을 구현한다고 해서 무조건 캡슐화를 지킬 수 있는 것은 아닙니다. 제가 생각한 몇 가지 조건을 더 충족해야 합니다.

 

 

(1) 인스턴스 필드는 되도록 private로 설정할 것

말이 필요 없겠죠? 하위 클래스가 상위 클래스의 인스턴스 필드를 알 필요가 없습니다. 아는 순간 캡슐화가 깨지는 것이죠. 아, 물론 인터페이스는 필드가 없으므로 고려하지 않아도 됩니다. 

 

 

(2) 이미 정의된 메소드를 재정의하지 말 것

추상 클래스라고 모든 메소드를 추상 메소드로 만드는 것은 쉽지 않을 것입니다. 그리고 사실 모든 메소드가 꼭 추상 메소드일 필요도 없습니다. 아무리 변화에 민감한 최신이라고한들, 코드의 재사용이라는 장점을 아예 버리자는 것은 아니니까요. 인터페이스도 Java 8 이후 생긴 디폴트 메소드도 위에서 언급한 코드의 재사용으로 인해 생겨난 것이니까요.

 

즉, 우리는 상위 클래스에서 잘 정의된 메소드를 만드는 것까지는 괜찮으나, 하위 클래스에서 해당 메소드를 재정의해서는 안 됩니다.

 

개인적으로는 하위 클래스에서 코드를 작성할 때 상위 클래스에서 정의된 메소드도 이용하는 것을 추천하고 싶지는 않습니다. 왜냐하면, 결합도를 높이는 일이라서 상위 클래스의 메소드가 바뀌면 해당 메소드도 바꾸어야 하기 때문이죠. (다만, 이 부분은 적절하게 사용하는 것은 괜찮습니다. 어디까지나 남발은 금물입니다!)

 

 

(3) 클래스 상속의 경우 'is-a' 관계인지 확인할 것

상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 합니다. 학교에서 배웠듯이 정사각형은 직사각형이 맞지만, 그보다는 정사각형은 도형이라고 하는 편이 낫습니다. 만약, 하위 클래스가 상위 클래스의 진짜 하위 타입이 아니면 LSP를 위반하기 때문입니다.

 

 

상속보다는 조합을 사용하자

추상 클래스나 인터페이스를 이용하여 상속을 사용하라는 것은 알겠습니다. 그리고 인스턴스 필드는 private이어야 하고, 이미 상위 객체에서 정의한 메소드를 오버라이딩하지 말라는 것도 이해가 가실겁니다.

 

그러나, 'is-a' 관계라는 부분은 명확하지 않습니다. 예를 들어, 슈퍼맨은 인간이라고 생각하여 슈퍼맨 객체가 인간 객체에 상속을 받도록 구현할 수도 있습니다. 그러나, 나중에 슈퍼맨이 외계인 되었다는 식으로 변화가 발생하면 이 'is-a' 관계마저 깨지가 됩니다. 

 

 

public abstract class Man {
    public void move() {
        System.out.println("걷는다");
    }

    public void eat() {
        System.out.println("먹는다");
    }
    
    public boolean canTouchKryptonite(){
        return true;
    }
    
    public abstract void attack();
}

// 슈퍼맨은 외계인이 된 상태
class SuperMan extends Man {
    public void fly() {
        System.out.println("날아간다.");
    }
    
    @Override
    public boolean canTouchKryptonite(){
        return false;
    }
    
    @Override
    public void attack() {
    // 대충 공격 어떻게 한다는 뜻.
    }
}

 

 

인간은 크립토나이트를 만질 수 있으나, 슈퍼맨은 만질 수 없습니다. 하지만, 만약 처음 개발할 때 슈퍼맨은 인간이라고 생각했으면 당연히 크립토나이트를 만질 수 있을 것입니다. 추후 슈퍼맨은 외계인이라는 것을 깨닫고 나면 canTouchKryptonite() 메소드를 오버라이딩할 수 밖에 없습니다. 이러면 제가 위에서 언급했던 캡슐화를 깨는 행위를 하게 됩니다.

 

 

즉, 이렇게 머리 아픈 상속보다는 조합을 사용하라고 권하고 싶습니다.

 

 

public class Man {
    public void move() {
        System.out.println("걷는다");
    }

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

class SuperMan {
    private final Man man = new Man();

    public void move() {
        man.move();
    }

    public void eat() {
        man.eat();
    }

    public boolean canTouchKryptonite(){
        return false;
    }

    public void fly() {
        System.out.println("날아간다.");
    }
    
    public void attack() {
    // 대충 공격한다는 뜻.
    }
}

 

 

다음과 같이 공통된 메소드가 있는 객체를 필드로 두고, 적절한 상황에 메소드를 호출하기만 하면 됩니다. 이러면 Man 객체의 캡슐화를 보장할 수도 있고, Man 객체의 변화에 영향을 거의 받지 않습니다. 또한, Man이 결함이 있는 객체임을 알게 되어도 어느 정도 피해를 줄일 수 있습니다.

 

 

그럼에도 클래스 상속을 쓰고 싶다면?

상속보다는 조합이라고 이야기는 했지만, 그럼에도 상속이 더욱 효율적으로 보이는 순간은 분명히 있습니다. 그럴 때에는 추상 클래스를 사용하되, 해당 상위 클래스가 미래에도 거의 변하지 않는지 꼭 확인해야 합니다. 가령, 상위 클래스가 게임 규칙과 관련된 것이면 미래에도 변화가 거의 없을 것이므로 괜찮습니다. 아니면 추상 클래스 자체를 전부 추상 메소드로 만들면 확실하긴한데 이것은 현실적이지는 못합니다.

 

 

만약, 변화가 있더라도 사용하고 싶다면 해당 클래스의 문서화를 아주 잘 해놔야 합니다. 캡슐화를 깨더라도 기능이라도 잘 동작해야하기 때문이죠.

 

 

 

 

정리

가벼운 상속 이야기부터 시작하여 상속의 문제점과 그의 해결책을 알아 보았습니다. 상속을 잘 사용하는 것은 여전히 어려운 문제입니다.

 

저같은 경우는 되도록 인터페이스를 사용하되, 확실한 'is-a' 관계가 보이면 추상 클래스를 통한 상속을 적용하는 편입니다. 제가 앞서 'is-a' 관계가 추후 바뀔 수도 있다고 하였지만, 거의 변화하지 않을 듯한 관계라면 사용하는 편입니다. 그 외에는 최대한 조합을 통해 상속의 문제점을 극복하려고 합니다.

 

어디까지나 이번 포스팅은 제 주관이 강하게 들어갔기에 여러분의 댓글을 통한 피드백은 언제나 환영합니다.

 

 

참고 자료

 

상속 대신 조합을 고려해보자

상속은 생각보다 단점이 많다. 상속으로 발생하는 Side effect를 조합으로 우회 해보자.

unluckyjung.github.io

 

 

 

상속보다는 조합(Composition)을 사용하자.

woowacourse.github.io

 

 

 

상속보다는 컴포지션을 사용하라

누군가에게 상속과 composite의 개념에 대해 듣고 정리를 하기 위해 여러 블로그들을 참조하고, Effective Java 3/E Item 18. 상속보다는 컴포지션을 사용하라. 의 내용 정리입니다.

smjeon.dev

 

댓글

추천 글