모바일 앱 API 오류 응답 형식을 하나로 고정해야 하는 이유와 설계 방법 (Laravel 예시)
웹과 모바일 앱 API를 같이 개발하고 운영하다 보면, 성공 응답은 data, meta, pagination 같은 구조를 예쁘게 잘 맞춥니다. 앱 화면에서 바로 쓰기 좋은 형태로 신경을 많이 쓰죠.
그런데 오류 응답은 생각보다 뒤로 밀리기 쉽습니다. 저의 경우도 처음에는 대충 에러 메시지만 던져줬다가, 앱 쪽에서 예외 처리가 자꾸 꼬이는 문제를 겪었습니다. 어떤 API는 message로 오고, 어떤 API는 error로 오고, 서버가 터지면 HTML 오류 페이지가 날아오니 앱 인터셉터(Interceptor)에서 공통 처리를 만들기가 불가능하더군요.
앱 API에서 오류 응답은 하나의 '계약'입니다. HTTP status만 맞추는 걸로 끝내면 안 되고, 앱이 화면 안내, 재로그인, 필드별 에러 표시, 재시도 같은 흐름을 안정적으로 만들 수 있게 JSON 구조를 완전히 고정해야 합니다. 자꾸 까먹기도 해서 이번 기회에 확실한 기준을 정리해 둡니다.
오류 응답 형식을 고정하지 않으면 생기는 문제들
운영하다 보면 아래와 같은 상황 때문에 앱 개발자와 서버 개발자 모두 골치가 아파집니다.
- 일관성 없는 포맷: 어떤 건 message, 어떤 건 error 배열로 내려와서 앱 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 |
주의할 점
절대 앱에서 메시지 문자열(message)만 보고 로직을 분기하게 만들면 안 됩니다. 기획이나 문구는 언제든 바뀔 수 있기 때문에, 분기 기준은 무조건 고정된 code가 되어야 합니다.
HTTP Status와 Error Code는 같이 써야 합니다
종끔 모든 오류를 HTTP 200 성공으로 내려보내고 내부 JSON으로만 에러를 구분하는 방식을 쓰기도 하는데, 피하는 것이 좋습니다. 중간 프록시나 모니터링 도구(APM), Nginx 단계에서 에러를 제대로 인지하지 못하기 때문입니다.
HTTP status는 오류의 큰 범주를 알려주고, 내부 error code는 앱이 해야 할 구체적인 행동을 지정합니다.
| 상황 | HTTP Status | Error Code | 앱 처리 예시 |
| 인증 없음 | 401 | AUTH_REQUIRED | 로그인 화면으로 이동 |
| 토큰 만료 | 401 | AUTH_EXPIRED | 토큰 갱신(Refresh) 후 재시도 |
| 권한 부족 | 403 | FORBIDDEN | 권한 없음 안내 팝업 |
| 데이터 없음 | 404 | NOT_FOUND | 삭제되었거나 없는 데이터 안내 |
| 유효성 검증 실패 | 422 | VALIDATION_FAILED | 입력 필드 밑에 에러 문구 표시 |
| 요청 과다 | 429 | RATE_LIMITED | 일정 시간 버튼 비활성화 및 안내 |
| 서버 오류 | 500 | INTERNAL_ERROR | 시스템 에러 안내 및 고객센터 문의 |
Laravel에서 공통 오류 응답 클래스 만들기
컨트롤러마다 오류 배열을 직접 타이핑하다 보면 형식이 무조건 흔들립니다. Laravel 기준으로는 Exception Handler나 별도 헬퍼 클래스를 만들어 묶어두는 편이 안전합니다.
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
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);
}
}
처음에는 에러 응답 하나 만들자고 클래스까지 파야 하나 싶지만, API 개수가 늘어날수록 이 구조가 빛을 발합니다. 형식이 깨지는 걸 원천 차단할 수 있습니다.
주요 상황별 활용 예시
1. 유효성 검증(Validation) 오류 형식
폼 입력 에러는 앱 화면의 각 입력 필드 바로 밑에 뿌려주는 경우가 많습니다. 그래서 details에 필드명을 Key로 하는 배열을 담아줍니다.
{
"error": {
"code": "VALIDATION_FAILED",
"message": "입력값을 확인해 주세요.",
"details": {
"email": ["이메일 형식이 올바르지 않습니다."],
"password": ["비밀번호는 8자 이상이어야 합니다."]
},
"requestId": "req_sample_456"
}
}
주의: 내부 validation rule 이름(예: Required, Unique)이나 DB 컬럼명을 가공 없이 노출하기보다, 앱 화면에서 사용자에게 바로 보여줄 수 있는 정제된 문구로 내려주는 게 좋습니다.
2. Rate Limit(요청 과다) 오류 형식
디도스 방지나 API 호출 제한에 걸렸을 때는 앱이 잠시 멈췄다 재시도할 수 있게 타이밍을 주는 것이 좋습니다.
{
"error": {
"code": "RATE_LIMITED",
"message": "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.",
"details": {
"retryAfterSeconds": 30
},
"requestId": "req_sample_789"
}
}
앱에서는 retryAfterSeconds 값을 보고 재시도 타이머를 돌리거나 등록 버튼을 잠시 비활성화할 수 있습니다. 외부에 구체적인 IP 정책이나 제한 기준까지 노출할 필요는 없습니다.
운영(Production) 서버 오류에서 숨겨야 할 정보
500 Internal Server Error는 가장 조심해야 하는 영역입니다. 로컬이나 개발(Dev) 환경에서는 대놓고 에러를 봐야 고치니까 상세히 내보내지만, 운영 환경 응답에 그대로 나가면 보안상 아주 위험합니다.
- stack trace, SQL query: DB 테이블 구조와 조건, 내부 코드 아키텍처가 통째로 노출됩니다.
- 서버 절대 경로, 환경 변수: 인프라 구조나 내부 Secret, API key 정보가 털릴 위험이 있습니다.
운영 환경에서는 에러 가해자를 추적할 수 있는 requestId와 안심할 수 있는 일반 메시지만 보여주고, 상세한 에러 로그는 서버 내부 파일(Log)이나 Sentry 같은 모니터링 도구로만 쌓이도록 Exception Handler 단에서 철저히 마스킹해야 합니다.
앱 Interceptor와 매끄럽게 연결하기
서버 에러 규격이 하나로 딱 고정되면, 프론트엔드나 모바일 앱(Axios, Fetch 등)에서 공통 인터셉터를 구성하기가 정말 편해집니다. 아래는 TypeScript 기반의 간단한 에러 정규화 예시입니다.
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,
};
}
이렇게 에러 객체를 일관되게 정규화해 두면, UI 컴포넌트나 화면 단에서는 서버 응답 형태를 매번 추측하고 예외 처리할 필요 없이 code 조건문 몇 개로 깔끔하게 대응할 수 있습니다.
배포 전 체크리스트 (자주 하는 실수)
운영계에 적용하기 전에 아래 항목들은 꼭 테스트로 검증해 보는 편이 좋습니다.
- 에러 문구 분기 여부: 인증 실패 시 401 status와 함께 상황에 따라 AUTH_REQUIRED와 AUTH_EXPIRED 코드가 명확히 구분되어 내려가는가?
- 구조 일관성: Validation 에러(422)가 터졌을 때 내가 약속한 details 구조를 그대로 유지하는가? API마다 구조가 다르면 앱 코드가 터집니다.
- 민감 정보 차단: 운영(Production) 환경에서 일부러 500 에러를 유도했을 때 stack trace나 내부 SQL 쿼리가 완벽히 숨겨지는가?
- 추적성: 모든 API 오류 응답에 requestId가 누락 없이 포함되어 있으며, 실제 서버 로그 시스템의 ID와 매칭되는가?
마무리
API 설계할 때 성공 구조에만 매몰되기 쉽지만, 결국 사용자 경험(UX)과 서비스 안정성을 결정짓는 건 에러가 났을 때 앱이 어떻게 유연하게 대처하느냐인 것 같습니다.
처음 규격을 잡고 예외 처리 핸들러를 공통화하는 작업이 조금 귀찮을 수 있어도, 한 번 뼈대를 잘 세워두면 앱 개발자와 소통할 때 에러 포맷 문제로 커뮤니케이션 리소스가 낭비되는 일을 확실히 줄일 수 있습니다. 나중에 다른 프로젝트 시작할 때도 이 포맷 그대로 들고 가서 써야겠습니다.