상태 코드는 성공과 실패를 나타내는 숫자가 아니다

vx_developer·3일 전

개발하다가

목록 보기
27/30
post-thumbnail

웹 개발을 처음 배울 때 HTTP 상태 코드는 보통 요청의 성공과 실패를 나타내는 숫자라고 배운다.

200; // 성공
400; // 클라이언트 오류
500; // 서버 오류

HTTP 통신의 기본 구조를 이해하기에는 충분한 설명이다.

하지만 실제 서비스를 개발하면 성공과 실패만으로는 처리 결과를 충분히 설명할 수 없다.

  • 결제 요청이 즉시 완료된 것인가, 접수만 된 것인가?
  • 입력 형식이 잘못된 것인가, 잔액이 부족한 것인가?
  • 로그인하지 않은 것인가, 권한이 없는 것인가?
  • 같은 결제가 이미 처리된 것인가?
  • 클라이언트가 같은 요청을 다시 보내도 되는가?
  • 사용자에게 어떤 메시지와 다음 행동을 보여줘야 하는가?

상태 코드는 단순히 성공과 실패를 구분하는 숫자가 아니다. 서버가 요청을 어떻게 처리했으며 클라이언트가 다음에 무엇을 해야 하는지 합의된 방식으로 전달하는 언어다.

실제 서비스에서 HTTP 상태 코드는 처리 결과의 종류와 클라이언트의 다음 행동을 전달하는 계약이다.


모든 성공을 200으로 반환하면 결과의 차이가 사라진다

크리스가 쇼핑몰에서 결제를 요청한다고 생각해 보자.

서버가 모든 처리 결과를 200 OK로 반환할 수도 있다.

return Response.json(
  {
    success: true,
    paymentId: "payment_42",
  },
  {
    status: 200,
  }
);

응답 본문을 읽으면 결제가 성공했다는 사실을 알 수 있다.

문제는 결제가 생성된 경우, 비동기 처리가 시작된 경우와 이미 존재하는 결과를 조회한 경우가 모두 같은 상태 코드로 표현될 수 있다는 점이다.

return Response.json(
  {
    success: true,
    message: "Payment processing has started.",
  },
  {
    status: 200,
  }
);

클라이언트는 본문의 문자열을 해석해야만 요청이 완료된 것인지 처리 중인지 판단할 수 있다.

성공 응답도 처리 결과에 따라 구분해야 한다.

return Response.json(
  {
    paymentId: "payment_42",
    status: "completed",
  },
  {
    status: 201,
    headers: {
      Location: "/api/payments/payment_42",
    },
  }
);

201 Created는 새로운 결제 리소스가 생성되었다는 의미를 전달한다. Location 헤더는 생성된 리소스를 어디에서 조회할 수 있는지도 알려준다.

비동기 처리가 시작되었지만 아직 완료되지 않았다면 다른 응답이 필요하다.

return Response.json(
  {
    paymentId: "payment_42",
    status: "processing",
    statusUrl: "/api/payments/payment_42",
  },
  {
    status: 202,
  }
);

202 Accepted는 요청을 접수했지만 처리가 완료되었다고 보장하지 않는다. 따라서 클라이언트가 처리 상태를 확인할 수 있는 방법도 함께 제공해야 한다.

상태 코드는 같은 성공 범주 안에서도 서버가 어디까지 처리했는지를 구분한다.


2xx는 모두 같은 성공을 의미하지 않는다

대표적인 성공 상태 코드는 서로 다른 결과를 표현한다.

상태 코드의미결제 서비스 예시
200 OK요청이 정상적으로 처리되었고 응답 본문이 있음결제 상세 조회
201 Created새로운 리소스가 생성됨결제 생성 완료
202 Accepted요청을 접수했지만 처리는 아직 진행 중외부 결제사 승인 대기
204 No Content처리는 완료되었지만 반환할 본문이 없음저장된 결제수단 삭제

결제수단 삭제가 완료된 뒤 응답 데이터가 필요하지 않다면 다음처럼 반환할 수 있다.

return new Response(null, {
  status: 204,
});

204 No Content 응답에는 본문을 포함하지 않는다. 클라이언트도 JSON 본문이 있을 것이라고 가정해서는 안 된다.

const response = await fetch(
  "/api/payment-methods/method_42",
  {
    method: "DELETE",
  }
);

if (response.status === 204) {
  removePaymentMethodFromScreen("method_42");
}

이 코드는 삭제가 완료되었다는 상태 자체를 처리 결과로 사용한다.

적절한 성공 코드를 선택하려면 단순히 작업이 성공했는지가 아니라 새 리소스가 만들어졌는지, 처리가 끝났는지, 반환할 표현이 있는지를 판단해야 한다.


HTTP 성공과 비즈니스 성공은 같은 개념이 아니다

결제 요청이 서버에서 정상적으로 처리되었더라도 카드 승인이 거절될 수 있다.

const paymentResult =
  await paymentGateway.authorize({
    amount: 12000,
    currency: "AUD",
    paymentMethodId: "method_42",
  });

외부 결제사와의 통신은 정상적으로 완료되었지만 결과가 declined일 수 있다.

이를 무조건 서버 장애로 반환하면 처리 결과를 잘못 설명하게 된다.

return Response.json(
  {
    code: "CARD_DECLINED",
    message: "The payment was declined.",
  },
  {
    status: 500,
  }
);

500 Internal Server Error는 서버가 예상하지 못한 문제 때문에 요청을 처리하지 못했다는 의미에 가깝다. 정상적으로 예상되는 카드 거절과는 성격이 다르다.

대신 결제 요청의 현재 상태와 클라이언트가 취할 행동을 명확하게 전달해야 한다.

return Response.json(
  {
    code: "CARD_DECLINED",
    message: "Please use another payment method.",
    paymentStatus: "declined",
  },
  {
    status: 422,
  }
);

이 설계에서는 요청 형식은 유효하지만 제공된 결제수단으로는 결제를 완료할 수 없다는 의미로 422 Unprocessable Content를 사용했다.

서비스에 따라 결제 시도를 정상적으로 생성한 뒤 201 또는 200 응답 안에 declined라는 도메인 상태를 표현할 수도 있다.

중요한 것은 특정 코드 하나를 모든 서비스에 기계적으로 적용하는 것이 아니다. 팀이 다음 두 결과를 의식적으로 구분하는 것이다.

  • HTTP 수준에서 요청을 처리할 수 있었는가
  • 비즈니스 수준에서 사용자가 원하는 결과가 이루어졌는가

상태 코드는 HTTP 처리 결과를 표현하고, 응답 본문의 도메인 코드는 구체적인 업무 결과를 설명한다.


400과 422는 실패 원인을 다르게 설명할 수 있다

다음 결제 요청은 올바른 JSON이 아니다.

{
  "amount": 12000,
  "currency": "AUD",

서버가 요청 본문 자체를 읽을 수 없다면 400 Bad Request로 응답할 수 있다.

return Response.json(
  {
    code: "INVALID_JSON",
    message: "The request body is not valid JSON.",
  },
  {
    status: 400,
  }
);

이 응답은 요청 형식이 HTTP 처리 단계에서 유효하지 않다는 사실을 알려준다.

반면 JSON 구조는 읽을 수 있지만 필드 값이 서비스 규칙을 만족하지 않을 수 있다.

const result = createPaymentSchema.safeParse(
  await request.json()
);

if (!result.success) {
  return Response.json(
    {
      code: "INVALID_PAYMENT_INPUT",
      fields: result.error.flatten().fieldErrors,
    },
    {
      status: 422,
    }
  );
}

이 코드는 본문을 해석할 수 있지만 금액, 통화 또는 결제수단 값이 요구 조건을 만족하지 않는 경우를 표현한다.

일부 API는 입력 검증 실패를 모두 400으로 통일한다. 다른 API는 문법적으로 해석할 수 없는 요청은 400, 구조는 유효하지만 처리할 수 없는 입력은 422로 구분한다.

두 방법 중 하나만 절대적으로 옳다고 보기보다 팀 전체에서 같은 기준을 일관되게 적용하는 것이 중요하다.

클라이언트는 일관된 기준이 있을 때 일반 요청 오류와 필드별 검증 오류를 안정적으로 구분할 수 있다.


401과 403은 로그인 실패와 권한 부족을 구분한다

401 Unauthorized라는 이름 때문에 권한 부족을 의미한다고 오해하기 쉽다.

실제 API 설계에서는 일반적으로 다음과 같이 구분한다.

  • 401 Unauthorized: 유효한 인증 정보가 없어 사용자를 확인할 수 없음
  • 403 Forbidden: 사용자는 확인했지만 해당 행동을 허용할 수 없음

크리스가 로그인하지 않은 상태에서 결제 내역을 요청했다고 생각해 보자.

const session = await getSession(request);

if (!session) {
  return Response.json(
    {
      code: "AUTHENTICATION_REQUIRED",
      message: "Please sign in.",
    },
    {
      status: 401,
    }
  );
}

서버는 현재 요청을 보낸 사용자가 누구인지 확인할 수 없다.

반면 로그인한 크리스가 다른 사용자의 결제를 조회한다면 인증은 완료되었지만 권한이 부족하다.

const payment =
  await paymentRepository.findById(
    "payment_42"
  );

if (payment.userId !== session.userId) {
  return Response.json(
    {
      code: "PAYMENT_ACCESS_DENIED",
      message:
        "You cannot access this payment.",
    },
    {
      status: 403,
    }
  );
}

클라이언트는 401을 받으면 로그인 화면으로 이동하거나 인증을 갱신할 수 있다. 403을 받으면 로그인 반복 대신 접근 권한이 없다는 안내를 보여줄 수 있다.

다만 다른 사용자의 데이터 존재 여부를 숨겨야 하는 서비스에서는 권한이 없는 리소스에 404 Not Found를 반환할 수도 있다.

상태 코드 선택에는 기능적 정확성뿐 아니라 정보 노출 정책도 반영되어야 한다.


404는 URL이 잘못되었을 때만 사용하는 코드가 아니다

다음 URL의 라우트가 존재하지 않는다면 서버는 404 Not Found를 반환한다.

GET /api/unknown-payments

하지만 라우트는 존재하고 특정 결제 데이터만 찾을 수 없는 경우에도 404를 사용할 수 있다.

const payment =
  await paymentRepository.findById(
    paymentId
  );

if (!payment) {
  return Response.json(
    {
      code: "PAYMENT_NOT_FOUND",
      message: "The payment was not found.",
    },
    {
      status: 404,
    }
  );
}

첫 번째 경우는 엔드포인트가 존재하지 않는다. 두 번째 경우는 요청한 결제 리소스가 존재하지 않는다.

클라이언트에는 둘 다 요청한 대상을 찾을 수 없다는 의미를 전달하지만, 서버 로그와 내부 오류 코드는 원인을 더 구체적으로 구분할 수 있다.

사용자에게 전달할 정보와 운영자가 진단에 사용할 정보를 같은 수준으로 노출할 필요는 없다.


409는 현재 상태와 요청이 충돌했음을 나타낸다

크리스가 이미 환불된 결제를 다시 환불하려고 한다고 생각해 보자.

입력 형식도 올바르고 로그인과 권한에도 문제가 없다. 하지만 현재 결제 상태에서는 해당 작업을 수행할 수 없다.

if (payment.status === "refunded") {
  return Response.json(
    {
      code: "PAYMENT_ALREADY_REFUNDED",
      message:
        "This payment has already been refunded.",
    },
    {
      status: 409,
    }
  );
}

409 Conflict는 요청이 현재 리소스 상태와 충돌한다는 사실을 표현할 수 있다.

같은 주문을 두 번 결제하려는 요청도 현재 상태와 충돌할 수 있다.

if (order.paymentStatus === "paid") {
  return Response.json(
    {
      code: "ORDER_ALREADY_PAID",
      orderId: order.id,
      paymentId: order.paymentId,
    },
    {
      status: 409,
    }
  );
}

클라이언트 화면에 표시된 주문 상태는 시간이 지난 복사본일 수 있다.

결제 가능 여부를 결정하는 Source of Truth는 서버가 관리하는 최신 주문과 결제 상태다. 서버는 요청을 처리하기 직전에 현재 상태를 다시 확인해야 한다.

409 응답에는 충돌 사실만 넣기보다 클라이언트가 화면을 최신 상태로 맞출 수 있는 식별자나 현재 상태를 함께 제공하는 것이 유용하다.


429는 실패가 아니라 기다린 뒤 다시 요청하라는 신호다

결제 API에는 중복 클릭, 자동화된 공격 또는 과도한 재시도를 제한하는 정책이 필요할 수 있다.

if (rateLimit.exceeded) {
  return Response.json(
    {
      code: "TOO_MANY_PAYMENT_ATTEMPTS",
      message:
        "Please wait before trying again.",
    },
    {
      status: 429,
      headers: {
        "Retry-After": "30",
      },
    }
  );
}

429 Too Many Requests는 일정 시간 동안 너무 많은 요청이 들어왔음을 나타낸다.

Retry-After 헤더는 클라이언트가 언제 다시 시도할 수 있는지 알려준다.

if (response.status === 429) {
  const retryAfter = Number(
    response.headers.get("Retry-After") ?? 30
  );

  showRetryMessage(retryAfter);
}

클라이언트는 같은 요청을 즉시 반복하는 대신 재시도 버튼을 잠시 비활성화하거나 남은 시간을 안내할 수 있다.

상태 코드와 헤더를 함께 사용하면 실패 사실뿐 아니라 복구 방법까지 전달할 수 있다.


5xx는 사용자가 입력을 바꿔 해결할 수 없는 문제를 나타낸다

결제 서버의 데이터베이스 연결이 끊겼다고 생각해 보자.

try {
  await paymentRepository.save(payment);
} catch (error) {
  logger.error("Failed to save payment", {
    paymentId: payment.id,
    error,
  });

  return Response.json(
    {
      code: "PAYMENT_SERVICE_UNAVAILABLE",
      message:
        "Payment is temporarily unavailable.",
    },
    {
      status: 503,
      headers: {
        "Retry-After": "60",
      },
    }
  );
}

503 Service Unavailable은 서버가 일시적으로 요청을 처리할 수 없다는 의미를 전달한다.

사용자는 카드 번호를 수정한다고 이 문제를 해결할 수 없다. 클라이언트는 잠시 후 다시 시도하도록 안내해야 한다.

예상하지 못한 서버 오류에는 500 Internal Server Error를 사용할 수 있다.

return Response.json(
  {
    code: "INTERNAL_SERVER_ERROR",
    message:
      "An unexpected error occurred.",
    requestId,
  },
  {
    status: 500,
  }
);

응답에는 스택 트레이스, 데이터베이스 쿼리와 내부 파일 경로를 노출하지 않는다.

자세한 오류 정보는 서버 로그에 남기고, 사용자에게는 안전한 메시지와 고객 지원에서 추적할 수 있는 요청 ID를 전달한다.

외부 입력은 검증 전까지 신뢰할 수 없지만, 모든 실패를 외부 입력 탓으로 돌려서도 안 된다. 입력 문제는 4xx, 서버가 책임져야 하는 예상 밖의 장애는 5xx 범주로 구분한다.


상태 코드와 오류 코드는 서로 다른 질문에 답한다

상태 코드만으로 모든 비즈니스 실패를 표현하려 하면 구체적인 의미가 사라진다.

return Response.json(
  {
    error: true,
  },
  {
    status: 400,
  }
);

이 응답만으로는 카드가 거절되었는지, 금액이 잘못되었는지, 결제가 이미 완료되었는지 알 수 없다.

상태 코드는 넓은 처리 범주를 전달하고, 응답 본문의 오류 코드는 서비스에 특화된 원인을 전달해야 한다.

type ApiError = {
  code:
    | "INVALID_PAYMENT_INPUT"
    | "CARD_DECLINED"
    | "ORDER_ALREADY_PAID"
    | "PAYMENT_SERVICE_UNAVAILABLE";
  message: string;
  fields?: Record<string, string[]>;
  requestId?: string;
};

예를 들면 다음과 같이 나눌 수 있다.

HTTP 상태도메인 오류 코드클라이언트 행동
422INVALID_PAYMENT_INPUT잘못된 필드 표시
422CARD_DECLINED다른 결제수단 안내
409ORDER_ALREADY_PAID최신 주문 상태 조회
429TOO_MANY_PAYMENT_ATTEMPTS일정 시간 후 재시도
503PAYMENT_SERVICE_UNAVAILABLE일시적 장애 안내

이 구조에서는 HTTP 라이브러리와 인프라가 상태 코드를 사용할 수 있고, 화면은 도메인 오류 코드를 사용해 구체적인 경험을 제공할 수 있다.

message 문자열 자체를 조건문에서 비교하지 않아야 한다. 사용자 메시지는 번역이나 문구 개선으로 바뀔 수 있지만 안정적인 오류 코드는 프로그램의 계약으로 유지할 수 있다.


클라이언트는 상태 코드에 따라 다음 행동을 선택한다

상태 코드는 단순히 오류 메시지 색상을 결정하는 값이 아니다.

async function submitPayment(
  input: PaymentInput
) {
  const response = await fetch(
    "/api/payments",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(input),
    }
  );

  const body = await response.json();

  switch (response.status) {
    case 201:
      return {
        next: "show-receipt",
        payment: body,
      };

    case 202:
      return {
        next: "show-processing",
        payment: body,
      };

    case 409:
      return {
        next: "refresh-order",
        error: body,
      };

    case 422:
      return {
        next: "correct-input",
        error: body,
      };

    case 429:
      return {
        next: "wait-before-retry",
        error: body,
      };

    default:
      return {
        next: "show-temporary-error",
        error: body,
      };
  }
}

이 코드는 상태 코드에 따라 영수증 표시, 처리 대기, 주문 새로고침, 입력 수정과 재시도 대기를 선택한다.

상태 코드가 구체적이고 일관될수록 클라이언트는 오류 메시지를 추측하지 않고 다음 행동을 결정할 수 있다.

다만 모든 4xx5xx를 무조건 같은 방식으로 처리하지 않아야 한다. 인증 갱신, 입력 수정, 충돌 해결과 일시적 장애 재시도는 서로 다른 흐름이다.


재시도 가능성은 상태 코드 하나만으로 결정되지 않는다

다음과 같이 모든 서버 오류를 자동으로 재시도할 수 있다.

if (response.status >= 500) {
  return submitPayment(input);
}

결제 요청에서는 위험한 구현이다.

첫 번째 요청이 외부 결제사에서 승인되었지만 내부 응답 과정에서 오류가 발생했다면, 같은 요청을 다시 보내 중복 결제를 만들 수 있다.

재시도 여부는 다음 정보를 함께 고려해야 한다.

응답일반적인 판단추가 조건
400같은 요청 재시도 불필요요청 형식을 수정해야 함
401인증 갱신 후 재시도 가능갱신 실패 시 로그인 필요
409최신 상태 확인 후 판단같은 명령을 즉시 반복하지 않음
422입력 또는 결제수단 수정 필요수정 전 재시도 불필요
429대기 후 재시도 가능Retry-After 준수
503일시적 재시도 가능멱등성과 중복 실행 방지 필요

결제 생성과 같이 중복 실행의 영향이 큰 요청에는 상태 코드와 별도로 멱등성 키가 필요하다.

await fetch("/api/payments", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": paymentAttemptId,
  },
  body: JSON.stringify(input),
});

같은 결제 작업을 재시도할 때는 새로운 키를 만들지 않고 동일한 키를 유지해야 한다.

상태 코드는 재시도 판단에 필요한 단서를 제공하지만, 요청의 멱등성, 서버 구현과 비즈니스 영향까지 대신 판단해 주지는 않는다.


상태 코드는 요청 흐름 전체에 처리 결과를 전달한다

결제 요청의 처리 결과는 화면 하나에서만 사용되지 않는다.

flowchart LR
    A[결제 화면] --> B[API 요청]
    B --> C[입력·인증 검증]
    C --> D[결제 도메인 로직]
    D --> E[외부 결제사]
    D --> F[데이터베이스]
    D --> G[HTTP 상태 코드와 응답]
    G --> H[사용자 다음 행동]
    G --> I[재시도 정책]
    G --> J[로그·모니터링]

상태 코드는 여러 계층에 같은 처리 결과를 전달한다.

  • 프론트엔드는 다음 화면과 메시지를 선택한다.
  • API 클라이언트는 인증 갱신이나 재시도를 판단한다.
  • 프록시와 게이트웨이는 오류 응답을 집계한다.
  • 모니터링 시스템은 4xx5xx 비율을 추적한다.
  • 운영자는 특정 오류 코드와 요청 ID로 문제를 조사한다.
  • 외부 API 소비자는 문서화된 계약에 따라 예외를 처리한다.

따라서 상태 코드가 실제 결과와 맞지 않으면 화면뿐 아니라 재시도, 모니터링과 장애 분석까지 왜곡된다.


상태 코드를 설계하기 전에 물어봐야 할 질문

성공의 종류를 구분했는가

  1. 기존 데이터를 정상적으로 반환한 것인가?
  2. 새로운 리소스를 생성한 것인가?
  3. 요청을 접수했지만 처리가 아직 진행 중인가?
  4. 처리는 끝났지만 반환할 본문이 없는가?
  5. 비동기 처리 상태를 다시 확인할 주소가 있는가?

실패의 책임이 어디에 있는가

  1. 서버가 요청 본문을 해석할 수 있는가?
  2. 입력 구조와 값이 서비스 규칙을 만족하는가?
  3. 사용자의 인증 정보가 유효한가?
  4. 인증된 사용자에게 해당 작업 권한이 있는가?
  5. 현재 리소스 상태와 요청이 충돌하는가?
  6. 서버가 예상하지 못한 장애 때문에 처리하지 못했는가?

클라이언트의 다음 행동이 분명한가

  1. 사용자가 입력을 수정해야 하는가?
  2. 다시 로그인하거나 인증을 갱신해야 하는가?
  3. 최신 데이터를 조회해야 하는가?
  4. 일정 시간 기다린 뒤 재시도해야 하는가?
  5. 다른 결제수단을 선택해야 하는가?
  6. 고객 지원에 전달할 요청 ID가 있는가?

응답 계약이 일관적인가

  1. 같은 종류의 실패에 같은 상태 코드를 사용하는가?
  2. 안정적인 도메인 오류 코드가 있는가?
  3. 오류 메시지 문자열을 프로그램 로직에 사용하고 있지는 않은가?
  4. 필드 오류, 현재 상태와 요청 ID의 형식이 통일되어 있는가?
  5. API 문서와 실제 응답이 일치하는가?

보안과 운영을 고려했는가

  1. 권한이 없는 사용자에게 리소스 존재 여부를 노출해도 되는가?
  2. 내부 스택 트레이스와 데이터베이스 정보를 숨기는가?
  3. 자세한 원인은 서버 로그에 안전하게 기록되는가?
  4. 4xx5xx를 구분해 모니터링하는가?
  5. 재시도 가능한 응답이 중복 결제를 만들지는 않는가?

흔한 실수는 상태 코드를 의미 없는 숫자로 만든다

모든 응답을 200으로 반환한다

return Response.json({
  success: false,
  error: "Payment failed",
});

HTTP 수준에서는 성공으로 기록되므로 API 클라이언트, 모니터링과 프록시가 실제 실패를 제대로 인식하기 어렵다.

처리 결과에 맞는 HTTP 상태 코드와 도메인 오류 코드를 함께 반환해야 한다.


모든 클라이언트 오류를 400으로 처리한다

return Response.json(error, {
  status: 400,
});

입력 형식 오류, 인증 실패, 권한 부족과 상태 충돌을 모두 같은 코드로 처리하면 클라이언트의 다음 행동을 결정하기 어렵다.

오류의 원인과 복구 방법이 다르다면 상태 코드도 구분할 가치가 있다.


예상 가능한 비즈니스 실패를 500으로 반환한다

if (payment.status === "declined") {
  return Response.json(
    {
      code: "CARD_DECLINED",
    },
    {
      status: 500,
    }
  );
}

카드 거절은 정상적으로 발생할 수 있는 업무 결과다. 이를 서버 장애로 기록하면 장애 지표가 왜곡되고 불필요한 경보가 발생한다.


HTTP 코드만으로 모든 업무 의미를 표현한다

return Response.json(null, {
  status: 422,
});

클라이언트는 입력 오류인지 카드 거절인지 알 수 없다.

상태 코드는 넓은 범주를 표현하고 도메인 오류 코드가 구체적인 원인을 설명해야 한다.


오류 응답에 내부 정보를 그대로 노출한다

return Response.json(
  {
    error: databaseError.stack,
    query: databaseError.query,
  },
  {
    status: 500,
  }
);

스택 트레이스, SQL과 내부 경로는 공격자에게 시스템 구조를 노출할 수 있다.

사용자에게는 안전한 메시지와 요청 ID를 제공하고 상세 정보는 접근이 제한된 로그에 남긴다.


상태 코드만 보고 결제를 자동 재시도한다

if (response.status === 503) {
  return submitPayment(input);
}

일시적 서버 오류처럼 보여도 결제 승인이 이미 처리되었을 수 있다.

멱등성 키, 결과 조회와 중복 처리 방지가 준비된 경우에만 안전한 재시도 정책을 적용해야 한다.


상태 코드의 핵심은 다음 행동까지 합의하는 데 있다

HTTP 상태 코드를 처음 배울 때는 다음과 같은 범주만으로도 전체 구조를 이해할 수 있다.

2xx = 성공
4xx = 클라이언트 오류
5xx = 서버 오류

하지만 실제 서비스를 개발할 때는 더 구체적인 질문에 답해야 한다.

  • 새로운 리소스가 만들어졌는가?
  • 요청이 접수만 되었는가, 처리가 끝났는가?
  • 사용자가 입력을 수정해야 하는가?
  • 인증이 필요한가, 권한이 부족한가?
  • 최신 서버 상태와 요청이 충돌하는가?
  • 잠시 기다리면 다시 시도할 수 있는가?
  • 같은 요청을 재시도하면 중복 결제가 생기는가?
  • 운영자는 어떤 정보로 실패 원인을 추적할 수 있는가?

상태 코드만으로 모든 비즈니스 의미를 표현할 수는 없다. 그렇다고 모든 결과를 200과 임의의 메시지로 반환해서도 안 된다.

상태 코드는 처리 결과의 공통 범주를 전달하고, 도메인 오류 코드는 서비스에 특화된 원인을 설명하며, 응답 본문과 헤더는 다음 행동에 필요한 정보를 제공한다.

결국 상태 코드를 선택한다는 것은 다음 질문에 답하는 일이다.

서버는 이 요청을 어디까지 처리했으며, 클라이언트는 이제 무엇을 해야 하는가?

상태 코드는 성공과 실패를 나타내는 숫자가 아니다.

서버의 처리 결과와 클라이언트의 다음 행동을 합의된 방식으로 전달하는 계약이다.

profile
Vision eXperience Developer

0개의 댓글