자바 상속의 한계, 합성을 통해 극복해보기

동준·어제
0

개인공부(자바)

목록 보기
20/20
post-thumbnail

상속과 합성

처음 자바를 공부하고, 객체지향 개념을 어렴풋이 익히며 SOLID가 뭐의 약자인지 끙끙거리면서 외울 때에는 상속이 신기하게만 느껴졌었으나, 몇 번의 토이 프로젝트와 사이드 프로젝트를 거치면서 생각보다 불편하다는 걸 느끼게 됐었다.
코드를 재정의하고 뭔가 다형성을 실현해야지! 하지만 뭔가 다형성에 입각한 코드 설계라기보단 그냥 외부 레퍼런스를 보고 모방하는 느낌밖에 없었다. 단순히 이론을 아는 것과 이론을 깊게 이해하고 실제의 동작을 확인하는 것은 차이가 컸다.

마침 이번에 스터디하려는 세션에서 상속과 합성을 비교하는 주제가 있어서 해당 교재를 바탕으로 내 나름대로의 여러 실험들도 해보면서 상속과 합성의 특성들에 대해 비교하고 뭘 고찰하며, 어떻게 실질적으로 적용할 수 있을까에 대한 정답은 아녀도 해답을 찾아보려고 한다.

이번 포스팅은 왕정 저 '디자인 패턴의 아름다움' 교재를 공부하며 작성함

1. 상속의 한계, 그리고 대안책인 합성

1) 상속과 집합의 관계

상속을 공부하다 보면 수학의 집합이 생각난다. 좀 생뚱맞은 얘기일 수 있는데 아래의 그림을 보자.

image

집합 A는 집합 B의 부분집합이다. 즉, 집합 B에 속하는 모든 원소들은 곧 집합 A에 속하는 원소라고 봐도 언제나 참이다. 이를 객체의 상속 관계로 옮겨보자.

public class A {
    public String a;

    public void methodOnlyA() {
        System.out.println("집합 A에서만 정의");
    }

    protected void method() {
        a = "집합 A";
        System.out.println(a);
    }
}
public class B extends A {
    public String b;

    @Override
    public void method() {
        b = "집합 B";
        System.out.println(b);
    }
}

A 객체는 B 객체에 의해 상속되고 있다. 여기서 봐야할 점은 A 객체에만 정의해둔 methodOnlyA() 메소드인데, 이 메소드는 분명 B에는 명시되어 있지 않으나, 상속 개념에 의해 B의 인스턴스에서 해당 메소드를 호출할 수 있게 된다. 물론 접근 제한자에 따라서 호출 여부가 달라지겠지만 일단은 집합과의 관계를 위해 모든 필드와 메소드가 public이라고 생각해보자.

public class Main {
    public static void main(String[] args) {
        B b = new B();
        b.methodOnlyA(); // 집합 A에서만 정의
        b.method(); // 집합 B
    }
}

이를 통해서, 앞으로 A 클래스에서 (접근 제한자가 public이라는 가정 하에) 모든 메소드나 필드의 추가는 결국 상속 받는 자식 클래스인 B에서도 똑같이 포함된다. 결국 위의 집합처럼 A가 B의 부분집합 관계임을 알 수 있다. A 집합에 어떤 원소를 계속 추가해도 B 집합에 포함되는 관계가 유지되는 것이다.

2) 집합은 자명한 원소 기준이 있으나, 상속은 다차원적이다.

그렇지만 집합과 상속이 완벽히 매칭된다고 할 수는 없다. 집합에는 조건제시법을 통해 명확한 집합의 기준을 정의할 수 있으나 상속 관계의 클래스들은 그렇지 않다. 교재에 꽤나 재밌는 예시가 있어서 이를 확인해보자.

우리는 '새'를 추상 클래스로 정의할 것이다. 그리고 클래스 내부에는 '동작'을 의미하는 메소드를 작성해야 될 것이다. 새의 대표적인 동작은 '날기'다. 그래서 fly()라는 메소드를 Brid 클래스에 넣었다. 이제 준비가 끝났으니 Bird 클래스를 만들어서, 세상의 모든 새들을 상속시켜서 클래스로 만들려고 한다.

그런데 생각해보니 타조나 닭은 날지 못한다. 하지만 얘네들 역시 새에는 포함된다. 그래서 일단은 Bird 클래스를 상속받긴 하는데, 날지 못하는 새들이 fly() 메소드를 갖고 있다. 이런 모순인 상황을 타개하기 위해 고민해봤지만, 오버라이딩해서 예외를 반환시키는 방법 말고는 적절한 게 보이지 않는다.

결국 Bird 추상 클래스를 상속하는 FlyableBird 추상 클래스와 UnFlyableBird 추상 클래스를 추가로 정의해서 이제 새들을 클래스로 정의해볼까 한다. 그런데 또 생각해보니 노래를 부를 수 있냐 없냐로도 나뉠 수 있다. 그래서 이번에는 저 날 수 있는 지를 기준으로 나눈 추상 클래스들을 또 상속하는 FlyableTweetableBird, FlyableUnTweetableBird, UnFlyableTweetableBird, UnFlyableUnTweetableBird 추상 클래스를 정의했다...

교재에서는 새를 기준으로 클래스 정의를 했을 때, 상속과 관련해서 발생하는 문제점을 명확히 잡아내고 있다. 상속의 본질적인 핵심은 상위 클래스의 추상적 상태다. 표현이 추상적 상태라고 되어있지만, 결국 얼마나 많은 범위를 담아낼 수 있는 기준이냐를 의미한다. 결국 이 기준을 명확하게 잡지 않고 상위 클래스를 정의해서 상속을 적용하면 위와 같은 딜레마에 빠지게 된다.

위에서 말했던 새 클래스 예시도 결국 가장 추상적인 새를 어떻게 정의하냐에서 발생하는 문제라 볼 수 있다. 새라는 생물을 클래스로 표현하기 위해서는, 그리고 그 새들의 구체적인 종류들의 정의해나가기 위해서는 어떻게 초기 기준을 잡냐인데 그것을 날기로 잡을지 노래로 잡을지에 대한 고민이 위의 예제에서 드러나는 것이다.

이런 문제들에 대한 정답은 잡기가 매우 어렵다. 앞서 말했듯이 집합은 명확한 원소 기준을 제시할 수 있지만, 객체는 현실세계의 기준을 제시하기에는 그 분류 방법이 너무나도 다차원적이기 때문에 상속을 통해 기준을 실현해도 부작용이 발생할 수밖에 없다.

3) 상속 대신 합성을 써보자

위에서 말한 새 예제를 클래스로 표현한다면 이렇게 될 것이다.

// 새 추상 클래스
public abstract class Bird {
}

// 날 수 있냐, 없냐
public abstract class FlyableBird extends Bird {
    // 날기 메소드
}

public abstract class UnFlyableBird extends Bird {
}

// 노래할 수 있냐, 없냐
public abstract class FlyableTweetableBird extends FlyableBird {
    // 날기 메소드
    // 노래 메소드
}

public abstract class FlyableUnTweetableBird extends FlyableBird {
    // 날기 메소드
}

public abstract class UnFlyableTweetableBird extends UnFlyableBird {
    // 노래 메소드
}

public abstract class UnFlyableUnTweetableBird extends UnFlyableBird {
}

기준이 2개로 늘었을 뿐인데, 추상 클래스가 무려 7개나 된다. 각 추상 클래스에 대응되는 메소드들을 정의하고 하위 클래스에서 맞춰 오버라이딩을 하면서 추후 추가되는 기준에 따라 또 추상 클래스가 늘어나게 되면 단순히 코드 추가에 그치지 않고 상속 계층이 깊어지면서 코드의 가독성과 유지보수성에 영향을 끼치게 된다. 또한 전통적인 소프트웨어 아키텍처의 특성인 캡슐화를 깨뜨리게 된다. 왜냐하면 부모 클래스에서 정의했던 내용을 하위 클래스에서 다시 재정의하기 위해서는 접근 제한자의 개방이 강제될 수밖에 없기 때문이다.

상속의 본질적인 단점의 원인은, 자식 클래스가 부모 클래스의 모든 것을 가지면서 동화되는 것이다. 즉, 부모 클래스에서 public하게 정의한 것들은 자식 클래스에서 동일하게 활용이 가능하기 떄문에 자식 클래스는 곧 부모 클래스와 다를 바가 없어진다라는 문장이 성립하게 된다. 이 점이 상속의 본질적인 한계이자 단점을 나타낸다. 분명 코드의 재사용성을 증가시키려고 했지만 그만큼 결합이 너무 강력해지는 것이다.

이제 합성을 알아보자. 위에서 언급한 상속의 본질적인 단점인 결합도가 강해지는 것을 방지하기 위해서 부모의 모든 것을 넘기는 상속에서 부모의 필요한 동작만을 넘기는 합성이 대안책으로 제시되는 것이다. 아까 확인한 새 문제는 결국 새의 모든 것을 떠넘기면서 발생하는 문제라고 볼 수 있다. 분류 기준은 날기 혹은 노래 등에 해당하는 동작에 불과할 뿐인데, 그 동작을 기반으로 분류하기 위해 에 해당하는 모든 것을 넘겨주면서 코드의 가독성과 유지보수성이 떨어지게 되므로 동작만을 넘겨주는 방법을 채택하는 것이 합성의 핵심이다.

public interface Flyable {
    // 날기 추상 메소드
}

public interface Tweetable {
    // 노래 추상 메소드
}

public class Oriole implements Flyable, Tweetable {
    // 날기 메소드 구현
    // 노래 메소드 구현
}

public class Eagle implements Flyable {
    // 날기 메소드 구현
}

public class Chicken {
}

결국 합성은 객체의 '무엇이냐'보다는 '무엇을 할 수 있느냐'에 집중한다. 즉, 객체의 행동(동작)을 조립하듯 구성함으로써 유연하고 유지보수가 쉬운 구조를 만든다. 어디서 많이 봤다 싶더만, 전통적인 객체지향 프로그래밍에서 동작의 파라미터화를 통한 함수형 프로그래밍 리팩토링과 똑같은 형태다. 함수형에서 고차 함수를 활용해 행위를 분리하고 재조립하며, 객체에 포함된 해당 동작의 자세한 내용을 하드코딩하지 않고 외부의 내용에 의존하면서(의존성 주입) 다형성을 유연하게 실현하는 것과 유사하다.

2. 상속과 관련된 다양한 시나리오

부모 클래스의 내용을 자식 클래스에서 넘겨받고 오버라이딩하면서 기능을 확장하는 상속의 기본 골자에서 다양한 문법들과 기능들이 결합되면서 생각할 거리들이 던져진다. 나름대로 사고실험을 진행해보면서 상속의 근본적인 단점, 그리고 그 원인들에 대해 고찰해봤다.

1) 동작이 발생했을 때, 그 책임을 누구한테 넘길 것인가?

이 질문의 핵심부터 먼저 말하자면, 상속에서는 부모가 모든 계약의 기준 시작점이라는 것이다. 자식 클래스들에서 아무리 다양하게 메소드가 구현되어도 그 모태는 결국 부모 클래스의 메소드에 의존하게 된다. 아래 예제를 보자.

public abstract class Parent {

    public void process() {
        stepA();
        stepB();
        stepC();
    }

    protected abstract void stepA();
    protected abstract void stepB();
    protected abstract void stepC();
}

public class Child extends Parent {
    @Override
    protected void stepA() {
        System.out.println("스텝 A");
    }

    @Override
    protected void stepB() {
        System.out.println("스텝 B");
    }

    @Override
    protected void stepC() {
        System.out.println("스텝 C");
    }
}

다음과 같은 추상 클래스에서 로직의 스텝 단위로 추상 메소드를 정의한 다음, 해당 스텝들을 모아 하나의 프로세스 메소드를 정의하였다. 스텝들은 상속된 자식 클래스에서 다양하게 구현될 수 있을 것이다. 이 코드로만 봤을 때는 문제가 없어보이지만, 만약 스텝의 순서를 조정하거나 특정 스텝을 수정하고 추가할 경우에는 자식 클래스에서 취할 수 있는 방법이 없다. 즉, 부모 클래스를 다시 건드리게 되고 이 과정에서 또 다른 자식 클래스들에도 영향이 갈 수도 있다. 비슷한 구조를 합성의 형태로 바꿔보자.

public interface Step {
    void run();
}

public class Processor {
    public final List<Step> steps;

    public Processor(List<Step> steps) {
        this.steps = steps;
    }

    public void process() {
        for (Step step: steps) step.run();
    }
}

class StepA implements Step {
    @Override
    public void run() {
        System.out.println("스텝 A");
    }
}

class StepB implements Step {
    @Override
    public void run() {
        System.out.println("스텝 B");
    }
}

class StepC implements Step {
    @Override
    public void run() {
        System.out.println("스텝 C");
    }
}

상속 형태의 로직과 유사하지만 달라진 점은 Step 인터페이스를 통해 각 스텝들을 구현하면서 해당 스텝 단계들을 프로세서(부모)에서 직접 조립하는 것이 아닌 외부에서 받아오고 있다. 즉, 프로세서는 순수하게 스텝들을 모아 실행하는 점에만 치중하고 그 스텝들이 어떤 것인지, 순서는 어떻게 조정되는지에 대한 내용은 외부에서 정해져서 들어오게 된다. 이 내용은 의존성 주입(Dependency Injection) 의 핵심 원리와도 일맥상통한다. 기존의 스텝들이 모인 프로세스의 구체적인 흐름이 상속 구조에서는 부모 클래스 내부에서 강하게 결합되어있던 반면, 합성 구조에서는 외부로부터 의존성 주입을 받음으로써 프로세스의 구체적인 흐름과 느슨하게 결합된다.

상속은 미리 조립해뒀기 때문에 수정하려면 다시 부숴야 하고, 합성은 조립을 위한 준비만 해두고 조립은 외부에서 하기 때문에 위험성이 적은 구조다. 부모의 책임 여파가 자식에게 전달되며 공동 책임이 되는 상속과 달리 책임의 상세 내용은 외부에서 이뤄지기 때문에 합성은 책임의 여파에서 상대적으로 자유롭다.

2) 상속의 구조적 제약: 필드와 메소드의 취급 차이

다음과 같은 코드가 있다. 부모, 자식, 손자로 연쇄 상속이 되며 각각 필드를 지니고 있고 메소드를 오버라이딩한다.

public class Parent {
    public String value = "부모";

    public void method() {
        System.out.println("부모");
    }
}

public class Child extends Parent {
    public String value = "자식";

    @Override
    public void method() {
        System.out.println("자식");
    }
}

public class GrandChild extends Child {
    public String value = "손자";

    @Override
    public void method() {
        System.out.println("손자");
    }
}

이제 이 클래스를 인스턴스로 호출하여서 메소드와 필드를 각각 호출해본다. Parent 참조 타입의 변수에 자식 인스턴스(GrandChild 타입)를 담아서 해당 변수로부터 메소드와 필드를 로그로 찍어본다.

public class Main {
    public static void main(String[] args) {
        Parent parent = new GrandChild();

        parent.method();
        System.out.println(parent.value);
    }
}

실행하면 다음과 같은 결과가 나온다.

스크린샷 2025-04-06 오전 2 31 40

분명히 GrandChild 타입의 생성자를 통해 인스턴스를 생성했기 때문에 메소드 호출에서는 "손자" 로그가 찍히는 것이 정상인데, 필드 호출에는 생뚱맞게 "부모"가 찍히고 있다. 그 이유는 자바에서의 메소드 호출은 참조 타입이 아닌 인스턴스 타입이 결정하기 때문에 JVM의 가상 메소드 테이블을 통해 실제 객체 타입의 메소드를 찾아내는 동적 바인딩(다형성 실현)이 이뤄지는 반면, 필드는 컴파일 시점에 참조 타입 기준으로 정적 바인딩되기 때문이다. 그래서 메소드에는 오버라이딩이라는 개념이 있는 반면, 필드는 오버라이딩이 아닌 숨김 처리라고 표현하기도 한다.

이 결과가 시사하는 바는, 상속을 통해 다형성을 실현하려고 해도 메소드와 필드의 컴파일 및 런타임에서 발생하는 구조적인 차이를 극복하지 못한다. 상속 구조에서는 클래스 내부의 모든 데이터(필드, 메소드)들이 자식에게 공유되지만 그 공유 형태가 다르기 때문에 동작(메소드)은 자식 기준인데, 상태(필드)는 부모 기준인 모순적인 상황이 발생할 수 있다. 그래서 합성이 권장되는 이유기도 하는데, 그 특성상 분리와 조립이 자유롭기 때문에 유연하게 적용하면서 모순을 방지할 수 있다.

3) 다중 상속과 다중 구현의 관점, 그리고 final 키워드

위에서 얘기했던 상속과 합성의 본질적인 차이는 결국 자바가 왜 다중 상속을 허용하지 않는 반면, 다중 구현은 허용하는지에 대한 얘기로도 이을 수 있다. 상속은 결국 부모의 책임이 자식들에게 전파되면서 공동 책임이 되는 구조이기 때문에 만약 다중 상속이 허용된다면 책임을 중복으로 지게 되면서 언어 설계 차원에서 제한을 두게 된다.

class A {
    void hello() {
        System.out.println("A");
    }
}

class B {
    void hello() {
        System.out.println("B");
    }
}

class C extends A, B { } // C의 메소드 호출은 그럼 누구를 기준으로 삼는가..?

위 코드처럼 다중 상속 구조는 부모들 중 누구의 코드를 기준으로 넘겨받게 되는 지에 대해 컴파일러가 결정할 수 없게 되면서 컴파일 에러를 일으키게 된다. 컴파일러 관점에서는 그렇고 개발자 입장에서 테스트나 코드 유지보수가 매우 어려워지기 때문에 다중 상속을 막아둔 것이라고 볼 수 있겠다.

interface Flyable {
    void fly();
}

interface Shootable {
    void shoot();
}

class Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("파닥파닥");
    }
}

class IronMan implements Flyable, Shootable {
    @Override
    public void fly() {
        System.out.println("비행");
    }

    @Override
    public void shoot() {
        System.out.println("발사");
    }
}

반면 구현에서는 결국 실제 구현 책임이 공동이 아닌, 조립하는 곳에서 책임을 지면 된다. 인터페이스는 순수하게 선언에만 그치기 때문에 해당 구현체의 책임은 순전히 조립을 하는 곳에서만 질 뿐, 부모에 대응되는 인터페이스가 공동으로 책임질 이유가 없어진다. 이 부분과 관련하여 상속과 합성의 본질적인 차이가 드러나게 된다.

  • 상속은 구현(메소드)와 상태(필드)를 전부 넘긴다.
  • 합성은 구현(메소드)만 넘긴다.

위의 특징 때문에 인터페이스에서는 필드를 가지지 않는다. 더 정확히 말하자면 인스턴스 필드를 가지지 않는다. 아래처럼 필드를 인터페이스에 선언할 수 있지만, 취급은 정적 필드로 처리된다.

public interface Step {
    String step = "스텝";

    void run();
}
스크린샷 2025-04-06 오전 3 10 13

이제까지의 내용들을 정리해봤을 때, 상속이 생각보다 제약이 많은 이유는 그 구조적인 단점들이 쉽게 드러나고 코드의 전체 유지보수에 큰 영향을 끼치기 때문이다. 그래서 다중 상속 금지처럼 문법적으로 제약이 되는 부분들이 있고 아예 상속을 개발자가 직접 막는 final 키워드도 존재한다. 클래스에 final 키워드를 붙이면 상속이 금지되고 메소드에 final 키워드를 붙이면 오버라이딩이 금지되는 것처럼 자바에서는 상속을 최대한 신중히 허용하는 스탠스를 취해왔다. 참고로 final 키워드 및 그 부가 효과들은 자바 초기 버전부터 존재했다. 상속에 대해 얼마나 민감하게 대하는지를 어렴풋이 짐작할 수 있다.

3. 다른 언어에서는 어떨까?

지금까지 자바와 관련된 상속의 단점, 그리고 합성을 통한 극복 방안을 다뤄봤다. 마지막으로 다른 프로그래밍 언어들은 상속에 대해 어떤 스탠스를 취하는지 간단하게만 파악해보자.

1) 파이썬: 다중 상속을 허용하지만, 우선순위와 흐름은 명확히

파이썬은 상속에 대해 상당히 관대한 스탠스를 취한다. 단일 상속뿐 아니라 다중 상속도 공식적으로 지원하며, 이로 인해 발생할 수 있는 충돌을 방지하기 위해 엄격한 탐색 규칙인 MRO(Method Resolution Order) 를 도입했다.

class A:
    def greet(self):
        print("A")

class B:
    def greet(self):
        print("B")

class C(A, B):
    pass

c = C()
c.greet()  # MRO에 따라 "A" 출력

위 코드에서 CA, B를 동시에 상속하지만, greet() 호출 시 A의 메서드가 먼저 실행된다. 이유는 파이썬 내부에서 C3 선형화(C3 Linearization) 알고리즘을 통해 클래스의 탐색 순서를 정해두었기 때문이다. 실제로 C.mro()를 호출하면 다음과 같은 순서를 확인할 수 있다:

print(C.__mro__)
# (<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>)

파이썬은 상속 순서를 막지 않되, 우선순위를 명확히 정하겠다는 입장이다. 이는 자바처럼 아예 다중 상속을 금지하는 방향이 아닌, 프로그래머에게 강력한 자유를 주되 그 자유로 인한 위험은 규칙(MRO)으로 제어하겠다는 철학으로도 읽힌다. 또한 파이썬의 super()는 단순히 바로 윗 부모 클래스만 호출하는 것이 아니라, MRO에서 다음으로 탐색할 클래스를 호출하는 역할을 한다. 이 덕분에 다중 상속 구조에서도 위임 체인을 자연스럽게 타고 올라갈 수 있다.

class A:
    def greet(self):
        print("A")

class B(A):
    def greet(self):
        super().greet()
        print("B")

class C(B):
    def greet(self):
        super().greet()
        print("C")

C().greet()
# A
# B
# C

위 예제처럼 super() 호출이 연결되면, 마치 MRO 순서대로 메서드를 릴레이 호출하는 듯한 결과가 나온다. 특히 다중 상속에서 이 구조는 충돌을 방지하면서 명확하고 예측 가능한 흐름을 보장한다. 자바가 다중 상속을 막고 구조적 충돌을 미연에 방지하는 반면, 파이썬은 충돌이 발생할 수 있음을 전제로 규칙을 설계하고, 명확한 실행 흐름을 보장함으로써 안정성을 확보하는 방향을 택한 셈이다.

2) 코틀린: 자바와 유사하면서도 더 엄격한 상속 규칙

단순히 코틀린이 자바의 업그레이드 혹은 슈퍼셋이라고 생각했었는데, 코틀린은 코틀린만의 설계 철학을 보유하고 있었고 상속에서도 그 점이 드러난다. 원칙적으로 코틀린에서는 모든 클래스가 final 취급이어서 기본적으로 상속이 불가능하고 open 키워드를 붙여야 상속이 가능해진다.

open class A // open 키워드를 붙여야 상속 가능
class B : A() 

또한 인터페이스에서도 자바처럼 다중 구현을 허용하지만 명시적으로 어떤 부모를 구현할지를 나타내야 한다.

interface A {
    fun greet() = println("A")
}

interface B {
    fun greet() = println("B")
}

class C : A, B {
    override fun greet() {
        super<A>.greet()  // 명확하게 지정
    }
}

자바에서는 분명 상속에 대해 조심스러운 스탠스를 취함에도 불구하고 명시적으로 엄금한다는 문법적 의도가 드러나는 편은 아니다. final 키워드나 sealed 키워드를 통해 상속을 금지할 수 있을 뿐, 기본적으로 모든 클래스는 상속이 가능한 구조를 띈다. 반면 코틀린은 원천적으로 상속을 금지하면서 open 키워드를 통해 상속을 가능하게 할 수 있고 인터페이스에서도 다중 구현은 허용하지만 그것에 대한 구체적인 명시가 요구된다. 코틀린이 객체지향에 함수형 사고를 더하며 자바를 바탕으로 재설계한 언어라는 점을 봤을 때, 유지보수의 효율성을 높이기 위한 선택 중 하나로써 상속 원천금지가 존재한다고 볼 수 있겠다.

3) 자바스크립트: 프로토타입의 유연함 속 모호한 스탠스

자바스크립트는 다른 언어들과 다르게 클래스 이전에 프로토타입이란 개념을 먼저 파악해야 한다. 자바에서는 클래스를 바탕으로 인스턴스를 공장에서 찍어내는 개념이었다면 자바스크립트는 기존 객체를 바탕으로 아예 새로운 객체를 생성하는 구조를 채택하고 있다.

const parent = {
  greet() {
    console.log("안녕");
  }
};

const child = Object.create(parent); // child 객체를 만들되, parent를 프로토타입으로 설정
child.greet(); // child 객체에 greet() 메소드가 없으니 프로토타입 체인을 타고 거슬러 올라가 parent.greet()을 실행

이를 프로토타입 상속이라고 하는데, 기존의 상속이 오버라이딩하면서 확장해나가는 것과 다르게 자바스크립트에서는 필요할 때 위로 거슬러 올라가 찾는다는 개념을 제시하고 있다. ES6 이후에 class 키워드가 자바스크립트에도 등장했지만 그냥 키워드와 그 쓰이는 모양새가 자바와 닮았을 뿐, 내부적으로는 완전 다른 동작을 취하기 때문에 자바스크립트에서는 class를 문법적 설탕이라는 표현으로 부른다.

class A {
  greet() {
    console.log("A");
  }
}

class B extends A {
  greet() {
    super.greet(); // A
    console.log("B");
  }
}

const b = new B();
b.greet(); // A \n B

마치 자바처럼 상속되고 생성자를 호출해 메소드를 실행하는 것처럼 보이지만, 실상은 저 class 키워드도 자바스크립트에서는 함수로 취급되고 프로토타입 체이닝을 통해 B의 인스턴스가 부모 A의 메소드에 접근 가능하도록 연결해서 거슬러 올라간다. 그래서 super는 컴파일 시점에 참조 타입을 결정하는 것이 아닌, 런타임 시점에 동적으로 부모 메소드를 찾아 호출하게 된다. 이런 특성들 때문에 유연한 만큼 예측이 상당히 어려워서 타입스크립트가 등장하는 계기가 됐다.

4. 마치며

상속은 비단 프로그래밍 언어의 문법의 일부로 치부할 만큼 가벼운 주제가 아니다. 객체의 재사용과 효율성 향상을 위해 개발자들의 오랜 시간의 고민이 녹여져 있는 부분이며 각 프로그래밍 언어에서 이를 어떻게 활용함과 동시에 발생하는 부수적 효과들의 대처와 연관된 설계 철학이 직접적으로 드러난다고 볼 수 있다. 그만큼 생각할 거리가 많고 동시에 너무나도 어려운 내용이기도 했다. 아직도 아리송하고 더 이해할 부분들이 많긴 하지만, 나름대로 객체지향을 같이 고민하면서 상속의 본질적인 부분에 대해 건드려보고 합성이 어떻게 대안점이 되는지 둘을 비교해볼 수 있었다.

있으니까 써야지에서 왜 있을까를 고민할 수 있어서 좋은 주제였다:)

profile
scientia est potentia / 벨로그 이사 예정...

0개의 댓글

관련 채용 정보