
내 사진 한 장으로 퍼스널 컬러 찾기 [2편] - 반응형 UI 만들기와 이미지 업로드

- 만들 내용 -


1편에서 Vite + Tailwind CSS 프로젝트를 세팅하고 ES 모듈로 파일을 나누는 방법을 익혔습니다. 색상 변환 유틸리티도 만들었고, 개발 서버에서 동작하는 것까지 확인했고요. 뼈대는 갖춰졌으니, 이제 살을 붙일 차례입니다.
교육 현장에서 학생들이 프로젝트를 시작할 때 가장 먼저 부딪히는 문제가 있습니다. "화면은 만들었는데 내 폰에서 열면 깨져요." 데스크톱 브라우저에서 열심히 만든 레이아웃이 모바일에서는 글자가 잘리고 버튼이 화면 밖으로 나가거든요. 두 번째 문제는 "파일 업로드를 어떻게 하는 건가요?"입니다. HTML의 <input type="file">은 알지만, 선택한 이미지를 화면에 보여주는 건 또 다른 이야기죠.
2편에서는 이 두 문제를 해결합니다. Tailwind CSS의 반응형 유틸리티로 모바일부터 데스크톱까지 자연스럽게 대응하는 UI를 만들고, File API와 Canvas API를 사용해서 사용자가 올린 이미지를 화면에 표시하는 기능을 구현합니다. 1편에서 만든 프로젝트를 그대로 이어서 진행하니, 1편의 코드가 있는 상태에서 시작합니다.
1편 코드 정리: 데모 코드 걷어내기
본격적인 작업 전에 1편에서 테스트용으로 넣어둔 코드를 정리합니다. src/components/app.js에 있던 색상 샘플 데모는 모듈이 잘 동작하는지 확인하기 위한 코드였습니다. 이제 그 역할을 다했으니 걷어내고, 실제 앱 구조로 바꿉니다.
src/components/app.js를 다음과 같이 수정합니다. 이 코드는 중간 단계입니다. 반응형 문법을 설명하기 위해 HTML 구조만 먼저 잡아두는 것이고, 기사 후반에서 uploadUI와 previewUI 모듈을 만든 뒤 최종 버전으로 교체합니다.
// src/components/app.js (중간 단계 - 기사 후반에서 최종 버전으로 교체) export default function renderApp(container) { container.innerHTML = ` <div class="min-h-screen bg-gradient-to-b from-purple-50 to-pink-50"> <header class="py-6 sm:py-8"> <div class="max-w-2xl mx-auto px-4 text-center"> <h1 class="text-2xl sm:text-3xl font-bold text-gray-800"> 퍼스널 컬러 분석기 </h1> <p class="mt-2 text-sm sm:text-base text-gray-500"> 사진 한 장으로 나에게 어울리는 색을 찾아보세요 </p> </div> </header> <main class="max-w-2xl mx-auto px-4 pb-16"> <div id="upload-area" class="bg-white rounded-2xl shadow-md p-4 sm:p-6"> <!-- 이미지 업로드 UI가 여기에 들어갑니다 --> </div> <div id="preview-area" class="mt-6 hidden"> <!-- 이미지 미리보기가 여기에 들어갑니다 --> </div> </main> </div> ` }
달라진 점을 짚어 보겠습니다. 색상 데모 관련 코드를 전부 제거했습니다. colors.js의 import도 지웠습니다. 색상 유틸리티는 3편에서 피부색을 분석할 때 다시 사용할 것이므로 파일 자체는 그대로 둡니다. 지금은 import하지 않을 뿐입니다.
id="upload-area"와 id="preview-area" 두 개의 영역을 만들었습니다. 업로드 영역에는 파일 선택 UI가, 미리보기 영역에는 업로드된 이미지가 표시됩니다. 미리보기 영역에 hidden 클래스가 붙어 있는데, 이미지가 업로드되기 전에는 보이지 않다가 이미지가 올라오면 hidden을 제거해서 보여주는 방식입니다.
그리고 눈에 띄는 변화가 하나 더 있습니다. text-2xl sm:text-3xl 같은 클래스가 새로 생겼습니다. 이것이 Tailwind CSS의 반응형 문법입니다. 지금부터 자세히 알아보겠습니다.
Tailwind CSS 반응형 레이아웃: 모바일 우선 접근
반응형이 왜 필요한가
퍼스널 컬러 분석기는 셀카를 올려서 쓰는 서비스입니다. 셀카는 대부분 스마트폰으로 찍죠. 주 사용 환경이 모바일이라는 뜻입니다. 데스크톱에서 예쁘게 보이는 것도 중요하지만, 모바일에서 제대로 안 돌아가면 사용자의 절반 이상을 잃습니다.
StatCounter의 2025년 전 세계 통계를 보면 웹 트래픽의 약 60%가 모바일에서 발생합니다. 한국도 비슷하고요. 특히 20~30대에서는 모바일 비중이 더 높습니다. 퍼스널 컬러 분석 같은 서비스는 이 연령대가 주요 타겟이니 모바일 대응은 선택이 아니라 필수입니다.
Tailwind CSS의 반응형 문법
Tailwind CSS는 "모바일 우선" 방식을 사용합니다. 아무런 접두사 없이 쓴 클래스가 모바일 기준이고, sm:, md:, lg: 같은 접두사를 붙이면 화면이 그 크기 이상일 때 적용됩니다.
Tailwind CSS가 제공하는 기본 브레이크포인트는 다음과 같습니다.
| 접두사 | 최소 너비 | 대표 기기 |
|---|---|---|
| (없음) | 0px | 모바일 (기본) |
sm: | 640px | 큰 모바일, 작은 태블릿 |
md: | 768px | 태블릿 |
lg: | 1024px | 노트북 |
xl: | 1280px | 데스크톱 |
이 프로젝트에서는 sm:과 md:만 사용합니다. 대부분의 웹 애플리케이션은 이 두 단계면 충분합니다.
구체적인 예를 보겠습니다. 아까 app.js에서 쓴 코드를 다시 살펴봅니다.
<h1 class="text-2xl sm:text-3xl font-bold text-gray-800">
이 코드는 이렇게 동작합니다. 화면 너비가 0px~639px(모바일)일 때는 text-2xl이 적용되어 글자 크기가 1.5rem(24px)입니다. 화면 너비가 640px 이상(태블릿~데스크톱)이 되면 sm:text-3xl이 적용되어 1.875rem(30px)으로 커집니다.
핵심은 "작은 화면부터 먼저 디자인하고, 큰 화면에서 덧씌운다"는 겁니다. CSS를 작성하는 발상 자체가 달라지는 거죠. 예전에는 데스크톱 디자인을 먼저 만들고 모바일에서 줄이는 방식이 많았는데, Tailwind CSS는 반대입니다. 모바일 화면을 기본으로 놓고, 화면이 커질수록 스타일을 추가해 나갑니다.
패딩도 마찬가지입니다.
<div class="p-4 sm:p-6">
모바일에서는 패딩 16px, 640px 이상에서는 24px로 바뀝니다. 작은 화면에서는 공간을 아끼고, 넓은 화면에서는 여유를 주는 거죠.
반응형 레이아웃 실습: 카드 레이아웃
방금 수정한 app.js의 헤더 부분을 다시 보겠습니다.
<header class="py-6 sm:py-8"> <div class="max-w-2xl mx-auto px-4 text-center"> <h1 class="text-2xl sm:text-3xl font-bold text-gray-800"> 퍼스널 컬러 분석기 </h1> <p class="mt-2 text-sm sm:text-base text-gray-500"> 사진 한 장으로 나에게 어울리는 색을 찾아보세요 </p> </div> </header>
max-w-2xl mx-auto는 콘텐츠의 최대 너비를 672px로 제한하고 가운데 정렬합니다. 반응형에서 자주 쓰는 조합이거든요. 모바일에서는 화면 전체를 쓰고, 데스크톱에서는 콘텐츠가 너무 넓어지지 않게 잡아줍니다.
px-4는 좌우 패딩 16px입니다. 모바일에서 콘텐츠가 화면 가장자리에 딱 붙지 않게 여백을 주는 건데, 이 패딩이 없으면 텍스트가 화면 끝에 닿아서 읽기가 불편해집니다.
py-6 sm:py-8은 상하 패딩으로, 모바일에서 24px, 넓은 화면에서 32px입니다. 이런 작은 차이가 모이면 모바일에서도 답답하지 않고, 데스크톱에서도 허전하지 않은 레이아웃이 나옵니다.
이미지 업로드 UI 만들기
이제 이 프로젝트의 핵심 기능인 이미지 업로드 UI를 만들 차례입니다. 사용자가 이미지를 올리는 방법은 크게 두 가지인데, 버튼을 클릭해서 파일을 선택하는 방법과 이미지를 끌어다 놓는 드래그 앤 드롭입니다. 둘 다 구현하겠습니다.
업로드 컴포넌트 모듈 만들기
1편에서 배운 모듈 분리를 실전에 적용해 봅니다. 이미지 업로드 UI를 src/components/uploadUI.js라는 새 파일로 만들 겁니다. app.js에 모든 코드를 몰아넣지 않고 파일을 나누는 거죠. 1편에서 "각 파일은 자기 역할만 한다"고 했던 원칙을 따릅니다.
src/components/uploadUI.js 파일을 새로 만듭니다.
// src/components/uploadUI.js export default function createUploadUI(container, onFileSelected) { container.innerHTML = ` <div id="drop-zone" class="border-2 border-dashed border-gray-300 rounded-xl p-6 sm:p-10 text-center cursor-pointer transition-colors duration-200 hover:border-purple-400 hover:bg-purple-50"> <div class="flex flex-col items-center gap-3"> <svg class="w-10 h-10 sm:w-12 sm:h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> </svg> <div> <p class="text-sm sm:text-base text-gray-600 font-medium"> 사진을 여기에 끌어다 놓거나 </p> <p class="text-sm text-purple-600 font-semibold mt-1"> 클릭하여 파일을 선택하세요 </p> </div> <p class="text-xs text-gray-400 mt-1">이미지 파일 (최대 10MB)</p> </div> <input id="file-input" type="file" accept="image/*" class="hidden" /> </div> ` const dropZone = container.querySelector('#drop-zone') const fileInput = container.querySelector('#file-input') // 클릭하면 파일 선택 창 열기 dropZone.addEventListener('click', () => { fileInput.click() }) // 파일 선택 시 처리 fileInput.addEventListener('change', (event) => { const file = event.target.files[0] if (file) { handleFile(file, onFileSelected) } }) // 드래그 앤 드롭 이벤트 dropZone.addEventListener('dragover', (event) => { event.preventDefault() dropZone.classList.add('border-purple-400', 'bg-purple-50') }) dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('border-purple-400', 'bg-purple-50') }) dropZone.addEventListener('drop', (event) => { event.preventDefault() dropZone.classList.remove('border-purple-400', 'bg-purple-50') const file = event.dataTransfer.files[0] if (file) { handleFile(file, onFileSelected) } }) } function handleFile(file, callback) { // 이미지 파일인지 확인 if (!file.type.startsWith('image/')) { alert('이미지 파일만 업로드할 수 있습니다.') return } // 10MB 제한 if (file.size > 10 * 1024 * 1024) { alert('파일 크기는 10MB 이하여야 합니다.') return } callback(file) }
코드가 길어 보이지만, 구조는 단순합니다. 하나씩 살펴보겠습니다.
createUploadUI 함수는 두 개의 매개변수를 받습니다. container는 UI를 넣을 DOM 요소이고, onFileSelected는 파일이 선택됐을 때 호출할 함수입니다. 이 함수를 외부에서 전달받는 이유가 있습니다. "업로드 UI는 파일 선택만 담당하고, 선택된 파일로 무엇을 할지는 호출한 쪽이 결정한다"는 원칙 때문이죠. 모듈이 자기 역할만 하는 겁니다.
HTML 부분을 보면 <input type="file">에 class="hidden"을 붙였습니다. 기본 파일 입력 버튼은 브라우저마다 생긴 게 다르고 스타일을 바꾸기도 어렵거든요. 그래서 숨겨놓고, 대신 드래그 앤 드롭 영역 전체를 클릭할 수 있게 만들었습니다. dropZone.addEventListener('click', () => { fileInput.click() }) 이 한 줄이 그 역할을 합니다. 드롭 영역을 클릭하면 숨겨진 파일 입력의 click() 메서드를 스크립트로 강제 호출하는 겁니다.
accept="image/*" 속성은 파일 선택 창에서 이미지 파일만 보이게 합니다. image/*는 JPEG, PNG, WebP, GIF 등 모든 이미지 타입을 허용한다는 뜻이고요. Canvas API가 이 형식들을 전부 처리할 수 있으니 굳이 특정 형식만 제한할 이유가 없습니다. 물론 사용자가 "모든 파일"로 바꿀 수도 있으니, JavaScript에서도 한 번 더 검증합니다. 그게 handleFile 함수의 역할입니다. handleFile에서도 file.type.startsWith('image/')로 모든 이미지 타입을 허용하므로, HTML과 JavaScript의 검증 범위가 일치합니다.
드래그 앤 드롭 이벤트 이해하기
드래그 앤 드롭에는 세 가지 이벤트가 필요합니다.
dragover는 파일을 드래그한 채로 영역 위에 올려놓고 있을 때 계속 발생합니다. 여기서 event.preventDefault()를 반드시 호출해야 합니다. 브라우저의 기본 동작은 파일을 새 탭에서 여는 것인데, 이걸 막아야 우리가 직접 파일을 처리할 수 있습니다. 동시에 테두리 색상을 보라색으로 바꿔서 "여기에 놓으면 됩니다"라는 시각적 피드백을 줍니다.
dragleave는 드래그한 파일이 영역을 벗어났을 때 발생합니다. 보라색 피드백을 원래대로 돌립니다.
drop은 파일을 실제로 놓았을 때 발생합니다. event.dataTransfer.files[0]으로 놓인 파일을 꺼냅니다. dataTransfer는 드래그 앤 드롭 과정에서 데이터를 전달하는 객체입니다. 파일을 여러 개 동시에 놓을 수도 있어서 files는 배열 형태인데, 우리는 첫 번째 파일만 사용합니다.
drop에서도 event.preventDefault()가 필요합니다. 이걸 빼면 브라우저가 이미지 파일을 새 탭에서 열어버립니다.
handleFile 함수: 파일 검증
handleFile은 두 가지를 검사합니다. file.type.startsWith('image/')로 이미지 파일인지 확인하고, file.size로 용량을 체크합니다. startsWith('image/')는 image/jpeg, image/png, image/webp 등 모든 이미지 MIME 타입을 허용합니다. HTML의 accept="image/*"와 같은 범위입니다. file.size의 단위는 바이트입니다. 10MB는 10 * 1024 * 1024 = 10,485,760바이트입니다.
검증을 통과하면 callback(file)로 파일을 넘깁니다. 이 callback이 바로 createUploadUI의 두 번째 매개변수인 onFileSelected입니다. 업로드 모듈은 여기서 끝입니다. 파일을 받아서 전달하는 것까지만 담당합니다.
handleFile에는 export를 붙이지 않았습니다. 이 함수는 uploadUI.js 내부에서만 사용하는 보조 함수이기 때문입니다. 1편에서 배운 것처럼, export 없는 함수는 파일 밖에서 접근할 수 없습니다.
File API와 FileReader: 이미지를 데이터로 바꾸기
사용자가 파일을 선택하면 File 객체가 생깁니다. 그런데 이 객체만으로는 이미지를 화면에 보여줄 수 없습니다. 이미지를 화면에 표시하려면 파일의 내용을 읽어서 브라우저가 이해할 수 있는 형태로 변환해야 하거든요. 이 일을 하는 게 FileReader입니다.
File 객체가 뭔가
<input type="file">에서 파일을 선택하면 브라우저가 File 객체를 만들어 줍니다. 이 객체에는 파일 이름, 크기, 타입 같은 정보가 들어 있습니다. 다만 파일의 실제 내용(이미지 픽셀 데이터)은 들어 있지 않습니다.
File 객체를 콘솔에 찍어보면 이런 정보가 나옵니다.
// 예시 출력 { name: "selfie.jpg", size: 2458624, // 바이트 단위 (약 2.4MB) type: "image/jpeg", lastModified: 1709366400000 }
name은 파일 이름, size는 바이트 단위 크기, type은 MIME 타입입니다. lastModified는 파일이 마지막으로 수정된 시간의 타임스탬프입니다. 아까 handleFile에서 file.type과 file.size를 검사한 게 바로 이 정보를 사용한 겁니다.
FileReader로 이미지 읽기
FileReader는 파일의 실제 내용을 읽는 API입니다. 이미지 파일을 읽는 방법 중에서 우리가 사용할 것은 readAsDataURL입니다. 이 메서드는 파일을 Base64로 인코딩된 문자열로 변환합니다. 이 문자열을 <img> 태그의 src에 넣으면 이미지가 표시됩니다.
사용법을 코드로 보겠습니다.
const reader = new FileReader() reader.addEventListener('load', (event) => { const imageDataUrl = event.target.result // imageDataUrl은 "data:image/jpeg;base64,/9j/4AAQ..." 같은 문자열 // 이걸 img 태그의 src에 넣으면 이미지가 보입니다 }) reader.readAsDataURL(file)
FileReader를 처음 보면 구조가 좀 낯설 수 있습니다. readAsDataURL(file)을 호출하면 파일 읽기가 시작되는데, 이 작업은 바로 끝나지 않습니다. 파일 크기에 따라 시간이 걸립니다. 그래서 읽기가 끝나면 load 이벤트가 발생하고, 그때 결과를 가져오는 방식입니다.
이 방식이 낯설 수 있습니다. 1편에서 사용한 코드는 모두 "위에서 아래로" 순서대로 실행됐으니까요. 하지만 FileReader는 "읽기를 시작해놓고, 끝나면 알려줘"라는 방식입니다. 이것을 비동기 처리라고 하는데, addEventListener로 이벤트를 등록해놓고 기다리는 패턴은 버튼 클릭 이벤트와 같습니다. 버튼 클릭을 기다리듯이, 파일 읽기 완료를 기다리는 거죠.
event.target.result가 읽기 결과입니다. Data URL이라는 형태의 문자열인데, data:image/jpeg;base64, 뒤에 Base64로 인코딩된 이미지 데이터가 붙어 있습니다. 이 문자열을 <img> 태그의 src 속성에 넣으면 브라우저가 이미지로 표시해 줍니다.
이미지 유틸리티 모듈 만들기
파일을 읽는 로직을 유틸리티 모듈로 분리합니다. src/utils/imageLoader.js 파일을 새로 만듭니다.
// src/utils/imageLoader.js /** * File 객체를 받아서 Data URL 문자열로 변환합니다. * 반환값: Promise<string> */ export function readFileAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.addEventListener('load', (event) => { resolve(event.target.result) }) reader.addEventListener('error', () => { reject(new Error('파일을 읽는 중 오류가 발생했습니다.')) }) reader.readAsDataURL(file) }) } /** * Data URL을 받아서 Image 객체를 생성합니다. * 반환값: Promise<HTMLImageElement> */ export function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image() img.addEventListener('load', () => { resolve(img) }) img.addEventListener('error', () => { reject(new Error('이미지를 불러올 수 없습니다.')) }) img.src = src }) }
여기서 Promise가 등장합니다. 처음 보는 독자를 위해 설명하겠습니다.
앞서 FileReader가 비동기로 동작한다고 했죠. 읽기가 끝나면 load 이벤트로 알려주는 방식이었습니다. Promise는 이런 비동기 작업을 다루는 표준 방법입니다. "나중에 결과를 줄게"라는 약속 객체라고 보면 됩니다.
new Promise((resolve, reject) => { ... }) 구조를 보면, resolve는 "작업 성공, 여기 결과야"라고 알려주는 함수이고, reject는 "작업 실패, 이런 에러가 났어"라고 알려주는 함수입니다. readFileAsDataURL 함수를 예로 들어 흐름을 따라가 보겠습니다.
readFileAsDataURL(file)을 호출하면 Promise 객체가 만들어집니다.- Promise 안에서
reader.readAsDataURL(file)이 실행되어 파일 읽기가 시작됩니다. - 파일 읽기가 끝나면
load이벤트가 발생하고,resolve(event.target.result)가 호출됩니다. 이때 Data URL 문자열이 결과로 전달됩니다. - 만약 파일 읽기 중 오류가 나면
error이벤트가 발생하고,reject(new Error(...))가 호출됩니다. - 이 Promise를 사용하는 쪽에서
await readFileAsDataURL(file)이라고 쓰면, 3번의resolve가 호출될 때까지 기다린 뒤 그 결과(Data URL)를 변수에 담습니다.
loadImage 함수도 같은 패턴입니다. img.src에 URL을 넣으면 이미지 로딩이 시작되고, 로딩이 끝나면 resolve(img), 실패하면 reject가 호출됩니다.
Promise의 세부 문법은 3편에서 async/await와 함께 본격적으로 다루지만, 위 흐름을 이해하면 이번 편의 코드를 따라가는 데 충분합니다.
이 두 함수를 순서대로 사용하면 "파일 -> Data URL -> Image 객체" 변환이 됩니다. Image 객체가 있으면 Canvas에 그릴 수 있습니다.
Canvas API 기초: 이미지를 캔버스에 그리기
Canvas API가 왜 필요한지 먼저 짚겠습니다. <img> 태그로 이미지를 보여주는 건 쉽습니다. 하지만 <img> 태그로는 이미지의 픽셀을 직접 읽을 수 없습니다. 3편에서 얼굴 영역의 피부색을 추출하려면 이미지의 각 픽셀 데이터를 읽어와야 하는데, 이때 Canvas API를 활용하면 됩니다.
Canvas는 HTML의 <canvas> 요소 위에 그림을 그리는 API입니다. 이미지를 캔버스에 그린 뒤 getImageData()로 픽셀 데이터를 가져올 수 있고요. 지금은 이미지를 캔버스에 그리는 것까지만 하고, 픽셀 추출은 3편에서 다루겠습니다.
Canvas 기본 사용법
Canvas를 사용하는 기본 흐름은 세 단계입니다.
// 1. canvas 요소 가져오기 const canvas = document.querySelector('#my-canvas') // 2. 그리기 컨텍스트 얻기 const ctx = canvas.getContext('2d') // 3. 무언가 그리기 ctx.fillStyle = '#ff5733' ctx.fillRect(10, 10, 100, 50) // x:10, y:10 위치에 100x50 사각형
getContext('2d')가 핵심입니다. 캔버스 자체는 그냥 빈 영역이고, ctx(컨텍스트)가 실제 그리기 도구입니다. 붓과 팔레트 같은 거라고 보면 됩니다. 모든 그리기 작업은 이 ctx 객체를 통해 수행합니다.
이미지를 캔버스에 그리는 것도 ctx를 사용합니다.
// Image 객체가 있다면 ctx.drawImage(img, 0, 0)
drawImage의 매개변수는 "어떤 이미지를, x좌표, y좌표에 그려라"입니다. (0, 0)은 캔버스의 왼쪽 위 모서리고요.
하지만 실전에서는 주의할 점이 있습니다. 이미지 크기와 캔버스 크기가 다를 수 있거든요. 요즘 스마트폰 카메라는 4032x3024 같은 고해상도 이미지를 찍어냅니다. 이걸 그대로 캔버스에 그리면 화면을 벗어나니, 캔버스 크기에 맞게 이미지를 조절해야 합니다.
이미지 처리 유틸리티 모듈
이미지를 캔버스에 그리는 유틸리티를 만들겠습니다. src/utils/imageLoader.js에 함수를 추가합니다. 이 파일은 아까 readFileAsDataURL과 loadImage를 작성한 파일입니다. 여기에 Canvas 관련 함수를 추가합니다.
// src/utils/imageLoader.js (기존 코드 아래에 추가) /** * Image 객체를 canvas에 그립니다. * canvas 크기에 맞게 이미지를 축소하되, 비율은 유지합니다. * 반환값: { canvas, ctx, scale } */ export function drawImageToCanvas(canvas, img) { const ctx = canvas.getContext('2d') // 캔버스의 CSS 크기를 기준으로 실제 그리기 크기 결정 const maxWidth = canvas.clientWidth const maxHeight = canvas.clientHeight || maxWidth * 0.75 // 이미지 비율 유지하면서 캔버스에 맞추기 const scale = Math.min(maxWidth / img.width, maxHeight / img.height, 1) const drawWidth = Math.round(img.width * scale) const drawHeight = Math.round(img.height * scale) // 캔버스 크기를 그리기 크기에 맞추기 canvas.width = drawWidth canvas.height = drawHeight // 이미지 그리기 ctx.drawImage(img, 0, 0, drawWidth, drawHeight) return { canvas, ctx, scale } }
drawImageToCanvas 함수의 핵심은 이미지 비율을 유지하면서 캔버스 크기에 맞추는 부분입니다.
이 코드를 이해하려면 Canvas의 두 가지 크기를 알아야 합니다. Canvas는 HTML 속성으로 정의하는 해상도(width/height)와 CSS로 지정하는 출력 크기가 서로 분리되어 있습니다. HTML 속성의 크기는 그림을 그리는 도화지의 실제 해상도이고, CSS의 크기는 그 도화지를 화면에 표시하는 크기입니다. canvas.width = drawWidth로 설정하는 것이 도화지 해상도를 정하는 것이고, CSS의 max-w-full이나 max-height: 480px은 화면에 보이는 크기를 제한하는 것입니다. 이 코드에서는 canvas.clientWidth로 CSS가 적용된 화면상의 크기를 읽은 뒤, 그에 맞게 도화지 해상도를 설정합니다. 두 크기가 일치하므로 이미지가 흐려지지 않고 선명하게 표시됩니다.
Math.min(maxWidth / img.width, maxHeight / img.height, 1) 이 한 줄이 비율 계산의 전부입니다. 가로 비율과 세로 비율 중 작은 값을 택하면, 이미지가 캔버스를 벗어나지 않으면서 최대한 크게 그려집니다. 마지막의 1은 이미지가 캔버스보다 작을 때 원본 크기 이상으로 키우지 않겠다는 뜻입니다. 작은 이미지를 억지로 키우면 화질이 떨어지니까요.
반환값에 scale을 포함시킨 이유는 3편에서 필요하기 때문입니다. 얼굴 랜드마크의 좌표가 원본 이미지 기준인데, 캔버스에 그린 이미지는 축소되어 있으므로 좌표를 변환할 때 이 축소 비율이 필요합니다.
미리보기 화면 만들기
이제 모든 조각이 준비됐습니다. 업로드 UI, 파일 읽기, 캔버스 그리기를 조합해서 미리보기 화면을 만듭니다.
미리보기 컴포넌트
src/components/previewUI.js 파일을 새로 만듭니다.
// src/components/previewUI.js import { readFileAsDataURL, loadImage, drawImageToCanvas } from '../utils/imageLoader.js' export default function createPreviewUI(container) { container.innerHTML = ` <div class="bg-white rounded-2xl shadow-md p-4 sm:p-6"> <div class="flex items-center justify-between mb-4"> <h2 class="text-lg font-semibold text-gray-700">업로드된 이미지</h2> <button id="reset-btn" class="text-sm text-gray-500 hover:text-red-500 transition-colors"> 다시 선택 </button> </div> <div class="flex justify-center"> <canvas id="preview-canvas" class="max-w-full rounded-lg" style="max-height: 480px;"> </canvas> </div> <div id="image-info" class="mt-4 text-center"> <p class="text-sm text-gray-500"></p> </div> <div class="mt-6 text-center"> <button id="analyze-btn" class="px-6 py-3 bg-purple-600 text-white font-semibold rounded-xl hover:bg-purple-700 transition-colors text-sm sm:text-base"> 퍼스널 컬러 분석하기 </button> <p class="text-xs text-gray-400 mt-2">3편에서 구현 예정</p> </div> </div> ` return { canvas: container.querySelector('#preview-canvas'), resetBtn: container.querySelector('#reset-btn'), analyzeBtn: container.querySelector('#analyze-btn'), imageInfo: container.querySelector('#image-info p'), } } export async function showPreview(file, elements) { const { canvas, imageInfo } = elements // 1. 파일을 Data URL로 읽기 const dataUrl = await readFileAsDataURL(file) // 2. Image 객체 생성 const img = await loadImage(dataUrl) // 3. Canvas에 이미지 그리기 drawImageToCanvas(canvas, img) // 4. 이미지 정보 표시 const sizeMB = (file.size / (1024 * 1024)).toFixed(1) imageInfo.textContent = `${file.name} (${img.width}x${img.height}, ${sizeMB}MB)` }
이 파일에는 두 개의 함수가 있습니다.
createPreviewUI는 미리보기 영역의 HTML을 만들고, 주요 DOM 요소들을 객체로 묶어서 반환합니다. canvas, resetBtn, analyzeBtn, imageInfo 네 가지를 돌려주는데, 이렇게 해두면 다른 코드에서 document.querySelector로 다시 찾을 필요가 없습니다.
showPreview는 실제로 이미지를 표시하는 함수입니다. async 키워드가 붙어 있는데, await를 사용하기 위해서입니다. await readFileAsDataURL(file)은 "파일 읽기가 끝날 때까지 기다린 뒤 결과를 받겠다"는 뜻이고, await loadImage(dataUrl)도 마찬가지로 이미지 로딩이 끝날 때까지 기다립니다.
async/await는 3편에서 MediaPipe와 함께 자세히 다루겠지만, 지금 쓰이는 패턴은 간단합니다. async 함수 안에서 await를 붙이면 Promise가 완료될 때까지 기다린다는 것만 이해하면 됩니다. 위에서 아래로 순서대로 읽히니까 직관적입니다.
showPreview는 export를 붙였지만 default가 아닌 named export입니다. createPreviewUI가 이 파일의 대표 함수이므로 default를 가지고, showPreview는 보조 함수이니 named export가 적절합니다.
이미지 정보를 표시하는 부분도 봐두면 좋습니다. file.size를 1024 * 1024로 나누면 MB 단위가 됩니다. .toFixed(1)은 소수점 한 자리까지만 표시합니다. img.width와 img.height는 이미지의 원본 해상도입니다. 사용자에게 "이 이미지가 제대로 올라갔다"는 피드백을 주는 용도입니다.
모든 것을 연결하기: app.js 완성
이제 만든 모듈들을 app.js에서 조립합니다. 1편에서 main.js가 교통 정리를 한다고 했는데, app.js도 비슷한 역할을 합니다. 각 모듈을 불러와서 서로 연결합니다.
src/components/app.js를 다음과 같이 수정합니다.
// src/components/app.js import createUploadUI from './uploadUI.js' import createPreviewUI, { showPreview } from './previewUI.js' export default function renderApp(container) { container.innerHTML = ` <div class="min-h-screen bg-gradient-to-b from-purple-50 to-pink-50"> <header class="py-6 sm:py-8"> <div class="max-w-2xl mx-auto px-4 text-center"> <h1 class="text-2xl sm:text-3xl font-bold text-gray-800"> 퍼스널 컬러 분석기 </h1> <p class="mt-2 text-sm sm:text-base text-gray-500"> 사진 한 장으로 나에게 어울리는 색을 찾아보세요 </p> </div> </header> <main class="max-w-2xl mx-auto px-4 pb-16"> <div id="upload-area" class="bg-white rounded-2xl shadow-md p-4 sm:p-6"> </div> <div id="preview-area" class="mt-6 hidden"> </div> </main> </div> ` const uploadArea = container.querySelector('#upload-area') const previewArea = container.querySelector('#preview-area') let previewElements = null // 업로드 UI 초기화: 파일이 선택되면 onFileSelected 실행 createUploadUI(uploadArea, async (file) => { // 미리보기 영역이 처음이면 생성 if (!previewElements) { previewElements = createPreviewUI(previewArea) // 다시 선택 버튼 previewElements.resetBtn.addEventListener('click', () => { previewArea.classList.add('hidden') uploadArea.classList.remove('hidden') previewElements = null previewArea.innerHTML = '' }) // 분석 버튼 (3편에서 구현) previewElements.analyzeBtn.addEventListener('click', () => { alert('퍼스널 컬러 분석 기능은 3편에서 구현합니다!') }) } // 업로드 영역 숨기고 미리보기 표시 uploadArea.classList.add('hidden') previewArea.classList.remove('hidden') // 이미지 미리보기 표시 try { await showPreview(file, previewElements) } catch (error) { alert('이미지를 불러올 수 없습니다. 다른 파일을 선택해 주세요.') previewArea.classList.add('hidden') uploadArea.classList.remove('hidden') previewElements = null previewArea.innerHTML = '' } }) }
app.js가 하는 일을 정리하면 이렇습니다.
먼저 페이지의 기본 구조(헤더, 업로드 영역, 미리보기 영역)를 HTML로 만듭니다.
그 다음 createUploadUI를 호출해서 업로드 영역에 드래그 앤 드롭 UI를 넣습니다. 두 번째 매개변수로 async (file) => { ... } 함수를 전달합니다. 이 함수가 파일이 선택됐을 때 실행되는 콜백입니다.
콜백 안에서는 미리보기 UI를 생성하고(createPreviewUI), 업로드 영역을 숨기고(hidden 추가), 미리보기 영역을 보여주고(hidden 제거), 이미지를 캔버스에 그립니다(showPreview).
showPreview를 try-catch로 감싼 부분도 눈여겨봐야 합니다. showPreview 안에서 readFileAsDataURL이나 loadImage가 실패하면(Promise가 reject되면) 에러가 발생합니다. try-catch 없이 이 에러가 터지면 콘솔에 에러 메시지만 찍히고 화면은 미리보기 영역이 빈 채로 멈춥니다. 사용자 입장에서는 뭐가 잘못된 건지 알 수 없습니다. catch 블록에서 alert으로 안내 메시지를 보여주고, 업로드 영역을 다시 표시해서 사용자가 다른 파일을 선택할 수 있게 합니다. 에러 처리는 3편에서 더 체계적으로 다루지만, 이 정도의 안전장치는 처음부터 넣어두는 것이 좋습니다.
"다시 선택" 버튼을 누르면 반대로 동작합니다. 미리보기를 숨기고 업로드 영역을 다시 보여줍니다. previewElements를 null로 초기화하고 previewArea.innerHTML = ''로 DOM을 비워서 다음에 새로 생성되도록 합니다. innerHTML을 빈 문자열로 설정하면 자식 요소들이 DOM에서 제거되는데, 이때 해당 요소들에 등록된 이벤트 리스너도 함께 정리됩니다. 별도로 removeEventListener를 호출하지 않아도 되는 이유입니다.
여기서 주목할 import 구문이 있습니다.
import createPreviewUI, { showPreview } from './previewUI.js'
default export와 named export를 동시에 가져오는 문법입니다. 쉼표로 구분합니다. createPreviewUI는 default export이므로 중괄호 없이, showPreview는 named export이므로 중괄호 안에 씁니다. 1편에서 표로 정리했던 두 방식을 한 줄에서 같이 쓸 수 있습니다.
main.js는 그대로
src/main.js는 수정할 필요가 없습니다.
// src/main.js (변경 없음) import './style.css' import renderApp from './components/app.js' const appContainer = document.querySelector('#app') renderApp(appContainer)
main.js는 진입점 역할만 합니다. 실제 동작은 app.js와 그 하위 모듈들이 처리합니다. 기능이 추가되어도 main.js를 건드릴 일은 거의 없습니다. 이것이 모듈 분리의 장점입니다.
완성된 프로젝트 구조
2편을 마친 프로젝트의 파일 구조를 보겠습니다.
personal-color/ ├── index.html ├── vite.config.js ├── package.json ├── src/ │ ├── main.js <- 진입점 (변경 없음) │ ├── style.css <- Tailwind CSS (변경 없음) │ ├── components/ │ │ ├── app.js <- 메인 앱 (수정: 모듈 조립) │ │ ├── uploadUI.js <- [새로 추가] 업로드 UI │ │ └── previewUI.js <- [새로 추가] 미리보기 UI │ ├── utils/ │ │ ├── colors.js <- 색상 유틸리티 (변경 없음) │ │ └── imageLoader.js <- [새로 추가] 이미지 로딩 │ └── styles/ └── public/
1편에서는 파일이 4개(main.js, style.css, app.js, colors.js)였는데, 2편에서 3개가 추가되어 7개가 됐습니다. 파일이 늘었지만 각 파일의 역할이 분명하죠. 파일 이름만 봐도 어디에 어떤 코드가 있는지 알 수 있습니다.
모듈 간의 의존 관계를 그림으로 그려보면 이렇습니다.
src/main.js └── src/components/app.js ├── src/components/uploadUI.js └── src/components/previewUI.js └── src/utils/imageLoader.js ├── readFileAsDataURL() ├── loadImage() └── drawImageToCanvas()
화살표 방향이 위에서 아래로만 향합니다. uploadUI.js가 previewUI.js를 import하지 않고, imageLoader.js가 app.js를 import하지 않습니다. 이런 단방향 의존 관계가 코드를 이해하기 쉽게 만들어 줍니다. 어떤 파일을 수정해도 그 파일을 import하는 상위 파일에만 영향이 가거든요.
동작 확인
개발 서버를 실행해서 결과를 확인해 봅니다.
npm run dev
브라우저에서 열면 보라색-핑크색 그라데이션 배경 위에 점선 테두리의 업로드 영역이 보입니다. 업로드 영역에 마우스를 올리면 테두리가 보라색으로 바뀝니다.
파일 업로드를 테스트하는 두 가지 방법이 있습니다.
첫째, 업로드 영역을 클릭하면 파일 선택 창이 열립니다. 이미지 파일을 선택하면 업로드 영역이 사라지고 미리보기 화면이 나타납니다. 이미지가 캔버스에 비율을 유지한 채로 표시되고, 아래에 파일 이름과 해상도, 용량이 표시됩니다.
둘째, 컴퓨터의 파일 탐색기에서 이미지를 드래그해서 업로드 영역에 놓아도 같은 결과가 나옵니다. 드래그하는 동안 영역이 보라색으로 변합니다.
"다시 선택" 버튼을 누르면 미리보기가 사라지고 업로드 영역이 다시 나타납니다. "퍼스널 컬러 분석하기" 버튼은 아직 동작하지 않습니다. 3편에서 MediaPipe를 연결할 때 이 버튼에 기능을 붙입니다.
모바일 환경도 확인해 봅니다. 브라우저의 개발자 도구(F12)를 열고 모바일 뷰를 선택하면 화면이 좁아지면서 자동으로 적응합니다. 글자 크기, 패딩, 아이콘 크기가 모바일에 맞게 조절되는데, 이게 sm: 접두사가 하는 일입니다.
마무리: 데이터가 흐르기 시작합니다
2편에서 한 일을 돌아보겠습니다. Tailwind CSS의 반응형 유틸리티로 모바일부터 데스크톱까지 대응하는 레이아웃을 만들었고, 드래그 앤 드롭을 지원하는 이미지 업로드 UI를 구현했습니다. File API와 FileReader로 사용자가 선택한 파일을 읽고, Canvas API로 화면에 표시하는 것까지 해봤고요. 세 개의 새로운 모듈(uploadUI.js, previewUI.js, imageLoader.js)을 추가하면서 1편의 프로젝트 구조가 자연스럽게 확장되는 과정도 경험했습니다.
1편에서는 파일을 나누는 게 번거롭게 느껴졌을 수 있습니다. 하지만 2편에서 3개의 모듈을 추가하면서 체감이 달라졌을 겁니다. uploadUI.js를 만들 때 previewUI.js를 신경 쓸 필요가 없었고, imageLoader.js를 만들 때 화면 구조를 생각하지 않아도 됐죠. 각 파일이 자기 역할만 하니까 한 번에 하나에만 집중할 수 있습니다.
이번 편에서 가장 중요한 건 데이터의 흐름입니다. 사용자가 파일을 선택하면 uploadUI.js가 File 객체를 만들고, 콜백을 통해 app.js에 전달합니다. app.js는 previewUI.js에 파일을 넘기고, previewUI.js는 imageLoader.js의 유틸리티를 써서 파일을 읽고 캔버스에 그립니다. 각 모듈이 배턴을 넘기듯 데이터를 전달하는 거죠.
3편에서는 이 캔버스 위의 이미지에서 얼굴을 찾습니다. Google의 MediaPipe 라이브러리를 써서 468개의 얼굴 랜드마크를 추출하고, 볼과 이마 영역의 피부색을 가져올 겁니다. "퍼스널 컬러 분석하기" 버튼을 누르면 실제로 분석이 시작되는 거죠. 2편에서 만든 Canvas 위의 이미지가 3편의 입력 데이터가 됩니다.
참고 자료






댓글
댓글을 작성하려면 이 필요합니다.