타입화된 오류, HTTP 상태 코드 매핑, 요청 ID, 내부 유출 없이 안전한 메시지를 표준화해 일관된 Go API 오류 응답을 만드는 패턴입니다.

각 엔드포인트가 실패를 다르게 보고하면 클라이언트는 API를 신뢰하지 못합니다. 어떤 경로는 { "error": "not found" }를 반환하고, 다른 경로는 { "message": "missing" }를 반환하며, 또 다른 경로는 평문을 보냅니다. 의미는 비슷해도 클라이언트 코드는 이제 무슨 일이 일어났는지 추측해야 합니다.
비용은 빠르게 드러납니다. 팀은 깨지기 쉬운 파싱 로직을 만들고 엔드포인트별 특수 처리를 추가합니다. 재시도는 "나중에 다시 시도"와 "입력이 잘못됨"을 구분할 수 없으므로 위험해집니다. 지원 티켓이 늘어나고 클라이언트는 모호한 메시지만 보며, 서버 로그 라인과 매칭하기 어렵습니다.
흔한 시나리오: 모바일 앱이 가입 과정에서 세 개의 엔드포인트를 호출합니다. 첫 번째는 필드 수준의 오류 맵과 함께 HTTP 400을 반환하고, 두 번째는 스택 트레이스를 담은 HTTP 500을 반환하며, 세 번째는 { "ok": false }로 HTTP 200을 반환합니다. 앱 팀은 세 가지 다른 오류 처리기를 만들고, 백엔드 팀은 어디서 문제가 생기는지 모르는 "가입이 가끔 실패해요"라는 보고를 받습니다.
목표는 하나의 예측 가능한 계약입니다. 클라이언트는 원인이 사용자 쪽인지 서버 쪽인지, 재시도가 가능한지, 지원에 붙여 넣을 수 있는 요청 ID가 무엇인지 신뢰성 있게 읽을 수 있어야 합니다.
범위: 여기서는 JSON HTTP API(유의: gRPC 아님)에 초점을 맞춥니다. 같은 아이디어는 다른 시스템으로 오류를 반환할 때도 적용됩니다.
오류에 대해 하나의 명확한 계약을 정하고 모든 엔드포인트가 이를 따르게 하세요. "일관성"은 동일한 JSON 형태, 동일한 필드 의미, 어떤 핸들러가 실패하든 동일한 동작을 의미합니다. 이렇게 하면 클라이언트는 추측을 멈추고 오류를 처리하기 시작합니다.
유용한 계약은 클라이언트가 다음에 무엇을 해야 하는지 결정하는 데 도움을 줍니다. 대부분의 애플리케이션에서 모든 오류 응답은 세 가지 질문에 답해야 합니다:
실용적인 규칙 집합:
미리 응답에 절대 표시해서는 안 될 항목을 결정하세요. 흔한 금지 항목에는 SQL 조각, 스택 트레이스, 내부 호스트명, 비밀값, 의존성으로부터 온 원시 오류 문자열이 포함됩니다.
간단한 분리 규칙을 유지하세요: 짧은 사용자용 메시지(안전하고 공손하며 행동 가능한)와 내부 세부정보(전체 오류, 스택, 컨텍스트)는 로그에만 남깁니다. 예: "변경 내용을 저장할 수 없습니다. 잠시 후 다시 시도해 주세요."는 안전합니다. "pq: duplicate key value violates unique constraint users_email_key"는 안전하지 않습니다.
모든 엔드포인트가 같은 계약을 따르면 클라이언트는 한 가지 오류 핸들러만 만들어 어디서든 재사용할 수 있습니다.
클라이언트는 모든 엔드포인트가 동일한 형태로 응답할 때만 오류를 깔끔하게 처리할 수 있습니다. 하나의 JSON 봉투를 선택하고 이를 안정적으로 유지하세요.
실용적인 기본 구조는 error 객체와 최상위의 request_id입니다:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
},
"request_id": "req_01HV..."
}
HTTP 상태는 넓은 범주를 제공하고, 기계가 읽을 수 있는 error.code는 클라이언트가 분기할 수 있는 구체적인 사례를 제공합니다. 이 분리는 중요합니다. 많은 다른 문제들이 같은 상태를 공유할 수 있기 때문입니다. 모바일 앱은 EMAIL_TAKEN과 WEAK_PASSWORD를 다르게 보여줄 수 있습니다(둘 다 400인 경우에도).
error.message는 안전하고 사람에게 도움이 되는 문구여야 합니다. 사용자가 문제를 고치도록 도와주되 내부 정보(SQL, 스택 트레이스, 제공자 이름, 파일 경로)는 절대 노출하지 마세요.
선택적 필드는 예측 가능할 때 유용합니다:
details.fields는 필드별 메시지 맵.details.retry_after_seconds.details.docs_hint(URL이 아닌 일반 텍스트).하위 호환성을 위해 error.code 값은 API 계약의 일부로 취급하세요. 새 코드를 추가할 때 기존 의미를 바꾸지 마세요. 선택적 필드만 추가하고, 클라이언트가 인식하지 못한 필드는 무시할 것이라 가정하세요.
각 핸들러가 실패를 신호하는 자체 방식을 만들 때 오류 처리는 엉망이 됩니다. 소수의 타입화된 오류를 두면 해결됩니다: 핸들러는 알려진 오류 타입을 반환하고, 하나의 응답 계층이 이를 일관된 응답으로 변환합니다.
실용적인 시작 집합은 대부분의 엔드포인트를 커버합니다:
핵심은 최상위에서의 안정성입니다. 근본 원인이 바뀌더라도 동일한 공개 타입을 반환하면 미들웨어가 이를 감지할 수 있습니다. 하위 수준의 오류(SQL, 네트워크, JSON 파싱 등)를 래핑하면서도 공개 타입은 유지하세요.
type NotFoundError struct {
Resource string
ID string
Err error // private cause
}
func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }
핸들러에서는 sql.ErrNoRows 같은 내부 오류를 직접 노출하는 대신 NotFoundError{Resource: "user", ID: id, Err: err}를 반환하세요.
오류를 검사할 때는 사용자 정의 타입에는 errors.As를, 센티넬 오류에는 errors.Is를 선호하세요. 간단한 경우에는 센티넬 오류(var ErrUnauthorized = errors.New("unauthorized"))가 유용하지만, 어떤 리소스가 없었는지 같은 안전한 컨텍스트가 필요하면 사용자 정의 타입이 더 낫습니다.
첨부하는 정보에 엄격하세요:
Err, 스택 정보, 원시 SQL 오류, 토큰, 사용자 데이터.이 분리는 내부를 노출하지 않고 클라이언트를 도울 수 있게 합니다.
타입화된 오류를 확보하면 다음 작업은 따분하지만 필수적입니다: 같은 오류 타입은 항상 같은 HTTP 상태를 산출해야 합니다. 클라이언트가 이에 기반한 로직을 만들기 때문입니다.
대부분의 API에 잘 맞는 실용적인 매핑:
| 오류 타입(예시) | 상태 | 사용 시기 |
|---|---|---|
| BadRequest (잘못된 JSON, 필수 쿼리 파라미터 누락) | 400 | 요청 자체가 프로토콜 또는 형식 수준에서 유효하지 않을 때. |
| Unauthenticated (토큰 없음/잘못됨) | 401 | 클라이언트가 인증해야 할 때. |
| Forbidden (권한 없음) | 403 | 인증은 되었지만 접근이 허용되지 않을 때. |
| NotFound (리소스 ID 없음) | 404 | 요청한 리소스가 없을 때(또는 존재 여부를 숨기기로 한 경우). |
| Conflict (고유 제약, 버전 불일치) | 409 | 요청은 형식상 올바르나 현재 상태와 충돌할 때. |
| ValidationFailed (필드 규칙 실패) | 422 | 형태는 맞지만 비즈니스 검증에 실패할 때(이메일 형식, 최소 길이 등). |
| RateLimited | 429 | 요청이 시간 창을 초과했을 때. |
| Internal (알 수 없는 오류) | 500 | 버그나 예기치 못한 실패. |
| Unavailable (의존성 다운, 타임아웃, 유지보수) | 503 | 일시적인 서버측 문제. |
혼동을 막는 두 가지 구분:
재시도 가이드라인:
요청 ID는 하나의 API 호출을 끝에서 끝까지 식별하는 짧은 고유 값입니다. 클라이언트가 모든 응답에서 이 ID를 보면 지원은 간단해집니다: "요청 ID를 보내 주세요"면 정확한 로그와 실패를 찾는 데 충분한 경우가 많습니다.
이 습관은 성공 응답과 오류 응답 모두에 유리합니다.
하나의 명확한 규칙을 사용하세요: 클라이언트가 요청 ID를 보내면 그대로 유지하고, 없으면 생성하세요.
X-Request-Id).요청 ID는 세 곳에 넣으세요:
request_id)배치 엔드포인트나 백그라운드 작업에는 부모 요청 ID를 유지하세요. 예: 클라이언트가 200개 행을 업로드하고 12개가 검증 실패하여 작업을 대기열에 넣을 경우, 전체 호출에 대해 하나의 request_id를 반환하고 각 작업 및 항목별 오류에 parent_request_id를 포함하세요. 이렇게 하면 많은 작업으로 팬아웃 되었을 때도 "하나의 업로드"를 추적할 수 있습니다.
클라이언트는 명확하고 안정적인 오류 응답이 필요합니다. 로그는 지저분한 진실을 필요로 합니다. 이 두 세계를 분리하세요: 클라이언트에는 안전한 메시지와 공개 오류 코드를 반환하고, 서버 로그에는 내부 원인, 스택, 컨텍스트를 남겨야 합니다.
오류 응답마다 하나의 구조화된 이벤트를 기록하고 request_id로 검색 가능하게 하세요.
일관되게 유지할 가치가 있는 필드:
내부 세부정보는 서버 로그(또는 내부 오류 저장소)에만 보관하세요. 클라이언트는 원시 데이터베이스 오류, 쿼리 텍스트, 스택 트레이스, 제공자 메시지를 절대 보지 않아야 합니다. 여러 서비스가 있으면 source(api, db, auth, upstream 등) 같은 내부 필드는 분류를 빠르게 합니다.
시끄러운 엔드포인트나 레이트 제한된 오류를 관찰하세요. 어떤 엔드포인트가 분당 수천 번 같은 429/400을 생성할 수 있으면 로그 스팸을 피하세요: 반복 이벤트를 샘플링하거나 예상 오류의 심각도를 낮추되 메트릭에는 계속 집계하세요.
메트릭은 로그보다 문제를 더 빨리 포착합니다. HTTP 상태와 오류 코드별로 집계된 카운트를 추적하고 급증을 경고하세요. 배포 후 RATE_LIMITED가 10배로 뛰면 샘플링된 로그를 보더라도 메트릭으로 빠르게 알 수 있습니다.
오류를 일관되게 만드는 가장 쉬운 방법은 오류를 "각자 처리"하는 것을 중지하고 하나의 작은 파이프라인으로 라우팅하는 것입니다. 그 파이프라인은 클라이언트에 보이는 것과 로그에 남기는 것을 결정합니다.
작은 집합의 오류 코드를 먼저 정의하세요(예: INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). 이들을 타입화된 오류로 래핑해 공개로 노출되는 안전한 필드(code, 안전한 message, 선택적 details)만 드러내고 내부 원인은 비공개로 유지하세요.
그 다음 어떤 오류든 (statusCode, responseBody)로 변환하는 번역 함수를 구현하세요. 이곳에서 타입화된 오류가 HTTP 상태 코드로 매핑되고, 알 수 없는 오류는 안전한 500 응답으로 바뀝니다.
다음으로 미들웨어를 추가하세요:
request_id가 있는지 보장패닉이 발생해도 절대 클라이언트에 스택 트레이스를 덤프하지 마세요. 일반적인 500 응답과 일반 메시지를 반환하고 동일한 request_id로 전체 패닉을 로그에 남기세요.
마지막으로 핸들러를 직접 응답을 쓰는 대신 error를 반환하도록 바꾸세요. 하나의 래퍼가 핸들러를 호출하고 번역기를 실행한 뒤 표준 형식으로 JSON을 작성합니다.
간단한 체크리스트:
골든 테스트는 계약을 고정시킵니다. 나중에 누군가 메시지나 상태 코드를 바꾸면 테스트가 실패해 클라이언트가 놀라기 전에 문제를 잡을 수 있습니다.
예: 클라이언트가 고객 레코드를 생성합니다.
POST /v1/customers에 { "email": "[email protected]", "name": "Pat" } 같은 JSON을 보냅니다. 서버는 항상 같은 오류 형태를 반환하고 항상 request_id를 포함합니다.
이메일이 없거나 형식이 잘못되었습니다. 클라이언트는 필드를 하이라이트할 수 있습니다.
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
이메일이 이미 존재합니다. 클라이언트는 로그인 권장이나 다른 이메일을 선택하라고 안내할 수 있습니다.
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
의존성 서비스가 다운되었습니다. 클라이언트는 백오프로 재시도하도록 유도하고 차분한 메시지를 보여줄 수 있습니다.
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
하나의 계약을 따르면 클라이언트는 일관되게 반응합니다:
details.fields를 사용해 필드를 표시request_id를 지원 ID로 표시지원에서는 그 request_id 하나로 내부 로그의 실제 원인에 빠르게 접근할 수 있고, 스택 트레이스나 DB 오류 같은 내부 정보를 노출할 필요가 없습니다.
클라이언트를 짜증나게 하는 가장 빠른 방법은 추측하도록 만드는 것입니다. 한 엔드포인트가 { "error": "..." }를 반환하고 다른 엔드포인트가 { "message": "..." }를 반환하면 모든 클라이언트는 특수 케이스의 더미가 되고 버그는 몇 주 동안 숨어 있습니다.
자주 보이는 실수들:
code를 제공하지 않음.request_id를 추가하여 성공 호출과 연결할 수 없게 함.내부 노출은 가장 쉽게 빠지는 함정입니다. 핸들러가 err.Error()를 편의상 반환하면 제약 이름이나 서드파티 메시지가 프로덕션 응답에 그대로 노출됩니다. 클라이언트 메시지는 짧고 안전하게 유지하고 상세 원인은 로그에 남기세요.
텍스트에만 의존하는 것도 서서히 문제를 일으킵니다. 클라이언트가 "email already exists" 같은 영어 문장을 파싱해야 하면 문구를 바꾸는 순간 동작이 깨질 수 있습니다. 안정적인 오류 코드는 메시지를 바꾸거나 번역해도 동작을 유지하게 해줍니다.
오류 코드를 공개 계약의 일부로 취급하세요. 변경이 필요하면 새 코드를 추가하고 당분간 이전 코드를 유지해 호환성을 보장하세요.
마지막으로 모든 응답(성공/실패)에 동일한 request_id 필드를 포함하세요. 사용자가 "되었다가 고장났다"고 말할 때 그 한 ID가 많은 추측을 줄여줍니다.
배포 전에 일관성을 위한 빠른 점검을 하세요:
error.code, error.message, request_id)를 반환.VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). 핸들러가 알 수 없는 코드를 반환하지 못하도록 테스트 추가.request_id를 반환하고 각 요청을 로그에 남김(패닉과 타임아웃 포함).그 후 몇 개의 엔드포인트를 수동으로 검사하세요. 검증 오류, 누락된 레코드, 예기치 못한 실패를 트리거하세요. 응답이 엔드포인트마다 다르게 보이면(필드가 바뀌거나 상태 코드가 일관되지 않거나 메시지가 내부 정보를 노출) 공유 파이프라인을 수정한 뒤 기능을 추가하세요.
실용적 규칙: 메시지가 공격자에게 도움이 되거나 정상 사용자를 혼란스럽게 한다면 응답에 포함하지 말고 로그에 남기세요.
이미 운영 중인 API여도 모든 엔드포인트가 따를 오류 계약(상태, 안정적인 오류 코드, 안전한 메시지, request_id)을 문서화하세요. 이것이 클라이언트에게 오류를 예측 가능하게 만드는 가장 빠른 방법입니다.
그다음 점진적으로 마이그레이션하세요. 기존 핸들러를 유지하면서 실패를 하나의 매퍼로 라우팅해 내부 오류를 공개 응답 형태로 바꾸면 위험한 대규모 리팩터 없이 일관성을 개선할 수 있고, 새로운 엔드포인트가 새로운 형식을 발명하는 것을 막을 수 있습니다.
작은 오류 코드 카탈로그를 만들고 이를 API의 일부로 관리하세요. 누군가 새 코드를 추가하려 하면 간단한 검토를 하세요: 정말 새로운가? 이름이 명확한가? 올바른 HTTP 상태에 매핑되는가?
계약의 이탈을 잡는 몇 가지 테스트를 추가하세요:
request_id가 포함되는가.error.code가 카탈로그에 있는 값인가.error.message는 안전하며 내부 세부정보를 포함하지 않는가.Go 백엔드를 새로 만든다면 초기 단계에서 계약을 고정해두는 것이 도움이 됩니다. 예를 들어, Koder.ai (koder.ai)는 계획 모드에서 오류 스키마와 코드 카탈로그 같은 규칙을 미리 정의하고 API가 성장할 때 핸들러를 계속 일치시킬 수 있게 도와줍니다.
하나의 JSON 형태로 모든 오류 응답을 통일하세요. 실용적인 기본 구조는 최상위에 request_id가 있고, code, message, 선택적 details 필드를 가진 error 객체를 포함하는 것입니다. 클라이언트는 이 구조를 신뢰해 오류를 파싱하고 대응할 수 있습니다.
클라이언트에 보이는 error.message는 짧고 안전한 문장으로 유지하고, 실제 원인은 서버 로그에 남기세요. 데이터베이스 오류, 스택 트레이스, 내부 호스트명이나 외부 제공자 메시지를 그대로 반환하지 마세요.
HTTP 상태는 넓은 범주를 설명하고, 기계가 처리할 분기는 error.code에 맡기세요. 예: ALREADY_EXISTS 같은 안정적인 error.code로 클라이언트 로직을 분기하고, 상태 코드는 참고용으로 사용합니다.
요청을 제대로 파싱할 수 없거나 해석할 수 없을 때(잘못된 JSON, 타입 불일치)는 400을 사용하세요. 요청은 파싱되지만 비즈니스 규칙을 위반할 때(이메일 형식 불일치 등)는 422를 사용하세요.
입력값 자체는 유효하지만 현재 서버 상태와 충돌할 때(이미 사용 중인 이메일, 버전 불일치)는 409를 사용하세요. 필드 수준의 검증 오류는 422가 적절합니다.
작은 집합의 타입화된 오류(검증 오류, 미발견, 충돌, 권한 없음, 내부 오류)를 만들고 핸들러는 이들을 반환하게 하세요. 그런 다음 공통 번역기(translator)가 이러한 타입을 상태 코드와 표준 JSON 응답으로 매핑합니다.
모든 응답(성공/실패)에 request_id를 포함하고, 서버 로그의 모든 라인에도 이 ID를 남기세요. 문제가 보고되면 이 하나의 ID만으로도 정확한 요청 경로와 로그를 찾을 수 있어 디버그가 빨라집니다.
작업이 성공했을 때만 200을 사용하고, 오류는 적절한 4xx/5xx 상태로 반환하세요. 200 안에 { "ok": false } 같은 형태로 오류를 숨기면 클라이언트가 본문을 파싱하도록 강제되어 일관성이 깨집니다.
일반적으로 재시도 허용: 503, 경우에 따라 429(대기 후). 변경 없이는 재시도가 도움이 되지 않으므로 기본적으로 400, 401, 403, 404, 409, 422는 재시도하지 마세요. idempotency 키가 있다면 POST 재시도도 안전해질 수 있습니다.
골든 테스트로 계약을 잠그세요: 모든 오류 응답에 request_id가 포함되는지, 상태 코드가 오류 타입과 일치하는지, error.code가 코드 목록에 있는지 등을 검증하면 진화로 인한 일관성 붕괴를 막을 수 있습니다.