UI 라이브러리
@mui/styled-engine, CSS 유틸리티 @mui/system, 공용 헬퍼 @mui/utils 등이 뼈대를 이루고, 그 위에 @mui/material(컴포넌트 본체)이 얹힌다. 예전에 이 레포 안에 있던 Base UI·Joy UI·Pigment CSS는 최근 별도 저장소로 완전히 분리됐다 — "MUI 생태계 지도"가 계속 바뀌고 있다는 뜻이라 함정 섹션에서 자세히 다룬다.
mui/material-ui · 라이선스 MIT(핵심 패키지) · 현재 버전 @mui/material 9.2.0 · pnpm 워크스페이스 + Lerna(independent) + Nx 캐싱 · Node ≥22.23.1 · 최신 커밋 2026-07-29, 릴리스 9.2.0은 2026-07-03 · MUI X(유료/일부 상용 라이선스)는 별도 저장소)
Material UI는 "이미 완성된 UI 부품 상자"다. 버튼·체크박스·다이얼로그·앱바·테이블·폼 필드처럼 웹 앱에 필요한 화면 요소를 <Button>, <Dialog>, <TextField> 같은 React 컴포넌트로 이미 만들어 배포한다. 개발자는 이걸 가져다 조립하고, 필요하면 테마(색·글꼴·간격)만 바꿔 자기 브랜드에 맞춘다.
npm install @mui/material @emotion/react @emotion/styled 한 줄이면 시작할 수 있다. 흔히 "MUI"라고 줄여 부르며, 회사 이름도 MUI(예전 material-ui.com)다. 이 저장소 하나에 @mui/material(컴포넌트), @mui/system(스타일 유틸), @mui/icons-material(구글 아이콘 2,000개+), @mui/lab(실험적 컴포넌트) 등 10여 개 패키지가 함께 산다.
레고로 집을 지을 때, 벽돌 하나하나를 처음부터 사출 성형하는 사람은 없다. 이미 규격화된 블록을 사서 조립하고, 원하면 색만 바꿔 칠한다.
Material UI는 웹 화면의 "정식 규격 블록 세트"다. 버튼·모달·테이블을 처음부터 CSS로 깎는 대신, 이미 접근성·반응형·다국어까지 검증된 블록을 가져다 조립하고, 팔레트(테마)만 바꿔 내 서비스 색으로 물들인다.
Material UI는 2014년부터 이어져 온, React UI 컴포넌트 생태계의 사실상 표준이다. React 프로젝트에서 "UI 라이브러리 뭐 쓸까"라는 질문에 가장 먼저 나오는 이름 중 하나이고, 채용 공고·튜토리얼·부트캠프 커리큘럼에 광범위하게 등장한다. "오래돼서 유행이 지났다"는 인식과 달리, npm 주간 다운로드 기준으로도 여전히 최상위권을 지킨다.
최근 shadcn/ui처럼 "라이브러리를 설치하는 대신 소스 코드를 복사해 온다"는 접근이 뜨면서 전통적 컴포넌트 라이브러리가 낡아 보인다는 말이 나온다. 하지만 Material UI는 "완성된 디자인 시스템 + 방대한 접근성·국제화(i18n) 테스트"가 이미 끝나 있다는 점에서 여전히 다른 선택지다. 처음부터 커스텀 디자인을 원하면 shadcn류가 맞고, 빠르게 완성도 있는 화면이 목표면 MUI가 여전히 강하다.
겉보기엔 "오래된 라이브러리"지만 내부는 계속 바뀐다. CSS 변수 기반 테마(cssVariables: true)로 서버 사이드 렌더링에서도 깜빡임 없는 다크모드를 지원하게 됐고, Pigment CSS라는 빌드타임 제로런타임 스타일링 실험이 진행 중이다(현재는 별도 저장소로 독립). Base UI·Joy UI를 별도 프로젝트로 떼어내면서 이 레포 자체는 "@mui/material에 집중"하는 쪽으로 재편되기도 했다.
Ant Design·Chakra UI·Mantine 같은 완성형 라이브러리와 경쟁하는 동시에, shadcn/ui 같은 복사형(소스를 내 코드로 가져오는) 접근, Radix·Base UI 같은 헤드리스(스타일 없는 동작만 제공) 접근과도 비교된다. Material UI는 "스타일까지 다 되어 있는 완성형"의 대표 주자 자리를 지키고 있다.
겉보기엔 간단해 보이는 버튼 하나에도 실은 포커스 링·키보드 조작·스크린리더 라벨·눌림 효과(ripple)·비활성 상태·로딩 상태·반응형 크기까지 신경 쓸 게 많다. 체크박스·드롭다운·모달은 더하다. 이걸 매 프로젝트 처음부터 만들면 시간이 많이 들고, 접근성 실수가 나기 쉽다.
Material UI는 이런 "당연히 필요하지만 반복적이고 까다로운" UI 로직을 미리 검증된 형태로 제공한다. 회사가 새 디자인 시스템 없이도 일관된 톤·간격·색상 체계로 화면을 빠르게 완성할 수 있고, 테마 하나로 전체 앱의 색·타이포그래피를 한 번에 바꿀 수 있다. 즉 "디자인 시스템 구축 비용"과 "접근성 준수 비용"을 동시에 줄여준다.
실제로 클론해 packages/를 열어 보면, "Material UI"라는 하나의 패키지가 아니라 여러 패키지가 계층을 이루는 모노레포임을 바로 확인할 수 있다. 핵심만 추리면 다음과 같다.
예전 자료를 보면 @mui/base, @mui/joy가 이 모노레포 안에 있었다고 나온다. 실제로 클론해 확인하면 지금은 둘 다 존재하지 않는다. Base UI는 완전히 새로 설계돼 mui/base-ui 저장소(패키지명도 @base-ui/react·@base-ui-components/react)로 독립했고, Joy UI 관련 코드도 최근 커밋에서 "leftover Joy UI references"를 지우며 정리됐다. packages/pigment-css-react·packages/pigment-react 폴더를 열어보면 실제 코드 대신 "The package has moved to mui/pigment-css"라는 안내 README만 남아 있다 — 이는 문서와 실제 코드가 어긋나는 대표적 함정이라 9장에서 다시 다룬다.
| 패키지 | 역할 |
|---|---|
| @mui/material | Material Design 컴포넌트 본체. 이 저장소의 핵심 산출물 |
| @mui/system | styled(), sx prop, createTheme, 반응형 브레이크포인트 등 CSS 유틸 — Joy UI 같은 다른 컴포넌트 라이브러리도 이 위에 지어졌었음 |
| @mui/styled-engine(-sc) | Emotion↔styled-components를 교체 가능하게 감싸는 얇은 어댑터 계층 |
| @mui/utils | useSlotProps·useForkRef 등 컴포넌트 제작에 쓰이는 공용 훅/헬퍼 |
| @mui/icons-material | 구글 Material 아이콘을 컴포넌트화한 별도 패키지(용량 최대) |
| Base UI (외부 저장소) | 스타일 없는 "헤드리스" 컴포넌트 — mui/base-ui로 독립, 이 레포엔 없음 |
| Joy UI (레포에서 제거) | Material Design과 다른 자체 디자인의 대안 라이브러리였으나 이 레포에서 정리됨 |
| Pigment CSS (외부 저장소) | 빌드타임 제로런타임 CSS-in-JS — mui/pigment-css로 독립, @mui/material-pigment-css가 어댑터로만 남음 |
styled()·sx prop — Emotion을 "갈아 끼울 수 있게" 감춘다
Material UI의 모든 컴포넌트는 CSS-in-JS 라이브러리 Emotion 위에서 스타일을 만든다. 그런데 실제 소스(packages/mui-styled-engine/src/index.ts)를 보면 컴포넌트들이 Emotion을 직접 부르지 않는다. 대신 @mui/styled-engine이라는 얇은 재수출(re-export) 계층을 거친다.
// packages/mui-styled-engine/src/index.ts (발췌, 실제 소스)
import emStyled from '@emotion/styled';
function styled(tag, options) {
const stylesFactory = emStyled(tag, options);
// 개발 모드에서 styled(Comp)() 처럼 인자 없이 호출하면 경고를 띄운다
return stylesFactory;
}
export default styled;
export { ThemeContext, keyframes, css } from '@emotion/react';
@mui/styled-engine-sc는 같은 인터페이스로 Emotion 대신 styled-components를 감싼다. 그래서 프로젝트 번들러 설정에서 @mui/styled-engine을 @mui/styled-engine-sc로 별칭(alias) 처리하면, MUI 컴포넌트 코드는 한 줄도 안 건드리고 내부 CSS-in-JS 엔진을 바꿀 수 있다. 이게 "styled-engine 추상화"의 정체다.
그 위에 @mui/system의 createStyled(packages/mui-system/src/createStyled/createStyled.js)가 한 겹 더 쌓인다. 여기서 모든 스타일드 컴포넌트에 sx prop을 자동으로 붙여준다 — attachTheme으로 테마를 주입하고, styleFunctionSx로 sx={{ p: 2, color: 'primary.main' }} 같은 객체를 실제 CSS로 변환한 뒤 기존 스타일 뒤에 이어붙인다. 즉 "Emotion 스타일 + sx 인라인 스타일"을 한 컴포넌트가 동시에 받는 구조다.
전기 콘센트(styled-engine)와 가전제품(MUI 컴포넌트)의 관계다. 컴포넌트는 "표준 규격 콘센트"에만 꽂혀 있을 뿐, 그 뒤에 어떤 발전소(Emotion 또는 styled-components)가 있는지 신경 쓰지 않는다. 발전소를 바꿔도 콘센트 모양(인터페이스)만 같으면 가전제품은 그대로 쓸 수 있다.
sx={{ mt: 2, display:'flex' }}처럼 테마 값(간격 단위·팔레트 색 이름)을 그대로 CSS로 바꿔준다. 매번 styled()로 새 컴포넌트를 안 만들어도 되는 "빠른 오버라이드" 용도다.createTheme·ThemeProvider — 테마 스케일과 팔레트 생성
@mui/system의 기본 createTheme(packages/mui-system/src/createTheme/createTheme.js)는 breakpoints·spacing·shape만 만드는 저수준 버전이다. 실제로 앱에서 쓰는 건 @mui/material/styles의 createTheme(packages/mui-material/src/styles/createTheme.ts)인데, 이게 팔레트 생성(createPalette)·타이포그래피 스케일·트랜지션·컴포넌트별 기본값까지 얹은 "완전판"이다.
import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme({
palette: { primary: { main: '#1565c0' } },
});
function App() {
return (
<ThemeProvider theme={theme}>
<Button>눌러보세요</Button>
</ThemeProvider>
);
}
primary.main 색 하나만 주면, 내부적으로 createPalette가 light/dark 보조색을 자동 생성하고, 컴포넌트들은 ThemeProvider의 React Context를 통해 이 값을 읽어 자기 스타일을 결정한다. "버튼 색을 하나하나 바꾸는 대신 테마 하나로 앱 전체 톤을 바꾼다"는 MUI의 핵심 사용 방식이 여기서 나온다.
cssVariables)와 다크모드 — 실제 분기 코드
v5까지는 테마 값이 JS 객체로만 존재해, 다크모드 전환 시 리액트가 전체 트리를 다시 렌더링해야 했다. 최근 버전은 cssVariables: true 옵션으로 테마 값을 실제 CSS 커스텀 프로퍼티(--mui-palette-primary-main 등)로 뽑아내는 방식을 지원한다. createTheme.ts의 실제 분기 로직을 그대로 옮기면:
// packages/mui-material/src/styles/createTheme.ts (발췌)
if (cssVariables === false) {
// v5와 완전히 동일하게 동작 — JS 객체 테마
return createThemeNoVars(options, ...args);
}
// cssVariables: true 인 경우 — CSS 변수 + colorSchemes 기반 테마
return createThemeWithVars({
...other,
colorSchemes: colorSchemesInput,
defaultColorScheme: defaultColorSchemeInput,
}, ...args);
cssVariables: true로 만들면 colorSchemes: { light: {...}, dark: {...} }를 함께 정의할 수 있고, 실제 화면 전환은 CSS의 data-mui-color-scheme 어트리뷰트만 바꿔치기하면 된다 — React 리렌더링 없이 브라우저가 CSS 변수 값을 즉시 다시 계산해준다. useColorScheme() 훅으로 모드를 읽고 바꾸며, 서버 사이드 렌더링에서 "처음 깜빡이는(FOUC)" 문제도 InitColorSchemeScript가 막아준다.
신규 프로젝트는 cssVariables: true를 켜는 것이 이제 사실상 권장 경로다. 다크모드가 리렌더링 없이 즉시 전환되고, SSR 깜빡임도 없다. 다만 커스텀 CSS에서 테마 값을 theme.palette.primary.main처럼 JS로 직접 읽던 코드가 있다면, CSS 변수 문자열(var(--mui-palette-primary-main))로 바뀌는 부분을 마이그레이션 가이드로 확인해야 한다.
slots·slotProps와 useSlotProps — 내부 구조를 열어주는 커스터마이징 API
Autocomplete·DataGrid류의 복합 컴포넌트는 내부에 여러 하위 요소(팝업, 리스트박스, 페이퍼 등)를 갖는다. 이를 개별적으로 바꿔치기·prop 추가할 수 있게 하는 것이 slots(컴포넌트 교체)와 slotProps(그 컴포넌트에 넘길 prop)다. 실제 구현은 @mui/utils의 useSlotProps(packages/mui-utils/src/useSlotProps/useSlotProps.ts)가 담당한다.
// 사용 예 — Select의 팝업 부분(paper)만 커스텀 스타일 주기
<Select
slotProps={{
paper: { sx: { borderRadius: 2 } },
}}
>
...
</Select>
useSlotProps 내부는 외부에서 온 slotProps(함수 또는 객체 모두 허용)를 컴포넌트 내부 기본 props와 병합(mergeSlotProps)하고, ownerState(현재 상태값)를 필요하면 함께 넘겨준다. "라이브러리가 미리 정한 마크업 안에서, 내가 원하는 하위 요소만 정밀 타격으로 바꾼다"는 게 이 API의 핵심 아이디어이며, 최근 data-* 어트리뷰트도 slotProps에 직접 얹을 수 있도록 확장됐다(9.2.0 변경 로그).
MUI는 런타임에 스타일을 계산하는 Emotion 방식의 성능 한계(초기 렌더링·SSR 비용)를 넘기 위해 빌드타임에 CSS를 미리 뽑아내는 Pigment CSS를 실험해 왔다. 하지만 이 저장소 안의 packages/pigment-css-react·packages/pigment-react 폴더는 현재 빈 껍데기고, 실제 구현은 mui/pigment-css라는 별도 저장소로 옮겨졌다. 이 레포에 남아 있는 @mui/material-pigment-css는 "Pigment CSS 위에 Material UI와 같은 styled/테마 API를 얹어주는 얇은 어댑터"일 뿐이다.
README에 명시된 대로, MUI X(mui/mui-x 별도 저장소)가 DataGrid·Date/Time Pickers·Charts·Tree View 같은 복합 컴포넌트를 담당한다. 이 중 상당수는 Community(MIT) 버전과 Pro/Premium(상용 라이선스) 버전이 나뉘어 있다. material-ui 레포 자체는 순수 MIT지만, "MUI 생태계 전체가 MIT다"라고 오해하면 안 된다.
| 영역 | 내용 |
|---|---|
| 모노레포 관리 | pnpm 워크스페이스(pnpm-workspace.yaml) + Lerna(version: independent — 패키지별 독립 버전) + Nx(빌드 캐시·의존 그래프) |
| CSS-in-JS | 기본 Emotion(@emotion/react, @emotion/styled) — @mui/styled-engine-sc로 styled-components로 교체 가능 |
| 언어 | TypeScript 전면 전환이 진행형(레거시 .js + .d.ts 쌍을 .ts로 옮기는 작업이 최근 커밋 로그에도 계속 등장) |
| 테스트 | Vitest(단위 테스트, cross-env TZ=UTC vitest) + Playwright(e2e·시각 회귀). 과거 Karma+Mocha 체계에서 이관 완료 |
| 문서 사이트 | docs/ 하위의 Next.js 기반 mui.com 문서 — 데모 코드가 곧 통합 테스트 역할도 겸함 |
| CI | GitHub Actions 워크플로 17개(ci.yml, CodeQL 보안 스캔, OpenSSF Scorecards, 릴리스 자동화 등) |
| 코드 변환 | @mui/codemod — 메이저 버전 업그레이드 시 import 경로·prop 이름을 자동으로 고쳐주는 CLI |
버전의 진실원(source of truth)은 각 패키지 폴더의 package.json이다. 저장소 루트 package.json의 version(예: 9.2.0)은 모노레포 관리용 사설(private) 버전이고, 실제로 npm에 배포되는 @mui/material·@mui/system·@mui/utils 등은 Lerna의 independent 모드 덕에 패키지마다 다른 속도로 버전이 오를 수 있다(실제로 @mui/styled-engine은 9.1.1인데 @mui/material은 9.2.0인 시점이 있다). "루트 버전 = 전체 버전"이라고 단정하면 안 된다.
workspace:^)로 연결해 로컬 개발 중에도 항상 최신 코드끼리 맞물리게 한다.일반 사용자(라이브러리로 쓰는 경우)는 매우 가볍다.
npm install @mui/material @emotion/react @emotion/styled
# 또는 styled-components 를 쓰고 싶다면:
npm install @mui/material @mui/styled-engine-sc styled-components
Roboto 폰트와 아이콘까지 쓰려면 @fontsource/roboto, @mui/icons-material을 추가로 설치한다. Next.js App Router라면 @mui/material-nextjs로 서버 컴포넌트 캐시 처리를 얹는 게 공식 권장이다.
| 대상 | 요구사항 |
|---|---|
| 라이브러리 사용자 | React 18+ (React 19까지 지원), Node는 프레임워크(Vite·Next.js 등) 요구사항을 따름 |
| 레포 기여자(빌드) | Node ≥22.23.1, pnpm 11.17.0(engineStrict로 강제). pnpm install → pnpm build |
| 동시 지원 스타일 엔진 | Emotion(기본) 또는 styled-components — 필수 peerDependency는 둘 중 하나 |
| 제로런타임 실험 | Pigment CSS를 쓰려면 별도 저장소(mui/pigment-css)의 번들러 플러그인을 추가로 설정해야 함 |
이 저장소 자체를 빌드/기여하려면 pnpm install 시점부터 전체 워크스페이스(패키지 16개 + 문서 사이트)를 함께 설치하게 되어 상당히 무겁다. "라이브러리로 쓰기"와 "레포를 클론해 내부 코드를 고치기"는 완전히 다른 난이도의 작업임을 구분해야 한다.
실제 화면 하나를 만드는 전형적인 흐름을 코드로 옮기면 다음과 같다.
import { createTheme, ThemeProvider, CssBaseline } from '@mui/material';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
// 1) 앱 전역 테마 정의 — 색·다크모드 옵션까지 한 번에
const theme = createTheme({
cssVariables: true,
colorSchemes: { light: true, dark: true },
palette: { primary: { main: '#1565c0' } },
});
function App() {
return (
<ThemeProvider theme={theme}>
{/* 브라우저 기본 스타일 초기화 */}
<CssBaseline />
{/* 2) 컴포넌트 조립 — 이미 접근성·상태 처리 완료 */}
<TextField label="이메일" fullWidth sx={{ mb: 2 }} />
<Button variant="contained" sx={{ mt: 1 }}>
가입하기
</Button>
</ThemeProvider>
);
}
더 깊이 커스텀하고 싶으면 slots/slotProps로 내부 하위 요소를 겨냥하고, 아예 새 스타일드 컴포넌트가 필요하면 styled(Button)(...)로 확장한다. 다크모드 토글은 useColorScheme() 훅 하나로 끝난다 — 이 모든 게 위 5장에서 본 내부 설계(styled-engine 추상화·CSS 변수 테마·슬롯 API)가 실제 사용자에게 노출되는 표면이다.
블로그·튜토리얼에서 "MUI의 헤드리스 버전 Base UI"를 이 저장소 안에서 찾으려 하면 실패한다. Base UI는 완전히 새로 설계돼 mui/base-ui라는 별도 저장소로 독립했고, 패키지명도 @base-ui/react 계열로 바뀌었다. 이 레포의 docs/package.json에는 데모용으로만 @base-ui/react: "catalog:docs"가 들어 있을 뿐, 소스 자체는 없다.
Joy UI(Material Design과 다른 자체 톤의 대안 라이브러리)는 이 저장소에서 관련 코드가 제거됐고(최신 변경 로그에 "Remove leftover Joy UI references" 항목이 실제로 있다), Pigment CSS(제로런타임 CSS-in-JS)도 mui/pigment-css로 독립했다. packages/pigment-css-react 폴더를 열어보면 실제 코드가 아니라 "이동했다"는 안내 README만 있다. "material-ui 레포 = MUI 생태계 전부"라는 가정은 더 이상 맞지 않는다.
이 레포(@mui/material 등)는 확실히 MIT다. 하지만 DataGrid의 고급 기능, DatePicker의 일부, Charts, Tree View 같은 복합 컴포넌트를 담당하는 MUI X는 별도 저장소이며 Community(무료)와 Pro/Premium(유료 상용 라이선스)이 나뉜다. "MUI라서 전부 무료겠지"라고 넘겨짚고 프로덕션에 Pro 전용 기능을 쓰면 라이선스 위반이 될 수 있다.
@mui/icons-material를 통째로 import하면 번들이 폭발한다이 패키지 하나만 로컬 디스크에서 130MB가 넘는다(아이콘 2,000개+ 각각을 개별 컴포넌트 파일로 내보내기 때문). import { Delete } from '@mui/icons-material'처럼 배럴(barrel) 임포트를 하면 번들러 설정에 따라 전체가 딸려 들어올 수 있으니, import DeleteIcon from '@mui/icons-material/Delete'처럼 개별 경로 임포트가 안전하다.
@mui/styled-engine-sc를 깔았다고 자동으로 엔진이 바뀌지 않는다. 번들러(Webpack·Vite 등) 설정에서 @mui/styled-engine 임포트를 @mui/styled-engine-sc로 별칭(alias) 처리해야 실제로 적용된다. 이 설정을 빼먹으면 두 엔진이 동시에 번들에 들어가 크기만 커진다.
저장소 루트 package.json의 버전(예: 9.2.0)은 모노레포 관리용 사설 버전이다. @mui/material·@mui/system·@mui/styled-engine 등은 Lerna의 independent 모드로 패키지마다 버전이 따로 오른다. 실제로 쓰는 패키지의 package.json을 직접 확인하는 습관이 안전하다.
| 라이브러리 | 포지션 · 차이점 |
|---|---|
| Material UI | 구글 Material Design 기반 "완성형" 컴포넌트. 방대한 컴포넌트 수·성숙한 접근성·긴 실전 검증 기간이 강점. 디자인이 "구글스럽다"는 인상을 벗기려면 테마 커스터마이징 공수가 든다 |
| Ant Design | 중국 기업(엔터프라이즈) 화면에 강한 "완성형" 라이브러리. 테이블·폼 등 데이터 중심 대시보드 컴포넌트가 촘촘함. 번들 크기와 커스터마이징 자유도에서 MUI와 종종 비교됨 |
| Chakra UI | 유틸리티 스타일 prop과 접근성을 앞세운 "완성형". Material Design 같은 고정 스타일 언어가 없어 처음부터 자유로운 톤을 원할 때 선호됨. 최근 버전은 스타일 엔진을 자체 시스템(Panda CSS 계열)으로 전환하는 중 |
| Mantine | 비교적 최근에 뜬 "완성형" 라이브러리. 훅(hooks) 모음이 강력하고 다크모드·폼 처리가 기본기가 좋다는 평. 커뮤니티·생태계 크기는 MUI보다 아직 작음 |
| shadcn/ui | "복사형" — 라이브러리를 설치하는 대신 Radix/Base UI 기반 소스 코드를 프로젝트에 직접 복사해 넣는다. 처음부터 완전히 다른 커스텀 디자인을 원하면 유리하지만, MUI처럼 "설치 즉시 완성된 스타일"은 제공하지 않는다 |
Material UI는 "React 위에서 완성된 UI 부품 세트를 원하는 사람"에게 여전히 강력한 선택이다. 스타트업 MVP, 사내 어드민, 접근성이 중요한 공공·금융 서비스, "디자이너 없이도 봐줄 만한 화면"이 필요한 개인 프로젝트까지 폭넓게 맞는다. 반대로 처음부터 완전히 고유한 브랜드 룩을 원한다면 shadcn/ui 같은 복사형이나 Chakra·Mantine처럼 스타일 언어가 덜 고정된 쪽이 나을 수 있다.
레포 자체를 공부 대상으로 본다면, ① styled-engine 같은 "구현체를 갈아 끼울 수 있게 감추는" 어댑터 패턴, ② createTheme의 팔레트/스케일 자동 생성, ③ CSS 변수로 리렌더링 없이 다크모드를 전환하는 기법, ④ slots/slotProps로 "정해진 마크업 안에서 정밀하게 커스터마이징을 열어주는" API 설계, ⑤ 모노레포 안에서 패키지 경계·라이선스 경계(MUI X)를 어떻게 긋는지가 좋은 학습 포인트다.
버튼 하나까지 픽셀 단위로 새로 디자인하고 싶은 프로젝트보다는, "이미 검증된 UI 부품으로 빠르게 완성하고 테마로 톤만 맞추면 되는" 프로젝트에 잘 맞는다. 동시에 대규모 오픈소스 모노레포가 스타일 엔진 추상화·테마 시스템·커스터마이징 API를 어떻게 설계하는지 보고 싶은 사람에게도 좋은 교재다.
아래 과제는 의존성이 준비된 별도 실습용 사본에서 수행한다. 명령과 경로는 모두 Material UI 저장소 루트를 기준으로 한다. Node와 pnpm 요구사항은 그 사본의 package.json에서 확인한다.
examples/material-ui-vite-ts/는 pnpm-workspace.yaml의 워크스페이스 목록에 포함되지 않으므로 pnpm --dir로 실행한다. 예제는 자체 package.json에 선언된 Material UI 의존성을 사용하며, packages/ 안의 소스를 바꾼 결과는 뒤의 단위 테스트에서 확인한다.
목표: 완성된 컴포넌트를 조립하면서 React 상태와 컴포넌트 속성의 관계를 익힌다.
examples/material-ui-vite-ts/package.json의 dev·build 스크립트와 src/main.tsx가 src/App.tsx를 렌더링하는 흐름을 읽는다.examples/material-ui-vite-ts/src/App.tsx에 TextField, Button, Typography로 닉네임 설정 패널을 만든다. 컴포넌트는 @mui/material/TextField처럼 개별 경로에서 가져온다.useState로 입력값과 저장된 값을 따로 관리한다. 입력란에 label="닉네임"을 붙이고, value·onChange를 연결한다. 공백을 제거한 입력이 비어 있으면 저장 버튼을 비활성화하고, 클릭하면 저장된 이름을 화면에 표시한다.pnpm --dir examples/material-ui-vite-ts dev
pnpm --dir examples/material-ui-vite-ts build
완료 기준: 빈 입력에서는 저장할 수 없고, 입력한 뒤 저장하면 이름이 표시된다. 입력을 다시 바꿔도 저장된 이름은 다음 저장 전까지 유지되며, 빌드가 통과한다.
목표: 테마의 공통 값이 sx와 CSS 변수로 이어지는 과정을 확인한다.
packages/mui-material/src/styles/createTheme.ts와 packages/mui-system/src/createTheme/createSpacing.ts를 읽어 테마 생성의 분기와 간격 변환을 찾는다.examples/material-ui-vite-ts/src/main.tsx에서 @mui/material/styles의 createTheme·ThemeProvider를 가져온다. 아래 테마를 컴포넌트 바깥에 만들고, 기존 App을 프로바이더로 감싼 뒤 그 안에 CssBaseline을 추가한다.App.tsx에서 useColorScheme()을 호출하는 하위 컴포넌트를 만든다. 초기 mode가 없으면 표시를 보류하고, 두 버튼에서 각각 setMode('light')·setMode('dark')를 호출한다.pnpm --dir examples/material-ui-vite-ts dev로 실행한 뒤, 패널의 Box에 sx={{ p: 2, display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 2 }}를 적용한다. 개발자 도구에서 계산된 여백, 화면 폭별 배치, 색상 모드에 따른 CSS 변수 값을 확인한다.const theme = createTheme({
cssVariables: { colorSchemeSelector: 'class' },
colorSchemes: {
light: { palette: { primary: { main: '#1565c0' } } },
dark: { palette: { primary: { main: '#90caf9' } } },
},
spacing: 10,
});
완료 기준: 위 설정에서 p: 2의 계산된 안쪽 여백은 20px이고, 창 폭을 바꾸면 세로·가로 배치가 전환된다. 모드 버튼으로 배경과 기본색이 바뀌며 --mui-palette-primary-main의 값도 달라진다.
목표: 내부 부품의 속성을 조정하면서 접근성 동작이 유지되는지 확인한다.
packages/mui-material/src/Dialog/Dialog.d.ts에서 paper 슬롯을 확인하고, Dialog.js의 useSlot('paper', ...) 호출을 찾는다. 이어서 packages/mui-material/src/utils/useSlot.ts와 packages/mui-utils/src/mergeSlotProps/mergeSlotProps.ts에서 외부 속성이 합쳐지는 흐름을 읽는다.examples/material-ui-vite-ts/src/App.tsx에 open 상태와 열기 버튼을 만든다. 아래 구성에 필요한 Dialog·DialogTitle·DialogContent·Button을 개별 경로에서 가져온다.pnpm --dir examples/material-ui-vite-ts dev로 실행하고 열기 버튼으로 창을 연다. 개발자 도구로 data-testid와 둥근 모서리가 루트 배경이 아닌 대화상자 표면에 적용됐는지 확인한다.Tab·Shift+Tab으로 이동하고 Escape로 닫는다. 접근성 트리에서 대화상자의 역할과 제목도 확인한다.<Dialog
open={open}
onClose={() => setOpen(false)}
slotProps={{
paper: {
sx: { borderRadius: 3 },
'data-testid': 'preview-paper',
},
}}
>
<DialogTitle>설정 미리보기</DialogTitle>
<DialogContent>
<Button onClick={() => setOpen(false)}>닫기</Button>
</DialogContent>
</Dialog>
완료 기준: 대화상자의 접근 가능한 이름이 “설정 미리보기”로 표시된다. 열린 동안 키보드 초점이 대화상자 안에 머무르고, 닫으면 열기 버튼으로 돌아온다. 슬롯 속성을 바꿔도 이 동작이 유지된다.
목표: 화면의 약속을 테스트로 표현하고, 그 약속을 책임지는 구현까지 추적한다.
packages/mui-material/src/Button/Button.js에서 disabled={disabled || loading}을 찾고, packages/mui-material/src/ButtonBase/ButtonBase.js로 상태가 전달되는 과정을 읽는다.packages/mui-material/src/Button/Button.test.js의 기존 테스트를 참고해 같은 파일에 테스트를 추가한다. createRenderer()의 render, 반환된 user·setProps, spy()로 클릭 횟수를 관찰한다.loading={true}인 버튼을 클릭해 호출 횟수가 0인지 확인한다. setProps({ loading: false }) 뒤 다시 클릭하면 1회가 되고, 다시 로딩 상태로 바꾸고 클릭해도 1회로 유지되는지 검증한다.pnpm test:unit run packages/mui-material/src/Button/Button.test.js --project 'node:@mui/material'
완료 기준: 정상 구현에서는 테스트가 통과하고, 로딩 중 클릭을 허용하면 새 테스트가 실패한다. jsdom 테스트로 확인한 클릭 동작과 실제 브라우저에서 별도로 확인할 시각적 로딩 표시를 구분해 설명할 수 있다.
매주 “입력 속성 → 내부 처리 → 화면에서 관찰한 결과”를 연결해 기록한다. 실습은 앞의 설정 패널을 이어서 사용하고, 소스 읽기는 아래 경로를 기준으로 한다.
| 주차 | 배울 개념 | 읽을 파일·폴더 | 결과물 |
|---|---|---|---|
| 1주차 | React 속성·상태·이벤트, 입력값을 상태로 관리하는 방식, 예제와 라이브러리 소스의 경계 | examples/material-ui-vite-ts/src/main.tsxexamples/material-ui-vite-ts/src/App.tsxpnpm-workspace.yaml |
입력·저장이 동작하는 설정 패널과 예제 실행 경로를 설명한 메모 |
| 2주차 | 디자인 토큰, 팔레트 생성, React Context를 통한 테마 전달 | packages/mui-material/src/styles/createTheme.tspackages/mui-material/src/styles/createPalette.jspackages/mui-material/src/styles/ThemeProvider.tsx |
색·간격을 한곳에서 바꾸는 테마와 컴포넌트까지 값이 전달되는 흐름도 |
| 3주차 | CSS-in-JS, 스타일 엔진 어댑터, sx 변환과 반응형 규칙 |
packages/mui-styled-engine/src/index.tspackages/mui-system/src/createStyled/packages/mui-system/src/styleFunctionSx/ |
p·팔레트 경로·반응형 객체가 어떤 CSS가 되는지 비교한 표와 화면 |
| 4주차 | 슬롯의 교체와 속성 병합, ownerState, 대화상자의 이름과 키보드 초점 관리 |
packages/mui-material/src/Dialog/packages/mui-material/src/utils/useSlot.tspackages/mui-utils/src/mergeSlotProps/packages/mui-material/src/Unstable_TrapFocus/ |
슬롯을 조정한 대화상자, 열기·이동·닫기·초점 복귀를 기록한 점검표 |
| 5주차 | 사용자 동작 중심 테스트, jsdom과 실제 브라우저의 차이, 구현과 공개 타입의 관계 | packages/mui-material/src/Button/Button.test.jspackages/mui-material/src/Button/Button.d.tsvitest.shared.mts |
로딩 중 클릭을 막는 회귀 테스트와 테스트가 지키는 동작을 설명한 짧은 문서 |
| 6주차 | 서버 렌더링과 하이드레이션, Emotion 스타일 수집, 초기 색상 모드와 CSS 선택자의 일치 | examples/material-ui-nextjs-ts/src/app/layout.tsxexamples/material-ui-nextjs-ts/src/theme.tspackages/mui-material-nextjs/src/ |
AppRouterCacheProvider와 InitColorSchemeScript의 역할을 나눈 렌더링 흐름도 |
스타일 수집과 색상 모드 초기화는 서로 다른 일이다. Next.js 예제에서 캐시 프로바이더는 Emotion 스타일을 서버 렌더링 흐름에 연결하고, 초기화 컴포넌트는 본문보다 먼저 색상 모드를 설정한다. 테마의 colorSchemeSelector: 'class'와 초기화 컴포넌트의 attribute="class"가 맞는지도 함께 확인한다.
palette·spacing·typography가 대표적이며, createTheme이 이 값을 컴포넌트가 사용할 형태로 구성한다.pnpm-workspace.yaml이 묶을 경로를 정하므로, examples/에 폴더가 있다고 자동으로 워크스페이스 패키지가 되지는 않는다.packages/mui-material/package.json의 peerDependencies와 peerDependenciesMeta를 함께 보면 React와 스타일 엔진 관련 요구사항, 선택 의존성 여부를 구분할 수 있다.ThemeProvider는 테마를 전달하는 입구이며, useTheme()으로 전달된 테마를 읽는다.packages/mui-styled-engine/src/index.ts가 Emotion의 스타일 생성 기능을 감싼다.@mui/system은 @mui/styled-engine을 통해 스타일을 생성하며, 이 경계가 대체 엔진을 연결하는 지점이 된다.styleFunctionSx가 p: 2 같은 간격 표현과 'primary.main' 같은 색상 경로를 CSS로 해석하므로, 숫자를 모두 픽셀로 읽으면 안 된다.sx={{ flexDirection: { xs: 'column', sm: 'row' } }} 같은 입력을 packages/mui-system/src/breakpoints/의 처리와 연결해 읽을 수 있다.--이름으로 값을 선언하고 var(...)로 읽는 브라우저의 CSS 변수 기능이다. Material UI의 CSS 변수 테마는 --mui-palette-primary-main 같은 값을 만들며, 개발자 도구에서 현재 적용된 값을 확인할 수 있다.colorSchemes로 값을 준비하고 useColorScheme()으로 현재 모드를 읽거나 바꾸며, 수동 전환에 쓸 선택자도 함께 설정한다.Dialog의 paper·backdrop 등이 해당하며, slots는 그 자리에 사용할 컴포넌트를 지정한다.Dialog의 slotProps.paper에 스타일이나 데이터 속성을 넣으면 useSlot과 mergeSlotProps를 거쳐 내부 속성과 합쳐진다.Dialog.js에서는 fullScreen·scroll 등이 포함되며, 함수 형태의 slotProps도 이 값을 받을 수 있다.Button.test.js처럼 클릭·키보드·비활성 상태를 검증하는 테스트가 해당하며, 화면 모양을 비교하는 시각 회귀 테스트와는 확인 대상이 다르다.