개발 노트/Backend,CMS,API

[Backend/API] DTO와 serializer로 응답 형식을 고정하는 방법

pposooj 2026. 5. 20. 14:56

Backend 앱 API에서 DTO와 serializer로 응답 형식을 고정하는 방법

Backend 앱 API를 만들 때 가장 편한 방식은 DB model을 그대로 JSON으로 반환하는 것입니다. 이게 처음에는 빠르고 단순해 보일지 몰라도 모바일 앱과 연결되는 API라면 이 방식은 금방 문제가 될 수 있습니다. DB 컬럼이 바뀌면 앱 응답도 같이 바뀌고, 내부 관리 필드가 앱에 노출될 위험도 커지기 때문입니다.

이럴 때 DTO와 serializer를 사용하면 API 응답 형식을 고정할 수 있습니다. DTO는 앱에 내려줄 데이터 구조를 정의하고, serializer는 DB model이나 내부 객체를 DTO 형태의 배열 또는 JSON으로 바꾸는 역할을 합니다. 이렇게 분리하면 DB 구조와 앱 응답 계약을 나눌 수 있습니다.

처음에는 model을 그대로 반환하는 방식이 훨씬 편해 보일 수 있습니다. 하지만 실제로 정리해보면 앱 API는 DB 구조보다 앱 화면이 기대하는 응답 형태를 기준으로 설계하는 편이 더 안정적인 것 같습니다.

이 글에서는 Backend 앱 API에서 DTO와 serializer로 응답 형식을 고정하는 기준을 정리해보겠습니다. CMS/API 구조를 검토할 때 놓치기 쉬운 응답 계약, 공개 필드, 목록/상세 분리, null 처리 기준을 중심으로 살펴보겠습니다.

이 글에서 다룰 문제

DB model을 그대로 API 응답으로 반환하면 다음 문제가 생길 수 있습니다.

  • 앱에 필요 없는 내부 컬럼이 노출된다.
  • 관리자 메모, 작성자 ID, 검수 상태 같은 내부 필드가 내려간다.
  • DB 컬럼명이 바뀌면 앱 코드도 함께 수정해야 한다.
  • null 값 처리 기준이 화면마다 달라진다.
  • 목록 API와 상세 API의 응답 구조가 뒤섞인다.
  • 날짜, 이미지 URL, 상태값 형식이 API마다 달라진다.
  • 테스트에서 무엇을 API 계약으로 봐야 할지 모호해진다.

초기 개발 단계에서는 model 반환이 빠르게 느껴집니다. 하지만 앱이 배포된 이후에는 응답 형식이 계약이 됩니다. 그래서 앱 API는 DB 구조가 아니라 앱 화면이 기대하는 형태를 기준으로 설계하는 것이 좋습니다.

DTO와 serializer의 역할

DTO와 serializer는 비슷하게 보일 수 있지만 역할을 나누면 이해하기 쉽습니다. 핵심은 model이 앱 응답으로 직접 나가지 않도록 막는 것입니다.

구분 역할 예시
Model DB 테이블과 가까운 내부 객체 Notice, Place, Banner
DTO 앱에 공개할 데이터 구조 NoticeListItemDto
Serializer Model을 DTO 또는 배열로 변환 NoticeSerializer
Resource 프레임워크 응답 변환 도구 Laravel API Resource
Response Contract 앱이 기대하는 최종 JSON data, meta, error

serializer가 공개 가능한 필드만 골라서 내려보내면 내부 필드 노출 가능성을 줄일 수 있습니다. 처음에는 한 단계가 더 생기는 것처럼 느껴질 수 있지만, 응답 계약을 안정적으로 유지하려면 이 분리가 꽤 중요합니다.

피해야 할 구조: model을 그대로 반환하기

아래처럼 model 전체를 그대로 반환하면 편하지만 위험합니다. DB 컬럼이 그대로 API 응답으로 노출될 수 있기 때문입니다.

public function show(int $id)
{
    $notice = Notice::findOrFail($id);

    return response()->json($notice);
}

이 방식에서는 admin_memo, created_by, updated_by, is_deleted, internal_status 같은 필드가 앱에 필요하지 않아도 응답에 포함될 수 있습니다. 처음에는 문제가 없어 보일 수 있지만, 내부 필드가 한 번 앱 응답에 포함되면 나중에 제거하기도 조심스러워집니다.

좋은 구조: serializer로 공개 필드만 반환하기

아래는 설명용으로 단순화한 예시입니다. 목록 응답과 상세 응답을 나누고, 앱에 공개할 필드만 골라서 반환합니다.

final class NoticeSerializer
{
    public static function listItem(object $notice): array
    {
        return [
            'id' => (int) $notice->id,
            'title' => (string) $notice->title,
            'summary' => (string) ($notice->summary ?? ''),
            'publishedAt' => optional($notice->published_at)->toIso8601String(),
        ];
    }

    public static function detail(object $notice): array
    {
        return [
            'id' => (int) $notice->id,
            'title' => (string) $notice->title,
            'body' => (string) ($notice->body ?? ''),
            'publishedAt' => optional($notice->published_at)->toIso8601String(),
        ];
    }
}

이렇게 하면 앱에는 필요한 필드만 내려갑니다. 목록 응답과 상세 응답도 분리할 수 있어 앱 화면에 맞는 구조를 만들기 쉽습니다.

처음에는 serializer를 따로 만드는 것이 번거롭게 느껴질 수 있지만 응답 필드가 명확해지면 앱 쪽에서도 어떤 값이 내려오는지 예측하기 쉬워집니다.

목록과 상세 응답을 분리해야 하는 이유

목록 API와 상세 API는 목적이 다릅니다. 목록은 빠르게 여러 항목을 보여주는 것이 중요하고, 상세는 한 항목의 전체 내용을 보여주는 것이 중요합니다.

구분 목록 API 상세 API
목적 카드/리스트 표시 본문 전체 표시
필드 id, title, summary, thumbnail id, title, body, images
성능 가볍게 유지 필요한 상세 필드 포함
페이징 필요 보통 불필요
내부 필드 제외 제외

목록 API에 상세 본문을 모두 넣으면 응답이 무거워집니다. 반대로 상세 API가 목록용 summary만 내려주면 앱에서 다시 요청이 필요할 수 있습니다. 응답 목적에 맞게 serializer 메서드를 나누는 것이 좋습니다.

null과 default 처리 기준

앱 API에서는 null 처리 기준을 정해야 합니다. null을 그대로 내려줄지, 빈 문자열이나 빈 배열로 바꿀지 일관성이 필요합니다.

final class ImageSerializer
{
    public static function fromModel(?object $image): ?array
    {
        if (!$image) {
            return null;
        }

        return [
            'url' => (string) $image->public_url,
            'alt' => (string) ($image->alt_text ?? ''),
        ];
    }
}

모든 null을 빈 문자열로 바꾸는 것이 항상 정답은 아닙니다. 값이 없음을 앱에서 구분해야 하는 경우에는 null이 더 적절할 수 있습니다. 중요한 것은 API마다 기준이 흔들리지 않도록 정하는 것입니다. API마다 null 처리 방식이 다르면 앱 코드가 복잡해집니다. 가능하면 응답 계약에서 기준을 먼저 정해두는 편이 좋습니다.

페이지네이션 meta 고정하기

목록 API는 datameta를 나누면 앱에서 처리하기 쉽습니다. 페이지 정보가 API마다 다르면 앱에서 공통 pagination 처리를 만들기 어렵습니다.

return response()->json([
    'data' => $items->map(fn ($notice) => NoticeSerializer::listItem($notice))->values(),
    'meta' => [
        'page' => $page,
        'limit' => $limit,
        'hasNext' => $hasNext,
    ],
]);

가능하면 page, limit, hasNext처럼 공통 필드를 정해두는 것이 좋습니다. 이렇게 해두면 앱에서 목록 화면을 재사용하거나 공통 훅을 만들 때 훨씬 편해집니다.

공개 필드와 내부 필드 구분하기

serializer를 쓰는 중요한 이유 중 하나는 내부 필드를 응답에서 제거하는 것입니다. 앱에서 필요하지 않은 값은 내려보내지 않는 것이 기본입니다.

필드 유형 앱 응답 포함 여부 이유
관리자 메모 제외 내부 운영 정보
작성자 관리자 ID 제외 내부 계정 정보
수정자 관리자 ID 제외 내부 계정 정보
삭제 상태 보통 제외 앱에는 공개 데이터만 전달
검수 상태 보통 제외 내부 workflow 정보
공개 제목/본문 포함 앱 화면에 필요
공개 이미지 URL 포함 가능 공개 리소스일 때만

기준은 숨겨도 상관없는 값이 아니라 필요한 값만 공개한다는 쪽에 가깝습니다. 앱에서 사용하지 않는 값이라면 응답에 포함하지 않는 것이 안전합니다.

테스트 기준

DTO와 serializer는 테스트하기 좋은 영역입니다. DB 전체나 화면을 띄우지 않아도 응답 구조를 검증할 수 있기 때문입니다.

  • 응답에 필요한 필드가 모두 포함되는지 확인한다.
  • 내부 필드가 응답에 포함되지 않는지 확인한다.
  • null/default 처리 기준이 일관적인지 확인한다.
  • 목록 응답과 상세 응답 필드가 구분되는지 확인한다.
  • 날짜 형식이 API 전체에서 일관적인지 확인한다.
  • meta 구조가 페이지네이션 API마다 동일한지 확인한다.

특히 포함되지 않아야 하는 필드를 테스트하는 것이 중요합니다. 앱 API 보안 검수에서는 내려가는 값뿐 아니라 내려가면 안 되는 값도 확인해야 합니다. 이 부분은 처음에는 놓치기 쉽지만, 내부 필드 노출을 막는 데 꽤 중요한 기준입니다.

자주 하는 실수

1. model을 그대로 반환하는 경우가 있습니다. 빠르게 개발할 수 있지만, DB 구조가 앱에 그대로 노출됩니다.

2. 목록과 상세 응답을 같은 serializer 하나로 처리하는 경우도 있습니다. 화면 목적이 다르기 때문에 응답도 나누는 편이 좋습니다.

3. null 처리 기준이 API마다 다른 경우가 있습니다. 어떤 API는 빈 문자열, 어떤 API는 null, 어떤 API는 필드 자체를 누락하면 앱 처리 코드가 복잡해집니다.

4. 내부 필드 제거를 프론트엔드에 맡기는 경우도 있습니다. 앱에서 안 쓰더라도 API 응답에 내려간 순간 이미 공개된 값으로 봐야 합니다.

결론

Backend 앱 API에서 DTO와 serializer는 응답 형식을 고정하는 안전장치입니다. DB model을 그대로 반환하면 빠르게 개발할 수는 있지만, 앱 응답 계약이 DB 구조에 묶이고 내부 필드가 노출될 위험이 생깁니다.

좋은 구조는 model과 API 응답을 분리하는 것입니다. serializer는 공개 가능한 필드만 골라내고, 목록/상세 응답을 나누며, nullmeta 구조를 일관되게 처리해야 합니다.

처음에는 DTO와 serializer가 코드만 늘리는 것처럼 느껴질 수 있습니다. 하지만 앱이 배포된 뒤에는 응답 계약을 안정적으로 유지하고 내부 필드를 차단하는 데 큰 도움이 됩니다. 앱 API는 필요한 값만 공개한다는 기준을 항상 먼저 두는 것이 좋습니다.

참고 자료

  • Laravel API Resource 공식 문서
  • REST API 응답 설계와 DTO 패턴 관련 문서
  • 프로젝트 내부 API 응답 계약, serializer, pagination 문서
  • 개인정보와 내부 필드 노출 방지 체크리스트