개발 단계 (Development Stage)/개발 (Development)

[사이드 프로젝트] 회원 탈퇴 기능 구현을 통해 배운 React 상태 관리와 사용자 경험 개선 이야기

vanillinav 2025. 4. 3. 23:28
728x90
반응형

안녕하세요.


오늘은 최근 진행한 회원 탈퇴 기능 구현 과정에서 경험한 기술적 고민과 해결 과정, 그리고 그 속에서 배운 점들을 공유드리고자 합니다.

[출처] 핀터레스트 - 회원탈퇴

 

프로젝트 배경

처음 회원 탈퇴 기능을 구현하라는 요청을 받았을 때, 단순히 API 연동만 하면 되는 간단한 작업이라 생각했습니다.

하지만 실제 구현 과정에서 마주한 다양한 고민들이 있었습니다. :-)

초기 요구사항 분석

  1. 회원 탈퇴 전 주의사항 안내
  2. 비밀번호 재확인
  3. 진행 중인 예약이 있는 경우 탈퇴 제한

이러한 요구사항들을 단순히 기능적으로만 구현하는 것이 아니라, 어떻게 하면 사용자에게 더 나은 경험을 제공할 수 있을지 고민했습니다.

기술적 도전과 해결 과정

1. 상태 관리의 진화

처음에는 이렇게 단순한 로컬 상태로 시작했습니다.

const Withdrawal = () => {
  const [step, setStep] = useState(1);
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  // ... 더 많은 상태들
}

하지만 기능이 복잡해지면서 몇 가지 문제점이 드러났습니다....

상태 간 의존성 증가

// 이전 코드의 문제점
const Withdrawal = () => {
  const [step, setStep] = useState(1);
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  // 상태 간 의존성이 컴포넌트 내부에 산재
  const handleNext = () => {
    if (step === 1) {
      setStep(2);
      setError(''); // step 변경 시 error 초기화 필요
    }
  };

  const handleWithdrawal = async () => {
    setIsLoading(true); // 로딩 상태 관리
    setError(''); // 에러 상태 초기화
    try {
      // API 호출...
    } catch (err) {
      setError(err.message);
    } finally {
      setIsLoading(false);
    }
  };
};

비동기 처리의 복잡성

const Withdrawal = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState('');

  const handleWithdrawal = async () => {
    setIsLoading(true);
    try {
      await withdrawalAPI(password);
      // 성공 처리
      // 캐시 무효화
      // 네비게이션
    } catch (err) {
      setError(err.message);
    } finally {
      setIsLoading(false);
    }
  };
};

에러 상태 관리

catch (err) {
  setError(err.message); // 단순히 에러 메시지만 저장
}

 

이러한 문제들을 해결하기 위해 'Zustand'와 'React Query'를 도입했습니다.

// Zustand로 해결한 방식
interface WithdrawalState {
  step: number;
  error: string | null;
  isLoading: boolean;
  setStep: (step: number) => void;
  startLoading: () => void;
  stopLoading: () => void;
  setError: (error: string | null) => void;
  resetState: () => void;
}

const useWithdrawalStore = create<WithdrawalState>((set) => ({
  step: 1,
  error: null,
  isLoading: false,
  setStep: (step) => set((state) => ({ 
    ...state,
    step,
    error: null // step 변경 시 자동으로 error 초기화
  })),
  startLoading: () => set({ isLoading: true, error: null }),
  stopLoading: () => set({ isLoading: false }),
  setError: (error) => set({ error, isLoading: false }),
  resetState: () => set({ step: 1, error: null, isLoading: false })
}));

// 컴포넌트에서의 사용
const WithdrawalForm = () => {
  const { step, setStep, error } = useWithdrawalStore();
  // 상태 간 의존성이 store 내부로 캡슐화됨
};
// React Query로 해결한 방식
const useWithdrawalMutation = () => {
  const queryClient = useQueryClient();
  const { resetState } = useWithdrawalStore();
  const navigate = useNavigate();

  return useMutation({
    mutationFn: withdrawalAPI,
    onMutate: () => {
      // 낙관적 업데이트 처리
      queryClient.cancelQueries(['user']);
    },
    onSuccess: () => {
      // 자동으로 로딩 상태 관리
      queryClient.invalidateQueries(['user']);
      resetState();
      navigate('/login', { 
        replace: true,
        state: { message: '회원탈퇴가 완료되었습니다.' }
      });
    },
    onError: (error) => {
      // 타입별 에러 처리
      if (error instanceof ValidationError) {
        useWithdrawalStore.getState().setError('비밀번호가 일치하지 않습니다.');
      } else if (error instanceof NetworkError) {
        useWithdrawalStore.getState().setError('네트워크 오류가 발생했습니다.');
      }
    },
    onSettled: () => {
      // 성공/실패 상관없이 실행되어야 하는 로직
      queryClient.invalidateQueries(['user']);
    }
  });
};

// 컴포넌트에서의 사용
const WithdrawalForm = () => {
  const withdrawal = useWithdrawalMutation();
  
  return (
    <form onSubmit={() => withdrawal.mutate(password)}>
      {/* isLoading, isError 등의 상태를 자동으로 관리 */}
      {withdrawal.isLoading && <LoadingSpinner />}
      {withdrawal.isError && <ErrorMessage error={withdrawal.error} />}
    </form>
  );
};

 

// 개선된 에러 처리 시스템
// 에러 타입 정의
interface WithdrawalError {
  code: string;
  message: string;
  field?: string;
  action?: {
    label: string;
    handler: () => void;
  };
}

// 에러 처리 유틸리티
const handleWithdrawalError = (error: unknown): WithdrawalError => {
  if (error instanceof ValidationError) {
    return {
      code: 'VALIDATION_ERROR',
      message: '비밀번호가 일치하지 않습니다.',
      field: 'password',
      action: {
        label: '비밀번호 찾기',
        handler: () => navigate('/password-reset')
      }
    };
  }

  if (error instanceof ReservationError) {
    return {
      code: 'ACTIVE_RESERVATION',
      message: '진행 중인 예약이 있어 탈퇴할 수 없습니다.',
      action: {
        label: '예약 확인하기',
        handler: () => navigate('/reservations')
      }
    };
  }

  return {
    code: 'UNKNOWN_ERROR',
    message: '알 수 없는 오류가 발생했습니다.'
  };
};

// React Query와 통합
const useWithdrawalMutation = () => {
  return useMutation({
    onError: (error) => {
      const handledError = handleWithdrawalError(error);
      useWithdrawalStore.getState().setError(handledError);
    }
  });
};

이러한 개선을 통해 상태 로직이 한 곳에서 관리 되어 예측이 가능,  명확한 비동기 작업의 생명 주기, 체계화된 에러처리를 통해 사용자에게 더 나은 피드백을 제공할 수 있게 되었습니다.

2. 사용자 경험 개선을 위한 고민

단순히 기능이 작동하는 것을 넘어, 사용자가 실수로 탈퇴하는 것을 방지하고 충분한 정보를 제공하는 것이 중요했습니다.

interface PasswordConfirmationProps {
  onSubmit: (password: string) => void;
  isLoading?: boolean;
}

const PasswordConfirmation = ({ onSubmit, isLoading }: PasswordConfirmationProps) => {
  const { password, setPassword, error, validate } = useWithdrawalForm();
  const { type, shown, toggle } = usePasswordToggle();
  const [focused, setFocused] = useState(false);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (validate()) {
      onSubmit(password);
    }
  };

  return (
    <section 
      className={styles.passwordSection}
      aria-labelledby="password-confirmation-title"
    >
      {/* 단계별 안내 메시지 제공 */}
      <header className={styles.header}>
        <h2 id="password-confirmation-title">비밀번호 확인</h2>
        <p className={styles.description}>
          회원님의 정보 보호를 위해 비밀번호를 한 번 더 확인합니다.
          <br />
          <strong>탈퇴 후에는 복구가 불가능</strong>하니 신중하게 진행해 주세요.
        </p>
      </header>

      <form onSubmit={handleSubmit} className={styles.form}>
        <div 
          className={`${styles.inputWrapper} ${focused ? styles.focused : ''}`}
          role="group"
          aria-labelledby="password-label"
        >
          <InputField
            id="withdrawal-password"
            name="password"
            type={type}
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            onFocus={() => setFocused(true)}
            onBlur={() => setFocused(false)}
            placeholder="비밀번호를 입력해주세요"
            aria-required="true"
            aria-invalid={!!error}
            aria-describedby={error ? "password-error" : "password-description"}
            disabled={isLoading}
            endAdornment={
              <PasswordToggleButton
                onClick={toggle}
                shown={shown}
                aria-label={shown ? "비밀번호 숨기기" : "비밀번호 표시"}
                disabled={isLoading}
              />
            }
          />
          
          {error && (
            <div 
              id="password-error" 
              className={styles.errorMessage}
              role="alert"
            >
              <ErrorIcon aria-hidden="true" />
              {error}
            </div>
          )}
        </div>

        <footer className={styles.footer}>
          {/* 
             * 실수 방지 
             * ㄴ 비밀번호 입력 전 버튼 비활성화
             * ㄴ 처리 중 중복 클릭 방지
             * ㄴ 위험 동작임을 알리는 시각적 표시
            */} 
          <Button
            type="submit"
            variant="danger"
            disabled={!password || isLoading}
            isLoading={isLoading}
          >
            {isLoading ? '처리 중...' : '회원 탈퇴'}
          </Button>
        </footer>
      </form>
    </section>
  );
};

이러한 개선을 통해 사요자가 실수로 탈퇴하는 것을 방지하고 진행 상황을 명확하게 인지할 수 있도록하였습니다.

3. 에러 처리의 세분화

에러 처리는 단순히 메시지를 보여주는 것을 넘어, 상황별로 적절한 대응이 필요했습니다.

const handleWithdrawalError = (error: unknown) => {
  if (error instanceof NetworkError) {
    return {
      message: '일시적인 네트워크 문제가 발생했습니다. 잠시 후 다시 시도해 주시겠습니까?',
      action: {
        label: '다시 시도',
        handler: () => mutation.retry()
      }
    };
  }

  if (error instanceof ValidationError) {
    return {
      message: '비밀번호가 일치하지 않습니다. 다시 한 번 확인해 주세요.',
      field: 'password'
    };
  }

  // ... 기타 에러 케이스
};

배운 점과 향후 개선 방향

1. 기술적 인사이트

이번 구현을 통해 몇 가지 중요한 인사이트를 얻을 수 있었습니다. :-) 

  1. 상태 관리 전략의 중요성
    • 단순한 기능도 확장성을 고려한 설계가 필요
    • 전역/로컬 상태의 적절한 분리가 중요
  2. 에러 처리의 체계화
    • 사용자 친화적인 에러 메시지
    • 복구 가능한 상황에 대한 대처 방안 제공
  3. 접근성과 사용성의 균형(적다보니.. 많아서 접근성 관련 내용은 생략합니다~...)
    • 키보드 네비게이션 지원
    • 스크린 리더 사용자를 위한 적절한 설명 제공

2. 향후 개선 계획

현재 구현에 만족하지 않고, 다음과 같은 개선을 계획하고 있습니다.

  1. 분석 기능 강화
    interface WithdrawalAnalytics {
         reason: string;
         hasActiveReservations: boolean;
     
         timeSpentOnService: number; // ... 기타 분석 데이터.
    }
  2. 사용자 피드백 시스템 개선
    • 탈퇴 사유 수집 및 분석
    • 서비스 개선을 위한 피드백 활용
  3. 복구 프로세스 구현
    • 일정 기간 내 계정 복구 기능
    • 데이터 백업 및 복원 가이드

마치며

이번 구현을 통해 단순해 보이는 기능도 사용자 경험을 고려하면 깊이 있게 발전시킬 수 있다는 것을 배웠습니다. 여러분의 프로젝트에서도 이러한 관점이 도움이 되길 바랍니다.

제가 나눈 경험에 대해 궁금하신 점이나 다른 의견이 있으시다면 언제든 말씀해 주세요. 함께 이야기를 나누며 더 나은 해결책을 찾아갈 수 있을 것 같습니다. 들어주셔서 감사합니다. 

728x90
반응형