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를 같이 맞춘다”처럼 선택의 조건과 위험 신호를 함께 적는다.
훈련 방식
각 훈련은 같은 순서로 진행한다.
- 상황을 읽고 문제 영역을 나눈다.
- Evidence에서 브라우저, 코드, API, CSS, 배포 설정 중 무엇이 결정적인 단서인지 표시한다.
먼저 써보기에 내 결정을 적는다.- 힌트만 보고 답안을 보강한다.
- 해설을 읽고 내 답안에서 빠진 계약, 상태, 위험 신호를 체크한다.
- 연결 문서로 돌아가 해당 판단을 다시 읽는다.
한 번에 전부 풀 필요는 없다. 하루에 Lab 하나와 Incident 하나 정도면 충분하다. 같은 훈련을 두 번째 풀 때는 답안이 더 짧아지는 것이 좋다. 핵심 판단 기준이 몸에 들어오면 문장이 길 필요가 없다.
반복 학습 루프
- 첫 번째 회독: 해설을 보기 전에 “무엇을 확인할지”만 적어도 된다.
- 두 번째 회독: 내 선택과 백엔드와 맞춰야 할 계약을 함께 적는다.
- 세 번째 회독: 위험 신호와 테스트 또는 검증 방법까지 적는다.
- 실제 프로젝트 중 회독: 지금 작성 중인 코드, API, 배포 환경에 맞게 Evidence를 바꿔 다시 푼다.
훈련 목록
| 구분 | 훈련 | 주로 다루는 판단 |
|---|---|---|
| Lab | Lab 01. 로그인 저장소와 refresh 흐름 설계 | localStorage, Cookie, XSS, CSRF, 401 refresh, CORS |
| Lab | Lab 02. 회원가입 Form과 서버 field error 매핑 | React Hook Form, Zod, number input, server validation |
| Lab | Lab 03. 프로젝트 목록의 URL, query key, mutation 설계 | URL query, TanStack Query, loading/empty/error, invalidation |
| Incident | Incident 01. 운영 배포 후 Cookie 로그인이 풀린다 | SameSite, Secure, credentials, preflight, Application/Network |
| Incident | Incident 02. 보관 mutation 후 화면이 오래된 값을 보여준다 | stale cache, optimistic update, rollback, query key |
| Incident | Incident 03. env를 바꿨는데 예전 API URL을 호출한다 | Vite build env, CDN cache, release, traceId |
| Review | Review 01. API 계약 설계안을 리뷰한다 | nullable, error schema, pagination, timezone, enum |
| Review | Review 02. React/TypeScript 코드 변경을 리뷰한다 | as, ?., useEffect, stale closure, key, input value |
| Review | Review 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보다 오래 살고, 재발급 권한을 가진다.
HttpOnlyCookie는 XSS로 token 값을 읽어가는 위험을 줄이지만, Cookie 자동 전송 때문에 CSRF와 SameSite 판단이 따라온다.app.example.com과api.example.com은 origin은 다르지만 site 판단은 별도로 봐야 한다.- refresh endpoint가 401을 반환했을 때 다시 refresh를 시도하면 흐름이 반복된다.
해설
이 상황에서 refresh token을 localStorage에 오래 두는 것은 좋은 기본값이 아니다. localStorage는 JavaScript가 읽을 수 있으므로 XSS 상황에서 refresh token이 그대로 탈취될 수 있다. 특히 refresh token은 access token 재발급 권한이므로 탈취 피해가 길고 크다.
현실적인 기본 설계는 다음과 같다.
- refresh token은
HttpOnly; Secure; SameSiteCookie로 둔다. - 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과 재사용 탐지 여부
연결 문서
- 토큰은 localStorage, sessionStorage, cookie 중 어디에 둘까?
- 401이 났을 때 자동 재발급은 어디서 처리해야 할까?
- 01. 브라우저 HTTP DOM 상세
- 01. 인증 토큰 쿠키 보안 상세
- [[14. Web-Front 실전 케이스북#case-02-빠르게-만들려고-token을-localstorage에-넣으려-한다|Case 02. 빠르게 만들려고 token을
localStorage에 넣으려 한다]]
가져갈 판단 기준
- 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 방어이고, 데이터 무결성은 서버가 최종 책임진다.
연결 문서
- 01. Form Validation 에러 UX 상세
- number input 값이 실제로는 string인 문제를 어떻게 다룰까?
- uncontrolled input 경고는 왜 생길까?
- form validation은 프론트와 백엔드 중 어디까지 해야 할까?
- Case 12. 서버 field error가 input 아래에 붙지 않는다
가져갈 판단 기준
- 입력 중 값은 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 기준, totalCount와 hasNext는 API 계약에 포함되어야 한다.
UI 상태는 다음처럼 나눈다.
- loading: skeleton이나 spinner
- error: 재시도 가능한 실패 화면
- empty: 요청은 성공했지만 표시할 데이터 없음
- permission denied: 인증은 되었지만 권한 부족
- success: 데이터 표시
- mutation pending: row action 비활성화 또는 해당 row loading
optimistic update는 보관처럼 rollback이 쉬운 기능에 적용할 수 있다. 다만 실패하면 이전 cache로 되돌리고 사용자에게 알려야 한다. 권한 변경, 결제, 재고 차감에는 더 보수적인 흐름이 맞다.
연결 문서
- 01. API 연동과 서버 상태 상세
- 01. URL Layout Navigation 상세
- pagination, infinite scroll, search filter는 API와 어떻게 맞춰야 할까?
- [[01. 백엔드 개발자를 위한 프론트엔드 실전 판단#서버-상태를-usestate에-넣는-것과-tanstack-query를-쓰는-것은-뭐가-다를까|서버 상태를
useState에 넣는 것과 TanStack Query를 쓰는 것은 뭐가 다를까?]] - Case 10. 검색, 필터, 정렬, 페이지가 서로 엇갈린다
가져갈 판단 기준
- 목록 조건은 URL, query key, API parameter에 같은 의미로 반영한다.
- mutation 설계에는 “성공 후 어떤 query가 stale해지는가”가 포함된다.
- error, empty, permission denied는 사용자 행동이 다르므로 화면도 달라야 한다.
- optimistic update는 실패 복구 가능성과 실패 비용을 먼저 따진다.
Part 2. 장애 대응 훈련
Incident 01. 운영 배포 후 Cookie 로그인이 풀린다
Incident 상황
로컬에서는 로그인이 유지된다. 운영 배포 후에는 로그인 직후 /me가 한 번 성공하는 듯하다가 새로고침하면 다시 로그인 화면으로 간다. 사용자는 “로그인이 풀린다”고 말하고, 백엔드는 “로그인 API는 200이고 Set-Cookie도 내려간다”고 말한다.
현재 보이는 증상
- Chrome Application 탭에서
refresh_tokenCookie가 보이지 않거나, 보이지만/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-typeSet-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-Cookie에Secure가 있는지 확인한다.- 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 저장/첨부 여부를 확인하지 않는다.
복구 절차
- 프론트 fetch에
credentials: "include"를 넣는다. - 서버 CORS 응답을 특정 origin으로 제한한다.
- Cookie를
HttpOnly; Secure; SameSite=None또는 배포 구조에 맞는 SameSite 값으로 설정한다. - preflight
OPTIONS는 인증 없이 CORS 허용 정책을 반환하게 한다. - refresh 실패 시 query cache와 사용자 상태를 비우고 로그인 화면으로 보낸다.
- 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 또는 배포 체크가 필요한가?
연결 문서
- 01. 브라우저 HTTP DOM 상세
- 01. 인증 토큰 쿠키 보안 상세
- 01. 빌드 배포 환경 운영 상세
- CORS 문제는 프론트 문제인가 백엔드 문제인가?
- Case 05. 로컬에서는 되던 Cookie 인증이 운영에서 흔들린다
가져갈 판단 기준
- 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에 중복 보관한다.
복구 절차
- query key에 목록 parameter를 포함한다.
- mutation 성공 후 관련 목록과 summary를 invalidate한다.
- 상세 화면이 있다면 해당 상세 query도 갱신하거나 서버 응답으로 patch한다.
- optimistic update는 rollback 가능한 기능에만 제한한다.
- 실패하면 이전 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 가능성을 따졌는가?
연결 문서
- 01. API 연동과 서버 상태 상세
- optimistic update는 언제 위험한가?
- 01. 컴포넌트 상태 렌더링 상세
- 01. 테스트 협업 디버깅 상세
- Case 07. 보관 버튼을 눌렀는데 목록이 그대로다
가져갈 판단 기준
- 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/html5분 안에 확인할 것
- 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 없이 사용자 신고만 기다린다.
복구 절차
VITE_API_BASE_URL변경 후 새 build artifact를 만든다.- HTML은 짧게 cache하고, hash가 붙은 JS/CSS asset은 길게 cache한다.
- 필요하면 CDN purge로 오래된 HTML을 제거한다.
- API URL helper는 relative path만 받도록 제한하고 base path prefix 보존을 테스트한다.
- Sentry release version과 source map 업로드를 배포 파이프라인에 넣는다.
- 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가 들어 있는가?
연결 문서
- 01. 빌드 배포 환경 운영 상세
- env 변수가 런타임이 아니라 빌드 타임에 박히는 문제는 왜 중요할까?
- 런타임 구분이 중요한가?
- Case 28. 운영 env를 바꿨는데 예전 API URL을 계속 호출한다
가져갈 판단 기준
- 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"
}겉보기 장점
- 응답 구조가 단순하다.
- 화면에 필요한 기본 필드가 들어 있다.
dueDate와updatedAt이 문자열이라 직렬화가 쉽다.- 성공 응답만 보면 빠르게 구현할 수 있다.
숨은 리스크
ownerName의null의미가 불명확하다. 아직 없음인지, 권한 때문에 숨김인지, 탈퇴한 사용자인지 화면 행동이 달라진다.- pagination metadata가 부족하다.
pageSize,totalCount,hasNext, sort 안정성이 없으면 UI가 다음 페이지를 추측해야 한다. dueDate는 date-only이고updatedAt은 timestamp인데 의미가 문서화되어 있지 않다.- error response에
code,fieldErrors,traceId가 없어 프론트가 사용자 행동을 나누기 어렵다. - enum 확장 정책이 없다.
status에blocked가 추가되면 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가 프론트 관측성과 연결된다.
연결 문서
- OpenAPI만 있으면 충분한가?
- 프론트 개발자가 백엔드에게 가장 답답해하는 API는 어떤 API인가?
- nullable API 응답 타입을 어떻게 표현해야 할까?
- 01. 비동기 타입 설계 상세
- 01. 테스트 협업 디버깅 상세
가져갈 판단 기준
- 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가 있다. - 체크박스 선택 상태도 구현되어 있다.
숨은 리스크
useEffectdependency가 비어 있어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은 어떻게 처리하는가?- 서버 상태를 직접
useEffect와useState로 관리할 이유가 있는가? - 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이 있다.
연결 문서
- [[02. 백엔드 개발자를 위한 프론트엔드 문법 함정과 디버깅#typescript의-any-unknown-as-non-null-assertion은-언제-위험할까|TypeScript의
any,unknown,as, non-null assertion은 언제 위험할까?]] - [[02. 백엔드 개발자를 위한 프론트엔드 문법 함정과 디버깅#useeffect-dependency-array-실수는-왜-자주-생길까|
useEffectdependency array 실수는 왜 자주 생길까?]] - 객체와 배열 mutation 때문에 React 화면이 안 바뀌는 이유는 무엇일까?
- React list key를 index로 두면 언제 문제가 될까?
- 01. 성능 접근성 실전 상세
가져갈 판단 기준
- 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 계약과 주기적으로 맞춘다.
연결 문서
- 02. Component UI Design System 상세
- 01. 성능 접근성 실전 상세
- 01. 테스트 협업 디버깅 상세
- 접근성은 어디까지 챙겨야 하는가?
- Case 27. 클릭은 되지만 keyboard와 테스트가 모두 불안하다
가져갈 판단 기준
- 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 error | code, message, fieldErrors, traceId | field path, 401/403, validation, 사용자 메시지 |
| 목록 | search/filter/sort/page는 URL과 query key에 포함 | page 기준, sort 안정성, totalCount 또는 hasNext |
| mutation | 생성/수정/보관 후 관련 목록/상세/summary invalidate | optimistic 적용 범위, rollback, 실패 code |
| form | client 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 응답은 api와 schemas에서 검증하고, 서버 상태는 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: 로그인, 프로젝트 생성, 목록 확인, 보관
배포 전에는 다음 표를 채운다.
| env | front origin | api origin | cookie domain | cors credentials | html cache | asset cache |
|---|---|---|---|---|---|---|
| dev | http://localhost:5173 | http://localhost:8080 | localhost | true | no-cache | no-cache |
| stage | https://stage-app.example.com | https://stage-api.example.com | .example.com | true | short | immutable hashed |
| prod | https://app.example.com | https://api.example.com | .example.com | true | short | immutable hashed |
마지막으로, 프로젝트 완성도를 판단할 때는 “화면이 일단 나온다”가 아니라 다음 질문에 답할 수 있어야 한다.
- 인증이 만료되면 사용자는 어떤 흐름을 보는가?
- 서버 field error는 정확한 input 아래에 붙는가?
- 목록 조건은 새로고침과 공유 링크에서 복원되는가?
- mutation 실패 시 화면은 거짓말하지 않는가?
- 긴 제목, 빈 이미지, 권한 부족, 빈 목록을 fixture로 확인했는가?
- 운영에서 터진 에러를 release와 traceId로 추적할 수 있는가?
연결 문서
- 학습 순서
- 01. 백엔드 개발자를 위한 프론트엔드 실전 판단
- 02. 백엔드 개발자를 위한 프론트엔드 문법 함정과 디버깅
- 01. API 연동과 서버 상태 상세
- 01. Form Validation 에러 UX 상세
- 01. 인증 토큰 쿠키 보안 상세
- 01. URL Layout Navigation 상세
- 02. Component UI Design System 상세
- 01. 빌드 배포 환경 운영 상세
- 01. 테스트 협업 디버깅 상세
가져갈 판단 기준
- 좋은 개인 프로젝트는 기능 수보다 선택 기준과 실패 복구가 코드에 남아 있다.
- 프론트 완성도는 인증, API, form, URL, UI 상태, 배포, 테스트가 서로 맞을 때 나온다.
- 백엔드 개발자가 프론트와 잘 소통하려면 성공 응답보다 실패, 경계값, 운영 증거를 함께 말할 수 있어야 한다.
반복 기록표
| 날짜 | 훈련 | 내가 처음 놓친 것 | 다시 본 연결 문서 | 다음 프로젝트에 적용할 한 줄 |
|---|---|---|---|---|
| Lab 01 | ||||
| Lab 02 | ||||
| Lab 03 | ||||
| Incident 01 | ||||
| Incident 02 | ||||
| Incident 03 | ||||
| Review 01 | ||||
| Review 02 | ||||
| Review 03 | ||||
| 최종 종합 훈련 |