2026.06.06·6

JavaScript 비동기 흐름 다시 보기 (04) - try/catch가 잡는 에러와 놓치는 에러

동기 예외, await한 rejected Promise, 기다리지 않은 Promise 실패를 구분하며 try/catch의 실제 범위를 정리합니다.

JavaScript 비동기 흐름 다시 보기 (04) - 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에서 잡히는 경우가 생깁니다.

이번 글의 질문은 하나입니다.

Keypoint

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로 잡히지 않을 수 있습니다.

결국 기준은 하나입니다.

Keypoint

try/catch가 잡는 것은 "어딘가에서 발생한 모든 에러"가 아닙니다. 현재 실행 흐름 안의 throw와 await로 끌어온 rejected Promise입니다.

다음 글에서는 여러 Promise를 함께 다룰 때 실패가 어떻게 달라지는지 보겠습니다. Promise.all, race, any, allSettled는 모두 여러 작업을 묶지만, 실패를 해석하는 방식은 서로 다릅니다.