Welcome To

Schnee’s Blog!

Flux 아키텍처 구현하기

코드스쿼드 과정이 어느덧 3개월이 지난 현재, 저는 여러 프론트엔드에서의 디자인 패턴들 중 Flux 아키텍처에 대해서 관심을 갖게 되었습니다.

하지만 이론적으로만 학습 하였을 때, Flux의 사용 의도와 장단점들이 와닿지 않아 바닐라 Javascript 기반의 뉴스 데이터통신 제어 미션을 구현하는 과정에서 이를 적용해보았습니다.

Flux 아키텍처

Flux 아키텍처는 Facebook (현 Meta) 에서 소개 되었으며, MVC 패턴이 가지고 있는 특성인 양방향 데이터 바인딩의 한계점을 보완한 아키텍처입니다.

Facebook은 Flux 아키텍처를 사용하기 이전에 MVC 패턴을 통해 코드베이스를 구성하고 있었는데, 프로젝트에 새로운 기능을 추가하려고 할 때마다 복잡도가 기하급수적으로 증가하고 코드가 예측 불가능하게 되었다고 합니다. Hacker Way: Rethinking Web App Development at Facebook

MVC 에서는 왜 코드가 예측 불가능하게 될까요?

MVC

위 그림을 보시면, ModelView 를 접근하고, View 를 통해서도 Model 이 바뀔 수 있는 상태입니다.

아키텍처가 이러한 양방향 데이터 흐름을 가질 때의 단점을 다음 코드를 보면서 확인해보겠습니다.

class Model { constructor(view) { this.view = view; } update() { this.state = "updated by Model"; this.view.updateFromModel(); } updateFromView() { this.state = "updated by View"; } } class View { constructor(model) { this.model = model; } update() { this.state = "updated by View"; this.model.updateFromView(); } updateFromModel() { this.state = "updated by Model"; } }

위 코드에서 model.update()view.updateFromModel() 을 호출하고, view.update()model.updateFromView() 를 호출하게 되는 무한 루프 버그가 생기면 어떻게 될까요?

버그가 발생한 시점이 언제부터인지 알 수가 없게 되고, 디버깅을 하기가 어려워지는 사태가 발생합니다.

실제로 저도 이벤트 루프 시각화 미션을 구현하면서, 비동기적인 상태 변경이나 한번에 다수의 상태를 변경하였을 때 코드를 추적하기 어려워지는 것을 느꼈습니다.

Facebook은 위와 같은 문제를 보완하기 위해 Flux 라는 아키텍처를 새롭게 개발하였고, 양방향이었던 데이터 흐름을 단방향으로 바꾸었습니다.

Flux

Flux 를 구성하는 요소들은 다음과 같습니다.

Flux 는 단방향으로 데이터를 제어함으로서 상태가 어떻게 변화되는지 예측하기 쉬워지고 이로 인해 디버깅도 쉬워지는 장점이 있습니다.

그리고 MVC 를 사용하는 대규모 애플리케이션에서 Controller 가 엄청나게 커지는 것과는 달리 Flux 에서는 Store 가 모듈화 되어있고, MVC 에서 데이터를 관리하던 Controller 의 관심사가 Action, Dispatcher 로 더 세분화 되어있어 기능 확장성을 더 높여줍니다.

동전은 반드시 앞면과 뒷면을 갖고 있듯이, Flux 도 위와 같은 장점이 있지만, 단점도 가지고 있습니다.

Flux 아키텍처는 소규모 프로젝트에서 코드가 보일러 플레이트 코드로 인해 불필요하게 많아질 수 있습니다.

Flux 에서 View 는 상태 변경이 발생할 때 마다 업데이트 되기 때문에 불필요한 리렌더링을 야기 할 수 있고, 이는 성능 최적화에 대한 추가적인 노력이 필요할 수 있습니다.

Flux 아키텍처를 적용한 사례

저는 뉴스 데이터통신 제어 미션에서 뉴스 데이터를 서버에 저장하고 클라이언트에서 요청하여 렌더링하는 기능을 구현하였고, 이 과정 중에 Flux 아키텍처를 적용하였습니다.

먼저, Actiontypepayload 를 포함하도록 하고, 이벤트가 발생하였을 때, ActionDispatcher 에게 전달합니다.

// Actions.ts interface Action { type: string; payload?: { className?: string; titles?: string[]; title?: string; content?: string; }; } const actionTypes = { UPDATE_START: "UPDATE_START", FETCH_NEWS_TITLES_SUCCESS: "FETCH_NEWS_TITLES_SUCCESS", FETCH_NEWS_TITLES_FAILURE: "FETCH_NEWS_TITLES_FAILURE", FETCH_NEWS_CONTENT_START: "FETCH_NEWS_CONTENT_START", FETCH_NEWS_CONTENT_SUCCESS: "FETCH_NEWS_CONTENT_SUCCESS", FETCH_NEWS_CONTENT_FAILURE: "FETCH_NEWS_CONTENT_FAILURE", }; ... async function fetchNewsContent(title: string) { startUpdate(); try { const news = await loadNewsContent(title); dispatcher.dispatch({ type: actionTypes.FETCH_NEWS_CONTENT_SUCCESS, payload: { className: CLASS_NAME.NEWS_CONTENT, title: news.title, content: news.content, } }); } catch (error) { console.error(`뉴스 컨텐츠 요청 중 에러가 발생 하였습니다. Status Code ${error.message}`); notifyFail(); } } // Updating.ts (View) const initializeListeners: () => void = function () { const tag = document.querySelector("main"); tag?.addEventListener("click", handleClick); }; const handleClick: (event: Event) => void = function (event) { const target = event.target as HTMLElement; if (target && target.tagName === "BUTTON") fetchRandomTitles(); if (target && target.tagName === "SPAN" && target.textContent) fetchNewsContent(target.textContent); };

DispatcherStore 의 콜백 함수들이 Dispatcher 를 구독하게 하고, Action 이 전달되면 구독한 콜백 함수들을 실행시킵니다.

// Dispatcher.ts export const dispatcher = { callbacks: [] as ((action: Action) => void)[], register(callback: (action: Action) => void) { this.callbacks.push(callback); return this.callbacks.length - 1; }, dispatch(action: Action) { this.callbacks.forEach(callback => callback(action)); } };

Store 는 상태를 저장하고, Dispatcher 를 구독하게 한 다음, Action 에 따라 상태를 변경하고, View 에게 변경된 상태를 알려줍니다.

// Store.ts export const newsTitlesStore = (function () { let titles: string[] = []; let isLoading: boolean = false; const observers = new Set<Function>(); const notify = (data: Object) => { observers.forEach((observer) => observer(data)); }; dispatcher.register(({ type, payload }: Action) => { switch (type) { case actionTypes.UPDATE_START: isLoading = true; notify({ className: CLASS_NAME.LOADING, isLoading }); break; case actionTypes.FETCH_NEWS_TITLES_SUCCESS: if (payload?.titles) { titles = payload.titles; isLoading = false; notify({ className: CLASS_NAME.LOADING, isLoading }); notify({ className: CLASS_NAME.NEWS_TITLES, titles }); } break; case actionTypes.FETCH_NEWS_TITLES_FAILURE: isLoading = false; break; } }); return { subscribe(observer: Function) { observers.add(observer); }, getTitles() { return titles; }, isLoading() { return isLoading; }, }; })();

아래에서 미션을 구현한 결과를 프리뷰로 보실 수 있습니다.

Data Fetching Preview

구현 중 느낀 점

사실 Flux 를 처음 구현한 입장으로서 느낀 점은 러닝 커브가 낮은 편은 아니다 라는 점입니다.

처음에는 Action 을 어떻게 Dispatcher 한테 전달 하는지 이해가 잘 되지 않았고, Store 를 구현할 때도 어떻게 View 를 변경해야 하는지 감이 잘 잡히지 않았습니다.

학습을 하고 여러 구현 예시들을 찾아보며 Action 이 어떻게 전달되는지 알게 되었고, Store 의 변경사항을 View 에게 알려줄 때 옵저버 패턴을 사용하는 아이디어가 생각나 이를 활용하였습니다.

이 후 미션 구현 후반부 즈음에는 디버깅을 할 때 Actiontypepayload 가 확실하여 동작의 흐름을 추적하기가 매우 용이하다는 것을 느꼈습니다.

추가적으로, 옵저버 패턴의 단점도 새롭게 느끼게 되었습니다.

const main: () => void = function () { const root: HTMLElement | null = document.querySelector("main"); if (root) root.innerHTML = renderIndex(); newsTitlesStore.subscribe(updateNewstitles); newsTitlesStore.subscribe(updateLoading); newsContentStore.subscribe(updateNewsContent); newsContentStore.subscribe(updateLoading); fetchRandomTitles(); initializeListeners(); }; main();

위 코드를 보시면, View 를 업데이트 하는 updateLoading 함수가 두 개의 Store 를 구독하시는 걸 볼 수 있습니다.

이런 과정에서 StoreUPDATE_START 라는 Action 이 전달 될 때, 두 Store 모두 이 Action 을 처리를 하는데, 이 상황에서 newsTitlesStore 에서 UPDATE_START 가 처리되어 로딩 View 를 변경하고, 의도치 않게 newsContentStore 에서 또다시 로딩 View 를 변경하는 것을 시도하여 버그가 발생하였습니다.

이 후, View 에서 매개변수에 타입 체크를 하는 로직을 추가하여 이를 해결하였습니다.

export const updateLoading: (props: LoadingProps) => void = function (props) { ... if (props.isLoading === undefined) return; loadingImg.style.visibility = "hidden"; document.querySelectorAll(".blur").forEach((element) => element.classList.remove("blur", "unClickable")); };

위 경험을 통하여 저는 여러 옵저버가 동일한 상태 변경을 구독하고 처리하는 경우, 중복된 업데이트가 발생할 수 있다는 것을 느꼈습니다.

마치며

이번 미션에서 Flux 를 학습하고 직접 구현하며 저번 MVC, 옵저버 패턴에 이어 Flux 에 대해서 더 많이 알고 옵저버의 새로운 단면도 학습하는 경험이 되었습니다.

Flux 아키텍처는 미래에 배울 React 의 아키텍처이기도 하기 때문에 React 를 배우기 전에 학습 할 수 있어서 좋았고, 동시에 React 로 구현한 프로젝트들에 이 아키텍처가 어떻게 적용될까? 라는 궁금점도 생기기도 하여 React 어플리케이션을 구현하며 이를 학습 해보고 싶다는 생각도 듭니다.

참고 자료