[TIL] Dock 개발 여정 (14) - 검색 API 대신 Research View를 선택한 이유

RE_BROTHER·2026년 8월 21일

dev-dock

목록 보기
15/28

/link 기능의 목표는 단순하다. Markdown 문서를 작성하다가 자료를 찾고, 확인한 페이지를 안전한 Markdown 링크로 넣는 것이다.

하지만 “검색 결과를 가져온다”는 구현 선택은 전혀 단순하지 않았다.

API key를 누가 보관할지, 검색 결과 HTML을 어디에서 실행할지, Renderer에 어느 정도 권한을 줄지, 사용자가 실제로 문서를 쓰는 흐름이 좋아지는지까지 함께 결정해야 했다.

이번 글에서는 처음 검토했던 Brave Search API, 대안으로 바꿨던 시스템 브라우저 검색, 최종적으로 앱 안에 넣은 Research View까지의 판단 과정을 정리한다. 결론부터 말하면, Dock의 /link는 검색 API도 브라우저 자동화도 아닌 Main 프로세스가 소유하는 격리된 WebContentsView로 자료를 열고, 사용자가 누른 순간의 현재 페이지 제목과 URL만 검증해 Markdown에 삽입하는 방식이 됐다.


첫번째 문제: 검색 기능이 아니라 문서 작성 흐름

기술 문서를 작성할 때 링크를 넣는 과정은 보통 다음과 같다.

문서 작성
→ 브라우저로 이동
→ 검색
→ 결과 페이지 확인
→ 제목·URL 복사
→ 에디터로 복귀
→ Markdown 문법으로 정리

이 과정에서 불편한 부분은 Google 결과를 표시하지 못하는 데만 있지 않다. 작성 중이던 문서와 검색 맥락이 끊기고, URL과 제목을 손으로 조합하는 과정에서 누락·오타·깨진 Markdown이 생긴다.

Dock은 로컬 Markdown 문서를 다루는 데스크톱 앱이다. 그래서 /link의 성공 기준을 “검색 결과 목록을 예쁘게 보여 주는가”가 아니라 다음처럼 잡았다.

사용자가 문서를 떠나지 않고 자료를 확인한다.
→ 원하는 페이지를 직접 선택한다.
→ 선택한 페이지의 제목과 URL을 검증한다.
→ 현재 커서 또는 선택 영역에 안전한 Markdown 링크를 삽입한다.

여기서 중요한 전제는 검색 결과와 원격 웹페이지가 신뢰할 수 없는 입력이라는 점이다. Dock의 Renderer는 로컬 문서와 편집 상태를 가진 영역이므로, 원격 HTML을 이곳에서 실행하거나 Renderer에 브라우저·파일 시스템 권한을 주는 방식은 처음부터 제외했다.

Solution A. Brave Search API

처음에는 Main 프로세스에서 Brave Search API를 호출하는 방식으로 /link를 구성했다. Renderer는 검색어와 사용자가 입력한 API key만 좁은 IPC로 전달하고, 실제 HTTP 요청·응답 검증·오류 변환은 Main이 맡는 구조였다.

이 접근에는 분명한 장점이 있었다.

  • 결과를 구조화된 JSON으로 받을 수 있다.
  • 응답 형식을 통제하기 쉽다.
  • 검색 결과를 Dock UI에 바로 그릴 수 있다.
  • 검색 provider를 바꾸더라도 service 경계를 유지할 수 있다.

하지만 데스크톱 앱 배포 관점에서 API key 문제가 남았다. 프로젝트 소유 key를 Electron 앱에 넣으면 패키지에서 추출될 수 있으므로 비밀이라고 볼 수 없다. 그렇다고 일반 사용자에게 각자 key를 발급받으라고 요구하면, 링크 하나 넣는 기능의 진입 장벽이 너무 높아진다.

별도 백엔드나 프록시를 두면 key는 보호할 수 있다. 그러나 그 순간부터 운영 비용, 호출량 제한, abuse 대응, 개인정보와 로그 정책, 장애 대응이 제품 범위에 들어온다. 로컬 Markdown 작성 도구의 MVP에 검색 서비스 운영까지 얹는 선택은 과했다.

그래서 Brave API는 한때 실제 구현까지 됐지만, 프로젝트 방향에서 제외했다. API 자체가 나쁜 선택이라서가 아니라 이 제품의 배포·운영 모델과 맞지 않았기 때문이다.

Solution B. 시스템 브라우저와 직접 입력

API key 문제를 피하는 가장 단순한 방법은 기본 브라우저에 고정 Google 검색 URL을 여는 것이었다. Dock은 검색어만 Main에 전달하고, Main이 shell.openExternal()로 다음 형태의 URL을 실행한다.

https://www.google.com/search?q=...

이 방식은 key도 필요 없고, 검색 엔진의 화면과 계정 상태를 그대로 사용할 수 있다. 또한 검색 결과 HTML을 Dock 안에서 파싱할 필요도 없다.

그러나 제품 경험에는 중요한 빈틈이 있었다.

Dock → 시스템 브라우저 → Dock

사용자는 다시 창을 전환해야 했고, 페이지 제목과 URL도 별도 입력 칸에 복사해야 했다. 기능은 안전했지만 Dock이 줄이고자 했던 컨텍스트 전환을 충분히 줄이지 못했다.

이 시점에서 “앱 안에서 브라우저를 띄울 수 없는가?”라는 질문이 나왔다. 이 질문은 단순히 UI 영역 하나를 추가하는 문제가 아니었다. 원격 웹 콘텐츠를 Electron 앱 안에 넣는 순간, 프로세스 경계와 권한 모델을 다시 설계해야 했다.

검토한 대안과 제외 이유

1. 검색 결과 스크래핑

requests나 비동기 HTTP 클라이언트로 검색 엔진 결과 페이지를 받아 필요한 링크만 추출하는 방법도 생각할 수 있다. 하지만 검색 결과 HTML은 안정적인 API 계약이 아니다. 마크업 변경, 지역·언어·로그인 상태, CAPTCHA, 차단 정책에 쉽게 영향을 받는다. 제공자의 이용 약관과 자동화 허용 범위도 확인해야 한다.

무엇보다 HTML을 받아 파싱하는 구현은 “링크를 삽입한다”는 제품 기능에 비해 유지 비용이 크다. 페이지 DOM을 신뢰할 수 없는 입력으로 계속 다루면서도 검색 품질을 보장해야 하기 때문이다.

2. Google 검색 API

Google 검색 API도 본질적으로 API key, 비용·할당량, 백엔드 운영 여부 문제를 다시 만든다. provider만 바뀔 뿐 배포된 데스크톱 앱에 프로젝트 소유 key를 넣기 어렵다는 조건은 달라지지 않는다.

3. <webview> 태그

Electron에는 Renderer에서 사용할 수 있는 <webview>가 있지만, Dock의 보안 기준은 MVP에서 이를 사용하지 않도록 정하고 있다. 원격 콘텐츠와 앱 Renderer가 너무 가까워지는 구조는 경계를 이해하고 검증하기 어렵다. Dock의 Renderer는 편집기와 로컬 문서 상태를 담당하므로, 원격 페이지를 같은 Renderer 측 기능으로 취급하지 않기로 했다.

4. 브라우저 자동화·DOM 추출

원격 페이지의 DOM을 읽어 자동으로 제목·링크·본문을 추출하는 기능은 편리해 보인다. 하지만 페이지마다 동작이 다르고, 로그인 상태·동적 렌더링·이용 약관의 영향을 받는다. 또한 원격 DOM을 제품 기능의 입력으로 넓게 받아들이는 순간 검증 범위가 급격히 커진다.

현재 MVP에서 필요한 것은 “사용자가 보고 있는 페이지의 링크를 문서에 넣는 것”이다. 요약, 본문 추출, 자동 인용은 별도 문제로 남겨 두었다.

최종 선택: Main 소유 WebContentsView

최종 구현은 Main 프로세스가 WebContentsView를 생성하고 Dock 창의 오른쪽 영역에 붙이는 방식이다. 이를 Research View라고 부른다.

핵심은 “앱 안에서 보인다”와 “Renderer가 원격 페이지를 제어한다”를 같은 의미로 두지 않는 것이다. 화면상으로는 같은 창 안에 있지만, 원격 웹 콘텐츠의 생성·탐색·세션·수명은 Main이 관리한다. Renderer는 원격 DOM, 쿠키, 네트워크, Electron 객체에 접근할 수 없다.

이번 결정은 ADR-0013에 기록했다. 이전 Brave API 결정과 시스템 브라우저 결정은 삭제하지 않고 Superseded 상태로 남겼다. 기술 결정은 결과만 남기면 왜 다른 선택을 하지 않았는지 사라지기 때문에, 바뀐 경로도 함께 보존하는 편이 이후 판단에 도움이 된다.

사용자 흐름: 검색어와 현재 페이지 링크만 넘긴다

/link 명령을 열면 사용자는 검색어를 넣고 Research View 열기를 누른다. 이후 검색과 페이지 이동은 오른쪽 Research View에서 직접 한다. 원하는 자료를 찾은 뒤 헤더의 현재 페이지 링크 삽입을 누르면 Dock이 현재 URL과 페이지 제목을 받아 Markdown 링크를 만든다.

/link 검색어 입력
→ research:open
→ Main이 Google HTTPS 검색 URL 로드
→ Research View에서 사용자 탐색
→ 현재 페이지 링크 삽입
→ research:current-link
→ Main URL·제목 검증
→ Renderer가 Markdown escape 후 선택 영역 교체

Renderer에 제공하는 공개 API도 이 세 동작뿐이다.

research: {
  open: (request: { query: string }) => Promise<ResearchOpenResultEnvelope>;
  close: () => Promise<ResearchCloseResultEnvelope>;
  currentLink: () => Promise<ResearchCurrentLinkResultEnvelope>;
}

의도적으로 빠진 API가 더 중요하다. Renderer에는 다음을 제공하지 않는다.

  • 임의 URL을 Research View에 여는 API
  • 뒤로 가기·앞으로 가기·새 탭·다운로드 API
  • 페이지 HTML, DOM, cookie, session 조회 API
  • 범용 ipcRenderer, shell, fs 객체

이렇게 범위를 좁히면 Renderer가 침해되더라도 원격 브라우저를 범용 제어 도구로 사용하는 경로를 만들기 어렵다. 또한 UI 요구가 늘어날 때마다 어떤 권한이 실제로 필요한지 명시적으로 다시 검토할 수 있다.

검색 URL은 고정하고, 검색어만 데이터로 다룬다

검색 URL을 문자열 연결로 만들면 &, ?, 공백 같은 문자가 쉽게 깨지거나 의도하지 않은 query parameter가 섞일 수 있다. 그래서 base URL은 코드에 고정하고, 검색어는 URLSearchParams를 통해 넣었다.

const GOOGLE_SEARCH_URL = 'https://www.google.com/search';

export const createGoogleSearchUrl = (query: string): string => {
  const url = new URL(GOOGLE_SEARCH_URL);
  url.searchParams.set('q', query);
  return url.toString();
};

IPC 요청도 런타임에서 검사한다. TypeScript 타입만으로는 Renderer에서 넘어오는 런타임 데이터를 믿을 수 없기 때문이다.

export const ResearchOpenRequestSchema = z
  .object({ query: z.string().trim().min(1).max(200) })
  .strict();

빈 검색어, 지나치게 긴 값, 예상하지 못한 필드는 Main에 도달하기 전에 거부한다. handler는 추가로 sender frame URL을 검사해 승인된 Dock Renderer에서 온 요청만 처리한다.

ipcMain.handle(IPC.RESEARCH_OPEN, async (event, request) => {
  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 } };
});

이 검증은 “UI에서 이미 입력을 막았으니 충분하다”는 가정을 제거한다. 개발자 도구, 악성 스크립트, 버전 불일치 등으로 IPC가 예상과 다른 값으로 호출될 수 있다는 전제에서 Main을 최종 권한 경계로 둔다.

원격 콘텐츠 격리: 보안 옵션은 기본값에 맡기지 않는다

Research View는 로컬 문서를 다루는 Dock Renderer와 다른 보안 설정으로 생성한다.

export const createResearchWebPreferences = () => ({
  partition: 'dock-research',
  contextIsolation: true,
  nodeIntegration: false,
  sandbox: true,
  webSecurity: true,
});

각 옵션은 다음 역할을 한다.

  • partition: 'dock-research': persist: 접두어가 없는 메모리 세션. 앱 종료 후 cookie와 로그인 상태를 영구 저장하지 않는다.
  • contextIsolation: true: 원격 페이지의 JavaScript와 Electron 쪽 실행 환경을 분리한다.
  • nodeIntegration: false: 원격 페이지에서 Node.js API를 사용할 수 없게 한다.
  • sandbox: true: renderer process 권한을 더 줄인다.
  • webSecurity: true: 웹 보안 정책을 끄지 않는다.

Research View에는 preload도 넣지 않았다. 원격 페이지에 Dock 전용 API를 제공할 이유가 없기 때문이다. “페이지가 화면 안에 있다”는 사실은 권한을 전달하는 이유가 될 수 없다.

탐색은 허용하되, 권한·새 창·다운로드는 거부한다

검색 결과를 눌러 실제 페이지로 갈 수 있어야 하므로 http:와 https: 탐색과 redirect는 허용한다. 반대로 file:, javascript: 같은 scheme은 차단한다.

export const isAllowedResearchUrl = (url: string): boolean =>
  isAllowedLinkUrl(url); // http: 또는 https:만 true

webContents.on('will-navigate', (event, url) => {
  if (!isAllowedResearchUrl(url)) event.preventDefault();
});

webContents.on('will-redirect', (event, url) => {
  if (!isAllowedResearchUrl(url)) event.preventDefault();
});

외부 페이지가 권한 요청이나 popup, 파일 다운로드를 시도할 수도 있다. Research View의 목적은 자료를 읽고 링크를 선택하는 것이므로 이 동작은 필요하지 않다. 기본적으로 모두 거부했다.

webContents.session.setPermissionCheckHandler(() => false);
webContents.session.setPermissionRequestHandler(
  (_contents, _permission, callback) => callback(false),
);
webContents.session.on('will-download', (event) => event.preventDefault());
webContents.setWindowOpenHandler(() => ({ action: 'deny' }));

이 기준은 기능 제한이 아니라 책임 분리다. 다운로드가 필요하면 이미지 다운로드처럼 URL·redirect·MIME·크기·저장 경로를 검증하는 별도 흐름으로 설계해야 한다. popup이나 영구 로그인, 브라우저 기록이 필요해져도 지금의 작은 API에 임시로 덧붙이지 않고, 요구와 위험을 다시 ADR로 검토한다.

링크 삽입은 Renderer가 하되, 링크 데이터의 출처는 Main이다

현재 페이지 링크를 삽입할 때 Main은 View가 실제로 열려 있는지, webContents가 파괴되지 않았는지, URL이 허용된 scheme인지, 길이가 제한 안에 있는지 확인한다. 제목이 비어 있으면 hostname을 fallback으로 사용한다.

currentLink(): ResearchCurrentLink | undefined {
  const view = this.view;
  if (!view || view.webContents.isDestroyed()) return undefined;

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

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

  return title ? { title, url } : undefined;
}

Markdown 문자열 조합과 커서 위치 변경은 에디터 상태를 가진 Renderer의 책임이다. 다만 여기서도 받아 온 제목과 URL을 그대로 문자열로 붙이지 않고 기존 Markdown escape·URL 검증 함수를 거친다.

const result = await window.dock.research.currentLink();
if (result.ok === false) {
  setResearchError('현재 페이지 링크를 삽입할 수 없습니다.');
  return;
}

const markdown = formatMarkdownLink(result.value);
const nextContent = insertAtSelection(
  content,
  markdown,
  linkSelection.start,
  linkSelection.end,
);
setContent(nextContent);

이 분리는 꽤 중요하다. Main은 외부 웹과 Electron 객체를 가진 쪽에서 데이터의 출처를 확인하고, Renderer는 검증된 작은 데이터를 사용해 문서 편집이라는 UI 작업만 한다. 어느 한쪽에 두 책임을 몰아넣지 않는다.

View 수명 주기와 레이아웃

Research View는 창마다 하나만 둔다. 처음 열 때 만들고, 창 크기가 바뀌면 오른쪽 영역의 bounds를 다시 계산한다. 닫기 버튼을 누르거나 메인 창이 닫힐 때 child view를 분리하고 webContents도 종료한다.

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),
  });
}

Renderer DOM 안에 포함된 iframe처럼 보이지만, 실제 bounds 계산은 Main의 BrowserWindow content area를 기준으로 한다. 이 때문에 Main과 Renderer의 UI 구조가 서로 영향을 받는다. 지금은 헤더 높이를 상수로 두고 오른쪽 영역을 사용하지만, 앞으로 반응형 레이아웃이 크게 바뀌면 view bounds를 전달·계산하는 별도 계약도 검토해야 한다.

테스트: 실제 검색 엔진에 의존하지 않는 이유

Remote web search를 E2E의 핵심 성공 조건으로 두면 네트워크, 검색 엔진 화면 변경, 지역·로그인 상태 때문에 테스트가 쉽게 흔들린다. 그래서 테스트를 두 층으로 나눴다.

먼저 단위 테스트에서 고정 Google URL 구성, http/https navigation 경계, 격리 webPreferences를 확인한다.

expect(createResearchWebPreferences()).toEqual({
  partition: 'dock-research',
  contextIsolation: true,
  nodeIntegration: false,
  sandbox: true,
  webSecurity: true,
});

expect(isAllowedResearchUrl('https://example.com/docs')).toBe(true);
expect(isAllowedResearchUrl('file:///secret.txt')).toBe(false);
expect(isAllowedResearchUrl('javascript:alert(1)')).toBe(false);

그 다음 IPC handler test에서는 신뢰되지 않은 sender, 잘못된 요청, 닫힌 view, 정상 open/current-link/close를 검사한다. Preload test는 Renderer에 research라는 좁은 API만 생기고, 요청·응답이 schema를 통과하는지 확인한다.

Electron E2E에서는 실제 Google을 열지 않는 deterministic controller를 --dock-e2e-link 인자로 주입했다. 이 controller는 Research View가 열렸을 때만 고정된 안전한 페이지 정보를 돌려준다. 덕분에 UI 전체 흐름은 실제 Electron에서 검증하면서 외부 네트워크에는 의존하지 않는다.

await page.getByRole('button', { name: 'Research View 열기' }).click();
await expect(page.getByRole('status')).toContainText(
  'Research View가 오른쪽 영역에서 열려 있습니다.',
);

await page.getByRole('button', { name: '현재 페이지 링크 삽입' }).click();
await expect(editor).toHaveValue(
  '# Start[Electron Security](https://www.electronjs.org/docs/latest/tutorial/security)',
);

이번 변경 시점에 확인한 결과는 다음과 같다.

  • npm run check: 타입 검사, ESLint, Prettier, 단위·컴포넌트·Preload 계약 테스트 포함 57개 통과
  • npm run test:e2e: Electron 기반 5개 시나리오 통과
  • npm run test:smoke: Windows packaged 실행 파일 smoke 통과

다만 E2E가 실제 Google UI를 자동으로 조작하지는 않는다. 첫 release 전에는 실제 앱에서 검색어 입력, 결과 페이지 이동, 링크 삽입, 작은 창 크기에서의 Research View bounds를 사람이 한 번 더 확인해야 한다. 자동화가 불안정한 외부 서비스를 흉내 내며 성공했다고 말하기보다, 결정적인 앱 경계는 자동화하고 실제 웹 호환성은 수동 검증 항목으로 분리했다.

이번 선택으로 얻은 것과 남은 것

이번 구현으로 얻은 것은 API key가 없는 검색 시작점, Dock 안에서 유지되는 문서 작성 맥락, 그리고 원격 웹 콘텐츠를 Dock Renderer에서 분리하는 경계다. 사용자는 검색 결과를 자유롭게 따라가되, Dock에 전달되는 값은 명시적 클릭 시점의 제목과 URL뿐이다.

반대로 Research View는 완성된 범용 브라우저가 아니다.

  • 로그인 상태는 영구 저장하지 않는다.
  • 다운로드, popup, 알림·위치 같은 권한 요청은 지원하지 않는다.
  • 탭, 방문 기록, 북마크, 개발자 도구, DOM 추출은 없다.
  • 검색 엔진·사이트별 표시 품질은 실제 환경에서 계속 확인해야 한다.

이 제한은 부족한 기능 목록이라기보다 MVP의 경계다. 지금 필요한 문제를 풀기 위한 최소 권한만 열어 두고, 더 큰 브라우저 기능이 정말 제품 가치가 있는지 증거가 생겼을 때 다시 설계하는 쪽을 선택했다.

다음 단계에서는 링크·이미지 흐름을 더 늘리기보다 M5 hardening으로 넘어간다. 핵심 사용자 여정 회귀, 접근성, 대용량 Markdown, 플랫폼별 package 검증, 라이선스와 알려진 제한 정리가 남아 있다. Research View도 그 과정에서 실제 사용 흐름이 충분히 좋아졌는지 다시 평가할 예정이다.

profile
will be better

0개의 댓글