상속(프로그래밍)

 

[image]
위 그림은 액션스크립트 3.0에서 기본적으로 지원하는 클래스간의 상속 구조도. OOP 언어라면 (당연히) 시스템을 제공하는 사람들도 이렇게 객체지향 구조를 이용한다.
1. 개념
2. 사용 예
2.1. 재정의
2.2. 직접 - 간접 상속
2.3. 다중 상속
2.3.1. 죽음의 다이아몬드
3. 문제점
3.1. 정보 은닉의 파괴
3.2. 동적인 유연성이 떨어짐
3.2.1. 결합도를 크게 늘림
3.3. 엉뚱한 상속구조의 발생
3.4. 부모 자리에 자식이 들어가더라도 정확히 같은 행동을 할까?
3.5. 동적 바인딩에서 오는 성능 하락


1. 개념


객체 지향 프로그래밍(OOP: Object Oriented Programing)에서 크게 3요소로 꼽는 캡슐화, 상속, 다형성 세 가지 중 상속을 일컫는다. 다른 표현으로는 계승, 확장[1]이라는 단어도 사용된다. 영어로는 inheritance 라고 한다. 관계로는 A is a B(A는 B다)라고 표현한다.[2]
자식 클래스가 부모 클래스의 기능을 받아 쓰는 것이라고 이해하면 쉽다. 이럴 땐 자식 클래스는 부모 클래스의 기능을 받았으므로 부모의 역할도 할 수 있게 된다.[3]
CSS에서 말하는 상속[4]과는 다른 개념이다.

2. 사용 예


철권 시리즈를 구현한다고 치자. 그러면 당신은 카즈야헤이하치 같은 캐릭터의 클래스를 정의할 것이다. 만약 당신이 초보 프로그래머라면
class 카즈야
{
    체력
    힘
    스피드 
    풍신권
    나락쓸기
    ...
}

class 헤이하치
{
    체력
    힘
    스피드
    뇌신권
    ...
}
대충 이런 식으로 캐릭터마다 클래스를 만들어낼 것이다.
하지만 잘 들여다보면 카즈야나 헤이하치는 상당히 많은 공통점을 갖고 있다. 일단 머리, 팔, 다리가 기본적으로 있고 수치상으로 따지고 들어가면 체력과 공격력, 방어력, 회피율 등의 수치가 있다. 쿠마모쿠진처럼 인간이 아닌 캐릭터와 비교해 봐도 이러한 공통점을 찾을 수 있다. 요컨대 격투게임 캐릭터는 저마다 기술이나 인종은 다를지 몰라도 공통적으로 체력과 힘, 스피드 같은 요소들을 갖추고 있다는 것이다.
그렇다면 아래와 같이 "캐릭터"라는 클래스를 만든 다음에
class 캐릭터
{
    체력
    힘
    스피드
    ...
}
이 캐릭터라는 클래스에게서 기능을 받아서 쓴다면 똑같은 기능을 계속 정의하는 수고를 덜 수 있을 것이다.
class 카즈야 : public 캐릭터
{
    풍신권
    나락쓸기
    ...
}
즉, 상속을 사용하면 기존에 만들어 둔 것들을 재사용할 수 있는 것이다. 제조업에서 제품 양산을 위해 금형틀을 만들어두는 것과 일맥상통하는 개념. 이렇게 해 두면 버전업 등의 이유로 코드를 대대적으로 손 볼 필요가 있을 때 프로그래머의 수고를 크게 덜어준다. 예를 들어 기존 버전엔 없던 "분노" 게이지 기능을 모든 캐릭터에게 부여해야 한다고 할 때, 상속을 쓰지 않았다면 모든 캐릭터에 대한 클래스를 일일이 찾아다니며 "분노" 변수를 붙여넣어야 하지만 상속을 쓰면 "캐릭터" 클래스에 "분노" 변수를 하나 추가하기만 하면 OK. 사람 손으로 일일이 붙여넣다가 일어날 수 있는 실수를 사전에 방지하는 부가효과는 덤이다.

2.1. 재정의


상속받은 파생 클래스가 부모 클래스의 요소를 재정의해야 할 필요가 있을 것이다. 이를테면 오우거진파치의 체력을 두 줄로 해주세요...같은 요구사항이 있을 경우에는 재정의를 사용한다. 영어로는 override 오버라이드라고 한다. 재정의를 할 수 있는 것은 멤버 함수에 한정되며, 이 때문에 함수 오버라이드라고도 한다.
오버로딩과 자주 혼동되는데 오버로딩과는 다른 개념이다. 오버로딩은 메소드 이름만 같고 인수 개수나 타입이 달라 서로 구별할 수 있는 서로 다른 메소드를 만드는 것을 말한다. 헷갈리지 말도록 하자. 여담으로 부모 클래스의 오버로딩된 메소드가 여러 개 있는데 그중에 일부만 재정의하면 나머지 메소드는 가려져 호출할 수 없다. 부모의 기능 중 하나만 오버라이드 해도 될 때도 다른 메소드도 전부 재정의하자. 그냥 부모의 메소드만 호출해 반환하는 코드 한줄이면 된다.

2.2. 직접 - 간접 상속


한 번 파생받은 클래스에서 또 파생되는 경우, 파생 클래스 바로 위의 클래스를 직접 클래스(direct class), 그 위의 클래스를 간접(indrect) 클래스라고 칭한다. 어느정도 규모가 되는 실 프로젝트에서는 위로 끝없는 클래스의 계층이 펼쳐져 있고 엄청난 수의 간접 클래스와 인터페이스가 가득하다(...).

2.3. 다중 상속


한 번에 둘 이상의 클래스를 파생받는 경우, 다시 말해 여러 부모를 둔 경우를 두고 다중 상속(multiple inheritance) 이라고 칭한다. 이 방식의 장점은 '''매우 직관적이라는 것'''. 하지만 사실 별로 권장되는 방법은 아닌데, 바로 밑의 ''''죽음의 다이아몬드'''' 때문에 일반 클래스를 다중 상속하는건 극히 꺼려지며 인터페이스 용도의 클래스에서만 상속받는게 일반적이다. 사실 다중 상속은 이런저런 것으로 대체 가능하므로 인터페이스 다중 상속을 제외하고 다중 상속되는 상황 자체가 이미 막장. Java와 C#은 아예 문법적으로 다중 상속을 하지 못하도록 막아 놓았다. 그나마 예외로는 C++의 Signal/Slot이나 GUI에서 부모 자식 관계를 이용한 자동 메모리 관리와 기본 GUI 컴포넌트의 상속, 또는 Qt(프레임워크)의 QObject 정도만 있다.

2.3.1. 죽음의 다이아몬드


[image]
The Deadly Diamond of Death(DDD)
프로그래밍 언어컴퓨터에게 내릴 명령을 순서대로 정리해 놓은 문서라고 볼 수 있으며, 가장 중요한 특징 중 하나는 '''같은 구문이 두 가지 이상의 의미로 해석될 여지가 있어서는 안 된다'''라는 것이다. 그런데 다중 상속이 허용될 경우 이러한 상황이 발생할 가능성이 있으며, 그 중 한 예가 바로 위의 그림과 같은 "죽음의 다이아몬드"이다.
예를 들어 '사람'이라는 기본 클래스가 있고, 여기에는 '성격()'과 '키()' 메소드가 있다. 이제 '사람' 클래스를 상속받은 '아빠'와 '엄마' 클래스가 있고, 이 둘을 동시에 상속받은 '' 클래스가 있다고 하자. 이렇게 되면 '딸' 클래스에서 '성격()' 혹은 '키()' 메소드를 호출할 때 '''어느 부모의 메소드를 따라야 하는지가 한 가지로 명확히 정해지지가 않는다.''' 예컨대 프로그래머는 '아빠'의 "키()" 메소드를 따르고 "엄마"의 "성격()" 메소드를 따르는 것을 의도했지만 전혀 의도하지 않은 정반대의 결과물이 나올 가능성이 존재한다는 것. 이것이 바로 '죽음의 다이아몬드'이다. 상속 관계가 마름모꼴(다이아몬드 형)으로 생겼다고 해서 붙여진 이름.
C#(.net)하고 Java에선 이것을 방지하기 위해 다중상속 자체를 제한해 놨다. 상속은 기본적으로 일반 클래스, 추상 클래스 하나만 가능하다. 인터페이스는 추상 메소드만 모아놓은 것이기 때문에 실질적으로 쓰이는 것은 하위 클래스에서 어떻게든 구현된 하나의 메소드 뿐이므로 죽음의 다이아몬드고 뭐고 뭘 호출할지 모호함이 발생할 여지조차도 없기 때문.
물론 추상 메소드만 쓰는 인터페이스 특성상 클래스마다 똑같은 기능을 매번 구현해야 하고 이로 인해 코드의 재사용성이 크게 저하되는 문제점이 있다. 이 문제 때문에 최근에 나오는 언어들은 트레이트나 믹스인 같이 일반 메소드가 있어도 다중 상속이 가능한 모델을 고안하고 있다. 위의 죽음의 다이아몬드에 대한 해결책도 새로 나온게 있는데, 바로 상속의 우선순위를 두는 것이다. 더 우선하는 클래스의 메소드만 상속하고 그 외의 클래스의 메소드는 무시해 버리는 것.

3. 문제점


객체지향에서 없어서는 안될 3요소중 하나인 상속이지만[5] 왠지 모르게 단점과 허점이 있어서 폭풍처럼 까이고 있기도 하다. 요즘은 다형성을 위해 써야 할 때가 아니면 가급적 구성(composition)을 사용하길 권장하고 있다.

3.1. 정보 은닉의 파괴


하위 클래스가 상위 클래스의 정보를 뜯어내 보안상 허점을 만들어낼 수 있다. 이를 방지하기 위해 자식한테도 안 보여줄 것은 private, 자식에겐 보여주되 남에게는 안 보여줄 건 protected, 누구에게나 다 보여줄 건 public으로 선언하도록 구분하는 기능이 기본적으로 지원되지만, 문제는 protected를 '''미칠듯이 남용'''해대는 프로그래머가 많다는 것.[6] 특히 객체지향에 갓 입문한 초보 프로그래머의 클래스엔 온통 protected와 public만이 가득한걸 볼 수 있다.

3.2. 동적인 유연성이 떨어짐


상속은 컴파일 시점에 부모를 지정해 놓으면 런타임 시점에 바꾸는 방법이 없다시피 하므로 유연성이 바닥이다. 동적인 슈퍼클래스 바꾸기를 지원하는 언어가 아주 없는 건 아니지만 그런 걸 지원했다가 발생할 수 있는 위험 요소가 한두가지가 아닌지라 거의 없다고 봐도 무방하다.

3.2.1. 결합도를 크게 늘림


좋은 객체지향적 코드는 한 클래스 내 요소들의 응집도는 높이고, 서로 다른 클래스 간의 결합도는 떨어뜨린 코드이다. 그런데 어떤 클래스가 상속으로 부모자식 관계가 되면, 그 부모 클래스는 몰라도 자식 클래스는 부모 클래스 없이는 그야말로 아무것도 아닌 클래스가 된다. 이는 자식(이 될) 클래스의 부모(가 될) 클래스에 대한 결합도를 크게 높이고, 이에 따라 객체지향의 핵심중 하나인 재사용이 힘들어진다. 상속하는 순간 그냥 한 세트가 된다고 생각하면 편하다. 이건 포함도 비슷하지만, 최소한 포함은 최상위 클래스의 인터페이스만 알면, 그와만 결합될 뿐이고 서브클래스는 아웃 오브 안중이 된다.

3.3. 엉뚱한 상속구조의 발생


특정 기능이 필요하기는 한데 상식적으로 전혀 is a 관계가 아님에도 불구하고 억지로 상속을 사용하면 괴랄한 상속구조가 탄생한다. '양윤경' 클래스를 구현하는 것을 예로 들어 보자. '양윤경' 클래스의 구현을 위해 '사람' 클래스를 상속받았을 경우 "'양윤경' is a '사람'"이라는 관계가 성립하며, 이는 누가 봐도 쉽게 납득할 수 있다. 그러나 여기에 더해 '훔치기()' 기능이 필요하다고 '사람' 클래스를 상속받은 '도둑' 클래스를 상속해 버리면 "'양윤경' is a '도둑'"이라는 아스트랄한 관계가 탄생한다. 그러므로 상속은 인터페이스와 부모와 자식 간 관계(A is a B-A는 B인가?)를 고려해서 상속하는 것이 확실한 경우에만 사용하고 아닐 경우에는 가급적 포함을 사용하는 것이 좋다.

3.4. 부모 자리에 자식이 들어가더라도 정확히 같은 행동을 할까?


is a 관계가 확실하다고 하더라도 문제가 발생할 소지는 남아있다. 상술했듯이 자식 클래스는 부모 클래스에 명시된 어떤 행동(메소드, 함수)을 물려받아 그대로 쓸 수도 있고, 재정의(override)할 수 있다. 여러 이유로 인해 자식 클래스의 행동이 보여주는 결과나 결과가 가지는 조건, 의미 등이 부모 클래스 때와는 딴판으로 달라질 수도 있다. 만약 자식 클래스가 부모 클래스 메소드를 물려받아 부모 클래스가 하던대로 똑같이 동작만 해준다면 다른 객체에서 "부모 클래스 객체의 어떤 메소드를 사용한다"는 자리에 자식 클래스 객체를 넣더라도 문제가 발생하지 않겠지만, 그렇게 되지 않을 가능성도 얼마든지 있다.
예를 하나 들어보자. "사람"이라는 기본 클래스가 있고 여기에는 "악수" 메소드가 있다. 즉, "사람" 클래스를 상속받은 모든 클래스는 "악수"를 할 수 있는 것이다. 보통 사람들이라면 악수를 하는 걸로 상대에게 해를 끼치는 경우는 거의 없다. 그런데 "사람" 클래스를 상속받은 "가위손 에드워드" 클래스가 있다고 하자. 에드워드도 일단은 "사람" 클래스를 상속받았으니 당연히 악수를 할 수 있지만 손이 가위로 돼 있기 때문에 다른 사람이 에드워드의 손을 잡으면 다치는 상황이 발생하는 것이다. 그러나 많은 프로그래밍 언어는 "에드워드"가 "사람" 클래스고 "악수"를 할 수 있다는 정도를 따지지, 손 대신 나오는게 위험한 가위손일 수도 있다는 점까지는 미리 따지지 못한다. 프로그래밍 언어 기준으로 말하자면 사람 클래스 + 악수 메소드 기준으로 만들어졌던 예전 코드들에 가위손 클래스의 객체를 넣으면 예전 코드에서 악수 메소드를 잘 쓰던 부분이 몽땅 망가질 수 있다는 뜻이다. 물론 프로그래밍 언어가 허용한다면 사람 클래스를 상속받은 어떤 클래스가 "악수" 행동을 할 때 가위손이 나오든 기관총을 발사하든 컴파일 오류는 안 나겠지만, 가위손과 같이 자신은 멀쩡한데 다른 쪽에서 망가지는 상황은 모두가 겪고 싶지 않을 것이다. 즉 논리적 오류가 난다. 상속을 할 수 있다고 하면 그만인 것이 아니라 다른 객체들을 고려해서 신중하게 해야 하는 이유이기도 하다.
상술된 사람과 가위손의 악수 차이를 학문에서 표현한 것이 리스코프 치환 원칙(Liskov Substitution Principle)이다. 즉 S가 T의 하위형(subtype)일 때 필요한 프로그램의 속성[7]을 변경하지 않고도 자료형 T의 객체를 자료형 S로 교체할 수 있다면 원칙이 만족된다는 것이다.

3.5. 동적 바인딩에서 오는 성능 하락


상속을 사용하면, 필연적으로 오버라이딩을 사용할 것이다. 이 오버라이딩이야 말로 상속이 다형성을 가져다 주는 요인인데, 문제는 오버라이딩을 하려면 필연적으로 메소드(함수)가 동적으로 바인딩되어야 한다는 것이다. 실행될 메소드를 컴파일 타임에 결정하는 정적 바인딩 방식으로 구현할 경우, 부모클래스 또는 상위 인터페이스의 메소드 호출 시 자식 클래스의 메소드가 동작하지 않고 부모의 메소드가 호출될 것이다. 이는 아무런 다형성도 가져다 줄 수 없다. 때문에, 런타임에 오버헤드가 생기게 되며 이것은 객체지향 방식이 절차적 방식에 비해 느린 이유중 하나이다.

[1] Java에서 사용한다(extends)...만 다들 그냥 상속이라고 부른다(...) [2] 예를 들면 자작나무 is a 나무(자작나무는 나무다)라고 표현하는 것과 같다.[3] 하지만 자식이 부모 노릇 못하는 경우가 종종 발생한다. 본 문서의 3.4 참조.[4] 부모 태그의 속성 중 상속이 가능한 CSS 속성을 그대로 자식 태그에게 넘겨 주는 기능[5] 심지어 3요소 중 하나인 '다형성'은 구현시 거의 대부분이 상속에 의존한다! 다형성 구현에 상향 형변환(upcasting, 부모 클래스 참조변수로 자식 클래스 객체 지칭)이 전제되고, 상향 형변환 구현에 동적 바인딩이 전제되고, 동적 바인딩 구현에 상속이 전제되기 때문이다.[6] 이 부분은 언어에 따라 다르지만, get()이나 set() 계열의 메소드를 구현해서 값은 private로 설정해 놓고 은닉성을 지키는 게 가능하다. 대신 코드가 길고 '''귀찮다.''' 하지만 get, set 과정에서 어떠한 처리가 필요할 경우 get, set 함수 내용만 수정하면 되므로 클래스 외부 코드를 수정할 필요가 없어진다는 장점이 있다. 많은 프로그래머들이 멍청하기 때문에 public을 냅두고 굳이 getter와 setter를 같이 구현하는 게 아니다.[7] OOP에서 '속성'은 데이터, 필드, 멤버 변수 등을 의미한다. '행위'는 메소드를 일컫는다.