Go Context 를 활용하여 애플리케이션을 안전하게 종료하기

Jake
16 min readDec 18, 2023

--

애플리케이션을 잘 종료하는 것은 무엇을 의미하는걸까요?

우리는 애플리케이션을 실행시키는 것만큼 잘 종료 시키는 것도 중요합니다. 정상적인 종료가 이루어지지 않는다면 여러 자원의 커넥션이 닫히지 않을수도 있고, 메모리에 dummy data 가 적재될 수도 있습니다. 이러한 문제는 즉각적으로 나타나지 않고 임계치를 넘게되면 발생하기 때문에 우리는 Runtime 상황에서 문제를 마주하게 됩니다. 또한 외부 자원에 대한 리소스 부하를 발생할 수 있기 때문에 전체 시스템 장애로도 이어질 수 있습니다.

프로그래밍에서 정상 종료의 사전적 의미는 프로그램이 모든 작업을 성공적으로 수행하고 종료하는 것을 말합니다. 애플리케이션이 동작하면서 사용하였던 리소스 자원을 모두 반납해야 하며, 현재 진행 중인 작업이 있다면 완료하고 종료하거나 재시작을 할 수 있도록 데이터에 반영하여야 합니다.

이러한 작업 없이 그냥 종료된다는 의미는 우리는 애플리케이션을 재시작할 때마다 장애를 내고 있는 것입니다.

정상 종료를 위한 프로그래밍은 Graceful Shutdown 이라고도 불리며, 이는 애플리케이션이 종료신호를 받을 때의 이벤트처리에 대한 로직으로 구현됩니다.

종료 신호의 종류

SIGTERM

  • 프로세스에게 종료 요청을 보내는 시그널로, 프로세스는 종료할 수 있도록 마무리 작업을 수행할 수 있습니다.

SIGINT

  • 프로그램을 인터럽트하여 실행 중인 작업을 중단시키는 시그널입니다.
  • 터미널에서 Ctrl + C 를 누르면 발생하며, 프로그램이 graceful 하게 종료할 수 있도록 합니다.

SIGKILL

  • 프로세스를 즉시 중단시키는 시그널입니다.
  • 운영체제에 의해 무조건적으로 처리되어 프로세스가 즉시 종료됩니다.
  • 프로그래밍에서 시그널을 처리하는 것은 불가능합니다.

위의 종료신호 중 프로그래밍에서 사용할 신호로 SIGTERM, SIGINT 가 있습니다. 도커 컨테이너로 올라간 애플리케이션은 종료될 때 docker stop 명령어를 통해 SIGTERM, SIGINT신호를 전달받기 때문입니다. SIGKILL 은 운영체제 레벨에서의 강제종료이기 때문에 애플리케이션에서의 프로그래밍은 의미가 없습니다. 이러한 종료신호를 받았을 때의 애플리케이션 작업 수행 순서는 다음과 같습니다.

작업 수행 순서

  1. SIGTERM, SIGINT 와 같은 프로세스 관리자로부터 종료 신호 이벤트를 수신합니다.
  2. 수신받은 종료신호 이벤트를 하위 쓰레드에게 전달합니다.
  3. 하위 쓰레드가 종료를 완료할 때까지 메인쓰레드는 종료를 기다립니다.
  4. 하위 쓰레드의 종료가 완료되면 메인쓰레드는 종료합니다.

Go 언어에서 여러 고루틴을 운영할 때 Graceful Shutdown 로직이 필요한 이유는, Go 애플리케이션이 종료 신호를 받았을 때 하위 고루틴들이 메인 고루틴보다 먼저 종료될 가능성이 있기 때문입니다.

Go 언어는 경량 스레드인 고루틴을 사용하여 병렬성을 지원하며, 이는 메인 고루틴과 별개로 실행됩니다. 따라서 애플리케이션이 종료될 때 메인 고루틴이 먼저 종료되면 하위 고루틴들이 강제로 종료될 수 있습니다. 이는 데이터 손실이나 리소스 누출과 같은 문제를 초래할 수 있습니다.

Graceful Shutdown은 애플리케이션이 종료되기 전에 모든 고루틴이 안전하게 종료되도록 하는 메커니즘입니다. 종료 신호를 받은 경우, 애플리케이션은 각 고루틴에게 종료 신호를 전달하고, 고루틴은 현재 진행 중인 작업을 완료한 후 종료됩니다. 이를 통해 데이터의 일관성을 유지하고 리소스를 정리할 수 있습니다.

따라서 Graceful Shutdown 로직은 애플리케이션의 안정성과 신뢰성을 높이는데 중요한 역할을 합니다. Go 언어에서의 Context 를 통해 Graceful Shutdown 로직을 구현 및 테스트해보겠습니다.

Context란?

context 패키지는 Go 언어에서 동시성을 관리하고 여러 고루틴 간에 값을 전달하며 취소 신호를 전파하는데 사용되는 패키지입니다.

context 는 Go 1.7 이후에 추가되었으며, 분산 시스템이나 서비스 간의 상호 작용에서 유용하게 활용됩니다.

Context 내부 함수 사용 방법

context 로 사용가능한 함수들을 설명하겠습니다.

context.Background

- 모든 context 의 기본이 되는 빈 context 를 반환합니다.
- 대부분의 context 는 이를 기반으로 생성됩니다.

context.TODO()

- 아직 구현되지 않은 부분을 나타내는 context 를 반환합니다.

Deadline() time.Time

- Deadline 메서드는 현재 context 의 만료 기한을 나타내는 time.Time 값을 반환합니다.
- deadline, ok := ctx.Deadline()

Done() ←chan struct{}

- Done 메서드는 context 가 취소되었을 때 신호를 받을 수 있는 채널 chan struct{} 를 반환합니다.
- doneChan := ctx.Done()

Err() err

- Err 메서드는 context 가 취소 되었을 때 발생한 오류를 반환합니다.
- 오류가 없으면 nil 을 반환합니다.
- err := ctx.Err()

Value(key interface{}) interface{};

- Value 메서드는 context 에 연결된 특정 키의 값을 반환합니다. 이를 통해 context 에서 전역적으로 사용 가능한 값을 전달할 수 있습니다.
- 키와 연결된 값이 없으면 nil 을 반환합니다.
- value := ctx.Value("myKey")

Context 의 취소 기능 종류

context 패키지에서는 WithCancel , WithTimeout , WithDeadline 의 함수들로 취소 기능을 제공합니다.

Timeout 기반 취소 (WithTimeout)

- context.WithTimeout 함수는 일정 시간이 경과하면 자동으로 context 를 취소합니다.
- context.WithTimeout(parentContext, 5 * time.Second) 은 5초 후에 자동으로 취소됩니다.

Deadline 기반 취소 (WithDeadline)

- context.WithDeadline 함수는 특정 시점에 context 를 취소합니다.
- deadline := time.Now().Add(5 * time.Second 이렇게 설정하게 되면 현재 시간으로부터 5초 후에 context 는 종료되게 됩니다.

Signal 기반 취소 (WithCancel)

- context.WithCancel 함수는 부모 context 에서 파생된 새로운 context 를 생성하며, 부모 context 가 취소되면 이를 파생된 모든 context 도 취소됩니다.

어떤 점을 테스트할 것인가?

  • 우리는 안전하게 종료하기 위해 context 를 활용할 예정입니다.
  • 우리가 고루틴으로 띄운 프로세스가 자신의 할 일을 모두 완료하고 종료되는 것을 원합니다.
  • print log 를 찍는 간단한 goroutine 을 생성하는 샘플 코드를 짜보겠습니다.

1. go routine 에서 함수 호출


func main() {
go printRoutine()
}

func printRoutine() {
i := 0
isComplete := false
defer func() {
log.Printf("is complete print Routine : %v\n", isComplete)
}()
for {
select {
default:
i++
log.Printf("print Routine : %d\n", i)
isComplete = false
time.Sleep(1 * time.Second)
isComplete = true
}
}
}

우리가 기대하는 것은 isComplete print Routine 에 대한 로그를 출력하고 애플리케이션이 종료하는 것입니다.
위의 printRoutine 을 고루틴으로 띄우면 log 자체를 출력하지 못하고 죽습니다.
왜냐하면 메인쓰레드는 go routine 을 생성하고 바로 종료하도록 되어있기 때문입니다.
이처럼 종료 시그널을 활용하지 않으면 고루틴은 동작도 하지 못하고 종료되게 됩니다.

2. signal 함수를 활용한 애플리케이션 종료

func main() {
go printRoutine()

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

<-quit
}

func printRoutine() {
i := 0
isComplete := false

defer func() {
log.Printf("is complete print Routine : %v\n", isComplete)
}()

for {
select {
default:
i++
log.Printf("print Routine : %d\n", i)
isComplete = false
time.Sleep(1 * time.Second)
isComplete = true
}
}
}

이제 signal 함수를 활용하여 ←quit 을 통해 종료 시그널이 전달될 때까지 메인쓰레드가 기다리게 되었습니다.

그러나 위의 사진과 같이 종료에 대한 log는 출력하지 못하고 죽어버립니다.우리가 원하는 것은 is complete print Routine 이란 메시지를 보고 죽는 것 입니다.

3. context timeout 적용하기 — before

func main() {

go printRoutine(context.Background())

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

<-quit
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

<-ctx.Done()
log.Println("receive sigint signal")
}

func printRoutine(ctx context.Context) {
i := 0
isComplete := false

defer func() {
log.Printf("is complete print Routine : %v\n", isComplete)
}()

for {
select {
case <-ctx.Done():
log.Println("ctx done printRoutine close")
return
default:
i++
log.Printf("print Routine : %d\n", i)
isComplete = false
time.Sleep(1 * time.Second)
isComplete = true
}
}
}

이번에는 위와 같이 context timeout 을 활용해보겠습니다.
5초동안 프린트 로그를 출력하게 한 후에 종료시그널을 보내보겠습니다.

출력을 보면 1~5까지 프린트를 출력하다가 종료시그널을 받고 5초 뒤에 종료되는 것을 볼 수 잇습니다.

그런데 이상한 점이 있습니다.

ctx done printRoutine close 라는 로그는 출력이 되지 않았을까요?

생각해보니 우리는 아직도 is complete print Routine 라는 로그도 보지 못했습니다.
이러한 원인은 context.Background() 는 각각 독립적인 context 로서 동작되고 있기 때문입니다.
사실상 ←quit 아래의 ctx, cancel 로 종료 신호는 그 어떠한 고루틴에게도 종료 신호를 전달하지 못했습니다.
고루틴이 5초를 기다리고 종료된 것이 아닌 메인쓰레드가 5초가 지난 다음에 종료된 것입니다.
우리는 사실상 하위 고루틴이 종료되는 것을 기다리지 않은 것입니다.

4. context timeout

func main() {

parentContext := context.Background()

ctx, cancel := context.WithTimeout(parentContext, 5*time.Second)

go printRoutine(ctx)

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

<-quit
cancel()
log.Println("receive sigint signal")
}

func printRoutine(ctx context.Context) {
i := 0
isComplete := false

defer func() {
log.Printf("is complete print Routine : %v\n", isComplete)
}()

for {
select {
case <-ctx.Done():
log.Println("ctx done printRoutine close")
return
default:
i++
log.Printf("print Routine : %d\n", i)
isComplete = false
time.Sleep(1 * time.Second)
isComplete = true
}
}
}

이번에는 위의 코드처럼 메인 쓰레드의 종료는 signal 함수를 통해 종료시그널을 기다리고, 고루틴의 경우에는 timeout context 를 적용시켜 보았습니다.

애플리케이션을 실행하고 5초동안 가만히 있으니까 고루틴은 종료되었습니다.
정상적으로 is compete print Routine : true 프린트까지 출력하고 말이죠
메인 쓰레드는 종료 시그널을 기다리고 있었습니다.
그리고 5초가 지나기전에 종료 시그널을 날리면 5초를 기다리지 않고 바로 종료가 되었습니다.

하위 고루틴의 종료를 기다리지 않고 종료되었습니다.
printRoutine 고루틴의 ←ctx 는 cancel 신호는 받지 않고 timeout 만 받는 것을 알 수 있었습니다.
이를 볼 때 timeout 의 ctx 같은 경우는 DB 자원에 대한 Query 또는 HTTP Request 에 대한 Timeout 을 지정할 때 사용됨을 알 수 있었습니다.

5. context cancel + waitgroup

func main() {

wg := sync.WaitGroup{}
parentContext := context.Background()

ctx, cancel := context.WithCancel(parentContext)

wg.Add(1)
go printRoutine(ctx, &wg)

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

<-quit
log.Println("receive sigint signal")

cancel()
wg.Wait()
log.Println("sub ctx receive sigterm signal")
}

func printRoutine(ctx context.Context, wg *sync.WaitGroup) {
i := 0
isComplete := false

defer func() {
log.Printf("is complete print Routine : %v\n", isComplete)
}()

for {
select {
case <-ctx.Done():
log.Println("ctx done printRoutine close")
wg.Done()
return
default:
i++
log.Printf("print Routine : %d\n", i)
isComplete = false
time.Sleep(1 * time.Second)
isComplete = true
}
}
}

위의 경우에는 wg sync.WaitGroup 함수를 사용해보았습니다.
앞선 사례에서 보면 ←ctx.Done 은 단순히 cancel() 함수 호출에 대한 이벤트 전달만을 하기 때문에 메인쓰레드가 고루틴보다 먼저 죽는 것을 막을 수는 없었습니다.
WaitGroup 함수를 사용하면 wg.Done 이 되기 전까지 wg.wait() 부분에서 기다리기 때문에 모든 고루틴의 종료를 기다릴 수 있습니다.

드디어 고루틴의 종료 후 메인 쓰레드의 종료를 볼 수 있었네요.

결론

  • 우리는 main.go 에서 먼저 부모 context 를 생성해야 합니다.
  • 우리가 고루틴을 생성하게 될 때에는 부모 context 에서 파생된 context 를 생성하여 취소 시그널을 전파 받을 수 있게 해야합니다.
  • 메인 쓰레드는 context.WithCancel() 과 waitgroup 을 활용하여 모든 고루틴이 종료된 이후에 Main 쓰레드가 종료될 수 있도록 해주세요
  • context.WithTimeout() 은 DB 쿼리 작업 또는 HTTP Curl 등과 같이 하나의 요청에 대한 Timeout 을 지정하고 싶을 때 사용합니다.

기타 Context 를 사용할 때 주의사항

  • Context 란 값을 전달할 수 있는 일종의 통로, 커뮤니케이션이다.
  • 하나의 프로세스 (request 등) 내에서 유지해야할 값을 Context 에 담아 전달하고, 필요한 곳에서 Context 에서 값을 꺼내 사용할 수 있다.
  • 하나의 프로세스가 시작되고 종료되었을 때 Context 를 종료시켜야 한다.
  • 부모 Context 가 종료되면 부모 Context 에서 파생된 자식 Context 들도 종료가 된다.
  • 구조체에 Context 를 저장하지 말아야 한다.
  • Context를 필요로 하는 함수에는 첫번째 인자에 ctx 로 명명하여 전달한다.
  • context 전달 시에는 nil 로 전달하지 말고, Context.TODO 로 전달하라
  • Context 는 서로 다른 고루틴에서 동시에 사용하기에 안전하다.
  • 서로 다른 고루틴으로 실행되는 함수들에게 동일한 컨텍스트를 전달할 수 있다.

--

--