
내 사진 한 장으로 퍼스널 컬러 찾기 [4편] - 퍼스널 컬러 판별과 무료 배포


3편에서 MediaPipe로 얼굴의 478개 랜드마크를 찾고, 볼과 이마에서 700개 픽셀을 추출해서 평균 피부색 RGB를 구했습니다. 결과 화면에 색상 사각형과 RGB 값이 표시되는 것까지 확인했죠. 그런데 RGB(195, 158, 135) 같은 숫자만 봐서는 이 사람이 웜톤인지 쿨톤인지 알 수 없습니다. 봄인지 가을인지는 더더욱 모르겠고요.
사람의 눈은 색을 RGB로 인식하지 않습니다. "빨강이 195이고 초록이 158이고 파랑이 135이다"라고 말하는 사람은 없죠. 대신 "노르스름한 피부", "핑크빛 피부", "밝은 피부", "어두운 피부"처럼 색의 느낌으로 표현합니다. 이 느낌을 숫자로 바꿔주는 게 HSL 색 공간입니다. H는 색조(노란기인지 핑크기인지), S는 채도(선명한지 탁한지), L은 명도(밝은지 어두운지)를 나타냅니다.
1편에서 colors.js에 rgbToHsl 함수를 만들어 뒀습니다. "4편에서 쓸 거다"라고 했던 바로 그 함수입니다. 드디어 진짜 역할을 할 차례입니다. 이 함수로 피부색 RGB를 HSL로 변환하고, H/S/L 값을 분석해서 웜톤/쿨톤을 판별한 뒤 4계절 타입을 분류합니다. 결과 화면도 만들고, 완성된 사이트를 Vercel에 무료로 배포하는 것까지 해서 시리즈를 마무리합니다.
RGB와 HSL: 컴퓨터의 언어에서 사람의 언어로
RGB의 한계
RGB는 빨강(Red), 초록(Green), 파랑(Blue) 세 가지 빛의 세기로 색을 표현합니다. 컴퓨터 화면이 이 세 가지 빛을 조합해서 색을 만들기 때문에, 디지털 이미지에서는 RGB가 기본입니다. 3편에서 Canvas의 getImageData로 추출한 픽셀 데이터가 RGBA 형태였던 이유이기도 합니다.
그런데 RGB 값만 봐서는 색의 성격을 파악하기 어렵습니다. RGB(195, 158, 135)와 RGB(210, 170, 135)가 있다고 합시다. 둘 다 피부색인 건 알겠는데, 어느 쪽이 더 노란기가 있는지, 어느 쪽이 더 밝은지를 R/G/B 세 숫자만 보고 직관적으로 판단하기는 쉽지 않습니다. 세 채널이 복잡하게 얽혀 있거든요.
HSL이 해결합니다
HSL은 색을 세 축으로 나눕니다. 각 축이 사람의 감각과 잘 맞습니다.
**H(Hue, 색조)**는 0에서 360 사이의 값으로, 색상환 위의 위치를 나타냅니다. 0도가 빨강이고 60도가 노랑, 120도가 초록, 240도가 파랑이며, 360도에서 다시 빨강으로 돌아옵니다. 피부색은 보통 H가 10~40도 사이에 몰려 있습니다. 색상환에서 0도(빨강) 방향으로 가까울수록 붉은기/핑크기가 강해지고, 60도(노랑) 방향으로 가까울수록 노란기가 강해집니다. 즉 피부색 범위 안에서 H가 작으면(10~25도) 빨강/주황에 가까운 색이고, H가 크면(25~40도) 노랑에 가까운 색입니다.
그런데 퍼스널 컬러 이론에서는 H가 작은 쪽(빨강/주황 계열)을 웜톤으로 분류합니다. 직관과 다를 수 있지만, 이유가 있습니다. 빨강/주황 계열은 따뜻한 느낌을 주기 때문에 "warm"이고, H가 커져서 노란기를 넘어 초록/파랑 쪽으로 갈수록 차가운 느낌이 됩니다. 피부색 범위(10~40도)에서 H = 25를 기준으로 나누면, 25 미만은 주황/복숭아빛이 강한 웜톤, 25 이상은 핑크/로즈빛이 섞인 쿨톤으로 분류합니다. 웜톤인지 쿨톤인지는 결국 이 H 값으로 갈립니다.
**S(Saturation, 채도)**는 0에서 100 사이의 값으로, 색이 얼마나 선명한지 나타냅니다. 0이면 회색, 100이면 가장 선명한 색이죠. 피부의 채도가 높으면 맑고 선명한 느낌이고, 낮으면 탁하고 차분한 느낌입니다.
**L(Lightness, 명도)**는 0에서 100 사이의 밝기 값입니다. 0이면 완전한 검정, 100이면 완전한 흰색이고요. 명도가 높으면 밝은 피부, 낮으면 어두운 피부입니다.
아까 예로 든 두 색을 HSL로 변환해 보겠습니다.
import { rgbToHsl } from './utils/colors.js' const color1 = rgbToHsl(195, 158, 135) // { h: 23, s: 33, l: 65 } const color2 = rgbToHsl(210, 170, 135) // { h: 28, s: 45, l: 68 }
이제 차이가 보입니다. color1은 H가 23으로 빨강/주황 쪽에 가깝습니다. 복숭아빛이 도는 따뜻한 느낌의 피부색입니다. color2는 H가 28로 color1보다 노랑 쪽에 가깝지만, 이 범위에서는 핑크/로즈빛이 섞이기 시작합니다. 채도(S: 45)도 높고 명도(L: 68)도 약간 높습니다. RGB로는 보이지 않던 색의 성격이 HSL로 변환하면 숫자로 읽힙니다. 앞으로 구현할 알고리즘에서 color1(H=23)은 웜톤, color2(H=28)는 쿨톤으로 분류됩니다.
rgbToHsl 함수 복습
1편에서 만든 rgbToHsl 함수를 다시 보겠습니다. src/utils/colors.js에 이미 있습니다.
// src/utils/colors.js (1편에서 작성한 코드) export function rgbToHsl(r, g, b) { r = r / 255 g = g / 255 b = b / 255 const max = Math.max(r, g, b) const min = Math.min(r, g, b) const diff = max - min let h = 0 let s = 0 let l = (max + min) / 2 if (diff !== 0) { s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min) if (max === r) { h = ((g - b) / diff) + (g < b ? 6 : 0) } else if (max === g) { h = ((b - r) / diff) + 2 } else { h = ((r - g) / diff) + 4 } h = h / 6 } return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100), } }
1편에서 "4편에서 피부색을 분석할 때 가장 많이 쓰는 함수"라고 했는데, 지금이 바로 그때입니다. 이 함수의 수학적 원리까지 깊이 들어가지는 않겠습니다. RGB 값을 0~1 범위로 정규화한 뒤, 최대값과 최소값의 관계에서 H/S/L을 계산하는 표준 알고리즘입니다. 중요한 건 입력과 출력입니다. rgbToHsl(195, 158, 135)를 넣으면 { h: 23, s: 33, l: 65 }가 나온다는 것, 그리고 이 H/S/L 값으로 퍼스널 컬러를 판별합니다.
언더톤 판별: 웜톤과 쿨톤
언더톤이란
퍼스널 컬러에서 가장 먼저 보는 건 언더톤입니다. 피부 표면 아래에 비치는 색조가 노란기인지 핑크/블루기인지에 따라 웜톤과 쿨톤으로 나뉩니다.
웜톤 피부는 노란빛, 복숭아빛, 골든빛이 비칩니다. 금색 액세서리가 잘 어울리고, 가을 단풍 같은 따뜻한 색상의 옷이 어울립니다. 쿨톤 피부는 핑크빛, 블루빛, 올리브빛이 비칩니다. 은색 액세서리가 잘 어울리고, 겨울 하늘 같은 차가운 색상의 옷이 어울립니다.
HSL의 H 값으로 판별하기
HSL에서 H(색조)가 바로 이 언더톤을 나타냅니다. 피부색의 H 값이 색상환에서 어디에 위치하느냐에 따라 분류합니다.
피부색은 대부분 H가 10~40도 사이에 분포합니다. arXiv에 발표된 피부 감지 연구(Human Skin Detection Using RGB, HSV and YCbCr Color Models)에 따르면, HSV 공간에서 피부는 H: 0~50도 범위에 집중됩니다. HSL의 H와 HSV의 H는 같은 색상환을 공유하므로 이 범위를 참고할 수 있습니다.
이 범위를 웜/쿨로 나누는 기준은 H = 25도입니다.
H < 25도 → 웜톤 (주황/복숭아빛, 골든, 따뜻한 느낌) H >= 25도 → 쿨톤 (핑크/로즈빛, 블루, 차가운 느낌)
엄밀히 말하면 퍼스널 컬러 판별은 전문 컨설턴트가 자연광 아래에서 여러 색상의 천을 대보면서 하는 작업입니다. 알고리즘으로 100% 정확하게 판별하기는 어렵습니다. 조명, 화장, 카메라 화이트밸런스에 따라 결과가 달라질 수 있거든요. 여기서 구현하는 건 "대략적인 경향성을 파악하는 재미있는 도구" 정도로 이해하면 됩니다. 그래도 맨 눈으로 보는 것보다는 객관적인 수치 기반이니 참고 값으로는 충분합니다.
4계절 분류 알고리즘
4계절 타입의 특징
웜톤과 쿨톤을 판별했으면, 다음으로 채도(S)와 명도(L)를 분석해서 4계절 중 하나로 분류합니다.
봄(Spring Warm): 웜톤이면서 피부가 밝고 채도가 높습니다. 산뜻하고 맑은 느낌입니다. 코럴, 피치, 밝은 오렌지 같은 따뜻하고 밝은 색이 어울립니다.
가을(Autumn Warm): 웜톤이면서 피부가 어둡거나 채도가 낮습니다. 깊고 풍부한 느낌입니다. 카키, 머스타드, 테라코타 같은 따뜻하고 깊은 색이 어울립니다.
여름(Summer Cool): 쿨톤이면서 피부가 밝거나 채도가 낮습니다. 부드럽고 차분한 느낌입니다. 라벤더, 로즈핑크, 파우더블루 같은 차갑고 부드러운 색이 어울립니다.
겨울(Winter Cool): 쿨톤이면서 피부가 어둡거나 채도가 높습니다. 강렬하고 선명한 느낌입니다. 로얄블루, 와인, 블랙 같은 차갑고 강한 색이 어울립니다.
정리하면 이렇습니다. 아래 표의 분류 조건은 코드와 1:1로 대응합니다.
| 계절 | 언더톤 | 분류 조건 | 느낌 |
|---|---|---|---|
| 봄 | 웜톤 (H < 25) | L >= 60 AND S >= 30 | 밝고 산뜻 |
| 가을 | 웜톤 (H < 25) | 봄이 아닌 나머지 웜톤 | 깊고 풍부 |
| 여름 | 쿨톤 (H >= 25) | L >= 60 OR S < 30 | 부드럽고 차분 |
| 겨울 | 쿨톤 (H >= 25) | 여름이 아닌 나머지 쿨톤 | 강렬하고 선명 |
봄은 명도와 채도가 모두 높아야 합니다. 둘 중 하나라도 낮으면 가을로 분류됩니다. 여름은 명도가 높거나 채도가 낮으면 해당합니다. 밝거나 부드러운 쪽이 여름이고, 어둡고 선명한 쪽이 겨울입니다.
컬러 분석 모듈 만들기
이 알고리즘을 코드로 구현합니다. src/utils/colorAnalyzer.js 파일을 새로 만듭니다.
// src/utils/colorAnalyzer.js import { rgbToHsl } from './colors.js' /** * 퍼스널 컬러 4계절 타입과 추천 팔레트 데이터 */ const SEASON_DATA = { spring: { name: '봄 웜톤', nameEn: 'Spring Warm', description: '밝고 산뜻한 색이 어울리는 따뜻한 타입', palette: ['#FF6B6B', '#FFA07A', '#FFD93D', '#6BCB77', '#4D96FF'], paletteNames: ['코럴 레드', '살몬 핑크', '선플라워', '민트 그린', '스카이 블루'], tips: '밝고 따뜻한 색상이 얼굴을 환하게 만들어 줍니다. 베이지, 코럴, 피치, 밝은 오렌지 계열을 추천합니다.' }, autumn: { name: '가을 웜톤', nameEn: 'Autumn Warm', description: '깊고 풍부한 색이 어울리는 따뜻한 타입', palette: ['#B85C38', '#E0A370', '#C1A35F', '#5C8A4D', '#2C5F2D'], paletteNames: ['테라코타', '카멜', '머스타드', '올리브', '포레스트'], tips: '깊고 따뜻한 색상이 피부를 풍부하게 보이게 합니다. 카키, 버건디, 머스타드, 브라운 계열을 추천합니다.' }, summer: { name: '여름 쿨톤', nameEn: 'Summer Cool', description: '부드럽고 차분한 색이 어울리는 시원한 타입', palette: ['#D4A5C9', '#A7C5EB', '#B8D4E3', '#F2BED1', '#CFBAF0'], paletteNames: ['라벤더', '파우더 블루', '스카이 그레이', '로즈 핑크', '라일락'], tips: '부드럽고 시원한 파스텔 톤이 피부를 맑게 보이게 합니다. 라벤더, 로즈핑크, 스카이블루 계열을 추천합니다.' }, winter: { name: '겨울 쿨톤', nameEn: 'Winter Cool', description: '강렬하고 선명한 색이 어울리는 시원한 타입', palette: ['#E63946', '#1D3557', '#457B9D', '#6A0572', '#000000'], paletteNames: ['체리 레드', '네이비', '틸 블루', '딥 퍼플', '블랙'], tips: '선명하고 대비가 강한 색상이 피부를 돋보이게 합니다. 블랙, 화이트, 로얄블루, 와인 계열을 추천합니다.' } } /** * RGB 색상으로 퍼스널 컬러를 분석합니다. * 반환값: { season, undertone, hsl, seasonData } */ export function analyzePersonalColor(avgColor) { const hsl = rgbToHsl(avgColor.r, avgColor.g, avgColor.b) // 1단계: 언더톤 판별 (H 기준) const undertone = hsl.h < 25 ? 'warm' : 'cool' // 2단계: 4계절 분류 (언더톤 + S/L 조합) let season = '' if (undertone === 'warm') { // 웜톤: 봄 vs 가을 if (hsl.l >= 60 && hsl.s >= 30) { season = 'spring' // 밝고 선명 → 봄 } else { season = 'autumn' // 어둡거나 탁함 → 가을 } } else { // 쿨톤: 여름 vs 겨울 if (hsl.l >= 60 || hsl.s < 30) { season = 'summer' // 밝거나 부드러움 → 여름 } else { season = 'winter' // 어둡고 선명 → 겨울 } } return { season, undertone, hsl, seasonData: SEASON_DATA[season] } } /** * 계절 데이터를 외부에서 조회할 수 있도록 export */ export { SEASON_DATA }
코드를 하나씩 보겠습니다.
SEASON_DATA 객체가 4계절 각각의 정보를 담고 있습니다. 계절 이름, 설명, 추천 컬러 팔레트, 팔레트 색상의 이름, 스타일 팁까지 포함되어 있습니다. 이 데이터를 나중에 결과 화면에서 그대로 사용합니다. 데이터와 로직을 같은 파일에 두면 팔레트 색상을 바꾸고 싶을 때 이 파일만 수정하면 됩니다.
analyzePersonalColor 함수가 핵심입니다. 3편에서 구한 평균 피부색 { r, g, b } 객체를 받아서, 1편에서 만든 rgbToHsl로 변환한 뒤 분류합니다.
1단계에서 H가 25 미만이면 웜톤, 25 이상이면 쿨톤으로 판별합니다.
2단계에서 웜톤이면 봄/가을을, 쿨톤이면 여름/겨울을 결정합니다. 웜톤 안에서는 명도와 채도가 모두 높으면 봄, 그렇지 않으면 가을입니다. 쿨톤 안에서는 명도가 높거나 채도가 낮으면 여름, 명도가 낮고 채도가 높으면 겨울입니다.
조건식이 단순하죠. 실제 퍼스널 컬러 분석은 피부뿐 아니라 머리카락 색, 눈동자 색, 혈관 색까지 함께 보기 때문에 훨씬 복잡합니다. 피부색 하나로만 판별하니 정확도에 한계가 있고요. 하지만 튜토리얼의 목적은 "RGB를 HSL로 변환하고, 조건문으로 분류하는 로직을 구현해보는 것"이니 이 정도면 충분합니다. 나중에 개선하고 싶다면, 머리카락/눈동자 색상 입력을 추가하거나 분류 경계값을 미세 조정하는 식으로 확장할 수 있습니다.
반환값에 seasonData를 포함시켜서 호출하는 쪽에서 바로 결과 화면을 그릴 수 있게 했고, SEASON_DATA도 named export로 내보내서 필요하면 외부에서 직접 조회할 수 있습니다.
결과 화면 UI 만들기
app.js가 너무 길어졌습니다
3편에서 app.js가 130줄 가까이 됐었죠. 결과 HTML을 생성하는 코드가 이벤트 핸들러 안에 인라인으로 들어가 있었거든요. 여기에 퍼스널 컬러 판별 결과까지 추가하면 더 길어집니다. "코드가 커지면 나누는 것, 이것이 모듈화의 기본 감각"이라고 했으니, 결과 화면을 별도 모듈로 분리합니다.
resultUI.js 만들기
src/components/resultUI.js 파일을 새로 만듭니다.
1편에서 colors.js에 rgbToHex, hexToRgb, rgbToHsl 세 함수를 만들었습니다. rgbToHsl은 바로 위에서 복습했고, 여기서는 rgbToHex도 사용합니다. rgbToHex(r, g, b)는 RGB 값을 HEX 문자열로 변환하는 함수입니다. 예를 들어 rgbToHex(195, 158, 135)를 호출하면 '#c39e87'이 반환됩니다. 1편의 colors.js 코드를 확인해 주세요.
// src/components/resultUI.js import { rgbToHex } from '../utils/colors.js' /** * 퍼스널 컬러 분석 결과 화면을 생성합니다. */ export default function renderResult(container, analysisResult) { const { avgColor, skinPixels, seasonData, undertone, hsl } = analysisResult const hex = rgbToHex(avgColor.r, avgColor.g, avgColor.b) const undertoneLabel = undertone === 'warm' ? '웜톤' : '쿨톤' container.innerHTML = ` <div class="bg-white rounded-2xl shadow-md p-4 sm:p-6"> <h2 class="text-lg sm:text-xl font-bold text-gray-800 mb-6 text-center"> 분석 결과 </h2> <div class="flex flex-col items-center gap-4 mb-6"> <div class="w-24 h-24 sm:w-28 sm:h-28 rounded-full shadow-lg border-4 border-white" style="background-color: ${hex}; box-shadow: 0 0 0 4px ${hex}33, 0 4px 12px rgba(0,0,0,0.1);"> </div> <div class="text-center"> <p class="text-2xl sm:text-3xl font-bold text-gray-800"> ${seasonData.name} </p> <p class="text-sm text-gray-500 mt-1"> ${seasonData.nameEn} </p> </div> </div> <div class="bg-gray-50 rounded-xl p-4 mb-6"> <p class="text-sm text-gray-700 leading-relaxed"> ${seasonData.description}. ${seasonData.tips} </p> </div> <div class="mb-6"> <h3 class="text-sm font-semibold text-gray-600 mb-3">추천 컬러 팔레트</h3> <div class="flex gap-2 sm:gap-3 justify-center"> ${seasonData.palette.map((color, i) => ` <div class="flex flex-col items-center gap-1"> <div class="w-10 h-10 sm:w-12 sm:h-12 rounded-lg shadow-sm border border-gray-100" style="background-color: ${color}"></div> <span class="text-xs text-gray-400 hidden sm:block"> ${seasonData.paletteNames[i]} </span> </div> `).join('')} </div> </div> <div class="border-t border-gray-100 pt-4"> <h3 class="text-sm font-semibold text-gray-600 mb-3">상세 분석 데이터</h3> <div class="grid grid-cols-2 gap-3 text-sm"> <div class="bg-gray-50 rounded-lg p-3"> <p class="text-gray-400 text-xs mb-1">평균 피부색</p> <div class="flex items-center gap-2"> <div class="w-5 h-5 rounded border border-gray-200" style="background-color: ${hex}"></div> <span class="text-gray-700 font-mono">${hex}</span> </div> </div> <div class="bg-gray-50 rounded-lg p-3"> <p class="text-gray-400 text-xs mb-1">언더톤</p> <p class="text-gray-700 font-medium">${undertoneLabel}</p> </div> <div class="bg-gray-50 rounded-lg p-3"> <p class="text-gray-400 text-xs mb-1">HSL</p> <p class="text-gray-700 font-mono text-xs"> H:${hsl.h} S:${hsl.s} L:${hsl.l} </p> </div> <div class="bg-gray-50 rounded-lg p-3"> <p class="text-gray-400 text-xs mb-1">분석 픽셀</p> <p class="text-gray-700">${skinPixels.length}개</p> </div> </div> </div> <div class="mt-6 text-center"> <p class="text-xs text-gray-400"> 이 결과는 사진의 피부색을 기반으로 한 참고용 분석입니다. 정확한 퍼스널 컬러 진단은 전문 컨설턴트에게 받는 것을 권장합니다. </p> </div> </div> ` }
구조를 보겠습니다.
renderResult 함수는 결과를 표시할 컨테이너 요소와 분석 결과 객체를 받습니다. analysisResult 객체에는 평균 피부색(avgColor), 추출된 픽셀 배열(skinPixels), 계절 데이터(seasonData), 언더톤(undertone), HSL 값(hsl)이 들어 있습니다. 이 데이터를 조합해서 결과 HTML을 만듭니다.
결과 화면은 네 부분으로 구성됩니다. 맨 위에 피부색 원과 계절 이름, 그 아래에 설명과 스타일 팁이 나옵니다. 다음으로 추천 컬러 팔레트 5가지가 색상 사각형으로 이어지고, 맨 아래에 상세 분석 데이터(HEX, 언더톤, HSL, 분석 픽셀 수)가 2x2 그리드로 보입니다.
반응형 처리도 넣었습니다. 피부색 원은 모바일에서 w-24 h-24, 데스크톱에서 sm:w-28 sm:h-28으로 커집니다. 팔레트 색상의 이름(paletteNames)은 모바일에서 hidden으로 숨기고 데스크톱에서만 sm:block으로 표시합니다. 작은 화면에서는 색상 사각형만 보이고, 넓은 화면에서는 이름까지 보이는 거죠.
팔레트 부분에서 map과 join을 사용했습니다.
seasonData.palette.map((color, i) => ` <div>...</div> `).join('')
map은 배열의 각 요소를 변환해서 새 배열을 만드는 메서드입니다. 여기서는 팔레트 색상 배열을 HTML 문자열 배열로 변환합니다. join('')은 배열의 요소를 하나의 문자열로 합칩니다. 빈 문자열 ''을 구분자로 썼으니 쉼표 없이 이어붙입니다. 결과적으로 5개의 색상 카드 HTML이 하나의 문자열이 됩니다.
마지막에 "참고용 분석입니다"라는 문구를 넣었습니다. 앞서 말한 것처럼 사진 한 장으로 하는 분석에는 한계가 있으니, 사용자에게 솔직하게 알려주는 게 맞습니다.
app.js 최종 수정: 모든 것을 연결하기
이제 새로 만든 모듈들을 app.js에 연결합니다. 3편의 app.js에서 달라지는 부분은 import 추가, 분석 버튼 핸들러 수정, 결과 표시 방식 변경입니다.
// src/components/app.js import createUploadUI from './uploadUI.js' import createPreviewUI, { showPreview } from './previewUI.js' import renderResult from './resultUI.js' import { detectFace, isReady } from '../utils/faceDetector.js' import { extractSkinPixels, calculateAverageColor, drawLandmarks, SKIN_LANDMARKS } from '../utils/skinAnalyzer.js' import { analyzePersonalColor } from '../utils/colorAnalyzer.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> <div id="result-area" class="mt-6 hidden"> </div> </main> </div> ` const uploadArea = container.querySelector('#upload-area') const previewArea = container.querySelector('#preview-area') const resultArea = container.querySelector('#result-area') let previewElements = null createUploadUI(uploadArea, async (file) => { if (!previewElements) { previewElements = createPreviewUI(previewArea) // 다시 선택 버튼 previewElements.resetBtn.addEventListener('click', () => { previewArea.classList.add('hidden') resultArea.classList.add('hidden') resultArea.innerHTML = '' uploadArea.classList.remove('hidden') previewElements = null previewArea.innerHTML = '' }) // 분석 버튼 previewElements.analyzeBtn.addEventListener('click', async () => { const analyzeBtn = previewElements.analyzeBtn if (!isReady()) { alert('얼굴 인식 모델을 불러오는 중입니다. 잠시 후 다시 시도해 주세요.') return } try { analyzeBtn.disabled = true analyzeBtn.textContent = '분석 중...' const canvas = previewElements.canvas // 1. 얼굴 랜드마크 감지 const landmarks = detectFace(canvas) if (!landmarks) { alert('얼굴을 찾을 수 없습니다. 얼굴이 잘 보이는 사진을 선택해 주세요.') return } // 2. 피부 픽셀 추출 (랜드마크 그리기 전에!) const skinPixels = extractSkinPixels(canvas, landmarks) const avgColor = calculateAverageColor(skinPixels) // 3. 퍼스널 컬러 분석 const analysis = analyzePersonalColor(avgColor) // 4. 랜드마크 시각화 (추출 이후에 그리기) const allIndices = [ ...SKIN_LANDMARKS.leftCheek, ...SKIN_LANDMARKS.rightCheek, ...SKIN_LANDMARKS.forehead ] drawLandmarks(canvas, landmarks, allIndices) // 5. 결과 표시 resultArea.classList.remove('hidden') renderResult(resultArea, { avgColor, skinPixels, seasonData: analysis.seasonData, undertone: analysis.undertone, hsl: analysis.hsl }) } catch (error) { console.error('분석 오류:', error) alert('분석 중 오류가 발생했습니다. 다른 사진을 시도해 주세요.') } finally { analyzeBtn.disabled = false analyzeBtn.textContent = '퍼스널 컬러 분석하기' } }) } // 업로드 영역 숨기고 미리보기 표시 uploadArea.classList.add('hidden') previewArea.classList.remove('hidden') resultArea.classList.add('hidden') resultArea.innerHTML = '' // 이미지 미리보기 표시 try { await showPreview(file, previewElements) } catch (error) { alert('이미지를 불러올 수 없습니다. 다른 파일을 선택해 주세요.') previewArea.classList.add('hidden') uploadArea.classList.remove('hidden') previewElements = null previewArea.innerHTML = '' } }) }
3편에서 뭐가 달라졌는지 보겠습니다.
import가 두 줄 추가됐습니다. resultUI.js에서 renderResult를, colorAnalyzer.js에서 analyzePersonalColor를 가져옵니다. 3편에서 import했던 rgbToHex와 colors.js는 app.js에서 더 이상 쓰지 않으니 제거했습니다. rgbToHex는 이제 resultUI.js 안에서 사용합니다.
분석 버튼 핸들러에서 3단계가 추가됐습니다. 3편에서는 피부 픽셀 추출 후 바로 인라인 HTML로 결과를 보여줬습니다. 4편에서는 analyzePersonalColor(avgColor)를 호출해서 퍼스널 컬러를 분석한 뒤, renderResult에 분석 결과를 넘겨서 결과 화면을 그립니다.
3편에 있던 resultArea.innerHTML = ... 인라인 HTML 코드가 전부 사라지고, 대신 renderResult(resultArea, {...}) 한 줄로 대체됐습니다. 결과 화면의 HTML 생성 책임이 app.js에서 resultUI.js로 이동했습니다. app.js는 "무엇을 보여줄지" 결정하고, resultUI.js는 "어떻게 보여줄지" 처리합니다.
코드 실행 순서는 3편과 같습니다. 피부 픽셀을 먼저 추출하고(2단계), 그 다음에 랜드마크를 캔버스에 그립니다(4단계). 순서가 바뀌면 보라색 점의 색상이 피부 픽셀에 섞이니까요. 3편에서 설명했던 주의사항을 그대로 지키고 있습니다.
previewUI.js 수정
3편에서 이미 "3편에서 구현 예정" 텍스트를 제거했지만, 분석 버튼의 텍스트를 한 가지 더 수정합니다. 3편의 previewUI.js에서 분석 버튼 아래에 "4편에서 이 색상 데이터로 퍼스널 컬러를 판별합니다"라는 텍스트가 결과 영역에 나왔었는데, 이제 실제 분석이 구현됐으니 그 안내 문구를 보여주는 인라인 HTML은 app.js에서 이미 제거되었습니다.
previewUI.js 자체는 3편에서 수정한 상태 그대로 사용합니다. 변경할 부분이 없습니다.
완성된 프로젝트 구조
4편을 마친 프로젝트의 파일 구조입니다.
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 (변경 없음) │ │ └── resultUI.js <- [새로 추가] 결과 화면 UI │ ├── utils/ │ │ ├── colors.js <- 색상 유틸리티 (변경 없음) │ │ ├── imageLoader.js <- 이미지 로딩 (변경 없음) │ │ ├── faceDetector.js <- 얼굴 랜드마크 감지 (변경 없음) │ │ ├── skinAnalyzer.js <- 피부 픽셀 추출 (변경 없음) │ │ └── colorAnalyzer.js <- [새로 추가] 퍼스널 컬러 분석 │ └── styles/ └── public/
3편에서 9개였던 소스 파일이 11개로 늘었습니다. 새로 추가된 두 파일은 components/resultUI.js와 utils/colorAnalyzer.js입니다. 패턴이 보이죠. 화면 관련은 components/, 로직 관련은 utils/에 들어갑니다. 1편에서 정한 규칙을 끝까지 따르고 있습니다.
모듈 간 의존 관계도 확인해 둡니다.
src/main.js ├── src/utils/faceDetector.js (초기화) └── src/components/app.js ├── src/components/uploadUI.js ├── src/components/previewUI.js │ └── src/utils/imageLoader.js ├── src/components/resultUI.js [4편 추가] │ └── src/utils/colors.js ├── src/utils/faceDetector.js (감지) ├── src/utils/skinAnalyzer.js └── src/utils/colorAnalyzer.js [4편 추가] └── src/utils/colors.js
colors.js가 두 곳에서 import됩니다. resultUI.js에서 rgbToHex를 사용하고, colorAnalyzer.js에서 rgbToHsl을 사용합니다. faceDetector.js가 두 곳에서 import되는 것과 같은 패턴입니다. ES 모듈은 한 번만 실행되니 문제가 없죠.
4편에서 수정한 파일은 app.js 하나뿐입니다. 나머지는 새로 추가한 파일이죠. 기존 유틸리티 모듈(colors.js, faceDetector.js, skinAnalyzer.js)은 한 줄도 수정하지 않았습니다. 모듈이 잘 나뉘어 있으면 새 기능을 추가할 때 기존 코드를 수정하지 않아도 됩니다. 이것이 모듈화가 주는 실질적인 이점입니다.
동작 확인
개발 서버를 실행해서 전체 흐름을 확인합니다.
npm run dev
브라우저를 열면 이전과 같은 업로드 화면이 보입니다. 셀카 사진을 업로드합니다. 미리보기 화면이 나타나면 "퍼스널 컬러 분석하기" 버튼을 누릅니다.
버튼을 누르면 "분석 중..."으로 바뀌었다가, 잠시 뒤 캔버스 위에 보라색 점이 찍히고, 그 아래에 결과 카드가 나타납니다. 3편에서는 평균 피부색만 보였는데, 이번에는 계절 이름(봄 웜톤, 여름 쿨톤 등)과 설명, 추천 컬러 팔레트, 상세 분석 데이터가 모두 표시됩니다.
다른 사진으로도 테스트해 봅니다. 사진마다 다른 결과가 나오는 게 정상입니다. 같은 사람이라도 조명에 따라 결과가 달라질 수 있는데, 자연광에서 찍은 사진이 가장 정확하고, 형광등 아래에서 찍으면 녹색기가 섞여 결과가 틀어질 수 있습니다.
"다시 선택" 버튼을 누르면 결과 화면이 사라지고 업로드 화면으로 돌아갑니다. 다른 사진을 올려서 비교해 볼 수 있습니다.
Vercel로 무료 배포하기
완성된 사이트를 인터넷에 공개합니다. Vercel은 프론트엔드 프로젝트를 무료로 배포할 수 있는 플랫폼입니다. GitHub 저장소와 연결하면, 코드를 push할 때마다 자동으로 빌드하고 배포합니다.
배포 전 빌드 확인
먼저 프로젝트가 제대로 빌드되는지 확인합니다.
npm run build
dist/ 폴더가 생기고 그 안에 배포용 파일이 만들어집니다. Vite가 JavaScript와 CSS를 번들링하고, 파일 이름에 해시를 붙여서 캐시 문제를 방지합니다. dist/ 폴더의 내용이 실제로 서버에 올라가는 파일입니다.
빌드 결과를 로컬에서 미리 확인할 수 있습니다.
npm run preview
http://localhost:4173 같은 주소가 나옵니다. 이 주소를 브라우저에서 열어 개발 서버와 동일하게 동작하는지 확인합니다. 빌드된 파일은 개발 서버와 달리 최적화되어 있어서 속도가 더 빠릅니다.
GitHub 저장소 만들기
Vercel은 GitHub 저장소에서 코드를 가져와 빌드합니다. GitHub 계정이 없다면 github.com에서 무료로 만들 수 있습니다. Git이 설치되어 있는지 확인합니다.
git --version
버전 번호가 나오면 됩니다. Git이 없다면 git-scm.com에서 설치합니다.
프로젝트 폴더에서 Git 저장소를 초기화하고 첫 커밋을 만듭니다.
git init git add . git commit -m "퍼스널 컬러 분석기 완성"
GitHub에서 새 저장소를 만듭니다. 저장소 이름은 personal-color로 하겠습니다. 저장소를 만들 때 README 파일 등 초기화 옵션은 전부 체크 해제합니다. 빈 저장소를 만들어야 로컬 코드를 바로 push할 수 있습니다.
GitHub에서 제공하는 안내에 따라 원격 저장소를 연결하고 push합니다.
git remote add origin https://github.com/사용자명/personal-color.git git branch -M main git push -u origin main
사용자명 부분은 본인의 GitHub 아이디로 바꿉니다. push가 완료되면 GitHub 저장소 페이지에서 코드가 올라간 것을 확인할 수 있습니다.
Vercel 배포
Vercel 사이트(vercel.com)에 접속해서 GitHub 계정으로 로그인합니다.
로그인하면 대시보드가 나옵니다. "Add New Project" 버튼을 클릭합니다. GitHub 저장소 목록이 나타나는데, 방금 만든 personal-color 저장소를 선택합니다. 저장소가 보이지 않으면 "Import Third-Party Git Repository" 대신 "Adjust GitHub App Permissions"를 클릭해서 저장소 접근 권한을 추가합니다.
저장소를 선택하면 프로젝트 설정 화면이 나오는데, Vercel이 package.json을 분석해서 알아서 설정을 잡아줍니다.
Framework Preset: Vite Build Command: npm run build Output Directory: dist Install Command: npm install
이 설정이 맞는지 확인하고 "Deploy" 버튼을 누릅니다. Vercel이 npm install, npm run build를 실행하고, dist/ 폴더를 배포합니다. 1~2분 안에 완료됩니다.
배포가 끝나면 https://personal-color-xxxx.vercel.app 같은 URL이 생깁니다. 이 URL을 브라우저에서 열면 완성된 퍼스널 컬러 분석기가 동작합니다. 이 URL을 친구에게 공유하면 누구나 사용할 수 있습니다.
배포에서 막힐 수 있는 부분
초보자가 가장 많이 막히는 두 가지를 짚어 두겠습니다.
첫째, git push에서 인증 오류가 나는 경우입니다. GitHub는 2021년 8월부터 비밀번호 인증을 중단했습니다. HTTPS 방식으로 push할 때 비밀번호를 물어보면, GitHub 비밀번호가 아니라 Personal Access Token을 입력해야 합니다. GitHub 설정의 Developer settings > Personal access tokens > Tokens (classic)에서 토큰을 생성하고, repo 권한을 체크합니다. 이 토큰을 비밀번호 대신 사용하면 됩니다. SSH 방식이 더 편하다면 GitHub Docs의 "Connecting to GitHub with SSH" 문서를 참고하여 SSH 키를 등록할 수도 있습니다.
둘째, Vercel 빌드가 실패하는 경우입니다. Vercel 대시보드에서 Deployments 탭을 클릭하면 배포 이력이 나옵니다. 실패한 배포를 클릭하면 빌드 로그를 확인할 수 있습니다. 가장 흔한 원인은 import 경로의 대소문자 불일치입니다. 로컬 macOS나 Windows에서는 파일명 대소문자를 구분하지 않지만, Vercel의 빌드 환경(Linux)에서는 구분합니다. 예를 들어 파일명이 colorAnalyzer.js인데 import에서 ColorAnalyzer.js로 썼다면 로컬에서는 동작하지만 Vercel에서는 빌드 실패합니다. 빌드 로그에서 "Module not found" 메시지를 확인하고 파일명과 import 경로의 대소문자를 맞춰 주면 됩니다.
자동 배포
Vercel이 편한 건 코드를 push할 때마다 자동으로 재배포된다는 점입니다. 로컬에서 코드를 수정하고 GitHub에 push하면 Vercel이 자동으로 감지해서 빌드하고 배포합니다. 별도의 배포 명령어를 실행할 필요가 없습니다.
# 코드 수정 후 git add . git commit -m "팔레트 색상 변경" git push
push한 지 1~2분이면 변경 사항이 반영됩니다. Vercel 대시보드에서 배포 이력을 확인할 수 있고, 문제가 생기면 이전 버전으로 롤백하는 것도 가능합니다.
무료 플랜도 쓸 만합니다. 월 100GB 대역폭에 하루 100회 빌드까지 되니 개인 프로젝트에는 충분하고요. 팀 프로젝트나 트래픽이 많은 서비스라면 Pro 플랜을 고려해야 하지만, 이번 프로젝트 규모에서는 무료로 충분합니다.
시리즈를 마치며
4편에 걸쳐서 바닐라 JavaScript와 Tailwind CSS만으로 퍼스널 컬러 분석 웹사이트를 완성했습니다. 각 편에서 한 일을 돌아보겠습니다.
1편에서는 Vite + Tailwind CSS 프로젝트를 세팅하고, ES 모듈로 파일을 나누는 방법을 배웠습니다. colors.js에 rgbToHex, hexToRgb, rgbToHsl 세 함수를 만들었는데, 이 함수들이 3편과 4편에서 실제로 사용됐습니다.
2편에서는 Tailwind CSS 반응형 유틸리티로 모바일 대응 UI를 만들고, File API와 Canvas API로 이미지 업로드 기능을 구현했습니다. uploadUI.js, previewUI.js, imageLoader.js 세 모듈이 추가됐습니다.
3편에서는 MediaPipe FaceLandmarker로 얼굴의 478개 랜드마크를 추출하고, 볼과 이마에서 피부 픽셀을 가져왔습니다. async/await를 제대로 배웠고, faceDetector.js와 skinAnalyzer.js가 추가됐습니다.
4편에서는 rgbToHsl로 색 공간을 변환하고, 언더톤 판별과 4계절 분류 알고리즘을 구현했습니다. 결과 화면을 resultUI.js로 분리했고, Vercel로 배포까지 마쳤습니다.
소스 파일 11개, 모듈 간 의존 관계가 트리 구조로 짜인 프로젝트가 완성됐습니다. React 같은 프레임워크 없이도 이 정도 구조의 웹 애플리케이션을 만들 수 있다는 걸 직접 확인한 셈입니다.
시리즈 내내 지킨 원칙이 하나 있습니다. "화면은 components, 로직은 utils". 1편에서 폴더를 나눌 때 정한 이 규칙을 4편까지 그대로 유지했고, 그 덕분에 새 기능을 추가할 때마다 기존 코드를 거의 건드리지 않았습니다. 이 감각은 나중에 React나 Vue를 배울 때도 그대로 적용됩니다. 프레임워크가 바뀌어도 "관심사 분리"라는 원칙은 같으니까요.
더 만들어보고 싶다면 몇 가지 아이디어를 남겨 둡니다. 머리카락이나 눈동자 색상을 추가 입력 받아서 분류 정확도를 높일 수 있습니다. 분류 경계값(H = 25, L = 60, S = 30)을 미세 조정하거나 더 세밀한 분류 체계(12타입)를 적용할 수도 있습니다. 결과를 이미지로 저장하는 기능이나 SNS 공유 버튼을 추가해도 재미있습니다. Canvas의 toDataURL()을 사용하면 캔버스 내용을 이미지 파일로 변환할 수 있으니, 관심이 있다면 MDN 문서를 참고해 보면 됩니다.
이 시리즈의 코드가 완벽하지는 않습니다. 실제 퍼스널 컬러 분석은 훨씬 복잡하고, 조명 보정이나 화이트밸런스 처리 같은 전처리도 필요합니다. 하지만 "사진에서 얼굴을 찾고, 피부색을 추출하고, 색 공간을 변환해서 분류한다"는 전체 흐름을 처음부터 끝까지 직접 만들어 본 경험은 어떤 프레임워크를 배우든 밑바탕이 됩니다. 문제를 작은 단위로 나누고, 각 단위를 모듈로 만들고, 모듈을 조합해서 완성하는 과정. 이게 소프트웨어 개발의 기본이니까요.
참고 자료






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