CRUD 테이블만으로는 충분하지 않을 때
Source: Dev.to

PHP로 관리자 패널을 만든 사람이라면 이 이야기가 보통 어떻게 끝나는지 알 것이다. 처음에는 모든 것이 단순하다: 테이블 하나, 폼 하나, 몇 개의 필터. 그러다 실제 요구사항이 들어오면 — 데이터 간 관계, 더 복잡한 흐름, 부분 업데이트 — 고전적인 “테이블 + 폼” CRUD가 금이 가기 시작한다.
CRUD의 잘못은 아니다. 문제는 관리자 인터페이스가 단순히 CRUD만이 아니기 때문이다.
실제 관리자는 다음이 필요하다:
- 다양한 상황에서 데이터를 볼 수 있어야 하고,
- 상태를 잃지 않고 관계를 탐색할 수 있어야 하며,
- 관련 엔티티를 빠르게 편집할 수 있어야 한다.
이러한 고민을 통해 여러 접근 방식을 탐색하게 되었고, 결국 가장 인기 있는 프레임워크 밖에서 처음부터 시스템을 구축하기로 결정했다. 여기까지가 내 결론이다.
문제를 생각하는 다른 방법
Instead of thinking “I need to build a page”, I started thinking “I need to describe a view of this data.”
That led me to builders: PHP objects that describe what to display and how. They are not frontend components, not magic widgets — just PHP structures that generate HTML, handle queries, and return either HTML or JSON responses.
The code stays PHP. The flow stays request → processing → response.
Source:
구체적인 예시: Posts
가장 간단한 경우부터 시작해 보겠습니다. 제목과 내용이 있는 게시물을 관리하는 모듈입니다.
모델이 데이터 구조를 정의합니다
class PostsModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__posts')
->id()
->string('title')->index()
->text('content')->formType('editor');
}
#[Validate('title')]
public function validateTitle($current_record_obj): string
{
$value = $current_record_obj->title;
if (strlen($value) model, 'idTablePosts')
->field('content')->truncate(50)
->field('title')->link('?page=posts&action=edit&id=%id%')
->setDefaultActions();
$response = array_merge($this->getCommonData(), $tableBuilder->getResponse());
Response::render(MILK_DIR . '/Theme/SharedViews/list_page.php', $response);
}
#[RequestAction('edit')]
public function postEdit()
{
$response = $this->getCommonData();
$response['form'] = FormBuilder::create($this->model, $this->page)->getForm();
Response::render(MILK_DIR . '/Theme/SharedViews/edit_page.php', $response);
}
}
TableBuilder는 이미 검색, 페이지네이션 및 정렬을 처리하는 방법을 알고 있습니다. FormBuilder는 어떤 필드를 보여줄지와 어떻게 검증할지를 알고 있습니다. 무엇을 원하는지 기술하면, 빌더가 나머지를 생성합니다.
스크린샷
뷰는 순수 PHP 템플릿이며, 템플릿 엔진이나 특수 문법이 없습니다. 무언가를 변경해야 할 경우 파일을 열어 HTML을 직접 수정하면 됩니다.
복잡해지는 부분: 관계
Simple CRUD는 어디서든 작동합니다. 엔티티가 관계를 가질 때 문제가 시작됩니다, 예를 들어:
Recipes
└─ Comments
관계가 인터랙티브해지면 일반적인 옵션은 다음 중 하나입니다:
- 모든 것을 여러 페이지로 나누기 (유동성 상실), 또는
- 상태를 관리해 주는 무거운 프레임워크에 의존하기 (투명성과 제어력 상실).
Laravel Nova나 Filament 같은 도구는 강력하고 구조화된 팀에서 잘 작동합니다. 하지만 흐름이 매우 구체적일 때는 백엔드와 프론트엔드 사이에서 실제로 무슨 일이 일어나고 있는지 이해하기 어려울 수 있습니다.
저는 다른 접근 방식을 원했습니다: 모든 것을 PHP로 유지하고, fetch를 사용해 페이지 리로드를 피하면서도 진행 상황을 숨기지 않는 것입니다.
Source:
레시피: 관계가 포함된 CRUD, 완전한 Fetch 구현
전체 예시입니다: 댓글이 달린 레시피, 두 단계의 인터페이스, 페이지 새로 고침 없이.
Recipe model
class RecipeModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__recipes')
->id()
->hasMany('comments', RecipeCommentsModel::class, 'recipe_id')
->image('image');
}
}
title('name')->index()
->text('ingredients')->formType('textarea')
->select('difficulty', ['Easy', 'Medium', 'Hard']);
}
Comments model
class RecipeCommentsModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__recipe_comments')
->id()
->int('recipe_id')->formType('hidden')
->text('comment');
}
}
그 외 모든 것 — 리스트, 오프‑캔버스 폼, 모달, 자동 새로 고침 — 은 동일한 빌더와 동일한 사고 모델을 사용해 구축됩니다.
왜 이 접근법인가
이것은 다른 프레임워크가 아닙니다. 관리자 패널에 대한 사고 방식입니다.
- 빌더는 보일러플레이트를 줄여 주지만, 코드는 여전히 읽기 쉬운 PHP입니다.
- 빌더가 필요한 작업을 수행하지 못한다면 언제든지 순수 PHP로 떨어져서 구현할 수 있습니다.
- 백엔드와 프론트엔드 사이의 계약으로 JSON을 사용하면 디버깅이 쉽습니다 — 숨겨진 상태도 없고 복잡한 라이프사이클도 없습니다. PHP가 명령을 보내고 브라우저가 이를 실행합니다.
- 가장 중요한 점은 관계도 기본 CRUD와 동일한 도구로 처리된다는 것입니다. 관계만을 위한 별도 시스템을 배울 필요가 없습니다.
대상 독자
Laravel을 사용하고 만족한다면, 이 방법은 여러분에게 맞지 않을 수 있습니다.
하지만 다음과 같은 상황에 처해 있다면:
- 마이그레이션이 어려운 기존 PHP 프로젝트를 유지보수하고 있다.
- 내부 도구, CRM, 관리 시스템을 개발하고 있다.
- 몇 달이 지나도 코드를 빠르게 이해해야 한다.
- 복잡한 생태계를 따라다니고 싶지 않다.
- PHP가 PHP처럼 보이고 느껴지길 원한다.
그렇다면 관리자 인터페이스를 구축하는 방식을 다시 생각해 볼 가치가 있습니다.
관리자 패널은 공개 웹사이트가 아닙니다. 작업용 도구입니다. 최고의 도구는 일을 잘 수행하고, 시간이 지나도 이해하기 쉬우며, 요구 사항이 바뀔 때 모든 것을 다시 작성하도록 강요하지 않습니다.
이러한 생각은 제가 직접 유지보수하고 있는 MilkAdmin이라는 PHP 관리자 패널 시스템 개발 과정에서 얻은 것입니다. 코드, 데모, 문서는 milkadmin.org 에서 확인할 수 있으며, MIT 라이선스로 제공됩니다.



