본문 바로가기

React

React에서 사용자 프로필 편집 페이지 제작하기

사용자 프로필 편집 페이지는 입력 폼, 파일 업로드, 서버 검증, 접근성까지 골고루 필요한 화면입니다. 이 글에서는 React Hook Form과 Zod를 사용해 빠르고 견고한 프로필 편집 페이지를 만드는 방법을 실무 관점에서 정리합니다.

1. 무엇을 만들 것인가

디스플레이 이름, 사용자명, 자기소개, 웹사이트, 위치, 아바타 이미지를 수정하는 폼을 만듭니다. 변경 사항이 있을 때만 저장 버튼이 활성화되며, 사용자명 실시간 중복 검사, 아바타 미리보기, 낙관적 업데이트를 적용합니다.

2. 패키지 설치와 기본 구조

React Hook Form과 Zod를 사용합니다. 프로젝트 구조는 api 유틸, 폼 컴포넌트, 페이지 컨테이너로 나눕니다.

// 설치
// npm i react-hook-form zod @hookform/resolvers
// 또는
// pnpm add react-hook-form zod @hookform/resolvers

3. API 유틸 만들기

JSON 요청/응답과 오류 처리를 표준화합니다. 업로드용 프리사인드 URL도 요청합니다.

// src/lib/fetcher.js
export async function fetchJSON(url, options = {}) {
  const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
  const res = await fetch(url, { credentials: 'include', ...options, headers });
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    let detail;
    try { detail = JSON.parse(text); } catch { detail = { message: text || res.statusText }; }
    const err = new Error(detail.message || 'Request failed');
    err.status = res.status;
    err.detail = detail;
    throw err;
  }
  if (res.status === 204) return null;
  return res.json();
}

// 사용 예시:
// await fetchJSON('/api/me', { method: 'PATCH', body: JSON.stringify(payload) });

4. 프로필 편집 폼 구현

React Hook Form과 Zod로 검증과 상태를 관리합니다. 아바타 업로드는 프리사인드 URL로 PUT 업로드 후 fileUrl을 저장합니다. 변경 사항이 없으면 저장 버튼이 비활성화되며, 제출 중 중복 제출을 방지합니다.

// src/components/ProfileEditForm.jsx
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { fetchJSON } from '../lib/fetcher';

const profileSchema = z.object({
  displayName: z.string().min(2, '이름은 2자 이상입니다.').max(40, '최대 40자입니다.'),
  username: z.string()
    .toLowerCase()
    .regex(/^[a-z0-9_]{3,20}$/i, '영문 소문자, 숫자, 밑줄 3~20자입니다.'),
  bio: z.string().max(160, '최대 160자입니다.').optional().or(z.literal('')),
  website: z.string().url('올바른 URL을 입력하세요.').optional().or(z.literal('')),
  location: z.string().max(50, '최대 50자입니다.').optional().or(z.literal('')),
  avatarFile: z.any().optional(), // FileList
});

export default function ProfileEditForm({ initialUser, onSaved }) {
  const { register, handleSubmit, watch, setError, clearErrors, reset, formState } = useForm({
    resolver: zodResolver(profileSchema),
    mode: 'onChange',
    defaultValues: {
      displayName: initialUser.displayName || '',
      username: initialUser.username || '',
      bio: initialUser.bio || '',
      website: initialUser.website || '',
      location: initialUser.location || '',
      avatarFile: undefined,
    },
  });
  const { errors, isDirty, isSubmitting, dirtyFields } = formState;

  const username = watch('username');
  const avatarFile = watch('avatarFile');

  // 아바타 미리보기 URL
  const previewUrl = useMemo(() => {
    if (avatarFile && avatarFile[0]) return URL.createObjectURL(avatarFile[0]);
    return initialUser.avatarUrl || '';
  }, [avatarFile, initialUser.avatarUrl]);

  // 미리보기 URL 정리
  useEffect(() => {
    return () => {
      if (avatarFile && avatarFile[0]) URL.revokeObjectURL(previewUrl);
    };
  }, [avatarFile, previewUrl]);

  // 사용자명 중복 검사 디바운스
  const [checkingUsername, setCheckingUsername] = useState(false);
  useEffect(() => {
    if (!username) return;
    const normalized = username.toLowerCase();
    if (normalized === (initialUser.username || '').toLowerCase()) {
      clearErrors('username');
      return;
    }
    if (!/^[a-z0-9_]{3,20}$/.test(normalized)) return;

    const ac = new AbortController();
    const t = setTimeout(async () => {
      try {
        setCheckingUsername(true);
        const res = await fetch(`/api/username/check?u=${encodeURIComponent(normalized)}`, { signal: ac.signal, credentials: 'include' });
        const data = await res.json().catch(() => ({ available: true }));
        if (!data.available) {
          setError('username', { type: 'validate', message: '이미 사용 중인 사용자명입니다.' });
        } else {
          clearErrors('username');
        }
      } catch (e) {
        // 네트워크 오류는 사용자 경험을 해치지 않도록 조용히 무시
      } finally {
        setCheckingUsername(false);
      }
    }, 400);
    return () => { ac.abort(); clearTimeout(t); };
  }, [username, initialUser.username, setError, clearErrors]);

  // 페이지 이탈 방지
  useEffect(() => {
    const onBeforeUnload = (e) => {
      if (isDirty && !isSubmitting) {
        e.preventDefault();
        e.returnValue = '';
      }
    };
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => window.removeEventListener('beforeunload', onBeforeUnload);
  }, [isDirty, isSubmitting]);

  async function uploadAvatarIfNeeded(fileList) {
    if (!fileList || !fileList[0]) return initialUser.avatarUrl || '';
    const file = fileList[0];
    if (!file.type.startsWith('image/')) {
      throw new Error('이미지 파일만 업로드할 수 있습니다.');
    }
    const presign = await fetchJSON('/api/uploads/avatar', {
      method: 'POST',
      body: JSON.stringify({ contentType: file.type }),
    });
    await fetch(presign.uploadUrl, {
      method: 'PUT',
      headers: { 'Content-Type': file.type },
      body: file,
    });
    return presign.fileUrl; // CDN/공개 URL
  }

  const onSubmit = handleSubmit(async (values) => {
    const payload = {
      displayName: values.displayName.trim(),
      username: values.username.trim().toLowerCase(),
      bio: (values.bio || '').trim(),
      website: (values.website || '').trim(),
      location: (values.location || '').trim(),
      avatarUrl: initialUser.avatarUrl || '',
    };

    // 낙관적 업데이트
    const optimisticUser = { ...initialUser, ...payload };
    try {
      if (values.avatarFile && values.avatarFile[0]) {
        payload.avatarUrl = await uploadAvatarIfNeeded(values.avatarFile);
      }
      if (onSaved) onSaved({ ...optimisticUser, avatarUrl: payload.avatarUrl });

      const saved = await fetchJSON('/api/me', {
        method: 'PATCH',
        body: JSON.stringify(payload),
      });

      // 서버 값 기준 재설정
      reset({
        displayName: saved.displayName || '',
        username: saved.username || '',
        bio: saved.bio || '',
        website: saved.website || '',
        location: saved.location || '',
        avatarFile: undefined,
      }, { keepDirty: false });

      if (onSaved) onSaved(saved);
    } catch (e) {
      if (onSaved) onSaved(initialUser);
      // 에러 바인딩
      if (e?.detail?.fields) {
        Object.entries(e.detail.fields).forEach(([key, msg]) => {
          setError(key, { type: 'server', message: String(msg) });
        });
      } else {
        setError('root', { type: 'server', message: e.message || '저장에 실패했습니다.' });
      }
      throw e;
    }
  });

  return (
    <form onSubmit={onSubmit} noValidate>
      <div>
        <label htmlFor="displayName">이름</label>
        <input
          id="displayName"
          type="text"
          aria-invalid={!!errors.displayName}
          aria-describedby={errors.displayName ? 'displayName-err' : undefined}
          {...register('displayName')}
        />
        {errors.displayName && <span id="displayName-err">{errors.displayName.message}</span>}
      </div>

      <div>
        <label htmlFor="username">사용자명</label>
        <input
          id="username"
          type="text"
          autoComplete="username"
          aria-invalid={!!errors.username}
          aria-describedby={(errors.username ? 'username-err ' : '') + (checkingUsername ? 'username-check' : '')}
          {...register('username')}
        />
        {checkingUsername && <span id="username-check">중복 검사 중...</span>}
        {errors.username && <span id="username-err">{errors.username.message}</span>}
      </div>

      <div>
        <label htmlFor="bio">자기소개</label>
        <textarea
          id="bio"
          rows={3}
          aria-invalid={!!errors.bio}
          aria-describedby={errors.bio ? 'bio-err' : undefined}
          {...register('bio')}
        />
        {errors.bio && <span id="bio-err">{errors.bio.message}</span>}
      </div>

      <div>
        <label htmlFor="website">웹사이트</label>
        <input
          id="website"
          type="url"
          placeholder="https://example.com"
          aria-invalid={!!errors.website}
          aria-describedby={errors.website ? 'website-err' : undefined}
          {...register('website')}
        />
        {errors.website && <span id="website-err">{errors.website.message}</span>}
      </div>

      <div>
        <label htmlFor="location">위치</label>
        <input
          id="location"
          type="text"
          aria-invalid={!!errors.location}
          aria-describedby={errors.location ? 'location-err' : undefined}
          {...register('location')}
        />
        {errors.location && <span id="location-err">{errors.location.message}</span>}
      </div>

      <div>
        <label htmlFor="avatarFile">아바타 이미지</label>
        <input
          id="avatarFile"
          type="file"
          accept="image/*"
          aria-invalid={!!errors.avatarFile}
          aria-describedby={errors.avatarFile ? 'avatarFile-err' : undefined}
          {...register('avatarFile')}
        />
        {errors.avatarFile && <span id="avatarFile-err">{errors.avatarFile.message}</span>}
        {previewUrl ? <img src={previewUrl} alt="아바타 미리보기" width="96" height="96" style={{ borderRadius: 8 }} /> : null}
      </div>

      {errors.root && <p role="alert">{errors.root.message}</p>}

      <div>
        <button type="submit" disabled={!isDirty || isSubmitting}>{isSubmitting ? '저장 중...' : '저장'}</button>
        <button type="button" onClick={() => reset(undefined, { keepDirty: false })} disabled={isSubmitting}>변경 취소</button>
      </div>
    </form>
  );
}

5. 라우팅과 페이지 통합

프로필 페이지에서 사용자 정보를 불러와 폼에 전달합니다. 저장 완료 시 상위 상태를 갱신합니다.

// src/pages/ProfilePage.jsx
import React, { useEffect, useState } from 'react';
import ProfileEditForm from '../components/ProfileEditForm';
import { fetchJSON } from '../lib/fetcher';

export default function ProfilePage() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState(null);

  useEffect(() => {
    let mounted = true;
    fetchJSON('/api/me')
      .then((u) => { if (mounted) setUser(u); })
      .catch((e) => setErr(e.message || '로드 실패'))
      .finally(() => setLoading(false));
    return () => { mounted = false; };
  }, []);

  if (loading) return <p>불러오는 중입니다...</p>;
  if (err) return <p>오류: {err}</p>;
  if (!user) return <p>사용자 정보를 찾을 수 없습니다.</p>;

  return (
    <section>
      <h2>프로필 편집</h2>
      <ProfileEditForm initialUser={user} onSaved={setUser} />
    </section>
  );
}

6. 실전 팁과 체크리스트

첫째, 저장 버튼은 isDirty와 isSubmitting으로 제어해 중복 저장을 방지합니다. 둘째, 사용자명 중복 검사는 디바운스로 서버 부하를 줄입니다. 셋째, 파일 업로드는 프리사인드 URL로 직접 전송해 백엔드 부담을 낮춥니다. 넷째, aria-invalid와 aria-describedby를 사용해 스크린 리더가 오류를 명확히 읽을 수 있도록 합니다. 다섯째, beforeunload 핸들러로 이탈 시 경고를 제공하지만 저장 완료 후 reset으로 더티 상태를 해제해 불필요한 경고를 막습니다. 여섯째, URL, 길이, 패턴 등 빠른 클라이언트 검증으로 사용자 시간을 절약합니다.

7. 에러 처리와 보안 고려

서버는 사용자명 중복, 금칙어, URL 허용 도메인 등 추가 검증을 수행합니다. 클라이언트는 서버의 필드별 오류를 setError로 바인딩합니다. 파일 업로드 시 MIME 타입과 파일 크기를 점검합니다. 텍스트 필드는 서버에서 최종적으로 HTML 이스케이프나 마크다운 파서 화이트리스트 등으로 안전하게 처리합니다.

8. 마무리

이 글의 구성으로 프로덕션 수준의 프로필 편집 페이지를 빠르게 구성할 수 있습니다. RHF와 Zod로 검증과 상태를 단순화하고, 프리사인드 업로드와 낙관적 업데이트로 체감 속도를 높이면 사용자가 만족하는 폼 경험을 제공할 수 있습니다.