본문 바로가기
IT/AI

[AI] Claude와 함께 타임박싱 웹앱 만들기 #12 — 데이터 보호: 덮어쓰기 차단, 백업, 로그

by 수누다 2026. 3. 31.

기능을 열심히 만들었는데, 데이터가 두 번 날아갔습니다. 크롬에서 작업하다가 엣지로 접속하면, 엣지의 빈 DOM이 PocketBase에 저장되면서 크롬에서 만든 데이터가 사라졌습니다. 인프라 엔지니어로서 이건 용납할 수 없었습니다. 이번 편은 기능 추가가 아니라 데이터를 지키는 이야기입니다.


1. 환경 정보

항목 버전/스펙
Backend PocketBase (Self-hosted, SQLite)
Frontend HTML + Vanilla JS (PWA)
Infra Proxmox VE — LXC 컨테이너
Web Server Nginx (리버스 프록시)
SSL Let's Encrypt

2. 배경 — 데이터가 두 번 날아갔다

11편까지 소셜, 캘린더, 가족 대시보드, 목표, 알림, 리뷰 강화까지 열심히 쌓아 올렸습니다. 그런데 어느 날 크롬에서 하루 종일 Plan/Done을 채워놓고, 저녁에 엣지로 접속했더니 — 오전에 열심히 채운 데이터가 전부 사라졌습니다. 한 번이면 실수겠거니 했는데, 이틀 뒤에 같은 일이 발생했습니다.

기능이 100개 있어도 데이터가 날아가면 의미가 없습니다. 그래서 이번 편은 새 기능 대신, 기존 데이터를 지키는 방어 체계를 구축한 기록입니다.


3. 원인 분석 — capture() 함수의 함정

타임박싱 앱은 DOM 상태를 통째로 캡처해서 JSON으로 저장하는 구조입니다. capture() 함수가 document.querySelectorAll('table td')를 순회하면서 className, style, innerHTML을 추출합니다. 문제는 이 함수가 너무 많은 것을 캡처했다는 점이었습니다.

문제 1 — 공유 일정 오버레이 혼입

공유 일정의 줄무늬 HTML(shared-ev div)이 payload에 포함됐습니다. 다른 브라우저에서 이 payload를 복원하면 공유 일정이 "개인 데이터"에 포함되고, 진짜 개인 데이터(Plan)는 사라졌습니다.

문제 2 — 오버레이 내부 input 포함

소셜 패널의 sp-friend-search, 목표 패널의 gl-name, 팝업의 share-chk 같은 입력 필드가 payload에 저장됐습니다. 이 필드들은 UI 상태일 뿐, 저장 대상이 아닙니다.

문제 3 — 빈 DOM 저장 (핵심 원인)

브라우저를 새로 열면 PocketBase에서 데이터를 로드하기 전에 MutationObserver가 auto-save를 트리거합니다. 빈 DOM이 서버 데이터를 덮어쓰는 전형적인 레이스 컨디션이었습니다. MDN MutationObserver 문서에서도 observe 타이밍에 대한 주의사항을 언급하고 있는데, 직접 당해보니 체감이 다릅니다.


4. 3중 보호 구현

4-1. capture() 정리 — 불필요한 요소 제외

오버레이 내부 요소와 공유 일정 HTML을 캡처 대상에서 제외했습니다.

function capture() {
  const socialOverlay = document.getElementById('social-overlay');
  const fmOverlay = document.getElementById('fm-overlay');
  const glOverlay = document.getElementById('gl-overlay');
  const popup = document.getElementById('popup-overlay');
  const tplOv = document.getElementById('tpl-overlay');

  // input 캡처에서 모든 오버레이 제외
  document.querySelectorAll('input,textarea').forEach(el => {
    if (socialOverlay?.contains(el)) return;
    if (fmOverlay?.contains(el)) return;
    if (glOverlay?.contains(el)) return;
    if (popup?.contains(el)) return;
    if (tplOv?.contains(el)) return;
    // ... 캡처
  });

  // td innerHTML에서 shared-ev 제거
  document.querySelectorAll('table td').forEach(el => {
    if (el.innerHTML !== el.textContent) {
      const clone = el.cloneNode(true);
      clone.querySelectorAll('.shared-ev').forEach(ev => ev.remove());
      // 클린 HTML만 저장
    }
  });
}

핵심은 "캡처 대상이 아닌 것"을 명확하게 제외하는 것입니다. DOM 전체를 캡처하는 방식은 편리하지만, 기능이 늘어날수록 의도하지 않은 요소가 포함될 위험이 커집니다.

4-2. 덮어쓰기 차단 — 빈 데이터 감지

서버에 데이터가 있는데 현재 캡처가 비어있으면 저장을 거부합니다.

function countSlotData(payloadStr) {
  // pc-X, done-marked-X, done-outlined-X 클래스 카운트
  // + brain dump 텍스트도 가중치 부여
}

async function save(date) {
  if (rec) {
    const serverCount = countSlotData(rec.payload);
    const localCount = countSlotData(payload);
    if (serverCount > 5 && localCount === 0) {
      // 저장 차단!
      status('⚠ 빈 데이터 — 덮어쓰기 차단됨');
      return;
    }
  }
}

countSlotData()는 payload 안에 실제 Plan/Done 데이터가 몇 개인지 세는 함수입니다. 서버에 5개 이상 있는데 현재가 0개면 "뭔가 잘못됐다"고 판단합니다. 단순히 payload가 비어있는지가 아니라, 의미 있는 슬롯 개수를 기준으로 판단하는 게 포인트입니다.

의도적 초기화(clearDay)는 PocketBase 레코드를 직접 삭제하는 별도 경로이므로 이 보호에 걸리지 않습니다.

4-3. _dataLoaded 플래그 — 로드 완료 전 저장 원천 차단

이게 3중 보호의 핵심입니다.

let _dataLoaded = false;

function sched() { // auto-save 스케줄러
  if (!_dataLoaded) return; // 로드 완료 전 저장 차단
  // ...
}

async function load(date) {
  // ... PocketBase에서 데이터 로드
  _dataLoaded = true; // 이제 저장 허용
}

async function go(delta) {
  _dataLoaded = false; // 날짜 이동 시 리셋
  await load(curDate);
}

앱 시작 → PocketBase 로드 완료 전까지는 auto-save가 아예 동작하지 않습니다. 날짜를 이동할 때도 플래그를 리셋해서, 새 날짜의 데이터가 로드되기 전에 이전 날짜의 잔여 DOM이 저장되는 것도 방지합니다. auto-save의 레이스 컨디션을 막으려면 Request Queue를 이용한 FIFO 처리가 정석이지만, 이 앱처럼 단일 사용자 구조에서는 플래그 하나로도 충분했습니다.


5. 자동 백업 시스템

3중 보호를 넣어도 "만약에"를 대비해야 합니다. 인프라 엔지니어 습성상, 백업 없는 시스템은 불안합니다.

5-1. localStorage 타임스탬프 백업

PocketBase 저장 성공 시마다 별도 키에 타임스탬프 포함 백업을 쌓습니다.

function saveBackup(date, payload) {
  const bkKey = 'tb_bk_' + uid + '_' + date;
  const backups = JSON.parse(localStorage.getItem(bkKey) || '[]');
  if (countSlotData(payload) === 0) return; // 빈 데이터는 백업 안 함
  backups.push({
    t: new Date().toISOString(),
    slots: countSlotData(payload),
    payload: payload
  });
  if (backups.length > 5) backups.splice(0, backups.length - 5);
  localStorage.setItem(bkKey, JSON.stringify(backups));
}

날짜별 최대 5개 보관이고, 빈 데이터는 백업하지 않습니다. 가장 오래된 것부터 삭제하는 FIFO 방식입니다. localStorage는 브라우저별로 별도이므로 크롬/엣지 각각 독립된 백업이 만들어집니다.

5-2. beforeunload 보호

브라우저 닫을 때 localStorage에 저장하는 beforeunload 핸들러에도 같은 보호를 적용했습니다.

window.addEventListener('beforeunload', () => {
  if (_calIsOpen) return; // 캘린더 열린 상태면 스킵
  const payload = JSON.stringify(capture());
  const newCount = countSlotData(payload);
  if (newCount === 0) {
    const existing = localStorage.getItem(localKey);
    if (existing && countSlotData(existing) > 0) return; // 기존 보호
  }
  localStorage.setItem(localKey, payload);
});

빈 데이터로 기존 로컬 백업을 덮어쓰는 것까지 차단합니다. 캘린더가 열린 상태에서는 DOM이 변형되어 있으므로 아예 스킵합니다.

5-3. 콘솔 복구 명령어

데이터가 날아갔을 때 F12 → Console에서 바로 복구할 수 있는 헬퍼 함수를 만들었습니다.

// 백업 목록 확인
_listBackups('2026-03-15')
// [0] 2026-03-15T09:30:00Z — 42 slots
// [1] 2026-03-15T12:15:00Z — 58 slots
// [2] 2026-03-15T18:45:00Z — 61 slots

// 2번 백업으로 복원
_restoreBackup('2026-03-15', 2)
// → confirm 창 → DOM 복원 + PocketBase 저장

_listBackups()으로 날짜별 백업을 확인하고, _restoreBackup()으로 원하는 시점의 백업을 선택해서 복원합니다. confirm 대화상자가 뜨니까 실수로 엉뚱한 백업을 복원하는 것도 방지됩니다.


6. 로그 시스템 구축

문제가 발생한 후에 "로그가 있었으면..."이라고 생각하기 전에 미리 넣어두자는 취지입니다. 서버 운영할 때도 syslog 없이 트러블슈팅하는 건 불가능한데, 웹앱도 다르지 않습니다.

function tbLog(action, detail) {
  const entry = {
    t: new Date().toISOString(),
    a: action,       // init, nav, load-pb, save-ok, save-blocked, clear...
    d: detail,       // slots=42 update
    date: curDate,
    uid: userId().slice(0, 6)
  };
  // 최근 100건 유지
}

기록하는 주요 이벤트는 다음과 같습니다.

이벤트 설명
init 앱 시작
nav 날짜 이동 (2026-03-14 → 2026-03-15)
load-pb PocketBase에서 로드 (slots=42)
load-local 로컬에서 로드 (PocketBase 404)
load-empty 데이터 없음
save-ok 저장 성공 (slots=42 update)
save-blocked 덮어쓰기 차단 (server=42 local=0)
save-fail 저장 실패
clear 초기화
backup 백업 생성
restore-backup 백업 복원

콘솔에서 _showLogs(20)을 입력하면 최근 20건을 테이블로 출력합니다. 다음에 데이터가 이상해지면 로그를 보고 "언제, 뭐가, 왜" 일어났는지 추적할 수 있습니다.


7. UI 개선 — 드롭다운 메뉴 정리

기능이 많아지면서 상단 바에 버튼이 15개까지 늘어났습니다. 모바일에서는 2줄도 부족해서 3줄로 넘어가고 있었습니다. 자주 쓰는 것만 메인에 남기고, 나머지는 ≡ 메뉴 드롭다운으로 이동했습니다.

모바일 메인 바:

초기화 | 어제PLAN | 템플릿 | 캘린더 | ≡ 메뉴

≡ 메뉴 드롭다운 항목:

다크모드 / 저장 / 소셜 / 가족 / 목표 / 알림 / 리뷰 / 로그아웃

데스크톱에서는 다크모드, 캘린더, 저장이 메인에 보이고, 모바일에서는 드롭다운으로 이동합니다. CSS의 .tb-mob-hide.tb-dd-mob-only로 분기했고, 메뉴 바깥 아무 곳이나 클릭하면 자동으로 닫힙니다.


8. 모바일 터치 스크롤 개선

Plan이 칠해진 셀에서 세로 스크롤이 안 되는 문제가 있었습니다. 드래그 시스템이 터치를 가로채서 preventDefault()를 호출하기 때문이었습니다.

해결 방법은 세로 움직임이면 스크롤, 가로 움직임이면 드래그로 방향을 판단하는 것입니다.

if (!_touchScrolling && !dragMoved) {
  if (dy > dx) {
    // 세로 움직임 → 스크롤! 드래그 취소
    _touchScrolling = true;
    isDrag = false;
    return;
  }
}
if (_touchScrolling) return;
e.preventDefault(); // 가로 드래그만 스크롤 차단

터치 시작 후 움직임의 x/y 변화량을 비교해서, 세로 방향이 우세하면 즉시 드래그를 취소하고 브라우저 기본 스크롤에 맡깁니다. 간단한 로직이지만, 이걸 안 넣으면 모바일에서 타임그리드를 스크롤할 수 없어서 사용성이 크게 떨어집니다.


9. 전체 파일 현황

index.html        ~1,460줄  (UI + CSS + 인터랙션)
sync-wrapper.js   ~2,670줄  (데이터 엔진 + 소셜 + 캘린더 + 알림 + 백업)
review.html         ~530줄  (주간 리뷰 + TOP5 + 변경률 + 월간 추이)

8편 끝에서 index.html 650줄, sync-wrapper.js 900줄이었던 게 약 3배가 됐습니다. 소셜, 캘린더, 가족 대시보드, 목표, 알림, 리뷰 강화, 데이터 보호가 추가된 결과입니다.


10. 마무리 — 배운 점 & 다음 편 예고

직접 데이터를 두 번 날려보니, 몇 가지가 뼈에 새겨졌습니다.

"데이터를 지키는 코드"는 "기능을 만드는 코드"보다 중요합니다. 서버 운영에서도 백업/모니터링 없이 서비스를 올리는 건 말이 안 되는데, 웹앱도 마찬가지였습니다.

capture()처럼 "전체를 가져오는" 함수는 위험합니다. 편리하지만 기능이 늘어날수록 의도하지 않은 요소까지 포함됩니다. 제외 목록을 꼼꼼히 관리해야 합니다.

빈 데이터 감지는 "의미 있는 슬롯 개수"로 판단해야 합니다. payload의 길이나 null 체크만으로는 부족합니다.

로그는 디버깅의 생명줄입니다. 문제가 터진 후에 넣으면 이미 늦습니다.

localStorage 백업은 서버 백업과 함께 이중 안전망이 됩니다. 클라이언트-서버 양쪽에 백업이 있어야 어느 쪽이 문제가 생겨도 복구할 수 있습니다.

8편에서 마무리한 줄 알았는데, 4편이 더 나왔습니다. 소셜, 캘린더, 가족 대시보드, 목표, 알림, 리뷰 강화, 데이터 보호까지. "완성"이란 건 없고, 매일 쓰면서 불편한 점을 고치다 보면 끝이 없습니다.

다음에 만들고 싶은 것들이 남아 있습니다. 반복 일정(매일/매주 자동 등록), PWA 푸시 알림(백그라운드), 데이터 내보내기(JSON/PDF). 비슷한 프로젝트를 하고 계신다면, 데이터 보호를 먼저 만들고 기능을 추가하세요. 저처럼 데이터 두 번 날리지 마시길 바랍니다.


메타 설명: 타임박싱 웹앱에서 데이터가 두 번 날아간 원인을 분석하고, capture() 정리·덮어쓰기 차단·_dataLoaded 플래그의 3중 보호와 자동 백업·로그 시스템을 구축한 과정을 정리합니다.