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(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => {
const formatted = {
...data,
fullName: `${data.firstName} ${data.lastName}`,
joinDate: new Date(data.createdAt).toLocaleDateString(),
};
setUser(formatted);
setLoading(false);
});
}, []);
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 |
| View | UI 렌더링만 | 컴포넌트 (props만 받아서 그림) |
실제 적용 예시
1. Model (데이터 & API)
export interface User {
id: number;
firstName: string;
lastName: string;
email: string;
createdAt: string;
}
export interface UserViewModel {
id: number;
fullName: string;
email: string;
joinDate: string;
}
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)
import { useState, useEffect, useCallback } from 'react';
import { User, UserViewModel, fetchUser, updateUser } from '../models/user';
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 (컴포넌트)
import { useUserViewModel } from '../viewmodels/useUserViewModel';
interface Props {
userId: number;
}
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. 테스트가 쉬움
import { renderHook, waitFor } from '@testing-library/react';
import { useUserViewModel } from './useUserViewModel';
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. 재사용 가능
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
| 항목 | 일반 React | MVVM |
|---|
| 코드 위치 | 컴포넌트에 모든 것 | 레이어별 분리 |
| 테스트 | 컴포넌트 전체 렌더링 필요 | 로직만 단위 테스트 |
| 재사용 | 어려움 | 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에서 같은 로직 재사용 | ✅ 추천 |
| 빠른 프로토타이핑 | ❌ 속도 저하 |
참고 자료