~Go Context 시리즈~
[1] Context에 관한 고찰 - 1 : Context 란
[2] Context에 관한 고찰 - 2 : Context의 중요성과 고루틴 누수
[3] Context에 관한 고찰 - 3 : Graceful Shutdown의 미학
이전 시리즈에서 우리는 Context가 무엇이고 Context를 이용해 개별 작업의 생명 주기를 어떻게 제어해야 좀비 고루틴을 방지할 수 있는지 살펴보았다. 이제 마지막으로, Context가 애플리케이션 전체의 생명주기를 어떻게 관장하며 ~우아한 종료~ 라는 현대 서버 애플리케이션의 필수 덕목을 구현하는지 살펴보려고 한다. 우아한 종료는 단순히 서버를 끄는 행위를 넘어, 시스템의 안정성과 데이터의 정합성을 보장하는 섬세한 오케스트라 지휘와 같다. 그리고 그 지휘봉의 역할을 하는 것이 바로 Context 이다. (혹시 몰라 다시금 언급하자면, 이 시리즈는 모두 AI 에이전트들로부터 작문의 도움을 받았다. 딱봐도 비유가 문학도 같지 않은가! 개발자 머리에서는 나오기 힘든 표현법이다! ㅋㅋㅋ)
Graceful Shutdown(우아한 종료)란?
우리가 서버 개발을 하다보면 간혹 '502 Bad Gateway'나 '504 Gateway Timeout'과 같은 에러 메세지를 마주하게 된다. 이 에러들은 단순히 서버에 문제가 있다는 신호를 넘어, 서버가 클라이언트의 요청을 어떻게 다루고 있는지를 보여주는 중요한 단서다. 특히, 서버 배포나 스케일링 과정에서 이런 에러가 빈번하게 발생한다면, 이는 서버가 '우아하게' 종료되지 않고 '강제' 종료 되고 있다는 증거일 수 있다.
일반적인 강제 종료는 kill -9 명령어나 Ctrl+C를 통해 프로세스를 즉시 중단시키는 방식이다. 그에 반해 Graceful shutdown 즉, 우아한 종료는 프로그램이 종료될 때, 진행 중인 작업을 모두 완료하고, 리소스를 깔끔하게 정리한 후 종료하는 방식을 말한다. 컴퓨터를 사용할 때 전원 버튼을 눌러서 갑자기 꺼버리는 것과 시작 메뉴에서 '종료'를 선택해서 정상적으로 끄는 것의 차이와 같다고 생각하면 된다.
🔸 왜 우아하게 종료해야할까?
과거의 모놀리식 서버는 한번 켜두면 수개월씩 재시작 없이 운영되기도 했다. 하지만 컨테이너와 오케스트레이션(쿠버네티스, ECS 등)이 지배하는 클라우드 네이티브 환경에서 애플리케이션의 '종료'는 더 이상 예외적인 이벤트가 아닌 일상적인 프로세스가 되었다. 이러한 환경에서는 개발자들이 새로운 기능을 추가하거나 버그를 수정하기 위해 구버전의 서버 프로세스를 내리고 새로운 버전의 프로세스를 올리는 작업(배포)은 수시로 일어난다. 뿐만 아니라, 트래픽 증가에 따른 오토스케일링으로 인한 인스턴스 생성과 삭제, 비용 절약을 위한 스케일 다운, 노드 장애 시 자동 복구를 위한 파드(pod) 재시작, 그리고 리소스 최적화를 위한 파드 재배치(rescheduling) 등이 자동으로, 그리고 빈번하게 발생한다. 결과적으로 현대의 클라우드 네이티브 애플리케이션에서 '종료'는 언제든지 일어날 수 있는 정상적인 운영 상황이 되었으며, 이에 대비한 graceful shutdown 구현은 선택이 아닌 필수가 되었다.
위의 상황에서 오케스트레이터는 컨테이너에게 SIGTERM 신호를 보내 "이제 곧 종료될테니 준비하라"고 알린다. 만약 애플리케이션이 이 신호를 무시하고 즉시 꺼져버린다면 어떤 결과가 발생할까?
- 진행 중인 요청 유실 (ex. 사용자가 막 결제 버튼을 누른 순간, 해당 요청을 처리하던 파드가 사라진다면? 사용자는 결제가 된 것인지 아닌지 알 수 없고, 데이터는 비정합 상태에 빠질 수 있음.)
- 백그라운드 작업 중단 (ex. DB에서 대량의 데이터를 읽어와 캐시를 갱신하던 작업이 중간에 끊긴다면 캐시는 이전 상태로 남게 됨.)
- 리소스 누수 (ex. 사용하던 DB 커넥션을 반납하지 않고 종료되면, 제한된 커넥션 풀이 서서히 고갈됨.)
이는 사용자 경험에도 영향을 미치는데 클라이언트는 갑작스러운 연결 끊김으로 인해 에러를 받게 되기 때문이다.
따라서 이 SIGTERM 신호를 받았을 때, 서버가 우아하게 종료되도록 구현하면, 종료 신호를 받으면 즉시 새로운 요청을 받는 것을 중단하고, 현재 진행 중인 모든 작업이 완료될 때까지 기다린다. 예를 들어, 처리 중인 HTTP 요청들이 모두 응답을 보낼 때까지 대기하고, 데이터베이스 트랜잭션이 완료되거나 롤백될 때까지 기다린다. 이후 데이터베이스 연결을 닫고, 파일 핸들을 정리하며, 고루틴들을 안전하게 종료한 다음 프로그램을 종료한다. 그래야 우리는 인프라의 동적인 변화 속에서도 서비스의 안정성과 데이터 무결성을 지킬 수 있다.
아키텍처 설계
우아한 종료를 구현하기 위한 핵심 아키텍처는 '생명 주기의 중앙 제어'와 'Context를 통한 신호 전파'다. 이를 위해 우리는 먼저 Context가 두 가지 다른 목적와 생명주기를 가지고 사용된다는 점을 이해해야한다.
🔸 Context의 목적 구분
1. 애플리케이션 생명주기 컨텍스트(Application Lifecycle Context)
- 목적: 애플리케이션과 생명을 같이하는 백그라운드 고루틴들(ex. 주기적인 캐시 갱신, DB 모니터링, 메세지 큐 subscribe 등)의 시작과 종료 관리
- 생명주기: 애플리케이션이 시작될 때 단 한번 생성되고, 애플리케이션이 종료될 때 단 한번 취소
- 공유 범위: 모든 백그라운드 서비스 컴포넌트가 반드시 이 동일한 컨텍스트 인스턴스를 공유해야함. 왜냐하면 그 컨텍스트가 종료 신호 전파를 위한 통로이기 때문.
2. 요청 범위 컨텍스트(Request-Scoped Context)
- 목적: 개별 API 요청(ex. 결제, 사용자 정보 조회)의 타임 아웃, 데드라인, 그리고 클라이언트의 연결 중단으로 인한 취소 처리
- 생명주기: API 요청이 들어올 때마다 새로 생성되고, 해당 요청에 대한 응답이 나가면 소멸
- 공유 범위: 각 API 요청마다 고유한 컨텍스트가 생성되며, 핸들러 -> 서비스 -> 데이터 접근 계층으로 전달된다. 이 컨텍스트들은 완전히 독립적
🔸 설계 원칙
그렇다면 애플리케이션 생명주기 컨텍스트는 누가 만들고 통제해야 할까?
바로 애플리케이션 전체의 시작과 종료를 책임지는, 애플리케이션의 메인 로직을 담고 있는 최상위 구조체다. (아래 예제에서는 App)
애플리케이션이 시작될 때 최상위 부모 context를 생성하고, 모든 백그라운드 작업을 이 context 위에서 실행한다. 부모 context는 애플리케이션 전체의 생명주기를 위해, 자식 context는 개별 작업의 제어를 위해 사용된다. 위에서 말한 Application Lifecycle context가 부모 context, Request-Scoped Context가 자식 context의 목적이라고 할 수 있다.
운영체제로부터 종료 신호(SIGINT, SIGTERM)를 받으면, 애플리케이션은 이 최상위 context의 cancel() 함수를 단 한번 호출한다.
이 취소 신호는 부모-자식 관계를 따라 애플리케이션의 모든 백그라운드 고루틴에 전파되고, 신호를 받은 고루틴들은 진행 중인 작업을 마무리하고, 루프를 탈출하며, 리소스를 반납한다. 만약 하위 컴포넌트들이 자체적으로 context를 만들어버리면, App은 그 컴포넌트의 생명주기를 제어할 방법을 잃게 된다. 즉, App은 컴포넌트가 언제 시작됐고 언제 종료되는지 알 수 없게 되어, 결국 우아한 종료가 불가능해진다.
올바른 context 설계는 App에서 생성한 생명주기 context를 모든 하위 컴포넌트에 의존성 주입(Dependency Injection)으로 전달하는 것이다. 그러면 분산된 수많은 동시성 작업들의 생명주기를 context를 통해 중앙에서 통제하고, 예측 가능하며, 안전한 방식으로 시스템을 종료시킬 수 있다.
전파 규칙
참고로 context가 전파되는 방식은 다음과 같다.
- 취소는 위에서 아래로만 전파된다.
부모 context가 취소되면, 그 자식 context들도 모두 즉시 취소 된다.
그리고 자식 context의 취소는 부모에게 영향을 끼치지 않는다. 예를 들어, API 요청 하나가 타임아웃(자식 context) 되어도, 애플리케이션 전체(부모 context)는 계속 정상적으로 동작한다. - 값은 위에서 아래로만 보인다.
자식 context는 부모가 가진 값을 볼 수 있지만, 부모는 자식이 WithValue로 추가한 값을 볼 수 없다.
🔸 주의 사항
추가로 주의해야할 사항들이 있다.
- context를 구조체 필드에 저장하는 것은 지양해야한다.
Context는 항상 함수의 첫번째 인자로 명시적으로 전달해야한다. 이는 함수의 의존성을 명확하게 하고 context의 범위가 섞이는걸 방지한다. - context.WithValue 사용을 최소화해야한다.
요청 범위의 필수적인 정보(인증 정보, Trace ID 등) 외에는 사용을 지양해야 한다. 함수의 의존성을 숨겨 코드를 이해하기 어렵게 만들기 때문이다. 필요한 값은 명시적인 파라미터로 전달하는 것이 좋다.
아키텍처 예시
1단계: 최상위 구조체
type App struct {
// ... 다른 컴포넌트들
ctx context.Context
cancel context.CancelFunc
}
func NewApp(cfg *config.Config) *App {
// 1. 애플리케이션 전체의 생명주기를 관장할 최상위 부모 컨텍스트를 생성한다.
ctx, cancel := context.WithCancel(context.Background())
app := &App{ ctx: ctx, cancel: cancel /*...*/ }
// 2. Repositories 초기화 시, 애플리케이션의 메인 컨텍스트(ctx)를 주입한다.
app.Repositories, err = repository.NewRepositories(app.ctx, /*...*/)
// ... 그외 컴포넌트들도 마찬가지
return app
}
func (a *App) Run() {
// HTTP 서버 실행
go func() { /*...*/ }()
// OS로부터 종료 시그널 대기
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
// a.cancel()이 호출될 때까지 대기
<-c
// 종료 시그널 받으면 stop 프로세스 시작
a.stop()
}
func (a *App) stop() {
// 1.. 모든 백그라운드 작업에 취소 신호를 전파
a.cancel()
// 2. 개별 컴포넌트 리소스 정리
a.Repositories.Close()
// ...
}
main.go 나 app.go 등 서버의 시작부분에 주로 선언되는 App은 애플리케이션 전체의 생명주기를 책임지는 최상위 구조체다.
메인 Context가 생성되고 Repositories, Middleware, Cache, HTTP Router 등 모든 하위 컴포넌트들은 이 context의 생명주기 아래에서 동작을 시작한다. 백그라운도 고루틴들도 이 context의 자식 context를 이용해서 실행시킨다.
운영체제로부터 종료 신호(ex. ctrl+c)를 받으면, App은 자신이 알고 있는 모든 하위 컴포넌트들에게 '이제 안전하게 종료하라'는 명령을 한번에 전파한다. 여기서는 직접 stop() 메소드를 호출하는 방식으로 했으나 defer를 통해 구현해도 괜찮을 것 같다.
2단계: 개별 컴포넌트
// repository/root.go
func NewRepositories(ctx context.Context, /*...*/) (*Repositories, error) {
// ...
for _, repoInit := range initializers {
// repo.Start()에 app의 메인 컨텍스트를 전달하여 백그라운드 작업을 시작
if err := repo.Start(ctx); err != nil {
return nil, err
}
}
// ...
}
// repository/someService/database.go
func (d *database) StartBackgroundTasks(ctx context.Context) {
go d.runCacheLoop(ctx) // 주입받은 ctx를 그대로 전달
}
func (d *database) runCacheLoop(ctx context.Context) {
for {
select {
case <-ctx.Done(): // App의 cancel() 호출에 반응
return
// ...
}
}
}
개별 컴포넌트들은 이들은 자신의 생명주기를 스스로 결정하지 않고, 최상위 구조체로부터 주입 받은 context에 따라 움직이며, 백그라운드 고루틴 등을 실행하며 작업을 진행한다. 별도로 timeout 등이 필요하다면 context를 상속해서 사용해도 괜찮지만 과도한 체이닝은 삼가해야한다.
go 백그라운드 작업의 정석 패턴인 for-select 패턴을 사용해서 주기적인 작업(<-ticker.C)와 취소 신호(<-ctx.Done())을 동시에 기다리도록 했다. 만약 상위 고루틴이 존재할 경우, ctx.Done()이 감지되었을 때 단순히 return하는 대신 ctx.Err()를 반환하여 이 고루틴이 '정상 종료'가 아닌 '취소로 인한 종료'임을 명확히 알려주는 것이 좋다.
시리즈를 마치며
정리하고 싶었던 context에 관한 시리즈 연재를 성공했다.(엄청 거창한 시리즈는 아니었지만..ㅋㅋ) 나는 golang을 현재 회사 업무때문에 처음 배우게 되었는데, 회사 레거시 코드들이 context 관리가 제대로 되어 있지 않아 나 역시 context의 중요성을 깨닫는데 좀 시간이 걸렸다. 한번 중요성을 깨닫고 나니 레거시 코드들을 다 뒤엎고 싶어져서 지금 하나하나 리팩토링해나가고 있는 중이다. 그 중 몇개는 알 수 없는 메모리릭 현상이 계속 발생하고 있는데 이 context 관련한 리팩토링을 거치면 증상이 개선되기를 기대하고 있다. 만약 성공한다면 또 다른 메모리릭 해결기에 관해서 포스트를 새롭게 작성해 보겠다.
이 시리즈를 통해 context가 go의 동시성 모델 위에 질서와 제어라는 견고한 구조물을 세우는 핵심 설계 도구임을 알 수 있었다. 좀비 고루틴으로부터 시스템 리소스를 보호하는 역할부터, 502 Gateway 에러를 방지하고 전체 애플리케이션의생명주기를 지휘하는 역할까지, context는 모든 단계에 관여한다는 것을 배웠다.
결국 잘 설계된 context의 흐름은 잘 설계된 어플리케이션의 흐름과 같다. 중앙에서 시작된 생명주기가 모든 컴포넌트에 일관되게 전파되고, 각 컴포넌트는 자신의 책임에 맞게 반응하며, 예기치 않은 상황에서는 안전하게 작업을 중단한다. context.Background()의 무분별한 사용을 경계하고, 애플리케이션의 생명주기와 요청 범위를 명확히 인지하여 사용하는 것. 이것은 우리가 더 안정적이고, 예측 가능하며 우아한 Go 서버를 만들어나가는 여정의 첫걸음이 될 것이다.
개발자임에도 이런 문학 감성 가득한 비유를 곁들어서 문장을 쓸 수 있도록 도와준 gemini, claude 그리고 gpt에게 소소한 감사의 인사를 남긴다. 나는 편집장, AI 에이전트들은 부원(?)의 입장으로서 좀 정이 들어서 언급을 해주고 싶었다..ㅋㅋㅋ
'Language > Go' 카테고리의 다른 글
[Go] Context에 관한 고찰 - 2 : Context의 중요성과 메모리 누수 (0) | 2025.07.24 |
---|---|
[Go] WaitGroup vs ErrGroup 비교 (0) | 2025.07.23 |
[Go] Context에 관한 고찰 - 1 (0) | 2025.07.11 |
[Go] field 이름을 기준으로 field 초기화하기 with reflect (0) | 2024.11.08 |
[Go] Decimal decoder : Decimal을 MongoDB에 바로 String으로 저장 (1) | 2024.06.10 |