Ayden's journal

비동기 고아

자바스크립트를 사용하다 보면 await 없이 비동기 함수를 호출하거나, .then(), .catch() 같은 후속 처리 없이 Promise를 생성하는 코드를 종종 보게 된다. 겉보기에 문제는 없어 보이지만, 이런 방식은 비동기 작업이 제대로 실행되지 않거나 중간에 무시될 위험을 내포하고 있다. 후속 처리를 붙이지 않으면 해당 Promise는 단지 생성만 된 채 아무도 추적하지 않는 상태로 남기 때문이다. 이러한 상태를 Unhandled Promise 혹은 보호자 없이 방치된 아이와 같다고 하여 "비동기 고아(Orphan Promise)"라고 부른다.

 

문제가 발생하는 이유

자바스크립트의 Promise는 비동기 작업의 결과를 나타내는 객체로, 생성 즉시 비동기 처리가 시작된다. 하지만 해당 Promise에 .then()이나 await 같은 후속 처리가 없으면, 자바스크립트 엔진, 특히 Node.js는 이 작업을 중요하지 않은 작업으로 간주할 수 있다. 이는 이벤트 루프가 활성 핸들(Active Handles)과 활성 요청(Active Requests)을 추적하면서, 후속 처리가 없는 Promise는 더 이상 추적 대상이 아니라고 판단하기 때문이다. 결과적으로 이러한 Promise는 이벤트 루프에서 무시되거나 중단될 위험이 있다.

더불어, NestJS나 Express 같은 서버 프레임워크에서는 HTTP 요청이 종료되는 시점에 관련 리소스가 정리된다. 이때 완료 여부를 추적하지 않은 Promise는 가비지 컬렉션 대상이 되어 비동기 작업이 중간에 멈출 수 있다. 또한, Promise 실행 도중 에러가 발생하더라도 .catch() 같은 에러 처리기가 없으면, Node.js는 이를 ‘Unhandled Promise Rejection’으로 감지해 경고 이벤트를 발생시키며, 경우에 따라 프로세스 종료까지 이어질 수 있다.

 

예를 들어, 아래와 같은 코드가 있다고 해보자. 처음 이 코드를 작성했을 때, 나는 인터뷰 메시지를 데이터베이스에 저장하는 작업의 결과를 굳이 기다릴 필요가 없다고 판단했다. 그래서 await 없이 단순히 Promise만 생성하고 종료했다.

createInterviewMessage(interviewMessage: Omit<InterviewMessage, "createdAt" | "id">) {
  this.prisma.interviewMessage.create({
    data: interviewMessage,
  });
}

그러나 이처럼 await 없이 생성된 Promise는 시스템 입장에서 “누구도 기다리지 않는 작업”으로 인식된다. NestJS처럼 요청-응답 기반의 프레임워크에서는 해당 HTTP 요청 처리가 끝나는 순간, 이벤트 루프는 다음 작업으로 넘어가 버린다. 결과적으로, 아직 완료되지 않은 비동기 작업이 중간에 유실될 수 있다.

 

비동기 고아가 발생하면 DB 저장이 누락될 수 있으며, 로그를 남기지 않으면 이러한 문제를 추적하기조차 어렵다. 작업이 실패해도 아무도 이를 감지하지 못하기 때문에, 사용자 입장에서는 모든 것이 정상처럼 보이지만 백엔드 상태는 실제로 일관성이 깨진다. 이처럼 겉으로 드러나지 않는 실패는 특히 실무에서 치명적인데, 테스트 환경에서는 우연히 잘 동작하더라도 운영 환경에서는 비동기 타이밍 이슈로 인해 예기치 못한 장애로 이어질 수 있기 때문이다.

 

fire-and-forget

위에서 봤던 상황과 같이 응답 성능을 위해 비동기 작업을 아예 기다리지 않는 구조가 필요할 때도 있다. 이런 경우를 'fire-and-forget'이라고 부른다. 이 방식은 작업의 성공 여부나 완료 시점을 기다리지 않고 바로 다음 로직으로 넘어가야 할 때 유용하다. 예를 들어, 로그 전송, 알림 발송, 통계 집계 같은 부가 작업들이 여기에 해당한다. 하지만 이렇게 작업을 "내버려 두면" 앞서 설명한 비동기 고아 문제가 발생할 수 있으므로, 반드시 최소한의 오류 처리만은 해줘야 한다.

가장 간단한 방법은 아래와 같이 .catch()를 붙여서 에러를 로깅하거나 처리하는 것이다. 이렇게 하면 Node.js는 이 Promise가 여전히 중요하다고 판단하여 이벤트 루프에서 작업 완료를 기다리며, 만약 에러가 발생해도 이를 무시하지 않고 기록할 수 있다. 즉, fire-and-forget이라도 ‘완전 방치’하지 말고, 최소한의 예외 처리는 꼭 해줘야 한다는 뜻이다.

this.prisma.interviewMessage
  .create({ data: interviewMessage })
  .catch((err) => console.error("DB 저장 실패:", err));

 

 

결론적으로, 비동기 작업을 제대로 관리하지 않으면 예상치 못한 데이터 누락이나 시스템 불안정으로 이어질 수 있으므로, await를 통한 명시적 대기 혹은 .catch()를 통한 오류 처리를 습관화하는 것이 중요하다. fire-and-forget 상황에서는 최소한의 에러 처리를 반드시 포함시켜 비동기 고아 문제를 예방해야 한다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기