티스토리 뷰

📺 시리즈

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 - 인터페이스

2023.10.09 - [Go/디자인 패턴] - [Go] SOLID in Go - 컴포지션

2023.10.10 - [Go/디자인 패턴] - [Go] SOLID in Go - 패키지

2023.10.11 - [Go/디자인 패턴] - [Go] SOLID in Go - 단일 책임 원칙

2023.10.12 - [Go/디자인 패턴] - [Go] SOLID in Go - 개방 폐쇄 원칙


🎭 리스코프 치환 원칙 (Liskov Substitution Principle)

 

q(x)를 타입 T의 객체 x에 대해 증명할 수 있는 속성이라 하자. 그렇다면 S가 T의 하위 타입이라면 q(y)는 타입 S의 객체 y에 대해 증명할 수 있어야 한다. 

 

 말이 어렵게 되어 있지만 쉽게 설명해 보자면, 부모(상위) 객체의 행동 규약을 자식(하위) 객체가 위반해서는 안된다는 것입니다. 만약 자식 객체가 부모 객체의 행동 규약을 위반하게 되면 예상치 못한 동작들이 발생할 수 있습니다. 리스코프 치환 원칙은 이를 예방하기 위한 것입니다.

 

리스코프 치환 원칙을 위배하는 코드

 여기서는 앞서 컴포지션 게시글에서 다루었던 직사각형과 정사각형 예시를 Go 코드로 변환하여 예시를 들어보겠습니다.

 

 Go는 클래스를 지원하지 않기 때문에 부모 객체를 하위 객체로 대체했을 때 발생하는 문제를 표현하려면 인터페이스를 사용해야 합니다. 그래서 다음과 같이 너비와 높이의 getter, setter 메서드를 가진 Sized 인터페이스를 정의해 주었습니다.

type Sized interface {
	Width() int
	SetWidth(width int)
	Height() int
	SetHeight(height int)
}

 다음으로 Sized 인터페이스를 구현하는 직사각형 Rectangle 구조체를 정의합니다.

type Rectangle struct {
	width, height int
}

func NewRectangle(width, height int) *Rectangle {
	return &Rectangle{width, height}
}

func (r *Rectangle) Width() int {
	return r.width
}

func (r *Rectangle) SetWidth(width int) {
	r.width = width
}

func (r *Rectangle) Height() int {
	return r.height
}

func (r *Rectangle) SetHeight(height int) {
	r.height = height
}

 PrintArea 함수는 Sized 타입의 매개변수를 받아 너비를 3, 높이를 4로 설정한 뒤, 너비와 높이를 곱한 값을 출력합니다.

func PrintArea(s Sized) {
	s.SetWidth(3)
	s.SetHeight(4)
	println(s.Width() * s.Height())
}

  Rectangle 구조체의 인스턴스를 PrintArea 함수에 인수로 넘겨주게 되면 너비가 3, 높이가 4로 변경되고 넓이는 12가 출력되는 것을 확인할 수 있습니다. 다른 직사각형도 마찬가지로 12가 출력될 것이라 예상할 수 있습니다.

package main

import "solid/lsp"

func main() {
	r := lsp.NewRectangle(4, 8)
	lsp.PrintArea(r) // expect 12
}
$ go run main.go 
12

 이번에는 정사각형 Square 구조체를 정의합니다. 정사각형은 직사각형의 부분집합이니까 Rectangle 구조체를 Square 구조체에 임베딩을 해보았습니다. 그런데 문제가 있습니다. 정사각형은 너비와 높이가 동일해야 하는데 Rectangle 구조체의 SetWidth와 SetHeight 메서드는 너비와 높이를 각각 설정해 줍니다. 따라서 이 메서드들을 정사각형의 행동 규약(너비와 높이를 동시에 설정)에 맞게 변경을 해줘야 합니다.

type Square struct {
	Rectangle
}

func NewSquare(size int) *Square {
	return &Square{Rectangle{size, size}}
}

// override
func (s *Square) SetWidth(width int) {
	s.width = width
	s.height = width
}

// override
func (s *Square) SetHeight(height int) {
	s.width = height
	s.height = height
}

 그리고 이번에는 Square 구조체의 인스턴스를 PrintArea 함수에 인수로 넘겨줍니다. 그리고 PrintArea 함수를 실행하기 전에 다음과 같이 예상합니다.

 

'상위 객체인 Rectangle에 대해서 PrintArea 함수는 12를 출력했고 Square 구조체를 Rectangle 구조체를 임베딩했으니까 이번에도 12를 출력하지 않을까?'

 

 그러나 결과는 12가 아닌 16입니다. 왜냐! Square 구조체는 메서드 오버라이딩을 통해 Rectangle 구조체의 행동 규약을 덮어써버렸기 때문입니다. (엄밀히 따지면 메서드 오버라이딩이라고 할 수 없지만 마땅히 대체할 만한 키워드가 떠오르지 않아 사용했습니다.)

package main

import "solid/lsp"

func main() {
	s := lsp.NewSquare(5)
	lsp.PrintArea(s)
}
$ go run main.go 
16

 이처럼 하위 객체가 부모 객체의 행동 규약을 위반함으로써 예상치 못한 결과가 나타나게 됩니다. 이와 같은 예상치 못한 동작은 사실 Go보다는 다른 객체지향 언어에서 더 큰 문제를 발생시킵니다. Go는 설계상 상속과 메서드 오버라이딩을 지원하지 않기 때문입니다.

 

리스코프 치환 원칙을 위배하지 않는 코드

 위의 코드를 리스코프 치환원칙을 위배하지 않는 방향으로 수정하는 방법에는 정해진 답이 없습니다. 리스코프 치환 원칙을 준수하는 방향으로 설계하는 방법도 있고, 애초에 문제를 일으킬만한 관계를 맺지 않는 방법도 있습니다.

 

1. 정사각형은 너비와 높이를 따로 가질 필요가 없다

 애초에 Rectangle 구조체의 행동 규약을 지킬 수 없다면, Square 구조체에 Rectangle을 임베딩하지 않고 size라는 별도의 필드를 추가하여 상하 관계가 아닌 별도의 구조체로 정의할 수 있습니다.

type Square struct {
	size int
}

func NewSquare(size int) *Square {
	return &Square{size}
}

func (s *Square) Size() int {
	return s.size
}

func (s *Square) SetSize(size int) {
	s.size = size
}

func (s *Square) Area() int {
	return s.size * s.size
}

func (s *Square) Rectangle() *Rectangle {
	return NewRectangle(s.size, s.size)
}

2. PrintArea 함수는 넓이만 출력해야 한다

 PrintArea 함수는 값 변경과 출력이라는 두 가지 동작을 수행하고 있습니다. 단일 책임 원칙을 위반하고 있죠. 함수의 이름만 가지고는 애초에 값이 변경된다는 것을 예상할 수도 없고 예상해서도 안 되는 것이 맞습니다. 따라서 이름만으로 특정 동작을 예측할 수 있게 값을 변경하는 부분을 제거하고 다음과 같이 넓이만 출력하게 변경할 수 있습니다.

func PrintArea(s Sized) {
	println(s.Width() * s.Height())
}

 또는 다음과 같이 넓이를 반환하는 메서드를 가진 Shape 인터페이스를 정의하고 PrintArea 함수는 Shape 타입의 값을 받아 넓이만을 출력하게할 수도 있습니다.

type Shape interface {
	Area() int
}

func PrintArea(s Shape) {
	println(s.Area())
}

🎯 리스코프 치환 원칙의 이점 정리

  • 코드 재사용성: 부모 객체와 하위 객체 간의 일관성이 유지되므로, 자식 객체를 부모 객체의 코드를 하위 객체에서 재사용할 수 있습니다.
  • 확장성: 기존의 객체를 수정하지 않고도 새로운 하위 객체를 추가하거나 시스템의 동작을 확장할 수 있습니다.
  • 다형성: 하위 객체를 부모 객체처럼 사용할 수 있습니다.
  • 테스트 용이: 부모 객체의 테스트 코드를 재사용하여 하위 객체를 테스트하는 것이 가능합니다.
  • 오류 감소: 하위 객체가 부모 객체와 일관성을 유지하므로 예상치 못한 동작을 줄일 수 있습니다.

🙏 마치며

 리스코프 치환 원칙에 대해 알아보았습니다. Go는 기본적으로 상속과 메서드 오버라이딩을 지원하지 않기 때문에 리스코프 치환 원칙을 위배함으로 인해 발생하는 문제로부터 비교적으로 안전하다 - 라고 생각할 수 있지만, 그렇다고 무지성으로 코딩을 해서는 안된다는 것이 리스코프 치환 원칙이 전하고자하는 교훈인 것 같습니다.


📖 참고자료

 

[Must Have] Tucker의 Go 언어 프로그래밍(세종도서 선정작) - 골든래빗

게임 회사 서버 전문가가 알려주는 Go 언어를 내 것으로 만드는 비법! 구글이 개발한 Go는 고성능 비동기 프로그래밍에 유용한 언어입니다. 이 책은 Go 언어로 ‘나만의 프로그램’을 만들 수 있

goldenrabbit.co.kr

https://www.udemy.com/course/design-patterns-go/

글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!
최근에 올라온 글
최근에 달린 댓글
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Total
Today
Yesterday
글 보관함