Skip to content
Published on

Go 1.26의 goroutineleak 프로파일 — GC의 마킹으로 고루틴 누수를 잡는다

공유하기
Authors

들어가며 — 고루틴 누수는 왜 프로파일에 잡히지 않았나

고루틴 누수를 디버깅해 본 사람은 이 좌절을 압니다. 서비스 메모리가 계속 우상향합니다. 고루틴 프로파일을 뜹니다. 고루틴이 4만 개 있습니다. 그중 몇 개가 영원히 안 깨어나는 놈일까요?

프로파일은 답하지 못합니다. goroutine 프로파일은 모든 고루틴의 스택을 덤프할 뿐이고, 거기 찍힌 [chan receive]는 "200ms 뒤에 깨어날 정상적인 워커"와 "아무도 참조하지 않는 채널을 붙들고 영원히 잠든 좀비"를 구분해 주지 않습니다. 둘 다 똑같이 채널 수신에 블록돼 있으니까요. Go 1.26 제안서의 표현이 정확합니다 — 고루틴 누수는 악명 높게 디버깅하기 어렵고, 심지어 메모리·고루틴 프로파일 같은 꽤 철저한 진단 정보를 갖고도 그 존재 자체를 알아채기 어려운 경우가 있습니다.

그래서 실무에서는 uber-go/goleak 같은 도구를 테스트에 붙여 왔습니다. 테스트 전후로 고루틴 스택을 비교해서 남아 있는 놈을 잡는 방식입니다. 잘 작동하지만, 테스트 안에서만 작동합니다. 프로덕션에서 진짜로 새고 있는 고루틴은 여전히 손이 안 닿는 곳에 있었습니다.

Go 1.26이 2026년 2월 10일 릴리스되면서 이 구멍에 도구가 하나 들어왔습니다. runtime/pprof의 새 프로파일 타입, goroutineleak입니다.

Go 1.26이 추가한 것

릴리스 노트의 정의부터 봅시다. 누수된 고루틴이란 "채널, sync.Mutex, sync.Cond 등 어떤 동시성 프리미티브에 블록돼 있는데, 결코 블록이 풀릴 수 없는 고루틴"입니다. 여기서 중요한 건 "결코 풀릴 수 없다"입니다 — 지금 블록돼 있다는 사실이 아니라, 미래의 어떤 실행에서도 깨어날 수 없다는 사실입니다.

런타임이 이걸 어떻게 판정하는지, 릴리스 노트가 직접 설명합니다. 고루틴 G가 동시성 프리미티브 P에 블록돼 있고, P가 어떤 실행 가능한 고루틴으로부터도, 그리고 그 고루틴들이 깨울 수 있는 어떤 고루틴으로부터도 도달 불가능하다면, P는 절대 풀릴 수 없으므로 G는 결코 깨어날 수 없습니다.

Go 1.26에서는 실험 기능이라 빌드 시점에 켜야 합니다.

# 프로파일을 켜서 빌드/실행
GOEXPERIMENT=goroutineleakprofile go run ./cmd/server

# 실험 플래그 없이 빌드하면 pprof.Lookup("goroutineleak")은 nil을 반환합니다

실험을 켜면 net/http/pprof 엔드포인트 /debug/pprof/goroutineleak도 함께 등록됩니다.

릴리스 노트가 이 기능에 붙인 단서 두 개가 인상적입니다. 하나는 구현 자체는 프로덕션 레디이고, 실험으로 분류한 건 순전히 API에 대한 피드백 — 구체적으로는 이걸 새 프로파일 타입으로 만든 선택이 옳은지 — 을 받기 위해서라는 것. 다른 하나는 실제로 사용 중이지 않으면 추가 런타임 오버헤드가 붙지 않도록 설계했다는 것입니다. 즉 켜 두기만 하고 프로파일을 뽑지 않으면 공짜에 가깝다는 뜻입니다.

그리고 Go 1.27 드래프트 릴리스 노트는 이미 이렇게 적고 있습니다 — goroutineleak 프로파일은 이제 일반 사용 가능(generally available)하며, goroutineleakprofile GOEXPERIMENT 설정은 삭제됐다고. Go 1.27은 2026년 8월 릴리스 예정이니, 이 기능은 다음 달이면 그냥 켜져 있게 됩니다. 지금 이해해 둘 값이 있는 이유입니다.

핵심 아이디어 — 도달 가능성을 생존성의 근사로 쓴다

이 기능의 아름다운 부분은 여기입니다. 새 추적 인프라를 만들지 않았습니다. 이미 있는 GC 마킹을 재활용합니다.

논리는 이렇습니다. 어떤 고루틴 G가 채널 ch에 블록돼 있다고 합시다. G를 깨우려면 누군가가 ch에 보내거나 닫아야 합니다. 그러려면 그 누군가는 ch에 대한 참조를 손에 넣을 수 있어야 합니다. 그런데 참조를 손에 넣을 수 있다는 건, 정확히 GC가 말하는 메모리 도달 가능성입니다.

그래서 논문의 표현으로는, 메모리 도달 가능성이 동시성 연산의 생존성을 건전하게 과대근사(soundly over-approximate)합니다. 과대근사라는 말이 핵심입니다 — GC가 "도달 가능"이라고 판정한 집합은 실제로 살아 있는 고루틴을 빠짐없이 포함합니다. 살아 있는데 도달 불가능으로 잘못 분류되는 일은 없습니다.

이게 왜 중요하냐면, 이 방향의 건전성이 거짓 양성이 없다는 보장으로 이어지기 때문입니다. 제안서가 명시합니다 — 이론적으로 건전하며, 즉 거짓 양성이 없습니다. 프로파일이 "이 고루틴은 누수"라고 말하면, 그건 진짜 누수입니다. 추측이 아닙니다.

논문은 이 보장이 왜 필수인지도 설명합니다. 거짓 양성은 아직 사용 중인 메모리를 해제하게 만들어 예기치 않은 크래시를 낳을 수 있기 때문입니다. 이 기법은 GC 안에서 돌기 때문에, 판정을 틀리면 그냥 오탐 리포트가 아니라 메모리 안전성 사고가 됩니다. 건전성은 선택이 아니라 요구사항이었던 셈입니다.

알고리즘 — 마킹을 다시 도는 6단계

공식 제안서가 GC 사이클의 변경을 6단계로 적어 뒀습니다. 요약하면 이렇습니다.

  1. 마크 루트 준비. 평소 GC는 모든 고루틴을 루트로 삼습니다. 누수 탐지 사이클은 실행 가능한(runnable) 고루틴만 루트로 삼습니다. 이 한 줄이 전부의 출발점입니다.
  2. 마킹. 이 루트 집합에서 도달 가능한 메모리를 마킹합니다.
  3. 검사. 마킹이 끝나면, 아직 마크되지 않은 고루틴 중에 2단계에서 마크된 동시성 프리미티브에 블록돼 있는 놈이 있는지 봅니다.
  4. 승격. 그런 고루틴은 "언젠가 실행 가능"(eventually runnable)으로 간주해 루트에 추가하고, 2단계로 돌아가 마킹을 재개합니다.
  5. 고정점과 보고. 이 과정을 반복해 고정점에 도달하면, 끝내 루트가 되지 못한 고루틴을 누수로 보고합니다. 그리고 누수된 고루틴을 루트로 삼아 마킹을 한 번 더 돌아, 평소 GC처럼 도달 가능한 모든 메모리가 마크되게 합니다.
  6. 스위핑은 평소대로 진행합니다.

3~4단계의 반복이 핵심입니다. "A가 B를 깨우고, B가 C를 깨우는" 사슬을 고정점에 도달할 때까지 따라가는 것입니다. 한 번의 마킹으로 끝내면 "지금 블록돼 있지만 곧 깨어날 고루틴"을 누수로 오판할 테니까요. 반복이 바로 거짓 양성을 없애는 장치입니다.

5단계의 마지막 문장도 그냥 지나치면 안 됩니다. 나중에 다시 다루겠지만, 누수된 고루틴의 메모리는 회수되지 않습니다. 보고만 하고, 마크는 그대로 합니다.

구현 쪽 디테일도 하나 짚어 둘 만합니다. 이 접근이 성립하려면 특정 포인터가 조건부로만 추적돼야 합니다. *sudog 객체(블록된 고루틴이 대기 큐에 다는 구조체)는 allgssemtable을 통해 전역적으로 도달 가능해서, 그냥 두면 아직 도달 가능하다고 판정되지 않은 채널까지 GC가 마크해 버립니다. 그래서 런타임에 maybe-traceable 포인터라는 개념을 도입해, 누수 탐지 사이클 동안에는 *sudog의 해당 필드를 추적 불가 상태로 돌려놓습니다. 공짜로 얻어진 기능이 아니라, 런타임 내부를 꽤 조심스럽게 건드려 만든 기능이라는 뜻입니다.

써 보기

전형적인 누수 하나를 만들어 봅시다.

package main

import (
	"os"
	"runtime/pprof"
	"time"
)

func leak() {
	ch := make(chan int) // 버퍼 없는 채널
	go func() {
		<-ch // 아무도 보내지 않는다 -> 영원히 블록
	}()
	// ch는 여기서 스코프를 벗어나고, 어떤 실행 가능한 고루틴도 참조하지 않는다
}

func main() {
	leak()
	time.Sleep(100 * time.Millisecond) // 고루틴이 실제로 블록될 시간을 준다

	f, _ := os.Create("leak.pprof")
	defer f.Close()
	pprof.Lookup("goroutineleak").WriteTo(f, 1) // debug=1: 사람이 읽을 요약
}

GOEXPERIMENT=goroutineleakprofile go run main.go로 돌리면 누수가 잡힙니다. 실험 플래그 없이 돌리면 pprof.Lookup("goroutineleak")nil을 반환하고 그대로 패닉하니 주의하세요.

debug 레벨의 의미는 제안서에 정의돼 있습니다 — debug < 2이면 누수 상태인 고루틴만 필터링하고, debug >= 2이면 모든 고루틴의 전체 스택 덤프를 받습니다. 그리고 debug=0이면 여느 프로파일처럼 gzip된 protobuf가 나오므로 go tool pprof로 열 수 있습니다.

서버라면 HTTP 엔드포인트가 더 편합니다.

# net/http/pprof를 import하면 기본 mux에 등록된다
curl 'localhost:6060/debug/pprof/goroutineleak?debug=1'

# go tool pprof로 바로 붙어도 된다
go tool pprof -top 'http://localhost:6060/debug/pprof/goroutineleak'

debug=2 출력은 익숙한 고루틴 덤프와 거의 같은데, 상태 표시에 꼬리표가 하나 붙습니다.

goroutine 21 [chan receive (leaked)]:
main.leak.func1()
	/path/main.go:13 +0x6c

(leaked). 이 한 단어가 이 기능의 전부입니다. 기존 고루틴 덤프에서는 [chan receive]만 보이고 그게 정상인지 좀비인지 알 수 없었는데, 이제 런타임이 대신 판정해 줍니다.

여기서 중요한 동작 하나 — 프로파일을 뽑는 행위가 그냥 스냅숏 읽기가 아닙니다. 제안서에 따르면 프로파일 추출은 누수 탐지 GC 사이클을 큐에 넣고 완료를 기다린 뒤, 고루틴 프로파일을 뽑아 누수 상태로 필터링합니다. 즉 curl /debug/pprof/goroutineleak 한 번이 특수한 GC 사이클 한 번을 강제합니다. 대시보드에 붙여서 10초마다 긁을 물건이 아니라는 뜻입니다.

무엇을 못 잡나 — 거짓 음성 두 패턴

이제 정직해질 부분입니다. 거짓 양성이 없다는 보장의 반대편에는, 거짓 음성은 얼마든지 있다는 사실이 있습니다. 릴리스 노트도 이걸 숨기지 않습니다 — 영구히 블록된 고루틴을 모든 경우에 탐지하는 것은 불가능하며, 이 접근은 그런 누수의 큰 부류를 탐지한다고 적습니다. "큰 부류"이지 "전부"가 아닙니다.

제안서는 한계를 한 줄로 요약합니다 — 힙 자원이 메모리에 과다 노출될수록, 즉 서로 도달 가능할수록 효과가 떨어집니다. 이게 실제로 어떤 코드를 뜻하는지, 논문이 실제 코드에서 발견한 패턴 두 가지로 보여 줍니다.

첫째, 전역 채널. 채널이 전역 변수에 매달려 있으면 항상 메모리에서 도달 가능합니다. 그래서 그 채널에 블록된 고루틴은 언제나 "언젠가 깨어날 수 있음"으로 분류됩니다 — 실제로는 아무도 그 채널을 다시 쓰지 않더라도요.

var gch = make(chan int) // 전역 -> 항상 도달 가능

func neverDetected() {
	go func() {
		<-gch // 진짜 누수지만, 프로파일에는 안 잡힌다
	}()
}

릴리스 노트도 같은 말을 합니다 — 이 기법이 도달 가능성 위에 세워졌기 때문에, 전역 변수나 실행 가능한 고루틴의 지역 변수를 통해 도달 가능한 동시성 프리미티브에 블록돼서 생긴 누수는 식별하지 못할 수 있습니다.

둘째, 이쪽이 더 고약합니다 — 논문의 표현으로 "폭주하는 살아 있는 고루틴"(runaway live goroutines). 논문이 실제 코드에서 뽑은 예를 옮기면 이렇습니다.

type dispatcher struct {
	ch    chan struct{}
	ticks int
}

func newDispatcher() *dispatcher {
	d := &dispatcher{ch: make(chan struct{})}
	go func() { // 하트비트 고루틴
		for ; ; time.Sleep(time.Second) {
			d.ticks++ // d를 계속 붙잡고 있다
		}
	}()
	return d
}

func main() {
	d := newDispatcher()
	go func() { d.ch <- struct{}{} }() // 이 고루틴이 누수될 수 있다
	if someCondition {
		return // 아무도 d.ch를 받지 않는다
	}
	<-d.ch
}

무슨 일이 벌어지는지 보세요. 하트비트 고루틴은 time.Sleep 루프를 돌기 때문에 항상 실행 가능합니다. 그리고 그놈이 d를 참조하므로, d가 도달 가능해지고, 따라서 d.ch도 도달 가능해집니다. GC 입장에서는 "하트비트 고루틴이 언젠가 d.ch에 보낼 수도 있잖아?"라고 판단할 수밖에 없습니다. 하트비트는 d.ticks만 건드리지 d.ch는 쳐다보지도 않는데도요. 도달 가능성은 필드 단위가 아니라 객체 단위이기 때문입니다.

이 패턴이 고약한 이유는, 하트비트·메트릭 리포터·백그라운드 리프레셔처럼 잘 짜인 서비스에 흔히 있는 구조라서입니다. 그런 고루틴 하나가 구조체를 붙들고 있으면, 같은 구조체에 매달린 채널의 누수는 통째로 투명 인간이 됩니다. 여러분의 코드베이스가 이 프로파일에서 깨끗하게 나왔다면, 그게 누수가 없다는 뜻인지 아니면 하트비트가 다 가려 준 것인지는 따로 생각해 봐야 합니다.

비용 — 오버헤드는 어디에 붙나

숫자는 두 출처를 구분해서 봐야 합니다.

업스트림이 말하는 것. Go 1.26 릴리스 노트는 이 기능이 실제로 사용 중이지 않으면 추가 런타임 오버헤드를 일으키지 않도록 설계됐다고 적습니다. 구체적 수치는 제시하지 않습니다. 제안서 역시 프로토타입을 언급할 뿐 성능 데이터를 담고 있지 않습니다.

논문이 측정한 것(저자 자체 측정, ASPLOS 25). 여기부터는 Saioc 등의 논문Golf라는 도구 — Go 1.22.5의 GC를 확장한 구현 — 로 잰 수치이고, Go 1.26에 들어간 것과 같은 구성이 아닙니다(아래에서 왜 다른지 설명합니다). 그대로 옮기면 이렇습니다.

  • 마이크로벤치마크에서 GC 마킹 단계의 슬로다운 중앙값은 정상 프로그램 기준 0.96배, 즉 오히려 살짝 빨랐습니다. 최소 0.13배(876µs → 112µs)까지 내려갔습니다. 다만 최악의 경우 4.8배(113µs → 538µs)까지 느려졌습니다.
  • 누수가 있는 프로그램에서는 중앙값 0.71배로 더 빨라집니다. 마킹할 메모리가 줄어들기 때문입니다. 최악은 5.87배(136µs → 798µs).
  • 절대 시간으로는, 마킹 단계가 여전히 10ms 안에 끝났습니다. 정상 예제 기준 최대 2ms(베이스라인 619µs 대비 3.27배).
  • 우버의 실제 서비스(170만 줄)를 통제된 환경에서 돌렸을 때, 주된 비용은 GC 일시정지 시간이었습니다. GC 사이클당 평균 일시정지가 베이스라인의 약 2.5배. 다만 10% 누수 시나리오에서도 총 GC 일시정지는 30초 실행 시간의 0.65% 수준에 그쳤습니다.

슬로다운 배수만 보면 겁이 나지만 절대값을 보면 밀리초 단위라는 점, 그리고 배수가 크게 흔들린다는 점을 같이 봐야 합니다. 그리고 이 수치는 전부 저자들이 자기 도구를 자기 벤치마크로 잰 것입니다.

실무적으로 기억할 건 오히려 단순합니다. 프로파일을 뽑는 순간 특수 GC 사이클이 강제로 돕니다. 안 뽑으면 (릴리스 노트의 설계 의도대로라면) 거의 공짜입니다. 그러니 비용 모델은 "켜 두는 비용"이 아니라 "호출하는 비용"입니다.

goleak과의 관계 — 대체가 아니라 보완

uber-go/goleak을 이미 쓰고 있다면 갈아탈 준비를 할 필요는 없습니다. 논문이 직접 비교했는데, 결과가 겸손합니다.

우버 코드베이스의 테스트 스위트에서 goleak은 개별 누수 29,513건(중복 제거 시 357건)을 보고했습니다. 같은 조건에서 Golf는 17,872건(60%)을 탐지했고, 중복 제거하면 180건 — 357건의 50% 수준이었습니다. 논문의 표현 그대로, goleak이 테스트 스위트에서는 설계상 더 효과적입니다.

조금 더 들어가면, 결함 있는 코드가 테스트에서 누수를 드러낼 때 Golf는 goleak이 찾은 것 중 82%를 찾았고(리포트별 비율의 곡선 아래 면적 기준), 중복 제거된 리포트 180건 중 103건(55%)에서는 goleak이 보고한 개별 누수를 전부 찾았습니다. 마이크로벤치마크에서는 전체 탐지율 94.75%로 훨씬 좋았습니다.

숫자가 이렇게 갈리는 이유가 중요합니다. 논문이 짚듯이, Golf의 효능은 GC 사이클이 누수 발생 시점과 테스트 종료 시점에 대해 언제 도는지에 달려 있습니다. 테스트는 짧고, GC는 안 돌 수도 있습니다. 반면 goleak은 GC와 무관하게 테스트 종료 시점의 스택을 직접 비교합니다.

그래서 역할 분담은 자연스럽습니다.

  • 테스트·CI에서는 goleak. 짧고 결정적인 환경에서, 스택 비교가 더 촘촘히 잡습니다.
  • 프로덕션과 그 언저리에서는 goroutineleak 프로파일. goleak이 갈 수 없는 곳이고, 거짓 양성이 없다는 보장이 온콜 상황에서 값을 합니다. 새벽 3시에 "이거 진짜 누수 맞아?"를 다시 검증할 필요가 없다는 건 생각보다 큽니다.

릴리스 노트도 테스트, CI, 프로덕션 모두에서 시험해 보길 권하고 있으니, 둘 중 하나를 고르라는 프레이밍 자체가 잘못된 질문입니다.

논문과 업스트림의 차이 — 회수는 빠졌다

이건 릴리스 노트만 읽으면 놓치기 쉬운 부분이라 따로 적습니다.

논문 제목은 "Dynamic Partial Deadlock Detection and Recovery via Garbage Collection"입니다. 탐지만이 아니라 회수가 절반입니다. 논문의 Golf는 교착된 고루틴을 stop-the-world 조건에서 보고하고 강제 종료하며, 그 결과 10% 누수 시나리오에서 서버 메모리 사용량이 약 49배 낮아지고, 처리량이 9% 높아지고, 꼬리 지연이 약 1.5배 줄었다고 보고합니다(역시 저자 자체 측정).

그런데 Go 1.26에 들어간 것은 그 절반입니다. 제안서의 5단계를 다시 보세요 — 누수를 보고한 뒤, 누수된 고루틴을 루트로 삼아 마킹을 한 번 더 돌아 평소 GC처럼 도달 가능한 모든 메모리가 마크되게 합니다. 즉 업스트림 구현은 누수된 고루틴이 붙들고 있는 메모리를 일부러 살려 둡니다. 릴리스 노트도 프로파일이 누수를 보고한다고만 말하지, 회수하거나 고루틴을 죽인다는 말은 하지 않습니다.

이건 타협이 아니라 합리적인 설계 결정으로 읽힙니다. 언어 런타임이 사용자 고루틴을 마음대로 죽이고 메모리를 회수하는 건, 진단 도구가 넘어서는 안 될 선입니다. 건전성 증명이 아무리 탄탄해도 프로덕션 런타임이 그 판단으로 코드를 강제 종료하는 것과, 리포트를 하나 찍어 주는 것은 위험의 무게가 다릅니다.

실무적 함의는 분명합니다. 이 프로파일은 누수를 고쳐 주지 않습니다. 메모리도 돌려주지 않습니다. 누수가 어디 있는지 스택 트레이스로 알려 줄 뿐이고, 고치는 건 여전히 여러분 몫입니다. 논문의 인상적인 49배 메모리 절감 같은 숫자를 Go 1.26에 기대하면 안 됩니다 — 그건 회수를 하는 도구의 숫자입니다.

언제 쓰고, 언제 쓰지 않나

값을 하는 경우

  • 장기 실행 서비스에서 메모리가 우상향하는데 원인이 안 잡힐 때. 특히 고루틴 수가 같이 늘어난다면 첫 번째로 뽑아 볼 프로파일입니다.
  • 온콜 중 "이 고루틴들이 진짜 좀비인가"를 확답 없이 추측하고 있을 때. 거짓 양성이 없다는 보장이 정확히 이 순간에 값을 합니다.
  • goleak을 이미 CI에 붙였지만, 프로덕션에서만 나타나는 비결정적 누수를 쫓고 있을 때.

과잉이거나 부적절한 경우

  • 짧고 결정적인 단위 테스트. GC가 돌 보장이 없어서 탐지가 들쭉날쭉합니다 — goleak이 맞는 도구입니다.
  • 상시 모니터링 대시보드. 프로파일을 뽑을 때마다 GC 사이클이 강제됩니다. 인시던트 중 몇 번 뽑는 도구지, 주기적으로 긁는 메트릭이 아닙니다.
  • 코드베이스가 전역 채널과 하트비트 고루틴으로 촘촘히 얽혀 있을 때. 거짓 음성이 지배적이라 "깨끗함"이라는 결과에서 아무 정보도 얻지 못할 수 있습니다.
  • 이 프로파일을 통과했으니 누수가 없다고 결론짓고 싶을 때. 그런 결론은 이 도구가 지지하지 않습니다.

마치며

정리하면 이렇습니다. Go 1.26의 goroutineleak 프로파일은 GC의 마킹 단계를 재활용해 고루틴 누수를 탐지합니다. 실행 가능한 고루틴만 루트로 삼아 마킹하고, 마크된 프리미티브에 블록된 고루틴을 루트로 승격시키기를 고정점까지 반복한 뒤, 끝내 남은 고루틴을 누수로 보고합니다. 메모리 도달 가능성이 생존성의 건전한 과대근사라는 통찰 덕분에 거짓 양성이 없고, 바로 그 도달 가능성 기반이라는 점 때문에 전역 변수와 폭주하는 살아 있는 고루틴 앞에서는 거짓 음성이 납니다.

개인적으로 이 기능이 좋은 이유는 성능 숫자 때문이 아닙니다. 이미 있는 메커니즘에서 공짜에 가까운 정보를 뽑아냈다는 점 때문입니다. GC는 원래 "누가 무엇에 도달할 수 있는가"를 매 사이클 계산하고 있었습니다. 그 계산이 사실은 "누가 누구를 깨울 수 있는가"에 대한 답이기도 하다는 걸 알아본 것이, 이 연구의 전부입니다. 그리고 그 통찰이 박사 연구에서 Go 런타임까지 실제로 도착했습니다 — ASPLOS 25 논문이 1년도 안 돼 표준 라이브러리에 들어간 셈입니다.

Go 1.27이 2026년 8월에 나오면 플래그 없이 그냥 쓸 수 있게 됩니다. 그전에 지금 GOEXPERIMENT=goroutineleakprofile로 여러분 서비스에 한 번 뽑아 보는 걸 권합니다. 뭔가 나오면 진짜입니다. 아무것도 안 나오면 — 그건 하트비트 고루틴을 한번 세어 볼 이유입니다.

참고 자료