프론트엔드 아키텍처 패턴 (MVC / MVP / MVVM / Flux)

chaen·2025년 9월 16일

개발지식

목록 보기
5/6
post-thumbnail

소프트웨어 아키텍처 패턴은 UI, 상태, 비즈니스 로직을 분리하여 유지보수성과 확장성을 높이는 것이 공통 목표입니다. 하지만 각 패턴은 관계 구조, 데이터 흐름, 역할 분담 방식이 다르기 때문에 프로젝트 특성에 맞는 선택이 중요합니다.

🔄 MVC (Model-View-Controller)

가장 고전적이고 널리 사용되는 패턴

구조

사용자 입력 → Controller → Model ↔ View

역할 분담

  • Model: 데이터와 비즈니스 로직 관리 (DB, API)
  • View: 사용자 인터페이스 표시
  • Controller: 사용자 입력을 받아 Model과 View를 연결

특징 및 활용

  • Controller가 View와 Model을 모두 알고 직접 업데이트
  • 서버 렌더링 웹앱(Rails, Spring MVC, Django)에 최적화
  • 양방향 데이터 흐름으로 인한 복잡성 증가 가능

✅ 장점

  • 단순하고 이해하기 쉬운 구조
  • 서버 사이드 렌더링에 이상적
  • 작은 규모의 프로젝트에 적합

❌ 단점

  • View와 Controller 간 강결합 발생
  • 복잡한 양방향 데이터 흐름으로 디버깅 어려움
  • 현대 프론트엔드(React, Vue)에서는 단독으로 거의 사용되지 않음

비유: 블로그 댓글 시스템 📝

  1. 사용자: 댓글을 입력하고 '등록' 버튼을 클릭합니다. (View에서 이벤트 발생)
  2. Controller: 버튼 클릭 이벤트를 받아 댓글 내용과 게시글 ID를 가져옵니다.
  3. Controller: CommentModel에 새로운 댓글 데이터 저장을 요청합니다. (DB에 저장)
  4. Model: 데이터베이스에 댓글을 성공적으로 저장하고, 저장된 댓글 정보를 Controller에 반환합니다.
  5. Controller: 업데이트된 댓글 목록을 Model에서 다시 가져와 View에 전달하며, 화면을 새로 렌더링하도록 지시합니다.

예시

<h3>MVC</h3>
<input id="mvc-input" placeholder="할 일 입력" />
<ul id="mvc-list"></ul>

<script>
/** Model: 데이터 + 비즈니스 로직 */
const Model = {
  todos: [],
  add(text) { this.todos.push({ text, done: false }); },
  toggle(i) { this.todos[i].done = !this.todos[i].done; }
};

/** View: 화면 그리기만 담당 */
const View = {
  el: {
    input: document.getElementById('mvc-input'),
    list: document.getElementById('mvc-list')
  },
  render(todos) {
    this.el.list.innerHTML = todos.map((t, i) => `
      <li>
        <label>
          <input type="checkbox" data-i="${i}" ${t.done ? 'checked' : ''}/>
          ${t.text}
        </label>
      </li>
    `).join('');
  }
};

/** Controller: 사용자 입력 수신 → Model 변경 → View 갱신 */
const Controller = {
  init() {
    View.el.input.addEventListener('keyup', (e) => {
      if (e.key === 'Enter' && e.target.value.trim()) {
        Model.add(e.target.value.trim());
        e.target.value = '';
        View.render(Model.todos);
      }
    });
    View.el.list.addEventListener('change', (e) => {
      const i = Number(e.target.dataset.i);
      Model.toggle(i);
      View.render(Model.todos);
    });
    View.render(Model.todos);
  }
};

Controller.init();
</script>

🎯 MVP (Model-View-Presenter)

MVC의 단점을 보완해 View를 더 얇게 만든 구조

구조

사용자 입력 → View → Presenter → Model
            └───── 데이터 갱신 ─────┘

핵심 차이점

  • Presenter: Controller보다 주도적 역할, View와 Model 사이의 모든 흐름을 담당
  • View: 단순히 UI를 보여주고 이벤트를 위임하는 수동적 역할

✅ 장점

  • View 테스트가 용이함
  • View와 Model의 직접 결합 최소화
  • 명확한 관심사 분리

❌ 단점

  • Presenter 코드가 비대해져 God Object 위험
  • 화면마다 Presenter가 1:1 대응되어 코드베이스 증가
  • 유지보수 시 Presenter 분리 전략 필요

주요 활용처

  • 안드로이드 네이티브 앱 (특히 구형)
  • 데스크톱 UI 애플리케이션

비유: 계산기 🧮

  • View: 숫자 버튼(0-9), 연산자 버튼(+, -, *), 디스플레이 화면으로 구성됩니다. View는 "사용자가 '5' 버튼을 눌렀다" 또는 "'+' 버튼을 눌렀다"는 사실을 Presenter에 전달할 뿐, '5' 다음에 '+'를 누르면 어떻게 해야 하는지는 전혀 모릅니다.

  • Presenter: View로부터 이벤트를 받아 모든 계산 로직을 처리합니다. "현재 입력 값은 '5'이고, 다음 연산자는 '+'이다"와 같은 상태를 내부적으로 관리합니다. 계산이 끝나면 View에게 "결과 화면에 '12'를 표시해"라고 직접 지시합니다.

  • Model: (선택적으로) 계산 기록을 저장하는 역할을 할 수 있습니다.

예시

<h3>MVP</h3>
<input id="mvp-input" placeholder="할 일 입력" />
<ul id="mvp-list"></ul>

<script>
/** Model */
const mvpModel = {
  todos: [],
  add(text) { this.todos.push({ text, done: false }); },
  toggle(i) { this.todos[i].done = !this.todos[i].done; }
};

/** View: 이벤트만 위임 + Presenter가 시키는 대로 그리기 */
function createMvpView() {
  const input = document.getElementById('mvp-input');
  const list = document.getElementById('mvp-list');
  let presenter = null; // 의존성 역전: 실제 로직은 Presenter

  return {
    bind(p) { presenter = p; },
    onInit() {
      input.addEventListener('keyup', (e) => {
        if (e.key === 'Enter' && e.target.value.trim()) {
          presenter.handleAdd(e.target.value.trim());
          e.target.value = '';
        }
      });
      list.addEventListener('change', (e) => {
        presenter.handleToggle(Number(e.target.dataset.i));
      });
    },
    render(todos) {
      list.innerHTML = todos.map((t, i) => `
        <li>
          <label>
            <input type="checkbox" data-i="${i}" ${t.done ? 'checked' : ''}/>
            ${t.text}
          </label>
        </li>
      `).join('');
    }
  };
}

/** Presenter: View 이벤트 처리, Model 조작, View 업데이트 */
function createPresenter(view, model) {
  return {
    start() { view.render(model.todos); },
    handleAdd(text) {
      model.add(text);
      view.render(model.todos);
    },
    handleToggle(i) {
      model.toggle(i);
      view.render(model.todos);
    }
  };
}

const mvpView = createMvpView();
const presenter = createPresenter(mvpView, mvpModel);
mvpView.bind(presenter);
mvpView.onInit();
presenter.start();
</script>

🔗 MVVM (Model-View-ViewModel)

양방향 바인딩을 통해 View 코드를 최소화한 구조

구조

사용자 입력 ↔ View ↔ ViewModel ↔ Model

핵심 메커니즘

  • ViewModel: UI 상태를 Observable로 관리
  • Data Binding: View 설정만으로 데이터 변경 시 UI 자동 갱신
  • 반응형 UI: 상태 변화에 따른 실시간 UI 업데이트

✅ 장점

  • View 로직이 거의 불필요 → 높은 생산성
  • 단위 테스트 용이성
  • 선언적 UI 구현 가능

❌ 단점

  • 과도한 데이터 바인딩 시 추적 어려움
  • 대규모 애플리케이션에서 디버깅 복잡
  • 간단한 앱에는 과도한 설정

주요 활용처

  • Vue.js, Angular (자연스러운 양방향 바인딩)
  • WPF, SwiftUI, Jetpack Compose
  • 실시간 UI 업데이트가 중요한 앱

비유: 실시간 유효성 검사가 포함된 회원가입 폼 ✅

  • View: 이메일, 비밀번호, 비밀번호 확인을 위한 <input> 필드와 '가입하기' <button>으로 구성됩니다. 각 입력 필드는 ViewModel의 상태와 양방향으로 바인딩되어 있습니다.

  • ViewModel: email, password, passwordConfirm 같은 상태 값을 가집니다. 또한, "비밀번호가 8자 이상인가?", "두 비밀번호가 일치하는가?" 와 같은 유효성 검사 로직과, 모든 조건이 충족되었을 때만 true가 되는 isFormValid 같은 **계산된 상태(Computed State)**를 가집니다.

  • Model: (필요하다면) 최종적으로 유효한 데이터를 서버로 전송하는 로직을 담당합니다.

예시

<h3>MVVM</h3>
<input id="mvvm-input" placeholder="할 일 입력" />
<ul id="mvvm-list"></ul>

<script>
/** ViewModel: 상태를 들고 있고, 변경되면 자동 렌더 트리거 */
const vmState = { todos: [] };

/** 아주 단순한 반응성: Proxy로 set을 가로채 렌더 호출 */
const vm = new Proxy(vmState, {
  set(target, key, value) {
    target[key] = value;
    render();
    return true;
  }
});

function render() {
  const list = document.getElementById('mvvm-list');
  list.innerHTML = vm.todos.map((t, i) => `
    <li>
      <label>
        <input type="checkbox" data-i="${i}" ${t.done ? 'checked' : ''}/>
        ${t.text}
      </label>
    </li>
  `).join('');
}

document.getElementById('mvvm-input').addEventListener('keyup', (e) => {
  if (e.key === 'Enter' && e.target.value.trim()) {
    // 배열 자체를 바꿔야 Proxy set 트랩을 태움 (불변 업데이트)
    vm.todos = [...vm.todos, { text: e.target.value.trim(), done: false }];
    e.target.value = '';
  }
});

document.getElementById('mvvm-list').addEventListener('change', (e) => {
  const i = Number(e.target.dataset.i);
  vm.todos = vm.todos.map((t, idx) => idx === i ? { ...t, done: !t.done } : t);
});

render();
</script>

⚡ Flux (단방향 데이터 흐름)

React 생태계에서 확립된 예측 가능한 상태 관리 패턴

구조

Action → Dispatcher → Store → View
   ↑                           ↓
   └─────── 사용자 이벤트 ────────┘

핵심 컴포넌트

  • Action: 상태 변화 요청을 나타내는 객체 (What happened?)
  • Dispatcher: 액션을 받아 적절한 Store로 전달
  • Store: 실제 상태와 비즈니스 로직 관리
  • View: 상태를 읽어 렌더링, 액션 발생시킴

핵심 철학: 단방향 데이터 흐름

  • 데이터는 항상 한 방향으로만 흐름
  • 상태 변화의 예측 가능성 보장
  • 사이드 이펙트와 버그 최소화

✅ 장점

  • 높은 예측 가능성: 상태 변화 추적 용이
  • 디버깅 친화적: Time-travel debugging 가능
  • 대규모 확장성: 복잡한 앱에서도 안정적
  • 테스트 용이성: 각 부분을 독립적으로 테스트

❌ 단점

  • 보일러플레이트 증가: 액션, 리듀서, 스토어 등 추가 코드
  • 학습 곡선: 초보자에게는 개념적 복잡성
  • 간단한 앱에는 과도함: 작은 프로젝트에 오버엔지니어링

Flux 구현체

  • Redux: 가장 성숙하고 생태계가 풍부
  • Zustand: 간단하고 가벼운 상태 관리
  • Recoil: Facebook에서 개발, 더 세밀한 상태 관리

비유: 여러 곳에서 상태를 공유하는 쇼핑몰 장바구니 🛒

  1. View (상품 목록): 사용자가 '장바구니 담기' 버튼을 클릭합니다.
  2. Action: { type: 'ADD_TO_CART', payload: { productId: 'abc', quantity: 1 } } 객체를 생성하여 Dispatcher에 보냅니다.
  3. Dispatcher: 이 Action을 모든 Store에 전달합니다.
  4. Store (CartStore): Action을 받고, 내부 상태(장바구니 목록)를 업데이트합니다.
  5. View (헤더의 미니 카트, 장바구니 페이지): Store의 변경을 감지하고, 스스로 화면을 다시 렌더링하여 "상품 갯수: 3개"와 같이 업데이트된 내용을 표시합니다.

예시

<h3>Flux</h3>
<input id="flux-input" placeholder="할 일 입력" />
<ul id="flux-list"></ul>

<script>
/** Store: 상태 + 구독 + dispatch */
const store = {
  state: { todos: [] },
  listeners: [],
  subscribe(fn) { this.listeners.push(fn); },
  dispatch(action) {
    this.state = reducer(this.state, action);
    this.listeners.forEach(fn => fn(this.state));
  }
};

/** Reducer: 순수 함수로 상태 전이 정의 */
function reducer(state, action) {
  switch (action.type) {
    case 'ADD':
      return { todos: [...state.todos, { text: action.text, done: false }] };
    case 'TOGGLE':
      return {
        todos: state.todos.map((t, i) =>
          i === action.index ? { ...t, done: !t.done } : t
        )
      };
    default:
      return state;
  }
}

/** View: 상태를 그리기만, 변경은 항상 dispatch로 */
function renderFlux(nextState = store.state) {
  const list = document.getElementById('flux-list');
  list.innerHTML = nextState.todos.map((t, i) => `
    <li>
      <label>
        <input type="checkbox" data-i="${i}" ${t.done ? 'checked' : ''}/>
        ${t.text}
      </label>
    </li>
  `).join('');
}

// 이벤트 → 항상 action dispatch
document.getElementById('flux-input').addEventListener('keyup', (e) => {
  if (e.key === 'Enter' && e.target.value.trim()) {
    store.dispatch({ type: 'ADD', text: e.target.value.trim() });
    e.target.value = '';
  }
});
document.getElementById('flux-list').addEventListener('change', (e) => {
  store.dispatch({ type: 'TOGGLE', index: Number(e.target.dataset.i) });
});

// 구독하여 자동 렌더
store.subscribe(renderFlux);
renderFlux();
</script>

📊 패턴 비교

구분MVCMVPMVVMFlux
중간 계층 이름ControllerPresenterViewModelStore (Dispatcher + Reducer 포함)
기본 패턴전통 서버 사이드 아키텍처구형 모바일/데스크톱 UI현대적 반응형 프론트엔드대규모 React SPA 상태 관리
View가 하는 일사용자 입력을 Controller에 전달하고, Controller 지시에 따라 직접 렌더링 또는 Model 변화를 관찰해 반영이벤트를 Presenter에게 위임. Presenter가 주는 데이터만 표시ViewModel의 상태를 선언적으로 구독. 상태 변경 시 자동 UI 반영Store 상태를 읽고 렌더. 모든 상태 변경은 Action dispatch 후 자동 렌더
중간 계층의 역할Model과 View를 모두 알고 직접 제어 (데이터 변경·화면 갱신)View와 Model 사이의 모든 흐름을 전담, View 업데이트까지 지시UI 상태·비즈니스 로직을 상태 중심으로 관리, Model과 동기화Action을 받아 순수 함수(reducer) 로 상태를 갱신하고, 변경을 View에 단방향으로 전파
상태 보관거의 없음 (요청마다 생성)Presenter 내부 로컬 상태(화면 단위)ViewModel이 장기적 UI 상태를 보유Store가 앱의 단일 진실(Single Source of Truth)
데이터 흐름View ↔ Controller ↔ Model (양방향 가능)View → Presenter ↔ Model → Presenter → View (Presenter가 전과정 지휘)View ↔ ViewModel ↔ Model (양방향 자동 바인딩)View → Action → Store → View (단방향 고정)
View 업데이트 방식render(template, data)명령형 직접 호출Presenter가 View.render()를 직접 호출속성 변경만으로 자동 반응Store 구독으로 자동 리렌더 (예: useSelector, subscribe)
의존성Controller가 View와 Model 모두에 강하게 의존Presenter가 View 인터페이스와 Model에 모두 의존Model은 알지만 View와는 느슨하게 연결View는 Store를 구독만 하고, Action과 Store는 View에 의존하지 않음
주 사용처서버 렌더링 웹앱, 전통적 웹 서비스 (Rails, Spring MVC, Django)구형 안드로이드·데스크톱 UI, View 테스트가 중요한 앱Vue, Angular, SwiftUI, Jetpack Compose 등 현대적 반응형 UIReact + Redux/Zustand/Recoil 등 대규모 SPA·공유 상태 관리

🎯 선택 가이드

서버 중심 웹 애플리케이션

MVC → Rails, Django, Spring MVC
  • 전통적인 요청-응답 모델
  • SEO가 중요한 웹사이트
  • 서버 렌더링이 주가 되는 앱

모바일/데스크톱 네이티브 앱

MVP (구형) → MVVM (현대적)
  • 구형 안드로이드: MVP
  • 현대적 네이티브: MVVM (SwiftUI, Jetpack Compose)

현대적 프론트엔드 SPA

소규모: MVVM (Vue/Angular 내장)
대규모: Flux/Redux (React + 상태관리)

💡 실무 적용 팁

Flux 패턴을 선택해야 하는 경우

  1. 복잡한 상태 관리: 여러 컴포넌트가 공유하는 상태가 많을 때
  2. 실시간 데이터: WebSocket, SSE 등으로 실시간 업데이트가 필요할 때
  3. 디버깅 중요도: 상태 변화 추적이 매우 중요한 비즈니스 앱
  4. 팀 협업: 여러 개발자가 동시에 작업하는 대규모 프로젝트

MVVM을 선택해야 하는 경우

  1. 프레임워크 자연스러움: Vue나 Angular 사용 시
  2. 폼 중심 앱: 입력이 많고 실시간 검증이 필요한 앱
  3. 빠른 프로토타이핑: MVP 개발이나 빠른 기능 구현

🚀 트렌드와 미래

현재 프론트엔드 생태계

  • React: Flux/Redux가 표준, 최근엔 Zustand도 인기
  • Vue: 내장 MVVM + Pinia(Flux 스타일) 조합
  • Angular: 완전한 MVVM 프레임워크
  • Svelte: 컴파일 타임 최적화로 패턴의 경계 모호

새로운 패러다임

  • Server Components: 서버와 클라이언트 경계 재정의
  • Signals: 더 세밀한 반응형 상태 관리
  • Micro Frontends: 여러 패턴의 혼재 가능

핵심은 프로젝트의 복잡도, 팀 크기, 유지보수 기간을 고려해 적절한 패턴을 선택하는 것입니다. 작은 프로젝트에 Flux를 적용하거나, 대규모 앱에 단순한 MVC만 사용하는 것은 비효율적일 수 있습니다.

0개의 댓글