dev.to

Aborting a Fetch Doesn't Stop Your Node.js Server. Here's What Does.

Fetch를 취소해도 Node.js 서버는 계속 실행됩니다 — 실제 작업을 멈추는 방법

클라이언트에서 `fetch`를 취소해도 서버 핸들러와 하위 API 요청, PostgreSQL 쿼리는 자동으로 멈추지 않습니다. 응답 연결 종료를 감지해 `AbortSignal`을 전달하고, DB 쿼리는 별도 연결로 취소해야 합니다. Node.js 버전별 차이와 취소 뒤 트랜잭션·재시도를 다루는 방법도 실험으로 확인합니다.

AI 요약

클라이언트가 AbortController.abort()를 호출하면 클라이언트 쪽 fetch는 거부되고 서버 소켓은 닫힙니다. 하지만 그 신호가 서버의 비동기 핸들러나 하위 작업까지 자동으로 전달되지는 않습니다. 글쓴이는 Node.js 22.22.2와 24.21.0, node:http, Express 4·5, Hono, pg 8.23.1, PostgreSQL 18.4를 로컬 HTTP/1.1 환경에서 테스트했습니다. 100ms 뒤 클라이언트가 떠나는 요청 20개를 보냈더니 취소 처리가 없는 서버는 예정된 작업 100단계를 모두 실행했고, 연결 종료를 감지하도록 바꾸자 한 단계도 실행하지 않았습니다.

연결 종료를 `AbortSignal`로 전달하기

ServerResponse의 close 이벤트는 응답이 정상 종료될 때도, 연결이 먼저 끊길 때도 발생합니다. 따라서 res.writableFinished를 확인해 정상 완료와 클라이언트 이탈을 구분해야 합니다. 글쓴이는 요청의 close 이벤트에는 기대지 않았습니다. 테스트에서는 연결 종료 때 발생했지만, 문서가 보장하는 의미는 요청 완료라서 안정적인 판단 기준으로 삼지 않았습니다.

제안한 requestSignal 헬퍼는 응답이 끝나기 전에 연결이 닫히면 AbortController를 중단하고, 요청별 시간 제한이 있으면 AbortSignal.timeout()과 AbortSignal.any()로 합칩니다. 이 신호를 fetch 같은 하위 작업에 전달하면 클라이언트 이탈이나 기한 초과 때 후속 요청도 닫힙니다. 테스트에서는 수동으로 연결한 취소가 클라이언트에서 API를 거쳐 하위 서비스까지 약 25ms 만에 전달됐습니다. 시간 제한 신호는 모듈 수준에서 한 번 만들지 말고 요청마다 생성해야 합니다. 타이머가 생성 시점부터 작동하기 때문입니다. 또 시간 제한은 AbortError가 아닌 TimeoutError를 던지므로 error.name을 확인해야 합니다.

Node.js 내장 req.signal도 버전별로 결과가 달랐습니다. 테스트한 Node 22에서는 node:http와 Express 4·5의 req.signal이 undefined였습니다. Node 24에서는 신호가 생겼고 연결 종료 때 핸들러도 멈췄습니다. 다만 Node 24.16~24.19, 26.1~26.6에서는 정상 요청이 끝난 뒤에도 신호가 중단될 수 있습니다. 이 문제는 24.20.0과 26.7.0에서 수정됐습니다. Hono 4.13.12와 @hono/node-server 2.1.3의 c.req.raw.signal은 테스트한 두 Node 버전 모두에서 동작했습니다.

PostgreSQL 쿼리는 별도로 취소해야 합니다

pg 8.23.1은 쿼리에 { signal }을 넘겨도 오류를 내지 않고 신호를 무시했습니다. select pg_sleep(3) 실행 중 클라이언트가 떠난 뒤에도 PostgreSQL은 쿼리를 약 2.6초 더 실행했습니다. 실제 쿼리라면 DB 연결을 붙잡고 CPU를 낭비하거나 잠금을 유지할 수 있습니다. 이 결과는 테스트한 pg 버전에 한정되므로 다른 드라이버도 직접 확인해야 합니다.

글쓴이는 실행 중인 쿼리를 취소할 별도 연결에서 pg_cancel_backend(pid)를 호출했습니다. 취소된 쿼리는 오류 코드 57014를 반환했습니다. 취소 요청은 작업용 풀과 분리한 작은 풀에서 보내야 합니다. 작업용 풀이 가득 찬 상태에서 같은 풀로 취소를 요청하면, 핸들러가 쿼리 연결을 점유한 채 빈 연결을 기다리는 교착 상태가 생길 수 있습니다. 실제로 작업용 풀 연결이 2개일 때 요청 2개가 동시에 취소되자 멈춤을 재현했습니다.

완전한 핸들러를 작성할 때도 주의할 점이 있습니다. PID를 조회한 직후 이미 신호가 중단됐는지 확인해야 합니다. 취소 리스너를 제거하고 진행 중인 취소 요청을 기다린 뒤 연결을 풀에 반환해야 합니다. 57014만으로 취소 원인을 알 수는 없습니다. 기한 초과, 클라이언트 이탈, statement_timeout, 관리자 취소가 모두 같은 코드로 나타나므로 signal.reason을 확인해야 합니다. 취소가 확실히 적용된다는 보장도 없습니다. 쿼리가 이미 끝난 뒤 취소 요청이 도착할 수 있기 때문입니다.

트랜잭션과 동기식 작업

쿼리 취소만으로 트랜잭션이 롤백되지는 않습니다. 테스트한 핸들러는 INSERT 뒤 느린 작업을 하다가 클라이언트가 떠나도 주문을 커밋했습니다. 취소를 감지한 뒤 롤백하도록 작성하면 행이 저장되지 않았습니다. 다만 송금처럼 사용자가 연결을 끊어도 완료해야 하는 작업도 있습니다. 중단 시 커밋할지 롤백할지는 의도에 따라 명시해야 합니다. POST 요청은 서버가 처리했는지 클라이언트가 알 수 없으므로, 무조건 재시도하면 부작용이 중복될 수 있습니다. 멱등성 키를 사용해야 합니다.

JavaScript에서 이미 실행 중인 동기식 코드는 중간에 중단할 수 없습니다. 1초짜리 동기 루프에서 클라이언트가 200ms 뒤 이탈해도 서버는 루프가 끝날 때까지 연결 종료를 처리하지 못했습니다. CPU 작업을 워커 스레드로 옮기거나, await 사이에 작업을 나눠 신호를 확인해야 합니다. 글쓴이는 모든 요청에 요청별 AbortSignal.timeout()과 PostgreSQL statement_timeout을 두고, 신호를 받을 수 있는 하위 작업에 전달하며, 부작용이 있는 작업에는 롤백 정책과 멱등성 키를 마련하라고 권합니다.

dev.to 반응

  • @dhruv_malaviya — node-postgres가 { signal }을 받아들이면서 무시한다는 점이 가장 위험한 발견입니다. 옵션이 오류를 내는 것보다 받아들이고 아무 일도 하지 않는 편이 더 나쁩니다. 타입과 런타임 모두 개발자가 올바르게 처리했다고 믿게 만들기 때문입니다. PostgreSQL 취소 요청은 별도 연결이 필요하므로, 풀을 다 쓰고 있으면 취소하려는 작업 뒤에서 대기합니다. 취소에는 전용 연결이 필요합니다. 그리고 실행 중인 JavaScript는 취소할 수 없습니다. 빡빡한 루프는 구조상 중단할 수 없으니 명시적으로 확인 지점을 둬야 합니다.
    • @shubhradev — 동의합니다. 제가 테스트한 8.23.1 기준으로는 다른 환경에서도 확인해 볼 만한 부분입니다. 옵션이 오류를 내면 알아차리기 쉽지만, 취소하지 않으면서 받아들이면 중단됐다고 생각한 쿼리가 PostgreSQL에서 계속 실행되는 상황을 직접 확인해야 합니다. 작업용 풀 연결이 2개일 때 요청 2개가 동시에 중단되자 두 핸들러가 연결을 잡은 채 같은 풀에서 취소 연결을 기다려 아무것도 진행되지 않았습니다. 그래서 전체 핸들러에서는 취소용 별도 풀을 씁니다. 동기식 1초 루프도 말씀하신 대로입니다. 루프가 끝날 때까지 서버가 연결 종료를 처리하지 못했고, 그때는 res.writableFinished가 이미 true라 클라이언트가 떠난 사실을 판별하지 못했습니다. await 사이에 나누면 이벤트 루프가 연결 종료를 처리할 기회가 생기지만, await만으로 작업이 중단되는 건 아니므로 각 구간에서 signal.aborted를 확인해야 합니다.
  • @leob — 자세한 부분까지 파고들어 알아낸 점이 정말 좋습니다. 대부분의 개발자는 클라이언트가 떠났을 때 서버 프로세스까지 중단하는 일은 신경 쓰지 않는 것 같습니다. 무시하지 않는 편이 좋은 경우도 있겠지만요.
    • @shubhradev — 클라이언트의 취소가 성공한 것처럼 보이기 때문에 놓치기 쉽습니다. 서버는 소켓 종료를 감지해도 핸들러나 이미 시작한 작업을 멈추지는 않습니다. 비용이 큰 작업이나 부작용이 있는 작업에서는 문제가 됩니다. 테스트에서는 클라이언트가 떠난 뒤에도 pg_sleep(3)이 약 2.6초 더 실행됐고, 단순한 트랜잭션은 이미 떠난 사용자의 주문을 커밋했습니다.
    • @leob — “이미 떠난 사용자의 주문을 단순한 트랜잭션이 커밋했다”는 말에 대해선, 은행 웹 앱에서 100달러 송금을 입력하고 전송을 누른 뒤 브라우저를 빨리 닫았다면, 연결이 끊겼어도 송금이 처리될 거라고 예상해야 합니다.
    • @shubhradev — 맞습니다. 송금이라면 처리하는 편이 옳습니다. 제 테스트에서 문제였던 점은 아무도 그 결정을 내리지 않았다는 것입니다. 핸들러가 클라이언트의 이탈을 알아차리지 못해 그냥 커밋했습니다. 부작용이 있는 작업은 취소 때 커밋할지 롤백할지를 명시해야 합니다. 재시도로 중복 처리되지 않도록 멱등성 키도 써야 합니다.
  • @hemapriya_kanagala — 클라이언트에서 fetch를 취소해도 서버와 PostgreSQL 쿼리가 계속 실행될 수 있다는 걸 몰랐습니다. 정말 흥미롭네요.

원문: dev.to / 번역·요약: Trawling