Vue 프로젝트를 하다 보면 어느 순간 하나의 컴포넌트가 감당할 수 없을 만큼 커지는 시점이 온다.
티스토리 뷰
이번에 이미지 처리 프로젝트의 메인 페이지인 ImgPrcPage.vue 가 정확히 그 상태였다.
script 블록만 1,500줄.
그래프 노드 조작, 프리셋 CRUD, 프로세스 CRUD, 원본 이미지 관리, 썸네일 연산, 확대 팝업, 크롭 관리까지,
하나의 페이지에 10개 이상의 관심사가 뒤엉켜 있었다.
🤕 왜 문제였나
코드가 길다는 것 자체가 문제는 아니다. 진짜 문제는 변경의 파급 범위를 예측할 수 없다는 것이다.
프리셋 저장 로직을 수정하려면 스크롤을 내려서 해당 함수를 찾고,
그 함수가 참조하는 nodes, edges, oOrigin 같은 상태가 어디서 변경되는지 추적해야 했다.
그런데 그 상태들은 썸네일 연산에서도, 프로세스 로드에서도, 캔버스 초기화에서도 쓰인다.
결국 무언가를 고치려면 파일 전체를 머릭속에 올려놓아야 했다.
거기에 FilterNode.vue와 SourceNode.vue에 복사-붙여넣기된 드래그 리사이즈 로직 40줄,
useCropManager 안에서 같은 함수가 두 번 정의된 것까지.
작은 문제들이 쌓이면 코드를 건드리기 싫어지고,
건드리기 싫어지면 기술 부채가 된다.
⛔ Before: 하나의 script에 뒤엉킨 상태와 함수
리팩토링 전 ImgPrcPage.vue의 script 영역을 보면, 관심사가 다른 상태와 함수가 선언 순서대로 나열되어 있었다. 아래는 실제 코드에서 발췌한 조각들이다.
// ── vue-flow (그래프) ──
const nodes = ref([...]);
const edges = ref([]);
const selectedNodeId = ref(null);
// ── Preset ──
const presets = ref([]);
const activePresetId = ref(null);
const showSavePresetDialog = ref(false);
const presetDialogName = ref('');
const presetDialogDescription = ref('');
// ── 처리목록 ──
const processList = ref([]);
const activeProcessId = ref(null);
// ── 원본 이미지 ──
const oOrigin = ref({ fileId: null, imageUrl: null, width: null, height: null });
const originalInputRef = ref(null);
const showImageGallery = ref(false);
// ── Crop ──
const cropMgr = useCropManager(...);
const showCropDialog = ref(false);
const cropDialogSrc = ref('');
// ── 파라미터 패널 ──
const showOptionPanel = ref(false);
const optionPanelTarget = ref(null);
// ── 노드 사이즈 ──
const nodeSizeInput = ref(settingsStore.nodeSize.width);
// ── 확대 팝업 ──
const zoomPopups = ref([]);
// ... 이 아래로 함수가 약 60개
function addFilterNode() { ... } // 그래프
function loadPresets() { ... } // Preset
function setOriginalFile() { ... } // 원본 이미지
function processNodeThumbnail() { ... } // 썸네일
function onNodeZoom() { ... } // 팝업
function onSelectCrop() { ... } // Crop
function onConfirmProcess() { ... } // 처리목록
function collectPathToNode() { ... } // 그래프 알고리즘
function hasCycle() { ... } // 그래프 알고리즘
// ...
상태 선언만 30개 이상, 함수 60개 이상. 프리셋 상태 바로 아래에 프로세스 상태가 있고,
그 아래에 원본 이미지 상태가 있다. 함수도 마찬가지로 관심사가 뒤섞여 있어서,
프리셋 관련 코드가 어디 있지?
하고 찾으려면 파일 전체를 훑어야 했다. 😭
👍 After: Composable로 응집된 구조
리팩토링 후 ImgPrcPage.vue의 script는 이렇게 바뀌었다.
// ── 공유 상태 ──
const sharedNodes = ref([...]);
const sharedEdges = ref([]);
// ── Composables ──
const originImage = useOriginImage(sharedNodes, ...);
const graph = useFilterGraph(fileIdRef, callbacks, sharedNodes, sharedEdges);
const cropMgr = useCropManager(...);
const thumbProcessor = useThumbnailProcessor(sharedNodes, sharedEdges, ...);
const presetMgr = usePresetMgr(sharedNodes, sharedEdges, ...);
const processMgr = useProcessMgr(sharedNodes, sharedEdges, ...);
const zoomPopup = useZoomPopup(sharedNodes, sharedEdges, ...);
// ── 페이지 고유 로직 (Crop 핸들러, 설정 토글 등) ──
function onSelectCrop(cropId) { ... }
function toggleFullResolution() { ... }
// ...
그리고 각 composable 내부에는 해당 관심사의 상태와 함수가 함께 들어있다.
// usePresetMgr.ts — Preset 관련 모든 것이 이 파일 안에
export function usePresetMgr(nodes, edges, ...) {
const presets = ref([]);
const activePresetId = ref(null);
const showSavePresetDialog = ref(false);
const presetDialogName = ref('');
const presetDialogDescription = ref('');
const isEditingPreset = ref(false);
async function loadPresets() { ... }
function loadPreset(preset) { ... }
function openSavePresetDialog() { ... }
function openUpdatePresetDialog() { ... }
async function onConfirmPreset(name, description) { ... }
async function removePreset(presetId) { ... }
return { presets, activePresetId, showSavePresetDialog, ... };
}
프리셋 로직을 수정하고 싶으면 usePresetMgr.ts만 열면 된다.
상태 6개와 함수 6개가 100줄 안에 모여 있다. ☺️
이전에는 1500줄 가량의의 파일에서 이 12개를 찾아 헤매야 했다. 😱
🚀 어떻게 나눴나
Vue 3의 Composition API가 제공하는 composable 패턴을 사용했다. 핵심 원칙은 관심사 단위로 분리하되, 공유 상태는 외부에서 주입한다는 것이다.
먼저 분리를 할때 보통 두단계로 나눈다.
- 상태와 기능에 따른 분리
- 상태와 기능 중 서로 다른 관심사 식별 후 분리
이때 관심사 분리시 서로 상호의존성이 심해지지 않는 선에서 분리한다.
✅ Composable 간 공유 상태가 있는 경우
상태와 기능을 응집할 때 별도의 관심사로 분리해야 하지만 상태는 동일한 것을 사용할 때가 있는데,
다음과 같은 경우가 그랬다.
useFilterGraph , useThumbnailProcessor 는
둘다 nodes , edges 라는 상태를 사용하여 기능이 응집되어있다.
관심사는 다르지만 같은 상태를 쓰는 경우다.
그래서 공유 ref를 외부에서 생성해서 주입했다.
// 공유 상태: nodes, edges
const nodes = ref([...]);
const edges = ref([]);
const graph = useFilterGraph(fileId, callbacks, nodes, edges);
const thumbProcessor = useThumbnailProcessor(nodes, edges, ...);
상태를 composable 내부에서 만들지 않고,
상위 호출부 (여기서는 페이지) 하나만 만들어서 여러 composable이 공유한다.
단순하지만 효과적이다. ✨
꼭 내부에서만 상태를 제어할 필요가 없다는 점에서 OOP Class와는 차이점이 드러난다.
✅ Composable 간 상호의존성이 생긴 경우
1️⃣ 순수함수와 래퍼 함수를 통해 상호의존도 와 기능 응집을 한번에 해결
기능을 응집할때 composable 간 순환 참조를 해야하는 경우가 있다. 다른 Composable에 기능을 쓰고 싶다면 먼저 순수함수로 분리가 가능한지 확인해보자.
useFilterGraph.ts
// Composable 내 순수함수 export
// 다른 함수에서 해당 함수 import 하여 사용
export function collectDescendantLeaves(nodeId: string, edges: Edge[]): string[] {
const children = edges.filter((e) => e.source === nodeId).map((e) => e.target);
if (children.length === 0) return [nodeId];
const leaves: string[] = [];
for (const childId of children) {
leaves.push(...collectDescendantLeaves(childId, edges));
}
return leaves;
}
// composable 내에서는 내부 state를 통한 래퍼함수 생성
// edges 내부 상태를 사용하여 래핑
function _collectDescendantLeaves(nodeId: string): string[] {
return collectDescendantLeaves(nodeId, edges.value);
}
function onChangeFilter() {
...
// 기타 내부함수에서는 래퍼함수를 사용
_collectDescendantLeaves(nodeId)
}
useThumbnailProcessor.ts
// Composable에서 해당 함수 import하여 사용
import { collectDescendantLeaves } from './useFilterGraph';
...
const leafIds = collectDescendantLeaves(SOURCE_NODE_ID, edges.value);
...
위와 같이 순수함수로 분리후 래퍼함수를 사용하면 다른 모듈에서도 import하여 해당 기능을
그대로 사용가능하다.
composable내에 기능을 응집하면서도 손쉽게 해당 기능을 공유하고 싶을때는
순수함수 + 래퍼함수 패턴을 사용해보자.
2️⃣ composable 간 기능 조율
composable이 다른 composable의 함수를 주입받아 내부에서 호출하면
의존 방향이 모호해지고 인자가 늘어난다.
관심사에 따라 분리를 할 때 생기는 현상으로 상호의조선성이 생기지 않는지 조심해야한다.
다음은 최초에 분리형태이다.
⛔ Before: 함수 주입 방식
// usePresetMgr가 relayout, processAllLeaves를 주입받아 내부에서 호출
const presetMgr = usePresetMgr({
nodes, edges,
oOriginImageUrl: ...,
getDefaultParams: graph.getDefaultParams, // 주입
relayout: graph.relayout, // 주입
processAllLeaves: () => thumb.processAllLeaves(), // 주입
});
// composable 내부
function loadPreset(preset) {
// ... flow 변환
nodes.value = flow.nodes;
edges.value = flow.edges;
nextTick(() => {
relayout(); // 다른 composable의 함수를 내부에서 호출
processAllLeaves(); // 다른 composable의 함수를 내부에서 호출
});
}
composable이 "프리셋 로드 후 레이아웃 + 썸네일 갱신"이라는 흐름 전체를 알고 있어야 한다.
이건 프리셋의 관심사가 아니다.
이러한 로직 흐름은 프로세스 흐름으로 상위에서 조율해야하는 부분이다.
그것이 Composable에 기능 합성 개념을 잘 사용하는 일이다.
👍 After: 상위 조율 방식
composable은 자기 상태를 변경하고 결과를 반환하는 데 집중하고,
"프리셋 로드 → 레이아웃 → 썸네일"이라는 연쇄 동작은 페이지가 조합한다.
ImgPrcPage.vue
// composable은 자기 상태만 변경
const presetMgr = usePresetMgr({
nodes, edges,
oOriginImageUrl: ...,
// relayout, processAllLeaves 주입 없음!
});
// 페이지에서 흐름을 조율
function onSelectPreset(preset: PresetRes) {
presetMgr.loadPreset(preset); // 1. 프리셋 로드
nextTick(() => {
graph.relayout(); // 2. 레이아웃
thumbnailProcessor.processAllLeaves(); // 3. 썸네일 갱신
});
}
composable은 자기 상태를 변경하고 결과를 반환하는 데 집중하고,
"프리셋 로드 → 레이아웃 → 썸네일"이라는 연쇄 동작은 페이지가 조합한다.
이렇게 하면:
- composable 간 순환 참조가 사라진다
- 선언 순서가 자유로워진다
- 각 composable을 독립적으로 테스트할 수 있다
관심사를 분리 후에 상호 의존성이 생긴다면, 흐름을 조율하는 부분이 필요하지 않나 고민해봐야할 것 같다.
현재는 Page에서 조율을 했지만, Comopsable간 상호작용이 복잡할 때는 또다른 조율 Composable을 생성하는 것도 고려해볼만 할 것 같다. 🚴
중복 이벤트 기능 분리: useNodeResize
FilterNode.vue와 SourceNode.vue에 동일하게 복붙된 드래그 리사이즈 코드가 있었다.
40줄짜리 mousedown/mousemove/mouseup 핸들러.
이걸 useNodeResize composable로 추출하면서, VueUse의 useEventListener를 활용했다.
useNodeResize.ts
export function useNodeResize(...) {
// Move
function onResizeMouseMove(e: MouseEvent) {
....
}
// Down, listener 등록
function onResizeMouseDown(e) {
// ...
cleanupMove = useEventListener(window, 'mousemove', onResizeMouseMove);
cleanupUp = useEventListener(window, 'mouseup', onResizeMouseUp);
}
// Up, listener 해제
function onResizeMouseUp() {
cleanupMove?.();
cleanupUp?.();
}
// MouseDown 핸들러만 노출
return { ..., onResizeMouseDown };
}
기존에는 이벤트 등록 시 window.addEventListener로 등록하고 removeEventListener로 해제했는데,
리사이즈 도중에 컴포넌트가 언마운트되면 리스너가 남는 메모리 누수 위험이 있었다.
useEventListener는 컴포넌트 언마운트 시 자동으로 정리해주고,
반환값으로 수동 해제도 가능해서 두 가지 시나리오를 모두 커버한다.
VueUse에 유용한 Composable이 많이 정의되어있으니 활용하도록 하자. 👏
🏁 최종 구조
분리한 composable은 7개다.
Composable 역할
| useFilterGraph | 노드/엣지 CRUD, 그래프 알고리즘, 레이아웃 |
| useThumbnailProcessor | 썸네일 연산, debounce + abort 관리 |
| useOriginImage | 원본 이미지 업로드/선택/동기화 |
| usePresetMgr | Preset CRUD |
| useProcessMgr | Process CRUD |
| useZoomPopup | 확대 팝업, 다운로드, 체인 복사 |
| useNodeResize | 드래그 리사이즈 (FilterNode/SourceNode 공통) |
ImgPrcPage.vue의 script는 1500줄에서 361줄로 줄었다. 약 75% 감소. 하지만 줄 수보다 중요한 건, 이제 프리셋 로직을 수정할 때 usePresetMgr.ts 한 파일만 보면 된다는 것이다. 👍
돌아보며 🤔
Composable 분리에서 가장 어려운 부분은
관심사를 어떻게 분리할 것인가?
최종적으로는 다음 단계를 따라서 최종 분리가 되었다.

한 가지 아쉬운 점이 있다면, 처음부터 composable 단위로 설계했다면 더 깔끔했을 것이다.
하지만 현실에서는 기능이 먼저 돌아가야 하고, 구조는 나중에 정리하게 된다. (다 알자나요?…)
중요한 건 정리할 타이밍을 놓치지 않는 것⭐✨ ****이다.
1,500줄이 3,000줄이 되기 전에…
'Vue.js' 카테고리의 다른 글
| [nuxt3] 초기 프로젝트 설정 (with. eslint, prettier, quasar 모듈) (0) | 2025.07.13 |
|---|---|
| [vue] TypeError: Cannot read properties of null (reading 'isCE') 해결 (vue & vite 사용자) (0) | 2024.03.12 |
| [vue] Computed, Watch 공통점과 차이점 간단 정리 (0) | 2022.12.03 |
| [Vue] Comoponent 내 상수 관리 (0) | 2022.12.03 |
| [Vue3] Child Component에 v-model 사용하기 (0) | 2022.08.30 |
- Total
- Today
- Yesterday
- nuxt3 프로젝트 설정
- python venv 구성
- nuxt3 structure
- Oracle Database 19C 설치
- unplugin-auto-import
- 스파르타 코딩클럽
- nuxt3 eslint prettier 설정
- vue watch 위험성
- python Pydantic
- unmounted document.addlistener
- Composable vs Component
- docker mssql 이미지 생성
- vue watch 대체
- 외래키 삭제
- Composable vs Class
- docker mssql
- 티스토리챌린지
- 오블완
- nuxt3 quasar 설정
- vue watch 문제점
- Oracle Database 19c install
- Pydantic 기능
- Compoent
- docker mssql create database
- 의존성 패키지 관리
- oracle 19c 설치
- 스마트피싱보호_캠페인
- Pydantic 기초
- vue 리팩토링
- FastAPI 초기 구성
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
