Tuesday, January 21, 2020

Flutter 개발자를 괴롭히는 무한 높이 에러, 완벽 해부 (Column, SingleChildScrollView)

서론: 우리 모두가 마주했던 그 절망의 순간

Flutter 개발 여정을 시작한 분이라면, 혹은 이미 숙련된 개발자라 할지라도, 누구나 한 번쯤은 검은 화면 위 노란색 줄무늬와 함께 나타나는 'RenderFlex overflowed' 또는 'unbounded height' 에러 메시지를 마주하며 깊은 절망에 빠져본 경험이 있을 것입니다. 특히 UI 레이아웃을 구성할 때 가장 기본적이면서도 핵심적인 위젯인 Column을 사용하다 보면 이 문제는 어김없이 우리를 찾아옵니다.

상단에는 프로필 이미지와 사용자 이름, 중간에는 스크롤이 가능한 긴 소개글, 그리고 하단에는 고정된 버튼들. 상상 속에서는 완벽하게 동작하는 이 아름다운 UI가 막상 코드로 구현되는 순간, 시뮬레이터는 무한한 공간으로 확장하려는 위젯들의 아우성으로 가득 차며 차가운 에러 메시지를 뿜어냅니다. 많은 개발자들이 이 문제의 원인을 Column이나 Flexible 위젯의 잘못된 사용 탓으로 돌리곤 하지만, 진정한 원인은 더 깊은 곳, 바로 'Flutter의 레이아웃 렌더링 원리'에 대한 이해 부족에 있습니다.

이 글에서는 단순히 '이 코드를 복사해서 붙여넣으세요' 식의 임시방편적인 해결책을 제시하는 것을 넘어, 왜 ColumnSingleChildScrollView가 함께 사용될 때 충돌하는지, Flutter의 레이아웃 제약 조건(Constraints)은 어떻게 작동하는지 근본적인 원리를 파헤쳐 볼 것입니다. 이 원리를 이해한다면, 당신은 더 이상 예측 불가능한 레이아웃 에러 앞에서 좌절하지 않고, 어떤 복잡한 UI 요구사항에도 자신감 있게 대처할 수 있는 진정한 Flutter 레이아웃 마스터로 거듭날 수 있을 것입니다.


1. 문제의 근원: 제약 없는 공간(Unbounded Constraints)의 비극

Flutter의 레이아웃 시스템은 매우 단순하지만 강력한 규칙에 따라 작동합니다: "제약 조건은 위젯 트리 아래로(down), 크기는 위젯 트리 위로(up), 부모는 자식의 위치를 결정한다." 이 규칙을 이해하는 것이 모든 레이아웃 문제 해결의 시작입니다.

여기서 핵심은 '제약 조건(Constraints)'입니다. 부모 위젯은 자식 위젯에게 "너는 최소 이만큼, 최대 이만큼의 너비와 높이를 가질 수 있어"라는 제약 조건을 전달합니다. 자식 위젯은 이 제약 조건 내에서 자신의 크기를 결정하고, 그 결과를 다시 부모에게 알립니다.

이제 이 관점에서 ColumnSingleChildScrollView를 분석해 보겠습니다.

1.1. Column의 본성: "주어진 공간을 모두 차지하겠다!"

Column 위젯은 기본적으로 수직 방향으로 자식들을 배치합니다. 이때 mainAxisSize 속성의 기본값은 MainAxisSize.max입니다. 이는 "부모가 허락하는 한, 수직 방향으로 가능한 모든 공간을 차지하겠다"는 의미입니다.

만약 ColumnScaffoldbody처럼 화면 전체라는 명확한 높이(Bounded Height)를 가진 부모 아래에 있다면 아무런 문제가 없습니다. Column은 화면의 높이만큼 자신을 확장하고 그 안에서 자식들을 배치합니다.

하지만 만약 Column의 부모가 "네가 원하는 만큼 커져도 좋아"라고 말하는, 즉 수직으로 무한한 공간(Unbounded Height)을 제공하는 위젯이라면 어떻게 될까요? Column은 그 말 그대로 무한한 높이를 가지려고 시도하며, 이는 Flutter 렌더링 엔진을 혼란에 빠뜨립니다.

1.2. SingleChildScrollView의 딜레마: "얼마나 커질 수 있는지 알려줘!"

SingleChildScrollView의 역할은 자신의 자식 위젯이 자신의 화면(뷰포트) 크기보다 클 경우 스크롤 기능을 제공하는 것입니다. 이 역할을 제대로 수행하기 위해 SingleChildScrollView는 두 가지를 알아야 합니다.

  1. 자신의 크기 (뷰포트의 크기): 스크롤이 보이는 영역의 크기입니다.
  2. 자식의 크기: 전체 스크롤 가능한 콘텐츠의 크기입니다.

문제는 SingleChildScrollView가 자신의 자식에게 제약 조건을 전달하는 방식에 있습니다. SingleChildScrollView는 자식에게 "너비는 내 너비에 맞춰야 하지만, 높이는 네가 원하는 만큼 무한히 커져도 괜찮아"라는 제약 조건을 전달합니다. 스크롤이 가능해야 하므로, 자식의 높이에 제한을 두지 않는 것은 당연한 동작입니다.

1.3. 비극의 시작: Column과 SingleChildScrollView의 만남

이제 최악의 시나리오를 조합해 봅시다. Column 안에 SingleChildScrollView를 넣는 경우입니다.


// 🚨 흔히 발생하는 에러 상황
Column(
  children: [
    Text('타이틀'),
    // 이 SingleChildScrollView는 부모인 Column으로부터
    // "네가 원하는 만큼 커져도 좋아" 라는 제약을 받는다.
    // 하지만 SingleChildScrollView는 자신의 높이가 정해져야만
    // 스크롤 영역을 계산할 수 있다. -> 충돌 발생!
    SingleChildScrollView(
      child: Column(
        children: List.generate(50, (index) => Text('아이템 $index')),
      ),
    ),
    Text('하단 텍스트'),
  ],
)

이 코드에서 무슨 일이 일어날까요?

  1. 최상위 Column은 자신의 자식들을 배치하려고 합니다.
  2. 첫 번째 Text는 자신의 공간을 차지합니다.
  3. 문제의 SingleChildScrollView 차례입니다. ColumnSingleChildScrollView에게 "네가 차지할 수 있는 높이에는 제한이 없어"라고 말합니다. 왜냐하면 Column 자신도 부모로부터 받은 공간 내에서 유연하게 크기를 조절하려고 하기 때문입니다.
  4. SingleChildScrollView는 혼란에 빠집니다. "부모가 내 높이를 정해주지 않았네? 그럼 내 자식에게 물어봐야 하나? 아니, 내 역할은 자식이 아무리 커도 스크롤되게 하는 건데... 내 자신의 높이를 모르니 뷰포트를 그릴 수가 없어!"

이러한 딜레마가 바로 Vertical viewport was given unbounded height. 라는 유명한 에러 메시지의 실체입니다. 뷰포트(SingleChildScrollView)가 무한한 높이 제약을 받았다는 뜻이죠.

특히 Column의 자식으로 Flexible이나 Expanded를 사용했을 때 이 문제는 더욱 명확하게 드러납니다. Expanded는 남은 공간을 '모두' 차지하라는 뜻인데, Column 자체가 무한한 높이를 가지려고 하는 상황에서는 '남은 공간'의 크기를 계산할 수 없기 때문입니다. 이것이 RenderFlex children have non-zero flex but incoming height constraints are unbounded. 에러의 원인입니다.


2. 해결책 완전 정복: 상황별 최적의 솔루션

원인을 명확히 이해했으니, 이제 해결은 간단합니다. 핵심은 단 하나, 어떤 방법으로든 SingleChildScrollView에게 유한한(Bounded) 높이를 알려주는 것입니다. 상황에 따라 다양한 해결책을 적용할 수 있습니다.

솔루션 1: `Expanded` - 남은 공간을 모두 채워라 (가장 일반적인 해결책)

가장 흔하고 이상적인 시나리오는 '화면의 특정 부분(헤더, 푸터 등)을 제외한 나머지 공간을 스크롤 영역으로 만들고 싶을 때'입니다. 이럴 때는 `Expanded` 위젯이 완벽한 해결책입니다.

원리: Expanded는 자신이 속한 `Flex` 위젯(Column, Row)의 주축(main axis) 방향으로 남은 공간을 모두 차지하도록 강제합니다. 중요한 것은 `Expanded`가 제대로 동작하려면 그 부모인 `Column` 자체가 유한한 높이를 가지고 있어야 한다는 점입니다. 일반적으로 `Scaffold`의 `body`에 직접配置된 `Column`은 화면 전체라는 유한한 높이를 가지므로, 이 조건이 충족됩니다.

잘못된 예시 (에러 발생):


Scaffold(
  appBar: AppBar(title: Text('에러 발생 예시')),
  body: Column(
    children: [
      Text('헤더 영역', style: TextStyle(fontSize: 24)),
      // Column이 SingleChildScrollView에게 무한 높이를 허용하므로 에러 발생
      SingleChildScrollView(
        child: Column(
          children: List.generate(50, (index) => ListTile(title: Text('아이템 $index'))),
        ),
      ),
      Text('푸터 영역', style: TextStyle(fontSize: 24)),
    ],
  ),
);

올바른 예시 (`Expanded` 사용):


Scaffold(
  appBar: AppBar(title: Text('Expanded 해결책')),
  body: Column(
    children: [
      // 1. 고정된 위젯 (헤더)
      Container(
        height: 100,
        color: Colors.blue[100],
        alignment: Alignment.center,
        child: Text('헤더 영역', style: TextStyle(fontSize: 24)),
      ),
      // 2. 남은 모든 공간을 차지할 스크롤 영역
      // Expanded가 SingleChildScrollView에게 유한한 높이를 강제한다.
      Expanded(
        child: SingleChildScrollView(
          child: Column(
            children: List.generate(50, (index) => ListTile(title: Text('아이템 $index'))),
          ),
        ),
      ),
      // 3. 고정된 위젯 (푸터)
      Container(
        height: 80,
        color: Colors.green[100],
        alignment: Alignment.center,
        child: Text('푸터 영역', style: TextStyle(fontSize: 24)),
      ),
    ],
  ),
);

위 코드에서 `Expanded`는 `Scaffold`의 `body`가 가진 전체 높이에서 헤더(100px)와 푸터(80px)의 높이를 뺀 '나머지 모든 공간'을 차지합니다. 이렇게 명확하고 유한한 높이 제약을 받은 `SingleChildScrollView`는 자신의 뷰포트 크기를 정확히 인지하고 정상적으로 스크롤 기능을 수행할 수 있게 됩니다.

솔루션 2: `SizedBox` 또는 `Container` - 명시적인 높이 지정

스크롤 영역이 남은 공간을 모두 채우는 것이 아니라, 특정 고정된 크기를 가져야 할 때가 있습니다. 예를 들어, 카드 UI 안에 200픽셀 높이의 스크롤 목록을 넣는 경우입니다.

원리: `SizedBox`나 `Container` 위젯으로 `SingleChildScrollView`를 감싸고 `height` 속성을 직접 지정해주는 가장 직관적이고 간단한 방법입니다. 이는 `SingleChildScrollView`에게 "너의 높이는 정확히 200픽셀이야" 라고 명시적으로 알려주는 것과 같습니다.

예시 코드:


Card(
  margin: EdgeInsets.all(16.0),
  child: Column(
    // Column의 크기가 자식들에 의해 결정되도록 설정
    mainAxisSize: MainAxisSize.min,
    children: [
      ListTile(
        leading: Icon(Icons.album),
        title: Text('앨범 제목'),
        subtitle: Text('아티스트 이름'),
      ),
      // 스크롤 뷰에 명시적인 높이를 부여
      SizedBox(
        height: 200, // 이 부분이 핵심!
        child: SingleChildScrollView(
          child: Padding(
            padding: const EdgeInsets.all(16.0),
            child: Text('매우 긴 앨범 소개글... ' * 100),
          ),
        ),
      ),
      ButtonBar(
        children: [
          TextButton(onPressed: () {}, child: Text('듣기')),
          TextButton(onPressed: () {}, child: Text('저장')),
        ],
      ),
    ],
  ),
)

이 방법은 매우 간단하지만, 고정된 값을 사용하기 때문에 다양한 화면 크기에 대응하기 어려울 수 있습니다. 이럴 때는 `MediaQuery`를 활용하여 화면 크기에 비례하는 높이를 지정할 수 있습니다.


// 화면 높이의 30%를 스크롤 영역으로 사용
SizedBox(
  height: MediaQuery.of(context).size.height * 0.3,
  child: SingleChildScrollView(
    // ...
  ),
)

솔루션 3: `ConstrainedBox` - 유연한 높이 제약

`SizedBox`보다 더 유연한 제약을 주고 싶을 때 `ConstrainedBox`가 유용합니다. 최소 높이와 최대 높이를 지정하여 콘텐츠의 양에 따라 스크롤 뷰의 크기가 동적으로 변하도록 만들 수 있습니다.

원리: `ConstrainedBox`는 자식에게 `BoxConstraints`라는 제약 조건을 전달합니다. 이를 통해 "너의 높이는 최소 100픽셀, 최대 300픽셀이어야 해"와 같은 복합적인 규칙을 설정할 수 있습니다.

예시 코드:


// 콘텐츠가 적으면 그만큼만 커지고, 많아지면 최대 300까지만 커지고 스크롤됨
ConstrainedBox(
  constraints: BoxConstraints(
    minHeight: 100.0,
    maxHeight: 300.0,
  ),
  child: SingleChildScrollView(
    child: Text(
      '내용이 짧으면 이 텍스트만큼의 높이만 차지합니다.\n' * (isExpanded ? 20 : 2),
    ),
  ),
)

이 방법은 동적인 콘텐츠 길이에 따라 UI가 자연스럽게 반응해야 할 때 매우 효과적입니다.

솔루션 4: `shrinkWrap: true`와 `MainAxisSize.min` - 자식의 크기에 맞춰라

이 솔루션은 문제의 관점을 약간 바꿉니다. `SingleChildScrollView`가 부모인 `Column` 안에 있는 것이 아니라, Column 자체가 스크롤 가능한 콘텐츠인 경우에 주로 사용됩니다. 예를 들어, `ListView` 안에 `Column`을 넣는 경우입니다.

원리:

  • `shrinkWrap: true` (`ListView`, `SingleChildScrollView` 등): 이 속성은 스크롤 뷰에게 "부모가 주는 공간을 다 차지하지 말고, 네 자식 콘텐츠가 필요한 만큼만 네 크기를 줄여라" 라고 지시합니다.
  • `mainAxisSize: MainAxisSize.min` (`Column`): 이 속성은 `Column`에게 "부모가 허락하는 공간을 다 차지하지 말고, 네 자식들의 높이의 합만큼만 네 크기를 줄여라" 라고 지시합니다.

이 둘은 보통 함께 사용됩니다. ListView 안에 들어간 `Column`이 `MainAxisSize.max`(기본값)를 유지하면, `ListView`라는 무한한 높이를 제공하는 부모 안에서 `Column` 역시 무한한 높이를 가지려고 시도하여 에러가 발생합니다. 따라서 `Column`의 크기를 자식들에게 맞추도록 `MainAxisSize.min`으로 설정해야 합니다.

예시 코드 (잘못된 사용):


// ListView 안에 일반 Column을 사용하면 에러 발생
ListView(
  children: [
    Text("ListView의 아이템 1"),
    Column( // MainAxisSize.max가 기본값이므로 무한 높이를 차지하려 함
      children: [
        Text("Column 내부 아이템 A"),
        Text("Column 내부 아이템 B"),
      ],
    ),
    Text("ListView의 아이템 3"),
  ],
);

예시 코드 (올바른 사용):


// ListView 안에 MainAxisSize.min을 적용한 Column 사용
ListView(
  children: [
    Text("ListView의 아이템 1"),
    Column(
      // 자식들의 크기만큼만 차지하도록 설정
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start, // 정렬을 위해 추가
      children: [
        Text("Column 내부 아이템 A", style: TextStyle(fontWeight: FontWeight.bold)),
        Text("Column 내부 아이템 B"),
        FlutterLogo(),
      ],
    ),
    Text("ListView의 아이템 3"),
  ],
);

이 방법은 스크롤 가능한 목록 내부에 부분적으로 `Column` 구조가 필요할 때 유용합니다.

솔루션 5: `IntrinsicHeight` - 최후의 수단 (성능 주의)

가끔은 `Column`의 자식들이 자신의 내재된(intrinsic) 크기에 따라 서로의 높이를 맞추어야 하는 복잡한 경우가 있습니다. 예를 들어, 두 개의 `Container`를 나란히 놓고, 둘 중 더 큰 `Container`의 높이에 다른 하나가 맞춰지도록 하고 싶을 때입니다.

원리: `IntrinsicHeight`는 자식 위젯 트리를 두 번 통과(two-pass)하는, 비용이 비싼 레이아웃을 수행합니다. 첫 번째 패스에서는 자식들이 가질 수 있는 '이상적인' 또는 '내재된' 높이를 계산만 하고 실제로 렌더링하지 않습니다. 두 번째 패스에서 이 계산된 높이를 기반으로 실제 제약 조건을 설정하고 자식들을 렌더링합니다.

경고: `IntrinsicHeight`는 매우 유용할 수 있지만, 레이아웃 계산을 두 번 수행하기 때문에 성능에 심각한 영향을 줄 수 있습니다. 특히 깊고 복잡한 위젯 트리나 스크롤 목록 안에서 사용하는 것은 피해야 합니다.

예시 코드:


// IntrinsicHeight를 사용하여 Row 안의 Column들이 높이를 맞추게 함
IntrinsicHeight(
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      Expanded(
        child: Column(
          children: [
            FlutterLogo(size: 50),
            Text('짧은 텍스트'),
          ],
        ),
      ),
      VerticalDivider(width: 20, thickness: 1, color: Colors.grey),
      Expanded(
        child: Column(
          children: [
            FlutterLogo(size: 80),
            Text('여기는 조금 더 긴 텍스트가 들어갑니다. 높이가 자동으로 맞춰집니다.'),
          ],
        ),
      ),
    ],
  ),
)

이 위젯은 `Column`과 `SingleChildScrollView` 문제의 직접적인 해결책이라기보다는, 복잡한 `Column` 레이아웃 자체를 해결하는 방법 중 하나이며, 정말 다른 방법이 없을 때 최후의 수단으로 고려해야 합니다.


결론: 제약 조건을 지배하는 자가 레이아웃을 지배한다

Flutter에서 `Column`과 `SingleChildScrollView`를 함께 사용할 때 발생하는 무한 높이 관련 에러는 버그가 아니라, Flutter의 일관된 렌더링 규칙이 만들어내는 자연스러운 결과입니다. 이 문제의 핵심은 "스크롤이 가능한 위젯은 반드시 유한한(Bounded) 높이 제약을 받아야 한다"는 대원칙에 있습니다.

우리는 이 문제를 해결하기 위한 다양한 무기를 살펴보았습니다.

  • Expanded: 남은 공간을 모두 채울 때 사용하는 최고의 선택.
  • SizedBox / Container: 고정된 크기를 명시적으로 부여할 때.
  • ConstrainedBox: 최소/최대 크기로 유연한 제약을 걸 때.
  • shrinkWrap / MainAxisSize.min: 스크롤 뷰 내부에서 위젯이 자신의 콘텐츠 크기에 맞게 줄어들어야 할 때.
  • IntrinsicHeight: 성능 저하를 감수하고 복잡한 높이 계산이 필요할 때 사용하는 최후의 수단.

이제 당신은 단순히 에러를 회피하는 개발자가 아닙니다. "왜" 이 에러가 발생하는지 근본 원리를 이해하고, 주어진 UI 요구사항과 맥락에 따라 가장 효율적이고 적절한 해결책을 선택할 수 있는 한 단계 높은 수준의 개발자로 성장했습니다. Flutter의 "제약 조건은 아래로, 크기는 위로"라는 황금률을 항상 기억하십시오. 이 원칙만 마음에 새긴다면, 앞으로 마주할 그 어떤 복잡한 레이아웃 문제도 자신감 있게 해결해 나갈 수 있을 것입니다.


0 개의 댓글:

Post a Comment