웹이랑 앱, 서버를 같이 만지며 운영하다 보면 꼭 마주치는 상황이 있습니다. 바로 공지사항, 관광지 정보, 배너, 카테고리 같은 데이터를 관리자 CMS에서 등록하고, 모바일 앱에서 API로 조회해서 보여주는 구조입니다.
처음에는 "어차피 똑같은 DB 데이터 긁어다 쓰는 건데 대충 같이 쓰면 안 되나?" 하고 Controller나 Query를 같이 재사용하고 싶어집니다. 코드가 줄어드니까 처음엔 속도가 빠른 것처럼 느껴지거든요. 하지만 이거 귀찮다고 대충 섞어 쓰면 나중에 운영 중에 무조건 사고가 터지거나 고치느라 고생하게 됩니다.
관리자는 수정, 삭제, 내부 메모나 검수 상태 확인이 필요하지만, 앱은 외부 사용자가 보는 거라 공개 가능한 데이터만 안정적인 형태로 받아야 하기 때문입니다. 저도 자꾸 까먹고 실수하지 않으려고 관리자 화면과 앱 API를 분리하는 명확한 기준을 실무 관점에서 정리해 둡니다.
이 글에서 다룰 문제 (대충 섞어 쓰면 터지는 일들)
관리자 화면이랑 앱 API를 제대로 분리하지 않고 한 통으로 묶어서 쓰면 보통 이런 골치 아픈 일들이 생깁니다.
- 앱 API 응답에 관리자용 내부 필드가 그대로 노출된다.
- 관리자가 보는 검색 조건과 앱에 보여줘야 하는 공개 조건이 꼬인다.
- 앱에서 전혀 필요 없는 삭제 여부(is_deleted), 내부 검수 상태, 등록자 ID 같은 값들이 그대로 내려간다.
- 관리자 로그인 인증과 앱 사용자의 토큰 인증 흐름이 모호해져서 보안 구멍이 생긴다.
- API 응답 형식을 CMS 테이블 구조에 맞춰놓는 바람에, 앱 화면 UI에서 갖다 쓰기 까다로워진다.
- 나중에 CMS 기능을 개편하거나 Next.js 같은 걸로 전환할 때 앱 API 계약(Contract)까지 같이 흔들린다.
기능이 몇 개 없을 때는 분기문(if문) 몇 개 넣어서 처리하는 게 빨라 보이지만, 기능이 늘어나면 백퍼센트 스파게티 코드가 됩니다. 특히 앱 API는 한 번 배포되면 클라이언트 측 코드를 마음대로 바꾸기 어렵기 때문에, 관리자 화면이랑 같은 기준으로 다루면 나중에 수정하기가 몇 배로 힘들어집니다.
관리자 화면과 앱 API의 차이점
두 환경은 같은 데이터를 바라보더라도 목적 자체가 아예 다릅니다. 이 차이를 확실히 인지해야 코드를 찢기가 편해집니다.
| 구분 | 관리자 CMS | 앱 API |
| 주요 사용자 | 운영자, 내부 관리자 | 일반 앱 사용자 |
| 주요 목적 | 데이터 등록, 수정, 검수, 상태 변경, 삭제 | 공개된 데이터 조회 및 사용자 기능 |
| 인증 방식 | 관리자 세션, 쿠키, 관리자 Guard | 앱 토큰(JWT 등), API Key, 공개 EndPoint |
| 응답 형태 | 어드민 테이블, 백오피스 검색, 페이지네이션 | 앱 화면에 최적화된 DTO / JSON |
| 필드 범위 | 내부 관리용 필드, 로그, 민감 정보 포함 | 공개 가능한 필드만 엄격하게 제한 |
| 오류 처리 | 브라우저용 에러 메시지, 리다이렉트 | 앱에서 파싱 가능한 에러 코드와 JSON |
관리자 화면은 내부 운영 효율이 제일 중요하고, 앱 API는 안정적인 응답 계약과 철저한 보안(공개 범위)이 핵심입니다. 이 기준을 머리에 박아두고 코드를 짜야 재사용할 부분과 분리할 부분이 명확해집니다.
1. 라우팅(Route)부터 확실하게 분리하기
가장 먼저 할 일은 Route 파일부터 찢는 겁니다. 라우팅이 섞여 버리면 인증 미들웨어, 로그 정책, Rate Limit, 응답 포맷까지 도미노처럼 같이 꼬이게 됩니다.
/admin/notices -> 관리자 공지 목록 화면 (어드민용)
/admin/notices/{id}/edit -> 관리자 공지 수정 화면 (어드민용)
/api/v1/notices -> 모바일 앱 공지 목록 API (사용자용)
/api/v1/notices/{id} -> 모바일 앱 공지 상세 API (사용자용)
저의 경우 Laravel을 쓸 때 관리자 route는 web.php에 두고 어드민 미들웨어를 태우고, 앱 API는 api.php나 별도의 API 전용 라우트 파일로 완전히 분리해서 관리합니다.
// routes/web.php (관리자 CMS용)
Route::middleware(['web', 'auth:admin'])
->prefix('admin')
->group(function () {
Route::get('/notices', [AdminNoticeController::class, 'index']);
});
// routes/api.php (모바일 앱 API용)
Route::middleware(['api'])
->prefix('v1')
->group(function () {
Route::get('/notices', [NoticeApiController::class, 'index']);
});
핵심은 관리자용 Controller와 API용 Controller를 따로 만드는 것입니다. 내부적으로 비즈니스 로직을 다루는 Service나 DB를 찌르는 Repository 레이어는 재사용할 수 있겠지만, 요청을 받고 응답을 내뱉는 통로는 무조건 책임 체계를 나눠야 나중에 피를 안 봅니다.
2. 응답 모델은 DTO나 Resource로 분리하기
관리자 화면에서는 작성자 IP나 내부 메모 같은 데이터가 필요할 수 있습니다. 하지만 앱 API에는 진짜 껍데기, 즉 공개 가능한 필드만 깎아서 내려보내야 합니다. 이때 DTO(Data Transfer Object)나 Resource 클래스를 쓰면 응답 필드를 아주 명확하게 제한할 수 있습니다.
final class NoticeApiResource
{
public static function fromModel(object $notice): array
{
return [
'id' => (int) $notice->id,
'title' => (string) $notice->title,
'summary' => (string) ($notice->summary ?? ''),
'publishedAt' => optional($notice->published_at)->toIso8601String(),
];
}
}
주의하셔야 합니다
ORM 모델 객체 전체를 그냥 json_encode 하거나 그대로 반환해 버리면, 나중에 컬럼 하나 추가했을 때 의도치 않게 내부 필드가 앱으로 노출됩니다. created_by, updated_by, is_deleted, admin_memo, review_status 같은 필드들은 앱 사용자 화면에 찍힐 이유가 전혀 없습니다. 귀찮아도 화이트리스트 방식으로 필요한 것만 골라 담아 내리는 구조가 안전합니다.
3. 관리자 검색 조건과 앱 공개 조건 찢기
DB에서 데이터를 긁어올 때도 조건이 결이 다릅니다. 관리자는 숨김 처리된 글, 비공개 글, 삭제 예정, 검수 대기 중인 데이터까지 다 검색하고 볼 수 있어야 하지만, 앱 API는 오직 '지금 당장 나가도 문제없는 정제된 데이터'만 긁어와야 합니다.
final class NoticeQuery
{
// 관리자용 조건: 필터에 따라 삭제된 것도 보고 검수 대기도 봐야 함
public function forAdmin(array $filters): Builder
{
return Notice::query()
->when($filters['keyword'] ?? null, function ($query, $keyword) {
$query->where('title', 'like', "%{$keyword}%");
})
->orderByDesc('id');
}
// 앱 사용자용 조건: 무조건 공개되고 안 지워진 정상 데이터만
public function forApp(): Builder
{
return Notice::query()
->where('is_published', true)
->whereNull('deleted_at')
->where('published_at', '<=', now())
->orderByDesc('published_at');
}
}
여기서 제일 중요한 점은 앱 공개 조건을 각 API Controller마다 일일이 수동으로 치지 않는 것입니다. 사람인지라 바쁘다 보면 조건 하나씩 빼먹는 실수를 꼭 하게 됩니다. 아예 위 코드처럼 forApp() 같은 공통 Query Scope나 Service 메서드로 묶어놓고 가져다 쓰는 게 마음 편합니다.
4. 인증과 권한 실패 처리 기준 나누기
관리자 인증은 운영자 계정 기준(세션, 쿠키 등)이고, 앱 인증은 토큰(JWT, API Key 등) 기반으로 돌아갑니다. 이 두 개가 섞이면 예외 처리할 때 앱이 터지는 현상이 발생합니다.
- 관리자 인증 실패: 로그인이 풀렸으니 로그인 폼 화면(HTML)으로 리다이렉트 시킵니다.
- 앱 API 인증 실패: 토큰이 만료되었거나 없으니 401 Unauthorized 상태 코드와 함께 JSON 에러 메시지를 뱉어야 합니다.
어설프게 공통 미들웨어를 태웠다가 앱 API가 인증 실패했을 때 느닷없이 로그인 HTML 페이지 코드를 응답으로 뱉어버리면, 모바일 앱 클라이언트는 JSON 파싱 에러를 내며 뻗어버립니다. API는 무슨 일이 있어도 항상 정제된 JSON 구조로 실패를 알려줘야 앱에서 안정적으로 예외 핸들링을 할 수 있습니다.
5. 앱 API 응답 계약 문서화 (Spec 고정)
앱 API는 프론트엔드 화면과 직결되는 규격(Contract)이기 때문에 꼼꼼하게 문서화해 두고 함부로 구조를 바꾸면 안 됩니다.
{
"data": [
{
"id": 1,
"title": "서버 점검 안내",
"summary": "안정적인 서비스를 위한 점검 안내입니다.",
"publishedAt": "2026-05-13T00:00:00+09:00"
}
],
"meta": {
"page": 1,
"limit": 20,
"hasNext": false
}
}
이렇게 규격을 딱 고정해 두면, 나중에 내부 관리자 CMS 화면 UI를 싹 다 갈아엎거나 백오피스 스택을 바꾸더라도 앱에 나가는 데이터 포맷은 안전하게 유지할 수 있습니다. CMS 어드민 테이블 컬럼명 바꾼다고 앱 API 필드명까지 도미노로 바뀌는 구조는 굉장히 위험합니다.
실무에서 체크할 분리 기준 리스트
제가 작업 끝내고 배포하기 전에 자꾸 까먹어서 보려고 만든 체크리스트입니다.
- [ ] 관리자 Route와 앱 API Route 파일이 완벽하게 쪼개져 있는가?
- [ ] 어드민 Controller와 앱 API Controller의 클래스가 분리되어 책임이 나누어졌는가?
- [ ] 앱 API는 모델을 쌩으로 안 내보내고 DTO/Resource를 거쳐 화이트리스트 필드만 반환하는가?
- [ ] 앱용 데이터 공개 조건(is_published, deleted_at 등)이 공통 쿼리나 서비스로 격리되어 있는가?
- [ ] 관리자 인증 실패(리다이렉트)와 API 인증 실패(401 JSON) 응답이 명확히 구별되는가?
- [ ] 외부 노출되면 안 되는 내부 메모, 작성자 ID, 관리자 전용 코드가 앱 응답에 섞여 나가지 않는가?
자주 하는 실수와 팁
- "관리자 목록 API가 잘 짜여있길래 그냥 앱에서 주소만 가져다 썼어요"
- 관리자 목록은 내부 검색 조건, 페이징 사이즈, 내부 상태 값이 떡칠 되어 있어서 앱에서 쓰면 성능도 안 나오고 보안상으로도 위험합니다. 절대 금지입니다.
- "귀찮은데 일단 모델 통째로 넘기고 앱에서 필터링해서 쓰면 안 되나요?"
- 한 번 노출된 API 응답 필드는 나중에 운영 중에 필드를 삭제하거나 줄이기가 매우 어렵습니다. (앱 구버전 사용자들이 앱을 업데이트 안 하면 터지니까요.) 처음부터 최소한의 필드만 열어두는 게 정석입니다.
- "CMS 개편하면서 API DB 구조가 바뀌었는데 앱도 같이 배포하죠 뭐"
- 웹과 달리 모바일 앱은 사용자가 업데이트를 누르기 전까지 예전 버전이 계속 살아있습니다. CMS UI가 바뀌더라도 기존 배포된 앱 버전을 위해 API Spec은 하위 호환성을 유지해야 합니다.
마무리
결론적으로 Backend/CMS 프로젝트를 설계할 때, 관리자 화면과 앱 API는 "바라보는 데이터 테이블만 같을 뿐, 아예 다른 별개의 독립된 서비스"라고 생각하고 접근하는 게 정신 건강에 이롭습니다.
처음 만들 때는 복사 붙여넣기 하거나 대충 합쳐 쓰는 게 진도가 빨라 보이지만, 서비스가 오픈하고 유지보수 궤도에 오르는 순간 처음 분리해 둔 구조가 수십 배의 공수를 아껴줍니다. 특히 보안 사고 방지와 안정적인 앱 서빙을 위해서라도 Route, Controller, DTO 세 가지는 무조건 분리해서 개발하는 습관을 지향합시다.
'개발 노트 > 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 |
| [API 개발] DB Model을 그대로 반환하면 안 되는 이유 (DTO와 Serializer로 응답 계약 고정하기) (0) | 2026.05.20 |