아키텍처에서 Presenter
레이어는 일반적으로 화면에 어떤 정보가 보여야 할지 문구, 색상 등을 정리하는 레이어로, View
레이어는 일반적으로 화면이 실제로 어떻게 구현되어야 하는지 정의하는 레이어로 구분됩니다. 이를 통해 뷰와 로직을 나누고 관심사의 분리를 달성하며, 유지보수에 용이한 코드를 구현할 수 있습니다.
프론트엔드 개발을 하며 최근 시도해 본 패턴들 중 관심사의 분리를 꽤 의미 있게 달성할 수 있고 리액트 생태계에도 어울리는 패턴을 하나 발견하여, 블로그를 통해 소개해보려 합니다.
모든 패턴이 그렇듯 이 패턴 역시 완성된 형태가 아니며, 은탄환이 아닙니다. 특정 상황에서는 유용하게 적용되지만 다른 상황에서는 아무 의미 없을 수 있고, 스스로 가지고 있는 모순점이나 애매한 지점도 조금 있습니다. 어떤 목적을 가지고 어떤 문제를 해결해 주는 패턴인지에 집중해서 읽어주세요. 이 글이 관심사의 분리를 달성하는 데 있어 인사이트가 되어 줄 수 있길 바랍니다.
글이 길어질 예정이므로 먼저 결론을 말씀드리자면, 뷰와 로직이 모두 많이 존재하는 복잡한 컴포넌트를 다룰 경우 아래와 같이 파일을 나누고 있습니다.
(컴포넌트이름)/
index.tsx
presenter.test.tsx
presenter.ts
view.tsx
먼저 뷰와 로직을 왜 분리하는지, 이점이 뭔지 좀더 명확하게 정리해보겠습니다.
설명을 위해 우리가 쉽게 이해할 수 있는 단순한 컴포넌트 하나를 사용하겠습니다:
// @/apis/todo.ts
export type ListTodosResponse = {
todos: { id: number; title: string; due: Date; completed: boolean }[];
};
// @/components/TodoList/index.tsx
const TodoList = () => {
const navigate = useNavigate();
const [data, setData] = useState<ListTodosResponse>();
const { fetchTodos } = useContext(ApisContext);
useEffect(() => {
let ignore = false;
fetchTodos().then((res) => !ignore && setData(res));
return () => { ignore = true; };
}, [fetchTodos]);
if (!data) return <div>loading...</div>;
return (
<ul className="flex flex-col gap-2">
{data
.todos
.toSorted((a, b) => a.due.getTime() - b.due.getTime())
.map((todo) => (
<li
className={clsx(
'text-sm p-2',
todo.due.getTime() < Date.now() && !todo.completed ? 'text-red' : 'text-black',
)}
key={todo.id}
onClick={() => navigate(`/todos/${todo.id}`)}
>
{formatDate(todo.due)} - {todo.title}
</li>
))}
</ul>
);
};
위 컴포넌트는 Todo 목록을 api를 쏴서 받아온 다음 화면에 그려줍니다. due 순으로 오름차순으로 보여주고, 만약 due가 지났는데 끝나지 않았다면 빨간색으로 보여줍니다. 이번 글에서는 이 예시를 계속 사용해 보겠습니다.
프론트엔드 개발자에게 디자인과 기획이 완전히 분리되는 개념은 아니지만, 아무튼 뷰는 디자인과 관련 있고, 로직은 기획과 관련있습니다. 일반적으로 이들은 디자이너와 기획자가 각각 관리합니다.
디자이너는 디자이너의 이유로 수정을 요청하고, 기획자는 기획자의 이유로 수정을 요청합니다. 이 둘은 서로 다른 시점에 다른 이유로 변경됩니다.
뷰와 로직이 결합되어 있다면, 개발자는 뷰를 수정할 때도 뷰와 로직을 모두 읽어야 하고, 로직을 수정할 때도 뷰와 로직을 모두 읽어야 합니다. 이는 생산성을 저하시키는 원인이 됩니다.
더 나아가서, 뷰를 수정하다가 의도치 않게 로직을 건드려서 (혹은 반대의 상황이 일어나거나) 버그가 발생할 수도 있습니다. 특히 많은 사람이 같이 개발에 참여한다면 컨플릭이 나면서 이 점이 더 큰 문제로 다가올 수 있습니다.
위 컴포넌트를 보면, 서로 다른 액터가 요청한 변경으로 인해 어떤 버그나 컨플릭이 생길 수 있을지 상상할 수 있습니다.
위 예시의 TodoList
컴포넌트의 핵심 기능인 "클릭했을 때 잘 넘어가는지", "로딩중일 때 로딩중이라고 보이는지", "Todo 가 순서대로 잘 정렬되는지" 를 단위테스트하고 싶다고 가정합시다. 간소화된 예시이다 보니 상태 변경이나 분기 처리 등이 훨씬 복잡하게 설계된 컴포넌트라고 상상해주시면 좋을 것 같습니다.
테스트 코드는 아래와 같은 형태일 것입니다.
// index.test.tsx
test('useViewModel', async () => {
const fetchTodosMock = mock(() =>
Promise.resolve({
todos: [
{ id: 1, title: 'Todo 1', due: new Date('2025-01-10'), completed: true },
{ id: 2, title: 'Todo 2', due: new Date('2024-01-10'), completed: false },
],
}),
);
const screen = render(<TodoList />);
// 초기 상태
expect(screen.getByText('loading...')).toBeInTheDocument();
// 데이터 로딩 후 상태
await waitFor(() => {
expect(screen.getByRole('li')).toHaveCount(2);
expect(screen.getByRole('li').nth(0).toHaveText('2024.01.10 - Todo 2');
expect(screen.getByRole('li'). ....
...
...
...
...
...
... // (getBy어쩌구 n회 반복)
});
});
테스트 코드에 보이듯이 우리는 브라우저에 결합된 형태의 단위 테스트를 진행해야 하며, 비슷한 코드를 많이 작성해야 합니다. 뷰와 로직이 결합되어 있으므로 반드시 뷰도 같이 테스트 대상이 되어 버리는데, 이렇게 되면 테스트 유지보수 비용이 증가하며, 자동화 테스트 소요시간도 길어지게 됩니다. 스크린 요소를 선택하는 코드를 작성하는 건 많은 경우 고통스럽습니다. data-testid
프로퍼티를 활용하더라도 유지보수가 상당히 귀찮아지고, 사용하지 않고 getByText()
같은 것만 사용한다면 그야말로 마크업에 매우 강하게 결합되어 버립니다.
꽤 많은 경우, 로직은 테스트하고 싶은 대상이 되지만, 마크업은 테스트하고 싶은 대상이 아니곤 합니다. 마크업 정보는 피그마를 그대로 옮겨온 것이며, 대부분의 경우에 테스트해서 얻는 이득이 크지 않습니다. 우리는 많은 경우에 <li>
태그로 표시하던 리스트가 <div>
태그로 표시하도록 변경되었다고 해서 테스트가 깨지는 걸 원하지 않으며, 테스트 코드를 더 단순하게 작성할 수 있길 원합니다.
이런 문제를 해결하기 위해 아키텍처적으로 Presenter 레이어와 View 레이어를 모듈로 분리하는 게 좋은 방식 중 하나로 여겨집니다.
Presenter
레이어에는 테스트하기 쉬운 코드를 담습니다.View
레이어에는 테스트하기 어려운 코드를 담고, 대신 테스트할 필요가 없을 만큼 단순하고 멍청하게 만듭니다. (험블 객체 개념)Presenter
레이어가 View
레이어에게 ViewModel
이라는 단순한 객체를 반환합니다. View
레이어는 ViewModel
의 필드들을 보고 하라는 대로 그리기만 합니다.예를 들자면, 위 컴포넌트는 이렇게 분리할 수 있겠습니다. 설명 편의을 위해 적당히 의사코드로 작성하겠습니다.
// 프레젠터 모듈에 있는 로직
- todo 아이템의 타이틀은 due 와 제목을 `{due} - {제목}` 형태로 조합해서 보여준다
- due 가 지났으면 빨간색, due 가 지나지 않았으면 검은색 글씨로 보여준다
- todo 목록이 로딩되지 않았으면 로딩중이라는 뷰를 보여준다
// 프레젠터 모듈이 반환하는 ViewModel 객체
{ 상태: 'loading' }
or
{ 상태: 'success', todoItems: { 제목: string; 색: '빨강' | '까망' }[]; }
// 뷰 모듈에 있는 로직
- 목록은 ul 태그로 감싸고, 아이템은 li 태그로 구현한다.
- ul 태그는 flex에 flex-direction: column, gap: 8px을 준다.
- todo item을 빨간 글씨로 보여줘야 한다면 li 태그에 (styles.red) className을 적용한다.
- 로딩중이라는 뷰를 보여줘야 한다면 div 태그로 loading... 이라는 내용을 보여준다
이렇게 Presenter와 View를 분리하는 것은 아이디어는 좋지만, 막상 코드를 작성해 보면 경계를 어떻게 그을지 / 어디까지 어떤 형태로 추상화할지 애매한 지점도 많고, 매우 다양한 방식이 가능합니다. 여러 방식을 시도해 본 끝에 제가 최근에 정착한 방식은 아래와 같습니다.
// @/apis/todo.ts
export type ListTodosResponse = {
todos: { id: number; title: string; due: Date; completed: boolean }[];
};
// @/components/TodoList/index.tsx
const TodoList = () => {
const viewModel = todoListPresenter.useViewModel();
return <TodoListView viewModel={viewModel} />;
};
// @/components/TodoList/view.tsx
type ViewModel = ReturnType<typeof todoListPresenter.useViewModel>;
export const TodoListView = ({ viewModel }: { viewModel: ViewModel }) => {
const navigate = useNavigate();
return (
<ul className="flex flex-col gap-2">
{viewModel.todos.map((todo) => (
<li
className={clsx(
'text-sm p-2',
{ red: 'text-red', black: 'text-black' }[todo.color],
)}
key={todo.key}
onClick={() => navigate(todo.link)}
>
{todo.title}
</li>
))}
</ul>
);
};
// @/components/TodoList/presenter.tsx
type ViewModel =
| { status: 'loading' }
| {
status: 'success';
todos: { key: React.Key; color: 'red' | 'black'; title: string; link: string }[];
};
export const todoListPresenter = {
useViewModel: (): ViewModel => {
const [data, setData] = useState<TodosResponse>();
const { fetchTodos } = useContext(ApisContext);
useEffect(() => {
let ignore = false;
fetchTodos().then((res) => !ignore && setData(res));
return () => { ignore = true; };
}, [fetchTodos]);
if (!data) return { status: 'loading' };
return {
status: 'success',
todos: data
.todos
.toSorted((a, b) => a.due.getTime() - b.due.getTime())
.map((todo) => ({
key: todo.id,
title: `${formatDate(todo.due)} - ${todo.title}`,
color: todo.due.getTime() < Date.now() && !todo.completed ? 'red' : 'black',
link: `/todos/${todo.id}`,
})),
};
},
};
먼저 View
와 Presenter
를 아래와 같이 쪼갰습니다.
View
의 역할: 디자인, 브라우저, tailwindcss, css, css 컨벤션, html 컨벤션Presenter
의 역할: 기획 및 문구 포매팅, 리액트 리렌더링, 상태관리index.tsx
의 역할: View
와 Presenter
를 합쳐주는 Main의 역할Presenter
를 react에 결합시킬지 말지 많은 고민이 있었는데, react 를 View
레이어로 넘겨버리면 View
레이어가 너무 비대하고 똑똑해지므로 험블 객체가 아니게 됩니다. 따라서 Presenter
까지 react 가 침투하는 것까지는 인정하기로 했습니다.
미래를 생각해도 프레임워크가 Vue
같은 것으로 변경되는 상황이나, React 가 더 이상 Hook을 지원하지 않는 상황 등을 고려하여 레이어를 구분하는 것은 투머치라고 생각했습니다. 오히려 추후 이 프로젝트가 React Native
로 바뀌더라도, View 만 바꾸면 되고 Presenter 는 거의 다 재활용할 수 있도록 한다 정도의 기준을 잡았습니다. (이 쪽이 오히려 더 현실적으로 수 년 내에 일어날 법 한 일이었습니다)
이렇게 View
와 Presenter
를 나누는 건 2017년쯤 유행했던 Container-Presenter 패턴과 혼동될 수 있는데요, 결과물이 비슷할지라도 큰 차이가 있습니다. useNavigate
를 Presenter
가 아닌 View
에서 import 하도록 결정했다는 점에 주목해 주세요. useNavigate
는 react-router-dom
의 함수이고, 따라서 브라우저에 결합되어 있으므로 위 기준에 따르면 프레젠터가 아닌 View 에 있어야 합니다. 마찬가지로 브라우저에 의존하는 IntersectionObserver
등을 사용해야 하는 상황이라면, 해당 로직은 useEffect
같은 복잡한 로직이 존재하더라도 Presenter
가 아닌 View
에 두겠습니다.
위 코드를 보면 Presenter
레이어에서 /todos/:todoId
라는 브라우저에 한정된 페이지 url 형태에 의존하는 것을 알 수 있는데요, 이 부분은 여전히 용인할 만 한 예외로 생각하고 넘어갈지, 아니면 페이지를 아예 entity화하여 { page: 'TODO_DETAIL', todoId: number }
와 같은 형태의 객체를 반환하도록 처리할지는 고민 중인 포인트입니다.
앞서 언급했듯이 테스트에서도 이점이 생깁니다. 위에서 봤던 복잡하고 중복 많은 테스트 코드는 아래와 같이 단순해집니다.
// presenter.test.tsx
test('useViewModel', async () => {
const fetchTodosMock = mock(() =>
Promise.resolve({
todos: [
{ id: 1, title: 'Todo 1', due: new Date('2025-01-10'), completed: true },
{ id: 2, title: 'Todo 2', due: new Date('2024-01-10'), completed: false },
],
}),
);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ApisContext.Provider value={{ fetchTodos: fetchTodosMock }}>{children}</ApisContext.Provider>
);
const { result } = renderHook(() => todoListPresenter.useViewModel(), { wrapper });
// 초기 상태
expect(result.current).toEqual({ status: 'loading' });
// 데이터 로딩 후 상태
await waitFor(() => expect(result.current).toEqual({
status: 'success',
todos: [
{ key: 2, title: '2024.01.10 - Todo 2', color: 'red', link: '/todos/2' },
{ key: 1, title: '2025.01.10 - Todo 1', color: 'black', link: '/todos/1' },
],
}));
});
이렇게 결과물을 테스트할 때 expect(viewModel).toEqual()
을 필요한 횟수만큼만 사용하여, 테스트가 필요한 유의미한 모든 코드를 빠르고 간결하게 테스트할 수 있었습니다.
물론 단점도 있었는데요, 위와 같은 방식은 아무튼 경계를 나누고 선을 긋는 것이기 때문에 작성해야 하는 코드량이 많아집니다. 단위 테스트를 작성하지 않아도 될 정도로 로직이 단순한 컴포넌트라면 이렇게 모듈을 나누는 게 불필요하게 생산성만 낮추는 꼴이 되었습니다.
또한 이 방식에서는 문구 포매팅 규칙과 상태관리를 모두 Presenter
레이어에서 다루도록 처리했는데요, 사실 이 둘은 분리되어야 하긴 합니다. 프로젝트를 Next.js 로 마이그레이션해서 서버 컴포넌트를 사용하게 되었다면, 문구 포매팅 규칙은 여전히 Presenter
레이어의 영역이지만 상태관리를 다루는 일은 더 이상 같은 모듈에서 진행할 수 없을 것이기 때문입니다. 이 부분을 고려한다면, 문구 레이어와 상태관리 레이어를 나누는 게 좋을 수도 있습니다.
이번 글에서는 복잡한 컴포넌트에서 관심사를 분리하여 단위 테스트를 쉽게 구현할 수 있도록 하고 코드를 깔끔하게 유지하는 하나의 아이디어를 소개드렸습니다. 결과물은 단순하지만, 그 속에 숨어 있는 고민들과 여러 코멘트들이 이 글을 읽어주신 분들께 조금이나마 인사이트를 드릴 수 있었길 바랍니다. 감사합니다!