바닐라 자바스크립트로 상태관리 해보기 - 데이터에 따라 UI를 변화시켜보자

김성현·2025년 5월 15일
1
post-thumbnail

HTML, CSS, 순수 자바스크립트만 이용해서 동적인 그래프를 구현해보았다.

사용성 좋은 UI/UX를 만들기 위해 열심히 이것저것 작업했고,, (이 화면 말고도 더 있음)
좋은 아키텍쳐로 구현하기 위해 일주일 동안 밤도 새가며 고민했다.

좋은 경험이었다

리액트 없이 JS만으로 뭔가를 만들어본 적은 처음이었다.
리액트의 지원 없이 개발해보니, 더 많이 고민하며 개발하게 됐다.
그리고 useState가 그리웠다,,

고민했던 것

가장 중요하게 생각한 것은 데이터렌더링 간의 관계다.
리액트가 그렇듯, 데이터가 변하면 관련된 UI 컴포넌트는 저절로 싹 다 변하게 하고 싶었다.

결과물이야 솔직히 어떤 코드로도 만들 수 있지만, 이런 구조가 가장 바람직하다고 생각했다.

고민 1 : 데이터 자체에 render() 메서드를 넣어볼까?


class Data {
	constructor(id, value) {
        this.id = id;
        this.value = value;      
    }
    
  	// 해당 UI 렌더링  
  	render() {
    }
}

이런 생각도 해봤다. 데이터가 생기면 바로 UI를 보여주기 위해서...

하지만, 이렇게 되면 new Data()로 데이터를 추가할 때는 보여줄 수 있지만, 데이터의 수정 삭제가 어렵다. 또한 데이터와 렌더링의 역할 분리가 되지 않고 있다. Data 클래스는 엄청나게 커진다.

이상한 걸 이미 알고 있어서 금방 접고, DataManager를 구현했다.

// data-manager.js

class DataManager {
    _dataStore = new Map();

    // 데이터 추가
    addData(data) {
        if (!data) return;
        this._dataStore.set(data.id, data);
    }
    
    getDataById(id) {} 
    
	updateDataById(id, newValue) {} 
	
	deleteData(id) {}	
	
	hasData() {}
	
	isExistId() {}
	
	...
}

고민 2 : 전체 화면을 랜더링하는 것은 비효율적이다.

데이터 추가, 수정, 삭제 시 전체 화면을 다시 그릴 필요는 없다고 생각했다.
따라서 다음과 같이 RenderType을 설정했다.

// data-manager.js

const RenderType = {
	ADD: 0,
    UPDATE : 1,
    DELETE : 2,
    CLEAR : 3,
}

게임 개발을 할 때 Enum 값을 이용해 상태(State) 패턴을 많이 사용했는데,
자바스크립트에는 Enum이 없으니, 비슷한 불변 효과를 주기 위해 Object.freeze()Symbol()을 활용했다.

const RenderType = Object.freeze({
    ADD: Symbol('ADD'), // 데이터 추가
    UPDATE: Symbol('UPDATE'), // 데이터 수정
    DELETE: Symbol('DELETE'), // 데이터 삭제
    CLEAR: Symbol('CLEAR'), // 데이터 초기화
});

그리고 아래와 같이 render() 함수에 type을 인자로 받아 type에 따라 필요한 부분만 랜더링되도록 구현했다.

// main.js

const render = (type, data = null) => {
    switch (type) {
        case RenderType.ADD:
            renderGraph(data);
            renderCard(data);
            break;

        case RenderType.UPDATE:
            break;
    
        case RenderType.DELETE:
            graph.remove();
            card.remove();
            break;

        case RenderType.CLEAR:
            break;
    }

    updateJSONTextarea(); // 공통로직
};

고민 3 : 하지만 여전히 이벤트 중심의 코드다.


RenderType을 통한 분기만으로는,
여전히 사용자가 클릭을 했을 때 데이터를 추가하고, render() 함수도 번거롭게 같이 호출해야 하는 코드였다.

// main.js

const onClickAddButton = (e, data) => {
 	e.preventDefault();
  	
  	dataManager.addData(data); // 데이터를 추가하고,
  	render(RenderType.ADD, data); // 추가된 데이터의 그래프 및 카드 렌더링
}

어떻게 하면 따로 render() 함수를 호출하지 않아도 자동으로 UI가 그려질까 고민했다.

이 때, 전 회사에서 사수 분이 알려주셨던 디자인 패턴 중 하나인 Observer 패턴이 생각났다.

Observer 패턴
새로운 유튜브 영상이 업로드 되었을 때 모든 구독자들에게 알림이 가듯이, 데이터에 어떤 변화가 생겼을 때. 자신의 구독자에게 변화를 알리고 콜백 함수를 실행하게 하면 어떨까? 라는 생각이 들었다.

여기서 유튜브 채널은 DataManager이고, 구독자는 render() 함수가 된다.
이 개념을 바탕으로 DataManager를 리팩토링했다.

class DataManager {
    _dataStore = new Map();
    _subscriber = new Set(); // 여기에 구독
	
	// 구독자에게 알림을 보냄
    _notify(type, data = null) {      
        for (const callback of this.subscribers) {
            if (!callback) return;
            // 콜백 실행
            callback(type, data);
        }
    }

	// 유튜브 영상 올라옴
    addData(data) {
        if (!data) return;
        this._dataStore.set(data.id, data);
        // 구독자에게 알림 호출
        this._notify(DataChange.ADD, data);
    }
}

그리고 이제 더 이상 RenderType이 아니라 데이터의 상태 변화를 나타내는DataChange 타입으로 이름을 바꿔주었다.

const DataChange = Object.freeze({
    ADD: Symbol('ADD'), // 데이터 추가
    UPDATE: Symbol('UPDATE'), // 데이터 수정
    DELETE: Symbol('DELETE'), // 데이터 삭제
    CLEAR: Symbol('CLEAR'), // 데이터 초기화
});

드디어 내가 원하는 방식 되었다.
이제 따로 render() 함수를 부르지 않아도 데이터가 변하면 저절로 UI가 그려진다.

onClickAddButton = (e, data) => {
 	e.preventDefault();
  	dataManager.addData(data); // 한 줄만 있으면 됨, render()는 따로 호출하지 않음
}

마무리


내가 구상하던 구조를 실제로 만들어보니 뿌듯했고, 드디어 코드에 대한 미련이 없어졌다.
그리고 앞으로 더 다양한 디자인 패턴을 익히고 코드에 적용시켜보고 싶은 욕심이 생겼다.

꾸준히 성장해보자 !!

0개의 댓글