

페이지 새로고침시 클라이언트에서 RST 패킷 발생

페이지 이동시 클라이언트에서 RST 패킷 발생
SSE(Server-Sent Events)로 OpenAI 스트리밍 응답을 전달하는 도중, 사용자가 페이지를 새로고침하거나 이동하면 TCP RST 패킷이 발생하며 연결이 끊깁니다.
문제는 토큰 차감과 채팅 저장이 response.completed 이벤트 수신 시점에 실행되는 구조였기 때문에, 연결이 끊기면 해당 단계에 도달하지 못하고 그대로 종료됩니다.
public Flux<String> responseChatDdSel(ChatStreamRequest streamRequest) {
...
return webClient.post()
...
.flatMap(clean -> {
JSONObject json = new JSONObject(clean);
...
switch (type) {
case "response.output_text.delta": {
...
return Mono.just(delta);
}
case "response.output_text.done": {
return Mono.just("[DONE]");
}
case "response.completed": {
...
return Mono.fromCallable(() -> {
// 튜니 토큰 차감
// 채팅 DB 저장
...
return "[COMPLETED]";
})
.subscribeOn(Schedulers.boundedElastic());
}
}
return Mono.empty();
});
}
결과적으로 OpenAI API 비용은 발생하지만, 사용자 토큰 차감과 채팅 기록 저장은 모두 누락됩니다. SSE 스트림 안에 내부 로직을 두는 구조는 클라이언트 연결 상태에 실행 여부가 종속된다는 근본적인 한계가 있습니다.
연결이 끊기는 과정을 좀 더 들여다보면,

Flux<String>으로 응답을 계속 흘려보내는 구조는 HTTP 커넥션을 계속 붙잡고 스트리밍하는 상태입니다.
그런데 브라우저가 페이지 새로고침 및 이동시 클라이언트에서 RST 패킷을 서버 OS로 전달하게 되고 소켓을 close 상태로 변경합니다. 스프링SSE 스트리밍은 계속해서 write()를 반복 호출 하지만 닫힌 커널이 "이미 닫힌 소켓"이라고 EPIPE 리턴 해서 Broken pipe이 발생합니다.
java.io.IOException: Broken pipe
at sun.nio.ch.SocketDispatcher.write0(Native Method) ← 여기서 터짐
...
at ReactiveTypeHandler$SseEmitterSubscriber.send()
→ AsyncRequestNotUsableException 으로 래핑되어 전파
Broken pipe 예외 처리로 로그 노이즈는 줄일 수 있지만, 비용 누수 문제의 근본 원인은 내부 로직이 SSE 스트림에 묶여 있다는 점입니다. 클라이언트 연결 상태에 따라 실행 여부가 결정되는 구조 자체가 문제입니다.
OpenAI 웹훅을 활용하면 클라이언트 연결 상태와 완전히 무관하게 response.completed 시점에 서버 간 통신으로 내부 로직을 실행할 수 있습니다.


OpenAI Project Setting에서 웹훅 엔드 포인트를 등록할수 있습니다. 저희는 Core concepts가 Responses API기 때문에 response.completed 상태로 전달 받습니다.