블로그로 돌아가기
Reactjs

react useActionState 폼 에러 처리: 실무 가이드와 코드例

react useActionState 폼 에러 처리 방법을 실무 관점에서 정리합니다. 커스텀 훅 설계, 동기·비동기 에러 매핑, 코드 예제와 체크리스트로 바로 적용할 수 있습니다.

폼 에러 처리커스텀 훅클라이언트 유효성검사비동기 유효성검사
react useActionState 폼 에러 처리를 설명하는 다이어그램 이미지

react useActionState 폼 에러 처리이 필요한 상황

react useActionState 폼 에러 처리는 클라이언트-서버 간 유효성 불일치, 비동기 검증, 그리고 사용자 경험(UX)을 해치지 않으면서 에러를 일관되게 보여줘야 할 때 필요합니다. 이 글에서는 useActionState 기반의 폼 상태 관리에서 발생하는 동기/비동기 에러를 어떻게 구조화하고, 서버 에러를 필드에 안전하게 매핑하며, 에러 메시지 노출 시 주의할 점까지 실무 적용 가능한 패턴과 코드 예제로 설명합니다.

react useActionState 폼 에러 처리 핵심 개념

  • 중앙화된 에러 스토어: 각 필드와 전역 에러를 분리해 관리하면 UI에서의 조건부 렌더링이 명확해집니다.
  • 동기 유효성 검사 vs 비동기 유효성 검사: 즉시 검사(문자열 길이 등)와 서버 검증(중복 체크 등)은 처리 시점과 사용자 피드백이 달라야 합니다.
  • 액션 기반 업데이트: useActionState 패턴은 액션(dispatch)으로 상태를 변경하므로 에러 발생 시 어떤 액션이 트리거됐는지 추적하기 쉽습니다.

react useActionState 폼 에러 처리 실무 예제

아래 예시는 간단한 커스텀 useActionState 훅 구조와 폼 컴포넌트에서의 에러 처리 흐름입니다. 핵심은 액션 타입별로 상태를 관리하고 서버 응답을 필드 에러로 매핑하는 방법입니다.

// hooks/useActionState.tsx import { useReducer, useCallback } from 'react'; type State = { values: Record<string, any>; errors: Record<string, string>; globalError?: string; loading: boolean; }; type Action = | { type: 'SET_VALUE'; field: string; value: any } | { type: 'SET_ERROR'; field: string; message: string } | { type: 'SET_GLOBAL_ERROR'; message: string } | { type: 'CLEAR_ERRORS' } | { type: 'SET_LOADING'; loading: boolean }; function reducer(state: State, action: Action): State { switch (action.type) { case 'SET_VALUE': return { ...state, values: { ...state.values, [action.field]: action.value } }; case 'SET_ERROR': return { ...state, errors: { ...state.errors, [action.field]: action.message } }; case 'SET_GLOBAL_ERROR': return { ...state, globalError: action.message }; case 'CLEAR_ERRORS': return { ...state, errors: {}, globalError: undefined }; case 'SET_LOADING': return { ...state, loading: action.loading }; default: return state; } } export function useActionState(initialValues = {}) { const [state, dispatch] = useReducer(reducer, { values: initialValues, errors: {}, globalError: undefined, loading: false, }); const setValue = useCallback((field: string, value: any) => { dispatch({ type: 'SET_VALUE', field, value }); }, []); const setError = useCallback((field: string, message: string) => { dispatch({ type: 'SET_ERROR', field, message }); }, []); const setGlobalError = useCallback((message: string) => { dispatch({ type: 'SET_GLOBAL_ERROR', message }); }, []); const clearErrors = useCallback(() => dispatch({ type: 'CLEAR_ERRORS' }), []); const setLoading = useCallback((loading: boolean) => dispatch({ type: 'SET_LOADING', loading }), []); return { state, setValue, setError, setGlobalError, clearErrors, setLoading }; }
// components/MyForm.tsx import React from 'react'; import { useActionState } from '../hooks/useActionState'; export function MyForm() { const { state, setValue, setError, setGlobalError, clearErrors, setLoading } = useActionState({ email: '', username: '' }); const validateSync = () => { let ok = true; if (!state.values.email.includes('@')) { setError('email', '유효한 이메일을 입력하세요.'); ok = false; } return ok; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); clearErrors(); if (!validateSync()) return; setLoading(true); try { const res = await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(state.values), }); const data = await res.json(); if (!res.ok) { // 서버-클라이언트 에러 매핑 예시 if (data.fieldErrors) { Object.entries(data.fieldErrors).forEach(([field, msg]) => setError(field, String(msg))); } else { setGlobalError(data.message || '알 수 없는 오류'); } } else { // 성공 처리 console.log('회원가입 성공'); } } catch (err) { setGlobalError('네트워크 오류가 발생했습니다.'); } finally { setLoading(false); } }; return ( <form onSubmit={handleSubmit} noValidate> <div> <label>Email</label> <input value={state.values.email} onChange={e => setValue('email', e.target.value)} /> {state.errors.email && <div role="alert">{state.errors.email}</div>} </div> <div> <label>Username</label> <input value={state.values.username} onChange={e => setValue('username', e.target.value)} /> {state.errors.username && <div role="alert">{state.errors.username}</div>} </div> {state.globalError && <div role="alert">{state.globalError}</div>} <button type="submit" disabled={state.loading}>Submit</button> </form> ); }

설명: 위 패턴은 액션으로 상태를 변경해 에러가 어디서 발생했는지 추적하기 용이합니다. 서버가 필드별 에러를 반환하면 fieldErrors 형태로 매핑하고, 그렇지 않으면 전역 에러로 처리합니다.

언제 쓰면 좋고 언제 피해야 할까

  • 언제 쓰면 좋은가:

    • 서버의 필드 검증 에러를 각 입력 필드에 정확히 표시해야 할 때
    • 비동기 검증(사용자명 중복 등)과 동기 검증을 같이 운영할 때
    • 폼 상태와 에러 관리 로직을 액션 기반으로 일관되게 유지하고 싶을 때
  • 언제 피해야 할가:

    • 단순한 한두 필드의 작은 폼(예: 검색창)에는 오버엔지니어링일 수 있음
    • 이미 검증 로직이 강하게 결합된 서드파티 폼 라이브러리를 사용하는 경우(예: Formik 대체 불필요)

비교표: 에러 처리 패턴 비교

구분중앙화(액션 기반)로컬(필드별 로컬상태)라이브러리(Formik 등)
유지보수성높음낮음높음
확장성높음낮음보통
러닝커브보통낮음보통-높음
서버-필드 매핑 편의성높음보통높음

실무 체크리스트

  • 서버 에러 스펙(fieldErrors, message, code)을 명확히 정의했는가?
  • 클라이언트에서 동기/비동기 유효성 검사를 분리했는가?
  • 에러 메시지에 민감한 정보를 포함하지 않는가? (보안)
  • 로컬화(다국어) 고려: 에러 키를 메시지로 바로 쓰지 않는가?
  • 에러 상태 초기화 시점을 명확히 했는가? (컴포넌트 언마운트, 제출 시 등)
  • 접근성: role="alert" 또는 aria-live로 에러를 스크린리더에 노출했는가?

성능·보안·운영 주의점

  • 성능

    • 불필요한 re-render를 피하려면 상태 구조를 평면화하고 메모이제이션을 사용하세요.
    • 각 입력마다별도의 상태로 관리하면 렌더링 비용이 늘어납니다. 필요시 React.memo 사용을 고려하세요.
  • 보안

    • 서버 에러 메시지를 그대로 노출하지 마세요. 내부 스택 트레이스나 DB 오류는 사용자에게 보여주면 안 됩니다.
    • 에러 응답에 민감한 정보(쿼리, 파라미터 등)가 포함되지 않도록 서버 계약(API contract)을 설계하세요.
  • 운영

    • 에러 발생 패턴을 로깅(클라이언트·서버)해 빈번한 폼 실패 지점을 모니터링하세요.
    • 사용자 재현이 어려운 에러는 추가 컨텍스트(버전, 브라우저, 네트워크 상태)를 함께 로깅하세요.

자주 묻는 질문

Q: 서버에서 받은 fieldErrors 키가 항상 필드명과 일치해야 하나요? A: 이상적으로는 필드 네임을 계약으로 정해 일치시키는 것이 편합니다. 그러나 백엔드와 클라이언트가 다른 네이밍을 쓴다면 매핑 레이어(transform)를 두어 변환하세요.

Q: 글로벌 에러와 필드 에러를 동시에 보여줘도 되나요? A: 가능합니다. 일반적으로 필드별 에러는 필드 바로 아래에, 글로벌 에러는 폼 상단(또는 토스트)으로 분리해 UX를 명확히 하는 것이 좋습니다.

Q: 비동기 중복 검사로 인한 레이스 컨디션을 어떻게 방지하나요? A: 요청마다 토큰(예: incremental request id)을 부여하고, 응답이 도착했을 때 최신 요청의 응답인 경우에만 상태를 업데이트하세요. abort controller를 사용해 이전 요청을 취소하는 것도 방법입니다.

Q: 외부 폼 라이브러리 사용 vs 자체 useActionState, 어느 쪽이 좋나요? A: 복잡한 폼(조건부 필드, 대량 입력, 성능 튜닝)이 많다면 라이브러리가 빠른 생산성을 제공합니다. 반면, 서버 매핑 규칙이나 특정 액션 로깅이 필요한 경우 커스텀 훅이 더 유연합니다.

정리

  • useActionState 패턴은 액션 기반으로 폼 값과 에러를 중앙화해 추적이 용이하다.
  • 동기 검증과 비동기(서버) 검증은 분리하고, 서버의 fieldErrors를 안전하게 매핑하라.
  • 에러 메시지에서 민감한 정보를 제거하고 접근성 표준(role/aria-live)을 지켜라.
  • 체크리스트와 로깅으로 운영 중 발생하는 에러 패턴을 모니터링하라.
  • 간단한 폼에는 오버엔지니어링을 피하고, 복잡한 폼에서는 라이브러리와 커스텀 훅의 장단점을 고려해 선택하라.