Web-Front 실전 훈련북

이 문서의 사용법

이 문서는 00~14 문서를 읽고 끝내는 대신, 실제 개발 상황에서 판단을 꺼내 쓰기 위한 훈련장이다. 먼저 상황과 Evidence를 읽고, 곧바로 해설을 보지 않는다. 먼저 써보기에 저장소 선택, API 계약, 화면 상태, 디버깅 순서, 테스트 범위를 직접 적은 뒤 해설과 비교한다.

기존 문서의 역할은 다음처럼 나눈다.

  • 00~13: 개념, 원리, 예시, 판단 기준을 담은 원본 학습 문서
  • 14. Web-Front 실전 케이스북: 상황을 보고 어느 문서를 열어야 하는지 알려주는 탐색 지도
  • 15. Web-Front 실전 훈련북: Evidence를 보고 직접 결정을 써본 뒤, 해설과 연결 문서로 되돌아가는 반복 훈련 문서

좋은 답안은 완벽한 정답보다 근거가 분명한 답안이다. “Cookie를 쓴다”보다 “refresh token은 HttpOnly; Secure; SameSite Cookie로 두고, CSRF와 CORS credentials를 같이 맞춘다”처럼 선택의 조건과 위험 신호를 함께 적는다.

훈련 방식

각 훈련은 같은 순서로 진행한다.

  1. 상황을 읽고 문제 영역을 나눈다.
  2. Evidence에서 브라우저, 코드, API, CSS, 배포 설정 중 무엇이 결정적인 단서인지 표시한다.
  3. 먼저 써보기에 내 결정을 적는다.
  4. 힌트만 보고 답안을 보강한다.
  5. 해설을 읽고 내 답안에서 빠진 계약, 상태, 위험 신호를 체크한다.
  6. 연결 문서로 돌아가 해당 판단을 다시 읽는다.

한 번에 전부 풀 필요는 없다. 하루에 Lab 하나와 Incident 하나 정도면 충분하다. 같은 훈련을 두 번째 풀 때는 답안이 더 짧아지는 것이 좋다. 핵심 판단 기준이 몸에 들어오면 문장이 길 필요가 없다.

반복 학습 루프

  • 첫 번째 회독: 해설을 보기 전에 “무엇을 확인할지”만 적어도 된다.
  • 두 번째 회독: 내 선택과 백엔드와 맞춰야 할 계약을 함께 적는다.
  • 세 번째 회독: 위험 신호와 테스트 또는 검증 방법까지 적는다.
  • 실제 프로젝트 중 회독: 지금 작성 중인 코드, API, 배포 환경에 맞게 Evidence를 바꿔 다시 푼다.

훈련 목록

구분훈련주로 다루는 판단
LabLab 01. 로그인 저장소와 refresh 흐름 설계localStorage, Cookie, XSS, CSRF, 401 refresh, CORS
LabLab 02. 회원가입 Form과 서버 field error 매핑React Hook Form, Zod, number input, server validation
LabLab 03. 프로젝트 목록의 URL, query key, mutation 설계URL query, TanStack Query, loading/empty/error, invalidation
IncidentIncident 01. 운영 배포 후 Cookie 로그인이 풀린다SameSite, Secure, credentials, preflight, Application/Network
IncidentIncident 02. 보관 mutation 후 화면이 오래된 값을 보여준다stale cache, optimistic update, rollback, query key
IncidentIncident 03. env를 바꿨는데 예전 API URL을 호출한다Vite build env, CDN cache, release, traceId
ReviewReview 01. API 계약 설계안을 리뷰한다nullable, error schema, pagination, timezone, enum
ReviewReview 02. React/TypeScript 코드 변경을 리뷰한다as, ?., useEffect, stale closure, key, input value
ReviewReview 03. UI 품질과 테스트 전략을 리뷰한다Button, Modal, Table, 접근성, MSW, e2e smoke
Final작은 프로젝트 관리 앱을 끝까지 설계한다인증, API, form, routing, UI, 배포, 테스트 종합

Part 1. 실습 랩

Lab 01. 로그인 저장소와 refresh 흐름 설계

상황

작은 프로젝트 관리 앱을 만들고 있다. 로그인, 프로젝트 CRUD, 관리자 전용 화면이 있다. 처음에는 빠르게 구현하기 위해 access token과 refresh token을 모두 localStorage에 넣으려 했지만, 사용자 이메일과 권한 정보가 있고 나중에 결제 기능도 붙을 수 있다.

백엔드는 다음 선택지를 제안했다.

  • access token은 15분 만료
  • refresh token은 14일 만료
  • 프론트는 Vite SPA
  • 운영 도메인은 https://app.example.com, API는 https://api.example.com

제공된 Evidence

현재 프론트 코드 초안은 다음과 같다.

export async function login(email: string, password: string) {
  const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  });
 
  if (!response.ok) {
    throw new Error("로그인 실패");
  }
 
  const body = await response.json();
 
  localStorage.setItem("accessToken", body.accessToken);
  localStorage.setItem("refreshToken", body.refreshToken);
}

대시보드 API wrapper 초안은 다음과 같다.

export async function apiFetch(path: string, init?: RequestInit) {
  const token = localStorage.getItem("accessToken");
 
  const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}${path}`, {
    ...init,
    headers: {
      ...init?.headers,
      Authorization: token ? `Bearer ${token}` : "",
    },
  });
 
  if (response.status === 401) {
    await refresh();
    return apiFetch(path, init);
  }
 
  return response;
}

백엔드가 Cookie 기반 refresh를 검토한다면 응답은 다음처럼 바꿀 수 있다고 한다.

Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=None; Path=/auth/refresh; Max-Age=1209600
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

해야 할 일

  • access token과 refresh token의 저장소를 결정한다.
  • localStorage를 쓰는 경우와 Cookie를 쓰는 경우의 위험을 나눈다.
  • 401 refresh를 어느 계층에서 처리할지 정한다.
  • 백엔드와 맞춰야 할 Cookie, CORS, CSRF, logout 계약을 적는다.
  • refresh 중복 호출과 무한 반복을 막는 최소 구조를 설계한다.

먼저 써보기

힌트

  • refresh token은 access token보다 오래 살고, 재발급 권한을 가진다.
  • HttpOnly Cookie는 XSS로 token 값을 읽어가는 위험을 줄이지만, Cookie 자동 전송 때문에 CSRF와 SameSite 판단이 따라온다.
  • app.example.comapi.example.com은 origin은 다르지만 site 판단은 별도로 봐야 한다.
  • refresh endpoint가 401을 반환했을 때 다시 refresh를 시도하면 흐름이 반복된다.

해설

이 상황에서 refresh token을 localStorage에 오래 두는 것은 좋은 기본값이 아니다. localStorage는 JavaScript가 읽을 수 있으므로 XSS 상황에서 refresh token이 그대로 탈취될 수 있다. 특히 refresh token은 access token 재발급 권한이므로 탈취 피해가 길고 크다.

현실적인 기본 설계는 다음과 같다.

  • refresh token은 HttpOnly; Secure; SameSite Cookie로 둔다.
  • access token은 짧게 유지하고, 가능하면 메모리 또는 짧은 수명으로 다룬다.
  • 새로고침 복구는 /auth/refresh 또는 /me로 처리한다.
  • 모든 API 요청에서 Cookie를 보내야 한다면 credentials: "include"를 명시한다.
  • 서버는 credentials 요청에 Access-Control-Allow-Origin: *가 아니라 실제 프론트 origin을 반환한다.
  • refresh 실패는 즉시 사용자 상태와 query cache를 비우고 로그인 화면으로 보낸다.

refresh 로직은 컴포넌트가 아니라 API client 계층에서 한 번만 처리한다. 동시에 여러 요청이 401을 받을 수 있으므로 단일 refresh promise를 공유한다.

let refreshPromise: Promise<void> | null = null;
 
async function refreshSession() {
  const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`, {
    method: "POST",
    credentials: "include",
  });
 
  if (!response.ok) {
    throw new Error("SESSION_EXPIRED");
  }
}
 
export async function authFetch(
  path: string,
  init?: RequestInit & { skipAuthRefresh?: boolean },
) {
  const { skipAuthRefresh = false, ...requestInit } = init ?? {};
 
  const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}${path}`, {
    ...requestInit,
    credentials: "include",
  });
 
  if (response.status !== 401 || skipAuthRefresh) {
    return response;
  }
 
  refreshPromise ??= refreshSession().finally(() => {
    refreshPromise = null;
  });
 
  await refreshPromise;
 
  return fetch(`${import.meta.env.VITE_API_BASE_URL}${path}`, {
    ...requestInit,
    credentials: "include",
  });
}

백엔드와는 다음 계약을 문서화해야 한다.

  • 401은 인증 만료 또는 인증 필요, 403은 권한 부족
  • refresh Cookie의 HttpOnly, Secure, SameSite, Domain, Path, 만료 시간
  • CSRF 방어 방식: SameSite, CSRF token, Origin/Referer 검증 중 무엇을 쓸지
  • logout 시 refresh token 서버 무효화 방식
  • preflight OPTIONS가 인증 미들웨어에서 막히지 않는지
  • refresh token rotation과 재사용 탐지 여부

연결 문서

가져갈 판단 기준

  • token 저장소는 구현 편의가 아니라 XSS, CSRF, refresh 권한, 배포 도메인으로 결정한다.
  • refresh token은 access token보다 더 보수적으로 다룬다.
  • Cookie 기반 인증은 credentials, CORS, SameSite, Secure, CSRF까지 한 묶음이다.
  • 401 refresh는 화면별 분기가 아니라 API client의 동시성 제어 문제다.

Lab 02. 회원가입 Form과 서버 field error 매핑

상황

회원가입 화면을 만든다. 입력값은 이메일, 비밀번호, 나이, 초대 코드다. 프론트는 React Hook Form과 Zod를 쓰고, 백엔드는 서버 validation error를 field 단위로 내려주기로 했다.

문제는 나이를 비웠을 때 0처럼 제출되는 경우가 있고, 서버가 내려준 field error가 input 아래에 붙지 않는다는 점이다. 팀원은 서버 에러를 전부 toast로 보여주자고 제안한다.

제공된 Evidence

현재 schema와 submit 코드는 다음과 같다.

const signupSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  age: z.coerce.number().int().min(14),
  inviteCode: z.string().optional(),
});
 
type SignupForm = z.infer<typeof signupSchema>;
 
function SignupPage() {
  const { register, handleSubmit, setError, formState } = useForm<SignupForm>({
    resolver: zodResolver(signupSchema),
  });
 
  const onSubmit = handleSubmit(async (values) => {
    const response = await fetch("/api/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });
 
    if (response.status === 400) {
      const error = await response.json();
      toast.error(error.message);
      return;
    }
  });
 
  return (
    <form onSubmit={onSubmit}>
      <input type="email" {...register("email")} />
      <input type="password" {...register("password")} />
      <input type="number" {...register("age")} />
      <input {...register("inviteCode")} />
      <button disabled={formState.isSubmitting}>가입</button>
    </form>
  );
}

서버 error response 초안은 다음과 같다.

{
  "code": "VALIDATION_ERROR",
  "message": "입력값을 확인해 주세요.",
  "fieldErrors": {
    "email": ["이미 사용 중인 이메일입니다."],
    "age": ["14세 이상만 가입할 수 있습니다."],
    "profile.nickname": ["닉네임은 필수입니다."]
  },
  "traceId": "req-7f1a"
}

해야 할 일

  • 나이 입력에서 빈 문자열, 0, NaN이 어떻게 처리되어야 하는지 정한다.
  • client validation과 server validation의 역할을 나눈다.
  • field error, form-level error, toast error를 구분한다.
  • 알 수 없는 서버 field가 왔을 때의 fallback을 설계한다.
  • 중복 submit 방어와 백엔드 멱등성 필요 여부를 판단한다.

먼저 써보기

힌트

  • DOM input의 value는 number input이어도 기본적으로 string이다.
  • z.coerce.number()는 빈 문자열을 의도와 다르게 숫자로 바꿀 수 있다.
  • 서버 validation은 최종 권위이고, 프론트 validation은 빠른 피드백이다.
  • 서버 field name을 as keyof SignupForm으로 밀어 넣으면 계약 변경을 숨길 수 있다.

해설

Form은 input 값을 모으는 코드가 아니라 사용자 의도, 검증, 제출 상태, 서버 에러, 접근성을 연결하는 경계다. 나이 입력은 특히 조심해야 한다. 사용자가 비운 값은 “숫자 0”이 아니라 “입력 없음”일 수 있다. 빈 문자열을 명시적으로 처리한 뒤 domain number로 변환해야 한다.

const signupSchema = z.object({
  email: z.string().email("이메일 형식이 아닙니다."),
  password: z.string().min(8, "비밀번호는 8자 이상이어야 합니다."),
  age: z.preprocess(
    (value) => (value === "" ? undefined : value),
    z.coerce.number().int().min(14, "14세 이상만 가입할 수 있습니다."),
  ),
  inviteCode: z.string().trim().optional(),
});

서버 field error는 사용자가 고칠 input 아래로 돌아가야 한다. 매핑할 수 없는 field는 억지로 field error에 넣지 말고 form-level error로 처리한다.

const serverValidationErrorSchema = z.object({
  code: z.literal("VALIDATION_ERROR"),
  message: z.string(),
  fieldErrors: z.record(z.string(), z.array(z.string())).optional(),
  traceId: z.string().optional(),
});
 
const signupFieldNames = {
  email: true,
  password: true,
  age: true,
  inviteCode: true,
} satisfies Record<keyof SignupForm, true>;
 
function isSignupField(field: string): field is keyof SignupForm {
  return field in signupFieldNames;
}
 
async function applyServerError(response: Response) {
  const error = serverValidationErrorSchema.parse(await response.json());
 
  for (const [field, messages] of Object.entries(error.fieldErrors ?? {})) {
    const message = messages[0] ?? error.message;
 
    if (!isSignupField(field)) {
      setError("root.server", {
        type: "server",
        message: `${error.message}${error.traceId ? ` (${error.traceId})` : ""}`,
      });
      continue;
    }
 
    setError(field, { type: "server", message });
  }
}

실제 컴포넌트에서는 label, aria-invalid, role="alert", noValidate, submit pending label을 함께 둔다.

<form onSubmit={onSubmit} noValidate>
  <label>
    이메일
    <input {...register("email")} aria-invalid={!!errors.email} />
  </label>
  {errors.email && <p role="alert">{errors.email.message}</p>}
 
  <button type="submit" disabled={formState.isSubmitting}>
    {formState.isSubmitting ? "가입 중" : "가입"}
  </button>
</form>

중복 submit은 프론트에서 isSubmitting으로 줄일 수 있지만, 가입, 결제, 주문처럼 중복 생성 비용이 큰 API라면 서버 멱등성이나 중복 방어가 필요하다. 프론트 disable은 UX 방어이고, 데이터 무결성은 서버가 최종 책임진다.

연결 문서

가져갈 판단 기준

  • 입력 중 값은 UI draft이고, 제출 값은 검증을 통과한 domain 값이다.
  • server validation error는 field error, form-level error, global error로 나누어야 한다.
  • 알 수 없는 field는 type assertion으로 숨기지 말고 안전한 fallback으로 보낸다.
  • submit pending은 UX 방어이고, 중요한 생성 작업은 백엔드 멱등성이 필요하다.

Lab 03. 프로젝트 목록의 URL, query key, mutation 설계

상황

프로젝트 목록 화면에 검색어, 상태 필터, 정렬, 페이지네이션, 보관 버튼이 있다. 사용자는 링크를 공유하거나 뒤로가기를 눌렀을 때 같은 목록 상태가 복원되길 원한다. 보관 버튼을 누르면 현재 목록에서 즉시 사라져야 하고, 사이드바의 프로젝트 개수도 갱신되어야 한다.

제공된 Evidence

현재 구현은 다음과 같다.

function ProjectListPage() {
  const [keyword, setKeyword] = useState("");
  const [status, setStatus] = useState("active");
  const [page, setPage] = useState(1);
 
  const projectsQuery = useQuery({
    queryKey: ["projects"],
    queryFn: () => fetchProjects({ keyword, status, page }),
  });
 
  const archiveMutation = useMutation({
    mutationFn: archiveProject,
    onSuccess: () => {
      toast.success("보관했습니다.");
    },
  });
 
  if (projectsQuery.isLoading) return <Spinner />;
  if (projectsQuery.isError) return <p>오류</p>;
 
  return (
    <ProjectTable
      projects={projectsQuery.data.items}
      onArchive={(id) => archiveMutation.mutate(id)}
    />
  );
}

API 응답은 다음처럼 온다.

{
  "items": [
    { "id": 10, "title": "긴 제목이 들어올 수 있는 프로젝트", "status": "active" }
  ],
  "page": 1,
  "pageSize": 20,
  "totalCount": 120
}

해야 할 일

  • URL query param에 둘 상태와 local state로 둘 상태를 나눈다.
  • query key가 어떤 값을 포함해야 하는지 설계한다.
  • 검색/필터/정렬 변경 시 page를 어떻게 처리할지 정한다.
  • mutation 성공 후 어떤 query를 invalidate할지 적는다.
  • loading, empty, error, success, permission denied 상태를 어떻게 나눌지 정한다.

먼저 써보기

힌트

  • 검색어, 필터, 정렬, 페이지는 공유와 복원이 필요하므로 URL에 잘 맞는다.
  • query key는 서버 데이터의 종류와 그 데이터를 결정하는 parameter를 표현해야 한다.
  • mutation이 성공해도 프론트 cache는 자동으로 최신이 되지 않는다.
  • empty는 error가 아니다. 권한 없음도 일반 error와 다르다.

해설

목록 화면은 URL, query key, API parameter가 한 세트로 움직여야 한다. 검색어, 상태, 정렬, 페이지는 공유와 복원이 필요한 상태이므로 URL query param에 두고, query key에도 같은 값을 넣는다.

function ProjectListPage() {
  const [searchParams, setSearchParams] = useSearchParams();
  const queryClient = useQueryClient();
 
  const keyword = searchParams.get("keyword") ?? "";
  const status = searchParams.get("status") ?? "active";
  const sort = searchParams.get("sort") ?? "createdAt.desc";
  const rawPage = Number(searchParams.get("page") ?? "1");
  const page = Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1;
 
  const projectsQuery = useQuery({
    queryKey: ["projects", { keyword, status, sort, page }],
    queryFn: () => fetchProjects({ keyword, status, sort, page }),
    staleTime: 30_000,
  });
 
  const archiveMutation = useMutation({
    mutationFn: archiveProject,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["projects"] });
      queryClient.invalidateQueries({ queryKey: ["project-summary"] });
    },
  });
 
  function changeStatus(nextStatus: string) {
    setSearchParams({ keyword, status: nextStatus, sort, page: "1" });
  }
}

필터나 검색어가 바뀌면 기존 page를 유지하면 빈 화면이나 중복 데이터를 만들 수 있으므로 page를 1로 reset한다. 정렬 안정성, page index 기준, totalCounthasNext는 API 계약에 포함되어야 한다.

UI 상태는 다음처럼 나눈다.

  • loading: skeleton이나 spinner
  • error: 재시도 가능한 실패 화면
  • empty: 요청은 성공했지만 표시할 데이터 없음
  • permission denied: 인증은 되었지만 권한 부족
  • success: 데이터 표시
  • mutation pending: row action 비활성화 또는 해당 row loading

optimistic update는 보관처럼 rollback이 쉬운 기능에 적용할 수 있다. 다만 실패하면 이전 cache로 되돌리고 사용자에게 알려야 한다. 권한 변경, 결제, 재고 차감에는 더 보수적인 흐름이 맞다.

연결 문서

가져갈 판단 기준

  • 목록 조건은 URL, query key, API parameter에 같은 의미로 반영한다.
  • mutation 설계에는 “성공 후 어떤 query가 stale해지는가”가 포함된다.
  • error, empty, permission denied는 사용자 행동이 다르므로 화면도 달라야 한다.
  • optimistic update는 실패 복구 가능성과 실패 비용을 먼저 따진다.

Part 2. 장애 대응 훈련

Incident 상황

로컬에서는 로그인이 유지된다. 운영 배포 후에는 로그인 직후 /me가 한 번 성공하는 듯하다가 새로고침하면 다시 로그인 화면으로 간다. 사용자는 “로그인이 풀린다”고 말하고, 백엔드는 “로그인 API는 200이고 Set-Cookie도 내려간다”고 말한다.

현재 보이는 증상

  • Chrome Application 탭에서 refresh_token Cookie가 보이지 않거나, 보이지만 /auth/refresh 요청에 실리지 않는다.
  • Network에는 OPTIONS /auth/refresh가 먼저 보인다.
  • Console에는 CORS 관련 메시지가 보인다.
  • 운영 프론트 origin은 https://app.example.com, API origin은 https://api.example.com이다.

로그/지표/SQL 단서

Request URL: https://api.example.com/auth/refresh
Request Method: OPTIONS
Status Code: 403
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
Set-Cookie: refresh_token=...; HttpOnly; SameSite=None; Path=/; Max-Age=1209600
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

프론트 요청은 다음과 같다.

await fetch("https://api.example.com/auth/refresh", {
  method: "POST",
});

5분 안에 확인할 것

  • Cookie가 저장되지 않은 문제인지, 저장됐지만 요청에 첨부되지 않는 문제인지 구분한다.
  • Set-CookieSecure가 있는지 확인한다.
  • credentials 요청인데 Access-Control-Allow-Origin: *를 쓰고 있지 않은지 본다.
  • 프론트 fetch에 credentials: "include"가 있는지 확인한다.
  • preflight OPTIONS가 인증 미들웨어에서 막히는지 확인한다.
  • SameSite와 HTTPS, Domain, Path가 운영 도메인 구조와 맞는지 본다.

원인 후보 분류

  • Cookie 속성 문제: SameSite=None인데 Secure가 없음, Domain/Path가 맞지 않음
  • CORS 문제: credentials 요청에 wildcard origin 사용, Access-Control-Allow-Credentials 누락
  • 프론트 요청 문제: credentials: "include" 누락
  • preflight 문제: OPTIONS가 인증/권한 필터에 막힘
  • route 처리 문제: refresh 실패 후 query cache와 auth state 정리가 안 됨

하면 안 되는 조치

  • localStorage에 refresh token을 넣어 운영 장애를 임시로 덮는다.
  • CORS를 *로 열면 해결된다고 보고 credentials를 같이 쓴다.
  • Postman 성공만 보고 브라우저 정책 문제를 닫는다.
  • protected route 코드만 수정하며 Cookie 저장/첨부 여부를 확인하지 않는다.

복구 절차

  1. 프론트 fetch에 credentials: "include"를 넣는다.
  2. 서버 CORS 응답을 특정 origin으로 제한한다.
  3. Cookie를 HttpOnly; Secure; SameSite=None 또는 배포 구조에 맞는 SameSite 값으로 설정한다.
  4. preflight OPTIONS는 인증 없이 CORS 허용 정책을 반환하게 한다.
  5. refresh 실패 시 query cache와 사용자 상태를 비우고 로그인 화면으로 보낸다.
  6. dev/stage/prod origin, API origin, cookie domain, HTTPS 여부를 표로 남긴다.

완료 검증

  • Application 탭에 refresh Cookie가 저장된다.
  • /auth/refresh 요청의 Request Headers에 Cookie가 실린다.
  • preflight가 2xx로 성공하고 필요한 CORS header가 있다.
  • 새로고침 후 /me 또는 /auth/refresh를 통해 로그인 상태가 복구된다.
  • 401과 403의 UI 행동이 다르게 나타난다.

회고 질문

  • 로컬 개발에서 same-origin proxy를 써서 운영 cross-origin 문제를 늦게 발견한 것은 아닌가?
  • SameSite, Secure, Domain, Path를 환경별 표로 관리하고 있는가?
  • 이 장애를 component test로 잡을 수 있는가, 브라우저 통합/e2e 또는 배포 체크가 필요한가?

연결 문서

가져갈 판단 기준

  • Cookie 인증 장애는 프론트 fetch, 서버 CORS, Cookie 속성, preflight를 함께 본다.
  • Postman 성공은 브라우저 성공의 증거가 아니다.
  • credentials 요청에서는 wildcard origin을 쓰지 않는다.
  • 운영 인증 문제는 Application 탭과 Network header가 가장 빠른 증거다.

Incident 02. 보관 mutation 후 화면이 오래된 값을 보여준다

Incident 상황

프로젝트를 보관하면 서버에서는 상태가 archived로 바뀐다. 하지만 현재 목록에는 계속 보이고, 사이드바의 active count도 그대로다. 개발자는 “API는 성공했으니 React가 알아서 다시 그릴 줄 알았다”고 말한다.

현재 보이는 증상

  • POST /projects/10/archive는 200이다.
  • 목록 query는 다시 호출되지 않는다.
  • 상세 화면에는 보관됨으로 보이지만 목록에는 진행 중으로 보인다.
  • optimistic update를 시도한 branch에서는 실패 후에도 보관된 것처럼 남는다.

로그/지표/SQL 단서

const projectsQuery = useQuery({
  queryKey: ["projects"],
  queryFn: () => fetchProjects({ status, page }),
});
 
const archiveMutation = useMutation({
  mutationFn: archiveProject,
  onSuccess: () => {
    toast.success("보관했습니다.");
  },
});
{
  "status": 409,
  "code": "PROJECT_ALREADY_ARCHIVED",
  "message": "이미 보관된 프로젝트입니다.",
  "traceId": "req-931b"
}

5분 안에 확인할 것

  • query key가 status, page, keyword, sort를 포함하는지 본다.
  • mutation 성공 후 어떤 query를 invalidate하는지 확인한다.
  • 목록, 상세, summary count 중 stale해지는 범위를 적는다.
  • optimistic update가 있다면 이전 cache 스냅샷과 rollback이 있는지 본다.
  • 실패 code가 사용자 메시지와 재조회 정책으로 연결되는지 확인한다.

원인 후보 분류

  • query key가 너무 넓어 서로 다른 목록 cache가 섞임
  • mutation 성공 후 invalidation 누락
  • optimistic update rollback 누락
  • 서버 상태를 local state에 복사해 query cache와 별도로 관리
  • error schema가 부족해 실패 후 복구 행동을 결정하지 못함

하면 안 되는 조치

  • 보관 성공 후 window.location.reload()로 덮는다.
  • 모든 query를 무조건 invalidate해서 작은 변경마다 앱 전체를 흔든다.
  • optimistic update를 실패 비용이 큰 작업에도 무조건 적용한다.
  • 같은 서버 데이터를 전역 store와 query cache에 중복 보관한다.

복구 절차

  1. query key에 목록 parameter를 포함한다.
  2. mutation 성공 후 관련 목록과 summary를 invalidate한다.
  3. 상세 화면이 있다면 해당 상세 query도 갱신하거나 서버 응답으로 patch한다.
  4. optimistic update는 rollback 가능한 기능에만 제한한다.
  5. 실패하면 이전 cache로 되돌리고 traceId와 사용자 행동 메시지를 남긴다.
const archiveMutation = useMutation({
  mutationFn: archiveProject,
  onSuccess: (_, projectId) => {
    queryClient.invalidateQueries({ queryKey: ["projects"] });
    queryClient.invalidateQueries({ queryKey: ["project", projectId] });
    queryClient.invalidateQueries({ queryKey: ["project-summary"] });
  },
});

완료 검증

  • active 목록에서 보관한 프로젝트가 사라진다.
  • archived 목록에는 보관한 프로젝트가 나타난다.
  • 사이드바 count가 갱신된다.
  • 실패 시 화면이 이전 상태로 돌아오고 재시도 또는 안내 메시지가 보인다.
  • 새로고침 없이도 목록, 상세, summary가 서로 다른 값을 보여주지 않는다.

회고 질문

  • 이 데이터의 source of truth가 서버인지 브라우저인지 처음부터 구분했는가?
  • query key 설계가 API parameter와 같은 언어를 쓰는가?
  • optimistic update를 적용하기 전에 실패 비용과 rollback 가능성을 따졌는가?

연결 문서

가져갈 판단 기준

  • mutation은 서버를 바꾸지만, 프론트 cache 갱신 정책은 따로 설계해야 한다.
  • query key는 데이터 종류와 parameter를 함께 표현한다.
  • optimistic update는 rollback 스냅샷과 실패 메시지까지 있어야 완성이다.
  • stale한 UI는 기능 버그이자 사용자 신뢰 문제다.

Incident 03. env를 바꿨는데 예전 API URL을 호출한다

Incident 상황

운영 API를 https://api-v2.example.com으로 바꾸기 위해 배포 플랫폼의 env를 수정했다. 그런데 사용자 브라우저는 여전히 https://api-v1.example.com을 호출한다. 일부 사용자는 새 URL을 호출하고, 일부는 예전 URL을 호출한다. Sentry에는 에러가 찍히지만 어느 배포부터 깨졌는지 서버 로그와 연결하기 어렵다.

현재 보이는 증상

  • DevTools Network에서 오래된 API URL 호출이 보인다.
  • 브라우저 source 검색에서 예전 URL 문자열이 bundle 안에 있다.
  • CDN에는 hashed JS asset과 HTML cache 정책이 섞여 있다.
  • Sentry event에는 release version이 없다.
  • API error response에는 traceId가 있지만 프론트 에러 수집에는 포함되지 않는다.

로그/지표/SQL 단서

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
 
export function buildApiUrl(path: string) {
  return new URL(path, apiBaseUrl).toString();
}
VITE_API_BASE_URL=https://api-v2.example.com/api/
build artifact contains: https://api-v1.example.com/api/
Cache-Control: public, max-age=86400
Content-Type: text/html

5분 안에 확인할 것

  • Vite 정적 build인지, SSR 런타임인지 확인한다.
  • env 값이 build 시점에 bundle로 들어갔는지 확인한다.
  • HTML cache와 hashed asset cache가 다르게 설정되어 있는지 본다.
  • API base URL과 relative path 조합에서 /api/ prefix가 사라지지 않는지 확인한다.
  • Sentry release, source map, traceId가 연결되어 있는지 본다.

원인 후보 분류

  • 배포 플랫폼 env만 바꾸고 rebuild/redeploy를 하지 않음
  • 오래된 HTML이 오래 cache되어 예전 asset을 가리킴
  • URL 조합 helper가 base path prefix를 버림
  • 브라우저 bundle에 secret 또는 환경별 값에 대한 오해가 있음
  • release/trace 정보 부족으로 사용자 에러와 서버 로그 연결 실패

하면 안 되는 조치

  • VITE_SECRET_KEY 같은 값을 프론트 env에 넣는다.
  • “서버 env를 바꿨으니 프론트도 바뀌었을 것”이라고 가정한다.
  • HTML과 hashed JS asset에 같은 cache 정책을 적용한다.
  • 에러 추적에 release와 traceId 없이 사용자 신고만 기다린다.

복구 절차

  1. VITE_API_BASE_URL 변경 후 새 build artifact를 만든다.
  2. HTML은 짧게 cache하고, hash가 붙은 JS/CSS asset은 길게 cache한다.
  3. 필요하면 CDN purge로 오래된 HTML을 제거한다.
  4. API URL helper는 relative path만 받도록 제한하고 base path prefix 보존을 테스트한다.
  5. Sentry release version과 source map 업로드를 배포 파이프라인에 넣는다.
  6. API error의 traceId를 프론트 error context에 포함한다.

완료 검증

  • 새로 받은 HTML이 최신 hashed asset을 가리킨다.
  • bundle 안에 예전 API URL이 없다.
  • buildApiUrl("projects")https://api-v2.example.com/api/projects를 만든다.
  • production error event에 release와 traceId가 함께 남는다.
  • 사용자가 신고한 에러를 서버 로그와 연결할 수 있다.

회고 질문

  • 프론트 env를 공개 설정으로 보고 있는가?
  • runtime config가 필요한 값을 build-time env로 박아둔 것은 아닌가?
  • 배포 체크리스트에 API origin, CORS origin, Cookie domain, cache policy가 들어 있는가?

연결 문서

가져갈 판단 기준

  • Vite 정적 배포의 env는 build 시점에 결정된다.
  • 브라우저 bundle에 들어간 값은 secret이 아니다.
  • HTML cache와 hashed asset cache는 다르게 다룬다.
  • 운영 디버깅은 release, source map, traceId가 연결될 때 빨라진다.

Part 3. 설계 리뷰 훈련

Review 01. API 계약 설계안을 리뷰한다

제안된 설계안

프로젝트 목록 API를 다음처럼 만들자는 제안이 왔다.

GET /projects?status=active&page=1
{
  "items": [
    {
      "id": 1,
      "title": "Project A",
      "status": "active",
      "ownerName": null,
      "dueDate": "2026-07-01",
      "updatedAt": "2026-07-01T09:30:00Z"
    }
  ],
  "page": 1
}

실패 응답은 팀 공통으로 다음처럼 하자는 제안이다.

{
  "message": "Bad request"
}

겉보기 장점

  • 응답 구조가 단순하다.
  • 화면에 필요한 기본 필드가 들어 있다.
  • dueDateupdatedAt이 문자열이라 직렬화가 쉽다.
  • 성공 응답만 보면 빠르게 구현할 수 있다.

숨은 리스크

  • ownerNamenull 의미가 불명확하다. 아직 없음인지, 권한 때문에 숨김인지, 탈퇴한 사용자인지 화면 행동이 달라진다.
  • pagination metadata가 부족하다. pageSize, totalCount, hasNext, sort 안정성이 없으면 UI가 다음 페이지를 추측해야 한다.
  • dueDate는 date-only이고 updatedAt은 timestamp인데 의미가 문서화되어 있지 않다.
  • error response에 code, fieldErrors, traceId가 없어 프론트가 사용자 행동을 나누기 어렵다.
  • enum 확장 정책이 없다. statusblocked가 추가되면 badge와 filter가 깨질 수 있다.

리뷰 질문

  • ownerName: null은 어떤 도메인 의미인가?
  • dueDate는 사용자 timezone에 따라 바뀌면 안 되는 date-only인가?
  • page는 0 기반인가, 1 기반인가?
  • sort 기본값과 안정성 보장은 무엇인가?
  • validation error, 권한 부족, 인증 만료, 서버 장애는 어떤 code와 status를 쓰는가?
  • 프론트가 empty, error, permission denied, retry UI를 만들 수 있는가?

빠진 계약

  • nullable과 optional의 의미
  • error schema: code, message, fieldErrors, traceId
  • pagination metadata: page, pageSize, totalCount 또는 hasNext
  • sort 문법과 안정성
  • date-only와 timestamp 기준
  • enum 추가 시 fallback 정책
  • mock fixture와 MSW handler 관리 방식

개선된 설계 방향

{
  "items": [
    {
      "id": 1,
      "title": "Project A",
      "status": "active",
      "owner": {
        "id": 10,
        "name": "Joseph"
      },
      "dueDate": "2026-07-01",
      "updatedAt": "2026-07-01T09:30:00Z"
    }
  ],
  "page": 1,
  "pageSize": 20,
  "totalCount": 120,
  "sort": "updatedAt.desc"
}
{
  "code": "VALIDATION_ERROR",
  "message": "입력값을 확인해 주세요.",
  "fieldErrors": {
    "title": ["제목은 필수입니다."]
  },
  "traceId": "req-421c"
}

프론트는 API DTO를 그대로 깊은 컴포넌트에 흘리지 말고 view model에서 nullable과 label, fallback image, disabled 이유를 좁힌다.

승인 조건

  • 성공 응답과 실패 응답 모두 OpenAPI 또는 문서에 예시가 있다.
  • nullable, optional, enum, date/timezone 기준이 명시되어 있다.
  • 목록 UI가 필요한 pagination metadata를 받는다.
  • MSW fixture에 success, empty, validation error, 401, 403, nullable case가 포함된다.
  • error response의 traceId가 프론트 관측성과 연결된다.

연결 문서

가져갈 판단 기준

  • API 계약은 성공 응답보다 실패 응답과 경계값에서 품질이 드러난다.
  • nullable, enum, pagination, timezone은 화면 상태와 직접 연결된다.
  • OpenAPI는 시작점이고, 실제 fixture와 mock이 함께 맞아야 협업 비용이 줄어든다.

Review 02. React/TypeScript 코드 변경을 리뷰한다

제안된 설계안

다음 PR은 프로젝트 검색 화면을 구현한다.

type Project = {
  id: number;
  title: string;
  thumbnailUrl: string;
  dueDate: string;
};
 
function ProjectSearch({ keyword }: { keyword: string }) {
  const [items, setItems] = useState<Project[]>([]);
  const [selectedIds, setSelectedIds] = useState<number[]>([]);
 
  useEffect(() => {
    fetch(`/api/projects?q=${keyword}`)
      .then((response) => response.json())
      .then((body) => setItems(body as Project[]));
  }, []);
 
  return (
    <ul>
      {items.map((project, index) => (
        <li key={index}>
          <input
            type="checkbox"
            checked={selectedIds.includes(project.id)}
            onChange={() => {
              selectedIds.push(project.id);
              setSelectedIds(selectedIds);
            }}
          />
          <img src={project.thumbnailUrl} />
          <span>{project.title}</span>
          <span>{new Date(project.dueDate).toLocaleDateString()}</span>
        </li>
      ))}
    </ul>
  );
}

겉보기 장점

  • 코드가 짧다.
  • 검색 API를 호출하고 목록을 보여준다.
  • TypeScript 타입 Project가 있다.
  • 체크박스 선택 상태도 구현되어 있다.

숨은 리스크

  • useEffect dependency가 비어 있어 keyword가 바뀌어도 다시 검색하지 않는다.
  • API 응답을 as Project[]로 단언해 런타임 검증이 없다.
  • thumbnailUrl이 nullable이면 이미지가 깨진다.
  • dueDate가 date-only라면 new Date("yyyy-MM-dd")로 하루 밀림이 생길 수 있다.
  • key={index}는 삭제, 정렬, 필터에서 row state를 잘못 재사용할 수 있다.
  • selectedIds.push는 기존 배열을 mutate한다.
  • checkbox에 label이 없고 이미지 alt가 없다.
  • loading, error, empty 상태가 없다.

리뷰 질문

  • API 응답은 누가 검증하는가?
  • keyword 변경, 요청 취소, race condition은 어떻게 처리하는가?
  • 서버 상태를 직접 useEffectuseState로 관리할 이유가 있는가?
  • date-only와 timestamp를 구분했는가?
  • list key가 item identity를 안정적으로 표현하는가?
  • 선택 상태 업데이트가 불변 업데이트인가?
  • 접근성 이름과 loading/error/empty 상태가 있는가?

빠진 계약

  • API response schema 또는 생성 타입
  • nullable thumbnail 처리
  • 검색 debounce와 최소 글자 수
  • error response와 재시도 UI
  • date-only 포맷 기준
  • row identity와 client id 정책
  • 이미지 width/height와 alt 정책

개선된 설계 방향

서버 상태는 query key에 keyword를 넣고, 응답은 schema로 좁힌다.

const projectSchema = z.object({
  id: z.number(),
  title: z.string(),
  thumbnailUrl: z.string().url().nullable(),
  dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
 
const projectListSchema = z.array(projectSchema);
type Project = z.infer<typeof projectSchema>;
 
function formatDateOnly(value: string) {
  const [year, month, day] = value.split("-");
  return `${year}.${month}.${day}`;
}
 
function ProjectSearch({ keyword }: { keyword: string }) {
  const projectsQuery = useQuery({
    queryKey: ["projects", { keyword }],
    queryFn: async () => {
      const response = await fetch(`/api/projects?q=${encodeURIComponent(keyword)}`);
      if (!response.ok) throw new Error("프로젝트를 불러오지 못했습니다.");
      const body: unknown = await response.json();
      return projectListSchema.parse(body);
    },
  });
 
  const [selectedIds, setSelectedIds] = useState<number[]>([]);
 
  function toggleSelected(id: number) {
    setSelectedIds((prev) =>
      prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
    );
  }
 
  if (projectsQuery.isLoading) return <ProjectListSkeleton />;
  if (projectsQuery.isError) return <ErrorView message="검색에 실패했습니다." />;
  if (projectsQuery.data.length === 0) return <EmptyState message="프로젝트가 없습니다." />;
 
  return (
    <ul>
      {projectsQuery.data.map((project) => (
        <li key={project.id}>
          <label>
            <input
              type="checkbox"
              checked={selectedIds.includes(project.id)}
              onChange={() => toggleSelected(project.id)}
            />
            {project.title}
          </label>
          <img
            src={project.thumbnailUrl ?? "/images/default-project.png"}
            width={48}
            height={48}
            alt=""
          />
          <span>{formatDateOnly(project.dueDate)}</span>
        </li>
      ))}
    </ul>
  );
}

승인 조건

  • API 응답에 as를 붙이는 대신 검증 또는 신뢰 가능한 생성 타입을 쓴다.
  • keyword는 query key에 포함된다.
  • loading, error, empty, success 상태가 분리되어 있다.
  • list key는 서버 id 또는 안정적인 client id다.
  • 상태 배열을 직접 mutate하지 않는다.
  • date-only를 Date로 파싱하지 않는다.
  • 접근성 이름, 이미지 크기, fallback이 있다.

연결 문서

가져갈 판단 기준

  • TypeScript 타입 선언은 런타임 JSON 검증이 아니다.
  • effect dependency를 줄이기 위해 lint를 끄면 오래된 값 문제가 생긴다.
  • list key는 성능 힌트가 아니라 item identity다.
  • React state는 불변 업데이트를 기본으로 한다.

Review 03. UI 품질과 테스트 전략을 리뷰한다

제안된 설계안

개인 프로젝트 UI를 빠르게 만들기 위해 화면마다 Tailwind class를 직접 붙이고, modal과 dropdown을 직접 구현한다. 테스트는 e2e만 많이 작성하고, API mock은 손으로 만든다.

function DeleteCard({ project }: { project: Project }) {
  return (
    <div className="p-4 rounded bg-white">
      <div onClick={() => deleteProject(project.id)} className="text-red-500">
        삭제
      </div>
    </div>
  );
}
test("프로젝트를 삭제한다", async ({ page }) => {
  await page.goto("/projects");
  await page.locator(".text-red-500").click();
  await expect(page.locator(".project-row")).toHaveCount(0);
});

겉보기 장점

  • 구현이 빠르다.
  • 시각적으로 버튼처럼 보인다.
  • e2e가 실제 브라우저에서 돌아간다.
  • Tailwind로 화면을 빨리 조립할 수 있다.

숨은 리스크

  • div onClick은 기본 keyboard interaction과 button semantics가 없다.
  • 삭제 같은 destructive action에 confirm, loading, error, disabled 상태가 없다.
  • class 기반 테스트는 스타일 변경에 취약하다.
  • e2e가 많아지면 느리고 flaky해진다.
  • MSW mock이 실제 API 계약과 drift되면 테스트가 통과해도 운영에서 깨진다.
  • modal/dropdown을 직접 만들면 focus trap, escape close, restore focus, portal, z-index를 직접 책임져야 한다.

리뷰 질문

  • Button, Modal, Toast, Table의 상태 목록이 정리되어 있는가?
  • 버튼처럼 동작하는 것은 실제 button인가?
  • 사용자가 keyboard만으로 삭제 흐름을 완료할 수 있는가?
  • destructive action 실패 시 화면은 어떻게 복구되는가?
  • 테스트는 role, label, text처럼 사용자 언어를 쓰는가?
  • mock response는 OpenAPI나 실제 fixture와 연결되어 있는가?

빠진 계약

  • 공통 Button 기본 type, variant, loading, disabled 정책
  • Modal focus trap, escape close, restore focus 정책
  • Table loading, empty, error, pagination, sort 상태
  • ErrorView와 EmptyState 공통 문구 기준
  • API mock fixture 관리 방식
  • e2e smoke 범위와 component test 범위

개선된 설계 방향

삭제 버튼은 semantics와 상태를 가진 공통 컴포넌트를 사용한다.

function DeleteProjectButton({ projectId }: { projectId: number }) {
  const deleteMutation = useMutation({
    mutationFn: deleteProject,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["projects"] });
    },
  });
 
  return (
    <Button
      type="button"
      variant="danger"
      isLoading={deleteMutation.isPending}
      onClick={() => deleteMutation.mutate(projectId)}
    >
      삭제
    </Button>
  );
}

테스트는 사용자 행동과 접근성 이름으로 작성한다.

test("삭제 실패 시 사용자에게 복구 가능한 메시지를 보여준다", async () => {
  const user = userEvent.setup();
  render(<ProjectRow project={projectFixture} />);
 
  await user.click(screen.getByRole("button", { name: "삭제" }));
 
  expect(await screen.findByRole("alert")).toHaveTextContent("삭제하지 못했습니다.");
});

e2e는 로그인 후 핵심 CRUD smoke flow만 얇게 둔다. form validation, empty/error state, modal interaction은 component test로 내리는 편이 빠르고 안정적이다.

승인 조건

  • clickable div가 아니라 semantic HTML을 우선 사용한다.
  • 공통 Button, Modal, Table, EmptyState, ErrorView, Toast 상태가 정의되어 있다.
  • 접근성이 어려운 overlay는 headless library 또는 검증된 primitive 사용을 검토한다.
  • component test는 role, label, text 기반 query를 쓴다.
  • e2e는 핵심 happy path와 smoke flow에 집중한다.
  • MSW fixture는 실제 API 계약과 주기적으로 맞춘다.

연결 문서

가져갈 판단 기준

  • UI 품질은 장식보다 상태 일관성, semantic HTML, focus, error recovery에서 나온다.
  • e2e를 많이 두는 것보다 테스트 계층을 나누는 것이 유지보수에 좋다.
  • mock은 실제 API 계약과 가까울수록 테스트 신뢰도가 높다.
  • Tailwind는 빠르지만 반복 패턴은 컴포넌트로 추출해야 한다.

최종 종합 훈련

작은 프로젝트 관리 앱을 설계한다. 기능은 다음과 같다.

  • 로그인/로그아웃
  • 회원가입
  • protected route
  • 프로젝트 목록
  • 프로젝트 생성/수정/보관
  • 검색/filter/sort/page
  • 긴 제목과 빈 이미지가 있는 카드/테이블
  • 배포 환경 분리
  • 최소 테스트와 에러 수집

상황

당신은 백엔드 개발자이지만 이번 개인 프로젝트에서는 프론트도 직접 만든다. 목표는 기능을 많이 넣는 것이 아니라, 프론트엔드 개발자와 대화 가능한 수준의 설계 판단을 코드에 남기는 것이다.

제공된 Evidence

백엔드 API 초안은 다음과 같다.

POST /auth/login
POST /auth/refresh
GET /me
GET /projects?keyword=&status=active&sort=updatedAt.desc&page=1
POST /projects
PATCH /projects/{projectId}
POST /projects/{projectId}/archive

화면은 다음 상태를 가져야 한다.

auth: unknown | guest | authenticated | forbidden
project list: loading | error | empty | success
form: idle | validating | submitting | server-error | success
deploy: dev | stage | prod

해야 할 일

  • 인증 저장소와 refresh 흐름을 결정한다.
  • 401, 403, refresh 실패, logout의 UI 행동을 정한다.
  • 회원가입 form의 client/server validation 경계를 정한다.
  • 서버 error schema와 field error 매핑 규칙을 정한다.
  • 프로젝트 목록의 URL query param과 query key를 설계한다.
  • mutation 후 invalidate할 query 범위를 정한다.
  • loading, empty, error, permission denied UI를 나눈다.
  • Button, Modal, Table, Toast, EmptyState, ErrorView의 최소 상태를 정한다.
  • 배포 환경별 API origin, CORS origin, Cookie domain, cache 정책을 표로 만든다.
  • unit, component, e2e smoke test의 최소 범위를 정한다.

먼저 써보기

해설

종합 설계는 기술 선택 목록이 아니라 계약 목록이어야 한다. 예를 들어 “Cookie 인증”이라고만 쓰면 부족하다. 다음처럼 환경, 보안, 실패 행동까지 함께 적어야 한다.

영역결정맞출 계약
인증refresh token은 HttpOnly Secure Cookie, access token은 짧은 수명SameSite, Secure, CORS credentials, CSRF, logout 무효화
API errorcode, message, fieldErrors, traceIdfield path, 401/403, validation, 사용자 메시지
목록search/filter/sort/page는 URL과 query key에 포함page 기준, sort 안정성, totalCount 또는 hasNext
mutation생성/수정/보관 후 관련 목록/상세/summary invalidateoptimistic 적용 범위, rollback, 실패 code
formclient validation은 빠른 피드백, server validation은 최종 권위Zod schema, field error mapping, submit pending
UI공통 컴포넌트 상태 정의loading, disabled, focus, empty, error, mobile
배포Vite env는 build-time 공개 설정API URL, Cookie domain, CORS origin, HTML/asset cache
테스트unit/component/e2e를 나눈다MSW fixture, OpenAPI, smoke account, trace

초기 프로젝트 구조는 단순하게 시작한다.

src/
  api/
    auth.ts
    projects.ts
  schemas/
    auth.ts
    project.ts
  hooks/
    useCurrentUser.ts
    useProjects.ts
  components/
    ui/
      Button.tsx
      ErrorView.tsx
      EmptyState.tsx
      Modal.tsx
  pages/
    LoginPage.tsx
    ProjectListPage.tsx
    ProjectFormPage.tsx
  test/
    msw/
      handlers.ts
      fixtures.ts

이 구조에서 중요한 것은 폴더 이름보다 경계다. API 응답은 apischemas에서 검증하고, 서버 상태는 query hook에서 관리하고, UI 컴포넌트는 상태 표현을 일관되게 담당한다.

최소 테스트는 다음 정도로 시작한다.

  • unit: date-only formatter, API DTO to view model mapper, schema helper
  • component: 회원가입 server field error, 프로젝트 empty/error/success 상태, 보관 실패 rollback
  • e2e smoke: 로그인, 프로젝트 생성, 목록 확인, 보관

배포 전에는 다음 표를 채운다.

envfront originapi origincookie domaincors credentialshtml cacheasset cache
devhttp://localhost:5173http://localhost:8080localhosttrueno-cacheno-cache
stagehttps://stage-app.example.comhttps://stage-api.example.com.example.comtrueshortimmutable hashed
prodhttps://app.example.comhttps://api.example.com.example.comtrueshortimmutable hashed

마지막으로, 프로젝트 완성도를 판단할 때는 “화면이 일단 나온다”가 아니라 다음 질문에 답할 수 있어야 한다.

  • 인증이 만료되면 사용자는 어떤 흐름을 보는가?
  • 서버 field error는 정확한 input 아래에 붙는가?
  • 목록 조건은 새로고침과 공유 링크에서 복원되는가?
  • mutation 실패 시 화면은 거짓말하지 않는가?
  • 긴 제목, 빈 이미지, 권한 부족, 빈 목록을 fixture로 확인했는가?
  • 운영에서 터진 에러를 release와 traceId로 추적할 수 있는가?

연결 문서

가져갈 판단 기준

  • 좋은 개인 프로젝트는 기능 수보다 선택 기준과 실패 복구가 코드에 남아 있다.
  • 프론트 완성도는 인증, API, form, URL, UI 상태, 배포, 테스트가 서로 맞을 때 나온다.
  • 백엔드 개발자가 프론트와 잘 소통하려면 성공 응답보다 실패, 경계값, 운영 증거를 함께 말할 수 있어야 한다.

반복 기록표

날짜훈련내가 처음 놓친 것다시 본 연결 문서다음 프로젝트에 적용할 한 줄
Lab 01
Lab 02
Lab 03
Incident 01
Incident 02
Incident 03
Review 01
Review 02
Review 03
최종 종합 훈련