React 및 Vue에서 대규모 컴포넌트를 분해하기 위한 실용적인 전략
Lukas Schneider
DevOps Engineer · Leapcell

소개
빠르게 발전하는 프론트엔드 개발 환경에서 React와 Vue는 복잡하고 인터랙티브한 사용자 인터페이스를 구축할 수 있는 개발자들에게 강력한 힘을 부여하며 지배적인 위치를 차지했습니다. 그러나 애플리케이션의 복잡성이 증가함에 따라 일반적인 문제가 발생하는데, 바로 대규모의 모놀리식 컴포넌트의 성장입니다. "신 컴포넌트"라고도 불리는 이들은 종종 통제 불능 상태가 되어 유지보수, 테스트 및 재사용이 어려워지며, 개발 주기 지연과 버그 발생 가능성 증가로 이어집니다. 이 문서는 React 및 Vue의 대규모 컴포넌트를 분해하기 위한 실용적이고 효과적인 전략을 탐구함으로써 이 중요한 문제에 접근하는 것을 목표로 합니다. 사용자 정의 훅/컴포저블 및 하위 컴포넌트 추출이라는 강력한 기법을 살펴보고, 이러한 아키텍처 패턴이 복잡하게 얽힌 코드베이스를 모듈화되고 이해하기 쉬우며 확장 가능한 코드로 변환하는 방법을 보여줌으로써 최종적으로 개발자 생산성과 애플리케이션 견고성을 향상시킬 것입니다.
핵심 개념 및 전략
실용적인 전략에 대해 자세히 알아보기 전에 React 및 Vue의 컴포넌트 분해의 기초를 형성하는 몇 가지 핵심 용어를 명확히 해보겠습니다.
컴포넌트(Component): React와 Vue 모두에서 기본적인 구축 블록입니다. UI의 한 부분과 관련 로직을 캡슐화합니다. 상태(State): 컴포넌트가 내부적으로 관리하며 시간이 지남에 따라 변경될 수 있고 리렌더링을 트리거하는 데이터입니다. Props: 부모 컴포넌트에서 자식 컴포넌트로 전달되는 변경 불가능한 데이터로, 통신 및 구성을 가능하게 합니다. 부수 효과(Side Effects): 외부 세계와 상호 작용하는 작업(예: 데이터 가져오기, DOM 조작, 구독)이며 일반적으로 렌더링 후에 처리됩니다.
사용자 정의 훅(Custom Hooks, React) / 컴포저블(Composables, Vue)
React의 사용자 정의 훅과 Vue의 컴포저블은 상태 관련 로직을 재사용 가능한 함수로 추출하는 강력한 메커니즘입니다. 이를 통해 관련 로직, 상태 및 부수 효과를 캡슐화하여 컴포넌트를 더 깔끔하고 UI 렌더링에 더 집중할 수 있습니다.
원칙: 컴포넌트 내에서 반복되는 로직, 상태 관리 또는 부수 효과를 식별합니다. 이 로직은 종종 컴포넌트의 렌더링 책임과 직접적으로 관련되지 않습니다.
구현 (React - 사용자 정의 훅):
제품 데이터를 가져오고, 로딩 상태를 관리하며, 즐겨찾기 상태를 처리하는 대규모 ProductDetails 컴포넌트를 고려해 보겠습니다.
// 이전: 대규모 ProductDetails 컴포넌트 function ProductDetails({ productId }) { const [product, setProduct] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isFavorite, setIsFavorite] = useState(false); const [error, setError] = useState(null); useEffect(() => { const fetchProduct = async () => { setIsLoading(true); try { const response = await fetch(`/api/products/${productId}`); if (!response.ok) throw new Error('Failed to fetch product'); const data = await response.json(); setProduct(data); // 제품이 즐겨찾기인지 확인하는 로직 가정 setIsFavorite(data.isFavoriteByUser); } catch (err) { setError(err.message); } finally { setIsLoading(false); } }; fetchProduct(); }, [productId]); const toggleFavorite = () => { // 즐겨찾기 상태 업데이트 API 호출 setIsFavorite(prev => !prev); }; if (isLoading) return <div>Loading product...</div>; if (error) return <div>Error: {error}</div>; if (!product) return <div>Product not found.</div>; return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <button onClick={toggleFavorite}> {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} </button> {/* ... 더 많은 UI 요소 */} </div> ); }
이제 데이터 가져오기 및 즐겨찾기 토글 로직을 사용자 정의 훅으로 추출해 보겠습니다.
// 사용자 정의 훅: useProductData.js import { useState, useEffect } from 'react'; function useProductData(productId) { const [product, setProduct] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchProduct = async () => { setIsLoading(true); setError(null); try { const response = await fetch(`/api/products/${productId}`); if (!response.ok) throw new Error('Failed to fetch product'); const data = await response.json(); setProduct(data); } catch (err) { setError(err.message); } finally { setIsLoading(false); } }; fetchProduct(); }, [productId]); return { product, isLoading, error, setProduct }; } // 사용자 정의 훅: useFavoriteToggle.js import { useState } from 'react'; function useFavoriteToggle(initialIsFavorite) { const [isFavorite, setIsFavorite] = useState(initialIsFavorite); const toggleFavorite = async (productId) => { // API 통화에 productId가 필요할 수 있습니다. // API 호출 시뮬레이션 console.log(`Toggling favorite for product ID: ${productId}`); await new Promise(resolve => setTimeout(resolve, 500)); setIsFavorite(prev => !prev); // 실제 앱에서는 실제 API 호출을 여기에 넣습니다. }; return { isFavorite, toggleFavorite, setIsFavorite }; } // 이후: 리팩토링된 ProductDetails 컴포넌트 function ProductDetails({ productId }) { const { product, isLoading, error, setProduct } = useProductData(productId); const { isFavorite, toggleFavorite } = useFavoriteToggle(product?.isFavoriteByUser || false); // 제품 즐겨찾기 상태로 초기화 // isFavorite이 변경될 때 제품의 즐겨찾기 상태를 업데이트합니다. useEffect(() => { if (product) { setProduct(prev => ({ ...prev, isFavoriteByUser: isFavorite })); } }, [isFavorite, product, setProduct]); if (isLoading) return <div>Loading product...</div>; if (error) return <div>Error: {error}</div>; if (!product) return <div>Product not found.</div>; return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <button onClick={() => toggleFavorite(productId)}> {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} </button> {/* ... 더 많은 UI 요소 */} </div> ); }
구현 (Vue - 컴포저블):
Vue 3와 Composition API를 사용하여 동일한 제품 상세 시나리오를 Vue에 맞게 조정해 보겠습니다.
<!-- 이전: 대규모 ProductDetails 컴포넌트 --> <template> <div> <div v-if="isLoading">Loading product...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else-if="!product">Product not found.</div> <div v-else> <h1>{{ product.name }}</h1> <p>{{ product.description }}</p> <button @click="toggleFavorite"> {{ isFavorite ? 'Remove from Favorites' : 'Add to Favorites' }} </button> <!-- ... 더 많은 UI 요소 --> </div> </div> </template> <script setup> import { ref, onMounted, watch } from 'vue'; const props = defineProps({ productId: String, }); const product = ref(null); const isLoading = ref(true); const isFavorite = ref(false); const error = ref(null); const fetchProduct = async () => { isLoading.value = true; error.value = null; try { const response = await fetch(`/api/products/${props.productId}`); if (!response.ok) throw new Error('Failed to fetch product'); const data = await response.json(); product.value = data; isFavorite.value = data.isFavoriteByUser; } catch (err) { error.value = err.message; } finally { isLoading.value = false; } }; const toggleFavorite = async () => { // API 호출 시뮬레이션 console.log(`Toggling favorite for product ID: ${props.productId}`); await new Promise(resolve => setTimeout(resolve, 500)); isFavorite.value = !isFavorite.value; // 실제 앱에서는 실제 API 호출을 여기에 넣습니다. }; onMounted(fetchProduct); watch(() => props.productId, fetchProduct); </script>
이제 로직을 컴포저블로 추출해 보겠습니다.
<!-- useProductData.js --> import { ref, onMounted, watch } from 'vue'; export function useProductData(productIdRef) { // productIdRef는 ref입니다. const product = ref(null); const isLoading = ref(true); const error = ref(null); const fetchProduct = async () => { isLoading.value = true; error.value = null; try { const response = await fetch(`/api/products/${productIdRef.value}`); if (!response.ok) throw new Error('Failed to fetch product'); const data = await response.json(); product.value = data; } catch (err) { error.value = err.message; } finally { isLoading.value = false; } }; onMounted(fetchProduct); watch(productIdRef, fetchProduct); return { product, isLoading, error, fetchProduct }; // 필요한 경우 fetchProduct를 노출합니다. } <!-- useFavoriteToggle.js --> import { ref } from 'vue'; export function useFavoriteToggle(initialIsFavorite) { const isFavorite = ref(initialIsFavorite); const toggleFavorite = async (productId) => { // API 통화에 productId가 필요할 수 있습니다. console.log(`Toggling favorite for product ID: ${productId}`); await new Promise(resolve => setTimeout(resolve, 500)); isFavorite.value = !isFavorite.value; // 실제 API 호출 }; return { isFavorite, toggleFavorite }; } <!-- 이후: 리팩토링된 ProductDetails 컴포넌트 --> <template> <div> <div v-if="isLoading">Loading product...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else-if="!product">Product not found.</div> <div v-else> <h1>{{ product.name }}</h1> <p>{{ product.description }}</p> <button @click="toggleFavorite(productId)"> {{ isFavorite ? 'Remove from Favorites' : 'Add to Favorites' }} </button> <!-- ... 더 많은 UI 요소 --> </div> </div> </template> <script setup> import { ref, watchEffect } from 'vue'; import { useProductData } from './useProductData.js'; import { useFavoriteToggle } from './useFavoriteToggle.js'; const props = defineProps({ productId: String, }); const productIdRef = ref(props.productId); // 컴포저블을 위한 prop에서 ref를 생성합니다. const { product, isLoading, error } = useProductData(productIdRef); const { isFavorite, toggleFavorite } = useFavoriteToggle(product.value?.isFavoriteByUser || false); // product 변경 사항을 감시하고 initialIsFavorite을 컴포저블에 업데이트합니다. // 이는 초기 컴포저블 상태가 비동기 데이터에 의존하는 일반적인 패턴입니다. watchEffect(() => { if (product.value) { isFavorite.value = product.value.isFavoriteByUser; } }); // product가 업데이트될 경우, product 내부의 즐겨찾기 상태도 업데이트되도록 합니다. watch(isFavorite, (newVal) => { if (product.value) { product.value.isFavoriteByUser = newVal; } }); </script>
컴포저블/사용자 정의 훅은 다음에 이상적입니다.
- 재사용 가능한 로직 캡슐화(예: 폼 유효성 검사, 데이터 가져오기, 인증).
- 여러 소스에서 파생된 복잡한 상태 관리.
- 컴포넌트의 렌더링 로직을 어지럽히지 않고 부수 효과를 깔끔하게 처리.
자식 컴포넌트(Child Components)
자식 컴포넌트는 대규모 컴포넌트를 분해하는 또 다른 기본적인 방법입니다. 여기서의 원칙은 시각적 및 논리적 경계를 기반으로 컴포넌트 내의 UI와 즉각적인 로직을 분해하는 것입니다.
원칙: 컴포넌트 렌더링 출력 내의 뚜렷한 섹션을 찾습니다. 섹션에 자체 상태(최소한이라도), props 또는 복잡한 렌더링 로직이 있는 경우 자식 컴포넌트로 추출할 후보입니다.
구현 (React):
ProductDetails 예제를 계속 이어나가서, 제품 상세 보기에 ProductReview 섹션과 AddToCartButton이 포함되어 있다고 가정해 보겠습니다.
// 이전: ProductDetails 안의 모든 것 function ProductDetails({ productId }) { // ... 이전과 같은 데이터 가져오기 및 즐겨찾기 토글 로직 ... if (isLoading) return <div>Loading product...</div>; if (error) return <div>Error: {error}</div>; if (!product) return <div>Product not found.</div>; return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <button onClick={() => toggleFavorite(productId)}> {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} </button> {/* 바로 여기에 제품 리뷰 섹션 */} <h2>Reviews</h2> {product.reviews.length > 0 ? ( <ul> {product.reviews.map(review => ( <li key={review.id}> <strong>{review.author}</strong> - {review.rating}/5 <p>{review.comment}</p> </li> ))} </ul> ) : ( <p>No reviews yet.</p> )} {/* 바로 여기에 장바구니 추가 섹션 */} <button onClick={() => alert(`Added ${product.name} to cart!`)}> Add to Cart </button> </div> ); }
이제 ProductReviews와 AddToCartButton을 자식 컴포넌트로 추출해 보겠습니다.
// ProductReviews.jsx function ProductReviews({ reviews }) { if (reviews.length === 0) { return <p>No reviews yet.</p>; } return ( <div> <h2>Reviews</h2> <ul> {reviews.map(review => ( <li key={review.id}> <strong>{review.author}</strong> - {review.rating}/5 <p>{review.comment}</p> </li> ))} </ul> </div> ); } // AddToCartButton.jsx function AddToCartButton({ productName, onAddToCart }) { return ( <button onClick={onAddToCart}> Add {productName} to Cart </button> ); } // 이후: 리팩토링된 ProductDetails 컴포넌트 function ProductDetails({ productId }) { const { product, isLoading, error } = useProductData(productId); const { isFavorite, toggleFavorite } = useFavoriteToggle(product?.isFavoriteByUser || false); // ... 이전과 같은 즐겨찾기 상태 동기화를 위한 useEffect ... if (isLoading) return <div>Loading product...</div>; if (error) return <div>Error: {error}</div>; if (!product) return <div>Product not found.</div>; const handleAddToCart = () => { alert(`Added ${product.name} to cart!`); // 실제 앱에서는 카트 스토어로 액션을 디스패치 }; return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <button onClick={() => toggleFavorite(productId)}> {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} </button> <ProductReviews reviews={product.reviews || []} /> <AddToCartButton productName={product.name} onAddToCart={handleAddToCart} /> </div> ); }
구현 (Vue):
<!-- 이전: ProductDetails 안에 모든 것 (Vue 템플릿 구문을 사용한 React 예제와 유사) --> <!-- 이전 Vue 예제와 동일하지만, 리뷰 및 장바구니 추가 로직이 템플릿에 직접 포함되어 있다고 상상해 보세요 --> <!-- ProductReviews.vue --> <template> <div> <h2>Reviews</h2> <ul v-if="reviews.length > 0"> <li v-for="review in reviews" :key="review.id"> <strong>{{ review.author }}</strong> - {{ review.rating }}/5 <p>{{ review.comment }}</p> </li> </ul> <p v-else>No reviews yet.</p> </div> </template> <script setup> defineProps({ reviews: { type: Array, default: () => [], }, }); </script> <!-- AddToCartButton.vue --> <template> <button @click="handleClick"> Add {{ productName }} to Cart </button> </template> <script setup> const props = defineProps({ productName: String, }); const emit = defineEmits(['add-to-cart']); const handleClick = () => { emit('add-to-cart', props.productName); }; </script> <!-- 이후: 리팩토링된 ProductDetails 컴포넌트 --> <template> <div> <div v-if="isLoading">Loading product...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else-if="!product">Product not found.</div> <div v-else> <h1>{{ product.name }}</h1> <p>{{ product.description }}</p> <button @click="toggleFavorite(productId)"> {{ isFavorite ? 'Remove from Favorites' : 'Add to Favorites' }} </button> <ProductReviews :reviews="product.reviews || []" /> <AddToCartButton :product-name="product.name" @add-to-cart="handleAddToCart" /> </div> </div> </template> <script setup> import { ref, watchEffect } from 'vue'; import { useProductData } from './useProductData.js'; import { useFavoriteToggle } from './useFavoriteToggle.js'; import ProductReviews from './ProductReviews.vue'; import AddToCartButton from './AddToCartButton.vue'; const props = defineProps({ productId: String, }); const productIdRef = ref(props.productId); const { product, isLoading, error } = useProductData(productIdRef); const { isFavorite, toggleFavorite } = useFavoriteToggle(product.value?.isFavoriteByUser || false); watchEffect(() => { if (product.value) { isFavorite.value = product.value.isFavoriteByUser; } }); watch(isFavorite, (newVal) => { if (product.value) { product.value.isFavoriteByUser = newVal; } }); const handleAddToCart = (productName) => { alert(`Added ${productName} to cart!`); // 카트 스토어로 액션 디스패치 }; </script>
자식 컴포넌트는 다음에 효과적입니다.
- 복잡한 UI 블록을 추상화하여 가독성을 향상시킵니다.
- 애플리케이션의 다른 부분에서 UI 요소의 재사용을 가능하게 합니다.
- 렌더링 성능 최적화 (React의
memo또는 Vue의 고유 반응성이 변경되지 않은 자식 컴포넌트의 불필요한 리렌더링을 방지할 수 있습니다). - 각 컴포넌트가 명확한 책임을 갖도록 관심사 분리를 강제합니다.
무엇을 언제 사용할 것인가?
- 사용자 정의 훅/컴포저블은 데이터 및 동작과 관련된 로직 재사용 및 관심사 분리를 위한 것입니다. 직접적으로 아무것도 렌더링하지 않지만 상태와 함수를 제공합니다.
- 자식 컴포넌트는 UI 재사용 및 프레젠테이션과 관련된 관심사 분리를 위한 것입니다. 시각적 인터페이스의 일부를 캡슐화합니다.
종종 둘 다 함께 사용하게 됩니다. 자식 컴포넌트가 자체 내부 로직에 사용자 정의 훅/컴포저블을 활용하여 모듈성을 더욱 향상시킬 수 있습니다.
결론
사용자 정의 훅/컴포저블 및 자식 컴포넌트를 사용하여 React 및 Vue 컴포넌트를 작고 집중된 단위로 분해하는 것은 단순한 스타일 선택이 아니라 유지보수 가능하고 확장 가능하며 고품질의 프론트엔드 애플리케이션을 구축하기 위한 기본 관행입니다. 이러한 전략을 일관되게 적용함으로써 개발자는 더 깔끔한 코드, 쉬운 디버깅, 개선된 재사용성 및 팀 내 향상된 협업을 달성할 수 있으며, 궁극적으로 더 견고하고 즐거운 사용자 경험으로 이어집니다. 이러한 분해 기법을 숙달하여 모놀리식 컴포넌트를 조화로운 모듈식 부분의 오케스트라로 변환하십시오.