합법적 월급 루팡을 위한 도구, Project Lupin 개발기 (feat. Next.js 16)
목차
지금 이 순간 얼마가 쌓이고 있을까
"똥 싸는데 돈이 나온다?"
직장인이라면 한 번쯤 화장실에 앉아 이런 엉뚱한 상상을 해보셨을 겁니다. '내가 지금 이 변기 위에 앉아있는 시간 동안 회사는 나에게 얼마를 지불하고 있을까?'라는 아주 원초적이고 자본주의적인 호기심 말이죠.
Project Lupin은 바로 그 호기심을 기술로 풀어낸 프로젝트입니다. 단순한 계산기를 넘어, 삭막한 회사 생활 속에서 직장인들에게 소소한 해방감과 '금융 치료'의 시각적 즐거움을 주고 싶었습니다. 또한 10년 차 개발자로서, 단순히 돌아가는 앱을 만드는 것을 넘어 Next.js 16과 React 19, Tailwind CSS 4 같은 최신 기술 스택을 실전(사이드 프로젝트)에 도입해보고, PWA를 통해 네이티브 앱처럼 몰입감 있는 사용자 경험을 웹표준으로 구현해보고자 하는 기술적 욕심도 컸습니다.
무엇을 만들었나
Project Lupin은 사용자가 '루팡 모드'에 진입하는 순간부터 경험이 시작됩니다.
- 실시간 수입 계산 (Real-time Calculator): 사용자가 연봉을 입력하고 타이머를 시작하면, 근무 시간 대비 초당 급여가 실시간으로 카운팅 됩니다. 숫자가 맹렬하게 올라가는 모습을 보며 시각적 쾌감을 느낄 수 있도록 디자인했습니다.
- 사이버펑크 테마 (Cyberpunk UI): 몰래 하는 '해킹' 같은 느낌을 주기 위해 블랙&네온 그린 컬러의 터미널/대시보드 스타일을 차용했습니다. Framer Motion을 활용해 숫자와 UI 요소들이 살아있는 듯한 생동감을 부여했습니다.
- 데이터 프라이버시 (Privacy First): 연봉이라는 민감한 정보는 서버로 전송되지 않고, 오직 사용자 기기에만 저장되도록 설계하여 심리적 거부감을 없앴습니다.
무엇으로 만들었나
이번 프로젝트는 최신 기술의 안정성을 테스트하고, 빠르고 가벼운 앱을 만드는 데 주안점을 두었습니다.
- Next.js 16 (App Router): React 19의 최신 기능들을 가장 잘 지원하는 프레임워크입니다. 특히 서버 컴포넌트와 클라이언트 컴포넌트의 경계를 명확히 하여, 초기 로딩 속도(FCP)를 극대화하고 SEO 성능을 챙기기 위해 선택했습니다.
- Tailwind CSS 4: 기존 v3 대비 설정 파일이 획기적으로 줄어들고, CSS 변수(Variables)를 활용한 동적 스타일링이 훨씬 강력해졌습니다. 사이버펑크 스타일의 복잡한 그라디언트와 애니메이션을 구현하는 데 있어 'Zero-runtime'에 가까운 가벼움을 유지할 수 있었습니다.
- Zustand: Redux나 Context API보다 보일러플레이트가 훨씬 적고 직관적입니다. 특히
persist미들웨어를 통해 로컬 스토리지(Local Storage)와의 연동을 코드 몇 줄로 끝낼 수 있어, 서버 없이 클라이언트에서만 데이터를 관리해야 하는 이 프로젝트의 요구사항에 딱 맞았습니다. - PWA (next-pwa): 이 서비스의 주 사용 공간은 '화장실'입니다. 네트워크가 불안정할 수 있고, 매번 브라우저 주소창을 치고 들어오는 번거로움을 없애야 했습니다. 홈 화면에 추가하여 네이티브 앱처럼 전체 화면으로 실행되도록 구현했습니다.
초당 얼마인지를 어떻게 정할 것인가
이 프로젝트에서 코드보다 오래 고민한 건 계산의 기준을 무엇으로 잡을 것인가였습니다. 결국 세 단계로 정리했습니다.
const annualSalaryRaw = salary * 10000; // 입력은 만원 단위
const dailySalary = annualSalaryRaw / 250; // 연간 근무일 근사치
const start = dayjs().hour(startH).minute(startM).second(0);
const end = dayjs().hour(endH).minute(endM).second(0);
const durationMs = end.diff(start, 'millisecond');
const salaryPerMs = dailySalary / durationMs; // 밀리초당 금액250이라는 임의의 숫자
연간 근무일을 250일로 박아 놓은 건 정확해서가 아니라 적당히 틀렸기 때문입니다. 365일에서 주말 104일을 빼면 261일, 공휴일과 연차를 감안하면 240일 안팎이 됩니다. 그 사이 어딘가를 고른 것뿐입니다.
정확하게 하려면 사용자에게 연차 일수와 근무 형태를 물어야 하는데, 화장실에서 30초 쓰자고 만든 계산기에 온보딩 질문을 네 개 더 넣는 건 배보다 배꼽이 큽니다. 숫자가 5% 틀려도 "지금 이 시간이 돈이다"라는 체감에는 아무 지장이 없다고 판단했습니다. 대신 이 근사치를 쓴다는 사실은 소개 페이지에 적어 뒀습니다. 틀린 걸 숨기는 것과 밝히는 것은 다르니까요.
세금은 일부러 계산하지 않았다
세전/세후를 고르게만 하고 자동 계산은 넣지 않았습니다. 4대 보험과 소득세를 제대로 계산하려면 부양가족 수, 비과세 항목, 연말정산 요소까지 받아야 하는데, 그 순간 이 앱은 급여 계산기가 아니라 세무 계산기가 됩니다. 그리고 어설프게 계산해서 보여주면 사용자는 그 숫자를 믿습니다. 정확할 수 없다면 아예 안 하는 편이 낫다고 봤습니다.
setInterval을 쓰지 않은 이유
숫자를 올리는 방법은 두 가지였습니다. setInterval로 일정 주기마다 갱신하거나, requestAnimationFrame으로 매 프레임 갱신하거나.
setInterval(fn, 1000)을 쓰면 숫자가 1초에 한 번 뚝뚝 끊겨 올라갑니다. 이 앱의 재미는 숫자가 연속적으로 흐르는 데 있어서 그걸로는 부족했습니다. 주기를 10ms로 줄이면 부드러워지지만 브라우저가 그 주기를 보장하지 않고 배터리만 먹습니다.
그래서 rAF 루프 안에서 매 프레임 현재 시각을 다시 읽어 처음부터 다시 계산하는 방식을 택했습니다.
const loop = () => {
const now = dayjs();
if (now.isBefore(start)) setCurrentAmount(0);
else if (now.isAfter(end)) setCurrentAmount(dailySalary);
else {
const elapsed = now.diff(start, 'millisecond');
setCurrentAmount(elapsed * salaryPerMs); // 누적이 아니라 매번 재계산
}
rafRef.current = requestAnimationFrame(loop);
};핵심은 누적하지 않는다는 점입니다. 이전 값에 조금씩 더하는 방식이면 프레임이 밀릴 때마다 오차가 쌓이는데, 매번 now - start로 다시 구하면 그런 일이 없습니다.
덕분에 브라우저가 배경 탭의 rAF를 멈춰도 문제가 없습니다. 다른 탭에 다녀오면 숫자가 멈춰 있다가, 돌아오는 순간 그동안 흐른 시간이 반영된 값으로 한 번에 점프합니다. 처음엔 이게 버그처럼 보여서 고치려 했는데, 생각해 보니 정확한 동작이었습니다. 화면이 멈춘 동안에도 월급은 계속 나오고 있었으니까요.
화면 높이가 계속 바뀌었다
모바일 브라우저(특히 Safari/Chrome iOS)에서 주소창이나 하단 툴바 때문에 100vh가 의도한 대로 동작하지 않는 고질적인 문제가 있었습니다. 스크롤이 생기거나 중요한 하단 버튼이 가려지는 현상이 발생했죠.
초기에는 JavaScript로 window.innerHeight를 계산해 CSS 변수로 주입하는 방식을 고려했으나, Tailwind CSS 4와 최신 CSS 스펙인 dvh (Dynamic Viewport Height) 단위를 적극 활용하는 방향으로 리팩토링했습니다.
/* Before: 스크롤 발생 가능성 있음 */
.container { min-height: 100vh; }
/* After: 모바일 UI에 완벽 대응 */
.container { min-height: 100dvh; }이를 통해 브라우저 UI가 동적으로 변하더라도 항상 꽉 찬 화면을 유지하여, 앱 같은 몰입감을 줄 수 있었습니다.
서버가 모르는 값을 화면에 그리려 했다
Zustand의 persist 기능을 사용하여 사용자의 연봉 정보를 로컬 스토리지에 저장했는데, Next.js의 SSR(Server Side Rendering) 과정에서 문제가 발생했습니다.
서버에서 렌더링 된 초기 HTML(데이터 없음)과, 클라이언트에서 로컬 스토리지 값을 불러온 후의 HTML(데이터 있음)이 달라 'Hydration Mismatch' 에러가 발생한 것입니다.
이를 해결하기 위해 커스텀 훅 등의 복잡한 방법 대신, useState와 useEffect를 사용해 '마운트 여부'를 체크하는 직관적인 방법을 택했습니다.
// src/store/useUserStore.ts (예시)
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true);
}, []);
if (!isHydrated) {
// 로딩 상태 혹은 빈 화면을 렌더링하여 매칭 오류 방지
return <LoadingSkeleton />;
}이 방식은 깜빡임(Flash of Unstyled Content)을 최소화하면서도, 데이터 무결성을 보장하고 에러 콘솔을 깨끗하게 유지해주었습니다.
연봉을 만원 단위로 받은 이유
사소해 보이지만 오래 고민한 게 입력 단위입니다. 연봉을 원 단위로 받을지, 만원 단위로 받을지요.
결국 만원 단위로 정했습니다. 사용자가 5000을 입력하면 코드가 만 배를 곱합니다.
const annualSalaryRaw = salary * 10000;이유는 단순합니다. 한국에서 연봉은 "오천"이나 "육천오백"으로 말하지 "오천만 원"이라고 또박또박 말하지 않습니다. 원 단위로 받으면 50000000처럼 0을 여덟 개 세면서 입력해야 하고, 하나 더 치거나 덜 치면 금액이 열 배 틀립니다. 화장실에서 30초 쓰자고 만든 앱에서 그런 실수가 나오면 그냥 나가버립니다.
대신 화면에서 단위를 명확히 보여줘야 합니다. 입력 필드 옆에 "만원"이 붙어 있지 않으면 사용자는 원 단위로 칠 수도 있고, 그러면 초당 금액이 만 배로 뛰면서 앱이 고장난 것처럼 보입니다. 입력 단위를 줄이는 대신 그 단위를 화면에 못 박아 두는 것이 한 세트입니다.
PWA로 만든 것의 실익
홈 화면에 추가하면 앱처럼 전체 화면으로 뜨도록 PWA 설정을 넣었습니다. 서비스 워커까지 등록해서 오프라인에서도 열립니다.
솔직히 오프라인 지원은 과했습니다. 이 앱은 어차피 네트워크를 안 쓰기 때문에, 정적 파일만 캐시되면 그만이었습니다. 서비스 워커의 진짜 값어치는 다른 데 있었습니다. 주소창이 사라진다는 것입니다.
브라우저 주소창이 있으면 이 앱은 아무리 잘 만들어도 "웹페이지"입니다. 그런데 홈 화면 아이콘을 눌러 전체 화면으로 뜨는 순간 앱이 됩니다. 화면 높이 계산이 안정되는 건 덤입니다. 앞서 100dvh로 해결한 주소창 문제 자체가 PWA로 실행하면 애초에 생기지 않습니다.
정리하면, PWA를 넣은 실익은 오프라인이 아니라 실행 맥락이었습니다. 같은 코드인데 어떻게 실행되느냐에 따라 사용자가 받아들이는 무게가 달라집니다.
광고를 금액에 묶었던 것에 대해
수익화를 붙이면서 한 가지 선택을 했는데, 지금은 그게 옳았는지 확신이 없습니다.
광고 노출 시점을 누적 금액 2만원마다로 잡았습니다. 시간이 아니라 금액 기준입니다.
const currentMilestone = Math.floor(currentAmount / 20000);
if (currentMilestone > lastAdMilestoneRef.current) {
lastAdMilestoneRef.current = currentMilestone;
openAdModal();
}여기에 더해, 이미 2만원을 넘긴 상태로 페이지에 들어온 사용자에게는 30초 뒤에 한 번 띄우도록 했습니다. 오후에 접속한 사람은 첫 마일스톤을 이미 지나 있어서 광고를 한 번도 못 보고 나가기 때문입니다.
당시엔 영리하다고 생각했습니다. 사용자가 가장 기분 좋은 순간, 그러니까 숫자가 의미 있는 단위를 넘긴 직후에 광고를 붙이면 거부감이 덜할 거라고 봤습니다.
지금 다시 보면 그건 거꾸로 읽어야 할 문장입니다. 기분 좋은 순간을 광고 노출에 쓰겠다는 뜻이니까요. 시간 기준이면 "5분마다 광고가 나오는구나"로 예측이 되는데, 금액 기준이면 사용자는 왜 지금 광고가 뜨는지 알 수 없습니다. 설계 의도를 사용자가 알 수 없게 만드는 건 대체로 좋은 신호가 아닙니다.
아직 안 고쳤습니다. 이 글을 쓰면서 정리해 보니 바꾸는 게 맞겠다는 생각이 들어서, 다음에 손댈 때 시간 기준으로 옮기려 합니다. 알면서 넘어간 것을 적어 두면 나중에 스스로에게 변명하기 어려워진다는 게, 이런 글을 쓰는 이유 중 하나입니다.
이 결정을 하고 나서 온보딩 화면의 문구도 바꿨습니다. 처음엔 "연봉을 입력하세요"였는데, 지금은 단위를 앞에 세워 "연봉 (만원)"으로 두고 예시 숫자를 회색으로 미리 깔아 뒀습니다. 입력값의 자릿수를 사용자가 헷갈리지 않게 만드는 데는 안내 문구보다 예시 하나가 훨씬 강력하더군요.
남은 것
농담으로 시작한 프로젝트인데 정작 오래 붙잡은 건 농담이 아닌 부분이었습니다. 근무일을 며칠로 잡을지, 세금을 계산할지 말지, 숫자를 어떤 방식으로 흐르게 할지 같은 것들입니다.
그 과정에서 반복해서 마주친 질문이 하나 있습니다. 정확할 수 없을 때 어떻게 할 것인가. 세금은 정확히 계산할 수 없어서 아예 빼기로 했고, 근무일은 정확할 수 없지만 없으면 계산 자체가 안 되니 근사치를 쓰되 그 사실을 밝히기로 했습니다. 둘 다 "대충 해놓고 정확한 척하지 않는다"는 같은 원칙에서 나온 결정입니다.
입력한 연봉이 서버로 가지 않는 것도 같은 맥락입니다. 통계를 내려면 모아야 하는데, 연봉은 그럴 만한 가치가 있는 데이터가 아니라고 판단했습니다. 나중에 그 데이터를 지키는 것도 혼자 해야 하는 일이니까요.
만든 것은 여기에 있습니다.