어떤 사람은 Go 언어는 객체지향 언어가 아니라고 말합니다. 그 이유는 객체지향 언어들의 특징인 상속을 지원하지 않기 때문입니다. 결론부터 말씀드리면 Go 언어는 객체지향 언어입니다. 객체지향 언어는 상속 지원 여부보다, 말 그대로 객체 간의 상호작용을 중심으로 한 프로그래밍에 있습니다. Go 언어는 상속을 지원하는 다른 언어보다 발전한 형태의 객체지향 언어입니다. 그 이유는 상속이 객체지향 설계를 깰 수 있는 많은 문제점을 가지고 있는데, 이를 지원하지 않아 문제를 미연에 방지했기 때문입니다. 먼저 상속이 무엇인지 간단히 살펴보겠습니다.
1.1 상속
상속inheritance이란 기존 객체를 확장하여 새로운 객체를 정의하는 기능을 말합니다. 프로그래밍을 할 때 공통으로 포함되는 기능을 여러 객체가 공유해야 하는 경우가 발생합니다. 예를 들어 게임을 프로그래밍을 할 때 게임에 등장하는 플레이어 캐릭터, 상점 주인, 몬스터 등이 모두 위치 이동이라는 공통 기능을 가지고 있다고 가정해보겠습니다. 각 객체별로 위치 이동이라는 기능을 따로 만들지 않고 객체 하나에 만들어 두고 플레이어 캐릭터, 상점 주인, 몬스터가 모두 이 객체를 사용하면 편할 겁니다. 이렇게 어떤 공통 기능을 객체 하나로 묶고 나머지 객체를 확장하는 방식이 바로 상속입니다.
위 그림과 같이 Actor 객체가 MovePosition()이라는 메서드를 가지고 있고, Player, NPC, Monster가 모두 Actor를 상속하고 있으면 Player, NPC, Monster가 각각 MovePosition() 기능을 따로 구현하지 않더라도 MovePosition( ) 기능을 이용할 수 있게 됩니다.
이때 Player, NPC, Monster가 Actor 객체를 상속했다고 말하고 Actor 객체를 부모 객체, Player, NPC, Monster 객체를 Actor에 대한 자식 객체라고 말합니다.
1.2 메서드 오버라이딩
메서드 오버라이딩method overriding이란 자식 객체에서 부모 객체의 메서드 기능을 변경하여 다시 정의하는 행위를 말합니다. Actor의 MovePosition( ) 기능을 상속해서 사용하지만 때론 자식 객체별로 다른 기능을 하고 싶을 때가 있습니다. 예를 들면 떠벌이 NPC는 이동하면서 주절주절 혼잣말을 하고, 몬스터는 독액을 길에 쏟아서 플레이어를 공격하고 싶을 수 있습니다. 그래서 부모 객체의 메서드를 그대로 사용하는 게 아닌 덮어써서 새로운 기능으로 정의할 수 있도록 한 것이 메서드 오버라이딩입니다.
메서드 오버라이딩를 활용하면 Actor의 MovePosition( ) 기능도 이용하지만 그 메서드를 확장해서 자식 객체만의 메서드를 만들 수 있습니다. 예제를 유사 코드로 살펴보겠습니다. 유사 코드로 살펴보는 이유는 Go 언어는 상속과 메서드 오버라이딩를 지원하지 않기 때문입니다.
class Actor {
x, y int
MovePosition() {
x += 100
y += 100
}
}
class NPC extends Actor {
@override
MovePosition() {
Actor.MovePosition()
Tell("주절주절")
}
}
Actor 객체가 있고 x, y 좌표값을 변경하는 MovePosition() 메서드를 가지고 있습니다. NPC 객체가 Actor 객체를 상속하고 있고 MovePosition( ) 메서드를 오버라이딩합니다. NPC 객체 의 MovePosition() 메서드는 Actor의 MovePosition() 메서드를 먼저 실행하고 그런 뒤 “주 절주절” 메시지를 출력합니다.
상속을 간단히 살펴보았습니다. 얼핏 봐서는 굉장히 좋은 기능 같아 보입니다. 실제로도 잘 사용하면 여러 문제를 손쉽게 해결할 수 있는 강력한 기능입니다. 그런데 이 편리한 상속이 어떤 문제점을 있길래 Go 언어에서 지원하지 않는 것인지 살펴보겠습니다.
1.3 다이아몬드 상속 문제
다중 상속을 통한 다이아몬드 상속은 상속의 고질적인 문제로써 이 문제를 없애고자 많은 언어가 다중 상속 자체를 금지합니다. 다이아몬드 상속이 왜 문제인지 살펴보겠습니다.
A를 B와 C가 상속합니다. D 클래스가 B와 C를 다중 상속합니다. 만약 A의 동일한 메서드를 B와 C가 따로 오버라이딩하면 과연 D는 B의 메서드와 C의 메서드 오버라이딩 중 어느 것을 실행할까요?
또 일부 언어에서는 다중 상속 시 내부 메모리 블록이 이중으로 포함되는 문제가 발생합니다. 이런 이유로 많은 언어에서 다중 상속을 금지합니다. 다중 상속이 허용된 언어라 하더라도 내부 규약으로 다중 상속을 못하게 하는 경우가 많습니다.
1.4 상속은 리스코프 치환 원칙을 위배하기 쉽다
상속은 리스코프 치환 원칙을 위배하기 쉽습니다. 오버라이딩으로 부모 클래스의 메서드와 자식 클래스의 오버라이딩한 메서드가 서로 다른 동작을 하도록 만들 수 있습니다. 리스코프 치환 원칙의 정의를 다시 보겠습니다.
“q(x)를 타입 T의 객체 x에 대해 증명할 수 있는 속성이라 하자. 그렇다면 S가 T 하위타입이라면 q(y)는 타입 S의 객체 y에 대해 증명할 수 있어야 한다.”
자식 타입 S는 부모 타입 T의 메서드를 오버라이딩해서 다른 동작을 하도록 만들 수 있습니다. 그렇기 때문에 S 타입 인수를 받는 함수 q()에 대해서 S 타입 객체 인스턴스를 사용할 때와 T 타입 객체 인스턴스를 사용할 때 동작이 달라져 리스코프 치환 원칙을 위배할 수 있습니다.
예를 들어보겠습니다. Go 언어는 클래스와 상속을 지원하지 않기 때문에 동작하지 않는 유사 코드 형태로 만들겠습니다.
class Rectangle {
`idth int
height int
setWidth(w int) { width = w }
setHeight(h int) { height = h }
}
class Square extends Rectangle {
@override
setWidth(w int) { width = w; height = w; }
@override
setHeight(h int) { height = h; width = h; }
}
사각형을 나타내는 부모 타입 Rectangle이 있고 정사각형을 나타내는 자식 타입 Square가 부모 타입 Rectangle을 상속합니다. Rectangle에는 setWidth( )와 setHeight( ) 메서드가 있는 데 각각 가로 길이와 세로 길이를 설정하는 메서드입니다. 자식 타입 Square가 이들 메서드를 오버라이딩합니다. 정사각형은 가로 길이와 세로 길이가 같기 때문에 하나의 변의 길이가 바뀌면 가로, 세로 모두 바꿔주도록 부모 메서드의 동작을 변경합니다.
이때 아래와 같은 함수가 있다고 보겠습니다.
// 화면 가로 크기에 맞게 이미지의 가로 크기를 늘립니다.
func FillScreenWidth(screenSize Rectangle, imageSize *Rectangle) {
if imageSize.width < screenSize.width {
imageSize.setWidth(screenSize.width)
}
}
FillScreenWidth( ) 함수는 화면의 가로를 모두 채우도록 이미지 가로 길이를 늘리는 함수입니다. FillScreenWidth() 함수 입장에서 보면 인수로 들어온 imageSize *Rectangle 객체의 setWidth()를 호출하면 가로 길이만 증가되지 세로 길이에는 변함이 없을 것으로 가정할 겁니다. 하지만 imageSize 객체가 자식 타입인 Square라면 가로 길이가 변할 때 세로 길이까지 같이 변화됩니다. 그런데 FillScreenWidth( ) 함수 내에서는 그런 사실을 알 수가 없어서 함수 동작이 예기치 않게 변화되어 버그가 발생할 수 있습니다. 이것이 리스코프 치환 원칙을 위배했을 때 생길 수 있는 문제이고 이러한 버그는 매우 찾기 어렵습니다.
상속과 오버라이딩 문제점을 보여주고자 과장한 예입니다만, 실무에서도 얼마든지 위와 같은 문제가 발생할 수 있습니다. 더 큰 문제는 실무 코드는 상속 관계가 더 복잡하게 얽혀 있어서 코드만 봐서는 도저히 어디서 문제가 발생했는지 찾기 어렵다는 데 있습니다.
1.5 상속은 강력한 의존 관계를 형성한다
상속을 하게 되면 부모 타입과 자식 타입 간 의존 관계가 형성됩니다. 객체 간 관계는 Is-a 관계와 Has-a 관계01로 나타낼 수 있습니다. Is-a 관계는 두 객체가 상속 관계를 맺고 있을 때이고 Has-a 관계는 두 객체가 포함 관계를 맺고 있을 때입니다. 클래스 다이어그램에서 상속 관계는 가운데가 비어 있는 화살표로 나타내고 포함 관계는 속이 찬 마름모로 나타냅니다.
예제를 살펴보겠습니다. 다음 코드 역시 유사 코드입니다.
// Parent와 Child는 상속 관계
class Parent {
...
}
class Child extends Parent {// ❶ 상속
...
}
// Manager와 Crew는 포함 관계
type Manager struct {// ❷ 포함
crews []*Crew
}
type Crew struct {
...
}
❶ Child 객체가 Parent 클래스를 상속하고 있기 때문에 상속 관계를 가지고 있습니다. 상속 관계를 Is-a라고 부르는 이유는 Child 객체가 Parent 타입 인스턴스로도 사용될 수 있기 때문입니다. 즉 아래 코드처럼 *Parent 타입을 받는 DoWork( ) 함수 인수로 *Child 타입 인스턴스를 사용할 수 있기 때문에 “Child is a Parent”라고 표현할 수 있습니다.
func DoWork(a *Parent) {
...
}
var child *Child
DoWork(child)
❷ Manager 타입은 내부 필드로 Crew 인스턴스 리스트를 가지고 있습니다. 그래서 Manager 가 Crew를 소유하고 있어서 포함 관계를 맺고 있습니다. 이것은 “Manager has a Crew”라고 표현할 수 있어서 Has-a 관계라고 말합니다.
1.6 상속 관계는 포함 관계보다 더 의존적이다
일반적으로 상속 관계를 지양하고 포함 관계를 지향해야 한다고 말합니다. 그 이유는 상속 관계가 의존성 문제를 더 많이 일으키기 때문입니다. 게임 프로그래밍의 예를 살펴보겠습니다.
플레이어를 나타내는 Player 객체, 상인과 같은 NPC를 나타내는 NPC 객체, 적을 나타내는 Monster 객체가 있다고 보겠습니다. 이들 객체는 부모 객체인 Actor를 상속합니다. 이때 Player와 NPC만 대화를 할 수 있다고 보겠습니다. 그러면 대화 기능을 부모 객체인 Actor에 구현할 수 없습니다. Actor에 구현하면 자식인 Monster 역시 대화 기능을 가지게 되기 때문입니다. 그래서 대화 기능을 Player와 NPC에 각각 구현해야 합니다. 그러면 중복 코드 문제가 발생합니다. 그 결과로 상속 구조를 다음 그림처럼 바꿨다고 보겠습니다.
대화 기능을 TalkActor에 구현해서 Actor를 상속하고 대화 가능한 Player와 NPC가 TalkActor를 상속하는 것으로 Player와 NPC만 대화가 가능하고 Monster는 대화를 할 수 없 도록 구현했습니다.
만약 이 상태에서 Player와 Monster만 전투 가능하고 NPC는 불가능하도록 구현하고 싶을 때 는 어떻게 해야 할까요? BattleActor를 만들어서 Player와 Monster만 상속할까요?
Player가 TalkActor와 BattleActor를 다중 상속합니다. 이것은 그대로 다이아몬드 상속 문제를 일으킵니다. 따라서 이 문제를 쉽게 풀 수 있는 방법이 없습니다. 상속이 의존 관계를 만들기 때문입니다. TalkActor를 Player와 NPC가 상속하는 순간, Player와 NPC가 공통의 의존 관계로 묶이게 되어서 TalkActor, Player, NPC 이들 세 객체를 분리하기가 힘들어집니다.
이 문제를 포함 관계로 했을 때는 어떻게 될 것인지 살펴보겠습니다.
그저 Player가 Talk와 Battle을 담당하는 객체를 포함하고 있고 NPC는 Talk만 Monster는 Battle만 포함하고 있으면 문제는 매우 간단하게 해결됩니다. 한눈에 보기에도 상속 구조보다 깔끔합니다. Player의 Battle 기능과 Monster의 Battle 기능이 달라져도 문제 없습니다. 서로 포함된 객체만 바꿔주면 되기 때문입니다.
포함 관계의 더 큰 장점은 추상 객체로 의존성 역전을 하기 편하다는 점입니다. 위 BaseActor, Battle, Talk 같은 객체를 구체화된 객체가 아닌 인터페이스로만 바꾸면 의존성 역전을 해서 서로 관계를 쉽게 끊어낼 수 있습니다.
위 그림처럼 추상 계층으로 의존성 역전을 하면 Player만을 위한 Talk 기능, 마법 유저만을 위한 전투 기능 등 다양한 객체로 손쉽게 교체할 수 있게 됩니다.
1.7 정리 : 상속은 양날의 검이다
물론, 상속이 무조건 나쁘다는 건 아닙니다. 상속은 양날의 검이라서 저도 실무에서 손쉽게 코딩할 때 많이 사용합니다. 하지만 무분별한 상속은 의존성 문제를 일으켜서 유지보수를 어렵게 만드는 역효과가 있습니다. 그래서 상속은 조심해서 사용해야 하는 양날의 검입니다. Go 언어는 상속 자체를 지원하지 않아서 이런 고민 자체를 없애버렸습니다. Go 언어에서는 상속 관계는 불가능하고 오직 포함 관계만 가능합니다. 그래서 때론 상속만 있으면 편하게 코딩할 텐데 하는 아쉬움이 생길 때마다 상속으로 발생하는 문제점을 미연에 방지하고 포함 관계가 더 유연한 코드를 만든다는 점을 생각하면서 아쉬움을 달래고 있습니다.
Go 언어에서 상속을 지원하지 않는 건 매우 대담한 결정이고 저는 그 용기에 박수를 보내고 싶습니다. Go 언어가 업데이트되더라도 상속을 지원하지 않는 결정을 계속 유지했으면 하는 바람입니다.
책 내용 중 궁금한 점, 공부하다가 막힌 문제 등 개발 관련 질문이 있으시다면
언제나 열려있는 <Must Have Tucker의 Go 언어 프로그래밍> 저자님의
카카오채널로 질문해주세요!
설명이 정말 이해가 잘됩니다. 덕분에 go를 공부하는데 많음 도움이 되네요. 감사합니다.