Back to feed
ASH avatar
ASH

2026. 5. 4.·base·

Promise로 동시요청 처리하기

NestJS 백엔드 리팩토링

nestjs
ts
const A = (async () => { ... })() // 즉시실행 IIFE

async로 선언된 함수를 호출하면 즉시 Promise를 반환하고, 내부 비동기 작업이 완료되면 해당 Promise가 resolve된다. (async 함수임을 보여주기 위해 예시는 즉시실행 화살표함수로 작성)

이 특성을 이용하면 동시에 들어온 여러 요청을 하나의 비동기 작업을 합쳐서 처리할 수 있다. (in-flight dedupe)

원리는 첫번째 요청이 왔을때 비동기 작업을 시작한다음 Promise를 상위 스코프 변수로 할당하고, 다음 요청이 들어왔을 때 이 변수에 값이 존재하면 Promise를 반환하고 없으면 이전 로직을 실행하도록 하는 것이다.
동일한 Promise를 여러 요청이 await하면, 작업이 완료되는 순간 모든 요청이 같은 값으로 함께 resume 된다.

예시를 통해 알아보자.

ts
@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값을 저장하는 변수다.

ts
 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를 반환받게 된다.

ts
 async getAllCampaigns(): Promise<CachedCampaign[]> {
	...
    if (this.allCampaignsInFlight) {
      return this.allCampaignsInFlight;
    }

결국 work가 resolve되면 Promise를 반환받은 await getAllCampaigns모든 요청이 동일한 결과로 함께 resume 된다.

그 결과로 요청이 동시에 몰려도 SCAN + JSON.GET + JSON.parse의 무거운 작업을 요청 수 만큼 전부 실행하지 않고 첫 요청 1번만 실행하고 나머지는 같은 Promise로 처리하도록 최적화를 할 수 있게 되는 것이다.

대신 그 1번의 작업이 느리면 그 순간 들어온 요청들이 전부 느려진다는 주의점도 있다.

0
Comments

Join the thread

Leave feedback, ask for clarification, or keep a focused discussion attached to this article.

0 comments
No comments yet. Start the first thread for this article.
Current user avatar
Styling with Markdown is supported