협업과 유지보수, 두 마리 토끼를 잡는 Flutter UI 클린 코드

Ximya·2023년 3월 12일
4

Plotz 개발일지

목록 보기
1/12
post-thumbnail

해당 포스팅은 유튜브 영화&드라마 리뷰 영상 큐레이션 플랫폼 Plotz를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.
다운로드 링크 : 앱스토어 / 플레이스토어

서론

제 깃헙 레포에는 야심 차게 시작했지만, 개발 도중에 어영부영 끝난 프로젝트가 몇 개 있습니다.
프로젝트가 도중에 중단된 이유야 제 게으림이 가장 크겠지만, 또 다른 이유는 클린하지 못한 코드 때문이 이였다고 봅니다.

저의 사례를 한번 소개해보죠.

작년에 처음 모바일 개발자로 회사에 입사하고 너무 일이 많고 정신이 없어서 잠시 진행 중이던 개인 프로젝트에 소홀했던 적이 있습니다.

회사에 조금 적응하고 약 두 달 만에 프로젝트 코드를 다시 열었는데 도저히 작업 이어갈 수 있는 수준이 아녔습니다.

모든 복잡한 비즈니스 로직들은 View Model 레이에서만 관리되고 있었고,
무분별한 StatefullWidget 범벅에
알아보기 힘들고 엄청난 긴 스파게티 UI 코드로 구성되어 있었죠.

협업과 유지보수가 용이한 친절한 코드를 작성하는 게 중요하다!라는 이야기를 줄 곳 들어왔지만,
두 달 만에 기억이 리셋된 제가 바라본 저의 코드는 전혀 친절하지 않았습니다.

기존 코드를 뜯어고치려고 노력했지만, 프로젝트를 뒤엎고 다시 시작하는 게 빠르다는 판단 했고 새로운 프로젝트를 만들었을 때 클린한 UI 코드를 작성하는 데 집중했습니다.

그래서 본 글에서는 제가 어떻게 클린한 UI 코드를 작성하려고 했는지에 대한 고민과 방법론을 소개 하려고 합니다.

UI코드의 구조화 (Refactoring)

사실 클린 UI 코드를 작성하는 방법이 정형화되어 있지는 않습니다.
하지만 기본적으로 중복 코드를 줄여서 위젯의 재사용성을 높이거나
Statless Widget을 사용해서 불필요한 리렌더링을 방지 등의 내용이 권장됩니다.

이런 기본적인 개념이 전제되어 있다고 가정하고,
어떻게 스크린 영역에 해당하는 UI 코드를 구조화하여 작성하는지에 대해 다루어보려고 합니다.

서론이 길었습니다.
그럼 이제 어떤 UI 코드를 구조화하여 리팩토링 하는 방법을 살펴보죠.

import 'dart:ui';

import 'package:soon_sak/utilities/index.dart';

class HomeScreen extends BaseScreen<HomeViewModel> {
  const HomeScreen({Key? key}) : super(key: key);

  
  Widget buildScreen(BuildContext context) {
    return Stack(
      children: [
        SingleChildScrollView(
          controller: vm.scrollController,
          child: Stack(
            children: <Widget>[
              Stack(
                children: <Widget>[
                  Obx(() {
                    if (vm.isBannerContentsLoaded) {
                      return CachedNetworkImage(
                        width: double.infinity,
                        fit: BoxFit.fitWidth,
                        imageUrl: vm.selectedTopExposedContent!.backdropImgUrl
                            .prefixTmdbImgPath,
                        errorWidget: (context, url, error) =>
                            const Icon(Icons.error),
                      );
                    } else {
                      return const SizedBox();
                    }
                  }),
                  Positioned.fill(
                    child: Container(
                      decoration: const BoxDecoration(
                        gradient: LinearGradient(
                          colors: [
                            Colors.black,
                            Colors.transparent,
                            AppColor.black
                          ],
                          begin: Alignment.topCenter,
                          end: Alignment.bottomCenter,
                          stops: <double>[0.0, 0.5, 1.0],
                        ),
                      ),
                    ),
                  )
                ],
              ),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  SizedBox(height: vm.appBarHeight), // 커스텀 앱바와 간격을 맞추기 위한 위젯
                  AppSpace.size72,
                  Obx(
                    () => CarouselSlider.builder(
                      carouselController: vm.carouselController,
                      itemCount: vm.bannerContentList?.length ?? 2,
                      options: CarouselOptions(
                        autoPlay: true,
                        onPageChanged: (index, _) {
                          vm.onBannerSliderSwiped(index);
                        },
                        viewportFraction: 0.93,
                        aspectRatio: 337 / 276,
                      ),
                      itemBuilder: (BuildContext context, int itemIndex,
                          int pageViewIndex) {
                        if (vm.isBannerContentsLoaded) {
                          final BannerItem item =
                              vm.bannerContentList![itemIndex];
                          return BannerItemView(
                            title: item.title,
                            description: item.description,
                            imgUrl: item.imgUrl,
                            onItemTapped: () {
                              final argument = ContentArgumentFormat(
                                contentId: item.id,
                                contentType: item.type,
                                posterImgUrl: item.backdropImgUrl,
                                thumbnailUrl: item.imgUrl,
                                videoId: item.videoId,
                                videoTitle: item.title,
                                originId: item.originId,
                              );
                              vm.routeToContentDetail(argument,
                                  sectionType: 'banner');
                            },
                          );
                        } else {
                          return const BannerSkeletonItem();
                        }
                      },
                    ),
                  ),
                  AppSpace.size40,
                  Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 16),
                    child: Text(
                      '순삭 Top10',
                      style: AppTextStyle.headline2,
                    ),
                  ),
                  AppSpace.size6,
                  Obx(
                    () => ContentPostSlider(
                      height: 200,
                      itemCount: vm.topTenContents?.contentList?.length ?? 5,
                      itemBuilder: (context, index) {
                        if (vm.isTopTenContentsLoaded) {
                          final item = vm.topTenContents!.contentList![index];
                          return GestureDetector(
                            onTap: () {
                              final argument = ContentArgumentFormat(
                                contentId: item.contentId,
                                contentType: item.contentType,
                                posterImgUrl: item.posterImgUrl,
                                originId: item.originId,
                              );
                              vm.routeToContentDetail(argument,
                                  sectionType: 'topTen');
                            },
                            child: ContentPostItem(
                              imgUrl: vm.topTenContents!.contentList![index]
                                  .posterImgUrl.prefixTmdbImgPath,
                            ),
                          );
                        } else {
                          return const ContentPostItem(imgUrl: null);
                        }
                      },
                    ),
                  ),
                  Obx(
                    () => ListView.separated(
                      physics: const NeverScrollableScrollPhysics(),
                      shrinkWrap: true,
                      itemCount:
                          vm.categoryContentCollection?.items.length ?? 4,
                      separatorBuilder: (__, _) => AppSpace.size26,
                      itemBuilder: (context, index) {
                        if (vm.categoryContentCollection.hasData) {
                          final item =
                              vm.categoryContentCollection!.items[index];
                          return CategoryContentSectionView(
                            contentSectionData: item,
                            onContentTapped: (nestedIndex) {
                              final argument = ContentArgumentFormat(
                                contentId: item.contents[nestedIndex].id,
                                contentType:
                                    item.contents[nestedIndex].contentType,
                                posterImgUrl:
                                    item.contents[nestedIndex].posterImgUrl,
                                originId: item.contents[nestedIndex].originId,
                              );
                              vm.routeToContentDetail(argument,
                                  sectionType: 'category');
                            },
                          );
                        } else {
                          return const CategoryContentSectionSkeletonView();
                        }
                      },
                    ),
                  ),
                  AppSpace.size72,
                  AppSpace.size72,
                ],
              ),
            ],
          ),
        ),
        Obx(
          () => AnimatedOpacity(
            opacity: vm.showBlurAtAppBar.value ? 1 : 0,
            duration: const Duration(milliseconds: 300),
            child: ClipRRect(
              child: BackdropFilter(
                filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
                child: Container(
                  height: vm.appBarHeight,
                  color: Colors.transparent,
                ),
              ),
            ),
          ),
        ),
        Container(
          padding: EdgeInsets.only(top: SizeConfig.to.statusBarHeight) +
              const EdgeInsets.symmetric(horizontal: 16),
          color: Colors.transparent,
          height: vm.appBarHeight,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: <Widget>[
              const SizedBox(),
              IconInkWellButton.assetIcon(
                iconPath: 'assets/icons/search.svg',
                size: 40,
                onIconTapped: vm.routeToSearch,
              )
            ],
          ),
        )
      ],
    );
  }
}    

리팩토링 하기 전 코드 입니다.

일부 위젯을 모듈로 분리했는데도 그렇게 가독성이 좋아 보이진 않네요.
이렇게 긴 스파게티 코드는 유지 보수성을 떨어트립니다.
추후에 기능이 추가되거나 오류를 수정할 때 기존 코드를 분석하느라 시간이 몇 배는 더 걸리겠죠.

1. 섹션 정의

먼저 섹션을 구분하는게 좋습니다.
저는 홈 화면을 아래와 같이 구분했습니다.

  • 슬라이드 배너
  • 배경 이미지
  • 앱바
  • Top Ten 컨텐츠 리스트
  • 카테고리 컨텐츠 컬렉션 리스트

NOTE : 각 섹션은 보통 하나의 주제나 내용으로 구분됩니다.


2. Scaffold 모듈 생성

섹션을 구분한 뒤, home_scaffold.dart 파일을 생성하고
아래와 같이 구성된 Statless 코드를 추가 합니다.

import 'package:soon_sak/utilities/index.dart';

class HomeScaffold extends StatelessWidget {
  const HomeScaffold({
    Key? key,
    required this.animationAppbar,
    required this.scrollController,
    required this.appBarHeight,
    required this.stackedGradientPosterBg,
    required this.topBannerSlider,
    required this.topTenContentSlider,
    required this.categoryContentCollectionList,
  }) : super(key: key);

  final List<Widget> animationAppbar;
  final List<Widget> stackedGradientPosterBg;
  final Widget topBannerSlider;
  final List<Widget> topTenContentSlider;
  final List<Widget> categoryContentCollectionList;
  final ScrollController scrollController;
  final double appBarHeight;

  
  Widget build(BuildContext context) {
    return Stack(
      children: [
        SingleChildScrollView(
          controller: scrollController,
          child: Stack(
            children: <Widget>[
              Stack(
                children: <Widget>[
                  ...stackedGradientPosterBg, // 배경 포스터 이미지
                ],
              ),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  SizedBox(height: appBarHeight), // 커스텀 앱바와 간격을 맞추기 위한 위젯
                  AppSpace.size72,
                  topBannerSlider, // 상단 대표 컨텐츠 슬라이더
                  ...topTenContentSlider, // Top10 컨텐츠 슬라이더
                  ...categoryContentCollectionList, // 카테고리 컨텐츠 리스트
                  AppSpace.size72,
                ],
              ),
            ],
          ),
        ),
        ...animationAppbar, // 애니메이션 앱바
      ],
    );
  }
}

HomeScaffold는 홈 화면 레이아웃을 구성하는 Statless 클래스 입니다.
사전에 정의한 섹션들을 위젯 프로퍼티로 받고 레이아웃을 구성하는 형태이죠.
그리고 이 Scaffold 모듈은 HomeScreen에 사용됩니다.

3. Scaffold 모듈 적용 & 위젯 매소드 추가

Scaffold 모듈을 구현했으면 이제 HomeScreen에 해당 Scaffold 모듈을 선언합니다.

import 'dart:ui';
import 'package:soon_sak/utilities/index.dart';

class HomeScreen extends BaseScreen<HomeViewModel> {
  const HomeScreen({Key? key}) : super(key: key);

  
  bool get wrapWithSafeArea => false;

  
  Widget buildScreen(BuildContext context) {
    return HomeScaffold(
      scrollController: vm.scrollController,
      animationAppbar: _buildAnimationAppbar(),
      stackedGradientPosterBg: _buildStackedGradientPosterBg(),
      topBannerSlider: _buildTopBannerSlider(),
      topTenContentSlider: _buildTopTenContentSlider(),
      categoryContentCollectionList: _buildCategoryContentCollectionList(),
      appBarHeight: vm.appBarHeight,
    );
  }

  /// 카테고리 리스트 - 각 리스트 안에 포스트 슬라이더 위젯이 구성되어 있음.
  List<Widget> _buildCategoryContentCollectionList() => [
        Obx(
          () => ListView.separated(
            physics: const NeverScrollableScrollPhysics(),
            shrinkWrap: true,
            itemCount: vm.categoryContentCollection?.items.length ?? 4,
            separatorBuilder: (__, _) => AppSpace.size26,
            itemBuilder: (context, index) {
              if (vm.categoryContentCollection.hasData) {
                final item = vm.categoryContentCollection!.items[index];
                return CategoryContentSectionView(
                  contentSectionData: item,
                  onContentTapped: (nestedIndex) {
                    final argument = ContentArgumentFormat(
                      contentId: item.contents[nestedIndex].id,
                      contentType: item.contents[nestedIndex].contentType,
                      posterImgUrl: item.contents[nestedIndex].posterImgUrl,
                      originId: item.contents[nestedIndex].originId,
                    );
                    vm.routeToContentDetail(argument, sectionType: 'category');
                  },
                );
              } else {
                return const CategoryContentSectionSkeletonView();
              }
            },
          ),
        ),
        AppSpace.size72,
      ];
  
  /// 상단 'Top10' 포스트 슬라이더
  List<Widget> _buildTopTenContentSlider() => [
        AppSpace.size40,
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Text(
            '순삭 Top10',
            style: AppTextStyle.headline2,
          ),
        ),
        AppSpace.size6,
        Obx(
          () => ContentPostSlider(
            height: 200,
            itemCount: vm.topTenContents?.contentList?.length ?? 5,
            itemBuilder: (context, index) {
              if (vm.isTopTenContentsLoaded) {
                final item = vm.topTenContents!.contentList![index];
                return GestureDetector(
                  onTap: () {
                    final argument = ContentArgumentFormat(
                      contentId: item.contentId,
                      contentType: item.contentType,
                      posterImgUrl: item.posterImgUrl,
                      originId: item.originId,
                    );
                    vm.routeToContentDetail(argument, sectionType: 'topTen');
                  },
                  child: ContentPostItem(
                    imgUrl: vm.topTenContents!.contentList![index].posterImgUrl
                        .prefixTmdbImgPath,
                  ),
                );
              } else {
                return const ContentPostItem(imgUrl: null);
              }
            },
          ),
        ),
      ];

  /// 상단 배너 슬라이더
  Widget _buildTopBannerSlider() => Obx(
        () => CarouselSlider.builder(
          carouselController: vm.carouselController,
          itemCount: vm.bannerContentList?.length ?? 2,
          options: CarouselOptions(
            autoPlay: true,
            onPageChanged: (index, _) {
              vm.onBannerSliderSwiped(index);
            },
            viewportFraction: 0.93,
            aspectRatio: 337 / 276,
          ),
          itemBuilder:
              (BuildContext context, int itemIndex, int pageViewIndex) {
            if (vm.isBannerContentsLoaded) {
              final BannerItem item = vm.bannerContentList![itemIndex];
              return BannerItemView(
                title: item.title,
                description: item.description,
                imgUrl: item.imgUrl,
                onItemTapped: () {
                  final argument = ContentArgumentFormat(
                    contentId: item.id,
                    contentType: item.type,
                    posterImgUrl: item.backdropImgUrl,
                    thumbnailUrl: item.imgUrl,
                    videoId: item.videoId,
                    videoTitle: item.title,
                    originId: item.originId,
                  );
                  vm.routeToContentDetail(argument, sectionType: 'banner');
                },
              );
            } else {
              return const BannerSkeletonItem();
            }
          },
        ),
      );

  /// 배경 위젯 - Poster + Gradient Image 로 구성됨.
  List<Widget> _buildStackedGradientPosterBg() => [
        Obx(() {
          if (vm.isBannerContentsLoaded) {
            return CachedNetworkImage(
              width: double.infinity,
              fit: BoxFit.fitWidth,
              imageUrl: vm
                  .selectedTopExposedContent!.backdropImgUrl.prefixTmdbImgPath,
              errorWidget: (context, url, error) => const Icon(Icons.error),
            );
          } else {
            return const SizedBox();
          }
        }),
        // Graident 레이어
        Positioned.fill(
          child: Container(
            decoration: const BoxDecoration(
              gradient: LinearGradient(
                colors: [Colors.black, Colors.transparent, AppColor.black],
                begin: Alignment.topCenter,
                end: Alignment.bottomCenter,
                stops: <double>[0.0, 0.5, 1.0],
              ),
            ),
          ),
        )
      ];



  /// 애니메이션 앱바 - 스크롤 동작 및 offset에 따라 blur animation이 적용됨.
  List<Widget> _buildAnimationAppbar() {
    return [
      Obx(
        () => AnimatedOpacity(
          opacity: vm.showBlurAtAppBar.value ? 1 : 0,
          duration: const Duration(milliseconds: 300),
          child: ClipRRect(
            child: BackdropFilter(
              filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
              child: Container(
                height: vm.appBarHeight,
                color: Colors.transparent,
              ),
            ),
          ),
        ),
      ),
      Container(
        padding: EdgeInsets.only(top: SizeConfig.to.statusBarHeight) +
            const EdgeInsets.symmetric(horizontal: 16),
        color: Colors.transparent,
        height: vm.appBarHeight,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: <Widget>[
            const SizedBox(),
            IconInkWellButton.assetIcon(
              iconPath: 'assets/icons/search.svg',
              size: 40,
              onIconTapped: vm.routeToSearch,
            )
          ],
        ),
      )
    ];
  }
}

여기서 중요한건 HomeScaffold 프로퍼티 전달되는 위젯은 가독성을 고려해 메소드 & 클래스 형태로 extract 되어야 한다는 점 입니다.

현재 코드는 HomeScaffold 프로퍼티를 _build... 메소드 형태로 전달하는 형식으로 구성이 되어 있습니다.


클린 UI 코드의 이점

이렇게 구조화된 코드는 크게 두 가지 이점을 가집니다.

유지보수 용이

일단 유지보수에 압도적으로 용이합니다.
예를 들어 '홈 스크린에 'Top10 카테고리'섹션 UI부분을 수정해주세요.'
라는 요청이 들어왔을 때 작업자는 홈 스크린에서 HomeScaffold 부분만 파악하면 됩니다.

  
  Widget buildScreen(BuildContext context) {
    return HomeScaffold(
      scrollController: vm.scrollController,
      animationAppbar: _buildAnimationAppbar(),
      stackedGradientPosterBg: _buildStackedGradientPosterBg(),
      topBannerSlider: _buildTopBannerSlider(),
      topTenContentSlider: _buildTopTenContentSlider(), <-- 'Top10 카테고리!!'
      categoryContentCollectionList: _buildCategoryContentCollectionList(),
      appBarHeight: vm.appBarHeight,
    );
  }

위 코드에서는 Top10 카테고리가 어디 있는지 쉽게 확인할 수 있고
해당 코드에 있는 섹션으로 빠르게 이동할 수 있죠.

NOTE : 모듈화된(분리된) _build.. 메소드를 command(ctrl) + 클릭하면 해당 소스파일 위치로 이동할 수 있습니다.


협업에 유리 (작업공간 분리)

두 번째 이점은 여러 개발자와 협업을 진행할 때 빛을 발휘합니다.
두 명 이상의 개발자가 홈 스크린을 UI를 처음부터 작업하는 단계라고 가정해 봅시다.
각자 구현할 UI 섹션들이 구분되어 있을 때
코드를 구조화하지 않고 home_scree.dart 소스 파일에서 작업을 하다 보면 나중에 코드를 병합하는 과정에서 필연적으로 conflict가 나기 마련입니다.

  
  Widget buildScreen(BuildContext context) {
    return HomeScaffold(
      scrollController: vm.scrollController,
      appBarHeight: vm.appBarHeight,
      animationAppbar:  _buildAnimationAppBar(),
      stackedGradientPosterBg: Container(),
      topBannerSlider: _buildTapBannerSlider(),
      topTenContentSlider: Container(),
      categoryContentCollectionList: Container(),
    );
    

	Widget _buildAnimationAppBar() {
		// <--팀원1 작업공간
	}
    
    Widget _buildTapBannerSlider() {
		// <--팀원2 작업공간
	}
}

하지만 위와 같이 코드를 섹션화하여 작성한다면, 불필요한 conflict를 사전에 방지할 수 있게 됩니다.
또한 정확히 작업공간과 단계를 분리함으로써 매니저가 작업 프로세스를 쉽게 매니징 할 수 있는 장점도 있겠네요.

담배꽁초와 클린 코드

또한 클린한 코드는 정돈되지 않는 코드를 적는 행위를 예방하는 효과가 있다고 생각합니다.

재미있는 비유를 하나 해보죠.

길거리를 지나가다 보면 길거리 구석에 담배꽁초가 쌓여 있는 것을 한 번쯤은 보셨을 거라 생각이 됩니다. 왜 흡연자들을 다들 약속이라도 한 듯이 담배꽁초를 특정한 장소에 버리는 걸까요?

환경부의 공개한 보고서에 따르면 크게 4가지 이유가 있다고 합니다.

  • 흡연구역의 축소와 경계 모호에 따른 무단 투기
  • 단속 미흡
  • 담배꽁초 전용수거함의 부족
  • 무의식적 투기

저는 여기서 무의식적 투기에 대한 내용이 흥미로웠습니다.

흡연자의 담배꽁초 무의식적 투기는 해당 지역의 흡연경험과 심리적 요인 및 주변 환경 등 복합적 요소에 의해 이루어짐.

정리하자면 늘 바닥에 꽁초를 버려왔으니, 오늘도 꽁초를 바닥에 버리는 거고, 나 말고도 주변에 버리는 사람이 많으니 담배를 피우는 본인도 부담 없이 버리는 것이라고 볼 수 있겠네요.

이런 담배꽁초를 버리는 심리는 정돈되지 않는 코드를 작성하는 개발자의 심리와도 비슷합니다.
더러운 코드가 있으면 더욱더 쉽게 정된지 않은 코드를 작성하게 되는 거죠.
코드를 작성하는 과정에서의 이러한 심리적인 요소는 코드의 품질에 직접적인 영향을 끼칠 수 있음을 유의해야 합니다.

앞서 소개해 드린 방식으로 클린하게 작성된 코드가 있다면,깔끔한 장소에 담배꽁초를 내버리지 않은 것처럼, 정돈되지 않는 코드를 작성하는 행위를 예방해줄 수 있다고 생각합니다.

마무리하면서

이번 글에서는 클린 UI 코드 작성 방법과 이점에 대해 알아보았습니다. 또한, 클린한 코드를 작성하는 것이 정돈되지 않은 코드를 작성하는 행위를 예방하는 효과가 있다는 개인적인 생각을 나누었습니다.

혹시 나도 모르게 더러운 코드를 작성하고 있는지 경계할 필요가 있어 보입니다 😊

profile
https://medium.com/@ximya

4개의 댓글

comment-user-thumbnail
2023년 12월 13일

좋은 글 감사합니다

답글 달기
comment-user-thumbnail
2024년 3월 11일

안녕하세요. 글 잘 읽었습니다 :)
Widget으로 분리하는게 아닌 method로 extract하시는 이유가 있을까요?

viewModel에서 특정 데이터가 변경되면 이를 바라보고 있는 obx가 이를 감지할 것이고 리빌드를 통해서 UI를 업데이트 할 것입니다. 이럴 경우 특정 위젯에서의 상태가 변경되게되면 HomeScaffold 페이지 전체가 리빌드 되지 않나요?

1개의 답글
comment-user-thumbnail
2024년 3월 11일

안녕하세요. 글 잘 읽었습니다 :)
Widget으로 분리하는게 아닌 method로 extract하시는 이유가 있을까요?

viewModel에서 특정 데이터가 변경되면 이를 바라보고 있는 obx가 이를 감지할 것이고 리빌드를 통해서 UI를 업데이트 할 것입니다. 이럴 경우 특정 위젯에서의 상태가 변경되게되면 HomeScaffold 페이지 전체가 리빌드 되지 않나요?

답글 달기