콘텐츠 대표 이미지 - PHP로 구현하는 MVC 아키텍처
🏗️ PHP Architecture Series

PHP로 구현하는 MVC 아키텍처

Model · View · Controller — 코드의 질서를 찾아서 🚀

야, 솔직히 말해봐. 처음 PHP 코드 짤 때 어땠어? 😅
HTML 파일 하나에 DB 쿼리, 비즈니스 로직, 화면 출력까지 다 때려박고 "이거 돌아가잖아? 완성!" 했던 경험, 다들 있잖아.
근데 그 코드 한 달 뒤에 다시 열어봤을 때의 그 공포감... 🫠 "이게 내가 짠 코드라고?"

바로 그 순간을 위해 오늘 우리가 이야기할 게 MVC 아키텍처야.
MVC는 Model-View-Controller의 약자인데, 쉽게 말하면 코드를 역할별로 깔끔하게 나눠서 관리하는 설계 패턴이야.
PHP로 이걸 직접 구현해보면서, "아 이래서 다들 MVC 쓰는구나!" 하는 그 감각을 같이 느껴보자. 💡

MVC 아키텍처 — 역할 분리의 미학 👤 사용자 요청/응답 Controller 🎮 요청 처리 & 흐름 제어 Model 🗄️ 데이터 & 비즈니스 로직 View 🖼️ 화면 출력 & 템플릿 💾 데이터베이스 데이터 요청 데이터 반환 렌더링 지시 HTML 응답

🤔 MVC가 뭔데? 진짜로 설명해줄게

MVC는 1970년대 Trygve Reenskaug라는 노르웨이 컴퓨터 과학자가 Smalltalk 언어를 위해 처음 제안한 디자인 패턴이야.
그게 지금까지 살아남아서 PHP, Java, Python, Ruby 등 거의 모든 웹 프레임워크의 기반이 됐어. 진짜 레전드 패턴이지. 🏆

🗄️ Model (모델) 데이터와 비즈니스 로직을 담당해. DB와 직접 대화하고, 데이터를 가공하고, 규칙을 적용하는 곳이야.
"이 사용자가 성인인지 확인해줘", "이 상품의 재고가 있는지 체크해줘" 같은 로직이 여기 들어가.
🖼️ View (뷰) 사용자에게 보여지는 화면을 담당해. HTML, CSS, 템플릿이 여기 있어.
데이터를 어떻게 예쁘게 보여줄지만 생각하면 돼. 비즈니스 로직은 절대 여기 없어야 해!
🎮 Controller (컨트롤러) 사용자의 요청을 받아서 Model에게 데이터를 요청하고, 받은 데이터를 View에 전달하는 중간 관리자야.
교통 정리하는 경찰관 같은 존재! 🚦
"MVC의 핵심은 관심사의 분리(Separation of Concerns)야. 각 컴포넌트가 자기 역할만 충실히 하면, 전체 시스템이 훨씬 유지보수하기 쉬워져."

📁 프로젝트 구조 잡기 — 집부터 제대로 짓자

MVC를 PHP로 구현할 때 가장 먼저 해야 할 일은 디렉토리 구조를 잡는 것이야.
구조가 엉망이면 나중에 파일 찾다가 시간 다 날아가거든. 😤

📂 프로젝트 디렉토리 구조
my-mvc-app/
├── app/
│   ├── controllers/
│   │   ├── HomeController.php
│   │   └── UserController.php
│   ├── models/
│   │   ├── User.php
│   │   └── Post.php
│   └── views/
│       ├── home/
│       │   └── index.php
│       ├── user/
│       │   ├── index.php
│       │   └── show.php
│       └── layouts/
│           └── main.php
├── core/
│   ├── App.php
│   ├── Controller.php
│   ├── Model.php
│   └── Router.php
├── config/
│   └── database.php
├── public/
│   ├── index.php  ← 진입점(Entry Point)
│   ├── css/
│   └── js/
└── .htaccess

이 구조에서 핵심은 public/index.php가 모든 요청의 진입점이라는 거야.
외부에서는 public 폴더만 접근 가능하고, 나머지 로직은 다 숨겨져 있어. 보안상으로도 훨씬 좋지! 🔒

.htaccess — URL 라우팅의 마법

Apache 서버를 쓴다면 .htaccess 파일로 모든 요청을 index.php로 보내야 해.

.htaccess
Options -MultiViews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.+)$ public/index.php [QSA,L]

이렇게 하면 /user/profile이든 /post/123이든 전부 public/index.php로 들어와서 우리가 만든 라우터가 처리하게 돼. 🎯


⚙️ 코어 시스템 구현 — 엔진 만들기

1단계: 진입점 (public/index.php)

모든 요청이 여기서 시작돼. 오토로더 설정하고 앱을 실행하는 게 전부야.

public/index.php
<?php

// 오토로더 설정 - 클래스 파일 자동으로 불러오기
spl_autoload_register(function($className) {
    $paths = [
        '../app/controllers/',
        '../app/models/',
        '../core/'
    ];
    
    foreach ($paths as $path) {
        $file = $path . $className . '.php';
        if (file_exists($file)) {
            require_once $file;
            return;
        }
    }
});

// 앱 시작!
require_once '../core/App.php';
$app = new App();
$app->run();

2단계: 라우터 (core/Router.php)

URL을 분석해서 어떤 컨트롤러의 어떤 메서드를 실행할지 결정하는 핵심 컴포넌트야.

core/Router.php
<?php

class Router {
    private array $routes = [];
    
    // 라우트 등록
    public function get(string $path, string $controller, string $method): void {
        $this->routes['GET'][$path] = [
            'controller' => $controller,
            'method'     => $method
        ];
    }
    
    public function post(string $path, string $controller, string $method): void {
        $this->routes['POST'][$path] = [
            'controller' => $controller,
            'method'     => $method
        ];
    }
    
    // URL 파싱 및 라우팅
    public function dispatch(string $url, string $httpMethod): void {
        $url = trim($url, '/');
        $httpMethod = strtoupper($httpMethod);
        
        // 정확한 매칭 먼저 시도
        if (isset($this->routes[$httpMethod][$url])) {
            $route = $this->routes[$httpMethod][$url];
            $this->callAction($route['controller'], $route['method'], []);
            return;
        }
        
        // 동적 파라미터 매칭 (예: /user/{id})
        foreach ($this->routes[$httpMethod] ?? [] as $pattern => $route) {
            $regex = preg_replace('/\{[^}]+\}/', '([^/]+)', $pattern);
            $regex = '#^' . $regex . '$#';
            
            if (preg_match($regex, $url, $matches)) {
                array_shift($matches); // 전체 매칭 제거
                $this->callAction($route['controller'], $route['method'], $matches);
                return;
            }
        }
        
        // 404 처리
        http_response_code(404);
        echo "404 - 페이지를 찾을 수 없어요 😢";
    }
    
    private function callAction(string $controller, string $method, array $params): void {
        if (!class_exists($controller)) {
            throw new \Exception("컨트롤러 '{$controller}'를 찾을 수 없어요!");
        }
        
        $controllerInstance = new $controller();
        
        if (!method_exists($controllerInstance, $method)) {
            throw new \Exception("메서드 '{$method}'를 찾을 수 없어요!");
        }
        
        call_user_func_array([$controllerInstance, $method], $params);
    }
}

3단계: 앱 클래스 (core/App.php)

core/App.php
<?php

class App {
    private Router $router;
    
    public function __construct() {
        $this->router = new Router();
        $this->registerRoutes();
    }
    
    private function registerRoutes(): void {
        // 라우트 등록
        $this->router->get('', 'HomeController', 'index');
        $this->router->get('home', 'HomeController', 'index');
        $this->router->get('users', 'UserController', 'index');
        $this->router->get('users/{id}', 'UserController', 'show');
        $this->router->post('users', 'UserController', 'store');
        $this->router->get('users/{id}/edit', 'UserController', 'edit');
        $this->router->post('users/{id}/update', 'UserController', 'update');
        $this->router->post('users/{id}/delete', 'UserController', 'destroy');
    }
    
    public function run(): void {
        // 현재 URL 가져오기
        $url = $_SERVER['REQUEST_URI'] ?? '/';
        
        // 쿼리스트링 제거
        $url = strtok($url, '?');
        
        // 베이스 경로 제거 (서브디렉토리에 설치된 경우)
        $basePath = dirname($_SERVER['SCRIPT_NAME']);
        if ($basePath !== '/') {
            $url = str_replace($basePath, '', $url);
        }
        
        $httpMethod = $_SERVER['REQUEST_METHOD'];
        
        // POST 메서드 오버라이드 (_method 파라미터로 PUT, DELETE 지원)
        if ($httpMethod === 'POST' && isset($_POST['_method'])) {
            $httpMethod = strtoupper($_POST['_method']);
        }
        
        $this->router->dispatch($url, $httpMethod);
    }
}
HTTP 요청의 여행 🚀 🌐 브라우저 GET /users/42 🔀 .htaccess index.php로 전달 🗺️ Router 라우트 매칭 🎮 Controller UserController::show 🗄️ Model DB에서 데이터 조회 데이터 반환 View 렌더링 🖼️ View HTML 생성 응답 요청 → 라우팅 → 컨트롤러 → 모델 → 뷰 → 응답 완료! ✅

🏛️ 베이스 클래스 구현 — 공통 기능 묶기

각 컴포넌트의 공통 기능을 베이스 클래스로 만들어두면, 실제 컨트롤러나 모델 작성이 훨씬 편해져. 코드 중복도 확 줄고! ✂️

베이스 컨트롤러 (core/Controller.php)

core/Controller.php
<?php

class Controller {
    
    // View 렌더링 메서드
    protected function view(string $viewPath, array $data = []): void {
        // 데이터를 변수로 추출 (extract 사용)
        extract($data);
        
        // 뷰 파일 경로
        $viewFile = '../app/views/' . str_replace('.', '/', $viewPath) . '.php';
        
        if (!file_exists($viewFile)) {
            throw new \Exception("뷰 파일을 찾을 수 없어요: {$viewFile}");
        }
        
        // 레이아웃이 있으면 레이아웃 안에 뷰 삽입
        $layoutFile = '../app/views/layouts/main.php';
        
        if (file_exists($layoutFile)) {
            // 뷰 내용을 버퍼에 담기
            ob_start();
            require $viewFile;
            $content = ob_get_clean();
            
            // 레이아웃에 content 변수로 전달
            require $layoutFile;
        } else {
            require $viewFile;
        }
    }
    
    // JSON 응답
    protected function json(mixed $data, int $statusCode = 200): void {
        http_response_code($statusCode);
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    }
    
    // 리다이렉트
    protected function redirect(string $url): void {
        header("Location: {$url}");
        exit;
    }
    
    // POST 데이터 가져오기 (XSS 방지)
    protected function input(string $key, mixed $default = null): mixed {
        $value = $_POST[$key] ?? $_GET[$key] ?? $default;
        if (is_string($value)) {
            return htmlspecialchars(trim($value), ENT_QUOTES, 'UTF-8');
        }
        return $value;
    }
    
    // 간단한 유효성 검사
    protected function validate(array $data, array $rules): array {
        $errors = [];
        
        foreach ($rules as $field => $rule) {
            $ruleList = explode('|', $rule);
            
            foreach ($ruleList as $r) {
                if ($r === 'required' && empty($data[$field])) {
                    $errors[$field][] = "{$field} 필드는 필수입니다.";
                }
                
                if (str_starts_with($r, 'min:')) {
                    $min = (int) substr($r, 4);
                    if (strlen($data[$field] ?? '') < $min) {
                        $errors[$field][] = "{$field}은 최소 {$min}자 이상이어야 합니다.";
                    }
                }
                
                if (str_starts_with($r, 'max:')) {
                    $max = (int) substr($r, 4);
                    if (strlen($data[$field] ?? '') > $max) {
                        $errors[$field][] = "{$field}은 최대 {$max}자 이하여야 합니다.";
                    }
                }
                
                if ($r === 'email' && !filter_var($data[$field] ?? '', FILTER_VALIDATE_EMAIL)) {
                    $errors[$field][] = "{$field}은 올바른 이메일 형식이 아닙니다.";
                }
            }
        }
        
        return $errors;
    }
}

베이스 모델 (core/Model.php)

PDO를 활용한 베이스 모델이야. DB 연결과 기본 CRUD 쿼리를 여기서 처리해.

core/Model.php
<?php

class Model {
    protected static ?PDO $db = null;
    protected string $table = '';
    protected string $primaryKey = 'id';
    
    // DB 연결 (싱글톤 패턴)
    protected static function getDB(): PDO {
        if (self::$db === null) {
            $config = require '../config/database.php';
            
            $dsn = "mysql:host={$config['host']};dbname={$config['dbname']};charset=utf8mb4";
            
            self::$db = new PDO($dsn, $config['username'], $config['password'], [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]);
        }
        
        return self::$db;
    }
    
    // 전체 조회
    public function all(): array {
        $stmt = self::getDB()->query("SELECT * FROM {$this->table}");
        return $stmt->fetchAll();
    }
    
    // ID로 단건 조회
    public function find(int $id): array|false {
        $stmt = self::getDB()->prepare(
            "SELECT * FROM {$this->table} WHERE {$this->primaryKey} = ?"
        );
        $stmt->execute([$id]);
        return $stmt->fetch();
    }
    
    // 조건 조회
    public function where(string $column, mixed $value): array {
        $stmt = self::getDB()->prepare(
            "SELECT * FROM {$this->table} WHERE {$column} = ?"
        );
        $stmt->execute([$value]);
        return $stmt->fetchAll();
    }
    
    // 생성
    public function create(array $data): int {
        $columns = implode(', ', array_keys($data));
        $placeholders = implode(', ', array_fill(0, count($data), '?'));
        
        $stmt = self::getDB()->prepare(
            "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})"
        );
        $stmt->execute(array_values($data));
        
        return (int) self::getDB()->lastInsertId();
    }
    
    // 수정
    public function update(int $id, array $data): bool {
        $setClause = implode(', ', array_map(fn($col) => "{$col} = ?", array_keys($data)));
        
        $stmt = self::getDB()->prepare(
            "UPDATE {$this->table} SET {$setClause} WHERE {$this->primaryKey} = ?"
        );
        
        return $stmt->execute([...array_values($data), $id]);
    }
    
    // 삭제
    public function delete(int $id): bool {
        $stmt = self::getDB()->prepare(
            "DELETE FROM {$this->table} WHERE {$this->primaryKey} = ?"
        );
        return $stmt->execute([$id]);
    }
    
    // 커스텀 쿼리
    public function query(string $sql, array $params = []): array {
        $stmt = self::getDB()->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll();
    }
}

🛠️ 실전! User CRUD 구현해보기

이제 진짜 재미있는 부분이야. 실제로 사용자 관리 기능을 MVC로 구현해볼게.
이걸 이해하면 어떤 기능이든 MVC로 만들 수 있어! 💪

User 모델 (app/models/User.php)

app/models/User.php
<?php

class User extends Model {
    protected string $table = 'users';
    
    // 이메일로 사용자 찾기
    public function findByEmail(string $email): array|false {
        $result = $this->where('email', $email);
        return $result[0] ?? false;
    }
    
    // 비밀번호 해싱해서 생성
    public function createWithPassword(array $data): int {
        $data['password'] = password_hash($data['password'], PASSWORD_BCRYPT);
        $data['created_at'] = date('Y-m-d H:i:s');
        return $this->create($data);
    }
    
    // 비밀번호 검증
    public function verifyPassword(string $plainPassword, string $hashedPassword): bool {
        return password_verify($plainPassword, $hashedPassword);
    }
    
    // 최근 가입자 조회
    public function getRecentUsers(int $limit = 10): array {
        return $this->query(
            "SELECT id, name, email, created_at FROM users ORDER BY created_at DESC LIMIT ?",
            [$limit]
        );
    }
    
    // 검색
    public function search(string $keyword): array {
        return $this->query(
            "SELECT * FROM users WHERE name LIKE ? OR email LIKE ?",
            ["%{$keyword}%", "%{$keyword}%"]
        );
    }
}

User 컨트롤러 (app/controllers/UserController.php)

app/controllers/UserController.php
<?php

class UserController extends Controller {
    private User $userModel;
    
    public function __construct() {
        $this->userModel = new User();
    }
    
    // GET /users - 사용자 목록
    public function index(): void {
        $keyword = $_GET['search'] ?? '';
        
        if ($keyword) {
            $users = $this->userModel->search($keyword);
        } else {
            $users = $this->userModel->all();
        }
        
        $this->view('user.index', [
            'users'   => $users,
            'keyword' => $keyword,
            'title'   => '사용자 목록'
        ]);
    }
    
    // GET /users/{id} - 사용자 상세
    public function show(string $id): void {
        $user = $this->userModel->find((int) $id);
        
        if (!$user) {
            http_response_code(404);
            $this->view('errors.404', ['title' => '사용자를 찾을 수 없어요']);
            return;
        }
        
        $this->view('user.show', [
            'user'  => $user,
            'title' => $user['name'] . '님의 프로필'
        ]);
    }
    
    // POST /users - 사용자 생성
    public function store(): void {
        $data = [
            'name'  => $this->input('name'),
            'email' => $this->input('email'),
            'password' => $_POST['password'] ?? '', // 해싱 전이라 raw로
        ];
        
        // 유효성 검사
        $errors = $this->validate($data, [
            'name'     => 'required|min:2|max:50',
            'email'    => 'required|email',
            'password' => 'required|min:8',
        ]);
        
        if (!empty($errors)) {
            $this->json(['success' => false, 'errors' => $errors], 422);
            return;
        }
        
        // 이메일 중복 체크
        if ($this->userModel->findByEmail($data['email'])) {
            $this->json(['success' => false, 'message' => '이미 사용 중인 이메일이에요!'], 409);
            return;
        }
        
        $userId = $this->userModel->createWithPassword($data);
        
        $this->json([
            'success' => true,
            'message' => '사용자가 생성됐어요! 🎉',
            'userId'  => $userId
        ], 201);
    }
    
    // POST /users/{id}/update - 사용자 수정
    public function update(string $id): void {
        $user = $this->userModel->find((int) $id);
        
        if (!$user) {
            $this->json(['success' => false, 'message' => '사용자를 찾을 수 없어요'], 404);
            return;
        }
        
        $data = [
            'name'       => $this->input('name'),
            'email'      => $this->input('email'),
            'updated_at' => date('Y-m-d H:i:s'),
        ];
        
        $errors = $this->validate($data, [
            'name'  => 'required|min:2|max:50',
            'email' => 'required|email',
        ]);
        
        if (!empty($errors)) {
            $this->json(['success' => false, 'errors' => $errors], 422);
            return;
        }
        
        $this->userModel->update((int) $id, $data);
        
        $this->redirect('/users/' . $id);
    }
    
    // POST /users/{id}/delete - 사용자 삭제
    public function destroy(string $id): void {
        $user = $this->userModel->find((int) $id);
        
        if (!$user) {
            $this->json(['success' => false, 'message' => '사용자를 찾을 수 없어요'], 404);
            return;
        }
        
        $this->userModel->delete((int) $id);
        
        $this->json(['success' => true, 'message' => '삭제 완료! 👋']);
    }
}

View 파일들

뷰는 최대한 단순하게! 로직은 절대 여기 넣으면 안 돼. 데이터 출력만!

app/views/layouts/main.php
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= htmlspecialchars($title ?? 'My MVC App') ?></title>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <nav>
        <a href="/">홈</a>
        <a href="/users">사용자 목록</a>
    </nav>
    
    <main>
        <?= $content ?>
    </main>
    
    <footer>
        <p>My MVC App &copy; 2024</p>
    </footer>
</body>
</html>
app/views/user/index.php
<div class="container">
    <h1>사용자 목록 👥</h1>
    
    <!-- 검색 폼 -->
    <form method="GET" action="/users">
        <input type="text" name="search" 
               value="<?= htmlspecialchars($keyword) ?>" 
               placeholder="이름 또는 이메일 검색...">
        <button type="submit">검색</button>
    </form>
    
    <!-- 사용자 목록 -->
    <?php if (empty($users)): ?>
        <p>사용자가 없어요 😢</p>
    <?php else: ?>
        <table>
            <thead>
                <tr>
                    <th>ID</th>
                    <th>이름</th>
                    <th>이메일</th>
                    <th>가입일</th>
                    <th>액션</th>
                </tr>
            </thead>
            <tbody>
                <?php foreach ($users as $user): ?>
                <tr>
                    <td><?= $user['id'] ?></td>
                    <td><?= htmlspecialchars($user['name']) ?></td>
                    <td><?= htmlspecialchars($user['email']) ?></td>
                    <td><?= $user['created_at'] ?></td>
                    <td>
                        <a href="/users/<?= $user['id'] ?>">보기</a>
                    </td>
                </tr>
                <?php endforeach; ?>
            </tbody>
        </table>
    <?php endif; ?>
</div>

🗄️ 데이터베이스 설정

config/database.php
<?php

return [
    'host'     => 'localhost',
    'dbname'   => 'my_mvc_app',
    'username' => 'root',
    'password' => 'your_password',
    'charset'  => 'utf8mb4',
];
SQL - users 테이블 생성
CREATE TABLE users (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    name       VARCHAR(50)  NOT NULL,
    email      VARCHAR(100) NOT NULL UNIQUE,
    password   VARCHAR(255) NOT NULL,
    created_at DATETIME     DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

🔐 심화: 미들웨어 패턴 추가하기

실제 프로젝트에서는 인증 체크, 로깅, CORS 처리 같은 공통 작업이 필요해.
이걸 미들웨어(Middleware)로 처리하면 컨트롤러가 훨씬 깔끔해져! 🧹

⚠️ 미들웨어란? 요청이 컨트롤러에 도달하기 전(또는 응답이 나가기 전)에 실행되는 코드야.
인증 확인, 로그 기록, 입력 필터링 등을 여기서 처리해.
core/Middleware.php
<?php

interface MiddlewareInterface {
    public function handle(callable $next): void;
}

// 인증 미들웨어
class AuthMiddleware implements MiddlewareInterface {
    public function handle(callable $next): void {
        session_start();
        
        if (!isset($_SESSION['user_id'])) {
            header('Location: /login');
            exit;
        }
        
        // 다음 미들웨어 또는 컨트롤러 실행
        $next();
    }
}

// 로깅 미들웨어
class LogMiddleware implements MiddlewareInterface {
    public function handle(callable $next): void {
        $start = microtime(true);
        $method = $_SERVER['REQUEST_METHOD'];
        $uri = $_SERVER['REQUEST_URI'];
        
        // 요청 로그
        error_log("[" . date('Y-m-d H:i:s') . "] {$method} {$uri}");
        
        $next();
        
        // 응답 시간 로그
        $elapsed = round((microtime(true) - $start) * 1000, 2);
        error_log("  → 처리 시간: {$elapsed}ms");
    }
}

// 미들웨어 파이프라인
class Pipeline {
    private array $middlewares = [];
    
    public function pipe(MiddlewareInterface $middleware): static {
        $this->middlewares[] = $middleware;
        return $this;
    }
    
    public function run(callable $destination): void {
        $pipeline = array_reduce(
            array_reverse($this->middlewares),
            fn($carry, $middleware) => fn() => $middleware->handle($carry),
            $destination
        );
        
        $pipeline();
    }
}

라우터에서 미들웨어를 적용하는 방법:

미들웨어 적용 예시
<?php

// App.php에서 미들웨어 파이프라인 사용
$pipeline = new Pipeline();
$pipeline
    ->pipe(new LogMiddleware())
    ->pipe(new AuthMiddleware())  // 인증이 필요한 라우트에만
    ->run(function() use ($router, $url, $httpMethod) {
        $router->dispatch($url, $httpMethod);
    });

🎯 서비스 레이어 — MVC를 더 강하게

순수 MVC에서 한 단계 더 나아가면 서비스 레이어(Service Layer)가 등장해.
복잡한 비즈니스 로직이 컨트롤러에 쌓이기 시작하면, 서비스 클래스로 분리하는 게 정석이야. 🏆

📐 레이어 구조
Controller → Service → Model → Database

컨트롤러는 요청/응답만, 서비스는 비즈니스 로직만, 모델은 DB 접근만!
app/services/UserService.php
<?php

class UserService {
    private User $userModel;
    
    public function __construct() {
        $this->userModel = new User();
    }
    
    // 회원가입 처리 (복잡한 비즈니스 로직)
    public function register(array $data): array {
        // 1. 이메일 중복 체크
        if ($this->userModel->findByEmail($data['email'])) {
            throw new \RuntimeException('이미 사용 중인 이메일이에요!');
        }
        
        // 2. 비밀번호 강도 체크
        if (!$this->isStrongPassword($data['password'])) {
            throw new \RuntimeException('비밀번호는 대소문자, 숫자, 특수문자를 포함해야 해요!');
        }
        
        // 3. 사용자 생성
        $userId = $this->userModel->createWithPassword([
            'name'  => $data['name'],
            'email' => $data['email'],
            'password' => $data['password'],
        ]);
        
        // 4. 환영 이메일 발송 (실제로는 메일 서비스 호출)
        $this->sendWelcomeEmail($data['email'], $data['name']);
        
        // 5. 생성된 사용자 반환
        return $this->userModel->find($userId);
    }
    
    private function isStrongPassword(string $password): bool {
        return strlen($password) >= 8
            && preg_match('/[A-Z]/', $password)
            && preg_match('/[a-z]/', $password)
            && preg_match('/[0-9]/', $password)
            && preg_match('/[^A-Za-z0-9]/', $password);
    }
    
    private function sendWelcomeEmail(string $email, string $name): void {
        // 실제 메일 발송 로직
        // mail($email, "환영합니다!", "안녕하세요, {$name}님!");
        error_log("환영 이메일 발송: {$email}");
    }
}

이렇게 서비스 레이어를 쓰면 컨트롤러가 엄청 깔끔해져:

서비스를 사용하는 컨트롤러
<?php

class UserController extends Controller {
    private UserService $userService;
    
    public function __construct() {
        $this->userService = new UserService();
    }
    
    public function store(): void {
        try {
            $user = $this->userService->register([
                'name'     => $this->input('name'),
                'email'    => $this->input('email'),
                'password' => $_POST['password'] ?? '',
            ]);
            
            $this->json(['success' => true, 'user' => $user], 201);
            
        } catch (\RuntimeException $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 400);
        }
    }
}
컨트롤러가 얼마나 깔끔해졌어? 😍 try-catch 하나로 모든 비즈니스 예외를 처리하고, 실제 로직은 서비스에 위임했어. 이게 바로 단일 책임 원칙(SRP)이야!

스파게티 코드 vs MVC 아키텍처 🍝 vs 🏗️ ❌ 스파게티 코드 DB 쿼리 HTML 출력 비즈니스 로직 세션 처리 파일 업로드 모든 게 한 파일에 뒤엉켜 있어 😱 유지보수? 불가능에 가까워... ✅ MVC 아키텍처 🎮 Controller 요청 처리 & 흐름 제어만 🗄️ Model 데이터 & 비즈니스 로직만 🖼️ View 화면 출력만 역할이 명확하게 분리돼 있어 😊 유지보수, 테스트, 협업 모두 쉬워!

💡 실전에서 꼭 알아야 할 팁들

1
뷰에서 로직 쓰지 마!
<?php if ($user->isAdmin() && $user->hasPermission('edit')) ?>
이런 복잡한 조건은 컨트롤러에서 처리하고, 뷰에는 단순한 boolean 변수만 전달해. 뷰는 멍청하게 유지해야 해(Dumb View 원칙).
2
모델에서 HTML 절대 금지!
모델이 HTML을 리턴하는 순간 MVC가 무너지기 시작해. 모델은 순수한 데이터(배열, 객체)만 반환해야 해.
3
컨트롤러는 얇게 유지해!
컨트롤러 메서드가 30줄을 넘어가기 시작하면 서비스 레이어로 분리할 타이밍이야. 컨트롤러는 교통 정리만 해야 해.
4
XSS 방지는 뷰에서!
뷰에서 데이터를 출력할 때 항상 htmlspecialchars()를 써. 아니면 헬퍼 함수 e()를 만들어서 써.
5
Composer 오토로더 활용하기!
실제 프로젝트에서는 spl_autoload_register 대신 Composer의 PSR-4 오토로더를 써. 훨씬 표준적이고 안정적이야.
🚀 Composer PSR-4 오토로더 설정 (composer.json)

{
    "autoload": {
        "psr-4": {
            "App\\": "app/",
            "Core\\": "core/"
        }
    }
}
그 다음 composer dump-autoload 실행하면 끝!

🆚 직접 구현 vs 프레임워크 — 뭘 써야 해?

이렇게 MVC를 직접 구현해봤는데, 실제 프로젝트에서는 어떻게 해야 할까? 🤔
솔직하게 비교해줄게.

✅ 직접 구현이 좋은 경우 MVC 원리를 깊이 이해하고 싶을 때, 아주 가벼운 프로젝트, 프레임워크 의존성을 없애고 싶을 때, 학습 목적
🚀 Laravel/Symfony 같은 프레임워크가 좋은 경우 실제 상용 프로젝트, 팀 협업, 빠른 개발이 필요할 때, 보안·성능·유지보수가 중요할 때

재능넷 같은 플랫폼에서 PHP 개발 재능을 거래할 때도, 클라이언트들이 가장 많이 요청하는 건 Laravel 기반 프로젝트야.
하지만 MVC 원리를 직접 구현해본 개발자는 프레임워크를 써도 훨씬 깊이 이해하고 활용할 수 있어. 기초가 탄탄해야 응용도 잘 되는 법! 💪

실제로 Laravel도 내부적으로 우리가 만든 것과 거의 동일한 구조야:
routes/web.php → 라우터
app/Http/Controllers/ → 컨트롤러
app/Models/ → 모델 (Eloquent ORM)
resources/views/ → 뷰 (Blade 템플릿)

우리가 만든 구조랑 완전히 같지? 😄


🧪 MVC의 숨겨진 장점 — 테스트하기 쉬워!

MVC의 진짜 강점 중 하나는 단위 테스트(Unit Test)가 쉽다는 거야.
역할이 분리돼 있으니까 각 컴포넌트를 독립적으로 테스트할 수 있어! 🎯

tests/UserModelTest.php (PHPUnit)
<?php

use PHPUnit\Framework\TestCase;

class UserModelTest extends TestCase {
    private User $userModel;
    
    protected function setUp(): void {
        $this->userModel = new User();
    }
    
    public function testPasswordHashing(): void {
        // 비밀번호 해싱 테스트
        $userId = $this->userModel->createWithPassword([
            'name'     => '테스트 유저',
            'email'    => 'test@example.com',
            'password' => 'TestPass123!',
        ]);
        
        $user = $this->userModel->find($userId);
        
        // 원본 비밀번호와 다른지 확인
        $this->assertNotEquals('TestPass123!', $user['password']);
        
        // 비밀번호 검증이 되는지 확인
        $this->assertTrue(
            $this->userModel->verifyPassword('TestPass123!', $user['password'])
        );
        
        // 정리
        $this->userModel->delete($userId);
    }
    
    public function testFindByEmail(): void {
        $result = $this->userModel->findByEmail('nonexistent@example.com');
        $this->assertFalse($result);
    }
}
MVC로 잘 분리된 코드는 테스트 커버리지를 높이기 쉬워. 그리고 테스트가 잘 된 코드는 리팩토링도 두렵지 않아. 이게 바로 좋은 아키텍처의 선순환이야! 🔄

🎓 마무리 — 이제 너도 MVC 마스터!

오늘 우리가 함께 만든 것들을 정리해볼게:

✓
프로젝트 구조 — 역할별로 깔끔하게 디렉토리 분리
✓
라우터 — URL을 분석해서 적절한 컨트롤러로 연결
✓
베이스 컨트롤러 — 뷰 렌더링, JSON 응답, 유효성 검사 공통화
✓
베이스 모델 — PDO 기반 CRUD 공통화
✓
미들웨어 — 인증, 로깅 등 공통 처리
✓
서비스 레이어 — 복잡한 비즈니스 로직 분리

MVC는 처음엔 "이렇게까지 해야 해?" 싶을 수 있어.
근데 프로젝트가 커지고, 팀원이 늘어나고, 6개월 뒤에 코드를 다시 봤을 때 — 그때 MVC의 진가를 느끼게 될 거야. 😌

재능넷에서 PHP 개발 재능을 공유하거나 의뢰할 때도, MVC 아키텍처를 이해하고 적용할 수 있는 개발자는 확실히 더 높은 가치를 인정받아. 코드의 품질이 곧 실력의 증거니까! 🏆

자, 이제 직접 만들어봐. 코드는 읽는 게 아니라 짜는 것이니까! 💻🔥

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.