최근 프로젝트를 하며 사용자가 업로드한 이미지를 저장하기 위해 AWS S3 클라우드 스토리지를 사용했습니다.
프리티어를 이용했기에 제한된 용량을 효율적으로 사용하고 싶었고, 이에 따라 스토리지 내 불필요한 이미지는 저장되지 않도록 노력했습니다.
이번 글에서는 가비지 컬렉션 Hook 구현으로 AWS S3 사용 용량을 감축한 오버엔지니어링 경험을 공유합니다.
문제
아래 이미지는 현재 제가 개발한 독후감 작성 페이지입니다.
'[선택] 추가 내용 작성' 부문에 사용자는 독후감 관련 내용을 작성할 수 있고 로컬 이미지를 업로드할 수 있습니다.
마치 블로그 글 작성과 같이 말이죠.
사용자가 로컬 이미지를 업로드하면, 즉시 AWS S3에 업로드하고 해당 URL을 반환받아 img 태그의 src 속성에 삽입합니다.
이때, 이미지 스토리지의 용량을 최대한 절약하고 싶은 저로서는 두가지 문제가 발생했습니다.
1. 이미지 업로드 후 새로고침, 페이지 이동, 탭 닫기 동작
2. 이미지 업로드 후 해당 이미지 삭제 (delete 키 또는 Backspace 키로 인한)
즉, 위와 같이 이미지를 업로드한 상태에서 새로고침, 페이지 이동, 탭 닫기, 해당 이미지 삭제 동작을 할 경우 AWS S3 내에는 가비지 데이터가 남게 되는 것입니다.
AWS S3 이미지 스토리지 내 사용하지 않는 불필요한 이미지가 쌓이게 되는 것을 저는 원치 않았습니다.
useUnload Hook 구현
먼저 첫번째 문제부터 해결해봅시다.
해당 문제 해결을 위해서는 새로고침, 페이지 이동, 탭 닫기 이벤트를 감지할 수 있어야 합니다.
그래서 useUnload Hook을 구현했습니다.
// src/hooks/useUnload.ts
import { useCallback, useEffect } from 'react';
import { useRouter } from 'next/router';
const useUnload = (handleUnload: () => void) => {
const router = useRouter();
const handleBeforeUnload = useCallback(() => {
handleUnload();
}, [handleUnload]);
useEffect(() => {
router.events.on('routeChangeStart', handleBeforeUnload);
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
router.events.off('routeChangeStart', handleBeforeUnload);
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [handleUnload, handleBeforeUnload, router]);
};
export default useUnload;
NextJS의 useRouter Hook을 이용하여 'routerChangeStart' 이벤트 타입으로 페이지 이동 이벤트를 감지하도록 했습니다.
그리고 'beforeunload' 이벤트 타입으로 새로고침, 탭 닫기 이벤트를 감지할 수 있도록 했구요.
이러한 useUnload Hook에 현재 사용자가 업도르한 이미지들을 이미지 스토리지에서 모두 삭제하는 콜백 함수를 전달해주면 되겠다 생각했습니다.
하지만 치명적인 문제가 하나 발생했습니다.
'beforeunload' 이벤트 발생시 비동기 콜백 함수가 동작하지 않았습니다.
비동기 콜백 함수가 동작하기 이전에 페이지가 언로드되면서 브라우저에 의해 메모리가 초기화되기 때문입니다.
즉, 이를 해결하기 위해서는 페이지가 언로드되어도 백그라운드에서 함수의 동작을 처리할 수 있어야 합니다.
서비스워커를 이용한 useS3GarbageCollection Hook 구현
처음에는 브라우저가 자바스크립트를 실행하기 위해 싱글 스레드를 사용하는 것이 원인일 수도 있겠다 생각했습니다.
그래서 웹워커를 이용하여 별도의 스레드에서 이미지 삭제 동작을 하도록 했습니다.
💡 웹워커?
웹워커는 스크립트 연산을 웹 애플리케이션의 메인 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술입니다. 무거운 작업을 분리된 스레드에서 처리하면 메인 스레드가 멈추거나 느려지지 않고 동작할 수 있습니다.
그러나 이 방법은 새로고침 이벤트에서는 동작하나 탭 닫기 이벤트에서는 동작하지 않았습니다.
웹워커가 실행 중인 탭을 닫으면 해당 웹워커가 종료되는 특성이 원인이었습니다.
브라우저 프로세스가 종료되면 해당 프로세스에 속한 모든 스레드가 종료되기 때문에 당연한 결과였습니다.
그래서 선택한 것이 바로 서비스워커입니다.
💡 서비스워커?
서비스워커는 특정 사이트의 하나 또는 그 이상의 페이지를 제어하는 스크립트이며, 이벤트 기반 Worker로서 JavaScript로 작성된 파일입니다. 자신이 제어하는 페이지에서 발생하는 이벤트를 수신할 수 있고, 네트워크 요청을 가로채어 수정할 수 있을 뿐더러, 이를 다시 페이지로 돌려보낼 수 있습니다. 또한 서비스에서 사용하는 리소스를 캐싱할 수 있습니다.
서비스워커는 브라우저 또는 탭의 외부에 위치하기 때문에, 활성 탭이 열려있지 않아도 백그라운드에서 계속 실행될 수 있기 때문입니다.
그래서 서비스워커를 활용하여 아래와 같은 useS3GarbageCollection Hook을 구현했습니다.
// src/hooks/useS3GarbageCollection.ts
import { useCallback, useEffect, useRef } from 'react';
import s3ImageURLStore from '@/stores/s3ImageKeyStore';
import useUnload from './useUnload';
const useS3GarbageCollection = () => {
const serviceWorkerController = useRef<ServiceWorker | null>();
const { imageKeySet, emptyImageKeySet } = s3ImageURLStore();
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register(new URL('../lib/serviceWorker.ts', import.meta.url))
.then((registration) => {
serviceWorkerController.current = registration.active;
});
}
}, []);
const handleWork = useCallback(() => {
if (!imageKeySet.size) {
return;
}
serviceWorkerController.current?.postMessage(Array.from(imageKeySet));
emptyImageKeySet();
}, [emptyImageKeySet, imageKeySet]);
useUnload(handleWork);
};
export default useS3GarbageCollection;
컴포넌트 마운트 시 서비스워커를 등록하고, ServiceWorker에 message 이벤트를 보내는 함수를 인자로 하여 useUnload Hook을 실행합니다.
즉, 페이지 이동/새로고침/탭 닫기 동작 발생시 ServiceWorker에 message 이벤트로 이미지 URL 배열을 보내는 것입니다.
이미지 URL 배열은 Zustand 상태 관리 라이브러리를 통해 전역 상태(s3ImageStore)로 관리했습니다.
이미지를 업로드할 때마다 s3ImageStore 내 이미지 URL을 추가하는 것이죠.
아래 코드가 바로 위 useS3GarbageCollection Hook에서 등록한 서비스워커입니다.
해당 서비스워커에 message 이벤트로 s3ImageStore 스토어 내 저장된 이미지 URL들을 배열 형태로 보내면 아래 handleDeleteS3Objects 함수를 실행합니다.
handleDeleteS3Objects 함수는 S3 내 이미지들을 삭제하는 동작을 합니다.
// src/lib/serviceWorker.ts
import { deleteS3Objects } from './s3Client';
self.addEventListener('message', (event: MessageEvent<string[]>) => {
deleteS3Objects(event.data);
});
이로서 1. 이미지 업로드 후 새로고침, 페이지 이동, 탭 닫기 동작 문제를 해결할 수 있었습니다.
더불어 2. 이미지 업로드 후 해당 이미지 삭제 (delete 키 또는 Backspace 키로 인한) 문제 또한 해결이 됩니다.
삭제된 이미지 URL 또한 전역 상태에 저장되어 있기 때문이죠.
useS3GarbageCollection Hook은 아래와 같이 사용하면 됩니다.
// src/pages/???.tsx
...
useS3GarbageCollection();
...
그러나 해당 Hook을 사용하게 되면, 작성한 독후감 발행시 페이지가 이동하면서 사용되는 이미지까지 전부 삭제되버립니다.
따라서 사용하는 이미지는 전역 상태 s3ImageStore에서 제외해주어야 합니다.
사용하는 이미지는 남기기
아래는 특정 DOM Element 내 모든 img 태그를 찾고 src 속성에 담겨있는 AWS S3 URL들을 배열로 반환하는 유틸리티 함수입니다.
독후감을 작성하는 DOM Element의 id를 인자로 넘기면 사용하는 이미지 URL 배열을 가져올 수 있는 것이죠.
// src/utils/getUsedS3ImageURLs.ts
import getS3DomainAddress from './getS3DomainAddress';
const getUsedS3ImageURLs = (imagesContainerElementId: string) => {
const imagesContainerElement = document.getElementById(
imagesContainerElementId,
);
const usedS3Images = imagesContainerElement?.getElementsByTagName('img');
if (!usedS3Images || !usedS3Images.length) {
return [];
}
const usedS3ImageURLs: string[] = [];
const domainAddress = getS3DomainAddress();
Array.from(usedS3Images).reduce((urls, { src }) => {
if (src.startsWith(domainAddress)) {
urls.push(src);
}
return urls;
}, usedS3ImageURLs);
return usedS3ImageURLs;
};
export default getUsedS3ImageURLs;
위 유틸 함수를 통해 반환된 이미지 URL들을 전역 상태에서 제외해주면 끝!
그럼 전역 상태 s3ImageStore 스토어에 사용하지 않는 이미지 URL들만 남게 됩니다.
그리고 사용하지 않는 이미지들은 useS3GarbageCollection Hook으로 인해 삭제되겠죠.
오버엔지니어링을 하다
S3 스토리지 최적화 문제에 엄청나게 오랜 시간을 사용했습니다.
이로 인해 주어진 요구사항 개발에 차질이 생겼고, 설정한 마감기한을 늘리게 되었습니다..
오버엔지니어링을 했음을 느꼈습니다. 미래에 발생할 가능성이 희박한 문제에 현재의 자원을 소모한 것입니다.
많은 생각이 들었습니다.
"신입 개발자로서 대규모 사용자와 관련된 도전적인 문제에 대해 고민해 봤으니 오히려 좋은 경험이 아니었을까?"
"그런데 현재 더 중요한 것은 요구사항 개발인데.."
고민 결과 가장 중요한 것은 목적 의식이라 판단했고, 세 줄 독후감 서비스를 사용자들에게 빠르게 제공하기 위해서는 S3 스토리지를 최적화하는 것보다 주어진 요구사항 개발에 우선을 두었어야 했다고 느꼈습니다.
이 경험으로 사용자의 실제 요구사항과 주어진 자원을 고려하는 적절한 논리력의 중요성을 깨닫게 되었습니다.
.
.
.
해당 글 내용이 현재 진행 중인 프로젝트에서 가장 힘들었던 부분이 아니었을까 생각합니다..
'그냥 사용하지 않는 이미지들도 저장하도록 할까' 하고 포기하고 싶은 순간이 여러번 있었지만, 결국 성공해서 너무 뿌듯하긴 합니다.
하지만 결론적으로는 오버엔지니어링을 범했고, 앞으로는 현재 더 중요한 문제는 무엇인지 판단하는 논리력을 갖추며 개발해야겠습니다.