본문 바로가기
tech epilogue

사용자들이 신청 완료 화면까지 볼 수 있도록!

by rami_ 2026. 4. 27.

폼 이탈을 막는 아키텍처 설계기

'어떻게 하면 이탈을 최소화할 수 있을까...'

 

폼을 만드는 프론트엔드 개발자, 디자이너, PM이라면 한 번쯤 해본 고민일 것입니다.

사용자 입장에서 가장 크게 느끼는 불편함은 두 가지입니다. 폼이 얼마나 남았는지 모르는 것, 그리고 중간에 이탈했다가 돌아왔더니 데이터가 모두 날아가 있는 것입니다. 이 두 가지를 해결하면 이탈률을 꽤 줄일 수 있습니다.

회사 프로젝트를 진행하면서 유지보수도 편하고, 사용자 이탈도 막을 수 있는 아키텍처를 어떻게 설계할지 고민했던 내용을 정리해봤습니다.

폼 규모부터 나눠보기

폼의 규모는 크게 소~중규모와 대규모로 나눌 수 있습니다.

대규모 폼은 보통 5스텝이 넘고, 답변에 따라 다음 질문이나 UI가 동적으로 바뀌는 경우입니다. 이런 경우엔 상태 관리 라이브러리를 활용하는 것이 일반적입니다.

반면 소~중규모 폼은 커스텀 훅과 localStorage 조합으로도 충분히 구현할 수 있습니다. 이번 글은 이 경우에 초점을 맞춥니다.

 

모바일 웹뷰에서 localStorage, 믿어도 될까?

localStorage를 쓸 때 주의해야 할 지점이 있습니다. 특히 모바일 웹뷰 환경에서입니다.

모바일 웹뷰는 브라우저가 아니라 앱이 띄운 내장 브라우저 인스턴스입니다. 즉, 생명주기가 브라우저 탭이 아니라 앱/액티비티/컴포넌트의 생명주기에 종속됩니다.

  • localStorage에 저장된 데이터는 앱의 디스크(파일 시스템)에 존재하기 때문에 웹뷰 인스턴스가 바뀌어도 그대로 살아있습니다.
  • sessionStorage는 웹뷰 인스턴스 메모리에 존재하기 때문에 인스턴스가 사라지면 함께 사라집니다.

localStorage가 유지되는 케이스 / 안 되는 케이스

새 웹뷰 인스턴스 생성
앱 삭제 후 재설치
앱 설정 > 데이터 초기화
iOS: WKWebsiteDataStore.nonPersistent() 사용
Android: WebStorage.getInstance().deleteAllData() 명시 호출
네이티브가 의도적으로 매번 초기화하도록 구현

 

핵심은 훅의 역할 분리

 

이번 설계에서 가장 중요하게 생각한 것은 훅의 역할을 명확하게 나누는 것이었습니다. 크게 세 가지로 분리했습니다.

  • 네비게이션 훅 (useDiagnosisNavigation): 순서와 흐름 관리
  • 폼 커스텀 훅 (useDiagnosisForm): 데이터 상태 관리
  • 검증 훅 (useDiagnosisValidation): 스텝별 유효성 검사

 

 

네비게이션 훅에는 animationStage라는 상태를 따로 두어서, 사용자가 단계를 이동할 때 UI 요소들이 순차적으로 나타나도록 제어했습니다.

 

UI에서 쓰는 데이터 구조와 서버에 보내야 하는 구조가 다를 때가 종종 있습니다. 이런 경우를 위해 폼 훅 내부에 getSubmitData 함수를 만들어서, 훅이 일종의 미들웨어 역할을 하도록 했습니다.

 

또 하나 중요한 것은, 사용자가 새로고침하거나 다시 돌아왔을 때 어디까지 작성했는지 정확하게 복원하는 로직입니다. 스토리지에 저장된 값을 날짜 피커나 셀렉트박스에 바로 넣으려면 데이터 보정이 필요하기 때문입니다. 그래서 값을 불러올 때마다 역직렬화(Deserialization) 함수를 거치도록 해서 데이터 정합성을 맞췄습니다.

 

검증 훅은 폼 상태를 관리하는 훅과 관심사가 다르기 때문에 별도로 분리했습니다. 스키마는 zod를 사용해 정의했는데, safeParse를 사용하면 검증 실패시 에러를 던지지 않기 때문에 입력 도중 실시간으로 호출되는 폼 검증에 안전하게 쓸 수 있습니다. PageContent에서는 isValidForm 하나만 받아서 다음 버튼 활성화 여부를 판단하면 되고, 스텝별 조건이 바뀌더라도 useDiagnosisValidation 내부만 수정하면 됩니다.

 

방어적 프로그래밍을 실감한 순간

이 아키텍처를 설계하면서 방어적 프로그래밍(Defensive Programming) 이라는 개념을 다시 한번 체감했습니다.

프론트엔드 개발자에게 하얀 화면만 노출되는 것은 절대 있어서는 안 되는 일입니다. 어떤 상황에서도 에러를 막으려는 노력이 방어적 프로그래밍과 닮아있다고 느꼈습니다.

방어적 프로그래밍의 핵심을 짧게 정리하면 다음과 같습니다.

  • 외부 데이터는 일단 의심한다
  • 터질 것 같으면 미리 터뜨린다
  • 불변성을 유지한다
  • 예측 가능한 예외 처리를 한다

이미 익히 알고 있는 내용이기도 하지만, 실제로 파싱 로직을 try-catch로 감싸거나 isMountedRef를 활용하는 과정이 결국 이 원칙들을 실천하는 과정이었습니다.

 

isMountedRef로 메모리 누수 막기

사용자가 애니메이션 도중 빠르게 '다음' 또는 '이전' 버튼을 누르면 이런 일이 생깁니다.

t=0ms   : step 1→2 이동, 타이머 4개 등록
t=50ms  : 사용자가 다시 step 1로 뒤로 감
           → 클린업 실행 → clearTimeout 4개 → isMountedRef = false
           → 새 effect 실행 → isMountedRef = true, 새 타이머 4개 등록

t=100ms : 이전 step2의 타이머 만료 시도
           → isMountedRef.current 확인 → false (이미 클린업됨)
           → setState 건너뜀 ✅ 메모리 누수 없음

컴포넌트가 언마운트된 뒤에도 setTimeout 콜백은 큐에 살아있습니다. 타이머가 만료돼서 콜백이 실행되면, 이미 사라진 컴포넌트의 setState가 호출될 수 있습니다. 메모리 누수와 예측 불가능한 상태 업데이트를 유발하는 것입니다.

useRef는 값이 바뀌어도 리렌더링이 일어나지 않고, 렌더링 사이클과 무관하게 항상 최신값을 유지합니다. 그래서 이런 상황에 딱 맞는 도구입니다.

const isMountedRef = useRef<boolean>(true);

useEffect(() => {
  isMountedRef.current = true; // 스텝 변경 시 마운트 상태 초기화

  return () => {
    isMountedRef.current = false; // 클린업 시 언마운트 표시
  };
}, [navigation.currentStep]); // 스텝이 바뀔 때마다 실행

 

Strict Mode에서 타이밍 버그가 생긴다면?

 

Strict Mode는 버그를 만드는 것이 아니라 드러내는 것입니다. 프로덕션에서 간헐적으로 터질 버그를 개발 중에 100% 재현시켜주기 때문에, Strict Mode에서 발생한 버그는 반드시 고쳐야 합니다.

 

두 Effect가 같은 의존성 배열을 가지고 있을 때가 문제입니다.

React는 같은 렌더 사이클에서 Effect를 선언 순서대로 실행하지만, Strict Mode의 강제 재실행 사이클에서는 두 Effect 사이의 타이밍이 미묘하게 어긋날 수 있습니다.

[Effect 실행 순서 - 두 Effect가 같은 dep로 묶여있음]

① Effect1 실행 → isMountedRef = true
② Effect2 실행 → isMountedRef.current 체크 → true → 타이머 4개 등록

③ Strict Mode 강제 클린업
   Effect2 클린업 → clearTimeout 4개 ✅ 타이머 제거됨
   Effect1 클린업 → isMountedRef = false

④ 재마운트
   Effect1 실행 → isMountedRef = true
   Effect2 실행 → 타이머 4개 다시 등록 ✅

이 흐름이 항상 보장된다면 좋겠지만, 최악의 시나리오가 존재합니다.

Effect1 클린업 → isMountedRef = false
Effect2 타이머 만료 (아직 clearTimeout 전)
  → isMountedRef.current 확인 → false → setState 스킵
Effect2 클린업 → clearTimeout (이미 만료됨, 의미 없음)
Effect1 재실행 → isMountedRef = true
Effect2 재실행 → 타이머 재등록

→ 1차 animationStage 시퀀스가 중간에 끊긴 채로 2차 시작 ⚠️

 

해결: 두 Effect를 하나로 합치기

 

두 Effect를 하나로 합치면 이 문제가 깔끔하게 해결됩니다.

useEffect(() => {
  isMountedRef.current = true;

  setNavigation((prev) => ({ ...prev, animationStage: 0 }));

  const timers: NodeJS.Timeout[] = [];

  timers.push(
    setTimeout(() => {
      if (isMountedRef.current) {
        setNavigation((prev) => ({ ...prev, animationStage: 1 }));
      }
    }, 100),
    setTimeout(() => {
      if (isMountedRef.current) {
        setNavigation((prev) => ({ ...prev, animationStage: 2 }));
      }
    }, 400),
    setTimeout(() => {
      if (isMountedRef.current) {
        setNavigation((prev) => ({ ...prev, animationStage: 3 }));
      }
    }, 700),
    setTimeout(() => {
      if (isMountedRef.current) {
        setNavigation((prev) => ({ ...prev, animationStage: 4 }));
      }
    }, 1000)
  );

  return () => {
    isMountedRef.current = false;

    timers.forEach((timer) => {
      if (timer) clearTimeout(timer);
    });
  };
}, [navigation.currentStep]);

 

마치며

폼은 정말 수도 없이 만들어보게 됩니다. 화려한 디자인 패턴이나 최신 라이브러리를 도입하는 것보다 중요한 것은, 사용자가 어떤 환경에서 어떤 실수를 하더라도 우리가 설계한 대로 안전하게 목적지까지 도달하게 만드는 것이라고 생각합니다.

이 과정을 통해 사용자 경험과 코드의 안정성을 동시에 깊게 고민해볼 수 있었습니다. 누군가에게도 도움이 됐으면 좋겠습니다.