Next.js 서버 액션useActionState테크 딥다이브

쇼핑몰 팀 프로젝트 ③-2 쓰기 경로 — 문의 폼이 표에 닿기까지

· 2026-10-24 발행· 35분 읽기

목차

10월 21일 밤, 로컬에서 문의 폼을 처음 돌려 봤어요. 「비공개」를 고르고 내용을 비운 채 등록을 눌렀더니 오류 문구는 떴는데, 라디오가 「공개」로 돌아가 있더라고요. 그걸 못 보고 내용만 채워 다시 등록했고, DB 에는 공개 글로 들어갔습니다. 비밀글이 비밀이 아니게 된 거예요. 고치는 데는 몇 줄이면 됐지만, 왜 그런지 이해하는 데는 사흘이 걸렸어요.

이 글은 그 사흘의 기록이에요. 1편 에서 화면과 폴더 구조를 세웠고, ②-1 과 ②-2 에서 ERD(Entity-Relationship Diagram, 표와 표 사이 관계를 그린 그림) 여덟 표를 설계했고, ③-1 읽기 경로 에서 그 표가 타입과 컴포넌트와 주소가 되는 길을 봤어요. 이 글 ③-2 는 반대 방향이에요. 문의 폼에 적은 글 한 줄이 표의 한 줄이 되기까지를 표 → 타입 → 서버 액션 → 폼의 순서로 만들고, 폼 → 액션 → 표의 순서로 돌아가는 길을 다룹니다. 개념은 수업에서 배운 것이고 증거는 제 상품 문의 폼에서 가져왔어요. 규칙은 ③-1 과 같아요. 실행해 보지 않은 코드는 싣지 않고, 틀렸던 순서를 지우지 않는다. 다 읽고 나면 “이 칸의 값은 어디서 거절되고 거절은 어디로 돌아오나” 를 칸마다 적어 가며 쓰기 폼 하나를 세울 수 있을 거예요. 연결 객체와 환경 변수, 권한 정책의 팀 DB 실측, 배포는 4편 프론트와 백엔드 연결로 넘깁니다.

이 글의 지도. 위에 입력, 가운데에 표·타입·액션·폼 네 부품과 조립·적용·교정·함정 띠, 아래에 10-21 부터 10-24 까지의 내 작업 타임라인이 있다 이 글의 지도. 개념은 가운데 네 부품에서, 증거는 아래 타임라인에서 가져온다

폼 하나가 표에 닿으려면 무엇이 먼저 있어야 할까?

쓰기 경로는 표에서 시작해 폼에서 끝나도록 만들고, 폼에서 시작해 표에서 끝나도록 돈다. 만드는 순서와 돌아가는 순서가 반대라서, 각 부품은 자기 오른쪽이 무엇을 거절할지 알고 만들어진다.

읽기 경로에는 거절이 없었어요. 쓰기 경로는 달라요. 사용자가 적은 값이 표에 닿기까지 거절당할 자리가 넷이에요. 브라우저가 글자 수를 막고, 서버의 검증 함수가 모양을 보고, 로그인이 없으면 돌려보내고, 마지막으로 DB 가 제약과 권한으로 한 번 더 봅니다. 거절이 있다는 건 되돌아오는 길이 있어야 한다는 뜻이고, 그 길의 모양을 먼저 정하지 않으면 폼은 “왜 안 되는지” 를 말할 수 없어요.

그래서 수업에서는 데이터부터 만들라고 해요. 저는 10월 21일에 표와 뷰와 권한을 SQL(DB 에 내리는 명령 언어) 로, 22일 낮에 타입·검증 함수·테스트를, 22일 저녁에 서버 액션을, 23일에 폼과 페이지를 썼어요. 표가 있어야 액션이 무엇을 보낼지, 액션이 있어야 폼이 무엇을 돌려받을지 알거든요. 돌아갈 때는 폼이 FormData(브라우저가 폼의 칸들을 이름과 값의 쌍으로 묶어 보내는 표준 객체) 를 만들고, 액션이 그 문자열들을 타입으로 바꿔 검증하고, 표에 넣어요. 실패는 ActionState 라는 팀 공용 모양으로 폼에 돌아와 다음 화면이 됩니다.

③-1 에서 본 타입 두 벌과 'use client' 경계는 다시 설명하지 않아요.

위 줄은 왼쪽에서 오른쪽으로 표·타입·액션·폼을 만든 날짜, 아래 줄은 오른쪽에서 왼쪽으로 FormData·변환·insert 가 흐르고 실패가 점선으로 폼에 돌아오는 그림 만드는 순서와 돌아가는 순서는 반대다. 실패는 점선을 타고 폼으로 돌아와 state 가 된다

표는 무엇을 스스로 지킬까 — CHECK, DEFAULT, 그리고 뷰?

표는 값의 규칙(CHECK)과 빈 열의 기본값(DEFAULT)과 줄 단위 권한(RLS)을 앱 코드 없이 스스로 지킨다. 뷰는 줄은 남기고 열만 가린다.

쇼핑몰 접수창구로 치면 CHECK 는 창구의 글자 수 자이고, DEFAULT 는 출입증을 보고 이름 칸을 대신 적어 주는 직원이고, 뷰는 봉투 목록은 다 보여 주되 남의 봉투는 열어 주지 않는 유리창이에요. 셋 다 손님(앱 코드)이 어떻게 적어 왔든 상관없이 창구 쪽에서 돌아갑니다.

제가 10월 21일에 쓴 SQL 에서 이 셋이 들어간 줄만 뽑으면 이래요. 표 전체는 열 열한 개지만 이 글에서 중요한 건 여기예요.

user_id  uuid NOT NULL DEFAULT auth.uid() REFERENCES public.users(id),
content  text NOT NULL CHECK (char_length(content) BETWEEN 1 AND 1000),

CREATE POLICY "own inquiry insert" ON public.product_inquiries
    FOR INSERT TO authenticated
    WITH CHECK (user_id = (select auth.uid()));

CASE WHEN i.is_public OR i.user_id = auth.uid()
     THEN i.content END AS content   -- 뷰 안: 남의 비밀글이면 NULL

auth.uid() 는 우리가 쓰는 백엔드 서비스 Supabase 가 PostgreSQL 안에 심어 둔 함수로, 지금 이 요청에 실려 온 로그인 토큰에서 사용자 id 를 꺼내 줘요. 저장된 값이 아니라 요청마다 다시 계산돼요. 그래서 DEFAULT auth.uid() 는 “작성자 칸을 비워 보내면 요청한 사람으로 채워라” 가 되고, WITH CHECK 는 “작성자 칸에 뭘 적어 보내든 요청한 사람과 다르면 거절해라” 가 됩니다. WITH CHECK 는 RLS(Row Level Security, DB 가 줄 단위로 접근을 검사하는 기능) 정책에서 새로 저장될 줄을 보는 자리예요. 이미 있는 줄을 보는 USING 과 짝인데, INSERT 는 새 줄만 생기니 하나면 돼요.

뷰가 필요했던 이유는 레퍼런스 패션 편집몰의 화면 때문이에요. 비밀글이 목록에서 사라지지 않고 내용 자리에만 자물쇠가 있죠. 그런데 RLS 는 줄을 숨기는 도구라 “줄은 주되 이 열만 비워라” 를 못 해요. 그래서 표를 직접 읽을 권한은 거두고, 소유자 권한으로 도는 뷰 하나만 열어 그 뷰의 CASE 가 열을 가리게 했어요. 수업에서 쓰기 경로를 소유자 권한 함수 하나로 모으는 설계를 배웠는데, 같은 원리를 읽기에 쓴 셈이에요.

이걸 이 글을 쓰면서 다시 돌려 봤어요. 로컬 DB 가 꺼져 있어서 PGlite(브라우저와 Node 에서 도는 WebAssembly 판 PostgreSQL) 를 썼고, auth.uid() 는 세션 변수에서 읽는 대역 함수로 바꿨습니다. SQL 본문은 10월 21일 설계 노트의 것이에요. 팀 저장소의 db/ 폴더에는 이 표의 SQL 파일이 없어요. 팀 DB 에는 10월 23일에 적용했고, 그날 공개 키로 뷰는 200, 표는 401 이 나오는 걸 확인했어요. 대시보드에서 찍은 모습은 PGlite 출력 아래에 둘게요.

PGlite 실행 출력. 회원 A 로 user_id 없이 INSERT 두 건 성공, 남의 user_id 는 42501, 1,001자와 빈 문자열은 23514, 표 직접 SELECT 와 비로그인 INSERT 는 permission denied. 아래 표에 user_id 가 A 로 채워진 두 줄과 세 사람이 같은 뷰를 읽은 결과 설계 노트 §9-1 의 SQL 을 PGlite 0.5.11(PostgreSQL 18.6)에서 실행한 출력. 보내지 않은 user_id 가 채워졌고, 남의 id 는 42501, 1,001자는 23514 로 거절됐으며, 같은 뷰가 작성자에게는 내용을, 다른 회원과 비로그인에게는 null 을 돌려줬다

출력에서 읽을 것은 넷이에요. 네 열만 보냈는데 user_id 에 회원 A 가 들어 있다(DEFAULT). 남의 id 를 끼운 INSERT 는 42501 (정책). 1,001자와 빈 문자열이 둘 다 23514 라 CHECK 가 양쪽 끝을 다 본다. 그리고 같은 뷰를 세 사람이 읽었는데 비공개 줄의 content 가 작성자에게만 있고 줄 수는 셋 다 둘이다. 하나 더, 비로그인의 INSERT 는 문구가 “permission denied” 로 달라요. RLS 가 아니라 그 앞의 권한 단계에서 막힌 거예요.

팀 DB 에 적용된 모습이에요(10월 25일 대시보드). 표는 Data API 가 꺼져 있고 INSERT 정책 하나뿐, 뷰는 user_id 없이 writer 와 가려진 content 를 내보내고, 표에는 10월 23일에 이 폼으로 등록한 시험 문의 두 줄이 user_id 가 채워진 채 있어요.

Supabase 정책 화면. product_inquiries 표에 API DISABLED 배지가 붙어 있고, 정책은 own inquiry insert 하나로 INSERT · authenticated 팀 DB 의 정책 화면 (2026-10-25). 표의 Data API 는 꺼져 있고 INSERT 정책 하나만 있다

Supabase 의 product_inquiries_view 열 목록. id, product_id, is_public, created_at, is_answered, writer, content, answer 가 전부 NULLABLE 로 표시된다 뷰의 열 목록 (2026-10-25). user_id 는 없고 writer 와 content 가 있다. 뷰의 열이 전부 NULLABLE 인 것은 CASE 가 null 을 낼 수 있어서다

Supabase Table Editor 의 product_inquiries 두 줄. user_id 가 같은 uuid 로 채워져 있고, content 는 머플러 사이즈·색상 문의, is_public 은 TRUE 와 FALSE 팀 DB 의 실제 두 줄 (2026-10-23 폼으로 등록, 2026-10-25 캡처). 액션은 user_id 를 보내지 않았는데 채워져 있다

왼쪽에 user_id 없는 insert 요청과 헤더의 토큰, 가운데에 auth.uid(), 오른쪽에 DEFAULT 로 채우기·WITH CHECK 로 검사하기·뷰로 가리기 세 상자 작성자는 코드가 아니라 토큰이 정한다. 세 겹이 모두 같은 auth.uid() 를 본다

10월 22일에 가장 오래 붙잡았던 질문이 이거였어요. 액션은 user_id 를 보내지 않는데 줄에는 작성자가 있다. 답은 코드 안에 없었어요. 서버 쪽 연결 객체가 쿠키의 로그인 토큰을 요청 헤더에 실어 보내고, DB 가 거기서 id 를 꺼내 채우고 검사해요. 앱이 user_id 를 넣어도 돌아가겠지만, 그러면 작성자를 정하는 곳이 둘이 돼요. 위조할 수 없는 토큰을 읽는 DB 한 곳이 짧고 안전합니다.

문자열 네 개는 어떻게 타입이 될까 — FormData, zod, 판별 결과?

FormData 는 문자열과 파일만 나른다. 타입은 서버에서 실행 중 검증이 만들고, 검증의 결과는 성공과 실패가 서로 다른 모양을 가진 하나의 값으로 돌아온다.

택배로 치면 name 이 상자에 적힌 품명이고 value 가 내용물인데, 이 택배사는 글자만 받아요. 숫자도 참·거짓도 글자로 포장해서 보내야 하고, 받는 쪽이 열어 보고 분류해야 합니다. 품명이 없는 칸은 아예 실리지 않아요. 제 폼의 휴대폰 칸이 그래요. name 이 없어서 보여 주기만 하고 전송되지 않습니다.

10월 22일에 쓴 검증 함수의 뼈대는 이래요. 스키마는 zod(실행 중에 값의 모양을 확인하는 라이브러리) 로 적었고, 팀의 다른 폼이 먼저 쓴 zod 선례를 따랐어요.

export const inquirySchema = z.object({
    productId: z.uuid({ error: '상품 정보가 올바르지 않습니다.' }),
    content: z.string().trim().min(1, …).max(INQUIRY_MAX_LENGTH, …).refine(…),
    notify: z.boolean(),
    isPublic: z.boolean(),
});

export function validateInquiryInput(formData: FormData):
    | { values: InquiryInput; errors: Record<string, never> }
    | { values: null; errors: Record<string, string> } {
    const result = inquirySchema.safeParse({
        productId: String(formData.get('productId') ?? ''),
        content: String(formData.get('content') ?? ''),
        notify: formData.get('notify') === 'on',
        isPublic: formData.get('isPublic') === 'true',
    });
    …
}

safeParse 에 넘기기 전의 네 줄이 이 절의 전부예요. 체크박스는 켜졌을 때만 'on' 이라는 글자로 오고 꺼지면 키 자체가 없어서 get 이 null 을 줘요. 그래서 === 'on' 이 곧 참·거짓 변환이에요. 라디오는 제가 value 를 "true" 와 "false" 로 적어 뒀는데, 둘 다 글자라서 Boolean('false') 는 참이 돼 버려요. === 'true' 로 비교해야 합니다. 문자열 칸은 String(… ?? '') 로 감쌌어요. get 이 돌려주는 타입이 문자열 또는 파일 또는 null 이라서요.

반환 타입이 두 모양의 합집합인 것도 일부러예요. 성공이면 values 가 있고 errors 는 빈 객체, 실패면 values 가 null 이고 errors 에 칸별 첫 메시지가 들어 있어요. 수업에서 배운 판별 유니온은 같은 이름의 속성에 서로 다른 리터럴 값을 두고 그 속성 하나로 분기를 가르는 방식인데, 제 함수는 values 가 null 인지로 가르는 변형이에요. 액션 쪽에서 if (!values) return … 한 줄이면 나머지 경로에서 values 의 타입이 좁혀지거든요. 타입 단언 as 로는 이걸 못 해요. 단언은 컴파일러의 취급만 바꾸고 실행 시에는 사라져서, 실제로 들어온 값이 그 모양인지 아무도 안 봅니다. zod 는 unknown 을 받아 들여다본 뒤에야 타입을 줘요.

네 행의 변환표. 폼의 name·value, FormData 가 나르는 것, 검증 함수 안의 변환, zod 가 확인한 타입이 네 열로 놓여 있고 휴대폰 칸은 행이 없다는 각주가 있다 네 칸이 타입이 되는 길. “false” 도 참인 문자열이라 === ‘true’ 로 비교한다

이 글을 쓰며 임시 작업본에서 세 군데를 일부러 틀리고 tsc(TypeScript 컴파일러의 타입 검사) 를 돌렸어요.

tsc 출력. 액션 반환에 ok 키를 쓴 TS2353, 액션 서명에서 _prevState 를 뺀 뒤 useActionState 가 낸 TS2769 와 formAction 호출의 TS2554, formData.get 을 string 변수에 담은 TS2322 네 오류 세 곳을 일부러 틀린 뒤의 타입 검사. ActionState 에 없는 키, (prevState, formData) 서명 위반, FormDataEntryValue | null 을 string 에 담은 것이 각각 잡혔다

formData.get('content') 를 string 변수에 담으면 잡혀요. 그런데 같은 값을 String() 없이 safeParse 에 바로 넘기는 변형은 오류 없이 통과했어요. safeParse 가 unknown 을 받으니까요. 타입 검사는 “문자열이라고 적어 둔 자리에 문자열 아닌 게 온다” 는 잡지만, “검증 함수에 뭘 넘기든” 은 안 봐요. 그 빈자리를 메우는 게 실행 중 검증이고, 검증이 실제로 네 변환을 지키는지는 테스트(vitest, 팀이 쓰는 테스트 러너) 가 봅니다.

vitest 출력. 테스트 파일 1개, 테스트 4개 통과, 633ms 이 세션에서 돌린 검증 함수 테스트 네 묶음. 앞뒤 공백 제거와 ‘on’·‘false’ 변환, 빈 내용·금지 문자·1,001자·잘못된 id 의 네 오류, 체크박스 키 없음은 false, 서울 시간 날짜 표기

서버 액션은 어떤 문을 차례로 지날까 — ‘use server’, (prevState, formData), ActionState?

서버 액션은 폼이 직접 부르는 서버 함수이고, 누구나 부를 수 있는 공개 입구라서 화면이 이미 막은 것을 한 번 더 막는다. 실패는 값으로 돌아가고 성공은 이동으로 끝난다.

창구 직원이에요. 신청서(FormData) 를 받아 양식을 보고, 출입증을 확인하고, 금고에 넣고, 끝나면 안내 데스크로 보냅니다. 어느 문에서 막히든 손님은 신청서를 든 채 제자리로 돌아와요.

10월 22일 저녁에 쓴 액션의 뼈대예요.

'use server';

export async function createInquiryAction(
    _prevState: ActionState,
    formData: FormData,
): Promise<ActionState> {
    const { values, errors } = validateInquiryInput(formData);
    if (!values) return { success: false, errors };

    const supabase = createServerClient();
    const { data: { user } } = await supabase.auth.getUser();
    if (!user) return { success: false, errors: { login: '로그인 후 문의를 남길 수 있습니다.' } };

    const { error } = await supabase.from('product_inquiries').insert({
        product_id: values.productId, content: values.content,
        notify: values.notify, is_public: values.isPublic,
    });
    if (error) return { success: false, message: … };

    revalidatePath(`/product/${values.productId}`);
    redirect(`/product/${values.productId}#faq`);
}

파일 맨 위 'use server' 는 이 파일의 함수가 서버에서 돈다고 못 박는 선언이고, async 는 프레임워크가 강제해요. 둘 다 스타일이 아니에요. 첫 매개변수 _prevState 는 안 쓰는데 받아요. 이 함수를 부르는 쪽이 useActionState 훅이고, 그 훅은 언제나 (이전 상태, 폼 데이터) 두 개로 부르거든요. 믿기지 않아서 이 글을 쓰며 개발 서버 로그를 봤어요.

POST /dd-shot/inquiry 200 in 23ms
  └─ ƒ createInquiryAction({"success":false}, {}) in 10ms domains/inquiry/actions/inquiry.actions.ts

첫 인자가 초기 상태 {"success":false}, 둘째가 폼 데이터예요. 반환 타입 ActionState 는 팀이 shared 에 한 번 정의한 공용 모양으로, success 와 선택적 message, 칸별 errors, data 네 칸이에요. 도메인마다 응답 모양을 새로 정하지 않기로 한 약속이고, 장바구니 액션도 같은 걸 써요.

문은 넷이에요. 검증을 화면에서 했는데 또 하는 이유는, 'use server' 파일에서 내보낸 함수는 전부 브라우저가 직접 부를 수 있는 POST(서버에 데이터를 보내는 요청) 입구가 되기 때문이에요. 화면을 거치지 않은 요청을 전제로 써야 해요. 로그인 확인은 문의 페이지가 이미 했지만, 페이지를 띄운 뒤 다른 탭에서 로그아웃한 경우를 액션이 잡아요. 다만 이 getUser() 는 친절한 문구용이고, 실제로 막는 건 앞 절의 DB 세 겹이에요. INSERT 뒤에 .select() 는 붙이지 않아요. 표의 읽기 권한을 거뒀으니 붙이면 실패해요.

끝나는 길은 두 갈래예요. 실패는 ActionState 를 return 해서 폼의 다음 상태가 되고, 성공은 redirect 가 예외를 던져 함수를 끝내요. 값이 돌아가는 경우는 전부 실패예요. 예외라서 try/catch 안에 두면 잡혀 버리고, 캐시를 비우는 revalidatePath 는 이동 전에 불러야 합니다. 비우는 범위는 상세 페이지 한 경로, 바뀐 데이터를 그리는 곳까지만이에요.

왼쪽에 createInquiryAction 입구, 오른쪽으로 검증·로그인·INSERT·뒤처리 네 상자, 앞 세 상자 아래로 실패 ActionState 가 점선을 타고 입구로 돌아가고, 넷째 상자에서는 redirect 로 끝난다 서버 액션의 문 네 개. 실패는 왼쪽으로 돌아가고 성공은 끝까지 간다

폼은 무엇을 기억하고 무엇을 잠글까 — useActionState, 제어 입력, fieldset?

폼은 서버 액션을 useActionState 로 감싸야 상태를 얻고, 입력의 진실은 DOM(브라우저가 그린 화면 요소의 트리) 이 아니라 state 에 두며, 제출 중에는 전체를 잠근다.

창구 앞의 손님이에요. 거절당해도 신청서를 처음부터 다시 쓰지 않게 사본을 손에 들고 있고(state), 접수가 진행되는 동안에는 펜을 내려놓아요(입력 묶음 요소 fieldset 의 잠금).

10월 23일에 쓴 폼의 뼈대예요. 훅을 쓰니 파일은 'use client' 입니다.

const [state, formAction, isPending] = useActionState(createInquiryAction, INITIAL_ACTION_STATE);
const [content, setContent] = useState('');
const [notify, setNotify] = useState(true);
const [isPublic, setIsPublic] = useState(true);

function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    startTransition(() => formAction(new FormData(event.currentTarget)));
}

<form action={formAction} onSubmit={handleSubmit} noValidate>
    <fieldset disabled={isPending}>
        <input type="hidden" name="productId" value={productId} />
        <textarea name="content" value={content} onChange={…} maxLength={1000} />
        <input type="tel" value={phone} readOnly />            {/* name 없음 */}
        <input type="checkbox" name="notify" checked={notify} onChange={…} />
        <input type="radio" name="isPublic" value="true"  checked={isPublic} … />
        <input type="radio" name="isPublic" value="false" checked={!isPublic} … />
        …

useActionState 는 세 가지를 돌려줘요. 액션이 마지막으로 돌려준 값이 state, 폼에 걸 함수가 formAction, 처리 중인지가 isPending. action 에 서버 액션을 날것으로 걸면 함수는 돌지만 상태와 대기 추적이 조용히 빠져요. 오류가 안 나서 알아채기 어려운 자리예요. isPending 은 <fieldset disabled> 로 폼 전체를 잠가 등록이 두 번 들어가는 걸 막고, 버튼 글자는 「등록 중…」이 돼요. 수업에는 폼 안쪽 자식만 쓸 수 있는 useFormStatus 도 있는데, 제 폼은 폼을 소유한 컴포넌트가 잠그니 isPending 이면 충분했어요.

입력 세 개는 전부 state 에 있어요. 수업에서 배운 제어 컴포넌트는 입력값을 DOM 에서 읽지 않고 state 에 적어 두고 그것을 그리는 방식인데, 거기에 “서버를 다녀온 뒤에도 입력이 남아 있어야 한다” 는 요구가 더해진 거예요. 비밀번호처럼 state 에 두면 안 되는 칸은 이 폼에 없어요. handleSubmit 이 왜 있는지는 교정 절에서 다뤄요. 이 글의 첫 문장이 거기서 나왔거든요.

검증용 임시 페이지에서 찍은 문의 폼. textarea 에 script 태그가 든 문장이 그대로 남아 있고 아래에 빨간 오류 문구, 카운터 32/1,000, 비공개 라디오가 선택된 상태 DB 없이 폼과 액션만 띄운 검증용 임시 페이지에서 금지 문자를 넣고 「비공개」를 고른 뒤 등록을 누른 직후. 오류 문구가 떴고 입력과 라디오가 그대로다

네 부품은 어떤 이름으로 맞물릴까?

같은 이름 하나가 네 층을 통과한다. 폼의 name 이 FormData 의 키가 되고, 검증 함수가 그 키로 읽어 같은 키로 오류를 넣고, 폼이 같은 키로 오류를 그린다. 하나라도 어긋나면 오류는 조용히 안 뜬다.

이 폼에서 content 라는 글자는 네 번 나와요. <textarea name="content">, formData.get('content'), zod 스키마의 키, 그리고 errors.content. 네 자리가 같아야 오류가 그 칸 아래에 떠요. errors 가 아무 문자열 키나 허용하는 타입이라 어긋나도 타입 검사는 통과하고, 화면은 빈 채로 돌아옵니다.

둘째 규칙은 거절을 두 겹으로 둔다는 거예요. 같은 규칙을 다른 층에서 다른 말로 다시 겁니다.

거절하는 층 무엇을 거절하나 어떻게 알리나 누가 보나 이 글의 근거
브라우저 1,000자 초과 입력 자체 (maxLength) 더 안 쳐짐, 카운터 사용자 폼 캡처의 32/1,000
검증 함수 (zod) 빈 내용 · 금지 문자 · 1,001자 · 형식 아닌 상품 id errors.{칸} 으로 반환 폼이 칸 아래에 vitest 4/4 · 폼 캡처의 빨간 문구
액션의 로그인 확인 토큰 없는 요청 errors.login 폼이 하단에 로그인 링크와 이 세션의 비로그인 제출 캡처
DB 제약 · 권한 1,001자 (23514) · 남의 작성자 (42501) · 비로그인 (permission denied) 오류 코드 → 액션이 message 로 폼이 하단 role="status" 에 PGlite 출력

1,000자 제한이 세 번 나와요. 브라우저는 편의, zod 는 화면을 거치지 않은 요청 대비, CHECK 는 앱이 바뀌어도 남는 마지막 선. 겹치는 검사는 낭비가 아니라 층마다 막는 요청의 종류가 다른 거예요. 브라우저는 실수를, 서버 검증은 우회를, DB 는 앱의 버그를 막습니다.

셋째 규칙은 돌아오는 값의 자리예요. 칸의 오류는 그 칸 아래, 칸이 아닌 실패(로그인 만료, 저장 실패)는 폼 하단의 공통 영역. ActionState 가 errors 와 message 를 따로 둔 이유가 이거예요.

층 들어오는 것 나가는 것 실패의 모양
폼 사용자 입력 (state) FormData 받은 state 를 그린다
검증 함수 FormData (문자열) values 또는 errors { values: null, errors }
액션 (prevState, formData) redirect ActionState 를 return
표 · 정책 · 뷰 네 열 + 토큰 저장된 줄 (작성자는 DB 가) 오류 코드

상품 문의 폼을 체크리스트로 풀면?

칸 하나를 만들 때 묻는 질문은 여섯이에요. ① name 이 있나 ② 전송되는 값의 모양 ③ 액션의 변환 ④ zod 규칙 ⑤ 표의 열과 제약 ⑥ 실패하면 어디에 뜨나. 이 여섯을 모든 칸에 같은 열로 적용하면 이렇게 돼요. 작성자는 폼에 칸이 없는데도 한 행을 차지해요.

칸 ① name ② 전송값 ③ 변환 ④ zod ⑤ 표의 열·제약 ⑥ 실패 시
상품 productId (hidden) uuid 문자열 String(… ?? '') z.uuid() product_id FK → products errors.productId (화면엔 칸이 없어 하단)
내용 content 입력 문자열, 공백 포함 String(… ?? '') .trim().min(1).max(1000).refine(금지 문자) content NOT NULL, CHECK 1~1000 errors.content 칸 아래 · 1,001자는 DB 도 23514
알림 notify 'on' 또는 없음 === 'on' z.boolean() notify boolean DEFAULT true 실패 없음
공개 여부 isPublic 'true' 또는 'false' === 'true' z.boolean() is_public boolean DEFAULT true 실패 없음
휴대폰 없음 전송 안 됨 — — 열 없음 (users.phone 을 보여 주기만) —
작성자 없음 쿠키의 토큰 (헤더) — — user_id DEFAULT auth.uid(), WITH CHECK 남의 id 면 42501

문 쪽도 같은 방식으로 적어요. 로그인 확인은 세 곳이고, 세 곳이 하는 일이 달라요.

확인하는 곳 없으면 왜 여기서 이 글의 근거
상세 페이지 (서버) 「문의하기」가 링크 대신 모달 트리거가 된다 왜 이동하는지 먼저 말해 준다 아래 모달 캡처
문의 페이지 (서버) /login?redirectUrl=/product/{id}/inquiry 로 보낸다 주소를 직접 쳐서 들어오는 경우 10-23 기록 (curl 로 NEXT_REDIRECT 확인)
액션 errors.login 을 돌려준다 페이지를 띄운 뒤 로그아웃한 경우 아래 폼 캡처

검증용 임시 페이지에서 정상 내용을 적고 등록을 누른 뒤. 입력은 그대로이고 폼 하단에 “로그인 후 문의를 남길 수 있습니다. 로그인” 빨간 문구 DB 없이 띄운 검증용 임시 페이지라 로그인 토큰이 없다. 검증을 통과한 뒤 액션의 둘째 문에서 멈춰 errors.login 이 돌아왔고, 입력은 지워지지 않았다

상품 문의 제목 아래 “문의는 로그인 후 작성 가능합니다. 로그인하시겠습니까?” 문구와 취소·확인 버튼이 있는 모달 같은 임시 페이지에서 비로그인 상태의 「문의하기」를 눌렀을 때. 확인은 redirectUrl 이 붙은 로그인 주소로 가는 링크다

등록이 끝나면 액션이 /product/{id}#faq 로 보내요. 어느 탭이 열려 있는지는 브라우저의 state 라 서버가 모르니, 주소 해시로 알리고 탭 컴포넌트가 처음 그려진 뒤 한 번 읽어요. 그 장면의 캡처는 이 글에 없어요. 이 글을 쓰면서는 팀 DB 에 새 줄을 남기지 않았고, 10월 23일에 폼으로 등록한 시험 문의 두 줄은 위 표 캡처에 있어요.

처음 설계한 것은 무엇이 달랐고 어떻게 고쳤나?

설계는 10월 21일에 한 번에 썼지만 코드는 사흘에 걸쳐 바뀌었어요. 설계에서 코드로 옮길 때 달라진 것, 코드를 쓴 다음 날 다시 고친 것, 아직 못 고친 것을 한 표에 둡니다.

자리 10-21 설계 무엇이 틀렸나 지금 (10-23 저장소)
라디오·체크박스 defaultChecked 로 두고 텍스트만 제어 React 19 가 액션 뒤 폼을 reset 해 DOM 의 선택이 state 와 어긋났다 (10-21 밤 실측, 비공개가 공개로 저장) 셋 다 state + onSubmit 에서 직접 제출
액션 파일 이름 inquiry.actions.ts 10-22 에 inquiry.action.ts 로 썼다. 팀 규칙과 폼의 import 가 어긋남 10-23 git mv 로 .actions.ts
금지 문자 \ / : < > 다섯 10-22 에 | 를 보태 여섯이 됐는데 안내문은 다섯을 말했다 다섯으로 되돌림
날짜 표기 2026.08.28 replaceAll('/', '.') 를 썼는데 en-CA 로케일은 - 를 내서 아무것도 안 바뀌었다. 테스트가 - 를 기대해 통과했다 replaceAll('-', '.') + 기대값 2026.08.28
테스트 러너 node:test 팀 설정이 vitest 로 *.test.ts 를 모은다 vitest
액션의 주석 작성자는 DB 가 채운다 10-22 에 “auth.uid() 활용하지 않음” 이라 적었다. 코드는 맞고 주석이 틀렸다 아직 그대로

첫 줄이 이 글의 첫 문장이에요. 10월 21일 설계에서는 텍스트 칸만 제어 입력으로 두고 라디오와 체크박스는 defaultChecked 로 뒀어요. 그날 밤 비공개가 공개로 저장된 원인은 React 19 가 action 으로 제출된 폼을 액션이 끝나면 자동으로 reset 하는 동작이었어요. 텍스트는 state 로 다시 그려지니 남았지만, 라디오의 선택은 reset 뒤 state 와 어긋난 채 보였던 거예요. 고친 방법은 팀의 다른 폼이 먼저 겪고 둔 것과 같아요. onSubmit 에서 기본 제출을 막고 startTransition 안에서 formAction 에 FormData 를 직접 넘겨 reset 경로를 타지 않게 하는 것. action={formAction} 은 자바스크립트가 뜨기 전의 제출용으로 남겨 뒀고, 라디오와 체크박스도 state 에 뒀습니다.

왼쪽 ‘전’ 은 비공개 선택·빈 내용 제출·reset·공개로 저장까지 네 단계, 오른쪽 ‘후’ 는 preventDefault 와 startTransition 으로 직접 제출해 비공개가 유지되는 네 단계 전(10-21 첫 실행)과 후(직접 제출). 위의 폼 캡처가 ‘후’ 의 상태다

위 폼 캡처가 ‘후’ 예요. 금지 문자를 넣고 「비공개」를 고른 뒤 등록했고, 오류가 돌아온 뒤에도 라디오가 「비공개」에 있다는 걸 눈으로도, 스크립트로 DOM 의 checked 를 읽어서도 확인했어요. 다만 이건 검증용 임시 페이지의 결과예요. 팀 DB 에는 10월 23일에 이 폼으로 공개 한 줄, 비공개 한 줄을 등록했고(위 표 캡처), 비공개 줄이 비공개로 남아 있는 것이 고친 뒤의 결과예요.

넷째 줄은 다른 종류의 교훈이에요. 10월 22일에 테스트 4개가 모두 통과했는데 날짜 표기는 설계와 달랐어요. 기대값을 그날의 출력 그대로 적어 뒀기 때문이에요. 테스트가 현재 출력을 기대값으로 박으면 버그를 보호하게 돼요. 기대값은 코드가 아니라 요구에서 가져와야 합니다. 23일에 기대값을 2026.08.28 로 먼저 고치고 코드를 맞췄어요.

마지막 줄은 아직 그대로예요. 코드는 user_id 를 보내지 않고 DB 가 채우는데, 바로 위 주석은 “auth.uid() 활용하지 않음” 이라고 말해요. 이해하기 전에 적은 문장을 이해한 뒤에도 안 고쳤고, 이 글을 쓰며 열어 보니 여전히 있습니다. 다음 커밋에서 고칠 거예요.

직접 겪은 함정은 무엇이었나?

1. React 19 는 액션이 끝나면 폼을 reset 한다. 위 교정 절의 첫 줄이에요. 재현 근거는 10월 21일 기록의 FAIL 과, 고친 뒤의 상태인 이 글의 폼 캡처예요. 라디오·체크박스가 있는 폼이면 defaultChecked 만으로는 안 돼요.

2. 테스트가 버그를 보호했다. replaceAll('/', '.') 은 조용히 아무 일도 안 했고 테스트는 하이픈을 기대해서 통과했어요. 기대값을 요구에서 가져왔다면 22일에 잡혔을 거예요.

3. 타입 검사가 보지 못하는 자리가 있다. String() 없이 safeParse 에 바로 넘기는 변형은 이 세션에서 오류 0 으로 통과했어요. 검증 함수의 입구가 unknown 이라서요. 그 네 줄의 변환은 타입이 아니라 테스트가 지켜요.

4. pull 이 처음으로 갈라졌고, 머지를 골랐다가 rebase 로 되돌렸다. 10월 22일 17시 25분, 그때까지 늘 fast-forward(원격이 내 커밋의 조상이라 포인터만 앞으로 옮기는 병합) 였던 pull 이 처음으로 갈라졌어요. 팀 브랜치에 PR 네 건이 먼저 들어가 있었거든요. 머지 커밋이 생겼고, 메시지 편집 화면에서 뭘 해야 할지 몰라 # 안내문이 메시지에 그대로 들어갔어요. 그 위에 18시 53분 커밋을 올렸고요. 19시에 pull --rebase 로 내 커밋을 떼어 최신 끝에 다시 붙였고, 20시 50분에 한 번 더 붙인 뒤 액션 커밋을 얹어 push 했어요. 이 글을 쓰며 reflog(HEAD 가 가리킨 곳의 이력) 를 다시 읽어 머지 커밋이 현재 커밋의 조상이 아닌 것을 확인했어요.

231ea4a HEAD@{2026-10-22 17:25:08}: commit (merge): Merge branch 'develop' …
7c571f6 HEAD@{2026-10-22 18:53:59}: commit: feat. 문의타입과 검증기능 추가
7878fca HEAD@{2026-10-22 19:00:15}: pull --rebase origin main (finish)
1ef56e7 HEAD@{2026-10-22 20:50:34}: pull --rebase origin develop (finish)
026ac48 HEAD@{2026-10-22 20:50:58}: commit: feat: 문의액션 페이지 추가

위 레인은 17:25 머지 커밋과 18:53 내 커밋이 갈래로 남은 모습, 아래 레인은 rebase 두 번 뒤 팀 커밋들 끝에 같은 커밋이 새 해시로 붙고 push 된 한 줄 히스토리 머지로 갔다가 rebase 로 되돌린 하루. 되돌릴 수 있었던 조건은 하나, 아직 push 하지 않았다는 것

rebase 는 커밋을 새 바닥 위에 다시 만들어요. 그래서 같은 내용인데 해시가 두 번 바뀌었고(7c571f6 → 7878fca → 1ef56e7), 문의 폴더의 diff 는 0줄이었어요. rebase 를 해도 되는 조건은 하나예요. 그 커밋을 아직 아무도 받지 않았는가. 둘 다 push 전이었고 원격은 전날 커밋에 있어서 push 는 강제 없이 들어갔어요. 이미 올린 커밋이었다면 팀원의 히스토리가 깨집니다. 수업에서 황금률이라 부르던 게 이거였어요.

5. 코드는 맞는데 주석이 틀렸다. 교정 절 마지막 줄. 테스트도 린트도 타입 검사도 주석은 안 읽어요. 커밋 전에 설계 노트와 대조하는 습관이 유일한 방어예요.

요약

쓰기 경로는 표에서 폼으로 만들고 폼에서 표로 돈다. 각 부품은 자기 오른쪽이 무엇을 거절할지 알고 만들어진다.

FormData 는 문자열만 나른다. 타입은 서버의 실행 중 검증이 만들고, 결과는 values 또는 errors 한 값으로 돌아온다.

서버 액션은 공개 입구라 화면이 막은 것을 다시 막고, 실패는 ActionState 로 돌아가며 성공은 redirect 로 끝난다. 작성자는 코드가 아니라 토큰이 정한다.

폼은 액션을 useActionState 로 감싸야 상태를 얻고, 입력의 진실은 state 에 두며, 제출 중에는 전체를 잠근다. React 19 의 자동 reset 은 직접 제출로 비켜 간다.

저는 이제 쓰기 폼을 만들 때 칸마다 “이 값은 어디서 거절되고 거절은 어디로 돌아오나” 를 먼저 적어요. 그 한 줄이 비어 있는 칸이 10월 21일 밤의 라디오였습니다.

여기까지가 ③-2 예요. 3편 두 글로 표가 화면이 되고 폼이 표가 되는 양방향을 봤습니다. 다음 4편 프론트와 백엔드 연결에서는 서버와 브라우저의 두 연결 객체, 환경 변수, 권한 정책의 팀 DB 실측, 배포를 다뤄요. 이 글에서 하지 않은 팀 DB 를 향한 등록 실측이 4편의 첫 장면이에요.

출처 — 개념은 수업의 React·Next.js·데이터베이스·Git 강의와 수업 가이드에서 배운 것을 제 말로 다시 썼다. PGlite 실행(PGlite 0.5.11, PostgreSQL 18.6), 타입 검사, vitest, 폼 화면 네 장, 서버 로그, reflog 는 2026-10-24 에 팀 브랜치의 임시 작업본(Next.js 16.4, React 19.3, zod 4, TypeScript 5)에서 직접 얻었다. 표·뷰·정책의 SQL 은 10-21 설계 노트의 것이며 팀 저장소의 db 폴더에는 아직 없고, 팀 DB 에 적용된 모습은 2026-10-25 에 Supabase 대시보드에서 찍었다. 날짜만 적은 기록은 10-21 설계 노트와 10-22 · 10-23 작업일지다.

관련 키워드

Next.js 서버 액션useActionStatezod 검증FormDataRow Level SecurityPostgreSQL CHECK DEFAULTReact 19 폼git rebase쇼핑몰 문의 폼

앤디의 마케팅노트

마케팅을 시스템으로 만드는 과정을 매주 기록해요. 잘 된 것도, 안 된 것도 같이 봐요.

maily에서 구독하기