상속보다 컴포지션이 컴포넌트 개발 방식을 어떻게 재편했는가
Min-jun Kim
Dev Intern · Leapcell

소개
수년간 객체 지향 프로그래밍(OOP) 원칙, 특히 상속은 프론트엔드 컴포넌트 구조 방식에 큰 영향을 미쳤습니다. 개발자들은 종종 클래스 계층 구조를 만들고, 기본 컴포넌트를 확장하고, 복잡한 this 컨텍스트를 탐색하며 로직을 재사용하곤 했습니다. 처음에는 체계적이라는 인식 때문에 매력적이었지만, 이 접근 방식은 종종 "래퍼 지옥(wrapper hell)", 프로퍼티 드릴링(prop drilling), 그리고 부모 클래스의 변경이 자식 컴포넌트를 예기치 않게 망가뜨릴 수 있는 취약한 컴포넌트 구조와 같은 문제로 이어졌습니다. 이 경직된 상속 모델은 유연성을 제한했고, 분산된 컴포넌트 간에 상태 저장 로직을 추출하고 공유하기 어렵게 만들었습니다. 여기서 패러다임 전환이 시작되었습니다: 상속 대신 컴포지션입니다. 오랫동안 소프트웨어 엔지니어링에서 옹호되어 온 이 근본적인 원칙은 React Hooks와 Vue Composition API의 등장으로 프론트엔드 세계에서 극적으로 재활성화되었습니다. 이 획기적인 기능들은 상태 관리 및 부작용 처리를 단순화했을 뿐만 아니라, 재사용 가능한 컴포넌트 로직에 대한 우리의 사고 방식과 작성 방식을 근본적으로 변화시켜, 더 유지보수 가능하고 확장 가능한 애플리케이션을 위한 길을 열었습니다.
컴포넌트 개발의 새로운 시대
Hooks와 Composition API의 자세한 내용으로 들어가기 전에, 이 논의의 밑바탕이 되는 몇 가지 핵심 개념을 간략하게 정의해 봅시다.
- 컴포지션 (Composition): 소프트웨어 엔지니어링에서 컴포지션은 더 작고 더 집중된 함수 또는 객체를 결합하여 더 크고 더 복잡한 것을 구축하는 행위를 말합니다. 새로운 클래스가 기존 클래스를 확장하는 상속과 달리, 컴포지션은 "is-a" 관계보다는 "has-a" 관계에 중점을 둡니다. 이는 유연성과 재사용성을 촉진합니다.
- 캡슐화 (Encapsulation): 데이터(상태)와 해당 데이터를 조작하는 메서드(동작)를 단일 단위로 묶는 것입니다. 프론트엔드 컴포넌트에서는 일반적으로 관련 로직을 함께 유지하는 것을 의미합니다.
- 관심사 분리 (Separation of Concerns): 컴퓨터 프로그램을 기능적으로 최대한 겹치지 않는 별개의 기능으로 나누는 실천 방법입니다. 이는 소프트웨어를 설계, 이해, 유지보수하기 쉽게 만듭니다.
React Hooks의 부상
React 16.8에 도입된 React Hooks는 함수형 컴포넌트가 상태와 부작용을 관리하는 방식을 혁신했습니다. Hooks 이전에는 상태와 생명주기 메서드가 클래스 컴포넌트에서만 가능했으며, 상태나 효과가 필요한 경우 개발자는 함수형 컴포넌트를 클래스로 변환해야 했습니다. 이로 인해 로직 재사용을 위해 "HOC 지옥(HOC hell)" 또는 "렌더 프롭 지옥(render prop hell)"이 발생하는 경우가 많았습니다. Hooks는 컴포넌트 계층 구조를 변경하지 않고도 상태 저장 로직을 재사용할 수 있는 방법을 제공합니다.
핵심적으로 Hook은 함수형 컴포넌트에서 React 기능에 "훅(hook)"할 수 있게 해주는 특별한 함수입니다. useState와 useEffect를 주요 예로 살펴보겠습니다.
상태 관리를 위한 useState
import React, { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); // count를 0으로 초기화 return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); }
여기서 useState는 함수형 컴포넌트가 자체 상태를 유지할 수 있도록 합니다. 컴포넌트는 기본 클래스에서 상태 관리 기능을 상속하는 것이 아니라, useState를 호출하여 이를 컴포즈(compose)합니다.
부작용 관리를 위한 useEffect
import React, { useState, useEffect } from 'react'; function DocumentTitleUpdater() { const [count, setCount] = useState(0); useEffect(() => { // 이것은 각 렌더링 후에 실행됩니다 document.title = `Count: ${count}`; }, [count]); // count가 변경될 때만 효과를 다시 실행 return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(prevCount => prevCount + 1)}>Increment</button> </div> ); }
useEffect는 데이터 가져오기, 구독, DOM 수동 변경과 같은 부작용을 처리합니다. componentDidMount, componentDidUpdate, componentWillUnmount에 로직을 산발적으로 분산시키는 대신, useEffect는 실행 시점이 아닌 무엇을 하는지에 따라 관련 로직을 함께 그룹화할 수 있도록 합니다. 종속성 배열 [count]는 count가 변경될 때만 효과가 다시 실행되도록 하여 제어 및 성능을 더욱 향상시킵니다.
재사용 가능한 로직을 위한 사용자 정의 Hooks
Hooks의 진정한 힘은 사용자 정의 Hooks에 있습니다. 이름이 use로 시작하고 다른 Hooks를 호출할 수 있는 JavaScript 함수입니다. 이를 통해 상태 저장 로직을 재사용 가능한 함수로 추출할 수 있습니다.
import { useState, useEffect } from 'react'; function useWindowWidth() { const [width, setWidth] = useState(window.innerWidth); useEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener('resize', handleResize); return () => { // 정리 함수 window.removeEventListener('resize', handleResize); }; }, []); // 빈 종속성 배열은 이 효과가 마운트 시 한 번 실행되고 언마운트 시 정리됨을 의미 return width; } function MyComponent() { const width = useWindowWidth(); // 창 너비 로직을 컴포즈 return ( <div> <p>Window width: {width}px</p> </div> ); }
useWindowWidth 사용자 정의 Hook은 창 너비 추적 로직을 캡슐화합니다. 이제 어떤 컴포넌트든 상속이나 프로퍼티 드릴링 없이 useWindowWidth()를 호출하여 이 로직을 컴포즈할 수 있습니다. 이는 시각적이지 않은 로직을 공유하는 문제를 직접적으로 해결합니다.
Vue Composition API
Vue 3는 Composition API를 도입했습니다. 이는 가져온 함수를 사용하여 컴포넌트 로직을 컴포즈할 수 있는 추가 API 세트입니다. React Hooks와 매우 유사하게, 이는 Options API(종종 data, methods, computed, watch 및 생명주기 Hook에 걸쳐 분산된 로직으로 비판받음)에 대한 강력한 대안을 제공합니다. Composition API는 유형에 관계없이 논리적 관심사를 함께 그룹화하는 것을 촉진합니다.
setup 함수와 반응형 상태
setup 함수는 컴포넌트에서 Composition API를 사용하기 위한 진입점입니다. 컴포넌트가 생성되기 전에 실행되며, 여기서 반응형 상태, computed 속성, watcher 및 생명주기 Hook을 선언합니다.
<template> <div> <p>Count: {{ count }}</p> <button @click="increment">Increment</button> </div> </template> <script> import { ref, onMounted } from 'vue'; export default { setup() { const count = ref(0); // ref를 사용한 반응형 상태 function increment() { count.value++; } onMounted(() => { console.log('Component mounted!'); }); return { count, increment, }; }, }; </script>
여기서 ref는 기본 유형에 대한 React의 useState와 유사하게 값의 반응형 참조를 생성합니다. onMounted 생명주기 Hook은 setup 내에서 가져와 호출되어 생명주기 관련 사항을 영향을 받는 로직 가까이에 유지합니다.
Composables를 사용한 로직 재사용
Vue에서 커스텀 Hook에 해당하는 것을 "composables"라고 합니다. 이는 상태 저장 로직을 캡슐화하고 컴포넌트 간에 재사용할 수 있는 함수입니다.
// useWindowWidth.js import { ref, onMounted, onUnmounted } from 'vue'; export function useWindowWidth() { const width = ref(window.innerWidth); const handleResize = () => { width.value = window.innerWidth; }; onMounted(() => { window.addEventListener('resize', handleResize); }); onUnmounted(() => { window.removeEventListener('resize', handleResize); }); return { width }; }
<template> <div> <p>Window width: {{ width }}px</p> </div> </template> <script> import { useWindowWidth } from './useWindowWidth'; export default { setup() { const { width } = useWindowWidth(); // 창 너비 로직 컴포즈 return { width }; }, }; </script>
React의 커스텀 Hook과 마찬가지로, 이 useWindowWidth composable은 창 크기 조정 로직을 효과적으로 추상화합니다. 이제 컴포넌트는 이를 직접 구현하거나 상속하지 않고 이 로직을 가져와 활용할 수 있어, 더 깔끔하고 집중된 컴포넌트를 만들 수 있습니다.
컴포넌트 설계에 미치는 영향
React Hooks와 Vue Composition API 모두 "상속 대신 컴포지션" 원칙을 다음을 통해 구현합니다.
- 코드 구성 개선: 관련 로직(예: 데이터 가져오기 및 해당 로딩/오류 상태)은 여러 생명주기 메서드나 옵션에 분산되는 대신 단일 Hook 또는 composable 내에서 함께 그룹화할 수 있습니다.
- 재사용성 향상: 로직은 일반 JavaScript 함수로 추출되어 공유할 수 있으므로, 컴포넌트 트리에 새로운 계층을 도입하지 않고도 상태 저장 및 부작용이 있는 로직을 다양한 컴포넌트에서 쉽게 재사용할 수 있습니다.
- 컴포넌트 계층 단순화: 로직 공유를 위해 고차 컴포넌트(HOC) 또는 렌더 프롭이 필요 없으므로, 래퍼 지옥과 깊게 중첩된 컴포넌트 트리가 줄어듭니다.
- 가독성 및 유지보수성 향상: 특정 기능에 대한 로직이 함께 위치하므로 컴포넌트 이해가 쉬워집니다. 자체 포함된 단위 내에서 로직을 추적할 수 있어 디버깅이 단순화됩니다.
- 유연하고 강력한 추상화: 개발자는 애플리케이션의 요구 사항에 맞는 강력한 추상화를 구축하여 도메인별 재사용 가능한 도구 세트를 만들 수 있습니다.
본질적으로 이러한 API는 개발자가 컴포넌트를 단일 클래스나 분산된 옵션의 모음이 아닌, 동작의 컴포지션으로 생각하도록 합니다.
결론
상속 중심의 컴포넌트 설계에서 React Hooks 및 Vue Composition API를 사용하는 컴포지션 기반 접근 방식으로의 전환은 프론트엔드 개발에서 중요한 진화를 나타냅니다. 상태 저장 로직을 개별 호출 가능한 함수로 추출, 재사용 및 구성할 수 있도록 함으로써 이러한 혁신은 코드 구성, 재사용성 및 가독성을 크게 향상시켰습니다. 이들은 경직되고 취약한 상속 대신 모듈식의 유연한 컴포지션을 선호함으로써 더 강력하고 확장 가능하며 유지보수 가능한 애플리케이션을 구축할 수 있도록 지원합니다. 이 전환은 더 간단하고, 더 집중적이며, 궁극적으로 더 이해하기 쉬운 컴포넌트를 설계하려는 의식적인 움직임을 나타내며, 사용자 인터페이스를 만드는 방식을 근본적으로 재편했습니다.