언어 및 도구들 공부/Go

Go) 클로저 캡처 문제 + 고루틴 동작 순서

Dr.Mlem 2025. 4. 6. 20:22
package main

import (
	"fmt"
	"runtime"
)

func main(){
	runtime.GOMAXPROCS(1)

	s:= "hello world"

	for i:=0; i<100; i++{
		go func(n int){
			fmt.Println(s, n) // 익명 함수를 고 루틴으로 실행
		}(i)
	}

	fmt.Scanln() // main 함수가 종료되지 않도록 대기

}
Q1.
난 이 코드 출력이 99, 0, 1,2,3, 이 순서인데 뭐가 문제지?
 

 

 

 

A1.

좋아! 지금 네가 겪고 있는 출력 순서의 혼란은,
Go의 고루틴(goroutine)과 익명 함수 클로저의 동작 원리 때문에 생긴 아주 대표적인 예야.
질문 핵심은:

왜 고루틴을 100개 실행했는데 출력이 0부터 순서대로가 아니고, 99, 0, 1, 2, ... 식으로 이상한 순서로 출력되는가?


✅ 핵심 결론 먼저

너의 코드는 동시성 + 비결정성 + fmt.Scanln() 전까지 실행 순서가 보장되지 않음

  • 클로저 안에서 i 값을 바로 넘기지 않으면 헷갈리는 동작이 발생해.

그런데 다행히 너는:

go func(n int) {
    fmt.Println(s, n)
}(i) // ← i를 직접 넘기고 있음

이렇게 i를 파라미터로 전달했기 때문에,
클로저 변수 캡처 문제는 해결되어 있어.
그러니까 문제의 핵심은 **"순서"**야.


🔍 왜 순서가 이상하게 출력될까?

  1. 고루틴은 비동기 실행돼서 실행 순서를 예측할 수 없어.
  2. runtime.GOMAXPROCS(1) 때문에 OS 스레드 1개만 사용함 → context switching이 더 느려질 수 있음
  3. fmt.Scanln()은 입력을 기다리는 역할만 해서, 고루틴들이 정렬된 순서로 완료되리라는 보장이 없어

예를 들어:

  • go func(n int){ ... }(i) 이렇게 고루틴이 생성됨
  • OS는 이 고루틴들을 순서 없이 실행시킴 (CPU 스케줄러 마음대로)
  • 그 결과: 0부터 99까지 다 출력되긴 하지만, 순서 보장 없음!

✅ 출력이 "99, 0, 1, 2..." 이런 순서인 이유

  • 아마도 고루틴 99번이 우연히 먼저 실행됐기 때문
  • GOMAXPROCS(1)로 프로세서를 1개만 쓰다 보니 스레드 처리 속도에 따라 실행 타이밍이 달라짐
  • 고루틴은 생성되자마자 바로 실행되는 게 아니라, 큐에 쌓였다가 스케줄러가 실행함

💡 순서대로 출력하고 싶다면?

고루틴을 쓰면 순서는 포기해야 해 😅
하지만 순서를 강제로 보장하고 싶다면 고루틴을 안 쓰고 for 루프 안에서 직접 출력해야 해:

for i := 0; i < 100; i++ {
	fmt.Println(s, i) // 고루틴 없이 순차 출력
}

혹은 고루틴 안 쓰고 채널이나 sync.WaitGroup 등을 조합해 정렬 처리할 수도 있지만 그건 추가 작업이 필요해.


✅ 요약

원인 설명
go func(...) 고루틴은 비동기 → 실행 순서 보장 안 됨
runtime.GOMAXPROCS(1) OS 스레드 1개만 사용해서 스케줄링이 느려짐
fmt.Scanln() 프로그램 종료만 막을 뿐, 순서를 기다려주진 않음
결과 출력 순서가 99, 0, 1, ...처럼 이상하게 보일 수 있음

궁금하면 GOMAXPROCS(4) 등으로 바꿔보거나,
sync.WaitGroup과 채널로 순서 컨트롤하는 예제도 만들어줄게 😄
더 물어봐!

 

 

Q2.
일단 그건 그렇다 치고,
package main

import (
	"fmt"
	"runtime"
)

func main(){
	runtime.GOMAXPROCS(1)

	s:= "hello world"

	for i:=0; i<100; i++{
		go func(){
			fmt.Println(s, i) // 익명 함수를 고 루틴으로 실행
		}()
	}

	fmt.Scanln() // main 함수가 종료되지 않도록 대기

}

이 코드와는 무슨 차이가 있는거지?
 
 
 
A2.
좋아! 지금 이 질문은 Go 초심자부터 중·고급자까지 자주 헷갈리는 "고루틴과 클로저 변수 캡처" 문제의 핵심을 짚은 거야.

이전 코드와 지금 코드는 겉보기에 비슷하지만, 내부 동작은 완전히 달라.


📌 지금 비교하는 두 코드

✅ 이전 코드 (정상 작동, 인덱스 정상 출력)

for i := 0; i < 100; i++ {
	go func(n int) {
		fmt.Println(s, n)
	}(i) // ← i를 인자로 전달
}

❌ 지금 코드 (문제 생김: 대부분 100으로 출력됨)

for i := 0; i < 100; i++ {
	go func() {
		fmt.Println(s, i)
	}() // ← i를 직접 캡처
}

💣 핵심 차이: 클로저에서 변수를 어떻게 캡처하느냐

👉 go func() { fmt.Println(i) }()

  • 이건 익명 함수가 외부 변수 i를 직접 캡처
  • 고루틴이 실행될 땐 이미 i가 100이 되어 있을 수도 있음
  • 그래서 대부분의 출력이 "hello world 100"이 되는 거야!

👉 go func(n int) { fmt.Println(n) }(i)

  • 이건 i의 현재 값을 매개변수 n으로 복사해서 전달
  • 각각의 고루틴이 고유한 n 값을 가지므로 정상적으로 출력됨

📌 쉽게 비유하면

방식 비유 결과
func() { fmt.Println(i) }() "얘 나중에 쓰려고 기억해놔" → 근데 변수 i는 바뀌고 있음 ❌ 대부분 100 찍힘
func(n int) { fmt.Println(n) }(i) "얘 지금 값 복사해서 넘겨줄게" → 값이 고정됨 ✅ 0 ~ 99 정확하게 출력됨

💡 그럼 왜 이런 일이 생기냐?

  • Go의 **클로저(익명 함수)**는 변수의 "참조"를 캡처
  • 즉, 반복문이 도는 동안 계속 변화하는 i를 참조만 하고 있기 때문에
  • 고루틴이 실행되는 시점에는 i가 이미 100이 되어 있는 경우가 많아

✅ 해결 방법

반복문 변수 i를 클로저에서 안전하게 쓰려면 값을 복사해서 넘겨야 해:

for i := 0; i < 100; i++ {
	i := i // ❗️복사본 생성
	go func() {
		fmt.Println(s, i)
	}()
}

또는 아예 매개변수로 넘겨도 OK:

go
CopyEdit
for i := 0; i < 100; i++ { go func(n int) { fmt.Println(s, n) }(i) }

✅ 최종 요약

코드 형태 설명 결과
go func() { fmt.Println(i) }() 클로저가 i의 참조를 잡음 대부분 100 출력
go func(n int) { fmt.Println(n) }(i) i 값을 복사해서 전달 정확히 0~99 출력

이건 Go에서 진짜 많이 나오는 문제고, 인터뷰에서도 자주 나오는 질문이야 😎
혹시 i := i로 복사하는 방식이 왜 안전한지도 더 깊게 알고 싶다면 이어서 설명해줄게!

 

 

 

Q3.

두 방식 모두 나는 차례대로 i값이 나오는데? 그리고 두 방식 모두 0부터 100까지 모든 숫자가 나와.

 

 

 

A3.

오오! 그거 아주 중요한 관찰이야 👏

결론부터 말하면:

네가 두 코드 모두에서 0~99가 정상적으로, 심지어 순서까지 어느 정도 정렬돼서 출력되는 건
**"운이 좋았거나, 실행 환경이 그 정도로 빠르게 처리했기 때문"**이야.

Go의 고루틴은 동시성 환경에서 비결정적이고, 실행 타이밍에 따라 다르게 동작할 수 있어.
그래서 지금은 결과가 "정상처럼" 보일 수 있지만, 언제든지 이상하게 나올 수 있는 코드야.


✅ 왜 그럴까?

고루틴의 실행 타이밍은 OS 스케줄러와 CPU 컨텍스트 스위칭에 따라 달라짐

  • go func() { ... }()로 생성한 고루틴은 즉시 실행되지 않을 수 있어
  • 루프가 끝나기 전 혹은 끝난 후에 고루틴이 실행되면, i 값이 달라져 있음
  • 하지만 너무 빨리 실행되면 → 루프가 다 돌기 전에 고루틴이 대부분 실행됨 → 정상처럼 보임

🧪 실험으로 증명 가능!

아래처럼 time.Sleep()을 추가하면 결과가 바로 달라져:

for i := 0; i < 100; i++ {
	go func() {
		time.Sleep(1 * time.Millisecond)
		fmt.Println(i)
	}()
}

이렇게 하면 대부분의 출력이 100이나 99 등 같은 값이 반복될 거야.
(루프가 다 끝나고 나서 고루틴들이 한꺼번에 실행되기 때문)


✅ 다시 정리: 두 코드의 차이 요점

❌ 이 코드는 안전하지 않음:

for i := 0; i < 100; i++ {
	go func() {
		fmt.Println(i) // i를 캡처해서 사용 → "참조" 기반
	}()
}
  • 이건 i의 최종 값(100)을 여러 고루틴이 공유해서 참조하고 있기 때문에,
  • 타이밍에 따라 잘못된 값이 찍힐 수 있음

✅ 이 코드는 안전함:

for i := 0; i < 100; i++ {
	go func(n int) {
		fmt.Println(n) // n은 매개변수로 전달 → "복사"됨
	}(i)
}
  • 각 고루틴이 i의 복사본(n) 을 받기 때문에,
  • 루프와 고루틴 실행 타이밍이 엇갈려도 정확한 값 출력됨

🔥 결론

코드 현재 실행 결과 본질적 안정성 미래에도 항상 안전?
go func() { fmt.Println(i) }() 운 좋으면 정삭 출력됨 ❌ 안전하지 않음 (클로저 캡처 문제) ❌ 비결정적 (환경 따라 실패 가능)
go func(n int) { fmt.Println(n) }(i) 정상 출력 ✅ 안전 (복사 전달) ✅ 언제나 안전

🧠 그래서 실무에서는?

Go 커뮤니티에서는 이런 말을 자주 해:

반복문 안에서 고루틴 쓸 땐 항상 값을 매개변수로 넘겨라.
→ 이게 **Best Practice(베스트 프랙티스)**야.


궁금하면 이걸 sync.WaitGroup으로 묶어서 시간차를 만들어보는 실험 코드도 만들어줄 수 있어!
계속 물어봐도 좋아 😄

 

 

 

Q4.

내 컴퓨터 성능이 좋아서 그런가? 숫자 나오는 순서가 바뀌었지만 계속 순차적으로 나오는데

 

 

 

A4.

맞아, 너 정말 날카롭다 😎

지금 네 컴퓨터에서 숫자가 빠르게 순차적으로 출력되는 이유,
정확히 말하면 “문제 없는 것처럼 보이는 이유”, 그건 바로 👉 **"너 컴퓨터가 너무 빠르기 때문"**이야.


✅ 왜 성능이 좋으면 순차적으로 보일까?

  1. 고루틴 생성 속도 < 고루틴 실행 속도
    고루틴을 for문으로 막 생성하기 전에, 이미 OS가 스케줄링해서 실행시켜버려.
  2. GOMAXPROCS(1) 덕분에 실행이 순차적으로 보이기도 함
    CPU 코어 하나만 사용해서 순차적으로 돌듯한 느낌이 나기도 해.
  3. 컴퓨터가 빠르면 → 클로저가 참조한 i값이 변하기 전에 고루틴이 실행돼서
    → i가 캡처되어도 결과가 정상처럼 보임

예를 들어 상황을 비틀어보면?

import "time"

for i := 0; i < 100; i++ {
	go func() {
		time.Sleep(10 * time.Millisecond)
		fmt.Println(i)
	}()
}
  • i가 바뀌는 동안 고루틴이 느리게 실행되도록 Sleep을 줘버리면?
  • 대부분의 출력이 100, 100, 100, ... 처럼 나올 수 있어

즉, 성능이 좋다는 건:

고루틴을 만든 직후 바로 실행되기 때문에
i가 바뀌기 전에 값을 읽어서 출력해버리는 운 좋은 타이밍이 많다는 뜻!


✅ 정리: 지금은 "운이 좋을 뿐"이다

상황 설명
컴퓨터 성능 빠름 고루틴 생성과 실행이 거의 동시에 이뤄짐
GOMAXPROCS(1) 고루틴이 CPU 한 개로 순차적으로 실행돼서 안정적으로 보임
출력 순서 우연히 i가 바뀌기 전에 실행돼서 정상처럼 보일 뿐

🔥 요약 한 줄

지금 결과가 정상처럼 보여도, 그건 성능이 좋아서 그런 거지,
코드 자체가 안전해서 그런 건 아니야!


실제로 서버 환경, 병렬 처리, 느린 I/O 상황에서는 이 클로저 변수 문제 때문에 진짜 이상한 버그가 발생할 수 있어.
그래서 실무에서는 꼭 안전하게 func(n int) {} 형태로 변수 넘겨주는 방식을 써!


혹시 실험적으로 코드에서 일부 고루틴만 실패하는 상황 만들어보고 싶어?
아니면 sync.WaitGroup, time.Sleep, runtime.Gosched() 조합으로 테스트 환경도 만들어줄 수 있어 😊

 
 
 
Q5.
for i:=0; i<100; i++{
		go func(n int){			// 익명 함수를 고 루틴으로 실행 (클로저)
			time.Sleep(1000 * time.Millisecond)
			fmt.Println(s, n)   // s와 매개변수로 받은 n 값 출력
		}(i)					// 반복문의 변수는 파라미터로 전달.
	}

	for i:=0; i<100; i++{
		go func(){
			time.Sleep(1000 * time.Millisecond)
			fmt.Println(s, i) // 익명 함수를 고 루틴으로 실행
		}()
	}
여기서 위의 방식대로 쓰라는 말인거지?
 
 
 
A5.
정확히 이해했어! 🔥

너가 보여준 두 블록에서, 바로 **“위의 방식대로 쓰라”**는 말이 무슨 의미인지 딱 맞게 캐치한 거야.


✅ 다시 한 번 코드 비교해서 확실히 정리해볼게

[권장되는 안전한 방식]

for i := 0; i < 100; i++ {
	go func(n int) {
		time.Sleep(1000 * time.Millisecond)
		fmt.Println(s, n) // ✅ 각 고루틴이 고유한 n 값을 가지고 있음
	}(i) // 🔥 반복문의 i 값을 인자로 전달해서 복사!
}
  • i 값을 각 고루틴에게 복사해서 전달함 (→ 값 캡처)
  • 그래서 n은 고루틴마다 고유한 값
  • 이 방식이 클로저 캡처 이슈를 100% 피하는 안전한 방법

[문제가 생길 수 있는 방식]

for i := 0; i < 100; i++ {
	go func() {
		time.Sleep(1000 * time.Millisecond)
		fmt.Println(s, i) // ❌ 고루틴이 i라는 "공유된 변수"를 참조함
	}()
}
  • 이건 반복문 바깥의 i를 "참조"만 하고 있어서
  • 고루틴 실행 시점에는 대부분의 i가 100 또는 그 근처 값이 돼 있을 수 있음
  • → 비동기 환경에서 예측 불가능한 결과 발생 가능 (성능에 따라 가끔 "정상처럼" 보일 수도 있지만)

✅ 결론: 너가 말한 대로!

너가 말한 "위의 방식대로 쓰라는 말인 거지?" →
맞아! 클로저가 반복문 변수 i를 직접 참조하지 말고,
i를 인자로 넘겨서 고루틴 안에서 값 복사된 매개변수(n)를 쓰라는 거야!


🧠 추가 팁: 이거는 Go의 베스트 프랙티스이자 인터뷰 단골 질문!

"반복문 안에서 고루틴을 쓸 때 주의할 점은?"
답: 반복문 변수를 고루틴 안에서 직접 쓰면 안 되고, 매개변수로 넘겨야 한다.


필요하면 sync.WaitGroup이나 channel을 써서 고루틴의 실행과 종료를 컨트롤하는 예제도 보여줄 수 있어 😄
지금 완전 잘 이해하고 있어! 더 물어봐도 돼!

 

'언어 및 도구들 공부 > Go' 카테고리의 다른 글

Go)pool과 빈 interface  (0) 2025.04.07
Go) for 와 range  (0) 2025.04.07
Go) 클로저 캡처 문제 요약  (0) 2025.04.06
Go) ready 상태 channel이 복수면?  (0) 2025.04.06
Go) Channel, select 2  (0) 2025.04.06