- 발행일
분류체계와 메타클래스 트리 공통 로직을 헬퍼 4종으로 추출한 회고
분류체계와 메타클래스 트리 공통 로직을 헬퍼 4종으로 추출한 회고
schemeTree.js(분류체계)와 metaClassTree.js(메타클래스)를 들여다보다가, 두 파일이 사실상 같은 동작을 다른 코드로 두 번 작성하고 있다는 걸 발견했습니다. 추출해서 헬퍼 4종으로 모은 과정을 정리합니다.
1. 시작 — "비슷해 보이는데 정말 같은 건가?"
분류체계 트리와 메타클래스 트리는 시각적으로 매우 비슷합니다. 둘 다 좌측에 jstree, 우측에 선택 노드의 정보를 표시하는 패널. 다만 트리에 표시하는 데이터가 다르니까 "내부 로직도 다르겠지" 라는 게 처음 가정이었어요.
코드를 비교해보니 의외였습니다. jstree 옵션 객체부터 이벤트 핸들러까지, 차이라곤:
- 데이터 fetch URL이 다름
- 노드 클릭 시 어디로 상세를 보여줄지가 다름
- 노드 아이콘 종류가 살짝 다름
그 외 95%는 동일했습니다.
// schemeTree.js (요지)
const $tree = $('#scheme-tree').jstree({
core: {
data: { url: '/api/schemes/tree/', dataType: 'json' },
themes: { name: 'proton' },
check_callback: true,
},
plugins: ['types', 'state', 'wholerow'],
types: { default: { icon: 'fa fa-folder' }, leaf: { icon: 'fa fa-file' } },
});
// metaClassTree.js (요지)
const $tree = $('#metaclass-tree').jstree({
core: {
data: { url: '/api/meta-class/tree/', dataType: 'json' },
themes: { name: 'proton' },
check_callback: true,
},
plugins: ['types', 'state', 'wholerow'],
types: { default: { icon: 'fa fa-folder' }, leaf: { icon: 'fa fa-file-code' } },
});
거의 동일하죠. 게다가 두 파일의 이벤트 핸들러도 거의 같았습니다.
$tree.on('select_node.jstree', (e, data) => {
const node = data.node;
// 패널에 정보 표시 + 트리 위치 유지
});
$tree.on('refresh.jstree', () => {
// 마지막 선택 복원
});
2. 추출 결정 — 헬퍼 4종
비슷한 두 코드를 한 곳으로 합칠 때 "한 함수에 옵션 객체로 다 받게" 가 흔한 함정입니다. 그 함수는 결국 인자가 10개 이상으로 부풀어 오르고, 두 호출처 모두 가독성이 떨어지게 돼요.
대신 작은 헬퍼 4개로 쪼갰습니다.
헬퍼 1: buildJstreeConfig(options)
jstree 옵션 객체를 만들어주는 빌더. 변하는 부분만 인자로 받습니다.
// static/js/tree/treeHelpers.js
export function buildJstreeConfig({ url, leafIcon = 'fa fa-file' }) {
return {
core: {
data: { url, dataType: 'json' },
themes: { name: 'proton' },
check_callback: true,
},
plugins: ['types', 'state', 'wholerow'],
types: {
default: { icon: 'fa fa-folder' },
leaf: { icon: leafIcon },
},
};
}
호출하는 쪽은 두 줄입니다.
$('#scheme-tree').jstree(buildJstreeConfig({ url: '/api/schemes/tree/' }));
$('#metaclass-tree').jstree(
buildJstreeConfig({ url: '/api/meta-class/tree/', leafIcon: 'fa fa-file-code' })
);
헬퍼 2: bindNodeSelect(tree, onSelect)
노드 선택 이벤트 바인딩. onSelect는 트리마다 다른 동작을 받는 콜백.
export function bindNodeSelect($tree, onSelect) {
$tree.on('select_node.jstree', (e, data) => {
onSelect(data.node);
});
}
헬퍼 3: refreshNode(tree, nodeId)
노드를 새로고침하면서 선택 상태를 유지. 인라인 폼에서 저장 후 호출하는 패턴.
export function refreshNode($tree, nodeId) {
$tree.jstree(true).refresh_node(nodeId);
$tree.one('refresh.jstree', () => {
$tree.jstree('select_node', nodeId);
});
}
헬퍼 4: getSelectedPath(tree)
선택된 노드의 root-to-node 경로를 반환. 우측 패널에 breadcrumb 표시할 때 사용.
export function getSelectedPath($tree) {
const tree = $tree.jstree(true);
const selected = tree.get_selected(true)[0];
if (!selected) return [];
return tree.get_path(selected, false, true).map(id => tree.get_node(id));
}
3. 추출 후 호출처
schemeTree.js와 metaClassTree.js가 각각 150줄 → 60줄 정도로 줄었습니다. 본질에 집중된 코드만 남았어요.
// schemeTree.js (after)
import * as TreeH from './tree/treeHelpers.js';
const $tree = $('#scheme-tree').jstree(
TreeH.buildJstreeConfig({ url: '/api/schemes/tree/' })
);
TreeH.bindNodeSelect($tree, node => {
// 분류체계 우측 패널에 정보 채우기
fillSchemePanel(node);
});
window.refreshScheme = id => TreeH.refreshNode($tree, id);
metaClassTree.js 도 거의 같은 구조. "트리마다 다른 동작은 콜백 한 줄로 끝나야 한다" 가 추출의 기준이었습니다.
4. 추출에서 의식적으로 피한 함정
4.1 옵션 폭발
옵션 객체에 모든 차이를 다 받게 하면 함수가 비대해집니다. 대신 "콜백으로 외부에서 주입" 으로 받게 하면 옵션이 작아져요. bindNodeSelect(tree, onSelect) 가 좋은 예. 노드 선택 후 무엇을 할지는 외부 책임.
4.2 너무 일찍 일반화
세 번째 트리(향후 만들 수도 있는 카탈로그 트리 같은)를 미리 가정하지 않았습니다. 두 호출처가 만들어진 시점의 공통점만 빼냈고, 세 번째가 나타나면 그 때 더 다듬을 생각이에요.
4.3 상태 보관소를 헬퍼에 넣지 않기
헬퍼 4종 모두 무상태(stateless)입니다. $tree 인스턴스를 호출자가 보관하고, 헬퍼는 받아서 처리만 함. 헬퍼 자체에 전역 상태를 넣으면 두 트리 사이 간섭이 생깁니다.
5. 부수 작업 — jstree 옵션 통일
추출하면서 발견한 작은 차이도 같이 정리했어요.
- 한쪽은
wholerow플러그인이 있고, 한쪽은 없었음 → 둘 다 있도록 통일 - 한쪽은
state플러그인으로 마지막 펼침 상태 유지, 한쪽은 매번 초기화 → 양쪽 다 유지하도록 - 자식 노드 아이콘이 한쪽은
fa-folder-open, 한쪽은fa-folder였음 → 일관되게fa-folder통일
이게 fix: term 트리 자식 노드 아이콘을 폴더로 통일 커밋이 된 부분입니다.
6. 검증
추출 리팩토링의 검증 기준은 "두 화면의 동작이 이전과 같은가" 입니다.
| 항목 | 결과 |
|---|---|
| 분류체계 트리 | 로드·선택·펼침 동작 이전과 동일 |
| 메타클래스 트리 | 동일, 리프 아이콘만 fa-file-code 유지 |
| 저장 후 갱신 | refreshNode 호출 시 선택 상태 유지 |
| breadcrumb | getSelectedPath로 root-to-node 경로 표시 |
| 코드 규모 | 각 파일 약 150줄 → 60줄 |
| 헬퍼 간섭 | 두 트리를 같은 세션에서 오갈 때 상호 영향 없음 |
7. 지금 다시 보면 걸리는 것
① "통일"이라고 적었지만 사실은 동작 변경이었습니다.
5절의 세 항목 중 두 번째가 문제입니다.
한쪽은
state플러그인으로 마지막 펼침 상태 유지, 한쪽은 매번 초기화 → 양쪽 다 유지하도록
이건 스타일 통일이 아니라 사용자에게 보이는 동작이 바뀐 변경이에요. 매번 초기화되던 트리가 이제 이전 펼침 상태를 기억합니다. 좋은 방향이라고 판단해서 넣었지만, 순수 추출 리팩토링과 같은 작업에 섞였습니다.
이게 왜 문제냐면, 나중에 "트리 펼침이 이상하다"는 제보가 왔을 때 리팩토링 때문인지 동작 변경 때문인지 구분이 안 되기 때문입니다. 추출은 추출대로 하고, 동작 변경은 따로 커밋했어야 했어요. 아이콘 통일은 별도 커밋으로 뺐으면서 이건 안 뺐습니다.
② 헬퍼는 무상태지만 그 아래는 유상태입니다.
4.3절에서 "헬퍼 4종 모두 무상태" 라고 적었는데, state 플러그인은 localStorage에 펼침 상태를 저장합니다. 헬퍼가 상태를 안 들고 있을 뿐, 그 아래 계층은 전역 저장소를 씁니다.
두 트리가 저장 키를 어떻게 나눠 갖는지는 이 헬퍼가 통제하지 않아요. 지금 문제가 없다면 jstree가 알아서 구분해주고 있는 것이지, 우리가 설계한 결과는 아닙니다. "무상태"라고 쓸 때는 어느 계층까지의 이야기인지 밝혀야 했습니다.
③ refreshNode의 리스너 등록 순서가 위험합니다.
export function refreshNode($tree, nodeId) {
$tree.jstree(true).refresh_node(nodeId); // ① 먼저 갱신을 트리거하고
$tree.one('refresh.jstree', () => { // ② 그 다음에 리스너를 건다
$tree.jstree('select_node', nodeId);
});
}
갱신이 동기적으로 끝나버리면 이벤트가 리스너보다 먼저 발생합니다. 그러면 select_node가 영영 안 불리고, 저장 후 선택이 풀린 채로 남아요. 지금 안 터지는 건 갱신이 비동기(fetch)라 운이 좋은 것이지, 순서가 맞아서가 아닙니다.
리스너를 먼저 걸고 그다음에 갱신을 트리거하는 게 맞습니다. "지금 동작한다"와 "순서가 옳다"는 다른 얘기라는 걸 이 세 줄이 잘 보여줍니다.
④ 모듈 방식이 프로젝트 안에서 갈립니다.
이 헬퍼는 export/import로 쓰는데, D3 줌 컨트롤은 window.attachZoomControls 전역 방식입니다. 같은 프로젝트에서 공용 모듈 두 개가 다른 방식으로 노출되고 있어요.
각각 만들 때는 자연스러운 선택이었는데, 모아놓고 보니 새 공용 모듈을 만들 사람이 어느 쪽을 따라야 할지 알 수 없습니다. 이런 건 규칙을 문서에 한 줄 적어두는 게 맞아요.
8. 교훈
- 두 곳에서 비슷한 코드가 보이면 우선 비교를 정확히 하자. "비슷하다"는 인상은 자주 "거의 같다" 와 동의어.
- 추출은 하나의 큰 함수보다 작은 헬퍼 여러 개가 사용처 가독성이 더 좋다.
- 다른 동작은 콜백으로. 옵션 객체로 다 받으려 들면 인자가 폭발.
- 세 번째 호출처가 나타나기 전에 일반화하지 말자. 두 호출처 사이의 공통점만이 진짜 추출 대상.
- 추출의 과정에서 발견된 "왜 한쪽만 이래?" 류의 미세 차이는 통일의 기회.
리팩토링은 회고하기 좋은 작업이 아니지만, 한 번 정리해두면 향후 트리가 또 추가됐을 때 가장 빠르게 만들 수 있는 시작점이 됩니다.