대규모 SPA를 위한 확장 가능한 프론트엔드 아키텍처 구축
Olivia Novak
Dev Intern · Leapcell

소개
웹 애플리케이션, 특히 단일 페이지 애플리케이션(SPA) 분야에서 복잡성과 규모가 증가함에 따라, 초기 개발의 즐거움은 곧 유지보수의 악몽으로 바뀔 수 있습니다. 무분별한 성장은 종종 이해하기 어렵고, 디버깅하기 힘들며, 발전이 느린 거대한 코드 베이스로 이어집니다. 이 글은 유지보수성을 향상시키고, 개발 속도를 높이며, 협업을 강화하는 전략에 초점을 맞춰 대규모 SPA를 위한 확장 가능한 프론트엔드 아키텍처를 설계하는 중요한 과제를 다룹니다. 기능 슬라이싱 및 모듈화와 같은 원칙을 채택함으로써, 조각난 코드를 체계적이고 강력한 시스템으로 전환하여 지속 가능성과 효율적인 개발 수명 주기를 위한 길을 열 수 있습니다.
확장성을 위한 핵심 아키텍처 원칙
기능 슬라이싱과 모듈화의 구체적인 내용을 살펴보기 전에, 프론트엔드 아키텍처에서 진정한 확장성을 뒷받침하는 기본 개념을 이해하는 것이 중요합니다. 이러한 개념은 정보에 입각한 설계 결정을 위한 프레임워크를 제공합니다.
단일 페이지 애플리케이션(SPA)이란 무엇인가?
단일 페이지 애플리케이션(SPA)은 단일 HTML 페이지를 로드하고 서버에서 완전히 새로운 페이지를 로드하는 대신 사용자가 앱과 상호 작용함에 따라 콘텐츠를 동적으로 업데이트하는 웹 애플리케이션입니다. 이 접근 방식은 더 유연하고 데스크톱과 같은 사용자 경험을 제공합니다. 그러나 SPA의 이점인 더 빠른 전환, 풍부한 상호 작용은 애플리케이션이 성장함에 따라 아키텍처적인 과제를 안고 있습니다.
모듈성이란 무엇인가?
소프트웨어 설계에서 모듈성은 시스템의 구성 요소를 분리하고 재결합할 수 있는 정도를 의미합니다. 모듈식 시스템은 특정 기능을 수행하는 개별적인 독립 단위(모듈)로 구성됩니다. 이러한 모듈은 자체 포함되어 있으며 시스템의 다른 부분과의 의존성을 최소화하는 잘 정의된 인터페이스를 노출합니다. 프론트엔드 개발의 맥락에서 모듈성은 복잡성을 관리하고, 재사용성을 향상시키며, 병렬 개발을 용이하게 하는 핵심입니다.
기능 슬라이싱이란 무엇인가?
기능 슬라이싱은 종종 프론트엔드에서 "기능 기반 개발" 또는 "도메인 기반 설계"라고도 불리며, 코드 베이스가 기술 유형(예: 한 폴더의 모든 컴포넌트, 다른 폴더의 모든 서비스)이 아닌 주로 비즈니스 기능별로 구성되는 아키텍처 패턴입니다. 각 "슬라이스" 또는 "기능 모듈"은 사용자에게 보이는 특정 기능과 관련된 모든 것을 캡슐화합니다. 즉, 해당 기능의 컴포넌트, 상태 관리, 라우트, API 통합, 심지어 스타일까지 포함합니다. 이는 유사한 기술적 관심사가 함께 그룹화되는 전통적인 계층 기반 아키텍처와는 대조적입니다. 핵심 아이디어는 각 기능을 가능한 한 독립적으로 만들어 개발, 테스트 및 배포를 단순화하는 것입니다.
이러한 원칙을 채택해야 하는 이유는 무엇인가?
- 개선된 유지보수성: 격리된 기능은 한 영역의 변경이 다른 영역을 중단시킬 가능성이 적음을 의미합니다.
- 향상된 협업: 여러 팀 또는 개발자가 최소한의 병합 충돌로 다른 기능을 동시에 작업할 수 있습니다.
- 더 빠른 개발 주기: 개발자는 자신의 작업과 관련된 코드를 신속하게 찾고 이해할 수 있습니다.
- 쉬운 온보딩: 새로운 팀 구성원은 애플리케이션 구조를 더 빠르게 파악할 수 있습니다.
- 더 나은 성능: 기능 수준에서 정교한 기술(적응형 로딩 등)을 가능하게 하여 초기 번들 크기를 줄입니다.
기능 슬라이싱 및 모듈화 구현
이러한 원칙이 실제 어떻게 적용될 수 있는지, 그리고 그 이점을 보여주는 예시를 통해 살펴보겠습니다.
전통적인 계층 기반 아키텍처의 문제점
일반적인 비확장 확장성 구조를 고려해 보겠습니다.
├── components/
│ ├── Button.js
│ ├── UserCard.js
│ └── Table.js
├── pages/
│ ├── HomePage.js
│ └── UserDetailsPage.js
├── services/
│ ├── userService.js
│ └── productService.js
├── store/
│ ├── userSlice.js
│ └── productSlice.js
└── App.js
조직적으로 보일 수 있지만, "사용자" 기능을 수정해야 한다고 상상해 보세요. components/UserCard.js
, pages/UserDetailsPage.js
, services/userService.js
, store/userSlice.js
사이를 오가야 할 것입니다. 이 분산된 접근 방식은 대규모 애플리케이션에서 상당한 병목 현상이 됩니다.
기능 슬라이싱 적용
기능 슬라이싱을 사용하면 디렉터리 구조가 별개의 비즈니스 기능을 중심으로 구성됩니다. features
디렉터리는 일반적으로 기본 컨테이너 역할을 합니다.
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ └── LoginForm.js
│ │ ├── pages/
│ │ │ └── LoginPage.js
│ │ ├── services/
│ │ │ └── authService.js
│ │ ├── store/
│ │ │ └── authSlice.js
│ │ └── index.js // 기능 진입점
│ ├── users/
│ │ ├── components/
│ │ │ └── UserCard.js
│ │ │ └── UserTable.js
│ │ ├── pages/
│ │ │ └── UserDetailsPage.js
│ │ ├── services/
│ │ │ └── userService.js
│ │ ├── store/
│ │ │ └── userSlice.js
│ │ ├── routes.js // 기능별 라우트
│ │ └── index.js
| └── products/
| // ... 유사한 구조
├── shared/
│ ├── components/ // 재사용 가능한 범용 컴포넌트 (예: Button, Modal)
│ ├── utils/
│ └── hooks/
└── App.js
이 구조에서는 features/users
디렉터리 내에 "users" 기능과 관련된 모든 것이 함께 배치됩니다. 이를 통해 전체 기능을 쉽게 찾고 수정하거나 필요한 경우 전체 기능을 추출하는 것이 매우 편리합니다.
코드 예시: 사용자 기능 슬라이스
React와 유사한 프레임워크 및 Redux Toolkit을 사용한 상태 관리를 사용하여 features/users
슬라이스 내의 간단한 예시를 살펴보겠습니다.
features/users/store/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import { fetchUsers, fetchUserDetails } from '../services/userService'; export const getUsers = createAsyncThunk('users/getUsers', async () => { const response = await fetchUsers(); return response.data; }); export const getUserDetails = createAsyncThunk('users/getUserDetails', async (userId) => { const response = await fetchUserDetails(userId); return response.data; }); const userSlice = createSlice({ name: 'users', initialState: { list: [], selectedUser: null, status: 'idle', error: null, }, reducers: {}, extraReducers: (builder) => { builder .addCase(getUsers.pending, (state) => { state.status = 'loading'; }) .addCase(getUsers.fulfilled, (state, action) => { state.status = 'succeeded'; state.list = action.payload; }) .addCase(getUsers.rejected, (state, action) => { state.status = 'failed'; state.error = action.error.message; }) .addCase(getUserDetails.fulfilled, (state, action) => { state.selectedUser = action.payload; }); }, }); export default userSlice.reducer;
features/users/services/userService.js
import api from '../../../shared/utils/api'; // 공유 API 유틸리티 export const fetchUsers = () => { return api.get('/users'); }; export const fetchUserDetails = (userId) => { return api.get(`/users/${userId}`); };
features/users/components/UserCard.js
import React from 'react'; import { Link } from 'react-router-dom'; const UserCard = ({ user }) => { return ( <div className="user-card"> <h3>{user.name}</h3> <p>{user.email}</p> <Link to={`/users/${user.id}`}>View Details</Link> </div> ); }; export default UserCard;
features/users/pages/UserDetailsPage.js
import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { getUserDetails } from '../store/userSlice'; import SharedSpinner from '../../../shared/components/Spinner'; const UserDetailsPage = () => { const { userId } = useParams(); const dispatch = useDispatch(); const { selectedUser, status, error } = useSelector((state) => state.users); useEffect(() => { if (userId) { dispatch(getUserDetails(userId)); } }, [dispatch, userId]); if (status === 'loading') return <SharedSpinner />; if (error) return <p>Error: {error}</p>; if (!selectedUser) return <p>User not found.</p>; return ( <div> <h1>{selectedUser.name}</h1> <p>Email: {selectedUser.email}</p> <p>Phone: {selectedUser.phone}</p> {/* More user details */} </div> ); }; export default UserDetailsPage;
루트 애플리케이션에 기능 통합
기능은 종종 메인 애플리케이션에 통합되어야 합니다. 이는 일반적으로 리듀서를 등록하고, 라우트를 정의하고, 메인 레이아웃에서 구성하는 것을 포함합니다.
루트 리듀서 (store/index.js
)
import { configureStore } from '@reduxjs/toolkit'; import userReducer from '../features/users/store/userSlice'; import authReducer from '../features/auth/store/authSlice'; const store = configureStore({ reducer: { users: userReducer, auth: authReducer, // ... other feature reducers }, }); export default store;
루트 라우터 (App.js
또는 router/index.js
)
import React from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import HomePage from './pages/HomePage'; // 일반 페이지, 아마도 shared에 있음 import LoginPage from './features/auth/pages/LoginPage'; import UserDetailsPage from './features/users/pages/UserDetailsPage'; import UserListPage from './features/users/pages/UserListPage'; // 다른 사용자 페이지 예시 function App() { return ( <Router> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/login" element={<LoginPage />} /> <Route path="/users" element={<UserListPage />} /> <Route path="/users/:userId" element={<UserDetailsPage />} /> {/* ... other feature routes */} </Routes> </Router> ); } export default App;
기능 내 및 공유 관심사에 모듈 활용
기능 슬라이스 내에서도 모듈성은 중요합니다. 기능은 자체 내부 컴포넌트, 훅 또는 유틸리티를 가질 수 있습니다. shared
디렉터리는 진정으로 범용적이며 애플리케이션 전체에서 사용되는 모듈이 있는 곳입니다.
shared/components/
: 버튼, 모달, 스피너, 범용 폼 입력 - 시각적으로 구별되지 않고 기본 UI 기능을 수행하는 컴포넌트.shared/utils/
: 날짜 형식 지정, 유효성 검사, 인증 토큰, API 요청 기본 양식에 대한 헬퍼 함수.shared/hooks/
: 범용 기능을 제공하는 사용자 정의 React 훅.
핵심적인 구분은 다음과 같습니다. 특정 비즈니스 기능과 개념적으로 관련된 컴포넌트 또는 유틸리티는 해당 기능 디렉터리 안에 속합니다. 거의 모든 곳에서 수정 없이 거의 모든 곳에서 사용할 수 있다면 shared
에 속합니다.
고급 개념: 마이크로 프론트엔드 및 모노레포
극도로 큰 SPA의 경우, 기능 슬라이싱이라는 개념이 "마이크로 프론트엔드"로 확장될 수 있습니다. 여기서 서로 다른 기능 또는 기능 그룹이 완전히 별도의 애플리케이션으로 개발 및 배포되어 런타임에 통합됩니다. 이는 최고의 격리를 제공하지만 상당한 운영 복잡성을 추가합니다. 반면에 모노레포는 여러 개의 별도 프로젝트(기능, 공유 라이브러리)를 위한 단일 리포지토리를 제공하여 코드 공유와 조정된 변경을 용이하게 하며, 종종 Lerna 또는 Nx와 같은 도구를 통해 지원됩니다. 이는 기능 슬라이싱의 엄격한 일부는 아니지만, 단일 애플리케이션을 넘어 확장하는 자연스러운 확장입니다.
결론
대규모 단일 페이지 애플리케이션을 위한 확장 가능한 프론트엔드 아키텍처를 설계하려면 거대한 사고방식에서 모듈식, 기능 중심 접근 방식으로 의도적인 전환이 필요합니다. 기능 슬라이싱을 채택함으로써 복잡한 코드 베이스를 관리 가능하고 독립적인 단위로 분리하여 유지보수성, 협업 촉진 및 개발 속도를 극적으로 향상시킬 수 있습니다. 이 아키텍처 패러다임은 애플리케이션을 강력하고 적응 가능하게 만들 뿐만 아니라 개발 팀이 자신감을 가지고 구축하고 반복할 수 있도록 지원합니다. 규모를 위한 구축은 코드를 작성하는 것뿐만 아니라, 원활한 성장과 지속적인 혁신을 가능하게 하는 명확한 목적을 가진 모든 부분을 가진 체계적인 시스템을 만드는 것입니다.