[Web] 시네마틱 웹 프레젠테이션

Daemon·2026년 1월 25일

웹

목록 보기
1/3
post-thumbnail

들어가며: 왜 웹으로 프레젠테이션을 만들었나?

PowerPoint, Figma, Canva와 같은 좋은 도구가 있음에도 불구하고 비효율적인 웹을 택하는 것은 합리적인 생각은 아닙니다.

웹 개발이 재밌는 것도 있고 무엇보다도 모바일 네이티브에서 구현하기 힘든 애니메이션을 비교적 쉽게 구현할 수 있다는 것이 매력적이고 좋은 도구를 쓴다고 해도 완전히 커스텀 애니메이션을 만들기에는 코드로 작성하는 것이 더 낫다고 판단했습니다.

웹 기술로 만든 프레젠테이션은 vercel과 함께라면 공유하기에도 편하고 모바일 대응까지 할 수 있어서 마우스보다 키보드가 익숙한 개발자한테는 그리 나쁘지 않은 선택이었던 것 같습니다.

디자인적 영감은 Pinterest, X, Spline, Y Combinator에서 가져왔고, 첫 연사로 참여한 자리에서 제가 선정한 주제는 기술보다는 경험 공유에 가까웠기 때문에 시네마틱한 미학을 적용했으며 슬라이드 템플릿을 오픈소스로 만들어서 다른 사람들도 재미를 볼 수 있도록 만들었습니다.

깃허브


기술 스택 선택의 이유

React 18 + TypeScript

왜 React인가?

  • 컴포넌트 기반 아키텍처로 슬라이드 템플릿을 조합할 수 있음
  • Hooks로 상태 관리와 사이드 이펙트를 깔끔하게 처리
  • TypeScript로 프롭스 타입을 명확히 하면 슬라이드 확장이 용이함
// 슬라이드 템플릿의 기본 구조
interface IcebergSlideProps {
  title: string;
  surface: { label: string; items: string[] };
  underwater: { label: string; items: string[] };
  footer?: string;
}

export const IcebergSlide: React.FC<IcebergSlideProps> = (props) => {
  // 구현
};

Vite

왜 Vite인가?

  • 개발 속도: HMR이 초고속. Three.js 매개변수를 튜닝할 때마다 즉시 반영됨
  • 빌드 속도: esbuild 기반으로 번들링이 번개같음
  • ES Module 네이티브: 최신 JavaScript 문법을 그대로 사용 가능
npm run dev      # 개발 서버 (거의 즉시 시작)
npm run build    # 프로덕션 빌드 (2초 이내)

Framer Motion

왜 Framer Motion인가?

  • 선언적 애니메이션: CSS나 setInterval 없이 깔끔한 JSX로 애니메이션 정의
  • 레이아웃 애니메이션: 슬라이드 전환 시 요소가 부드럽게 움직임
  • Gesture 지원: 마우스, 터치 이벤트를 쉽게 처리 가능 (현재 버전은 키보드 중심)
const blurFadeVariants = {
  enter: { opacity: 0, filter: 'blur(20px)', scale: 1.05 },
  center: { opacity: 1, filter: 'blur(0px)', scale: 1 },
  exit: { opacity: 0, filter: 'blur(20px)', scale: 0.95 },
};

<motion.div
  variants={blurFadeVariants}
  initial="enter"
  animate="center"
  exit="exit"
  transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
>
  {/* 슬라이드 콘텐츠 */}
</motion.div>

Three.js + React Three Fiber + Drei

왜 3D 그래픽인가?

  • 웹 프레젠테이션을 정말 "시네마틱"하게 만들 수 있음
  • 시선을 사로잡는 시각적 임팩트
  • 웹으로 제작하는 이유이자 장점

React Three Fiber: Three.js를 React처럼 쓸 수 있게 해주는 라이브러리

  • JSX로 3D 씬을 구성: <mesh>, <material>, <light> 등이 컴포넌트처럼 작동
  • useFrame 훅으로 애니메이션 루프 관리

Drei: React Three Fiber 생태계의 유틸리티 라이브러리

  • MeshTransmissionMaterial: 유리처럼 보이는 물리 기반 재료
  • Environment: 반사와 조명을 한 줄로 설정
  • RoundedBox: 모서리가 둥근 박스 지오메트리

Perlin Noise 기반 3D 빙산

IcebergSlide는 빙산의 일각이라는 아이디어에서 출발했고 연사를 진행한다면 생각보다 사용할 일이 많을 것 같습니다. 물 위에 떠 있는 불규칙한 빙산을 Three.js로 렌더링하되, 물 위의 항목과 물 아래의 항목을 시각적으로 구분합니다.

Perlin Noise 알고리즘

Ken Perlin이 1983년에 개발한 Perlin Noise는 자연스러운 랜덤을 생성하는 알고리즘입니다.

특징:

  • 완전 랜덤이 아니라 이웃한 값들이 부드럽게 연결됨
  • 지형, 구름, 질감 생성에 최적
  • 여러 옥타브를 겹치면 더 복잡한 패턴 생성 가능

구현 코드:

const noise3D = (x: number, y: number, z: number): number => {
    const p = [151,160,137,91,90,15,...]; // 256개 Permutation Table
    const perm = [...p, ...p];

    const floor = Math.floor;
    const X = floor(x) & 255;
    const Y = floor(y) & 255;
    const Z = floor(z) & 255;

    // 소수 부분 추출
    x -= floor(x);
    y -= floor(y);
    z -= floor(z);

    // 페이딩 함수: 부드러운 보간
    const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);

    // 선형 보간
    const lerp = (a: number, b: number, t: number) => a + t * (b - a);

    // 그래디언트 함수
    const grad = (hash: number, x: number, y: number, z: number) => {
        const h = hash & 15;
        const u = h < 8 ? x : y;
        const v = h < 4 ? y : h === 12 || h === 14 ? x : z;
        return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
    };

    // 페이딩값 계산
    const u = fade(x);
    const v = fade(y);
    const w = fade(z);

    // 8개 모서리 해시 계산
    const A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z;
    const B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z;

    // 3차원 보간
    return lerp(
        lerp(
            lerp(grad(perm[AA], x, y, z), grad(perm[BA], x - 1, y, z), u),
            lerp(grad(perm[AB], x, y - 1, z), grad(perm[BB], x - 1, y - 1, z), u),
            v
        ),
        lerp(
            lerp(grad(perm[AA + 1], x, y, z - 1), grad(perm[BA + 1], x - 1, y, z - 1), u),
            lerp(grad(perm[AB + 1], x, y - 1, z - 1), grad(perm[BB + 1], x - 1, y - 1, z - 1), u),
            v
        ),
        w
    );
};

이 알고리즘을 왜 구현했나?

  • 외부 라이브러리 없이 번들 크기를 최소화
  • 기본 원리를 이해하고 최적화할 수 있음
  • Three.js 커스텀 쉐이더에 직접 포함 가능

멀티 옥타브 노이즈로 자연스러운 지형 생성

IcebergSlide에서는 Perlin Noise를 4개 옥타브로 중첩합니다:

// IcebergSlide.tsx에서 지오메트리 생성
const icebergGeometry = useMemo(() => {
    const geo = new THREE.IcosahedronGeometry(1.15, 5);
    const positions = geo.attributes.position;

    for (let i = 0; i < positions.count; i++) {
        let x = positions.getX(i);
        let y = positions.getY(i);
        let z = positions.getZ(i);

        // 4개 옥타브의 노이즈를 각각 다른 진폭으로 합침
        const noise1 = noise3D(x * 1.8, y * 1.8, z * 1.8) * 0.45;   // 큰 변형
        const noise2 = noise3D(x * 3.5 + 7, y * 3.5, z * 3.5 + 7) * 0.25;   // 중간 디테일
        const noise3 = noise3D(x * 7.0 + 15, y * 7.0, z * 7.0 + 15) * 0.12;  // 작은 울퉁불퉁
        const noise4 = noise3D(x * 12.0 + 30, y * 12.0, z * 12.0 + 30) * 0.06; // 극세한 표면

        const totalNoise = noise1 + noise2 + noise3 + noise4;

        // 불규칙한 모양을 위해 비대칭 노이즈 추가
        const asymmetricNoise = noise3D(x * 2.5 + 50, y * 1.5, z * 2.0 + 50) * 0.3;

        // 물 아래쪽을 더 크게 변형 (실제 빙산처럼)
        const yFactor = y < 0 ? 1.6 : 1.1;
        const displacement = totalNoise * yFactor;

        // 좌우 스케일 조정: 물 아래는 넓게, 위는 좁게
        const xScale = y < 0 ? 1.4 + Math.abs(y) * 0.5 + asymmetricNoise : 0.65 - y * 0.3;
        const zScale = y < 0 ? 1.3 + Math.abs(y) * 0.45 - asymmetricNoise * 0.5 : 0.6 - y * 0.25;

        // 최종 변형 적용
        x = x * xScale * (1 + displacement * 0.6);
        z = z * zScale * (1 + displacement * 0.5);
        y = y * 1.25 + displacement * 0.25;

        positions.setXYZ(i, x, y, z);
    }

    geo.computeVertexNormals();
    return geo;
}, []);

결과: 빙산은 울퉁불퉁한 다면체이기 때문에 다각형을 덧붙인다는 생각으로 만들면 됩니다.

Three.js 쉐이더 커스터마이징: 물 위와 아래 색상 구분

빙산의 색상이 Y 좌표에 따라 부드럽게 변하도록 쉐이더를 커스터마이징합니다:

<meshStandardMaterial
    color="#8dd3e8"
    onBeforeCompile={(shader) => {
        // 버텍스 쉐이더에 변수 추가
        shader.vertexShader = shader.vertexShader.replace(
            '#include <common>',
            `#include <common>
            varying float vY;
            varying vec3 vWorldPosition;`
        );

        // 월드 좌표 저장
        shader.vertexShader = shader.vertexShader.replace(
            '#include <begin_vertex>',
            `#include <begin_vertex>
            vY = position.y;
            vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;`
        );

        // 프래그먼트 쉐이더에서 높이에 따라 색상 결정
        shader.fragmentShader = shader.fragmentShader.replace(
            '#include <color_fragment>',
            `#include <color_fragment>
            float worldY = vWorldPosition.y;

            vec3 brightIce = vec3(0.7, 0.9, 0.97);        // 밝은 얼음
            vec3 glacialBlue = vec3(0.5, 0.78, 0.92);     // 빙하 파랑
            vec3 midIce = vec3(0.3, 0.6, 0.78);           // 중간 얼음
            vec3 underwaterCyan = vec3(0.12, 0.4, 0.55);  // 물 아래 청록색
            vec3 deepOcean = vec3(0.02, 0.12, 0.22);      // 깊은 바다

            float normalizedY = clamp((worldY + 1.5) / 3.0, 0.0, 1.0);

            vec3 iceColor;
            if (normalizedY > 0.58) {
                float t = smoothstep(0.58, 1.0, normalizedY);
                iceColor = mix(glacialBlue, brightIce, t);
            } else if (normalizedY > 0.48) {
                float t = smoothstep(0.48, 0.58, normalizedY);
                iceColor = mix(midIce, glacialBlue, t);
            } else if (normalizedY > 0.25) {
                float t = smoothstep(0.25, 0.48, normalizedY);
                iceColor = mix(underwaterCyan, midIce, t);
            } else {
                float t = smoothstep(0.0, 0.25, normalizedY);
                iceColor = mix(deepOcean, underwaterCyan, t);
            }

            diffuseColor.rgb = iceColor;`
        );
    }}
/>

실시간 물결 애니메이션

useFrame 훅을 사용해 매 프레임마다 water surface의 꼭짓점을 업데이트합니다:

useFrame((state) => {
    if (waterRef.current) {
        const positions = waterRef.current.geometry.attributes.position;
        const time = state.clock.elapsedTime;

        for (let i = 0; i < positions.count; i++) {
            const x = positions.getX(i);
            const z = positions.getZ(i);

            // 빙산 중심으로부터의 거리
            const distFromCenter = Math.sqrt(x * x + z * z);
            const centerFactor = Math.max(0, 1 - distFromCenter / 8);

            // 여러 주파수의 파도 중첩
            const wave1 = Math.sin(x * 0.5 + time * 0.7) * 0.15;
            const wave2 = Math.sin(z * 0.4 + time * 0.5) * 0.12;
            const wave3 = Math.sin((x + z) * 0.6 + time * 1.0) * 0.08;
            const wave4 = Math.sin(x * 1.2 - time * 1.5) * 0.05 * centerFactor;

            positions.setY(i, wave1 + wave2 + wave3 + wave4);
        }
        positions.needsUpdate = true;
    }
});

입자 시스템: 버블과 플로팅 파티클

물리 시뮬레이션 없이 간단한 루프로 자연스러운 움직임을 구현합니다:

const Bubbles: React.FC = () => {
    const bubblesRef = useRef<THREE.Points>(null);
    const bubbleCount = 60;

    const [positions, speeds, sizes] = useMemo(() => {
        const pos = new Float32Array(bubbleCount * 3);
        const spd = new Float32Array(bubbleCount);
        const siz = new Float32Array(bubbleCount);

        for (let i = 0; i < bubbleCount; i++) {
            // 빙산 주변에 무작위로 분포
            pos[i * 3] = (Math.random() - 0.5) * 6;
            pos[i * 3 + 1] = -Math.random() * 4 - 0.5; // 물 아래에서 시작
            pos[i * 3 + 2] = (Math.random() - 0.5) * 6;
            spd[i] = 0.2 + Math.random() * 0.4;  // 개별 상승 속도
            siz[i] = 0.03 + Math.random() * 0.06;
        }
        return [pos, spd, siz];
    }, []);

    useFrame((state) => {
        if (bubblesRef.current) {
            const positions = bubblesRef.current.geometry.attributes.position.array as Float32Array;
            for (let i = 0; i < bubbleCount; i++) {
                // 위로 상승하면서 좌우로 흔들림
                positions[i * 3 + 1] += speeds[i] * 0.015;
                positions[i * 3] += Math.sin(state.clock.elapsedTime * 2 + i) * 0.002;

                // 수면에 도달하면 리셋
                if (positions[i * 3 + 1] > -0.1) {
                    positions[i * 3 + 1] = -Math.random() * 3 - 1;
                    positions[i * 3] = (Math.random() - 0.5) * 5;
                    positions[i * 3 + 2] = (Math.random() - 0.5) * 5;
                }
            }
            bubblesRef.current.geometry.attributes.position.needsUpdate = true;
        }
    });

    return (
        <points ref={bubblesRef}>
            <bufferGeometry>
                <bufferAttribute
                    attach="attributes-position"
                    count={bubbleCount}
                    array={positions}
                    itemSize={3}
                />
                {/* ... size attribute ... */}
            </bufferGeometry>
            <pointsMaterial
                size={0.08}
                color="#8ee8ff"
                transparent
                opacity={0.6}
                sizeAttenuation
                depthWrite={false}
            />
        </points>
    );
};

GlassMorphism, MeshTransmissionMaterial

GlassCube는 프레젠테이션의 시작 슬라이드에 나타나는 회전하는 유리 큐브입니다. 물리 기반 렌더링으로 정말 "유리처럼" 보입니다.

MeshTransmissionMaterial의 마법

@react-three/drei의 MeshTransmissionMaterial은 복잡한 광학 효과를 간단하게 구현합니다:

<MeshTransmissionMaterial
    backside={true}              // 뒷면도 렌더링
    samples={8}                  // 샘플링 횟수 (높을수록 품질 좋음, 느림)
    resolution={256}             // 반사 맵 해상도
    transmission={0.9}           // 투명도 (1 = 완전 투명)
    roughness={0.05}             // 표면 거칠기 (0 = 매끄러움, 1 = 거칠음)
    thickness={0.5}              // 재료 두께 (굴절 효과)
    ior={1.5}                    // 굴절률 (유리 ≈ 1.5, 다이아몬드 ≈ 2.4)
    chromaticAberration={0.15}   // 색수차 (프리즘 효과)
    anisotropy={0.3}             // 이방성 (방향에 따른 거칠기)
    distortion={0.1}             // 왜곡 효과
    distortionScale={0.2}        // 왜곡 스케일
    temporalDistortion={0.1}      // 시간에 따른 왜곡 변화
    clearcoat={1}                // 코팅층 광택도
    attenuationDistance={1}       // 빛이 재료를 통과할 때 감쇠 거리
    attenuationColor="#22d3ee"   // 감쇠 색상 (청록색)
    color="#60a5fa"              // 기본 색상
    toneMapped={false}            // Tone mapping 비활성화 (HDR 효과)
/>

각 파라미터의 의미:

  • transmission: 빛이 얼마나 투명하게 통과하는가
  • ior (Index of Refraction): 빛이 재료 내에서 굽어지는 정도
  • chromaticAberration: 빛의 파장에 따라 다르게 굴절되는 색수차 (프리즘처럼)
  • attenuationColor: 두꺼운 유리를 통과할 때 색상이 변하는 효과

Three Lights 조명 설정

큐브가 "반짝이도록" 3개의 point light를 배치합니다:

<Canvas
    camera={{ position: [0, 0, 5], fov: 45 }}
    style={{ background: 'transparent' }}
    gl={{ alpha: true, antialias: true }}
>
    <Environment preset="night" />  // 야간 환경맵으로 반사

    <ambientLight intensity={0.3} />
    <pointLight position={[5, 5, 5]} intensity={2} color="#22d3ee" />      // 청록색 광원
    <pointLight position={[-5, -5, 5]} intensity={1.5} color="#a78bfa" />  // 보라색 광원
    <pointLight position={[0, 5, -5]} intensity={1} color="#60a5fa" />     // 파란색 광원
    <spotLight
        position={[0, 10, 0]}
        angle={0.3}
        penumbra={1}
        intensity={2}
        color="#ffffff"
    />

    <RotatingCube />
</Canvas>

조명 배치 의도:

  • 다양한 색상의 light source로 유리의 굴절을 시각적으로 다양하게
  • Environment preset "night"는 다크 테마 배경에서 반사가 부드럽게 나타남

Rotation과 Float Animation

function RotatingCube() {
    const meshRef = useRef<THREE.Mesh>(null);

    useFrame(() => {
        if (meshRef.current) {
            meshRef.current.rotation.x += 0.003;
            meshRef.current.rotation.y += 0.005;
        }
    });

    return (
        <Float
            speed={2}              // 부유 애니메이션 속도
            rotationIntensity={0.2}  // 회전 강도
            floatIntensity={0.5}   // 부유 움직임 강도
        >
            <RoundedBox
                args={[2, 2, 2]}
                radius={0.15}
                smoothness={4}
            >
                {/* MeshTransmissionMaterial */}
            </RoundedBox>
        </Float>
    );
}

Blur + Fade 효과로 슬라이드 전환

PowerPoint의 단순한 페이드 효과와 달리, 이 프레젠테이션은 시네마틱이라는 이름에 걸맞게 블러를 활용한 입체적인 전환을 구현합니다.

블러 페이드 Variants

const blurFadeVariants = {
    enter: {
        opacity: 0,              // 투명한 상태로 시작
        filter: 'blur(20px)',    // 강한 블러
        scale: 1.05,             // 약간 확대
    },
    center: {
        opacity: 1,              // 완전 불투명
        filter: 'blur(0px)',     // 블러 없음
        scale: 1,                // 정상 크기
    },
    exit: {
        opacity: 0,              // 투명해짐
        filter: 'blur(20px)',    // 블러됨
        scale: 0.95,             // 축소
    },
};

<AnimatePresence mode="wait">
    <motion.div
        key={currentSlide}
        variants={blurFadeVariants}
        initial="enter"
        animate="center"
        exit="exit"
        transition={{
            duration: 0.6,
            ease: [0.4, 0, 0.2, 1],  // cubic-bezier로 자연스러운 타이밍!!!
            filter: { duration: 0.4 }  // 블러 효과는 더 빠르게
        }}
    >
        {/* 슬라이드 콘텐츠 */}
    </motion.div>
</AnimatePresence>

AnimatePresence mode="wait"의 의미:

  • wait: exit 애니메이션이 완료된 후 enter 애니메이션 시작
  • 겹치지 않으므로 시각적으로 깔끔함

이징 함수의 비결

ease: [0.4, 0, 0.2, 1]은 cubic-bezier 함수입니다:

bezier가 뭘까요?

  • 처음에 빠르게 변화 (0.4) → 중간에 느려짐 (0) → 끝에 다시 가속 (0.2) → 매끄럽게 완료 (1)
  • 이것이 느낌상 "자연스러운" 애니메이션을 만듭니다

YouTube Shorts 스타일 비디오 블러 배경

SkillShowcaseSlide에서 비디오가 화면 비율과 맞지 않을 때, 빈 공간을 블러 배경으로 채웁니다. 템플릿인만큼 확장성을 고려했을 때 연사자가 영상 파일을 첨부할 가능성은 높기 때문에 이것도 요긴하게 사용될 것 같습니다.

구현 방식

{/* 블러 배경 비디오 */}
<video
    autoPlay
    loop
    muted
    playsInline
    className="absolute inset-0 w-full h-full object-cover scale-150 blur-2xl opacity-50"
>
    <source src={videoSrc} type="video/mp4" />
</video>

{/* 실제 비디오 */}
<video
    autoPlay
    loop
    muted
    playsInline
    className="relative z-10 w-full h-full object-contain"
>
    <source src={videoSrc} type="video/mp4" />
</video>

CSS 클래스 설명:

  • absolute inset-0: 컨테이너 전체를 채움
  • object-cover: 비디오가 컨테이너를 꽉 채우도록 (일부 잘릴 수 있음)
  • scale-150: 150% 확대 (주변 블러 영역 채우기)
  • blur-2xl: Tailwind의 blur-2xl (blur(40px))
  • opacity-50: 50% 투명도 (백그라운드라는 느낌)
  • relative z-10: 실제 비디오가 위에 나타남
  • object-contain: 비디오 전체를 보이도록 (검은 영역 가능)

효과

유튜브 쇼츠나 인스타 릴스처럼 비디오 콘텐츠가 돋보이면서도, 배경이 공백인 것보다는 훨씬 더 역동적으로 보입니다.


이미지/비디오 프리로더

제작하다보니까 무거운 컨텐츠가 있는 슬라이드는 로딩이 걸리는 문제가 있어서 첫 앱 시작 시 모든 에셋을 병렬로 미리 로드해서 슬라이드 전환 시 로딩 지연이 없도록 합니다.

useImagePreloader Hook

export const useImagePreloader = (): PreloadState => {
    const [state, setState] = useState<PreloadState>({
        isLoading: true,
        progress: 0,
        loadedCount: 0,
        totalCount: PRELOAD_IMAGES.length + PRELOAD_VIDEOS.length,
    });

    useEffect(() => {
        let loadedCount = 0;
        const totalCount = PRELOAD_IMAGES.length + PRELOAD_VIDEOS.length;

        const updateProgress = () => {
            loadedCount++;
            const progress = Math.round((loadedCount / totalCount) * 100);
            setState({
                isLoading: loadedCount < totalCount,
                progress,
                loadedCount,
                totalCount,
            });
        };

        // 이미지 프리로드
        const imagePromises = PRELOAD_IMAGES.map((src) => {
            return new Promise<void>((resolve) => {
                const img = new Image();
                img.onload = () => {
                    updateProgress();
                    resolve();
                };
                img.onerror = () => {
                    // 에러도 로드 완료로 처리 (진행률 계속 진행)
                    updateProgress();
                    resolve();
                };
                img.src = src;
            });
        });

        // 비디오 프리로드
        const videoPromises = PRELOAD_VIDEOS.map((src) => {
            return new Promise<void>((resolve) => {
                const video = document.createElement('video');
                video.preload = 'auto';

                const handleLoad = () => {
                    updateProgress();
                    resolve();
                };

                video.oncanplaythrough = handleLoad;
                video.onerror = handleLoad;

                // 타임아웃: 비디오는 최대 3초만 기다림
                setTimeout(handleLoad, 3000);

                video.src = src;
                video.load();
            });
        });

        // 모든 이미지와 비디오 로드 완료 대기
        Promise.all([...imagePromises, ...videoPromises]).then(() => {
            setState((prev) => ({ ...prev, isLoading: false, progress: 100 }));
        });
    }, []);

    return state;
};

QR 코드 외부 API 포함

const generateQrUrl = (url: string) =>
    `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(url)}&bgcolor=ffffff&color=000000`;

const PRELOAD_IMAGES = [
    // ... 로컬 이미지들 ...
    generateQrUrl('https://github.com/tristanjung1006'),
    generateQrUrl('https://linkedin.com/in/seung-won-daemon'),
];

왜 이렇게 할까?

  • 슬라이드를 전환할 때 다시 말해서 발표할 때 로딩이 조금이라도 걸리는 것은 매우 치명적임
  • 60장의 사진과 비디오를 미리 준비하는 것은 네트워크 효율적
  • 상단에 Progress bar로 사용자가 기다리는 느낌을 덜 수 있음
  • 오히려 초반 로딩은 마치 게임의 인트로 로딩처럼 느껴져서 기대감을 고조시키는 역설적인 효과도 기대할 수 있음(?)

Kotlin 문법 하이라이팅 (외부 라이브러리 없이)

SkillShowcaseSlide에서 Kotlin 코드를 보여줄 때, 정규식 기반으로 토큰을 파싱해서 색상을 입혀줍니다.

토큰 파싱 로직

const highlightKotlin = (code: string): React.ReactNode[] => {
    const lines = code.split('\n');

    return lines.map((line, lineIndex) => {
        const tokens: React.ReactNode[] = [];
        let remaining = line;
        let keyIndex = 0;

        while (remaining.length > 0) {
            // 주석
            const commentMatch = remaining.match(/^(\/\/.*)/);
            if (commentMatch) {
                tokens.push(<span key={keyIndex++} className="text-gray-500">{commentMatch[1]}</span>);
                remaining = remaining.slice(commentMatch[1].length);
                continue;
            }

            // 어노테이션
            const annotationMatch = remaining.match(/^(@\w+)/);
            if (annotationMatch) {
                tokens.push(<span key={keyIndex++} className="text-yellow-400">{annotationMatch[1]}</span>);
                remaining = remaining.slice(annotationMatch[1].length);
                continue;
            }

            // 문자열
            const stringMatch = remaining.match(/^("[^"]*")/);
            if (stringMatch) {
                tokens.push(<span key={keyIndex++} className="text-green-400">{stringMatch[1]}</span>);
                remaining = remaining.slice(stringMatch[1].length);
                continue;
            }

            // Kotlin 키워드
            const keywordMatch = remaining.match(
                /^(fun|val|var|if|else|return|class|interface|object|private|public|override|suspend|data|sealed|companion)\b/
            );
            if (keywordMatch) {
                tokens.push(<span key={keyIndex++} className="text-purple-400 font-semibold">{keywordMatch[1]}</span>);
                remaining = remaining.slice(keywordMatch[1].length);
                continue;
            }

            // 타입 (대문자로 시작)
            const typeMatch = remaining.match(/^([A-Z][a-zA-Z0-9]*)/);
            if (typeMatch) {
                tokens.push(<span key={keyIndex++} className="text-cyan-300">{typeMatch[1]}</span>);
                remaining = remaining.slice(typeMatch[1].length);
                continue;
            }

            // 함수 호출
            const funcMatch = remaining.match(/^([a-z][a-zA-Z0-9]*)(\s*\()/);
            if (funcMatch) {
                tokens.push(<span key={keyIndex++} className="text-blue-400">{funcMatch[1]}</span>);
                tokens.push(<span key={keyIndex++} className="text-white/80">{funcMatch[2]}</span>);
                remaining = remaining.slice(funcMatch[0].length);
                continue;
            }

            // 기본값: 한 글자씩
            tokens.push(<span key={keyIndex++} className="text-white/80">{remaining[0]}</span>);
            remaining = remaining.slice(1);
        }

        return (
            <React.Fragment key={lineIndex}>
                {tokens}
                {lineIndex < lines.length - 1 && '\n'}
            </React.Fragment>
        );
    });
};

색상 매핑

왜 정규식으로 직접 구현했나?

  • highlight.js 같은 라이브러리를 쓰면 번들 크기가 커짐
  • Kotlin 문법은 비교적 간단해서 정규식으로도 충분
  • React 컴포넌트로 바로 렌더링 가능

GlassMorphism CSS

CSS를 잘한다는 것이 무슨 말인지는 웹에 무지했던 시절에는 이해가 안되었지만 홀로그래픽 포켓몬 카드 만든 사람 보고 벽을 느꼈습니다.

/* Tailwind Config */
colors: {
  background: '#0a0a0f',      // 깊은 검정색
  surface: '#12121a',
  text: {
    primary: '#f0f0f5',
    secondary: '#a0a0b0',
    muted: '#606070',
  },
  accent: {
    cyan: '#22d3ee',
    blue: '#60a5fa',
    teal: '#2dd4bf',
    purple: '#a78bfa',
  },
},
boxShadow: {
  'glow-purple': '0 0 20px rgba(168, 85, 247, 0.5)',
  'glow-white': '0 0 20px rgba(255, 255, 255, 0.3)',
  'glow-purple-lg': '0 0 40px rgba(168, 85, 247, 0.6)',
},

글래스 카드

<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl p-6
    hover:shadow-glow-purple transition-all duration-300">
    {/* 콘텐츠 */}
</div>

각 클래스의 역할:

  • bg-white/5: 흰색 배경 5% 투명도 (배경과의 미묘한 구분)
  • backdrop-blur-xl: 뒷배경 블러 처리 (유리 효과)
  • border border-white/10: 흰색 테두리 10% 불투명도
  • hover:shadow-glow-purple: 호버 시 보라색 글로우
  • transition-all duration-300: 0.3초에 걸쳐 부드럽게 변화

슬라이드 네비게이션 상태 관리

키보드와 클릭 이벤트를 처리하면서, SkillShowcaseSlide 같은 내부 네비게이션과 충돌하지 않도록 해야 합니다.

useSlideNavigation Hook

export const useSlideNavigation = (totalSlides: number) => {
    const [currentSlide, setCurrentSlide] = useState(0);

    const nextSlide = useCallback(() => {
        setCurrentSlide((prev) => (prev + 1) % totalSlides);
    }, [totalSlides]);

    const prevSlide = useCallback(() => {
        setCurrentSlide((prev) => (prev - 1 + totalSlides) % totalSlides);
    }, [totalSlides]);

    const goToSlide = useCallback((slide: number) => {
        setCurrentSlide(Math.min(slide, totalSlides - 1));
    }, [totalSlides]);

    useEffect(() => {
        const handleKeyDown = (e: KeyboardEvent) => {
            // 특정 요소 (input, textarea)에서는 무시
            if (
                document.activeElement instanceof HTMLInputElement ||
                document.activeElement instanceof HTMLTextAreaElement
            ) {
                return;
            }

            if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') {
                nextSlide();
            } else if (e.key === 'ArrowLeft') {
                prevSlide();
            }
        };

        window.addEventListener('keydown', handleKeyDown);
        return () => window.removeEventListener('keydown', handleKeyDown);
    }, [nextSlide, prevSlide]);

    return { currentSlide, nextSlide, prevSlide, goToSlide };
};

SkillShowcaseSlide의 이벤트 캡처

SkillShowcaseSlide 내부에서는 capture: true를 사용해 이벤트를 먼저 처리합니다:

useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
        if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') {
            if (phase === 'skills') {
                e.stopPropagation();        // 상위 핸들러로 전파 중단
                e.preventDefault();         // 기본 동작 취소
                goNext();
            }
        } else if (e.key === 'ArrowLeft') {
            if (phase === 'message' || currentIndex > 0) {
                e.stopPropagation();
                e.preventDefault();
                goPrev();
            }
        }
    };

    // capture: true로 상위 핸들러보다 먼저 받음
    window.addEventListener('keydown', handleKeyDown, true);
    return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [phase, currentIndex, skills.length]);

AI 레버리지

이 프로젝트는 Claude Opus, Gemini Pro와 함께 개발되었습니다.

AI가 도움이 되었던 순간들

1. Perlin Noise 알고리즘 구현

상황: Ken Perlin이 1983년에 개발한 3D Perlin Noise를 구현해야 했는데, 수학이 깊고 복잡했습니다.

AI의 역할:

  • 기본 개념 설명 (Gradient 벡터, Fade 함수, Permutation Table)
  • 자연어 → JavaScript로 바로 변환
  • 버그 수정: "왜 노이즈가 특정 범위에서만 0에 가까워?" → 정규화 문제 지적
  • 최적화: 멀티 옥타브 구성으로 자연스러운 지형 생성

2. Three.js 쉐이더 커스터마이징

상황: 빙산이 물 위에서는 밝은 색, 물 아래에서는 어두운 색으로 보여야 했습니다.

AI의 역할:

  • GLSL 언어 설명 (varying 변수, 보간, smoothstep 함수)
  • onBeforeCompile 콜백 구조 설명
  • Vertex shader에서 world position 계산하고 Fragment shader에서 사용하는 방식 제시
  • 색상 그래디언트를 smoothstep으로 부드럽게 처리하는 방법

3. Three.js + React Three Fiber 최적화

상황: 처음에는 버블과 파티클이 너무 느렸습니다.

AI의 제안:

  • BufferGeometry 사용 (메시 재구성 대신)
  • needsUpdate 플래그 활용 (모든 프레임 업데이트, 아니라 명시적으로만)
  • depthWrite={false} 설정 (깊이 버퍼 쓰지 않아 성능 향상)
  • 60개 버블 + 100개 파티클 정도가 최적 (더 추가하면 프레임 드롭)

4. MeshTransmissionMaterial 파라미터 튜닝

상황: 처음 파라미터를 임의로 설정했을 때 전혀 유리 같지도 않은 큐브였습니다.

AI의 지도:

  • IOR (Index of Refraction): 유리는 1.5, 다이아몬드는 2.4 → 1.5로 설정
  • chromaticAberration: 너무 높으면 비현실적 → 0.15로 조정
  • roughness: 매끄러운 유리(0.05) vs 성에 낀 유리(0.3)
  • samples와 resolution의 트레이드오프: 품질 vs 성능

5. 애니메이션 이징 함수 선정

상황: Figma에서는 딸깍이면 되지만 적당한 계수를 전혀 몰랐습니다.

AI의 조언:

  • 기본 ease "linear"는 로봇같음
  • "easeInOutCubic" 은 너무 드라마틱
  • cubic-bezier [0.4, 0, 0.2, 1] (Material Design의 스탠다드)

AI의 한계와 직접 개입이 필요했던 부분

1. 비즈니스 로직: 슬라이드 데이터 구조

문제: 슬라이드 템플릿이 20개를 넘는데, 각각의 props 타입을 어떻게 정의할 것인가?

AI의 제안: Union type으로 모든 가능한 props를 합치기 (매우 복잡함)

내 판단: 각 템플릿을 독립적인 컴포넌트로 유지하고, slides.tsx에서 수동으로 배열 구성

export const slides: SlideItem[] = [
  { id: 'start', component: StartSlide, props: {...} },
  { id: 'iceberg', component: IcebergSlide, props: {...} },
  // ...
];

더 읽기 쉽고 유지보수하기 좋음.

2. 모바일 경고 UI

문제: 모바일에서는 Three.js 성능이 떨어지니 경고를 표시해야 함

AI의 제안: 간단한 경고 다이얼로그

내 판단: 사용자 경험을 고려해서:

  • "그래도 계속 볼게요" 버튼 제공 (센스있게 강제는 하지 않음)
  • Framer Motion으로 부드러운 진입 애니메이션
  • dismiss 후 다시 표시 안 함 (localStorage 대신 useState)

3. 이미지 프리로더 에러 처리

AI의 제안: 이미지 로드 실패 시 에러 처리해서 재시도

내 판단: 실제로는 로드 실패해도 진행률만 증가시키기

  • 프레젠테이션 시간이 정해져 있으므로 기다릴 여유 없음
  • 한 두 개 이미지 실패는 발표에 영향 미미
  • 프로그레스 100%에 도달하면 시작 (실패 여부 무관)

앞으로 추가된다면 좋을 기능들

  1. 스피커 노트: 각 슬라이드별 메모 입력
  2. 발표 타이머: 슬라이드 시간 제한 알림
  3. PDF 내보내기: 오프라인 버전 생성
  4. 다크/라이트 테마 토글: 환경에 맞춰 변경
  5. 댓글 기능: 온라인 컨퍼런스 지원

끝마치며

웹 기술로 프레젠테이션을 만드는 것은 이성적으로 보면 쓸데없고 비효율적이지만 내 생각은 좀 다르다. 나의 살아있지 않은 멘토 중 하나인 레오나르도 다 빈치는 이런 말을 남겼다. 완전한 정신을 기르려면 예술의 과학을 공부하고 과학의 예술을 공부하라고 했는데 이것이 이번 프로젝트의 의미이다.

0개의 댓글