AJAX는 페이지 전체를 다시 불러오지 않고 JavaScript가 서버와 데이터를 주고받아 화면 일부를 갱신하는 방식이다. 이름에는 XML이 들어가지만 현재는 JSON을 많이 사용하며, 브라우저에서는 XMLHttpRequest 대신 Promise 기반의 Fetch API로 구현할 수 있다.
핵심은 요청 한 줄보다 HTTP 상태, 응답 형식, 화면 출력의 안전성, same-origin 경계를 함께 처리하는 데 있다.
서버는 JSON이라는 사실을 HTTP로 알려야 한다
PHP가 배열을 JSON으로 반환하는 가장 작은 예는 다음과 같다.
<?php
declare(strict_types=1);
header('Content-Type: application/json');
try {
$items = [
['id' => 1, 'name' => '첫 번째 항목'],
['id' => 2, 'name' => '두 번째 항목'],
];
echo json_encode(
$items,
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
} catch (JsonException $exception) {
http_response_code(500);
echo '{"error":"encoding_failed"}';
}
Content-Type: application/json은 응답 본문이 JSON임을 클라이언트에 알린다. RFC 8259에 따르면 네트워크에서 교환하는 JSON 텍스트는 UTF-8로 인코딩해야 한다.
JSON_THROW_ON_ERROR를 사용하면 인코딩 실패를 조용히 false로 넘기지 않고 JsonException으로 처리할 수 있다. 운영 코드에서는 예외의 상세 메시지나 stack trace를 응답에 그대로 내보내지 말고 서버 로그와 사용자용 오류를 분리한다.
데이터베이스를 사용한다면 SQL 인젝션 방지를 위해 prepared statement를 사용하고, 조회 권한과 반환 필드를 서버에서 제한해야 한다. JSON으로 바꿨다는 사실 자체가 입력 검증이나 접근 제어를 제공하지는 않는다.
fetch는 HTTP 404와 500에서 자동으로 reject되지 않는다
Fetch Promise는 DNS 실패, 연결 실패, CORS 차단 같은 네트워크 계열 오류에서는 reject될 수 있다. 하지만 서버가 404 Not Found나 500 Internal Server Error 응답을 보냈다면 보통 Response로 이행된다. 따라서 response.ok를 직접 확인해야 한다.
<button id="load-items" type="button">목록 불러오기</button>
<p id="status" role="status"></p>
<ul id="item-list"></ul>
const button = document.querySelector('#load-items');
const status = document.querySelector('#status');
const list = document.querySelector('#item-list');
button.addEventListener('click', loadItems);
async function loadItems() {
button.disabled = true;
status.textContent = '불러오는 중입니다.';
try {
const response = await fetch('/api/items.php', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('application/json')) {
throw new Error('JSON 응답이 아닙니다.');
}
const items = await response.json();
if (!Array.isArray(items)) {
throw new Error('예상한 응답 구조가 아닙니다.');
}
renderItems(items);
status.textContent = `${items.length}개를 불러왔습니다.`;
} catch (error) {
console.error(error);
list.replaceChildren();
status.textContent = '목록을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.';
} finally {
button.disabled = false;
}
}
오류 메시지에 내부 URL, SQL, 토큰 같은 정보를 붙이지 않는다. 브라우저 console도 운영 환경에서 누가 볼 수 있는지 고려해 로그 수준을 조정한다.
응답 데이터는 innerHTML보다 DOM API로 출력한다
서버 응답을 문자열 템플릿으로 만들어 innerHTML에 바로 넣으면 데이터 안의 HTML이 코드로 해석될 수 있다. 신뢰할 수 없는 값은 textContent로 넣는 편이 안전하다.
function renderItems(items) {
const fragment = document.createDocumentFragment();
for (const item of items) {
const li = document.createElement('li');
li.textContent = String(item.name ?? '이름 없음');
fragment.append(li);
}
list.replaceChildren(fragment);
}
이 코드는 항목 이름을 HTML로 해석하지 않고 텍스트로 표시한다. 정말 HTML을 받아 렌더링해야 한다면 허용할 요소와 속성을 정한 검증된 sanitizer를 사용하고, 서버 측 출력 인코딩과 Content Security Policy도 함께 검토해야 한다.
요청과 응답의 계약을 먼저 정한다
화면 코드와 PHP 코드가 우연히 같은 필드명을 쓰는 상태로 두면 변경에 취약하다. 성공과 실패의 HTTP 상태, JSON 구조와 각 필드의 타입을 먼저 정한다.
{
"items": [
{ "id": 1, "name": "첫 번째 항목" }
],
"nextCursor": null
}
목록이 커지면 배열만 반환하기보다 페이지네이션 정보와 함께 감싸는 구조가 확장하기 쉽다. 오류도 200 OK 안에 성공 여부를 숨기기보다 의미에 맞는 HTTP 상태와 안정적인 오류 코드를 조합한다.
다만 상태 코드 하나로 사용자 메시지를 정할 수는 없다. 401 뒤 재인증, 429의 재시도 정책, 5xx의 일시 장애처럼 클라이언트가 해야 할 행동을 API 계약에 함께 남긴다.
HTTP 요청과 응답의 기본 구조는 HTTP 프로토콜 정리, REST·메시지 큐 등 통신 방식을 고르는 기준은 API 통신 방식 선택에서 이어서 볼 수 있다.
same-origin과 CORS는 인증 기능이 아니다
같은 origin의 상대 URL을 호출하면 배포 환경에 맞추기 쉽다. 다른 origin을 호출할 때는 서버가 CORS 응답 헤더로 브라우저에 공유를 허용해야 한다.
Fetch의 credentials 기본 모드는 same-origin이다. cross-origin 요청에 쿠키를 포함하려고 credentials: 'include'를 사용하면 서버도 특정 origin과 credential 허용을 명시해야 하며, Access-Control-Allow-Origin: *와 함께 쓸 수 없다.
CORS는 브라우저가 응답을 JavaScript에 공개할지 정하는 프로토콜이지 사용자 인증이나 요청 권한 검사가 아니다. 쿠키 기반으로 상태를 바꾸는 요청은 CSRF 방어도 별도로 필요하다. 서버는 origin 허용 여부와 관계없이 매 요청의 인증·인가를 검사해야 한다.
운영 전 확인할 항목
- 성공 응답과
4xx,5xx, 연결 실패를 각각 처리했는가? - JSON이 아닌 오류 페이지가 와도 사용자 화면이 깨지지 않는가?
- 버튼 연타와 중복 요청에 대한 정책이 있는가?
- 빈 목록, 필드 누락, 큰 목록을 처리하는가?
- 응답 값을
innerHTML에 그대로 넣지 않는가? - 인증·인가, CSRF와 CORS의 역할을 구분했는가?
- 서버 오류의 내부 정보를 사용자에게 노출하지 않는가?
위 예제는 Fetch와 PHP 사이의 기본 경계를 보여 주는 코드다. 실제 서버와 데이터베이스에서 실행·배포한 결과가 아니므로, 사용 중인 PHP 버전과 웹 서버 설정, 인증 방식에 맞춰 통합 테스트해야 한다.
참고 자료
'배움과 성장 > 소프트웨어 개발' 카테고리의 다른 글
| Assertion은 운영에서 꺼야 할까: Java·Python·TypeScript·Go의 실제 차이 (0) | 2024.08.28 |
|---|---|
| 비트 연산과 2의 보수: 고정 비트폭·마스크·Python 음수 표현 (0) | 2024.08.16 |
| 디자인 패턴 고르는 법: Strategy·Factory·Decorator·Observer의 변화 지점 (0) | 2024.08.13 |
| 응집도와 결합도: 변경 비용으로 판단하는 모듈 설계 (0) | 2024.08.13 |
| 동기·비동기와 블로킹·논블로킹 차이: 통신과 코드에서 헷갈리지 않기 (0) | 2024.08.11 |
댓글