백프레셔
백프레셔 (Back-pressure)
이 페이지는 스트림 작업 시 백프레셔와 취소를 이해하는 데 집중합니다. AI SDK를 쓰는 데 이 정보를 알 필요는 없지만, 관심 있는 이를 위해 SDK가 응답을 최적으로 스트리밍하는 이유와 방식을 더 깊이 설명합니다.
간단한 예제 프로그램을 통해 백프레셔와 취소를 탐구합니다. 성급한(eager) 접근에서 발생할 수 있는 문제를 논의하고 느긋한(lazy) 접근이 그것을 어떻게 해결하는지 보여줍니다.
출처: 공식문서
본문
스트림의 백프레셔와 취소
양의 정수를 생성하는 async-generator, 그 생성기를 감싸는 ReadableStream, 스트림에서 값을 읽는 reader를 만듭니다. 생성기는 100ms sleep, reader는 1초 sleep으로 데이터를 처리하는 데 임의의 시간이 걸립니다.
백프레셔
이 프로그램을 실행하면 "yield" 로그가 "read" 로그마다 대략 10개씩 나오는 것을 볼 수 있습니다. 생성기가 reader가 꺼내는 것보다 10배 빠르게 값을 밀어넣을 수 있기 때문입니다. 하지만 이것은 문제를 나타냅니다. stream은 밀어넣었지만 꺼내지 않은 항목의 큐를 계속 확장해서 유지해야 합니다.
문제는 생성기를 스트림으로 감싸는 방식에서 비롯됩니다. start 핸들러 안의 for await (…)는 성급한(eager) for 루프로, 생성기에서 다음 값을 가져와 스트림에 인큐하려고 계속 실행됩니다. 이는 스트림이 백프레셔(소비자가 아직 더 많은 값이 필요하지 않다는 생산자로의 신호)를 존중하지 않는다는 뜻입니다. 데이터가 더 필요 없을 때 이 스레드가 멈추도록 신호를 보낼 방법도 없습니다.
이를 고치려면 ReadableStream이 pull 핸들러를 허용합니다. pull은 소비자가 스트림에서 더 많은 데이터를 읽으려고 할 때마다(내부에 이미 대기된 데이터가 없으면) 호출됩니다. 하지만 for await(…)를 pull로 옮기는 것만으로는 부족합니다. 성급한 인큐에서 느긋한(lazy) 인큐로 바꿔야 합니다. 이 두 변경으로 소비자에 반응할 수 있습니다. 더 많은 데이터가 필요하면 쉽게 생산하고, 아니면 불필요한 작업에 시간을 쓰지 않습니다.
function createStream(iterator) {
return new ReadableStream({
async pull(controller) {
const { value, done } = await iterator.next();
if (done) {
controller.close();
} else {
controller.enqueue(value);
}
},
});
}
이제 iterator.next()를 직접 호출해야 합니다. 이는 { done: boolean, value: T } 시그니처의 객체 Promise를 반환합니다. done이 true면 생성기가 더 이상 값을 내지 않을 것이므로 close해야 합니다. 아니면 새 값을 enqueue합니다.
이제 "yield"와 "read" 로그가 짝을 이룹니다. 더 이상 read마다 10배의 정수를 생성하지 않습니다. 그리고 스트림은 내부 버퍼에 1개 항목만 유지하면 됩니다. 소비자에게 제어권을 줘서, 필요할 때 새 값을 생산하도록 한 것입니다.
취소
초기 성급한 예시로 돌아가서, 10,000개가 아니라 3개만 읽는다고 해 봅시다. 3개를 읽은 뒤에도 계속 새 값을 생성하는 것을 볼 수 있습니다. reader가 더 이상 값을 읽지 않을 것을 알지만 스트림은 모릅니다. 성급한 for await (…)는 프로그램 메모리를 다 쓸 때까지 스트림 버퍼에 새 값을 시끄럽게 인큐하며 무한히 계속됩니다.
해결책은 똑같습니다: pull과 수동 반복을 사용하세요. 값을 느긋하게 생산함으로써 정수 생성기의 수명을 reader의 수명에 묶습니다. 읽기가 멈추면 생성도 멈춥니다. 백프레셔를 구현하는 것과 같은 해결책이므로, 이 둘은 같은 문제의 두 측면입니다: 스트림에 값을 밀어넣는 것은 느긋하게 해야 하고, 성급하게 하면 예상된 문제가 발생합니다.
스트림 느긋함을 AI 응답에 연결
제품에 AIBot 서비스를 통합한다고 가정해 봅시다. 사용자가 "1부터 무한까지 세"라고 프롬프트하고, 브라우저가 AI API 엔드포인트를 fetch하며, 서버가 AIBot에 연결해 응답을 받습니다. 하지만 "무한"은 무한입니다. 응답은 끝나지 않을 것입니다.
몇 초 후 사용자가 지루해져 내비게이션으로 떠납니다. 브라우저는 API 엔드포인트와의 연결을 끊었지만, 서버는 AIBot과의 연결을 끊을까요? 성급한 for await (...) 접근을 썼다면 연결은 계속 돌아가고 서버는 AIBot에 점점 더 많은 데이터를 요청합니다. 서버는 "스레드"를 띄웠고 성급한 풀을 끝낼 신호가 없습니다. 결국 서버는 메모리가 고갈됩니다(버퍼링된 응답을 읽어 해제할 활성 fetch 연결이 없으므로).
느긋한 접근으로는 이것이 자동으로 처리됩니다. 스트림은 소비자가 요청할 때만 AIBot에서 새 데이터를 요청하므로, 페이지에서 떠나면 자연스럽게 모든 리소스가 해제됩니다. fetch 연결이 중단되고 서버가 응답을 정리할 수 있습니다. 그 응답에 묶인 ReadableStream은 가비지 컬렉션될 수 있고, 그러면 AIBot에 대한 연결도 해제됩니다.