Skip to main content

정적 사이트 홈을 다시 만들면서 마주친 5가지 함정

6 min read

AI 90 / Human 10

AI-drafted, edited

AI가 거의 다 쓰고, 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 카드 홈이 떠 있다. 며칠 더 보고 결정하려고.