Flutter 전역 반응형 - 중간 크기와 공백 지대까지 버티는 설계
들어가며
플러터 앱을 개발하며 태블릿·폴더블·가로·세로 등 다양한 화면에 대응하고 있는데, 생각지 못한 화면에선 여지없이 어긋났다.
모든 경우의 수를 확인할 순 없는데, 확인하지 않은 화면이라고 UI/UX가 크게 달라져선 안 된다. 지금보다 적절한 반응형 세팅이 필요했다.
flutter_screenutil 기반 앱에서 여러 기기 화면에 대응하며 정리한 레이어 구조와 패턴, 그리고 거기서 만난 문제들을 적는다.
반응형의 두 갈래
먼저 큰 그림부터. 화면이 커졌을 때 할 수 있는 건 두 가지이다.
| 스케일(scale) | 적응(adapt) | |
|---|---|---|
| 방식 | 하나의 디자인을 비율로 늘림 | 레이아웃 구조 자체를 바꿈 |
| 결과 | 더 크게 | 더 많이 (2단, 그리드, master-detail) |
| 대표 | flutter_screenutil | LayoutBuilder, 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은 “디자인 기준 대비 실제 화면 비율”로 모든 값을 스케일한다.
| 단위 | 배율 | 용도 |
|---|---|---|
.w | screenWidth / designWidth | 너비, 가로 거리 |
.h | screenHeight / designHeight | 높이, 세로 거리 |
.r | min(scaleW, scaleH) | 정사각·아이콘·radius |
.sp | minTextAdapt: true면 min(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×765 | design 360×738 / scaleW 1.65 / scaleH 1.04 | design 473.6×738 / scaleW 1.25 / scaleH 1.04 |
| 가로 575×556 | design 738×360 / scaleW 0.78 / scaleH 1.55 | design 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.infinity나 Expanded로 깔린 풀폭 요소는 스케일과 무관하게 창 폭을 그대로 따라가기 때문이다. 그래서 컨텐츠 폭을 제한하고 가운데 정렬하는 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 실제값 |
|---|---|---|---|
| 1024 | 600 (cap) | 1.0 → 1.0 | 20 |
| 1280 | 600 (cap) | 1.25 → 1.25 | ≈ 25 |
| 1600 | 600 (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 자체의 동작만).
| 화면 폭 | scaleW | 20.w | k | 20.wc |
|---|---|---|---|---|
| 500 | 0.49 | 9.8 | 1.0 | 9.8 (= .w) |
| 600 (= cap) | 0.59 | 11.7 | 1.0 | 11.7 (= .w) |
| 1024 | 1.0 | 20 | 0.59 | 11.7 |
| 1280 | 1.25 | 25 | 0.47 | 11.7 |
.w는 화면 폭을 따라가고, .wc는 박스 폭을 따라간다. cap 아래에선 .w와 완전히 같고, cap을 넘는 순간부터 그 값에 얼어붙는다.
레이어 1 clamp(밴드 1.0~1.25)까지 얹으면 이렇게 된다.
| 화면 폭 | scaleW (raw → clamp) | 20.w | k | 20.wc |
|---|---|---|---|---|
| 500 | 0.49 → 1.0 (하한) | 20 | 1.0 | 20 (= .w) |
| 600 (= cap) | 0.59 → 1.0 (하한) | 20 | 1.0 | 20 (= .w) |
| 1024 | 1.0 | 20 | 0.59 | 11.7 |
| 1280 | 1.25 | 25 | 0.47 | 11.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도 만들어야 할까? 대부분 안 만드는 게 낫다.
- 가독성은 컨테이너가 아니라 화면 기준이다. 박스가 600에서 멈췄다고 글자까지 줄이면 큰 화면에서 오히려 답답하다. 웹도 max-width 컨테이너를 걸지만 폰트는 독립 스케일한다.
minTextAdapt: true면 이미 보수적이다..sp배율이min(scaleW, scaleH)라 가로가 아무리 넓어도 세로가 상한 역할을 한다.- 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)만 국소 방어한다.