Frontend
프론트엔드 아키텍처 패턴
대규모 프론트엔드 프로젝트를 위한 폴더 구조와 아키텍처 패턴을 알아봅니다.
프로젝트가 커지면 코드 구조가 중요해집니다. 잘 설계된 아키텍처는 유지보수를 쉽게 하고, 팀 협업을 원활하게 합니다.
폴더 구조
기능 기반 구조
가장 추천하는 방식입니다. 기능별로 관련 파일을 모아둡니다.
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── SignupForm.tsx
│ │ ├── hooks/
│ │ │ └── useAuth.ts
│ │ ├── services/
│ │ │ └── authApi.ts
│ │ ├── types.ts
│ │ └── index.ts
│ ├── products/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── index.ts
│ └── cart/
│ └── ...
├── shared/
│ ├── components/
│ │ ├── Button.tsx
│ │ └── Modal.tsx
│ ├── hooks/
│ │ └── useLocalStorage.ts
│ └── utils/
│ └── format.ts
├── app/ # 라우팅 (Next.js App Router)
└── lib/ # 외부 라이브러리 설정
장점은 관련 코드를 찾기 쉽고, 기능 단위로 작업할 수 있다는 것입니다.
타입 기반 구조
소규모 프로젝트에서 사용합니다.
src/
├── components/
│ ├── common/
│ ├── auth/
│ └── products/
├── hooks/
├── services/
├── types/
├── utils/
└── pages/
단점은 프로젝트가 커지면 각 폴더가 비대해진다는 것입니다.
컴포넌트 설계
Compound Components 패턴
관련 컴포넌트를 그룹화합니다.
// 사용
<Card>
<Card.Header>
<Card.Title>제목</Card.Title>
</Card.Header>
<Card.Body>내용</Card.Body>
<Card.Footer>푸터</Card.Footer>
</Card>
// 구현
const Card = ({ children }) => (
<div className="card">{children}</div>
);
Card.Header = ({ children }) => (
<div className="card-header">{children}</div>
);
Card.Title = ({ children }) => (
<h2 className="card-title">{children}</h2>
);
Card.Body = ({ children }) => (
<div className="card-body">{children}</div>
);
Card.Footer = ({ children }) => (
<div className="card-footer">{children}</div>
);
export { Card };
Render Props 패턴
렌더링 로직을 외부에서 제어합니다.
function DataFetcher({ url, render }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, [url]);
return render({ data, loading });
}
// 사용
<DataFetcher
url="/api/users"
render={({ data, loading }) => (
loading ? <Spinner /> : <UserList users={data} />
)}
/>
Container/Presenter 패턴
로직과 UI를 분리합니다.
// Container: 로직 담당
function UserListContainer() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUsers().then(setUsers).finally(() => setLoading(false));
}, []);
const handleDelete = async (id) => {
await deleteUser(id);
setUsers(users.filter(u => u.id !== id));
};
return (
<UserList users={users} loading={loading} onDelete={handleDelete} />
);
}
// Presenter: UI 담당
function UserList({ users, loading, onDelete }) {
if (loading) return <Spinner />;
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => onDelete(user.id)}>삭제</button>
</li>
))}
</ul>
);
}
상태 관리 구조
전역 상태 vs 로컬 상태
전역 상태 (Zustand, Redux 등)
- 인증 정보
- 사용자 설정
- 장바구니
서버 상태 (React Query, SWR)
- API 데이터
- 캐싱, 재검증
로컬 상태 (useState)
- 폼 입력
- UI 상태 (모달, 토글)
상태 코로케이션
상태는 사용하는 곳과 가까이 둡니다.
// 나쁜 예: 전역에 불필요한 상태
const useGlobalStore = create(set => ({
searchQuery: '', // SearchBar에서만 사용
isDropdownOpen: false, // Header에서만 사용
}));
// 좋은 예: 컴포넌트 내부에
function SearchBar() {
const [query, setQuery] = useState('');
// ...
}
API 레이어
API 함수 분리
// services/userApi.ts
const BASE_URL = '/api/users';
export const userApi = {
getAll: () =>
fetch(BASE_URL).then(res => res.json()),
getById: (id: string) =>
fetch(`${BASE_URL}/${id}`).then(res => res.json()),
create: (data: CreateUserDto) =>
fetch(BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json()),
update: (id: string, data: UpdateUserDto) =>
fetch(`${BASE_URL}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json()),
delete: (id: string) =>
fetch(`${BASE_URL}/${id}`, { method: 'DELETE' }),
};
React Query와 함께
// hooks/useUsers.ts
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: userApi.getAll,
});
}
export function useUser(id: string) {
return useQuery({
queryKey: ['users', id],
queryFn: () => userApi.getById(id),
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: userApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
마무리
아키텍처에 정답은 없습니다. 팀의 상황과 프로젝트 규모에 맞게 선택해야 합니다.
중요한 것은 일관성입니다. 어떤 구조를 선택하든 팀 전체가 같은 규칙을 따라야 합니다. 문서화하고, 새로운 팀원이 쉽게 이해할 수 있도록 합니다.