블로그: 브라우저 안에 OS를 만들었다 — 윈도우 매니저 구현
블로그: 브라우저 안에 OS를 만들었다에 대해 "왜?"라고 물으면 답할 수 있으신가요?
\n\n> 블로그: 브라우저 안에 OS를 만들었다에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n어느 순간부터 "그냥 블로그" 말고 뭔가 다른 걸 하고 싶다는 생각이 들었습니다.
터미널을 열고, 파일 탐색기를 띄우고, 게임도 하나 돌릴 수 있는 — 마치 데스크톱 OS처럼 동작하는 블로그를 만들면 재밌겠다 싶었습니다. 그러려면 먼저 윈도우 매니저 가 필요합니다.
설계: 뭐가 필요한가
윈도우 매니저가 하는 일을 정리하면:
- 윈도우 열기/닫기 — 터미널, 파일 탐색기, 게임 등
- z-index 관리 — 클릭한 윈도우가 맨 앞으로
- ** 드래그** — 타이틀바를 잡고 이동
- ** 리사이즈** — 모서리를 잡고 크기 변경
- ** 최소화/최대화** — 트레이로 내리기, 전체 화면
이걸 전역 상태로 관리해야 하니까, 상태 관리 도구가 필요합니다.
왜 Context + useReducer인가
Redux나 Zustand 같은 외부 라이브러리를 쓸까도 고민했는데, 윈도우 매니저의 상태는 결국 ** 열린 윈도우 목록과 각 윈도우의 좌표·크기** 정도입니다. 복잡한 비동기 로직도 없고, 미들웨어도 필요 없습니다.
React 내장 도구만으로 충분한 규모라서 Context + useReducer 조합을 선택했습니다.
상태 구조
const initialState = {
windows: [], // 열린 윈도우 목록
nextZIndex: 100, // 다음에 할당할 z-index
};
// 개별 윈도우 객체
{
id: 'terminal-1',
type: 'terminal', // terminal | fileExplorer | game
title: 'Terminal',
x: 100, y: 100, // 위치
width: 600, height: 400, // 크기
zIndex: 100,
minimized: false,
maximized: false,
meta: {}, // 윈도우별 추가 데이터
}
windows 배열에 열린 윈도우가 들어가고, 각 윈도우는 위치·크기·z-index를 가집니다. meta는 윈도우 타입에 따라 다른 데이터를 넣을 수 있는 자유 필드입니다. 게임 윈도우면 { game: 'snake' } 같은 걸 넣습니다.
Reducer
액션 타입은 직관적으로 정했습니다.
function desktopReducer(state, action) {
switch (action.type) {
case 'OPEN_WINDOW': {
const newWindow = {
...action.payload,
id: `${action.payload.type}-${Date.now()}`,
zIndex: state.nextZIndex,
};
return {
...state,
windows: [...state.windows, newWindow],
nextZIndex: state.nextZIndex + 1,
};
}
case 'CLOSE_WINDOW':
return {
...state,
windows: state.windows.filter(w => w.id !== action.payload),
};
case 'FOCUS_WINDOW':
return {
...state,
windows: state.windows.map(w =>
w.id === action.payload
? { ...w, zIndex: state.nextZIndex }
: w
),
nextZIndex: state.nextZIndex + 1,
};
case 'MOVE_WINDOW':
return {
...state,
windows: state.windows.map(w =>
w.id === action.payload.id
? { ...w, x: action.payload.x, y: action.payload.y }
: w
),
};
case 'RESIZE_WINDOW':
return {
...state,
windows: state.windows.map(w =>
w.id === action.payload.id
? { ...w, width: action.payload.width, height: action.payload.height }
: w
),
};
// MINIMIZE, MAXIMIZE 등...
}
}
FOCUS_WINDOW가 핵심입니다. 클릭한 윈도우의 z-index를 현재 최대값으로 올려서 맨 앞에 오게 합니다. nextZIndex를 계속 증가시키면 z-index 충돌이 없습니다.
Context Provider
const DesktopContext = createContext();
const DesktopProvider = ({ children }) => {
const [state, dispatch] = useReducer(desktopReducer, initialState);
const openWindow = useCallback((type, options = {}) => {
dispatch({ type: 'OPEN_WINDOW', payload: { type, ...options } });
}, []);
const closeWindow = useCallback((id) => {
dispatch({ type: 'CLOSE_WINDOW', payload: id });
}, []);
const focusWindow = useCallback((id) => {
dispatch({ type: 'FOCUS_WINDOW', payload: id });
}, []);
// ...
return (
<DesktopContext.Provider value={{
windows: state.windows,
openWindow,
closeWindow,
focusWindow,
// ...
}}>
{children}
</DesktopContext.Provider>
);
};
dispatch를 직접 노출하는 대신 openWindow, closeWindow 같은 의미 있는 함수로 감쌌습니다. 사용하는 쪽에서 액션 타입을 알 필요 없으니까요.
WindowShell: 공통 윈도우 컴포넌트
모든 윈도우는 타이틀바, 닫기 버튼, 드래그, 리사이즈 같은 공통 기능이 있습니다. 이걸 WindowShell이라는 wrapper 컴포넌트로 만들었습니다.
const WindowShell = ({ window, children }) => {
const { closeWindow, focusWindow } = useContext(DesktopContext);
return (
<div
className="window-shell"
style={{
left: window.x,
top: window.y,
width: window.width,
height: window.height,
zIndex: window.zIndex,
}}
onMouseDown={() => focusWindow(window.id)}
>
{/* 타이틀바 */}
<div className="window-titlebar" onMouseDown={startDrag}>
<span>{window.title}</span>
<div className="window-controls">
<button onClick={minimize}>—</button>
<button onClick={maximize}>□</button>
<button onClick={() => closeWindow(window.id)}>×</button>
</div>
</div>
{/* 콘텐츠 */}
<div className="window-body">
{children}
</div>
</div>
);
};
윈도우 아무 곳이나 클릭하면 focusWindow가 호출돼서 맨 앞으로 옵니다. 타이틀바를 드래그하면 MOVE_WINDOW 액션이 발생합니다.
드래그 구현
드래그는 mousedown → mousemove → mouseup 이벤트 조합으로 구현합니다.
const startDrag = (e) => {
const offsetX = e.clientX - window.x;
const offsetY = e.clientY - window.y;
const onMove = (e) => {
dispatch({
type: 'MOVE_WINDOW',
payload: {
id: window.id,
x: e.clientX - offsetX,
y: e.clientY - offsetY,
},
});
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
mousemove를 window가 아닌 document에 거는 게 중요합니다. 마우스가 빠르게 움직이면 윈도우 요소 밖으로 나갈 수 있는데, document에 걸면 어디서든 이벤트를 받을 수 있습니다.
사용 예시
// 터미널 열기
const { openWindow } = useContext(DesktopContext);
openWindow('terminal', { title: 'Terminal' });
// 게임 열기
openWindow('game', { title: 'Snake', meta: { game: 'snake' } });
이렇게 호출하면 윈도우가 하나 뜨고, 드래그·리사이즈·닫기가 전부 동작합니다. 새 종류의 윈도우를 추가하려면 콘텐츠 컴포넌트만 만들면 됩니다.
돌아보면
처음에 "이게 과연 블로그에 필요한 기능인가?" 싶었는데, 만들고 나니까 블로그의 정체성이 된 느낌입니다. 기술적으로도 Context + useReducer만으로 이 정도 상태 관리가 가능하다는 걸 확인한 좋은 경험이었습니다.
다음 글에서는 이 윈도우 매니저 위에 올라가는 터미널 에뮬레이터를 만들어보겠습니다.