로그인 화면(앱) -> 회원가입 화면(웹) -> 회원가입 완료 후 앱으로 복귀하는 플로우를 다루면서 어떤 문제가 생겼고, 그걸 어떤 기준으로 정리했는지 기록한 글이다.
로그인 성공 이후 세션 구성, SNS 회원가입 유도, 웹 화면 이동, 추가정보 입력, 본인인증, 다시 앱으로 복귀하는 흐름까지 전부 이어져야 비로소 안정적인 사용자 경험이 된다.
이 글에서는 아래 문제들을 중심으로 정리해보려 한다.
로그인 버튼을 누르면 단순 사용자 인증 이후에도 이어지는 작업이 꽤 많았다.
즉 로그인은 단일 API 호출이 아니라, 여러 단계를 순서대로 통과해야 하는 진입 플로우에 가까웠다.
앱-웹 혼합 로그인에서 가장 먼저 정리해야 했던 건 "결과 분류"였다.
단순히 PASS/FAIL로 나누면 실패 안에 너무 많은 의미가 섞이기 시작한다.
예를 들면 이런 케이스들이다.
그래서 로그인 결과를 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;
}
}
다음과 같이 개선되었다.
소셜 로그인에서 까다로운 부분은 인증 자체보다 그 다음이었다.
외부 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 사용자"를 실패로 해석하지 않는 것이었다.
이걸 분리하고 나서 로그인 플로우가 훨씬 자연스러워졌다.
앱-웹 혼합 구조에서 가장 중요한 포인트 중 하나는, 웹 회원가입이 끝났다고 해서 앱 로그인까지 끝난 건 아니라는 점이었다.
웹이 담당하는 역할과 앱이 담당하는 역할을 분리해서 봐야 한다.
즉 웹 가입 완료 이후에도 앱은 다시 자기 세션을 세워야 했다.
이 과정은 보통 아래 흐름으로 정리된다.
예제 코드는 이렇게 단순화할 수 있다.
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;
}
}
여기서 중요했던 건 "웹 회원가입 완료"와 "앱 세션 수립 완료"를 분리해서 다루는 것이었다.
처음에는 추가정보 입력, 계정찾기, 비밀번호 찾기, 본인인증 화면을 로그인 바깥의 부가 기능처럼 보기 쉬웠다.
그런데 실제로는 전부 "로그인 후 사용 가능한 상태에 도달하기까지의 과정"에 가까웠다.
예를 들면 이런 흐름이다.
그래서 이 화면들을 단순 서브 페이지로 보지 않고, 로그인 후속 단계로 정리했다.
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;
}
이렇게 상태 기반으로 다음 화면을 결정하면 순서가 하드코딩되지 않는다.
장점도 꽤 컸다.
자동 로그인과 로그아웃을 로그인과 별도 기능처럼 다루면 세션 정합성이 자주 깨졌다.
자주 만난 문제는 이런 것들이었다.
그래서 로그인/자동 로그인/로그아웃을 하나의 세션 라이프사이클로 묶어 관리하는 쪽이 맞았다.
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);
}
}
여기서 특히 중요했던 건 로그아웃 시 웹 세션 정리였다.
앱만 로그아웃하고 쿠키가 남아 있으면, 다음 진입에서 앱과 웹의 상태가 쉽게 어긋난다.
앱-웹 혼합 로그인 플로우를 정리하면서 가장 크게 느낀 건, 복잡성의 원인은 화면 수가 아니라 분기 기준의 불명확함이라는 점이었다.
내가 실제로 정리한 기준은 아래와 같았다.
이 기준을 잡고 나니 화면 단위 땜질보다 플로우 전체를 기준으로 수정할 수 있었다.
이번 작업을 통해 정리한 핵심은 아래와 같다.
로그인 플로우는 잘 될 때보다 예외 상황이 많아질수록 구조의 수준이 드러난다.
앱과 웹이 같이 움직이는 구조일수록, 분기 설계가 곧 품질이라는 걸 많이 느꼈다.