Vercel KV는 웹 프로젝트에서 매우 유용한 도구지만, 광범위하게 사용하다 보면 HTTP 요청 수가 급격히 늘어나 성능에 악영향을 줄 수 있습니다. Redis 파이프라인은 여러 명령을 묶어 요청 수를 줄이는 방법을 제공하지만, 구현이 만만치 않습니다. 그렇다면 파이프라인의 효율성과 기본 Redis 명령의 간편함을 동시에 누릴 방법은 없을까요? 자동 파이프라이닝(auto-pipelining)이 바로 그 해답입니다. 복잡한 코딩 없이도 성능을 매끄럽게 개선할 수 있습니다.
문제 상황
Redis를 데이터 소스로 폭넓게 활용하는 웹페이지를 만들었다고 가정해 보겠습니다. 여러 컴포넌트가 각자 Redis 호출을 수행하고, 일부 컴포넌트는 서로 다른 콘텐츠로 여러 번 렌더링됩니다.
이런 구조에서는 웹페이지를 열 때마다 Redis로 상당한 수의 요청이 발생합니다. 요청량 자체가 큰 오버헤드가 되어 성능을 저하시킵니다. 특히 Cloudflare Workers처럼 동시 HTTP 요청 수가 엄격하게 제한된 환경에서는 치명적인 문제가 됩니다.
일반 파이프라인
이런 상황의 전통적인 해결책은 Redis 파이프라인을 사용하는 것입니다. 요청을 하나씩 보내는 대신, 파이프라인을 활용하면 여러 명령을 모아 한 번에 실행할 수 있습니다. 하나의 HTTP 요청에 여러 Redis 명령이 담기므로 HTTP 요청 수가 크게 줄고 성능이 향상됩니다.
import { kv } from '@vercel/kv';
const pipeline = kv.pipeline();
pipeline.set("foo", "bar");
pipeline.get("foo");
const res = await pipeline.exec();
console.log(res); // ["OK", "bar"]
파이프라인의 단점
하지만 파이프라인은 개발자 관점에서 상당한 부담을 안겨줍니다. 파이프라인 API는 표준 Redis API와 다르기 때문에, 이를 켜거나 끄는 작업이 결코 간단하지 않습니다.
또한 하나의 파이프라인 안에서 서로 다른 컴포넌트의 데이터를 요청해야 하는 경우, 데이터 조회 로직을 실제 데이터가 사용되는 위치와 분리해서 작성해야 합니다. 이런 분리 구조는 코드베이스를 산발적으로 만들고 유지보수성을 떨어뜨립니다.
자동 파이프라이닝(Auto-Pipelining)
자동 파이프라이닝은 이러한 문제를 매끄럽게 해결합니다. 기존 코드를 전혀 수정하지 않고도 파이프라인 기능을 활성화할 수 있습니다. 평소처럼 Redis를 사용하면 되고, Redis 클라이언트가 가능한 경우 명령들을 자동으로 묶어 처리하므로 별도의 노력 없이 성능이 향상됩니다.
자동 파이프라이닝이 여러 키의 값을 조회하는 흔한 시나리오를 어떻게 최적화하는지 살펴보겠습니다.
const keys = ["key1", "key2", "key3"];
const values = await Promise.all(keys.map(key => kv.get(key)));
파이프라인 없이는 이 코드가 Redis로 세 개의 HTTP 요청을 보냅니다. 하지만 자동 파이프라이닝이 활성화되면 이 요청들은 하나의 HTTP 요청으로 묶입니다.
관용적인 React Server Components를 위한 자동 파이프라인
React Server Components(RSC)는 자체적으로 데이터를 가져올 수 있습니다. 대표적인 예로 트윗 컴포넌트를 다음과 같이 구현할 수 있습니다.
async function Tweet({id}) {
const tweet = kv.get(`tweets:${id}`)
return <div>{tweet.text}</div>
}
이 컴포넌트를 아래와 같이 반복문으로 호출하면
{tweetIds.map(id => <Tweet id={id} />)}
앞선 예제와 마찬가지로 N개의 백엔드 요청이 발생합니다. 자동 파이프라이닝을 활성화하면 N개의 명령이 하나의 파이프라인으로 묶이면서도, 관용적인 React 코드 스타일은 그대로 유지됩니다.
동작 원리
자동 파이프라이닝은 백그라운드에서 '활성 파이프라인(active pipeline)'을 유지하는 방식으로 동작합니다. 각 명령은 스스로 파이프라인에 추가된 뒤 deferExecution을 호출합니다.
private async deferExecution() {
await Promise.resolve()
return await Promise.resolve()
}
deferExecution이 호출되면 해당 명령은 Node.js 메인 스레드의 제어권을 내려놓습니다. 그러면 다음 GET 명령이 스레드 제어권을 얻어 실행을 진행하는데, 첫 번째 GET과 정확히 같은 동작을 수행합니다. 즉, 자신을 활성 파이프라인에 추가하고 스레드 제어권을 다시 내려놓습니다.
다음은 자동 파이프라인 로직을 의사 코드(pseudo-code)로 나타낸 것입니다.
let activePipeline: Pipeline;
let pipelinePromises: new WeakMap<Pipeline, Promise<Array<unknown>>>();
let commandIndex: number;
const executeCommand = (command) => {
activePipeline = activePipeline || createNewPipeline();
activePipeline.addCommand(command);
commandIndex++;
const pipelinePromise = deferExecution().then(() => {
if (!pipelinePromises.has(activePipeline) {
const pipelinePromise = pipeline.exec();
pipelinePromises.set(pipeline, pipelinePromise);
activePipeline = null;
commandIndex = 0
};
return pipelinePromises.get(activePipeline)!;
});
const result = await pipelinePromise;
return result[commandIndex];
};
세 번째 GET 명령도 활성 파이프라인에 자신을 추가한 뒤 deferExecution을 호출합니다. 이 시점에는 더 이상 제어권을 넘길 다른 명령이 없으므로, 대기 중이던 GET 명령 중 하나가 제어권을 다시 얻어 파이프라인을 실행합니다.
예제에서 이 파이프라인은 세 개의 결과를 반환하며, 각각 초기 명령에 대응합니다. 각 명령은 자신의 인수 인덱스를 추적하여 배치 응답에서 올바른 결과를 받도록 보장합니다.
자동 파이프라이닝 로직의 구현 코드는 공개되어 있습니다.
v0.dev에서의 자동 파이프라이닝
자동 파이프라이닝이 v0.dev 성능에 미친 영향은 랜딩 페이지 개선에서 뚜렷하게 드러납니다. 랜딩 페이지는 과거 쿼리와 생성 결과 예시를 보여주는데, 먼저 표시할 항목 목록을 가져온 뒤 각 항목마다 개별 페치를 수행하여 수많은 Redis 요청이 발생했습니다.

자동 파이프라이닝을 활성화하자 이 과정이 스위치 하나만으로 최적화되었습니다. 이전에는 여러 번의 개별 페치로 이루어졌던 두 번째 단계가 이제 하나의 파이프라인 연산으로 통합됩니다.
자동 파이프라이닝 도입 전에는 이러한 Redis 요청이 수많은 개별 HTTP 요청으로 이어져 페이지 로딩 속도가 느려졌습니다. 하지만 자동 파이프라이닝을 활성화하면 요청들이 효율적으로 파이프라인으로 묶여 필요한 HTTP 요청 수가 크게 줄어듭니다. 그 결과 페이지 로딩 시간이 약 450ms에서 약 200ms로 개선되었습니다.
v0 팀은 처음에 자동 파이프라이닝을 자체 사용 목적의 임시 기능(hack)으로 출시했습니다. 현재는 Upstash Redis 클라이언트 v1.31.3과 Vercel KV v2.0에서 자동 파이프라이닝을 사용할 수 있습니다.