1. 개요
현재 개발 중인 프로젝트의 초기 설계 구조를 분석하고, 유지보수성과 확장성 측면에서 발견된 문제점을 해결하기 위해 어떻게 설계를 개선했는지 그 과정과 결과를 정리한 글입니다.
프로젝트의 게임기 컴포넌트인 `Device` 컴포넌트는 크게 애플리케이션을 조작하는 `Controls`과 `Controls`에 의한 변경 사항이 반영되는 `Screen`으로 이루어져 있습니다.
2. 이전 설계 구조 분석
2.1. 핵심 코드 구조
초기 설계는 단일 기능인 '뽀모도로 타이머'를 구현하는 데 초점이 맞춰져 있었습니다. 핵심 컴포넌트들의 데이터 흐름과 책임은 다음과 같습니다.
이전 App.tsx
// App.tsx
function App() {
const [currentScreen, setCurrentScreen] = useState<ScreenType>("title");
// ...
return (
// ...
<Device
currentScreen={currentScreen}
setCurrentScreen={setCurrentScreen}
/>
// ...
);
}
- 역할:
currentScreen상태를 관리하며Device컴포넌트에 상태와 상태 변경 함수를 전달합니다.
이전 Device.tsx
// Device.tsx
export function Device({ currentScreen, setCurrentScreen }: DeviceProps) {
const {
// ... 뽀모도로 타이머 상태와 핸들러들
handleAButton,
handleBButton,
// ...
} = usePomodoroTimer();
return (
<div className={styles.device}>
<div className={styles.upper}>
<Screen /* ...뽀모도로 타이머 props... */ />
</div>
<div className={styles.lower}>
<Controls
setCurrentScreen={setCurrentScreen}
onA={() => { playSound(); handleAButton(); }}
onB={() => { playSound(); handleBButton(); }}
// ... 다른 핸들러들
/>
</div>
</div>
);
}
- 역할:
usePomodoroTimer훅을 직접 호출하여 뽀모도로 타이머의 모든 상태와 비즈니스 로직을 소유합니다. 이 로직들을Screen과Controls컴포넌트에 props로 직접 전달합니다.
이전 Controls.tsx
// Controls.tsx
export function Controls({
setCurrentScreen,
onUp,
onDown,
onA,
onB,
// ...
}: ControlsProps) {
// ...
return (
// ...
<button onMouseDown={onA}>A</button>
<button onMouseDown={onB}>B</button>
// ...
);
}
- 역할: 상위 컴포넌트(
Device)로부터onA,onB등 각 버튼에 해당하는 핸들러 함수를 개별적으로 전달받아 실행합니다.
2.2. 이전 구조의 문제점
이 구조는 뽀모도로 타이머라는 단일 기능을 구현하는 데는 문제가 없지만, 새로운 기능을 추가하는 상황에서는 다음과 같은 문제점을 드러냅니다.
- 강한 결합:
Device컴포넌트는usePomodoroTimer훅에 직접적으로 의존하며, 뽀모도로 타이머의 세부적인 동작(예:handleAButton)을 모두 알고 있어야 합니다.Controls컴포넌트는onA,onB등의 props를 통해 상위 컴포넌트의 특정 기능(뽀모도로 타이머 제어)과 강하게 결합되어 있습니다. 새로운 기능(예: 계산기)을 추가하려면Controls의 props 목록 자체가 변경되어야 할 수도 있습니다.
- 낮은 확장성:
- 만약 '계산기' 기능을 추가한다면,
Device컴포넌트는useCalculator와 같은 새로운 훅을 추가로 호출하고, 현재 활성화된 기능이 무엇인지에 따라Controls에pomodoro.handleAButton또는calculator.handlePlusButton을 선택적으로 전달하는 복잡한 조건 분기(if/switch)를 가져야 합니다. - 이는 새로운 기능이 추가될 때마다
Device컴포넌트의 복잡도를 기하급수적으로 증가시킵니다.
- 만약 '계산기' 기능을 추가한다면,
- 관심사의 분리 원칙(SoC) 위배:
Device컴포넌트는 상태 관리, 비즈니스 로직(타이머), 자식 컴포넌트로의 props 전달 등 너무 많은 책임을 동시에 수행하고 있습니다.Controls컴포넌트는 단순한 사용자 입력 전달자여야 하지만,onA,onB와 같은 명명된 props를 통해 간접적으로 '뽀모도로 타이머'라는 특정 도메인을 인지하게 됩니다.
3. 개선된 설계 구조
이러한 문제들을 해결하기 위해 피처(Feature) 기반 아키텍처와 커맨드 패턴(Command Pattern)을 도입하여 프로젝트 구조를 리팩터링했습니다.
3.1. 핵심 설계 원칙
- 피처(Feature) 단위 모듈화: 뽀모도로 타이머, 계산기 등 각 기능은 자신만의 폴더(
src/features/) 내에서 컴포넌트, 훅, 타입 등을 독립적으로 소유합니다. 이를 통해 기능별 응집도를 높이고 다른 기능과의 결합도를 낮춥니다. - 중앙에서 상태 관리:
Device컴포넌트가 '두뇌' 역할을 하며, 어떤 피처를 활성화할지, 그에 따른 컨트롤러의 동작은 무엇인지를 모두 결정하고 제어합니다. - 커맨드 패턴을 통한 요청 캡슐화:
Controls컴포넌트는 더 이상onA,onB와 같은 개별 함수를 받지 않습니다. 대신, 현재 활성화된 기능에 필요한 모든 액션 함수가 담긴actions객체(커맨드 객체) 하나만 전달받아, 요청자(Controls)와 수신자(피처 로직)를 완전히 분리합니다.
3.2. 개선된 코드 구조
개선한 Device.tsx
// Device.tsx
export function Device() {
const [currentScreen, setCurrentScreen] = useState<ScreenType>("title");
const pomodoroTimer = usePomodoroTimer(); // 포모도로 피처의 로직
// 현재 화면(currentScreen)에 따라 '명령' 객체를 동적으로 생성
const actions = useMemo(() => {
const playClickSound = () => playSound({ sound: clickSound });
switch (currentScreen) {
case "timer":
return { // 뽀모도로 타이머가 활성화되었을 때의 명령들
onA: () => { playClickSound(); pomodoroTimer.handleAButton(); },
onB: () => { playClickSound(); pomodoroTimer.handleBButton(); },
// ...
};
case "title":
return { // 타이틀 화면일 때의 명령들
onA: () => { playClickSound(); setCurrentScreen("timer"); },
};
default:
return {}; // 아무 동작도 하지 않는 기본 명령
}
}, [currentScreen, pomodoroTimer]);
return (
<div className={styles.device}>
<div className={styles.upper}>
<Screen /* ...필요한 상태 전달... */ />
</div>
<div className={styles.lower}>
{/* '명령' 객체를 통째로 전달 */}
<Controls setCurrentScreen={setCurrentScreen} actions={actions} />
</div>
</div>
);
}
- 개선점:
Device는currentScreen상태에 따라 적절한actions객체를 생성하는 '두뇌'의 역할을 명확히 수행합니다.pomodoroTimer와Controls사이의 직접적인 연결이 사라지고,actions객체가 중간에서 매개 역할을 합니다.
개선한 Controls.tsx
// Controls.tsx
interface ControlsProps {
setCurrentScreen: (screen: ScreenType) => void;
actions: { // '명령' 객체를 받는 단일 prop
onUp?: () => void;
onDown?: () => void;
onA?: () => void;
onB?: () => void;
// ...
};
}
export function Controls({ setCurrentScreen, actions }: ControlsProps) {
// ...
return (
// ...
<button onMouseDown={actions.onB}>B</button>
<button onMouseDown={actions.onA}>A</button>
// ...
);
}
- 개선점:
Controls는actions객체의 내용이 무엇인지 전혀 신경 쓰지 않습니다. 그저actions.onA가 있으면 실행할 뿐입니다. 이로써Controls는 어떤 기능에도 종속되지 않는, 완전히 재사용 가능한 범용 입력 컴포넌트가 되었습니다.
3.3. 개선 결과
- 향상된 확장성:
- 새로운 '계산기' 기능을 추가하려면,
src/features/Calculator폴더를 만들고,Device.tsx의useMemo안에case "calculator":블록을 추가하여 계산기용actions객체를 정의하기만 하면 됩니다.Controls.tsx는 한 줄도 수정할 필요가 없습니다.
- 새로운 '계산기' 기능을 추가하려면,
- 향상된 유지보수성:
- 특정 버튼의 동작을 수정하고 싶을 때, 더 이상 여러 컴포넌트를 넘나들며 props의 흐름을 추적할 필요가 없습니다.
Device.tsx의actions생성 로직만 보면 해당 기능의 모든 컨트롤러 동작을 파악하고 수정할 수 있습니다.
- 특정 버튼의 동작을 수정하고 싶을 때, 더 이상 여러 컴포넌트를 넘나들며 props의 흐름을 추적할 필요가 없습니다.
- 명확한 관심사 분리:
App: 애플리케이션의 전체 레이아웃Device: 어떤 피처를 보여줄지, 그 피처는 어떻게 제어되는지를 결정하는 컨트롤 타워Screen:Device의 결정에 따라 적절한 피처 UI를 렌더링하는 디스플레이Controls: 사용자 입력을 받아Device가 내려준 명령을 실행하는 역할features/*: 각각의 독립적인 기능 로직과 UI를 캡슐화한 블랙박스
4. 결론
초기 설계는 단일 기능 구현에는 효율적이었으나 확장성에 한계가 명확했습니다. 피처 기반 아키텍처와 커맨드 패턴을 도입한 새로운 설계는 컴포넌트 간의 결합도를 낮추고 각자의 역할을 명확히 하여, 향후 새로운 기능을 쉽고 안전하게 추가할 수 있는 유연하고 확장 가능한 기반을 마련했습니다. 이는 추후에 넣고자 하는 로그인 기능, 미니게임(테트리스) 등을 추가하여 확장할 때 기존의 코드 수정을 최소화해줄 것입니다.
'개발' 카테고리의 다른 글
| [Three.js] Vite + React Three Fiber 환경에서 배포 시 GLTF 모델이 로드되지 않는 현상 해결 (1) | 2025.06.08 |
|---|