학습 키워드
- FSD
- SCSS
- bem 방법론
- Web Component
- 논블로킹/블로킹과 동기/비동기
- nodejs 디버깅
- express
- JIT 컴파일러
- express html 모듈
- 이벤트 위임
- DOM API
- 드래그앤드롭
SCSS
scss는 브라우저가 읽을 수 없으므로, css로 컴파일한 다음에 css파일을 붙여야지 원할하게 작동할 수 있다.
컴파일의 경우 vscode의 live sass Compiler를 쓰면 된다.

vscode의 setting.json 설정
"liveSassCompile.settings.generateMap": false,
"liveSassCompile.settings.formats": [
{
"format": "expanded",
"extensionName": ".css",
"savePath": "~/"
}
]sass Compile 옵션 추가하려면 vscode의 setting.json에서 수정
- generateMap → 맵 파일(매핑할 때) 생성 여부
.map파일 sass를 컴파일할 때 생기는 map파일은 브라우저에게 제공하기 위한 목적으로 생성되는 파일이다 css파일이 압축되지 않고 .map파일이 생성되면 디버깅에서 scss 파일의 라인이 나오므로 개발자들이 브라우저의 개발 툴을 통해 라이브로 컴파일된 css가 아닌 scss 파일 자체의 디버깅이 가능해진다.
- format → 가독성이 높은 형태의 포맷
- extensionName → 확장자 이름
- savePath → 저장 경로
웹 컴포넌트
ES6 이전에는 가능한 한 코드를 재사용하는 것이 좋은 생각이라고 생각은 했지만, 당시 기술력으로는 한계가 있었다. 하지만 ES6가 나오면서 기본적인 html에서도 리액트와 같은 컴포넌트 개발 기법과 비슷한 개발 방법론을 사용할 수 있게 되었다.
컴포넌트 기반 아키텍처(CBA)의 핵심 원칙을 지키면서 컴포넌트를 설계해보자
모듈성: 소프트웨어는 독립적인 컴포넌트로 나뉘며, 각 컴포넌트는 특정 기능을 수행하는 모듈 역할을 함 재사용성: 컴포넌트는 다양한 애플리케이션에서 재사용될 수 있어야 함 독립성: 컴포넌트는 독립적으로 운영될 수 있으며, 다른 컴포넌트에 최대한 의존하지 않아야 함
구성요소
웹 컴포넌트는 세 가지 요소로 구성된다.
- Custom elements
- 사용자 정의 요소 + 해당 동작을 정의하는 js
- Shadow DOM
- 메인 DOM에서가 아닌 독립적으로 렌더링되는 js api의 집합
- HTML
<template>과<slot>과 같이 렌더링된 페이지에 나타나지 않는 마크업 템플릿과 커스텀 요소를 활용하여 재사용 가능한 컴포넌트 제작
기본 사용법
Class를 활용한 컴포넌트 명시
class MyWebComponent extends HTMLElement {...};
window.customElements.define('my-web-component', MyWebComponent);커스텀 요소의 등록
class MyComponent extends HTMLElement{
constructor() {
super();
/*called when the class is
instantiated
*/
}
connectedCallback() {
/*called when the element is
connected to the page.
This can be called multiple
times during the element's
lifecycle.
for example when using drag&drop
to move elements around
*/
}
disconnectedCallback() {
/*called when the element
is disconnected from the page
*/
}
}여기서 중요한 점은 라이프사이클 콜백인 connectedCallback과 disconnetedCallback이다.
라이프사이클이란 웹 컴포넌트가 생성부터 소멸까지 주기가 있으며, 일어날 수 있는 일들이 정해져 있다는 것이다.
그래서 이 connectedCallback은 컴포넌트가 DOM 트리에 추가되었을 때 트리거 되는 콜백이며, disconnectedCallback은 요소가 제거되었을 때 트리거되는 콜백으로 이 둘을 통해 컴포넌트의 생명주기를 보다 유용하게 관리할 수 있다.
Shadow DOM 등록을 통한 캡슐화
ShadowDOM이란 숨겨진 DOM트리로, 기존 DOM에 붙일 수 있다. shadowDOM에서 일어나는 스타일링, 자신의 append, attribute 설정 등은 외부의 트리에 어떠한 영향도 끼치지 않기 때문에 캡슐화가 가능하다.
<!DOCTYPE html>
<html>
<body>
<template>
<h1>Hello Rick!</h1>
</template>
<my-component></my-component>
<script>
class MyComponent extends HTMLElement {
constructor() {
super();
this.addEventListener('click',
() => {
this.style.color === 'red'
? this.style.color = 'blue':
this.style.color = 'red';
});
}
connectedCallback() {
/*called when the element is
connected to the page
*/
this.style.color = 'blue';
const template =
document.querySelector('template');
const clone = document.importNode(template.content, true);
//this.appendChild(clone);
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(clone);
}
}
customElements.define('my-component', MyComponent);
</script>
</body>
</html>shadowDOM을 붙이고 싶을 때에는 attachShadow를 통해 DOM에 추가한다.
let shadow = this.attachShadow({mode: 'open'});template와 slot 태그의 사용
<body>
<!-- html 템플릿 -->
<template id="helloWorld">
<h1>Hello, world!</h1>
</template>
<script src="main.js">
// div태그 요소하나를 생성하고,
const $div = document.createElement('div');
// helloWorld라는 id를 가진 템플릿요소의 내용을 복제하는 true 속성을 주면 children까지 복제합니다.
// https://developer.mozilla.org/ko/docs/Web/API/Node/cloneNode
$div.append(helloWorld.content.cloneNode(true));
// DOM의 바디에 붙임
document.body.append($div);
</script>
</body>Nonblocking/Blocking I/O && synchronous/Asynchronous
동기/비동기와 블로킹/논블로킹은 표현 형태가 비슷하지만, 서로 가르키는 차원이 다르다.
- 동기/비동기 → 요청한 작업에 대해 완료 여부와 이에 따른 작업의 수행 순서
- 블로킹/논블로킹 → I/O 작업이 차단/비차단으로 이루어져 다음 작업들이 곧바로 실행될 수 있는지
동기와 비동기
동기와 비동기는 주로 성능과 연결되는 중요한 부분이다.
클라이언트를 예시로 들어보자면 요청한 작업에 대해서 순차적으로 작업을 처리하게 된다면, 화면은 하나씩 켜져가는 사용자 경험을 겪을 것이고, 성능 또한 작업 시간이 오래 걸리게 된다.
하지만 비동기의 경우는 작업의 처리에 대해서 클라이언트가 신경쓰지 않고 기존에 하던 일들을 그대로 작업한다. 그렇기 때문에 모든 작업들이 메인 스레드가 아닌 워커쓰레드에 작업이 위임되며, 넘겨주는 클로저를 통해 작업 완료 후에 콜백을 실행하는 형태가 되기 때문에 모든 작업들이 병렬적으로 처리가 될 수 있다.

그렇게 된다면 비동기는 동기 방식에 비해 성능이 훨씬 좋아질 수 있는 것이다.
블로킹과 논블로킹

블로킹과 논블로킹은 자바스크립트를 기준으로 할 때, 자바스크립트가 node.js를 통해서 IO를 수행하는 과정에서 커널의 도움을 받는다.
블로킹의 경우는 다른 요청의 작업을 처리하기 위해 현재 작업을 block, 즉 차단하는 것이다. 얼핏 보면 동기와 차이가 뭐가 있냐고 생각할 수 있는데, 동기의 경우는 작업의 흐름을 순차적으로 진행되도록 하는 것이라면, 블로킹의 경우는 작업의 흐름 자체를 막는 것이다.
이를 OS의 커널에서 사용하는 제어권이라는 개념으로 구별할 수 있는데, 블로킹의 경우 제어권을 호출된 함수가 호출한 함수에게 제어권을 넘기기 때문에 호출한 함수의 완료까지 제어권이 없는 상태이기 때문에 작업의 흐름 자체가 막히게 되는 것이다.
이벤트 위임
공통적인 요소들에 대해서 똑같은 html 구조를 가진 부분들을 재사용성을 높여 다른 곳에서도 유용하게 사용할 뿐만 아니라 개별적인 서버의 templating 작업에서도 유용하게 쓰일 수 있는 방식을 사용하게 되면 전체적인 코드의 길이를 줄일 수 있을 뿐만 아니라 다른 페이지에서도 사용할 수 있어 활용도가 높다.
pug 템플릿 엔진의 경우, 이를 mixin 기능을 통해 구현할 수 있었는데, 이 mixin은 argument를 받고 이에 해당하는 html 요소들을 동적으로 생성할 수 있다는 점에서 우리가 알던 리액트와 같은 컴포넌트의 구조와 비슷하여 알아보거나 처리하기 쉽다는 장점을 가졌다.
mixin card(title,detail,author,date,id)
style
| @import url('/shared/card/css/card.css')
include /shared/deleteConfirm/component/deleteConfirm.pug
div.cardContainer(id=`${id}` draggable="true" data-date=`${date}`)
form(action="post").editform(hidden)
.form__div--cardForm
input.form__input--title(type="text" placeholder="제목을 입력하세요" value=`${title}`)
textarea.form__textarea--cardDetail(type="text" placeholder="내용을 입력하세요" rows=1 maxlength=500)#cardDetail #{detail}
.form__div--buttonWrapper
input.form__input__cancel-button(type="button", value="취소")
input.form__input__submit-button(type="button", value="등록")
li.generalCard(data-drag=id)
.articleWrapper
article
p.title #{title}
p.detail #{detail}
p.author author by #{author}
aside
img.deleteTodo(src="../asset/close.svg", alt="delete", data-close=id)
img.activeEditmode(src="../asset/pen.svg", alt="edit", data-edit=id)
+deleteConfirm("todo")해당하는 컴포넌트는 각각의 todolist 안의 카드들이며, 이를 +card(title,detail,author,date,id)를 통해 계속해서 재사용할 수 있는 형태로 바꿔주었다.
하지만 개별 카드에 해당하는 이벤트를 등록하기 위해 해당 컴포넌트 안에서 스크립트를 제작하고 붙여주었는데, 문제는 해당하는 이벤트가 3번이 실행되었다.
문제를 파악해보니 section이 3개로 나뉘어져 이 또한 컴포넌트로 구현했었는데, 재사용하는 컴포넌트의 문제점은 id가 모두 같아 이를 판별하기 어렵다는 점이다. 모든 컴포넌트들에서 등록했던 이벤트가 각각 실행되어 3번이 실행되는 문제가 생긴 것이다.
기존의 문제를 해결하기 위해서는 상위 컴포넌트에서 각 컴포넌트에 해당하는 이벤트를 개별적으로 등록해주는 방법이 있었는데,
- querySelectorAll를 통해 모든 컴포넌트들을 가져와 forEach문을 통해 각각의 요소에 이벤트 등록
- 상위 컴포넌트에서 이벤트를 위임하여 처리 가 있었다. 하지만 1번의 경우 개별적인 컴포넌트들이 많아지게 되면 각각에 대해서도 모든 이벤트 핸들러를 등록해줘야 하기 때문에 성능상에 이슈가 생길 수 있다. 따라서 상위에서 모두 공통적으로 작용할 수 있는 이벤트를 하나 등록하고, 위임하여 해당하는 컴포넌트를 구분 한 후에 처리할 수 있도록 하는 이벤트위임을 선택하였다.
document.getElementById("sections").addEventListener("click", (event) => {
// 각 섹션별 이벤트들
let addTodo = event.target.dataset.addform;
if (addTodo) {
// 각 폼에 이벤트 위임
let form = document.getElementById(addTodo);
// 보이게 하는 속성
form.hidden = false;
// 할일 추가 폼 입력란 크기 조절
let detailInput = form.getElementsByClassName(
"form__textarea--cardDetail"
)[0];
detailInput.addEventListener("input", adjustTextInput);
// 창 닫기 폼
let cancelButton = form.getElementsByClassName(
"form__input__cancel-button"
)[0];
cancelButton.addEventListener("click", () => closeForm(form));
let submitButton = form.getElementsByClassName(
"form__input__submit-button"
)[0];
submitButton.addEventListener("click", () => {
console.log("submit event");
});
}
// 각 카드 관련 이벤트들
// 카드 파트(편집 모드 열기)
let editId = event.target.dataset.edit;
if (editId) {
const editComponent = document.getElementById(editId);
// Edit mode 활성화
editComponent.getElementsByClassName("editform")[0].hidden = false;
// 할일 추가 폼 입력란 크기 조절
let detailInput = editComponent.getElementsByClassName(
"form__textarea--cardDetail"
)[0];
detailInput.addEventListener("input", adjustTextInput);
// 카드 수정 -> 취소 눌렀을 때
let cancelButton = editComponent.getElementsByClassName(
"form__input__cancel-button"
)[0];
cancelButton.addEventListener("click", () => closeEditForm(editComponent));
// 일반 카드 숨기기
const generalCard = editComponent.getElementsByClassName("generalCard")[0];
if (generalCard) {
generalCard.style.display = "none";
}
}
// 카드 삭제하기
let closeId = event.target.dataset.close;
if (closeId) {
const closeComponent = document.getElementById(closeId);
closeComponent.getElementsByClassName("modalBackground")[0].style.display =
"flex";
closeComponent
.getElementsByClassName("cancelButton")[0]
.addEventListener("click", () => {
closeComponent.getElementsByClassName(
"modalBackground"
)[0].style.display = "none";
});
closeComponent
.getElementsByClassName("conFirmButton")[0]
.addEventListener("click", () => {
closeComponent.getElementsByClassName(
"modalBackground"
)[0].style.display = "none";
closeComponent.remove();
});
}
});FSD 패턴
Feature-sliced Design은 모듈 간의 느슨한 결합과 높은 응집력을 제공할 수 있으며, 쉽게 확장할 수 있는 아키텍처이다.
이러한 패턴은 세 가지 구분 개념이 있다.

Layer
- app
- 애플리케이션 로직이 초기화되는 곳
- 프로바이더, 라우터, 전역 스타일, 전역 타입 선언 등
- 애플리케이션의 진입점 역할
- processes(depricated)
- 이 레이어는 여러 단계로 이루어진 등록과 같이 여러 페이지에 걸쳐 있는 프로세스를 처리
- 이 레이어는 더 이상 사용되지 않는 것으로 간주되지만 여전히 가끔씩 마주할 수 있습니다. 선택적 레이어입니다.
- pages
- 이 레이어에는 애플리케이션의 페이지가 포함됩니다.
- widgets
- 페이지에 사용되는 독립적인 UI 컴포넌트
- features
- 이 레이어는 비즈니스 가치를 전달하는 사용자 시나리오와 기능
- 좋아요, 리뷰 작성, 제품 평가 등이 있습니다.
- 선택적 레이어
- entities
- 비즈니스 엔티티
- 사용자, 리뷰, 댓글 등이 포함될 수 있습니다.
- 선택적 레이어
- shared
- 이 레이어에는 특정 비즈니스 로직에 종속되지 않은 재사용 가능한 컴포넌트와 유틸리티
- 여기에는 UI 키트, axios 설정, 애플리케이션 설정, 비즈니스 로직에 묶이지 않은 헬퍼 등
Slices
슬라이스는 특정 엔티티에 대해서 코드를 그룹화 한다.
이와 같이 필요한 값에 대해서 독립적으로 다룰 수 있는 디렉토리를 생성한다.
세그먼트
각 슬라이스는 세그먼트로 구성되며 목적에 따라 슬라이스 내의 코드를 나누는데 도움이 될 수 있다.
- api - 필요한 서버 요청.
- UI - 슬라이스의 UI 컴포넌트.
- model - 비즈니스 로직, 즉 상태와의 상호 작용. actions 및 selectors가 이에 해당
- lib - 슬라이스 내에서 사용되는 보조 기능.
- config - 슬라이스에 필요한 구성값이지만 구성 세그먼트는 거의 필요하지 않음.
- consants - 필요한 상수.
나는 이러한 FSD를 일부 참고하여 나만의 디렉토리 구조를 만드는데 활용하였다. 하지만 중요한 점인 슬라이스와 세그먼트의 본질에 어긋나지 않도록 조율하였다.

이벤트 전파와 위임
DOM API를 통해 이벤트를 등록하고 이를 실행할 때면, 내가 원하는 예상대로 동작하지 않는 경우가 꽤 많다. 이는 이벤트 전파에 의한 예외 상황을 고려하지 않았을 때 주로 발생하며, 이에 이벤트 전파와 위임에 대해 자세히 알아볼 필요가 있다.
이벤트 전파
DOM 트리 상에 존재하는 DOM 요소의 노드에서 이벤트가 발생하면, 이벤트 객체는 DOM 트리를 통해 다른 DOM 요소 노드로 전파된다. 이를 이벤트 전파라고 한다.
쉽게 말해보자면, 트리 구조로 이루어진 DOM이니만큼, 계층적인 구조를 가지고 있어 이벤트가 일어날 경우 해당 태그에서만 하나의 이벤트를 등록하고 싶어도, 부모 요소에도 이벤트가 달려 있다면 부모 요소의 이벤트도 연쇄적으로 일어난다는 것이다.
이벤트 전파의 종류
이러한 이벤트의 전파는 전파 방향에 따라 버블링과 캡처링으로 나눌 수 있다.
- 버블링(Bubbling): 자식 요소 → 바깥 부모 요소로 전파
- 캡처링(Capturing): 부모 요소 → 자식 요소 순서대로 계속해서 이벤트 전파
표준 DOM 이벤트

표준 DOM 이벤트에서는 이벤트 흐름을 3단계로 정의한다.
- 캡처링 단계 → 이벤트가 하위 요소로 전파
- 타깃 단계 → 이벤트가 실제 타깃 요소에 전달
- 버블링 단계 → 이벤트가 상위 요소로 전파
위의 그림처럼 td를 클릭하면 맨 처음 캡처링 단계를 통해 이벤트가 전파되고, 이벤트가 타깃 요소에 도착하면 해당하는 이벤트가 실행되며, 다시 위로 올라가며 버블링 단계를 거치면서 요소들에 할당된 이벤트 핸들러들이 실행된다
<!doctype html>
<body>
<style>
body * {
margin: 10px;
border: 1px solid blue;
}
</style>
<form>FORM
<div>DIV
<p>P</p>
</div>
</form>
<script>
for(let elem of document.querySelectorAll('*')) {
elem.addEventListener("click", e => alert(`캡쳐링: ${elem.tagName}`), true);
elem.addEventListener("click", e => alert(`버블링: ${elem.tagName}`));
}
</script>
</body>이런 식으로 모든 요소에 이벤트를 달아주게 되면, 타깃을 눌렀을 때, 처음 해당 요소로 들어가기까지 캡처링을 통해 모든 이벤트들이 부모요소의 수만큼 실행되게 되고, 타깃에 다다른 후 다시 돌아올 때는 다시 노드를 올라가면서 버블링을 거치기 때문에 다시 부모요소의 수만큼 이벤트 핸들러가 실행된다.
이를 단계별로 정리하면
HTML→BODY→FORM→DIV(캡처링)P(타깃 단계로 캡쳐링과 버블링 둘 다 리스너를 설정해서 두 번 호출됨)DIV→FORM→BODY→HTML(버블링) 으로 총 10번의 이벤트가 발생하게 되는 것이다.
왜 그럴까?
사실 부모요소와 자식 요소 둘 다 이벤트가 등록되어 있을 경우, 자식요소의 이벤트만 실행시키려고 할 때, click을 예시로 들어보자면 사실상 자식요소만을 누른다고 해도 결국은 부모 요소의 자식이기 때문에 부모 요소의 이벤트 또한 작동할 수밖에 없다. 계층적인 구조에서는 당연한 일이라고 생각한다. 어찌보면 자식 요소를 클릭한 것이 부모 요소를 클릭한 것과 같은 셈이니까.
전파 흐름 제어하기
상황에 따라서는 유용하게 사용할 수도 있지만, 캡처링의 경우는 부모 요소의 이벤트들을 따로 실행시키지 않고 내가 이벤트를 등록한 타겟요소의 이벤트핸들러만 실행시키고 싶은 때가 있을 수도 있다.
stopPropagation
그럴 때 필요한게 이벤트의 stopPropagation 메소드이다.
이 메소드는 버블링 또는 캡처링 설정에 따라 상위, 하위로 가는 이벤트 전파를 차단함으로써 내가 원하는 타겟 요소의 이벤트핸들러만 실행시킬 수 있는 환경을 제공할 수 있다.
<html>
<script>
const ancestor = document.querySelector("#ancestor")
const parent = document.querySelector("#parent")
const child = document.querySelector("#child")
let count = 1;
ancestor.addEventListener("click", (e) => {
e.stopPropagation()
print('ancestor')
})
parent.addEventListener("click", (e) => {
e.stopPropagation()
print('parent')
})
child.addEventListener("click", (e) => {
e.stopPropagation()
print('child')
})
function print(name) {
document.querySelector("section")
.insertAdjacentHTML("beforeend",`<p>${count++}. ${name} clicked</p>`);
}
document.body.addEventListener("click", (e) => {
[...document.querySelector("section").children].forEach(e => {
e.remove();
})
count = 1;
}, true)
</script>
<body>
<div id="ancestor">
<div id="parent">
<div id="child"></div>
</div>
</div>
<section></section>
</body>
</html>이렇게 한다면 각 요소에 있는 이벤트를 버블링 및 캡처링 하는 과정에서 불필요한 이벤트 핸들러의 실행을 차단시킬 수 있다. 이를 통해 각 요소의 이벤트 리스너만 동작시킬 수 있는 것이다.
stopImmediatePropagation
이러한 부모-자식 요소의 html element 뿐만 아니라, 형제 요소들의 이벤트의 실행 또한 제어할 수 있는데, 이는 stopImmediatePropagation() 메소드를 통해 여러 개의 이벤트를 실행시키지 않고 하나의 이벤트만 실행시킬 수 있다.
child.addEventListener("click", (e) => {
if(조건)
e.stopImmediatePropagation()
print('child')
})
child.addEventListener("click", (e) => {
print('child 2')
})target에 조건을 걸은 이벤트 핸들러 컨트롤
기존의 이벤트에 대해서 메소드들을 통해서 캡처링, 버블링을 중간에 차단시키는 방법도 있지만, target element에 dataset attribute를 이용해서 조작하는 방법도 있다.
html의 dataset 속성은 커스텀 사용자 속성을 DOM 요소에 직접적으로 저장할 수 있는데, 이렇게 저장한 요소의 경우 html 내에서 동작하는 자바스크립트 변수와 같은 느낌이 있어 문자열 데이터를 넣어놓고 상태를 조작하거나 특정 target의 id 등을 저장해놓는 방식에도 잘 활용된다.
<input type="text" data-country="Norway" data-code="c03" name="Country">이런 식으로 앞에 data- prefix를 붙이고 내가 쓰고 싶은 데이터의 속성명을 기재해주고, 이를 자바스크립트의 이벤트 핸들러안에서 찾아서 특정 요소만 검색하는 방식으로도 가능하다
const $div = document.getElementById('post');
// 일반적인 객체 속성 접근
$div.dataset.code // "c03"
// 배열 인덱스로 접근
$div.dataset['name'] // "Country"
// data-country에서 dataset.country로 변환됨
$div.dataset.country // "Norway"이러한 방식은 조금 더 각각의 요소에 유니크한 값을 넣어놓고 정밀하게 조정할 때 유용한데, 이 부분은 이벤트 위임에서 계속 쓸 예정이다.
이벤트 위임
이처럼 브라우저는 3단계의 과정을 거치기 때문에, 버블링과 캡처링이 내가 예상한 동작 외의 이벤트들 또한 실행될 수 있다는 단점이 있지만, 이를 활용하여 유용하게 사용할 수도 있다.
어차피 부모 요소의 이벤트 또한 자식 요소에도 영향을 끼치는데, 그렇게 한다면 여러 개의 컴포넌트가 있었을 때, 가장 상위에서 이벤트를 등록해놓는다면, 이를 통해서 여러 개가 있는 자식 요소들을 모두 하나의 이벤트 등록으로도 관리할 수 있다는 발상이 가능해진다. 이러한 방식을 이벤트 위임이라고 한다.
<table>
<tr>
<th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th>
</tr>
<tr>
<td class="nw"><strong>Northwest</strong><br>Metal<br>Silver<br>Elders</td>
<td class="n">...</td>
<td class="ne">...</td>
</tr>
<tr>...2 more lines of this kind...</tr>
<tr>...2 more lines of this kind...</tr>
</table>위와 같은 예시를 javascript.info 에서 가져왔다.
해당 테이블에 있는 각각의 td 요소들을 누르면 누른 td 요소가 highlight되는 효과를 부여하려 한다.
이를 위해서는 크게 두 가지 방법이 있다.
- 해당하는 모든 td 요소를
querySelectorAll로 검색한 뒤forEach를 통해 캡처링 & 버블링 이벤트 제어 하는 이벤트를 등록한다. - 부모요소에서 이벤트를 등록하면서 캡처링 방식으로 넘겨주되, 특정한 data attribute를 통해 각각의 요소를 구분하고, 뽑아낸 특정 요소에 대한 이벤트 핸들링
하지만 1번의 경우 모든 요소에 대해 이벤트를 일일이 등록해주는 행위 자체는 성능성의 이슈가 있기도 하고, 새로운 요소가 추가되었을 경우에도 계속해서 이벤트를 직접 추가해줘야 하므로 상당히 비효율적이라고 생각한다.
따라서 2번의 방식을 생각하게 되는데, 이 부분의 경우 세심하게 고려할 부분이 있다.
event의 target 고려하기
이벤트 핸들러에 parameter로 오는 event 객체에는 target이라는 속성이 있다. 해당 target 속성은 이 이벤트가 이루어진 요소가 어떤 html 요소인지를 보여준다.
let selectedTd;
table.onclick = function(event) {
let target = event.target; // 클릭이 어디서 발생했을까요?
if (target.tagName != 'TD') return; // TD에서 발생한 게 아니라면 아무 작업도 하지 않습니다,
highlight(target); // 강조 함
};
function highlight(td) {
if (selectedTd) { // 이미 강조되어있는 칸이 있다면 원상태로 바꿔줌
selectedTd.classList.remove('highlight');
}
selectedTd = td;
selectedTd.classList.add('highlight'); // 새로운 td를 강조 함
}그래서 해당 타겟의 html element를 보고 해당 element에 효과를 넣어주는 방식으로 이벤트 위임을 할 수 있다.
하지만 event.target의 중요한 점은 클릭이 이루어진 해당 ‘요소’ 를 정확히 찾아내 리턴한다는 점이다.
따라서 td를 포함하는 td안의 들어있는 모든 요소 대해서 이벤트가 실행되도록 하고 싶은데, 만약에 td 요소 안의 strong 태그 안에 있는 텍스트를 누르게 되면 event.target 으로 받은 html element가 td 안에 들어있는 strong element만을 리턴한다.
따라서 내가 이벤트를 등록하려는 Html element의 범위에 대해서 생각하고, 등록하려는 html element 안의 요소를 클릭했을 때, 어떤 방식으로 해당 부모 요소까지 조작할 지에 대해 고려해야 할 필요성이 있다.
closest 메소드 활용하기
이를 쉽게 할 수 있는 방법 중 하나는 html element의 closest 메소드를 활용하는 것이다.
closest메소드를 활용하여 내가 누른 요소와 가장 가까이에 있는 타켓 요소를 선택할 수 있도록 할 수 있다.
table.onclick = function(event) {
let td = event.target.closest('td'); // (1)
if (!td) return; // (2)
if (!table.contains(td)) return; // (3)
highlight(td); // (4)
};이를 통해 누른 요소의 가장 가까운 td 요소를 가져와 해당 요소에 대해서 조작을 해줌으로써 세심하게 이벤트를 제어할 수 있다.
dataset 활용하기
html 요소의 attribute에 데이터를 넣어 사용하는 dataset을 활용하는 방식은 closest만큼, 혹은 그보다 더 세심하게 자식 요소의 기능을 관리할 수 있는 방식이다.
<table>
<tr>
<th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th>
</tr>
<tr>
<td class="td0"><strong>Northwest</strong><br>Metal<br data-action="silver" data-target="td0">Silver<br>Elders</td>
<td class="td1">...</td>
<td class="td2">...</td>
</tr>
<tr>...2 more lines of this kind...</tr>
<tr>...2 more lines of this kind...</tr>
</table>해당 코드에서는 dataset의 action이라는 데이터에 부모 요소의 안쪽 요소들의 세부적인 기능을 제어하기 위한 기능적인 명시를 해두었다. 이러한 div 태그에는 각각 유니크한 id가 붙어있고, 가장 상위에 있는 table이나 html의 body 자체에 이벤트를 등록해두는 것이다.
let selectedTd;
table.onclick = function(event) {
if(event.target.dataset.action === "silver"){
let target = event.target.dataset.target;
target = document.getElementByID(target)
highlight(target); // 강조 함
}
};
function highlight(td) {
if (selectedTd) { // 이미 강조되어있는 칸이 있다면 원상태로 바꿔줌
selectedTd.classList.remove('highlight');
}
selectedTd = td;
selectedTd.classList.add('highlight'); // 새로운 td를 강조 함
}이벤트에 대한 세부 기능을 data-action attribute로 넣어놓고 이를 포함하는 부모 요소의 unique한 id를 data-target attribute로 부여해주었다. 이렇게 되었을 경우 보다 부모 요소에서 세부적인 기능을 발견했을 경우 해당 이벤트와 그 기능에 대해서만 처리할 수 있도록 핸들링 할 수 있는 것이 유용하다.
class를 이용하여 요소 관리하기
<div id="menu">
<button data-action="save">저장하기</button>
<button data-action="load">불러오기</button>
<button data-action="search">검색하기</button>
</div>
<script>
class Menu {
constructor(elem) {
this._elem = elem;
elem.onclick = this.onClick.bind(this); // (*)
}
save() {
alert('저장하기');
}
load() {
alert('불러오기');
}
search() {
alert('검색하기');
}
onClick(event) {
let action = event.target.dataset.action;
if (action) {
this[action]();
}
};
}
new Menu(menu);
</script>class 컴포넌트를 이용해서 관리하는 방식은 보다 가독성이 뛰어나다고 생각한다. 클래스의 메소드를 활용하여 버튼마다 핸들러를 할당해주는 코드를 따로 작성할 필요가 없고, 다른 이벤트 핸들러의 수정 또한 메소드만 관리해주면 되기 때문에 각 기능들에 대해서도 관리하는 방식은 확실히 스마트하다.
constructor에서 onclick 이벤트에는 this를 바인딩하게 되는데, 바인딩된 요소는 해당 Menu 객체의 요소를 가리킨다. 바인딩된 onClick 이벤트는 캡처링을 통해 자식의 요소들의 onclick 이벤트 또한 감지하고, 이를 action으로 나누어 세부적인 기능들을 메소드로 구분할 수 있기 때문에 활용성을 보다 높였다고 생각한다.
장단점
이러한 이벤트위임의 장점은 앞에서도 이야기를 많이 했지만,
- 핸들러를 일일이 할당하지 않아 초기화가 단순해지고 메모리 절약
- 요소를 추가, 제거,수정 등에 대해서 할당된 핸들러를 따로 건들 필요가 없음
- innerHTML이나 유사한 기능을 하는 스크립트로 요소 덩어리를 더하거나 뺄 수 있기 때문에 컴포넌트 기반 개발이 편리함 등이 있다. 하지만 그럼에도 불구하고, 이벤트 위임에도 단점은 있다.
- 이벤트 위임을 사용하기 위해서는 이벤트의 버블링이 필수인데, 몇몇 이벤트는 버블링이 불가능한 이벤트도 있음 + 낮은 레벨에 할당한 핸들러에 stopPropagation 사용 불가
- 부모 요소에서 핸들러를 관리하기 때문에 그 안쪽의 모든 요소에 대해서 등록된 이벤트가 실행되기 때문에 CPU 작업부하
- 무시할만한 수준이라고는 한다.
끝내며
이벤트 전파와 위임에 대해 제대로 이해하는 것은 이번이 아마 처음이었던 것 같다. vanilla js를 거의 쓰지 않고 리액트만 주로 썼던 나에게는 어떠한 이벤트가 ‘전파’된다는 사실 자체가 생소하게 들렸다. 리액트는 주로 이벤트를 따로 등록할 때에는 attribute의 onClick 속성에 이벤트 핸들러 함수를 넣어주었고, 이러한 이벤트들은 각각의 이벤트 핸들러를 모두 가지고 있기 때문에 이벤트 위임 또한 생소하게 느껴졌다고 생각한다. 기본기가 탄탄해야 하나의 프레임워크에 의존되지 않고 여러 프레임워크를 빠르게 적응하고 사용할 수 있는 숙련도가 쌓이는 것 같다. 아무튼 이렇게 기존의 이벤트 핸들러를 보다 효율적이고 스마트하게 사용하는 법을 통해 다양하게 시도해보는 습관을 계속 쌓아나가는 것이 좋은 방식이라고 생각한다.
참조
https://inpa.tistory.com/entry/JS-%F0%9F%93%9A-%EB%B2%84%EB%B8%94%EB%A7%81-%EC%BA%A1%EC%B3%90%EB%A7%81 https://inpa.tistory.com/entry/JS-%F0%9F%93%9A-HTML-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%85%8Bdata-%EC%86%8D%EC%84%B1 https://ko.javascript.info/bubbling-and-capturing https://ko.javascript.info/event-delegation
express + 이벤트 위임 + pug로 SSR + CSR 구현하기
express로 처음 서버를 구동해보면서 이를 html+css+js와 섞는다면 SSR 뿐만 아니라 CSR 또한 내 마음대로 입맛따라 고를 수 있을 것 같았다. 그렇게 된다면 next.js와 비슷한 느낌이지 않을까도 싶다.
아무튼 맨 처음에 express를 시작했을 때는 전체적인 html 템플릿을 그대로 출력시켜 열었던 로컬호스트 주소로 접속하면 통채로 웹사이트를 렌더링하여 보내주는 SSR 방식으로 시작했지만, pug라는 템플릿 엔진이 생각보다 여러가지 쓸만한 구석이 많아 이를 좀 더 활용해서 CSR을 만들기 보다 쉽지 않을까 생각해서 시도해보았다.
pug를 처음 맛보기로 써봤는데, 생각보다 장점이 많았다.
그 중에서도 이번 templating에 사용하려는 pug의 api는 클라이언트 사이드 렌더링을 하기에 최적화된 api라고 생각했다.
const pug = require('pug');
// Compile the source code
const compiledFunction = pug.compileFile('template.pug');
// Render a set of data
console.log(compiledFunction({
name: 'Timothy'
}));
// "<p>Timothy's Pug source code!</p>"
// Render another set of data
console.log(compiledFunction({
name: 'Forbes'
}));
// "<p>Forbes's Pug source code!</p>"compileFile은 파일을 읽어들여 해당 템플릿을 통해 templating을 하는 함수를 반환한다. 이 외에도 각종 메서드들은 컴파일에 필요한 여러가지 상황을 상정하여 다양한 방식으로 컴파일한 후 렌더링 할 수 있는 방법을 제시한다.
- compile(source : string,?options : ?options) : function()
- 함수를 반환하고, 반환한 함수를 실행시키면 렌더링 시킬 수 있다
- compileFile(source : string,?options : ?options) : function()
- pug 파일을 읽어들여, templating 하는 함수를 반환
- compileClient(source : string,?options : ?options) : function()
- 문자열을 넣고 클라이언트 사이드에서 렌더링
- compileClientWithDependenciesTracked(source, ?options): function()
- 의존성을 가진 template 클라이언트 사이드에서 렌더링
- compileFileClient(path:string, ?options: ?options): function()
- 클라이언트 사이드에서 쓰일 수 있는 문자열로 templating 렌더링 관련 메서드
- render(source: string, ?options: ?options): string
- 문자열을 넣고 html 문자열로 리턴
- renderFile(source: string, ?options: ?options, ?callback: ?callback) : string
- html 문자열로 리턴
서버와 클라이언트 처리
서버: 컴파일한 다음 문자열로 보내기
해당 경우는 어떠한 요소를 추가했을 때 가져오는 기능이라고 생각해보자
app.post("/api", (req, res) => {
const { title, detail, section } = req.body;
const data = JSON.parse(readFileSync(`${DATAPATH}`, "utf-8"));
const data2 = JSON.parse(
readFileSync(`${DATAPATH}`, "utf-8")
);
// 서버 데이터에 추가하는 코드
//JSON으로 컴파일한 html 문자열 stringify
const template = JSON.stringify({
data: pug.compileFile(
path.join(
__dirname,
내 경로들
),
{
basedir: path.join(__dirname, "views"),
}
)({argument : value}),
});
res.type("Content-Type", "application/json");
res.json(template);
});서버에서는
- 데이터를 갱신
- 갱신한 데이터를 다시 가져오거나 받은 데이터에서 직접적으로 추가(이건 보내는 용도로)
- templating할 pug 파일을 pug의 compileFile을 통해 함수를 받기
- 갱신한 데이터를 compileFile의 리턴값으로 온 함수에 넣기
- 리턴값으로 온 string을 json 형식으로 만들어 클라이언트의 응답 body에 넣어 보내기 의 과정을 거쳐 새롭게 갱신된 데이터의 컴포넌트 html을 다시금 클라이언트로 보낼 수 있다.
클라이언트: api 요청하고 받은 값을 기준으로 해당하는 Html 교체
function handler(sectionId) {
...
submitButton.addEventListener("click", async () => {
const title =
targetSection.getElementsByClassName("form__input--title")[0].value;
const detail = targetSection.getElementsByClassName(
"form__textarea--cardDetail"
)[0].value;
const section = targetSection.id;
const response = await fetch("/api/todo/new", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: title,
detail: detail,
section: section,
}),
});
let data = await response.json();
data = JSON.parse(data);
document.getElementById("sections").innerHTML = data.data;
});
}사실상 서버에서 html 파일을 통채로 보내서 렌더링 시키는 서버 사이드 렌더링 방식인 만큼 클라이언트라고 불리는게 맞을까 싶다. 그냥 view라고 볼 수 있지만 편의를 위해 클라이언트라고 칭하겠다.
클라이언트에서는 이벤트 위임 방식을 통해 이벤트들을 등록하기 때문에 기존의 html을 제외한 정적 파일들은 모두 남아있는 것들을 활용할 수 있다고 생각했다.
innerHTML을 통해서 html을 교체하기 전에 기존의 js script로 들어간 js 파일을 개발자도구로 열어 script에 console.log하는 코드를 실행했는데 새롭게 렌더링이 이루어졌음에도 불구하고 스크립트 파일은 여전히 기존 파일을 유지하는 것을 볼 수 있다. 이러한 점은 새롭게 렌더링하는 과정에서 불필요한 정적 파일들을 불러오지 않는다는 이점을 가진다고 생각했다.
여러 컴포넌트로 복사된 html들에 이벤트 등록을 하는 방법은 이벤트 위임 뿐만이 아닌, querySelectorAll을 통해 해당하는 각각의 컴포넌트에 forEach를 돌면서 하나하나 이벤트 등록을 해줘야 하는 방식도 존재하지만 이러한 방식은 성능 상에 이슈가 있을 뿐만 아니라 새롭게 렌더링된 Html에는 이벤트 등록을 해주지 않은 상태에서 스크립트는 변하지 않았기 때문에 이벤트가 제대로 등록되지 않아 동작하지 않을 가능성이 있다.
TroubleShooting
1. 다중 attribute 문제
You should not have pug tags with multiple attributes.
사실 이건 해당 templating의 문제가 아닌 내 코드 문제였다

form(action="post").editform(hidden)나도 모르게 그냥 여러 개의 attribute를 콤마로 구분하지 않고 써놔서 warning이 떴다. 정상적으로 인식은 되지만 주의하자
2. mixin 컴파일링 불가능
이 문제에 대해서는 stackoverflow를 뒤져보고 했는데 결국 제대로 된 느낌의 답변을 찾지 못했다.
내 나름대로 생각한 점으로는 내가 이제까지 pug 파일을 컴파일 했던 때 사용했던 파일이 mixin형태로 arguments들을 받는 방식으로 될까 싶어서 했던건데, 다른 사용 예시들을 보니 mixin을 활용한 예시가 없었다.
아마도 html 컴파일 과정에서 mixin을 지원하지 않는건가 싶어서 일반 html으로 바로 컴파일이 가능한 pug 코드를 쓰니 정상작동되었다.
아무래도 mixin은 해당 템플릿 엔진에서 자체적으로 컴포넌트를 만드는 방식이나보니 compileFile에서는 아직 지원하지 않는 것이라 생각했다.
정리
기존에 있는 정적 파일들을 그대로 활용하면서 Html 파일만 교체했음에도 정상적으로 모두 작동한다는 점은 큰 이점을 가진다고 생각한다. ssr과 pug를 공부하면서, ‘pug를 통해서 보다 컴포넌트 단위로 나누는 것이 쉬워졌는데, 이러한 방식으로 페이지 전체를 새로고침하지 않아도 문제없을 정도로 계속 사용 가능한 html만 바꿀 수 있지 않을까?‘라는 생각에서부터 시작한 실험이었는데 생각보다 이벤트 위임 방식과 시너지를 잘 이루어 나름 야매(?) 클라이언트 사이드 렌더링 방식을 구현했다는 점이 의의가 있는 것 같다. 이번 시도를 통해 CSR과 SSR의 차이도 공부하고, 조금 더 잘 파악할 수 있는 기회가 되었다고 생각한다.
하지만 CSR임에도 불구하고 문제가 남아있었으니,, 기존의 placeholder 안에서 모든 html을 통채로 갈아치우다 보니 SSR처럼 모든 화면이 깜빡이지는 않더라도, 컴포넌트의 placeHolder의 일부분이 깜빡이는 부분은 조금 킹받는 부분이 있다. 이를 위해서라면, 컴포넌트에 있는 DOM 노드들을 검사해서 변동사항이 있는 부분만 갱신하는 식으로 해도 될 것 같은데, 이거는 조금 더 깊게 파고 들어가야하기 때문에 조금 더 설계해보려고 한다.