본문으로 건너뛰기

Fire-and-forget 패턴: await를 빼면 실패는 누가 책임질까?

· 약 13분
MinjiKim
MinjiKim
FrontEnd Engineer

기다리지 않아도 되는 Promise는 무엇일까?

async function generateImage(prompt: string) {
const job = await createJob(prompt) // 1. job 생성, id 반환
const result = await pollUntilDone(job.id) // 2. job 완료까지 polling

await sendTelemetry('image_generated', { jobId: job.id }) // 3. telemetry 전송

return result
}

AI 이미지 생성 SDK에 이런 함수가 있다고 해봅시다.

이 코드에서 sendTelemetry 앞의 await는 필요할까요?

바로 답이 나오지 않는다면, 이렇게 질문을 바꿔보겠습니다.

이 함수가 호출자에게 결과를 반환하기 전에 이 세 호출 중 어디까지 기다려야 할까요?

기준은 이 호출의 실패가 이미지 생성 실패로 이어지는지입니다.

createJob이 실패하면 job id가 없고, pollUntilDone이 실패하면 결과 이미지가 없습니다. 이 둘은 이미지 생성 흐름의 일부입니다. 반면 sendTelemetry는 실패하더라도 이미지 결과 자체가 이미 만들어졌고, 호출자에게 반환할 result도 있습니다. 이런 호출은 사용자 흐름을 막지 않도록 기다리지 않아도 됩니다.

이처럼 비동기 작업을 시작하되 그 결과를 기다리지 않는 방식을 흔히 fire-and-forget 패턴이라고 합니다. 이 글에서는 그중에서도 Promise를 반환하는 함수를 await하지 않는 경우를 다룹니다.

sendTelemetry() // async 함수. Promise 반환. await 안 함.
console.log('continue')

이때 await하지 않은 Promise가 reject되면, 그 실패는 사라지는 것이 아니라 처리되지 않은 Promise rejection으로 남습니다.

SDK 작성자라면 여기에서 더 생각해야 할 부분이 있습니다. 호스트마다 unhandledrejection 처리 방식이 다르기 때문에, SDK는 호스트가 어떤 환경인지 알 수 없습니다.

await 하지 않은 Promise의 실패는 어디로 갈까?

그게 어떻게 SDK 작성에 영향을 미치게 되는 걸까요? 같은 코드라도 호스트의 전역 에러 처리 방식에 따라 다르게 보일 수 있습니다. 먼저 가장 간단한 코드로 동작을 확인해봅시다.

async function sendTelemetry() {
throw new Error('analytics endpoint down')
}

sendTelemetry()
console.log('continued')

동일한 코드를 Vite 앱, google.com, claude.ai의 콘솔에서 돌려보았을 때 아래처럼 다른 결과가 나왔습니다.

환경unhandledrejection 리스너preventDefault 호출콘솔 출력
Vite dev발생
google.com🟢🟢발생 X
claude.ai🟢발생

Vite dev — 핸들러 없음, 에러 그대로 출력

continued
Uncaught (in promise) Error: analytics endpoint down
at sendAnalytics (<anonymous>:2:9)
at <anonymous>:5:1

google.com — 핸들러가 preventDefault() 호출, 콘솔 출력 억제

continued

claude.ai — 핸들러 있지만 preventDefault() 미호출, 에러 출력

continued
Uncaught (in promise) Error: analytics endpoint down
at sendAnalytics (<anonymous>:2:9)
at <anonymous>:5:1

동일한 코드임에도 페이지마다 결과가 달랐습니다. 차이를 만든 핵심은 각 페이지가 unhandledrejection 이벤트를 어떻게 다루느냐입니다.

Promise가 reject됐을 때, catchthen의 rejection 핸들러가 등록되어 있지 않으면, 브라우저에서 unhandledrejection 이벤트를 발생시킬 수 있습니다. 기본 동작은 아무 처리도 하지 않은 Vite dev 환경에서처럼 콘솔창에 에러를 찍는 것입니다.

그렇다면 왜 google.com에서는 이런 에러가 찍히지 않았을까요? 전역 핸들러가 preventDefault()를 호출하고 있었고, 그래서 기본 콘솔 출력이 막힌 것으로 보입니다.

MDN은 unhandledrejection 이벤트의 기본 동작으로 콘솔 출력이 발생할 수 있으며, 이를 막으려면 event.preventDefault()를 사용할 수 있다고 설명합니다.

다만 이 동작은 브라우저마다 다를 수 있습니다. Firefox는 스펙과 달리 preventDefault()를 호출해도 콘솔 출력을 억제하지 않습니다 (Mozilla Bugzilla #1642147). 위 실험은 Chrome 기준 결과입니다.

그렇다면 호스트가 google.com처럼 전역 핸들러를 두고 preventDefault()까지 호출한다면, SDK는 아무것도 하지 않아도 될까요? 사실 경우에 따라 다릅니다.

google.com이 preventDefault() 후 reason을 내부 함수로 넘기더라도, SDK 작성자는 그 처리가 무엇인지 알 수 없고 접근할 수도 없습니다. 즉 SDK가 자기 자신의 에러를 관측하려면 호스트 처리와는 별개로 SDK 자체 처리가 필요합니다. 또한, 텔레메트리 전송처럼 SDK 내부 호출은 호스트가 알고 싶어 할 만한 에러가 아니니 SDK에서 에러를 처리해주는 것이 깔끔합니다.

fire-and-forget Promise를 다루는 3가지 선택지

SDK에서 호스트에 이 에러를 흘려보내지 않기로 결정했다면 어떻게 구현할 수 있을까요?

await하지 않는 Promise를 다루는 선택지는 크게 세 가지입니다. 차이는 실패의 책임을 어디에 두느냐입니다.

선택지의미same-origin에서 unhandledrejection
void sendTelemetry()결과를 기다리지 않는다발생할 수 있음
sendTelemetry().catch(() => {})실패를 의도적으로 삼킨다발생하지 않음
sendTelemetry().catch(e => report(e))실패를 SDK 내부에서 관측한다발생하지 않음

void sendTelemetry()는 에러를 처리하는 것처럼 보이지만 실제로는 아무 처리도 하지 않습니다. 단지 결과값을 버리고, 코드 리뷰어와 ESLint에게 "의도적이다"라는 신호를 줄 뿐입니다. rejection은 처리되지 않은 상태로 남고, same-origin 환경에서는 호스트의 unhandledrejection 정책에 노출될 수 있습니다.

sendTelemetry().catch(() => {})는 실패를 의도적으로 삼키는 선택입니다. 이 방식은 실패해도 사용자 결과, 제품 상태, 과금, 감사 로그, 운영 지표에 영향을 주지 않는 호출에만 제한적으로 사용할 수 있습니다.

반면 sendTelemetry().catch(e => report(e))는 사용자 흐름을 막지 않고 SDK 작성자가 실패를 관측하는 선택입니다. 여기서 report는 콘솔 출력이 아니라 SDK 내부 모니터링 채널로 보내는 함수를 가정합니다.

실무 사례: 오픈소스 SDK는 어떻게 하고 있을까

오픈소스 SDK 코드를 보면 이 세 선택지가 모두 실제로 쓰입니다.

void — 이유를 주석으로 반드시 명시

자동 메시지 전송 로직에서는 await하면 데드락이 생깁니다. void를 쓰되, 왜 기다리지 않는지 주석으로 남깁니다.

// no await to avoid deadlocking
this.makeRequest({ ... })

chat.ts L520

.catch(() => {}) — 스트림 정리 실패는 조용히 무시

스트림 처리가 끝나거나 취소될 때, 리더 정리(cleanup)가 실패해도 메인 흐름에 영향을 주지 않아야 합니다. finally 블록에서 .catch(() => {})로 조용히 무시합니다.

} finally {
currentReader?.cancel().catch(() => {});
}

stream-google-interactions.ts L215

.catch(e => report) — 전송 실패를 SDK 내부에서 기록

이벤트 전송을 await하지 않되, 실패하면 SDK 내부 logger로 기록합니다. 호스트에는 노출하지 않습니다.

fetch(upstreamSentryUrl, { method: 'POST', body: envelopeBytes })
.catch(err => {
DEBUG_BUILD && debug.error('Error sending envelope to Sentry', err);
});

aws-lambda-extension.ts L154

세 선택지 모두 실제 프로덕션 SDK에서 찾아볼 수 있는 패턴입니다.

호스트도 알아야 하는 실패라면 별도 API가 필요하다

"호스트가 우리 SDK의 rejection을 알고 싶을 수도 있지 않냐"는 우려도 있을 것입니다.

앞의 선택지만으로는 SDK와 호스트가 동시에 실패를 관측하기 어렵습니다. report로 SDK 내부에서 기록하면 호스트는 알 수 없고, void로 호스트에 맡기면 SDK는 그 실패를 안정적으로 관측할 수 없습니다. 그렇다면 SDK에서도 처리하면서 호스트에도 전달해주는 방식은 어떨까요? 이 trade-off는 에러 이벤트를 호스트가 구독할 수 있도록 노출해 해결할 수 있습니다.

// SDK 내부
sendTelemetry(payload).catch((error) => {
report(error) // SDK 내부 기록
sdk.emit('telemetry_error', error) // 호스트가 구독할 수 있게 노출
})

// 호스트 사용
sdk.on('telemetry_error', (e) => {
Sentry.captureException(e) // 호스트도 원하면 자기 시스템으로
})

cross-origin SDK에서는 무엇이 달라질까?

MDN에 따르면 SDK가 CDN을 통해 cross-origin으로 로드되면, 보안상 이유로 호스트의 unhandledrejection 리스너는 SDK의 rejection을 받지 못합니다. same-origin에서 void는 호스트에 처리를 맡겼지만, cross-origin에서는 호스트가 그 rejection을 unhandledrejection 이벤트로 관측하지 못할 수 있습니다. 따라서 cross-origin SDK라면 void를 선택할 때는 더 신중해야 합니다.

닫으며

실패해도 사용자에게 전달되는 결과나 제품 상태를 바꾸지 않는 SDK 내부 호출은 fire-and-forget으로 분리할 수 있습니다. 하지만 fire-and-forget 패턴은 동작을 시작하되 기다리지 않으며, 실패했을 때의 처리는 다른 어딘가에서 책임져야 합니다.

same-origin 환경에서는 호스트가 unhandledrejection을 필요한 대로 처리할 수 있습니다. 반대로 cross-origin 환경에서는 호스트가 이를 전역 이벤트로 관측하지 못할 수도 있습니다. 따라서 SDK 작성자는 경우에 따라 이 실패를 호스트에 맡길지, 조용히 무시할지, SDK 내부에서 기록할지, 또는 별도의 API로 호스트에 알려줄지 직접 결정해야 합니다.

그 선택을 코드로 의도를 드러내는 것이 fire-and-forget의 완성입니다.

REF

  1. HTML spec - HostPromiseRejectionTrack
  2. MDN - unhandledrejection
  3. Mozilla Bugzilla #1642147 - preventDefault does not work for unhandledrejection event