Flutter 개발 가이드라인: 가독성, 예측 가능성, 응집성 📖

woogi·2025년 4월 29일
0

Flutter 개발 가이드

목록 보기
3/5
post-thumbnail

안녕하세요, 우기입니다! Flutter 개발 가이드라인 시리즈의 두 번째 글을 시작합니다. 이전 글에서 소개한 핵심 원칙들을 실제 코드에 어떻게 적용할 수 있는지 구체적인 예시와 함께 살펴보겠습니다.

개요

Flutter 앱을 개발할 때 단순히 작동하는 코드를 넘어 유지보수하기 좋은 코드를 작성하는 것이 중요합니다. 지난 글에서 네 가지 핵심 원칙(가독성, 예측 가능성, 응집성, 낮은 결합도)을 소개했습니다. 이번 글에서는 처음 세 가지 원칙을 실제 코드에 적용하는 방법을 알아봅니다.

이 글을 통해 얻을 수 있는 것:

  • 가독성 높은 Flutter 코드 작성법
  • 예측 가능한 함수와 API 설계 방법
  • 응집성이 높은 컴포넌트 구조화 기법

가독성 (Readability) 👓

가독성이 좋은 코드는 다른 개발자가 쉽게 이해하고, 수정하고, 확장할 수 있습니다. 다음은 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)을 비교하고, 코드 결합도를 낮추는 효과적인 방법을 알아봅니다.

profile
안녕하세요! 👋 저는 6년차 Flutter 개발자 우기입니다.

0개의 댓글