보일러플레이트 코드를 피하자
보일러 플레이트
Flutter로 코드를 짜다보면 반복적인 코드가 등장하게 된다.
이런 반복적인 코드를 프로그래머들은 보일러플레이트 코드라고 부른다.
보일러플레이트 코드는 없어져야 한다
최소한의 변경으로 반복되는 코드는 실용적이지도 않고, 코드의 길이는 길어지며, 길어진 코드는 가독성을 해치게 된다.
개발자는 이런 상황을 막기 위해 최대한 보일러플레이트 코드를 없애는 방향으로 코드를 작성해야 한다.
이런 보일러플레이트 코드를 없애는 방향으로 디자인 패턴(delegate pattern의 장점으로도 나온다.)을 이용하기도 하고, 라이브러리와 IDE의 기능들을 사용한다.
Flutter의 보일러플레이트
위젯이 반복돼
Flutter에서의 보일러플레이트는 아무래도 위젯에 관한 것이 굉장히 클 것이다.
아래와 같은 Text 위젯을 작성했다고 하자. ▼
Text('hello');
그런데 글자만 있으면 조금 휑하니, Container 위젯으로 감싸 조금 꾸며보자. ▼
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Text('hello'),
);
이제 이걸 ListView에 넣는다고 해보자. ▼
ListView(
children: <Widget>[
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Text('hello'),
),
],
)
여기까지는 읽는데에 큰 무리가 없다.
하지만 이 Container들이 많아지면 그 때부터는 코드가 불필요하게 길어지게 된다. ▼
ListView(
children: <Widget>[
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Text('hello'),
),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Text('hello'),
),
...
],
)
여기서부터는 다들 알겠지만, 이 코드를 줄이기 위해 두 가지 방법을 택하곤 한다.
첫번째는 Helper Method, 두번째는 별도의 Class Widget이다.
Helper Method VS Class Widget
Helper Method
Helper Method라고 하면 초심자 입장에서는 생소할 수 있는데, 함수의 형태로 위젯을 건네주는 것을 말한다.
위의 코드를 helper method로 넘겨주면 ListView의 코드는 훨씬 간결해진다. ▼
ListView(
children: <Widget>[
_textBox(),
_textBox(),
],
)
Widget _textBox() {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: const Text('hello'),
);
}
Helper method의 다른 장점은 해당 위젯의 모든 변수에 바로 접근이 가능하다는 것이다.
즉, 데이터 접근에 별 다른 코스트가 없다는 것이다.
작성도 용이하고 접근도 편리하니 모든게 완벽해 보이지만 사실은 그렇지 않다.
완벽하지 않은 이유에 대해서는 Class Widget에 대해 이야기 한 뒤에 이야기해보겠다.
Class Widget
다음으로는 Class Widget이다.
처음 Custom Widget에 대해 배울 때, Stateless Widget 혹은 Stateful Widget을 상속받아 만드는 것을 배웠을 것으로 예상이 된다.
위의 코드를 Class Widget으로 넘겨주면 앞에서와 마찬가지로 ListView의 코드는 훨씬 간결해진다.
하지만 Class Widget은 이것저것 붙는게 너무나도 많다. ▼
ListView(
children: <Widget>[
_TextBox(),
_TextBox(),
],
)
class _TextBox extends StatelessWidget {
const _TextBox({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: const Text('hello'),
);
}
}
기본적으로 Class 구조를 취해야하며, StatelessWidget을 상속받아야한다.
일반적으로 key부분을 잘 사용하지 않지만, key부분이 기본 템플릿으로 작성이 되며, build를 통해 위젯을 반환하기 때문에 같은 위젯을 전달한다고 해도 helper method 보다 더 길이가 길어지게 된다.
그런데 이 글의 제목을 알다시피, flutter에서는 helper method 방식으로 위젯을 쪼개는 것을 지양해야한다고 한다.
"Helper method 방식이 훨씬 코드가 간결하고 사용도 편한데도 메소드로 위젯을 나누는 것을 왜 지양해야할까?"
위젯을 메소드로 쪼개는 것이 왜 안좋은가
사실상 이 글의 핵심적인 부분이다.
위젯을 메소드로 리팩터링 하는 것이 왜 안좋은 지 설명하는 부분이다.
Flutter에서 이 helper method에서 공식적으로 입장을 밝히기도 했다. ▼
굉장히 쉽게 설명해준다.
영상에서는 Performance, Testability, Accuracy의 3가지 이유로 설명을 했고, 필자도 이 3가지 이유의 형식을 따라 소개를 해보려고 한다.
Performance : rebuild
둘은 성능적인 측면에서 굉장히 다름을 보인다.
어떤 다름을 보이는지 알기 위해 예시를 보자.
컨테이너를 가지는 컨테이너 예시
첫번째는 helper method로 작성된 위젯이고, 두번째는 class로 작성된 위젯이다. ▼
Widget helperMethodWidget({ Widget child}) {
return Container(child: child);
}
class ClassWidget extends StatelessWidget {
final Widget child;
const ClassWidget({Key key, this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
child: child,
);
}
}
이 두 위젯에는 `child` 매개변수가 있는데, child에 각각 자신 스스로를 넣어본다고 해보자. ▼
helperMethodWidget(child: helperMethodWidget);
ClassWidget(child: ClassWidget());
이렇게 작성을 하면 Container안에 Container가 들어간 같은 위젯이 나오게 된다.
둘이 같은 위젯을 반환하니까 차이점이 없는 것 같지만 둘의 위젯 트리를 보면 다른 부분이 보인다.
트리 구조의 차이
아래는 둘의 트리 구조다. ▼
Container
Container
ClassWidget
Container
ClassWidget
Container
Helper method를 통해 만든 위젯은 Container만 보이지만, class를 통해 만든 위젯은 그 위젯 자체도 트리에 등록이 된다.
이것이 의미하는 것은 helper method로 위젯을 넘겨주게 되면 위젯 트리에 등록이 되지 않는다는 것이다.
‘위젯 트리에 등록이 되지 않는게 무슨 문제가 되는거지?’
위젯을 다시 그릴 때 (rebuild)
Flutter에서 setState() 콜백 함수를 호출하면 해당 위젯을 rebuild, 즉 새로 그리게 된다.
부모 위젯이 rebuild되면 자식 위젯도 rebuild가 되지만, 자식 위젯의 rebuild는 부모 위젯의 rebuild로 이어지지 않는다.
조금 상식적으로 생각을 해보면, 자식 위젯의 rebuild가 부모 위젯의 rebuild로 이어지게 되면 불필요한 연산을 하게 된다.
인스타그램의 좋아요 아이콘을 생각해보자. ▼
조그마한 좋아요 아이콘을 눌렀을 때, 해당 페이지 전체가 새로 그려지는 것은 굉장히 비효율적이다.
해당 아이콘의 색상만 바뀌면 되는 것인데, 페이지 전체를 새로 그리는것은 불필요한 연산을 하게 하는 것이다.
면적 상으로 따지면 거의 100배가 되는 면적을 다시 그리게 된다.
만약에 아이콘에 애니메이션이 들어간다면?
페이지 전체를 초당 60회씩 다시 그리게 되는 것이다.
원래는 40 * 40 면적을 그릴 것을, 360 * 640 면적을 다시 그리게 되는 것을 생각하면, 거의 100배가 넘게 다시 그리는 비효율적인 행동을 하게 된다.
‘그래서 위젯 트리에 등록 되지 않은게 왜 문제가 되는 건데?’
재사용의 경계
플러터는 성능 및 메모리 사용량을 최적화하기 위해 위젯을 재사용하려고 노력한다.
위젯을 재사용 할 때는 어디까지 재사용할 것인지를 확실하게 정하기 위해 '재사용의 경계'를 설정하고, 일반적으로는 위젯 트리에 등록된 내용대로 따르게 된다.
하지만 부모 위젯의 build 메소드 내에서 helper method가 호출되면, 해당 헬퍼 메소드에서 반환한 위젯은 일종의 ‘재사용의 경계’를 새로 형성하게 되는데, 이 경계가 부모 위젯에 까지 영향을 주게 된다.
그렇기에 helper method가 반환한 위젯이 변경되면, 최적화를 위해 부모 위젯 전체가 다시 그려질 수 있다.
이 흐름을 그림으로 보면 아래와 같다.
아래와 같은 위젯이 있다고 하면, 위젯 트리는 그림과 같이 설정이 된다. ▼
여기서 가장 안쪽에 있는 Container가 rebuild 된다고 하면, 재사용의 경계는 아래의 그림과 같이 설정된다. ▼
이렇듯 helper method를 통해 위젯을 전달하게 되면, 재사용의 경계가 제대로 설정되지 않아 해당 페이지 전체가 다시 그려지는 불상사가 발생할 수 있게 된다.
Accuracy : BuildContext
BuildContext는 위젯 트리의 위치에 따라서만 의미가 있는 값이다.
Helper method 내에서 부모 위젯의 BuildContext를 사용하려고 하면, 해당 BuildContext는 헬퍼 메소드를 호출한 부모 위젯의 위치에 대한 정보만을 갖게 된다.
이 위치에서 상위나 하위의 BuildContext를 사용하려고 하면 예측이 어렵고 버그를 초래할 수 있다.
예제를 통해 확인해보자.
InheritedNotifier 예제
아래는 테스트할 위젯의 기본 페이지 구조다.
현재는 home: 부분이 주석 처리 되어있는데, //home: Home(), 과 //home: home(context) 으로 두 줄을 주석처리 해놓았다.
결과부터 이야기하자면, 첫번째의 Home() 은 정상적으로 작동하지만, 두번쨰의 home(context) 는 null exception을 일으킨다. ▼
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
print('이 빌드 메소드는 한 번 만 실행 된다.');
return Counter(
count: Count(),
child: MaterialApp(
//home: Home(),
//home: home(context),
),
);
}
}
Count, Counter
해당 예제는 ValueNotifier를 상속하는 Count를 통해, InheritedNotfier를 상속하는 Count를 rebuild하는 예제다.
쉽게 이야기하면, Count의 값이 변하면 Count가 이를 보고 자동으로 rebuild하게 하는 코드다. ▼
class Count extends ValueNotifier<int> {
Count() : super(0);
}
class Counter extends InheritedNotifier {
Counter({
super.key,
required this.count,
required super.child,
}) : super(notifier: count);
final Count count;
static Count of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<Counter>()!.count;
}
}
하지만 조금 자세히 봐야할 메소드가 있는데, 바로 dependOnInheritedWidgetOfExactType 이라는 메소드다.
해당 메소드는 현재 context에서 가장 가까운 위치에 있는 T 타입의 인스턴스를 반환해준다.
이게 왜 중요한 지는 조금 뒤에 나오니 일단은 기억하고 넘어가보자.
어쨌거나 우리는 이를 통해 home: 에 들어갈 두 가지 위젯을 만들 것이다.
첫번째는 Class Widget을 통해 만든 Home, 두번째는 Helper Method를 통해 만든 home이다. ▼
class Home extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text('Count ${Counter.of(context).value}'),
),
floatingActionButton: FloatingActionButton(
onPressed: () => Counter.of(context).value++,
child: Icon(Icons.add),
),
);
}
}
결과는? 결과의 원인은 바로 제대로 생성되지 않은 context
결과는 앞에서 언급한 대로 home: Home(), 은 제대로 동작하지만 home: home(context) 은 제대로 동작하지 않는다.
home(context)는 아예 크래시가 나버린다.
'그렇다면 왜 크래시가 나는 것일까?'
그 이유는 아까 이야기했던 dependOnInheritedWidgetOfExactType 메소드와 BuildContext에 있다.
위에서 작성한 코드의 home(context)의 흐름을 따라가보면 아래의 그림과 같다.
Counter와 MaterialApp에는 각각 BuildContext가 있어 위젯 트리에서 정확한 위치를 확인할 수 있다. ▼
이상적인 위젯 형성이라면 home 역시도 별도의 BuildContext가 있어야하지만, 현재 코드의 home은 helper method로 생성되었기 때문에 BuildContext가 없다.
BuildContext가 없는 것 보다 더 문제되는 것은 위의 Counter의 BuildContext를 이용한다는 것이다. ▼
앞에서 dependOnInheritedWidgetOfExactType 메소드를 기억하라고 했는데, 그 문제점이 여기서 드러난다.
해당 메소드는 현재 context에서 가장 가까운 T 타입의 인스턴스를 찾아주는데, 우리가 제공한 context는 Counter의 context이다.
그리고 우리가 찾고자 하는 인스턴스 역시도 Counter 인스턴스이기에, Counter의 위치에서 Counter를 찾게 되면서 flutter는 Counter 인스턴스를 찾지 못하게 된다. ▼
이렇듯 helper method로 위젯을 전달하게 되면 BuildContext가 제대로 생성이 되지 않고, 다른 위젯의 BuildContext를 끌어 사용하는 상황이 발생하면서 위젯 트리가 꼬이게 된다.
Testablity
테스팅을 할 때에도 클래스로 만들어진 위젯이 더 편리하고 테스트에 용이하다.
이는 위의 두 Performance와 Accuracy와 일맥상통하는 이야기이다.
Helper method로 위젯을 짜면 앞의 문제들이 발생하기도 하고, performance 측면에서는 애니메이션이 제대로 동작하지 않는 문제도 발생한다.
또한 테스트를 할 때는 제대로 된 BuildContext가 제공될 것이기에 테스트하는 동안에는 BuildContext에 대한 이슈 역시 찾을 수 없다.
그리고 가장 큰 문제는 메소드화 된 위젯은 해당 클래스 내부에 종속되어있기 때문에 테스팅을 하기가 상당히 까다롭다는 것이다.
위젯이 종속적이기 때문에 제대로 된 테스트가 이뤄지지 못할 가능성이 높다.
마치며
Flutter에서는 이렇게 3가지 측면에서 위젯을 메소드로 나누는 것을 권장하지 않는다.
Flutter 초기에는 이에 대한 논쟁이 꽤 있었고 Flutter팀에서 공식적인 입장을 밝히지 않았었다고 한다.
최근이라고 뭐하지만 2021년에 Flutter팀에서 공식적인 입장을 밝히면서 Class Widget VS Helper Method의 싸움은 Class Widget으로 정리가 되었다.
솔직히 helper method로 짜는게 너무나도 편하긴 하다.
클래스 내부에 선언을 하면 생성자도, 매개변수도 따로 만들지 않아도 바로 접근이 되어 편하다.
허나 대체로 그렇듯, 우리가 편하다고 느끼면 안좋을 때가 많다.
약간 허리를 박살 내며 앉아있는 그런 느낌과 비슷한 포지션이라고 생각한다.
지금 당장은 편하지만 앱은 박살나있는 그런 상태가 될 거 같으니 Class Widget으로 전부 수정하고 이후에 하는 프로젝트들에도 적용해야겠다.
참고한 사이트
https://iiro.dev/splitting-widgets-to-methods-performance-antipattern/
'Develop > Flutter' 카테고리의 다른 글
[Flutter][Error] M1 맥 Flutter CocoaPod 설치 오류 (0) | 2024.01.21 |
---|---|
[Flutter] 동기와 비동기 개론 (0) | 2024.01.20 |
[Flutter] Stateless & Stateful 알아보기 (0) | 2024.01.18 |
[Flutter] Flutter 위젯 디자인에 관하여 (0) | 2024.01.17 |
[Flutter] M1 맥북에서 Flutter 세팅하기(feat. VSCode) (11) | 2024.01.16 |