목록으로
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'] });
    },
  });
}

마무리

아키텍처에 정답은 없습니다. 팀의 상황과 프로젝트 규모에 맞게 선택해야 합니다.

중요한 것은 일관성입니다. 어떤 구조를 선택하든 팀 전체가 같은 규칙을 따라야 합니다. 문서화하고, 새로운 팀원이 쉽게 이해할 수 있도록 합니다.