dev

Matter.js을 이용해서 물리 엔진 기능 구현하기

1. 들어가며 — 어떤 기능인가?

이전에 만들었던 포트폴리오 메인 페이지의 상단 영역에 음절이 화면 위에서 떨어지는 인터랙션 기능을 구현했습니다.

  • "안", "녕", "하", "세", "요"... 와 같은 한글 음절이 하나씩 낙하
  • 바닥·벽에 부딪히며 쌓이고, 마우스/터치로 드래그 하며 이동 가능

React로 UI를 구성하고, 2D 물리 엔진 Matter.js로 중력·충돌·드래그를 처리하며, Canvas API로 한글 텍스트를 그립니다.

...

관련 파일 구성

파일역할
app/components/HangulJamoPlayground.tsx물리 연출 메인 컴포넌트
app/utils/syllableToJamos.ts한글 문장 → 음절 배열 변환
app/components/Introduce.tsx플레이그라운드를 배경 레이어로 배치
matter-js2D 물리 엔진 + 캔버스 렌더러

2. Matter.js란?

Matter.js는 브라우저에서 동작하는 JavaScript 2D 물리 엔진입니다.
HTML5 Canvas 위에서 물체의 중력, 충돌, 마찰, 회전, 제약(constraint) 등을 시뮬레이션할 수 있습니다.

Matter.js의 핵심 개념

개념설명
Engine물리 시뮬레이션의 심장. 중력·충돌 해결·위치 업데이트를 담당
WorldEngine 안의 공간. Body, Constraint 등이 모여 있는 컨테이너
Body물리 세계에 존재하는 강체. 위치, 속도, 각도, 질량, 마찰 등을 가짐
BodiesBody를 만드는 팩토리 (rectangle, circle 등)
Constraint두 Body를 연결하는 제약. 스프링·로프·고정점 등
RunnerEngine.update()를 주기적으로 호출해 물리를 진행시키는 루프
RenderEngine의 World를 캔버스에 그리는 렌더러 (개발·디버깅·간단한 게임용)
Mouse / MouseConstraint마우스 입력을 받아 Body를 끌어당기는 드래그 기능
EventsafterRender, 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 대신 Canvas shadowBlur + 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-indexpointer-events역할
HangulJamoPlaygroundz-1auto음절 드래그·낙하 연출
전경 콘텐츠z-10대부분 none, 링크만 auto이름·소개·소셜 링크

배경 연출은 드래그를 받고, 전경 텍스트는 기본적으로 클릭을 통과시키되 링크 등 필요한 요소만 pointer-events-auto로 다시 활성화합니다.


6. 동작 흐름 — 처음부터 끝까지

6-1. 마운트: 접근성 확인 및 데이터 로드

useEffect(() => {
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    setSkipPhysics(true);
    return;
  }
  setJamos(getIntroPhraseSyllables());
}, []);
  1. OS/브라우저 모션 축소(prefers-reduced-motion: reduce) 설정 확인
  2. 축소 모드면 skipPhysics = true → 컴포넌트 return null (연출 비활성)
  3. 아니면 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;
설정값설명
enableSleepingtrue정지한 body는 연산 생략 → 성능 향상
gravity.y1아래 방향 중력
gravity.scale0.0018중력 강도. 값을 낮춰 천천히 떨어지게 조정
positionIterations4충돌 위치 정확도
constraintIterations2드래그 제약(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: React containerRef div에 <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 mismatchSSR/CSR jamos 불일치초기 jamos = [], 마운트 후 설정
캔버스가 중복 생성됨dispose 시 캔버스 미제거Render.stop 후 canvas DOM 제거
Canvas 폰트 미적용CSS 변수를 font에 사용시스템 폰트 스택 직접 지정

11. 정리

HangulJamoPlayground는 React + Matter.js + Canvas를 조합해 포트폴리오 Introduce 섹션에 인터랙티브한 한글 낙하 연출을 구현한 기능입니다.

구현 요약

  1. Matter.js Engine으로 중력·충돌·드래그 물리 시뮬레이션
  2. Matter.js Render로 캔버스 프레임 루프 구동
  3. Runner로 물리 업데이트 (Render만으로는 body가 움직이지 않음)
  4. afterRender에서 Canvas API로 한글 음절 직접 그리기
  5. Collision Filter로 벽·음절·마우스 상호작용 분리
  6. ResizeObserver로 반응형 월드 재구성
  7. 동적 import + prefers-reduced-motion으로 성능·접근성 고려

Matter.js를 쓰면서 배운 점

  • Render.run과 Runner.run은 역할이 다르다. 렌더링과 물리 업데이트는 별도 루프
  • 물리 body와 시각 표현을 분리하면 (body는 invisible, 텍스트는 canvas) 디자인 자유도가 높아진다
  • collisionFilter는 드래그 UX에 직접적인 영향을 준다
  • Canvas에서는 CSS 폰트·그림자를 그대로 쓸 수 없으므로 API로 재현해야 한다

포트폴리오처럼 "가볍지만 인상적인 인터랙션"이 필요한 곳에서 Matter.js는 충분히 실용적인 선택입니다.


참고