과거에는 서버에서 HTML을 완성하고 클라이언트에 넘겨주어, 클라이언트는 받아온 HTML을 그대로 렌더링 하는 SSR 방식으로 웹개발을 하였다. 그러나, JS의 발전과 함께 브라우저 단에서 직접 컴포넌트를 만들어 렌더링을 하고, 서버는 단지 REST API를 통해 브라우저에게 렌더링의 필요한 데이터만 보내주는 형식으로 웹개발 방식이 변화하였다. 즉, SSR 방식에서 CSR 방식으로 점차 변화한 것이다.
좀 더 구체적으로 정리하면 다음과 같다.
- SSR
- 서버에서 완성된 HTML 파일을 브라우저에게 넘겨준다.
- 과거에는 JSP, PHP 등과 웹서버 소프트웨어인 Apache, Nginx 등을 이용하여 SSR 방식을 이용하였다. 최근에는 웹서버 기능을 하는 Node.js의 Express.js 파이썬의 장고와 같은 프레임워크와 각 언어의 템플릿 엔진을 조합하여 SSR 방식을 구현할 수 있다.
- SSR 방식은 완성된 HTML 방식을 브라우저에게 넘겨주므로 초기 렌더링 속도가 매우 빠르다.
- 하지만 서버에서 매번 HTML을 생성하고 전달하는 것은 서버 부하가 커질 수 있으며 동적인 콘텐츠가 많을 때는 잦은 새로고침으로 인하여 CSR에 비해 사용자 경험이 낮다.
- CSR
- 서버에서는 최소한의 HTML 파일을 브라우저에게 넘겨준다.
- JS는 제공받은 HTML에 컴포넌트를 주입하고 브라우저는 이를 렌더링 한다.
- React, Svelt, Vue 와 같은 프레임워크가 CSR에 기반한 SPA 프레임워크이다.
- 초기 로딩 속도는 브라우저가 JS를 해석하고 컴포넌트를 렌더링 해야 하므로 SSR보다 느리다.
- 그러나, SPA에 기반한 새로고침 없는 부드러운 화면을 사용자에게 제공할 수 있다.
이번 글에서는 프레임워크를 사용하지 않고, 바닐라JS만으로 CSR 방식에 기반한 웹 컴포넌트를 만들어보겠다. 또한 타입스크립트를 사용하여 타입스크립트에 더욱 익숙해지는 시간을 가져보겠다.
1️⃣ 개발환경
타입스크립트를 컴파일 하는 수고스러움을 덜고자 웹팩을 사용하였다. 또한 HTMLWebpackPlugin을 사용하여 HTML 파일에 script 태그를 생략했다. 해당 플러그인을 사용하면 웹팩에서 알아서 script 태그를 달아주니 매우 간편하다.
설정은 아래와 같다.
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './client/src/app.ts',
module: {
rules: [
{
test: /\.(ts|js)x?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
'@babel/preset-react',
'@babel/preset-typescript',
],
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, 'client', 'public', 'index.html'),
}),
],
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'client', 'dist'),
},
devServer: {
compress: true,
port: 9000,
client: {
logging: 'none', // 로그 출력 안 하도록 설정
},
},
};
package.json 파일의 구성은 다음과 같다.
{
"name": "web-fe-p2-reactcraft",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "webpack serve --mode development",
"build": "webpack --mode production"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@types/express": "^4.17.21",
"@types/node": "^22.6.1",
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"babel-loader": "^9.2.1",
"html-webpack-plugin": "^5.6.0",
"typescript": "^5.6.2",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.1.0"
},
"dependencies": {
"express": "^4.21.0"
}
}
폴더 구조는 다음과 같다.
.
├─ client
│ ├─ public
│ │ └─ index.html
│ └─ src
│ └─ app.ts
├─ node_modules
├─ package-lock.json
├─ package.json
├─ tsconfig.json
└─ wepack.config.json
2️⃣ 간단한 컴포넌트 만들기
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root">
</div>
</body>
</html>
HTMl 코드는 위와 같이 아주 단순하게 구성했다. 서버에서 제공하는 HTML코드는 고작 위의 코드가 끝이다. 이제 우리는 타입스크립트를 통해 컴포넌트를 만든 후 root div 의 자식으로 넣어줄 것이다. 또한, 위에서 언급했듯이 웹팩 플러그인을 사용하므로 script 태그를 넣지 않아도 된다.
먼저, 서버한테 초기 렌더링에 필요한 데이터를 요청하여 받아온다. 서버를 직접 구현할 필요는 없으므로 프로미스와 Mock 데이터를 이용한다.
// app.ts
interface Item {
id: number;
value: string;
}
function fetchItemData(): Promise<Item[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, value: 'item1' },
{ id: 2, value: 'item2' },
{ id: 3, value: 'item3' },
]);
}, 0);
});
}
fetchItemData 함수는 서버에서 데이터를 받아오는 함수이다.
이어서 DOM 을 직접 조작하여 랜더링 하는 render 함수를 구현한다.
// app.ts
interface Item {
id: number;
value: string;
}
function fetchItemData(): Promise<Item[]> {
// 생략
}
let items = [] as Item[]
function render() {
const root = document.querySelector('#root') as HTMLElement;
root.innerHTML = `
<ul>
${items.map((item) => `<li>${item.value}</li>`).join('')}
</ul>
`;
}
async function initApp() {
items = await fetchItemData();
render();
}
initApp()
state 역할을 수행할 items 를 선언한다. render 함수는 서버로부터 받아온 HTML 파일을 통해 생성된 DOM을 조작하여 렌더링 하는 함수이다. initApp 함수는 items에 서버로부터 받아온 데이터를 할당하고, 초기 items를 토대로 render 함수를 불러와 렌더링을 진행한다.
이제 npm run dev 로 웹펙 dev 서버를 가동하면 다음과 같은 결과가 나온다.

서버에서 보내준 HTML 코드에 타입스크립트를 사용하여 클라이언트 단에서 컴포넌트를 생성했다.
3️⃣ 이벤트 핸들러를 통해 state 변경하기
CSR에서 중요한 것 중 하나가 state이다. CSR 프레임워크의 주요 동작은 state가 변경되면 재렌더링을 하여 새로고침 없이 동적으로 컴포넌트의 내용을 변경하는 것이다. 현재 초기 state는 fecthItemData 함수를 통해 가져온 Mock 데이터이다. item을 추가할 수 있는 버튼과 setState 함수를 만들고 버튼에 이벤트 핸들러를 등록하여 setState 함수를 실행하는 로직을 구현해보겠다.
// app.ts
interface Item {
id: number;
value: string;
}
function fetchItemData(): Promise<Item[]> {
// 생략
}
let items = [] as Item[]
function render() {
const root = document.querySelector('#root') as HTMLElement;
root.innerHTML = `
<ul>
${items.map((item) => `<li>${item.value}</li>`).join('')}
</ul>
<button class='append'>추가</button>
`;
const $button = document.querySelector('.append') as HTMLElement;
$button.addEventListener('click', () => {
const itemsLength = items.length;
setState({ id: itemsLength + 1, value: `item${itemsLength + 1}` });
});
}
function setState(newState: Item) {
items = [...items, newState]
render(); //재렌더링
}
async function initApp() {
items = await fetchItemData();
render();
}
initApp();

버튼을 클릭하니 state가 변경되어 재렌더링이 이루어지며 새로고침 없이 item이 추가된다.
4️⃣ 클래스로 리팩토링
이제, 위의 함수형 컴포넌트 코드를 클래스를 사용하여 리팩토링 해보자. 먼저, 추상화를 해보자. 앞서 예제의 코드에 빗대어 Component 클래스는 어떤 멤버를 갖는지 생각해보면 다음과 같다.

추상화한 Component 클래스를 코드로 구현해보면 다음과 같다.
abstract class Component<T> {
protected $target: HTMLElement;
protected state: T[];
constructor($target: HTMLElement) {
this.$target = $target;
this.state = [];
this.initialize();
}
private async initialize() {
this.state = await this.fetchState();
this.render();
}
render() {
this.$target.innerHTML = this.template();
this.setEvent()
}
setState(newState: T) {
this.state = [...this.state, newState];
this.render();
}
abstract fetchState(): Promise<T[]>;
abstract template(): string;
abstract setEvent(): void;
}
Component 인스턴스를 생성하면 생성자 함수에 intialize 메서드가 동작하여 컴포넌트를 초기화한다.
여기서 abstract 키워드가 붙은 메서드는 상속한 클래스에서 오버라이딩 할 예정이다. 그 이유는 해당 메서드의 구현은 컴포넌트마다 다르기 때문이다.
자, 이제 함수로 구현했던 컴포넌트를 Component 클래스를 상속 받아 구체화 해보자.
class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, value: 'item1' },
{ id: 2, value: 'item2' },
{ id: 3, value: 'item3' },
]);
}, 0);
});
}
template(): string {
return `
<ul>
${this.state.map((item) => `<li>${item.value}</li>`).join('')}
</ul>
<button class='append'>추가</button>
`;
}
setEvent(): void {
const $button = this.$target.querySelector('.append') as HTMLElement;
$button.addEventListener('click', () => {
const itemsLength = this.state.length;
this.setState({ id: itemsLength + 1, value: `item${itemsLength + 1}` });
});
}
}
new ItemComponent(document.querySelector('#root') as HTMLElement);

매우 잘 작동하는 것을 확인할 수 있다. 클래스를 통해 컴포넌트의 각 기능을 명확하게 하여 안정성을 갖췄고, 다양한 컴포넌트를 만들 때마다 Component 클래스를 상속 받는 구조로 재사용 함으로써 확장성을 갖추었다.
5️⃣ 모듈화
이제, 모듈화를 해보자. 폴더 구조는 다음과 같다.
.
├─ client
│ ├─ public
│ │ └─ index.html
│ └─ src
│ ├─ app.ts
│ ├─ components
│ │ └─ Item.ts
│ ├─ core
│ │ └─ Component.ts
│ └─ types
│ └─ state.ts
├─ node_modules
├─ package-lock.json
├─ package.json
├─ tsconfig.json
└─ wepack.config.json
// app.ts
import { ItemComponent } from './components/Item';
class App {
constructor() {
const root = document.querySelector('#root') as HTMLElement;
new ItemComponent(root);
}
}
new App()
// types/state.ts
export interface Item {
id: number;
value: string;
}
// core/Component.ts
export abstract class Component<T> {
protected $target: HTMLElement;
protected state: T[];
constructor($target: HTMLElement) {
this.$target = $target;
this.state = [];
this.initialize();
}
private async initialize() {
this.state = await this.fetchState();
this.render();
}
render() {
this.$target.innerHTML = this.template();
this.setEvent();
}
setState(newState: T) {
this.state = [...this.state, newState];
this.render();
}
abstract fetchState(): Promise<T[]>;
abstract template(): string;
abstract setEvent(): void;
}
// components/Item.ts
import { Component } from '../core/Component';
import { Item } from '../types/state';
export class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, value: 'item1' },
{ id: 2, value: 'item2' },
{ id: 3, value: 'item3' },
]);
}, 0);
});
}
template(): string {
return `
<ul>
${this.state.map((item) => `<li>${item.value}</li>`).join('')}
</ul>
<button class='append'>추가</button>
`;
}
setEvent(): void {
const $button = this.$target.querySelector('.append') as HTMLElement;
$button.addEventListener('click', () => {
const itemsLength = this.state.length;
this.setState({ id: itemsLength + 1, value: `item${itemsLength + 1}` });
});
}
}
6️⃣ 이벤트 위임 추상화
이번에는 이벤트 위임을 컴포넌트에 적용해보자. 우선 삭제 버튼을 템플릿에 넣고 각 버튼에 이벤트 핸들러를 등록하겠다. 먼저, 템플릿에 삭제버튼을 추가해준다.
// components/Item.ts
import { ModuleSource } from 'module';
import { Component } from '../core/Component';
import { Item } from '../types/state';
export class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
// 생략
}
template(): string {
return `
<ul>
${this.state
.map(
(item) => `
<li>${item.value}</li>
<button class="deleteBtn" data-id=${item.id}>삭제</button>
`
)
.join('')}
</ul>
<button class='append'>추가</button>
`;
}
setEvent(): void {
// 생략
}
}
data 속성을 사용한 이유는 이벤트 핸들러에게 어떤 요소를 삭제해야 되는지 쉽게 전달하기 위해서이다.
현재의 setState 로직은 state에 새로운 데이터를 추가하는 로직으로 구현되어 있다. 삭제 동작에서도 구현될 수 있게 구조를 수정하고, ItemComponent에서 이벤트 처리 로직을 변경해보자.
// core/Component
export abstract class Component<T> {
protected $target: HTMLElement;
protected state: T[];
constructor($target: HTMLElement) {
// 생략
}
private async initialize() {
// 생략
}
render() {
// 생략
}
setState(newState: T[]) {
this.state = newState;
this.render();
}
abstract fetchState(): Promise<T[]>;
abstract template(): string;
abstract setEvent(): void;
}
// components/Item.ts
import { ModuleSource } from 'module';
import { Component } from '../core/Component';
import { Item } from '../types/state';
export class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
// 생략
}
template(): string {
// 생략
}
setEvent(): void {
const $appendButton = this.$target.querySelector('.append') as HTMLElement;
$appendButton.addEventListener('click', () => {
const itemsLength = this.state.length;
const newItems = [
...this.state,
{ id: itemsLength + 1, value: `item${itemsLength + 1}` },
];
this.setState(newItems);
});
const deleteButtons =
this.$target.querySelectorAll<HTMLElement>('.deleteBtn');
deleteButtons.forEach((deleteButton) => {
deleteButton.addEventListener('click', (e: MouseEvent) => {
if (e.target instanceof HTMLElement) {
const deletingId = Number(e.target.dataset.id);
let items = [...this.state];
items = items.filter((item) => item.id !== deletingId);
this.setState(items);
}
});
});
}
}

삭제버튼이 정상적으로 동작하는 것을 확인할 수 있다.
허나, 위와 같이 구현하는 것보다 이벤트 위임을 사용하는 것이 더욱 효과적이므로 이벤트 위임을 사용하여 리팩토링 하겠다. 이벤트 위임이 무엇인지 모른다면 아래 글을 참고하자.
[JavaScript] 이벤트 버블링(Bubbling), 이벤트 위임(Delegation)
프로젝트 진행 중 DOM API 를 활용하여 이벤트를 다룰 일이 생겼다.모든 컴포넌트에 이벤트 등록을 하던 중, 해당 방식이 매우 비효율적인 것을 알게 되었다.이 글을 통해 이벤트의 전반적인 흐름
junhee1203.tistory.com
// components/Item.ts
import { ModuleSource } from 'module';
import { Component } from '../core/Component';
import { Item } from '../types/state';
export class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
// 생략
}
template(): string {
// 생략
}
setEvent(): void {
this.$target.addEventListener('click', (e: MouseEvent) => {
if (e.target instanceof HTMLElement) {
if (e.target.closest('.append')) {
const itemsLength = this.state.length;
const newItems = [
...this.state,
{ id: itemsLength + 1, value: `item${itemsLength + 1}` },
];
this.setState(newItems);
}
if (e.target.closest('.deleteBtn')) {
const deletingId = Number(e.target.dataset.id);
let items = [...this.state];
items = items.filter((item) => item.id !== deletingId);
this.setState(items);
}
}
});
}
}
$target은 컴포넌트의 부모 요소를 뜻했다. 따라서, $target에 핸들러를 등록하여 그 아래 버블링 된 이벤트를 $target에서 처리하게 하였다.
현재의 setState는 호출하면 내부에서 render 메서드도 같이 호출하는 구조이다. render가 호출되면 setEvent가 호출되므로 재렌더링이 될 때마다 핸들러를 중복으로 등록하게 된다. 따라서 setEvent 메서드를 초기 렌더링에서 한 번만 실행되게 render 내부에서 intialize 메서드 안으로 넣어준다.
// core/Component
export abstract class Component<T> {
protected $target: HTMLElement;
protected state: T[];
constructor($target: HTMLElement) {
// 생략
}
private async initialize() {
this.state = await this.fetchState();
this.render();
this.setEvent();
}
render() {
this.$target.innerHTML = this.template();
}
setState(newState: T[]) {
// 생략
}
abstract fetchState(): Promise<T[]>;
abstract template(): string;
abstract setEvent(): void;
}
이벤트 위임은 현재 ItemComponent 뿐만 아니라 다른 컴포넌트에도 사용할 수 있으므로 추상화를 하겠다.
// core/Component
export abstract class Component<T> {
protected $target: HTMLElement;
protected state: T[];
constructor($target: HTMLElement) {
// 생략
}
private async initialize() {
// 생략
}
render() {
// 생략
}
setState(newState: T[]) {
// 생략
}
addEvent(eventType: string, selector: string, callback: (e: Event) => void) {
this.$target.addEventListener(eventType, (e: Event) => {
if (e.target instanceof HTMLElement) {
if (e.target.closest(selector)) {
callback(e);
}
}
});
}
abstract fetchState(): Promise<T[]>;
abstract template(): string;
abstract setEvent(): void;
}
// components/Item.ts
import { ModuleSource } from 'module';
import { Component } from '../core/Component';
import { Item } from '../types/state';
export class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
// 생략
}
template(): string {
// 생략
}
setEvent(): void {
this.addEvent('click', '.append', () => {
const itemsLength = this.state.length;
const newItems = [
...this.state,
{ id: itemsLength + 1, value: `item${itemsLength + 1}` },
];
this.setState(newItems);
});
this.addEvent('click', '.deleteBtn', (e) => {
if (e.target instanceof HTMLElement) {
const deletingId = Number(e.target.dataset.id);
let items = [...this.state];
items = items.filter((item) => item.id !== deletingId);
this.setState(items);
}
});
}
}
아주 기본적인 컴포넌트 구조가 완성됐다. 사실, 위의 컴포넌트는 props, children 멤버가 없기에 컴포넌트라고 하기에는 매우 단순한 형태이다. children과 props를 통해 자식 컴포넌트에게 state를 단방향 흐름으로 전달하는 로직이 추가적으로 필요하다. 그럼에도, 컴포넌트가 대락적으로라도 어떤 역할을 하는지 이해할 수 있는 것에 의의를 둔다.
이 포스팅은 아래 포스팅에 기반하여, 필자의 스타일로 재각색한 글입니다.
참고 : https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Component/
'TypeScript' 카테고리의 다른 글
[TypeScript] tsconfig.json 주요 옵션 정리 (1) | 2024.09.25 |
---|---|
[TypeScript] 제네릭(Generic) 정복하기 (0) | 2024.09.22 |
[TypeScript] 인터페이스(interface) 총 분석 (0) | 2024.09.20 |
과거에는 서버에서 HTML을 완성하고 클라이언트에 넘겨주어, 클라이언트는 받아온 HTML을 그대로 렌더링 하는 SSR 방식으로 웹개발을 하였다. 그러나, JS의 발전과 함께 브라우저 단에서 직접 컴포넌트를 만들어 렌더링을 하고, 서버는 단지 REST API를 통해 브라우저에게 렌더링의 필요한 데이터만 보내주는 형식으로 웹개발 방식이 변화하였다. 즉, SSR 방식에서 CSR 방식으로 점차 변화한 것이다.
좀 더 구체적으로 정리하면 다음과 같다.
- SSR
- 서버에서 완성된 HTML 파일을 브라우저에게 넘겨준다.
- 과거에는 JSP, PHP 등과 웹서버 소프트웨어인 Apache, Nginx 등을 이용하여 SSR 방식을 이용하였다. 최근에는 웹서버 기능을 하는 Node.js의 Express.js 파이썬의 장고와 같은 프레임워크와 각 언어의 템플릿 엔진을 조합하여 SSR 방식을 구현할 수 있다.
- SSR 방식은 완성된 HTML 방식을 브라우저에게 넘겨주므로 초기 렌더링 속도가 매우 빠르다.
- 하지만 서버에서 매번 HTML을 생성하고 전달하는 것은 서버 부하가 커질 수 있으며 동적인 콘텐츠가 많을 때는 잦은 새로고침으로 인하여 CSR에 비해 사용자 경험이 낮다.
- CSR
- 서버에서는 최소한의 HTML 파일을 브라우저에게 넘겨준다.
- JS는 제공받은 HTML에 컴포넌트를 주입하고 브라우저는 이를 렌더링 한다.
- React, Svelt, Vue 와 같은 프레임워크가 CSR에 기반한 SPA 프레임워크이다.
- 초기 로딩 속도는 브라우저가 JS를 해석하고 컴포넌트를 렌더링 해야 하므로 SSR보다 느리다.
- 그러나, SPA에 기반한 새로고침 없는 부드러운 화면을 사용자에게 제공할 수 있다.
이번 글에서는 프레임워크를 사용하지 않고, 바닐라JS만으로 CSR 방식에 기반한 웹 컴포넌트를 만들어보겠다. 또한 타입스크립트를 사용하여 타입스크립트에 더욱 익숙해지는 시간을 가져보겠다.
1️⃣ 개발환경
타입스크립트를 컴파일 하는 수고스러움을 덜고자 웹팩을 사용하였다. 또한 HTMLWebpackPlugin을 사용하여 HTML 파일에 script 태그를 생략했다. 해당 플러그인을 사용하면 웹팩에서 알아서 script 태그를 달아주니 매우 간편하다.
설정은 아래와 같다.
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './client/src/app.ts',
module: {
rules: [
{
test: /\.(ts|js)x?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
'@babel/preset-react',
'@babel/preset-typescript',
],
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, 'client', 'public', 'index.html'),
}),
],
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'client', 'dist'),
},
devServer: {
compress: true,
port: 9000,
client: {
logging: 'none', // 로그 출력 안 하도록 설정
},
},
};
package.json 파일의 구성은 다음과 같다.
{
"name": "web-fe-p2-reactcraft",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "webpack serve --mode development",
"build": "webpack --mode production"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@types/express": "^4.17.21",
"@types/node": "^22.6.1",
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"babel-loader": "^9.2.1",
"html-webpack-plugin": "^5.6.0",
"typescript": "^5.6.2",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.1.0"
},
"dependencies": {
"express": "^4.21.0"
}
}
폴더 구조는 다음과 같다.
.
├─ client
│ ├─ public
│ │ └─ index.html
│ └─ src
│ └─ app.ts
├─ node_modules
├─ package-lock.json
├─ package.json
├─ tsconfig.json
└─ wepack.config.json
2️⃣ 간단한 컴포넌트 만들기
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root">
</div>
</body>
</html>
HTMl 코드는 위와 같이 아주 단순하게 구성했다. 서버에서 제공하는 HTML코드는 고작 위의 코드가 끝이다. 이제 우리는 타입스크립트를 통해 컴포넌트를 만든 후 root div 의 자식으로 넣어줄 것이다. 또한, 위에서 언급했듯이 웹팩 플러그인을 사용하므로 script 태그를 넣지 않아도 된다.
먼저, 서버한테 초기 렌더링에 필요한 데이터를 요청하여 받아온다. 서버를 직접 구현할 필요는 없으므로 프로미스와 Mock 데이터를 이용한다.
// app.ts
interface Item {
id: number;
value: string;
}
function fetchItemData(): Promise<Item[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, value: 'item1' },
{ id: 2, value: 'item2' },
{ id: 3, value: 'item3' },
]);
}, 0);
});
}
fetchItemData 함수는 서버에서 데이터를 받아오는 함수이다.
이어서 DOM 을 직접 조작하여 랜더링 하는 render 함수를 구현한다.
// app.ts
interface Item {
id: number;
value: string;
}
function fetchItemData(): Promise<Item[]> {
// 생략
}
let items = [] as Item[]
function render() {
const root = document.querySelector('#root') as HTMLElement;
root.innerHTML = `
<ul>
${items.map((item) => `<li>${item.value}</li>`).join('')}
</ul>
`;
}
async function initApp() {
items = await fetchItemData();
render();
}
initApp()
state 역할을 수행할 items 를 선언한다. render 함수는 서버로부터 받아온 HTML 파일을 통해 생성된 DOM을 조작하여 렌더링 하는 함수이다. initApp 함수는 items에 서버로부터 받아온 데이터를 할당하고, 초기 items를 토대로 render 함수를 불러와 렌더링을 진행한다.
이제 npm run dev 로 웹펙 dev 서버를 가동하면 다음과 같은 결과가 나온다.

서버에서 보내준 HTML 코드에 타입스크립트를 사용하여 클라이언트 단에서 컴포넌트를 생성했다.
3️⃣ 이벤트 핸들러를 통해 state 변경하기
CSR에서 중요한 것 중 하나가 state이다. CSR 프레임워크의 주요 동작은 state가 변경되면 재렌더링을 하여 새로고침 없이 동적으로 컴포넌트의 내용을 변경하는 것이다. 현재 초기 state는 fecthItemData 함수를 통해 가져온 Mock 데이터이다. item을 추가할 수 있는 버튼과 setState 함수를 만들고 버튼에 이벤트 핸들러를 등록하여 setState 함수를 실행하는 로직을 구현해보겠다.
// app.ts
interface Item {
id: number;
value: string;
}
function fetchItemData(): Promise<Item[]> {
// 생략
}
let items = [] as Item[]
function render() {
const root = document.querySelector('#root') as HTMLElement;
root.innerHTML = `
<ul>
${items.map((item) => `<li>${item.value}</li>`).join('')}
</ul>
<button class='append'>추가</button>
`;
const $button = document.querySelector('.append') as HTMLElement;
$button.addEventListener('click', () => {
const itemsLength = items.length;
setState({ id: itemsLength + 1, value: `item${itemsLength + 1}` });
});
}
function setState(newState: Item) {
items = [...items, newState]
render(); //재렌더링
}
async function initApp() {
items = await fetchItemData();
render();
}
initApp();

버튼을 클릭하니 state가 변경되어 재렌더링이 이루어지며 새로고침 없이 item이 추가된다.
4️⃣ 클래스로 리팩토링
이제, 위의 함수형 컴포넌트 코드를 클래스를 사용하여 리팩토링 해보자. 먼저, 추상화를 해보자. 앞서 예제의 코드에 빗대어 Component 클래스는 어떤 멤버를 갖는지 생각해보면 다음과 같다.

추상화한 Component 클래스를 코드로 구현해보면 다음과 같다.
abstract class Component<T> {
protected $target: HTMLElement;
protected state: T[];
constructor($target: HTMLElement) {
this.$target = $target;
this.state = [];
this.initialize();
}
private async initialize() {
this.state = await this.fetchState();
this.render();
}
render() {
this.$target.innerHTML = this.template();
this.setEvent()
}
setState(newState: T) {
this.state = [...this.state, newState];
this.render();
}
abstract fetchState(): Promise<T[]>;
abstract template(): string;
abstract setEvent(): void;
}
Component 인스턴스를 생성하면 생성자 함수에 intialize 메서드가 동작하여 컴포넌트를 초기화한다.
여기서 abstract 키워드가 붙은 메서드는 상속한 클래스에서 오버라이딩 할 예정이다. 그 이유는 해당 메서드의 구현은 컴포넌트마다 다르기 때문이다.
자, 이제 함수로 구현했던 컴포넌트를 Component 클래스를 상속 받아 구체화 해보자.
class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, value: 'item1' },
{ id: 2, value: 'item2' },
{ id: 3, value: 'item3' },
]);
}, 0);
});
}
template(): string {
return `
<ul>
${this.state.map((item) => `<li>${item.value}</li>`).join('')}
</ul>
<button class='append'>추가</button>
`;
}
setEvent(): void {
const $button = this.$target.querySelector('.append') as HTMLElement;
$button.addEventListener('click', () => {
const itemsLength = this.state.length;
this.setState({ id: itemsLength + 1, value: `item${itemsLength + 1}` });
});
}
}
new ItemComponent(document.querySelector('#root') as HTMLElement);

매우 잘 작동하는 것을 확인할 수 있다. 클래스를 통해 컴포넌트의 각 기능을 명확하게 하여 안정성을 갖췄고, 다양한 컴포넌트를 만들 때마다 Component 클래스를 상속 받는 구조로 재사용 함으로써 확장성을 갖추었다.
5️⃣ 모듈화
이제, 모듈화를 해보자. 폴더 구조는 다음과 같다.
.
├─ client
│ ├─ public
│ │ └─ index.html
│ └─ src
│ ├─ app.ts
│ ├─ components
│ │ └─ Item.ts
│ ├─ core
│ │ └─ Component.ts
│ └─ types
│ └─ state.ts
├─ node_modules
├─ package-lock.json
├─ package.json
├─ tsconfig.json
└─ wepack.config.json
// app.ts
import { ItemComponent } from './components/Item';
class App {
constructor() {
const root = document.querySelector('#root') as HTMLElement;
new ItemComponent(root);
}
}
new App()
// types/state.ts
export interface Item {
id: number;
value: string;
}
// core/Component.ts
export abstract class Component<T> {
protected $target: HTMLElement;
protected state: T[];
constructor($target: HTMLElement) {
this.$target = $target;
this.state = [];
this.initialize();
}
private async initialize() {
this.state = await this.fetchState();
this.render();
}
render() {
this.$target.innerHTML = this.template();
this.setEvent();
}
setState(newState: T) {
this.state = [...this.state, newState];
this.render();
}
abstract fetchState(): Promise<T[]>;
abstract template(): string;
abstract setEvent(): void;
}
// components/Item.ts
import { Component } from '../core/Component';
import { Item } from '../types/state';
export class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, value: 'item1' },
{ id: 2, value: 'item2' },
{ id: 3, value: 'item3' },
]);
}, 0);
});
}
template(): string {
return `
<ul>
${this.state.map((item) => `<li>${item.value}</li>`).join('')}
</ul>
<button class='append'>추가</button>
`;
}
setEvent(): void {
const $button = this.$target.querySelector('.append') as HTMLElement;
$button.addEventListener('click', () => {
const itemsLength = this.state.length;
this.setState({ id: itemsLength + 1, value: `item${itemsLength + 1}` });
});
}
}
6️⃣ 이벤트 위임 추상화
이번에는 이벤트 위임을 컴포넌트에 적용해보자. 우선 삭제 버튼을 템플릿에 넣고 각 버튼에 이벤트 핸들러를 등록하겠다. 먼저, 템플릿에 삭제버튼을 추가해준다.
// components/Item.ts
import { ModuleSource } from 'module';
import { Component } from '../core/Component';
import { Item } from '../types/state';
export class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
// 생략
}
template(): string {
return `
<ul>
${this.state
.map(
(item) => `
<li>${item.value}</li>
<button class="deleteBtn" data-id=${item.id}>삭제</button>
`
)
.join('')}
</ul>
<button class='append'>추가</button>
`;
}
setEvent(): void {
// 생략
}
}
data 속성을 사용한 이유는 이벤트 핸들러에게 어떤 요소를 삭제해야 되는지 쉽게 전달하기 위해서이다.
현재의 setState 로직은 state에 새로운 데이터를 추가하는 로직으로 구현되어 있다. 삭제 동작에서도 구현될 수 있게 구조를 수정하고, ItemComponent에서 이벤트 처리 로직을 변경해보자.
// core/Component
export abstract class Component<T> {
protected $target: HTMLElement;
protected state: T[];
constructor($target: HTMLElement) {
// 생략
}
private async initialize() {
// 생략
}
render() {
// 생략
}
setState(newState: T[]) {
this.state = newState;
this.render();
}
abstract fetchState(): Promise<T[]>;
abstract template(): string;
abstract setEvent(): void;
}
// components/Item.ts
import { ModuleSource } from 'module';
import { Component } from '../core/Component';
import { Item } from '../types/state';
export class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
// 생략
}
template(): string {
// 생략
}
setEvent(): void {
const $appendButton = this.$target.querySelector('.append') as HTMLElement;
$appendButton.addEventListener('click', () => {
const itemsLength = this.state.length;
const newItems = [
...this.state,
{ id: itemsLength + 1, value: `item${itemsLength + 1}` },
];
this.setState(newItems);
});
const deleteButtons =
this.$target.querySelectorAll<HTMLElement>('.deleteBtn');
deleteButtons.forEach((deleteButton) => {
deleteButton.addEventListener('click', (e: MouseEvent) => {
if (e.target instanceof HTMLElement) {
const deletingId = Number(e.target.dataset.id);
let items = [...this.state];
items = items.filter((item) => item.id !== deletingId);
this.setState(items);
}
});
});
}
}

삭제버튼이 정상적으로 동작하는 것을 확인할 수 있다.
허나, 위와 같이 구현하는 것보다 이벤트 위임을 사용하는 것이 더욱 효과적이므로 이벤트 위임을 사용하여 리팩토링 하겠다. 이벤트 위임이 무엇인지 모른다면 아래 글을 참고하자.
[JavaScript] 이벤트 버블링(Bubbling), 이벤트 위임(Delegation)
프로젝트 진행 중 DOM API 를 활용하여 이벤트를 다룰 일이 생겼다.모든 컴포넌트에 이벤트 등록을 하던 중, 해당 방식이 매우 비효율적인 것을 알게 되었다.이 글을 통해 이벤트의 전반적인 흐름
junhee1203.tistory.com
// components/Item.ts
import { ModuleSource } from 'module';
import { Component } from '../core/Component';
import { Item } from '../types/state';
export class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
// 생략
}
template(): string {
// 생략
}
setEvent(): void {
this.$target.addEventListener('click', (e: MouseEvent) => {
if (e.target instanceof HTMLElement) {
if (e.target.closest('.append')) {
const itemsLength = this.state.length;
const newItems = [
...this.state,
{ id: itemsLength + 1, value: `item${itemsLength + 1}` },
];
this.setState(newItems);
}
if (e.target.closest('.deleteBtn')) {
const deletingId = Number(e.target.dataset.id);
let items = [...this.state];
items = items.filter((item) => item.id !== deletingId);
this.setState(items);
}
}
});
}
}
$target은 컴포넌트의 부모 요소를 뜻했다. 따라서, $target에 핸들러를 등록하여 그 아래 버블링 된 이벤트를 $target에서 처리하게 하였다.
현재의 setState는 호출하면 내부에서 render 메서드도 같이 호출하는 구조이다. render가 호출되면 setEvent가 호출되므로 재렌더링이 될 때마다 핸들러를 중복으로 등록하게 된다. 따라서 setEvent 메서드를 초기 렌더링에서 한 번만 실행되게 render 내부에서 intialize 메서드 안으로 넣어준다.
// core/Component
export abstract class Component<T> {
protected $target: HTMLElement;
protected state: T[];
constructor($target: HTMLElement) {
// 생략
}
private async initialize() {
this.state = await this.fetchState();
this.render();
this.setEvent();
}
render() {
this.$target.innerHTML = this.template();
}
setState(newState: T[]) {
// 생략
}
abstract fetchState(): Promise<T[]>;
abstract template(): string;
abstract setEvent(): void;
}
이벤트 위임은 현재 ItemComponent 뿐만 아니라 다른 컴포넌트에도 사용할 수 있으므로 추상화를 하겠다.
// core/Component
export abstract class Component<T> {
protected $target: HTMLElement;
protected state: T[];
constructor($target: HTMLElement) {
// 생략
}
private async initialize() {
// 생략
}
render() {
// 생략
}
setState(newState: T[]) {
// 생략
}
addEvent(eventType: string, selector: string, callback: (e: Event) => void) {
this.$target.addEventListener(eventType, (e: Event) => {
if (e.target instanceof HTMLElement) {
if (e.target.closest(selector)) {
callback(e);
}
}
});
}
abstract fetchState(): Promise<T[]>;
abstract template(): string;
abstract setEvent(): void;
}
// components/Item.ts
import { ModuleSource } from 'module';
import { Component } from '../core/Component';
import { Item } from '../types/state';
export class ItemComponent extends Component<Item> {
fetchState(): Promise<Item[]> {
// 생략
}
template(): string {
// 생략
}
setEvent(): void {
this.addEvent('click', '.append', () => {
const itemsLength = this.state.length;
const newItems = [
...this.state,
{ id: itemsLength + 1, value: `item${itemsLength + 1}` },
];
this.setState(newItems);
});
this.addEvent('click', '.deleteBtn', (e) => {
if (e.target instanceof HTMLElement) {
const deletingId = Number(e.target.dataset.id);
let items = [...this.state];
items = items.filter((item) => item.id !== deletingId);
this.setState(items);
}
});
}
}
아주 기본적인 컴포넌트 구조가 완성됐다. 사실, 위의 컴포넌트는 props, children 멤버가 없기에 컴포넌트라고 하기에는 매우 단순한 형태이다. children과 props를 통해 자식 컴포넌트에게 state를 단방향 흐름으로 전달하는 로직이 추가적으로 필요하다. 그럼에도, 컴포넌트가 대락적으로라도 어떤 역할을 하는지 이해할 수 있는 것에 의의를 둔다.
이 포스팅은 아래 포스팅에 기반하여, 필자의 스타일로 재각색한 글입니다.
참고 : https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Component/
'TypeScript' 카테고리의 다른 글
[TypeScript] tsconfig.json 주요 옵션 정리 (1) | 2024.09.25 |
---|---|
[TypeScript] 제네릭(Generic) 정복하기 (0) | 2024.09.22 |
[TypeScript] 인터페이스(interface) 총 분석 (0) | 2024.09.20 |