본문으로 바로가기

이번 글에서는 await의 책임 범위와 user gesture의 신뢰도 판단을 살펴볼 예정이다.

인스턴스 GPU 사용률을 엑셀 파일로 다운로드하는 기능을 구현하면서, await로 감싸둔 코드가 항상 성공으로 판단되는 문제를 겪었다.
분명히 다운로드가 되지 않는 경우도 있었는데, try / catch에서는 에러가 발생하지 않았다.

 

이 글에서는

  1. 왜 await에서는 엑셀 다운로드 실패를 감지하지 못하는지
  2. 브라우저 다운로드가 JS 관점에서 어떻게 동작하는지
  3. 최종적으로 어떤 코드가 문제를 해결했는지

이 순서로 정리해본다.

1. await는 무엇을 기다리고 있을까

다음과 같은 코드가 있다고 가정해보자.

  const handleGpuDeviceUsageDownload = async () => {
    try {
      showLoading();
      await exportSelectedInstancesGpuDeviceUsage(
        selectedHostnames,
        openSearchQuery,
        timeRange
      );
      updateToast(t('monitoring.gpuDeviceUsageDownloaded'));
    } catch (_error) {
      updateToast(t('monitoring.gpuDeviceUsageDownloadFailed'), 'error');
    } finally {
      hideLoading();
    }
  };

이 코드에서 await가 실제로 기다리는 것은 엑셀 다운로드 자체가 아니다.

await는 Promise가 resolve / reject 되는지만 기다린다.

엑셀 다운로드 로직에서 Promise가 담당하는 영역은 보통 여기까지다.

  • API 요청
  • 서버 응답 수신
  • 엑셀 데이터(buffer) 생성

2. 브라우저 다운로드는 JS가 알 수 없는 영역

엑셀 파일을 다운로드 코드이다.

  const blob = new Blob([buffer], {
    type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  });
  const url = window.URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = fileName;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);

여기서 중요한 점은

link.click() 이후의 다운로드 과정은 JS가 관여하지 않는다는 것


브라우저 내부에서는 이후에 다음과 같은 일이 일어난다.

  • 파일 저장 위치 결정
  • 사용자 취소 여부
  • 디스크 쓰기
  • 브라우저 보안 정책 판단

하지만 이 모든 과정은 Promise도 아니고, 이벤트도 아니다.
그래서

  • 다운로드가 취소돼도
  • 브라우저 정책으로 막혀도

JS 입장에서는 “click을 호출했다” → 끝이다.

이 때문에 await는 항상 성공처럼 보인다.


3. 그럼 왜 어떤 경우에는 다운로드 자체가 안 됐을까

여기서 두 번째 문제가 등장한다.

같은 코드인데

  • 로딩 오버레이가 없을 때는 잘 되고
  • 로딩 오버레이가 있을 때는 안 되는 경우가 있었다

처음에는 단순히 “타이밍 문제”라고 생각했다.
하지만 원인은 전혀 다른 곳에 있었다.


4. 문제의 핵심은 user gesture였다

브라우저는 다운로드 같은 민감한 동작을
반드시 사용자 제스처에서 직접 발생해야만 허용한다.

여기서 말하는 사용자 제스처란:

  • 실제 클릭 이벤트
  • 해당 클릭의 콜스택 안에서 실행된 코드

즉, 이런 구조다.
사용자 클릭 → 이벤트 핸들러 → 다운로드 트리거


5. 로딩 오버레이가 user gesture를 끊는 순간

문제의 코드는 보통 이런 구조였다.

onClick={() => {
  showLoading();        // 상태 변경
  handleDownload();    // async
}}

showLoading()은 단순한 UI 코드처럼 보이지만
실제로는 React 렌더 사이클을 발생시킨다.

이 과정에서:

  1. 클릭 이벤트 스택 종료
  2. 이벤트 루프 종료
  3. React 리렌더 실행
  4. 오버레이 DOM 마운트

이 시점에서 다운로드를 트리거하면
브라우저는 이렇게 판단할 수 있다.

“이 다운로드는 사용자 클릭과 직접적인 관련이 없다”


그래서 다운로드가 환경에 따라 달라진다.


6. 오버레이가 클릭을 막아서 실패하는 건 아니다

중요한 오해 하나.

오버레이가 화면을 덮고 있다고 해서, link.click()이 막히는 건 아니다.

link.click()은 마우스 이벤트가 아니라, JS가 강제로 호출하는 메서드다.

문제는 보안 판단이지, UI 레이어가 아니다.


7. 기존 방식이 더 불안정했던 이유

기존에는 이런 코드를 사용했다.

document.body.appendChild(link);
link.click();
document.body.removeChild(link);

이 방식은:

  • DOM 변경
  • 레이아웃 재계산
  • 포커스 변경

을 동반한다.

이미 user gesture가 끊긴 상태에서
DOM까지 조작하면 브라우저는 더 확신한다.

“이건 사용자가 유발한 다운로드가 아니다”


8. 최종적으로 문제를 해결한 코드

const blob = new Blob([buffer], {
  type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);

const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();

setTimeout(() => URL.revokeObjectURL(url), 1000);

이 방식의 핵심은 명확하다.

  • DOM에 추가하지 않는다
  • 렌더링에 영향을 주지 않는다
  • 클릭 이벤트 컨텍스트를 최대한 유지한다

그래서 로딩 오버레이가 있는 환경에서도 안정적으로 동작했다.

엑셀 파일 다운로드에 성공한 것을 알 수 있다.

 

여기까지 오면서 “showLoading 때문에 user gesture가 끊겼다면, DOM 구조 변경을 제거해도 의미가 없는 것 아니냐” 라고 생각할 수도 있다.

User activation is not a simple boolean that flips on and off. Browsers track activation with timing and contextual heuristics.
https://textslashplain.com/2020/05/18/browser-basics-user-gestures/

하지만 user gesture는 on / off처럼 단순하게 판단되지 않는다.
브라우저는 이를 신뢰도의 확률적 조건으로 평가하고, 그 결과에 따라 성공하거나 실패하도록 처리한다.

즉, showLoading으로 인해 user gesture가 완전히 끊긴 상태가 아니라, 신뢰도가 약해진 상태일 뿐이다.

그래서 로딩 상태 변경이 항상 다운로드 실패로 이어지는 것은 아니다.

 

다만 이 과정에서 사용자 제스처의 신뢰도가 낮아진 상태에서, appendChild와 같은 DOM 조작까지 더해지면, 브라우저는 해당 다운로드를 인위적인 행위로 판단하고 차단할 가능성이 높아진다.

DOM에 추가하지 않고 즉시 click()을 호출하는 방식이 상대적으로 안정적인 이유는, 이러한 불필요한 DOM 변경과 렌더 개입을 최소화하여 user gesture의 신뢰도를 가능한 한 유지하기 때문이다.