[개발일지 #07 | 클라이언트] Flutter 앱 첫 연동: Secure Storage 토큰 관리, Dio 자동 갱신 & 로그인 화면 구현

카카오톡 스타일의 가족/친구용 프라이빗 크로스플랫폼 메신저 ‘딩동(DingDong)’ 만들기 프로젝트 7편입니다.

지난 [6편]에서는 Go 백엔드에 초대코드 기반 회원가입, Argon2id 비밀번호 해싱, 다중 기기용 JWT Access/Refresh 토큰 발급 API를 구현했습니다.
백엔드 진도를 더 나가기 전에, 이번 7편에서는 [2편]에서 준비해 둔 Flutter 개발 환경을 열어 로그인/회원가입 화면과 토큰 자동 갱신 네트워크 계층을 구현하고 실제 서버와 통신하는 전체 흐름을 연결합니다.


1. 클라이언트 인증 흐름 및 폴더 구조

앱이 시작될 때 저장된 토큰을 확인해 자동 로그인을 시도하고, 통신 중 Access Token(15분)이 만료되면 백그라운드에서 Refresh Token(30일)으로 재발급받는 구조입니다.

flowchart LR
    classDef titleStyle fill:#0f172a,stroke:#0284c7,stroke-width:2px,color:#ffffff;
    classDef nodeStyle fill:#1e293b,stroke:#475569,stroke-width:2px,color:#ffffff;

    A["앱 실행"]:::titleStyle -->|"Secure Storage 조회"| B["/api/v1/users/me 호출"]:::nodeStyle
    B -->|"200 OK"| C["홈 화면 이동 (자동 로그인)"]:::titleStyle
    B -->|"401 만료"| D["Dio 인터셉터 (/auth/refresh)"]:::nodeStyle
    D -->|"갱신 성공"| B
    D -->|"갱신 실패 / 토큰 없음"| E["로그인 화면 이동"]:::titleStyle
  • 보안 저장소 (flutter_secure_storage): 평문으로 저장되는 SharedPreferences 대신, iOS/macOS의 Keychain과 Android의 Keystore 기반 암호화를 사용해 토큰을 보관합니다.
  • 순차 갱신 인터셉터 (QueuedInterceptorsWrapper): 6편 백엔드에 Refresh Token Rotation(1회 사용 시 새 토큰으로 교체)이 적용되어 있으므로, 동시에 여러 요청이 401 에러를 받았을 때 갱신 요청이 중복으로 나가지 않도록 큐(Queue) 방식으로 순차 처리합니다.
  • 상태 관리 (flutter_riverpod): “지금 로그인된 사용자”(AsyncValue<User?>)만 전역으로 관리하여, 로그인 여부에 따라 화면이 자동으로 전환되도록 구성합니다. (로그인 버튼의 로딩 표시와 에러 메시지는 화면 안에서 따로 다룹니다.)

전체 작업 폴더 구조 (백엔드 + Flutter 앱)

내 맥(Mac)의 전체 프로젝트 루트 폴더(예: dingdong/) 아래에 4~6편에서 만든 Go 백엔드(server/)와 2편에서 생성한 Flutter 프로젝트(dingdong_app/)가 나란히 위치하는 구조입니다.

오늘 작성할 모든 코드는 dingdong_app/lib/ 폴더 아래에 들어갑니다.

dingdong/                                 # 내 맥(Mac) 전체 프로젝트 루트 폴더
 ├─ server/                               # [4~6편] Go 백엔드 서버 폴더
 │   ├─ cmd/api/main.go
 │   ├─ internal/
 │   ├─ migrations/
 │   ├─ queries/
 │   ├─ go.mod
 │   └─ sqlc.yaml
 │
 └─ dingdong_app/                         # 👈 [2편, 7편] Flutter 앱 폴더 (pubspec.yaml 위치)
     ├─ android/
     ├─ ios/
     ├─ macos/
     ├─ pubspec.yaml
     └─ lib/                              # 👈 Flutter 소스 코드 기본 폴더
         ├─ core/                         # [새로 만들 폴더] 공통 핵심 모듈
         │   ├─ storage/
         │   │   └─ token_storage.dart    # 1. Access / Refresh 토큰 암호화 저장소
         │   └─ network/
         │       └─ dio_client.dart       # 2. Dio 설정 및 401 토큰 자동 갱신 인터셉터
         ├─ features/                     # [새로 만들 폴더] 기능별 화면 및 상태 관리
         │   └─ auth/
         │       ├─ user_model.dart       # 3. 서버 응답 JSON 매핑 모델
         │       ├─ auth_provider.dart    # 4. 로그인 / 회원가입 / 자동 로그인 상태 관리
         │       └─ login_screen.dart     # 5. 로그인 및 회원가입 UI 화면
         └─ main.dart                     # 6. 앱 진입점 및 라우팅 분기 (기존 파일 수정)

2. Flutter 프로젝트 준비 및 패키지 설치

터미널에서 2편에서 만든 Flutter 프로젝트(dingdong_app) 폴더로 이동하여 필요한 패키지 3개를 설치하고, lib/ 아래에 하위 폴더들을 생성합니다.

cd dingdong_app

# 1. HTTP 클라이언트(dio), 보안 저장소(flutter_secure_storage), 상태 관리(flutter_riverpod) 설치
flutter pub add dio flutter_secure_storage flutter_riverpod

# 2. lib/ 폴더 아래에 기능별 하위 폴더 일괄 생성
mkdir -p lib/core/storage lib/core/network lib/features/auth

⚠️ macOS 데스크톱 타겟으로 실행할 경우 주의사항 (iOS/Android는 해당 없음)

flutter_secure_storage 는 내부적으로 애플의 Keychain을 사용합니다. iOS 시뮬레이터나 Android 에뮬레이터에서는 별도 설정 없이 바로 동작하지만, macOS 데스크톱 앱(flutter run -d macos) 으로 실행할 때는 Keychain 접근 권한이 없으면 OSStatus error -34018 에러가 발생합니다.

macOS 데스크톱으로 테스트하려면 아래 두 파일의 <dict> 안에 항목을 추가하거나, iOS 시뮬레이터로 실행하세요. 기존 항목은 지우지 말고, 없는 것만 추가합니다.

대상 파일: macos/Runner/DebugProfile.entitlements 및 macos/Runner/Release.entitlements

	<!-- 외부 API 서버와 통신하기 위한 네트워크 클라이언트 권한 -->
	<key>com.apple.security.network.client</key>
	<true/>
	<!-- flutter_secure_storage가 키체인을 쓰기 위해 필요 -->
	<key>keychain-access-groups</key>
	<array/>

3. 토큰 저장소 및 데이터 모델 구현

3.1 보안 토큰 저장소 (lib/core/storage/token_storage.dart)

Access Token과 Refresh Token을 읽고 쓰는 래퍼 클래스입니다.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

final tokenStorageProvider = Provider<TokenStorage>((ref) {
  return TokenStorage();
});

class TokenStorage {
  static const _accessTokenKey = 'dingdong_access_token';
  static const _refreshTokenKey = 'dingdong_refresh_token';

  // 플랫폼별 기본 보안 저장소(Keychain / Android Keystore)를 그대로 사용합니다.
  final FlutterSecureStorage _storage = const FlutterSecureStorage();

  Future<void> saveTokens({
    required String accessToken,
    required String refreshToken,
  }) async {
    await Future.wait([
      _storage.write(key: _accessTokenKey, value: accessToken),
      _storage.write(key: _refreshTokenKey, value: refreshToken),
    ]);
  }

  Future<String?> getAccessToken() => _storage.read(key: _accessTokenKey);

  Future<String?> getRefreshToken() => _storage.read(key: _refreshTokenKey);

  Future<void> clearTokens() async {
    await Future.wait([
      _storage.delete(key: _accessTokenKey),
      _storage.delete(key: _refreshTokenKey),
    ]);
  }
}

3.2 사용자 및 인증 응답 모델 (lib/features/auth/user_model.dart)

6편의 Go 서버 코드를 보면, 핸들러가 정의한 AuthResponse는 camelCase(accessToken, refreshToken)를 사용하고, sqlc가 생성한 db.User 구조체는 DB 컬럼명 그대로 snake_case(display_name, created_at)를 JSON 키로 내려줍니다. 이에 맞춰 파싱 로직을 작성합니다.

class User {
  final String id;
  final String displayName;
  final String? statusMessage;
  final String role;
  final String status;

  const User({
    required this.id,
    required this.displayName,
    this.statusMessage,
    required this.role,
    required this.status,
  });

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as String,
      displayName: json['display_name'] as String,
      statusMessage: json['status_message'] as String?,
      role: json['role'] as String,
      status: json['status'] as String,
    );
  }
}

class AuthResponse {
  final String accessToken;
  final String refreshToken;
  final User user;

  const AuthResponse({
    required this.accessToken,
    required this.refreshToken,
    required this.user,
  });

  factory AuthResponse.fromJson(Map<String, dynamic> json) {
    return AuthResponse(
      accessToken: json['accessToken'] as String,
      refreshToken: json['refreshToken'] as String,
      user: User.fromJson(json['user'] as Map<String, dynamic>),
    );
  }
}

4. Dio 클라이언트 및 토큰 자동 갱신 인터셉터 (lib/core/network/dio_client.dart)

인터셉터 작성 시 주의해야 할 점은 다음 네 가지입니다.

  1. /api/v1/auth/ 하위 경로(로그인, 회원가입, 리프레시 자체)에서 발생한 401 에러는 토큰 갱신을 시도하지 않고 즉시 에러를 반환해야 무한 루프에 빠지지 않습니다.
  2. 토큰 갱신 요청과, 갱신 뒤 원래 요청을 다시 보내는 일은 인터셉터가 없는 별도의 Dio 인스턴스(plainDio) 로 보냅니다. 인터셉터가 있는 dio로 다시 보내면, 그 재요청이 실패했을 때 에러가 아직 처리 중인 같은 큐에 들어가 영원히 기다리는 교착 상태(데드락)에 빠질 수 있습니다.
  3. QueuedInterceptorsWrapper를 사용해 여러 요청이 동시에 401을 받았을 때 순차적으로 처리하되, 앞선 요청이 이미 토큰을 갱신했다면 뒤따라온 요청은 /refresh를 다시 호출하지 않고 새 토큰으로 재시도만 수행합니다.
  4. 저장된 토큰을 지우는(로그아웃시키는) 경우는 서버가 리프레시 토큰을 거부(401)했을 때로 한정합니다. 와이파이가 잠깐 끊겼거나 NAS가 재시작 중일 때까지 로그아웃시키면 안 되기 때문입니다.
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../storage/token_storage.dart';

// 실행 시 --dart-define=API_BASE_URL=https://api.myfamily.com 으로 주입할 수 있습니다.
// 기본값은 로컬 테스트용 localhost:3000 (Android 에뮬레이터는 10.0.2.2:3000 사용)
const String kApiBaseUrl = String.fromEnvironment(
  'API_BASE_URL',
  defaultValue: 'http://localhost:3000',
);

final dioProvider = Provider<Dio>((ref) {
  final tokenStorage = ref.watch(tokenStorageProvider);
  return createDioClient(tokenStorage);
});

Dio createDioClient(TokenStorage tokenStorage) {
  final baseOptions = BaseOptions(
    baseUrl: kApiBaseUrl,
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 15),
    headers: {'Content-Type': 'application/json'},
  );

  final dio = Dio(baseOptions);
  // 인터셉터가 없는 별도 Dio: 토큰 갱신 요청과, 갱신 후 원래 요청 재시도에만 사용합니다.
  final plainDio = Dio(baseOptions);

  dio.interceptors.add(
    QueuedInterceptorsWrapper(
      onRequest: (options, handler) async {
        final accessToken = await tokenStorage.getAccessToken();
        if (accessToken != null && accessToken.isNotEmpty) {
          options.headers['Authorization'] = 'Bearer $accessToken';
        }
        handler.next(options);
      },
      onError: (DioException err, ErrorInterceptorHandler handler) async {
        final statusCode = err.response?.statusCode;
        final requestPath = err.requestOptions.path;

        // 401 에러가 아니거나, 인증 관련 엔드포인트(/auth/*)에서 난 에러면 그대로 통과
        if (statusCode != 401 || requestPath.contains('/api/v1/auth/')) {
          return handler.next(err);
        }

        final storedAccess = await tokenStorage.getAccessToken();
        final requestAuthHeader = err.requestOptions.headers['Authorization'];

        // [동시성 방어] 큐에서 기다리는 동안 앞선 요청이 이미 토큰을 갱신했다면,
        // 다시 /refresh를 호출하지 않고 새로 저장된 토큰으로 원래 요청만 재시도합니다.
        if (storedAccess != null &&
            requestAuthHeader != null &&
            requestAuthHeader != 'Bearer $storedAccess') {
          err.requestOptions.headers['Authorization'] = 'Bearer $storedAccess';
          try {
            return handler.resolve(await plainDio.fetch(err.requestOptions));
          } on DioException catch (retryErr) {
            return handler.next(retryErr);
          }
        }

        // 저장된 Refresh Token 확인
        final refreshToken = await tokenStorage.getRefreshToken();
        if (refreshToken == null || refreshToken.isEmpty) {
          await tokenStorage.clearTokens();
          return handler.next(err);
        }

        // 6편의 Refresh Token Rotation API 호출
        String newAccess;
        try {
          final refreshRes = await plainDio.post(
            '/api/v1/auth/refresh',
            data: {'refreshToken': refreshToken},
          );
          newAccess = refreshRes.data['accessToken'] as String;
          final newRefresh = refreshRes.data['refreshToken'] as String;

          // 새로 발급받은 두 토큰을 Secure Storage에 덮어쓰기
          await tokenStorage.saveTokens(
            accessToken: newAccess,
            refreshToken: newRefresh,
          );
        } on DioException catch (refreshErr) {
          // 서버가 리프레시 토큰을 거부(401)했을 때만 저장소를 비웁니다.
          // 네트워크 오류나 서버 장애일 때는 토큰을 남겨 두어 나중에 다시 시도할 수 있게 합니다.
          if (refreshErr.response?.statusCode == 401) {
            await tokenStorage.clearTokens();
          }
          return handler.next(err);
        } catch (_) {
          // 응답 형식이 예상과 다른 경우 등
          return handler.next(err);
        }

        // 실패했던 원래 요청의 헤더를 새 토큰으로 교체하여 재요청
        err.requestOptions.headers['Authorization'] = 'Bearer $newAccess';
        try {
          return handler.resolve(await plainDio.fetch(err.requestOptions));
        } on DioException catch (retryErr) {
          return handler.next(retryErr);
        }
      },
    ),
  );

  return dio;
}

5. 인증 상태 관리 구현 (lib/features/auth/auth_provider.dart)

Riverpod의 AsyncNotifier를 사용해 앱 시작 시 자동 로그인 여부를 판별하고, 로그인/회원가입/로그아웃 동작을 수행합니다.

이 Provider의 상태는 “지금 로그인된 사용자(User?)” 하나만 나타냅니다. 로그인 요청이 실패했을 때는 상태를 바꾸지 않고 예외를 던져서, 로그인 화면이 직접 에러 메시지를 보여 주도록 합니다. (로그인 도중에 상태를 로딩이나 에러로 바꾸면 main.dart가 화면을 통째로 갈아 끼워서 입력하던 내용이 사라지고 에러 메시지도 놓치게 됩니다.)

import 'dart:io' show Platform;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/dio_client.dart';
import '../../core/storage/token_storage.dart';
import 'user_model.dart';

final authProvider = AsyncNotifierProvider<AuthNotifier, User?>(
  AuthNotifier.new,
);

class AuthNotifier extends AsyncNotifier<User?> {
  @override
  Future<User?> build() async {
    final tokenStorage = ref.read(tokenStorageProvider);
    final accessToken = await tokenStorage.getAccessToken();

    // 저장된 토큰이 없으면 비로그인 상태(null)로 시작
    if (accessToken == null || accessToken.isEmpty) {
      return null;
    }

    // 토큰이 있으면 /api/v1/users/me를 호출해 유효성 검증 및 내 정보 로드
    // (Access Token이 만료되었더라도 Dio 인터셉터가 자동으로 갱신 후 가져옵니다)
    try {
      final dio = ref.read(dioProvider);
      final res = await dio.get('/api/v1/users/me');
      final userMap = res.data['user'] as Map<String, dynamic>;
      return User.fromJson(userMap);
    } on DioException catch (_) {
      // 세션이 만료된 경우(401)에는 인터셉터가 이미 토큰을 지웠습니다.
      // 네트워크 오류나 서버 점검이면 토큰은 그대로 남겨 두고 로그인 화면을 보여 줍니다.
      // (서버가 복구된 뒤 앱을 다시 켜면 자동 로그인됩니다.)
      return null;
    }
  }

  // 현재 실행 중인 OS 플랫폼 문자열 반환
  String _currentPlatform() {
    if (kIsWeb) return 'web';
    if (Platform.isAndroid) return 'android';
    if (Platform.isIOS) return 'ios';
    if (Platform.isMacOS) return 'macos';
    if (Platform.isWindows) return 'windows';
    return 'unknown';
  }

  // 로그인 요청 (실패하면 DioException이 그대로 던져집니다)
  Future<void> login({
    required String loginId,
    required String password,
  }) async {
    final res = await ref.read(dioProvider).post(
      '/api/v1/auth/login',
      data: {
        'loginId': loginId.trim(),
        'password': password,
        'platform': _currentPlatform(),
        'deviceName': 'DingDong-${_currentPlatform()}',
      },
    );
    await _startSession(res.data as Map<String, dynamic>);
  }

  // 초대코드 기반 회원가입 요청 (실패하면 DioException이 그대로 던져집니다)
  Future<void> register({
    required String inviteCode,
    required String loginId,
    required String password,
    required String displayName,
    String? phone,
  }) async {
    final res = await ref.read(dioProvider).post(
      '/api/v1/auth/register',
      data: {
        'inviteCode': inviteCode.trim(),
        'loginId': loginId.trim(),
        'password': password,
        'displayName': displayName.trim(),
        if (phone != null && phone.trim().isNotEmpty) 'phone': phone.trim(),
        'platform': _currentPlatform(),
        'deviceName': 'DingDong-${_currentPlatform()}',
      },
    );
    await _startSession(res.data as Map<String, dynamic>);
  }

  // 로그인/가입 성공 시 공통 처리: 토큰 저장 후 "로그인된 사용자"로 상태 전환
  Future<void> _startSession(Map<String, dynamic> json) async {
    final authRes = AuthResponse.fromJson(json);
    await ref.read(tokenStorageProvider).saveTokens(
      accessToken: authRes.accessToken,
      refreshToken: authRes.refreshToken,
    );
    state = AsyncData(authRes.user);
  }

  // 로그아웃 (로컬 토큰 삭제)
  Future<void> logout() async {
    final tokenStorage = ref.read(tokenStorageProvider);
    await tokenStorage.clearTokens();
    state = const AsyncData(null);
  }
}

// DioException에서 서버가 내려준 {"error": "메시지"}를 추출하는 헬퍼 함수
String extractErrorMessage(Object error) {
  if (error is DioException) {
    final data = error.response?.data;
    if (data is Map<String, dynamic> && data['error'] is String) {
      return data['error'] as String;
    }
    return '서버와 통신할 수 없습니다 (${error.message})';
  }
  return error.toString();
}

6. 로그인/회원가입 화면 및 메인 엔트리 구현

6.1 로그인 & 회원가입 폼 화면 (lib/features/auth/login_screen.dart)

하나의 화면에서 로그인 모드와 회원가입 모드를 전환하며 테스트할 수 있도록 구성합니다.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';

class LoginScreen extends ConsumerStatefulWidget {
  const LoginScreen({super.key});

  @override
  ConsumerState<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends ConsumerState<LoginScreen> {
  bool _isRegisterMode = false;
  bool _isLoading = false; // 요청 진행 중 여부 (버튼 비활성화/로딩 표시용)

  final _inviteCodeCtrl = TextEditingController();
  final _loginIdCtrl = TextEditingController();
  final _passwordCtrl = TextEditingController();
  final _displayNameCtrl = TextEditingController();
  final _phoneCtrl = TextEditingController();

  @override
  void dispose() {
    _inviteCodeCtrl.dispose();
    _loginIdCtrl.dispose();
    _passwordCtrl.dispose();
    _displayNameCtrl.dispose();
    _phoneCtrl.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    final notifier = ref.read(authProvider.notifier);
    setState(() => _isLoading = true);

    try {
      if (_isRegisterMode) {
        await notifier.register(
          inviteCode: _inviteCodeCtrl.text,
          loginId: _loginIdCtrl.text,
          password: _passwordCtrl.text,
          displayName: _displayNameCtrl.text,
          phone: _phoneCtrl.text,
        );
      } else {
        await notifier.login(
          loginId: _loginIdCtrl.text,
          password: _passwordCtrl.text,
        );
      }
      // 성공하면 authProvider가 User로 바뀌고, main.dart가 홈 화면으로 전환합니다.
    } catch (e) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(extractErrorMessage(e)),
          backgroundColor: Colors.redAccent,
        ),
      );
    } finally {
      // 성공 시에는 이 화면이 이미 사라졌을 수 있으므로 확인 후 갱신
      if (mounted) setState(() => _isLoading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    final isLoading = _isLoading;

    return Scaffold(
      appBar: AppBar(
        title: Text(_isRegisterMode ? '딩동 회원가입' : '딩동 로그인'),
      ),
      body: Center(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 400),
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(24),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                if (_isRegisterMode) ...[
                  TextField(
                    controller: _inviteCodeCtrl,
                    decoration: const InputDecoration(
                      labelText: '초대코드',
                      hintText: '예: DING26',
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 12),
                  TextField(
                    controller: _displayNameCtrl,
                    decoration: const InputDecoration(
                      labelText: '이름 (닉네임)',
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 12),
                  TextField(
                    controller: _phoneCtrl,
                    decoration: const InputDecoration(
                      labelText: '전화번호 (선택, E.164 형식)',
                      hintText: '+821012345678',
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 12),
                ],
                TextField(
                  controller: _loginIdCtrl,
                  decoration: const InputDecoration(
                    labelText: '로그인 아이디',
                    border: OutlineInputBorder(),
                  ),
                ),
                const SizedBox(height: 12),
                TextField(
                  controller: _passwordCtrl,
                  obscureText: true,
                  decoration: const InputDecoration(
                    labelText: '비밀번호 (최소 8자)',
                    border: OutlineInputBorder(),
                  ),
                ),
                const SizedBox(height: 24),
                FilledButton(
                  onPressed: isLoading ? null : _submit,
                  style: FilledButton.styleFrom(
                    padding: const EdgeInsets.symmetric(vertical: 16),
                  ),
                  child: isLoading
                      ? const SizedBox(
                          height: 20,
                          width: 20,
                          child: CircularProgressIndicator(strokeWidth: 2),
                        )
                      : Text(_isRegisterMode ? '가입하기' : '로그인'),
                ),
                const SizedBox(height: 12),
                TextButton(
                  onPressed: isLoading
                      ? null
                      : () {
                          setState(() {
                            _isRegisterMode = !_isRegisterMode;
                          });
                        },
                  child: Text(
                    _isRegisterMode
                        ? '이미 계정이 있으신가요? 로그인으로 전환'
                        : '초대코드가 있으신가요? 회원가입으로 전환',
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

6.2 앱 진입점 및 자동 로그인 분기 (lib/main.dart)

authProvider의 상태에 따라 로딩 화면, 로그인 화면, 로그인 완료 홈 화면을 분기합니다.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'features/auth/auth_provider.dart';
import 'features/auth/login_screen.dart';
import 'features/auth/user_model.dart';

void main() {
  runApp(const ProviderScope(child: DingDongApp()));
}

class DingDongApp extends ConsumerWidget {
  const DingDongApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final authState = ref.watch(authProvider);

    return MaterialApp(
      title: '딩동 메신저',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      home: authState.when(
        loading: () => const Scaffold(
          body: Center(child: CircularProgressIndicator()),
        ),
        error: (_, __) => const LoginScreen(),
        data: (User? user) {
          if (user == null) {
            return const LoginScreen();
          }
          return HomePlaceholderScreen(user: user);
        },
      ),
    );
  }
}

class HomePlaceholderScreen extends ConsumerWidget {
  final User user;
  const HomePlaceholderScreen({super.key, required this.user});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('딩동 홈'),
        actions: [
          IconButton(
            icon: const Icon(Icons.logout),
            tooltip: '로그아웃',
            onPressed: () => ref.read(authProvider.notifier).logout(),
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              '환영합니다, ${user.displayName}님!',
              style: Theme.of(context).textTheme.headlineSmall,
            ),
            const SizedBox(height: 8),
            Text('User ID: ${user.id}'),
            Text('Role: ${user.role} | Status: ${user.status}'),
          ],
        ),
      ),
    );
  }
}

7. 실행 및 연동 테스트

6편에서 만든 Go 백엔드 서버가 실행 중인 상태에서 Flutter 앱을 구동합니다. (맥에서 직접 띄우는 방법은 6편 6.2절, NAS에서는 docker compose up -d --build입니다.)

7.1 로컬 Go 서버와 연결할 때 (iOS 시뮬레이터 / macOS 데스크톱)

flutter run

(참고: Android 에뮬레이터에서 내 맥의 localhost:3000에 접속할 때는 flutter run --dart-define=API_BASE_URL=http://10.0.2.2:3000으로 실행해야 합니다.)

⚠️ Android는 기본적으로 http://(암호화되지 않은) 통신을 막습니다. 에뮬레이터에서 위처럼 http://10.0.2.2:3000으로 테스트하려면 android/app/src/debug/AndroidManifest.xml의 <manifest> 안에 아래 한 줄을 추가하세요. debug 폴더의 파일이라 개발 중에만 적용되고, 배포용 빌드에는 들어가지 않습니다. NAS 도메인(https://)으로 접속할 때는 필요 없습니다.

<application android:usesCleartextTraffic="true"/>

7.2 유그린 NAS에 배포된 도메인과 연결할 때

3편과 4편에서 Caddy로 연결한 도메인 주소(본인 도메인으로 바꿔 입력)를 주입하여 실행합니다. 실제 스마트폰은 localhost가 맥이 아니라 폰 자신을 가리키므로, 이 방식으로 실행해야 합니다.

flutter run --dart-define=API_BASE_URL=https://api.myfamily.com

확인해 볼 체크리스트

  1. 로그인 테스트: 6편에서 생성한 계정(daddy / my_secure_password_123!)으로 로그인했을 때 홈 화면에 이름과 UUID가 정상 출력되는지 확인합니다.
  2. 자동 로그인 테스트: 앱을 완전히 종료했다가 다시 실행했을 때 로그인 화면을 거치지 않고 곧바로 홈 화면으로 진입하는지 확인합니다.
  3. 에러 메시지 표시: 틀린 비밀번호를 입력했을 때 하단 스낵바에 서버가 내려준 "아이디 또는 비밀번호가 일치하지 않습니다" 문구가 표시되고, 입력하던 아이디가 지워지지 않는지 확인합니다.

8. 이번 편 요약 및 다음 단계

이번 7편에서는 백엔드 인증 API와 맞물려 돌아가는 Flutter 클라이언트 인증 구조를 만들었습니다:

  1. 암호화 저장소 연동: flutter_secure_storage를 통해 토큰을 OS 보안 영역에 저장하고 앱 재실행 시 자동 로그인을 수행합니다.
  2. 순차 토큰 갱신: QueuedInterceptorsWrapper를 사용해 Access Token 만료 시 Refresh Token Rotation 규칙을 깨뜨리지 않고 백그라운드에서 토큰을 교체합니다.

아직 적용하지 않은 것 (추후 보완 사항)

  • 서버 측 로그아웃 API 호출: 현재 logout()은 스마트폰에 저장된 토큰만 지웁니다. 완벽한 세션 종료를 위해서는 서버에서 해당 기기(devices 행)도 삭제해야 하는데, 6편에는 DeleteDevice 쿼리만 있고 이를 호출하는 API 경로(예: DELETE /api/v1/devices/{id})는 아직 없습니다. 서버에 먼저 만들어야 합니다.
  • 세션 만료 시 화면 전환: 앱을 쓰는 도중 리프레시 토큰까지 거부되면 인터셉터가 토큰은 지우지만, authProvider의 상태는 그대로라 홈 화면에 머무릅니다. 인터셉터가 만료를 알리면 로그아웃 상태로 바꿔 주는 연결이 필요합니다.
  • 실제 기기 모델명 가져오기: 현재는 deviceName을 DingDong-ios처럼 단순 문자열로 보내고 있습니다. 추후 device_info_plus 패키지를 연동하면 "iPhone 16 Pro" 같은 실제 기기 이름을 기록할 수 있습니다.
  • 전화번호 형식 검증: 입력란에는 E.164 형식(+821012345678)이라고 안내하지만 앱에서 검사하지는 않습니다. 형식이 제각각이면 8편의 연락처 해시 매칭에서 같은 번호가 다른 값으로 계산되므로, 저장 전에 형식을 통일해야 합니다.

눈으로 동작을 확인했으니, 다음 [개발일지 #08]에서는 다시 Go 백엔드로 돌아가 연락처 HMAC 해시 매칭을 통한 친구 추가 API와 중복 생성이 방지되는 1:1 및 그룹 채팅방 생성 API를 구현하겠습니다.

📂 Part 1. 기획 & 아키텍처 설계

📂 Part 2. 홈서버 & 인프라 구축

📂 Part 3. 백엔드(API) & 실시간 통화 서버

📂 Part 4. 모바일 앱 개발 (iOS / Android)

  • ✅ [개발일지 #07 | 클라이언트] Flutter 앱 첫 연동: Secure Storage 토큰 관리, Dio 자동 갱신 & 로그인 화면 구현
  • ⏳ [#09 | 클라이언트] 친구 목록 동기화 & 채팅방 목록 UI 연동하기 (예정)
  • ⏳ [#11 | 클라이언트] 실시간 WebSocket 채팅방 화면 & 끊김 없는 대화 동기화 구현 (예정)
  • ⏳ [#12 | 앱-공통] 300MB 대용량 원본 사진/영상 전송 및 미디어 처리 (예정)
  • ⏳ [#13 | 앱-푸시] 앱이 꺼져 있어도 울리는 푸시 알림(FCM/APNs) 및 보이스톡 연동 (예정)

📂 Part 5. 정식 배포 & 운영

  • ⏳ [#14 | 배포] 백엔드 서버 도커 이미지 빌드 및 유그린 NAS 정식 배포 (예정)
  • ⏳ [#15 | 운영] DB 자동 백업 스크립트 구성 및 가족 실사용 후기 (예정)

댓글 남기기