codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
에듀›HTML/CSS/JS 부터 React, Next, Tailwind 까지›9단계

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 로. 서버가 없다면 미리보기와 크기 검사까지만 클라이언트에서 해 봐도 충분합니다.

더 깊이

  • 이미지 파이프라인 노트
  • Supabase Storage 노트

다음 단계

여기까지 텍스트 와 이미지 를 받아 살아 있는 화면을 짰어요. 그 모든 코드를 뒤에서 받쳐 준 것이 TypeScript 입니다. 마지막 10단계 — TypeScript 깊이 (strict·narrowing·generic) 에서는 그 도구를 정면으로 봅니다 — strict 의 안전망, 타입 좁히기, unknown 으로 외부 데이터 받기, 제네릭과 유틸리티 타입까지.

← 8단계

8단계 — 폼·검증·UX 마무리

10단계 →

10단계 — TypeScript 깊이 (strict·narrowing·generic)