이번 리액트 수업시간 때, 모듈 파일을 내보내고 불러오는 과정에서 확실히 이해하지 못한 것 같아 이 글을 작성하게 되었습니다.

📌 모듈이란?

모듈이란, 개발을 하면서 애플리케이션이 방대해짐에 따라 파일을 분리해야할 경우가 발생한다. 이 때 분리된 파일을 모듈이라고 한다.

시간이 지나 모듈이 웹 표준에 등재되면서 브라우저와 Node.js 환경에서 모듈을 사용할 수 있게 되었다.

모듈 특징

  • 모듈파일을 사용하기 위해서는 import 해온 script 파일에 type 속성이 module로 설정되어 있어야한다.
1
<script type="module" src="index.js"></script>
  • 모듈 스코프를 가진다.
  • Strict mode
  • 지연 실행된다(defer) -> 스크립트의 상대적인 순서가 유지될 수 있는 이유
  • 확장자를 꼭 적어줘야한다.

모듈은 단독적으로 사용하는 경우는 드물고 주로 웹팩(Webpack)이라는 번들러를 사용하여 모듈을 한번에 묶어 서버에 올리는 방식을 사용한다.

웹팩에 대해서는 새로운 게시물에서 다뤄보도록 하고 이제 모듈을 불러오고 내보내는 방법에 대해 알아보자.

CommonJS vs ES6 방식

1. CommonJS

  • require : 가져오기 위한 키워드
1
2
// index.js
const sayHi = require("./sayHi.js");
  • exports : 내보내기 위한 키워드
1
2
3
4
// sayHi.js
const foo = () => console.log("sayHi~!");

module.exports = foo;

2. ES6 방식

  • import : 가져오기 위한 키워드
1
2
// index.js
import foo from "./sayHi.js";
  • export
1
2
3
4
// sayHi.js
const foo = () => console.log("sayHi~!");

export default foo;

둘의 차이에 대해 알아두고 앞으로는 ES6 문법을 사용할 것이니 이에 대해서 자세히 알아보자.

export

모듈을 내보내는 방식은 다양하다.

  1. 선언부에서 내보내기
1
export const name = "loco";
  • 단, 함수나 클래스를 내보내기할 경우에는 ; (세미콜론)을 붙이지 않아야 한다.
  1. 선언부와 떨어진 곳에서 내보내기
1
2
3
const name = 'loco';

export name;

만약 여러개를 내보내고 싶다면 객체로 감싸서 내보내기 해야한다.

1
2
3
4
5
6
7
8
9
10
// say.js
const sayHi = (name) => {
console.log(`Hi~ ${name}!`);
};

const sayBye = (name) => {
console.log(`Bye~ ${name}!`);
};

export { sayHi, sayBye };

import

1
2
3
4
5
// main.js
import { sayHi, sayBye } from "./say.js";

sayHi("John"); // Hi~ John!
sayBye("selly"); // Bye~ selly!

export 할 때, 객체형태로 감싸서 내보내기를 하였으면 불러올 때도 객체형태로 감싸 이름 그대로 가져와야한다.

하지만 가져와야할 함수나 변수가 100만개라면? 일일히 언제 써주기가 힘드니 이럴 경우 * (에스터리스트)를 사용하여 모두 가져오기하여 객체처럼 사용할 수 있다.

1
2
3
4
5
// main.js
import * as greeting from "./say.js";

greeting.sayHi("Yong"); // Hi~ Yong!
greeting.sayBye("Jin"); // Bye~ Jin!

하지만 이런 경우는 드물고, 대게 어떤 대상을 가져오는지를 구체적으로 하는 것 명시하는 것이 좋다.

왜냐하면 웹팩을 사용하면 로딩속도를 높이기 위해 최적화를 하게되는데 이 경우 사용하지 않는 리소스는 삭제되기 때문이다.

import as

as를 사용하여 이름을 바꿔서 가져올 수 있다. export 에서도 가능하지만 주로 import 에서만 사용하는 것이 명시적이다.

1
2
3
4
5
// main.js
import { sayHi as hi, sayBye as bye } from "./say.js";

hi("John"); // Hi~ John!
bye("John"); // Bye~ John!

export default

모듈은 크게 2가지 종류로 나뉘게 된다.

  1. 위 처럼 say라는 함수들이 다수있는 라이브러리 형태의 모듈
  2. 개체 하나만 선언된 모듈

유틸 함수들을 내보내는 경우가 아니라면 주로 2번의 경우를 선호한다.

해당 모듈에 개체가 1개만 있다는 것을 명시적으로 구분하기 위해 export default 키워드를 사용한다.

1
2
3
import validation from "./validation.js";

const isValid = validation();
  • export default로 내보내기한 경우에 가져오기할 때는 중괄호{}가 필요없고 원하는 이름으로 가져올 수 있다.
  • 또한, 개체 한개만 내보낸 것을 가져오기 때문에 export default로 내보낼 때는 식별자나 이름없이 내보낼 수 있다.

🔥 [22.11.09 추가]

❗️ component 폴더의 index.js 모아서 다시 내보내기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// components/ToggleButton/ToggleButton.js
export function ToggleButton() {
return <button></button>;
}

// components/index.js
export * from "./Demo";
export * from "./ToggleButton";
export * from "./A11yHidden";
export * from "./Banner";

// app/App.jsx
import { ToggleButton } from "components";
export default function App() {
<div className="App">
<ToggleButton />
</div>;
}
  • ToggleButton 컴포넌트를 이름 내보내기 해주고 있다.
  • 위와 같이 components 폴더의 index.js파일에 여러 컴포넌트를 다시 내보내기로 보내줄 때, 만약 ToggleButton 컴포넌트를 기본 내보내기를 해주었다면 아래처럼 index.js에서 다시내보내기 해줄 때, {}로 감싸고 default로 받은 객체를 이름으로 설정해주고 내보내줘야한다.
1
export { default as ToggleButton } from './ToggleButton";

왜냐하면, ToggleButton 이외의 다른 컴포넌트들도 default로 내보냈기 때문에 구분을 하기 위해서 다시 내보내기 할 때, 이름을 설정해줘야한다.

이러한 불편함 때문에 이름 내보내기를 사용한다.

❗️ 화살표 함수로 내보내기시 주의사항

1
2
3
export default ({ onText, offText, on, onToggle, activeClass }) => {
return <button>...</button>;
};
  • 위와 같이 화살표 함수로 한줄로 작성하여 익명함수로 내보내고 App.jsx에서 이름을 정하여 받아서 사용하더라도 브라우저 환경에서 컴포넌트 구조를 확인하면 컴포넌트 이름이 Annonymous라고 뜬다.

🏓 소감

수업시간에 모듈을 기본 내보내기, 이름 내보내기를 사용하면서 둘의 차이가 무엇인지? 그리고 CommonJS와 ES6에서 모듈을 불러오고 내보내는 방식의 차이에 대해 확실히 알고 가는 시간이여서 한층 성장하였다고 느낀다.

댓글 공유

MVC 아키텍처 기반 TodoList V3를 만들어 보면서 느낀점을 기록해두려한다.

1. 동적 HTML vs 정적 HTML

동적 HTML은 js를 이용하여 동적으로 HTML을 render 해줘서 화면에 나타나게 하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$todoList.innerHTML = filteredTodos
.map(
({ id, content, completed }) => `
<li data-id="${id}">
<div class="view">
<input type="checkbox" class="toggle" ${completed ? "checked" : ""} />
<label>${content}</label>
<button class="destroy"></button>
</div>
<input class="edit" value="${content}" />
</li>
`
)
.join("");

const activeTodoCount = state.todos.filter((todo) => !todo.completed).length;

if (state.todos.length === activeTodoCount) {
document.querySelector(".clear-completed").style.display = "none";
} else {
document.querySelector(".clear-completed").style.display = "block";
}

if (!activeTodoCount) {
document.querySelector(".toggle-all").checked = true;
}

위 예제를 보면서 설명해보면, 동적 HTML로 생성한 HTML의 DOM을 조작하기 위해서는 innerHTML에 할당을 해준 뒤 render 함수를 사용하여 화면에 재렌더링 하는 방식을 사용하고 있다.

반면에 정적 HTML의 DOM을 조작하기 위해서는 document.querySelector를 꼭 써줘야 하고 자바스크립트 코드를 짜는데 있어서 통일성과 일관성을 유지하기가 어렵다.

또한, HTML이 바뀌었을 때 그에 따른 요소 노드도 변경되기 때문에 유지보수가 어려워지는 단점이 있다.

이를 js가 HTML에 종속되었다고 표현하며 js가 HTML에 종속되면 될수록 일관성이 결여된 코드를 짜게되며 가독성을 떨어뜨리고 복잡성을 증가시키는 요인이 되므로, 앞으로는 js를 사용하여 동적 HTML을 생성할 것이다.

동적 HTML의 단점도 분명히 있다. 예를 들면 빈 HTML 파일에다가 동적으로 HTML을 그려넣는 것이므로 SEO(검색엔진최적화)가 떨어질 수 있다. 하지만 HTML 요소를 조작하는 작업이 많다면 동적으로 HTML을 생성해주는 것이 편리하다.

2. 동적 HTML로 MVC 아키텍처 설계

처음에 Js 파일이 읽히고 난 다음에 render 함수로 HTML이 그려지기 때문에 다음과 같이 요소노드를 취득하면 null값이 나온다.

1
document.querySelector(".todo-list"); // null

그러면 어떻게 동적인 HTML요소를 조작할 수 있을까?

=> 이벤트 위임을 통해서 이벤트를 등록할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Todos v3</title>
<link rel="stylesheet" href="css/style.css" />
<script defer src="js/app.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

HTML 파일은 다음과 같이 #root div태그만 가지고 있고 div 태그 안에 동적으로 HTML을 생성해줄 것이다. 즉, 동적으로 생성된 HTML의 요소 조작을 위해서 부모 요소 노드인 document.querySelector('#root')를 통해 동적 HTML을 조작할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
window.addEventListener("DOMContentLoaded", fetchState);

$root.addEventListener("keyup", (e) => {
if (!e.target.matches(".new-todo")) return;

const content = e.target.value.trim();
if (e.key !== "Enter" || content === "") return;

addTodo(content);
e.target.value = "";
});

일단 window가 로드되면서 서버로 부터 초기 데이터를 받아 화면에 렌더링 해주는 것부터 시작한다.

이후, 아키텍처를 고려하면서 각자 역할에 맞는 함수를 분류하여 코딩을 한다.

3. 아키텍처가 무너지면 망한다

Model에는 state를 변경해주는 함수들이 모여있고, view에는 화면에 렌더링해주는 함수들이 모여있고, controller에는 화면에서 발생한 이벤트를 이벤트 핸들러로 이벤트 객체를 생성하여 Model의 함수의 인자로 넣어 호출한다.

여기서 중요한 점은 각자의 역할이 정해져있는데 이를 절대로 무시하거나 어기는 행위를 금지한다는 것이다.

1
2
3
4
5
6
// 함수로 통일시켜주기.
$root.addEventListener("click", (e) => {
if (!e.target.matches(".filter")) return;

setState({ filterId: e.target.id });
});

controller는 이벤트 객체를 Model의 함수의 인자로 전달하여 호출하는 역할을 해야한다. 하지만 위 예제에서는 controller가 Model의 함수를 호출하는 것이 아닌 직접 setState 함수를 호출하는 모습을 볼 수 있다.

이렇게 통일성이 지켜지지 않는다면 앞으로의 코딩 협업은 무쓸모가 되므로 꼭 아키텍처를 지키면서 생각하며 코딩하는 습관을 길러야 한다.

추가로 함수와 변수의 이름은 첫 아이 이름 짓듯이 신중하게 지어줘야 한다.

4. 이벤트 핸들러 내부에 중첩된 이벤트 핸들러

1
2
3
4
5
6
7
8
9
10
11
12
// 더블클릭했을 때, editId에 상태를 관리해줘야한다.
// addEventListener 안에 또 있는것은 나눠줘야한다.
$root.addEventListener("dblclick", (e) => {
e.path[2].classList.add("editing");

e.path[2].addEventListener("keyup", (e) => {
const content = e.target.value.trim();
if (e.key !== "Enter" || content === "") return;

editTodo(content, e.path[1].dataset.id);
});
});

더블클릭하였을 때, 생겨난 input 창에 글을 수정하는 로직을 구현하기 위해 사용한 코드이다.

위와 같이 이벤트 핸들러가 중첩되어 코드를 작성하게되면 앞서 설명한 일관성에 어긋나 유지보수를 어렵게 하는 원인이 되므로 이 경우 다음과 같이 코드를 최소한의 단위로 분리해주어야 하며, 아키텍처를 고려하여 역할을 나눠서 코딩을 해줘야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Model

// Model에 editId를 재할당해준다. => render 함수에서는 state.editId를 보고 'editing' 클래스 추가할지 말지 확인하여 렌더링한다.
const createEditId = (id) =>
setState({
editId: +id,
});

// 편집은 한번에 한개씩만 허용하기 때문에 editId를 배열로 받지 않는다.
// 편집이 완료되었으면 editId를 초기값으로 돌려놔야 한다. render 함수에서는 state.editId를 보고 'editing' 클래스 유지할지 제거할지 판단하여 렌더링한다.
const editTodo = (content) =>
setState({
todos: state.todos.map((todo) =>
todo.id === +state.editId ? { ...todo, content } : todo
),
editId: 0,
});

// Controller

// 더블클릭하면 EditId를 생성하여 Model에 반영하는 함수를 호출한다.
$root.addEventListener("dblclick", (e) => {
createEditId(e.path[2].dataset.id);
});

// state.editId 값과 해당 요소가 같은지 비교하여 같으면 content를 매개변수로 받은 함수를 호출한다.
$root.addEventListener("keyup", (e) => {
if (!e.target.matches(".edit")) return;

const content = e.target.value.trim();
if (e.key !== "Enter" || content === "") return;

editTodo(content);
});

결론

그동안은 코드가 돌아가기만 하면 되는 것인줄 알았지만, 앞으로는 돌아가는 코드를 만들기 보다는 돌아가는 코드를 어떻게 하면 아키텍처를 잘 지키면서 각자 역할에 맞는 최소한의 단위로 구분하여 코드를 구현할지 생각하며 코딩을 하도록 해야한다는 것을 깊이 깨달은 날이었다.

댓글 공유

오늘은 MVC TodoList V2를 만들어 보면서 배웠던 내용을 정리해보는 시간을 갖겠다.

1. setState를 왜 쓰는지?

직접 라이브 코딩한다고 생각을 하면서 한줄 한줄 코딩을 해나가다가 문득 setState 함수를 사용하지 않고 render 함수로 렌더만 해주면 되지 않을까? 라는 생각이 들었다.

이후 경현님께 코드 리뷰를 부탁하여 이러한 의견을 여쭤보니 다음과 같은 이점때문에 setState 함수를 만들어 사용하는 것이 좋다는 결론이 나왔다.

  1. 관리해야할 상태 객체가 늘어나 확장성이 요구될 때 용이하다.

  2. state에 대한 에러가 발생하였을 때 에러 핸들링이 용이하다. (상황에 따라 렌더링 할지 말지 결정 가능)

  3. state가 변경되거나 재할당 되는 경우를 한곳에서 관리하여 용이하다.

2. filter 함수를 사용한 코드 리팩터

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// before
let filteredTodos;
if (state.filterId === "completed") {
filteredTodos = state.todos.filter((todo) => todo.completed);
} else if (state.filterId === "active") {
filteredTodos = state.todos.filter((todo) => !todo.completed);
} else {
filteredTodos = state.todos;
}

// after
const filteredTodos = state.todos.filter((todo) => {
if (state.filterId === "completed") return todo.completed;
if (state.filterId === "active") return !todo.completed;
return todo;
});

if 문으로 filter 함수 반환값이 다르게 filteredTodos 값이 할당되는 경우라면 filter 함수 안에 로직을 넣어서 해결해보자.

=> 훨씬 더 간결해진다.

3. MVC 패턴으로 나누었다면 그에 알맞게 자신만의 역할을 하도록 분류한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// before
const addTodo = (content) => {
state.todos = [
{ id: generateNextTodoId(), content, completed: false },
...state.todos,
];
setState({ todos: state.todos });
};

// after
const addTodo = (content) =>
setState({
todos: [
{ id: generateNextTodoId(), content, completed: false },
...state.todos,
],
});

위 예제에서 addTodo 함수는 내부에서 state.todos에 새로운 값을 할당 해준뒤 state를 변경하는 함수를 호출하여 렌더링 해주고 있다.

하지만, addTodo 함수의 기능은 Model의 todos 배열에 todo 요소 하나를 추가하는 것일 뿐이므로 before 처럼 state.todos(원본)에 재할당 하는 것은 옮지 않다.

setState 함수에게 새로운 객체(상태)를 전달하여 state의 변경에 따라 화면에 렌더링 되도록 로직을 짜는 것이 더 통일성을 지킬 수 있다.

4. 함수의 매개변수를 명확하게 적어주자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// before
const toggleAllCompleted = (boolean) => {
state.todos = state.todos.map((todo) => ({ ...todo, completed: boolean }));
setState({ todos: state.todos });
};

// after
const toggleAll = (checked) => {
checked
? setState({
todos: state.todos.map((todo) =>
!todo.completed ? { ...todo, completed: checked } : todo
),
})
: setState({
todos: state.todos.map((todo) =>
todo.completed ? { ...todo, completed: checked } : todo
),
});
};

before 처럼 매개변수를 boolean 으로 설정하는 것은 옳지 않다. boolean은 타입을 나타내는 것이지 매개변수에 대한 설명으로는 부적절하다.

after 코드에서는 인수로 넘어온 모든 check를 토글로 켰다 껐다 할 수 있는 함수이다.

5. 이벤트 핸들러 사용시 이벤트 객체와 선택한 요소를 명확히 이해하자

오늘 코딩할 때 이 부분에서 문제를 계속 부딪혀서 시간을 많이 할애하였다.

1
2
3
4
5
6
7
8
9
10
11
$todoList.addEventListener("dblclick", (e) => {
e.path[2].classList.add("editing");
// e.target.parentNode.parentNode.classList.add('editing');

e.path[1].nextElementSibling.addEventListener("keyup", (e) => {
const content = e.target.value.trim();
if (e.key !== "Enter" || content === "") return;

editTodo(content, e.path[1].dataset.id);
});
});

위 코드에서 ul 태그에서 더블클릭 이벤트 발생하면 div가 안보이게되고 input이 보이게 된다.

이 때, 내가 어떤 요소를 선택하여 어떤 타입의 이벤트를 등록하였고 그 타입에 따른 이벤트 객체는 무엇이 나오는지를 명확히 이해하고 코딩을 하자.

헷갈린다면 console.log()를 찍어보면서 확인하자. 그림을 그리거나 HTML 구조와 비교해가면서 이해하면 수월하다.

소감

오늘 MVC TodoList V2를 만들어보면서 아침 10시부터 저녁 12시까지 이거만 집중해봤는데, 확실히 아직 익숙하지 않아서 버벅거리는 거 같다고 생각이 든다. 이해가 안되는 부분은 없지만 손으로 코딩이 술술 나오지가 않아서 어렵게 느껴졌다. 앞으로도 더 손 코딩을 많이 해보는 습관을 길러야 겠다.

댓글 공유

MVC 패턴을 적용시킨 TodoList

MVC 패턴을 적용 시킨 TodoList를 달달 외울 정도로 손에 익혀두자.

이를 기본으로 앞으로 애플리케이션을 만들때 두고두고 유용하게 쓰일 것이다.

HTML, CSS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MVC - TodoList</title>
<style>
.todo-list {
list-style-type: none;
padding-inline-start: 0;
}

.todo-filters {
display: flex;
gap: 15px;
list-style-type: none;
padding-inline-start: 0;
}

.todo-list input:checked + span {
text-decoration: line-through;
}

.active {
text-decoration: underline;
}
</style>
</head>

<body>
<input type="text" class="todo-input" />
<ul class="todo-list"></ul>
<ul class="todo-filters">
<li id="all" class="active">All</li>
<li id="completed">Completed</li>
<li id="active">Active</li>
</ul>
</body>
</html>
  1. 우선 정적인 요소와 동적인 요소를 구분지어서 HTML 구조를 설계한다.

동적인 todo 요소를 추가할 것이고 그 외에 항목들은 정적인 요소들이다.

Javascript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
let todos = [];

const $todoInput = document.querySelector(".todo-input");
const $todoList = document.querySelector(".todo-list");

const generateNextTodoId = () =>
Math.max(...todos.map((todo) => todo.id), 0) + 1;

const render = (todos) => {
$todoList.innerHTML = todos
.map(
({ id, content, completed }) =>
`<li id="${id}">
<input type="checkbox" ${completed ? "checked" : ""} />
<span>${content}</span>
<button>X</button>
</li>`
)
.join("");
};

const setTodos = (newTodos) => {
todos = newTodos;
render(todos);
};

const fetchTodos = () => {
setTodos([
{ id: 3, content: "일찍 일어나기", completed: false },
{ id: 2, content: "공부하기", completed: false },
{ id: 1, content: "저녁먹기", completed: false },
]);
};

const addTodo = (content) => {
todos = [{ id: generateNextTodoId(), content, completed: false }, ...todos];
setTodos(todos);
};

const toggleCompleted = (id) => {
todos = todos.map((todo) =>
todo.id === +id ? { ...todo, completed: !todo.completed } : todo
);
setTodos(todos);
};

const removeTodo = (id) => {
todos = todos.filter((todo) => todo.id !== +id);
setTodos(todos);
};

window.addEventListener("DOMContentLoaded", () => {
fetchTodos();
});

$todoInput.addEventListener("keyup", (e) => {
const content = $todoInput.value.trim();
if (e.key !== "Enter" || content === "") return;
addTodo(content);
$todoInput.value = "";
});

$todoList.addEventListener("change", (e) => {
toggleCompleted(e.target.parentNode.id);
});

$todoList.addEventListener("click", (e) => {
if (!e.target.matches("button")) return;

removeTodo(e.target.parentNode.id);
});
  1. 화면을 나타내는 부분을 View

  2. 상태(데이터)와 상태를 변경하는 메서드를 Model

  3. 화면으로 부터 받은 이벤틑 객체를 가지고 Model의 함수를 호출하여 상태를 변경하는 Controller

크게 3가지 역할로 구분하여 애플리케이션을 바라보자.

단, 너무 이 틀에 강박관념을 갖지 말길 바란다. 이러한 큰 틀이 있는 것일 뿐 100% 이 틀에 맞게 구분하는 것을 불가능하다.

댓글 공유

비동기 이해하기 (연습)

이번시간에는 비동기 처리 결과를 이용하는 예제를 따라쳐보면서 익숙해지도록 해보자.

목적

API를 활용하여 서버에서 데이터를 가져와 화면에 렌더링하기

코드 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>

<body>
<ul></ul>
<script>
const url = "https://api.artic.edu/api/v1/artworks";
const render = (items) => {
document.querySelector("ul").innerHTML = items
.map((item) => `<li>${item.title}</li>`)
.join("");
};
const get = () => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();

xhr.addEventListener("load", () => {
if (xhr.status === 200) {
render(JSON.parse(xhr.response).data);
} else {
console.error("Status Error occured");
}
});
};
</script>
</body>
</html>

서버에 XMLHttpRequest 요청을 보내 데이터 응답을 받았다.

이 과정을 다시 살펴보면 비동기 코드의 처리 결과를 render() 함수의 인수로 전달해주면서 비동기 처리 결과를 다루는 모습을 볼 수 있다.

만약에 비동기 처리 결과를 단순히 렌더해주는 것만이 아니라, 데이터의 조작이나 어떤 로직으로 처리가 많아지면 그 때마다 함수의 인수로 전달해줘야 하기 때문에 콜백 헬 현상이 발생하여 가독성과 복잡성이 떨어지게된다.

콜백함수를 이용한 비동기 처리 결과 다루기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const url = "https://api.artic.edu/api/v1/artworks";
const render = (items) =>
items.map((item) => `<li>${item.title}</li>`).join("");
const get = (url, callback) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();

xhr.addEventListener("load", () => {
if (xhr.status === 200) {
callback(JSON.parse(xhr.response).data);
} else {
console.error(`${xhr.status} ${xhr.statusText}`);
}
});
};
get(url, (param) => {
document.querySelector("ul").innerHTML = render(param);
});

위와 같이 비동기 처리 결과를 렌더해주는 로직 하나만 해주고 있어서 콜백 헬이 발생하지 않는다.

만약 데이터를 받아와서 수정을 거치고 삭제도 하고 여러가지 로직을 다루게 되면 그 때마다 함수의 인수로 전달해줘야 하기 때문에 코드가 가로로 길어지게 된다.

또한, 콜백함수들에서 에러가 발생했을 경우 에러처리가 까다로워진다.

에러는 caller 즉, 함수 호출자(콜 스택하위)방향으로 전파가 된다. 비동기 함수는 호출자가 브라우저이므로 브라우저가 콜백함수를 호출할 때에는 이벤트 루프가 콜 스택에 비었을 때, 태스크 큐에서 콜 스택으로 콜백함수를 이동시켜(호출)주기 때문에 콜 스택 하위로 에러 전파가 발생하지 않아서 에러가 발생한 위치에서만 에러를 캐치해야한다는 단점이 있다.

프로미스로 비동기 처리 결과 다루기

프로미스는 객체의 완료 상태와 값을 저장한 객체이다. 이 때, 값에는 비동기 처리 성공시 resolve 함수의 인수로 전달되어 값이 저장되고 실패이 reject 함수의 인수로 전달된 에러가 저장된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const url = "https://api.artic.edu/api/v1/artworks";
const render = (items) =>
items.map((item) => `<li>${item.title}</li>`).join("");
const get = (url) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();

xhr.addEventListener("load", () => {
if (xhr.status === 200) {
resolve(render(JSON.parse(xhr.response).data));
} else {
reject(xhr.status);
}
});
});
const promise = get(url);
promise
.then((result) => {
document.querySelector("ul").innerHTML = result;
})
.catch((err) => {
console.error(err);
});

get 함수가 프로미스 객체를 반환하고 있다. 반환한 프로미스는 콜백함수를 인수로 받는데 그 콜백함수 안에서 로직을 구현하고 콜백함수의 인수로 2개의 함수를 전달한다. 성공했을 경우 호출함 함수 resolve 함수와 실패했을 때 호출할 함수 reject 함수를 구현한다.

get 함수를 호출하여 반환된 프로미스 객체를 후속 처리 메서드 then, catch가 이어서 받아서 처리를 해준다.

  • then 메서드 : 프로미스 객체의 상태가 pending 상태에서 fulfilled로 변경되면 resolve 함수의 인수를 인수로 전달 받아 콜백함수를 호출한다.

  • catch 메서드 : 프로미스 객체의 상태가 pending 상태에서 rejected로 변경되면 reject 함수의 인수를 인수로 전달받아 콜백함수를 호출한다.

catch 메서드를 사용하면 catch 이전의 구간에서 발생한 에러를 캐치할 수 있다.

async, await로 비동기 처리 결과 다루기

ES6에서 새로 생겨난 async, await를 사용하면 마치 동기 처리 방식처럼 비동기 처리결과를 다룰 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const url = "https://api.artic.edu/api/v1/artworks";
const render = (items) =>
items.map((item) => `<li>${item.title}</li>`).join("");
const get = (url) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();

xhr.addEventListener("load", () => {
if (xhr.status === 200) {
resolve(render(JSON.parse(xhr.response).data));
} else reject(xhr.status);
});
});
(async () => {
const titleData = await get(url);
document.querySelector("ul").innerHTML = titleData;
})();
  • await는 반드시 async 키워드로 선언된 함수 안에서 사용해야한다.

    단, Node.js 애플리케이션 환경에서 함수 레벨이 아닌 최상위 레벨에서도 await를 사용할 수 있다.

1
2
3
4
// package.json
{
"type": "module"
}

package.json 파일의 type을 module로 바꿔주면 독자적인 스코프인 모듈 스코프를 갖게되어 최상위 레벨에서도 await를 사용할 수 있다.

  • async를 사용하면 에러전파가 되어 에러처리가 용이하다.

제너레이터 함수와 연관지어 생각해보면 await 키워드를 만나면 일시 정지하고 제어권을 함수 외부로 이동시켜 동기 처리르 하다가 전역 코드가 다 실행되고 다시 일시 정지 했던 await 부분으로 돌아와서 프로미스 객체의 완료 상태가 settled로 바뀌면 코드를 실행하고 다음 코드 라인으로 넘어간다.

즉, 프로미스 객체의 완료 상태가 pending이면 다음 코드를 실행하지 않고 계속 대기한다.

결론

비동기를 동기처럼 다룰 수 있는 async,await를 사용하자.

댓글 공유

CORS

카테고리 JavaScript

CORS란?

크롬, 사파리 같은 브라우저에서 보안상의 이슈때문에 모든 웹 사이트는 같은 출처에 대한 데이터 요청은 허락하지만(SOP, 동일 출처 정책), 다른 출처로 데이터를 요청할 경우 특별한 규칙에 따라 허락을 받아야한다.

Cross Origin Resource Sharing

교차 출처 정보 공유에 대한 정책으로, 브라우저가 출처가 불분명한 응답을 막고 있는 것을 풀어주는 역할을 한다.

예시

내가 은행 사이트 서버에 요청을 보내서 은행 홈페이지에 로그인을 하였다. 그러면 브라우저 쿠키에 사용자의 인증정보 및 쿠키가 저장되어 있는데, 해커가 어떤 사이트 링크를 내게 보내서 내가 그 링크를 클릭하여 앞서 말한 인증 정보, 쿠키를 서버로 가져오는 Script 코드가 포함된 리소스를 응답으로 보내서 내가 만약에 그 응답을 받게 된다면, 해커가 나의 인증 정보와 쿠키를 가져갈 수 있게 된다.

이러한 보안상의 이슈를 방지하기 위해 브라우저가 동일한 출처의 요청이 아니라면 응답을 막아주는 것이다.

또한 쿠키를 못읽게 자바스크립트 코드로 만들어줘야한다.

즉, CORS는 다른 출처 간의 리소스를 공유할 수 있도록 하는 정책이다.

  • 여기서 말하는 출처란, 보내고 받는 위치 즉, 웹 사이트랑 API 주소이다.
  • 리소스는 주고 받는 데이터를 말한다.

내가 만든 사이트와 어떤 API라는 서로 다른 출처끼리 정보교환이 가능하려면 CORS 정책을 지켜야 한다는 말이다.

CORS 과정

요청을 받는 서버쪽에서 허락할 웹사이트를 미리 명시해줘야한다.

Simple Request (GET, POST 방식일 때 사용)

  1. 브라우저는 다른 출처끼리의 요청을 보낼 때에는 요청에 Origin 이라는 header를 추가한다.
1
https://이주의사이트.com:5000
  • Origin은 요청하는 쪽의 scheme, 도메인, 포트가 담겨있다.
    • scheme은 요청할 자원에 접근할 방법(Http, FTP, telnet…) = 프로토콜
  • https - scheme
  • 이주의사이트.com - 도메인
  • :5000 - 포트
  1. 요청을 받은 API 서버는 응답의 헤더에 지정된 ACAO(Access Control Allow Origin) 정보를 실어서 보낸다.

    • ACAO 정보에는 미리 명시된 URL들이 들어가있다.
  2. 브라우저가 ACAO정보가 담긴 응답과 요청의 Origin을 비교하여 동일하면 허락해준다.

  3. 만약 동일하지 않아 허락못받으면 응답만 못받아온다. 빨간색 에러 발생

추가로 토큰과 같은 사용자 식별 정보가 담긴 요청에 대해서는 더 엄격한데 요청의 옵션에 credentials 항목을 true로 세팅해줘야하며, 받는 쪽에서도 아무 출처나 다 된다는 의미의 와일드 카드(*)가 아니라 보내는 쪽의 출처와 웹페이지 주소를 명확히 명시하고 Access Control Allow Credentials 항목을 true로 맞춰줘야 한다.

Preflighted (PUT, DELETE 방식일 때 사용)

Preflight 요청을 먼저 보내서 그에 대한 서버의 응답을 보고 안전한지 먼저 확인한다. 여기서 서버의 허락이 받아야지만 본 요청을 보낼 수 있다.

본 요청에 대한 과정은 심플리퀘스트와 동일하다.

  • Origin, credentials, method 가 담긴 요청

⇒ 서버의 데이터에 영향을 줄 수 있는 요청이기 때문에 요청 보내기 전에 먼저 허용 여부를 검증해줘야 한다.

댓글 공유

setTimeout 타이머 함수는 일정 시간이 경과된 이후 콜백 함수가 호출되도록 타이머를 생성한다. setTimeout 함수가 생성한 타이머가 만료되면 콜백함수가 호출된다.

  • 타이머 함수는 ECMAScript 사양에 정의된 빌트인 함수가 아니라 호스트 객체이다.
  • setTimeout 함수가 생성한 타이머는 1번 동작하고 콜백함수도 1번 호출된다.

setTimeout 함수

두번째 인수로 전달받은 시간(ms)으로 단 한번 동작하는 타이머를 생성한다. 타이머 만료되면 첫번째 인수로 전달받은 콜백함수가 호출된다.

1
2
3
4
5
6
7
8
9
10
11
const timeoutId = setTimeout(func[, delay,param1, param2,...]

// 1초(1000ms) 후 타이머가 만료되면 콜백 함수가 호출된다.
setTimeout(() => console.log('Hi!'), 1000);

// 1초(1000ms) 후 타이머가 만료되면 콜백 함수가 호출된다.
// 이때 콜백 함수에 'Lee'가 인수로 전달된다.
setTimeout(name => console.log(`Hi! ${name}.`), 1000, 'Lee');

// 두 번째 인수(delay)를 생략하면 기본값 0이 지정된다.
setTimeout(() => console.log('Hello!'));
  • 콜백함수에 전달할 인수가 있다면 세번째 이후의 인수로 전달할 수 있다.
  • setTimeout 함수는 생성된 타이머를 식별할 수 있는 고유한 타이머 id를 반환한다.
  • setTimeout 함수가 반환한 타이머 id를 clearTimeout 함수의 인수로 전달하여 타이머를 취소한다.

setTimeout 함수에 대한 오해

흔히들 setTimeout 함수를 인수로 전달해준 딜레이 시간이 지난 후에 콜백함수를 호출해준다고 알고 있다.

하지만 이 말에는 정확히 짚고 넘어가야할 부분이 있다.

setTimeout 함수의 첫번째 인수로 넘겨준 콜백함수는 setTimeout 함수가 호출해주는 것인가?

=> 그렇지 않다.

1
2
setTimeout(() => console.log("타이머"), 0);
console.log("호출해주세요.");

만약 위 코드에서 setTimeout 함수가 콜백함수를 호출하는 것이라면 setTimeout 함수의 실행 컨텍스트가 제거되지 않고 남아 있어 “호출해주세요”라는 메시지가 콘솔창에 출력되지 않고 Blocking 현상이 발생해야한다.

하지만 그렇지 않다. 그 이유는 setTimeout 함수는 타이머를 생성하고 timeId를 반환한 후 브라우저에게 타이머 계산과 콜백함수 호출을 위임하고 setTimeout 함수의 실행 컨텍스트가 종료된다.

쉽게 말해서 setTimeout 함수가 브라우저에게 일정 시간이 지나면 콜백함수를 호출해주세요~ 라고 위임하고 종료되는 것이다.

즉, setTimeout 함수의 콜백함수는 브라우저가 호출하는 것이다.

비동기 함수

자바스크립트 엔진의 콜 스택은 싱글 스레드이기 때문에 한번에 한가지 일 밖에 할 수 없다. 그리하여 시간이 오래 걸리는 작업을 하게된다면 그 작업이 끝날 때 까지 다음 코드가 실행되지 못하는 Blocking(블로킹) 현상이 발생한다.

시간이 오래걸리는 작업이나 setTimeout 함수의 콜백함수, addEventListener의 이벤트 핸들러 같은 함수는 개발자가 호출하지 않고 브라우저가 호출한다. 이러한 코드들을 포함하고 있는 함수를 비동기 함수라고 한다.

자바스크립트는 Blocking(블로킹) 현상을 해결하면서 동시성을 추구하기 위해 콜 스택, 태스크 큐, 이벤트 루프 구조를 생성하게 되었다.

eventLoop

위 이미지를 보면서 아까의 코드를 이해해보면,

  1. 전역 실행 컨텍스트가 생성되어 콜 스택에 쌓인다.
  2. setTimeout 함수 실행 컨텍스트가 생성되어 콜 스택에 푸시된다.
  3. setTimeout 함수는 두번째 인수로 전달받은 딜레이를 가진 타이머를 생성하고 브라우저에게 타이머 계산과 첫번째 인수로 전달받은 콜백함수를 호출할 것을 위임하고 timeId를 반환하며 종료되고 실행 컨텍스트 스택에서 pop 되어 종료된다.
  4. 브라우저는 타이머를 계산하여 만료되면 태스크 큐에 콜백함수를 전달한다. 동시에 콜 스택에서는 console.log 함수 실행 컨텍스트가 생성된다.
  5. 이벤트 루프는 콜 스택과 태스크 큐를 확인하면서 콜 스택이 비워지면 태스크 큐의 작업을 콜 스택으로 푸시하는 역할을 한다. 즉, console.log 함수 실행 컨텍스트가 제거되고 전역 실행 컨텍스트가 제거되기 전까지는 태스크 큐에 있는 콜백함수가 콜 스택으로 푸시될 수 없다.
  6. console.log 함수가 종료되어 실행 컨텍스트가 pop되어 제거되고 전역 실행 컨텍스트도 pop되어 제거되면 이벤트 루프가 콜 스택이 비어있는 것을 확인하여 태스크 큐의 콜백함수를 콜 스택으로 푸시한다.
  7. 브라우저가 호출하여 콜백함수의 실행 컨텍스트가 생성되고 콜백함수를 실행한 뒤 종료되면 실행 컨텍스트 스택에서 pop되어 제거되고 코드가 종료된다.

7번에서 브라우저가 콜백함수를 호출한다는 의미는 태스크 큐에 있던 콜백함수를 콜 스택으로 이동시켜준다는 의미이다.

다음 시간에는 비동기 함수의 callback 패턴과 promise 패턴, async, await에 대해서 알아보자.

참고자료
https://velog.io/@jjunyjjuny/%EB%B2%88%EC%97%AD-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%8B%9C%EA%B0%81%ED%99%94-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84

댓글 공유

Event

카테고리 JavaScript

이벤트 드리븐 프로그래밍

브라우저는 처리해야할 특정 사건이 발생하면 이를 감지하여 **이벤트(event)를 발생(trigger)**시킨다. 사용자가 어떤 행동을 하였을 때 브라우저가 이를 감지하여 함수를 호출하고 싶을 때 이벤트 드리븐 프로그래밍 방식을 사용한다.

즉, 이벤트란? 브라우저가 특정 사건이 발생한 것을 감지하고 함수를 호출하기 위해 필요한 것이다.

즉, 함수를 언제 호출할지 알 수 없으므로 개발자가 명시적으로 함수를 호출하는 것이 아니라 브라우저에게 함수 호출을 위임하는 것이다.

  • 이벤트 발생시 호출될 함수 ⇒ 이벤트 핸들러
  • 이벤트 발생시 브라우저에게 이벤트 핸들러의 호출을 위임 ⇒ 이벤트 핸들러 등록

Window, Document, HTMLElement 타입의 객체는 onclick과 같이 특정 이벤트에 대응하는 다양한 이벤트 핸들러 프로퍼티를 가지고 있다. 이 이벤트 핸들러 프로퍼티에 함수를 할당하면 해당 이벤트가 발생했을 때, 할당한 함수가 브라우저에 의해 호출된다.

이와 같이 프로그램의 흐름을 이벤트 중심으로 제어하는 프로그래밍 방식을 이벤트 드리븐 프로그래밍이라고 한다.

이벤트 타입

이벤트 타입은 이벤트의 종류를 나타내는 문자열이다. 대표적으로 마우스 이벤트(click, mouseup, mousemove, mouseover…), 키보드 이벤트(keydown, keypress…), 포커스 이벤트(focus, focusin…) 등 다양한 이벤트가 있다.

  • 이벤트 핸들러 프로퍼티 방식과 addEventListener 메서드 방식으로 등록할 수 있다.

이벤트 핸들러 등록 방식

이벤트 핸들러 프로퍼티 방식

이벤트 핸들러 프로퍼티에 함수를 바인딩하면 이벤트 핸들러가 등록된다.

  • 이벤트 핸들러는 대부분 이벤트를 발생시킬 이벤트 타깃에 바인딩한다. 하지만 반드시 이벤트 타깃에 이벤트 핸들러를 바인딩해야하는 것은 아니다.
  • 이벤트 핸들러 프로퍼티에 하나의 이벤트 핸들러만 바인딩할 수 있다는 단점이 있다.

addEventListener 메서드 방식

EventTarget.prototype.addEventListener 메서드를 사용하여 이벤트 핸들러를 등록할 수 있다.

  • 이벤트 타입을 전달시 on 접두사를 붙이지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');

// 이벤트 핸들러 프로퍼티 방식
$button.onclick = function () {
console.log('button click');
};

// addEventListener 메서드 방식
$button.addEventListener('click', function () {
console.log('button click');
});
</script>
</body>
</html>
  • 이벤트 핸들러 프로퍼티 방식과 달리 addEventListener 메서드에는 이벤트 핸들러를 인수로 전달한다.
  • 위 예제대로 두가지 방식을 같이 사용하여도 서로에게 영향을 주지 않으므로 클릭 이벤트 발생 시 2개의 이벤트 핸들러가 모두 호출된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');

// addEventListener 메서드는 동일한 요소에서 발생한 동일한 이벤트에 대해
// 하나 이상의 이벤트 핸들러를 등록할 수 있다.
$button.addEventListener('click', function () {
console.log('[1]button click');
});

$button.addEventListener('click', function () {
console.log('[2]button click');
});
</script>
</body>
</html>
  • 동일한 HTML 요소에서 발생한 동일한 이벤트에 대해 이벤트 핸들러 프로퍼티 방식은 하나 이상의 이벤트 핸들러를 등록할 수 없지만, addEventListener 메서드는 하나 이상의 이벤트 핸들러를 등록할 수 있다.

이벤트 핸들러 제거

addEventListener 메서드로 등록된 이벤트를 제거하기 위해 removeEventListener 메서드를 사용한다.

  • 단, addEventListener 메서드에게 전달한 인수와 removeEventListener 메서드에게 전달한 인수가 같아야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');

const handleClick = () => console.log('button click');

// 이벤트 핸들러 등록
$button.addEventListener('click', handleClick);

// 이벤트 핸들러 제거
// addEventListener 메서드에 전달한 인수와 removeEventListener 메서드에
// 전달한 인수가 일치하지 않으면 이벤트 핸들러가 제거되지 않는다.
$button.removeEventListener('click', handleClick, true); // 실패
$button.removeEventListener('click', handleClick); // 성공
</script>
</body>
</html>
  • 이벤트 핸들러로 전달한 등록 이벤트 핸들러가 동일해야 하므로 무명 함수를 이벤트 핸들러로 등록한 경우 제거할 수 없다. 이 경우 이벤트 핸들러를 제거하기 위해서는 이벤트 핸들러 참조를 변수나 자료구조에 저장하고 있어야 한다.

이벤트 객체

이벤트 발생 시 이벤트에 관련한 다양한 정보를 담고 있는 이벤트 객체가 동적으로 생성된다. 생성된 이벤트 객체는 이벤트 핸들러의 첫번째 인수로 전달된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html>
<body>
<p>클릭하세요. 클릭한 곳의 좌표가 표시됩니다.</p>
<em class="message"></em>
<script>
const $msg = document.querySelector('.message');

// 클릭 이벤트에 의해 생성된 이벤트 객체는 이벤트 핸들러의 첫 번째 인수로 전달된다.
function showCoords(e) {
$msg.textContent = `clientX: ${e.clientX}, clientY: ${e.clientY}`;
}

document.onclick = showCoords;
</script>
</body>
</html>
  • 이벤트 객체는 이벤트 핸들러의 첫번째 인수로 전달되어 매개변수 e에 암묵적으로 할당된다. 이는 브라우저가 이벤트 핸들러 호출할 때, 이벤트 객체를 인수로 전달하기 때문이다.

이벤트 타입에 따라 생성되는 이벤트 객체의 고유한 프로퍼티가 달라진다.

이벤트 객체의 공통 프로퍼티

공통 프로퍼티 설명 타입
type 이벤트 타입 string
target 이벤트를 발생시킨 DOM 요소 DOM 요소 노드
currentTarget 이벤트 핸들러가 바인딩된 DOM 요소 DOM 요소 노드
eventPhase 이벤트 전파 단계 (0: 이벤트없음, 1: 캡처링 단계, 2: 타깃 단계, 3: 버블링 단계) number
bubbles false면 버블링하지 않는다. boolean
cancelable preventDefault 메서드 호출하여 이벤트 기본 동작 취소 가능한지 여부 false면 취소할 수 없다. boolean
defaultPrevented preventDefault 메서드 호출하여 이벤트 취소했는지 여부 boolean
isTrusted 사용자 행위에 의해 발생한 이벤트인지 여부, click메서드, dispatchEvent 메서드를 통해 인위적으로 발생시킨 이벤트인 경우 false boolean
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<body>
<input type="checkbox">
<em class="message">off</em>
<script>
const $checkbox = document.querySelector('input[type=checkbox]');
const $msg = document.querySelector('.message');

// change 이벤트가 발생하면 Event 타입의 이벤트 객체가 생성된다.
$checkbox.onchange = e => {
console.log(Object.getPrototypeOf(e) === Event.prototype); // true

// e.target은 change 이벤트를 발생시킨 DOM 요소 $checkbox를 가리키고
// e.target.checked는 체크박스 요소의 현재 체크 상태를 나타낸다.
$msg.textContent = e.target.checked ? 'on' : 'off';
};
</script>
</body>
</html>
  • 사용자 입력에 의해 checked 프로퍼티 값 변경되면 change 이벤트 발생하고 Event 타입의 이벤트 객체가 생성된다.
  • 이벤트 객체의 target 프로퍼티는 이벤트를 발생시킨 객체를 나타낸다. 즉, change 이벤트를 발생시킨 DOM 요소인 $checkbox 이다.
  • 이벤트 객체의 currentTarget 프로퍼티는 이벤트 핸들러가 바인딩된 DOM 요소를 가리킨다.
  • 일반적으로 이벤트 객체의 target 프로퍼티와 currentTarget 프로퍼티는 동일한 객체를 가리키지만 나중에 이벤트 위임에서는 서로 다른 객체를 가리킬 수 도 있다.

이벤트 전파

DOM 트리 상에 존재하는 DOM 요소 노드에서 발생한 이벤트는 DOM 트리를 통해 전파된다.

  • 생성된 이벤트 객체는 이벤트를 발생시킨 DOM 요소인 이벤트 타깃을 중심으로 DOM 트리를 통해 전파된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li id="apple">Apple</li>
<li id="banana">Banana</li>
<li id="orange">Orange</li>
</ul>
<script>
const $fruits = document.getElementById('fruits');

// #fruits 요소의 하위 요소인 li 요소를 클릭한 경우
$fruits.addEventListener('click', e => {
console.log(`이벤트 단계: ${e.eventPhase}`); // 3: 버블링 단계
console.log(`이벤트 타깃: ${e.target}`); // [object HTMLLIElement]
console.log(`커런트 타깃: ${e.currentTarget}`); // [object HTMLUListElement]
});
</script>
</body>
</html>
  • 위 예제는 ul 요소에 이벤트 핸들러 바인딩하고 ul 요소의 하위 요소인 li 요소를 클릭하여 이벤트를 발생 시켜보자. event.target은 li 요소이고, event.currentTarget은 ul 요소이다.

과정

  1. li 요소 클릭시 클릭 이벤트 발생하여 클릭 이벤트 객체를 생성한다. 클릭된 li 요소가 이벤트 타깃이 된다. 이 때 이벤트 객체는 window에서 시작해서 이벤트 타깃 방향으로 전파된다. (캡처링 단계)
  2. 이벤트 객체는 이벤트를 발생시킨 이벤트 타깃에 도달한다. (타깃 단계)
  3. 이벤트 객체는 이벤트 타깃에서 시작해서 window 방향으로 전파된다. (버블링 단계)
  • 이벤트 핸들러 어트리뷰트/프로퍼티 방식으로 등록한 이벤트 핸들러는 타깃단계와 버블링 단계의 이벤트만 캐치할 수 있지만, addEventListener 메소드로 등록한 이벤트 핸들러는 타깃단계, 버블링단계, 캡처링 단계의 이벤트도 선별적으로 캐치할 수 있다. 캡처링 단계의 이벤트 캐치하려면 addEventListener 메소드의 3번째 인수로 true를 전달해야한다.

즉, 이벤트는 이벤트를 발생시킨 이벤트 타깃을 물론 상위 DOM 요소에서도 캐치할 수 있다.

다음 이벤트들은 버블링을 통해 전파되지 않는다. 왜냐하면 event.bubbles 값이 false

  • focus/blur
  • load/unload/abort/error
  • mouseenter/mouseleave

이러한 이벤트를 사용하여 캡처링으로 이벤트를 캐치하는 것보다 대안의 이벤트들을 사용하여 버블링을 통해 캐치하는 것이 더 합리적이다.

이벤트 위임

이벤트 위임은 여러 개의 하위 DOM 요소에 각각 이벤트 핸들러를 등록하는 대신 하나의 상위 DOM 요소에 이벤트 핸들러를 등록하는 방법을 말한다. 이벤트 위임을 통해 상위 DOM 요소에 이벤트 핸들러 등록하면 여러 개의 하위 DOM 요소에 이벤트 핸들러를 등록할 필요가 없다. 또한 동적으로 하위 DOM 요소 추가하더라도 일일이 추가된 DOM 요소에 이벤트 핸들러를 등록할 필요가 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<!DOCTYPE html>
<html>
<head>
<style>
#fruits {
display: flex;
list-style-type: none;
padding: 0;
}

#fruits li {
width: 100px;
cursor: pointer;
}

#fruits .active {
color: red;
text-decoration: underline;
}
</style>
</head>
<body>
<nav>
<ul id="fruits">
<li id="apple" class="active">Apple</li>
<li id="banana">Banana</li>
<li id="orange">Orange</li>
</ul>
</nav>
<div>선택된 내비게이션 아이템: <em class="msg">apple</em></div>
<script>
const $fruits = document.getElementById('fruits');
const $msg = document.querySelector('.msg');

// 사용자 클릭에 의해 선택된 내비게이션 아이템(li 요소)에 active 클래스를 추가하고
// 그 외의 모든 내비게이션 아이템의 active 클래스를 제거한다.
function activate({ target }) {
// 이벤트를 발생시킨 요소(target)가 ul#fruits의 자식 요소가 아니라면 무시한다.
if (!target.matches('#fruits > li')) return;

[...$fruits.children].forEach($fruit => {
$fruit.classList.toggle('active', $fruit === target);
$msg.textContent = target.id;
});
}

// 이벤트 위임: 상위 요소(ul#fruits)는 하위 요소의 이벤트를 캐치할 수 있다.
$fruits.onclick = activate;
</script>
</body>
</html>
  • 이벤트 객체의 currentTarget 프로퍼티는 언제나 $fruits 요소를 가리키지만 이벤트 객체의 target 프로퍼티는 실제로 이벤트를 발생시킨 DOM 요소를 가리킨다.

만약 $fruits 요소의 하위 요소에서 클릭 이벤트가 발생했다면 이벤트 객체의 currentTarget 프로퍼티와 target 프로퍼티는 다른 DOM 요소를 가리킨다.

댓글 공유

DOM

렌더링 엔진에 의해 HTML 문서를 브라우저가 이해할 수 있는 자료구조인 DOM을 생성한다. DOM이란, HTML문서의 계층적인 구조와 정보를 표현하며 이를 제어할 수 있는 API, 즉 프로퍼티와 메서드를 제공하는 트리 자료구조이다.

노드

HTML 요소는 렌더링 엔진에 의해 DOM을 구성하는 요소 노드 객체로 변환된다. HTML 요소의 어트리뷰트는 어트리뷰트 노드로, HTML 요소의 텍스트 컨텐츠는 텍스트 노드로 변환된다.

노드 객체들로 구성된 트리 자료구조를 DOM이라한다.

노드도 자바스크립트 객체이므로 프로토타입에 의한 상속 구조를 갖는다.

주의사항

상속관계를 아는 것보다 어떤 DOM API를 사용하여 동적으로 변경하고 조작할 수 있는지를 알아야한다.

querySelector, querySelectorAll 메서드가 다소 느리긴 하더라도 CSS 선택자로 요소 노드 취득시 구체적인 조건과 일관된 방식으로 요소 노드 취득할 수 있으므로 id 어트리뷰트가 있는 요소는 getElementById 메서드를 사용하고 그 외의 경우에는 querySelector, querySelectorAll 메서드를 사용하자.

HTMLCollection과 NodeList

DOM 컬렉션 객체이 두 객체는 DOM API가 여러 개의 결과값을 반환하기 위한 객체이다. 둘 다 유사 배열 객체이면서 이터러블이다. 그러므로 for…of문으로 순회할 수 있으며 스프레드 문법을 사용하여 간단히 배열로 변환할 수 있다.

  • HTMLCollection과 NodeList는 노드 객체의 상태 변화를 실시간으로 반영하는 살아있는 객체이다.
  • HTMLCollection은 언제나 live 객체로 동작한다.
  • NodeList는 대부분의 경우 상태 변화를 실시간으로 반영하지 않고 과거의 정적 상태를 유지하는 non-live 객체로 동작하지만 경우에 따라 live 객체로 동작한다.

결론

HTMLCollection, NodeList 객체를 사용하지 말아라. 유사 배열 객체이면서 이터러블인 NodeList, HTMLCollection 객체를 배열로 변환하면 부작용을 제거할 수 있다. 유용한 배열 고차함수 forEach, map, filter 등을 사용할 수 있다.

DOM 조작

DOM 조작은 새로운 노드를 생성하여 DOM에 추가하거나 기존 노드 삭제 또는 교체 하는 것을 말한다. 이 경우 리플로우와 리페인트가 발생한다.

innerHTML

시작 태그와 종료 태그 사이의 모든 마크업을 문자열로 반환한다. HTML 마크업도 포함된 문자열을 반환하는 것이 textContent 프로퍼티와 차이점이다.

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
<body>
<div id="foo">Hello <span>world!</span></div>
</body>
<script>
// #foo 요소의 콘텐츠 영역 내의 HTML 마크업을 문자열로 취득한다.
console.log(document.getElementById('foo').innerHTML);
// "Hello <span>world!</span>"
</script>
</html>
  • 요소 노드의 innerHTML 프로퍼티에 문자열 할당하면 요소 노드의 모든 자식 노드가 제거되고 할당한 문자열이 텍스트로 추가된다. 이 때 HTML 마크업이 포함되어 있으면 파싱되어 요소 노드의 자식 노드로 DOM에 반영된다.

복수의 노드 생성과 추가 ⭐️

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<body>
<ul id="fruits"></ul>
</body>
<script>
const $fruits = document.getElementById('fruits');

['Apple', 'Banana', 'Orange'].forEach(text => {
// 1. 요소 노드 생성
const $li = document.createElement('li');

// 2. 텍스트 노드 생성
const textNode = document.createTextNode(text);

// 3. 텍스트 노드를 $li 요소 노드의 자식 노드로 추가
$li.appendChild(textNode);

// 4. $li 요소 노드를 #fruits 요소 노드의 마지막 자식 노드로 추가
$fruits.appendChild($li);
});
</script>
</html>
  • 위 예제는 3개의 요소 노드가 생성하여 DOM에 3번 추가하여 DOM이 3번 변경된다. 이러한 방법은 리플로우를 많이 발생 시키므로 피해야한다.

⇒ 컨테이너 요소를 미리 생성한 후 DOM에 추가할 3개 요소 노드를 컨테이너 요소의 자식 노드로 추가한 뒤 컨테이너 요소를 #fruits 요소에 자식으로 추가한다. 하지만 이 또한, 불필요한 컨테이너 요소(div)가 DOM에 추가되어 바람직 하지 않다.

어트리뷰트

어트리뷰트 노드와 attributes 프로퍼티

HTML 문서의 요소는 여러 개의 어트리뷰트를 가질 수 있다. ex) class, checked, aria-label…

  • 모든 HTML 요소에 공통적인 것부터 해당 요소만 사용할 수 있는 어트리뷰트가 있다.
  • HTML 요소의 어트리뷰트는 어트리뷰트 노드로 변환되어 요소 노드와 연결된다. 이는 NamedNodeMap 객체에 담겨 요소 노드의 attributes 프로퍼티에 저장된다.
  • attributes 프로퍼티는 getter만 존재하는 접근자 프로퍼티이다.

HTML 어트리뷰트 vs DOM 프로퍼티

  • HTML 어트리뷰트는 초기값 (변하지 않는다.)
  • DOM 프로퍼티는 HTML 프로퍼티를 초기값으로 가지고 변경될 수 있다.

첫렌더링 까지 어트리뷰트 노드의 어트리뷰트 값과 요소 노드의 value 프로퍼티에 할당된 값은 HTML 어트리뷰트 값과 동일하다. 하지만 첫 렌더링 이후 사용자가 input 요소에 무언가 입력시 바뀌게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<body>
<input id="user" type="text" value="ungmo2">
<script>
const $input = document.getElementById('user');

// attributes 프로퍼티에 저장된 value 어트리뷰트 값
console.log($input.getAttribute('value')); // ungmo2

// 요소 노드의 value 프로퍼티에 저장된 value 어트리뷰트 값
console.log($input.value); // ungmo2
</script>
</body>
</html>
  • input 요소 노드는 상태를 가지고 있고 사용자의 입력에 의한 변경된 최신 상태를 관리해야 할뿐더러 HTML 어트리뷰트로 지정한 초기 상태도 관리해야한다.

즉, 요소 노드의 초기 상태는 어트리뷰트 노드가 관리하며 요소 노드의 최신 상태는 DOM 프로퍼티가 관리한다.

HTML 어트리뷰트와 DOM 프로퍼티의 대응 관계

대부분은 HTML 어트리뷰트는 HTML 어트리뷰트 이름과 동일한 DOM 프로퍼티와 1:1로 대응한다. 반드시는 아니다.

  • id 어트리뷰트와 id 프로퍼티는 1:1 대응, 동일한 값으로 연동한다.
  • input 요소의 value 어트리뷰트는 value 프로퍼티와 1:1 대응, value 어트리뷰트는 초기상태를, value 프로퍼티는 최신 상태를 갖는다.
  • class 어트리뷰트에 대응하는 DOM 프로퍼티는 className, classList 프로퍼티이다.

DOM 프로퍼티 값의 타입

getAttribute 메서드로 취득한 어트리뷰트 값은 언제나 문자열이지만 DOM 프로퍼티로 취득한 최신 상태 값은 문자열이 아닐 수 있다. boolean 타입일 수 있다.

ex) checkbox요소의 checked 어트리뷰트 값은 문자열 이지만 checked 프로퍼티 값은 불리언이다.

댓글 공유

브라우저란?

브라우저는 HTML, CSS, Javascript로 작성된 텍스트 문서를 서버에게 요청하여 응답을 받아 의미있는 단위인 토큰으로 파싱하여 시각적으로 렌더링 해주는 역할을 담당한다.

브라우저 렌더링 과정

1. 요청과 응답

서버에 요청하기 위해 브라우저는 주소창을 제공한다. 주소창에 URL을 입력하면 URL의 호스트 이름이 DNS를 통해 IP주소로 변환되고 IP 주소를 갖는 서버에게 요청을 전송한다.

이렇게 요청을 보내면 서버는 서버의 루트 폴더에 존재하는 정적파일로 응답을 보낸다. 기본적으로 index.html

2. HTML 파싱과 DOM 생성

브라우저 요청에 의해 서버가 응답한 HTML 문서는 문자열로 이루어진 순수한 텍스트이다. 의미없는 문자열 데이터를 브라우저가 이해할 수 있는 자료구조(객체)로 변환하여 메모리에 저장해야한다.

그래서 HTML 문서를 파싱하여 브라우저가 이해할 수 있는 자료구조인 DOM을 생성한다.

  1. 서버는 요청의 응답하기 위해 요청한 HTML 파일을 읽어 메모리에 저장한 뒤 메모리에 저장된 바이트(2진수)를 인터넷을 경유하여 응답한다.
  2. 브라우저는 이를 받아 meta 태그의 charset 방식에 따라(UTF-8) 문자열로 변환한다.
  3. 문자열로 변환된 HTML문서를 토큰화한다.
  4. 각 토큰을 객체로 변환하여 노드를 생성한다. 노드는 DOM을 구성하는 기본 요소이다. ex) 문서 노드, 요소 노드 등
  5. HTML 문서는 중첩관계를 통해 부자관계가 형성된다. 이러한 부자관계를 반영하여 모든 노드들을 트리 자료구조로 구성한다. 이러한 노드들로 구성된 트리 자료구조를 DOM이라 부른다.

3. CSS 파싱과 CSSOM 생성

렌더링 엔진은 HTML을 한줄씩 읽어나가며 순차적으로 파싱하여 DOM을 생성해 나간다. DOM을 생성하다가 CSS를 로드하는 link 태그나 style 태그를 만나면 DOM 생성을 일시중단한다.

그 결과 CSS 파일을 서버에 요청하여 응답받은 CSS 파일이나 style 태그 내의 CSS를 HTML과 동일한 과정으로 토큰화 생성 → CSSOM 생성 과정을 거친다. 이후 파싱이 완료되면 HTML 파싱이 중단된 지점부터 다시 HTML을 파싱하기 시작한다.

4. 렌더 트리 생성

앞선 과정에서 생성된 DOM과 CSSOM은 렌더링을 위해 렌더 트리로 결합된다. 이 때 브라우저 화면에 렌더링되지 않는 노드(meta태그, script 태그 등)와 CSS에 의해 표시되지 않는(display:none) 노드들은 포함하지 않는다.

지금까지의 렌더링 과정은 여러번 반복되서 실행될 수 있다. 렌더링이 반복 실행되는 원인은 다음과 같다.

  • 자바스크립트에 의한 노드 추가 또는 삭제
  • 브라우저 창의 리사이징에 의한 viewport 크기 변경
  • HTML 요소의 레이아웃(위치와 크기)을 변경시키는 width, height, margin, padding, border, display, position 등의 스타일 변경

이러한 리렌더링은 비용이 많이 들고 성능에 악영향을 주므로 리렌더링이 적게 발생하도록 하여야한다.

5. 자바스크립트 파싱과 실행

HTML 파싱의 결과물 DOM은 HTML 문서의 구조와 정보뿐 아니라 HTML 요소와 스타일을 변경할 수 있는 프로그래밍 인터페이스로서 DOM API를 제공한다.

즉, DOM API를 사용하여 이미 생성된 DOM을 동적으로 조작할 수 있다.

CSS 파싱과정과 마찬가지로 script 태그 만나면 DOM 생성을 일시 중단한다.

이후 자바스크립트 파일을 서버에 요청하여 응답받은 파일이나 script 태그내의 코드를 파싱하기 위해 자바스크립트 엔진에 제어권을 넘긴다. (렌더링 엔진 → 자바스크립트 엔진으로 제어권 이동)

이후 자바스크립트 파싱과 실행이 종료되면 렌더링 엔진으로 다시 제어권 넘겨 HTML 파싱 중단된 시점부터 다시 DOM 생성을 재개한다.

자바스크립트 엔진은 자바스크립트 코드를 파싱하기 시작한다. 자바스크립트를 해석하여 AST(추상적 구문 트리)를 생성한다. 그리고 AST를 기반으로 인터프리터가 실행할 수 있는 중간 코드인 바이트 코드를 생성하여 실행한다.

6. 리플로우와 리페인트

만약 자바스크립트 코드에 DOM, CSSOM을 변경하는 DOM API가 사용된 경우 DOM, CSSOM이 변경되고 변경된 DOM, CSSOM으로 다시 렌더트리로 결합되고 레이아웃과 페인트 과정을 거쳐 브라우저 화면에 다시 렌더링한다. 이를 리플로우, 리페인트라고 한다.

자바스크립트 파싱에 의한 HTML 파싱중단

렌더링 엔진과 자바스크립트 엔진은 서로 제어권을 이동시키면서 병렬적으로 파싱하지 않고 직렬적으로 파싱을 수행한다. 브라우저는 이처럼 동기적으로, 즉 순차적으로 HTML, CSS, 자바스크립트를 파싱하고 실행한다.

script 태그를 만나면 제어권이 이동하기 때문에 HTML 문서 내의 script 태그의 위치는 중요한 의미를 갖는다.

대표적인 문제로는 HTML이 생성되기 전에 자바스크립트 코드가 HTML요소를 동적으로 조작하려고 하면 정상적으로 동작하지 않을 수 있다. 이에 대한 해결책으로는 아래와 같다.

  • body 태그 제일 하단에 script 태그(자바스크립트)를 위치 시키는 것

script 태그의 async/defer 어트리뷰트

앞서 알아본 문제를 근본적으로 해결하기 위해서 HTML5부터 script 태그에 asyncdefer 어트리뷰트가 추가되었다.

두 어트리뷰트는 src 어트리뷰트를 통해 외부의 자바스크립트 파일을 로드하는 경우에만 사용할 수 있다.

1
2
<script async src="extern.js"></script>
<script defer src="extern.js"></script>

이 둘을 사용하면 HTML 파싱과 외부 자바스크립트 파일의 로드가 비동기적으로 진행된다. 단 두 어트리뷰트의 실행 시점의 차이가 있다.

  1. async 어트리뷰트

자바스크립트의 파싱과 실행은 자바스크립트 파일의 로드가 완료된 직후 진행된다. 이 때 HTML 파싱이 중단된다.

여러개의 script 태그에 async 어트리뷰트를 지정하면 script 태그의 순서와는 상관없이 로드가 완료된 자바스크립트부터 먼저 실행되므로 순서가 보장되지 않는다.

즉, 순서보장이 필요한 script 태그는 async 어트리뷰트 지정하지 않아야 한다.

  1. defer 어트리뷰트

자바스크립트의 파싱과 실행은 HTML 파싱이 완료된 직후, 즉 DOM 생성이 완료된 직후(DOMContentLoaded 이벤트가 발생한다) 진행된다.

모듈은 기본적으로 defer이다. async는 잘 사용하지 않지만 폰트의 경우 용량도 크고 순서가 크게 상관이 없으니 최대한 빨리 가져오기 위해 async를 사용하기도 한다.

댓글 공유

loco9939

author.bio


author.job