이 글은 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.6079273.141593
LCG 32비트 전체0.8104152.720957
같은 LCG 의 상위 16비트0.6079553.141521
64비트 LCG 의 상위 32비트0.6085963.139864
math/rand/v2 (PCG)0.6077173.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.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 로 고정했습니다.

시리즈