React 디자인 패턴을 Vue로 옮겨보자 -2 (Container-Presentation Pattern)

쭌로그·2025년 11월 4일
post-thumbnail

Container Presentation Pattern이란, 로직을 위한 컴포넌트(Container)와 UI를 위한 컴포넌트(View)로 분리시키는 디자인 패턴입니다.

1. 구현 컴포넌트

  • Presentational Component: Props를 통해 데이터를 받습니다. 주요 기능은 전달 받은 데이터를 통해 렌더링 할 화면을 표현하는 것입니다.

  • Container Component: Presentational 컴포넌트에 데이터를 전달하는 컴포넌트입니다. Container 컴포넌트 자체는 화면에 렌더링하지 않습니다.

2.장점

  • 자연스럽게 로직/UI 컴포넌트가 분리되어 관심사가 분리됩니다.
  • Presentational Prop로 데이터를 주입받기 때문에 다양한 목적으로 재사용 할 수 있습니다.
  • Props로만 데이터를 전달받기 때문에 Presentational 컴포넌트는 수정 및 테스트가 쉽습니다.

3. 단점

  • 많은 양의 데이터가 필요해지면 Container 컴포넌트의 로직이 복잡해질 수 있습니다.
  • Container 컴포넌트가 커짐에 따라 자연스럽게 Presentational Component의 Props 또한 비대해질 수 있습니다.
  • 단순한 컴포넌트도 위 규칙을 따르면 오버엔지니이링이 발생할 수 있습니다.

4.React로 구현

Container Component

import { useEffect, useState } from "react";
import UserView from "../view/UserView";

export interface User {
  name: string;
  email: string;
}

async function fetchUser(): Promise<User> {
  await new Promise((resolve) => setTimeout(resolve, 500));
  return {name:"테스터", email:"test@test.com"}
}

//User API를 호출하여 Presentational Component에 전달.
function UserContainer() {
  const [user, setUser] = useState<User | null >(null);

  const [isLoading, setIsLoading] = useState<boolean>(false);

  useEffect(() => {
    setIsLoading(true)
    const init = async () => {
      try {
        const user = await fetchUser();
        setUser(user);
      } catch(e) {
        console.error(e);
      } finally {
        setIsLoading(false)
      }
    }

    init();
  },[])


  return (
    //UI 구현 없이 UserView에 데이터만 전달
   <UserView
      user={user}
      isLoading={isLoading}
      onRefresh={fetchUser}
    />
  )
}

export default UserContainer;

Presentation Component

import type { User } from "../container/UserContainer";

interface UserViewProps {
  user: User | null;
  isLoading: boolean;
  onRefresh: () => Promise<User>
}
// 데이터 조작 없이 전달된 Props를 통해 화면만 갱신하는 컴포넌트
function UserView({user, isLoading}: UserViewProps) {

  if(isLoading) return <div>Loading...</div>

  if(user === null) return <div>유저 정보가 없어요.</div>

  return (
    <div>
      <h2>유저 정보</h2>
      <div>이름: {user.name}</div>
      <div>이름: {user.email}</div>
    </div>
  )
}

export default UserView;

만약 API 로직 혹은 데이터 조작 로직을 모듈화하고 싶다면 Hook으로 분리할 수 있습니다.

import { useState, useEffect, useCallback } from "react";

export interface User {
  name: string;
  email: string;
}

export function useUser() {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  async function fetchUser(): Promise<User> {
  	try {
      	setIsLoading(true)
    	await new Promise((resolve) => setTimeout(resolve, 500));
  		setUser({name:"테스터", email:"test@test.com"})
    } catch(e) {
      console.log(e)
    } finally {
    	setIsLoading(false)
    }
  }

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

  return {
    user,
    isLoading,
    refresh: fetchUser,
  };
}
import React from "react";
import { useUser } from "../hooks/useUser";
import { UserView } from "./UserView";

export function UserContainer() {
  //User의 정보를 불러오는 로직을 Hook으로 분리
  const { user, isLoading, refresh } = useUser();

  return (
    <UserView
      user={user}
      isLoading={isLoading}
      onRefresh={refresh}
    />
  );
}

5. Vue로 구현

Container Component

//User API를 호출하여 Presentational Component에 전달.
<script setup lang="ts">
import { onMounted, ref, shallowRef } from 'vue';
import UserView from '../view/UserView.vue';


export interface User {
  name: string;
  email: string;
}

async function fetchUser(): Promise<User> {
  await new Promise((resolve) => setTimeout(resolve, 500));
  return {name:"테스터", email:"test@test.com"}
}

const user = shallowRef<User>();
const isLoading = ref<boolean>(false);


onMounted(async () => {
  try {
    isLoading.value = true;
    const res = await fetchUser();
    user.value = res;
  } catch(e) {
    console.error(e)
  }finally {
    isLoading.value = false;
  }
})


</script>

<template>
  <UserView
    :user="user"
    :isLoading="isLoading"
    :onRefresh="fetchUser"
  />
</template>

Presentation Component

// 데이터 조작 없이 전달된 Props를 통해 화면만 갱신하는 컴포넌트
<script setup lang="ts">
import type { User } from '../container/UserContainer.vue';


interface UserViewProps {
  user?: User;
  isLoading: boolean;
  onRefresh: () => Promise<User>
}

const props = withDefaults(defineProps<UserViewProps>(), {
  user: () => ({
    name: '',
    email: ''
  })
})


</script>

<template>
  <div>
    <h2>유저 정보</h2>
    <div>이름: {{props.user.name}}</div>
    <div>이름: {{props.user.email}}</div>
  </div>
</template>

Vue 또한 Hook과 같이 로직을 모듈화 하고싶다면 composable method로 분리할 수 있습니다.

useUser

import { shallowRef, ref, onMounted, type ShallowRef, type Ref } from "vue";

export interface User {
  name: string;
  email: string;
}

interface useUserProps {
  user: ShallowRef<User | undefined>;
  isLoading: Ref<boolean>;
  fetchUser: () => Promise<User>
}

async function fetchUser(): Promise<User> {
  await new Promise((resolve) => setTimeout(resolve, 500));
  return {name:"테스터", email:"test@test.com"}
}


function useUser():useUserProps {
  const user = shallowRef<User>();
  const isLoading = ref<boolean>(false);


  onMounted(async () => {
    try {
      isLoading.value = true;
      const res = await fetchUser();
      user.value = res;
    } catch(e) {
      console.error(e)
    }finally {
      isLoading.value = false;
    }
  })

  return {
    user,
    isLoading,
    fetchUser
  }
}

export default useUser;
<script setup lang="ts">
import useUser, {type User} from '../../composables/useUser';
import UserView from '../view/UserView.vue';


const {user, isLoading, fetchUser} = useUser();
</script>

<template>
  <UserView
    :user="user"
    :isLoading="isLoading"
    :onRefresh="fetchUser"
  />
</template>

정리

오늘은 Container-Presentation 패턴을 React와 Vue로 구현했습니다.
사실 Container-Presentation 패턴은 'React의 디자인 패턴이다'라고 보기에는 어디에서든지 사용할 수 있기 때문에 공통 디자인 패턴이라고 생각합니다.
Container-Presentation 패턴은 프론트엔드 개발자가 리팩토링, 유지보수를 하다보면 알고있지 않아도 자연스럽게 적용하게 되는 디자인 패턴입니다.
UI적 관심사와 기능적 관심사가 분리되어야 가독성도 높아지고 코드의 응집도도 낮아지기 때문입니다.
하지만 많은 양의 데이터가 필요하거나 데이터 조작이 필요한 경우에는 Container 컴포넌트가 복잡해지기 때문에 적절하게 분리하는 방식이 필요합니다.
또한 단순한 기능을 가진 컴포넌트도 디자인 패턴을 적용해야하나라는 고민이 생기게 되는데 이는 팀원들과의 논의를 통하여 정하거나 사전에 명확하게 경계를 분리하는것이 좋다고 생각합니다.

저는 실무에서 Atomic Design Pattern과 Container-Presentation를 혼용해서 사용하는 방식을 사용하고 있는데 UI/기능적 컴포넌트로 깔끔하게 유지보수 할 수 있어서 유용하게 사용하고 있는 디자인 패턴입니다:)

오늘도 긴 글 읽어주셔서 감사합니다 :)

profile
매일 발전하는 프론트엔드 개발자

0개의 댓글