본문 바로가기
개발

React에 MVVM 아키텍처 적용하기

by 현명5079 2025. 7. 25.

📌 문제

단순하게 게시글 목록을 조회하는 페이지를 구현한 코드이다. 이 페이지는 다음과 같은 요구사항이 있다.

  • 게시글 목록을 보여준다.
  • 게시글 1개는 제목, 내용, 사용자 아이디, 게시글 번호가 노출된다.
  • 제목이 20자 이상이면 ... 처리를 한다.
function App() {
  const [posts, setPosts] = useState<Post[]>([]);

  //1. API를 호출한다.
  const fetchPosts = async () => {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts");
    const data = await response.json();
    setPosts(data);
  };

  useEffect(() => {
    fetchPosts();
  }, []);
  
  //3. 화면에 표시하기
  return (
    <div>
      <header>
        <h1>게시글 목록</h1>
      </header>
      <main>
        <div>
          {posts.map((post) => (
            <div key={post.id}>
              <div>
                <h3>
                  {/* 2. 20자 이상이면 20자까지만 보여주고 나머지는 ...로 표시한다. */}
                  {post.title.length > 20
                    ? post.title.substring(0, 20) + "..."
                    : post.title}
                </h3>
              </div>
              <p> {post.body}</p>
              <div>
                <span>작성자: {post.userId}</span>
                <span>게시글 #{post.id}</span>
              </div>
            </div>
          ))}
        </div>
      </main>
    </div>
  );
}
 

위 코드는 다음 3가지 과정이 한 페이지에 구현되어 있다.

1. 백엔드에서 API를 통해 목록 데이터 받아오기 ➡️ 백엔드

2. 받아온 데이터를 요구사항에 맞게 가공하기 ➡️ 기획

3. 화면에 보여주기 ➡️ 디자인

프론트엔드 코드에는 백엔드, 기획, 디자인과 관련된 코드들이 같이 있다. 만약 백엔드 API에 수정사항이 있다면 1번을, 기획이 수정된다면 2번을, 디자인이 수정된다면 3번을 수정하면 된다. 간단한 화면을 구현하는 것이라면 한 페이지에 이 과정을 모두 적어도 상관없다. 하지만 코드가 길어지면 요구사항 변경 시 코드에 어떤 부분을 수정해야 하는지 찾는 것에만 굉장히 오랜 시간이 걸린다.

 

내가 입사에서 겪었던 문제도 이와 같았다. 문제가 생겼을 때 정확히 어디를 봐야 할까? 즉, '관심사의 분리'가 필요했다. 이를 해결하는 핵심은 단일 책임 원칙이다. 1개의 파일이 1개의 역할만을 하면 어디를 봐야 할지 빠르게 찾을 수 있다. 이를 MVVM 패턴과 API에 DTO 클래스를 도입하는 방식으로 해결했다.


 

📌 MVVM 이란?

MVVM은 Model, View, ViewModel로 레이어를 나누어서 역할을 부여하는 디자인 패턴이다.

  • Model : 비즈니스에 필요한 데이터와 로직이 있는 부분
  • View: 사용자가 보는 인터페이스의 코드가 작성되는 부분. html, css와 관련된 코드를 작성하게 된다.
  • View Model: Model의 데이터와 View 화면의 연결지점으로 화면과 데이터를 바인딩 하게 된다.

 

📌 적용

✅ View

function AppV2() {
  const { posts } = usePostViewModel();

  return (
    <div>
      <header>
        <h1>게시글 목록 (MVVM + Custom Hook)</h1>
      </header>
      <main>
        <div>
          {posts.map((post) => (
            <div>
              <div>
                <h3>{post.getTitle()}</h3>
              </div>
              <p>{post.getContent()}</p>
              <div>
                <span>작성자: {post.getUserId()}</span>
                <span>게시글 #{post.getId()}</span>
              </div>
            </div>
          ))}
        </div>
      </main>
    </div>
  );
}
 

먼저 해당 코드의 View 파일이다. return 이전의 코드는 usePostViewModel이라는 훅으로 모두 이동시켜서 return 이전의 코드가 줄어들었다. 즉, 디자인과 관련된 코드가 이 파일에 모여있게 된다. 그렇다면 usePostViewModel의 코드는 어떨까?

 

✅ ViewModel

export const usePostViewModel = () => {
  const [posts, setPosts] = useState<PostModel[]>([]);

  const fetchPosts = useCallback(async () => {
    try {
      const fetchedPosts = await postApi.fetchPosts();
      //view와 model을 연결
      setPosts(fetchedPosts);
    } catch (error) {
      console.error("게시글 목록 조회에 실패했습니다:", error);
    }
  }, []);

  useEffect(() => {
    fetchPosts();
  }, [fetchPosts]);

  return {
    posts,
    fetchPosts,
  };
};
 

ViewModel은 커스텀 훅으로 작성했다. Model에서 상태와 행위를 한 번에 묶기 위해 Class를 쓸 경우가 있다. 이 경우 ViewModel은 화면과 데이터를 바인딩 해주는 역할을 해야 하기 때문에 React 훅을 사용했다.

 

이 예시의 경우 ViewModel에서 API를 호출한 뒤, Post를 상태로 관리한다. 이 외에 비즈니스 로직을 ViewModel에 적어줘도 괜찮을 것 같다. 하지만 나는 ViewModel이 아닌 Model에 비즈니스 로직을 이동했다.

 

✅ Model

export default class PostModel {
  id: number;
  title: string;
  content: string;
  userId: number;

  constructor() {
    this.id = 0;
    this.title = "";
    this.content = "";
    this.userId = 0;
  }

  public getTitle(): string {
    return this.title.length > 20
      ? this.title.substring(0, 20) + "..."
      : this.title;
  }
 //...다른 Getter는 생략
}
 

Model은 데이터를 보관하고 비즈니스 로직을 담당한다. 데이터는 상태와 행위를 같이 모아두는 것이 편해서 Class로 적는 방식을 선택했다. Getter, Setter를 가지고, 해당 Model 만으로 처리 가능한 비즈니스 로직은 Model에 적을 예정이다. 만약 다른 Entity와 협업하며 생기는 비즈니스 로직은 ViewModel에 적을 것 같다.

 

제목은 20자 이내로 표시한다는 로직은 Model이 가지고 있는 title을 가공하는 것만으로 가능하기 때문에 Model에 위치시켰다. 만약 객체 리터럴 형태로 함수를 작성하게 되면 상태와 행위가 분리되기 때문에 title을 매개변수로 받아서 처리하는 함수가 만들어져야 한다. 이보다는 비즈니스 로직은 상태와 행위가 결합된 경우가 많기 때문에 같이 class로 만들었다.

 

✅ API

API 호출 코드는 백엔드의 영향을 받기 때문에 프론트엔드 코드와 분리되기를 원했다. 그래서 API 레이어에서 API를 호출하고, DTO를 Model로 바꾸어주는 역할까지 부여했다.

const postApi = {
  fetchPosts: async () => {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts");
    const data = await response.json();

    //여기서 response를 model로 변경
    const posts = data.map((post: PostsResponseType) =>
      PostsResponse.toModel(post)
    );

    if (!response.ok) {
      throw new Error("Failed to fetch posts");
    }
    return posts;
  },
};

export default class PostsResponse {
  id: number;
  title: string;
  body: string;
  userId: number;

  constructor() {
    this.id = 0;
    this.title = "";
    this.body = "";
    this.userId = 0;
  }

  static toModel(response: PostsResponse): PostModel {
    const postModel = new PostModel();
    postModel.id = response.id;
    postModel.title = response.title;
    //body를 content로 변경
    postModel.content = response.body;
    postModel.userId = response.userId;
    return postModel;
  }
}
 

 


‼️ 정리

MVVM 패턴과 API 레이어를 두면서 관심사를 분리했다. 각 레이어가 맡은 역할은 다음과 같다

 

View html, css로 디자인과 관련된 영역
ViewModel View와 Model을 바인딩 해주는 역할
2개 이상의 Model이 협력해야 하는 비즈니스 로직을 담당
Model 데이터 저장
혼자서 처리할 수 있는 비즈니스 로직 담당
API API를 호출하고, DTO를 Model로 전환

📌 참고 자료

  • 우아한 타입스크립트