[태그:] HTTP

  • PHP cURL로 GET·POST JSON 요청 보내기: 오류 처리와 타임아웃까지

    PHP에서 외부 API를 호출할 때 가장 많이 사용하는 방법 중 하나가 cURL입니다.

    cURL을 사용하면 다음과 같은 HTTP 요청을 처리할 수 있습니다.

    • GET 요청과 쿼리스트링 생성
    • 일반 form POST 요청
    • JSON POST 요청
    • 요청 헤더 설정
    • Bearer Token 인증
    • 연결 및 응답 타임아웃 설정
    • HTTP 상태 코드 확인
    • 네트워크 오류 처리
    • JSON 응답 파싱
    • SSL 인증서 검증

    단순히 curl_exec()만 호출하면 정상 응답과 오류 상황을 구분하기 어렵습니다.

    운영 환경에서는 최소한 다음 세 가지를 구분해야 합니다.

    1. 네트워크 또는 cURL 실행 오류
    2. HTTP 4xx·5xx 응답
    3. 정상 응답이지만 JSON 형식이 잘못된 경우

    이 글에서는 PHP 8 이상을 기준으로 GET, form POST, JSON POST 요청을 안전하게 작성하는 방법을 정리합니다.

    PHP cURL 확장 설치 여부 확인

    PHP에서 cURL 함수를 사용하려면 cURL 확장이 활성화되어 있어야 합니다.

    터미널에서 다음 명령어로 확인할 수 있습니다.

    php -m | grep curl

    다음과 같이 출력되면 cURL 확장이 활성화된 것입니다.

    curl

    PHP 코드에서도 확인할 수 있습니다.

    <?php
    
    if (!extension_loaded('curl')) {
        throw new RuntimeException('PHP cURL 확장이 설치되어 있지 않습니다.');
    }
    
    echo 'PHP cURL 확장이 활성화되어 있습니다.';

    cURL 버전과 SSL 정보를 확인하려면 다음 코드를 실행합니다.

    <?php
    
    if (!extension_loaded('curl')) {
        throw new RuntimeException('PHP cURL 확장이 설치되어 있지 않습니다.');
    }
    
    $version = curl_version();
    
    echo 'cURL version: ' . ($version['version'] ?? 'unknown') . PHP_EOL;
    echo 'SSL version: ' . ($version['ssl_version'] ?? 'unknown') . PHP_EOL;

    리눅스 배포판에 따라 cURL 확장 설치 명령은 다를 수 있습니다.

    Ubuntu 또는 Debian 계열의 예시는 다음과 같습니다.

    sudo apt update
    sudo apt install php-curl

    특정 PHP 버전을 사용하고 있다면 버전에 맞는 패키지가 필요할 수 있습니다.

    sudo apt install php8.3-curl

    설치 후 PHP-FPM이나 Apache를 사용하고 있다면 해당 서비스를 재시작해야 할 수 있습니다.

    현재 PHP 버전은 다음 명령어로 확인합니다.

    php -v

    PHP cURL의 기본 처리 순서

    cURL 요청은 일반적으로 다음 순서로 작성합니다.

    curl_init()
        ↓
    curl_setopt_array()
        ↓
    curl_exec()
        ↓
    curl_errno() 또는 curl_error()
        ↓
    curl_getinfo()
        ↓
    curl_close()

    가장 단순한 GET 요청은 다음과 같습니다.

    <?php
    
    $curl = curl_init('https://api.example.com/users');
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorMessage = curl_error($curl);
        curl_close($curl);
    
        throw new RuntimeException('HTTP 요청 실패: ' . $errorMessage);
    }
    
    $statusCode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    
    curl_close($curl);
    
    echo 'HTTP 상태 코드: ' . $statusCode . PHP_EOL;
    echo $response;

    CURLOPT_RETURNTRANSFERtrue로 설정하면 응답 내용을 화면에 바로 출력하지 않고 문자열로 반환받을 수 있습니다.

    CURLOPT_RETURNTRANSFER => true

    GET 요청 보내기

    GET 요청은 서버에서 데이터를 조회할 때 주로 사용합니다.

    기본 GET 요청은 다음과 같습니다.

    <?php
    
    declare(strict_types=1);
    
    $url = 'https://api.example.com/users';
    
    $curl = curl_init($url);
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
        ],
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorNumber = curl_errno($curl);
        $errorMessage = curl_error($curl);
    
        curl_close($curl);
    
        throw new RuntimeException(
            sprintf(
                'GET 요청 실패 [%d]: %s',
                $errorNumber,
                $errorMessage
            )
        );
    }
    
    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    curl_close($curl);
    
    if ($statusCode < 200 || $statusCode >= 300) {
        throw new RuntimeException(
            sprintf(
                'GET 요청이 실패했습니다. HTTP 상태 코드: %d, 응답: %s',
                $statusCode,
                $response
            )
        );
    }
    
    echo $response;

    쿼리스트링이 있는 GET 요청

    GET 요청에 검색 조건이나 페이지 번호를 전달할 때는 URL 뒤에 쿼리스트링을 붙입니다.

    예를 들어 다음 요청을 만든다고 가정해 보겠습니다.

    https://api.example.com/users?page=1&size=20&keyword=kim

    문자열을 직접 연결하기보다 http_build_query()를 사용하는 것이 안전합니다.

    <?php
    
    declare(strict_types=1);
    
    $baseUrl = 'https://api.example.com/users';
    
    $query = [
        'page' => 1,
        'size' => 20,
        'keyword' => 'kim',
    ];
    
    $queryString = http_build_query(
        $query,
        '',
        '&',
        PHP_QUERY_RFC3986
    );
    
    $url = $baseUrl . '?' . $queryString;
    
    echo $url;

    출력 결과는 다음과 같습니다.

    https://api.example.com/users?page=1&size=20&keyword=kim

    한글이나 공백이 포함된 값도 URL에 맞게 인코딩됩니다.

    <?php
    
    $query = [
        'keyword' => '홍길동 개발자',
    ];
    
    $queryString = http_build_query(
        $query,
        '',
        '&',
        PHP_QUERY_RFC3986
    );
    
    echo $queryString;

    출력 예시는 다음과 같습니다.

    keyword=%ED%99%8D%EA%B8%B8%EB%8F%99%20%EA%B0%9C%EB%B0%9C%EC%9E%90

    재사용 가능한 GET 요청 함수

    GET 요청을 함수로 분리하면 여러 API에서 재사용할 수 있습니다.

    <?php
    
    declare(strict_types=1);
    
    /**
     * @param array<string, scalar|null> $query
     * @param list<string> $headers
     *
     * @return array{
     *     statusCode: int,
     *     headers: array<string, mixed>,
     *     body: string
     * }
     */
    function sendGetRequest(
        string $baseUrl,
        array $query = [],
        array $headers = []
    ): array {
        $url = $baseUrl;
    
        if ($query !== []) {
            $queryString = http_build_query(
                $query,
                '',
                '&',
                PHP_QUERY_RFC3986
            );
    
            $url .= '?' . $queryString;
        }
    
        $curl = curl_init($url);
    
        if ($curl === false) {
            throw new RuntimeException('cURL 초기화에 실패했습니다.');
        }
    
        $requestHeaders = array_merge(
            [
                'Accept: application/json',
            ],
            $headers
        );
    
        curl_setopt_array($curl, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 15,
            CURLOPT_HTTPHEADER => $requestHeaders,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);
    
        $response = curl_exec($curl);
    
        if ($response === false) {
            $errorNumber = curl_errno($curl);
            $errorMessage = curl_error($curl);
    
            curl_close($curl);
    
            throw new RuntimeException(
                sprintf(
                    'GET 요청 실패 [%d]: %s',
                    $errorNumber,
                    $errorMessage
                )
            );
        }
    
        $statusCode = curl_getinfo(
            $curl,
            CURLINFO_RESPONSE_CODE
        );
    
        $responseInfo = curl_getinfo($curl);
    
        curl_close($curl);
    
        return [
            'statusCode' => $statusCode,
            'headers' => $responseInfo,
            'body' => $response,
        ];
    }

    사용 예시는 다음과 같습니다.

    <?php
    
    $result = sendGetRequest(
        'https://api.example.com/users',
        [
            'page' => 1,
            'size' => 20,
            'keyword' => 'kim',
        ]
    );
    
    if (
        $result['statusCode'] < 200 ||
        $result['statusCode'] >= 300
    ) {
        throw new RuntimeException(
            sprintf(
                'API 요청 실패: HTTP %d, 응답: %s',
                $result['statusCode'],
                $result['body']
            )
        );
    }
    
    echo $result['body'];

    GET JSON 응답 파싱하기

    API가 JSON을 반환한다면 json_decode()로 변환할 수 있습니다.

    <?php
    
    $result = sendGetRequest(
        'https://api.example.com/users',
        [
            'page' => 1,
            'size' => 20,
        ]
    );
    
    if (
        $result['statusCode'] < 200 ||
        $result['statusCode'] >= 300
    ) {
        throw new RuntimeException(
            sprintf(
                'API 요청 실패: HTTP %d, 응답: %s',
                $result['statusCode'],
                $result['body']
            )
        );
    }
    
    try {
        $data = json_decode(
            $result['body'],
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    } catch (JsonException $exception) {
        throw new RuntimeException(
            'JSON 응답 파싱 실패: ' . $exception->getMessage(),
            previous: $exception
        );
    }
    
    print_r($data);

    두 번째 인자를 true로 지정하면 JSON 객체를 PHP 연관 배열로 변환합니다.

    json_decode($json, true);

    JSON_THROW_ON_ERROR를 사용하면 잘못된 JSON을 조용히 null로 처리하지 않고 JsonException을 발생시킬 수 있습니다.

    일반 form POST 요청

    HTML 폼과 비슷한 방식으로 데이터를 전송할 때는 보통 다음 Content-Type을 사용합니다.

    application/x-www-form-urlencoded

    PHP 배열을 http_build_query()로 변환하여 전송할 수 있습니다.

    <?php
    
    declare(strict_types=1);
    
    $url = 'https://api.example.com/login';
    
    $formData = [
        'username' => 'test-user',
        'password' => 'test-password',
    ];
    
    $postBody = http_build_query(
        $formData,
        '',
        '&',
        PHP_QUERY_RFC3986
    );
    
    $curl = curl_init($url);
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $postBody,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
            'Content-Type: application/x-www-form-urlencoded',
        ],
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorNumber = curl_errno($curl);
        $errorMessage = curl_error($curl);
    
        curl_close($curl);
    
        throw new RuntimeException(
            sprintf(
                'POST 요청 실패 [%d]: %s',
                $errorNumber,
                $errorMessage
            )
        );
    }
    
    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    curl_close($curl);
    
    if ($statusCode < 200 || $statusCode >= 300) {
        throw new RuntimeException(
            sprintf(
                'POST 요청 실패: HTTP %d, 응답: %s',
                $statusCode,
                $response
            )
        );
    }
    
    echo $response;

    실제 서비스 코드에서는 비밀번호를 소스코드에 직접 작성하지 말고 환경변수나 안전한 비밀정보 저장소에서 가져와야 합니다.

    JSON POST 요청

    REST API에서는 JSON 형식으로 데이터를 전송하는 경우가 많습니다.

    JSON POST 요청은 다음 두 가지가 중요합니다.

    요청 본문을 JSON 문자열로 변환
    Content-Type을 application/json으로 지정

    전체 예시는 다음과 같습니다.

    <?php
    
    declare(strict_types=1);
    
    $url = 'https://api.example.com/users';
    
    $requestData = [
        'name' => '홍길동',
        'email' => 'hong@example.com',
        'roles' => [
            'USER',
        ],
    ];
    
    try {
        $jsonBody = json_encode(
            $requestData,
            JSON_THROW_ON_ERROR |
            JSON_UNESCAPED_UNICODE |
            JSON_UNESCAPED_SLASHES
        );
    } catch (JsonException $exception) {
        throw new RuntimeException(
            '요청 데이터를 JSON으로 변환하지 못했습니다.',
            previous: $exception
        );
    }
    
    $curl = curl_init($url);
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $jsonBody,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
            'Content-Type: application/json',
            'Content-Length: ' . strlen($jsonBody),
        ],
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorNumber = curl_errno($curl);
        $errorMessage = curl_error($curl);
    
        curl_close($curl);
    
        throw new RuntimeException(
            sprintf(
                'JSON POST 요청 실패 [%d]: %s',
                $errorNumber,
                $errorMessage
            )
        );
    }
    
    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    curl_close($curl);
    
    if ($statusCode < 200 || $statusCode >= 300) {
        throw new RuntimeException(
            sprintf(
                'JSON POST 요청 실패: HTTP %d, 응답: %s',
                $statusCode,
                $response
            )
        );
    }
    
    try {
        $responseData = json_decode(
            $response,
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    } catch (JsonException $exception) {
        throw new RuntimeException(
            'JSON 응답 파싱 실패: ' . $exception->getMessage(),
            previous: $exception
        );
    }
    
    print_r($responseData);

    Content-Length는 cURL이 자동으로 처리할 수 있으므로 반드시 직접 지정해야 하는 값은 아닙니다.

    다음처럼 생략해도 됩니다.

    CURLOPT_HTTPHEADER => [
        'Accept: application/json',
        'Content-Type: application/json',
    ],

    재사용 가능한 JSON POST 함수

    JSON API 호출을 재사용 가능한 함수로 분리한 예시입니다.

    <?php
    
    declare(strict_types=1);
    
    /**
     * @param array<string, mixed> $data
     * @param list<string> $headers
     *
     * @return array{
     *     statusCode: int,
     *     body: string
     * }
     */
    function sendJsonPostRequest(
        string $url,
        array $data,
        array $headers = []
    ): array {
        try {
            $jsonBody = json_encode(
                $data,
                JSON_THROW_ON_ERROR |
                JSON_UNESCAPED_UNICODE |
                JSON_UNESCAPED_SLASHES
            );
        } catch (JsonException $exception) {
            throw new RuntimeException(
                '요청 데이터를 JSON으로 변환하지 못했습니다.',
                previous: $exception
            );
        }
    
        $curl = curl_init($url);
    
        if ($curl === false) {
            throw new RuntimeException('cURL 초기화에 실패했습니다.');
        }
    
        $requestHeaders = array_merge(
            [
                'Accept: application/json',
                'Content-Type: application/json',
            ],
            $headers
        );
    
        curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $jsonBody,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 15,
            CURLOPT_HTTPHEADER => $requestHeaders,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);
    
        $response = curl_exec($curl);
    
        if ($response === false) {
            $errorNumber = curl_errno($curl);
            $errorMessage = curl_error($curl);
    
            curl_close($curl);
    
            throw new RuntimeException(
                sprintf(
                    'JSON POST 요청 실패 [%d]: %s',
                    $errorNumber,
                    $errorMessage
                )
            );
        }
    
        $statusCode = curl_getinfo(
            $curl,
            CURLINFO_RESPONSE_CODE
        );
    
        curl_close($curl);
    
        return [
            'statusCode' => $statusCode,
            'body' => $response,
        ];
    }

    사용 예시는 다음과 같습니다.

    <?php
    
    $result = sendJsonPostRequest(
        'https://api.example.com/users',
        [
            'name' => '홍길동',
            'email' => 'hong@example.com',
        ]
    );
    
    if (
        $result['statusCode'] < 200 ||
        $result['statusCode'] >= 300
    ) {
        throw new RuntimeException(
            sprintf(
                'API 응답 오류: HTTP %d, 응답: %s',
                $result['statusCode'],
                $result['body']
            )
        );
    }
    
    try {
        $responseData = json_decode(
            $result['body'],
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    } catch (JsonException $exception) {
        throw new RuntimeException(
            'API 응답을 JSON으로 변환하지 못했습니다.',
            previous: $exception
        );
    }
    
    print_r($responseData);

    Bearer Token을 포함한 요청

    OAuth 2.0이나 JWT 기반 API에서는 Authorization 헤더에 Bearer Token을 전달하는 경우가 많습니다.

    <?php
    
    $accessToken = getenv('API_ACCESS_TOKEN');
    
    if (
        $accessToken === false ||
        $accessToken === ''
    ) {
        throw new RuntimeException(
            'API_ACCESS_TOKEN 환경변수가 설정되어 있지 않습니다.'
        );
    }
    
    $result = sendJsonPostRequest(
        'https://api.example.com/orders',
        [
            'productId' => 100,
            'quantity' => 2,
        ],
        [
            'Authorization: Bearer ' . $accessToken,
        ]
    );

    토큰을 PHP 소스에 직접 작성하지 않는 것이 좋습니다.

    잘못된 예시는 다음과 같습니다.

    $accessToken = '실제 운영 토큰';

    환경변수에서 가져오는 예시는 다음과 같습니다.

    $accessToken = getenv('API_ACCESS_TOKEN');

    서버 로그를 남길 때도 Authorization 헤더 전체를 출력하지 않도록 주의해야 합니다.

    공통 HTTP 요청 함수로 통합하기

    GET과 POST를 하나의 함수로 처리하고 싶다면 HTTP 메서드와 요청 본문을 인자로 받을 수 있습니다.

    <?php
    
    declare(strict_types=1);
    
    final class HttpResponse
    {
        /**
         * @param array<string, mixed> $info
         */
        public function __construct(
            public readonly int $statusCode,
            public readonly string $body,
            public readonly array $info
        ) {
        }
    
        public function isSuccessful(): bool
        {
            return $this->statusCode >= 200
                && $this->statusCode < 300;
        }
    }
    
    /**
     * @param array<string, scalar|null> $query
     * @param array<string, mixed>|null $jsonBody
     * @param list<string> $headers
     */
    function sendHttpRequest(
        string $method,
        string $url,
        array $query = [],
        ?array $jsonBody = null,
        array $headers = [],
        int $connectTimeoutSeconds = 5,
        int $timeoutSeconds = 15
    ): HttpResponse {
        $normalizedMethod = strtoupper($method);
    
        if ($query !== []) {
            $queryString = http_build_query(
                $query,
                '',
                '&',
                PHP_QUERY_RFC3986
            );
    
            $separator = str_contains($url, '?')
                ? '&'
                : '?';
    
            $url .= $separator . $queryString;
        }
    
        $curl = curl_init($url);
    
        if ($curl === false) {
            throw new RuntimeException('cURL 초기화에 실패했습니다.');
        }
    
        $requestHeaders = array_merge(
            [
                'Accept: application/json',
            ],
            $headers
        );
    
        $options = [
            CURLOPT_CUSTOMREQUEST => $normalizedMethod,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => $connectTimeoutSeconds,
            CURLOPT_TIMEOUT => $timeoutSeconds,
            CURLOPT_HTTPHEADER => $requestHeaders,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ];
    
        if ($jsonBody !== null) {
            try {
                $encodedBody = json_encode(
                    $jsonBody,
                    JSON_THROW_ON_ERROR |
                    JSON_UNESCAPED_UNICODE |
                    JSON_UNESCAPED_SLASHES
                );
            } catch (JsonException $exception) {
                curl_close($curl);
    
                throw new RuntimeException(
                    '요청 데이터를 JSON으로 변환하지 못했습니다.',
                    previous: $exception
                );
            }
    
            $options[CURLOPT_POSTFIELDS] = $encodedBody;
            $options[CURLOPT_HTTPHEADER] = array_merge(
                $requestHeaders,
                [
                    'Content-Type: application/json',
                ]
            );
        }
    
        if (!curl_setopt_array($curl, $options)) {
            curl_close($curl);
    
            throw new RuntimeException(
                'cURL 옵션 설정에 실패했습니다.'
            );
        }
    
        $response = curl_exec($curl);
    
        if ($response === false) {
            $errorNumber = curl_errno($curl);
            $errorMessage = curl_error($curl);
    
            curl_close($curl);
    
            throw new RuntimeException(
                sprintf(
                    '%s 요청 실패 [%d]: %s',
                    $normalizedMethod,
                    $errorNumber,
                    $errorMessage
                )
            );
        }
    
        $statusCode = curl_getinfo(
            $curl,
            CURLINFO_RESPONSE_CODE
        );
    
        $info = curl_getinfo($curl);
    
        curl_close($curl);
    
        return new HttpResponse(
            statusCode: $statusCode,
            body: $response,
            info: $info
        );
    }

    GET 요청 예시는 다음과 같습니다.

    <?php
    
    $response = sendHttpRequest(
        method: 'GET',
        url: 'https://api.example.com/users',
        query: [
            'page' => 1,
            'size' => 20,
        ]
    );
    
    if (!$response->isSuccessful()) {
        throw new RuntimeException(
            sprintf(
                '사용자 조회 실패: HTTP %d, 응답: %s',
                $response->statusCode,
                $response->body
            )
        );
    }
    
    echo $response->body;

    JSON POST 요청 예시는 다음과 같습니다.

    <?php
    
    $response = sendHttpRequest(
        method: 'POST',
        url: 'https://api.example.com/users',
        jsonBody: [
            'name' => '홍길동',
            'email' => 'hong@example.com',
        ]
    );
    
    if (!$response->isSuccessful()) {
        throw new RuntimeException(
            sprintf(
                '사용자 등록 실패: HTTP %d, 응답: %s',
                $response->statusCode,
                $response->body
            )
        );
    }
    
    echo $response->body;

    PUT 요청도 같은 함수로 처리할 수 있습니다.

    <?php
    
    $response = sendHttpRequest(
        method: 'PUT',
        url: 'https://api.example.com/users/100',
        jsonBody: [
            'name' => '김개발',
        ]
    );

    DELETE 요청 예시는 다음과 같습니다.

    <?php
    
    $response = sendHttpRequest(
        method: 'DELETE',
        url: 'https://api.example.com/users/100'
    );

    연결 타임아웃과 전체 타임아웃 차이

    cURL에서는 연결 타임아웃과 전체 요청 타임아웃을 구분할 수 있습니다.

    CURLOPT_CONNECTTIMEOUT

    서버와 연결을 맺을 때까지 기다리는 최대 시간입니다.

    CURLOPT_CONNECTTIMEOUT => 5

    예를 들어 DNS 조회, TCP 연결, TLS 연결 과정이 지나치게 오래 걸리면 지정한 시간 이후 요청을 중단합니다.

    CURLOPT_TIMEOUT

    요청 전체가 완료될 때까지 기다리는 최대 시간입니다.

    CURLOPT_TIMEOUT => 15

    연결이 성공했더라도 서버가 응답을 늦게 보내면 전체 타임아웃에 걸릴 수 있습니다.

    일반적인 API 호출 예시는 다음과 같습니다.

    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT => 15,

    API의 특성에 따라 값을 조정해야 합니다.

    대용량 파일 업로드나 오래 걸리는 배치 API에 무조건 15초를 적용하면 정상 요청도 중단될 수 있습니다.

    반대로 타임아웃을 지나치게 크게 설정하면 장애가 발생했을 때 PHP 프로세스가 오랫동안 점유될 수 있습니다.

    밀리초 단위가 필요하다면 다음 옵션을 사용할 수 있습니다.

    CURLOPT_CONNECTTIMEOUT_MS => 3000,
    CURLOPT_TIMEOUT_MS => 10000,

    초 단위와 밀리초 단위 옵션을 동시에 중복 설정하지 말고 하나의 기준으로 통일하는 것이 좋습니다.

    cURL 오류와 HTTP 오류는 다르다

    다음 두 오류는 서로 다른 상황입니다.

    cURL 실행 오류
    HTTP 상태 코드 오류

    cURL 실행 오류

    다음과 같은 경우입니다.

    DNS 조회 실패
    연결 거부
    연결 시간 초과
    SSL 인증서 검증 실패
    네트워크 연결 중단

    이 경우 curl_exec()false를 반환할 수 있습니다.

    $response = curl_exec($curl);
    
    if ($response === false) {
        echo curl_errno($curl);
        echo curl_error($curl);
    }

    HTTP 상태 코드 오류

    서버와 통신은 성공했지만 서버가 오류 상태 코드를 반환한 경우입니다.

    400 Bad Request
    401 Unauthorized
    403 Forbidden
    404 Not Found
    409 Conflict
    422 Unprocessable Content
    429 Too Many Requests
    500 Internal Server Error
    503 Service Unavailable

    이 경우 curl_exec()가 문자열 응답을 정상적으로 반환할 수 있습니다.

    따라서 HTTP 상태 코드를 별도로 확인해야 합니다.

    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    if ($statusCode < 200 || $statusCode >= 300) {
        // HTTP 오류 처리
    }

    HTTP 상태 코드 처리 예제

    <?php
    
    $response = sendHttpRequest(
        method: 'GET',
        url: 'https://api.example.com/users/100'
    );
    
    switch ($response->statusCode) {
        case 200:
            echo $response->body;
            break;
    
        case 401:
            throw new RuntimeException(
                '인증이 필요하거나 토큰이 만료되었습니다.'
            );
    
        case 403:
            throw new RuntimeException(
                '해당 요청을 실행할 권한이 없습니다.'
            );
    
        case 404:
            throw new RuntimeException(
                '요청한 사용자를 찾을 수 없습니다.'
            );
    
        case 429:
            throw new RuntimeException(
                'API 요청 횟수 제한을 초과했습니다.'
            );
    
        default:
            throw new RuntimeException(
                sprintf(
                    'API 요청 실패: HTTP %d, 응답: %s',
                    $response->statusCode,
                    $response->body
                )
            );
    }

    실제 운영 코드에서는 전체 응답 본문에 개인정보나 토큰이 포함될 수 있으므로 그대로 로그에 남기지 않도록 주의해야 합니다.

    CURLOPT_FAILONERROR를 사용할 때 주의점

    다음 옵션을 사용하면 HTTP 상태 코드가 400 이상인 응답을 cURL 실패로 처리할 수 있습니다.

    CURLOPT_FAILONERROR => true

    그러나 이 옵션을 사용하면 HTTP 오류 응답 본문을 처리하기 어려워질 수 있습니다.

    API 서버가 다음과 같은 JSON 오류 메시지를 반환한다고 가정해 보겠습니다.

    {
      "code": "INVALID_EMAIL",
      "message": "이메일 형식이 올바르지 않습니다."
    }

    오류 응답 본문을 애플리케이션에서 직접 분석해야 한다면 CURLOPT_FAILONERROR를 사용하지 않고 상태 코드를 검사하는 방식이 더 명확합니다.

    CURLOPT_FAILONERROR => false

    또는 옵션 자체를 생략합니다.

    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    if ($statusCode < 200 || $statusCode >= 300) {
        // 응답 본문과 상태 코드를 함께 처리
    }

    SSL 인증서 검증을 끄면 안 되는 이유

    인터넷의 오래된 PHP cURL 예제에는 다음 설정이 자주 포함되어 있습니다.

    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_SSL_VERIFYHOST => false,

    이 설정은 HTTPS 인증서 검증을 비활성화합니다.

    운영 환경에서는 사용하지 않는 것이 좋습니다.

    SSL 인증서 검증을 끄면 현재 접속한 서버가 실제 대상 서버인지 확인하기 어려워지고, 중간자 공격이나 잘못된 서버 연결을 감지하지 못할 수 있습니다.

    권장 설정은 다음과 같습니다.

    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,

    대부분의 정상적인 서버 환경에서는 기본 CA 인증서 저장소를 통해 검증할 수 있습니다.

    cURL error 60 해결 방법

    SSL 인증서 검증 과정에서 다음 오류가 발생할 수 있습니다.

    cURL error 60:
    SSL certificate problem:
    unable to get local issuer certificate

    이 오류가 발생했다고 해서 인증서 검증을 바로 비활성화하면 안 됩니다.

    먼저 다음 항목을 확인합니다.

    서버 인증서가 만료되지 않았는지
    접속 도메인과 인증서 도메인이 일치하는지
    서버가 중간 인증서를 제대로 제공하는지
    PHP 또는 운영체제의 CA 인증서가 최신인지
    사내 프록시가 인증서를 교체하고 있는지

    리눅스의 CA 인증서 패키지를 확인하거나 갱신할 수 있습니다.

    Ubuntu 또는 Debian 계열 예시는 다음과 같습니다.

    sudo apt update
    sudo apt install --reinstall ca-certificates
    sudo update-ca-certificates

    PHP가 사용하는 설정 파일 위치를 확인합니다.

    php --ini

    cURL 관련 설정을 확인합니다.

    php -i | grep -i curl.cainfo

    OpenSSL CA 설정도 확인할 수 있습니다.

    php -i | grep -i openssl.cafile

    별도의 CA 파일을 사용해야 한다면 php.ini에서 지정할 수 있습니다.

    curl.cainfo="/path/to/cacert.pem"
    openssl.cafile="/path/to/cacert.pem"

    또는 요청별로 CA 파일을 지정할 수 있습니다.

    CURLOPT_CAINFO => '/path/to/company-ca.pem',

    사내 CA 인증서를 사용할 때는 출처와 인증서 지문을 관리자에게 확인해야 합니다.

    인터넷에서 임의로 내려받은 인증서를 신뢰하도록 등록하면 안 됩니다.

    리디렉션 처리

    API나 웹페이지가 다른 주소로 리디렉션될 수 있습니다.

    리디렉션을 따라가려면 다음 옵션을 사용합니다.

    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_MAXREDIRS => 5,

    전체 예시는 다음과 같습니다.

    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS => 5,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
    ]);

    모든 API 요청에서 리디렉션을 무조건 허용할 필요는 없습니다.

    특히 Authorization 헤더나 민감한 데이터가 포함된 요청에서는 예상하지 못한 다른 호스트로 이동하지 않는지 확인해야 합니다.

    최종 요청 URL은 다음과 같이 확인할 수 있습니다.

    $effectiveUrl = curl_getinfo(
        $curl,
        CURLINFO_EFFECTIVE_URL
    );

    응답 헤더까지 가져오기

    응답 본문뿐 아니라 응답 헤더가 필요하다면 헤더 콜백을 사용할 수 있습니다.

    <?php
    
    declare(strict_types=1);
    
    $url = 'https://api.example.com/users';
    
    $responseHeaders = [];
    
    $curl = curl_init($url);
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
        ],
        CURLOPT_HEADERFUNCTION => static function (
            CurlHandle $curlHandle,
            string $headerLine
        ) use (&$responseHeaders): int {
            $length = strlen($headerLine);
            $trimmedLine = trim($headerLine);
    
            if (
                $trimmedLine === '' ||
                !str_contains($trimmedLine, ':')
            ) {
                return $length;
            }
    
            [$name, $value] = explode(
                ':',
                $trimmedLine,
                2
            );
    
            $normalizedName = strtolower(trim($name));
            $normalizedValue = trim($value);
    
            $responseHeaders[$normalizedName][] =
                $normalizedValue;
    
            return $length;
        },
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorMessage = curl_error($curl);
        curl_close($curl);
    
        throw new RuntimeException(
            'HTTP 요청 실패: ' . $errorMessage
        );
    }
    
    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    curl_close($curl);
    
    print_r($responseHeaders);
    echo $response;

    API 요청 횟수 제한을 확인할 때 응답 헤더를 사용할 수 있습니다.

    Retry-After
    X-RateLimit-Limit
    X-RateLimit-Remaining
    X-RateLimit-Reset

    실제 헤더 이름은 API 제공업체마다 다릅니다.

    요청 시간 측정

    curl_getinfo()를 사용하면 요청 처리 시간을 확인할 수 있습니다.

    <?php
    
    $response = sendHttpRequest(
        method: 'GET',
        url: 'https://api.example.com/users'
    );
    
    $totalTime = $response->info['total_time'] ?? null;
    $connectTime = $response->info['connect_time'] ?? null;
    $nameLookupTime =
        $response->info['namelookup_time'] ?? null;
    
    echo 'DNS 조회 시간: '
        . (string) $nameLookupTime
        . PHP_EOL;
    
    echo '연결 시간: '
        . (string) $connectTime
        . PHP_EOL;
    
    echo '전체 요청 시간: '
        . (string) $totalTime
        . PHP_EOL;

    다음 정보를 장애 분석에 활용할 수 있습니다.

    namelookup_time
    connect_time
    appconnect_time
    starttransfer_time
    total_time
    primary_ip
    http_code

    운영 로그에 응답 시간을 남기면 DNS 지연, 연결 지연, 서버 응답 지연을 구분하는 데 도움이 됩니다.

    API 요청 로그에 남기면 안 되는 정보

    디버깅을 위해 요청 정보를 로그에 남길 때는 민감정보를 제거해야 합니다.

    다음 값은 그대로 기록하지 않는 것이 좋습니다.

    Authorization 헤더
    Bearer Token
    API Key
    비밀번호
    주민등록번호
    카드번호
    계좌번호
    세션 쿠키
    개인정보가 포함된 요청 본문

    잘못된 예시는 다음과 같습니다.

    error_log(
        json_encode([
            'headers' => $headers,
            'body' => $requestData,
        ])
    );

    필요한 값만 선별해 기록합니다.

    <?php
    
    $logContext = [
        'method' => 'POST',
        'url' => 'https://api.example.com/orders',
        'statusCode' => $response->statusCode,
        'totalTime' =>
            $response->info['total_time'] ?? null,
    ];
    
    error_log(
        json_encode(
            $logContext,
            JSON_UNESCAPED_UNICODE |
            JSON_UNESCAPED_SLASHES
        )
    );

    URL의 쿼리스트링에도 토큰이나 개인정보가 포함될 수 있으므로 전체 URL을 그대로 기록하기 전에 확인해야 합니다.

    재시도 처리 시 주의점

    네트워크 오류나 일시적인 503 응답이 발생하면 재시도를 고려할 수 있습니다.

    하지만 모든 요청을 자동으로 재시도하면 안 됩니다.

    특히 다음 요청은 중복 실행 위험이 있습니다.

    결제 승인
    주문 생성
    포인트 차감
    계좌 이체
    회원 등록
    파일 업로드

    POST 요청이 서버에서 처리된 후 응답만 유실된 경우, 클라이언트가 다시 요청하면 동일 작업이 두 번 실행될 수 있습니다.

    재시도가 필요한 API라면 다음을 함께 고려해야 합니다.

    멱등키 Idempotency-Key
    고유 요청 ID
    서버의 중복 처리 방지
    재시도 가능한 상태 코드 제한
    최대 재시도 횟수
    지수 백오프

    멱등키 헤더 예시는 다음과 같습니다.

    <?php
    
    $idempotencyKey = bin2hex(
        random_bytes(16)
    );
    
    $response = sendJsonPostRequest(
        'https://api.example.com/payments',
        [
            'orderId' => 'ORDER-20260801-001',
            'amount' => 10000,
        ],
        [
            'Idempotency-Key: ' . $idempotencyKey,
        ]
    );

    실제로 Idempotency-Key를 지원하는지는 API 제공업체의 명세를 확인해야 합니다.

    파일 업로드는 JSON POST와 다르다

    파일 업로드는 일반적으로 다음 Content-Type을 사용합니다.

    multipart/form-data

    CURLFile 객체를 사용할 수 있습니다.

    <?php
    
    declare(strict_types=1);
    
    $filePath = __DIR__ . '/sample.jpg';
    
    if (!is_file($filePath)) {
        throw new RuntimeException(
            '업로드할 파일을 찾을 수 없습니다.'
        );
    }
    
    $curlFile = new CURLFile(
        $filePath,
        'image/jpeg',
        basename($filePath)
    );
    
    $postData = [
        'title' => '샘플 이미지',
        'file' => $curlFile,
    ];
    
    $curl = curl_init(
        'https://api.example.com/files'
    );
    
    if ($curl === false) {
        throw new RuntimeException('cURL 초기화에 실패했습니다.');
    }
    
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $postData,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 60,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
        ],
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);
    
    $response = curl_exec($curl);
    
    if ($response === false) {
        $errorMessage = curl_error($curl);
        curl_close($curl);
    
        throw new RuntimeException(
            '파일 업로드 실패: ' . $errorMessage
        );
    }
    
    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    curl_close($curl);
    
    if ($statusCode < 200 || $statusCode >= 300) {
        throw new RuntimeException(
            sprintf(
                '파일 업로드 실패: HTTP %d, 응답: %s',
                $statusCode,
                $response
            )
        );
    }
    
    echo $response;

    CURLOPT_POSTFIELDS에 배열과 CURLFile을 전달하면 cURL이 multipart 요청을 구성합니다.

    이 경우 Content-Type: multipart/form-data 헤더를 직접 작성하지 않는 편이 좋습니다.

    cURL이 boundary 값을 포함한 올바른 Content-Type을 자동으로 생성해야 하기 때문입니다.

    자주 발생하는 코드 오류

    URL에 꺾쇠괄호를 포함한 경우

    잘못된 예시는 다음과 같습니다.

    $url = '<https://api.example.com/users>';

    마크다운 링크 표기를 PHP 문자열에 그대로 복사한 형태입니다.

    올바른 URL은 다음과 같습니다.

    $url = 'https://api.example.com/users';

    문자열 연결에 쉼표를 사용한 경우

    잘못된 예시는 다음과 같습니다.

    $url = 'https://api.example.com/users'
        . '?',
        http_build_query($query);

    PHP 문자열 연결에는 마침표 연산자 .를 사용해야 합니다.

    올바른 예시는 다음과 같습니다.

    $url = 'https://api.example.com/users'
        . '?'
        . http_build_query(
            $query,
            '',
            '&',
            PHP_QUERY_RFC3986
        );

    더 명확하게 나누면 다음과 같습니다.

    $queryString = http_build_query(
        $query,
        '',
        '&',
        PHP_QUERY_RFC3986
    );
    
    $url = $baseUrl . '?' . $queryString;

    GET 요청에 CURLOPT_POST를 사용한 경우

    GET 요청에는 다음 옵션이 필요하지 않습니다.

    CURLOPT_POST => true

    GET 요청은 URL에 쿼리스트링을 포함하고 기본 요청 방식으로 실행하면 됩니다.

    $curl = curl_init($url);

    JSON 요청에 배열을 그대로 전달한 경우

    다음 코드는 JSON 요청이 아니라 multipart 또는 form 형태로 처리될 수 있습니다.

    CURLOPT_POSTFIELDS => [
        'name' => '홍길동',
        'email' => 'hong@example.com',
    ],

    JSON 요청은 먼저 JSON 문자열로 변환해야 합니다.

    $jsonBody = json_encode(
        $data,
        JSON_THROW_ON_ERROR
    );
    CURLOPT_POSTFIELDS => $jsonBody,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
    ],

    Content-Type만 JSON이고 본문은 form 형식인 경우

    잘못된 예시는 다음과 같습니다.

    CURLOPT_POSTFIELDS => http_build_query($data),
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
    ],

    헤더는 JSON인데 실제 본문은 다음 형태가 됩니다.

    name=hong&email=hong%40example.com

    JSON API라면 본문도 JSON으로 만들어야 합니다.

    CURLOPT_POSTFIELDS => json_encode(
        $data,
        JSON_THROW_ON_ERROR
    ),

    curl_exec 결과를 검사하지 않은 경우

    잘못된 예시는 다음과 같습니다.

    $response = curl_exec($curl);
    
    $data = json_decode($response, true);

    curl_exec()false를 반환할 수 있으므로 먼저 확인합니다.

    $response = curl_exec($curl);
    
    if ($response === false) {
        throw new RuntimeException(
            curl_error($curl)
        );
    }

    HTTP 상태 코드를 확인하지 않은 경우

    응답 본문이 반환되었다고 요청이 성공한 것은 아닙니다.

    $statusCode = curl_getinfo(
        $curl,
        CURLINFO_RESPONSE_CODE
    );
    
    if ($statusCode < 200 || $statusCode >= 300) {
        // 오류 처리
    }

    타임아웃을 설정하지 않은 경우

    외부 API가 응답하지 않으면 PHP 요청이 장시간 대기할 수 있습니다.

    최소한 연결 타임아웃과 전체 타임아웃을 설정하는 것이 좋습니다.

    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT => 15,

    SSL 검증을 비활성화한 경우

    다음 설정은 운영 환경에서 피해야 합니다.

    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_SSL_VERIFYHOST => false,

    권장 설정은 다음과 같습니다.

    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,

    최종 JSON API 요청 예제

    다음 코드는 JSON POST 요청에서 필요한 핵심 처리를 포함한 예제입니다.

    <?php
    
    declare(strict_types=1);
    
    final class ApiException extends RuntimeException
    {
        public function __construct(
            string $message,
            public readonly ?int $statusCode = null,
            public readonly ?string $responseBody = null,
            ?Throwable $previous = null
        ) {
            parent::__construct(
                $message,
                previous: $previous
            );
        }
    }
    
    /**
     * @param array<string, mixed> $payload
     * @param list<string> $additionalHeaders
     *
     * @return array<string, mixed>
     */
    function postJson(
        string $url,
        array $payload,
        array $additionalHeaders = []
    ): array {
        try {
            $requestBody = json_encode(
                $payload,
                JSON_THROW_ON_ERROR |
                JSON_UNESCAPED_UNICODE |
                JSON_UNESCAPED_SLASHES
            );
        } catch (JsonException $exception) {
            throw new ApiException(
                message: '요청 데이터 JSON 변환 실패',
                previous: $exception
            );
        }
    
        $curl = curl_init($url);
    
        if ($curl === false) {
            throw new ApiException(
                'cURL 초기화에 실패했습니다.'
            );
        }
    
        $headers = array_merge(
            [
                'Accept: application/json',
                'Content-Type: application/json',
            ],
            $additionalHeaders
        );
    
        $optionResult = curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $requestBody,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 15,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);
    
        if ($optionResult === false) {
            curl_close($curl);
    
            throw new ApiException(
                'cURL 옵션 설정에 실패했습니다.'
            );
        }
    
        $responseBody = curl_exec($curl);
    
        if ($responseBody === false) {
            $errorNumber = curl_errno($curl);
            $errorMessage = curl_error($curl);
    
            curl_close($curl);
    
            throw new ApiException(
                sprintf(
                    'API 연결 실패 [%d]: %s',
                    $errorNumber,
                    $errorMessage
                )
            );
        }
    
        $statusCode = curl_getinfo(
            $curl,
            CURLINFO_RESPONSE_CODE
        );
    
        curl_close($curl);
    
        if ($statusCode < 200 || $statusCode >= 300) {
            throw new ApiException(
                message: sprintf(
                    'API가 오류 상태 코드를 반환했습니다: HTTP %d',
                    $statusCode
                ),
                statusCode: $statusCode,
                responseBody: $responseBody
            );
        }
    
        if ($responseBody === '') {
            return [];
        }
    
        try {
            $decodedResponse = json_decode(
                $responseBody,
                true,
                512,
                JSON_THROW_ON_ERROR
            );
        } catch (JsonException $exception) {
            throw new ApiException(
                message: 'API 응답 JSON 파싱 실패',
                statusCode: $statusCode,
                responseBody: $responseBody,
                previous: $exception
            );
        }
    
        if (!is_array($decodedResponse)) {
            throw new ApiException(
                message: 'API 응답이 예상한 객체 또는 배열 형식이 아닙니다.',
                statusCode: $statusCode,
                responseBody: $responseBody
            );
        }
    
        return $decodedResponse;
    }

    호출 예시는 다음과 같습니다.

    <?php
    
    try {
        $result = postJson(
            'https://api.example.com/users',
            [
                'name' => '홍길동',
                'email' => 'hong@example.com',
            ]
        );
    
        print_r($result);
    } catch (ApiException $exception) {
        error_log(
            sprintf(
                'API 요청 오류: %s, HTTP 상태: %s',
                $exception->getMessage(),
                $exception->statusCode !== null
                    ? (string) $exception->statusCode
                    : '없음'
            )
        );
    
        echo '외부 API 요청 중 오류가 발생했습니다.';
    }

    운영 환경에서는 $exception->responseBody 전체를 그대로 로그에 기록하기 전에 개인정보나 민감정보가 포함되어 있는지 확인해야 합니다.

    적용 전 체크리스트

    [ ] PHP cURL 확장이 활성화되어 있다.
    [ ] URL에 < > 문자가 포함되어 있지 않다.
    [ ] GET 쿼리스트링은 http_build_query로 생성한다.
    [ ] PHP_QUERY_RFC3986 옵션을 검토했다.
    [ ] CURLOPT_RETURNTRANSFER를 true로 설정했다.
    [ ] 연결 타임아웃을 설정했다.
    [ ] 전체 요청 타임아웃을 설정했다.
    [ ] curl_exec가 false인지 확인한다.
    [ ] curl_errno와 curl_error를 함께 확인한다.
    [ ] HTTP 상태 코드를 별도로 확인한다.
    [ ] JSON 요청 본문은 json_encode로 생성한다.
    [ ] JSON Content-Type 헤더를 지정했다.
    [ ] JSON 응답은 JSON_THROW_ON_ERROR로 파싱한다.
    [ ] CURLOPT_SSL_VERIFYPEER를 false로 설정하지 않았다.
    [ ] CURLOPT_SSL_VERIFYHOST는 2로 설정했다.
    [ ] SSL 오류 발생 시 CA 인증서와 서버 인증서를 점검한다.
    [ ] Authorization 토큰을 소스코드에 직접 작성하지 않았다.
    [ ] 로그에 토큰과 개인정보를 남기지 않는다.
    [ ] POST 재시도 전에 중복 처리 위험을 검토한다.
    [ ] 결제·주문 API는 멱등성 처리를 확인한다.

    정리

    PHP cURL 요청은 curl_exec()를 호출하는 것만으로 끝나지 않습니다.

    GET 요청에서는 http_build_query()를 사용해 쿼리스트링을 안전하게 생성하고, JSON POST 요청에서는 데이터를 json_encode()로 변환한 뒤 Content-Type: application/json 헤더를 지정해야 합니다.

    네트워크 요청은 다음 세 가지 오류를 구분해 처리하는 것이 중요합니다.

    cURL 네트워크 오류
    HTTP 4xx·5xx 오류
    JSON 인코딩 또는 디코딩 오류

    연결 타임아웃과 전체 타임아웃도 반드시 구분하여 설정해야 합니다.

    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT => 15,

    HTTPS 요청에서 인증서 오류가 발생하더라도 다음 설정으로 검증을 끄는 것은 피해야 합니다.

    CURLOPT_SSL_VERIFYPEER => false

    인증서 문제는 서버 인증서 체인, 접속 도메인, 인증서 만료일, 운영체제의 CA 인증서 저장소를 확인하여 해결하는 것이 안전합니다.

    또한 결제나 주문 생성처럼 중복 실행되면 문제가 되는 POST 요청은 단순 자동 재시도보다 멱등키와 서버 측 중복 방지 처리를 먼저 검토해야 합니다.