본문으로 건너뛰기

클로저

클로저란?

클로저(Closure) 는 자신이 선언된 환경의 변수를 캡처(capture)하는 함수입니다. 클로저는 함수 본문 밖에 정의된 변수를 참조하고, 그 변수의 생명주기를 함수보다 길게 유지할 수 있습니다.

클로저가 변수를 캡처한다는 말은 단순히 값을 복사하는 것이 아니라, 변수 자체에 대한 참조를 유지 한다는 의미입니다. 따라서 클로저 안에서 변수를 변경하면 바깥의 변수도 함께 바뀝니다.

package main

import "fmt"

func main() {
// 외부 변수 x를 캡처하는 클로저
x := 10

increment := func() {
x++ // x의 참조를 캡처 — 외부 x를 직접 수정
}

printX := func() {
fmt.Println("x =", x) // 동일한 x를 참조
}

printX() // x = 10
increment()
increment()
printX() // x = 12 (클로저가 x를 변경했음)

fmt.Println("main의 x =", x) // 12 — 동일한 변수
}

실행 결과:

x = 10
x = 12
main의 x = 12

incrementprintX는 모두 같은 x 변수를 공유합니다. 이것이 클로저의 핵심입니다.


클로저 생성 패턴

클로저를 반환하는 함수를 만들면 상태를 캡슐화한 독립적인 객체처럼 동작하는 함수를 생성할 수 있습니다.

package main

import "fmt"

// 카운터 생성기 — 각 호출마다 독립적인 count를 가진 클로저 반환
func makeCounter(start int) func() int {
count := start
return func() int {
current := count
count++
return current
}
}

// 누산기 — 이전 합계를 기억
func makeAccumulator() func(int) int {
sum := 0
return func(n int) int {
sum += n
return sum
}
}

// 순서 보장 ID 생성기
func makeIDGenerator(prefix string) func() string {
id := 0
return func() string {
id++
return fmt.Sprintf("%s-%04d", prefix, id)
}
}

func main() {
// 독립적인 카운터 인스턴스 생성
counterA := makeCounter(0)
counterB := makeCounter(100)

fmt.Println(counterA()) // 0
fmt.Println(counterA()) // 1
fmt.Println(counterA()) // 2
fmt.Println(counterB()) // 100 — A와 독립적
fmt.Println(counterB()) // 101
fmt.Println(counterA()) // 3 — B와 상관없이 자체 상태 유지

// 누산기
acc := makeAccumulator()
for _, n := range []int{10, 20, 30, 40} {
fmt.Printf("추가 %d → 합계: %d\n", n, acc(n))
}

// ID 생성기
userID := makeIDGenerator("USR")
orderID := makeIDGenerator("ORD")

fmt.Println(userID()) // USR-0001
fmt.Println(userID()) // USR-0002
fmt.Println(orderID()) // ORD-0001
fmt.Println(userID()) // USR-0003
fmt.Println(orderID()) // ORD-0002
}

실행 결과:

0
1
2
100
101
3
추가 10 → 합계: 10
추가 20 → 합계: 30
추가 30 → 합계: 60
추가 40 → 합계: 100
USR-0001
USR-0002
ORD-0001
USR-0003
ORD-0002

counterAcounterB는 각각 독립적인 count 변수를 가집니다. makeCounter가 호출될 때마다 새로운 count 변수가 생성되고, 반환된 클로저가 그것을 캡처합니다.


루프 클로저 함정

Go 초보자가 가장 많이 만나는 버그 중 하나입니다. 루프 변수를 클로저로 캡처할 때 의도하지 않은 동작이 발생할 수 있습니다.

문제 코드

package main

import "fmt"

func main() {
// 함정: 모든 클로저가 같은 i 변수를 참조
funcs := make([]func(), 5)
for i := 0; i < 5; i++ {
funcs[i] = func() {
fmt.Println(i) // 루프가 끝난 후의 i 값(5)을 캡처
}
}

for _, fn := range funcs {
fn() // 모두 5를 출력!
}
}

실행 결과 (의도와 다름):

5
5
5
5
5

루프가 끝난 후 i는 5가 됩니다. 클로저들은 i의 값이 아닌 i 변수 자체를 참조하므로, 실행 시점에 모두 5를 읽습니다.

해결책 1: 루프 변수를 로컬 변수로 복사

package main

import "fmt"

func main() {
funcs := make([]func(), 5)
for i := 0; i < 5; i++ {
i := i // 새로운 로컬 변수 i를 선언 — 루프 변수를 가림(shadow)
funcs[i] = func() {
fmt.Println(i) // 로컬 i를 캡처 (각 반복마다 독립적)
}
}

for _, fn := range funcs {
fn()
}
}

실행 결과 (정상):

0
1
2
3
4

해결책 2: 클로저에 인자로 전달

package main

import "fmt"

func main() {
funcs := make([]func(), 5)
for i := 0; i < 5; i++ {
func(n int) {
funcs[n] = func() {
fmt.Println(n) // 파라미터 n은 각 호출마다 독립적
}
}(i)
}

for _, fn := range funcs {
fn()
}
}

해결책 3: Go 1.22+ 루프 변수 개선

Go 1.22부터는 for 루프의 변수가 각 반복마다 새로운 변수로 생성됩니다.

// go.mod의 go 버전이 1.22 이상이면 아래 코드가 정상 동작
package main

import "fmt"

func main() {
funcs := make([]func(), 5)
for i := range 5 { // Go 1.22: range 정수 지원
funcs[i] = func() {
fmt.Println(i) // Go 1.22+에서는 각 반복의 i를 캡처
}
}
for _, fn := range funcs {
fn()
}
}

Go 1.22 이전 버전을 지원해야 한다면 해결책 1이나 2를 사용하세요.

고루틴에서의 루프 클로저 함정

package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup
items := []string{"사과", "바나나", "체리"}

// 잘못된 코드 — 모두 같은 item을 출력할 수 있음
fmt.Println("=== 잘못된 패턴 ===")
for _, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(item) // item이 변경될 수 있음
}()
}
wg.Wait()

// 올바른 코드 — 각 고루틴이 독립적인 값을 캡처
fmt.Println("=== 올바른 패턴 ===")
for _, item := range items {
item := item // 로컬 복사
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(item)
}()
}
wg.Wait()
}

클로저를 이용한 상태 캡슐화

클로저는 구조체와 메서드를 사용하지 않고도 상태를 캡슐화할 수 있습니다. 간단한 경우에는 구조체보다 클로저가 더 간결합니다.

package main

import (
"errors"
"fmt"
)

// 스택 — 클로저로 구현한 데이터 구조
func makeStack() (push func(int), pop func() (int, error), peek func() (int, error), size func() int) {
data := []int{}

push = func(n int) {
data = append(data, n)
}

pop = func() (int, error) {
if len(data) == 0 {
return 0, errors.New("스택이 비어 있습니다")
}
n := data[len(data)-1]
data = data[:len(data)-1]
return n, nil
}

peek = func() (int, error) {
if len(data) == 0 {
return 0, errors.New("스택이 비어 있습니다")
}
return data[len(data)-1], nil
}

size = func() int {
return len(data)
}

return
}

// 레이트 리미터 — 클로저로 상태 관리
func makeRateLimiter(maxPerSecond int) func() bool {
count := 0
// 실제 구현에서는 time.Now()로 초당 리셋
return func() bool {
if count >= maxPerSecond {
return false // 한도 초과
}
count++
return true
}
}

// 설정 빌더 — 클로저로 옵션 축적
func makeConfigBuilder() (set func(key, value string), build func() map[string]string) {
config := map[string]string{}

set = func(key, value string) {
config[key] = value
}

build = func() map[string]string {
// 복사본 반환 — 원본 보호
result := make(map[string]string, len(config))
for k, v := range config {
result[k] = v
}
return result
}

return
}

func main() {
// 스택 사용
push, pop, peek, size := makeStack()

push(1)
push(2)
push(3)
fmt.Println("크기:", size())

if top, err := peek(); err == nil {
fmt.Println("상단:", top)
}

for size() > 0 {
if n, err := pop(); err == nil {
fmt.Println("팝:", n)
}
}

if _, err := pop(); err != nil {
fmt.Println("에러:", err)
}

// 레이트 리미터
limiter := makeRateLimiter(3)
for i := 0; i < 5; i++ {
allowed := limiter()
fmt.Printf("요청 %d: %v\n", i+1, allowed)
}

// 설정 빌더
set, build := makeConfigBuilder()
set("host", "localhost")
set("port", "5432")
set("dbname", "myapp")

config := build()
fmt.Println("설정:", config)
}

실행 결과:

크기: 3
상단: 3
팝: 3
팝: 2
팝: 1
에러: 스택이 비어 있습니다
요청 1: true
요청 2: true
요청 3: true
요청 4: false
요청 5: false
설정: map[dbname:myapp host:localhost port:5432]

실전 예제: 메모이제이션

메모이제이션(memoization) 은 함수의 계산 결과를 캐시해두고 같은 입력이 오면 재계산 없이 캐시 값을 반환하는 최적화 기법입니다. 클로저로 우아하게 구현할 수 있습니다.

package main

import (
"fmt"
"time"
)

// 정수 → 정수 함수를 메모이제이션
func memoize(fn func(int) int) func(int) int {
cache := map[int]int{}
return func(n int) int {
if v, ok := cache[n]; ok {
fmt.Printf(" 캐시 히트: fib(%d) = %d\n", n, v)
return v
}
result := fn(n)
cache[n] = result
return result
}
}

// 피보나치 메모이제이션 (클로저 필요)
func makeMemoFib() func(int) int {
cache := map[int]int{}
var fib func(int) int
fib = func(n int) int {
if n <= 1 {
return n
}
if v, ok := cache[n]; ok {
return v
}
result := fib(n-1) + fib(n-2)
cache[n] = result
return result
}
return fib
}

// 비용이 큰 계산 시뮬레이션
func expensiveCalc(n int) int {
time.Sleep(100 * time.Millisecond) // 실제 계산 시뮬레이션
return n * n
}

func main() {
// 메모이제이션 적용
memoizedCalc := memoize(expensiveCalc)

inputs := []int{5, 3, 5, 3, 7, 5}
for _, n := range inputs {
start := time.Now()
result := memoizedCalc(n)
elapsed := time.Since(start)
fmt.Printf("calc(%d) = %d (소요: %v)\n", n, result, elapsed.Round(time.Millisecond))
}

fmt.Println()

// 피보나치 메모이제이션
fib := makeMemoFib()

start := time.Now()
fmt.Printf("fib(40) = %d (소요: %v)\n", fib(40), time.Since(start).Round(time.Millisecond))

start = time.Now()
fmt.Printf("fib(40) = %d (재계산 소요: %v)\n", fib(40), time.Since(start).Round(time.Millisecond))
}

실행 결과:

calc(5) = 25 (소요: 100ms)
calc(3) = 9 (소요: 100ms)
캐시 히트: fib(5) = 25
calc(5) = 25 (소요: 0s)
캐시 히트: fib(3) = 9
calc(3) = 9 (소요: 0s)
calc(7) = 49 (소요: 100ms)
캐시 히트: fib(5) = 25
calc(5) = 25 (소요: 0s)

fib(40) = 102334155 (소요: 0s)
fib(40) = 102334155 (재계산 소요: 0s)

실전 예제: 미들웨어 체인

클로저를 연쇄적으로 적용하는 미들웨어 체인 패턴입니다.

package main

import (
"fmt"
"strings"
)

// 문자열 처리 파이프라인
type StringProcessor func(string) string

func compose(processors ...StringProcessor) StringProcessor {
return func(s string) string {
for _, p := range processors {
s = p(s)
}
return s
}
}

// 검증 체인 — 실패 시 즉시 중단
type Validator func(string) error

func validateAll(validators ...Validator) Validator {
return func(s string) error {
for _, v := range validators {
if err := v(s); err != nil {
return err
}
}
return nil
}
}

func main() {
// 문자열 처리 파이프라인
process := compose(
strings.TrimSpace,
strings.ToLower,
func(s string) string { return strings.ReplaceAll(s, " ", "-") },
func(s string) string {
if len(s) > 20 {
return s[:20]
}
return s
},
)

inputs := []string{
" Hello World ",
"Go Programming Language",
" Simple ",
}

for _, input := range inputs {
fmt.Printf("%q → %q\n", input, process(input))
}

// 검증 체인
notEmpty := func(s string) error {
if strings.TrimSpace(s) == "" {
return fmt.Errorf("빈 문자열은 허용되지 않습니다")
}
return nil
}
minLen := func(min int) Validator {
return func(s string) error {
if len(s) < min {
return fmt.Errorf("최소 %d자 이상이어야 합니다 (현재: %d자)", min, len(s))
}
return nil
}
}
noSpaces := func(s string) error {
if strings.Contains(s, " ") {
return fmt.Errorf("공백을 포함할 수 없습니다")
}
return nil
}

validateUsername := validateAll(notEmpty, minLen(3), noSpaces)

testCases := []string{"", "ab", "valid_user", "has space"}
for _, tc := range testCases {
if err := validateUsername(tc); err != nil {
fmt.Printf(" %q: 실패 — %v\n", tc, err)
} else {
fmt.Printf(" %q: 통과\n", tc)
}
}
}

실행 결과:

"  Hello World  " → "hello-world"
"Go Programming Language" → "go-programming-langua"
" Simple " → "simple"
"": 실패 — 빈 문자열은 허용되지 않습니다
"ab": 실패 — 최소 3자 이상이어야 합니다 (현재: 2자)
"valid_user": 통과
"has space": 실패 — 공백을 포함할 수 없습니다

고수 팁

클로저와 가비지 컬렉션: 클로저가 큰 슬라이스나 맵을 캡처하면 클로저가 살아있는 한 해당 메모리는 해제되지 않습니다. 대용량 데이터를 처리한 후에는 필요한 값만 추출해 작은 변수에 담고 클로저를 소멸시키세요.

클로저 vs 구조체: 클로저는 간결하지만 외부에서 상태를 직접 검사하거나 수정하기 어렵습니다. 상태가 복잡하거나 테스트가 중요하다면 구조체와 메서드를 사용하세요. 클로저는 짧고 국소적인 상태 캡슐화에 적합합니다.

동시성 주의: 여러 고루틴이 같은 클로저의 캡처된 변수를 접근하면 데이터 경쟁(data race)이 발생합니다. sync.Mutexsync/atomic으로 보호하거나, 채널을 통해 접근을 직렬화하세요.