최종 업데이트: 2026-09-16 (앱인토스 미니앱 관련 정정)
![]()
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 소셜 로그인 전체 흐름은 몇 단계인가요? 앱 → 서버 → 저장 → 요청 → 화면

| 단계 | 어디서 | 파일 | 줄 수 |
|---|---|---|---|
| ① 소셜 로그인 | 앱 | 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줄이고 절반은 에러 메시지와 주석이에요.
카카오 로그인은 어떻게 구현했나요? 카카오톡 앱 먼저, 실패하면 웹

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단계

@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")}
- 검증: 앱이 준 카카오 access token 으로 kapi.kakao.com/v2/user/me 를 서버가 직접 호출해요. 앱이 보낸 닉네임을 믿지 않고 카카오에서 다시 받아요.
- 가입 또는 조회: 카카오 id 로 유저를 찾고 없으면 만들어요. 그래서 앱에는 가입 화면이 따로 없어요.
- 발급: access(1시간) + refresh(30일) 두 장을 돌려줘요. refresh 엔드포인트는 expected_type=”refresh” 로 검사해서 access 토큰으로는 갱신할 수 없어요.
소셜 토큰을 그대로 쓰지 않는 이유는 만료 규칙이 제공자마다 달라서예요. 서버 JWT 로 통일하면 앱은 규칙 하나만 알면 되고, 나중에 애플 로그인을 붙여도 앱 쪽 인터셉터는 바뀌지 않아요.
토큰 저장과 401 자동 갱신은 어떻게 동작하나요? TokenManager + 인터셉터 66줄

// 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

// 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편에서 다뤄요.
클로드코드에 로그인 구현을 시킬 때 꼭 줘야 하는 것은?
- SDK 버전: google_sign_in 은 6.x 와 7.x 가 다른 라이브러리 수준이에요. pubspec 줄을 그대로 붙여주세요.
- 토큰 정책: “access 1시간, refresh 30일, 401 이면 갱신 후 재시도”처럼 숫자로. 주지 않으면 access 만 만들어요.
- 키는 환경변수로: String.fromEnvironment 로 받게 하고 기본값을 넣지 말라고 하세요. COSMO 는 기본값에 실제 키가 들어간 채로 한동안 있었고, 2026-09-15 에 전부 제거해 dart_defines.json(git 제외)으로 옮겼어요. 빌드 시 –dart-define-from-file 을 주지 않으면 디버그에서 assert 로 즉시 멈춰요.
- 양쪽 끝 연결 확인: “만든 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) 디자인 시스템
- kakao_flutter_sdk_user / google_sign_in 7 / flutter_secure_storage / dio (pub.dev)
- 카카오 로그인 REST API: 사용자 정보 가져오기 (kapi.kakao.com/v2/user/me)
- Firebase Cloud Messaging Flutter 클라이언트 설정
출처: COSMO 앱 실제 코드(앱 인증 5파일 573줄 + FastAPI auth 라우터, 2026년 9월 15일 기준). 키, 클라이언트 ID, 서버 주소는 전부 제거했어요.

Leave a Reply