[Flutter 공부] 반응형 UI 구현하기 - 다양한 화면 크기에 적응하는 앱 개발

반응형

안녕하세요. 오늘 주제는 바로 '반응형 UI'에 대해 다뤄볼게요. 디자이너가 주는 예쁜 UI를 구현하는 것보다 더 어려운 건 그 UI를 다양한 화면 크기에서 망가뜨리지 않는 일이더라구요. 폰, 태블릿, 데스크톱까지... 솔직히 처음엔 꽤 고생했습니다. 그래서 오늘은 제가 삽질하면서 배운 Flutter 반응형 UI 구현 방법을 정리해 봤어요.

1. 반응형 UI의 기본 원칙

반응형 UI를 만드는 건 결국 일관된 사용자 경험을 다양한 화면 크기에서 제공하는 문제입니다. 화면 크기가 바뀐다고 UI가 깨지거나 사용하기 어려워지면 안 되죠. 하지만 단순히 모든 화면에서 똑같이 보이게 하는 것도 해결책은 아닙니다. 폰에서는 컴팩트하게, 태블릿에서는 여유롭게, 데스크톱에서는 효율적으로 공간을 활용해야 합니다.

반응형 UI 구현의 핵심 원칙은 크게 다음과 같죠

  • 유동적 레이아웃(Fluid Layout): 픽셀 단위가 아닌 비율로 생각하기
  • 적응형 위젯(Adaptive Widgets): 화면 크기에 따라 크기와 배치가 조정되는 위젯 설계
  • 브레이크포인트(Breakpoints): 화면 크기에 따라 레이아웃을 변경하는 기준점 설정
  • 콘텐츠 우선순위(Content Priority): 작은 화면에서는 중요한 콘텐츠만 표시

Flutter에서는 이런 원칙들을 적용하기 위한 다양한 도구와 위젯을 제공합니다. 픽셀 단위로 하드코딩된 UI를 피하고, 상대적인 비율과 제약 조건을 활용해 유연한 레이아웃을 구성하는 것이 핵심입니다. 그럼 실제 Flutter에서 활용할 수 있는 반응형 위젯들을 살펴보겠습니다.

2. Flutter 반응형 위젯 활용하기

Flutter의 장점 중 하나는 반응형 디자인을 구현하기 위한 풍부한 위젯을 기본적으로 제공한다는 점입니다. 이런 위젯들을 조합하면 다양한 화면 크기에 유연하게 대응할 수 있습니다. 아래는 반응형 UI를 구현할 때 필수적으로 알아야 할 위젯들입니다.

위젯 용도 특징 사용 시 주의점
Expanded 공간을 비율로 나누기 flex 값으로 공간 분배 비율 지정 Row나 Column 내부에서만 사용 가능
Flexible 공간 할당과 크기 제한 fit 속성으로 자식 크기 조절 Expanded보다 더 세밀한 제어 가능
FractionallySizedBox 부모 크기의 비율로 크기 지정 widthFactor, heightFactor로 비율 지정 부모 위젯 크기가 명확해야 정확히 동작
AspectRatio 가로세로 비율 유지 비율 유지하면서 가능한 최대 크기로 조정 부모 위젯의 제약에 따라 실제 비율이 달라질 수 있음
MediaQuery 화면 크기 정보 가져오기 현재 기기의 화면 크기, 방향 등 제공 빌드 메서드에서 자주 호출하면 성능 저하 가능
LayoutBuilder 부모 위젯 제약 조건에 따른 빌드 부모의 BoxConstraints를 기반으로 위젯 빌드 부모 제약이 변경될 때마다 다시 빌드됨

이 위젯들은 개별적으로 유용하지만, 실제로는 이들을 조합해서 사용하는 경우가 많습니다. 예를 들어, MediaQuery로 화면 너비를 확인한 후 조건에 따라 다른 레이아웃을 반환하고, 그 내부에서는 ExpandedFlexible을 사용해 요소들의 크기를 조절하는 방식으로 복합적인 레이아웃을 구성합니다.

3. 레이아웃 전략: Flex, Expanded, Constraints

Flutter에서 반응형 레이아웃을 구현하는 핵심은 위젯들의 크기를 하드코딩하지 않고, 유연하게 조절되도록 만드는 것입니다. Row와 Column을 기반으로 Flex 레이아웃을 구성하고, Expanded와 Flexible을 활용하는 전략을 알아보겠습니다.

Flex 레이아웃 마스터하기

Row와 Column은 모두 Flex 클래스를 상속받은 위젯으로, CSS의 flexbox와 유사한 개념입니다. 자식 위젯들을 배치할 때 유연한 공간 분배가 가능합니다.

  1. mainAxisAlignment: 주축(Row에서는 가로, Column에서는 세로)을 따라 자식 위젯을 어떻게 배치할지 결정합니다.
    • spaceBetween, spaceEvenly, spaceAround 등의 옵션으로 공간 분배 방식 지정
  2. crossAxisAlignment: 교차축(Row에서는 세로, Column에서는 가로)을 따라 자식 위젯을 어떻게 배치할지 결정합니다.
    • start, center, end, stretch 등의 옵션 제공
  3. Expanded와 Flexible: 자식 위젯이 차지할 공간의 비율을 지정합니다.
    • Expanded는 남은 모든 공간을 차지 (flex 값으로 비율 조정)
    • Flexible은 필요한 만큼만 공간을 차지 (FlexFit.loose) 하거나 전체 공간을 차지 (FlexFit.tight)

Constraints 이해하기

Flutter의 레이아웃 시스템은 제약 조건(Constraints) 기반으로 작동합니다. 부모 위젯이 자식 위젯에게 특정 제약 조건을 전달하고, 자식 위젯은 그 제약 안에서 자신의 크기를 결정합니다. 이를 이해하는 것이 반응형 UI를 구현하는 데 매우 중요합니다.

  • Tight Constraints: 위젯이 정확한 크기를 가져야 함 (예: Container의 width와 height를 지정)
  • Loose Constraints: 위젯이 특정 최대 크기까지 자유롭게 조절 가능 (예: Center 위젯 내부)
  • Unbounded Constraints: 위젯이 원하는 크기를 가질 수 있음 (예: ListView 내부의 세로 방향)

ConstrainedBox, UnconstrainedBox, SizedBox 등의 위젯을 활용하면 자식 위젯에게 전달되는 제약 조건을 조절할 수 있습니다. 반응형 UI 구현에 있어 제약 조건(Constraints)을 이해하는 것은 매우 중요합니다. Flutter의 UI 시스템은 기본적으로 부모 위젯이 자식 위젯에게 특정 제약 조건을 전달하고, 자식 위젯은 그 제약 안에서 자신의 크기를 결정하는 방식으로 동작합니다. 따라서 반응형 UI를 구현할 때는 위젯들이 다양한 제약 조건 하에서 어떻게 동작하는지 이해하고 활용해야 합니다.

이러한 제약 기반 레이아웃은 부모 위젯의 크기가 변할 때 자식 위젯들이 자연스럽게 적응할 수 있게 해줍니다. 고정된 크기를 지정하는 대신 부모의 공간을 어떻게 차지할지에 집중하면, 화면 크기에 상관없이 잘 작동하는 UI를 만들 수 있습니다. 다음 섹션에서는 MediaQuery와 LayoutBuilder를 활용해 현재 화면 크기를 감지하고 이에 따라 UI를 동적으로 조정하는 방법을 알아보겠습니다.

📝 TIP: 픽셀 단위 피하기

Flutter에서 반응형 UI를 구현할 때 가장 흔한 실수는 픽셀 단위로 위젯 크기를 고정하는 것입니다. Container(width: 300, height: 200)과 같이 하드코딩된 값은 특정 디바이스에서는 잘 보이지만, 다른 디바이스에서는 문제가 될 수 있습니다.

대신 MediaQuery.of(context).size.width * 0.8처럼 화면 크기에 비례한 값이나, Expanded, Flexible 등을 활용해 비율 기반으로 레이아웃을 구성하세요. 그래야 다양한 화면 크기에서 자연스럽게 작동합니다.

4. MediaQuery와 LayoutBuilder 마스터하기

Flutter에서 진정한 반응형 UI를 구현하려면 현재 기기의 화면 크기와 방향을 알아야 합니다. 이를 위해 MediaQuery와 LayoutBuilder가 필수적입니다. 두 도구 모두 화면 크기에 따라 다른 레이아웃을 구성할 수 있게 해주지만, 약간 다른 용도로 사용됩니다.

MediaQuery 효과적으로 사용하기

MediaQuery는 현재 기기의 화면 크기, 방향, 패딩, 밝기 모드 등 다양한 정보를 제공합니다. 주로 화면 너비에 따라 다른 레이아웃을 표시하거나, 화면 회전 시 UI를 재배치하는 데 사용됩니다.

MediaQuery를 사용할 때 주의할 점은 성능입니다. 빌드 메서드 내에서 MediaQuery.of(context)를 반복적으로 호출하면 비효율적이죠. 대신 한 번만 호출하여 변수에 저장하고 재사용하는 것이 좋습니다:

@override
Widget build(BuildContext context) {
  // MediaQuery를 한 번만 호출하여 변수에 저장
  final mediaQuery = MediaQuery.of(context);
  final screenWidth = mediaQuery.size.width;
  final screenHeight = mediaQuery.size.height;
  final isPortrait = mediaQuery.orientation == Orientation.portrait;
  
  // 화면 크기에 따라 다른 레이아웃 구성
  if (screenWidth < 600) {
    return _buildMobileLayout();
  } else if (screenWidth < 900) {
    return _buildTabletLayout();
  } else {
    return _buildDesktopLayout();
  }
}

브레이크포인트를 상수로 정의해두면 코드의 가독성을 높이고 유지보수를 쉽게 할 수 있습니다:

// 앱 전체에서 사용할 브레이크포인트 정의
class Breakpoints {
  static const double mobile = 600;
  static const double tablet = 900;
  static const double desktop = 1200;
}

LayoutBuilder 활용하기

LayoutBuilder는 부모 위젯이 제공하는 제약 조건(constraints)에 따라 위젯을 다르게 빌드할 수 있게 해줍니다. MediaQuery가 전체 화면 크기를 기준으로 한다면, LayoutBuilder는 부모 위젯의 크기를 기준으로 합니다. 이는 재사용 가능한 위젯을 만들 때 특히 유용합니다.

@override
Widget build(BuildContext context) {
  return LayoutBuilder(
    builder: (context, constraints) {
      // constraints.maxWidth는 부모 위젯이 제공하는 최대 너비
      if (constraints.maxWidth < 300) {
        return _buildCompactLayout();
      } else {
        return _buildExpandedLayout();
      }
    },
  );
}

LayoutBuilder는 MediaQuery와 달리 위젯 트리의 어느 위치에서든 사용할 수 있어 유연합니다. 예를 들어, 화면에 여러 카드가 있고 각 카드 내부의 레이아웃을 카드 크기에 따라 조정하고 싶다면 LayoutBuilder를 사용하는 것이 적합합니다.

MediaQuery와 LayoutBuilder를 적절히 조합하면 어떤 화면 크기에서도 최적의 사용자 경험을 제공하는 반응형 UI를 구현할 수 있습니다. 일반적으로 앱 수준의 레이아웃 전환은 MediaQuery를, 컴포넌트 수준의 세부 조정은 LayoutBuilder를 사용하는 전략이 효과적입니다.

5. 효과적인 반응형 디자인 패턴

반응형 앱을 개발할 때는 몇 가지 검증된 디자인 패턴을 활용하면 효율적입니다. 이러한 패턴은 다양한 화면 크기에서 일관된 사용자 경험을 제공하는 데 도움이 됩니다.

패턴 설명 적합한 상황
Column/Row 전환 화면 크기에 따라 세로 배치(Column)에서 가로 배치(Row)로 전환 콘텐츠가 제한된 수의 주요 요소로 구성된 경우
Drawer/Side 메뉴 전환 모바일에서는 Drawer, 넓은 화면에서는 항상 표시되는 사이드 메뉴로 전환 네비게이션이 많은 앱, 대시보드 형태의 앱
그리드 열 수 조정 화면 너비에 따라 GridView의 열 수를 동적으로 조정 이미지 갤러리, 상품 목록, 대시보드 타일
Master-Detail 패턴 작은 화면에서는 별도 화면으로, 큰 화면에서는 분할 뷰로 표시 이메일 앱, 설정 화면, 목록-상세 구조의 앱
컨텐츠 우선순위 조정 작은 화면에서는 중요한 콘텐츠만 표시하고, 큰 화면에서는 추가 정보 표시 정보가 많은 대시보드, 복잡한 폼

반응형 그리드 구현 예시

화면 크기에 따라 그리드의 열 수를 조정하는 것은 자주 사용되는 패턴입니다. 이렇게 하면 콘텐츠 항목이 항상 적절한 크기를 유지할 수 있습니다:

Widget build(BuildContext context) {
  return LayoutBuilder(
    builder: (context, constraints) {
      // 화면 너비에 따라 열 수 계산
      final crossAxisCount = _calculateCrossAxisCount(constraints.maxWidth);
      
      return GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: crossAxisCount,
          childAspectRatio: 1.5,
          mainAxisSpacing: 10,
          crossAxisSpacing: 10,
        ),
        itemCount: items.length,
        itemBuilder: (context, index) => ItemCard(item: items[index]),
      );
    },
  );
}

int _calculateCrossAxisCount(double width) {
  // 원하는 최소 카드 너비를 기준으로 열 수 계산
  const double minCardWidth = 200;
  
  if (width < 600) {
    return 1; // 모바일 화면에서는 1열
  } else if (width < 900) {
    return 2; // 태블릿 화면에서는 2열
  } else if (width < 1200) {
    return 3; // 작은 데스크톱 화면에서는 3열
  } else {
    return 4; // 큰 데스크톱 화면에서는 4열
  }
}

Master-Detail 패턴 구현

Master-Detail 패턴은 앱의 복잡성을 관리하면서도 화면 공간을 효율적으로 활용할 수 있게 해줍니다. 작은 화면에서는 목록과 상세 화면이 별도 페이지로 표시되고, 큰 화면에서는 나란히 표시됩니다:

@override
Widget build(BuildContext context) {
  final mediaQuery = MediaQuery.of(context);
  final bool isWideScreen = mediaQuery.size.width > 900;
  
  // 작은 화면에서는 일반 목록만 표시
  if (!isWideScreen) {
    return Scaffold(
      appBar: AppBar(title: Text('Items')),
      body: ItemsList(
        items: items,
        onItemSelected: (item) {
          // 아이템 선택 시 상세 화면으로 네비게이션
          Navigator.of(context).push(
            MaterialPageRoute(
              builder: (context) => ItemDetailScreen(item: item),
            ),
          );
        },
      ),
    );
  }
  
  // 넓은 화면에서는 분할 뷰로 표시
  return Scaffold(
    appBar: AppBar(title: Text('Items')),
    body: Row(
      children: [
        // 왼쪽 1/3에 목록 표시
        Expanded(
          flex: 1,
          child: ItemsList(
            items: items,
            selectedItem: selectedItem,
            onItemSelected: (item) {
              setState(() {
                selectedItem = item;
              });
            },
          ),
        ),
        // 수직 구분선
        VerticalDivider(width: 1, thickness: 1),
        // 오른쪽 2/3에 상세 정보 표시
        Expanded(
          flex: 2,
          child: selectedItem != null
              ? ItemDetail(item: selectedItem!)
              : Center(child: Text('선택된 아이템이 없습니다')),
        ),
      ],
    ),
  );
}

6. 실전 사례 분석

지금까지 배운 내용을 토대로 실제 애플리케이션의 반응형 UI를 구현하는 사례를 살펴보겠습니다. 대표적인 레이아웃 유형인 대시보드 형태의 앱을 예로 들어 설명하겠습니다.

대시보드 앱의 반응형 UI 구현

일반적인 대시보드 앱은 다음과 같은 구성 요소를 가집니다:

  1. 네비게이션 메뉴 (사이드바 또는 드로어)
  2. 상단 앱바 (검색, 알림, 사용자 프로필)
  3. 주요 컨텐츠 영역 (위젯/카드 그리드)
  4. 세부 정보 패널 (선택적)

이 구성 요소들을 화면 크기에 따라 다르게 배치하는 것이 반응형 UI의 핵심입니다:

class DashboardScreen extends StatefulWidget {
  @override
  _DashboardScreenState createState() => _DashboardScreenState();
}

class _DashboardScreenState extends State {
  @override
  Widget build(BuildContext context) {
    // 화면 크기 정보 한 번만 가져오기
    final mediaQuery = MediaQuery.of(context);
    final bool isDesktop = mediaQuery.size.width >= 1100;
    final bool isTablet = mediaQuery.size.width >= 650 && mediaQuery.size.width < 1100;
    final bool isMobile = mediaQuery.size.width < 650;

    return Scaffold(
      // 모바일에서만 드로어 메뉴 표시
      drawer: isMobile ? NavigationDrawer() : null,
      
      // 앱바 - 모든 화면 크기에서 표시
      appBar: AppBar(
        title: Text('대시보드'),
        // 모바일에서만 드로어 아이콘 표시
        leading: isMobile ? null : IconButton(
          icon: Icon(Icons.menu),
          onPressed: () {}, // 데스크톱/태블릿에서는 사이드바가 항상 표시되므로 불필요
        ),
        actions: [
          // 태블릿/데스크톱에서는 검색창 표시
          if (!isMobile) Expanded(child: SearchBar()),
          IconButton(icon: Icon(Icons.notifications), onPressed: () {}),
          IconButton(icon: Icon(Icons.account_circle), onPressed: () {}),
        ],
      ),
      
      // 메인 콘텐츠
      body: Row(
        children: [
          // 태블릿/데스크톱에서만 사이드바 표시
          if (!isMobile)
            NavigationSidebar(isExpanded: isDesktop),
            
          // 메인 콘텐츠 영역
          Expanded(
            child: Column(
              children: [
                // 모바일에서는 검색창을 여기에 배치
                if (isMobile) SearchBar(),
                
                // 대시보드 카드 그리드
                Expanded(
                  child: DashboardGrid(
                    // 화면 크기에 따라 열 수 조정
                    columns: isDesktop ? 4 : (isTablet ? 2 : 1),
                  ),
                ),
              ],
            ),
          ),
          
          // 데스크톱에서만 세부 정보 패널 표시
          if (isDesktop && selectedItem != null)
            DetailPanel(item: selectedItem),
        ],
      ),
    );
  }
}

위 코드에서는 화면 크기에 따라 다음과 같은 반응형 전략을 적용했습니다:

  • 네비게이션: 모바일에서는 드로어(숨겨진 메뉴), 태블릿/데스크톱에서는 사이드바(항상 표시)
  • 검색창: 모바일에서는 본문 상단에, 태블릿/데스크톱에서는 앱바에 통합
  • 카드 그리드: 화면 크기에 따라 열 수 조정(모바일 1열, 태블릿 2열, 데스크톱 4열)
  • 세부 정보 패널: 모바일과 태블릿에서는 상세 정보를 별도 화면으로, 데스크톱에서는 오른쪽 패널로 표시

이처럼 다양한 화면 크기에 따라 적절한 UI 패턴을 선택하면, 사용자는 어떤 기기에서도 최적화된 경험을 얻을 수 있습니다. 모바일에서는 화면 공간을 최대한 효율적으로 사용하고, 데스크톱에서는 넓은 화면을 활용해 더 많은 정보와 기능을 제공하는 전략이 중요합니다.

반응형 UI 테스트하기

반응형 UI를 개발할 때는 다양한 화면 크기와 방향에서 지속적으로 테스트하는 것이 중요합니다. Flutter는 이를 위한 몇 가지 유용한 도구를 제공합니다:

  • Flutter DevTools: 다양한 화면 크기를 시뮬레이션하고 UI가 어떻게 반응하는지 확인할 수 있습니다.
  • 디바이스 회전: 에뮬레이터나 실제 기기에서 화면을 회전시켜 세로 모드와 가로 모드에서 UI를 테스트합니다.
  • 다양한 기기 테스트: 가능한 한 다양한 화면 크기의 기기(폰, 태블릿, 데스크톱)에서 테스트합니다.

다음 섹션에서는 이러한 반응형 UI 기법들을 활용한 실제 코드 예제를 살펴보고, 실전에서 적용할 수 있는 팁들을 알아보겠습니다.

📝 TIP: OrientationBuilder 활용하기

화면 방향(가로/세로)에 따라 UI를 다르게 구성해야 할 때는 OrientationBuilder 위젯을 활용하세요. 이 위젯은 화면 방향이 바뀔 때마다 UI를 다시 빌드합니다.

OrientationBuilder(
  builder: (context, orientation) {
    return orientation == Orientation.portrait
        ? PortraitLayout()
        : LandscapeLayout();
  },
)

이 방법은 MediaQuery.of(context).orientation을 사용하는 것보다 성능상 더 효율적일 수 있습니다.

📝 TIP: 위젯 추출로 코드 재사용성 높이기

반응형 UI를 구현할 때 코드가 복잡해지는 경향이 있습니다. 조건문이 여러 개 중첩되면 가독성이 떨어지고 유지보수가 어려워집니다. 이런 문제를 해결하기 위해 위젯을 적절히 분리하고 추출하는 것이 좋습니다.

화면 크기별로 별도의 위젯 클래스를 만들거나, 공통 로직을 별도의 위젯으로 추출하면 코드가 훨씬 깔끔해집니다. 예를 들어 ResponsiveBuilder와 같은 헬퍼 위젯을 만들어 사용하는 것도 좋은 방법입니다.

완전한 반응형 레이아웃 예제 코드

이제 지금까지 다룬 내용을 종합하여 실용적인 반응형 UI의 완전한 구현 예제를 살펴보겠습니다. 아래 코드는 재사용 가능한 헬퍼 클래스와 위젯을 활용해 깔끔하게 반응형 UI를 구현하는 방법을 보여줍니다.

// 1. 디바이스 크기에 따른 반응형 레이아웃 헬퍼 클래스
import 'package:flutter/material.dart';

// 반응형 브레이크포인트 상수 정의
class ScreenBreakpoints {
  static const double mobile = 650;
  static const double tablet = 1100;
  static const double desktop = 1200;
}

// 디바이스 타입 열거형
enum DeviceType { mobile, tablet, desktop }

// 반응형 레이아웃 헬퍼 클래스
class ResponsiveHelper {
  // 현재 디바이스 타입 반환
  static DeviceType getDeviceType(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    
    if (width < ScreenBreakpoints.mobile) {
      return DeviceType.mobile;
    } else if (width < ScreenBreakpoints.tablet) {
      return DeviceType.tablet;
    } else {
      return DeviceType.desktop;
    }
  }
  
  // 현재 디바이스가 모바일인지 확인
  static bool isMobile(BuildContext context) {
    return getDeviceType(context) == DeviceType.mobile;
  }
  
  // 현재 디바이스가 태블릿인지 확인
  static bool isTablet(BuildContext context) {
    return getDeviceType(context) == DeviceType.tablet;
  }
  
  // 현재 디바이스가 데스크톱인지 확인
  static bool isDesktop(BuildContext context) {
    return getDeviceType(context) == DeviceType.desktop;
  }
  
  // 디바이스 타입에 따라 값 반환하는 유틸리티 메서드
  static T deviceValue({
    required BuildContext context,
    required T mobile,
    T? tablet,
    required T desktop,
  }) {
    final deviceType = getDeviceType(context);
    
    switch (deviceType) {
      case DeviceType.mobile:
        return mobile;
      case DeviceType.tablet:
        return tablet ?? desktop;
      case DeviceType.desktop:
        return desktop;
    }
  }
}

// 2. 반응형 레이아웃 빌더 위젯
class ResponsiveLayoutBuilder extends StatelessWidget {
  final Widget Function(BuildContext, DeviceType) builder;

  const ResponsiveLayoutBuilder({
    Key? key,
    required this.builder,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        // 부모 위젯 제약에 따라 디바이스 타입 결정
        DeviceType deviceType;
        
        if (constraints.maxWidth < ScreenBreakpoints.mobile) {
          deviceType = DeviceType.mobile;
        } else if (constraints.maxWidth < ScreenBreakpoints.tablet) {
          deviceType = DeviceType.tablet;
        } else {
          deviceType = DeviceType.desktop;
        }
        
        return builder(context, deviceType);
      },
    );
  }
}

// 3. 다양한 화면 크기별 대시보드 레이아웃 구현
class DashboardScreen extends StatelessWidget {
  final List items;

  const DashboardScreen({
    Key? key,
    required this.items,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('반응형 대시보드'),
        actions: [
          // 모바일이 아닌 경우에만 앱바에 검색창 추가
          if (!ResponsiveHelper.isMobile(context))
            Expanded(
              child: Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16.0),
                child: TextField(
                  decoration: InputDecoration(
                    hintText: '검색...',
                    border: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(8.0),
                    ),
                    contentPadding: EdgeInsets.symmetric(vertical: 8.0),
                    prefixIcon: Icon(Icons.search),
                    filled: true,
                    fillColor: Colors.white,
                  ),
                ),
              ),
            ),
          IconButton(
            icon: Icon(Icons.notifications),
            onPressed: () {},
          ),
          IconButton(
            icon: Icon(Icons.account_circle),
            onPressed: () {},
          ),
        ],
      ),
      // 모바일에서만 드로어 메뉴 활성화
      drawer: ResponsiveHelper.isMobile(context) ? NavigationDrawer() : null,
      body: Row(
        children: [
          // 태블릿/데스크톱에서만 사이드바 표시
          if (!ResponsiveHelper.isMobile(context))
            NavigationSidebar(
              // 데스크톱에서는 확장된 사이드바, 태블릿에서는 축소된 사이드바
              isExpanded: ResponsiveHelper.isDesktop(context),
            ),
          
          // 메인 콘텐츠 영역
          Expanded(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // 모바일에서만 검색창 표시
                  if (ResponsiveHelper.isMobile(context))
                    Padding(
                      padding: const EdgeInsets.only(bottom: 16.0),
                      child: TextField(
                        decoration: InputDecoration(
                          hintText: '검색...',
                          border: OutlineInputBorder(),
                          prefixIcon: Icon(Icons.search),
                        ),
                      ),
                    ),
                  
                  // 타이틀 섹션
                  Text(
                    '대시보드',
                    style: Theme.of(context).textTheme.headlineMedium,
                  ),
                  SizedBox(height: 8),
                  Text(
                    '최근 업데이트: 2025년 3월 16일',
                    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                      color: Colors.grey[600],
                    ),
                  ),
                  SizedBox(height: 24),
                  
                  // 대시보드 그리드 (화면 크기에 따라 열 수 조정)
                  Expanded(
                    child: ResponsiveLayoutBuilder(
                      builder: (context, deviceType) {
                        // 디바이스 타입에 따라 적절한 열 수 반환
                        int columns;
                        switch (deviceType) {
                          case DeviceType.mobile:
                            columns = 1;
                            break;
                          case DeviceType.tablet:
                            columns = 2;
                            break;
                          case DeviceType.desktop:
                            columns = 3;
                            break;
                        }
                        
                        return GridView.builder(
                          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                            crossAxisCount: columns,
                            childAspectRatio: 1.5,
                            crossAxisSpacing: 16,
                            mainAxisSpacing: 16,
                          ),
                          itemCount: items.length,
                          itemBuilder: (context, index) {
                            return DashboardCard(item: items[index]);
                          },
                        );
                      },
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// 4. 실제 사용 예시
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter 반응형 UI',
      theme: ThemeData(
        primarySwatch: Colors.deepPurple,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: DashboardScreen(
        items: [
          DashboardItem(title: '매출', value: '₩120,450', icon: Icons.attach_money),
          DashboardItem(title: '사용자', value: '1,240', icon: Icons.people),
          DashboardItem(title: '주문', value: '356', icon: Icons.shopping_cart),
          DashboardItem(title: '방문자', value: '4,790', icon: Icons.visibility),
          DashboardItem(title: '상품', value: '98', icon: Icons.inventory),
          DashboardItem(title: '리뷰', value: '25', icon: Icons.star),
        ],
      ),
    );
  }
}

// 5. 필요한 추가 위젯들 (간략화)
class NavigationDrawer extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: ListView(
        padding: EdgeInsets.zero,
        children: [
          UserAccountsDrawerHeader(
            accountName: Text('Jane Doe'),
            accountEmail: Text('jane.doe@example.com'),
            currentAccountPicture: CircleAvatar(
              child: Text('JD'),
            ),
          ),
          // 메뉴 아이템들...
        ],
      ),
    );
  }
}

class NavigationSidebar extends StatelessWidget {
  final bool isExpanded;
  
  const NavigationSidebar({
    Key? key,
    required this.isExpanded,
  }) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return Container(
      width: isExpanded ? 250 : 80,
      color: Theme.of(context).primaryColor,
      child: Column(
        children: [
          // 사이드바 내용...
        ],
      ),
    );
  }
}

class DashboardItem {
  final String title;
  final String value;
  final IconData icon;
  
  DashboardItem({
    required this.title,
    required this.value,
    required this.icon,
  });
}

class DashboardCard extends StatelessWidget {
  final DashboardItem item;
  
  const DashboardCard({
    Key? key,
    required this.item,
  }) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(item.icon, size: 48, color: Theme.of(context).primaryColor),
            SizedBox(height: 8),
            Text(
              item.title,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            SizedBox(height: 4),
            Text(
              item.value,
              style: Theme.of(context).textTheme.headlineSmall?.copyWith(
                fontWeight: FontWeight.bold,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

위 코드에서 주목할 만한 점은 다음과 같습니다:

  1. 헬퍼 클래스 활용: 반복적인 코드를 줄이기 위해 ResponsiveHelper 클래스를 만들어 화면 크기 체크 로직을 캡슐화했습니다.
  2. 레이아웃 빌더 위젯: ResponsiveLayoutBuilder 위젯을 만들어 화면 크기에 따른 UI 분기 처리를 깔끔하게 구현했습니다.
  3. 조건부 UI 요소: if 조건을 활용해 화면 크기에 따라 UI 요소를 선택적으로 표시합니다.
  4. 동적 그리드 레이아웃: 화면 크기에 따라 그리드의 열 수를 동적으로 조정하는 패턴을 구현했습니다.
⚠️ 성능 주의사항

반응형 UI를 구현할 때는 성능에도 주의해야 합니다. MediaQuery.of(context)를 빌드 메서드 내에서 여러 번 호출하면 불필요한 계산이 반복될 수 있습니다. 위 예제에서처럼 헬퍼 메서드를 활용하거나, 상태 관리 솔루션(Provider, Riverpod, GetX 등)을 사용해 화면 크기 정보를 효율적으로 관리하는 것이 좋습니다.

마치며

이제 Flutter로 반응형 UI를 구현하는 핵심 원칙과 기법들을 알아봤습니다. 처음에는 반응형 UI 구현이 복잡하게 느껴질 수 있습니다. 사실 저도 처음에는 그랬으니까요. 하지만 몇 가지 기본 원칙만 이해하면 생각보다 쉽게 구현할 수 있습니다.

결국 완벽한 반응형 UI를 만들기 위해서는 다음 세 가지가 핵심입니다:

  1. 유연한 레이아웃: 고정 크기보다는 Expanded, Flexible, FractionallySizedBox 등을 활용해 비율 기반 레이아웃을 구현합니다.
  2. 화면 크기 감지: MediaQuery와 LayoutBuilder를 효과적으로 활용해 현재 화면 크기에 맞는 UI를 구성합니다.
  3. 코드 구조화: 헬퍼 클래스와 위젯을 만들어 반응형 로직을 체계적으로 관리하면 복잡도를 낮출 수 있습니다.

실제 개발하다 보면 요구 사항에 따라 더 복잡한 반응형 패턴이 필요할 수도 있습니다. 그럴 때는 responsive_framework, flutter_screenutil 같은 패키지의 도움을 받는 것도 좋은 방법입니다.

그리고 마지막으로 가장 중요한 것은... 테스트입니다. 다양한 화면 크기와 방향에서 앱을 직접 테스트해보세요. Flutter DevTools의 기기 미리보기 기능을 활용하거나, 실제 여러 기기에서 테스트해보면 놓친 부분을 발견할 수 있습니다. 저도 매번 실수하지만, 개발 시간의 절반은 테스트에 투자하는 게 결국 더 빠른 길입니다.

Flutter로 반응형 UI를 구현하는 여정이 성공적이길 바랍니다. 별거 아닌 것 같지만 이런 디테일이 결국 사용자 경험의 품질을 결정한다는 걸 기억하세요!

Designed by JB FACTORY