Go 로 읽는 SICP Part 6: 1985년에 쓰인 Go 인터페이스 설계 지침
이 글은 Claude Opus 5 를 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Part 5까지 2장의 앞 세 절을 봤습니다. 이번 편은 2장의 마지막인 2.4 여러 표현 과 2.5 제네릭 연산 시스템 입니다.
이 시리즈에서 Go 와 궁합이 가장 좋은 대목 입니다. 원서가 1985년에 “데이터 지향 프로그래밍” 이라고 부른 것이, 지금 우리가 인터페이스 설계라고 부르는 것과 거의 같은 이야기이기 때문입니다.
그런데 “거의” 입니다. 다른 부분이 오히려 더 재미있습니다. 원서는 세 가지 방식을 나란히 놓고 각각의 장단을 따지는데, Go 인터페이스는 그중 하나에 정확히 대응하고, 그 하나가 가진 약점을 그대로 물려받습니다.
1. 문제 설정 — 복소수를 두 가지로 표현하기#
복소수를 표현하는 방법은 둘입니다.
직교좌표 는 실수부와 허수부를 저장합니다. 덧셈이 쉽습니다. 각각 더하면 됩니다. 극좌표 는 크기와 각도를 저장합니다. 곱셈이 쉽습니다. 크기는 곱하고 각도는 더하면 됩니다.
어느 쪽이 나은가. 문제에 따라 다릅니다. 그러니 원서는 두 표현이 한 시스템 안에 공존 하게 만듭니다. 이게 2.4 의 설정입니다.
필요한 것은 네 개의 연산입니다. real-part, imag-part, magnitude, angle. 이 넷만 있으면 위층은 어떤 표현이 들어 있는지 몰라도 됩니다.
func AddComplex(a, b Complex) Complex {
return Rect{a.RealPart() + b.RealPart(), a.ImagPart() + b.ImagPart()}
}
func MulComplex(a, b Complex) Complex {
return Polar{a.Magnitude() * b.Magnitude(), a.Angle() + b.Angle()}
}
문제는 저 네 개의 연산을 어떻게 갈래 짓느냐 입니다. 원서는 세 가지 답을 순서대로 보여 줍니다.
2. 방법 A — 태그를 붙이고 연산마다 분기한다#
가장 단순한 방법입니다. 데이터에 타입 태그를 붙이고, 연산마다 태그를 보고 갈라집니다.
type Tagged struct {
Tag string
Contents []float64
}
func realPartA(z Tagged) float64 {
switch z.Tag {
case "rectangular":
return realPartRect(z)
case "polar":
return realPartPolar(z)
}
panic("알 수 없는 타입: " + z.Tag)
}
동작합니다.
realPart(직교) = 3.0000 realPart(극) = 3.0000
원서는 이것을 명시적 디스패치(dispatch on type) 라고 부르고, 두 가지 약점을 지적합니다.
첫째, 새 표현을 추가하면 모든 연산을 고쳐야 합니다. 네 개 연산에 case 를 하나씩 더 넣어야 합니다. 표현이 세 개가 되고 연산이 열 개가 되면 서른 곳을 고쳐야 합니다.
둘째, 이름이 충돌합니다. realPartRect 와 realPartPolar 처럼 접미사를 붙여야 합니다. 시스템 전체를 한 사람이 한 번에 만들 때는 견딜 만하지만, 여러 사람이 각자 표현을 추가하면 규칙을 강제할 방법이 없습니다.
원서의 진단이 핵심입니다. 이 방식은 가법적(additive)이지 않습니다. 새 조각을 더하려면 기존 조각을 건드려야 합니다.
3. 방법 B — 데이터 지향 프로그래밍#
2.4.3 의 처방은 우아합니다. 연산과 타입을 두 축으로 하는 표를 만들고, 그 표에 프로시저를 넣습니다.
flowchart LR
H0[" "]:::hdr
H1["rectangular"]:::hdr
H2["polar"]:::hdr
R1["real-part"]:::hdr
A["realPartRect"]
B["realPartPolar"]
R2["magnitude"]:::hdr
C["magnitudeRect"]
D["magnitudePolar"]
H0 ~~~ H1 ~~~ H2
R1 ~~~ A ~~~ B
R2 ~~~ C ~~~ D
classDef hdr fill:none,stroke:none,color:#dddddd
style A fill:#90EE90,color:#000000
style B fill:#87CEEB,color:#000000
style C fill:#90EE90,color:#000000
style D fill:#87CEEB,color:#000000
Go 로 옮기면 이렇습니다.
type key struct{ op, typ string }
var table = map[key]func(Tagged) float64{}
func put(op, typ string, f func(Tagged) float64) { table[key{op, typ}] = f }
func applyGeneric(op string, z Tagged) float64 {
f, ok := table[key{op, z.Tag}]
if !ok {
panic(fmt.Sprintf("연산 %q 이 타입 %q 에 없습니다", op, z.Tag))
}
return f(z)
}
그리고 각 표현이 자기 열을 스스로 채웁니다.
func installRectangular() {
put("real-part", "rectangular", realPartRect)
put("imag-part", "rectangular", imagPartRect)
put("magnitude", "rectangular", magnitudeRect)
put("angle", "rectangular", angleRect)
}
rectangular real=3.0000 imag=4.0000 mag=5.0000 ang=0.9273
polar real=3.0000 imag=4.0000 mag=5.0000 ang=0.9273
이제 새 표현을 추가할 때 기존 코드를 한 줄도 안 고칩니다. installXxx 함수를 하나 만들어 부르면 끝입니다. 이름 충돌도 없습니다. 각 install 함수 안에 지역 함수로 두면 됩니다.
Go 프로그래머에게 이 패턴은 낯설지 않습니다. database/sql 의 sql.Register, image 패키지의 image.RegisterFormat, encoding/gob 의 gob.Register 가 전부 같은 구조입니다. 그리고 대부분 init() 안에서 부릅니다. installRectangular() 가 init() 인 셈입니다.
원서가 지적하지 않는 대가가 하나 있습니다. 정적 검사를 잃습니다.
방법 B 런타임 실패: 연산 "conjugate" 이 타입 "rectangular" 에 없습니다
표에 없는 조합을 부르면 컴파일 시점에는 아무 일도 안 일어나고, 실행 중에 터집니다. Scheme 에서는 원래 그러니 손해가 아니지만, Go 에서는 포기하는 것이 있습니다.
4. 방법 C — 메시지 패싱#
2.4.3 의 마지막에 세 번째 방법이 나옵니다. 표를 두는 대신 데이터가 스스로 분기하게 하는 것입니다.
Part 4 에서 본 “저장 공간 없는 pair” 와 같은 아이디어입니다. 데이터를 함수로 만듭니다.
type Message func(op string) float64
func makeFromRealImagMsg(x, y float64) Message {
return func(op string) float64 {
switch op {
case "real-part":
return x
case "imag-part":
return y
case "magnitude":
return math.Hypot(x, y)
case "angle":
return math.Atan2(y, x)
}
panic("알 수 없는 연산: " + op)
}
}
이 함수를 원서는 “지능적인 데이터 객체” 라고 부릅니다. 연산 이름을 메시지로 받아서 자기가 처리합니다. 그리고 원서는 여기서 한 문장을 덧붙입니다. 이것이 메시지 패싱 이고, 3장에서 상태를 다룰 때 유용해질 것이라고.
이게 객체 지향의 정의입니다. 데이터에 필드가 있느냐 없느냐가 아니라, 메시지를 받아 스스로 답하느냐 입니다. 1985년 교재의 2장 끝자락에 객체 지향의 핵심이 세 줄짜리 클로저로 요약되어 있습니다.
5. 그래서 Go 인터페이스는 어느 것인가#
이제 답할 수 있습니다.
type Complex interface {
RealPart() float64
ImagPart() float64
Magnitude() float64
Angle() float64
}
type Rect struct{ X, Y float64 }
func (r Rect) RealPart() float64 { return r.X }
func (r Rect) Magnitude() float64 { return math.Hypot(r.X, r.Y) }
// ...
type Polar struct{ R, A float64 }
func (p Polar) RealPart() float64 { return p.R * math.Cos(p.A) }
func (p Polar) Magnitude() float64 { return p.R }
// ...
a = 3.0000+4.0000i (r=5.0000, θ=0.9273)
b = 0.0000+2.0000i (r=2.0000, θ=1.5708)
a + b = 3.0000+6.0000i (r=6.7082, θ=1.1071)
a * b = -8.0000+6.0000i (r=10.0000, θ=2.4981)
(3+4i)(2i) = -8+6i 입니다. 맞습니다. 그리고 a 는 직교좌표, b 는 극좌표인데 AddComplex 는 둘의 차이를 모릅니다.
Go 인터페이스는 방법 C, 메시지 패싱입니다. 정확히는 컴파일 시점에 검사되는 메시지 패싱 입니다.
| 원서의 메시지 패싱 | Go 인터페이스 | |
|---|---|---|
| 메시지 이름 | 문자열 "real-part" | 메서드 이름 RealPart |
| 디스패치 | 클로저 안의 switch | 메서드 테이블(itab) |
| 없는 메시지를 부르면 | 런타임 panic | 컴파일 에러 |
| 타입이 인터페이스를 만족하는지 | 알 수 없음 | 컴파일러가 검사 |
Go 의 인터페이스가 암묵적(implicit) 이라는 점도 원서 쪽에 가깝습니다. Rect 는 자기가 Complex 를 구현한다고 선언하지 않습니다. 메서드가 맞으면 그냥 됩니다. 원서의 메시지 패싱 객체도 자기가 무엇인지 선언하지 않습니다. 메시지에 답할 줄 알면 그만입니다.
그리고 원서의 “내부 표현은 숨기고 연산만 노출한다” 는 주장이 Go 에서는 그냥 관용구입니다. Rect 의 X, Y 를 소문자로 두고 인터페이스만 내보내면 됩니다.
1985년에 쓰인 절이 지금 Go 인터페이스 설계 지침으로 그대로 읽힙니다.
6. 표현 문제 — Go 가 물려받은 약점#
그런데 방법 B 와 방법 C 는 서로 다른 약점을 가집니다. 원서는 2.4.3 끝에서 이 대비를 명확히 짚습니다.
새 타입을 추가하기 쉬운가, 새 연산을 추가하기 쉬운가. 둘 다 쉬운 방법은 없습니다.
실제로 확인해 봤습니다. 먼저 Complex 인터페이스에 연산 하나(Conjugate)를 추가하고, Polar 에만 구현을 안 붙였습니다.
p06c/main.go:33:18: cannot use Polar{…} (value of struct type Polar)
as Complex value in variable declaration:
Polar does not implement Complex (missing method Conjugate)
연산 하나를 추가했더니 모든 구현 타입이 깨졌습니다. 인터페이스를 고치는 순간, 그 인터페이스를 구현하던 모든 타입이 컴파일에 실패합니다.
반대로 새 타입을 추가해 봤습니다. PureImag 라는 순허수 타입에 다섯 개 메서드를 붙인 새 파일 하나만 만들었습니다.
새 타입 PureImag 추가: 컴파일 성공 (기존 코드 무수정)
기존 파일을 한 줄도 안 고쳤습니다.
정리하면 이렇습니다.
| 새 타입 추가 | 새 연산 추가 | 오류 발견 시점 | |
|---|---|---|---|
| 방법 A 명시적 디스패치 | 모든 연산 수정 | 새 함수 하나 | 컴파일 (누락은 놓침) |
| 방법 B 디스패치 테이블 | 파일 하나 추가 | 행 하나 추가 | 런타임 |
| 방법 C / Go 인터페이스 | 파일 하나 추가 | 모든 구현체 수정 | 컴파일 |
이것이 이른바 표현 문제(expression problem) 입니다. 1998년에 Philip Wadler 가 이 이름을 붙였는데, 원서는 1985년에 이미 이 구도를 정확히 서술하고 있습니다. 이름만 없었을 뿐입니다.
Go 실무 지침으로 번역하면 이렇게 됩니다.
- 구현체가 늘어날 것 같으면 인터페이스가 맞습니다. 새 드라이버, 새 포맷, 새 저장소가 계속 붙는 자리.
- 연산이 늘어날 것 같으면 인터페이스가 부담이 됩니다. 타입은 몇 개로 고정인데 그 위에서 할 일이 계속 늘어나는 자리. 이럴 때는 타입 스위치를 쓰는 함수를 밖에 두는 편이 낫습니다.
- 그래서 Go 커뮤니티의 “인터페이스는 작게 유지하라” 는 조언이 나옵니다.
io.Reader가 메서드 하나인 이유가 이것입니다. 인터페이스가 작을수록 연산 추가의 파급이 작습니다.
원서가 준 것은 이 조언의 이유 입니다. 관용구를 외우는 것과 왜 그런지 아는 것은 다릅니다.
7. 2.5 — 서로 다른 타입이 섞이면#
2.5 는 한 단계 더 밀어붙입니다. 지금까지는 같은 종류 의 값(복소수) 안에서 표현만 달랐습니다. 이제 다른 종류 를 섞습니다. 정수 + 유리수 + 실수 + 복소수를 같은 Add 로 처리해야 합니다.
원서의 처방이 타입 타워(tower of types) 입니다. 정수는 유리수이고, 유리수는 실수이고, 실수는 복소수입니다. 낮은 것을 높은 것으로 올릴 수 있습니다.
type Num interface{ tower() int } // 타워에서의 높이
func (Integer) tower() int { return 0 }
func (Rational) tower() int { return 1 }
func (Real) tower() int { return 2 }
func (Cplx) tower() int { return 3 }
func raise(x Num) Num {
switch v := x.(type) {
case Integer:
return mkRat(int(v), 1)
case Rational:
return Real(float64(v.N) / float64(v.D))
case Real:
return Cplx{float64(v), 0}
}
panic("더 올릴 수 없습니다")
}
func coerce(a, b Num) (Num, Num) {
for a.tower() < b.tower() { a = raise(a) }
for b.tower() < a.tower() { b = raise(b) }
return a, b
}
이제 Add 는 먼저 높이를 맞추고, 같은 타입끼리 더합니다.
3:정수 + 4:정수 = 7:정수
3:정수 + 1/2:유리수 = 7/2:유리수
1/2:유리수 + 1/2:유리수 = 1/1:유리수 drop -> 1:정수
1/3:유리수 + 0.5:실수 = 0.8333333333333333:실수
1.5:실수 + 2+3i:복소수 = 3.5+3i:복소수
2:정수 + 0+1i:복소수 = 2+1i:복소수
drop — 올라간 것을 도로 내리기#
연습문제 2.85 가 반대 방향을 요구합니다. 계산 결과가 1/1 이면 정수 1 로 돌려주는 게 낫습니다. 2+0i 도 마찬가지입니다.
방법이 영리합니다. 한 칸 내렸다가 다시 올려서 원래 값과 같으면, 정말로 내려도 되는 것입니다.
func drop(x Num) Num {
for {
p, ok := project(x)
if !ok { return x }
back := p
for back.tower() < x.tower() { back = raise(back) }
if fmt.Sprint(back) != fmt.Sprint(x) { return x }
x = p
}
}
(0+1i) * (0+1i) = -1+0i:복소수 -> drop: -1:정수
1/2 * 2 = 1:정수
i × i 가 복소수 −1+0i 를 거쳐 정수 −1 까지 세 칸을 내려왔습니다. 복소수 → 실수 → 유리수 → 정수. 세 번 연속으로 “내렸다 올려도 같다” 가 성립했기 때문입니다.
8. Go 로 못 하는 것 — 더블 디스패치#
여기서 Go 의 벽이 하나 나옵니다.
Add(a, b) 는 두 인자의 타입 모두 를 봐야 합니다. 그런데 Go 의 메서드 디스패치는 리시버 하나 에만 걸립니다. a.Add(b) 로 쓰면 b 의 타입은 정적 타입뿐이고, 동적 디스패치가 안 됩니다.
그래서 위 코드에서 Add 를 메서드가 아니라 일반 함수 + 타입 스위치 로 썼습니다. 어쩔 수 없는 선택입니다.
원서의 디스패치 테이블은 이 문제를 자연스럽게 다룹니다. 키를 (연산, 타입1, 타입2) 로 잡으면 되기 때문입니다. Scheme 의 apply-generic 은 실제로 인자 리스트 전체의 타입을 키로 씁니다.
이건 Go 만의 문제가 아니라 단일 디스패치 객체 지향 언어 전체의 문제 입니다. Java 의 equals, C++ 의 연산자 오버로딩이 다 같은 곳에서 고생합니다. Common Lisp 의 다중 메서드나 Julia 의 다중 디스패치가 이 문제를 언어 차원에서 푼 드문 사례입니다.
실무 대응은 셋 중 하나입니다.
- 일반 함수 + 타입 스위치. 위에서 한 것. 타입이 몇 개 안 되면 이게 제일 낫습니다.
- 강제 변환으로 한쪽을 없앤다.
coerce를 먼저 돌려서 두 인자를 같은 타입으로 만든 뒤 단일 디스패치. 위 코드가 실제로 하는 일입니다. - 디스패치 테이블. 타입이 많고 계속 늘어나면. 대신 런타임 오류를 감수합니다.
원서 2.5.2 는 이 문제를 정면으로 다루고, “강제 변환의 계층 구조를 잘못 설계하면 시스템이 감당 못 하게 커진다” 고 경고합니다. 타입 n 개에 이항 연산 m 개면 최악의 경우 n²m 개의 조합 이 나옵니다. 타워로 줄인 것이 그 대응입니다.
9. 이번 편의 정리#
| SICP 2.4~2.5 의 주장 | Go 에서 |
|---|---|
| 태그를 붙이고 연산마다 분기 | 가능하지만 가법적이지 않음. 원서와 같은 진단 |
| 디스패치 테이블로 가법성 확보 | 성립. sql.Register 등 표준 라이브러리의 관용구 |
| 메시지 패싱 = 지능적 데이터 객체 | = Go 인터페이스. 정적 검사가 붙은 판 |
| 새 타입 vs 새 연산의 트레이드오프 | 그대로 성립. 컴파일 에러로 확인함 |
| 타입 타워와 강제 변환 | 성립. drop 까지 재현 |
| 인자 여러 개에 동시 디스패치 | 불가. 단일 디스패치의 한계. 함수 + 타입 스위치로 우회 |
2장이 여기서 끝납니다. 두 장을 지나오며 조립한 것은 계산 방법(1장) 과 데이터(2장) 입니다. 둘 다 시간이 없는 세계였습니다. 같은 인자를 넣으면 언제나 같은 답이 나왔고, 치환 모델이 통했습니다.
3장은 그 세계를 깹니다. 대입 을 도입합니다. 같은 이름이 시점에 따라 다른 값을 가리키게 되고, Part 2 에서 유효기간을 예고했던 치환 모델이 여기서 실제로 폐기됩니다. 대신 환경 모델 이 들어옵니다.
그리고 3장은 그 대가를 정직하게 계산합니다. 대입을 얻고 무엇을 잃었는지를 절 하나를 통째로 써서 따집니다. Go 프로그래머에게는 익숙한 이야기일 텐데, 익숙한 이유가 무엇인지 알게 되는 편이 될 것입니다.
References#
1차 자료
- Abelson, H., Sussman, G. J., with Sussman, J. Structure and Interpretation of Computer Programs, 2nd ed. MIT Press, 1996. CC BY-SA 4.0.
- 2.4 절 (여러 표현·태그드 데이터·데이터 지향) — https://sarabander.github.io/sicp/html/2_002e4.xhtml
- 2.5 절 (제네릭 연산 시스템·강제 변환) — https://sarabander.github.io/sicp/html/2_002e5.xhtml
drop은 연습문제 2.85, 강제 변환 조합 폭발 문제는 2.5.2 절 본문에서 다룹니다.
용어
- “표현 문제(expression problem)” 라는 이름은 1998년 Philip Wadler 가 붙인 것입니다. 원서는 이 이름을 쓰지 않습니다. 원서가 서술한 트레이드오프에 후대의 이름을 붙여 설명한 것임을 밝혀 둡니다.
본문의 실측 데이터
- 모든 출력값과 컴파일 에러 메시지는
go version go1.26.0 darwin/arm64에서 직접 실행·컴파일해 얻은 것입니다. - 6절의 컴파일 에러는
go build출력 원문을 줄바꿈만 넣어 옮겼습니다. “새 타입 추가는 무수정” 도 실제로 파일 하나만 추가해 빌드를 통과시킨 결과입니다.
시리즈