PHP 프로젝트는 초기에 빠르게 기능을 붙이다 보면 Controller가 쉽게 비대해진다.
요청 처리, 검증, 비즈니스 로직, DB 쿼리, 응답 생성이 한 파일에 섞이면서 유지보수가 어려워진다.
Controller를 얇게 유지한다는 것은 “로직을 없앤다”가 아니라 “책임을 분리한다”는 의미다.
Controller는 요청을 해석하고, 서비스에 위임하고, 응답만 만드는 역할로 제한하는 것이 기준이다.
1. Controller가 두꺼워지는 전형적인 패턴
아래 형태가 반복되면 Controller는 기능이 늘어날수록 폭발한다.
특히 조건 분기와 예외 처리가 Controller에 쌓이기 시작하면 구조가 무너진다.
public function create()
{
$title = $_POST['title'] ?? '';
$content = $_POST['content'] ?? '';
if ($title === '' || $content === '') {
return $this->json(['ok' => false, 'msg' => 'invalid'], 400);
}
$sql = "INSERT INTO post (title, content) VALUES ('{$title}', '{$content}')";
$this->db->query($sql);
return $this->json(['ok' => true]);
}
입력 검증, SQL 작성, 저장 로직, 응답 포맷이 한 곳에 섞여 있다.
이 구조는 테스트가 어렵고, 재사용이 불가능하며, 코드 중복이 빠르게 늘어난다.
2. Controller의 역할을 명확히 제한
Controller는 다음 3가지만 담당하도록 제한하는 것이 기준이다.
- 요청 파라미터 수집
- 입력 검증(형식 수준)
- 서비스 호출 및 응답 생성
비즈니스 규칙 판단, 트랜잭션 처리, 저장소 접근은 Service로 이동한다.
쿼리는 Repository로 이동한다.
입력 수집/검증/응답"] C["Service
비즈니스 로직"] D["Repository
DB 접근"] E["DB"] A --> B B --> C C --> D D --> E
3. Service Layer로 로직을 내리기
Service는 “업무 흐름을 통제하는 계층”이다.
Controller에서 조건 분기와 처리 흐름이 늘어나면 Service로 옮기는 신호다.
final class PostService
{
public function __construct(
private PostRepository $posts
) {}
public function create(int $memberId, string $title, string $content): int
{
if ($title === '' || $content === '') {
throw new InvalidArgumentException('invalid');
}
return $this->posts->insert($memberId, $title, $content);
}
}
이렇게 되면 Controller는 서비스 호출만 남는다.
로직 변경이 필요할 때 수정 범위가 명확해진다.
4. Repository로 쿼리 격리
SQL이 Controller나 Service에 흩어지면 유지보수가 어려워진다.
DB 스키마 변경이 발생했을 때 수정 지점을 한 곳으로 모으기 위해 Repository로 격리한다.
final class PostRepository
{
public function __construct(private PDO $pdo) {}
public function insert(int $memberId, string $title, string $content): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO post (member_id, title, content) VALUES (:member_id, :title, :content)'
);
$stmt->execute([
':member_id' => $memberId,
':title' => $title,
':content' => $content,
]);
return (int) $this->pdo->lastInsertId();
}
}
Prepared Statement로 통일하면 SQL Injection 리스크도 같이 줄어든다.
5. Controller 예시 — 얇아진 형태
Controller는 입력을 받고, 서비스에 위임하고, 결과를 응답으로만 만든다.
로직이 늘어나지 않는 구조를 유지하는 것이 핵심이다.
public function create()
{
$memberId = (int) ($_POST['member_id'] ?? 0);
$title = trim((string) ($_POST['title'] ?? ''));
$content = trim((string) ($_POST['content'] ?? ''));
if ($memberId <= 0) {
return $this->json(['ok' => false, 'msg' => 'invalid member'], 400);
}
try {
$postId = $this->postService->create($memberId, $title, $content);
return $this->json(['ok' => true, 'post_id' => $postId]);
} catch (InvalidArgumentException $e) {
return $this->json(['ok' => false, 'msg' => 'invalid'], 400);
}
}
Controller는 흐름을 단순하게 유지하고, 예외는 “입력 문제인지/서버 문제인지”만 구분한다.
비즈니스 판단은 Service에서 처리하는 것이 일관된 구조다.
6. 자주 발생하는 문제와 대처
Service가 또 비대해지는 경우
Service가 너무 커지면 도메인 단위로 서비스를 쪼개야 한다.
PostService, MemberService처럼 책임 경계를 나누고, 공통 로직은 별도 컴포넌트로 분리한다.
Repository가 비대해지는 경우
쿼리 책임이 커지면 Query 객체나 전용 ReadModel을 분리한다.
목록 조회와 상세 조회가 복잡해지는 시점에서 분리가 필요하다.
7. 최소 기준 폴더 구조 예시
프로젝트 규모가 커지기 전에도 최소한의 계층 분리는 유효하다.
/app
/Controller
/Service
/Repository
/Dto
/Exception
/config
/public
이 구조를 유지하면 기능이 늘어나도 Controller가 두꺼워지지 않는다.
책임 분리만 유지해도 장기 유지보수 비용이 크게 줄어든다.
한 줄 요약
Controller는 입력 수집·형식 검증·응답만 담당하고, 비즈니스 로직은 Service로, DB 쿼리는 Repository로 분리하면 Controller가 비대해지지 않는다.
댓글 0