안녕하세요, 우기입니다! Flutter 개발 가이드라인 시리즈의 두 번째 글을 시작합니다. 이전 글에서 소개한 핵심 원칙들을 실제 코드에 어떻게 적용할 수 있는지 구체적인 예시와 함께 살펴보겠습니다.
Flutter 앱을 개발할 때 단순히 작동하는 코드를 넘어 유지보수하기 좋은 코드를 작성하는 것이 중요합니다. 지난 글에서 네 가지 핵심 원칙(가독성, 예측 가능성, 응집성, 낮은 결합도)을 소개했습니다. 이번 글에서는 처음 세 가지 원칙을 실제 코드에 적용하는 방법을 알아봅니다.
이 글을 통해 얻을 수 있는 것:
가독성이 좋은 코드는 다른 개발자가 쉽게 이해하고, 수정하고, 확장할 수 있습니다. 다음은 Flutter에서 가독성을 높이는 주요 방법들입니다.
매직 넘버(코드 내 직접 사용된 숫자)와 문자열 리터럴을 명명된 상수로 대체해 코드의 의미를 명확히 합니다.
// ❌ 잘못된 방법
Future<void> refreshData() async {
await Future.delayed(const Duration(milliseconds: 300));
await fetchItems();
}
// ✅ 좋은 방법
const Duration kRefreshDebounce = Duration(milliseconds: 300);
Future<void> refreshData() async {
await Future.delayed(kRefreshDebounce);
await fetchItems();
}
이 방식은 코드의 의도를 명확히 전달하고, 값 변경 시 한 곳만 수정하면 됩니다.
복잡한 위젯을 작고 집중된 컴포넌트로 분할합니다.
// ❌ 잘못된 방법: 여러 책임을 가진 하나의 큰 위젯
class ProductDetailScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('상품 상세')),
body: ListView(
children: [
// 50줄 이상의 이미지 갤러리 코드...
// 40줄 이상의 상품 정보 카드 코드...
// 60줄 이상의 리뷰 섹션 코드...
// 40줄 이상의 관련 상품 코드...
],
),
);
}
}
// ✅ 좋은 방법: 작고 집중된 위젯으로 분리
class ProductDetailScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('상품 상세')),
body: ListView(
children: [
ProductImageGallery(images: product.images),
ProductInfoCard(product: product),
ProductReviewsSection(productId: product.id),
RelatedProductsList(category: product.category),
],
),
);
}
}
이렇게 분리하면:
복잡한 조건문 대신 UI 상태별로 별도의 위젯을 만듭니다.
// ❌ 잘못된 방법: 단일 위젯 내 복잡한 조건문
class OrderStatus extends StatelessWidget {
final Order order;
Widget build(BuildContext context) {
if (order.status == OrderStatus.processing) {
return Row(children: [
CircularProgressIndicator(),
Text('주문 처리 중...'),
]);
} else if (order.status == OrderStatus.shipped) {
return Row(children: [
Icon(Icons.local_shipping),
Text('${order.shippedDate}에 배송됨'),
]);
} else if (order.status == OrderStatus.delivered) {
return Row(children: [
Icon(Icons.check_circle),
Text('${order.deliveryDate}에 배달됨'),
]);
} else {
return Text('알 수 없는 상태');
}
}
}
// ✅ 좋은 방법: 특화된 위젯에 위임
class OrderStatus extends StatelessWidget {
final Order order;
Widget build(BuildContext context) {
return switch (order.status) {
OrderStatus.processing => ProcessingOrderStatus(order),
OrderStatus.shipped => ShippedOrderStatus(order),
OrderStatus.delivered => DeliveredOrderStatus(order),
_ => UnknownOrderStatus(),
};
}
}
// 각 상태별 전용 위젯 구현
class ProcessingOrderStatus extends StatelessWidget {
final Order order;
const ProcessingOrderStatus(this.order);
Widget build(BuildContext context) {
return Row(children: [
CircularProgressIndicator(),
const SizedBox(width: 8),
Text('주문 처리 중...'),
]);
}
}
Dart 3.0+ 버전의 switch 표현식을 활용하면 조건부 UI 렌더링이 더 간결해집니다.
복잡한 불리언 조건에 의미 있는 이름을 지정합니다.
// ❌ 잘못된 방법
if (user.subscription != null &&
user.subscription!.isActive &&
DateTime.now().isBefore(user.subscription!.expiryDate) &&
user.subscription!.plan == SubscriptionPlan.premium) {
showPremiumFeatures();
}
// ✅ 좋은 방법
bool get isPremiumActive {
if (user.subscription == null) return false;
final subscription = user.subscription!;
final isActive = subscription.isActive;
final isNotExpired = DateTime.now().isBefore(subscription.expiryDate);
final isPremiumPlan = subscription.plan == SubscriptionPlan.premium;
return isActive && isNotExpired && isPremiumPlan;
}
// 사용
if (isPremiumActive) {
showPremiumFeatures();
}
이 방식은 코드의 의도를 명확히 전달하며, 조건 논리를 한 곳에서 관리해 수정이 필요할 때 유지보수가 쉬워집니다.
예측 가능한 코드는 개발자가 코드의 동작을 쉽게 예측할 수 있게 합니다. 이는 버그가 적고 유지보수가 쉬운 코드의 핵심 특성입니다.
유사한 함수와 메서드에 일관된 반환 타입을 사용합니다.
// ❌ 잘못된 방법: 일관성 없는 반환 타입
Future<User?> getUser() async {
final response = await api.getUser();
return response.isSuccess ? User.fromJson(response.data) : null;
}
Future<List<Product>> getProducts() async {
final response = await api.getProducts();
if (!response.isSuccess) throw Exception('상품 로드 실패');
return response.data.map((json) => Product.fromJson(json)).toList();
}
// ✅ 좋은 방법: Result 패턴을 사용한 일관된 반환 타입
Future<Result<User>> getUser() async {
try {
final response = await api.getUser();
if (!response.isSuccess) {
return Result.failure(ApiError(response.errorMessage));
}
return Result.success(User.fromJson(response.data));
} catch (e) {
return Result.failure(UnexpectedError(e.toString()));
}
}
Future<Result<List<Product>>> getProducts() async {
try {
final response = await api.getProducts();
if (!response.isSuccess) {
return Result.failure(ApiError(response.errorMessage));
}
final products = response.data
.map((json) => Product.fromJson(json))
.toList();
return Result.success(products);
} catch (e) {
return Result.failure(UnexpectedError(e.toString()));
}
}
// Result 클래스 구현
class Result<T> {
final T? data;
final ErrorEntity? error;
final bool isSuccess;
Result._({this.data, this.error, required this.isSuccess});
factory Result.success(T data) => Result._(data: data, isSuccess: true);
factory Result.failure(ErrorEntity error) => Result._(error: error, isSuccess: false);
}
이 패턴은 호출자가 성공과 실패를 일관되게 처리할 수 있게 합니다.
함수는 목적과 동작을 명확히 나타내는 이름으로 지정합니다.
// ❌ 잘못된 방법: 모호한 이름
Future<void> process() async { /* ... */ }
void handle(User user) { /* ... */ }
// ✅ 좋은 방법: 명확하고 구체적인 이름
Future<void> processPayment(Order order) async { /* ... */ }
void handleUserRegistration(User user) { /* ... */ }
함수 이름은 동사로 시작하여 그 함수의 주요 작업과 대상을 명확히 합니다.
함수는 단일 책임을 갖고 숨겨진 부작용을 피해야 합니다.
// ❌ 잘못된 방법: 숨겨진 부작용이 있는 함수
Future<User> getUser() async {
final user = await api.fetchUser();
analytics.logEvent('user_fetched'); // 숨겨진 부작용
cache.save('user', user); // 숨겨진 부작용
return user;
}
// ✅ 좋은 방법: 각 책임에 대한 명시적 함수
Future<User> fetchUser() async {
return await api.fetchUser();
}
Future<void> logUserFetch() async {
analytics.logEvent('user_fetched');
}
Future<void> cacheUser(User user) async {
await cache.save('user', user);
}
// 사용 시 모든 작업을 명시적으로 표시
Future<User> getUserWithLoggingAndCaching() async {
final user = await fetchUser();
await Future.wait([
logUserFetch(),
cacheUser(user),
]);
return user;
}
이 방식은 각 함수의 목적을 명확히 하고, 테스트와 재사용성을 개선합니다.
응집성이 높은 코드는 관련된 기능이 함께 모여 있어 유지보수와 이해가 쉽습니다. Flutter에서 응집성을 높이는 방법을 살펴보겠습니다.
폼의 복잡도에 따라 적절한 유효성 검사 접근법을 선택합니다.
간단한 폼을 위한 필드 수준 유효성 검사:
class SimpleForm extends StatefulWidget {
_SimpleFormState createState() => _SimpleFormState();
}
class _SimpleFormState extends State<SimpleForm> {
final _formKey = GlobalKey<FormState>();
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
decoration: InputDecoration(labelText: '이메일'),
validator: (value) {
if (value == null || value.isEmpty) {
return '이메일을 입력해주세요';
}
if (!value.contains('@')) {
return '유효한 이메일을 입력해주세요';
}
return null;
},
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// 폼 제출
}
},
child: Text('제출'),
),
],
),
);
}
}
복잡한 폼을 위한 폼 수준 유효성 검사 (formz 패키지 사용):
// formz 패키지 사용 예시
class PasswordInput extends FormzInput<String, PasswordValidationError> {
const PasswordInput.pure() : super.pure('');
const PasswordInput.dirty([String value = '']) : super.dirty(value);
PasswordValidationError? validator(String value) {
if (value.isEmpty) return PasswordValidationError.empty;
if (value.length < 8) return PasswordValidationError.tooShort;
return null;
}
}
class ConfirmPasswordInput extends FormzInput<String, ConfirmPasswordValidationError> {
const ConfirmPasswordInput.pure({this.password = ''}) : super.pure('');
const ConfirmPasswordInput.dirty({
required this.password,
String value = '',
}) : super.dirty(value);
final String password;
ConfirmPasswordValidationError? validator(String value) {
if (value.isEmpty) return ConfirmPasswordValidationError.empty;
if (value != password) return ConfirmPasswordValidationError.mismatch;
return null;
}
}
상호 의존적인 유효성 검사가 필요하거나 복잡한 비즈니스 로직이 포함된 폼은 폼 수준 유효성 검사가 적합합니다.
상수는 관련된 로직과 가까운 곳에 정의합니다.
// ❌ 잘못된 방법: 관련 로직과 멀리 떨어진 상수
class Constants {
static const Duration animationDuration = Duration(milliseconds: 300);
static const Duration tooltipDelay = Duration(milliseconds: 500);
static const Duration refreshInterval = Duration(minutes: 5);
}
// 코드베이스 훨씬 나중에...
class AnimatedButton extends StatelessWidget {
Widget build(BuildContext context) {
return AnimatedContainer(
duration: Constants.animationDuration, // 관계 추적이 어려움
// ...
);
}
}
// ✅ 좋은 방법: 관련 로직 근처에 상수 정의
class AnimatedButton extends StatelessWidget {
// 이 애니메이션과 명확히 연관된 지속 시간
static const Duration _animationDuration = Duration(milliseconds: 300);
Widget build(BuildContext context) {
return AnimatedContainer(
duration: _animationDuration,
// ...
);
}
}
여러 클래스에서 공유되는 상수는 관련 기능을 담당하는 클래스나 파일에 정의합니다:
// animation_constants.dart
class AnimationConstants {
static const Duration defaultDuration = Duration(milliseconds: 300);
static const Duration longDuration = Duration(milliseconds: 500);
static const Curve defaultCurve = Curves.easeInOut;
}
이론을 아는 것과 실제 프로젝트에 적용하는 것은 다릅니다. 다음은 이 원칙들을 실제로 적용하는 데 도움이 될 팁들입니다.
기존 프로젝트를 한 번에 모두 리팩토링하지 마세요. 대신:
코드 리뷰에서 다음 질문을 고려합니다:
원칙을 문서화하고 팀 내에서 합의를 형성합니다:
가독성, 예측 가능성, 응집성은 고품질 Flutter 코드의 핵심 원칙입니다. 이러한 원칙들을 일상적인 개발 작업에 적용하면:
다음 글에서는 나머지 핵심 원칙인 '낮은 결합도'와 다양한 상태 관리 접근법에 대해 알아보겠습니다.
다음 포스트: Flutter 개발 가이드라인: 결합도 및 상태 관리
다양한 상태 관리 솔루션(BLoC, GetX, Riverpod)을 비교하고, 코드 결합도를 낮추는 효과적인 방법을 알아봅니다.