2026. 6. 25. 08:00ㆍFrontend/JavaScript
Promise와 async/await 깊이 이해하기 4/4
들어가며
Promise 코드를 작성하는 것은 어렵지 않다.
어려운 것은 작업의 생명주기를 설계하는 일이다.
사용자가 페이지를 떠나면 요청을 중단해야 하는가?
timeout이 발생하면 실제 네트워크 요청도 종료되는가?
부모 작업이 실패하면 자식 작업은 어떻게 되는가?
어떤 오류를 재시도해도 안전한가?
영원히 pending인 Promise는 무엇을 메모리에 유지하는가?
하나가 아닌 여러 비동기 값은 무엇으로 표현해야 하는가?
Promise는 성공과 실패를 표현하지만 다음 요소를 직접 제공하지 않는다.
취소
timeout
자원 소유권
재시도 정책
멱등성
backpressure
구조적 동시성
이 요소들은 애플리케이션 아키텍처가 별도로 설계해야 한다.
1. Promise에는 취소가 없다
Promise는 작업 자체가 아니라 결과를 관찰하는 객체다.
이 차이는 취소 설계에서 중요하다.
const sharedRequest = fetch("/api/config");
const consumerA = sharedRequest.then(useForA);
const consumerB = sharedRequest.then(useForB);
하나의 요청 결과를 두 소비자가 함께 사용하고 있다.
consumerA가 더 이상 결과를 원하지 않는다고 해서 실제 네트워크 요청을 중단하면 consumerB도 영향을 받는다.
결과 관찰을 중단한다
≠
실제 작업을 중단한다
Promise 객체 자체에 보편적인 cancel()이 없는 이유를 이 관점에서 이해할 수 있다.
취소 권한은 Promise보다 작업을 생성하고 소유한 계층에 있어야 한다.
2. AbortController와 AbortSignal
웹 플랫폼에서는 취소 의사를 전달하는 표준 구조로 AbortController와 AbortSignal을 사용한다.
const controller = new AbortController();
const request = fetch("/api/users", {
signal: controller.signal,
});
controller.abort();
각 역할은 다음과 같다.
AbortController
= 취소를 발생시키는 주체
AbortSignal
= 취소 상태와 이유를 전달하는 객체
Fetch는 요청에 연결된 AbortSignal을 통해 취소 상태를 관찰한다. DOM Standard는 signal의 취소 상태와 signal 결합·timeout 관련 연산을 정의하고, Fetch Standard는 Request가 AbortSignal을 갖도록 정의한다.
취소는 기본적으로 협력적이다.
controller.abort()
→ 취소 의사를 전달
실제 작업
→ signal을 관찰하고 정지해야 함
아무 연산이나 AbortSignal을 전달한다고 자동으로 중단되는 것은 아니다.
해당 API가 signal을 지원해야 한다.
3. timeout과 취소 신호 결합
DOM Standard에는 timeout signal과 여러 signal을 결합하는 구조가 정의되어 있다.
const timeoutSignal = AbortSignal.timeout(3000);
외부 취소와 timeout을 결합할 수 있다.
function createRequestSignal({
signal,
timeoutMs,
}) {
const signals = [
AbortSignal.timeout(timeoutMs),
];
if (signal) {
signals.push(signal);
}
return AbortSignal.any(signals);
}
사용 예시는 다음과 같다.
async function fetchJson(
url,
{
signal,
timeoutMs = 3000,
} = {},
) {
const requestSignal = createRequestSignal({
signal,
timeoutMs,
});
const response = await fetch(url, {
signal: requestSignal,
});
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${url}`,
);
}
return response.json();
}
이제 작업은 두 경우 중 먼저 발생하는 이유로 취소된다.
외부 signal 취소
또는
timeout 만료
배포 환경에서 해당 정적 메서드를 지원하지 않는다면 AbortController와 setTimeout()으로 같은 정책을 직접 구현할 수 있다.
4. Promise.race timeout의 한계
다음 timeout 구현은 결과 대기를 중단하지만 실제 작업은 중단하지 않는다.
function timeout(ms) {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Timeout"));
}, ms);
});
}
await Promise.race([
fetch("/api/data"),
timeout(3000),
]);
3초 뒤 timeout Promise가 먼저 reject되면 호출자는 실패를 관찰한다.
그러나 원래 fetch() 요청은 계속 진행될 수 있다.
Promise.race에서 패배
≠ 작업 취소
실제 요청을 중단하려면 요청 자체에 signal을 전달해야 한다.
const response = await fetch("/api/data", {
signal: AbortSignal.timeout(3000),
});
Promise 조합자는 결과 선택 정책을 정의할 뿐 작업 생명주기를 관리하지 않는다.
5. 취소, timeout, 실패는 같은 의미가 아니다
Promise API에서는 이 세 상황이 모두 rejection으로 나타날 수 있다.
네트워크 실패
사용자 취소
timeout
그러나 도메인 의미는 다르다.
실패
= 완료하려 했으나 성공하지 못함
취소
= 더 이상 완료를 원하지 않음
timeout
= 정해진 시간 안에 완료되지 않음
이를 모두 일반 Error로 처리하면 다음 문제가 생길 수 있다.
사용자 취소가 오류 모니터링에 보고됨
timeout과 서버 오류를 구분할 수 없음
취소된 요청이 자동 재시도됨
잘못된 사용자 안내 메시지가 표시됨
예외 클래스를 분리할 수 있다.
class TimeoutError extends Error {
constructor(message = "작업 시간이 초과되었습니다.") {
super(message);
this.name = "TimeoutError";
}
}
class UserCanceledError extends Error {
constructor(message = "사용자가 작업을 취소했습니다.") {
super(message);
this.name = "UserCanceledError";
}
}
또는 예상 가능한 결과를 tagged union으로 표현할 수 있다.
type OperationResult<T> =
| {
type: "success";
value: T;
}
| {
type: "canceled";
reason: unknown;
}
| {
type: "timeout";
duration: number;
}
| {
type: "failure";
error: Error;
};
Promise rejection은 제어 채널일 뿐이다.
도메인 오류 분류는 별도의 설계다.
6. Floating Promise와 작업 소유권
다음 함수는 두 작업을 시작하지만 기다리지 않는다.
async function updateUser() {
saveAuditLog();
sendNotification();
return updateDatabase();
}
saveAuditLog()와 sendNotification()이 Promise를 반환한다고 가정해보자.
이 Promise들은 부모 함수의 완료 조건에 포함되지 않는다.
updateUser 완료
├─ saveAuditLog는 계속 실행될 수 있음
└─ sendNotification도 계속 실행될 수 있음
이런 Promise를 흔히 floating Promise라고 부른다.
문제는 작업의 소유자가 불분명해진다는 것이다.
오류는 누가 처리하는가?
요청이 끝난 뒤에도 실행되어도 되는가?
부모가 취소되면 자식은 어떻게 되는가?
테스트는 언제 완료되었다고 판단하는가?
부모가 자식 작업을 소유한다면 명시적으로 기다려야 한다.
async function updateUser({
signal,
}) {
await Promise.all([
saveAuditLog({ signal }),
sendNotification({ signal }),
updateDatabase({ signal }),
]);
}
의도적인 fire-and-forget이라면 그 사실과 오류 정책을 드러내야 한다.
void sendTelemetry()
.catch((error) => {
reportTelemetryError(error);
});
다음 코드보다는 낫다.
sendTelemetry();
void가 작업을 안전하게 만드는 것은 아니다.
다만 결과를 기다리지 않는 선택이 의도적이라는 것을 코드에 표시한다.
7. 구조적 동시성이라는 관점
구조적 동시성의 핵심 아이디어는 비동기 작업의 생명주기를 코드 블록의 생명주기에 묶는 것이다.
부모 scope가 끝나기 전에
→ 자식 작업을 기다린다.
부모가 취소되면
→ 자식에게 취소를 전달한다.
자식이 실패하면
→ 형제 작업 처리 정책을 명시한다.
JavaScript Promise는 이러한 규칙을 자동으로 강제하지 않는다.
개발자가 다음 수단으로 직접 구조를 만들어야 한다.
await
Promise.all
AbortSignal 전달
try/finally
자원 cleanup
명시적인 오류 경계
다음 함수는 작업 수명이 함수 블록 안에 묶여 있다.
async function loadPage({
signal,
}) {
const userPromise = loadUser({
signal,
});
const settingsPromise = loadSettings({
signal,
});
const [user, settings] = await Promise.all([
userPromise,
settingsPromise,
]);
return {
user,
settings,
};
}
loadPage()의 Promise가 완료될 때는 자신이 소유한 두 작업도 완료된 상태다.
8. 재시도 대상은 Promise가 아니라 함수다
다음 구조로는 작업을 다시 실행할 수 없다.
const promise = fetch("/api/data");
await retry(promise);
이미 생성된 Promise를 다시 기다려도 같은 결과만 관찰한다.
재시도 함수는 새로운 Promise를 만들 수 있는 함수를 받아야 한다.
await retry(() => {
return fetch("/api/data");
});
signal을 지원하는 지연 함수를 먼저 작성해보자.
function delay(
ms,
{
signal,
} = {},
) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timerId = setTimeout(() => {
cleanup();
resolve();
}, ms);
function handleAbort() {
clearTimeout(timerId);
cleanup();
reject(signal.reason);
}
function cleanup() {
signal?.removeEventListener(
"abort",
handleAbort,
);
}
signal?.addEventListener(
"abort",
handleAbort,
{
once: true,
},
);
});
}
재시도 함수는 다음처럼 작성할 수 있다.
async function retry(
operation,
{
attempts = 3,
baseDelayMs = 200,
signal,
shouldRetry = () => true,
} = {},
) {
let lastError;
for (
let attempt = 1;
attempt <= attempts;
attempt += 1
) {
signal?.throwIfAborted();
try {
return await operation({
attempt,
signal,
});
} catch (error) {
lastError = error;
const isLastAttempt =
attempt === attempts;
if (
isLastAttempt ||
!shouldRetry(error, attempt)
) {
throw error;
}
const exponentialDelay =
baseDelayMs * 2 ** (attempt - 1);
const jitter =
Math.random() * baseDelayMs;
await delay(
exponentialDelay + jitter,
{
signal,
},
);
}
}
throw lastError;
}
사용 예시는 다음과 같다.
const response = await retry(
({ signal }) => {
return fetch("/api/data", {
signal,
});
},
{
attempts: 3,
signal,
shouldRetry(error) {
return error instanceof TypeError;
},
},
);
9. 재시도와 멱등성
기술적으로 재시도할 수 있다고 해서 안전한 것은 아니다.
GET 요청
→ 일반적으로 재시도하기 쉬움
결제 승인
→ 중복 결제 가능
이메일 전송
→ 중복 발송 가능
주문 생성
→ 중복 주문 가능
재시도 안전성은 Promise가 아니라 작업의 도메인 의미에 달려 있다.
부수 효과가 있는 요청에는 다음과 같은 설계가 필요할 수 있다.
멱등성 키
중복 요청 식별자
서버 측 deduplication
트랜잭션
처리 상태 조회
보상 작업
예를 들어 결제 요청에 고유한 멱등성 키를 전달할 수 있다.
await retry(
({ signal }) => {
return fetch("/api/payments", {
method: "POST",
headers: {
"Idempotency-Key": paymentRequestId,
},
body: JSON.stringify(payment),
signal,
});
},
);
재시도 정책은 오류 종류에 따라서도 달라져야 한다.
일시적 네트워크 실패
→ 재시도 가능
인증 실패
→ 자동 재시도 의미 없음
유효성 검사 실패
→ 같은 입력으로 재시도해도 실패
서버 rate limit
→ Retry-After 정책 고려
10. 예상 가능한 실패와 예외적 실패
모든 비정상 결과를 rejection으로 표현할 필요는 없다.
사용자 검색 결과가 없는 상황을 생각해보자.
검색 결과 없음
이것은 시스템 오류가 아니라 정상적인 도메인 결과일 수 있다.
type SearchResult<T> =
| {
type: "found";
value: T;
}
| {
type: "not-found";
};
구현은 다음과 같이 만들 수 있다.
async function searchUser(
userId,
{
signal,
},
) {
const response = await fetch(
`/api/users/${userId}`,
{
signal,
},
);
if (response.status === 404) {
return {
type: "not-found",
};
}
if (!response.ok) {
throw new Error(
`HTTP ${response.status}`,
);
}
return {
type: "found",
value: await response.json(),
};
}
제어 흐름을 다음처럼 분리할 수 있다.
예상 가능한 도메인 분기
→ fulfillment 안의 tagged result
예외적 인프라 실패
→ rejection
모든 상황을 rejection으로 보내면 정상적인 분기까지 try/catch에 의존하게 된다.
11. pending Promise와 메모리 유지
Promise는 후속 반응 목록을 유지한다.
각 반응 handler는 클로저이므로 외부 변수를 참조할 수 있다.
const never = new Promise(() => {});
function registerHandler() {
const largeData = new Array(
1_000_000,
).fill("data");
never.then(() => {
console.log(largeData.length);
});
}
never가 영원히 settle되지 않는다면 등록된 handler도 계속 필요하다.
handler가 largeData를 참조하므로 해당 데이터도 도달 가능한 상태로 남을 수 있다.
pending Promise
└─ fulfillment reaction
└─ handler closure
└─ largeData
이를 반복하면 반응과 데이터가 계속 누적될 수 있다.
setInterval(registerHandler, 1000);
Promise가 settle되면 명세 알고리즘은 저장된 반응 목록을 꺼내 Job을 생성하고 Promise 내부 목록을 정리한다. 반대로 영원히 pending인 Promise는 반응 목록을 계속 유지할 수 있다.
Promise 자체가 메모리 누수는 아니다.
문제는 다음 조합이다.
장기간 pending인 Promise
+
계속 추가되는 handler
+
handler가 참조하는 큰 객체
12. 중단된 async 함수의 상태
다음 async 함수는 await에서 영원히 중단된다.
async function holdLargeData() {
const largeData = new Array(
1_000_000,
).fill("data");
await neverSettlingPromise;
return largeData.length;
}
await 이후에 largeData가 필요하다.
따라서 중단된 실행은 재개를 위해 필요한 상태를 보존해야 한다.
중단 지점
지역 변수
렉시컬 환경
외부 Promise 결과
재개 함수
ECMAScript의 Await 과정도 현재 async 실행 컨텍스트를 캡처한 반응을 생성하고, Promise가 완료되면 해당 컨텍스트를 재개하도록 정의한다.
엔진이 불필요한 값을 최적화할 수는 있지만, 향후 실행에서 필요할 수 있는 상태는 의미론적으로 유지되어야 한다.
따라서 다음 가정은 안전하지 않다.
현재 실행 중이 아닌 async 함수는
메모리를 거의 사용하지 않을 것이다.
13. Promise는 여러 값의 흐름을 표현하지 못한다
Promise는 단 하나의 최종 결과를 표현한다.
0회 또는 1회의 settlement
다음과 같이 시간에 따라 계속 값이 발생하는 시스템에는 맞지 않을 수 있다.
WebSocket 메시지
마우스 이벤트
스트리밍 응답
파일 청크
실시간 가격 데이터
단일 메시지를 Promise로 기다리는 것은 가능하다.
function nextMessage(socket) {
return new Promise((resolve) => {
socket.addEventListener(
"message",
resolve,
{
once: true,
},
);
});
}
하지만 지속적인 메시지 흐름에는 다음 문제들이 생긴다.
생산자가 소비자보다 빠르면 어떻게 하는가?
버퍼는 얼마나 유지하는가?
소비자가 중단하면 listener는 어떻게 정리하는가?
일부 메시지를 버릴 수 있는가?
이런 문제에는 다음 추상화가 더 적합할 수 있다.
AsyncIterator
ReadableStream
Observable
이벤트 채널
메시지 큐
Async Iterator는 next() 호출이 다음 IteratorResult를 담은 Promise를 반환하는 프로토콜이며, for await...of로 소비할 수 있다.
for await (const chunk of stream) {
await processChunk(chunk);
}
이 구조에서는 다음 값을 요청하는 시점과 처리 완료 시점을 연결해 backpressure를 표현할 수 있다.
Promise
= 하나의 최종 결과
AsyncIterator
= 시간에 따라 도착하는 여러 결과
14. 종합 예제: 필수 데이터, 선택 데이터, timeout, 취소
대시보드에 다음 데이터가 필요하다고 가정하자.
사용자 정보
= 필수
주문 정보
= 필수
추천 정보
= 선택
분석 정보
= 선택
전체 timeout
= 5초
페이지 이탈
= 모든 요청 취소
HTTP 오류를 표현하는 클래스를 만든다.
class HttpError extends Error {
constructor(
status,
url,
) {
super(`HTTP ${status}: ${url}`);
this.name = "HttpError";
this.status = status;
this.url = url;
}
}
JSON 요청 함수를 작성한다.
async function fetchJson(
url,
{
signal,
},
) {
const response = await fetch(url, {
signal,
});
if (!response.ok) {
throw new HttpError(
response.status,
url,
);
}
return response.json();
}
선택 데이터의 실패를 값으로 변환한다.
단, 전체 취소는 삼키지 않는다.
async function optional(
operation,
{
signal,
},
) {
try {
return {
ok: true,
value: await operation(),
};
} catch (error) {
if (signal.aborted) {
throw signal.reason ?? error;
}
return {
ok: false,
error,
};
}
}
대시보드 로더는 다음과 같다.
async function loadDashboard(
userId,
{
signal,
timeoutMs = 5000,
} = {},
) {
const signals = [
AbortSignal.timeout(timeoutMs),
];
if (signal) {
signals.push(signal);
}
const requestSignal =
AbortSignal.any(signals);
const userPromise = fetchJson(
`/api/users/${userId}`,
{
signal: requestSignal,
},
);
const ordersPromise = fetchJson(
`/api/users/${userId}/orders`,
{
signal: requestSignal,
},
);
const recommendationsPromise = optional(
() => {
return fetchJson(
`/api/users/${userId}/recommendations`,
{
signal: requestSignal,
},
);
},
{
signal: requestSignal,
},
);
const analyticsPromise = optional(
() => {
return fetchJson(
`/api/users/${userId}/analytics`,
{
signal: requestSignal,
},
);
},
{
signal: requestSignal,
},
);
const [
user,
orders,
recommendations,
analytics,
] = await Promise.all([
userPromise,
ordersPromise,
recommendationsPromise,
analyticsPromise,
]);
return {
user,
orders,
recommendations:
recommendations.ok
? recommendations.value
: [],
analytics:
analytics.ok
? analytics.value
: null,
warnings: [
recommendations.ok
? null
: recommendations.error,
analytics.ok
? null
: analytics.error,
].filter(Boolean),
};
}
React에서는 컴포넌트 생명주기와 연결할 수 있다.
useEffect(() => {
const controller = new AbortController();
loadDashboard(userId, {
signal: controller.signal,
})
.then(setDashboard)
.catch((error) => {
if (controller.signal.aborted) {
return;
}
setError(error);
});
return () => {
controller.abort(
new DOMException(
"Page changed",
"AbortError",
),
);
};
}, [userId]);
이 설계에는 다음 정책이 명시되어 있다.
독립 작업은 동시에 시작한다.
필수 데이터 실패
→ 전체 작업 실패
선택 데이터 실패
→ 부분 결과와 warning 반환
페이지 이탈
→ 모든 요청 취소
5초 초과
→ 모든 요청 timeout
취소
→ 선택 데이터 실패로 오인하지 않음
중요한 것은 사용한 문법이 아니다.
다음 설계 결정을 코드로 드러낸 것이 핵심이다.
작업 소유자는 누구인가?
어떤 실패가 전체 실패인가?
부분 실패를 허용하는가?
작업은 언제 종료되어야 하는가?
취소는 어디까지 전달되는가?
15. 실무 설계 체크리스트
비동기 API를 설계할 때 다음 질문을 확인할 수 있다.
이 함수 호출 시 작업이 즉시 시작되는가?
같은 Promise를 재사용하는가,
매번 새 작업을 만드는가?
timeout은 누가 책임지는가?
외부에서 작업을 취소할 수 있는가?
부모 취소가 자식 작업에 전파되는가?
필수 작업과 선택 작업은 무엇인가?
부분 실패를 허용하는가?
어떤 오류를 재시도할 수 있는가?
재시도해도 부수 효과가 중복되지 않는가?
동시에 몇 개의 작업까지 실행할 것인가?
영원히 pending될 가능성이 있는가?
handler가 큰 객체를 오래 참조하지 않는가?
하나의 결과인가,
여러 값의 흐름인가?
마무리
Promise는 결과와 오류를 표현하는 강력한 추상화다.
그러나 Promise만으로는 완전한 비동기 아키텍처를 만들 수 없다.
실무에서는 다음 층이 추가로 필요하다.
취소
timeout
작업 소유권
오류 분류
재시도
멱등성
동시성 제한
메모리 관리
backpressure
좋은 비동기 코드는 단순히 모든 함수를 async로 만드는 코드가 아니다.
작업이 언제 시작되고, 누가 소유하며, 언제 종료되고, 실패와 취소가 어디로 전파되는지를 명시한 코드다.
참고 자료
- WHATWG DOM Standard, AbortController와 AbortSignal.
- WHATWG Fetch Standard, Request와 AbortSignal.
- ECMA-262, Promise 반응 목록과 Await 실행 컨텍스트.
- ECMA-262, Async Iterator Interface.
'Frontend > JavaScript' 카테고리의 다른 글
| Promise 동시성 설계: 순차 실행, 조합자, Critical Path (0) | 2026.06.24 |
|---|---|
| async/await의 실행 원리: Job, 마이크로태스크, 실행 컨텍스트 (0) | 2026.06.23 |
| Promise의 의미론: 비동기 작업이 아닌 단일 할당 상태 기계 (0) | 2026.06.22 |
| JavaScript 클로저 완전 정복: 함수는 어떻게 자신이 태어난 환경을 기억하는가 (0) | 2026.06.20 |
| [JS] 값(value)에 의한 전달과 참조(reference)에 의한 전달 (0) | 2023.01.03 |