Post

Go 언어의 동시성 제어: 컨텍스트(Context)

Go의 context 패키지를 사용하여 요청 범위의 데이터를 전달하고, 타임아웃과 취소 신호를 전파하는 방법을 학습함.

Go 언어의 동시성 제어: 컨텍스트(Context)

Go 언어 컨텍스트 (Context)

Go 언어에서 context 패키지는 API 경계를 넘나드는 요청의 생명주기를 관리하기 위한 표준적인 방법을 제공함. 주요 기능은 취소 신호 전파, 타임아웃 및 데드라인 설정, 요청 범위 값 전달임. 특히 여러 고루틴에 걸쳐 작업이 수행되는 서버 환경에서 클라이언트의 연결 종료나 타임아웃을 모든 하위 작업에 전파하여 시스템 리소스를 효율적으로 관리할 수 있게 함.

Go Context 쉽게 이해하기

Go의 context작업을 시작할 때 생기는 “신호기”로 이해할 수 있다.

  • 여러 고루틴이 동시에 일할 때, 모두에게 작업을 중단하라고 알려주는 역할
    • 택배 기사가 여러 하청 업체(고루틴)에 물건 배송을 맡김
    • 고객이 주문을 취소하면, 기사(컨텍스트)는 모든 하청 업체들에게 “그만 배송해!”라고 신호를 보냄
    • 각 업체는 그 신호를 받으면, 일을 중단하고 리소스를 낭비하지 않게 됨
  • 요청 전체에 필요한 데이터 전달 역할
    • 또한 기사(컨텍스트)는 배송서류(Request ID, 고객 정보)를 함께 들고 다니므로, 하청 업체도 동일한 데이터를 공유할 수 있음.

비유로 이해하기

Java의 동시성 제어 vs Go의 Context

Java에서는 Go의 Context와 정확히 일치하는 단일 기능은 없음. 대신 여러 기능을 조합하여 비슷한 목적을 달성함.

구분Go (context 패키지)Java설명
취소context.WithCancelThread.interrupt(), Future.cancel()작업 중단 신호를 보내지만, Go의 컨텍스트는 여러 고루틴에 걸쳐 신호를 전파하는 데 더 특화되어 있음.
타임아웃context.WithTimeoutFuture.get(timeout, unit)특정 시간 동안 결과를 기다리거나 작업을 제한하는 기능.
값 전달context.WithValueThreadLocal스레드(고루틴) 범위 내에서 안전하게 값을 전달하는 메커니즘.

Java의 ThreadLocal이 단일 스레드 내에서 값을 공유하는 데 초점을 맞춘다면, Go의 context.WithValue는 요청 처리와 같이 시작점(예: HTTP 핸들러)에서 여러 하위 고루틴으로 이어지는 작업 전체에 걸쳐 값을 전달하는 데 사용됨.

이번 시간에는 외부 API를 호출하는 서버를 가정하고, 클라이언트 요청이 타임아웃될 경우 모든 하위 작업을 안전하게 종료시키는 예제를 통해 컨텍스트의 활용법을 익혀보겠음.


context.WithValue: 요청 범위 값 전달

context.WithValue는 부모 컨텍스트에 키-값 쌍을 저장한 새로운 컨텍스트를 반환함. 이렇게 저장된 값은 해당 컨텍스트를 전달받는 모든 함수에서 꺼내 쓸 수 있음. 주로 요청 ID, 인증 토큰 등과 같이 요청 전체에 걸쳐 필요한 데이터를 전달하는 데 사용됨.

실습 1: 컨텍스트로 값 전달하기

HTTP 요청이 들어왔을 때 생성된 “Request ID”를 명시적인 파라미터 전달 없이 여러 함수에 걸쳐 공유하는 예제임.

API파라미터리턴값설명
context.Background()없음context.Context비어있는 최상위 컨텍스트를 반환함. 보통 main 함수나 요청의 시작점에서 사용됨.
context.WithValue(parent, key, val)parent context.Context, key, val interface{}context.Context부모 컨텍스트에 키-값 쌍을 저장한 새로운 자식 컨텍스트를 반환함.
(ctx Context).Value(key interface{})interface{}interface{}컨텍스트 체인을 따라 올라가며 주어진 키에 해당하는 값을 찾아서 반환함.

실행 흐름

sequenceDiagram
    participant main as "main()"
    participant WithValue as "context.WithValue()"
    participant process as "processRequest()"

    main->>main: ctx = context.Background()
    main->>WithValue: WithValue(ctx, key, "12345")
    WithValue-->>main: ctxWithID (값이 저장된 새 컨텍스트) 반환
    main->>process: processRequest(ctxWithID) 호출
    process->>process: ctx.Value(key)로 값 조회
    process->>process: "Request ID: 12345" 출력

실습 파일: 13-컨텍스트/01-기본-컨텍스트/main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
	"context"
	"fmt"
)

// 1. 키 충돌을 방지하기 위해 사용자 정의 타입을 사용
type requestIDKey string

func processRequest(ctx context.Context) {
	// 2. 컨텍스트에서 "requestID" 키로 값을 조회
	id, ok := ctx.Value(requestIDKey("requestID")).(string)
	if !ok {
		id = "unknown"
	}
	fmt.Printf("Processing request with ID: %s\n", id)
}

func main() {
	// 3. 비어 있는 최상위 컨텍스트 생성
	ctx := context.Background()

	// 4. 컨텍스트에 "requestID"와 값 "12345"를 저장
	ctxWithID := context.WithValue(ctx, requestIDKey("requestID"), "12345")

	// 5. 값이 저장된 컨텍스트를 함수에 전달
	processRequest(ctxWithID)
}

실습 2: 취소 신호 전파

실습 파일: 13-컨텍스트/02-취소신호전파/main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done(): // 취소 신호 수신
			fmt.Printf("[%s] 작업 중단: %v\n", name, ctx.Err())
			return
		default:
			fmt.Printf("[%s] 작업 진행 중...\n", name)
			time.Sleep(500 * time.Millisecond)
		}
	}
}

func main() {
	// 1. 최상위 컨텍스트 생성
	ctx := context.Background()

	// 2. 취소 가능한 컨텍스트 생성
	ctx, cancel := context.WithCancel(ctx)

	// 3. 두 개의 worker 고루틴 실행
	go worker(ctx, "A")
	go worker(ctx, "B")

	// 4. 2초 뒤 작업 취소
	time.Sleep(2 * time.Second)
	fmt.Println("메인: 취소 신호 전송")
	cancel()

	// 5. 고루틴 종료 대기
	time.Sleep(1 * time.Second)
}

실습 3: 타임아웃

실습 파일: 13-컨텍스트/03-타임아웃/main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
	"context"
	"fmt"
	"time"
)

func task(ctx context.Context) {
	select {
	case <-time.After(3 * time.Second): // 3초 걸리는 작업 --> 타임아웃이 먼저 발생하여 실행되지 않음
		fmt.Println("작업 완료")
	case <-ctx.Done(): // 타임아웃 또는 취소
		fmt.Println("작업 중단:", ctx.Err())
	}
}

func main() {
	// 타임아웃 2초짜리 컨텍스트
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	task(ctx)
}
This post is licensed under CC BY 4.0 by the author.