- 모든 웹 애플리케이션에서 가장 중요한 기능 중 하나는 데이터의 표시됩니다
- 데이터를 표시한다는 것은 요소를 화면이나 다른 출력 장치에 렌더링하는 것을 의미합니다
- W3C(world Wide Web Consortium)는 프로그래밍 방식으로 요소를 렌더링 하는 방식을 DOM(Document Object Model)로 정의했습니다.
- 2장의 목적은 프레임워크 없이 DOM을 효과적으로 조작하는 방법을 배우는 데 있습니다.
문서 객체 모델
DOM은 웹 애플리케이션을 구성하는 요소를 조작할 수 있는 API입니다.
<html>
<body>
<table>
<tr>
<th>Framework</th>
<th>Github stars</th>
</tr>
<tr>
<th>Vue</th>
<th>118917</th>
</tr>
<tr>
<th>React</th>
<th>115392</th>
</tr>
</table>
</body>
</html>
2-1. 간단한 HTML 테이블
const SELECTOR = 'tr:nth-child(3) > td'
const cell = document.querySelector(SELECTOR)
cell.style.background = 'red'
React 셀의 색상 변경
기술적 관점에서 보면 모든 HTML페이지(또는 그 일부)는 트리로 구성되고 그림 2-2처럼 DOM으로 표현됩니다. 그리고 HTML 요소로 정의된 트리를 관리하는 방법은 아래와 같은 형식을 사용합니다.
코드는 간단하게 표준 CSS 선택자를 사용해 올바른 셀을 선택한 다음 셀 노드의 style 속성을 변경합니다. querySelector 메서드는 Node 메서드입니다. Node는 HTML 트리에서 노드를 나타내는 기본 인터페이스입니다.
렌더링 성능 모니터링
- 웹용 렌더링 엔진을 설계할 때 중요한 요소는 가독성과 유지 관리성 그리고 성능입니다.
- 렌더링 엔진을 처음부터 새로 작성하기로 결정했다면 이해하고 발전시키기 쉽게 설계해야합니다.
렌더링 엔진의 성능을 모니터링하는 여러 도구를 살펴보자!
크롬 개발자 도구
FPS(Frames Per Second) : 렌더링 성능 모니터링에서 사용할 수 있는 기능 중 하나로 초당 프레임 수를 추적할 수 있습니다. 크롬 개발자 도구 → cmd/ctrl + Shift + P : FPS 및 GPU 사용량 확인 가능
💡 웹 개발에서 권장되는 FPS(Frames Per Second)는 일반적으로 60 FPS입니다. 일반적으로 대부분의 웹 브라우저에서는 W3C 권장사항에 따라 디스플레이 주사율과 일치됩니다.
stats.js
- 애플리케이션의 FPS를 모니터링할 수 있는 라이브러리중 하나입니다
- 프레임 & 할당된 메가바이트의 메모리 렌더링에 필요한 밀리초 표시 가능합니다
- 어떤 웹 사이트에도 위젯을 연결할 수 있는 간단한 북마크렛(bookmarklet)기능 제공합니다
💡 Bookmarklet은 북마크를 통해 실행할 수 있는 작은 자바스크립트 프로그램입니다. 북마크를 클릭하면, 해당 자바스크립트 코드가 현재 브라우저 탭에서 실행됩니다. 북마크렛 만드는 방법
사용자 정의 성능 위젯
function fps() {
let panel: HTMLDivElement | null = null;
let start: number | null = null;
let frames: number = 0;
function create(): HTMLDivElement {
const div: HTMLDivElement = document.createElement("div");
div.style.position = "fixed";
div.style.left = "0px";
div.style.top = "0px";
div.style.width = "50px";
div.style.height = "50px";
div.style.backgroundColor = "black";
div.style.color = "white";
return div;
}
function resetFramesAndStart(now: number): void {
if (panel !== null) {
panel.innerText = frames.toString();
}
frames = 0;
start = now;
}
function tick(): void {
frames++;
const now: number = window.performance.now();
if (start !== null && now >= start + 1000) {
resetFramesAndStart(now);
}
window.requestAnimationFrame(tick);
}
function initializePanel(parent: HTMLElement): void {
panel = create();
if (panel !== null) {
parent.appendChild(panel);
}
}
function init(parent: HTMLElement = document.body): void {
initializePanel(parent);
window.requestAnimationFrame(() => {
start = window.performance.now();
tick();
});
}
return init();
}
export default fps;
- fps 함수는 panel, start, frames라는 세 가지 변수를 선언합니다. panel은 FPS를 표시하는 HTML 요소를 참조하고, start는 FPS 계산을 시작하는 시간을 저장하며, frames는 계산된 프레임 수를 저장합니다.
- create 함수는 FPS를 표시하는 HTML 요소를 생성하고 반환합니다.
- resetFramesAndStart 함수는 프레임 수를 0으로 재설정하고, 시작 시간을 현재 시간으로 설정합니다. 또한, panel이 null이 아닌 경우, panel의 텍스트를 현재 프레임 수로 설정합니다.
- tick 함수는 프레임 수를 증가시키고, 현재 시간이 시작 시간보다 1초 이상 지났는지 확인합니다. 만약 1초 이상 지났다면, resetFramesAndStart 함수를 호출하여 프레임 수와 시작 시간을 재설정합니다. 그리고 window.requestAnimationFrame을 사용하여 tick 함수를 다시 호출합니다.
- initializePanel 함수는 create 함수를 호출하여 FPS를 표시하는 HTML 요소를 생성하고, 이 요소를 부모 요소에 추가합니다.
- init 함수는 initializePanel 함수를 호출하여 FPS를 표시하는 HTML 요소를 초기화하고, window.requestAnimationFrame을 사용하여 tick 함수를 호출합니다. 이 때, 시작 시간을 현재 시간으로 설정합니다.
- 마지막으로, fps 함수는 init 함수를 호출하여 FPS 계산을 시작합니다. 이 함수는 init 함수의 반환값을 반환합니다.
렌더링 함수
이번 장은 순수하게 함수를 사용해 요소를 DOM에 렌더링하는 다양한 방법을 분석해봅니다. 순수 함수로 요소를 렌더링한다는 것은 DOM 요소가 애플리케이션의 상태에만 의존한다는 것을 의미합니다.
view = f(state)
순수 함수 렌더링의 수학적 표현
Todo MVC
- 2장의 예제를 위해 TodoMVC(https://todomvc.com/) 템플릿을 사용합니다.
- 책에서 사용된 코드는 https://github.com/Apress/frameworkless-front-end-development/tree/747d119cd515f74d54cea67e92a31f45e0a803ea/Chapter02 입니다.
순수 함수 렌더링
index.html은 TodoMVC 애플리케이션의 골격을 나타냅니다.
<html>
<head>
<link rel="shortcut icon" href="../favicon.ico" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/todomvc-common@1.0.5/base.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/todomvc-app-css@2.1.2/index.css"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Faker/3.1.0/faker.js"></script>
<title>Frameworkless Frontend Development: Rendering</title>
</head>
<body>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
placeholder="What needs to be done?"
autofocus
/>
</header>
<section class="main">
<input id="toggle-all" class="toggle-all" type="checkbox" />
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list"></ul>
</section>
<footer class="footer">
<span class="todo-count">1 Item Left</span>
<ul class="filters">
<li>
<a href="#/">All</a>
</li>
<li>
<a href="#/active">Active</a>
</li>
<li>
<a href="#/completed">Completed</a>
</li>
</ul>
<button class="clear-completed">Clear completed</button>
</footer>
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
<p>
Created by
<a href="http://twitter.com/thestrazz86">Francesco Strazzullo</a>
</p>
<p>Thanks to <a href="http://todomvc.com">TodoMVC</a></p>
</footer>
<script type="module" src="index.js"></script>
</body>
</html>
index.html
이 어플리케이션을 동적으로 만들려면 to-do 리스트 데이터를 가져와 다음을 업데이트 해야 합니다.
- 필터링된 todo 리스트를 가진 ul
- 완료되지 않은 todo 수를 가진 span
- 선택된 filter를 강조 표시하기 위해 filters하위의 li > a class에 selected 추가
const getTodoElement = (todo) => {
const { text, completed } = todo;
return `
<li ${completed ? 'class="completed"' : ""}>
<div class="view">
<input
${completed ? "checked" : ""}
class="toggle"
type="checkbox">
<label>${text}</label>
<button class="destroy"></button>
</div>
<input class="edit" value="${text}">
</li>`;
};
const getTodoCount = (todos) => {
const notCompleted = todos.filter((todo) => !todo.completed);
const { length } = notCompleted;
if (length === 1) {
return "1 Item left";
}
return `${length} Items left`;
};
export default (targetElement, state) => {
const { currentFilter, todos } = state;
const element = targetElement.cloneNode(true); //노드 복사
const list = element.querySelector(".todo-list");
const counter = element.querySelector(".todo-count");
const filters = element.querySelector(".filters");
list.innerHTML = todos.map(getTodoElement).join("");
counter.textContent = getTodoCount(todos);
Array.from(filters.querySelectorAll("li a")).forEach((a) => {
if (a.textContent === currentFilter) {
a.classList.add("selected");
} else {
a.classList.remove("selected");
}
});
return element;
};
view.js
- getTodoElement(todo): 이 함수는 하나의 todo 항목을 받아서 HTML 요소를 문자열로 반환합니다. 이 문자열은 각 todo 항목의 상태와 텍스트를 반영합니다.
- getTodoCount(todos): 이 함수는 완료되지 않은 todo 항목의 수를 반환합니다. 이 수는 "1 Item left" 또는 "{length} Items left" 형식의 문자열로 반환됩니다.
- export default (targetElement, state): 이 함수는 대상 HTML 요소와 상태를 인자로 받아서, 상태에 따라 대상 요소를 업데이트하고 새로운 요소를 반환합니다. 이 함수는 todo 항목의 목록, 완료되지 않은 항목의 수, 그리고 현재 필터 상태를 업데이트합니다.
- 기본으로 사용되는 targetElement를 받습니다.
- 원래 노드를 복제하고 state 매개 변수 사용을 통해 업데이트 합니다.
- 새 노드 반환
- 본 코드에서의 DOM 수정은 가상(virtual)이며, 분리된(detached) **요소 로 작업하고 있습니다
- 분리된 요소 생성을 위해 cloneNode메소드로 기존 노드 복제 합니다.
- 새로 생성된 DOM element는 targetElement 와 동일한 복제본이지만 targetElement 는 전혀 관련이 없습니다.
- 아직 DOM의 실제 수정 사항이 커밋되지 않은 상태입니다
- 분리된 DOM요소의 수정이 이루어질 경우 성능 향상
- view.js 를 실제 DOM에 연결 하기 위해 컨트롤러인 index.js를 사용합니다.
- 본 코드에서의 DOM 수정은 가상(virtual)이며, 분리된(detached) **요소 로 작업하고 있습니다
import getTodos from "./getTodos.js";
import view from "./view.js";
const state = {
todos: getTodos(),
currentFilter: "All",
};
const main = document.querySelector(".todoapp");
window.requestAnimationFrame(() => {
const newMain = view(main, state);
main.replaceWith(newMain);
});
control 역할을 하는 index.js
- control 역할을 하는 index.js처럼 간단한 ‘렌더링 엔진은’ requestAnimationFrame을 기반으로 합니다.
- requestAnimationFrame 콜백 내에서 DOM작업을 수행하면 더 휴율적이게 됩니다.
- requestAnimationFrame는 메인 스레드를 차단하지 않으며 실행될 다시 그리기(repaint) 가 이벤트 루프에서 스케줄링되기 직전에 실행됩니다.
- replaceWith: Element.replaceWith()메서드는 부모의 하위 목록에서 이 Element 노드 또는 문자열 객체 집합으로 대체합니다. 문자열 객체는 동등한 텍스트 노드로 삽입됩니다.
const { faker } = window;
const createElement = () => ({
text: faker.random.words(2),
completed: faker.random.boolean(),
});
const repeat = (elementFactory, number) => {
const array = [];
for (let index = 0; index < number; index++) {
array.push(elementFactory());
}
return array;
};
export default () => {
const howMany = faker.random.number(10);
return repeat(createElement, howMany);
};
model역할을 하는 getTodos.js
- 여기서 작성하는 데이터 모델은 랜덤 데이터 생성에 유용한 라이브러리인 Faker.js로 생성된 랜덤 배열입니다.
코드 리뷰
여기서의 렌더링 방식은 requestAnimationFrame과 가상 노드 조작을 사용해 충분한 성능을 보여줍니다. 하지만 뷰 함수는 읽기 쉽지 않습니다. 코드는 두 가지 중요한 문제를 갖고 있습니다.
- 하나의 거대한 함수: 여러 DOM 요소를 조작하는 함수가 단 하나 뿐입니다. 이는 상황을 아주 쉽게 복잡하게 만들 수 있습니다.
- 동일한 작업을 수행하는 여러 방법: DOM조작을 위해 필요한 요소를 각각의 query selector로 접근 하지만 DOM을 조작하는 방법은 모두 다름
- list: 문자열로 리스트 항목 생성 후 innerHTML 에 값을 넣어줍니다.
- count : 완료되지 않은 todo의 개수를 textContent 에 넣어줍니다.
- filter: ‘selected’ class를 classList 로 관리 하고 있습니다.
💡 위 문제를 해결 하기 위해 view.js 를 조금더 작은 함수로 분리 했을 때의 예제 코드입니다.
view폴더를 만들고 기능에 맞게 counter.js, filter.js, todos.js 파일로 코드를 분리하고 app.js에서 만들어직 각각의 새 노드들을 replaceWith 메서드로 대체 해줍니다. 위 예제 코드는 구성 요소 라이브러의 첫 번째 초안이 됩니다.
구성 요소 함수
구성 요소 기반의 애플리케이션을 작성하려면 구성 요소 간의 상호작용에 선언적 방식을 사용해야 합니다.
https://github.com/Apress/frameworkless-front-end-development/tree/master/Chapter02/03는 구성 요소 레지스트리를 갖는 렌더링 엔진의 예입니다. Todo 앱에서는 todos, counters, filters의 세 가지 구성 요소를 가집니다.
<html>
<head>
<link rel="shortcut icon" href="../favicon.ico" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/todomvc-common@1.0.5/base.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/todomvc-app-css@2.1.2/index.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Faker/3.1.0/faker.js"></script>
<title>
Frameworkless Frontend Development: Rendering
</title>
</head>
<body>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
placeholder="What needs to be done?"
autofocus>
</header>
<section class="main">
<input
id="toggle-all"
class="toggle-all"
type="checkbox">
<label for="toggle-all">
Mark all as complete
</label>
<ul class="todo-list" data-component="todos">
</ul>
</section>
<footer class="footer">
<span
class="todo-count"
data-component="counter">
1 Item Left
</span>
<ul class="filters" data-component="filters">
<li>
<a href="#/">All</a>
</li>
<li>
<a href="#/active">Active</a>
</li>
<li>
<a href="#/completed">Completed</a>
</li>
</ul>
<button class="clear-completed">
Clear completed
</button>
</footer>
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
<p>Created by <a href="http://twitter.com/thestrazz86">Francesco Strazzullo</a></p>
<p>Thanks to <a href="http://todomvc.com">TodoMVC</a></p>
</footer>
<script type="module" src="index.js"></script>
</body>
</html>
2-11. 구성 요소를 사용하고자 데이터 속성을 사용하는 앱
- 2-11. 구성 요소를 사용하고자 데이터 속성을 사용하는 앱에서는 todos, counters, filters의 세 가지 구성 요소를 data-component 속성에 넣었습니다.
- 이 속성은 뷰 함수에서의 호출에서 사용됩니다.
- 컴포넌트 라이브러리를 생성하기 위한 또 다른 필수 조건은 registry로 입니다. 레지스트리는 애플리케이션에서 사용할 수 있는 모든 구성 요소의 인덱스 입니다.
const registry = {
todos: todosView,
counter: counterView,
filters: filtersView,
};
간단한 구성 요소 레지스트리
- 레지스트리의 키는 data-component 속성 값과 일치 합니다.(이것이 구성 요소 기반 렌더링 엔진의 핵심 메커니즘입니다.)
- 이 메커니즘은 루트 컨테이너 뿐만 아니라 생성할 모든 컴포넌트에 적용돼야 합니다. 이렇게 하면 모든 컴포넌트가 다른 컴포넌트 안에서 불러와 사용될 수 있습니다. 이런 재사용성은 컴포넌트 기반 애플리케이션에서 필수적입니다.
- 모든 컴포넌트가 data-component 속성의 값을 읽고 올바른 함수를 자동으로 호출하는 기본 구성 요소에 상속돼야합니다. 하지만 순수 함수로 작성하고 있기 때문에 실제로는 이 기본 객체에서 상속 받을 수 없어서 구성 요소를 래핑하는 고차 함수를 생성해야 합니다.
const renderWrapper = (component) => {
return (targetElement, state) => {
const element = component(targetElement, state);
const childComponents = element.querySelectorAll("[data-component]");
Array.from(childComponents).forEach((target) => {
const name = target.dataset.component;
const child = registry[name];
if (!child) {
return;
}
target.replaceWith(child(target, state));
});
return element;
};
};
고차 함수 렌더링
- renderWrapper(component): 이 함수는 컴포넌트를 인자로 받아서, 새로운 함수를 반환합니다. 반환된 함수는 targetElement와 state를 인자로 받아서 컴포넌트를 렌더링합니다.
- 반환된 함수 내부에서는 먼저 component(targetElement, state)를 호출하여 컴포넌트를 렌더링하고, 결과로 나온 DOM 요소를 element에 저장합니다.
- element.querySelectorAll('[data-component]'): 이 코드는 element 내부에서 data-component 속성을 가진 모든 자식 컴포넌트를 찾습니다. 이 속성은 자식 컴포넌트의 이름을 나타냅니다.
- Array.from(childComponents).forEach(target => {...}): 찾아낸 자식 컴포넌트들을 배열로 변환한 후, 각각에 대해 반복 처리를 합니다. 반복 처리 내부에서는 registry에서 해당 컴포넌트의 렌더링 함수를 찾아 호출하고, 결과로 나온 DOM 요소로 기존의 자식 컴포넌트를 대체합니다.
const add = (name, component) => {
registry[name] = renderWrapper(component);
};
2-14 레지스트리 접근자 메서드
레지스트리에 구성 요소를 추가하려면 2-14와 같이 이전 함수로 구성 요소를 래핑하는 간단한 함수가 필요합니다.
const renderRoot = (root, state) => {
const cloneComponent = (root) => {
return root.cloneNode(true);
};
return renderWrapper(cloneComponent)(root, state);
};
2-15 구성 요소 기반 애플리케이션의 부팅 함수
최초 DOM 요소에서 렌더링을 시작하려면 애플리케이션의 루트를 렌더링하는 메서드를 제공해야 합니다.
import getTodos from "./getTodos.js";
import todosView from "./view/todos.js";
import counterView from "./view/counter.js";
import filtersView from "./view/filters.js";
import registry from "./registry.js";
registry.add("todos", todosView);
registry.add("counter", counterView);
registry.add("filters", filtersView);
const state = {
todos: getTodos(),
currentFilter: "All",
};
window.requestAnimationFrame(() => {
const main = document.querySelector(".todoapp");
const newMain = registry.renderRoot(main, state);
main.replaceWith(newMain);
});
구성 요소 레지스트리를 사용하는 컨트롤러
동적 데이터 렌더링
이전 예제에서는 정적 데이터를 사용했습니다. 그러나 실제 애플리케이션에서는 사용자나 시스템의 이벤트에 의해 데이터가 변경됩니다.
가상 DOM
- 리액트에 의해 유명해진 가상 DOM 개념은 선언적 렌더링 엔진의 성능을 개선시키는 방법입니다.
- UI 표현은 메모리에 유지되고 ‘실제’ DOM과 동기화 됩니다.
- 실제 DOM은 가능한 적은 작업을 수행되어야 합니다. 우리는 이 과정을 조정(reconciliation)이라고 부릅니다.
- 가상 DOM의 핵심은 diff 알고리즘입니다. 이 알고리즘은 실제 DOM을 문서에서 분리된 새로운 DOM element의 사본으로 바꾸는 가장 빠른 방법을 찾아냅니다.
간단한 가상 DOM 구현
const isNodeChanged = (node1, node2) => {
const n1Attributes = node1.attributes;
const n2Attributes = node2.attributes;
if (n1Attributes.length !== n2Attributes.length) {
return true;
}
const differentAttribute = Array.from(n1Attributes).find((attribute) => {
const { name } = attribute;
const attribute1 = node1.getAttribute(name);
const attribute2 = node2.getAttribute(name);
return attribute1 !== attribute2;
});
if (differentAttribute) {
return true;
}
if (
node1.children.length === 0 &&
node2.children.length === 0 &&
node1.textContent !== node2.textContent
) {
return true;
}
return false;
};
const applyDiff = (parentNode, realNode, virtualNode) => {
if (realNode && !virtualNode) {
realNode.remove();
return;
}
if (!realNode && virtualNode) {
parentNode.appendChild(virtualNode);
return;
}
if (isNodeChanged(virtualNode, realNode)) {
realNode.replaceWith(virtualNode);
return;
}
const realChildren = Array.from(realNode.children);
const virtualChildren = Array.from(virtualNode.children);
const max = Math.max(realChildren.length, virtualChildren.length);
for (let i = 0; i < max; i++) {
applyDiff(realNode, realChildren[i], virtualChildren[i]);
}
};
export default applyDiff;
코드는 크게 두 부분으로 나뉩니다: isNodeChanged 함수와 applyDiff 함수입니다.
isNodeChanged 함수
이 함수는 두 노드(HTML 요소)를 비교하여 변경이 필요한지 여부를 판단합니다. 다음과 같은 경우에 변경이 필요하다고 판단합니다:
- 속성의 개수가 다른 경우: 두 노드의 속성 개수가 다르면, 무조건 변경이 필요합니다.
- 속성의 값이 다른 경우: 두 노드의 속성을 하나씩 비교하여 값이 다른 속성이 하나라도 있으면 변경이 필요합니다.
- 자식 노드가 없고 텍스트 내용이 다른 경우: 두 노드 모두 자식 노드가 없으면서 텍스트 내용이 다를 경우 변경이 필요합니다.
applyDiff 함수
이 함수는 실제 DOM 노드(realNode)와 가상 DOM 노드(virtualNode)를 비교하여, 필요한 변경사항을 실제 DOM에 적용합니다. 다음과 같은 경우에 대해 처리합니다:
- 실제 노드는 있지만 가상 노드가 없는 경우: 실제 노드를 DOM에서 제거합니다.
- 실제 노드는 없지만 가상 노드가 있는 경우: 가상 노드를 실제 DOM에 추가합니다.
- 두 노드가 다른 경우: isNodeChanged 함수를 사용하여 두 노드가 다르다고 판단되면, 실제 노드를 가상 노드로 교체합니다.
- 자식 노드 비교: 실제 노드와 가상 노드의 자식 노드들을 재귀적으로 비교하여, 각 자식 노드에 대해서도 applyDiff 함수를 호출합니다. 이 과정에서 자식 노드들 사이의 차이점도 적용됩니다.
이 코드는 DOM의 변경사항을 최소화하여 성능을 개선하는 데 도움을 줍니다. 가상 DOM과 실제 DOM 사이의 차이점만을 적용함으로써, 불필요한 DOM 조작을 줄이고 애플리케이션의 반응 속도를 향상시킵니다.
참고
'독서 > 2024' 카테고리의 다른 글
[프레임워크 없는 프론트엔드 개발] - 3장. DOM 이벤트 관리 (0) | 2024.07.06 |
---|---|
개발자를 넘어 기술 리더로 가는 길 - 타냐 라일리 (1) | 2024.06.15 |
[쏙쏙들어오는 함수형 코딩] CHAPTER2 현실에서의 함수형 사고 - 요약 정리 (0) | 2023.08.30 |
[쏙쏙들어오는 함수형 코딩] CHAPTER1 쏙쏙 들어오는 함수형 코딩에 오신 것을 환영합니다 - (0) | 2023.08.21 |
[쏙쏙들어오는 함수형 코딩] 들어가기 전 - 요약 정리 (0) | 2023.08.17 |