Go Context를 사용하여 다운스트림 작업을 우아하게 종료하기
Min-jun Kim
Dev Intern · Leapcell

소개
현대의 마이크로서비스 아키텍처와 동시성 애플리케이션에서는 작업 수명 주기를 제어하는 것이 무엇보다 중요합니다. 사용자가 브라우저 탭을 닫거나, 클라이언트가 연결을 끊거나, 오래 실행되는 백그라운드 작업이 불필요해질 수 있습니다. 이러한 시나리오에서 진행 중인 데이터베이스 쿼리 또는 gRPC 호출이 불필요하게 완료되도록 허용하면 리소스가 소모되고 지연 시간이 늘어나며, 심지어 오래된 데이터나 원치 않는 부작용이 발생할 수도 있습니다. Go의 context 패키지는 고루틴 경계를 넘어 취소 신호를 전파하는 강력하고 관용적인 메커니즘을 제공하여 이러한 다운스트림 호출을 깔끔하게 종료할 수 있는 솔루션을 제공합니다. 이 글에서는 데이터베이스 상호 작용 및 gRPC 통신을 위한 우아한 취소를 달성하기 위해 context를 효과적으로 활용하는 방법을 자세히 살펴보고, 애플리케이션이 응답성이 뛰어나고 리소스 효율적으로 작동하도록 보장합니다.
핵심 개념 이해
구현 세부 사항을 자세히 살펴보기 전에 Go의 취소 메커니즘을 뒷받침하는 핵심 개념을 간략하게 정의해 보겠습니다.
context.Context: 이 인터페이스는 API 경계 및 고루틴 간에 데드라인, 취소 신호 및 기타 요청 범위 값을 전달합니다. 불변 값이므로 새 컨텍스트는 기존 컨텍스트에서 파생됩니다. 부모 컨텍스트에 취소 신호가 전송되면 자동으로 모든 파생된 자식에게 전파됩니다.- 취소 신호: 작업이 중지되어야 한다는 알림입니다. 일반적으로
context.WithCancel에서 반환된cancel함수를 호출하거나context.WithTimeout또는context.WithDeadline컨텍스트가 만료될 때 트리거됩니다. - 고루틴 누수: 고루틴이 (데이터베이스 쿼리와 같은) 작업을 시작했는데 상위 컨텍스트가 취소될 때 명시적으로 중지하지 않으면 해당 고루틴은 영원히 또는 작업이 자연스럽게 완료될 때까지 계속 실행되어 불필요하게 리소스를 계속 보유할 수 있습니다. 이를 고루틴 누수라고 합니다.
- 멱등성: 컨텍스트와 직접적인 관련은 없지만 중요한 고려 사항입니다. 작업을 취소할 때 해당 작업이 이미 데이터를 수정했다면 후속 재시도 또는 부분 완료 시 일관성 없는 상태가 발생할 수 있습니다. 가능한 경우 멱등성이 있도록 작업을 설계하십시오.
원칙에 따른 취소
context 패키지는 I/O 또는 기타 장기 실행 작업을 포함하는 함수의 첫 번째 인수로 전달되도록 설계되었습니다. 이를 통해 통화 스택 아래로 취소 신호가 흐를 수 있습니다.
데이터베이스 쿼리 취소
대부분의 최신 Go 데이터베이스 드라이버, 특히 database/sql의 context-aware 메서드를 준수하는 드라이버는 기본적으로 컨텍스트 기반 취소를 지원합니다.
웹 핸들러가 데이터베이스 쿼리를 시작하는 일반적인 시나리오를 고려해 보겠습니다.
package main import ( "context" "database/sql" "fmt" "log" "net/http" "time" _ "github.com/go-sql-driver/mysql" // 데이터베이스 드라이버로 바꾸세요 ) // simulateDBQuery는 장기 실행 데이터베이스 쿼리를 시뮬레이션합니다. func simulateDBQuery(ctx context.Context, db *sql.DB) (string, error) { // 실제 쿼리는 db.QueryRowContext(ctx, "SELECT some_data FROM some_table WHERE id = ?", someID).Scan(&result)와 같은 것이 될 것입니다. // 데모를 위해 시간을 소모하는 모의 문을 사용합니다. log.Println("데이터베이스 쿼리 시작...") select { case <-time.After(5 * time.Second): // 5초간 데이터베이스 작업을 시뮬레이션합니다. log.Println("데이터베이스 쿼리 완료.") return "some_data_from_db", nil case <-ctx.Done(): log.Printf("데이터베이스 쿼리 취소됨: %v\n", ctx.Err()) return "", ctx.Err() // 컨텍스트 오류를 반환합니다. } } func handler(w http.ResponseWriter, r *http.Request) { // r.Context()는 클라이언트가 연결을 끊으면 취소되는 요청 컨텍스트를 제공합니다. ctx := r.Context() // 여기에서 데이터베이스 작업에 대한 특정 제한 시간을 추가할 수 있습니다. // ctx, cancel := context.WithTimeout(ctx, 3*time.Second) // defer cancel() data, err := simulateDBQuery(ctx, nil) // 실제 앱에서는 실제 *sql.DB 객체를 전달하세요. if err != nil { if err == context.Canceled { http.Error(w, "요청 취소됨", http.StatusRequestTimeout) // 또는 499 클라이언트 연결 끊김 return } log.Printf("요청 처리 중 오류 발생: %v", err) http.Error(w, "내부 서버 오류", http.StatusInternalServerError) return } fmt.Fprintf(w, "DB에서 데이터: %s\n", data) } func main() { http.HandleFunc("/", handler) log.Println("서버 시작됨: :8080. 클라이언트에서 CTRL+C를 눌러 요청을 취소하거나 브라우저를 닫아 보세요.") log.Fatal(http.ListenAndServe(":8080", nil)) }
실제 시나리오에서 database/sql의 db.QueryRowContext(ctx, ...) 또는 stmt.ExecContext(ctx, ...)를 사용하는 경우, 기본 드라이버는 일반적으로 ctx.Done()을 모니터링합니다. 클라이언트가 연결을 끊으면 r.Context()가 취소되고, 이는 데이터베이스 작업으로 전파됩니다. simulateDBQuery는 이러한 원칙을 보여줍니다. ctx.Done()을 수신 대기하는 select 문이 있어 견고한 드라이버가 차단 작업을 중단하는 방식을 모방합니다.
gRPC 호출 취소
gRPC는 Protocol Buffers 및 HTTP/2를 기반으로 구축되어 context를 최우선으로 지원합니다. 클라이언트 및 서버 측의 모든 gRPC 메서드는 첫 번째 인수로 context.Context를 사용합니다.
클라이언트 측 취소:
package main import ( "context" "log" "time" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" pb "your-project/your_proto_package" // 생성된 proto 패키지로 바꾸세요. ) func callGRPCService(client pb.YourServiceClient, ctx context.Context) { // gRPC 호출에 제한 시간을 설정합니다. timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() // 중요: 호출 후 리소스를 해제합니다. log.Println("gRPC 호출 시작...") resp, err := client.DoSomething(timeoutCtx, &pb.SomeRequest{ // ... 요청 필드를 채웁니다 ... }) if err != nil { st, ok := status.FromError(err) if ok && st.Code() == codes.Canceled { log.Println("gRPC 호출이 클라이언트 측 제한 시간으로 인해 취소되었습니다.") return } log.Printf("gRPC 호출 실패: %v", err) return } log.Printf("gRPC 호출 성공: %v", resp) } // 메인 또는 호출 함수에서: func main() { conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure()) if err != nil { log.Fatalf("연결되지 않음: %v", err) } defer conn.Close() client := pb.NewYourServiceClient(conn) // 취소될 수 있는 상위 컨텍스트를 시뮬레이션합니다. parentCtx, parentCancel := context.WithCancel(context.Background()) defer parentCancel() // 상위 컨텍스트로 gRPC 서비스를 호출합니다. go func() { time.Sleep(1 * time.Second) // 취소 전에 일부 작업을 시뮬레이션합니다. log.Println("상위 컨텍스트 취소 중...") parentCancel() }() callGRPCService(parentCtx, client) // 출력을 확인할 시간을 줍니다. time.Sleep(3 * time.Second) }
여기서 클라이언트 측의 context.WithTimeout은 gRPC 서버가 응답에 너무 오래 걸리는 경우 클라이언트가 자동으로 요청을 취소하도록 보장합니다. 들어오는 컨텍스트를 존중하는 (Go에서 모든 정상적인 gRPC 서버가 그렇듯이) 서버는 이 취소 신호를 받게 됩니다.
서버 측 처리:
gRPC 서버 측에서 context는 서비스 메서드에 첫 번째 인수로 자동으로 전달됩니다.
package main import ( "context" "fmt" "log" "net" "time" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" pb "your-project/your_proto_package" // 생성된 proto 패키지로 바꾸세요. ) // server는 your_proto_package.YourServiceServer을 구현하는 데 사용됩니다. type server struct { pb.UnimplementedYourServiceServer } // DoSomething는 your_proto_package.YourServiceServer을 구현합니다. func (s *server) DoSomething(ctx context.Context, in *pb.SomeRequest) (*pb.SomeResponse, error) { log.Println("gRPC 요청 수신. 장기 실행 작업 시뮬레이션 중...") select { case <-time.After(5 * time.Second): // 5초간 작업 시뮬레이션 log.Println("서버 처리 완료.") return &pb.SomeResponse{ // ... 응답 필드를 채웁니다 ... }, nil case <-ctx.Done(): log.Printf("서버 취소 신호 수신: %v\n", ctx.Err()) return nil, status.Error(codes.Canceled, "클라이언트 요청 취소 또는 제한 시간 초과로 인한 서버 작업 취소") } } func main() { lis, err := net.Listen("tcp", ":50051") if err != nil { log.Fatalf("듣기 실패: %v", err) } ss := grpc.NewServer() pb.RegisterYourServiceServer(ss, &server{}) log.Printf("서버 주소: %v\n", lis.Addr()) if err := ss.Serve(lis); err != nil { log.Fatalf("서비스 실패: %v", err) } }
서버의 DoSomething 메서드는 명시적으로 ctx.Done()을 확인합니다. 클라이언트 측 컨텍스트 (제한 시간 또는 명시적 취소로 인해)가 취소되면 서버는 이를 감지하고 장기 실행 작업을 중지한 후 적절한 오류를 반환합니다. 이는 서버가 불필요한 작업을 수행하는 것을 방지하고 리소스를 해제합니다.
컨텍스트 체인
컨텍스트가 트리 구조를 형성한다는 것을 이해하는 것이 중요합니다. 상위 컨텍스트에서 새 컨텍스트를 파생할 때 (context.WithTimeout(parentCtx, ...)와 같은), 상위 컨텍스트의 취소는 자동으로 하위 컨텍스트를 취소합니다. 이를 통해 계층적 취소가 가능합니다. 예를 들어, 웹 요청의 컨텍스트는 gRPC 호출의 컨텍스트에 대한 부모 역할을 할 수 있으며, 이는 데이터베이스 쿼리의 컨텍스트에 대한 부모 역할을 할 수 있습니다.
func handleRequest(w http.ResponseWriter, r *http.Request) { // HTTP 서버에서 요청 컨텍스트 clientReqCtx := r.Context() // 전체 작업 체인에 대한 제한 시간을 추가합니다. opCtx, opCancel := context.WithTimeout(clientReqCtx, 5*time.Second) defer opCancel() // opCtx와 함께 gRPC 호출 수행 grpcResponse, err := makeGRPCCall(opCtx, "some_data") // opCtx를 gRPC 호출에 전달 if err != nil { // 오류 처리, opCtx.Done()이 원인인지 확인 http.Error(w, "gRPC 호출 실패", http.StatusInternalServerError) return } // gRPC 응답에 따라 DB 쿼리를 수행할 수 있습니다. dbData, err := makeDBQuery(opCtx, grpcResponse) // DB 쿼리에 opCtx 전달 if err != nil { // 오류 처리, opCtx.Done()이 원인인지 확인 http.Error(w, "DB 쿼리 실패", http.StatusInternalServerError) return } fmt.Fprintf(w, "결합된 데이터: %s", dbData) }
이 예제에서 HTTP 클라이언트가 연결을 끊어 clientReqCtx를 취소하거나 opCtx에 대한 5초 제한 시간이 만료되면 makeGRPCCall과 makeDBQuery 모두 취소 신호를 받게 됩니다.
결론
취소 신호를 관리하는 데 Go의 context 패키지를 사용하는 것은 강력하고 효율적이며 응답성이 뛰어난 애플리케이션을 구축하는 데 필수적인 관행입니다. 데이터베이스 쿼리 및 gRPC 호출과 같은 모든 다운스트림 작업에 context를 전달함으로써 깔끔한 종료를 가능하게 하고, 리소스 누수를 방지하며, 시스템의 전반적인 복원력을 향상시킵니다. 컨텍스트 인식 프로그래밍을 수용하면 Go 애플리케이션이 정상적으로 작동하고 동시 요청 및 분산 시스템의 동적인 특성을 우아하게 처리할 수 있습니다.