들어가며

우리는 앞선 챕터에서 코드 재사용을 목적으로 상속을 사용하면 컴파일 타임 의존성 때문에 변경하기 어렵고 유연하지 못한 설계에 이를 확률이 높아진다는 점을 배웠다. 거듭 강조하지만 상속의 목적은 코드 재사용이 아니다. 상속은 타입 계층을 구조화 하기 위해 사용해야 한다. 이번 챕터에서 살펴보겠지만 타입 계층은 객체지향 프로그래밍의 중요한 특성 중 하나인 다형성의 기반을 제공한다.

상속을 이용해 자식 클래스를 추가하려 한다면 스스로에게 다음과 같은 질문을 해보길 바란다. 상속을 사용하려는 목적이 단순히 코드 재사용을 위해서인가? 아니면 클라이언트 관점에서 인스턴스들을 동일하게 행동하는 그룹으로 묶기 위해서인가?

이번 장에서는 상속의 관점에서 다형성이 구현되는 기술적인 메커니즘을 살펴보기로 한다. 이번 장을 학습하고 나면 다형성이 런타임에 메시지를 처리하기 위해 적합한 메서드를 동적으로 탐색하는 과정을 통해 구현되며, 상속이 이런 메서드를 찾기 위한 일종의 탐색 경로를 클래스 계층의 형태로 구현하기 위한 방법이라는 사실을 이해하게 될 것이다.


다형성

다형성(Polymorphism)이라는 단어는 컴퓨터 과학에서 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력 으로 정의한다. 간단히 말해서 다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 잇는 방법이라고 할 수 있다.

다형성의 종류는 다양하다. 오버로딩 다형성, 강제 다형성, 매개변수 다형성, 포함 다형성과 같이 하나에만 국한되지 않는다. 하지만 객체지향 패러다임에서 말하는 다형성은 주로 포함 다형성을 의미한다. 이번 챕터에서도 포함 다형성에 대해서 학습한다.

포함 다형성메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력을 의미한다.

위 코드는 포함 다형성의 전형적인 예를 잘 보여준다. Movie 클래스는 discountPolicy에게 calculateDiscountAmount 메시지를 전송하지만 실제로 실행되는 메서드는 메시지를 수신한 객체의 타입에 따라 달라진다.

포함 다형성을 구현하는 가장 일반적인 방법은 상속을 이용하는 것이다. 두 클래스를 상속 관계로 연결하고 자식 클래스에서 부모 클래스의 메서드를 오버라이딩한 후 클라이언트는 부모 클래스만 참조하면 포함 다형성을 구현할 수 있다.

포함 다형성 -> 서브타입 다형성이라고 부른다는 사실에서 예상할 수 있겠지만 포함 다형성을 위한 전제 조건은 자식 클래스가 부모 클래스의 서브타입이어야 한다는 것이다. 그리고 상속의 진정한 목적은 코드 재사용이 아니라 다형성을 위한 서브타입 계층을 구축하는 것이다.

포함 다형성을 위해 상속을 사용하는 가장 큰 이유는 상속이 클래스들을 계층으로 쌓아 올린 후 상황에 따라 적절한 메서드를 선택할 수 있는 메커니즘을 제공하기 때문이다.

객체가 메시지를 수신하면 객체지향 시스템은 메시지를 처리할 적절한 메서드를 상속 계층 안에서 탐색한다. 실행할 메서드를 선택하는 기준은 어떤 메시지를 수신했느냐에 따라, 어떤 클래스의 인스턴스인지에 따라, 상속 계층이 어떻게 구성되어있는지에 따라 달라진다.

이번 장의 목표는 포함 다형성의 관점에서 런타임에 상속 계층 안에서 적절한 메서드를 선택하는 방법을 이해하는 것이다.


상속의 양면성

상속의 목적은 코드 재사용이 아니다. 상속은 프로그램을 구성하는 개념들을 기반으로 다형성을 가능하게 하는 타입 계층을 구축하기 위한 것이다. 타입 계층에 대한 고민 없이 코드를 재사용하기 위해 상속을 사용하면 이해하기 어렵고 유지보수하기 버거운 코드가 만들어질 확률이 높다. 문제를 피할 수 잇는 유일한 방법은 상속이 무엇이고 언제 사용해야 하는지를 이해하는 것 뿐이다.

이번 장에서는 상속의 메커니즘을 이해하기 위해 필요한 몇 가지 개념을 살펴보자.

  • 업캐스팅
  • 동적 메서드 탐색
  • 동적 바인딩
  • self 참조
  • super 참조

상속을 사용한 강의 평가

Lecture 클래스 살펴보기 상속의 예를 보기 위해 Lecture라는 클래스를 살펴보자.

아래는 이수 기준이 70점인 객체지향 프로그래밍 과목의 수강생 5명에 대한 성적 통계를 구하는 코드를 나타낸 것이다.

상속을 이용해 Lecture 클래스 재사용하기 Lecture 클래스는 새로운 기능을 구현하는 데 필요한 대부분의 데이터와 메서드를 포함하고 있다. 따라서 Lecture 클래스를 상속받으면 새로운 기능을 쉽고 빠르게 추가할 수 있을 것이다. 원하는 기능은 Lecture의 출력 결과에 등급별 통계를 추가하는 것이므로 클래스의 이름으로는 GradeLecture가 적절할 것 가다. GradeLecture 클래스에는 Grade 인스턴스들을 리스트로 보관하는 인스턴스 변수 grades를 추가하자.

Grade 클래스는 등급의 이름(name)과 각 등급 범위를 정의하는 최소 성적과 최대 성적을 인스턴스 변수로 포함한다. include 메서드는 수강생의 성적이 등급이 포함되는지를 검사한다.

이제 GradeLecture 클래스에 학생들의 이수 여부와 등급별 통계를 함께 반환하도록 evaluate 메서드를 재정의하자.

GradeLectureevaluate 메서드에서는 예약어 super를 사용해 Lecture 클래스의 evaluate 메서드를 먼저 실행한다.

여기서 주목할 부분은 GradeLectureLecture에 구현된 두 evaluate 메서드의 시그니처가 완전히 동일(Override)하다는 것이다. 부모 클래스와 자식 클래스에 동일한 시그니처를 가진 메서드가 존재할 경우 자식 클래스의 메서드 우선순위가 더 높다.

이처럼 상속받은 메서드와 동일한 시그니처 메서드를 재정의하여 부모 클래스의 구현을 새로운 구현을 대체하는 것을 메서드 오버라이딩이라고 부른다.

부모 클래스에 없던 새로운 메서드를 자식 클래스에 추가하는 것도 가능하다. 예를 들어, 다음과 같이 등급별 평균 성적을 구하는 average 메서드를 추가할 수 있다.

evaluate 메서드와 달리 GradeLectureaverage 메서드는 부모 클래스인 Lecture에 정의된 average 메서드와 이름은 같지만 시그니처는 다르다. 두 메서드의 시그니처가 다르기 때문에 GradeLecture의 average 메서드는 Lecture의 average 메서드를 대체하지 않으며 결과적으로 두 메서드는 사이좋게 공존할 수 있다.

이처럼 부모 클래스에서 정의한 메서드와 이름은 동일하지만 시그니처는 다른 메서드를 자식 클래스에 추가하는 것을 메서드 오버로딩이라고 부른다.

데이터 관점의 상속

다음과 같이 Lecture의 인스턴스를 생성했다고 가정하자.

Lecture의 인스턴스를 생성하면 시스템은 인스턴스 변수 title, pass, scores를 저장할 수 있는 메모리 공간을 할당하고 생성자의 매개변수를 이용해 값을 설정한 후 생성된 인스턴스의 주소를 lecture라는 이름의 변수에 대입한다. 아래의 그림은 메모리 상에 생성된 객체의 모습을 개념적으로 표현한 것이다.

이번에는 GradeLecture의 인스턴스를 생성했다고 가정하자. GradeLecture 클래스의 인스턴스는 직접 정의한 인스턴스 변수 뿐만 아니라 부모 클래스인 Lecture가 정의한 인스턴스 변수도 함께 포함된다.

메모리 상에 생성된 GradeLecture는 아래의 그림과 같이 표현할 수 있다. 상속을 인스턴스 관점에서 바라볼 때는 개념적으로 자식 클래스의 인스턴스에서 부모 클래스의 인스턴스로 접근 가능한 링크가 존재한다는 것처럼 생각하는것이 유용하다. 인스턴스를 참조하는 lectureGradeLecture의 인스턴스를 가리키기 때문에 특별한 방법을 사용하지 않으면 Lecture의 인스턴스에 직접 접근할 수 없다.

요약하면 데이터 관점에서 상속은 자식 클래스의 인스턴스에서 부모 클래스로 접근 가능한 링크가 존재하는 것으로 볼 수 있다.

행동 관점의 상속

행동 관점의 상속부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미한다. 즉, 부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함된다. 따라서 외부의 객체가 부모 클래스의 인스턴스에게 전송할 수 있는 모든 메시지는 자식 클래스의 인스턴스에게도 전송할 수 있다. 앞에서 부모 클래스인 Lecture 클래스에서 evaluate를 구현하고 있기 때문에 자식 클래스인 GradeLecture에서 evaluate 메서드를 구현하지 않더라도 evaluate 메시지를 처리할 수 있는 이유가 바로 이 때문이다.

부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 포함되어진다고 표현했지만 실제로 클래스의 코드를 합치거나 복사하는 작업이 수행되는 것은 아니다.

그렇다면 어떻게 부모 클래스에서 구현한 메서드를 자식 클래스의 인스턴스에서 수행할 수 있을까? 그 이유는 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스에서 탐색하기 때문이다.

다시한번 강조한다. 런타임에 메서드를 탐색하다가 자식 클래스에 해당 메서드가 없을 경우 부모 클래스에서 메서드를 탐색한다.

이처럼 행동 관점에서 상속과 다형성의 기본적인 개념을 이해하기 위해서는 상속 관계로 연결된 클래스 사이의 메서드 탐색 과정을 이해하는 것이 가장 중요하다.

객체의 경우에는 서로 다른 상태를 저장할 수 있도록 각 인스턴스 별로 독립적인 메모리를 할당받아야 한다. 하지만 메서드의 경우에는 동일한 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한 번만 메모리에 로드하고 각 인스턴스별로 클래스를 가리키는 포인터를 갖게 하는 것이 경제적이다.

아래 그림은 두 개의 Lecture 인스턴스를 생성한 후의 메모리 상태를 개념적으로 표현한 것이다. 그림에서 오른쪽에 위치한 사각형들은 메모리에 로드된 클래스를 표현한다. 인스턴스는 두개가 생성됐지만 클래스는 단 하나만 메모리에 로드되었다는 사실에 주목하라. 각 객체는 자신의 클래스인 Lecture의 위치를 가리키는 class라는 이름의 포인터를 가지며 이 포인터를 이용해 자신의 클래스 정보에 접근할 수 있다.

Lecture 클래스가 자신의 부모 클래스인 Object의 위치를 가리키는 parent라는 이름의 포인터를 가진다는 사실에도 주목하자. 이 포인터를 이용하면 클래스의 상속 계층을 따라 부모 클래스의 정의로 이동하는 것이 가능하다.

이제 자식 클래스의 인스턴스를 통해 어떻게 부모 클래스에 정의된 메서드를 실행할 수 있는지 살펴보자. 메시지를 수신한 객체는 class 포인터로 연결된 자신의 클래스에서 적절한 메서드가 존재하는지를 찾는다. 만약 메서드가 존재하지 않으면 클래스의 parent 포인터를 따라 부모 클래스를 차례로 훑어가며 적절한 메서드가 존재하는지를 검색한다.

자식 클래스에서 부모 클래스로의 메서드 탐색이 가능하기 때문에 자식 클래스는 마치 부모 클래스에 구현된 메서드의 복사본을 갖고 있는 것 처럼 보이게 된다. 따라서 각 객체에 포함된 class 포인터와 클래스에 포함된 parent 포인터를 조합하면 현재 인스턴스의 클래스에서 최상위 부모 클래스에 이르기 까지 모든 부모 클래스에 접근하는 것이 가능하다.

마지막으로 GradeLecture 클래스의 인스턴스를 생성했을 때의 메모리 구조를 살펴보자. GradeLecture 클래스는 Lecture 클래스의 자식 클래스이기 때문에 아래 그림에서 볼 수 있는 것 처럼 GradeLecture의 인스턴스는 Lecture의 인스턴스를 내부에 포함한다. GradeLecture 인스턴스의 class 포인터를 따라가면 GradeLecture 클래스에 이르고 GradeLecture 클래스의 parent 포인터를 따라가면 부모 클래스인 Lecture 클래스에 이르게 된다. Lecture 클래스의 parent 포인터는 자바에서 모든 클래스의 부모 클래스인 Object를 가리키기 때문에 상속 계층은 여기서 끝나게 된다.


업캐스팅과 동적 바인딩

같은 메시지, 다른 메서드

실행 시점에 메서드를 탐색하는 과정을 자세히 살펴보기 위해 지금까지 작성한 성적 계산 프로그램에 각 교수별로 강의에 대한 성적 통계를 계산하는 기능을 추가해보자. 통게를 계산하는 책임은 Professor 클래스가 맡기로 하자. Professor 클래스의 compileStatistics 메서드는 통계 정보를 생성하기 위해 Lectureevaluate 메서드와 average 메서드를 호출한다.

다음은 다익스트라 교수가 강의한느 알고리즘 과목의 성적 통계를 계산하는 코드다.

위 코드에서 Professor 클래스의 인스턴스를 생성할 때 생성자의 두 번째 인자로 Lecture 클래스의 인스턴스를 전달했다. 만약 Lecture 클래스 대신 자식 클래스인 GradeLecture의 인스턴스를 전달하면 어떻게 될까?

생성자의 인자 타입은 Lecture로 선언돼 있지만 GradeLecture의 인스턴스를 전달하더라도 아무 문제 없이 실행된다는 사실을 알 수 있다. 위 예제는 동일한 객체 참조인 lecture에 대해 동일한 evaluate 메시지를 전송하는 동일한 코드 안에서 서로 다른 클래스 안에 구현된 메서드를 실행할 수 있다는 사실을 알 수 있다.

이처럼 코드 안에 선언된 참조 타입과 무관하게 실제로 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 달라질 수 있는 것은 업캐스팅동적 바인딩이라는 메커니즘이 작용하기 때문이다.

  • 부모 클래스(Lecture) 타입으로 선언된 변수에 자식 클래스(GradeLecture)의 인스턴스를 할당하는 것이 가능하다. 이를 업캐스팅이라고 부른다.

  • 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다. 이것은 객체지향 시스템이 메시지를 처리할 적절한 메서드를 컴파일 타임이 아닌 런타임에 결정하기 때문이다. 이를 동적 바인딩이라 부른다.

동일한 수신자(lecture)에게 동일한 메시지를 전송(lecture.evalute())하는 동일한 코드를 이용해 서로 다른 메서드(Lecture.evaluate() of GradeLecture.evaluate())를 실행할 수 있는 이유는 업캐스팅과 동적 메서드 탐색이라는 기반 메커니즘이 존재하기 때문이다.

업캐스팅은 서로 다른 클래스의 인스턴스를 동일한 타입에 할당하는 것을 가능하게 해준다. 따라서 부모 클래스에 대해 작성된 코드를 전혀 수정하지 않고도 자식 클래스에 적용할 수 있다. 동적 메서드 탐색은 부모 클래스의 타입에 대해 메시지를 전송하더라도 실행 시에는 실제 클래스(메시지를 수신한 객체의 타입)를 기반으로 실행될 메서드가 선택되게 해준다. 따라서 코드를 변경하지 않고도 실행되는 메서드를 변경할 수 있다.

개방-폐쇄 원칙과 의존성 역전 원칙

업캐스팅동적 메서드 탐색에 대한 설명을 읽다 보면 자연스럽게 머릿속에서 개방-폐쇄 원칙이 떠오를 것이다. 업캐스팅동적 메서드 탐색은 코드를 변경하지 않고도 기능을 추가할 수 있게 해주며, 이것은 개방-폐쇄 원칙의 의도와도 일치한다.

개방-폐쇄 원칙은 유연하고 확장 가능한 코드를 만들기 위해 의존 관계를 구조화하는 방법을 설명한다. 업캐스팅과 동적 메서드 탐색은 상속을 이용해 개방-폐쇄 원칙을 따르는 코드를 작성할 때 하부에서 동작하는 기술적인 내부 메커니즘을 설명한다. 개방-폐쇄 원칙이 목적이라면 업캐스팅동적 메서드 탐색은 목적에 이르는 방법이다.

아마 바로 전에 살펴본 Professor 예제가 의존성 역전 원칙을 따른다고 생각할지도 모르겠다. 하지만 Professor추상화가 아닌 구체 클래스(Lecture 클래스)에 의존하고 있기 때문에 의존성 역전 원칙을 따른다고 말하기는 어렵다. 사실 현재의 코드가 개방-폐쇄 원칙을 따르는 코드를 만들기 위해 상속을 올바르게 사용했다고 말하기도 어려운데 개방-폐쇄 원칙의 중심에는 추상화가 위치하고 있기 때문이다.


업캐스팅

상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐지는 것 처럼 보이기 때문에 부모 클래스의 인스턴스에게 전송할 수 있는 메시지를 자식 클래스의 인스턴스에게 전송할 수 있다. 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용하더라도 메시지를 처리하는 데는 아무런 문제가 없으며, 컴파일러는 명시적인 타입 변환 없이도 자식 클래스가 부모 클래스를 대체할 수 있게 허용한다.

이런 특성을 활용할 수 있는 대표적인 두 가지가 대입문메서드의 파라미터 타입이다.

부모 클래스 타입으로 선언된 파라미터에 자식 클래스의 인스턴스를 전달하는 것도 가능하다.

반대로 부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 변환하기 위해서는 명시적인 타입 캐스팅이 필요한데, 이를 다운 캐스팅이라고 한다.

컴파일러의 관점에서 자식 클래스는 아무런 제약 없이 부모 클래스를 대체할 수 있기 때문에 부모 클래스와 협력하는 클라이언트는 다양한 자식 클래스의 인스턴스와도 협력이 가능하다. 여기서 자식 클래스란 현재 상속 계층에 존재하는 자식 클래스 뿐만 아니라 앞으로 추가될지도 모르는 미래의 자식 클래스들을 포함한다. Lecture의 모든 자식 클래스들은 evaluate 메시지를 이해할 수 있기 때문에 ProfessorLecture를 상속받는 어떤 자식클래스와도 협력할 수 있는 무한한 확장 가능성을 가진다. 따라서 이 설계는 유연하며 확장이 용이하다.

동적 바인딩

전통적인 언어에서 함수를 실행하는 방법은 함수를 호출하는 것이다. 객체지향 언어에서 메서드를 실행하는 방법은 메시지를 전송하는 것이다. 함수 호출메시지 전송 사이의 차이는 생각보다 큰데 프로그램 안에 작성된 함수 호출 구문과 실제로 실행되는 코드를 연결하는 언어적인 메커니즘이 완전히 다르기 때문이다.

함수를 호출하는 전통적인 언어들은 호출될 함수를 컴파일타임에 결정한다. 다시말해 코드가 작성하는 시점에 호출될 코드가 결정된다. 이처럼 컴파일타임에 호출할 함수를 결정하는 방식을 정적 바인딩, 초기 바인딩, 컴파일타임 바인딩이라고 부른다.

객체지향 언어에서는 메시지를 수신했을 때 실핼됭 메서드가 런타임에 결정된다. foo.bar()라는 코드를 읽는 것만으로는 실행되는 bar()가 어떤 클래스의 어떤 메서드인지를 판단하기 어렵다. foo가 가리키는 객체가 실제로 어떤 클래스의 인스턴스인지를 알아야 하고 bar 메서드가 해당 클래스의 상속 계층의 어디에 위치하는지를 알아야 한다. 이처럼 실행될 메서드를 런타임에 결정하는 방식을 동적 바인딩 또는 지연 바인딩 이라고 부른다.

ProfessorcompileStatistics 메서드가 호출하는 lectureevalute 메서드는 어떤 클래스에 정의돼 있는가? 클래스 정의를 살펴보는 것만으로는 정확한 메서드를 알 수 없다. 실행 시점에 어떤 클래스의 인스턴스를 생성해서 전달하는지를 알아야만 실제로 실행되는 메서드를 알 수 있다.

객체지향 언어가 제공하는 업캐스팅동적 바인딩을 이용하면 부모 클래스 참조에 대한 메시지 전송을 자식 클래스에 대한 메서드 호출로 변환할 수 있다. 그렇다면 객체지향 언어는 어떤 규칙에 따라 메시지 전송과 메서드 호출을 바인딩하는 것일까?


동적 메서드 탐색과 다형성

객체지향 시스템은 다음 규칙에 따라 실행할 메서드를 선택한다.

  • 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메서드가 존재하는지 검사한다. 존재하면 메서드를 실행하고 탐색을 종료한다.

  • 메서드를 찾지 못했다면 부모클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속된다.

  • 상속 계층의 가장 최상위 클래스에 이르렀지만 메서드를 발견하지 못한 경우 예외를 발생시키며 탐색을 중단한다.

메서드 탐색과 관련해서 이해해야 하는 중요한 변수가 하나 있다. 바로 self 참조(self reference)이다. 객체가 메시지를 수신하면 컴파일러는 self 참조라는 임시 변수를 자동으로 생성한 후 메시지를 수신한 객체를 가리키도록 설정한다.

동적 메서드 탐색self가 가리키는 객체의 클래스에서 시작해서 상속 계층의 역방향으로 이뤄지며 메서드 탐색이 종료되는 순간 self 참조는 자동으로 소멸된다.

시스템은 앞서 설명한 class 포인터parent 포인터, self 참조를 조합하여 메서드를 탐색한다.

간단한 예를 통해서 메서드 탐색 과정을 살펴보자. 아래 그림은 메시지를 수신한 시점GradeLecture 인스턴스의 메모리 상태를 나타낸 것이다. 시스템은 메시지를 처리할 메서드를 탐색하기 위해 self 참조가 가리키는 메모리로 이동한다. 이 메모리에는 객체의 현재 상태를 표현하는 데이터와 객체의 클래스를 가리키는 class 포인터가 존재한다. class 포인터를 따라 이동하면 메모리에 로드된 GradeLecture 클래스의 정보를 읽을 수 있다. 클래스 정보 안에는 클래스 안에 구현된 전체 메서드의 목록이 포함되어 있다. 이 목록 안에 메시지를 처리할 적절한 메서드가 존재하면 해당 메서드를 실행한 후 동적 메서드 탐색을 종료한다.

GradeLecture 클래스에서 적절한 메서드를 찾지 못했다면 parent 참조를 따라 부모 클래스인 Lecture 클래스로 이동한 후 ㅊ탐색을 계속한다. 시스템은 상속 계층을 따라 최상위 클래스인 Object 클래스에 이를때까지 메서드를 탐색한다. 최상위 클래스에 이르러서도 적절한 메서드를 찾지 못한 경우에는 에러를 발생시키고 메서드 탐색을 종료한다.

메서드 탐색은 자식 클래스에서 부모 클래스의 방향으로 진행된다. 따라서 항상 자식 클래스의 메서드가 부모 클래스의 메서드보다 먼저 탐색되기 때문에 자식 클래스에 선언된 메서드가 부모 클래스의 메서드보다 더 높은 우선순위를 갖게 된다.

지금까지의 설명을 종합해보면 동적 메서드 탐색두 가지 원리로 구성된다는 것을 알 수 있다. 첫 번째 원리는 자동적인 메시지 위임이다. 자식 클래스는 자신이 이해할 수 없는 메시지를 전송받은 경우 상속 계층을 따라 부모 클래스에게 처리를 위임한다. 클래스 사이의 위임은 프로그래머의 개입 없이 상속 계층을 따라 자동으로 이뤄진다.

두 번째 원리는 메서드를 탐색하기 위해 동적인 문맥을 사용한다는 것이다. 메시지를 수신했을 때 실제로 어떤 메서드를 실행할지는 컴파일 시점이 아닌 런타임 시점에 이뤄지며, 메서드를 탐색하는 경로는 self 참조를 이용해서 결정한다.

메시지가 처리되는 문맥을 이해하기 위해서는 정적인 코드를 분석하는 것만으로는 충분하지 않다. 런타임에 실제로 메시지를 수신한 객체가 어떤 타입인지를 추적해야 한다. 이 객체의 타입에 따라 메서드를 탐색하는 문맥이 동적으로 결정되며, 여기서 가장 중요한 역할을 하는 것이 바로 self 참조다.

자동적인 메시지 위임

동적 메서드 탐색의 입장에서 상속 계층은 메시지를 수신한 객체가 자신이 이해할 수 없는 메시지를 부모 클래스에게 전달하기 위한 물리적인 경로를 정의하는 것으로 볼 수 있다. 상속 계층 안의 클래스는 메시지를 처리할 방법을 알지 못할 경우 메시지에 대한 처리를 부모 클래스에게 위임한다. 부모 클래스 역시 수신한 메시지를 이해할 수 없다면 자신의 부모 클래스에게 메시지를 전달한다. 여기서 핵심은 적절한 메서드를 찾을 때까지 상속 계층을 따라 부모 클래스로 처리가 위임된다는 것이다.

상속을 이용할 경우 프로그래머가 메시지 위임과 관련된 코드를 명시적으로 작성할 필요가 없음에 주목하자. 메시지는 상속 계층을 따라 부모 클래스에게 자동으로 위임된다. 이런 관점에서 상속 계층을 정의하는 것은 메서드 탐색 경로를 정의하는 것과 동일하다.

자식 클래스에서 부모 클래스의 방향으로 자동으로 메시지 처리가 위임되기 때문에 자식 클래스에서 어떤 메서드를 구현하고 있느냐에 따라 부모 클래스에 구현된 메서드의 운명이 결정되기도 한다.

“메서드 오버라이딩”은 자식 클래스의 메서드가 동일한 시그니처를 가진 부모 클래스의 메서드보다 먼저 탐색되기 때문에 벌어지는 현상이다.

동일한 시그니처를 가지는 자식 클래스의 메서드는 부모 클래스의 메서드를 감추지만 이름만 같고 시그니처가 완전히 동일하지 않은 메서드들은 상속 계층에 걸쳐 사이좋게 공존할 수도 있다. 이것이 바로 메서드 오버로딩이다.

메서드 오버라이딩

메서드 오버라이딩을 이해하기 위해 Lecture 클래스의 인스턴스에 evaluate() 메시지를 전송하는 코드를 예로 들어보자.

아래 그림은 Lecture 인스턴스에게 evalute 메시지를 전송한 시점의 메모리 상태를 나타낸 것이다. 런타임에 자동으로 self 참조가 메시지 수신 객체를 가리키도록 설정된다는 사실을 반드시 기억하자.

이제 evaluate 메시지를 수신했을 때 실행될 메서드를 결정하는 과정을 따라가보자. 아래 그림은 이 과정을 그림으로 나타낸 것이다. 메서드 탐색은 self 참조가 가리키는 객체의 클래스인 Lecture에서 시작하게 된다. 다행스럽게도 Lecture 클래스 안에 evaluate 메서드가 존재하기 때문에 시스템은 메서드를 실행한 후 메서드 탐색을 종료한다.

이번에는 Lecutre의 자식 클래스인 GradeLecture 인스턴스에 evaluate 메시지를 전송할 경우의 메서드 탐색 과정을 살펴보자.

부모 클래스 Lecture에서 정의한 evaluate 메서드와 시그니처가 동일한 메서드를 자식 클래스인 GradeLecture에서 재정의 하고 있다. 그리고 실행 결과는 예상했던 것 처럼 lecture의 타입인 Lecture에 정의된 메서드가 아닌 실제 객체를 생성한 클래스인 GradeLecture에 정의된 메서드가 실행된다. 동적 메서드 탐색self 참조가 가리는 객체의 클래스인 GradeLecture에서 시작되고 GradeLecture 클래스 안에 evaluate 메서드가 구현돼있기 때문에 먼저 발견된 메서드가 실행되는 것이다.

동적 메서드 탐색이 자식 클래스에서 부모 클래스의 순서로 진행된다는 사실을 떠올려보면 실행 결과를 쉽게 이해 가능하다. 자식 클래스와 부모 클래스 양쪽 모두에 동일한 시그니처를 가진 메서드가 구현돼 있다면 자식 클래스의 메서드가 먼저 검색된다. 따라서 자식 클래스의 메서드가 부모 클래스의 메서드를 감추는 것 처럼 보이게 된다.

이와 같이 자식 클래스가 부모 클래스의 메서드를 오버라이딩하면 자식 클래스에서 부모 클래스로 향하는 메서드 탐색 순서 때문에 자식 클래스의 메서드가 부모 클래스의 메서드를 감추게 된다.

메서드 오버로딩

GradeLecture 인스턴스에 average(String grade) 메시지를 전송하는 경우를 살펴보자.

이 경우에는 메시지에 응답할 수 있는 average 메서드를 GradeLecture 클래스에서 발견할 수 있기 때문에 동적 메서드 탐색은 탐색이 시작되는 첫 번째 클래스인 GradeLecture에서 종료된다.

이번에는 GradeLecture 클래스의 인스턴스에 이름은 동일하지만 파라미터를 갖지 않는 average() 메시지를 전송하는 경우를 살펴보자.

앞의 경우와 동일하게 동적 메서드 탐색은 메시지를 수신한 객체의 클래스인 GradeLecture에서 시작된다. 하지만 이번에는 GradeLecture 클래스 안에서 메시지에 응답할 수 있는 적절한 메서드를 발견하지 못하기 때문에 부모 클래스인 Lecture 클래스에서 메서드를 찾으려고 시도한다. 다행히 Lecture 클래스 안에 적절한 시그니처를 가진 avaerage() 메서드가 존재하기 때문에 해당 메서드를 실행한 후 메서드 탐색을 종료한다.

메서드 오버라이딩은 자식 클래스가 부모 클래스에 존재하는 메서드와 동일한 시그니처를 가진 메서드를 재정의해서 부모 클래스의 메서드를 감추는 현상을 가리킨다. 하지만 average() 메서드와 average(String grade) 메서드는 이름은 같지만 시그니처는 다르다.

이처럼 시그니처가 다르기 때문에 동일한 이름의 메서드가 공존하는 경우를 메서드 오버로딩이라고 부른다. 메서드 오버라이딩은 메서드를 감추지만 메서드 오버로딩은 사이좋게 공존한다. 다시말해서 클라이언트의 관점에서 오버로딩된 모든 메서드를 호출할 수 있는 것이다.


동적인 문맥

이제 우리는 lecture.evaluate()라는 메시지 전송 코드만으로는 어떤 클래스의 어떤 메서드가 실행될지를 알 수 없다는 사실을 이해했다. 여기서 중요한 것은 메시지를 수신한 객체가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 동적으로 바뀐다는 것이다. 그리고 이 동적인 문맥을 결정하는 것은 바로 메시지를 수신한 객체를 가리키는 self 참조이다.

self 참조Lecture의 인스턴스를 가리키고 있다면 메서드를 탐색할 문맥은 Lecture 클래스에서 시작해서 Object 클래스에서 종료되는 상속 계층이 된다. self 참조GradeLecture 인스턴스를 가리키고 있다면 메서드 탐색의 문맥은 GradeLecture 클래스에서 시작해서 Object 클래스에서 종료되는 상속 계층이 된다.

동일한 코드라고 하더라도 self 참조가 가리키는 객체가 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다.

따라서 self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 바꿀 수 있다.

self 참조가 동적인 문맥을 결정한다는 사실은 종종 어떤 메서드가 실행될지를 예상하기 어렵게 만든다. 대표적인 경우가 자신에게 다시 메시지를 전송하는 self 전송이다. self 전송의 특성을 이해하기 위해 LectureGradeLecture 클래스에 평가 기준에 대한 정보를 반환하는 stats 메서드를 추가하자 Lecture 클래스에서는 stats 메서드 안에서 자신의 getEvaluateMethod 메서드를 호출한다는 사실을 주의깊게 살펴보기 바란다.

자신의 getEvaluateMethod를 호출한다고 표현했지만 사실 이 말은 정확하지 않다. getEvaluateMethod()라는 현재 클래스의 메서드를 호출하는 것이 아니라 현재 객체에게 getEvaluateMethod 메시지를 전송하는 것이다. 다시한번 강조하겠다. 현재 클래스의 메서드를 호출하는 것이 아니라 현재 객체에게 메시지를 전송하는 것이다.

그렇다면 현재 객체란 무엇인가? 바로 self 참조가 가리키는 객체다. 이 객체는 처음에 stats 메시지를 수신했던 바로 그 객체다. 이처럼 self 참조가 가리키는 자기 자신에게 메시지를 전송하는 것을 self 전송이라고 부른다. self 전송을 이해하기 위해서는 self 참조가 가리키는 바로 그 객체에서부터 탐색을 다시 시작한다는 사실을 기억해야 한다.

self 전송은 현재의 클래스가 아닌 현재의 객체에게 메시지를 전송한다.

self 전송을 이해하기 위해 stats 메서드 탐색 과정을 따라가보자. Lecture의 인스턴스가 stats 메시지를 수신하면 self 참조는 메시지를 수신한 Lecture 인스턴스를 가리키도록 자동 할당된다. 시스템은 이 객체의 클래스인 Lecture에서 stats 메서드를 발견하고는 이를 실행시킬 것이다.

stats 메서드를 실행하던 중에 getEvaluateMethod 메서드 호출 구문을 발견하면 시스템은 self 참조가 가리키는 현재 객체에게 메시지를 전송해야 한다고 판단한다. 결과적으로 stats 메시지를 수신한 동일한 객체에게 getEvaluateMethod 메시지를 전송할 것이다. 결과적으로 self 참조가 가리키는 Lecture 클래스에서부터 다시 메서드 탐색이 시작되고 LecturegetEvaluateMethod 메서드를 실행한 후에 메서드 탐색을 종료한다.

여기서 중요한 것은 getEvaluateMethod()라는 문장이 Lecture 클래스의 getEvaluateMethod 메서드를 실행시키라는 의미가 아니라 self가 참조하는 현재 객체에 getEvaluateMethod 메시지를 전송하라는 의미라는 것이다. 그리고 메서드 탐색은 처음에 메시지 탐색을 시작했던 self 참조가 가리키는 바로 그 클래스에서부터 다시 시작한다는 것이다.

여기까지는 단순하다. 하지만 상속이 끼어들면 이야기가 달라진다. 이번에는 Lecture 클래스를 상속받는 GradeLecture 클래스에서 다음과 같이 getEvaluateMethod 메서드를 오버라이딩 해보자.

GradeLecturestats 메시지를 전송하면 self 참조GradeLecture의 인스턴스를 가리키도록 설정되고 메서드 탐색은 GradeLecture 클래스에서부터 시작된다. GradeLecutre 클래스에는 stats 메시지를 처리할 수 있는 적절한 메서드가 존재하지 않기 때문에 부모 클래스인 Lecture에서 메서드 탐색을 계속하고 Lecture 클래스의 stats 메서드를 발견하고는 이를 실행할 것이다.

Lecture 클래스의 stats 메서드를 실행하는 중에 self 참조가 가리키는 객체에게 getEvaluateMethod 메시지를 전송하는 구문과 마주치게 된다. 이제 메서드 탐색은 self 참조가 가리키는 객체에서 시작된다. 여기서 self 참조가 가리키는 객체는 바로 GradeLecture 인스턴스다. 따라서 메시지 탐색은 Lecture 클래스를 벗어나 self 참조가 가리크는 GradeLecture에서부터 다시 시작된다.

시스템은 GradeLecture 클래스에서 getEvaluateMethod 메서드를 발견하고 실행한 후 동적 메서드 탐색을 종료한다. 그 결과 Lecture 클래스의 stats 메서드와 GradeLecture 클래스의 getEvaluateMethod메서드의 실행 결과를 조합한 문자열이 반환될 것이다.

self 전송은 자식 클래스에서 부모 클래스 방향으로 진행되는 동적 메서드 탐색 경로를 다시 self 참조가 가리키는 원래의 자식 클래스로 이동시킨다. 이로 인해 최악의 경우에는 실제로 실행될 메서드를 이해하기 위해 상속 계층 전체를 훑어가며 코드를 이해해야 하는 상황이 발생할 수도 있다.

결과적으로 self 전송이 깊은 상속 계층과 계층 중간중간에 함정처럼 숨겨져 있는 메서드 오버라이딩과 만나면 극단적으로 이해하기 어려운 코드가 만들어 진다.

이제 self 참조가 메시지 탐색을 위한 문맥을 동적으로 결정한다는 사실을 이해했을 것이다. 지금까지 설명한 것 처럼 업캐스팅동적 바인딩이라는 언어적인 특성과 실행 시점에 적절한 메서드를 선택하는 동적 메서드 탐색을 혼합하여 동일한 코드를 이용해 서로 다른 메서드를 실행하는 것이 가능해진다. 객체지향 프로그래밍 언어는 이와 같은 메커니즘의 도움을 받아 동일한 메시지에 대해 서로 다른 메서드를 실행할 수 있는 다형성을 구현하는 것이다.

self 와 super

self 참조의 가장 큰 특징은 동적이라는 것이다. self 참조는 메시지를 수신한 객체의 클래스에 따라 메서드 탐색을 위한 문맥을 실행 시점에 결정한다. self의 이러한 특성과 대비해서 언급할 만한 가치가 있는 것이 바로 super 참조다.

자식 클래스에서 부모 클래스의 구현을 재사용해야 하는 경우가 있다. 대부분의 객체지향 언어들은 자식 클래스에서 부모 클래스의 인스턴스 변수나 메서드에 접근하기 위해 사용할 수 있는 super 참조라는 내부 변수를 제공한다.

대부분의 사람들은 super.something()이라는 문장이 단순히 부모 클래스의 something 메서드를 호출한다고 생각할 것이다. 하지만 super.something()에 의해 호출되는 메서드는 부모 클래스의 메서드가 아니라 더 상위에 위치한 조상 클래스의 메서드일 수도 있다.

사실 super 참조의 용도는 부모 클래스에 정의된 메서드를 실행하기 위해서가 아니다. super 참조의 정확한 의도는 지금 이 클래스의 부모 클래스에서 부터 메서드 탐색을 시작하세요 이다. 만약 부모 클래스에서 원하는 메서드를 찾지 못한다면 더 상위의 부모 클래스로 이동하면서 메서드가 존재하는지 검사한다.

이것은 super 참조를 통해 실행하고자 하는 메서드가 반드시 부모 클래스에 위치하지 않아도 되는 유연성을 제공한다. 그 메서드가 조상 클래스 어딘가에 있기만 하면 성공적으로 탐색될 것이기 때문이다.

self 전송이 메시지를 수신하는 객체의 클래스에 따라 메서드를 탐색할 시작 위치를 동적으로 결정하는데 비해 super 전송은 항상 메시지를 전송하는 클래스의 부모 클래스에서부터 시작된다.

이를 앞에서 self 참조를 통해 getEvalutionMethod 메시지를 전송했던 self 전송의 예와 비교해보면 self 전송은 어떤 클래스에서 메시지 탐색이 시작될지 알지 못한다. Lecture일 수도 있고 GradeLecture일 수도 있고 미래에 추가될 새로운 자식 클래스일 수도 있다.

super 전송은 다르다 super 전송은 항상 해당 클래스의 부모 클래스에서부터 메서드 탐색을 시작한다. self 전송에서 메시지 탐색을 시작하는 클래스는 미정이지만 super 전송에서는 미리 정해진다는 것이다. 따라서 self 전송의 경우 메서드 탐색을 시작할 클래스를 반드시 실행 시점에 동적으로 결정해야 하지만 super 전송의 경우에는 컴파일 시점에 미리 결정해놓을 수 있다.

지금까지 살펴본 것 처럼 동적 바인딩, self 참조, super 참조상속을 이용해 다형성을 구현하고 코드를 재사용하기 위한 가장 핵심적인 재료다. 동적 바인딩self 참조는 동일한 메시지를 수신하더라도 객체의 타입에 따라 적합한 메서드를 동적으로 선택할 수 있게 한다. super 참조는 부모 클래스의 코드에 접근할 수 있게 함으로써 중복 코드를 제거할 수 있게 한다.


상속 대 위임

지금까지 살펴본 것 처럼 다형성self 참조가 가리키는 현재 객체에서 메시지를 전달하는 특성을 기반으로 한다. 동일한 타입의 객체 참조에게 동일한 메시지를 전송하더라도 self 참조가 가리키는 객체의 클래스가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 달라진다.

일단 self 참조가 동적인 문맥을 결정한다는 사실을 이해하고 나면 상속을 바라보는 새로운 시각이 형성된다. 바로 자식 클래스에서 부모 클래스로 self 참조를 전달하는 메커니즘으로 상속을 바라보는 것이다.

위임과 self 참조

자식 클래스의 인스턴스를 생성할 경우 아래 그림과 같이 개념적으로 자식 클래스 안에 부모 클래스의 인스턴스를 포함하는 것으로 표현할 수 있다. 이 그림을 보고 다음 질문에 답해보자. GradeLecture 인스턴스의 입장에서 self 참조는 무엇을 가리키는가? 당연히 GradeLecture 인스턴스 자신이다.

그렇다면 GradeLecture 인스턴스에 포함된 Lecture 인스턴스의 입장에서 self 참조는 무엇을 가리킬까? 처음에는 다소 의아하게 생각될 수도 있겠지만 이 경우에도 GradeLecture의 인스턴스다. self 참조는 항상 메시지를 수신한 객체를 가리키기 때문이다.

따라서 메서드 탐색 중에는 자식 클래스의 인스턴스와 부모 클래스의 인스턴스가 동일한 self 참조를 공유하는 것으로 봐도 무방하다. 따라서 위 그림을 아래 그림과 같이 바꿀 수 있다. 그리고 상속 계층을 구성하는 객체들 사이에서 self 참조를 공유하기 때문에 개념적으로 각 인스턴스에서 self 참조를 공유하는 self라는 변수를 포함하는 것처럼 표현할 수 있다.

개념을 좀 더 쉽게 설명하기 위해 GradeLecture에서 Lectureself 참조가 공유되는 과정을 상속을 이용하지 않고 직접 코드로 구현해보자. 여기서는 루비를 이용해 이 과정을 표현해보겠다. 먼저 Lecture 클래스의 코드를 살펴보자.

이 코드에서 눈여겨 볼 부분은 stats 메서드 인자로 this를 전달한다는 것이다. 이 this에는 메시지를 수신한 객체를 가리키는 self 참조가 보관된다. 루비에서는 self가 예약어 이므로 여기서는 대신 this를 사용했다. 모든 객체지향 언어는 self 참조를 자동으로 생성하고 할당하기 때문에 우리가 작성한 코드처럼 메서드의 첫 번째 파라미터로 this를 받을 필요가 없다는 사실을 명심하자. 여기서는 단지 self 참조가 상속 계층을 따라 전달된다는 상황을 재현하기 위해 인위적으로 this를 전달하고 있는 것이다.

stats 메서드는 인자로 전달된 this에게 getEvaluationMethod 메시지를 전달한다. 이 코드는 실제로 실행되는 메서드가 LecturegetEvaluationMethod가 아닐 수도 있다는 사실을 명시적으로 드러낸다. this에 전달되는 객체가 Lecture의 인스턴스라면 LecturegetEvaluationMethod 메서드가 실행되겠지만 getEvaluationMethod 메서드를 정의한 다른 객체가 전달된다면 해당 객체의 메서드가 실행될 수도 있을 것이다.

아래 코드는 명시적으로 객체가 실행될 문맥인 lecturestats 메서드의 인자로 전달한다. 이제 Lecturestats 메서드가 실행되고 이어서 this.getEvaluationMethod() 구문이 실행될 것이다. 현재 thisLecture의 인스턴스 이므로 LecturegetEvaluationMethod 메서드가 실행될 것이다.

이제 Lecture를 상속 받는 GradeLecture 클래스의 코드를 추가하자.

GradeLecture 클래스는 전통적인 상속 관계를 나타내는 구문을 사용하지 않고 자식 클래스의 인스턴스가 부모 클래스의 인스턴스에 대한 링크를 포함하는 것으로 상속 관계를 흉내내고 있다. 여기서는 인스턴스 변수인 @parent에 부모 클래스인 Lecture의 인스턴스를 할당하고 있다.

Lecture 처럼 GradeLecturestats 메서드를 호출하기 위해서는 실행 문맥인 GradeLecture의 인스턴스를 직접 전달해야 한다.

위 코드에서 중요한 부분은 네 가지다.

첫째, GradeLecture는 인스턴스 변수인 @parentLecture의 인스턴스를 생성해서 저장한다. 따라서 GradeLecture의 인스턴스에서 Lecture의 인스턴스로 이동할 수 있는 명시적인 링크가 추가된다. 우리는 이 링크를 통해 컴파일러가 제공해주던 동적 메서드 탐색 메커니즘을 직접 구현한다.

둘째, GradeLecturestats 메서드는 추가적인 작업 없이 @parent에게 요청을 그대로 전달한다. 이것은 자식 클래스에 메서드가 존재하지 않을 경우에 부모 클래스에서 메서드 탐색을 계속 하는 동적 메서드 탐색 과정을 흉내낸 것이다. 동적 메서드 탐색은 런타임에 클래스의 메타 정보를 이용해 자동으로 처리를 위임하지만 우리의 경우에는 메시지 전달 과정을 직접 구현하고 있다는 차이가 있을 뿐이다. 이를 위해 부모 클래스와 동일한 메시지를 수신하기 위해 부모 클래스의 퍼블릭 메서드를 그대로 선언하고 요청을 전달하는 코드를 구현하고 잇는 것이다. 이때 실행 문맥을 자식 클래스에서 부모 클래스로 전달하는 상속 관계를 흉내내기 위해 인자로 전달받은 this를 그대로 전달한다는 점에 주목하라.

셋째, GradeLecutregetEvaluationMethod 메서드는 stats 메서드 처럼 요청을 @parent에 전달하지 않고 자신만의 방법으로 메서드를 구현하고 있다. 이제 GradeLecture의 외부에서는 LecturegetEvaluationMethod 메서드가 감춰진다. 부모 클래스의 메서드와 동일한 메서드를 구현하고 부모 클래스와는 다른 방식으로 메서드를 구현하는 것은 상속에서의 메서드 오버라이딩과 동일하다.

넷째, GradeLecturestats 메서드는 인자로 전달된 this를 그대로 Lecturestats 메서드에 전달한다. Lecturestats 메서드는 인자로 전달된 this에게 getEvaluationMethod 메시지를 전송하기 때문에 LecturegetEvaluationMethod가 아니라 GradeLecutregetEvaluationMethod 메서드가 실행된다. 이 과정은 self 전송에 의한 동적 메서드 탐색 과정과 동일하다.

GradeLecture의 stats 메서드는 메시지를 직접 처리하지 않고 Lecture의 stats 메서드에게 요청을 전달한다는 것에 주목하자. 이처럼 자신이 수신한 메시지를 다른 객체에게 동일하게 전달해서 처리를 요청하는 것을 위임이라고 한다.

‘위임’은 본질적으로 자신이 정의하지 않거나 처리할 수 없는 속성 또는 메서드의 탐색 과정을 다른 객체로 이동시키기 위해 사용한다. 이를 위해 ‘위임’은 항상 “현재의 실행 문맥을 가리키는 self 참조”를 인자로 전달한다. 바로 이것이 “self 참조”를 전달하지 않는 포워딩과 위임의 차이점이다.

객체가 다른 객체에게 요청을 처리할 때 인자로 self를 전달하지 않을 수도 있다. 이것은 요청을 전달 받은 최초의 객체에게 다시 메시지를 전송할 필요는 없고 단순히 코드를 재사용 하고 싶은 경우라고 할 수 있다. 이처럼 처리를 요청할 때 self 참조를 전달하지 않는 경우를 ‘포워딩’ 이라고 한다. 이와 달리 self 참조를 전달하는 경우에는 ‘위임’ 이라고 부른다. ‘위임’의 정확한 용도는 클래스를 이용한 상속 관계를 객체 사이의 합성 관계로 대체해서 다형성을 구현하는 것이다.

위 설명을 읽어도 와닿지 않을 수 있다. 앞선 챕터의 예제를 잠깐 살펴보자.

위 예제는 Phone의 객체가 calculateFee 메시지를 수신했을 때 합성 관계인 RatePolicy의 인스턴스 ratePolicy에게 self 참조를 전달하여 처리를 요청한다.

그렇다면 이 경우에 PhoneRatePolicy에게 요청을 위임하는 것일까? 아니면 포워딩 하는 것일까?

위임self 참조를 전달하여 요청을 한다고 하였다. 반면 포워딩self 참조를 전달하지 않음으로써 코드를 재사용 한다고 하였다. 이 문맥만 살펴보면 self 참조를 전달하는 위 예제 코드는 위임 이라고 생각 할 수 있다. 하지만 위 예제는 포워딩에 해당한다. 위임의 정확한 용도는 클래스를 이용한 상속 관계를 객체 사이의 합성 관계로 대체해서 다형성을 구현 한다는 표현에 주목해야 한다.

PhoneRatePolicy상속 관계를 합성 관계로 표현한 것일까? 그렇지 않다. Phone IS A RatePolicy는 성립하지 않는다. 좀 더 확실한 설명을 위해 저자님(조영호 분)의 메일 답변을 첨부한다.

단순히 합성 관계로 연결되어있다고 해서 위임이라고 부르지는 않고 “올바른 상속 관계를 합성으로 변경했을 경우에만 위임”이라고 부를 수 있습니다. 여기에서 올바른 상속관계란 “올바른 is-a. 관계”를 의미합니다. 즉, 개념적으로 ‘리스코프 치환 원칙’을 만족하면서 대체 가능한 객체여야 하는 것이죠. 예제에서 RatePolicy is-a Phone이 아닙니다. 따라서 이 관계는 위임이라고 보지 않습니다.


이제 우리는 위임객체 사이의 동적인 연결 관계를 이용해 상속을 구현하는 방법이라는 사실을 이해했다. 상속이 매력적인 이유는 우리가 직접 구현해야 하는 이런 번잡한 과정을 자동으로 처리해 준다는 점이다. 간단히 GradeLectureLecture의 자식 클래스로 선언하면 실행 시에 인스턴스들 사이에서 self 참조가 자동으로 전달된다. 이 self 참조의 전달은 결과적으로 자식 클래스의 인스턴스와 부모 클래스의 인스턴스 사이에 동일한 실행 문맥을 공유할 수 있게 해준다.

이번 절을 시작할 때 상속 관계로 연결된 클래스 사이에는 자동적인 메시지 위임이 일어난다고 설명했다. 이제 왜 위임이라는 단어를 사용했는지 이해할 수 있을 것이다. 상속은 동적으로 메서드를 탐색하기 위해 현재의 실행 문맥을 가지고 있는 self 참조를 전달한다. 그리고 이 객체들 사이에서 메시지를 전달하는 과정은 자동으로 이루어진다. 따라서 자동적인 메시지 위임이라고 하는 것이다.

지금까지 살펴본 것처럼 클래스 기반의 객체지향 언어에서 객체 사이의 위임을 직접 구현하는 것은 생각보다 쉽지 않다. 하지만 클래스 기반의 객체지향 언어가 클래스 사이의 메시지 위임을 자동으로 해주는 것 처럼 프로토타입 기반의 객체지향 언어는 객체 사이의 메시지 위임을 자동으로 처리해준다.

프로토타입 기반의 객체지향 언어

우리는 위임 예제를 통해 클래스가 아닌 객체를 이용해서도 상속을 흉내낼 수 있다는 사실을 알게되었다. 사실 클래스가 존재하지 않고 오직 객체만 존재하는 프로토타입 기반의 객체지향 언어에서 상속을 구현하는 유일한 방법은 객체 사이의 위임을 이용하는 것이다.

클래스 기반의 객체지향 언어들이 상속을 이용해 클래스 사이에 self 참조를 자동으로 전달하는 것처럼 프로토타입 기반의 객체지향 언어들 역시 위임을 이용해 객체 사이에 self 참조를 자동으로 전달한다.

여기서는 자바스크립트를 이용해 객체 사이의 상속이 어떻게 이뤄지는지 살펴보자.

자바스크립트의 모든 객체들은 다른 객체를 가리키는 용도로 사용되는 prototype 이라는 이름의 링크를 가진다. prototype은 앞에서 위임을 직접 구현했던 예제에서 부모 객체를 가리키기 위해 사용했던 인스턴스 변수 @parent와 동일한 것으로 봐도 무방하다. 차이점이라면 prototype은 언어 차원에서 제공되기 때문에 self 참조를 직접 전달하거나 메시지 포워딩을 번거롭게 직접 구현할 필요가 없다는 것이다.

자바스크립트에서 인스턴스는 메시지를 수신하면 먼저 메시지를 수신한 객체의 prototype 안에서 메시지에 응답할 적절한 메서드가 존재하는지 검사한다. 만약 메서드가 존재하지 않는다면 prototype이 가리키는 객체를 따라 메시지 처리를 자동적으로 위임한다. 이것은 상속에서 클래스 사이에 메시지를 위임했던 것과 유사하다. 이와같이 자바스크립트에서는 prototype 체인으로 연결된 객체 사이에 메시지를 위임함으로써 상속을 구현할 수 있다.

예제를 살펴보자. 자바스크립트에서 객체를 생성하는 고전적인 방법은 생성자 함수에 대해 new 연산자를 호출하는 것이다. Lecture 함수를 정의하고 이 함수로부터 생성될 객체들이 공유할 stats 메서드와 getEvaluationMethod 메서드를 구현하자.

메서드를 Lectureprototype이 참조하는 객체에 정의했다는 점에 주목하자. Lecture를 이용해서 생성된 모든 객체들은 prototype 객체에 정의된 메서드를 상속받는다. 특별한 작업을 하지 않는 한 prototype에 할당되는 객체는 자바스크립트의 최상위 객체 타입인 Object이다. 따라서 Lecture를 이용해서 생성되는 모든 객체들은 prototype이 참조하는 Object에 정의된 모든 속성과 메서드를 상속받는다.

이제 GradeLectureLecture를 상속받게 하고 getEvaluationMethod 메서드를 오버라이딩 하자.

GradeLectureprototypeLecture의 인스턴스를 할당했다는 것에 주목하자. 이 과정을 통해 GradeLecture를 이용해 생성된 모든 객체들이 prototype을 통해 Lecture에 정의된 모든 속성과 함수에 접근할 수 있게 된다. 이제 메시지를 전송하면 prototype으로 연결된 객체 사이의 경로를 통해 객체 사이의 메서드 탐색이 자동으로 이뤄진다.

아래와 같이 GradeLecture의 인스턴스를 생성한 후 stats 메시지를 전송해보자.

메시지를 수신한 인스턴스는 먼저 GradeLecutrestats 메서드가 존재하는지 검사한다. GradeLecture에는 stats 메서드가 존재하지 않기 때문에 다시 prototype을 따라 Lecture의 인스턴스에 접근한 후 stats 메서드가 존재하는지 살펴본다. 이 경우에는 메서드를 발견한다. 이제 Lecturestats 메서드가 실행될 것이다.

자바스크립트 실행 환경은 Lecturestats 메서드를 실행하는 도중에 this.getEvaluationMethod() 문장을 발견한다. 이 경우에도 상속과 마찬가지로 self 참조가 가리키는 현재 객체에서부터 다시 메서드 탐색을 시작한다. 메서드 탐색 결과, 현재 객체의 prototype이 참조하고 있는 GradeLecture의 인스턴스에서 getEvaluationMethod 메서드를 발견하고 이 메서드를 실행함으로써 동적 메서드 탐색이 종료된다.

위 설명에서 알 수 있는 것처럼 메서드 탐색하는 과정은 클래스 기반의 상속과 거의 동일하다. 단지 정적인 클래스 간의 관계가 아니라 동적인 객체 사이의 위임을 통해 상속을 구현하고 있을 뿐이다. 자바스크립트는 prototype으로 연결된 객체들의 체인을 거슬로 올라가며 자동적으로 메시지에 대한 위임을 처리한다.

클래스 기반 언어에서의 상속과 동일하게 객체 사이에 self 참조가 전달된다는 점 역시 눈여겨 보기 바란다. Lecturestats 메서드 안의 thisLecture의 인스턴스가 아니다. 메시지를 수신한 현재 객체를 가리킨다. 아래 그림은 프로토타입 체인을 통해 자동적으로 메시지 위임이 발생하는 구조를 그림으로 표현한 것이다. 이것은 객체지향 패러다임에서 클래스가 필수 요소가 아니라는 점을 잘 보여준다. 또한 상속 이외의 방법으로도 다형성을 구현할 수 있다는 사실 역시 잘 보여준다.

객체지향은 객체를 지향하는 것이다. 클래스는 객체를 편리하게 정의하고 생성하기 위해 제공되는 프로그래밍 구성 요소일 뿐이며 중요한 것은 메시지와 협력이다. 클래스 없이도 객체 사이의 협력 관계를 구축하는 것이 가능하며 상속 없이도 다형성을 구현하는 것이 가능하다.

지금까지 살펴본 것 처럼 프로토타입 기반의 객체지향 언어는 객체 사이의 자동적인 메시지 위임을 통해 상속을 구현한다. 이제 우리는 상속이 단지 클래스 사이의 정적인 관계로만 구현되는 것이 아니라는것을 깨달았다. 심지어 클래스 기반의 객체지향 언어를 사용하고 있더라도 클래스라는 제약을 벗어나기 위해 위임 메커니즘을 사용할 수 있다.

중요한 것은 클래스 기반의 상속과 객체 기반의 위임 사이에 기본 개념과 메커니즘을 공유한다는 점이다. 이 사실을 이해하면 다형성과 상속, 나아가 객체지향 언어를 바라보는 시각이 달라질 것이다.

test2

참고 및 출처

  • 오브젝트