Astro 컴포넌트에서 이벤트 핸들링이 addEventListener여야 하는 이유
React의 onClick에 익숙한 개발자가 Astro에서 마주하는 이벤트 핸들링 방식의 차이를, 렌더링 아키텍처와 실전 코드를 통해 설명합니다.
들어가며
React에서 Astro로 넘어온 개발자라면 한 번쯤 이런 의문을 가질 수 있다.
“왜 Astro에서는
onClick으로 이벤트를 바인딩할 수 없는 거지?”
Astro 컴포넌트의 템플릿은 JSX와 비슷한 문법을 사용하지만, 이벤트를 처리하는 방식은 근본적으로 다르다. 이 글에서는 그 차이의 원인을 렌더링 아키텍처 관점에서 설명하고, Astro에서의 올바른 이벤트 핸들링 패턴을 정리한다.
Astro 컴포넌트의 렌더링 특성
Astro 컴포넌트(.astro)는 빌드 타임에 정적 HTML로 렌더링된다. 프론트매터(--- 블록) 내의 코드는 빌드 시점(서버)에만 실행되고, 그 결과물은 순수한 HTML 문자열이다.
---
// 이 코드는 빌드 타임에만 실행된다
const greeting = 'Hello';
const handleClick = () => console.log('clicked');
---
<p>{greeting}</p>
<!-- handleClick은 빌드 타임의 함수 참조 — 브라우저로 전달되지 않는다 -->
빌드 결과물에는 greeting 변수의 값('Hello')만 남고, handleClick 같은 함수 참조는 사라진다. 브라우저가 받는 것은 다음과 같은 정적 HTML뿐이다.
<p>Hello</p>
이것이 핵심이다. Astro에는 클라이언트 사이드 런타임이 없다. 빌드 타임의 JavaScript 컨텍스트는 브라우저에 도달하지 않는다.
React의 onClick은 왜 동작하는가
React에서 onClick이 동작하는 이유를 이해하면, Astro에서 왜 같은 방식을 쓸 수 없는지 명확해진다.
const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
여기서 onClick은 HTML의 onclick 속성이 아니다. React 런타임이 JSX를 해석하는 과정에서 다음과 같은 일이 일어난다.
JSX 파싱
│
├─ onClick 감지
│ └─ React 런타임이 내부적으로 addEventListener 호출
│ └─ 이벤트 위임(Event Delegation) 방식으로 루트에서 처리
│
└─ Virtual DOM → Real DOM 반영
즉, React의 onClick은 문법적 설탕(syntactic sugar)이다. 내부적으로는 addEventListener를 사용하며, 이를 가능하게 하는 것이 클라이언트에서 상주하는 React 런타임이다.
Astro에는 이 런타임이 존재하지 않으므로, 템플릿에서 onClick={handler}를 작성해도 빌드 결과물에 함수 참조가 포함되지 않는다.
HTML 네이티브 onclick의 한계
그렇다면 HTML 표준 onclick 속성은 어떨까? 동작은 하지만, 실제 프로젝트에서 사용하기엔 여러 제약이 있다.
문자열로만 작성해야 한다
<!-- onclick 값은 HTML 속성 문자열이다 -->
<button onclick="handleClick()">클릭</button>
브라우저는 이 문자열을 eval처럼 평가한다. 따라서 handleClick이 전역 스코프에 존재해야만 호출할 수 있다.
클로저 변수에 접근할 수 없다
이것이 가장 큰 문제다. 실제 컴포넌트에서는 상태를 클로저로 관리하는 경우가 대부분이다.
<script>
const initSlider = () => {
let currentIndex = 0; // 클로저 내부 변수
const images = [
/* ... */
]; // 클로저 내부 변수
const goTo = (index: number) => {
currentIndex = Math.max(0, Math.min(index, images.length - 1));
updateView();
};
// addEventListener는 같은 클로저 스코프이므로 접근 가능
prevBtn?.addEventListener('click', () => goTo(currentIndex - 1));
};
</script>
currentIndex, images, goTo는 initSlider 함수의 클로저 안에 존재한다. HTML onclick에서는 이 스코프에 접근할 방법이 없다.
<!-- currentIndex는 클로저 내부에 있으므로 접근 불가 -->
<button onclick="goTo(currentIndex - 1)">이전</button>
이를 해결하려면 모든 변수를 전역 스코프로 끌어올려야 하는데, 이는 전역 오염과 네이밍 충돌을 야기한다.
CSP(Content Security Policy) 위반
보안 정책으로 unsafe-inline을 금지하는 환경에서는 인라인 이벤트 핸들러 자체가 차단된다. addEventListener는 이 제약에 해당하지 않으므로, 보안 측면에서도 더 안전한 선택이다.
Astro의 표준 패턴: addEventListener
Astro에서 클라이언트 인터랙션을 처리하는 표준 패턴은 다음과 같다.
---
// 빌드 타임: 정적 HTML 생성
const label = '클릭';
---
<!-- HTML: data 속성으로 필요한 정보를 전달 -->
<button class="my-btn" data-action="greet">{label}</button>
<!-- 클라이언트 사이드: addEventListener로 이벤트 바인딩 -->
<script>
const button = document.querySelector('.my-btn');
button?.addEventListener('click', () => {
const action = (button as HTMLElement).dataset.action;
console.log(action); // 'greet'
});
</script>
이 패턴의 핵심은 역할 분리다.
- 프론트매터 (
---): 데이터 준비 (빌드 타임) - 템플릿: HTML 구조 +
data-*속성으로 정보 전달 <script>: 이벤트 바인딩 + 인터랙션 로직 (클라이언트 타임)
빌드 타임의 데이터를 클라이언트로 넘겨야 할 때는 data-* 속성을 활용한다. HTML 속성은 정적 문자열이므로 빌드 결과물에 자연스럽게 포함된다.
실전 사례: ImageSlider 컴포넌트
이 패턴이 실제로 어떻게 적용되는지, 이미지 슬라이더 컴포넌트를 예로 살펴본다.
템플릿: 구조와 데이터 전달
---
interface Props {
images: { src: ImageMetadata; alt: string }[];
}
const { images } = Astro.props;
---
{
images.map((image, index) => (
<figure class="slider-slide">
<button
type="button"
class="slider-image-btn"
data-image-index={index}
aria-label={`${image.alt} 크게 보기`}
>
<Image src={image.src} alt={image.alt} />
</button>
</figure>
))
}
data-image-index={index}로 각 이미지의 인덱스를 HTML 속성에 심어둔다. 이 값은 빌드 결과물에 data-image-index="0", data-image-index="1" 같은 정적 문자열로 남는다.
스크립트: 클로저와 이벤트 바인딩
const initLightbox = () => {
const lightbox = document.querySelector('.lightbox') as HTMLElement | null;
if (!lightbox) return;
let currentIndex = 0; // 클로저 내부 상태
const open = (index: number) => {
currentIndex = index;
// lightbox 열기 로직
};
const goTo = (index: number) => {
currentIndex = Math.max(0, Math.min(index, images.length - 1));
// 이미지 전환 로직
};
// data 속성에서 인덱스를 읽어 클로저 내부 함수 호출
document.querySelectorAll('.slider-image-btn').forEach((btn) => {
btn.addEventListener('click', (event) => {
const index = Number((event.currentTarget as HTMLElement).dataset.imageIndex);
open(index);
});
});
// 키보드 이벤트도 같은 클로저 스코프에서 처리
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') goTo(currentIndex - 1);
if (e.key === 'ArrowRight') goTo(currentIndex + 1);
});
};
currentIndex는 initLightbox 클로저 내부에 캡슐화되어 있다. addEventListener 콜백은 같은 스코프에 있으므로 이 변수에 자유롭게 접근할 수 있다. HTML onclick으로는 불가능한 구조다.
View Transitions와 이벤트 리스너 관리
Astro에서 ClientRouter(View Transitions)를 사용하면, 페이지 전환 시 DOM이 교체(swap)된다. 이때 기본 <script>는 모듈 스크립트이므로 재실행되지 않는다. 따라서 두 가지를 신경 써야 한다.
1. 재초기화: astro:page-load
astro:page-load는 초기 페이지 로드와 View Transitions 전환 모두에서 발생하는 이벤트다. 직접 호출과 astro:after-swap을 분리하는 것보다, 이 이벤트 하나로 통합하는 것이 깔끔하다.
// ❌ 이중 초기화 — 코드 중복, 관리 포인트 분산
initSlider();
initLightbox();
document.addEventListener('astro:after-swap', () => {
initSlider();
initLightbox();
});
// ✅ 통합 초기화 — 초기 로드 + 페이지 전환 모두 처리
document.addEventListener('astro:page-load', () => {
initSlider();
initLightbox();
});
2. 정리(cleanup): astro:before-swap
DOM 요소에 바인딩된 이벤트 리스너는 해당 요소가 제거되면 함께 정리된다. 하지만 document에 등록한 리스너는 DOM swap과 무관하게 남아 있다. 페이지를 전환할 때마다 리스너가 중복 등록되면 메모리 누수와 예기치 않은 동작이 발생한다.
이를 방지하려면 cleanup 패턴을 사용한다. 초기화 함수가 정리 함수를 반환하고, DOM 교체 직전에 호출하는 구조다.
const initLightbox = () => {
const lightbox = document.querySelector('.lightbox') as HTMLElement | null;
if (!lightbox) return () => {};
// 이벤트 핸들러를 named function으로 분리
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
if (e.key === 'ArrowLeft') goTo(currentIndex - 1);
if (e.key === 'ArrowRight') goTo(currentIndex + 1);
};
// 등록
document.addEventListener('keydown', handleKeydown);
// cleanup 함수 반환
return () => {
document.removeEventListener('keydown', handleKeydown);
};
};
// 라이프사이클 관리
let cleanup = () => {};
document.addEventListener('astro:page-load', () => {
initSlider();
cleanup = initLightbox();
});
document.addEventListener('astro:before-swap', () => {
cleanup();
});
astro:before-swap는 새 DOM이 현재 DOM을 교체하기 직전에 발생한다. 이 시점에 document 레벨 리스너를 제거하면, 새 페이지의 astro:page-load에서 깨끗한 상태로 다시 등록할 수 있다.
정리
| 방식 | Astro에서 사용 가능 | 클로저 접근 | CSP 안전 |
|---|---|---|---|
React onClick | 불가 | — | — |
HTML onclick="..." | 가능 | 불가 | 위반 가능 |
addEventListener | 가능 | 가능 | 안전 |
Astro 컴포넌트에서 이벤트를 처리할 때 기억할 원칙은 세 가지다.
- 템플릿은 구조,
<script>는 동작을 담당한다. 이벤트 핸들러는 항상<script>내에서addEventListener로 바인딩한다. - 빌드 타임 데이터는
data-*속성으로 전달한다. 프론트매터의 값을 클라이언트에서 사용해야 할 때는 HTML 속성에 심어둔다. - View Transitions 환경에서는 라이프사이클을 관리한다.
astro:page-load로 초기화를 통합하고,document레벨 리스너는astro:before-swap에서 정리한다.