"처음부터 다시 짜면 어때요?"
Claude가 이 말을 했을 때 잠깐 멈칫했습니다. 그런데 생각해보니… 맞는 말이었거든요.
몇 주째 타임박싱 앱을 쓰면서 점점 불편함이 쌓여가고 있었습니다. 아이들 재워놓고 새벽에 홈랩 작업할 때 앱을 켜면, 작은 모바일 화면에서 15분짜리 셀을 손가락으로 정확히 눌러야 하는데 이게 생각보다 너무 어려웠거든요. 오른쪽 엄지로 PLAN 4칸, 왼쪽 엄지로 DONE 4칸을 번갈아 보면서 시선이 분산되는 것도 문제였고요. "이거, 원래부터 좀 이상했던 거 아닐까?" 싶었습니다.
결국 이번 편에서는 레이아웃 자체를 전면 리디자인하기로 결정했습니다. 코드 리팩터링보다 훨씬 무거운 작업이지만, 어차피 쓸 앱이라면 제대로 만들어야죠.
환경 정보
| 항목 | 내용 |
|---|---|
| 앱 스택 | Vanilla JS + HTML + CSS |
| 서버 환경 | Proxmox LXC 위 Ubuntu 24.04 |
| 웹 서버 | Nginx (리버스 프록시 + SSL) |
| 백엔드 | PocketBase |
| 이전 버전 | v2 (PLAN 4칸 + DONE 4칸 = 8칸 구조) |
| 이번 버전 | v3 (통합 4칸 오버레이 구조) |
v2의 문제를 솔직하게 짚어보자
2화, 3화를 거치면서 나름 기능은 갖춰졌습니다. PocketBase에 데이터도 저장되고, Nginx 리버스 프록시로 HTTPS도 붙였고, 멀티유저도 됩니다. 그런데 막상 쓰다 보면 느끼는 불편함이 있었어요.
첫 번째는 공간 문제입니다. PLAN 4칸 + DONE 4칸을 나란히 배치하니 각 셀의 너비가 절반으로 줄어들었습니다. PC에서는 괜찮은데 모바일에서는 15분짜리 칸이 손가락 하나 크기도 안 됩니다. 오탭 남발로 엉뚱한 시간대를 건드리는 일이 허다했어요.
두 번째는 시선 분산 문제입니다. PLAN과 DONE을 좌우로 비교하는 구조라 오늘 계획 대비 실행을 한눈에 파악하기 어려웠습니다. 뭔가 계속 고개를 좌우로 흔들면서 보는 느낌이랄까요.
세 번째는 심리적 장벽입니다. PLAN과 DONE이 분리되어 있으니 "완료했다는 것을 따로 또 표시해야 한다"는 번거로움이 있었습니다. 생산성 도구인데 오히려 마찰이 생기는 거죠.
이 세 가지를 해결할 구조를 Claude와 함께 고민했고, 결론은 오버레이형 통합 레이아웃이었습니다.

오버레이형 설계: 아이디어의 출발점
"구글 캘린더처럼 PLAN과 DONE을 한 칸에 합치면 안 될까?" 이 한 마디에서 시작됐습니다.
구글 캘린더를 보면 하나의 타임슬롯에 일정 블록이 겹쳐서 표시됩니다. 비슷한 원리를 타임박싱에 적용하면, 하나의 셀이 계획 상태와 완료 상태를 동시에 표현할 수 있습니다. 여러 방안을 검토했는데요.
- 구글 캘린더형: 타임라인에 블록을 시각적으로 쌓는 방식 → 구현이 복잡하고 CSS 관리가 어렵다
- 투 패널형(탭): PLAN/DONE을 탭으로 전환 → 여전히 분리된 느낌, UX 개선 없음
- 오버레이형: PLAN 위에 DONE 상태가 덮이는 방식 → 채택
- 현재 구조 유지 + 칸 크기만 키우기: 근본적인 UX 문제가 남음
최종적으로 선택한 오버레이형의 핵심 아이디어는 이렇습니다.
빈 칸 → 흰색 (터치/클릭하면 PLAN 설정 팝업 열림)
PLAN만 있음 → 파스텔 색 (연한 파랑) + 라벨 텍스트
PLAN + DONE → 진한 색 (강한 파랑) + ✓ 체크마크
다른 일정 → 테두리(outline) + 라벨하나의 셀만 봐도 "계획은 잡혔는가", "실행은 했는가"를 즉시 알 수 있는 구조입니다. 계획표와 실행 기록을 한 화면에서 비교할 수 있으니 타임박싱의 핵심인 "계획 대비 실행률"을 눈으로 바로 확인하게 됩니다.
인터랙션 설계: 한 셀에서 세 가지 동작 구분하기
오버레이형 구조에서 가장 골치 아팠던 부분이 바로 인터랙션 설계였습니다. 하나의 셀에서 아래 세 가지 동작을 모두 처리해야 하거든요.
| 동작 | 대상 | 결과 |
|---|---|---|
| 클릭 / 드래그 | 빈 셀 | PLAN 설정 팝업 열기 |
| 클릭 | PLAN이 있는 셀 | DONE 토글 (파스텔 → 진한 색 + ✓) |
| 길게 누르기(Long Press) | PLAN 또는 DONE 셀 | 편집 팝업 열기 |
문제는 브라우저가 "클릭"과 "드래그 시작"을 구분하지 못한다는 점입니다. mousedown 이벤트 기준으로는 둘 다 같은 이벤트예요. 그래서 이벤트 상태 머신 방식으로 처리했습니다.
// 이벤트 상태 머신 개념
let pointerState = 'idle'; // idle | pressing | dragging
let pressTimer = null;
cell.addEventListener('pointerdown', (e) => {
pointerState = 'pressing';
pressTimer = setTimeout(() => {
pointerState = 'longpress';
openEditPopup(cell); // 길게 누르기 → 편집 팝업
}, 500);
});
cell.addEventListener('pointermove', (e) => {
if (pointerState === 'pressing') {
pointerState = 'dragging'; // 드래그 시작 감지
clearTimeout(pressTimer);
}
});
cell.addEventListener('pointerup', (e) => {
clearTimeout(pressTimer);
if (pointerState === 'pressing') {
handleCellClick(cell); // 짧은 클릭
} else if (pointerState === 'dragging') {
finalizeDrag(); // 드래그 완료
}
pointerState = 'idle';
});mousedown/mouseup 대신 pointer 이벤트 계열을 쓴 이유는 모바일 터치와 PC 마우스를 통일해서 처리할 수 있기 때문입니다. iOS Safari에서는 특히 터치 이벤트 처리가 까다로운데, pointer 이벤트 계열이 훨씬 일관되게 동작했습니다.

반응형 레이아웃: PC는 사이드바, 모바일은 드로어
레이아웃을 새로 짜면서 반응형도 제대로 설계했습니다. 두 가지 뷰를 만들었어요.
PC 레이아웃 (768px 초과)
넓은 화면에서는 왼쪽에 사이드바(BIG 3, Brain Dump, 메모 영역), 오른쪽에 타임그리드를 배치합니다. 전체 너비는 max-width: 1100px로 제한하고 가운데 정렬했습니다. 넓은 모니터에서 양쪽에 여백이 생기면서 카드처럼 떠 있는 느낌을 줍니다. 시선이 분산되지 않고 중앙에 집중되는 효과가 있어서 꽤 만족스러웠어요.
.app-container {
display: flex;
max-width: 1100px;
margin: 0 auto;
gap: 20px;
}
.sidebar {
width: 280px;
flex-shrink: 0;
}
.timegrid {
flex: 1;
}모바일 레이아웃 (768px 이하)
모바일에서는 사이드바가 숨겨지고, 우하단에 📋 플로팅 버튼(FAB)이 나타납니다. 이 버튼을 누르면 하단에서 드로어(Drawer)가 슬라이드 업으로 올라오는데, 여기서 중요한 포인트가 있습니다.
BIG 3와 Brain Dump를 드로어에서 편집하더라도, PC와 모바일에서 같은 DOM 요소를 쓰도록 했습니다. 복제(clone)가 아니라 실제 DOM 노드를 이동(move)하는 방식입니다. 상태 동기화 버그를 원천 차단하는 깔끔한 방법이에요.
function toggleDrawer() {
if (!isOpen) {
// 사이드바를 드로어 안으로 이동 (복제 X, 이동 O)
drawerContent.appendChild(sidebar);
sidebar.style.display = 'flex';
drawer.classList.add('open');
} else {
// 원래 위치(앱 본문)로 복원
appBody.insertBefore(sidebar, appBody.firstChild);
sidebar.style.display = '';
drawer.classList.remove('open');
}
isOpen = !isOpen;
}처음에는 innerHTML을 복사하는 방식을 시도했다가, 이벤트 리스너가 날아가는 문제를 경험하고 바로 DOM 이동 방식으로 전환했습니다. 교훈: 데이터를 복제할 때는 데이터만, DOM을 이동할 때는 노드 자체를.
폰트 삽질기: Pretendard를 서버에 직접 올리기까지
솔직히 이 부분이 이번 편에서 가장 재미있는 구간입니다. 기능 구현보다 폰트 하나 때문에 삽질을 훨씬 오래 했거든요.
기본 시스템 폰트가 타임테이블에서 너무 어색했습니다. 숫자랑 한글이 섞이는데 줄 간격도 이상하고 두께감도 제각각이에요. 그래서 요즘 한국 웹에서 가장 많이 쓰는 폰트인 Pretendard를 적용하기로 했습니다.
1차 시도: Google Fonts CDN → 실패
<link href="https://fonts.googleapis.com/css2?family=Pretendard&display=swap" rel="stylesheet">- Pretendard는 Google Fonts에 없습니다. 당연히 없는 건데 희망 고문을 당한 기분이랄까요.
2차 시도: jsDelivr CDN → 실패
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />홈랩 서버에서 외부 CDN 요청이 차단되어 있었습니다. Nginx 설정 상의 문제가 아니라 네트워크 레벨에서 막혀 있는 상황이었어요. LXC 컨테이너 환경 특성상 외부 요청이 제한될 수 있는데, 제 환경이 딱 그랬습니다.
3차 시도: 서버에 직접 업로드 → 성공 🎉
GitHub의 Pretendard 리포지토리에서 woff2 파일을 직접 다운받아 서버에 올렸습니다.
# 서버에서 직접 다운로드
wget https://github.com/orioncactus/pretendard/releases/download/v1.3.9/Pretendard-1.3.9.zip
unzip Pretendard-1.3.9.zip
cp web/static/woff2/* /var/www/timebox/fonts/그리고 CSS에 @font-face로 직접 등록했습니다.
@font-face {
font-family: 'Pretendard';
src: url('/fonts/Pretendard-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap; /* FOIT 방지: 폰트 로딩 전에 시스템 폰트로 먼저 렌더링 */
}
@font-face {
font-family: 'Pretendard';
src: url('/fonts/Pretendard-Medium.woff2') format('woff2');
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: 'Pretendard';
src: url('/fonts/Pretendard-Bold.woff2') format('woff2');
font-weight: 700;
font-display: swap;
}
body {
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont,
'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
}font-display: swap을 꼭 넣어야 합니다. 이게 없으면 폰트가 로드될 때까지 텍스트가 아예 안 보이는 FOIT(Flash of Invisible Text) 현상이 생기거든요. 셀프호스팅 환경이라 로딩이 빠르긴 하지만, 그래도 명시적으로 선언해 두는 게 좋습니다.
추가 삽질: CSS 클래스명이 달라서 폰트가 안 먹히는 문제
Pretendard를 적용했는데 시간 셀(5 AM, 6 AM…)에는 여전히 이상한 폰트가 표시됐습니다. 30분 넘게 디버깅한 끝에 원인을 찾았는데요.
기존에 저장된 데이터(v2)에는 셀 클래스명이 hour-cell로 저장되어 있었는데, 새 HTML에서는 h-cell로 바꾼 거였습니다. 저장된 데이터를 복원할 때 className을 그대로 덮어쓰니까, 새 CSS에는 h-cell만 정의되어 있어서 hour-cell이 적용되지 않았던 거죠.
해결책은 간단했습니다. CSS에 구 클래스명을 같이 선언하면 됩니다.
/* 구 버전(v2) 호환 클래스명도 같이 선언 */
.h-cell,
.hour-cell {
font-family: 'Pretendard', sans-serif;
font-size: 11px;
color: #888;
/* ... */
}교훈: CSS 클래스명을 바꿀 때는 저장된 데이터도 고려하라. 서버사이드 렌더링 없는 클라이언트 앱은 특히 이런 함정이 많습니다.

데이터 호환성: 완벽한 마이그레이션은 포기하기로 했다
레이아웃이 완전히 바뀌었으니 저장 형식도 달라졌습니다. v2는 8칸 구조, v3는 4칸 구조라 td 구성이 다를 수밖에 없어요.
전략을 이렇게 잡았습니다.
- 새로 저장하는 데이터:
v:3버전 태그 포함 - v1/v2 데이터 로드 시: 텍스트 데이터(BIG 3, Brain Dump, 메모)만 복원, 타임그리드는 초기화
- 주간 리뷰 페이지: v2/v3 모두 파싱 가능하도록 분기 처리
function loadData(savedData) {
const version = savedData?.v || 1;
if (version >= 3) {
// 현재 버전 - 전체 복원
restoreFullLayout(savedData);
} else {
// 구 버전 - 텍스트만 복원, 타임그리드 초기화
restoreTextOnly(savedData);
console.warn(`v${version} 데이터: 타임그리드는 초기화되었습니다.`);
}
}"지난 데이터의 타임그리드까지 완벽하게 마이그레이션해야 하지 않을까?" 고민했지만, 현실적으로 과거 날짜의 타임그리드를 수정할 일이 거의 없다는 판단이 들었습니다. 텍스트(BIG 3, Brain Dump)만 살아있으면 지난 주 회고할 때는 충분하니까요.
UI 리디자인에서 가장 어려운 건 코드가 아니라 이전 데이터 호환성이다 — 이 말을 뼈저리게 실감했습니다.
현재 시간 표시줄: 구글 캘린더의 그 기능
제가 구글 캘린더에서 가장 좋아하는 기능이 빨간 가로선입니다. 타임그리드 위에 "지금 몇 시인지"를 실시간으로 표시해 주는 그거요. 타임박싱 앱에도 이걸 추가했습니다.
function updateTimeLine() {
const now = new Date();
const h = now.getHours();
const m = now.getMinutes();
const hoursSince5am = h - 5; // 5 AM 시작 기준
const rowHeight = 44; // td 높이 (px)
const headerHeight = 36; // 헤더 높이
const topPx = headerHeight + (hoursSince5am * rowHeight) + (m / 60 * rowHeight);
timeLine.style.top = topPx + 'px';
}
// 1분마다 위치 업데이트
setInterval(updateTimeLine, 60000);
// 페이지 로드 시 현재 시간 위치로 자동 스크롤
window.addEventListener('load', () => {
updateTimeLine();
scrollToCurrentTime();
});scrollToCurrentTime()은 현재 시간 위치가 화면 중앙에 오도록 스크롤해 줍니다. 아침에 앱을 열면 현재 시간대가 바로 보이는 거죠. 작은 기능이지만 매일 쓰는 앱에서 이런 디테일이 UX를 크게 좌우합니다.

이번 편 회고: 내가 배운 것들
개발하면서 실제로 와닿았던 교훈들을 정리하면 이렇습니다.
UI 리디자인은 코드보다 데이터 호환이 더 어렵다. 새 코드는 새로 짜면 되는데, 이미 저장된 데이터는 건드릴 수 없습니다. 마이그레이션 전략을 설계할 때 "얼마나 완벽하게 할 것인가"보다 "어디서 선을 그을 것인가"가 더 중요한 질문이에요.
한 요소에 클릭/드래그/길게누르기를 동시에 넣으려면 이벤트 상태 머신이 필요하다. 이걸 그냥 click, drag 이벤트로 처리하려고 했다가 버그를 엄청 만들었습니다. 상태 변수 하나가 코드를 훨씬 명확하게 만들어줍니다.
CSS 클래스명 변경은 저장된 데이터에도 영향을 미친다. 특히 className을 직렬화/역직렬화하는 패턴을 쓰고 있다면, 클래스명 변경 시 반드시 구 이름을 CSS에 함께 선언해 줘야 합니다.
셀프 호스팅 환경에서 외부 CDN은 믿을 수 없다. LXC 컨테이너, 방화벽이 걸려 있는 홈랩 환경에서는 외부 CDN 의존을 최소화하고 직접 서빙하는 게 훨씬 안전합니다. Pretendard 같은 무료 폰트라면 서버에 올려서 @font-face로 직접 서빙하는 방식이 속도도 빠르고 안정적입니다.
작은 UX 디테일이 매일 쓰는 앱에서는 큰 차이를 만든다. 현재 시간 표시줄, 모바일 드로어의 슬라이드 애니메이션 같은 것들은 "없어도 되는 기능"처럼 느껴지지만, 매일 아침 앱을 켤 때마다 체감 차이가 납니다.
다음 편 예고
이번 편에서 반응형 구조를 새로 잡았으니, 다음 편에서는 모바일 최적화를 본격적으로 다룰 예정입니다. 특히 iOS Safari의 뷰포트 이슈(주소창이 나타날 때 높이가 바뀌는 문제), 터치 드래그 감도, 그리고 홈 화면 추가(PWA) 관련 내용도 다뤄볼 것 같습니다.
홈랩 앱이라 "어차피 나만 쓰면 되지"라고 생각했는데, 가족들이 쓰기 시작하면서 모바일 UX가 생각보다 훨씬 중요해졌습니다. 특히 아이들 보면서 폰으로 빠르게 체크할 때 터치 반응이 조금이라도 느리면 진짜 짜증나거든요. 다음 편도 삽질기 가득할 예정입니다 😅
👉 #1화 — 타임박싱이란? 앱 만들게 된 이유
👉 #2화 — PocketBase + Nginx + Proxmox 서버 인프라 구축
👉 #3화 — 멀티유저 인증과 데이터 동기화
👉 #4화 — 전면 리디자인: 오버레이형 통합 레이아웃
👉 #5화 — 모바일 최적화: 터치와의 전쟁
👉 #6화 — PWA: 웹앱을 앱처럼 쓰는 마법
👉 #7화 — 가족끼리 일정 공유
👉 #8화 — 가족끼리 일정 공유
'IT > AI' 카테고리의 다른 글
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #7 — 가족끼리 일정 공유 (0) | 2026.03.25 |
|---|---|
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #6 — PWA: 웹앱을 앱처럼 쓰는 마법 🪄 (0) | 2026.03.24 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #5 — 모바일 최적화: 터치와의 전쟁 (0) | 2026.03.23 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #3 — 반복 템플릿, 주간 리뷰 대시보드, 그리고 다크모드까지 (1) | 2026.03.20 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #2 — Nginx 리버스 프록시 + PocketBase로 셀프호스팅 서버 구축하기 (0) | 2026.03.19 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #1 — 종이 플래너를 웹으로 옮긴 인프라 엔지니어의 바이브 코딩 도전기 (1) | 2026.03.18 |