이 글은 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 의 다중 디스패치가 이 문제를 언어 차원에서 푼 드문 사례입니다.

실무 대응은 셋 중 하나입니다.

  1. 일반 함수 + 타입 스위치. 위에서 한 것. 타입이 몇 개 안 되면 이게 제일 낫습니다.
  2. 강제 변환으로 한쪽을 없앤다. coerce 를 먼저 돌려서 두 인자를 같은 타입으로 만든 뒤 단일 디스패치. 위 코드가 실제로 하는 일입니다.
  3. 디스패치 테이블. 타입이 많고 계속 늘어나면. 대신 런타임 오류를 감수합니다.

원서 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차 자료

용어

  • “표현 문제(expression problem)” 라는 이름은 1998년 Philip Wadler 가 붙인 것입니다. 원서는 이 이름을 쓰지 않습니다. 원서가 서술한 트레이드오프에 후대의 이름을 붙여 설명한 것임을 밝혀 둡니다.

본문의 실측 데이터

  • 모든 출력값과 컴파일 에러 메시지는 go version go1.26.0 darwin/arm64 에서 직접 실행·컴파일해 얻은 것입니다.
  • 6절의 컴파일 에러는 go build 출력 원문을 줄바꿈만 넣어 옮겼습니다. “새 타입 추가는 무수정” 도 실제로 파일 하나만 추가해 빌드를 통과시킨 결과입니다.

시리즈