<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>re_brother.log</title>
        <link>https://velog.io/</link>
        <description>will be better</description>
        <lastBuildDate>Tue, 01 Sep 2026 14:33:04 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>re_brother.log</title>
            <url>https://images.velog.io/images/re_brother/profile/07e8e4db-4942-4550-b944-3680fc068a59/제목 없음.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. re_brother.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/re_brother" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (27) - 대용량 document workspace의 초기 비용 줄이기]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-27-%EB%8C%80%EC%9A%A9%EB%9F%89-document-workspace%EC%9D%98-%EC%B4%88%EA%B8%B0-%EB%B9%84%EC%9A%A9-%EC%A4%84%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-27-%EB%8C%80%EC%9A%A9%EB%9F%89-document-workspace%EC%9D%98-%EC%B4%88%EA%B8%B0-%EB%B9%84%EC%9A%A9-%EC%A4%84%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Tue, 01 Sep 2026 14:33:04 GMT</pubDate>
            <description><![CDATA[<p>지난 포스팅에서는 실제 문서 작업 중 발생한 Explorer와 상태 보존 문제를 다뤘다. 그 과정을 진행하면서 기능이 정상적으로 동작하는지와 별개로, 큰 저장소를 열었을 때 앱이 얼마나 빨리 반응하는지도 제품 품질의 일부라는 사실이 분명해졌다.</p>
<p>작은 테스트 폴더에서는 보이지 않던 문제가 Git 저장소를 선택했을 때 나타났다.</p>
<ul>
<li>초기 화면이 늦게 나타난다.</li>
<li>파일과 폴더를 읽는 동안 UI가 버벅인다.</li>
<li>앱 메모리가 평소 약 200MB에서 1~2GB 수준까지 증가한다.</li>
</ul>
<p>이번에는 이 문제를 한 번에 해결했다고 주장하지 않고, 어떤 비용이 어디에서 발생하는지 나누어 측정할 수 있는 첫 단위를 만들었다.</p>
<h2 id="gitignore를-그대로-explorer-규칙으로-쓰지-않은-이유"><code>.gitignore</code>를 그대로 Explorer 규칙으로 쓰지 않은 이유</h2>
<p>처음에는 사용자의 <code>.gitignore</code>를 읽어 필요 없는 파일을 숨기면 된다고 생각할 수 있다. 하지만 <code>.gitignore</code>는 Git이 추적하지 않을 파일을 정하는 정책이고, Dock의 Explorer는 사용자가 확인하고 편집할 document workspace의 파일을 보여 주는 기능이다.</p>
<p>Git에서 무시하는 파일이라고 해서 사용자가 절대 열어 보지 않는 것은 아니다. 반대로 저장소에서 추적되는 파일이어도 문서 Explorer에 보일 필요가 없을 수 있다. 그래서 첫 단계에서는 모든 규칙을 가져오지 않고, 생성물과 의존성처럼 제품 관점에서 명확히 제외할 수 있는 기본 디렉터리만 공통 규칙으로 관리했다.</p>
<pre><code class="language-ts">export const DEFAULT_IGNORED_DIRECTORIES = new Set([
  &#39;.git&#39;,
  &#39;node_modules&#39;,
  &#39;dist&#39;,
  &#39;out&#39;,
  &#39;coverage&#39;,
  &#39;test-results&#39;,
  &#39;playwright-report&#39;,
]);</code></pre>
<p>이 목록은 Explorer, Markdown 목록, 이미지 자산 스캔이 공유한다. 기능마다 서로 다른 제외 목록을 갖게 되면 한 화면에는 보이는 파일이 다른 화면에서는 사라지는 정합성 문제가 생기기 때문이다.</p>
<h2 id="watcher도-플랫폼별로-비용이-달라진다">watcher도 플랫폼별로 비용이 달라진다</h2>
<p>Dock은 운영체제 밖에서 파일이 변경되는 상황을 감지해야 한다. Windows와 macOS에서는 재귀 <code>fs.watch</code>를 우선 사용하고, 해당 방식이 지원되지 않거나 오류가 발생하면 Linux에서 디렉터리별 watcher를 유지하는 fallback을 사용한다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/0ecd25f0-8c58-4566-95ce-b59bedd9d3e1/image.png" alt=""></p>
<p>모든 변경 이벤트마다 전체 디렉터리를 다시 읽으면 이벤트가 연속해서 발생할 때 비용이 커진다. watcher는 이벤트를 짧게 debounce하고, 내부에서 발생시킨 이름 변경·이동 이벤트는 다시 처리하지 않도록 구분한다. 다만 현재 구조는 초기 workspace 목록과 Markdown 참조를 여전히 재귀적으로 수집하므로 watcher 개선만으로 메모리 급증을 해결할 수는 없다.</p>
<h2 id="화면에-필요한-만큼만-explorer를-펼치기">화면에 필요한 만큼만 Explorer를 펼치기</h2>
<p>기존 Explorer는 전체 entry 목록을 받은 뒤 모든 폴더를 자동으로 펼쳤다. 사용자가 아직 열어 보지 않은 하위 파일까지 React element와 이벤트 핸들러를 만들기 때문에, 큰 workspace에서는 데이터 조회 비용과 별개로 Renderer DOM 비용이 커진다.</p>
<pre><code class="language-ts">const next = new Set(
  [...current].filter(
    (path) =&gt; path === &#39;&#39; || directories.has(path),
  ),
);
next.add(&#39;&#39;);</code></pre>
<p>빈 문자열은 workspace 루트다. 초기에는 루트 항목만 표시하고, 사용자가 폴더를 펼친 뒤에 하위 트리를 렌더링한다. 폴더를 닫으면 해당 하위 DOM도 다시 화면에서 제거된다.</p>
<p>이 변경은 <code>workspace:list-entries</code> IPC를 당장 바꾸지 않았다. 현재 IPC를 <code>list-children</code> 방식으로 바꾸면 파일 목록, 검색, backlinks, watcher 갱신까지 동시에 영향을 받는다. 먼저 Renderer 렌더링 비용을 줄인 뒤, 실제 대규모 workspace에서 Main·Renderer 메모리와 초기 응답 시간을 측정해야 다음 구조 변경을 판단할 수 있다.</p>
<h2 id="이미지-자산-스캔도-같은-규칙을-사용해야-한다">이미지 자산 스캔도 같은 규칙을 사용해야 한다</h2>
<p>이미지 진단 화면은 <code>assets</code> 아래의 이미지를 찾아 문서에서 사용 중인지 확인한다. 기존 이미지 수집기가 <code>assets</code> 하위 모든 디렉터리를 재귀 방문하면, <code>assets/node_modules</code>나 <code>assets/dist</code>처럼 관심 대상이 아닌 경로까지 읽을 수 있다.</p>
<pre><code class="language-ts">if (entry.isDirectory()) {
  if (DEFAULT_IGNORED_DIRECTORIES.has(entry.name)) continue;
  assets.push(...(await collectImageAssets(root, absolutePath)));
  continue;
}</code></pre>
<p>이 검사는 이미지 파일을 읽기 전에 디렉터리 진입 자체를 막는다. 실제 자산을 읽는 단계에서는 별도로 <code>assets</code> 경계, 지원 MIME, 매직 바이트, 최대 파일 크기를 검증한다. 탐색 비용을 줄였다고 해서 외부 파일을 신뢰하는 것은 아니다.</p>
<h2 id="먼저-기준을-기록해야-최적화라고-부를-수-있다">먼저 기준을 기록해야 최적화라고 부를 수 있다</h2>
<p>감으로 코드를 바꾸면 개선 여부를 판단할 수 없다. 그래서 workspace를 변경하지 않는 기준 측정 스크립트를 추가했다.</p>
<pre><code class="language-powershell">node scripts/measure-workspace-baseline.mjs D:\dev\git\jarvis-dock</code></pre>
<p>스크립트는 다음 값을 JSON으로 출력한다.</p>
<ul>
<li>디렉터리 수와 파일 수</li>
<li>Markdown 파일 수</li>
<li>기본 제외 디렉터리 수</li>
<li>최대 깊이와 무시 디렉터리 이름별 분포</li>
<li>현재 플랫폼 watcher 전략과 추정 watcher 수</li>
<li>스캔 시간</li>
<li>측정 프로세스의 RSS와 V8 heap 변화량</li>
</ul>
<p>현재 작은 fixture에서는 7개 디렉터리·24개 파일·18개 Markdown, 최대 깊이 3, Windows 재귀 watcher 추정 1개가 측정됐다. 저장소에서는 18개 디렉터리·174개 파일·86개 Markdown, 최대 깊이 4, 기본 제외 디렉터리 4개, Windows 재귀 watcher 추정 1개가 측정됐다. RSS 변화량은 각각 약 1.01MB와 1.85MB였다. 이 결과는 사용자가 보고한 1~2GB 메모리 증가를 재현한 것이 아니며, 최적화가 끝났다는 의미도 아니다. 현재 환경에서 재현 대상이 충분히 크지 않다는 사실을 확인한 기준값이다.</p>
<p>watcher 수는 현재 운영체제에서 Dock이 선택할 watcher 전략을 기준으로 계산한 추정치다. 측정 스크립트 자체의 메모리는 Main·Renderer가 함께 실행되는 실제 앱의 메모리와 다르므로, 대규모 workspace의 packaged 앱 측정이 별도로 필요하다.</p>
<h2 id="테스트-결과와-남은-한계">테스트 결과와 남은 한계</h2>
<p>이번 단위에서는 동작 변경과 성능 구조 변경을 섞지 않기 위해 기존 IPC 권한 경계와 watcher 계약을 유지했다.</p>
<ul>
<li>이미지 자산 스캔 단위 테스트 4개 통과</li>
<li><code>npm run check</code> 통과: 33개 test file·161개 테스트</li>
<li>packaged Electron E2E 21개 통과</li>
<li>Windows smoke 통과</li>
<li>이미지 자산 스캔 변경 커밋 <code>aae2b7f</code>의 Windows·Linux·macOS 패키징 및 Windows Electron runtime regression 성공</li>
<li>최신 측정 도구 보강 커밋 <code>0a45d46</code>에서 측정 기준을 확장하고, <code>npm run check</code>를 다시 통과</li>
</ul>
<p>아직 남아 있는 비용은 명확하다.</p>
<ol>
<li><code>workspace:list-entries</code>가 전체 트리를 한 번에 수집한다.</li>
<li>검색·backlinks를 위해 Markdown 전체 목록과 내용을 읽는 흐름이 있다.</li>
<li>운영체제별 watcher 수와 실제 Main·Renderer 메모리를 측정하지 않았다.</li>
<li>사용자가 보고한 대규모 저장소에서의 초기 지연을 아직 동일 조건으로 재현하지 못했다.</li>
</ol>
<p>다음 단계에서는 실제 대용량 document workspace를 기준으로 초기 선택부터 Explorer 표시까지의 시간, 전체 entry 수, watcher 수, Main·Renderer 메모리를 측정한다. 그 결과가 확인되면 폴더를 펼칠 때 해당 하위 entry만 조회하는 지연 로딩 IPC와 변경된 경로만 반영하는 증분 갱신을 별도의 설계·ADR 단위로 검토한다.</p>
<h2 id="마무리">마무리</h2>
<p>대용량 workspace 최적화는 <code>.gitignore</code>를 읽어 파일을 숨기는 한 줄짜리 문제가 아니었다. 무엇을 사용자에게 보여 줄지, 어떤 파일 변경을 감시할지, 언제 하위 트리를 렌더링할지, 이미지 진단이 어느 경로까지 탐색할지를 같은 정책과 측정 기준 안에서 분리해야 했다.</p>
<p>이번 작업은 최종 해답이 아니라 다음 판단을 위한 기반이다. 측정 없이 IPC를 바꾸지 않고, 화면에서 보이지 않는다고 파일 시스템에서도 무조건 제외하지 않으며, 최적화 전후의 차이를 숫자로 확인하는 흐름을 만들었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (26) - 중간 QA에서 발견한 문제 해결과 성능 개선으로 연결하기]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-26-QA%EC%97%90%EC%84%9C-%EB%B0%9C%EA%B2%AC%ED%95%9C-%EB%AC%B8%EC%A0%9C%EB%A5%BC-%EA%B8%B0%EB%8A%A5-%EC%88%98%EC%A0%95%EA%B3%BC-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%EC%9C%BC%EB%A1%9C-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-26-QA%EC%97%90%EC%84%9C-%EB%B0%9C%EA%B2%AC%ED%95%9C-%EB%AC%B8%EC%A0%9C%EB%A5%BC-%EA%B8%B0%EB%8A%A5-%EC%88%98%EC%A0%95%EA%B3%BC-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%EC%9C%BC%EB%A1%9C-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 31 Aug 2026 17:18:00 GMT</pubDate>
            <description><![CDATA[<h2 id="테스트가-통과해도-실사용에서-막힐-수-있다">테스트가 통과해도 실사용에서 막힐 수 있다</h2>
<p>앞선 수동 QA에서 Dock은 대부분의 핵심 흐름을 통과했다. 하지만 실제 사용자의 관점에서는 버튼을 눌러도 아무 반응이 없는 기능, 실패했지만 이유를 알 수 없는 기능, 파일은 저장됐지만 문서에는 참조가 들어가지 않는 기능이 남아 있었다.</p>
<p>이번 작업의 기준은 단순했다.</p>
<blockquote>
<p>사용자가 다음 행동을 판단할 수 있고, 파일 시스템과 화면의 상태가 같은가?</p>
</blockquote>
<p>QA 결과를 기능별 결함으로 분해한 뒤, 사용을 막는 문제부터 수정하고 테스트와 성능 작업을 분리했다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/4b9f72bd-19d0-46b0-93b5-f8a0979578ef/image.png" alt=""></p>
<h2 id="1-탐색기-생성-버튼이-실행되지-않던-문제">1. 탐색기 생성 버튼이 실행되지 않던 문제</h2>
<p>기존 탐색기는 상단의 <code>+ 파일</code>, <code>+ 폴더</code>와 폴더 컨텍스트 메뉴를 제공하고 있었다. 그러나 이름 입력을 브라우저의 <code>window.prompt</code>에 맡기고 있어 실제 앱 안에서는 사용 흐름이 불명확했다. 실패한 요청도 부모 컴포넌트의 상태만 변경한 뒤 생성 UI는 사라질 수 있었다.</p>
<p>파일 작업은 사용자가 가장 자주 확인하는 기능이다. 따라서 별도의 브라우저 대화상자 대신 탐색기 내부에 생성 다이얼로그를 배치했다.</p>
<ul>
<li>상단 버튼과 컨텍스트 메뉴가 같은 생성 로직을 사용</li>
<li>파일 또는 폴더 이름을 인앱 입력창에서 작성</li>
<li>생성 성공 시 다이얼로그 종료와 탐색기 갱신</li>
<li>중복 이름이나 잘못된 경로면 입력값과 다이얼로그 유지</li>
<li>새 Markdown 파일 생성 시 Editor 자동 열기</li>
</ul>
<p>Renderer는 생성 결과를 <code>boolean</code>으로 받아 성공과 실패를 구분한다. 이 작은 반환 계약 덕분에 UI가 “요청을 보냈다”와 “파일이 실제로 만들어졌다”를 혼동하지 않는다.</p>
<h2 id="2-새-문서-생성-실패가-보이지-않던-문제">2. 새 문서 생성 실패가 보이지 않던 문제</h2>
<p>새 문서 생성 요청이 실패하면 이전 구현은 Shell 상태를 <code>error</code>로 바꾸는 데 그쳤다. 이미 document workspace가 선택된 화면에서는 해당 Shell 오류 문구가 보이지 않기 때문에 사용자는 입력이 무시된 것으로 느낄 수 있었다.</p>
<p>오류 코드는 사용자 행동 기준으로 변환했다.</p>
<ul>
<li>중복 파일명: 다른 경로 또는 파일명 입력</li>
<li>존재하지 않는 상위 폴더: 상위 폴더 경로 확인</li>
<li>잘못된 경로: 파일명과 폴더명 확인</li>
<li>지원하지 않는 확장자: <code>.md</code> 또는 <code>.markdown</code> 안내</li>
<li>권한 오류: 폴더 권한 안내</li>
</ul>
<p>실패 뒤 입력값을 지우지 않는 것도 중요하다. 사용자는 오류 메시지를 읽고 같은 입력창에서 바로 수정해 다시 시도할 수 있어야 한다.</p>
<h2 id="3-이미지-검색과-markdown-삽입을-하나의-상태로-취급하지-않기">3. 이미지 검색과 Markdown 삽입을 하나의 상태로 취급하지 않기</h2>
<p>이미지 검색에서는 원본 다운로드 성공과 Markdown 삽입 성공이 서로 다른 사건이다. 검색 UI로 포커스가 이동한 뒤 Editor selection이 사라지면, 파일은 <code>assets/</code>에 저장됐지만 문서에는 참조가 들어가지 않는 고아 asset이 남을 수 있다.</p>
<p>이미지 명령을 시작할 때 다음 정보를 별도로 캡처하도록 보완했다.</p>
<ul>
<li>document workspace ID</li>
<li>선택된 Markdown 문서 경로</li>
<li>Editor selection의 시작·끝 위치</li>
</ul>
<p>다운로드가 끝난 뒤에도 대상 문서가 같을 때만 캡처한 위치에 Markdown을 삽입한다. 사용자가 그 사이 다른 문서를 열거나 원래 문서를 닫았다면 파일만 남기지 않도록 저장된 asset을 정리하고 삽입 실패를 안내한다.</p>
<pre><code class="language-text">이미지 명령 실행
→ 문서·selection 캡처
→ 이미지 다운로드
→ 대상 문서 동일 여부 확인
→ 동일하면 Markdown 삽입
→ 달라졌으면 asset 정리와 사용자 안내</code></pre>
<p>정상적인 검색·다운로드·삽입 흐름은 기존 Electron E2E로 계속 검증한다. 문서 전환과 다운로드 완료가 겹치는 경계는 실제 수동 검증으로 추가 확인할 대상이다.</p>
<h2 id="4-저장-단축키와-상태-메시지">4. 저장 단축키와 상태 메시지</h2>
<p>문서 편집기는 버튼을 찾지 않아도 저장할 수 있어야 한다. Editor의 <code>keydown</code>에서 <code>Ctrl+S</code>와 <code>Cmd+S</code>를 가로채 현재 문서 저장 함수로 연결했다. 브라우저의 페이지 저장 대화상자가 실행되지 않도록 기본 동작도 막는다.</p>
<p>외부 변경을 불러온 뒤 표시되는 성공 메시지는 계속 남아 있으면 다음 작업의 오류처럼 보일 수 있다. 이 메시지는 2.5초 뒤 사라지도록 수명을 제한했다. 오류는 재시도에 필요하므로 즉시 지우지 않고 다음 정상 작업에서 정리한다.</p>
<p>작은 창에서 레이아웃이 무리하게 축소되는 문제에는 BrowserWindow 최소 크기 720×480을 적용했다. 이 값은 모든 화면을 크게 보이게 하기 위한 설정이 아니라, Editor·Preview·Explorer가 서로 겹치지 않으면서 각 영역의 내부 스크롤을 유지할 수 있는 하한선이다.</p>
<h2 id="5-대규모-폴더에서의-초기-비용-줄이기">5. 대규모 폴더에서의 초기 비용 줄이기</h2>
<p>대용량 Git 저장소를 열었을 때 앱 메모리가 약 200MB에서 2GB 수준까지 증가하고 초기 반응이 느려지는 현상이 보고됐다. 원인은 단일 항목이 아니라 여러 비용이 겹친 구조에 있다.</p>
<ul>
<li>전체 하위 디렉터리 재귀 탐색</li>
<li>Markdown 목록과 Explorer 전체 목록의 중복 탐색</li>
<li>디렉터리별 native watcher 등록</li>
<li>전체 트리의 Renderer 렌더링</li>
</ul>
<p>이번 단위에서는 위험이 낮고 효과를 측정하기 쉬운 두 가지를 먼저 적용했다.</p>
<p>첫째, <code>workspace:list-entries</code>가 제공되는 환경에서는 전체 Entry 결과에서 Markdown 파일을 추출한다. 같은 폴더를 Markdown 목록과 Explorer 목록으로 두 번 재귀 탐색하지 않는다.</p>
<p>둘째, <code>node_modules</code>, <code>dist</code>, <code>out</code>, <code>coverage</code>, <code>.cache</code>처럼 생성물·의존성에 해당하는 디렉터리를 초기 탐색과 watcher 대상에서 제외한다. <code>.gitignore</code> 전체를 Dock의 표시 규칙으로 사용하지 않은 이유는 Git 추적 여부와 사용자가 문서로 확인해야 하는지 여부가 다르기 때문이다. <code>docs/</code>와 일반 Markdown 문서는 계속 탐색 대상이다.</p>
<p>이는 최적화 Job의 최종 완료가 아니다. 다음 단계에서는 동일한 대용량 workspace에서 초기 표시 시간, Entry 수, watcher 수, Main·Renderer 메모리를 측정하고, 지연 로딩·증분 갱신·watcher 범위 축소를 적용할지 판단한다.</p>
<h2 id="검증-결과">검증 결과</h2>
<p>이번 보완 이후 다음 검증을 통과했다.</p>
<ul>
<li><code>npm run check</code></li>
<li>32개 test file, 152개 unit·component test</li>
<li>Explorer 파일·폴더 생성 Electron E2E</li>
<li>전체 Electron E2E 20/20</li>
<li>Windows packaging을 포함한 Electron 개발 실행 검증</li>
</ul>
<p>성능 최적화 전후의 메모리 기준값과 <code>Downloads/Dock</code> 실사용 재검증은 아직 남아 있다. 따라서 이번 작업은 “QA 결함 수정 완료”이지 “대규모 workspace 최적화 완료”라고 부르지 않는다.</p>
<h2 id="다음-확인-순서">다음 확인 순서</h2>
<ol>
<li><code>Downloads/Dock</code>에서 탐색기 상단·컨텍스트 메뉴의 파일·폴더 생성 확인</li>
<li>중복 문서와 잘못된 경로 입력 시 오류·입력 유지 확인</li>
<li>Editor selection을 잃은 상태에서 이미지 검색 후 삽입 결과 확인</li>
<li><code>Ctrl+S</code>와 외부 변경 성공 메시지 수명 확인</li>
<li>대규모 저장소 기준값 측정</li>
<li>전체 수동 QA checklist 재실행</li>
</ol>
<p>기능을 추가하는 속도보다 중요한 것은, 실패했을 때 사용자가 멈추지 않고 다음 행동을 선택할 수 있는 구조를 만드는 일이다. 이번 QA 보완은 그 기준을 파일 작업, 이미지 삽입, 저장, 성능 경계에 적용한 작업이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (25) - 중간 QA]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-25-%EC%A4%91%EA%B0%84-QA</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-25-%EC%A4%91%EA%B0%84-QA</guid>
            <pubDate>Mon, 31 Aug 2026 08:54:15 GMT</pubDate>
            <description><![CDATA[<h2 id="jarvis-dock">JARVIS Dock</h2>
<p><img src="https://velog.velcdn.com/images/re_brother/post/a48522af-9cb0-475f-bb8d-0e7ccedb4525/image.png" alt=""></p>
<p>20개가 넘는 개발 포스팅을 작성해오면서 텍스트와 Mermaid로 워크 플로우만 공유했지, 실제 어떤 제품을 개발하고 있는지 공유한 사항은 없었다.</p>
<blockquote>
<ul>
<li>어떤걸 만들고 있고</li>
</ul>
</blockquote>
<ul>
<li>어떤 기능들이 어떻게 구현되어있는지</li>
<li>실제 화면은 어떻게 구성되어있는지</li>
</ul>
<p>포스팅에 공유하고 개선되어가는 과정도 함께 공유하고자 한다.</p>
<blockquote>
<p><a href="https://github.com/jhpark-jarvis/jarvis-dock">JARVIS Dock Github Repository</a></p>
</blockquote>
<p>중간 QA를 하면서 당연히 예외 상황에 대한 처리도 중요하지만, &quot;실제 업무 상황에서 쓰인다면?&quot; 이라는 전제를 깔았다.
해당 전제를 깔게 되면서 테스트를 진행하다보니 예외 상황에 대한 처리보다 성능적인 개선도 함께 포함 되어야 한다는걸 뼈저리게 느꼈다.</p>
<h2 id="기능과-실사용-그-사이">기능과 실사용 그 사이</h2>
<p>Dock의 Workspace Explorer, 문서 충돌 보호, Markdown Preview, 이미지 삽입, Research View를 구현한 뒤 한 가지 질문이 남았다.</p>
<p>“지금까지 구현한 기능들이 예외 상황에서 정상적으로 동작하는가?”</p>
<p>단위 테스트와 Electron E2E가 통과했다는 의미의 동작과, 실제 사용자가 앱을 처음 열고 파일을 만들고 지우고 외부에서 수정하는 과정에서 불편 없이 사용할 수 있다는 의미의 동작은 같지 않다.</p>
<p>이번에는 <code>C:\Users\user\Downloads\Dock</code>를 실제 document workspace로 선택하고, 개발 중 만든 기능을 준비 단계부터 packaged 앱까지 순서대로 확인했다. 기준 commit은 다음과 같다.</p>
<blockquote>
<p>Dock Repository: <a href="https://github.com/jhpark-jarvis/jarvis-dock/commit/329ad56d1769750fa8509e56d1a190abce5012cc">JARVIS Dock Github</a></p>
</blockquote>
<p>검증 환경은 Windows 10 x64. 결과는 전체 <code>PARTIAL</code>이다. 대부분의 기능은 정상적으로 동작했지만, Explorer 생성 흐름과 새 문서 입력 검증, 이미지 삽입 위치 처리처럼 실제 사용성에 직접 영향을 주는 문제가 확인됐다.</p>
<h2 id="이번-qa에서-지킨-원칙">이번 QA에서 지킨 원칙</h2>
<p>이번 검증은 “버튼이 화면에 보이는가”만 확인하는 테스트가 아니었다. 사용자의 작업이 실제 파일에 반영되는지, 외부 변경으로 작업을 잃지 않는지, 실패했을 때 다음 행동을 알 수 있는지까지 확인했다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/f3334b18-95e2-42ef-ae1c-4892336b376e/image.png" alt=""></p>
<p>결과 표기는 원래 체크리스트와 동일하게 사용했다.</p>
<ul>
<li><code>P</code>: PASS</li>
<li><code>F</code>: FAIL</li>
<li><code>B</code>: BLOCKED</li>
<li><code>N/A</code>: 테스트 환경 또는 조건 없음</li>
</ul>
<p>총 143개 항목의 결과는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>판정</th>
<th align="right">개수</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>PASS</td>
<td align="right">125</td>
<td>기대 결과와 일치</td>
</tr>
<tr>
<td>FAIL</td>
<td align="right">6</td>
<td>실제 오류 또는 기대 결과와 불일치</td>
</tr>
<tr>
<td>BLOCKED</td>
<td align="right">6</td>
<td>선행 실패 때문에 진행 불가</td>
</tr>
<tr>
<td>N/A</td>
<td align="right">6</td>
<td>macOS·Linux 환경 부재</td>
</tr>
</tbody></table>
<p>중요한 점은 PASS 개수가 많다는 사실보다, 실패한 항목이 어떤 사용자 흐름을 막고 있는지다.</p>
<h2 id="0-준비-단계">0. 준비 단계</h2>
<p>테스트 폴더를 백업 또는 복사본으로 준비하고 저장소 최신 상태를 확인했다. <code>npm run dev</code>로 최신 Dock을 실행했으며, 기존 Research View와 문서 탭을 닫은 상태에서 테스트를 시작했다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td>테스트 폴더 백업 또는 복사본 준비</td>
</tr>
<tr>
<td>P</td>
<td>저장소 최신 상태 확인</td>
</tr>
<tr>
<td>P</td>
<td><code>npm run dev</code>로 최신 Dock 실행</td>
</tr>
<tr>
<td>P</td>
<td>기존 Research View와 문서 탭 닫기</td>
</tr>
<tr>
<td>P</td>
<td>테스트 시작 시각 기록: 2026-08-30 21:46</td>
</tr>
</tbody></table>
<p>출발 조건은 정상적이었다. 이후 결과는 특정 이전 화면이나 남아 있던 문서 상태에 의존하지 않는다.</p>
<h2 id="1-폴더-선택과-초기-explorer">1. 폴더 선택과 초기 Explorer</h2>
<p><code>폴더 선택</code>으로 <code>C:\Users\user\Downloads\Dock</code> 선택하고 실제 디렉터리와 Dock의 Explorer 트리를 비교했다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
<th>실제 결과</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td><code>폴더 선택</code>으로 테스트 폴더 선택</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>실제 폴더 구조와 Dock Explorer 목록 일치</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>파일 아이콘과 폴더 아이콘 표시</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>폴더 펼침·접힘 아이콘이 문자 기호가 아닌 아이콘으로 표시</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>폴더 접기 후 Editor가 확장</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>폴더 펼치기 후 Explorer·Editor·Preview가 겹치지 않음</td>
<td>정상</td>
</tr>
<tr>
<td>F</td>
<td>작은 창 크기에서도 가로 overflow 없음</td>
<td>가로 overflow는 없었지만 Document Workspace에 스크롤 발생</td>
</tr>
</tbody></table>
<p>마지막 항목은 해석이 필요하다. 원래 체크 항목은 가로 overflow를 확인하는 내용이므로 그 조건만 보면 문제가 없었다. 하지만 사용자가 작은 창에서 문서를 작성할 때 Document Workspace에 예상하지 못한 스크롤이 생기는 것은 별도의 UX 문제다.</p>
<p>따라서 이 항목은 순수한 CSS overflow 결함이라기보다 작은 창에서 최소 크기와 내부 스크롤 정책이 명확하지 않다는 레이아웃 문제로 분류했다.</p>
<p>검토 방향은 세 가지다.</p>
<ul>
<li>앱의 최소 창 크기를 정하고 그 이하에서는 창 확대를 안내한다.</li>
<li>전체 레이아웃의 스크롤과 Document Workspace 내부 스크롤을 분리한다.</li>
<li>작은 창 전용으로 Explorer 헤더·목록·Editor 영역의 높이 배분을 조정한다.</li>
</ul>
<h2 id="2-dock-외부-파일·폴더-동기화">2. Dock 외부 파일·폴더 동기화</h2>
<p>이번 단계의 목적은 Windows 파일 탐색기에서 파일과 폴더를 바꿨을 때 Dock이 재실행 없이 실제 디렉터리 상태를 따라오는지 확인하는 것이었다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
<th>실제 결과</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td><code>external.md</code> 생성 후 Dock Explorer에 자동 표시</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td><code>external.md</code>를 Dock에서 열기</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>파일 내용을 정상적으로 읽기</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td><code>external.md</code>를 <code>renamed.md</code>로 변경</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>기존 이름이 Dock에서 사라짐</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>새 이름이 Dock에 표시됨</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td><code>renamed.md</code> 삭제 후 Dock에서 자동 제거</td>
<td>정상</td>
</tr>
<tr>
<td>F</td>
<td><code>notes</code> 폴더 생성 후 Dock에 자동 표시</td>
<td>테스트 메모상 Dock 내부 <code>+ 폴더</code> 버튼 무반응</td>
</tr>
<tr>
<td>B</td>
<td><code>notes/nested.md</code> 생성 후 중첩 항목 표시</td>
<td>폴더 생성 실패로 진행 불가</td>
</tr>
<tr>
<td>B</td>
<td><code>notes</code> 폴더 삭제 후 폴더와 내부 파일이 모두 제거</td>
<td>폴더 생성 실패로 진행 불가</td>
</tr>
<tr>
<td>P</td>
<td>Dock 재실행 없이 모든 변경이 반영</td>
<td>파일 변경 흐름에서는 정상</td>
</tr>
</tbody></table>
<p>여기서 테스트 절차의 구분이 중요하다. 이 섹션은 Windows 파일 탐색기에서 <code>notes</code> 폴더를 만들어 native watcher가 감지하는 흐름이어야 한다. 그러나 Dock 내부의 <code>[+ 폴더]</code>, <code>[+ 파일]</code> 버튼을 클릭했을 때 아무런 상호작용이 없었다.</p>
<p>따라서 다음 두 가지를 분리해서 봐야 한다.</p>
<ol>
<li>외부 파일 생성·이름 변경·삭제 동기화는 실제로 통과했다.</li>
<li>외부 폴더 생성 동기화는 절차가 정확히 수행되지 않아 아직 검증하지 못했다.</li>
</ol>
<p>이 문제를 수정한 뒤에는 Windows 파일 탐색기에서 <code>notes</code>를 직접 생성하고, Dock에서 중첩 파일 표시와 폴더 삭제까지 다시 확인해야 한다.</p>
<h2 id="3-explorer-내부-crud">3. Explorer 내부 CRUD</h2>
<p>이번 QA에서 가장 큰 기능 결함이 발견된 구간이다. 이름 변경과 삭제는 동작했지만, Explorer 상단과 컨텍스트 메뉴의 새 파일·새 폴더 생성 흐름이 동작하지 않았다.</p>
<h3 id="파일·폴더-생성">파일·폴더 생성</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
<th>실제 결과</th>
</tr>
</thead>
<tbody><tr>
<td>F</td>
<td>Explorer의 <code>+ 파일</code>로 <code>new.md</code> 생성</td>
<td>버튼 클릭 후 무반응</td>
</tr>
<tr>
<td>B</td>
<td>생성된 파일이 자동으로 Editor에서 열림</td>
<td>파일 생성 실패로 진행 불가</td>
</tr>
<tr>
<td>F</td>
<td>Explorer의 <code>+ 폴더</code>로 <code>drafts</code> 생성</td>
<td>버튼 클릭 후 무반응</td>
</tr>
<tr>
<td>B</td>
<td><code>drafts</code> 안에 <code>draft.md</code> 생성</td>
<td>폴더 생성 실패로 진행 불가</td>
</tr>
</tbody></table>
<h3 id="이름-변경">이름 변경</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
<th>실제 결과</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td>파일 더블클릭 시 행 내부 입력창 표시</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>새 이름 입력 후 Enter로 저장</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>실제 파일 이름 변경</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>Escape로 이름 변경 취소</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>폴더 이름도 inline 방식으로 변경</td>
<td>정상</td>
</tr>
</tbody></table>
<h3 id="컨텍스트-메뉴">컨텍스트 메뉴</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
<th>실제 결과</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td>파일에서 마우스 오른쪽 클릭 시 메뉴 표시</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>폴더에서 마우스 오른쪽 클릭 시 메뉴 표시</td>
<td>정상</td>
</tr>
<tr>
<td>F</td>
<td><code>새 파일</code> 실행</td>
<td>실행 후 무반응</td>
</tr>
<tr>
<td>F</td>
<td><code>새 폴더</code> 실행</td>
<td>실행 후 무반응</td>
</tr>
<tr>
<td>P</td>
<td><code>이름 변경</code> 실행</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td><code>삭제</code> 실행</td>
<td>정상</td>
</tr>
</tbody></table>
<h3 id="오류·삭제">오류·삭제</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
<th>실제 결과</th>
</tr>
</thead>
<tbody><tr>
<td>B</td>
<td>동일한 이름의 파일 생성 시 오류 표시</td>
<td>파일 생성 진입 실패로 확인 불가</td>
</tr>
<tr>
<td>B</td>
<td>동일한 이름의 폴더 생성 시 오류 표시</td>
<td>폴더 생성 진입 실패로 확인 불가</td>
</tr>
<tr>
<td>P</td>
<td>잘못된 이름 입력 시 오류 표시</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>이름 변경 실패 시 입력 내용 유지</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>삭제 취소 시 실제 항목 유지</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>삭제 확인 시 실제 항목 삭제</td>
<td>정상</td>
</tr>
<tr>
<td>P</td>
<td>폴더 삭제 시 내부 항목도 삭제</td>
<td>정상</td>
</tr>
</tbody></table>
<p>이름 변경 자체는 작동했지만, 중복 이름 오류 메시지의 위치와 여백이 어색했다. 더 중요한 문제는 오류가 이름 변경 종료 후에도 계속 남아 있다는 점이다. 오류는 작업 단위의 상태로 관리하고, 이름 변경이 취소되거나 정상적으로 종료되면 해당 오류를 제거해야 한다.</p>
<p>별도의 <code>[새 문서 생성]</code> 흐름은 다음과 같이 확인됐다.</p>
<ul>
<li>현재 존재하는 폴더 이름을 새 문서 경로로 입력하면 정상적으로 생성된다.</li>
<li>중복 이름을 입력해도 오류 메시지가 표시되지 않는다.</li>
<li>존재하지 않는 경로를 입력하면 생성되지 않지만 사용자에게 아무런 안내가 없다.</li>
</ul>
<p>즉, Explorer의 생성 버튼 무반응과 새 문서 생성 폼의 입력 검증 부족은 서로 다른 문제다. 전자는 이벤트 연결 또는 UI 진입 흐름을 먼저 복구해야 하고, 후자는 Main의 오류를 Renderer가 사용자 메시지로 매핑해야 한다.</p>
<h2 id="4-저장된-문서의-외부-변경">4. 저장된 문서의 외부 변경</h2>
<p>저장된 <code>guide.md</code>를 열고 Windows 파일 탐색기에서 내용을 변경하거나 삭제했다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td>저장된 <code>guide.md</code> 열기</td>
</tr>
<tr>
<td>P</td>
<td>Editor가 저장 상태인지 확인</td>
</tr>
<tr>
<td>P</td>
<td>Windows 파일 탐색기에서 <code>guide.md</code> 내용 변경</td>
</tr>
<tr>
<td>P</td>
<td>Dock Editor에 외부 내용 자동 반영</td>
</tr>
<tr>
<td>P</td>
<td>변경된 파일의 Explorer 상태 정상</td>
</tr>
<tr>
<td>P</td>
<td>저장된 문서를 외부에서 삭제</td>
</tr>
<tr>
<td>P</td>
<td>Dock에서 문서가 자동으로 닫힘</td>
</tr>
</tbody></table>
<p>저장된 문서는 외부 변경을 그대로 반영해도 사용자의 미저장 작업을 잃을 위험이 없기 때문에 자동 동기화가 가능하다. 이 흐름은 기대대로 동작했다.</p>
<h2 id="5-미저장-문서의-외부-변경-충돌">5. 미저장 문서의 외부 변경 충돌</h2>
<p>문서 내용을 수정한 뒤 저장하지 않고 외부 파일을 바꿔 충돌 상태를 만들었다.</p>
<h3 id="충돌-감지와-보호">충돌 감지와 보호</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td><code>guide.md</code> 열기</td>
</tr>
<tr>
<td>P</td>
<td>Editor 내용 변경</td>
</tr>
<tr>
<td>P</td>
<td>저장하지 않은 상태에서 외부 파일 내용 변경</td>
</tr>
<tr>
<td>P</td>
<td>충돌 안내 표시</td>
</tr>
<tr>
<td>P</td>
<td>Editor의 미저장 내용 유지</td>
</tr>
<tr>
<td>P</td>
<td>외부 내용으로 자동 덮어쓰기되지 않음</td>
</tr>
<tr>
<td>P</td>
<td><code>외부 변경 불러오기</code> 버튼 표시</td>
</tr>
<tr>
<td>P</td>
<td><code>내 작업으로 저장</code> 버튼 표시</td>
</tr>
<tr>
<td>P</td>
<td><code>문서 닫기</code> 버튼 표시</td>
</tr>
</tbody></table>
<h3 id="외부-변경-불러오기">외부 변경 불러오기</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td><code>외부 변경 불러오기</code> 클릭</td>
</tr>
<tr>
<td>P</td>
<td>외부 내용이 Editor에 표시</td>
</tr>
<tr>
<td>P</td>
<td>충돌 안내 제거</td>
</tr>
<tr>
<td>P</td>
<td>저장 상태 정상화</td>
</tr>
</tbody></table>
<h3 id="내-작업으로-저장">내 작업으로 저장</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td>Editor 내용 재수정</td>
</tr>
<tr>
<td>P</td>
<td>외부 파일을 다시 변경해 충돌 발생</td>
</tr>
<tr>
<td>P</td>
<td><code>내 작업으로 저장</code> 클릭</td>
</tr>
<tr>
<td>P</td>
<td>실제 파일 내용이 Editor 내용과 일치</td>
</tr>
<tr>
<td>P</td>
<td>저장 상태가 <code>저장됨</code>으로 변경</td>
</tr>
</tbody></table>
<h3 id="문서-닫기">문서 닫기</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td>미저장 변경 후 외부 변경 발생</td>
</tr>
<tr>
<td>P</td>
<td><code>문서 닫기</code> 클릭</td>
</tr>
<tr>
<td>P</td>
<td>문서 탭과 Editor에서 문서 제거</td>
</tr>
<tr>
<td>P</td>
<td>충돌 안내 제거</td>
</tr>
</tbody></table>
<p>핵심 보호 규칙은 지켜졌다. Dock은 미저장 내용을 외부 파일 내용으로 자동 덮어쓰지 않았고, 사용자가 선택한 뒤에만 외부 내용을 불러오거나 자신의 작업을 저장했다.</p>
<p>다만 <code>외부 변경 내용을 불러왔습니다.</code> 메시지는 문서 전환을 해야 사라졌다. 성공 메시지는 일정 시간 후 자동 제거하거나, 동일 작업이 종료되는 즉시 제거하는 편이 자연스럽다.</p>
<h2 id="6-미저장-문서-외부-삭제·이름-변경">6. 미저장 문서 외부 삭제·이름 변경</h2>
<p>문서를 편집한 상태에서 외부에서 삭제하거나 이름을 변경해 복구 흐름을 확인했다.</p>
<h3 id="삭제-후-복구-저장">삭제 후 복구 저장</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td><code>guide.md</code> 열기</td>
</tr>
<tr>
<td>P</td>
<td>내용을 수정하고 저장하지 않음</td>
</tr>
<tr>
<td>P</td>
<td>파일 탐색기에서 <code>guide.md</code> 삭제</td>
</tr>
<tr>
<td>P</td>
<td>충돌 안내 표시</td>
</tr>
<tr>
<td>P</td>
<td>미저장 내용 유지</td>
</tr>
<tr>
<td>P</td>
<td><code>내 작업으로 저장</code> 클릭</td>
</tr>
<tr>
<td>P</td>
<td>원래 경로에 <code>guide.md</code> 재생성</td>
</tr>
<tr>
<td>P</td>
<td>파일 내용이 미저장 Editor 내용과 일치</td>
</tr>
</tbody></table>
<h3 id="외부-이름-변경">외부 이름 변경</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td>새 테스트 문서 열기</td>
</tr>
<tr>
<td>P</td>
<td>내용을 수정하고 저장하지 않음</td>
</tr>
<tr>
<td>P</td>
<td>파일 탐색기에서 문서 이름 변경</td>
</tr>
<tr>
<td>P</td>
<td>충돌 안내 표시</td>
</tr>
<tr>
<td>P</td>
<td>미저장 내용 유지</td>
</tr>
<tr>
<td>P</td>
<td><code>문서 닫기</code> 또는 <code>내 작업으로 저장</code> 동작 확인</td>
</tr>
</tbody></table>
<p>외부에서 파일이 사라져도 미저장 작업을 버리지 않고 원래 경로에 다시 저장할 수 있었다. 별도 개선 의견으로 <code>Ctrl + S</code> 저장 단축키가 남았다. 현재 저장 버튼을 직접 누르는 흐름은 정상이나, 문서 편집기의 기본 기대 동작을 만족하려면 단축키를 제공하는 편이 좋다.</p>
<h2 id="7-editor·preview-회귀">7. Editor·Preview 회귀</h2>
<p>긴 Markdown 문서와 이전에 문제가 발생했던 표·인용문·Mermaid 문서를 사용해 Editor와 Preview의 회귀 여부를 확인했다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td>긴 Markdown 문서에서 Editor 자체 스크롤 동작</td>
</tr>
<tr>
<td>P</td>
<td>Preview 자체 스크롤 동작</td>
</tr>
<tr>
<td>P</td>
<td>Editor와 Preview가 서로 겹치지 않음</td>
</tr>
<tr>
<td>P</td>
<td>Markdown 표가 정상 렌더링</td>
</tr>
<tr>
<td>P</td>
<td>인용문 <code>&gt;</code>의 간격과 디자인 확인</td>
</tr>
<tr>
<td>P</td>
<td>코드 하이라이트 표시</td>
</tr>
<tr>
<td>P</td>
<td>Mermaid 다이어그램 표시</td>
</tr>
<tr>
<td>P</td>
<td>긴 Mermaid 다이어그램이 Preview 영역 밖으로 튀어나오지 않음</td>
</tr>
<tr>
<td>P</td>
<td>문서 탭과 Editor 헤더 사이 여백 확인</td>
</tr>
<tr>
<td>P</td>
<td>창 크기 변경 후 레이아웃 유지</td>
</tr>
</tbody></table>
<p>기능 회귀는 없었다. 다만 창 크기를 변경하는 동안 일부 렉이 관찰됐다. Preview가 Markdown을 다시 파싱하고 코드 하이라이트와 Mermaid를 다시 렌더링하는 과정에서 리소스가 발생하는 것으로 추정된다.</p>
<p>이 관찰은 단순한 CSS 문제로 단정하지 않는다. 다음 최적화 Job에서 렌더링 시간, Preview 재계산 횟수, Mermaid 재생성 횟수, Main과 Renderer 메모리를 측정해 원인을 분리해야 한다.</p>
<h2 id="8-이미지-기능-회귀">8. 이미지 기능 회귀</h2>
<p>이미지 검색, 다운로드, 로컬 저장, Markdown 삽입, Preview, 클립보드, 원본 삭제 흐름을 확인했다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td><code>/image</code> 실행</td>
</tr>
<tr>
<td>P</td>
<td>검색 결과 카드에 썸네일 표시</td>
</tr>
<tr>
<td>P</td>
<td>이미지 출처·라이선스 표시</td>
</tr>
<tr>
<td>P</td>
<td>이미지 선택 후 <code>assets/</code>에 원본 저장</td>
</tr>
<tr>
<td>P</td>
<td>Markdown 이미지 문법 삽입</td>
</tr>
<tr>
<td>P</td>
<td>Preview에서 이미지 렌더링</td>
</tr>
<tr>
<td>P</td>
<td>캡처 이미지를 클립보드에 복사</td>
</tr>
<tr>
<td>P</td>
<td>Editor에 붙여넣기</td>
</tr>
<tr>
<td>P</td>
<td>클립보드 이미지가 <code>assets/</code>에 저장</td>
</tr>
<tr>
<td>P</td>
<td>클립보드 이미지 Preview 렌더링</td>
</tr>
<tr>
<td>P</td>
<td>본문 이미지 참조 삭제 후 저장</td>
</tr>
<tr>
<td>P</td>
<td>원본 삭제 확인창 표시</td>
</tr>
<tr>
<td>P</td>
<td>삭제 취소 시 원본 유지</td>
</tr>
<tr>
<td>P</td>
<td>삭제 확인 시 원본 삭제</td>
</tr>
</tbody></table>
<p>기본 흐름은 통과했지만 결과를 그대로 <code>PASS</code>로 확정하기 어려운 조건이 하나 있다.</p>
<p>Editor에 커서를 둔 상태로 이미지 검색을 실행하면 이미지가 정상적으로 다운로드되고 Markdown도 삽입된다. 그러나 커서가 없는 상태에서 검색하면 <code>assets/</code>에 원본만 저장되고 Markdown 참조는 삽입되지 않는다.</p>
<p>이 상태는 사용자에게 “이미지를 넣었다”는 인상을 주지만 실제 문서에서는 이미지를 참조하지 않는다. 결과 파일만 남는 고아 asset이 생길 수 있으므로 <code>PARTIAL PASS</code>로 재분류하는 것이 정확하다.</p>
<p>다음 중 하나를 제품 규칙으로 정해야 한다.</p>
<ul>
<li>마지막 Editor selection을 복원해 그 위치에 삽입한다.</li>
<li>커서가 없으면 다운로드 전에 삽입 위치를 안내한다.</li>
<li>커서가 없으면 다운로드를 실행하지 않고 Editor 포커스를 요청한다.</li>
</ul>
<p>개인적으로는 마지막 selection 보존을 우선 검토할 만하다. 검색 창이나 Research View를 여는 순간 Editor의 selection이 사라지는 것은 자연스러운 UI 동작이기 때문에, 검색을 시작할 때 selection을 기억해 두고 다운로드 성공 후 복원하는 방식이 사용자의 기대와 가장 잘 맞는다.</p>
<p>이미지 검색 provider를 Wikimedia Commons 외로 확장하는 의견도 있었지만, 이는 현재 결함 수정과 분리된 별도 provider 검토 과제다.</p>
<h2 id="9-research-view">9. Research View</h2>
<p>Google 검색은 반복 요청에서 <code>Sorry</code> 차단이 발생할 수 있으므로 한 세션에서 최소한으로 확인했다. User-Agent 변경이나 사용자 Chrome 프로필 복제는 적용하지 않았다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td><code>/link</code> 실행</td>
</tr>
<tr>
<td>P</td>
<td>Research View 표시</td>
</tr>
<tr>
<td>P</td>
<td>상단바에 현재 URL 표시</td>
</tr>
<tr>
<td>P</td>
<td>새로고침 버튼 표시</td>
</tr>
<tr>
<td>P</td>
<td>중지 버튼 표시</td>
</tr>
<tr>
<td>P</td>
<td>닫기 버튼 표시</td>
</tr>
<tr>
<td>P</td>
<td>상단바와 WebView 사이 여백·구분선 표시</td>
</tr>
<tr>
<td>P</td>
<td>Research View가 Editor를 덮지 않음</td>
</tr>
<tr>
<td>P</td>
<td>검색 결과 카드 표시 여부 기록</td>
</tr>
<tr>
<td>P</td>
<td>카드 클릭 시 현재 Editor 커서 위치에 링크 삽입</td>
</tr>
<tr>
<td>P</td>
<td>카드가 없을 때 현재 페이지 링크 삽입</td>
</tr>
<tr>
<td>P</td>
<td><code>Sorry</code> 페이지에서도 fallback 동작</td>
</tr>
<tr>
<td>P</td>
<td>새 창·새 탭 링크가 Research 탭으로 추가</td>
</tr>
<tr>
<td>P</td>
<td>명령 팔레트가 Research View 아래로 깔리지 않음</td>
</tr>
</tbody></table>
<p>검색 결과 기록은 다음과 같다.</p>
<pre><code class="language-text">검색어: python
표시된 URL: https://www.google.com/search?q=python
카드 표시 여부: Y
Sorry 표시 여부: Y
fallback 동작: Y</code></pre>
<p>여기서 <code>Sorry</code>가 표시됐다는 사실과 Dock의 Research View 동작이 통과했다는 사실은 동시에 성립할 수 있다. Google이 차단 응답을 보냈지만, Dock은 Research View를 표시했고 현재 페이지 링크 fallback도 수행했다. 따라서 앱의 fallback·레이아웃·삽입 경계는 PASS지만, Google 검색 결과의 안정적인 제공은 여전히 외부 환경에 종속된 제한 사항이다.</p>
<p>처음 실행하는 별도 머신에서도 동일한 현상이 발생하는지 추가 확인할 필요가 있다. 다만 User-Agent 위조나 실제 Chrome 프로필·쿠키 복제로 차단을 우회하는 방식은 현재 보안·제품 결정상 적용하지 않는다.</p>
<h2 id="10-packaged-앱-최종-확인">10. Packaged 앱 최종 확인</h2>
<p>개발 서버가 아닌 packaged 실행 파일을 사용해 최종 동작을 확인했다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td><code>npm run test:smoke</code> 통과</td>
</tr>
<tr>
<td>P</td>
<td><code>npm run make</code> 통과</td>
</tr>
<tr>
<td>P</td>
<td><code>apps/desktop/out/Dock-win32-x64/Dock.exe</code> 실행</td>
</tr>
<tr>
<td>P</td>
<td>Dock 창 표시</td>
</tr>
<tr>
<td>P</td>
<td>Explorer 아이콘·트리 표시</td>
</tr>
<tr>
<td>P</td>
<td>Editor·Preview 레이아웃 정상</td>
</tr>
<tr>
<td>P</td>
<td>Research View 표시 시 다른 UI를 덮지 않음</td>
</tr>
<tr>
<td>P</td>
<td>충돌 안내 UI 표시</td>
</tr>
<tr>
<td>P</td>
<td>창 크기 변경 후 레이아웃 유지</td>
</tr>
<tr>
<td>P</td>
<td>앱 정상 종료</td>
</tr>
</tbody></table>
<p>Windows packaged 앱에서는 개발 환경에서만 발생하는 동작 차이를 발견하지 못했다.</p>
<h2 id="11-플랫폼별-확인">11. 플랫폼별 확인</h2>
<table>
<thead>
<tr>
<th>상태</th>
<th>확인 항목</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>P</td>
<td>Windows GUI 확인</td>
<td>완료</td>
</tr>
<tr>
<td>N/A</td>
<td>macOS GUI 확인</td>
<td>테스트 환경 없음</td>
</tr>
<tr>
<td>N/A</td>
<td>macOS native folder dialog 확인</td>
<td>테스트 환경 없음</td>
</tr>
<tr>
<td>N/A</td>
<td>macOS <code>fs.watch</code> 외부 변경 확인</td>
<td>테스트 환경 없음</td>
</tr>
<tr>
<td>N/A</td>
<td>Linux GUI 확인</td>
<td>테스트 환경 없음</td>
</tr>
<tr>
<td>N/A</td>
<td>Linux native folder dialog 확인</td>
<td>테스트 환경 없음</td>
</tr>
<tr>
<td>N/A</td>
<td>Linux <code>fs.watch</code> 외부 변경 확인</td>
<td>테스트 환경 없음</td>
</tr>
</tbody></table>
<p><code>N/A</code>는 실패가 아니라 미검증이다. 그러나 릴리스 후보를 선언하려면 지원 플랫폼의 실제 GUI 동작과 native folder dialog, 파일 변경 감시를 별도로 확인해야 한다.</p>
<h2 id="무엇이-실제-결함이고-무엇이-다음-개선인가">무엇이 실제 결함이고, 무엇이 다음 개선인가</h2>
<p>QA 메모를 모두 기능 결함으로 취급하면 우선순위가 흐려진다. 이번 결과는 다음처럼 나누는 것이 정확하다.</p>
<h3 id="즉시-수정해야-하는-결함">즉시 수정해야 하는 결함</h3>
<ol>
<li>Explorer <code>+ 파일</code>, <code>+ 폴더</code> 버튼 무반응</li>
<li>컨텍스트 메뉴의 <code>새 파일</code>, <code>새 폴더</code> 무반응</li>
<li>새 문서 생성 시 중복 이름·존재하지 않는 경로에 대한 오류 안내 부재</li>
<li>Editor selection이 없을 때 이미지가 파일만 저장되고 Markdown에는 삽입되지 않는 문제</li>
</ol>
<h3 id="기능은-통과했지만-ux-개선이-필요한-항목">기능은 통과했지만 UX 개선이 필요한 항목</h3>
<ol>
<li>작은 창에서 Document Workspace에 발생하는 스크롤 정책</li>
<li>이름 변경 오류 메시지의 위치·여백과 종료 후 잔존</li>
<li>외부 변경 불러오기 성공 메시지의 제거 시점</li>
<li><code>Ctrl + S</code> 저장 단축키</li>
<li>이미지 검색 카드 UI와 provider 확장성</li>
</ol>
<h3 id="환경-또는-외부-서비스에-의존하는-항목">환경 또는 외부 서비스에 의존하는 항목</h3>
<ol>
<li>Google <code>Sorry</code> 응답의 재현성과 첫 실행 머신 확인</li>
<li>macOS GUI와 native folder dialog</li>
<li>Linux GUI와 native folder dialog</li>
</ol>
<h3 id="별도-job으로-분리한-항목">별도 Job으로 분리한 항목</h3>
<p>창 크기 변경 중 렉과 대용량 저장소 선택 시 메모리 급증은 “느낌이 조금 느리다” 수준으로 넘길 문제가 아니다. 현재 구조에서는 다음 작업이 동시에 일어날 수 있다.</p>
<ul>
<li>전체 document workspace 재귀 탐색</li>
<li>Markdown 파일 목록 조회</li>
<li>Explorer entry 목록 조회</li>
<li>디렉터리별 native <code>fs.watch</code> 등록</li>
<li>전체 Explorer 트리 렌더링</li>
<li>Preview Markdown 파싱과 Mermaid·코드 하이라이트 재계산</li>
</ul>
<p>특히 <code>node_modules</code>, <code>out</code>처럼 수만 개 파일과 하위 디렉터리를 가진 저장소를 선택하면 탐색 대상, watcher, React 노드가 한꺼번에 늘어난다. 그래서 QA가 끝난 뒤 <code>Job: Dock 앱 최적화</code>로 분리했다.</p>
<h2 id="수정-순서">수정 순서</h2>
<p>수정은 다음 순서가 적절하다.</p>
<h3 id="1단계-explorer-생성-흐름-복구">1단계: Explorer 생성 흐름 복구</h3>
<p>먼저 상단의 <code>+ 파일</code>, <code>+ 폴더</code>와 컨텍스트 메뉴의 <code>새 파일</code>, <code>새 폴더</code>가 동일한 생성 함수를 호출하는지 확인한다. 버튼 이벤트가 연결됐는지, 생성 대상 경로가 현재 선택된 디렉터리로 전달되는지, 생성 성공 후 목록 갱신과 Editor 자동 열기가 이어지는지 분리해서 검증한다.</p>
<h3 id="2단계-오류-반환과-사용자-메시지-정리">2단계: 오류 반환과 사용자 메시지 정리</h3>
<p>새 문서 생성과 Explorer 생성에서 다음 결과를 명확한 메시지로 보여줘야 한다.</p>
<ul>
<li>중복 파일·폴더</li>
<li>존재하지 않는 부모 경로</li>
<li>잘못된 이름</li>
<li>권한 부족</li>
<li>저장 또는 목록 갱신 실패</li>
</ul>
<p>오류 메시지는 Main의 실제 실패 이유를 그대로 노출하지 않고, 사용자 행동으로 번역해야 한다. 이름 변경이 종료되면 이전 이름 변경 오류도 제거해야 한다.</p>
<h3 id="3단계-이미지-삽입-위치-보존">3단계: 이미지 삽입 위치 보존</h3>
<p>이미지 검색을 시작하는 시점에 Editor의 selection과 문서 경로를 보존한다. 검색과 다운로드가 성공한 뒤에도 문서가 같은 상태인지 확인하고, 안전한 경우에만 기존 selection에 Markdown을 삽입한다. 삽입할 위치가 사라졌다면 파일만 남기지 말고 사용자에게 명확히 알려야 한다.</p>
<h3 id="4단계-회귀-qa">4단계: 회귀 QA</h3>
<p>수정 후에는 실패한 항목만 보는 것이 아니라 다음을 다시 확인해야 한다.</p>
<ul>
<li>Explorer 내부 생성과 컨텍스트 메뉴</li>
<li>외부 폴더 생성·중첩 파일·삭제 동기화</li>
<li>새 문서 오류 검증</li>
<li>이미지 검색과 커서 위치 삽입</li>
<li>기존 문서 충돌 보호</li>
<li>Packaged 앱 실행</li>
</ul>
<p>이 회귀가 끝난 뒤에야 수동 QA Exit Gate를 다시 판정할 수 있다.</p>
<h2 id="다음-job-dock-앱-최적화">다음 Job: Dock 앱 최적화</h2>
<p>성능 최적화는 QA 결과에 대한 즉흥적인 <code>setTimeout</code>이나 무조건적인 <code>.gitignore</code> 적용으로 시작하지 않는다. 먼저 현재 구현의 비용을 측정해야 한다.</p>
<p>착수 시 확인할 기준은 다음과 같다.</p>
<ul>
<li>초기 workspace 선택부터 첫 Explorer 표시까지의 시간</li>
<li>재귀 탐색 파일·디렉터리 수</li>
<li>등록된 watcher 수</li>
<li>Main과 Renderer의 메모리 사용량</li>
<li>Preview 재계산 횟수와 Mermaid 재생성 횟수</li>
<li>창 크기 변경 중 UI 응답성</li>
</ul>
<p>그 다음 아래 정책을 결정한다.</p>
<ul>
<li><code>.git</code>, <code>node_modules</code>, <code>out</code>, <code>.vite</code>, <code>dist</code>, <code>build</code>, <code>coverage</code>, <code>test-results</code>, <code>playwright-report</code>, <code>.cache</code> 같은 기본 제외 디렉터리</li>
<li><code>.gitignore</code>를 탐색 힌트로 사용할지 여부</li>
<li><code>.gitignore</code>에 포함됐더라도 사용자가 봐야 하는 <code>docs/</code> 같은 문서 폴더의 가시성</li>
<li><code>!</code> negation과 중첩 <code>.gitignore</code>를 어느 수준까지 지원할지</li>
</ul>
<p><code>.gitignore</code>는 곧 “Dock에서 숨길 파일 목록”이 아니다. Git 저장소에서 추적하지 않는다는 의미와 사용자가 문서 앱에서 열람하지 않아야 한다는 의미는 다르다. 예를 들어 이 저장소의 <code>docs/</code>는 Git 기준에서는 ignore 대상이지만 Dock에서는 사용자가 반드시 확인해야 하는 문서 workspace가 될 수 있다.</p>
<p>구현 후보는 하나만 선택할 필요가 없다.</p>
<ul>
<li>한 번의 탐색 결과를 Markdown 목록과 Explorer가 함께 사용하도록 중복 스캔 제거</li>
<li>폴더를 펼칠 때만 자식 항목을 조회하는 지연 로딩</li>
<li>변경된 디렉터리만 갱신하는 증분 탐색</li>
<li>모든 디렉터리에 watcher를 두지 않고 감시 범위를 줄이는 방식</li>
<li>Preview가 실제로 변경됐을 때만 파싱·렌더링하는 방식</li>
</ul>
<p>이 Job의 완료 조건은 “메모리가 줄었다”가 아니다. 최적화 전후 측정값으로 원인을 설명하고, <code>Downloads/Dock</code>의 일반 문서 흐름을 훼손하지 않으며, 대용량 workspace에서도 초기 화면과 Explorer가 응답하는지 회귀 테스트로 확인하는 것이다.</p>
<h2 id="마무리">마무리</h2>
<p>이번 QA를 통해 Dock은 핵심 문서 편집 흐름과 외부 파일 변경 보호를 안정적으로 수행한다는 것을 확인했다. 동시에 실제 사용성은 자동 테스트의 녹색 결과만으로 완성되지 않는다는 것도 확인했다.</p>
<p>특히 다음 세 가지가 이번 QA의 핵심 결과다.</p>
<ol>
<li>Explorer 생성 버튼은 화면에 존재하는 것과 실제로 사용할 수 있는 것이 달랐다.</li>
<li>이미지 검색은 다운로드 성공과 문서 삽입 성공을 같은 결과로 취급하면 안 된다.</li>
<li>대용량 workspace와 Preview 렌더링 비용은 기능 QA 이후 별도 성능 작업으로 다뤄야 한다.</li>
</ol>
<p>현재 Dock은 릴리스 완료가 아니라, 실패 항목을 수정하고 회귀 검증을 다시 진행해야 하는 상태다. QA는 끝났지만 Exit Gate는 아직 열려 있다. 다음 목표는 기능을 더 추가하는 것이 아니라, 이번에 실제로 드러난 결함을 닫고 사용자가 실패 상황에서도 다음 행동을 알 수 있게 만드는 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (24) - 로컬 Markdown 편집기의 파일 변경 충돌과 Explorer UX를 다룬 방법]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-24-%EB%A1%9C%EC%BB%AC-Markdown-%ED%8E%B8%EC%A7%91%EA%B8%B0%EC%9D%98-%ED%8C%8C%EC%9D%BC-%EB%B3%80%EA%B2%BD-%EC%B6%A9%EB%8F%8C%EA%B3%BC-Explorer-UX%EB%A5%BC-%EB%8B%A4%EB%A3%AC-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-24-%EB%A1%9C%EC%BB%AC-Markdown-%ED%8E%B8%EC%A7%91%EA%B8%B0%EC%9D%98-%ED%8C%8C%EC%9D%BC-%EB%B3%80%EA%B2%BD-%EC%B6%A9%EB%8F%8C%EA%B3%BC-Explorer-UX%EB%A5%BC-%EB%8B%A4%EB%A3%AC-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Sun, 30 Aug 2026 14:46:20 GMT</pubDate>
            <description><![CDATA[<h2 id="파일-목록을-새로고침하는-것만으로는-부족">파일 목록을 새로고침하는 것만으로는 부족</h2>
<p>Dock은 사용자가 선택한 로컬 document workspace에서 Markdown을 열고 편집하는 애플리케이션이다. 처음에는 파일 목록을 다시 조회하면 실제 디렉터리와 화면이 어느 정도 맞을 것이라고 생각했다. 하지만 편집기가 실제 파일을 열고 있는 동안에는 단순한 목록 갱신만으로 해결되지 않는 상황이 생긴다.</p>
<ul>
<li>Dock 밖에서 파일 내용이 바뀐다.</li>
<li>편집 중인 파일이 운영체제 파일 탐색기에서 삭제되거나 이름이 바뀐다.</li>
<li>사용자가 편집하는 동안 Explorer의 CRUD 작업이 실행된다.</li>
<li>파일과 폴더가 많아지면서 평면 목록의 위치 구분이 어려워진다.</li>
</ul>
<p>특히 미저장 내용이 있는 문서를 외부 변경 이벤트만 보고 자동으로 다시 읽으면 사용자의 작업이 사라질 수 있다. 이번 작업은 이 문제를 “목록을 최신 상태로 보이게 하는 일”과 “편집 중인 내용을 안전하게 보존하는 일”로 나누어 다뤘다.</p>
<h2 id="이번에-정한-사용자-흐름">이번에 정한 사용자 흐름</h2>
<p>파일 시스템 이벤트는 화면에 변경 내용을 직접 전달하는 데이터가 아니라 재조회 신호로 사용한다. Renderer는 이벤트에 포함된 경로를 믿고 화면을 부분 수정하지 않고, Main을 통해 최신 파일 목록과 문서 내용을 다시 조회한다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/036d3817-ae0b-46cf-84e0-636b46b5fd07/image.png" alt=""></p>
<p>여기서 중요한 것은 미저장 내용을 자동으로 덮어쓰지 않는다는 규칙이다. 외부 변경이 감지된 문서는 현재 편집 내용을 그대로 두고 다음 선택지를 보여 준다.</p>
<ul>
<li>외부 변경 불러오기: 외부 파일 내용을 편집기에 반영한다.</li>
<li>내 작업으로 저장: 현재 편집 내용을 외부 변경 위에 저장한다.</li>
<li>문서 닫기: 충돌 중인 문서를 닫는다.</li>
</ul>
<p>파일 자체가 사라진 경우에는 “내 작업으로 저장”을 선택할 수 있도록 원래 상대 경로에 파일을 다시 만들고 저장한다. 이 선택은 자동 복구가 아니라 사용자가 의도를 명확히 선택한 뒤 실행되는 동작이다.</p>
<h2 id="충돌을-revision과-편집-상태로-판단">충돌을 revision과 편집 상태로 판단</h2>
<p>문서에는 내용의 SHA-256 기반 revision을 함께 보관한다. 저장할 때 읽어 둔 revision을 <code>expectedRevision</code>으로 전달해 Main에서 외부 변경 여부를 확인하고, watcher가 자신의 저장 이벤트를 다시 전달했을 때는 방금 저장한 revision을 구분한다.</p>
<pre><code class="language-ts">const result = await window.dock.document.write({
  workspaceId,
  relativePath: selectedPath,
  content,
  ...(!force &amp;&amp; documentRevision
    ? { expectedRevision: documentRevision }
    : {}),
});</code></pre>
<p>외부 변경 이벤트가 도착하면 다음 순서로 처리한다.</p>
<ol>
<li>최신 Markdown 목록과 Explorer entry를 다시 조회한다.</li>
<li>선택한 문서가 목록에서 사라졌는지 확인한다.</li>
<li>문서가 존재하면 최신 내용을 읽고 현재 revision과 비교한다.</li>
<li>현재 편집 내용이 저장 내용과 다르면 충돌 상태를 표시한다.</li>
<li>편집 중이 아니면 최신 외부 내용을 자동으로 반영한다.</li>
</ol>
<p>자신의 저장으로 발생한 watcher 이벤트는 저장 결과로 기록한 revision과 일치하는지 확인한다. 이 구분이 없으면 정상적인 저장 직후에도 외부 변경 경고가 나타날 수 있다.</p>
<h2 id="explorer를-vs-code식으로-다듬기">Explorer를 VS Code식으로 다듬기</h2>
<p>파일 탐색기는 평면 Markdown 목록을 대체하는 트리다. Main은 다음과 같은 최소 entry만 반환하고, 트리 계층과 접힘 상태는 Renderer가 관리한다.</p>
<pre><code class="language-ts">type WorkspaceEntry = {
  relativePath: string;
  displayName: string;
  kind: &quot;file&quot; | &quot;directory&quot;;
};</code></pre>
<p>이번 UI에서는 폴더 접힘·펼침 기호를 텍스트 <code>&gt;</code>와 <code>⌄</code>로 표시하지 않고 SVG chevron 아이콘으로 바꿨다. 파일과 폴더도 같은 간격·색상 규칙의 아이콘을 사용해 이름 앞의 장식이 제각각 보이지 않도록 했다.</p>
<p>이름 변경은 <code>prompt</code>를 다시 띄우는 방식 대신 Explorer 행 안에서 바로 입력한다. 더블클릭이나 기존 이름 변경 버튼으로 입력 상태에 들어가며, Enter는 저장, Escape는 취소로 연결한다. 컨텍스트 메뉴에서도 파일·폴더 생성, 이름 변경, 삭제를 같은 작업 함수로 실행한다. UI 진입점만 달라지고 최종 검증은 기존 Main IPC와 파일 서비스가 담당한다.</p>
<pre><code class="language-tsx">&lt;input
  value={editingName}
  aria-label={`${entry.displayName} 이름 변경 입력`}
  onKeyDown={(event) =&gt; {
    if (event.key === &#39;Enter&#39;) void commitRename();
    if (event.key === &#39;Escape&#39;) cancelRename();
  }}
/&gt;</code></pre>
<p>이 방식의 장점은 이름 변경 실패 시에도 입력값을 잃지 않고 재시도할 수 있다는 점이다. 중복 이름, 잘못된 이름, 권한 오류는 Main에서 오류로 반환하고 Explorer는 입력 상태를 유지한다.</p>
<h2 id="권한은-계속-main에">권한은 계속 Main에</h2>
<p>Explorer가 파일과 폴더를 관리한다고 해서 Renderer에 <code>fs</code> 권한을 주지는 않았다. Renderer는 다음처럼 사용자의 의도만 표현하는 좁은 API를 호출한다.</p>
<pre><code class="language-ts">window.dock.workspace.renameEntry({
  workspaceId,
  relativePath,
  newName,
});</code></pre>
<p>Main은 workspace ID를 실제 등록 경로로 해석하고, 상대 경로·파일명·symbolic link 경계를 다시 검증한다. Renderer가 전달한 절대 경로나 <code>..</code> 경로를 그대로 사용하지 않으며, 숨김 이름과 경로 구분자가 포함된 새 이름도 거부한다. 감시 이벤트에도 실제 경로와 파일 내용을 넣지 않고 workspace ID만 전달한다.</p>
<p>이 경계를 유지하면 나중에 Explorer 디자인을 바꾸거나 Activity Bar에 다른 파일 관련 패널을 추가해도 파일 시스템 권한 정책은 흔들리지 않는다.</p>
<h2 id="테스트는-실제-흐름을-기준으로-추가">테스트는 실제 흐름을 기준으로 추가</h2>
<p>이번 변경에서는 단순히 React 요소가 렌더링되는지만 보지 않고, 외부 파일 조작과 사용자 선택까지 Electron에서 검증했다.</p>
<ul>
<li>Explorer 컴포넌트에서 SVG 아이콘, 폴더 접기·펼치기, Markdown 파일 열기 확인</li>
<li>더블클릭 후 inline 이름 변경과 Enter 저장 확인</li>
<li>컨텍스트 메뉴에서 이름 변경 진입 확인</li>
<li>외부 파일 생성·이름 변경·삭제 후 Explorer 자동 갱신 확인</li>
<li>외부 폴더와 중첩 Markdown 파일 생성·삭제 후 트리 갱신 확인</li>
<li>미저장 문서 외부 변경 후 편집 내용 보존 확인</li>
<li>외부 변경 불러오기 후 최신 내용 표시 확인</li>
<li>내 작업으로 저장 후 실제 파일 내용 확인</li>
<li>미저장 문서 삭제 후 원래 경로 재생성 및 저장 확인</li>
</ul>
<p>검증 결과는 다음과 같다.</p>
<ul>
<li><code>npm run check</code>: 32개 test file, 148개 테스트 통과</li>
<li><code>npm run test:e2e --workspace=@jarvis-dock/desktop</code>: 19/19 통과</li>
<li>Electron package: 통과</li>
</ul>
<p>실제 운영체제 파일 탐색기에서 변경을 만드는 과정은 native <code>fs.watch</code>의 플랫폼별 동작을 확인하는 수동 검증으로 남겼다. 자동 테스트는 deterministic한 임시 디렉터리를 사용하고, Windows·macOS·Linux의 이벤트 순서 차이를 같은 결과라고 가정하지 않는다.</p>
<h2 id="남은-범위">남은 범위</h2>
<p>현재 Explorer는 문서 작업에 필요한 기본 파일·폴더 흐름을 제공한다. 다음과 같은 기능은 제품 범위를 넓히는 별도 작업으로 남긴다.</p>
<ul>
<li>파일 전체 형식 미리보기와 편집</li>
<li>workspace 전체 검색 패널</li>
<li>Git 변경사항 패널</li>
<li>확장 기능과 AI 작업 패널</li>
<li>diff 기반의 세밀한 충돌 병합</li>
</ul>
<p>이번 작업의 핵심은 기능을 많이 넣는 것이 아니라, 로컬 문서 편집 중 파일이 바뀌어도 사용자의 작업을 잃지 않게 하고, 익숙한 Explorer 조작을 안전한 IPC 경계 안에 넣는 것이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (23) - 로컬 Markdown 편집기에 Explorer와 실시간 파일 동기화 추가]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-23-%EB%A1%9C%EC%BB%AC-Markdown-%ED%8E%B8%EC%A7%91%EA%B8%B0%EC%97%90-VS-Code%EC%8B%9D-Explorer%EC%99%80-%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%8C%8C%EC%9D%BC-%EB%8F%99%EA%B8%B0%ED%99%94%EB%A5%BC-%EB%B6%99%EC%9D%B8-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-23-%EB%A1%9C%EC%BB%AC-Markdown-%ED%8E%B8%EC%A7%91%EA%B8%B0%EC%97%90-VS-Code%EC%8B%9D-Explorer%EC%99%80-%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%8C%8C%EC%9D%BC-%EB%8F%99%EA%B8%B0%ED%99%94%EB%A5%BC-%EB%B6%99%EC%9D%B8-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Sun, 30 Aug 2026 14:40:28 GMT</pubDate>
            <description><![CDATA[<h2 id="문제의-재정의">문제의 재정의</h2>
<p>Dock에서 폴더를 선택하면 Markdown 파일 목록을 보여주고 문서를 편집할 수 있었다. 하지만 실제 디렉터리와 앱 화면은 계속 같은 상태를 유지하지 못했다. Dock 밖에서 파일을 만들거나 삭제해도 앱에 바로 반영되지 않았고, 폴더가 여러 단계로 나뉜 프로젝트에서는 평면 목록만으로 파일 위치를 파악하기 어려웠다.</p>
<p>사용자 입장에서는 이미 익숙한 기준이 있었다. VS Code에서 Explorer를 열어 폴더를 접고 펼치고, 파일을 만들거나 이름을 바꾸고, 운영체제 파일 탐색기에서 일어난 변경을 다시 확인하는 흐름이다. 그래서 이번 작업의 목표를 단순한 “파일 목록 새로고침”이 아니라 다음 세 가지로 정리했다.</p>
<ul>
<li>실제 document workspace와 Dock의 Explorer 상태를 빠르게 일치시킨다.</li>
<li>파일과 폴더를 계층 구조로 탐색하고 기본적인 CRUD를 수행한다.</li>
<li>Renderer 권한을 넓히지 않으면서 미저장 문서를 보호한다.</li>
</ul>
<h2 id="구조를-선택한-이유">구조를 선택한 이유</h2>
<p>Electron 앱에서 가장 먼저 지켜야 할 경계는 파일 시스템 권한이다. Renderer가 <code>fs</code>를 직접 호출하면 UI 코드가 곧바로 로컬 파일 시스템 권한을 갖게 된다. 따라서 파일을 읽고 쓰는 기존 원칙과 마찬가지로 이번에도 Main이 파일 시스템의 유일한 권위가 되도록 구성했다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/8e42db94-3f25-461f-aa74-26ecaf4aab2f/image.png" alt=""></p>
<p>Main은 선택된 폴더를 기준으로 디렉터리별 native <code>fs.watch</code>를 등록한다. 운영체제 이벤트는 짧은 시간에 여러 번 들어올 수 있으므로 바로 화면을 수정하지 않고 debounce한 뒤 <code>workspace:changed</code>를 보낸다. 이 이벤트에는 변경된 실제 경로나 파일 내용이 들어가지 않는다. Renderer가 그 값을 신뢰해서 화면을 고치는 대신, 이벤트를 “다시 조회해야 한다”는 신호로만 사용하도록 했다.</p>
<pre><code class="language-ts">watcher = new WorkspaceWatcher(rootPath, () =&gt; {
  onChanged({ workspaceId });
});</code></pre>
<p>이렇게 하면 파일 감시가 제공하는 정보가 제한되고, 실제 최신 목록은 언제나 Main의 <code>workspace:list-entries</code> 결과에서 나온다. 폴더 선택을 바꾸거나 앱을 닫을 때 watcher를 정리하는 것도 Main이 담당한다.</p>
<h2 id="평면-목록에서-트리로-바꾸기">평면 목록에서 트리로 바꾸기</h2>
<p>기존 Markdown 목록 API를 그대로 트리 데이터로 사용하지 않았다. Markdown 파일을 열기 위한 목록과 Explorer를 그리기 위한 entry 목록은 목적이 다르기 때문이다.</p>
<p><code>workspace:list-entries</code>는 파일과 디렉터리의 상대 경로, 종류처럼 트리 구성에 필요한 최소 정보만 반환한다. Renderer는 이 결과를 경로 segment별로 묶어 폴더 아래에 자식 항목을 만들고, 접힘·펼침과 선택 상태를 보유한다. 파일 시스템 경로 전체를 UI 상태로 복제하거나, Main의 절대 경로를 화면에 노출하지 않는다.</p>
<p>Explorer에서는 Markdown 파일만 Editor로 열고 새 문서로 생성한다. 다른 파일은 폴더 구조를 이해하는 데 도움이 되도록 보일 수 있지만, Dock의 Markdown 편집 대상으로 취급하지 않는다. 숨김 항목과 symbolic link를 목록에서 제외한 것도 탐색 범위와 경계 우회를 단순하게 유지하기 위한 결정이다.</p>
<h2 id="crud를-ipc로-좁히기">CRUD를 IPC로 좁히기</h2>
<p>파일과 폴더를 조작하는 요청은 범용 <code>delete(path)</code>나 <code>rename(from, to)</code>로 공개하지 않았다. 요청 하나가 사용자의 의도 하나만 표현하도록 workspace ID와 상대 경로, 새 이름을 조합하는 형태로 제한했다.</p>
<pre><code class="language-ts">type WorkspaceEntry = {
  relativePath: string;
  kind: &quot;file&quot; | &quot;directory&quot;;
};

type WorkspaceChanged = {
  workspaceId: string;
};</code></pre>
<p>Main에서는 workspace root 자체를 이름 변경하거나 삭제하지 못하게 하고, <code>..</code>, absolute path, 경로 구분자가 포함된 이름, 숨김 이름, 잘못된 파일명을 거부한다. 중복 대상도 파일 시스템에 맡겨 우연히 처리하지 않고 안정적인 오류로 돌려준다. 삭제는 UI에서 확인한 뒤 요청하지만, 최종 경로 검증은 Main에서 다시 수행한다.</p>
<p>이 구조의 장점은 UI가 바뀌어도 보안 기준이 흔들리지 않는다는 점이다. 나중에 컨텍스트 메뉴나 inline rename을 추가하더라도 동일한 service와 IPC 계약을 통과해야 한다.</p>
<h2 id="외부-변경과-미저장-문서">외부 변경과 미저장 문서</h2>
<p>가장 조심해야 했던 상황은 사용자가 문서를 편집하는 중에 운영체제 파일 탐색기에서 해당 파일을 삭제하거나 이름을 바꾸는 경우였다. 외부 변경을 감지했다는 이유로 Editor 내용을 자동으로 다시 읽으면 사용자의 미저장 작업이 사라질 수 있다.</p>
<p>그래서 현재 정책은 두 경우를 나눈다.</p>
<ul>
<li>저장된 선택 문서가 외부에서 사라지거나 이름이 바뀌면 더 이상 열 수 없으므로 선택을 닫는다.</li>
<li>미저장 선택 문서는 현재 Editor 내용을 유지하고 경고를 표시한다.</li>
</ul>
<p>자동 reload나 자동 병합은 이번 범위에 넣지 않았다. 충돌이 발생했을 때 어느 내용을 기준으로 할지 결정하려면 diff, 복구, 사용자 선택 UI까지 별도 설계가 필요하기 때문이다.</p>
<h2 id="의존성을-늘리지-않은-이유">의존성을 늘리지 않은 이유</h2>
<p>파일 감시를 위해 <code>chokidar</code> 같은 라이브러리를 추가하는 선택지도 있었다. 하지만 현재 Dock은 외부 구독과 dependency를 최대한 줄이는 방향을 유지하고 있다. 이번 범위에서는 운영체제가 제공하는 <code>fs.watch</code>와 기존 파일 시스템 서비스만으로 요구사항을 충족할 수 있어 새 watcher dependency를 추가하지 않았다.</p>
<p>그 대신 플랫폼별 이벤트 차이와 debounce 동작은 테스트와 수동 검증에서 확인해야 한다. native API를 사용했다고 해서 모든 운영체제에서 동일한 이벤트 순서가 보장되는 것은 아니기 때문이다.</p>
<h2 id="검증-결과와-남은-확인">검증 결과와 남은 확인</h2>
<p>코드와 테스트 기준으로는 다음 검증을 통과했다.</p>
<ul>
<li><code>npm run check</code>: 32개 test file, 147개 테스트 통과</li>
<li><code>npm run test:e2e --workspace=@jarvis-dock/desktop</code>: 16/16 통과</li>
<li>Electron package 통과</li>
<li>watcher service, IPC contract, Explorer component 흐름 검증</li>
</ul>
<p>다만 외부 운영체제 파일 탐색기에서 실제로 파일·폴더를 추가·이름 변경·삭제하는 흐름은 별도 수동 검증으로 남겼다. <code>C:\Users\user\Downloads\Dock</code>를 document workspace로 선택한 뒤, Dock 안팎에서 변경을 만들고 Explorer가 자동으로 수렴하는지 확인해야 한다. 특히 미저장 문서에서 경고와 내용 보존이 기대대로 동작하는지 확인하는 것이 중요하다.</p>
<p>이번 작업으로 Dock의 문서 탐색은 단순한 목록에서 실제 프로젝트 구조를 다루는 Explorer에 가까워졌다. 다음 개선 후보는 inline rename과 context menu지만, 먼저 현재 watcher와 미저장 충돌 정책을 실제 환경에서 확인한 뒤 범위를 넓히는 것이 순서다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (22) - 로컬 Markdown 편집기의 문서 탐색과 연결 관계를 정리한 과정]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-22-%EB%A1%9C%EC%BB%AC-Markdown-%ED%8E%B8%EC%A7%91%EA%B8%B0%EC%9D%98-%EB%AC%B8%EC%84%9C-%ED%83%90%EC%83%89%EA%B3%BC-%EC%97%B0%EA%B2%B0-%EA%B4%80%EA%B3%84%EB%A5%BC-%EC%A0%95%EB%A6%AC%ED%95%9C-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-22-%EB%A1%9C%EC%BB%AC-Markdown-%ED%8E%B8%EC%A7%91%EA%B8%B0%EC%9D%98-%EB%AC%B8%EC%84%9C-%ED%83%90%EC%83%89%EA%B3%BC-%EC%97%B0%EA%B2%B0-%EA%B4%80%EA%B3%84%EB%A5%BC-%EC%A0%95%EB%A6%AC%ED%95%9C-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Sun, 30 Aug 2026 11:41:37 GMT</pubDate>
            <description><![CDATA[<p>처음 만든 로컬 Markdown 편집기는 폴더를 선택하고 파일을 열어 내용을 수정하는 데 집중했다. 문서를 실제로 작성하기 시작하면 곧 다른 문제가 보인다. 파일이 많아질수록 원하는 문서를 찾기 어렵고, 문서 사이의 링크를 따라가려면 파일을 직접 열어 봐야 한다. 외부 프로그램에서 파일이 바뀌었을 때는 내가 편집 중인 내용을 덮어쓸 위험도 있다.</p>
<p>이번에는 기능을 하나씩 추가하는 것보다 document workspace 안에서 문서를 찾고, 열고, 연결 관계를 확인하고, 안전하게 저장하는 흐름을 정리했다. 여기서 document workspace는 사용자가 Dock에서 선택한 로컬 문서 폴더를 뜻한다. npm workspace와 혼동하지 않기 위해 이 글에서도 구분해서 사용한다.</p>
<p>이번 작업에서 다룬 범위는 다음과 같다.</p>
<ul>
<li>workspace 전체 Markdown 검색과 검색 결과 위치로 이동</li>
<li>문서 생성 템플릿</li>
<li>여러 문서를 열어 두기 위한 문서 탭</li>
<li>외부 변경을 감지하는 저장 충돌 보호</li>
<li>현재 문서가 연결한 문서와 현재 문서를 참조하는 문서 표시</li>
<li>문서 열기 실패를 사용자가 이해할 수 있는 상태로 표시</li>
</ul>
<p>AI나 원격 문서 저장소를 추가하지 않았다. 먼저 로컬 파일을 다루는 기본 흐름이 예측 가능하게 동작해야 이후 기능도 같은 경계 안에 넣을 수 있기 때문이다.</p>
<h2 id="파일을-열-수-있다는-것만으로는-부족했다">파일을 열 수 있다는 것만으로는 부족했다</h2>
<p>기존 파일 목록은 Markdown 파일을 보여 주고, 파일을 선택하면 Editor에서 내용을 편집할 수 있게 했다. 작은 폴더에서는 충분하지만 프로젝트 문서가 늘어나면 다음과 같은 불편이 생긴다.</p>
<ol>
<li>파일명이 기억나지 않으면 목록을 위아래로 훑어야 한다.</li>
<li>검색 결과가 문서의 어느 줄에 있는지 알기 어렵다.</li>
<li><code>arc42.md</code>에서 <code>c4-context.md</code>로 이동하려면 링크를 직접 찾아야 한다.</li>
<li>문서를 여러 개 비교하려면 한 파일을 닫고 다른 파일을 열어야 한다.</li>
<li>Editor에 열어 둔 뒤 외부에서 파일이 바뀌면 마지막 저장이 외부 변경을 덮어쓸 수 있다.</li>
</ol>
<p>문서 편집기의 핵심은 textarea 하나가 아니라 문서를 찾고 문맥을 유지하는 작업 흐름이라는 결론을 내렸다.</p>
<h2 id="workspace-검색은-파일명-검색과-본문-검색을-나눴다">workspace 검색은 파일명 검색과 본문 검색을 나눴다</h2>
<p>Activity Bar의 workspace 검색은 두 종류의 검색을 제공한다.</p>
<ul>
<li>파일명 검색: <code>docs/architecture</code>처럼 상대 경로를 기준으로 Markdown 파일 목록을 좁힌다.</li>
<li>본문 검색: 문서 내용을 줄 단위로 검색하고, 일치한 문서·줄 번호·문장 일부를 반환한다.</li>
</ul>
<p>파일명 검색은 목록을 빠르게 줄이는 용도이고, 본문 검색은 기억해 둔 문장이나 키워드가 어느 문서에 있는지 찾는 용도다. 두 결과를 같은 형태로 처리하지 않고 각각의 목적에 맞는 결과를 유지했다.</p>
<p>본문 검색은 Renderer가 이미 읽어 온 Markdown 문서 목록을 대상으로 수행한다.</p>
<pre><code class="language-ts">export const searchMarkdownDocuments = (
  documents: readonly WorkspaceSearchDocument[],
  query: string,
): WorkspaceSearchResult[] =&gt; {
  const normalizedQuery = query.trim().toLocaleLowerCase();
  if (!normalizedQuery) return [];

  const results: WorkspaceSearchResult[] = [];
  for (const document of documents) {
    const lines = document.content.split(/\r?\n/);
    lines.forEach((line, index) =&gt; {
      if (!line.toLocaleLowerCase().includes(normalizedQuery)) return;
      results.push({
        relativePath: document.relativePath,
        line: index + 1,
        snippet: line.trim() || &#39;(빈 줄)&#39;,
      });
    });
    if (results.length &gt;= 100) break;
  }
  return results.slice(0, 100);
};</code></pre>
<p>검색 결과를 클릭하면 해당 문서를 열고, Editor의 커서를 일치한 줄로 이동한다. 검색 결과에 파일 경로만 표시하지 않고 줄 번호와 snippet을 함께 표시한 이유는 결과를 다시 확인하기 위해 문서를 열어야 하는 횟수를 줄이기 위해서다.</p>
<p>이 구현은 현재 workspace에 이미 로드된 문서를 검색한다. 검색할 때마다 디스크를 무제한으로 읽거나 별도 색인을 만들지 않는다. 단일 사용자의 로컬 Markdown 편집기라는 현재 범위에서는 이 방식이 구조와 동작을 단순하게 유지한다. 문서 수가 커져 성능 문제가 확인되면 그때 색인이나 증분 검색을 검토할 수 있다.</p>
<h2 id="문서-생성은-빈-파일보다-목적이-중요했다">문서 생성은 빈 파일보다 목적이 중요했다</h2>
<p>새 문서를 만들 때 파일명만 입력하게 하면 사용자는 빈 textarea 앞에서 다시 무엇을 써야 할지 결정해야 한다. 그래서 문서 생성 시 용도에 맞는 Markdown 템플릿을 선택할 수 있게 했다.</p>
<p>템플릿은 다음과 같은 최소 구조를 제공한다.</p>
<ul>
<li>일반 노트: 제목과 기록 영역</li>
<li>기술 문서: 문제, 결정, 구현, 검증 영역</li>
<li>회의 기록: 일시, 참석자, 안건, 결정 사항</li>
<li>체크리스트: 완료 여부를 표시할 항목</li>
</ul>
<p>템플릿은 완성된 문서를 대신 작성하지 않는다. 처음부터 형식을 강제하는 대신, 사용자가 바로 내용을 채울 수 있는 시작점을 제공한다.</p>
<p>다만 문서 생성은 기존 파일을 덮어쓰면 안 된다. 생성 요청은 새 파일 전용 저장 경계를 사용하고, 이미 같은 경로가 존재하면 실패한다. 문서 생성과 기존 문서 저장을 같은 동작으로 처리하지 않은 이유는 “새로 만들기”와 “수정해서 저장하기”의 위험이 다르기 때문이다.</p>
<h2 id="문서-탭은-editor-상태를-보존하기-위한-장치다">문서 탭은 Editor 상태를 보존하기 위한 장치다</h2>
<p>파일 목록에서 문서를 열 때마다 현재 문서를 교체하면 비교 작업이 끊긴다. 그래서 열어 본 문서를 문서 탭으로 유지하고, 탭을 선택하면 해당 문서의 내용과 저장 상태를 복원하도록 했다.</p>
<p>탭에서 관리하는 값은 대략 다음과 같다.</p>
<pre><code class="language-text">문서 탭
  ├─ relativePath
  ├─ content
  ├─ 원본 revision
  └─ 수정 여부</code></pre>
<p>문서가 수정된 상태에서 다른 탭을 선택하거나 현재 탭을 닫으려고 하면 먼저 저장 여부를 확인한다. 이 확인이 없으면 링크를 따라 다른 문서를 열거나 검색 결과를 클릭하는 순간 편집 중인 내용이 사라질 수 있다.</p>
<p>빈 문서도 같은 원칙을 따른다. 파일이 아직 선택되지 않은 상태라도 Editor가 편집 가능한 초안이면 링크 삽입과 이미지 삽입을 허용한다. “Markdown 문서를 선택해야만 편집할 수 있다”는 식으로 상태를 단순화하면 새 문서를 작성하는 정상적인 흐름까지 막히기 때문이다.</p>
<h2 id="저장-전에-revision을-비교했다">저장 전에 revision을 비교했다</h2>
<p>탭에 문서 내용을 보관하는 것만으로는 외부 변경을 보호할 수 없다. 예를 들어 Dock에서 <code>arc42.md</code>를 열어 수정하는 동안 VS Code나 메모장에서 같은 파일을 바꿀 수 있다. 이때 Dock이 아무 확인 없이 저장하면 외부에서 추가한 내용이 사라진다.</p>
<p>문서를 읽을 때 현재 내용의 SHA-256 해시를 <code>revision</code>으로 함께 반환하고, 저장할 때 Editor가 마지막으로 읽은 revision을 <code>expectedRevision</code>으로 보낸다.</p>
<pre><code class="language-ts">export const getDocumentRevision = (content: string): string =&gt;
  createHash(&#39;sha256&#39;).update(content, &#39;utf8&#39;).digest(&#39;hex&#39;);

export const readDocument = async (
  absolutePath: string,
  relativePath: string,
): Promise&lt;DocumentData&gt; =&gt; {
  const content = await fs.readFile(absolutePath, &#39;utf8&#39;);
  return {
    relativePath,
    content,
    encoding: &#39;utf-8&#39;,
    revision: getDocumentRevision(content),
  };
};</code></pre>
<p>저장 시점에 디스크의 현재 내용으로 다시 revision을 계산하고 두 값이 다르면 저장하지 않는다.</p>
<pre><code class="language-text">문서 읽기
  → content + revision 반환
  → Editor에서 내용 수정
  → expectedRevision과 새 content로 저장 요청
  → 디스크의 현재 revision 재계산
      ├─ 같음: 원자적 저장
      └─ 다름: WRITE_CONFLICT 반환</code></pre>
<p>이 흐름은 파일 잠금이 아니다. 다른 프로그램이 파일을 바꾸는 것을 막지는 않는다. 대신 Dock이 오래된 내용을 기준으로 외부 변경을 덮어쓰는 상황을 차단한다. 충돌이 발생하면 Editor의 현재 초안을 지우지 않고 사용자에게 문서를 다시 읽은 뒤 저장하라는 안내를 보여 준다.</p>
<p>저장 파일은 임시 파일에 먼저 쓰고 sync한 뒤 교체한다. 저장 중 프로세스가 종료되거나 쓰기 오류가 발생했을 때 기존 파일을 바로 훼손할 가능성을 줄이기 위해서다. 파일 경로는 선택된 document workspace 내부의 Markdown 경로인지 Main에서 검증한다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/a05640c6-2d96-46d5-8510-ef70e0d47e12/image.png" alt=""></p>
<h2 id="연결-문서는-한-방향만-보여-주면-부족했다">연결 문서는 한 방향만 보여 주면 부족했다</h2>
<p>문서 연결 패널을 처음 만들 때는 현재 문서를 참조하는 문서, 즉 backlink만 찾았다. 예를 들어 <code>c4-context.md</code>가 <code>arc42.md</code>를 링크하면 <code>c4-context.md</code>를 <code>arc42.md</code>의 참조 문서로 보여 주는 방식이다.</p>
<p>그런데 <code>arc42.md</code>를 열고 보니 본문에서 <code>./c4-context.md</code>, <code>./c4-container.md</code> 같은 문서를 직접 연결하고 있었다. 이 문서들이 패널에 나타나지 않으니 사용자는 링크가 없는 것으로 오해하게 된다. 문제는 파서가 링크를 못 읽은 것이 아니라, 화면이 한 방향의 관계만 표현하고 있었던 것이다.</p>
<p>그래서 연결 문서를 두 그룹으로 나눴다.</p>
<pre><code class="language-text">연결 문서
  ├─ 이 문서가 연결한 문서       : outgoing links
  └─ 이 문서를 참조하는 문서     : backlinks</code></pre>
<p>정방향 링크는 현재 문서의 Markdown 링크를 읽고, workspace 안에 실제로 존재하는 <code>.md</code> 또는 <code>.markdown</code> 파일만 결과에 포함한다. 이미지 문법인 <code>![alt](...)</code>은 문서 연결로 취급하지 않는다. 외부 URL, 프로토콜이 있는 링크, 앵커 링크도 로컬 문서 연결 목록에서 제외한다.</p>
<p>상대 경로는 링크가 작성된 문서의 폴더를 기준으로 해석한다. 따라서 다음 두 링크는 서로 다른 위치를 가리킨다.</p>
<pre><code class="language-md">[Context](./c4-context.md)
[ADR](../adr/0001-initial-architecture.md)</code></pre>
<p>링크를 해석할 때 <code>.</code>과 <code>..</code>을 정규화하지만 workspace 밖으로 나가는 경로는 허용하지 않는다. 링크의 의미를 보여 주기 위한 기능이라도 경로 검증을 생략하면 잘못된 파일을 열거나 외부 경로를 다루는 코드로 이어질 수 있기 때문이다.</p>
<p>정방향 링크를 추출하는 핵심 형태는 다음과 같다.</p>
<pre><code class="language-ts">export const findDocumentLinks = (
  document: BacklinkDocument,
  workspacePaths: ReadonlySet&lt;string&gt;,
): DocumentLinkResult[] =&gt; {
  const results: DocumentLinkResult[] = [];
  const lines = document.content.split(/\r?\n/);

  lines.forEach((line, index) =&gt; {
    for (const match of line.matchAll(markdownLinkPattern)) {
      if (match[1] === &#39;!&#39;) continue;
      const target = match[2] ?? match[3];
      const targetPath = target
        ? resolveDocumentLink(document.relativePath, target)
        : undefined;
      if (!targetPath || !workspacePaths.has(targetPath)) continue;
      results.push({
        relativePath: document.relativePath,
        targetPath,
        line: index + 1,
        snippet: line.trim() || &#39;(빈 줄)&#39;,
      });
    }
  });
  return results;
};</code></pre>
<p>패널의 항목을 클릭하면 해당 문서를 열고 링크가 있는 줄로 이동한다. 연결 관계를 단순한 파일 목록으로 끝내지 않고 위치 정보까지 함께 반환한 이유는 “어떤 문서와 연결되어 있는가”뿐 아니라 “본문의 어디에서 연결했는가”까지 확인할 수 있어야 하기 때문이다.</p>
<h2 id="링크-파싱에서-생긴-오류도-수정했다">링크 파싱에서 생긴 오류도 수정했다</h2>
<p>Markdown에서 이미지 문법은 링크와 비슷하게 생겼다.</p>
<pre><code class="language-md">![이미지 설명](./assets/example.png)
[문서 링크](./notes.md)</code></pre>
<p>앞의 <code>!</code> 하나가 두 문법을 구분한다. 이 구분을 무시하면 이미지가 문서 연결 목록에 나타나거나, 현재 페이지 링크를 삽입할 때 일반 링크가 이미지 문법으로 저장되는 문제가 생길 수 있다.</p>
<p>이번 정리에서는 문서 연결 분석에서 이미지 문법을 제외하고, 링크 삽입 동작도 링크와 이미지 삽입을 별도 경로로 유지했다. Preview가 링크를 렌더링하지 않는 것처럼 보이는 문제도 실제 Markdown 문자열에 <code>!</code>가 들어갔는지와 Preview 파서가 링크를 처리하지 못하는지를 나눠서 확인해야 한다. 화면에서 보이는 결과만 보고 Preview 문제로 단정하지 않는 것이 중요했다.</p>
<h2 id="열기-실패도-상태로-다뤄야-했다">열기 실패도 상태로 다뤄야 했다</h2>
<p>문서 선택은 다음 여러 이유로 실패할 수 있다.</p>
<ul>
<li>선택된 document workspace가 없음</li>
<li>요청한 Markdown 파일이 삭제됨</li>
<li>workspace 밖의 경로가 전달됨</li>
<li>권한 부족</li>
<li>파일 읽기 실패</li>
</ul>
<p>이전에는 이런 실패가 일반적인 “문서를 선택하세요” 안내로 뭉뚱그려지거나, 화면상 아무 변화가 없는 것처럼 보일 수 있었다. 이제는 Main이 오류 코드를 반환하고 Renderer가 사용자가 다음 행동을 알 수 있는 문장으로 표시한다.</p>
<p>Renderer는 파일 시스템 오류의 원인을 직접 해석하지 않는다. Main이 실제 경로와 파일 시스템을 다루고, Preload는 검증된 API만 노출하며, Renderer는 성공 결과나 오류 상태를 화면에 표현한다.</p>
<pre><code class="language-text">Renderer: 선택·검색·탭·Editor 상태
      │ 좁은 IPC
      ▼
Preload: 요청·응답 schema 검증
      │
      ▼
Main: workspace 경계·경로·파일 읽기·revision·저장
      │
      ▼
로컬 document workspace</code></pre>
<p>이 경계를 유지하면 문서 탐색 기능이 늘어나도 Renderer에 <code>fs</code>나 범용 Electron API를 노출하지 않아도 된다.</p>
<h2 id="테스트-함수보단-ux-위주로">테스트: 함수보단 UX 위주로</h2>
<p>이번 변경에서는 단위 테스트와 Renderer 테스트, 실제 Electron E2E를 함께 사용했다.</p>
<p>단위 테스트에서는 다음을 확인했다.</p>
<ul>
<li>대소문자를 구분하지 않는 workspace 검색</li>
<li>최대 결과 수 제한</li>
<li>상대 경로와 상위 경로를 포함한 Markdown 링크 해석</li>
<li>이미지 문법과 외부 링크 제외</li>
<li>정방향 링크와 역방향 backlink 추출</li>
<li>문서 revision 계산과 충돌 조건</li>
<li>템플릿 구조와 문서 탭 상태</li>
</ul>
<p>Renderer 테스트에서는 검색 결과를 클릭했을 때 문서와 줄 위치가 바뀌는지, 탭 전환과 닫기 전에 수정 내용을 보호하는지, 연결 문서 패널이 두 방향의 결과를 분리해 보여 주는지 확인했다.</p>
<p>실제 Electron E2E에서는 임시 document workspace를 만들고 다음 흐름을 검증했다.</p>
<pre><code class="language-text">폴더 선택
→ Markdown 문서 목록 확인
→ 검색어 입력
→ 검색 결과에서 문서와 줄 위치로 이동
→ 문서 탭 전환
→ 외부 변경을 만든 뒤 저장
→ 충돌 안내 확인
→ arc42에서 연결 문서 패널 확인</code></pre>
<p>현재 검증 결과는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>검증</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td><code>npm run check</code></td>
<td>29개 테스트 파일, 138개 테스트 통과</td>
</tr>
<tr>
<td>TypeScript typecheck</td>
<td>통과</td>
</tr>
<tr>
<td>ESLint·Prettier 검사</td>
<td>통과</td>
</tr>
<tr>
<td>연결 문서 단위 테스트</td>
<td>통과</td>
</tr>
<tr>
<td>문서 탐색·탭·충돌 Renderer 테스트</td>
<td>통과</td>
</tr>
<tr>
<td>Electron E2E</td>
<td>기존 전체 흐름과 연결 문서 흐름 검증</td>
</tr>
<tr>
<td>패키징</td>
<td>통과</td>
</tr>
</tbody></table>
<h2 id="이번-작업에서-일부러-하지-않은-것">이번 작업에서 일부러 하지 않은 것</h2>
<p>문서 연결을 구현했다고 해서 곧바로 Graph View나 데이터베이스를 추가하지 않았다. 현재 사용자와 데이터 범위에서는 Markdown 파일을 직접 읽어 관계를 계산하는 것만으로 충분하다.</p>
<p>다음 항목은 필요성이 확인될 때 별도로 검토할 대상이다.</p>
<ul>
<li>대규모 workspace를 위한 증분 검색 색인</li>
<li>링크 대상이 삭제되었을 때의 broken link 전용 화면</li>
<li>문서 연결 그래프 시각화</li>
<li>문서 변경 감시와 자동 reload</li>
<li>충돌 발생 시 diff 비교와 선택적 병합</li>
<li>문서 템플릿과 연결 규칙의 사용자 정의</li>
<li>사용자가 승인하는 AI 기반 문서 초안 제안</li>
</ul>
<p>특히 자동 reload와 자동 병합은 편의 기능처럼 보이지만, 사용자가 편집 중인 내용을 다시 바꿀 수 있다. 그래서 현재는 자동으로 조정하지 않고, 충돌을 알려 사용자가 판단하도록 했다.</p>
<h2 id="마무리">마무리</h2>
<p>이번 작업의 핵심은 기능을 많이 추가한 것이 아니라 로컬 Markdown 문서의 기본 흐름을 하나의 작업 공간으로 연결한 것이다.</p>
<p>문서를 찾고, 검색 결과의 위치로 이동하고, 여러 문서를 탭으로 열고, 연결 관계를 양방향으로 확인하고, 외부 변경을 감지한 뒤 안전하게 저장할 수 있게 됐다. 이 모든 기능은 document workspace라는 하나의 경계 안에서 동작하며, Renderer·Preload·Main의 책임도 분리되어 있다.</p>
<p>문서 도구는 파일을 열고 저장하는 순간보다 문서가 늘어난 이후의 맥락을 얼마나 잘 보존하는지가 중요하다. 이번 변경으로 Dock은 단순한 Markdown textarea에서 프로젝트 문서를 탐색하고 관계를 확인하는 작업 공간에 조금 더 가까워졌다.</p>
<p>다음 검토에서는 연결된 문서가 실제로 존재하는지, 문서의 제목과 링크가 일관적인지 같은 정합성 검사를 사용자의 편집 흐름 안에서 얼마나 자연스럽게 제공할 수 있는지 살펴볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (21) - 명령 팔레트에 머물던 Architecture Workspace를 Activity Bar로 옮기기]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-21-%EB%AA%85%EB%A0%B9-%ED%8C%94%EB%A0%88%ED%8A%B8%EC%97%90-%EB%A8%B8%EB%AC%BC%EB%8D%98-Architecture-Workspace%EB%A5%BC-Activity-Bar%EB%A1%9C-%EC%98%AE%EA%B8%B0%EA%B8%B0</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-21-%EB%AA%85%EB%A0%B9-%ED%8C%94%EB%A0%88%ED%8A%B8%EC%97%90-%EB%A8%B8%EB%AC%BC%EB%8D%98-Architecture-Workspace%EB%A5%BC-Activity-Bar%EB%A1%9C-%EC%98%AE%EA%B8%B0%EA%B8%B0</guid>
            <pubDate>Sun, 30 Aug 2026 08:24:51 GMT</pubDate>
            <description><![CDATA[<p>앞선 글에서는 Architecture Workspace에서 다음 ADR을 추가하는 흐름을 만들었다. 제목·상태·배경·결정·결과를 입력하면 Main이 다음 번호의 ADR 파일을 생성하고, README Index까지 갱신하는 기능이었다.</p>
<p>그런데 사용 흐름을 다시 보니 아키텍처 기능의 진입점이 명령 팔레트에만 있었다. 명령 팔레트는 일회성 명령을 실행하기에는 좋지만, 문서를 작성하는 동안 자주 확인해야 하는 영역의 기본 진입점으로는 부족했다.</p>
<p>Architecture Workspace는 프로젝트 문서가 쌓이는 작업 공간이다. 따라서 탐색기, 문서 개요, 이미지 자산처럼 Activity Bar에서 언제든 접근할 수 있어야 했다.</p>
<p>이번 작업의 목표는 다음과 같다.</p>
<ul>
<li>Activity Bar에 아키텍처 문서 진입점 추가</li>
<li>arc42·C4·ADR 문서의 빠른 열기</li>
<li>정합성 검사와 초기화 흐름의 패널 연결</li>
<li>생성된 ADR 파일의 동적 목록 표시</li>
<li>기존 명령 팔레트 흐름 유지</li>
<li>좁은 창과 키보드 탐색에서도 기존 Editor·Preview 레이아웃 보존</li>
</ul>
<h2 id="문제-기능은-있었지만-찾기-어려웠다">문제: 기능은 있었지만 찾기 어려웠다</h2>
<p>기존 구조는 다음과 같았다.</p>
<pre><code class="language-text">명령 팔레트
  ├─ Architecture Workspace 초기화
  └─ ADR 작성</code></pre>
<p>이 방식은 기능을 알고 있는 개발자에게는 충분하지만, 문서 작업 중에 현재 아키텍처 문서를 확인하려면 매번 명령 팔레트를 열어야 한다. 또 생성된 문서 목록은 일반 Explorer에 섞여 있어 arc42, C4, ADR이 어떤 역할을 하는지 한눈에 파악하기 어려웠다.</p>
<p>기능의 존재와 기능의 접근성은 다른 문제였다. 초기 수직 기능 검증 단계에서는 명령 팔레트만으로도 충분했지만, 다음 단계에서는 지속적으로 사용하는 작업 영역으로 승격할 필요가 있었다.</p>
<h2 id="activity-bar의-역할을-분명히-나눴다">Activity Bar의 역할을 분명히 나눴다</h2>
<p>현재 Dock의 Activity Bar는 다음 기능을 제공한다.</p>
<pre><code class="language-text">Activity Bar
  ├─ 탐색기
  ├─ 문서 개요
  ├─ 이미지 자산
  └─ 아키텍처 문서</code></pre>
<p>아키텍처 문서 버튼을 누르면 기존 Explorer와 같은 위치에 전용 패널이 열린다. Editor와 Preview를 덮는 별도 창이나 팝업을 추가하지 않았기 때문에, 기존 레이아웃과 Research View의 native 영역에도 영향을 주지 않는다.</p>
<p>패널에는 다음 작업을 배치했다.</p>
<ul>
<li>문서 세트 초기화</li>
<li>ADR 작성</li>
<li>정합성 점검</li>
<li>arc42 문서 열기</li>
<li>C4 Context·Container·Component 문서 열기</li>
<li>ADR Index와 개별 ADR 열기</li>
</ul>
<p>이 구조에서 명령 팔레트는 제거하지 않았다. Activity Bar는 탐색과 반복 작업을 위한 진입점이고, 명령 팔레트는 키보드 중심의 실행 경로다. 둘은 대체 관계가 아니라 서로 다른 사용 상황을 보완한다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/b4ddb097-16c3-4df1-8f33-c3a303f34f1e/image.png" alt=""></p>
<h2 id="고정-문서와-동적-adr을-분리했다">고정 문서와 동적 ADR을 분리했다</h2>
<p>arc42와 C4 문서는 초기 Architecture Workspace에서 정해진 파일명으로 생성된다. 따라서 다음 항목은 고정된 문서 목록으로 표시할 수 있다.</p>
<pre><code class="language-ts">const ARCHITECTURE_FILE_ITEMS = [
  { path: &#39;docs/architecture/arc42.md&#39;, label: &#39;arc42 아키텍처&#39; },
  { path: &#39;docs/architecture/c4-context.md&#39;, label: &#39;C4 Context&#39; },
  { path: &#39;docs/architecture/c4-container.md&#39;, label: &#39;C4 Container&#39; },
  { path: &#39;docs/architecture/c4-component.md&#39;, label: &#39;C4 Component&#39; },
  { path: &#39;docs/adr/README.md&#39;, label: &#39;ADR Index&#39; },
] as const;</code></pre>
<p>반면 ADR은 프로젝트가 진행될수록 계속 추가된다. 고정 목록에 첫 ADR만 넣으면 두 번째 이후의 결정은 다시 일반 파일 목록에서 찾아야 한다. 그래서 현재 files 상태에서 docs/adr 아래의 Markdown 파일을 찾아 패널 목록에 합친다.</p>
<pre><code class="language-ts">const architectureFileItems = [
  ...ARCHITECTURE_FILE_ITEMS,
  ...files
    .filter(
      (file) =&gt;
        file.relativePath.startsWith(&#39;docs/adr/&#39;) &amp;&amp;
        file.relativePath !== &#39;docs/adr/README.md&#39;,
    )
    .map((file) =&gt; ({
      path: file.relativePath,
      label: `ADR ${file.displayName.replace(/\.md$/i, &#39;&#39;)}`,
    })),
];</code></pre>
<p>이렇게 하면 초기화 직후에는 ADR 0001이 보이고, 이후 ADR 작성 흐름으로 0002와 0003이 생기면 문서 목록도 새로고침 결과에 맞춰 늘어난다. 파일을 읽기 위해 새로운 IPC를 추가하지 않고, 기존 document workspace 파일 목록과 문서 열기 API를 재사용했다.</p>
<h2 id="문서가-없을-때도-패널의-목적을-보여-준다">문서가 없을 때도 패널의 목적을 보여 준다</h2>
<p>Activity Bar 버튼은 document workspace가 없어도 동작한다. 다만 이 상태에서 빈 목록만 보여 주면 사용자는 기능이 고장 났다고 느낄 수 있다.</p>
<p>그래서 폴더를 선택하지 않은 경우에는 빈 상태 chip을 중앙에 표시하고, 문서 세트 초기화 버튼은 계속 제공한다. document workspace를 선택한 뒤에는 파일 목록이 나타나고, 아직 문서 세트를 만들지 않았다면 고정 문서 항목이 없음 상태로 표시된다.</p>
<p>이 차이는 다음 두 상황을 구분한다.</p>
<ul>
<li>폴더가 선택되지 않음</li>
<li>폴더는 선택됐지만 Architecture Workspace가 아직 초기화되지 않음</li>
</ul>
<p>사용자에게 필요한 다음 행동을 상태별로 보여 주는 것이 목적이다.</p>
<h2 id="정합성-검사를-패널에서-바로-실행한다">정합성 검사를 패널에서 바로 실행한다</h2>
<p>기존 정합성 검사는 Architecture Workspace 초기화 폼 안에 있었다. Activity Bar 패널에도 정합성 점검 버튼을 배치해 문서를 탐색하다가 바로 실행할 수 있도록 했다.</p>
<p>검사 결과는 패널 안에 유지된다.</p>
<ul>
<li>전체 문서 세트가 정상인지 여부</li>
<li>누락된 파일</li>
<li>필수 표식이 없는 파일</li>
<li>확인이 필요한 항목</li>
</ul>
<p>검사 로직 자체는 새로 만들지 않았다. 이미 Main에 있는 architecture:check-project IPC와 결과 schema를 재사용했다. Renderer는 결과를 받아 표시하고, 파일 시스템 접근은 계속 Main에 남아 있다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/3ef1121e-e6a6-4820-8ed5-073a44652cce/image.png" alt=""></p>
<h2 id="activity-bar에서-명령-팔레트로-이어지는-흐름">Activity Bar에서 명령 팔레트로 이어지는 흐름</h2>
<p>초기화와 ADR 작성은 패널 안에 폼을 복제하지 않았다. 패널의 버튼이 명령 팔레트를 열고 해당 명령을 바로 선택하는 방식이다.</p>
<p>이 방식에는 세 가지 장점이 있다.</p>
<p>첫째, 입력 폼의 validation과 오류 표시가 한 곳에 유지된다. 둘째, Activity Bar와 명령 팔레트에서 서로 다른 동작이 생기는 것을 막을 수 있다. 셋째, 키보드 사용자는 기존 명령 팔레트 단축 흐름을 계속 사용할 수 있다.</p>
<pre><code class="language-text">아키텍처 문서 패널
  → 문서 세트 초기화
  → 명령 팔레트의 Architecture Workspace 폼

아키텍처 문서 패널
  → ADR 작성
  → 명령 팔레트의 ADR 폼</code></pre>
<h2 id="테스트로-실제-접근-경로를-확인했다">테스트로 실제 접근 경로를 확인했다</h2>
<p>Renderer component test에는 Activity Bar 버튼을 눌렀을 때 아키텍처 문서 패널이 열리는지 추가했다. 기존 focus order에도 아키텍처 버튼을 포함해 키보드 순서가 깨지지 않는지 확인했다.</p>
<p>Electron E2E에서는 Architecture Workspace를 초기화한 뒤 다음 흐름을 실제 패키지 앱에서 확인했다.</p>
<pre><code class="language-text">문서 폴더 선택
→ Architecture Workspace 초기화
→ 정합성 점검
→ 명령 팔레트 닫기
→ Activity Bar 아키텍처 문서 열기
→ arc42 문서 열기
→ ADR 0001 목록 확인</code></pre>
<p>최종 검증 결과는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>검증</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>npm run check</td>
<td>102 tests 통과</td>
</tr>
<tr>
<td>Architecture Workspace E2E</td>
<td>통과</td>
</tr>
<tr>
<td>전체 Electron E2E</td>
<td>14 scenarios 통과</td>
</tr>
<tr>
<td>npm run package</td>
<td>통과</td>
</tr>
</tbody></table>
<h2 id="이번-작업으로-고정된-구조">이번 작업으로 고정된 구조</h2>
<p>이번 변경으로 Architecture Workspace의 진입점은 다음처럼 정리됐다.</p>
<ul>
<li>반복적인 문서 탐색: Activity Bar</li>
<li>빠른 명령 실행: 명령 팔레트</li>
<li>문서 내용 편집: 기존 Editor</li>
<li>문서 미리보기: 기존 Preview</li>
<li>파일 시스템과 정합성 검사: Main</li>
<li>Renderer와 Main 사이의 데이터 전달: 좁은 IPC</li>
</ul>
<p>새로운 전역 상태 관리나 별도 저장소는 추가하지 않았다. 현재 files 상태를 기준으로 패널을 구성하고, 기존 문서 열기·정합성 검사·명령 팔레트 흐름을 조합했다.</p>
<h2 id="처음-보는-사람도-기능의-목적을-알-수-있어야-했다">처음 보는 사람도 기능의 목적을 알 수 있어야 했다</h2>
<p>Activity Bar에 진입점을 추가한 뒤 직접 사용해 보니, 기능을 찾는 문제와 기능을 이해하는 문제는 별개였다. 버튼이 보인다고 해서 사용자가 <code>arc42</code>, <code>C4</code>, <code>ADR</code>, <code>Architecture Workspace</code>가 무엇인지 바로 알 수 있는 것은 아니었다.</p>
<p>처음 화면에 기술 용어만 노출되면 사용자는 다음과 같은 질문부터 하게 된다.</p>
<ul>
<li>이 기능은 문서 편집 기능인가, 설계 도구인가?</li>
<li>프로젝트 정보를 왜 입력해야 하는가?</li>
<li>생성되는 파일들은 각각 어디에 쓰는가?</li>
<li><code>C4</code>와 <code>ADR</code>을 모르면 사용을 시작할 수 없는가?</li>
</ul>
<p>그래서 용어를 숨기기보다, 사용자가 이해할 수 있는 표현을 앞에 두고 기술 용어를 보조 정보로 배치했다.</p>
<table>
<thead>
<tr>
<th>기존에 보이던 표현</th>
<th>화면에 먼저 보여 주는 표현</th>
<th>사용자가 이해할 수 있는 의미</th>
</tr>
</thead>
<tbody><tr>
<td>Architecture Workspace</td>
<td>프로젝트 설계 문서</td>
<td>프로젝트 구조와 기술 결정을 문서로 정리하는 영역</td>
</tr>
<tr>
<td>arc42</td>
<td>전체 아키텍처</td>
<td>프로젝트의 목표와 전체 구조</td>
</tr>
<tr>
<td>C4 Context</td>
<td>시스템과 외부 관계</td>
<td>사용자와 외부 시스템의 연결</td>
</tr>
<tr>
<td>C4 Container</td>
<td>주요 애플리케이션 구성</td>
<td>시스템을 구성하는 큰 단위</td>
</tr>
<tr>
<td>C4 Component</td>
<td>구성 내부의 책임</td>
<td>각 단위가 맡은 역할</td>
</tr>
<tr>
<td>ADR</td>
<td>기술 결정 목록</td>
<td>왜 그렇게 만들었는지 남기는 기록</td>
</tr>
</tbody></table>
<p>기술 용어를 완전히 제거하지 않은 이유도 있다. 문서를 다른 개발자와 공유하거나 관련 자료를 찾아볼 때는 <code>arc42</code>, <code>C4</code>, <code>ADR</code>이라는 명칭이 필요하다. 다만 첫 번째 설명이 기술 용어가 되면 진입 장벽이 생기므로, 화면의 위계만 바꿨다.</p>
<h2 id="입력-전에-결과와-목적을-설명했다">입력 전에 결과와 목적을 설명했다</h2>
<p>초기화 폼의 첫 문장은 기능의 목적을 직접 설명한다.</p>
<blockquote>
<p>프로젝트의 구조와 기술 결정을 Markdown 문서로 정리하는 기능입니다. 전문 용어를 몰라도 기본 문서를 만든 뒤 내용을 채워 갈 수 있습니다.</p>
</blockquote>
<p>입력 레이블도 내부 용어가 아니라 사용자가 답할 수 있는 질문으로 바꿨다.</p>
<ul>
<li><code>프로젝트 이름</code>: 문서에 기록할 프로젝트 이름</li>
<li><code>무엇을 만들고 있나요?</code>: 프로젝트가 해결하려는 문제와 목표</li>
<li><code>사용 기술</code>: 언어, 프레임워크, 데이터베이스 등 사용 기술</li>
</ul>
<p>특히 프로젝트 목적을 <code>Purpose</code>나 <code>System Description</code>으로 부르지 않고 “무엇을 만들고 있나요?”로 바꾼 것은 사용자가 빈 문서 앞에서 멈추지 않게 하기 위해서다. 기술 문서의 정확한 항목명보다 먼저, 자신의 말로 답할 수 있어야 한다.</p>
<p>생성될 문서도 파일 경로만 나열하지 않는다. 각 파일이 어떤 질문에 답하는지 함께 보여 준다.</p>
<pre><code class="language-text">프로젝트 입력
  → 전체 아키텍처         : 프로젝트의 목표와 전체 구조
  → 시스템과 외부 관계    : 사용자·외부 서비스와의 연결
  → 주요 애플리케이션 구성 : 시스템을 구성하는 큰 단위
  → 구성 내부의 책임       : 각 단위가 맡은 역할
  → 기술 결정 목록         : 왜 이렇게 만들었는지 기록
  → 첫 번째 기술 결정      : 문서 세트를 만든 이유</code></pre>
<p>이 안내는 사용자가 모든 문서 형식을 미리 공부하지 않아도 “입력하면 어떤 초안이 생기는지”를 확인하게 한다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/e602e930-9cf9-4ac4-ab76-ed98d7f617d5/image.png" alt=""></p>
<h2 id="패널에서도-다음-행동을-설명한다">패널에서도 다음 행동을 설명한다</h2>
<p>Activity Bar의 아키텍처 패널 역시 문서 목록만 보여 주지 않는다. 패널 상단에서 다음 작업의 의미를 바로 알 수 있도록 안내한다.</p>
<ul>
<li><code>문서 세트 초기화</code>: 아직 없는 기본 문서 묶음 생성</li>
<li><code>ADR 작성</code>: 새로운 기술 결정을 기록</li>
<li><code>정합성 점검</code>: 필요한 문서와 기본 표식의 존재 여부 확인</li>
</ul>
<p>폴더를 선택하지 않은 상태에서는 단순히 빈 목록을 표시하지 않고, “선택된 폴더가 없습니다”와 함께 문서 폴더를 선택하면 무엇이 나타나는지 설명한다. 폴더는 선택했지만 아직 문서 세트를 만들지 않은 상태와도 구분한다.</p>
<p>이렇게 하면 사용자는 오류와 미완료 상태를 다르게 이해할 수 있다. 기능이 고장 난 것이 아니라 아직 다음 준비 단계가 남아 있다는 사실을 화면에서 확인할 수 있기 때문이다.</p>
<h2 id="사용성-개선의-기준">사용성 개선의 기준</h2>
<p>이번 변경에서 중요한 점은 문서 생성 로직이나 IPC를 새로 만든 것이 아니라는 점이다. Main의 파일 보호와 Renderer·Preload 경계는 그대로 두고, 사용자가 기능을 이해하는 데 필요한 설명과 이름을 보강했다.</p>
<p>결과적으로 Architecture Workspace는 다음 순서로 사용할 수 있게 됐다.</p>
<ol>
<li>Activity Bar에서 프로젝트 설계 문서를 연다.</li>
<li>기능의 목적과 생성될 문서를 확인한다.</li>
<li>프로젝트 이름과 목표를 자신의 말로 입력한다.</li>
<li>생성된 초안을 Editor에서 보완한다.</li>
<li>필요할 때 정합성 점검으로 문서 세트를 확인한다.</li>
</ol>
<p>기술 용어를 아는 사람에게는 보조 표기가 계속 보이고, 처음 사용하는 사람에게는 각 화면의 다음 행동이 먼저 보인다. Architecture Workspace를 “아는 사람만 쓰는 기능”에서 “설명을 읽고 시작할 수 있는 기능”으로 바꾼 부분이다.</p>
<h2 id="남은-범위">남은 범위</h2>
<p>Activity Bar 진입점은 생겼지만, 생성된 문서의 내용은 여전히 사용자가 직접 채워야 한다. 다음으로 검토할 수 있는 항목은 다음과 같다.</p>
<ul>
<li>arc42·C4 문서별 작성 가이드 표시</li>
<li>정합성 검사 결과에서 문제 문서 바로 열기</li>
<li>ADR Index에서 상태별 필터링</li>
<li>Architecture Workspace 문서 세트의 사용자 정의</li>
<li>사람이 승인하는 AI 초안 제안</li>
</ul>
<p>AI 연동은 여전히 후순위다. 먼저 사람이 Activity Bar에서 문서를 찾고, 열고, 수정하고, 검사하는 흐름이 자연스러운지 확인해야 한다.</p>
<h2 id="마무리">마무리</h2>
<p>이번 작업은 새로운 문서 형식을 추가한 것이 아니라, 이미 만든 Architecture Workspace를 실제 작업 공간답게 배치한 변경이다.</p>
<p>명령 팔레트는 명령을 실행하는 곳이고, Activity Bar는 계속 머무는 작업 영역으로 가는 곳이다. Architecture Workspace를 Activity Bar에 배치하면서 사용자는 문서를 작성하다가 현재 구조를 확인하고, ADR을 열고, 정합성 검사를 실행하는 흐름을 중단 없이 이어갈 수 있게 됐다.</p>
<p>다음 단계는 기능을 더 늘리는 것보다, 이 문서 흐름이 실제 프로젝트에서 충분히 읽기 쉽고 수정하기 쉬운지 검토하는 일이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (20) - Architecture Workspace에서 다음 ADR을 이어 쓰는 흐름 만들기]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-20-Architecture-Workspace%EC%97%90%EC%84%9C-%EB%8B%A4%EC%9D%8C-ADR%EC%9D%84-%EC%9D%B4%EC%96%B4-%EC%93%B0%EB%8A%94-%ED%9D%90%EB%A6%84-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-20-Architecture-Workspace%EC%97%90%EC%84%9C-%EB%8B%A4%EC%9D%8C-ADR%EC%9D%84-%EC%9D%B4%EC%96%B4-%EC%93%B0%EB%8A%94-%ED%9D%90%EB%A6%84-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Fri, 28 Aug 2026 18:00:36 GMT</pubDate>
            <description><![CDATA[<p>지난 포스팅에서는 Windows·macOS·Linux를 대상으로 한 CI 기준과 Windows 패키징을 점검했다. 로컬 검사가 통과했다는 사실만으로 제품 전체가 검증된 것은 아니므로, 플랫폼별 검사 범위와 실제 실행 가능한 패키지를 분리해 확인하는 과정이었다.</p>
<p>이번에는 Architecture Workspace의 다음 단계에 집중했다. 초기 Architecture Workspace는 새 프로젝트를 시작할 때 arc42, C4 Context·Container·Component, ADR index와 첫 ADR을 한 번에 생성한다. 그러나 첫 문서 세트만 만들어 주면 이후의 결정은 다시 채팅이나 개인 메모로 흩어질 수 있다.</p>
<p>이번 작업의 목표는 다음과 같다.</p>
<ul>
<li>프로젝트 진행 중에도 같은 규칙으로 ADR을 추가하는 진입점</li>
<li>ADR 번호와 제목 기반 파일명의 자동화</li>
<li>실제 ADR 파일과 README Index의 동시 갱신</li>
<li>기존 의사결정 기록을 덮어쓰지 않는 저장 경계</li>
<li>외부 구독과 AI API 없이 사용자가 직접 결정 내용을 기록하는 흐름</li>
</ul>
<h2 id="문제-첫-adr-이후의-기록-흐름이-비어-있었다">문제: 첫 ADR 이후의 기록 흐름이 비어 있었다</h2>
<p>초기 생성 세트는 다음과 같다.</p>
<pre><code class="language-text">docs/architecture/arc42.md
docs/architecture/c4-context.md
docs/architecture/c4-container.md
docs/architecture/c4-component.md
docs/adr/README.md
docs/adr/0001-initial-architecture.md</code></pre>
<p>실제 개발에서는 결정이 계속 추가된다. 검색 공급자 대신 격리된 Research View를 선택한 이유, 이미지 원본 저장 방식, document workspace 경계, MVP에서 기능을 제외한 이유처럼 나중에 다시 설명해야 하는 결정이 생긴다.</p>
<p>매번 수동으로 0002 파일을 만들고 README 표에 행을 추가하면 번호 중복이나 index 누락이 생기기 쉽다. 반대로 전체 ADR 세트를 다시 생성하면 사용자가 이미 검토한 기록이 바뀔 수 있다.</p>
<p>따라서 기준을 새 ADR만 추가하고 기존 ADR은 건드리지 않는 것으로 고정했다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/88277c19-768a-49d4-8c6a-af8ed14eed0c/image.png" alt=""></p>
<h2 id="ai를-바로-연결하지-않은-이유">AI를 바로 연결하지 않은 이유</h2>
<p>AI Agent가 코드를 분석해 arc42 항목이나 ADR 초안을 제안하는 기능은 나중에 충분히 검토할 수 있다. 다만 ADR은 단순한 텍스트가 아니라 프로젝트의 결정을 승인하는 기록이다.</p>
<p>AI 결과를 곧바로 Accepted로 저장하면 실제 합의가 없는 결정이 공식 문서처럼 남을 수 있고, 코드와 문서의 사실 관계가 틀려도 발견하기 어렵다. 사용자 API key, 외부 LLM 호출, 비용과 개인정보 검토도 추가된다.</p>
<p>그래서 현재 단계에서는 사용자가 제목·상태·배경·결정·결과를 직접 입력한다. AI는 향후 제안 단계에서만 검토하고, 제안 결과도 사용자의 승인 이후 기존 파일 경계와 충돌 검사를 통과하는 별도 흐름으로 두는 방향이다.</p>
<h2 id="화면-초기화와-후속-기록을-분리했다">화면: 초기화와 후속 기록을 분리했다</h2>
<p>명령 팔레트에는 두 개의 아키텍처 관련 진입점이 있다.</p>
<ul>
<li>Architecture Workspace: 새 document workspace에 전체 문서 세트 초기화</li>
<li>ADR 작성: 이미 생성된 문서 세트에 새 의사결정 기록 추가</li>
</ul>
<p>초기화는 여러 파일의 일괄 생성과 충돌 확인이 핵심이고, ADR 작성은 한 개의 결정과 index 갱신이 핵심이다. 목적이 다른 작업을 하나의 폼으로 합치지 않아 사용자가 지금 어떤 파일 작업을 요청하는지 분명하게 했다.</p>
<p>ADR 폼은 결정 제목, 상태, 배경, 결정, 결과를 받는다. 상태는 Proposed, Accepted, Rejected, Superseded 중 하나만 허용한다. 빈 document workspace에서 폼을 열 수는 있지만, 선택된 폴더가 없으면 파일 작업은 시작되지 않고 안내 메시지를 보여 준다.</p>
<p>편집 가능한 UI와 파일 저장 권한은 별개의 문제다. 화면이 열렸다는 이유로 임의의 경로에 쓰기를 허용하지 않는 것이 이 기능의 중요한 기준이다.</p>
<h2 id="ipc-renderer는-입력과-상태만-담당한다">IPC: Renderer는 입력과 상태만 담당한다</h2>
<p>Electron 보안 경계를 지키기 위해 Renderer에서 파일 시스템을 직접 사용하지 않는다. Renderer는 입력 상태를 관리하고 Preload가 노출한 기능별 API만 호출한다.</p>
<pre><code class="language-ts">const response = await window.dock.architecture.createAdr({
  workspaceId,
  title: adrTitle,
  status: adrStatus,
  context: adrContext,
  decision: adrDecision,
  consequences: adrConsequences,
});</code></pre>
<p>Preload에서는 요청과 응답을 다시 schema로 검증한다.</p>
<pre><code class="language-ts">export const ArchitectureCreateAdrRequestSchema = z
  .object({
    workspaceId: WorkspaceIdSchema,
    title: ArchitectureAdrTextSchema.max(200),
    status: ArchitectureAdrStatusSchema,
    context: ArchitectureAdrTextSchema,
    decision: ArchitectureAdrTextSchema,
    consequences: ArchitectureAdrTextSchema,
  })
  .strict();</code></pre>
<p>제목과 본문에는 길이 제한과 제어 문자 검사가 있다. 파일명은 제목을 그대로 사용하지 않고 줄바꿈과 특수 문자를 정리한 slug로 만든다. README의 Markdown 표에 제목을 넣을 때 표 구분자인 세로 막대도 별도로 escape한다.</p>
<h2 id="번호-생성-기존-파일에서-다음-번호를-계산한다">번호 생성: 기존 파일에서 다음 번호를 계산한다</h2>
<p>Main은 선택된 document workspace의 canonical path를 기준으로 고정된 docs/adr 디렉터리만 사용한다. 그 안에서 NNNN-*.md 패턴을 만족하는 일반 파일의 번호를 읽고 가장 큰 번호에 1을 더한다.</p>
<pre><code class="language-ts">const entries = await fs.readdir(realAdrDirectory, {
  withFileTypes: true,
});
const numbers = entries
  .filter((entry) =&gt; entry.isFile())
  .map((entry) =&gt; readAdrNumber(entry.name))
  .filter((number): number is number =&gt; number !== undefined);
const adrNumber = Math.max(0, ...numbers) + 1;</code></pre>
<p>첫 ADR이면 0001, 초기화로 0001이 이미 있으면 0002가 된다. 제목이 ADR 작성 흐름 추가라면 다음과 같은 파일이 생긴다.</p>
<pre><code class="language-text">docs/adr/0002-adr-작성-흐름-추가.md</code></pre>
<p>한국어 제목도 사용할 수 있으므로 slug를 만든 뒤 파일명의 유니코드 정규화 형태를 유지한다. 파일명과 Markdown 표에 사용되는 문자열은 서로 다른 규칙으로 처리한다.</p>
<h2 id="파일-생성과-index-갱신-순서와-실패-복구">파일 생성과 index 갱신: 순서와 실패 복구</h2>
<p>새 ADR 파일과 README Index는 서로 다른 파일이다. 하나만 성공하면 문서 체계가 어긋난다. 이번 구현은 다음 순서로 처리한다.</p>
<ol>
<li>docs/adr의 실제 경로와 document workspace 내부 여부 확인</li>
<li>기존 ADR 번호 계산</li>
<li>새 ADR 경로의 workspace 내부 여부 확인</li>
<li>기존 index가 있으면 일반 파일인지 확인하고 내용 읽기</li>
<li>새 ADR 파일을 exclusive 방식으로 생성</li>
<li>기존 index는 원자적으로 갱신하고, 없으면 새 index 생성</li>
<li>index 갱신 실패 시 이번 요청에서 만든 ADR 파일 제거</li>
</ol>
<p>새 ADR의 기본 구조는 상태, 배경, 결정, 결과로 고정했다.</p>
<pre><code class="language-md"># ADR-0002: ADR 작성 흐름 추가

## 상태

Accepted

## 배경

중요한 구조 결정을 채팅에만 남기면 추적하기 어렵습니다.

## 결정

Dock에서 번호가 붙은 ADR을 생성하고 index를 갱신합니다.

## 결과

결정의 배경과 결과를 document workspace 안에서 함께 관리합니다.</code></pre>
<p>기존 ADR 파일은 덮어쓰지 않는다. 새 ADR은 createDocumentWithContent의 exclusive 저장 경계를 사용하고, 기존 index는 내용을 읽어 새 행을 추가한 뒤 writeDocument로 원자적 저장을 요청한다.</p>
<p>이 구조는 다중 Dock 인스턴스가 동시에 같은 번호를 계산하는 경쟁 상태까지 해결하는 완전한 트랜잭션은 아니다. 현재 Dock은 단일 로컬 사용자용 MVP이므로, 이번 범위에서는 기존 파일 보호와 index 실패 시 부분 생성 정리를 우선했다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/cf0c64e1-5222-46bd-9089-f479ffd5e832/image.png" alt=""></p>
<h2 id="index는-기존-내용을-최대한-보존한다">index는 기존 내용을 최대한 보존한다</h2>
<p>초기 README에는 설명과 상태 표가 함께 있다. 새 ADR을 추가할 때 README 전체를 템플릿으로 교체하면 사용자가 직접 추가한 안내나 규칙이 사라질 수 있다.</p>
<p>그래서 기존 문서에서 Index heading을 찾아 해당 섹션에 한 행을 추가한다. heading이 없는 경우에는 기존 내용을 유지한 뒤 최소한의 표를 새로 붙인다.</p>
<pre><code class="language-text">| [0001-initial-architecture.md](./0001-initial-architecture.md) | Accepted | Dock 초기 아키텍처 문서 세트 |
| [0002-adr-creation-workflow.md](./0002-adr-creation-workflow.md) | Accepted | 애플리케이션에서 ADR 작성 흐름 제공 |</code></pre>
<p>사용자가 표의 열 구조를 완전히 바꾼 경우까지 의미적으로 해석하지는 않는다. 그 수준의 자동 문서 마이그레이션은 현재 단순 생성 기능의 범위를 넘어선다.</p>
<h2 id="테스트-네-계층으로-검증했다">테스트: 네 계층으로 검증했다</h2>
<p>이번 작업에서는 한 종류의 테스트만으로 완료를 판단하지 않았다.</p>
<p>Main service는 빈 docs/adr에서 0001 생성, 두 번째 요청에서 0002 생성, 기존 ADR 보존, Markdown 필드 생성, index 누적을 확인한다.</p>
<p>IPC와 Preload는 잘못된 workspace ID, 선택되지 않은 document workspace, 허용되지 않은 상태, 제목 길이 초과, 고정된 architecture:create-adr 채널을 검증한다.</p>
<p>Renderer는 명령 팔레트 진입, 필수 입력, workspace 미선택 안내, 성공 후 Editor 전환을 검증한다. 실제 Electron E2E는 임시 document workspace에서 다음 흐름을 확인한다.</p>
<pre><code class="language-text">폴더 선택
→ 명령 팔레트
→ ADR 작성
→ 0001 생성
→ Editor에서 열림
→ 다시 ADR 작성
→ 0002 생성
→ README Index에 두 행 존재</code></pre>
<p>최종 검증 결과는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>검증</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>npm run check</td>
<td>101 tests 통과</td>
</tr>
<tr>
<td>ADR 단독 Electron E2E</td>
<td>통과</td>
</tr>
<tr>
<td>전체 Electron E2E</td>
<td>14 scenarios 통과</td>
</tr>
<tr>
<td>npm run package</td>
<td>통과</td>
</tr>
<tr>
<td>npm run test:smoke</td>
<td>통과</td>
</tr>
</tbody></table>
<h2 id="이번-작업으로-고정된-것과-남은-것">이번 작업으로 고정된 것과 남은 것</h2>
<p>이번 작업으로 ADR은 사용자가 직접 작성하는 로컬 Markdown 기록이 됐다. 번호와 파일명은 Main에서 생성하고, 저장 위치는 선택된 document workspace의 docs/adr로 제한한다. 새 ADR과 README Index를 함께 갱신하며 기존 기록은 덮어쓰지 않는다.</p>
<p>아직 구현하지 않은 항목도 명확하다.</p>
<ul>
<li>기존 ADR 내용 분석과 자동 마이그레이션</li>
<li>ADR 간 Superseded 관계 자동 변경</li>
<li>코드 변경과 ADR의 자동 연결</li>
<li>Git branch·commit 생성</li>
<li>AI 초안의 승인 workflow</li>
<li>다중 Dock 인스턴스의 동시 번호 발급 lock</li>
</ul>
<p>AI를 영원히 배제했다는 뜻은 아니다. 사람이 읽고 고치고 승인하는 문서 흐름을 먼저 고정해야 이후 AI 제안 기능도 같은 저장 경계와 승인 규칙 안에 넣을 수 있다.</p>
<h2 id="마무리">마무리</h2>
<p>Architecture Workspace의 가치는 문서 파일을 한 번 만들어 주는 데서 끝나지 않는다. 프로젝트가 진행될수록 결정이 쌓이고, 그 결정이 현재 구조와 왜 연결되는지 추적할 수 있어야 한다.</p>
<p>이번 ADR 작성 흐름은 작지만 중요한 기반이다. Renderer는 입력과 상태만 담당하고, Preload는 좁은 계약만 노출하며, Main은 번호·경로·충돌·저장을 책임진다. 이 경계를 유지하면 다음 단계에서 arc42 항목 보완이나 C4 문서 제안 기능을 추가하더라도 기존 로컬 파일 보호 규칙을 흔들지 않을 수 있다.</p>
<p>다음 검토 대상은 생성된 문서 세트를 실제 프로젝트에 맞게 편집하는 경험이다. AI를 붙이기 전에 사람이 읽고 고치고 승인하는 문서 흐름이 충분히 자연스러운지부터 확인하는 순서다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (19) - 로컬 통과를 넘어 플랫폼 CI와 Windows 패키징까지 검증하기]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-19-%EB%A1%9C%EC%BB%AC-%ED%86%B5%EA%B3%BC%EB%A5%BC-%EB%84%98%EC%96%B4-%ED%94%8C%EB%9E%AB%ED%8F%BC-CI%EC%99%80-Windows-%ED%8C%A8%ED%82%A4%EC%A7%95%EA%B9%8C%EC%A7%80-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-19-%EB%A1%9C%EC%BB%AC-%ED%86%B5%EA%B3%BC%EB%A5%BC-%EB%84%98%EC%96%B4-%ED%94%8C%EB%9E%AB%ED%8F%BC-CI%EC%99%80-Windows-%ED%8C%A8%ED%82%A4%EC%A7%95%EA%B9%8C%EC%A7%80-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 27 Aug 2026 14:56:31 GMT</pubDate>
            <description><![CDATA[<p>이전 TIL에서는 document workspace에서 문서를 선택하고, 생성하고, 저장한 뒤 앱을 종료했다가 다시 실행해 같은 파일을 다시 읽는 흐름을 실제 Electron IPC로 검증했다.</p>
<p>그 다음 문제가 생겼다. Windows에서 개발하고 Windows에서 테스트한 결과만으로는 Dock이 Windows·macOS·Linux 제품이라는 사실을 증명할 수 없었다. 로컬에서는 통과하던 경로가 macOS의 임시 디렉터리 정규화나 Windows의 경로 표현 차이에서 깨졌고, 이미지 저장과 Windows installer 생성도 별도의 플랫폼 문제를 드러냈다.</p>
<p>이번 작업의 목표는 기능을 더 추가하는 일이 아니었다.</p>
<ul>
<li>세 플랫폼에서 같은 검사와 패키징을 반복할 수 있는 CI 기준 만들기</li>
<li>플랫폼별 경로 표현 차이를 보안 경계 안에서 흡수하기</li>
<li>실제 Windows E2E와 packaged smoke, installer 생성까지 한 번에 확인하기</li>
<li>검증 과정에서 발견된 이미지 저장 회귀와 테스트 경쟁 상태 제거하기</li>
<li>이미 push된 Git 커밋 설명의 literal \n을 실제 줄바꿈으로 정리하기</li>
</ul>
<h2 id="문제-로컬-테스트-통과가-제품-검증-완료는-아니었다">문제: 로컬 테스트 통과가 제품 검증 완료는 아니었다</h2>
<p>기존 검증은 Windows 개발 환경에서 충분히 많은 계층을 포함하고 있었다. npm run check, Electron E2E, packaged smoke까지 통과했지만, 이 결과는 “현재 개발 환경에서 현재 fixture가 동작한다”는 의미에 가깝다.</p>
<p>Dock은 로컬 파일 경계와 플랫폼 파일 시스템을 함께 다룬다. 같은 경로라도 운영체제는 다음처럼 다르게 표현할 수 있다.</p>
<ul>
<li>macOS 임시 경로: /var/...가 realpath 뒤 /private/var/...로 보일 수 있음</li>
<li>Windows 경로: 대소문자, 드라이브 문자, extended-length prefix(\?)가 섞일 수 있음</li>
<li>파일 확정 방식: Unix에서 익숙한 hard link 동작이 Windows에서 동일하게 동작한다고 보장하기 어려움</li>
<li>패키지 식별자: npm scoped 이름과 Squirrel/NuGet package 이름의 규칙이 다름</li>
</ul>
<p>따라서 단순히 OS를 matrix에 추가하는 것보다, 각 검증이 어떤 경계를 확인하는지부터 분리해야 했다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/63a9cbd7-b8ca-4722-9dac-01bbf1fa189e/image.png" alt=""></p>
<p>여기서 macOS와 Linux는 우선 clean install·check·package를 matrix로 고정하고, Windows는 현재 개발 환경에서만 재현 가능한 Electron runtime·installer 검증까지 추가했다. 통과하지 않은 macOS/Linux GUI E2E와 packaged smoke를 통과한 것으로 기록하지 않는 것도 같은 원칙에 포함된다.</p>
<h2 id="플랫폼-ci는-실패-위치를-보여줘야-했다">플랫폼 CI는 실패 위치를 보여줘야 했다</h2>
<p>처음 workflow를 만들 때 npm run check 하나로 모든 검사를 묶을 수도 있었다. 하지만 플랫폼 차이로 실패했을 때 typecheck인지, lint인지, format인지, unit test인지 바로 알기 어렵다.</p>
<p>그래서 matrix job에서는 각 단계를 독립적인 step으로 유지했다.</p>
<pre><code class="language-yaml">- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm run format:check
- run: npm run test:unit
- run: npm run package</code></pre>
<p>Windows runtime job은 matrix job과 분리했다. Electron E2E와 packaged smoke는 실행 시간이 길고, 패키징 산출물과 OS 실행 파일을 요구하기 때문이다.</p>
<pre><code class="language-yaml">windows-runtime:
  needs: platform-check
  runs-on: windows-latest
  steps:
    - run: npm ci
    - run: npm run test:e2e
    - run: npm run test:smoke
    - run: npm run make</code></pre>
<p>npm ci는 각 runner에서 매번 실행되며, cache는 npm metadata 복원에만 사용한다. 특정 개발 PC의 node_modules 상태가 CI 결과에 섞이지 않도록 한 구성이다. 동시에 fail-fast: false를 사용해 한 플랫폼의 실패가 다른 플랫폼의 진단 결과를 숨기지 않도록 했다.</p>
<h2 id="macos에서-드러난-canonical-path-문제">macOS에서 드러난 canonical path 문제</h2>
<p>document workspace와 assets 디렉터리의 경계를 검사하는 코드는 “문자열로 보이는 경로가 비슷한가”가 아니라 “실제 파일 시스템에서 같은 root 아래에 있는가”를 확인해야 한다.</p>
<p>그런데 macOS runner의 임시 경로가 한쪽에서는 /var로, 다른 쪽에서는 realpath 결과인 /private/var로 비교되면서 같은 위치가 workspace 밖으로 판정됐다. Windows에서도 symlink와 경로 표기 차이가 같은 종류의 경계 오류를 만들 수 있었다.</p>
<p>해결 방향은 경로 검사를 느슨하게 만드는 것이 아니었다. 비교에 참여하는 root와 candidate를 같은 canonicalization 기준으로 맞추는 것이었다.</p>
<pre><code class="language-ts">const root = await fs.realpath(storedRoot);
const absolutePath = path.resolve(root, relativePath);

if (!isInside(root, absolutePath)) return undefined;

if (mustExist) {
  const real = await fs.realpath(absolutePath);
  if (!isInside(root, real)) return undefined;
}</code></pre>
<p>특히 resolveWorkspacePath 요청마다 저장된 root를 다시 realpath하는 부분이 중요했다. 등록 시점의 root만 canonicalize하면 이후 파일 시스템 상태가 바뀌었을 때 root와 candidate가 서로 다른 기준으로 비교될 수 있다.</p>
<p>Windows 비교에는 별도의 비교용 정규화도 추가했다.</p>
<pre><code class="language-ts">const comparablePath = (value: string): string =&gt; {
  const resolved = path.resolve(value);
  if (process.platform !== &#39;win32&#39;) return resolved;

  return resolved
    .replace(/^\\\\\?\\UNC\\/i, &#39;\\\\&#39;)
    .replace(/^\\\\\?\\/i, &#39;&#39;)
    .toLowerCase();
};</code></pre>
<p>이 값은 파일을 열거나 저장할 실제 경로가 아니다. path.relative로 경계 안쪽인지 비교할 때만 사용한다. 실제 파일 접근에는 canonical root와 검증된 absolute path를 사용해, 비교 편의를 위해 보안 경계를 없애지 않도록 했다.</p>
<h2 id="windows-이미지-저장에서-hard-link를-제거한-이유">Windows 이미지 저장에서 hard link를 제거한 이유</h2>
<p>이미지 다운로드 서비스는 임시 파일에 먼저 응답 bytes를 저장한 뒤, 최종 파일명을 정하고, 기존 파일을 덮어쓰지 않는 방식으로 assets에 확정한다. 이전 구현은 새 파일을 확정할 때 hard link를 사용했다.</p>
<p>이 방식은 임시 파일과 최종 파일을 원자적으로 연결하려는 의도는 분명했지만, Windows 환경의 파일 시스템 동작과 권한 조건에서 Linux 개발 환경과 같은 결과를 보장하지 못했다. 이미지 저장 자체의 보안 규칙보다 파일 확정 방식이 플랫폼 호환성의 병목이 된 셈이다.</p>
<p>최종 저장은 COPYFILE_EXCL을 사용하는 exclusive copy로 바꿨다.</p>
<pre><code class="language-ts">await fs.copyFile(
  temporaryPath,
  absolutePath,
  fsConstants.COPYFILE_EXCL,
);
await fs.unlink(temporaryPath);</code></pre>
<p>이 변경으로 유지되는 조건은 다음과 같다.</p>
<ul>
<li>최종 파일명은 기존 파일이 있으면 suffix를 붙인다.</li>
<li>COPYFILE_EXCL이 EEXIST를 반환하면 다음 후보 파일명을 시도한다.</li>
<li>성공한 뒤에만 임시 파일을 제거한다.</li>
<li>중간 오류가 나면 finally에서 임시 파일을 정리한다.</li>
<li>다운로드 성공 전에는 Markdown을 변경하지 않는다.</li>
</ul>
<p>즉, 저장 방식만 바뀌었을 뿐 URL scheme, host allowlist, redirect, timeout, MIME, magic bytes, 최대 크기와 같은 Main download service의 검증 경계는 그대로 유지했다.</p>
<h2 id="e2e-fixture와-실제-저장-service의-책임-분리">E2E fixture와 실제 저장 service의 책임 분리</h2>
<p>Windows CI에서 이미지 삽입 E2E가 실패했을 때, 처음에는 실제 다운로드 service의 실패인지 fixture 응답의 문제인지 구분하기 어려웠다. 테스트가 외부 네트워크를 사용하지 않도록 고정한 것은 맞지만, fixture 초기화와 Renderer의 비동기 상태 전이가 겹치면 실패 증거가 흐려졌다.</p>
<p>그래서 다음처럼 책임을 분리했다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/22422e1d-042b-4a23-8b04-c523d40620bf/image.png" alt=""></p>
<p>runtime E2E에서는 고정 PNG 응답을 사용해 성공 경로를 deterministic하게 만들고, downloader unit test에서는 실제 저장 service의 MIME·signature·크기·충돌·정리 규칙을 계속 확인한다. 이렇게 하면 E2E는 Main IPC와 Renderer 삽입 경계를 확인하고, 파일 저장 알고리즘의 세부 실패는 빠른 unit test에서 진단할 수 있다.</p>
<p>또한 Playwright는 초기 workspace가 준비되기 전에 검색 동작을 시작하지 않도록 첫 문서의 # Start 상태를 기다린다. 단순 timeout 증가가 아니라, 다음 상태로 진행할 수 있다는 의미가 있는 UI 상태를 기다리는 방식이다.</p>
<h2 id="windows-installer에서-표시-이름과-패키지-식별자를-분리했다">Windows installer에서 표시 이름과 패키지 식별자를 분리했다</h2>
<p>npm run make는 앱을 실행하는 npm run package보다 한 단계 더 많은 규칙을 거친다. Squirrel.Windows는 내부적으로 NuGet package metadata를 만들기 때문에 npm package 이름을 그대로 사용하면 scoped 이름인 @jarvis-dock/desktop이 .nuspec 경로 처리와 충돌했다.</p>
<p>해결은 사용자에게 보이는 앱 이름을 바꾸는 것이 아니었다. Squirrel maker에 NuGet 식별자로 사용할 이름만 별도로 전달했다.</p>
<pre><code class="language-ts">new MakerSquirrel({ name: &#39;Dock&#39; })</code></pre>
<p>그 결과 사용자 표시 이름은 Dock.exe, Dock-<version> Setup.exe로 유지하면서 Squirrel 내부 package metadata는 Dock이라는 단순 식별자를 사용한다. 로컬과 CI에서 다음 산출물을 확인했다.</p>
<ul>
<li>Dock-1.0.0 Setup.exe</li>
<li>Dock-1.0.0-full.nupkg</li>
<li>RELEASES</li>
</ul>
<p>package 성공과 make 성공을 같은 의미로 기록하지 않은 이유도 여기에 있다. 전자는 실행 가능한 package 디렉터리 생성, 후자는 실제 설치 배포물과 maker metadata 생성까지 포함한다.</p>
<h2 id="ci-실패를-고친-뒤-커밋-이력도-정리했다">CI 실패를 고친 뒤 커밋 이력도 정리했다</h2>
<p>플랫폼 작업 중 만들어진 일부 Git commit description에는 줄바꿈을 전달하는 과정에서 실제 개행 대신 literal \n 문자열이 들어갔다.</p>
<p>예를 들면 다음처럼 보였다.</p>
<pre><code class="language-text">- 요청별 async realpath 기준\n- root와 candidate 동일 비교 기준\n- Windows 이미지 경로 회귀</code></pre>
<p>이것은 소스 코드 동작에는 영향을 주지 않지만, git log와 GitHub commit 화면에서 설명이 한 줄의 문자열처럼 보이게 만들고 변경 의도를 읽기 어렵게 한다. 이미 push된 main 이력도 사용자가 확인하는 프로젝트 기록이므로, 새 커밋 하나로 덮어쓰는 대신 실제 branch 이력을 정리했다.</p>
<p>정리 과정은 다음 원칙으로 제한했다.</p>
<ol>
<li>기존 main의 모든 reachable commit message에서 literal \n만 실제 줄바꿈으로 치환</li>
<li>파일 내용과 tree 변경은 수정하지 않음</li>
<li>작업 중이던 .gitignore, README.md는 임시 보관 후 그대로 복원</li>
<li>backup ref를 제거해 오래된 이력이 일반 branch 목록에 남지 않도록 정리</li>
<li>기존 원격 tip을 지정한 force-with-lease로만 원격 main 갱신</li>
<li>원격 branch의 새 HEAD와 로컬 HEAD가 같은지 확인</li>
</ol>
<p>이 작업은 commit message를 바꾸는 일이지만 remote branch history를 변경하는 작업이기도 하다. 따라서 임의의 force가 아니라 기존 원격 SHA를 조건으로 둔 force-with-lease를 사용했다.</p>
<h2 id="최종-검증-결과">최종 검증 결과</h2>
<p>재작성된 main 기준 GitHub Actions run #17에서 다음 검증이 모두 통과했다.</p>
<table>
<thead>
<tr>
<th>영역</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>Windows check</td>
<td>통과</td>
</tr>
<tr>
<td>macOS check</td>
<td>통과</td>
</tr>
<tr>
<td>Linux check</td>
<td>통과</td>
</tr>
<tr>
<td>Windows/macOS/Linux package</td>
<td>통과</td>
</tr>
<tr>
<td>Windows Electron E2E</td>
<td>10개 시나리오 통과</td>
</tr>
<tr>
<td>Windows packaged smoke</td>
<td>통과</td>
</tr>
<tr>
<td>Windows Squirrel npm run make</td>
<td>통과</td>
</tr>
<tr>
<td>원격 main literal \n 검색</td>
<td>미검출</td>
</tr>
</tbody></table>
<p>최종 원격 HEAD는 0f13403이며, 로컬 HEAD와 origin/main이 일치한다. 작업 트리에는 사용자가 별도로 수정한 .gitignore와 README.md만 남겨 두었고, 이 파일들은 커밋하지 않았다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/ccfefd51-8e2f-4189-a6f0-6b057e0d2c16/image.png" alt=""></p>
<h2 id="아직-남은-범위">아직 남은 범위</h2>
<p>이번 작업으로 세 플랫폼의 정적 검사와 package, Windows runtime·installer 검증은 CI에 들어왔다. 하지만 다음 항목을 완료했다고 표현할 수는 없다.</p>
<ul>
<li>macOS/Linux의 실제 GUI Electron E2E</li>
<li>macOS/Linux packaged executable smoke</li>
<li>실제 Google 결과의 selector·locale별 카드 추출 수동 확인</li>
<li>보조기술을 이용한 접근성 수동 점검</li>
<li>릴리스에 사용할 macOS/Linux maker와 서명·배포 정책</li>
</ul>
<p>이번 단계에서 확인한 것은 “모든 플랫폼에서 모든 사용자 흐름이 끝났다”가 아니다. 현재 CI가 실제로 실행한 범위와 아직 사람이 확인해야 하는 범위를 분리하고, 다음 작업의 기준을 더 구체적으로 만든 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (18) - 로컬 문서 저장이 앱 재실행 뒤에도 남는지 실제 IPC로 확인하기]]></title>
            <link>https://velog.io/@re_brother/TIL-Electron-App-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-18-%EB%A1%9C%EC%BB%AC-%EB%AC%B8%EC%84%9C-%EC%A0%80%EC%9E%A5%EC%9D%B4-%EC%95%B1-%EC%9E%AC%EC%8B%A4%ED%96%89-%EB%92%A4%EC%97%90%EB%8F%84-%EB%82%A8%EB%8A%94%EC%A7%80-%EC%8B%A4%EC%A0%9C-IPC%EB%A1%9C-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@re_brother/TIL-Electron-App-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-18-%EB%A1%9C%EC%BB%AC-%EB%AC%B8%EC%84%9C-%EC%A0%80%EC%9E%A5%EC%9D%B4-%EC%95%B1-%EC%9E%AC%EC%8B%A4%ED%96%89-%EB%92%A4%EC%97%90%EB%8F%84-%EB%82%A8%EB%8A%94%EC%A7%80-%EC%8B%A4%EC%A0%9C-IPC%EB%A1%9C-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 26 Aug 2026 16:30:27 GMT</pubDate>
            <description><![CDATA[<p>Dock에서 문서를 저장한다는 말은 textarea의 상태를 바꾸는 일이 아니다. 사용자가 고른 document workspace 안에서만 Main이 파일을 열고, Renderer는 좁은 IPC로 상대 경로와 내용만 전달하며, 저장 결과는 앱을 닫아도 로컬 파일로 남아야 한다.</p>
<p>M2에서 이 경로의 서비스·handler·Renderer 테스트는 이미 만들었다. 다만 각각을 따로 통과하는 것과, 실제 Electron 프로세스에서 폴더 선택부터 앱 종료 후 다시 읽기까지 이어지는 것은 다른 검증이다. 이번에는 Journey A·B·E의 기본 회귀를 하나의 E2E로 고정했다.</p>
<h2 id="문제-ui-mock만으로는-파일-저장을-증명할-수-없다">문제: UI mock만으로는 파일 저장을 증명할 수 없다</h2>
<p>Renderer component test는 <code>window.dock</code>을 test double로 교체한다. 버튼 이름, loading 상태, 저장 요청 내용 같은 UI 계약에는 알맞지만, Main이 workspaceId를 보관하는지, 상대 경로를 실제 파일 경로로 해석하는지, 원자적 저장 뒤 새 Electron 실행에서도 파일을 읽을 수 있는지까지 확인하지는 못한다.</p>
<p>반대로 실제 사용자의 폴더를 E2E에 쓰면 테스트가 문서를 만들고 수정할 수 있어 위험하다. OS native dialog도 Playwright의 일반 selector로 제어할 수 없다.</p>
<p>그래서 테스트가 소유한 임시 폴더와, 그 폴더만 반환하는 Main의 dialog test double을 결합했다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/3e914686-f74e-4e15-a37b-914346e92a8c/image.png" alt=""></p>
<p>핵심은 dialog 자체를 화면 selector로 흉내 낸 것이 아니라, 실제 <code>workspace:choose</code> handler가 받는 dialog 의존성만 테스트 시에 고정했다는 점이다. 그 아래의 sender 검사, workspace 등록, 파일 목록·읽기·생성·저장 IPC는 전부 실제 코드로 통과한다.</p>
<h2 id="테스트-전용-경계는-main에만-둔다">테스트 전용 경계는 Main에만 둔다</h2>
<p>테스트가 <code>--dock-e2e-document</code>로 실행될 때만 Main은 <code>DOCK_E2E_DOCUMENT_WORKSPACE_ROOT</code> 환경 변수의 경로를 사용한다. 이 플래그가 없는 일반 실행은 기존 native dialog를 그대로 사용한다. Renderer에 테스트 전용 파일 경로를 노출하거나 범용 파일 시스템 API를 추가하지 않았다.</p>
<p>Main에서는 설정된 root를 별도로 기억해 dialog 응답에만 사용한다.</p>
<pre><code class="language-ts">const createE2eDocumentDialog = () =&gt; ({
  showOpenDialog: async () =&gt; ({
    canceled: !e2eDocumentWorkspaceRoot,
    filePaths: e2eDocumentWorkspaceRoot
      ? [e2eDocumentWorkspaceRoot]
      : [],
  }),
});</code></pre>
<p>여기서 중요한 구현은 삭제 책임도 분리한 부분이다. 환경 변수로 전달된 폴더는 Playwright가 만들고 정리한다. Main은 자기 자신이 <code>mkdtempSync</code>로 만든 fixture root만 <code>will-quit</code>에서 삭제한다. 테스트 코드가 전달한 경로를 앱 종료 훅에서 재귀 삭제하면, 실수로 실제 사용자 경로를 가리켰을 때 피해 범위가 커질 수 있기 때문이다.</p>
<h2 id="선택·생성·저장·재실행을-한-흐름으로-확인했다">선택·생성·저장·재실행을 한 흐름으로 확인했다</h2>
<p>E2E는 빈 앱에서 <code>폴더 선택</code>을 누르고 fixture의 <code>guide.md</code>를 열어 초기 읽기부터 확인한다. 이어서 <code>relaunch.md</code>를 생성하고 Markdown 내용을 입력한 뒤 저장한다.</p>
<pre><code class="language-ts">await firstPage.getByLabel(&#39;새 문서 경로&#39;).fill(&#39;relaunch.md&#39;);
await firstPage.getByRole(&#39;button&#39;, { name: &#39;새 문서 생성&#39; }).click();
await editor.fill(&#39;# Relaunch\n\n저장된 Markdown 문서&#39;);
await firstPage.getByRole(&#39;button&#39;, { name: &#39;저장&#39; }).click();</code></pre>
<p>이 호출은 기존 UI가 사용하는 <code>document:create</code>, <code>document:write</code> IPC로 이어진다. Main의 workspace service는 선택한 root 안인지 다시 검사하고, Markdown 확장자만 허용하며, 임시 파일을 쓴 뒤 rename 또는 link 방식으로 저장한다. 따라서 E2E는 단순히 화면의 “저장됨” 표시만 읽지 않고, 앱을 닫은 뒤 새 Electron 실행에서 동일한 <code>relaunch.md</code>를 열어 내용 전체가 남았는지 확인한다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/bd2ecc8b-ef0f-4cc0-9c46-52e1c519063d/image.png" alt=""></p>
<p>이 범위는 “최근 폴더를 기억해 자동으로 다시 연다”는 기능을 뜻하지 않는다. Dock은 현재 workspaceId를 메모리에만 보관하므로, 재실행 뒤 테스트도 다시 <code>폴더 선택</code>을 누른다. 이번에 보장한 것은 사용자가 같은 로컬 폴더를 다시 선택했을 때, 이전 실행에서 저장한 문서가 손상되거나 사라지지 않고 정상적인 IPC 경로로 다시 열린다는 점이다.</p>
<h2 id="검증-결과와-남은-hardening">검증 결과와 남은 hardening</h2>
<p><code>npm run check</code>는 60개 테스트, <code>npm run test:e2e</code>는 7개 시나리오, <code>npm run test:smoke</code>는 Windows packaged executable smoke를 통과했다.</p>
<p>이번 E2E로 document workspace의 기본 정상 여정은 실제 Electron에서 고정됐다. 아직 남은 범위도 분명하다. native dialog의 실제 화면 동작은 릴리스 전 수동 smoke로 확인해야 하고, 사용자가 선택을 취소했을 때의 E2E, 저장 실패 후 편집 내용 보존, 대용량 Markdown과 접근성 점검은 다음 hardening에서 별도로 다룬다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (17) - WebContentsView를 실제로 열어 원격 페이지 권한 검증하기]]></title>
            <link>https://velog.io/@re_brother/TIL-Electron-App-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-17-WebContentsView%EB%A5%BC-%EC%8B%A4%EC%A0%9C%EB%A1%9C-%EC%97%B4%EC%96%B4-%EC%9B%90%EA%B2%A9-%ED%8E%98%EC%9D%B4%EC%A7%80-%EA%B6%8C%ED%95%9C%EC%9D%84-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@re_brother/TIL-Electron-App-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-17-WebContentsView%EB%A5%BC-%EC%8B%A4%EC%A0%9C%EB%A1%9C-%EC%97%B4%EC%96%B4-%EC%9B%90%EA%B2%A9-%ED%8E%98%EC%9D%B4%EC%A7%80-%EA%B6%8C%ED%95%9C%EC%9D%84-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 25 Aug 2026 16:02:15 GMT</pubDate>
            <description><![CDATA[<p>Research View는 Main이 소유하는 별도 <code>WebContentsView</code>다. 코드에서 <code>sandbox: true</code>, <code>nodeIntegration: false</code>, permission 거부를 설정했다고 해도, 실제 Electron에서 원격 페이지가 어떤 권한을 갖는지까지 확인하지 않으면 설정을 신뢰하기 어렵다.</p>
<p>이번 작업은 Google의 실제 응답이나 네트워크 상태에 의존하지 않고, 실제 Research View에서 popup·권한 요청·download·위험 navigation을 실행해 차단 여부를 E2E로 확인하는 데 집중했다.</p>
<h2 id="왜-기존-mock-e2e만으로는-부족했나">왜 기존 mock E2E만으로는 부족했나</h2>
<p>기존 <code>/link</code> E2E는 mock controller가 카드 목록을 반환하고, 사용자가 카드를 누르면 Markdown이 삽입되는 흐름을 검증했다. 이 테스트는 IPC와 Renderer 삽입에는 충분하지만, <code>WebContentsView</code> 자체의 session handler나 navigation listener가 Electron에서 실제로 동작하는지는 증명하지 못한다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/535091ba-5c10-48f6-ac6b-328f91e715ca/image.png" alt=""></p>
<p>두 테스트는 대체 관계가 아니다. 하나는 작성 흐름을, 다른 하나는 원격 콘텐츠 경계를 확인한다.</p>
<h2 id="외부-google-페이지를-fixture로-바꾼-이유">외부 Google 페이지를 fixture로 바꾼 이유</h2>
<p>E2E가 실제 Google 검색 결과를 기다리면 locale, 실험 그룹, 네트워크 상태 때문에 불안정해진다. 하지만 <code>WebContentsView</code>를 mock으로 바꾸면 검증하려는 Electron session과 event handler도 사라진다.</p>
<p>그래서 E2E 실행 인자가 있을 때만 Research View의 <code>dock-research</code> session에서 <code>https://www.google.com/search</code> 요청에 고정 HTML fixture를 반환했다.</p>
<pre><code class="language-ts">session.fromPartition(&#39;dock-research&#39;).protocol.handle(&#39;https&#39;, (request) =&gt; {
  const url = new URL(request.url);
  if (url.origin === &#39;https://www.google.com&#39; &amp;&amp; url.pathname === &#39;/search&#39;) {
    return new Response(E2E_RESEARCH_SECURITY_HTML, {
      headers: { &#39;content-type&#39;: &#39;text/html; charset=utf-8&#39; },
    });
  }
  return new Response(&#39;Research security fixture blocks test network.&#39;, {
    status: 404,
  });
});</code></pre>
<p>이 handler는 <code>--dock-e2e-research-security</code>일 때만 등록된다. 일반 실행 경로와 production package의 실제 Google 요청은 이 fixture를 거치지 않는다.</p>
<h2 id="실제-view에서-확인한-경계">실제 View에서 확인한 경계</h2>
<p>fixture가 로드된 뒤 Playwright는 Main context를 통해 Research View의 <code>webContents</code>에 접근한다. 여기서 원격 페이지가 Dock API나 Node.js 객체를 볼 수 없는지 먼저 확인했다.</p>
<pre><code class="language-ts">const remoteCapabilities = await webContents.executeJavaScript(
  `({
    dock: typeof window.dock,
    nodeProcess: typeof window.process,
    require: typeof window.require,
  })`,
  true,
);</code></pre>
<p>세 값은 모두 <code>undefined</code>여야 한다. 그 다음 동일한 View에서 다음 시도를 수행했다.</p>
<ul>
<li><code>window.open()</code>으로 popup 생성</li>
<li>geolocation permission 요청</li>
<li><code>file:</code> URL navigation</li>
<li><code>data:</code> download 시작</li>
</ul>
<p><img src="https://velog.velcdn.com/images/re_brother/post/d422e4d2-cee5-4d5b-acb2-2787fa6e5b6d/image.png" alt=""></p>
<p>popup은 새 BrowserWindow가 생기지 않는지로 확인했고, permission은 거부 callback, navigation은 기존 Google Search URL 유지, download는 <code>will-download</code> event의 <code>preventDefault</code> 상태로 확인했다.</p>
<p>한 가지 구현 세부사항도 확인했다. Playwright Electron의 <code>app.windows()</code>는 BrowserWindow만이 아니라 <code>WebContentsView</code>도 Page로 집계한다. 따라서 절대 개수 1을 기대하면 정상 Research View 자체를 popup으로 오인한다. 테스트는 privileged action 전후의 Page 수가 같다는 방식으로 새 창이 없음을 판정했다.</p>
<h2 id="검증-결과와-남은-범위">검증 결과와 남은 범위</h2>
<p><code>npm run check</code>는 60개 테스트, <code>npm run test:e2e</code>는 6개 시나리오, <code>npm run test:smoke</code>는 Windows packaged executable smoke를 통과했다.</p>
<p>이것은 Google 결과 selector가 모든 locale에서 동작한다는 증명은 아니다. 실제 Google 검색·카드 추출·현재 페이지 링크 fallback은 여전히 수동 확인 대상이다. 다만 원격 페이지가 권한 경계를 우회해 Dock API, Node.js, popup, 파일 navigation, download에 접근하지 못한다는 회귀 기준은 이제 실제 Electron 실행에서 고정됐다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (16) - 외부 검색 API 없이 Research View 결과를 링크 카드로 연결하기]]></title>
            <link>https://velog.io/@re_brother/Electron-App-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-16-%EC%99%B8%EB%B6%80-%EA%B2%80%EC%83%89-API-%EC%97%86%EC%9D%B4-Research-View-%EA%B2%B0%EA%B3%BC%EB%A5%BC-%EB%A7%81%ED%81%AC-%EC%B9%B4%EB%93%9C%EB%A1%9C-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@re_brother/Electron-App-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-16-%EC%99%B8%EB%B6%80-%EA%B2%80%EC%83%89-API-%EC%97%86%EC%9D%B4-Research-View-%EA%B2%B0%EA%B3%BC%EB%A5%BC-%EB%A7%81%ED%81%AC-%EC%B9%B4%EB%93%9C%EB%A1%9C-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 24 Aug 2026 14:50:29 GMT</pubDate>
            <description><![CDATA[<p><code>/link</code> 기능은 처음부터 검색 공급자를 앱 내부 API로 고정하지 않았다. 사용자가 자료를 찾고 문서에 링크를 남기는 흐름을 안전하게 만드는 것이 목적이었다. Brave API는 사용자 key와 외부 구독 문제가 있었고, 시스템 브라우저를 여는 방식은 앱 안에서 맥락을 이어 가기 어려웠다. 그래서 Main 프로세스가 별도 <code>WebContentsView</code>를 소유하는 Research View를 먼저 만들었다.</p>
<p>그다음 요구는 단순했다. 검색 결과에서 주소를 복사하지 않고, 보이는 결과를 카드로 눌러 Markdown에 삽입하고 싶었다.</p>
<h2 id="json-endpoint를-먼저-찾으려-했던-이유">JSON endpoint를 먼저 찾으려 했던 이유</h2>
<p>검색 결과 HTML을 통째로 다루는 것보다, 브라우저가 이미 요청한 JSON 응답에서 제목과 URL만 읽는 편이 경계가 명확하다. 그래서 Chrome과 실제 Electron <code>WebContentsView</code>에서 검색 직후의 XHR·Fetch를 관찰했다.</p>
<p>하지만 이 환경의 Google 검색 결과는 고정해서 쓸 만한 JSON XHR·Fetch endpoint가 보이지 않았다. Main에서 <code>https://www.google.com/search?q=...</code>를 직접 요청해도 결과 문서가 아니라 JavaScript 활성화를 요구하는 HTML을 받았다. JSON endpoint 캡처와 직접 HTTP 파싱은 모두 이번 경로의 기반으로 삼을 수 없었다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/76f89ed1-7745-4203-bb2a-d9db1bc4ca59/image.png" alt=""></p>
<p>여기서 중요한 구분이 있다. 별도의 봇 요청으로 Google 검색 페이지를 수집하는 기능을 만든 것이 아니다. 사용자가 명시적으로 연 앱 내부 Research View가 이미 화면에 렌더링한 결과에서, 필요한 두 필드만 한 번 읽는 로컬 실험이다.</p>
<h2 id="이번-실험의-데이터-경계">이번 실험의 데이터 경계</h2>
<p>Research View는 여전히 Renderer가 아닌 Main 프로세스가 소유한다. 원격 페이지에는 preload도 없고 Node.js나 Electron API도 없다. Renderer는 원격 DOM에 접근할 수 없으며, 고정 IPC 응답으로 전달된 제목·HTTPS URL 카드만 받는다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/e698cd53-aca9-48fe-9e7c-6cf9104b8a58/image.png" alt=""></p>
<p>이 흐름에서 저장하거나 전달하지 않는 데이터도 명확히 했다.</p>
<ul>
<li>원본 HTML과 DOM 전체</li>
<li>cookie, request header, session, 방문 기록</li>
<li>검색어의 별도 기록</li>
<li>snippet, 광고 식별자, 원격 페이지가 제공한 임의 HTML</li>
</ul>
<p>카드에는 제목과 검증된 HTTPS URL만 들어간다. 결과가 비어 있으면 기능이 실패한 것으로 가장하지 않고, 기존의 “현재 페이지 링크 삽입”을 fallback으로 남긴다.</p>
<h2 id="결과-카드가-만들어지는-방식">결과 카드가 만들어지는 방식</h2>
<p>Main의 extractor는 검색 결과 anchor 안의 <code>h3</code> 제목과 브라우저가 해석한 <code>href</code>만 반환한다. 선택자는 Main에 상수로 고정되어 있으므로 Renderer 입력으로 JavaScript가 만들어지지 않는다.</p>
<pre><code class="language-ts">const GOOGLE_RESULT_EXTRACTOR = `
(() =&gt; Array.from(document.querySelectorAll(&#39;a&#39;)).flatMap((anchor) =&gt; {
  const heading = anchor.querySelector(&#39;h3&#39;);
  const title = heading?.textContent?.replace(/\\s+/g, &#39; &#39;).trim();
  const href = anchor.href;
  return title &amp;&amp; href ? [{ title, href }] : [];
}))()
`;</code></pre>
<p>추출 직후 값을 그대로 보내지 않았다. Google의 <code>/url?q=...</code> redirect는 목적 URL로 한 번 풀고, 중복 URL은 제거했다. HTTP 주소, 빈 제목, 형식이 깨진 값은 버렸다. 허용된 결과도 제목 500자, URL 2,048자, 최대 10개로 제한했다.</p>
<pre><code class="language-ts">const parsed = ResearchSearchResultSchema.safeParse({ title, url });
if (!parsed.success) continue;
results.push(parsed.data);
if (results.length === 10) break;</code></pre>
<p>IPC 계약 자체가 HTTPS만 허용하도록 만들었기 때문에, Main의 추출 코드가 바뀌더라도 Preload와 Renderer로 넘어가는 경계에서 한 번 더 막힌다.</p>
<h2 id="ui에서는-새-삽입-로직을-만들지-않았다">UI에서는 새 삽입 로직을 만들지 않았다</h2>
<p>카드 선택이 곧바로 문서를 쓰게 하면 실패 경로가 늘어난다. 이미 <code>/link</code>에 있던 Markdown escape와 선택 영역 삽입 함수를 재사용했다. 즉, 카드가 전달하는 것은 <code>title</code>, <code>url</code>뿐이고, 실제 문서 변경은 기존의 검증된 링크 삽입 경로를 그대로 지난다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/f52910ba-2d30-45ab-8b00-05355cfeba85/image.png" alt=""></p>
<p>그래서 카드 추출이 실패하거나 Google 화면이 바뀌어도 Markdown 파일 권한이나 원격 DOM 접근 권한이 Renderer로 넓어지지 않는다.</p>
<h2 id="테스트-범위와-남은-확인">테스트 범위와 남은 확인</h2>
<p>이번 단계에서는 외부 Google 화면에 의존하는 자동 테스트를 만들지 않았다. 대신 extractor fixture에서 아래 경우를 고정했다.</p>
<ul>
<li>제목 공백 정리</li>
<li>Google redirect URL 해제</li>
<li>중복 URL 제거</li>
<li>HTTP·빈 제목·깨진 URL 거부</li>
<li>최대 10개 결과 제한</li>
</ul>
<p>그리고 packaged Electron E2E에서는 mock Research View가 카드를 돌려주고, 사용자가 카드를 선택하면 Markdown이 삽입되는 전체 흐름을 검증했다. <code>npm run check</code>는 60개 테스트, <code>npm run test:e2e</code>는 5개 시나리오를 통과했다.</p>
<p>실제 Google의 selector는 지역·언어·실험 그룹에 따라 달라질 수 있다. 이 실험은 로컬 전용이므로 첫 사용에서는 실제 Research View에서 카드가 나오는지 수동 확인한다. 결과가 없으면 fallback으로 현재 페이지 URL을 삽입할 수 있고, 일반 사용자 배포나 background healthcheck가 필요해지는 시점에는 제공자 정책과 별도 데이터 경로를 다시 결정해야 한다.</p>
<p>이번 구현의 핵심은 “검색 결과를 가져왔다”가 아니다. 원격 페이지를 앱의 강한 권한과 섞지 않으면서, 사용자가 이미 보고 있는 화면에서 문서 작성에 필요한 최소 정보만 좁은 계약으로 옮겼다는 데 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (15) - Network 탭의 XHR을 링크 카드로 쓰기 전에 확인한 것]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-15-Network-%ED%83%AD%EC%9D%98-XHR%EC%9D%84-%EB%A7%81%ED%81%AC-%EC%B9%B4%EB%93%9C%EB%A1%9C-%EC%93%B0%EA%B8%B0-%EC%A0%84%EC%97%90-%ED%99%95%EC%9D%B8%ED%95%9C-%EA%B2%83</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-15-Network-%ED%83%AD%EC%9D%98-XHR%EC%9D%84-%EB%A7%81%ED%81%AC-%EC%B9%B4%EB%93%9C%EB%A1%9C-%EC%93%B0%EA%B8%B0-%EC%A0%84%EC%97%90-%ED%99%95%EC%9D%B8%ED%95%9C-%EA%B2%83</guid>
            <pubDate>Sun, 23 Aug 2026 03:52:33 GMT</pubDate>
            <description><![CDATA[<p>Research View를 만든 뒤 자연스럽게 다음 질문이 나왔다.</p>
<blockquote>
<p>앱 안에서 Google 검색을 열었으니, DevTools Network 탭의 XHR·Fetch 응답을 한 번만 읽어 링크 카드로 보여 주면 되지 않을까?</p>
</blockquote>
<p>Selenium Wire를 사용해 본 적이 있다면 더욱 현실적인 생각이다. Selenium Wire는 브라우저와 서버 사이의 네트워크를 프록시로 관찰해 요청과 응답을 확인할 수 있다. Electron도 Chromium 기반이므로, Main 프로세스에서 Chrome DevTools Protocol(CDP)에 붙으면 비슷한 관찰을 할 수 있다.</p>
<p>이번 글은 그 가능성을 실제로 확인한 과정과, 왜 아직 Dock의 기능으로 넣지 않았는지를 정리한 기록이다. 결론은 단순하다. <strong>네트워크 응답을 읽는 기술은 가능하지만, 현재 Google 검색 결과에는 코드에 고정할 만한 JSON XHR·Fetch endpoint를 확인하지 못했다.</strong> 그래서 범용 감시나 HTML 파싱으로 범위를 넓히지 않고 보류했다.</p>
<hr>
<h2 id="출발점-research-view는-원격-페이지를-보여-주되-renderer에는-권한을-주지-않는다">출발점: Research View는 원격 페이지를 보여 주되 Renderer에는 권한을 주지 않는다</h2>
<p>Dock의 <code>/link</code>는 API key를 요구하는 검색 API와 시스템 브라우저 왕복을 거쳐, Main이 소유하는 <code>WebContentsView</code> Research View로 바뀌었다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/4ca68335-cff4-4011-9756-7fe735d994af/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/96f0fa70-fc53-424c-87aa-11e923cb8b81/image.png" alt=""></p>
<p>Renderer가 받는 기능은 검색 열기, 닫기, 현재 페이지 링크 읽기 세 가지다. 원격 DOM, cookie, session, 범용 URL 탐색, Electron API는 Renderer에 제공하지 않는다.</p>
<pre><code class="language-ts">research: {
  open: (request: { query: string }) =&gt; Promise&lt;ResearchOpenResultEnvelope&gt;;
  close: () =&gt; Promise&lt;ResearchCloseResultEnvelope&gt;;
  currentLink: () =&gt; Promise&lt;ResearchCurrentLinkResultEnvelope&gt;;
}</code></pre>
<p>그 상태에서 “검색 결과 카드”를 추가하려면 원격 페이지의 정보를 어디서, 어떤 범위까지 읽을지 새로 정해야 한다. DOM을 Renderer에서 파싱하는 것은 기존 경계를 깨므로 제외했다. 대신 Main이 CDP Network domain을 사용해 특정 JSON response 하나만 읽을 수 있는지를 검토했다.</p>
<h2 id="selenium-wire와-electron-cdp의-차이">Selenium Wire와 Electron CDP의 차이</h2>
<p>Selenium Wire는 일반적으로 브라우저 앞의 프록시로 트래픽을 관찰한다. Electron에서는 별도 프록시보다 <code>webContents.debugger</code>가 적합하다. 이 API는 Main 프로세스에서 Chrome DevTools Protocol에 연결해 <code>Network.enable</code>과 Network 이벤트를 사용할 수 있게 한다.</p>
<pre><code class="language-ts">const debuggerApi = researchView.webContents.debugger;

debuggerApi.attach(&#39;1.3&#39;);
await debuggerApi.sendCommand(&#39;Network.enable&#39;);

debuggerApi.on(&#39;message&#39;, async (_event, method, params) =&gt; {
  if (method !== &#39;Network.responseReceived&#39;) return;
  if (params.type !== &#39;Fetch&#39; &amp;&amp; params.type !== &#39;XHR&#39;) return;

  const response = await debuggerApi.sendCommand(
    &#39;Network.getResponseBody&#39;,
    { requestId: params.requestId },
  );
});</code></pre>
<p>이 코드는 기술적으로 응답 body까지 가져올 수 있다. 그러나 이 자체는 제품 기능으로 안전하다는 뜻이 아니다. response에는 검색어, 개인화 정보, 인증 헤더와 연결된 데이터 또는 현재 페이지와 무관한 API 응답이 섞일 수 있다. 따라서 “모든 XHR을 수집”하는 구조는 만들지 않기로 했다.</p>
<p><code>session.webRequest</code>도 후보였다. 이 API는 URL, method, resource type, request/response header를 필터링하고 제어하기 좋다. 하지만 응답 body를 링크 카드 데이터로 읽는 용도는 CDP Network domain과 다르다. body가 필요하다면 CDP를 쓰되, 더 엄격한 allowlist가 필요하다.</p>
<h2 id="먼저-확인한-것-정말-google-결과-json-endpoint가-있는가">먼저 확인한 것: 정말 Google 결과 JSON endpoint가 있는가</h2>
<p>가정부터 구현하지 않았다. Chrome을 숨김 모드로 실행해 <code>electron security</code>를 Google에서 검색하고, <code>xhr</code>와 <code>fetch</code> resource type만 관찰했다. 출력에는 privacy를 위해 query 값, header, cookie, response body를 넣지 않고 origin·path·status·MIME만 남기도록 했다.</p>
<pre><code class="language-ts">page.on(&#39;response&#39;, (response) =&gt; {
  const request = response.request();
  if (request.resourceType() !== &#39;fetch&#39; &amp;&amp; request.resourceType() !== &#39;xhr&#39;) {
    return;
  }

  const url = new URL(response.url());
  if (url.hostname !== &#39;www.google.com&#39;) return;

  observed.set(`${request.resourceType()}:${url.origin}${url.pathname}`, {
    type: request.resourceType(),
    url: `${url.origin}${url.pathname}`,
    status: response.status(),
    mimeType: response.headers()[&#39;content-type&#39;] ?? &#39;&#39;,
  });
});</code></pre>
<p>2026-08-22 관찰 결과, 해당 검색에서는 Google 검색 결과를 제공하는 JSON XHR·Fetch 응답이 나타나지 않았다. 결과는 일반 document navigation의 HTML로 내려왔다. 즉 “Google 검색 결과의 특정 JSON endpoint”라는 전제를 만족하는 origin·path·MIME·JSON schema를 코드에 고정할 수 없었다.</p>
<p>headless Chrome과 Electron의 네트워크 차이도 배제하기 위해 같은 관찰기를 실제 Dock Research View에 붙여 Electron E2E로 다시 실행했다. <code>www.google.com</code>뿐 아니라 <code>*.google.com</code>, <code>*.googleapis.com</code>, <code>*.gstatic.com</code>까지 body 없이 XHR·Fetch 메타데이터를 확인했지만 결과는 역시 0건이었다. 관찰기는 제품 코드에 남기지 않고 제거했다.</p>
<p>이 결과가 Google이 항상 XHR을 전혀 사용하지 않는다는 뜻은 아니다. 지역, 로그인, 실험 그룹, 브라우저 환경에 따라 네트워크 형태는 바뀔 수 있다. 바로 그 점이 제품 기능의 고정 endpoint로 삼기 어려운 이유다. 한 환경에서만 우연히 보인 내부 endpoint에 의존하면 사용자의 다른 환경에서 링크 카드가 조용히 사라질 수 있다.</p>
<h2 id="공식-google-json-api도-새-선택지는-아니었다">공식 Google JSON API도 새 선택지는 아니었다</h2>
<p>그렇다면 Google이 제공하는 공식 JSON API를 쓰면 될까? 2026-08-22에 Google 공식 문서를 다시 확인한 결과, Custom Search JSON API는 신규 고객에게 닫혀 있고 기존 고객도 2027-01-01까지 대체 서비스로 전환해야 한다. API key와 Programmable Search Engine ID가 필요한 구조였던 데다, 이제 신규 프로젝트의 장기적인 선택지도 아니다.</p>
<p>따라서 “Google 결과를 JSON으로 받는다”는 요구는 현재 두 경로 모두 성립하지 않는다.</p>
<ul>
<li>Google 화면의 내부 XHR·Fetch: 고정 가능한 결과 JSON endpoint를 확인하지 못함</li>
<li>Google 공식 Custom Search JSON API: 신규 고객 사용 불가, 종료 전환 일정 존재</li>
</ul>
<p>정형 JSON 링크 카드를 계속 원한다면 Google 화면을 감시하는 대신, 별도 검색 provider의 공식 JSON API를 선택해야 한다. 이 경우에는 user-provided API key, 프로젝트 소유 backend, 검색 범위를 제한한 site search 중 어느 운영 모델을 사용할지 먼저 정해야 한다.</p>
<h2 id="html-직접-요청과-1시간-healthcheck도-진행하지-않은-이유">HTML 직접 요청과 1시간 healthcheck도 진행하지 않은 이유</h2>
<p>Google 검색 URL에 Main HTTP 요청을 보내면 실제로 <code>200 OK</code>, <code>text/html</code> 응답을 받을 수 있다. 기술적으로는 이 HTML에서 제목·URL·snippet을 파싱해 링크 카드로 만드는 것도 가능하다. 마크업 변경은 fixture와 정기 healthcheck로 감지하고, 초기 단일 사용자의 지역·언어는 고정 파라미터로 줄이는 접근도 가능하다.</p>
<p>하지만 이 판단은 성능이나 데이터베이스 사용 여부보다 먼저 제공자의 자동 접근 정책을 통과해야 한다. 2026-08-22에 확인한 Google <code>robots.txt</code>는 <code>User-agent: *</code>에 <code>/search</code>를 <code>Disallow</code>로 선언한다. Google 현재 약관도 웹페이지의 machine-readable instructions를 위반하는 자동 접근을 금지한다. Main이 <code>/search?q=…</code>를 요청해 파싱하는 동작과 1시간마다 같은 endpoint를 호출하는 healthcheck는 이 조건과 맞지 않는다.</p>
<p>따라서 Dock은 Google HTML 직접 요청·파싱을 구현하지 않았다. 단일 사용자, 낮은 빈도, 비영구 저장은 운영 부하를 낮추지만 자동 접근 허용 여부를 바꾸지는 않는다. 이 결론은 법률 자문이 아니라 현재 공개된 provider 정책을 프로젝트의 보안·서비스 약관 준수 기준에 적용한 결과다.</p>
<h2 id="그래서-하지-않은-것">그래서 하지 않은 것</h2>
<p>결과가 없다고 다음 단계로 범위를 넓히는 선택은 하지 않았다.</p>
<ul>
<li>Google document HTML을 받아 DOM·정규식으로 결과를 파싱하기</li>
<li>모든 Google XHR·Fetch response를 body까지 읽기</li>
<li>allowlist 없는 일반 웹사이트 response 수집하기</li>
<li>원본 JSON·header·cookie를 Renderer에 넘기거나 로그로 남기기</li>
<li>CDP API를 Renderer에 노출해 임의로 <code>Network.getResponseBody</code> 호출하기</li>
</ul>
<p>이 중 어느 하나라도 허용하면 Research View는 “페이지를 보여 주고 현재 URL만 가져오는 도구”에서 “사용자가 열어 본 웹사이트의 데이터를 읽는 도구”로 성격이 바뀐다. 보안 검토, 개인정보 처리, 제공자 정책, 테스트 범위도 함께 커진다.</p>
<h2 id="만약-유효한-endpoint가-확인되면-지켜야-할-경계">만약 유효한 endpoint가 확인되면 지켜야 할 경계</h2>
<p>Google 또는 다른 허용된 검색 화면에서 안정적이고 정책상 사용할 수 있는 JSON endpoint가 확인된다면, 구현은 다음 조건을 모두 만족해야 한다.</p>
<pre><code class="language-text">사용자 검색 동작
→ 정확한 HTTPS origin·path allowlist 매칭
→ Fetch/XHR·JSON MIME·최대 크기 확인
→ JSON schema로 title·https URL·짧은 snippet만 정규화
→ 링크 카드 배열만 Renderer에 전달
→ 첫 유효 결과 또는 실패 직후 CDP detach</code></pre>
<ul>
<li>endpoint의 query 값, request header, cookie, 원본 response body는 저장·로그·전달하지 않는다.</li>
<li>response는 최대 크기와 JSON schema를 검사한다.</li>
<li><code>http:</code>나 위험한 scheme은 카드 URL로 허용하지 않는다.</li>
<li>CDP 연결은 검색 직후 한 번만 열고, 완료·실패·view close·window close 시 즉시 해제한다.</li>
<li>Renderer API는 <code>research:search-results</code>처럼 결과를 읽는 고정 채널 하나만 제공한다.</li>
<li>실제 외부 검색에 의존하지 않는 deterministic E2E와 endpoint/schema unit test를 함께 둔다.</li>
</ul>
<p>이 기준은 현재 구현된 기능이 아니라 ADR-0014의 Proposed 설계다. exact endpoint를 증명하지 못한 상태에서 구현을 시작하지 않는다.</p>
<h2 id="현재-결론">현재 결론</h2>
<p>Research View의 현재 기능은 그대로 유지한다.</p>
<pre><code class="language-text">Google 검색 열기
→ 사용자가 결과와 페이지를 직접 확인
→ 현재 페이지 링크 삽입</code></pre>
<p>검색 결과 JSON을 캡처해 링크 카드로 보여 주는 기능은 보류다. 현재 Google 결과 화면에서 코드에 고정할 수 있는 JSON XHR·Fetch endpoint를 확인하지 못했기 때문이다.</p>
<p>이 판단은 “CDP로는 못 한다”가 아니라 “할 수 있는 모든 데이터를 읽는 방향으로 제품을 확장하지 않는다”에 가깝다. endpoint의 안정성·정책 적합성·응답 schema를 확인할 수 있다면 좁은 Main 전용 기능으로 다시 검토할 수 있다. 그렇지 않다면 지금의 명시적 현재 페이지 링크 삽입이 더 정직하고 안전한 사용자 경험이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (14) - 검색 API 대신 Research View를 선택한 이유]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-14-%EA%B2%80%EC%83%89-API-%EB%8C%80%EC%8B%A0-Research-View%EB%A5%BC-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-14-%EA%B2%80%EC%83%89-API-%EB%8C%80%EC%8B%A0-Research-View%EB%A5%BC-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Fri, 21 Aug 2026 15:44:38 GMT</pubDate>
            <description><![CDATA[<p><code>/link</code> 기능의 목표는 단순하다. Markdown 문서를 작성하다가 자료를 찾고, 확인한 페이지를 안전한 Markdown 링크로 넣는 것이다.</p>
<blockquote>
<p>하지만 “검색 결과를 가져온다”는 구현 선택은 전혀 단순하지 않았다.</p>
</blockquote>
<p>API key를 누가 보관할지, 검색 결과 HTML을 어디에서 실행할지, Renderer에 어느 정도 권한을 줄지, 사용자가 실제로 문서를 쓰는 흐름이 좋아지는지까지 함께 결정해야 했다.</p>
<p>이번 글에서는 처음 검토했던 Brave Search API, 대안으로 바꿨던 시스템 브라우저 검색, 최종적으로 앱 안에 넣은 <code>Research View</code>까지의 판단 과정을 정리한다. 결론부터 말하면, Dock의 <code>/link</code>는 검색 API도 브라우저 자동화도 아닌 <strong>Main 프로세스가 소유하는 격리된 <code>WebContentsView</code></strong>로 자료를 열고, 사용자가 누른 순간의 현재 페이지 제목과 URL만 검증해 Markdown에 삽입하는 방식이 됐다.</p>
<hr>
<h2 id="첫번째-문제-검색-기능이-아니라-문서-작성-흐름">첫번째 문제: 검색 기능이 아니라 문서 작성 흐름</h2>
<p>기술 문서를 작성할 때 링크를 넣는 과정은 보통 다음과 같다.</p>
<pre><code class="language-text">문서 작성
→ 브라우저로 이동
→ 검색
→ 결과 페이지 확인
→ 제목·URL 복사
→ 에디터로 복귀
→ Markdown 문법으로 정리</code></pre>
<p>이 과정에서 불편한 부분은 Google 결과를 표시하지 못하는 데만 있지 않다. 작성 중이던 문서와 검색 맥락이 끊기고, URL과 제목을 손으로 조합하는 과정에서 누락·오타·깨진 Markdown이 생긴다.</p>
<p>Dock은 로컬 Markdown 문서를 다루는 데스크톱 앱이다. 그래서 <code>/link</code>의 성공 기준을 “검색 결과 목록을 예쁘게 보여 주는가”가 아니라 다음처럼 잡았다.</p>
<pre><code class="language-text">사용자가 문서를 떠나지 않고 자료를 확인한다.
→ 원하는 페이지를 직접 선택한다.
→ 선택한 페이지의 제목과 URL을 검증한다.
→ 현재 커서 또는 선택 영역에 안전한 Markdown 링크를 삽입한다.</code></pre>
<p>여기서 중요한 전제는 검색 결과와 원격 웹페이지가 신뢰할 수 없는 입력이라는 점이다. Dock의 Renderer는 로컬 문서와 편집 상태를 가진 영역이므로, 원격 HTML을 이곳에서 실행하거나 Renderer에 브라우저·파일 시스템 권한을 주는 방식은 처음부터 제외했다.</p>
<h2 id="solution-a-brave-search-api">Solution A. Brave Search API</h2>
<p>처음에는 Main 프로세스에서 Brave Search API를 호출하는 방식으로 <code>/link</code>를 구성했다. Renderer는 검색어와 사용자가 입력한 API key만 좁은 IPC로 전달하고, 실제 HTTP 요청·응답 검증·오류 변환은 Main이 맡는 구조였다.</p>
<p>이 접근에는 분명한 장점이 있었다.</p>
<ul>
<li>결과를 구조화된 JSON으로 받을 수 있다.</li>
<li>응답 형식을 통제하기 쉽다.</li>
<li>검색 결과를 Dock UI에 바로 그릴 수 있다.</li>
<li>검색 provider를 바꾸더라도 service 경계를 유지할 수 있다.</li>
</ul>
<p>하지만 데스크톱 앱 배포 관점에서 API key 문제가 남았다. 프로젝트 소유 key를 Electron 앱에 넣으면 패키지에서 추출될 수 있으므로 비밀이라고 볼 수 없다. 그렇다고 일반 사용자에게 각자 key를 발급받으라고 요구하면, 링크 하나 넣는 기능의 진입 장벽이 너무 높아진다.</p>
<p>별도 백엔드나 프록시를 두면 key는 보호할 수 있다. 그러나 그 순간부터 운영 비용, 호출량 제한, abuse 대응, 개인정보와 로그 정책, 장애 대응이 제품 범위에 들어온다. 로컬 Markdown 작성 도구의 MVP에 검색 서비스 운영까지 얹는 선택은 과했다.</p>
<p>그래서 Brave API는 한때 실제 구현까지 됐지만, 프로젝트 방향에서 제외했다. API 자체가 나쁜 선택이라서가 아니라 <strong>이 제품의 배포·운영 모델과 맞지 않았기 때문</strong>이다.</p>
<h2 id="solution-b-시스템-브라우저와-직접-입력">Solution B. 시스템 브라우저와 직접 입력</h2>
<p>API key 문제를 피하는 가장 단순한 방법은 기본 브라우저에 고정 Google 검색 URL을 여는 것이었다. Dock은 검색어만 Main에 전달하고, Main이 <code>shell.openExternal()</code>로 다음 형태의 URL을 실행한다.</p>
<pre><code class="language-text">https://www.google.com/search?q=...</code></pre>
<p>이 방식은 key도 필요 없고, 검색 엔진의 화면과 계정 상태를 그대로 사용할 수 있다. 또한 검색 결과 HTML을 Dock 안에서 파싱할 필요도 없다.</p>
<p>그러나 제품 경험에는 중요한 빈틈이 있었다.</p>
<pre><code class="language-text">Dock → 시스템 브라우저 → Dock</code></pre>
<p>사용자는 다시 창을 전환해야 했고, 페이지 제목과 URL도 별도 입력 칸에 복사해야 했다. 기능은 안전했지만 Dock이 줄이고자 했던 컨텍스트 전환을 충분히 줄이지 못했다.</p>
<p>이 시점에서 “앱 안에서 브라우저를 띄울 수 없는가?”라는 질문이 나왔다. 이 질문은 단순히 UI 영역 하나를 추가하는 문제가 아니었다. 원격 웹 콘텐츠를 Electron 앱 안에 넣는 순간, 프로세스 경계와 권한 모델을 다시 설계해야 했다.</p>
<h2 id="검토한-대안과-제외-이유">검토한 대안과 제외 이유</h2>
<h3 id="1-검색-결과-스크래핑">1. 검색 결과 스크래핑</h3>
<p><code>requests</code>나 비동기 HTTP 클라이언트로 검색 엔진 결과 페이지를 받아 필요한 링크만 추출하는 방법도 생각할 수 있다. 하지만 검색 결과 HTML은 안정적인 API 계약이 아니다. 마크업 변경, 지역·언어·로그인 상태, CAPTCHA, 차단 정책에 쉽게 영향을 받는다. 제공자의 이용 약관과 자동화 허용 범위도 확인해야 한다.</p>
<p>무엇보다 HTML을 받아 파싱하는 구현은 “링크를 삽입한다”는 제품 기능에 비해 유지 비용이 크다. 페이지 DOM을 신뢰할 수 없는 입력으로 계속 다루면서도 검색 품질을 보장해야 하기 때문이다.</p>
<h3 id="2-google-검색-api">2. Google 검색 API</h3>
<p>Google 검색 API도 본질적으로 API key, 비용·할당량, 백엔드 운영 여부 문제를 다시 만든다. provider만 바뀔 뿐 배포된 데스크톱 앱에 프로젝트 소유 key를 넣기 어렵다는 조건은 달라지지 않는다.</p>
<h3 id="3-webview-태그">3. <code>&lt;webview&gt;</code> 태그</h3>
<p>Electron에는 Renderer에서 사용할 수 있는 <code>&lt;webview&gt;</code>가 있지만, Dock의 보안 기준은 MVP에서 이를 사용하지 않도록 정하고 있다. 원격 콘텐츠와 앱 Renderer가 너무 가까워지는 구조는 경계를 이해하고 검증하기 어렵다. Dock의 Renderer는 편집기와 로컬 문서 상태를 담당하므로, 원격 페이지를 같은 Renderer 측 기능으로 취급하지 않기로 했다.</p>
<h3 id="4-브라우저-자동화·dom-추출">4. 브라우저 자동화·DOM 추출</h3>
<p>원격 페이지의 DOM을 읽어 자동으로 제목·링크·본문을 추출하는 기능은 편리해 보인다. 하지만 페이지마다 동작이 다르고, 로그인 상태·동적 렌더링·이용 약관의 영향을 받는다. 또한 원격 DOM을 제품 기능의 입력으로 넓게 받아들이는 순간 검증 범위가 급격히 커진다.</p>
<p>현재 MVP에서 필요한 것은 “사용자가 보고 있는 페이지의 링크를 문서에 넣는 것”이다. 요약, 본문 추출, 자동 인용은 별도 문제로 남겨 두었다.</p>
<h2 id="최종-선택-main-소유-webcontentsview">최종 선택: Main 소유 <code>WebContentsView</code></h2>
<p>최종 구현은 Main 프로세스가 <code>WebContentsView</code>를 생성하고 Dock 창의 오른쪽 영역에 붙이는 방식이다. 이를 <code>Research View</code>라고 부른다.</p>
<p><img src="https://velog.velcdn.com/images/re_brother/post/79448361-0b2f-49b5-9ac5-9c4db767ab4f/image.png" alt=""></p>
<p>핵심은 “앱 안에서 보인다”와 “Renderer가 원격 페이지를 제어한다”를 같은 의미로 두지 않는 것이다. 화면상으로는 같은 창 안에 있지만, 원격 웹 콘텐츠의 생성·탐색·세션·수명은 Main이 관리한다. Renderer는 원격 DOM, 쿠키, 네트워크, Electron 객체에 접근할 수 없다.</p>
<p>이번 결정은 ADR-0013에 기록했다. 이전 Brave API 결정과 시스템 브라우저 결정은 삭제하지 않고 <code>Superseded</code> 상태로 남겼다. 기술 결정은 결과만 남기면 왜 다른 선택을 하지 않았는지 사라지기 때문에, 바뀐 경로도 함께 보존하는 편이 이후 판단에 도움이 된다.</p>
<h2 id="사용자-흐름-검색어와-현재-페이지-링크만-넘긴다">사용자 흐름: 검색어와 현재 페이지 링크만 넘긴다</h2>
<p><code>/link</code> 명령을 열면 사용자는 검색어를 넣고 <strong>Research View 열기</strong>를 누른다. 이후 검색과 페이지 이동은 오른쪽 Research View에서 직접 한다. 원하는 자료를 찾은 뒤 헤더의 <strong>현재 페이지 링크 삽입</strong>을 누르면 Dock이 현재 URL과 페이지 제목을 받아 Markdown 링크를 만든다.</p>
<pre><code class="language-text">/link 검색어 입력
→ research:open
→ Main이 Google HTTPS 검색 URL 로드
→ Research View에서 사용자 탐색
→ 현재 페이지 링크 삽입
→ research:current-link
→ Main URL·제목 검증
→ Renderer가 Markdown escape 후 선택 영역 교체</code></pre>
<p>Renderer에 제공하는 공개 API도 이 세 동작뿐이다.</p>
<pre><code class="language-ts">research: {
  open: (request: { query: string }) =&gt; Promise&lt;ResearchOpenResultEnvelope&gt;;
  close: () =&gt; Promise&lt;ResearchCloseResultEnvelope&gt;;
  currentLink: () =&gt; Promise&lt;ResearchCurrentLinkResultEnvelope&gt;;
}</code></pre>
<p>의도적으로 빠진 API가 더 중요하다. Renderer에는 다음을 제공하지 않는다.</p>
<ul>
<li>임의 URL을 Research View에 여는 API</li>
<li>뒤로 가기·앞으로 가기·새 탭·다운로드 API</li>
<li>페이지 HTML, DOM, cookie, session 조회 API</li>
<li>범용 <code>ipcRenderer</code>, <code>shell</code>, <code>fs</code> 객체</li>
</ul>
<p>이렇게 범위를 좁히면 Renderer가 침해되더라도 원격 브라우저를 범용 제어 도구로 사용하는 경로를 만들기 어렵다. 또한 UI 요구가 늘어날 때마다 어떤 권한이 실제로 필요한지 명시적으로 다시 검토할 수 있다.</p>
<h2 id="검색-url은-고정하고-검색어만-데이터로-다룬다">검색 URL은 고정하고, 검색어만 데이터로 다룬다</h2>
<p>검색 URL을 문자열 연결로 만들면 <code>&amp;</code>, <code>?</code>, 공백 같은 문자가 쉽게 깨지거나 의도하지 않은 query parameter가 섞일 수 있다. 그래서 base URL은 코드에 고정하고, 검색어는 <code>URLSearchParams</code>를 통해 넣었다.</p>
<pre><code class="language-ts">const GOOGLE_SEARCH_URL = &#39;https://www.google.com/search&#39;;

export const createGoogleSearchUrl = (query: string): string =&gt; {
  const url = new URL(GOOGLE_SEARCH_URL);
  url.searchParams.set(&#39;q&#39;, query);
  return url.toString();
};</code></pre>
<p>IPC 요청도 런타임에서 검사한다. TypeScript 타입만으로는 Renderer에서 넘어오는 런타임 데이터를 믿을 수 없기 때문이다.</p>
<pre><code class="language-ts">export const ResearchOpenRequestSchema = z
  .object({ query: z.string().trim().min(1).max(200) })
  .strict();</code></pre>
<p>빈 검색어, 지나치게 긴 값, 예상하지 못한 필드는 Main에 도달하기 전에 거부한다. handler는 추가로 sender frame URL을 검사해 승인된 Dock Renderer에서 온 요청만 처리한다.</p>
<pre><code class="language-ts">ipcMain.handle(IPC.RESEARCH_OPEN, async (event, request) =&gt; {
  if (!isTrustedSender(event.senderFrame.url)) {
    return unauthorizedSenderResult;
  }

  const parsed = ResearchOpenRequestSchema.safeParse(request);
  if (!parsed.success) return invalidRequestResult;

  await controller.open(parsed.data.query);
  return { ok: true, value: { opened: true } };
});</code></pre>
<p>이 검증은 “UI에서 이미 입력을 막았으니 충분하다”는 가정을 제거한다. 개발자 도구, 악성 스크립트, 버전 불일치 등으로 IPC가 예상과 다른 값으로 호출될 수 있다는 전제에서 Main을 최종 권한 경계로 둔다.</p>
<h2 id="원격-콘텐츠-격리-보안-옵션은-기본값에-맡기지-않는다">원격 콘텐츠 격리: 보안 옵션은 기본값에 맡기지 않는다</h2>
<p>Research View는 로컬 문서를 다루는 Dock Renderer와 다른 보안 설정으로 생성한다.</p>
<pre><code class="language-ts">export const createResearchWebPreferences = () =&gt; ({
  partition: &#39;dock-research&#39;,
  contextIsolation: true,
  nodeIntegration: false,
  sandbox: true,
  webSecurity: true,
});</code></pre>
<p>각 옵션은 다음 역할을 한다.</p>
<ul>
<li><code>partition: &#39;dock-research&#39;</code>: <code>persist:</code> 접두어가 없는 메모리 세션. 앱 종료 후 cookie와 로그인 상태를 영구 저장하지 않는다.</li>
<li><code>contextIsolation: true</code>: 원격 페이지의 JavaScript와 Electron 쪽 실행 환경을 분리한다.</li>
<li><code>nodeIntegration: false</code>: 원격 페이지에서 Node.js API를 사용할 수 없게 한다.</li>
<li><code>sandbox: true</code>: renderer process 권한을 더 줄인다.</li>
<li><code>webSecurity: true</code>: 웹 보안 정책을 끄지 않는다.</li>
</ul>
<p>Research View에는 preload도 넣지 않았다. 원격 페이지에 Dock 전용 API를 제공할 이유가 없기 때문이다. “페이지가 화면 안에 있다”는 사실은 권한을 전달하는 이유가 될 수 없다.</p>
<h2 id="탐색은-허용하되-권한·새-창·다운로드는-거부한다">탐색은 허용하되, 권한·새 창·다운로드는 거부한다</h2>
<p>검색 결과를 눌러 실제 페이지로 갈 수 있어야 하므로 <code>http:</code>와 <code>https:</code> 탐색과 redirect는 허용한다. 반대로 <code>file:</code>, <code>javascript:</code> 같은 scheme은 차단한다.</p>
<pre><code class="language-ts">export const isAllowedResearchUrl = (url: string): boolean =&gt;
  isAllowedLinkUrl(url); // http: 또는 https:만 true

webContents.on(&#39;will-navigate&#39;, (event, url) =&gt; {
  if (!isAllowedResearchUrl(url)) event.preventDefault();
});

webContents.on(&#39;will-redirect&#39;, (event, url) =&gt; {
  if (!isAllowedResearchUrl(url)) event.preventDefault();
});</code></pre>
<p>외부 페이지가 권한 요청이나 popup, 파일 다운로드를 시도할 수도 있다. Research View의 목적은 자료를 읽고 링크를 선택하는 것이므로 이 동작은 필요하지 않다. 기본적으로 모두 거부했다.</p>
<pre><code class="language-ts">webContents.session.setPermissionCheckHandler(() =&gt; false);
webContents.session.setPermissionRequestHandler(
  (_contents, _permission, callback) =&gt; callback(false),
);
webContents.session.on(&#39;will-download&#39;, (event) =&gt; event.preventDefault());
webContents.setWindowOpenHandler(() =&gt; ({ action: &#39;deny&#39; }));</code></pre>
<p>이 기준은 기능 제한이 아니라 책임 분리다. 다운로드가 필요하면 이미지 다운로드처럼 URL·redirect·MIME·크기·저장 경로를 검증하는 별도 흐름으로 설계해야 한다. popup이나 영구 로그인, 브라우저 기록이 필요해져도 지금의 작은 API에 임시로 덧붙이지 않고, 요구와 위험을 다시 ADR로 검토한다.</p>
<h2 id="링크-삽입은-renderer가-하되-링크-데이터의-출처는-main이다">링크 삽입은 Renderer가 하되, 링크 데이터의 출처는 Main이다</h2>
<p>현재 페이지 링크를 삽입할 때 Main은 View가 실제로 열려 있는지, webContents가 파괴되지 않았는지, URL이 허용된 scheme인지, 길이가 제한 안에 있는지 확인한다. 제목이 비어 있으면 hostname을 fallback으로 사용한다.</p>
<pre><code class="language-ts">currentLink(): ResearchCurrentLink | undefined {
  const view = this.view;
  if (!view || view.webContents.isDestroyed()) return undefined;

  const url = view.webContents.getURL();
  if (!isAllowedResearchUrl(url) || url.length &gt; 2048) return undefined;

  const title = (view.webContents.getTitle().trim() || new URL(url).hostname)
    .slice(0, 500)
    .trim();

  return title ? { title, url } : undefined;
}</code></pre>
<p>Markdown 문자열 조합과 커서 위치 변경은 에디터 상태를 가진 Renderer의 책임이다. 다만 여기서도 받아 온 제목과 URL을 그대로 문자열로 붙이지 않고 기존 Markdown escape·URL 검증 함수를 거친다.</p>
<pre><code class="language-ts">const result = await window.dock.research.currentLink();
if (result.ok === false) {
  setResearchError(&#39;현재 페이지 링크를 삽입할 수 없습니다.&#39;);
  return;
}

const markdown = formatMarkdownLink(result.value);
const nextContent = insertAtSelection(
  content,
  markdown,
  linkSelection.start,
  linkSelection.end,
);
setContent(nextContent);</code></pre>
<p>이 분리는 꽤 중요하다. Main은 외부 웹과 Electron 객체를 가진 쪽에서 데이터의 출처를 확인하고, Renderer는 검증된 작은 데이터를 사용해 문서 편집이라는 UI 작업만 한다. 어느 한쪽에 두 책임을 몰아넣지 않는다.</p>
<h2 id="view-수명-주기와-레이아웃">View 수명 주기와 레이아웃</h2>
<p>Research View는 창마다 하나만 둔다. 처음 열 때 만들고, 창 크기가 바뀌면 오른쪽 영역의 bounds를 다시 계산한다. 닫기 버튼을 누르거나 메인 창이 닫힐 때 child view를 분리하고 webContents도 종료한다.</p>
<pre><code class="language-ts">close(): void {
  const view = this.view;
  if (!view) return;

  this.view = undefined;
  this.mainWindow.contentView.removeChildView(view);
  if (!view.webContents.isDestroyed()) view.webContents.close();
}

private layout(): void {
  const { width, height } = this.mainWindow.getContentBounds();
  const x = Math.floor(width * 0.52);

  this.view?.setBounds({
    x,
    y: 72,
    width: Math.max(0, width - x),
    height: Math.max(0, height - 72),
  });
}</code></pre>
<p>Renderer DOM 안에 포함된 iframe처럼 보이지만, 실제 bounds 계산은 Main의 <code>BrowserWindow</code> content area를 기준으로 한다. 이 때문에 Main과 Renderer의 UI 구조가 서로 영향을 받는다. 지금은 헤더 높이를 상수로 두고 오른쪽 영역을 사용하지만, 앞으로 반응형 레이아웃이 크게 바뀌면 view bounds를 전달·계산하는 별도 계약도 검토해야 한다.</p>
<h2 id="테스트-실제-검색-엔진에-의존하지-않는-이유">테스트: 실제 검색 엔진에 의존하지 않는 이유</h2>
<p>Remote web search를 E2E의 핵심 성공 조건으로 두면 네트워크, 검색 엔진 화면 변경, 지역·로그인 상태 때문에 테스트가 쉽게 흔들린다. 그래서 테스트를 두 층으로 나눴다.</p>
<p>먼저 단위 테스트에서 고정 Google URL 구성, <code>http</code>/<code>https</code> navigation 경계, 격리 webPreferences를 확인한다.</p>
<pre><code class="language-ts">expect(createResearchWebPreferences()).toEqual({
  partition: &#39;dock-research&#39;,
  contextIsolation: true,
  nodeIntegration: false,
  sandbox: true,
  webSecurity: true,
});

expect(isAllowedResearchUrl(&#39;https://example.com/docs&#39;)).toBe(true);
expect(isAllowedResearchUrl(&#39;file:///secret.txt&#39;)).toBe(false);
expect(isAllowedResearchUrl(&#39;javascript:alert(1)&#39;)).toBe(false);</code></pre>
<p>그 다음 IPC handler test에서는 신뢰되지 않은 sender, 잘못된 요청, 닫힌 view, 정상 open/current-link/close를 검사한다. Preload test는 Renderer에 <code>research</code>라는 좁은 API만 생기고, 요청·응답이 schema를 통과하는지 확인한다.</p>
<p>Electron E2E에서는 실제 Google을 열지 않는 deterministic controller를 <code>--dock-e2e-link</code> 인자로 주입했다. 이 controller는 Research View가 열렸을 때만 고정된 안전한 페이지 정보를 돌려준다. 덕분에 UI 전체 흐름은 실제 Electron에서 검증하면서 외부 네트워크에는 의존하지 않는다.</p>
<pre><code class="language-ts">await page.getByRole(&#39;button&#39;, { name: &#39;Research View 열기&#39; }).click();
await expect(page.getByRole(&#39;status&#39;)).toContainText(
  &#39;Research View가 오른쪽 영역에서 열려 있습니다.&#39;,
);

await page.getByRole(&#39;button&#39;, { name: &#39;현재 페이지 링크 삽입&#39; }).click();
await expect(editor).toHaveValue(
  &#39;# Start[Electron Security](https://www.electronjs.org/docs/latest/tutorial/security)&#39;,
);</code></pre>
<p>이번 변경 시점에 확인한 결과는 다음과 같다.</p>
<ul>
<li><code>npm run check</code>: 타입 검사, ESLint, Prettier, 단위·컴포넌트·Preload 계약 테스트 포함 57개 통과</li>
<li><code>npm run test:e2e</code>: Electron 기반 5개 시나리오 통과</li>
<li><code>npm run test:smoke</code>: Windows packaged 실행 파일 smoke 통과</li>
</ul>
<p>다만 E2E가 실제 Google UI를 자동으로 조작하지는 않는다. 첫 release 전에는 실제 앱에서 검색어 입력, 결과 페이지 이동, 링크 삽입, 작은 창 크기에서의 Research View bounds를 사람이 한 번 더 확인해야 한다. 자동화가 불안정한 외부 서비스를 흉내 내며 성공했다고 말하기보다, 결정적인 앱 경계는 자동화하고 실제 웹 호환성은 수동 검증 항목으로 분리했다.</p>
<h2 id="이번-선택으로-얻은-것과-남은-것">이번 선택으로 얻은 것과 남은 것</h2>
<p>이번 구현으로 얻은 것은 API key가 없는 검색 시작점, Dock 안에서 유지되는 문서 작성 맥락, 그리고 원격 웹 콘텐츠를 Dock Renderer에서 분리하는 경계다. 사용자는 검색 결과를 자유롭게 따라가되, Dock에 전달되는 값은 명시적 클릭 시점의 제목과 URL뿐이다.</p>
<p>반대로 Research View는 완성된 범용 브라우저가 아니다.</p>
<ul>
<li>로그인 상태는 영구 저장하지 않는다.</li>
<li>다운로드, popup, 알림·위치 같은 권한 요청은 지원하지 않는다.</li>
<li>탭, 방문 기록, 북마크, 개발자 도구, DOM 추출은 없다.</li>
<li>검색 엔진·사이트별 표시 품질은 실제 환경에서 계속 확인해야 한다.</li>
</ul>
<p>이 제한은 부족한 기능 목록이라기보다 MVP의 경계다. 지금 필요한 문제를 풀기 위한 최소 권한만 열어 두고, 더 큰 브라우저 기능이 정말 제품 가치가 있는지 증거가 생겼을 때 다시 설계하는 쪽을 선택했다.</p>
<p>다음 단계에서는 링크·이미지 흐름을 더 늘리기보다 M5 hardening으로 넘어간다. 핵심 사용자 여정 회귀, 접근성, 대용량 Markdown, 플랫폼별 package 검증, 라이선스와 알려진 제한 정리가 남아 있다. Research View도 그 과정에서 실제 사용 흐름이 충분히 좋아졌는지 다시 평가할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (13) - Main에서 검증하는 이미지 다운로드/삽입]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-13-Main%EC%97%90%EC%84%9C-%EA%B2%80%EC%A6%9D%ED%95%98%EB%8A%94-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C%EC%82%BD%EC%9E%85</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-13-Main%EC%97%90%EC%84%9C-%EA%B2%80%EC%A6%9D%ED%95%98%EB%8A%94-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C%EC%82%BD%EC%9E%85</guid>
            <pubDate>Thu, 20 Aug 2026 16:34:03 GMT</pubDate>
            <description><![CDATA[<p>이번 단계에서는 <code>/image</code> 검색 결과를 문서에 넣는 과정을 마무리했다. 검색 결과를 고르는 것과 실제 파일을 저장하는 것은 서로 다른 책임이기 때문에, Renderer에서 바로 Markdown을 만들지 않고 Main의 다운로드 과정을 거치도록 구성했다.</p>
<h2 id="선택한-이미지를-바로-문서에-넣지-않은-이유">선택한 이미지를 바로 문서에 넣지 않은 이유</h2>
<p>이미지 결과를 선택했다고 바로 Markdown을 삽입하면, 다운로드가 실패했을 때 깨진 경로가 문서에 남는다. 그래서 선택 후 별도의 다운로드 동작을 두고, 파일 저장이 성공한 뒤에만 상대 경로를 삽입하도록 했다.</p>
<pre><code class="language-text">이미지 선택
→ Main image:download IPC
→ HTTPS·host·redirect 검증
→ MIME·magic bytes·크기 검증
→ assets/에 충돌 없는 파일명으로 저장
→ 상대 경로 Markdown 삽입</code></pre>
<p>Renderer에는 <code>fs</code>나 네트워크 권한을 열지 않고, IPC를 통해 저장을 요청한 뒤 결과로 받은 상대 경로만 사용한다. 다운로드가 실패하면 문서 상태를 변경하지 않으며, 성공했을 때만 현재 선택 영역을 Markdown 이미지 문법으로 치환한다.</p>
<pre><code class="language-ts">const response = await window.dock.image.download({
  workspaceId,
  relativePath: selectedPath,
  image: selectedImage,
});

if (response.ok === false) {
  setImageStatus(&#39;error&#39;);
  return;
}

const nextContent = insertMarkdownImage(
  content,
  altText,
  response.value.assetPath,
  selection.start,
  selection.end,
);
setContent(nextContent);</code></pre>
<p>이렇게 하면 네트워크 오류나 저장 오류가 발생해도 문서에는 아무 변화가 없다. 파일 저장은 Main에서 임시 파일을 거쳐 hard link로 확정하므로, 같은 이름의 기존 asset을 덮어쓰지 않는다.</p>
<h2 id="다운로드-검증-기준">다운로드 검증 기준</h2>
<p>ADR-0010 승인 기준에 따라 다음을 적용했다.</p>
<ul>
<li>HTTPS와 provider host allowlist</li>
<li>최대 3회 redirect, 각 redirect URL 재검증</li>
<li>10초 timeout과 10MiB 응답 제한</li>
<li>PNG/JPEG/WebP Content-Type과 magic bytes 일치</li>
<li>제목 기반 안전한 slug와 <code>-2</code>, <code>-3</code> 충돌 suffix</li>
<li>실패 시 임시 파일 정리와 문서 불변</li>
</ul>
<p>현재 검색 provider는 deterministic mock이다. 실제 이미지 provider와 attribution/license 정책은 별도 ADR에서 확정한다.</p>
<h2 id="renderer의-선택-결과를-main에서-다시-검증하기">Renderer의 선택 결과를 Main에서 다시 검증하기</h2>
<p>Renderer가 보낸 결과를 그대로 신뢰하지 않고, IPC handler를 거쳐 Main service에서 scheme과 host를 다시 확인한다.</p>
<pre><code class="language-ts">const validateDownloadUrl = (
  value: string,
  allowedHosts: ReadonlySet&lt;string&gt;,
): URL =&gt; {
  const url = new URL(value);

  if (
    url.protocol !== &#39;https:&#39; ||
    url.port !== &#39;&#39; ||
    !allowedHosts.has(url.hostname.toLowerCase())
  ) {
    throw new ImageDownloadServiceError(
      &#39;IMAGE_UNSUPPORTED&#39;,
      &#39;The image download URL is not allowed.&#39;,
    );
  }
  return url;
};</code></pre>
<p>핵심은 URL을 Renderer에서 한 번 검사했다는 사실을 권한으로 사용하지 않는 것이다. redirect로 이동한 URL도 같은 검증을 다시 통과해야 한다.</p>
<h2 id="content-type만으로-파일을-판단하지-않기">Content-Type만으로 파일을 판단하지 않기</h2>
<p>URL 확장자나 <code>Content-Type</code>만 믿으면 텍스트·스크립트·위장 파일이 이미지처럼 저장될 수 있다. 응답을 읽은 뒤 선언된 MIME과 실제 파일 앞부분의 magic bytes를 함께 비교했다.</p>
<pre><code class="language-ts">const mimeType = response.headers
  .get(&#39;content-type&#39;)
  ?.split(&#39;;&#39;, 1)[0]
  .trim()
  .toLowerCase();

const bytes = await readResponseBytes(response);

if (
  mimeType !== &#39;image/png&#39; ||
  !Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).equals(
    bytes.subarray(0, 8),
  )
) {
  throw new ImageDownloadServiceError(
    &#39;IMAGE_UNSUPPORTED&#39;,
    &#39;The image bytes do not match the declared format.&#39;,
  );
}</code></pre>
<p>실제 구현에서는 PNG, JPEG, WebP만 허용하고 각 포맷의 magic bytes를 확인한다.</p>
<h2 id="검증">검증</h2>
<ul>
<li>Main download service: 정상 저장, 충돌 파일명, redirect, host, MIME, magic bytes, 크기 테스트</li>
<li>IPC handler와 Preload 계약 테스트</li>
<li>Renderer: alt text 수정 및 저장 성공 후 Markdown 삽입</li>
<li><code>npm run check</code> 52개 테스트</li>
<li><code>npm run test:e2e</code> 4개 시나리오</li>
<li><code>npm run test:smoke</code> Windows packaged smoke</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (12) - 이미지 검색 mock과 다운로드 전 경계]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-12-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EA%B2%80%EC%83%89-mock%EA%B3%BC-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-%EC%A0%84-%EA%B2%BD%EA%B3%84</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-12-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EA%B2%80%EC%83%89-mock%EA%B3%BC-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-%EC%A0%84-%EA%B2%BD%EA%B3%84</guid>
            <pubDate>Thu, 20 Aug 2026 15:44:17 GMT</pubDate>
            <description><![CDATA[<h2 id="이번-작업의-범위">이번 작업의 범위</h2>
<p><code>/image</code> 전체 로직을 한 번에 구현하지 않고, 먼저 검색 결과를 보여 주고 사용자가 선택하는 경계까지 만들었다.</p>
<pre><code class="language-text">/image 실행
→ 검색어 입력
→ 이미지 결과 표시
→ 결과 선택
→ 다운로드는 다음 단계</code></pre>
<p>이미지 결과는 제목, 출처 페이지, thumbnail URL, download URL, 라이선스 정보를 가질 수 있는 계약으로 정리했다. 현재는 deterministic mock provider만 사용하므로 네트워크 요청은 발생하지 않는다.</p>
<h2 id="선택했다고-바로-markdown을-바꾸지-않는-이유">선택했다고 바로 Markdown을 바꾸지 않는 이유</h2>
<p>링크는 URL만 검증하면 삽입할 수 있지만, 이미지는 먼저 실제 파일을 안전하게 다운로드하고 저장해야 한다. 따라서 선택 시점에는 문서에 아무것도 삽입하지 않고 선택 상태만 표시한다.</p>
<p>다음 단계의 Main download service에서 아래를 검증한 뒤 저장 성공 시에만 상대 경로 Markdown을 삽입한다.</p>
<ul>
<li>허용된 HTTPS download URL</li>
<li>HTTP 상태와 Content-Type</li>
<li>이미지 magic bytes와 선언 MIME 일치</li>
<li>최대 크기와 timeout</li>
<li>redirect 및 내부 네트워크 접근</li>
<li><code>assets/</code> 안의 충돌 없는 파일명</li>
</ul>
<h2 id="검증">검증</h2>
<ul>
<li>이미지 mock provider 정상·결과 없음·오류 단위 테스트</li>
<li><code>/image</code> Renderer 검색·선택 component test</li>
<li>이미지 선택 후 원문 불변 Electron E2E</li>
<li><code>npm run check</code> 43개 테스트</li>
<li><code>npm run test:e2e</code> 4개 시나리오</li>
<li><code>npm run test:smoke</code> Windows packaged smoke</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (11) - 로컬 문서 링크와 안전한 Markdown 미리보기, 실제 link 연동]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-11-%EB%A1%9C%EC%BB%AC-%EB%AC%B8%EC%84%9C-%EB%A7%81%ED%81%AC%EC%99%80-%EC%95%88%EC%A0%84%ED%95%9C-Markdown-%EB%AF%B8%EB%A6%AC%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-11-%EB%A1%9C%EC%BB%AC-%EB%AC%B8%EC%84%9C-%EB%A7%81%ED%81%AC%EC%99%80-%EC%95%88%EC%A0%84%ED%95%9C-Markdown-%EB%AF%B8%EB%A6%AC%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Thu, 20 Aug 2026 02:05:35 GMT</pubDate>
            <description><![CDATA[<h2 id="미리보기는-왜-바로-html로-만들면-안-될까">미리보기는 왜 바로 HTML로 만들면 안 될까?</h2>
<p>Markdown 파일은 내 컴퓨터에서 만든 문서일 수도 있지만, 외부에서 내려받았거나 다른 사람이 공유한 파일일 수도 있다. Markdown 안의 HTML과 링크를 그대로 화면에 넣으면 문서가 실행 가능한 입력이 될 수 있다.</p>
<p>이번 단계에서는 Markdown을 HTML로 변환하는 parser와 최종 HTML에서 위험한 요소를 제거하는 sanitizer를 분리했다.</p>
<h2 id="적용한-기준">적용한 기준</h2>
<ul>
<li>제목, 문단, 목록, 인용, 코드 블록, 링크만 기본 지원</li>
<li>raw HTML 제거</li>
<li>이미지와 외부 리소스는 렌더링하지 않음</li>
<li><code>http</code>와 <code>https</code> 외부 링크만 허용</li>
<li><code>file:</code>, <code>javascript:</code>, <code>data:</code> 링크는 실행하지 않음</li>
</ul>
<h2 id="로컬-문서-링크는-어떻게-지원할까">로컬 문서 링크는 어떻게 지원할까?</h2>
<p>로컬 문서 간 이동 자체는 필요한 기능이다. 다만 운영체제 파일 URL을 직접 실행하지 않는다.</p>
<pre><code class="language-md">[설계 문서](./design.md)</code></pre>
<p>링크를 누르면 Renderer가 상대 경로를 추출하고, 현재 workspace 권한과 함께 Main에 요청한다. Main은 workspace 내부의 Markdown 파일인지 다시 검증한 뒤 Dock 편집기로 문서를 연다.</p>
<p>이렇게 하면 문서 간 이동은 가능하면서도 임의의 로컬 파일 경로를 실행하지 않게 된다.</p>
<h2 id="검증한-입력">검증한 입력</h2>
<ul>
<li>raw <code>&lt;script&gt;</code> HTML</li>
<li><code>file:</code> 링크</li>
<li><code>javascript:</code> 링크</li>
<li>workspace 상대 Markdown 링크</li>
<li>기본 Markdown 제목과 강조 문법</li>
</ul>
<p>미리보기 결과는 sanitizer를 통과한 HTML만 화면에 표시하고, 로컬 문서 링크에는 Dock 전용 속성을 붙여 내부 열기 흐름으로 전환한다.</p>
<h2 id="현재까지의-검증">현재까지의 검증</h2>
<ul>
<li>전체 단위 테스트 40개 통과</li>
<li>TypeScript, ESLint, Prettier 검사 통과</li>
<li>Windows x64 패키징 통과</li>
<li>Playwright Electron 보안·mock 흐름 E2E 3개 통과</li>
<li>패키징 산출물 smoke 통과</li>
</ul>
<p>새 문서 생성 UX와 저장 상태 안내는 후속 M2 작업에서 완료했다. <code>/link</code> mock 검색·결과 선택·Markdown 삽입과 Electron E2E 이후, ADR-0009를 승인하고 Brave Search API를 실제 공급자로 연결했다.</p>
<h2 id="실제-검색-공급자는-어디에서-호출할까">실제 검색 공급자는 어디에서 호출할까?</h2>
<p>Renderer가 검색 API를 직접 호출하면 API key와 원격 응답을 다루는 경계가 넓어진다. 그래서 흐름을 다음처럼 고정했다.</p>
<pre><code class="language-text">검색어 + 사용자가 입력한 key
→ 검증된 Preload IPC
→ Main의 Brave Search 요청
→ URL·응답 구조 검증
→ 제목·URL·출처만 Renderer로 반환</code></pre>
<p>현재 문서 내용이나 저장되지 않은 편집 내용은 검색 요청에 포함하지 않는다. key도 저장하지 않고 command palette가 닫힐 때 화면의 임시 상태를 지운다. Brave 응답은 timeout, redirect, 크기, HTTP 상태, JSON 구조를 확인한 뒤 <code>http</code>와 <code>https</code> URL만 결과로 사용한다.</p>
<p>실제 API key가 없는 환경에서는 네트워크 smoke를 실행하지 않고, provider service와 IPC handler는 deterministic 테스트 double로 검증한다. 외부 프로그램과의 파일 충돌 처리는 별도 ADR로 남긴다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (10) - 로컬 Markdown 파일을 안전하게 연결한 방법]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-10-%EB%A1%9C%EC%BB%AC-Markdown-%ED%8C%8C%EC%9D%BC%EC%9D%84-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%97%B0%EA%B2%B0%ED%95%9C-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-10-%EB%A1%9C%EC%BB%AC-Markdown-%ED%8C%8C%EC%9D%BC%EC%9D%84-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%97%B0%EA%B2%B0%ED%95%9C-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Tue, 18 Aug 2026 18:49:38 GMT</pubDate>
            <description><![CDATA[<h2 id="이번-글에서-만든-것">이번 글에서 만든 것</h2>
<p>이번 단계에서는 Dock 화면에 실제 로컬 문서 폴더를 연결했다.</p>
<p>사용자는 폴더를 선택하고, Markdown 파일 목록을 확인하고, 문서를 열어 편집한 뒤 저장할 수 있다. 다만 파일 시스템을 Renderer에 직접 열어 주지는 않았다. 파일 권한은 Main 프로세스가 가지고, 화면은 필요한 기능만 좁은 API로 요청한다.</p>
<h2 id="왜-폴더-경로를-그대로-넘기지-않았을까">왜 폴더 경로를 그대로 넘기지 않았을까?</h2>
<p>Renderer는 화면을 그리는 영역이다. 여기에 절대 경로와 파일 시스템 객체를 그대로 노출하면 화면 코드의 실수나 악성 입력이 곧바로 로컬 파일 접근으로 이어질 수 있다.</p>
<p>그래서 폴더를 선택하면 Main이 임의의 <code>workspaceId</code>를 만들고 실제 경로와 연결한다. Renderer는 이 ID와 <code>notes/today.md</code> 같은 상대 경로만 보낸다. 실제 폴더 경로는 Renderer에 전달하지 않는다.</p>
<h2 id="파일-요청은-어떻게-검증할까">파일 요청은 어떻게 검증할까?</h2>
<p>요청이 Main에 도착하면 다음을 차례로 확인한다.</p>
<ul>
<li>요청을 보낸 화면이 우리가 허용한 Renderer인지 확인한다.</li>
<li>workspaceId가 현재 앱 실행 중 선택된 폴더인지 확인한다.</li>
<li>절대 경로, 드라이브 경로, <code>..</code>, null byte를 거부한다.</li>
<li>최종 경로가 workspace 밖으로 나가지 않는지 확인한다.</li>
<li>symbolic link를 따라간 실제 경로도 workspace 안인지 확인한다.</li>
<li>확장자는 <code>.md</code>와 <code>.markdown</code>만 허용한다.</li>
</ul>
<p>이 검사를 통과한 뒤에만 파일을 읽거나 저장한다.</p>
<h2 id="저장-중-문제가-생기면-기존-파일은-어떻게-될까">저장 중 문제가 생기면 기존 파일은 어떻게 될까?</h2>
<p>기존 파일을 바로 덮어쓰면 저장 도중 오류가 났을 때 문서가 깨질 수 있다. 그래서 새 임시 파일에 먼저 내용을 기록하고 flush한 다음, 같은 폴더 안에서 교체한다.</p>
<p>이 방식은 저장에 실패해도 기존 파일을 남겨 둔다는 장점이 있다. M2에서는 외부 프로그램이 동시에 파일을 수정했는지 감지하는 기능까지 넣지 않았다. 그 문제는 충돌 표시와 복구 UX를 함께 설계하는 별도 단계로 남겼다.</p>
<h2 id="화면에서-실제로-연결한-흐름">화면에서 실제로 연결한 흐름</h2>
<ol>
<li>폴더 선택 버튼을 누른다.</li>
<li>Main의 폴더 선택 dialog가 열린다.</li>
<li>선택된 폴더의 표시 이름과 Markdown 목록만 화면에 나타난다.</li>
<li>파일을 누르면 Main이 내용을 읽어 편집기에 전달한다.</li>
<li>편집 후 저장 버튼을 누르면 Main이 원자적으로 저장한다.</li>
<li>미리보기는 아직 HTML로 렌더링하지 않고 안전한 일반 텍스트로 표시한다.</li>
</ol>
<h2 id="검증-결과">검증 결과</h2>
<ul>
<li>경로 계약과 파일 서비스 단위 테스트</li>
<li>IPC handler 계약 테스트</li>
<li>TypeScript, ESLint, Prettier 검사</li>
<li>Windows x64 패키징</li>
<li>Playwright Electron 보안 E2E</li>
<li>패키징 산출물 기동 smoke</li>
</ul>
<p>전체 검증을 통과한 뒤 M2 핵심 흐름을 커밋했다. 다음 단계에서는 새 문서 생성 UX, 저장 상태와 오류 안내를 다듬고, 외부 파일 변경 감지 여부를 별도 설계한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (9) - 파일 기능보다 먼저 Application Shell을 만든 이유]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-9-%ED%8C%8C%EC%9D%BC-%EA%B8%B0%EB%8A%A5%EB%B3%B4%EB%8B%A4-%EB%A8%BC%EC%A0%80-Application-Shell%EC%9D%84-%EB%A7%8C%EB%93%A0-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-9-%ED%8C%8C%EC%9D%BC-%EA%B8%B0%EB%8A%A5%EB%B3%B4%EB%8B%A4-%EB%A8%BC%EC%A0%80-Application-Shell%EC%9D%84-%EB%A7%8C%EB%93%A0-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Tue, 18 Aug 2026 08:22:13 GMT</pubDate>
            <description><![CDATA[<p>Dock에 파일 열기나 Markdown 편집 기능을 바로 붙일 수도 있었다. 하지만 그 전에 사용자가 어떤 흐름으로 문서를 찾고, 쓰고, 미리보고, 명령을 실행하는지를 담을 화면 구조가 필요했다. 그래서 M1에서는 권한을 추가하지 않고 Application Shell부터 만들었다.</p>
<h2 id="이번-단계에서-만든-화면">이번 단계에서 만든 화면</h2>
<p>화면은 다섯 영역으로 나뉜다.</p>
<pre><code class="language-text">상단: Dock 이름과 명령 팔레트 진입점
왼쪽: document workspace와 문서 목록 자리
가운데: Markdown editor 자리
오른쪽 또는 아래: preview 자리
하단: 현재 문서 상태</code></pre>
<p>아직 document workspace를 실제로 선택하지는 않는다. 폴더 선택 버튼과 문서 목록은 다음 단계의 파일 권한 기능이 들어갈 자리다. 이 단계에서 버튼에 파일 시스템 권한을 미리 연결하지 않은 이유는, 화면 작업과 권한 작업을 한 번에 섞지 않기 위해서다.</p>
<h2 id="빈-화면도-제품-경험이다">빈 화면도 제품 경험이다</h2>
<p>초기 앱에서 가장 먼저 보는 상태는 문서가 없는 상태다. 단순히 빈 공간만 보이면 사용자는 다음 행동을 알기 어렵다.</p>
<p>그래서 기본 화면에는 다음 안내를 넣었다.</p>
<ul>
<li>문서 폴더를 선택하면 Markdown 파일이 표시된다는 설명</li>
<li>문서를 열면 preview가 나타난다는 설명</li>
<li>로컬 파일 기능은 다음 단계에서 연결된다는 상태 표시</li>
</ul>
<p>문서 목록을 불러오는 중인 상태와 오류 상태도 별도 메시지로 만들었다. 실제 파일 읽기는 아직 없지만, 다음 단계에서 같은 자리에 상태를 연결할 수 있다.</p>
<h2 id="작은-창도-별도-화면으로-취급했다">작은 창도 별도 화면으로 취급했다</h2>
<p>넓은 창에서는 sidebar·editor·preview를 나란히 보이게 한다. 폭이 줄어들면 preview를 다음 행으로 내리고, 더 좁아지면 모든 영역을 한 열로 쌓는다.</p>
<p>이 선택은 정보를 숨기기보다 흐름을 유지하기 위한 것이다. 작은 창에서 editor가 너무 좁아지거나 영역이 겹치면 문서를 쓰는 앱으로 사용할 수 없기 때문이다.</p>
<h2 id="키보드-흐름을-같이-테스트했다">키보드 흐름을 같이 테스트했다</h2>
<p>개발자용 문서 도구는 마우스만으로 쓰는 앱이 아니다. 그래서 화면이 보이는지뿐 아니라 Tab 키의 이동 순서도 component test에 넣었다.</p>
<pre><code class="language-text">명령 팔레트 열기
→ 폴더 선택
→ Markdown 편집기</code></pre>
<p>또한 main landmark, 문서 sidebar, editor region, preview region의 accessible name과 empty/loading/error 상태를 테스트했다. CSS class나 DOM 깊이가 아니라 사용자와 보조 기술이 인식하는 구조를 기준으로 삼았다.</p>
<h2 id="확인한-결과">확인한 결과</h2>
<p>Windows x64에서 다음 자동 검증을 통과했다.</p>
<pre><code class="language-powershell">npm run check
npm run package
npm run test:e2e
npm run test:smoke</code></pre>
<p>개발용 Electron 창에서는 800px 폭과 480px 폭으로 레이아웃을 확인했다. 이후 production package도 직접 열어 화면 표시, 작은 창 레이아웃, Tab focus 순서가 정상임을 수동 확인했다.</p>
<h2 id="다음-단계">다음 단계</h2>
<p>다음 M2에서는 실제 document workspace를 선택하고 Markdown 파일 목록·열기·편집·저장을 연결한다. 이때는 파일 시스템 권한이 생기므로, 코드보다 먼저 document workspace authority와 IPC contract를 ADR로 확정할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Dock 개발 여정 (8) - 배포용 앱을 직접 실행해 본 이유]]></title>
            <link>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-8-%EB%B0%B0%ED%8F%AC%EC%9A%A9-%EC%95%B1%EC%9D%84-%EC%A7%81%EC%A0%91-%EC%8B%A4%ED%96%89%ED%95%B4-%EB%B3%B8-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@re_brother/TIL-Dock-%EA%B0%9C%EB%B0%9C-%EC%97%AC%EC%A0%95-8-%EB%B0%B0%ED%8F%AC%EC%9A%A9-%EC%95%B1%EC%9D%84-%EC%A7%81%EC%A0%91-%EC%8B%A4%ED%96%89%ED%95%B4-%EB%B3%B8-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Mon, 17 Aug 2026 10:05:51 GMT</pubDate>
            <description><![CDATA[<p>개발 모드에서 창이 열리고 E2E가 통과해도, 배포용 앱이 같은 방식으로 동작한다는 보장은 없다. Dock은 Vite로 Main·Preload·Renderer를 번들링하고 Electron Forge가 ASAR 기반 package를 만든다. 이 과정에서 경로나 preload, fuse가 달라질 수 있다.</p>
<p>그래서 F4의 마지막 단계로 packaged smoke를 추가했다. 목적은 기능을 더 만드는 것이 아니라, 실제 배포 산출물이 최소한 안전한 설정을 유지한 채 기동하는지 확인하는 것이다.</p>
<h2 id="개발용-e2e와-packaged-smoke는-다르다">개발용 E2E와 packaged smoke는 다르다</h2>
<p>Playwright E2E는 개발용 Electron을 제어한다. Main, Preload, Renderer의 연결과 <code>window.dock</code>의 좁은 API를 확인하기에 좋다.</p>
<p>하지만 배포용 Electron에는 production fuse가 적용된다. Dock은 <code>RunAsNode</code>, <code>NODE_OPTIONS</code>, Node inspect 인자를 끈다. 이 상태의 앱에 테스트 편의를 위해 inspect 기능을 다시 열어 주면, 배포 보안을 검증한다는 목적이 무너진다.</p>
<p>따라서 두 검증을 분리했다.</p>
<table>
<thead>
<tr>
<th>검증</th>
<th>실행 대상</th>
<th>확인하는 것</th>
</tr>
</thead>
<tbody><tr>
<td>Electron E2E</td>
<td>개발용 Electron</td>
<td>창·preload·IPC bridge·Renderer 격리</td>
</tr>
<tr>
<td>packaged smoke</td>
<td>실제 Forge package</td>
<td>실행 파일·fuse·기동과 종료</td>
</tr>
</tbody></table>
<h2 id="smoke가-확인하는-내용">smoke가 확인하는 내용</h2>
<p><code>npm run test:smoke</code>는 package를 새로 만든 뒤 Windows x64 실행 파일을 대상으로 다음을 자동 확인한다.</p>
<ol>
<li>예상한 실행 파일이 생성됐는지</li>
<li>ASAR 기반 package에 적용된 Electron Fuses V1 상태가 맞는지</li>
<li><code>RunAsNode</code>, Node options, Node inspect 인자가 비활성화됐는지</li>
<li>cookie encryption, ASAR integrity, ASAR only load가 활성화됐는지</li>
<li>실행 파일이 3초 안에 비정상 종료하지 않는지</li>
<li>smoke가 시작한 프로세스만 종료되는지</li>
</ol>
<p>마지막 항목은 중요하다. 테스트를 끝내기 위해 이름이 비슷한 다른 Electron 프로세스를 강제로 종료하면 안 된다. smoke는 자신이 시작한 PID만 종료한다.</p>
<h2 id="실제-결과">실제 결과</h2>
<p>Windows x64에서 다음 명령을 실행했다.</p>
<pre><code class="language-powershell">npm run check
npm run test:e2e
npm run test:smoke</code></pre>
<p>모두 통과했다. package 실행 파일은 <code>out/jarvis-dock-win32-x64/jarvis-dock.exe</code>에 생성됐고, production fuse 상태도 기대값과 일치했다.</p>
<h2 id="자동화하지-않은-것">자동화하지 않은 것</h2>
<p>production inspect fuse를 유지하는 대신, packaged app 내부의 화면 표시와 preload bridge를 자동화 도구로 들여다보지는 않는다. 이 두 항목은 릴리스 직전에 사람이 실행 파일을 열어 확인하는 수동 smoke로 남겼다.</p>
<p>또한 이번 결과는 Windows x64 기준이다. macOS와 Linux의 package smoke는 각 플랫폼 환경 또는 CI에서 별도로 실행해야 한다.</p>
<h2 id="f4를-마치며">F4를 마치며</h2>
<p>이제 Dock은 기능을 추가하기 전에 필요한 보안 경계와 테스트 계층을 갖췄다. 다음 M1에서는 파일 접근 권한을 늘리지 않은 채, sidebar·editor·preview·command·status 영역을 가진 Application Shell을 만든다.</p>
<p>기능을 빠르게 만드는 것보다, 나중에 기능이 들어갈 자리를 검증 가능한 형태로 먼저 만드는 편이 결국 수정 비용이 적다고 판단했다.</p>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://www.electronjs.org/docs/latest/tutorial/fuses">Electron Fuses</a></li>
<li><a href="https://www.electronforge.io/">Electron Forge</a></li>
</ul>
]]></description>
        </item>
    </channel>
</rss>