new Promise(async ...), 내 에러 어디 갔니? (부제: 스택트레이스는 죄가 없습니다)

요즘 공부한다는 핑계로 남의 오픈소스를 뜯어보는 취미가 생겼습니다. 이번에 뜯은 건 ethora-chat-component-rn이라는 리액트네이티브 채팅 라이브러리였어요. XMPP 실시간 연결을 실제 프로덕트 코드가 어떻게 굴리는지 보고 싶었거든요. (제 프로젝트는 아닙니다. 저는 그냥 남의 집 구경 온 사람이에요.)

그런데 CHANGELOG를 읽다가 흥미로운 사냥 기록을 발견했습니다. 26.5.8 릴리즈에서 src/ 전체에 promise 위생 점검을 돌렸는데, 오래 묵은 id:0/id:2 레드스크린 — 재연결 이후에만 간헐적으로 뜨던 그 유령 — 의 유력 용의자로 파일 딱 두 개가 지목된 거예요. getRoomsPaged.xmpp.ts, presenceInRoom.xmpp.ts.

궁금해서 두 파일을 열어봤더니, 범인의 얼굴이 같았습니다.

new Promise(async (resolve, reject) => {
  // ... 안에 await하지 않은 client.send(...)
});

Promise 생성자에 async 함수를 넘기는 패턴. 한 번쯤 써보신 적 있으실 거예요. 저요? ...그 질문에는 노코멘트하겠습니다.

증상: try/catch로 다 감쌌는데 에러가 샌다

이 패턴이 심어진 앱의 증상은 이렇습니다. 분명 try/catch로 다 감쌌는데 원인 불명의 Uncaught (in promise) 빨간 화면이 뜨고, 스택 트레이스는 프레임워크 내부 어딘가를 가리킵니다. 정작 진짜 실패 지점은 스택 어디에도 없어요. 에러는 실존하는데 지목된 용의자는 무죄인, 수사가 제일 꼬이는 유형입니다.

ethora의 경우가 정확히 이랬습니다. 재연결 시점에 await하지 않은 client.send(...)가 reject되면, 그 rejection이 try/catch를 유유히 빠져나가 unhandled rejection으로 떠올랐던 거예요. XMPP 전송 로직 근처는 쳐다보지도 않는 스택을 달고요.

왜: executor는 원래 동기 함수라는 계약

Promise 생성자의 executor(생성자에 넘기는 그 콜백)는 원래 동기 함수여야 합니다. 그 안에서 resolve 또는 reject를 동기적으로 호출하는 것까지가 계약의 전부예요.

그런데 async를 붙이는 순간, 이 함수는 Promise 생성자가 존재조차 모르는 또 다른 promise를 반환하게 됩니다. 그 내부 promise가 reject되어도, 바깥 promise의 reject로 흘러갈 경로 자체가 없어요. 그래서 두 종류의 실패가 조용히 퇴근합니다.

  • await하지 않은 내부 작업에서 발생한 rejection
  • 내부 try 블록보다 먼저 터진 동기 throw

둘 다 바깥으로 전파되지 않으니, 이 promise는 영영 pending 상태로 명상에 들어가거나, 엉뚱한 스택을 단 Uncaught (in promise)로 튀어나옵니다. ESLint에 no-async-promise-executor라는 전용 규칙이 있는 것도, 이 실패가 그만큼 소리 없이 조용하기 때문이에요. 사람 눈으로는 못 잡는다고 린트가 공인한 셈입니다.

어떻게 고칠까요?

1. 대부분의 경우: 그냥 async 함수로 올리세요

가장 흔한 정답입니다. Promise 생성자를 쓸 이유가 없다면, 그 일을 평범한 async 함수로 옮기고 생성자를 걷어내면 됩니다. async 함수는 어차피 promise를 반환하고, 안에서 뭐가 터지면 알아서 reject됩니다. 그 위에 생성자를 한 겹 더 씌우는 건 대체로 코드가 옆으로 자랐다는 신호예요.

async function loadValue() {
  const value = await doSomething(); // 실패는 자연스럽게 상위로 전파됩니다
  return value;
}

2. 꼭 생성자가 필요하다면: 내부 async IIFE로 감싸기

콜백 기반 API를 promise로 감싸는 경우처럼 정말 생성자가 필요할 때가 있죠. 그럴 땐 executor는 동기 함수로 두고, 그 안에서 async IIFE를 돌려 모든 실패를 reject()로 깔때기처럼 모아줍니다.

new Promise((resolve, reject) => {
  (async () => {
    try {
      // ... 여기서 await
      resolve(value);
    } catch (err) {
      reject(err);
    }
  })();
});

executor 자체는 동기라서 생성자가 만족하고, 에러는 try/catch가 전부 진짜 reject로 배달합니다.

3. 규칙으로 막으세요

no-async-promise-executor를 프로젝트 전역에 켜 두면, 이 패턴은 애초에 커밋되지 못합니다. 저는 평소에 "린트보다 테스트를 믿으세요" 주의인데, 이 실패 모드는 테스트의 해피패스가 절대 밟지 않는 경로라서요. 이 규칙만큼은 린트 말을 들으세요.

그리고 원인 불명의 Uncaught (in promise) 빨간 화면을 만나면, 디버거 켜기 전에 일단 이것부터 해보세요.

grep -rn "new Promise(async" src/

10초짜리 검색으로 가장 흔한 범인 하나를 확정하거나 배제할 수 있습니다. 의외로 여기서 수사가 끝나는 경우가 많아요.

정리

  • new Promise(async () => {})는 내부 rejection과 이른 동기 throw바깥 promise로 전파하지 못합니다.
  • executor는 동기 함수여야 한다는 계약을 async 한 단어가 깨기 때문입니다.
  • 대부분은 그냥 async 함수로 올리는 것이 정답이고, 정말 생성자가 필요하면 내부 async IIFE + try/catch → reject로 감쌉니다.
  • no-async-promise-executor를 켜서 재발을 원천 차단하세요.

남의 코드 뜯어보기, 은근히 남는 장사입니다. 제 코드였으면 "내가 왜 이렇게 짰지"하고 자책하며 고치고 끝났을 텐데, 남의 코드라서 패턴이 패턴으로 보였거든요. 여러분의 src/에도 유령이 살고 있다면 — grep 한 번이면 됩니다. 잡히면 어떤 reject가 실종됐었는지 댓글로 알려주세요. 저는 이런 실종 사건 수집이 취미입니다(?)