MVC 글은 Flux라는 이름을 꺼낸 데서 끝났다. 큰 MVC 앱에서는 값 하나를 바꾸면 그 값에 기대는 다른 값이 줄줄이 바뀌고, 결국 「누가 이걸 바꿨지?」를 찾을 수 없게 됐다. Facebook은 그 답으로 Flux를 내놓았다. 그 글에는 「Flux가 실제로 어떤 모양인지는 다루지 않았다」고 적었다.
이 글은 그 빈칸을 채운다. 이 글은 내가 AI 대화 상대인 Claude(Anthropic이 만든 AI)와 대화하며 공부한 것을 옮긴 것이다. 처음 설명은 한 번에 너무 많아서 이해하지 못했고, 은행 비유로 다시 들으면서 잡혔다. 그 경로를 그대로 남긴다.
이 글에 옮긴 Flux 문서 인용은 전부 Flux 공식 저장소 facebookarchive/flux의 docs/In-Depth-Overview.md(커밋 4ee8c50 판)에서 직접 확인했다.
먼저: 우리 앱의 좋아요 버튼이 이 글의 예제다
글 뒤쪽에서는 Flux를 우리 앱의 실제 코드에 대 본다. 그 앱을 먼저 소개한다.
우리 client 앱은 방문자가 보는 회사 공개 사이트로, Next.js로 만든 React 앱이다. 후기 상세 화면에는 하트 모양 좋아요 버튼과 좋아요 수가 있다. 이 버튼을 그리는 코드가 components/ReviewActions.js 파일이고, React에서는 이런 화면 조각 하나를 컴포넌트라고 부른다.
하트를 누르면 이 순서로 일이 일어난다.
- 화면의 자바스크립트가 서버(좋아요 수를 세고 저장하는 우리 자바 백엔드)에 「좋아요 1 올려 줘」라고 요청한다. 이미 하트를 누른 상태에서 다시 누르면 반대로 「좋아요 취소해 줘」라고 요청한다
- 서버가 다른 사람들이 누른 것까지 센 지금 좋아요 수를 돌려준다
- 화면이 그 수를 받아 적어 두고, 하트를 채우고 숫자를 바꿔 그린다
MVC 글에서 이 파일을 열어 봤을 때, 좋아요 수를 적어 두는 작은 보관함(counts)과 「값이 바뀌면 알려 주세요」 명단(listeners)이 파일 맨 위에 있었다. 이 글에서도 그 두 가지를 다시 본다.
컴포넌트가 「화면에 나타난다」는 건 React가 그 컴포넌트를 처음 그려 브라우저 화면에 붙이는 순간을 말한다. 「화면에서 사라진다」는 건 다른 페이지로 옮겨 가는 등의 이유로 더 이상 그리지 않고 떼어 내는 순간이다. 자바로 치면 객체가 쓰이기 시작하는 때와 더는 쓰이지 않게 되는 때에 가깝다.
먼저: 이 글의 자바스크립트 코드는 여섯 가지만 알면 읽힌다
이 글의 예제 중 일부는 자바스크립트다. 자바와 이름만 비슷한 다른 언어라서, 자바 독자가 처음 보면 걸리는 문법이 여섯 가지 있다. 미리 풀어 둔다.
{ type: '대화_읽음', threadId: 7 }: 클래스 없이 중괄호만으로 객체를 바로 만든다.이름: 값짝을 쉼표로 늘어놓고,obj.type처럼 꺼낸다. 자바의Map<String, Object>에 값을 넣어 둔 것과 비슷하다{ reviewId, count: updated }: 이름만 있고 콜론이 없는reviewId는reviewId: reviewId를 줄여 쓴 것이다. 같은 이름의 변수 값을 그대로 넣는다const x = ...: 변수를 만든다. 다시 대입할 수 없다는 점이 자바의final변수와 같다. 타입은 적지 않는다function subscribe(onChange) { ... }: 함수를 만든다. 자바 메서드와 같은데, 매개변수와 반환값의 타입을 적지 않는다(x) => ...,x => ...,() => ...: 함수를 짧게 쓴 화살표 함수다. 자바의 람다x -> ...와 같다. 자바스크립트에서는 함수도 값이라서, 변수에 담고, 다른 함수에 넘기고(그렇게 넘긴 함수가 콜백이다), 반환값으로 돌려줄 수 있다. 변수에 담긴 함수는onChange()처럼 괄호를 붙여 부른다a === b: 「같은가」를 비교한다. 이 글에서는action.type === '좋아요_반영됨'처럼 글자 둘이 같은지 볼 때만 쓰고, 그때는 자바의"좋아요_반영됨".equals(action.type)과 같다고 보면 된다
먼저: MVC에서는 값을 바꾸는 문이 사방에 열려 있었다
MVC 글에서 본 Flux 문서의 장면을 다시 꺼낸다. 메시지 앱에서 한쪽 화면은 대화 목록을 보여주며 안 읽은 대화를 강조하고, 화면 위쪽에는 안 읽은 대화 수를 보여준다. 대화 하나를 읽음으로 표시하면 대화 Model이 바뀌고, 안 읽은 수 Model도 따라 바꿔야 한다.
문제는 값을 바꾸는 문이 사방에 열려 있었다는 것이다. 자바로 쓰면 이런 모양이다.
// 화면 A가 부른다
threadModel.setAsRead(7);
// 화면 B도, 다른 코드도 부른다
unreadCountModel.decrease();
setAsRead()나 decrease() 같은 메서드는 아무 화면이나 아무 때나 부를 수 있다. 부르는 곳이 늘수록 「이 값은 언제, 누가, 왜 바꿨나」를 알 수 없게 된다. Flux 문서는 이렇게 적었다.
We found that two-way data bindings led to cascading updates, where changing one object led to another object changing, which could also trigger more updates. As applications grew, these cascading updates made it very difficult to predict what would change as the result of one user interaction.
양방향 데이터 연결은 연쇄 갱신을 낳았다. 객체 하나를 바꾸면 다른 객체가 바뀌고, 그게 또 다른 갱신을 일으켰다. 앱이 커지자 이 연쇄 때문에 사용자의 행동 하나가 무엇을 바꿀지 예측하기가 아주 어려워졌다.
여기서 「양방향 데이터 연결」은 화면과 Model을 서로 직접 고칠 수 있게 이어 둔 것을 말한다. 위 자바 코드처럼 화면이 Model의 setter를 불러 값을 고치고, Model이 바뀌면 그걸 보여주는 화면이 다시 바뀐다. 화면 → Model, Model → 화면 양쪽으로 길이 열려 있어서 「양방향」이다.
Flux는 값을 직접 고치는 문을 닫고 전표 하나만 남겼다
처음 들은 설명은 Flux의 부품 넷을 표로 늘어놓고, 자바 코드를 붙이고, Redux(Flux 다음 해에 나와 Flux를 더 단순하게 만든 도구로, 다음 글에서 다룬다)까지 같이 꺼냈다. 나는 이해하지 못했다. 다시 들은 설명은 은행 하나였다.
통장에 잔고 10만 원이 있다고 하자.
MVC 방식의 은행에서는 누구든 통장을 펼쳐서 잔고 숫자를 직접 지우고 새로 쓴다. 엄마도, 아빠도, 나도 쓴다. 어느 날 잔고가 엉뚱한 숫자로 되어 있다. 고친 사람이 셋이나 되고 기록도 없으니, 누가 언제 왜 고쳤는지 알 수가 없다.
Flux 방식의 은행에서는 아무도 통장에 직접 손대지 못한다. 돈을 넣거나 빼려면 「3만 원 출금」 같은 전표를 써서 창구에 내야 한다. 창구가 전표를 통장 담당에게 넘기면, 통장 담당이 전표를 보고 직접 잔고를 고친다.
이제 잔고가 이상하면 창구에 들어온 전표만 순서대로 보면 된다. 잔고가 바뀌는 길이 전표 하나뿐이기 때문이다.
이 은행을 Flux 이름에 대면 이렇다.
- Action: 전표. 「무엇이 일어났다」를 적은 평범한 객체다. 예:
{ type: '대화_읽음', threadId: 7 }.type이 전표의 종류다 - Dispatcher: 창구. 전표를 받아 모든 통장 담당에게 똑같이 돌린다. 스스로 판단하는 일은 없다. 직접 만들 필요는 없고 Flux 라이브러리가
Dispatcher클래스를 준다. 문서의 소제목이 「A Single Dispatcher」인 대로 앱 전체에 하나만 만들어 모두가 같이 쓴다. 아래 코드의dispatcher가 그 하나다 - Store: 통장 담당. 값과 규칙을 들고 있고, 전표를 보고 자기와 관계있으면 스스로 값을 고친다
- View: 통장을 보는 사람이자 전표를 내는 사람. 화면이다
한 줄로 줄이면 이것이다.
값을 바꾸고 싶으면 직접 고치지 말고 「무슨 일이 있었다」고 적어서 내라. 고치는 건 값을 가진 쪽이 한다.
Store에는 밖에서 값을 바꾸는 메서드가 없다
이 규칙은 Flux 문서에 그대로 있다.
Stores have no direct setter methods like
setAsRead(), but instead have only a single way of getting new data into their self-contained world — the callback they register with the dispatcher.Store에는
setAsRead()같은 직접 바꾸는 메서드가 없다. 대신 자기만의 세계에 새 데이터를 들이는 길이 딱 하나 있다. Dispatcher에 등록해 둔 콜백이다.
앞의 자바 코드를 Flux로 바꾸면 이렇게 된다.
// Flux: 바꾸는 길은 전표 하나뿐이다
dispatcher.dispatch(new Action("대화_읽음", 7));
// → ThreadStore가 전표를 보고 스스로 7번 대화를 읽음으로 바꾼다
// → UnreadCountStore도 같은 전표를 보고 스스로 수를 줄인다
한 줄씩 보면 이렇다.
new Action("대화_읽음", 7): 「7번 대화가 읽혔다」는 전표를 만든다dispatcher.dispatch(...): 그 전표를 창구에 낸다. 창구는 등록된 Store 전부에게 전표를 돌린다- ThreadStore와 UnreadCountStore는 같은 전표를 받는다. 서로를 부르지 않고, 각자 「이건 내 일이네」 하고 자기 값을 고친다
값이 바뀌는 이유는 언제나 「어떤 전표가 들어왔기 때문」이다. 그래서 이상한 값이 보이면 들어온 전표 목록만 보면 된다. 문서는 하나를 더 적었다. 「갱신이 한 바퀴 안에서만 데이터를 바꿀 수 있으면 시스템 전체가 더 예측 가능해진다」. 여기서 「한 바퀴」는 전표 하나가 창구를 지나 모든 Store에 전해지고, Store들이 값을 다 고치기까지다. Flux의 Dispatcher 소스(src/Dispatcher.js)를 보면, 한 바퀴가 도는 도중에 누가 새 전표를 내면 Cannot dispatch in the middle of a dispatch.(전달하는 도중에는 전달할 수 없다)라는 에러를 낸다. Store가 전표를 처리하다가 그 안에서 또 다른 전표를 내서 다른 값을 고치는 일 자체가 막혀 있다. 그래서 갱신이 사슬처럼 번질 틈이 없다.
공부하며 받은 확인 문제는 앞에서 소개한 좋아요 버튼을 Flux로 바꿨다고 치고, 「하트를 누르면 화면이 가장 먼저 하는 일은 무엇인가」였다. 보기는 좋아요 수를 들고 있는 Store(likeStore라고 부르자)의 값을 직접 1 올리는 likeStore.count++였고, 나는 「그거 말고 다른 것」이라고만 답했다. 방향은 맞았고, 그 「다른 것」을 채우면 이 문장이다. 화면은 값을 건드리지 않고 「하트가 눌렸다」는 전표를 만들어 창구에 낸다. 숫자를 몇으로 바꿀지는 Store가 정한다.
이 문제는 서버를 빼고 단순하게 그린 장면이었다. 실제 우리 좋아요는 하트를 누르면 먼저 서버에 요청하고, 서버가 새 수를 돌려준 뒤에야 값을 바꾼다. 그래서 뒤에서 우리 코드를 Flux로 바꿔 볼 때는 전표가 「하트가 눌렸다」가 아니라, 서버 응답을 받은 뒤 내는 「좋아요가 반영됐다(좋아요_반영됨)」가 된다. 어느 쪽이든 화면이 값을 직접 고치지 않고 전표를 낸다는 점은 같다.
Flux라는 이름의 유래는 문서에 없고, flux는 「흐름」이라는 뜻이다
공부하다가 「왜 이걸 Flux라고 부르지?」가 궁금해졌다. Facebook이 이름을 왜 Flux로 지었는지는 받아 둔 Flux 문서 전체에서 찾지 못했다. 그래서 아래는 단어 뜻과 문서 내용으로 짐작한 것이다.
flux는 영어로 「흐름」이라는 뜻이다. 라틴어 fluxus(흐르다)에서 왔고, 같은 뿌리의 말로 fluid(유체)가 있다. 그리고 Flux 문서가 처음부터 끝까지 강조하는 것이 흐름의 방향이다.
Flux eschews MVC in favor of a unidirectional data flow.
Flux는 MVC를 피하고 한 방향 데이터 흐름을 택한다.
문서는 이 한 방향 흐름 그림을 「Flux 개발자가 머릿속에 가져야 할 가장 중요한 모델」이라고까지 적는다.
전표(Action) → 창구(Dispatcher) → 통장 담당(Store) → 화면(View)
│
┌─────────────── 새 전표를 낸다 ────────────────┘
▼
전표 → 창구 → ...
거꾸로 가는 길이 없다. 통장 담당이 창구에 직접 무언가를 시키지 않고, 화면이 잔고를 직접 고치지도 않는다. 데이터가 한쪽으로만 흐르며 한 바퀴 돈다. 그래서 「흐름」이라고 불렀다고 보는 게 자연스럽지만, 이름을 지은 사람이 그렇게 말한 기록은 확인하지 못했다.
Store는 화면을 모르고, 화면이 Store에 등록해 둔다
전표에 type을 넣는다는 걸 듣고 바로 이 질문이 나왔다. 「전표에 타입만 넣는데, Store는 어떻게 화면의 어느 부분을 고쳐야 하는지 아는 거야?」
답은 「Store는 화면을 모른다」였다. 방향이 거꾸로다. 화면이 Store를 안다.
전표의 type은 Store가 읽는 것이다. Store는 그걸 보고 「좋아요 수를 이 값으로 바꾸면 되겠다」만 판단한다. 어느 화면이 그 숫자를 보여주는지는 Store의 관심 밖이다.
화면이 바뀐 걸 아는 방법은 은행에 하나를 더하면 보인다. 통장 담당 옆에 「잔고 바뀌면 알려 주세요」 명단이 있다. 잔고를 보고 싶은 사람은 미리 거기에 이름을 적어 둔다. 통장 담당은 전표대로 잔고를 고친 다음, 명단을 보고 「잔고 바뀌었어요」라고만 외친다. 얼마로 바뀌었는지, 누가 무엇을 보려는지는 말하지 않는다. 명단에 있던 사람들이 그 소리를 듣고 각자 통장을 다시 들여다본다.
Flux 문서도 이 순서로 적었다.
After the stores are updated, they broadcast an event declaring that their state has changed, so the views may query the new state and update themselves.
Store는 갱신된 뒤 「상태가 바뀌었다」는 이벤트를 알린다. 그러면 View들이 새 상태를 물어보고 스스로 갱신한다.
이 「바뀌었다고만 외치고, 듣는 쪽이 다시 읽는다」는 모양은 MVC 글에서 이미 본 것이다. 앞에서 소개한 components/ReviewActions.js의 맨 위가 정확히 이렇게 생겼다.
const listeners = new Set(); // 「바뀌면 알려 주세요」 명단
function subscribe(onChange) { // 화면이 명단에 이름을 적는다
listeners.add(onChange);
return () => listeners.delete(onChange);
}
function notify() { // 「바뀌었어요」라고만 외친다
listeners.forEach(onChange => onChange());
}
한 줄씩 보면 이렇다.
listeners: 알림을 받겠다고 등록한 함수들을 모아 두는 집합(자바의Set)이다subscribe(onChange): 화면이 「값이 바뀌면 이 함수를 불러 줘」라며 넘긴 함수onChange를 명단에 넣는다. 그리고 「부르면 명단에서 지우는 함수」를 만들어 돌려준다. 이걸 왜 돌려주는지는 다음 섹션에서 본다notify(): 명단에 있는 함수를 하나씩 꺼내 부른다.onChange => onChange()는 「함수 하나를 받아 그 함수를 부른다」는 화살표 함수다
notify()는 명단에 있는 함수를 하나씩 부르기만 한다. 어떤 화면인지, 화면이 몇 개인지 모른다. Store가 화면을 몰라도 되니까, 화면이 몇 개로 늘든 Store는 고칠 게 없다.
등록은 두 군데서 일어나고, 화면 쪽 등록은 React가 대신 한다
다음 질문은 「그럼 미리 등록하는 절차가 필요한 거야?」였다. 필요하다. 등록이 없으면 Store가 「바뀌었다」고 외쳐도 들을 사람이 없다. 다만 등록은 두 종류이고, 둘은 헷갈리기 쉽다.
- Store가 Dispatcher에 등록한다. 전표를 받으려는 것이다. 앱이 처음 켜질 때 한 번 한다. 은행으로 치면 통장 담당이 창구에 「전표 오면 저한테도 주세요」라고 말해 두는 것이다. 앞 인용문의 「Dispatcher에 등록해 둔 콜백」이 이것이다
- View가 Store에 등록한다. 「바뀌었다」 알림을 받으려는 것이다. 화면이 나타날 때마다 한다. 은행으로 치면 통장을 보고 싶은 사람이 명단에 이름을 적는 것이다
화면 쪽 등록은 개발자가 손으로 하지 않는다. 우리 코드에는 「등록」이라고 쓴 줄이 없고, 이 한 줄뿐이다.
const liked = useSyncExternalStore(subscribe, () => siteUtils.isReviewLiked(reviewId), () => false);
React는 컴포넌트마다 값을 기억해 두는 자기 보관함(상태)을 따로 갖고 있다. counts와 listeners는 그 바깥, 파일 맨 위에 우리가 직접 만든 보관함이라서 「바깥 보관함」이다. useSyncExternalStore는 React가 주는 함수로, 컴포넌트를 이런 바깥 보관함에 연결한다. 인자가 셋이다. 첫째 subscribe는 앞에서 본 「명단에 등록하는 함수」다. 둘째 () => siteUtils.isReviewLiked(reviewId)는 「지금 값을 읽는 함수」로, lib/site-utils.js의 도우미가 브라우저 저장 공간에서 이 후기(reviewId, 후기 번호)에 내가 좋아요를 눌렀는지 읽어 온다. 셋째 () => false는 화면을 미리 만들어 둘 때 쓰는 처음 값이다. 우리 앱은 사이트를 올리기 전에 모든 화면을 HTML(브라우저가 읽는 화면의 뼈대 글) 파일로 미리 만들어 두는 방식(정적 export)이고, 그때 컴포넌트 코드가 브라우저 없이 한 번 돈다. 그때는 브라우저가 없어서 「눌렀는지」를 알 수 없으니 「안 눌렀다」로 둔다. 결과 liked는 하트를 채워 그릴지 정하는 데 쓴다. 좋아요 숫자도 같은 방식으로 읽는다. 파일에는 useSyncExternalStore(subscribe, () => counts.get(reviewId) ...)처럼 counts에서 지금 수를 읽는 줄이 하나 더 있다. 그래서 notify()가 불리면 하트와 숫자가 둘 다 다시 읽힌다.
「눌렀는지」만 서버가 아니라 브라우저 쪽에 두는 데는 이유가 있다. 파일 맨 위 주석에 「서버 좋아요는 누가 눌렀는지 남기지 않는 익명 카운터입니다. 그래서 “내가 눌렀는가”는 이 브라우저의 localStorage 만 압니다」라고 적혀 있다. 서버는 수만 세고 누가 눌렀는지는 모른다. 그래서 「내가 눌렀다」는 사실은 localStorage(브라우저가 사이트마다 따로 마련해 둔 작은 저장 공간으로, 이 컴퓨터의 이 브라우저에만 남는다)에 적어 둔다.
subscribe 함수를 넘겨주기만 하면 React가 나머지를 한다. MVC 글에서 확인한 React 공식 문서의 설명대로다.
ReviewActions가 화면에 나타날 때 React가subscribe를 불러 명단에 이름을 올린다- 알림이 오면 React가 둘째 인자(지금 값을 읽는 함수)를 다시 불러 값을 읽고, 달라졌으면 다시 그린다
- 화면에서 사라질 때 React가
subscribe가 돌려준 함수를 불러 명단에서 이름을 지운다
3번을 빠뜨리면 사라진 화면이 명단에 계속 남는다. 명단(listeners)이 사라진 화면의 함수를 계속 쥐고 있으면, 그 함수가 붙들고 있는 화면 조각도 아직 쓰이는 것으로 보여서 가비지 컬렉터가 치우지 못한다. 쓰지도 않는 것이 메모리에 쌓이고, 알림이 올 때마다 없는 화면을 다시 그리려는 헛일도 생긴다. 그래서 subscribe가 「등록을 지우는 함수」를 돌려주고, React가 때맞춰 그걸 부른다.
등록은 필요하지만, 언제 등록하고 언제 지울지는 도구가 화면이 나타나고 사라지는 때에 맞춰 대신 한다. 개발자는 무엇을 보고 싶은지만 적는다.
우리 좋아요 코드는 값을 직접 고치고, 지금은 그걸로 충분하다
이제 Flux를 우리 코드에 대 봤다. ReviewActions.js에서 하트를 누르면 이 코드가 돈다.
// 하트 클릭 처리 함수 onLike 안
counts.set(reviewId, updated); // 좋아요 수를 직접 고친다
siteUtils.setReviewLiked(reviewId, next); // 「내가 눌렀는지」를 직접 고친다
notify(); // 「바뀌었어」 알림
updated는 바로 앞 줄에서 서버에 좋아요 요청을 보내고 돌려받은 새 좋아요 수이고, next는 지금 「눌렀는지」의 반대 값(!liked)이다. 아직 안 누른 상태에서 하트를 클릭하면 true(이제 누른다)이고, 이미 누른 상태에서 클릭하면 false(취소한다)다. counts는 후기 번호마다 최신 좋아요 수를 적어 두는 Map(자바의 Map과 같다)이고, siteUtils.setReviewLiked()는 「눌렀는지」를 브라우저 저장 공간에 적는 도우미다.
클릭을 받은 함수가 값을 직접 고친다. 은행으로 치면 통장을 펼쳐서 숫자를 직접 고쳐 쓰는 쪽이다. 같은 일을 Flux로 쓰면 이렇게 된다.
// 화면: 직접 고치지 않고 전표만 낸다
dispatcher.dispatch({ type: '좋아요_반영됨', reviewId, count: updated, liked: next });
// 통장 담당(Store): 전표를 보고 스스로 고친다
dispatcher.register(action => {
if (action.type === '좋아요_반영됨') {
counts.set(action.reviewId, action.count);
siteUtils.setReviewLiked(action.reviewId, action.liked);
notify();
}
});
한 줄씩 보면 이렇다.
dispatcher.dispatch({...}): 화면은 「좋아요가 반영됐다」는 전표에 후기 번호, 서버가 돌려준 수, 눌렀는지를 담아 창구에 낸다dispatcher.register(action => {...}): Store가 창구에 「전표가 오면 이 함수를 불러 줘」라고 등록한다.action => {...}는 전표를 받아 중괄호 안을 실행하는 화살표 함수다if (action.type === '좋아요_반영됨'): 전표 종류를 보고 자기 일인지 가린다. 맞으면 지금과 같은 세 줄로 값을 고치고 알린다
하는 일은 똑같고 줄은 늘었다. 지금 우리 코드에서는 Flux로 얻을 게 없다. 좋아요 수를 바꾸는 곳이 onLike 함수 한 곳뿐이기 때문이다. 통장을 고치는 사람이 나 혼자면, 전표 제도를 만들어도 기록을 들여다볼 일이 없다. 「누가 고쳤지?」라는 질문이 생기지 않는다.
Flux가 이득이 되는 건 값을 바꾸는 곳이 여러 군데일 때다.
| 통장을 고치는 사람 | 직접 고치기 (지금 방식) | 전표 제도 (Flux) |
|---|---|---|
| 나 혼자 | 충분하다. 잘못되면 나만 보면 된다 | 서류만 늘어난다 |
| 가족 다섯 명, 자동이체, 은행 이자 | 잔고가 이상해도 누가 그랬는지 모른다 | 전표만 순서대로 보면 원인이 나온다 |
MVC 글에서 세운 기준과 같은 이야기다. 그 글은 한 값을 보여주는 곳이 여럿이고 값이 바뀌는 길도 여럿이면, 「곳 × 길」만큼 손으로 챙겨야 해서 어긋나기 쉽다고 했다. 둘이 많아질수록 한 방향 흐름이 이득이고, 적으면 짐이다.
같은 종류의 일이면 전표만 내면 되고, 새 종류의 일이면 Store에 한 번 적는다
공부하며 받은 확인 문제는 우리 앱에 기능 셋이 생긴다고 치는 것이었다. 후기 목록 화면에서도 하트를 누를 수 있고, 관리자가 부적절한 좋아요를 지우면 숫자가 줄고, 화면 맨 위에 「오늘 받은 좋아요 합계」가 뜬다. 이때도 직접 고치기를 계속 쓸까.
나는 이 문제를 보다가 「아, 코드를 고치는 게 아니라 전표의 내용을 고치는 거구나. 그럼 값 하나만 바꿔 주면 같은 동작을 하네」라고 말했다. 내가 말한 「값」은 전표에 적는 내용, 즉 어떤 종류의 일인지(type)와 어느 후기인지(reviewId) 같은 것이었다. 새 기능도 그 값만 맞춰 전표를 내면 Store가 알아서 같은 처리를 해 준다는 뜻이었다. 반은 맞았다. 기능 셋을 하나씩 대 보면 어디까지 맞는지 보인다.
후기 목록 화면에 하트가 생긴다. 목록 화면은 상세 화면과 똑같은 전표를 내기만 하면 된다. 숫자를 어떻게 고칠지는 이미 Store에 있으니, 목록 화면은 그걸 몰라도 된다. 「같은 전표를 내면 같은 동작을 한다」가 그대로 맞는다.
「오늘 받은 좋아요 합계」가 뜬다. 합계를 들고 있는 Store를 하나 더 두고, 그 Store도 같은 좋아요_반영됨 전표를 듣게 한다. 하트를 누르는 쪽 코드는 한 줄도 바뀌지 않는다. 전표를 듣는 쪽만 늘어난다.
관리자가 좋아요를 지운다. 여기는 다르다. 「지웠다」는 지금까지 없던 일이라 좋아요_삭제됨 같은 새 전표 종류가 필요하고, Store에 「이 전표가 오면 이렇게 고친다」를 한 번은 코드로 적어야 한다. 다만 그 코드를 쓰는 곳은 Store 한 곳뿐이다. 관리자 화면 여기저기에 「숫자 줄이기」를 흩어 놓을 필요가 없다.
그러니 「값 하나만 바꾸면 같은 동작」은 이미 있는 종류의 일일 때 맞는 말이다. 새 종류의 일이면 Store에 처리 방법을 한 번 더해야 한다. 그래도 고치는 곳이 항상 Store 하나로 모인다는 점이 Flux의 이득이다. 확인 문제의 답도 여기서 나온다. 이 기능 셋이 생기면 하트를 누르는 곳, 값을 바꾸는 길, 값을 보는 곳이 모두 늘어나니 전표 제도로 바꾸는 게 낫다.
판단 기준 정리
| 질문 | 답 | 근거 |
|---|---|---|
| Flux는 무엇을 바꿨나 | 값을 직접 고치는 문을 닫고 전표(Action) 하나만 남겼다 | MVC에서는 아무 곳이나 setter를 불러 연쇄 갱신이 번졌다 |
| 화면은 값을 어떻게 바꾸나 | 직접 고치지 않고 전표를 창구(Dispatcher)에 낸다 | Store에는 밖에서 부르는 setter가 없다 |
| 누가 값을 고치나 | 값을 가진 Store가 전표를 보고 스스로 | 고치는 방법이 Store 한 곳에 모인다 |
| 이름이 왜 Flux인가 | 문서에 유래가 없다. flux는 「흐름」이다 | 문서의 핵심이 한 방향 데이터 흐름이다 |
| Store는 화면을 아나 | 모른다. 화면이 Store에 등록해 둔다 | Store는 「바뀌었다」고만 알리고 화면이 다시 읽는다 |
| 등록은 누가 하나 | Store는 Dispatcher에, 화면은 Store에. 화면 쪽은 React가 대신 한다 | useSyncExternalStore가 나타날 때 등록, 사라질 때 해제 |
| 우리 좋아요에 Flux가 필요한가 | 지금은 아니다 | 값을 바꾸는 곳이 onLike 한 곳뿐이다 |
| 언제 필요해지나 | 바꾸는 곳, 바뀌는 길, 보는 곳이 늘어날 때 | 같은 종류의 일은 전표만 내면 되고, 새 종류는 Store에 한 번 적는다 |
이 정리에 닿기까지
시작은 MVC 글의 마지막 빈칸이었다. 큰 MVC 앱에서 연쇄 갱신이 번졌고, 그 답이 Flux라는 데까지만 적었다.
처음 설명은 이해하지 못했다. 부품 넷의 표, 자바 코드, Redux가 한꺼번에 나왔다. 「아직도 이해를 못 했어」라고 말했고, Redux를 미뤄 두고 은행 비유 하나로 다시 들었다. 통장을 직접 고치는 은행과 전표를 내는 은행을 나란히 놓자 「값이 바뀌는 길을 하나로 줄인다」가 보였다.
이름이 궁금했다. 왜 Flux인지 물었고, 문서에서 유래를 찾지 못했다. flux가 「흐름」이라는 뜻이고 문서의 핵심이 한 방향 흐름이라는 데서 이어 붙였지만, 짐작이라고 적었다.
우리 코드에 대 보니 지금은 필요 없었다. 좋아요를 바꾸는 곳이 한 곳뿐이라 전표 제도는 줄만 늘린다. 기능이 늘어나는 장면을 그려 보고 나서야 이득이 보였다. 그때 「전표 내용만 바꾸면 같은 동작」이라고 말했고, 새 종류의 일이면 Store에 한 번 적어야 한다는 데까지 바로잡았다.
마지막 두 질문이 그림을 완성했다. 「Store가 어떻게 화면을 아나」를 물었고, 방향이 거꾸로라는 것을 알았다. Store는 화면을 모르고 화면이 Store에 등록한다. 「그럼 등록이 필요하냐」를 물었고, 등록은 두 종류이며 화면 쪽은 React가 대신 한다는 것을 알았다. 그 모양이 MVC 글에서 본 우리 ReviewActions.js의 subscribe · notify와 같았다.
정리
- Flux는 값을 직접 고치는 문을 닫고 전표(Action) 하나만 남겼다. 값이 바뀌는 이유는 언제나 「어떤 전표가 들어왔기 때문」이다
- 고치는 건 값을 가진 Store가 한다. Store에는 밖에서 부르는 setter가 없다
- Store는 화면을 모른다. 「바뀌었다」고만 알리고, 미리 등록한 화면들이 다시 읽는다. 화면 쪽 등록과 해제는 React가 대신 한다
- 이름의 유래는 문서에 없다. flux는 「흐름」이고, Flux의 핵심은 한 방향 흐름이다
- 값을 바꾸는 곳이 하나면 Flux는 짐이다. 우리 좋아요가 지금 그렇다. 바꾸는 곳, 바뀌는 길, 보는 곳이 늘어날 때 이득이 된다
자신만의 철학을 만들어가는 중입니다.
댓글남기기