-SpreadJS Designer 활용법 및 버그 해결기-
들어가며
한 기업 프로젝트에 투입되면서 SpreadJS 라이브러리를 처음 접했습니다. 해당 라이브러리는 excel을 화면 단에서 조회 및 수정이 가능하게 하기 위해 사용했으며, 이를 적용하는 과정에서 겪은 일들을 정리했습니다. 새로고침·import 시 사이드바와 바인딩패스가 사라지는 버그, 그리고 고객사 요구사항으로 추가된 시트 추가 방지 및 셀 삭제 방지 기능입니다. 두 문제 모두 결국 SpreadJS Designer의 커맨드 체계를 이해해야 풀리는 문제였고, 결과적으로는 문서에 나와 있지 않은 부분을 직접 분석해서 요구사항을 구현해야 했던 경험이었습니다.
새로고침·재조회 시 사이드바와 바인딩패스가 사라지는 현상
템플릿을 처음 조회할 때는 엑셀 화면과 함께 사이드바에 바인딩패스 트리가 정상적으로 표시됩니다. 그런데 새로고침 버튼을 누르거나 파일을 다시 열면, 엑셀 화면에도 바인딩패스가 표시되지 않고 사이드바도 사라지는 현상이 나타났습니다. 첫 조회는 성공, 첫 새로고침은 실패, 두 번째 새로고침은 다시 성공. 정확히 홀수 번째와 짝수 번째가 번갈아 나타나는 패턴이었습니다.
원인: 체크박스 타입 커맨드가 부를 때마다 상태를 뒤집는다
사이드바를 그리는 함수는 workbook.open()의 성공 콜백 안에서 호출되고 있었습니다. 그 안에서 Designer.getCommand(TEMPLATE_DESIGN_MODE)로 가져온 command 객체를 콘솔에 찍어보니 다음 필드가 있었습니다.
{
"commandName": "templateDesignMode",
"type": "checkbox"
}
"type": "checkbox"가 핵심이었습니다. SpreadJS Designer의 체크박스 타입 커맨드는 리본의 굵게, 기울임 버튼처럼 호출할 때마다 on/off 상태를 반전시키도록 설계되어 있습니다. 조회할 때마다 이 커맨드를 실행하고 있었으니, 실제로는 다음과 같은 흐름이 반복되고 있었던 것입니다.
새로고침 버튼 자체와는 무관하게, "이 커맨드를 몇 번째 호출하는지"에 정확히 종속된 버그였습니다. 처음에는 렌더링 타이밍 문제로 의심해 setTimeout이나 suspendPaint/resumePaint 쪽을 먼저 만져봤지만 원인과는 무관했습니다. 증상이 정확히 홀짝으로 갈린다는 사실 자체가 이미 "상태값이 관여하는 문제"라는 단서였는데, 그 신호를 초반에 바로 알아채지 못한 건 아쉬움으로 남습니다.
해결
실행하기 전에 커맨드의 현재 상태를 먼저 확인하여, 이미 켜져 있으면 실행을 건너뛰도록 했습니다.
return (designer) => {
const isAlreadyOn = command.getState?.(designer);
if (isAlreadyOn) return;
return execute(designer);
};
고객사 요구사항: 시트 추가 방지와 바인딩 셀 삭제 방지
기능을 어느 정도 마무리해가던 중 고객사로부터 요구사항 세 가지가 추가로 내려왔습니다.
1. 템플릿 설정 화면에서는 시트를 추가하지 못하게 해달라 (+ 버튼을 없애거나 다른 방법으로든)
2. 바인딩패스가 있는 셀은 삭제하지 못하게 해달라. 이 셀 값을 다른 화면에서 그대로 가져다 쓸 예정이라 지워지면 안 된다. 다만 바인딩패스가 없는 나머지 셀은 지금처럼 자유롭게 편집 가능해야 한다. 딱 "삭제"만 막으면 된다.
3. 시트를 전환할 때마다 그 시트에 있는 바인딩패스를 사이드바 필드 목록에 반영해달라.
시트 전환 시 사이드바 갱신
3번 요구사항은 ActiveSheetChanged 이벤트로 비교적 수월하게 해결했습니다. 시트가 전환될 때마다 현재 모드(파일 import 모드인지, 기존 DB 조회 모드인지)에 따라 바인딩패스를 다시 추출하거나 매핑한 뒤, 사이드바 트리를 다시 그리는 함수를 호출하는 방식입니다.
workbook.bind(Events.ActiveSheetChanged, function (sender, args) {
const currentSheet = args.newSheet;
if (!currentSheet) return;
const sheetName = currentSheet.name();
if (isImport) {
const bindingPathsFromSheet = extractBindingPathsFromSheet(currentSheet);
void setBindingPathToData(currentDesigner, currentWorkbook, bindingPathsFromSheet);
} else {
const currentSheetDatas = fieldsBySheetMap?.get(sheetName) || [];
void setBindingPathToData(currentDesigner, currentWorkbook, currentSheetDatas);
}
});
1번과 2번은 접근 방식을 세 번 정도 갈아엎고 나서야 제대로 된 방법을 찾았습니다.
접근 1. Cell Locking
바인딩패스가 있는 셀만 잠그고 나머지는 편집 가능하도록 풀어둔 다음 시트 보호를 켜는 방식을 먼저 시도했습니다.
const unlockedStyle = new GC.Spread.Sheets.Style();
unlockedStyle.locked = false;
sheet.setDefaultStyle(unlockedStyle);
for (let r = 0; r < sheet.getRowCount(); r++) {
for (let c = 0; c < sheet.getColumnCount(); c++) {
if (sheet.getBindingPath(r, c)) {
sheet.getCell(r, c).locked(true);
}
}
}
sheet.options.isProtected = true;
실제로 적용해보니 바인딩패스가 없는 셀까지 전부 편집이 되지 않았습니다. setDefaultStyle로도, 전체 범위를 강제로 덮어써도 마찬가지였고, 콘솔로 확인해보니 애초에 기본 상태에서 locking 자체가 걸려있지도 않았습니다. 원인을 끝까지 명확히 밝히지는 못했는데, 템플릿(.sjs) 파일 자체에 코드로는 바로 확인되지 않는 별도의 보호 옵션이 이미 설정되어 있었을 가능성을 의심하고 있습니다. 원인과 별개로, "바인딩되지 않은 셀은 완전히 자유롭게 편집되어야 하고 삭제만 막으면 된다"는 요구사항과 시트 보호 기능 자체가 애초에 잘 맞지 않는 조합이었습니다.
접근 2. Command 오버라이드
체크박스 타입 커맨드를 해결하면서 알게 된 커맨드 체계를 그대로 활용해봤습니다. 삭제도 이름 있는 커맨드가 있을 거라 보고, deleteRows 같은 이름의 커맨드를 commandManager().getCommand()로 찾아 원본을 저장한 뒤 조건부로 차단하는 방식으로 오버라이드했습니다.
const originalDeleteRows = commandManager.getCommand('deleteRows');
commandManager.register('deleteRows', {
canUndo: originalDeleteRows.canUndo,
execute: (context, options, isUndo) => {
// 대상 행에 바인딩패스가 있으면 차단하는 로직
return originalDeleteRows.execute(context, options, isUndo);
}
}, false, false, false, false);
그런데 우클릭 메뉴에서 행/열 삭제를 눌러도 오버라이드한 execute가 실제로 타지 않았습니다. 정확한 이유를 공식 포럼에서 확인하지는 못했지만, 앞서 체크박스 커맨드에서 확인했듯 Designer는 리본이든 우클릭 메뉴든 자체 commandMap 체계로 동작을 연결하고 있어서, 우클릭 메뉴의 "행 삭제" 클릭이 commandManager에 등록된 이름 있는 커맨드를 그대로 호출하는 게 아니라 Designer 내부에서 별도 경로로 처리되고 있는 것으로 보입니다. 즉 겉으로는 같은 "삭제"처럼 보여도, Delete 키(→ clear류 커맨드)와 우클릭 메뉴의 행/열 삭제가 서로 다른 경로를 타고 있었던 셈입니다. 커맨드를 붙잡고 늘어지는 방향은 한계가 있다고 판단하고 접근 자체를 바꿨습니다.
접근 3. Context Menu 제어
커맨드 실행을 막으려 하지 않고, 우클릭 메뉴가 열릴 때 그 안의 "삭제" 항목 하나만 비활성화하면 되지 않을까 하는 생각으로 방향을 바꿨습니다. "우클릭으로 메뉴가 열릴 때마다 호출되는 이벤트가 무엇인가?"로 질문을 좁혀서 찾은 것이 contextMenu.onOpenMenu였습니다.
다만 이걸 그대로 걸어보니 콜백은 정상 호출되는데 itemsDataForShown(실제로 표시될 메뉴 항목 목록)이 계속 빈 배열로 찍혔습니다. Designer가 리본과 우클릭 메뉴를 자체 commandMap 체계로 관리하고 있어서, 플레인 워크북의 contextMenu에 거는 것만으로는 Designer가 실제로 그리는 항목까지는 안 잡히는 것으로 보였습니다. 원본 onOpenMenu를 미리 저장해두고 그 위를 감싸는 방식으로 다시 정리하자 항목이 정상적으로 넘어왔습니다.
workbook.contextMenu.onOpenMenu = function (menuData, itemsDataForShown, hitInfo, spread) {
let result = true;
if (typeof originalOnOpenMenu === 'function') {
result = originalOnOpenMenu.apply(this, arguments);
}
const sheet = spread.getActiveSheet();
const info = hitInfo.worksheetHitInfo;
if (!info || hitInfo.hitTestType == null) {
// 시트 탭 영역: insertSheet 항목 비활성화
itemsDataForShown?.forEach((item) => {
if (item.name === 'insertSheet') item.disable = item.disabled = true;
});
} else if (info.row >= 0 && info.col >= 0 && sheet.getBindingPath(info.row, info.col)) {
// 셀 영역: 바인딩패스가 있으면 delete 관련 항목 비활성화
itemsDataForShown?.forEach((item) => {
const name = item.name?.toLowerCase() ?? '';
const text = item.text?.toLowerCase() ?? '';
if (name.includes('delete') || text.includes('delete')) item.disable = item.disabled = true;
});
}
return result;
};
이 로직으로 시트 탭의 insertSheet와 셀의 delete 항목을 각각 비활성화해 두 요구사항을 해결했습니다. 리본의 + 버튼은 이 오버라이드와는 별개로, workbook.options.newTabVisible = false; 옵션 하나로 아예 화면에서 숨겼습니다. 우클릭으로 새 시트를 추가하는 경로는 onOpenMenu로, 버튼 자체는 이 옵션으로 막은 셈입니다.
마치며
이번 작업을 하면서 느낀 점은 SpreadJS Designer는 일반 SpreadJS Workbook API만 보고 접근하면 해결되지 않는 경우가 많다는 것이었습니다. 리본, Context Menu, Command가 하나의 체계로 연결되어 있기 때문에, 막히는 순간에는 관련 커맨드 객체를 먼저 확인하는 것이 가장 빠른 디버깅 방법이었습니다. 이번 경험 덕분에 단순히 API를 사용하는 수준을 넘어 Designer 내부 구조를 조금 더 이해할 수 있었습니다.
pong