상속(프로그래밍)

500px
위 그림은 액션스크립트 3.0에서 기본적으로 지원하는 클래스간의 상속 구조도. OOP언어라면 (당연히) 시스템을 제공하는 사람들도 이렇게 객체지향 구조를 이용한다.

1 개념

객체 지향 프로그래밍(OOP: Object Oriented Programing)에서 크게 3요소로 꼽는 상속, 캡슐화, 다형성 세 가지 중 상속을 일컫는다. 다른 표현으로는 계승, 확장[1]이라는 단어도 사용된다. 영어로는 inheritance 라고 한다. 관계로는 A is a B(A는 B다)라고 표현한다.[2]

자식 클래스가 부모 클래스의 기능을 받아 쓰는 것이라고 이해하면 쉽다. 이럴 땐 자식 클래스는 부모 클래스의 기능을 받았으므로 부모의 역할도 할 수 있게 된다.[3]

1.1 어떻게 써먹는가?

간단하게 철권을 만든다고 치자. 그러면 당신은 카즈야헤이하치 같은 캐릭터의 클래스를 정의할 것이다[4]. 만약 당신이 초보 프로그래머라면

class 카즈야
{

체력
스피드
풍신권
나락쓸기

}

class 헤이하치
{

체력
스피드
뇌신권
....

}

대충 이런 식으로 클래스를 마구 만들어 낼 것이다. 그리고 직장에서 짤린다.(...)

잘 들여다보면 카즈야나 헤이하치나 똑같은 인간이다. 물론 쿠마모쿠진은 인간이 아니라고 따질 수 있을 것이다. 그렇다면 인간이 아닌 철권의 '캐릭터' 라고 하자. 이 격투게임 캐릭터는 저마다 기술이나 인종은 다를지 몰라도 공통적으로 체력과 힘 스피드 같은 요소들을 갖추고 있다.

그렇다면 캐릭터라는 가상의 클래스를 만든 다음에

class 캐릭터
{

체력
스피드
...

}

이 캐릭터라는 클래스에게서 기능을 받으면 똑같은 기능을 더 만드는 수고를 덜 수 있지 않을까?

class 카즈야 : public 캐릭터
{

풍신권
나락쓸기
...

}

대충 이런 개념이다. 상속을 사용하면서 기존에 만들어 둔 것들을 재사용할 수 있는 것이다. 혹자는 이런 질문을 할 수 있을지도 모른다.

고작 2~30명 정도 되는 캐릭터 클래스 작성하는게 뭐가 그리 어려운가요? 코드 복사 붙여넣기 하면 되잖아요?

그럴 수 있을지도 모른다. 근데 만약 캐릭터의 공통된 부분의 사양에 변화가 생겼다면 답이 없다. 이를테면 체력의 최대치가 바뀐다거나, 힘의 기준이 변동하거나 해서 코드를 수정해야 한다면? 그 캐릭터 수만큼 클래스를 모두 뜯어고쳐야 한다. 30명분 정도만 들이면(...) 금방 할 수 있을 수 있겠지만 똑같은 일을 사람이 반복할거면 뭣하러 컴퓨터를 사용하는가? 게다가 수정 도중에 실수를 해서 안 만들 버그를 잔뜩 만들 수도 있고 사양이 또 변경돼서 또 같은 일이 반복될 수도 있다! 하지만 만약 상속을 사용했다면, 우아하게 부모 클래스만 조금 손보면 끝나는 일이다. 그리고 격투게임이니까 30명으로 끝나지, 만약 축구나 야구 게임 또는 오픈월드 게임이었다면? 당신은 반드시 과로사 하게 될 것이다. Dead end
풋볼매니저의 모든 선수와 인물을 일일이 수정한다 생각해보자

2 재정의

상속받은 파생 클래스가 부모 클래스의 요소를 재정의 해야 할 필요가 있을 것이다. 이를테면 오우거진파치의 체력을 두 줄로 해주세요...같은 요구사항이 있을 경우에는 재정의를 사용한다. 영어로는 override 오버라이드라고 한다.

하지만 멤버 변수는 건드릴 수 없고, 오직 멤버 함수만 재정의가 가능하다. 생각해보면 변수를 뭘 어떻게 재정의할수도 없기도 하고... 그래서 함수 오버라이드한다.

오버로딩과는 다르다 오버로딩과는! 오버로딩은 메소드 이름만 같고 인수 개수나 타입이 달라 서로 구별할 수 있는 서로 다른 메소드를 만드는 것을 말한다. 헷갈리지 말도록 하자. 여담으로 부모 클래스의 오버로딩된 메소드가 여러 개 있는데 그중에 일부만 재정의하면 나머지 메소드는 가려져 호출할 수 없다. 부모의 기능 중 하나만 오버라이드 해도 될 때도 다른 메소드도 전부 재정의하자. 그냥 부모의 메소드만 호출해 반환하는 코드 한줄이면 된다.

3 직접 - 간접 상속

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

4 다중 상속

한 번에 둘 이상의 클래스를 파생받는 경우, 다시 말해 여러 부모를 둔 경우를 두고 다중 상속(multiple inheritance) 이라고 칭한다. 이 방식의 장점은 몹시 직관적이라는 것. 하지만 사실 별로 권장되는 방법은 아닌데, 바로 밑의 '죽음의 다이아몬드' 때문에 일반 클래스를 다중 상속하는건 극히 꺼려지며 인터페이스 용도의 클래스에서만 상속받는게 일반적이다. 사실 다중 상속은 이런저런 것으로 대체 가능하므로 인터페이스 다중 상속을 제외하고 다중 상속되는 상황 자체가 이미 막장.

4.1 죽음의 다이아몬드

400px


the Deadly Diamond of Death(DDD)
예를 들어, '게임'이라는 기본 클래스가 있고, 여기에는 '제목구하기()' 메소드가 있다. '라이트 노벨'과 '횡스크롤' 클래스가 여기서 파생되었다고 하자. 그러면 이 두 클래스에서 또 파생된 새로운 클래스 '라이트 노벨 2D 액션'를 허용한다면, '라이트 노벨 2D 액션' 클래스에서 '제목구하기()'를 호출할 경우 '라이트 노벨'과 '횡스크롤' 중 어느 부모에서부터 '제목구하기()'를 따라야 하는지 알 수 없는 사태가 벌어진다. 이것이 바로 '죽음의 다이아몬드'이다. 상속 관계가 마름모꼴(다이아몬드 형)으로 생겼다고 해서 붙여진 이름.

이런 사태를 막기 위해 C++에선 virtual 상속 기능이 제공된다. 좋은 기능... 이지만 C++11에서부터 지원된다. 게다가 인지도 없는 C++11 추가 기능 중 탑을 달리는 투명성을 자랑한다(...).

게다가 C#(.net)하고 Java에선 아예 그냥 다중 상속이 안된다. 사실 되긴 하는데, 일반 클래스, 추상 클래스 하나만 가능하며, 인터페이스만 추가로 더 상속할 수 있다. 인터페이스는 어차피 추상 메소드만 모여있어 구현 그런거 없고 하위 클래스에서 어떻게든 구현된 하나의 메소드 뿐이므로 죽음의 다이아몬드고 뭐고 뭘 호출할지 모호함이 발생할 여지조차도 없기 때문.

아, 물론 때에 따라 저런 다중 상속도 필요하긴 한데, 그 땐 Mixin 같은 방식을 사용한다.

5 까임

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

5.1 정보 은폐의 파괴

하위 클래스가 상위 클래스의 정보를 뜯어내 보안에 위기를 가져온다는 말이다. 자식 클래스가 부모 클래스를 하나의 멤버 변수로서 멤버로 두고 있다고 생각하면, 엄청난 부모의 보안 침해가 아닐수 없다. 듣고 보면 '자식한테 안 보여줄 건 private로 설정하고 보여줄 건 protected로 설정하면 되지 않나?'라고 생각하겠지만, 그 protected를 미칠듯이 남용해대서 문제다! 객체지향에 입문한 초보 프로그래머의 클래스엔 온통 protected와 public만이 가득한걸 볼 수 있다. 문제는 안가르쳐주면 은퇴할때까지 쭉 저래서 문제지... 그래도 public 떡칠보단 낫다 본격 사회주의 프로그래밍

5.2 동적인 유연성이 떨어짐

상속은 컴파일 시점에 부모를 지정해 놓으면 런타임 시점에 바꾸는 방법이 없다시피 하므로 유연성이 바닥이다. 명확한 is a 관계가 성립되지 않을때 상속을 쓰면...더 이상의 자세한 설명은 생략한다. 물론 동적인 슈퍼클래스 바꾸기를 지원한다면 상관 없는 이야기겠지만 그걸 지원하는 언어는 없다고 봐도 된다.

5.2.1 결합도를 크게 늘림

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

5.3 괴랄한 상속구조

기능은 같고 싶은데, 상식적으로 전혀 is a 관계가 아닐때 그래도 생떼쓰고 상속을 사용하면 괴랄한 상속구조가 탄생한다. 위에 음악 예시를 봐도 바로 알 수 있다. 상식적으로 '가수'나 '음악장르'가 '음악'이라는것이나 '노래'가 '가수'면서 '음악장르'라는 소리는 말이 되지 않는다. '음악장르'는 독립된 클래스(나 열거형으)로 나와서 '음악'에 포함돼야 할 것이고, '가수'는 '음악'이 아닌 '인간'이나 '직업'을 상속받아야 할 것이고, 그 안에 음악 타입의 배열인 발매 음반 같은게 있어야 할 것이다. 그리고 '노래'가 가사가 있는 음악이라고 치면, '음악'을 상속받고 가사 관련 메소드와 속성을 추가하는 식으로 음악을 바로 상속받아야 할 것이다.

이처럼 기능에만 의존해서 상속을 하면 도저히 알 수 없는 무언가가 생겨나므로, 상속은 인터페이스와 부모와 자식관 관계(A is a B-A는 B인가?)를 고려해서 '이건 확실히 상속이다!'하는 경우에만 시전하고 아니면 가급적 포함을 사용하자.

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

is a 관계가 확실하다고 하더라도 문제가 발생할 소지는 남아있다. 상술했듯이 자식 클래스는 부모 클래스에 명시된 어떤 행동(메소드, 함수)을 물려받아 그대로 쓸 수도 있고, 재정의(override)할 수 있다. 여러 이유로 인해 자식 클래스의 행동이 보여주는 결과나 결과가 가지는 조건, 의미 등이 부모 클래스 때와는 딴판으로 달라질 수도 있다. 만약 자식 클래스가 부모 클래스 메소드를 물려받아 부모 클래스가 하던대로 똑같이 동작만 해준다면 다른 객체에서 "부모 클래스 객체의 어떤 메소드를 사용한다"는 자리에 자식 클래스 객체를 넣더라도 문제가 발생하지 않겠지만, 그렇게 되지 않을 가능성도 얼마든지 있다.

예를 하나 들어보자. 사람 클래스의 "발명가"는 "악수" 행동을 할 수 있다. 사람 클래스를 상속받아 만들어진 가위손 클래스의 "에드워드"는 가위손을 내밀겠지만 아무튼 사람 클래스니까 똑같이 "악수"를 할 수 있다. 이제 예전에 발명가가 악수하던 자리에 에드워드가 들어가서 악수를 한다면 당연히 사고가 터질 것이다!

그러나 많은 프로그래밍 언어는 "에드워드"가 "사람" 클래스고 "악수"를 할 수 있다는 정도를 따지지, 손 대신 나오는게 위험한 가위손일 수도 있다는 점까지는 미리 따지지 못한다. 프로그래밍 언어 기준으로 말하자면 사람 클래스 악수 메소드 기준으로 만들어졌던 예전 코드들에 가위손 클래스의 객체를 넣으면 예전 코드에서 악수 메소드를 잘 쓰던 부분이 몽땅 망가질 수 있다는 뜻이다. 물론 프로그래밍 언어가 허용한다면 사람 클래스를 상속받은 어떤 클래스가 "악수" 행동을 할 때 가위손이 나오든 기관총을 발사하든 컴파일 오류는 안 나겠지만, 가위손과 같이 자신은 멀쩡한데 다른 쪽에서 망가지는 상황은 모두가 겪고 싶지 않을 것이다. 즉 논리적 오류가 난다. 상속을 할 수 있다고 하면 그만인 것이 아니라 다른 객체들을 고려해서 신중하게 해야 하는 이유이기도 하다.

상술된 사람과 가위손의 악수 차이를 학문에서 표현한 것이 리스코프 치환 원칙(Liskov Substitution Principle)이다. 즉 S가 T의 하위형(subtype)일 때 필요한 프로그램의 속성을 변경하지 않고도 자료형 T의 객체를 자료형 S로 교체할 수 있다면 원칙이 만족된다는 것이다.
  1. Java에서 사용한다(extends)...만 다들 그냥 상속이라고 부른다(...)
  2. 예를 들면 자작나무 is a 나무(자작나무는 나무다)라고 표현하는 것과 같다.
  3. 하지만 자식이 부모 노릇 못하는 경우가 종종 발생한다. 본 문서의 2.5.4 참조.
  4. 물론 실제로 이런 식으로 짠다는 건 아니다. 어디까지나 예제.
  5. 심지어 3요소중 하나인 '다형성'은 구현시 거의 대부분이 상속에 의존한다!