웹이랑 모바일 앱, 서버까지 이래저래 같이 잡고 운영하다 보면 API 응답 구조 때문에 꼭 한 번씩 골치 아픈 일이 생깁니다.
처음 API 만들 때는 그냥 DB에서 조회한 모델(Model) 객체를 그대로 JSON으로 던지는 게 가장 편합니다. 코드 한두 줄이면 끝나고 개발 속도도 엄청 빠르니까요. 저도 예전에는 초기 빌드할 때 종종 그렇게 고집을 부렸습니다.
하지만 모바일 앱과 연동되는 API라면 이 방식은 머지않아 시한폭탄이 됩니다. DB 컬럼 하나 바꿨다고 이미 배포된 앱이 죽어버리거나, 관리자용 내부 메모 같은 민감한 필드가 앱 패킷에 그대로 노출되는 사고가 터지기 때문입니다.
자꾸 까먹기도 하고, 다음 프로젝트에서 실수하지 않기 위해 DTO와 Serializer를 활용해 API 응답 형식을 안전하게 고정하는 기준을 정리해 둡니다.
1. 내가 겪었던 문제: Model을 그대로 반환할 때의 위험성
라라벨(Laravel)이나 다른 프레임워크를 쓸 때 아래처럼 짜는 경우가 제일 흔합니다.
public function show(int $id)
{
// DB 모델을 그대로 조회해서 바로 JSON으로 반환
$notice = Notice::findOrFail($id);
return response()->json($notice);
}
이러면 당장은 잘 돌아가는 것처럼 보입니다. 하지만 운영하다 보면 아래와 같은 문제가 무조건 터집니다.
- 내부 운영 필드 노출: DB 테이블에 있던 admin_memo(관리자 메모), created_by(작성자 ID), is_deleted 같은 앱 화면에 전혀 필요 없는 값들이 통째로 내려갑니다. 보안 검수할 때 무조건 지적받는 지점입니다.
- 앱 코드와의 강한 결합: 기획이 바뀌어서 DB 컬럼명을 published_at에서 opened_at으로 바꾸는 순간, 구버전 앱을 쓰는 사용자들은 공지사항 날짜가 안 나오는 버그를 겪게 됩니다. 앱은 웹과 달라서 한 번 배포하면 사용자가 업데이트를 안 할 수도 있기 때문에 응답 계약(Contract)을 유지하는 게 핵심입니다.
2. 해결 방법: Serializer로 공개 필드만 필터링하기
가장 좋은 방법은 DB 모델이 앱 응답으로 직접 나가지 못하게 중간에 차단벽을 세우는 것입니다. DTO(Data Transfer Object)나 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(),
];
}
}
이렇게 분리해 두면 DB 컬럼명이 어떻게 바뀌든 프론트엔드나 모바일 앱이 받는 JSON 키 값(publishedAt 등)은 그대로 유지할 수 있습니다. 백엔드 내부 사정 때문에 앱 코드까지 건드릴 필요가 없어지는 거죠.
3. 실무 운영 시 놓치기 쉬운 체크포인트
이래저래 서비스를 운영하면서 깨달은 몇 가지 기준들입니다. API 설계할 때 규칙을 미리 정해두는 편이 좋습니다.
① 목록과 상세 응답은 무조건 분리하기
하나의 Serializer로 목록과 상세를 같이 퉁치려고 하면 성능이나 데이터 낭비가 심해집니다.
- 목록 API: 카드나 리스트만 빠르게 보여줘야 하므로 id, title, summary 같은 필수 필드만 가볍게 담아야 합니다. (페이징 필수)
- 상세 API: 본문 전체(body)나 고화질 이미지 리스트 등 무거운 데이터를 포함합니다.
② null과 default 처리 기준 고정하기
값이 없을 때 어떤 API는 null을 주고, 어떤 API는 아예 필드를 누락시키거나 빈 문자열("")을 주면 앱 개발자분이 파싱하다가 에러를 내기 십상입니다.
final class ImageSerializer
{
public static function fromModel(?object $image): ?array
{
if (!$image) {
return null; // 데이터 자체가 없을 땐 명확히 null 반환
}
return [
'url' => (string) $image->public_url,
'alt' => (string) ($image->alt_text ?? ''), // null 대신 빈 문자열로 기본값 처리
];
}
}
값이 아예 없는 것과, 값은 있지만 내용이 비어있는 것을 화면에서 구분해야 하는지 기획에 맞춰 기준을 똑같이 맞춰야 앱 코드가 깔끔해집니다.
③ 페이지네이션(Pagination) meta 구조 통일
목록 API를 만들 때 페이징 정보 구조가 제각각이면 앱에서 공통 모듈을 만들 수가 없습니다. 항상 data와 meta 영역을 나누어 고정하는 게 편합니다.
return response()->json([
'data' => $items->map(fn ($notice) => NoticeSerializer::listItem($notice))->values(),
'meta' => [
'page' => $page,
'limit' => $limit,
'hasNext' => $hasNext,
],
]);
4. 어떤 필드를 숨기고 어떤 필드를 보낼 것인가?
기준은 단순합니다. "숨겨도 되는 값을 찾는 게 아니라, 앱 화면에 꼭 필요한 값만 허용 리스트(White-list) 방식으로 공개한다"고 접근해야 안전합니다.
| 필드 유형 | 앱 응답 포함 여부 | 이유 |
| 관리자 메모, 내부 workflow 상태 | 제외 | 내부 운영 정보이므로 노출 시 보안 위험 |
| 작성자/수정자 계정 ID | 제외 | 시스템 내부 식별자는 앱에 보여줄 이유가 없음 |
| 삭제 여부 (is_deleted) | 제외 | API 조회 단계에서 이미 필터링해서 내려주어야 함 |
| 제목, 본문, 이미지 URL | 포함 | 실제 앱 화면을 그리는 데 필수적인 리소스 |
5. 테스트 코드 작성할 때 팁
Serializer를 따로 분리해 두면 좋은 점이 또 하나 있습니다. 굳이 무거운 통합 테스트나 DB 전체를 띄우지 않아도 단위 테스트(Unit Test) 수준에서 응답 구조를 아주 쉽게 검증할 수 있다는 점입니다.
- 응답 포맷에 필요한 필드가 다 들어있는지 확인
- (중요) admin_memo 같은 내부 필드가 실수로라도 포함되어 나오지 않는지 검증 (assertArrayNotHasKey 활용)
- 날짜 포맷이 전부 ISO-8601(YYYY-MM-DDTHH:mm:ssZ) 형식으로 일관되게 찍히는지 확인
특히 내려가면 안 되는 필드가 차단되었는지 반대 케이스를 테스트하는 버릇을 들이면 API 보안 사고를 정말 많이 막을 수 있습니다.
짧은 마무리
초기 개발 단계에서는 모델을 그대로 던지는 게 한없이 편하게 느껴집니다. 하지만 서비스가 오픈하고 배포된 이후에는 그 API 응답 형식 자체가 깨뜨려서는 안 되는 엄격한 '계약'이 됩니다.
조금 귀찮더라도 처음부터 DTO와 Serializer를 구축해서 구조를 떼어놓는 게 장기적으로 내 야근을 줄이고 서버와 앱 모두 평화로워지는 가장 확실한 방법입니다.
'개발 노트 > Backend,CMS,API' 카테고리의 다른 글
| 모바일 앱 API 오류 응답 형식을 하나로 고정해야 하는 이유와 설계 방법 (Laravel 예시) (0) | 2026.05.21 |
|---|---|
| [Next.js 전환] 기존 Backend/CMS를 Next.js로 바꿀 때 앱 API 경계 유지하는 방법 (0) | 2026.05.21 |
| [Backend] API 캐시(Cache)와 레이트 리밋(Rate Limit) 적용할 때 놓치는 실무 운영 기준 정리 (0) | 2026.05.21 |
| [Backend] 앱 API 목록 조회 설계 기준 정리 (Pagination, Filter, Sort Allowlist) (0) | 2026.05.20 |
| [백엔드 설계] 관리자 CMS와 모바일 앱 API 코드를 처음부터 분리해야 하는 이유 (0) | 2026.05.20 |