코드는 GitHub에, 앱은 gongnog.up.railway.app에서 볼 수 있다.
공노기를 만들기 전에 설계서를 먼저 썼다.
CLAUDE.md라는 파일이다. DB 스키마, API 명세, 컴포넌트 구조, CSS 규칙, 계산 공식, 배포 설정까지 전부 들어 있다. 게임 개발할 때 기획서를 먼저 쓰고 구현에 들어가는 것과 같은 방식이다. 다만 이 기획서는 Claude Code가 읽는 기획서라서 애매한 표현 없이 구체적으로 써야 한다.
이 설계서를 기반으로 Claude Code와 함께 앱을 만들었다. 초기 버전은 v1.0이었고, 지금은 v4.0이다.
v1.0에서 v4.0 사이에 뭐가 바뀌었느냐. 거의 다 바뀌었다.
설계서에 적은 것 vs 실제로 만든 것
설계서에는 컴포넌트 7개, store 4개, 테이블 4개를 정의했다. 실제로는 컴포넌트 9개, store 9개 + 헬퍼 함수 4개, 테이블 4개 + 컬럼 추가가 됐다. 설계에 없던 기능이 10개 넘게 추가됐고, 설계에 있던 것 중 그대로 살아남은 것보다 바뀐 게 더 많다.
큰 것만 추려도 이렇다.
캘린더 요일 순서가 월요일 시작에서 일요일 시작으로 바뀌었다. DaySheet가 인라인에서 오버레이 바텀시트로 갈아엎어졌다. 퀵 액션 버튼, 스플래시 화면, 튜토리얼 시스템, 커스텀 다이얼로그, 토스트 알림, 뒤로가기 지원, 미래 날짜 차단, 테마 실시간 미리보기 — 전부 설계서에 없던 것들이다.
fmtMin의 포맷이 "1h 30m"에서 "1시간 30분"으로 바뀌었다. 식비 입력이 조식/중식/석식/간식 4개 필드에서 단일 필드로 통합됐다. window.alert/confirm이 커스텀 다이얼로그로 대체됐다. 로그인 화면의 "근무/기록" 텍스트 타이포가 로고 이미지로 바뀌었다.
이 글은 그 바뀐 것들 이야기다. 왜 바뀌었고, 바꾸면서 뭘 배웠는지.
캘린더 요일: 월~일 → 일~토
설계서에는 이렇게 적혀 있었다.
캘린더 그리드 — 월요일 시작 (월~일)
실제로 만들고 폰에서 열어봤더니 이상했다. 한국에서 쓰는 달력 대부분이 일요일 시작이다. 월요일 시작에서는 토요일과 일요일이 오른쪽 끝에 몰리는데, 공무원 입장에서 주말 출근 기록이 중요한 만큼 이게 한눈에 안 들어온다.
바꿨다. 일 월 화 수 목 금 토.
코드 쪽에서도 깔끔해졌다. WKKO = ["일","월","화","수","목","금","토"] 배열의 인덱스가 JavaScript getDay() 반환값(0=일, 6=토)과 그대로 일치한다. 월요일 시작일 때는 인덱스를 시프트해야 했는데, 일요일 시작이면 변환 없이 그대로 쓸 수 있다.
설계 단계에서 "월요일 시작이 표준적"이라고 생각했는데, 실제 사용자가 보는 달력의 표준은 다른 거였다.
DaySheet: 인라인에서 오버레이로 갈아엎다
설계서에는 이렇게 적혀 있었다.
날짜 클릭 → Day Sheet (DaySheet.svelte) — Bottom Sheet 형태
"Bottom Sheet 형태"라고만 썼다. 인라인인지 오버레이인지 명시하지 않았다. 이 애매함이 나중에 문제가 됐다.
첫 구현은 인라인이었다. 캘린더 그리드 아래에 flex: 1로 남은 공간을 채우는 영역을 만들고, 날짜를 탭하면 거기에 상세 정보가 펼쳐지는 구조.
PC에서는 잘 보인다. 390px 폰에서는 안 보인다.
계산해보면 답이 나온다. 헤더가 약 80px, 요약 스트립이 약 40px, 캘린더 그리드가 6주 기준으로 약 370px, 하단 바가 50px. 합산하면 540px을 이미 넘는다. 날짜 상세를 넣을 공간이 0에 가깝다.
세 가지 방안을 놓고 비교했다.
B안 — 주간 접기. 날짜를 선택하면 해당 주(1행)만 남기고 나머지 주를 접는 방식. 공간은 확보되지만 캘린더 접기/펼치기 애니메이션, 접힌 상태에서 다른 주의 날짜 탐색, 월 넘김 처리 등 구현 복잡도가 급격히 올라간다.
C안 — 셀 높이 축소. min-height를 62px에서 48px으로 줄이는 방식. 근본 해결이 안 된다. 14px 줄여봤자 6주면 84px인데, DaySheet가 제대로 보이려면 최소 200px은 필요하다.
A안 — 오버레이 바텀시트. 캘린더 구조를 건드리지 않고, DaySheet를 position: fixed 오버레이로 캘린더 위에 띄우는 방식.
A안을 골랐다.
.ds-overlay {
position: fixed;
inset: 0;
backdrop-filter: blur(2px);
z-index: 150;
}
.ds-sheet {
position: absolute;
bottom: 0;
width: 100%;
max-height: 70vh;
border-radius: 22px 22px 0 0;
background: var(--bg);
}
캘린더 레이아웃은 한 줄도 안 건드렸다. 기존 인라인 방식의 CSS 클래스(detail-wrap, detail-hdr, detail-list 등)를 전부 걷어내고 ds-overlay, ds-sheet, ds-handle 같은 새 클래스로 교체했다.
게임 UI를 만들 때도 같은 패턴이 있다. 화면에 공간이 부족하면 정보를 우겨넣는 대신 레이어를 하나 올리는 게 거의 항상 정답이다. HUD 위에 인벤토리를 오버레이로 띄우는 것과 같은 구조다.
설계서에 "max-height: 70vh, position: fixed, z-index: 150"까지 적었으면 이 삽질은 없었을 거다. "Bottom Sheet 형태"라는 한 줄이 결국 DaySheet를 두 번 만들게 했다.
퀵 액션: 설계에 없던 핵심 UX
설계서에 DaySheet의 역할은 이렇게 적혀 있었다.
기록 있으면: 근무 카드 + 급량비 수지 카드 + ✏️ 편집
기록 없으면: + 기록 입력 버튼
편집 버튼 하나. 그게 전부였다.
실제로 써보니까 이게 부족했다. "지금 출근" 하려면 DaySheet에서 편집을 누르고, RecordModal이 열리고, 출근 시간을 입력하고, 저장을 눌러야 한다. 4단계. 매일 아침 출근하면서 4단계를 거치게 할 수는 없다.
그래서 DaySheet에 퀵 액션 버튼 3개를 추가했다. 출근, 퇴근, 식사. 하단 바에도 같은 기능의 "지금 출근/지금 퇴근" 스마트 버튼을 넣었다. 버튼을 누르면 현재 시간이 채워진 확인 팝업이 뜨고, 시간을 확인하거나 조절한 뒤 바로 저장된다.
여기서도 설계와 현실 사이에 작은 격차가 있었다. 처음에는 출근 버튼을 누르면 즉시 저장되게 만들었다. 빠르니까 좋다고 생각했다. 근데 실제로 써보면 출근 버튼을 누르는 시점이 정확히 출근한 시점이 아니다. 짐 풀고, 자리 잡고, 커피 타고 나서 누른다. 몇 분 차이가 난다.
그래서 시간 확인 팝업을 추가했다. 현재 시간이 자동으로 채워져 있고, -1분, -5분, -10분, -60분 조절 버튼이 붙어 있다. "08:03에 눌렀는데 실제 출근은 07:58이었으면" -5분 한 번이면 된다.
function adjustTime(min) {
const [h, m] = clockPopupTime.split(':').map(Number);
let total = h * 60 + m + min;
if (total < 0) total = 0;
if (total > 1439) total = 1439;
const nh = Math.floor(total / 60);
const nm = total % 60;
clockPopupTime = `${String(nh).padStart(2,'0')}:${String(nm).padStart(2,'0')}`;
}
0분 미만이나 23:59(1439분) 초과는 클램프한다. 자정 이후 퇴근은 현재 지원하지 않는다 — 설계서에도 "outM <= inM이면 workMin=0"으로 명시돼 있다.
식사 버튼도 마찬가지로 즉시 저장에서 팝업 확인으로 바뀌었다. 기본 급량비 단가가 채워진 팝업이 뜨고, 금액을 수정할 수 있다. 3회차 이상 식사를 기록하면 "오늘 식사를 N회 하셨습니다. 더 추가하시는 게 맞습니까?" 확인 팝업이 뜬다. 실수로 여러 번 누르는 걸 방지한다.
Svelte Store: 4개에서 9개 + 헬퍼 함수 4개
설계서의 store는 4개였다.
export const settings = writable({ ... });
export const records = writable({});
export const currentView = writable('cal');
export const selectedDate = writable(null);
실제로는 여기에 5개 store와 헬퍼 함수들이 추가됐다.
themePreview는 설정 탭에서 테마를 바꿀 때 저장 전에도 미리 보이게 하기 위한 store다. +layout.svelte에서 themePreview가 있으면 settings보다 우선 적용한다. 설정 탭을 벗어나면(onDestroy) 프리뷰가 초기화되면서 원래 테마로 돌아간다.
settingsDirty는 설정을 바꿨는데 저장을 안 한 상태를 추적한다. 이 상태에서 탭을 옮기거나 뒤로가기를 누르면 "저장되지 않은 변경사항이 있습니다" 팝업이 뜬다. JSON.stringify로 원본 설정과 현재 설정을 비교해서 dirty 여부를 판단한다.
dialogState와 customAlert/customConfirm은 브라우저 기본 window.alert/confirm을 대체한다. 브라우저 alert은 테마가 적용되지 않고, iOS Safari에서는 특히 못생겼다. Promise 기반으로 만들었다.
export function customConfirm(message) {
return new Promise(resolve => {
dialogState.set({ type: 'confirm', message, resolve });
});
}
dialogState에 메시지와 resolve 함수를 담고, CustomDialog.svelte가 이걸 구독해서 팝업을 띄운다. 확인을 누르면 resolve(true), 취소를 누르면 resolve(false). 기존 코드에서 if (!await customConfirm('정말로 로그아웃?')) return; 이런 식으로 쓸 수 있다. window.confirm이랑 사용법이 똑같다.
CustomDialog.svelte는 +layout.svelte에 전역으로 마운트돼 있어서 어떤 컴포넌트에서든 customConfirm()을 호출할 수 있다. 설계서에는 이 컴포넌트가 없었다. "당연히 있어야 하는 건데 설계서에 안 적은" 전형적인 케이스.
튜토리얼 관련 store도 추가됐다. tutorialFlags는 비트 플래그 정수로, 어떤 튜토리얼을 봤는지를 추적한다. createHasSeenStore는 derived store로 반응형 완료 여부를 제공한다. 이걸로 ? 버튼의 pulse 애니메이션을 제어한다.
튜토리얼: 설계에 아예 없던 시스템
초기 설계서에 튜토리얼이라는 단어는 한 번도 등장하지 않는다.
앱을 어느 정도 만들고 나서야 "처음 들어온 사람이 뭘 해야 하는지 모르겠다"는 생각이 들었다. 그래서 별도로 설계 문서를 2개 더 만들었다(gongnog-tutorial-spec.md, gongnog-tutorial-impl.md). 이건 CLAUDE.md와 별개의 설계 → 구현 사이클이 다시 돌아간 거다.
Tutorial.svelte는 스포트라이트 오버레이 방식이다. DOM 3개만 쓴다.
click-blocker (z-index: 1009) — 배경 클릭 차단
highlight (z-index: 1010) — box-shadow로 딤드 + 하이라이트 겸용
tooltip (z-index: 1012) — 설명 박스
핵심은 highlight의 box-shadow: 0 0 0 9999px rgba(0,0,0,0.65) 한 줄이다. 이걸로 별도 dim 오버레이 없이 주변을 어둡게 만든다. 별도 dim 레이어를 만들면 z-index 관리가 복잡해지고, 하이라이트 대상이 바뀔 때마다 dim에 구멍을 뚫어야 하는데, box-shadow 방식은 highlight 요소 위치만 옮기면 된다.
가짜 데이터 주입도 재미있는 부분이다. 튜토리얼 3단계(캘린더 설명)에서 beforeShow 콜백이 Svelte records store에 가짜 기록 6개를 넣는다. 캘린더에 색깔과 숫자가 채워지니까 신규 사용자도 "아, 이렇게 보이는구나"를 바로 이해한다. cleanup 콜백이 튜토리얼 종료 시 가짜 데이터를 지운다. DB에는 저장되지 않고 store에만 잠깐 있다가 사라진다.
튜토리얼 완료 여부는 localStorage + DB 이중 저장이다. 비로그인이면 localStorage만, 로그인이면 users.tutorial_flags 비트 플래그에도 저장한다. 크로스 디바이스에서 "이미 봤음" 상태가 유지된다.
export async function setSeen(tutorialKey) {
if (browser) localStorage.setItem(`tutorialSeen_${tutorialKey}`, 'v1');
const bit = FLAG_BITS[tutorialKey];
if (bit) {
tutorialFlags.update(f => f | bit);
try {
await fetch('/api/tutorial-flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ flag: bit }),
});
} catch (e) { /* 비로그인이면 실패해도 무시 */ }
}
}
DB에 tutorial_flags 컬럼을 추가할 때도 삽질이 있었다. 처음에 init() 함수의 멀티스테이트먼트 쿼리 안에 ALTER TABLE을 넣었더니 PostgreSQL에서 에러가 났다. pg 라이브러리가 멀티스테이트먼트를 하나의 트랜잭션으로 묶는데, DDL 변경과 CREATE TABLE을 같은 블록에 섞으면 충돌한다. 결국 별도 pool.query() 호출로 분리했다.
모바일 확대 방지: 3커밋 4분의 삽질
설계서에는 viewport 메타태그 한 줄만 있었다.
<meta name="viewport" content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, user-scalable=no" />
iOS Safari에서 이걸 무시한다. iOS 10부터 접근성 이유로 user-scalable=no를 존중하지 않는다. 결과: 버튼을 빠르게 두 번 누르면 화면이 확대된다.
첫 번째 시도는 CSS touch-action: manipulation이었다. 더블탭 줌을 비활성화하면서 스크롤은 유지하는 속성이다. 효과가 있었다. 근데 빠르게 버튼을 누르면 두 번째 탭이 씹히는 느낌이 남았다.
두 번째 시도. JS로 더블탭을 직접 차단했다.
var lastTap = 0;
document.addEventListener('touchend', function(e) {
var now = Date.now();
if (now - lastTap < 300) e.preventDefault();
lastTap = now;
}, { passive: false });
300ms 안에 연속 탭이 오면 막는 코드. 이게 버튼 입력을 씹어먹었다. 출퇴근 시간 확인 팝업에서 -1분 버튼을 빠르게 연타하면, 두 번째 탭이 더블탭으로 잡혀서 무시된다. 확대를 막으려고 넣은 코드가 핵심 기능을 망가뜨렸다.
4분 뒤에 JS 더블탭 핸들러를 제거했다.
// 핀치 줌만 차단 (더블탭은 CSS touch-action으로 처리)
document.addEventListener('touchmove', function(e) {
if (e.touches.length > 1) e.preventDefault();
}, { passive: false });
최종 구조: CSS touch-action: pan-x pan-y로 더블탭 줌을 막고, JS에서는 핀치 줌(두 손가락)만 차단한다. 커밋 3개가 4분 간격으로 찍혀 있다. touch-action: manipulation → 완전 차단 시도 → JS 핸들러 제거. 전형적인 "고치려다 더 망가뜨리고 되돌린" 패턴.
교훈: 터치 이벤트는 최소한만 건드린다.
iOS 100vh: 100vh가 100vh가 아닌 세계
앱 레이아웃을 height: 100vh로 잡았다. 화면 전체를 채우고 스크롤은 안 되게.
iOS Safari에서 열면 아래가 잘린다. 도움말 ? 버튼이 화면 밖으로 나가서 안 보였다.
iOS Safari는 주소창이 있을 때와 없을 때 뷰포트 높이가 다른데, 100vh는 주소창이 없을 때의 높이를 기준으로 잡는다. 주소창이 보이는 상태에서는 100vh가 실제 화면보다 크다.
100dvh(Dynamic Viewport Height)를 써봤다. 주소창 상태에 따라 높이가 바뀌는 단위다. 근데 주소창이 나타나고 사라질 때마다 레이아웃이 흔들렸다.
결국 100vh를 버리고 position: fixed로 갈아엎었다.
/* Before */
#app {
height: 100vh;
height: 100dvh;
overflow: hidden;
}
/* After */
#app {
position: fixed;
top: 0;
bottom: 0;
left: 50%;
transform: translateX(-50%);
overflow: hidden;
}
position: fixed + top: 0; bottom: 0은 실제 보이는 화면에 정확히 붙는다. 주소창이 있든 없든 상관없다.
게임에서도 비슷한 상황이 있다. 해상도 대응에서 "논리적 화면 크기"와 "실제 렌더링 영역"이 다를 때, 논리적 크기를 믿지 않고 실제 영역에 맞추는 게 정답이다. 100vh라는 논리적 높이를 믿지 말고, fixed로 실제 뷰포트에 붙이는 게 낫다.
키보드가 입력창을 삼킨다
식비나 메모를 입력하려고 텍스트 필드를 탭하면 키보드가 올라온다. 문제는 키보드가 화면 아래 절반을 차지하면서 입력 중인 필드가 키보드 뒤로 들어가는 것.
RecordModal이 position: fixed 바텀시트로 돼 있다. iOS에서 fixed 요소는 키보드가 올라와도 위치가 안 바뀐다. 모달 아래쪽이 그대로 가려진다.
첫 번째 시도: scrollIntoView.
function scrollIntoView(e) {
setTimeout(() => {
e.target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 300);
}
PC에서는 된다. iOS에서는 안 된다. fixed 요소 안에서 scrollIntoView가 모달 내부가 아니라 body 쪽으로 스크롤을 건다.
두 번째 시도: visualViewport API.
function onInputFocus(e) {
focusedInput = e.target;
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', onViewportResize);
}
}
function onViewportResize() {
const vv = window.visualViewport;
if (!vv || !sheetEl) return;
sheetEl.style.maxHeight = `${vv.height - 10}px`;
if (focusedInput) {
focusedInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
visualViewport는 키보드가 올라오면 resize 이벤트를 발생시킨다. vv.height가 키보드를 뺀 실제 가시 영역 높이다. 모달의 maxHeight를 이 높이에 맞추면 모달이 키보드 위에 딱 들어간다. 그 상태에서 scrollIntoView를 호출하면 모달 내부에서 스크롤이 일어난다.
키보드가 닫히면(blur) maxHeight를 원래대로 되돌린다.
이 버그의 짜증나는 점은 개발 도구에서 재현이 안 된다는 거다. 크롬의 모바일 시뮬레이션에서는 키보드가 올라오지 않는다. 실제 아이폰에서 실제 키보드로 테스트해야만 잡힌다.
다크 모드: CSS 변수의 함정 3가지
테마 시스템을 CSS 변수로 만들었다. --t1이 기본 텍스트 색상, 라이트에서는 검정, 다크에서는 흰색. 설계서에 테마 변수 체계가 잘 정의돼 있어서 이 부분은 순탄할 줄 알았다.
함정 1: #app에 color가 없다
다크 모드를 켜면 텍스트가 검정 그대로다. 원인은 단순했다. #app에 color: var(--t1)을 안 걸었다. CSS 변수를 정의해놓고 정작 최상위 요소에서 사용하지 않으면, 자식 요소들이 브라우저 기본값(검정)을 상속받는다.
#app { color: var(--t1); }
이 한 줄이 없어서 다크 모드 전체가 깨졌다.
함정 2: Chart.js는 CSS 변수를 모른다
통계 탭의 Chart.js 그래프가 다크 모드에서 안 보였다. Chart.js는 Canvas API 위에서 돌아간다. Canvas는 CSS를 모른다. var(--acc)라는 문자열을 색상으로 넘기면 무시한다.
// 안 된다 — Canvas는 CSS 변수를 해석 못한다
backgroundColor: 'var(--acc)'
// getComputedStyle로 실제 값을 꺼내야 한다
function getThemeColor(varName) {
const el = document.getElementById('app');
return getComputedStyle(el).getPropertyValue(varName).trim();
}
const accColor = getThemeColor('--acc'); // '#1a56db'
테마가 바뀔 때마다 CSS 변수의 실제 값을 읽어서 Chart.js에 넘기고 차트를 재빌드한다.
여기서 한 가지 더 함정이 있다. 테마 클래스를 바꾼 직후에 getComputedStyle을 호출하면 이전 값이 읽힌다. CSS 변수가 적용되려면 브라우저 렌더링 사이클이 한 번 돌아야 한다.
$: if (ChartLib && canvas && $settings.accTheme && $settings.bgTheme) {
setTimeout(() => buildChart(ChartLib), 50);
}
setTimeout 50ms로 한 틱 지연시켜서 해결했다.
함정 3: body 배경이 테마를 안 따라간다
다크 모드를 켜면 앱 영역은 어두워지는데, 앱 바깥 영역(body 배경)은 밝은 그대로다. 390px 앱 옆에 흰색 여백이 남는다.
CSS 변수가 #app에 선언돼 있으니까 body에서는 var(--surface)를 쓸 수 없다. CSS 변수는 선언된 요소와 자식에게만 유효한데, body는 #app의 부모다.
해결: document.documentElement(html 태그)에 테마 클래스를 추가한다.
$: if (browser) document.documentElement.className = `acc-${accTheme} bg-${bgTheme}`;
html 태그에 클래스가 붙으면 CSS 변수가 html → body → #app → 모든 자식으로 cascade된다.
식비 입력: 4개 필드에서 1개로
목업에는 식비 필드가 4개였다. expB(조식), expL(중식), expD(석식), expE(간식). 끼니별로 나눠서 입력하는 구조.
설계서(CLAUDE.md)에서 이걸 meal_expense 단일 필드로 확정했다. 목업과 설계서가 다른 몇 안 되는 지점 중 하나다.
이유는 간단하다. 끼니별로 입력하는 건 너무 귀찮다. 공무원이 급량비를 관리하는 실제 목적은 "수령액 - 총 식비 = 수지"를 파악하는 것이지, 점심에 얼마 저녁에 얼마를 분석하는 게 아니다.
다이어리에서 앱으로 바꾸는 이유가 편해지려고인데, 입력 필드가 4개면 다이어리보다 더 귀찮을 수 있다. 단일 필드로 만들고, 식사 버튼을 누를 때마다 금액이 누적되는 방식이 훨씬 자연스럽다.
fmtMin: "1h 30m" → "1시간 30분"
설계서의 fmtMin 함수:
// 설계서 버전
return h > 0 ? `${h}h${n > 0 ? ' ' + n + 'm' : ''}` : n + '분';
앱 전체가 한국어인데 시간 표시만 영문이다. "3h 30m"이라고 뜨면 뭔가 어색하다.
// 실제 구현
return h > 0 ? `${h}시간${n > 0 ? ' ' + n + '분' : ''}` : n + '분';
전면 한글화했다. 추가로 원래 코드에는 m <= 0 가드가 없어서 0을 넣으면 "NaN분"이 출력되는 버그도 수정했다.
fmtTime이라는 함수도 새로 만들었다. "08:00" → "8시 00분" 변환용. 설계서에 없던 함수인데, 퀵 클록 토스트 메시지("8시 30분 출근")랑 DaySheet 기록 표시에서 반복적으로 필요해서 constants.js에 추가됐다.
공휴일: 당일만 → 연휴 포함
목업에는 공휴일이 11개였다. 설날은 1월 29일만, 추석은 9월 25일만. 당일만 있고 연휴가 빠져 있다.
실제 구현에서는 15개로 늘렸다. 설날 연휴(1/28, 1/29, 1/30), 추석 연휴(9/24, 9/25, 9/26)를 전부 포함한다. 공무원이 연휴에 출근하면 주말과 동일하게 초과근무 계산이 적용되니까, 연휴 날짜가 빠져 있으면 계산이 틀린다.
로그인 화면: 텍스트에서 로고로
설계서:
큰 볼드 "근무" + 라이트 "기록" 두 줄
텍스트 타이포그래피로 로그인 화면을 구성하는 방식이었다. 앱 이름도 "근무기록"이었다.
실제로는 앱 이름이 "공노기"로 바뀌었고, 로고 이미지(logo.png)를 만들면서 로그인 화면 히어로도 텍스트에서 이미지로 교체됐다.
<!-- Before -->
<div class="login-logo">근무</div>
<div class="login-logo-sub">기록</div>
<!-- After -->
<img src="/logo.png" alt="공노기" class="login-logo-img" />
<div class="login-desc">공무원 출퇴근 · 초과근무 · 급량비 기록</div>
비밀번호 확인 필드도 추가됐다. 설계서에는 회원가입에 대한 디테일이 부족했다. "로그인/회원가입 토글 (같은 화면)"이라고만 적혀 있었고, 비밀번호 확인이라든가 회원가입 모드에서 헤더가 달라진다든가 하는 건 없었다.
뒤로가기: 설계에 한 줄도 없던 것
모바일 웹앱에서 뒤로가기를 누르면 브라우저가 이전 페이지로 이동한다. 앱 안에서 통계 탭을 보고 있다가 뒤로가기를 누르면, 캘린더로 돌아가는 게 아니라 사이트를 나간다.
SPA라서 페이지가 하나밖에 없기 때문이다. 탭 전환은 URL 변경 없이 Svelte store만 바꾸는 거라서 브라우저 히스토리에 흔적이 없다.
currentView.subscribe(v => {
if (v !== 'cal') history.pushState({ view: v }, '');
});
window.addEventListener('popstate', handlePopState);
async function handlePopState() {
if ($currentView === 'settings' && $settingsDirty) {
const save = await customConfirm('저장되지 않은 변경사항이 있습니다.\n저장하시겠습니까?');
if (save) settingsRef?.saveSettings();
else settingsRef?.resetSettings();
}
if ($currentView !== 'cal') currentView.set('cal');
}
탭이 바뀔 때 history.pushState로 히스토리를 쌓고, popstate 이벤트에서 캘린더로 돌아간다. 설정 탭에서 미저장 상태면 customConfirm으로 확인 팝업을 띄운다.
이 기능이 customConfirm을 전제로 하기 때문에, CustomDialog.svelte가 먼저 만들어져야 했다. 기능들이 서로 의존하면서 자연스럽게 덩어리로 추가된 케이스다.
목업 CSS는 거의 그대로 살아남았다
재밌는 건, 이렇게 많은 게 바뀌었는데 CSS는 목업에서 거의 그대로 가져왔다는 점이다.
.dcell, .ev, .chip, .spill, .modal-sheet, .btm-bar 같은 핵심 클래스들의 스타일 규칙은 목업(mockup_ios.html)의 것을 app.css로 옮겼다. 테마 시스템(6종 액센트 + 4종 배경)도 목업에서 정의한 CSS 변수 체계를 그대로 사용한다.
설계에서 가장 많이 바뀐 건 기능과 UX 흐름이고, 가장 안 바뀐 건 시각적 디자인이다. 목업이 가장 크게 기여한 부분이 여기다. 레이아웃, 컴포넌트 스타일, 테마 변수를 미리 확정해놓으니까 구현할 때 디자인 고민이 거의 없었다.
설계서가 다 틀렸다는 건 아니다
오해하면 안 되는 게, 설계서가 쓸모없었다는 얘기가 아니다.
DB 스키마, 인증 방식(bcryptjs + 커스텀 세션), 계산 공식(540분 고정, 자동공제, overlap 기반 급량비), API 구조(upsert, camelCase ↔ snake_case 변환), Railway 배포 설정 — 이런 핵심 구조는 설계서대로 만들어졌고 거의 안 바뀌었다.
바뀐 건 대부분 실제로 써봐야 알 수 있는 UX 문제와 설계 시점에 생각 못 한 기능이다.
DaySheet가 인라인에서 오버레이로 바뀐 건 모바일에서 실제로 열어봤기 때문이고. 캘린더 요일이 바뀐 건 한국 달력의 관행을 설계 때 놓쳤기 때문이고. 튜토리얼이 추가된 건 앱을 만들고 나서야 온보딩이 필요하다는 걸 깨달았기 때문이고. 모바일 확대 방지가 3커밋 삽질이 된 건 iOS Safari의 동작을 테스트 전에는 알 수 없었기 때문이다.
설계서가 상세할수록 결과물이 정확해진다는 건 맞다. 근데 아무리 상세해도 모바일에서 실제로 써보기 전까지는 모르는 문제가 있다. 설계서를 완벽하게 쓰는 건 불가능하다. 대신 빠르게 만들고 빠르게 부딪히는 게 낫다.
CLAUDE.md가 v1.0에서 v4.0으로 올라간 건 설계가 부족해서가 아니라, 현실과 부딪히면서 진화한 거다. 설계 문서가 살아 있는 문서여야 의미가 있다.
게임을 만들 때도 기획서대로 나오는 게임은 없다. 플레이테스트를 하면 반드시 바뀐다. 웹앱도 마찬가지다. "폰에서 열어보는 게 플레이테스트"고, 플레이테스트 결과가 설계서를 고친다.
"설계서는 출발점이다. 도착점은 사용자가 정한다."
Top comments (0)