포스트

Flutter 전역 반응형 - 중간 크기와 공백 지대까지 버티는 설계

Flutter 전역 반응형 - 중간 크기와 공백 지대까지 버티는 설계

들어가며

플러터 앱을 개발하며 태블릿·폴더블·가로·세로 등 다양한 화면에 대응하고 있는데, 생각지 못한 화면에선 여지없이 어긋났다.
모든 경우의 수를 확인할 순 없는데, 확인하지 않은 화면이라고 UI/UX가 크게 달라져선 안 된다. 지금보다 적절한 반응형 세팅이 필요했다.

flutter_screenutil 기반 앱에서 여러 기기 화면에 대응하며 정리한 레이어 구조와 패턴, 그리고 거기서 만난 문제들을 적는다.

반응형의 두 갈래

먼저 큰 그림부터. 화면이 커졌을 때 할 수 있는 건 두 가지이다.

 스케일(scale)적응(adapt)
방식하나의 디자인을 비율로 늘림레이아웃 구조 자체를 바꿈
결과더 크게더 많이 (2단, 그리드, master-detail)
대표flutter_screenutilLayoutBuilder, breakpoint, flutter_adaptive_scaffold
장점단일 디자인을 픽셀 퍼펙트로 재현, 싸다화면을 제대로 활용, 네이티브 정석
한계큰 화면에서 그냥 커지기만 함디자인·구현 비용

둘은 배타적이지 않다. 보통 거친 단위는 적응, 그 안의 연속 변화는 스케일로 나눠 맡긴다. 적응이 어떤 디자인을 쓸까(폰용? 태블릿용?)를 정하고, 스케일이 그 디자인을 얼마나 늘릴까를 맡는다.

레이어

전역 반응형은 대략 이런 레이어로 쌓인다.

1
2
3
4
5
레이어 0. 기기 분류        - 지금 어떤 기기/방향인가 (프로필)
레이어 1. 스케일 기준      - 무엇을 기준으로 몇 배 할 것인가 (designSize)
레이어 2. 컨텐츠 폭        - 얼마나 넓어지게 둘 것인가 (maxWidth cap)
레이어 3. 스케일 ↔ 폭 연결 - cap 안에서 스케일이 cap을 존중하게 (.wc)
레이어 4. 텍스트           - 어디까지 방어할 것인가

레이어 0이 적응 축(어떤 디자인을 쓸지 고르는 것)이고, 레이어 1~4는 그렇게 고른 디자인을 스케일로 펼치며 생기는 문제들을 잡는다. 아래로 내려가며 각 레이어의 패턴과 함정을 본다.

레이어 0. 기기 분류 - 프로필

축을 분해하고, 판정은 한 곳에서

기기 분류는 2축 분해가 정석이다. 기기 등급과 방향을 곱해 프로필을 만든다.

1
device class (폰/태블릿)  ×  orientation (세로/가로)  =  4 프로필
1
2
3
4
5
6
7
8
9
10
11
12
13
enum AppDesignProfile { phonePortrait, phoneLandscape, tabletPortrait, tabletLandscape }

static AppDesignProfile designProfileForWindow(Size size) {
  final isTablet = size.shortestSide >= 600;   // ① 기기 등급
  if (!isTablet) {
    return size.width > size.height           // ② 방향
        ? AppDesignProfile.phoneLandscape
        : AppDesignProfile.phonePortrait;
  }
  return size.width > size.height
      ? AppDesignProfile.tabletLandscape
      : AppDesignProfile.tabletPortrait;
}
  • 600은 임의값이 아니라 안드로이드 sw600dp / Material window size class의 표준선이다.
  • shortestSide(짧은 변) 기준이라 기기를 돌려도 폰↔태블릿이 안 바뀌고 방향만 바뀐다.

패턴의 핵심은 판정을 한 곳에 가두는 것이다. 앱 전체가 AppBreakpoints.designProfile(context) 하나만 부르고, 나머지 코드는 프로필 enum만 받아서 분기한다. MediaQuery.of(context).size.width > 600 같은 판정이 여기저기 흩어지는 순간, 기준을 바꿀 때 손댈 곳이 걷잡을 수 없어진다.

언제 프로필을 늘리나

4개로 시작하고, 실제로 어색한 기기가 나타날 때 중간 tier(예: Material의 medium 600–840)를 추가하면 된다. 프로필을 늘리면 모든 분기 switch를 다 손봐야 하니 공짜가 아니다.

레이어 1. 스케일 기준 - designSize와 안전장치

프로필별 baseline

ScreenUtil은 “디자인 기준 대비 실제 화면 비율”로 모든 값을 스케일한다.

단위배율용도
.wscreenWidth / designWidth너비, 가로 거리
.hscreenHeight / designHeight높이, 세로 거리
.rmin(scaleW, scaleH)정사각·아이콘·radius
.spminTextAdapt: truemin(scaleW, scaleH)폰트

기준을 프로필별로 둔다.

1
2
3
4
phonePortrait   = Size(360, 738)
phoneLandscape  = Size(738, 360)
tabletPortrait  = Size(680, 1024)
tabletLandscape = Size(1024, 680)

이러면 각 프로필의 표준 기기에서 배율이 1에서 크게 벗어나지 않아 디자인 의도대로 나온다.

함정: 창 aspect가 baseline과 멀면 한 축이 폭주한다

PC 모드에서 창을 리사이즈하니 UI가 무너졌다.

1
2
세로 창 592×765 → scaleW 1.65 / scaleH 1.04   → 가로만 늘어남
가로 창 575×556 → scaleW 0.78 / scaleH 1.55   → 세로만 늘어남 + 글자 작아짐

원인은 명확했다. baseline은 0.49(홀쭉) 또는 2.05(납작)인데 리사이즈 창은 1:1 근처라 두 기준 어디에도 안 맞는다. 실기기(폰 0.46, 태블릿 1.6대)는 기준과 가까워 안 드러나던 문제가, 임의 창 크기에서 터진 것이다. 리사이즈 창만의 얘기도 아니다. 갤럭시 Z 폴드·플립은 접고 펴는 상태에 따라(폴드 내부는 정사각, 플립 커버는 홀쭉) 다양한 aspect가 나온다.

직접 못 고치면 “분모”를 고쳐라

.w에 clamp를 걸 순 없다. 전역 싱글턴인 데다 num 확장이기 때문이다. 대신 배율의 분모인 designSize를 조정해서 결과 배율을 밴드에 가둔다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static const double minDesignScale = 1.0;   // 디자인 기준보다 작게는 안 줄임
static const double maxDesignScale = 1.25;  // 최대 1.25배까지만

static Size designSizeForWindow(Size size) {
  final base = baseDesignSizeForWindow(size);   // 프로필별 고정 baseline (phonePortrait, tabletPortrait ...)
  return Size(
    _clampDesignDim(size.width, base.width),
    _clampDesignDim(size.height, base.height),
  );
}

static double _clampDesignDim(double window, double baseDesign) {
  if (window <= 0 || baseDesign <= 0) return baseDesign;
  final scale = (window / baseDesign).clamp(minDesignScale, maxDesignScale).toDouble();
  return window / scale;   // 배율이 밴드를 벗어나면 designSize를 조정해 상쇄
}
후 (밴드 1.0~1.25)
세로 592×765design 360×738 / scaleW 1.65 / scaleH 1.04design 473.6×738 / scaleW 1.25 / scaleH 1.04
가로 575×556design 738×360 / scaleW 0.78 / scaleH 1.55design 575×444.8 / scaleW 1.00 / scaleH 1.25
일반 폰·태블릿배율 ≈ 1.0~1.25무변화

이 패턴이 강력한 이유는 .w/.h/.r/.sp가 전부 designSize에서 파생된다는 데 있다. 분모 하나만 조정하면 모든 스케일 값이 자동으로 clamp를 통과한다. 호출부는 한 줄도 안 고친다.

일반화: 직접 손댈 수 없는 파생값이 있으면, 그 값이 계산되는 입력을 고쳐라. 라이브러리를 포크하거나 호출부 수백 곳을 고치는 것보다 거의 항상 싸다.

하한 1.0은 공짜가 아니다

상한 1.25는 “너무 커지지 마라”는 미관 문제지만, 하한은 성격이 다르다. 배율이 1 아래라는 건 창이 캔버스보다 좁다는 뜻이고, 줄이거나(글자·터치 타깃이 작아짐) 버티거나(컨텐츠가 넘침) 둘 중 하나다. 넘치는 건 Flexible·wrap·스크롤로 흡수하지만, 뭉개진 본문과 40dp 밑의 터치 타깃은 못 살린다. 하한 1.0은 회복 가능한 실패를 택하고 회복 불가능한 실패를 차단하는 선택이다. 밴드가 [0.8, 1.2] 같은 대칭이 아닌 것도 이 때문이다.

단, 이건 baseline과 커플링된 결정이다. phonePortrait baseline 360은 현역 폰의 사실상 최소폭이라 하한에 걸리는 창은 분할화면 조각이나 리사이즈 창 같은 예외뿐이다. baseline을 414(큰 폰)로 잡았다면 360 폰 전부가 하한에 걸려 일상적으로 넘쳤을 것이다. baseline을 프로필 범위의 하단에 두면 하한 1.0이 거의 공짜고, 중간에 두면 비싸진다.

레이어 2. 컨텐츠 폭 - maxWidth cap

왜 필요한가

스케일만으로 가면 태블릿에서 버튼 하나가 800px이 된다. 레이어 1의 clamp로 배율을 묶어도 소용없다. double.infinityExpanded로 깔린 풀폭 요소는 스케일과 무관하게 창 폭을 그대로 따라가기 때문이다. 그래서 컨텐츠 폭을 제한하고 가운데 정렬하는 max-width 컨테이너를 둔다.

1
2
3
4
5
6
double get contentMaxWidth => switch (this) {
      AppDesignProfile.phonePortrait   => double.infinity,  // 폰은 cap 무의미
      AppDesignProfile.phoneLandscape  => 600,
      AppDesignProfile.tabletPortrait  => 800,
      AppDesignProfile.tabletLandscape => 800,
    };

double.infinity로 “cap 없음”을 표현하면 분기 없이 자연스럽게 꺼진다.

단일 주입점

cap은 공용 스캐폴드 한 곳에서만 건다. 페이지마다 걸지 않는다.

  • cap이 유한하면 appbar까지 포함한 스캐폴드 전체를 Align(topCenter) + ConstrainedBox(maxWidth)로 중앙정렬한다. body만 cap하고 appbar를 full-width로 두면 좌우 라인이 어긋난다.
  • 스캐폴드 배경은 투명으로 두고 그 뒤에 전체 화면 배경 레이어를 깐다. 안 그러면 좁아진 양옆 gutter가 허옇게 빈다.
  • cap이 면 래핑을 아예 안 씌우고 기존 Scaffold를 그대로 반환한다. 레이아웃 무변화다.

주입점이 하나면 모든 페이지가 자동으로 대응되고, 정책을 바꿀 때도 한 곳만 고친다.

함정: drawer가 같이 갇힌다

스캐폴드 전체를 cap하면 그 안의 endDrawer도 갇힌다. scrim(어두운 오버레이)이 좁은 컬럼 안에서만 깔리고, drawer가 화면 끝이 아니라 컬럼 끝에서 슬라이드한다.

해결은 바깥에 전체화면 Scaffold를 하나 더 두고 거기에 drawer를 옮기는 것이다. 안쪽 스캐폴드는 투명 + drawer 없음으로 두고, appbar 버튼은 GlobalKey<ScaffoldState>로 바깥 drawer를 연다.

레이어 3. 스케일 ↔ 폭 연결 - .wc

cap을 걸어도 스케일은 cap을 모른다.

.w는 컨테이너를 전혀 모른다

1
2
3
4
// flutter_screenutil 내부
extension SizeExtension on num {
  double get w => ScreenUtil().setWidth(this); // 전역 싱글턴, BuildContext 없음
}

.w는 전역 싱글턴에서 기기 전체 창 폭을 읽는다. 위젯 트리의 ConstrainedBox를 알 방법이 없다. 그래서 컨텐츠를 600으로 묶어도 이렇게 된다.

기기 폭컨텐츠 박스scaleW (raw → clamp)20.w 실제값
1024600 (cap)1.0 → 1.020
1280600 (cap)1.25 → 1.25≈ 25
1600600 (cap)1.56 → 1.25≈ 25 (clamp 없으면 ≈31)

박스는 멈췄는데 그 안의 여백은 화면 따라 자란다. 레이어 1의 clamp가 폭주는 막아주지만(1600에서도 25에서 멈춤), cap으로 600에 묶인 박스 입장에선 여전히 25% 어긋난 값이다. clamp는 화면 대비 배율을 지키는 장치지 컨테이너를 아는 장치가 아니다.

degrade하는 보정 유틸

라이브러리를 못 고치니 cap 영역 전용 가로 스케일을 하나 더 만든다. 수식은 단순하다.

1
2
k  = min(screenWidth, maxWidth) / screenWidth      // <= 1
wc = value.w × k

숫자로 보면 이렇다. design 1024, cap 600 기준 (레이어 1 clamp 제외, .wc 자체의 동작만).

화면 폭scaleW20.wk20.wc
5000.499.81.09.8 (= .w)
600 (= cap)0.5911.71.011.7 (= .w)
10241.0200.5911.7
12801.25250.4711.7

.w는 화면 폭을 따라가고, .wc는 박스 폭을 따라간다. cap 아래에선 .w와 완전히 같고, cap을 넘는 순간부터 그 값에 얼어붙는다.

레이어 1 clamp(밴드 1.0~1.25)까지 얹으면 이렇게 된다.

화면 폭scaleW (raw → clamp)20.wk20.wc
5000.49 → 1.0 (하한)201.020 (= .w)
600 (= cap)0.59 → 1.0 (하한)201.020 (= .w)
10241.0200.5911.7
12801.25250.4711.7

cap 위는 동일하고 cap 아래만 다르다. 하한이 “디자인 기준보다 작게는 안 줄임”을 지키느라, 9.8로 쪼그라들 값을 20으로 받쳐준 것이다.

그래서 화면이 커질수록 .wc가 20 → 11.7로 내려가는 구간이 생긴다. 열화가 아니라 수렴이다. 600 박스에서 시안 비율(20/1024 = 1.95%)을 지키는 값은 11.7이고, 20은 하한이 가독성을 위해 떠받친 예외값이다. 화면이 커지면 하한이 손을 떼고 값은 제자리로 돌아온다. 작은 창의 절대 크기를 지키는 대가로 기기 간 여백 크기의 일관성을 내준 트레이드오프다.

두 장치가 서로를 모른 채 각자의 일만 해서 생기는 일이다. 아래 “두 clamp는 직교한다”에서 다시 본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ContentWidthScope extends InheritedWidget {
  final double widthFactor; // k

  static double factorFor({required double screenWidth, required double maxWidth}) {
    if (screenWidth <= 0 || maxWidth <= 0) return 1.0;
    return math.min(screenWidth, maxWidth) / screenWidth;
  }

  static double factorOf(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<ContentWidthScope>()?.widthFactor ?? 1.0;

  @override
  bool updateShouldNotify(ContentWidthScope old) => old.widthFactor != widthFactor;
}

extension CappedWidthExt on num {
  double wc(BuildContext context) => w * ContentWidthScope.factorOf(context);
}

이 패턴에서 진짜 중요한 건 수식이 아니라 ?? 1.0 이다.

  • cap이 없거나 화면이 cap보다 좁으면 k = 1.wc == .w
  • 스코프 밖에서 불러도 k = 1.wc == .w

즉 기존과 완전히 동일하게 동작하는 기본값을 가진다. 그래서 전역에 깔아도 폰·세로에선 아무것도 안 바뀌고, 공용 위젯에서도 안심하고 쓴다.

일반화: 전역에 새 유틸을 도입할 땐 safe degrade를 먼저 설계하라. “적용 안 된 곳에서 기존과 똑같다”가 보장되면 전면 마이그레이션 없이 점진 전환이 가능하다. 이게 안 되면 전역 도입은 전부-아니면-전무가 되고, 그럼 아무도 못 한다.

함정: context 게이트

.wc(context)InheritedWidget을 읽으니 스코프 하위 컨텍스트에서 불러야 한다.

1
2
3
4
5
Widget build(BuildContext context) {
  // ❌ 여기 context는 스코프보다 위 → k=1로 읽혀 cap이 안 먹음 (에러도 안 남!)
  final padding = EdgeInsets.symmetric(horizontal: 20.wc(context));
  return SomeScaffold(child: Column(children: [Padding(padding: padding, ...)]));
}

dependOnInheritedWidgetOfExactType은 주어진 context에서 위로만 훑는다. 그런데 스코프를 까는 건 SomeScaffold, 즉 이 build가 반환하는 트리 안이다. 호출 지점 기준으로 스코프는 조상이 아니라 자손이라 탐색에 안 걸린다.

1
2
3
4
build의 context          ← .wc 호출. 위로 훑어도 스코프가 없음 → k = 1
  └ SomeScaffold
      └ ContentWidthScope   ← 스코프는 호출 지점보다 아래
          └ Column ...
1
2
3
4
5
// ✅ Builder로 하위 컨텍스트 확보
child: Builder(builder: (context) {
  final padding = EdgeInsets.symmetric(horizontal: 20.wc(context));
  return Column(children: [Padding(padding: padding, ...)]);
}),

Builder는 자기 자리에서 새 context를 만든다. 그 context는 스코프 아래에 있으니 위로 훑으면 걸린다.

조용히 무효화되는 게 최악이다. 에러가 안 나니 “왜 cap이 안 먹지?”로 한참 헤맨다. 앞에서 칭찬한 ?? 1.0이 범인이다. 스코프를 못 찾을 때 던지게 했다면 이 함정은 없었겠지만, 그러면 공용 위젯에서 못 쓴다. safe degrade의 대가가 이 조용함이다. “값은 raw로 넘기고 최종 위젯에서 스케일 적용”이라는 원칙을 지키면 자연히 피해간다.

.hc는 왜 없나

cap은 본질적으로 가로(width) 한계다. .h는 화면 세로 기준이라 maxWidth와 무관하고, “높이가 cap을 넘었다”는 상황 자체가 없다. 개념적으로 불필요하다.

두 clamp는 직교한다

여기까지 오면 “자르는 게” 두 번 나온다. 다른 문제를 푸는 별개 장치다.

 ① designSize clamp.wc 계수 k
푸는 문제창 aspect가 baseline과 멀 때 배율 폭주컨텐츠 maxWidth cap을 가로가 안 따름
기준값window / designBaseline 을 밴드로min(screen, cap) / screen
적용 축가로·세로 둘 다가로만
영향 범위앱 전역 (.w/.h/.r/.sp 전부)스코프 하위, .wc 쓴 곳만
구현designSize(분모)를 조정.w 결과에 곱셈

둘은 대체가 아니라 순서대로 겹쳐 적용된다.

1
2
3
4
5
6
7
① designSize clamp →  scaleW 가 [1.0, 1.25] 로 묶임
       ↓
  .w = value × scaleW            ← 이미 clamp된 배율
       ↓
② .wc = .w × k                   ← cap 보정 한 번 더
       ↓
  최종 = value × clampedScaleW × k

①은 “배율이 미쳐 날뛰지 않게” 하는 전역 안전장치, ②는 “cap 안에서 가로가 cap을 존중하게” 하는 국소 보정이다. 푸는 문제가 직교(orthogonal)해서 하나만으론 다른 문제가 안 풀린다.

단, 직교하는 건 설계지 효과가 아니다. 최종값은 두 계수의 곱이라 서로 간섭한다. ①이 배율을 밴드에 고정하면 ②가 상쇄할 항이 사라지는데, 앞에서 본 .wc의 20 → 11.7이 그 흔적이다. 서로를 모르게 설계한 대가는 곱해지는 지점에서 나온다.

레이어 4. 텍스트 - 전역 방어 vs 국소 방어

.sp.w처럼 cap을 모른다. 그럼 .spc도 만들어야 할까? 대부분 안 만드는 게 낫다.

  1. 가독성은 컨테이너가 아니라 화면 기준이다. 박스가 600에서 멈췄다고 글자까지 줄이면 큰 화면에서 오히려 답답하다. 웹도 max-width 컨테이너를 걸지만 폰트는 독립 스케일한다.
  2. minTextAdapt: true면 이미 보수적이다. .sp 배율이 min(scaleW, scaleH)라 가로가 아무리 넓어도 세로가 상한 역할을 한다.
  3. plumbing 비용이 크다. .sp는 보통 스타일 함수 안에 16.sp로 박혀 있고 context가 없다. .spc(context)를 도입하려면 그 함수 시그니처를 전부 context화해야 한다.

그럼 안 깨지나? - 깨지는 곳은 정해져 있다

상황결과
줄바꿈 되는 텍스트가 커짐줄 수만 늘어남 → 스크롤이 흡수, 안 깨짐
고정 높이 박스 안 텍스트세로 잘림 가능
경직된 Row(Flexible 없음)가로 오버플로

전역 방어보다 국소 방어. 전역 장치는 깨지는 게 구조적으로 불가피할 때만 정당화된다. 텍스트는 그렇지 않다. 깨지는 유형이 특정되니, 거기만 Flexible/ellipsis로 막으면 된다.

개발 환경 팁

1) 임의 크기로 조절 가능한 환경

프로필 4개를 회전으로만 확인하면 경계와 중간 지대를 못 본다. 창을 자유롭게 늘였다 줄일 수 있어야 “정사각 창” 같은 게 드러난다.

안드로이드 태블릿의 PC 모드(삼성 DeX 등)를 쓰면 편리하다. 앱을 창 모드로 띄워서 크기를 다양하게 바꿔볼 수 있다.

2) 디버그 오버레이

환경이 문제를 드러내면, 다음은 원인을 찾을 차례다. 지금 무슨 프로필이고 배율이 얼마인지 바로 알 수 있어야 디버깅이 편하다.

1
2
3
4
5
TABLET · landscape
1227.6 × 984.0 dp
short 984.0 · dpr 1.38
cap 800 · design tabletLandscape
scaleW 1.20 · scaleH 1.25

MediaQuery에 의존하게 만들면 창 크기가 바뀔 때마다 자동 갱신된다. 리사이즈하면서 프로필이 튀는 순간, 배율이 밴드에 걸리는 순간이 실시간으로 보인다.

정리

  • 반응형은 적응(어떤 디자인을 쓸까)과 스케일(얼마나 늘릴까) 두 축이다. 배타적이지 않고, 보통 프로필로 나눈 뒤 그 안을 스케일로 메운다.
  • 기기 판정은 한 곳에 가둔다. 나머지 코드는 프로필 enum만 본다.
  • 배율이 폭주하는 건 창 aspect가 baseline과 멀기 때문이다. .w를 못 고치면 분모인 designSize를 고친다. 파생값 전부가 자동으로 따라온다.
  • cap은 공용 스캐폴드 한 곳에서만 건다. drawer처럼 전체화면이어야 하는 건 감싸는 레이어보다 바깥으로 빼야 한다.
  • .w는 cap을 모른다. .wc로 보정하되 ?? 1.0(safe degrade)이 핵심이다. 그래야 전면 마이그레이션 없이 점진 전환된다.
  • clamp는 두 개고 직교한다. ①은 전역 배율 안전장치, ②는 cap 안 가로 보정.
  • 텍스트는 전역 장치를 만들지 말고 깨지는 유형(경직된 Row)만 국소 방어한다.

참고

이 기사는 저작권자의 CC BY-NC 4.0 라이센스를 따릅니다.