[잔뿌리 호기심]은 잔뿌리처럼 메인 주제에서 뻗어 나온 개인적인 궁금증을 다룹니다.
Intro
이전에 작성했던 글인 프론트엔드와 소프트웨어 아키텍처 와 직접적으로는 이어지지 않지만 같은 맥락을 가진 글입니다.
저는 여러 사람이 함께 일 할때 효율적인 하나의 공통 규격이 있다면 만드는 사람도, 읽는 사람도 효율적으로 일할 수 있을 것 같다는 생각을 하게 되었어요. 그래서 그것을 해소해 줄 수 있는게 소프트웨어 아키텍처일 것이라고 추측하고, 소프트웨어 아키텍처를 설명해드리며 MVC 나 MVVM 등의 패턴들을 소개 해 드렸는데요, 여전히 저는 제가 주로 개발하는 리액트에 적용할 수 있는 ‘구조’ 를 찾지 못했어요.
더불어 여전히 ‘아키텍처’ 와 ‘패턴’ 이 너무 모호하게 서로 유사한 의미를 가지고있다고 생각했어요.
그래서 가장 먼저 아키텍처와 패턴이라는 단어가 가진 의미를 조금 더 명확히 분리하고 넘어가는게 좋을 것 같다고 생각했습니다.
아키텍처와 패턴
두 단어는 설계를 담당한다는 점에서는 유사한 부분이 많습니다. 하지만 담당하는 역할은 명확히 다른데요.
소프트웨어 아키텍처의 경우엔 전체 시스템을 포괄하는 고수준 설계를 이야기합니다. 구성요소나, 설계원칙같은 것들이 여기에 포함되죠.
반면, 소프트웨어 패턴은 조금 더 작은 규모에서 반복적으로 발생하는 문제를 해결해주는 하나의 설계 탬플릿으로 볼 수 있습니다. 여기에 바로 직전에 소개 해 드렸던 MVC패턴같은 것들이 포함되구요.
제가 여기서 헷갈렸던 이유도 MVC 패턴 때문입니다. 이녀석은 아키텍처 패턴이거든요. 정말 엄밀히 따지자면 패턴이지만 조금 더 전체적인 구조에 영향을 주는 패턴이기때문에 아키텍처 패턴이라고 불린대요.
그렇다면 이제 조금 더 프론트엔드 개발자에게 가까운 개념으로 아키텍처와 패턴을 정리해볼까요?
- 아키텍처 : 컴포넌트 기반설계 React, Vue, Angular / 라우팅 설계 Next, SPA / 상태관리 : Redux, Zustand …
- 패턴 : HOC, Compound, Render Props …
대충 느낌이 오시나요??
제가 궁금했던 부분은 아키텍처 보다는 디자인패턴에 조금 더 가까웠던 것 입니다! 따라서 오늘은 한번 리액트에서 사용되는 디자인패턴에 대해서 조금 더 알아보려고 해요.
HOC 패턴
HOC (Higher Order Component) 는 다른 함수형 프로그래밍에서 등장하는 키워드인 HOF 와 유사하게 컴포넌트를 인자로 받아 컴포넌트를 반환하는 컴포넌트에요.
리액트에서의 특징이라고 한다면 이 또한 ‘컴포넌트’ 이기때문에 다양한 리액트 훅들을 활용할 수 있고 공통로직을 재활용할 수 있다는 점이에요.
그리고 기존의 컴포넌트가 있어도 HOC를 활용하여 새로운 기능을 추가할 수 있기때문에 컴포넌트 확장에 조금 더 열려있어요.
하지만 결국 HOC에 컴포넌트가 Props 로 전달되기 때문에 Props 드릴링문제가 발생할 수 있고, HOC 체인이 형성된다면 디버깅도 점점 어려워지게 돼요.
HOC 패턴은 사용자 인증이나 로딩상태를 처리하는 등 반복적인 작업을 처리할 때 도움이 되는 패턴이에요.
사실 최근에는 리액트쿼리와 Suspense등을 활용해서 훨씬 더 간편하게 로딩처리 혹인 인증처리가 가능하지만, 그 이상의 복잡한 과정이 필요하다면 HOC를 고려해볼만 합니다!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React, { useEffect } from 'react';
function withConditionalRendering(WrappedComponent: React.ComponentType, AdminComponent: React.ComponentType) {
return function(props: any) {
useEffect(() => {
console.log(`Rendering component for ${props.name}`);
}, [props.name]);
// name이 'Admin'일 경우 AdminComponent를 반환하고, 그 외에는 WrappedComponent 반환
if (props.name === 'Admin') {
return <AdminComponent {...props} />;
} else {
return <WrappedComponent {...props} />;
}
};
}
type MyComponentProps = {
name: string;
};
function MyComponent({ name }: MyComponentProps) {
return <div>Hello, {name}!</div>;
}
function AdminComponent({ name }: MyComponentProps) {
return <div>Welcome, Admin {name}!</div>;
}
export default withConditionalRendering(MyComponent, AdminComponent);
아주 간단한 예제인데요, prop으로 받아온 정보에 따라서 다른 컴포넌트를 반환할 수 있도록 작성된 예제코드입니다.
이런 구조는 사실 사용하는 쪽에서 name === 'Admin' ? <AdminComponent /> : <WrappedComponent /> 와 같이 처리할 수도 있는데요, 더욱 복잡한 과정이 필요하다면 삼항연산자로는 부족할수밖에 없습니다!
커스텀 훅 패턴
커스텀훅 패턴은 리액트에 조금 더 특화된 패턴이라고 할 수 있을 것 같은데요, use라는 키워드가 붙은 훅만 리액트훅의 컨텍스트를 사용할 수 있기 때문이기도 해요.
커스텀훅 패턴의 가장 큰 특징은 로직 추출 및 재사용, 그리고 상태와 사이드이펙트를 리액트 훅과 함께 관리할 수 있다는 점이에요.
이로인해 로직의 재사용성을 크게 늘릴 수 있고, 컴포넌트 레이어에서는 코드가 훨씬 간결해질 수 있어요.
또한 훅은 독립적으로 존재하기 때문에 UI로직과 비즈니스 로직의 분리에 매우 유용해요.
하지만 커스텀훅에 의존하게 된다면 수많은 use라는 이름이 붙은 훅들과함께 훅 관리의 어려움을 겪게 되고 무엇보다 이름짓기가 엄청나게 어렵습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { useState, useEffect } from 'react';
type UseFetchResult<T> = {
data: T | null;
loading: boolean;
error: string | null;
};
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const result = await response.json();
setData(result);
} catch (error: any) {
setError(error.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
}
export default useFetch;
위 useFetch 라는 훅은 URL을 인자로 받아서 비동기로 API호출을 관리하고 오류 및 로딩상태 처리를 담당하는 훅이에요. API 관리는 정말 많은 컴포넌트에서 재활용할 수 있으니 유용하겠죠?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React from 'react';
import useFetch from './useFetch';
function UserList() {
const { data, loading, error } = useFetch<{ id: number; name: string }[]>('https://jsonplaceholder.typicode.com/users');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export default UserList;
이렇게 사용한다면 원래 훨씬 더 길고 복잡했어야 할 UserList 컴포넌트는 단순히 데이터를 받아와서 렌더링만 해주는 UI에만 집중한 컴포넌트가 될 수 있어요.
컴파운드 컴포넌트 패턴
오늘 글을 쓰게 된 가장 큰 이유가 바로 컴파운드 컴포넌트 패턴이에요. 종종 라이브러리들을 사용하다보면 만나볼 수 있는 구조가 바로 이 컴파운드 컴포넌트 패턴이거든요.
1
2
3
4
5
6
<Dropdown>
<Dropdown.Toggle />
<Dropdown.Menu>
<Dropdown.Item />
</Dropdown.Menu>
</Dropdown>
이렇게 생긴 구조를 컴파운드 컴포넌트 패턴이라고 불러요.
특징은 여러개의 작은 컴포넌트들이 하나의 부모 컴포넌트 아래에서 함께 동작하도록 설계된 패턴이라는 점이에요.
부모 컴포넌트가 자식 컴포넌트들에게 상태를 전달하며, 자식 컴포넌트는 부모컴포넌트의 상태나 메서드에 접근할 수 있도록 구현된 컴포넌트이기 때문에 UI구성에 자유로움이 생기고 컴포넌트 간 상태공유가 매우 간편해져요.
장점은 마찬가지로 코드 재사용성이 향상되고, ContextAPI를 활용할 수 있는 가장 좋은 방법이라는 점이에요.
단점은 각각의 컴포넌트들이 강결합되어있기 때문에 컴포넌트 내부의 상태변화가 다른 컴포넌트에도 변화를 줄 수 있다는 점이에요. 그리고 ContextAPI를 사용한다는 게 디버깅에 어려움을 더하기 때문에 장점이면서 단점이기도 해요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import React, { createContext, useContext, useState } from 'react';
// Context 생성
const TabsContext = createContext<any>(null);
// 부모 컴포넌트 (상태 관리)
function Tabs({ children }: { children: React.ReactNode }) {
const [activeTab, setActiveTab] = useState(0);
return (
<TabsContext.Provider value=>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// 자식 컴포넌트들 (상태 사용)
function TabList({ children }: { children: React.ReactNode }) {
return <div className="tab-list">{children}</div>;
}
function Tab({ index, children }: { index: number; children: React.ReactNode }) {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<button
className={`tab ${activeTab === index ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{children}
</button>
);
}
function TabPanels({ children }: { children: React.ReactNode }) {
return <div className="tab-panels">{children}</div>;
}
function TabPanel({ index, children }: { index: number; children: React.ReactNode }) {
const { activeTab } = useContext(TabsContext);
return activeTab === index ? <div className="tab-panel">{children}</div> : null;
}
구현은 아무래도 상당히 복잡하죠? 이게 어디에 어떻게 사용될 지 지정하고 구현하는것이 아니다보니 특히나 어려운 것 같다는 생각이 듭니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 사용 예시
function App() {
return (
<Tabs>
<TabList>
<Tab index={0}>Tab 1</Tab>
<Tab index={1}>Tab 2</Tab>
<Tab index={2}>Tab 3</Tab>
</TabList>
<TabPanels>
<TabPanel index={0}>Content 1</TabPanel>
<TabPanel index={1}>Content 2</TabPanel>
<TabPanel index={2}>Content 3</TabPanel>
</TabPanels>
</Tabs>
);
}
export default App;
하지만 사용하는 입장에서는 훨씬 더 구조적으로 예측 가능하고 명시적인 사용이 가능해요.
결론
이 외에도 여러가지 디자인패턴에 대해서 알아봤지만, 제 문제를 명쾌하게 한번에 해결해 줄 은탄환 패턴은 찾지 못했어요. 당연한 결과일지도 모르겠어요. 각자의 문맥은 너무나도 다른데 이 모든 문제를 해결해줄 수 있는 마법같은 패턴이 있었다면 그게 이미 개발의 표준이 되었겠죠? 마치 현재의 리액트처럼요. ( Vue 와 Angular 를 비하하는건 아닙니다. 하지만 거의 표준인건 사실이잖아요 )
그래도 다양한 패턴들을 알아보며 다음번엔 이 패턴을 사용해보면 좋겠다는 생각을 정리해보게 되었어요. 최근에 정말 많이 바쁜 일상을 보내는지라 다음 글은 어떤 글이될 지 모르겠습니다만, 당분간은 이런 구조적인 솔루션에 관심을 갖고있을 것 같아요.
읽어주셔서 감사합니다.
