Devin.KR

PHP · 기본

웹으로 시작하는 PHP

요청과 응답 - 폼 처리와 출력 이스케이프

$_GET·$_POST·$_SERVER, 입력 검증, htmlspecialchars 로 XSS 막기, 리다이렉트와 PRG 패턴, 헤더

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장까지는 프로그램이 스스로 만든 값만 다뤘다. 웹 프로그램은 다르다. 브라우저가 보내는 값을 받아서 처리하고, 그 결과를 다시 브라우저에 돌려준다. 보내는 쪽은 손님일 수도 있고 장난으로 주소를 고쳐 쓴 사람일 수도 있다. 그래서 웹 프로그램은 들어오는 값을 믿지 않고 확인해야 하고, 나가는 값은 브라우저가 코드로 오해하지 않게 처리해야 한다. 이 장에서는 빵집 예약 폼을 만들면서 이 두 가지를 익힌다.

  • $_GET, $_POST, $_SERVER 에 어떤 값이 들어 있는지 구분하고, 없을 수도 있는 키를 안전하게 읽는다.
  • 공백 제거, 길이 검사, filter_var(), 허용 목록으로 폼 입력을 검증한다.
  • htmlspecialchars() 로 출력을 이스케이프해서 XSS 를 막는다.
  • 상태 코드, 헤더, 리다이렉트를 이용한 PRG 패턴으로 새로고침 중복 제출을 막는다.

문제 상황

빵집 사장이 예약 폼을 열었더니 다음과 같은 일이 생겼다.

  • 이름 칸을 비우고 예약해도 주문이 저장된다. 수량 칸에 abc 를 넣은 주문도 들어온다.
  • 어떤 손님이 이름 칸에 <b>민수</b> 를 적었다. 주문 목록 화면에서 이름이 굵은 글씨로 보인다. 이름 자리에 <script> 로 시작하는 코드를 넣으면 그 코드가 사장 화면에서 실행될 수 있다.
  • 예약 버튼을 누른 뒤 완료 화면에서 새로고침을 누르면 브라우저가 "양식을 다시 제출하시겠습니까"라고 묻고, 확인하면 같은 주문이 한 번 더 들어간다.

세 문제는 원인이 서로 다르다. 첫째는 입력 검증이 없어서, 둘째는 출력 이스케이프가 없어서, 셋째는 저장 요청에 대한 응답을 잘못 설계해서 생긴다. 이 장은 세 문제를 차례로 고친다.

요청을 배열로 받기

웹 브라우저와 서버는 HTTP(HyperText Transfer Protocol)라는 규칙으로 대화한다. 요청 하나에는 메서드(GET, POST 같은 동작 이름), 경로, 헤더(부가 정보), 본문이 들어 있다. 응답 하나에는 상태 코드(200, 404 같은 결과 번호), 헤더, 본문이 들어 있다. PHP 는 웹 서버가 받은 요청을 해석해서 미리 정해진 배열 세 개에 나누어 담아 준다. 이런 배열을 슈퍼글로벌(superglobal)이라 부르며, 함수 안에서도 따로 선언하지 않고 쓸 수 있다.

요청 정보는 출처에 따라 세 배열로 나뉜다
배열담기는 값주로 쓰는 곳예
$_GET주소의 ? 뒤 쿼리 문자열조회, 검색, 페이지 번호?no=1
$_POST요청 본문의 폼 데이터저장, 변경 요청name=민수
$_SERVER서버와 요청에 관한 정보메서드와 경로 확인REQUEST_METHOD

이 배열들에 대해 기억할 점이 두 가지 있다. 첫째, 값은 거의 항상 문자열이다. 수량 칸에 3 을 적어도 "3" 이라는 문자열로 온다. 또 qty[]=1 처럼 보내면 값이 배열이 되므로, 문자열이라고 가정하면 안 된다. 둘째, 키가 없을 수 있다. 폼 칸을 지우거나 직접 요청을 만들어 보내면 name 키 자체가 빠진다. 없는 키를 그냥 읽으면 경고가 나므로 ?? 연산자로 기본값을 준다.

요청은 배열에 담겨 검증과 처리를 거친 뒤 응답이 되고, 출력할 때는 이스케이프가 필요하다

이 장의 예제는 슈퍼글로벌을 직접 읽지 않고, 같은 모양의 배열을 만드는 함수 makeRequest() 를 둔다. 처리 함수는 요청 배열을 인수로 받아 응답 배열을 돌려준다. 이렇게 하면 브라우저 없이 php main.php 로 모든 경우를 확인할 수 있다. 실제 웹 서버에서는 $_SERVER, $_GET, $_POST 를 그 배열 자리에 넣기만 하면 된다.

쿼리 문자열은 parse_url() 로 주소에서 떼어 내고 parse_str() 로 배열로 바꾼다. 웹 서버가 $_GET 을 채울 때 하는 일과 같다. 예를 들어 no=1&x[]=2 는 ['no' => '1', 'x' => ['2']] 가 된다.

입력 검증과 출력 이스케이프

입력 검증: 받은 값을 믿지 않는다

검증(validation)은 값이 우리가 기대하는 모양인지 확인하는 일이다. 이 장의 폼에는 세 가지 규칙이 있다.

  • 이름: 앞뒤 공백을 trim() 으로 지운 뒤 비어 있지 않고 10자 이하여야 한다. 한글은 한 글자가 여러 바이트이므로 글자 수는 mb_strlen() 으로 센다.
  • 빵 이름: 메뉴 배열에 있는 이름이어야 한다. 이렇게 허용하는 값을 미리 정해 두고 그 안에 있는지만 보는 방식을 허용 목록(allowlist)이라 한다. 나쁜 값을 하나씩 찾아 막는 방식보다 빠뜨릴 위험이 작다.
  • 수량: 1 이상 20 이하의 정수여야 한다. filter_var() 에 FILTER_VALIDATE_INT 와 범위 옵션을 주면 정수 여부와 범위를 한 번에 확인한다. 통과하면 정수를 돌려주고, 실패하면 false 를 돌려준다.

검증에 실패하면 어느 칸이 왜 틀렸는지 오류 배열에 모은다. 하나가 틀렸다고 바로 멈추지 않고 모두 검사해야 손님이 한 번에 고칠 수 있다. 그리고 손님이 입력했던 값을 폼에 다시 채워서 보여 준다.

출력 이스케이프: 글자를 코드로 오해하지 않게 한다

HTML 에서 < 는 태그의 시작을 뜻한다. 손님이 입력한 이름을 그대로 HTML 에 끼워 넣으면 브라우저는 그 글자를 데이터가 아니라 태그로 해석한다. 이 틈을 이용해 다른 사람의 화면에서 스크립트를 실행시키는 공격을 XSS(Cross-Site Scripting)라 한다. 막는 방법은 출력 직전에 특수 문자를 안전한 표기로 바꾸는 이스케이프(escape)다. PHP 의 htmlspecialchars() 는 다음과 같이 바꾼다.

  • & 는 &amp; 로, < 는 &lt; 로, > 는 &gt; 로 바꾼다.
  • ENT_QUOTES 플래그를 주면 큰따옴표와 작은따옴표도 바꾼다. 속성값 value="…" 안에 값을 넣을 때 필요하다.
  • 세 번째 인수로 문자 인코딩 'UTF-8' 을 밝힌다. ENT_SUBSTITUTE 는 잘못된 UTF-8 바이트가 있을 때 결과 전체를 빈 문자열로 만들지 않고 대체 문자로 바꾼다.

매번 긴 인수를 쓰면 빠뜨리기 쉬우므로 예제에서는 e() 라는 짧은 함수로 감싼다. 규칙은 하나다. 사용자에게서 온 값을 HTML 에 넣을 때는 반드시 e() 를 거친다.

검증과 이스케이프는 서로 대신할 수 없다. 이름 <b>민수</b> 는 10자 이하이므로 검증을 통과한다. 이 값이 잘못된 값인지는 검증의 관심사가 아니고, 화면에 안전하게 보여 주는 일은 이스케이프의 몫이다. 반대로 이스케이프를 했다고 해서 수량이 정수라는 보장이 생기지도 않는다. 입력 쪽에서는 검증하고, 출력 쪽에서는 이스케이프한다. 자세한 인수는 htmlspecialchars 문서와 filter_var 문서에서 확인할 수 있다.

응답, 리다이렉트, PRG 패턴

상태 코드와 헤더

응답의 첫 줄에는 요청이 어떻게 처리되었는지를 나타내는 상태 코드가 온다. 그 아래에 헤더가 오고, 빈 줄 다음에 본문이 온다. 이 장에서 쓰는 코드는 다음과 같다.

이 장의 예제가 돌려주는 상태 코드
코드뜻이 장에서 쓰는 경우
200성공폼 화면, 완료 화면
303다른 주소를 GET 으로 보라주문 저장 직후 완료 화면으로 보낼 때
404찾을 수 없음없는 경로, 없는 주문 번호
405허용하지 않는 메서드/order 에 PUT 을 보낸 경우
422내용을 처리할 수 없음검증 실패로 폼을 다시 보여 줄 때

PHP 에서 상태 코드는 http_response_code(303), 헤더는 header('Location: /order/done') 으로 정한다. 이 함수들은 본문을 한 글자라도 출력하기 전에 불러야 한다. 헤더는 본문보다 먼저 전송되기 때문이다. 405 응답에는 허용하는 메서드를 알리는 Allow 헤더를 함께 보낸다. 헤더에 줄바꿈이 섞인 값을 넣으면 헤더를 위조할 수 있어서 PHP 의 header() 는 그런 값을 거부한다. 그래도 리다이렉트 주소에 사용자 입력을 그대로 붙이는 습관은 피한다.

PRG 패턴

폼을 POST 로 받아 저장한 뒤 곧바로 완료 화면 HTML 을 돌려주면, 브라우저의 현재 페이지는 "POST 요청의 결과"가 된다. 여기서 새로고침하면 브라우저는 같은 POST 를 다시 보내고, 주문이 중복 저장된다. 해결책이 PRG(Post/Redirect/Get) 패턴이다.

  1. POST 요청을 받아 검증하고 저장한다.
  2. 화면을 그리지 않고 303 상태 코드와 Location 헤더만 돌려준다.
  3. 브라우저가 Location 주소를 GET 으로 다시 요청하고, 서버는 완료 화면을 보여 준다.
POST 는 303 응답으로 GET 요청으로 바뀌므로 새로고침해도 마지막 GET 만 다시 보내진다

이제 브라우저 주소창에 남는 요청은 GET 이다. 새로고침해도 완료 화면을 다시 볼 뿐 저장은 일어나지 않는다. 검증에 실패했을 때는 저장하지 않았으므로 리다이렉트하지 않고 422 로 폼을 바로 다시 그린다. 리다이렉트 뒤에는 나머지 코드가 계속 실행되지 않도록 exit 로 끝내는 것이 관례다. 이 장의 예제는 응답을 반환값으로 돌려주므로 return 이 그 역할을 한다.

완성 코드

파일은 main.php 하나다. 명령줄(cli)에서는 요청 여섯 개를 흉내 내어 결과를 출력하고, php -S 로 띄운 서버(cli-server)에서는 실제 요청을 같은 처리 함수로 보낸다.

<?php
declare(strict_types=1);

const MENU = [
    '소보로빵' => 2500,
    '단팥빵' => 2000,
    '식빵' => 4500,
];

function makeRequest(string $method, string $uri, array $post = []): array
{
    $query = parse_url($uri, PHP_URL_QUERY);
    $get = [];
    if (is_string($query)) {
        parse_str($query, $get);
    }
    return [
        'server' => ['REQUEST_METHOD' => $method, 'REQUEST_URI' => $uri],
        'get' => $get,
        'post' => $post,
    ];
}

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

function response(int $status, array $headers, string $body): array
{
    if ($body === '') {
        return ['status' => $status, 'headers' => $headers, 'body' => $body];
    }
    $headers += ['Content-Type' => 'text/html; charset=UTF-8'];
    return ['status' => $status, 'headers' => $headers, 'body' => $body];
}

function fieldText(array $input, string $key): string
{
    $value = $input[$key] ?? '';
    return is_string($value) ? trim($value) : '';
}

function validateOrder(array $input): array
{
    $errors = [];

    $name = fieldText($input, 'name');
    if ($name === '') {
        $errors['name'] = '이름을 입력하세요.';
    } elseif (mb_strlen($name) > 10) {
        $errors['name'] = '이름은 10자 이하로 입력하세요.';
    }

    $item = fieldText($input, 'item');
    if (array_key_exists($item, MENU) === false) {
        $errors['item'] = '메뉴에서 빵을 고르세요.';
    }

    $qty = fieldText($input, 'qty');
    $options = ['options' => ['min_range' => 1, 'max_range' => 20]];
    if (filter_var($qty, FILTER_VALIDATE_INT, $options) === false) {
        $errors['qty'] = '수량은 1 이상 20 이하의 정수여야 한다.';
    }

    return [
        'errors' => $errors,
        'values' => ['name' => $name, 'item' => $item, 'qty' => $qty],
    ];
}

function fieldLine(string $label, string $name, string $value, array $errors): string
{
    $line = '  <p>' . e($label) . ' <input name="' . $name . '" value="' . e($value) . '">';
    if (isset($errors[$name])) {
        $line .= ' <strong>' . e($errors[$name]) . '</strong>';
    }
    return $line . "</p>\n";
}

function renderForm(array $values, array $errors): string
{
    return "<form method=\"post\" action=\"/order\">\n"
        . fieldLine('이름', 'name', $values['name'] ?? '', $errors)
        . fieldLine('빵', 'item', $values['item'] ?? '', $errors)
        . fieldLine('수량', 'qty', $values['qty'] ?? '', $errors)
        . "  <button>예약</button>\n</form>\n";
}

function handleOrder(string $method, array $post, array &$orders): array
{
    if ($method === 'GET') {
        return response(200, [], renderForm([], []));
    }
    if ($method === 'POST') {
        $result = validateOrder($post);
        if (count($result['errors']) > 0) {
            return response(422, [], renderForm($result['values'], $result['errors']));
        }
        $orders[] = [
            'name' => $result['values']['name'],
            'item' => $result['values']['item'],
            'qty' => (int) $result['values']['qty'],
        ];
        return response(303, ['Location' => '/order/done?no=' . count($orders)], '');
    }
    return response(405, ['Allow' => 'GET, POST'], "허용하지 않는 메서드다.\n");
}

function handleDone(array $get, array $orders): array
{
    $no = filter_var($get['no'] ?? '', FILTER_VALIDATE_INT);
    if (is_int($no) && $no >= 1 && isset($orders[$no - 1])) {
        $order = $orders[$no - 1];
        $total = MENU[$order['item']] * $order['qty'];
        $line = e($order['name']) . '님, ' . e($order['item']) . ' ' . $order['qty']
            . '개 예약 완료. 합계 ' . number_format($total) . '원';
        return response(200, [], "<p>{$line}</p>\n");
    }
    return response(404, [], "주문을 찾을 수 없다.\n");
}

function handle(array $req, array &$orders): array
{
    $method = $req['server']['REQUEST_METHOD'];
    $path = parse_url($req['server']['REQUEST_URI'], PHP_URL_PATH);

    if ($path === '/order') {
        return handleOrder($method, $req['post'], $orders);
    }
    if ($path === '/order/done' && $method === 'GET') {
        return handleDone($req['get'], $orders);
    }
    return response(404, [], "페이지가 없다.\n");
}

function show(string $title, array $res): void
{
    echo "== {$title}\n";
    echo 'HTTP ' . $res['status'] . "\n";
    foreach ($res['headers'] as $name => $value) {
        echo "{$name}: {$value}\n";
    }
    $body = $res['body'] === '' ? '' : "\n" . $res['body'];
    echo $body . "\n";
}

function runDemo(): void
{
    $orders = [];
    echo '쿼리 해석: ' . json_encode(makeRequest('GET', '/order/done?no=1&x[]=2')['get']) . "\n\n";

    show('1. GET /order', handle(makeRequest('GET', '/order'), $orders));

    $bad = ['name' => ' ', 'item' => '초코빵', 'qty' => '0'];
    show('2. POST /order (잘못된 입력)', handle(makeRequest('POST', '/order', $bad), $orders));

    $good = ['name' => '<b>민수</b>', 'item' => '소보로빵', 'qty' => '3'];
    $res = handle(makeRequest('POST', '/order', $good), $orders);
    show('3. POST /order (올바른 입력)', $res);

    $location = $res['headers']['Location'];
    show("4. 리다이렉트를 따라감 GET {$location}", handle(makeRequest('GET', $location), $orders));

    show('5. PUT /order', handle(makeRequest('PUT', '/order'), $orders));
    show('6. GET /order/done?no=9', handle(makeRequest('GET', '/order/done?no=9'), $orders));
}

function runWeb(): void
{
    $file = sys_get_temp_dir() . '/bakery-orders.json';
    $orders = is_file($file)
        ? json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR)
        : [];

    $req = ['server' => $_SERVER, 'get' => $_GET, 'post' => $_POST];
    $res = handle($req, $orders);
    file_put_contents($file, json_encode($orders, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));

    http_response_code($res['status']);
    foreach ($res['headers'] as $name => $value) {
        header("{$name}: {$value}");
    }
    echo $res['body'];
}

if (PHP_SAPI === 'cli-server') {
    runWeb();
} else {
    runDemo();
}

줄별 해설

  • const MENU: 메뉴 이름과 가격이다. 빵 이름 검증의 허용 목록이자 합계 계산의 가격표로 쓴다.
  • makeRequest(): 메서드, 주소, POST 본문을 받아 server, get, post 세 배열을 가진 요청 배열을 만든다. parse_url() 의 PHP_URL_QUERY 는 쿼리가 없으면 null 을 돌려주므로 is_string() 으로 확인하고, 있을 때만 parse_str() 로 $get 에 채운다.
  • e(): htmlspecialchars() 를 고정된 인수로 감싼 함수다. 출력하는 모든 사용자 값이 이 함수를 지난다.
  • response(): 상태, 헤더, 본문을 응답 배열로 묶는다. 본문이 있으면 += 로 Content-Type 을 덧붙인다. 배열 + 는 이미 있는 키를 덮어쓰지 않으므로 호출한 쪽이 지정한 헤더가 우선한다.
  • fieldText(): 입력 배열에서 키를 읽어 문자열일 때만 trim() 해서 돌려주고, 키가 없거나 배열이면 빈 문자열로 처리한다. 뒤의 검증 코드가 없는 키와 배열 값을 걱정하지 않아도 된다.
  • validateOrder(): 이름은 빈 값과 길이를, 빵은 array_key_exists() 로 메뉴 포함 여부를, 수량은 filter_var() 의 정수 범위 옵션을 검사한다. 오류는 칸 이름을 키로 $errors 에 모은다. 폼에 다시 채울 값은 문자열 그대로 values 에 담아 돌려준다.
  • fieldLine(): 입력 칸 한 줄을 만든다. 라벨과 값은 e() 로 이스케이프한다. $name 은 코드에 적은 상수 문자열이라 이스케이프하지 않았다. 사용자 입력이 이 자리에 들어오게 바꾸면 이스케이프가 필요하다.
  • renderForm(): 세 칸과 버튼을 이어 붙여 폼 HTML 을 만든다. 처음 폼은 빈 배열 두 개로, 실패한 폼은 입력값과 오류 배열로 그린다.
  • handleOrder(): /order 를 메서드별로 나눈다. GET 이면 빈 폼(200), POST 이면 검증한다. 오류가 있으면 422 로 폼을 다시 그리고, 없으면 주문을 저장한 뒤 본문 없이 303 과 Location 만 돌려준다. 이것이 PRG 의 R 이다. 그 밖의 메서드는 Allow 헤더와 함께 405 를 돌려준다. array &$orders 의 & 는 참조 전달이다. 함수 안에서 $orders[] = … 로 추가한 내용이 호출한 쪽 배열에도 반영된다.
  • handleDone(): 쿼리의 no 를 정수로 검증한다. 정수이고 1 이상이며 실제로 있는 주문일 때만 완료 화면을 만든다. 이름과 빵 이름은 e() 를 거치고 수량과 합계는 숫자라 그대로 붙인다. 하나라도 어긋나면 404 다.
  • handle(): $_SERVER 에 해당하는 배열에서 메서드와 경로를 꺼내 경로별 함수로 나눈다. 어느 경로에도 맞지 않으면 404 다. 경로는 parse_url() 로 쿼리를 뗀 뒤 비교하므로 /order?x=1 도 /order 로 처리된다.
  • show(): 응답 배열을 사람이 읽는 형태로 출력한다. 상태 줄, 헤더, 빈 줄, 본문 순서다. 실제 HTTP 응답과 같은 순서라서 흐름을 눈으로 따라가기 좋다.
  • runDemo(): 여섯 가지 요청을 차례로 보낸다. 3번 응답에서 Location 값을 꺼내 4번 요청으로 다시 보내는 부분이 브라우저의 리다이렉트 따라가기를 흉내 낸 것이다.
  • runWeb(): 실제 서버용이다. $_SERVER, $_GET, $_POST 로 요청 배열을 만들고 같은 handle() 을 부른다. 서버는 요청마다 새로 시작하므로 주문은 임시 폴더의 JSON 파일에 보관한다. 그다음 http_response_code() 와 header() 로 상태와 헤더를 보내고 마지막에 본문을 출력한다.
  • 마지막 if: PHP_SAPI 는 PHP 가 어떤 방식으로 실행되었는지 알려 주는 상수다. php main.php 는 cli, php -S 는 cli-server 다.

실행 결과

$ php main.php
쿼리 해석: {"no":"1","x":["2"]}

== 1. GET /order
HTTP 200
Content-Type: text/html; charset=UTF-8

<form method="post" action="/order">
  <p>이름 <input name="name" value=""></p>
  <p>빵 <input name="item" value=""></p>
  <p>수량 <input name="qty" value=""></p>
  <button>예약</button>
</form>

== 2. POST /order (잘못된 입력)
HTTP 422
Content-Type: text/html; charset=UTF-8

<form method="post" action="/order">
  <p>이름 <input name="name" value=""> <strong>이름을 입력하세요.</strong></p>
  <p>빵 <input name="item" value="초코빵"> <strong>메뉴에서 빵을 고르세요.</strong></p>
  <p>수량 <input name="qty" value="0"> <strong>수량은 1 이상 20 이하의 정수여야 한다.</strong></p>
  <button>예약</button>
</form>

== 3. POST /order (올바른 입력)
HTTP 303
Location: /order/done?no=1

== 4. 리다이렉트를 따라감 GET /order/done?no=1
HTTP 200
Content-Type: text/html; charset=UTF-8

<p>&lt;b&gt;민수&lt;/b&gt;님, 소보로빵 3개 예약 완료. 합계 7,500원</p>

== 5. PUT /order
HTTP 405
Allow: GET, POST
Content-Type: text/html; charset=UTF-8

허용하지 않는 메서드다.

== 6. GET /order/done?no=9
HTTP 404
Content-Type: text/html; charset=UTF-8

주문을 찾을 수 없다.

2번에서는 세 칸이 모두 틀려 오류 세 개가 함께 나오고, 손님이 쓴 값은 그대로 다시 채워진다. 3번은 본문이 없고 Location 헤더만 있다. 4번에서는 이름의 <b> 가 &lt;b&gt; 로 바뀌어 있다. 브라우저는 이 표기를 화면에 글자 <b> 로 보여 주므로 굵은 글씨가 되지 않는다.

브라우저에서 확인하기

같은 파일을 내장 웹 서버로 띄우면 실제 브라우저에서 확인할 수 있다.

$ php -S localhost:8000 main.php

브라우저에서 http://localhost:8000/order 를 연다. 이 서버는 개발용이며 운영 서비스에는 쓰지 않는다. 폼을 채워 예약하면 주소가 /order/done?no=1 로 바뀌는 것을 볼 수 있고, 여기서 새로고침해도 재제출 경고는 나오지 않는다. 개발자 도구의 네트워크 탭에서는 303 응답과 Location 헤더를 확인할 수 있다. 주문은 임시 폴더의 bakery-orders.json 에 쌓인다. 번호를 처음부터 다시 세고 싶으면 그 파일을 지운다. 서버는 터미널에서 Ctrl+C 로 끈다.

실무에서 자주 틀리는 것

1. 사용자 값을 이스케이프 없이 출력한다

검색어나 이름을 화면에 다시 보여 줄 때 가장 흔한 실수다.

// 틀린 코드
echo '<p>검색어: ' . $q . '</p>';

// 고친 코드
echo '<p>검색어: ' . e($q) . '</p>';

$q 에 <script> 로 시작하는 문자열이 오면 틀린 코드에서는 스크립트가 실행된다. 고친 코드에서는 글자로만 보인다. 데이터베이스에서 꺼낸 값도 원래 사용자가 넣은 값이므로 똑같이 이스케이프한다.

2. 없는 키를 그냥 읽는다

// 틀린 코드
$name = trim($_POST['name']);

// 고친 코드
$value = $_POST['name'] ?? '';
$name = is_string($value) ? trim($value) : '';

폼을 거치지 않고 직접 보낸 요청에는 name 키가 없을 수 있다. 틀린 코드는 Undefined array key 경고를 내고, 값이 null 인 채로 trim() 에 들어가 타입 오류로 이어진다. 값이 배열로 오는 경우까지 is_string() 으로 막는다.

3. 저장한 뒤 바로 화면을 그린다

// 틀린 코드: 저장 뒤 같은 POST 응답으로 완료 화면을 그린다
$orders[] = $order;
echo '<p>예약되었습니다.</p>';

// 고친 코드: 303 으로 GET 화면에 보낸다
$orders[] = $order;
http_response_code(303);
header('Location: /order/done?no=' . count($orders));
exit;

틀린 코드는 완료 화면에서 새로고침할 때마다 주문이 늘어난다. 고친 코드는 완료 화면 요청이 GET 이라 저장과 무관하다.

4. 본문을 출력한 뒤 헤더를 보낸다

// 틀린 코드
echo '처리 중...';
header('Location: /order/done?no=1');

// 고친 코드
header('Location: /order/done?no=1');
exit;

틀린 코드는 headers already sent 경고가 나고 리다이렉트가 일어나지 않는다. 본문이 나가기 시작하면 헤더는 이미 전송되었기 때문이다. 파일 맨 앞의 <?php 이전에 있는 빈 줄이나 공백도 본문으로 나간다. 또 exit 가 없으면 리다이렉트 헤더를 보낸 뒤에도 아래 코드가 계속 실행된다.

한눈에 보기

이 장에서 다룬 도구와 기억할 점
주제쓰는 것기억할 점
요청 읽기$_GET, $_POST, $_SERVER값은 문자열이거나 배열이고, 키가 없을 수 있다. ?? 로 읽는다.
입력 검증trim(), mb_strlen(), filter_var(), 허용 목록모든 칸을 검사해 오류를 모으고, 입력값은 폼에 다시 채운다.
출력 이스케이프htmlspecialchars()HTML 에 사용자 값을 넣을 때마다 ENT_QUOTES 와 'UTF-8' 을 준다.
응답 만들기http_response_code(), header()본문보다 먼저 호출한다.
중복 제출 방지PRG: 303 + Location저장 성공 뒤에는 화면을 그리지 않고 GET 주소로 보낸다.
확인 방법php main.php, php -S요청 배열로 CLI 에서 검증하고, 서버로 브라우저에서 본다.

연습 문제

  1. $_GET 에서 페이지 번호를 읽는 함수 pageNumber(array $get): int 를 만들어라. page 가 1 이상의 정수 문자열이면 그 정수를, 키가 없거나 정수가 아니거나 1 미만이면 1 을 돌려준다.
  2. e('<a href="x">Tom & \'Jerry\'</a>') 의 결과를 예측해서 써라. 이 장의 e() 를 쓴다.
  3. 다음 처리기는 주문을 저장한 뒤 곧바로 <p>완료</p> 를 200 으로 돌려준다. 이 방식의 문제와, 고쳤을 때 응답의 상태 코드와 헤더를 설명하라.
  4. 완료 화면 주소에서 no 가 1abc, -1, 1.0, no[]=1 일 때 이 장의 handleDone() 이 각각 어떻게 응답하는지, 그 이유와 함께 답하라.

정답과 해설

  1. function pageNumber(array $get): int
    {
        $options = ['options' => ['min_range' => 1]];
        $page = filter_var($get['page'] ?? '', FILTER_VALIDATE_INT, $options);
        return is_int($page) ? $page : 1;
    }

    filter_var() 는 실패하면 false 를 돌려주므로 is_int() 로 성공 여부를 가른다. 키가 없으면 빈 문자열이 되어 실패하고, 배열이 와도 실패하므로 모두 1 이 된다. min_range 옵션이 1 미만을 실패로 처리한다.

  2. 결과는 &lt;a href=&quot;x&quot;&gt;Tom &amp; &#039;Jerry&#039;&lt;/a&gt; 이다. 꺾쇠, 큰따옴표, &, 작은따옴표가 모두 표기로 바뀐다. ENT_QUOTES 를 준 덕분에 작은따옴표도 &#039; 로 바뀐다. 이 플래그가 없으면 작은따옴표는 그대로 남아 작은따옴표로 감싼 속성값 안에서 문제가 된다.

  3. 저장 요청(POST)의 응답이 곧 현재 페이지가 되므로 새로고침하면 브라우저가 POST 를 다시 보내 주문이 중복된다. 고치려면 저장 뒤 상태 코드 303 과 Location: /order/done?no=번호 헤더만 돌려주고 본문은 비운다. 브라우저가 그 주소를 GET 으로 요청하고, 그 요청에 200 과 <p>완료</p> 를 돌려준다. PHP 코드로는 http_response_code(303); header('Location: …'); exit; 다.

  4. 네 경우 모두 404 다. 1abc 와 1.0 은 정수 형식이 아니어서 filter_var() 가 false 를 돌려준다. -1 은 정수로 통과하지만 $no >= 1 조건에서 걸린다. no[]=1 은 값이 배열이라 filter_var() 가 false 를 돌려준다. 이런 검사가 없으면 $orders[-2] 처럼 엉뚱한 위치를 읽거나 배열을 문자열로 다루다 오류가 난다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.