dev
Matter.js을 이용해서 물리 엔진 기능 구현하기
1. 들어가며 — 어떤 기능인가?
이전에 만들었던 포트폴리오 메인 페이지의 상단 영역에 음절이 화면 위에서 떨어지는 인터랙션 기능을 구현했습니다.
- "안", "녕", "하", "세", "요"... 와 같은 한글 음절이 하나씩 낙하
- 바닥·벽에 부딪히며 쌓이고, 마우스/터치로 드래그 하며 이동 가능
React로 UI를 구성하고, 2D 물리 엔진 Matter.js로 중력·충돌·드래그를 처리하며, Canvas API로 한글 텍스트를 그립니다.

관련 파일 구성
| 파일 | 역할 |
|---|---|
app/components/HangulJamoPlayground.tsx | 물리 연출 메인 컴포넌트 |
app/utils/syllableToJamos.ts | 한글 문장 → 음절 배열 변환 |
app/components/Introduce.tsx | 플레이그라운드를 배경 레이어로 배치 |
matter-js | 2D 물리 엔진 + 캔버스 렌더러 |
2. Matter.js란?
Matter.js는 브라우저에서 동작하는 JavaScript 2D 물리 엔진입니다.
HTML5 Canvas 위에서 물체의 중력, 충돌, 마찰, 회전, 제약(constraint) 등을 시뮬레이션할 수 있습니다.
Matter.js의 핵심 개념
| 개념 | 설명 |
|---|---|
| Engine | 물리 시뮬레이션의 심장. 중력·충돌 해결·위치 업데이트를 담당 |
| World | Engine 안의 공간. Body, Constraint 등이 모여 있는 컨테이너 |
| Body | 물리 세계에 존재하는 강체. 위치, 속도, 각도, 질량, 마찰 등을 가짐 |
| Bodies | Body를 만드는 팩토리 (rectangle, circle 등) |
| Constraint | 두 Body를 연결하는 제약. 스프링·로프·고정점 등 |
| Runner | Engine.update()를 주기적으로 호출해 물리를 진행시키는 루프 |
| Render | Engine의 World를 캔버스에 그리는 렌더러 (개발·디버깅·간단한 게임용) |
| Mouse / MouseConstraint | 마우스 입력을 받아 Body를 끌어당기는 드래그 기능 |
| Events | afterRender, afterUpdate 등 이벤트 훅 제공 |
왜 Matter.js를 선택했는가?
- 가볍고 브라우저 친화적: 별도 서버 없이 클라이언트에서 바로 동작
- 2D 물리에 특화: 포트폴리오 배경 연출처럼 "떨어지고, 부딪히고, 드래그"하는 요구에 적합
- API가 직관적:
Bodies.rectangle,World.add,MouseConstraint등으로 빠르게 프로토타입 가능 - Canvas 렌더러 내장: 물리 body와 화면 출력을 한 파이프라인에서 다루기 쉬움
3. 전체 아키텍처 — 무엇으로 어떻게 구현했는가?
사용 기술 스택
| 영역 | 기술 | 역할 |
|---|---|---|
| UI 프레임워크 | React (Next.js Client Component) | 마운트/언마운트, 접근성 분기, 페이드 인 |
| 물리 엔진 | Matter.js 0.20 | 중력, 충돌, 드래그, body 위치·각도 계산 |
| 렌더링 | Matter.js Render + Canvas 2D API | 캔버스 프레임 루프 + 한글 텍스트 직접 그리기 |
| 반응형 | ResizeObserver | 컨테이너 크기 변경 시 월드 재구성 |
| 번들 최적화 | import("matter-js") 동적 로딩 | 초기 번들 크기 감소 |
구조 한눈에 보기
[ syllableToJamos.ts ]
↓
jamos: string[] ← "안", "녕", "하", ...
↓
[ React HangulJamoPlayground ]
↓
containerRef (div) ← 캔버스가 붙는 컨테이너
↓
┌───────────────────────────────────────┐
│ Matter.js Engine │
│ - 중력, 충돌, 마찰, 회전 계산 │
│ - Runner.run() 으로 매 프레임 update │
└───────────────────────────────────────┘
↓ body.position, body.angle
┌───────────────────────────────────────┐
│ Matter.js Render │
│ - Render.run() 으로 캔버스 루프 │
│ - afterRender → drawSyllableOnCanvas │
└───────────────────────────────────────┘
↓
Canvas에 한글 음절 텍스트 렌더링
핵심 설계: "물리 body는 숨기고, 텍스트만 그린다"
음절 하나는 Matter.js 직사각형 Body 1개와 대응됩니다.
하지만 body 자체는 화면에 그리지 않고(render: { visible: false }), afterRender 이벤트에서 Canvas fillText 로 한글만 그립니다.
이렇게 하면:
- 물리 충돌 박스(72×100px)와 시각적 텍스트를 분리해 튜닝 가능
- CSS
text-shadow대신 CanvasshadowBlur+strokeText로 네온 효과 구현 - Matter 기본 도형 렌더링에 묶이지 않고 한글 타이포그래피를 자유롭게 표현
4. 데이터 준비 — 음절 단위로 문장 쪼개기
app/utils/syllableToJamos.ts에서 인트로 문장을 완성형 음절 단위로 분리합니다.
export const INTRO_PHRASE_SYLLABLES =
"안녕하세요프론트엔드주니어개발자신희범입니다잘부탁드립니다";
export const getIntroPhraseSyllables = (): string[] => [
...INTRO_PHRASE_SYLLABLES,
];
- 자모(ㄱ, ㅏ, ㄴ) 단위가 아니라 "안", "녕", "하" 같은 음절 단위
- 스프레드 연산자
[...문자열]로 유니코드 코드 포인트 단위 분리 → 한글 음절 1글자 = 배열 원소 1개 - 각 음절이 Matter.js body 1개 + 캔버스 텍스트 1개에 1:1 대응
5. Introduce 섹션에서의 배치
Introduce.tsx에서 플레이그라운드는 섹션 전체를 덮는 배경 레이어로 사용됩니다.
<HangulJamoPlayground className="pointer-events-auto absolute inset-0 z-1" />
<div className="relative z-10 flex w-full flex-col items-center pointer-events-none">
{/* 이름, 소셜 링크, 소개 문구 */}
</div>
| 레이어 | z-index | pointer-events | 역할 |
|---|---|---|---|
| HangulJamoPlayground | z-1 | auto | 음절 드래그·낙하 연출 |
| 전경 콘텐츠 | z-10 | 대부분 none, 링크만 auto | 이름·소개·소셜 링크 |
배경 연출은 드래그를 받고, 전경 텍스트는 기본적으로 클릭을 통과시키되 링크 등 필요한 요소만 pointer-events-auto로 다시 활성화합니다.
6. 동작 흐름 — 처음부터 끝까지
6-1. 마운트: 접근성 확인 및 데이터 로드
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setSkipPhysics(true);
return;
}
setJamos(getIntroPhraseSyllables());
}, []);
- OS/브라우저 모션 축소(
prefers-reduced-motion: reduce) 설정 확인 - 축소 모드면
skipPhysics = true→ 컴포넌트return null(연출 비활성) - 아니면
getIntroPhraseSyllables()로 음절 배열 설정
SSR 주의:
jamos초기값은[]. 클라이언트 마운트 후에만 채워 hydration 불일치를 방지합니다.
6-2. 페이드 인
useEffect(() => {
if (jamos.length === 0) return;
const id = requestAnimationFrame(() => setFadeIn(true));
return () => cancelAnimationFrame(id);
}, [jamos.length]);
음절 배열이 준비되면 다음 프레임에 fadeIn = true → CSS opacity-0 → opacity-100 (500ms).
6-3. 물리 엔진·캔버스 초기화 (useLayoutEffect)
jamos가 준비되면 useLayoutEffect에서 Matter.js를 동적 import 후 월드를 구성합니다.
containerRef 확인
→ ResizeObserver 등록
→ setup(width, height)
→ import("matter-js")
→ Engine.create()
→ Bodies.rectangle() — 벽 3개 + 음절 N개
→ Render.create() — 캔버스 생성
→ Mouse.create(render.canvas) + MouseConstraint
→ World.add(...)
→ Events.on(render, "afterRender", drawSyllables)
→ Runner.run(runner, engine) ← 물리 업데이트
→ Render.run(render) ← 캔버스 그리기
useLayoutEffect를 쓰는 이유: DOM 레이아웃(컨테이너 크기)이 확정된 직후 물리 월드·캔버스 크기를 맞추고, paint 전에 캔버스를 붙여 깜빡임을 줄입니다.
6-4. 매 프레임 루프
[ Runner ] Engine.update() → body 위치·각도·속도 갱신
↓
[ Render ] Render.world() → 캔버스 클리어 + 기본 body 렌더 (visible:false라 스킵)
↓
afterRender 이벤트
↓
drawSyllables() → 각 body 위치에 한글 fillText
중요:
Render.run()만으로는 물리가 진행되지 않습니다.
Matter.js 0.20에서Engine.update()는Runner.run()을 통해서만 호출됩니다.
Render.run()은 캔버스 그리기 루프이고,Runner.run()은 물리 시뮬레이션 루프입니다. 둘 다 필요합니다.
6-5. 리사이즈 대응
const ro = new ResizeObserver((entries) => {
setup(el.clientWidth, el.clientHeight);
});
ro.observe(container);
- Introduce 섹션 크기가 바뀌면 월드 width/height 재계산
- 동일 크기면 재생성 스킵 (
lastSizeRef) - 크기 변경 시 기존 engine·render dispose 후 새로 생성
6-6. 언마운트 (cleanup)
return () => {
cancelled = true;
ro.disconnect();
disposeRef.current?.();
};
dispose 시:
pointerup/pointercancel리스너 제거afterRender이벤트 해제Render.stop(render),Runner.stop(runner)- 캔버스 DOM 제거
World.clear,Engine.clear
7. Matter.js 활용 기능 상세
7-1. Engine — 물리 세계의 심장
const engine = Engine.create({ enableSleeping: true });
engine.gravity.y = 1;
engine.gravity.scale = 0.0018;
engine.positionIterations = 4;
engine.constraintIterations = 2;
| 설정 | 값 | 설명 |
|---|---|---|
enableSleeping | true | 정지한 body는 연산 생략 → 성능 향상 |
gravity.y | 1 | 아래 방향 중력 |
gravity.scale | 0.0018 | 중력 강도. 값을 낮춰 천천히 떨어지게 조정 |
positionIterations | 4 | 충돌 위치 정확도 |
constraintIterations | 2 | 드래그 제약(constraint) 정확도 |
7-2. Bodies — 벽과 음절 강체
정적 벽 (바닥·좌·우)
Bodies.rectangle(x, y, width, height, {
isStatic: true, // 움직이지 않음
friction: 0.98, // 바닥 마찰 (높을수록 미끄러지지 않음)
render: { visible: false },
collisionFilter: WALL_COLLISION_FILTER,
});
- 보이지 않는 직사각형 body
- 바닥은 컨테이너 하단(
worldH) 바로 아래에 배치 - 좌·우 벽은 컨테이너 바깥쪽에 배치해 음절이 화면 밖으로 나가지 않게 함
음절 body (드래그 가능)
Bodies.rectangle(x, y, JAMO_BOX_W, JAMO_BOX_H, {
friction: 0.62,
frictionAir: 0.016, // 공기 저항 (떨어질 때 감쇠)
restitution: 0.04, // 반발 계수 (낮을수록 탄성 적음)
density: 0.0022,
chamfer: { radius: 2 }, // 모서리 둥글게
render: { visible: false },
collisionFilter: DRAGGABLE_COLLISION_FILTER,
});
- 초기 위치: 컨테이너 위쪽 (
y < 0)에서 랜덤 x → 떨어지는 연출 JAMO_BOX_W = 72,JAMO_BOX_H = 100
7-3. Collision Filter — 벽과 음절의 충돌 분리
Matter.js의 collisionFilter는 어떤 body끼리 충돌·상호작용할지 비트마스크로 제어합니다.
const WALL_COLLISION_FILTER = { category: 0x0002, mask: 0xffffffff };
const DRAGGABLE_COLLISION_FILTER = { category: 0x0001, mask: 0xffffffff };
const MOUSE_COLLISION_FILTER = { category: 0x0001, mask: 0x0001, group: 0 };
| 필터 | category | 역할 |
|---|---|---|
| 벽 | 0x0002 | 충돌은 하되, 마우스 드래그 대상에서 제외 |
| 음절 | 0x0001 | 드래그·충돌 모두 가능 |
| 마우스 | 0x0001 (mask도 0x0001) | 음절 body만 잡히도록 제한 |
이 설정이 없으면 바닥·측벽이 글자보다 먼저 드래그되는 문제가 발생합니다.
7-4. Render — 캔버스 렌더러
const render = Render.create({
element: containerRef.current,
engine,
options: {
width: worldW,
height: worldH,
background: "transparent",
wireframes: false,
pixelRatio: window.devicePixelRatio || 1,
},
});
element: ReactcontainerRefdiv에<canvas>를 자동 삽입background: "transparent": Introduce 배경이 비치도록pixelRatio: Retina 디스플레이 선명도
7-5. Mouse + MouseConstraint — 드래그
const mouse = Mouse.create(render.canvas);
mouse.pixelRatio = pixelRatio;
const mouseConstraint = MouseConstraint.create(engine, {
mouse,
collisionFilter: MOUSE_COLLISION_FILTER,
constraint: {
stiffness: 0.32, // 뻣뻣함 (높을수록 단단히 붙음)
damping: 0.08, // 감쇠 (높을수록 덜 출렁임)
render: { visible: false },
},
});
- 캔버스 기준이므로
devicePixelRatio를mouse.pixelRatio에 반영해야 hit 좌표가 정확 pointerup/pointercancel시mouse.button = -1로 드래그 강제 종료 (캔버스 밖에서 손을 뗐을 때)
7-6. Events — afterRender 커스텀 드로잉
Events.on(render, "afterRender", drawSyllables);
const drawSyllables = () => {
for (let i = 0; i < bodies.length; i++) {
drawSyllableOnCanvas(
render.context,
jamos[i],
body.position.x,
body.position.y,
body.angle,
);
}
};
Matter.js가 기본 body 렌더링을 끝낸 직후 afterRender가 호출됩니다.
여기서 각 body의 position과 angle을 읽어 Canvas에 한글을 그립니다.
7-7. Runner — 물리 시뮬레이션 루프
const runner = Runner.create();
Runner.run(runner, engine);
Runner는 requestAnimationFrame 기반으로 Engine.update(engine, delta)를 반복 호출합니다.
이것이 없으면 body가 전혀 움직이지 않습니다 — 초기 위치(화면 위 y < 0)에 고정된 채로 남아 아무것도 보이지 않게 됩니다.
8. Canvas 한글 텍스트 렌더링
drawSyllableOnCanvas 함수가 body 위치·회전에 맞춰 음절을 그립니다.
const drawSyllableOnCanvas = (context, text, x, y, angle) => {
context.save();
context.translate(x, y);
context.rotate(angle);
context.font = `900 ${JAMO_FONT_SIZE_PX}px ui-sans-serif, system-ui, sans-serif`;
context.textAlign = "center";
context.textBaseline = "middle";
// 1차 네온 글로우
context.shadowColor = "rgba(125, 249, 255, 0.9)";
context.shadowBlur = 12;
context.fillStyle = "#D8FEFF";
context.fillText(text, 0, 0);
// 2차 네온 글로우 (더 넓은 범위)
context.shadowBlur = 32;
context.shadowColor = "rgba(0, 210, 255, 0.42)";
context.fillText(text, 0, 0);
// 외곽선 + 본문
context.shadowColor = "transparent";
context.shadowBlur = 0;
context.strokeStyle = "rgba(200, 252, 255, 0.45)";
context.lineWidth = 0.55;
context.strokeText(text, 0, 0);
context.fillText(text, 0, 0);
context.restore();
};
| 단계 | API | 효과 |
|---|---|---|
translate + rotate | 변환 행렬 | body 위치·각도에 맞춰 텍스트 배치 |
shadowBlur 2단 | 그림자 | CSS text-shadow 네온 효과 근사 |
strokeText | 외곽선 | 글자 테두리 강조 |
fillText | 채우기 | 본문 색상 #D8FEFF |
Canvas
font에는 CSS 변수(var(--font-geist-sans))가 적용되지 않으므로, 시스템 폰트 스택을 직접 지정합니다.
9. 성능·접근성·번들 최적화
동적 import
void import("matter-js").then((Matter) => {
// Engine, Render, Runner 등 초기화
});
- Introduce 섹션에 진입하기 전까지
matter-js가 초기 번들에 포함되지 않음 "use client"컴포넌트이므로 브라우저에서만 실행
enableSleeping
정지한 body는 물리 연산을 생략합니다. 음절이 바닥에 쌓인 뒤 CPU 사용량이 줄어듭니다.
prefers-reduced-motion
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setSkipPhysics(true);
}
모션 민감 사용자를 위해 물리 연출 전체를 비활성화하고 return null합니다.
루트에 aria-hidden을 설정해 스크린 리더에서도 장식 콘텐츠로 처리합니다.
contain-paint
className = "... overflow-hidden contain-paint ...";
캔버스 영역 밖으로 그리기가 번지지 않도록 CSS containment를 적용합니다.
10. 트러블슈팅 — 겪었던 문제와 해결
| 증상 | 원인 | 해결 |
|---|---|---|
| 음절이 아예 안 보임 | Render.run()만 사용, Runner 없음 | Runner.run(runner, engine) 추가 |
| 드래그 hit이 안 맞음 | mouse.pixelRatio 미설정 | render.options.pixelRatio와 동일하게 설정 |
| 바닥·측벽이 먼저 잡힘 | collision filter 미분리 | 벽 0x0002, 음절 0x0001 |
| 리사이즈 후 좌표 어긋남 | 월드·캔버스 크기 불일치 | ResizeObserver + dispose/recreate |
| hydration mismatch | SSR/CSR jamos 불일치 | 초기 jamos = [], 마운트 후 설정 |
| 캔버스가 중복 생성됨 | dispose 시 캔버스 미제거 | Render.stop 후 canvas DOM 제거 |
| Canvas 폰트 미적용 | CSS 변수를 font에 사용 | 시스템 폰트 스택 직접 지정 |
11. 정리
HangulJamoPlayground는 React + Matter.js + Canvas를 조합해 포트폴리오 Introduce 섹션에 인터랙티브한 한글 낙하 연출을 구현한 기능입니다.
구현 요약
- Matter.js Engine으로 중력·충돌·드래그 물리 시뮬레이션
- Matter.js Render로 캔버스 프레임 루프 구동
- Runner로 물리 업데이트 (
Render만으로는 body가 움직이지 않음) - afterRender에서 Canvas API로 한글 음절 직접 그리기
- Collision Filter로 벽·음절·마우스 상호작용 분리
- ResizeObserver로 반응형 월드 재구성
- 동적 import + prefers-reduced-motion으로 성능·접근성 고려
Matter.js를 쓰면서 배운 점
Render.run과Runner.run은 역할이 다르다. 렌더링과 물리 업데이트는 별도 루프- 물리 body와 시각 표현을 분리하면 (body는 invisible, 텍스트는 canvas) 디자인 자유도가 높아진다
collisionFilter는 드래그 UX에 직접적인 영향을 준다- Canvas에서는 CSS 폰트·그림자를 그대로 쓸 수 없으므로 API로 재현해야 한다
포트폴리오처럼 "가볍지만 인상적인 인터랙션"이 필요한 곳에서 Matter.js는 충분히 실용적인 선택입니다.
참고
- Matter.js 공식 문서
- Matter.js 데모
- 프로젝트 내부 기술 문서:
docs/hangul-jamo-playground.md