레거시 비동기 코드를 Async/Await로 마이그레이션

기존 비동기 코드를 프로덕션 환경에 영향을 주지 않고 async/await로 마이그레이션하는 방법

2009년 Node.js가 등장했을 당시 JavaScript의 콜백 모델은 비동기 I/O를 위한 유일한 메커니즘이었습니다. ES6에서 Promise가 도입되고 ES2017에서 async/await가 추가될 무렵, 대부분의 프로덕션 코드베이스에는 이미 수년간 콜백 기반 로직이 구현되어 있었습니다. 중첩된 오류 우선 핸들러, 클로저를 통해 전달되는 공유 가변 상태, 3단계 깊이의 익명 함수에 내장된 재시도 로직 등이 그 예입니다. 구문은 바뀌었지만, 실행 중인 코드는 그대로였습니다. 오늘날 대부분의 엔지니어링 팀은 바로 이러한 상황을 물려받았습니다. 새로운 패턴과 기존 패턴이 공존하는 코드베이스, 팀은 마이그레이션을 원하지만 마이그레이션하는 동안 시스템을 멈출 수 없는 상황 말입니다.

비동기 코드베이스를 안전하게 마이그레이션하세요

SMART TS XL 또한 코드 한 줄을 변경하기 전에 전체 코드베이스에 걸쳐 마이그레이션 위험을 식별합니다.

지금 탐색

다행인 점은 마이그레이션에 코드 재작성이 필요하지 않다는 것입니다. 콜백, 프로미스, async/await는 명확하게 정의된 경계 내에서 상호 운용 가능합니다. Node.js는 이러한 기능을 제공합니다. util.promisify 오류 우선 콜백과 Promise 반환 함수 사이의 간극을 메우기 위해 이러한 방식이 사용됩니다. 래퍼 레이어를 통해 전환 과정에서 기존 코드와 새 코드가 공존할 수 있습니다. 모듈 단위로 점진적으로 마이그레이션하면 코드베이스가 발전하는 동안에도 프로덕션 환경은 계속 운영될 수 있습니다. 핵심 과제는 변환 자체가 아니라 체계적인 변환 방식입니다. 즉, 어떤 콜백을 먼저 변환하는 것이 안전한지, 어떤 안티 패턴이 단순하게 재작성될 경우 버그를 유발하는지, 그리고 변환된 각 함수가 기존 함수와 동일하게 동작하는지 어떻게 검증하는지 이해하는 것이 중요합니다.

콜백 모델 이해하기 및 대규모 환경에서 문제가 발생하는 이유

Node.js 콜백 규칙은 간단합니다. 비동기 작업을 수행하는 함수는 마지막 인수로 콜백 함수를 받습니다. 콜백 함수는 첫 번째 인수로 오류를, 두 번째 인수로 결과를 받습니다. Node.js의 모든 표준 라이브러리 함수는 이 규칙을 따릅니다. fs.readFile, http.get, child_process.exec, 그리고 수백 개가 더 있습니다.

자바 스크립트

// Standard Node.js error-first callback pattern
const fs = require('fs');

fs.readFile('config.json', 'utf8', (err, data) => {
  if (err) {
    console.error('Failed to read config:', err);
    return;
  }
  const config = JSON.parse(data);
  console.log('Config loaded:', config);
});

이 패턴은 단일 작업에는 직관적입니다. 하지만 여러 작업을 연결해야 할 때는 문제가 발생하는데, 각 후속 작업이 이전 콜백 함수 내에서 시작되어야 하기 때문입니다. 읽기, 변환, 쓰기의 3단계 시퀀스는 3단계의 중첩된 콜백을 생성하고, 5단계 시퀀스는 5단계의 중첩된 콜백을 생성합니다. 개발자들이 "콜백 지옥" 또는 "파멸의 피라미드"라고 부르는 이 구조는 단순히 미적인 문제만이 아닙니다. 깊게 중첩된 콜백은 오류 처리를 일관성 없게 만들고(각 단계마다 오류 인수를 독립적으로 확인해야 함), 실행 순서를 파악하기 어렵게 하며, 데이터 흐름이 명시적이지 않고 암묵적이기 때문에 리팩토링을 위험하게 만듭니다.

실제 운영 시스템에서 더 심각한 문제는 콜백 함수에 자체적인 조정 메커니즘이 없다는 점입니다. 두 개의 비동기 작업을 실행하고 두 작업이 모두 완료될 때까지 기다리려면 수동으로 카운터를 추적해야 합니다. 배열에 대한 일련의 작업을 실행하려면 재귀 패턴이나 타사 라이브러리를 사용해야 합니다. async이러한 복잡성은 함수 시그니처에서는 전혀 드러나지 않습니다. 콜백 기반 API와 그 위에 구축된 동시성 오케스트레이션은 오류가 발생하기 전까지는 겉으로 보기에 동일해 보입니다.

콜백 지옥의 실제 모습은 어떨까요?

사용자 데이터를 읽고, 권한을 검증하고, 접근 기록을 남기는 Node.js 서비스의 실제 콜백 피라미드 구조입니다.

자바 스크립트

// Three-level callback pyramid -- representative of real legacy code
function getUserReport(userId, callback) {
  db.query('SELECT * FROM users WHERE id = ?', [userId], (err, user) => {
    if (err) return callback(err);
    if (!user) return callback(new Error('User not found'));

    permissions.check(userId, 'read:reports', (err, allowed) => {
      if (err) return callback(err);
      if (!allowed) return callback(new Error('Permission denied'));

      auditLog.write({ userId, action: 'read:reports' }, (err) => {
        if (err) return callback(err);
        // Finally, the actual work
        callback(null, buildReport(user));
      });
    });
  });
}

오류 처리가 모든 단계에서 반복됩니다. 들여쓰기 때문에 데이터 흐름이 시각적으로 복잡해 보입니다. 네 번째 단계를 추가하려면 또 다른 중첩 레벨이 필요합니다. 이 함수를 테스트하려면 세 가지 종속성을 순서대로 모두 모킹해야 하며, 세 번째 레벨에서 오류를 시뮬레이션하려면 처음 두 가지가 성공하도록 모킹해야 합니다. 이러한 모든 특징은 체인이 길어질수록 더욱 악화됩니다.

util.promisify를 사용하여 콜백과 프로미스를 연결하는 방법

Node.js 8.0에서 도입됨 util.promisify이 함수는 표준 오류 우선 콜백 규칙을 따르는 모든 함수를 Promise를 반환하는 함수로 변환합니다. 이는 Node.js 코드베이스에서 async/await로 마이그레이션할 때 올바른 시작점입니다.

자바 스크립트

const { promisify } = require('util');
const fs = require('fs');

// Convert Node.js built-ins
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);

// Now usable with async/await
async function processConfig(path) {
  const data = await readFile(path, 'utf8');
  const config = JSON.parse(data);
  config.lastLoaded = Date.now();
  await writeFile(path, JSON.stringify(config, null, 2), 'utf8');
  return config;
}

util.promisify 이 메서드는 오류 우선 규칙을 자동으로 처리합니다. 콜백 함수가 null이 아닌 첫 번째 인수를 받으면 해당 오류와 함께 Promise가 거부됩니다. 콜백 함수가 null 첫 번째 인수와 결과를 받으면 해당 결과와 함께 Promise가 해결됩니다. 이 규칙을 따르는 대다수의 Node.js 코어 API 및 타사 라이브러리의 경우 별도의 래핑이 필요하지 않습니다.

Promisifying 사용자 지정 콜백 함수

Node.js 내장 함수가 아니지만 오류 우선 규칙을 따르는 사용자 지정 함수의 경우, util.promisify 동일하게 작동합니다:

자바 스크립트

const { promisify } = require('util');

// Your existing callback-based function
function fetchUserFromDB(userId, callback) {
  db.query('SELECT * FROM users WHERE id = ?', [userId], (err, rows) => {
    if (err) return callback(err);
    callback(null, rows[0] || null);
  });
}

// Promisified version -- no changes to the original function needed
const fetchUser = promisify(fetchUserFromDB);

// Use in async context
async function getUser(userId) {
  const user = await fetchUser(userId);
  if (!user) throw new Error(`User ${userId} not found`);
  return user;
}

이러한 접근 방식이 중요합니다. util.promisify 원래 함수를 수정하지 않고 그대로 감싸줍니다. 원래 콜백 버전은 계속 작동합니다. 아직 마이그레이션되지 않은 호출자는 콜백 버전을 계속 사용하고, 마이그레이션된 호출자는 프로미스화된 버전을 사용합니다. 이러한 공존 덕분에 점진적 마이그레이션이 가능합니다.

비표준 콜백 시그니처 처리

일부 오래된 라이브러리는 콜백 함수에 여러 개의 결과 값을 전달합니다. util.promisify 첫 번째 값으로만 ​​해석됩니다. 이러한 경우, util.promisify.custom 이 심볼을 사용하면 사용자 지정 프로미스화를 정의할 수 있습니다.

자바 스크립트

const { promisify } = require('util');

// A function that passes two results to its callback
function parseData(input, callback) {
  // callback(err, parsedData, metadata)
  callback(null, { value: input.trim() }, { length: input.length });
}

// Custom promisification that returns both results
parseData[promisify.custom] = (input) => {
  return new Promise((resolve, reject) => {
    parseData(input, (err, data, meta) => {
      if (err) reject(err);
      else resolve({ data, meta });
    });
  });
};

const parseDataAsync = promisify(parseData);
const result = await parseDataAsync('  hello  ');
// result === { data: { value: 'hello' }, meta: { length: 9 } }

콜백을 프로미스로 변환하기: 단계별 패턴

처리할 수 없는 코드의 경우 util.promisify 직접적으로, 수동 Promise 래퍼가 마이그레이션 경로입니다. 패턴은 일관적입니다.

자바 스크립트

// Step 1: Original callback-based function
function checkPermission(userId, resource, callback) {
  acl.check({ userId, resource }, (err, result) => {
    if (err) return callback(err);
    callback(null, result.allowed);
  });
}

// Step 2: Promise wrapper (coexists with the original)
function checkPermissionAsync(userId, resource) {
  return new Promise((resolve, reject) => {
    checkPermission(userId, resource, (err, allowed) => {
      if (err) reject(err);
      else resolve(allowed);
    });
  });
}

// Step 3: async/await consumer
async function authorizeRequest(userId, resource) {
  const allowed = await checkPermissionAsync(userId, resource);
  if (!allowed) {
    throw new Error(`${userId} does not have access to ${resource}`);
  }
}

앞서 설명한 3단계 콜백 피라미드를 async/await를 사용하여 다시 작성했습니다.

자바 스크립트

// After migration: same logic, linear structure
async function getUserReport(userId) {
  const user = await db.queryAsync('SELECT * FROM users WHERE id = ?', [userId]);
  if (!user) throw new Error('User not found');

  const allowed = await permissions.checkAsync(userId, 'read:reports');
  if (!allowed) throw new Error('Permission denied');

  await auditLog.writeAsync({ userId, action: 'read:reports' });

  return buildReport(user);
}

이제 오류 처리는 모든 단계에서 반복되는 대신 호출 지점의 단일 try/catch 구문에서 처리됩니다. 들여쓰기는 평평해졌습니다. 네 번째 단계를 추가하려면 한 단계가 더 필요합니다. await 테스트에서는 각 종속성을 순서에 상관없이 독립적으로 모킹해야 합니다.

Promise.all 및 Promise.allSettled를 사용한 병렬 실행

콜백에서 async/await로 마이그레이션할 때 가장 흔한 실수 중 하나는 병렬로 실행될 수 있는 작업을 순차적으로 실행하는 것입니다. 콜백은 병렬 실행을 너무 복잡하게 만들어서 많은 개발자가 기본적으로 순차적인 코드 체인을 작성하게 되었습니다. async/await는 병렬 처리를 순차적으로 보이게 하여 개발자가 콜백을 사용했을 때보다 느린 코드를 작성하게 만드는 오류를 범할 수 있습니다.

자바 스크립트

// WRONG: sequential execution -- each awaits the previous result
async function loadDashboardData(userId) {
  const profile = await fetchProfile(userId);       // 100ms
  const orders  = await fetchOrders(userId);        // 150ms
  const reviews = await fetchReviews(userId);       // 80ms
  return { profile, orders, reviews };              // Total: ~330ms
}

// RIGHT: parallel execution with Promise.all
async function loadDashboardData(userId) {
  const [profile, orders, reviews] = await Promise.all([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchReviews(userId),
  ]);
  return { profile, orders, reviews };              // Total: ~150ms
}

Promise.all 배열에 있는 Promise 중 하나라도 거부되면 호출도 거부합니다. 독립적인 작업들이 서로 영향을 주지 않고 실패할 수 있고 호출자가 모든 실패를 알아야 할 때 사용합니다. Promise.allSettled 올바른 도구입니다:

자바 스크립트

// Promise.allSettled: runs all, reports success or failure per operation
async function syncAllSources(userId) {
  const results = await Promise.allSettled([
    syncFromGitHub(userId),
    syncFromJira(userId),
    syncFromSlack(userId),
  ]);

  const failed = results
    .filter(r => r.status === 'rejected')
    .map(r => r.reason.message);

  if (failed.length > 0) {
    console.warn('Some syncs failed:', failed);
  }

  return results
    .filter(r => r.status === 'fulfilled')
    .map(r => r.value);
}

이 패턴은 수동 카운터나 라이브러리 없이는 콜백 기반 코드에서 자연스러운 대응 방식이 없습니다. async.parallel. 다음으로 마이그레이션 중 Promise.allSettled 이는 기존 비동기 코드베이스에서 가장 큰 영향을 미치는 변경 사항 중 하나인 경우가 많습니다.

루프 내 대기 안티 패턴 피하기

병렬 처리 대신 순차 처리를 하는 실수는 반복문 내부에서 가장 자주 발생합니다.

자바 스크립트

// WRONG: sequential -- processes items one at a time
async function processOrders(orderIds) {
  const results = [];
  for (const id of orderIds) {
    const result = await processOrder(id);  // blocks until each completes
    results.push(result);
  }
  return results;
}

// RIGHT: parallel -- all orders processed concurrently
async function processOrders(orderIds) {
  return Promise.all(orderIds.map(id => processOrder(id)));
}

// RIGHT (with concurrency limit): parallel but bounded
const pLimit = require('p-limit');
const limit = pLimit(5);  // max 5 concurrent

async function processOrders(orderIds) {
  return Promise.all(
    orderIds.map(id => limit(() => processOrder(id)))
  );
}

async/await로 콜백 기반 코드를 마이그레이션하는 과정에서 가장 흔하게 발생하는 회귀 오류 중 하나가 바로 루프 내 대기 패턴입니다. 콜백은 개발자가 동시성을 명시적으로 고려하도록 강제했지만, async/await는 이를 모호하게 만들기 때문입니다.

Async/Await에서의 오류 처리: 콜백 오류 전파 대체

콜백 함수는 관례적으로 오류를 전파합니다. 모든 콜백 함수의 첫 번째 인수는 오류 또는 null입니다. 이 방법은 작동하지만 호출하는 모든 쪽에서 오류 인수를 수동으로 확인해야 합니다. async/await는 JavaScript의 기본 try/catch 구문과 통합된 Promise 거부 메커니즘을 통해 오류를 전파합니다.

자바 스크립트

// Callback error propagation: repeated at every level
function processPayment(orderId, callback) {
  validateOrder(orderId, (err, order) => {
    if (err) return callback(err);  // propagate
    chargeCard(order.amount, (err, charge) => {
      if (err) return callback(err);  // propagate again
      updateInventory(orderId, (err) => {
        if (err) return callback(err);  // propagate again
        callback(null, charge.id);
      });
    });
  });
}

// Async/await: error propagation is automatic
async function processPayment(orderId) {
  const order  = await validateOrder(orderId);   // throws on error
  const charge = await chargeCard(order.amount); // throws on error
  await updateInventory(orderId);                // throws on error
  return charge.id;
}

마이그레이션 과정에서 가장 중요한 고려 사항은 오류 컨텍스트를 보존하는 것입니다. 콜백 기반 코드는 종종 각 단계에서 컨텍스트 정보가 추가된 오류를 전달합니다. async/await로 마이그레이션할 때는 오류 래핑이 이러한 컨텍스트를 보존하는지 확인해야 합니다.

자바 스크립트

// Preserving error context during async migration
async function processPayment(orderId) {
  let order;
  try {
    order = await validateOrder(orderId);
  } catch (err) {
    throw new Error(`Payment validation failed for order ${orderId}: ${err.message}`);
  }

  try {
    const charge = await chargeCard(order.amount);
    await updateInventory(orderId);
    return charge.id;
  } catch (err) {
    // Attempt rollback, then rethrow with context
    await refundCharge(order.amount).catch(console.error);
    throw new Error(`Payment processing failed for order ${orderId}: ${err.message}`);
  }
}

처리되지 않은 약속 거절

콜백 기반 코드는 오류 인수가 무시될 경우 오류를 조용히 처리합니다. async/await는 처리되지 않은 Promise 거부를 발생시키는데, Node.js 15 이상 버전에서는 이로 인해 기본적으로 프로세스가 종료됩니다. 이는 마이그레이션 시 호환성을 깨뜨리는 변경 사항입니다. 이전에는 오류를 조용히 처리했던 코드가 이제는 충돌하게 됩니다.

자바 스크립트

// This produces an unhandled rejection in Node.js 15+
async function riskyOperation() {
  throw new Error('Something failed');
}

riskyOperation(); // Promise rejected, but rejection is not caught

// Fix: always await or chain .catch()
await riskyOperation();              // throws, caller handles it
riskyOperation().catch(console.error); // handles inline

마이그레이션 중에 모든 비동기 함수 호출을 검사합니다. 반환 값을 기다리지 않거나 다른 함수와 연결하지 않은 비동기 함수 호출은 모두 검사 대상에 포함됩니다. .catch() 콜백 환경에서는 잠재적인 조용한 실패이지만, async/await 환경에서는 처리되지 않은 거부로 인한 크래시입니다.

EventEmitter 패턴을 Promise 및 비동기 반복자로 마이그레이션하기

Node.js의 EventEmitters는 명명된 이벤트에 대해 여러 콜백을 등록하는 콜백 패턴의 한 형태입니다. 스트림, 네트워크 연결 및 사용자 지정 이벤트 버스에서 흔히 사용됩니다. async/await로 직접 마이그레이션하려면 이벤트 기반 API를 래핑해야 합니다.

자바 스크립트

const { EventEmitter } = require('events');

// Original EventEmitter-based pattern
function fetchDataLegacy(source) {
  const emitter = new EventEmitter();
  setTimeout(() => {
    emitter.emit('data', { records: [1, 2, 3] });
    emitter.emit('end');
  }, 100);
  return emitter;
}

// Usage: callback registration
const stream = fetchDataLegacy('api');
stream.on('data', chunk => console.log('received', chunk));
stream.on('error', err => console.error('error', err));
stream.on('end', () => console.log('done'));

일회성 이벤트를 Promise로 변환하는 것은 간단합니다.

자바 스크립트

// Converting a single-event completion to Promise
function waitForEvent(emitter, successEvent, errorEvent = 'error') {
  return new Promise((resolve, reject) => {
    emitter.once(successEvent, resolve);
    emitter.once(errorEvent, reject);
  });
}

async function fetchData(source) {
  const emitter = fetchDataLegacy(source);
  const data = await waitForEvent(emitter, 'data');
  await waitForEvent(emitter, 'end');
  return data;
}

여러 데이터 이벤트를 내보내는 스트림의 경우 Node.js는 다음을 제공합니다. events.on 이는 비동기 이터레이터를 반환하므로 전체 스트림을 소비할 수 있습니다. for await...of:

자바 스크립트

const { on } = require('events');

async function processStream(readable) {
  for await (const chunk of on(readable, 'data')) {
    await processChunk(chunk);
  }
}

Node.js 10 버전부터는 Node.js의 읽기 가능한 스트림을 비동기 반복 객체처럼 직접 반복할 수도 있습니다.

자바 스크립트

const fs = require('fs');

async function countLines(filePath) {
  let lines = 0;
  const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
  for await (const chunk of stream) {
    lines += chunk.split('\n').length - 1;
  }
  return lines;
}

TypeScript Async/Await 마이그레이션 패턴

TypeScript 코드베이스는 async/await로 마이그레이션할 때 추가적인 고려 사항이 있습니다. 콜백 함수의 시그니처에서 반환 타입을 업데이트해야 합니다. Promise<T>컴파일러는 await가 비동기 함수 내부에서만 사용되도록 강제합니다.

타이프 스크립트

// Before: callback signature
function fetchUser(
  id: string,
  callback: (err: Error | null, user: User | null) => void
): void {
  db.findOne({ id }, callback);
}

// After: async/await signature with proper return type
async function fetchUser(id: string): Promise<User> {
  const user = await db.findOneAsync<User>({ id });
  if (!user) throw new Error(`User ${id} not found`);
  return user;
}

TypeScript의 엄격한 null 검사는 비동기 코드와 상호 작용하여 일반적인 마이그레이션 오류를 잡아냅니다. 함수가 이전에 null을 반환한 경우 User | null 콜백을 통해 마이그레이션이 변경됩니다. Promise<User> (null을 반환하는 대신 예외를 던지는 경우) TypeScript는 null을 확인했지만 더 이상 확인할 필요가 없는 호출자와 오류를 확인하지 않았지만 이제 처리해야 하는 호출자를 포착합니다.

기존 TypeScript 코드가 사용하는 경우 @types/node 콜백 시그니처, util.promisify 완전한 타입 지정 기능을 제공하며 Node.js 내장 함수의 올바른 Promise 반환 타입을 자동으로 추론합니다.

운영 시스템을 위한 점진적 마이그레이션 전략

운영 시스템을 한 번에 모두 마이그레이션할 수는 없습니다. 점진적 접근 방식은 모듈 하나씩 변환하고 유효성을 검증한 후 다음 모듈로 넘어가는 방식입니다. 핵심은 모든 단계에서 변환된 코드와 변환되지 않은 코드 사이의 경계를 명확하게 유지하는 것입니다.

마이그레이션 순서는 의존성 방향을 따라야 합니다. 가장 깊은 의존성부터 먼저 변환한 다음 호출자 쪽으로 상향 변환을 진행하십시오. 이렇게 하면 상위 수준 함수가 변환될 때쯤에는 해당 의존성 함수들이 이미 Promise를 반환하게 되어 래퍼 계층이 더 이상 필요하지 않게 됩니다.

자바 스크립트

// Stage 1: Promisify the data layer (deepest dependency)
const db = {
  queryAsync: promisify(db.query.bind(db)),
  insertAsync: promisify(db.insert.bind(db)),
};

// Stage 2: Convert the service layer (depends on db)
class UserService {
  async getUser(id) {
    return db.queryAsync('SELECT * FROM users WHERE id = ?', [id]);
  }
  async createUser(data) {
    return db.insertAsync('users', data);
  }
}

// Stage 3: Convert the controller layer (depends on service)
// -- only after Stage 2 is validated and deployed
async function handleGetUser(req, res) {
  try {
    const user = await userService.getUser(req.params.id);
    res.json(user);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
}

이러한 단계적 접근 방식은 분석 도구를 통해 직접적으로 지원됩니다. 데이터 및 제어 흐름 분석 에서 논의된 바와 같이 , 비동기 계층을 수정하기 전에 데이터가 어떻게 흐르는지 이해하는 것은 안전한 점진적 리팩토링을 위한 필수 조건입니다. 종속성 방향은 어떤 모듈을 먼저 변환해도 안전한지, 어떤 모듈은 종속성 마이그레이션이 완료될 때까지 기다려야 하는지를 결정합니다.

하위 호환성을 위한 래퍼 레이어

마이그레이션 과정에서 일부 호출자는 여전히 콜백 기반 API를 기대할 것입니다. util.callbackify 함수는 역함수입니다. util.promisify비동기 함수를 오류 우선 콜백 인터페이스로 다시 변환합니다.

자바 스크립트

const { callbackify } = require('util');

// New async implementation
async function fetchUserAsync(id) {
  return db.queryAsync('SELECT * FROM users WHERE id = ?', [id]);
}

// Backward-compatible callback version for unconverted callers
const fetchUser = callbackify(fetchUserAsync);

// Old callers continue to work unchanged
fetchUser(userId, (err, user) => {
  if (err) return handleError(err);
  render(user);
});

// New callers use the async version directly
const user = await fetchUserAsync(userId);

이러한 양방향 호환성 덕분에 변환은 특정 날짜에만 진행되는 작업이 아닙니다. 개별 모듈은 모든 담당자와 동시에 협의할 필요 없이 언제든지 스프린트 중에 변환할 수 있습니다.

마이그레이션 중 흔히 발생하는 비동기/await 관련 문제점

비동기 함수 호출에 await가 누락되었습니다.

가장 흔한 마이그레이션 오류는 async 함수를 await 없이 호출하는 것입니다. 이 오류는 함수가 reject될 때까지 JavaScript 런타임에 감지되지 않으며, reject되는 경우 예외가 발생하는 대신 처리되지 않은 Promise reject로 처리됩니다.

자바 스크립트

// Bug: missing await -- function runs but result is a Promise, not the user
async function updateUserName(id, name) {
  const user = fetchUser(id);  // BUG: forgot await, user is a Promise object
  user.name = name;            // setting .name on a Promise, not a user
  await saveUser(user);        // saves the Promise object
}

// Fix
async function updateUserName(id, name) {
  const user = await fetchUser(id);
  user.name = name;
  await saveUser(user);
}

TypeScript와 ESLint의 no-floating-promises 이 규칙은 해당 패턴을 자동으로 포착합니다. 마이그레이션 중에 이 린트 규칙을 추가하는 것을 강력히 권장합니다.

배열 메서드의 비동기 함수

Array.prototype.forEach 비동기 콜백을 기다리지 않습니다. 이로 인해 루프 내 await와 동일하게 순차적으로 실행되지만 잘못된 동작이 발생하며, 코드는 겉으로는 정상적으로 작동하는 것처럼 보이지만 실제로는 아무것도 기다리지 않고 모든 콜백을 동시에 실행합니다.

자바 스크립트

// Bug: forEach does not await async callbacks
async function processAll(ids) {
  ids.forEach(async (id) => {
    await processItem(id);  // these run concurrently, forEach completes immediately
  });
  // function returns before any processItem completes
}

// Fix: use Promise.all with map
async function processAll(ids) {
  await Promise.all(ids.map(id => processItem(id)));
}

try/catch는 await 외부의 비동기 오류를 포착하지 못합니다.

try/catch 블록은 await가 적용된 표현식에서 발생하는 오류만 처리합니다. await 없이 비동기 함수를 호출하면 해당 함수의 거부는 주변의 try/catch 블록에 의해 처리되지 않습니다.

자바 스크립트

// Bug: the rejection from riskyOp() is not caught
async function run() {
  try {
    riskyOp();  // not awaited -- rejection escapes the try/catch
  } catch (err) {
    console.error(err);  // never reached
  }
}

// Fix: await inside the try block
async function run() {
  try {
    await riskyOp();
  } catch (err) {
    console.error(err);
  }
}

마이그레이션 후 비동기 코드 테스트

마이그레이션된 비동기 함수에는 비동기 테스트 케이스가 필요합니다. 최신 테스트 프레임워크는 이를 기본적으로 지원합니다.

자바 스크립트

// Jest async test patterns
describe('UserService', () => {
  // Pattern 1: async/await in test
  test('fetches user by id', async () => {
    const user = await userService.getUser('user-123');
    expect(user.id).toBe('user-123');
  });

  // Pattern 2: testing rejection
  test('throws when user not found', async () => {
    await expect(userService.getUser('nonexistent'))
      .rejects.toThrow('User nonexistent not found');
  });

  // Pattern 3: parallel setup
  beforeAll(async () => {
    await db.connect();
    await db.seed(testData);
  });

  afterAll(async () => {
    await db.cleanup();
    await db.disconnect();
  });
});

마이그레이션된 함수가 이전 콜백 함수와 동일한 출력을 생성하는지 검증할 때 비교 테스트가 효과적입니다.

자바 스크립트

// Comparison test: callback version vs. async version must agree
test('async version matches callback version output', async () => {
  const callbackResult = await promisify(fetchUserLegacy)('user-123');
  const asyncResult    = await fetchUserAsync('user-123');
  expect(asyncResult).toEqual(callbackResult);
});

이 테스트 패턴은 두 버전이 병렬로 실행되는 전환 기간 동안 특히 유용합니다. 코드 변경 전 영향 분석 에서 살펴본 바와 같이 , 리팩토링된 함수가 이전 버전과 동일한 출력을 생성하는지 검증하는 것은 이전 버전을 제거하기 전에 마이그레이션이 올바르게 수행되었음을 입증하는 근거 기반 확인 방법입니다.

방법 SMART TS XL 대규모 환경에서 안전한 비동기 마이그레이션을 지원합니다.

콜백 체인이 여러 파일, 서비스 및 팀에 걸쳐 있는 엔터프라이즈 코드베이스의 경우 안전한 마이그레이션을 위한 첫 번째 요구 사항은 기존 비동기 종속성에 대한 완벽한 지도를 작성하는 것입니다. 즉, 어떤 함수가 어떤 함수를 호출하는지, 어떤 데이터가 함수 간에 전달되는지, 어떤 체인이 애플리케이션의 핵심 작업에 중요한 경로인지, 그리고 어떤 체인이 독립적으로 마이그레이션할 수 있는 격리된 유틸리티인지 파악해야 합니다.

SMART TS XL 이 도구는 개별 파일이 아닌 전체 코드베이스를 분석하여 의존성 맵을 구축합니다. 이를 통해 콜백 기반 함수들이 모듈 경계를 넘어 서로를 어떻게 참조하는지 파악하고, 클로저를 통해 전달되는 공유 상태를 식별하며, 마이그레이션 과정에서 유지되어야 하는 실행 체인을 시각화합니다. 이러한 구조적 분석은 본 가이드에서 설명하는 단계별 마이그레이션 접근 방식의 기초 자료를 제공합니다. 즉, 의존성 그래프의 맨 아래에 있어 먼저 변환할 수 있는 모듈과 의존성 마이그레이션이 완료될 때까지 기다려야 하는 모듈을 구분하는 데 사용됩니다.

플랫폼의 영향 분석 이 기능은 변경 평가까지 확장됩니다. 콜백 기반 모듈을 Promise를 반환하도록 변환하기 전에 영향 분석을 통해 코드베이스에서 해당 모듈을 콜백 인터페이스로 호출하는 다른 모든 모듈을 식별합니다. 이러한 호출자는 다음 단계의 마이그레이션 범위가 되며, 동시에 변환하거나 하위 호환성을 유지하는 래퍼를 사용해야 합니다. util.callbackify 변환될 때까지 유지 관리해야 합니다. 이 열거형이 없으면 마이그레이션이 알 수 없는 범위에서 진행되어, 식별되지 않은 호출자가 콜백을 기대했던 위치에서 Promise를 만나게 되면 예기치 않은 오류가 발생합니다.

자바스크립트와 타입스크립트를 혼합해서 사용하거나, 다른 언어로 작성된 백엔드 서비스를 호출하는 코드베이스의 경우, SMART TS XL의 언어 간 의존성 분석 JavaScript 계층뿐만 아니라 전체 실행 경로에 대한 가시성을 제공합니다. Java 또는 Python으로 작성된 외부 서비스 호출로 끝나는 콜백 체인에는 단일 언어 도구로는 파악할 수 없는 종속성이 있으며, 이러한 종속성을 무시하는 마이그레이션 계획은 불완전합니다. 종속성 시각화 그 SMART TS XL 이 기능은 마이그레이션 변경이 이루어지기 전에 경계를 넘나드는 관계를 시각화할 수 있도록 해줍니다.

마이그레이션 지속하기: 전체 코드베이스에서 콜백에서 async/await로 전환

콜백에서 async/await로의 전환은 첫 번째 모듈이 변환될 때 완료되는 것이 아닙니다. 마지막 래퍼 레이어가 제거되고 코드베이스에 더 이상 남은 부분이 없을 때 완료됩니다. callback 핵심 논리에 관례가 있습니다. 이를 달성하려면 마이그레이션 기간 동안 규율을 지켜야 합니다. 새 코드는 async/await를 사용하여 작성해야 하고, 래퍼 레이어는 임시적인 것으로 취급해야 하며, ESLint 규칙을 통해 변환된 모듈에 콜백 스타일 함수가 도입되지 않도록 해야 합니다.

이주가 완료되었음을 나타내는 실질적인 지표는 다음과 같습니다: 없음 util.promisify 애플리케이션 코드 내의 호출(전환 기간 동안에만 필요했음), 없음 (err, result) => 핵심 비즈니스 로직의 패턴(비동기 함수에서는 try/catch로 대체됨), 수동 Promise 생성자는 더 이상 사용하지 않음 async/await 그 정도면 충분할 것이다. Promise.all 이전에 독립적인 작업이 순차적으로 실행되었던 모든 곳에서.

이러한 요소들은 모두 정적 분석을 통해 측정 가능하므로, 진행 상황을 추정치가 아닌 객관적으로 추적하고 보고할 수 있습니다. 대규모 운영 팀의 경우, 콜백 패턴을 찾는 자동화된 정적 분석과 각 마이그레이션 단계의 범위를 설정하는 종속성 분석을 결합하는 것이 마이그레이션이 정해진 기한 내에 완료되는지 아니면 범위가 완전히 파악되지 않아 무기한으로 지속되는지를 결정짓는 핵심 요소입니다.