✏️ 이 글은 오빠! 톤 많아? w 테오의 스프린트 14기에 이어지는 글로,
오빠 톤 많아? 퍼스널 컬러 자가진단 서비스
개발 과정을 담고 있습니다.
계속 참여 가능한 팀원들끼리 프로젝트를 이어서 진행했다.
기존 팀원 5명 중 4명과, 아이디어와 레퍼런스를 제공해주신 soojjung님을 초대해서 5명이서 진행했다.
v1.0.0
을 위한 목표 설정 🎯테오의 스프린트 회고에서 이야기가 나왔던 우리 서비스에서 개선하고 싶은 점을 모아보았다.
스프린트를 계속 진행하며 이 중 필요하고 실현 가능한 것을 골라 하나씩 구현하여 v1.0.0
배포하는 것을 목표로 세웠다.
가장 먼저 해야 할 것은 기존에 데모를 위해서 퍼스널 컬러 유형을 우선 봄, 여름, 가을, 겨울 이렇게 4가지 유형으로만 구성했었는데, 이 4타입을 12타입으로 확장하는 것이었다. 퍼스널 컬러 유형은 흔히 계절별로 각각 3타입씩―예를 들면 봄 계절에는 봄웜, 봄라이트, 봄브라이트 이렇게―총 12타입으로 구분되어 있었다.
그러기 위해서는 결과 데이터도 12타입으로 늘리고, 테스트에서 컬러를 선택하는 로직도 다시 구성해야 했다. 그리고 무엇보다도 12유형 확장과 더 정확한 결과 데이터를 위해 레퍼런스를 찾아야 했기에... 퍼스널 컬러 책도 보고 외국 사이트도 찾아보고 도메인에 대한 공부를 많이 하게 되었다. 😂
버저닝을 고민하다가 데모 버전은 프로토타입이므로 버전에 포함하지 않기로 결정해서 v1.0.0
을 새로 배포하기로 했다.
v1.0.0
배포라는 큰 목표를 달성하기 위한 작은 목표들을 매 주마다 설정하고 주 단위의 스프린트를 계속하면서 작은 목표들을 달성해나갔다.
당장 이번 주에 할 일을 정하고, 일주일 동안 작업하고, 일주일 후 미팅에서 작업 진행도를 공유하며 미완료 작업은 그 다음 주 스프린트로 넘기고 새로운 할 일을 추가해서 다음 주 스프린트를 이어가는 방식으로 약 한 달간 프로젝트를 이어서 진행했다.
타입스크립트를 강의 신청해놓고 아직 안 들어서 구글링으로 배운 나는 프로젝트에 TypeScript를 적용하는 컨벤션을 잘 몰라서 망설였는데, 팀원분들이 도와주신다고 TypeScript 적용해보자고 하셔서 TypeScript로 전환하게 됐다!
js
, jsx
파일들을 ts
, tsx
로 변환if (typeof result === 'string') {
setImage(result);
return;
}
throw Error('이미지 파일을 불러오는 데 오류가 발생했습니다.');
개발하면서 발생하는 에러는 개발자가 직접 확인할 수 있지만, 배포된 서비스를 사용자가 사용하면서 발생하는 에러는 개발자가 알 수 없다.
서비스 사용 시 발생하는 에러를 수집하기 위해 Sentry를 도입했다. 물론 개발 단계에서 발생하는 에러도 수집하고 분석할 수 있다.
실제로 개발 단계에서 내 맥북에서 결과 페이지의 공유하기 버튼을 눌렀을 때 다음과 같은 에러가 발생했다. 모바일이나 다른 팀원 기기에서는 에러 없이 잘 작동하는데 내 맥북에서만 이런 에러가 발생하는 것이었다.
Sentry를 도입한 팀원 준이 원인과 해결책을 찾아주셨다.
Sentry에서는 아래와 같이 에러가 발생한 디바이스와 브라우저 환경을 확인할 수 있다.
내가 접속한 크롬 + Mac OS 조합에서는 Web Share API를 지원하지 않는 것이 원인이었다. 이 조합에서는 API를 호출하지 않고 메시지를 띄우는 것으로 예외 처리를 했다.
import { UAParser } from 'ua-parser-js';
function isOSX() {
const parser = UAParser(navigator.userAgent);
const os = parser.os;
return os.name === 'macOS';
}
function isChrome() {
const parser = UAParser(navigator.userAgent);
const browser = parser.browser;
return browser.name === 'Chrome';
}
const handleShare = async () => {
if (isChrome() && isOSX()) {
alert(
'macOS 환경의 크롬 브라우저에서는 지원하지 않는 기능입니다.\n다른 브라우저에서 실행해 주세요. 🥰'
);
} else {
await webShare();
}
};
커스텀 에러를 설계하면 더 체계적으로 에러 핸들링을 할 수 있다.
준이 작성한 에러 핸들링 문서를 참고하여 추후 커스텀 에러를 추가해나갈 예정이다.
결론부터 말하면 법적인 문제로 현재로선 불가능하다고 판단해서 이 기능은 구현 목록에서 제외했다.
결과 공유 시 얼굴 사진이 포함되도록 해서 친구가 내 사진으로 진단해주는 기능이 있으면 좋겠다는 의견이 있었다. 이 서비스가 내 생각대로 어울리는 컬러를 선택해서 진단해보는 서비스이기 때문에 다른 사람들이 함께 선택해주면 확실히 도움이 되긴 할 것이다.
하지만 얼굴 사진을 다른 사람에게 공유하려면 사진을 서버에 저장해야 한다. 사진을 수집하기 위해서는 개인정보 수집 동의를 받아야 한다. 그러려면 동의 약관도 있어야 할 것이고... 일정 기간 뒤에는 사진을 파기해야 할 것이고... 사진이 수집된다고 하면 사용자가 본인 얼굴 사진을 올리는 것을 꺼려할 수도 있다.
코드 외적으로 해결해야 할 과정이 너무 복잡하고 현재로서는 그 정도의 노력을 투자할 정도로 필요한 기능이 아니기에 이 기능은 추가하지 않기로 결정했다.
다음은 내가 한 일 중 주요 작업들이다.
사용자가 업로드한 얼굴 사진 데이터를 recoil 전역 상태로 저장하고, 컬러 선택 페이지에서 사용하고, 결과 페이지로 이동할 때 recoil에 저장된 이미지 데이터를 초기화했다.
그런데 결과 페이지에서 뒤로가기를 하면 이미 recoil에서 초기화했기 때문에 이미지 데이터가 없어서 깨진 화면이 나오는 것이 매우 거슬렸다. 보기에도 안 좋고, 사용자가 결과를 확인했다가 다시 컬러 선택 화면으로 돌아가고 싶을 수도 있는데 이미지가 없으면 다시 테스트할 수도 없다.
// Landing/index.tsx
import { useSetRecoilState } from 'recoil';
function LandingPage() {
const setUserImg = useSetRecoilState(CropImage);
useEffect(() => {
setUserImg('');
}, [setUserImg]);
...
}
// Router.tsx
function AppRouter() {
return (
<BrowserRouter>
<Routes>
<Route path={landing} element={<LandingPage />} />
...
<Route path="/*" element={<WrongAccessPage />} />
</Routes>
</BrowserRouter>
);
}
// useRedirectNoImage.ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const useRedirectNoImage = (userImg: string) => {
const navigate = useNavigate();
useEffect(() => {
if (userImg) return;
navigate('/no-image');
}, [navigate, userImg]);
};
export default useRedirectNoImage;
이제는 결과 페이지에서 뒤로 가기해서 컬러 선택 페이지로 이동해도 이미지가 그대로 있어서 테스트를 다시 할 수도 있고, 이미지 데이터가 초기화된 후에 앞으로 가기/뒤로 가기를 통해 컬러 선택 페이지에 접근하더라도 깨진 화면을 보는 대신 404 커스텀 페이지를 통해 처음으로 되돌아갈 수 있다.
public
폴더에서 src
폴더로 이동기존에는 이미지 파일을 public 폴더에서 관리하고 있었는데, 이미지를 public 폴더보다 src 폴더에서 관리하는 것이 에러 핸들링과 최적화에 유리하다는 것을 알게 되어 리팩토링을 했다.
이미지 파일을 src/assets
폴더로 옮기고, 변수로 import해서 사용하도록 바꾸었다.
그런데 타입스크립트에서 이미지 파일을 인식하지 못해서 import 에러가 발생했다.
이미지 파일 확장자를 전역 모듈로 선언해주니 해결되었다.
// index.d.ts
declare module '*.jpg';
이때 alias path 설정하는 방법도 알게 되어서 import 상대 경로는 이후에 절대 경로로 바꾸었다.
한 단계에는 4개의 선택지가 있다. 이 선택지에는 각각 4계절을 보여준다. 각 계절마다 3가지 유형이 있으므로, 3단계를 진행하면 12타입을 모두 선택지에 보여줄 수 있다.
단계가 너무 적으면 결과의 정확도가 떨어지고, 단계가 너무 많으면 사용자가 귀찮고 피곤해진다. 팀원들과 논의한 끝에 총 9단계가 적정하다는 결론을 내렸다. 4선택지 × 9단계 = 12유형 × 3번
이므로 9단계면 한 유형당 3번씩 선택지에 노출된다.
퍼스널 컬러 선택 결과를 도출하는 기본 로직은 최빈값이다. 가장 많이 선택한 유형이 최종 결과가 된다. 그런데 9번 선택했을 때 중복 최빈값이 발생할 수 있다. 9단계가 끝난 후 봄웜 2회 선택, 여름쿨 2회 선택으로 최빈값이 2가지 이상이 될 수도 있다는 말이다. 이렇게 중복 최빈값이 발생했을 때 이 중에서 최종 결과를 가려내기 위해 보너스 단계를 추가하기로 했다.
보너스 단계는 9단계까지 진행했을 때 가장 많이 선택된 유형들로 선택지가 구성된다. 그리고 이 보너스 단계에서 선택한 유형이 최종 결과가 된다.
그런데 최악의 경우에는 사용자가 9번 모두 다른 선택을 해서 12유형 중 9유형이 최빈값이 될 수도 있다. 이 경우 어떻게 할 것인지 논의했는데 (팀원들: 이러면 테스트 다시 해야지~!) 어차피 이 경우는 이미 결과의 신뢰성을 보장할 수 없게 되었다고 판단해서, 한국인 퍼스널 컬러 유형 중 가장 흔한 타입 4가지를 추려내어 선택지를 구성하도록 했다.
또 9단계에서 이미 최빈값이 1가지 유형으로 결정된 경우에도 보너스 단계를 진행할지 논의한 결과 의미가 없다고 판단하여, 보너스 단계 없이 바로 결과 페이지로 이동하도록 했다.
그래서 이후에 배포하고 사람들이 같이 해볼 때 바로 결과 나온 사람이 다른 사람 하는 거 보면서 "뭐야 난 왜 보너스 단계 없어?!"하면서 서운해했다고... 하지만 의미 없는 보너스 단계를 추가할 이유는 되지 못한다
보너스 단계는 UI 통일성을 위해 중복 최빈값이 몇 가지든 4선택지로 고정하되(중복 최빈값이 4가지 넘는 경우는 9번 모두 다른 선택인 경우밖에 없다), 결정적인 선택이기 때문에 한 선택지에 3색을 보여주도록 했다. 색을 원그래프처럼 하나의 element에 보여주기 위해 CSS conic-gradient
속성을 이용했다.
background: conic-gradient(
${({ colors }) => colors[0]} 120deg,
${({ colors }) => colors[1]} 120deg 240deg,
${({ colors }) => colors[2]} 240deg
);
기존에는 봄 여름 가을 겨울 순서대로 컬러 선택지가 보였지만, 계절을 예상하고 컬러를 선택하는 것을 방지하기 위해 선택지 위치를 랜덤으로 섞는 기능을 추가했다.
ChatGPT가 짜준 배열 랜덤 셔플하는 함수를 컬러 선택 단계 매 렌더링마다 호출해서 선택지 배열을 섞게 했다.
function shuffle(array: ChoiceColorDataType[]) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
우리 서비스는 모바일 우선 레이아웃을 적용하고 있는데, 모바일 기기에서 주소창 등 브라우저 내 상단/하단 메뉴 때문에 높이를 100vh로 설정한 화면에서 스크롤이 생기는 현상이 발견됐다.
React 모바일 웹 앱 100vh 실제 화면 크기로 맞추기 글을 참고해서 해결했다.
// useViewportHeight.ts
import { useEffect } from 'react';
function setViewportHeight() {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
const useViewportHeight = () => {
useEffect(() => {
setViewportHeight();
}, []);
};
export default useViewportHeight;
// MobileLayout.tsx
import useViewportHeight from '@Hooks/useViewportHeight';
function MobileLayout() {
useViewportHeight();
...
}
// GlobalStyle.ts
const GlobalStyle = createGlobalStyle`
:root {
--vh: 100%;
}
...
`
height: 100vh;
를 다음 코드로 바꿨다.height: calc(var(--vh, 1vh) * 100);
/* --vh 변수로 높이를 계산하고, --vh 값이 유효하지 않을 때만 vh로 계산한다. */
v1.0.0
배포 🎉이외에도 스타일 수정, 오류 해결, 컬러 데이터 추가 등 크고 작은 부분들을 보완하여 드디어 4월 5일 v1.0.0
을 배포했다! 그리고 약 한 달간 방학😇을 가진 뒤 다음 버전을 준비할 계획이다.
열심히 만들어서 배포했으니 이제 주변에 홍보하고 피드백을 받아오는 것이 숙제다. 팀 디스코드에 피드백 채널을 개설해서 각자 받아온 피드백을 함께 수집했다.
테오도 직접 사용해본 후 피드백을 주셨다! ❤️
개발할 때 발견되지 않았던 에러도 Sentry에 착실하게 기록되고 있다. 😂
무료 계정은 30일 전 에러까지만 기록을 보여주기 때문에 기록이 사라지기 전에 캡쳐해두거나 해결을 해야 할 것 같다.
을 계획하고 있다.
to be continued ... 🫧
제목 보고 왕간다님의 영상이 생각나서 들어왔네요ㅎㅎ sentry사용에 관심이 있었는데 후기를 보니까 반갑네요!