먼저 JBLOG 홈 화면에 들어가면 명함 형태의 인터랙션 요소를 만날 수 있다.

갑자기 왜 만들었을까


사실 내가 명함 애니메이션을 만들게 된 계기는 그렇게 창의적이지는 않다.
DAN24 후기 글을 보다가, 우연히 한 블로그를 들어가게 되었고 거기서 명함으로 만든 정말 재밌는 인터랙션 요소를 보았다.
이 링크에서 확인할 수 있다 ➡️  jgjgill-blogjgjgill-blogjgjgill - Nameplate
 
이걸 어떻게 구현했을까 참 고민을 많이 했다.
이걸 내 블로그에 그대로 구현하고 싶었다.
이 블로그 주인의 github를 들어가서 코드를 보려고 했으나 중요한 기술인지 npm 라이브러리로 따로 만들어서 install해서 사용하더라.
 
내 여자친구랑 불멍이 아니라 명함멍이라는 것을 할 정도로,
이 인터랙션은 너무 재미있었고 인터랙션 개발자는 아니지만 어떻게든 구현해보고 싶었다.
 
구현 전에 주의했던 점은,
내 블로그는 SEO를 위해 SSR 목적으로 Next.js를 만들었다.
그래서 최대한 작은 컴포넌트 단위에서 “use client” 쓰려고 노력했다.
 

Three.js


이 명함 구현의 포인트는,
무엇보다 물리 엔진이 중요해보였고 특히 카드가 회전 함에 따라 햇살이 반사되는 정도가 달라지는 점이었다.
 
나도 Framer Motion이나 다른 여러 animation js 라이브러리들을 사용해보았다.
하지만 Three.js가 내가 원하는 포인트들을 잘 구현할 수 있길래 Three.js를 사용하였다.
물론 GPT와 함께 말이다. 하지만 GPT도 이런 애니메이션 특히 3D 영역은 잘 도와주지 못하는 사실을 발견했다.
그럼에도 최대한 흉내낼 정도는 구현을 해보았다.

코드


코드를 보며 하나하나 분석해보자.

전체 코드

"use client"; import React, { Suspense, useRef } from "react"; import { Canvas, useFrame } from "@react-three/fiber"; import { Text, useTexture, Environment, RoundedBox } from "@react-three/drei"; import * as THREE from "three"; function IDBadge() { const groupRef = useRef<THREE.Group>(null!); const profileTexture = useTexture("/images/me.jpg"); // 애니메이션(살짝 흔들 + 회전) useFrame((state) => { const t = state.clock.getElapsedTime(); groupRef.current.rotation.z = 0.1 * Math.sin(t * 2); groupRef.current.rotation.y = 0.3 * Math.sin(t * 0.7); }); return ( <group ref={groupRef} position={[0, 2, 0]}> {/* 줄(밴드) */} <mesh position={[0, -1, -0.01]}> <boxGeometry args={[0.15, 2, 0.01]} /> <meshBasicMaterial color="black" /> </mesh> {/* 명찰(카드) - RoundedBox로 모서리를 둥글게 */} <group position={[0, -2.2, 0]}> <RoundedBox args={[1.0, 1.4, 0.03]} radius={0.03} smoothness={0.9}> <meshPhysicalMaterial color="#000" // 검정 metalness={0.5} // 금속성 roughness={1} // 거칠기 낮춰 반사 clearcoat={0.5} clearcoatRoughness={0.4} reflectivity={0.3} /> </RoundedBox> </group> {/* 프로필 사진(원형) */} <mesh position={[0, -2.0, 0.021]}> <circleGeometry args={[0.3, 32]} /> <meshBasicMaterial map={profileTexture} /> </mesh> {/* 텍스트 */} <NameText text="Jun Beom" position={[0, -2.45, 0.021]} fontSize={0.12} /> <NameText text="frontend developer" position={[0, -2.65, 0.021]} fontSize={0.06} /> </group> ); } interface NameTextProps { text: string; position: [number, number, number]; fontSize?: number; } function NameText({ text, position, fontSize = 0.1 }: NameTextProps) { return ( <Text position={position} fontSize={fontSize} color="white" anchorX="center" anchorY="middle" > {text} </Text> ); } export default function ThreeDIdBadge() { return ( <Canvas gl={{ alpha: true }} camera={{ position: [0, 0, 3], fov: 50 }} style={{ width: "100%", height: "100%", background: "transparent" }} > <Suspense fallback={null}> {/* 환경 맵(반사) */} <Environment preset="city" background={false} environmentIntensity={2} /> {/* 광원 (너무 세지 않게) */} <directionalLight intensity={3} position={[-4, 1, 4]} /> <directionalLight intensity={2} position={[4, -2, 5]} /> <IDBadge /> </Suspense> </Canvas> ); }

ThreeDIdBadge: 3D 씬 전체 구성자

export default function ThreeDIdBadge() { return ( <Canvas camera={...}> <Suspense fallback={null}> <Environment /> <directionalLight /> <IDBadge /> </Suspense> </Canvas> ); }
  • WebGL 렌더링 영역을 구성하는 최상위 컴포넌트
  • Canvas: 3D 렌더링 컨텍스트 생성
  • camera: 3D 공간을 보는 시점 설정 (정면에서 약간 떨어진 거리)
  • Suspense: 리소스(텍스처, 모델 등) 로딩 중 대기 처리
  • Environment: 배경 환경광 설정 (city preset → 반사와 조명 느낌 향상)
  • directionalLight: 씬에 조명 추가
  • IDBadge: 3D 명함 실제 구성 요소
 
이렇게 구조를 본 후에 각 컴포넌트의 코드를 보면 직관적이라 잘 이해가 될 것이다.
 

어려웠던 점


Typescript

Three.js와 관련된 변수들을 typescript로 작성하다보니 수많은 type 오류를 겪었다.
그냥 Javascript를 써도 되지 않았을까?
하지만 앞으로 외부 라이브러리를 사용할텐데 그 때마다 Typescript를 포기하는 것은 좋은 방법이 아니었고,
변수들을 ctrl + 클릭으로 하나씩 타고 타고 들어가면서 props의 type을 하나씩 찾아갔다.
 

광원

광원의 위치를 잡는 것이 굉장히 어려웠다.
내가 Three.js를 잘 모를 수도 있겠지만 내가 원하는 방향대로 이동하지도, 빛을 쏘지도 않았다.
광원의 종류도 여러가지였는데 어떤 차이점들이 있는지는 내가 크게 구별하지는 못하겠다.
그리고 햇빛이라는게 사실 멀리서 직선의 방향으로 오는 것이 맞지 않나?
근데 명함 가까이에 두어야 이쁘게 반사되는 점을 알게 되었다.
 

useRef

그리고 React 내부에 생성된 Three.js 객체에 접근하기 위해 useRef를 사용하였다.
React의 state를 변화시키면 항상 렌더링이 유발된다. 그러나 ref는 렌더링 없이 값에 접근할 수 있다.
 
내 명함은 롯데월드의 자이로스윙처럼 각도 옆으로, 앞뒤로 계속 바뀌는데
그 때마다 각도를 useState로 바꿔준다면 거의 무한렌더링이 일어나지 않을까 싶다.
 

reflow

나는 이전 글에서 reflow에 대해서 공부했다.
마침 내 블로그에서 명함 초기 로딩이 느려서 이것을 최적화할 수 있지 않을까 생각해보았다.
 
그래서 reflow를 개선해보려고 했으나…
three.js는 기본적으로 DOM과는 분리된 WebGL 렌더링 환경이라고 한다.
<Canvas> 내부는 <div> 기반 DOM이 아니라 WebGL 캔버스로 작동한다.
three.js는 GPU를 사용하기 때문에 reflow가 거의 일어나지 않는다고 본다.
 

느린 초기 로딩

Environment에 무거운 hdr 로드 때문에 초기 로딩이 느렸다.
HDRI 환경맵 대신, light 조합만으로도 표현할 수 있었다.
 
그리고 아래 코드와 같이 쉐이더 머티리얼에서도 직접 반사를 흉내낼 수 있었다.
<meshStandardMaterial metalness={0.3} roughness={0.4} color="#222" emissive="#111" emissiveIntensity={0.1} />
 

마무리


처음에는 내 명함도 빛 반사가 잘 구현되어서 그런지 정말 그럴싸했다.
흔히 있어벌리티가 넘쳤다.
그러나 내 명함은 사용자와의 인터랙션 요소는 부족하다 생각한다.
다음에는 three.js보다는 좀 더 가벼운 요소를 선택하여 물리 엔진을 적용해봐야겠다.