Go 요청 코드를 읽을 때는 값의 전달 방식, 취소의 전파, 작업의 완료 확인, 자원의 정리를 서로 다른 수명으로 나눕니다. context.Context가 호출을 취소할 수 있다는 사실은 함수가 실제로 취소를 관찰한다는 뜻이 아니며, Wait가 반환된다는 사실은 외부 효과가 되돌아갔다는 뜻도 아닙니다. 이 네 경계를 따라가면 goroutine 누수와 너무 이른 정리를 구별할 수 있습니다.
Context 의존성과 명시적 함수 인자
DB 핸들·필수 설정·사용자 정보·큰 요청 본문을 모두 context.Context에 넣으면 함수 서명은 짧아지지만, 호출자는 무엇이 필수인지 서명만 보고 알기 어렵습니다. 테스트나 배치 진입점에서 값을 넣지 않으면 컴파일 단계에서 누락을 드러내기 어렵고, 해당 값에 접근할 때 실행 중에야 발견할 수 있습니다. context.Context는 취소·deadline과 요청 범위 메타데이터를 전달하는 통로이지 모든 의존성을 숨기는 저장소가 아닙니다.
저장소·필수 설정은 명시적인 인자나 객체 의존성으로 둡니다. 요청 ID·검증된 주체 같은 문맥 값은 충돌을 피하는 비공개 키 타입과 안전한 accessor로 다루고 누락·타입 오류를 명시합니다. 문맥에 가변 포인터를 넣었다고 요청별 복사나 동기화가 생기지는 않습니다.
취소 전달과 취소 관찰
하위 함수에 ctx를 넘겨도 그 함수가 I/O·채널·반복 경계에서 취소를 관찰해야 종료됩니다. 편의상 context.Background로 바꾸면 상위 deadline을 잃을 수 있습니다. 요청 뒤에도 반드시 지속할 작업은 임의 goroutine을 남기기보다 별도 내구 작업과 소유자·기한으로 분리합니다.
context를 장수 객체에 저장하면 그 안의 큰 값·토큰·사용자 정보 수명이 늘어날 수 있습니다. 원문 자격과 큰 payload를 불필요하게 넣지 않습니다. 자식 context를 만든 소유자는 적절한 시점에 cancel을 호출해 관련 자원을 정리합니다.
Errgroup 오류 전파와 함수 종료
errgroup.WithContext로 그룹을 만들면 등록된 함수 중 첫 non-nil 오류가 파생 context의 취소를 시작하고, Wait는 등록된 모든 함수가 반환한 뒤 그 오류를 돌려줍니다. 예를 들어 A가 먼저 오류를 반환해도 B가 I/O나 채널 대기에서 ctx를 관찰할 때까지 B의 함수는 계속 실행될 수 있으므로, 오류가 다른 함수를 강제로 중단한다고 보면 안 됩니다.
그룹이 성공해도 파생 context는 Wait가 반환될 때 취소되므로, 그 context를 그룹 뒤에 계속할 후속 작업의 장기 context로 재사용하지 않습니다.
| 책임 | errgroup이 제공하는 부분 | 남는 설계 |
|---|---|---|
| 완료 집계 | Wait로 함수 반환 확인 | 취소 무시 작업의 기한 |
| 오류 | 첫 non-nil 오류 반환 | 필수·선택 결과 정책 |
| 동시 실행 | SetLimit 등의 활성 수 제한 | 입력 목록·제출 대기자 상한 |
| 결과 | 함수 실행 | 공유 map·slice의 동기화 |
추천 API는 선택적이고 계정 API는 필수라면 추천 실패를 명시적인 부분 결과로 기록하고 필수 실패만 그룹 오류로 처리할 수 있습니다. 모든 오류를 빈 성공값으로 숨기면 장애를 정상 데이터 부재로 오인하므로 결과 상태를 구분합니다.
SetLimit 제출 대기자와 실행 상한
SetLimit에 도달하면 Group.Go를 호출한 쪽 자체가 활성 자리가 생길 때까지 기다릴 수 있습니다. 따라서 Group.Go를 호출하는 제출자 goroutine을 계속 늘리거나, 한 제출자가 모든 입력을 미리 메모리에 만들면, 실행 중인 작업 수만 제한될 뿐 제출 대기자 수와 입력 데이터 메모리에는 별도 상한이 생기지 않습니다. 제출을 취소할 수 있게 하거나 bounded 입력, TryGo의 거절 결과, 별도 worker pool 중 실제 계약에 맞는 구조를 선택해야 합니다.
활성 작업이 자기 그룹에 자식 작업을 추가한 뒤 그 자식이 끝나기를 기다리면, SetLimit의 자리를 부모 작업이 차지해 자식이 시작하지 못하는 교착이 한도에 따라 생길 수 있습니다. 실행 중에 limit을 바꿀 수 있는지는 사용 중인 라이브러리 계약을 확인합니다.
결과 배열은 미리 크기를 정해 worker마다 독립 인덱스를 맡길 수 있지만, 동시에 append하면 slice 헤더와 기반 저장소를 함께 갱신해 경쟁합니다. 루프 변수 캡처는 Go 버전에 따라 :=로 새 변수를 선언했는지 기존 변수에 대입했는지를 구분해 확인합니다.
그림을 글로 읽기
- 제한된 입력·제출 대기
- errgroup 활성 작업: ctx를 실제 대기에 전달
- Wait로 실제 반환 확인
- 자원 정리·결과 확정
- 제한된 입력·제출 대기 → errgroup 활성 작업: 상한 안 수락
- errgroup 활성 작업 → Wait로 실제 반환 확인: 성공·실패·취소
- Wait로 실제 반환 확인 → 자원 정리·결과 확정: 남은 책임 종료
Defer 실행 범위와 자원 수명
긴 함수의 루프에서 파일을 열 때마다 defer Close를 등록하면 함수 반환 전까지 열린 파일이 쌓일 수 있습니다. 반복별 짧은 helper 함수에서 open·defer·처리를 묶거나 필요한 시점에 명시적으로 닫습니다. open 오류를 먼저 검사하고 유효한 자원에만 정리를 등록합니다.
func processOne(path string) (err error) {
f, err := os.Open(path)
if err != nil { return err }
defer func() {
if closeErr := f.Close(); err == nil {
err = closeErr
}
}()
return consume(f)
}
os와 consume의 실제 구현은 호출 환경에서 제공합니다. 이 예제는 주 오류가 있으면 그것을 유지하고, 주 오류가 없을 때 close 오류를 반환하는 정책입니다. 두 오류를 모두 보고해야 하면 지원 버전의 errors.Join 등 명시적 결합을 사용합니다. 저장 작업의 close·flush·내구 성공 의미는 별도 계약입니다.
defer의 함수 값과 인자는 등록 시 평가되지만 closure가 나중에 읽는 변수는 다를 수 있습니다. 여러 defer는 역순으로 실행됩니다. 정상 반환과 같은 goroutine의 panic 전파에는 정리가 일어날 수 있지만 os.Exit·프로세스 강제 종료에서 같은 보장을 기대하지 않습니다. defer로 잠금을 풀더라도 함수가 너무 길면 임계 구역도 길어집니다.
재현 결과는 goroutine 수만으로 판정하지 않습니다. 첫 오류 시점, 각 자식이 ctx를 관찰하고 반환한 시점, Wait 반환 시점, 열린 파일·소켓·lock이 정리된 시점을 순서대로 기록해야 합니다. Wait가 끝났는데 외부 작업이 계속되면 그 작업은 그룹의 완료 계약 밖에 있는 것입니다.
취소·누락값·정리 오류 주입
값 없는 context로 helper를 호출해 명확한 오류가 나오는지, 첫 자식 오류가 다른 자식에게 전달되는지, 취소를 무시한 자식 때문에 Wait가 기다리는지 확인합니다. 제출 상한에서 부모가 취소되는 상황도 포함합니다.
루프의 많은 파일·중간 반환·읽기 오류·close 오류를 넣고 최대 열린 자원이 제한되는지 측정합니다. 이 노트는 Go 표준 기능과 errgroup의 수명 설계이며 실제 외부 API·파일 장애·선택한 errgroup 버전의 통합 실행 결과는 아닙니다.