MUI로 React UI 만들기: 레이아웃, sx Prop, 테마와 다크 모드, 폼 예제
이 글의 핵심
MUI는 컴포넌트가 풍부한 만큼 스타일을 어디서 바꿔야 하는지 헷갈리기 쉽습니다. 일회성 조정은 sx prop, 전체 일관성은 테마 토큰으로 나누는 기준을 세우고, 다크 모드와 폼 예제로 실제 구성을 보여 줍니다. Chakra UI와의 비교, 번들 크기, Material Design이 아닌 디자인 적용 가능 여부도 FAQ로 다룹니다.
이 글의 핵심
MUI로 React 화면을 구성하는 방법을 정리한 글입니다. Components, Theming, sx Prop, Customization, TypeScript를 예제로 다룹니다. 본문 코드는 MUI v5 기준이며, v6·v7에서 바뀐 부분은 해당 위치에 적었습니다.
실무에서 마주치는 문제들
Material Design 구현이 어려워요
버튼 하나에도 hover·focus·disabled 상태, 물결(ripple) 효과, 키보드 포커스 표시가 필요하고, 드롭다운이나 모달은 포커스 가두기, ESC 닫기, 스크린 리더용 ARIA 속성까지 챙겨야 합니다. 이런 세부 사항을 직접 구현하면 시간도 오래 걸리고 접근성 버그가 남기 쉽습니다. MUI는 Material Design 가이드라인을 따라 이런 동작이 이미 구현된 컴포넌트를 제공합니다.
일관된 디자인이 필요해요
여러 사람이 화면을 만들면 버튼 모서리 반경, 간격, 회색 계열 색상이 조금씩 달라집니다. MUI의 테마는 색상, 글꼴, 간격 단위, 모서리 반경을 한곳에 정의하고 모든 컴포넌트가 이를 참조하게 해서, 값 하나를 바꾸면 앱 전체에 반영됩니다.
빠른 프로토타이핑이 필요해요
관리자 화면이나 사내 도구처럼 디자인 차별화보다 기능이 중요한 화면은 MUI 기본 스타일만으로도 충분히 완성도 있게 보입니다. 데이터 그리드, 날짜 선택기처럼 직접 만들기 까다로운 컴포넌트도 MUI X 패키지로 제공됩니다(일부 고급 기능은 유료).
반대로 브랜드 디자인이 Material Design과 많이 다르다면 기본 스타일을 덮어쓰는 작업이 오히려 부담이 됩니다. MUI 컴포넌트의 내부 DOM 구조와 클래스 이름을 알아야 원하는 부분을 바꿀 수 있어서, 이 경우 스타일이 없는 Base UI나 Radix 기반의 shadcn/ui처럼 처음부터 스타일을 입히는 방식이 더 맞을 수 있습니다.
MUI란?
핵심 특징
MUI (Material-UI)는 Google의 Material Design을 React 컴포넌트로 구현한 라이브러리입니다. 주요 장점:
- Material Design: Google 디자인 가이드라인 기반
- 풍부한 컴포넌트: 입력, 레이아웃, 내비게이션, 피드백 컴포넌트
- 테마: 색상·글꼴·간격 토큰 커스터마이징
- 접근성: WAI-ARIA 패턴을 따르는 키보드·스크린 리더 지원
- TypeScript: 테마와 props 타입 제공
설치 및 설정
설치
npm install @mui/material @emotion/react @emotion/styled
@emotion/react와 @emotion/styled를 함께 설치하는 이유는 MUI가 기본 스타일 엔진으로 Emotion을 쓰기 때문입니다. 이 둘이 없으면 Module not found: Can't resolve '@emotion/react' 에러로 빌드가 멈춥니다. 여기서 알아 둘 점은 Emotion이 런타임 CSS-in-JS라 Next.js App Router의 서버 컴포넌트에서는 MUI 컴포넌트가 동작하지 않는다는 것입니다. App Router에서는 @mui/material-nextjs의 AppRouterCacheProvider로 스타일을 서버 렌더링에 주입하고, MUI를 쓰는 컴포넌트는 클라이언트 컴포넌트로 둡니다.
아이콘
npm install @mui/icons-material
기본 사용
import { Button, Box, Typography } from '@mui/material';
export default function App() {
return (
<Box sx={{ p: 4 }}>
<Typography variant="h4" gutterBottom>
Welcome to MUI
</Typography>
<Button variant="contained">Click me</Button>
</Box>
);
}
Box는 스타일을 입힐 수 있는 범용 <div>이고, Typography는 테마의 글꼴 규칙을 적용하는 텍스트 컴포넌트입니다. variant="h4"는 보이는 모양과 함께 기본적으로 <h4> 태그를 렌더링합니다. 디자인상 큰 글씨가 필요하지만 문서 구조상 제목이 아니라면 <Typography variant="h4" component="p">처럼 component로 태그를 따로 지정해야 제목 계층이 어긋나지 않습니다. 검색 엔진과 스크린 리더가 제목 구조를 읽기 때문에 이 구분이 의외로 중요합니다.
기본 컴포넌트
import {
Button,
TextField,
Checkbox,
Radio,
Select,
Switch,
Slider,
Rating,
} from '@mui/material';
export default function Components() {
return (
<>
<Button variant="contained">Contained</Button>
<Button variant="outlined">Outlined</Button>
<Button variant="text">Text</Button>
<TextField label="Email" variant="outlined" />
<TextField label="Password" type="password" />
<Checkbox defaultChecked />
<Radio />
<Switch defaultChecked />
<Slider defaultValue={50} />
<Rating defaultValue={3} />
</>
);
}
defaultChecked, defaultValue처럼 default가 붙은 props는 비제어 방식으로 초기값만 정하고, checked/value와 onChange를 함께 넘기면 제어 방식이 됩니다. 한 컴포넌트에서 두 방식을 섞거나, 처음에 value={undefined}였다가 나중에 값을 넣으면 A component is changing an uncontrolled input to be controlled 경고가 납니다. 서버에서 받은 값으로 폼을 채울 때 초기값이 undefined인 경우에 자주 나오므로, 제어 방식이라면 처음부터 빈 문자열을 넣어 둡니다.
import에 Select가 있지만 예제에서는 쓰지 않았습니다. Select는 <MenuItem> 자식과 함께 쓰며, 라벨을 붙이려면 FormControl과 InputLabel로 감싸고 labelId와 label을 맞춰야 라벨이 테두리 위로 올바르게 올라갑니다. 단순한 선택이라면 <TextField select>가 이 조합을 한 번에 처리해 줍니다. Radio와 Checkbox도 단독으로 쓰면 클릭할 수 있는 라벨이 없으므로, 실제로는 FormControlLabel로 감싸 텍스트와 연결합니다.
Layout
import { Container, Grid, Box, Stack, Paper } from '@mui/material';
export default function Layout() {
return (
<Container maxWidth="lg">
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>Card 1</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>Card 2</Paper>
</Grid>
</Grid>
<Stack direction="row" spacing={2} mt={4}>
<Box>Item 1</Box>
<Box>Item 2</Box>
</Stack>
</Container>
);
}
Grid는 12칸 기반 반응형 격자입니다. xs={12} md={6}은 작은 화면에서는 12칸 전체(한 줄에 하나), md(기본 900px) 이상에서는 6칸(한 줄에 둘)을 차지하라는 뜻이며, 모바일 우선이라 큰 중단점만 따로 지정하면 됩니다. 이 item·xs 문법은 v5의 기존 Grid 기준입니다. v6에서 도입된 Grid2가 v7에서 기본 Grid가 되면서 item prop이 없어지고 size={{ xs: 12, md: 6 }}로 바뀌었으며, 기존 방식은 GridLegacy로 이름이 바뀌었습니다. 버전을 올린 뒤 레이아웃이 모두 한 줄로 쌓인다면 이 변경을 의심해 봐야 합니다.
Stack은 flexbox로 자식들을 한 방향으로 나열하고 spacing으로 간격을 줍니다. 격자가 필요 없는 단순한 가로·세로 배치는 Grid보다 Stack이 가볍고 읽기 쉽습니다. spacing={3}의 숫자는 픽셀이 아니라 테마 간격 단위(기본 8px)의 배수라서 24px이 됩니다. mt={4}처럼 컴포넌트에 직접 붙인 스타일 prop도 같은 규칙을 따르는데, 이 system props 방식은 v6부터 deprecated되어 sx={{ mt: 4 }}로 쓰는 것이 권장됩니다.
sx Prop
import { Box } from '@mui/material';
export default function SxProp() {
return (
<Box
sx={{
p: 4,
m: 2,
bgcolor: 'primary.main',
color: 'white',
borderRadius: 2,
'&:hover': {
bgcolor: 'primary.dark',
},
}}
>
Styled Box
</Box>
);
}
sx는 CSS를 객체로 쓰되 테마 값을 짧게 참조할 수 있게 한 prop입니다. p: 4는 padding: 32px(4 × 8px), bgcolor: 'primary.main'은 테마 팔레트의 주 색상, borderRadius: 2는 테마 shape.borderRadius(기본 4px)의 두 배로 해석됩니다. 테마 색을 문자열 경로로 참조하기 때문에 다크 모드로 바꾸면 primary.main도 자동으로 다크 팔레트 값이 됩니다. 반면 color: 'white'처럼 직접 적은 값은 테마와 무관하게 고정되므로, primary.contrastText처럼 팔레트 값을 쓰는 편이 테마 변경에 안전합니다.
반응형 값은 p: { xs: 2, md: 4 }처럼 중단점 객체로 넣습니다. sx는 편리하지만 렌더링마다 스타일 객체를 해석해 클래스를 만들기 때문에, 수천 행짜리 목록의 각 행에 복잡한 sx를 넣으면 렌더링 비용이 눈에 띄게 늘어납니다. 반복되는 스타일은 styled()로 컴포넌트를 만들어 두거나 테마의 styleOverrides로 옮기는 것이 좋습니다.
Theming
import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme({
palette: {
primary: {
main: '#3498db',
},
secondary: {
main: '#2ecc71',
},
},
typography: {
fontFamily: 'Arial, sans-serif',
h1: {
fontSize: '2.5rem',
fontWeight: 700,
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
textTransform: 'none',
},
},
},
},
});
export default function App() {
return (
<ThemeProvider theme={theme}>
{/* 컴포넌트 */}
</ThemeProvider>
);
}
palette.primary에는 main만 지정해도 MUI가 light, dark, contrastText를 자동으로 계산합니다. 자동 계산된 contrastText는 명도 대비를 기준으로 흰색이나 검은색 중 하나를 고르는데, 브랜드 가이드에 정해진 텍스트 색이 있다면 직접 지정하는 편이 정확합니다. #3498db 같은 중간 밝기 파랑은 흰 글자와의 대비가 WCAG AA 기준(4.5:1)에 못 미칠 수 있으므로 접근성 검사 도구로 확인해 보는 것이 좋습니다.
components.MuiButton.styleOverrides는 앱의 모든 Button에 스타일을 덮어씁니다. MUI 버튼의 기본값인 대문자 변환(textTransform: 'uppercase')은 한국어에는 영향이 없지만 영문 라벨을 모두 대문자로 바꾸므로, 대부분의 프로젝트에서 이처럼 none으로 끕니다. 특정 variant에만 적용하고 싶다면 root 대신 contained 같은 슬롯 키를 쓰거나, variants 배열로 props 조건별 스타일을 정의합니다. 여기서 TypeScript를 쓴다면 테마에 새 색상(palette.brand)을 추가할 때 declare module '@mui/material/styles'로 Palette와 PaletteOptions 인터페이스를 확장해야 theme.palette.brand에서 타입 에러가 나지 않습니다.
Dark Mode
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { CssBaseline, Button } from '@mui/material';
import { useState } from 'react';
export default function App() {
const [mode, setMode] = useState<'light' | 'dark'>('light');
const theme = createTheme({
palette: {
mode,
},
});
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>
Toggle {mode === 'light' ? 'Dark' : 'Light'}
</Button>
</ThemeProvider>
);
}
palette.mode를 'dark'로 바꾸면 MUI가 배경, 텍스트, 구분선 색을 다크 모드용 기본값으로 바꿉니다. CssBaseline은 body의 배경색과 글자색을 테마에 맞춰 주므로, 이것이 없으면 컴포넌트는 어두워지는데 페이지 배경은 흰색으로 남는 어색한 화면이 됩니다.
이 예제는 동작을 보여 주기 위한 최소 형태라 실제 서비스에서는 세 가지를 보완해야 합니다. 첫째, createTheme을 렌더링마다 새로 호출하고 있으므로 useMemo(() => createTheme(...), [mode])로 감싸 모드가 바뀔 때만 테마를 만들어야 합니다. 테마 객체가 매번 바뀌면 모든 MUI 컴포넌트가 스타일을 다시 계산합니다. 둘째, 선택한 모드를 localStorage에 저장하지 않으면 새로고침할 때마다 라이트 모드로 돌아가고, 저장하더라도 React가 값을 읽기 전에 화면이 먼저 라이트 모드로 그려져 번쩍이는 문제가 남습니다. 셋째, 운영체제의 다크 모드 설정을 따르려면 useMediaQuery('(prefers-color-scheme: dark)')로 기본값을 정해야 합니다.
v6 이상에서는 createTheme({ colorSchemes: { dark: true }, cssVariables: true })와 useColorScheme() 훅으로 이 문제들을 한 번에 해결할 수 있습니다. 색상을 CSS 변수로 출력하고 <html>의 속성만 바꿔 모드를 전환하므로, 테마 재생성 없이 전환되고 InitColorSchemeScript로 첫 화면 깜빡임도 막을 수 있습니다.
Form 예제
import {
Box,
TextField,
Button,
FormControl,
FormLabel,
FormHelperText,
Stack,
} from '@mui/material';
import { useForm, Controller } from 'react-hook-form';
interface FormData {
email: string;
password: string;
}
export default function LoginForm() {
const { control, handleSubmit, formState: { errors } } = useForm<FormData>();
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ maxWidth: 400 }}>
<Stack spacing={3}>
<Controller
name="email"
control={control}
rules={{ required: 'Email is required' }}
render={({ field }) => (
<TextField
{...field}
label="Email"
error={!!errors.email}
helperText={errors.email?.message}
/>
)}
/>
<Controller
name="password"
control={control}
rules={{ required: 'Password is required' }}
render={({ field }) => (
<TextField
{...field}
label="Password"
type="password"
error={!!errors.password}
helperText={errors.password?.message}
/>
)}
/>
<Button type="submit" variant="contained" fullWidth>
Submit
</Button>
</Stack>
</Box>
);
}
TextField는 내부에 실제 <input>이 있지만 ref가 바깥 <div>에 붙기 때문에 React Hook Form의 register를 그대로 스프레드하면 포커스 이동 같은 기능이 제대로 동작하지 않습니다. 그래서 예제는 Controller로 감쌌습니다. register를 쓰고 싶다면 const { ref, ...rest } = register('email') 후 <TextField {...rest} inputRef={ref} />처럼 inputRef로 연결하는 방법도 있습니다.
Controller를 쓸 때는 useForm에 defaultValues: { email: '', password: '' }를 지정해 두는 것이 좋습니다. 기본값이 없으면 첫 렌더링의 field.value가 undefined라서, 입력을 시작하는 순간 앞에서 설명한 uncontrolled → controlled 경고가 납니다. error와 helperText를 연결하면 TextField가 테두리를 빨갛게 바꾸고 아래에 메시지를 표시하며, aria-invalid와 aria-describedby도 자동으로 설정해 스크린 리더가 에러를 읽어 줍니다. 예제에서 import한 FormControl, FormLabel, FormHelperText는 TextField가 내부적으로 쓰는 부품이라, 체크박스 그룹처럼 TextField로 표현할 수 없는 입력을 조립할 때 직접 씁니다.
Icons
import { Button } from '@mui/material';
import { Send, Delete, Add } from '@mui/icons-material';
export default function Icons() {
return (
<>
<Button startIcon={<Send />}>Send</Button>
<Button startIcon={<Delete />} color="error">
Delete
</Button>
<Button startIcon={<Add />} variant="outlined">
Add
</Button>
</>
);
}
@mui/icons-material에는 2천 개가 넘는 아이콘이 들어 있습니다. 프로덕션 빌드에서는 트리 셰이킹으로 쓴 아이콘만 남지만, 개발 서버는 import { Send } from '@mui/icons-material' 같은 이름 있는 import를 처리하려고 패키지 전체 진입점을 읽기 때문에 첫 실행이나 HMR이 눈에 띄게 느려질 수 있습니다. 개발 중 느림이 체감된다면 import SendIcon from '@mui/icons-material/Send'처럼 아이콘별 경로로 가져오는 것이 가장 확실한 해결책입니다. Next.js라면 optimizePackageImports 설정이 이 변환을 자동으로 해 줍니다.
아이콘만 있는 버튼(IconButton)은 스크린 리더가 읽을 텍스트가 없으므로 aria-label="삭제"를 반드시 붙여야 합니다. 위 예제처럼 텍스트가 함께 있는 버튼은 아이콘이 장식용으로 처리되어 따로 설정할 필요가 없습니다.
정리 및 체크리스트
핵심 요약
- MUI: React Material Design 라이브러리
- 레이아웃: Grid(12칸), Stack(한 방향 나열)
- 스타일: 일회성은
sx, 반복은styled·테마styleOverrides - 테마: 팔레트·글꼴·간격 토큰으로 일관성 유지
- 다크 모드:
palette.mode또는 v6+colorSchemes - 폼: React Hook Form은
Controller나inputRef로 연결
구현 체크리스트
- MUI 설치
- Provider 설정
- 기본 컴포넌트 사용
- Layout 구성
- Form 구현
- Theming 커스터마이징
- Dark Mode 구현
- Icons 활용
같이 보면 좋은 글
- Chakra UI 가이드
- shadcn/ui: 설치 대신 소스를 복사하는 컴포넌트 키트, 테마·다크 모드, CLI, 커스터마이징
- React Hook Form으로 폼 만들기
- Emotion으로 React 스타일링
자주 묻는 질문 (FAQ)
Q. Chakra UI와 비교하면 어떤가요?
A. MUI는 컴포넌트 종류가 많고 데이터 그리드·날짜 선택기 같은 MUI X 생태계가 있어 관리자 화면처럼 기능이 많은 앱에 유리합니다. Chakra UI는 기본 스타일이 중립적이라 Material Design과 거리가 먼 디자인을 입히기가 상대적으로 수월합니다.
Q. 번들 크기는 어떤가요?
A. 트리 셰이킹으로 쓴 컴포넌트만 포함되지만, Emotion 런타임과 공통 스타일 코드가 기본으로 들어가 가벼운 편은 아닙니다. 실제 크기는 번들 분석 도구로 확인하는 것이 정확합니다.
Q. Material Design만 가능한가요?
A. 테마와 styleOverrides로 상당 부분 바꿀 수 있지만, 컴포넌트 구조와 상호작용은 Material Design 기준으로 설계되어 있습니다. 디자인이 크게 다르다면 덮어쓰는 비용과 스타일 없는 라이브러리를 쓰는 비용을 비교해 보는 것이 좋습니다.
Q. 버전 업그레이드는 어렵나요?
A. 메이저 버전마다 Grid API, system props, 테마 구조 같은 변경이 있습니다. 공식 마이그레이션 가이드와 codemod(npx @mui/codemod)를 함께 쓰면 반복 작업을 상당 부분 자동화할 수 있습니다.