정적 사이트 홈을 다시 만들면서 마주친 5가지 함정
AI 90 / Human 10
AI-drafted, editedAI가 거의 다 쓰고, kimtoma가 일부 수정.
— 브라우저로 직접 보면서 피드백 → AI가 코드/디버깅/문서화. 시각적 판단은 사람, 손은 AI.
3D 카드 떠다니는 홈을 한 컬럼 디렉토리 형태로 다시 만들었다. NOW.LOG 자리에 Strava 마지막 활동, Spotify 지금 듣는 곡(인라인 플레이어 포함), Obsidian에서 매시간 동기화되는 마크다운까지 라이브로 붙였다. 만드는 며칠 동안 마주친 디버깅 포인트들이 꽤 흥미로워서 정리해둔다.
왜 다시 만들었나
기존 홈은 3D bento canvas 였다. 카드가 떠다니고, 호버하면 살짝 기울어지고, 클릭하면 펼쳐졌다. 만들 때는 재미있었는데 막상 매일 들어가 보면 좀 무거웠다. 무엇보다 지금 무엇을 하고 있는지를 보여주는 위젯을 추가하기가 어려웠다. 카드 한 장 더 늘리는 게 쉬운 일이 아니었다.
새 구조는 한 컬럼 디렉토리다. [10rem | 1fr] 그리드 위에 다섯 섹션이 같은 수직선에 정렬되어 있다.
NOW.LOG updated 2026-05-05
WORKING ON my-K · KT 지식허브
READING 나는 가능성이다
LISTENING 🎵 듣는중 · Track Name · Artist ▶ play
WALKING 🚶 걷기 · Lunch Walk · 3.5km · 41분
WRITING.LOG archive (33) →
2025.10.15 글 제목
...
/about, /blog, /things 와 같은 max-w-6xl 컨테이너 안에 들어가서 전체 사이트의 룩앤필이 통일됐다.
만드는 과정에서 마주친 함정 다섯 개.
1. Tailwind v4 dev 캐시
globals.css 에 .chat-greet 클래스를 추가했는데 브라우저에 적용이 안 됐다. 분명히 파일에는 쓰여 있는데, 서빙되는 CSS 에는 없었다.
$ curl -s "$(node-css-url)" | grep chat-greet
# 결과: 0
확인해보니 Next.js dev 모드의 @tailwindcss/postcss 캐시가 갱신되지 않은 채로 컴파일된 결과를 그대로 들고 있는 거였다. 특히 @layer 밖에 있는 plain CSS 규칙을 추가했을 때 자주 발생했다.
해결:
pkill -f "next dev" && rm -rf .next && PORT=3010 npm run dev
이번 작업 중에 같은 패턴으로 세 번 정도 막혔다. globals.css 에 새 규칙 추가 후 안 보이면 무조건 .next 비우고 재시작.
2. inline style 이 hover className 을 이김
홈에 있는 언더라인 링크 일부가 호버해도 색이 안 바뀌었다. 코드 보면 hover:text-[hsl(var(--primary))] 클래스가 분명히 있는데도.
const A = ({ href, children }) => {
const cls = "underline hover:text-[hsl(var(--primary))] transition-colors"
const style = {
color: "hsl(var(--foreground))", // ← 이 친구
textDecorationColor: "hsl(var(--muted-foreground) / 0.5)",
}
return <a className={cls} style={style}>{children}</a>
}
브라우저 spec 상 inline style 은 같은 element 의 외부 CSS 클래스를 언제나 이긴다. color: foreground 가 inline 으로 박혀 있으면 hover:text-primary 클래스가 호버 시 적용되더라도 inline 이 우선해서 색이 안 바뀐다.
해결:
const cls = "underline text-[hsl(var(--foreground))] hover:text-[hsl(var(--primary))] transition-colors"
const style = {
textDecorationColor: "hsl(var(--muted-foreground) / 0.5)", // 이건 className 으로 표현 어려움
}
color 를 className 으로 빼고, inline style 에는 className 으로 표현 어려운 것만 남긴다. 그러면 hover 가 정상 작동.
같은 함정에 빠진 컴포넌트가 6 개 있었다 (A, IconLink, LiveWalking, LiveListening 트랙 링크, LiveListening play 버튼). 한 번에 다 정리.
3. 말풍선 꼬리 깜빡임 (60fps 프레임 함정)
Bio 끝에 프로필 사진 + Caveat 폰트 "Hello?" 를 넣고 호버하면 말풍선이 펼쳐지는 인터랙션을 만들었다. iMessage 스타일로, 둥근 꼬리가 아바타 쪽으로 뻗어나가는 모양.
.bubble::before {
/* 외곽 (말풍선과 같은 색) */
background: hsl(var(--primary));
border-bottom-right-radius: 18px 16px;
opacity: 0;
}
.bubble::after {
/* 안쪽 (배경색) — ::before 를 깎아서 곡선을 만듦 */
background: hsl(var(--background));
border-bottom-right-radius: 14px 14px;
opacity: 0;
}
.bubble:hover::before, .bubble:hover::after { opacity: 1; }
기본 상태에서는 둘 다 opacity 0. 호버하면 둘 다 페이드 인하면서 ::after 가 ::before 의 일부분을 가려서 곡선 꼬리가 만들어진다. 정상 동작하는 것처럼 보였다.
근데 화면 녹화를 프레임 단위로 보니까, 말풍선 꼬리가 사각형 모양으로 한 프레임 깜빡이고 사라지는 게 잡혔다. 처음에는 모니터 문제인 줄 알았다.
원인은 alpha compositing 이었다. opacity 가 0 → 1 로 트랜지션 되는 순간, 두 pseudo-element 가 동시에 페이드 인되면서 브라우저가 한 프레임 정도는 둘의 사각형 bounding box 를 그대로 렌더한 다음에야 border-radius cut 이 적용된 결과를 보여준다. 그 한 프레임이 비디오에 잡힌 거였다.
CSS 트랜지션을 GPU 합성 레이어에 올리면 이 문제가 사라진다.
.bubble::before, .bubble::after {
will-change: opacity;
backface-visibility: hidden; /* GPU layer 강제 */
}
will-change: opacity 는 브라우저에 "이 요소 opacity 가 곧 애니메이션될 거야" 라고 알려서 미리 별도 레이어를 만들게 한다. backface-visibility: hidden 은 추가로 GPU 레이어를 강제. 이 두 속성 덕에 alpha 합성이 GPU 단계에서 깔끔하게 처리되고, 사각형 bounding box 가 한 프레임도 노출되지 않는다.
(중간에 clip-path: path(...) 단일 pseudo-element 로 꼬리를 그려본 시도도 있었다. 깜빡임은 없었지만 곡선 모양이 원본과 달라서 결국 두 pseudo + GPU 힌트로 회귀.)
4. inline-flex padding 변화로 인한 흔들림
말풍선 호버 시 padding 이 4px → 6px 로 변화하면서 텍스트가 더 시원하게 보이도록 했다. 그런데 사람들이 호버해보면 아바타가 미세하게 위아래로 흔들린다 는 피드백을 받았다.
원인: 말풍선과 아바타가 같은 inline-flex 컨테이너에 들어 있고 align-items: center 라서, 말풍선 높이가 변하면 아바타가 새로운 중심선에 맞춰 1px 정도 재정렬됐다.
해결:
- vertical padding 은 고정 (6px 6px)
- 호버 시에는
transform: scale(0.97 → 1)으로 팝업 효과만 - 아바타에
position: relative; z-index: 2줘서 말풍선 꼬리가 아바타 뒤로 깔리도록
transform 은 layout 에 영향을 주지 않는다. padding 은 영향을 준다. 흔들림이 싫다면 padding 대신 transform 쪽으로.
5. 정적 export + 라이브 데이터 → Cloudflare Worker
kimtoma.com 은 Cloudflare Pages 에 static export 로 배포되어 있다. 백엔드 라우트가 없다. 그런데 NOW.LOG 의 Walking 행은 Strava API 결과를 보여줘야 하고, Listening 행은 Spotify 의 currently-playing 을 폴링해야 한다. OAuth 콜백을 받을 곳도 필요하다.
해법: 별도 Cloudflare Worker 에 커스텀 도메인을 붙였다.
# workers/gemini-proxy/wrangler.toml
name = "gemini-proxy-with-logging"
main = "src/index.ts"
# 이 한 줄이면 Cloudflare 가 DNS 도 자동으로 만들어준다
routes = [
{ pattern = "api.kimtoma.com", custom_domain = true }
]
# 기존 *.workers.dev URL 도 살려둠 (legacy chat client 용)
workers_dev = true
배포하면 자동으로 api.kimtoma.com DNS 가 생성되고 SSL 인증서도 발급된다.
워커는:
GET /strava/auth→ Strava OAuth 시작GET /strava/callback→ 토큰 받아서 D1 에 저장GET /strava/recent→ 캐시된 JSON (1 시간 TTL)GET /spotify/auth,/spotify/callback,/spotify/now-playing→ 동일 패턴- 토큰은 D1 의
settings테이블에 보관
프론트엔드는 클라이언트 컴포넌트에서 fetch 만 하면 된다.
// src/components/home/LiveWalking.tsx
"use client"
const STRAVA_ENDPOINT = "https://api.kimtoma.com/strava/recent"
export default function LiveWalking({ fallback }) {
const [activity, setActivity] = useState(null)
useEffect(() => {
fetch(STRAVA_ENDPOINT).then(r => r.json()).then(d => {
if (d.ok && d.authenticated) setActivity(d.activity)
})
}, [])
if (!activity) return <>{fallback}</>
return <a href={activity.url}>...</a>
}
마크다운 fallback 은 Obsidian vault 의 Now Log.md 에서 빌드 타임에 읽어온다. iCloud 동기화로 Mac mini → 다른 기기에서 작성한 내용이 자연스럽게 반영된다.
Spotify Embed iframe — 인라인 재생
Listening 행 옆 ▶ play 버튼을 누르면 80px 컴팩트 Spotify 임베드 플레이어가 펼쳐진다.
{expanded && trackId && (
<iframe
src={`https://open.spotify.com/embed/track/${trackId}?utm_source=generator`}
width="100%"
height="80"
allow="autoplay; clipboard-write; encrypted-media"
loading="lazy"
/>
)}
방문자가 Spotify Premium 이 아니어도 30 초 미리듣기는 자동 재생된다. Premium 이면 풀 트랙. Web Playback SDK 로 가면 모든 방문자한테 Premium 을 강제하게 되니, 임베드 iframe 이 정답.
결과 + 배운 것
- 시각적 디테일은 결국 사람이 직접 보고 피드백해야 한다. AI 한테 "더 부드럽게" 같은 추상적 요청만 던지면 잘못된 방향으로 깊이 들어간다. 비디오 녹화로 프레임 단위 깜빡임을 잡아주니까 그제서야 진짜 원인을 찾았다.
- "왜 안 보이지?" 디버깅의 절반은 캐시다. 코드가 분명히 옳아 보이면 빌드 캐시부터 의심.
- inline
style은 우선순위 함정이다. 가능하면 className 만 쓰는 게 안전. - 60fps 환경에서 부드러운 마이크로 인터랙션을 만들려면 GPU 합성 힌트가 필수다.
will-change,backface-visibility,transform: translateZ(0)셋 중 하나는 거의 항상 필요. - 정적 사이트라고 해서 라이브 데이터를 못 보여주는 건 아니다. 워커 + 커스텀 도메인 + D1 조합이면 충분히 가볍고 안전하다.
배포는 아직 안 했다 — 라이브로는 여전히 옛날 3D 카드 홈이 떠 있다. 며칠 더 보고 결정하려고.