teji

tede

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

전체보기

클로드코드(Claude Code)로 Flutter 앱 만들기 (1) 2026: 폴더 구조·Riverpod 상태관리·go_router 인증 게이트·Dio 클라이언트, COSMO 실제 코드

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

클로드코드(Claude Code)로 Flutter 앱 만들기 1편: 폴더 구조와 Riverpod, go_router 뼈대 썸네일

코스메틱 리뷰 커뮤니티 앱 COSMO 는 Flutter 로 만들었고 코드는 거의 전부 클로드코드(Claude Code)가 작성했어요. lib 폴더에 dart 파일 87개, 기능(feature) 폴더 13개 규모예요. 이 글은 그 뼈대를 실제 코드로 보여주는 1편이에요. 폴더 규칙(core / features / shared), Riverpod 의 sealed class 3상태 + Notifier 패턴, go_router 의 redirect 인증 게이트, Dio 싱글턴 + 인터셉터 2개, 그리고 클로드코드에 시킨 순서(규칙 문서 → core → 모델 → 화면)를 다뤄요.

앞선 앱인토스 개발기 3부작에서는 “왜 이렇게 만들었나”를 썼고, 이번 4부작은 “어떻게 짜여 있나”예요. Flutter 를 처음 시작하는 분, 클로드코드에 앱을 맡기려는데 어디부터 시켜야 할지 모르는 분께 맞췄어요.

COSMO Flutter 앱의 기술 스택은 무엇이고 왜 골랐나요?

역할 패키지 고른 이유
상태관리 flutter_riverpod 2.6 Notifier 하나 = 화면 하나. 클로드코드가 패턴을 복제하기 가장 쉬움
라우팅 go_router 17 redirect 함수 하나로 인증 게이트. 화면 경로를 URL 처럼 관리
HTTP dio 5.7 인터셉터로 토큰·401 갱신·로그를 한 곳에서
JSON 모델 json_serializable + build_runner 서버 snake_case 를 fieldRename 한 줄로 흡수
저장소 flutter_secure_storage / shared_preferences 토큰은 보안 저장소, 설정은 prefs
로그인 kakao_flutter_sdk_user / google_sign_in 한국 앱은 카카오가 기본. 구글은 iOS 심사용
운영 firebase_core / crashlytics / analytics / messaging 크래시·이벤트·푸시 한 벌
UI 보조 cached_network_image / shimmer / flutter_svg 이미지 캐시, 스켈레톤 로딩

솔직한 한 줄이에요. pubspec 에 retrofit 과 freezed 도 들어 있지만 실제 코드는 Dio 직접 호출 + JsonSerializable 로 통일돼 있어요. 클로드코드가 초기에 넣어둔 것을 제거하지 않아서 그래요. 처음 시작한다면 한쪽만 고르세요.

Flutter 폴더 구조는 어떻게 나눴나요? core / features / shared

Flutter lib 폴더 구조: core, features 13개, shared 위젯. dart 87개

lib/
├── main.dart          # Firebase·Crashlytics·카카오 SDK·토큰 저장소 초기화
├── app.dart           # MaterialApp.router(theme, routerConfig)
├── core/              # 기능과 무관한 공통 층
│   ├── network/       # api_client · api_service · auth_interceptor · api_endpoints
│   ├── router/        # app_router.dart (go_router, 인증 redirect)
│   ├── theme/         # app_colors · app_spacing · app_text_styles · app_theme
│   ├── storage/ services/ utils/ widgets/
│   └── providers.dart # apiServiceProvider 등 싱글턴 Provider 모음
├── features/          # home · dalgeul · items · amumal · auth · mypage · search ...
│   └── home/
│       ├── data/models/home_models.dart      # @JsonSerializable + .g.dart
│       └── presentation/home_screen.dart · home_view_model.dart
└── shared/widgets/    # main_shell(하단 탭) · underline_tab_bar · empty_state · section_header

# 규칙 하나 : feature 끼리 import 금지. 공유할 게 생기면 core 나 shared 로 올린다
  1. core 는 기능을 모르는 층이에요. 네트워크, 라우터, 테마, 저장소, 공통 유틸. 어떤 feature 도 import 하지 않아요.
  2. features/이름/ 안에 data(모델)와 presentation(화면 + 뷰모델)만 둬요. 13개 폴더가 전부 같은 모양이에요.
  3. feature 끼리는 import 금지예요. 홈에서 달글 카드가 필요하면 shared/widgets 로 올려요.

이 규칙이 중요한 이유는 클로드코드에 “검색 기능 추가해줘”라고 했을 때 features/search 폴더만 새로 만들고 core 는 건드리지 않는 결과가 나오기 때문이에요. 규칙이 없으면 매번 다른 자리에 파일이 생기고 두 달 뒤엔 사람도 못 찾아요.

화면에서 서버까지 몇 층을 거치나요?

Flutter 앱 4층 구조: Screen → Notifier → ApiService → ApiClient(Dio)

Screen 은 상태를 그리기만 하고, Notifier 가 상태를 바꾸고, ApiService 가 서버를 부르고, ApiClient 가 통신 세부를 감춰요. 홈 화면 하나가 이 4층을 타면 나머지 12개 기능도 똑같이 타요. 그래서 새 화면을 시킬 때 “홈이랑 같은 구조로” 한 마디면 돼요. go_router 는 이 4층 바깥에서 “누가 어느 화면에 들어갈 수 있나”만 정해요.

Riverpod 상태관리는 어떤 패턴인가요? sealed class 3개 + Notifier 하나

Riverpod Notifier 패턴: sealed class Loading/Success/Error 3상태와 Future.wait 병렬 호출

sealed class HomeUiState { const HomeUiState(); }
class HomeLoading extends HomeUiState { const HomeLoading(); }
class HomeSuccess extends HomeUiState { final HomeData data; const HomeSuccess(this.data); }
class HomeError   extends HomeUiState { final String message; const HomeError(this.message); }

class HomeNotifier extends Notifier<HomeUiState> {
  @override
  HomeUiState build() { loadHome(); return const HomeLoading(); }

  Future<void> loadHome() async {
    state = const HomeLoading();
    final api = ref.read(apiServiceProvider);
    try {
      // 홈 + 주간 랭킹 병렬 호출. 랭킹 실패는 null 로 무시
      final results = await Future.wait([
        api.getHome(),
        api.getWeeklyRankings().then<WeeklyRankingResponse?>((v) => v, onError: (_) => null),
      ]);
      state = HomeSuccess(HomeData(home: results[0] as HomeResponse,
                                   rankings: results[1] as WeeklyRankingResponse?));
    } on DioException catch (e) {
      state = HomeError(NetworkError.fromDioException(e).message);
    } catch (e) {
      state = HomeError('알 수 없는 오류가 발생했어요.');
    }
  }
}

화면 상태는 항상 Loading / Success / Error 셋 중 하나예요. sealed class 로 묶어서 switch 문에서 빠진 케이스가 있으면 컴파일이 안 돼요. Notifier 의 build() 에서 바로 loadHome() 을 호출하니 화면이 뜨는 순간 로딩이 시작돼요. Future.wait 로 홈 데이터와 주간 랭킹을 동시에 부르고, 랭킹이 실패해도 홈은 뜨게 해뒀어요. 클로드코드가 먼저 이렇게 작성했고 저는 “랭킹 실패는 무시”만 확인했어요.

go_router 인증 게이트는 어떻게 가입 흐름을 지키나요?

go_router redirect 인증 게이트: 비로그인 → 로그인, 약관 미동의 → 동의, 프로필 미완 → 프로필 설정

String? _authRedirect(BuildContext context, GoRouterState state) {
  final auth = ProviderScope.containerOf(context).read(authProvider);
  final loc = state.matchedLocation;

  if (loc == '/onboarding' || loc.startsWith('/policy/')) return null;  // 인증 무관
  if (auth.status == AuthStatus.unknown) return null;               // 확인 중이면 보류
  if (auth.status == AuthStatus.unauthenticated) return loc == '/login' ? null : '/login';
  final user = auth.user;
  if (user == null) return loc == '/login' ? null : '/login';
  if (user.consentsAt == null) return loc == '/consent' ? null : '/consent';           // 약관 미동의
  if (user.isProfileComplete == false) return loc == '/profile-setup' ? null : '/profile-setup';
  if (loc == '/login' || loc == '/consent' || loc == '/profile-setup') return '/'; // 다 끝났으면 홈
  return null;
}

final appRouter = GoRouter(
  initialLocation: '/',
  redirect: _authRedirect,
  refreshListenable: authRouterListenable,   // 로그인 상태 바뀌면 redirect 재평가
  routes: [
    GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingScreen()),
    GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
    ShellRoute(                                 // 하단 탭 6개는 셸 안에
      builder: (_, __, child) => MainShell(child: child),
      routes: [ /* '/', '/dalgeul', '/items', '/amumal', '/notifications', '/mypage' */ ],
    ),
    GoRoute(path: '/dalgeul/:id', builder: (_, s) => DalgeulDetailScreen(id: s.pathParameters['id']!)),
    // ... 상세·작성 화면은 셸 밖 (탭바 없이 전체 화면)
  ],
);

go_router 의 redirect 에 함수 하나를 걸어두면 어느 화면으로 가든 이 함수를 먼저 거쳐요. 순서가 핵심이에요. 비로그인이면 로그인으로, 로그인했는데 약관 동의가 없으면 동의 화면으로, 동의했는데 프로필이 비었으면 프로필 설정으로 보내요.

이걸 만든 계기는 가입 도중 앱을 끄고 다시 켠 사용자가 닉네임도 사진도 없는 상태로 홈에 떨어지는 사고였어요. 화면마다 체크하는 대신 라우터 입구에서 한 번만 막으니 재발이 없었어요. refreshListenable 에 로그인 상태를 물려두면 상태가 바뀔 때마다 이 함수가 다시 실행돼요. 하단 탭 6개(홈·달글·아이템·아무말·알림·마이)는 ShellRoute 안에 두고, 상세·작성 화면은 셸 밖에 둬서 상세 화면에서는 탭바가 사라져요.

Dio 네트워크 클라이언트는 어떻게 구성했나요?

Dio ApiClient 싱글턴: BaseOptions 타임아웃, AuthInterceptor 401 갱신, 디버그 전용 LogInterceptor

class ApiClient {
  static const _baseUrl = 'https://<백엔드 Cloud Run 주소>/';
  final Dio dio;
  ApiClient._({required this.dio});

  factory ApiClient({required TokenManager tokenManager}) {
    final dio = Dio(BaseOptions(
      baseUrl: _baseUrl,
      connectTimeout: const Duration(seconds: 15),
      receiveTimeout: const Duration(seconds: 30),
      sendTimeout: const Duration(seconds: 30),
      headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
    ));
    dio.interceptors.add(AuthInterceptor(tokenManager: tokenManager, dio: dio)); // 토큰 붙이기 + 401 갱신
    if (kDebugMode) {
      dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
    }
    return ApiClient._(dio: dio);
  }
}

// core/providers.dart
final apiServiceProvider = Provider<ApiService>((ref) {
  final client = ApiClient(tokenManager: tokenManager);
  return ApiService(client.dio);
});

Dio 인스턴스는 앱 전체에 하나예요. Provider 로 감싸서 어느 Notifier 에서든 ref.read(apiServiceProvider) 로 꺼내 써요. AuthInterceptor 는 매 요청에 토큰을 붙이고 401 이 오면 갱신 후 재시도하고, LogInterceptor 는 디버그 빌드에만 붙어서 릴리즈에서는 로그가 남지 않아요. 타임아웃은 연결 15초, 응답 30초예요. 서버가 Cloud Run 이라 콜드 스타트가 있어서 연결 타임아웃을 짧게 두면 첫 요청이 자주 실패했고, 15초로 올려 해결했어요.

클로드코드에는 어떤 순서로 시켰나요? 규칙 문서 → core → 모델 → 화면

  1. 규칙 문서(CLAUDE.md) 먼저: 폴더 규칙 세 개, 스택 표, 이슈 번호 규칙을 적어두고 시작했어요. 클로드코드는 매 세션 이 파일을 읽어요.
  2. core 부터: 테마, 라우터 뼈대, ApiClient, TokenManager. 화면 하나 없이 앱이 빌드되는 상태를 먼저 만들어요.
  3. 모델 다음: 서버 응답 JSON 을 주고 features/*/data/models 를 만들게 해요. build_runner 가 .g.dart 를 생성해요.
  4. 화면은 마지막: “홈 화면, 홈 뷰모델과 같은 구조로”처럼 기존 것을 가리키며 시켜요.

순서를 거꾸로 하면(화면부터) 클로드코드가 화면 안에 API 호출을 직접 넣어버려요. 나중에 뷰모델로 빼는 리팩터링이 화면 수만큼 반복돼요. 초반에 이걸 두 번 겪고 순서를 고정했어요. 클로드코드 자체의 사용법은 클로드코드 사용방법 2026에 있어요.

자주 묻는 질문 (FAQ)

Q. Riverpod 말고 Bloc 이나 Provider 면 안 되나요?
돼요. 중요한 건 패키지가 아니라 “화면당 상태 클래스 하나 + 로직 클래스 하나” 규칙이에요. 클로드코드에 시킬 땐 예시 하나를 만들어두고 “이것과 같은 구조로”라고 하는 게 핵심이에요.

Q. dart 87개 파일이면 큰 앱인가요?
아니요, 커뮤니티 앱으로는 작은 편이에요. 대신 13개 기능이 같은 모양이라 파일 수가 늘어도 찾는 시간은 늘지 않아요.

Q. 클로드코드가 짠 코드를 다 읽어보셨나요?
core 는 다 읽었고 features 는 뷰모델만 읽었어요. 화면 코드는 실기기에서 눌러보는 것으로 검증했어요. 구조가 잡혀 있으면 이 정도로 충분했어요.

Q. 앱인토스 미니앱과 이 Flutter 앱이 같은 코드인가요?
아니요. 정정해요(2026-09-16). 앱인토스 미니앱은 React 18 + Vite + 토스 TDS 로 따로 만든 웹앱(codal/tossapp)이고, 이 Flutter 앱(안드로이드·iOS)과는 FastAPI 백엔드만 공유해요. 처음 올린 글에는 “lib 폴더 하나로 웹까지 간다”고 잘못 적었어요. 미니앱 쪽 이야기는 앱인토스 개발기 (2)에 있고, 두 앱이 백엔드를 나눠 쓰는 구조는 4편에서 다뤄요.

다음 편과 참고 자료

2편은 화면·디자인 시스템이에요. app_colors / app_spacing / app_text_styles 세 파일로 색과 간격을 통일한 방법, 언더라인 탭바, 스켈레톤 로딩, 빈 화면 위젯을 코드로 보여드려요. 앞선 시리즈: 앱인토스 개발기 (1) 기획·컨셉 · (2) 구현기 · (3) 출시

출처: COSMO 앱 실제 코드(2026년 9월 13일 기준 dart 87개). 백엔드 주소와 SDK 키는 글에서 제거했어요.

Comments

3 responses to “클로드코드(Claude Code)로 Flutter 앱 만들기 (1) 2026: 폴더 구조·Riverpod 상태관리·go_router 인증 게이트·Dio 클라이언트, COSMO 실제 코드”

Leave a Reply

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