API를 만들 때 성공 응답은 비교적 신경을 많이 쓰게 됩니다. data, meta, DTO, pagination 같은 구조를 맞추고, 앱 화면에서 사용하기 좋은 형태로 응답을 정리하죠.
그런데 오류 응답은 생각보다 뒤로 밀리기 쉽습니다. 인증 실패, 권한 부족, 유효성 검증 실패, rate limit, 서버 오류가 모두 다른 형식으로 반환되면 모바일 앱은 오류 처리를 안정적으로 만들기 어렵습니다.
앱 API에서는 오류 응답도 하나의 계약입니다. HTTP status만 맞추는 것으로 끝나지 않고, 앱이 화면 안내, 재로그인, 입력값 표시, 재시도, 고객센터 문의 같은 흐름을 만들 수 있도록 JSON 구조를 고정해야 합니다.
처음에는 오류 상황마다 필요한 메시지만 내려주면 충분해 보일 수 있습니다. 하지만 실제로 정리해보면 오류 응답 형식이 흔들릴수록 앱 쪽 공통 처리가 점점 복잡해지는 것 같습니다. 이 글에서는 Backend 앱 API에서 오류 응답 형식을 고정하는 기준을 정리해보겠습니다.
이 글에서 다룰 문제
오류 응답 형식이 고정되어 있지 않으면 다음 문제가 생길 수 있습니다.
- 어떤 API는
message, 어떤 API는error, 어떤 API는 HTML 오류 페이지를 반환한다. - 앱 interceptor가 공통 오류 처리를 만들기 어렵다.
- 인증 만료와 권한 부족을 앱에서 구분하지 못한다.
- 유효성 검증 오류를 입력 필드별로 표시하기 어렵다.
- rate limit 초과 시 재시도 안내를 할 수 없다.
- 서버 오류 응답에 stack trace, SQL, 내부 경로가 노출된다.
requestId가 없어 로그 추적이 어렵다.
오류 응답은 실패 상황에서 사용자 경험을 결정합니다. 그래서 성공 응답만큼 일관성이 중요합니다. 특히 모바일 앱에서는 응답 형식이 조금만 달라져도 공통 오류 처리 흐름이 흔들릴 수 있습니다.
오류 응답의 기본 구조
앱 API에서는 아래처럼 공통 구조를 두는 것이 좋습니다. 앱은 code를 기준으로 분기하고, 사용자는 message를 통해 상황을 이해하며, 개발자는 requestId로 서버 로그를 추적할 수 있습니다.
{
"error": {
"code": "VALIDATION_FAILED",
"message": "입력값을 확인해 주세요.",
"details": {
"title": ["제목은 필수입니다."]
},
"requestId": "req_sample_123"
}
}
각 필드의 역할은 분명히 나누는 것이 좋습니다. 중요한 점은 앱이 메시지 문자열만 보고 로직을 분기하지 않도록 하는 것입니다. 문구는 언제든 바뀔 수 있기 때문에, 분기 기준은 고정된 오류 코드가 되어야 합니다.
| 필드 | 역할 | 예시 |
|---|---|---|
code |
앱이 분기 처리할 고정 코드 | AUTH_EXPIRED |
message |
사용자 또는 앱 표시용 기본 메시지 | 다시 로그인해 주세요. |
details |
필드별 오류나 추가 정보 | { "email": ["형식이 올바르지 않습니다."] } |
requestId |
서버 로그 추적용 ID | req_sample_123 |
HTTP status와 error code를 함께 쓰기
HTTP status는 오류의 큰 범주를 알려주고, error code는 앱 내부 처리 기준을 알려줍니다. 모든 오류를 200으로 내려보내는 방식은 피하는 것이 좋습니다. 앱과 중간 프록시, 모니터링 도구가 오류를 제대로 인식하기 어렵기 때문입니다.
| 상황 | HTTP status | error code |
|---|---|---|
| 인증 없음 | 401 |
AUTH_REQUIRED |
| 토큰 만료 | 401 |
AUTH_EXPIRED |
| 권한 부족 | 403 |
FORBIDDEN |
| 데이터 없음 | 404 |
NOT_FOUND |
| 유효성 검증 실패 | 422 |
VALIDATION_FAILED |
| 요청 과다 | 429 |
RATE_LIMITED |
| 서버 오류 | 500 |
INTERNAL_ERROR |
처음에는 HTTP status만으로 충분하다고 생각하기 쉽습니다. 하지만 앱에서는 같은 401이라도 로그인 자체가 필요한 상황인지, 토큰 갱신이 필요한 상황인지 구분해야 할 수 있습니다. 그래서 status와 error code를 함께 쓰는 편이 더 안정적입니다.
Laravel에서 공통 오류 응답 만들기
Laravel 기준으로는 exception handler나 middleware에서 API 요청에 대해 JSON 오류를 통일할 수 있습니다.
final class ApiErrorResponse
{
public static function make(
string $code,
string $message,
int $status,
array $details = [],
?string $requestId = null,
): JsonResponse {
return response()->json([
'error' => array_filter([
'code' => $code,
'message' => $message,
'details' => $details ?: null,
'requestId' => $requestId,
], fn ($value) => $value !== null),
], $status);
}
}
controller마다 오류 배열을 직접 만들면 형식이 흔들리기 쉽습니다. 공통 helper나 response class로 묶어두면 오류 응답 형태를 일정하게 유지할 수 있습니다.
처음에는 작은 오류 응답 하나를 위해 클래스를 만드는 것이 과하게 느껴질 수 있습니다. 하지만 API가 늘어나면 응답 형식이 흔들리는 문제를 막는 데 도움이 됩니다.
유효성 검증 오류 형식
유효성 검증 오류는 앱 화면에서 입력 필드별로 표시해야 할 수 있습니다. 그래서 details에는 필드별 메시지를 담아두는 편이 좋습니다.
{
"error": {
"code": "VALIDATION_FAILED",
"message": "입력값을 확인해 주세요.",
"details": {
"email": ["이메일 형식이 올바르지 않습니다."],
"password": ["비밀번호는 8자 이상이어야 합니다."]
},
"requestId": "req_sample_456"
}
}
여기서 details는 앱에서 필드별 메시지를 보여주기 위한 데이터입니다. 내부 validation rule 이름이나 DB 컬럼명을 그대로 노출하지 않도록 주의해야 합니다. 서버에서 받은 validation 메시지를 그대로 내려주는 방식은 빠르지만, 앱 화면과 사용자 안내에 맞게 정리된 메시지인지 반드시 확인해야 합니다.
rate limit 오류 형식
rate limit 오류는 재시도 시간을 알려주면 앱에서 처리하기 좋습니다. 사용자가 같은 요청을 계속 반복하지 않도록 버튼을 잠시 비활성화하거나 안내 문구를 보여줄 수 있기 때문입니다.
{
"error": {
"code": "RATE_LIMITED",
"message": "요청이 많습니다. 잠시 후 다시 시도해 주세요.",
"details": {
"retryAfterSeconds": 30
},
"requestId": "req_sample_789"
}
}
앱에서는 retryAfterSeconds 값을 보고 재시도 타이밍을 조절할 수 있습니다. 사용자에게 내부 제한 정책이나 IP 정보까지 보여줄 필요는 없습니다.
서버 오류에서 숨겨야 할 정보
서버 오류는 가장 조심해야 합니다. 개발 환경에서는 stack trace가 필요할 수 있지만, 운영 API 응답에 그대로 내려가면 안 됩니다. 운영 환경에서는 일반 메시지와 requestId만 내려주고, 자세한 오류는 서버 로그에서 확인하는 방식이 안전합니다.
| 노출하면 안 되는 정보 | 이유 |
|---|---|
| stack trace | 내부 코드 구조 노출 |
| SQL query | DB 구조와 조건 노출 |
| 서버 절대 경로 | 인프라 구조 노출 |
| 환경 변수 | secret 노출 위험 |
| 토큰, API key | 인증 정보 노출 |
| 관리자 URL | 내부 경로 노출 |
처음에는 디버깅 편의를 위해 오류 내용을 자세히 내려주고 싶을 수 있습니다. 하지만 운영 응답은 사용자와 외부 클라이언트가 볼 수 있는 영역입니다. 자세한 정보는 로그로 남기고, API 응답은 안전한 메시지로 제한하는 편이 좋습니다.
앱 interceptor와 연결하기
오류 응답이 고정되어 있으면 앱에서는 공통 interceptor를 만들기 쉽습니다. 아래 예시는 API 오류 응답을 앱 내부에서 사용하기 좋은 형태로 정규화하는 코드입니다.
type ApiErrorBody = {
error?: {
code?: string;
message?: string;
details?: Record<string, unknown>;
requestId?: string;
};
};
function normalizeApiError(status: number, body: ApiErrorBody) {
const error = body.error;
return {
status,
code: error?.code || 'UNKNOWN_ERROR',
message: error?.message || '요청을 처리하지 못했습니다.',
details: error?.details || {},
requestId: error?.requestId,
};
}
이렇게 정규화하면 화면에서는 오류 코드를 기준으로 재로그인, 안내, 재시도, 입력 필드 표시를 분기할 수 있습니다. 앱에서 매번 응답 형태를 추측하지 않아도 되기 때문에 오류 처리가 훨씬 단순해집니다.
오류 코드 설계 기준
오류 코드는 너무 많아도 관리가 어렵고, 너무 적어도 앱이 분기하기 어렵습니다. 문자열로 관리하되, 서버와 앱이 같은 의미로 이해할 수 있도록 문서화해야 합니다.
| 코드 | 앱 처리 예시 |
|---|---|
AUTH_REQUIRED |
로그인 화면으로 이동 |
AUTH_EXPIRED |
토큰 갱신 또는 재로그인 안내 |
FORBIDDEN |
권한 없음 안내 |
NOT_FOUND |
삭제되었거나 없는 데이터 안내 |
VALIDATION_FAILED |
입력 필드별 오류 표시 |
RATE_LIMITED |
일정 시간 후 재시도 안내 |
INTERNAL_ERROR |
일반 오류 안내와 requestId 표시 |
오류를 너무 세세하게 나누면 오히려 관리가 어려워집니다. 사용자 흐름이 달라지는 지점을 기준으로 코드를 나누는 편이 좋습니다.
테스트 기준
오류 응답은 성공 응답만큼 테스트가 필요합니다. 특히 서버 오류 응답에 내부 정보가 없는지 확인하는 테스트는 보안 관점에서도 중요합니다.
- 인증 실패가
401과AUTH_REQUIRED또는AUTH_EXPIRED로 내려가는지 확인한다. - 권한 부족이
403과FORBIDDEN으로 내려가는지 확인한다. - validation 오류가
422와details구조로 내려가는지 확인한다. - rate limit 오류가
429와retryAfterSeconds를 포함하는지 확인한다. - 서버 오류 응답에 stack trace, SQL, 내부 경로가 없는지 확인한다.
- 모든 API 오류 응답이 같은 error wrapper를 사용하는지 확인한다.
requestId가 로그와 응답에 연결되는지 확인한다.
자주 하는 실수
1. 오류 메시지 문자열로 앱 로직을 분기하는 경우가 있습니다. 메시지는 바뀔 수 있으므로 code를 기준으로 처리해야 합니다.
2. validation 오류 구조를 API마다 다르게 만드는 경우도 있습니다. 응답 구조가 흔들리면 앱에서 입력 필드별 오류 표시가 복잡해집니다.
3. 운영 환경에서 디버그 오류를 그대로 내려보내는 경우가 있습니다. stack trace, SQL, 내부 경로는 응답에 포함되면 안 됩니다.
4. 인증 실패와 권한 부족을 같은 오류로 처리하는 경우도 있습니다. 앱에서는 재로그인이 필요한지, 권한 안내만 필요한지 구분해야 합니다.
결론
Backend 앱 API에서 오류 응답 형식은 성공 응답만큼 중요한 계약입니다. 인증 실패, 권한 부족, 유효성 검증, rate limit, 서버 오류가 모두 다른 형식으로 내려가면 앱은 안정적인 공통 처리를 만들 수 없습니다.
좋은 구조는 HTTP status, error code, message, details, requestId를 일관되게 내려주는 것입니다. 앱은 error code를 기준으로 분기하고, 사용자는 메시지를 통해 상황을 이해하며, 개발자는 requestId로 서버 로그를 추적할 수 있습니다.
특히 운영 환경에서는 stack trace, SQL, 내부 경로, 토큰 같은 민감 정보가 오류 응답에 포함되지 않도록 주의해야 합니다. 오류 응답은 실패 상황에서 가장 많이 보이는 API 계약이라는 점을 기억하는 것이 좋습니다.
참고 자료
- Laravel Exception Handler, Validation, Rate Limiting 공식 문서
- HTTP status code와 REST API 오류 응답 설계 문서
- 모바일 앱 API interceptor 설계 문서
- 프로젝트 내부 오류 코드, requestId, 로그 추적 문서
'개발 노트 > Backend,CMS,API' 카테고리의 다른 글
| [Backend/API] 파일과 이미지 업로드 보안을 설계하는 기준 (0) | 2026.05.21 |
|---|---|
| [Backend/API] 관리자 인증과 앱 토큰 인증을 분리하는 기준 (0) | 2026.05.21 |
| [Backend/CMS] CMS를 Next.js로 전환할 때 API 경계를 유지하는 기준 (0) | 2026.05.21 |
| [Backend/API] 캐시와 rate limit을 설계하는 기준 (0) | 2026.05.21 |
| [Backend/API] 목록 조회 pagination, search, filter를 설계하는 기준 (0) | 2026.05.20 |