들어가며
리액트만 쓰다 보니 문득 궁금해졌다. 리액트 없이 컴포넌트를 만들면 어떻게 될까? innerHTML로 요소를 주입하는 것에서 시작해서, 함수로 분리하고, 클래스로 컴포넌트를 만들어보기로 했다.
예제는 내가 좋아하는 음식 목록을 관리하는 간단한 앱이다. 단계별로 발전시켜 나가보자.
컴포넌트 만들기
1) Vanilla JavaScript로 요소 주입하기
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vanilla JavaScript</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.js"></script>
</body>
</html>
위와 같은 html파일이 있다고 해보자. id가 app인 div요소가 있고 main.js라는 자바스크립트 파일을 불러오고 있다. 우리는 이제 #app 요소에 main.js에서 동적으로 요소들을 주입할 것이다.
document.querySelector("#app").innerHTML = `
<h1>내가 가장 좋아하는 음식들</h1>
<ul>
<li>삼겹살</li>
<li>감자탕</li>
<li>치킨</li>
</ul>
`;
주입하는 방법은 간단하다. innerHTML을 이용해 HTML 요소를 동적으로 추가할 수 있다.
2) 로직 분리
위와 같이 코드를 계속 작성하면 코드가 복잡해지고 유지보수가 어려워질 것이다. 이를 해결하기 위해 함수로 로직을 분리해보자. appendFoods라는 함수로 분리한 코드이다.
// main.js
function appendFoods() {
document.querySelector("#app").innerHTML = `
<h1>내가 가장 좋아하는 음식들</h1>
<ul>
<li>삼겹살</li>
<li>감자탕</li>
<li>치킨</li>
</ul>
`;
}
appendFoods();
여기서 만약 동적으로 음식 요소가 추가되면 어떻게 해야할까?
함수의 인자로 음식을 전달받아보자.
// main.js
// ..
function appendFoods(foods) {
document.querySelector("#foods").innerHTML = `
<h1>내가 가장 좋아하는 음식들</h1>
<ul>
${foods.map((food) => `<li>${food}</li>`).join("")}
</ul>
`;
}
appendFoods(["삼겹살", "감자탕", "치킨"]);
그리고 사용자의 입력값을 받는 폼도 추가해보자.
// main.js
// ..
function appendFoods(foods) {
document.querySelector("#foods").innerHTML = `
<h1>내가 가장 좋아하는 음식들</h1>
<ul>
${foods.map((food) => `<li>${food}</li>`).join("")}
</ul>
<form>
<input id="add-food" />
<button>음식 추가</button>
</form>
`;
document
.querySelector("#foods")
.querySelector("form")
.addEventListener("submit", (event) => {
event.preventDefault();
const addFoodInputValue = document.querySelector("#add-food").value;
foods.push(addFoodInputValue); // 피자
console.log(foods); // ['삼겹살', '감자탕', '치킨', '피자']
});
}
appendFoods(["삼겹살", "감자탕", "치킨"]);
위와 같이 만약 사용자가 ‘피자’를 입력해 제출했다고 해보자. 그럼 foods에는 ‘피자’가 추가되었을 것이다. 그런데 UI에 보여지는 음식도 바뀌었을까? 안바뀌었다. 왜냐하면 foods배열이 변경되었다고 해서 이를 UI에서 자동으로 반영하지 않기 때문이다. 그렇다면 어떻게 해야할까?
그렇다. 다시 innerHTML을 이용하여 기존 요소 #foods를 리렌더링해야한다.
// main.js
// ..
function appendFoods(foods) {
document.querySelector("#foods").innerHTML = `
<h1>내가 가장 좋아하는 음식들</h1>
<ul>
${foods.map((food) => `<li>${food}</li>`).join("")}
</ul>
<form>
<input id="add-food" />
<button>음식 추가</button>
</form>
`;
}
function attachFoodEventHandler() {
document
.querySelector("#foods")
.querySelector("form")
.addEventListener("submit", (event) => {
event.preventDefault();
const addFoodInputValue = document.querySelector("#add-food").value;
foods.push(addFoodInputValue);
appendFoods(foods);
});
}
appendFoods(foods);
attachFoodEventHandler();
이벤트 리스너를 추가하는 로직을 attachFoodEventHandler함수에 분리하고, 내부에서 변경된 foods를 바탕으로 appendFoods를 다시 호출하는 방식으로 위 리렌더링 문제를 해결했다.
그럼 음식이 추가될 때마다 직접 리렌더링 로직(appendChild)을 호출하고 음식이 제거될 때마다 또 호출할 것인가? 또한 innerHTML로 렌더링하는 로직이 계속해서 반복될 것이다. 그럴 때마다 직접 innerHTML을 작성할 것인가? 물론 이를 함수로 분리할 수는 있다. 하지만 이러한 함수를 강제화할 수 있을까?
이러한 반복적인 로직의 문제와 강제성의 문제를 클래스의 상속과 오버라이딩을 통해 해결해보자.
3) 클래스로 분리
// Component.js
export default class Component {
constructor() {}
render() {
const element = document.createElement("div");
element.innerHTML = this.template();
return element;
}
template() {
return ``;
}
attachEventListener() {}
}
위와 같이 기본적인 골격을 갖춘 컴포넌트 클래스를 만들었다.
render()에서는 template()메서드를 실행해 문자열 형태의 HTML템플릿을 element.innerHTML로 돔 트리를 생성한다. 그리고 생성한 요소를 반환한다. template()메서드는 기본적으로 빈 문자열을 반환하며 이는 자식 클래스에서 오버라이딩해서 사용하는 메서드이다. 상속과 오버라이딩은 클래스의 꽃이다. 이걸로 컴포넌트의 구조와 동작을 정리할 수 있다.
import Component from "./Component.js";
const foods = ["삼겹살", "감자탕", "치킨"];
export default class Foods extends Component {
template() {
return `
<h1>내가 가장 좋아하는 음식들</h1>
<ul>
${foods.map((food) => `<li>${food}</li>`).join("")}
</ul>
<form>
<input id="add-food" />
<button>음식 추가</button>
</form>
`;
}
attachEventListener() {
document
.querySelector("#foods")
.querySelector("form")
.addEventListener("submit", (event) => {
event.preventDefault();
const addFoodInputValue = document.querySelector("#add-food").value;
foods.push(addFoodInputValue);
});
}
}
자식 클래스에서는 위와 같이 Component클래스를 상속하고 template()과 attachEventListener()을 오버라이딩해 사용한다.
위 클래스를 main.js에서 사용하면 다음과 같다.
const foods = new Foods();
document.querySelector("#foods").appendChild(foods.render());
foods.attachEventListener();
현재 문제점이 여러가지 있다.
- 폼에 음식을 입력해 제출했을 때 음식이 추가되지 않는다.
attachEventListener()메서드를 매 번 직접 호출해야한다.- 음식이 추가되면 다시
render()메서드를 직접 호출해야한다. - 외부에서 값을 전달받지 못한다.
- div가 중첩으로 생긴다.
하나씩 해결해보자.
문제점 1 : 폼에 음식을 입력해 제출했을 때 음식이 추가되지 않는다.
왜 음식이 추가되지 않을까? 이는 foods에 음식을 추가하고 render()메서드를 호출하지 않았기 때문이다.
그렇다면 아래와 같이 음식을 추가한 후 render()메서드를 호출하면 해결이 될까?
export default class Foods extends Component {
// ..
attachEventListener() {
document
.querySelector("#foods")
.querySelector("form")
.addEventListener("submit", (event) => {
event.preventDefault();
const addFoodInputValue = document.querySelector("#add-food").value;
foods.push(addFoodInputValue);
this.render()
});
}
}
그럼에도 해결이 되지 않는다. 그 이유는 아래 코드를 살펴보자.
export default class Component {
// ..
render() {
const element = document.createElement("div");
element.innerHTML = this.template();
return element;
}
}
현재 render()는 실행이 될 때마다 새로운 요소를 생성하고 있다. 처음에 document에 추가될 때는 문제가 되지 않는다. 그런데 이후에 컴포넌트 내부가 바뀌었을 때는? 그 때도 새롭게 요소를 생성하면 기존 document에 있는 요소의 내부가 바뀌는 게 아니라 새롭게 생성된 요소의 내부가 바뀐 상태로 적용될 것이다. 우리가 원하는 것은 기존 document에 있는 요소의 내부가 바뀌는 것이다. 그렇기에 이를 컴포넌트 인스턴스의 element 프로퍼티 하나로 관리해보자.
export default class Component {
#element;
constructor() {}
render() {
if (!this.#element) this.#element = document.createElement("div");
this.#element.innerHTML = this.template();
return this.#element;
}
template() {
return ``;
}
attachEventListener() {}
get element() {
return this.#element;
}
}
요소는 외부에서 수정하지 못하고 접근만 할 수 있도록 private필드와 getter메서드를 활용했다.
문제점 2 : attachEventListener() 메서드를 직접 호출해야한다.
// main.js
const foods = new Foods();
document.querySelector("#foods").appendChild(foods.render());
foods.attachEventListener();
main.js에서는 위와 같이 요소를 렌더링하고, 직접 attachEventListener를 호출해야한다. 하지만 모든 컴포넌트에서 “(최초) 렌더링한다 → 이벤트 리스너를 단다.” 이 순서는 동일하다. 이러한 순서를 class를 활용해 강제화해보자.
export default class Component {
#element;
constructor() {
this.render();
this.attachEventListener();
}
render() {
if (!this.#element) this.#element = document.createElement("div");
this.#element.innerHTML = this.template();
return this.#element;
}
template() {
return ``;
}
attachEventListener() {}
get element() {
return this.#element;
}
}
생성자 함수에서 요소를 렌더링(render)하고, 이벤트 리스너까지 단다(attachEventListener)
그럼 여기서 기존 document에서 요소를 찾던 Foods컴포넌트의 로직을 수정해야한다. 왜냐하면 Foods 컴포넌트가 렌더링되는 시점은 document에 append하기 전이기 때문이다.
const foods = new Foods(); // Foods의 constructor가 호출되는 시점
document.querySelector("#foods").appendChild(foods.element);
export default class Foods extends Component {
// ..
attachEventListener() {
this.element
.querySelector("form")
.addEventListener("submit", (event) => {
event.preventDefault();
const addFoodInputValue = document.querySelector("#add-food").value;
foods.push(addFoodInputValue);
this.render();
});
}
}
Foods 컴포넌트를 document에 달기 전이니 현재 요소로부터 form을 찾아 이벤트 리스너를 단다.
문제점 3 : 음식이 추가되면 다시 render() 메서드를 직접 호출해야한다.
문제점 1번에서 아래와 같이 요소가 추가된 후, this.render()로 추가된 음식과 함께 다시 요소를 렌더링하는 방식으로 바꾸었다.
// Foods
export default class Foods extends Component {
// ..
attachEventListener() {
this.element
.querySelector("form")
.addEventListener("submit", (event) => {
event.preventDefault();
const addFoodInputValue = document.querySelector("#add-food").value;
foods.push(addFoodInputValue);
this.render();
});
}
}
음식을 추가한다 → 컴포넌트를 렌더링한다. 를 직접 작성하는 건 상당히 명령적이다. 이를 선언적으로 바꿀 수 있을까? 상태를 변경하는 메서드를 만들어서 상태 변경 시 render()를 항상 호출하는 건 어떨까? 상태가 변경되면 컴포넌트가 다시 렌더링되는 것은 자연스러운 흐름이기 때문이다.
// Component.js
export default class Component {
// ..
setState(nextState) {
this.state = { ...this.state, ...nextState };
this.render();
}
}
setState()에서는 상태를 변경하고 render()를 호출할 것을 강제화했다.
문제점 4 : 외부에서 값을 전달받지 못한다.
외부에서 값을 전달받지 못하는 문제는 생성자 함수에서 props라는 인자를 전달받아 필드로 등록해 해결해보자.
// Component.js
export default class Component {
#props;
// ..
constructor(props) {
this.#props = props;
// ..
}
get props() {
return this.#props;
}
}
외부에서 직접 수정하지 못하도록 private 필드로 선언하고 getter메서드로 READ만 할 수 있도록 했다.
// main.js
const foods = new Foods({ foods: ["삼겹살", "감자탕", "치킨"] });
// Foods.js
export default class Foods extends Component {
setup() {
this.state = {
foods: this.props.foods,
};
}
Foods컴포넌트에서는 초기 foods의 상태값을 props로 전달받은 음식들로 설정한다.
문제점 5 : div가 중첩으로 생긴다.
내가 Foods 컴포넌트에서 작성한 템플릿 다음과 같다.
// foods.js
export default class Foods extends Component<FoodsProps, FoodsProps> {
template() {
return `
<h1>내가 가장 좋아하는 음식들</h1>
<ul>
${this.state.foods.map((food) => `<li>${food}</li>`).join("")}
</ul>
<form>
<input id="add-food" />
<button>음식 추가</button>
</form>
`;
}
// ..
}
그리고 개발자 모드에서 확인한 돔 트리 구조는 다음과 같다.
div 래핑이 하나 생겼다. 이는 우리가 Component.ts에서 render()메서드의 방식과 관련이 있다.
render() {
if (!this.#element) this.#element = document.createElement("div");
this.#element.innerHTML = this.template();
}
현재 방식은 새로운 div를 생성하고, 내부에 innerHTML로 template()의 반환 문자열을 삽입하고 있다.
이 문제를 해결하기 위해서는 새로운 div요소를 생성하기는 하되, 반환을 내부 요소만 반환하면 어떨까? 그럼 가장 래핑하는 요소를 Component를 사용하는 측에서 결정하는 것이다.
이를 위해 element.firstElementChild를 활용해보자.
// Component.js
export default class Component{
render() {
const element = document.createElement("div");
element.innerHTML = this.template();
const elementFirstChild = element.firstElementChild as HTMLElement;
if (!this.#element) this.#element = elementFirstChild;
else this.#element.innerHTML = elementFirstChild.innerHTML;
}
}
기존처럼 div요소를 만들고, innerHTML로 내부에 template()의 돔 트리를 생성한다. 그리고 element필드에는 첫 번째 자식 요소인 elementFirstChild를 넣는 것이다.
이제 주의해야할 점이 있다. 기존 코드와 같이 template()이 반환하는 돔 트리를 래핑하는 요소는 하나여야하는 것이다. 왜냐하면 위 코드에서 firstElementChild로 첫 번째 자식 요소만을 요소로 만들고 있기 때문이다.
그럼 기존 코드를 아래와 같이 section으로 감싸줄 수 있다.
// foods.js
export default class Foods extends Component<FoodsProps, FoodsProps> {
template() {
return `
<section>
<h1>내가 가장 좋아하는 음식들</h1>
<ul>
${this.state.foods.map((food) => `<li>${food}</li>`).join("")}
</ul>
<form>
<input id="add-food" />
<button>음식 추가</button>
</form>
</section>
`;
}
// ..
}
section으로 결국 감싸야하기에 기존과 똑같지 않냐고 생각할 수 있지만, 이는 컴포넌트를 사용하는 측에서 최상위 요소를 결정할 수 있다는 점에서 큰 차이가 있다. 즉, 컴포넌트의 재사용성과 유연성이 향상된다. Foods 컴포넌트를 사용하는 측에서 section이 아닌 article이나 ul로 감싸서 사용할 수도 있기 때문이다.
Type Safe한 컴포넌트 만들기
지금까지 JavaScript로만 컴포넌트를 작성했다. 타입 추론이 되지 않고, 실제로 존재하지 않는 상태나 props에 접근을 해도 에러가 발생하지 않는다.
이를 해결하기 위해 Typescript로 마이그레이션을 해보자.
// Component.ts
export default class Component
<Props, State>
{
#element: HTMLElement;
#props
: Props;
state
: State;
constructor(
props: Props
) {
this.#props = props;
// ..
}
setState(
nextState: Partial<State>
) {
this.state = { ...this.state, ...nextState };
this.render();
}
// ..
}
Props와 State는 제네릭 타입으로, 각각 props와 state의 타입이 된다. 이는 아래와 같이 Component를 상속받을 때 뒤에 제네릭 타입을 선언해주면 된다.
interface FoodsProps {
foods: string[];
}
interface FoodsState {
foods: string[];
}
export default class Foods extends
Component<FoodsProps, FoodsState>
{
setup() {
this.state = {
foods: this.props.foods,
};
}
자동완성이 되는 것을 확인할 수 있다.
문제점 1 : Props 혹은 State의 타입이 없다면 에러가 발생한다.
현재는 props와 state의 타입을 반드시 전달해야한다. 한 개라도 전달하지 않을 경우 아래와 같은 에러가 발생한다.

왜냐하면 우리가 Component를 선언할 때 Props와 State라는 제네릭 타입을 선언했기 때문이다.
제네릭 타입을 만들었으니 이는 필수적으로 선언해야 한다. 이 문제를 해결하기 위해서는 Props와 State에 기본값을 설정해주면 된다.
// Component.ts
export default class Component<Props = {}, State = {}> {
// ..
}
기본 타입으로 {}타입을 지정했다. Record<string, never>, object, 혹은 never도 가능하지만 리액트와 같은 라이브러리에서도 일반적으로 사용하는 패턴이기에 {}를 사용했다.
Record<string,any>vsobjectvs{}
Record<string,any>: Record<Keys,Type>는 Keys를 키값으로, Type을 Value값으로 하는 객체 타입을 생성하는 유틸리티 타입이다.Record<string,any>는 key로 문자열, value값으로는 어떤 값이 와도 된다는 걸 의미한다. 이 방법 또한 내가 원하는 방향과 일치해보인다.object: 모든 객체 타입(객체, 배열, 함수 등)만을 허용하는 타입이다.{}: null과 undefined를 제외한 모든 값의 타입을 말한다. 숫자, 문자열, 배열, 객체 등 많은 타입을 의미하며 일반적으로{}보다는 특정 속성을 가진 인터페이스나 타입 별칭을 사용할 것이 권장된다.
문제점 2 : Props의 타입을 string으로 지정한다면?
우리가 원하는 방식은 props = { foods : [’a’,’b’], id = 5 }와 같은 객체일 것이다. 그런데 현재 Props와 State의 제네릭 타입에는 어떤 타입도 들어갈 수 있다. 이러한 제네릭 타입의 유형을 강제화 하기 위해 extends를 활용할 수 있다.
// Component.ts
export default class Component<
Props extends object = {},
State extends object = {}
> {
// ..
}
extends
extends가 다양한 의미(클래스 상속, 인터페이스 확장 등)으로 쓰일 수 있지만 위와 같이 제네릭 타입 선언부 내에서 쓰일 때는 타입 제약 조건의 역할을 수행한다. 타입 제약 조건은 제네릭 타입 파라미터가 특정 조건을 만족하도록 제한하는 메커니즘이다.
function someFunction<T extends 제약조건>(arg: T): T { // 함수 내용 return arg; }여기서
T extends 제약조건은 "T는 제약조건의 서브타입이어야 한다"는 의미이다.
위와 같이 object을 extends하면 해결이 될까? object는 모든 객체 타입을 허용한다. 그렇기에 아래와 같이 우리가 원하는 리터럴 객체가 아니여도 에러가 발생하지 않는다.
export default class Foods extends Component<string[], FoodsProps> {
그렇다면 Record<string,any>는 어떨까?
// Component.ts
export default class Component<
Props extends Record<string,any> = {},
State extends Record<string,any> = {}
> {
// ..
}
// Foods.ts
export default class Foods extends Component<string[], FoodsProps> {
Record<string,any>는 key에 string타입, value에 어떤 타입이 오는 모든 객체를 허용한다. 이는 Date, Map같은 객체 타입이 오는 것을 방지할 수 있다. 하지만 예외가 있다. string[]같은 배열 객체에서는 에러가 발생하지 않는다. 이는 자바스크립트에서 배열의 특징과 관련이 있다. 자바스크립트에서 배열은 key가 index, value가 배열의 각 원소인 객체이기 때문이다.
다시말해 아래와 같은 배열은
[1,2,3]
아래와 같은 객체라 할 수 있다.
{
"0":1,
"1":2,
"2":3,
"length":3
}
정리하면, 자바스크립트에서 배열은 내부적으로 객체이기 때문에 Record<string,any> 타입에 충족한 것이다.
이를 해결하기 위해서는 타입을 좁혀줘야한다. 우리가 해결하고 싶은 문제는 ‘배열이 Record<string,any> 타입에 해당한다’는 것이다. 그리고 배열은 length프로퍼티를 가진 리터럴 객체라는 특징이 있다. 그렇다면 length 프로퍼티를 가지지 않은 객체로 좁히면 어떨까?
interface StrictObject {
[key: string]: any;
length?: never; // 배열 제외
}
export default class Component<
Props extends StrictObject = {},
State extends StrictObject = {}
> {
// Foods.ts
export default class Foods extends Component<string[], FoodsProps> {
// Type 'string[]' does not satisfy the constraint 'StrictObject'.
// Types of property 'length' are incompatible.
// Type 'number' is not assignable to type 'undefined'.ts(2344)
유레카! 원하던대로 에러가 발생한다!
전체 코드
맺으며
innerHTML 한 줄로 시작한 코드가 어느새 setState를 가진 컴포넌트 클래스가 됐다. 만들면서 리액트가 왜 그렇게 생겼는지 하나씩 납득이 갔다. 상태가 바뀌면 다시 렌더링해야 하니까 setState가 있고, 매번 새 요소를 만들면 안 되니까 인스턴스가 요소를 들고 있는 것이었다.
바닐라 자바스크립트만을 이용해 웹 프로젝트를 구현한다는 것은 참 흥미로운 경험이었다. 바닐라 자바스크립트를 이용해 직접 컴포넌트를 만들며 자바스크립트에 대한 자신감이 느는 것이 느껴졌다. 이전에는 리액트에서 제공해주는 기능만 주로 사용하고, 만약 필요하다면 서드파티 라이브러리에 의존하는게 전부였다. 하지만 직접 구현하고 나니 만약 리액트에서 기능을 제공해주지 않으면 직접 만들 수 있을 것이라는 자신감이 생겼다.
이번 우아한테크코스에서 과제에서 위 컴포넌트를 활용한 프로젝트를 공유하며 글을 마무리하겠다.
https://github.com/guesung/javascript-lunch/tree/step2
https://github.com/guesung/javascript-movie-review/tree/step-2