본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 29. · 2 Views
Three.js Raycaster 상호작용 구현 완벽 가이드
Three.js의 Raycaster를 활용하여 3D 객체와의 상호작용을 구현하는 방법을 초급자 눈높이에서 설명합니다. 마우스 클릭, 터치 이벤트, 하이라이트 효과까지 실전 예제와 함께 배워봅니다.
목차
- Raycaster 개념 이해
- 마우스 클릭으로 객체 선택
- 객체 하이라이트 효과
- 상호작용 가능한 오브젝트 구분
- 거리 기반 상호작용
- 모바일 터치 이벤트 처리
- 실전 예제: 클릭 가능한 표지판
1. Raycaster 개념 이해
김웹씨는 처음으로 3D 웹 프로젝트를 맡았습니다. "사용자가 3D 객체를 클릭하면 정보를 보여줘야 하는데, 어떻게 해야 하지?" 고민하던 그에게 선배 박쓰리 씨가 말했습니다.
"Raycaster를 사용하면 됩니다."
Raycaster는 한마디로 3D 공간에 보이지 않는 광선을 쏘아 객체를 감지하는 도구입니다. 마치 레이저 포인터로 물체를 가리키는 것과 같습니다.
이것을 제대로 이해하면 클릭, 호버, 선택 등 모든 3D 상호작용을 자유롭게 구현할 수 있습니다.
다음 코드를 살펴봅시다.
// Three.js의 Raycaster 생성
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// 마우스 좌표를 정규화된 좌표로 변환 (-1 ~ 1 범위)
function onMouseMove(event) {
// 화면 좌표를 -1에서 1 사이로 정규화
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
// Raycaster로 광선 발사
raycaster.setFromCamera(mouse, camera);
// 교차하는 객체들 찾기
const intersects = raycaster.intersectObjects(scene.children);
김웹 씨는 갓 입사한 프론트엔드 개발자입니다. 그동안 2D 웹만 다뤄왔던 그에게 3D 웹 프로젝트는 낯설기만 했습니다.
특히 "사용자가 3D 모델을 클릭하면 상세 정보를 보여주세요"라는 요구사항을 받고 막막했습니다. "2D에서는 그냥 onClick 이벤트 쓰면 됐는데, 3D에서는 어떻게 하지?" 고민하던 김웹 씨에게 선배 박쓰리 씨가 다가왔습니다.
"Raycaster를 배워보세요. 3D 상호작용의 기본이에요." Raycaster란 정확히 무엇일까요? 쉽게 비유하자면, Raycaster는 마치 FPS 게임에서 총을 쏘는 것과 같습니다.
화면 중앙에서 똑바로 총알이 날아가 적을 맞히는지 확인하듯이, 카메라에서 마우스 커서 방향으로 보이지 않는 광선을 쏘아 3D 객체를 맞히는지 감지합니다. 이 광선이 물체와 부딪히면 "충돌했다!"라고 알려주는 것이죠.
실제로 ray는 광선, caster는 발사하는 사람을 의미합니다. 이름부터 직관적입니다.
Raycaster가 없던 시절에는 어땠을까요? 초창기 3D 웹에서는 개발자들이 직접 수학 공식을 작성해야 했습니다. 마우스 좌표를 3D 공간 좌표로 변환하고, 각 객체의 위치와 크기를 계산해서 클릭 여부를 판단했습니다.
객체가 10개만 되어도 코드가 복잡해지고 성능도 나빠졌습니다. 더 큰 문제는 3D 객체가 회전하거나 크기가 변할 때였습니다.
매번 좌표 계산을 다시 해야 했고, 버그가 자주 발생했습니다. 프로젝트가 커질수록 유지보수는 악몽이 되었습니다.
바로 이런 문제를 해결하기 위해 Raycaster가 등장했습니다. Raycaster를 사용하면 복잡한 수학 계산을 신경 쓸 필요가 없습니다. Three.js가 모든 계산을 대신 처리해줍니다.
또한 여러 객체가 겹쳐 있어도 가장 가까운 객체부터 정확하게 찾아냅니다. 무엇보다 코드가 간결해지고 직관적이라는 큰 이점이 있습니다.
코드를 한 줄씩 살펴보겠습니다. 먼저 new THREE.Raycaster()로 Raycaster 인스턴스를 생성합니다. 이것이 우리의 광선 발사기입니다.
그리고 new THREE.Vector2()로 마우스 좌표를 저장할 2D 벡터를 만듭니다. 다음으로 중요한 부분이 나옵니다.
mouse.x와 mouse.y를 계산하는 부분인데, 여기서 **정규화(normalization)**가 일어납니다. 브라우저의 픽셀 좌표(예: 800px, 600px)를 -1에서 1 사이의 값으로 변환하는 것입니다.
왜 이렇게 할까요? Three.js의 카메라는 화면을 -1부터 1까지의 범위로 인식하기 때문입니다.
raycaster.setFromCamera(mouse, camera)는 핵심 메서드입니다. 이 한 줄로 카메라 위치에서 마우스 방향으로 광선이 발사됩니다.
마지막으로 intersectObjects()가 광선과 교차하는 모든 객체를 배열로 반환합니다. 실제 현업에서는 어떻게 활용할까요? 예를 들어 가구 배치 시뮬레이션 서비스를 개발한다고 가정해봅시다.
사용자가 소파를 클릭하면 색상을 변경하고, 테이블을 클릭하면 크기를 조절하는 기능이 필요합니다. Raycaster를 활용하면 어떤 가구를 클릭했는지 정확하게 감지하고, 해당 가구만 수정할 수 있습니다.
IKEA, 한샘 같은 기업에서 이런 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 매 프레임마다 intersectObjects()를 호출하는 것입니다.
이렇게 하면 성능이 크게 저하될 수 있습니다. 특히 씬에 수백 개의 객체가 있다면 심각한 렉이 발생합니다.
따라서 클릭이나 마우스 이동 같은 이벤트가 발생할 때만 체크하는 방식으로 사용해야 합니다. 정리해봅시다. 다시 김웹 씨의 이야기로 돌아가 봅시다.
박쓰리 씨의 설명을 들은 김웹 씨는 눈이 반짝였습니다. "아, 그래서 게임에서 총 쏠 때도 이런 원리를 쓰는군요!" Raycaster를 제대로 이해하면 클릭뿐만 아니라 호버, 드래그, 심지어 VR 컨트롤러 상호작용까지 구현할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - Raycaster는 한 번 생성하면 재사용하세요. 매번 new로 생성하면 메모리 낭비입니다.
- 마우스 좌표 정규화 시 Y축은 반전시켜야 합니다(화면 좌표계와 Three.js 좌표계가 반대).
- 성능을 위해 intersectObjects의 두 번째 파라미터로 recursive를 true로 설정하면 자식 객체까지 검사합니다.
2. 마우스 클릭으로 객체 선택
김웹 씨가 Raycaster의 개념을 이해하고 나니, 이제 실제로 사용해보고 싶어졌습니다. "클릭하면 큐브 색깔이 바뀌는 걸 만들어볼까?" 하지만 막상 코드를 작성하려니 어디서부터 시작해야 할지 막막했습니다.
마우스 클릭 이벤트와 Raycaster를 결합하면 사용자가 클릭한 3D 객체를 정확하게 선택할 수 있습니다. 마치 데스크톱에서 파일을 클릭하는 것처럼 자연스러운 3D 인터랙션을 구현하는 첫걸음입니다.
이 패턴을 익히면 모든 3D 웹 프로젝트에 응용할 수 있습니다.
다음 코드를 살펴봅시다.
// 클릭 이벤트 리스너 등록
window.addEventListener('click', onClick);
function onClick(event) {
// 마우스 좌표 정규화
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// 레이캐스팅 실행
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
// 클릭된 객체가 있다면
if (intersects.length > 0) {
const clickedObject = intersects[0].object;
// 색상 변경
clickedObject.material.color.set(0xff0000);
console.log('클릭된 객체:', clickedObject.name);
}
}
김웹 씨는 간단한 데모를 만들기로 했습니다. 화면에 큐브 3개를 배치하고, 클릭하면 빨간색으로 변하는 기능입니다.
"쉬울 줄 알았는데 생각보다 고려할 게 많네요." 먼저 그는 HTML의 button처럼 그냥 onClick을 쓰면 될 줄 알았습니다. 하지만 Canvas 요소는 하나의 덩어리일 뿐, 내부의 3D 객체는 DOM 요소가 아닙니다.
따라서 window에 이벤트를 걸고 수동으로 감지해야 합니다. 클릭 이벤트와 Raycaster를 어떻게 연결할까요? 핵심은 이벤트가 발생한 순간의 마우스 좌표를 사용하는 것입니다.
마치 사진을 찍듯이 클릭한 그 순간의 좌표를 캡처해서 Raycaster에게 전달합니다. "여기를 클릭했으니 이 방향으로 광선을 쏴봐"라고 명령하는 셈이죠.
왜 addEventListener를 사용할까요? Canvas는 단일 DOM 요소이므로 모든 클릭 이벤트가 Canvas 전체에 걸립니다. Three.js 객체들은 브라우저가 직접 인식하지 못합니다.
따라서 우리가 직접 "이 좌표에 어떤 3D 객체가 있나?"를 계산해야 합니다. addEventListener는 이런 저수준 제어를 가능하게 합니다.
예전에는 jQuery를 써서 $('canvas').click()처럼 했지만, 요즘은 바닐라 JavaScript로 직접 처리하는 게 표준입니다. 더 빠르고 의존성도 없으니까요.
바로 이 패턴이 3D 상호작용의 기본입니다. 이벤트가 발생하면 좌표를 정규화하고, Raycaster를 업데이트하고, 교차점을 찾습니다. 이 3단계만 기억하면 됩니다.
클릭이든 마우스 이동이든 터치든, 모두 같은 패턴을 따릅니다. 코드를 단계별로 분석해봅시다. 첫 줄 window.addEventListener('click', onClick)은 전역 클릭 이벤트를 감지합니다.
Canvas 밖을 클릭해도 반응하므로, 실전에서는 Canvas 요소에만 걸어야 합니다. onClick 함수 안에서 먼저 마우스 좌표를 정규화합니다.
앞서 배운 공식 그대로입니다. 그다음 setFromCamera로 광선을 발사하고, intersectObjects로 충돌 체크를 합니다.
여기서 중요한 포인트가 나옵니다. intersects 배열은 거리순으로 정렬되어 있습니다.
즉, intersects[0]은 카메라에서 가장 가까운 객체입니다. 여러 객체가 겹쳐 있어도 사용자가 실제로 "보이는" 객체를 선택하게 됩니다.
정말 똑똑하죠? 마지막으로 material.color.set()으로 색상을 변경합니다.
이 부분을 응용하면 크기 변경, 회전, 삭제 등 무엇이든 가능합니다. 실무에서는 어떻게 쓸까요? 부동산 VR 투어 서비스를 예로 들어봅시다.
사용자가 소파를 클릭하면 "이 소파의 가격: 89만원"이라는 팝업을 띄워야 합니다. Raycaster로 클릭된 객체를 찾고, 그 객체의 userData에 저장된 가격 정보를 꺼내 UI에 표시하면 됩니다.
직방, 다방 같은 서비스에서 실제로 이런 방식을 사용합니다. 주의해야 할 함정이 있습니다. 많은 초보자가 intersects[0]을 바로 사용하다가 에러를 겪습니다.
왜냐하면 빈 공간을 클릭하면 intersects.length가 0이기 때문입니다. 반드시 if (intersects.length > 0) 체크를 먼저 해야 합니다.
이 한 줄을 빼먹으면 "Cannot read property 'object' of undefined" 에러가 발생합니다. 또 하나, scene.children을 직접 넘기면 모든 객체(조명, 카메라 포함)가 체크됩니다.
실전에서는 클릭 가능한 객체만 따로 배열에 담아서 전달하는 게 좋습니다. 다시 김웹 씨의 자리로 가봅시다. 코드를 완성한 김웹 씨가 브라우저를 열었습니다.
큐브를 클릭하자 빨간색으로 변했습니다! "와, 진짜 되네!" 첫 성공의 기쁨이었습니다.
클릭 이벤트와 Raycaster의 조합은 3D 웹의 핵심입니다. 이것만 마스터하면 게임, 시뮬레이션, VR 투어 등 어떤 프로젝트든 자신 있게 시작할 수 있습니다.
실전 팁
💡 - Canvas 요소에만 이벤트를 걸려면 canvas.addEventListener 사용하세요.
- intersects 배열이 비어있을 수 있으므로 항상 length 체크를 먼저 하세요.
- 객체의 name 속성을 미리 지정해두면 디버깅이 훨씬 쉽습니다.
3. 객체 하이라이트 효과
김웹 씨의 데모를 본 팀장님이 피드백을 주셨습니다. "좋은데, 마우스를 올렸을 때 살짝 밝아지면 더 직관적이겠어요.
사용자가 클릭 가능한 객체인지 알 수 있게요." 김웹 씨는 고개를 끄덕였습니다. "호버 효과를 넣으라는 말씀이군요!"
하이라이트 효과는 사용자가 마우스를 올렸을 때 객체의 색상이나 발광 효과를 변경하여 상호작용 가능함을 시각적으로 알려주는 기법입니다. 마치 버튼에 호버하면 색이 바뀌는 것처럼, 3D 객체에도 같은 UX를 적용할 수 있습니다.
이를 통해 사용자 경험이 크게 향상됩니다.
다음 코드를 살펴봅시다.
let hoveredObject = null;
window.addEventListener('mousemove', onMouseMove);
function onMouseMove(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(selectableObjects);
// 이전에 하이라이트된 객체 복원
if (hoveredObject) {
hoveredObject.material.emissive.setHex(0x000000);
}
// 새로운 객체 하이라이트
if (intersects.length > 0) {
hoveredObject = intersects[0].object;
hoveredObject.material.emissive.setHex(0x555555);
} else {
hoveredObject = null;
}
}
김웹 씨는 새로운 과제를 받았습니다. 호버 효과라면 CSS의 :hover처럼 간단할 줄 알았는데, 3D에서는 그렇지 않았습니다.
"마우스가 움직일 때마다 체크해야 하나?" 선배 박쓰리 씨가 옆에서 말했습니다. "맞아요.
mousemove 이벤트를 써야 해요. 그리고 emissive를 사용하면 발광 효과를 줄 수 있어요." 하이라이트 효과가 왜 중요할까요? 웹 디자인의 기본 원칙 중 하나는 **어포던스(affordance)**입니다.
사용자가 "아, 이건 클릭할 수 있겠구나"라고 직감적으로 알아야 한다는 뜻입니다. 2D 웹에서는 커서가 포인터로 바뀌고 색이 변하죠.
3D에서도 마찬가지입니다. 하이라이트가 없으면 사용자는 무엇을 클릭할 수 있는지 모릅니다.
답답함을 느끼고 페이지를 떠날 수도 있습니다. 반면 자연스러운 하이라이트가 있으면 "이 서비스, 디테일이 살아있네"라는 긍정적인 인상을 줍니다.
왜 emissive를 사용할까요? Three.js 재질(Material)에는 여러 속성이 있습니다. color는 기본 색상, emissive는 자체 발광 색상입니다.
마치 네온사인처럼 스스로 빛을 내는 효과입니다. 단순히 color를 바꾸면 조명의 영향을 받아 어두운 곳에서는 잘 안 보입니다.
하지만 emissive는 조명과 무관하게 항상 밝게 빛나므로 하이라이트에 완벽합니다. 게임 UI에서 선택된 아이템이 반짝이는 것도 같은 원리입니다.
이 패턴이 UX를 혁신적으로 개선합니다. 사용자가 마우스를 움직이면 실시간으로 객체가 반응합니다. 클릭하기 전에 미리 "이게 버튼이구나"라고 알 수 있습니다.
이런 미세한 디테일이 프로와 아마추어를 가릅니다. 코드를 자세히 뜯어봅시다. 먼저 hoveredObject 변수로 현재 하이라이트된 객체를 추적합니다.
이게 없으면 이전 객체를 복원할 수 없습니다. mousemove 이벤트는 마우스가 1픽셀만 움직여도 발생합니다.
따라서 매우 자주 호출됩니다. 성능이 걱정될 수 있지만, Raycaster는 충분히 최적화되어 있어서 수십 개 정도 객체는 문제없습니다.
핵심 로직은 "이전 객체 복원 → 새 객체 하이라이트" 순서입니다. 만약 순서를 바꾸면 같은 객체 위에서 마우스를 움직일 때 깜빡이는 버그가 생깁니다.
emissive.setHex(0x555555)는 회색 발광을 줍니다. 0x000000은 발광 없음, 0xffffff는 최대 발광입니다.
프로젝트 분위기에 맞게 색상을 조절하면 됩니다. else 블록에서 hoveredObject = null로 초기화하는 것도 중요합니다.
마우스가 빈 공간으로 가면 하이라이트를 해제해야 하니까요. 실전 사례를 볼까요? 건축 시각화 서비스를 예로 들어봅시다.
사용자가 건물의 방을 둘러봅니다. 각 방에 마우스를 올리면 은은하게 빛나고, 클릭하면 면적과 가격 정보가 나타납니다.
이런 디테일 덕분에 사용자는 "이 서비스 진짜 잘 만들었네"라고 느낍니다. 실제로 Autodesk, SketchUp 같은 전문 툴도 같은 원리를 사용합니다.
흔한 실수를 피해야 합니다. 많은 개발자가 mousemove 이벤트 안에서 복잡한 계산을 합니다. 예를 들어 API 호출이나 애니메이션을 시작하면 성능이 폭락합니다.
mousemove는 초당 수십 번 발생하므로, 안에서는 최소한의 작업만 해야 합니다. 또 다른 실수는 selectableObjects 배열을 관리하지 않는 것입니다.
scene.children을 통째로 넘기면 조명, 헬퍼, 카메라까지 체크해서 불필요한 계산이 늘어납니다. 클릭 가능한 객체만 따로 배열에 담으세요.
김웹 씨는 성공했습니다. 코드를 적용하고 마우스를 움직이자 큐브들이 은은하게 빛났습니다. "오, 이거 진짜 게임 같은데?" 팀장님도 만족스러워하셨습니다.
하이라이트 효과는 작지만 강력한 디테일입니다. 이것 하나로 사용자 만족도가 크게 올라갑니다.
여러분의 프로젝트에도 꼭 적용해보세요.
실전 팁
💡 - emissive 대신 scale을 키워서 살짝 확대하는 방법도 효과적입니다.
- 성능 최적화를 위해 throttle 함수로 mousemove 호출 빈도를 줄일 수 있습니다.
- 커서를 pointer로 바꾸면(CSS cursor: pointer) 더 직관적입니다.
4. 상호작용 가능한 오브젝트 구분
김웹 씨의 프로젝트가 점점 커지면서 문제가 생겼습니다. 씬에는 100개가 넘는 객체가 있는데, 클릭 가능한 건 10개뿐입니다.
하지만 Raycaster는 모든 객체를 체크하고 있었습니다. "이거 비효율적인데, 어떻게 개선하지?"
상호작용 가능한 객체를 구분하는 것은 성능과 정확성을 위해 필수입니다. 마치 마트에서 계산할 물건만 따로 바구니에 담듯이, 클릭 가능한 객체만 따로 관리하면 불필요한 계산을 피할 수 있습니다.
userData 속성과 별도 배열을 활용하는 두 가지 패턴을 배워봅니다.
다음 코드를 살펴봅시다.
// 방법 1: 별도 배열로 관리
const selectableObjects = [];
// 큐브 생성 시 배열에 추가
const cube = new THREE.Mesh(geometry, material);
cube.name = 'interactiveCube';
selectableObjects.push(cube);
scene.add(cube);
// 방법 2: userData로 구분
const decoration = new THREE.Mesh(decoGeometry, decoMaterial);
decoration.userData.interactive = false; // 장식용, 클릭 불가
scene.add(decoration);
// Raycaster 체크 시
const intersects = raycaster.intersectObjects(selectableObjects);
// 또는 필터링
const clickableIntersects = intersects.filter(
intersect => intersect.object.userData.interactive !== false
);
김웹 씨는 프로젝트가 복잡해지면서 새로운 고민에 빠졌습니다. 씬에는 배경 건물, 나무, 구름, 조명 등 수많은 객체가 있습니다.
하지만 실제로 클릭 가능한 건 안내판 몇 개뿐입니다. "지금은 모든 객체를 체크하고 있어.
이거 낭비 아닌가?" 선배 박쓰리 씨가 웃으며 답했습니다. "맞아요.
상호작용 가능한 객체만 따로 관리해야 해요." 왜 객체를 구분해야 할까요? Raycaster는 광선과 모든 객체의 교차점을 계산합니다. 객체가 100개면 100번 계산합니다.
문제는 그중 90개가 절대 클릭되지 않는 배경 객체라는 점입니다. 이건 마치 마트에서 살 것도 아닌데 모든 상품을 카트에 담아 계산대까지 가는 것과 같습니다.
성능 문제뿐 아니라 정확성 문제도 있습니다. 투명한 배경 객체가 앞을 가리면 뒤의 버튼을 클릭할 수 없습니다.
사용자는 "왜 클릭이 안 되지?"라며 당황합니다. 어떻게 구분할까요? 크게 두 가지 방법이 있습니다.
첫째는 별도 배열을 만드는 것입니다. selectableObjects라는 배열에 클릭 가능한 객체만 담습니다.
간단하고 직관적입니다. 둘째는 userData 속성을 활용하는 방법입니다.
Three.js의 모든 Object3D는 userData라는 빈 객체를 가지고 있습니다. 여기에 interactive: true 같은 플래그를 저장하면 나중에 필터링할 수 있습니다.
더 유연하고 확장성이 좋습니다. 이 패턴이 프로젝트를 확장 가능하게 만듭니다. 초반에는 객체가 몇 개 안 되니 상관없습니다.
하지만 프로젝트가 커지면 성능 병목이 생깁니다. 미리 구조를 잡아두면 나중에 리팩토링할 필요가 없습니다.
또한 유지보수도 쉬워집니다. "이 객체는 클릭 가능한가?"를 코드 전체를 뒤지지 않고 userData만 보면 알 수 있으니까요.
코드를 단계별로 살펴봅시다. 방법 1에서는 객체를 생성할 때 selectableObjects.push()로 배열에 추가합니다. 나중에 Raycaster에 이 배열만 넘기면 됩니다.
코드가 명확하고 성능도 좋습니다. 방법 2에서는 userData.interactive = false로 표시합니다.
true는 생략 가능합니다(기본값처럼 쓰면 됨). 나중에 intersects를 filter()로 걸러내면 됩니다.
어떤 방법이 더 좋을까요? 객체가 동적으로 추가/제거되면 userData가 편합니다.
고정된 씬이라면 별도 배열이 더 빠릅니다. 프로젝트 상황에 맞게 선택하세요.
실무에서는 어떻게 쓸까요? 게임을 예로 들어봅시다. 맵에는 수백 개의 나무, 돌, 풀이 있습니다.
하지만 클릭 가능한 건 NPC와 아이템뿐입니다. 이들만 selectableObjects에 담으면 성능이 10배 이상 빨라집니다.
또 다른 예로, 박물관 VR 투어를 만든다면 전시품은 클릭 가능하지만 벽과 바닥은 아닙니다. userData로 exhibit: true를 표시해두면 나중에 통계도 낼 수 있습니다.
"사용자가 가장 많이 클릭한 전시품은?" 주의해야 할 점이 있습니다. 많은 초보자가 scene.children을 직접 수정하려고 합니다. 예를 들어 scene.children = scene.children.filter(...)처럼요.
이러면 씬에서 객체가 사라집니다! userData는 메타데이터일 뿐, 씬 구조를 바꾸지 않습니다.
또 하나, selectableObjects 배열을 업데이트하는 걸 잊으면 안 됩니다. 객체를 제거했는데 배열에 남아있으면 메모리 누수가 생깁니다.
김웹 씨는 깨달았습니다. 배열 방식으로 리팩토링하자 FPS가 60으로 안정화되었습니다. "와, 이렇게 차이가 나네!" 박쓰리 씨가 칭찬했습니다.
"좋아요. 이제 진짜 개발자처럼 생각하는군요." 객체 구분은 작은 습관이지만 큰 차이를 만듭니다.
처음부터 올바른 구조로 시작하면 나중에 고생하지 않습니다.
실전 팁
💡 - 객체 생성 시 즉시 배열에 추가하는 습관을 들이세요.
- userData에는 interactive 외에도 type, id 등 유용한 정보를 저장할 수 있습니다.
- 성능이 중요하면 배열, 유연성이 중요하면 userData를 선택하세요.
5. 거리 기반 상호작용
김웹 씨의 프로젝트에 새로운 요구사항이 추가되었습니다. "가까이 있을 때만 클릭할 수 있게 해주세요.
멀리서는 안 돼요." FPS 게임처럼 일정 거리 안에서만 상호작용하는 기능이었습니다. "거리를 어떻게 체크하지?"
거리 기반 상호작용은 카메라와 객체 사이의 거리를 측정하여 일정 범위 내에서만 클릭을 허용하는 기법입니다. 마치 게임에서 아이템을 주울 때 "F키를 누르세요"가 뜨는 것처럼, 현실감 있는 인터랙션을 구현할 수 있습니다.
Raycaster의 intersects 배열에는 거리 정보가 포함되어 있어 간단하게 구현 가능합니다.
다음 코드를 살펴봅시다.
const MAX_INTERACTION_DISTANCE = 5; // 최대 상호작용 거리
function onClick(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(selectableObjects);
if (intersects.length > 0) {
const target = intersects[0];
// 거리 체크
if (target.distance <= MAX_INTERACTION_DISTANCE) {
console.log('상호작용 가능! 거리:', target.distance.toFixed(2));
target.object.material.color.set(0x00ff00);
} else {
console.log('너무 멉니다. 거리:', target.distance.toFixed(2));
showMessage('가까이 다가가세요');
}
}
}
김웹 씨는 VR 박물관 프로젝트를 진행하고 있었습니다. 기획자가 요청했습니다.
"사용자가 전시품에 가까이 갔을 때만 설명을 볼 수 있게 해주세요. 너무 멀리서는 안 돼요." 이건 현실적인 요구사항입니다.
실제 박물관에서도 멀리서는 안내문이 안 보이잖아요? 김웹 씨는 고민했습니다.
"거리를 어떻게 계산하지? 직접 수학 공식을 써야 하나?" 거리 정보는 이미 준비되어 있습니다. 놀랍게도 Raycaster가 이미 계산해줍니다.
intersects 배열의 각 요소에는 distance 속성이 있습니다. 카메라에서 교차점까지의 직선거리입니다.
우리는 그냥 가져다 쓰기만 하면 됩니다. 마치 택배 상자에 "거리: 3.5km"라고 적혀 있는 것과 같습니다.
이미 계산되어 있으니 우리는 비교만 하면 되죠. 왜 거리 제한이 필요할까요? 첫째, 현실감입니다.
게임이나 시뮬레이션에서 무한대 거리로 클릭할 수 있으면 이상합니다. FPS 게임에서 칼로 100m 밖을 공격할 수 없는 것처럼요.
둘째, 사용자 유도입니다. 거리 제한을 두면 사용자가 자연스럽게 중요한 객체에 가까이 다가갑니다.
VR 투어에서는 이게 중요합니다. 멀리서 모든 걸 보면 몰입감이 떨어지거든요.
셋째, 성능입니다. 거리가 가까운 객체만 체크하면 계산량이 줄어듭니다.
큰 씬에서는 이게 결정적일 수 있습니다. 이 패턴이 게임성을 만듭니다. "3m 안에 들어오면 F키를 눌러 문을 열 수 있습니다." 이런 메시지를 본 적 있죠?
바로 거리 기반 상호작용입니다. 단순히 기술적인 구현이 아니라 게임 디자인의 핵심 요소입니다.
코드를 자세히 분석해봅시다. 상수 MAX_INTERACTION_DISTANCE = 5로 최대 거리를 정의합니다. 이 값은 프로젝트마다 다릅니다.
FPS 게임이라면 2-3, VR 투어라면 5-10 정도가 적당합니다. Three.js의 기본 단위는 상대적이지만, 보통 1 = 1m로 생각하면 됩니다.
intersects[0]을 target 변수에 담습니다. 이 객체는 여러 속성을 가지고 있습니다: - object: 충돌한 3D 객체 - distance: 카메라로부터의 거리 - point: 정확한 교차점 좌표 (Vector3) - face: 충돌한 면 정보 우리가 필요한 건 distance입니다.
이것을 MAX_INTERACTION_DISTANCE와 비교합니다. 간단하죠?
toFixed(2)는 소수점 2자리로 반올림합니다. 콘솔 로그를 깔끔하게 보기 위해서입니다.
실제 비교에는 불필요합니다. 실무 사례를 볼까요? VR 탈출 게임을 만든다고 가정합시다.
방에는 열쇠, 문, 금고가 있습니다. 각각 상호작용 거리가 다릅니다: - 열쇠: 1m (손을 뻗어야 함) - 문: 2m (문 앞까지 가야 함) - 금고: 0.5m (바로 앞에서만) 이런 디테일이 게임을 재미있게 만듭니다.
Pokemon GO에서 포켓스톱을 돌리려면 가까이 가야 하는 것도 같은 원리입니다. 주의할 점이 있습니다. 많은 개발자가 camera.position.distanceTo(object.position)를 직접 계산합니다.
틀린 건 아니지만 비효율적입니다. Raycaster가 이미 계산했는데 또 계산하는 셈이니까요.
또 하나, 거리는 카메라 중심에서 교차점까지입니다. 객체 중심까지가 아닙니다.
큰 객체의 경우 가장자리와 중심의 거리 차이가 클 수 있으니 테스트가 필요합니다. 김웹 씨는 완성했습니다. 거리 체크를 추가하니 훨씬 자연스러워졌습니다.
멀리서 클릭하면 "가까이 다가가세요"라는 메시지가 뜨고, 가까이 가면 상호작용할 수 있습니다. 기획자가 만족했습니다.
"바로 이거예요!" 거리 기반 상호작용은 몰입감의 핵심입니다. 작은 디테일이지만 사용자 경험을 크게 바꿉니다.
실전 팁
💡 - 거리 단위는 프로젝트 초반에 명확히 정하세요(예: 1 unit = 1m).
- 거리에 따라 UI 힌트를 달리 보여주면 더 직관적입니다.
- target.point를 사용하면 정확한 클릭 지점에 이펙트를 낼 수 있습니다.
6. 모바일 터치 이벤트 처리
김웹 씨의 프로젝트가 모바일에서도 작동해야 한다는 공지가 떨어졌습니다. "마우스 이벤트는 되는데, 모바일에서는 왜 안 되지?" 디버깅해보니 터치 이벤트를 처리하지 않았기 때문이었습니다.
"click은 되는데 touchstart는 안 되네?"
모바일 터치 이벤트는 마우스와 다른 방식으로 작동하므로 별도 처리가 필요합니다. 마치 PC와 모바일이 다른 언어를 쓰는 것처럼, 터치는 touches 배열을 통해 좌표를 전달합니다.
터치와 마우스를 모두 지원하는 통합 패턴을 익히면 크로스 플랫폼 3D 웹을 만들 수 있습니다.
다음 코드를 살펴봅시다.
// 통합 좌표 계산 함수
function getPointerCoordinates(event) {
let clientX, clientY;
// 터치 이벤트 처리
if (event.touches && event.touches.length > 0) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
}
// 마우스 이벤트 처리
else {
clientX = event.clientX;
clientY = event.clientY;
}
return {
x: (clientX / window.innerWidth) * 2 - 1,
y: -(clientY / window.innerHeight) * 2 + 1
};
}
// 통합 이벤트 리스너
canvas.addEventListener('click', onInteract);
canvas.addEventListener('touchstart', onInteract);
function onInteract(event) {
event.preventDefault(); // 터치 시 스크롤 방지
const coords = getPointerCoordinates(event);
mouse.x = coords.x;
mouse.y = coords.y;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(selectableObjects);
if (intersects.length > 0) {
handleObjectClick(intersects[0].object);
}
}
김웹 씨는 당황했습니다. 데스크톱에서는 완벽하게 작동하던 프로젝트가 스마트폰에서는 아무 반응이 없었습니다.
"분명히 클릭 이벤트를 걸었는데?" 선배 박쓰리 씨가 설명했습니다. "모바일에는 click 이벤트가 있긴 한데, 300ms 지연이 있어요.
그리고 터치는 다른 방식으로 작동합니다." 터치와 마우스는 무엇이 다를까요? 마우스 이벤트는 event.clientX, event.clientY로 직접 좌표를 줍니다. 하나의 포인터만 있으니까요.
하지만 터치는 다릅니다. 손가락이 여러 개일 수 있습니다!
따라서 event.touches라는 배열로 전달됩니다. 멀티터치를 지원하기 위해서죠.
우리는 보통 첫 번째 터치(touches[0])만 사용합니다. 마치 전화와 이메일의 차이와 같습니다.
전화는 한 명과 통화하지만(마우스), 단체 채팅은 여러 명이 동시에 말할 수 있습니다(터치). 왜 통합 처리가 중요할까요? 2024년 현재 모바일 트래픽이 전체의 60% 이상입니다.
모바일을 지원하지 않으면 절반 이상의 사용자를 잃는 셈입니다. 또한 같은 코드를 두 번 작성하면 유지보수가 두 배로 힘듭니다.
버그를 고쳐도 마우스만 고치고 터치는 안 고치는 실수가 생깁니다. 통합 함수를 만들면 한 곳만 수정하면 되니 훨씬 효율적입니다.
이 패턴이 크로스 플랫폼의 핵심입니다. 데스크톱, 태블릿, 스마트폰에서 모두 작동하는 웹. 이게 현대 웹의 표준입니다.
Three.js 프로젝트도 예외가 아닙니다. 코드를 단계별로 뜯어봅시다. getPointerCoordinates 함수가 핵심입니다.
이 함수는 마우스든 터치든 상관없이 정규화된 좌표를 반환합니다. 일종의 어댑터 패턴입니다.
if (event.touches && event.touches.length > 0) 조건문으로 터치인지 판별합니다. event.touches가 존재하고 비어있지 않으면 터치 이벤트입니다.
첫 번째 터치의 좌표를 가져옵니다. 그렇지 않으면 마우스 이벤트입니다.
event.clientX를 직접 사용합니다. 중요한 부분이 event.preventDefault()입니다.
터치 이벤트는 기본적으로 페이지 스크롤을 일으킵니다. 3D 캔버스를 터치할 때 스크롤되면 짜증나죠?
preventDefault()로 막아야 합니다. 두 이벤트 리스너를 모두 등록하는 것도 포인트입니다.
click과 touchstart 둘 다 같은 함수를 호출합니다. 이렇게 하면 플랫폼에 관계없이 작동합니다.
실무에서는 어떻게 쓸까요? 제품 3D 뷰어를 예로 들어봅시다. 사용자가 신발을 회전하면서 보고, 마음에 들면 클릭해서 구매합니다.
데스크톱에서는 마우스 드래그, 모바일에서는 스와이프로 회전합니다. 이런 서비스는 반드시 모바일을 지원해야 합니다.
쇼핑의 70%가 모바일에서 일어나니까요. 나이키, 아디다스 같은 브랜드 사이트가 모두 이런 패턴을 씁니다.
주의해야 할 함정이 있습니다. 많은 개발자가 click 이벤트만 쓰고 "모바일도 되겠지"라고 생각합니다. 기술적으로는 작동하지만 300ms 지연이 있어서 답답합니다.
사용자가 "이 사이트 느리네"라고 느낍니다. 또 다른 실수는 touchend 대신 touchstart를 쓰지 않는 것입니다.
touchstart가 더 즉각적이고 반응성이 좋습니다. 멀티터치를 고려하지 않는 것도 문제입니다.
예를 들어 핀치 줌을 하면 touches.length가 2가 됩니다. 이 경우 상호작용을 막아야 할 수도 있습니다.
김웹 씨는 해냈습니다. 코드를 수정하고 스마트폰으로 테스트했습니다. 완벽하게 작동했습니다!
"이제 진짜 어디서든 쓸 수 있겠네요." 모바일 지원은 선택이 아니라 필수입니다. 한 번만 제대로 구현하면 모든 디바이스에서 작동하는 3D 웹을 만들 수 있습니다.
실전 팁
💡 - passive: false 옵션으로 addEventListener를 등록하면 preventDefault가 확실히 작동합니다.
- 멀티터치 제스처(핀치 줌 등)를 지원하려면 touches.length를 체크하세요.
- 터치와 마우스 모두에서 mousemove, touchmove를 활용하면 드래그도 구현할 수 있습니다.
7. 실전 예제: 클릭 가능한 표지판
김웹 씨는 드디어 실전 프로젝트를 맡았습니다. 캠퍼스 투어 웹사이트에서 표지판을 클릭하면 건물 정보를 보여주는 기능입니다.
"지금까지 배운 걸 다 써볼 때가 왔네!" 그는 설레는 마음으로 코딩을 시작했습니다.
클릭 가능한 표지판은 지금까지 배운 모든 기법을 종합한 실전 예제입니다. Raycaster, 클릭 이벤트, 하이라이트, 거리 체크, 모바일 지원을 모두 결합하여 완성도 높은 3D 인터랙션을 구현합니다.
이 패턴을 마스터하면 어떤 3D 웹 프로젝트든 자신 있게 만들 수 있습니다.
다음 코드를 살펴봅시다.
// 표지판 생성 및 정보 저장
function createSignboard(position, buildingInfo) {
const geometry = new THREE.BoxGeometry(1, 1.5, 0.1);
const material = new THREE.MeshStandardMaterial({
color: 0x8B4513,
emissive: 0x000000
});
const signboard = new THREE.Mesh(geometry, material);
signboard.position.copy(position);
signboard.name = buildingInfo.name;
signboard.userData = {
interactive: true,
type: 'signboard',
info: buildingInfo,
originalColor: 0x8B4513
};
selectableObjects.push(signboard);
scene.add(signboard);
return signboard;
}
// 통합 상호작용 핸들러
let hoveredSign = null;
const INTERACTION_DISTANCE = 8;
canvas.addEventListener('mousemove', onPointerMove);
canvas.addEventListener('touchmove', onPointerMove);
canvas.addEventListener('click', onPointerClick);
canvas.addEventListener('touchstart', onPointerClick);
function onPointerMove(event) {
const coords = getPointerCoordinates(event);
mouse.set(coords.x, coords.y);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(selectableObjects);
// 이전 하이라이트 제거
if (hoveredSign) {
hoveredSign.material.emissive.setHex(0x000000);
canvas.style.cursor = 'default';
}
// 새 하이라이트
if (intersects.length > 0 && intersects[0].distance <= INTERACTION_DISTANCE) {
hoveredSign = intersects[0].object;
hoveredSign.material.emissive.setHex(0x444444);
canvas.style.cursor = 'pointer';
} else {
hoveredSign = null;
}
}
function onPointerClick(event) {
event.preventDefault();
const coords = getPointerCoordinates(event);
mouse.set(coords.x, coords.y);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(selectableObjects);
if (intersects.length > 0) {
const target = intersects[0];
if (target.distance <= INTERACTION_DISTANCE) {
const info = target.object.userData.info;
showInfoPanel(info);
playClickSound();
} else {
showToast(`${target.object.name}에 더 가까이 다가가세요`);
}
}
}
// 사용 예시
createSignboard(
new THREE.Vector3(5, 1, -3),
{
name: '공학관',
description: '컴퓨터공학과와 전자공학과가 위치한 건물입니다.',
year: 1985,
floors: 5
}
);
김웹 씨는 요구사항을 정리했습니다. 표지판을 클릭하면 건물 이름, 설명, 준공 연도가 나타나야 합니다.
가까이 가면 하이라이트되고, 멀리서는 "가까이 오세요" 메시지를 보여줘야 합니다. 그리고 모바일에서도 작동해야 합니다.
"하나씩 차근차근 해보자." 김웹 씨는 이제 자신감이 생겼습니다. 왜 표지판 예제가 중요할까요? 표지판은 3D 웹에서 가장 흔한 패턴입니다.
박물관에서는 전시품 설명, 게임에서는 퀘스트 알림, 부동산 투어에서는 방 정보를 보여줍니다. 이 패턴 하나로 수많은 프로젝트에 응용할 수 있습니다.
또한 표지판은 정보 전달이라는 실용적 목적이 있습니다. 단순히 기술 데모가 아니라 실제 사용자에게 가치를 주는 기능입니다.
어떻게 설계할까요? 먼저 표지판 객체를 생성하는 헬퍼 함수를 만듭니다. createSignboard는 위치와 건물 정보를 받아 표지판을 생성합니다.
이렇게 하면 여러 표지판을 쉽게 추가할 수 있습니다. 핵심은 userData에 정보를 저장하는 것입니다.
나중에 클릭했을 때 이 정보를 꺼내 UI에 표시합니다. userData는 Three.js 객체에 메타데이터를 붙이는 표준 방법입니다.
interactive: true로 표시하고, type: 'signboard'로 객체 종류를 구분합니다. 나중에 건물, 나무, 표지판을 다르게 처리할 수 있게 하기 위해서입니다.
이 패턴이 확장성을 보장합니다. 지금은 표지판만 있지만, 나중에 동상, 벤치, 나무도 추가될 수 있습니다. userData에 type을 저장해두면 if (type === 'signboard') 식으로 분기 처리가 쉽습니다.
또한 정보를 객체 내부에 저장하므로 별도의 데이터베이스나 매핑 테이블이 필요 없습니다. 코드가 간결해지고 유지보수가 쉬워집니다.
코드를 자세히 살펴봅시다. createSignboard 함수는 재사용 가능한 팩토리 패턴입니다. 같은 모양의 표지판을 여러 개 만들 때 유용합니다.
실무에서는 GLTF 모델을 로드해서 더 멋진 표지판을 만들 수도 있습니다. originalColor를 저장하는 것도 좋은 습관입니다.
하이라이트 후 원래 색으로 돌아갈 때 필요하니까요. onPointerMove에서는 거리 체크를 합니다.
8m 이내일 때만 하이라이트됩니다. 커서도 pointer로 바꿔서 클릭 가능함을 알립니다.
이런 디테일이 UX를 결정합니다. onPointerClick에서는 실제 클릭을 처리합니다.
거리가 충분히 가까우면 showInfoPanel로 정보를 표시하고, 멀면 안내 메시지를 보여줍니다. playClickSound()로 사운드 피드백까지 주면 완벽합니다.
실무에서는 어떻게 응용할까요? 이 패턴을 응용하면 정말 많은 걸 만들 수 있습니다. 역사 교육 사이트라면 유적지마다 표지판을 세우고, 클릭하면 역사 설명과 사진을 보여줍니다.
쇼핑몰이라면 제품에 가격표를 달고, 클릭하면 구매 페이지로 이동합니다. 게임이라면 NPC 위에 느낌표를 띄우고, 클릭하면 퀘스트 대화가 시작됩니다.
실제로 구글 아트 앤 컬처, 스미소니언 박물관 VR 투어가 이런 방식을 사용합니다. 주의할 점도 있습니다. 표지판이 너무 많으면 성능 문제가 생깁니다.
수백 개의 표지판을 모두 체크하면 느려집니다. 이럴 때는 공간 분할(spatial partitioning) 기법을 쓰거나, 카메라 시야 안의 것만 체크하는 **프러스텀 컬링(frustum culling)**을 적용해야 합니다.
또한 표지판 텍스트를 3D 텍스트로 만들면 멋있지만 무겁습니다. 대신 HTML CSS로 2D 오버레이를 쓰는 게 더 현실적입니다.
userData에 너무 많은 데이터를 담으면 메모리를 많이 먹습니다. 간단한 ID만 저장하고, 상세 정보는 별도 객체에서 참조하는 방식도 고려하세요.
김웹 씨는 성공했습니다. 코드를 완성하고 캠퍼스 곳곳에 표지판 10개를 배치했습니다. 데스크톱에서도, 스마트폰에서도 완벽하게 작동했습니다.
클릭하면 건물 정보가 팝업으로 나타나고, 멀리서는 "가까이 오세요"라고 친절하게 안내했습니다. 팀장님이 데모를 보고 감탄했습니다.
"이거 진짜 게임 같은데요? 퀄리티 높네요!" 김웹 씨는 뿌듯했습니다.
몇 주 전만 해도 Raycaster가 뭔지도 몰랐는데, 이제는 실전 프로젝트를 완성했습니다. "점프 투 자바처럼 차근차근 배우니까 되는구나." 정리해봅시다. Raycaster 상호작용은 3D 웹의 기초이자 핵심입니다.
광선 발사 개념부터 시작해서 클릭, 하이라이트, 거리 체크, 모바일 지원까지 단계별로 익히면 어렵지 않습니다. 지금까지 배운 내용을 자신의 프로젝트에 적용해보세요.
처음에는 간단한 큐브 클릭부터 시작하고, 점점 복잡한 인터랙션으로 발전시켜 나가면 됩니다. Three.js는 생각보다 쉽습니다. 한 걸음씩 나아가면 여러분도 멋진 3D 웹을 만들 수 있습니다.
파이팅!
실전 팁
💡 - 표지판 모델은 Blender에서 만들어 GLTF로 익스포트하면 훨씬 멋있습니다.
- 정보 패널은 React나 Vue 컴포넌트로 만들면 관리가 쉽습니다.
- 클릭 사운드, 호버 사운드를 추가하면 몰입감이 크게 높아집니다.
- 표지판 위에 HTML CSS로 텍스트 레이블을 띄우려면 CSS3DRenderer를 활용하세요.
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
3D 포트폴리오 웹사이트 개발 완벽 가이드
Three.js와 Blender를 활용하여 인상적인 3D 포트폴리오 웹사이트를 만드는 방법을 초급 개발자도 쉽게 이해할 수 있도록 실무 중심으로 설명합니다. 프로젝트 구조부터 접근성까지 모든 과정을 담았습니다.
Three.js 고급 코드 분석 완벽 가이드
Three.js 공식 예제를 분석하고 복잡한 씬 구조를 이해하는 방법부터 커스텀 Shader, PostProcessing, 성능 최적화, 디버깅까지 실무에서 바로 활용할 수 있는 고급 기법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 상황 스토리와 비유로 풀어낸 가이드입니다.
Three.js 충돌 감지 완벽 가이드
3D 웹 게임에서 캐릭터가 벽을 뚫고 지나가는 문제를 해결하는 충돌 감지 기술을 배웁니다. Bounding Box부터 물리 엔진까지 실전 예제로 완벽하게 마스터해보세요.
GSAP 애니메이션 라이브러리 완벽 가이드
웹 애니메이션의 표준, GSAP를 처음부터 제대로 배워봅니다. 기본 사용법부터 Timeline, Easing, Three.js 통합까지 실무에서 바로 쓸 수 있는 핵심 내용을 다룹니다.
Three.js 카메라 및 조명 설정 완벽 가이드
3D 웹 개발에서 가장 중요한 카메라와 조명 설정을 실무 중심으로 배워봅니다. 초급 개발자도 쉽게 따라할 수 있도록 실전 예제와 함께 설명합니다.