Javascript

트러블슈팅: blob URL과 새 창에서 메모리 누수 방지하기!

변기원 2024. 8. 19. 12:44

pdf 데이터를 서버에서 base64로 인코딩 된 문자열을 받아서 pdf타입의 blob객체를 만들고 이것을 새창에서 띄운다.

새창에서 한번 확인한 후에 저장한다.

blob url을 만들어서 새창에 띄워주면 크롬 pdf 뷰어가 나오면서 빨간색 동그라미로 표시한 다운로드 버튼을 누르면 pdf가 저장된다.

근데 이 pdf가 엄청 많아질 수 있다. 그러면 자연스럽게 서버에서 받아오는 base64로 인코딩 된 데이터가 많아진다.

이것을 URL.createObjectURL(blob)메서드로 계속 만들기만 한다면 메모리에 데이터가 계속 쌓이기만 할 테니 필요 없어지면 제거하자.

const openToArrayBuffer = ({
    data,
    extension = 'pdf',
  }: IOpenToArrayBufferProps) => {
    const blob = base64ToBlob(data, DOWNLOAD_EXTENSION_TYPE[extension]);
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    
    link.href = url;
    link.target = '_blank';
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(url);
  };

 

url을 생성하고 a태그를 프로그래밍적으로 생성한 뒤에 href와 target 어트리뷰트를 넣어주고

클릭시키면 새창에 내가 원하는 blob url이 뜨면서 pdf 뷰어가 나온다.

그리고 바로 body에서 필요없는 a 태그를 지워주고 메모리까지 청소해 준다.

이제 다운로드 버튼을 누르면 이런 에러가 나온다.

 

다운로드가 안된다. blob으로 만든 데이터가 분명히 새창에서 잘 보이는데도 다운로드가 안된다! 

혹시나 싶어. revokeObjectURL을 제거했더니 다운로드가 잘 된다.

url을 생성한 old window의 메모리에서 revoke 시키니 new window에서 다운로드가 안 되는 것이다.

 

https://pungwa.tistory.com/226

 

[Nextjs]트러블슈팅: 브라우저의 프로세스와 스레드, 과부하로 인한 이벤트 생략?

이 글을 리액트 코드의 성능과 브라우저에 대한 내용이다.분명히 발생해야 할 이벤트가 무시될 수도 있을까?아래와 같은 코드가 있었다const [barcode, setBarcode] = useState('');const handleBarcode = (e) => { se

pungwa.tistory.com


위 포스팅에서 배운 내용인데, 크롬은 새 탭마다 프로세스를 할당한다. 프로세스가 각자의 메모리공간을 가지고 있기 때문에 독립적으로 실행될 수 있다. 위 케이스는 blob url로 분명히 새 창이 켜졌지만 메모리 공간을 공유하고 있다는 것이고 프로세스가 따로 할당되지 않았다는 추측이 되었다.

새 창이 같은 프로세스에서 실행되어 url을 생성한 window의 메모리공간을 같이 참조하고 있는 것 아닐까?

 

https://chromium.googlesource.com/chromium/src/+/main/docs/process_model_and_site_isolation.md#Special-Cases

 

Chromium Docs - Process Model and Site Isolation

Security Principal (implemented by SiteInfo): In security terminology, a principal is an entity with certain privileges. Chromium associates a security principal with execution contexts (e.g., documents, workers) to track which data their process is allowe

chromium.googlesource.com

크로미움 docs에서 프로세스모델과 사이트격리 부분에 special case부분을 보면 힌트가 있다.

data URLs와 file URLs인 경우 

Chromium generally keeps documents with data: URLs in the same process as the site that created them

그 url을 생성한 곳과 같은 프로세스를 유지한다는 것이다.

추측했듯이, 새 창으로 열었지만 blob url을 생성한 프로세스와 같은 프로세스에서 실행되고 있으므로 동기적으로 revoke 시켜버리면 유저가 다운로드 할때쯤엔 이미 메모리에서 사라진 상태가 되겠다.

그래서 다운로드도 안되고 새로고침 하면 이미 메모리에 없으니 미리보기도 보여줄 수 없는 게 정상이다.

const openToArrayBuffer = ({
    data,
    extension = 'pdf',
  }: IOpenToArrayBufferProps) => {
    const blob = base64ToBlob(data, DOWNLOAD_EXTENSION_TYPE[extension]);
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    
    link.href = url;
    link.target = '_blank';
    document.body.appendChild(link);
    link.click();
    // 여기서 새창이 열려서 pdf뷰어로 미리보기 확인할 수 있지만
    document.body.removeChild(link);
    URL.revokeObjectURL(url);
    // 즉시 revokeObjectURL 메서드가 실행되어 메모리에서는 사라진다. 
    // 그래서 새로고침하면 안보인다.
  };

 

그렇다고 revoke 메서드를 제거하면 브라우저 메모리에 누수가 발생할 것이다. 용량이 꽤 커질 수 있는 상황..

 

해결

유저가 모든 행동을 완료하고 pdf 뷰어를 끌 때 revoke를 시켜주면 된다.

그럼 window와 window 사이에 통신을 해야 하고 이럴 때 window.postMessage()를 사용한다.

https://developer.mozilla.org/ko/docs/Web/API/Window/postMessage

const openToArrayBuffer = ({
    data,
    extension = 'pdf',
  }: IOpenToArrayBufferProps) => {
    const blob = base64ToBlob(data, DOWNLOAD_EXTENSION_TYPE[extension]);
    const url = URL.createObjectURL(blob);
    const newWindow = window.open(url, '_blank');

    if (newWindow) {
      newWindow.addEventListener('load', () => {
        const script = newWindow.document.createElement('script');
        script.textContent = `
        window.addEventListener('beforeunload', function() {
          window.opener.postMessage('closing', window.location.origin);
        });
      `;
        newWindow.document.body.appendChild(script);
      });

      const messageListener = (event: MessageEvent) => {
        if (event.data === 'closing') {
          URL.revokeObjectURL(url);
          window.removeEventListener('message', messageListener);
        }
      };
      window.addEventListener('message', messageListener);
    } else {
      console.error('Failed to open new window');
      URL.revokeObjectURL(url);
    }
  };

 

새 창에 script를 첨부해야 한다. 이벤트는 beforeunload이다.

새창이 꺼지기 직전에 발생하는 이벤트인데, 이 순간에 opener에게 postMessage를 보내주자.

new window에는 load이벤트에 내가 원하는 script를 넣어주고

window에는 message 이벤트 리스터를 등록한다. 그럼 newWindow가 꺼지기 직전에 opener에게 closing이라는 메시지를 보낼 것이고, window는 이 이벤트를 보고 revoke를 시키고 이벤트 리스너 또한 remove 시켜준다.

 

저번 트러블 슈팅 때 사이트격리와 브라우저의 프로세스에 관한 글을 한번 읽었던 것이 원인을 찾아내는데 큰 도움이 되었다.