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

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

이전에 같은 앱을 JavaScript 버전으로 만들었는데요, 이번에는 TypeScript로 다시 작성합니다. 기능은 완전히 동일하고, 타입 시스템을 입히는 것만 다릅니다.
왜 굳이 같은 걸 TypeScript로 다시 만들까요? JavaScript 버전에서는 함수 파라미터에 뭐가 들어오는지, API 응답이 어떤 모양인지 코드를 읽어봐야 알 수 있었거든요. TypeScript를 쓰면 에디터가 알려줍니다. 자동완성도 되고, 오타를 내면 빌드 전에 잡아주고요.
시작하기 전에
아래 두 가지가 설치되어 있어야 합니다.
- Node.js (v18 이상) - nodejs.org에서 다운로드
- VS Code 또는 익숙한 코드 편집기
터미널에서 버전을 확인해 보세요.
node -v # v18.0.0 이상 npm -v # 9.0.0 이상
JavaScript 버전 튜토리얼을 먼저 따라해 본 분이라면 구조가 익숙할 거예요. 처음이라도 괜찮습니다. 하나씩 만들어 가면 되니까요.
1. 프로젝트 생성
터미널을 열고 프로젝트를 만들겠습니다.
npm create vite@latest board-app-ts
선택지가 나오면 이렇게 골라주세요.
- framework:
Vanilla - variant:
TypeScript
JavaScript 버전에서는 Vanilla + JavaScript를 선택했는데, 이번에는 TypeScript를 선택합니다. 이것만 다르고 나머지 과정은 거의 같아요.
프로젝트 폴더로 이동해서 의존성을 설치합니다.
cd board-app-ts npm install
Tailwind CSS 설치
Tailwind CSS v4와 Vite 플러그인을 설치합니다.
npm install -D tailwindcss @tailwindcss/vite
Vite가 생성한 기본 파일 중 불필요한 파일을 정리합니다. counter.ts, typescript.svg, style.css 내용, main.ts 내용을 지워주세요. 이제부터 하나씩 새로 작성할 거예요.
확인 포인트:
npm run dev를 실행했을 때 브라우저에 빈 페이지가 나타나면 정상입니다.
2. 프로젝트 구조
완성된 프로젝트의 파일 구조는 이렇습니다.
board-app-ts/ ├── index.html ├── vite.config.ts ├── tsconfig.json ├── package.json ├── src/ │ ├── main.ts ← 라우터 설정 + 진입점 │ ├── style.css ← Tailwind CSS 임포트 │ ├── utils/ │ │ ├── types.ts ← 타입 정의 (TypeScript 버전에만 있음) │ │ ├── api.ts ← API 클라이언트 │ │ └── router.ts ← 해시 기반 라우터 │ └── pages/ │ ├── login.ts ← 로그인 │ ├── signup.ts ← 회원가입 │ ├── postList.ts ← 글 목록 + 페이지네이션 │ ├── postDetail.ts ← 글 상세 + 댓글 │ ├── postWrite.ts ← 글쓰기 │ └── postEdit.ts ← 글 수정
JavaScript 버전과 비교하면 파일이 하나 늘었습니다. src/utils/types.ts가 추가됐어요. 모든 데이터 구조를 인터페이스로 정의하는 파일입니다. 나머지 파일은 .js가 .ts로 바뀐 것뿐이에요.
각 페이지 파일은 하나의 render 함수를 export하고, 그 함수 안에서 HTML을 그리고 이벤트를 등록하는 구조예요. JavaScript 버전과 동일합니다.
3. 기본 파일 설정
package.json
{ "name": "board-app-ts", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", "tailwindcss": "^4.2.2", "typescript": "~6.0.2", "vite": "^8.0.4" } }
JavaScript 버전과 다른 점 두 가지가 보이죠.
"build"스크립트에tsc &&가 붙었습니다. 빌드 전에 타입 검사를 먼저 합니다.devDependencies에typescript가 추가됐습니다.
tsconfig.json
TypeScript 프로젝트에는 tsconfig.json이 필요합니다. npm create vite 명령이 자동으로 만들어 주지만, 내용을 확인해 볼게요.
{ "compilerOptions": { "target": "es2023", "module": "esnext", "lib": ["ES2023", "DOM", "DOM.Iterable"], "types": ["vite/client"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, /* Linting */ "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, "include": ["src"] }
Vite가 생성한 기본 설정 그대로 사용합니다. "lib": ["DOM"]이 있어서 document, window, HTMLElement 같은 브라우저 API 타입을 쓸 수 있고, "noUnusedLocals": true가 있어서 사용하지 않는 변수가 있으면 에러가 발생합니다.
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.ts"></script> </body> </html>
핵심은 <div id="app"></div> 입니다. 모든 페이지가 이 div 안에 렌더링됩니다. <script> 태그의 src가 main.ts인 것 외에는 JavaScript 버전과 동일해요.
src/style.css
@import "tailwindcss";
Tailwind CSS v4에서는 이 한 줄이면 됩니다. tailwind.config.js 파일도 필요 없어요.
4. 타입 정의 — types.ts
여기가 이번 튜토리얼의 핵심입니다. JavaScript 버전에는 없던 파일이에요.
API와 주고받는 데이터 구조를 모두 인터페이스로 정의합니다. 이걸 만들어 두면 나머지 파일에서 "이 변수에 뭐가 들어 있지?" 하고 고민할 일이 없어요. 에디터가 자동완성으로 알려주거든요.
src/utils/types.ts
// 사용자 export interface User { id: number username: string nickname: string createdAt: string } // 로그인 응답 export interface LoginResponse { token: string user: User } // API 래퍼 응답 export interface ApiResponse<T> { success: boolean message: string data: T } // 게시글 목록 항목 export interface PostSummary { id: number title: string contentPreview: string authorNickname: string viewCount: number commentCount: number thumbnailUrl: string | null publishedAt: string | null createdAt: string } // 게시글 상세 export interface Post { id: number title: string content: string status: 'DRAFT' | 'PUBLISHED' authorId: number | null authorNickname: string viewCount: number commentCount: number images: PostImage[] publishedAt: string | null createdAt: string } // 게시글 이미지 export interface PostImage { id: number imageUrl: string originalFilename: string fileSize: number createdAt: string } // 페이지네이션 응답 export interface PageResponse<T> { content: T[] totalElements: number totalPages: number number: number size: number } // 댓글 export interface Comment { id: number content: string authorId: number authorNickname: string createdAt: string updatedAt: string } // 라우터 파라미터 export interface RouteParams { [key: string]: string } // 라우트 핸들러 export type RouteHandler = (params: RouteParams) => void | Promise<void>
몇 가지 포인트를 짚어 볼게요.
ApiResponse<T> — 서버의 모든 응답은 { success, message, data } 형태로 감싸져 있습니다. data의 타입만 다르니까 제네릭 T로 표현합니다. 글 목록을 조회하면 ApiResponse<PageResponse<PostSummary>>, 글 하나를 조회하면 ApiResponse<Post>가 되는 식이에요.
PageResponse<T> — 페이지네이션 응답도 제네릭입니다. content 배열에 들어가는 항목의 타입이 다를 수 있으니까요.
status: 'DRAFT' | 'PUBLISHED' — 문자열 리터럴 유니온 타입입니다. status에 'ACTIVE' 같은 엉뚱한 값을 넣으면 컴파일 에러가 나요.
RouteHandler — 라우터에서 각 페이지를 렌더링하는 함수의 타입입니다. 파라미터 객체를 받고, 동기(void)든 비동기(Promise<void>)든 반환할 수 있어요.
5. API 클라이언트 — api.ts
서버와의 모든 통신을 이 파일 하나에 모아둡니다.
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.ts
import type { User, LoginResponse, Post, PostSummary, PageResponse, Comment, ApiResponse } from './types' const BASE_URL = '/api/edu/ws-283fc1' // === 토큰/유저 관리 === function getToken(): string | null { return localStorage.getItem('token') } function setToken(token: string): void { localStorage.setItem('token', token) } function removeToken(): void { localStorage.removeItem('token') } export function getUser(): User | null { const user = localStorage.getItem('user') return user ? JSON.parse(user) as User : null } function setUser(user: User): void { localStorage.setItem('user', JSON.stringify(user)) } function removeUser(): void { localStorage.removeItem('user') } export function isLoggedIn(): boolean { return !!getToken() } // === HTTP 요청 === interface RequestOptions { method?: string headers?: Record<string, string> body?: unknown } async function request<T>(path: string, options: RequestOptions = {}): Promise<T> { const url = `${BASE_URL}${path}` const headers: Record<string, string> = { ...options.headers } const token = getToken() if (token) { headers['Authorization'] = `Bearer ${token}` } const fetchOptions: RequestInit = { method: options.method, headers, } if (options.body && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json' fetchOptions.body = JSON.stringify(options.body) } const response = await fetch(url, fetchOptions) if (response.status === 204) { return null as T } 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 as ApiResponse<T>).success !== undefined && (data as ApiResponse<T>).data !== undefined) { return (data as ApiResponse<T>).data } return data as T } // === Auth === export async function signup(username: string, password: string, nickname: string): Promise<User> { return request<User>('/auth/signup', { method: 'POST', body: { username, password, nickname }, }) } export async function login(username: string, password: string): Promise<LoginResponse> { const data = await request<LoginResponse>('/auth/login', { method: 'POST', body: { username, password }, }) setToken(data.token) setUser(data.user) return data } export function logout(): void { removeToken() removeUser() } // === Posts === export async function getPosts(page: number = 0, size: number = 5): Promise<PageResponse<PostSummary>> { return request<PageResponse<PostSummary>>(`/posts?page=${page}&size=${size}`) } export async function getPost(id: number | string): Promise<Post> { return request<Post>(`/posts/${id}`) } export async function createPost(title: string, content: string): Promise<Post> { // 1. Draft 생성 const draft = await request<Post>('/posts', { method: 'POST', body: { title, content }, }) // 2. 바로 발행 const published = await request<Post>(`/posts/${draft.id}/publish`, { method: 'PUT', body: { title, content }, }) return published } export async function updatePost(id: number | string, title: string, content: string): Promise<Post> { await request<Post>(`/posts/${id}`, { method: 'PUT', body: { title, content }, }) return request<Post>(`/posts/${id}/publish`, { method: 'PUT', body: { title, content }, }) } export async function deletePost(id: number | string): Promise<null> { return request<null>(`/posts/${id}`, { method: 'DELETE' }) } // === Comments === export async function getComments(postId: number | string): Promise<Comment[]> { return request<Comment[]>(`/posts/${postId}/comments`) } export async function createComment(postId: number | string, content: string): Promise<Comment> { return request<Comment>(`/posts/${postId}/comments`, { method: 'POST', body: { content }, }) } export async function updateComment(id: number | string, content: string): Promise<Comment> { return request<Comment>(`/comments/${id}`, { method: 'PUT', body: { content }, }) } export async function deleteComment(id: number | string): Promise<null> { return request<null>(`/comments/${id}`, { method: 'DELETE' }) }
JavaScript 버전과 비교하면서 달라진 부분을 살펴볼게요.
제네릭 request 함수
가장 큰 변화는 request 함수입니다.
// JavaScript 버전 async function request(path, options = {}) { ... } // TypeScript 버전 async function request<T>(path: string, options: RequestOptions = {}): Promise<T> { ... }
<T>가 제네릭 타입 파라미터입니다. 이 함수를 호출할 때 "응답 데이터의 타입이 뭔지" 알려주는 거예요. 예를 들어:
// T가 Post로 결정됨 → 반환 타입은 Promise<Post> const post = await request<Post>(`/posts/${id}`) // T가 PageResponse<PostSummary>로 결정됨 const data = await request<PageResponse<PostSummary>>(`/posts?page=0&size=5`)
이렇게 하면 post.title, data.content[0].authorNickname 같은 코드에서 자동완성이 됩니다. JavaScript에서는 API 문서를 번갈아 참조하며 필드 이름을 확인해야 했는데, 그럴 필요가 없어집니다.
함수 시그니처에 타입 붙이기
모든 함수에 파라미터 타입과 반환 타입이 붙었습니다.
// JavaScript export function getUser() { ... } export function isLoggedIn() { ... } // TypeScript export function getUser(): User | null { ... } export function isLoggedIn(): boolean { ... }
getUser()의 반환 타입이 User | null이라는 점을 알 수 있으므로, 호출하는 쪽에서 null 확인을 누락하면 컴파일러가 알려줍니다.
RequestOptions 인터페이스
options 파라미터의 타입도 따로 정의했습니다.
interface RequestOptions { method?: string headers?: Record<string, string> body?: unknown }
body의 타입이 unknown인 이유는, 로그인 정보({ username, password }), 글 데이터({ title, content }), FormData 등 여러 형태가 들어올 수 있기 때문이에요. any를 쓸 수도 있지만, unknown이 더 안전합니다. unknown은 "아직 뭔지 모르니까 사용하기 전에 타입을 확인하라"는 뜻이거든요.
import type
파일 상단에 import type을 사용한 것도 주목해 주세요.
import type { User, LoginResponse, Post, PostSummary, PageResponse, Comment, ApiResponse } from './types'
import type은 "이건 타입 정보만 가져온다"는 뜻입니다. 빌드할 때 완전히 사라지고, JavaScript 번들에 포함되지 않아요. 런타임 성능에 영향이 없습니다.
6. 해시 라우터 — router.ts
SPA에서 페이지 전환을 담당하는 간단한 해시 라우터입니다.
src/utils/router.ts
import type { RouteHandler, RouteParams } from './types' const routes: Record<string, RouteHandler> = {} export function addRoute(path: string, handler: RouteHandler): void { routes[path] = handler } export function navigate(path: string): void { window.location.hash = path } interface MatchResult { handler: RouteHandler params: RouteParams } function matchRoute(hash: string): MatchResult | null { // 정확한 매치 먼저 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: RouteParams = {} 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(): void { function handleRoute(): void { 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() }
JavaScript 버전과 로직은 동일하며, 타입이 추가된 부분만 다릅니다.
const routes: Record<string, RouteHandler> = {}
Record<string, RouteHandler>는 "키가 string이고 값이 RouteHandler인 객체"라는 뜻이에요. routes['/posts'] = renderPostList 같은 코드에서 renderPostList가 RouteHandler 타입과 맞지 않으면 에러가 납니다.
MatchResult 인터페이스도 주목해 주세요. matchRoute 함수가 반환하는 값의 구조를 명확하게 정의했어요.
interface MatchResult { handler: RouteHandler params: RouteParams } function matchRoute(hash: string): MatchResult | null { ... }
반환 타입이 MatchResult | null이므로, 호출하는 쪽에서 null 체크를 해야 합니다. JavaScript에서는 간과하기 쉬운 부분이지만, TypeScript가 이를 미연에 방지해 줍니다.
7. 메인 진입점 — main.ts
src/main.ts
import './style.css' import { addRoute, startRouter } from './utils/router' import { renderLogin } from './pages/login' import { renderSignup } from './pages/signup' import { renderPostList } from './pages/postList' import { renderPostDetail } from './pages/postDetail' import { renderPostWrite } from './pages/postWrite' import { renderPostEdit } from './pages/postEdit' // 라우트 등록 addRoute('/login', renderLogin) addRoute('/signup', renderSignup) addRoute('/posts', renderPostList) addRoute('/posts/write', renderPostWrite) addRoute('/posts/:id', renderPostDetail) addRoute('/posts/:id/edit', renderPostEdit) // 라우터 시작 startRouter()
JavaScript 버전과 거의 같습니다. import 경로에서 .js 확장자가 생략된 점이 유일한 차이입니다(TypeScript에서는 확장자를 생략합니다).
여기서 타입 시스템의 힘이 조용히 발휘됩니다. addRoute의 두 번째 파라미터가 RouteHandler 타입이므로, 각 render 함수의 시그니처가 (params: RouteParams) => void | Promise<void>와 맞아야 해요. 만약 renderLogin의 파라미터를 (params: string)으로 잘못 정의하면 여기서 에러가 납니다.
8. 회원가입 페이지
src/pages/signup.ts
import { signup } from '../utils/api' import { navigate } from '../utils/router' export function renderSignup(): void { const app = document.getElementById('app') as HTMLDivElement 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> ` const form = document.getElementById('signup-form') as HTMLFormElement form.addEventListener('submit', async (e: Event) => { e.preventDefault() const username = (document.getElementById('username') as HTMLInputElement).value.trim() const password = (document.getElementById('password') as HTMLInputElement).value.trim() const nickname = (document.getElementById('nickname') as HTMLInputElement).value.trim() const errorMsg = document.getElementById('error-msg') as HTMLDivElement const successMsg = document.getElementById('success-msg') as HTMLDivElement 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 as Error).message errorMsg.classList.remove('hidden') } }) }

TypeScript로 DOM을 다룰 때 자주 등장하는 패턴입니다. 하나씩 살펴보겠습니다.
타입 단언 (Type Assertion)
const app = document.getElementById('app') as HTMLDivElement
document.getElementById()의 반환 타입은 HTMLElement | null입니다. 하지만 우리는 이 요소가 <div>라는 걸 알고 있죠. as HTMLDivElement를 붙여서 TypeScript에게 알려줍니다.
input 요소도 마찬가지예요.
const username = (document.getElementById('username') as HTMLInputElement).value.trim()
HTMLInputElement로 단언해야 .value 속성에 접근할 수 있습니다. 그냥 HTMLElement에는 value 속성이 없거든요.
에러 처리
catch (err) { errorMsg.textContent = (err as Error).message }
TypeScript의 catch 절에서 err의 타입은 unknown입니다. .message에 접근하려면 Error 타입이라고 단언해야 해요. JavaScript에서는 그냥 err.message라고 쓰면 됐는데, TypeScript에서는 한 단계 더 필요합니다. 다소 번거롭지만, 그만큼 코드가 안전해집니다.
9. 로그인 페이지
src/pages/login.ts
import { login } from '../utils/api' import { navigate } from '../utils/router' export function renderLogin(): void { const app = document.getElementById('app') as HTMLDivElement 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> ` const form = document.getElementById('login-form') as HTMLFormElement form.addEventListener('submit', async (e: Event) => { e.preventDefault() const username = (document.getElementById('username') as HTMLInputElement).value.trim() const password = (document.getElementById('password') as HTMLInputElement).value.trim() const errorMsg = document.getElementById('error-msg') as HTMLDivElement if (!username || !password) { errorMsg.textContent = '아이디와 비밀번호를 입력하세요.' errorMsg.classList.remove('hidden') return } try { await login(username, password) navigate('/posts') } catch (err) { errorMsg.textContent = (err as Error).message errorMsg.classList.remove('hidden') } }) }

회원가입 페이지와 구조가 거의 같습니다. login() 함수를 호출하면 api.ts에서 토큰과 유저 정보를 localStorage에 저장하고, 성공하면 navigate('/posts')로 글 목록 페이지로 이동해요.
10. 글 목록 + 페이지네이션
src/pages/postList.ts
import { getPosts, isLoggedIn, getUser, logout } from '../utils/api' import { navigate } from '../utils/router' import type { PostSummary } from '../utils/types' export async function renderPostList(): Promise<void> { const app = document.getElementById('app') as HTMLDivElement 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-blue-500 hover:underline cursor-pointer">로그아웃</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') as HTMLButtonElement).addEventListener('click', () => { logout() navigate('/posts') }); (document.getElementById('write-btn') as HTMLButtonElement).addEventListener('click', () => { navigate('/posts/write') }) } await loadPosts(0) } async function loadPosts(page: number): Promise<void> { const listEl = document.getElementById('post-list') as HTMLDivElement const pagEl = document.getElementById('pagination') as HTMLDivElement 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: PostSummary) => ` <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<HTMLButtonElement>('.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 as Error).message}</p>` } } function escapeHtml(text: string): string { const div = document.createElement('div') div.textContent = text return div.innerHTML } function formatDate(dateStr: string): string { if (!dateStr) return '' const d = new Date(dateStr) return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}` }

이 파일에서 주목할 만한 TypeScript 활용 포인트를 몇 가지 살펴보겠습니다.
async 함수의 반환 타입
export async function renderPostList(): Promise<void> { ... }
JavaScript에서는 그냥 async function renderPostList() 였는데, TypeScript에서는 반환 타입을 Promise<void>로 명시합니다. async 함수는 항상 Promise를 반환하니까요.
querySelectorAll에 제네릭 사용
pagEl.querySelectorAll<HTMLButtonElement>('.pagination-btn').forEach((btn) => { btn.addEventListener('click', () => { loadPosts(Number(btn.dataset.page)) }) })
querySelectorAll도 제네릭을 지원합니다. <HTMLButtonElement>를 지정하면 btn의 타입이 HTMLButtonElement로 확정되므로, btn.dataset에 바로 접근할 수 있습니다.
옵셔널 체이닝과 타입
<span class="text-sm text-gray-600">${user?.nickname}님</span>
user의 타입이 User | null로 정의되어 있으므로, 옵셔널 체이닝(user?.nickname)으로 안전하게 접근합니다. user가 null이면 undefined가 반환되며, 템플릿 리터럴 내에서는 빈 문자열로 처리됩니다.
페이지네이션

getPosts 함수의 반환 타입이 Promise<PageResponse<PostSummary>>로 지정되어 있어, data.totalPages, data.number, data.content 같은 필드에서 자동완성 기능을 활용할 수 있습니다. 페이지네이션 로직을 구현할 때 정확한 필드명을 일일이 기억하지 않아도 혼동할 일이 없습니다.
11. 글쓰기 (Draft → Publish)
src/pages/postWrite.ts
import { createPost, isLoggedIn } from '../utils/api' import { navigate } from '../utils/router' export function renderPostWrite(): void { if (!isLoggedIn()) { navigate('/login') return } const app = document.getElementById('app') as HTMLDivElement 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> ` const form = document.getElementById('write-form') as HTMLFormElement form.addEventListener('submit', async (e: Event) => { e.preventDefault() const title = (document.getElementById('title') as HTMLInputElement).value.trim() const content = (document.getElementById('content') as HTMLTextAreaElement).value.trim() const errorMsg = document.getElementById('error-msg') as HTMLDivElement 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 as Error).message errorMsg.classList.remove('hidden') } }) }

글쓰기에서 주목할 점은 textarea 요소의 타입 단언입니다.
const content = (document.getElementById('content') as HTMLTextAreaElement).value.trim()
<textarea>는 HTMLTextAreaElement이고, <input>은 HTMLInputElement입니다. 둘 다 .value 속성이 있지만 타입이 다릅니다. TypeScript에서는 구분해서 써야 해요.
createPost 함수는 내부적으로 두 번의 API 호출을 합니다. Draft를 먼저 만들고, 바로 Publish하는 방식이에요. api.ts에서 이 로직을 처리하기 때문에 페이지 코드에서는 createPost(title, content) 한 번만 호출하면 됩니다.
12. 글 상세 + 댓글
src/pages/postDetail.ts
import { getPost, deletePost, getComments, createComment, updateComment, deleteComment, isLoggedIn, getUser } from '../utils/api' import { navigate } from '../utils/router' import type { RouteParams, Comment, User } from '../utils/types' export async function renderPostDetail({ id }: RouteParams): Promise<void> { const app = document.getElementById('app') as HTMLDivElement 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 !== null && 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: Comment) => renderComment(c, user)).join('') } </div> </section> </main> </div> ` if (isAuthor) { (document.getElementById('edit-btn') as HTMLButtonElement).addEventListener('click', () => { navigate(`/posts/${id}/edit`) }); (document.getElementById('delete-btn') as HTMLButtonElement).addEventListener('click', async () => { if (confirm('정말 삭제하시겠습니까?')) { await deletePost(id) navigate('/posts') } }) } if (loggedIn) { (document.getElementById('comment-form') as HTMLFormElement).addEventListener('submit', async (e: Event) => { e.preventDefault() const input = document.getElementById('comment-input') as HTMLInputElement const content = input.value.trim() if (!content) return await createComment(id, content) renderPostDetail({ id }) }) } (document.getElementById('comment-list') as HTMLDivElement).addEventListener('click', async (e: Event) => { const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]') if (!btn) return const commentId = btn.dataset.commentId as string const action = btn.dataset.action as string if (action === 'delete') { if (confirm('댓글을 삭제하시겠습니까?')) { await deleteComment(commentId) renderPostDetail({ id }) } } else if (action === 'edit') { const contentEl = document.getElementById(`comment-content-${commentId}`) as HTMLParagraphElement 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 as Error).message}</p> <a href="#/posts" class="text-blue-500 hover:underline">목록으로 돌아가기</a> </div> </div> ` } } function renderComment(comment: Comment, user: User | null): string { const isAuthor = user !== null && 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: string): string { const div = document.createElement('div') div.textContent = text return div.innerHTML } function formatDate(dateStr: string): string { if (!dateStr) return '' const d = new Date(dateStr) return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}` }

이 파일은 분량이 가장 길지만, TypeScript 관점에서 중요한 패턴이 많이 나옵니다.
구조 분해 할당과 타입
export async function renderPostDetail({ id }: RouteParams): Promise<void> {
RouteParams 객체에서 구조 분해 할당으로 id를 추출합니다. RouteParams가 { [key: string]: string }이므로 id의 타입은 string입니다.
작성자 비교
const isAuthor = user !== null && user.nickname === post.authorNickname
이 API에서는 authorId가 null로 내려오기 때문에, 닉네임으로 작성자를 비교합니다. user !== null임을 먼저 확인해야만 user.nickname에 안전하게 접근할 수 있습니다. TypeScript의 타입 좁히기(narrowing) 덕분에, 이 조건문 안에서는 user가 null이 아닌 User 타입으로 추론됩니다.
이벤트 위임과 타입
(document.getElementById('comment-list') as HTMLDivElement).addEventListener('click', async (e: Event) => { const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]') if (!btn) return const commentId = btn.dataset.commentId as string const action = btn.dataset.action as string ... })
댓글의 수정/삭제 버튼은 이벤트 위임으로 처리합니다. 댓글 목록 div에 click 이벤트를 하나만 걸고, 클릭된 요소에서 data-action 속성을 확인하는 방식이에요.
e.target의 타입은 EventTarget | null인데, DOM 요소의 메서드를 쓰려면 HTMLElement로 단언해야 합니다. closest도 제네릭을 지원해서 closest<HTMLElement>('[data-action]')처럼 쓸 수 있어요.
댓글 렌더링 함수의 타입
function renderComment(comment: Comment, user: User | null): string {

renderComment 함수는 HTML 문자열을 반환합니다. 반환 타입이 string이고, 파라미터로 Comment와 User | null을 받아요. 댓글 작성 후 renderPostDetail을 호출해 페이지 전체를 다시 렌더링하더라도, 현재 앱 규모에서는 성능상 큰 문제가 되지 않습니다.

13. 글 수정
src/pages/postEdit.ts
import { getPost, updatePost, isLoggedIn } from '../utils/api' import { navigate } from '../utils/router' import type { RouteParams } from '../utils/types' export async function renderPostEdit({ id }: RouteParams): Promise<void> { if (!isLoggedIn()) { navigate('/login') return } const app = document.getElementById('app') as HTMLDivElement 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> ` const form = document.getElementById('edit-form') as HTMLFormElement form.addEventListener('submit', async (e: Event) => { e.preventDefault() const title = (document.getElementById('title') as HTMLInputElement).value.trim() const content = (document.getElementById('content') as HTMLTextAreaElement).value.trim() const errorMsg = document.getElementById('error-msg') as HTMLDivElement if (!title) { errorMsg.textContent = '제목을 입력하세요.' errorMsg.classList.remove('hidden') return } try { await updatePost(id, title, content) navigate(`/posts/${id}`) } catch (err) { errorMsg.textContent = (err as Error).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 as Error).message}</p> </div> ` } } function escapeHtml(text: string): string { const div = document.createElement('div') div.textContent = text || '' return div.innerHTML } function escapeAttr(text: string): string { return (text || '').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>') }

글쓰기 페이지와 구조가 비슷합니다. 다른 점은 기존 글 데이터를 불러와서 input에 채워 넣는 부분이에요.
escapeAttr 함수가 추가로 있습니다. HTML 속성에 값을 넣을 때는 ", <, > 문자를 이스케이프해야 하거든요. 제목에 따옴표가 들어 있으면 value 속성이 깨질 수 있으니까요.
updatePost 역시 createPost와 마찬가지로 내부적으로 두 번의 API 호출을 거치며, 수정된 내용을 서버에 다시 반영합니다.

14. Vite 프록시 설정
마지막으로 vite.config.ts 파일입니다.
vite.config.ts
import { defineConfig } from 'vite' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [ tailwindcss(), ], server: { proxy: { '/api': { target: 'https://api.fullstackfamily.com', changeOrigin: true, }, }, }, })
JavaScript 버전의 설정 파일은 vite.config.js였지만, TypeScript 프로젝트인 이번에는 vite.config.ts를 사용합니다. Vite는 기본적으로 TypeScript 설정 파일을 지원하기 때문입니다.
server.proxy 설정이 핵심이에요. 브라우저에서 /api로 시작하는 요청을 보내면, Vite 개발 서버가 https://api.fullstackfamily.com으로 대신 전달합니다. 브라우저 입장에서는 같은 도메인으로 요청하는 것처럼 보이니까 CORS 문제가 발생하지 않아요.
마무리
이제 npm run dev를 실행하면 게시판이 동작합니다. 회원가입하고, 로그인하고, 글을 쓰고, 댓글을 달아 보세요.
JavaScript 버전과 뭐가 달라졌나
코드를 쭉 읽으면서 느끼셨겠지만, 비즈니스 로직은 완전히 동일합니다. 달라진 건 타입 관련 부분뿐이에요. 정리하면 이렇습니다.
| 항목 | JavaScript | TypeScript |
|---|---|---|
| 파일 확장자 | .js | .ts |
| 타입 정의 파일 | 없음 | types.ts 추가 |
| 함수 파라미터 | 타입 없음 | 타입 명시 |
| 함수 반환 | 타입 없음 | Promise<void>, string 등 명시 |
| DOM 접근 | document.getElementById('x') | document.getElementById('x') as HTMLInputElement |
| 에러 처리 | err.message | (err as Error).message |
| API 함수 | request(path, options) | request<T>(path, options): Promise<T> |
| import | import { ... } from './api' | import type { ... } from './types' 추가 |
| 빌드 스크립트 | vite build | tsc && vite build |
TypeScript를 쓰면 뭐가 좋아지나
-
자동완성 —
post.을 치면title,content,authorNickname같은 필드가 자동완성됩니다. API 문서를 번갈아 확인할 필요가 없어요. -
오타 방지 —
post.tilte처럼 오타가 발생하면 에디터에 즉시 경고가 표시됩니다. 브라우저에서 "왜 안 나오지?" 하고 한참 디버깅할 일이 줄어들어요. -
리팩터링 —
User인터페이스에 필드를 추가하거나 이름을 바꾸면, 그 타입을 쓰는 모든 곳에서 에러가 표시됩니다. 수정할 곳을 놓칠 수가 없어요. -
문서 역할 —
types.ts파일 하나만 보면 이 앱에서 어떤 데이터를 다루는지 한눈에 파악할 수 있습니다.
코드가 더 길어지지 않나?
네, 좀 더 길어집니다. as HTMLInputElement 같은 타입 단언, (err as Error).message 같은 에러 처리가 추가되니까요. 하지만 types.ts를 한 번 잘 작성해두면, 나머지 코드에서는 에디터의 자동완성과 타입 체크 기능이 많은 부분을 도와줍니다. 전체적으로는 개발 속도가 빨라지는 느낌이에요.
작은 프로젝트에서는 JavaScript가 더 빠를 수 있지만, 프로젝트 규모가 조금만 커져도 TypeScript의 장점을 확실히 체감할 수 있습니다. 이 정도 규모의 프로젝트라 하더라도 types.ts가 제공하는 편리함은 충분히 느낄 수 있을 거예요.
전체 소스 코드는 GitHub에서 확인할 수 있습니다.






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