teji

tede

AI 에이전트, 트레이딩 봇 개발 중인 남편의 일기 🤖
화장품에 진심인 간호사 아내의 코덕로그 💄

전체보기

클로드코드(Claude Code)로 Flutter 앱 만들기 (3) 2026: 카카오·구글 소셜 로그인 → FastAPI JWT 교환 → flutter_secure_storage → Dio 인터셉터 401 자동 갱신, COSMO 실제 코드

최종 업데이트: 2026-09-16 (앱인토스 미니앱 관련 정정)

클로드코드(Claude Code)로 Flutter 앱 만들기 3편: 카카오 로그인부터 401 자동 갱신까지 토큰이 지나가는 길 5단계 썸네일

COSMO Flutter 앱의 로그인은 카카오·구글 소셜 토큰을 서버(FastAPI)에 한 번 넘기고, 그 뒤로는 서버가 발급한 JWT 두 장(access 1시간, refresh 30일)만 써요. 앱은 토큰을 flutter_secure_storage 에 저장하고 메모리에 캐시하며, Dio 인터셉터가 매 요청에 Bearer 헤더를 붙이고 401 이 오면 refresh 후 원래 요청을 재시도해요. 로그인 상태는 Riverpod StateNotifier 가 들고, 상태가 바뀔 때마다 go_router 의 redirect 가 재평가돼요. 이 글은 그 길을 5단계로 나눠 앱 코드와 서버 코드를 나란히 보여주고, 쓰면서 발견한 FCM 토큰 미전송 버그와 2편의 폰트 버그 수정 결과까지 적었어요. 키, 클라이언트 ID, 서버 주소는 전부 가렸어요.

1편 라우터의 “비로그인이면 /login 으로” 게이트가 어디서 신호를 받는지가 이번 편에서 이어져요.

Flutter 소셜 로그인 전체 흐름은 몇 단계인가요? 앱 → 서버 → 저장 → 요청 → 화면

Flutter 소셜 로그인 5단계 흐름: 카카오 토큰, FastAPI JWT 교환, secure storage, 인터셉터 401 갱신, 라우터 재평가

단계 어디서 파일 줄 수
① 소셜 로그인 앱 features/auth/presentation/login_screen.dart 213
② 토큰 교환 서버 backend/app/api/auth.py + services/auth.py FastAPI
③ 저장 앱 core/storage/token_manager.dart 54
④ 요청·갱신 앱 core/network/auth_interceptor.dart 66
⑤ 상태·화면 앱 features/auth/presentation/auth_providers.dart 240

앱 쪽 인증 코드는 합쳐서 573줄이고 절반은 에러 메시지와 주석이에요.

카카오 로그인은 어떻게 구현했나요? 카카오톡 앱 먼저, 실패하면 웹

Flutter kakao_flutter_sdk 로그인 코드: loginWithKakaoTalk 실패 시 loginWithKakaoAccount 폴백

Future<void> _loginWithKakao(BuildContext context, WidgetRef ref) async {
  try {
    // 이전 세션 정리. 안 하면 다음 로그인 때 silent auth 로 돌아감
    try { await UserApi.instance.logout(); } catch (_) {}

    OAuthToken token;
    bool talkInstalled = false;
    try { talkInstalled = await isKakaoTalkInstalled(); } catch (_) {}

    // KakaoTalk 앱 우선 (지문·얼굴 인증으로 빠름). 실패하면 카카오계정 웹 로그인.
    // prompts:[login] 으로 새 인증창을 강제하면 카카오 웹뷰가 redirect 를 못 받아
    // 멈추는 사고가 있어서 단순 흐름으로 복귀.
    if (talkInstalled) {
      try {
        token = await UserApi.instance.loginWithKakaoTalk();
      } catch (e) {
        token = await UserApi.instance.loginWithKakaoAccount();
      }
    } else {
      token = await UserApi.instance.loginWithKakaoAccount();
    }

    if (!context.mounted) return;
    await ref.read(authProvider.notifier).loginWithKakao(token.accessToken); // 서버로 교환
  } catch (e) {
    if (!context.mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('카카오 로그인 실패: $e')));
  }
}

// 구글은 google_sign_in 7.x: GoogleSignIn.instance.initialize(clientId: <웹 클라이언트 ID>)
// → authenticate() → account.authentication.idToken 을 서버로

카카오 SDK(kakao_flutter_sdk_user)에는 로그인이 두 가지예요. 카카오톡 앱으로 넘어가 지문·얼굴로 끝내는 loginWithKakaoTalk 과, 웹뷰에서 아이디·비밀번호를 입력하는 loginWithKakaoAccount 예요. 앱이 설치돼 있으면 전자, 실패하거나 없으면 후자로 폴백해요. 이 폴백이 없으면 카카오톡 미설치 기기에서 로그인 버튼이 반응하지 않아요.

코드 주석에 남은 사고가 하나 있어요. 매번 새 인증 화면을 띄우려고 prompts:[login] 옵션을 넣었더니 카카오 웹뷰가 리다이렉트를 받지 못하고 화면이 멈췄어요. 지금은 이전 세션을 logout() 으로 정리하고 단순 흐름으로 돌아왔어요. 구글은 google_sign_in 7.x 라 GoogleSignIn.instance.initialize(clientId) → authenticate() → idToken 순서예요. 6.x 와 API 가 완전히 달라서 클로드코드가 6.x 코드를 생성하면 컴파일이 안 되니 pubspec 버전을 먼저 알려줘야 해요.

서버는 소셜 토큰을 어떻게 JWT 로 바꾸나요? FastAPI 3단계

FastAPI 카카오 로그인 엔드포인트 코드: kapi.kakao.com 토큰 검증, 유저 생성, JWT access 1시간 refresh 30일 발급

@router.post("/kakao", response_model=TokenResponse)
async def login_kakao(body: KakaoLoginRequest, db: AsyncSession = Depends(get_db)):
    kakao_info = await verify_kakao_token(body.kakao_access_token)   # GET https://kapi.kakao.com/v2/user/me
    user = await get_or_create_kakao_user(kakao_info, db)           # 첫 로그인이면 가입
    return TokenResponse(
        access_token=create_access_token(user.id),     # timedelta(hours=1)
        refresh_token=create_refresh_token(user.id),   # timedelta(days=30)
        token_type="bearer",
        user=user,
    )

@router.post("/google", response_model=TokenResponse)
async def login_google(body: GoogleLoginRequest, db: AsyncSession = Depends(get_db)):
    google_info = await verify_google_token(body.google_id_token)   # 구글에 id_token 검증
    user = await get_or_create_google_user(google_info, db)
    return TokenResponse(access_token=create_access_token(user.id), refresh_token=create_refresh_token(user.id), token_type="bearer", user=user)

@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
    user_id = verify_token(body.refresh_token, expected_type="refresh")  # access 토큰으로는 refresh 불가
    user = await db.get(User, user_id)
    return TokenResponse(access_token=create_access_token(user.id), refresh_token=create_refresh_token(user.id), token_type="bearer", user=user)

# app/services/auth.py
async def verify_kakao_token(access_token: str) -> dict:
    async with httpx.AsyncClient(timeout=10.0) as client:
        resp = await client.get("https://kapi.kakao.com/v2/user/me", headers={"Authorization": f"Bearer {access_token}"})
        resp.raise_for_status()
        data = resp.json()
        profile = data.get("kakao_account", {}).get("profile", {})
        return {"kakao_id": str(data["id"]), "nickname": profile.get("nickname"), "profile_image": profile.get("profile_image_url")}
  1. 검증: 앱이 준 카카오 access token 으로 kapi.kakao.com/v2/user/me 를 서버가 직접 호출해요. 앱이 보낸 닉네임을 믿지 않고 카카오에서 다시 받아요.
  2. 가입 또는 조회: 카카오 id 로 유저를 찾고 없으면 만들어요. 그래서 앱에는 가입 화면이 따로 없어요.
  3. 발급: access(1시간) + refresh(30일) 두 장을 돌려줘요. refresh 엔드포인트는 expected_type=”refresh” 로 검사해서 access 토큰으로는 갱신할 수 없어요.

소셜 토큰을 그대로 쓰지 않는 이유는 만료 규칙이 제공자마다 달라서예요. 서버 JWT 로 통일하면 앱은 규칙 하나만 알면 되고, 나중에 애플 로그인을 붙여도 앱 쪽 인터셉터는 바뀌지 않아요.

토큰 저장과 401 자동 갱신은 어떻게 동작하나요? TokenManager + 인터셉터 66줄

Dio AuthInterceptor 코드: onRequest Bearer 헤더, onError 401 refresh 후 원래 요청 재시도

// core/storage/token_manager.dart
class TokenManager {
  static const _accessTokenKey = 'access_token';
  static const _refreshTokenKey = 'refresh_token';
  final FlutterSecureStorage _storage;
  String? _cachedAccessToken;   // 인터셉터가 매 요청마다 읽으니 메모리에 캐시
  String? _cachedRefreshToken;

  TokenManager({FlutterSecureStorage? storage}) : _storage = storage ?? const FlutterSecureStorage();

  Future<void> init() async {   // main() 에서 1회
    _cachedAccessToken = await _storage.read(key: _accessTokenKey);
    _cachedRefreshToken = await _storage.read(key: _refreshTokenKey);
  }
  Future<void> saveTokens({required String accessToken, required String refreshToken}) async {
    _cachedAccessToken = accessToken; _cachedRefreshToken = refreshToken;
    await Future.wait([_storage.write(key: _accessTokenKey, value: accessToken),
                       _storage.write(key: _refreshTokenKey, value: refreshToken)]);
  }
  String? get accessToken => _cachedAccessToken;
  String? get refreshToken => _cachedRefreshToken;
  bool get hasTokens => _cachedAccessToken != null;
  Future<void> clearTokens() async { /* 캐시 null + 저장소 delete */ }
}

// core/network/auth_interceptor.dart
class AuthInterceptor extends Interceptor {
  final TokenManager _tokenManager;
  final Dio _dio;
  AuthInterceptor({required TokenManager tokenManager, required Dio dio}) : _tokenManager = tokenManager, _dio = dio;

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = _tokenManager.accessToken;
    if (token != null) options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401 && _tokenManager.refreshToken != null) {
      try {
        if (await _refreshToken()) {
          final options = err.requestOptions;
          options.headers['Authorization'] = 'Bearer ${_tokenManager.accessToken}';
          return handler.resolve(await _dio.fetch(options));   // 원래 요청 재시도
        }
      } catch (_) {
        await _tokenManager.clearTokens();                    // 갱신도 실패 = 로그아웃
      }
    }
    handler.next(err);
  }

  Future<bool> _refreshToken() async {
    final response = await _dio.post(ApiEndpoints.refresh, data: {'refresh_token': _tokenManager.refreshToken});
    if (response.statusCode == 200) {
      final data = response.data as Map<String, dynamic>;
      await _tokenManager.saveTokens(accessToken: data['access_token'], refreshToken: data['refresh_token']);
      return true;
    }
    return false;
  }
}

TokenManager 는 flutter_secure_storage 에 쓰되 메모리에도 복사본을 둬요. 인터셉터가 요청마다 토큰을 읽는데 그때마다 키체인을 열면 느리기 때문에 앱 시작 때 init() 으로 한 번만 읽어요. 인터셉터는 두 훅만 써요. onRequest 는 토큰이 있으면 Authorization: Bearer 헤더를 붙이고, onError 는 401 이고 refresh 토큰이 있으면 갱신 후 원래 요청을 새 토큰으로 다시 보내 그 응답을 돌려줘요. 화면은 401 이 있었는지도 몰라요. 갱신도 실패하면 토큰을 지워 다음 화면 이동 때 로그인으로 가게 해요.

알려진 한계가 하나 있어요. 동시에 여러 요청이 401 을 받으면 갱신 요청이 여러 번 나가요. refresh 토큰이 지워진 뒤에는 조건이 false 라 무한 루프는 돌지 않지만, 4편 전에 갱신 중 플래그를 하나 둘 예정이에요.

로그인 상태는 어떻게 라우터에 전달되나요? AuthStatus 3개와 listenable

Riverpod AuthNotifier 코드: AuthStatus unknown/authenticated/unauthenticated, authRouterListenable 로 go_router redirect 재평가

// authProvider 상태가 변할 때 GoRouter 가 redirect 를 다시 평가하도록 알리는 listenable.
// app_router.dart 가 auth_providers 를 import 하므로 반대 방향 의존을 피하려고 auth 쪽에 둠.
class _AuthRouterListenable extends ChangeNotifier {
  void notify() => notifyListeners();
}
final authRouterListenable = _AuthRouterListenable();

enum AuthStatus { unknown, authenticated, unauthenticated }

class AuthState {
  final AuthStatus status;
  final UserInfo? user;
  final bool isLoading;
  final String? error;
  const AuthState({this.status = AuthStatus.unknown, this.user, this.isLoading = false, this.error});
  bool get isLoggedIn => status == AuthStatus.authenticated && user != null;
}

class AuthNotifier extends StateNotifier<AuthState> {
  final Ref _ref;
  AuthNotifier(this._ref) : super(const AuthState()) {
    _checkAuth();
    addListener((_) => authRouterListenable.notify());   // 상태 바뀔 때마다 redirect 재평가
  }

  Future<void> _checkAuth() async {                        // 앱 시작 시 1회
    if (tokenManager.hasTokens) {
      try {
        final user = await _ref.read(apiServiceProvider).getMe();
        state = AuthState(status: AuthStatus.authenticated, user: user);
      } catch (_) {
        state = const AuthState(status: AuthStatus.unauthenticated); // 토큰 만료·폐기 → 로그인으로
      }
    } else {
      state = const AuthState(status: AuthStatus.unauthenticated);
    }
  }

  Future<void> loginWithKakao(String accessToken) async {
    state = state.copyWith(isLoading: true, error: null);
    try {
      final res = await _ref.read(apiServiceProvider).loginKakao(accessToken); // 서버가 JWT 2장 + user
      await tokenManager.saveTokens(accessToken: res.accessToken, refreshToken: res.refreshToken);
      state = AuthState(status: AuthStatus.authenticated, user: res.user);
    } on DioException catch (e) {
      state = state.copyWith(isLoading: false, error: NetworkError.fromDioException(e).message);
    }
  }

  Future<void> logout() async {
    await _signOutSocialSdks();      // Kakao UserApi.logout + GoogleSignIn.signOut
    await tokenManager.clearTokens();
    state = const AuthState(status: AuthStatus.unauthenticated);
  }
}

final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) => AuthNotifier(ref));

AuthStatus 는 unknown(앱 시작 직후 확인 중), authenticated, unauthenticated 셋이에요. unknown 이 따로 있는 이유는 라우터 게이트 때문이에요. 확인이 끝나기 전에 redirect 가 돌면 로그인 화면이 잠깐 떴다 사라지는 깜빡임이 생기는데, unknown 이면 게이트가 보류를 돌려줘요. 앱을 켰을 때 _checkAuth() 가 한 번 돌아 토큰이 있으면 /api/auth/me 로 검증하고, 서버가 거부하면 unauthenticated 로 떨어져요.

상태가 바뀔 때마다 authRouterListenable.notify() 가 호출돼 go_router 가 redirect 를 다시 평가해요. 이 listenable 을 auth 쪽에 둔 이유는 라우터가 auth 를 import 하므로 반대 방향 import 를 피하기 위해서예요. 로그아웃은 카카오·구글 SDK 세션 끊기, 토큰 지우기, 상태 바꾸기 세 줄이에요. SDK 세션을 끊지 않으면 다음 로그인 때 카카오가 silent auth 로 이전 계정에 자동 로그인돼요.

트러블슈팅: FCM 푸시 토큰이 서버에 전송되지 않던 문제

이번 편을 준비하며 푸시 코드를 열었다가 발견했어요. push_notification_service.dart 는 FCM 토큰을 받아 _sendTokenToServer() 를 부르는데, 그 함수 안이 debugPrint 한 줄이에요. api_service.dart 에 registerFcmToken() 이 만들어져 있지만 호출하는 곳이 0곳이에요.

// core/services/push_notification_service.dart (현재)
Future<void> _sendTokenToServer(String token) async {
  debugPrint('[FCM] Sending token to server: $token');   // 여기서 끝. 서버로 안 감
}

// core/network/api_service.dart (있지만 아무도 안 부름)
Future<Map<String, String>> registerFcmToken(String token) async {
  final response = await _dio.post('api/auth/fcm-token', data: {'token': token});
  return Map<String, String>.from(response.data);
}
있는 것 없는 것
FCM 초기화, 권한 요청, 토큰 획득, 토큰 갱신 리스너, 포그라운드·탭 핸들러 토큰을 서버에 보내는 한 줄
서버 API POST /api/auth/fcm-token 과 앱 쪽 메서드 그 메서드를 호출하는 곳

앱은 푸시를 받을 준비는 다 됐는데 서버가 누구에게 보낼지 모르는 상태였어요. 클로드코드가 양쪽 끝을 다 만들고 가운데 한 줄을 잇지 않은 거예요. 고쳤어요(2026-09-15). _sendTokenToServer 가 registerFcmToken(token) 을 실제로 호출하고, 로그인 전에 토큰이 먼저 오면 보관했다가 로그인 성공 직후 _onLoggedIn 에서 다시 보내요. 로그인 성공 처리 3곳(카카오·구글·테스트)을 이 헬퍼 하나로 합치면서 함께 넣었어요.

2편의 Pretendard 폰트 버그는 어떻게 고쳤나요?

테마에 Pretendard 를 선언만 하고 pubspec 에 등록하지 않아 시스템 폰트로 나오던 문제예요. Pretendard 공식 저장소(v1.3.9)에서 OTF 4개(Regular·Medium·SemiBold·Bold, 각 1.5MB)를 받아 assets/fonts 에 넣고, pubspec.yaml 의 fonts 에 family: Pretendard 로 weight 400~700 을 등록했어요. flutter pub get 통과했고, 디버그 APK 를 빌드해 열어보니 OTF 4개가 포함되고 FontManifest 에 weight 400~700 이 등록돼 있었어요. 화면 캡처는 4편에서 다뤄요.

클로드코드에 로그인 구현을 시킬 때 꼭 줘야 하는 것은?

  1. SDK 버전: google_sign_in 은 6.x 와 7.x 가 다른 라이브러리 수준이에요. pubspec 줄을 그대로 붙여주세요.
  2. 토큰 정책: “access 1시간, refresh 30일, 401 이면 갱신 후 재시도”처럼 숫자로. 주지 않으면 access 만 만들어요.
  3. 키는 환경변수로: String.fromEnvironment 로 받게 하고 기본값을 넣지 말라고 하세요. COSMO 는 기본값에 실제 키가 들어간 채로 한동안 있었고, 2026-09-15 에 전부 제거해 dart_defines.json(git 제외)으로 옮겼어요. 빌드 시 –dart-define-from-file 을 주지 않으면 디버그에서 assert 로 즉시 멈춰요.
  4. 양쪽 끝 연결 확인: “만든 API 메서드 중 호출되지 않는 것 찾아줘”를 마지막에 한 번. 위 FCM 건이 그렇게 나왔어요.

자주 묻는 질문 (FAQ)

Q. 소셜 토큰을 그냥 API 인증에 쓰면 안 되나요?
되긴 하지만 만료 규칙이 제공자마다 달라서 앱이 전부 알아야 해요. 서버에서 한 번 교환해 자체 JWT 로 통일하면 앱은 규칙 하나만 알면 돼요.

Q. flutter_secure_storage 에 넣으면 토큰이 안전한가요?
안드로이드는 Keystore, iOS 는 Keychain 에 들어가요. 루팅·탈옥 기기에서는 뚫릴 수 있어서 access 를 1시간으로 짧게 두는 거예요. 유출돼도 피해가 1시간이에요.

Q. Flutter 앱을 껐다 켜면 다시 로그인해야 하나요?
아니요. 보안 저장소에 토큰이 있으면 /me 로 검증하고 바로 홈이에요. 30일 넘게 켜지 않아 refresh 가 만료된 경우에만 로그인 화면이에요.

Q. 이 카카오 로그인 코드가 앱인토스 미니앱에서도 쓰이나요?
아니요. 정정해요(2026-09-16). 앱인토스 미니앱은 React + Vite 로 따로 만든 웹앱이라 이 Flutter 코드를 쓰지 않고, 로그인 흐름도 별개예요. 이 편의 코드는 Flutter 앱(안드로이드·iOS) 전용이에요.

다음 편과 참고 자료

4편은 빌드와 배포예요. 안드로이드·iOS 두 타깃을 dart-define 키 주입으로 빌드하는 법, 플레이스토어 등록 자료, Flutter 앱과 React 미니앱(앱인토스)이 FastAPI 백엔드를 나눠 쓰는 구조, 그리고 에뮬레이터에서 확인한 폰트·FCM 실측까지요. 앞선 글: (1) 폴더 구조·Riverpod·go_router · (2) 디자인 시스템

출처: COSMO 앱 실제 코드(앱 인증 5파일 573줄 + FastAPI auth 라우터, 2026년 9월 15일 기준). 키, 클라이언트 ID, 서버 주소는 전부 제거했어요.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *