D-8: Server Actions & Route Handlers ② — 기다림과 문지기
목차 64
안녕하세요, 홍순구입니다. 시작하기 전에 지난 시간에 만든 팔로우 버튼을 한 번 눌러보고 오세요.
눌러보셨나요? 버튼이 "보내는 중…"으로 바뀌고 반 초쯤 지나서 "팔로잉"이 되는데, 팔로우는 하루에 몇 번 안 누르는 버튼이라 그 정도 기다림은 충분히 견딥니다.
그런데 좋아요는요? 인스타그램에서 하트를 누르면 반 초 뒤가 아니라 누르는 순간 빨개지죠.
오늘은 그 반 초를 없앱니다. 없애는 방법이 조금 뻔뻔해서, 서버한테 물어보기도 전에 화면부터 바꿔놓고 서버가 안 된다고 하면 그때 되돌립니다.
하나 더 있습니다. 지난 시간에 세운 문지기는 지금 "이 사람 있나"만 보고 있는데, 오늘은 "이 사람이 들어와도 되나"도 보게 합니다. 문지기를 세우고 나면 반드시 알아야 하는 게 하나 있는데, 바로 문지기가 못 보는 곳이 어디인지입니다.
💡 오늘 수업의 핵심 — "먼저 바꿔놓고, 아니면 되돌립니다"
지난 시간에 우리는 요청을 보낼 수 있게 됐는데, 보낼 수 있으니까 이제 보내면 기다려야 한다는 새로운 문제가 생겼어요.
기다림을 없애는 방법은 두 가지인데, 서버를 빠르게 만들거나 기다리는 티를 안 내거나예요. 오늘 배우는 것은 두 번째입니다. 서버가 400밀리초를 쓰는 건 그대로인데, 화면은 2밀리초 만에 바뀝니다.
그리고 화면을 앞질러 바꿔놓는 순간, 우리가 지난 시간에 얻었던 것 하나를 내놓게 됩니다. 무엇을 내놓는지도 오늘 눈으로 확인합니다.
🎯 학습 목표
useFormStatus로 폼 안 어디에 있든 "보내는 중"을 아는 버튼을 만듭니다.useOptimistic으로 서버를 기다리지 않고 화면을 먼저 바꾸고, 거절당하면 되돌아오게 합니다.proxy.ts에 인증 검사를 얹고, 검사 순서가 무엇을 바꾸는지 재봅니다.- 서버 액션이 공개된 통로라는 것을 실제로 확인하고, 그래서 어디에서 막아야 하는지 정합니다.
오늘의 여정
1. 하트는 기다릴 수 없습니다 좋아요를 서버까지 보낸다 -- 413ms
2. 버튼을 따로 떼면 useFormStatus -- 부모 폼만 본다
3. 먼저 바꾸고 나중에 맞춥니다 useOptimistic -- 2ms
4. 거절당하면 되돌아옵니다 롤백을 우리가 안 짰는데 되돌아온다
5. 대신 무엇을 팔았을까요 폼이 평범한 HTML 이 아니게 된다
6. 문지기에게 두 번째 일 인증 검사를 앞에 둔다
7. 액션은 공개 엔드포인트입니다 문지기가 못 보는 곳
8. 그래서 액션 안에서 확인합니다 묶은 인자는 신원이 아니다
Step 1: "하트는 기다릴 수 없습니다"
지난 시간 첫 Step에서 좋아요 버튼을 열어보고 이렇게 말했었죠. 이 버튼은 거짓말을 하고 있다고요. 숫자는 오르는데 서버는 모른다고요.
그때 고치지 않고 남겨뒀습니다. 오늘 고칩니다.
지금 이 버튼이 하는 일
먼저 현재 상태를 다시 봅시다.
// apps/web-next/app/components/LikeButton.tsx
export function LikeButton({ likeCount, liked }: { likeCount: number; liked: boolean }) {
const [pressed, setPressed] = useState(liked);
const [count, setCount] = useState(likeCount);
function toggle() {
setPressed(!pressed);
setCount(pressed ? count - 1 : count + 1);
}
// ...
}
브라우저 안에서만 숫자가 오릅니다. 새로고침하면 사라지고요. B-2에서 처음 만든 그대로예요.
지난 시간에 배운 방식으로 이걸 서버까지 잇는데, 팔로우 때와 똑같은 순서로 서버 함수를 하나 만들고, 폼에 건네고, 결과를 화면에 연결하고요.
좋아요 서버 함수
// apps/web-next/app/actions/like.ts
'use server';
import { cookies } from 'next/headers';
const API_BASE = 'http://localhost:8090/api';
export type LikeState = {
liked: boolean;
likeCount: number;
message: string | null;
};
export async function toggleLike(postId: number, previous: LikeState): Promise<LikeState> {
const me = (await cookies()).get('me')?.value;
if (me === undefined) {
return { ...previous, message: '로그인이 필요해요' };
}
const response = await fetch(`${API_BASE}/posts/${postId}/like`, {
method: 'POST',
headers: { 'X-Actor': me },
});
const envelope = await response.json();
if (!response.ok || !envelope.success) {
return { ...previous, message: envelope.message ?? '좋아요를 저장하지 못했어요' };
}
return { liked: envelope.data.liked, likeCount: envelope.data.likeCount, message: null };
}
팔로우 함수와 판박이죠. 인자를 두 개 받는 것도 같습니다. 앞의 postId는 우리가 미리 묶어 보낼 값이고, 뒤의 previous는 React가 채워주는 직전 상태예요.
실패했을 때 { ...previous, message }를 돌려주는 것도 그대로입니다. 저장이 안 됐으니 알던 상태는 건드리지 않고 사유만 얹는 겁니다.
⚠️ 연습용 API 서버의 좋아요는 원래 출입증(토큰)을 요구했습니다. C-6에서 SPA가 그렇게 쓰고 있었거든요. 지금 우리 앱은 출입증 대신
me쿠키를 쓰니까, 팔로우와 같은 방식으로도 받아주도록 서버를 손봐뒀습니다. SPA 쪽 동작은 그대로예요.
버튼을 폼으로
// apps/web-next/app/components/LikeButton.tsx
const initial: LikeState = { liked, likeCount, message: null };
const [state, formAction, pending] = useActionState(toggleLike.bind(null, postId), initial);
return (
<form action={formAction}>
<button
type="submit"
aria-pressed={state.liked}
disabled={pending}
className="mt-2 text-sm text-black/60 disabled:opacity-40"
>
{state.liked ? '♥' : '♡'} 좋아요 {state.likeCount}
{pending ? ' (보내는 중…)' : ''}
</button>
{state.message !== null && (
<p aria-live="polite" className="text-sm text-black/60">
{state.message}
</p>
)}
</form>
);
useState 두 개가 사라졌습니다. 이제 화면에 나오는 숫자는 브라우저가 세는 값이 아니라 서버가 돌려준 값이에요.
toggleLike.bind(null, postId)로 게시물 번호를 미리 묶는 것도 팔로우와 같습니다. 피드에 카드가 열 장이니 버튼도 열 개고, 각자 다른 번호를 묶어 들고 있습니다.
눌러보면
하트를 눌러보고, 새로고침도 해보세요.
숫자가 남아 있는데, 서버가 기억하고 있다는 뜻이니까 지난 시간에 우리가 산 것이 여기서도 값을 하는 셈이에요.
그런데 누르고 나서 무슨 일이 있었는지 보셨나요. 버튼이 "보내는 중…"으로 바뀌고 잠깐 멈춰 있었는데, 재보면 이렇습니다.
클릭 -----> 화면이 바뀐 시각
413ms
400밀리초는 연습용 서버가 일부러 넣어둔 지연입니다. 진짜 서버라면 더 짧겠지만, 지구 반대편에 있는 서버라면 더 길 수도 있어요. 어느 쪽이든 0은 아닙니다.
팔로우는 이 정도 기다림을 견뎠지만, 하트는 못 견뎌요. 인스타그램에서 하트가 반 초 뒤에 빨개진다면 아무도 두 번은 안 누를 겁니다.
💡 한 줄 정리
좋아요를 서버까지 이었더니 숫자가 새로고침에도 남습니다. 대신 누를 때마다 413밀리초를 기다리게 됐어요.
🙋 학생 질문 — "지난 시간 팔로우랑 코드가 거의 똑같은데, 새로 배운 게 있나요?"
없습니다. 그게 오늘 Step 1의 요점이에요.
서버 함수를 만들고 폼에 건네는 방식은 지난 시간에 다 배웠습니다. 오늘은 그 방식을 좋아요에 그대로 적용해서 같은 문제가 다시 나타나는지 보려는 거예요. 나타났죠. 기다림이요.
새로운 도구는 Step 2부터 나옵니다. Step 1은 문제를 눈앞에 다시 데려다 놓는 단계입니다. 팔로우에서는 참을 만했던 것이 좋아요에서는 못 참을 일이 된다는 것, 이게 오늘 배울 도구가 왜 따로 있는지에 대한 답입니다.
Step 2: "버튼을 따로 떼면 어떻게 알까요"
우리 앱에는 이제 서버로 무언가를 보내는 버튼이 둘인데, 팔로우와 좋아요요. 두 코드를 나란히 놓아 봅시다.
// 팔로우
<button type="submit" disabled={pending} className="...">
{pending ? '보내는 중…' : state.following === true ? '팔로잉' : '팔로우'}
</button>
// 좋아요
<button type="submit" disabled={pending} className="...">
{state.liked ? '♥' : '♡'} 좋아요 {state.likeCount}
{pending ? ' (보내는 중…)' : ''}
</button>
같은 규칙이 두 번 적혀 있는데, 보내는 중이면 잠그고 보내는 중이면 그렇게 말하는 식이에요. 버튼이 셋 넷으로 늘면 이 규칙도 셋 넷이 됩니다.
당연히 컴포넌트로 뽑고 싶어집니다. 그런데 뽑으려고 하면 곧바로 막혀요.
뽑으면 pending을 못 받습니다
pending은 useActionState가 돌려준 값입니다. 그 훅은 폼을 렌더하는 컴포넌트에 있어요. 버튼을 밖으로 뽑으면 버튼은 그 값을 모릅니다.
LikeButton ── useActionState 가 여기 있다 → pending 을 안다
└ form
└ SubmitButton ── 여기서는 pending 을 모른다
props로 내려주면 되지 않냐고요? 이 경우엔 됩니다. 한 칸이니까요.
폼이 깊어지면 이야기가 달라지는데, 폼 안에 툴바가 있고 툴바 안에 버튼 묶음이 있고 그 안에 제출 버튼이 있다면 pending을 네 칸 아래로 내려야 해요. C-3에서 봤던 그 배달 문제가 그대로 돌아옵니다.
useFormStatus
React는 이 배달을 없애는 훅을 따로 갖고 있습니다.
// apps/web-next/app/components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
import type { ReactNode } from 'react';
export function SubmitButton({
children,
pendingLabel,
pressed,
className,
}: {
children: ReactNode;
pendingLabel: string;
pressed?: boolean;
className?: string;
}) {
const { pending } = useFormStatus();
return (
<button type="submit" aria-pressed={pressed} disabled={pending} className={className}>
{pending ? pendingLabel : children}
</button>
);
}
useFormStatus()는 인자를 하나도 안 받습니다. 어느 폼을 보라고 말해주지 않아요. 그런데 자기를 감싼 폼이 보내는 중인지를 압니다.
가져오는 곳이 react가 아니라 react-dom이라는 것도 눈여겨보세요. 폼은 브라우저 화면에만 있는 개념이라 DOM 쪽 패키지에 들어 있습니다.
돌려주는 값은 넷입니다. pending 말고도 data(보내는 중인 FormData), method, action이 함께 옵니다. 오늘 쓰는 건 pending 하나예요.
두 버튼이 같은 버튼을 씁니다
// apps/web-next/app/components/FollowButton.tsx
const [state, formAction] = useActionState(follow.bind(null, username), initial);
return (
<form action={formAction} className="mb-4">
<SubmitButton
pendingLabel="보내는 중…"
className="rounded border border-black/15 px-3 py-1 text-sm disabled:opacity-40"
>
{state.following === true ? '팔로잉' : '팔로우'}
{/* ... */}
</form>
);
useActionState에서 받아오던 값이 셋에서 둘로 줄었습니다. pending을 여기서 쓸 일이 없어졌으니까요.
좋아요 버튼도 똑같이 바뀝니다. 같은 SubmitButton을 쓰고, 잠그는 규칙과 "보내는 중" 규칙은 이제 앱 전체에 한 번만 적혀 있습니다.
규칙 하나
useFormStatus에는 반드시 지켜야 할 규칙이 있습니다. 자기를 감싼 부모 폼만 봅니다.
// 이렇게 쓰면 pending 은 늘 false 입니다
function Form() {
const { pending } = useFormStatus();
return <form action={submit}>...</form>;
}
폼을 렌더하는 그 컴포넌트에서 부르면 아무것도 못 보는데, 그 컴포넌트 입장에서 폼은 자식이지 부모가 아니기 때문에 이 훅을 쓰려면 버튼이 별도 컴포넌트여야 합니다.
불편해 보이지만 반대로 생각하면 이게 이 훅의 존재 이유예요. 폼 안 어디에 있든 상태를 알 수 있게 하려고 만든 훅이라, 폼 안에 있어야 한다는 조건이 붙는 겁니다.
💡 한 줄 정리
useFormStatus는 인자 없이 부모 폼의 상태를 읽습니다. 덕분에 제출 버튼을 앱 전체가 공유하는 컴포넌트 하나로 만들 수 있어요.
🙋 학생 질문 — "그냥 props로 내려주면 안 되나요? 우리 폼은 얕은데요."
지금은 됩니다. 한 칸이니까요.
이 훅이 값을 하는 곳은 버튼을 만든 사람과 폼을 만든 사람이 다를 때입니다. 디자인 시스템의 Button을 떠올려 보세요. E-5에서 들여온 컴포넌트들처럼요. 그 버튼을 만드는 사람은 어떤 폼에 쓰일지 모릅니다. 그런데도 제출 중일 때 잠기게 만들어야 해요.
props로 받게 설계하면 그 버튼을 쓰는 모든 사람이 매번 pending을 넘겨야 하고, 한 명이라도 빼먹으면 그 폼만 조용히 두 번 제출됩니다. useFormStatus를 쓰면 버튼이 스스로 알아내니 빼먹을 수가 없습니다.
우리 앱에서도 지금은 폼이 둘이지만, 회원가입·댓글·설정으로 늘어나면 같은 상황이 됩니다.
Step 3: "먼저 바꾸고 나중에 맞춥니다"
버튼이 정리됐으니 원래 문제로 돌아옵시다. 413밀리초요.
이 시간을 줄일 수는 없습니다. 서버가 저 멀리 있고, 네트워크는 빛보다 빠를 수 없으니까요.
그래서 방향을 바꿉니다. 기다리는 시간을 줄이는 대신, 기다리는 동안 화면을 미리 바꿔놓습니다.
생각해보면 우리는 답을 이미 알고 있습니다
하트를 눌렀을 때 어떻게 될지, 서버한테 안 물어봐도 알잖아요. 안 눌려 있었으면 눌린 상태가 되고 숫자가 하나 오릅니다. 눌려 있었으면 반대고요.
99번 중 99번은 그렇게 됩니다. 실패하는 1번을 위해 99번을 기다리게 하는 게 지금 상황이에요.
그러니 이렇게 합시다. 일단 아는 대로 바꿔놓고, 서버가 답하면 진짜 값으로 맞추고, 거절하면 되돌립니다.
이걸 낙관적 업데이트(optimistic update)라고 부릅니다. "잘될 거라고 보고 먼저 움직인다"는 뜻이에요.
지금까지 클릭 ──────── 413ms 대기 ────────> 화면 변경
낙관적 업데이트 클릭 ─> 화면 변경(2ms)
└─── 413ms 후 서버 값으로 확정 또는 되돌림
useOptimistic
React 19가 이걸 위한 훅을 갖고 있습니다.
// apps/web-next/app/components/LikeButton.tsx
const [state, formAction] = useActionState(toggleLike.bind(null, postId), initial);
// 첫 인자는 진짜 값, 둘째 인자는 "이렇게 될 거예요" 를 만드는 함수다.
// 여기서 만든 값은 액션이 도는 동안만 살고, 끝나면 다시 state 로 돌아간다.
const [optimistic, setOptimistic] = useOptimistic(state, (previous, next: boolean) => ({
liked: next,
likeCount: previous.likeCount + (next ? 1 : -1),
message: null,
}));
인자가 둘입니다.
첫째는 진짜 값입니다. 여기서는 useActionState가 들고 있는 서버 확정 상태예요.
둘째는 "이렇게 될 거예요"를 만드는 함수입니다. 지금 상태와 우리가 넘긴 값을 받아서 새 상태를 만듭니다. 하트를 켤 거면 숫자를 하나 올리고, 끌 거면 하나 내리고요.
부르는 곳이 중요합니다
// apps/web-next/app/components/LikeButton.tsx
<form
action={() => {
// 먼저 화면을 바꿔놓고
setOptimistic(!optimistic.liked);
// 그다음에 서버로 보낸다
return formAction();
}}
>
<SubmitButton
pressed={optimistic.liked}
className="mt-2 text-sm text-black/60 disabled:opacity-40"
>
{optimistic.liked ? '♥' : '♡'} 좋아요 {optimistic.likeCount}
</SubmitButton>
{state.message !== null && (
<p aria-live="polite" className="text-sm text-black/60">
{state.message}
</p>
)}
</form>
바뀐 곳이 셋입니다.
첫째, <form action>에 formAction을 그대로 건네지 않고 함수로 한 겹 감쌌습니다. setOptimistic을 부를 자리가 필요했거든요.
둘째, 화면에 그리는 값이 state에서 optimistic으로 바뀌어, 이제 하트와 숫자는 낙관적 값을 봅니다.
셋째, pendingLabel을 안 넘깁니다. 이미 하트가 바뀌어 있는데 "보내는 중…"이라고 덮어쓰면 낙관적 업데이트를 한 의미가 없어서, SubmitButton의 pendingLabel을 선택 항목으로 바꿨습니다. 안 넘기면 내용이 그대로 보이고 잠기기만 합니다.
setOptimistic은 아무 데서나 부르면 안 됩니다. 액션 안에서 불러야 해요. 폼의 action 안이나 startTransition 안이 그런 곳입니다. 밖에서 부르면 값이 잠깐 보였다가 곧바로 사라지고 React가 경고를 냅니다.
재봅시다
같은 버튼을 다시 눌러보고, 화면이 언제 바뀌는지 재보면 이렇습니다.
t = 2ms ♡ 좋아요 1240 <- 낙관적 값이 먼저 들어온다
t = 413ms ♡ 좋아요 1240 <- 서버가 확정. 값이 같아서 화면은 안 흔들린다
2밀리초입니다. 사람은 이걸 지연으로 못 느껴요.
413밀리초는 그대로입니다. 서버는 여전히 그만큼 걸려요. 달라진 건 그 시간 동안 사용자가 무엇을 보고 있느냐입니다.
낙관적 값은 오래 안 삽니다
여기서 이 훅의 성격을 하나 짚고 가야 합니다. useOptimistic이 만든 값은 액션이 도는 동안에만 삽니다. 액션이 끝나면 첫째 인자로 준 진짜 값으로 돌아가요.
우리가 따로 정리하지 않아도 그렇게 됩니다. 그리고 이 성질이 다음 Step의 전부입니다.
💡 한 줄 정리
useOptimistic은 진짜 값과 "이렇게 될 거예요"를 함께 들고 있다가, 액션이 도는 동안만 후자를 보여줍니다. 413밀리초가 2밀리초가 됐어요.
🙋 학생 질문 — "그냥 useState로 미리 바꿔놓으면 안 되나요?"
됩니다. 그게 C-7에서 우리가 했던 방식인데, 그때는 TanStack Query의 onMutate에서 캐시를 미리 고치고 onError에서 되돌렸습니다.
차이는 되돌리는 코드를 누가 쓰느냐입니다.
useState로 하면 성공했을 때도 실패했을 때도 우리가 직접 정리해야 합니다. 실패 처리를 빼먹으면 화면에 틀린 숫자가 남아요. 액션을 두 번 연달아 눌렀을 때 어느 응답이 먼저 올지도 우리가 신경 써야 하고요.
useOptimistic은 낙관적 값의 수명을 React가 관리합니다. 액션이 끝나면 사라지는 값이라, 끝난 뒤에 남아 있을 방법이 없어요. 다음 Step에서 이게 실패 처리를 어떻게 공짜로 만드는지 보겠습니다.
Step 4: "거절당하면 되돌아옵니다"
지금까지는 서버가 늘 받아줬습니다. 거절하면 어떻게 되는지 봅시다.
연습용 서버에 실패를 만드는 스위치가 있어요. 지난 시간 팔로우 때 썼던 것과 같은 방식입니다.
LIKE_FAIL_EVERY=2 API_STUB_LOG=1 node apps/api-stub/server.mjs
두 번에 한 번씩 서버가 500으로 거절합니다.
눌러봅시다
하트를 누르고 화면이 어떻게 움직이는지 재보면 이렇습니다.
t = 1ms ♥ 좋아요 1241 <- 먼저 바꿔놓고
t = 414ms ♡ 좋아요 1240 <- 정확히 되돌아왔다
좋아요를 저장하지 못했습니다 <- 사유가 붙었다
하트가 잠깐 빨개졌다가 원래대로 돌아오는데, 숫자도 마찬가지고요. 그리고 왜 안 됐는지가 버튼 아래에 뜹니다.
되돌리는 코드를 우리는 안 썼습니다
LikeButton.tsx를 다시 보세요. 되돌리는 코드가 어디 있나요.
없습니다. catch도 없고 setOptimistic을 원래대로 부르는 곳도 없어요.
되돌아온 이유는 이겁니다. 낙관적 값은 액션이 도는 동안만 살고, 끝나면 첫째 인자였던 state로 돌아가는데, 서버가 거절했으니 액션은 이렇게 답했어요.
// apps/web-next/app/actions/like.ts
if (!response.ok || !envelope.success) {
// 실패하면 알던 상태는 그대로 두고 사유만 얹는다.
return { ...previous, message: envelope.message ?? '좋아요를 저장하지 못했어요' };
}
...previous입니다. liked와 likeCount는 누르기 전 그대로고, message만 새로 붙었어요. 그러니 state가 안 바뀌었고, 낙관적 값이 사라지면서 화면이 원래 값으로 돌아온 겁니다.
롤백은 우리가 짠 기능이 아니라, 진짜 값을 안 바꾼 결과입니다. 이게 useOptimistic을 쓰는 진짜 이유예요.
그래서 실패 갈래를 어떻게 쓰느냐가 중요합니다
만약 실패했을 때 이렇게 돌려줬다면 어떻게 될까요.
// 이렇게 쓰면 안 됩니다
return { liked: false, likeCount: 0, message: '좋아요를 저장하지 못했어요' };
실패했는데 하트가 꺼지고 숫자가 0이 됩니다. 실패가 성공처럼 보이거나, 더 나쁘게는 없던 변화가 생겨요.
지난 시간 과제에서 팔로우로 같은 것을 확인했었죠. 실패했는데 "팔로잉"이 "팔로우"로 바뀌는 문제요. 낙관적 업데이트를 얹으면 이 실수가 훨씬 눈에 잘 띕니다. 화면이 갔다가 돌아오는 게 아니라 갔다가 엉뚱한 데로 가버리니까요.
C-7과 무엇이 달라졌나
C-7에서도 낙관적 업데이트를 했습니다. 그때와 지금의 차이를 정리하면 이렇습니다.
| C-7 (SPA) | D-8 (Next.js) | |
|---|---|---|
| 미리 바꾸는 대상 | 브라우저 안의 캐시 | 화면에 그릴 값 |
| 되돌리는 방법 | onError에서 이전 캐시를 직접 복원 |
진짜 값이 안 바뀌면 저절로 |
| 확정하는 값의 출처 | 다시 요청해서 받아온 값 | 액션이 돌려준 값 |
| 실패 사유 표시 | 별도 상태로 관리 | 액션 결과에 함께 담긴다 |
방향은 같고 손이 덜 갑니다. 서버가 답을 돌려주는 통로가 이미 있으니 실패 사유를 따로 나를 필요가 없어졌어요.
💡 한 줄 정리
실패하면 화면이 저절로 되돌아옵니다. 우리가 롤백을 짠 게 아니라, 액션이 진짜 값을 안 바꿨기 때문이에요.
🙋 학생 질문 — "빠르게 여러 번 누르면 어떻게 되나요?"
지금은 못 누릅니다. SubmitButton이 보내는 중일 때 버튼을 잠그거든요.
잠그지 않으면 어떻게 될까요. React는 같은 폼의 액션들을 순서대로 처리합니다. 세 번 누르면 세 번의 낙관적 업데이트가 쌓이고, 응답이 올 때마다 하나씩 확정돼요. 순서가 뒤집히지는 않습니다.
문제는 화면이 아니라 서버입니다. 좋아요는 누를 때마다 켜고 끄는 동작이라, 세 번 누르면 서버 상태가 세 번 바뀝니다. 사용자가 원한 게 "최종적으로 켜짐" 이었어도 서버에는 세 번의 변경이 남아요.
진짜 서비스라면 마지막 것만 보내거나(디바운스), 켜고 끄기 대신 "이 상태로 만들어줘"를 보내는 방식으로 바꿉니다. 지금은 잠그는 쪽으로 단순하게 갑니다. 다만 잠그면 연타가 안 된다는 것도 대가예요. 인스타그램은 안 잠급니다.
Step 5: "대신 무엇을 팔았을까요"
여기서 한 번 멈추고 우리가 방금 무엇을 바꿨는지 확인하고 갑시다.
지난 시간에 이런 이야기를 했었죠. 서버 액션을 쓴 폼은 평범한 HTML 폼이라고요. 소스 보기로 열어보면 진짜 <form>이 있고, curl로 제출해도 그대로 동작했습니다. 자바스크립트가 한 줄도 없어도 로그인이 됐어요.
그게 아직도 사실일까요. 확인해봅시다.
소스 보기를 다시 엽니다
Step 2까지의 좋아요 폼은 이렇게 그려졌습니다.
<form action="" encType="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_REF_11" />
<input type="hidden" name="$ACTION_11:0" value="..." />
<input type="hidden" name="$ACTION_11:1" value="..." />
<input type="hidden" name="$ACTION_KEY" value="..." />
<button type="submit">♡ 좋아요 1240</button>
</form>
지난 시간에 본 그 모양 그대로입니다. method="POST"가 있고 숨은 입력이 넷 있어요.
Step 3 이후는 이렇습니다.
<form action="javascript:throw new Error('React form unexpectedly submitted.')">
<button type="submit">♡ 좋아요 1240</button>
</form>
숨은 입력이 하나도 없고, action 속성에는 "이 폼이 제출되면 안 되는데 제출됐다"는 오류를 던지는 코드가 들어 있어요.
왜 이렇게 됐을까요
우리가 <form action>에 서버 함수 대신 우리가 만든 함수를 건넸기 때문입니다.
// Step 2 까지 — 서버 함수를 그대로 건넸다
<form action={formAction}>
// Step 3 부터 — 우리 함수로 한 겹 감쌌다
<form action={() => { setOptimistic(...); return formAction(); }}>
React는 서버 함수를 건네받으면 그 함수의 식별자를 숨은 입력으로 그려 넣습니다. 브라우저가 그냥 폼을 제출해도 서버가 어떤 함수를 부를지 알 수 있게요.
우리 함수는 브라우저 안에서만 존재해서 서버는 그런 함수를 모르고, 그려 넣을 식별자가 없으니 숨은 입력도 없고, 자바스크립트 없이 제출되면 안 되니 저런 방어 코드가 들어간 거예요.
확인해보면
자바스크립트가 아직 안 붙은 상태에서 하트를 눌러보면 정말 아무 일도 안 일어납니다. 화면도 안 바뀌고 서버에도 요청이 안 가요.
Step 2까지는 같은 상황에서도 눌렸습니다. 폼이 그냥 제출됐고, 서버가 처리해서 새 화면을 보내줬어요.
우리는 지난 시간에 얻은 것 하나를 오늘 내놓았습니다.
그런데 팔로우 폼은 그대로입니다
같은 앱에서 프로필 화면의 소스를 보면 팔로우 폼은 여전히 이렇습니다.
<form class="mb-4" action="" encType="multipart/form-data" method="POST">
팔로우 버튼에는 낙관적 업데이트를 안 얹었으니까요. 서버 함수를 그대로 건네고 있고, 그래서 자바스크립트 없이도 동작합니다.
한 앱 안에 두 형태가 공존하는데, 이건 실수가 아니라 선택이에요.
서버 함수를 직접 건넨다 자바스크립트 없이도 동작 | 누르면 기다린다
우리 함수로 감싼다 자바스크립트가 필요 | 누르는 즉시 바뀐다
무엇을 기준으로 고를까요
낙관적 업데이트는 브라우저가 먼저 그리는 방식이라 브라우저가 아무것도 못 하는 상황을 위한 대비와는 애초에 같이 갈 수 없으니, 기준은 이렇게 잡습니다.
자바스크립트가 없을 때도 반드시 되어야 하는 것은 서버 함수를 직접 건넵니다. 로그인, 회원가입, 결제, 글 작성처럼 못 하면 서비스를 아예 못 쓰는 일들이요.
빨라야 하는 것은 감쌉니다. 좋아요, 북마크, 읽음 표시처럼 자주 누르고 실패해도 큰일이 아닌 일들이요. 자바스크립트가 없으면 이 버튼들이 안 눌리지만, 그 상황에서도 글은 읽을 수 있습니다.
우리 앱이 지금 정확히 그렇게 되어 있습니다. 로그인과 팔로우는 자바스크립트 없이 되고, 좋아요는 안 됩니다.
💡 한 줄 정리
낙관적 업데이트를 얹으면 그 폼은 자바스크립트를 전제하게 됩니다. 못 하면 서비스를 못 쓰는 일은 서버 함수를 직접 건네고, 빨라야 하는 일만 감쌉니다.
🙋 학생 질문 — "요즘 자바스크립트 꺼놓고 쓰는 사람이 있나요?"
일부러 끄는 사람은 거의 없지만, 자바스크립트가 없는 순간은 모두에게 있어요.
페이지가 열리고 나서 자바스크립트 파일이 내려와 실행되기까지 시간이 걸립니다. 그 사이가 바로 자바스크립트가 없는 상태예요. 지하철에서 신호가 약할 때는 이 시간이 몇 초씩 되기도 합니다.
파일 하나가 안 내려오거나 오류로 멈추면 그날 그 사용자에게는 계속 없는 상태고요.
D-1에서 검색 엔진 이야기를 했었죠. 크롤러도 자바스크립트를 실행하긴 하지만 늘 그러지는 않습니다.
그러니 "꺼놓은 사람"을 위한 배려가 아니라 느린 순간과 실패한 순간을 위한 보험으로 생각하는 편이 맞습니다. 그 보험을 어디에 들지가 방금 이야기한 기준이에요.
Step 6: "문지기에게 두 번째 일을 줍니다"
오늘의 후반부로 접어들며, 지난 시간에 세운 proxy.ts로 돌아갑시다.
지금 문지기가 하는 일은 하나예요. 요청이 들어오면 그 이름의 사람이 있는지 물어보고, 없으면 404 화면을 그리게 합니다.
오늘 일을 하나 더 줍니다. 로그인 안 한 사람은 프로필 화면을 못 보게 하는 거예요. 인스타그램도 그렇게 하고 있죠. 로그인 없이 프로필 주소로 들어가면 로그인 화면이 뜹니다.
검사를 어디에 넣을까요
// apps/web-next/proxy.ts
export async function proxy(request: NextRequest) {
if (request.method !== 'GET') {
return NextResponse.next();
}
// 들어와도 되는 사람인지부터 본다. 네트워크를 타기 전에 끝나는 검사라 여기가 가장 싸다.
if (request.cookies.get('me') === undefined) {
return NextResponse.redirect(new URL('/', request.url));
}
const username = request.nextUrl.pathname.slice(1);
const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`);
if (response.status === 404) {
return NextResponse.rewrite(new URL('/_not-found', request.url), { status: 404 });
}
return NextResponse.next();
}
세 줄입니다. 그런데 어디에 넣느냐가 이 Step의 핵심이에요.
쿠키 검사를 fetch 앞에 뒀는데, 뒤에 둘 수도 있었어요. 그러면 무슨 차이가 날까요.
순서가 바꾸는 것 두 가지
첫째는 속도입니다. 쿠키를 읽는 건 요청 안에 이미 들어 있는 값을 보는 일이라 네트워크를 안 탑니다. 앞에 두면 로그인 안 한 요청은 API 서버까지 안 갔다 와요.
둘째가 더 중요합니다. 없는 사람에게 무엇을 돌려주느냐가 달라집니다.
재보면 이렇게 나옵니다.
| 주소 | 로그인 상태 | 로그아웃 상태 |
|---|---|---|
/ |
200 · 13~21ms | 200 |
/explore |
200 · 13~18ms | 200 |
/jaehoon |
200 · 425~449ms | 307 · 3~9ms |
/zzzzz |
404 · 422~433ms | 307 · 2~3ms |
로그인한 사람에게는 지난 시간에 되찾은 404가 그대로 있습니다. 세 회차에 걸쳐 잃었다 되찾은 그 404 요.
로그아웃 상태에서는 없는 사람도 307이지, 404가 아니에요.
이게 손해일까요
처음 보면 되찾은 것을 다시 잃은 것처럼 보입니다. 그런데 반대로 생각해보세요.
로그아웃 상태에서 /zzzzz가 404를 주고 /jaehoon이 307을 준다면, 로그인 안 한 사람도 누가 가입했는지 알아낼 수 있습니다. 주소만 바꿔가며 눌러보면 계정 목록이 나오는 거예요.
인증 검사를 앞에 두면 로그인 안 한 사람에게는 전부 똑같이 307입니다. 있는지 없는지 안 알려줘요.
이걸 계정 열거(account enumeration)를 막는다고 부릅니다. 로그인 화면에서 "없는 아이디입니다" 대신 "아이디 또는 비밀번호가 틀렸습니다"라고 하는 것과 같은 이유예요.
지난 시간에 산 404는 로그인한 사람에게 남았고, 로그아웃한 사람에게는 애초에 안 주는 편이 낫습니다. 순서 하나로 둘 다 얻었어요.
무는 범위는 그대로입니다
matcher는 안 건드렸습니다.
export const config = {
matcher: ['/((?!api|_next|favicon.ico|explore).+)'],
};
/와 /explore는 그래서 로그아웃 상태에서도 열리는데, 위 표에서 둘만 200인 이유예요.
일부러 그렇게 뒀습니다. 로그인 화면이 /에 있는데 /를 막으면 로그인할 방법이 없어지니까요. 문지기를 세울 때 가장 먼저 확인해야 하는 게 이겁니다. 들어오는 문 자체를 막지는 않았는가.
💡 한 줄 정리
인증 검사를 404 확인보다 앞에 두면 왕복 한 번을 아끼고, 로그인 안 한 사람에게 계정 존재 여부를 안 알려줍니다. 검사 순서가 성능과 보안을 동시에 정해요.
🙋 학생 질문 — "쿠키가 있기만 하면 통과인가요? 아무 값이나 넣으면요?"
지금은 통과합니다. 쿠키가 있는지만 보고 있으니까요.
이 쿠키는 httpOnly라 브라우저 자바스크립트로는 못 만드는데, 개발자 도구로는 만들 수 있어요. 그러니 지금 우리 문지기는 "장난 삼아 주소를 쳐보는 사람"은 막지만 "작정한 사람"은 못 막습니다.
진짜 서비스에서는 쿠키에 서명이 들어갑니다. 서버만 아는 열쇠로 만든 서명이라 값을 고치면 검사에서 걸려요. 그 서명을 만들고 확인하는 일을 G-1에서 인증 라이브러리로 할 겁니다.
오늘 중요한 건 어디에서 확인하느냐입니다. 확인하는 방법이 정교해져도 확인해야 하는 곳은 그대로인데, 다음 Step에서 볼 것처럼 문지기 하나만으로는 부족합니다.
Step 7: "액션은 공개 엔드포인트입니다"
문지기를 세웠으니 이제 안전할까요.
지금 로그인 안 한 사람은 프로필 화면을 못 봅니다. 팔로우 버튼이 화면에 안 보이니 누를 수도 없고요.
그런데 버튼이 안 보이는 것과 그 버튼이 하는 일을 못 하는 것은 다릅니다.
문지기가 안 보는 요청
지난 시간에 문지기에 이 줄을 넣었던 것 기억나시죠.
if (request.method !== 'GET') {
return NextResponse.next();
}
화면을 달라는 요청에만 확인이 필요하다고 판단해서 넣은 줄입니다. 그 덕에 팔로우 한 번에 나가던 존재 확인이 세 번에서 두 번으로 줄었어요.
서버 액션은 자기가 쓰인 화면의 주소로 POST 되는데, 팔로우 버튼은 /minji에 있으니 /minji로 POST 되는 거예요.
정리하면 지금 문지기는 프로필 화면을 달라는 요청은 다 막으면서, 같은 주소로 오는 액션 호출은 그냥 보내주고 있습니다.
재봅시다
로그인 안 한 상태에서 액션을 직접 불러봤습니다. 세 가지 조건으로요.
| 조건 | 보낸 요청 | 결과 |
|---|---|---|
| 지금 그대로 | POST /minji 쿠키 없음 |
200 · 액션이 실행됐다 |
| 비-GET 통과 줄을 지우면 | POST /minji 쿠키 없음 |
307 · 막혔다 |
| 위와 같은 조건에서 주소만 바꾸면 | POST / 쿠키 없음 |
200 · 또 실행됐다 |
첫 줄이 지금 우리 앱입니다. 화면은 다 막혀 있는데 액션은 돌아갔어요.
둘째 줄을 보면 고칠 수 있을 것 같습니다. 속도를 조금 포기하고 POST도 검사하면 막히니까요.
그런데 셋째 줄이 그 희망을 없앱니다.
왜 주소를 바꾸면 뚫릴까요
서버 액션은 주소가 아니라 식별자로 찾아집니다. 요청 본문에 들어 있는 그 긴 문자열이요.
그 덕에 로그인 폼의 액션을 프로필 주소로 보내도 실행되고, 반대로 프로필의 액션을 /로 보내도 실행됩니다. 어느 주소로 보내든 서버는 식별자를 보고 함수를 찾아요.
우리 matcher는 /와 /explore와 /api를 안 봅니다. 그러니 그 주소로 보내면 문지기를 아예 안 거칩니다.
문지기는 주소를 보고 판단한다
그런데 어느 주소로 보낼지는 부르는 쪽이 고른다
matcher를 아무리 넓혀도 마찬가지예요. 정적 파일이나 API 통로처럼 반드시 열어둬야 하는 주소는 늘 남고, 그 열린 주소 하나면 충분합니다.
그래서 액션은 공개 엔드포인트입니다
정리하면 이렇습니다.
'use server'를 붙인 함수는 인터넷에 공개된 통로입니다. 그 함수를 부르는 폼이 화면에 안 보여도, 그 화면에 못 들어가는 사람이어도, 식별자만 알면 부를 수 있어요.
지난 시간에 배운 것을 뒤집어 생각하면 당연합니다. 함수 하나가 주소가 된다고 했잖아요. 주소가 됐다는 건 누구나 그 주소로 요청을 보낼 수 있다는 뜻입니다.
문지기가 쓸모없다는 말이 아닙니다. 문지기는 화면을 지킵니다. 로그인 안 한 사람에게 프로필을 안 보여주는 일은 문지기만 할 수 있어요.
다만 동작은 못 지킵니다. 동작을 지키는 곳은 따로 있습니다.
💡 한 줄 정리
서버 액션은 주소가 아니라 식별자로 찾아지므로, 문지기가 안 무는 주소로 보내면 그냥 실행됩니다. 문지기는 화면을 지키지 동작을 지키지 않아요.
🙋 학생 질문 — "그 식별자를 어떻게 알아내나요? 아무도 모르면 안전한 거 아닌가요?"
소스 보기에 그대로 적혀 있습니다.
지난 시간에 폼을 소스 보기로 열어봤죠. 숨은 입력에 긴 16진수 문자열이 있었습니다. 그게 식별자예요. 묶은 인자가 없는 폼은 $ACTION_ID_ 뒤에 붙어 있고, 팔로우처럼 인자를 묶은 폼은 {"id":"...","bound":"..."} 안에 들어 있습니다. 화면을 한 번이라도 본 사람은 다 갖고 있어요.
"로그인 안 한 사람은 그 화면을 못 보니까 모른다" 고 생각할 수도 있는데, 로그인 한 번만 하면 볼 수 있습니다. 계정 하나 만드는 게 어려운 일이 아니고요.
게다가 이 식별자는 빌드할 때 정해져서 배포된 앱 전체가 같은 값을 씁니다. 한 번 알아내면 계속 쓸 수 있어요.
아무도 모를 거라는 가정 위에 보안을 세우면 안 됩니다. 이걸 "숨기는 것은 보안이 아니다"라고 부릅니다. 알아도 못 하게 만드는 것이 보안이에요.
Step 8: "그래서 액션 안에서 매번 확인합니다"
문지기가 못 지키면 누가 지킬까요. 액션 자신입니다.
우리는 사실 이미 그렇게 쓰고 있었는데, 지난 시간에 팔로우 함수를 만들면서 이 줄을 넣었죠.
const me = (await cookies()).get('me')?.value;
if (me === undefined) {
return { ...previous, message: '로그인이 필요해요' };
}
그때는 "로그인 안 한 사람이 누르면 안내 문구를 보여주려고" 넣은 줄처럼 보였습니다. 오늘 보니 그게 아니에요. 이 줄이 진짜 방어선입니다.
확인해봅시다
로그인 안 한 상태로 좋아요 액션을 직접 불러봤습니다. 문지기가 안 무는 주소로요.
액션은 실행됐습니다. 응답에 "로그인이 필요해요"가 담겨 왔지만, 연습용 API 서버 쪽 기록을 보면 좋아요 요청이 한 건도 안 들어왔습니다.
문지기 통과시킴 (안 무는 주소였다)
액션 실행됨
쿠키 확인 여기서 멈춤
API 서버 요청 안 옴
막은 것은 문지기가 아니라 액션 안의 그 한 줄입니다.
묶어 보낸 인자는 신원이 아닙니다
지난 시간 마지막 과제에서 폼의 숨은 입력을 열어봤죠. 프로필 화면의 소스 보기를 다시 열면 팔로우 폼에 이런 값이 그대로 있습니다.
$ACTION_2:1 = ["minji",{"following":null,"message":null}]
"minji"는 follow.bind(null, username)으로 묶은 그 이름입니다. 뒤의 객체는 직전 상태고요.
이름 옆의 숫자($ACTION_2)는 그 화면에서 몇 번째 액션인지를 나타내는 값이라 화면마다 다릅니다. 소스 보기에서 $ACTION_으로 검색하면 찾을 수 있어요.
우리는 이 값을 코드 안에서 묶었으니 안전하다고 느끼기 쉽습니다. 그런데 보시다시피 브라우저까지 내려갔다가 그대로 돌아옵니다.
고쳐서 보내면 어떻게 될까요. 실제로 해봤는데, 민지 프로필의 팔로우 폼을 그대로 제출하되 이름만 "minji"에서 "seungwoo"로 바꿔서요.
승우의 팔로워가 320에서 321로 늘었습니다. 민지 화면에서 보낸 요청으로 승우를 팔로우한 거예요.
무엇이 뚫리고 무엇이 안 뚫렸나
여기서 정확히 갈라야 합니다.
대상은 바꿀 수 있었습니다. 민지 화면에서 온 요청으로 승우를 팔로우했어요.
신원은 못 바꿉니다. 누가 눌렀는지는 인자에 없거든요. 서버가 쿠키에서 직접 읽습니다. 남의 이름으로 누르려면 남의 쿠키가 있어야 해요.
우리 앱에서는 대상이 바뀌어도 큰일이 아닙니다. 누구나 아무나 팔로우할 수 있으니까요. 승우 프로필에 가서 누른 것과 결과가 같습니다.
덧붙이면 피드의 좋아요 폼에는 이 방법을 못 씁니다. Step 5에서 본 것처럼 낙관적 업데이트를 얹은 뒤로 숨은 입력이 아예 없거든요. 이게 보안이 되는 건 아니에요. 브라우저 개발자 도구를 열면 그 값들이 그대로 보이니까 폼에 안 적혀 있을 뿐 없어진 게 아닙니다.
그리고 만약 액션이 "이 댓글을 지운다" 였다면요? 인자로 받은 댓글 번호를 그대로 믿고 지우면, 남의 댓글 번호로 바꿔 보내는 것만으로 남의 댓글이 지워집니다.
규칙은 이렇습니다.
인자는 "무엇을"이고, 쿠키는 "누가"입니다. 그리고 "이 사람이 그것을 해도 되는가"는 서버가 판단합니다.
빼먹을 수 없게 만듭니다
액션이 이제 둘이고 둘 다 같은 확인을 하고 있는데, 앞으로 댓글 작성, 댓글 삭제, 게시물 업로드가 늘어날 텐데 그때마다 이 줄을 다시 씁니다.
한 번 빼먹으면 그 액션만 조용히 열리는데, 오류도 안 나고 화면도 멀쩡하니까 아무도 모릅니다.
그러니 반복되는 확인을 한 곳으로 모읍시다.
// apps/web-next/lib/session.ts
import { cookies } from 'next/headers';
/**
* 지금 요청을 보낸 사람. 로그인 안 했으면 null.
*
* 액션은 신원을 오직 이 함수로만 얻는다. 인자로 받은 이름은 절대 신원이 아니다 —
* 그 값은 브라우저를 한 번 다녀온 값이라 누구든 고쳐 보낼 수 있다.
*/
export async function currentUser(): Promise<string | null> {
return (await cookies()).get('me')?.value ?? null;
두 액션이 이 함수만 씁니다.
// apps/web-next/app/actions/like.ts
import { currentUser } from '@/lib/session';
export async function toggleLike(postId: number, previous: LikeState): Promise<LikeState> {
// 폼이 화면에 없어도 이 함수는 누구나 부를 수 있다. 그래서 매번 확인한다.
const me = await currentUser();
if (me === null) {
return { ...previous, message: '로그인이 필요해요' };
}
// ...
}
동작은 똑같습니다. 달라진 건 신원을 얻는 방법이 앱 전체에 하나뿐이라는 것이에요. 새 액션을 만들 때 cookies()를 직접 부르는 코드가 보이면 그것부터 의심하면 됩니다.
G-1에서 진짜 인증을 들이면 이 함수 안쪽만 바뀝니다. 부르는 쪽은 그대로고요.
💡 한 줄 정리
인자는 브라우저를 다녀온 값이라 대상은 바뀔 수 있지만, 신원은 서버가 쿠키에서 직접 읽어 못 바꿉니다. 그 확인을 한 함수로 모아 새 액션이 빼먹을 수 없게 했어요.
🙋 학생 질문 — "그럼 인자로 받은 값은 아예 못 믿는 건가요?"
못 믿는 게 맞습니다. 다만 "못 믿는다"가 "안 쓴다"는 아니에요.
게시물 번호는 인자로 받을 수밖에 없습니다. 어느 게시물인지는 화면이 알려줘야 하니까요. 대신 그 번호로 무엇을 할지는 서버가 정합니다.
그 게시물이 있는지 확인하고, 이 사람이 그 게시물에 그 동작을 해도 되는지 확인하고, 그다음에 처리합니다. 좋아요는 누구나 눌러도 되니 두 번째 확인이 없는 거예요. 삭제라면 반드시 있어야 합니다.
브라우저에서 온 모든 값에 같은 규칙이 적용됩니다. 폼 입력값, 주소의 검색 조건, 쿠키 값 전부요. B-6에서 Zod로 서버 응답까지 검사했던 것 기억나시죠. 그때 "타입은 배포된 앱에서 안 돈다" 고 했던 이야기와 같은 줄기입니다.
마무리
오늘 배운 핵심 세 가지
💡 하나 — 기다림은 없앨 수 없지만 감출 수는 있습니다. 서버는 여전히 413밀리초를 쓰지만, 화면은 2밀리초 만에 바뀌어요. useOptimistic은 진짜 값과 예상 값을 함께 들고 있다가 액션이 도는 동안만 예상 값을 보여주기 때문에, 실패하면 저절로 되돌아와요. 우리가 롤백을 짠 게 아니라, 진짜 값을 안 바꿨기 때문입니다.
💡 둘 — 빨라지는 대신 내놓는 것이 있습니다. 낙관적 업데이트를 얹은 폼은 자바스크립트를 전제하게 되어서, 지난 시간에 확인한 "자바스크립트 없이도 제출되는 폼"이 그 버튼에서는 끝나고, 못 하면 서비스를 못 쓰는 일과 빨라야 하는 일을 갈라서 결정하게 됩니다. 우리 앱은 로그인과 팔로우는 남겼고 좋아요만 감쌌습니다.
💡 셋 — 문지기는 화면을 지키고, 동작은 액션이 지킵니다. 서버 액션은 주소가 아니라 식별자로 찾아지므로, 문지기가 안 무는 주소로 보내면 그냥 실행됩니다. 화면이 안 보이는 것과 그 화면의 동작을 못 하는 것은 다릅니다. 그래서 인증 확인은 액션 안에 있어야 하고, 인자로 받은 값은 대상일 뿐 신원이 아닙니다.
다음 시간 예고
이걸로 Next.js로 화면을 만들고 데이터를 다루는 이야기가 한 바퀴 돌았습니다. 그리는 것, 굳히는 것, 갈아 끼우는 것, 받는 것, 지키는 것까지요.
그동안 미뤄둔 것들을 이제 갚을 차례인데, 우선 우리 피드에는 사진이 없습니다. 인스타그램인데요. 데이터에는 imageUrl이 계속 있었는데 화면에는 글자만 그려왔고, 다음 시간에 처음 붙이면 사진이 내려올 때마다 아래 글이 밀리는 문제부터 만나게 돼요.
브라우저 탭도 보세요. 어느 화면에 가 있든 제목이 "인스타그램 클론" 하나인데, 프로필에 있어도 탐색에 있어도, 링크를 메신저에 붙여넣어도 마찬가지고요.
사진이 미리 크기를 잡게 만들고, 주소마다 다른 제목이 뜨게 만드는 데까지가 다음 시간입니다. 이 앱을 컨테이너 하나에 담아서 어디서든 실행되게 만드는 건 그다음 시간이고요.
그리고 그 두 시간에 걸쳐 오늘 만든 것이 한 번 더 시험을 받습니다. 개발할 때 보이는 모습과 진짜로 빌드해서 띄웠을 때의 모습이 갈리는 자리가 있거든요.
과제
[구현] 팔로우도 즉시 반응하게
오늘 좋아요에 한 것을 팔로우 버튼에도 해보세요.
FollowButton.tsx에useOptimistic을 얹어서, 누르는 즉시 "팔로우"가 "팔로잉"으로 바뀌게 만드세요.FollowState의following은boolean | null입니다. 아직 안 눌러본 상태가null이에요. 예상 값을 만드는 함수에서 이걸 어떻게 다룰지 정해야 합니다.- 고친 뒤 프로필 화면의 소스 보기를 다시 열어보고, 무엇이 달라졌는지와 그게 어떤 대가인지를 한 문장으로 적으세요.
[구현] 실패를 눈으로 확인하기
연습용 서버의 실패 스위치를 켜고 롤백을 직접 보세요.
LIKE_FAIL_EVERY=2 API_STUB_LOG=1 node apps/api-stub/server.mjs
- 하트를 네 번 눌러보고, 성공한 회차와 실패한 회차에 화면이 어떻게 움직이는지 기록하세요.
- 이제
like.ts의 실패 갈래를return { liked: false, likeCount: 0, message: ... }로 바꿔보세요. 무엇이 달라지나요. - 원래대로
{ ...previous, message }가 나은 이유를 낙관적 업데이트와 엮어서 설명하세요.
[탐구] 문지기의 범위를 바꿔봅니다
proxy.ts를 두 가지로 고쳐보고 각각 무슨 일이 생기는지 확인하세요.
- 먼저
matcher에서explore를 빼는 대신, 이번에는 인증 검사를fetch뒤로 옮겨보세요. 로그아웃 상태에서/zzzzz와/jaehoon이 각각 무엇을 돌려주나요. 응답 시간은요. - 다음으로
matcher에서 제외 목록을 전부 지우고['/(.*)']로 바꿔보세요. 로그아웃 상태로 접속하면 어떻게 되나요. 왜 그런가요. - 두 실험에서 얻은 규칙을 각각 한 줄로 정리하세요.
[탐구] 묶은 인자를 따라가 봅니다
Step 8에서 본 것을 직접 확인하세요.
- 프로필 화면(
/minji)의 소스 보기를 열어 팔로우 폼의 숨은 입력을 전부 적으세요. 어느 것이 우리가 묶어 보낸 이름인가요. - 그 값을 다른 사람 이름으로 고쳐서 제출해보세요. 힌트는 지난 시간 마지막 과제에서 폼을 제출했던 방법입니다.
- 성공했다면 다음 두 가지를 갈라서 설명하세요. 이 방법으로 할 수 있는 것과 아무리 해도 못 하는 것이요.
- 같은 방법을 피드의 좋아요 폼에 해보려고 하면 막힙니다. 왜 막히는지, 그게 보안이 되어주는지 답하세요.
- 마지막으로 이 액션이 팔로우가 아니라 "댓글 삭제" 였다면 무엇을 더 확인해야 하는지 코드로 적어보세요.
생각해볼 주제
1. 낙관적 업데이트를 쓰면 안 되는 곳
오늘 좋아요를 즉시 반응하게 만들었습니다. 사용자 경험이 확실히 좋아졌어요.
그런데 모든 버튼에 이걸 붙이면 어떻게 될까요. 송금 버튼을 누르자마자 잔액이 줄어든 화면을 보여주고, 서버가 거절하면 되돌린다면요.
어떤 동작에 낙관적 업데이트를 쓰면 안 되는지, 그 기준을 무엇으로 잡을지 정리해보세요.
2. 점진적 향상을 포기할 값어치
Step 5에서 좋아요 폼이 자바스크립트를 전제하게 됐습니다. 대신 413밀리초가 2밀리초가 됐고요.
이 거래를 어떤 근거로 정당화할 수 있을까요. 반대로 이 거래를 거절해야 하는 서비스는 어떤 서비스일까요.
여러분이 팀에서 "좋아요에 낙관적 업데이트를 넣자" 고 제안했을 때 나올 반대 의견을 하나 예상하고, 거기에 어떻게 답할지 준비해보세요.
3. 문지기와 액션, 둘 다 필요한가
Step 7에서 문지기가 동작을 못 지킨다는 걸 봤습니다. 액션 안에서 확인해야 한다고요.
그렇다면 질문 하나. 액션이 어차피 스스로 확인한다면, 문지기의 인증 검사는 없어도 되지 않을까요. 코드가 두 곳에 있으면 언젠가 어긋나기 마련이고요.
둘 다 두는 쪽과 하나만 두는 쪽의 장단점을 정리하고, 여러분이라면 어떻게 할지 정하세요.
✅ 예시 답안정답 보기
과제 넷과 생각해볼 주제 셋의 예시답안입니다. 배점은 과제당 25점, 합계 100점입니다.
먼저 스스로 풀어보고 나서 펼쳐보세요. 특히 [탐구] 과제는 예상을 먼저 적어두고 확인하는 것이 훨씬 남습니다.
🎯 [과제 1 예시답안] 팔로우도 즉시 반응하게 (25점)
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
useOptimistic을 얹어 누르는 즉시 글자가 바뀐다 |
8 | 액션 안에서 setter를 불렀는가 |
following의 null을 어떻게 다룰지 정했다 |
7 | 안 눌러본 상태를 무엇으로 볼 것인가 |
pendingLabel을 뺐다 |
4 | 안 빼면 낙관적 값이 덮여 사라진다 |
| 소스 보기 변화를 확인하고 대가를 적었다 | 6 | 팔로우도 자바스크립트를 전제하게 됐다 |
풀이 예시
// apps/web-next/app/components/FollowButton.tsx
'use client';
import { useActionState, useOptimistic } from 'react';
import { follow, type FollowState } from '@/app/actions/follow';
import { SubmitButton } from './SubmitButton';
const initial: FollowState = { following: null, message: null };
export function FollowButton({ username }: { username: string }) {
const [state, formAction] = useActionState(follow.bind(null, username), initial);
// null 은 "아직 안 눌러봤다" 는 뜻이라 눌린 적 없는 것으로 보고 뒤집는다.
const [optimistic, setOptimistic] = useOptimistic(state, (_previous, next: boolean) => ({
following: next,
message: null,
}));
return (
<form
action={() => {
setOptimistic(optimistic.following !== true);
return formAction();
}}
className="mb-4"
>
<SubmitButton className="rounded border border-black/15 px-3 py-1 text-sm disabled:opacity-40">
{optimistic.following === true ? '팔로잉' : '팔로우'}
</SubmitButton>
{state.message !== null && (
<p aria-live="polite" className="mt-1 text-sm text-black/60">
{state.message}
</p>
)}
</form>
);
}
null을 어떻게 다뤘나
좋아요는 liked가 boolean이라 !optimistic.liked로 끝났습니다. 팔로우는 boolean | null이라 한 갈래가 더 있어요.
setOptimistic(optimistic.following !== true);
!== true로 쓴 이유는 null과 false를 같게 보기 위해서입니다. 화면에 "팔로우"라고 떠 있는 상태가 둘 다 그렇거든요. 아직 안 눌러본 것과 팔로우를 끊은 것은 서버 입장에서는 다르지만, 버튼 입장에서는 똑같이 안 눌린 상태입니다.
!optimistic.following으로 써도 결과는 같습니다. 다만 null을 의도적으로 다뤘다는 것이 코드에 안 드러나요.
만든 값에서 message: null을 넣은 것도 봐두세요. 이전에 실패해서 문구가 떠 있었다면, 다시 눌렀을 때 그 문구는 사라져야 합니다.
소스 보기 변화
고치기 전 프로필 화면의 팔로우 폼은 이랬습니다.
<form class="mb-4" action="" encType="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_REF_2" />
<input type="hidden" name="$ACTION_2:0" value="..." />
<input type="hidden" name="$ACTION_2:1" value="["minji",{...}]" />
<input type="hidden" name="$ACTION_KEY" value="..." />
고친 뒤는 이렇습니다.
<form class="mb-4" action="javascript:throw new Error('React form unexpectedly submitted.')">
대가는 이겁니다. 이제 우리 앱에서 자바스크립트 없이 되는 것은 로그인과 로그아웃뿐입니다. 팔로우도 좋아요도 브라우저가 코드를 실행해야 눌려요.
받은 것은 즉시 반응이고요. 팔로우가 좋아요만큼 자주 누르는 버튼인지, 그래서 이 거래가 맞는지는 팀에서 정할 문제입니다. 인스타그램은 둘 다 즉시 반응하게 만들어놨습니다.
자주 나오는 실수
setOptimistic을 onClick에서 부르는 경우가 많은데, 그러면 값이 잠깐 보였다가 곧바로 사라지고 콘솔에 경고가 뜹니다. 액션 밖에서 부르면 React가 그 값을 액션과 묶어주지 못하거든요. <form action> 안에서 불러야 합니다.
다른 하나는 pendingLabel="보내는 중…"을 그대로 두는 것입니다. 낙관적 값으로 "팔로잉"을 만들어놓고 그 위를 "보내는 중…"이 덮어버려서, 고생해서 얹은 낙관적 업데이트가 화면에 안 보입니다.
💡 튜터의 한마디
이 과제의 진짜 목적은 useOptimistic을 한 번 더 써보는 게 아니라 같은 도구를 다른 타입에 얹어보는 것 이었습니다.
boolean은 뒤집으면 끝이지만 boolean | null은 "안 눌러본 상태"를 어느 쪽으로 볼지 우리가 정해야 해요. 이런 결정은 라이브러리가 대신 해주지 않습니다. 도메인을 아는 사람만 할 수 있는 판단이에요.
🎯 [과제 2 예시답안] 실패를 눈으로 확인하기 (25점)
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 성공·실패가 번갈아 나오는 것을 기록했다 | 6 | 실패한 차례에 화면이 어떻게 움직였는가 |
롤백이 저절로 되는 이유를 ...previous로 설명했다 |
8 | 진짜 값이 안 바뀌었다는 것 |
| 실패 갈래를 바꿨을 때 무엇이 달라지는지 확인했다 | 7 | 숫자가 0 이 된다 |
| 원래대로가 나은 이유를 사용자 관점으로 설명했다 | 4 | 실패가 성공처럼 보인다 |
풀이 예시
실패를 만들어내는 서버로 띄웁니다.
LIKE_FAIL_EVERY=2 API_STUB_LOG=1 node apps/api-stub/server.mjs
첫 번째 카드의 하트를 네 번 눌러본 결과입니다.
| 회차 | 누른 직후 | 400밀리초 뒤 | 문구 |
|---|---|---|---|
| 1 | ♥ 1241 | ♥ 1241 | — |
| 2 | ♡ 1240 | ♥ 1241 | 좋아요를 저장하지 못했습니다 |
| 3 | ♡ 1240 | ♡ 1240 | — |
| 4 | ♥ 1241 | ♡ 1240 | 좋아요를 저장하지 못했습니다 |
성공한 회차는 누른 그대로 남고, 실패한 회차는 갔다가 돌아옵니다.
왜 저절로 되돌아오나
액션이 실패했을 때 돌려준 값을 보면 답이 나옵니다.
{"liked":true,"likeCount":1241,"message":"좋아요를 저장하지 못했습니다"}
누르기 전 상태가 liked: true, likeCount: 1241 이었는데, 실패 응답이 그 값을 그대로 담고 있어요. message만 새로 붙었습니다.
// apps/web-next/app/actions/like.ts
if (!response.ok || !envelope.success) {
return { ...previous, message: envelope.message ?? '좋아요를 저장하지 못했어요' };
}
...previous가 그 일을 합니다. 그러니 useActionState가 들고 있는 진짜 값이 안 바뀌었고, 액션이 끝나면서 낙관적 값이 사라지자 화면이 원래 값으로 돌아온 겁니다.
되돌리는 코드는 우리 파일 어디에도 없는데, 낙관적 값의 수명이 액션과 같기 때문에 생기는 일이에요.
실패 갈래를 바꾸면
이렇게 바꿔봅니다.
// 이렇게 쓰면 안 됩니다
return { liked: false, likeCount: 0, message: envelope.message ?? '좋아요를 저장하지 못했어요' };
같은 조건에서 실패했을 때 액션이 돌려주는 값이 이렇게 바뀝니다.
{"liked":false,"likeCount":0,"message":"좋아요를 저장하지 못했습니다"}
화면에서는 1241 이던 숫자가 0이 됩니다. 하트도 꺼지고요.
저장에 실패했을 뿐인데 그 게시물의 좋아요가 전부 사라진 것처럼 보입니다. 새로고침하면 1241로 돌아와요. 서버는 멀쩡하고 화면만 거짓말을 한 겁니다.
원래대로가 나은 이유
실패했을 때 화면이 가야 할 곳은 누르기 직전 상태입니다. 그것 말고는 정답이 없어요.
우리는 previous로 넘어온 그 값을 이미 갖고 있어서 새로 만들 필요가 없는데, 새로 만드는 순간 우리가 지어낸 값이 되고, 지어낸 값은 반드시 어딘가에서 틀립니다.
지난 시간 팔로우 과제에서 같은 것을 봤죠. 그때는 "팔로잉"이 "팔로우"로 잘못 되돌아가는 문제였습니다. 낙관적 업데이트를 얹으면 이 실수가 훨씬 크게 보여요. 화면이 갔다가 엉뚱한 데로 가버리니까요.
자주 나오는 실수
실패 갈래에서 previous 대신 서버가 보낸 봉투의 data를 쓰려는 경우가 있습니다. 실패했을 때 data는 null이라 그대로 쓰면 화면이 깨집니다.
또 실패했을 때 다시 조회해서 진짜 값을 받아오려는 접근도 있어요. 틀린 방법은 아니지만 요청이 한 번 더 나가고, 그 요청도 실패할 수 있습니다. 이미 아는 값이 있는데 굳이 물어볼 이유가 없어요.
💡 튜터의 한마디
낙관적 업데이트에서 가장 위험한 것은 롤백이 안 되는 게 아니라 엉뚱한 곳으로 롤백되는 것입니다.
안 되면 사용자가 압니다. 숫자가 안 바뀌었으니 다시 누르죠. 그런데 엉뚱한 값으로 가면 사용자는 그게 진짜인 줄 압니다. 좋아요가 0이 됐다고 믿고 다시 누르면 이번엔 1이 되겠지만, 실제 서버 값은 1242 인데요.
실패 갈래를 쓸 때는 늘 이렇게 물어보세요. 이 값을 내가 지어내고 있는가, 아니면 알던 값을 그대로 두는가.
🎯 [과제 3 예시답안] 문지기의 범위를 바꿔봅니다 (25점)
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 인증 검사를 뒤로 옮긴 결과를 상태 코드와 시간으로 기록했다 | 8 | 404 가 돌아오고 시간이 늘어난다 |
| 그게 왜 문제인지 설명했다 | 5 | 계정이 있는지 없는지가 새어나간다 |
matcher를 전부로 넓힌 결과를 확인했다 |
8 | 리다이렉트가 반복된다 |
| 두 실험의 규칙을 각각 한 줄로 정리했다 | 4 | 순서와 범위 |
풀이 예시
실험 하나 — 인증 검사를 fetch 뒤로
// apps/web-next/proxy.ts
const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`);
if (response.status === 404) {
return NextResponse.rewrite(new URL('/_not-found', request.url), { status: 404 });
}
// 검사를 여기로 옮기면
if (request.cookies.get('me') === undefined) {
return NextResponse.redirect(new URL('/', request.url));
}
로그아웃 상태로 재보면 이렇습니다.
| 주소 | 검사가 앞에 있을 때 | 검사를 뒤로 옮기면 |
|---|---|---|
/jaehoon |
307 · 3~9ms | 307 · 415~443ms |
/zzzzz |
307 · 2~3ms | 404 · 442~455ms |
두 가지가 나빠졌습니다.
시간이 3밀리초에서 440밀리초가 됐어요. 로그인 안 한 사람인데 API 서버까지 갔다 오고 나서 돌려보내니까요. 어차피 안 보여줄 화면이면 그 왕복은 통째로 낭비입니다.
더 중요한 건 두 번째 줄인데, /zzzzz가 404를 돌려줍니다. 307이 오면 있는 사람, 404가 오면 없는 사람이니, 로그인 안 한 사람이 주소만 바꿔가며 눌러보면 어떤 이름이 가입되어 있는지 알아낼 수 있어요.
실험 둘 — matcher를 전부로
export const config = {
matcher: ['/(.*)'],
};
로그아웃 상태로 접속하면 이렇게 됩니다.
| 주소 | 결과 |
|---|---|
/ |
307 → / |
/explore |
307 → / |
/jaehoon |
307 → / |
/로 보냈는데 /도 막혀 있으니 또 /로 보내서, 다섯 번을 따라가도 계속 307이고 브라우저에서는 리디렉션이 너무 많다는 오류가 뜹니다.
로그인 화면이 /에 있는데 /를 막았기 때문에, 로그인해야 들어올 수 있는데 로그인하러 갈 수가 없어요.
두 규칙
- 순서 — 싼 검사를 앞에, 비싼 검사를 뒤에 두고, 앞의 검사가 뒤의 검사 결과를 감춰준다면 그게 보안이 된다.
- 범위 — 문지기가 무는 범위에 들어오는 문이 포함되면 안 된다. 로그인 화면과 로그인 액션은 반드시 열려 있어야 한다.
자주 나오는 실수
matcher를 넓힌 뒤 /만 예외로 빼고 끝내는 경우가 있는데, 그러면 _next로 시작하는 자바스크립트 파일 요청까지 문지기를 거쳐요. 화면 하나에 파일이 수십 개씩 붙으니 그만큼 검사가 늘고, 로그인 안 한 사람에게는 그 파일들이 전부 /로 돌아갑니다.
원래 matcher에 api·_next·favicon.ico가 빠져 있던 이유가 이겁니다.
💡 튜터의 한마디
문지기를 세울 때 가장 먼저 그려야 하는 것은 "무엇을 막을까"가 아니라 "막힌 사람이 어디로 가는가"입니다.
이번 실험 둘에서 우리를 곤란하게 만든 건 막는 규칙이 아니라 보내는 곳이었어요. 보내는 곳이 막혀 있으면 규칙이 아무리 정확해도 앱이 안 돌아갑니다.
실무에서는 여기에 하나가 더 붙습니다. 로그인한 다음 원래 가려던 곳으로 되돌려 보내는 일이요. /minji를 열려다 막힌 사람은 로그인 후 /minji로 가야지 /에 남으면 안 되겠죠. 주소에 어디로 가려 했는지를 실어 보내는 방식으로 해결합니다. G-2에서 문지기를 다시 손볼 때 만납니다.
🎯 [과제 4 예시답안] 묶은 인자를 따라가 봅니다 (25점)
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 숨은 입력 넷을 찾고 묶은 인자를 짚었다 | 6 | 어느 것이 이름인지 |
| 값을 고쳐 보내 다른 대상이 바뀌는 것을 확인했다 | 7 | 실제로 숫자가 변했는가 |
| 할 수 있는 것과 못 하는 것을 갈랐다 | 7 | 대상은 바뀌고 신원은 안 바뀐다 |
| 좋아요 폼이 막히는 이유가 보안이 아니라는 것을 짚었다 | 3 | 숨긴 것과 막은 것은 다르다 |
| 댓글 삭제였다면 무엇을 더 확인할지 코드로 적었다 | 2 | 소유자 확인 |
풀이 예시
/minji의 소스 보기에서 팔로우 폼을 찾으면 숨은 입력이 넷입니다.
$ACTION_REF_2 (값 없음)
$ACTION_2:0 {"id":"609cb69805c748e0cea4e0e129edb170316e192f2e","bound":"$@1"}
$ACTION_2:1 ["minji",{"following":null,"message":null}]
$ACTION_KEY kd2eec45c3fb1810fecc2c3a2d62171ed
묶은 인자는 $ACTION_2:1의 배열입니다. 첫 칸 "minji"가 follow.bind(null, username)으로 묶은 이름이고, 둘째 칸은 직전 상태예요.
$ACTION_2:0의 id는 이 폼이 부를 함수의 식별자입니다. bound는 묶은 인자가 따로 실려 온다는 표시고요.
고쳐 보내면
지난 시간 마지막 과제에서 폼을 제출했던 방법 그대로, 이름만 바꿔서 보냅니다.
curl -b "me=jaehoon" -X POST http://localhost:3100/minji \
-F '$ACTION_REF_2=' \
-F '$ACTION_2:0={"id":"609cb...","bound":"$@1"}' \
-F '$ACTION_2:1=["seungwoo",{"following":null,"message":null}]' \
-F '$ACTION_KEY=kd2ee...'
승우의 팔로워 수를 앞뒤로 재보면 320에서 321로 늘어 있습니다. 연습용 서버 기록에도 POST /api/users/seungwoo/follow가 남고요.
민지 프로필에서 보낸 요청으로 승우를 팔로우했습니다.
할 수 있는 것과 못 하는 것
| 결과 | 이유 | |
|---|---|---|
| 대상을 바꾼다 | 된다 | 인자로 왔고, 인자는 브라우저를 다녀온 값이다 |
| 직전 상태를 지어낸다 | 된다 | 같은 배열의 둘째 칸이다 |
| 남의 이름으로 누른다 | 안 된다 | 누가 눌렀는지는 인자에 없다 |
| 로그인 없이 누른다 | 안 된다 | 액션이 쿠키를 확인하고 멈춘다 |
신원이 안 바뀌는 이유는 하나인데, 인자로 안 받았기 때문이에요.
// apps/web-next/app/actions/follow.ts
const me = await currentUser();
이 값은 요청에 딸려 온 쿠키에서 서버가 직접 읽습니다. 폼을 아무리 고쳐도 여기엔 손이 안 닿아요.
좋아요 폼은 왜 안 되나
같은 방법을 피드의 좋아요 폼에 해보려고 하면 숨은 입력이 없어서 값을 못 찾습니다. 낙관적 업데이트를 얹으면서 폼이 그 정보를 안 그리게 됐거든요.
그런데 이건 보안이 아닙니다. 개발자 도구를 열어 요청을 한 번 지켜보면 같은 값이 그대로 보입니다. 폼에 안 적혀 있을 뿐 사라진 게 아니에요.
숨기는 것과 막는 것은 다릅니다. 좋아요 액션이 안전한 이유는 값이 안 보여서가 아니라, 쿠키를 확인하고 누구나 눌러도 되는 동작만 하기 때문입니다.
댓글 삭제였다면
// apps/web-next/app/actions/comment.ts (아직 없는 파일)
export async function deleteComment(commentId: number, previous: State): Promise<State> {
const me = await currentUser();
if (me === null) {
return { ...previous, message: '로그인이 필요해요' };
}
// 여기가 추가되는 곳이다.
// 인자로 받은 번호가 정말 이 사람 댓글인지는 서버만 판단할 수 있다.
const comment = await fetchComment(commentId);
if (comment === null) {
return { ...previous, message: '없는 댓글이에요' };
}
if (comment.username !== me) {
return { ...previous, message: '본인 댓글만 지울 수 있어요' };
}
// 그다음에 지운다
}
로그인 확인만으로는 부족합니다. 로그인은 했는데 남의 댓글 번호를 보낸 사람을 걸러야 하니까요.
앞의 것을 인증(누구인가), 뒤의 것을 인가(이 사람이 그것을 해도 되는가)라고 부릅니다. 팔로우와 좋아요에는 뒤의 것이 없었어요. 누구나 아무나 팔로우해도 되는 동작이라서요.
자주 나오는 실수
인가 검사를 화면에서 하는 경우가 많습니다. 내 댓글에만 삭제 버튼을 그리는 식으로요.
그건 배려지 방어가 아닙니다. 오늘 배운 대로 버튼이 안 보여도 액션은 부를 수 있습니다. 화면의 조건문은 사용자를 돕고, 서버의 조건문이 서비스를 지킵니다. 둘 다 필요해요.
💡 튜터의 한마디
이 과제에서 딱 하나만 가져가야 한다면 이 문장입니다.
인자는 무엇을(what), 쿠키는 누가(who), 그리고 되는지 안 되는지는 서버가 판단합니다.
프런트엔드 개발자가 서버 액션을 쓰기 시작하면 이 경계가 흐려지기 쉽습니다. 같은 파일에 화면과 서버 코드를 나란히 쓰다 보면 "내가 넘긴 값"이라는 감각이 생기거든요.
그런데 그 값은 내가 넘긴 게 아니라 브라우저가 돌려준 값입니다. 중간에 무슨 일이 있었는지 서버는 모릅니다. 이 감각을 잃지 않는 것이 서버 액션 시대의 프런트엔드 개발자에게 요구되는 가장 중요한 습관이에요.
🤔 [생각해볼 주제 1] 낙관적 업데이트를 쓰면 안 되는 곳
문제 상황 요약
좋아요를 즉시 반응하게 만들었더니 확실히 좋아졌는데, 모든 버튼에 붙이면 되지 않을까요.
송금 버튼을 누르자마자 잔액이 줄어든 화면을 보여주고, 서버가 거절하면 되돌린다면요.
튜터의 가이드 및 해설
기준은 하나로 정리되는데, 틀렸을 때 사용자가 무엇을 하느냐입니다.
좋아요가 잠깐 잘못 보였다가 되돌아오면 사용자는 다시 누르는데, 잃은 건 1초예요.
송금이 잘못 보였다가 되돌아오면 사용자는 이미 다음 행동을 했을 수 있습니다. 상대에게 "보냈어요"라고 말했거나, 잔액을 보고 다른 결제를 시작했거나요. 화면은 되돌릴 수 있어도 그 행동은 못 되돌립니다.
화면을 되돌리는 비용과 사용자의 판단을 되돌리는 비용은 다릅니다.
세 가지로 나눠서 판단해보세요.
하나, 실패 확률이 얼마나 되나. 좋아요는 거의 안 실패합니다. 재고가 하나 남은 상품의 구매는 자주 실패해요. 자주 실패하는 동작에 낙관적 업데이트를 붙이면 화면이 자주 뒤집힙니다. 그건 안 붙인 것보다 나빠요.
둘, 그 화면을 보고 사용자가 무엇을 하나. 좋아요 숫자를 보고 하는 일은 없습니다. 잔액을 보고는 많은 일을 하죠.
셋, 되돌린 것을 사용자가 알아차리나. 하트는 눈앞에서 되돌아가니 압니다. 목록 스무 번째 줄의 값이 조용히 되돌아가면 아무도 모릅니다. 알아차리지 못하는 롤백은 없느니만 못해요.
그리고 하나 더. 되돌릴 수 없는 동작에는 절대 쓰면 안 됩니다. 삭제가 대표적이에요. "지워졌습니다"를 먼저 보여줬는데 서버가 거절하면 그 항목을 다시 그려야 하는데, 이미 화면에서 지워진 것을 원래 순서대로 되살리는 건 생각보다 어렵습니다.
실무에서는 대체로 이렇게 갈립니다. 좋아요·북마크·읽음 표시·정렬 바꾸기에는 씁니다. 결제·송금·삭제·제출에는 안 쓰고 대신 "처리 중"을 정직하게 보여줍니다.
🎯 면접관을 홀리는 핵심 멘트
"낙관적 업데이트를 붙일지는 실패 확률이 아니라 틀렸을 때 사용자가 이미 한 행동을 되돌릴 수 있는가로 판단합니다. 좋아요는 되돌려도 1초 손해지만, 잔액을 잘못 보여주면 사용자의 다음 결정을 되돌릴 방법이 없습니다. 화면은 롤백되지만 사람의 판단은 롤백이 안 되니까요."
🤔 [생각해볼 주제 2] 점진적 향상을 포기할 값어치
문제 상황 요약
좋아요 폼이 자바스크립트를 전제하게 됐습니다. 대신 413밀리초가 2밀리초가 됐고요.
이 거래를 어떻게 정당화할까요. 그리고 이 거래를 거절해야 하는 서비스는 어떤 서비스일까요.
튜터의 가이드 및 해설
먼저 무엇을 잃었는지 정확히 재야 합니다. 잃은 것은 "자바스크립트를 끈 사람"이 아니에요. 그런 사람은 거의 없습니다.
잃은 것은 자바스크립트가 아직 없는 순간인데, 그 순간은 모두에게 있어요. 페이지가 뜨고 코드가 내려와 실행되기까지의 몇백 밀리초, 신호가 나쁜 곳에서는 몇 초요.
그러니 질문이 이렇게 바뀝니다. 그 몇 초 사이에 사용자가 이 버튼을 누를까요?
좋아요는 안 누릅니다. 글을 읽고 나서 누르는 버튼이니까요. 글을 읽는 동안 자바스크립트는 이미 붙어 있습니다.
로그인 버튼은 누릅니다. 화면이 뜨자마자 누르는 버튼이라서, 로그인 폼에는 낙관적 업데이트를 안 얹은 겁니다.
화면에 뜨자마자 눌리는 버튼인가, 한참 보다가 눌리는 버튼인가로 갈리는 것이, 실무에서 쓸 만한 판별 기준입니다.
거절해야 하는 서비스도 같은 기준으로 갈립니다.
공공 서비스와 금융은 접근성 기준을 지켜야 하는 경우가 많습니다. 자바스크립트 없이도 핵심 기능이 되어야 한다는 조건이 계약에 들어 있기도 해요.
검색 유입이 생명인 서비스도 조심해야 합니다. D-1에서 본 것처럼 크롤러는 자바스크립트를 늘 실행하지는 않습니다. 다만 좋아요 버튼이 크롤러에게 중요할 일은 없으니, 이건 버튼이 아니라 콘텐츠와 링크에 적용되는 이야기입니다.
반대 의견에 답하는 방법도 준비해두세요. 팀에서 "점진적 향상을 깨는 건데요"라는 말이 나오면 이렇게 답하면 됩니다.
"이 폼만 깹니다. 로그인과 글쓰기는 그대로 두고요. 이 버튼은 화면이 뜨고 3초 뒤에 눌리는 버튼이라 그 시점엔 이미 자바스크립트가 있습니다."
전부 아니면 전무가 아니라 버튼 단위로 결정하는 것이 요점입니다.
🎯 면접관을 홀리는 핵심 멘트
"점진적 향상은 전부 지키거나 전부 버리는 게 아니라 동작 단위로 값을 매기는 문제라고 봅니다. 로그인처럼 화면이 뜨자마자 눌리고 못 하면 서비스를 못 쓰는 동작은 서버 액션을 그대로 건네서 자바스크립트 없이도 되게 두고, 좋아요처럼 한참 보다가 누르고 실패해도 되는 동작만 감쌉니다. 저희 앱에서 실제로 이 기준으로 갈랐고, 소스 보기로 두 폼이 다르게 그려지는 것까지 확인했습니다."
🤔 [생각해볼 주제 3] 문지기와 액션, 둘 다 필요한가
문제 상황 요약
문지기는 동작을 못 지킵니다. 액션이 스스로 확인해야 해요.
그렇다면 문지기의 인증 검사는 없어도 되지 않을까요. 코드가 두 곳에 있으면 언젠가 어긋나기 마련인데요.
튜터의 가이드 및 해설
결론부터 말하면 둘 다 둡니다. 다만 둘이 하는 일이 다르다는 것을 알고 둬야 합니다.
액션의 검사는 필수입니다. 없으면 뚫리니까 협상 대상이 아니에요.
문지기의 검사는 선택입니다. 없어도 안 뚫리지만, 없으면 세 가지가 나빠집니다.
하나, 사용자가 헛수고를 합니다. 문지기가 없으면 로그인 안 한 사람도 프로필 화면을 받습니다. 화면이 다 그려지고 나서 버튼을 눌러야 "로그인이 필요해요"를 보게 되죠. 문지기가 있으면 들어오는 순간 로그인 화면으로 갑니다.
둘, 서버가 헛일을 합니다. 어차피 못 볼 화면을 그리느라 데이터를 가져오고 렌더를 합니다. 문지기는 그 앞에서 끊어요.
셋, 정보가 새어나갑니다. 과제 3에서 본 그것입니다. 문지기가 인증을 먼저 안 보면 로그인 안 한 사람도 어떤 계정이 있는지 알아낼 수 있어요.
그러니 역할을 이렇게 정리하면 됩니다.
문지기 빨리 돌려보낸다 · 정보를 안 흘린다 (있으면 좋다)
액션 실제로 막는다 (없으면 안 된다)
"코드가 두 곳에 있으면 어긋난다"는 걱정은 맞습니다. 그래서 두 곳이 같은 판단을 하지 않게 만듭니다.
문지기는 "로그인했나"만 보고, 액션은 "로그인했나 + 이 사람이 이걸 해도 되나"를 보는데, 문지기가 인가까지 판단하려 들면 그때부터 어긋나기 시작해요. 어떤 주소에 누가 들어갈 수 있는지를 문지기가 알려면 결국 데이터를 조회해야 하고, 그 순간 문지기는 싸지도 않고 정확하지도 않게 됩니다.
실무에서 자주 보는 실패가 이겁니다. 문지기에 인가 규칙을 잔뜩 넣어두고 액션에서는 생략하는 것이요. 그러면 새 액션 하나 추가할 때마다 문지기의 규칙을 같이 고쳐야 하고, 한 번 빼먹으면 조용히 뚫립니다.
막는 코드는 막을 곳에 가장 가까이 두는 것이 원칙이라서, 팔로우를 막고 싶으면 팔로우 함수 안에서 막습니다.
🎯 면접관을 홀리는 핵심 멘트
"미들웨어와 액션 양쪽에 인증을 둡니다. 다만 미들웨어는 최적화이고 액션이 방어선이라는 것을 팀에 명확히 합니다. 서버 액션은 식별자로 호출되기 때문에 미들웨어가 안 무는 주소로 보내면 그대로 실행되거든요. 그래서 미들웨어는 로그인 여부까지만 보게 하고 인가 판단은 액션에 두어, 두 곳이 같은 규칙을 중복으로 갖지 않게 설계합니다."