window.confirm() 대신 두 번 클릭 패턴: Obsidian 플러그인에서 안전한 삭제 UX 만들기
Obsidian 플러그인의 Settings 탭에서 window.confirm()을 쓰다가 Linux에서 dialog가 차단되는 문제를 만났습니다. 그리고 credential 삭제는 확인 없이 즉시 삭제되는 더 큰 문제도 있었죠. 두 문제를 한 번에 해결하는 two-step delete 패턴과 destructive action UX의 일반 원칙을 정리했습니다.
1. window.confirm()이 멈추는 환경이 있다
처음에는 이렇게 썼습니다.
// Before: 브라우저 기본 dialog로 확인
deleteBtn.addEventListener('click', async () => {
const confirmed = confirm(
`Delete configuration "${config.name}"? This cannot be undone.`,
);
if (!confirmed) return;
this.plugin.settings.configs = configs.filter(c => c.id !== config.id);
await this.plugin.saveSettings();
this.display();
});
평범한 패턴입니다. window.confirm()은 네이티브 modal dialog를 띄워서 사용자의 확인을 받습니다. 대부분의 웹 앱에서 이 한 줄이 확인 UI를 해결합니다.
문제는 Obsidian이 Electron 앱이라는 것입니다. 그리고 Electron에서 window.confirm()은 두 가지 미묘한 장애를 가지고 있습니다.
1.1 Linux에서는 자주 차단된다
Linux 배포판과 Electron 버전의 조합에 따라 confirm()이 호출되자마자 자동으로 dismiss되거나, 아예 dialog가 뜨지 않는 경우가 있습니다. 사용자 관점에서는 "Delete 버튼을 눌렀는데 아무 일도 안 일어남"이거나, 더 나쁜 경우 "눌렀더니 즉시 삭제됨"입니다. 둘 다 혼란스러운 동작입니다.
이건 Electron의 알려진 이슈이고, 특정 X11 window manager나 Wayland 합성기에서 native dialog의 modal 처리가 Chromium의 기대와 다르게 작동하는 것이 원인입니다. Obsidian은 이 버그의 수정 책임이 없지만, 영향은 고스란히 플러그인 개발자에게 옵니다.
1.2 메인 스레드를 블로킹한다
confirm()은 synchronous입니다. 사용자가 답할 때까지 JavaScript 메인 스레드 전체가 멈춥니다. Obsidian은 이 멈춤 동안 어떤 이벤트도 처리하지 못하고, 최악의 경우 자동 저장 타이머가 얼어붙거나 백그라운드 싱크가 중단될 수 있습니다.
작은 UI에서는 무시할 만한 문제이지만, 플러그인이 백그라운드 sync 작업을 돌리는 중에 confirm()이 호출되면 그 sync가 응답 대기로 멈춥니다. 이건 사용자가 인지하기 어려운 조용한 side effect입니다.
1.3 더 근본적인 문제: credential 삭제에는 확인조차 없었다
설정 UI에는 두 가지 삭제가 있었습니다.
- Config 삭제:
confirm()으로 확인 (위의 문제) - Credential 삭제: 확인 없이 즉시 삭제 (더 큰 문제)
Credential 삭제는 inUse 체크(이 credential을 쓰는 config가 있나)를 먼저 하고, 안 쓰이고 있으면 바로 지웠습니다. 실수 클릭 한 번으로 Airtable API key가 날아가는 UX였습니다. 사용자가 불평은 안 했지만, 이건 시한폭탄이었습니다.
두 문제를 한 번에 해결하려면 플러그인 내부에 자체 확인 UI를 만들어야 했습니다. 그런데 "새 modal을 띄우는" 접근은 복잡도를 더하고, 포커스 관리, 키보드 접근성, ESC 처리 같은 문제가 줄줄이 따라옵니다. 다른 방법이 있습니다.
2. Two-Step Delete 패턴
핵심 아이디어는 이렇습니다.
Delete 버튼을 두 번 누르게 한다. 첫 번째 클릭은 "진짜 지울 거야?"라는 상태를 만들고, 두 번째 클릭에서 실제 삭제가 일어난다.
UI는 modal 없이 같은 버튼이 두 가지 상태를 가지도록 구성됩니다.
- State 1 (idle): "Delete" 버튼. 클릭하면 pending 상태로 전환.
- State 2 (pending): "Confirm delete" 버튼 + "Cancel" 버튼. Confirm을 클릭하면 삭제, Cancel을 클릭하면 idle로 복원.
이 패턴의 장점은 네 가지입니다.
첫째, 네이티브 dialog를 쓰지 않는다. confirm()의 Linux 이슈와 메인 스레드 블로킹에서 완전히 자유롭습니다.
둘째, 포커스 관리가 필요 없다. 새 modal이 열리지 않기 때문에 포커스가 어디로 가는지 신경 쓸 필요가 없습니다. 사용자는 이미 버튼에 포커스를 둔 상태로 두 번째 클릭을 합니다.
셋째, 실수 방지 효과가 모든 confirm() 대체 중 가장 강하다. 실수로 버튼을 한 번 눌렀을 때는 어떤 destructive 동작도 일어나지 않습니다. 사용자가 화면을 보고 "어? Confirm delete가 떴네, 안 누르고 다른 데 클릭하면 되겠지"라고 판단할 시간을 줍니다.
넷째, 키보드/마우스/터치 모든 입력에 공평하다. 키보드 사용자는 Enter로 첫 번째 버튼을 눌렀다가, 확인 후 Enter로 두 번째 버튼을 누릅니다. 마우스 사용자는 클릭 두 번. 터치 사용자는 탭 두 번. 다 같은 경로로 동작합니다.
3. 구현 — Config 삭제 버튼
3.1 상태 추가
삭제 대기 중인 config의 ID를 저장할 상태를 Settings 탭 클래스에 추가합니다.
export class AutoNoteImporterSettingTab extends PluginSettingTab {
private pendingDeleteConfigId: string | null = null;
private pendingDeleteCredentialId: string | null = null;
// ...
}
두 개의 독립된 pending 상태를 둔 이유는 config 삭제와 credential 삭제가 같은 화면에 동시에 떠 있을 수 있기 때문입니다. 한 쪽을 pending으로 두고 다른 쪽을 클릭하면 원래 pending이 취소되지 않고 유지됩니다. 각자 독립적인 "pending ID" 하나씩 관리하면 됩니다.
3.2 렌더링 로직
private renderDeleteConfigButton(containerEl: HTMLElement, config: ConfigEntry): void {
new Setting(containerEl).setName('Danger zone').setHeading();
const isPending = this.pendingDeleteConfigId === config.id;
const setting = new Setting(containerEl)
.setName('Delete this configuration')
.setDesc(
isPending
? 'Click again to confirm deletion.'
: 'Permanently remove this sync configuration. This cannot be undone.',
)
.addButton(button => {
button
.setButtonText(isPending ? 'Confirm delete' : 'Delete')
.setWarning()
.onClick(async () => {
if (!isPending) {
this.pendingDeleteConfigId = config.id;
this.display(); // ← 재렌더로 State 2 진입
return;
}
// 두 번째 클릭: 실제 삭제
const { configs } = this.plugin.settings;
if (configs.length <= 1) {
new Notice('Auto Note Importer: Cannot delete the last configuration.');
return;
}
this.pendingDeleteConfigId = null;
this.plugin.settings.configs = configs.filter(c => c.id !== config.id);
this.plugin.settings.activeConfigId = this.plugin.settings.configs[0]?.id ?? '';
await this.plugin.saveSettings();
this.display();
});
if (isPending) {
button.buttonEl.addClass('mod-destructive'); // 시각 강조
}
});
if (isPending) {
setting.addButton(button =>
button.setButtonText('Cancel').onClick(() => {
this.pendingDeleteConfigId = null;
this.display();
}),
);
}
setting.settingEl.addClass('ani-delete-config');
}
핵심 포인트 5개:
isPending을 함수 시작에서 한 번만 계산. 템플릿 전체에 걸쳐 일관된 분기가 유지됩니다.- 버튼 텍스트·설명 텍스트 모두 상태에 따라 분기. 사용자가 "지금 어느 단계인지"를 시각적으로 즉시 알 수 있습니다.
- 첫 클릭에서
this.display()호출. Settings 탭 전체를 재렌더해서 State 2로 UI가 전환됩니다. - 두 번째 클릭에서
this.pendingDeleteConfigId = null먼저. 삭제 로직 실행 전에 상태를 초기화하면 "삭제 중 에러가 나도 pending 상태로 남아 있지 않음"이 보장됩니다. - Cancel 버튼은
isPending일 때만 추가. State 1에서는 존재하지 않습니다. 이 조건부 버튼 추가가addButton()의 chaining을 깨긴 하지만, 읽기 편합니다.
3.3 재진입 시나리오
이 패턴에는 사용자가 할 수 있는 여러 재진입 시나리오가 있고, 각각이 자연스럽게 동작해야 합니다.
| 시나리오 | 예상 동작 |
|---|---|
| Delete 한 번 클릭 → Cancel 클릭 | Idle로 복원 |
| Delete 한 번 클릭 → 다른 설정 변경 | pending 유지 (화면에 계속 보임) |
| Delete 한 번 클릭 → 탭 이동 후 복귀 | Pending이 풀림 (display() 새로 호출됨) |
| Delete 한 번 클릭 → 다른 config의 Delete 클릭 | 둘 다 pending? — 아니오. 뒤의 클릭이 앞의 pending을 덮어씀 |
4번째가 미묘합니다. 두 pending이 동시에 살아남을 수 있으면 UX가 혼란스러워집니다. 그래서 pendingDeleteConfigId를 단일 문자열로 두고, 새 config의 Delete를 누르면 자동으로 전의 pending이 해제됩니다. "동시에 하나만 pending" 규칙을 단순한 데이터 구조로 강제합니다.
3번째는 의도된 동작입니다. 사용자가 탭을 이동했다가 돌아오면 "방금 뭔가 pending이었던 것 같은데?"라는 혼란을 없애기 위해 탭 재진입 시 pending을 자동 해제합니다. 이건 display()가 호출될 때마다 Settings 탭의 상태가 새로 그려지기 때문에 자연스럽게 일어납니다.
4. 구현 — Credential 삭제 버튼
Credential 삭제도 같은 패턴을 적용했지만, UI 구조가 조금 다릅니다. Credential은 테이블 형태로 렌더링되고, 각 row에 작은 휴지통 아이콘 버튼이 있습니다.
const isPendingDelete = this.pendingDeleteCredentialId === cred.id;
const deleteBtn = actionsCell.createEl('button', {
cls: `ani-cred-action-btn${isPendingDelete ? ' ani-cred-action-confirm' : ''}`,
});
setIcon(deleteBtn, isPendingDelete ? 'check' : 'trash-2'); // ← 아이콘도 바뀜
deleteBtn.title = isPendingDelete ? 'Confirm delete' : 'Delete credential';
deleteBtn.addEventListener('click', async () => {
if (!isPendingDelete) {
// 1단계: in-use 체크
const inUse = this.plugin.settings.configs.some(c => c.credentialId === cred.id);
if (inUse) {
new Notice('Auto Note Importer: Cannot delete a credential that is in use by a configuration.');
return;
}
// 2단계: pending 상태로 진입
this.pendingDeleteCredentialId = cred.id;
this.display();
return;
}
// 3단계: 실제 삭제
this.pendingDeleteCredentialId = null;
this.plugin.settings.credentials = this.plugin.settings.credentials.filter(c => c.id !== cred.id);
await this.plugin.saveSettings();
this.display();
});
텍스트 버튼이 아니라 아이콘 버튼인 것이 차이점입니다. 같은 시각 계층에 Cancel 버튼을 추가하기가 번거로워서 Cancel을 별도 버튼으로 두지 않고, 대신 사용자가 다른 곳을 클릭하면 자동으로 pending이 풀리는 경로를 활용합니다.
그럼 사용자는 어떻게 "취소"할까요? 세 가지 경로가 있습니다.
- 다른 credential의 Delete를 누른다 → 이 credential의 pending이 풀리고 저쪽이 pending으로 전환됩니다.
- settings 탭을 닫았다가 연다 →
display()재호출로 pending이 리셋됩니다. - Delete 버튼을 한 번 더 안 누르고 기다린다 → Pending 상태는 유지되지만 시각 단서(체크 아이콘, 빨간색)가 계속 보여서 사용자가 "현재 상황"을 인지합니다. 실질적으로 "confirm을 누르지 않음 = cancel"입니다.
테이블 row 안에 Cancel 버튼을 집어넣는 것도 가능했지만, row 너비가 빠르게 어지러워집니다. 첫 번째 접근보다 시각적으로 깔끔한 절충안이었습니다.
4.1 시각 강조 — CSS 한 줄
Pending 상태의 휴지통 버튼은 빨간색(var(--text-error))으로 바뀝니다.
.ani-credentials-table .ani-cred-action-confirm {
color: var(--text-error);
}
아이콘도 trash-2(휴지통)에서 check(체크 마크)로 바뀝니다. 같은 위치에 있지만 "다른 의미의 버튼"이라는 것을 두 가지 시각 신호(색 + 모양)로 동시에 전달합니다.
5. Config 삭제와 Credential 삭제의 디자인 차이
같은 two-step 패턴이지만 두 케이스의 UI가 조금 다릅니다. 이 차이에는 이유가 있습니다.
| 항목 | Config 삭제 | Credential 삭제 |
|---|---|---|
| 위치 | Config 설정 하단의 "Danger zone" 섹션 | Credentials 테이블의 action column |
| 버튼 형태 | 텍스트 버튼 ("Delete" / "Confirm delete") | 아이콘 버튼 (휴지통 / 체크) |
| Cancel 버튼 | 있음 (Confirm 옆에 명시적으로 추가) | 없음 (다른 곳 클릭으로 자동 해제) |
| 설명 텍스트 | 있음 (변경됨) | 없음 (tooltip만) |
| 빈도 | 드묾 (설정을 실수로 지우면 곤란) | 가끔 (여러 credential 관리 시 자주 일어남) |
Config 삭제는 "희귀하지만 파괴적". 실수로 누르면 사용자의 전체 설정이 날아가고, 재생성이 귀찮습니다. 그래서 Confirm 버튼 + 명시적 Cancel 버튼 + 설명 텍스트의 3중 방어벽을 칩니다.
Credential 삭제는 "자주 하지만 덜 파괴적". Credential이 inUse 상태면 삭제 자체가 막히므로, 실수로 지울 수 있는 경우는 "어차피 쓰이지 않는 credential"입니다. 피해가 작은 대신 화면에서 빨리 정리하고 싶을 때가 많습니다. 그래서 아이콘 버튼 한 개 + Cancel 없음의 가벼운 UX를 씁니다.
같은 패턴이지만 destructiveness의 정도에 맞춰 방어벽의 두께를 조절하는 것이 핵심입니다. "무조건 two-step이니까 다 똑같이" 하면 자주 하는 작업이 번거로워지고, "가볍게 간다"고 다 one-click으로 두면 드물지만 치명적인 실수를 막지 못합니다.
6. 다른 대안들과의 비교
Two-step 패턴을 선택하기 전에 고려했던 다른 방법들입니다.
6.1 Obsidian의 Modal 클래스 사용
Obsidian 플러그인 API에는 Modal 클래스가 있습니다. 이걸로 커스텀 확인 dialog를 만들 수 있습니다.
class ConfirmModal extends Modal {
constructor(app: App, private message: string, private onConfirm: () => void) {
super(app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl('p', { text: this.message });
const btnContainer = contentEl.createDiv();
btnContainer.createEl('button', { text: 'Cancel' })
.onclick = () => this.close();
btnContainer.createEl('button', { text: 'Confirm', cls: 'mod-warning' })
.onclick = () => { this.onConfirm(); this.close(); };
}
}
장점: 확실한 modal 경험. 키보드 접근성이 Obsidian 내장 modal의 동작을 따라감.
단점: 구현 복잡도. 포커스 관리, ESC 닫기, 스타일 맞춤. 이미 3개의 modal이 있는 Settings 탭에 또 하나를 추가하면 인지 부담이 커짐.
플러그인의 다른 부분에서 이미 Modal을 쓰고 있었다면 이 방법을 썼을 겁니다. 현재는 모든 UI가 인라인으로 구성돼 있어서, modal 하나를 위해 새 추상화를 도입하는 비용이 컸습니다.
6.2 Obsidian Notice로 "정말 지울까요?" 띄우기
Notice는 화면 우상단에 잠깐 뜨는 토스트 메시지입니다. 클릭 가능한 Notice를 만들어서 "클릭하면 진짜 삭제" 경로를 설계할 수 있습니다.
const notice = new Notice('Click here within 5 seconds to confirm deletion', 5000);
notice.noticeEl.addEventListener('click', () => {
// 실제 삭제
});
장점: 새 UI 요소 없음.
단점: 5초 카운트다운 UX가 불안정. 사용자가 읽을 시간이 모자라거나, 읽는 도중 사라짐. Mouse pointer가 Notice 위치까지 이동하는 동안 시간이 흐름. 터치 디바이스에서는 더 불편. 시간 제약이 있는 확인 UX는 "느긋한 사용자"에게 스트레스가 됨.
이 방법은 빠르지만 사용자 친화적이지 않았습니다.
6.3 Hold-to-Delete (긴 누르기)
"Delete 버튼을 2초간 눌러 홀드해야 삭제"라는 패턴도 있습니다. iOS의 일부 앱에서 씁니다.
장점: 실수 방지가 매우 강함.
단점: 발견 가능성이 낮음. 사용자가 버튼을 "눌렀다 뗐다"만 하고 "왜 안 되지"라고 포기할 수 있음. 특히 데스크톱 환경에서는 이 패턴이 드물어서 교육이 필요.
이 방법은 모바일에 더 적합합니다. Desktop-first인 Obsidian 플러그인에는 부적절했습니다.
6.4 왜 Two-Step이 최선이었나
| 평가 축 | Two-Step | Modal | Notice | Hold |
|---|---|---|---|---|
confirm() 문제 해결 |
✅ | ✅ | ✅ | ✅ |
| 구현 복잡도 | 낮음 | 중간 | 낮음 | 중간 |
| 발견 가능성 | 높음 | 높음 | 중간 | 낮음 |
| 실수 방지 | 중간 | 높음 | 낮음 | 높음 |
| 시간 압박 | 없음 | 없음 | 있음 | 있음 |
| 키보드 접근성 | 자동 | 수동 | 수동 | 부적합 |
Two-step은 모든 축에서 적어도 중간 이상이면서 구현 비용이 가장 낮습니다. "완벽하지는 않지만 전 영역에서 무난"이 Obsidian 플러그인 settings 같은 저빈도 UI에는 최적이었습니다.
7. 일반화 — Destructive Action UX의 4가지 원칙
이 작업에서 추출한 원칙:
7.1 네이티브 dialog에 의존하지 말라
Electron/Obsidian/VS Code 같은 하이브리드 앱에서 window.confirm(), window.alert(), window.prompt()는 환경에 따라 동작이 다릅니다. Linux/Wayland에서는 차단될 수 있고, 모바일 iOS WebView에서는 다른 방식으로 렌더링되며, 자동 테스트에서는 dialog가 떠 있는 동안 이벤트 루프가 멈춥니다. UI 레이어 안에 확인 상태를 직접 구현하는 것이 가장 안전합니다.
7.2 Destructiveness에 비례해 방어벽의 두께를 조절하라
- 완전 파괴적(config 전체 삭제): Two-step + Cancel 버튼 + 설명 텍스트 + 빨간색
- 부분 파괴적(credential 하나 삭제): Two-step + 아이콘 전환 + 빨간색
- 복구 가능(로컬 파일 삭제, 나중에 다시 싱크 가능): Two-step만
- 영향 없음(UI 상태 변경): 확인 없음
"모든 삭제에 같은 두께의 방어벽"은 자주 하는 작업을 번거롭게 만듭니다.
7.3 State 머신을 데이터로 표현하라
pendingDeleteConfigId: string | null이라는 단일 문자열 상태가 전체 패턴을 표현합니다. Boolean flag나 여러 개의 pending map을 쓰지 않습니다. "동시에 하나만 pending" 규칙이 데이터 구조 자체에 박혀 있어서 코드로 enforce할 필요가 없습니다.
7.4 재진입·탈출 경로를 모두 생각하라
- 사용자가 pending 후 다른 설정을 건드리면?
- 탭을 떠났다가 돌아오면?
- 다른 row의 Delete를 연달아 누르면?
- ESC를 누르면?
이런 경로를 모두 열거하고 각각의 기대 동작을 정하지 않으면 "이상한 상태"가 쉽게 만들어집니다. Two-step은 대부분의 경로가 자연스럽게 올바르게 동작하는 구조이지만, 그래도 한 번은 모든 경로를 loud하게 검토해야 합니다.
8. FAQ
Q: 빨간색 버튼으로 바뀌는 것만으로 충분한가요? 사용자가 눈치 못 챌 수도 있지 않나요?
A: 단독으로는 부족합니다. 그래서 색 + 텍스트 + 설명 텍스트 3가지를 동시에 바꿉니다 (config 케이스). Credential 케이스는 색 + 아이콘 + tooltip 3가지입니다. 한 가지 시각 신호만으로는 색각 이상 사용자에게 부족할 수 있기 때문에 항상 최소 2가지 이상의 독립된 신호를 함께 변경합니다.
Q: this.display()를 호출하면 Settings 탭이 전부 다시 그려지는데, 성능 문제는 없나요?
A: Obsidian Settings 탭은 기본적으로 탭을 열 때마다 전체를 다시 그리는 구조입니다. 재렌더 비용이 원래 작고, two-step 전환 한 번은 탭 열기와 비슷한 수준입니다. 수백 개의 config가 있으면 느려질 수 있지만 실용 범위에서는 문제가 없었습니다.
Q: Cancel 버튼을 누르지 않고 다른 버튼을 누르면 pending이 자동 해제되도록 구현했다고 했는데, ESC 키는 어떤가요?
A: 현재는 ESC 키 핸들러를 따로 붙이지 않았습니다. Obsidian Settings 탭은 ESC로 닫히지 않는 구조이고, 사용자가 ESC를 누를 자연스러운 맥락이 없어서입니다. 필요하면 document.addEventListener('keydown', ...)로 ESC를 잡아 pending을 해제할 수 있지만, 리소스 해제가 번거로워서 현재는 구현하지 않았습니다.
Q: Undo(되돌리기) 기능을 추가하는 건 어떨까요?
A: Gmail 같은 서비스의 "Undo Send" 패턴처럼 "5초 안에 되돌릴 수 있음"을 제공하는 것도 좋은 접근입니다. 다만 플러그인 settings의 삭제는 빈도가 낮고 파괴적이라, "실수 방지(two-step)"가 "실수 회복(undo)"보다 비용 대비 효과가 컸습니다. 만약 Gmail처럼 자주 일어나는 작업(예: 노트 삭제)이면 Undo가 더 나을 수 있습니다.
Q: 이 패턴을 React나 Vue 기반 앱에서도 쓸 수 있나요?
A: 네. 상태 하나(pendingId)를 컴포넌트 state로 두고, 조건부 렌더링으로 버튼 텍스트·아이콘·추가 버튼을 분기하면 됩니다. React의 경우 useState로 간단히 표현 가능하고, Vue는 ref 하나로 처리됩니다. 어떤 프레임워크든 "하나의 scalar state + 조건부 UI" 조합이 핵심입니다.
9. 참고 자료
- Obsidian Plugin Developer Docs — Settings
- Nielsen Norman Group — Confirmation Dialogs
- Electron Issues — dialog behavior on Linux (환경별 주의사항)
- 검색 키워드: "two-step confirmation ux pattern"
- 검색 키워드: "destructive action design button"