멀티 이슈 윈도우
여러 이슈를 OS 바탕화면처럼 동시에 띄워 비교하고 작업합니다.
이슈 한 건이 한 윈도우입니다. 대시보드 위에 떠있는 작은 창을 여러 개 띄워 두고, OS 바탕화면을 다루듯 옮기고 겹치고 최소화합니다. 두 이슈의 스크린샷을 나란히 놓고 비교하거나, 작업 중인 이슈를 최소화한 채 다른 이슈를 빠르게 훑는 흐름이 자연스럽게 만들어집니다.
OS 바탕화면 메타포
윈도우 모델은 의도적으로 macOS / Windows 의 윈도우 매니저를 흉내냅니다. 사용자가 이미 알고 있는 인터랙션 — 타이틀바를 잡고 드래그, 모서리를 잡고 리사이즈, 더블클릭으로 최대화 — 을 그대로 재사용해, 학습 비용 없이 멀티 이슈 작업이 가능합니다.
| OS 동작 | QA Note 멀티 윈도우 |
|---|---|
| 타이틀바 드래그 | 윈도우 위치 이동 |
| 모서리 드래그 | 윈도우 크기 조정 |
| 타이틀바 더블클릭 | 최대화 ↔ 복원 |
| 화면 가장자리 드래그 | 좌반·우반·전체로 스냅 |
윈도우끼리 z-order 가 있어, 클릭한 윈도우가 자동으로 맨 위로 올라옵니다.
타이틀바 액션
각 윈도우의 타이틀바에는 4 개 버튼이 있습니다.
| 버튼 | 아이콘 | 단축키 | 동작 |
|---|---|---|---|
| 최소화 | _ | Cmd/Ctrl+M | 윈도우를 하단 트레이로 접음 |
| 최대화 | □ | Cmd/Ctrl+Shift+F | 화면 전체로 확장 (다시 누르면 복원) |
| 전용 페이지로 이동 | ↗ | Cmd/Ctrl+O | /issues/[id] 풀스크린으로 전환 |
| 닫기 | × | Cmd/Ctrl+W | 윈도우 제거 (이슈 자체는 보존) |
단축키는 포커스가 있는 윈도우에만 적용됩니다.
전용 페이지 이동
윈도우는 작은 창이라 데이터가 압축되어 표시됩니다. 깊게 파고들어야 할 때는 타이틀바의 ↗ 로 풀스크린 페이지(/issues/[id])로 전환합니다. URL 이 바뀌므로 그대로 복사해 동료에게 공유 가능합니다.
반대로 풀스크린 페이지에서 "윈도우로 빼기" 버튼을 누르면 다시 윈도우로 돌아갑니다. 두 모드는 같은 데이터를 공유하므로, 작성 중인 코멘트 등은 모드 간 전환에서 보존됩니다.
드래그 도킹
윈도우를 화면 가장자리로 드래그하면 자동으로 영역에 스냅됩니다. macOS 의 윈도우 스냅 (Magnet · Rectangle 류) 과 같은 동작입니다.
| 드래그 위치 | 스냅 결과 |
|---|---|
| 좌측 가장자리 | 화면 좌반 (50%) |
| 우측 가장자리 | 화면 우반 (50%) |
| 상단 가장자리 | 화면 전체 (최대화) |
| 좌상·우상 모서리 | 1/4 분할 (좌상·우상) |
스냅된 윈도우를 다시 드래그하면 원래 크기로 복원됩니다.
Hydration mismatch 해결
윈도우의 위치·크기·열림 상태는 localStorage 에 저장됩니다. Next.js App Router 의 SSR 단계에서는 localStorage 에 접근할 수 없기 때문에, 서버 렌더 결과와 클라이언트 첫 렌더가 어긋나면 hydration mismatch 가 발생합니다.
해결 패턴은 첫 렌더는 항상 빈 상태로, useEffect 안에서 복원 입니다.
"use client";
import { useEffect, useState } from "react";
type WindowState = { id: string; x: number; y: number; w: number; h: number };
export function MultiWindowShell() {
const [windows, setWindows] = useState<WindowState[]>([]);
// 첫 client render 후에만 localStorage 에서 복원 → hydration mismatch 방지
useEffect(() => {
const stored = localStorage.getItem("qanote:windows");
if (stored) setWindows(JSON.parse(stored));
}, []);
// 변경 시 저장
useEffect(() => {
localStorage.setItem("qanote:windows", JSON.stringify(windows));
}, [windows]);
return (
<div className="multi-window-shell">
{windows.map((w) => (
<IssueWindow key={w.id} state={w} />
))}
</div>
);
}
서버 렌더 결과는 windows = [] 로 고정되고, 클라이언트가 hydration 직후 useEffect 가 한 번 돌면서 실제 상태를 채웁니다. 사용자는 "처음 들어왔을 때 윈도우가 한 박자 늦게 나타난다" 정도의 미세한 깜빡임을 보지만, hydration 경고는 발생하지 않습니다.
같은 이유로 next/dynamic 의 ssr: false 옵션으로 윈도우 셸 자체를 클라이언트 전용 컴포넌트로 만들어도 됩니다. 어느 쪽이든 핵심은 서버와 클라이언트 첫 렌더가 동일 하게 만드는 것입니다.
제한 사항
- 최대 동시 열림 수: 8 개. 초과 시 가장 오래된 윈도우가 자동으로 트레이로 접힙니다.
- 모바일 미지원: 뷰포트 폭 768px 이하에서는 풀스크린 페이지로 강제 전환됩니다. 작은 화면에서는 윈도우가 의미가 없습니다.
- 세션 간 복원 범위: localStorage 에 저장되므로 같은 브라우저 · 같은 도메인에서만 복원됩니다. 시크릿 모드 · 다른 브라우저에서는 빈 상태로 시작합니다.
- 윈도우 크기 하한: 320×240. 그 아래로는 리사이즈되지 않습니다.