Flutter 튜토리얼 34편: 반응형 디자인 기초
요약
핵심 요지
- 문제 정의: 하나의 앱이 휴대폰, 태블릿, 데스크톱 등 다양한 화면 크기에서 동작해야 하는데, 고정된 레이아웃은 모든 화면에서 잘 보이지 않는다.
- 핵심 주장: Flutter의
반응형1 설계는 3단계 접근법(Abstract, Measure, Branch)을 따르며,MediaQuery2와LayoutBuilder3를 상황에 맞게 사용해야 한다. - 주요 근거: MediaQuery.sizeOf()는 화면 전체 크기를 알려주고, LayoutBuilder는 부모가 허용한 공간을 알려준다. SafeArea는 노치와 시스템 UI를 피해 콘텐츠를 배치한다.
- 실무 기준: 전체 레이아웃 변경은 MediaQuery, 위젯별 적응은 LayoutBuilder, 화면 가장자리 안전 영역은 SafeArea를 사용한다.
문서가 설명하는 범위
- 반응형 디자인과 적응형 디자인의 차이
- 3단계 접근법: Abstract, Measure, Branch
- MediaQuery.sizeOf()와 LayoutBuilder 비교
- SafeArea로 안전 영역 확보하기
- 반응형 디자인 베스트 프랙티스
읽는 시간: 18분 | 난이도: 중급
참고 자료
- Adaptive and responsive design - 반응형/적응형 디자인 개요
- General approach - 3단계 접근법 설명
- SafeArea & MediaQuery - SafeArea와 MediaQuery 상세
- Best practices - 반응형 디자인 권장사항
문제 상황
Flutter 앱을 만들었는데, 휴대폰에서는 잘 보이지만 태블릿에서는 어색합니다.
좁은 화면에 맞춰 만든 레이아웃이 넓은 화면에서 너무 늘어나 보입니다.
노치가 있는 기기에서는 콘텐츠가 잘려서 보입니다.
// 고정 크기 레이아웃 - 모든 화면에서 같은 모습Scaffold( body: Column( children: [ Container(width: 300, height: 200), // 작은 화면에서 넘침 Row(children: [ Expanded(child: Card()), Expanded(child: Card()), ]), // 넓은 화면에서 카드가 너무 늘어남 ], ),)문제는 다음과 같습니다.
- 작은 화면에서 콘텐츠가 넘치거나 잘린다.
- 큰 화면에서 UI 요소가 과도하게 늘어난다.
- 노치, 상태 바 등 시스템 UI와 콘텐츠가 겹친다.
- 가로/세로 모드 전환 시 레이아웃이 깨진다.
해결 방법
Flutter는 화면 크기에 따라 레이아웃을 조정하는 다양한 도구를 제공합니다.
반응형 디자인은 콘텐츠가 화면 크기에 맞게 흐르듯 조정되는 방식이고, 적응형 디자인4은 특정 중단점에서 완전히 다른 레이아웃을 보여주는 방식입니다.
챕터 1: 반응형과 적응형 디자인의 차이
Why
NOTE“반응형”과 “적응형”이라는 용어가 혼용되어 혼란스러울 수 있습니다.
두 개념의 차이를 이해해야 상황에 맞는 접근법을 선택할 수 있습니다.// 반응형: 화면 너비에 따라 패딩이 부드럽게 변함padding: EdgeInsets.all(width * 0.05)// 적응형: 특정 너비에서 완전히 다른 레이아웃width > 600 ? TwoColumnLayout() : SingleColumnLayout()
What
NOTE반응형 디자인 (Responsive)
- 화면 크기에 따라 콘텐츠가 연속적으로 조정됨
- 비율 기반 크기, Flex 위젯 사용
- 예: 카드 너비가 화면의 90%를 차지
적응형 디자인 (Adaptive)
- 특정
중단점5에서 불연속적으로 레이아웃 변경- 조건문으로 다른 위젯 선택
- 예: 600px 이하면 1열, 이상이면 2열
특성 반응형 적응형 변화 방식 연속적 불연속적 구현 방법 비율, Flex 조건문, 중단점 사용 예 패딩, 폰트 크기 레이아웃 구조 변경
How
TIP반응형 예시: 화면 비율에 맞는 패딩
class ResponsivePadding extends StatelessWidget {const ResponsivePadding({super.key, required this.child});final Widget child;@overrideWidget build(BuildContext context) {final width = MediaQuery.sizeOf(context).width;// 화면 너비의 5%를 패딩으로 사용return Padding(padding: EdgeInsets.symmetric(horizontal: width * 0.05),child: child,);}}적응형 예시: 중단점에 따른 레이아웃 변경
class AdaptiveLayout extends StatelessWidget {const AdaptiveLayout({super.key});@overrideWidget build(BuildContext context) {final width = MediaQuery.sizeOf(context).width;// 600px를 기준으로 레이아웃 변경if (width < 600) {return const MobileLayout();} else if (width < 900) {return const TabletLayout();} else {return const DesktopLayout();}}}실제 앱에서는 두 가지를 함께 사용합니다.
큰 구조는 적응형으로, 세부 요소는 반응형으로 처리합니다.
Watch out
WARNING반응형만 사용하면 극단적인 화면 크기에서 문제가 생깁니다.
// ❌ 문제: 아주 넓은 화면에서 텍스트가 너무 길어짐Container(width: MediaQuery.sizeOf(context).width * 0.9,child: Text('매우 긴 텍스트...'),)// ✅ 해결: 최대 너비 제한Container(width: min(MediaQuery.sizeOf(context).width * 0.9, 800),child: Text('매우 긴 텍스트...'),)적응형만 사용하면 중단점 근처에서 갑작스러운 레이아웃 변경이 일어나 사용자 경험이 나빠집니다.
두 접근법을 균형 있게 조합하세요.
결론: 반응형은 부드러운 조정에, 적응형은 구조적 변경에 사용합니다.
챕터 2: 3단계 접근법 (Abstract, Measure, Branch)
Why
NOTE화면 크기에 따라 다른 UI를 보여주려면 체계적인 접근이 필요합니다.
무작정 조건문을 추가하면 코드가 복잡해지고 유지보수가 어려워집니다.// ❌ 체계 없이 조건문을 추가한 복잡한 코드Widget build(BuildContext context) {final width = MediaQuery.sizeOf(context).width;return Column(children: [if (width > 600) const SideMenu(),Container(padding: EdgeInsets.all(width > 600 ? 24 : 16),child: width > 900? const ThreeColumnGrid(): width > 600? const TwoColumnGrid(): const SingleColumnList(),),],);}Flutter 공식 문서는 Abstract → Measure → Branch 3단계를 권장합니다.
What
NOTE1단계: Abstract (추상화)
- 화면 크기에 따라 달라지는 값을 추출
- 예: 열 개수, 패딩 크기, 폰트 크기
2단계: Measure (측정)
- MediaQuery나 LayoutBuilder로 현재 크기 측정
- 필요한 정보만 측정 (너비, 높이, 방향 등)
3단계: Branch (분기)
- 측정된 값에 따라 적절한 UI 선택
- 추상화된 값을 사용하여 분기
flowchart LR A[Abstract<br/>변화 요소 추출] --> B[Measure<br/>현재 크기 측정] B --> C[Branch<br/>UI 선택]
How
TIP예제: 그리드 열 개수 조정
// 1단계: Abstract - 화면 크기에 따른 열 개수 정의int getColumnCount(double width) {if (width < 600) return 1;if (width < 900) return 2;if (width < 1200) return 3;return 4;}// 2단계: Measure - 현재 너비 측정// 3단계: Branch - 열 개수에 맞는 그리드 생성class ResponsiveGrid extends StatelessWidget {const ResponsiveGrid({super.key, required this.items});final List<Widget> items;@overrideWidget build(BuildContext context) {// Measurefinal width = MediaQuery.sizeOf(context).width;// Branch (Abstract 함수 활용)final columns = getColumnCount(width);return GridView.builder(gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: columns,crossAxisSpacing: 16,mainAxisSpacing: 16,),itemCount: items.length,itemBuilder: (context, index) => items[index],);}}예제: 화면 크기 클래스 정의
// 1단계: Abstract - 화면 크기 클래스 정의enum ScreenSize { compact, medium, expanded }ScreenSize getScreenSize(double width) {if (width < 600) return ScreenSize.compact;if (width < 840) return ScreenSize.medium;return ScreenSize.expanded;}// 2단계 & 3단계: Measure와 Branchclass AdaptiveScaffold extends StatelessWidget {const AdaptiveScaffold({super.key, required this.body});final Widget body;@overrideWidget build(BuildContext context) {final width = MediaQuery.sizeOf(context).width;final screenSize = getScreenSize(width);return Scaffold(// 큰 화면에서만 사이드 메뉴 표시drawer: screenSize == ScreenSize.compact? const AppDrawer(): null,body: Row(children: [// 중간 이상 화면에서 NavigationRail 표시if (screenSize != ScreenSize.compact)const NavigationRail(destinations: [...],selectedIndex: 0,),Expanded(child: body),],),);}}
Watch out
WARNING중단점 값을 여러 곳에 하드코딩하면 일관성이 깨집니다.
// ❌ 중단점이 여러 곳에 흩어져 있음if (width > 600) ... // 어떤 파일에서if (width > 599) ... // 다른 파일에서 - 1픽셀 차이!if (width >= 600) ... // 또 다른 파일에서// ✅ 중단점을 한 곳에서 관리abstract class Breakpoints {static const double compact = 600;static const double medium = 840;static const double expanded = 1200;}// 사용if (width >= Breakpoints.compact) ...또한 너무 많은 중단점은 피하세요.
Material Design 3는 compact, medium, expanded 세 가지를 권장합니다.
결론: Abstract → Measure → Branch 패턴으로 반응형 코드를 체계적으로 구성합니다.
챕터 3: MediaQuery.sizeOf() 활용하기
Why
NOTE화면 전체 크기를 기준으로 레이아웃을 결정해야 할 때가 있습니다.
”휴대폰인가 태블릿인가?”를 판단하려면 전체 화면 크기가 필요합니다.// 화면 전체 크기에 따라 네비게이션 스타일 변경final screenWidth = MediaQuery.sizeOf(context).width;if (screenWidth > 600) {return const TabletNavigation();} else {return const MobileNavigation();}
What
NOTE
MediaQuery.sizeOf(context)는 화면 전체 크기를 반환합니다.final size = MediaQuery.sizeOf(context);print(size.width); // 화면 너비 (픽셀)print(size.height); // 화면 높이 (픽셀)MediaQuery가 제공하는 주요 정보:
속성 설명 사용 예 size화면 크기 레이아웃 분기 orientation가로/세로 방향 방향별 레이아웃 padding시스템 UI 영역 SafeArea 구현 viewInsets키보드 높이 키보드 대응 textScaleFactor텍스트 크기 배율 접근성 대응
How
TIP화면 방향에 따른 레이아웃 변경
class OrientationAwareLayout extends StatelessWidget {const OrientationAwareLayout({super.key});@overrideWidget build(BuildContext context) {final orientation = MediaQuery.orientationOf(context);if (orientation == Orientation.landscape) {// 가로 모드: 사이드바 + 콘텐츠return Row(children: [const SizedBox(width: 200, child: Sidebar()),const Expanded(child: MainContent()),],);} else {// 세로 모드: 콘텐츠만return const MainContent();}}}텍스트 크기 배율 대응
class ScalableText extends StatelessWidget {const ScalableText({super.key, required this.text});final String text;@overrideWidget build(BuildContext context) {final textScaler = MediaQuery.textScalerOf(context);// 사용자가 시스템에서 텍스트 크기를 키웠을 때// 레이아웃이 깨지지 않도록 최대 크기 제한return Text(text,textScaler: textScaler.clamp(maxScaleFactor: 1.5),);}}of() vs sizeOf() 차이
// ❌ 전체 MediaQueryData를 구독 - 불필요한 리빌드 발생final size = MediaQuery.of(context).size;// ✅ size만 구독 - 다른 MediaQuery 값이 변해도 리빌드 안 함final size = MediaQuery.sizeOf(context);
Watch out
WARNING
MediaQuery.of(context)는 모든 MediaQuery 값이 변경될 때마다 위젯을 리빌드합니다.
키보드가 나타나거나 시스템 UI가 변경될 때도 리빌드됩니다.// ❌ 키보드가 나타날 때마다 리빌드Widget build(BuildContext context) {final data = MediaQuery.of(context);return Container(width: data.size.width * 0.5);}// ✅ 크기가 변할 때만 리빌드Widget build(BuildContext context) {final size = MediaQuery.sizeOf(context);return Container(width: size.width * 0.5);}특정 속성만 필요하면 전용 메서드를 사용하세요:
MediaQuery.sizeOf(context)- 크기만MediaQuery.orientationOf(context)- 방향만MediaQuery.paddingOf(context)- 패딩만MediaQuery.viewInsetsOf(context)- 키보드 영역만
결론: MediaQuery.sizeOf()로 화면 전체 크기를 효율적으로 측정합니다.
챕터 4: LayoutBuilder 활용하기
Why
NOTE위젯이 실제로 사용할 수 있는 공간은 화면 크기와 다를 수 있습니다.
부모 위젯이 제한을 걸면 자식은 그 안에서만 레이아웃을 구성해야 합니다.// MediaQuery는 화면 전체 크기를 반환// 하지만 이 Container의 실제 공간은 부모가 결정SizedBox(width: 300, // 이 위젯 안에서child: MyWidget(), // MyWidget이 쓸 수 있는 공간은 300px)
LayoutBuilder3는 부모가 허용한 공간을 알려줍니다.
What
NOTELayoutBuilder는 부모의
제약 조건6을 받아 자식을 빌드합니다.LayoutBuilder(builder: (context, constraints) {// constraints.maxWidth: 사용 가능한 최대 너비// constraints.maxHeight: 사용 가능한 최대 높이// constraints.minWidth: 최소 너비 요구사항// constraints.minHeight: 최소 높이 요구사항return SomeWidget();},)MediaQuery vs LayoutBuilder 비교
특성 MediaQuery.sizeOf() LayoutBuilder 측정 대상 화면 전체 부모가 허용한 공간 업데이트 시점 화면 크기 변경 부모 제약 변경 사용 목적 전역 레이아웃 결정 위젯별 적응 중첩 영향 없음 부모에 따라 달라짐
How
TIP예제: 사용 가능한 공간에 맞는 카드 배치
class AdaptiveCardList extends StatelessWidget {const AdaptiveCardList({super.key, required this.items});final List<String> items;@overrideWidget build(BuildContext context) {return LayoutBuilder(builder: (context, constraints) {// 부모가 허용한 너비에 따라 카드 크기 결정final cardWidth = constraints.maxWidth > 600 ? 200.0 : 150.0;final crossAxisCount = (constraints.maxWidth / cardWidth).floor();return GridView.builder(gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: crossAxisCount.clamp(1, 4),crossAxisSpacing: 8,mainAxisSpacing: 8,),itemCount: items.length,itemBuilder: (context, index) => Card(child: Center(child: Text(items[index])),),);},);}}예제: 재사용 가능한 반응형 위젯
// 이 위젯은 어디에 놓여도 자신의 공간에 맞게 적응class ResponsivePanel extends StatelessWidget {const ResponsivePanel({super.key, required this.child});final Widget child;@overrideWidget build(BuildContext context) {return LayoutBuilder(builder: (context, constraints) {// 좁은 공간에서는 세로 배치if (constraints.maxWidth < 400) {return Column(children: [const Header(), Expanded(child: child)]);}// 넓은 공간에서는 가로 배치return Row(children: [const Sidebar(), Expanded(child: child)]);},);}}// 어디에 놓여도 적응// 1. 전체 화면에 놓으면 화면 크기에 맞게// 2. Dialog 안에 놓으면 Dialog 크기에 맞게// 3. 다른 위젯 안에 놓으면 그 위젯 크기에 맞게
Watch out
WARNINGLayoutBuilder의 constraints가 무한대일 수 있습니다.
ListView나 SingleChildScrollView 안에서는 maxHeight가double.infinity입니다.// ❌ 무한 높이 문제ListView(children: [LayoutBuilder(builder: (context, constraints) {// constraints.maxHeight == double.infinity!// 높이 기반 계산이 불가능return Container(height: constraints.maxHeight * 0.5); // 문제!},),],)// ✅ 높이가 필요하면 ListView 밖에서 LayoutBuilder 사용LayoutBuilder(builder: (context, constraints) {return ListView(children: [Container(height: constraints.maxHeight * 0.3),// ...],);},)또한 LayoutBuilder는 레이아웃 단계에서 실행됩니다.
성능에 민감한 작업은 builder 함수 안에서 피하세요.
결론: LayoutBuilder로 위젯이 실제 사용할 수 있는 공간에 맞게 적응합니다.
챕터 5: SafeArea로 안전 영역 확보하기
Why
NOTE최신 기기에는 노치, 홈 인디케이터, 카메라 홀 등이 있습니다.
이런 영역에 콘텐츠가 배치되면 잘리거나 가려집니다.// ❌ 노치가 있는 기기에서 텍스트가 가려짐Scaffold(body: Column(children: [Text('이 텍스트가 노치 뒤에 숨어요'),// ...],),)
What
NOTE
SafeArea7는 시스템 UI를 피해 콘텐츠를 안전한 영역에 배치합니다.SafeArea(child: Text('이 텍스트는 항상 보여요'),)SafeArea가 피하는 영역:
영역 설명 예시 상단 상태 바, 노치, 카메라 홀 iPhone 노치 하단 홈 인디케이터, 네비게이션 바 iPhone 홈바 좌우 둥근 모서리, 폴더블 힌지 Galaxy Fold SafeArea는 내부적으로 MediaQuery.paddingOf()를 사용합니다.
How
TIP기본 사용법
Scaffold(body: SafeArea(child: Column(children: [const Text('안전한 영역에 있어요'),Expanded(child: ListView(...)),],),),)특정 방향만 적용
SafeArea(// 상단만 패딩 적용, 하단은 무시top: true,bottom: false,left: true,right: true,child: Column(...),)최소 패딩 설정
SafeArea(// 시스템 패딩이 없어도 최소 16px 보장minimum: const EdgeInsets.all(16),child: Column(...),)Scaffold와 함께 사용
Scaffold는 일부 SafeArea를 자동으로 처리합니다.
Scaffold(appBar: AppBar(), // 상단 안전 영역 자동 처리body: ListView(...), // body는 SafeArea 적용 안 됨bottomNavigationBar: BottomNavigationBar(...), // 하단 자동 처리)// body에 SafeArea가 필요하면 직접 추가Scaffold(body: SafeArea(child: ListView(...),),)
Watch out
WARNINGSafeArea를 중첩하면 패딩이 중복 적용됩니다.
// ❌ 패딩이 두 번 적용됨SafeArea(child: Column(children: [SafeArea( // 불필요한 중첩child: Text('패딩이 너무 많아요'),),],),)또한 MediaQuery.removePadding()을 사용하면 하위 위젯에서 SafeArea가 작동하지 않을 수 있습니다.
// SafeArea 효과를 제거하고 직접 패딩 관리할 때MediaQuery.removePadding(context: context,removeTop: true,child: ListView(...), // 상단 패딩이 제거됨)스크롤 가능한 위젯에서는
SliverSafeArea를 고려하세요.CustomScrollView(slivers: [SliverSafeArea(sliver: SliverList(...),),],)
결론: SafeArea로 시스템 UI와 겹치지 않는 안전한 콘텐츠 영역을 확보합니다.
챕터 6: 반응형 디자인 베스트 프랙티스
Why
NOTE반응형 디자인을 잘못 구현하면 코드가 복잡해지고 성능이 떨어집니다.
검증된 패턴을 따르면 유지보수가 쉽고 일관된 사용자 경험을 제공할 수 있습니다.// ❌ 조건문이 여기저기 흩어져 있음if (MediaQuery.sizeOf(context).width > 600) ...if (MediaQuery.sizeOf(context).width > 599) ... // 1픽셀 차이!
What
NOTEFlutter 공식 문서가 권장하는 핵심 원칙:
- Flex 위젯 우선: Row, Column, Wrap, Flex로 유연한 레이아웃
- ConstrainedBox 활용: 최소/최대 크기 제한으로 극단적 상황 대응
- 중단점 중앙화: 상수로 정의하여 일관성 유지
- 성능 고려: 불필요한 리빌드 방지
How
TIP1. Flex 위젯으로 유연한 레이아웃
// Wrap: 공간이 부족하면 자동 줄바꿈Wrap(spacing: 8,runSpacing: 8,children: [for (final item in items)Chip(label: Text(item)),],)// Flexible: 남은 공간을 비율로 분배Row(children: [Flexible(flex: 2, child: LeftPanel()),Flexible(flex: 3, child: RightPanel()),],)2. 최대/최소 크기 제한
// 너무 넓어지지 않도록 제한Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 800),child: Content(),),)// FractionallySizedBox: 부모의 비율로 크기 지정FractionallySizedBox(widthFactor: 0.8, // 부모 너비의 80%child: Card(),)3. 중단점 중앙 관리
breakpoints.dart abstract class Breakpoints {// Material Design 3 기준static const double compact = 600;static const double medium = 840;static const double expanded = 1200;static bool isCompact(BuildContext context) =>MediaQuery.sizeOf(context).width < compact;static bool isMedium(BuildContext context) {final width = MediaQuery.sizeOf(context).width;return width >= compact && width < medium;}static bool isExpanded(BuildContext context) =>MediaQuery.sizeOf(context).width >= medium;}// 사용if (Breakpoints.isCompact(context)) {return const MobileLayout();}4. 반응형 위젯 래퍼 패턴
class ResponsiveBuilder extends StatelessWidget {const ResponsiveBuilder({super.key,required this.compact,this.medium,this.expanded,});final Widget compact;final Widget? medium;final Widget? expanded;@overrideWidget build(BuildContext context) {final width = MediaQuery.sizeOf(context).width;if (width >= 840 && expanded != null) return expanded!;if (width >= 600 && medium != null) return medium!;return compact;}}// 사용ResponsiveBuilder(compact: const MobileView(),medium: const TabletView(),expanded: const DesktopView(),)
Watch out
WARNING성능 주의사항
- MediaQuery.of() 대신 특정 속성용 메서드 사용
// ❌ 모든 MediaQuery 변경에 반응final width = MediaQuery.of(context).size.width;// ✅ 크기 변경에만 반응final width = MediaQuery.sizeOf(context).width;
- 불필요한 리빌드 방지
// ❌ 매 빌드마다 새 객체 생성Widget build(BuildContext context) {return Container(decoration: BoxDecoration(...), // 매번 새로 생성);}// ✅ const 또는 캐싱 사용static const _decoration = BoxDecoration(...);Widget build(BuildContext context) {return Container(decoration: _decoration);}
- 중단점 변경 시 과도한 애니메이션 피하기
// 중단점에서 레이아웃이 갑자기 바뀌면 어색함// AnimatedSwitcher로 부드러운 전환 고려AnimatedSwitcher(duration: const Duration(milliseconds: 300),child: Breakpoints.isExpanded(context)? const ExpandedLayout(key: ValueKey('expanded')): const CompactLayout(key: ValueKey('compact')),)
결론: 검증된 패턴을 따라 유지보수가 쉽고 성능 좋은 반응형 UI를 만듭니다.
한계
이 문서는 반응형 디자인의 기초를 다룹니다.
다음 주제는 별도로 학습해야 합니다.
- 대형 화면 최적화: 태블릿, 데스크톱 전용 레이아웃 패턴
- 폴더블 대응: 힌지 영역 처리, 화면 분할
- 플랫폼별 적응: iOS/Android/Web 각각의 디자인 컨벤션
- 접근성: 텍스트 크기 조정, 고대비 모드 대응
Footnotes
-
responsive(반응형): 화면 크기에 따라 콘텐츠가 연속적으로 조정되는 디자인 방식이다. ↩
-
MediaQuery(미디어쿼리): 화면 크기, 방향, 패딩 등 디바이스 정보를 제공하는 Flutter 위젯이다. ↩
-
LayoutBuilder(레이아웃빌더): 부모가 허용한 제약 조건을 받아 자식을 빌드하는 위젯이다. ↩ ↩2
-
adaptive(적응형): 특정 중단점에서 완전히 다른 레이아웃으로 전환하는 디자인 방식이다. ↩
-
breakpoint(중단점): 레이아웃이 변경되는 화면 너비 기준점이다. ↩
-
constraints(제약 조건): 부모가 자식에게 전달하는 최소/최대 크기 정보다. ↩
-
SafeArea(세이프에리어): 노치, 상태 바 등 시스템 UI를 피해 콘텐츠를 배치하는 위젯이다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!