뱁새 다리찢기
Published 2026. 4. 22. 20:54
이거 진짜 퍼널 맞나요? <frontend>

배경

회원가입 플로우에 MFA 설정 단계를 추가하게 됐어요.

 

기존 회원가입은 여러 스텝을 거치는 구조였습니다. 이메일 입력, 인증 코드 확인, 프로필 입력, … 클라이언트가 "지금 몇 번째 스텝이냐"를 useState로 들고 있고, 화면이 순차적으로 이어지는, 우리가 흔히 "퍼널" 이라고 부르는 그런 구조요.

 

여기에 MFA 설정 단계를 끼워 넣어야 했어요. TOTP를 설정하고, recovery code를 확인시키고, 완료 처리까지요. 이것도 스텝이 순차적으로 이어지니까, 기존 회원가입 퍼널에 스텝 몇 개 추가하듯이 자연스럽게 MfaSetupFlow라는 페이지 안의 상태 기계로 묶었어요.

type MfaSetupStep =
  | { step: "totpSetup" }
  | { step: "emailSetup" }
  | { step: "recoveryCode"; mfaMethod: MfaMethod }
  | { step: "complete" };

function MfaSetupFlow({ initialStep = { step: "totpSetup" } }) {
  const [state, setState] = useState<MfaSetupStep>(initialStep);

  switch (state.step) {
    case "totpSetup":
      return (
        <TotpSetupStep
          onComplete={() => setState({ step: "recoveryCode", mfaMethod: "totp" })}
          onSwitchToEmail={() => setState({ step: "emailSetup" })}
        />
      );
    case "emailSetup":
      return (
        <EmailSetupStep
          onComplete={() => setState({ step: "recoveryCode", mfaMethod: "email" })}
          onSwitchToTotp={() => setState({ step: "totpSetup" })}
        />
      );
    case "recoveryCode":
      return <RecoveryCodeStep onComplete={() => setState({ step: "complete" })} />;
    case "complete":
      return <SetupCompleteStep />;
  }
}

그런데 버그가 하나 잡혔어요. TOTP 설정을 마치고 recovery code 화면에서 새로고침하면, recovery code를 건너뛰고 홈으로 빠져나갈 수 있었어요.
recovery code는 MFA의 복구 수단이라 반드시 확인시켜야 하는데, 새로고침 한 번으로 뚫린 거예요.

이 코드가 이렇게 생긴 이유

가드 조건을 고치면 임시로 막을 수야 있어요. 근데 고치다 보니 이게 가드의 문제가 아니라는 생각이 들었어요. 왜 이 코드가 이런 구조로 만들어졌는지 거꾸로 따라가봤어요.

Amplify SDK의 nextStep 패턴

시작은 AWS Amplify의 인증 API 설계였어요.

const totpOutput = await setUpTOTP();
// → totpOutput.nextStep.signInStep === "CONFIRM_SIGN_IN_WITH_TOTP_CODE"

const verifyOutput = await verifyTOTPSetup({ code: "123456" });
// → verifyOutput.nextStep.signInStep === "DONE"

SDK가 호출할 때마다 "다음에 뭘 해야 하는지"를 응답으로 돌려주는 구조예요. 순차적인 인터페이스로, 호출자가 response를 보고 다음 동작을 결정해요.
이 패턴을 프론트에서 자연스럽게 받아들이면 이렇게 돼요.

// "SDK가 nextStep을 주니까, 나도 step을 상태로 관리하면 되겠다"
const [step, setStep] = useState("totpSetup");

async function handleVerify(code: string) {
  const result = await verifyTOTPSetup({ code });
  if (result.nextStep.signInStep === "DONE") {
    setStep("recoveryCode");
  }
}

SDK의 step을 React의 useState로 그대로 옮긴 형태예요. 여기까진 직관적이고 틀린 것도 아니에요.

퍼널처럼 생겼지만 퍼널이 아니었다

우리가 보통 퍼널이라고 하면 이런 구조를 떠올려요.

step1 → step2 → step3 → 제출
  ↑
  중간 스텝은 클라이언트 메모리에만 존재
  마지막에 한 번만 서버로 POST
  중간 새로고침 시 처음부터 다시 (서버 영향 없음)

깔끔해요. 중간 스텝의 상태는 클라이언트가 독점하고, 서버는 완료 전까지 아무것도 모르는 구조죠

 

근데 제가 만들고 있던 MFA는 이렇지 않았어요. 회원가입 중간에도 이미 서버로 signUp()이 호출되고 있었고, 이메일 인증 단계에서도 서버 상태가 바뀌고 있었거든요. 겉보기엔 스텝이 쭉 이어지는 퍼널인데, 속은 중간중간 서버 상태가 변하는 플로우였어요.

totpSetup → recoveryCode → complete
  ↑             ↑
  여기서         여기서 서버 상태가 이미 변경됨
  verifyTOTPSetup 호출 성공 = 서버는 "MFA 설정됨"으로 기록

 

퍼널 중간엔 서버 부수 효과 없음이라는 규칙을 지키지 않아서 MFA 셋업은 그 숨겨진 경계가 새로고침 바이패스라는 형태로 드러난 케이스였어요.

합산 가드

서버 상태가 중간에 바뀐다는 걸 인지하고 나서는, 가드가 필요해져요.

function MfaSetupContent() {
const [{ data: mfaPreference }, { data: recoveryStatus }] = useSuspenseQueries({
  queries: [mfaQueries.preference(), recoveryCodeQueries.status()],
});

  // "서버는 MFA 완료라 하는데, recovery code는 아직?" → 가드로 분기
  if (mfaPreference?.preferred && recoveryStatus.confirmed) {
    return <Navigate to="/home" />;
  }
  if (mfaPreference?.preferred) {
    return <MfaSetupFlow initialStep={{ step: "recoveryCode" }} />;
  }

  return <MfaSetupFlow />;
}

useSuspenseQuery가 두 번 호출되는 게 보일 거예요. 이 플로우엔 서버 상태가 두 개 걸려있어서 그래요.

  • MFA 설정 완료 여부 — Cognito 관할. verifyTOTPSetup() 성공 시점에 바뀜.
  • Recovery code 수령 완료 여부 — 자체 구현. recovery code는 Cognito 기능이 아니라 별도 API로 기록해요.

UI에선 한 스텝처럼 묶여있지만 속은 두 서버 상태가 따로 움직여요. 거기에 UI 상태(useState)까지 더해지면서, 이 가드는 결국 세 종류의 상태를 런타임에 합산해서 "유저가 지금 어디에 있어야 하는지"를 추론하는 코드가 돼요.

 

문제는 이 한 페이지가 합산을 떠안고 있다는 것이에요. 정상 흐름에서는 잘 돌아가지만, 새로고침이 발생하면 UI 상태는 초기화되고 서버 상태는 유지되는, 상태들의 생명주기가 어긋나는 순간이 생겨요. 그 순간 합산 결과가 틀어지면서 바이패스가 뚫려요.

 

합산할 상태가 많아질수록 가드는 두꺼워지고 엣지 케이스도 계속 생길 거예요. 싱크 안 되는 상태들을 한 페이지가 묶고 있는 한, 가드는 그 어긋남을 떠안을 수밖에 없어요. 애초에 한 페이지가 합산을 해야 한다는 것부터가, 페이지 경계를 잘못 그었다는 신호였어요.


페이지 경계를 UI 플로우 기준으로

한 걸음 물러나서 보니 구조는 이랬어요.

/mfa-setup  (한 페이지)
  └─ MfaSetupFlow (한 상태 기계)
       ├─ totpSetup      ← 서버 부수 효과 없음 (폼 입력 중)
       ├─ recoveryCode   ← 여기 오기 전에 서버 상태 이미 변경됨 ⚠️
       └─ complete

서버 상태가 바뀌는 경계가 페이지 안에 숨어있어요. totpSetup에서 recoveryCode로 넘어가는 setState 한 줄이, 사실은 서버 상태를 "전"과 "후"로 가르는 중요한 경계였던 거예요.
근데 페이지는 이 경계를 무시하고 UI 플로우 전체를 하나로 묶어버렸어요. "스텝이 순차적으로 이어지니까 퍼널"이라는 UI 관점의 판단이었거든요.
결과적으로 한 페이지 안에서:

  • UI 상태(useState)는 새로고침 시 초기화
  • 서버 상태는 새로고침 시 유지

이 둘이 "유저가 어디에 있는가"라는 같은 판단에 같이 영향을 주는데, 생명주기가 서로 다르다는 것. 이게 새로고침 바이패스의 정체였어요.

서버 상태가 바뀌는 지점에서 페이지를 쪼갠다

// ─── /mfa-setup: TOTP 등록 전 ───────────────────
//  서버 상태: MFA 미설정
//  판단: "MFA 설정이 필요한가?"
function MfaSetupContent() {
  const { data: mfaPreference } = useSuspenseQuery(mfaQueries.preference());

  if (mfaPreference?.preferred) {
    return <Navigate to="/home" replace />;
  }

  return <MfaSetupFlow />;
}

function MfaSetupFlow() {
  const [state, setState] = useState({ step: "totpSetup" });
  const navigate = useNavigate();

  // 서버 부수 효과가 발생하는 순간 = 페이지 전환 순간
  // API 응답으로 받은 recovery code를 다음 페이지로 넘김
  const navigateToComplete = async (mfaMethod) => {
    const { code } = await recoveryCodeApi.generate(mfaMethod);
    navigate("/mfa-setup-complete", { state: { code }, replace: true });
  };

  switch (state.step) {
    case "totpSetup":
      return <TotpSetupStep onComplete={() => navigateToComplete("totp")} />;
    case "emailSetup":
      return <EmailSetupStep onComplete={() => navigateToComplete("email")} />;
  }
}

// ─── /mfa-setup-complete: TOTP 등록 후 ──────────
//  서버 상태: MFA 설정됨
//  판단: "보여줄 recovery code가 있는가?"
function MfaSetupComplete() {
  const { state } = useLocation();

  if (!state?.code) {
    return <Navigate to="/home" replace />;
  }

  return <MfaSetupCompleteFlow code={state.code} />;
}

페이지를 둘로 쪼갰어요. 경계는 서버 상태가 바뀌는 지점이에요.

페이지 서버 상태 전제 판단 기준
/mfa-setup MFA 미설정 서버 상태만
/mfa-setup-complete MFA 설정됨 route state만

각 페이지 안에서는 서버 상태가 변하지 않아요. 그러니까 각 페이지의 가드가 한 종류의 상태만 보고 판단할 수 있어요. 합산이 사라져요.

상태 기계에 남는 것

수정 후 MfaSetupFlow의 상태 기계에는 서버 부수 효과가 없는 스텝만 남았어요.

type MfaSetupStep = { step: "totpSetup" } | { step: "emailSetup" };

TOTP 설정과 Email 설정은 서로 전환하는 관계지 순차 진행이 아니에요. 진짜 순차 진행 부분(등록 전 → 등록 후)은 페이지 전환으로 표현돼서 상태 기계에서 빠졌어요.

Before: totpSetup → recoveryCode → complete  (전부 한 상태 기계 안)
          ↑
          여기 어딘가에 서버 부수 효과 경계가 숨어있음

After:  totpSetup ↔ emailSetup                (페이지 A의 상태 기계, 서버 부수 효과 없음)
           ↓ 서버 부수 효과 발생 = 페이지 전환
        recoveryCode → complete               (페이지 B의 상태 기계, 이미 서버 부수 효과 끝남)

서버 부수 효과가 발생하는 경계를 곧 페이지 경계로 일치시키는 것. 이게 핵심이었어요.


이 징후가 보이면 페이지 경계를 의심하자

  1. 한 페이지의 가드가 서버 쿼리 결과 + useState를 합산해서 조건부 렌더링한다
  2. 새로고침하면 동작이 달라진다 — 상태 간 생명주기 불일치의 증거
  3. 가드 조건을 수정했더니 플로우가 깨진다 — 한 페이지가 너무 많은 관심사를 담고 있다
  4. 상태 기계 안에 서버 부수 효과가 있는 전환이 섞여 있다 — 페이지 경계가 잘못 그어졌다는 신호
  5. 페이지가 initialStep 같은 걸로 "어디서 이어 시작할지"를 인자로 받는다 — 사실은 서로 다른 페이지여야 한다는 신호

후기

가드 한 줄만 덧대도 이 버그는 닫을 수 있었어요. 근데 그러면 다음에 비슷한 엣지 케이스가 또 생길 것 같았고, 결국 경계 자체를 다시 그은 게 정답이었습니다.

 

글을 정리하면서 든 생각인데, 퍼널처럼 보이는 플로우들 중에 실제로 중간 스텝이 서버 부수 효과 없이 돌아가는 경우가 생각보다 많지 않은 것 같아요. 중간에 서버 상태가 조금씩 바뀌고 있어요. 그래서 "이게 퍼널인가?"라는 질문을 "중간 스텝에서 서버 상태가 바뀌는가?"로 바꿔서 던져보는 게 더 정확한 것 같아요. 답이 Yes면, 그건 퍼널이 아니라 여러 페이지로 쪼개져야 하는 순차적 트랜잭션 플로우예요.

profile

뱁새 다리찢기

@donghyk2-eric

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!