[Flutter] Layout 기본 원칙

저우웅·2026년 3월 9일

Flutter

목록 보기
8/8
post-thumbnail

Flutter 레이아웃의 대원칙은
Constraints go down, Sizes go up, Parent sets position 입니다.

저는 React, Vue같은 웹 FE 프레임워크만 사용하다 Flutter를 처음 써봤을 때 정말 혼돈의 도가니였는데요, 오늘은 그래서 Flutter 레이아웃 원칙을 함께 공부하는 세션을 준비해보았습니다!




너무 빡빡한 부모... 어떻게 하죠?


가장 먼저 아래 코드를 봅시다.
main.dart의 body를 수정한 코드인데요, 어떻게 작동할지 예측해볼까요?
Flutter 개발 경험이 거의 없는 (저같은) 사람들은 해당 코드의 문제점을 못 느낄텐데요.


  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      // 여기부터! 해봅시다.
      // 우리의 예상대로라면, 300x300 파란 박스 속에 100x100짜리 빨간 박스가 생겨야 합니다.
      // 앱 실행시, 어떤 화면이 보이시나요? 예상대로 되었나요?
      body: Container(
        width: 300,
        height: 300,
        color: Colors.blue,
        child: Container(width: 100, height: 100, color: Colors.red),
      ),
    );
  }

main.dart의 body부분을 위 코드처럼 바꾼 뒤 직접 실행시켜봅시다!
우리의 예상대로라면 300x300파란 박스와 내부에 100x100 빨간 박스가 등장해야 합니다.

하지만 앱 실행시 보이는 화면은 우리의 예상과는 다르게...



음??
child인 빨간색 박스가 300x300만큼 팽창해서 파란색 박스를 덮어버렸죠.


왜 이럴까요?

웹 개발에서는 부모 div가 300px이고 자식 div가 100px이면, 큰 상자 안에 작은 상자가 예쁘게 들어갑니다.

하지만 플러터는 Constraints go down이죠.
부모(Container)가 자식(child)에게 "너도 내 크기에 무조건 맞춰!"라고 강제(Tight Constraint)하는 상황입니다.


그러면 이 상황을 어떻게 해결할 수 있을까요?

문제 해결을 위해서는 두 가지를 알아야겠군요.

  1. Constraint, 그 중에서도 Tight Constraint가 어떤 거고, 왜 생기는 건가요?
  2. 그러면 tight하지 않은 constraint도 있습니까? (네!)



Constraint

제약(Constraint)이란 무엇인가요?

Flutter에서 constraint는,
플러터에서 부모 위젯이 자식 위젯에게 넘겨주는 '크기의 허용 범위'입니다. 단순히 width나 height 값 하나를 넘기는 게 아니라, 최소/최대 너비최소/최대 높이 (총 4개의 값)를 세트로 넘겨줍니다.


Tight Constraint

Constraint 중에서도 빡빡한 제약이 있습니다.
방금 본 코드를 다시 봅시다.

Container( 
  // 1번 (부모, 파란색)
  width: 300,
  height: 300,
  color: Colors.blue,
  child: Container(
    // 2번 (자식, 빨간색)
    width: 100,
    height: 100,
    color: Colors.red,
  ),
)

이 코드에서 1번 부모 컨테이너는 가로 300, 세로 300이라는 크기를 가집니다. 이때 플러터의 대원칙 "Constraints go down (제약은 아래로(부모에게서 자식으로) 내려간다)"이 발동합니다.

부모는 자식(2번 빨간 컨테이너)에게 이렇게 명령합니다.

"너의 최소 너비도 300이고, 최대 너비도 300이야. 최소 높이도 300이고, 최대 높이도 300이야. 무조건 내 크기에 꽉 맞추렴!?"

이것이 바로 Tight Constraint(빡빡한 제약)입니다.


Tight Constraint의 특징은 최소 크기 == 최대 크기 이며,

결과적으로 자식 위젯이 "저는 100x100으로 하고 싶은데요..."라고 코드(width: 100, height: 100)에 적어놔도, Tight Constraint 앞에서는 무시당합니다.

결국 자식인 빨간 컨테이너는 강제로 300x300 크기로 팽창하게 되고, 부모인 파란색을 완벽하게 덮어버려서 화면에는 거대한 빨간 네모만 보이게 되는 겁니다.


어떻게 해결할까요? - Loose Constraint

앞의 코드에 무언가를 추가해줍시다.


Container(
        width: 300,
        height: 300,
        color: Colors.blue,
        child: Center(
          // 중재자 등장!(Center)
          child: Container(width: 100, height: 100, color: Colors.red),
        ),
      ),

child의 Container를 Center로 감싸주면, 우리가 의도한 화면이 나오는 것을 보실 수 있습니다.



이 코드가 실행되면 내부적으로 이런 대화가 오간다고 생각하면 됩니다.


파란 컨테이너 (부모): 내 밑으로 무조건 300x300으로 맞춰라.. (Tight Constraint 내려보내는 중)

Center (중재자 역할): 네 제가 300x300만큼 다 차지할게요... (부모의 Tight Constraint를 자기가 흡수함)

\rightarrow 내 자식 빨간 컨테이너야... 너는 최소 크기 0부터 최대 크기 300 사이에서 네 맘대로 크기를 정해봐... (Loose Constraint를 걸어줌).
\rightarrow 네가 크기를 정해서 위로 알려주면(Sizes go up),
\rightarrow 내가 널 한가운데에 배치해 줄게~ (Parent sets position)

빨간 컨테이너 (자식): 그럼 전 제가 원래 원했던 100x100으로 합니다~


이렇게 Center 위젯이 tight constraint를 Loose Constraint(최소 크기 0, 최대 크기 300)으로 바꿔서 아래로 내려주었기 때문에, 마침내 파란색 배경을 둔 100x100짜리 빨간 상자가 한가운데에 예쁘게 나타나게 됩니다.




너무 방임하는 부모도 있어...


위의 파란 Container와 반대로 생각해 볼게요. 부모가 자식한테 '너 세로로 크고 싶은 만큼 무한대로 다 커도 돼!'라며 완전한 자유를 주면 어떻게 될까요?

웹이나 다른 프레임워크에서는 보통 자기 컨텐츠 크기만큼만 예쁘게 늘어나겠죠? 하지만 플러터는 사뭇 다릅니다. (초비상사태가 일어날지도?)


Flutter앱을 만들 때 제일 많이 쓰는 위젯이 뭘까요?
우선 모바일 디바이스는 세로로 긴 경우가 많으니 위에서 아래로 위젯을 쌓는 Column이랑, 카톡 창처럼 스크롤 되는 ListView를 많이 쓰겠군요.

그럼 자연스럽게 Column 안에 ListView를 넣는 코드를 짜게 되겠죠? 다들 이 코드가 어떻게 될지 머릿속으로 그려보시면서 화면을 봐주세요.

body: Column(
        // 여기서는 우리의 예상대로라면
        // "포켓몬 도감" 텍스트가 아래로 세로 스크롤되는 리스트 20개가 떠야 하겠군요.
        children: [
          const Text('포켓몬 도감', style: TextStyle(fontSize: 24)),
          ListView.builder(
            itemCount: 20,
            itemBuilder: (context, index) {
              return ListTile(title: Text('포켓몬 $index'));
            },
          ),
        ],
      ),

이 코드를 실행해보면


음??
그냥 화면 렌더링이 안되는 것 같습니다.


이유가 뭘까요?



Unbounded Constraint

내용을 조금 자세히 들여다보면,

Vertical viewport was given unbounded height.
Viewports expand in the scrolling direction to fill their container.

이렇게 써져있습니다.

해석하면, 세로 viewport는 무한한 높이를 가지는데, viewport는 부모를 가득 채우기 위해 팽창한다는 내용이네요.

즉, Column은 세로 한계가 없는 unbounded인데, ListView는 부모(Column)에 맞게 본인이 싹 늘어나서 그 크기만큼 채우려고 하기 때문에 플러터 엔진이 계산을 못 하고 터진 상황입니다.

결론적으로는 constraint끼리 충돌한 상황이라고 볼 수 있겠군요.
Column과 ListView가 무한대로 커지겠다고 싸우다가 화면이 날아간 상황입니다.


그러면 이 상황을 고쳐봅시다.
여기서도 어떠한 중재자가 필요한 상황입니다.


Bounded Constraint

Column(
        children: [
          const Text('포켓몬 도감', style: TextStyle(fontSize: 24)),
          // Expanded로 남은 공간의 한계 정해주기
          Expanded(
            child: ListView.builder(
              itemCount: 20,
              itemBuilder: (context, index) {
                return ListTile(title: Text('포켓몬 $index'));
              },
            ),
          ),
        ],
      ),

직전의 코드에서 ListView를 Expanded로 감싸줘봅시다.


이제 잘 보이네요!


수정된 코드처럼 Expanded를 넣었을 때 플러터 엔진 내부에서는 정확히 이런 3단계 과정이 일어납니다.

제일 먼저 고정 크기를 계산합니다.
부모인 Column은 먼저 자식들 중 Expanded가 아닌 애들('포켓몬 도감' Text 위젯)의 크기를 먼저 잽니다. 예시로, Text 위젯이 30px라고 가정합시다.

그런 다음, 남은 물리적 공간을 계산합니다.
Expanded가 Column에게 '스마트폰 화면 전체 높이에서 아까 Text가 쓴 30픽셀 빼면 얼만큼 남니?' 하고 물어본 뒤, '공간이 딱 600픽셀 남았네' 하고 계산을 끝냅니다.

마지막으로 Constraint를 바꿔치기 합니다.
이제 Expanded는 자식인 ListView에게 새롭게 Constraint를 내려줍니다. '너 무한대로 커질 생각 하지 말고, 내가 방금 계산해 보니까 화면에 딱 600픽셀 남았어. 네 최대 높이는 이제부터 무조건 600이야!'

결과적으로 Expanded는 중간에 껴서 부모의 무한한 제약(Unbounded)을 흡수한 다음, 물리적인 화면 크기를 계산해서 자식에게 명확한 한계가 있는 제약(Bounded Constraint)으로 번역해서 내려준 거예요.

이제 ListView는 자기가 600픽셀 안에서만 놀아야 한다는 걸 깨달았기 때문에, 해당 600픽셀 안에서 리스트를 예쁘게 스크롤해 주는 거죠.




Constraint를 다루는 위젯 (Constraint Control Widgets)

Flex 공간 할당 위젯 (남은 공간 분배)

Row나 Column 같은 Flex 기반 레이아웃 안에서 가용 공간(Available Space)을 어떻게 나눠 가질지 결정합니다.

  • Expanded (강제 공간 채움 - FlexFit.tight)

    • 동작 원리: 부모의 남은 공간을 모두 계산한 뒤, 자식에게 그 공간을 무조건 꽉 채우라는 강한 제약(Tight Constraint)을 내립니다.

    • 비교 지점: 웹의 flex-grow: 1과 유사하지만, 자식의 원래 크기를 완전히 무시하고 남은 공간 크기로 덮어씌운다는 점이 핵심입니다. 텍스트나 컨테이너가 화면을 뚫고 나가는 오버플로우를 막을 때 1순위로 사용합니다.

  • Flexible (최대 한계치 부여 - FlexFit.loose)

    • 동작 원리: 남은 공간을 계산하는 것까지는 Expanded와 같지만, 자식에게 "최대 이 공간까지만 커질 수 있어"라는 느슨한 제약(Loose Constraint)을 내립니다.

    • Expanded와의 차이점: 자식 위젯의 본래 크기가 남은 공간보다 작다면, 억지로 팽창시키지 않고 원래 크기만 유지하게 둡니다.


명시적 크기 제어 위젯 (고정 제약 주입)

부모가 내려주는 제약을 바탕으로, 개발자가 원하는 수치를 직접 개입시킬 때 사용합니다.

  • SizedBox (절대 크기 강제 - Fixed Tight Constraint)

    • 동작 원리: 자식에게 정확한 width와 height 값을 강제합니다. (자식이 없다면 그 크기만큼의 투명한 빈 공간이 되어 여백을 만드는 용도로 쓰입니다.)
  • ConstrainedBox (최소/최대 한계선 설정 - Bounded Constraint)

    • 동작 원리: BoxConstraints(minWidth, maxWidth, ....) 객체를 사용하여 자식이 가질 수 있는 크기의 범위를 디테일하게 제한합니다. 모바일 화면 크기에 따라 UI가 너무 커지거나 작아지는 것을 막을 때 유용합니다.

제약 완화 및 정렬 위젯 (중재자)

부모가 "내 크기에 무조건 맞춰!"라고 강제할 때(Tight Constraint), 그 압력을 풀어주는 역할을 합니다.

  • Center / Align (Constraint Loosening)

    • 동작 원리: 부모의 Tight Constraint를 자신이 대신 흡수하여 부모 크기만큼 팽창합니다. 그리고 자신의 자식에게는 "크기를 0부터 부모 크기 사이에서 자유롭게 정해"라는 Loose Constraint을 내려준 뒤, 결정된 크기를 바탕으로 Positioning만 수행합니다.


이제 직접 문제를 고쳐봅시다!


리스트가 잘 스크롤되니 이제 리스트 안의 항목(ListTile)을 좀 더 예쁜 카드 형태로 꾸며보겠습니다. 왼쪽에 포켓몬 사진을 두고, 오른쪽에 이름과 긴 설명을 넣으려고 코드를 짰습니다.

그런데 Hot Reload를 해보니... 화면 오른쪽이 공사장이 되어버렸습니다.



정확히는 텍스트가 오른쪽으로 523px만큼 넘쳐버린 상황인데요,
어떻게 해결할 수 있을까요?




어떻게 고칠까요?


정답은 텍스트들을 묶고 있던 Column 전체를 Expanded로 감싸는 거였습니다.

에러가 났던 이유는 Row가 가로 방향으로 무한한 자유(Unbounded width)를 주었기 때문입니다. 그래서 긴 텍스트가 화면 밖으로 그냥 질주해버려서 오버플로우가 난 거죠.

여기에 Expanded를 투입하면 어떻게 될까요?
아까 했던 것과 같이, Expanded가 Row에게 묻겠죠. 스마트폰 가로 넓이에서 저기 저 100픽셀짜리 이미지 빼면 얼만큼 남니?

계산이 끝나면 Expanded는 자식인 Column에게 명확한 한계(Bounded Constraint)를 내려줍니다. 너 최대 가로 길이가 280픽셀이니까 그 안에서만 놀아! 라고요.

그러면 Text는 이제 가로로 질주하는 걸 멈추고, 지정된 공간 안에서 예쁘게 아래로 줄바꿈(Wrap)을 하게 되는 겁니다. 세로(ListView)에서 일어났던 일이 가로(Row)에서도 똑같이 적용된다는 것, 이제 이해되시나요!?


읽어주셔서 감사합니다...🙂‍↕️

profile
ㅠㅠ

0개의 댓글