고랭, 컨텍스트, Go, Context, 동시성, 고루틴

고랭, 컨텍스트, Go, Context, 동시성, 고루틴

안녕하세요!

 

고랭(Golang) 개발에서 정말 중요한 개념인 컨텍스트(Context)에 대해 깊이 파고드는 시간을 가져볼까 합니다.

 

이게 처음에는 좀 낯설 수 있지만, 알고 보면 정말 유용하고 강력한 도구입니다.

 

1. 컨텍스트(Context)란 무엇일까요?

 

간단히 말해서, 컨텍스트(Context)는 고랭(Go) 버전 1.7부터 표준 라이브러리에 포함된 인터페이스(interface)입니다.

 

약속된 틀이라고 생각하면 이해하기 쉬운데요. 이 인터페이스는 다음과 같이 정의되어 있습니다.

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

 

이 인터페이스 안에는 네 가지 메서드(method), 즉 기능이 정의되어 있는데요. 하나씩 살펴보겠습니다.

  • Deadline: 이 컨텍스트(Context)가 언제 취소되어야 하는지, 즉 마감 시간을 설정하는 기능입니다. 마감 시간이 설정되었다면 그 시간과 함께 true를 반환하고, 설정되지 않았다면 false를 반환합니다.
  • Done: 읽기 전용 채널(channel)을 반환하는데요. 이 채널은 컨텍스트(Context)가 취소되거나 마감 시간에 도달했을 때 닫힙니다. 채널이 닫힌다는 것은 "이제 그만!"이라는 신호를 보내는 것과 같습니다. 이 신호를 받아서 관련 작업을 중단할 수 있습니다. Done 메서드를 여러 번 호출해도 항상 같은 채널을 돌려줍니다.
  • Err: 컨텍스트(Context)가 왜 종료되었는지 그 이유를 알려줍니다. Done 메서드가 반환한 채널이 닫혔을 때만 의미 있는 값(nil이 아닌 값)을 반환하는데요. 두 가지 경우가 있습니다.
    • 컨텍스트(Context)가 명시적으로 취소되었다면 Canceled라는 에러(error)를 반환합니다.
    • 컨텍스트(Context)가 마감 시간(deadline)을 넘겨서 종료되었다면 DeadlineExceeded라는 에러(error)를 반환합니다.
  • Value: 컨텍스트(Context) 안에 저장된 키(key)에 해당하는 값(value)을 가져오는 기능입니다. 마치 맵(map)에서 키로 값을 찾는 것과 비슷합니다. 같은 컨텍스트(Context)에 대해 같은 키(key)로 여러 번 Value를 호출하면 항상 같은 결과를 얻을 수 있습니다. 만약 해당하는 키(key)가 없다면 nil을 반환합니다. 키-값 쌍은 나중에 설명할 WithValue라는 함수를 통해 컨텍스트(Context)에 저장합니다.

2. 컨텍스트(Context) 생성하기

 

컨텍스트(Context)를 사용하려면 먼저 만들어야 하는데요.

 

컨텍스트(Context)는 크게 두 종류로 나눌 수 있습니다. 바로 모든 컨텍스트(Context)의 시작점이 되는 루트 컨텍스트(Root Context)와, 이 루트 컨텍스트(Root Context)로부터 파생되는 자식 컨텍스트(Child Context)입니다.

 

루트 컨텍스트(Root Context) 만들기

 

루트 컨텍스트(Root Context)를 만드는 방법은 주로 두 가지가 있습니다.

  • context.Background()
  • context.TODO()

소스 코드를 들여다보면 context.Backgroundcontext.TODO 사이에 큰 차이는 없습니다.

 

둘 다 아무런 기능도 가지지 않은, 비어있는 컨텍스트(Context)를 만드는 데 사용됩니다.

 

이게 바로 모든 컨텍스트(Context) 계층 구조의 최상단, 즉 뿌리가 되는 거죠. 보통은 특별한 이유가 없다면, 함수의 입력 파라미터(parameter)로 컨텍스트(Context)가 주어지지 않았을 때 context.Background()를 사용해서 가장 기본적인 루트 컨텍스트(Root Context)를 만들고 이를 아래로 전달하는 방식으로 많이 사용합니다.

 

context.TODO()는 '아직 어떤 컨텍스트(Context)를 써야 할지 잘 모르겠다' 또는 '나중에 적절한 컨텍스트(Context)로 바꿀 예정이다'라는 임시적인 의미로 사용될 때가 많습니다.

 

자식 컨텍스트(Child Context) 만들기

 

루트 컨텍스트(Root Context) 자체는 아무 기능이 없기 때문에, 실제로 프로그램에서 유용하게 사용하려면 context 패키지(package)에서 제공하는 With로 시작하는 함수들을 이용해 새로운 컨텍스트(Context)를 만들어야 합니다.

 

이 과정을 '파생시킨다'고 표현하는데요. 주요 파생 함수들은 다음과 같습니다.

  • func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
  • func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
  • func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
  • func WithValue(parent Context, key, val interface{}) Context

With 함수들은 기존의 컨텍스트(Context)를 기반으로 새로운 컨텍스트(Context)를 만들어냅니다.

 

이때 기존 컨텍스트(Context)를 부모 컨텍스트(Parent Context), 새로 만들어진 컨텍스트(Context)를 자식 컨텍스트(Child Context)라고 부릅니다.

 

마치 나무와 같은 구조를 생각하면 이해하기 쉬운데요. 루트 컨텍스트(Root Context)라는 뿌리에서 시작해서, 네 가지 With 함수들을 통해 다양한 종류의 자식 컨텍스트(Context) 가지들을 뻗어 나갈 수 있습니다.

 

그리고 각각의 자식 컨텍스트(Context)에서도 또 다른 With 함수들을 호출해서 새로운 자식 컨텍스트(Context)를 계속 만들어 나갈 수 있습니다. 이렇게 전체 구조가 나무처럼 형성되는 것입니다.

 

3. 컨텍스트(Context)는 언제 사용할까요?

 

그렇다면 이 컨텍스트(Context)는 도대체 왜 필요하고 언제 사용하는 걸까요?

 

주로 두 가지 중요한 목적 때문에 사용하는데요. 프로젝트에서도 아주 흔하게 볼 수 있는 용도입니다.

  • 동시성 제어: 여러 작업(고루틴, goroutine)을 동시에 실행할 때, 이 작업들을 안전하고 깔끔하게 종료시키는 데 사용합니다.
  • 컨텍스트 정보 전달: 작업 간에 필요한 정보, 예를 들어 요청 ID 같은 것들을 전달하는 데 사용합니다.

일반적으로 컨텍스트(Context)는 부모 고루틴(goroutine)과 자식 고루틴(goroutine) 사이에서 값을 전달하거나, "이제 그만 작업해도 돼!"라는 취소 신호를 보내는 메커니즘(mechanism)이라고 이해하면 좋습니다.

 

동시성 제어 상세 설명

 

우리가 흔히 만드는 서버(server) 프로그램을 생각해 보겠습니다.

 

서버는 보통 계속 실행되면서 클라이언트(client)나 브라우저(browser)로부터 요청을 받고 응답하는 일을 반복합니다.

 

그런데 요즘처럼 복잡한 마이크로서비스(microservice) 구조에서는, 서버가 요청 하나를 처리하기 위해 단순히 하나의 작업(고루틴)만 사용하는 것이 아니라 여러 개의 고루틴(goroutine)을 만들어서 협력하며 일을 처리하는 경우가 많습니다.

 

예를 들어, 어떤 요청이 들어왔을 때, 서버 내부에서는 첫 번째 원격 작업(RPC1)을 호출하고, 그 결과를 받아 두 번째 원격 작업(RPC2)을 호출합니다.

 

그리고 또 다른 두 개의 원격 작업(RPC3, RPC4)을 동시에 실행시킨다고 가정해 봅시다.

 

심지어 네 번째 작업(RPC4) 내부에서는 다섯 번째 작업(RPC5)을 또 호출할 수도 있습니다. 모든 작업들이 성공적으로 끝나야 최종 결과를 반환할 수 있는 구조인데요.

 

만약 이 과정에서 첫 번째 작업(RPC1)에서 에러(error)가 발생했다고 생각해 봅시다. 컨텍스트(Context)가 없다면 어떻게 될까요?

 

이미 첫 단계에서 실패했지만, 뒤따르는 RPC2, RPC3, RPC4, RPC5 작업들은 계속 실행될 겁니다.

 

그리고 모든 작업이 끝날 때까지 기다렸다가 결국 실패 결과를 반환하게 되죠. 이건 정말 비효율적입니다.

 

첫 번째 작업(RPC1)에서 실패했다면, 그 즉시 결과를 반환하고 나머지 작업들은 실행할 필요가 없어야 합니다.

 

이미 실패한 요청을 위해 뒤따르는 작업들을 계속 실행하는 것은 컴퓨터 자원(CPU, 메모리, 네트워크 등)만 낭비하는 셈입니다.

 

바로 이럴 때 컨텍스트(Context)가 빛을 발합니다. 컨텍스트(Context)를 도입하면, 특정 작업에서 문제가 발생했을 때 관련된 다른 자식 고루틴(goroutine)들에게 "더 이상 작업할 필요 없어, 이제 종료해!"라는 신호를 보낼 수 있습니다.

 

이렇게 하면 불필요한 자원 낭비를 막고 프로그램을 더 효율적으로 만들 수 있습니다.

 

4. 컨텍스트(Context) 활용 예시: 동시성 제어 및 정보 전달

 

이제 실제로 컨텍스트(Context)를 어떻게 활용하는지 구체적인 예시와 함께 살펴보겠습니다.

 

특히 WithCancel, WithDeadline, WithTimeout, WithValue 함수들이 어떻게 사용되는지 집중해서 봐주시면 좋겠습니다.

 

context.WithCancel 활용: 명시적으로 작업 취소하기

 

이 함수는 다음과 같이 정의되어 있습니다.

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

 

context.WithCancel 함수는 취소 기능을 제어하는 데 사용됩니다.

 

부모 컨텍스트(parent Context)를 입력으로 받아서, 새로운 자식 컨텍스트(ctx)와 취소 함수(cancel)를 함께 반환합니다.

 

이 자식 컨텍스트(ctx)를 새로 생성하는 고루틴(goroutine)들에게 전달해주면, 나중에 우리가 원할 때 cancel 함수를 호출해서 해당 컨텍스트(Context)와 그로부터 파생된 모든 자식 컨텍스트(Context)들을 한꺼번에 취소시킬 수 있습니다.

 

cancel 함수가 호출되면, 관련된 모든 고루틴(goroutine)들은 Done() 채널을 통해 취소 신호를 동시에 받게 됩니다.

 

실제 사용 예시를 보겠습니다.

package main

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

func main() {
    // Background()로 루트 컨텍스트를 만들고, WithCancel로 자식 컨텍스트와 취소 함수 생성
    ctx, cancel := context.WithCancel(context.Background())

    // 두 개의 고루틴 시작, 생성된 ctx 전달
    go Watch(ctx, "고루틴1")
    go Watch(ctx, "고루틴2")

    // 고루틴들이 6초 동안 실행되도록 기다림
    time.Sleep(6 * time.Second)
    fmt.Println("이제 작업을 종료합니다!!!")
    // cancel 함수 호출! 고루틴1과 고루틴2에게 종료 신호 전달
    cancel()
    // 고루틴들이 종료 메시지를 출력할 시간을 주기 위해 잠시 대기
    time.Sleep(1 * time.Second)
}

// Watch 함수는 컨텍스트를 받아서 주기적으로 상태를 출력하는 고루틴
func Watch(ctx context.Context, name string) {
    for {
        select {
        // ctx.Done() 채널이 닫히면(취소 신호가 오면) 이 case가 실행됨
        case <-ctx.Done():
            fmt.Printf("%s 종료!\n", name) // 메인 고루틴에서 cancel()을 호출하면 이 메시지가 출력됨
            return // 고루틴 종료
        // 취소 신호가 없으면 default가 실행됨
        default:
            fmt.Printf("%s 작동 중...\n", name)
            time.Sleep(time.Second) // 1초 대기
        }
    }
}

 

실행 결과는 다음과 같습니다.

고루틴2 작동 중...
고루틴1 작동 중...
고루틴1 작동 중...
고루틴2 작동 중...
고루틴2 작동 중...
고루틴1 작동 중...
고루틴1 작동 중...
고루틴2 작동 중...
고루틴2 작동 중...
고루틴1 작동 중...
고루틴1 작동 중...
고루틴2 작동 중...
이제 작업을 종료합니다!!!
고루틴1 종료!
고루틴2 종료!

 

코드를 보면, context.WithCancel(context.Background())를 통해 ctxcancel 함수를 얻고, 이 ctxWatch라는 함수를 실행하는 두 개의 자식 고루틴(goroutine)에게 전달했습니다.

 

처음 6초 동안은 cancel 함수가 호출되지 않았기 때문에, 자식 고루틴(goroutine)들은 select 문에서 default 부분만 계속 실행하며 "작동 중..." 메시지를 출력합니다.

 

6초가 지난 후 main 함수에서 cancel()을 호출하면, ctx.Done() 채널이 닫히면서 자식 고루틴(goroutine)들의 select 문에서 case <-ctx.Done(): 부분이 신호를 받게 됩니다.

 

그러면 "종료!" 메시지를 출력하고 return을 통해 고루틴(goroutine)이 깔끔하게 종료되는 것을 볼 수 있습니다.

 

context.WithDeadline 활용: 마감 시간 설정하기

 

이 함수는 다음과 같이 정의되어 있습니다.

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

 

context.WithDeadline 역시 취소 제어 기능 함수입니다. 이 함수는 두 개의 파라미터(parameter)를 받는데요.

 

첫 번째는 부모 컨텍스트(parent Context)이고, 두 번째는 마감 시간(d, 특정 시점)입니다.

 

이 함수도 자식 컨텍스트(ctx)와 취소 함수(cancel)를 반환합니다. WithDeadline을 사용할 때는 두 가지 방식으로 컨텍스트(Context)가 취소될 수 있습니다.

 

첫째, 설정된 마감 시간(d)이 되기 전에 우리가 직접 cancel 함수를 호출해서 취소할 수 있습니다.

 

둘째, 우리가 cancel 함수를 호출하지 않더라도 설정된 마감 시간(d)이 지나면 자동으로 자식 컨텍스트(Context)의 Done() 채널이 닫히면서 취소 신호가 전달됩니다.

 

이를 통해 자식 고루틴(goroutine)들의 종료를 제어할 수 있습니다.

 

사용 예시를 보겠습니다.

package main

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

func main() {
    // 현재 시간으로부터 4초 뒤를 마감 시간으로 설정
    deadline := time.Now().Add(4 * time.Second)
    ctx, cancel := context.WithDeadline(context.Background(), deadline)
    // defer cancel()은 main 함수가 끝나기 직전에 항상 cancel()을 호출하도록 보장합니다.
    // 이는 혹시 모를 리소스 누수를 방지하는 좋은 습관입니다.
    defer cancel()

    go Watch(ctx, "고루틴1")
    go Watch(ctx, "고루틴2")

    // 6초 동안 기다려봄 (마감 시간 4초보다 김)
    time.Sleep(6 * time.Second)
    fmt.Println("이제 작업을 종료합니다!!!")
}

// Watch 함수는 이전 예시와 동일합니다.
func Watch(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            // 마감 시간(4초)이 지나면 자동으로 이 신호를 받음
            fmt.Printf("%s 종료! (이유: %v)\n", name, ctx.Err())
            return
        default:
            fmt.Printf("%s 작동 중...\n", name)
            time.Sleep(time.Second)
        }
    }
}

 

실행 결과는 다음과 비슷하게 나올 것입니다. (정확한 순서는 다를 수 있습니다.)

고루틴1 작동 중...
고루틴2 작동 중...
고루틴2 작동 중...
고루틴1 작동 중...
고루틴1 작동 중...
고루틴2 작동 중...
고루틴2 작동 중...
고루틴1 작동 중...
고루틴1 종료! (이유: context deadline exceeded)
고루틴2 종료! (이유: context deadline exceeded)
이제 작업을 종료합니다!!!

 

이번 예시에서는 main 함수에서 직접 cancel() 함수를 호출하지 않았습니다.

 

하지만 WithDeadline으로 컨텍스트(Context)를 만들 때 마감 시간을 지금으로부터 4초 뒤로 설정했기 때문에, 정확히 4초가 지나면 ctx.Done() 채널이 자동으로 닫힙니다.

 

그래서 자식 고루틴(goroutine)들은 4초 후에 신호를 받고 "종료!" 메시지를 출력한 뒤 스스로 종료합니다.

 

ctx.Err()를 호출해보면 종료 이유가 context deadline exceeded(마감 시간 초과)임을 확인할 수 있습니다.

 

defer cancel()은 마감 시간 전에 main 함수가 다른 이유로 종료될 경우를 대비해 적어두는 것이 좋습니다.

 

context.WithTimeout 활용: 시간 제한 설정하기

 

이 함수는 다음과 같이 정의되어 있습니다.

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

 

context.WithTimeoutcontext.WithDeadline과 기능적으로 매우 유사합니다.

 

둘 다 시간이 지나면 자식 컨텍스트(Context)를 자동으로 취소하는 데 사용됩니다. 유일한 차이점은 두 번째 파라미터(parameter)입니다.

 

WithDeadline은 특정 마감 '시점'(time.Time)을 받는 반면, WithTimeout은 '지속 시간'(time.Duration)을 받습니다. 즉, "몇 초 후에 취소해줘" 와 같은 방식으로 설정할 때 더 편리합니다.

 

사용 예시를 보겠습니다.

package main

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

func main() {
    // 4초의 시간 제한(timeout)을 설정
    ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
    defer cancel() // 리소스 누수 방지를 위한 습관

    go Watch(ctx, "고루틴1")
    go Watch(ctx, "고루틴2")

    // 6초 동안 기다려봄 (시간 제한 4초보다 김)
    time.Sleep(6 * time.Second)
    fmt.Println("이제 작업을 종료합니다!!!")
}

// Watch 함수는 이전 예시와 동일합니다.
func Watch(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
             // 시간 제한(4초)이 지나면 자동으로 이 신호를 받음
            fmt.Printf("%s 종료! (이유: %v)\n", name, ctx.Err())
            return
        default:
            fmt.Printf("%s 작동 중...\n", name)
            time.Sleep(time.Second)
        }
    }
}

 

실행 결과는 WithDeadline 예시와 거의 동일하게 나옵니다.

고루틴2 작동 중...
고루틴1 작동 중...
고루틴1 작동 중...
고루틴2 작동 중...
고루틴2 작동 중...
고루틴1 작동 중...
고루틴1 작동 중...
고루틴2 작동 중...
고루틴1 종료! (이유: context deadline exceeded)
고루틴2 종료! (이유: context deadline exceeded)
이제 작업을 종료합니다!!!

 

프로그램 코드는 WithDeadline 예시와 거의 똑같고, 컨텍스트(Context)를 파생시키는 함수만 context.WithTimeout으로 변경했습니다.

 

특히 두 번째 파라미터(parameter)가 특정 시점이 아니라 4초라는 '지속 시간'으로 바뀐 점을 주목해 주세요.

 

실행 결과 역시 마찬가지로, 4초가 지나면 자식 고루틴(goroutine)들이 자동으로 종료됩니다. 종료 이유는 여전히 context deadline exceeded입니다.

 

WithTimeout은 내부적으로 WithDeadline을 사용하기 때문에 결과는 동일합니다.

 

context.WithValue 활용: 컨텍스트(Context)에 정보 담아 전달하기

 

이 함수는 다음과 같이 정의되어 있습니다.

func WithValue(parent Context, key, val interface{}) Context

 

context.WithValue 함수는 부모 컨텍스트(parent Context)로부터 값 전달을 위한 자식 컨텍스트(Context)를 만듭니다.

 

함수 파라미터(parameter)는 부모 컨텍스트(parent Context), 그리고 키-값 쌍(key, val)입니다.

 

이 함수는 새로운 컨텍스트(Context)를 반환합니다. (취소 함수는 반환하지 않습니다.)

 

프로젝트에서는 보통 이 메서드를 사용해서 작업 수행에 필요한 부가 정보, 예를 들어 각 요청을 추적하기 위한 고유한 요청 ID(request ID)나 트레이스 ID(trace ID) 등을 전달하는 데 사용합니다. 이렇게 전달된 정보는 로깅(logging), 모니터링(monitoring), 디버깅(debugging) 등에 유용하게 쓰입니다.

 

사용 예시를 보겠습니다.

package main

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

// func1은 컨텍스트(Context)를 받아서 "name"이라는 키의 값을 출력하는 함수
func func1(ctx context.Context) {
    // ctx.Value("name")으로 값을 가져옵니다. 반환 타입이 interface{}이므로 실제 타입(string)으로 변환(.(string))해줘야 합니다.
    name := ctx.Value("name")
    if name != nil { // 키가 존재하지 않으면 nil이 반환되므로 확인하는 것이 좋습니다.
        fmt.Printf("func1에서 확인한 이름은: %s입니다.\n", name.(string))
    } else {
        fmt.Println("func1에서 이름을 찾을 수 없습니다.")
    }
}

func main() {
    // Background 컨텍스트에 "name"이라는 키와 "드리프트"라는 값을 저장하여 새로운 ctx 생성
    ctx := context.WithValue(context.Background(), "name", "드리프트")

    // 생성된 ctx를 func1 고루틴에 전달
    go func1(ctx)

    // 고루틴이 실행될 시간을 줌
    time.Sleep(time.Second)

    // main 함수에서도 값을 확인해볼 수 있습니다.
    retrievedName := ctx.Value("name")
    fmt.Printf("main에서 확인한 이름은: %s입니다.\n", retrievedName.(string))

    // 존재하지 않는 키로 값을 가져오려고 시도
    unknownValue := ctx.Value("age")
    if unknownValue == nil {
        fmt.Println("main에서 'age' 키에 해당하는 값은 없습니다 (nil).")
    }
}

 

실행 결과는 다음과 같습니다.

func1에서 확인한 이름은: 드리프트입니다.
main에서 확인한 이름은: 드리프트입니다.
main에서 'age' 키에 해당하는 값은 없습니다 (nil).

 

main 함수에서 context.WithValue를 사용해 context.Background()에 "name"이라는 키(key)와 "드리프트"라는 값(value)을 넣어 새로운 ctx를 만들었습니다.

 

ctxfunc1이라는 함수를 실행하는 고루틴(goroutine)에 전달했습니다. func1 함수 내에서는 ctx.Value("name")을 호출하여 "드리프트"라는 값을 성공적으로 꺼내서 출력하는 것을 볼 수 있습니다.

 

중요한 점은 WithValue로 생성된 컨텍스트(Context)는 불변(immutable)하다는 것입니다.

 

즉, 한 번 저장된 값은 변경할 수 없으며, 값을 꺼낼 때는 원래 저장했던 타입으로 타입 단언(.(string))을 해줘야 합니다.

 

또한, 존재하지 않는 키("age")로 값을 조회하면 nil이 반환되는 것도 확인할 수 있습니다.


자, 오늘은 고랭(Golang)의 컨텍스트(Context)에 대해 알아보았습니다.

 

컨텍스트(Context)는 고루틴(goroutine)들을 효과적으로 제어하고, 작업 간에 필요한 정보를 안전하게 전달하는 데 아주 중요한 역할을 합니다.

 

특히 복잡한 동시성 프로그래밍이나 웹 서버 개발에서는 거의 필수적으로 사용되는 개념이니, 오늘 배운 내용을 바탕으로 실제 프로젝트에 적용해보시면 큰 도움이 될 것입니다.

 

처음에는 조금 어렵게 느껴질 수 있지만, 자꾸 사용하다 보면 금방 익숙해지실 겁니다.