Go API 에러 응답 규약 정리: 실무에서 흔들리지 않는 기준 만들기
트랜잭션까지 정리했다면,
이제 서버 코드에서 마지막으로 꼭 잡아야 하는 기준이 남아 있다.
바로 API 에러 응답 규약이다.
에러 처리를 대충 넘기면 초반에는 편해 보이지만,
운영 단계에 들어가면 다음 문제가 바로 터진다.
- 프론트엔드에서 에러를 분기하기 어렵다
- 로그에는 에러가 있는데, 클라이언트에는 의미 없는 메시지만 내려간다
- 같은 에러인데 API마다 응답 형식이 다르다
이번 챕터에서는
Go + Gin 기반 API 서버에서 실무적으로 가장 많이 쓰는 에러 처리 기준을 정리한다.
API 에러 처리의 목표부터 명확히 하자
에러 처리는 “깔끔한 코드”를 위한 게 아니다.
목표는 딱 세 가지다.
- 클라이언트가 분기 처리할 수 있어야 하고
- 운영자가 로그로 원인을 추적할 수 있어야 하며
- 에러 표현이 서버 전체에서 일관되어야 한다
이 세 가지를 만족하면
에러 설계는 절반 이상 성공이다.
흔히 보이는 잘못된 에러 응답
실무에서 정말 자주 보게 되는 형태다.
{
"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 서버 시리즈는 실무 기준 핵심 라인을 거의 다 밟은 셈이다.
다음으로 이어가기에 가장 좋은 주제는
👉 운영 환경 로그 전략 + 요청 단위 트레이싱
👉 또는 이 시리즈 전체를 마무리하는 실전 정리 편
중 하나다.
다음 주제, 바로 갈까?