Back

2026년인데 아직도 이런 실수를? Async/Await 완전 정복 가이드

async/await 쓴 지 몇 년 됐잖아요. Promise도 이제 눈 감고도 쓸 수 있고요. 근데 새벽 3시에 터진 그 장애, 결국 내가 짠 async 함수가 원인이었다면? 다들 한 번쯤 겪어보셨죠?

사실 async/await가 위험한 이유가 바로 이거예요. 너무 깔끔해 보여서 마치 동기 코드처럼 느껴지거든요. 그래서 우리가 같은 실수를 반복하는 거예요. 이 글에서는 2026년에도 여전히 개발자들을 괴롭히는 async 함정들을 하나씩 파헤쳐 볼게요.

기초 강의는 아닙니다. async/await는 이미 아는데, 제대로 쓰고 싶은 분들을 위한 실전 가이드예요.

루프에서 await 쓰면 망하는 이유

async 코드에서 성능 킬러 1순위가 뭔지 아세요? 바로 이거예요:

async function fetchUserData(userIds) { const users = []; for (const id of userIds) { const user = await fetchUser(id); users.push(user); } return users; }

언뜻 보면 멀쩡해 보이죠? 근데 유저가 10명이고 fetchUser가 각각 100ms 걸린다면, 이 함수는 1초가 걸려요. 병렬로 처리하면 100ms면 끝나는데요.

뭐가 문제일까요?

await는 Promise가 끝날 때까지 다음 줄로 안 넘어가요. 루프 안에서 쓰면 각 요청이 줄 서서 기다리는 거예요. 동시에 보낼 수 있는 걸 굳이 하나씩 보내는 셈이죠.

해결책: Promise.all 쓰세요

async function fetchUserData(userIds) { const userPromises = userIds.map(id => fetchUser(id)); const users = await Promise.all(userPromises); return users; }

이러면 모든 요청이 한꺼번에 날아가고, 가장 느린 것만 기다리면 돼요.

근데 순차 실행이 필요할 때도 있어요

async function processPayments(payments) { const results = []; for (const payment of payments) { // 각 결제 후 잔액이 바뀌니까 순서대로 해야 함 const result = await processPayment(payment); results.push(result); } return results; }

핵심은 의도했느냐예요. 병렬로 할 수 있는 걸 실수로 순차 처리하면 안 되는 거죠.

고급편: 동시성 제어하기

Promise.all도 만능은 아니에요. API 1000개를 한꺼번에 쏘면 서버가 뻗을 수 있거든요. 이럴 땐 동시 요청 수를 제한해야 해요:

async function fetchWithConcurrency(urls, concurrency = 5) { const results = []; const executing = new Set(); for (const url of urls) { const promise = fetch(url).then(response => { executing.delete(promise); return response.json(); }); executing.add(promise); results.push(promise); if (executing.size >= concurrency) { await Promise.race(executing); } } return Promise.all(results); }

아니면 세마포어 패턴을 쓰는 방법도 있어요:

class Semaphore { #queue = []; #running = 0; constructor(concurrency) { this.concurrency = concurrency; } async acquire() { if (this.#running >= this.concurrency) { const { promise, resolve } = Promise.withResolvers(); this.#queue.push(resolve); await promise; } this.#running++; } release() { this.#running--; if (this.#queue.length > 0) { const next = this.#queue.shift(); next(); } } async run(fn) { await this.acquire(); try { return await fn(); } finally { this.release(); } } } // 사용 예시 const semaphore = new Semaphore(5); const results = await Promise.all( urls.map(url => semaphore.run(() => fetch(url))) );

Unhandled Rejection이 서버를 죽인다

Node.js 22 이상에서는 처리 안 된 Promise rejection이 프로세스를 그냥 종료시켜요. 이 변경 하나가 수많은 프로덕션 서버를 터뜨렸어요.

조용히 다가오는 죽음

async function riskyOperation() { const result = await fetchData(); // 에러 날 수 있음 return result; } // 위험: 에러 처리 없이 그냥 호출 riskyOperation();

fetchData()가 에러를 던지면 어디로 갈까요? 아무 데도 안 가요. try/catch도 없고, .catch()도 없으니까요. Node.js 15 전에는 경고만 찍혔어요. 지금은? 서버가 그냥 죽어버려요.

더 무서운 건 발견이 어렵다는 거예요

app.get('/users', async (req, res) => { const users = await getUsers(); // 여기서 터지면... res.json(users); });

Express 4.x에서는 이게 알아서 에러 응답을 안 보내요. 요청이 그냥 멈춰있다가 타임아웃 나요. Express 5에서 고쳐졌는데, 아직도 4.x 쓰는 프로젝트가 많죠.

제대로 막는 법

1. 전역 핸들러는 최후의 보루로만:

process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection:', reason); Sentry.captureException(reason); });

2. Express에서는 래퍼 함수 쓰세요:

const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; app.get('/users', asyncHandler(async (req, res) => { const users = await getUsers(); res.json(users); }));

3. 경계 지점에는 무조건 try/catch:

API 라우트, 이벤트 핸들러, cron job처럼 "끝"에 해당하는 곳에서는 반드시 에러를 잡아야 해요:

async function cronJob() { try { await performScheduledTask(); } catch (error) { await notifyOnCall(error); // 다시 던지지 마세요. 여기가 끝이에요. } }

Fire-and-Forget할 때도 .catch() 잊지 마세요

// ❌ 이러면 안 됨 async function saveAndNotify(data) { await saveToDatabase(data); sendNotification(data); // await 안 하는 건 OK, 근데... } // ✅ 이렇게 해야 함 async function saveAndNotify(data) { await saveToDatabase(data); sendNotification(data).catch(err => { console.error('알림 전송 실패:', err); }); }

메모리 누수: 서버 며칠 돌리면 터지는 이유

async 코드는 예상치 못한 곳에서 메모리를 먹어요. 며칠 돌려야 발견되는 게 함정이죠.

클로저가 대용량 데이터를 물고 있으면?

async function processLargeFile(filePath) { const hugeData = await readEntireFile(filePath); // 500MB return async function getSlice(start, end) { return hugeData.slice(start, end); }; }

리턴된 함수가 hugeData를 계속 붙잡고 있어요. 작은 조각만 필요해도 500MB가 메모리에 그대로 남아있는 거죠.

해결책: WeakRef

async function processLargeFile(filePath) { let hugeData = await readEntireFile(filePath); const dataRef = new WeakRef(hugeData); hugeData = null; // 참조 끊기 return async function getSlice(start, end) { const data = dataRef.deref(); if (!data) { throw new Error('데이터가 가비지 컬렉션됨'); } return data.slice(start, end); }; }

이벤트 리스너 누수

이건 정말 많이들 놓쳐요:

class DataProcessor { constructor() { this.eventEmitter = new EventEmitter(); } async processWithUpdates(data) { return new Promise((resolve, reject) => { const onProgress = (progress) => { console.log(`Progress: ${progress}%`); }; const onComplete = (result) => { // ⚠️ onProgress 안 지움! resolve(result); }; this.eventEmitter.on('progress', onProgress); this.eventEmitter.on('complete', onComplete); this.eventEmitter.on('error', reject); this.startProcessing(data); }); } }

호출할 때마다 리스너가 쌓여요. 1000번 호출하면 3000개의 좀비 리스너가 생겨요.

반드시 cleanup 하세요

async processWithUpdates(data) { return new Promise((resolve, reject) => { const cleanup = () => { this.eventEmitter.off('progress', onProgress); this.eventEmitter.off('complete', onComplete); this.eventEmitter.off('error', onError); }; const onProgress = (progress) => { console.log(`Progress: ${progress}%`); }; const onComplete = (result) => { cleanup(); resolve(result); }; const onError = (error) => { cleanup(); reject(error); }; this.eventEmitter.on('progress', onProgress); this.eventEmitter.on('complete', onComplete); this.eventEmitter.on('error', onError); this.startProcessing(data); }); }

AbortController 패턴

모던 자바스크립트에서는 이게 더 깔끔해요:

async function fetchWithTimeout(url, timeoutMs = 5000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); return await response.json(); } finally { clearTimeout(timeoutId); } }

레이스 컨디션: 개발 환경에선 잘 되는데요?

레이스 컨디션은 타이밍에 따라 동작이 달라지는 버그예요. 개발할 땐 멀쩡한데 프로덕션에서만 터져서 미치게 해요.

React에서 흔한 패턴

function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { async function loadUser() { const userData = await fetchUser(userId); setUser(userData); // userId가 바뀌었으면? } loadUser(); }, [userId]); return <div>{user?.name}</div>; }

유저가 빠르게 두 링크를 클릭하면? 두 요청이 동시에 나가고, 먼저 간 요청이 나중에 도착할 수 있어요. 결과적으로 이전 유저 데이터가 화면에 남아요.

수정: 이전 요청 취소하기

function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { const controller = new AbortController(); async function loadUser() { try { const userData = await fetchUser(userId, { signal: controller.signal }); setUser(userData); } catch (error) { if (error.name !== 'AbortError') { console.error('유저 로딩 실패:', error); } } } loadUser(); return () => controller.abort(); }, [userId]); return <div>{user?.name}</div>; }

백엔드의 더블 클릭 문제

app.post('/orders', async (req, res) => { const { userId, productId } = req.body; // 기존 주문 확인 const existing = await Order.findOne({ userId, productId, status: 'pending' }); if (existing) { return res.status(400).json({ error: '이미 주문됨' }); } // 주문 생성 const order = await Order.create({ userId, productId, status: 'pending' }); res.json(order); });

유저가 주문 버튼을 더블 클릭하면? 두 요청이 거의 동시에 와서, 둘 다 "기존 주문 없음"을 확인하고, 둘 다 주문을 만들어요.

해결책: DB 유니크 제약 조건

// 스키마에서 Order.createIndex({ userId: 1, productId: 1, status: 1 }, { unique: true }); // 라우트에서 app.post('/orders', async (req, res) => { try { const order = await Order.create({ userId: req.body.userId, productId: req.body.productId, status: 'pending' }); res.json(order); } catch (error) { if (error.code === 11000) { // 중복 키 에러 return res.status(400).json({ error: '이미 주문됨' }); } throw error; } });

서버가 여러 대면? 분산 락 필요

import Redis from 'ioredis'; const redis = new Redis(); async function withDistributedLock(key, ttlMs, fn) { const lockKey = `lock:${key}`; const lockValue = crypto.randomUUID(); const acquired = await redis.set(lockKey, lockValue, 'PX', ttlMs, 'NX'); if (!acquired) { throw new Error('락 획득 실패'); } try { return await fn(); } finally { const script = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end `; await redis.eval(script, 1, lockKey, lockValue); } }

생성자에서 await 쓰면 안 돼요

가끔 이런 시도를 봐요:

class DatabaseConnection { constructor() { // ❌ 문법 에러! await this.connect(); } async connect() { this.connection = await mongodb.connect(); } }

이건 문법 에러예요. 그래서 이렇게 우회하기도 하는데:

class DatabaseConnection { constructor() { this.ready = this.connect(); } async connect() { this.connection = await mongodb.connect(); } async query(sql) { await this.ready; return this.connection.query(sql); } }

동작은 해요. 근데 모든 메서드에서 await this.ready 해야 해서 번거롭죠.

팩토리 패턴이 정답

class DatabaseConnection { static async create() { const instance = new DatabaseConnection(); await instance.connect(); return instance; } async connect() { this.connection = await mongodb.connect(); } async query(sql) { return this.connection.query(sql); } } // 사용 const db = await DatabaseConnection.create();

Promise.all vs Promise.allSettled

잘못 고르면 에러가 사라지거나, 하나 실패에 전체가 죽어요.

Promise.all은 하나만 실패해도 전체 실패

const results = await Promise.all([ fetchUser(1), fetchUser(2), fetchUser(3) ]);

2번 유저 fetch가 실패하면? 성공한 1번, 3번 결과도 다 날아가요.

Promise.allSettled는 다 기다려줌

const results = await Promise.allSettled([ fetchUser(1), fetchUser(2), fetchUser(3) ]); const users = results .filter(r => r.status === 'fulfilled') .map(r => r.value); const errors = results .filter(r => r.status === 'rejected') .map(r => r.reason);

언제 뭘 써야 할까요?

  • Promise.all: 전부 성공해야만 의미가 있을 때 (트랜잭션 같은 거)
  • Promise.allSettled: 일부 실패해도 나머지는 처리해야 할 때 (대량 알림 발송 같은 거)

하이브리드: 개별 에러 처리

const results = await Promise.all([ fetchUser(1).catch(err => ({ error: err, id: 1 })), fetchUser(2).catch(err => ({ error: err, id: 2 })), fetchUser(3).catch(err => ({ error: err, id: 3 })) ]); results.forEach(result => { if (result.error) { console.log(`${result.id}번 유저 로딩 실패`); } });

Async 제너레이터: 진짜 갓갓 기능

대용량 데이터 처리할 때 async 제너레이터가 짱이에요:

async function* readLargeFile(path) { const stream = fs.createReadStream(path, { encoding: 'utf8' }); for await (const chunk of stream) { yield chunk; } } // 메모리 폭발 없이 처리 for await (const chunk of readLargeFile('huge.txt')) { await processChunk(chunk); }

디버깅 팁

1. 이름 있는 함수 쓰세요

// ❌ 스택 트레이스에서 뭔지 모름 const result = await somePromise.then(x => x.map(y => y.value)); // ✅ 에러 나면 어디인지 바로 앎 const result = await somePromise.then(function extractValues(items) { return items.map(function getValue(item) { return item.value; }); });

2. 추적용 ID 넣기

async function debuggableOperation(input) { const startTime = performance.now(); const operationId = crypto.randomUUID().slice(0, 8); console.log(`[${operationId}] 시작:`, input); try { const result = await doSomething(input); console.log(`[${operationId}] 완료 (${performance.now() - startTime}ms)`); return result; } catch (error) { console.error(`[${operationId}] 실패 (${performance.now() - startTime}ms):`, error); throw error; } }

3. Promise 상태 직접 확인하기

Promise 상태를 직접 들여다볼 순 없지만, race를 이용하면 가능해요:

async function getPromiseState(promise) { const sentinel = Symbol('pending'); const result = await Promise.race([ promise, Promise.resolve(sentinel) ]); if (result === sentinel) { return 'pending'; } return 'resolved'; }

4. async_hooks로 비동기 추적하기

Node.js의 async_hooks 모듈로 비동기 작업을 추적할 수 있어요:

import async_hooks from 'async_hooks'; import fs from 'fs'; const contexts = new Map(); const hook = async_hooks.createHook({ init(asyncId, type, triggerAsyncId) { fs.writeSync(1, `${type} 생성: ${asyncId} (부모: ${triggerAsyncId})\n`); contexts.set(asyncId, { type, parent: triggerAsyncId }); }, destroy(asyncId) { contexts.delete(asyncId); } }); hook.enable();

테스트할 때 실수

await 깜빡하면 테스트가 거짓 성공해요

// ❌ 이러면 항상 통과 it('유저를 가져와야 함', async () => { fetchUser(1).then(user => { expect(user.name).toBe('Alice'); }); }); // ✅ 이렇게 해야 함 it('유저를 가져와야 함', async () => { const user = await fetchUser(1); expect(user.name).toBe('Alice'); });

rejection 테스트

it('잘못된 입력에 에러 던져야 함', async () => { await expect(fetchUser(-1)).rejects.toThrow('Invalid ID'); });

Fake Timer랑 async 같이 쓰기

// Fake timer 쓰면 async 테스트가 깨질 수 있어요 jest.useFakeTimers(); it('5초 후에 타임아웃 돼야 함', async () => { const promise = fetchWithTimeout('/slow'); // 타이머 진행 jest.advanceTimersByTime(5000); // 마이크로태스크 큐도 처리해줘야 해요 await jest.runAllTimersAsync(); // Jest 29+ await expect(promise).rejects.toThrow('Timeout'); });

놓치기 쉬운 성능 이슈

마이크로태스크 큐 폭발

await 하나마다 마이크로태스크가 생겨요. 타이트한 루프에서는 I/O를 막을 수 있어요:

// 마이크로태스크 큐 폭발 async function processItems(items) { for (const item of items) { await processItem(item); // 매번 마이크로태스크 생성 } }

CPU 작업과 I/O가 섞여있으면, 중간중간 양보해야 해요:

async function processItems(items) { for (let i = 0; i < items.length; i++) { await processItem(items[i]); // 100개마다 I/O에 양보 if (i % 100 === 0) { await new Promise(resolve => setImmediate(resolve)); } } }

V8 최적화 한계

V8이 async 함수를 동기 함수만큼 최적화 못 할 때가 있어요. 핫 패스에서는 이렇게 하세요:

// 핫 패스: 가능하면 동기로 function getValue(cache, key) { const cached = cache.get(key); if (cached !== undefined) { return cached; // 동기 리턴 } return fetchValue(key).then(value => { cache.set(key, value); return value; }); }

캐시 히트할 때 동기로 리턴하면 Promise 오버헤드를 피할 수 있어요.

마무리: async 마인드셋

async/await 잘 쓰려면 패턴 외우는 게 아니라 흐름을 이해해야 해요.

핵심만 정리하면:

  1. 기본은 병렬: 특별한 이유 없으면 Promise.all 쓰세요
  2. 에러는 반드시 잡아요: 경계 지점에서 누가 처리할지 정하세요
  3. 리소스는 직접 정리: 리스너, 타이머, 연결은 알아서 안 사라져요
  4. 레이스 컨디션은 어디에나: 처음부터 동시 접근 고려해서 설계하세요
  5. 실패 케이스도 테스트: rejection, 타임아웃, 부분 실패 다 챙기세요

이 글에서 다룬 버그들은 다 실제 장애에서 가져온 거예요. 처음엔 다 멀쩡해 보였어요. 그게 async의 무서운 점이에요—문법이 복잡함을 숨기거든요.

근데 한 번 이해하면 충분히 컨트롤할 수 있어요. 이제 새벽 3시에 깨는 일 없이, 주무시면서도 돌아가는 코드 짜시길 바랍니다.

코드는 의도를 담아서 짜세요. 엣지 케이스 테스트하세요. 그리고 rejection은 반드시 처리하세요.

JavaScriptAsync/AwaitDebuggingNode.jsPerformanceBest Practices

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요