Zod와 방어적 파싱을 활용한 로컬 퍼스트 앱: 오프라인 데이터를 신뢰할 수 있게 만들기
Source: Dev.to
Offline‑first이 “입력 검증”의 의미를 바꾼다
대부분의 앱은 방금 제출한 HTML 폼만 검증한다.
로컬‑first 앱은 더 많은 입력 표면을 가진다:
- 영속화된 상태 블롭(재수화)
- 이전 버전의 IndexedDB 행
- 가져오기/복원 흐름
- 실수로 흐트러진 테스트 픽스처
이것들을 “로컬이니 신뢰한다”는 식으로 취급하면 결국 다음과 같은 버전을 배포하게 된다:
- 누군가의 장기 데이터에서 충돌이 발생하거나
- 로드되지만 필드를 조용히 오해한다.
Pain Tracker는 명확한 경계를 그린다:
- TypeScript 타입은 컴파일‑타임 진실이다.
- Zod 스키마는 런타임 진실이다.
프로젝트는 src/types.ts에서 이를 명시한다:
src/types/index.ts에 있는 정규PainEntry인터페이스를 재내보낸다.src/types/pain-entry.ts에 있는 Zod 스키마를 재내보낸다.
(그리고 스키마는 런타임 검증 전용임을 명시한다.)
PainEntrySchema
스키마는 src/types/pain-entry.ts에 있다. 복사해도 좋은 몇 가지 선택 사항:
- 역호환 가능한 ID –
id는string | number의 유니온이므로 오래된 저장 데이터가 깨지지 않는다. - 닫힌 형태로 실패하는 타임스탬프 검증 –
timestamp는 파싱 가능한 날짜 문자열이어야 하며, 그렇지 않으면 무효이다. “최선의 추측”은 하지 않는다. - 선택적 섹션에 대한 기본값 – 많은 중첩 객체가
.default(...)를 사용하므로 누락된 섹션이 모든 호출자가 전체 구조를 재구성하도록 강제하지 않는다.
기본값은 검증을 대체하는 것이 아니라, 유효하지만 불완전한 입력이 안정적이고 예측 가능한 형태로 들어오게 하는 방법이다.
Pain Tracker는 다음을 구분한다:
- “이것이 유효한
PainEntry형태인가?” - “이것이 유효한 새 엔트리인가?”
생성 스키마는 다음과 같이 만든다:
// src/types/pain-entry.ts
import { z } from "zod";
export const PainEntrySchema = z.object({
id: z.union([z.string(), z.number()]),
timestamp: z.string().refine((s) => !isNaN(Date.parse(s)), {
message: "Invalid timestamp",
}),
// …other fields…
});
export const CreatePainEntrySchema = PainEntrySchema
.omit({ id: true, timestamp: true })
.superRefine((data, ctx) => {
if (!data.locations?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one location must be selected",
});
}
});
그 규칙은 src/types/pain-entry.test.ts에서 직접 테스트한다.
검증 패턴
- 마이그레이션 / 가져오기를 위해 “shape” 스키마를 안정적으로 유지한다.
- 사용자‑대면 생성 경로에는 더 엄격한 스키마를 사용한다.
- UI에서는
safeParse를 사용한다(부드러운 오류 처리). - 불변식, 경계 검사, 혹은 테스트에서는
parse를 사용한다.
UI 예시
// src/components/pain-tracker/PainEntryForm.tsx
import { CreatePainEntrySchema } from "../../types/pain-entry";
const result = CreatePainEntrySchema.safeParse(formData);
if (!result.success) {
// display the first issue message
}
내보낸 헬퍼
// src/types/pain-entry.ts
export const validatePainEntry = (data: unknown) => PainEntrySchema.parse(data);
export const safeParsePainEntry = (data: unknown) => PainEntrySchema.safeParse(data);
스키마를 “지루하게” 유지하라 (미래의 당신이 고마워할 것이다)
스키마‑first 앱이 유지보수 불가능해지는 것을 방지하는 몇 가지 규칙:
- “모두 잡아먹는” 객체보다 명시적인 필드를 선호한다.
- 교차 필드 로직은
superRefine을 사용한다(예: “최소 하나의 위치를 포함해야 함”). - 규칙을 추가할 때마다 테스트를 추가한다.
- 런타임 검증을 폼 UX뿐 아니라 마이그레이션 전략의 일부로 간주한다.
시리즈 다음 편
- Part 5 – Trauma‑informed UX + accessibility as architecture
- Previous: Part 3 – Service workers that don’t surprise you
프로젝트 지원
- Sponsor the build
- Star the repo