Vite + JavaScript + Tailwind CSS로 게시판 만들기

프레임워크 없이, 순수 JavaScript만으로 게시판을 만들어 봅니다. 회원가입, 로그인, 글 쓰기/수정/삭제, 페이지네이션, 댓글까지 전부 구현하는 튜토리얼이에요.
완성하면 이런 모습이 됩니다.

빌드 도구는 Vite, 스타일은 Tailwind CSS를 사용합니다. React나 Vue 같은 프레임워크는 쓰지 않아요. DOM을 직접 다루면서 SPA(Single Page Application)의 기본 구조를 이해하는 게 이 튜토리얼의 목표입니다.
시작하기 전에
아래 두 가지가 설치되어 있어야 합니다.
- Node.js (v18 이상) - nodejs.org에서 다운로드
- VS Code 또는 익숙한 코드 편집기
터미널에서 버전을 확인해 보세요.
node -v # v18.0.0 이상 npm -v # 9.0.0 이상
1. 프로젝트 생성
터미널을 열고 프로젝트를 만들겠습니다.
npm create vite@latest board-app
선택지가 나오면 이렇게 골라주세요.
- framework:
Vanilla - variant:
JavaScript
프로젝트 폴더로 이동해서 의존성을 설치합니다.
cd board-app npm install
Tailwind CSS 설치
Tailwind CSS v4와 Vite 플러그인을 설치합니다.
npm install -D tailwindcss @tailwindcss/vite
Vite가 생성한 기본 파일 중 불필요한 파일을 정리합니다. counter.js, javascript.svg, style.css 내용, main.js 내용을 지워주세요. 이제부터 하나씩 새로 작성할 거예요.
확인 포인트:
npm run dev를 실행했을 때 브라우저에 빈 페이지가 나타나면 정상입니다.
2. 프로젝트 구조
완성된 프로젝트의 파일 구조는 이렇습니다.
board-app/ ├── index.html ├── vite.config.js ├── package.json ├── src/ │ ├── main.js ← 라우터 설정 + 진입점 │ ├── style.css ← Tailwind CSS 임포트 │ ├── utils/ │ │ ├── api.js ← API 클라이언트 │ │ └── router.js ← 해시 기반 라우터 │ └── pages/ │ ├── login.js ← 로그인 │ ├── signup.js ← 회원가입 │ ├── postList.js ← 글 목록 + 페이지네이션 │ ├── postDetail.js ← 글 상세 + 댓글 │ ├── postWrite.js ← 글쓰기 │ └── postEdit.js ← 글 수정
각 페이지 파일은 하나의 render 함수를 export하고, 그 함수 안에서 HTML을 그리고 이벤트를 등록하는 구조예요. 단순하죠.
3. 기본 파일 설정
package.json
{ "name": "board-app", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", "tailwindcss": "^4.2.2", "vite": "^8.0.4" } }
index.html
<!doctype html> <html lang="ko"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>게시판</title> </head> <body> <div id="app"></div> <script type="module" src="/src/main.js"></script> </body> </html>
핵심은 <div id="app"></div> 입니다. 모든 페이지가 이 div 안에 렌더링됩니다.
src/style.css
@import "tailwindcss";
Tailwind CSS v4에서는 이 한 줄이면 됩니다. tailwind.config.js 파일도 필요 없어요.
4. API 클라이언트 만들기
src/utils/api.js 파일을 만듭니다. 서버와의 모든 통신을 이 파일 하나에 모아둘 거예요.
API 서버 정보
이 튜토리얼에서는 미리 준비된 REST API 서버를 사용합니다.
- 실제 서버 주소:
https://api.fullstackfamily.com - API 경로:
/api/edu/ws-283fc1 - 로컬에서는 Vite 프록시를 통해
/api/edu/ws-283fc1로 접근합니다 (CORS 문제 해결)
API 응답은 { success, message, data } 형태로 구성되어 있어요. 필요한 건 data 부분뿐이니까, fetch 래퍼에서 자동으로 꺼내줄 겁니다.
src/utils/api.js
const BASE_URL = '/api/edu/ws-283fc1' function getToken() { return localStorage.getItem('token') } function setToken(token) { localStorage.setItem('token', token) } function removeToken() { localStorage.removeItem('token') } function getUser() { const user = localStorage.getItem('user') return user ? JSON.parse(user) : null } function setUser(user) { localStorage.setItem('user', JSON.stringify(user)) } function removeUser() { localStorage.removeItem('user') } async function request(path, options = {}) { const url = `${BASE_URL}${path}` const headers = { ...options.headers } const token = getToken() if (token) { headers['Authorization'] = `Bearer ${token}` } if (options.body && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json' options.body = JSON.stringify(options.body) } const response = await fetch(url, { ...options, headers }) if (response.status === 204) { return null } const data = await response.json().catch(() => null) if (!response.ok) { const message = data?.message || `요청 실패 (${response.status})` throw new Error(message) } // API 응답이 { success, message, data } 래퍼로 감싸져 있으면 data만 반환 if (data && data.success !== undefined && data.data !== undefined) { return data.data } return data } // Auth export async function signup(username, password, nickname) { return request('/auth/signup', { method: 'POST', body: { username, password, nickname }, }) } export async function login(username, password) { const data = await request('/auth/login', { method: 'POST', body: { username, password }, }) setToken(data.token) setUser(data.user) return data } export function logout() { removeToken() removeUser() } export function isLoggedIn() { return !!getToken() } export { getUser } // Posts export async function getPosts(page = 0, size = 5) { return request(`/posts?page=${page}&size=${size}`) } export async function getPost(id) { return request(`/posts/${id}`) } export async function createPost(title, content) { // 1. Draft 생성 const draft = await request('/posts', { method: 'POST', body: { title, content }, }) // 2. 바로 발행 const published = await request(`/posts/${draft.id}/publish`, { method: 'PUT', body: { title, content }, }) return published } export async function updatePost(id, title, content) { // 수정 후 다시 발행 await request(`/posts/${id}`, { method: 'PUT', body: { title, content }, }) return request(`/posts/${id}/publish`, { method: 'PUT', body: { title, content }, }) } export async function deletePost(id) { return request(`/posts/${id}`, { method: 'DELETE' }) } // Comments export async function getComments(postId) { return request(`/posts/${postId}/comments`) } export async function createComment(postId, content) { return request(`/posts/${postId}/comments`, { method: 'POST', body: { content }, }) } export async function updateComment(id, content) { return request(`/comments/${id}`, { method: 'PUT', body: { content }, }) } export async function deleteComment(id) { return request(`/comments/${id}`, { method: 'DELETE' }) }
코드가 길어 보이지만, 크게 네 부분으로 나뉩니다.
토큰 관리 (getToken, setToken, removeToken, getUser, setUser, removeUser)
로그인하면 서버가 JWT 토큰을 줍니다. 이걸 localStorage에 저장해두고, 매 요청마다 Authorization 헤더에 실어 보내는 거예요.
사용자 정보(user)도 함께 저장해둡니다. 나중에 "이 글의 작성자가 나인가?" 판별할 때 사용합니다.
fetch 래퍼 (request 함수)
모든 API 호출이 이 함수를 거칩니다. 하는 일은 세 가지예요.
- 토큰이 있으면
Authorization헤더에 자동 추가 - body가 객체면
JSON.stringify처리 - 응답의
{ success, data }래퍼를 벗겨서data만 반환
이렇게 해두면 각 페이지에서 API를 호출할 때 부가적인 처리 없이 데이터만 받을 수 있습니다.
인증 함수 (signup, login, logout, isLoggedIn)
signup: 회원가입 요청login: 로그인 후 토큰과 사용자 정보를 localStorage에 저장logout: localStorage에서 토큰과 사용자 정보를 삭제isLoggedIn: 토큰이 있는지 확인
CRUD 함수
게시글과 댓글의 생성/조회/수정/삭제 함수입니다. 여기서 주목할 점은 createPost와 updatePost예요.
이 API에서는 글 작성이 2단계로 진행됩니다.
- Draft 생성 -
POST /posts로 초안을 만들고 - 발행 -
PUT /posts/{id}/publish로 발행 처리
수정도 마찬가지예요. 내용을 PUT /posts/{id}로 업데이트한 뒤, 다시 PUT /posts/{id}/publish로 발행합니다.
한 가지 더, 이 API에서는 authorId가 null로 반환됩니다. 그래서 "이 글이 내가 쓴 글인가?"를 판별할 때 authorId 대신 authorNickname을 사용합니다.
확인 포인트: 이 파일을 만든 뒤에는 아직 동작을 확인할 수 없어요. 다음 단계에서 라우터를 만들고 페이지를 연결해야 합니다.
5. 해시 라우터 만들기
SPA에서는 페이지 이동을 라우터가 담당합니다. URL이 바뀌면 해당하는 페이지를 렌더링하는 거죠. React Router 같은 라이브러리를 쓸 수도 있지만, 여기서는 직접 만들어 봅니다.
해시 라우팅이란?
URL의 # 뒷부분(해시)을 이용한 라우팅 방식입니다.
http://localhost:5173/#/posts → 글 목록 http://localhost:5173/#/posts/42 → 42번 글 상세 http://localhost:5173/#/login → 로그인
해시가 바뀌면 브라우저가 hashchange 이벤트를 발생시킵니다. 이 이벤트를 감지해서 화면을 바꿔주는 방식이에요. 서버에 추가 설정이 필요 없다는 게 장점입니다.
src/utils/router.js
const routes = {} export function addRoute(path, handler) { routes[path] = handler } export function navigate(path) { window.location.hash = path } export function getParams() { const hash = window.location.hash.slice(1) // # 제거 const parts = hash.split('/') return parts } function matchRoute(hash) { // 정확한 매치 먼저 if (routes[hash]) { return { handler: routes[hash], params: {} } } // 파라미터 매치 (예: /posts/:id) for (const pattern in routes) { const patternParts = pattern.split('/') const hashParts = hash.split('/') if (patternParts.length !== hashParts.length) continue const params = {} let match = true for (let i = 0; i < patternParts.length; i++) { if (patternParts[i].startsWith(':')) { params[patternParts[i].slice(1)] = hashParts[i] } else if (patternParts[i] !== hashParts[i]) { match = false break } } if (match) { return { handler: routes[pattern], params } } } return null } export function startRouter() { function handleRoute() { const hash = window.location.hash.slice(1) || '/posts' const result = matchRoute(hash) if (result) { result.handler(result.params) } else { navigate('/posts') } } window.addEventListener('hashchange', handleRoute) handleRoute() }
코드를 살펴보겠습니다.
addRoute(path, handler)
라우트를 등록하는 함수입니다. 예를 들어 addRoute('/login', renderLogin)을 호출하면, URL 해시가 #/login이 됐을 때 renderLogin 함수가 실행됩니다.
navigate(path)
페이지를 이동하는 함수입니다. navigate('/posts')를 호출하면 window.location.hash가 #/posts로 바뀌고, 자동으로 hashchange 이벤트가 발생하여 해당 페이지가 렌더링됩니다.
matchRoute(hash)
URL 해시와 등록된 라우트를 비교합니다. 여기서 중요한 건 파라미터 매칭이에요.
/posts/:id라는 패턴이 등록되어 있을 때 /posts/42라는 해시가 들어오면, :id 자리에 42가 들어가서 params = { id: '42' } 형태로 전달됩니다.
정확한 매치를 먼저 확인하고, 일치하는 항목이 없으면 파라미터 매치를 시도합니다. 그래서 /posts/write(정확한 매치)와 /posts/:id(파라미터 매치)가 충돌하지 않아요.
startRouter()
hashchange 이벤트 리스너를 등록하고, 현재 해시를 기준으로 첫 화면을 렌더링합니다. 해시가 비어 있으면 기본값으로 /posts(글 목록)로 이동합니다.
확인 포인트: 아직 페이지 파일이 없으니 다음 단계로 넘어갑시다.
6. 메인 진입점
src/main.js
import './style.css' import { addRoute, startRouter } from './utils/router.js' import { renderLogin } from './pages/login.js' import { renderSignup } from './pages/signup.js' import { renderPostList } from './pages/postList.js' import { renderPostDetail } from './pages/postDetail.js' import { renderPostWrite } from './pages/postWrite.js' import { renderPostEdit } from './pages/postEdit.js' // 라우트 등록 addRoute('/login', renderLogin) addRoute('/signup', renderSignup) addRoute('/posts', renderPostList) addRoute('/posts/write', renderPostWrite) addRoute('/posts/:id', renderPostDetail) addRoute('/posts/:id/edit', renderPostEdit) // 라우터 시작 startRouter()
짧은 파일이지만 앱 전체의 진입점이에요. 하는 일은 두 가지입니다.
- 각 경로에 어떤 render 함수를 연결할지 등록
- 라우터를 시작
라우트 등록 순서가 중요합니다. /posts/write가 /posts/:id보다 먼저 등록되어 있죠. matchRoute 함수가 정확한 매치를 먼저 시도하기 때문에 순서와 상관없이 잘 동작하지만, 읽기 좋게 정적 경로를 먼저 배치했어요.
확인 포인트: 아직 페이지 파일들을 만들지 않았으니, 이 상태에서
npm run dev를 실행하면 import 에러가 납니다. 다음 단계에서 하나씩 만들어 갈 거예요.
7. 회원가입 페이지
첫 번째로 만들 페이지는 회원가입입니다. src/pages/ 폴더를 만들고 signup.js 파일을 생성합니다.
src/pages/signup.js
import { signup } from '../utils/api.js' import { navigate } from '../utils/router.js' export function renderSignup() { const app = document.getElementById('app') app.innerHTML = ` <div class="min-h-screen bg-gray-50 flex items-center justify-center p-4"> <div class="bg-white rounded-lg shadow-md p-8 w-full max-w-md"> <h1 class="text-2xl font-bold text-center mb-6">회원가입</h1> <form id="signup-form" class="space-y-4"> <div> <label class="block text-sm font-medium text-gray-700 mb-1">아이디</label> <input type="text" id="username" placeholder="4~20자 영문/숫자" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label> <input type="password" id="password" placeholder="4~20자" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label class="block text-sm font-medium text-gray-700 mb-1">닉네임</label> <input type="text" id="nickname" placeholder="2~20자" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div id="error-msg" class="text-red-500 text-sm hidden"></div> <div id="success-msg" class="text-green-500 text-sm hidden"></div> <button type="submit" class="w-full bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 transition font-medium"> 회원가입 </button> </form> <p class="text-center text-sm text-gray-500 mt-4"> 이미 계정이 있으신가요? <a href="#/login" class="text-blue-500 hover:underline">로그인</a> </p> </div> </div> ` document.getElementById('signup-form').addEventListener('submit', async (e) => { e.preventDefault() const username = document.getElementById('username').value.trim() const password = document.getElementById('password').value.trim() const nickname = document.getElementById('nickname').value.trim() const errorMsg = document.getElementById('error-msg') const successMsg = document.getElementById('success-msg') errorMsg.classList.add('hidden') successMsg.classList.add('hidden') if (!username || !password || !nickname) { errorMsg.textContent = '모든 항목을 입력하세요.' errorMsg.classList.remove('hidden') return } try { await signup(username, password, nickname) successMsg.textContent = '회원가입 완료! 로그인 페이지로 이동합니다.' successMsg.classList.remove('hidden') setTimeout(() => navigate('/login'), 1500) } catch (err) { errorMsg.textContent = err.message errorMsg.classList.remove('hidden') } }) }

패턴을 살펴봅시다. 이후 모든 페이지가 이 패턴을 따릅니다.
app.innerHTML에 HTML을 통째로 넣기 - Tailwind CSS 클래스로 스타일을 입힌 HTML 문자열을innerHTML에 대입합니다.- 이벤트 리스너 등록 - HTML을 넣은 뒤에
getElementById로 요소를 찾아 이벤트를 걸어줍니다. - API 호출은 try-catch - 성공하면 다음 페이지로 이동, 실패하면 에러 메시지를 표시합니다.
회원가입이 성공하면 "회원가입 완료!" 메시지를 보여주고, 1.5초 뒤에 로그인 페이지로 자동 이동합니다.

확인 포인트: 브라우저에서
http://localhost:5173/#/signup으로 접속하면 회원가입 폼이 보여야 합니다. 아이디, 비밀번호, 닉네임을 입력하고 가입해 보세요.
8. 로그인 페이지
src/pages/login.js
import { login } from '../utils/api.js' import { navigate } from '../utils/router.js' export function renderLogin() { const app = document.getElementById('app') app.innerHTML = ` <div class="min-h-screen bg-gray-50 flex items-center justify-center p-4"> <div class="bg-white rounded-lg shadow-md p-8 w-full max-w-md"> <h1 class="text-2xl font-bold text-center mb-6">로그인</h1> <form id="login-form" class="space-y-4"> <div> <label class="block text-sm font-medium text-gray-700 mb-1">아이디</label> <input type="text" id="username" placeholder="아이디를 입력하세요" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label> <input type="password" id="password" placeholder="비밀번호를 입력하세요" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div id="error-msg" class="text-red-500 text-sm hidden"></div> <button type="submit" class="w-full bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 transition font-medium"> 로그인 </button> </form> <p class="text-center text-sm text-gray-500 mt-4"> 계정이 없으신가요? <a href="#/signup" class="text-blue-500 hover:underline">회원가입</a> </p> </div> </div> ` document.getElementById('login-form').addEventListener('submit', async (e) => { e.preventDefault() const username = document.getElementById('username').value.trim() const password = document.getElementById('password').value.trim() const errorMsg = document.getElementById('error-msg') if (!username || !password) { errorMsg.textContent = '아이디와 비밀번호를 입력하세요.' errorMsg.classList.remove('hidden') return } try { await login(username, password) navigate('/posts') } catch (err) { errorMsg.textContent = err.message errorMsg.classList.remove('hidden') } }) }

회원가입 페이지와 거의 같은 구조입니다. 차이점은 login() 함수가 호출되면 내부에서 토큰과 사용자 정보를 localStorage에 저장한다는 점이에요. 로그인 성공 후에는 navigate('/posts')로 글 목록 페이지로 이동합니다.
하단의 "계정이 없으신가요?" 링크를 클릭하면 회원가입 페이지로, 회원가입 페이지의 "이미 계정이 있으신가요?" 링크를 클릭하면 로그인 페이지로 이동합니다. <a href="#/signup">처럼 해시 링크를 사용하면 라우터가 알아서 처리합니다.
확인 포인트: 방금 가입한 계정으로 로그인해 보세요. 로그인 성공 시 글 목록 페이지로 이동하면 됩니다.
9. 게시글 목록 페이지
게시판의 메인 화면이에요. 헤더, 글 목록, 페이지네이션으로 구성됩니다.
src/pages/postList.js
import { getPosts, isLoggedIn, getUser, logout } from '../utils/api.js' import { navigate } from '../utils/router.js' export async function renderPostList() { const app = document.getElementById('app') const user = getUser() const loggedIn = isLoggedIn() app.innerHTML = ` <div class="min-h-screen bg-gray-50"> <!-- 헤더 --> <header class="bg-white shadow-sm"> <div class="max-w-3xl mx-auto px-4 py-4 flex justify-between items-center"> <a href="#/posts" class="text-xl font-bold text-gray-800">게시판</a> <div class="flex items-center gap-3"> ${loggedIn ? `<span class="text-sm text-gray-600">${user?.nickname}님</span> <button id="logout-btn" class="text-sm text-gray-500 hover:text-gray-700">로그아웃</button>` : `<a href="#/login" class="text-sm text-blue-500 hover:underline">로그인</a>` } </div> </div> </header> <!-- 본문 --> <main class="max-w-3xl mx-auto px-4 py-6"> <div class="flex justify-between items-center mb-4"> <h2 class="text-lg font-semibold text-gray-800">글 목록</h2> ${loggedIn ? `<button id="write-btn" class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-600 transition">글쓰기</button>` : '' } </div> <div id="post-list" class="space-y-3"> <p class="text-gray-400 text-center py-8">불러오는 중...</p> </div> <!-- 페이지네이션 --> <div id="pagination" class="flex justify-center gap-2 mt-6"></div> </main> </div> ` // 이벤트 if (loggedIn) { document.getElementById('logout-btn').addEventListener('click', () => { logout() navigate('/posts') }) document.getElementById('write-btn').addEventListener('click', () => { navigate('/posts/write') }) } // 글 목록 로드 await loadPosts(0) } async function loadPosts(page) { const listEl = document.getElementById('post-list') const pagEl = document.getElementById('pagination') try { const data = await getPosts(page, 5) if (data.content.length === 0) { listEl.innerHTML = '<p class="text-gray-400 text-center py-8">게시글이 없습니다.</p>' pagEl.innerHTML = '' return } listEl.innerHTML = data.content.map(post => ` <a href="#/posts/${post.id}" class="block bg-white rounded-lg shadow-sm p-4 hover:shadow-md transition"> <h3 class="font-medium text-gray-800 mb-1">${escapeHtml(post.title || '(제목 없음)')}</h3> <p class="text-sm text-gray-500 mb-2 line-clamp-2">${escapeHtml(post.contentPreview || '')}</p> <div class="flex gap-4 text-xs text-gray-400"> <span>${post.authorNickname}</span> <span>조회 ${post.viewCount}</span> <span>댓글 ${post.commentCount}</span> <span>${formatDate(post.publishedAt || post.createdAt)}</span> </div> </a> `).join('') // 페이지네이션 if (data.totalPages > 1) { let buttons = '' for (let i = 0; i < data.totalPages; i++) { const active = i === data.number buttons += ` <button class="pagination-btn px-3 py-1 rounded text-sm ${active ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-100'}" data-page="${i}"> ${i + 1} </button> ` } pagEl.innerHTML = buttons pagEl.querySelectorAll('.pagination-btn').forEach(btn => { btn.addEventListener('click', () => { loadPosts(Number(btn.dataset.page)) }) }) } else { pagEl.innerHTML = '' } } catch (err) { listEl.innerHTML = `<p class="text-red-500 text-center py-8">${err.message}</p>` } } function escapeHtml(text) { const div = document.createElement('div') div.textContent = text return div.innerHTML } function formatDate(dateStr) { if (!dateStr) return '' const d = new Date(dateStr) return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}` }
이 파일은 이전 페이지들보다 코드 분량이 조금 더 많습니다. 하나씩 살펴보겠습니다.
헤더 (로그인 상태에 따른 분기)
템플릿 리터럴 안에서 삼항 연산자를 씁니다.
${loggedIn ? `<span>${user?.nickname}님</span> <button id="logout-btn">로그아웃</button>` : `<a href="#/login">로그인</a>` }
로그인했으면 닉네임과 로그아웃 버튼을, 안 했으면 로그인 링크를 보여줍니다. 글쓰기 버튼도 로그인한 사용자에게만 보여요.
loadPosts 함수
글 목록과 페이지네이션을 동시에 처리합니다. getPosts(page, 5)를 호출하면 서버가 다음과 같은 형태의 데이터를 반환합니다.
{ "content": [...], // 현재 페이지의 글 배열 "totalPages": 3, // 전체 페이지 수 "number": 0 // 현재 페이지 번호 (0부터 시작) }
content 배열을 map으로 돌려서 HTML로 변환하고, totalPages가 2 이상이면 페이지 버튼을 그립니다. 현재 페이지는 파란색(bg-blue-500 text-white), 나머지는 흰색으로 표시하고요.
페이지 버튼을 클릭하면 loadPosts(페이지번호)를 다시 호출합니다. 전체 페이지를 다시 렌더링하지 않고 글 목록과 페이지네이션 부분만 교체합니다.
escapeHtml과 formatDate
escapeHtml은 XSS 방지용입니다. 사용자가 입력한 텍스트에 <script> 같은 태그가 포함되면 그대로 렌더링되어서는 안 됩니다. DOM의 textContent에 넣었다가 innerHTML로 다시 꺼내면 HTML 이스케이프가 자동으로 됩니다.
formatDate는 ISO 날짜 문자열을 2025.04.12 형식으로 바꿔줍니다.


확인 포인트: 로그인 후 글 목록이 보이고, 헤더에 닉네임과 로그아웃 버튼이 표시되면 됩니다. 로그아웃을 누르면 로그인 링크로 바뀌고, 글쓰기 버튼이 사라져야 합니다.

10. 글쓰기 페이지
src/pages/postWrite.js
import { createPost, isLoggedIn } from '../utils/api.js' import { navigate } from '../utils/router.js' export function renderPostWrite() { if (!isLoggedIn()) { navigate('/login') return } const app = document.getElementById('app') app.innerHTML = ` <div class="min-h-screen bg-gray-50"> <header class="bg-white shadow-sm"> <div class="max-w-3xl mx-auto px-4 py-4 flex justify-between items-center"> <a href="#/posts" class="text-xl font-bold text-gray-800">게시판</a> <a href="#/posts" class="text-sm text-gray-500 hover:text-gray-700">← 목록으로</a> </div> </header> <main class="max-w-3xl mx-auto px-4 py-6"> <div class="bg-white rounded-lg shadow-sm p-6"> <h1 class="text-xl font-bold text-gray-800 mb-4">글쓰기</h1> <form id="write-form" class="space-y-4"> <div> <label class="block text-sm font-medium text-gray-700 mb-1">제목</label> <input type="text" id="title" placeholder="제목을 입력하세요" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label class="block text-sm font-medium text-gray-700 mb-1">내용</label> <textarea id="content" rows="10" placeholder="내용을 입력하세요" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y"></textarea> </div> <div id="error-msg" class="text-red-500 text-sm hidden"></div> <div class="flex gap-2"> <button type="submit" class="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 transition font-medium">등록</button> <a href="#/posts" class="px-6 py-2 rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-50 transition">취소</a> </div> </form> </div> </main> </div> ` document.getElementById('write-form').addEventListener('submit', async (e) => { e.preventDefault() const title = document.getElementById('title').value.trim() const content = document.getElementById('content').value.trim() const errorMsg = document.getElementById('error-msg') if (!title) { errorMsg.textContent = '제목을 입력하세요.' errorMsg.classList.remove('hidden') return } try { const post = await createPost(title, content) navigate(`/posts/${post.id}`) } catch (err) { errorMsg.textContent = err.message errorMsg.classList.remove('hidden') } }) }

맨 위에 로그인 체크가 있습니다.
if (!isLoggedIn()) { navigate('/login') return }
로그인하지 않은 사용자가 URL을 직접 입력해서 글쓰기 페이지에 접근하면 로그인 페이지로 이동시킵니다. 간단하지만 꼭 필요한 처리예요.
Draft에서 Publish로
createPost 함수를 다시 보면 내부적으로 두 번의 API 호출이 일어납니다.
// api.js의 createPost 함수 const draft = await request('/posts', { method: 'POST', body: { title, content } }) const published = await request(`/posts/${draft.id}/publish`, { method: 'PUT', body: { title, content } })
1단계에서 초안(Draft)을 만들고, 2단계에서 발행(Publish)합니다. 이런 구조가 낯설 수 있는데, 임시저장 기능을 지원하는 블로그 플랫폼에서 흔히 쓰는 패턴이에요. 이 튜토리얼에서는 임시저장 없이 바로 발행하니까, 두 단계를 한 함수 안에서 연달아 처리합니다.
글 등록이 성공하면 navigate(/posts/${post.id})로 방금 쓴 글의 상세 페이지로 이동합니다.

확인 포인트: 제목과 내용을 입력하고 등록 버튼을 누르세요. 글 상세 페이지로 이동하면 성공입니다.
11. 상세 조회 페이지
가장 복잡한 페이지입니다. 글 내용, 수정/삭제 버튼, 댓글 목록, 댓글 작성 폼, 댓글 수정/삭제가 모두 포함되어 있습니다.
src/pages/postDetail.js
import { getPost, deletePost, getComments, createComment, updateComment, deleteComment, isLoggedIn, getUser } from '../utils/api.js' import { navigate } from '../utils/router.js' export async function renderPostDetail({ id }) { const app = document.getElementById('app') const user = getUser() const loggedIn = isLoggedIn() app.innerHTML = '<div class="min-h-screen bg-gray-50 flex items-center justify-center"><p class="text-gray-400">불러오는 중...</p></div>' try { const post = await getPost(id) const comments = await getComments(id) const isAuthor = user && user.nickname === post.authorNickname app.innerHTML = ` <div class="min-h-screen bg-gray-50"> <header class="bg-white shadow-sm"> <div class="max-w-3xl mx-auto px-4 py-4 flex justify-between items-center"> <a href="#/posts" class="text-xl font-bold text-gray-800">게시판</a> <a href="#/posts" class="text-sm text-gray-500 hover:text-gray-700">← 목록으로</a> </div> </header> <main class="max-w-3xl mx-auto px-4 py-6"> <!-- 게시글 --> <article class="bg-white rounded-lg shadow-sm p-6 mb-6"> <h1 class="text-2xl font-bold text-gray-800 mb-3">${escapeHtml(post.title)}</h1> <div class="flex gap-4 text-sm text-gray-400 mb-4 pb-4 border-b"> <span>${post.authorNickname}</span> <span>조회 ${post.viewCount}</span> <span>${formatDate(post.publishedAt || post.createdAt)}</span> </div> <div class="text-gray-700 whitespace-pre-wrap leading-relaxed">${escapeHtml(post.content)}</div> ${isAuthor ? ` <div class="flex gap-2 mt-6 pt-4 border-t"> <button id="edit-btn" class="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition">수정</button> <button id="delete-btn" class="px-4 py-2 text-sm bg-red-50 text-red-500 rounded-lg hover:bg-red-100 transition">삭제</button> </div> ` : ''} </article> <!-- 댓글 --> <section class="bg-white rounded-lg shadow-sm p-6"> <h2 class="text-lg font-semibold text-gray-800 mb-4">댓글 (${comments.length})</h2> ${loggedIn ? ` <form id="comment-form" class="flex gap-2 mb-4"> <input type="text" id="comment-input" placeholder="댓글을 입력하세요" class="flex-1 border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> <button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-600 transition">등록</button> </form> ` : '<p class="text-sm text-gray-400 mb-4">댓글을 쓰려면 <a href="#/login" class="text-blue-500 hover:underline">로그인</a>하세요.</p>'} <div id="comment-list" class="space-y-3"> ${comments.length === 0 ? '<p class="text-gray-400 text-sm text-center py-4">댓글이 없습니다.</p>' : comments.map(c => renderComment(c, user)).join('') } </div> </section> </main> </div> ` // 이벤트: 수정/삭제 if (isAuthor) { document.getElementById('edit-btn').addEventListener('click', () => { navigate(`/posts/${id}/edit`) }) document.getElementById('delete-btn').addEventListener('click', async () => { if (confirm('정말 삭제하시겠습니까?')) { await deletePost(id) navigate('/posts') } }) } // 이벤트: 댓글 작성 if (loggedIn) { document.getElementById('comment-form').addEventListener('submit', async (e) => { e.preventDefault() const input = document.getElementById('comment-input') const content = input.value.trim() if (!content) return await createComment(id, content) renderPostDetail({ id }) // 새로고침 }) } // 이벤트: 댓글 수정/삭제 document.getElementById('comment-list').addEventListener('click', async (e) => { const btn = e.target.closest('[data-action]') if (!btn) return const commentId = btn.dataset.commentId const action = btn.dataset.action if (action === 'delete') { if (confirm('댓글을 삭제하시겠습니까?')) { await deleteComment(commentId) renderPostDetail({ id }) } } else if (action === 'edit') { const contentEl = document.getElementById(`comment-content-${commentId}`) const currentText = contentEl.textContent const newText = prompt('댓글을 수정하세요:', currentText) if (newText !== null && newText.trim()) { await updateComment(commentId, newText.trim()) renderPostDetail({ id }) } } }) } catch (err) { app.innerHTML = ` <div class="min-h-screen bg-gray-50 flex items-center justify-center"> <div class="text-center"> <p class="text-red-500 mb-4">${err.message}</p> <a href="#/posts" class="text-blue-500 hover:underline">목록으로 돌아가기</a> </div> </div> ` } } function renderComment(comment, user) { const isAuthor = user && user.nickname === comment.authorNickname return ` <div class="flex justify-between items-start py-3 border-b border-gray-100 last:border-0"> <div class="flex-1"> <div class="flex gap-2 items-center mb-1"> <span class="text-sm font-medium text-gray-700">${escapeHtml(comment.authorNickname)}</span> <span class="text-xs text-gray-400">${formatDate(comment.createdAt)}</span> </div> <p id="comment-content-${comment.id}" class="text-sm text-gray-600">${escapeHtml(comment.content)}</p> </div> ${isAuthor ? ` <div class="flex gap-1 ml-2"> <button data-action="edit" data-comment-id="${comment.id}" class="text-xs text-gray-400 hover:text-blue-500">수정</button> <button data-action="delete" data-comment-id="${comment.id}" class="text-xs text-gray-400 hover:text-red-500">삭제</button> </div> ` : ''} </div> ` } function escapeHtml(text) { const div = document.createElement('div') div.textContent = text return div.innerHTML } function formatDate(dateStr) { if (!dateStr) return '' const d = new Date(dateStr) return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}` }
길지만 구조는 단순합니다. 나눠서 살펴보겠습니다.
라우트 파라미터 받기
export async function renderPostDetail({ id }) {
함수 파라미터로 { id }를 받습니다. 라우터의 matchRoute 함수가 /posts/:id 패턴에서 추출한 id를 전달합니다. URL이 #/posts/42이면 id는 '42'입니다.
작성자 판별
const isAuthor = user && user.nickname === post.authorNickname
로그인한 사용자의 닉네임과 글 작성자의 닉네임을 비교합니다. 같으면 수정/삭제 버튼이 나타납니다.
댓글 이벤트 위임
댓글 수정/삭제 버튼의 이벤트 처리에 이벤트 위임(Event Delegation) 패턴을 적용했습니다.
document.getElementById('comment-list').addEventListener('click', async (e) => { const btn = e.target.closest('[data-action]') if (!btn) return // ... })
각 댓글 버튼에 일일이 이벤트를 거는 대신, 부모 요소(comment-list)에 한 번만 등록하고 클릭된 요소를 확인하는 방식입니다. 댓글이 몇 개든 이벤트 리스너는 하나만 필요합니다.
data-action과 data-comment-id라는 data 속성으로 어떤 댓글의 어떤 버튼이 클릭됐는지 구분합니다.
댓글 작성 후 새로고침
await createComment(id, content) renderPostDetail({ id }) // 새로고침
댓글을 등록한 뒤 renderPostDetail을 다시 호출해서 페이지 전체를 다시 그립니다. 댓글 목록만 부분적으로 업데이트하는 것보다 구현이 간단하고 잘 동작합니다.


확인 포인트: 글 상세 페이지에서 댓글을 작성해 보세요. 등록 즉시 댓글 목록에 나타나면 됩니다. 자기가 쓴 댓글에만 수정/삭제 버튼이 보여야 합니다.
12. 글 수정 페이지
src/pages/postEdit.js
import { getPost, updatePost, isLoggedIn } from '../utils/api.js' import { navigate } from '../utils/router.js' export async function renderPostEdit({ id }) { if (!isLoggedIn()) { navigate('/login') return } const app = document.getElementById('app') app.innerHTML = '<div class="min-h-screen bg-gray-50 flex items-center justify-center"><p class="text-gray-400">불러오는 중...</p></div>' try { const post = await getPost(id) app.innerHTML = ` <div class="min-h-screen bg-gray-50"> <header class="bg-white shadow-sm"> <div class="max-w-3xl mx-auto px-4 py-4 flex justify-between items-center"> <a href="#/posts" class="text-xl font-bold text-gray-800">게시판</a> <a href="#/posts/${id}" class="text-sm text-gray-500 hover:text-gray-700">← 돌아가기</a> </div> </header> <main class="max-w-3xl mx-auto px-4 py-6"> <div class="bg-white rounded-lg shadow-sm p-6"> <h1 class="text-xl font-bold text-gray-800 mb-4">글 수정</h1> <form id="edit-form" class="space-y-4"> <div> <label class="block text-sm font-medium text-gray-700 mb-1">제목</label> <input type="text" id="title" value="${escapeAttr(post.title)}" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label class="block text-sm font-medium text-gray-700 mb-1">내용</label> <textarea id="content" rows="10" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y">${escapeHtml(post.content)}</textarea> </div> <div id="error-msg" class="text-red-500 text-sm hidden"></div> <div class="flex gap-2"> <button type="submit" class="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 transition font-medium">수정 완료</button> <a href="#/posts/${id}" class="px-6 py-2 rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-50 transition">취소</a> </div> </form> </div> </main> </div> ` document.getElementById('edit-form').addEventListener('submit', async (e) => { e.preventDefault() const title = document.getElementById('title').value.trim() const content = document.getElementById('content').value.trim() const errorMsg = document.getElementById('error-msg') if (!title) { errorMsg.textContent = '제목을 입력하세요.' errorMsg.classList.remove('hidden') return } try { await updatePost(id, title, content) navigate(`/posts/${id}`) } catch (err) { errorMsg.textContent = err.message errorMsg.classList.remove('hidden') } }) } catch (err) { app.innerHTML = ` <div class="min-h-screen bg-gray-50 flex items-center justify-center"> <p class="text-red-500">${err.message}</p> </div> ` } } function escapeHtml(text) { const div = document.createElement('div') div.textContent = text || '' return div.innerHTML } function escapeAttr(text) { return (text || '').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>') }

글쓰기 페이지와 거의 같은 구조이지만, 두 가지 차이점이 있습니다.
기존 데이터 채우기
먼저 getPost(id)로 기존 글을 불러와서 폼에 채워 넣습니다.
<input type="text" id="title" value="${escapeAttr(post.title)}" ... /> <textarea id="content" ...>${escapeHtml(post.content)}</textarea>
input의 value 속성에는 escapeAttr을, textarea 안의 텍스트에는 escapeHtml을 씁니다. 둘 다 XSS를 방지하는 함수이지만, 사용되는 위치가 다릅니다.
escapeAttr: HTML 속성 안에서 쓰이므로"를"로 바꿔야 합니다escapeHtml: HTML 텍스트 안에서 쓰이므로<와>를 이스케이프합니다
updatePost의 내부 동작
수정도 글 작성과 마찬가지로 2단계입니다.
// api.js의 updatePost 함수 await request(`/posts/${id}`, { method: 'PUT', body: { title, content } }) return request(`/posts/${id}/publish`, { method: 'PUT', body: { title, content } })
내용을 업데이트한 뒤 다시 발행 처리를 합니다. 수정 완료 후에는 해당 글의 상세 페이지로 이동합니다.

확인 포인트: 자기가 쓴 글의 상세 페이지에서 수정 버튼을 누르면 수정 폼이 나타납니다. 제목이나 내용을 바꾸고 "수정 완료"를 누르면 변경된 내용이 반영되어야 합니다.
13. 글 삭제
별도의 페이지가 필요하지 않습니다. 상세 페이지(postDetail.js)에서 처리하기 때문입니다.
document.getElementById('delete-btn').addEventListener('click', async () => { if (confirm('정말 삭제하시겠습니까?')) { await deletePost(id) navigate('/posts') } })
confirm 대화상자로 한 번 확인한 뒤, deletePost(id)를 호출하고 글 목록 페이지로 돌아갑니다.

확인 포인트: 자기가 쓴 글의 상세 페이지에서 삭제 버튼을 눌러 보세요. 확인 대화상자가 뜨고, 확인을 누르면 글 목록으로 돌아오면서 해당 글이 사라져 있어야 합니다.
14. Vite 프록시 설정
마지막으로 반드시 설정해야 할 항목이 하나 있습니다. CORS 문제 해결입니다.
브라우저에서 localhost:5173(Vite 개발 서버)이 api.fullstackfamily.com(API 서버)에 직접 요청을 보내면 CORS 에러가 발생합니다. 도메인이 다르기 때문입니다.
해결 방법은 Vite의 프록시 기능을 사용하는 것입니다. 브라우저는 같은 도메인(localhost:5173)의 /api로 요청을 보내고, Vite 개발 서버가 그 요청을 실제 API 서버로 전달합니다.
vite.config.js
import { defineConfig } from 'vite' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [ tailwindcss(), ], server: { proxy: { '/api': { target: 'https://api.fullstackfamily.com', changeOrigin: true, }, }, }, })
server.proxy 설정을 보면, /api로 시작하는 모든 요청을 https://api.fullstackfamily.com으로 전달합니다. changeOrigin: true는 요청의 Origin 헤더를 대상 서버의 주소로 변경하는 옵션입니다.
이 설정 덕분에 api.js에서 BASE_URL을 /api/edu/ws-283fc1처럼 상대 경로로 쓸 수 있습니다.
브라우저 → localhost:5173/api/edu/ws-283fc1/posts ↓ (Vite 프록시) → api.fullstackfamily.com/api/edu/ws-283fc1/posts
이 설정은 개발 환경에서만 동작합니다. 프로덕션 배포할 때는 Nginx나 다른 웹서버에서 프록시 설정을 따로 해줘야 합니다.
확인 포인트:
npm run dev로 개발 서버를 실행했을 때 API 호출이 정상 동작하면 프록시 설정이 제대로 된 겁니다. 브라우저 개발자 도구의 Network 탭에서 요청이localhost:5173/api/...로 나가는 걸 확인해 보세요.
마무리
프레임워크 없이 순수 JavaScript로 게시판을 만들어 봤습니다. 전체 코드는 12개 파일, 약 500줄 정도입니다.
정리하면 이런 내용을 다뤘습니다.
- Vite - 빌드 도구와 개발 서버, 프록시 설정
- Tailwind CSS v4 - 유틸리티 클래스 기반 스타일링
- 해시 라우터 -
hashchange이벤트를 이용한 SPA 라우팅 - fetch 래퍼 - 토큰 관리, 응답 언래핑, 에러 처리를 한 곳에 모으기
- JWT 인증 - 로그인/로그아웃, 토큰 기반 인증
- CRUD - 게시글과 댓글의 생성/조회/수정/삭제
- 이벤트 위임 - 동적으로 생성되는 요소의 이벤트 처리
- XSS 방지 -
escapeHtml,escapeAttr함수로 사용자 입력 이스케이프
React나 Vue를 배우기 전에 이런 패턴을 한 번 직접 구현해보면 좋습니다. 프레임워크가 어떤 문제를 해결하는지, 왜 필요한지를 직접 체감할 수 있습니다. innerHTML로 HTML 문자열을 직접 조합하다 보면 "상태가 바뀔 때마다 화면을 다시 그리는 게 너무 번거롭다"는 걸 느끼게 될 거예요. 그 불편함이 React의 Virtual DOM이나 Vue의 반응성 시스템이 존재하는 이유입니다.
이 프로젝트를 기반으로 추가로 구현해 볼 만한 기능도 있습니다.
- 글 검색 기능 추가
- 마크다운 에디터 연동
- 이미지 업로드
- 무한 스크롤 방식의 목록
소스 코드 전체는 GitHub에서 확인할 수 있습니다.






댓글
댓글을 작성하려면 이 필요합니다.