1주차: 01장 프론트엔드의 구성요소와 발전 과정
핵심 내용 정리
학습 목표
- 웹 애플리케이션을 구성하는 주요 컴포넌트를 이해한다.
- 모던 프론트엔드 프레임워크가 등장하기까지의 발전 과정을 돌아본다.
- 명령형 프로그래밍과 선언형 프로그래밍의 차이를 구분한다.
1.1 프론트엔드의 구성요소와 발전 과정을 돌아봐야 하는 이유
- 기술이 등장한 근본적인 이유를 이해하기 위해 과거의 문제와 해결 과정을 살펴볼 필요가 있다.
- jQuery는 브라우저마다 다른 DOM API를 추상화하고, 복잡한 DOM 조작을 단순화하기 위해 등장했다.
- 웹 애플리케이션의 규모가 커지면서 React 같은 컴포넌트 기반 아키텍처가 주목받기 시작했다.
- 기술의 배경을 알면 상황에 맞는 기술을 선택하는 능력을 기를 수 있다.
1.2 웹 개발과 프론트엔드가 차지하는 위상과 구성요소
- 웹 개발은 크게 서버 사이드 개발과 클라이언트 사이드 개발로 나눌 수 있다.
- 서버 사이드는 프로그래밍 언어와 런타임 환경을 기반으로 DB나 외부 API에서 데이터를 조회하고 처리한다.
- 클라이언트 사이드는 브라우저에서 실행되는 JavaScript를 통해 사용자 입력을 처리하고 즉각적인 반응을 만든다.
- 큰 흐름으로 보면 웹은
클라이언트 - 서버 - DB구조로 이해할 수 있다.
1.3 초창기 웹 프론트엔드
- 초창기 웹은 HTML 문서를 서버에서 완성해 전달하는 방식이었다.
- CSS가 충분히 분리되지 않아 HTML 태그 안에 스타일을 직접 포함하기도 했고, 상호작용도 거의 없었다.
- 1995년 JavaScript가 등장했지만 대부분의 로직은 여전히 서버에서 처리되었고, 변경사항을 보려면 페이지를 새로고침해야 했다.
- 2000년대 이후 브라우저가 안정화되면서 JavaScript와 DOM 조작 방식이 발전했다.
DOM(Document Object Model)은 HTML 문서를 트리 구조로 표현하고 JavaScript가 요소를 동적으로 조작할 수 있게 해주는 인터페이스다.
DOM을 사용하면 다음과 같은 일을 할 수 있다.
- 페이지 새로고침 없이 콘텐츠나 속성을 변경할 수 있다.
- 클릭, 입력, 스크롤 같은 사용자 상호작용에 반응할 수 있다.
- HTML을 트리 구조로 다뤄 원하는 요소를 찾고 수정할 수 있다.
- CSS와 함께 애니메이션 효과를 적용할 수 있다.
- Ajax나 Fetch API와 함께 서버 데이터를 비동기로 받아 화면에 표시할 수 있다.
1999년 Internet Explorer 5에서 XHR(XMLHttpRequest)이 도입되었고, 이후 Ajax라는 개발 방식이 정립되면서 새로고침 없이 서버와 데이터를 주고받을 수 있게 되었다.
XHR 등장
↓
브라우저에서 JavaScript로 서버에 비동기 요청 가능
↓
페이지 전체 새로고침 없이 데이터만 수신
↓
이 방식을 Ajax라고 부르게 됨
XHR은 브라우저에서 서버와 비동기로 데이터를 주고받는 API이고, Ajax는 XHR 같은 API를 이용해 새로고침 없이 서버와 통신하고 화면 일부만 바꾸는 개발 방식이다.
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/users");
xhr.send();
요즘은 XHR 대신 fetch나 axios를 많이 사용하지만, 새로고침 없이 비동기로 서버와 통신한다는 점에서는 Ajax의 연장선으로 볼 수 있다.
1.4 모듈의 탄생과 한계
- 초창기 웹페이지는 여러 페이지로 이루어진 MPA(Multi Page Application) 방식이 일반적이었다.
- MPA는 페이지 이동의 주도권이 서버에 있고, 각 URL에 중요한 메타 태그가 포함될 수 있어 SEO에 유리하다.
- 다만 페이지를 이동할 때마다 화면 전체를 새로고침해야 하므로 사용자 경험이 떨어질 수 있다.
- Ajax 등장 이후 일부 콘텐츠만 갱신할 수 있게 되었지만, 완전히 새로운 페이지는 여전히 서버에 요청해야 했다.
MPA와 SPA의 차이는 다음처럼 정리할 수 있다.
MPA는 페이지 이동의 주도권이 서버에 있고, SPA는 페이지 이동의 주도권이 브라우저 JavaScript에 있다.
SPA는 보통 다음과 같은 방식으로 동작한다.
<a href="/profile"> 클릭
→ JavaScript가 click 이벤트 감지
→ event.preventDefault()로 브라우저 기본 이동 차단
→ history.pushState()로 주소창만 /profile로 변경
→ 실제 서버 요청 없이 React가 Profile 컴포넌트 렌더링
MPA의 불편함을 줄이기 위해 재사용 가능한 코드 조각인 모듈을 만들어 공유하기 시작했다. 하지만 모듈이 많아지면서 서로 의존성이 생기고, 의존성 관계와 로드 순서를 관리하기 어려워졌다.
1.5 번들러의 탄생
번들러는 여러 JavaScript 모듈의 의존 관계를 분석해 브라우저가 실행할 수 있는 형태의 파일로 묶어주는 도구다. 개발자는 파일을 기능별로 나누어 작성할 수 있고, 번들러는 import와 export 관계를 따라 필요한 코드를 모아 최종 번들 파일을 생성한다.
| 도구 | 한 줄 설명 |
|---|---|
| Grunt | 반복 작업을 자동화해주는 초기 JavaScript 태스크 러너 |
| Webpack | JavaScript 모듈과 리소스 의존성을 분석해 브라우저용 번들로 묶는 대표적인 번들러 |
| Rollup | ES Module 기반으로 라이브러리 번들링과 Tree Shaking에 강점을 가진 번들러 |
| esbuild | Go로 작성되어 빠른 번들링과 트랜스파일을 제공하는 빌드 도구 |
| Vite | 개발 서버는 빠른 ESM 기반으로 동작하고, 프로덕션 빌드는 Rollup을 활용하는 현대적인 프론트엔드 빌드 도구 |
번들러 덕분에 개발자는 의존성 순서를 직접 맞추지 않고도 import와 require를 사용할 수 있게 되었다. 또한 Tree Shaking과 코드 스플리팅을 통해 불필요한 코드를 줄이고 로딩 시간을 개선할 수 있다.
1.6 패키지 매니저의 도입
- 이전에는 라이브러리 웹사이트에서 JavaScript 파일을 직접 내려받아 사용하는 방식이 많았다.
- npm(Node Package Manager)은
npm install같은 CLI 명령으로 의존성을 쉽게 설치할 수 있게 해주었다. - 설치된 패키지는 의존성 트리 형태로
node_modules에 배치된다. package.json과package-lock.json이 있으면 개발 환경을 쉽게 복원할 수 있다.- 라이브러리뿐 아니라 빌드 도구, 템플릿, 개발 환경 구성 도구도 패키지를 통해 관리할 수 있게 되었다.
1.7 템플릿 엔진의 도입
템플릿 엔진은 서버의 데이터를 HTML 템플릿에 주입해 최종 HTML 문서를 생성하는 도구다. 변수, 조건문, 반복문 등을 HTML 안에서 사용할 수 있어 동적인 페이지를 더 쉽게 만들 수 있고, 공통 레이아웃이나 부분 템플릿을 재사용해 유지보수성을 높일 수 있다.
대표적인 템플릿 엔진으로는 Pug, Handlebars, EJS 등이 있다.
템플릿 엔진의 핵심은 다음과 같다.
- 서버에서 HTML을 만든다.
- HTML 안에서 변수, 조건문, 반복문을 쓸 수 있다.
- 공통 레이아웃을 재사용할 수 있다.
이 시기를 거치며 웹 프로젝트는 점점 체계적인 구조를 갖추게 되었다. 예를 들어 src에는 주요 소스 코드를, public에는 정적 파일을, dist에는 번들링된 파일을 두는 식의 구조가 자리 잡았다.
1.8 명령형 프로그래밍과 선언형 프로그래밍
프론트엔드 UI를 업데이트하는 방식은 크게 명령형 방식과 선언형 방식으로 나눌 수 있다.
| 구분 | 설명 |
|---|---|
| 명령형 프로그래밍 | 개발자가 DOM을 직접 선택하고 어떤 순서로 어떻게 바꿀지 명시하는 방식 |
| 선언형 프로그래밍 | 개발자가 상태와 UI 결과를 선언하면 실제 DOM 업데이트는 React 같은 도구가 처리하는 방식 |
명령형 프로그래밍은 “어떻게 동작해야 하는지”를 개발자가 직접 지시하는 방식이다. jQuery를 사용한 DOM 조작 방식이 대표적인 예시다.
$(document).ready(function() {
$('#addItem').click(function() {
$('#itemList').append('<li>New Item</li>');
});
});
위 코드는 다음 순서로 동작한다.
- 문서가 준비된다.
#addItem버튼을 찾는다.- 버튼에 클릭 이벤트를 직접 등록한다.
- 클릭되면
#itemListDOM을 찾는다. li엘리먼트를 직접 추가한다.
즉 개발자가 직접 DOM을 찾아서 수정하는 방식이다. 작은 예제에서는 단순하지만, 애플리케이션이 커질수록 DOM 조작 코드가 여러 곳에 흩어지고 상태와 UI가 어긋나기 쉬워진다. 결과적으로 개발자가 UI 동기화 책임을 직접 가져가야 한다.
반면 선언형 프로그래밍은 “어떻게 바꿀지”가 아니라 “무엇을 보여줄지”를 선언하는 방식이다. React는 선언형 프로그래밍 방식을 기반으로 UI를 구성한다.
import React, { useState } from 'react';
function ItemList() {
const [items, setItems] = useState(['Item 1']);
function addItem() {
setItems([...items, 'New Item']);
}
return (
<div>
<ul>
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
<button onClick={addItem}>Add Item</button>
</div>
);
}
React에서는 개발자가 직접 ul DOM을 찾거나 li를 append 하지 않는다. 대신 items라는 상태를 만들고 이 상태를 기준으로 UI를 렌더링한다.
const [items, setItems] = useState(['Item 1']);
버튼을 클릭하면 DOM을 직접 조작하는 것이 아니라 상태를 변경한다.
setItems([...items, 'New Item']);
그리고 UI는 상태를 기반으로 표현된다.
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
즉 개발자는 “items 배열을 li 목록으로 보여줘”라고 선언하고, 상태가 변경되면 React가 변경을 감지해 UI를 업데이트한다.
명령형과 선언형의 차이는 다음처럼 비교할 수 있다.
| 비교 항목 | 명령형 프로그래밍 | 선언형 프로그래밍 |
|---|---|---|
| 집중하는 영역 | 결과를 달성하는 방법 | 결과가 무엇인지 |
| 코드 방식 | DOM을 직접 찾고 조작 | 상태를 기준으로 UI를 선언 |
| 추상화 수준 | 낮은 수준의 세부 구현 중심 | 높은 수준의 결과 중심 |
| UI 업데이트 | 개발자가 직접 처리 | React가 상태 변경을 감지해 처리 |
| 유지보수 | 규모가 커질수록 어려움 | 상대적으로 쉬움 |
| 예시 | Vanilla JS, jQuery | React, Vue |
한 줄로 요약하면, 명령형은 “이 DOM에 li를 추가해”라고 직접 지시하는 방식이고, 선언형은 “items 상태를 리스트로 보여줘”라고 선언하는 방식이다.
2주차: 02장 싱글 페이지 애플리케이션 돌아보기
핵심 내용 정리
학습 목표
- 싱글 페이지 애플리케이션(SPA)과 기존 멀티 페이지 애플리케이션(MPA)의 차이를 이해한다.
- SPA에서 URL 변경과 화면 렌더링이 어떤 방식으로 이루어지는지 이해한다.
- SPA가 프론트엔드와 백엔드 역할 분리, 상태 관리, 선언형 UI 개발에 어떤 영향을 주었는지 정리한다.
SPA의 기본 동작 방식
SPA는 URL이 어떻게 변경되더라도 서버가 대체로 같은 index.html을 제공하고, 실제 화면 구성은 JavaScript 번들에 선언된 코드에 따라 브라우저에서 변경된다.
사용자가 /profile 접근
↓
서버는 index.html 제공
↓
브라우저가 bundle.js 로드
↓
JavaScript 라우터가 현재 URL 확인
↓
Profile 화면에 필요한 컴포넌트 렌더링
즉, SPA에서 URL은 서버가 새 HTML 문서를 골라주는 기준이라기보다, 클라이언트 JavaScript가 어떤 화면을 보여줄지 판단하는 상태에 가깝다.
CSS나 JavaScript 파일을 여러 모듈로 나누어 작성해도 빌드 도구와 번들러가 알아서 의존성을 분석하고 묶어준다. 덕분에 개발자는 기능 단위로 코드를 나누면서도 브라우저에는 실행 가능한 형태의 결과물을 제공할 수 있다.
SPA의 장점
SPA는 단순히 “페이지 새로고침이 적은 웹”이 아니라, 프론트엔드 애플리케이션의 구조 자체를 바꾼 방식이다.
- 컴포넌트 기반 아키텍처를 적용하기 쉽다.
- 클라이언트 사이드 라우팅을 통해 화면 전환을 빠르게 처리할 수 있다.
- 서버에서 HTML 전체를 다시 받지 않고 필요한 데이터만 API로 요청할 수 있다.
- 프론트엔드와 백엔드를 분리하는 계기가 되었다.
- 화면 렌더링뿐 아니라 데이터 흐름, 비동기 통신, 상태 관리 같은 애플리케이션 생명주기를 프론트엔드가 적극적으로 다루게 되었다.
네트워크 호출 빈도
기존 MPA에서는 페이지를 이동할 때마다 서버에서 새 HTML 문서와 관련 리소스를 다시 받아와야 했다. URL이 변경될 때마다 서버 요청이 발생하고, 브라우저는 화면 전체를 다시 구성해야 했다.
반면 SPA는 최초 진입 시 필요한 정적 파일을 받은 뒤, 페이지 이동 시에는 전체 HTML을 다시 요청하지 않는다. 대신 클라이언트 라우터가 URL 변경을 감지하고 필요한 컴포넌트를 렌더링한다. 데이터가 필요한 경우에도 HTML 전체가 아니라 API 응답만 받아오는 방식으로 네트워크 비용을 줄일 수 있다.
성능 향상과 지연 로딩
SPA를 처음 보면 “여러 번 받을 파일을 하나로 뭉치면 오히려 더 커지는 것 아닌가?”라는 의문이 생긴다. 실제로 모든 코드를 하나의 거대한 번들로 만들면 초기 로딩이 느려질 수 있다.
이 문제를 해결하기 위해 지연 로딩(Lazy Loading)과 코드 스플리팅(Code Splitting)을 사용한다. 사용자가 당장 보지 않는 화면이나 기능의 코드는 나중에 필요할 때 불러오도록 나눌 수 있다.
초기 진입
↓
공통 코드와 현재 화면 코드만 로드
↓
다른 페이지로 이동
↓
해당 페이지에 필요한 코드 조각을 추가 로드
따라서 SPA의 성능은 “모든 것을 한 번에 받는다”가 아니라, “초기 실행에 필요한 것과 나중에 필요한 것을 나누어 관리한다”는 관점에서 이해해야 한다.
생산성 향상
SPA는 프론트엔드와 백엔드의 역할을 더 명확하게 분리했다.
| 영역 | 주요 역할 |
|---|---|
| 프론트엔드 | 정적 파일 서빙, 화면 렌더링, 사용자 상호작용 처리, 상태 관리 |
| 백엔드 | API 제공, 비즈니스 로직 처리, 인증/인가, 데이터 저장과 조회 |
이 구조에서는 백엔드가 완성된 HTML을 매번 내려주기보다 API를 통해 데이터를 제공하고, 프론트엔드는 그 데이터를 바탕으로 화면을 구성한다.
또한 React 같은 도구를 사용하면 DOM에 직접 접근해 명령형으로 화면을 조작하는 과정을 줄일 수 있다. 개발자는 “어떤 DOM을 찾아서 어떻게 바꿀지”보다 “현재 상태에서 어떤 UI를 보여줄지”에 집중하게 된다.
명령형 DOM 조작은 내부 속성값을 직접 바꾸는 과정에서 의도하지 않은 사이드 이펙트가 생기기 쉽다. 반면 선언형 UI는 상태와 화면의 관계를 코드로 표현하므로, 화면 업데이트 흐름을 더 예측하기 쉽게 만든다.
사이드 이펙트는 의도한 주된 결과 외에 함께 발생하는 현상이나 영향을 의미한다. 프론트엔드에서는 DOM 직접 조작, 전역 상태 변경, 비동기 요청 결과 등이 예상치 못한 화면 변화로 이어질 수 있다.
3주차: 03장 UI 컴포넌트의 위상 돌아보기
핵심 내용 정리
학습 목표
- UI가 프론트엔드 개발에서 왜 중요한지 이해한다.
- 바닐라 JavaScript로 간단한 UI 컴포넌트를 만드는 과정을 살펴본다.
- 웹 컴포넌트 API를 사용해 프레임워크 없이도 재사용 가능한 UI 컴포넌트를 만들 수 있음을 이해한다.
UI 컴포넌트의 의미
숙련된 프론트엔드 개발자라면 디자인을 보고 UI를 적절한 컴포넌트 단위로 나눌 수 있어야 한다.
컴포넌트는 재사용 가능하고 추상화된 코드 조각이다. React의 JSX는 UI 컴포넌트를 만들기 위한 도구 중 하나일 뿐이고, 컴포넌트의 본질 자체는 아니다.
컴포넌트의 핵심 원칙은 다음 세 가지로 볼 수 있다.
- 추상화
- 인터페이스 설계
- 캡슐화
UI 컴포넌트의 위상을 돌아봐야 하는 이유
프레임워크로부터 독립적인 이해
React, Vue 같은 프레임워크를 사용하면 컴포넌트를 쉽게 작성할 수 있지만, 그 과정에서 state와 props가 왜 필요한지 깊게 고민하지 않고 넘어가기 쉽다.
순수 JavaScript로 UI를 독립적인 조각으로 나누어보면 컴포넌트의 핵심 원리를 더 잘 이해할 수 있다. 중요한 것은 JSX 문법이 아니라, UI를 어떤 단위로 나누고 외부와 어떤 방식으로 상호작용하게 만들 것인지다.
더 나은 추상화 설계 능력
좋은 컴포넌트는 그 자체로 하나의 작은 제품처럼 동작한다.
- 명확한 API를 제공한다.
- 복잡한 내부 구현은 감춘다.
- 외부에서 예측 가능하게 사용할 수 있다.
- 필요한 역할만 책임진다.
React에서의 props는 컴포넌트가 외부와 소통하는 API에 가깝다. 컴포넌트 사용자는 내부 구현을 몰라도 적절한 값을 전달해 원하는 UI를 사용할 수 있어야 한다.
웹 표준의 흐름 이해
UI의 중요성이 커지면서 브라우저도 컴포넌트를 네이티브로 지원하기 시작했다. Web Components는 프레임워크 없이도 재사용 가능한 컴포넌트를 만들 수 있게 해주는 표준 기술이다.
바닐라 JavaScript와 웹 컴포넌트를 직접 다뤄보면 UI 컴포넌트가 특정 프레임워크만의 개념이 아니라, 웹 플랫폼 자체의 중요한 흐름이라는 점을 이해할 수 있다.
결국 UI 컴포넌트의 위상을 돌아보는 것은 라이브러리와 프레임워크를 벗어나 컴포넌트 설계자로 성장하는 과정이라고 볼 수 있다.
프론트엔드 개발에서 UI 컴포넌트의 위상
프론트엔드 개발자는 구현된 UI를 사용자에게 실제로 전달하는 역할을 맡는다. 공사를 할 때 구조와 설계를 보고 시공하듯이, 웹 개발자도 디자인 의도와 구현 결과 사이의 간극을 줄여야 한다.
UI 컴포넌트를 미리 제작해두거나 디자인 토큰을 사용하면 일관성과 생산성을 높일 수 있다. 버튼, 입력창, 카드, 모달 같은 공통 UI를 재사용 가능한 컴포넌트로 만들면 같은 문제를 반복해서 풀지 않아도 된다.
추상화, 인터페이스, 캡슐화
React에서 자주 사용하는 state와 props도 컴포넌트 원리를 이해하기 위한 좋은 예시다.
| 개념 | 설명 |
|---|---|
| state | 컴포넌트 내부에서 변경 가능한 동적인 데이터. 값이 변경되면 컴포넌트가 다시 렌더링된다. |
| props | 컴포넌트 간 데이터 전달 방식. 자식 컴포넌트는 전달받은 props를 직접 수정할 수 없다. |
추상화는 공통 특성을 일반화하고 복잡한 세부사항을 감추는 것이다. 컴포넌트 관점에서는 외부에 공개하기로 약속한 API의 집합이라고 볼 수 있다.
예를 들어 지연 로딩을 수행하는 이미지 컴포넌트가 있다면, 사용자는 내부에서 IntersectionObserver를 쓰는지, 임시 이미지 객체를 쓰는지 몰라도 된다. 정해진 속성으로 이미지 주소와 대체 텍스트를 전달하면 컴포넌트가 알아서 필요한 동작을 수행한다.
캡슐화는 내부 구현을 외부에서 함부로 건드리지 못하게 감추는 것이다. 클래스 기반 코드에서는 private 메서드로, 함수 기반 코드에서는 클로저로 일부 캡슐화를 구현할 수 있다.
바닐라 JavaScript로 컴포넌트 만들기
바닐라 JavaScript로도 클래스를 만들고 DOM을 생성해 재사용 가능한 UI 단위를 만들 수 있다. 아래 예시는 지연 로딩을 수행하는 이미지 컴포넌트다.
class LazyImage extends HTMLElement {
static observedAttributes = ["src", "alt"];
constructor() {
super();
this.attachShadow({ mode: "open" });
this.img = document.createElement("img");
this.placeholder = document.createElement("div");
this.observer = null;
this.isLoaded = false;
this.render();
}
connectedCallback() {
this.setupIntersectionObserver();
}
disconnectedCallback() {
this.cleanup();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (name === "src") {
this.isLoaded = false;
this.showLoading();
this.setupIntersectionObserver();
}
if (name === "alt") {
this.img.alt = newValue ?? "";
}
}
render() {
const style = document.createElement("style");
style.textContent = `
:host {
display: block;
width: 100%;
overflow: hidden;
position: relative;
}
.placeholder {
width: 100%;
height: 100%;
min-height: 200px;
background: #eee;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
transition: opacity 0.25s ease;
}
img.loaded {
opacity: 1;
}
.hidden {
display: none;
}
`;
this.placeholder.className = "placeholder";
this.placeholder.textContent = "Loading...";
this.img.alt = this.getAttribute("alt") ?? "";
this.shadowRoot.append(style, this.placeholder, this.img);
}
setupIntersectionObserver() {
this.cleanup();
const src = this.getAttribute("src");
if (!src || this.isLoaded) return;
this.observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
this.loadImage(src);
this.cleanup();
}
},
{
root: null,
rootMargin: "200px",
threshold: 0.1,
}
);
this.observer.observe(this);
}
loadImage(src) {
this.showLoading();
const tempImage = new Image();
tempImage.onload = () => {
this.img.src = src;
this.img.classList.add("loaded");
this.placeholder.classList.add("hidden");
this.isLoaded = true;
this.dispatchEvent(
new CustomEvent("load-success", {
detail: { src },
})
);
};
tempImage.onerror = () => {
this.showError();
this.dispatchEvent(
new CustomEvent("load-error", {
detail: { src },
})
);
};
tempImage.src = src;
}
showLoading() {
this.placeholder.classList.remove("hidden");
this.placeholder.textContent = "Loading...";
this.img.classList.remove("loaded");
this.img.removeAttribute("src");
}
showError() {
this.placeholder.classList.remove("hidden");
this.placeholder.textContent = "이미지를 불러오지 못했습니다.";
this.img.classList.remove("loaded");
}
cleanup() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
}
customElements.define("lazy-image", LazyImage);
이 예시에서 중요한 점은 이미지 로딩, 로딩 상태, 에러 상태, 이벤트 발생, 옵저버 정리 같은 세부 구현이 컴포넌트 내부에 들어 있다는 것이다. 외부 사용자는 <lazy-image> 태그와 속성만 알고 있어도 사용할 수 있다.
웹 컴포넌트 API
웹 컴포넌트를 사용하면 div, p, span 같은 기본 HTML 요소 외에도 직접 정의한 커스텀 엘리먼트를 사용할 수 있다.
<lazy-image src="/images/profile.png" alt="프로필 이미지"></lazy-image>
웹 컴포넌트를 구성하는 대표적인 기술은 다음과 같다.
| 기술 | 설명 |
|---|---|
| Custom Elements | 개발자가 직접 새로운 HTML 태그를 정의할 수 있게 해준다. |
| Shadow DOM | 컴포넌트 내부의 HTML, CSS, 동작을 외부 DOM과 분리해 캡슐화한다. |
| HTML Templates | 재사용 가능한 마크업 구조를 정의할 수 있게 해준다. |
Shadow DOM
Shadow DOM은 커스텀 컴포넌트 내부의 HTML, CSS, 동작을 외부 DOM과 분리해서 캡슐화하는 브라우저 기능이다.
겉으로는 커스텀 태그 하나만 보이지만, 내부에는 브라우저가 따로 관리하는 DOM 트리가 존재할 수 있다.
외부 DOM
└── <my-button>
└── Shadow DOM
├── <style>
└── <button>
일반 DOM에서는 전역 CSS가 의도치 않게 여러 요소에 영향을 줄 수 있다.
<style>
button {
color: red;
}
</style>
<button>일반 버튼</button>
하지만 Shadow DOM 내부에 정의한 스타일은 기본적으로 해당 컴포넌트 내부에만 적용된다.
class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
button {
color: blue;
}
</style>
<button>내 버튼</button>
`;
}
}
customElements.define("my-button", MyButton);
attachShadow의 mode는 open과 closed로 나눌 수 있다.
this.attachShadow({ mode: "open" });
open이면 외부 JavaScript에서 shadowRoot로 접근할 수 있다.
document.querySelector("my-button").shadowRoot;
반면 closed이면 외부에서 shadowRoot로 직접 접근할 수 없다.
this.attachShadow({ mode: "closed" });
다만 closed는 보안 기능이라기보다 내부 구현을 더 강하게 숨기는 방식에 가깝다.
React 컴포넌트와 Web Components의 차이
React 컴포넌트는 프레임워크 레벨의 컴포넌트이고, Web Components는 브라우저가 제공하는 표준 컴포넌트 기술이다.
<Button />
위 코드는 React가 관리하는 컴포넌트다.
<my-button></my-button>
위 코드는 브라우저가 이해할 수 있는 커스텀 엘리먼트가 될 수 있다.
React에서 컴포넌트를 만들어도 기본 CSS는 전역으로 새어 나갈 수 있다. 반면 Shadow DOM을 사용하면 커스텀 엘리먼트 내부 구현을 브라우저 레벨에서 분리할 수 있다.
바닐라 JavaScript와 웹 컴포넌트 API 비교
| 비교 항목 | 바닐라 JavaScript | 웹 컴포넌트 API |
|---|---|---|
| 정의 및 생성 방식 | 함수나 클래스로 DOM 요소를 직접 생성하고 조작 | Custom Elements, Shadow DOM 같은 표준 API로 컴포넌트 정의 |
| 재사용성 | 별도의 함수나 클래스를 만들어 재사용 | 독립적인 커스텀 엘리먼트로 여러 곳에서 재사용 |
| 캡슐화 | 변수, 함수 스코프로 일부 캡슐화 가능 | Shadow DOM으로 스타일과 마크업을 외부와 분리 |
| 스타일링 | 전역 CSS 또는 클래스 기반 스타일링 사용 | Shadow DOM 내부 스타일로 외부 스타일과 충돌 감소 |
| 브라우저 지원 | 현대 브라우저 대부분에서 지원 | 현대 브라우저 대부분에서 지원, 구형 브라우저는 폴리필 필요 가능 |
| 학습 곡선 | JavaScript 기본 지식 중심 | 웹 컴포넌트 표준 이해 필요 |
| 통합 용이성 | 어떤 프레임워크와도 비교적 쉽게 통합 | 프레임워크와 통합 시 이벤트/속성 처리 방식 확인 필요 |
| 성능 | DOM 조작을 직접 관리해야 함 | 브라우저 표준 API를 사용하지만 직접 최적화 책임도 있음 |
| 유지보수성 | 규모가 커지면 복잡해질 수 있음 | 컴포넌트 단위 분리로 유지보수에 유리 |
| SEO 친화성 | 서버 렌더링 방식에 따라 최적화 가능 | 클라이언트 렌더링 중심이면 추가 고려 필요 |
웹 컴포넌트도 인터랙티브한 기능을 구현할 때는 여전히 JavaScript로 이벤트와 상태 변화를 명령형으로 다루는 부분이 있다. 또한 Shadow DOM은 브라우저 환경의 기능이므로 서버 사이드 HTML 생성이나 SSR과 함께 사용할 때는 별도 고려가 필요하다.
4주차: 04장 상태와 반응성 돌아보기
핵심 내용 정리
학습 목표
- 클라이언트 상태와 서버 상태를 구분해 설명할 수 있다.
- 프론트엔드 개발에서 상태와 반응성이 어떤 역할을 하는지 이해한다.
- MVC, MVVM, Flux 같은 애플리케이션 아키텍처의 공통 구조를 살펴본다.
- 반응성을 구현하기 위한 관찰자 패턴을 이해한다.
- MVVM 패턴과 관찰자 패턴을 사용해 미니 프레임워크의 동작 원리를 체감한다.
상태와 반응성
React를 비롯한 모던 JavaScript 라이브러리와 프레임워크의 핵심에는 상태와 반응성이 있다.
- 상태는 UI를 표현하고 렌더링하는 데 사용되는 데이터다.
- 반응성은 상태가 변경될 때 UI가 자동으로 업데이트되는 원리다.
프레임워크마다 상태를 다루는 문법과 방식은 다르지만, 사용자 인터랙션으로 상태가 바뀌고 그 상태 변화가 UI 업데이트로 이어진다는 근본 원리는 같다.
사용자 입력
↓
상태 변경
↓
변경 감지
↓
UI 업데이트
상태와 반응성의 원리를 모르면 useState 같은 도구를 사용하는 수준에 머무를 수 있다. 반대로 이 원리를 이해하면 애플리케이션 규모가 커졌을 때 상태를 어디에 두고, 어떻게 공유하고, 어떤 흐름으로 변경할지 설계하는 힘을 기를 수 있다.
상태와 설계 컴포넌트
소프트웨어 공학에서 상태는 특정 시점에 시스템이 가지는 조건이나 상황을 의미한다. 애플리케이션도 발단, 전개, 위기, 절정, 결말처럼 다양한 상태의 연속으로 볼 수 있다.
웹 애플리케이션에서 상태는 크게 서버 상태와 클라이언트 상태로 나누어 볼 수 있다.
| 구분 | 설명 | 예시 |
|---|---|---|
| 서버 상태 | 서버나 DB에 원천이 있고 API를 통해 클라이언트에 전달되는 데이터 | 사용자 정보, 게시글 목록, 상품 목록, 검색 결과 |
| 클라이언트 상태 | 브라우저 안에서 사용자 인터랙션과 UI 표현을 위해 관리되는 데이터 | 입력값, 모달 열림 여부, 선택된 탭, 로딩 여부 |
예를 들어 사용자가 검색어를 입력하고 검색 버튼을 누르면 다음 흐름이 발생한다.
- 사용자가 검색어를 입력한다.
- 클라이언트는 로딩 상태를 표시한다.
- 서버에 검색 요청을 보낸다.
- 서버는 DB에서 검색 결과를 조회한다.
- 클라이언트는 응답받은 결과로 UI를 업데이트한다.
이 과정에서 서버 상태와 클라이언트 상태가 함께 사용된다. 서버에서 받은 검색 결과는 서버 상태에 가깝고, 검색창 입력값이나 로딩 스피너 표시 여부는 클라이언트 상태에 가깝다.
View: UI 인터랙션을 담당하는 영역
MVC, MVP, MVVM, Flux 같은 디자인 패턴은 애플리케이션 구성요소를 역할에 따라 분리한다. 구조는 다르지만 대부분 공통적으로 View, Model, 중재자 역할을 가진다.
View는 프레젠테이션 레이어라고도 부르며, 사용자가 실제로 상호작용하는 UI를 책임진다.
- UI를 화면에 렌더링한다.
- 사용자 입력을 받아낸다.
- 상태나 데이터를 시각적으로 표현한다.
프레임워크별로 View는 다음처럼 나타날 수 있다.
| 환경 | View의 예 |
|---|---|
| Vanilla JavaScript | 정적 HTML과 DOM API로 직접 업데이트하는 JavaScript 코드 |
| React | JSX로 작성한 선언적인 UI 컴포넌트 |
| Vue.js | <template> 구문으로 작성한 선언적인 UI 컴포넌트 |
Model: 데이터를 담당하는 영역
Model은 애플리케이션의 데이터와 그 데이터를 처리하는 비즈니스 로직을 포함한다. View가 “무엇을 보여줄지”를 담당한다면, Model은 “무엇을 어떤 규칙으로 다룰지”에 집중한다.
프론트엔드와 백엔드에서 Model의 모습은 다르게 나타난다.
| 영역 | Model의 예 |
|---|---|
| 프론트엔드 | 클라이언트 상태와 상태 관리 로직, Redux/Zustand/MobX 스토어 |
| 백엔드 | DB 스키마, ORM 객체, 도메인 규칙, 유효성 검증 |
Model이 수행하는 주요 역할은 크게 두 가지다.
- 데이터 관리: 사용자 정보, 상품 목록, 재고, 장바구니 같은 핵심 데이터를 보유하고 유지한다.
- 비즈니스 로직: 할인율 계산, 재고 소진 시 주문 차단, API 호출, 오류 처리 같은 애플리케이션 고유 규칙을 실행한다.
중재자: Model과 View를 연결하는 영역
View는 UI 렌더링과 입력 처리에 집중하고, Model은 데이터와 비즈니스 로직에 집중해야 한다. 이 둘이 서로를 직접 알게 되면 관심사가 섞이고 변경에 약한 구조가 된다.
그래서 아키텍처에는 보통 중재자가 등장한다.
| 패턴 | 중재자 역할 |
|---|---|
| MVC | Controller |
| MVP | Presenter |
| MVVM | ViewModel |
| Flux | Action과 Store를 통한 단방향 파이프라인 |
이름은 다르지만 핵심 역할은 비슷하다.
- View에서 발생한 사용자 입력을 전달받는다.
- 입력을 해석해 Model의 상태를 변경하거나 비즈니스 로직을 실행한다.
- Model의 데이터를 View가 표시하기 좋은 형태로 가공한다.
- 유효성 검사 같은 프레젠테이션 로직을 캡슐화한다.
전통적인 웹 개발에서는 Model, View, Controller를 별도 파일이나 클래스로 명확히 나누는 경우가 많았다. 반면 React 같은 모던 프레임워크에서는 경계가 더 유연하다. 컴포넌트 내부에서 useState나 useReducer로 상태를 정의하고, 이벤트 핸들러로 상태를 변경하고, JSX로 View를 반환한다.
이는 관심사가 완전히 사라졌다는 뜻이 아니라, 컴포넌트 단위로 응집도를 높여 재사용성을 높이는 방향으로 경계가 조정된 것으로 볼 수 있다.
비반응성 MVC 패턴
MVC 패턴은 Model, View, Controller를 분리해 코드의 테스트와 유지보수를 쉽게 만든다. 하지만 가장 단순한 MVC 구현에서는 상태가 변경될 때마다 Controller가 직접 View의 렌더링 함수를 호출해야 한다.

비반응성 MVC에서 Controller가 View 업데이트를 직접 호출하는 흐름
이 구조에서는 다음 흐름이 발생한다.
- View에서 이벤트가 발생한다.
- Controller가 이벤트 핸들러를 실행한다.
- Controller가 Model의 데이터를 변경한다.
- Controller가
renderTodos()같은 View 업데이트 함수를 직접 호출한다.
View 예시는 UI 생성과 렌더링에 집중한다.
export class TodoView {
constructor() {
this.app = document.getElementById('app');
this.todoList = document.createElement('ul');
this.input = document.createElement('input');
this.addButton = document.createElement('button');
this.addButton.textContent = 'Add';
this.app.appendChild(this.input);
this.app.appendChild(this.addButton);
this.app.appendChild(this.todoList);
}
renderTodos(todos) {
this.todoList.innerHTML = '';
todos.forEach((todo, index) => {
const listItem = document.createElement('li');
listItem.textContent = todo;
const deleteButton = document.createElement('button');
deleteButton.textContent = 'Delete';
deleteButton.dataset.index = index;
listItem.appendChild(deleteButton);
this.todoList.appendChild(listItem);
});
}
}
Model은 데이터 관리에 집중한다.
export class TodoModel {
constructor() {
this.todos = [];
}
addTodo(todo) {
this.todos.push(todo);
}
removeTodo(index) {
this.todos.splice(index, 1);
}
getTodos() {
return this.todos;
}
}
Controller는 View 이벤트와 Model 변경을 연결한다.
export class TodoController {
constructor(model, view) {
this.model = model;
this.view = view;
this.view.addButton.addEventListener('click', this.handleAddTodo.bind(this));
this.view.todoList.addEventListener('click', this.handleDeleteTodo.bind(this));
this.updateView();
}
handleAddTodo() {
const todoText = this.view.input.value.trim();
if (todoText) {
this.model.addTodo(todoText);
this.updateView();
this.view.input.value = '';
}
}
handleDeleteTodo(event) {
if (event.target.tagName === 'BUTTON') {
const index = parseInt(event.target.dataset.index, 10);
this.model.removeTodo(index);
this.updateView();
}
}
updateView() {
const todos = this.model.getTodos();
this.view.renderTodos(todos);
}
}
이 방식은 단순하지만, 개발자가 상태 변경 뒤 updateView() 호출을 빼먹으면 UI가 최신 상태를 반영하지 못한다. 기능이 늘어나고 상태 변경 지점이 많아질수록 수동 업데이트 코드는 버그의 원인이 되기 쉽다.
MVC 패턴에 반응성 주입하기
비반응성 MVC의 문제는 Controller가 Model을 변경할 때마다 View 렌더링을 직접 호출해야 한다는 점이다. 이를 개선하려면 Model이 자신의 상태 변경을 외부에 알릴 수 있어야 한다.

반응성 MVC에서 Model이 변경을 알리고 등록된 리스너가 View를 업데이트하는 흐름
반응형 Model은 리스너 목록을 가지고, 상태가 변경될 때 등록된 리스너에게 변경 사실을 알린다.
export class TodoModel {
constructor() {
this.todos = [];
this.listeners = [];
}
subscribe(listener) {
this.listeners.push(listener);
}
notify() {
this.listeners.forEach(listener => listener(this.todos));
}
addTodo(todo) {
this.todos.push(todo);
this.notify();
}
removeTodo(index) {
this.todos.splice(index, 1);
this.notify();
}
getTodos() {
return this.todos;
}
}
여기서 핵심 개념은 세 가지다.
| 개념 | 설명 |
|---|---|
| 리스너 목록 | 상태 변경을 통지받고 싶은 콜백 함수들을 저장하는 배열 |
| 구독 | 외부 컴포넌트가 특정 콜백을 리스너 목록에 등록하는 과정 |
| 알림 | 상태가 변경되었을 때 등록된 콜백을 실행해 변경 사실을 전파하는 과정 |
Controller는 더 이상 매번 View 업데이트를 직접 호출하지 않는다. 대신 Model을 구독하고, 변경 알림이 오면 View를 렌더링하는 콜백을 등록한다.
export class TodoController {
constructor(model, view) {
this.model = model;
this.view = view;
this.model.subscribe(todos => {
this.view.renderTodos(todos);
});
this.view.addButton.addEventListener('click', this.handleAddTodo.bind(this));
this.view.todoList.addEventListener('click', this.handleDeleteTodo.bind(this));
this.view.renderTodos(this.model.getTodos());
}
handleAddTodo() {
const todoText = this.view.input.value.trim();
if (todoText) {
this.model.addTodo(todoText);
this.view.input.value = '';
}
}
handleDeleteTodo(event) {
if (event.target.tagName === 'BUTTON') {
const index = parseInt(event.target.dataset.index, 10);
this.model.removeTodo(index);
}
}
}
이제 상태 변경 흐름은 다음처럼 바뀐다.
- Controller가 Model의 상태 변경을 요청한다.
- Model은 상태를 변경한 뒤
notify()를 호출한다. - 등록된 리스너가 실행된다.
- View가 최신 상태로 다시 렌더링된다.
이 구조는 관심사 분리를 유지하면서도 수동 렌더링 호출 누락 문제를 줄여준다.
관찰자 패턴
관찰자 패턴(Observer Pattern)은 발행자와 구독자로 구성된다. 발행-구독 패턴으로 설명하기도 한다.
| 역할 | 설명 | Todo 예시 |
|---|---|---|
| 발행자 | 관찰 대상이 되는 객체. 자신의 상태 변화를 외부에 알릴 책임을 가진다. | TodoModel |
| 구독자 | 발행자의 상태 변화에 관심이 있어 알림을 받고 싶은 객체나 콜백 | View를 렌더링하는 콜백 |
동작 방식은 다음과 같다.
- 구독자는 발행자의 이벤트나 상태 변경을 구독한다.
- 발행자의 상태가 변경된다.
- 발행자는 자신이 관리하는 구독자 목록에 변경 사실을 알린다.
- 구독자들은 알림을 받고 약속된 작업을 수행한다.
관찰자 패턴에서 자주 등장하는 인터페이스는 다음과 같다.
| 메서드 | 역할 |
|---|---|
subscribe | 구독을 신청한다. |
notify | 구독자에게 변경 사실과 최신 데이터를 알린다. |
unsubscribe | 구독을 취소한다. 메모리 누수를 막기 위해 필요하다. |
Redux도 큰 흐름에서 관찰자 방식으로 이해할 수 있다. Redux Store가 발행자 역할을 하고, React 컴포넌트는 Store를 구독하다가 상태 변경 알림을 받으면 리렌더링된다.
결국 관찰자 패턴은 프론트엔드에서 자주 마주치는 “상태와 View를 어떻게 동기화할 것인가”라는 문제를 해결하는 중요한 도구다.
MVVM 패턴과 반응성
MVVM은 Model, View, ViewModel로 구성된다. View와 Model의 분리를 유지하되, ViewModel이라는 중간 객체를 통해 둘을 연결한다.
MVC에서는 Controller가 view.render()처럼 명령하는 흐름이 강하다. 반면 MVVM에서는 View가 ViewModel의 변경에 반응해 스스로 업데이트되는 구조에 가깝다.

MVVM에서 View, ViewModel, Model이 상호작용하는 흐름
MVVM의 흐름은 두 방향으로 볼 수 있다.
사용자 입력 흐름
View → ViewModel → Model
- View에서 사용자 인터랙션이 발생한다.
- 데이터 바인딩을 통해 ViewModel의 특정 메서드가 호출된다.
- ViewModel은 전달받은 내용을 바탕으로 Model의 상태 변경을 요청한다.
상태 변경 흐름
Model → ViewModel → View
- Model의 상태가 변경된다.
- Model은 관찰자 패턴을 통해 ViewModel에 변경을 알린다.
- ViewModel은 최신 데이터를 View가 표시하기 좋은 형태로 가공한다.
- View는 자신이 바인딩한 ViewModel의 변경을 감지해 자동으로 UI를 업데이트한다.
이 구조에서는 View가 DOM을 직접 제어하는 코드를 많이 작성하지 않아도 된다. View는 “무엇과 바인딩될지”를 선언하고, 실제 연결과 업데이트는 바인더나 프레임워크가 담당한다.
단방향 바인딩과 양방향 바인딩
데이터 바인딩은 View와 ViewModel의 데이터를 자동으로 동기화하는 메커니즘이다.
| 구분 | 설명 | 대표 예 |
|---|---|---|
| 단방향 바인딩 | 데이터가 ViewModel에서 View로 한 방향으로 흐른다. View의 입력은 이벤트 핸들러로 명시적으로 상태를 변경해야 한다. | React |
| 양방향 바인딩 | View와 ViewModel 사이에서 데이터가 양방향으로 동기화된다. 입력값 변경이 ViewModel 상태에 자동 반영된다. | Vue의 v-model |
React에서 입력값을 다룰 때 value와 onChange를 함께 작성하는 이유는 단방향 바인딩을 기본 철학으로 삼기 때문이다.
const [value, setValue] = useState('');
return (
<input
value={value}
onChange={(event) => setValue(event.target.value)}
/>
);
단방향 바인딩은 상태 변경 지점이 명시적이라 추적하기 쉽다. 대신 매번 이벤트 핸들러를 작성해야 하므로 코드량이 늘 수 있다.
양방향 바인딩은 보일러플레이트를 줄여 개발 속도를 높일 수 있다. 하지만 데이터가 어디서 변경되었는지 추적하기 어려워질 수 있다.
MVVM Todo List의 구성
MVVM 예시에서는 HTML이 View 역할을 한다. data-* 속성은 바인딩 엔진을 위한 약속이자 선언이다.
<body>
<div id="app">
<h2>Todo List (MVVM Example)</h2>
<input
type="text"
data-bind="newTodo"
placeholder="Enter a new todo..."
/>
<button data-click="addTodo">Add Todo</button>
<ul data-list="todos"></ul>
</div>
<script type="module" src="./todo.js"></script>
</body>
각 속성은 다음 의미를 가진다.
| 속성 | 의미 |
|---|---|
data-bind="newTodo" | input의 value를 ViewModel의 newTodo 속성과 양방향으로 바인딩한다. |
data-click="addTodo" | 버튼 클릭 시 ViewModel의 addTodo() 메서드를 실행한다. |
data-list="todos" | todos 배열을 기반으로 목록을 렌더링한다. |
ViewModel은 View가 사용할 상태와 동작을 모아둔 중간 객체다.
export class ViewModel {
constructor() {
this.observers = [];
this.newTodo = '';
this.todos = [];
}
subscribe(callback) {
this.observers.push(callback);
}
notify(property, value) {
this.observers.forEach(cb => cb(property, value));
}
set(property, value) {
this[property] = value;
this.notify(property, value);
}
get(property) {
return this[property];
}
addTodo() {
const text = this.newTodo.trim();
if (text) {
this.todos.push(text);
this.notify('todos', this.todos);
this.set('newTodo', '');
}
}
removeTodo(index) {
this.todos.splice(index, 1);
this.notify('todos', this.todos);
}
}
여기서 ViewModel은 상태와 로직을 모두 캡슐화한다. subscribe, notify, set을 통해 반응성의 기반도 제공한다.
Binder의 역할
View와 ViewModel을 만들었더라도 둘을 연결하는 장치가 필요하다. 이 역할을 하는 것이 Binder다.
Binder는 data-* 속성을 읽고 View와 ViewModel을 연결한다.
- DOM을 스캔해
data-bind,data-click,data-list같은 속성을 가진 요소를 찾는다. - ViewModel을 구독해 상태 변경 알림을 받을 준비를 한다.
- ViewModel 상태가 바뀌면 연결된 DOM 요소를 업데이트한다.
- View에서 이벤트가 발생하면 연결된 ViewModel 메서드를 호출한다.
Binder의 핵심 코드는 다음처럼 볼 수 있다.
export function bindViewModel(viewModel, root) {
const boundInputs = root.querySelectorAll('[data-bind]');
boundInputs.forEach((inputEl) => {
const property = inputEl.getAttribute('data-bind');
inputEl.value = viewModel.get(property) || '';
inputEl.addEventListener('input', (event) => {
viewModel.set(property, event.target.value);
});
viewModel.subscribe((changedProp, newValue) => {
if (changedProp === property && inputEl.value !== newValue) {
inputEl.value = newValue;
}
});
});
const clickableEls = root.querySelectorAll('[data-click]');
clickableEls.forEach((clickEl) => {
const methodName = clickEl.getAttribute('data-click');
clickEl.addEventListener('click', () => {
if (typeof viewModel[methodName] === 'function') {
viewModel[methodName]();
}
});
});
const listEls = root.querySelectorAll('[data-list]');
listEls.forEach((listEl) => {
const property = listEl.getAttribute('data-list');
const renderList = (items) => {
listEl.innerHTML = '';
items.forEach((item, index) => {
const li = document.createElement('li');
li.textContent = item;
const removeBtn = document.createElement('button');
removeBtn.textContent = '삭제';
removeBtn.style.marginLeft = '8px';
removeBtn.addEventListener('click', () => {
viewModel.removeTodo(index);
});
li.appendChild(removeBtn);
listEl.appendChild(li);
});
};
renderList(viewModel.get(property) || []);
viewModel.subscribe((changedProp, newValue) => {
if (changedProp === property) {
renderList(newValue);
}
});
});
}
애플리케이션 진입점에서는 ViewModel을 만들고 Binder로 View와 연결한다.
import { ViewModel } from './ViewModel.js';
import { bindViewModel } from './binder.js';
const vm = new ViewModel();
const rootElement = document.getElementById('app');
bindViewModel(vm, rootElement);
vm.set('todos', ['양방향 바인딩 구현하기', '단방향 바인딩 공부하기']);
겉으로 보기에는 HTML에 data-* 속성을 선언하는 것만으로 반응형 UI가 동작하는 것처럼 보인다. 하지만 내부에는 DOM 스캔, 이벤트 연결, 구독 등록, 변경 알림 처리, 렌더링 같은 복잡한 보일러플레이트가 숨어 있다.
이 과정을 직접 구현해보면 React나 Vue 같은 프레임워크가 왜 생산성을 크게 높여주는지 체감할 수 있다.