Theme:

블로그: 터미널 에뮬레이터 만들기에 대해 "왜?"라고 물으면 답할 수 있으신가요?

\n\n> 블로그: 터미널 에뮬레이터 만들기에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n윈도우 매니저를 만들었으니 이제 그 위에 올릴 것들을 만들 차례입니다. 제일 먼저 손댄 건 터미널이었습니다.

블로그에서 cd posts, ls, cat 파일명.md 같은 명령어로 글을 탐색할 수 있으면 재밌겠다 싶었거든요. 실제 서버의 셸이 아니라 브라우저 메모리에서 돌아가는 가짜 터미널이지만, 사용 경험은 최대한 진짜처럼 만들고 싶었습니다.


뭘 만들어야 하나

터미널 에뮬레이터를 분해하면 대략 이런 구성입니다.

  1. 입력 — 프롬프트, 커서, 키보드 이벤트
  2. ** 파싱** — 입력된 문자열에서 명령어와 인자 분리
  3. ** 실행** — 명령어에 맞는 함수 호출
  4. ** 출력** — 실행 결과를 화면에 표시
  5. ** 히스토리** — 위/아래 화살표로 이전 명령어 탐색

이걸 하나씩 만들어갔습니다.


프롬프트와 입력

터미널의 프롬프트는 이런 형태입니다.

PLAINTEXT
visitor@sim.junghun:~/posts $

visitor는 방문자를 의미하고, ~/posts는 현재 디렉토리입니다. 진짜 리눅스 터미널 스타일이죠.

입력은 <input> 태그를 쓰되, 보이지 않게 숨기고 터미널 영역 클릭 시 자동 포커스되게 했습니다. 화면에 보이는 텍스트는 별도 <span>으로 렌더링합니다. 이렇게 하면 커서 위치나 선택 영역을 자유롭게 커스터마이징할 수 있습니다.

JSX
const TerminalOverlay = () => {
    const inputRef = useRef(null);
    const [input, setInput] = useState('');
    const [output, setOutput] = useState([]);

    return (
        <div className="terminal" onClick={() => inputRef.current?.focus()}>
            {/* 출력 영역 */}
            {output.map((line, i) => (
                <div key={i} className="terminal-line">{line}</div>
            ))}

            {/* 입력 행 */}
            <div className="terminal-prompt">
                <span className="prompt-text">
                    visitor@sim.junghun:{cwd} $&nbsp;
                </span>
                <span className="prompt-input">{input}</span>
                <span className="cursor blink"></span>
            </div>

            {/* 숨겨진 실제 input */}
            <input
                ref={inputRef}
                value={input}
                onChange={e => setInput(e.target.value)}
                onKeyDown={handleKeyDown}
                className="terminal-hidden-input"
            />
        </div>
    );
};

명령어 파싱

Enter를 누르면 입력된 문자열을 명령어와 인자로 분리합니다.

JAVASCRIPT
function parseCommand(input) {
    const trimmed = input.trim();
    if (!trimmed) return null;

    // 따옴표 안의 공백은 분리하지 않음
    const parts = trimmed.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
    const command = parts[0];
    const args = parts.slice(1).map(a => a.replace(/^"|"$/g, ''));

    return { command, args };
}

cd "사이드 프로젝트" 처럼 따옴표로 감싼 경로도 처리해야 하니까, 단순히 공백으로 split하면 안 됩니다. 정규식으로 따옴표 안의 공백은 보존합니다.

한글 폴더명이 많아서 이 부분을 안 챙기면 cd 사이드 프로젝트 이렇게 두 개의 인자로 쪼개져 버립니다.


명령어 실행

명령어는 레지스트리 패턴으로 관리합니다. 객체에 명령어 이름과 실행 함수를 매핑해놓고, 입력된 명령어를 키로 찾아서 실행합니다.

JAVASCRIPT
const COMMANDS = {
    cd,
    ls,
    cat,
    pwd: (ctx) => ctx.push(ctx.cwd),
    clear: (ctx) => ctx.clear(),
    help: (ctx) => ctx.push(HELP_TEXT),
    echo: (ctx, args) => ctx.push(args.join(' ')),
    whoami: (ctx) => ctx.push('visitor'),
    date: (ctx) => ctx.push(new Date().toString()),
};

function execute(input, ctx) {
    const parsed = parseCommand(input);
    if (!parsed) return;

    const handler = COMMANDS[parsed.command];
    if (!handler) {
        ctx.push(`${parsed.command}: command not found`);
        return;
    }

    handler(ctx, parsed.args);
}

ctx 객체에는 출력 함수(push), 현재 디렉토리(cwd), 화면 클리어(clear) 등이 들어있습니다. 새 명령어를 추가하려면 이 객체에 함수 하나만 넣으면 됩니다.

cd, ls, cat 같은 파일 시스템 명령어는 가상 파일 시스템과 연동됩니다. 이건 다음 글에서 자세히 다루겠습니다.


히스토리

위/아래 화살표로 이전 명령어를 탐색하는 기능입니다. 실제 터미널과 동일한 동작을 구현했습니다.

JAVASCRIPT
const [history, setHistory] = useState([]);
const [historyIndex, setHistoryIndex] = useState(-1);

const handleKeyDown = (e) => {
    if (e.key === 'Enter') {
        // 실행 + 히스토리에 추가
        setHistory(prev => [...prev, input]);
        setHistoryIndex(-1);
        execute(input, ctx);
        setInput('');
    }

    if (e.key === 'ArrowUp') {
        e.preventDefault();
        const newIndex = historyIndex === -1
            ? history.length - 1
            : Math.max(0, historyIndex - 1);
        setHistoryIndex(newIndex);
        setInput(history[newIndex] || '');
    }

    if (e.key === 'ArrowDown') {
        e.preventDefault();
        if (historyIndex === -1) return;
        const newIndex = historyIndex + 1;
        if (newIndex >= history.length) {
            setHistoryIndex(-1);
            setInput('');
        } else {
            setHistoryIndex(newIndex);
            setInput(history[newIndex]);
        }
    }
};

historyIndex-1이면 현재 입력 상태, 0 이상이면 히스토리 탐색 중입니다. Enter를 누르면 인덱스를 리셋합니다.


탭 자동완성

Tab 키를 누르면 현재 입력에 맞는 명령어나 파일명을 자동완성합니다.

JAVASCRIPT
if (e.key === 'Tab') {
    e.preventDefault();
    const completions = getCompletions(input, cwd);

    if (completions.length === 1) {
        // 후보가 하나면 바로 완성
        setInput(completions[0]);
    } else if (completions.length > 1) {
        // 여러 개면 목록 표시
        ctx.push(completions.join('  '));
    }
}

cd 개에서 Tab을 누르면 cd 개발/로 완성되고, 후보가 여러 개면 목록을 보여줍니다. 이 동작도 실제 bash와 동일합니다.


스타일링

터미널 느낌을 내려면 몇 가지 디테일이 필요합니다.

  • 모노스페이스 폰트 — 당연히 고정폭 폰트를 써야 합니다
  • ** 깜빡이는 커서** — CSS animation으로 구현
  • ** 스크롤** — 출력이 많아지면 자동 스크롤
  • ** 선택 색상** — 터미널 특유의 반전 선택
CSS
.terminal {
    background: var(--terminal-bg);
    color: var(--terminal-fg);
    font-family: 'Fira Code', 'Cascadia Code', monospace;
    padding: 1rem;
    overflow-y: auto;
}

.cursor.blink {
    animation: blink 1s step-end infinite;
}

@keyframes blink {
    50% { opacity: 0; }
}

이스터에그

터미널이 있으니 이스터에그도 넣었습니다. tetris를 입력하면 블록 게임이 열리고, snake를 입력하면 스네이크 게임이 열립니다. 윈도우 매니저가 있으니까 게임도 윈도우로 뜹니다.

JAVASCRIPT
tetris: (ctx) => ctx.openWindow('game', { meta: { game: 'blocks' } }),
snake: (ctx) => ctx.openWindow('game', { meta: { game: 'snake' } }),

쓸데없는 기능이긴 한데, 이런 게 사이드 프로젝트의 재미 아닐까 싶습니다.

다음 글에서는 cd, ls, cat이 실제로 동작하게 해주는 가상 파일 시스템을 다루겠습니다.

댓글 로딩 중...