PowerPoint, Figma, Canva와 같은 좋은 도구가 있음에도 불구하고 비효율적인 웹을 택하는 것은 합리적인 생각은 아닙니다.
웹 개발이 재밌는 것도 있고 무엇보다도 모바일 네이티브에서 구현하기 힘든 애니메이션을 비교적 쉽게 구현할 수 있다는 것이 매력적이고 좋은 도구를 쓴다고 해도 완전히 커스텀 애니메이션을 만들기에는 코드로 작성하는 것이 더 낫다고 판단했습니다.
웹 기술로 만든 프레젠테이션은 vercel과 함께라면 공유하기에도 편하고 모바일 대응까지 할 수 있어서 마우스보다 키보드가 익숙한 개발자한테는 그리 나쁘지 않은 선택이었던 것 같습니다.
디자인적 영감은 Pinterest, X, Spline, Y Combinator에서 가져왔고, 첫 연사로 참여한 자리에서 제가 선정한 주제는 기술보다는 경험 공유에 가까웠기 때문에 시네마틱한 미학을 적용했으며 슬라이드 템플릿을 오픈소스로 만들어서 다른 사람들도 재미를 볼 수 있도록 만들었습니다.
왜 React인가?
// 슬라이드 템플릿의 기본 구조
interface IcebergSlideProps {
title: string;
surface: { label: string; items: string[] };
underwater: { label: string; items: string[] };
footer?: string;
}
export const IcebergSlide: React.FC<IcebergSlideProps> = (props) => {
// 구현
};
왜 Vite인가?
npm run dev # 개발 서버 (거의 즉시 시작)
npm run build # 프로덕션 빌드 (2초 이내)
왜 Framer Motion인가?
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>
왜 3D 그래픽인가?
React Three Fiber: Three.js를 React처럼 쓸 수 있게 해주는 라이브러리
<mesh>, <material>, <light> 등이 컴포넌트처럼 작동useFrame 훅으로 애니메이션 루프 관리Drei: React Three Fiber 생태계의 유틸리티 라이브러리
MeshTransmissionMaterial: 유리처럼 보이는 물리 기반 재료Environment: 반사와 조명을 한 줄로 설정RoundedBox: 모서리가 둥근 박스 지오메트리IcebergSlide는 빙산의 일각이라는 아이디어에서 출발했고 연사를 진행한다면 생각보다 사용할 일이 많을 것 같습니다. 물 위에 떠 있는 불규칙한 빙산을 Three.js로 렌더링하되, 물 위의 항목과 물 아래의 항목을 시각적으로 구분합니다.
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
);
};
이 알고리즘을 왜 구현했나?
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;
}, []);
결과: 빙산은 울퉁불퉁한 다면체이기 때문에 다각형을 덧붙인다는 생각으로 만들면 됩니다.
빙산의 색상이 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>
);
};
GlassCube는 프레젠테이션의 시작 슬라이드에 나타나는 회전하는 유리 큐브입니다. 물리 기반 렌더링으로 정말 "유리처럼" 보입니다.
@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 효과)
/>
각 파라미터의 의미:
큐브가 "반짝이도록" 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>
조명 배치 의도:
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>
);
}
PowerPoint의 단순한 페이드 효과와 달리, 이 프레젠테이션은 시네마틱이라는 이름에 걸맞게 블러를 활용한 입체적인 전환을 구현합니다.
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 함수입니다:
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: 비디오 전체를 보이도록 (검은 영역 가능)유튜브 쇼츠나 인스타 릴스처럼 비디오 콘텐츠가 돋보이면서도, 배경이 공백인 것보다는 훨씬 더 역동적으로 보입니다.
제작하다보니까 무거운 컨텐츠가 있는 슬라이드는 로딩이 걸리는 문제가 있어서 첫 앱 시작 시 모든 에셋을 병렬로 미리 로드해서 슬라이드 전환 시 로딩 지연이 없도록 합니다.
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;
};
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'),
];
왜 이렇게 할까?
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>
);
});
};
왜 정규식으로 직접 구현했나?
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 같은 내부 네비게이션과 충돌하지 않도록 해야 합니다.
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 내부에서는 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]);
이 프로젝트는 Claude Opus, Gemini Pro와 함께 개발되었습니다.
상황: Ken Perlin이 1983년에 개발한 3D Perlin Noise를 구현해야 했는데, 수학이 깊고 복잡했습니다.
AI의 역할:
상황: 빙산이 물 위에서는 밝은 색, 물 아래에서는 어두운 색으로 보여야 했습니다.
AI의 역할:
onBeforeCompile 콜백 구조 설명상황: 처음에는 버블과 파티클이 너무 느렸습니다.
AI의 제안:
BufferGeometry 사용 (메시 재구성 대신)needsUpdate 플래그 활용 (모든 프레임 업데이트, 아니라 명시적으로만)depthWrite={false} 설정 (깊이 버퍼 쓰지 않아 성능 향상)상황: 처음 파라미터를 임의로 설정했을 때 전혀 유리 같지도 않은 큐브였습니다.
AI의 지도:
상황: Figma에서는 딸깍이면 되지만 적당한 계수를 전혀 몰랐습니다.
AI의 조언:
문제: 슬라이드 템플릿이 20개를 넘는데, 각각의 props 타입을 어떻게 정의할 것인가?
AI의 제안: Union type으로 모든 가능한 props를 합치기 (매우 복잡함)
내 판단: 각 템플릿을 독립적인 컴포넌트로 유지하고, slides.tsx에서 수동으로 배열 구성
export const slides: SlideItem[] = [
{ id: 'start', component: StartSlide, props: {...} },
{ id: 'iceberg', component: IcebergSlide, props: {...} },
// ...
];
더 읽기 쉽고 유지보수하기 좋음.
문제: 모바일에서는 Three.js 성능이 떨어지니 경고를 표시해야 함
AI의 제안: 간단한 경고 다이얼로그
내 판단: 사용자 경험을 고려해서:
AI의 제안: 이미지 로드 실패 시 에러 처리해서 재시도
내 판단: 실제로는 로드 실패해도 진행률만 증가시키기
웹 기술로 프레젠테이션을 만드는 것은 이성적으로 보면 쓸데없고 비효율적이지만 내 생각은 좀 다르다. 나의 살아있지 않은 멘토 중 하나인 레오나르도 다 빈치는 이런 말을 남겼다. 완전한 정신을 기르려면 예술의 과학을 공부하고 과학의 예술을 공부하라고 했는데 이것이 이번 프로젝트의 의미이다.