Zod와 로컬 퍼스트 앱에서 방어적 파싱: 오프라인 데이터를 신뢰할 수 있게 만들기
Source: Dev.to
오프라인‑우선이 “입력 검증”의 의미를 바꾼다
대부분의 앱은 방금 제출한 HTML 폼만 검증한다.
로컬‑우선 앱은 더 많은 입력 경로를 가진다:
- 영속화된 상태 블롭(재수화)
- 이전 버전의 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);
스키마를 “지루하게” 유지하라 (미래의 당신이 고마워할 것이다)
스키마‑퍼스트 앱이 유지보수 불가능해지는 것을 방지하는 몇 가지 규칙:
- “모두 잡아내는” 객체보다 명시적인 필드를 선호한다.
- 교차 필드 로직은
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: