개발

PHP 8.5 에서 외부 API 호출의 TLS 연결을 요청 간에 재사용하기 — curl 영구 공유 핸들 실측 (93ms→14ms)

  • 7th October 2026
  • 6 min read

결제 승인, 지도 좌표 변환, 메신저 알림처럼 요청이 들어올 때마다 외부 HTTPS API를 한 번씩 부르는 웹앱이 많다. PHP에서는 이 호출이 매번 처음부터 시작된다. DNS를 찾고, TCP 연결을 맺고, TLS 핸드셰이크를 하고, 그다음에야 요청 한 줄을 보낸다. 응답을 받고 PHP 요청이 끝나면 그 연결은 버려지고, 다음 방문자의 요청은 같은 서버에 같은 과정을 또 거친다.

PHP 8.5에 들어온 curl_share_init_persistent()는 이 연결을 PHP 요청이 끝나도 버리지 않고 다음 요청에 넘겨준다. 같은 프로세스가 다음 요청을 받으면, 이미 열려 있는 TLS 연결 위에 바로 요청을 보낸다. 맥에서 HTTPS 서버 한 곳으로 재 보니 호출 한 번이 중앙값 93ms에서 14ms로 줄었다.

이 함수를 "curl 핸들끼리 DNS와 연결을 공유하는 기능"으로 소개하는 글이 있는데, 그건 PHP 5.5부터 있던 curl_share_init()이 하던 일이다. 새로 생긴 것은 요청의 경계를 넘는다는 점 하나다. 이 글은 그 차이를 재 본 결과와 쓸 때 알아야 할 제약에 대한 것이다.

한 요청 안에서는 원래 재사용됐다

먼저 스크립트 하나 안에서 같은 HTTPS 주소를 세 번씩 불러 봤다. PHP 8.5.4, libcurl 8.19.0, CLI. CURLINFO_NUM_CONNECTS는 이번 전송에서 새로 맺은 연결 수다. 0이면 기존 연결을 썼다는 뜻이다.

방식1회2회3회
매번 curl_init() 새로연결 1 · 177ms연결 1 · 103ms연결 1 · 107ms
curl_init() 하나를 재사용연결 1 · 170ms연결 0 · 33ms연결 0 · 34ms
curl_share_init()로 공유연결 1 · 107ms연결 0 · 21ms연결 0 · 34ms
curl_share_init_persistent()연결 1 · 116ms연결 0 · 31ms연결 0 · 112ms

핸들을 재사용하거나 share 핸들로 묶으면, 한 스크립트 안에서는 두 번째 호출부터 연결 0이 된다. 시간은 TLS 핸드셰이크가 빠진 만큼 줄어든다. 네 번째 줄 persistent도 CLI에서는 세 번째 줄과 다를 게 없다. CLI 스크립트는 한 번 실행하면 프로세스가 끝나서, "요청이 끝난 뒤"라는 시점이 오지 않기 때문이다. 세 번째 호출의 112ms는 연결 0인데도 늦게 온 것으로, 서버 쪽 응답 편차다.

그래서 이 함수는 CLI로 재면 효과가 안 보인다. 웹 서버 뒤에서 HTTP 요청을 연달아 보내 봐야 한다.

요청을 넘어서 재 보기

웹 요청 하나가 외부 API를 한 번 부르는 페이지를 만들고, php -S로 띄워 curl로 10번씩 연달아 호출했다. 페이지는 이렇게 생겼다.

<?php
$ch = curl_init('https://api.example.com/v1/geocode?q=...');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$sh = curl_share_init_persistent([
    CURL_LOCK_DATA_DNS,
    CURL_LOCK_DATA_CONNECT,
    CURL_LOCK_DATA_SSL_SESSION,
]);
curl_setopt($ch, CURLOPT_SHARE, $sh);

$body = curl_exec($ch);

바뀐 것은 CURLOPT_SHARE에 넘기는 핸들을 만드는 한 줄뿐이다. 세 가지로 나눠 쟀다.

페이지 안의 코드NUM_CONNECTS (요청 10번)호출 시간 중앙값
share 없음1, 1, 1, 1, 1, 1, 1, 1, 1, 193ms (61~184ms)
curl_share_init()1, 1, 1 (3번 중 3번 새 연결)116~232ms
curl_share_init_persistent()1, 0, 0, 0, 0, 0, 0, 0, 0, 014ms (13~99ms, 첫 요청 제외)

curl_share_init()으로 만든 share 핸들은 요청이 끝나면 같이 사라진다. 그래서 다음 요청은 매번 새 연결로 시작한다. persistent는 첫 요청만 연결을 맺고, 두 번째 요청부터는 연결 0이다. 줄어든 79ms 정도가 TCP 연결과 TLS 핸드셰이크에 들던 시간이다. 측정한 서버까지의 왕복 시간이 30ms 남짓이었으니, 먼 해외 API일수록 줄어드는 폭은 더 커진다.

워커마다 따로 연결한다

"요청을 넘어 유지된다"는 말은 정확히는 같은 프로세스 안에서라는 뜻이다. PHP-FPM이나 아파치 prefork처럼 워커 프로세스가 여러 개면 연결 풀도 워커마다 하나씩 생긴다. PHP_CLI_SERVER_WORKERS=4로 워커 4개를 띄우고 요청 12번을 보내 봤다.

워커 PIDNUM_CONNECTS 순서
576471, 0, 0, 0
576511, 0, 0
576521, 0
576491, 0, 0

워커 4개가 각자 처음 한 번씩 연결을 맺었고, 그 뒤로는 자기 연결을 다시 썼다. FPM의 pm.max_children가 50이면 외부 API 서버 쪽에는 최대 50개의 연결이 열려 있을 수 있다는 뜻이다. 워커가 pm.max_requests에 걸려 재시작되면 그 워커의 연결도 사라지고 다시 맺는다.

오래 쉬면 연결은 끊긴다

연결을 들고 있다고 해서 영원히 쓸 수 있는 것은 아니다. 상대 서버는 놀고 있는 연결을 일정 시간 뒤에 닫는다(nginx 기본 keepalive_timeout은 75초). 연달아 두 번 부르고, 80초 쉰 뒤 다시 두 번 불러 봤다.

순서NUM_CONNECTS호출 시간
11171ms
2015ms
(80초 대기)
31177ms
4038ms

쉬고 난 뒤 첫 호출은 다시 새 연결을 맺었다. 오류가 나지는 않는다. 끊긴 연결은 libcurl이 알아서 버리고 새로 맺는다. 다만 이득은 같은 API를 자주 부르는 서비스에서만 생긴다. 분당 몇 번 호출하는 정도라면 대부분의 호출이 첫 호출처럼 느려서, 함수를 바꿔도 차이를 느끼기 어렵다.

쓸 때 지켜지는 제약

이 함수는 처음엔 curl_share_init()에 $persistent_id 인자를 더하는 안으로 통과됐다가, 구현 단계에서 별도 함수로 다시 설계됐다(RFC Persistent curl share handle improvement). 그때 바뀐 점들이 그대로 제약이 됐다.

쿠키는 공유할 수 없다. 요청 사이에 쿠키가 남으면 앞 방문자의 세션 쿠키가 다음 방문자의 API 호출에 실려 나갈 수 있다. 그래서 아예 막혀 있다.

curl_share_init_persistent([CURL_LOCK_DATA_COOKIE]);
// ValueError: curl_share_init_persistent(): Argument #1 ($share_options)
// must not contain CURL_LOCK_DATA_COOKIE because sharing cookies across PHP requests is unsafe

RFC 본문은 RuntimeException을 던진다고 적었지만, 8.5.4에서 실제로 나는 것은 ValueError다(공식 매뉴얼도 ValueError로 적혀 있다). catch (RuntimeException $e)로 잡으려고 하면 빠져나간다.

만든 뒤에는 바꿀 수 없다. 반환 타입이 CurlShareHandle이 아니라 CurlSharePersistentHandle이라, 기존 curl_share_* 함수에 넘기면 타입 오류가 난다.

curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);
// TypeError: curl_share_setopt(): Argument #1 ($share_handle) must be of type CurlShareHandle,
// CurlSharePersistentHandle given

curl_share_close($sh);
// TypeError (같은 메시지). 참고로 curl_share_close()는 8.5에서 deprecated 됐다.

ID를 짓지 않는다. 옵션 조합이 곧 ID다. 같은 옵션 조합으로 부르면 PHP가 같은 공유 핸들을 찾아 준다. 배열 순서는 상관없다. 순서만 바꿔 두 번 만든 핸들은 PHP 객체로는 서로 다르지만(===가 false), 첫 번째 핸들로 연 연결을 두 번째 핸들이 연결 0으로 받아 썼다. 조합이 다르면(DNS만) 별개의 풀이라 다시 연결을 맺었다. 그러니 코드 여러 곳에서 이 함수를 불러도 조합만 같으면 연결을 나눠 쓴다. 빈 배열과 CURL_LOCK_DATA_*가 아닌 값은 ValueError다.

쓸 수 있는 곳과 아닌 곳

환경효과
PHP-FPM, 아파치 mod_php, php -S있음. 워커 프로세스마다 연결을 유지한다
FrankenPHP 워커 모드, RoadRunner, Swoole 같은 상주 실행기원래 요청 간에 객체가 살아 있어서 curl_share_init()을 전역에 두는 것으로도 된다
CLI 스크립트, 크론없음. 프로세스가 한 번 돌고 끝난다. 스크립트 안의 재사용은 curl_share_init()으로 충분하다
PHP 8.4 이하함수가 없다. function_exists()로 갈라 두면 8.5 이전에서는 기존처럼 동작한다
$sh = function_exists('curl_share_init_persistent')
    ? curl_share_init_persistent([CURL_LOCK_DATA_DNS, CURL_LOCK_DATA_CONNECT, CURL_LOCK_DATA_SSL_SESSION])
    : null;

if ($sh) {
    curl_setopt($ch, CURLOPT_SHARE, $sh);
}

Guzzle 같은 HTTP 클라이언트를 쓰고 있다면 내부에서 curl 핸들을 만든다. 핸들러 옵션으로 CURLOPT_SHARE를 넘길 수 있는지는 쓰는 버전의 문서를 확인해야 한다. 이번에는 curl 함수를 직접 부르는 경우만 쟀다.

정리

  • 한 요청 안에서 연결을 재사용하는 것은 curl_share_init()이나 핸들 재사용으로 원래 됐다. curl_share_init_persistent()의 새로운 점은 PHP 요청이 끝나도 연결이 남는다는 것이다.
  • 웹 요청마다 같은 HTTPS API를 부르는 페이지에서 호출 시간이 중앙값 93ms → 14ms로 줄었다(두 번째 요청부터 NUM_CONNECTS 0).
  • 연결은 워커 프로세스마다 따로 생기고, 상대 서버가 놀고 있는 연결을 닫으면 다시 맺는다. 같은 API를 자주 부를수록 이득이 크다.
  • 쿠키 공유는 ValueError로 막혀 있고, 만든 핸들은 바꿀 수도 닫을 수도 없다. 같은 옵션 조합이면 같은 연결 풀을 쓴다.
  • CLI와 크론에서는 효과가 없고, PHP 8.5부터 쓸 수 있다.

측정 환경: macOS, PHP 8.5.4 (Homebrew), libcurl 8.19.0 / OpenSSL 3.6.3, 대상은 왕복 지연 약 30ms인 HTTPS 서버 한 곳. 네트워크 상황에 따라 절대값은 달라진다. PHP-FPM은 직접 띄우지 않고 같은 프로세스 모델인 php -S 다중 워커로 쟀다.

함께 읽으면 좋은 글