MVVM 패턴

코헤·2026년 2월 16일

cohiChat

목록 보기
8/10

MVVM 패턴 (React)

Model-View-ViewModel: 데이터 로직과 UI를 분리하는 아키텍처 패턴

참고: MVVM in React - Medium


MVVM이란?

기존 React 코드의 문제

// ❌ 모든 것이 한 컴포넌트에 섞여 있음
function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // API 호출 (Model)
    fetch('/api/user')
      .then(res => res.json())
      .then(data => {
        // 데이터 변환 로직 (ViewModel)
        const formatted = {
          ...data,
          fullName: `${data.firstName} ${data.lastName}`,
          joinDate: new Date(data.createdAt).toLocaleDateString(),
        };
        setUser(formatted);
        setLoading(false);
      });
  }, []);

  // UI 렌더링 (View)
  if (loading) return <Spinner />;
  return <div>{user.fullName}</div>;
}

문제점:

  • API 로직, 데이터 변환, UI가 한 곳에 섞임
  • 테스트하기 어려움
  • 재사용 불가

MVVM 구조

┌─────────────────────────────────────────────────────┐
│                      View                           │
│            (React 컴포넌트 - UI만)                    │
│                        │                            │
│                        ▼                            │
│                   ViewModel                         │
│         (Custom Hook - 상태 & 비즈니스 로직)          │
│                        │                            │
│                        ▼                            │
│                     Model                           │
│              (API, 데이터 타입)                      │
└─────────────────────────────────────────────────────┘
레이어역할React에서 구현
Model데이터 구조, API 통신타입, API 함수
ViewModel상태 관리, 비즈니스 로직, 데이터 변환Custom Hook
ViewUI 렌더링만컴포넌트 (props만 받아서 그림)

실제 적용 예시

1. Model (데이터 & API)

// models/user.ts

// 데이터 타입 정의
export interface User {
  id: number;
  firstName: string;
  lastName: string;
  email: string;
  createdAt: string;
}

export interface UserViewModel {
  id: number;
  fullName: string;
  email: string;
  joinDate: string;
}

// API 함수
export async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error('Failed to fetch user');
  return response.json();
}

export async function updateUser(id: number, data: Partial<User>): Promise<User> {
  const response = await fetch(`/api/users/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  return response.json();
}

2. ViewModel (Custom Hook)

// viewmodels/useUserViewModel.ts
import { useState, useEffect, useCallback } from 'react';
import { User, UserViewModel, fetchUser, updateUser } from '../models/user';

// Model → ViewModel 변환 함수
function toViewModel(user: User): UserViewModel {
  return {
    id: user.id,
    fullName: `${user.firstName} ${user.lastName}`,
    email: user.email,
    joinDate: new Date(user.createdAt).toLocaleDateString('ko-KR'),
  };
}

export function useUserViewModel(userId: number) {
  const [user, setUser] = useState<UserViewModel | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // 데이터 로드
  useEffect(() => {
    setLoading(true);
    fetchUser(userId)
      .then(data => {
        setUser(toViewModel(data));
        setError(null);
      })
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [userId]);

  // 액션: 이메일 변경
  const changeEmail = useCallback(async (newEmail: string) => {
    if (!user) return;
    try {
      const updated = await updateUser(user.id, { email: newEmail });
      setUser(toViewModel(updated));
    } catch (err) {
      setError('이메일 변경 실패');
    }
  }, [user]);

  return {
    // 상태
    user,
    loading,
    error,
    // 액션
    changeEmail,
  };
}

3. View (컴포넌트)

// views/UserProfile.tsx
import { useUserViewModel } from '../viewmodels/useUserViewModel';

interface Props {
  userId: number;
}

// View는 UI만 담당 - 로직 없음!
export function UserProfile({ userId }: Props) {
  const { user, loading, error, changeEmail } = useUserViewModel(userId);

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>에러: {error}</div>;
  if (!user) return null;

  return (
    <div className="user-profile">
      <h1>{user.fullName}</h1>
      <p>이메일: {user.email}</p>
      <p>가입일: {user.joinDate}</p>
      <button onClick={() => changeEmail('new@email.com')}>
        이메일 변경
      </button>
    </div>
  );
}

MVVM의 장점

1. 테스트가 쉬움

// ViewModel 테스트 - UI 없이 로직만 테스트
import { renderHook, waitFor } from '@testing-library/react';
import { useUserViewModel } from './useUserViewModel';

// API Mock
vi.mock('../models/user', () => ({
  fetchUser: vi.fn().mockResolvedValue({
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    email: 'john@test.com',
    createdAt: '2024-01-01',
  }),
}));

test('fullName을 올바르게 조합한다', async () => {
  const { result } = renderHook(() => useUserViewModel(1));

  await waitFor(() => {
    expect(result.current.user?.fullName).toBe('John Doe');
  });
});

2. 재사용 가능

// 같은 ViewModel을 다른 View에서 재사용
function UserCard({ userId }: Props) {
  const { user } = useUserViewModel(userId);
  return <div className="card">{user?.fullName}</div>;
}

function UserHeader({ userId }: Props) {
  const { user } = useUserViewModel(userId);
  return <header>{user?.fullName}님 환영합니다</header>;
}

3. 관심사 분리

변경 사항수정 위치
API 엔드포인트 변경Model만
날짜 포맷 변경ViewModel만
버튼 스타일 변경View만

MVVM vs 일반 React

항목일반 ReactMVVM
코드 위치컴포넌트에 모든 것레이어별 분리
테스트컴포넌트 전체 렌더링 필요로직만 단위 테스트
재사용어려움ViewModel 재사용 가능
복잡도낮음높음 (파일 수 증가)
적합한 규모소규모중~대규모

Bulletproof + MVVM 조합

Bulletproof의 feature 구조와 MVVM을 함께 쓰면:

features/calendar/
├── api/                 # Model (API)
│   └── bookings.ts
├── types/               # Model (타입)
│   └── index.ts
├── hooks/               # ViewModel
│   ├── useBookings.ts
│   └── useCalendarNavigation.ts
├── components/          # View
│   ├── CalendarBody.tsx
│   └── BookingForm.tsx
└── index.ts

언제 MVVM을 쓸까?

상황MVVM 추천도
간단한 CRUD❌ 오버엔지니어링
복잡한 비즈니스 로직✅ 필수
테스트 중요한 프로젝트✅ 추천
여러 View에서 같은 로직 재사용✅ 추천
빠른 프로토타이핑❌ 속도 저하

참고 자료

profile
하이하이

0개의 댓글