Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

단 25줄의 Ruby 코드로 나만의 쉘(Shell) 만들기

Linux나 Mac을 사용한다면 터미널을 열 때마다 이미 쉘(shell) 애플리케이션을 사용하고 있는 것입니다.

쉘은 시스템에서 명령어를 실행할 수 있도록 도와주는 인터페이스입니다.

쉘은 환경 변수를 관리하며, 명령어 히스토리와 자동 완성 같은 유용한 기능도 제공합니다.

사물의 내부 동작 원리를 깊이 파악하고 싶어 하는 분이라면 이 글이 딱 맞을 것입니다!

쉘은 어떻게 동작할까?

직접 쉘 애플리케이션을 만들어 보기 위해, 먼저 쉘이 실제로 무엇인지 생각해 봅시다.

먼저 프롬프트가 나타납니다. 보통 현재 사용자와 현재 디렉터리 같은 부가 정보가 함께 표시되죠. 그다음 명령어를 입력하고 엔터 키를 누르면 결과가 화면에 출력됩니다.

네, 꽤 단순해 보이지만, 이 구조가 어딘가 익숙하지 않으신가요?

pry가 떠올랐다면 정답입니다!

쉘은 본질적으로 운영체제를 위한 REPL(Read-Eval-Print-Loop), 즉 읽고-평가하고-출력하는 반복 인터페이스입니다.

이 사실을 알았으니 첫 번째 버전의 쉘을 작성해 볼 수 있습니다:

prompt = "> "

print prompt

while (input = gets.chomp)
  break if input == "exit"

  system(input)
  print prompt
end

이 코드만으로 최소한의 기능을 갖춘 동작하는 쉘이 완성됩니다. 여기에 다른 REPL 기반 애플리케이션들이 널리 사용하는 라이브러리를 더하면 한층 개선할 수 있습니다.

그 라이브러리의 이름은 바로 Readline입니다.

Readline 라이브러리 활용하기

Readline은 Ruby 표준 라이브러리에 포함되어 있어 별도 설치 없이 require만 하면 바로 사용할 수 있습니다.

Readline을 사용하면 얻을 수 있는 가장 큰 장점 중 하나는 명령어 히스토리를 자동으로 관리해 준다는 점입니다.

명령 프롬프트 출력 처리 등 다양한 작업도 대신 해줍니다.

이번에는 Readline을 적용한 두 번째 버전의 쉘입니다:

require 'readline'

while input = Readline.readline("> ", true)
  break if input == "exit"

  system(input)
end

훨씬 깔끔해졌습니다. 프롬프트를 출력하던 puts 호출 두 개를 제거했고, Readline이 제공하는 강력한 기능들을 활용할 수 있게 되었습니다. 예를 들어 단어 단위 삭제(CTRL + W)나 히스토리 검색(CTRL + R) 같은 키보드 단축키도 사용할 수 있습니다!

이제 전체 히스토리를 출력하는 새로운 명령어를 추가해 보겠습니다:

require 'readline'

while input = Readline.readline("> ", true)
  break                       if input == "exit"
  puts Readline::HISTORY.to_a if input == "hist"

  # 히스토리에서 빈 줄 제거
  Readline::HISTORY.pop if input == ""

  system(input)
end

재미있는 사실: 이 코드를 pry에서 실행하면 pry 자체의 명령어 히스토리가 출력됩니다! 그 이유는 pry 역시 Readline을 사용하고 있으며, Readline::HISTORY는 여러 프로세스가 공유하는 상태이기 때문입니다.

이제 hist를 입력하면 지금까지 입력한 명령어 히스토리를 확인할 수 있습니다 🙂

자동 완성 기능 추가하기

여러분이 즐겨 쓰는 쉘의 자동 완성 덕분에 타이핑 시간을 크게 아낄 수 있습니다. Readline을 사용하면 이 기능을 쉘에 손쉽게 통합할 수 있습니다.

먼저 히스토리에 있는 명령어들을 자동 완성해 보겠습니다.

예시:

comp = proc { |s| Readline::HISTORY.grep(/^#{Regexp.escape(s)}/) }

Readline.completion_append_character = " "
Readline.completion_proc = comp

## 나머지 코드는 여기에 작성 ##

이 코드를 적용하면 <tab> 키를 눌러 이전에 입력했던 명령어를 자동 완성할 수 있습니다. 이제 한 단계 더 나아가 디렉터리 자동 완성 기능도 추가해 보겠습니다.

예시:

comp = proc do |s|
  directory_list = Dir.glob("#{s}*")

  if directory_list.size > 0
    directory_list
  else
    Readline::HISTORY.grep(/^#{Regexp.escape(s)}/)
  end
end

completion_proc는 가능한 후보 목록을 반환합니다. 위 코드에서는 Dir.glob을 사용해 입력된 문자열과 일치하는 디렉터리 이름이 있는지 확인합니다. 나머지 처리는 Readline이 알아서 해줍니다!

system 메서드의 동작 원리 구현하기

이제 히스토리와 자동 완성 기능을 갖춘 쉘이 완성되었습니다. 단 25줄의 코드치고는 꽤 훌륭한 결과입니다 🙂

하지만 조금 더 깊이 파고들고 싶은 부분이 있습니다. 바로 명령어가 실제로 실행될 때 내부에서 어떤 일이 벌어지는지 이해하는 것이죠.

이 작업은 system 메서드가 담당합니다. C 언어에서 이 메서드는 단순히 명령어를 /bin/sh, 즉 쉘 애플리케이션에 전달합니다. Ruby로 /bin/sh가 하는 일을 직접 구현해 보겠습니다.

참고: 이 코드는 Linux / Mac 환경에서만 동작합니다 🙂

system 메서드의 구현:

def system(command)
  fork {
    exec(command)
  }
end

여기서 일어나는 일은 다음과 같습니다. fork가 현재 프로세스의 새로운 복사본을 생성하고, 이 새 프로세스는 exec 메서드를 통해 우리가 실행하려는 명령어로 교체됩니다. 이것은 Linux 프로그래밍에서 매우 흔하게 사용되는 패턴입니다.

만약 fork를 하지 않으면 현재 프로세스 자체가 교체됩니다. 즉, 실행 중인 명령어(ls, cd 등 무엇이든)가 종료되는 순간 여러분의 Ruby 프로그램도 함께 종료되어 버립니다.

실제로 어떻게 동작하는지 확인해 보세요:

def system(command)
  exec(command)
end

system('ls')

# 이 코드는 절대 실행되지 않습니다!
puts "after system"

마무리

이 글에서 여러분은 쉘이 시스템과 상호작용하기 위한 REPL 방식의 인터페이스(irb / pry를 떠올려 보세요)라는 것을 배웠습니다. 또한 강력한 Readline 라이브러리를 활용해 히스토리와 자동 완성 같은 내장 기능을 갖춘 자신만의 쉘을 만드는 방법도 익혔습니다(물론 자동 완성이 어떻게 동작할지는 직접 정의해야 합니다).

마지막으로 Linux 프로그래밍 프로젝트에서 널리 쓰이는 fork + exec 패턴에 대해서도 살펴보았습니다.

이 글이 유익했다면 주변의 Ruby 친구들에게 공유해 주시겠어요? 블로그 성장에 큰 도움이 되고, 더 많은 사람들이 배울 수 있습니다 🙂