JavaScript 비동기 흐름 다시 보기 (04) - try/catch가 잡는 에러와 놓치는 에러
동기 예외, await한 rejected Promise, 기다리지 않은 Promise 실패를 구분하며 try/catch의 실제 범위를 정리합니다.
try/catch는 모든 비동기 에러를 자동으로 잡아 주지 않습니다. 같은 실행 흐름 안에서 throw된 예외와 await한 rejected Promise를 잡습니다.
들어가며
앞선 글에서는 Promise의 후속 흐름이 현재 동기 실행이 끝난 뒤 microtask로 이어진다는 점을 정리했습니다. 이제 이 기준을 에러 처리에 적용해 보겠습니다.
JavaScript에서 에러 처리를 떠올리면 가장 먼저 try/catch가 나옵니다.
try {
riskyWork();
} catch (error) {
console.error(error);
}동기 코드만 보면 이 구조는 직관적입니다. try 안에서 문제가 생기면 catch가 잡습니다. 하지만 Promise와 async/await가 들어오면 이야기가 조금 달라집니다.
겉으로는 try/catch로 감싼 것처럼 보이는데도 에러가 바깥으로 새거나, 반대로 await를 붙였더니 같은 catch에서 잡히는 경우가 생깁니다.
이번 글의 질문은 하나입니다.
try/catch는 무엇을 잡고, 무엇을 놓칠까요. 기준은 에러가 같은 실행 흐름 안에서 발생했는지, Promise 실패를 await하고 있는지 입니다.
동기 throw는 가장 가까운 catch가 잡습니다
먼저 동기 코드부터 보겠습니다.
function parseConfig(text: string) {
if (!text.startsWith("{")) {
throw new Error("JSON 형식이 아닙니다.");
}
return JSON.parse(text);
}
try {
const config = parseConfig("invalid");
console.log(config);
} catch (error) {
console.error("설정을 읽지 못했습니다.", error);
}parseConfig는 조건이 맞지 않으면 throw로 예외를 발생시킵니다. 이 함수는 try 블록 안에서 호출됐고, 예외도 같은 동기 실행 흐름 안에서 발생했습니다.
그래서 catch가 이 예외를 잡습니다.
여기서 throw는 일반 반환값과 다릅니다. 함수가 값을 돌려주는 것이 아니라 현재 흐름을 중단하고, 가장 가까운 처리 지점으로 이동합니다.
즉 동기 코드에서는 아래 기준이 비교적 단순합니다.
try안에서 함수가 호출됩니다.- 그 호출 흐름 안에서
throw가 발생합니다. - 가장 가까운
catch가 예외를 처리합니다.
이 기준은 Promise가 들어오기 전까지는 크게 흔들리지 않습니다.
then 안에서 던진 에러는 rejected Promise가 됩니다
Promise 체인에서는 then 안에서 발생한 예외가 다음 Promise의 rejected 상태로 이어집니다.
Promise.resolve("not-json")
.then((text) => {
return JSON.parse(text);
})
.catch((error) => {
console.error("파싱에 실패했습니다.", error);
});JSON.parse("not-json")는 예외를 던집니다. 이 예외는 then 콜백 안에서 발생했기 때문에, Promise 체인에서는 다음 rejected 흐름으로 바뀝니다. 뒤에 붙은 catch는 그 실패를 처리합니다.
이 코드에서 중요한 점은 catch가 동기 try/catch 문법이 아니라 Promise 메서드라는 것입니다. Promise의 .catch()는 앞선 Promise 체인에서 발생한 rejected 흐름을 처리합니다.
즉 Promise 체인에서는 아래처럼 읽는 편이 좋습니다.
then안에서 값을 반환하면 다음 fulfilled 흐름으로 이어집니다.then안에서 예외가 발생하면 다음 rejected 흐름으로 이어집니다.- 뒤에 붙은
.catch()가 그 rejected 흐름을 처리합니다.
이 기준은 async/await를 이해할 때도 그대로 이어집니다.
await한 rejected Promise는 try/catch가 잡습니다
async/await에서는 rejected Promise가 await 지점에서 예외처럼 보입니다.
async function loadUserName() {
try {
const response = await fetch("/api/user");
const user = await response.json();
return user.name;
} catch (error) {
console.error("사용자 이름을 불러오지 못했습니다.", error);
return "이름 없음";
}
}이 코드에서 fetch("/api/user")가 rejected 상태가 되면 첫 번째 await 지점에서 예외처럼 흐름이 넘어갑니다. response.json()이 rejected 상태가 되어도 두 번째 await 지점에서 같은 catch로 넘어갑니다.
여기서 핵심은 try 안에 Promise를 만들었다는 사실이 아닙니다. 그 Promise를 await하고 있다는 점입니다.
await는 fulfilled Promise에서는 값을 꺼내고, rejected Promise에서는 예외처럼 흐름을 던집니다. 그래서 같은 try/catch 안에서 처리할 수 있습니다.
async function readSettings() {
try {
const settings = await loadSettings();
return settings.theme;
} catch {
return "light";
}
}이 구조는 실무에서 가장 익숙한 async/await 에러 처리 방식입니다. 읽기 쉽고, 성공 흐름과 실패 흐름이 한 함수 안에 모입니다.
다만 이 구조가 안전하려면 조건이 있습니다. 실패할 수 있는 Promise를 실제로 await해야 합니다.
await하지 않은 Promise 실패는 같은 catch로 들어오지 않습니다
일부러 잘못 쓴 예를 보겠습니다.
async function saveAndRedirect() {
try {
savePost();
navigateToPostList();
} catch (error) {
showErrorMessage(error);
}
}savePost()가 Promise를 반환하고, 그 Promise가 나중에 rejected가 된다고 가정해 보겠습니다. 이 코드는 겉으로는 try/catch로 감싸져 있습니다. 하지만 savePost()를 await하지 않았습니다.
그래서 savePost() 호출 자체가 동기적으로 예외를 던지지 않는 한, 나중에 발생한 rejected Promise는 이 catch로 들어오지 않습니다.
더 정확히는 이런 구조입니다.
async function savePost() {
throw new Error("저장에 실패했습니다.");
}
async function saveAndRedirect() {
try {
savePost();
navigateToPostList();
} catch (error) {
showErrorMessage(error);
}
}savePost() 안에서 throw가 발생하지만, async 함수 안의 throw는 rejected Promise로 이어집니다. saveAndRedirect는 그 Promise를 await하지 않았기 때문에, 같은 catch가 실패를 처리하지 못합니다.
고쳐 쓰면 이렇게 됩니다.
async function saveAndRedirect() {
try {
await savePost();
navigateToPostList();
} catch (error) {
showErrorMessage(error);
}
}이제 savePost()가 rejected 상태가 되면 await savePost() 지점에서 예외처럼 흐름이 넘어가고, catch가 잡습니다.
이 차이는 작아 보이지만 실무에서 자주 중요합니다. 저장 요청이 실패했는데도 화면이 목록으로 이동하거나, 에러 메시지가 보이지 않는 문제가 여기서 생길 수 있습니다.
나중에 실행되는 콜백의 throw도 같은 try/catch로 잡히지 않습니다
try/catch는 같은 동기 실행 흐름 안의 예외를 잡습니다. 그래서 나중에 실행되는 콜백 안의 throw는 바깥의 try/catch로 잡히지 않습니다.
일부러 잘못 쓴 예를 보겠습니다.
try {
setTimeout(() => {
throw new Error("나중에 발생한 에러입니다.");
}, 0);
} catch (error) {
console.error("잡았습니다.", error);
}이 코드는 catch가 에러를 잡을 것처럼 보이지만, 실제로는 그렇지 않습니다. setTimeout에 넘긴 콜백은 현재 try 블록이 끝난 뒤 별도의 task로 실행됩니다.
즉 throw가 발생하는 시점에는 이미 바깥 try/catch의 실행이 끝나 있습니다.
이런 코드는 콜백 내부에서 직접 처리해야 합니다.
setTimeout(() => {
try {
riskyWork();
} catch (error) {
console.error("타이머 콜백 안에서 처리했습니다.", error);
}
}, 0);Promise도 비슷한 오해를 만들 수 있습니다. Promise의 후속 흐름은 microtask로 이어지므로, 현재 동기 try/catch가 끝난 뒤 실행될 수 있습니다. 그래서 Promise 실패는 .catch()로 연결하거나 await로 현재 async 함수의 try/catch 안에 끌어와야 합니다.
return과 await의 차이도 에러 처리에 영향을 줍니다
async 함수 안에서 Promise를 반환할 때도 주의할 점이 있습니다.
async function loadUser() {
try {
return fetchUser();
} catch (error) {
return null;
}
}이 코드는 fetchUser()가 반환한 Promise를 그대로 return합니다. 만약 fetchUser()가 나중에 rejected 상태가 되면, 이 catch는 그 실패를 잡지 못합니다. try 블록 안에서 동기적으로 던진 예외가 아니라, 반환된 Promise가 나중에 실패한 것이기 때문입니다.
이 함수 안에서 실패를 처리하고 싶다면 await해야 합니다.
async function loadUser() {
try {
return await fetchUser();
} catch (error) {
return null;
}
}여기서는 fetchUser()의 rejected 상태가 await 지점에서 예외처럼 바뀌고, catch가 처리합니다.
다만 항상 return await가 필요하다는 뜻은 아닙니다. 실패 처리를 호출한 쪽에 맡기고 싶다면 Promise를 그대로 반환할 수 있습니다.
async function loadUser() {
return fetchUser();
}
async function renderUser() {
try {
const user = await loadUser();
render(user);
} catch (error) {
renderFallback(error);
}
}중요한 것은 어느 함수가 실패를 책임질지 정하는 것입니다. catch를 써 놓고 실제 실패는 다른 Promise 흐름으로 흘려보내면, 코드가 읽히는 것과 실제 에러 처리 범위가 달라집니다.
UI 코드에서는 실패 처리의 위치가 곧 상태 설계입니다
비동기 에러 처리는 단순히 콘솔에 에러를 찍는 문제가 아닙니다. UI에서는 실패를 어디에서 잡느냐가 곧 상태를 어디에서 바꿀지의 문제로 이어집니다.
type SaveState =
| { status: "idle" }
| { status: "pending" }
| { status: "fulfilled" }
| { status: "rejected"; message: string };
let state: SaveState = { status: "idle" };
async function submit() {
state = { status: "pending" };
try {
await savePost();
state = { status: "fulfilled" };
} catch (error) {
state = {
status: "rejected",
message: error instanceof Error ? error.message : "저장에 실패했습니다.",
};
}
}이 코드는 savePost() 실패를 submit 안에서 책임집니다. 그래서 pending, fulfilled, rejected 상태 전환도 한곳에 모입니다.
반대로 savePost()를 await하지 않으면 상태는 쉽게 어긋납니다.
async function submit() {
state = { status: "pending" };
try {
savePost();
state = { status: "fulfilled" };
} catch (error) {
state = {
status: "rejected",
message: error instanceof Error ? error.message : "저장에 실패했습니다.",
};
}
}이 코드는 저장이 끝나기 전에 fulfilled 상태로 바꿀 수 있습니다. 그리고 나중에 savePost()가 실패해도 같은 catch에서 rejected 상태로 바뀌지 않습니다.
즉 UI 상태를 안정적으로 만들려면 질문을 바꿔야 합니다.
- 실패할 수 있는 Promise를 await하고 있는가
- 실패 처리를 이 함수가 책임지는가
- 아니면 호출한 쪽으로 Promise를 그대로 넘기는가
- 실패했을 때 UI 상태가 어디에서 rejected로 바뀌는가
이 질문이 명확해야 try/catch가 의미 있는 에러 처리 경계가 됩니다.
마치며
try/catch는 강력하지만 모든 비동기 실패를 자동으로 잡지는 않습니다. 동기 코드에서 throw된 예외는 가장 가까운 catch가 잡습니다. async/await에서는 await한 rejected Promise가 catch로 들어옵니다.
반대로 await하지 않은 Promise의 실패, 나중에 실행되는 콜백 안의 throw, 호출한 쪽으로 그대로 반환한 Promise의 rejected 상태는 같은 try/catch로 잡히지 않을 수 있습니다.
결국 기준은 하나입니다.
try/catch가 잡는 것은 "어딘가에서 발생한 모든 에러"가 아닙니다. 현재 실행 흐름 안의 throw와 await로 끌어온 rejected Promise입니다.
다음 글에서는 여러 Promise를 함께 다룰 때 실패가 어떻게 달라지는지 보겠습니다. Promise.all, race, any, allSettled는 모두 여러 작업을 묶지만, 실패를 해석하는 방식은 서로 다릅니다.