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

오늘은 최근 진행한 회원 탈퇴 기능 구현 과정에서 경험한 기술적 고민과 해결 과정, 그리고 그 속에서 배운 점들을 공유드리고자 합니다.
프로젝트 배경
처음 회원 탈퇴 기능을 구현하라는 요청을 받았을 때, 단순히 API 연동만 하면 되는 간단한 작업이라 생각했습니다.
하지만 실제 구현 과정에서 마주한 다양한 고민들이 있었습니다. :-)
초기 요구사항 분석
- 회원 탈퇴 전 주의사항 안내
- 비밀번호 재확인
- 진행 중인 예약이 있는 경우 탈퇴 제한
이러한 요구사항들을 단순히 기능적으로만 구현하는 것이 아니라, 어떻게 하면 사용자에게 더 나은 경험을 제공할 수 있을지 고민했습니다.
기술적 도전과 해결 과정
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. 기술적 인사이트
이번 구현을 통해 몇 가지 중요한 인사이트를 얻을 수 있었습니다. :-)
- 상태 관리 전략의 중요성
- 단순한 기능도 확장성을 고려한 설계가 필요
- 전역/로컬 상태의 적절한 분리가 중요
- 에러 처리의 체계화
- 사용자 친화적인 에러 메시지
- 복구 가능한 상황에 대한 대처 방안 제공
- 접근성과 사용성의 균형(적다보니.. 많아서 접근성 관련 내용은 생략합니다~...)
- 키보드 네비게이션 지원
- 스크린 리더 사용자를 위한 적절한 설명 제공
2. 향후 개선 계획
현재 구현에 만족하지 않고, 다음과 같은 개선을 계획하고 있습니다.
- 분석 기능 강화
interface WithdrawalAnalytics {
reason: string;
hasActiveReservations: boolean;
timeSpentOnService: number; // ... 기타 분석 데이터.
} - 사용자 피드백 시스템 개선
- 탈퇴 사유 수집 및 분석
- 서비스 개선을 위한 피드백 활용
- 복구 프로세스 구현
- 일정 기간 내 계정 복구 기능
- 데이터 백업 및 복원 가이드
마치며
이번 구현을 통해 단순해 보이는 기능도 사용자 경험을 고려하면 깊이 있게 발전시킬 수 있다는 것을 배웠습니다. 여러분의 프로젝트에서도 이러한 관점이 도움이 되길 바랍니다.
제가 나눈 경험에 대해 궁금하신 점이나 다른 의견이 있으시다면 언제든 말씀해 주세요. 함께 이야기를 나누며 더 나은 해결책을 찾아갈 수 있을 것 같습니다. 들어주셔서 감사합니다.