ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 위젯 생성은 싸지만 작업은 싸지 않습니다
    AI Agent/flutter 2026. 7. 8. 21:00
    728x90
    반응형

    잘게 자른 종이 조각들과 하나를 집어 든 손, 가위
    잘게 쪼갰는데 느려진 스크롤의 범인

    "위젯은 얼마든지 쪼개도 된다"는 말을 어디선가 읽고, 그 말을 좀 순진하게 믿었습니다.
    어느 날 저녁, 한 화면의 build 함수가 너무 길어 눈에 거슬려서 리스트 화면 하나를 스무 개 가까운 작은 위젯으로 잘게 나눴습니다.
    카드, 배지, 썸네일, 가격 라벨, 심지어 구분선까지 전부 별도 클래스로 뽑아냈죠.
    코드는 눈에 띄게 읽기 좋아졌고, 저는 그날 꽤 뿌듯한 채로 잤습니다.
    그런데 다음 날 아침 실제 기기에서 스크롤을 내려보니 예전보다 오히려 손끝에 뚝뚝 걸렸습니다.
    분명히 더 잘게 나눴는데 더 느려진 겁니다.

    쪼갤수록 느려진다는 게 말이 되나

    처음엔 제 눈을 의심했습니다.
    위젯을 나누는 건 공짜에 가깝다고 배웠으니까요.
    그래서 엉뚱한 데를 먼저 팠습니다.
    const를 빠뜨린 위젯이 있나 싶어 눈에 보이는 생성자마다 const를 붙였고, 그래도 안 되자 이번엔 위젯을 너무 많이 만들어서 그런가 싶어 몇 개를 도로 합쳐봤습니다.
    삼십 분쯤 헤매다 문득, 합쳐도 별로 안 빨라진다는 걸 알아챘습니다.
    개수가 문제였으면 합쳤을 때 티가 났어야 하는데 아니었거든요.

    그래서 방향을 틀었습니다.
    썸네일 위젯 build 맨 위에 debugPrint('decode ${item.id}') 한 줄을 심고 화면을 딱 한 번 쓸어내렸습니다.
    콘솔에 같은 카드의 decode 로그가 스크롤할 때마다 수십 줄씩 쏟아졌습니다.
    DevTools 타임라인을 열어보니 그 구간에서 raster 쪽 막대가 유독 길게 튀어 있었고요.
    그제야 범인이 보였습니다.
    썸네일 위젯 build 안에서 매번 이미지 바이트를 다시 디코딩하고 있었습니다.
    원래 화면에 진입할 때 한 번만 하던 일을, 위젯을 쪼개면서 각 카드의 build 경로 안으로 무심코 옮겨 심은 거죠.
    스크롤이 카드를 다시 그릴 때마다 그 디코딩이 통째로 다시 돌았습니다.
    위젯 생성은 쌌지만, 그 안에 딸려 들어간 디코딩은 전혀 싸지 않았습니다.

    build는 배치하는 자리지 계산하는 자리가 아니다

    그때 다시 세운 기준이 하나 있습니다.
    build 함수는 "무엇을 어디에 놓을지"를 그리는 자리지, "무거운 값을 만들어내는" 자리가 아니라는 것.
    무거운 변환은 화면에 들어오기 전에 한 번 끝내두고, 위젯에는 이미 만들어진 결과만 넘깁니다.

    class ThumbnailCard extends StatelessWidget {
      const ThumbnailCard({super.key, required this.item});
      final CatalogItem item;
    
      @override
      Widget build(BuildContext context) {
        // item.thumbnail은 이미 디코딩이 끝난 결과.
    build는 배치만 한다.
        return Image(image: item.thumbnail, fit: BoxFit.cover);
      }
    }
    

    이렇게 디코딩을 밖으로 빼고 나니, 위젯 개수는 그대로 스무 개인데도 스크롤이 다시 매끄러워졌습니다.
    재밌는 건 그 뒤로 위젯을 더 잘게 나눠도 아무 문제가 없었다는 점입니다.
    디코딩이든 JSON 파싱이든 큰 리스트 정렬이든, build 밖에서 한 번 끝내두면 위젯을 아무리 쪼개도 스크롤은 멀쩡했습니다.
    반대로 build 안에 그런 작업이 한 줄이라도 남아 있으면, 위젯을 나눌수록 그 한 줄이 더 자주 불려서 오히려 손해였습니다.

    정렬 버튼을 누를 때마다 반 박자씩 굳었다

    그로부터 얼마 뒤, 전혀 다른 화면에서 비슷한 벽을 만났습니다.
    데이터가 많은 표 화면이었는데, 이번엔 스크롤이 아니라 정렬 버튼을 누를 때마다 화면이 반 박자씩 굳었습니다.
    처음엔 정렬 알고리즘이 느린가 싶어 비교 함수를 한참 들여다봤죠.
    근데 행이 백 개 남짓이라 그게 느릴 리 없었습니다.
    결국 또 build였습니다.
    화면이 다시 그려질 때마다 그 리스트를 처음부터 다시 정렬하고 있었거든요.
    정렬은 버튼을 누른 그 순간에만 필요한데, rebuild가 일어날 때마다 습관처럼 딸려 돌았습니다.

    정렬한 결과를 상태로 한 번 만들어두고 build에서는 그것만 읽게 바꾸니 굳는 느낌이 사라졌습니다.
    스크롤이 끊긴 화면과 정렬에서 굳던 화면은 증상도 다르고 화면도 달랐지만, build 안에서 매번 무거운 값을 새로 만든다는 뿌리는 같았습니다.
    const를 아무리 붙여도 그날 스크롤이 안 빨라졌던 이유가 여기 있었습니다.
    저는 "몇 번 그려지나"만 붙잡고 있었지, "그려질 때마다 무슨 일을 같이 하나"는 안 봤던 겁니다.

    그래서 rebuild 자체를 겁내지 않기로 했다

    한동안 저는 rebuild가 일어나는 걸 무조건 나쁜 신호로 봤습니다.
    const를 붙이고, 위젯을 쪼개고, build 호출 수를 줄이는 데 집착했죠.
    근데 이 두 번을 겪고 나서는 질문이 바뀌었습니다.
    위젯을 아무리 예쁘게 쪼개도 그 안에서 무거운 일이 돌면 rebuild는 그만큼 비싸지고, 반대로 build가 가벼우면 rebuild가 몇 번 일어나든 사용자는 못 느낍니다.
    제가 겁냈어야 할 건 rebuild의 횟수가 아니라 rebuild 한 번의 무게였습니다.

    썸네일 하나의 디코딩을 build 밖으로 뺐을 뿐인데 스무 개짜리 리스트가 통째로 다시 매끄러워지던 그 감각이, 지금도 화면이 끊길 때 제일 먼저 떠오릅니다.
    그래서 요즘 제 손은, 화면이 뻑뻑하면 위젯 트리가 아니라 build 함수 안쪽부터 뒤집니다.

    원문 기준

    원문 기준: https://docs.flutter.dev/perf/best-practices

    원문 본 날짜: 2026-07-01.

    728x90
    반응형
Designed by Tistory.