티스토리 뷰

 

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 패턴을 사용했다. 핵심 원칙은 관심사 단위로 분리하되, 공유 상태는 외부에서 주입한다는 것이다.

먼저 분리를 할때 보통 두단계로 나눈다.

  1. 상태와 기능에 따른 분리
  2. 상태와 기능 중 서로 다른 관심사 식별 후 분리

이때 관심사 분리시 서로 상호의존성이 심해지지 않는 선에서 분리한다.

✅ 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로 추출하면서, VueUseuseEventListener를 활용했다.

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줄이 되기 전에…

댓글