Go 로 읽는 SICP Part 7: 시간이 들어오면 치환 모델이 무너진다
이 글은 Claude Opus 5 를 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Part 6까지 2장을 끝냈습니다. 1장과 2장이 만든 세계에는 시간이 없었습니다. 같은 인자를 넣으면 언제나 같은 답이 나왔고, 그래서 프로그램을 수식처럼 다룰 수 있었습니다.
3장은 그 세계를 깹니다. 제목은 Modularity, Objects, and State 이고, 첫 도구는 대입 입니다.
원서가 대입을 다루는 방식이 이 책답습니다. 대입을 소개하는 절(3.1.1) 다음에 “대입을 도입해서 얻는 것”(3.1.2) 을 놓고, 바로 이어서 “대입을 도입해서 잃는 것”(3.1.3) 을 놓습니다. 절 하나를 통째로 써서 자기가 방금 준 도구의 대가를 계산합니다.
1. 3.1.1 — 상태를 가진 것을 만들기#
원서의 첫 예제는 인출 함수입니다. 부를 때마다 잔액이 줄어듭니다.
func makeWithdraw(balance int) func(int) string {
return func(amount int) string {
if balance >= amount {
balance -= amount // 바깥 스코프의 변수를 고친다
return fmt.Sprint(balance)
}
return "잔액 부족"
}
}
w1(50) = 50
w1(60) = 잔액 부족
w1(30) = 20
w2(50) = 50 <- w1 과 무관하다
두 가지가 동시에 일어났습니다.
첫째, 같은 인자에 다른 답이 나옵니다. w1(50) 을 두 번 부르면 처음엔 50, 두 번째엔 잔액 부족 입니다. 1~2장에서는 있을 수 없는 일이었습니다.
둘째, w1 과 w2 가 서로를 모릅니다. 각자 자기 balance 를 가집니다. 이 격리가 대입의 진짜 값어치입니다.
원서는 여기에 메시지 패싱을 얹어 계좌 객체를 만듭니다. Part 6 에서 본 그 패턴입니다.
func makeAccount(balance int) Account {
withdraw := func(amount int) string { /* ... */ }
deposit := func(amount int) string { balance += amount; return fmt.Sprint(balance) }
return func(msg string) func(int) string {
switch msg {
case "withdraw":
return withdraw
case "deposit":
return deposit
}
panic("알 수 없는 요청: " + msg)
}
}
withdraw 50 -> 50
withdraw 60 -> 잔액 부족
deposit 40 -> 90
withdraw 60 -> 30
이게 객체입니다. 상태를 감추고, 메시지로만 접근을 허용하고, 여러 개를 만들면 서로 독립입니다. 클래스도, new 도, 상속도 없이 클로저 하나로 만들어졌습니다.
Go 라면 어떻게 쓸까#
Go 프로그래머는 당연히 이렇게 씁니다.
type BankAccount struct{ balance int }
func (a *BankAccount) Withdraw(amount int) (int, error) {
if a.balance < amount {
return a.balance, fmt.Errorf("잔액 부족: 잔액 %d, 요청 %d", a.balance, amount)
}
a.balance -= amount
return a.balance, nil
}
func (a *BankAccount) Deposit(amount int) int { a.balance += amount; return a.balance }
세 판본을 나란히 놓으면 이렇습니다.
| 상태가 사는 곳 | 접근 통제 | 연산 추가 | Go 에서의 자리 | |
|---|---|---|---|---|
| 클로저 | 클로저 환경 | 완전 (밖에서 볼 방법 없음) | 어려움 | 상태 하나·연산 하나짜리. 미들웨어, 카운터 |
| 메시지 패싱 | 클로저 환경 | 완전 | 쉬움 (case 추가) | 거의 안 씀 |
| 구조체 + 메서드 | 구조체 필드 | 패키지 경계까지 | 쉬움 | 기본값 |
Go 에서 구조체가 기본값인 이유는 취향이 아닙니다. 디버거와 fmt.Printf("%+v") 로 상태를 들여다볼 수 있고, 메서드 집합이 인터페이스로 검사되고, 필드 정렬과 할당이 예측 가능합니다. 클로저 안의 balance 는 이 셋 중 무엇도 안 됩니다.
그럼에도 원서가 클로저로 시작하는 이유가 있습니다. 상태가 무엇인지 보여 주기 위해서입니다. 구조체로 쓰면 “필드가 있으니 상태가 있지” 로 끝나는데, 클로저로 쓰면 상태가 변수를 붙잡고 있는 환경 이라는 게 드러납니다. 이게 3.2 절 환경 모델의 예고편입니다.
2. 3.1.2 — 대입을 도입해서 얻는 것#
“상태를 감출 수 있다” 는 게 왜 값어치가 있는가. 원서는 몬테카를로 시뮬레이션으로 답합니다.
체사로(Cesàro)의 정리에 따르면, 무작위로 고른 두 정수의 최대공약수가 1일 확률은 6/π² 입니다. 뒤집으면 그 확률을 실험으로 재서 π 를 추정할 수 있습니다.
원서의 요점은 π 가 아니라 두 가지 작성법의 차이 입니다.
// (a) 상태를 감춘다
func makeRand(seed uint32) func() uint32 {
x := seed
return func() uint32 {
x = randUpdate(x)
return x
}
}
func estimatePi(trials int, rnd func() uint32) float64 {
passed := 0
for i := 0; i < trials; i++ {
if gcd(rnd(), rnd()) == 1 { passed++ }
}
return math.Sqrt(6 / (float64(passed) / float64(trials)))
}
// (b) 상태를 드러낸다
func estimatePiExplicit(trials int, seed uint32) float64 {
x := seed
passed := 0
for i := 0; i < trials; i++ {
x = randUpdate(x); a := x
x = randUpdate(x); b := x // 씨앗을 손으로 굴려야 한다
if gcd(a, b) == 1 { passed++ }
}
// ...
}
두 함수의 답은 같습니다. 다른 것은 책임의 위치 입니다. (b)에서는 estimatePi 가 난수 생성기의 내부 사정을 알아야 합니다. 씨앗을 들고 다니고, 언제 굴릴지 알아야 합니다. 몬테카를로 실험이 하는 일과 아무 상관 없는 지식입니다.
대입은 모듈 경계를 그을 수 있게 해 줍니다. 원서의 표현으로는, 상태를 시스템의 여러 부분에 분산시켜 각 부분이 자기 상태만 신경 쓰게 만드는 것입니다. 이것이 3장 제목의 “Modularity” 입니다.
Go 에서 이 이야기가 낯설지 않은 이유가 있습니다. rand.Rand, bufio.Reader, sql.Tx 가 전부 같은 구조입니다. 내부 상태를 들고 있고, 부를 때마다 그 상태가 바뀌고, 부르는 쪽은 상태를 모릅니다.
3. 그런데 π 가 2.72 로 나왔습니다#
여기서 사고가 하나 났습니다. 위 코드를 처음 돌렸을 때 이렇게 나왔습니다.
시행 1000 추정 π = 2.714960 (오차 -0.426633)
시행 100000 추정 π = 2.717448 (오차 -0.424144)
시행 10000000 추정 π = 2.720871 (오차 -0.420722)
시행을 만 배로 늘려도 오차가 안 줄어듭니다. 통계 오차라면 √n 에 반비례해 줄어야 하는데, 2.72 근처에 딱 붙어 있습니다. 이건 표본이 모자란 게 아니라 추정량 자체가 편향 되었다는 신호입니다.
범인은 난수 생성기였습니다. 원서는 rand-update 의 구현을 명시하지 않고 넘어가는데, 저는 교과서적인 선형 합동 생성기를 그대로 썼습니다.
func randUpdate(x uint32) uint32 { return x*1103515245 + 12345 }
연속한 출력의 홀짝을 찍어 봤습니다.
연속 출력의 홀짝 : [0 1 0 1 0 1 0 1]
짝수와 홀수가 정확히 번갈아 나옵니다. 그러면 gcd(a, b) 를 부를 때 a 와 b 중 하나는 반드시 홀수입니다. 두 수가 동시에 짝수인 경우가 절대 안 나옵니다. 최대공약수가 2의 배수일 확률이 0이 되니 서로소일 확률이 부풀려집니다.
이건 32비트 LCG 의 알려진 성질입니다. 법이 2³²이고 승수가 4로 나눈 나머지가 1이면 하위 k번째 비트의 주기가 2^k 입니다. 최하위 비트의 주기는 2입니다.
생성기를 바꿔 가며 200만 번씩 재 봤습니다.
| 난수원 | 서로소 비율 p | 추정 π |
|---|---|---|
| 이론값 | 0.607927 | 3.141593 |
| LCG 32비트 전체 | 0.810415 | 2.720957 |
| 같은 LCG 의 상위 16비트 | 0.607955 | 3.141521 |
| 64비트 LCG 의 상위 32비트 | 0.608596 | 3.139864 |
math/rand/v2 (PCG) | 0.607717 | 3.142136 |
같은 생성기인데 상위 비트만 쓰니 소수점 넷째 자리까지 맞습니다. 한 줄 고친 결과입니다.
이 사고를 지우지 않고 싣는 이유가 있습니다. 원서의 3.1.2 는 “대입으로 모듈 경계를 그을 수 있다” 를 가르치려고 몬테카를로를 예로 듭니다. 그런데 모듈 경계를 잘 긋는 것과 그 모듈 안이 올바른 것은 별개 입니다. makeRand 로 상태를 깔끔하게 감춘 것과 그 안의 알고리즘이 쓸 만한 것은 아무 관계가 없습니다.
추상화는 경계를 지켜 줄 뿐 내용을 보증하지 않습니다. Part 5 에서 “인터페이스는 계약이지 성능 보증서가 아니다” 라고 했는데, 여기서는 정확성 쪽에서 같은 일이 벌어졌습니다.
4. 3.1.3 — 대입을 도입해서 잃는 것#
이제 청구서입니다. 원서는 세 가지를 잃는다고 말합니다.
(1) 치환 모델이 무너집니다#
Part 2 에서 배운 치환 모델은 “이름을 값으로 바꿔치기한다” 는 규칙이었습니다. 대입이 들어오면 이 규칙이 성립하지 않습니다. 같은 이름이 시점에 따라 다른 값을 가리키기 때문입니다.
원서는 대입이 없는 프로그래밍을 함수형(functional) 프로그래밍 이라고 부릅니다. 그 세계에서는 프로그램을 수학 함수처럼 다룰 수 있습니다.
(2) 참조 투명성을 잃습니다#
같은 식을 같은 값으로 바꿔 쓸 수 있는 성질을 참조 투명성 이라고 합니다. 대입이 있으면 사라집니다. w1(50) 을 50 으로 바꿔 쓸 수 없습니다. 두 번째 호출은 다른 답을 내기 때문입니다.
Go 프로그래머에게 이건 익숙한 통증입니다. 함수 호출 순서를 바꿔도 되는지 판단하려면 그 함수가 무엇을 건드리는지 알아야 합니다. 시그니처만으로는 알 수 없습니다.
(3) “같다” 가 무슨 뜻인지 애매해집니다#
이게 원서가 가장 공들여 다루는 부분입니다. 두 계좌의 잔액이 둘 다 100원이면 같은 계좌인가.
peter := &Acc{100}
paul := peter // 공동 계좌: 같은 객체
mary := &Acc{100} // 별도 계좌: 값만 같다
peter == paul : true (포인터 동일)
peter == mary : false (포인터 다름)
*peter == *mary: true (값은 같다)
peter 인출 30 후 → peter=70 paul=70 mary=100
*peter == *mary: false <- 이제 값도 다르다
peter 와 mary 는 인출 전까지 구별할 방법이 없었습니다. 그런데 하나를 건드리자 달라졌습니다. 즉 대입이 있는 세계에서는 “지금 같아 보인다” 가 “같다” 를 뜻하지 않습니다.
그리고 대입이 없으면 이 질문 자체가 생기지 않습니다.
불변 값끼리: true - 구별할 이유가 없다
a := 100 과 b := 100 이 “같은 100 인가” 를 묻는 것은 무의미합니다. 100 을 바꿀 수 없으니 구별할 이유가 없습니다.
Go 에서 이 구분이 == 의 의미로 그대로 드러납니다. 포인터끼리의 == 는 동일성(identity), 구조체 값끼리의 == 는 동등성(equality)입니다. 두 개의 다른 질문에 같은 연산자를 쓰는 것이고, 어느 쪽인지는 타입이 정합니다.
5. 3.2 — 환경 모델#
치환 모델이 죽었으니 대체품이 필요합니다. 환경 모델 입니다.
핵심 개념은 프레임(frame) 입니다. 프레임은 이름과 값을 묶는 표이고, 각 프레임은 바깥 환경 을 하나 가리킵니다. 이름을 찾을 때는 현재 프레임부터 보고, 없으면 바깥으로 올라갑니다.
func makeCounter() func() int {
n := 0
return func() int { n++; return n }
}
c1, c2 := makeCounter(), makeCounter()
1 2 3 1
c1 을 세 번 부르면 1, 2, 3. c2 는 다시 1. 그림으로 그리면 이렇습니다.
flowchart TD
G["전역 환경<br/>makeCounter → 프로시저"]
F1["프레임 E1<br/>n: 3"]
F2["프레임 E2<br/>n: 1"]
C1["c1 클로저"]
C2["c2 클로저"]
F1 -->|"바깥 환경"| G
F2 -->|"바깥 환경"| G
C1 -->|"이 환경에서 실행된다"| F1
C2 -->|"이 환경에서 실행된다"| F2
style G fill:#D3D3D3,color:#000000
style F1 fill:#90EE90,color:#000000
style F2 fill:#87CEEB,color:#000000
style C1 fill:#FFD700,color:#000000
style C2 fill:#FFD700,color:#000000
makeCounter 를 부를 때마다 새 프레임이 생깁니다. 반환된 클로저는 그 프레임을 가리킵니다. 그래서 c1 과 c2 가 각자의 n 을 갖습니다.
원서가 강조하는 것은 이겁니다. 프로시저는 코드와 환경의 쌍입니다. 코드만으로는 무엇을 할지 정해지지 않습니다. 어느 환경에서 실행되느냐가 함께 정해져야 합니다.
6. Go 의 프레임은 어디에 있는가#
원서의 환경 모델은 종이 위의 그림입니다. Go 에서는 그 그림이 실제로 힙 위에 있습니다. 컴파일러에게 물어볼 수 있습니다.
$ go build -gcflags='-m' ./p07c/
p07c/main.go:6:2: moved to heap: n
p07c/main.go:7:9: func literal escapes to heap
moved to heap: n. n 은 지역 변수로 선언되었지만 스택에 있을 수 없습니다. makeCounter 가 반환된 뒤에도 클로저가 그걸 봐야 하기 때문입니다. 그래서 Go 의 탈출 분석(escape analysis)이 n 을 힙으로 옮깁니다.
원서의 “프레임” 이 Go 에서는 힙에 할당된 클로저 캡처 변수입니다. 그림이 아니라 실제 할당입니다.
이걸 알고 나면 실무 지식 하나가 따라옵니다. 클로저로 상태를 감추면 할당이 생깁니다. 뜨거운 루프 안에서 클로저를 만들면 반복마다 힙 할당이 일어납니다. Go 에서 구조체 + 메서드가 기본값인 이유가 하나 더 늘어난 셈입니다.
7. Go 1.22 의 루프 변수 변경은 프레임 이야기였습니다#
환경 모델을 알고 나면 Go 역사상 가장 유명한 함정 하나가 정확히 설명됩니다.
var fns []func() int
for i := 0; i < 3; i++ {
fns = append(fns, func() int { return i })
}
이 세 클로저가 뭘 돌려줄까요. 두 가지 답이 있고, 둘 다 Go 에서 실제로 관측됩니다. 같은 소스를 go.mod 의 언어 버전만 바꿔 돌렸습니다.
go 1.26 시맨틱 : 루프 변수 캡처: [0 1 2]
go 1.21 시맨틱 : 루프 변수 캡처: [3 3 3]
환경 모델로 설명하면 한 문장입니다.
Go 1.21 까지는 루프 전체에 프레임이 하나였고, Go 1.22 부터는 반복마다 프레임이 새로 생깁니다.
옛 시맨틱에서는 세 클로저가 같은 프레임의 같은 i 를 가리킵니다. 루프가 끝나면 그 i 는 3 이고, 셋 다 3 을 봅니다. 새 시맨틱에서는 반복마다 i 가 새로 만들어지고 각 클로저가 자기 것을 가리킵니다.
makeCounter 를 두 번 불러 c1 과 c2 가 각자의 n 을 갖는 것과 정확히 같은 이야기 입니다. 프레임을 몇 개 만드느냐의 문제입니다.
Go 1.22 이전 코드에서 사람들이 쓰던 관용구를 다시 보면 재미있습니다.
for i := 0; i < 3; i++ {
i := i // 프레임을 하나 더 만든다
fns = append(fns, func() int { return i })
}
이 이상해 보이는 i := i 가 하는 일이 새 프레임을 만드는 것 이었습니다. 1985년 교재의 3.2 절을 읽고 나면 이 관용구가 우연한 요령이 아니라 환경 모델의 직접적인 귀결로 보입니다.
8. 이번 편의 정리#
| SICP 3.1~3.2 의 주장 | Go 에서 |
|---|---|
| 클로저로 지역 상태를 만든다 | 성립. 다만 Go 관용구는 구조체 + 메서드 |
| 메시지 패싱으로 객체를 만든다 | 성립하나 실무에서는 안 씀. 메서드가 대신함 |
| 대입은 모듈 경계를 그을 수 있게 한다 | 성립. rand.Rand·bufio.Reader 가 그 구조 |
| 대입은 치환 모델을 무너뜨린다 | 그대로 성립 |
| 대입은 참조 투명성을 없앤다 | 그대로 성립 |
| 대입은 “같다” 를 애매하게 만든다 | 성립. Go 의 == 가 포인터/값에서 다른 질문이 됨 |
| 프로시저는 코드 + 환경이다 | 실측 가능. moved to heap 으로 프레임이 보임 |
| 프레임을 언제 만드느냐가 의미를 정한다 | 성립. Go 1.22 루프 변수 변경이 정확히 그것 |
다음 편은 3.3 입니다. 이번 편이 상태를 가진 프로시저 였다면, 다음은 상태를 가진 데이터 입니다. set-car! 와 set-cdr! 로 pair 를 고칠 수 있게 되면 큐와 테이블을 만들 수 있고, 그 위에 디지털 회로 시뮬레이터 를 올립니다.
회로 시뮬레이터는 3장에서 가장 재미있는 예제입니다. AND 게이트와 인버터를 부품으로 놓고 반가산기·전가산기를 조립하는데, 놀랍게도 그 시뮬레이터가 이벤트 루프 입니다. 시간을 명시적으로 다루는 프로그램을 만들어 보면, 3.4 절의 동시성 이야기로 넘어갈 준비가 됩니다.
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.
- 3.1 절 (대입과 지역 상태) — https://sarabander.github.io/sicp/html/3_002e1.xhtml
- 3.2 절 (환경 모델) — https://sarabander.github.io/sicp/html/3_002e2.xhtml
- 몬테카를로 π 추정은 3.1.2 절, “같음과 변화” 논의는 3.1.3 절에 있습니다.
- 원서는
rand-update의 구현을 명시하지 않습니다. 본문의 선형 합동 생성기는 이 글에서 고른 것이며, 그 선택이 만든 결함도 이 글의 책임입니다.
Go 쪽 근거
- Go 1.22 의 루프 변수 시맨틱 변경은 언어 버전에 따라 동작이 갈립니다. 본문의 두 결과는 같은 소스 파일 을
go.mod의go지시자만1.26과1.21로 바꿔 실행한 것입니다. moved to heap: n은go build -gcflags='-m'의 실제 출력입니다.
본문의 실측 데이터
- 모든 수치는
go version go1.26.0 darwin/arm64에서 직접 실행한 결과입니다. - 3절의 난수 생성기 비교는 각 생성기로 200만 번씩 시행한 값입니다. 씨앗은 모두 20260825 로 고정했습니다.
시리즈