JavaScript 비동기 흐름 다시 보기 (01) - Promise는 값이 아니라 작업의 상태입니다
Promise를 미래의 값보다 상태를 가진 비동기 작업으로 보고, pending/fulfilled/rejected와 then/catch/finally 흐름을 정리합니다.
Promise를 "미래의 값"으로만 이해하면 비동기 흐름이 값 대입처럼 보입니다. 하지만 실무에서 더 중요한 관점은 Promise가 상태를 가진 작업이라는 점입니다.
들어가며
React Query, SWR, 서버 액션, 프레임워크의 data fetching API를 쓰다 보면 Promise를 직접 붙잡는 시간이 줄어듭니다. 로딩 상태, 성공 데이터, 에러 상태는 라이브러리가 보기 좋게 나눠 주고, 우리는 보통 그 결과를 UI에 배치합니다.
그런데 문제가 생기면 다시 Promise로 돌아오게 됩니다. 왜 로딩이 끝나지 않는지, 왜 에러가 잡히지 않는지, 왜 finally는 실행됐는데 데이터는 없는지 같은 질문은 결국 비동기 작업의 기본 흐름을 알아야 설명할 수 있습니다.
이 시리즈는 Promise 문법을 처음부터 외우는 글이 아닙니다. 이미 익숙하다고 생각했던 비동기 코드를 다시 보면서, 그것이 코드 흐름과 UI 상태로 어떻게 이어지는지 정리하려는 글입니다.
첫 글에서는 기준선을 하나만 잡겠습니다.
Promise는 값 자체가 아닙니다. 아직 끝나지 않았거나, 성공했거나, 실패한 비동기 작업의 상태를 표현하는 객체입니다.
이 관점을 잡아 두면 then, catch, finally도 단순 메서드 목록이 아니라 상태 변화에 반응하는 흐름으로 보입니다. 이후 async/await, try/catch, Promise.all 계열 API를 이해할 때도 이 기준선이 계속 필요합니다.
"미래의 값"이라는 설명의 한계
Promise를 설명할 때 "미래의 값"이라는 표현을 많이 씁니다. 틀린 말은 아닙니다. 비동기 작업이 성공하면 Promise는 나중에 어떤 값을 줍니다.
하지만 이 설명만 붙잡으면 Promise를 변수에 담긴 값처럼 다루기 쉽습니다.
function loadUserName() {
return new Promise<string>((resolve) => {
setTimeout(() => {
resolve("Ada");
}, 1000);
});
}
const name = loadUserName();
console.log(name);이 코드에서 name은 문자열이 아닙니다. 지금 당장 "Ada"가 들어 있는 변수도 아닙니다. name에는 문자열을 얻기 위해 진행 중인 작업이 들어 있습니다.
그래서 아래처럼 생각하면 흐름이 어긋납니다.
const name = loadUserName();
// name은 아직 string이 아니므로 이 시점에 문자열처럼 다룰 수 없습니다.
console.log(name.toUpperCase());Promise를 "미래의 값"이라고 부를 수는 있지만, 코드가 실제로 손에 쥐는 것은 값이 아니라 작업입니다. 그 작업은 아직 끝나지 않았을 수도 있고, 성공했을 수도 있고, 실패했을 수도 있습니다.
즉 Promise를 볼 때 먼저 물어야 할 질문은 "나중에 어떤 값이 오지?"가 아닙니다. 더 먼저 물어야 할 질문은 "이 작업은 지금 어느 상태인가?"입니다.
Promise에는 세 가지 상태가 있습니다
Promise의 상태는 크게 세 가지입니다.
- pending: 아직 끝나지 않은 상태
- fulfilled: 성공적으로 끝난 상태
- rejected: 실패한 상태
pending은 작업이 진행 중이라는 뜻입니다. 네트워크 요청을 보냈지만 응답이 아직 오지 않았거나, 타이머가 아직 끝나지 않았거나, 파일을 읽는 작업이 끝나지 않은 상태를 떠올리면 됩니다.
fulfilled는 작업이 성공했다는 뜻입니다. 이때 Promise는 성공 값을 갖습니다. then에 넘긴 함수는 이 성공 값을 받아 다음 흐름을 이어 갑니다.
rejected는 작업이 실패했다는 뜻입니다. 이때 Promise는 실패 이유를 갖습니다. 보통 Error 객체를 넘기지만, JavaScript 문법상 어떤 값이든 실패 이유가 될 수 있습니다. 실무에서는 디버깅과 로깅을 위해 Error를 쓰는 편이 좋습니다.
중요한 점은 fulfilled와 rejected가 모두 끝난 상태라는 것입니다. Promise는 한 번 fulfilled나 rejected가 되면 다시 pending으로 돌아가지 않습니다. 성공한 Promise가 나중에 실패로 바뀌거나, 실패한 Promise가 나중에 성공으로 바뀌는 일도 없습니다.
이 성질 때문에 Promise는 비동기 작업의 생애를 하나의 방향으로만 표현합니다.
const task = new Promise<string>((resolve, reject) => {
const ok = Math.random() > 0.5;
if (ok) {
resolve("성공 결과");
return;
}
reject(new Error("실패 이유"));
});이 코드에서 resolve가 호출되면 task는 fulfilled 상태가 됩니다. reject가 호출되면 rejected 상태가 됩니다. 둘 중 하나로 결정된 뒤에는 같은 Promise의 상태가 다시 바뀌지 않습니다.
그래서 Promise는 값 하나를 늦게 주는 상자가 아닙니다. 성공 또는 실패로 한 번 결정되는 비동기 작업의 기록에 가깝습니다.
then, catch, finally는 상태에 반응합니다
Promise의 상태를 기준으로 보면 then, catch, finally의 역할도 단순해집니다.
loadUserName()
.then((name) => {
console.log(`사용자 이름: ${name}`);
})
.catch((error) => {
console.error("사용자 이름을 불러오지 못했습니다.", error);
})
.finally(() => {
console.log("요청 흐름이 끝났습니다.");
});이 코드는 다음처럼 읽을 수 있습니다.
- fulfilled가 되면 then의 함수가 실행됩니다.
- rejected가 되면 catch의 함수가 실행됩니다.
- 둘 중 어느 쪽으로 끝나도 finally의 함수가 실행됩니다.
여기서 finally는 성공 값을 바꾸기 위한 자리가 아닙니다. 로딩 표시를 끄거나, 임시 리소스를 정리하거나, 요청이 끝났다는 사실만 처리할 때 어울립니다.
실무 UI 코드로 바꿔 보면 더 익숙합니다.
showLoading();
fetchProfile()
.then((profile) => {
renderProfile(profile);
})
.catch((error) => {
renderErrorMessage(error);
})
.finally(() => {
hideLoading();
});이 코드는 Promise 상태를 UI 상태로 번역합니다. pending 동안에는 로딩을 보여 주고, fulfilled가 되면 데이터를 그리고, rejected가 되면 에러를 보여 줍니다. 마지막에는 성공과 실패와 상관없이 로딩을 끕니다.
이렇게 보면 React Query 같은 라이브러리가 제공하는 isPending, data, error도 갑자기 낯설지 않습니다. 라이브러리는 Promise를 더 높은 수준의 상태 모델로 감싸지만, 그 밑에는 여전히 비동기 작업의 성공과 실패가 있습니다.
Promise.resolve와 Promise.reject는 이미 결정된 흐름을 만듭니다
항상 new Promise를 써야 Promise가 만들어지는 것은 아닙니다. 이미 알고 있는 값이나 실패를 Promise 흐름으로 맞춰야 할 때는 Promise.resolve와 Promise.reject를 씁니다.
const cachedUser = Promise.resolve({
id: "user-1",
name: "Ada",
});
cachedUser.then((user) => {
console.log(user.name);
});Promise.resolve는 성공으로 결정된 Promise를 만듭니다. 이미 동기적으로 알고 있는 값이지만, 호출하는 쪽에는 Promise 흐름으로 맞춰 주고 싶을 때 유용합니다.
반대로 실패 흐름을 만들 때는 Promise.reject를 씁니다.
const unauthenticated = Promise.reject(
new Error("로그인이 필요합니다."),
);
unauthenticated.catch((error) => {
console.error(error.message);
});Promise.reject는 실패로 결정된 Promise를 만듭니다. 여기서도 중요한 것은 값이 아니라 상태입니다. Promise.reject가 만든 Promise는 처음부터 rejected 상태이므로, 성공 흐름이 아니라 실패 흐름으로 이어집니다.
이 API들은 테스트 코드나 캐시 코드에서 자주 유용합니다. 어떤 함수가 항상 Promise를 반환해야 한다면, 동기 값도 Promise.resolve로 감싸 같은 형태로 맞출 수 있습니다.
function getUser(id: string) {
const cached = userCache.get(id);
if (cached) {
// 호출하는 쪽은 캐시 여부와 상관없이 Promise로 다룰 수 있습니다.
return Promise.resolve(cached);
}
return fetchUser(id);
}이 구조에서는 캐시가 있든 네트워크 요청을 하든 getUser를 호출하는 쪽의 모양이 같아집니다. 호출자는 항상 Promise 상태를 기준으로 성공과 실패를 처리하면 됩니다.
동기 값과 Promise 값은 다르게 읽어야 합니다
동기 값은 이미 계산이 끝난 값입니다. 코드의 다음 줄에서 바로 사용할 수 있습니다.
function getCachedName() {
return "Ada";
}
const name = getCachedName();
console.log(name.toUpperCase());하지만 Promise는 아직 끝나지 않았을 수 있는 작업입니다. 다음 줄에서 바로 성공 값을 꺼낼 수 없습니다.
function loadName() {
return Promise.resolve("Ada");
}
const nameTask = loadName();
// nameTask는 string이 아니라 Promise<string>입니다.
console.log(nameTask.toUpperCase());이 차이는 단순 타입 차이가 아닙니다. 동기 값은 현재 코드 흐름 안에 이미 들어와 있습니다. Promise 값은 작업이 끝난 뒤의 흐름에서 다뤄야 합니다.
그래서 Promise의 성공 값을 쓰려면 then 안으로 들어가야 합니다.
loadName().then((name) => {
console.log(name.toUpperCase());
});이 코드는 "값을 꺼내서 다음 줄에서 쓴다"가 아닙니다. 더 정확히는 "이 작업이 fulfilled가 되면 이 함수를 실행한다"입니다.
이 관점을 놓치면 Promise 코드가 어색해집니다. Promise를 동기 값처럼 변수에 담아 놓고, 그다음 줄에서 이미 값이 있다고 기대하게 되기 때문입니다. 반대로 Promise를 작업 상태로 보면 then이 왜 필요한지 자연스럽게 이해됩니다.
에러도 값처럼 돌아오지 않습니다
Promise의 실패는 일반적인 반환값과 다르게 흘러갑니다. 실패한 Promise는 성공 값 대신 rejected 상태가 되고, 그 실패 이유는 catch 흐름으로 이동합니다.
function loadSettings() {
return Promise.reject(new Error("설정을 불러오지 못했습니다."));
}
loadSettings()
.then((settings) => {
console.log(settings);
})
.catch((error) => {
console.error(error.message);
});이 코드에서 then은 실행되지 않습니다. Promise가 fulfilled가 아니라 rejected 상태로 결정됐기 때문입니다. 실패는 성공 값 자리에 들어오는 특수한 값이 아니라, 다른 흐름으로 이동하는 상태입니다.
then 안에서 에러가 발생해도 마찬가지입니다.
Promise.resolve("not-json")
.then((text) => {
return JSON.parse(text);
})
.catch((error) => {
console.error("JSON 파싱에 실패했습니다.", error);
});처음 Promise는 fulfilled 상태로 시작합니다. 하지만 then 안에서 예외가 발생하면 다음 Promise는 rejected 상태가 됩니다. 그래서 뒤에 붙은 catch가 그 실패를 처리합니다.
이 지점은 나중에 try/catch를 볼 때 중요해집니다. try/catch는 동기 예외를 잡는 문법처럼 보이지만, async/await와 만나면 rejected Promise를 예외처럼 다루게 됩니다. 그 이야기는 별도 글에서 더 정확히 다루겠습니다.
UI 상태는 Promise 상태를 그대로 복사하지 않습니다
Promise의 상태와 UI 상태는 비슷하지만 완전히 같은 것은 아닙니다. Promise에는 pending, fulfilled, rejected가 있습니다. UI에는 여기에 더해 빈 상태, 재시도 중, 이전 데이터 유지, 권한 없음, 부분 성공 같은 상태가 붙을 수 있습니다.
그래도 출발점은 Promise 상태입니다.
type ProfileState =
| { status: "pending" }
| { status: "fulfilled"; data: Profile }
| { status: "rejected"; error: Error };
let state: ProfileState = { status: "pending" };
fetchProfile()
.then((data) => {
state = { status: "fulfilled", data };
})
.catch((error: Error) => {
state = { status: "rejected", error };
});이 예시는 실제 React 상태 코드는 아니지만, 생각의 구조를 보여 줍니다. Promise가 pending일 때 화면은 기다립니다. fulfilled가 되면 데이터를 보여 줍니다. rejected가 되면 에러를 보여 줍니다.
React Query 같은 도구는 이 흐름을 더 정교하게 확장합니다. 처음 요청 중인지, 이미 데이터가 있는데 다시 가져오는 중인지, 마지막 요청이 실패했지만 이전 데이터는 남아 있는지 같은 세부 상태를 나눕니다.
하지만 그 확장된 상태 모델도 기본 질문에서 출발합니다.
- 작업이 아직 끝나지 않았는가
- 성공 값이 있는가
- 실패 이유가 있는가
Promise를 작업 상태로 이해하면, 라이브러리가 제공하는 상태 필드도 단순 편의값이 아니라 비동기 흐름을 UI 언어로 번역한 결과로 보입니다.
async/await로 넘어가기 전에 잡아야 할 기준선
async/await는 Promise를 더 순서대로 읽히게 만들어 줍니다.
async function printUserName() {
const name = await loadUserName();
console.log(name);
}이 코드는 동기 코드처럼 보입니다. 그래서 async/await를 "비동기를 동기로 바꿔 주는 문법"처럼 설명하기도 합니다. 하지만 정확히는 그렇지 않습니다.
await는 Promise를 없애지 않습니다. await는 Promise가 끝날 때까지 다음 줄 실행을 잠시 미루고, fulfilled가 되면 성공 값을 꺼냅니다. rejected가 되면 예외처럼 흐름을 던집니다.
즉 async/await를 이해하기 위한 기준선은 여전히 Promise입니다.
- await하는 대상은 Promise입니다.
- fulfilled가 되면 값처럼 이어집니다.
- rejected가 되면 throw처럼 이어집니다.
- pending 동안에는 해당 async 함수 안의 다음 줄이 바로 실행되지 않습니다.
이 기준선을 잡지 못하면 async/await 코드에서도 같은 혼란이 반복됩니다. 겉으로는 동기 코드처럼 보이는데, 실제로는 비동기 작업의 상태 전환 위에서 움직이기 때문입니다.
그래서 1편에서는 여기까지만 잡고 멈추겠습니다. 이벤트 루프와 마이크로태스크 큐, 즉 "왜 나중에 실행되는가"는 중요한 주제지만 지금 글의 중심은 아닙니다. 이 글의 목표는 실행 순서 전체를 설명하는 것이 아니라, Promise를 값이 아니라 상태를 가진 작업으로 보는 기준선을 세우는 것입니다.
마치며
Promise를 "미래의 값"이라고만 이해하면 비동기 코드를 값 대입의 연장선으로 보기 쉽습니다. 하지만 Promise는 값 자체가 아닙니다. pending에서 시작해 fulfilled 또는 rejected로 한 번 결정되는 비동기 작업의 상태입니다.
then은 성공 상태에 반응하고, catch는 실패 상태에 반응합니다. finally는 성공과 실패 어느 쪽이든 작업이 끝난 뒤의 정리 흐름을 맡습니다. Promise.resolve와 Promise.reject는 이미 결정된 성공 또는 실패 흐름을 Promise 형태로 맞춰 줍니다.
이 기준선을 잡아 두면 async/await도 더 정확히 보입니다. async/await는 비동기를 동기로 바꾸는 문법이 아니라, Promise의 상태 전환을 더 읽기 쉬운 형태로 쓰게 해 주는 문법입니다.
다음 글에서는 이 지점에서 이어 가겠습니다. async와 await가 왜 동기 코드처럼 보이지만 실제로는 비동기 흐름을 그대로 유지하는지, 그리고 그 차이가 에러 처리와 UI 상태에서 어떤 오해를 만드는지 정리하겠습니다.