9단계
9단계 — 이미지 업로드와 최적화
0회 조회
9단계 — 이미지 업로드와 최적화
8단계까지 폼으로 텍스트 를 받았어요. 이제 사용자가 올리는 이미지 차례입니다. 이미지는 무겁고, 형식이 제각각이고, 그대로 받으면 위험합니다. 받기 → 검증 → 변환 → 저장, 이 네 칸을 손으로 짜 봅니다.
업로드 받기
<input type="file"> 로 파일을 받고, 서버 라우트로 보냅니다.
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload(file);
}}
/>
accept="image/*" 는 파일 선택창을 이미지만 으로 좁혀 줍니다. 하지만 이건 편의일 뿐 — 서버는 무엇이 와도 다시 검증해야 해요.
async function upload(file: File) {
const body = new FormData();
body.append("image", file);
await fetch("/api/upload", { method: "POST", body });
}
검증 — 가장 중요한 칸
이미지 변환 라이브러리(sharp)는 네이티브 코드라, 받은 파일을 그대로 디코드합니다. 악의적인 파일을 거르지 않으면 서버가 위험해져요. 세 가지를 봅니다.
1) 진짜 형식 (MIME 스니핑). 파일 이름이 photo.jpg 라고 진짜 JPEG 라는 보장이 없습니다. 클라이언트가 보낸 Content-Type 도 위조 가능해요. 파일 앞부분의 바이트 로 실제 형식을 판별합니다.
import { fileTypeFromBuffer } from "file-type";
const type = await fileTypeFromBuffer(buffer);
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (!type || !allowed.includes(type.mime)) {
throw new Error("허용되지 않는 형식");
}
2) 크기 제한. 본문을 읽기 전에 Content-Length 로 과대 요청을 거부하고, 디코드 단계에서는 limitInputPixels 로 이미지 폭탄 (작은 파일이 수십억 픽셀로 펼쳐지는 압축 폭탄) 을 막습니다.
3) SVG 는 받지 않기. SVG 는 XML 이라 <script> 를 품을 수 있는 XSS 벡터예요. 이미지 업로드에서는 화이트리스트(jpeg/png/webp) 밖이라 위 검증에서 자연히 거부됩니다.
sharp 로 변환
검증을 통과한 버퍼를 표준 모양으로 정리합니다.
import sharp from "sharp";
const output = await sharp(buffer, { limitInputPixels: 24_000_000 })
.rotate() // EXIF 회전 적용
.resize(1600, 1600, { fit: "inside", withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
한 줄씩 읽어 봅니다.
.rotate()— EXIF 의orientation을 적용해 세로/가로를 바로잡습니다. 이걸 빼면 휴대폰 사진이 옆으로 눕습니다..resize(1600, 1600, { fit: "inside", withoutEnlargement: true })— 비율을 지키며 1600×1600 박스 안에 맞추고, 원본이 작으면 키우지 않습니다..webp({ quality: 80 })— WebP 로 변환. 사진은 quality 75~85 면 눈에 차이가 거의 없어요.
sharp 는 네이티브 바인딩에 의존하므로, 이 라우트는 Node 런타임 으로 둬야 합니다 (Edge 런타임 불가).
사이즈 프리셋
화면마다 필요한 크기가 다릅니다. 목록의 썸네일과 상세 페이지의 큰 이미지를 같은 파일로 쓰면 둘 다 어정쩡해요. 용도별 프리셋을 정의합니다.
const PRESETS = {
thumb: { w: 200, h: 200, quality: 70 },
card: { w: 600, h: 600, quality: 78 },
detail: { w: 1600, h: 1600, quality: 82 },
};
async function makeVariant(buffer: Buffer, p: { w: number; h: number; quality: number }) {
return sharp(buffer)
.rotate()
.resize(p.w, p.h, { fit: "inside", withoutEnlargement: true })
.webp({ quality: p.quality })
.toBuffer();
}
한 번의 업로드로 thumb · card · detail 세 변형을 만들어 두면, 화면은 자기에게 맞는 것을 고르기만 하면 됩니다.
저장
변환 결과를 스토리지에 올립니다. 이때 파일명은 서버가 만든 무작위 값 이어야 해요. 사용자가 준 이름을 그대로 쓰면 경로 조작·덮어쓰기 위험이 있습니다.
import { randomUUID } from "crypto";
const key = `images/${randomUUID()}.webp`;
await storage.put(key, output); // 스토리지 SDK 의 업로드 호출
원래 파일명을 보여 줘야 한다면 DB 의 메타데이터 컬럼에만 둡니다 — 저장 경로에는 절대 쓰지 않습니다.
직접 해 보기
8단계의 폼에 <input type="file"> 을 더해 보세요. 파일을 고르면 미리보기 를 보여 주고 (URL.createObjectURL), 5 MB 가 넘으면 제출 버튼을 disabled 로. 서버가 없다면 미리보기와 크기 검사까지만 클라이언트에서 해 봐도 충분합니다.
더 깊이
다음 단계
여기까지 텍스트 와 이미지 를 받아 살아 있는 화면을 짰어요. 그 모든 코드를 뒤에서 받쳐 준 것이 TypeScript 입니다. 마지막 10단계 — TypeScript 깊이 (strict·narrowing·generic) 에서는 그 도구를 정면으로 봅니다 — strict 의 안전망, 타입 좁히기, unknown 으로 외부 데이터 받기, 제네릭과 유틸리티 타입까지.