Theme:

블로그: 브라우저 안에 OS를 만들었다에 대해 "왜?"라고 물으면 답할 수 있으신가요?

\n\n> 블로그: 브라우저 안에 OS를 만들었다에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n어느 순간부터 "그냥 블로그" 말고 뭔가 다른 걸 하고 싶다는 생각이 들었습니다.

터미널을 열고, 파일 탐색기를 띄우고, 게임도 하나 돌릴 수 있는 — 마치 데스크톱 OS처럼 동작하는 블로그를 만들면 재밌겠다 싶었습니다. 그러려면 먼저 윈도우 매니저 가 필요합니다.


설계: 뭐가 필요한가

윈도우 매니저가 하는 일을 정리하면:

  1. 윈도우 열기/닫기 — 터미널, 파일 탐색기, 게임 등
  2. z-index 관리 — 클릭한 윈도우가 맨 앞으로
  3. ** 드래그** — 타이틀바를 잡고 이동
  4. ** 리사이즈** — 모서리를 잡고 크기 변경
  5. ** 최소화/최대화** — 트레이로 내리기, 전체 화면

이걸 전역 상태로 관리해야 하니까, 상태 관리 도구가 필요합니다.


왜 Context + useReducer인가

Redux나 Zustand 같은 외부 라이브러리를 쓸까도 고민했는데, 윈도우 매니저의 상태는 결국 ** 열린 윈도우 목록과 각 윈도우의 좌표·크기** 정도입니다. 복잡한 비동기 로직도 없고, 미들웨어도 필요 없습니다.

React 내장 도구만으로 충분한 규모라서 Context + useReducer 조합을 선택했습니다.


상태 구조

JAVASCRIPT
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

액션 타입은 직관적으로 정했습니다.

JAVASCRIPT
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

JSX
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 컴포넌트로 만들었습니다.

JSX
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 액션이 발생합니다.


드래그 구현

드래그는 mousedownmousemovemouseup 이벤트 조합으로 구현합니다.

JAVASCRIPT
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에 걸면 어디서든 이벤트를 받을 수 있습니다.


사용 예시

JSX
// 터미널 열기
const { openWindow } = useContext(DesktopContext);
openWindow('terminal', { title: 'Terminal' });

// 게임 열기
openWindow('game', { title: 'Snake', meta: { game: 'snake' } });

이렇게 호출하면 윈도우가 하나 뜨고, 드래그·리사이즈·닫기가 전부 동작합니다. 새 종류의 윈도우를 추가하려면 콘텐츠 컴포넌트만 만들면 됩니다.


돌아보면

처음에 "이게 과연 블로그에 필요한 기능인가?" 싶었는데, 만들고 나니까 블로그의 정체성이 된 느낌입니다. 기술적으로도 Context + useReducer만으로 이 정도 상태 관리가 가능하다는 걸 확인한 좋은 경험이었습니다.

다음 글에서는 이 윈도우 매니저 위에 올라가는 터미널 에뮬레이터를 만들어보겠습니다.

댓글 로딩 중...