Astro 컴포넌트 깊이 파헤치기: Props부터 슬롯, 합성 패턴까지
Astro로 사이트를 만들다 보면 .astro 파일을 수도 없이 만들게 됩니다. 처음에는 HTML이랑 비슷하니까 감으로 쓰다가, 어느 순간 “이 컴포넌트에 props를 어떻게 넘기지?”, “슬롯이 여러 개 필요한데?”, “스타일이 자식 컴포넌트까지 먹히질 않네?” 같은 질문이 하나둘 쌓이기 시작하죠.
사실 Astro 컴포넌트는 겉보기엔 단순하지만 파고 들어가면 꽤 많은 게 숨어 있습니다. TypeScript 기반의 Props 타입 시스템, 이름 있는 슬롯과 폴백 콘텐츠, CSS 스코핑과 전역 스타일 제어, 동적 태그까지. 이 글에서는 Astro 컴포넌트를 제대로 활용하기 위해 알아야 할 것들을 하나씩 풀어보겠습니다.
컴포넌트의 두 영역
Astro 컴포넌트는 코드 펜스(---)로 구분되는 두 영역으로 나뉩니다. 위쪽은 컴포넌트 스크립트, 아래쪽은 HTML 템플릿이죠.
---
// 컴포넌트 스크립트: 서버에서만 실행
import Card from "./Card.astro";
const response = await fetch("https://api.example.com/posts");
const posts = await response.json();
---
<!-- HTML 템플릿: 최종 HTML 출력 -->
<section>
{posts.map((post) => (
<Card title={post.title} />
))}
</section>
컴포넌트 스크립트에서 눈여겨볼 점은 서버에서만 실행된다는 겁니다. 빌드 시점(SSG)이든 요청 시점(SSR)이든, 이 코드는 브라우저로 절대 전달되지 않아요. 그래서 fetch로 외부 API를 호출하면서 API 키를 써도 클라이언트에 노출될 걱정이 없고, 파일 시스템에 접근하는 것도 가능합니다. 또 하나, 최상위 레벨에서 await를 바로 쓸 수 있습니다. 별도의 async 함수로 감쌀 필요가 없죠.
템플릿 영역은 HTML을 기본으로 하되, {}로 JavaScript 표현식을 삽입할 수 있습니다. JSX와 비슷하지만 미묘한 차이가 있는데, 이건 뒤에서 따로 정리하겠습니다.
Props 타입 시스템
Astro 컴포넌트에 데이터를 넘기려면 Props를 사용합니다. Astro.props로 접근하는데, TypeScript의 Props 인터페이스를 선언하면 타입 검사와 에디터 자동 완성을 받을 수 있습니다.
---
interface Props {
type: "info" | "warning" | "error";
title: string;
dismissible?: boolean;
}
const { type, title, dismissible = false } = Astro.props;
---
<div class={`alert alert-${type}`} role="alert">
<strong>{title}</strong>
<slot />
{dismissible && <button class="close" aria-label="닫기">×</button>}
</div>
Props 인터페이스에 정의한 타입은 이 컴포넌트를 사용하는 쪽에서도 동작합니다. type에 “success”처럼 허용하지 않은 값을 넘기면 에디터가 빨간 줄로 알려주죠. dismissible처럼 ?가 붙은 optional 속성에는 구조 분해 할당에서 기본값을 지정할 수 있습니다.
HTML 속성 확장하기
버튼이나 링크처럼 네이티브 HTML 요소를 감싸는 컴포넌트를 만들 때가 있습니다. 이런 경우 onclick, aria-label, class 같은 표준 HTML 속성도 받아서 전달해야 하는데, 일일이 다 타입에 정의하기는 번거롭죠. Astro는 이를 위해 HTMLAttributes 타입을 제공합니다.
---
import type { HTMLAttributes } from "astro/types";
interface Props extends HTMLAttributes<"button"> {
variant?: "primary" | "secondary" | "ghost";
}
const { variant = "primary", class: className, ...attrs } = Astro.props;
---
<button class:list={["btn", `btn-${variant}`, className]} {...attrs}>
<slot />
</button>
HTMLAttributes<"button">을 확장하면 <button> 태그가 받을 수 있는 모든 표준 속성의 타입이 자동으로 포함됩니다. class는 JavaScript 예약어라 class: className으로 이름을 바꿔서 받고, 나머지 속성은 ...attrs로 모아서 스프레드합니다. 이렇게 하면 컴포넌트 사용자가 disabled, aria-expanded, data-testid 같은 속성을 자유롭게 넘길 수 있게 됩니다.
<!-- 사용 예시 -->
<Button variant="primary" disabled aria-label="제출하기">
저장
</Button>
슬롯 활용하기
React의 children, Vue/Svelte의 <slot>에 해당하는 개념입니다. 컴포넌트 안에 <slot />을 놓으면 부모가 넘긴 자식 콘텐츠가 그 자리에 들어갑니다.
<div class="card">
<slot />
</div>
<!-- 사용 -->
<Card>
<h2>제목</h2>
<p>본문 내용</p>
</Card>
여기까지는 간단한데, 실제로 컴포넌트를 만들다 보면 슬롯이 하나로는 부족한 경우가 금방 생깁니다.
이름 있는 슬롯
레이아웃 컴포넌트처럼 여러 영역에 콘텐츠를 끼워 넣어야 할 때 이름 있는 슬롯(named slot)을 씁니다.
<div class="page">
<header>
<slot name="header" />
</header>
<main>
<slot />
</main>
<aside>
<slot name="sidebar" />
</aside>
</div>
사용하는 쪽에서는 slot 속성으로 어디에 넣을지 지정합니다.
<PageLayout>
<h1 slot="header">페이지 제목</h1>
<p>본문 내용은 기본 슬롯으로 들어갑니다.</p>
<nav slot="sidebar">
<a href="#section-1">섹션 1</a>
<a href="#section-2">섹션 2</a>
</nav>
</PageLayout>
이름 있는 슬롯에 여러 요소를 한꺼번에 넣고 싶다면 <Fragment>로 감쌉니다.
<PageLayout>
<Fragment slot="header">
<h1>제목</h1>
<p class="subtitle">부제목</p>
</Fragment>
<p>본문</p>
</PageLayout>
폴백 콘텐츠
슬롯에 아무것도 전달되지 않았을 때 보여줄 기본 콘텐츠를 지정할 수 있습니다.
<aside>
<slot name="widget">
<p>아직 위젯이 없습니다.</p>
</slot>
</aside>
부모가 widget 슬롯에 아무것도 넘기지 않으면 “아직 위젯이 없습니다.”가 표시됩니다.
슬롯 존재 여부 확인
Astro.slots.has()를 쓰면 특정 슬롯에 콘텐츠가 전달되었는지 확인할 수 있습니다. 콘텐츠가 없을 때 해당 영역 자체를 렌더링하지 않으려면 이 방법이 유용합니다.
---
const hasFooter = Astro.slots.has("footer");
---
<article>
<slot />
</article>
{hasFooter && (
<footer class="article-footer">
<slot name="footer" />
</footer>
)}
폴백 콘텐츠와의 차이점이 뭘까요? 폴백은 슬롯이 비었을 때 대체 콘텐츠를 보여주는 것이고, has()는 슬롯이 비었을 때 감싸는 래퍼까지 통째로 안 그리는 것입니다. 위 예제에서 footer 슬롯이 비어 있으면 <footer> 태그 자체가 HTML에 나타나지 않습니다.
템플릿 표현식
Astro 템플릿에서 {}를 사용해 JavaScript 표현식을 쓸 수 있다는 건 이미 아실 텐데요. 실무에서 자주 쓰는 패턴 몇 가지를 정리해보겠습니다.
조건부 렌더링
---
const isLoggedIn = true;
const role = "admin";
---
<!-- && 패턴 -->
{isLoggedIn && <p>환영합니다!</p>}
<!-- 삼항 연산자 -->
{role === "admin" ? (
<AdminDashboard />
) : (
<UserDashboard />
)}
React와 거의 같은 방식이죠. 다만 Astro에서는 이게 빌드 시점에 평가되기 때문에 조건에 따라 아예 해당 HTML이 생성되지 않습니다.
리스트 렌더링
---
const languages = [
{ name: "TypeScript", color: "#3178C6" },
{ name: "Rust", color: "#DEA584" },
{ name: "Python", color: "#3776AB" },
];
---
<ul>
{languages.map((lang) => (
<li style={`color: ${lang.color}`}>{lang.name}</li>
))}
</ul>
React와 달리 key 속성이 필요 없습니다. Astro 컴포넌트는 한 번 렌더링되고 끝이라 DOM 비교(diffing)를 할 필요가 없기 때문이죠.
동적 HTML 삽입
마크다운 파싱 결과처럼 이미 만들어진 HTML 문자열을 삽입해야 할 때는 set:html을 사용합니다.
---
const bio = "<strong>개발자</strong>이자 <em>블로거</em>입니다.";
---
<p set:html={bio} />
set:html은 React의 dangerouslySetInnerHTML에 해당하는데, 이름이 훨씬 간결하죠. 다만 외부 입력을 그대로 넣으면 XSS 공격에 취약해질 수 있으니 신뢰할 수 있는 소스에서 온 데이터에만 사용해야 합니다. 사용자 입력을 표시할 때는 set:text를 쓰면 자동으로 이스케이프됩니다.
CSS 스코핑과 스타일링
Astro 컴포넌트의 <style> 블록은 기본적으로 해당 컴포넌트에만 적용됩니다. 내부적으로 고유한 data 속성(data-astro-cid-xxx)을 선택자에 추가해서 스코프를 만드는 방식인데요. 별도의 설정 없이 자동으로 동작하니 클래스 이름이 겹칠 걱정 없이 .title이나 .card 같은 간단한 이름을 마음껏 쓸 수 있습니다.
<span class="badge"><slot /></span>
<style>
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
background: var(--color-primary);
color: white;
}
</style>
이 .badge 스타일은 이 컴포넌트 안에서만 동작합니다. 다른 컴포넌트에 .badge라는 클래스가 있어도 서로 간섭하지 않죠.
전역 스타일이 필요할 때
그런데 가끔은 스코프를 벗어나야 할 때가 있습니다. 슬롯으로 받은 자식 요소에 스타일을 적용하고 싶거나, 마크다운에서 생성된 HTML에 스타일을 입혀야 할 때가 대표적이죠.
<div class="prose">
<slot />
</div>
<style>
.prose {
max-width: 65ch;
line-height: 1.75;
}
/* :global()로 슬롯 자식에 스타일 적용 */
.prose :global(h2) {
margin-top: 2rem;
font-size: 1.5rem;
}
.prose :global(pre) {
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
}
.prose :global(a) {
color: var(--color-primary);
text-decoration: underline;
}
</style>
.prose는 스코프가 적용되어 이 컴포넌트에서만 동작하고, :global() 안의 h2, pre, a는 .prose 안에 있는 모든 요소에 적용됩니다. 이렇게 하면 스코프의 안전성을 유지하면서도 슬롯 콘텐츠의 스타일을 제어할 수 있습니다.
<style is:global> 디렉티브를 쓰면 블록 전체를 전역으로 만들 수도 있지만, 이건 정말 필요한 경우가 아니면 피하는 게 좋습니다. 전역 스타일이 늘어나면 컴포넌트 간 스타일 충돌이 생기기 쉬우니까요.
JavaScript 변수를 CSS로 넘기기
define:vars를 사용하면 컴포넌트 스크립트의 변수를 CSS 커스텀 속성으로 넘길 수 있습니다.
---
interface Props {
value: number;
color?: string;
}
const { value, color = "#3b82f6" } = Astro.props;
---
<div class="progress">
<div class="bar" />
</div>
<style define:vars={{ value: `${value}%`, color }}>
.progress {
width: 100%;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.bar {
width: var(--value);
height: 100%;
background: var(--color);
transition: width 0.3s ease;
}
</style>
define:vars에 넘긴 객체의 키가 CSS 커스텀 속성 이름이 됩니다. value는 --value로, color는 --color로 사용할 수 있죠. 이렇게 하면 Props에 따라 스타일이 동적으로 달라지는 컴포넌트를 쉽게 만들 수 있습니다.
class:list로 조건부 클래스
여러 조건에 따라 클래스를 조합해야 할 때는 class:list 디렉티브가 편합니다.
---
interface Props {
size?: "sm" | "md" | "lg";
rounded?: boolean;
disabled?: boolean;
}
const { size = "md", rounded = false, disabled = false } = Astro.props;
---
<div class:list={[
"chip",
`chip-${size}`,
{ "chip-rounded": rounded, "chip-disabled": disabled },
]}>
<slot />
</div>
배열 안에 문자열, 객체, 다른 배열을 섞어 넣을 수 있습니다. 객체의 경우 값이 truthy인 키만 클래스로 추가됩니다. rounded가 true이고 disabled가 false라면 최종 클래스는 "chip chip-md chip-rounded"가 되죠.
컴포넌트 합성 패턴
작은 컴포넌트를 조합해서 큰 컴포넌트를 만드는 건 어떤 컴포넌트 시스템이든 빠지지 않는 패턴이죠. Astro도 마찬가지입니다.
레이아웃 합성
가장 기본적인 패턴은 레이아웃입니다. 공통 HTML 구조를 레이아웃으로 빼고, 각 페이지가 콘텐츠만 채우는 방식이죠.
---
interface Props {
title: string;
description?: string;
}
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{description && <meta name="description" content={description} />}
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>
---
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout title="소개" description="저에 대해 알아보세요">
<h1>안녕하세요!</h1>
<p>웹 개발을 좋아하는 개발자입니다.</p>
</BaseLayout>
레이아웃을 중첩하는 것도 가능합니다. BaseLayout 위에 BlogLayout을 올리고, BlogLayout 위에 각 포스트 페이지를 올리는 식으로요.
동적 태그
같은 스타일의 컴포넌트인데 렌더링되는 HTML 태그만 바꾸고 싶을 때가 있습니다. 제목 컴포넌트를 예로 들면, 어떤 곳에서는 <h1>으로, 다른 곳에서는 <h2>로 렌더링해야 하죠.
---
interface Props {
as?: "h1" | "h2" | "h3" | "h4";
}
const { as: Tag = "h2" } = Astro.props;
---
<Tag>
<slot />
</Tag>
<!-- 사용 예시 -->
<Heading as="h1">메인 제목</Heading>
<Heading>기본은 h2</Heading>
<Heading as="h3">소제목</Heading>
변수에 태그 이름을 담으면 Astro가 그 태그로 렌더링합니다. 변수 이름은 대문자로 시작해야 합니다. 소문자로 시작하면 Astro가 일반 HTML 태그로 해석하려고 하거든요.
재귀 컴포넌트
트리 구조의 데이터를 렌더링해야 할 때는 Astro.self로 컴포넌트가 자기 자신을 참조할 수 있습니다.
---
interface MenuItem {
label: string;
href?: string;
children?: MenuItem[];
}
interface Props {
items: MenuItem[];
}
const { items } = Astro.props;
---
<ul>
{items.map((item) => (
<li>
{item.href ? <a href={item.href}>{item.label}</a> : <span>{item.label}</span>}
{item.children && <Astro.self items={item.children} />}
</li>
))}
</ul>
파일 탐색기, 댓글 스레드, 카테고리 트리 같은 재귀적인 UI에 유용합니다. import 없이 Astro.self로 바로 쓸 수 있어서 순환 참조 걱정도 없어요.
JSX와 다른 점
Astro 템플릿은 JSX를 닮았지만 분명한 차이가 있습니다. React에서 넘어온 분들이 자주 헷갈리는 부분을 정리하면 다음과 같습니다.
---
const isNew = true;
const items = ["사과", "바나나", "딸기"];
---
<!-- HTML 표준 속성명을 그대로 씁니다 -->
<label for="name">이름</label>
<input id="name" class="input" tabindex="0" />
<!-- JSX에서는 htmlFor, className, tabIndex -->
<!-- 여러 루트 요소도 그냥 됩니다 -->
<h1>제목</h1>
<p>본문</p>
<!-- JSX에서는 Fragment로 감싸야 함 -->
<!-- HTML 주석도 그대로 -->
<!-- 이 주석은 최종 HTML에 나타납니다 -->
{/* 이 주석은 나타나지 않습니다 */}
<!-- key 속성이 필요 없습니다 -->
<ul>
{items.map((item) => <li>{item}</li>)}
</ul>
정리하면 이렇습니다.
| 항목 | Astro | JSX (React) |
|---|---|---|
| 클래스 | class | className |
| label의 for | for | htmlFor |
| 속성 케이스 | tabindex (HTML 표준) | tabIndex (카멜케이스) |
| 여러 루트 요소 | 그냥 가능 | Fragment 필요 |
| 리스트의 key | 불필요 | 필요 |
| HTML 주석 | <!-- --> (DOM에 출력) | 지원하지 않음 |
Astro가 HTML 표준에 더 가깝습니다. JSX는 JavaScript 문법의 제약 때문에 예약어를 피해야 했지만, Astro 템플릿은 그런 제약이 없어서 HTML을 아는 사람이면 바로 쓸 수 있죠.
프레임워크 컴포넌트와 함께 쓰기
Astro 컴포넌트만으로는 상태 관리나 이벤트 핸들링 같은 클라이언트 측 인터랙션을 구현할 수 없습니다. 빌드 시점에 한 번 렌더링되고 끝이기 때문이죠. 이때 React, Vue, Svelte 같은 프레임워크 컴포넌트를 아일랜드로 끼워 넣을 수 있습니다.
---
import Header from "../components/Header.astro";
import SearchDialog from "../components/SearchDialog.tsx";
import PostList from "../components/PostList.astro";
---
<!-- Astro 컴포넌트: 정적 HTML -->
<Header />
<!-- React 컴포넌트: 클라이언트에서 하이드레이션 -->
<SearchDialog client:load />
<!-- Astro 컴포넌트: 정적 HTML -->
<PostList />
client:load를 빼면 어떻게 될까요? React 컴포넌트가 서버에서 HTML로 렌더링되긴 하지만 클라이언트에서 하이드레이션이 일어나지 않습니다. useState, onClick 같은 건 전부 동작하지 않죠. 디렉티브를 빼먹으면 “버튼을 눌러도 아무 반응이 없다”는 상황이 벌어지니 주의하세요.
하이드레이션 시점은 용도에 따라 골라 쓰면 됩니다.
| 디렉티브 | 하이드레이션 시점 | 적합한 경우 |
|---|---|---|
client:load | 페이지 로드 즉시 | 헤더, 검색, 즉시 반응해야 하는 UI |
client:idle | 브라우저가 유휴 상태일 때 | 우선순위가 낮은 위젯 |
client:visible | 뷰포트에 들어올 때 | 스크롤해야 보이는 차트, 댓글 |
client:media="(max-width: 768px)" | 미디어 쿼리 조건 충족 시 | 모바일에서만 필요한 컴포넌트 |
client:only="react" | 클라이언트 전용 (SSR 건너뜀) | window에 의존하는 컴포넌트 |
핵심은 필요한 곳에만 JavaScript를 보내는 겁니다. 페이지 대부분을 Astro 컴포넌트로 만들어 정적 HTML을 유지하고, 인터랙션이 꼭 필요한 부분에만 프레임워크 컴포넌트를 쓰는 방식이죠. 이게 바로 Astro의 아일랜드 아키텍처가 추구하는 접근법입니다.
<script> 태그 다루기
Astro 컴포넌트에서 간단한 클라이언트 스크립트가 필요할 때는 프레임워크 컴포넌트를 쓰지 않고 <script> 태그로 해결할 수도 있습니다.
<button class="copy-btn" data-text="복사할 텍스트">
복사하기
</button>
<script>
document.querySelectorAll(".copy-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const text = btn.getAttribute("data-text");
navigator.clipboard.writeText(text);
});
});
</script>
Astro의 <script> 태그는 기본적으로 번들링과 최적화를 거칩니다. TypeScript도 바로 쓸 수 있고, npm 패키지를 import하는 것도 가능합니다. 자동으로 type="module"이 붙기 때문에 전역 스코프를 오염시킬 걱정도 없죠.
한 가지 알아둘 점은, Astro 5 기준으로 <script> 태그가 선언된 위치에 그대로 렌더링된다는 겁니다. 예전 버전에서는 <head>로 자동 호이스팅됐지만 지금은 그렇지 않습니다. <head>에 넣고 싶으면 레이아웃의 <head> 안에 직접 넣어야 합니다.
번들링 없이 스크립트를 그대로 출력하고 싶다면 is:inline 디렉티브를 씁니다.
<script is:inline>
// 번들링, 최적화 없이 그대로 출력
console.log("인라인 스크립트");
</script>
실전 팁 모음
마지막으로 Astro 컴포넌트를 쓰면서 알아두면 좋은 팁 몇 가지를 정리합니다.
첫 번째, 컴포넌트 스크립트가 비어 있으면 코드 펜스를 생략해도 됩니다. Props도 없고 import도 없는 순수 HTML 컴포넌트라면 --- 없이 바로 마크업을 쓸 수 있습니다.
<hr class="divider" />
<style>
.divider {
border: none;
border-top: 1px solid var(--color-border);
margin: 2rem 0;
}
</style>
두 번째, Props에 Record<string, never> 타입을 쓰면 props를 아예 받지 않겠다고 명시할 수 있습니다. 이렇게 하면 실수로 props를 넘기려고 할 때 타입 에러가 발생합니다.
세 번째, Astro.url과 Astro.request로 현재 요청 정보에 접근할 수 있습니다. 현재 경로에 따라 네비게이션 활성 상태를 표시하거나, 쿼리 파라미터를 읽을 때 유용하죠.
---
const currentPath = Astro.url.pathname;
---
<nav>
<a href="/" class:list={[{ active: currentPath === "/" }]}>홈</a>
<a href="/about" class:list={[{ active: currentPath === "/about" }]}>소개</a>
</nav>
네 번째, 컴포넌트를 페이지(src/pages/)에 넣으면 자체적으로 라우트가 됩니다. src/components/에 넣으면 재사용 가능한 빌딩 블록이 되고요. 같은 .astro 파일이지만 어디에 두느냐에 따라 역할이 달라지는 셈입니다.
마치며
Astro 컴포넌트는 겉보기에는 “HTML에 약간의 JavaScript를 얹은 것” 같지만 실제로 써보면 생각보다 할 수 있는 게 많습니다. 타입 안전한 Props, 이름 있는 슬롯, 자동 CSS 스코핑, 동적 태그에 재귀 패턴까지. 서버에서만 실행된다는 제약이 오히려 “JavaScript 없이 렌더링한다”는 Astro의 철학과 맞물려서 강점이 되는 거죠.
물론 상태 관리나 이벤트 핸들링은 Astro 컴포넌트만으로 안 되고 React나 Vue 같은 프레임워크 컴포넌트가 필요합니다. 하지만 대부분의 웹 페이지에서 인터랙티브한 부분은 전체의 일부에 불과하니까 Astro 컴포넌트로 뼈대를 세우고 프레임워크 컴포넌트로 인터랙션을 채우는 방식이 꽤 합리적이에요.
Astro 시작하기에서 프레임워크 전체 그림을 익히셨다면 이제 컴포넌트를 직접 만들면서 손에 익혀보세요. Content Collections로 콘텐츠 관리하기나 이미지 최적화도 함께 살펴보면 Astro 프로젝트의 전체 흐름이 잡힐 겁니다.
더 깊이 파고 싶다면 Astro 공식 컴포넌트 문서를 참고하세요.
This work is licensed under
CC BY 4.0