본문 바로가기
튜토리얼/백엔드

[PHP] REST API 서버 만들기 — 프레임워크 없이 순수 PHP로 CRUD API 구현

by 루까(Luka) 2026. 3. 4.
반응형

왜 만들었나

지난 두 편에서 PHP로 로그인 시스템과 게시판을 만들었다. 둘 다 서버에서 HTML을 렌더링하는 전통적인 방식이었다. 이번엔 반대로, JSON만 응답하는 REST API 서버를 만든다.

프론트엔드가 React든 Vue든 모바일 앱이든, API 서버는 입력받고 처리하고 JSON을 돌려주는 역할만 한다. 프레임워크 없이 PHP 내장 기능만으로 이걸 구현하면, 라우팅이 어떻게 동작하는지, HTTP 메서드가 왜 5가지인지, 상태 코드가 왜 중요한지 직접 체감할 수 있다.


기술 상세

아키텍처

27-php-rest-api/
├── index.php   # 라우터 + 핸들러 (단일 진입점)
├── db.php      # PDO SQLite 싱글턴
├── .htaccess   # Apache 리라이트 (php -S 사용 시 불필요)
└── data/       # SQLite DB (gitignore)

단일 파일(index.php) 구조를 선택했다. 라우터, 핸들러, 헬퍼가 한 곳에 있어서 코드 흐름을 한눈에 파악할 수 있다.

API 엔드포인트

메서드 경로 역할 응답 코드
GET /api/tasks 전체 목록 200
POST /api/tasks 태스크 생성 201
GET /api/tasks/{id} 단건 조회 200
PUT /api/tasks/{id} 수정 200
DELETE /api/tasks/{id} 삭제 204

DB 스키마

CREATE TABLE tasks (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT    NOT NULL,
    done       INTEGER NOT NULL DEFAULT 0,
    created_at TEXT    NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT
);

done은 SQLite에 boolean이 없어서 0/1 INTEGER로 저장하고, 응답 시 (bool)로 캐스팅해서 내보낸다.


실제 소스 코드

공통 헤더 + CORS

header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');

// CORS preflight (브라우저가 먼저 OPTIONS 요청을 보낸다)
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

모든 응답에 Content-Type: application/json을 설정하고, CORS를 허용한다. 프론트엔드를 다른 포트에서 실행할 때 필요하다.

라우터

$method = $_SERVER['REQUEST_METHOD'];
$uri    = rtrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');

if (preg_match('#^/api/tasks(?:/(\d+))?$#', $uri, $m)) {
    $id = isset($m[1]) ? (int)$m[1] : null;

    if ($id === null) {
        if ($method === 'GET')  { list_tasks();  exit; }
        if ($method === 'POST') { create_task(); exit; }
    } else {
        if ($method === 'GET')    { get_task($id);    exit; }
        if ($method === 'PUT')    { update_task($id); exit; }
        if ($method === 'DELETE') { delete_task($id); exit; }
    }
    json_error(405, 'Method Not Allowed');
}

정규식 하나로 /api/tasks/api/tasks/{id} 두 패턴을 동시에 처리한다. $m[1]이 있으면 단건 리소스, 없으면 컬렉션.

태스크 생성 (POST)

function create_task(): void
{
    $body  = json_body();
    $title = trim($body['title'] ?? '');

    if ($title === '') {
        json_error(400, '"title" is required');
        return;
    }

    $stmt = get_db()->prepare('INSERT INTO tasks (title) VALUES (?)');
    $stmt->execute([$title]);

    $id   = (int)get_db()->lastInsertId();
    $stmt = get_db()->prepare('SELECT * FROM tasks WHERE id = ?');
    $stmt->execute([$id]);
    $task = $stmt->fetch();
    $task['done'] = (bool)$task['done'];

    json_ok(201, $task);
}

생성 성공 시 201 Created를 반환하고, 삽입된 레코드를 다시 조회해서 응답에 담는다.

부분 수정 (PUT)

function update_task(int $id): void
{
    $body   = json_body();
    $fields = [];
    $params = [];

    if (array_key_exists('title', $body)) {
        $title = trim($body['title']);
        if ($title === '') { json_error(400, '"title" cannot be empty'); return; }
        $fields[] = 'title = ?';
        $params[] = $title;
    }

    if (array_key_exists('done', $body)) {
        $fields[] = 'done = ?';
        $params[] = $body['done'] ? 1 : 0;
    }

    if (empty($fields)) { json_error(400, 'No fields to update'); return; }

    $fields[] = "updated_at = datetime('now')";
    $params[]  = $id;

    $upd = get_db()->prepare(
        'UPDATE tasks SET ' . implode(', ', $fields) . ' WHERE id = ?'
    );
    $upd->execute($params);
}

요청 바디에 있는 필드만 업데이트한다. isset 대신 array_key_exists를 쓰는 이유는 "done": false처럼 값이 falsy해도 필드가 있다는 걸 감지해야 하기 때문이다.

삭제 (DELETE)

function delete_task(int $id): void
{
    $chk = get_db()->prepare('SELECT id FROM tasks WHERE id = ?');
    $chk->execute([$id]);
    if (!$chk->fetch()) { json_error(404, 'Task not found'); return; }

    get_db()->prepare('DELETE FROM tasks WHERE id = ?')->execute([$id]);
    http_response_code(204);
}

204 No Content — 성공했지만 응답 바디가 없다는 의미다.

헬퍼

function json_body(): array
{
    return json_decode(file_get_contents('php://input'), true) ?? [];
}

function json_ok(int $status, mixed $data): void
{
    http_response_code($status);
    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}

function json_error(int $status, string $message): void
{
    http_response_code($status);
    echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE);
}

php://input은 요청 바디 스트림이다. POST 폼 데이터($_POST)와 달리 JSON 바디를 읽을 때 사용한다.


비교 테이블

항목 이번 구현 Laravel / Slim
라우팅 정규식 직접 작성 프레임워크 라우터
미들웨어 없음 (헤더 직접 출력) 미들웨어 파이프라인
유효성 검사 수동 if 분기 검증 규칙 DSL
에러 처리 json_error() 함수 예외 핸들러
코드량 ~130줄 더 많음, 더 구조화됨
학습 효과 HTTP/PHP 내부 이해 프레임워크 사용법

삽질 기록

1. isset vs array_key_exists — falsy 값 누락 버그

처음엔 isset($body['done'])을 썼다. {"done": false} 요청을 보내면 isset이 false를 반환해서 done 필드가 업데이트되지 않는 문제가 발생했다. array_key_exists로 교체해서 해결했다.

2. SQLite에 boolean 없음

done 컬럼을 INTEGER로 저장하는데, fetchAll()로 꺼내면 "0"/"1" 문자열이 아니라 정수로 온다. JSON 응답에서는 (bool)$row['done']으로 캐스팅해서 true/false로 내보냈다.

3. php -S 라우터 스크립트

php -S localhost:8080만 쓰면 /api/tasks 같은 경로는 파일이 없어서 404가 난다. php -S localhost:8080 index.php처럼 라우터 스크립트를 지정해야 모든 요청이 index.php를 거친다.


curl 테스트

# 서버 시작
php -S localhost:8080 index.php

# 생성
curl -X POST http://localhost:8080/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"할 일 추가"}'

# 목록
curl http://localhost:8080/api/tasks

# 완료 처리
curl -X PUT http://localhost:8080/api/tasks/1 \
  -H 'Content-Type: application/json' \
  -d '{"done":true}'

# 삭제
curl -X DELETE http://localhost:8080/api/tasks/1

마무리

프레임워크 없이 REST API를 구현하면 당연하게 써왔던 것들의 실체가 보인다. 라우터는 결국 정규식, 미들웨어는 함수 호출 순서, 에러 핸들러는 http_response_code + json_encode다. 이 원리를 알고 나서 Laravel 같은 프레임워크를 쓰면 내부 동작을 이해하면서 쓸 수 있다.


기술 스택 PHP 8 PDO SQLite REST API CORS JSON

소스 코드 GitHub

로컬 실행

cd 27-php-rest-api
php -S localhost:8080 index.php
# → http://localhost:8080/api/tasks
반응형