PHP가 HTML만 만드는 것은 아니다
지금까지 PHP는 주로 HTML 페이지를 만들어 브라우저에 보냈다. 게시글 목록 페이지를 요청하면 PHP가 데이터베이스에서 글을 읽고, 그 결과를 <table>이나 <ul> 같은 HTML로 출력했다.
하지만 현대 웹에서 서버가 항상 완성된 HTML만 보내는 것은 아니다. 모바일 앱, JavaScript로 만든 화면, 다른 서버 프로그램은 HTML보다 데이터를 원할 때가 많다. 이때 자주 사용하는 형식이 JSON이다.
JSON은 JavaScript Object Notation의 줄임말이다. 이름에는 JavaScript가 들어가지만, 지금은 거의 모든 언어에서 사용하는 가벼운 데이터 형식이다. PHP 배열과 비슷하게 키와 값으로 데이터를 표현한다.
{
"id": 1,
"title": "첫 번째 글",
"author": "홍길동"
}API는 프로그램끼리 약속된 방식으로 데이터를 주고받는 통로다. PHP로 JSON API를 만든다는 말은 "브라우저나 앱이 요청하면 PHP가 JSON 데이터를 응답한다"는 뜻이다.
비동기 요청은 페이지 전체를 새로고침하지 않고, 필요한 데이터만 뒤에서 가져오는 방식이다. 예를 들어 게시글 목록 화면에서 "더 보기" 버튼을 눌렀을 때 전체 페이지를 다시 불러오지 않고 새 게시글만 추가할 수 있다.
AI 생성 이미지
Content-Type과 상태 코드로 응답의 의미 표현하기
HTML 응답과 JSON 응답은 모양만 다른 것이 아니다. 서버는 브라우저에게 "지금 보내는 내용이 JSON이다"라고 알려 주어야 한다. 이때 사용하는 HTTP 헤더가 Content-Type이다.
header('Content-Type: application/json; charset=utf-8');JSON API에서는 응답 본문뿐 아니라 HTTP 상태 코드도 중요하다. 상태 코드는 요청 결과를 숫자로 표현하는 약속이다.
| 상태 코드 | 의미 | 예시 |
|---|---|---|
| 200 | 성공 | 목록 조회, 상세 조회 성공 |
| 201 | 생성 성공 | 새 게시글 등록 성공 |
| 204 | 성공했지만 응답 본문 없음 | 삭제 성공 |
| 400 | 잘못된 요청 | id가 숫자가 아님 |
| 401 | 인증 필요 | 로그인하지 않음 |
| 403 | 권한 없음 | 다른 사람의 글 수정 시도 |
| 404 | 찾을 수 없음 | 존재하지 않는 게시글 |
| 422 | 형식은 맞지만 검증 실패 | 제목이 비어 있음 |
| 500 | 서버 오류 | 데이터베이스 오류 |
API를 만들 때 모든 결과를 200 OK로 보내고 본문에만 성공·실패를 적는 방식은 피하는 것이 좋다. 클라이언트는 상태 코드를 보고 먼저 요청 성공 여부를 판단할 수 있어야 한다.
반복해서 사용할 JSON 응답 함수를 만들어 보자.
<?php
declare(strict_types=1);
function json_response(array $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(
$data,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
exit;
}JSON_UNESCAPED_UNICODE는 한글을 uD55C 같은 형태로 바꾸지 않고 그대로 보여 주기 위한 옵션이다. 기능상 필수는 아니지만 개발 중 응답을 읽기 쉬워진다.
never는 이 함수가 값을 반환하지 않고 exit로 종료된다는 뜻이다. PHP 8.1 이상에서 사용할 수 있다. 어렵다면 처음에는 반환 타입을 생략해도 된다.
json_encode와 json_decode 사용하기
PHP 배열을 JSON 문자열로 바꿀 때는 json_encode()를 사용한다.
<?php
$post = [
'id' => 1,
'title' => 'PHP로 API 만들기',
'published' => true,
];
$json = json_encode($post, JSON_UNESCAPED_UNICODE);
echo $json;출력 결과는 다음과 비슷하다.
{"id":1,"title":"PHP로 API 만들기","published":true}반대로 JSON 문자열을 PHP 배열로 바꿀 때는 json_decode()를 사용한다.
<?php
$rawJson = '{"title":"새 글","body":"본문입니다."}';
$data = json_decode($rawJson, true);
var_dump($data['title']);PHP 문자열을 작은따옴표로 감싸면 JSON 안의 큰따옴표를 그대로 쓸 수 있다. 그래서 위 예제처럼 JSON 문자열을 만들 때 큰따옴표 앞에 불필요한 역슬래시를 붙일 필요가 없다.
두 번째 인수로 true를 넣으면 객체가 아니라 연관 배열로 변환된다. 이 책에서는 초보자가 다루기 쉬운 배열 방식을 기준으로 설명한다.
브라우저가 POST 요청으로 JSON을 보낼 때는 $_POST에 값이 들어오지 않는다. $_POST는 주로 HTML 폼 전송에 사용된다. JSON 본문은 다음처럼 직접 읽어야 한다.
<?php
$rawBody = file_get_contents('php://input');
$data = json_decode($rawBody, true);
if (!is_array($data)) {
json_response([
'error' => [
'message' => '올바른 JSON 형식이 아닙니다.',
],
], 400);
}
$title = trim($data['title'] ?? '');
$body = trim($data['body'] ?? '');
if ($title === '' || $body === '') {
json_response([
'error' => [
'message' => '제목과 본문을 입력하세요.',
],
], 422);
}JSON 파싱은 실패할 수 있다. 실무에서는 json_last_error_msg()나 JSON_THROW_ON_ERROR 옵션을 사용해 실패 원인을 더 명확하게 처리한다. 핵심은 "클라이언트가 보낸 JSON도 사용자 입력"이라는 점이다. 반드시 검증한 뒤 사용해야 한다.
게시글 목록 API와 상세 조회 API 만들기
간단한 게시판 API를 설계해 보자. HTML 페이지가 아니라 JSON 데이터를 돌려주는 주소다.
| 메서드 | 주소 | 설명 |
|---|---|---|
| GET | /api/posts.php | 게시글 목록 조회 |
| GET | /api/post.php?id=1 | 게시글 상세 조회 |
더 REST에 가까운 구조에서는 /api/posts/1처럼 표현하기도 한다. 지금은 PHP를 처음 배우는 독자가 직접 실행하기 쉽도록 파일 단위 예제로 작성한다.
먼저 게시글 목록 API다.
<?php
// public/api/posts.php
declare(strict_types=1);
require_once __DIR__ . '/../../src/support.php';
require_once __DIR__ . '/../../config/db.php';
$pdo = db();
$stmt = $pdo->query(
'SELECT id, title, author_name, created_at
FROM posts
ORDER BY id DESC
LIMIT 20'
);
$posts = $stmt->fetchAll();
json_response([
'data' => $posts,
]);응답은 다음처럼 만든다.
{
"data": [
{
"id": 3,
"title": "세 번째 글",
"author_name": "김개발",
"created_at": "2026-06-21 10:30:00"
}
]
}이번에는 상세 조회 API다.
<?php
// public/api/post.php
declare(strict_types=1);
require_once __DIR__ . '/../../src/support.php';
require_once __DIR__ . '/../../config/db.php';
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null) {
json_response([
'error' => [
'message' => '올바른 게시글 id가 필요합니다.',
],
], 400);
}
$pdo = db();
$stmt = $pdo->prepare(
'SELECT id, title, body, author_name, created_at
FROM posts
WHERE id = :id'
);
$stmt->execute(['id' => $id]);
$post = $stmt->fetch();
if (!$post) {
json_response([
'error' => [
'message' => '게시글을 찾을 수 없습니다.',
],
], 404);
}
json_response([
'data' => $post,
]);여기서도 사용자 입력인 id를 SQL 문자열에 직접 붙이지 않는다. prepare()와 execute()를 사용해 안전하게 값을 전달한다. JSON API라고 해서 SQL 인젝션이나 XSS 위험이 사라지는 것은 아니다.
다만 JSON 응답에서는 HTML 이스케이프를 보통 서버에서 미리 하지 않는다. API는 데이터를 보내고, 화면에 출력하는 쪽에서 안전하게 처리한다. 브라우저에서 DOM에 넣을 때는 innerHTML보다 textContent를 우선 사용해야 한다.
fetch로 브라우저에서 API 호출하기
브라우저의 fetch() 함수는 JavaScript에서 HTTP 요청을 보낼 때 사용한다. 페이지 전체를 새로고침하지 않고 API를 호출할 수 있다.
<button id="loadButton">게시글 불러오기</button>
<ul id="postList"></ul>
<script>
const button = document.querySelector('#loadButton');
const postList = document.querySelector('#postList');
button.addEventListener('click', async () => {
postList.textContent = '불러오는 중...';
try {
const response = await fetch('/api/posts.php', {
headers: {
'Accept': 'application/json'
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error?.message ?? '요청에 실패했습니다.');
}
postList.textContent = '';
for (const post of result.data) {
const li = document.createElement('li');
li.textContent = `${post.id}. ${post.title}`;
postList.appendChild(li);
}
} catch (error) {
postList.textContent = error.message;
}
});
</script>중요한 부분은 두 가지다.
첫째, response.ok를 확인한다. HTTP 상태 코드가 200번대이면 true, 아니면 false다. API가 404나 422를 보냈는데도 무조건 성공처럼 처리하면 화면이 이상해진다.
둘째, 사용자 데이터는 textContent로 넣는다. innerHTML은 문자열을 HTML로 해석한다. 게시글 제목에 악성 스크립트가 들어 있다면 XSS 취약점이 될 수 있다.
JSON을 보내는 POST 요청은 다음처럼 작성한다.
async function createPost(title, body) {
const response = await fetch('/api/posts.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ title, body })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error?.message ?? '등록에 실패했습니다.');
}
return result.data;
}요청 헤더의 Content-Type: application/json은 "지금 보내는 본문은 JSON이다"라는 뜻이다. 응답 헤더의 Content-Type과 요청 헤더의 Content-Type을 구분해서 이해하자.
인증이 필요한 API의 기본 방향
API에도 인증이 필요하다. 로그인한 사용자만 글을 쓰거나, 본인 글만 수정해야 하기 때문이다.
같은 도메인에서 PHP 세션을 사용하는 웹 애플리케이션이라면 기존 세션을 API에서도 사용할 수 있다.
<?php
session_start();
if (!isset($_SESSION['user_id'])) {
json_response([
'error' => [
'message' => '로그인이 필요합니다.',
],
], 401);
}브라우저는 같은 사이트로 보내는 요청에 세션 쿠키를 함께 보낸다. 따라서 HTML 페이지와 API가 같은 도메인에 있다면 세션 방식이 단순하고 이해하기 쉽다.
다만 쿠키 기반 인증 API는 CSRF를 함께 고려해야 한다. CSRF는 사용자가 로그인한 상태를 악용해 원치 않는 요청을 보내게 만드는 공격이다. 글 등록, 수정, 삭제처럼 서버 상태를 바꾸는 요청에는 CSRF 토큰을 검증하는 것이 안전하다.
모바일 앱이나 외부 서비스가 API를 호출해야 한다면 토큰 방식을 고려한다. 예를 들어 로그인 성공 후 발급받은 토큰을 다음 요청의 Authorization 헤더에 담아 보내는 방식이다.
Authorization: Bearer 토큰값토큰을 사용할 때는 다음 원칙을 지킨다.
토큰을 URL 쿼리 문자열에 넣지 않는다.
HTTPS를 사용한다.
만료 시간을 둔다.
로그 파일에 토큰이 남지 않게 한다.
권한이 없을 때는
401또는403상태 코드를 명확히 사용한다.
초기 프로젝트에서는 같은 도메인의 세션 기반 API로 시작해도 충분하다. 이후 모바일 앱이나 외부 연동이 필요해질 때 토큰 기반 인증으로 확장하면 된다.
실습 과제
/api/posts.php에page와limit쿼리 문자열을 추가해 페이지네이션을 구현해 보자./api/post.php?id=1응답에 댓글 목록을 함께 포함해 보자. 응답 구조는{ "data": { "post": ..., "comments": [...] } }처럼 설계한다.게시글 목록에서 제목을 클릭하면
fetch('/api/post.php?id=...')로 상세 내용을 가져와 화면 아래에 표시해 보자.JSON
POST요청으로 새 게시글을 등록하는 API를 만들고, 제목과 본문이 비어 있을 때422상태 코드를 반환해 보자.로그인하지 않은 사용자가 글 등록 API를 호출하면
401JSON 응답을 보내도록 수정해 보자.
자주 발생하는 오류 점검
JSON 대신 HTML이 응답된다
브라우저 콘솔에 Unexpected token < 같은 오류가 보이면 JSON을 기대했는데 HTML이 온 경우가 많다. PHP 오류 화면, 404 HTML 페이지, 로그인 페이지가 응답되었을 수 있다. 네트워크 탭에서 실제 응답 본문을 확인하자.
header() 호출 전에 출력이 있었다
header('Content-Type: application/json')를 호출하기 전에 공백, HTML, BOM, 디버깅용 echo가 출력되면 헤더를 바꿀 수 없다. API 파일에서는 응답 전에 불필요한 출력을 하지 않는다.
json_decode() 결과가 null이다
JSON 문법이 잘못되었을 수 있다. 작은따옴표를 사용했거나, 마지막 항목 뒤에 쉼표가 남아 있거나, 요청 본문이 비어 있는 경우를 확인한다. 더 자세한 원인을 보고 싶다면 json_last_error_msg()를 함께 확인한다.
204 응답에서 response.json()을 호출했다
204 No Content는 본문이 없는 응답이다. 이때 response.json()을 호출하면 파싱할 내용이 없어 오류가 날 수 있다. 204를 사용할 때는 클라이언트에서 본문을 읽지 않도록 처리한다.
CORS 오류가 발생한다
HTML 페이지와 API의 도메인, 포트, 프로토콜이 다르면 브라우저의 CORS 정책이 적용된다. 처음 학습할 때는 같은 도메인에서 실행하는 것이 가장 단순하다. 외부 도메인에서 호출해야 한다면 허용할 출처와 인증 정보 전송 여부를 명확히 설정해야 한다.
API 응답을 innerHTML로 넣었다
JSON으로 받은 데이터도 결국 사용자 입력일 수 있다. 제목, 이름, 댓글을 화면에 넣을 때 innerHTML을 사용하면 XSS 위험이 생긴다. HTML이 필요한 특별한 경우가 아니라면 textContent를 사용하자.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.