프로그래밍/Python

📨 응답 형식 불일치: 프론트엔드에서 예상하는 JSON 구조와 다름

Tiboong 2026. 2. 15. 17:33

🚨 이런 상황, 겪어보셨나요?

"백엔드에서는 성공이라고 하는데, 프론트에서는 에러로 처리됩니다."

"같은 API인데 상품 목록은 배열로 오고, 상품 상세는 객체로 와요. 프론트에서 매번 분기 처리해야 합니다."

"에러가 났는데 500 에러만 돌아와요. 어디서 뭔 문제인지 메시지가 없습니다."

"성공할 때는 data에 담기고, 실패할 때는 error에 담기고, 어떤 API는 그냥 바로 배열이 오고... 프론트 개발자가 멘붕했습니다."

 

Django REST Framework(DRF)로 API를 만들다 보면, 응답 형식의 일관성을 생각하지 않는 경우가 많습니다. DRF가 기본으로 제공하는 응답을 그대로 쓰면 되니까 편하긴 하죠.

 

그런데 API가 10개, 20개, 50개로 늘어나면서, 각각의 API가 제각각의 형식으로 응답하기 시작하면 프론트엔드는 매번 다른 규칙으로 데이터를 파싱해야 하는 지옥에 빠집니다.


🔥 실제 상황: 프론트엔드 개발자의 하루

한 스타트업에서 백엔드 2명, 프론트엔드 2명이 쇼핑몰 API를 개발하고 있었습니다. 백엔드 개발자 A와 B는 각자 담당한 API를 만들었는데, 응답 형식이 전부 달랐습니다.

 

개발자 A의 상품 목록 API:

[
    {"id": 1, "name": "스니커즈", "price": 89000},
    {"id": 2, "name": "러닝화", "price": 129000}
]

 

개발자 B의 주문 목록 API:

{
    "status": "success",
    "result": [
        {"order_id": 100, "total": 89000},
        {"order_id": 101, "total": 218000}
    ]
}

 

상품 상세 API (성공):

{"id": 1, "name": "스니커즈", "price": 89000, "stock": 50}

 

상품 상세 API (실패):

{"detail": "Not found."}

 

주문 생성 API (성공):

{"status": "success", "message": "주문이 완료되었습니다.", "order_id": 102}

 

주문 생성 API (실패):

{"status": "fail", "errors": {"quantity": ["재고가 부족합니다."]}}

 

프론트엔드 개발자가 마주한 현실:

  1. 성공 응답의 구조가 API마다 다름 — 어떤 API는 날것 데이터, 어떤 API는 result로 감싸져 있고, 어떤 API는 data로 감싸져 있음
  2. 에러 응답의 구조도 API마다 다름 — 어떤 건 detail 키, 어떤 건 errors 키, 어떤 건 message 키
  3. HTTP 상태 코드와 응답 본문의 불일치 — 400 에러인데 status: "success"가 들어오는 경우도 있음
  4. 볈값 없는 의미의 status 필드 — HTTP 상태 코드로 성공/실패를 이미 알 수 있는데, 본문에 또 status: "success"를 넣음

결과적으로 프론트엔드 코드는 API마다 다른 파싱 로직을 작성해야 했고, 새로운 API가 추가될 때마다 "이번엔 응답이 어떤 형식이야?"라는 질문이 반복되었습니다.


🎯 응답 형식 불일치가 뭔데, 왜 이렇게 중요할까?

택배 상자 비유로 이해하기

API 응답 형식을 이해하려면 택배 상자를 떠올려보세요.

 

택배를 받을 때 상자의 형태가 매번 다르다고 상상해보세요. 어떤 건 갈색 상자에 송장이 위에 붙어 있고, 어떤 건 흰 상자에 송장이 안에 들어 있고, 어떤 건 비닐봉투에 송장이 없이 온다면. 받는 사람은 매번 "이건 어디를 열어야 하지?", "송장이 어디 있지?"를 고민해야 합니다.

반면, 모든 택배가 같은 형태의 상자, 같은 위치의 송장으로 온다면? 받는 사람은 생각할 필요 없이 바로 상자를 열고 송장을 확인할 수 있습니다.

 

API 응답도 똑같습니다. 통일된 포맷으로 오면 프론트엔드는 한 번만 파싱 로직을 작성하면 되지만, 매번 다른 형식이면 API마다 별도의 로직을 작성해야 합니다.

DRF 기본 응답의 문제점

DRF를 기본 설정 그대로 사용하면, 상황마다 다른 형식의 응답이 나갑니다.

 

목록 조회 (ListAPIView):

[{"id": 1}, {"id": 2}]

 

단건 조회 (RetrieveAPIView):

{"id": 1, "name": "스니커즈"}

 

생성 성공 (CreateAPIView):

{"id": 3, "name": "새 상품"}

 

유효성 에러 (400):

{"name": ["이 필드는 필수입니다."]}

 

인증 에러 (401):

{"detail": "인증 정보가 제공되지 않았습니다."}

 

서버 에러 (500):

<html><body><h1>Internal Server Error</h1></body></html>

 

목록은 배열, 단건은 객체, 에러는 또 다른 객체, 500 에러는 JSON조차 아닙니다. 프론트엔드 입장에서는 응답을 받을 때마다 "uc774게 배열인지 객체인지 HTML인지" 먼저 판단해야 하는 상황입니다.


📊 비즈니스 영향: 응답 형식 불일치가 가져오는 실질적 피해

1. 프론트엔드 개발 속도 저하

API마다 응답 형식이 다르면, 프론트엔드는 API별로 다른 파싱 함수를 만들어야 합니다. API가 50개라면, 구조가 다른 50개의 파싱 로직이 필요한 것입니다.

통일된 응답 형식이 있으면, 프론트엔드는 공통 API 클라이언트 함수 하나로 모든 API 응답을 처리할 수 있습니다.

2. 에러 처리의 사각지대

성공과 실패의 구조가 다르면, 프론트엔드에서 에러를 사용자에게 제대로 보여주기 어렵습니다. "재고가 부족합니다"라는 메시지를 보여줘야 하는데, 에러 매시지가 errors.quantity[0]에 있을 수도 있고, detail에 있을 수도 있고, message에 있을 수도 있다면?

프론트엔드는 결국 "오류가 발생했습니다" 같은 무의미한 범용 메시지를 보여주게 되고, 사용자는 뭐가 잘못된 건지 알 수 없습니다.

3. 디버깅 난이도 증가

운영 중 문제가 발생했을 때, 응답 형식이 통일되어 있으면 로그만 보고도 빠르게 파악할 수 있습니다. 하지만 API마다 형식이 다르면, "이 API는 에러가 어떤 형태로 나오는데?"부터 확인해야 합니다.

4. 팀 온보딩 비용

새로운 프론트엔드 개발자가 합류했을 때, 응답 형식이 통일되어 있으면 "모든 API는 이 구조로 응답합니다"라고 한 문장으로 설명이 끝납니다. 그러나 API마다 다르면 50개 API의 응답 형식을 하나씩 외워야 합니다.


🔍 통일된 API 응답 포맷 설계하기

이 문제를 해결하는 답은 간단합니다. 모든 API가 지키는 하나의 응답 포맷을 만드는 것입니다.

추천 표준 응답 포맷

성공 응답:

{
    "success": true,
    "data": {
        "id": 1,
        "name": "스니커즈",
        "price": 89000
    },
    "error": null
}

 

목록 성공 응답:

{
    "success": true,
    "data": [
        {"id": 1, "name": "스니커즈"},
        {"id": 2, "name": "러닝화"}
    ],
    "error": null
}

 

에러 응답:

{
    "success": false,
    "data": null,
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "입력 데이터가 올바르지 않습니다.",
        "details": {
            "quantity": ["재고가 부족합니다."]
        }
    }
}

 

이 포맷의 핵심 원칙:

  1. 모든 응답은 객체(Object) — 최상위에 배열이 오지 않습니다
  2. 3개의 고정 키 — success, data, error가 항상 존재합니다
  3. 성공하면 data에 값, 실패하면 error에 값 — 서로 배타적입니다
  4. 에러는 구조화 — code(기계용), message(사용자용), details(필드별 상세)

🛠️ 실전 적용: DRF에서 통일된 응답 포맷 구현하기

1단계: 표준 응답 함수 만들기

모든 응답이 통과하는 하나의 응답 생성 함수를 만듭니다.

# core/responses.py
from rest_framework.response import Response
from rest_framework import status

def api_response(data=None, status_code=status.HTTP_200_OK):
    """성공 응답 생성"""
    return Response(
        {
            'success': True,
            'data': data,
            'error': None,
        },
        status=status_code
    )

def api_error(
    message='오류가 발생했습니다.',
    code='ERROR',
    details=None,
    status_code=status.HTTP_400_BAD_REQUEST
):
    """에러 응답 생성"""
    return Response(
        {
            'success': False,
            'data': None,
            'error': {
                'code': code,
                'message': message,
                'details': details,
            },
        },
        status=status_code
    )

 

이 두 함수만 있으면 모든 뷰에서 통일된 응답을 만들 수 있습니다.

# views.py
from core.responses import api_response, api_error

class ProductDetailView(APIView):
    def get(self, request, pk):
        try:
            product = Product.objects.get(pk=pk)
        except Product.DoesNotExist:
            return api_error(
                message='상품을 찾을 수 없습니다.',
                code='PRODUCT_NOT_FOUND',
                status_code=status.HTTP_404_NOT_FOUND
            )
        serializer = ProductSerializer(product)
        return api_response(data=serializer.data)

2단계: 커스텀 Renderer로 전체 응답 감싸기

1단계의 문제점은 모든 뷰에서 api_response/api_error를 직접 호출해야 한다는 것입니다. 개발자가 하나라도 빠뜨리면 통일성이 깨집니다.

 

더 견고한 방법은 커스텀 Renderer를 사용하는 것입니다. DRF의 Renderer는 응답 데이터를 최종 JSON으로 변환하기 직전에 동작하므로, 모든 응답을 자동으로 통일된 포맷으로 감싸줄 수 있습니다.

# core/renderers.py
from rest_framework.renderers import JSONRenderer

class StandardJSONRenderer(JSONRenderer):
    """모든 응답을 통일된 포맷으로 감싸는 Renderer"""

    def render(self, data, accepted_media_type=None, renderer_context=None):
        response = renderer_context.get('response')

        # 이미 통일된 포맷이면 그대로 통과
        if isinstance(data, dict) and 'success' in data:
            return super().render(
                data, accepted_media_type, renderer_context
            )

        # 성공 응답 (2xx)
        if response and response.status_code < 400:
            wrapped = {
                'success': True,
                'data': data,
                'error': None,
            }
        # 에러 응답 (4xx, 5xx)
        else:
            wrapped = {
                'success': False,
                'data': None,
                'error': self._format_error(data, response),
            }

        return super().render(
            wrapped, accepted_media_type, renderer_context
        )

    def _format_error(self, data, response):
        """다양한 DRF 에러 형식을 통일된 구조로 변환"""
        status_code = response.status_code if response else 500

        # DRF 기본 에러: {"detail": "..."}  
        if isinstance(data, dict) and 'detail' in data:
            return {
                'code': self._get_error_code(status_code),
                'message': str(data['detail']),
                'details': None,
            }

        # DRF 유효성 에러: {"field": ["..."]}
        if isinstance(data, dict):
            return {
                'code': 'VALIDATION_ERROR',
                'message': '입력 데이터가 올바르지 않습니다.',
                'details': data,
            }

        # 그 외
        return {
            'code': 'ERROR',
            'message': str(data) if data else '오류가 발생했습니다.',
            'details': None,
        }

    def _get_error_code(self, status_code):
        """HTTP 상태 코드에 따른 에러 코드 매핑"""
        code_map = {
            400: 'BAD_REQUEST',
            401: 'UNAUTHORIZED',
            403: 'FORBIDDEN',
            404: 'NOT_FOUND',
            405: 'METHOD_NOT_ALLOWED',
            429: 'THROTTLED',
            500: 'INTERNAL_ERROR',
        }
        return code_map.get(status_code, 'ERROR')
# settings.py
REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': [
        'core.renderers.StandardJSONRenderer',
    ],
}

 

이렇게 하면 뷰 코드를 전혀 수정하지 않아도 모든 응답이 통일된 포맷으로 나갑니다. 기존에 Response(serializer.data) 처럼 쓰던 코드도 자동으로 감싸집니다.


3단계: 커스텀 예외 처리로 모든 에러 통일하기

Renderer만으로는 모든 에러를 잡을 수 없습니다. **예상치 못한 예외(500 에러)**가 발생하면 DRF는 기본적으로 HTML을 반환합니다. 이것까지 JSON으로 처리하려면 커스텀 예외 핸들러가 필요합니다.

# core/exceptions.py
from rest_framework.views import exception_handler
from rest_framework.response import Response
from rest_framework import status
import logging

logger = logging.getLogger(__name__)

def custom_exception_handler(exc, context):
    """모든 예외를 통일된 JSON 형식으로 처리"""

    # DRF 기본 예외 처리 먼저 시도
    response = exception_handler(exc, context)

    if response is not None:
        # DRF가 처리한 예외 (400, 401, 403, 404 등)
        return response  # Renderer가 자동으로 감싸줄 것

    # DRF가 처리하지 못한 예외 (500 에러)
    logger.exception(
        f'처리되지 않은 예외 발생: {exc}',
        exc_info=exc
    )

    return Response(
        {
            'success': False,
            'data': None,
            'error': {
                'code': 'INTERNAL_ERROR',
                'message': '서버 내부 오류가 발생했습니다.',
                'details': None,
                # 주의: 프로덕션에서는 절대 실제 에러 내용을 노출하지 마세요!
            },
        },
        status=status.HTTP_500_INTERNAL_SERVER_ERROR
    )
# settings.py
REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'core.exceptions.custom_exception_handler',
    'DEFAULT_RENDERER_CLASSES': [
        'core.renderers.StandardJSONRenderer',
    ],
}

 

이제 어떤 에러가 발생하든 통일된 JSON 형식으로 응답됩니다. 500 에러에서 HTML이 나오는 경우는 더 이상 없습니다.


4단계: 페이지네이션 응답도 통일하기

DRF의 페이지네이션은 기본적으로 count, next, previous, results 키를 사용합니다. 이것도 우리의 통일된 포맷에 맞춰야 합니다.

# core/pagination.py
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response

class StandardPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = 'page_size'
    max_page_size = 100

    def get_paginated_response(self, data):
        return Response({
            'success': True,
            'data': data,
            'error': None,
            'pagination': {
                'count': self.page.paginator.count,
                'page': self.page.number,
                'page_size': self.get_page_size(self.request),
                'total_pages': self.page.paginator.num_pages,
                'has_next': self.page.has_next(),
                'has_previous': self.page.has_previous(),
            }
        })

 

응답 예시:

{
    "success": true,
    "data": [
        {"id": 1, "name": "스니커즈"},
        {"id": 2, "name": "러닝화"}
    ],
    "error": null,
    "pagination": {
        "count": 1250,
        "page": 3,
        "page_size": 20,
        "total_pages": 63,
        "has_next": true,
        "has_previous": true
    }
}

 

페이지네이션이 있는 응답도 success, data, error 구조를 유지하면서 추가로 pagination 정보만 더해집니다. 프론트엔드는 pagination 키의 존재 여부로 페이지네이션이 있는 응답인지 구분할 수 있습니다.


🐛 자주 발생하는 실수와 디버깅

실수 1: null과 빈 값의 혼재

같은 "데이터 없음"을 표현하는 방식이 API마다 다르면 프론트엔드는 혼란에 빠집니다.

# ❌ "데이터 없음"을 표현하는 방식이 제각각
{"phone": null}       # null
{"phone": ""}         # 빈 문자열
{}                    # 키 자체가 없음
{"phone": "N/A"}     # 특수 문자열
# ✅ 명확한 규칙 설정
class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ['id', 'name', 'price', 'description', 'image_url']

    def to_representation(self, instance):
        data = super().to_representation(instance)
        # 규칙: 값이 없으면 null, 키는 항상 존재
        for field in ['description', 'image_url']:
            if data.get(field) == '':
                data[field] = None
        return data

 

팀 내에서 "값이 없을 때는 null을 사용하고, 키는 항상 포함한다"라는 규칙만 정해도 혼란이 확 줄어듭니다.


실수 2: 날짜/시간 형식이 제각각

# ❌ API마다 다른 날짜 형식
{"created_at": "2025-01-15"}                     # 날짜만
{"created_at": "2025-01-15T14:30:00"}              # 타임존 없음
{"created_at": "2025-01-15T14:30:00+09:00"}        # KST
{"created_at": "2025-01-15T05:30:00Z"}             # UTC
{"created_at": "2025년 1월 15일"}                     # 한국어 형식
# ✅ settings.py에서 전체 통일
REST_FRAMEWORK = {
    'DATETIME_FORMAT': '%Y-%m-%dT%H:%M:%S%z',  # ISO 8601 + 타임존
    'DATE_FORMAT': '%Y-%m-%d',
    'TIME_FORMAT': '%H:%M:%S',
}

# 타임존 설정
TIME_ZONE = 'Asia/Seoul'
USE_TZ = True  # 내부적으로는 UTC, 응답은 타임존 포함 ISO 8601

 

ISO 8601 형식으로 통일하면, 프론트엔드에서 JavaScript Date 객체로 바로 파싱할 수 있습니다. 사람이 읽기 좋은 "오후 2시 30분" 같은 형식은 프론트엔드에서 포맷팅하는 것이 올바릅니다.


실수 3: 빈 목록과 404를 혼동

검색 결과가 없을 때 404를 반환하는 경우가 있습니다.

# ❌ 검색 결과 0건일 때 404 반환
class ProductSearchView(ListAPIView):
    def list(self, request, *args, **kwargs):
        queryset = self.filter_queryset(self.get_queryset())
        if not queryset.exists():
            return Response(
                {"detail": "Not found."},
                status=status.HTTP_404_NOT_FOUND
            )
        # ...

 

404는 리소스 자체가 존재하지 않는 경우에 사용합니다. 검색 결과가 0건인 것은 "성공적으로 조회했는데 결과가 비어 있는 것"이지 "리소스가 없는 것"이 아닙니다.

# ✅ 빈 목록은 200 + 빈 배열
# 별도 코드 필요 없음 — DRF ListAPIView가 자동으로 처리

 

응답:

{
    "success": true,
    "data": [],
    "error": null
}

프론트엔드는 data가 빈 배열이면 "검색 결과가 없습니다" UI를 보여주고, 404면 "페이지를 찾을 수 없습니다" UI를 보여주면 됩니다. 구분이 명확해야 합니다.


실수 4: 에러 코드 없이 메시지만 반환

# ❌ 메시지만 있는 에러
{"error": "재고가 부족합니다."}

 

이 메시지는 사람은 읽을 수 있지만, 프론트엔드 코드에서 특정 에러를 구분하려면 문자열 비교를 해야 합니다. 언어가 달라지면? 메시지 문구가 살짝만 바뀌면?

# ✅ 도문대범 에러 코드 + 사람 읽는 메시지 동시 제공
{
    "success": false,
    "data": null,
    "error": {
        "code": "INSUFFICIENT_STOCK",      # 기계가 판단하는 값
        "message": "재고가 부족합니다.",     # 사용자에게 보여주는 값
        "details": {
            "available": 3,
            "requested": 5
        }
    }
}

 

code는 프론트엔드가 if (error.code === 'INSUFFICIENT_STOCK') 처럼 분기 처리하는 데 사용하고, message는 사용자에게 직접 보여줍니다. 언어가 달라져도, 에러 메시지 문구가 살짝 달라져도, 코드는 바뀌지 않으므로 프론트엔드 코드가 깨지지 않습니다.


실수 5: HTTP 상태 코드 오용

# ❌ 모든 응답을 200으로 반환하고 본문으로 구분
return Response(
    {"status": "error", "message": "권한이 없습니다."},
    status=200  # 에러인데 200?
)

 

HTTP 상태 코드는 그 자체로 의미를 가집니다. 프론트엔드의 HTTP 클라이언트(axios 등)는 상태 코드를 기준으로 성공/실패를 판단합니다.

# ✅ 적절한 HTTP 상태 코드 사용
# 200 OK: 성공
# 201 Created: 생성 성공
# 204 No Content: 삭제 성공
# 400 Bad Request: 입력 오류
# 401 Unauthorized: 인증 실패
# 403 Forbidden: 권한 없음
# 404 Not Found: 리소스 없음
# 409 Conflict: 충돌 (중복 가입 등)
# 429 Too Many Requests: 요청 제한 초과
# 500 Internal Server Error: 서버 오류

 

원칙은 간단합니다. HTTP 상태 코드로 성공/실패의 카테고리를 표현하고, 응답 본문으로 상세 내용을 전달하는 것입니다.


✅ 베스트 프랙티스 체크리스트

설계 단계

  • [ ] 통일된 응답 포맷(success/data/error)을 정의했는가?
  • [ ] 에러 코드 체계(code, message, details)를 설계했는가?
  • [ ] 날짜/시간 형식을 ISO 8601로 통일했는가?
  • [ ] null/빈 값 처리 규칙을 정했는가?
  • [ ] 페이지네이션 응답도 통일된 포맷을 따르는가?

개발 단계

  • [ ] 커스텀 Renderer로 자동 감싸기를 적용했는가?
  • [ ] 커스텀 Exception Handler로 모든 에러를 통일했는가?
  • [ ] HTTP 상태 코드를 의미에 맞게 사용하고 있는가?
  • [ ] 500 에러 시에도 JSON 응답이 나가는가? (HTML 노출 없는가?)
  • [ ] 프론트엔드 개발자와 응답 포맷을 합의하고 문서화했는가?

운영 단계

  • [ ] 새로운 API 추가 시 통일된 포맷을 따르는지 코드 리뷰로 확인하는가?
  • [ ] API 문서(예: Swagger/OpenAPI)에 응답 예시가 포함되어 있는가?
  • [ ] 에러 코드 목록을 문서화하여 프론트엔드와 공유하고 있는가?
  • [ ] 다국어 지원 시 에러 메시지 처리 방안이 있는가?

🎯 결론: 응답 형식은 프론트엔드와의 계약입니다

API 응답 형식의 불일치는 백엔드만의 문제가 아니라 프론트엔드와의 소통 문제입니다. 구조가 통일되어 있으면 양쪽 모두 해피하고, 제각각이면 양쪽 모두 불행합니다.

 

기억해야 할 핵심 원칙

  1. 모든 응답은 같은 그릇으로: success, data, error 3개의 고정 키만 있으면 프론트엔드는 하나의 파싱 로직으로 모든 API를 처리할 수 있습니다.
  2. 에러도 구조화하세요: code(기계용), message(사용자용), details(상세 정보) 3계층 구조면 복잡한 에러 시나리오도 대응할 수 있습니다.
  3. Renderer + Exception Handler로 자동화하세요: 개발자가 빠뜨릴 수 없는 구조를 만드는 것이 진짜 해결책입니다. "uc8fc의해서 쓰세요"는 실무에서 통하지 않습니다.
  4. HTTP 상태 코드를 올바르게 사용하세요: 본문에 status: "error"를 넣고 200을 반환하는 것은 안티패턴입니다.

 

서비스 규모별 추천

서비스 규모 추천 전략

MVP/초기 스타트업 api_response/api_error 함수 + 기본 커스텀 Exception Handler
성장기 스타트업 커스텀 Renderer + Exception Handler + 에러 코드 체계
중대형 서비스 커스텀 Renderer + Exception Handler + 에러 코드 문서 + API 명세서

💼 Django API 설계 컨설팅

통일된 API 응답 포맷은 프론트엔드와 백엔드의 협업 효율을 극적으로 높입니다.

이런 고민이 있으시다면:

  • API마다 응답 형식이 다르다는 프론트엔드의 불만이 끊이지 않는다면
  • 에러 응답을 체계적으로 설계하고 싶다면
  • 이미 만들어진 API들의 응답을 일괄 통일하고 싶다면
  • 외부 파트너용 API 명세서를 작성해야 한다면

크몽에서 Django 전문 컨설팅을 제공합니다. 실제 운영 경험을 바탕으로, 여러분의 서비스에 맞는 API 설계와 프론트엔드 협업 구조를 함께 만들어 드립니다.

👉 [크몽에서 Django 컨설팅 문의하기]