Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Interactive/3D/EN

3D Scroll Interaction Recruiting Landing Site

3D 스크롤 인터랙션 기반 채용 랜딩 사이트

서버 없이 스크롤 하나로 채용 환경을 만지게 한 페이지

물건이 아니라 환경을 파는 일

채용 랜딩페이지를 만드는 일이었습니다. 이 페이지가 내세워야 할 것은 물건이 아니라 환경이었어요. 작업 공간, 장비 세팅, 지원 인력, 외부 상담 같은 것들이죠. 이걸 목록으로 적어두면 CardList.jsx에 들어 있는 열한 줄짜리 표가 됩니다.

서버도 DB도 없는 정적 사이트입니다. 지원서는 백엔드 없이 접수해야 했고, 인스타그램과 네이버 블로그에 올라간 소식은 사이트 안에서도 최신 상태로 보여야 했습니다.

이 글에서는 스크롤 축 하나로 3D와 DOM을 같이 굴린 이야기와 타일 서른일곱 개를 직접 적분한 이야기만 다룹니다. 지원 폼을 EmailJS로 넘긴 선택과 SNS 피드를 합치는 구간, 화면을 눌러야 스크롤이 풀리는 잠금 게이트는 각각 따로 다룰 가치가 있는 이야기라 이 글에서는 접습니다.

시스템 경계와 외부 의존
시스템 경계와 외부 의존

3D와 DOM을 한 축에 묶기

drei ScrollControls 한 축 위에 아홉 구간을 얹고, 구간 길이를 페이지 수로 상수화

3D 씬과 DOM 오버레이가 같은 스크롤을 공유해야 했습니다. constants/scroll.js에 구간마다 몇 페이지를 쓸지만 적으면 전체 페이지 수(데스크톱 17)와 각 구간의 0~1 범위가 자동으로 계산됩니다. 어느 컴포넌트에서든 SCROLL_SECTIONS.section5.start 하나로 자기 구간을 압니다. 모바일은 카드 뒤집기 구간이 손가락 스크롤로 너무 빨리 지나가서 section5만 3페이지에서 6페이지로 늘렸습니다. 이 판정이 모듈 로드 시점의 navigator.userAgent로 한 번 굳는 게 두고두고 아쉬운 지점입니다.

Canvas를 2장으로 쪼개고, DPR은 프레임레이트를 보고 1.0~1.5 사이에서 흔들기

배경 그라디언트는 항상 떠 있어야 하는데 톤매핑과 DPR 정책이 본 씬과 다릅니다. 한 캔버스에 우겨넣으면 배경 평면이 씬의 레이어 마스크에 계속 끌려다녀요. App.jsx에서 zIndex: 0 배경 캔버스와 zIndex: 1 메인 캔버스를 나누고, 메인 쪽에만 PerformanceMonitor를 걸었습니다. WebGL 컨텍스트를 2개 쓰는 대가는 있습니다. 대신 배경은 평면 하나에 프래그먼트 셰이더뿐이라 비용이 거의 없어요. MeshTransmissionMaterial은 굴절마다 씬을 다시 그리므로 모바일에서는 samples 8→1, resolution 128→16으로 낮췄습니다. 유리 질감을 포기할 수 없어서 화질을 포기한 셈입니다.

S3 + CloudFront 정적 배포, 데이터가 필요한 두 곳만 Lambda

상시 서버를 만들지 않기로 했습니다. 다만 인스타그램 Graph API 토큰을 브라우저에 둘 수 없고 네이버 블로그 이미지는 핫링크가 막혀 있어서, 게시물 조회와 이미지 프록시만 API Gateway 뒤 Lambda 두 개로 떼어냈습니다. 나머지는 전부 정적 파일입니다. GitHub Actions가 feature/deploy push에만 반응하고, 배포마다 --delete로 버킷을 통째로 맞춘 뒤 /*를 인밸리데이트합니다. 잦은 배포에는 어울리지 않는 구조지만, 배포 주체가 한 명이라 이 단순함이 더 나았습니다.

배포와 인프라 구성
배포와 인프라 구성

진행도 하나로 도는 아홉 구간

이 사이트에는 라우트가 사실상 하나입니다. / 아래에 아홉 구간이 세로로 이어져 있고, 모든 화면이 스크롤 오프셋이라는 숫자 하나에서 파생됩니다. 흐름은 이렇습니다. ScrollControls가 만든 스크롤 컨테이너를 ScrollHandler가 매 프레임 읽어 zustand의 scrollStore에 넣습니다. 캔버스 안쪽 컴포넌트들은 useFrame에서 자기 구간의 진행도를 계산해 visible을 켜고 끄고 위치를 보간하고, 캔버스 바깥의 DOM 오버레이(지원 폼, SNS 피드)는 같은 값으로 y와 opacity, 심지어 borderRadius까지 계산합니다. 3D와 DOM이 같은 눈금자를 보고 있으니 둘이 어긋날 일이 없습니다.

핵심 데이터 모델
핵심 데이터 모델

물리 엔진을 걷어내고 타일 서른일곱 개를 직접 적분했습니다

구 표면에 뿌린 타일이 마우스에 밀렸다가 제자리로 돌아오고, 길게 누르면 퍼졌다가 터져야 합니다. 물리처럼 보이니 물리 엔진을 붙이는 게 자연스러운 선택이죠. 실제로 package.json에는 @react-three/rapier와 @react-three/cannon이 지금도 남아 있습니다(코드에서는 한 번도 import되지 않습니다). 그런데 강체 엔진은 충돌을 잘 풀지, "원래 자세로 정확히 되돌아오기"를 잘 풀지 않습니다. 타일마다 RigidBody를 만들면 매 프레임 목표 쿼터니언으로 끌어당기는 힘을 손으로 넣게 되고, 결국 엔진을 쓰면서 엔진과 싸우게 됩니다.

엔진을 지우고 useInstancedTilePhysics에 필요한 항만 남겼습니다. 타일은 InstancedMesh 하나이고 상태는 위치·쿼터니언·속도·각속도뿐입니다. 매 프레임 (1) 마우스 반발, (2) 홀드 중 확산, (3) 폭발, (4) 원래 자세로 돌아가는 복원력 중 하나만 적용하고 감쇠를 건 뒤 행렬을 씁니다. 임시 벡터 아홉 개는 useMemo로 한 번만 만들어 돌려쓰고, 속도와 각속도가 모두 1e-6 아래이면서 마우스 영향도 폭발 상태도 아닌 타일은 루프에서 아예 건너뜁니다. 한 개도 움직이지 않은 프레임에서는 instanceMatrix.needsUpdate조차 세우지 않습니다.

연출에 필요한 건 충돌 해석이 아니라 밀림과 복원, 두 힘의 합이었습니다. 그것만 적으면 코드가 짧아지는 대신 복원 목표를 원본 타일의 위치와 쿼터니언으로 직접 지정할 수 있어서, 손이 떠나면 정확히 구 표면으로 되돌아옵니다. 화면이 멈춰 있을 때 루프가 사실상 공짜인 것도 큽니다. 이 구간은 홀드 게이트 때문에 사람이 몇 초씩 가만히 바라보는 구간이거든요.

스티커를 종이 위에 얹지 않고 종이 텍스처 안에 그려 넣기

클릭 지점에 평면 메시나 Decal을 얹는 방식.

종이 텍스처를 CanvasTexture로 바꿔 클릭 교차점의 uv 위치에 스티커를 직접 그렸습니다.

UV 공간에 구워지니 정점이 출렁여도 스티커가 함께 움직이고 메시는 하나로 유지됩니다.

유저가 할 수 있는 일

화면을 2.5초 누르고 있어야 스크롤이 열린다
화면을 2.5초 누르고 있어야 스크롤이 열린다

주소창 해시로 원하는 구간에 바로 떨어진다
주소창 해시로 원하는 구간에 바로 떨어진다

사이드 메뉴에서 아홉 구간 중 하나로 건너뛴다
사이드 메뉴에서 아홉 구간 중 하나로 건너뛴다

종이비행기를 눌러 플랫폼 로고 스티커를 찍는다
종이비행기를 눌러 플랫폼 로고 스티커를 찍는다

WE ARE, MISSION, PRINCIPLE을 차례로 읽는다
WE ARE, MISSION, PRINCIPLE을 차례로 읽는다

사업영역 카드 네 장을 펼치고 뒤집어 본다
사업영역 카드 네 장을 펼치고 뒤집어 본다

지원 혜택 카드 열한 장을 가로로 훑는다
지원 혜택 카드 열한 장을 가로로 훑는다

지원 폼을 채워 크리에이터 지원서를 보낸다
지원 폼을 채워 크리에이터 지원서를 보낸다

인스타그램과 블로그 소식을 한 줄에서 넘겨 본다
인스타그램과 블로그 소식을 한 줄에서 넘겨 본다

약도에서 지하철역부터 사무실까지 길을 확인한다
약도에서 지하철역부터 사무실까지 길을 확인한다

배경 음악을 켜고 끈다
배경 음악을 켜고 끈다

개발용 화면에서 GLB의 머티리얼과 조명을 확인한다
개발용 화면에서 GLB의 머티리얼과 조명을 확인한다

1 / 1

프레임마다 상태를 쓴 대가

가장 아쉬운 건 스크롤 값을 매 프레임 zustand에 밀어 넣은 것입니다. ScrollHandler가 useFrame마다 setScroll을 부르고, 그 값을 구독하는 DOM 오버레이들이 초당 예순 번 리렌더됩니다. 오버레이가 몇 개 안 되어 지금은 버티지만, 다시 만든다면 연속 값은 motion value나 ref로 흘리고 zustand에는 구간 진입·이탈 같은 이산 이벤트만 남기겠습니다.

SCROLL_SECTIONS가 모듈 로드 시점의 navigator.userAgent로 한 번 계산되고 끝나는 것도 문제입니다. 창을 좁히거나 태블릿을 돌려도 구간 길이는 그대로예요. 반응형을 제대로 하려면 이 표는 상수가 아니라 상태여야 합니다.

에셋은 솔직히 방치돼 있습니다. public/에 4k HDR과 GLB 열일곱 개가 그대로 올라가 있고 쓰이지 않는 것도 섞여 있습니다. InstancedTiles는 존재하지 않는 /images/studio_small_03_1k.hdr를 넘기면서 preset: 'city'도 함께 주고 있어서, 실제로 받아오는 건 drei의 원격 프리셋입니다. 로딩 화면에 최소 2초 지연과 10초 강제 완료 타임아웃을 박아둔 건 이 무게를 가리려는 임시방편이었어요.

테스트는 한 줄도 없습니다. 특히 인스타그램 글과 블로그 글의 중복 판정이 "세 글자 이상 연속 일치"라는 규칙 하나로 돌아가는데, 흔한 단어가 겹치면 멀쩡한 게시물이 조용히 사라집니다. Lambda 응답 스키마가 바뀌어도 알아차릴 방법이 화면을 열어보는 것뿐이고요. 계약 테스트 한 세트와 중복 판정 단위 테스트 몇 개만 있었어도 "어제 올린 글이 왜 안 보이냐"는 질문은 줄었을 겁니다.

Read next

내가 쓴 일기 문장에서 단어를 뽑는 암기 카드 앱

Seiai — 2025