블로그: 터미널 에뮬레이터 만들기 — 진짜 cd, ls가 되게
블로그: 터미널 에뮬레이터 만들기에 대해 "왜?"라고 물으면 답할 수 있으신가요?
\n\n> 블로그: 터미널 에뮬레이터 만들기에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n윈도우 매니저를 만들었으니 이제 그 위에 올릴 것들을 만들 차례입니다. 제일 먼저 손댄 건 터미널이었습니다.
블로그에서 cd posts, ls, cat 파일명.md 같은 명령어로 글을 탐색할 수 있으면 재밌겠다 싶었거든요. 실제 서버의 셸이 아니라 브라우저 메모리에서 돌아가는 가짜 터미널이지만, 사용 경험은 최대한 진짜처럼 만들고 싶었습니다.
뭘 만들어야 하나
터미널 에뮬레이터를 분해하면 대략 이런 구성입니다.
- 입력 — 프롬프트, 커서, 키보드 이벤트
- ** 파싱** — 입력된 문자열에서 명령어와 인자 분리
- ** 실행** — 명령어에 맞는 함수 호출
- ** 출력** — 실행 결과를 화면에 표시
- ** 히스토리** — 위/아래 화살표로 이전 명령어 탐색
이걸 하나씩 만들어갔습니다.
프롬프트와 입력
터미널의 프롬프트는 이런 형태입니다.
visitor@sim.junghun:~/posts $
visitor는 방문자를 의미하고, ~/posts는 현재 디렉토리입니다. 진짜 리눅스 터미널 스타일이죠.
입력은 <input> 태그를 쓰되, 보이지 않게 숨기고 터미널 영역 클릭 시 자동 포커스되게 했습니다. 화면에 보이는 텍스트는 별도 <span>으로 렌더링합니다. 이렇게 하면 커서 위치나 선택 영역을 자유롭게 커스터마이징할 수 있습니다.
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} $
</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를 누르면 입력된 문자열을 명령어와 인자로 분리합니다.
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 사이드 프로젝트 이렇게 두 개의 인자로 쪼개져 버립니다.
명령어 실행
명령어는 레지스트리 패턴으로 관리합니다. 객체에 명령어 이름과 실행 함수를 매핑해놓고, 입력된 명령어를 키로 찾아서 실행합니다.
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 같은 파일 시스템 명령어는 가상 파일 시스템과 연동됩니다. 이건 다음 글에서 자세히 다루겠습니다.
히스토리
위/아래 화살표로 이전 명령어를 탐색하는 기능입니다. 실제 터미널과 동일한 동작을 구현했습니다.
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 키를 누르면 현재 입력에 맞는 명령어나 파일명을 자동완성합니다.
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으로 구현
- ** 스크롤** — 출력이 많아지면 자동 스크롤
- ** 선택 색상** — 터미널 특유의 반전 선택
.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를 입력하면 스네이크 게임이 열립니다. 윈도우 매니저가 있으니까 게임도 윈도우로 뜹니다.
tetris: (ctx) => ctx.openWindow('game', { meta: { game: 'blocks' } }),
snake: (ctx) => ctx.openWindow('game', { meta: { game: 'snake' } }),
쓸데없는 기능이긴 한데, 이런 게 사이드 프로젝트의 재미 아닐까 싶습니다.
다음 글에서는 cd, ls, cat이 실제로 동작하게 해주는 가상 파일 시스템을 다루겠습니다.