Three.js로 3D 웹 만들기: Scene·Camera·Mesh, 조명, 카메라 컨트롤, 텍스처, React Three Fiber
이 글의 핵심
Three.js의 Scene·Camera·Renderer 구성부터 도형과 재질, 조명, OrbitControls, 텍스처 입히기, 애니메이션 루프, React에서 선언적으로 쓰는 React Three Fiber까지 다룹니다.
이 글의 핵심
Three.js로 브라우저에 3D 장면을 그리는 기본기를 정리한 글입니다. Scene, Camera, Mesh, Material, Animation, React Three Fiber를 예제로 다룹니다.
실무에서 마주치는 문제들
제품을 3D로 보여주고 싶어요
제품 사진을 여러 각도로 찍어 두는 방식은 보여줄 수 있는 각도가 정해져 있고, 색상 옵션이 늘어날 때마다 촬영을 다시 해야 합니다. 3D 모델을 한 번 만들어 두면 사용자가 직접 돌려 보고, 재질이나 색상은 코드에서 바꿔 끼울 수 있습니다.
데이터 시각화가 필요해요
지형, 건물 내부 배치, 분자 구조, 수만 개의 점으로 이루어진 클라우드 데이터처럼 원래 3차원인 데이터는 2D로 투영하면 정보가 사라집니다. 다만 막대그래프처럼 2D로 충분한 데이터를 3D로 그리면 원근 때문에 값을 비교하기가 오히려 어려워지므로, 3D가 정말 필요한지 먼저 따져 볼 필요가 있습니다.
게임을 만들고 싶어요
Canvas 2D API는 3D 변환, 깊이 판정, 조명 계산을 제공하지 않습니다. WebGL은 이것을 GPU로 처리하지만 셰이더와 버퍼를 직접 다뤄야 해서 삼각형 하나를 그리는 데도 수십 줄이 필요합니다. Three.js는 이 WebGL 위에 장면 그래프, 카메라, 재질, 조명 같은 익숙한 개념을 얹어 줍니다. 물리 엔진이나 충돌 판정, 에디터 같은 게임 엔진 기능은 포함되어 있지 않으므로, 본격적인 게임이라면 cannon-es·Rapier 같은 물리 라이브러리를 조합하거나 Babylon.js·PlayCanvas 같은 엔진을 함께 검토합니다.
Three.js란?
핵심 특징
Three.js는 3D 웹 라이브러리입니다. 주요 장점:
- WebGL: 하드웨어 가속
- 간단한 API: WebGL 추상화
- 풍부한 기능: Geometry, Material, Light
- 애니메이션: 부드러운 애니메이션
- React 통합: React Three Fiber
Three.js의 모든 코드는 세 가지 요소로 요약됩니다. 물체와 조명을 담는 Scene, 어디서 어떤 화각으로 볼지 정하는 Camera, 그리고 Scene을 Camera 시점에서 픽셀로 그리는 Renderer입니다. 화면에 보이는 물체는 Mesh이며, Mesh는 모양인 Geometry와 표면의 성질인 Material을 조합해 만듭니다. 같은 Geometry와 Material을 여러 Mesh가 공유할 수 있어서, 똑같은 나무 1,000그루를 그릴 때 모양 데이터는 GPU에 한 번만 올라갑니다.
Three.js는 아직 1.0 없이 r160, r170 같은 리비전 번호로 매달 릴리스되고, 리비전 사이에 API가 바뀌는 일이 잦습니다. 인터넷에 있는 예제를 가져다 쓸 때 코드가 동작하지 않거나 색이 다르게 나온다면 예제가 작성된 리비전부터 확인하는 것이 좋습니다.
설치 및 기본 설정
설치
npm install three
npm install -D @types/three
three 패키지는 타입 정의를 포함하지 않으므로 TypeScript 프로젝트라면 @types/three를 함께 설치합니다. 두 패키지의 리비전 번호를 맞춰 두어야 실제 API와 타입이 어긋나지 않습니다.
기본 Scene
// main.ts
import * as THREE from 'three';
// Scene
const scene = new THREE.Scene();
// Camera
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.z = 5;
// Renderer
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Cube
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// Animation Loop
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
}
animate();
PerspectiveCamera의 네 인자는 세로 화각(도 단위), 화면 비율, 가까운 절단면(near), 먼 절단면(far)입니다. near보다 가깝거나 far보다 먼 물체는 그려지지 않습니다. near를 0.0001처럼 아주 작게, far를 아주 크게 잡으면 깊이 버퍼의 정밀도가 부족해져 두 면이 겹친 곳에서 번갈아 깜빡이는 z-fighting이 생기므로, 장면 크기에 맞게 범위를 좁히는 편이 좋습니다. 카메라는 기본적으로 원점에서 -z 방향을 보기 때문에 camera.position.z = 5로 뒤로 물러나 원점의 큐브를 보게 한 것입니다. 이 줄을 빠뜨리면 카메라가 큐브 안에 들어가 화면이 검게 나오는데, Three.js를 처음 쓸 때 가장 흔한 “아무것도 안 보인다” 원인입니다.
requestAnimationFrame은 브라우저가 화면을 다시 그릴 때마다(보통 초당 60회, 고주사율 모니터에서는 120회 이상) 함수를 호출합니다. 그래서 회전량을 0.01로 고정하면 120Hz 모니터에서는 두 배 빨리 돕니다. 기기와 관계없이 같은 속도로 움직이게 하려면 THREE.Clock의 getDelta()로 지난 프레임 이후의 경과 시간을 곱해야 합니다.
이 예제에는 실제 페이지에서 꼭 필요한 두 가지가 빠져 있습니다. 첫째, 창 크기가 바뀔 때 camera.aspect를 갱신하고 camera.updateProjectionMatrix()와 renderer.setSize()를 호출하지 않으면 화면이 늘어나거나 찌그러집니다. 둘째, renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))를 설정하지 않으면 레티나 화면에서 흐리게 보입니다. 상한을 2로 두는 이유는 픽셀 비율이 3인 휴대폰에서 그대로 렌더링하면 픽셀 수가 9배가 되어 GPU 부담이 급격히 커지기 때문입니다. 계단 현상이 신경 쓰인다면 new THREE.WebGLRenderer({ antialias: true })로 안티에일리어싱을 켭니다.
Geometry & Material
다양한 Geometry
// 박스
const box = new THREE.BoxGeometry(1, 1, 1);
// 구
const sphere = new THREE.SphereGeometry(1, 32, 32);
// 평면
const plane = new THREE.PlaneGeometry(5, 5);
// 원통
const cylinder = new THREE.CylinderGeometry(1, 1, 2, 32);
SphereGeometry(1, 32, 32)의 뒤쪽 두 숫자는 가로·세로 분할 수입니다. 값을 늘리면 곡면이 매끄러워지지만 삼각형 수가 곱으로 늘어납니다. 32×32 구는 약 2천 개의 삼각형인데, 이런 구를 수천 개 배치하면 삼각형이 수백만 개가 되어 모바일에서 프레임이 떨어집니다. 멀리 있는 물체는 분할 수를 줄이거나, 같은 모양을 많이 그린다면 InstancedMesh로 한 번의 드로우 콜에 묶는 것이 기본 최적화입니다. 실제 제품 모델처럼 복잡한 모양은 코드로 만들지 않고 Blender 등에서 만든 glTF 파일을 GLTFLoader로 불러옵니다.
Material
// 기본
const basic = new THREE.MeshBasicMaterial({ color: 0xff0000 });
// Lambert (빛 반응)
const lambert = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
// Phong (반사)
const phong = new THREE.MeshPhongMaterial({
color: 0x0000ff,
shininess: 100,
});
// Standard (PBR)
const standard = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 0.5,
roughness: 0.5,
});
재질은 아래로 갈수록 사실적이지만 계산 비용이 큽니다. MeshBasicMaterial은 조명을 전혀 계산하지 않고 지정한 색을 그대로 칠하므로, 2장의 첫 예제처럼 조명이 없어도 보입니다. 반대로 나머지 재질은 조명이 없으면 완전히 검게 보입니다. 1장 예제의 MeshBasicMaterial을 MeshStandardMaterial로 바꿨더니 큐브가 사라졌다면 4장의 조명을 추가하지 않았기 때문입니다.
MeshStandardMaterial은 물리 기반 렌더링(PBR) 재질로, metalness(금속성)와 roughness(거칠기) 두 값으로 대부분의 표면을 표현합니다. glTF 모델이 기본으로 쓰는 재질도 이것이라 새 프로젝트는 Standard를 기준으로 삼는 것이 일반적입니다. 금속성이 높은 재질은 주변 환경을 반사해서 색이 정해지므로, 조명만 있고 환경 맵(scene.environment)이 없으면 금속이 거의 검게 보입니다. drei의 <Environment>나 RoomEnvironment로 환경 맵을 넣으면 해결됩니다. Lambert와 Phong은 더 가볍지만 사실감이 떨어져, 저사양 기기를 우선하거나 스타일화된 표현을 원할 때 씁니다.
Light
조명마다 계산 비용과 그림자 지원 여부가 다릅니다. 셰이더는 조명 수에 비례해 계산이 늘어나므로 조명을 무작정 많이 두기보다 역할을 나누는 편이 좋습니다.
// Ambient Light (전체 조명)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
// Directional Light (방향 조명)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(5, 5, 5);
scene.add(directionalLight);
// Point Light (점 조명)
const pointLight = new THREE.PointLight(0xff0000, 1, 100);
pointLight.position.set(0, 3, 0);
scene.add(pointLight);
// Spot Light (스포트 조명)
const spotLight = new THREE.SpotLight(0xffffff, 1);
spotLight.position.set(0, 5, 0);
scene.add(spotLight);
AmbientLight는 방향 없이 모든 면을 똑같이 밝히므로 그림자 진 면이 완전히 검게 되는 것을 막는 보조 조명으로 씁니다. 이것만 쓰면 모든 면의 밝기가 같아 입체감이 사라집니다. DirectionalLight는 태양처럼 평행하게 들어오는 빛이라 위치보다 방향이 중요하고(position에서 target을 향함), PointLight는 전구처럼 한 점에서 퍼지며 거리에 따라 약해지고, SpotLight는 원뿔 모양으로 비춥니다.
리비전 r155 이후로 조명 세기의 기본 해석이 물리 단위 기반으로 바뀌었습니다. 그래서 PointLight(0xff0000, 1, 100)처럼 예전 예제의 세기 값을 그대로 쓰면 거리 감쇠(decay 기본값 2) 때문에 빛이 거의 보이지 않는 경우가 많습니다. 예전 코드를 옮기다가 “점 조명이 안 비친다”면 세기를 수십~수백 단위로 올려 보는 것이 먼저입니다.
그림자는 자동으로 켜지지 않습니다. renderer.shadowMap.enabled = true, 조명의 castShadow = true, 그림자를 드리울 Mesh의 castShadow와 받을 Mesh의 receiveShadow를 모두 설정해야 합니다. 그림자를 켠 조명은 장면을 한 번 더 렌더링해 그림자 맵을 만들기 때문에 비용이 크며, PointLight 그림자는 여섯 방향을 렌더링하므로 특히 비쌉니다. 모바일을 고려한다면 그림자는 DirectionalLight 하나 정도로 제한하는 것이 무난합니다.
Camera Controls
OrbitControls
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
OrbitControls는 코어 패키지가 아니라 examples/jsm 아래의 애드온이라 경로로 가져옵니다. 최신 리비전에서는 three/addons/controls/OrbitControls.js라는 별칭 경로도 제공합니다. 번들러 없이 브라우저의 ES 모듈로 불러올 때는 .js 확장자까지 적어야 하고, import map으로 three의 위치를 알려줘야 Failed to resolve module specifier "three" 에러가 나지 않습니다.
마우스 드래그로 카메라를 대상 주위로 회전시키고, 휠로 확대·축소하고, 오른쪽 버튼으로 평행 이동합니다. enableDamping을 켜면 드래그를 놓은 뒤에도 관성으로 부드럽게 멈추는데, 이 감속은 controls.update()가 매 프레임 호출되어야 동작합니다. 렌더링을 필요할 때만 하도록 최적화했다면 댐핑 중에는 계속 렌더링해야 한다는 점을 잊지 말아야 합니다. 제품 뷰어라면 minDistance, maxDistance, maxPolarAngle로 카메라가 물체 안이나 바닥 아래로 들어가지 않게 제한해 두는 것이 좋습니다.
Texture
const textureLoader = new THREE.TextureLoader();
const texture = textureLoader.load('/textures/brick.jpg');
const material = new THREE.MeshStandardMaterial({
map: texture,
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
TextureLoader.load()는 텍스처 객체를 즉시 반환하고 이미지는 비동기로 채웁니다. 이미지가 도착하기 전의 몇 프레임은 텍스처 없이 그려지므로, 로딩 완료가 중요하다면 loadAsync()로 기다리거나 LoadingManager로 진행률을 표시합니다. 이미지 경로가 틀리면 에러 없이 검은 표면만 남는 경우가 많으니 네트워크 탭에서 404를 확인합니다.
처음 텍스처를 입혔을 때 색이 원본보다 뿌옇고 물 빠진 것처럼 보인다면 색 공간 설정 문제입니다. 사진 같은 색상 텍스처는 sRGB로 저장되어 있으므로 texture.colorSpace = THREE.SRGBColorSpace를 지정해야 합니다(오래된 리비전에서는 texture.encoding = THREE.sRGBEncoding). 반대로 노멀 맵이나 거칠기 맵 같은 데이터 텍스처에는 이 설정을 하면 안 됩니다. 텍스처 크기는 GPU 메모리에 압축 없이 올라가기 때문에 파일 용량보다 훨씬 큰 메모리를 차지합니다. 4096×4096 텍스처 하나가 RGBA 기준으로 밉맵을 포함해 약 85MB를 쓸 수 있어, 모바일에서는 텍스처 크기가 크래시의 주된 원인이 됩니다. 필요한 해상도로 줄이고 KTX2 같은 GPU 압축 포맷을 쓰는 것이 좋습니다.
React Three Fiber
React Three Fiber(R3F)는 Three.js 객체를 JSX로 선언하게 해 주는 React 렌더러입니다. <mesh>는 new THREE.Mesh(), <boxGeometry args={[1, 1, 1]} />는 new THREE.BoxGeometry(1, 1, 1)에 해당하며, 태그 이름은 Three.js 클래스 이름의 첫 글자를 소문자로 바꾼 것입니다. 성능이 떨어지는 래퍼가 아니라 React의 재조정(reconciliation)으로 Three.js 객체를 직접 만들고 고치는 방식이라, 할 수 있는 일은 순수 Three.js와 같습니다.
설치
npm install @react-three/fiber @react-three/drei
기본 사용
// App.tsx
// 필요한 모듈 import
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
function Box() {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="orange" />
</mesh>
);
}
export default function App() {
return (
<Canvas>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<Box />
<OrbitControls />
</Canvas>
);
}
<Canvas>가 Scene, Camera, Renderer 생성과 크기 조정, 픽셀 비율 설정, 렌더 루프를 모두 대신 처리합니다. 2장에서 직접 써야 했던 리사이즈 처리와 setPixelRatio가 기본으로 들어 있고, 컴포넌트가 언마운트되면 Geometry와 Material의 dispose()도 자동으로 호출됩니다. @react-three/drei는 OrbitControls, Environment, useGLTF, Text 같은 자주 쓰는 도우미를 컴포넌트로 모아 둔 패키지입니다.
<Canvas>는 부모 요소의 크기를 채우므로, 부모에 높이가 없으면 높이 0인 캔버스가 만들어져 아무것도 보이지 않습니다. <div style={{ height: '100vh' }}>처럼 부모의 크기를 지정해야 합니다. 또 <Canvas> 안쪽은 별도의 React 렌더러라서 바깥에서 제공한 Context가 자동으로 전달되지 않습니다. 테마나 상태를 Context로 공유한다면 Zustand처럼 Context에 의존하지 않는 상태 관리 도구를 쓰거나 Canvas 안에서 Provider를 다시 감싸야 합니다.
애니메이션
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';
function RotatingBox() {
const meshRef = useRef<THREE.Mesh>(null);
useFrame(() => {
if (meshRef.current) {
meshRef.current.rotation.x += 0.01;
meshRef.current.rotation.y += 0.01;
}
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="hotpink" />
</mesh>
);
}
useFrame은 매 프레임 호출되는 콜백을 등록하는 훅으로, 순수 Three.js의 animate() 루프 안에 코드를 넣는 것과 같습니다. 여기서 핵심은 React 상태를 거치지 않고 ref로 Three.js 객체를 직접 수정한다는 점입니다. 회전값을 useState로 관리하고 매 프레임 setRotation을 호출하면 초당 60번 React 재렌더링이 일어나 금방 느려집니다. R3F로 처음 애니메이션을 만들 때 가장 흔한 성능 문제가 이것입니다. React 상태는 “선택됨”, “색상 변경”처럼 가끔 바뀌는 값에만 쓰고, 연속적인 움직임은 useFrame 안에서 ref로 처리합니다.
useFrame의 콜백은 (state, delta)를 인자로 받습니다. meshRef.current.rotation.x += delta처럼 delta(지난 프레임 이후 초)를 곱하면 모니터 주사율과 관계없이 일정한 속도로 회전합니다. useFrame은 <Canvas> 안쪽 컴포넌트에서만 호출할 수 있으며, 바깥에서 호출하면 R3F: Hooks can only be used within the Canvas component! 에러가 납니다.
정리 및 체크리스트
핵심 요약
- Three.js: 3D 웹 라이브러리
- WebGL: 하드웨어 가속
- Scene: 3D 공간
- Camera: 시점
- Light: 조명
- React Three Fiber: React 통합
구현 체크리스트
- Three.js 설치
- Scene 설정
- Geometry & Material 생성
- Light 추가
- Camera Controls 구현
- Texture 적용
- Animation 구현
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. 성능은 어떤가요?
A. GPU로 그리므로 수만~수십만 개의 삼각형도 부드럽게 처리할 수 있지만, 실제 성능은 드로우 콜 수, 텍스처 메모리, 그림자와 후처리 효과, 픽셀 비율에 크게 좌우됩니다. 느려지면 renderer.info로 드로우 콜과 삼각형 수를 먼저 확인하는 것이 출발점입니다.
Q. 모바일에서도 작동하나요?
A. WebGL을 지원하는 대부분의 모바일 브라우저에서 작동합니다. 다만 GPU 메모리가 작아 큰 텍스처나 많은 그림자에서 쉽게 한계에 닿고, 메모리가 부족하면 WebGL 컨텍스트가 사라지는(context lost) 일이 생기므로 픽셀 비율 상한과 텍스처 크기를 보수적으로 잡아야 합니다.
Q. React와 함께 사용할 수 있나요?
A. React Three Fiber를 쓰면 Three.js 객체를 컴포넌트로 다룰 수 있습니다. 연속 애니메이션은 React 상태 대신 useFrame과 ref로 처리해야 성능이 유지됩니다.
Q. 메모리 누수는 어떻게 막나요?
A. 순수 Three.js에서는 scene.remove()만으로 GPU 메모리가 해제되지 않으므로, 더 이상 쓰지 않는 Geometry, Material, Texture의 dispose()를 직접 호출해야 합니다. 페이지 전환이 있는 SPA에서 이를 빠뜨리면 화면을 오갈 때마다 GPU 메모리가 늘어납니다.