Cowork AI는 AI를 활용하여 다양한 제품을 개발하고 있습니다. 많은 관심 부탁드립니다! 둘러보기

Go에서 컨텍스트가 끊겨도 작업을 진행하는 방법

Go에서 사용자가 요청을 도중에 취소해도 작업을 계속 진행하는 방법을 알아봅니다.

절대 중단되어서는 안 되는 작업

종종 사용자가 한 번 요청하면 도중에 작업이 절대 중단되어서는 안 되는 경우들이 있다.

예를 들어, 송금 버튼을 누른 후 사용자가 즉시 웹페이지를 닫아버리는 상황이나, 데이터베이스에 대용량의 데이터를 저장하는 작업 등이 있다.

사실 일반적인 사용자는 이런 행동을 잘 하지 않겠지만, 인터넷 연결이 끊기거나 정전 등의 이유로 작업 도중에 사용자의 요청이 취소되는 경우는 생각보다 흔하다.

API가 도중에 끊기면 어떤 일이 일어나나?

일반적으로 Go에서는 context라는 객체를 사용하여 서버 내 작업의 흐름을 제어한다.
context는 사용자의 요청이 취소되었을 때, 서버 내 작업을 중단시키는 역할을 한다.

예를 들어, net/http에서는 아래와 같이 r.Context() 함수를 통해 요청에 대한 컨텍스트를 전달받을 수 있다.

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	
	// ...
}

예를 들어, 다음과 같이 API가 구성되어 있을 때 도중에 작업이 중단되면 어떻게 될까?

func SomeHandler() http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(10 * time.Second)
        
        slog.InfoContext(r.Context(), "Request received", "method", r.Method, "url", r.URL)
        
        w.Write([]byte("Hello World"))
    })
}
2025/03/19 15:48:42 INFO Starting server port=:8080
2025/03/19 15:49:03 INFO Request received method=GET url=/

놀랍게도, 별다른 문제가 발생하지 않는다.
컨텍스트는 여러 흐름을 제어하기 위해 사용될 뿐, 그 자체로 API를 중간에 강제 종료시키는 기능은 탑재하고 있지 않고, 이러한 기능을 디폴트로 탑재하고 있다면 오히려 문제의 소지가 될 것이다.

그렇다면 컨텍스트가 잘 끊기게 하려면 어떻게 할까? 다음과 같이 코드를 작성해보자.

func SomeHandler() http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        result := make(chan string)
		
        go func() {
            time.Sleep(10 * time.Second)
            result <- "DONE!!"
        }()
		
        select {
        case res := <-result:
            slog.Info("Request completed")
            w.Write([]byte(res))
            return
        case <-r.Context().Done():
            slog.Info("Request cancelled", "err", r.Context().Err())
            return
        }
    })
}

사용자가 10초를 기다려 정상적으로 작업이 완료되면 다음과 같이 로그가 출력된다.

2025/03/19 16:34:06 INFO Request completed

반면, 10초가 가기 전 중간에 컨텍스트가 끊기면 다음과 같이 로그가 출력된다.

2025/03/19 16:35:20 INFO Request cancelled err="context canceled"

이와 같이, 컨텍스트를 이용하여 사용자가 요청을 취소할 경우 중단해야 하는 대부분의 작업에서 ctx의 취지에 맞게 처리할 수 있다.

또한, database/sql 패키지에서도 ctx를 사용하는 QueryContext를 살펴보면, 내부에서 ctx.Done()을 수신하여 에러를 반환하도록 구현되어 있는 것을 확인할 수 있다.

func ctxDriverQuery(ctx context.Context, queryerCtx driver.QueryerContext, queryer driver.Queryer, query string, nvdargs []driver.NamedValue) (driver.Rows, error) {
	if queryerCtx != nil {
		return queryerCtx.QueryContext(ctx, query, nvdargs)
	}
	dargs, err := namedValueToValue(nvdargs)
	if err != nil {
		return nil, err
	}

	select {
	default:
	case <-ctx.Done():
		return nil, ctx.Err()
	}
	return queryer.Query(query, dargs)
}

이처럼 많은 라이브러리 코드에서 메서드에 ..Context(ctx)라는 접미사가 붙거나 WithContext(ctx)와 같은 형태로 ctx를 전달하는 경우가 많다.
이는 이전 포스팅 Go API 서버에서 Request ID를 로깅하는 방법에서처럼 context 내부의 파라미터를 활용하거나, 위의 ctxDriverQuery 메서드와 같이 Done() 채널을 통해 함수가 중간에 종료되는 것을 방지하기 위함이다.

API가 끊기지 않고 계속 진행되도록 하는 방법

설명이 길었지만, Context의 원리를 모르면 아래에서 구현할 예제가 크게 의미 없을 수 있다.
이제 API 요청이 중단되더라도 작업은 계속 진행되도록 구현해보자.

package main

import (
	"context"
	"log"
	"log/slog"
	"net/http"
	"time"
)

func SomeHandler() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		errCh := make(chan error)

		go func() {
			defer cancel()
			errCh <- someFuncNeedContext(ctx)
		}()
		select {
		case err := <-errCh:
			if err != nil {
				slog.Error("Failed to do something", "err", err)
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			w.WriteHeader(http.StatusOK)
			w.Write([]byte("OK"))
		case <-r.Context().Done():
			slog.Info("Request cancelled", "err", r.Context().Err())
			return
		}
	})
}

func someFuncNeedContext(ctx context.Context) error {
	time.Sleep(3 * time.Second)
	slog.InfoContext(ctx, "Doing something")
	return nil
}

func main() {
	mux := http.NewServeMux()
	mux.Handle("GET /", SomeHandler())

	port := ":8080"
	slog.Info("Starting server", "port", port)
	if err := http.ListenAndServe(port, mux); err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

someFuncNeedContextctx가 필요한 (중간에 종료 처리를 고려한) 함수를 가정한 것이다.
이 함수는 3초가 걸리는 작업을 수행한다.
주목할 점은, 해당 함수에 전달되는 ctxr.Context()가 아닌, 별도로 생성한 context.WithTimeout(context.Background(), 5*time.Second)로 만든 Context 임을 알 수 있다.

즉, API의 요청과 관련된 r.Context()가 취소되어 Done() 신호가 수신되더라도, 별도로 생성한 ctx 덕분에 중간 작업은 계속 진행될 수 있다.

이 코드를 실행시키고 요청 후 바로 연결을 끊으면 다음과 같은 로그가 출력된다.

2025/03/19 17:06:39 INFO Starting server port=:8080
2025/03/19 17:06:45 INFO Request cancelled err="context canceled"
2025/03/19 17:06:46 INFO Doing something

이처럼 Context를 분리하여 사용하면, API 요청이 취소되더라도 중간에 종료되어서는 안 되는 작업은 안전하게 수행할 수 있다.

비동기 API

사실 이 방법은 API 자체를 동기로 유지할 때 주로 사용된다. 이 외에도 API 자체를 비동기로 구성하는 방법도 있다.

이 경우 핸들러는 202 (Accepted)와 같은 상태 코드만 반환하기 때문에 클라이언트가 정상/비정상 종료되었음을 알기 위해 추가적인 데이터 설계와 알림 시스템 등이 필요하다.

해당 내용은 이 포스팅에서 다루기에는 방대하므로, 이후 포스팅에서 다루도록 하겠다.

Cookies
essential