Skip to main content
Summary

왜 구조를 나누어야 할까

처음에는 index.php 하나에 모든 코드를 넣어도 괜찮아 보입니다. 요청 값을 읽고, 데이터베이스에서 글을 조회하고, HTML을 출력하면 페이지는 동작합니다.

<?php
require_once __DIR__ . '/db.php';

$stmt = $pdo->query('SELECT id, title, created_at FROM posts ORDER BY id DESC');
$posts = $stmt->fetchAll();
?>
<!doctype html>
<html lang="ko">
<body>
  <h1>게시글 목록</h1>
  <?php foreach ($posts as $post): ?>
    <p><?= htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8') ?></p>
  <?php endforeach; ?>
</body>
</html>

작은 예제에서는 문제가 없습니다. 하지만 기능이 늘어나면 한 파일 안에 SQL, 조건문, 입력 검증, HTML이 뒤섞입니다. 글 목록, 글 작성, 글 수정, 로그인, 관리자 페이지가 추가될수록 어디를 고쳐야 하는지 찾기 어려워집니다.

MVC는 이 문제를 줄이기 위한 대표적인 구조입니다.

  • Model: 데이터와 관련된 일을 담당합니다. 보통 데이터베이스 조회, 저장, 수정, 삭제가 들어갑니다.

  • View: 사용자에게 보여 줄 HTML을 담당합니다.

  • Controller: 요청을 받아 모델을 호출하고, 어떤 뷰를 보여 줄지 결정합니다.

쉽게 말해 모델은 "데이터 담당", 뷰는 "화면 담당", 컨트롤러는 "흐름 담당"입니다.

MVC 구조로 작은 웹 애플리케이션 만들기AI 생성 이미지

이번 장에서 만들 구조

이번 장에서는 순수 PHP로 작은 게시글 애플리케이션 구조를 만듭니다. Laravel 같은 프레임워크를 사용하지 않습니다. 대신 프레임워크가 내부에서 해 주는 일을 아주 작게 직접 만들어 봅니다.

기본 폴더 구조는 다음과 같습니다.

myapp/
├── composer.json
├── config/
│   └── database.php
├── public/
│   └── index.php
├── src/
│   ├── Controllers/
│   │   └── PostController.php
│   └── Models/
│       └── Post.php
└── views/
    ├── layout.php
    └── posts/
        ├── index.php
        └── create.php

중요한 점은 public/index.php만 브라우저에서 직접 접근하게 한다는 것입니다. 이 파일은 모든 요청이 처음 도착하는 입구입니다. 이런 파일을 프론트 컨트롤러라고 부릅니다. 실제 서비스에서는 웹 서버의 문서 루트를 public/으로 맞추어 src/, config/, views/ 같은 내부 파일이 직접 노출되지 않게 합니다.

Composer 오토로딩은 다음처럼 설정했다고 가정합니다.

{
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

설정 후에는 터미널에서 실행합니다.

composer dump-autoload

로컬에서 PHP 내장 서버로 바로 확인하고 싶다면 프로젝트 루트에서 다음처럼 실행할 수 있습니다.

php -S 127.0.0.1:8000 -t public public/index.php

여기서 public/index.php를 함께 지정하는 이유는 /posts처럼 실제 파일이 아닌 주소도 프론트 컨트롤러로 보내기 위해서입니다. Apache나 Nginx를 사용할 때도 마찬가지로, 존재하지 않는 경로를 public/index.php로 넘기는 설정이 필요합니다.

데이터베이스 연결 분리하기

먼저 PDO 연결 코드를 config/database.php로 분리합니다. 데이터베이스 연결은 여러 곳에서 필요하지만, 매번 새로 작성하면 중복이 생깁니다.

<?php
// config/database.php
declare(strict_types=1);

$dsn = 'mysql:host=127.0.0.1;dbname=php_textbook;charset=utf8mb4';
$user = 'root';
$password = '';

return new PDO($dsn, $user, $password, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

이 파일은 $pdo 변수를 전역으로 만들지 않고, PDO 객체를 return합니다. 필요한 곳에서 값을 받아 쓰면 의존 관계가 더 분명해집니다. 예제에서는 설명을 단순하게 하기 위해 접속 정보를 코드에 직접 적었지만, 실제 프로젝트에서는 환경 변수나 별도 설정 파일을 사용하는 편이 좋습니다.

예제에서 사용할 posts 테이블은 다음 정도면 충분합니다.

CREATE TABLE posts (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(200) NOT NULL,
  body TEXT NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

모델: 데이터베이스 접근 담당

모델은 데이터 처리의 중심입니다. 여기서는 Post 모델이 게시글 조회와 저장을 담당합니다.

<?php
// src/Models/Post.php
declare(strict_types=1);

namespace AppModels;

use PDO;

final class Post
{
    public function __construct(private PDO $pdo)
    {
    }

    public function all(): array
    {
        $stmt = $this->pdo->query(
            'SELECT id, title, created_at FROM posts ORDER BY id DESC'
        );

        return $stmt->fetchAll();
    }

    public function create(string $title, string $body): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO posts (title, body) VALUES (:title, :body)'
        );

        $stmt->execute([
            'title' => $title,
            'body' => $body,
        ]);

        return (int) $this->pdo->lastInsertId();
    }
}

모델 안에서는 HTML을 출력하지 않습니다. 모델은 "게시글 목록을 가져온다", "게시글을 저장한다" 같은 데이터 작업에 집중합니다. 사용자 입력이 들어가는 SQL은 여전히 prepare()와 execute()로 처리합니다.

뷰: HTML 출력 담당

뷰는 화면을 담당합니다. 뷰 파일에서는 데이터베이스에 직접 접근하지 않습니다. 컨트롤러가 전달한 데이터를 받아 HTML로 보여 줍니다.

먼저 모든 뷰에서 사용할 이스케이프 함수를 생각해 봅시다.

<?php
function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

실제 프로젝트에서는 공통 함수 파일로 분리할 수 있습니다. 핵심은 사용자 입력이나 데이터베이스 값을 HTML에 출력할 때 그대로 출력하지 않는 것입니다.

게시글 목록 뷰는 다음과 같이 작성할 수 있습니다.

<!-- views/posts/index.php -->
<h2>게시글 목록</h2>

<p><a href="/posts/create">새 글 작성</a></p>

<?php if ($posts === []): ?>
  <p>아직 게시글이 없습니다.</p>
<?php else: ?>
  <ul>
    <?php foreach ($posts as $post): ?>
      <li>
        <?= e($post['title']) ?>
        <small><?= e($post['created_at']) ?></small>
      </li>
    <?php endforeach; ?>
  </ul>
<?php endif; ?>

글 작성 화면은 폼을 출력합니다.

<!-- views/posts/create.php -->
<h2>새 글 작성</h2>

<?php if (!empty($errors)): ?>
  <ul>
    <?php foreach ($errors as $error): ?>
      <li><?= e($error) ?></li>
    <?php endforeach; ?>
  </ul>
<?php endif; ?>

<form method="post" action="/posts">
  <div>
    <label>제목</label><br>
    <input type="text" name="title" value="<?= e($old['title'] ?? '') ?>">
  </div>

  <div>
    <label>본문</label><br>
    <textarea name="body"><?= e($old['body'] ?? '') ?></textarea>
  </div>

  <button type="submit">저장</button>
</form>

뷰에서 조건문과 반복문을 아예 쓰지 말아야 하는 것은 아닙니다. 목록을 반복 출력하거나 오류 메시지를 보여 주는 정도는 자연스럽습니다. 다만 SQL 실행, 복잡한 입력 처리, 비즈니스 규칙은 뷰에 넣지 않는 것이 좋습니다.

공통 레이아웃으로 중복 줄이기

여러 페이지에는 공통으로 들어가는 HTML이 있습니다. <!doctype html>, <head>, 메뉴, 푸터 같은 부분입니다. 이 코드를 모든 뷰에 반복해서 쓰면 수정이 어려워집니다.

views/layout.php를 만듭니다.

<!-- views/layout.php -->
<!doctype html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title><?= e($title ?? 'PHP 게시판') ?></title>
</head>
<body>
  <header>
    <h1><a href="/posts">PHP 게시판</a></h1>
    <hr>
  </header>

  <main>
    <?= $content ?>
  </main>

  <hr>
  <footer>
    <small>순수 PHP MVC 예제</small>
  </footer>
</body>
</html>

여기서 $content는 각 페이지의 실제 본문 HTML입니다. 단, $content는 이미 뷰 파일을 실행해서 만든 HTML이므로 다시 e()로 감싸지 않습니다. 반대로 제목처럼 문자열 데이터는 e()로 이스케이프합니다.

컨트롤러: 요청 흐름 담당

컨트롤러는 요청을 처리하는 중심입니다. 직접 SQL을 길게 작성하지 않고 모델을 호출합니다. 직접 HTML을 길게 만들지 않고 뷰를 렌더링합니다.

<?php
// src/Controllers/PostController.php
declare(strict_types=1);

namespace AppControllers;

use AppModelsPost;

final class PostController
{
    public function __construct(private Post $post)
    {
    }

    public function index(): void
    {
        $posts = $this->post->all();

        $this->render('posts/index', [
            'title' => '게시글 목록',
            'posts' => $posts,
        ]);
    }

    public function create(): void
    {
        $this->render('posts/create', [
            'title' => '새 글 작성',
            'errors' => [],
            'old' => [],
        ]);
    }

    public function store(): void
    {
        $title = trim($_POST['title'] ?? '');
        $body = trim($_POST['body'] ?? '');

        $errors = [];

        if ($title === '') {
            $errors[] = '제목을 입력하세요.';
        }

        if ($body === '') {
            $errors[] = '본문을 입력하세요.';
        }

        if ($errors !== []) {
            $this->render('posts/create', [
                'title' => '새 글 작성',
                'errors' => $errors,
                'old' => ['title' => $title, 'body' => $body],
            ]);
            return;
        }

        $this->post->create($title, $body);

        header('Location: /posts');
        exit;
    }

    private function render(string $view, array $data = []): void
    {
        extract($data, EXTR_SKIP);

        ob_start();
        require dirname(__DIR__, 2) . "/views/{$view}.php";
        $content = ob_get_clean();

        require dirname(__DIR__, 2) . '/views/layout.php';
    }
}

ob_start()는 출력 버퍼링을 시작합니다. 쉽게 말해 화면에 바로 출력하지 않고 잠시 문자열로 모아 두는 기능입니다. 뷰 파일이 만든 HTML을 $content에 담은 뒤, 공통 레이아웃 안에 끼워 넣기 위해 사용합니다.

extract()는 배열의 키를 변수로 바꿉니다. 예를 들어 ['posts' => $posts]는 뷰 안에서 $posts 변수로 사용할 수 있게 됩니다. 편리하지만 변수명이 충돌할 수 있으므로 EXTR_SKIP을 사용했습니다. 큰 프로젝트에서는 더 명시적인 뷰 객체나 템플릿 도구를 쓰기도 합니다.

라우터: URL과 컨트롤러 연결하기

라우터는 요청 URL을 보고 어떤 컨트롤러 메서드를 실행할지 결정합니다. 아주 단순한 라우터는 public/index.php에 만들 수 있습니다.

<?php
// public/index.php
declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use AppControllersPostController;
use AppModelsPost;

function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$pdo = require dirname(__DIR__) . '/config/database.php';

$postModel = new Post($pdo);
$postController = new PostController($postModel);

$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/';

$routes = [
    'GET /' => [$postController, 'index'],
    'GET /posts' => [$postController, 'index'],
    'GET /posts/create' => [$postController, 'create'],
    'POST /posts' => [$postController, 'store'],
];

$routeKey = $method . ' ' . $path;

if (!isset($routes[$routeKey])) {
    http_response_code(404);
    echo '404 Not Found';
    exit;
}

$handler = $routes[$routeKey];
$handler();

parse_url()로 쿼리 문자열을 제외한 경로만 가져옵니다. 예를 들어 /posts?page=2 요청이 들어와도 경로는 /posts로 처리됩니다.

라우터의 핵심은 단순합니다.

요청 메서드 + URL 경로 → 실행할 컨트롤러 메서드

실무 프레임워크의 라우터는 훨씬 많은 일을 합니다. /posts/10처럼 동적인 주소를 분석하고, 미들웨어를 실행하고, 권한을 검사합니다. 하지만 기본 원리는 지금 만든 작은 배열 라우터와 크게 다르지 않습니다.

역할을 나누는 기준

MVC를 처음 적용할 때 가장 많이 헷갈리는 질문은 "이 코드를 어디에 넣어야 하지?"입니다. 다음 기준을 사용하면 판단이 쉬워집니다.

  • 데이터베이스 SQL을 실행하는가? → 모델

  • 요청 값을 읽고 검증하며 다음 화면을 결정하는가? → 컨트롤러

  • HTML을 만들고 데이터를 화면에 표시하는가? → 뷰

  • 모든 페이지에 공통으로 필요한 HTML인가? → 레이아웃

  • URL에 따라 실행할 기능을 고르는가? → 라우터

이 기준은 절대 법칙이 아닙니다. 프로젝트가 커지면 서비스 클래스, 요청 검증 클래스, 템플릿 엔진 등이 추가될 수 있습니다. 하지만 처음에는 위 기준만 지켜도 코드가 훨씬 읽기 쉬워집니다.

작은 프레임워크를 만든다는 생각

이번 장에서 만든 구조는 매우 단순합니다. 그래도 중요한 흐름은 들어 있습니다.

  1. 브라우저가 /posts를 요청합니다.

  2. 모든 요청은 public/index.php로 들어옵니다.

  3. 라우터가 GET /posts에 맞는 컨트롤러 메서드를 찾습니다.

  4. 컨트롤러가 모델에 게시글 목록을 요청합니다.

  5. 모델이 PDO로 데이터베이스를 조회합니다.

  6. 컨트롤러가 조회 결과를 뷰에 전달합니다.

  7. 뷰가 HTML을 만들고 레이아웃 안에 표시됩니다.

Laravel 같은 프레임워크는 이 흐름을 더 강력하고 편리하게 만든 도구입니다. 라우팅, 컨트롤러 실행, 모델, 뷰, 설정 관리, 보안 기능을 정교하게 제공합니다. 순수 PHP로 MVC를 한 번 만들어 보면 프레임워크가 마법이 아니라 반복되는 구조를 체계화한 결과라는 점을 이해할 수 있습니다.

실습 과제

  1. Post 모델에 find(int $id): ?array 메서드를 추가해 특정 게시글을 조회해 보세요.

  2. /posts/show?id=1 주소를 라우터에 추가하고, 상세 보기 컨트롤러와 뷰를 만들어 보세요.

  3. views/layout.php에 공통 내비게이션 메뉴를 추가해 보세요.

  4. 글 작성 폼에 CSRF 토큰을 적용해 보세요. 세션에 토큰을 저장하고, 폼에는 hidden 입력으로 토큰을 함께 보낸 뒤, POST 요청을 처리할 때 세션의 토큰과 제출된 토큰이 일치하는지 확인하면 됩니다.

  5. e() 함수를 별도 파일로 옮기고 public/index.php에서 불러오도록 정리해 보세요.

자주 발생하는 오류 점검

  • 클래스를 찾을 수 없다는 오류가 난다: 네임스페이스와 파일 경로가 PSR-4 규칙에 맞는지 확인하세요. composer dump-autoload 실행도 잊지 마세요.

  • /posts 주소가 404로 나온다: 모든 요청이 public/index.php로 들어오도록 PHP 내장 서버의 라우터 스크립트나 웹 서버의 rewrite 설정을 확인하세요.

  • 뷰 파일을 찾지 못한다: require 경로를 현재 파일 기준이 아니라 dirname(__DIR__, 2)처럼 프로젝트 기준으로 계산했는지 확인하세요.

  • 헤더 리다이렉트가 동작하지 않는다: header('Location: ...') 호출 전에 공백이나 HTML이 출력되면 안 됩니다. 리다이렉트 후에는 exit를 호출하세요.

  • 한글이 깨진다: HTML의 <meta charset="UTF-8">, 데이터베이스 DSN의 charset=utf8mb4, 테이블 문자셋을 함께 확인하세요.

  • HTML 태그가 실행된다: 뷰에서 사용자 입력이나 데이터베이스 값을 출력할 때 e()를 사용했는지 확인하세요.

  • 모델이 너무 복잡해진다: SQL과 데이터 접근은 모델에 두되, 여러 모델을 조합하는 복잡한 업무 규칙은 별도 서비스 클래스로 분리할 수 있습니다.

MVC의 목적은 파일 수를 늘리는 것이 아닙니다. 코드를 역할별로 나누어 변경하기 쉽게 만드는 것입니다. 작은 애플리케이션에서 이 감각을 익혀 두면, 이후 JSON API, 테스트, 배포, 프레임워크 학습으로 넘어갈 때 훨씬 안정적으로 이해할 수 있습니다.

책 목록

PHP 교과서: 기초 문법부터 게시판 프로젝트와 배포까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기