2024년 11월, 중고차 상품화 화면에 차량 외관 360도 회전 뷰를 등록하는 기능을 개발하며 프레임 수 기준을 어떻게 정했고, 추출·미리보기·업로드를 어떻게 이어 붙였는지 정리한 글입니다.
중고차 상세 화면에는 차량을 좌우로 드래그해 돌려볼 수 있는 영역이 있다. 이 글에서는 이것을 360도 회전 뷰라고 부른다.
이 화면은 사실 3D 모델이 아니라 여러 장의 정지 이미지를 순서대로 바꿔 보여주는 방식이다. 드래그 방향에 따라 인덱스를 하나씩 증가/감소시키면 사용자에게는 차량이 회전하는 것처럼 보인다.
이름이 비슷해 헷갈리기 쉬운데, 이 화면이 다루는 360도짜리 이미지는 두 종류이고 서로 다른 기법이다.
| 차량 외관 | 차량 실내 | |
|---|---|---|
| 기법 | 회전 뷰 (spin) | 파노라마 |
| 이미지 수 | 여러 장 | 한 장 |
| 만드는 방법 | 밖에서 한 바퀴 돌며 촬영 | 안에서 사방을 한 장에 담아 촬영 |
| 보는 방법 | 인덱스를 바꿔 갈아 끼움 | 한 장을 펼쳐 시점을 옮김 |
이 글이 다루는 것은 외관 쪽, 즉 회전 뷰다. 실내 파노라마는 촬영 장비로 만든 이미지 한 장을 그대로 올리면 되므로 별도의 처리가 없었다.
문제는 회전 뷰에 쓸 이미지들을 관리자가 어떻게 등록하느냐였다.
- 차량 한 대당 수십 장의 사진을 순서대로 촬영해 업로드하는 것은 운영 비용이 크다
- 촬영 간격이 일정하지 않으면 회전이 끊겨 보인다
- 파일명 순서가 어긋나면 회전 순서가 뒤섞인다
그래서 관리자는 차량 주위를 한 바퀴 돌며 촬영한 영상 하나만 업로드하고, 프레임 분할은 웹에서 처리하는 방향을 선택했다.
이 글에서는 구현 코드를 한 줄씩 해설하기보다, 어떤 기준으로 프레임 수를 정했고 각 레이어가 어떤 책임을 맡았는지를 중심으로 정리한다.
문제 상황
기능의 요구사항은 단순하게 표현하면 다음과 같았다.
360도 촬영 영상 1개
↓
회전에 사용할 이미지 N장
↓
S3 업로드
↓
상품화 저장 요청여기서 결정해야 할 것이 세 가지 있었다.
N을 몇으로 할 것인가- 프레임 분할을 어디서 할 것인가 (서버 / 클라이언트)
- 추출한 이미지들을 기존 상품화 폼 상태에 어떻게 끼워 넣을 것인가
특히 첫 번째가 다른 두 가지의 비용을 모두 결정했다. 프레임이 많을수록 회전은 부드러워지지만, 그만큼 추출 시간과 업로드 요청 수, 상세 화면의 로딩 비용이 함께 늘어난다.
프레임 수 기준을 정한 방법
프레임 수는 "부드러워 보인다"는 감각의 문제라 문서로 합의하기 어려웠다.
후보는 36 / 72 / 144 세 가지였다. 360도를 기준으로 보면 프레임 수는 곧 회전 각도 단위다.
- 36프레임 → 10도 간격
- 72프레임 → 5도 간격
- 144프레임 → 2.5도 간격
그래서 세 후보로 같은 영상을 잘라 샘플을 만들고, 실제 회전 갤러리 컴포넌트에 넣어 드래그하는 화면을 그대로 녹화해 공유하는 방식을 택했다.
녹화를 택한 이유는 단순하다. 판단해야 할 사람들이 개발 환경을 띄워 직접 드래그해볼 수는 없기 때문이다. 감각의 문제를 전달하려면 실행할 수 있는 형태가 아니라 관찰할 수 있는 형태여야 했다. 세 영상을 같은 조건으로 찍어 나란히 두면, 설명 없이도 차이가 보인다.
이 값을 프론트엔드 안에서 정하지 않은 이유가 있다. 프레임 수는 화면의 인상만 바꾸는 값이 아니라, 차량 한 대당 저장되는 이미지 수와 등록 시 발생하는 업로드 부하까지 함께 바꾸는 값이었다.
그래서 샘플과 함께 비용을 같은 문서에 정리해 의사결정 라인과 서버 담당자에게 공유했다.
| 기준 | 프레임 수에 따른 변화 |
|---|---|
| 회전의 자연스러움 | 프레임 간 각도 차이가 클수록 끊겨 보임 |
| 추출 소요 시간 | seek·캡처를 순차로 반복하므로 프레임 수에 선형 비례 |
| 업로드 요청 수 | 이미지 1장당 presigned URL 발급 + S3 전송으로 2회 |
| 상세 화면 로딩 | 전체 이미지를 선로딩하므로 장수에 비례 |
업로드 요청 수는 특히 적지 않았다. 이 화면은 회전 뷰 외에 일반 차량 사진(외부 6장 + 내부 4장)과 실내 파노라마 1장을 함께 저장하기 때문에, 신규 등록 기준으로 계산하면 다음과 같다.
| 프레임 수 | 업로드 파일 수 | 총 요청 수 |
|---|---|---|
| 36 | 47 | 94 |
| 72 | 83 | 166 |
| 144 | 155 | 310 |
144프레임은 회전이 가장 부드러웠지만, 차량 한 대를 등록할 때마다 300건이 넘는 요청이 발생하고 추출 시간도 72프레임의 두 배가 됐다.
반대쪽인 36프레임은 판단이 더 애매했다. 확실히 끊긴다고 할 정도는 아니었지만, 드래그할 때 미묘하게 버벅거리는 느낌이 남았다. 이 정도 차이는 문장으로 옮기기 어렵다. 세 결과를 나란히 놓고 봐야 비로소 "이건 좀 아쉽다"가 공유된다.
최종적으로 의사결정 라인의 승인을 거쳐 72프레임으로 확정했다.
여기서 중요했던 것은 숫자 자체보다, 기준을 말이 아니라 샘플로 합의했다는 점이다.
"부드럽다"는 각자의 감각이지만, 같은 조건으로 만든 세 결과물이 나란히 놓이면 판단이 한 곳으로 모인다. 문장으로 "5도 간격이면 충분히 부드럽습니다"라고 썼다면 각자 다른 것을 상상했을 것이다.
비용을 같은 문서에 함께 올려둔 것도 "가장 부드러운 것"이 아니라 "감당할 수 있는 것 중 가장 부드러운 것" 을 고르는 자리로 만들기 위해서였다. 실제로 가장 부드러웠던 144프레임은 그렇게 탈락했다.
여기서 한 비교를 직접 해볼 수 있는 데모를 만들어 두었습니다. 360도 영상 프레임 분할 데모 →
36 / 72 / 144프레임을 각각 추출해 드래그로 돌려보고, 추출 시간·용량·업로드 요청 수를 나란히 비교할 수 있습니다. 위 표의 숫자가 실제로 어떻게 벌어지는지 확인해보실 수 있습니다.
예시 영상이 준비돼 있어 360도 촬영 영상이 없어도 됩니다. 직접 촬영한 영상을 넣어볼 수도 있고, 그 영상은 브라우저 안에서만 처리되어 어디에도 업로드되지 않습니다.
다만 실제 서비스 코드 그 자체는 아닙니다. 아래에서 설명하는 추출 방식을 같은 순서로 다시 구현한 것으로, 폼과 업로드 계층을 걷어내고 추출 부분만 브라우저에서 바로 돌아가도록 옮겼습니다.
승인된 값은 훅 안에서 하나의 변수로 두었다.
const frame = 72;다만 이 값은 훅 내부의 지역 변수라, 기준을 바꾸려면 훅을 직접 열어야 한다. 결정 과정이 프론트엔드 밖에 있었던 값인 만큼 공용 상수로 분리해 두는 편이 나았다.
분할을 클라이언트에서 처리한 이유
프레임 분할은 서버에서 처리할 수도 있다. 영상 파일을 그대로 올리고 서버가 이미지로 쪼개는 방식이다.
그럼에도 클라이언트에서 처리한 이유는 다음과 같았다.
- 관리자 화면이므로 실행 환경(브라우저, 데스크톱)이 비교적 통제된다
- 업로드 직후 관리자가 결과물을 즉시 확인하고 다시 등록할 수 있어야 한다
- 영상 원본을 서버에 보관할 필요가 없다
- 별도의 영상 처리 파이프라인을 추가하지 않아도 된다
즉 "영상 → 이미지"라는 변환 자체보다, 등록 전에 결과를 확인하고 되돌릴 수 있는 흐름이 더 중요했다.
클라이언트 안에서도 방법은 여러 가지였다
"클라이언트에서 처리한다"까지 정하면 곧바로 다음 질문이 남는다. 어떤 방법으로 자를 것인가.
| 방식 | 장점 | 이 상황에서의 문제 |
|---|---|---|
WebCodecs (VideoDecoder) | seek 왕복이 없어 훨씬 빠름 | 컨테이너 파싱을 직접 해야 하고, 브라우저 지원이 고르지 않음 |
ffmpeg.wasm | 코덱 호환성이 가장 넓음 | 번들이 수십 MB. 관리자 화면 하나를 위해 지불하기엔 큼 |
requestVideoFrameCallback | 실제 렌더된 프레임을 보장 | 재생 기반이라 "정확히 1/72 지점" 같은 시점 제어가 어려움 |
video + canvas | 추가 의존성이 없고 어디서나 동작 | seek 왕복만큼 느리고, 코덱은 브라우저에 의존 |
여기서도 관리자 화면이라 환경이 통제된다는 전제가 한 번 더 작용했다. 사용 빈도가 높지 않고 실행 환경이 좁으므로, 속도나 호환성을 위해 의존성을 늘리기보다 추가 의존성 없이 동작하는 쪽을 택했다.
다만 이 선택에는 분명한 비용이 있다.
- 추출이 사용자의 브라우저 성능에 의존한다
- 추출 중에는 폼을 저장할 수 없는 상태를 만들어야 한다
- 영상 코덱에 따라 브라우저가 디코딩하지 못할 수 있다
마지막 항목은 ffmpeg.wasm을 쓰지 않은 대가다. 번들 크기를 지키는 대신 코덱 호환성을 브라우저에 맡겼다.
구현 방법
1. Video 엘리먼트로 영상을 디코딩한다
파일 입력으로 받은 영상은 URL.createObjectURL로 임시 URL을 만들어 video 엘리먼트에 연결한다.
DOM에 붙이지 않아도 디코딩과 렌더링은 가능하므로, 화면에 보이지 않는 엘리먼트를 만들어 사용한다.
const videoElement = document.createElement("video");
videoElement.src = URL.createObjectURL(file);
videoElement.muted = true;
videoElement.preload = "auto";loadeddata 시점부터 duration과 videoWidth, videoHeight를 신뢰할 수 있다.
videoElement.addEventListener("loadeddata", async () => {
const duration = videoElement.duration;
const interval = duration / frame;
});interval은 프레임 하나를 캡처할 시간 간격이다. 영상 길이가 달라도 항상 72장이 나오도록, 장수가 아니라 간격을 영상 길이에서 계산한다.
2. Canvas에 현재 프레임을 그린다
canvas의 2D 컨텍스트는 drawImage의 소스로 video 엘리먼트를 받을 수 있다. 이때 그려지는 것은 그 시점에 재생 중인 프레임이다.
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) return;
canvas.width = videoElement.videoWidth / 2;
canvas.height = videoElement.videoHeight / 2;캔버스 크기를 원본의 절반으로 잡은 것은 의도적인 선택이다.
72장을 원본 해상도로 만들면 업로드 용량과 상세 화면 로딩 비용이 함께 커진다. 회전 뷰의 목적은 인쇄나 확대가 아니라 차량의 형태 변화를 연속으로 보여주는 것이므로, 해상도를 낮춰 장수를 확보하는 쪽이 유리하다고 판단했다.
3. seek와 캡처를 순차적으로 반복한다
여기가 이 기능의 핵심이자 가장 조심해야 하는 부분이다.
currentTime을 지정한다고 해서 즉시 해당 프레임이 그려지지는 않는다. 브라우저가 해당 시점으로 이동을 완료하면 seeked 이벤트가 발생하고, 그 이후에 그려야 원하는 프레임을 얻는다.
for (let i = 0; i < frame; i++) {
videoElement.currentTime = i * interval;
await new Promise<void>((resolve) => {
videoElement.addEventListener("seeked", async function captureFrame() {
context.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (blob) {
const frameFile = new File([blob], `frame-${i + 1}.jpg`, {
type: "image/jpeg",
});
frames.push(frameFile);
}
resolve();
}, "image/jpeg");
videoElement.removeEventListener("seeked", captureFrame);
});
});
}이 반복문은 의도적으로 병렬 처리하지 않았다.
video 엘리먼트는 재생 위치가 하나뿐인 단일 자원이다. 여러 시점을 동시에 요청하면 마지막 currentTime만 반영되어 같은 프레임이 중복 캡처되거나 순서가 뒤섞인다.
따라서 다음 순서를 지켜야 한다.
currentTime을 설정한다seeked를 기다린다- 캔버스에 그린다
toBlob으로 이미지 파일을 만든다- 리스너를 해제하고 다음 프레임으로 넘어간다
리스너 해제도 중요하다. 매 반복마다 리스너를 추가하고 해제하지 않으면, 이후의 seek마다 이전 리스너들이 함께 실행되어 중복 캡처가 발생한다.
canvas.toBlob은 콜백 기반이므로 Promise로 감싸 순서를 보장한다. 결과적으로 frames 배열에는 frame-1.jpg부터 frame-72.jpg까지가 촬영 순서 그대로 쌓인다.
회전 갤러리는 배열의 인덱스를 그대로 회전 순서로 사용하므로, 이 순서가 곧 화면의 회전 방향이 된다.
4. 추출 결과를 폼 상태와 연결한다
추출된 파일 배열은 별도의 상태로 두지 않고, 상품화 등록 폼의 필드 값으로 직접 넣었다.
setIsExtracting(false);
setValue("view360.rotation", frames);폼 스키마에서 해당 필드는 문자열과 파일을 모두 허용한다.
const image = z.union([z.string(), z.instanceof(File)]).nullable();
view360: z.object({
panorama: image,
rotation: z.array(image),
}),이렇게 둔 이유는 이 필드가 두 가지 출처를 갖기 때문이다.
- 이미 등록된 차량: 서버에서 내려온 이미지 URL 문자열
- 새로 등록하는 차량: 방금 추출한
File객체
두 경우를 하나의 필드로 다루면 화면과 저장 로직이 분기 없이 같은 값을 바라볼 수 있다.
미리보기에서도 같은 기준으로 문자열과 파일을 처리한다.
const images = watch("view360.rotation")?.map((image) =>
typeof image === "string"
? image
: URL.createObjectURL(image as File),
);즉 화면은 항상 문자열 URL 배열만 보고, 그 값이 서버 이미지인지 방금 만든 파일인지는 알 필요가 없다.
5. 추출 중 상태와 삭제·재등록을 처리한다
72장을 순차로 추출하는 동안에는 화면에 진행 중인 상태를 보여줘야 한다.
const [isExtracting, setIsExtracting] = useState<boolean>(false);이 상태는 두 가지 용도로 사용된다.
- 추출 중임을 알리는 로딩 표시
- 추출 중 삭제 버튼 비활성화
검증 오류 초기화는 이 상태가 아니라 추출 시작과 삭제 시점에서 각각 직접 호출한다.
삭제는 값을 비우고 오류까지 함께 정리한다.
const handleDelete = (): void => {
setIsExtracting(false);
setValue("view360.rotation", []);
clearErrors("view360.rotation");
};화면에서는 값이 있는지, 추출 중인지에 따라 세 가지 상태를 구분해 렌더링한다.
{isExtracting || !!images?.length ? (
<Gallery>
<RotationGallery isLoading={!images?.length} images={images ?? []} />
<DeleteButton disabled={isExtracting} onClick={handleDelete} />
</Gallery>
) : (
<FileInput accept="video/*" type="file" onChange={handleFileChange} />
)}정리하면 상태는 다음과 같이 나뉜다.
| 상태 | 화면 |
|---|---|
| 값 없음 | 영상 업로드 영역 |
| 추출 중 | 회전 갤러리 자리에 로딩, 삭제 비활성화 |
| 추출 완료 | 드래그 가능한 미리보기 + 삭제 버튼 |
미리보기에 실제 상세 화면과 같은 회전 갤러리 컴포넌트를 사용한 것도 의도적이다. 관리자가 등록 단계에서 보는 결과와 사용자가 보게 될 결과가 같아야, 프레임 순서나 촬영 품질 문제를 등록 전에 발견할 수 있다.
갤러리는 이미지들이 모두 로드되기 전까지 로딩을 노출한다.
const { isLoading } = useImagesLoaded({ images });72장을 미리 로드해두지 않으면 드래그할 때마다 이미지가 하나씩 늦게 붙어 회전이 끊겨 보이기 때문이다.
6. S3 Presigned URL로 다중 업로드한다
저장 시점에는 폼에 담긴 값들을 실제 업로드 가능한 형태로 변환한다.
이 화면은 일반 차량 사진, 실내 파노라마, 외관 회전 뷰를 함께 다루므로, 먼저 타입을 붙여 하나의 목록으로 만든다.
const allImages = [
...[...(form.photos.exterior || []), ...(form.photos.interior || [])].map(
(image) => ({ type: "photo" as const, value: image }),
),
{ type: "panorama" as const, value: form.view360.panorama },
...(form.view360.rotation?.map((image) => ({
type: "rotation" as const,
value: image,
})) ?? []),
].filter((image) => !!image.value);업로드는 presigned URL 발급과 전송을 파일 단위로 처리하고, 전체를 병렬로 실행한다.
const uploaded = await Promise.all(
allImages.map(async ({ type, value: file }) => ({
type,
url: await uploadToS3({ prefix, file: file!, createPresignedUrl }),
})),
);여기서 이미 업로드된 이미지는 다시 업로드하지 않는다.
if (typeof file === "string") return extractKey(file);값이 문자열이면 이미 S3에 있는 이미지이므로 키만 추출해 반환한다. 수정 화면에서 사진을 바꾸지 않았는데 72장을 다시 업로드하는 상황이 이 지점에서 걸러진다.
이 분기는 이 기능을 위해 추가한 것이 아니라, 공용 업로드 유틸이 원래 File | string을 받도록 설계돼 있었기 때문에 그대로 동작한 것이다.
즉 폼 필드를 유니온으로 둔 선택이 업로드 유틸의 시그니처와 그대로 맞물렸다. 화면에도 저장 로직에도 분기가 필요 없었던 건 우연이 아니라, 두 계층이 같은 표현을 쓰고 있었기 때문이다.
업로드가 끝나면 타입별로 다시 묶어 요청 본문을 만든다.
const byType = uploaded.reduce<Record<ImageType, string[]>>(
(acc, { type, url }) => {
acc[type] = acc[type] || [];
acc[type].push(url);
return acc;
},
{ photo: [], panorama: [], rotation: [] },
);photos: byType.photo,
panoramaImage: byType.panorama[0] || null,
rotationImages: byType.rotation || null,Promise.all을 사용하면 업로드 순서가 아니라 완료 순서가 뒤섞일 수 있다. 하지만 map의 반환 배열은 입력 순서를 유지하므로, 회전 순서를 결정하는 rotationImages의 정렬은 그대로 보존된다.
적용하면서 고려한 점
1. 프레임 순서는 기능의 정확성 그 자체다
일반적인 다중 이미지 업로드에서는 순서가 조금 어긋나도 화면이 크게 어색해지지 않는다.
하지만 회전 뷰는 배열 인덱스가 곧 회전 각도이므로, 순서가 어긋나면 차량이 튀거나 되감기는 것처럼 보인다.
그래서 다음 지점마다 순서를 유지하는지 확인해야 했다.
- 추출 반복문 (seek 완료 후 캡처)
- 폼 상태에 저장하는 배열
- 업로드 결과 배열
- 요청 본문의
rotationImages
2. 추출 시간은 사용자에게 보이는 비용이다
72장을 순차로 처리하므로 추출은 즉시 끝나지 않는다.
이 시간을 없앨 수는 없기 때문에, 진행 중임이 분명하게 보이고 그동안 잘못된 조작이 불가능하도록 만드는 쪽을 택했다.
- 추출 중에는 갤러리 자리에 로딩을 노출한다
- 추출 중 삭제 버튼을 비활성화한다
- 추출이 끝나야 폼 값이 채워진다
3. 해상도와 장수는 함께 결정해야 한다
프레임 수만 늘리면 회전은 부드러워지지만 용량이 함께 늘어난다.
이번 기능에서는 캔버스 크기를 원본의 절반으로 줄여 장수를 확보했다. 즉 "장수 × 해상도"의 총량을 기준으로 판단하는 편이 프레임 수만 두고 논의하는 것보다 실용적이었다.
4. 영상 자체의 품질은 코드로 보정할 수 없다
촬영 시 이동 속도가 일정하지 않거나 중간에 멈추면, 시간 기준으로 균등하게 자른 프레임이 각도상으로는 균등하지 않게 된다.
이 부분은 코드가 아니라 촬영 가이드로 해결해야 했다. 등록 화면에서 실제 회전 미리보기를 제공한 것도 이 문제를 등록 전에 발견하도록 하기 위해서였다.
결과
- 관리자는 영상 하나만 업로드하면 차량 외관 회전 뷰가 자동으로 만들어진다
- 프레임 수 기준을 샘플 비교로 합의해 72프레임(5도 간격)으로 정했다
- 등록 화면에서 실제 상세 화면과 동일한 회전 미리보기를 제공한다
- 추출 결과가 폼 상태와 연결되어 삭제·재등록이 자연스럽게 동작한다
- 이미 업로드된 이미지는 재업로드하지 않고 키만 재사용한다
반면 다음과 같은 비용도 함께 발생했다.
- 추출 시간이 사용자의 실행 환경에 의존한다
- 순서를 보장하기 위해 병렬화를 포기했다
- 영상 코덱과 브라우저 지원 범위에 영향을 받는다
- 촬영 품질 문제는 여전히 사람이 확인해야 한다
지금 다시 만든다면
이 글을 정리하며 약 2년 전에 쓴 코드를 다시 읽었다. 이미 회사를 떠난 뒤라 이후에 개선됐을 수도 있고, 실제 동작을 다시 측정해볼 수도 없다. 아래는 2024년 11월 당시 코드를 기준으로 지금이라면 다르게 했을 지점들이다.
1. 리렌더 비용을 비용 표에 넣지 않았다
프레임 수를 정할 때 추출 시간, 업로드 요청 수, 상세 화면 로딩을 표로 만들어 따졌다. 그런데 그 표에 렌더 비용이라는 열이 없었다.
blob URL을 만드는 코드는 훅 본문에 그대로 있다.
const images = watch("view360.rotation")?.map((image) =>
typeof image === "string"
? image
: URL.createObjectURL(image as File),
);이 줄은 컴포넌트가 리렌더될 때마다 다시 실행된다. 그리고 이 훅은 폼 훅과 같은 컴포넌트에 있고, 폼 훅은 가격 필드를 watch한다. 즉 판매가 입력칸에 숫자 한 글자를 칠 때마다 blob URL 72개가 새로 만들어진다. 이전 72개는 해제되지 않은 채로 남는다.
여기서 끝이 아니다. 매 렌더마다 새 배열이 만들어지므로, 그 배열을 받는 갤러리의 선로딩 로직도 함께 다시 돈다.
useEffect(() => {
const imageElements = images.map((src) => {
const img = new Image();
img.src = src;
return img;
});
...
}, [images]);URL이 매번 달라지므로 브라우저 이미지 캐시도 타지 않는다. 리렌더 한 번에 new Image() 72개와 디코딩 72회가 추가로 발생한다.
당시에는 관리자 화면이니 렌더 최적화는 나중 문제라고 생각했다. 사용자 수가 적고 화면도 단순하니 틀린 판단은 아니다. 문제는 이 화면에 리렌더 1회당 72배로 증폭되는 값이 있었다는 것이다. 장수를 늘리는 결정이 렌더링 비용까지 함께 늘린다는 사실을 계산에 넣지 않았다.
다시 만든다면 코드를 고치기 전에 이 질문을 먼저 하겠다.
이 값은 리렌더마다 새로 만들어져도 되는 값인가?
File을 blob URL로 바꾸는 것은 단순한 계산이 아니라 해제 책임이 따라붙는 부수효과다. 렌더 함수 본문에 둘 값이 아니었다. 값을 메모이제이션하고 정리 시점에 revokeObjectURL로 해제하는 것이 맞다.
2. 업로드는 순서만 보고 부하는 보지 않았다
추출은 순서를 지키기 위해 병렬화를 포기했다. 반대로 업로드는 배열이 순서를 보장해주니 전부 병렬로 던졌다.
같은 기능 안에서 판단이 정반대로 갈렸고 각각 근거도 있었지만, 업로드 쪽은 순서만 봤지 부하와 실패를 보지 않았다. 신규 등록 한 번에 83개 파일, 166건의 요청이 동시성 제한 없이 나간다.
더 큰 문제는 실패 처리다. Promise.all은 하나라도 실패하면 전체가 실패한다. 82장이 이미 S3에 올라간 뒤 마지막 한 장이 네트워크 오류로 실패하면, 폼은 저장 실패로 끝나고 재시도하면 82장을 처음부터 다시 올린다. 앞서 올라간 파일은 아무도 참조하지 않은 채 남는다.
다시 만든다면 동시 실행 수를 제한하고, 전부 아니면 전무가 아니라 실패한 것만 다시 시도할 수 있는 형태로 만들겠다.
3. 실패했을 때 빠져나갈 문이 없다
추출 상태를 해제하는 코드는 정상 완주 경로 한 곳에만 있다.
setIsExtracting(true);
// ...
if (!context) return; // 여기서 빠지면 상태가 풀리지 않는다
// ...
setIsExtracting(false); // 이 줄에 도달해야만 해제된다영상 로드 실패에 대한 error 리스너도 없다. 그래서 디코딩할 수 없는 영상을 올리면 loadeddata가 끝내 발생하지 않고 로딩이 계속 돈다.
그런데 이 상태에서 삭제 버튼은 disabled={isExtracting}이라 눌리지 않는다. 잘못된 조작을 막으려고 만든 장치가, 실패했을 때는 사용자를 가두는 장치가 됐다.
앞에서 "진행 중임이 분명하게 보이고 그동안 잘못된 조작이 불가능하도록 만들었다"고 썼는데, 그 설계의 뒷면을 함께 준비했어야 했다. 실패 경로에서도 반드시 상태가 풀리도록 하고, 최소한 취소는 열어두는 것이 맞다.
4. 순서는 보장했지만 개수는 보장하지 않았다
프레임 순서에는 신경을 많이 썼다. 그런데 캡처가 실패하는 경우는 이렇게 처리돼 있다.
canvas.toBlob((blob) => {
if (blob) {
frames.push(frameFile);
}
resolve();
}, "image/jpeg");toBlob이 값을 주지 못하면 그 프레임은 조용히 빠지고 다음으로 넘어간다. 루프가 끝난 뒤 장수를 검사하지도 않는다.
71장이 되어도 배열은 여전히 순서대로다. 그래서 눈에 잘 띄지 않는다. 하지만 이 기능의 전제는 "72장 = 5도 간격"이므로, 한 장이 빠지는 순간 어느 지점에서 각도가 두 배로 튄다. 회전 중 한 번 덜컹거리는데 원인을 찾기 어렵다.
배열 인덱스가 각도인 구조에서는 순서만 보장 대상이 아니라 개수도 보장 대상이었다.
5. 그 밖에
- 추출 진행률(예: 32/72)을 보여주면 대기 경험이 나아진다
- 프레임 수가 훅 내부 지역 변수라, 공용 상수로 분리하면 기준 변경이 쉬워진다
toBlob의 품질 인자를 지정하지 않아 기본값에 맡겨져 있다. 해상도와 장수 외에 품질도 총량을 조절하는 축이다