PHP 백엔드 환경에서 OpenAI Chat Completions API를 연동해 AI 답변을 생성할 때, 텍스트 생성이 길어지면서 cURL 타임아웃 에러(Operation timed out after 30000 milliseconds)를 경험해봤을 것이다.
다만 대부분의 개발자들은 CURLOPT_TIMEOUT 시간을 단순히 60초, 120초로 늘리는 식으로 임시 조치를 취하지만, 이는 웹 서버의 FPM 워커 프로세스를 오랫동안 점유하여 서비스 전체가 먹통이 되는 원인이 된다.
이번에는 OpenAI API의 타임아웃 원인을 정확히 분석하고, 이를 근본적으로 해결하는 SSE(Server-Sent Events) 기반 스트리밍 연동 방식을 완벽하게 정리해서 소개하겠다.

 

1. 일반 동기 요청과 SSE 스트리밍 방식의 차이

OpenAI의 Chat Completions API는 기본적으로 생성하려는 텍스트 전체가 완성될 때까지 기다린 후 한 번에 JSON 객체로 반환한다. 생성되는 문장이 길거나 모델의 부하가 높은 시간대에는 응답 완료까지 10초에서 30초 이상 걸리기도 한다.
반면, stream 옵션을 true로 설정하면 Server-Sent Events(SSE) 규격에 따라 단어 단위(Token)로 실시간 조각 데이터를 전송받는다. 이를 통해 클라이언트는 첫 번째 토큰을 수 초 내에 즉시 전달받아 브라우저 화면에 타이핑 효과로 보여줄 수 있다.

비교 항목일반 JSON 응답 방식 (stream: false)SSE 스트리밍 방식 (stream: true)
응답 수신 시점전체 문장 완결 후 일괄 반환토큰 생성 즉시 실시간 조각 분할 반환
타임아웃 위험성매우 높음 (긴 텍스트 생성 시 연결 종료)매우 낮음 (지속적인 HTTP 청크 수신)
사용자 경험 (UX)응답 완료 시까지 로딩 스피너 대기ChatGPT처럼 실시간 타이핑 렌더링
서버 메모리 점유완전한 응답 객체를 수신할 때까지 대기수신 즉시 클라이언트로 전달 후 버퍼 비움

 

2. PHP cURL에서 스트리밍 데이터 수신 원리

PHP cURL 라이브러리는 기본적으로 curl_exec() 실행 시 요청이 완료될 때까지 실행을 멈추고 결과를 통째로 변수에 담는다. 스트리밍 방식을 구현하려면 CURLOPT_WRITEFUNCTION 옵션을 사용하여 서버로부터 데이터 조각이 도착할 때마다 호출되는 콜백 함수를 등록해야 한다.

OpenAI 스트림 응답은 각 줄마다 data: { ... } 형태의 JSON 규격으로 들어오며, 전송이 끝나는 시점에는 data: [DONE] 이라는 특수 플래그를 전달한다. 콜백 함수 내에서 이 응답을 파싱하여 실시간으로 출력하거나 클라이언트 측 이벤트 스트림으로 전달해주면 된다.

 

3. 실전 예제: 동기 방식 vs 스트리밍 방식

실제 코드 구현을 통해 두 방식의 작성 형태와 동작 원리를 비교해보자.

 

✗ 잘못된 방식: 일반 동기 cURL 요청 (타임아웃 발생 위험)

전체 응답을 한 번에 받으려고 하면 대량의 문장 생성 시 PHP 실행 제한 시간이나 cURL 타임아웃에 걸려 요청이 실패한다.

<?php
$apiKey = 'YOUR_OPENAI_API_KEY';
$ch = curl_init('https://api.openai.com/v1/chat/completions');

$payload = [
    'model' => 'gpt-4o',
    'messages' => [
        ['role' => 'user', 'content' => 'PHP의 cURL 라이브러리에 대해 2000자 이상 상세히 설명해줘.']
    ],
    'stream' => false // 기본값: 완료될 때까지 대기함
];

curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $apiKey
    ],
    CURLOPT_TIMEOUT => 10 // 10초 이내 응답 안 올 경우 Fatal Error 발생
]);

$response = curl_exec($ch);

if (curl_errno($ch)) {
    // Operation timed out after 10005 milliseconds 에러 발생
    echo 'cURL 에러: ' . curl_error($ch);
} else {
    $result = json_decode($response, true);
    echo $result['choices'][0]['message']['content'];
}
curl_close($ch);
?>

출력 결과:

cURL 에러: Operation timed out after 10000 milliseconds with 0 bytes received

 

✓ 올바른 방식: CURLOPT_WRITEFUNCTION을 활용한 SSE 스트리밍

stream 옵션을 true로 두고, 데이터 청크가 들어올 때마다 콜백 함수에서 즉시 파싱 및 출력 처리를 수행한다.

<?php
// 브라우저 및 웹서버 버퍼링 방지 설정
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // Nginx 버퍼링 비활성화

$apiKey = 'YOUR_OPENAI_API_KEY';
$ch = curl_init('https://api.openai.com/v1/chat/completions');

$payload = [
    'model' => 'gpt-4o',
    'messages' => [
        ['role' => 'user', 'content' => 'PHP의 cURL 라이브러리에 대해 2000자 이상 상세히 설명해줘.']
    ],
    'stream' => true // 실시간 스트리밍 활성화
];

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $apiKey
    ],
    // 데이터 조각이 도착할 때마다 호출되는 콜백 함수
    CURLOPT_WRITEFUNCTION => function($ch, $chunk) {
        $lines = explode("
", $chunk);
        foreach ($lines as $line) {
            $line = trim($line);
            if (strpos($line, 'data: ') === 0) {
                $dataData = substr($line, 6);
                if ($dataData === '[DONE]') {
                    break; // 스트림 종료
                }
                $json = json_decode($dataData, true);
                if (isset($json['choices'][0]['delta']['content'])) {
                    $text = $json['choices'][0]['delta']['content'];
                    echo $text;
                    // PHP 출력 버퍼 강제 비우기
                    if (ob_get_level() > 0) {
                        ob_flush();
                    }
                    flush();
                }
            }
        }
        return strlen($chunk); // 수신한 바이트 수를 반환해야 cURL이 계속 진행됨
    ]
]);

curl_exec($ch);
curl_close($ch);
?>

출력 결과:

PHP의 cURL(Client URL) 라이브러리는 다양한 프로토콜을 사용해 서버 간 통신을 가능하게 해주는 강력한 도구입니다... (실시간으로 한 단어씩 즉시 출력됨)

 

4. 실무 적용 시 흔히 하는 실수와 해결법

스트리밍 코드를 제대로 작성했음에도 불구하고, 실제 화면에는 답변 전체가 끝난 뒤 한 번에 쏟아져 나오는 현상이 자주 발생한다. 이는 PHP와 웹 서버(Nginx/Apache) 레벨의 버퍼링 옵션 때문이다.

✗ 잘못된 예: 출력 버퍼를 비우지 않거나 Nginx 버퍼링이 켜져 있는 경우

// 버퍼 비우기(flush)를 누락하면 PHP 버퍼가 채워질 때까지 응답이 묶여있게 된다.
CURLOPT_WRITEFUNCTION => function($ch, $chunk) {
    echo $chunk; 
    return strlen($chunk);
}

✓ 올바른 예: PHP 버퍼 비우기 및 Nginx 제어 헤더 추가

// 1. 헤더에 Nginx 프록시 버퍼링 중단 명령 추가
header('X-Accel-Buffering: no');

// 2. 콜백 함수 내에서 ob_flush() 및 flush() 반드시 연속 호출
if (ob_get_level() > 0) {
    ob_flush();
}
flush();

또한 콜백 함수의 마지막에는 반드시 수신한 strlen($chunk) 바이트 크기를 정수로 반환해야 한다. 바이트 수를 다르게 반환하거나 0을 반환하면 cURL은 전송 에러로 판단하고 연결을 즉시 중단한다.

 

5. 마무리 및 요약

OpenAI API 연동 시 발생되는 타임아웃 장애는 cURL 제한 시간을 올리는 방식으로는 해결되지 않는다. 긴 응답을 처리할 때는 stream 옵션을 활성화하여 SSE 방식으로 청크 데이터를 수신하는 구조로 전환해야 한다. 작은 최적화와 올바른 스트리밍 처리가 모여서 서버 자원 절약과 실시간 사용자 경험 향상이라는 큰 효과를 만든다는 점을 잊지 말자. 이 글의 CURLOPT_WRITEFUNCTION 예제 및 버퍼 플러시 설정을 참고해 프로젝트에 적용하면, 타임아웃 없는 안정적인 AI 서비스를 구축할 수 있을 것이다.