4 minute read

해당 아티클은 다음 수준의 지식을 요구합니다.

  • context패키지에 대한 기본적인 활용법

https://medium.com/p/b1482754fc61 아티클을 읽고 오시길 권장합니다. 물론 읽지 않아도 어려운 점은 없습니다.

이전 아티클에서 안정성 루프 패턴에 활용되는 context에 대하여 기본적인 사용법과 내부 동작을 살펴봤습니다. 이번에는 안전성 패턴 구현 외에도 외부 라이브러리와 함께 context를 활용하는 경우 주의해야 할 점에 대하여 알아보겠습니다.


case1: 상위 라이브러리에서 context를 내려주는 경우

흔히 golang에서 grpc나 gin과 같은 라이브러리를 활용할 때 라이브러리 사용자가 context를 포함한 메서드나 함수를 구현하고 해당 함수를 라이브러리의 컬백으로 실행하는 경우가 존재합니다. 예를 들어, grpc와 같은 경우 다음과 같이 메서드를 구현하여 서버를 만드는 경우가 있습니다.

func (s *SomeServer) Get(ctx context.Context, req *api.GetRequest) (*api.GetResponse, error) {
	res := &api.GetResponse{}
	// ... some logic
	return res, nil
}

위 케이스는 grpc로 서버를 생성하는 데 Get이라는 rpc를 구현하는 메서드입니다. 이때 이 Get 메서드는 내부에서 상위에서 받은 ctx로부터 자식 context를 생성하여 다른 처리를 시도할 수 있습니다. 예를 들어 database 커넥션을 활용하는 라이브러리를 이용하여 외부 db와 데이터를 주고받는 경우도 있을 것입니다. 이런 경우 코드를 다음과 같이 작성할 수 있습니다.

func (s *SomeServer) Get(ctx context.Context, req *api.GetRequest) (*api.GetResponse, error) {
	res := &api.GetResponse{}
	// ... some logic
	
	// 자식 context를 WithCancel로 생성 후 다른 라이브러리의 call을 ctx와 함께 전달합니다.
	cctx, cancel := context.WithCancel(ctx)
	someData, err := somelib.someCall(cctx)
	
	// ... some logic
	return res, nil
}

이때 cancel을 꼭 호출해야 할까요? 때에 따라서 다르지만 defer로 호출하는 게 좋습니다. 즉 다음과 같이 구현하는 게 좋습니다.

func (s *SomeServer) Get(ctx context.Context, req *api.GetRequest) (*api.GetResponse, error) {
	res := &api.GetResponse{}
	// ... some logic
	
	// 자식 context를 WithCancel로 생성 후 다른 라이브러리의 call을 ctx와 함께 전달합니다.
	cctx, cancel := context.WithCancel(ctx)
	defer cancel()
	someData, err := somelib.someCall(cctx)
	
	// ... some logic
	return res, nil
}

앞 포스팅에서 살펴본 context.WithCancel호출 시 호출되는 함수인 propagateCancel을 다시 살펴봅시다.

// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/context/context.go#L462
func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
	c.Context = parent // ✅ parent context 가져옴

	done := parent.Done()
	if done == nil { // ✅ emptyCtx: 아무것도 하지 않음
		return // parent is never canceled
	}

	select {
	case <-done:
		// ✅ 부모 context가 이미 종료됐으면 자식도 종료
		child.cancel(false, parent.Err(), Cause(parent))
		return
	default:
	}

	// ✅ 부모 컨텍스트가 *cancelCtx이면
	if p, ok := parentCancelCtx(parent); ok { 
		// parent is a *cancelCtx, or derives from one.
		p.mu.Lock()
		if p.err != nil { // ✅ 부모가 이미 종료됐다면 바로 취소
			child.cancel(false, p.err, p.cause)
		} else { // ✅ 부모 컨텍스트에 자신을 자식으로 등록
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
		return
	}
	
	// ...
	
	// ✅ 타입 추정 불가한 경우 goroutine으로 대기
	go func() {
		select {
		case <-parent.Done():
			child.cancel(false, parent.Err(), Cause(parent))
		case <-child.Done():
		}
	}()
}

먼저 parent가 emptyCtx type인 경우 propagateCancel에서 해주는 작업이 따로 존재하지 않습니다. 위와 같이 cancelCtx의 Context에 parent를 등록하고 종료합니다. 이 경우, defer로 cancel을 호출하지 않아도 문제가 되지 않습니다. 왜냐하면 생성한 cancelCtx는 사용자가 구현한 메서드가 반환되면 회수되는 메모리이기 때문입니다.

// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/context/context.go#L462
func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
	c.Context = parent // ✅ parent context 가져옴

	done := parent.Done()
	if done == nil { // ✅ emptyCtx: 아무것도 하지 않음
		return // parent is never canceled
	}

	select {
	case <-done:
		// ✅ 부모 context가 이미 종료됐으면 자식도 종료
		child.cancel(false, parent.Err(), Cause(parent))
		return
	default:
	}
	
	// ...

다음은 상위 컨텍스트가 cancelCtx로부터 파생된 경우입니다. 이 경우 propagateCancel의 다음 부분이 호출됩니다.

// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/context/context.go#L478
func (c *cancelCtx) propagateCancel(...) {
	// ...
	// ✅ 부모 컨텍스트가 *cancelCtx이면
	if p, ok := parentCancelCtx(parent); ok { 
		// parent is a *cancelCtx, or derives from one.
		p.mu.Lock()
		if p.err != nil { // ✅ 부모가 이미 종료됐다면 바로 취소
			child.cancel(false, p.err, p.cause)
		} else { // ✅ 부모 컨텍스트에 자신을 자식으로 등록
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
		return
	}
	// ...

이때 부모 context의 타입을 cancelCtx로 추정하고 부모 context의 children에 사용자가 생성한 자식 context를 등록합니다. 따라서 자식 context가 생성된 함수가 끝나도 부모 context의 children 필드의 map에 자식 context가 남아있게 됩니다. 이 경우, 부모 context가 적절한 시점에 종료를 호출하지 않으면 지속적으로 map에 남아있게 됩니다. 따라서 부모 context의 종료가 호출되지 않는다면, 계속 context에 남아있어 메모리 누수로 이어질 수 있습니다.

마지막으로 직접 구현한 context의 경우입니다.

// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/context/context.go#L509
func (c *cancelCtx) propagateCancel(...) {
	// ...
	// ✅ 타입 추정 불가한 경우 goroutine으로 대기
	go func() {
		select {
		case <-parent.Done():
			child.cancel(false, parent.Err(), Cause(parent))
		case <-child.Done():
		}
	}()
}

이 경우 고루틴이 생성됩니다. 만약 child.Done과 parent.Done이 모두 닫히지 않는 경우 그대로 남아있게 됩니다. 따라서 이 경우도 마찬가지로 고루틴 누수로 이어지게 됩니다.

위에서 다룬 메모리나 고루틴이 누수가 되는 시나리오는 상위 함수에서 context로 넘어올 때 부모 context가 닫히지 않음을 가정하는 경우입니다. 만약 닫히는 경우는 누수가 일어나지 않습니다. grpc는 기본적으로 context가 default timeout이 있어 일정 시간 뒤에 context가 종료되므로 문제가 발생하지 않습니다. 그러나 라이브러리를 아예 신뢰하지 않는 경우 defer로 cancel을 호출하여 올바르게 부모 context로부터 제거하는 게 안전한 방법이라 볼 수 있습니다.


case2: 하위 라이브러리 호출에서 context를 전달하는 경우

이번에는 하위 라이브러리 호출의 경우입니다. 예를 들어, 서비스 A에서 서비스 B를 호출하는 상황을 context를 통해 비동기 채널로 요청을 전달받고 응답과 context.Done을 select로 체크하여 에러와 같이 리턴하는 경우의 상황입니다. 이때 서비스 A에서 다음과 같이 호출한다고 가정해봅시다.

// service A
func SomeFunc1() {
	// ...
	cctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	someResponse, err := somelib.CallServiceB(cctx, someReqeust)
	// ... 
}

이때 1초가 지나서 err로 context.DeadlineExceeded가 발생했다고 합시다. 이때 요청 자체에 에러가 있었던 걸까요? 만약 CallServiceB로 인해 호출되는 함수가 다음과 같이 SomeFunc2라고 합시다.

// service B
func SomeFunc2(req *SomeRequest) *SomeResponse {
	// ...
	time.Sleep(time.Second*3)
	// ... 
}

만약 위처럼 서비스 B에서 CallServiceB에 대응되는 함수 SomeFunc2 내부 로직이 길어지는 경우, 서비스 A는 timeout이 발생하지만 서비스 B는 아직 실행 중일 수 있다는 의미입니다. 이런 경우 서비스 B에 도달한 요청은 성공했지만 서비스 A에서 호출한 함수 자체는 실패하게 됩니다. 즉, 내부 로직의 실행 시간에 따라 호출한 함수가 성공할 지 실패할 지 모릅니다. 따라서 만약 context로 인한 timeout 에러가 발생한 경우 실제로 성공했는지 실패했는지 재확인해야 합니다.


요약하면 context를 활용할 때 다음을 알아두면 좋습니다.

  • 자식 context를 cancel로 생성하는 경우 defer로 cancelFunc을 호출합니다.
  • context를 통해 외부 네트워크 호출로 서비스를 트리거하는 경우, timeout은 무조건 실패를 보장하지 않습니다.

Leave a comment