*JavaScript에서 클래스 기반의 컴포넌트를 사용하고 있으며, 본 게시글 또한 클래스를 사용한 코드로 작성합니다.
클래스 기반의 컴포넌트를 사용하여 프로젝트를 진행하다 보니 A컴포넌트에서 생성된 state를 전역적으로 사용할 수 있도록 해주어 B컴포넌트도 해당 state를 사용할 수 있도록 해주어야 했습니다.
store 구축 전 아래의 기본 개념을 먼저 살펴보겠습니다.
Object.defineProperty
정적 메서드 defineProperty는 객체에 새로운 속성을 직접 정의하거나 이미 정의된 속성을 수정한 후 새롭게 정의된 객체를 반환합니다.
[특징]
- 속성에 접근 권한을 설정할 수 있습니다.
- 속성에 getter와 setter를 만들 수 있습니다.
[매개변수]
Object.defineProperty(obj, prop, descriptor)
- obj : 속성을 정의할 객체
- prop : 새로 정의하거나 수정하려는 속성의 이름
- descriptor : 새로 정의하거나 수정하려는 속성에 기술하는 객체
간단한 사용 예시를 살펴보겠습니다.
class MovieData {
constructor(state) {
this.state = {};
for (const key in state)
Object.defineProperty(this.state, key, {
// getter 설정
get: () => {
return state[key];
},
// setter 설정
set: (newValue) => {
state[key] = newValue;
},
});
}
}
const movie = new MovieData({ title: "", num: 1 });
movie.state.title = "겨울왕국";
console.log(movie.state.title);
클래스 MovieData는 생성자 함수의 인수로 전달받은 객체 데이터를 사용하여 클래스 내부의 state라는 객체에 인수로 전달받은 객체의 속성의 값을 저장하고 관리하는 클래스입니다.
사용자 A는 클래스 MovieData를 사용하여 영화의 title과 자신이 영화에 할당한 번호를 관리하고 싶어 title 속성과 num 속성이 있는 객체를 인수로 전달했습니다.
defineProperty 메서드는 첫 번째 매개 변수의 인수로 관리할 객체, 즉 클래스 MovieData에 있는 state(this.state)에서 영화 정보를 관리하고 싶으니까 this.state를 전달합니다.
두 번째 매개 변수의 인수로는 관리할 객체에서 수정하거나 추가하려는 속성, 즉 사용자 A는 title과 num을 추가해서 관리하고 싶어 하니 title과 num을 전달합니다.
이때 사용자에 따라 관리하고 싶은 속성이 다를 수 있습니다. 예를 들어 사용자 A는 영화 정보를 title과 num으로 저장하고 관리하고 싶고, 사용자 B는 title만 관리하고 싶은 경우입니다. 관리하고 싶은 속성이 title 하나이면 defineProperty메서드의 두 번째 인수로 title을 전달하여 title만 관리하면 되겠지만 관리할 속성이 하나를 넘을 수 도 있으니 for in 반복문을 사용하여 전달받은 객체의 모든 속성을 defineProperty의 두 번째 인수로 전달해 주었습니다.
세 번째 매개 변수의 인수로는 새로 정의하거나 수정하려는 속성에 대한 기술, 즉 객체의 특정 속성에 값을 추가해줄 수 도 있고 함수등을 추가할 수 있습니다. 이때 세 번째 인수에는 getter와 setter를 지정할 수 있습니다.
정리해보면 클래스 MovieData 생성자 함수에 인수로 title과 num 속성이 있는 객체를 전달했습니다. 클래스 내부에는 state라는 객체가 있고 인수로 전달된 객체는 state에서 defineProperty 메서드를 사용하여 관리하게 됩니다. 이때 인수로 전달받은 객체 중 특정 속성 하나만 관리할 것이 아니고 모든 속성을 관리할 것이기 때문에 for in 반복문을 사용하여 defineProperty 메서드를 사용해 줍니다. 결과는 클래스 내부에 있는 state 객체에 title속성과 num 속성이 추가되었고, title 속성에 getter와 setter가 추가되었으며, num 속성에 getter와 setter가 추가되었습니다.
getter / setter
const movie = new MovieData({ title: "", num: 1 }); // 1번
movie.state.title = "겨울왕국"; // setter 작동 2번
console.log(movie.state.title); // getter 작동 3번
1번 코드를 통해 정상적으로 클래스 MovieData 인스턴스에 있는 state에 title 속성과 num 속성을 추가해 주었고, 각각의 속성에는 getter와 setter를 기술해 주었습니다.
2번 코드를 통해 클래스 MovieData 인스턴스에 있는 state의 title 속성의 값에 겨울왕국을 할당해 주었습니다. 값 할당 즉, setter가 작동되게 됩니다. 앞서 각각의 속성 모두에 getter와 setter를 추가해주었기 때문에 해당 속성이 변경되면 setter가 작동되기 때문입니다.
3번 코드를 통해 클래스 MovieData 인스턴스에 있는 state의 title 속성의 값을 가져오고 있습니다. 값을 가져오니 해당 속성에 추가된 getter가 작동되어 해당 속성의 값을 반환하게 됩니다.
이렇게 정적 메서드 defineProperty와 세 번째 인수로 전달하는 속성 기술을 사용해 store을 구축해보도록 하겠습니다.
store 클래스
export class Store {
constructor(state) {
this.state = {};
this.observers = {};
for (const key in state) {
// Object.defineProperty는 객체 데이터에 속성을 정의할 때 사용하는 메섣,
Object.defineProperty(this.state, key, {
// getter
get: () => {
return state[key];
},
//setter
set: (val) => {
state[key] = val;
if (Array.isArray(this.observers[key])) {
// 호출할 콜백이 있는 경우!
this.observers[key].forEach((observer) => observer(val));
}
},
});
}
}
subscribe(key, cb) {
Array.isArray(this.observers[key])
? this.observers[key].push(cb)
: (this.observers[key] = [cb]);
}
}
위의 빨간 박스 안의 코드를 제외한 코드는 앞서 작성한 defineProperty 메서드 사용 예시 코드와 동일하니 빨간 박스 안의 코드만 아래에서 정리하도록 하겠습니다.
const store = new Store({
searchText: "",
page: 1,
movies: [],
});
현재 진행 중인 프로젝트는 영화 정보 애플리케이션 구축이며, REST API에서 페칭 된 데이터를 store에 저장하여 관리하려 합니다. 때문에 클래스 Store 생성자 함수에 searchText, page, movies 속성으로 구성된 객체를 전달합니다. 클래스 Store 인스턴스에서 영화 정보를 저장하고 관리가 가능해지게 되었습니다.
export const searchMoives = async (page) => {
// 사용자 요청 페이지 1일 떄 초기화 1번 블록
if (page === 1) {
store.state.page = 1;
store.state.movies = [];
}
const res = await fetch( // 2번 블록
`https://omdbapi.com?apikey=7035c60c&s=${store.state.searchText}&page=${page}`
);
const { Search } = await res.json(); // 3번
// more 버튼 클릭 시 이전 검색 결과 유지하면서 새로운 검색 결과 보여주기
store.state.movies = [...store.state.movies, ...Search]; // 4번
};
store 생성자 함수 아래에서 인수로 page 번호를 전달받는 함수 searchMoives를 추가로 작성해주었으며 간략하게 정리해 보겠습니다.
2번 블록 : REST API에서 데이터를 페칭 하여 응답 결과를 상수 res에 할당하였습니다. 해당 API는 쿼리스트링으로 검색어와 페이지를 전달해주어야 하기 때문에 위와 같이 코드를 작성해 주었습니다.
3번 : 응답 결과 데이터를 구분 분석합니다. 이때 구문 분석된 데이터는 객체이며 객체 구조 분해합니다.
4번 : 구조 분해한 속성의 값(검색 결과)을 store 내부에 있는 state 객체의 movies 속성에 할당해주었으므로 movies 속성에 기술한 setter가 작동하여 movies 속성의 값이 Search의 값으로 변경되게 됩니다.
export default class Search extends Component {
render() {
this.el.classList.add("search");
this.el.innerHTML = /*html*/ `
<input placeholder = "Enter the movie title to search!" />
<button class = "btn btn-primary">Search!</button>
`;
const inputEl = this.el.querySelector("input");
inputEl.addEventListener("input", () => {
movieStore.state.searchText = inputEl.value; // 1번 store state의 searchText setter
});
inputEl.addEventListener("keydown", (event) => {
if (event.key === "Enter" && movieStore.state.searchText.trim()) {
searchMoives(1); // 2번
}
});
const btnEl = this.el.querySelector("button");
btnEl.addEventListener("click", () => {
if (movieStore.state.searchText.trim()) {
searchMoives(1); // 2번
}
});
}
}
사용자가 애플리케이션의 검색창에 검색어를 입력하면 해당 검색어를 사용하여 REST API에 요청을 보내려면 입력한 검색어를 저장해주어야 하기 때문에 store에 저장될 수 있도록 합니다. 때문에 1번 코드에서 movieStore(store)에 있는 state 객체의 searchText 속성에 사용자 입력값을 할당해 주었습니다. 해당 속성에 값을 할당해주었기 때문에 searchText 속성에 기술된 setter가 작동하게 되고 값이 할당됩니다.
2번, 즉 사용자가 검색어를 입력하고 엔터나 검색 버튼을 클릭하게 되면 앞서 생성한 함수 searchMoives를 호출하여 인수로 페이지 번호 1을 전달하게 됩니다. 함수 searchMoives가 실행되었으니 내부의 API HTTP 요청 코드가 실행되겠고 응답의 결과가 state의 movies에 감기게 됩니다.
이렇게 검색어를 store에 저장해 주었으며, 별도로 분리된 컴포넌트에서 store에 저장된 상태(데이터)를 이용해 HTTP 요청을 보내어 응답 결과를 다시 store에 저장해 주었습니다. 이렇게 store에 저장된 응답 결과를 다시 별도로 분리된 컴포넌트에서 사용해보도록 하겠습니다.
export default class MovieList extends Component {
constructor() { // 1번
super();
movieStore.subscribe("movies", () => {
this.render();
});
}
render() { // 2번
this.el.classList.add("moive-list");
this.el.innerHTML = /* html */ `
<div class="movies"></div>
`;
const moviesEl = this.el.querySelector(".movies");
moviesEl.append( // 3번
movieStore.state.movies.map((movie) => {
return movie.Title;
})
);
}
}
2번에서 생성한 클래스가 movies인 요소를 생성해 주었으며, 3번에서 생성한 요소에서 store에 저장된 state의 movies 속성의 값을 사용하여 렌더링을 해주었습니다.
이때 주의할 점은 아래와 같습니다.
3번에서 생성한 요소에 store에 저장된 state의 movies 속성의 값을 렌더링 해주고 있고 store에 저장된 state의 movies 속성의 값은 HTTP 요청을 보내는 함수 searchMoives의 실행으로 바뀌고 있습니다.
HTTP 요청은 비동기이기 때문에 응답으로 들어오는 데이터가 movies 속성의 값에 할당되기 전에 3번 코드가 실행되게 됩니다.
때문에 클래스 Store 생성자 함수의 인수로 전달한 기본값, 즉 빈 배열이 렌더링 되게 됩니다.
때문에 클래스 Store에서 관리하는 state의 값이 변경되는지 확인(구독)해줄 필요가 있으며, 구독(subscribe)을 통해 구독하고 있는 속성의 값이 변경되면 특정 코드가 실행되도록 해줄 필요가 있습니다.
이러한 이유로 클래스 Store에 subscribe 메서드를 추가해 주었으며, 해당 메서드는 인수로 구독할 속성의 이름(key)과 콜백함수를 전달받고 전달받은 콜백함수는 key : () =>{}의 형태로 observers 객체에 저장되게 됩니다.
이렇게 추가한 subscribe 메서드를 각각의 속성에 setter를 기술하는 부분에 추가하여 구독하는 속성의 값이 변하면 observers 객체에 저장된 콜백을 실행시키게 됩니다.
정리해보면 현재 비동기 코드로 인해 HTTP 요청의 응답 데이터가 movies에 저장되기 전에 렌더링이 되기 때문에 정상적으로 렌더링이 이루어지지 않는 상황입니다. 때문에 HTTP 요청의 응답 데이터가 movies에 저장되었을 때 render 메서드(요소를 렌더링 해주는 메서드)를 한 번 더 실행해주어야 합니다.
앞서 추가한 subscribe 메서드의 인수로 movies와 콜백함수(내부에 render 메서드 실행)를 전달해주게 되면 state의 movies 속성의 값이 변경되었을 때(HTTP 요청의 응답 데이터 저장되었을 때) 두 번째 인수로 전달받은 콜백함수가 실행되어 한 번 더 요소의 렌더링이 이루어지게 됩니다.
마무리
클래스를 이용한 컴포넌트 구축이 함수형 컴포넌트를 사용하는 입장에서 다소 난해하고 state를 관리하는 store를 별도의 API 없기 구축하는 것이 어렵긴 했지만 기본부터 차근차근 단계를 밟아가니 store 구축을 할 수 있게 되었습니다.