ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 3D Room Planer : 웹 기반 3D 가구 배치 시뮬레이터 만들기
    Projects 2026. 4. 24. 22:12
    반응형

     

    뭘 만들었나?

    6m × 6m × 2.7m 크기의 3D 방에 가구를 배치할 수 있는 웹 애플리케이션.

    • 카탈로그에서 가구를 선택해 바닥에 배치
    • 드래그로 이동
    • R 키로 90도 회전
    • 다른 가구와의 충돌 시각화 (빨강 하이라이트)
    • 탑뷰 / 원근 카메라 전환
    • 배치 내용 자동 저장 & 복원

    왜 이걸 만들었나?

    이 프로젝트는 의도적으로 짧은 스코프에 4가지 기술 시연을 담았다:

    1. WebGL 시각화 — Three.js로 실시간 렌더링
    2. 3D 선형대수 — 광선-평면 교점, AABB 충돌 판정
    3. 성능 최적화 — 50개 가구도 부드럽게 (< 16ms)
    4. 도메인 설계 — 인테리어 도구의 아키텍처

    작은 프로젝트지만 "진짜 애플리케이션"으로 동작시키는 것이 목표였다.


    2. 기술 스택 선택 이유

    Vite + TypeScript     빠른 개발 루프, 타입 안전
    ↓
    React + React Three Fiber  선언형 UI + 3D 렌더링
    ↓
    Zustand             상태 관리
    ↓
    Tailwind CSS        UI 스타일

    왜 React Three Fiber인가?

    처음엔 바닐라 Three.js를 고려했지만, UI와 3D를 함께 관리해야 했다.

    React Three Fiber는:

    • 선언형: "가구 5개가 있다" → JSX로 표현 가능
    • React 생태계: 기존 상태관리 패턴 그대로 사용 가능
    • 성능: memo, useFrame ref 패턴으로 불필요한 리렌더 방지
    // 이렇게 쓸 수 있다
    <Furniture items={items.beds} type="bed" />
    <Furniture items={items.sofas} type="sofa" />
    // 각 아이템이 변하면, 해당 타입의 Instanced mesh만 업데이트

     

    왜 Zustand인가?

    Redux나 Context API도 가능했지만:

    • 최소한의 보일러플레이트create() 한 줄
    • Subscription 친화적 — 3D 루프에서 전체 리렌더 없이 일부만 구독 가능
    • 미들웨어 확장성 — localStorage persist, undo/redo 나중에 추가 가능
    const useStore = create((set) => ({
      items: [],
      selectedId: null,
      addItem: (item) => set(state => ({
        items: [...state.items, item]
      }))
    }))

     


     

    3. 프론트엔드 개발자가 알면 좋은 기술들

    3.1 React Three Fiber: 선언형 3D

    기존 Three.js:

    const geometry = new THREE.BoxGeometry(1, 1, 1)
    const material = new THREE.MeshStandardMaterial({ color: 0xff0000 })
    const mesh = new THREE.Mesh(geometry, material)
    scene.add(mesh)

     

    React Three Fiber:

    <mesh>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={0xff0000} />
    </mesh>

     

    장점:

    • Props 기반이라 상태 변화를 자연스럽게 표현
    • JSX 문법이라 학습곡선이 낮음
    • React의 라이프사이클 활용 가능

    3.2 InstancedMesh: 배치 렌더링의 핵심

    10개 의자를 10번 그리는 게 아니라, 1번에 10개를 그린다.

    const bedsRef = useRef<THREE.InstancedMesh>(null)
    
    useEffect(() => {
      beds.forEach((bed, i) => {
        const matrix = new THREE.Matrix4()
        matrix.compose(bed.position, bed.quaternion, bed.scale)
        bedsRef.current?.setMatrixAt(i, matrix)
      })
      bedsRef.current!.instanceMatrix.needsUpdate = true
    }, [beds])
    
    return (
      <instancedMesh ref={bedsRef} args={[geometry, material, 100]}>
        {/* 100개 인스턴스를 위한 준비만 함 */}
      </instancedMesh>
    )

     

    성능 차이:

    • 10개 개별 메시 = 10개 draw call
    • InstancedMesh = 1개 draw call

    이게 가능한 이유는 GPU 측에서 gl_InstanceID라는 내장 변수로 각 인스턴스를 구분하기 때문.

    3.3 Raycast: 마우스 → 3D 공간 변환

    사용자가 마우스로 클릭한 위치가 3D 방의 어디인가?

    const raycaster = new THREE.Raycaster()
    const pointer = new THREE.Vector2()
    
    const handleMouseMove = (event: MouseEvent) => {
      pointer.x = (event.clientX / window.innerWidth) * 2 - 1
      pointer.y = -(event.clientY / window.innerHeight) * 2 + 1
    
      raycaster.setFromCamera(pointer, camera)
    
      // 광선과 바닥 평면의 교점 계산
      const floorIntersects = raycaster.intersectObject(floorMesh)
      if (floorIntersects.length > 0) {
        const point = floorIntersects[0].point
        // point.x, point.z = 가구 놓을 위치
      }
    }

     

    원리:

    1. 마우스 좌표를 정규화 좌표(NDC)로 변환 (-1 ~ 1)
    2. raycaster.setFromCamera()로 카메라에서 시작하는 광선 생성
    3. 3D 객체와의 교점 계산

    이 기술은 모바일 터치, VR 인터랙션 등 모든 곳에서 쓰인다.

    3.4 3D 선형대수: AABB 충돌 판정

    두 가구가 겹치는지 판정하려면?

    // Axis-Aligned Bounding Box (AABB): 축 정렬 경계상자
    type AABB = {
      min: { x: number, z: number }
      max: { x: number, z: number }
    }
    
    function aabbsOverlap(a: AABB, b: AABB): boolean {
      return !(
        a.max.x < b.min.x ||
        a.min.x > b.max.x ||
        a.max.z < b.min.z ||
        a.min.z > b.max.z
      )
    }

    90도 회전만 지원하는 이유:

    • 90도 회전 = 단순히 width ↔ depth 교환
    • 임의 각도 회전 = 8개 코너를 모두 변환해야 함 (복잡함)

    이 트레이드오프는 의도적인 스코핑이다.

    프로토타입에서 기능을 제한하면 구현은 간단해지지만 여전히 실용성 있게 만들 수 있다.

    3.5 frameloop="demand": 배터리 절약

    <Canvas frameloop="demand" /* 유휴 상태에서 렌더링 안 함 */>
      <Scene />
    </Canvas>

     

    기본값 "always"는 매 프레임 렌더링하므로:

    • 60fps × 60초 = 3600번 그리기 (아무 변화 없어도)
    • 랩톱 팬 소음, 배터리 방전

    "demand"로 설정하고 상태 변화 시에만 invalidate() 호출:

    const invalidate = useThree(state => state.invalidate)
    const handlePlaceItem = () => {
      store.addItem(newItem)
      invalidate() // 한 번만 그린다
    }

     

    이 패턴은 mobile-first 이다.

    3.6 localStorage: 낮은 수준의 영속화

    // 저장
    localStorage.setItem('roomLayout', JSON.stringify(items))
    
    // 복원
    const saved = localStorage.getItem('roomLayout')
    if (saved) store.hydrate(JSON.parse(saved))

    TypeScript에서는 스키마를 정의해서 타입 안전성 확보:

    type SavedLayout = {
      version: 1
      items: Item[]
      timestamp: number
    }
    
    function saveLayout(items: Item[]) {
      const data: SavedLayout = {
        version: 1,
        items,
        timestamp: Date.now()
      }
      localStorage.setItem('roomLayout', JSON.stringify(data))
    }

     

    주의할 점:

    • 5MB 제한 (보통 충분)
    • 동기 작업 (큰 데이터는 setTimeout으로 블록 방지)
    • XSS에 취약하므로 신뢰할 수 있는 데이터만 저장

    4. 구현 과정의 도전과제들

    문제 1: React 리렌더링 폭발

    처음엔 이렇게 짰다:

    function Furniture() {
      const items = useStore(state => state.items) // 전체 구독
      return (
        <group>
          {items.map(item => <mesh key={item.id} {...item} />)}
        </group>
      )
    }

    문제: 가구 1개 위치 변경 → 전체 배열 업데이트 → 전체 JSX 리렌더 → 50개 메시 all re-create

    해결: Zustand의 selector로 필요한 데이터만 구독

     

    const items = useStore(state => state.items)
    const beds = useStore(state => state.items.filter(i => i.type === 'bed'))
    
    // 그리고 InstancedMesh 사용

    문제 2: 카메라 전환 시 뭔가 이상함

    탑뷰 ↔ 원근 전환하면 드래그 좌표가 틀렸다.

    원인: Raycaster가 old 카메라 기준으로 광선을 만들고 있음

    해결:

    const camera = useThree(state => state.camera)
    const handleMouseMove = (e: MouseEvent) => {
      raycaster.setFromCamera(pointer, camera) // 매번 현재 카메라 사용
    }

     

    배운 점: 3D에서는 카메라가 기준이다. 상태 변화 시 모든 기하 계산을 다시 해야 한다.

    문제 3: 회전 + 충돌 판정

    AABB는 축 정렬이므로, 가구가 45도 회전하면 AABB도 커진다 (8개 코너의 min/max).

    90도만 지원하기로 결정해서 해결했지만, 만약 모든 각도를 지원한다면:

    function worldAABBFromItem(item: Item): AABB {
      const corners = [
        [-w/2, -d/2], [w/2, -d/2], [w/2, d/2], [-w/2, d/2]
      ]
    
      const rotated = corners.map(c => {
        // 각도에 따라 회전 행렬 적용
        return rotatePoint(c, item.rotation)
      })
    
      // 모든 코너의 min/max 계산
      return {
        min: { x: Math.min(...rotated.map(c => c[0])), ... },
        max: { x: Math.max(...rotated.map(c => c[0])), ... }
      }
    }

    삭제할 시점 판단: 작은 프로젝트에서는 간단한 규칙(90도 회전)을 선택하는 게 맞다. 나중에 필요하면 그때 추가한다.


    5. 성능 최적화: 측정부터

    최적화 전략

    1. InstancedMesh — draw call 1/10로 감소
    2. frameloop="demand" — idle 상태에서 렌더링 안 함
    3. Geometry/Material 재사용 — 각 타입마다 1개씩만 생성
    4. MemoizationReact.memo로 정적 컴포넌트 보호
    5. Shadow 최적화 — 카메라만 큼, shadow map도 tight하게

    실제 효과

    Before: 50개 가구 = 50 draw call = 8-12ms
    After:  50개 가구 = 1-5 draw call = 2-3ms
    
    → 1초에 그린다고 해도 여유 (60fps = 16ms)

    측정 도구:

    useEffect(() => {
      const startTime = performance.now()
      invalidate()
      console.time('frame')
      // ... render 후
      console.timeEnd('frame')
    }, [items])

    세 가지 관점에서 체크:

    • CPU: React 렌더, state 계산
    • GPU: draw call, vertex count
    • Memory: texture, geometry 캐싱

    6. 프론트엔드 개발자로서 얻은 인사이트

    6.1 선언형 vs 명령형의 경계

    React Three Fiber는 선언형 API를 제공하지만, 내부는 명령형이다.

    // 우리가 쓰는 코드 (선언형)
    <Furniture items={items} />
    
    // 내부에서 일어나는 일 (명령형)
    const matrix = new Matrix4().compose(pos, quat, scale)
    instancedMesh.setMatrixAt(index, matrix)
    instancedMesh.instanceMatrix.needsUpdate = true

    배운 점: 좋은 추상화는 사용자는 선언형으로 쓰게 하고, 내부는 명령형으로 동작하게 한다. React도 마찬가지다.

    6.2 상태 설계가 성능을 좌우한다

    좋은 상태 구조:

    type Store = {
      items: Item[] // 평평한 배열
      selectedId: string | null
      gridSnap: boolean
      // ...
    }

    나쁜 구조 (nested):

    type Store = {
      beds: { [id: string]: Bed }
      sofas: { [id: string]: Sofa }
      // ... (각 타입마다 구분)
    }

    평평한 구조는:

    • 정규화되어 중복 없음
    • 한 번의 업데이트로 일관성 유지
    • Selector로 필요한 부분만 구독 가능

    6.3 도메인 설계의 중요성

    "가구 배치 시뮬레이터"라는 도메인을 이해하면 코드도 명확해진다:

    // 도메인 언어
    type Item = {
      id: string
      type: FurnitureType // 'bed' | 'sofa' | ...
      position: Vector3 // 월드 좌표
      rotation: Euler // Y축 회전만
      selected: boolean
      colliding: boolean // UI에서 빨강 표시
    }
    
    // vs 일반적인 이름
    type Obj = { x, y, z, r, c, s } // 뭔지 모름

    좋은 타입 이름은 문서가 된다.

    6.4 작은 스코프의 가치

    2-짧은 기간 안에 끝내려고 의도적으로 기능을 제한했다:

    • 90도 회전만 (임의 각도 X)
    • GLTF 로딩 X (primitives만)
    • Undo/Redo X
    • Texture X (flat material)

    결과:

    • 구현 복잡도 1/3으로 감소
    • 여전히 실용적 (원래 목표 달성)
    • 나중에 확장 가능한 구조

    "빅 스코프 = 엔드리스 버그"라는 배움.


    7. 향후 개선 아이디어

    현재 MVP 외에 추가 가능한 기능들:

    쉬운 것 (1일 이내)

    • Undo/Redo (Zustand middleware)
    • Keyboard shortcut 정의 파일화
    • GLTF 모델 로딩 (three.js loader)
    • 다크 모드 (Tailwind)

    중간 난도 (3-5일)

    • 벽 충돌 감지
    • 문/창 지원
    • 가구 높이에 따른 그림자 최적화
    • Export as 3D model / 이미지

    어려운 것 (1주 이상)

    • 멀티플레이 (WebSocket)
    • AR 프리뷰 (WebXR API)
    • AI 추천 배치 (ML 모델)

    결론

    프로젝트에서 배운 기술 스택:

    • React Three Fiber — 3D와 React 통합하기
    • InstancedMesh — 성능 최적화의 핵심
    • Raycast — 마우스/터치 입력 처리
    • 3D 수학 — 실제 필요한 수준만 학습
    • 상태 설계 — 아키텍처의 기초
    • 의도적 스코핑 — MVP의 가치

    이 기술들은 대규모 3D 애플리케이션(게임, 설계 도구, 가상 공간)의 기초다.

    작은 프로젝트부터 제대로 배우면, 나중에 복잡한 문제도 풀 수 있다.

     

    물론 AI도 적극적으로 활용했다.

    그 덕분에 더 빠르게 끝낼 수 있었고, 개발자로서는 하네스 역할을 하면서 진행을 했다.

     


    참고 자료


    반응형
Designed by Tistory.