ID001 Dev Blog
회고 10 분 소요

블로그 개발일지 #2 — 세 가지 버그와 정적 사이트의 함정

claude-code ai astro blog debugging

1편에서 블로그의 뼈대를 완성했다. 이번 글에서는 실제로 사용하면서 발견한 세 가지 버그를 수정한 과정을 기록한다. 각각은 사소해 보였지만, 정적 사이트 생성(SSG)의 특성과 CSS 상속의 함정이 얽혀 있어 원인을 찾기까지 몇 번의 시행착오가 필요했다.

Bug 1: 카테고리 목록이 하드코딩되어 있다

카테고리 필터에 포스트가 하나도 없는 카테고리가 표시되고 있었다. 원인은 단순했다.

// astro-loader.ts — Before
async getAllCategories(): Promise<string[]> {
  return ["til", "retrospective", "article", "tutorial"];
}

배열이 하드코딩되어 있었다. 실제 포스트에서 카테고리를 추출하도록 변경했다.

// astro-loader.ts — After
async getAllCategories(locale: string = "ko"): Promise<string[]> {
  const posts = await this.getAllPosts(locale);
  const categories = new Set(posts.map((p) => p.category));
  return [...categories].sort();
}

CategoryFilter.astroCATEGORIES 상수 전체를 순회하는 대신, 이 메서드를 호출하여 실제 포스트가 있는 카테고리만 렌더링하도록 수정했다. 가장 단순한 버그였지만, 다음 두 버그의 기반이 되는 수정이었다.

컴포넌트 분리 — 같은 데이터, 다른 용도

수정 후 홈 페이지의 카테고리 섹션은 여전히 CATEGORIES 상수를 직접 순회하고 있었다. 같은 데이터를 두 곳에서 다른 방식으로 가져오면 불일치가 생기기 마련이다. 이를 해결하기 위해 카테고리 데이터를 가져와 링크 목록을 렌더링하는 CategoryList.astro를 분리했다.

  • CategoryList — 동적 카테고리 데이터를 가져와 링크 목록을 렌더링하는 공유 컴포넌트. 홈 페이지에서 직접 사용한다.
  • CategoryFilterCategoryList를 내부에서 사용하면서 “전체” 버튼과 클라이언트 필터링 스크립트를 추가한 /blog 페이지 전용 컴포넌트.

데이터 소스가 하나로 통일되니 홈과 블로그 페이지의 카테고리가 항상 일치한다.

Bug 2: 카테고리를 클릭해도 필터링되지 않는다

카테고리 버튼을 클릭하면 URL에 ?category=til이 추가되지만, 포스트 목록은 전혀 변하지 않았다.

첫 번째 시도 — 서버 사이드 필터링 (실패)

처음에는 Astro의 서버 사이드에서 쿼리 파라미터를 읽어 필터링하려고 했다.

// blog/index.astro — 이렇게 하면 될 줄 알았다
const category = Astro.url.searchParams.get("category");
const allPosts = category
  ? await contentService.getPostsByCategory(category, locale)
  : await contentService.getAllPosts(locale);

코드는 깔끔했지만 전혀 동작하지 않았다. 이유는 astro.config.ts에 있었다.

export default defineConfig({
  output: "static", // ← 이것
});

output: "static"은 빌드 타임에 모든 페이지를 HTML로 생성한다. 즉 Astro.url.searchParams는 빌드 타임에 항상 빈 값이다. 런타임에 사용자가 어떤 쿼리 파라미터를 보내든, 이미 생성된 정적 HTML이 응답된다.

두 번째 시도 — 클라이언트 사이드 필터링 (성공)

정적 빌드를 유지하면서 필터링을 구현하려면 클라이언트 JavaScript가 필요하다. 접근 방식은 이렇다.

  1. PostList.astro에서 각 포스트 카드를 <div data-category={post.category}>로 감싼다
  2. CategoryFilter.astro<script>를 추가하여 클릭 시 DOM 필터링을 수행한다
  3. URL은 history.replaceState로 동기화하여 새로고침 시에도 필터 상태가 유지된다
// CategoryFilter.astro <script>
buttons.forEach((btn) => {
  btn.addEventListener("click", (e) => {
    e.preventDefault(); // 페이지 이동 방지
    setActive(btn.dataset.category ?? "all");
  });
});

// 초기 로드 시 URL에서 카테고리 복원
const initialCategory =
  new URL(window.location.href).searchParams.get("category") ?? "all";
setActive(initialCategory);

모든 포스트가 HTML에 포함되어 있으므로 페이지 로딩 없이 즉시 필터링된다. SEO 관점에서도 모든 포스트가 크롤링 가능하다는 장점이 있다.

Bug 3: 모바일 메뉴가 투명하게 보인다

모바일 햄버거 메뉴를 열면 슬라이드 패널이 나타나야 하는데, 본문 콘텐츠가 패널을 통해 비쳐 보여서 읽을 수 없는 상태였다. 이 버그는 원인을 찾기까지 가장 많은 시행착오를 겪었다.

원인 1 — 애니메이션 라이브러리 호환 문제

원래 코드는 tw-animate-cssanimate-in slide-in-from-right 클래스를 사용하고 있었다. 하지만 이 라이브러리는 data-open:animate-in 같은 조건부 형태로 동작하도록 설계되어 있어, React의 조건부 렌더링({open && ...})으로 마운트/언마운트하면 제대로 동작하지 않았다.

이 부분은 Tailwind의 transition-transform으로 직접 구현하여 해결했다.

const [open, setOpen] = useState(false); // 트랜지션 상태
const [visible, setVisible] = useState(false); // DOM 마운트 여부

<nav
  className={cn(
    "transition-transform duration-200 ease-out",
    open ? "translate-x-0" : "translate-x-full",
  )}
/>;

원인 2 — CSS 상속의 함정

애니메이션을 고쳤는데도 패널이 여전히 투명했다. bg-background가 적용되어 있고, CSS 변수 --background는 불투명한 oklch(1 0 0)(흰색)인데도 말이다.

문제는 패널이 렌더링되는 위치에 있었다. MobileNav 컴포넌트는 Header의 slot으로 들어가므로, 오버레이와 패널이 Header DOM 내부에서 렌더링된다.

<!-- Header -->
<header
  class="bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-sm"
>
  <!-- ... -->
  <div class="md:hidden">
    <!-- MobileNav가 여기에 렌더링됨 -->
    <!-- fixed 패널도 Header의 자식 -->
    <div class="bg-background fixed z-50 ...">
      <!-- bg-background가 적용되어 있지만,
           부모의 backdrop-blur + bg-background/60이
           이 요소에도 영향을 미침 -->
    </div>
  </div>
</header>

fixed 포지셔닝은 요소를 뷰포트 기준으로 배치하지만, CSS 상속과 stacking context는 여전히 DOM 트리를 따른다. Header의 backdrop-blur-sm이 새로운 stacking context를 생성하고, 그 안의 모든 자식 요소에 블러와 반투명 효과가 적용된 것이다.

해결 — React Portal

createPortal로 오버레이와 패널을 <body> 직접 자식으로 렌더링하여, Header의 CSS 효과에서 완전히 독립시켰다.

import { createPortal } from "react-dom";

{
  visible &&
    createPortal(
      <>
        <div className="fixed inset-0 z-50 bg-black/40 ..." />
        <nav className="bg-background fixed inset-y-0 right-0 z-50 w-64 ...">
          {/* 메뉴 내용 */}
        </nav>
      </>,
      document.body,
    );
}

이제 패널은 Header와 무관한 stacking context에서 렌더링되므로, bg-background가 의도대로 불투명하게 동작한다.

오늘의 회고

정적 사이트의 멘탈 모델

가장 큰 배움은 “이 코드가 언제 실행되는가”를 항상 의식해야 한다는 것이다. Astro의 프론트매터(--- 블록)는 빌드 타임에 실행되고, <script> 태그는 런타임에 실행된다. 같은 .astro 파일 안에 있지만 실행 시점이 다르다. 카테고리 필터 버그는 이 차이를 간과해서 발생했다.

CSS 디버깅은 위치가 핵심이다

모바일 메뉴 버그에서 가장 많은 시간을 쓴 건 “왜 bg-background가 투명한가”였다. CSS 속성값만 보면 문제가 없었다. 요소가 DOM 트리의 어디에 위치하는지, 부모의 어떤 속성이 stacking context를 생성하는지를 파악해야 비로소 원인이 보였다.

AI와 디버깅할 때

Claude Code와 디버깅하면서 느낀 점은, 증상을 정확히 전달하는 것이 중요하다는 것이다. “안 돼요”보다 “패널이 투명하게 보여요”가, 그보다 “패널의 bg-background가 적용되었는데도 본문이 비쳐 보여요”가 더 정확한 진단으로 이어졌다. 결국 세 번째 시도에서 createPortal 해결책에 도달했다.

블로그 개발일지 #1

Claude Code로 하루 만에 블로그 만들기