홈으로 돌아가기

TypeScript 계약 Next.js App Router

이 글은 Next.js App Router에서 TypeScript를 계약 시스템으로 적용하는 방법을 보여줍니다. 서버-클라이언트 payload 타입, params 정규화, 도메인 엔티티 및 union-상태가 애플리케이션 경계에서 유효하지 않은 상태를 제외합니다.

Next.js에서 TypeScript를 계약으로: 실제 예제
Advertisement 728x90

Next.js App Router에서 계약 시스템으로서의 TypeScript

Next.js App Router에서 TypeScript는 서버-클라이언트, 외부 입력-도메인, 입력-엔티티, 변이-UI 등 계층 간 계약 시스템 역할을 합니다. 코드 위에 주석을 추가하는 대신, 타입이 경계에서 유효하지 않은 상태를 제거합니다. 이는 추가 코드 없이 프로젝트의 견고성을 향상시킵니다.

주요 계약 영역:

  • 서버 컴포넌트에서 클라이언트 컴포넌트로 페이로드 전달
  • 입력 시 params와 searchParams 파싱
  • 도메인 엔티티 정규화
  • 예측 가능한 상태의 변이 결과

페이로드를 통한 서버-클라이언트 계약

서버에서 클라이언트로 데이터를 전달하는 것은 중요한 경계입니다. 타입이 지정되지 않은 페이로드의 경우, 클라이언트는 구조에 대한 추측에 의존하고 서버는 임의의 객체를 전송합니다.

Google AdInline article slot

페이로드 타입 정의:

// src/demo/contracts.ts
export type HelloPayload = {
  appName: string;
  renderedAt: string;
  mode: "server-to-client";
  initialNotes: DemoNote[];
};

서버 페이지는 계약에 따라 엄격하게 데이터를 구성합니다:

// src/app/(demo)/demo-contract/page.tsx
import type { HelloPayload } from "@/demo/contracts";
import { getNotes } from "@/demo/notesStore";
import HelloClient from "./HelloClient";

export default function DemoContractPage() {
  const payload: HelloPayload = {
    appName: "Workbench Notes",
    renderedAt: new Date().toISOString(),
    mode: "server-to-client",
    initialNotes: getNotes(),
  };

  return <HelloClient payload={payload} />;
}

이 접근 방식은 직렬화 가능성과 일관성을 보장합니다. App Router의 서버/클라이언트 경계는 렌더링, 의존성 및 데이터 흐름을 정의합니다.

Google AdInline article slot

경계에서 외부 입력 정규화

Params와 searchParams는 string | string[] | undefined로 도착합니다. 원시 입력을 도메인에 전달하면 트리 전체에 타입 단언과 검사가 발생합니다.

올바른 접근 방식은 경계에서 파싱하는 것입니다:

// src/demo/noteId.ts
export function parseNoteId(value: unknown): DemoNoteId | null {
  if (typeof value !== "string") return null;

  const v = value.trim();

  if (!/^n\\d+$/.test(v)) return null;

  return v;
}

searchParams 유틸리티:

Google AdInline article slot
// src/lib/searchParams.ts
export type StringParamValue = string | string[] | undefined | null;

export function getStringParam(value: StringParamValue): string | undefined {
  if (typeof value === "string") return value;
  if (Array.isArray(value)) return value[0];
  return undefined;
}

정규화 후 코드는 유효한 값으로 작동하며, TypeScript는 내부 로직을 방해하지 않습니다.

도메인 엔티티에서 표현 불가능한 상태

타입은 논리적으로 불가능한 조합을 제외합니다. 엔티티는 항상 완전하고 일관됩니다:

type DemoNoteStatus = "draft" | "published" | "archived";
type DemoNotePriority = 1 | 2 | 3;

type DemoNote = {
  id: DemoNoteId;
  title: string;
  status: DemoNoteStatus;
  priority: DemoNotePriority;
  tags: string[];
  description: string;
  createdAt: IsoDateString;
  updatedAt: IsoDateString;
};

입력 타입은 불완전한 데이터를 허용합니다:

type DemoNoteCreateInput = {
  title: string;
  status?: DemoNoteStatus;
  priority?: DemoNotePriority;
  tags?: string[];
  description?: string;
};

기본값으로 조립:

// src/demo/notesStore.ts
export function createNote(input: DemoNoteCreateInput): DemoNote {
  const id: DemoNoteId = `n${Date.now()}`;
  const t = new Date().toISOString();

  const note: DemoNote = {
    id,
    title: input.title.trim(),
    status: input.status ?? "draft",
    priority: input.priority ?? 2,
    tags: input.tags ?? ["new"],
    description: input.description ?? "",
    createdAt: t,
    updatedAt: t,
  };

  notes = [note, ...notes];
  return note;
}

UI는 검사 없이 예측 가능한 객체로 작동합니다.

계약 신호로서의 TypeScript 오류 처리

타입이 할당 불가능은 로컬 오류가 아닌 깨진 계약을 신호합니다:

// 올바른 방법
export function demo_id_barrier(raw: string) {
  const noteId = parseNoteId(raw);
  if (!noteId) return null;

  return getNoteById(noteId);
}

// 잘못된 방법
export function demo_bad_fix(raw: string) {
  const unsafeId = raw as any;
  return getNoteById(unsafeId);
}

undefined 가능성은 가드로 해결됩니다:

export function demo_parseNoteId(raw: string) {
  const noteId = parseNoteId(raw);

  if (!noteId) return null;

  return getNoteById(noteId);
}

조기 반환은 TypeScript에 대한 타입을 좁힙니다.

작업 상태를 위한 유니온 타입

isLoading/hasError 플래그 대신 판별 유니온 사용:

export type ActionResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

export function ok<T>(data: T): ActionResult<T> {
  return { ok: true, data };
}

export function fail(error: string): ActionResult<never> {
  return { ok: false, error };
}

자동 타입 좁히기:

export function demo_narrowing() {
  const state = createResult(Math.random() > 0.5);

  if (!state.ok) {
    return state.error;
  }

  return state.data.title;
}

논리적으로 불가능한 상태는 발생하지 않습니다.

핵심 요약:

  • TypeScript는 서버/클라이언트, params/도메인, 입력/엔티티 경계에서 계약을 강제합니다
  • 표현 불가능한 상태는 UI에서 런타임 오류를 제거합니다
  • TypeScript 오류는 로컬 문제가 아닌 깨진 계약을 신호합니다
  • 유니온 타입은 플래그를 작업 상태로 대체합니다
  • 경계에서 외부 입력 정규화는 내부 코드를 단순화합니다

— Editorial Team

Advertisement 728x90

다음 읽기