Flutter 프로젝트에서 Clean Architecture를 적용할 때마다 반복되는 작업이 있습니다. Feature를 추가할 때마다 data, domain, presentation 폴더를 만들고, DataSource, Repository, UseCase, State, Notifier 파일들을 일일이 생성해야 합니다.
이런 반복 작업을 Claude Code의 Custom Command로 자동화하면, 단 몇 초 만에 완벽한 Clean Architecture 구조를 생성할 수 있습니다.
/create-feature coupon
이 한 줄로 아래 구조가 자동 생성됩니다:
lib/feature/coupon/
├── data/
│ ├── datasource/coupon_remote_datasource.dart
│ ├── model/coupon_model.dart
│ ├── repository/coupon_repository_impl.dart
│ └── data.dart
├── domain/
│ ├── entity/coupon_entity.dart
│ ├── repository/coupon_repository.dart
│ ├── usecase/usecases.dart
│ ├── failures/coupon_failure.dart
│ └── domain.dart
├── presentation/
│ ├── page/pages.dart
│ ├── view/view.dart
│ ├── widget/widget.dart
│ ├── provider/provider.dart
│ └── presentation.dart
└── di/coupon_providers.dart
Claude Code에서 .claude/commands/ 디렉토리에 마크다운 파일을 생성하면, 해당 파일명이 슬래시 커맨드로 등록됩니다.
.claude/commands/
├── create-feature.md → /create-feature
├── create-usecase.md → /create-usecase
├── create-state.md → /create-state
└── create-notifier.md → /create-notifier
| 요소 | 설명 |
|---|---|
$ARGUMENTS | 사용자가 입력한 인자값 |
{Name} | PascalCase 플레이스홀더 |
{name} | snake_case 플레이스홀더 |
| 코드 블록 | 생성할 코드 템플릿 |
/create-feature - Feature 스켈레톤 생성파일 위치: .claude/commands/create-feature.md
# Feature 스켈레톤 생성
새로운 feature의 기본 구조를 생성합니다.
## 입력
- $ARGUMENTS: feature 이름 (예: order, payment, coupon)
## 생성할 구조
lib/feature/{name}/
├── data/
│ ├── datasource/{name}_remote_datasource.dart
│ ├── model/{name}_model.dart
│ ├── repository/{name}_repository_impl.dart
│ └── data.dart
├── domain/
│ ├── entity/{name}_entity.dart
│ ├── repository/{name}_repository.dart
│ ├── usecase/usecases.dart
│ ├── failures/{name}_failure.dart
│ └── domain.dart
├── presentation/
│ ├── page/pages.dart
│ ├── view/view.dart
│ ├── widget/widget.dart
│ ├── provider/provider.dart
│ └── presentation.dart
└── di/{name}_providers.dart
## 코드 패턴
### Entity (Domain Layer)
\`\`\`dart
class {Name}Entity {
const {Name}Entity({
required this.id,
});
final int id;
}
\`\`\`
### Model (Data Layer)
\`\`\`dart
class {Name}Model {
const {Name}Model({required this.id});
factory {Name}Model.fromJson(Map<String, dynamic> json) {
return {Name}Model(id: json['id'] as int);
}
final int id;
{Name}Entity toEntity() => {Name}Entity(id: id);
}
\`\`\`
실행 예시:
/create-feature coupon
결과:
lib/feature/coupon/domain/entity/coupon_entity.dart:
/// Coupon 엔티티
class CouponEntity {
const CouponEntity({
required this.id,
});
final int id;
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CouponEntity && other.id == id;
}
int get hashCode => id.hashCode;
}
lib/feature/coupon/data/model/coupon_model.dart:
import 'package:gear_freak_flutter/feature/coupon/domain/entity/coupon_entity.dart';
/// Coupon 모델 (API 응답 DTO)
class CouponModel {
const CouponModel({
required this.id,
});
factory CouponModel.fromJson(Map<String, dynamic> json) {
return CouponModel(
id: json['id'] as int,
);
}
final int id;
Map<String, dynamic> toJson() {
return {'id': id};
}
/// Model -> Entity 변환
CouponEntity toEntity() {
return CouponEntity(id: id);
}
}
/create-usecase - UseCase 생성파일 위치: .claude/commands/create-usecase.md
# UseCase 생성
UseCase 클래스를 생성하고 관련 파일들을 업데이트합니다.
## 입력
- $ARGUMENTS: {featureName} {usecaseName} {returnType} {paramType}
- 예: coupon get_coupons "List<CouponEntity>" void
## 생성할 파일
`lib/feature/{featureName}/domain/usecase/{usecaseName}_usecase.dart`
## 코드 패턴
\`\`\`dart
import 'package:dartz/dartz.dart';
class {UseCaseName}UseCase {
const {UseCaseName}UseCase(this._repository);
final {Feature}Repository _repository;
Future<Either<Failure, {ReturnType}>> call() async {
return _repository.{methodName}();
}
}
\`\`\`
## 추가 작업 (자동으로 수행)
1. Repository interface에 메서드 추가
2. Repository impl에 메서드 구현
3. DataSource에 메서드 추가
4. DI Provider에 UseCase Provider 추가
5. Barrel 파일 업데이트
실행 예시:
/create-usecase coupon get_coupons "List<CouponEntity>" void
결과:
lib/feature/coupon/domain/usecase/get_coupons_usecase.dart:
import 'package:dartz/dartz.dart';
import 'package:gear_freak_flutter/feature/coupon/domain/domain.dart';
/// GetCoupons UseCase
class GetCouponsUseCase {
const GetCouponsUseCase(this._repository);
final CouponRepository _repository;
Future<Either<Failure, List<CouponEntity>>> call() async {
return _repository.getCoupons();
}
}
lib/feature/coupon/domain/repository/coupon_repository.dart:
import 'package:dartz/dartz.dart';
import 'package:gear_freak_flutter/feature/coupon/domain/entity/coupon_entity.dart';
import 'package:gear_freak_flutter/shared/domain/failure/failure.dart';
/// Coupon Repository 인터페이스
abstract class CouponRepository {
/// 쿠폰 목록 조회
Future<Either<Failure, List<CouponEntity>>> getCoupons();
}
lib/feature/coupon/data/repository/coupon_repository_impl.dart:
import 'package:dartz/dartz.dart';
import 'package:gear_freak_flutter/feature/coupon/data/datasource/coupon_remote_datasource.dart';
import 'package:gear_freak_flutter/feature/coupon/domain/entity/coupon_entity.dart';
import 'package:gear_freak_flutter/feature/coupon/domain/failures/coupon_failure.dart';
import 'package:gear_freak_flutter/feature/coupon/domain/repository/coupon_repository.dart';
import 'package:gear_freak_flutter/shared/domain/failure/failure.dart';
/// Coupon Repository 구현
class CouponRepositoryImpl implements CouponRepository {
const CouponRepositoryImpl(this._remoteDataSource);
final CouponRemoteDataSource _remoteDataSource;
Future<Either<Failure, List<CouponEntity>>> getCoupons() async {
try {
final models = await _remoteDataSource.getCoupons();
final entities = models.map((model) => model.toEntity()).toList();
return Right(entities);
} on Exception catch (e) {
return Left(CouponFailure('쿠폰 목록을 불러오는데 실패했습니다.', exception: e));
}
}
}
/create-state - State 클래스 생성파일 위치: .claude/commands/create-state.md
# State 클래스 생성
Sealed class 기반 State를 생성합니다.
## 입력
- $ARGUMENTS: {featureName} {stateName} {dataType} {isPaginated?}
- 예: coupon coupon_list "List<CouponEntity>" paginated
## 코드 패턴 (페이지네이션)
\`\`\`dart
sealed class {StateName}State {
const {StateName}State();
}
class {StateName}Initial extends {StateName}State {
const {StateName}Initial();
}
class {StateName}Loading extends {StateName}State {
const {StateName}Loading();
}
class {StateName}Error extends {StateName}State {
const {StateName}Error(this.message);
final String message;
}
class {StateName}Loaded extends {StateName}State {
const {StateName}Loaded({
required this.items,
required this.page,
required this.hasMore,
});
final {DataType} items;
final int page;
final bool hasMore;
}
class {StateName}LoadingMore extends {StateName}State {
const {StateName}LoadingMore({
required this.items,
required this.page,
required this.hasMore,
});
final {DataType} items;
final int page;
final bool hasMore;
}
\`\`\`
실행 예시:
/create-state coupon coupon_list "List<CouponEntity>" paginated
결과:
lib/feature/coupon/presentation/provider/coupon_list_state.dart:
import 'package:gear_freak_flutter/feature/coupon/domain/domain.dart';
/// CouponList 상태 (Sealed Class 방식)
sealed class CouponListState {
const CouponListState();
}
/// 초기 상태
class CouponListInitial extends CouponListState {
const CouponListInitial();
}
/// 로딩 중 상태
class CouponListLoading extends CouponListState {
const CouponListLoading();
}
/// 에러 상태
class CouponListError extends CouponListState {
const CouponListError(this.message);
final String message;
}
/// 로드 성공 상태
class CouponListLoaded extends CouponListState {
const CouponListLoaded({
required this.items,
required this.page,
required this.hasMore,
});
final List<CouponEntity> items;
final int page;
final bool hasMore;
CouponListLoaded copyWith({
List<CouponEntity>? items,
int? page,
bool? hasMore,
}) {
return CouponListLoaded(
items: items ?? this.items,
page: page ?? this.page,
hasMore: hasMore ?? this.hasMore,
);
}
}
/// 추가 로딩 중 상태 (기존 데이터 유지)
class CouponListLoadingMore extends CouponListState {
const CouponListLoadingMore({
required this.items,
required this.page,
required this.hasMore,
});
final List<CouponEntity> items;
final int page;
final bool hasMore;
}
/create-notifier - Notifier 생성파일 위치: .claude/commands/create-notifier.md
# Notifier 생성
StateNotifier를 생성합니다.
## 입력
- $ARGUMENTS: {featureName} {notifierName} {stateName} {usecaseNames...}
- 예: coupon coupon_list coupon_list get_coupons
## 코드 패턴
\`\`\`dart
class {NotifierName}Notifier extends StateNotifier<{StateName}State> {
{NotifierName}Notifier(this._getCouponsUseCase)
: super(const {StateName}Initial());
final GetCouponsUseCase _getCouponsUseCase;
Future<void> fetchCoupons() async {
state = const {StateName}Loading();
final result = await _getCouponsUseCase();
result.fold(
(failure) => state = {StateName}Error(failure.message),
(items) => state = {StateName}Loaded(items: items, page: 1, hasMore: true),
);
}
}
\`\`\`
## 추가 작업
1. DI Provider에 Notifier Provider 추가
2. Barrel 파일 업데이트
실행 예시:
/create-notifier coupon coupon_list coupon_list get_coupons
결과:
lib/feature/coupon/presentation/provider/coupon_list_notifier.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gear_freak_flutter/feature/coupon/domain/usecase/get_coupons_usecase.dart';
import 'package:gear_freak_flutter/feature/coupon/presentation/provider/coupon_list_state.dart';
/// CouponList Notifier
class CouponListNotifier extends StateNotifier<CouponListState> {
CouponListNotifier(this._getCouponsUseCase) : super(const CouponListInitial());
final GetCouponsUseCase _getCouponsUseCase;
static const int _pageSize = 20;
/// 첫 페이지 로드
Future<void> fetchCoupons() async {
state = const CouponListLoading();
final result = await _getCouponsUseCase();
result.fold(
(failure) => state = CouponListError(failure.message),
(items) => state = CouponListLoaded(
items: items,
page: 1,
hasMore: items.length >= _pageSize,
),
);
}
/// 다음 페이지 로드
Future<void> loadMoreCoupons() async {
final currentState = state;
if (currentState is! CouponListLoaded) return;
if (!currentState.hasMore) return;
final nextPage = currentState.page + 1;
state = CouponListLoadingMore(
items: currentState.items,
page: currentState.page,
hasMore: currentState.hasMore,
);
final result = await _getCouponsUseCase();
result.fold(
(failure) => state = CouponListError(failure.message),
(newItems) => state = CouponListLoaded(
items: [...currentState.items, ...newItems],
page: nextPage,
hasMore: newItems.length >= _pageSize,
),
);
}
/// 새로고침
Future<void> refresh() async {
await fetchCoupons();
}
}
lib/feature/coupon/di/coupon_providers.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gear_freak_flutter/core/di/dio_provider.dart';
import 'package:gear_freak_flutter/feature/coupon/data/datasource/coupon_remote_datasource.dart';
import 'package:gear_freak_flutter/feature/coupon/data/repository/coupon_repository_impl.dart';
import 'package:gear_freak_flutter/feature/coupon/domain/repository/coupon_repository.dart';
import 'package:gear_freak_flutter/feature/coupon/domain/usecase/get_coupons_usecase.dart';
import 'package:gear_freak_flutter/feature/coupon/presentation/provider/coupon_list_notifier.dart';
import 'package:gear_freak_flutter/feature/coupon/presentation/provider/coupon_list_state.dart';
/// Coupon Remote DataSource Provider
final couponRemoteDataSourceProvider = Provider<CouponRemoteDataSource>((ref) {
final dio = ref.watch(dioProvider);
return CouponRemoteDataSource(dio);
});
/// Coupon Repository Provider
final couponRepositoryProvider = Provider<CouponRepository>((ref) {
final remoteDataSource = ref.watch(couponRemoteDataSourceProvider);
return CouponRepositoryImpl(remoteDataSource);
});
/// GetCoupons UseCase Provider
final getCouponsUseCaseProvider = Provider<GetCouponsUseCase>((ref) {
final repository = ref.watch(couponRepositoryProvider);
return GetCouponsUseCase(repository);
});
/// CouponList Notifier Provider
final couponListNotifierProvider =
StateNotifierProvider.autoDispose<CouponListNotifier, CouponListState>((ref) {
final getCouponsUseCase = ref.watch(getCouponsUseCaseProvider);
return CouponListNotifier(getCouponsUseCase);
});
새로운 Feature를 추가할 때의 전체 흐름입니다:
# 1. Feature 스켈레톤 생성
/create-feature coupon
# 2. UseCase 생성 (Repository, DataSource 메서드도 자동 추가)
/create-usecase coupon get_coupons "List<CouponEntity>" void
# 3. State 생성
/create-state coupon coupon_list "List<CouponEntity>" paginated
# 4. Notifier 생성 (DI Provider도 자동 추가)
/create-notifier coupon coupon_list coupon_list get_coupons
4개 커맨드 실행 결과:
| 레이어 | 생성된 파일 |
|---|---|
| Data | coupon_remote_datasource.dart, coupon_model.dart, coupon_repository_impl.dart |
| Domain | coupon_entity.dart, coupon_repository.dart, get_coupons_usecase.dart, coupon_failure.dart |
| Presentation | coupon_list_state.dart, coupon_list_notifier.dart |
| DI | coupon_providers.dart |
생성된 코드를 Page에서 사용하는 방법:
class CouponListPage extends ConsumerWidget {
const CouponListPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(couponListNotifierProvider);
return Scaffold(
appBar: AppBar(title: const Text('쿠폰 목록')),
body: switch (state) {
CouponListInitial() || CouponListLoading() =>
const Center(child: CircularProgressIndicator()),
CouponListError(:final message) =>
Center(child: Text(message)),
CouponListLoaded(:final items) || CouponListLoadingMore(:final items) =>
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => CouponCard(coupon: items[index]),
),
},
);
}
}
| 항목 | 설명 |
|---|---|
| 일관성 | 모든 Feature가 동일한 구조와 패턴을 따름 |
| 생산성 | 보일러플레이트 작성 시간 90% 이상 절감 |
| 실수 방지 | 파일 누락, 오타, import 실수 방지 |
| 온보딩 | 신규 팀원도 커맨드만 알면 바로 작업 가능 |
중요: 이 글에서 소개한 커맨드는 제 프로젝트 구조에 맞게 커스텀한 예시입니다.
여러분의 프로젝트에서는 각자의 아키텍처, 상태관리, 패키지 선택에 맞게 수정해서 사용하시면 됩니다!
커스텀 가능한 항목들:
| 항목 | 이 글의 예시 | 다른 선택지 |
|---|---|---|
| HTTP 클라이언트 | Dio | http, Retrofit, Chopper |
| 상태관리 | Riverpod (StateNotifier) | Bloc, GetX, Provider, MobX |
| 에러 처리 | dartz (Either) | fpdart, Result 패턴, try-catch |
| 폴더 구조 | Feature-first | Layer-first |
| DI 방식 | Riverpod Provider | get_it, injectable |
수정 방법:
.claude/commands/ 폴더의 마크다운 파일에서 코드 패턴만 바꿔주면 끝!
예를 들어 Bloc을 사용한다면:
// create-notifier.md의 코드 패턴을 이렇게 변경
class {Name}Bloc extends Bloc<{Name}Event, {Name}State> {
{Name}Bloc(this._useCase) : super(const {Name}Initial()) {
on<Fetch{Name}>(onFetch);
}
// ...
}
핵심은 구조가 아니라 "반복 작업의 자동화"입니다. 각자의 프로젝트 컨벤션에 맞게 템플릿을 만들어두면, 새로운 Feature를 추가할 때마다 일관된 코드를 빠르게 생성할 수 있습니다.