
이번 시간부터 본격적으로 웹 터미널 구현을 위한 코드 작성을 시작해보겠습니다!
이번 글에서는 웹 터미널 구현을 위한 프론트엔드 구현을 진행하고, 다음 시간에는 백엔드 구현을 진행해보겠습니다.
아직 웹 터미널이 무엇인지 모르시거나, 터미널에 관련하여 간단한 기초 지식이 필요하신 분들은 이전 글을 확인해주세요!
본격적인 구현에 앞서 우리 프로젝트가 어떻게 동작하는지 알아볼까요?

위 그림은 웹 터미널에 ls를 입력했을 때 일어나는 과정을 다이어그램으로 그려본 것입니다.
위 그림처럼 Web <-> Server는 socket.io(혹은 websocket)로, Server <-> Remote는 SSH를 통해 통신하게 됩니다.
그리고 사용자의 입력이 Server로 보내지고 Server는 Remote로 입력을 중개하게 됩니다.
Remote에서는 Shell에 들어온 입력을 전달하고 Shell은 내부적으로 해당 입력을 처리하고 입력을 처리했다는 의미로 입력을 그대로 응답합니다.
Server는 SSH에서 받은 입력을 다시 Web으로 전달하고 Web은 받은 데이터를 그대로 UI에 출력하게 됩니다.
아직 글로만 봐서는 이해가 잘 안될 수 있습니다.
서버 구현 시 이 과정을 그대로 실습해볼 수 있으니 걱정하지마세요.
본 글에서는 위와 같은 환경으로 개발해 나갈 예정입니다.
물론 다른 환경에 익숙하신 분들은 해당 환경에 맞추어 세팅해주세요.
xterm.js 패키지xterm.js 라이브러리는 웹 화면으로 터미널 UI를 그릴 수 있는 라이브러리입니다.
깃허브 설명에 따르면 VS Code에서도 이 라이브러리를 쓴다고 하네요.
필자가 확인해본 결과 AWS의 웹 터미널도 이 라이브러리를 사용하는 것 같았어요.

해당 라이브러리를 이용해 본 프로젝트의 UI를 만들 예정이니 라이브러리를 설치 해줍시다!
npm install @xterm/xterm
우선 터미널의 동작을 정의할 클래스를 작성해볼까요?
terminal.ts
import {Terminal as XTerminal} from '@xterm/xterm';
export default class Terminal {
private readonly xterm: XTerminal
constructor() {
this.xterm = new XTerminal(
{
cursorBlink: true, // 터미널에서 커서 깜빡임 활성화
scrollSensitivity: 2, // 스크롤 민감도 설정
theme: {
background: "#222" // 터미널 배경화면 색상 설정
}
}
);
}
}
우선 간단하게 xterm.js 라이브러리의 Terminal 클래스를 래핑하는 클래스를 만들었습니다!
이제 이걸 컴포넌트에 붙이는 작업을 해보겠습니다.
terminal.ts
// terminal.ts에 추가
open(container: HTMLDivElement) {
this.xterm.open(container);
this.xterm.focus();
}
dispose() {
this.xterm.dispose();
}
open 메소드는 컴포넌트가 마운트 됐을 시 터미널을 열어주고,
dispose 메소드는 컴포넌트가 언마운트 됐을 시 터미널을 정리할 역할을 하게 됩니다.
App.tsx
import {useEffect, useRef} from "react";
import Terminal from "./terminal.ts";
import '@xterm/xterm/css/xterm.css'; // CSS 추가
function App() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const terminal = new Terminal();
if (containerRef.current) {
terminal.open(containerRef.current);
}
return () => {
terminal.dispose();
}
}, [])
return <div className="h-screen" ref={containerRef}/>
}
export default App
그 후 ref를 생성하여 div 요소에 할당해 준 후, useEffect를 통해 컴포넌트가 마운트/언마운트 됐을 시 각각 터미널을 열고 닫도록 작성합니다.
이제 React 앱을 실행해 화면을 볼까요?

화면에 커서가 깜빡이면서 터미널 같은 UI가 나타났네요.
하지만 div에 h-screen 클래스를 줬음에도 터미널이 화면 전체를 덮진 않네요.
이건 FitAddon으로 해결 가능합니다!
xterm.js에서는 추가 기능을 Addon 형태로 제공하고 있습니다.
FitAddon은 컨테이너 요소의 크기에 맞추어 터미널의 크기를 조정해주는 기능을 제공합니다.
npm install --save @xterm/addon-fit
우선 해당 Addon을 설치한 후,
terminal.ts
export default class Terminal {
private readonly xterm: XTerminal
private readonly fitAddon = new FitAddon(); // FitAddon 클래스 생성
constructor() {
// 생략...
this.xterm.loadAddon(this.fitAddon); // FitAddon 로드
}
// 생략...
fit() {
this.fitAddon.fit()
}
}
App.tsx
// 생략...
useEffect(() => {
const terminal = new Terminal();
if (containerRef.current) {
terminal.open(containerRef.current);
terminal.fit(); // fit 메소드 함수 호출
}
return () => {
terminal.dispose();
}
}, [])
// 생략...
이제 다시 웹 페이지를 확인해보면 터미널이 꽉차게 나타나고 있는 것을 보실 수 있습니다.
하지만 아직 키보드를 뚜들겨도 아무런 문자가 표시가 되지 않을겁니다...
프런트에서는 사용자의 입력을 화면에 다시 표시해줄 필요는 없습니다.
이건 동작 과정을 보시면 어느정도 이해가 가실거예요.
어차피 서버에서 받은 응답을 그대로 표시만 하면 되거든요!
이제 서버와 통신하기 위한 socket.io 클래스를 작성해봅시다.
당연히 socket.io-client 패키지를 설치해줘야겠죠?
npm install socket.io-client
아래는 socket.io-client 패키지의 Socket 클래스를 래핑하는 클래스입니다.
SocketIOClient.ts
import {io, Socket} from 'socket.io-client';
interface IEventsMap {
[event: string]: any;
}
interface IDefaultEventsMap {
[event: string]: (...args: any[]) => void;
}
class SocketIOClient<
ListenEvents extends IEventsMap = IDefaultEventsMap,
EmitEvents extends IEventsMap = ListenEvents,
> {
readonly client: Socket<ListenEvents, EmitEvents>;
constructor(namespace: string = '') {
this.client = io(`http://localhost:8081${namespace}`, {
reconnectionAttempts: 5,
});
}
}
export default SocketIOClient;
ListenEvents: Event를 listen하고 있을 때 콜백 함수 타입을 지정할 수 있음EmitEvents: Event를 emit할 때 데이터 타입을 지정할 수 있음이후 SocketIOClient 클래스를 상속하는 WebTerminalClient 클래스를 작성해봅시다.
WebTerminalClient.ts
import SocketIOClient from "./SocketIOClient.ts";
interface IListenEvents {}
interface IEmitEvents {}
class WebTerminalClient extends SocketIOClient<IListenEvents, IEmitEvents> {
constructor() {
super('/terminal');
this.client.io.on('reconnect_failed', () => {
console.log('reconnect_failed');
});
}
disconnect() {
this.client.disconnect();
}
connect() {
this.client.connect();
}
}
export default WebTerminalClient;
우선은 간단하게 초기 코드만 작성하였습니다.
추후 기능적인 코드들은 서버 구현을 마무리한 후 진행하겠습니다.
다음 시간에는 웹과 원격 컴퓨터를 중개하는 서버를 구현해보겠습니다!