Devin.KR

Go · 심화

동시성과 서버 설계로 깊어지는 Go

context 로 취소 전파하기

WithCancel·WithTimeout·WithDeadline, 취소 신호를 받는 select, context 를 첫 인자로 넘기는 관례, 요청 범위 값의 올바른 쓰임

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 파이프라인, 팬아웃, 워커 풀을 만들었다. 그 구조들은 일을 시작하는 방법은 갖췄지만 일을 멈추는 방법은 아직 부실하다. 손님이 주문을 취소하거나, 배달원 배정이 너무 오래 걸리거나, 서버가 종료되면 이미 출발한 고루틴들이 한꺼번에 손을 놓아야 한다. 이 장은 그 신호를 호출 사슬 전체에 전달하는 표준 수단인 context 패키지를 다룬다.

  • WithCancel, WithTimeout, WithDeadline 의 차이를 구분하고 알맞은 것을 고른다.
  • select 로 취소 신호와 작업 완료를 동시에 기다리는 코드를 작성한다.
  • context 를 함수의 첫 번째 인자로 넘기는 관례와 그 이유를 설명한다.
  • 요청 범위 값(request-scoped value)을 안전한 키와 접근 함수로 다룬다.
  • HTTP 핸들러에서 r.Context() 를 기점으로 마감과 취소를 이어 붙인다.

문제 상황

중계 서비스는 주문 하나를 받으면 가까운 배달원을 찾는 배정 단계를 호출한다. 배정 단계는 외부 위치 조회 서버에 묻기 때문에 평소 수 밀리초지만 가끔 수 초가 걸린다. 손님은 기다리다 앱을 닫고, 서버는 닫힌 연결을 향해 계속 배정 작업을 돌린다. 이런 작업이 쌓이면 고루틴과 연결이 줄지 않고 늘기만 한다.

해결하려면 세 가지가 필요하다. 첫째, 호출자가 "이제 그만"이라고 말할 수 있어야 한다. 둘째, 말하지 않아도 시간이 다 되면 스스로 멈춰야 한다. 셋째, 그 신호가 호출 사슬의 맨 아래 고루틴까지 닿아야 한다. 채널을 직접 만들어 done 채널을 모든 함수에 끼워 넣을 수도 있지만, 함수마다 모양이 달라지고 마감 시각이나 오류 원인을 실어 나를 수 없다. context.Context 는 이 요구를 하나의 인터페이스로 묶는다.

Context 의 기본 구조

Context 는 네 가지 메서드를 가진 인터페이스다. Done 은 취소되면 닫히는 채널을 돌려주고, Err 는 닫힌 이유를 알려 주며, Deadline 은 마감 시각이 있으면 그 값을, Value 는 키에 묶인 값을 돌려준다. 이 장에서 쓰는 대부분의 동작은 Done 과 Err 두 가지로 설명된다.

컨텍스트는 나무를 이룬다. context.Background() 가 뿌리이고, WithCancel 같은 함수를 부를 때마다 부모에서 자식이 갈라진다. 부모가 취소되면 모든 자손이 취소되고, 자식만 취소하면 부모와 형제는 영향을 받지 않는다. 아래 그림이 이 방향성을 보여 준다.

취소는 부모에서 자손으로만 퍼지고 자식의 취소는 부모에 닿지 않는다.

세 가지 파생 함수

WithCancel 은 cancel 함수를 돌려주며 호출한 순간 취소된다. WithTimeout 은 지금부터 얼마 뒤라는 상대 시간으로, WithDeadline 은 몇 시 몇 분이라는 절대 시각으로 마감을 정한다. WithTimeout(parent, d) 는 내부에서 WithDeadline(parent, time.Now().Add(d)) 를 부르는 것과 같다. 세 함수 모두 취소 함수를 돌려주고, 마감이 먼저 와도 이 함수를 호출하는 것이 규칙이다. 호출하면 타이머와 부모에 걸어 둔 등록이 즉시 해제된다.

파생 함수별 마감 지정 방식과 취소 후 Err 값
함수마감 지정마감으로 닫힌 뒤 Errcancel 로 닫힌 뒤 Err
WithCancel없음해당 없음context.Canceled
WithTimeout지금부터 dcontext.DeadlineExceededcontext.Canceled
WithDeadline절대 시각 tcontext.DeadlineExceededcontext.Canceled

부모의 마감이 자식이 요청한 마감보다 이르면 자식은 부모의 마감을 그대로 따른다. 자식이 마감을 늘려서 부모를 넘어설 수는 없다. 완성 코드의 세 번째 실험이 이 성질을 확인한다.

취소 신호를 받는 select

Done 채널은 값을 보내지 않고 닫기만 한다. 닫힌 채널은 수신이 언제나 즉시 성공하므로, select 의 한 갈래에 case <-ctx.Done(): 을 두면 "취소되면 이 갈래가 선택된다"는 의미가 된다. 나머지 갈래에는 실제 작업의 완료를 둔다.

time.Sleep 으로 기다리는 코드는 이 구조를 만들 수 없다. 잠든 고루틴은 취소 신호를 볼 수 없어 약속한 시간을 끝까지 채운다. 반면 타이머 채널과 Done 을 select 로 묶으면 둘 중 먼저 오는 쪽에서 즉시 깨어난다. 아래 그림은 같은 시점에 취소가 들어왔을 때 두 방식의 차이를 나타낸다.

select 는 취소 시점에 바로 반환하지만 time.Sleep 은 끝까지 기다린다.

반복문 안에서 일하는 고루틴도 같은 원리다. 앞 장의 생산자처럼 채널에 값을 보내는 곳에는 보내기와 Done 을 select 로 묶는다. 수신자가 떠난 뒤 보내기에서 영원히 막힌 고루틴이 남는 것을 이 갈래가 막아 준다.

취소를 확인한 뒤 돌려줄 오류는 ctx.Err() 를 그대로 쓴다. 호출자는 errors.Is 로 Canceled 와 DeadlineExceeded 를 구분해 대응할 수 있다. 오류를 감싸는 법은 앞서 오류 설계에서 다룬 방식을 따르면 된다.

context 를 넘기는 관례

표준 라이브러리와 거의 모든 외부 패키지는 같은 관례를 따른다. context 는 첫 번째 인자이고 이름은 ctx 이며, 구조체 필드에 저장하지 않는다. 첫 번째 위치에 고정하면 시그니처만 봐도 취소 가능한 함수인지 알 수 있고, 린터가 규칙을 검사하기도 쉽다. 구조체에 저장하면 그 구조체가 쓰이는 모든 호출이 하나의 취소 범위에 묶여 요청마다 다른 마감을 줄 수 없게 된다.

nil 을 넘기지 않는다. 아직 어떤 context 를 써야 할지 모르는 자리에는 context.TODO() 를 두어 "나중에 채울 곳"임을 코드에 남긴다. 프로그램의 진입점과 테스트처럼 뿌리가 필요한 곳에서만 context.Background() 를 쓴다.

요청 범위 값의 올바른 쓰임

WithValue 는 context 에 키와 값을 묶어 아래로 내려보낸다. 요청 ID, 인증된 사용자 식별자처럼 요청 하나의 수명과 함께하고 여러 계층을 가로지르는 값에 적합하다. 반대로 함수가 동작하는 데 꼭 필요한 입력, 예컨대 주문 번호나 가게 이름은 인자로 받아야 한다. context 에 넣으면 시그니처에서 의존성이 사라져 호출자가 무엇을 채워야 하는지 알 수 없고, 빠뜨려도 컴파일러가 알려 주지 않는다.

키는 string 같은 기본 타입으로 만들지 않는다. 다른 패키지가 우연히 같은 문자열을 쓰면 값이 덮이거나 읽힌다. 패키지 안에서만 보이는 자체 타입을 정의하고, 읽기와 쓰기를 함수 두 개로 감싸 키를 밖에 노출하지 않는다. 읽는 쪽은 타입 단언에서 ok 를 확인해 값이 없을 때도 안전하게 빈 값을 돌려준다.

값을 context 에 넣을지 인자로 받을지 가르는 기준
값수명없으면 동작이 달라지는가둘 곳
요청 ID요청 하나아니오, 로그에만 쓰인다context
주문 번호함수 호출예, 처리 대상 자체다인자
데이터베이스 핸들프로그램 전체예구조체 필드나 인자
호출 추적 정보요청 하나아니오context

HTTP 핸들러와 이어 붙이기

net/http 서버는 요청마다 context 를 만들어 r.Context() 로 건넨다. 이 context 는 클라이언트가 연결을 끊거나 핸들러가 끝나면 취소된다. 핸들러는 이것을 뿌리로 삼아 WithTimeout 으로 자기 단계의 마감을 덧붙이고, 하위 호출에 그대로 넘긴다. 그러면 클라이언트의 이탈과 서버의 마감이 같은 신호 하나로 합쳐진다.

핸들러가 context 오류를 HTTP 응답으로 옮기는 방식
ctx.Err()의미응답 코드비고
DeadlineExceeded서버가 정한 마감 초과504재시도 안내 가능
Canceled클라이언트가 떠남499비표준 코드, 로그용
nil 이 아닌 다른 오류배정 자체의 실패500원인은 로그에 남김

499 는 표준 상태 코드가 아니고 일부 프록시가 관습으로 쓰는 값이다. 연결이 이미 끊겼다면 응답이 전달되지 않으므로 이 값은 사실상 서버 쪽 기록용이다. 이 장의 예제는 핸들러를 직접 호출하기 때문에 값을 볼 수 있다.

완성 코드

다섯 가지 실험을 한 파일에 담았다. 각 실험은 시간에 따라 달라지는 값을 출력하지 않고, 마감 비교는 큰 간격(수 밀리초 대 수백 밀리초)으로 둬서 결과가 고정된다.

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"time"
)

type ctxKey int

const requestIDKey ctxKey = iota

// WithRequestID 는 요청 ID 를 담은 자식 context 를 만든다.
func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey, id)
}

// RequestID 는 context 에서 요청 ID 를 꺼낸다. 없으면 빈 문자열이다.
func RequestID(ctx context.Context) string {
	id, _ := ctx.Value(requestIDKey).(string)
	return id
}

// assignCourier 는 delay 만큼 걸리는 배달원 배정을 흉내 내며 취소를 존중한다.
func assignCourier(ctx context.Context, orderID string, delay time.Duration) (string, error) {
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-timer.C:
		return "courier-" + orderID, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func produceOrders(ctx context.Context) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for id := 1; ; id++ {
			select {
			case out <- id:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

func demoCancel() {
	fmt.Println("[1] WithCancel")
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	var got []int
	for id := range produceOrders(ctx) {
		got = append(got, id)
		if len(got) == 3 {
			cancel()
			break
		}
	}
	fmt.Println("받은 주문:", got)
	fmt.Println("ctx.Err:", ctx.Err())
}

func tryAssign(timeout, delay time.Duration) (string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()
	return assignCourier(ctx, "A1", delay)
}

func demoTimeout() {
	fmt.Println("[2] WithTimeout")
	c, err := tryAssign(time.Second, 5*time.Millisecond)
	fmt.Printf("빠른 배정: %s, 오류=%v\n", c, err)
	_, err = tryAssign(20*time.Millisecond, 500*time.Millisecond)
	fmt.Printf("느린 배정: 오류=%v, 시간 초과=%t\n", err, errors.Is(err, context.DeadlineExceeded))
}

func demoDeadline() {
	fmt.Println("[3] WithDeadline")
	parent, cancelParent := context.WithDeadline(context.Background(), time.Now().Add(20*time.Millisecond))
	defer cancelParent()
	child, cancelChild := context.WithTimeout(parent, time.Hour)
	defer cancelChild()
	pd, _ := parent.Deadline()
	cd, ok := child.Deadline()
	fmt.Println("자식 마감 존재:", ok)
	fmt.Println("자식 마감이 부모와 같다:", pd.Equal(cd))
	<-child.Done()
	fmt.Println("자식 오류:", child.Err())
}

func demoTree() {
	fmt.Println("[4] 취소 전파")
	parent, cancel := context.WithCancel(context.Background())
	var wg sync.WaitGroup
	results := make([]string, 3)
	started := make(chan struct{}, 3)
	for i := range 3 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			child, stop := context.WithTimeout(parent, time.Hour)
			defer stop()
			started <- struct{}{}
			<-child.Done()
			results[i] = fmt.Sprintf("가게 %d: %v", i, child.Err())
		}()
	}
	for range 3 {
		<-started
	}
	cancel()
	wg.Wait()
	for _, r := range results {
		fmt.Println(r)
	}
}

func withRequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = "none"
		}
		next.ServeHTTP(w, r.WithContext(WithRequestID(r.Context(), id)))
	})
}

func dispatchHandler(timeout time.Duration) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx, cancel := context.WithTimeout(r.Context(), timeout)
		defer cancel()
		q := r.URL.Query()
		delay := time.Millisecond
		if q.Get("slow") == "1" {
			delay = 500 * time.Millisecond
		}
		courier, err := assignCourier(ctx, q.Get("order"), delay)
		switch {
		case errors.Is(err, context.DeadlineExceeded):
			http.Error(w, "배정 시간 초과 요청="+RequestID(ctx), http.StatusGatewayTimeout)
		case errors.Is(err, context.Canceled):
			http.Error(w, "요청 취소됨", 499)
		case err != nil:
			http.Error(w, "배정 실패", http.StatusInternalServerError)
		default:
			fmt.Fprintf(w, "%s 배정 요청=%s\n", courier, RequestID(ctx))
		}
	})
}

func call(ctx context.Context, h http.Handler, target, id string) string {
	req := httptest.NewRequestWithContext(ctx, http.MethodGet, target, nil)
	if id != "" {
		req.Header.Set("X-Request-ID", id)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return fmt.Sprintf("%d %s", rec.Code, strings.TrimSpace(rec.Body.String()))
}

func demoHTTP() {
	fmt.Println("[5] HTTP 핸들러")
	h := withRequestID(dispatchHandler(50 * time.Millisecond))
	bg := context.Background()
	fmt.Println("정상:", call(bg, h, "/dispatch?order=A1", "req-1"))
	fmt.Println("지연:", call(bg, h, "/dispatch?order=A2&slow=1", "req-2"))
	gone, cancel := context.WithCancel(bg)
	cancel()
	fmt.Println("취소:", call(gone, h, "/dispatch?order=A3&slow=1", "req-3"))
}

func main() {
	demoCancel()
	demoTimeout()
	demoDeadline()
	demoTree()
	demoHTTP()
}

줄별 해설

키와 접근 함수

ctxKey 는 이 패키지 밖에서 만들 수 없는 타입이므로 같은 정수 값을 가진 다른 키와 충돌하지 않는다. WithRequestID 와 RequestID 두 함수만 키를 안다. RequestID 의 id, _ := ...(string) 은 값이 없거나 타입이 다르면 빈 문자열을 돌려주는 안전한 단언이다.

assignCourier

time.Sleep 대신 time.NewTimer 를 쓰고 select 로 타이머와 Done 을 묶었다. 함수가 어느 쪽으로 반환하든 defer timer.Stop() 이 타이머를 정리한다. 취소로 깨어나면 빈 문자열과 ctx.Err() 를 돌려준다.

demoCancel 과 produceOrders

생산자는 보내기와 Done 을 select 로 묶는다. 소비자는 세 건을 받자마자 cancel() 을 부르고 반복을 빠져나온다. 이후 소비자가 수신하지 않으므로 보내기 갈래는 준비되지 않고, Done 갈래만 남아 생산자가 종료한다. 그래서 받은 주문은 항상 1, 2, 3 이다. 반복문 밖에서도 defer cancel() 을 두어 어떤 경로로 나가도 정리되게 했다.

demoTimeout

tryAssign 은 호출마다 새 WithTimeout 을 만들고 defer 로 cancel 을 부른다. 첫 호출은 마감 1초에 작업 5밀리초라 성공하고, 두 번째는 마감 20밀리초에 작업 500밀리초라 DeadlineExceeded 로 끝난다. errors.Is 로 원인을 확인한다.

demoDeadline

부모는 20밀리초 뒤의 절대 시각을 마감으로 갖는다. 자식은 1시간을 요청했지만 부모의 마감이 더 이르므로 Deadline() 이 부모와 같은 시각을 돌려준다. <-child.Done() 은 부모의 마감이 도래할 때까지 기다리고, 자식의 Err 는 DeadlineExceeded 다.

demoTree

세 고루틴이 각자 부모에서 파생한 자식 context 를 만들어 Done 을 기다린다. started 버퍼 채널로 세 고루틴이 모두 대기에 들어간 것을 확인한 뒤 부모를 취소한다. 결과는 인덱스 칸에 쓰므로 고루틴 실행 순서와 무관하게 출력 순서가 고정된다. 서로 다른 칸에 쓰므로 경쟁도 없고, 읽기는 wg.Wait() 뒤에 한다.

핸들러와 미들웨어

withRequestID 는 헤더에서 ID 를 읽어 r.WithContext 로 값이 실린 요청 사본을 다음 핸들러에 넘긴다. dispatchHandler 는 r.Context() 에서 파생해 50밀리초 마감을 덧붙이고, 같은 ctx 를 assignCourier 와 RequestID 에 쓴다. switch 는 오류 종류를 상태 코드로 옮긴다. 마감과 취소를 구분하는 이유는 응답 코드와 로그의 의미가 다르기 때문이다.

call 과 demoHTTP

call 은 주어진 context 로 요청을 만들고 httptest.NewRecorder 로 응답을 받아 상태 코드와 본문을 한 줄로 돌려준다. 포트를 열지 않으므로 실행이 바로 끝난다. 세 번째 요청은 호출 전에 이미 취소한 context 를 실어 클라이언트가 떠난 상황을 만든다. 배정에 500밀리초가 걸리므로 취소 갈래가 선택된다.

실행 결과

$ go run main.go
[1] WithCancel
받은 주문: [1 2 3]
ctx.Err: context canceled
[2] WithTimeout
빠른 배정: courier-A1, 오류=<nil>
느린 배정: 오류=context deadline exceeded, 시간 초과=true
[3] WithDeadline
자식 마감 존재: true
자식 마감이 부모와 같다: true
자식 오류: context deadline exceeded
[4] 취소 전파
가게 0: context canceled
가게 1: context canceled
가게 2: context canceled
[5] HTTP 핸들러
정상: 200 courier-A1 배정 요청=req-1
지연: 504 배정 시간 초과 요청=req-2
취소: 499 요청 취소됨

실무에서 자주 틀리는 것

cancel 을 부르지 않는다

WithTimeout 은 마감 전에 끝나도 타이머와 부모 등록을 유지한다. cancel 을 부르지 않으면 마감이 될 때까지 자원이 남는다. go vet 도 이 누락을 경고한다.

// 틀림
ctx, _ := context.WithTimeout(parent, 3*time.Second)
courier, err := assignCourier(ctx, id, d)

// 고침
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()
courier, err := assignCourier(ctx, id, d)

취소를 보지 않고 기다린다

context 를 인자로 받고도 Done 을 확인하지 않으면 취소는 아무 효과가 없다. 기다림의 자리에는 select 를 쓴다.

// 틀림
func wait(ctx context.Context, d time.Duration) error {
	time.Sleep(d)
	return nil
}

// 고침
func wait(ctx context.Context, d time.Duration) error {
	t := time.NewTimer(d)
	defer t.Stop()
	select {
	case <-t.C:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

필수 입력과 문자열 키를 context 에 넣는다

주문 번호처럼 함수가 반드시 알아야 할 값을 context 에 숨기면 시그니처가 거짓말을 한다. 문자열 키는 다른 패키지와 충돌할 수 있다.

// 틀림
ctx = context.WithValue(ctx, "orderID", "A1")
func process(ctx context.Context) { id := ctx.Value("orderID").(string); _ = id }

// 고침
func process(ctx context.Context, orderID string) { _ = orderID }
// 요청 ID 처럼 부가 정보만 전용 키 타입과 접근 함수로 싣는다.
ctx = WithRequestID(ctx, "req-1")

핸들러 안에서 Background 로 새로 시작한다

핸들러가 context.Background() 에서 파생하면 클라이언트 이탈이 하위 작업에 전달되지 않고, 미들웨어가 실은 값도 사라진다.

// 틀림
ctx, cancel := context.WithTimeout(context.Background(), time.Second)

// 고침
ctx, cancel := context.WithTimeout(r.Context(), time.Second)

한눈에 보기

이 장의 핵심 규칙 요약
주제규칙이유관련 코드
취소 함수파생 직후 defer cancel()타이머와 부모 등록 해제WithCancel, WithTimeout
기다림select 로 Done 과 함께 기다림취소 즉시 반환assignCourier
시그니처ctx 를 첫 인자로, 필드에 저장하지 않음요청마다 다른 수명모든 함수
값전용 키 타입과 접근 함수, 부가 정보만충돌 방지와 명시적 의존RequestID
HTTPr.Context() 에서 파생클라이언트 이탈 전파dispatchHandler
오류errors.Is 로 Canceled 와 DeadlineExceeded 구분응답과 로그 구분switch 문

연습 문제

  1. demoTree 에서 부모 대신 가게 1번의 자식 context 만 취소하도록 바꾸면 다른 가게의 결과는 어떻게 되는지 설명하라. 취소하지 않은 가게가 영원히 기다리지 않게 하려면 무엇을 해야 하는지도 쓰라.
  2. produceOrders 를 고쳐 WithCancel 대신 WithTimeout 으로 호출하는 main 을 작성하라. 마감이 지난 뒤 ctx.Err() 로 어떤 값이 나오는가.
  3. RequestID 를 쓰는 로그 함수 logf(ctx context.Context, format string, args ...any) 를 작성하라. 요청 ID 가 없으면 "-" 를 쓴다.
  4. dispatchHandler 의 delay 를 쿼리 값에서 읽도록 바꿀 때, 잘못된 입력은 어떤 상태 코드로 응답하는 것이 좋은가. 그 판단이 context 와 관련이 있는지도 답하라.

정답과 해설

1. 가게 1번만 context.Canceled 로 끝나고 나머지 둘은 아직 Done 이 닫히지 않아 대기를 계속한다. 자식은 WithTimeout(parent, time.Hour) 이므로 한 시간 뒤에는 DeadlineExceeded 로 끝난다. 그전에 끝내려면 부모를 취소하거나 더 짧은 마감을 주면 된다. 자식의 취소는 부모와 형제에게 닿지 않는다는 점이 핵심이다.

2. 예를 들어 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) 로 만들고 for id := range produceOrders(ctx) 안에서 시간이 지나기를 기다린다. 소비자가 계속 수신하는 동안 생산자는 보내기를 이어 가므로, 마감이 되면 생산자가 닫고 반복이 끝난다. 이때 ctx.Err() 는 context.DeadlineExceeded 다. 다만 얼마나 많은 주문을 받는지는 속도에 따라 달라지므로 개수를 출력하지 않는 것이 좋다.

3.

func logf(ctx context.Context, format string, args ...any) {
	id := RequestID(ctx)
	if id == "" {
		id = "-"
	}
	fmt.Printf("[%s] "+format+"\n", append([]any{id}, args...)...)
}

요청 ID 는 부가 정보이므로 없어도 동작은 바뀌지 않는다. 그래서 context 에 두는 것이 알맞고, 빈 값 처리만 접근하는 쪽에서 맡는다.

4. 입력 오류는 클라이언트의 잘못이므로 400 Bad Request 가 알맞다. context 를 만들기 전에 검증하면 불필요한 타이머를 만들지 않아도 된다. 검증 실패는 취소나 마감과 무관하므로 context 오류와 섞어 처리하지 않는다. 500 으로 응답하면 서버 결함으로 오해되어 알림이 잘못 울린다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.