최종 업데이트: 2026-09-16 (앱인토스 미니앱 관련 정정)
![]()
코스메틱 리뷰 커뮤니티 앱 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

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 로 올린다
- core 는 기능을 모르는 층이에요. 네트워크, 라우터, 테마, 저장소, 공통 유틸. 어떤 feature 도 import 하지 않아요.
- features/이름/ 안에 data(모델)와 presentation(화면 + 뷰모델)만 둬요. 13개 폴더가 전부 같은 모양이에요.
- feature 끼리는 import 금지예요. 홈에서 달글 카드가 필요하면 shared/widgets 로 올려요.
이 규칙이 중요한 이유는 클로드코드에 “검색 기능 추가해줘”라고 했을 때 features/search 폴더만 새로 만들고 core 는 건드리지 않는 결과가 나오기 때문이에요. 규칙이 없으면 매번 다른 자리에 파일이 생기고 두 달 뒤엔 사람도 못 찾아요.
화면에서 서버까지 몇 층을 거치나요?

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

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 인증 게이트는 어떻게 가입 흐름을 지키나요?

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 네트워크 클라이언트는 어떻게 구성했나요?

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 → 모델 → 화면
- 규칙 문서(CLAUDE.md) 먼저: 폴더 규칙 세 개, 스택 표, 이슈 번호 규칙을 적어두고 시작했어요. 클로드코드는 매 세션 이 파일을 읽어요.
- core 부터: 테마, 라우터 뼈대, ApiClient, TokenManager. 화면 하나 없이 앱이 빌드되는 상태를 먼저 만들어요.
- 모델 다음: 서버 응답 JSON 을 주고 features/*/data/models 를 만들게 해요. build_runner 가 .g.dart 를 생성해요.
- 화면은 마지막: “홈 화면, 홈 뷰모델과 같은 구조로”처럼 기존 것을 가리키며 시켜요.
순서를 거꾸로 하면(화면부터) 클로드코드가 화면 안에 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) 출시
- flutter_riverpod / go_router / dio (pub.dev 공식 패키지 문서)
- Riverpod 공식 문서 (Notifier 패턴)
- Claude Code 공식 문서: CLAUDE.md 메모리 파일
출처: COSMO 앱 실제 코드(2026년 9월 13일 기준 dart 87개). 백엔드 주소와 SDK 키는 글에서 제거했어요.

Leave a Reply