backend

Go API 에러 응답 규약 정리: 실무에서 흔들리지 않는 기준 만들기

mirabo01 2026. 2. 4. 22:43

트랜잭션까지 정리했다면,
이제 서버 코드에서 마지막으로 꼭 잡아야 하는 기준이 남아 있다.
바로 API 에러 응답 규약이다.

에러 처리를 대충 넘기면 초반에는 편해 보이지만,
운영 단계에 들어가면 다음 문제가 바로 터진다.

  • 프론트엔드에서 에러를 분기하기 어렵다
  • 로그에는 에러가 있는데, 클라이언트에는 의미 없는 메시지만 내려간다
  • 같은 에러인데 API마다 응답 형식이 다르다

이번 챕터에서는
Go + Gin 기반 API 서버에서 실무적으로 가장 많이 쓰는 에러 처리 기준을 정리한다.


API 에러 처리의 목표부터 명확히 하자

에러 처리는 “깔끔한 코드”를 위한 게 아니다.
목표는 딱 세 가지다.

  1. 클라이언트가 분기 처리할 수 있어야 하고
  2. 운영자가 로그로 원인을 추적할 수 있어야 하며
  3. 에러 표현이 서버 전체에서 일관되어야 한다

이 세 가지를 만족하면
에러 설계는 절반 이상 성공이다.


흔히 보이는 잘못된 에러 응답

실무에서 정말 자주 보게 되는 형태다.

{
  "message": "something went wrong"
}

이 응답의 문제점은 명확하다.

  • 어떤 에러인지 알 수 없음
  • 코드로 분기 불가능
  • 로그 없이는 원인 추적 불가

이 방식은
개발 초반 테스트용으로도 오래 쓰면 안 된다.


에러 응답의 최소 구성 요소

실무 기준으로 가장 단순하면서도 충분한 구조는 다음이다.

{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "user not found"
  }
}

여기서 핵심은 두 가지다.

  • code: 프로그래밍적으로 분기할 값
  • message: 사람이 읽을 메시지

HTTP status code는
이 에러의 카테고리를 나타내는 역할이다.


HTTP Status Code는 이렇게 쓴다

실무에서 흔히 쓰는 기준은 이 정도면 충분하다.

  • 400 Bad Request
    → 요청 자체가 잘못됨 (파라미터, JSON 형식 등)
  • 401 Unauthorized
    → 인증 실패
  • 403 Forbidden
    → 인증은 됐지만 권한 없음
  • 404 Not Found
    → 리소스 없음
  • 409 Conflict
    → 중복, 상태 충돌
  • 500 Internal Server Error
    → 서버 내부 문제

⚠️ 주의할 점
HTTP status로 모든 에러를 구분하려고 하면
오히려 복잡해진다.
세부 분기는 error code로 한다.


에러 코드는 문자열로 고정한다

에러 코드는 반드시 문자열 상수로 정의한다.

const (
    ErrUserNotFound   = "USER_NOT_FOUND"
    ErrInvalidInput   = "INVALID_INPUT"
    ErrUnauthorized   = "UNAUTHORIZED"
)

이렇게 하면

  • 프론트엔드와 협업하기 쉽고
  • 문서화가 간단해지며
  • 로그에서도 바로 검색 가능하다

숫자 코드보다
문자열 코드가 유지보수에 훨씬 유리하다.


공통 API 에러 타입 만들기

에러를 구조화하기 위해
공통 에러 타입을 하나 정의한다.

type APIError struct {
    Code       string
    Message    string
    StatusCode int
}
func (e *APIError) Error() string {
    return e.Message
}

이렇게 하면
APIError는 Go의 error 인터페이스를 그대로 만족한다.


service에서는 error를 반환한다

중요한 기준 하나.

service는 HTTP를 모른다

즉, service에서는
*gin.Context도, StatusCode도 직접 다루지 않는다.

func (s *UserService) GetUser(id int) (User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        return User{}, &APIError{
            Code:       ErrUserNotFound,
            Message:    "user not found",
            StatusCode: 404,
        }
    }
    return user, nil
}

service는
의미 있는 에러를 반환하는 데까지만 책임진다.


handler에서 에러를 HTTP 응답으로 변환한다

에러를 HTTP로 바꾸는 책임은 handler에 있다.

func (h *UserHandler) Get(c *gin.Context) {
    user, err := h.service.GetUser(id)
    if err != nil {
        handleError(c, err)
        return
    }

    c.JSON(200, user)
}
func handleError(c *gin.Context, err error) {
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        c.JSON(apiErr.StatusCode, gin.H{
            "error": gin.H{
                "code":    apiErr.Code,
                "message": apiErr.Message,
            },
        })
        return
    }

    // 알 수 없는 에러
    c.JSON(500, gin.H{
        "error": gin.H{
            "code":    "INTERNAL_ERROR",
            "message": "internal server error",
        },
    })
}

이 패턴의 장점은 분명하다.

  • 에러 응답 형식이 서버 전체에서 동일
  • service / handler 책임 분리
  • 테스트가 쉬움

로그는 어디서 남겨야 할까

⚠️ 중요한 기준

  • client 응답용 메시지 ≠ 로그 메시지

보통 다음 기준이 안정적이다.

  • service: 비즈니스 에러만 반환
  • handler 또는 middleware: 에러 로그 기록
log.Error("get user failed", zap.Error(err))

클라이언트에는
필요한 정보만 내려보내고,
로그에는 원인 추적에 필요한 정보를 남긴다.


validation 에러는 어떻게 다룰까

요청 값 검증 에러는
보통 하나의 규칙으로 묶는다.

{
  "error": {
    "code": "INVALID_INPUT",
    "message": "email is required"
  }
}
  • 필드별 상세 에러가 필요하면 확장
  • 기본 구조는 유지

처음부터 너무 복잡한 validation 응답을 만들 필요는 없다.


이 구조의 핵심 장점

이 에러 구조를 적용하면 다음이 가능해진다.

  • 프론트엔드에서 에러 코드 기준 분기
  • API 문서화가 쉬워짐
  • 에러 처리 리팩터링 범위 최소화
  • 로그와 사용자 메시지 분리

즉,
운영 단계에서 진짜 차이가 난다.


정리

API 에러 처리는
“에러를 예쁘게 보여주는 문제”가 아니라
“운영 가능한 시스템을 만드는 문제”다.

  • HTTP status는 카테고리
  • error code는 분기 기준
  • service는 의미 있는 에러 반환
  • handler는 HTTP 응답으로 변환

여기까지 왔다면,
이 Go API 서버 시리즈는 실무 기준 핵심 라인을 거의 다 밟은 셈이다.

다음으로 이어가기에 가장 좋은 주제는

👉 운영 환경 로그 전략 + 요청 단위 트레이싱
👉 또는 이 시리즈 전체를 마무리하는 실전 정리 편

중 하나다.

다음 주제, 바로 갈까?