Go 언어 - Context 패키지

검프·2021년 9월 11일
2

Concurrency in Go

목록 보기
5/6
post-thumbnail

Go 동시성 프로그래밍의 내용을 참고하여 작성했습니다.

동시성 프로그램에서 시간 초과, 취소, 에러로 인해 작업을 선점해야 하는 경우가 있습니다. 이런 경우 done 채널을 이용해서 동시에 수행되는 작업들을 취소할 수 있습니다. 하지만, 취소가 되었다는 단순한 신호를 받을 뿐 취소된 이유의 전달이나 함수가 취소되어야 하는 마감 시한 등에 대한 정보는 전달할 수 없습니다. Go 언어 1.7에서는 done 채널을 감싸는 표준 라이브러리인 context 패키지를 도입했습니다.

Context 패키지 살펴보기

Context 타입done 채널처럼 선점하고자 하는 프로세스 전체에 전달해야 하는 타입입니다. 그래서 context 패키지를 사용하는 경우, 최초 호출되는 루틴routine^{routine}으로부터 호출되는 모든 서브루틴subroutine^{sub-routine}의 첫 번째 인수로 Context를 받습니다.

type Context interface {
	// 컨텍스트가 완료되었을때 그 시간을 반환
	// daedline이 설정되지 않은 경우 ok는 false를 반환
	// 여러번 호출해도 동일한 결과를 반환
	Deadline() (deadline time.Time, ok bool)

	// 컨텍스트가 취소 또는 Timeout 되었을때 닫힌 채널을 반환
	// 컨텍스트가 절대로 취소되지 않을 수 있다면 nil을 반환할 수도 있음
	// 여러번 호출해도 동일한 결과를 반환
	Done() <-chan struct{}

	// Done이 닫힌 후에 nil이 아닌 에러 값을 반환
	// 컨텍스트가 취소되면 Canceled를 반환
	// 시간 제한을 초과하면 DeadlineExceeded를 반환
	// Done이 닫힌 후에 여러번 호출해도 동일한 결과를 반환
	Err() error

	// key에 대해서 컨텍스트에 맵핑된 값을 반환
	// 맵핑된 값이 없으면 nil을 반환
	Value(key interface{}) interface{}
}

context 패키지는 크게 두 가지 목적으로 사용됩니다.

  1. 호출 그래프상의 분기를 취소하기 위한 API 제공
  2. 호출 그래프를 따라 요청 범위requestscope^{request-scope} 데이터를 전송하기 위한 데이터 저장소 제공

함수에서의 취소는 크게 3가지 사항을 고려해야 합니다.

  • 고루틴의 부모가 해당 고루틴을 취소하고자 할 수 있다.
  • 고루틴이 자신의 자식을 취소하고자 할 수 있다.
  • 고루틴 내의 모든 대기 중인 작업은 취소될 수 있도록 선점 가능할 필요가 있다.

context 패키지를 이용하면 위 3가지 사항을 모두 관리할 수 있습니다.

Context 타입은 불변immutable^{immutable}입니다. 부모 Context에 영향을 주지 않으면서 자신만의 Context를 만들 수 있습니다. Context가 불변이라면, 어떻게 호출 스택에서 현재 함수 아래에 있는 함수들을 취소시킬 수 있을까요?

// 반환된 cancel 함수가 호출될 때 done 채널을 닫는 새 Context를 반환
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

func propagateCancel(parent Context, child canceler) {
	...
	} else {
		atomic.AddInt32(&goroutines, +1)
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

// 주어진 시간이 넘었을 때 done 채널을 닫는 새로운 Context를 반환
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

// 주어진 실행 시간 후 done 채널을 닫는 새로운 Context를 반환
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

위 함수들은 모두 Context를 인자로 받고 context를 반환합니다. 이 함수들은 모두 각 함수와 관련된 옵션들로 Context의 새 인스턴스를 생성합니다. 함수가 호출 그래프에서 아래쪽에 있는 함수들을 취소해야 하는 경우 함수는 위 함수들 중 하나를 호출해 자신에게 주어진 Context를 전달하고 리턴된 Context를 자식들에게 전달합니다. Context를 전달받은 함수가 취소 동작을 수정하지 않아도 되는 경우에는 주어진 Context를 전달만 합니다.

함수 호출 스택의 모든 함수에 Context를 전달해야 하기 때문에 객체의 멤버 변수로 Context를 관리하고 싶을 수도 있지만, 그렇게 하지 않는 것이 바람직합니다.

context 패키지는 함수 호출 스택을 시작하기 위한 Context를 만드는 함수를 제공합니다.

// 빈 Context를 반환
func Background() Context

// 빈 Context를 반환. Context를 제공받기를 기대하지만 아직 상위 함수에서 코드를 제공하지 않았을 때 자리를 표시하는 역할
func TODO() Context

context 패키지를 사용했을 경우의 이점을 설명하기 위해서 먼저 done 채널을 사용하는 예제를 살펴보겠습니다.


작업 취소를 위한 done 채널 이용

동시에 안부 인사greeting^{greeting}와 작별 인사farewell^{farewell}를 출력하는 코드입니다.

func main() {
	var wg sync.WaitGroup
	done := make(chan interface{}) // <1>
	defer close(done)

	wg.Add(1)
	go func() {
		defer wg.Done()
		if err := printGreeting(done); err != nil {
			fmt.Printf("%v", err)
			return
		}
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		if err := printFarewell(done); err != nil {
			fmt.Printf("%v", err)
			return
		}
	}()

	wg.Wait()
}

func printGreeting(done <-chan interface{}) error {
	greeting, err := genGreeting(done)
	if err != nil {
		return err
	}
	fmt.Printf("%s world!\n", greeting)
	return nil
}

func printFarewell(done <-chan interface{}) error {
	farewell, err := genFarewell(done)
	if err != nil {
		return err
	}
	fmt.Printf("%s world!\n", farewell)
	return nil
}

func genGreeting(done <-chan interface{}) (string, error) {
	switch locale, err := locale(done); {
	case err != nil:
		return "", err
	case locale == "EN/US":
		return "hello", nil
	}
	return "", fmt.Errorf("unsupported locale")
}

func genFarewell(done <-chan interface{}) (string, error) {
	switch locale, err := locale(done); {
	case err != nil:
		return "", err
	case locale == "EN/US":
		return "goodbye", nil
	}
	return "", fmt.Errorf("unsupported locale")
}

func locale(done <-chan interface{}) (string, error) {
	select {
	case <-done:
		return "", fmt.Errorf("canceled")
	case <-time.After(5 * time.Second):
	}
	return "EN/US", nil
}

<출력결과>
goodbye world!
hello world!

레이스 컨디션race condition^{race\ condition} 때문에 작별 인사가 먼저 출력 되었습니다. <1>에서는 done 채널을 만들고 이를 서브루틴에 전달함으로써 선점 가능하도록 설정했습니다. 이제 done 채널을 닫으면 모든 서브루틴이 취소됩니다.

done 채널을 이용한 구현에서 아래와 같은 요구 사항이 추가된다면 어떻게 처리할 수 있을까요?

  • genGreeting이 오래 걸리는 경우 시간 초과timeout^{timeout}를 설정하고 싶다.
  • printGreeting이 실패한다면 printFarewell을 취소한다.

작업 취소를 위한 context 패키지 이용

genGreetinglocale에 호출에 대해서 1초의 타임아웃을 갖도록 설정하고 싶습니다. 또한 printGreeting이 실패할 경우 printFarewell도 취소하도록 처리하고 싶습니다.

func main() {
	var wg sync.WaitGroup
	ctx, cancel := context.WithCancel(context.Background()) // <1>
	defer cancel()

	wg.Add(1)
	go func() {
		defer wg.Done()

		if err := printGreeting(ctx); err != nil {
			fmt.Printf("cannot print greeting: %v\n", err)
			cancel() // <2>
		}
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		if err := printFarewell(ctx); err != nil {
			fmt.Printf("cannot print farewell: %v\n", err)
		}
	}()

	wg.Wait()
}

func printGreeting(ctx context.Context) error {
	greeting, err := genGreeting(ctx)
	if err != nil {
		return err
	}
	fmt.Printf("%s world!\n", greeting)
	return nil
}

func printFarewell(ctx context.Context) error {
	farewell, err := genFarewell(ctx)
	if err != nil {
		return err
	}
	fmt.Printf("%s world!\n", farewell)
	return nil
}

func genGreeting(ctx context.Context) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 1*time.Second) // <3>
	defer cancel()

	switch locale, err := locale(ctx); {
	case err != nil:
		return "", err
	case locale == "EN/US":
		return "hello", nil
	}
	return "", fmt.Errorf("unsupported locale")
}

func genFarewell(ctx context.Context) (string, error) {
	switch locale, err := locale(ctx); {
	case err != nil:
		return "", err
	case locale == "EN/US":
		return "goodbye", nil
	}
	return "", fmt.Errorf("unsupported locale")
}

func locale(ctx context.Context) (string, error) {
	select {
	case <-ctx.Done():
		return "", ctx.Err() // <4>
	case <-time.After(5 * time.Second):
	}
	return "EN/US", nil
}

<출력결과>
cannot print greeting: context deadline exceeded
cannot print farewell: context canceled

<1>에서는 context.Background()로 Context를 만들고 취소할 수 있도록 하기 위하여 context.WithCancel로 감쌉니다. <2>에서는 에러가 반환될 경우 Context를 취소합니다.

<3>에서 genGreeting은 자신의 Context를 context.WithTimeout으로 감싸서 반환된 Context를 locale에 전달합니다. 이 Context는 1초 후에 취소될 것이고, 이를 받은 locale 함수도 함께 취소됩니다.

<4>에서는 Context가 취소된 이유를 반환합니다. 이 에러는 부모 루틴으로 전달되어 main까지 보고됩니다. locale이 실행되는 데는 5초가 걸리기 때문에 genGreeting의 호출은 제한 시간이 초과될 것이고, mainprintFarewell의 호출 그래프를 취소합니다.

genGreeting은 부모의 Context에 영향을 미치지 않고 자신의 필요에 맞는 Context를 구축했습니다. 호출 그래프상의 루틴 간에 서로의 동작 방식을 모르더라도 구성할 수 있음으로 인해 관심사가 분리된 상태로 커다란 시스템을 작성할 수 있습니다.

Context를 이용한 데이터 전달

다음은 Context에 데이터를 저장하고 조회하는 예제입니다.

func main() {
	ProcessRequest("gump", "abc123")
}

func ProcessRequest(userID, authToken string) {
	// <1>
	ctx := context.WithValue(context.Background(), "userID", userID)
	ctx = context.WithValue(ctx, "authToken", authToken)
	HandleResponse(ctx)
}

func HandleResponse(ctx context.Context) {
	// <2>
	fmt.Printf("handle response for %v (%v)",
		ctx.Value("userID"),
		ctx.Value("authToken"),
	)
}

<출력결과>
handle response for gump (abc123)

<1>에서 키+값 형태로 Context에 데이터를 저장하고 있습니다. 그리고 <2>에서 키를 이용해 맵핑된 값을 조회하고 있습니다. Context에 데이터를 저장할 때는 다음을 고려해야 합니다.

  • 키는 Go의 비교 가능성 개념을 충족해야 한다. 항등 연산자 == 및 !=을 사용하면 올바른 결과를 반환해야 한다.
  • 반환되는 값은 여러 고루틴에서 접근할 때 안전해야 한다.

Context의 카와 값이 interface{}로 정의돼 있기 때문에 검색 시 타입 안전성을 잃어버리게 됩니다. 이 때문에 Context에 값을 저장하고 조회할 때 몇 가지 규칙을 따르는 것이 안전합니다.

우선 패키지에 맞춤형 키를 사용하면 키값 충돌을 방지할 수 있습니다. 그 이유를 알아보기 위해서 map^{map}을 이용한 간단한 예제를 살펴보겠습니다.

func main() {
	type foo int
	type boo int

	rawValue := 1
	m := make(map[interface{}]int)
	m[foo(rawValue)] = 2
	m[boo(rawValue)] = 3

	fmt.Printf("m = %v\n", m)
	fmt.Printf("m[foo(%d)] = %d\n", rawValue, m[foo(rawValue)])
	fmt.Printf("m[boo(%d)] = %d\n", rawValue, m[boo(rawValue)])
}

<결과출력>
m = map[1:3 1:2]
m[foo(1)] = 2
m[boo(1)] = 3

타입을 감싸기 전에 전달한 값은 같지만 서로 다른 타입으로 감싼 데이터는 맵 내에서 서로 구분됩니다. 패키지의 키를 위해서 정의한 타입은 외부로 내보내지 않으므로 다른 패키지에서 사용하는 키와 충돌할 수 없게 됩니다. 그렇다면 패키지의 외부에서 값을 조회해야 할 필요가 생기면 어떻게 할까요? 이때는 데이터를 검색하여 제공하는 접근자 함수를 만들어 이를 가능하게 합니다.

func main() {
	ProcessRequest("gump", "abc123")
}

type ctxKey int

const (
	ctxUserID ctxKey = iota
	ctxAuthToken
)

func UserID(ctx context.Context) string {
	return ctx.Value(ctxUserID).(string)
}

func AuthToken(ctx context.Context) string {
	return ctx.Value(ctxAuthToken).(string)
}

func ProcessRequest(userID, authToken string) {
	ctx := context.WithValue(context.Background(), ctxUserID, userID)
	ctx = context.WithValue(ctx, ctxAuthToken, authToken)
	HandleResponse(ctx)
}

func HandleResponse(ctx context.Context) {
	fmt.Printf("handle response for %v (%v)",
		UserID(ctx),
		AuthToken(ctx),
	)
}

<결과출력>
handle response for gump (abc123)

접근자 함수를 사용하면 다른 패키지에서도 타입 안전성이 확보된 정적 함수를 사용할 수 있기 때문에 바람직합니다. 하지만, 이 방법에도 문제는 존재합니다.

HandleResponseresponse라는 다른 패키지에 존재하는 예제입니다.

// sample.go
package main

import "your-project/process"

func main() {
	process.ProcessRequest("gump", "abc123")
}

// process.go
package process

import (
	"context"
	"your-project/response"
)

type ctxKey int

const (
	ctxUserID ctxKey = iota
	ctxAuthToken
)

func UserID(ctx context.Context) string {
	return ctx.Value(ctxUserID).(string)
}

func AuthToken(ctx context.Context) string {
	return ctx.Value(ctxAuthToken).(string)
}

func ProcessRequest(userID, authToken string) {
	ctx := context.WithValue(context.Background(), ctxUserID, userID)
	ctx = context.WithValue(ctx, ctxAuthToken, authToken)
	response.HandleResponse(ctx)
}

// response.go
package response

import (
	"context"
	"fmt"
	"your-project/process"
)

func HandleResponse(ctx context.Context) {
	fmt.Printf("handle response for %v (%v)",
		process.UserID(ctx),
		process.AuthToken(ctx),
	)
}

<오류메시지>
package your-project
	imports your-project/process
	imports your-project/response
	imports your-project/process: import cycle not allowed

Context에 키를 저장하는 데 사용하는 타입이 process 패키지에 정의되어 있고 외부로 제공되지 않기 때문에 response 패키지는 내보내기 함수를 이용할 수밖에 없습니다. 이 때문에 process 패키지와 response 패키지가 순환 의존성을 발생시킵니다. 결국 다시 두 패키지가 모두 접근 가능한 위치에 정의된 타입을 중심으로 패키지를 구성하는 방법을 사용할 수밖에 없습니다.

context 패키지는 잘 설계되어 있지만, 다소 논란이 존재합니다.

첫째는, Context에 임의의 데이터를 저장할 수 있고, 타입에 안전하지 않은 방식 채택으로 인한 논란이 존재합니다. 잘못된 타입을 저장해 잠재적인 버그 발생 가능성을 만들기 때문입니다.

두 번째는, 개발자가 Context에 데이터를 저장 가능한데, 어떤 방식으로 데이터를 저장하는 게 바람직한지에 대한 가이드라인이 모호하다는 것입니다.

// context.go

Use context values only for request-scoped data that transits
processes and API boundaries, not for passing optional parameters to functions.

API나 프로세스 경계를 통과하는 요청 범위의 데이터에 대해서만 컨텍스트 값을 사용하고, // <1>
함수에 선택적 매개 변수를 전달하는 용도로는 사용하지 않는다. // <2>

<2>의 가이드라인인 비교적 명확하게 그 의미를 알 수 있습니다. 문제는 <1>의 상황이 명확하지 않다는 것입니다. Concurrency in Go에서 저자는 아래와 같은 경험적 가이드라인을 제시합니다.

  1. 데이터가 API나 프로세스 경계를 통과해야 한다.
    프로세스의 메모리 내에서 데이터를 생성한 경우, API의 경계를 넘지 않는 한, 요청 범위의 데이터가 될 가능성이 적다.
  2. 데이터는 변경 불가능해야 한다.
    불변값이 아니라면 당신이 저장하고 있는 값은 요청으로부터 온 것이 아니다.
  3. 데이터는 단순한 타입으로 변해야 한다.
    요청 범위 데이터가 프로세스 및 API 경계를 통과하기 위한 것이라면, 패키지들의 복잡한 그래프를 임포트할 필요가 없으며, 다른 쪽에서 손쉽게 이 데이터를 가져올 수 있다.
  4. 데이터는 메서드가 있는 타입이 아닌 데이터여야 한다.
    연산은 논리이며, 이 데이터를 소비하는 것이 연산을 보유해야 한다.
  5. 데이터는 연산을 주도하는 것이 아니라 꾸미는데 도임이 돼야 한다.
    Context에 무엇이 포함됐는지, 혹은 포함되지 않았는지에 따라 알고리즘이 다르게 동작하는 경우, 선택적 매개 변수의 영역으로 넘어갔을 가능성이 크다.

여러번 곱 씹었지만, 정확한 의미를 알기 어렵다는게 함정이네요. 고오오급 개발자 분들의 조언이 필요하군요.

profile
권구혁

0개의 댓글