WebGL로 사내 해커톤 1등하기
이전 직장인 한국신용데이터에서 재직하던 시절, 사내 해커톤에서 만들었던 '캐시노트 타운' 프로젝트를 요즘 다시 꺼내 살펴보고 있습니다.
약 3MB 를 내려받습니다. 누르기 전까지는 아무것도 받지 않습니다.
꼬박 하루 동안 Blender로 리소스를 수정하고 필요한 오브젝트를 직접 모델링했습니다. 텍스처를 매핑해 glTF 원본을 만들고, 오브젝트의 3D 좌표를 기준으로 사용자 입력 이벤트를 바인딩했습니다. 우리가 전하고 싶은 비즈니스 스토리를 고객이 직접 돌아다니고 상호작용하며 경험하는 참여형 콘텐츠로 만들어 보자는 프로젝트였습니다.
읽는 콘텐츠가 아니라 걸어 다니는 콘텐츠
해커톤에 들고 간 문제의식은 단순했습니다. 우리가 하는 이야기는 사장님들의 장부에 관한 이야기인데, 정작 그 이야기를 전달하는 방식은 늘 문서와 이미지였습니다.
"매일 매출이 얼마인지, 비용이 얼마나 나가는지 신경 쓰지 않아도 된다"는 문장은 읽으면 한 줄입니다. 하지만 그 한 줄이 실제로 어떤 의미인지는 가게가 늘어선 거리를 직접 돌아다녀 보면 훨씬 빠르게 와닿습니다. 정육점도 있고, 빵집도 있고, 학교도 있는 동네. 그리고 가게마다 사장님이 한마디씩 남겨 두는 모습을 떠올렸습니다.
그래서 마을을 하나 만들고 그 안을 돌아다니게 하자로 방향을 잡았습니다. 사용자가 직접 시점을 돌리고, 궁금한 가게의 말풍선을 눌러 보는 구조입니다. 스크롤을 내리는 대신 마을을 돌리는 것이죠.
시간은 꼬박 하루였습니다. 그 제약이 이후 거의 모든 기술 선택을 결정했습니다.
마을을 만드는 일: Blender에서 glTF까지
첫 관문은 코드가 아니라 모델이었습니다. 에셋을 가져와 Blender에서 필요한 형태로 수정하고, 새로 필요한 오브젝트는 직접 모델링했습니다. 텍스처를 매핑한 뒤 최종적으로 glTF로 내보냈습니다. 완성된 파일은 이 정도 규모였습니다.
| 항목 | 수치 |
|---|---|
| 메시(mesh) | 672 |
| 프리미티브(primitive) | 696 |
| 정점(vertex) | 445,317 |
| 삼각형(triangle) | 402,655 |
| 머티리얼(material) | 38 |
| 파일 크기 | 5.07MB |
내보낼 때는 Draco 압축을 적용했습니다. glTF의 메시 데이터를 압축하는 방식으로, 파일 크기를 크게 줄일 수 있는 대신 불러오는 쪽에서 반드시 디코더를 연결해야 합니다. 이 파일에는 KHR_draco_mesh_compression이 필수 확장으로 지정되어 있어 디코더가 없으면 모델 자체를 정상적으로 불러올 수 없습니다.
const dracoLoader = new DRACOLoader();
const loader = new GLTFLoader();
dracoLoader.setDecoderPath("https://www.gstatic.com/draco/v1/decoders/");
loader.setDRACOLoader(dracoLoader);
이 단계는 제게 오히려 가장 익숙한 구간이었습니다. 개발자가 되기 전 산업 디자이너로 일하며 설계 공차까지 고려하는 제품 모델링을 다뤄 봤기 때문입니다. 다만 두 모델링은 목적이 꽤 다릅니다.
- 제품 설계 모델링에서는 치수가 곧 사실입니다. 0.2mm의 공차가 조립이 되느냐 마느냐를 가릅니다.
- 메시(mesh) 모델링에서는 같은 모양이라도 얼마나 많은 정점과 면으로 구성했는지가 렌더링 비용을 결정합니다.
이 차이를 몸으로 알고 있었던 것이 나중에 성능 문제를 볼 때 도움이 됐습니다. "예뻐 보이는데 왜 느리지?"가 아니라 "이 나무 한 그루가 면을 몇 개 쓰고 있지?"라고 질문하게 되기 때문입니다.
아이소메트릭 화면에는 직교 카메라가 잘 맞습니다
마을을 게임 화면처럼 보이게 만드는 핵심은 카메라였습니다. 흔히 사용하는 원근 카메라(Perspective Camera)를 쓰면 가까운 건물은 커지고 먼 건물은 작아집니다. 사진에서는 자연스럽지만, 마을 전체를 한눈에 보여 주는 화면에서는 안쪽 건물이 급격히 작아져 마을이라기보다 골목처럼 느껴집니다.
그래서 직교 카메라(Orthographic Camera)를 사용했습니다. 직교 투영에서는 카메라와의 거리에 따라 오브젝트 크기가 달라지지 않기 때문에, 마을 전체를 내려다보는 게임 같은 화면을 만들기에 적합했습니다.
const d = 60;
const camera = new THREE.OrthographicCamera(
-d * aspect, d * aspect, // 좌우
d, -d, // 상하
-200, 2000 // 근/원 평면
);
camera.position.set(20, 20, 10);
d는 카메라가 담는 세로 영역의 반높이입니다. 사실상 이 값 하나가 "화면에 마을을 얼마나 담을 것인가"를 결정합니다.
시점 조작은 OrbitControls에 맡겼고, 사용자가 아무것도 하지 않을 때는 화면이 아주 천천히 회전하도록 했습니다. 정지한 3D 화면은 언뜻 보면 이미지와 크게 다르지 않기 때문입니다.
controls.autoRotate = true;
controls.autoRotateSpeed = -0.2;
오브젝트 좌표에 이벤트를 바인딩한다는 것
이 프로젝트에서 가장 재미있었던 부분입니다. 말풍선을 띄울 위치를 코드에 좌표로 직접 적어 두면 모델을 조금만 수정해도 모든 위치가 어긋납니다. 그래서 Blender에서 정한 오브젝트 이름 자체를 하나의 규약으로 사용했습니다. 가게 건물에는 09_Boutika, 12_Burger_House처럼 두 자리 숫자로 시작하는 이름을 붙여 두고, glTF를 불러온 뒤 씬 전체를 순회하면서 이 규칙에 해당하는 오브젝트만 골라냅니다.
gltf.scene.traverse(function (node) {
if (!node.isMesh) return;
// 두 자리 숫자로 시작하는 이름 = 말풍선을 띄울 가게
if (/^[0-9]{2}_{1,2}(.*)/.exec(node.name)) {
targets.push(node);
}
// 건물 위 로고는 따로 모아 둔다
if (/cashnotelogo/i.exec(node.name)) {
aniTargets.push(node);
}
node.castShadow = true;
node.receiveShadow = true;
});
이렇게 해 두면 모델러가 오브젝트 이름만 규칙에 맞게 지어도 코드를 수정하지 않고 상호작용을 붙일 수 있습니다. 디자이너와 개발자가 같은 사람일 때 특히 편한 방식이었습니다. 가게를 하나 더 만들고 이름을 15_Flower라고 붙이면 말풍선이 자동으로 따라 올라옵니다.
말풍선은 항상 정면을 봅니다
말풍선은 3D 오브젝트가 아니라 평면(plane)에 이미지를 붙여 만든 것입니다. 그대로 두면 카메라가 회전할 때 말풍선도 옆으로 돌아가 어느 순간 선처럼 보입니다. 그래서 매 프레임 말풍선의 회전을 카메라의 회전과 맞춰 줍니다. 흔히 빌보드(Billboard)라고 부르는 기법입니다.
// 카메라가 어디로 돌든 말풍선은 정면을 향한다
container.addEventListener("render", () => {
plane.quaternion.copy(camera.quaternion);
});
높이는 건물마다 다르게 잡았습니다. 3층 건물과 단층 상가 위에 같은 높이로 말풍선을 띄우면 하나는 지붕에 파묻히고, 다른 하나는 하늘 높이 떠 버립니다. 낮은 건물 목록을 따로 두고 높이를 24와 36으로 나눴습니다. 자동으로 계산하는 방법도 있었지만, 하루짜리 일정에서는 목록 한 줄 추가하는 편이 훨씬 빨랐습니다.
누르면 사라지는 맛
클릭 판정은 three.interactive로 붙이고, 눌렀을 때의 연출은 트윈(Tween)으로 처리했습니다. 말풍선의 투명도를 0으로 보내면서 동시에 위로 띄웁니다.
plane.addEventListener("click", (event) => {
new TWEEN.Tween(plane.material, false)
.to({ opacity: 0 }, 500)
.start();
new TWEEN.Tween(plane.position, false)
.to({ y: 60 }, 500)
.easing(TWEEN.Easing.Back.In)
.start();
});
Back.In을 고른 이유는 하나였습니다. 그냥 위로 올라가면 단순히 사라지는 것처럼 보이지만, 아래로 살짝 눌렸다가 튀어 오르면 무언가가 "터진" 것처럼 느껴집니다. 0.5초짜리 연출에서도 이 차이가 생각보다 큽니다.
만 하루가 남긴 것: 주석 처리된 카드
해커톤 마지막에 잘라낸 기능도 있습니다. 마을을 처음 열었을 때 사장님이 말을 걸어 주는 대화 카드였습니다. index.html에는 지금도 주석으로 남아 있습니다.
<!-- <div class="banner show">
<div id="card">
<span><img src="./assets/community Icon source_230331-07 1.png" /></span>
<h2>건너집 김사장님</h2>
<p id="dialog">
매일 매출은 얼마나 발생하는지, 비용은 얼마나 나가는지 신경쓰지
않아도 되서 너무 편해요!
</p>
<button type="button" id="btn">김사장님 따라하기</button>
</div>
</div> -->
카메라를 특정 가게로 부드럽게 이동시키는 panning() 함수도 절반쯤 구현한 채 함께 주석 처리했습니다. 시간이 없었습니다.
지금 다시 보면 이 부분이 가장 아쉽습니다. 말풍선을 눌러 없애는 것과, 누군가의 부탁을 받고 코인을 모으는 것은 조작 자체는 같습니다. 화면에서 벌어지는 일도 비슷합니다. 그런데 남는 것은 완전히 다릅니다. 앞의 것은 몇 번 만지작거리다 끝나는 데모지만, 뒤의 것은 시작과 끝이 있는 이야기가 됩니다. 그 차이를 만드는 것이 카드 한 장이었는데, 당시에는 그 한 장을 붙이지 못한 채 발표했습니다.
이번에 글을 쓰면서 그 자리를 다시 이어 붙였습니다. 위에서 '3D로 열기'를 누르면 김사장님이 먼저 부탁을 하고, 마을의 코인을 모두 모으면 마지막 인사를 합니다. 그때 잘라냈던 것을 이제야 되돌린 셈입니다.
예뻤지만 뜨거웠습니다
발표는 잘 끝났지만 시연을 노트북으로 한 데에는 이유가 있었습니다. 폰에서 열면 몇 분 만에 뜨거워졌습니다.
당시에는 WebGL로 3D 화면을 그리는 것 자체가 무거운 일이라고 막연히 생각했습니다. 하지만 시간이 지나 코드를 다시 열어 보니 원인은 훨씬 구체적이었습니다. WebGL은 GPU를 이용해 래스터라이징, 셰이딩, 그림자 맵 생성 같은 렌더링 작업을 처리합니다. 문제는 3D라는 사실 자체보다 각 작업에 얼마만큼의 자원을 쓰도록 설정했느냐에 있었습니다.
그림자 맵 한 장이 1억 텍셀이었습니다
sunLight.shadow.mapSize.width = 1024 * 10;
sunLight.shadow.mapSize.height = 1024 * 10;
1024 * 10, 즉 10240 × 10240입니다. 텍셀로 계산하면 약 1억 개입니다. 그림자를 선명하게 만들고 싶어서 숫자를 계속 올리다가 그대로 굳어 버린 값이었습니다.
게다가 이 그림자 카메라는 반경 1000에 가까운 영역을 덮고 있었습니다. 정작 마을의 크기는 약 374 × 180이었습니다. 즉 텍셀의 상당 부분을 아무것도 없는 영역에 사용하고 있었던 셈입니다. 그림자 카메라의 범위를 실제 마을 크기에 맞춘다면 그림자 맵을 2048 정도로 줄여도 오히려 필요한 영역의 텍셀 밀도는 높일 수 있습니다.
그리고 매 프레임 다시 굽고 있었습니다
여기서 한 가지가 더 보였습니다. 해도 움직이지 않고 마을도 움직이지 않습니다. 카메라가 회전하더라도 광원과 그림자를 만드는 오브젝트의 관계가 변하지 않는다면 그림자 맵 자체는 달라질 필요가 없습니다.
그런데 기본적인 실시간 그림자 설정에서는 이 그림자 맵을 계속 갱신할 수 있습니다. 정적인 장면이라면 한 번 생성한 뒤 필요할 때만 다시 갱신하도록 바꿀 수 있는 부분입니다.
draw call 수백 번 중 실제로 움직이는 것은 열몇 개였습니다
glTF에는 696개의 프리미티브가 있었습니다. 별도의 배칭이나 병합이 없다면 메인 렌더링만으로도 매 프레임 수백 번의 draw call이 발생할 수 있습니다. 여기에 그림자를 만드는 오브젝트는 shadow pass에서도 다시 그려집니다.
그런데 장면에서 실제로 움직이는 것은 회전하는 로고 몇 개와 말풍선 열세 개 정도뿐이었습니다. 나머지 도로, 건물, 나무, 벤치는 모두 제자리에 붙박여 있습니다.
정적인 오브젝트들을 머티리얼과 렌더링 조건에 맞춰 병합하거나 배칭하면 draw call을 크게 줄일 여지가 있습니다. 이 모델의 머티리얼은 38개뿐이었기 때문에, 움직이는 캐릭터나 로고처럼 별도 처리가 필요한 오브젝트를 제외하면 처음의 수백 회보다 훨씬 작은 단위로 줄일 수 있었습니다.
그림보다 텍스처가 무거웠습니다
5.07MB짜리 glb를 열어 보니 그중 1.72MB가 내장 텍스처였습니다. 그중 한 장은 3840 × 2181 크기의 PNG였습니다. 하지만 실제 화면에서 그 텍스처가 차지하는 영역은 아무리 커도 수백 픽셀 정도였습니다. 이를 1024 수준으로 축소하고 WebP로 변환하니 같은 화면에서 약 77KB까지 줄일 수 있었습니다.
결국 제가 겪었던 발열은 단순히 "3D라서 무겁다"는 문제가 아니었습니다. 필요 이상으로 크게 잡아 둔 값들이 하나씩 쌓여 생긴 문제였고, 각각은 이유를 따져 보면 줄일 수 있는 것들이었습니다. 이 사실을 아는 것과 모르는 것의 차이가 꽤 컸습니다.
그래서, 경쟁력에 대해
저는 개발자가 되기 전 산업 디자이너로 일하며 가상 시각화(Virtual Visualization)와 공차를 고려한 제품 모델링을 경험했습니다. 그래서 메시 기반의 3D 모델링도 익숙해지는 데 아주 오랜 시간이 필요하지는 않았습니다.
개발자로 전향한 뒤에도 늘 고민하는 것이 있습니다. 디자이너로서의 경험을 개발 커리어에 어떻게 접목해 시너지를 만들 것인가. WebGL 역시 언젠가 저만의 경쟁력을 만드는 씨앗 중 하나가 될 수 있다고 생각하고 있습니다.
이번 프로젝트에서 그 접목이 실제로 일어난 지점은 두 곳이었습니다.
하나는 모델을 직접 만들 수 있었다는 것입니다. 꼬박 하루라는 시간 안에 모델링을 외주로 돌리거나 다른 사람에게 부탁할 여유는 없었습니다. 에셋을 수정하고 필요한 모델을 만들고 텍스처까지 직접 입힐 수 있었기 때문에, 머릿속에서 기획한 그림을 그대로 화면까지 가져갈 수 있었습니다.
다른 하나는 모델과 코드 사이의 규약까지 직접 정할 수 있었다는 것입니다. 오브젝트 이름 규칙으로 상호작용을 연결하는 방식은 모델을 만드는 사람과 코드를 작성하는 사람이 서로의 사정을 이해해야 나오기 쉬운 설계입니다. 그 두 사정을 한 사람이 모두 알고 있다면 회의 한 번 없이 바로 결정할 수 있습니다.
저뿐만 아니라 많은 동료와 개발자 지인들도 자신만의 독보적인 강점을 어디에서 찾고, 어떻게 발전시켜야 할지 고민합니다. 자신만의 확고한 길을 걷고 싶은 사람이라면 결국 한 번쯤 마주하게 되는 숙제가 아닐까 생각합니다.
요즘 제가 생각하는 경쟁력은 남들보다 잘하는 한 가지를 갖는 것과는 조금 다릅니다. 경쟁력이란 남들이 따로 배워야 하는 두 가지를 한 사람 안에서 붙여 쓰는 일에 가깝습니다. 디자인과 개발이 제게는 그렇습니다.

사내 해커톤 1등했던 시절

