티스토리 뷰
📺 시리즈
2023.10.02 - [Go/디자인 패턴] - [Go] SOLID in Go - SOLID란?
2023.10.03 - [Go/디자인 패턴] - [Go] SOLID in Go - 구조체와 메서드
2023.10.04 - [Go/디자인 패턴] - [Go] SOLID in Go - 인터페이스
🔗 컴포지션
Go는 상속을 지원하지 않습니다. 대신 구조체 안에 구조체 또는 인터페이스를 임베딩하는 컴포지션을 사용합니다.
인터페이스 또는 구조체에 다른 타입을 임베딩할 수 있게 함으로써 임베딩된 내부 타입의 메서드를 외부 타입이 차용할 수 있습니다.
구조체 임베딩
해적단에는 먼저 선원(Crew)이 있습니다. 선원들은 저마다 이름(Name)을 가지고 있습니다. 그리고 뱃노래를 부를(SingShanty) 수도 있죠.
type Crew struct {
Name string
}
func (c Crew) SingShanty() {
fmt.Printf("%s is singing a shanty!\n", c.Name)
fmt.Println("Yo-ho-ho, and a bottle of rum!")
}
해적단에는 선원들(Crews)을 거느리는 선장(Captain)도 있습니다. 그런데 따지고 보면 선장도 배에 타고 있는 선원이죠. 선장도 이름이 있고 뱃노래를 부를 수 있습니다. 따라서 Crew 구조체를 Captain 구조체에 임베딩합니다.
type Captain struct {
Crew
Crews []Crew
}
그리고 다음과 같이 Captain 타입의 변수에 '.' 연산자를 사용하여 SingShanty 메서드를 호출하면 임베딩된 내부 타입 Crew의 SingShanty 메서드가 호출됩니다.
func main() {
crews := []Crew{
{Name: "Jack"},
{Name: "Jones"},
{Name: "Blackbeard"},
}
captain := Captain{
Crew: Crew{Name: "Cook"},
Crews: crews,
}
captain.SingShanty()
for _, crew := range captain.Crews {
crew.SingShanty()
}
}
$ go run main.go
Cook is singing a shanty!
Yo-ho-ho, and a bottle of rum!
Jack is singing a shanty!
Yo-ho-ho, and a bottle of rum!
Jones is singing a shanty!
Yo-ho-ho, and a bottle of rum!
Blackbeard is singing a shanty!
Yo-ho-ho, and a bottle of rum!
다만 동일한 이름의 메서드가 외부 타입에 존재할 경우, 해당 메서드는 내부 타입에 직접 접근하여 호출해야 합니다.
func (c Captain) SingShanty() {
c.Crew.SingShanty()
fmt.Println("And a bucket of chum!")
for _, crew := range c.Crews {
crew.SingShanty()
}
}
func main() {
crews := []Crew{
{Name: "Jack"},
{Name: "Jones"},
{Name: "Blackbeard"},
}
captain := Captain{
Crew: Crew{Name: "Cook"},
Crews: crews,
}
captain.SingShanty()
captain.Crew.SingShanty()
}
$ go run main.go
Cook is singing a shanty!
Yo-ho-ho, and a bottle of rum!
And a bucket of chum!
Jack is singing a shanty!
Yo-ho-ho, and a bottle of rum!
Jones is singing a shanty!
Yo-ho-ho, and a bottle of rum!
Blackbeard is singing a shanty!
Yo-ho-ho, and a bottle of rum!
Cook is singing a shanty!
Yo-ho-ho, and a bottle of rum!
❓ 왜 상속대신 컴포지션인가(Composition Over Inheritance)?
상속은 리스코프 치환 원칙을 위배하기 쉽다
리스코프 치환 원칙은 이후에 다시 다루겠습니다만, 간단하게 말해서 부모 객체의 행동 규약을 자식 객체가 위반하게 되는 것입니다.
가장 흔한 예로 직사각형을 나타내는 Rectangle 클래스가 있습니다. printArea 함수는 Rectangle 객체를 받아 너비를 3으로, 높이를 4로 변경하고 넓이를 출력합니다. 우리는 printArea 함수가 어떤 Rectangle 객체를 매개변수로 받더라도 12가 출력된다는 것을 예상할 수 있습니다.
class Rectangle {
width;
height;
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
function printArea(rectangle) {
rectangle.setWidth(3);
rectangle.setHeight(4);
console.log(rectangle.getArea());
}
const rectangle = new Rectangle(2, 3);
printArea(rectangle); // 12
다음으로 정사각형을 나타내는 Square 클래스가 있습니다. 정사각형은 직사각형의 일종이라고 생각해서 Rectangle 클래스를 상속받았습니다. 그런데 너비를 지정하면 너비만 바뀌고 높이를 지정하면 높이만 바뀌는 부모 클래스의 기존 동작 방식은 모든 변의 길이가 동일해야 하는 정사각형 하고는 맞지 않습니다. 따라서 너비와 높이가 동시에 바뀌게 메서드를 오버라이딩합니다.
class Square extends Rectangle {
// override
setWidth(width) {
this.width = width;
this.height = width;
}
// override
setHeight(height) {
this.width = height;
this.height = height;
}
}
Square 객체는 Rectangle 객체를 상속했기 때문에 Rectangle 객체처럼 사용될 수 있습니다. 따라서 printArea 함수의 인수로 넘겨줄 수 있죠. 그런데 12를 출력할 것이라고 예상되었던 printArea 함수의 호출 결과가 달라진 것을 확인할 수 있습니다. 앞서 Square 클래스에서 부모 클래스의 행동을 마음대로 덮어썼기 때문에 이처럼 예상치 못한 결과를 얻게 된 것입니다.
function printArea(rectangle) {
rectangle.setWidth(3);
rectangle.setHeight(4);
console.log(rectangle.getArea());
}
const square = new Square(3, 3);
printArea(square); // 16
이러한 문제는 실제 개발 환경에서도 얼마든지 발생할 수 있고, 특히 상속 관계가 더 복잡하게 얽혀있으면 찾기가 굉장히 어려울 수 있습니다.
상속은 강력한 의존 관계를 형성한다
객체 간의 관계는 Is-a 관계와 Has-a 관계로 나타낼 수 있습니다. Is-a는 두 객체가 상속 관계를 맺고 있는 것으로, 'Car is vehicle'로 나타낼 수 있습니다. Has-a는 두 객체가 포함 관계를 맺고 있는 것으로 'Car has an engine'으로 나타낼 수 있습니다.
Is-a 관계의 경우, 두 객체가 강하게 결합되어 있어서 부모 객체의 일부가 변경되면 자식 객체에도 그 영향을 미치게 됩니다. 만약 탈 것에 날 수 있어야 한다는 기능이 추가로 정의된다면 자동차에 그것을 어떻게든 구현을 해야할지도 모를 일입니다. 반면, Has-a 관계의 경우는 두 객체가 약하게 결합되어 있어 포함하는 객체의 변경이 포함되는 객체에 미치는 영향이 거의 없습니다. 자동차를 어떤 식으로 커스텀하든 엔진은 그 자체로 완성되어 있는 것이고 필요에 따라 교체될 수 있는 대상이기 때문입니다.
컴포지션의 단점?
코드 구조가 장황해짐으로 인해 DRY(Don't repeat yourself) 패턴을 위배할 수 있다는 점을 제외하면 딱히 없는 것 같습니다. 만약 다형성이 필요하다면 인터페이스를 활용하면 됩니다.
🙏 마치며
Go에서 사용하는 컴포지션에 대해 알아보았습니다. 상속으로 인해 발생할 수 있는 문제점들을 살펴보고 나니 Go의 설계 철학에 저절로 고개가 숙여집니다. 앞으로도 상속은 추가되지 않았으면 하네요.
다음 게시글에서는 패키지에 간단히 살펴보겠습니다.
📖 참고자료
글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!
'Go > 디자인 패턴' 카테고리의 다른 글
[Go] SOLID in Go - 단일 책임 원칙 (1) | 2023.10.11 |
---|---|
[Go] SOLID in Go - 패키지 (1) | 2023.10.10 |
[Go] SOLID in Go - 인터페이스 (1) | 2023.10.04 |
[Go] SOLID in Go - 구조체와 메서드 (0) | 2023.10.03 |
[Go] SOLID in Go - SOLID란? (1) | 2023.10.02 |