2026. 5. 4.·base·
Promise로 동시요청 처리하기
NestJS 백엔드 리팩토링
const A = (async () => { ... })() // 즉시실행 IIFEasync로 선언된 함수를 호출하면 즉시 Promise를 반환하고, 내부 비동기 작업이 완료되면 해당 Promise가 resolve된다. (async 함수임을 보여주기 위해 예시는 즉시실행 화살표함수로 작성)
이 특성을 이용하면 동시에 들어온 여러 요청을 하나의 비동기 작업을 합쳐서 처리할 수 있다. (in-flight dedupe)
원리는 첫번째 요청이 왔을때 비동기 작업을 시작한다음 Promise를 상위 스코프 변수로 할당하고, 다음 요청이 들어왔을 때 이 변수에 값이 존재하면 Promise를 반환하고 없으면 이전 로직을 실행하도록 하는 것이다.
동일한 Promise를 여러 요청이 await하면, 작업이 완료되는 순간 모든 요청이 같은 값으로 함께 resume 된다.
예시를 통해 알아보자.
@Injectable()
export class RedisCampaignCacheRepository implements CampaignCacheRepository {
private readonly logger = new Logger(RedisCampaignCacheRepository.name);
private readonly KEY_PREFIX = 'campaign:';
private readonly CAMPAIGN_CACHE_TTL = 60 * 60 * 24;
private readonly ALL_CAMPAIGNS_CACHE_TTL_MS = 10_000; // RTB decision hot path (짧은 TTL로 Redis SCAN/JSON.GET 비용 완화)
private allCampaignsCache: {
value: CachedCampaign[];
expiresAtMs: number;
} | null = null;
private allCampaignsInFlight: Promise<CachedCampaign[]> | null = null;위 코드에서 allCampaignsCache는 비동기작업이 완료된 후 반환하는 모든 캠페인 목록을 저장하는 변수고, allCampaignsInFlight가 바로 현재 진행 중인 비동기 작업의 Promise값을 저장하는 변수다.
async getAllCampaigns(): Promise<CachedCampaign[]> {
const nowMs = Date.now();
// 전체 캠페인 캐시값이 존재하고 TTL이 안지났으면 그 값을 리턴
const cached = this.allCampaignsCache;
if (cached && cached.expiresAtMs > nowMs) {
return cached.value;
}
if (this.allCampaignsInFlight) {
return this.allCampaignsInFlight;
}
const work = (async () => {
try {
const pattern = `${this.KEY_PREFIX}*`;
const keys: string[] = [];
// SCAN으로 모든 campaign:* 키 조회
let cursor = '0';
do {
const result = await this.ioredisClient.scan(
cursor,
'MATCH',
pattern,
'COUNT',
100
);
cursor = result[0];
keys.push(...result[1]);
} while (cursor !== '0');
if (keys.length === 0) {
return [];
}
// JSON.GET는 다건 호출 시 latency가 커져 in-flight 요청이 쌓이며 heap spike로 이어질 수 있음
// → pipeline + batch로 라운드트립을 줄입니다.
const campaigns: CachedCampaign[] = [];
const BATCH_SIZE = 200;
for (let i = 0; i < keys.length; i += BATCH_SIZE) {
const batchKeys = keys.slice(i, i + BATCH_SIZE);
const pipeline = this.ioredisClient.pipeline();
batchKeys.forEach((key) => {
pipeline.call('JSON.GET', key);
});
const results = await pipeline.exec();
if (!results) continue;
results.forEach(([error, result], idx) => {
if (error) {
this.logger.warn(`캠페인 조회 실패: ${batchKeys[idx]}`, error);
return;
}
if (result && typeof result === 'string') {
try {
campaigns.push(JSON.parse(result) as CachedCampaign);
} catch (parseError) {
this.logger.warn(
`캠페인 JSON 파싱 실패: ${batchKeys[idx]}`,
parseError
);
}
}
});
}
return campaigns;
} catch (error) {
this.logger.error('모든 캠페인 조회 실패', error);
return [];
} finally {
this.allCampaignsInFlight = null;
}
})();
this.allCampaignsInFlight = work;
const campaigns = await work;
this.allCampaignsCache = {
value: campaigns,
expiresAtMs: Date.now() + this.ALL_CAMPAIGNS_CACHE_TTL_MS,
};
return campaigns;
}이 구현에서 중요한 포인트는 시작 지점과 대기 시점을 분리했다는 점이다.getAllCampaigns메서드는 첫 요청에서 비동기 작업을 시작하고 그 Promise를 work라는 변수에 할당한다.
이 resolve되지 않은 Promise는 allCampaignsInFlight에 할당되고 다음 라인에서 await work를 만나면 이전에 실행한 (async () => {...}()는 완료될때까지 대기상태에 들어가고 이벤트루프는 다른 요청을 처리한다.
await work하는 동안 들어오는 다른 요청들은 아래 코드로 인해 새로운 작업을 시작하지 않고 이전 요청에서 resolve되지 않은 동일한 Promise를 반환받게 된다.
async getAllCampaigns(): Promise<CachedCampaign[]> {
...
if (this.allCampaignsInFlight) {
return this.allCampaignsInFlight;
}결국 work가 resolve되면 Promise를 반환받은 await getAllCampaigns모든 요청이 동일한 결과로 함께 resume 된다.
그 결과로 요청이 동시에 몰려도 SCAN + JSON.GET + JSON.parse의 무거운 작업을 요청 수 만큼 전부 실행하지 않고 첫 요청 1번만 실행하고 나머지는 같은 Promise로 처리하도록 최적화를 할 수 있게 되는 것이다.
대신 그 1번의 작업이 느리면 그 순간 들어온 요청들이 전부 느려진다는 주의점도 있다.
Join the thread
Leave feedback, ask for clarification, or keep a focused discussion attached to this article.