Padding과 Margin으로 여백 관리하기
“박스들이 너무 붙어있어서 답답해 보인다”고 생각해본 적 있나요? UI에서 여백은 단순히 빈 공간이 아닙니다. 콘텐츠를 돋보이게 하고, 사용자의 시선을 안내하며, 전체적인 완성도를 높이는 중요한 요소입니다.
🎯 학습 목표
이 튜토리얼을 완료하면 다음을 할 수 있습니다:
- Padding과 Margin의 차이점 명확히 이해하기
- EdgeInsets 클래스의 다양한 메서드 활용하기
- 시각적으로 균형잡힌 레이아웃 만들기
- 반응형 여백 패턴 구현하기
- 컴포넌트 간 일관된 간격 유지하기
🤔 여백이 왜 중요할까요?
여백은 디자인에서 “호흡”과 같은 역할을 합니다. 적절한 여백이 있어야 사용자가 콘텐츠를 편안하게 인식할 수 있습니다.
여백의 심리적 효과:
- 가독성 향상: 텍스트와 요소 간 충분한 여백으로 읽기 편안함
- 계층 구조 명확화: 관련된 요소는 가깝게, 다른 그룹은 멀리 배치
- 시각적 휴식: 복잡한 인터페이스에서 사용자의 눈을 편안하게 함
- 프리미엄 느낌: 여유로운 여백은 고급스러운 인상을 줌
- 터치 친화적: 모바일에서 터치 타겟 간 충분한 간격 제공
실제 사용 사례:
- 카드 레이아웃에서 각 카드 간 구분
- 폼 요소들 간의 논리적 그룹화
- 헤더와 본문 콘텐츠 간 시각적 분리
- 버튼과 텍스트 간 터치하기 편한 간격
- 네비게이션 메뉴 항목들 간 명확한 구분
📏 Padding vs Margin 완전 정복
개념 차이
┌─────── Container (margin) ───────┐
│ ┌─── Container (padding) ───┐ │
│ │ │ │
│ │ 실제 콘텐츠 영역 │ │
│ │ │ │
│ └──────────────────────────┘ │
└──────────────────────────────────┘
- Padding: 컨테이너 내부의 콘텐츠와 테두리 사이의 여백
- Margin: 컨테이너 외부의 다른 요소들과의 여백
시각적 비교
// Padding만 적용
Container({
width: 200,
height: 100,
color: '#FF6B6B',
padding: EdgeInsets.all(20), // 내부 여백
child: Text("콘텐츠")
})
// Margin만 적용
Container({
width: 200,
height: 100,
color: '#4ECDC4',
margin: EdgeInsets.all(20), // 외부 여백
child: Text("콘텐츠")
})
// 둘 다 적용
Container({
width: 200,
height: 100,
color: '#45B7D1',
margin: EdgeInsets.all(16), // 외부 여백
padding: EdgeInsets.all(12), // 내부 여백
child: Text("콘텐츠")
})
🎨 EdgeInsets 클래스 마스터하기
모든 방향 동일한 여백
// 모든 방향에 20px 여백
EdgeInsets.all(20)
// 실사용 예제
Container({
padding: EdgeInsets.all(16),
margin: EdgeInsets.all(8),
child: Text("균등한 여백")
})
대칭적 여백
// 가로(좌우)와 세로(상하) 여백을 각각 설정
EdgeInsets.symmetric({
horizontal: 24, // 좌우 24px
vertical: 12 // 상하 12px
})
// 실사용 예제 - 버튼 스타일
Container({
padding: EdgeInsets.symmetric({ horizontal: 32, vertical: 12 }),
decoration: new BoxDecoration({
color: '#4299E1',
borderRadius: BorderRadius.circular(6)
}),
child: Text("버튼", { style: { color: 'white' } })
})
개별 방향 여백
// 각 방향을 개별적으로 설정
EdgeInsets.only({
top: 20,
right: 16,
bottom: 24,
left: 12
})
// 일부만 설정 (나머지는 0)
EdgeInsets.only({ bottom: 16 }) // 아래쪽만
EdgeInsets.only({ left: 20, right: 20 }) // 좌우만
조합형 여백 패턴
// 카드 레이아웃 패턴
Container({
margin: EdgeInsets.symmetric({ horizontal: 16, vertical: 8 }),
padding: EdgeInsets.all(20),
decoration: new BoxDecoration({
color: 'white',
borderRadius: BorderRadius.circular(12),
boxShadow: [new BoxShadow({
color: 'rgba(0, 0, 0, 0.1)',
offset: { dx: 0, dy: 2 },
blurRadius: 8
})]
}),
child: Column({
children: [
Text("제목", { style: { fontSize: 18, fontWeight: 'bold' } }),
Container({ height: 12 }), // Spacer 역할
Text("내용이 들어가는 곳입니다.")
]
})
})
🏗️ 실습: 여백이 있는 카드 레이아웃 만들기
위의 시작 코드에서 박스들이 서로 붙어있어 답답해 보입니다. 이를 다음과 같이 개선해보세요:
단계별 힌트:
- 바깥 Container에
padding: EdgeInsets.all(20)
추가 - 각 박스에
margin: EdgeInsets.only({ bottom: 16 })
추가 (마지막 박스 제외) - 각 박스에
padding: EdgeInsets.all(12)
추가하여 텍스트 여백 확보
완성 후 결과:
- 전체 레이아웃에 바깥 여백이 생김
- 박스들 간에 적절한 간격이 생김
- 텍스트가 박스 가장자리에서 떨어져 보기 좋아짐
🎨 실전 여백 패턴 모음
1. 모던 카드 리스트
import { StatelessWidget, Container, Column, Text, EdgeInsets, BoxDecoration, BorderRadius, BoxShadow, TextStyle } from "@meursyphus/flitter";
class ModernCardList extends StatelessWidget {
constructor({ items = [], ...props } = {}) {
super(props);
this.items = items;
}
build(context) {
return Container({
padding: EdgeInsets.all(16),
child: Column({
children: this.items.map((item, index) =>
Container({
margin: EdgeInsets.only({
bottom: index < this.items.length - 1 ? 16 : 0
}),
padding: EdgeInsets.all(20),
decoration: new BoxDecoration({
color: 'white',
borderRadius: BorderRadius.circular(12),
boxShadow: [new BoxShadow({
color: 'rgba(0, 0, 0, 0.08)',
offset: { dx: 0, dy: 2 },
blurRadius: 12
})]
}),
child: Column({
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title, {
style: new TextStyle({
fontSize: 18,
fontWeight: 'bold',
color: '#1A202C',
marginBottom: 8
})
}),
Text(item.description, {
style: new TextStyle({
fontSize: 14,
color: '#718096',
lineHeight: 1.5
})
})
]
})
})
)
})
});
}
}
export default function ModernCardList(props) {
return new _ModernCardList(props);
}
2. 폼 레이아웃
import { StatefulWidget, State, Container, Column, Text, GestureDetector, EdgeInsets, BoxDecoration, BorderRadius, TextStyle, Center } from "@meursyphus/flitter";
class FormLayout extends StatefulWidget {
createState() {
return new _FormLayoutState();
}
}
class _FormLayoutState extends State {
build(context) {
return Container({
padding: EdgeInsets.symmetric({ horizontal: 24, vertical: 32 }),
child: Column({
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 폼 제목
Text("회원가입", {
style: new TextStyle({
fontSize: 28,
fontWeight: 'bold',
color: '#1A202C',
textAlign: 'center'
})
}),
Container({ height: 32 }), // 제목과 폼 사이 여백
// 이메일 필드
this.buildFormField("이메일", "[email protected]"),
Container({ height: 20 }), // 필드 간 여백
// 비밀번호 필드
this.buildFormField("비밀번호", "••••••••"),
Container({ height: 20 }),
// 비밀번호 확인 필드
this.buildFormField("비밀번호 확인", "••••••••"),
Container({ height: 32 }), // 필드와 버튼 사이 여백
// 가입 버튼
GestureDetector({
onClick: () => console.log("가입하기 클릭"),
child: Container({
height: 48,
decoration: new BoxDecoration({
color: '#4299E1',
borderRadius: BorderRadius.circular(8)
}),
child: Center({
child: Text("가입하기", {
style: new TextStyle({
color: 'white',
fontSize: 16,
fontWeight: 'bold'
})
})
})
})
}),
Container({ height: 16 }),
// 로그인 링크
Center({
child: Text("이미 계정이 있으신가요? 로그인", {
style: new TextStyle({
color: '#4299E1',
fontSize: 14
})
})
})
]
})
});
}
buildFormField(label, placeholder) {
return Column({
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, {
style: new TextStyle({
fontSize: 14,
fontWeight: '500',
color: '#374151',
marginBottom: 8
})
}),
Container({
height: 48,
padding: EdgeInsets.symmetric({ horizontal: 16, vertical: 12 }),
decoration: new BoxDecoration({
color: '#F9FAFB',
borderRadius: BorderRadius.circular(8),
border: Border.all({ color: '#D1D5DB', width: 1 })
}),
child: Text(placeholder, {
style: new TextStyle({
color: '#9CA3AF',
fontSize: 16
})
})
})
]
});
}
}
export default function FormLayout(props) {
return new _FormLayout(props);
}
3. 반응형 그리드 시스템
import { StatelessWidget, Container, Row, Column, Text, Expanded, EdgeInsets, BoxDecoration, BorderRadius, TextStyle, Center } from "@meursyphus/flitter";
class ResponsiveGrid extends StatelessWidget {
constructor({ items = [], columns = 2, ...props } = {}) {
super(props);
this.items = items;
this.columns = columns;
}
build(context) {
const rows = [];
for (let i = 0; i < this.items.length; i += this.columns) {
const rowItems = this.items.slice(i, i + this.columns);
rows.push(
Container({
margin: EdgeInsets.only({ bottom: 16 }),
child: Row({
children: rowItems.map((item, index) => [
Expanded({
child: Container({
margin: EdgeInsets.only({
right: index < rowItems.length - 1 ? 8 : 0
}),
padding: EdgeInsets.all(20),
decoration: new BoxDecoration({
color: item.color,
borderRadius: BorderRadius.circular(12)
}),
child: Column({
children: [
Text(item.title, {
style: new TextStyle({
fontSize: 16,
fontWeight: 'bold',
color: 'white',
textAlign: 'center',
marginBottom: 8
})
}),
Text(item.value, {
style: new TextStyle({
fontSize: 24,
fontWeight: 'bold',
color: 'white',
textAlign: 'center'
})
})
]
})
})
}),
// 빈 공간 채우기 (마지막 행에서 열이 부족할 때)
...Array(this.columns - rowItems.length).fill(null).map(() =>
Expanded({ child: Container() })
)
]).flat()
})
})
);
}
return Container({
padding: EdgeInsets.all(16),
child: Column({ children: rows })
});
}
}
export default function ResponsiveGrid(props) {
return new _ResponsiveGrid(props);
}
// 사용 예제
const gridData = [
{ title: "총 매출", value: "₩1,234만", color: '#4299E1' },
{ title: "신규 주문", value: "156건", color: '#48BB78' },
{ title: "활성 사용자", value: "2,340명", color: '#ED8936' },
{ title: "전환율", value: "12.3%", color: '#9F7AEA' }
];
const widget = ResponsiveGrid({
items: gridData,
columns: 2
});
📐 여백 시스템 설계 원칙
1. 일관된 스케일 시스템
// 여백 상수 정의
const SPACING = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48
};
// 사용 예
Container({
padding: EdgeInsets.all(SPACING.md), // 16px
margin: EdgeInsets.only({ bottom: SPACING.lg }) // 24px
})
2. 컴포넌트별 여백 패턴
// 카드 컴포넌트의 표준 여백
class StandardCard extends StatelessWidget {
static get SPACING() {
return {
container: EdgeInsets.all(16),
content: EdgeInsets.all(20),
margin: EdgeInsets.only({ bottom: 16 })
};
}
build(context) {
return Container({
margin: StandardCard.SPACING.margin,
padding: StandardCard.SPACING.content,
// ... 나머지 스타일
});
}
}
3. 계층적 여백 설계
// 페이지 > 섹션 > 컴포넌트 순으로 여백 크기 감소
Container({
padding: EdgeInsets.all(32), // 페이지 레벨 (가장 큰 여백)
child: Column({
children: [
Container({
margin: EdgeInsets.only({ bottom: 24 }), // 섹션 레벨
child: Column({
children: [
Container({
margin: EdgeInsets.only({ bottom: 12 }), // 컴포넌트 레벨
child: Text("제목")
}),
Text("내용")
]
})
})
]
})
})
🚀 고급 여백 테크닉
1. 조건부 여백
class ConditionalSpacing extends StatelessWidget {
constructor({ items = [], isCompact = false, ...props } = {}) {
super(props);
this.items = items;
this.isCompact = isCompact;
}
build(context) {
const spacing = this.isCompact ? 8 : 16;
return Column({
children: this.items.map((item, index) =>
Container({
margin: EdgeInsets.only({
bottom: index < this.items.length - 1 ? spacing : 0
}),
child: item
})
)
});
}
}
2. 네거티브 마진 효과
// 겹치는 효과를 위한 음수 마진 시뮬레이션
Stack({
children: [
Container({
width: 100,
height: 100,
color: '#FF6B6B'
}),
Positioned({
top: 20,
left: 20,
child: Container({
width: 100,
height: 100,
color: '#4ECDC4'
})
})
]
})
3. 반응형 여백
class ResponsiveSpacing extends StatelessWidget {
constructor({ screenWidth = 400, ...props } = {}) {
super(props);
this.screenWidth = screenWidth;
}
get spacing() {
if (this.screenWidth < 768) {
return { horizontal: 16, vertical: 12 }; // 모바일
} else if (this.screenWidth < 1024) {
return { horizontal: 24, vertical: 16 }; // 태블릿
} else {
return { horizontal: 32, vertical: 24 }; // 데스크톱
}
}
build(context) {
return Container({
padding: EdgeInsets.symmetric(this.spacing),
// ... 나머지 구현
});
}
}
📝 연습 문제
연습 1: 프로필 카드 만들기
// TODO: 적절한 여백을 가진 프로필 카드를 만들어보세요
class ProfileCard extends StatelessWidget {
constructor({ name, role, avatar, ...props } = {}) {
super(props);
this.name = name;
this.role = role;
this.avatar = avatar;
}
build(context) {
// 여기에 코드를 작성하세요
// 힌트: padding, margin을 사용해서 시각적으로 균형잡힌 카드를 만드세요
}
}
연습 2: 뉴스 피드 레이아웃
// TODO: 뉴스 아이템들 간에 적절한 간격이 있는 피드를 만들어보세요
class NewsFeed extends StatelessWidget {
constructor({ articles = [], ...props } = {}) {
super(props);
this.articles = articles;
}
build(context) {
// 각 뉴스 아이템 간 간격, 카드 내부 여백 등을 고려해서 구현하세요
}
}
연습 3: 설정 메뉴 UI
// TODO: 설정 항목들이 그룹화되고 적절한 여백을 가진 설정 메뉴를 만들어보세요
class SettingsMenu extends StatelessWidget {
build(context) {
// 그룹 제목, 설정 항목들 간의 여백을 계층적으로 구성해보세요
}
}
🐛 흔한 실수와 해결법
❌ 실수 1: 일관성 없는 여백
// 나쁜 예 - 여백이 들쭉날쭉
Column({
children: [
Container({ margin: EdgeInsets.only({ bottom: 5 }) }),
Container({ margin: EdgeInsets.only({ bottom: 15 }) }),
Container({ margin: EdgeInsets.only({ bottom: 10 }) })
]
})
✅ 올바른 방법:
// 좋은 예 - 일관된 여백 시스템
const ITEM_SPACING = 12;
Column({
children: [
Container({ margin: EdgeInsets.only({ bottom: ITEM_SPACING }) }),
Container({ margin: EdgeInsets.only({ bottom: ITEM_SPACING }) }),
Container({ margin: EdgeInsets.only({ bottom: ITEM_SPACING }) })
]
})
❌ 실수 2: 과도한 여백
// 너무 많은 여백으로 인한 공간 낭비
Container({
padding: EdgeInsets.all(100), // 너무 큼!
child: Text("작은 텍스트")
})
✅ 올바른 방법:
// 콘텐츠에 적절한 여백
Container({
padding: EdgeInsets.symmetric({ horizontal: 16, vertical: 8 }),
child: Text("작은 텍스트")
})
❌ 실수 3: 중복된 여백
// margin과 padding이 중복되어 과도한 여백
Container({
margin: EdgeInsets.all(20),
child: Container({
padding: EdgeInsets.all(20), // 중복!
child: Text("텍스트")
})
})
✅ 올바른 방법:
// 목적에 맞게 하나만 사용
Container({
padding: EdgeInsets.all(20),
child: Text("텍스트")
})
🎊 완성된 결과
이 튜토리얼을 완료하면:
- 시각적 개선: 박스들 간에 적절한 간격이 생겨 깔끔해짐
- 가독성 향상: 텍스트가 박스 가장자리에서 떨어져 읽기 편함
- 전문적 외관: 일관된 여백으로 완성도 높은 디자인
- 확장 가능: 여백 패턴을 다른 프로젝트에도 적용 가능
🔥 추가 도전 과제
1. 다크 모드 지원 여백 시스템
다크 모드에서도 적절한 시각적 분리를 제공하는 여백 시스템을 만들어보세요.
2. 애니메이션 여백 변화
호버나 클릭 시 여백이 부드럽게 변화하는 인터랙티브 컴포넌트를 만들어보세요.
3. 접근성을 고려한 여백
스크린 리더 사용자와 키보드 내비게이션을 고려한 여백 설계를 해보세요.
4. 성능 최적화
많은 수의 아이템에서도 효율적인 여백 관리 시스템을 구현해보세요.
🎯 다음 단계
다음 튜토리얼에서는 **SizedBox와 ConstrainedBox**를 배워보겠습니다. 정확한 크기 제어와 제약 조건을 통해 더욱 정교한 레이아웃을 만들어보세요!