앱-웹 혼합 로그인 플로우를 개선하면서 겪은 문제와 해결

Clean Code Big Poo·2026년 6월 1일

Flutter

목록 보기
41/44

Overview

로그인 화면(앱) -> 회원가입 화면(웹) -> 회원가입 완료 후 앱으로 복귀하는 플로우를 다루면서 어떤 문제가 생겼고, 그걸 어떤 기준으로 정리했는지 기록한 글이다.

로그인 성공 이후 세션 구성, SNS 회원가입 유도, 웹 화면 이동, 추가정보 입력, 본인인증, 다시 앱으로 복귀하는 흐름까지 전부 이어져야 비로소 안정적인 사용자 경험이 된다.

이 글에서는 아래 문제들을 중심으로 정리해보려 한다.

  • 앱 로그인과 웹 회원가입이 분리된 구조
  • SNS 로그인 후 회원 미존재 케이스 처리
  • 웹 회원가입 완료 이후 앱 세션 복구
  • 로그인 성공/실패/취소/후속 처리의 혼재
  • 로그인 직후 추가정보 입력, 본인인증, 홈 진입 순서 정리

로그인은 "성공 또는 실패"만으로 끝나지 않았다

로그인 버튼을 누르면 단순 사용자 인증 이후에도 이어지는 작업이 꽤 많았다.

  • 세션 키 발급
  • 사용자 기본 정보 동기화
  • 디바이스 토큰 등록
  • SNS 회원 미존재 시 웹 회원가입 유도
  • 추가정보 미입력 사용자 분기 처리
  • 본인인증 필요 사용자 분기 처리
  • 홈 진입 전 후속 플로우 연결

즉 로그인은 단일 API 호출이 아니라, 여러 단계를 순서대로 통과해야 하는 진입 플로우에 가까웠다.


1. 오류를 세분화하다

앱-웹 혼합 로그인에서 가장 먼저 정리해야 했던 건 "결과 분류"였다.
단순히 PASS/FAIL로 나누면 실패 안에 너무 많은 의미가 섞이기 시작한다.

예를 들면 이런 케이스들이다.

  • 비밀번호 오류
  • 사용자의 로그인 취소
  • SNS 인증은 성공했지만 서비스 회원 정보 없음
  • 세션 부트스트랩 실패
  • 로그인은 됐지만 추가정보 입력 필요
  • 로그인은 됐지만 본인인증 필요

그래서 로그인 결과를 bool 하나로 다루는 대신, 후속 액션까지 포함한 상태로 분리했다.

sealed class SignInResult {
  const SignInResult();
}

class SignInSuccess extends SignInResult {
  const SignInSuccess(this.session);
  final UserSession session;
}

class SignUpRequired extends SignInResult {
  const SignUpRequired(this.socialToken);
  final SocialToken socialToken;
}

class ExtraProfileRequired extends SignInResult {
  const ExtraProfileRequired(this.session);
  final UserSession session;
}

class VerificationRequired extends SignInResult {
  const VerificationRequired(this.session);
  final UserSession session;
}

class SignInCancelled extends SignInResult {
  const SignInCancelled();
}

class SignInFailure extends SignInResult {
  const SignInFailure(this.message);
  final String message;
}

이렇게 결과를 나누면 화면 이동도 훨씬 명확해진다.

Future<void> handleSignIn() async {
  final result = await authFlow.signIn();

  switch (result) {
    case SignInSuccess(:final session):
      await moveToHome(session);
      break;
    case SignUpRequired(:final socialToken):
      await openSignUpPage(socialToken);
      break;
    case ExtraProfileRequired(:final session):
      await openExtraProfilePage(session);
      break;
    case VerificationRequired(:final session):
      await openVerificationPage(session);
      break;
    case SignInCancelled():
      break;
    case SignInFailure(:final message):
      showError(message);
      break;
  }
}

다음과 같이 개선되었다.

  • 실패와 후속 처리 필요 상태의 분리
  • 화면 이동 책임의 명시화
  • QA 이슈 설명 용이성
  • 예외 케이스 추가 시 분기 관리 수월

2. SNS 로그인 실패가 아니라 "회원가입 필요 상태"인 경우가 있었다

소셜 로그인에서 까다로운 부분은 인증 자체보다 그 다음이었다.
외부 SNS 인증은 성공했지만, 우리 서비스에는 아직 가입된 계정이 없는 사용자가 있었기 때문이다.

  • 사용자는 로그인에 성공했다고 느꼈는데 실패 메시지만 보이는 상황
  • 앱은 실패로 처리하지만 실제로는 회원가입 유도가 필요한 상황
  • 사용자가 다시 회원가입 버튼을 눌러야 하는 중복 액션

그래서 이 케이스는 실패가 아니라 회원가입 필요 상태로 다루는 쪽이 맞았다.

Future<SignInResult> signInWithSocial() async {
  final socialToken = await socialProvider.authenticate();

  final member = await accountService.findMemberBySocialToken(socialToken);

  if (member == null) {
    return SignUpRequired(socialToken);
  }

  final session = await sessionBootstrapper.create(member);
  return SignInSuccess(session);
}

이후 앱은 SNS 인증 결과를 버리지 않고 웹 회원가입 화면으로 이어준다.

Future<void> openSignUpPage(SocialToken token) async {
  final uri = Uri.https(
    'account.example.com',
    '/join/social',
    {
      'provider': token.provider,
      'accessToken': token.accessToken,
      'email': token.email ?? '',
      'name': token.displayName ?? '',
    },
  );

  await navigator.push(
    MaterialPageRoute(
      builder: (_) => SignUpWebViewPage(initialUri: uri),
    ),
  );
}

핵심은 "가입 안 된 SNS 사용자"를 실패로 해석하지 않는 것이었다.
이걸 분리하고 나서 로그인 플로우가 훨씬 자연스러워졌다.


3. 웹 회원가입 완료와 앱 로그인 완료는 같은 일이 아니었다

앱-웹 혼합 구조에서 가장 중요한 포인트 중 하나는, 웹 회원가입이 끝났다고 해서 앱 로그인까지 끝난 건 아니라는 점이었다.

웹이 담당하는 역할과 앱이 담당하는 역할을 분리해서 봐야 한다.

  • 웹의 역할: 회원가입 완료
  • 앱의 역할: 앱 세션 수립, 저장, 후속 화면 이동

즉 웹 가입 완료 이후에도 앱은 다시 자기 세션을 세워야 했다.

이 과정은 보통 아래 흐름으로 정리된다.

  1. 웹 회원가입 완료
  2. 웹이 브릿지로 완료 이벤트 전달
  3. 앱이 인증 코드 또는 식별자 수신
  4. 앱이 세션 발급 API 호출
  5. 앱 저장소에 세션 저장
  6. 추가정보/본인인증/홈 진입 등 후속 플로우 결정

예제 코드는 이렇게 단순화할 수 있다.

Future<void> onBridgeMessage(Map<String, dynamic> message) async {
  switch (message['type']) {
    case 'sign_up_completed':
      final authCode = message['payload']['authCode'] as String;
      final session = await sessionBootstrapper.exchange(authCode);
      await sessionStore.save(session);
      await moveToNextStep(session);
      break;
  }
}

여기서 중요했던 건 "웹 회원가입 완료"와 "앱 세션 수립 완료"를 분리해서 다루는 것이었다.

  • 장애 지점 분리
  • 세션 복구 실패 시 예외 처리 가능
  • 웹과 앱의 책임 경계 명확화
  • 추후 플로우 변경 대응 수월

4. 추가정보 입력과 본인인증도 로그인 플로우의 일부였다

처음에는 추가정보 입력, 계정찾기, 비밀번호 찾기, 본인인증 화면을 로그인 바깥의 부가 기능처럼 보기 쉬웠다.
그런데 실제로는 전부 "로그인 후 사용 가능한 상태에 도달하기까지의 과정"에 가까웠다.

예를 들면 이런 흐름이다.

  • 회원가입 직후 추가정보 입력 필요
  • 특정 사용자 본인인증 필요
  • 계정찾기/비밀번호 찾기 후 로그인 화면 복귀
  • 추가정보 완료 후 홈이 아니라 후속 플로우 이동 필요

그래서 이 화면들을 단순 서브 페이지로 보지 않고, 로그인 후속 단계로 정리했다.

enum PostSignInStep {
  home,
  extraProfile,
  identityVerification,
}
Future<PostSignInStep> resolveNextStep(UserSession session) async {
  if (!session.isIdentityVerified) {
    return PostSignInStep.identityVerification;
  }

  if (!session.isProfileCompleted) {
    return PostSignInStep.extraProfile;
  }

  return PostSignInStep.home;
}

이렇게 상태 기반으로 다음 화면을 결정하면 순서가 하드코딩되지 않는다.

장점도 꽤 컸다.

  • 홈 진입 조건의 일원화
  • 후속 플로우 추가 시 구조 유지
  • 기능별 화면 책임 정리
  • 테스트 케이스 분리 용이

5. 자동 로그인과 로그아웃도 같은 라이프사이클 안에서 봐야 했다

자동 로그인과 로그아웃을 로그인과 별도 기능처럼 다루면 세션 정합성이 자주 깨졌다.

자주 만난 문제는 이런 것들이었다.

  • 앱은 로그아웃했는데 웹 세션은 남아 있는 상태
  • 자동 로그인 데이터는 남아 있는데 실제 세션은 만료된 상태
  • 이전 사용자의 인증 상태가 다음 로그인에 섞이는 문제
  • 디바이스 토큰 등록 상태와 로그인 상태의 불일치

그래서 로그인/자동 로그인/로그아웃을 하나의 세션 라이프사이클로 묶어 관리하는 쪽이 맞았다.

class SessionLifecycleManager {
  Future<void> signIn(UserSession session) async {
    await secureStore.write(key: 'accessToken', value: session.accessToken);
    await secureStore.write(key: 'refreshToken', value: session.refreshToken);
    await secureStore.write(key: 'sessionKey', value: session.sessionKey);
  }

  Future<void> signOut() async {
    await secureStore.deleteAll();
    await clearWebSession();
    await unregisterPushToken();
  }

  Future<UserSession?> restore() async {
    final accessToken = await secureStore.read(key: 'accessToken');
    if (accessToken == null) return null;
    return validateSession(accessToken);
  }
}

여기서 특히 중요했던 건 로그아웃 시 웹 세션 정리였다.
앱만 로그아웃하고 쿠키가 남아 있으면, 다음 진입에서 앱과 웹의 상태가 쉽게 어긋난다.


6. 결국 중요한 건 "분기 기준"이었다

앱-웹 혼합 로그인 플로우를 정리하면서 가장 크게 느낀 건, 복잡성의 원인은 화면 수가 아니라 분기 기준의 불명확함이라는 점이었다.

내가 실제로 정리한 기준은 아래와 같았다.

  • 로그인 실패와 회원가입 필요 상태의 분리
  • 회원가입 완료와 앱 세션 수립 완료의 분리
  • 웹 화면 종료와 플로우 완료의 분리
  • 사용자 취소와 시스템 오류의 분리
  • 홈 진입 가능 상태와 인증 완료 상태의 분리

이 기준을 잡고 나니 화면 단위 땜질보다 플로우 전체를 기준으로 수정할 수 있었다.


마무리

이번 작업을 통해 정리한 핵심은 아래와 같다.

  • 로그인 결과를 성공/실패 둘만으로 다루지 않기
  • SNS 회원 미존재 케이스를 실패가 아니라 가입 유도 상태로 해석
  • 웹 회원가입 완료 이후 앱 세션 수립 단계를 분리
  • 추가정보 입력, 본인인증, 자동 로그인, 로그아웃까지 하나의 로그인 라이프사이클로 정리

로그인 플로우는 잘 될 때보다 예외 상황이 많아질수록 구조의 수준이 드러난다.
앱과 웹이 같이 움직이는 구조일수록, 분기 설계가 곧 품질이라는 걸 많이 느꼈다.

0개의 댓글