Skip to main content
민트라

인터랙티브 3D 명함 만들기 - 사이드 프로젝트 기록

네트워킹 행사에서 종이 명함을 주고받을 때마다 생각했습니다. "개발자라면서 왜 종이 명함이지?" 그래서 주말을 투자해서 3D 명함을 만들었습니다.

컨셉 #

기존 명함의 문제점:

3D 명함의 목표:

기획 #

명함을 QR로 스캔하면 3D 씬이 열리고, 제 정보가 인터랙티브하게 표시됩니다.

구성 요소:

  1. 3D 카드 모델 (실제 명함처럼)
  2. 회전/줌 인터랙션
  3. 클릭하면 상세 정보 표시
  4. 연락처 저장 버튼

개발 #

명함 모델 #

실제 명함 비율(90mm × 50mm)로 만들었습니다.

const cardGeometry = new THREE.BoxGeometry(9, 5, 0.05);

// 앞면 텍스처
const frontCanvas = document.createElement('canvas');
const frontCtx = frontCanvas.getContext('2d');
frontCanvas.width = 900;
frontCanvas.height = 500;

// 배경
frontCtx.fillStyle = '#1a1a2e';
frontCtx.fillRect(0, 0, 900, 500);

// 이름
frontCtx.font = 'bold 48px Pretendard';
frontCtx.fillStyle = '#ffffff';
frontCtx.fillText('홍길동', 50, 200);

// 직함
frontCtx.font = '24px Pretendard';
frontCtx.fillStyle = '#888888';
frontCtx.fillText('Frontend Developer', 50, 250);

const frontTexture = new THREE.CanvasTexture(frontCanvas);

인터랙션 #

마우스/터치로 카드를 회전시킬 수 있습니다.

let isDragging = false;
let previousPosition = { x: 0, y: 0 };

canvas.addEventListener('pointerdown', (e) => {
  isDragging = true;
  previousPosition = { x: e.clientX, y: e.clientY };
});

canvas.addEventListener('pointermove', (e) => {
  if (!isDragging) return;

  const deltaX = e.clientX - previousPosition.x;
  const deltaY = e.clientY - previousPosition.y;

  card.rotation.y += deltaX * 0.01;
  card.rotation.x += deltaY * 0.01;

  previousPosition = { x: e.clientX, y: e.clientY };
});

canvas.addEventListener('pointerup', () => {
  isDragging = false;
});

뒤집기 애니메이션 #

더블클릭하면 카드가 뒤집힙니다.

let isFlipped = false;

canvas.addEventListener('dblclick', () => {
  gsap.to(card.rotation, {
    y: isFlipped ? 0 : Math.PI,
    duration: 0.6,
    ease: 'power2.inOut'
  });
  isFlipped = !isFlipped;
});

뒷면 정보 #

뒷면에는 QR 코드와 연락처를 넣었습니다.

const backCanvas = document.createElement('canvas');
const backCtx = backCanvas.getContext('2d');

// QR 코드 생성 (qrcode 라이브러리 사용)
const qr = await QRCode.toCanvas(contactVCard, { width: 200 });
backCtx.drawImage(qr, 350, 150);

// 소셜 아이콘들
backCtx.fillText('github.com/username', 50, 400);
backCtx.fillText('linkedin.com/in/username', 50, 440);

연락처 저장 (vCard) #

연락처 저장 버튼을 누르면 vCard 파일이 다운로드됩니다.

function downloadVCard() {
  const vcard = `BEGIN:VCARD
VERSION:3.0
FN:홍길동
ORG:회사명
TITLE:Frontend Developer
TEL:010-1234-5678
EMAIL:[email protected]
URL:https://portfolio.com
END:VCARD`;

  const blob = new Blob([vcard], { type: 'text/vcard' });
  const url = URL.createObjectURL(blob);

  const link = document.createElement('a');
  link.href = url;
  link.download = 'contact.vcf';
  link.click();

  URL.revokeObjectURL(url);
}

시각 효과 #

홀로그램 효과 #

명함에 홀로그램 느낌을 주고 싶었습니다.

const hologramMaterial = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0 },
    uTexture: { value: frontTexture }
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform float uTime;
    uniform sampler2D uTexture;
    varying vec2 vUv;

    void main() {
      vec4 tex = texture2D(uTexture, vUv);

      // 무지개빛 효과
      float rainbow = sin(vUv.x * 10.0 + uTime) * 0.5 + 0.5;
      vec3 hologram = vec3(
        sin(rainbow * 6.28) * 0.5 + 0.5,
        sin(rainbow * 6.28 + 2.09) * 0.5 + 0.5,
        sin(rainbow * 6.28 + 4.18) * 0.5 + 0.5
      );

      // 텍스처와 혼합
      vec3 color = mix(tex.rgb, hologram, 0.1);
      gl_FragColor = vec4(color, 1.0);
    }
  `
});

파티클 배경 #

배경에 파티클을 띄워서 분위기를 냈습니다.

const particleCount = 500;
const positions = new Float32Array(particleCount * 3);

for (let i = 0; i < particleCount; i++) {
  positions[i * 3] = (Math.random() - 0.5) * 20;
  positions[i * 3 + 1] = (Math.random() - 0.5) * 20;
  positions[i * 3 + 2] = (Math.random() - 0.5) * 20;
}

const particleGeometry = new THREE.BufferGeometry();
particleGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

const particleMaterial = new THREE.PointsMaterial({
  size: 0.05,
  color: 0x6c5ce7,
  transparent: true,
  opacity: 0.6
});

const particles = new THREE.Points(particleGeometry, particleMaterial);
scene.add(particles);

모바일 최적화 #

명함은 주로 폰으로 볼 거라서 모바일 최적화가 중요했습니다.

터치 제스처 #

핀치 줌, 두 손가락 회전을 구현했습니다.

let initialDistance = 0;
let initialRotation = 0;

canvas.addEventListener('touchstart', (e) => {
  if (e.touches.length === 2) {
    initialDistance = getDistance(e.touches[0], e.touches[1]);
    initialRotation = getAngle(e.touches[0], e.touches[1]);
  }
});

canvas.addEventListener('touchmove', (e) => {
  if (e.touches.length === 2) {
    // 줌
    const distance = getDistance(e.touches[0], e.touches[1]);
    const scale = distance / initialDistance;
    camera.position.z = 10 / scale;

    // 회전
    const rotation = getAngle(e.touches[0], e.touches[1]);
    card.rotation.z = rotation - initialRotation;
  }
});

성능 #

파티클 수를 줄이고, 셰이더를 단순화했습니다.

const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent);
const particleCount = isMobile ? 100 : 500;

배포 #

정적 호스팅 #

Vercel에 배포했습니다. 빌드 결과물이 2MB 이하라 무료 플랜으로 충분했습니다.

QR 코드 #

실제 종이 명함에 QR 코드를 인쇄했습니다. 스캔하면 3D 명함 페이지로 연결됩니다.

종이 명함 앞면:
- 이름, 직함
- QR 코드 (3D 명함 링크)

3D 명함:
- 인터랙티브 체험
- 연락처 저장
- 포트폴리오 링크

반응 #

네트워킹 행사에서 실제로 사용해봤습니다.

긍정적 반응:

의외의 문제:

개선 사항 #

실제 사용 후 개선한 것들:

  1. 프리로더 추가: 로딩 중에 스켈레톤 UI 표시
  2. 오프라인 지원: Service Worker로 캐싱
  3. 저전력 모드: 애니메이션 최소화 옵션
  4. 접근성: 3D 외에 일반 텍스트 버전 제공
// 저전력 모드 감지
const prefersReducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

if (prefersReducedMotion) {
  // 정적 이미지로 폴백
  showStaticVersion();
}

소스 코드 #

GitHub에 공개했습니다. Fork해서 자신만의 명함을 만들어보세요.

필요한 것:

주말 프로젝트로 시작했는데, 생각보다 반응이 좋아서 뿌듯합니다. 개발자답게, 코드로 자신을 소개하는 것도 좋은 방법인 것 같습니다.