FSD 글에서 프론트엔드 구조 후보 넷을 놓고 FSD를 골랐다. FSD는 화면 코드를 층으로 나누고 「위층 파일만 아래층 파일을 불러 쓸 수 있다」는 방향 규칙을 거는 구조다. 그때 MVC는 「화면과 로직을 나누나에 답할 뿐, 누가 누구를 부르나에는 답이 없다」는 한 줄로 넘겼다. 깊이 비교하지 않았다고도 적었다.

FSD를 다 공부하고 나서 그 한 줄이 걸렸다. MVC는 이름을 수없이 들었고, Spring으로 서버를 만들며 매일 쓰는 말이다. 그런데 정작 MVC가 무엇을 풀려고 나왔는지는 설명하지 못했다. 그래서 이 글은 그 질문에서 시작했다.

이 글은 내가 AI 대화 상대인 Claude(Anthropic이 만든 AI)와 대화하며 공부한 것을 옮긴 것이다. 표준을 정한 글이 아니라 공부한 경로를 적은 글이다.


먼저: 나는 Spring MVC를 이미 안다

이 블로그에는 브라우저가 보낸 HTTP 요청(「이 페이지 주세요」 같은 부탁) 하나가 Spring 서버 안을 지나가는 길을 따라간 딥다이브가 있다. 7단계에서 서버 프로그램(Tomcat)이 Spring MVC의 입구인 DispatcherServlet으로 요청을 넘기고, 17-V5단계에서 DispatcherServlet이 Model을 Thymeleaf View에 넘겨 HTML을 만들게 한다.

그러니까 나에게 MVC는 이런 모양이었다.

요청 → Controller → (Service 호출) → Model에 값 담기 → View가 HTML 생성 → 응답

요청을 받은 Controller가 실제 일은 Service에게 시키고, 결과를 Model에 담아 View에게 넘기면, View가 HTML을 만들어 응답으로 보낸다.

이 그림이 MVC의 원래 모양이 아니라는 것이 이 글의 첫 번째 발견이었다. 그걸 알려면 MVC가 처음 풀려던 문제부터 봐야 했다.


MVC는 한 값이 여러 곳에 보일 때 화면이 서로 어긋나는 문제를 풀려고 나왔다

MVC는 1970년대 말 Smalltalk라는 언어로 화면 프로그램을 만들던 곳에서 나왔다. 처음 제안한 사람은 Trygve Reenskaug로 알려져 있다. 이 출처는 원문을 열어 확인하지 못했다(이 환경에서 그 페이지에 접속할 수 없었다).

풀려던 문제는 장면 하나로 보인다.

화면에 좋아요 수 「12」가 두 군데 떠 있다. 후기 카드 아래에 하나, 화면 위쪽 요약 칸에 하나. 사용자가 하트를 누르면 두 군데가 다 「13」이 되어야 한다.

MVC 없이 짜면 이렇게 된다.

// 버튼을 눌렀을 때 실행되는 코드 하나에 전부 들어 있다
void onHeartClick() {
    likeCount = likeCount + 1;          // 값 바꾸기
    cardLabel.setText(likeCount);       // 카드 아래 숫자 다시 그리기
    summaryLabel.setText(likeCount);    // 위쪽 요약 숫자 다시 그리기
}

한 줄씩 보면 이렇다.

  • likeCount = likeCount + 1: 좋아요 수를 1 올린다
  • cardLabel.setText(likeCount): 카드 아래 글자 칸에 새 숫자를 넣는다
  • summaryLabel.setText(likeCount): 위쪽 요약 글자 칸에도 새 숫자를 넣는다

처음엔 괜찮다. 깨지는 곳은 두 군데다.

숫자를 보여주는 곳이 늘면 이 함수를 고쳐야 한다. 세 번째 칸이 생기면 onHeartClick()에 한 줄을 더 넣어야 한다.

좋아요가 바뀌는 길이 하나가 아니다. 다른 사람들이 누른 결과가 서버에서 내려와 값이 바뀔 수도 있다. 그 코드에도 「두 군데 다시 그리기」를 똑같이 복사해야 한다. 한 곳이라도 빠뜨리면 화면 두 군데의 숫자가 서로 달라진다.

문제의 뿌리는 「값 바꾸기, 입력 받기, 화면 그리기」가 한 덩어리로 엉켜 있다는 것이었다. 보여주는 곳이 여러 개이고 값이 바뀌는 길도 여러 개라서, 「곳 × 길」만큼 손으로 챙겨야 한다.


MVC는 셋으로 떼고, Model이 바뀌면 View들에 알리게 했다

MVC는 그 덩어리를 셋으로 뗀다.

  • Model: 데이터와 규칙을 들고 있다. 화면을 전혀 모른다. 위 장면에서는 likeCount와 「1 올리기」다
  • View: Model의 값을 화면에 그린다. 위 장면에서는 카드 아래 숫자와 위쪽 요약 숫자, 둘 다 View다
  • Controller: 사용자의 입력을 받아 Model에게 시킨다. 위 장면에서는 하트 클릭을 받아 「1 올려」라고 전한다

여기까지는 나도 알던 내용이다. 몰랐던 것은 원래 MVC의 핵심 장치 하나였다. Model이 바뀌면 Model이 「나 바뀌었어」라고 알리고, View들이 그 알림을 듣고 스스로 다시 그린다.

등록은 간단하다. Model이 「알림 받을 View 목록」을 하나 들고 있고, View가 처음 화면에 뜰 때 그 목록에 자기를 넣는다. Model은 바뀔 때마다 그 목록을 돌며 하나씩 「다시 그려」라고 부른다. 아래 LikeModel 코드에 이 목록이 들어 있다.

이 장치가 있으면 Controller는 「1 올려」만 하면 된다. 숫자를 보여주는 곳이 몇 개든 상관없다. 세 번째 칸이 생겨도 그 칸이 「알림을 듣겠다」고 등록만 하면 끝이고, Controller는 고치지 않는다. 서버에서 값이 내려오는 길도 Model만 고치면 되고, 다시 그리기는 알림이 알아서 퍼뜨린다. 「곳 × 길」로 늘던 일이 「곳 하나당 등록 한 번」으로 줄어든다.


「100개를 넘으면 금색」 같은 규칙은 Controller가 아니라 Model에 둔다

공부하면서 확인 문제를 하나 받았다. 위 장면에 「좋아요가 100개를 넘으면 하트가 금색이 된다」는 규칙이 붙었다. 판단하는 코드와 금색으로 칠하는 코드를 각각 어디에 두나.

나는 판단을 Controller에, 칠하기를 View에 두었다. 칠하기는 맞았고 판단은 틀렸다. 하트를 누르는 순간 「100 넘었나?」를 확인하는 게 자연스러워 보였다.

틀린 이유는 앞 장면에 이미 있었다. 다른 사람들이 좋아요를 눌러서 서버가 「지금 101개야」라는 새 값을 내려줬다고 해 보자. 이 길은 하트 클릭이 아니라서 Controller를 거치지 않는다. 그러면 Controller에 둔 판단이 실행되지 않고, 숫자는 101인데 하트는 금색이 안 된다. 앞에서 본 「한 곳 빠뜨리면 화면이 어긋난다」가 그대로 돌아온다.

Model에 두면 이렇게 된다.

class LikeModel {
    private int count;
    private final List<LikeView> views = new ArrayList<>();      // 알림 받을 View 목록

    void addView(LikeView view) { views.add(view); }            // View가 자기를 등록한다
    private void notifyViews() {                               // 등록된 View 전부에게 「다시 그려」
        for (LikeView view : views) {
            view.redraw();
        }
    }

    void increase()        { count++;       notifyViews(); }  // 하트 클릭 길
    void update(int value) { count = value; notifyViews(); }  // 서버에서 값이 내려오는 길

    boolean isGold() { return count > 100; }  // 규칙은 여기 한 곳에만
}

한 줄씩 보면 이렇다.

  • increase(): 하트를 눌렀을 때 Controller가 부른다. 1 올리고 알린다
  • update(int value): 서버에서 새 값이 왔을 때 부른다. 그 값으로 바꾸고 알린다
  • views, addView(): 알림 받을 View 목록과, View가 자기를 그 목록에 넣는 메서드다. LikeView는 「숫자를 화면에 그리는 쪽」을 나타내는 클래스이고, 다시 그리는 메서드 redraw()를 가지고 있다고 치자. 카드 아래 숫자와 위쪽 요약 숫자가 각각 LikeView 객체 하나다
  • notifyViews(): 「나 바뀌었어」 알림이다. 목록의 View를 하나씩 불러 다시 그리게 한다
  • isGold(): 「100 넘었나」 규칙이다. View는 다시 그릴 때 이걸 물어보기만 하고, true면 금색으로 칠한다

값이 어떤 길로 바뀌든 결국 Model을 거친다. 그래서 규칙을 Model에 두면 빠지는 길이 없다. 정리하면 이렇다.

「이 값이 이렇다면 저렇다」는 규칙은 Model, 그 결과를 어떻게 보여줄지는 View, 사용자의 입력을 받아 전달하는 건 Controller.

Spring에서 업무 판단을 Controller가 아니라 Service에 넣는 것과 같은 이유다. 서버의 데이터도 Controller 한 곳으로만 바뀌지 않는다. 정해진 시간마다 도는 작업(배치)이나 다른 서버가 보낸 메시지를 받는 코드도 같은 데이터를 바꾼다. 판단이 Controller에 있으면 그 길들에서는 빠진다.


Spring MVC는 알림 장치 없이 MVC의 이름과 역할 나누기만 빌렸다

이제 처음 그림, 내가 알던 Spring MVC로 돌아갔다. 둘을 나란히 놓으니 같은 이름 아래 다른 것이 들어 있었다.

  원래 MVC (화면 프로그램) Spring MVC (웹 서버)
Model 데이터와 규칙을 계속 들고 살아 있는 객체. 바뀌면 View에 알린다 Controller가 View에 넘기는 값 상자. 한 번 넘기면 끝이다
View 화면에 계속 떠 있으면서 알림을 듣고 다시 그린다 Thymeleaf 같은 템플릿(빈칸을 둔 HTML에 값을 채우는 틀). HTML을 한 번 만들어 보내면 끝이다
Controller 키보드와 마우스 입력을 받는다 브라우저가 보낸 HTTP 요청을 받는다
알림 장치 있다 없다

Spring의 Model이 정말 「값 상자」인지는 이 저장소에 있는 Spring 소스로 확인했다. Controller 메서드에 Model model이라고 매개변수를 적으면 Spring이 객체를 만들어 넣어 준다. Model은 인터페이스라서, 실제로 들어오는 객체는 그 인터페이스를 구현한 클래스다. 그 클래스인 ExtendedModelMap은 이렇게 선언되어 있다(Spring이 실제로 넣어 주는 BindingAwareModelMap도 이 클래스를 상속한다).

// spring-context/src/main/java/org/springframework/ui/ModelMap.java
public class ModelMap extends LinkedHashMap<String, Object> { ... }

// spring-context/src/main/java/org/springframework/ui/ExtendedModelMap.java
public class ExtendedModelMap extends ModelMap implements Model { ... }

ModelMap이 자바의 LinkedHashMap<String, Object>를 그대로 상속한다. 이름표(String)를 붙인 값(Object)을 순서대로 담는 Map이다. model.addAttribute("count", 12)는 결국 이 Map에 "count" → 12를 넣는 일이다. 알림을 보내는 기능은 없다.

가장 큰 차이는 「계속 살아 있느냐」다. 웹 서버는 요청이 오면 HTML을 만들어 보내고 잊는다. Model Map은 Spring이 요청 하나를 처리하는 동안만 들고 있는 객체라서, 응답을 보내고 그 요청 처리가 끝나면 아무도 붙잡고 있지 않아 버려진다. 그래서 공부하며 받은 확인 문제, 「Controller에서 model.addAttribute("count", 12)로 보낸 뒤, 브라우저에 화면이 이미 뜬 다음에 서버 코드에서 그 값을 13으로 바꾸면 화면도 바뀌나?」의 답은 「안 바뀐다」였다. 나는 「SSR이라 한 번 더 요청해야 바뀐다」고 답했고, 거기에 하나가 더 붙는다. 바꾸려 해도 바꿀 대상(Map)이 이미 남아 있지 않다. 브라우저는 받은 HTML 글자만 들고 있다.

그러니 Spring MVC는 원래 MVC에서 「Model · View · Controller로 역할을 나눈다」만 빌려 오고, 핵심이던 알림 장치는 들어갈 자리가 없어서 빠진 셈이다.


브라우저 화면은 계속 떠 있어서 MVC가 풀던 문제를 다시 만난다

여기서 프론트엔드로 이어진다. 브라우저 안의 화면은 Spring과 달리 계속 떠 있다. 브라우저는 HTML과 함께 자바스크립트 코드도 받는데, 이 코드는 페이지가 열려 있는 동안 브라우저 안에서 계속 돌면서 값을 들고 화면을 고친다. 좋아요를 누르면 페이지를 새로 받지 않고 숫자만 바뀌어야 한다. 그러면 원래 MVC가 풀던 문제, 「값이 바뀌면 그 값을 보여주는 곳이 전부 다시 그려져야 한다」를 다시 만난다.

공부하며 「브라우저에서 계속 떠 있는 화면에 알림 장치가 왜 필요한가」를 좋아요 장면 한 문장으로 설명해 보라는 문제를 받았다. 나는 「실시간으로 Model과 View가 연동되어 있어서 Model이 바뀌면 화면이 바뀐다」고 답했다. 그건 어떻게 바뀌는지에 대한 답이었고, 왜 필요한지는 이 문장이었다.

좋아요 숫자를 보여주는 곳이 여러 군데이고 값이 바뀌는 길도 여러 개라서, 알림 장치가 없으면 길마다 어느 화면을 다시 그릴지 손으로 챙겨야 하고, 하나라도 빠지면 화면끼리 숫자가 어긋나기 때문이다.

보여주는 곳이 하나, 바뀌는 길이 하나뿐이면 알림 장치가 없어도 괜찮다. 장치가 필요해지는 조건은 「여러 곳 × 여러 길」이다.


프론트엔드에서 MVC를 크게 쓰자 알림이 사방으로 번져 추적할 수 없게 됐다

2010년 전후로 브라우저 화면이 복잡해지면서 원래 MVC를 자바스크립트로 옮긴 도구들이 나왔다. Backbone.js가 그 예다. 처음엔 잘 됐는데, 화면이 커지자 새 문제가 생겼다.

이 문제는 Flux 공식 문서가 직접 적어 두었다. Flux를 만든 Facebook이 왜 MVC를 버렸는지 설명하는 대목이다.

We originally set out to deal correctly with derived data: for example, we wanted to show an unread count for message threads while another view showed a list of threads, with the unread ones highlighted. This was difficult to handle with MVC — marking a single thread as read would update the thread model, and then also need to update the unread count model. These dependencies and cascading updates often occur in a large MVC application, leading to a tangled weave of data flow and unpredictable results.

처음 풀려던 것은 파생 데이터를 제대로 다루는 일이었다. 예를 들어 한쪽 화면은 메시지 대화 목록을 보여주며 안 읽은 대화를 강조하고, 다른 쪽은 안 읽은 대화 수를 보여주고 싶었다. MVC로는 어려웠다. 대화 하나를 읽음으로 표시하면 대화 Model이 바뀌고, 그다음 안 읽은 수 Model도 바꿔야 했다. 이런 의존과 연쇄 갱신은 큰 MVC 앱에서 자주 일어나고, 데이터 흐름을 뒤엉키게 만들어 결과를 예측할 수 없게 한다.

— facebookarchive/flux docs/In-Depth-Overview.md (커밋 4ee8c50 판)

여기서 파생 데이터는 다른 값에서 계산해 낸 값이다. 안 읽은 대화 수는 대화 목록에서 「안 읽은 것」을 세어 낸 값이다. 앞의 LikeModel처럼 View 둘이 대화 Model 하나를 같이 듣고 그때그때 세면 될 것 같다. 그런데 인용문이 말하듯 그 앱에서는 「안 읽은 수」 Model이 따로 있었다. 왜 따로 두었는지는 문서에 적혀 있지 않다. 문서의 요지는 그다음이다. Model이 둘이 되는 순간 둘을 맞추는 일이 생기고, 앱이 커지면 그런 짝이 곳곳에 늘어난다.

이 장면이 앞의 좋아요 장면과 같은 모양이었다. 한 값(안 읽은 대화)이 두 곳(목록의 강조, 상단의 수)에 보인다. 다른 점은 규모다. Model과 View가 여러 쌍이 되고, 한 Model이 바뀌면 그 값에 기대는 다른 Model도 따라 고쳐야 하고(대화 Model이 바뀌면 안 읽은 수 Model도), 그 Model이 또 다른 View에 알린다. 그 「따라 고치기」 코드가 앱 곳곳에 붙는다.

대화를 읽음으로 표시
 → 대화 Model이 바뀜 → 알림
   → 목록 View가 다시 그려짐
   → 안 읽은 수 Model도 고쳐야 함 → 알림
     → 상단 숫자 View가 다시 그려짐
       → ...

하나를 바꾸면 무엇이 어떤 순서로 바뀌는지 따라갈 수가 없다. 화면에 엉뚱한 값이 떴을 때 「누가 이걸 바꿨지?」를 찾으려면 알림의 사슬을 거꾸로 전부 뒤져야 한다. 앞에서 「손으로 챙기다 빠뜨리는」 문제를 알림 장치로 풀었더니, 이번엔 「자동으로 너무 많이 번져서 추적이 안 되는」 문제가 생긴 것이다.

Facebook은 이 문제를 2014년 F8(Facebook이 해마다 여는 개발자 행사) 발표에서 Flux로 내놓았다. 같은 문서의 첫 문장이 답의 모양이다.

Flux eschews MVC in favor of a unidirectional data flow.

Flux는 MVC를 피하고 한 방향 데이터 흐름을 택한다.

값을 바꾸고 싶으면 「무엇이 일어났다」는 쪽지를 한 곳으로 보내고, 그곳에서만 값이 바뀌고, 바뀐 결과가 화면으로 흐른다. 알림이 사방으로 번지는 대신 길이 하나로 묶인다. Flux가 실제로 어떤 모양인지는 이 글에서 다루지 않았다. 다음 공부거리다.


React는 View와 Controller를 컴포넌트 하나에 합쳤다

같은 시기에 나온 React는 다른 쪽에서 이 문제에 답했다. React에서 화면 조각 하나는 컴포넌트라는 자바스크립트 함수 하나이고, 컴포넌트가 기억하는 값을 상태라고 부른다. React는 View를 「상태를 넣으면 화면이 나오는 함수」로 본다. 컴포넌트 안에서 const [count, setCount] = useState(12);라고 쓰면 React가 값 count와 그 값을 바꾸는 함수 setCount를 짝으로 준다. 상태는 이 함수(setCount(13) 같은 것)로만 바꾸게 되어 있어서, 바꾸는 순간 React가 그 사실을 안다. 그러면 React는 그 컴포넌트 함수를 다시 불러 새 화면 모양을 얻고(다시 계산), 이전 화면 모양과 비교해서 실제 브라우저 화면에서는 달라진 글자나 칸만 바꾼다(고친다). 그래서 「어느 View가 어느 Model의 알림을 듣고 있나」를 사람이 일일이 관리하지 않아도 된다.

알림이 사슬처럼 번지던 문제에도 답이 있다. 그 전에 하나만 먼저 알아 두자. React에서는 컴포넌트가 다른 컴포넌트를 HTML 태그처럼 넣어 부를 수 있다. 예를 들어 후기 상세 페이지 컴포넌트 안에 <ReviewActions reviewId={12} likeCount={30} />라고 쓰면 ReviewActions 컴포넌트가 불리고, reviewId={12}처럼 적은 값(props)이 넘어간다. 부르는 쪽을 부모, 불리는 쪽을 자식이라고 한다.

React에서 값은 이렇게 부모에서 자식으로, 위에서 아래로만 흐른다. 자식은 받은 props를 읽기만 하고 직접 바꿀 수 없다. 값을 바꾸고 싶으면 그 값을 상태로 가진 컴포넌트에게 부탁해야 하고(부모가 「바꿀 때 이걸 불러」 하고 함수를 props로 같이 넘겨준다), 실제로 바꾸는 일은 그 컴포넌트에서만 일어난다. 자식이 부모가 넘겨준 함수를 불러 값을 바꾸게 하는 일은 있다. 그래도 바뀌는 곳은 그 상태를 가진 컴포넌트 한 곳이고, 바뀐 뒤에는 그 아래 화면이 위에서 아래로 한 번 다시 계산될 뿐이다. 다시 계산하는 도중에 다른 값을 또 고치는 일은 하지 않는다.

그리고 React는 「안 읽은 수」 같은 파생 데이터를 상태로 따로 들지 말라고 권한다. 다시 그릴 때마다 원래 값(대화 목록)에서 세면 된다. 그러면 맞춰 줄 두 번째 값이 아예 없으니, Flux 문서가 말한 「대화 Model을 고친 뒤 안 읽은 수 Model도 고쳐야 하는」 일 자체가 생기지 않는다. 「누가 이 값을 바꿨나」를 찾으려면 그 상태를 가진 컴포넌트 하나만 보면 된다.

그 결과 React로 짠 우리 앱에서는 M, V, C가 이렇게 흩어져 있다. 여기서 우리 앱은 방문자가 보는 회사 공개 사이트(client 앱)로, Next.js로 만든 React 앱이다.

원래 MVC 우리 React 앱에서는
View 컴포넌트가 돌려주는 화면 모양(JSX, <div>...</div>)
Controller 같은 컴포넌트 안의 onClick 같은 클릭 처리 함수
Model 컴포넌트의 상태, 서버 호출과 데이터 함수를 모은 lib/ 폴더, 그리고 서버

View와 Controller가 컴포넌트 하나에 같이 들어 있다. 원래 MVC는 셋을 떼어 놓는 게 목적이었는데, React는 「화면 조각 하나에 필요한 보기와 입력 처리는 같이 둔다」 쪽을 골랐다.

이게 FSD 글에서 MVC를 넘긴 이유를 다시 설명해 줬다. 그때는 「MVC는 누가 누구를 부르나에 답이 없다」고만 적었다. 이제 이유가 보인다. React 앱에서는 MVC의 V와 C 경계가 컴포넌트 안에 녹아 있다. FSD 글에서 찾던 「방향」은 「어느 파일이 어느 파일을 import 해서 불러 쓸 수 있나」였다. MVC로 그걸 정하려면 V 파일, C 파일, M 파일로 나눈 뒤 「V는 M을 읽기만 하고, C는 M을 고친다」 같은 방향을 걸어야 한다. 그런데 React는 V와 C가 한 파일(컴포넌트)에 들어 있어서, 애초에 그 칸으로 파일이 나뉘지 않는다. Model 쪽(lib/)은 따로 떼어 있으니 「컴포넌트는 lib/을 부른다」는 방향 하나는 정할 수 있다. 하지만 그건 경계 하나일 뿐이다. FSD에서 정하려던 것은 수십 개 컴포넌트끼리 「이 컴포넌트가 저 컴포넌트를 불러도 되나」였고, MVC로 보면 그 컴포넌트들은 전부 같은 「V+C」 칸이라 서로 사이에 방향을 줄 기준이 없다.


우리 ReviewActions.js에는 원래 MVC의 알림 장치가 그대로 들어 있었다

마지막 확인 문제는 앞에서 말한 우리 client 앱의 실제 파일 components/ReviewActions.js(후기 좋아요·공유 버튼)를 MVC로 뜯어 보는 것이었다. 하트 모양을 그리는 JSX는 V, 하트를 눌렀을 때 실행되는 함수는 C, 서버에 좋아요를 보내는 lib/reviews.js의 likeReview()는 M, 「내가 눌렀는지」를 기억하는 값도 M이다. 넷 다 맞혔다.

그런데 문제를 낸 쪽이 파일을 열어 보니 문제가 실제 코드와 달랐다. 문제는 「내가 눌렀는지」를 React의 기본 상태(useState)로 기억한다고 설명했는데, 실제 코드는 그렇지 않았다. 대신 파일 맨 위에 이런 코드가 있었다.

// components/ReviewActions.js 맨 위 (실제 코드, 주석은 줄임)
const listeners = new Set();   // 알림을 듣겠다고 등록한 화면들
const counts = new Map();      // 후기별 최신 좋아요 수

function subscribe(onChange) {             // 「나도 알림 받을래」 등록
  listeners.add(onChange);
  return () => listeners.delete(onChange); // 등록을 푸는 함수를 돌려준다
}

function notify() {                        // 「값 바뀌었어」 등록된 전부에게 알림
  listeners.forEach(onChange => onChange());
}

한 줄씩 보면 이렇다.

  • listeners: 알림을 받겠다고 등록한 함수들을 모아 두는 집합이다. 자바의 Set과 같다
  • counts: 후기 번호마다 최신 좋아요 수를 적어 두는 Map이다
  • subscribe(onChange): 「값이 바뀌면 onChange를 불러 줘」라고 등록한다. 이렇게 알림을 받겠다고 등록하는 것을 「구독한다」고 한다. () => listeners.delete(onChange)는 「부르면 등록을 지우는 함수」를 짧게 쓴 화살표 함수이고, subscribe는 이 함수를 값처럼 돌려준다. 이 함수들을 직접 부르는 것은 우리 코드가 아니라 React다. 컴포넌트가 화면에 나타날 때 React가 subscribe를 불러 등록하고, 화면에서 사라질 때 돌려받은 함수를 불러 등록을 지운다. 그 연결은 아래 useSyncExternalStore가 한다
  • notify() 안의 onChange => onChange()도 화살표 함수다. 「등록된 함수를 하나씩 받아 부른다」는 뜻이다
  • notify(): 등록된 함수를 전부 하나씩 부른다. 「바뀌었다」는 알림이다

이게 원래 MVC의 「Model이 바뀌면 View들에 알린다」 그 자체다. 파일 맨 위 주석에 이유도 적혀 있었다.

같은 후기가 한 화면에 두 번 그려져도(compact/full) 하트와 숫자가 같이 움직입니다.

후기 상세 페이지는 ReviewActions를 두 번 그린다. 사진 아래에 공유 버튼만 있는 작은 것(compact) 하나, 본문 끝에 좋아요와 공유가 다 있는 것(full) 하나다. 두 컴포넌트가 같은 바깥 보관함(위의 listeners와 counts)을 구독하고 같은 값을 읽는다. listeners와 counts는 컴포넌트 함수 바깥, 파일 맨 위에 선언되어 있어서 컴포넌트를 몇 번 그려도 한 벌만 생긴다. 자바로 치면 객체마다 생기는 필드가 아니라 static 필드 하나를 모두가 같이 쓰는 것과 같다. 「바깥」은 React가 관리하는 상태의 바깥이라는 뜻이다.

처음에 나는 이걸 보고 「공부를 시작할 때 지어낸 장면, 좋아요 숫자가 두 군데 떠 있다가 우리 앱에 실제로 있었다」고 받아들였다. 글을 쓰며 화면 코드를 끝까지 읽어 보니 그건 과장이었다. compact는 공유 버튼만 그리고, 하트와 숫자는 full에만 보인다. 지금 화면에 좋아요 숫자가 두 곳 뜨지는 않는다. 정확히는 한 값을 컴포넌트 둘이 함께 구독하고 있고, 그중 하나만 그 값을 화면에 보여준다. 주석이 말하는 대로, 나중에 compact에도 하트를 보이게 바꾸면 그날부터 두 곳이 같이 움직이도록 미리 만들어 둔 것이다. 그래서 이 파일은 원래 MVC의 알림 장치를 갖추고 있다.

화면 쪽에서는 React가 제공하는 useSyncExternalStore로 이 알림을 듣는다. 「내가 눌렀는지」와 좋아요 수를 각각 이렇게 읽는다. reviewId(후기 번호)와 likeCount(페이지를 만들 때 서버에서 받아 둔 처음 좋아요 수)는 부모인 후기 상세 페이지가 ReviewActions를 부를 때 넘겨주는 값(props)이다. siteUtils는 lib/site-utils.js에 있는 도우미 모음이고, isReviewLiked()는 브라우저 저장 공간에서 「이 후기에 좋아요를 눌렀는지」를 읽어 온다.

const liked = useSyncExternalStore(
  subscribe,                                  // 이 함수로 알림에 등록한다
  () => siteUtils.isReviewLiked(reviewId),    // 지금 값을 읽는다(브라우저 안에서)
  () => false,                                // 미리 만든 HTML에 쓸 처음 값
);

const count = useSyncExternalStore(
  subscribe,
  () => (counts.has(reviewId) ? counts.get(reviewId) : likeCount),  // 받아 둔 최신 수, 없으면 처음 수
  () => likeCount,
);

React 공식 문서는 이 함수를 「컴포넌트가 바깥 저장소(React 밖에 있는 값 보관함)를 구독하게 해 주는 Hook」이라고 설명한다. 첫 번째 인자 subscribe는 콜백 하나를 받아 보관함에 등록하는 함수이고, 저장소가 바뀌면 그 콜백을 불러야 하며, 그러면 React가 값을 다시 읽고 필요하면 컴포넌트를 다시 그린다고 적혀 있다(reactjs/react.dev src/content/reference/react/useSyncExternalStore.md, 커밋 8c68ae8 판). 두 번째 인자는 지금 값을 읽는 함수이고, 세 번째 인자는 HTML을 미리 만들 때 쓰는 처음 값이다. 여기서 「서버」가 두 가지라는 점을 짚어 두자. 하나는 미리 만든 HTML 파일을 내주기만 하는 곳이고, 다른 하나는 좋아요 수를 세고 저장하는 우리 자바 백엔드다. 화면의 자바스크립트는 /api로 시작하는 주소로 백엔드에 요청을 보낸다. 이 글에서 「서버에 좋아요를 보낸다」, 「서버에서 받은 수」라고 할 때의 서버는 백엔드 쪽이다.

우리 client 앱은 정적 export라서, 사이트를 올리기 전에 한 번 돌리는 준비 작업(빌드) 때 모든 페이지의 HTML을 미리 만들어 둔다. 방문자는 그 HTML을 먼저 받고, 이어서 받은 자바스크립트가 그 HTML의 버튼에 클릭 처리 같은 동작을 연결한다(하이드레이션). 미리 HTML을 만드는 시점에는 브라우저가 없으니, 그때 쓸 값을 세 번째 인자로 따로 준다. 「내가 눌렀는지」는 브라우저의 localStorage만 알기 때문에, 미리 만든 HTML에서는 false로 두고 브라우저에서 다시 읽는다.

원래 MVC ReviewActions.js
Model counts(좋아요 수)와 localStorage에 남기는 「내가 눌렀는지」
View가 알림을 듣겠다고 등록 useSyncExternalStore(subscribe, ...)
Model이 알림 하트 클릭 처리 함수 안의 notify()
Controller 하트 클릭 처리 함수 onLike: 서버에 요청하고 Model을 고친다

「규칙은 Model, 진짜 값은 한 곳」이라는 판단도 코드에 그대로 있었다.

// 서버가 반영한 수를 그대로 씁니다. 여기서 1을 더하고 빼면
// 다른 사람이 누른 만큼 어긋납니다.
const updated = next ? await likeReview(reviewId) : await unlikeReview(reviewId);
counts.set(reviewId, updated);
siteUtils.setReviewLiked(reviewId, next);
notify();

next는 바로 앞에서 const next = !liked;로 만든 값으로, 지금 상태의 반대(누르기 전이면 「누른다」, 누른 뒤면 「취소한다」)다. await는 서버 응답이 올 때까지 기다렸다가 그 결과를 updated에 담는다는 뜻이다. 그 결과를 counts에 적고, 「내가 눌렀는지」를 브라우저 저장 공간에 적은 뒤(setReviewLiked), notify()로 구독한 컴포넌트 전부에게 알린다. 알림을 받은 React는 두 useSyncExternalStore의 두 번째 인자를 다시 불러 liked와 count를 새로 읽고, 값이 달라졌으면 화면을 다시 그린다. 클릭부터 화면이 바뀌기까지가 이 네 줄로 이어진다.

화면에서 +1 하지 않고 서버가 돌려준 값을 그대로 쓴다. 이유는 처음 숫자에 있다. 화면의 처음 좋아요 수 likeCount는 빌드 때 찍어 둔 옛날 값이다. 그 뒤로 다른 사람들이 누른 만큼은 들어 있지 않다. 거기에 화면에서 1을 더하면 「옛날 값 + 1」이 되어 실제 수와 어긋난다. 서버가 돌려주는 값은 다른 사람들이 누른 것까지 다 센 지금 값이다. 진짜 Model은 서버이고, 화면 쪽 counts는 서버 값을 받아 적어 두는 사본이다. 다른 사람도 좋아요를 누르니 「값이 바뀌는 길이 여러 개」이고, 그래서 「좋아요 수는 서버가 센 값이 정답이다」라는 판단을 화면에서 하지 않고 서버 한 곳에 맡겼다. 「100개 넘으면 금색」 같은 규칙을 Model 한 곳에 둔 것과 같은 판단이다.

React가 MVC를 대신했다고 해서 MVC의 문제가 사라진 건 아니었다. React의 기본 상태(useState)는 그걸 만든 컴포넌트 하나에 딸린다. 앞에서 본 대로 부모는 자식을 부를 때 props로 값을 넘겨줄 수 있다. 그래서 같은 값을 자식 두 개가 쓰게 하는 React의 기본 방법은, 상태를 둘의 공통 부모가 들고 있다가 두 자식에게 props로 똑같이 넘겨주는 것이다. 부모의 상태가 바뀌면 두 자식이 같이 다시 그려진다.

그런데 우리 앱에서는 그 방법을 쓸 수 없었다. 두 ReviewActions의 공통 부모는 후기 상세 페이지인데, 이 페이지는 Next.js의 서버 컴포넌트다. 서버 컴포넌트는 HTML을 미리 만들 때(우리 앱은 빌드 때) 한 번만 실행되고, 그 코드는 브라우저로 보내지지 않는다. 브라우저에는 결과 HTML만 간다. 그 안에 든 클라이언트 컴포넌트(ReviewActions)는 다르게 다룬다. Next.js는 빌드할 때 클라이언트 컴포넌트 코드만 따로 모아 자바스크립트 파일로 만들고, HTML에는 「여기에 이 컴포넌트가 붙는다」는 표시를 남긴다. 브라우저는 HTML을 받은 뒤 그 자바스크립트를 받아 표시된 자리에 붙인다(앞에서 본 하이드레이션). 그래서 부모 페이지의 코드는 브라우저에 없지만, 자식인 ReviewActions의 코드는 있다. 그러니 브라우저에서 좋아요를 누를 때 이 페이지 코드는 브라우저에 아예 없고, 바뀌는 상태를 들고 있을 수가 없다. 반대로 ReviewActions처럼 파일 맨 위에 'use client'를 적은 클라이언트 컴포넌트는 브라우저에서도 돈다. 파일 주석의 「상세 페이지가 서버 컴포넌트로 남도록 클라이언트 잎으로 둡니다」는, 페이지 전체를 브라우저로 보내지 않으려고 브라우저에서 돌아야 하는 작은 조각(잎)만 클라이언트 컴포넌트로 뗐다는 뜻이다.

「그럼 페이지에도 'use client'를 적어 공통 부모가 상태를 들게 하면 되지 않나」 싶을 수 있다. 그러면 후기 본문을 그리는 코드까지 페이지 전체와 그 페이지가 불러 쓰는 것들이 자바스크립트로 브라우저에 실려 간다. 방문자는 화면을 보기 전에 받아야 할 양이 늘고, 좋아요 버튼 하나 때문에 페이지 전체가 무거워진다. 그 대가를 치르지 않으려고 페이지는 서버 컴포넌트로 두었다. 그래서 공통 부모 대신 React 바깥에 보관함을 하나 두고, 두 컴포넌트가 그걸 구독하게 만들었다. 그게 원래 MVC의 알림 장치를 작게 다시 만든 모양이다.


판단 기준 정리

질문 답 근거
MVC는 무엇을 풀려고 나왔나 한 값이 여러 곳에 보일 때 화면끼리 어긋나는 문제 「보여주는 곳 × 바뀌는 길」만큼 손으로 챙겨야 했다
MVC의 핵심 장치는 Model이 바뀌면 View들에 알린다 곳 하나당 등록 한 번으로 줄어든다
규칙은 어디에 두나 Model 값이 어떤 길로 바뀌든 Model은 거친다. Controller는 거치지 않는 길이 있다
Spring MVC는 원래 MVC인가 아니다. 이름과 역할 나누기만 빌렸다 Model이 LinkedHashMap이고, 응답 뒤 사라져 알릴 대상이 없다
프론트엔드는 왜 MVC 문제를 다시 만나나 화면이 계속 떠 있다 요청 없이 값이 바뀌고 여러 곳을 다시 그려야 한다
큰 MVC 앱은 왜 버려졌나 알림이 연쇄로 번져 추적할 수 없다 Flux 문서가 「tangled weave of data flow」라고 적었다
React에서 MVC는 어디 있나 V와 C가 컴포넌트 하나에 합쳐졌다 그래서 MVC의 칸으로는 파일 사이의 방향을 정할 수 없다
알림 장치는 언제 다시 필요한가 한 값을 컴포넌트 여럿이 함께 볼 때 ReviewActions.js가 subscribe · notify로 직접 만들었다

이 정리에 닿기까지

시작은 FSD 글에서 MVC를 한 줄로 넘긴 것이었다. 「누가 누구를 부르나에 답이 없다」고 적었지만, MVC가 원래 무엇을 풀려고 나왔는지는 설명하지 못했다.

MVC를 장면부터 다시 봤다. 좋아요 숫자가 두 군데 떠 있는 장면을 세우니, 「값 바꾸기, 입력, 그리기가 엉키면 곳 × 길만큼 손으로 챙겨야 한다」가 보였다. 그리고 원래 MVC의 핵심이 역할 나누기보다 「Model이 바뀌면 알린다」라는 장치라는 것을 처음 알았다.

규칙을 Controller에 두었다가 틀렸다. 「100개 넘으면 금색」 판단을 하트 클릭 순간에 하면 될 것 같았다. 서버에서 값이 내려오는 길은 Controller를 거치지 않는다는 걸 보고 Model로 옮겼다. Spring에서 판단을 Service에 두는 이유와 같았다.

내가 아는 Spring MVC가 원래 MVC가 아니었다. 둘을 나란히 놓으니 Spring의 Model은 응답 한 번 쓰고 버리는 Map이었다. 소스를 열어 LinkedHashMap을 상속하는 것까지 확인했다. 알림 장치가 들어갈 자리가 없으니 이름과 역할 나누기만 남은 것이다.

브라우저 화면이 계속 떠 있다는 점에서 프론트엔드로 이어졌다. 계속 떠 있는 화면은 원래 MVC의 문제를 다시 만난다. 그런데 크게 쓰면 이번엔 알림이 번져서 추적이 안 됐다. Flux 문서를 확인해 보니, 그 문제를 설명하려고 든 예가 「안 읽은 대화 수를 두 곳에 보여주기」였다. 내가 공부하려고 지어낸 좋아요 장면과 같은 모양이었다.

React가 V와 C를 합쳤다는 데서 FSD 글의 한 줄이 설명됐다. MVC의 칸이 컴포넌트 안에 녹아 있으니, 파일 사이의 방향을 MVC로는 정할 수 없었다.

마지막으로 우리 코드에서 원래 MVC를 찾았다. 확인 문제를 내려고 ReviewActions.js를 열었는데, 문제 설명과 달리 useState가 아니라 subscribe · notify로 만든 알림 장치가 있었다. 같은 후기가 한 화면에 두 번 그려지기 때문이었다. 처음엔 「지어낸 장면이 실제 코드에 있었다」고 받아들였는데, 글을 쓰며 끝까지 읽어 보니 두 번째 컴포넌트는 숫자를 보여주지 않았다. 장면 그대로는 아니었다. 그래도 MVC가 풀던 문제가 React 앱에서도 「한 값을 여러 컴포넌트가 함께 볼 때」 그대로 다시 나온다는 것, 그리고 우리 코드가 그 자리에 알림 장치를 미리 만들어 두었다는 것은 사실이었다.


정리

  • MVC는 한 값이 여러 곳에 보일 때 화면끼리 어긋나는 문제를 풀려고 나왔다. 핵심은 역할 나누기보다 「Model이 바뀌면 View들에 알린다」는 장치다
  • 규칙은 Model에 둔다. 값이 어떤 길로 바뀌든 Model은 거치지만, Controller는 거치지 않는 길이 있다
  • Spring MVC는 알림 장치 없이 이름과 역할 나누기만 빌렸다. Model은 응답 한 번 쓰고 버리는 LinkedHashMap이다
  • 브라우저 화면은 계속 떠 있어서 MVC의 문제를 다시 만났고, 크게 쓰자 알림이 번져 추적할 수 없었다. 그 답으로 Flux(한 방향)와 React(화면은 상태의 함수)가 나왔다
  • React는 View와 Controller를 컴포넌트 하나에 합쳤다. 그래서 MVC로는 파일 사이의 방향을 정할 수 없다
  • 그래도 한 값을 여러 컴포넌트가 함께 보는 자리에서는 알림 장치가 다시 필요하다. 우리 ReviewActions.js가 그걸 subscribe · notify로 직접 만들었다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기