간단한 유틸리티 스크립트를 하나 만들어 보는 것도 재미있지만, 때로는 진짜 명령줄 애플리케이션(Command-line Application)이 필요합니다. 인자를 받아들이고, 유닉스의 입출력·에러 보고 관례를 자연스럽게 따르는 앱 말입니다.
다행히 Ruby는 이런 명령줄 앱을 비교적 쉽게 만들 수 있는 모든 빌딩 블록을 제공합니다. 이 글에서는 흔한 "gem Y로 X 하는 법" 스타일을 넘어, 훌륭한 명령줄 앱을 구성하는 요소들을 폭넓게 살펴보겠습니다.
입력: 환경 변수(Environment Variables)
환경 변수는 한동안 유지하고 싶은 짧은 설정 값에 주로 사용됩니다. API 키가 대표적인 예입니다.
환경 변수 설정하기
사용자는 보통 bash 같은 셸을 통해 환경 변수를 설정합니다. 특정 세션에만 설정할 수도 있습니다:
$ AWS_ACCESS_KEY_ID=FOO
현재 세션과 이후 열릴 새 bash 세션 모두에 적용하려면 다음과 같이 합니다:
$ export AWS_ACCESS_KEY_ID=FOO
또는 단 하나의 프로그램 실행에만 적용할 수도 있습니다:
$ env AWS_ACCESS_KEY_ID=FOO aws s3 mb s3://mybucket
환경 변수 읽기
사용자가 환경 변수를 어떤 방식으로 설정했든, Ruby의 ENV 해시를 통해 읽을 수 있습니다.
key_id = ENV["AWS_ACCESS_KEY_ID"]
환경 변수는 내부적으로 어떻게 동작할까?
모든 프로세스에는 자신만의 환경 변수 테이블이 연결되어 있습니다. 프로세스가 자식 프로세스를 생성하면, 자식은 부모의 테이블 사본과 부모가 가한 변경 사항을 함께 물려받습니다.
쉽게 와닿지 않으시나요? 더 자세하고 읽기 편한 설명이 필요하다면, 별도 포스트인 "루비스트를 위한 환경 변수 가이드"를 참고해 보세요.
명령줄 인자(Command-line Arguments)
명령줄 인자란 터미널에서 프로그램을 실행할 때 프로그램 이름 뒤에 붙이는 모든 것을 의미합니다:
$ echo foo bar baz
위 예제에서 "foo", "bar", "baz"가 모두 명령줄 인자입니다.
좀 더 현실적인 예는 다음과 같습니다:
$ honeybadger deploy -v --environment staging
하지만 여기서조차 명령줄 인자는 그저 문자열일 뿐입니다. --environment staging이 실제로 무언가를 의미하게 하려면, 우리가 직접 해석하는 로직을 만들어야 합니다.
명령줄 인자 전달하기
앞서 본 것처럼 인자를 이렇게 전달할 수 있습니다:
$ echo foo bar baz
그런데 Ruby는 인터프리터 언어이기 때문에 다음과 같은 형태도 자주 접하게 됩니다:
$ ruby myprog.rb foo
운영체제 입장에서는 "ruby"라는 프로그램을 실행하면서 두 개의 인자를 넘긴 것입니다.
다행히 Ruby는 똑똑해서, "foo"라는 인자가 Ruby 자신이 아니라 당신의 프로그램을 위한 것임을 알아냅니다. 따라서 프로그램은 "foo"라는 인자 하나만 보게 됩니다.
명령줄 인자 읽기
명령줄 인자는 ARGV라는 배열에 저장됩니다. 전역 변수이므로 프로그램 어디에서든 접근할 수 있습니다.
아래 예제는 ARGV의 모든 내용을 출력합니다. 터미널에 직접 붙여 넣고 여러 가지로 실험해 보세요.
$ ruby -e "puts ARGV.inspect" foo bar baz
["foo", "bar", "baz"]
ruby -e옵션에 혼란스러워하지 마세요. 프로그램 코드와 실행 결과를 한 줄에 함께 보여주기 위해 잠깐 사용한 것일 뿐입니다.
명령줄 인자 파싱하기
명령줄 인자는 결국 문자열입니다. 이 문자열에 의미를 부여하려면 파싱(parsing) 과정이 필요합니다. 다행히 이를 도와주는 좋은 라이브러리가 여럿 있습니다.
오랜 세월에 걸쳐 명령줄 인자를 위한 일종의 표준 문법이 자리 잡았습니다. 대체로 다음과 같은 형태입니다:
$ program -a --option foo
이 스타일은 -h처럼 도움말을 보여주는 불리언 플래그와, 값을 갖는 옵션을 모두 지원합니다.
OptionParser 소개
OptionParser 클래스는 Ruby 표준 라이브러리에 포함되어 있으며, 위 스타일의 옵션을 손쉽게 파싱할 수 있게 해 줍니다.
간단한 인사 앱을 만들어 보겠습니다. 명령줄 인자로 이름을 지정할 수 있는 앱입니다.
require 'optparse'
# 파싱된 옵션을 담을 해시
options = {}
OptionParser.new do |parser|
# -n 또는 --name과 함께 인자가 오면,
# 그 값을 저장한다.
parser.on("-n", "--name NAME", "인사할 사람의 이름") do |v|
options[:name] = v
end
end.parse!
# 이제 options 해시를 자유롭게 활용한다.
puts "Hello #{ options[:name] }" if options[:name]
실행해 보면 잘 동작합니다:
$ ruby hello.rb --name Starr
Hello Starr
$ ruby hello.rb -n Starr
Hello Starr
도움말 화면 추가하기
도움말 기능도 마찬가지로 쉽습니다. 설명 문구를 제공하고 -h 명령만 추가하면 됩니다:
OptionParser.new do |parser|
parser.banner = "Usage: hello.rb [options]"
parser.on("-h", "--help", "도움말 메시지 표시") do ||
puts parser
end
...
end.parse!
이제 도움말을 요청할 수 있습니다:
$ ruby hello.rb -h
Usage: hello.rb [options]
-h, --help Show this help message
-n, --name NAME The name of the person to greet.
인자 타입 변환(Typecasting)
모든 명령줄 인자는 문자열입니다. 하지만 사용자에게 숫자나 날짜를 입력받고 싶을 때가 있습니다. 매번 손으로 변환하기는 번거로우니, OptionParser가 대신 처리해 줍니다.
아래 예제에서는 프로그램이 인사를 몇 번 반복할지 지정하는 "count" 옵션을 추가합니다. OptionParser에게 Integer로 변환하라고 알려주면 알아서 처리해 줍니다.
OptionParser.new do |parser|
...
# `Integer` 인자에 주목하세요. 값을 정수로 변환하라고 지시하는 것입니다.
# `Float`, `Date` 등 다른 타입도 사용할 수 있습니다.
parser.on("-c", "--count COUNT", Integer, "메시지를 COUNT번 반복 출력") do |v|
options[:count] = v
end
end.parse!
if options[:name]
options.fetch(:count, 1).times do
puts "Hello #{ options[:name] }"
end
end
이제 프로그램을 실행하면 여러 번 인사를 건넵니다:
$ ruby hello.rb -n Starr -c 5
Hello Starr
Hello Starr
Hello Starr
Hello Starr
Hello Starr
플래그 네이밍 컨벤션
프로그래밍에서 가장 어려운 일 중 하나가 바로 이름 짓기입니다. 명령줄 인자도 예외는 아닙니다. 도움이 되도록 자주 쓰이는 플래그와 그 의미를 정리했습니다:
| 플래그 | 일반적인 의미 |
|---|---|
-a |
All(전체), Append(추가) |
-d |
디버그 모드 또는 디렉터리 지정 |
-e |
무언가를 실행하거나 편집 |
-f |
파일 지정 또는 작업 강제 실행 |
-h |
도움말(Help) |
-m |
메시지 지정 |
-o |
출력 파일 또는 장치 지정 |
-q |
조용한 모드(Quiet) |
-v |
상세 출력 모드(Verbose), 현재 버전 출력 |
-y |
모든 확인 질문에 "예"로 답하기 |
OptionParser의 대안들
OptionParser는 훌륭하지만 한계도 있습니다. 가장 눈에 띄는 것은 최근 유행하는 커맨드 기반 문법을 지원하지 않는다는 점입니다:
$ myprog command subcommand -V
다행히 선택할 수 있는 옵션 파싱 라이브러리는 엄청나게 많습니다. 마음에 드는 것을 반드시 찾을 수 있을 겁니다. 흥미로운 세 가지를 소개합니다:
- GLI - "Git-like Interface"라는 이름 그대로, git처럼 하나의 실행 파일 안에 여러 커맨드를 담은 앱을 쉽게 만들 수 있는 명령줄 파서입니다.
- CRI - 중첩 커맨드(nested commands)를 지원하는, 사용하기 쉬운 명령줄 도구 빌딩 라이브러리입니다.
- Methadone - 자체 옵션 파싱 DSL을 제공하는 데 그치지 않고, 디렉터리 구조, 테스트 스위트, 로깅까지 세팅해 줍니다. CLI 세계의 Rails라 할 만합니다.
STDIN으로 대량 데이터 입력받기
명령줄 인자는 대량의 데이터를 입력받기에 적합하지 않습니다. 그런 용도에는 IO 스트림을 사용해야 하며, STDIN은 가장 편리한 IO 스트림 중 하나입니다.
대부분의 프로그램은 운영체제로부터 STDIN을 자동으로 할당받습니다. 운영체제가 앱에 데이터를 전달하기 위해 사용하는, 읽기 전용 파일 같은 것입니다. 사용자의 키보드 입력을 받는 데 쓸 수도 있지만, 더 중요한 것은 파이프(pipe)를 통해 다른 프로그램으로부터 데이터를 받을 수 있다는 점입니다.
STDIN은 읽기 전용 파일처럼 사용하면 됩니다. 아래에서는 텍스트를 프로그램의 STDIN으로 파이핑한 뒤, read 메서드로 가져옵니다.
$ echo "hello world" | ruby -e "puts STDIN.read.upcase"
HELLO WORLD
Ruby에서는 IO 객체에 Enumerable 기능을 사용할 수 있으며, STDIN도 예외가 아닙니다. 덕분에 다양한 트릭이 가능합니다:
# 처음 20줄 가져오기
STDIN.first(20)
# 정수로 변환한 뒤 홀수 제외하기
STDIN.map(&:to_i).reject(&:odd)
...등등
STDOUT으로 결과 출력하기
STDOUT은 운영체제가 프로그램에 할당하는 쓰기 전용(write-only) IO 스트림입니다.
STDOUT에 쓰면 사용자의 터미널로 텍스트를 보낼 수 있습니다. 사용자는 이 출력을 파일로 리디렉션하거나 다른 프로그램에 파이핑할 수도 있습니다.
STDOUT 역시 다른 쓰기 전용 파일처럼 사용하면 됩니다:
STDOUT.write("Hi!\n")
# hi
물론 puts와 print도 STDOUT으로 출력합니다.
STDERR로 상태 정보 보내기
STDERR 역시 쓰기 전용 IO 스트림입니다. 일반 출력용이 아니라 상태 메시지 전용으로, 진짜 결과물을 방해하지 않도록 분리해 주는 역할을 합니다.
STDOUT을 파일이나 다른 프로그램으로 리디렉션하더라도, STDERR은 보통 사용자의 터미널에 그대로 표시됩니다.
아래 예제는 curl로 웹페이지를 가져오는 모습입니다. 웹페이지 내용은 STDOUT으로 출력되고, 진행 상황 정보는 STDERR로 표시됩니다:
$ curl "https://www.google.com/" > /tmp/g.html
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 151k 0 151k 0 0 277k 0 --:--:-- --:--:-- --:--:-- 277k
여러분의 프로그램에서 STDERR에 쓰는 것도 간단합니다. 그냥 IO 객체로 다루면 됩니다:
STDERR.write("blah\n")
STDERR.puts("blah")
CLI도 예쁘게 꾸미자
지금까지 본 예제들은 디자인상 수상 경력을 기대하기는 어렵습니다. 하지만 명령줄 앱이라고 해서 반드시 투박하거나 비상호작용적이어야 할 이유는 없습니다.
앱을 업그레이드하기로 했다면, 무거운 작업을 대신해 줄 훌륭한 gem이 많습니다. 제가 좋아하는 세 가지를 소개합니다:
- highline - 사용자 입력 수집, 검증, 타입 변환의 수고를 크게 덜어 주는 훌륭한 라이브러리입니다.
- command_line_reporter - ASCII 진행 보고서를 쉽게 생성할 수 있게 해 줍니다.
- paint - ANSI 색상 코드를 손쉽게 추가해 지루한 텍스트에 생기를 불어넣어 줍니다.
더 방대한 목록이 필요하다면 관련 자료를 찾아보시길 권합니다.
종료 상태(Exit Status)
프로그램이 에러로 종료될 때는 종료 코드(exit code)를 통해 운영체제에 알려야 합니다. 바로 이 덕분에 다음과 같은 bash 코드가 동작하는 것입니다:
$ prog1 && prog2
bash의 && 연산자가 낯설다면, 간단히 말해 "prog1이 성공적으로 종료된 경우에만 prog2를 실행한다"는 의미입니다.
Ruby는 기본적으로 "성공" 코드로 종료되며, 예외가 발생하면 "실패" 코드로 종료됩니다. 나머지는 직접 구현해야 하지만, 다행히 아주 쉽습니다:
# `exit`에 0이 아닌 값을 넘기면 실패로 간주됩니다
exit(1)
더 자세한 내용은 "Ruby 프로그램 종료 방법" 관련 포스트를 참고하세요.
프로세스 이름 설정하기
명령줄 프로그램이 오랫동안 실행될 예정이라면, 시스템 프로세스 목록을 조회할 때 사람들이 그것이 무엇인지 알 수 있도록 하는 것이 중요합니다. 기본적으로 Ruby 프로그램의 프로세스 이름은 프로그램을 실행하기 위해 입력한 전체 명령어입니다. 즉 ruby myapp -v를 입력했다면, 그것이 곧 프로세스 이름입니다.
인자가 많으면 이 이름은 금방 알아볼 수 없게 됩니다. 이럴 때 더 친숙한 프로세스 이름을 설정하는 것이 좋습니다. 다음과 같이 하면 됩니다:
Process.setproctitle("My Awesome Command Line App")
좀 더 멋지게 활용하고 싶다면, 프로세스 타이틀을 이용해 특정 순간에 프로세스가 무엇을 하고 있는지 사용자에게 알려줄 수도 있습니다.
Process.setproctitle("Mail Sender: initializing")
init(...)
Process.setproctitle("Mail Sender: connecting")
connect(...)
Process.setproctitle("Mail Sender: sending")
send(...)
더 자세한 내용은 "top과 ps에 표시되는 Ruby 스크립트의 프로세스 이름 변경 방법" 포스트를 참고하세요.