logo hsb.horse
← 블로그 목록으로 돌아가기

블로그

Astro 프로젝트에 개발 전용 Live 에디터 구현하기

Astro에서 개발 모드 전용 브라우저 에디터를 구현하는 방법. Vite 미들웨어와 React를 사용하여 프로덕션 빌드에 전혀 영향을 주지 않으면서 개발 중 콘텐츠 관리를 편리하게 만드는 절차를 정리.

게시일:

Astro로 정적 사이트를 만들 때 콘텐츠 관리는 Git 기반이 기본이다. 다만 개발 중에 브라우저에서 직접 편집할 수 있으면 작업이 빨라진다.

에디터와 브라우저를 오가지 않고 콘텐츠를 편집할 수 있다. 변경하면 바로 화면에 반영된다. 이미지도 GUI를 통해 업로드할 수 있다.

PromptLex라는 프로젝트에서 개발 모드 전용으로 동작하는 에디터를 만들었다. 프로덕션 빌드에는 전혀 영향이 없다.

왜 Live 에디터를 만들었나

프로덕션에서 Git 기반을 사용하는 것은 당연하지만 개발 중에는 다르다.

테스트도 콘텐츠 작성도 훨씬 편해졌다. 프로토타이핑 시 반복 속도가 올라가고 이미지 관리도 같은 UI에서 완결된다. 개발 테스트용 더미 데이터를 쉽게 만들고 지울 수 있다.

아키텍처

3개의 컴포넌트로 구성되어 있다.

React 컴포넌트가 UI. Vite 미들웨어가 CRUD 작업용 API 엔드포인트. Astro 페이지가 DEV 모드일 때만 에디터를 로드하는 진입점.

스텝 1: 관리 화면 페이지 생성

개발 모드에서만 동작하는 Astro 페이지를 만든다.

src/pages/admin/index.astro
---
import Base from '../../layouts/Base.astro';
import AdminEditor from '../../components/AdminEditor';
const isDev = import.meta.env.DEV;
---
<Base title="관리 화면">
<meta slot="head" name="robots" content="noindex, nofollow" />
{isDev ? (
<AdminEditor client:only="react" />
) : (
<div>
<h1>관리 화면</h1>
<p>관리 화면은 개발 모드에서만 이용할 수 있습니다.</p>
<p><code>npm run dev</code>를 실행하여 접근하세요.</p>
</div>
)}
</Base>

client:only="react"를 지정하면 컴포넌트가 클라이언트 측에서만 로드된다.

스텝 2: API 미들웨어 구축

개발 서버에 API 엔드포인트를 추가하는 Astro 통합을 만든다.

src/integrations/admin-api.ts
import type { AstroIntegration } from 'astro';
import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';
export default function adminApi(): AstroIntegration {
return {
name: 'admin-api',
hooks: {
'astro:server:setup': ({ server }) => {
server.middlewares.use(async (req, res, next) => {
const url = req.url || '';
const method = req.method || 'GET';
if (!url.startsWith('/api/')) {
return next();
}
const contentDir = join(process.cwd(), 'src', 'content');
// GET /api/items - 모든 아이템 목록
if (url === '/api/items' && method === 'GET') {
const files = readdirSync(contentDir).filter(f => f.endsWith('.json'));
const items = files.map(file => {
const content = readFileSync(join(contentDir, file), 'utf-8');
return JSON.parse(content);
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(items));
return;
}
// POST /api/items - 새 아이템 생성
if (url === '/api/items' && method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
const data = JSON.parse(body);
const id = generateId(data.text);
const filePath = join(contentDir, `${id}.json`);
const item = {
id,
text: data.text,
createdAt: new Date().toISOString(),
};
writeFileSync(filePath, JSON.stringify(item, null, 2));
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(item));
});
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
});
},
},
};
}
function generateId(text: string): string {
return text.slice(0, 12).replace(/\W/g, '').toLowerCase();
}

스텝 3: React 컴포넌트 생성

API를 호출하는 React 컴포넌트를 작성한다.

src/components/AdminEditor.tsx
import React, { useState, useEffect } from 'react';
interface Item {
id: string;
text: string;
createdAt: string;
}
export default function AdminEditor() {
const [items, setItems] = useState<Item[]>([]);
const [newText, setNewText] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchItems();
}, []);
const fetchItems = async () => {
const response = await fetch('/api/items');
const data = await response.json();
setItems(data);
setLoading(false);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newText.trim()) return;
const response = await fetch('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newText }),
});
const newItem = await response.json();
setItems([newItem, ...items]);
setNewText('');
};
if (loading) return <div>로딩 중...</div>;
return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
<h1>콘텐츠 에디터</h1>
<form onSubmit={handleCreate}>
<textarea
value={newText}
onChange={(e) => setNewText(e.target.value)}
placeholder="콘텐츠를 입력..."
rows={4}
style={{ width: '100%', padding: '10px' }}
/>
<button type="submit">생성</button>
</form>
<div>
<h2>아이템 ({items.length})</h2>
{items.map(item => (
<div key={item.id} style={{ border: '1px solid #ddd', padding: '10px', margin: '10px 0' }}>
<strong>{item.id}</strong>
<p>{item.text}</p>
<small>{new Date(item.createdAt).toLocaleString()}</small>
</div>
))}
</div>
</div>
);
}

스텝 4: 통합 등록

astro.config.mjs에 관리 API 통합을 추가한다.

import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import adminApi from './src/integrations/admin-api';
export default defineConfig({
integrations: [
react(),
adminApi(),
],
});

고급 기능

파일 업로드 지원

멀티파트 폼 데이터로 이미지를 업로드한다.

async function parseMultipart(req) {
return new Promise((resolve) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
const buffer = Buffer.concat(chunks);
// boundary를 분석하여 파일 추출
// ...
resolve({ fields, files });
});
});
}

중복 방지

콘텐츠 해시로 중복을 회피한다.

import { createHash } from 'crypto';
function generateId(text: string): string {
const hash = createHash('sha256');
hash.update(text);
return hash.digest('hex').substring(0, 12);
}

삭제 기능

삭제 전 확인 다이얼로그를 표시한다.

const handleDelete = async (id: string) => {
if (!confirm('이 아이템을 삭제하시겠습니까?')) return;
await fetch(`/api/items/${id}`, { method: 'DELETE' });
setItems(items.filter(item => item.id !== id));
};

베스트 프랙티스

개발 전용임을 import.meta.env.DEV로 반드시 확인한다. 관리 페이지에는 noindex 메타 태그를 붙인다. TypeScript 인터페이스로 타입을 명시한다. 에러 메시지는 알기 쉽게 작성한다. 저장 전에 입력을 검증한다. 파일 작업에는 path.join()을 사용한다.

프로덕션 환경에 대한 영향

프로덕션 빌드에 영향이 없는 것이 이 방법의 장점이다.

에디터는 astro dev 중에만 동작한다. 프로덕션 빌드에 불필요한 라우트는 포함되지 않는다. API 엔드포인트도 공개되지 않는다. 정적 사이트는 정적인 채로 남는다.

Astro 프로젝트용 Live 에디터를 만들면 개발 경험이 바뀐다. Vite 미들웨어와 React를 사용하면 개발 중에만 존재하는 콘텐츠 관리 인터페이스를 만들 수 있다.

완전한 구현은 PromptLex 리포지토리에 있다.

참고 자료