API 응답을 받거나 외부 데이터를 처리할 때 JSON을 파싱하는 작업은 일상이다. 하지만 대부분의 개발자들은 json_decode를 쓸 때 기본 형태만 알고, 잘못된 JSON이나 보안 문제까지 제대로 대응하지 못한 채 진행한다. 특히 json_last_error()를 확인하지 않아서 조용히 실패하거나, 두 번째 인자를 모르는 경우가 많다. 이번에는 json_decode의 정확한 동작 원리부터 시작해서 실제 프로덕션 코드에서 바로 쓸 수 있는 안전한 파싱 방법까지 완벽하게 정리하겠다.
JSON은 단순해 보이지만 PHP에서 다룰 때 몇 가지 함정이 있다. json_decode() 함수는 JSON 문자열을 PHP 객체 또는 배열로 변환하는데, 기본값과 옵션을 제대로 알아야 예상치 못한 문제를 피할 수 있다.
먼저 기본 형태를 보자. json_decode(string, assoc, depth, flags) 함수는 4개의 인자를 받는다. 첫 번째는 JSON 문자열, 두 번째 assoc 인자는 true로 설정하면 객체 대신 배열을 반환하고, 세 번째 depth는 중첩 깊이 제한(기본 512), 네 번째 flags는 JSON_INVALID_UTF8_IGNORE 같은 옵션들이다. 대부분의 개발자는 첫 번째 인자만 주고 넘어가는데, 이게 원인이 되어 예상과 다른 결과를 얻곤 한다.
$json = '{"name":"John","age":30}';
$data = json_decode($json);
echo $data->name; // 잘 작동하는 것 같지만...
$invalid_json = '{"name":"John"invalid}';
$data = json_decode($invalid_json);
echo $data->name; // NULL을 출력하거나 에러가 발생할 수 있음
// 하지만 json_last_error()를 확인하지 않으면 뭐가 잘못됐는지 모름
✓ 올바른 코드 — 에러를 확인하고 안전하게 처리
$json = '{"name":"John","age":30}';
$data = json_decode($json, true); // true로 설정해서 배열 반환
if (json_last_error() !== JSON_ERROR_NONE) {
echo "JSON 파싱 에러: " . json_last_error_msg();
exit;
}
echo $data['name']; // 배열로 안전하게 접근
결과: 첫 번째 코드는 NULL을 반환해도 왜인지 알 수 없지만, 두 번째는 json_last_error_msg()로 정확한 에러 메시지를 받을 수 있다. 예를 들어 "Syntax error, malformed JSON"처럼.
실제 프로덕션 환경에서는 외부 API 응답이나 사용자가 보낸 JSON을 처리해야 한다. 단순히 json_decode만으로는 부족하고, 데이터 검증과 에러 처리까지 함께 가야 한다.
function safe_json_decode($json_string, $assoc = true) {
if (!is_string($json_string)) {
return null;
}
$data = json_decode($json_string, $assoc);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("JSON Parse Error: " . json_last_error_msg());
return null;
}
return $data;
}
// 사용
$api_response = '{"status":"success","data":{"id":1}}';
$result = safe_json_decode($api_response);
if ($result === null) {
echo "JSON 파싱 실패";
} else {
echo $result['status']; // "success"
}
function parse_json_with_schema($json_string, $required_keys = []) {
if (!is_string($json_string) || empty($json_string)) {
return ['error' => 'JSON string is empty or invalid'];
}
$data = json_decode($json_string, true);
// 1단계: JSON 파싱 에러 확인
if (json_last_error() !== JSON_ERROR_NONE) {
return ['error' => 'JSON syntax error: ' . json_last_error_msg()];
}
// 2단계: 데이터 타입 확인
if (!is_array($data)) {
return ['error' => 'JSON must be an object or array'];
}
// 3단계: 필수 키 확인
foreach ($required_keys as $key) {
if (!array_key_exists($key, $data)) {
return ['error' => "Missing required key: $key"];
}
}
return ['success' => true, 'data' => $data];
}
// 사용
$webhook_data = file_get_contents('php://input');
$result = parse_json_with_schema($webhook_data, ['event', 'timestamp', 'user_id']);
if (isset($result['error'])) {
http_response_code(400);
echo json_encode(['error' => $result['error']]);
} else {
$data = $result['data'];
// 안전하게 처리
}
function validate_json_structure($data, $schema) {
foreach ($schema as $key => $type) {
if (!array_key_exists($key, $data)) {
return false;
}
if (is_array($type)) {
// 재귀적으로 중첩된 배열 검증
if (!is_array($data[$key])) {
return false;
}
if (!validate_json_structure($data[$key], $type)) {
return false;
}
} else if ($type !== 'any' && gettype($data[$key]) !== $type) {
return false;
}
}
return true;
}
$json_string = '{"user":{"id":123,"name":"John"},"status":"active"}';
$data = json_decode($json_string, true);
$schema = [
'user' => [
'id' => 'integer',
'name' => 'string'
],
'status' => 'string'
];
if (validate_json_structure($data, $schema)) {
echo "데이터 구조 검증 완료";
} else {
echo "데이터 구조 불일치";
}
| 실수 | 문제점 | 해결법 |
|---|---|---|
| assoc 인자 생략 | stdClass 객체 반환 → 배열처럼 접근 불가 | json_decode($json, true) 항상 사용 |
| json_last_error() 미확인 | 에러 발생해도 조용히 NULL 반환 | json_last_error() !== JSON_ERROR_NONE 체크 |
| 깊이 제한 무시 | 매우 깊은 중첩 구조 파싱 실패 | depth 인자 명시적으로 설정 (기본값 512) |
| UTF-8 문자 오류 | 이모지나 특수문자 포함 시 파싱 실패 | JSON_INVALID_UTF8_IGNORE 플래그 사용 |
| 사용자 입력 직접 파싱 | 악의적인 JSON으로 보안 위험 | 스키마 검증 + 입력값 크기 제한 |
외부에서 받은 JSON을 파싱할 때는 반드시 다음을 확인해야 한다. 첫째, JSON 크기 제한을 두자. 극단적으로 큰 JSON을 받으면 메모리 오버플로우가 발생할 수 있다. 둘째, 필수 필드를 검증하자. 스키마 검증 없이 json_decode만 하면 누락된 필드를 제대로 감지할 수 없다. 셋째, 데이터 타입을 확인하자. is_array(), is_string() 같은 함수로 예상한 타입인지 검증해야 한다. 넷째, 깊이 제한을 설정하자. 악의적으로 깊게 중첩된 JSON으로 공격받을 수 있다.
// 보안이 강화된 JSON 파싱
$max_size = 1 * 1024 * 1024; // 1MB 제한
$json_string = file_get_contents('php://input');
if (strlen($json_string) > $max_size) {
http_response_code(413);
echo json_encode(['error' => 'JSON payload too large']);
exit;
}
$data = json_decode($json_string, true, 10); // 깊이 10으로 제한
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo json_encode(['error' => json_last_error_msg()]);
exit;
}
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['error' => 'JSON must be an object']);
exit;
}
// 필수 필드 검증
$required = ['action', 'timestamp'];
foreach ($required as $field) {
if (!isset($data[$field])) {
http_response_code(400);
echo json_encode(['error' => "Missing field: $field"]);
exit;
}
}
// 이제 안전하게 처리 가능
process_data($data);
JSON 파싱은 간단해 보이지만, 에러 처리와 검증 없이는 디버깅이 어렵고 보안도 취약해진다. json_decode()의 assoc 인자를 항상 true로 설정하고, json_last_error()를 필수로 확인하는 습관이 모여서 안정적인 서버를 만든다는 점을 잊지 말자. 이 글의 "안전 파싱 함수"와 "스키마 검증" 부분을 참고해 지금 운영 중인 코드에 적용하면, 예상치 못한 JSON 파싱 오류로 인한 장애를 크게 줄일 수 있을 것이다.