요약: Ruby에서 셸 명령어를 실행하면서 stdout, stderr, 종료 상태를 모두 캡처하고 싶다면 Open3.capture3 메서드를 사용하세요. stdout과 stderr를 스트리밍 방식으로 실시간 처리하고 싶다면 Open3.popen3가 답입니다.
너무나 많은 나쁜 선택지
Ruby에는 셸 명령어를 실행하는 방법이 무려 492가지나 있고, 각각 조금씩 다르게 동작합니다. 아마 여러분도 아래 방법 중 하나를 사용해 보셨을 겁니다. 필자가 주로 애용해 온 것은 백틱(``) 방식입니다.
exec("echo 'hello world'") # Ruby 프로세스를 종료한 뒤 명령어 실행
system('echo', 'hello world') # 상태 코드 반환
sh('echo', 'hello world') # 상태 코드 반환
`echo "hello world"` # stdout 반환
%x[echo 'hello world'] # stdout 반환
하지만 이런 방식들은 꽤 제한적입니다. 예를 들어 셸 명령어의 stdout뿐 아니라 stderr까지 함께 캡처해야 한다면? 방법이 없습니다. 명령어가 끝날 때까지 기다리지 않고 출력 데이터를 스트림으로 실시간 처리하고 싶다면? 역시 방법이 없죠.
그런데 또 다른 선택지가 있습니다. 명령어를 비동기적으로 실행할 수 있고, stdout, stderr, 종료 코드, PID까지 모두 얻을 수 있는 방법 말입니다. 지금 바로 살펴보겠습니다!
Open3란?
이름부터 생소하게 느껴지는 open3 모듈은 Ruby 표준 라이브러리에 포함되어 있습니다. 도대체 무슨 일을 하는 걸까요?
Open3는 다른 프로그램을 실행할 때 stdout, stderr, 종료 코드와 자식 프로세스를 기다리는 스레드에 접근할 수 있게 해 줍니다. Process.spawn과 마찬가지 방식으로 프로그램의 다양한 속성, 리디렉션, 작업 디렉터리 등을 지정할 수 있습니다. (출처: Open3 공식 문서)
한 번도 사용해 본 적이 없거나 이름조차 들어보지 못하셨나요? 아마 이 라이브러리가 그리 친숙해 보이지 않기 때문일 겁니다. 이름 자체가 Ruby보다는 C 언어처럼 들리고, 공식 문서도 상당히 난해합니다. 하지만 직접 사용해 보면 생각만큼 어렵지 않다는 걸 금방 알게 될 겁니다.
capture3: 한 번에 세 가지를
stdout, stderr, 상태 코드까지 한꺼번에 캡처하는 쉬운 방법이 있다면 어떨까요? 실제로 있습니다. 이 글을 끝까지 읽을 시간이 없다면 capture3라는 메서드만 기억하세요. 이것 하나로 충분합니다.
예제를 살펴보겠습니다. 현재 디렉터리의 파일 목록을 가져오고 싶다고 가정해 봅시다. 이럴 때 ls 명령어를 실행하면 됩니다.
백틱 문법을 사용한다면 이렇게 작성할 수 있습니다:
puts(`ls`)
capture3를 사용하면 이렇게 됩니다:
require 'open3'
stdout, stderr, status = Open3.capture3("ls")
명령어를 실행하고 stdout과 stderr를 문자열로 돌려줍니다. 간단하죠?
보안: 명령어와 데이터 분리
웹 서버에서 사용자에게 임의의 명령어를 실행할 권한을 주는 건 대개 위험합니다. 그래서 identify #{ params[:filename] } 같은 코드는 정말 위험한 발상입니다.
Open3는 명령어와 데이터를 분리함으로써 이런 문제를 예방할 수 있게 해 줍니다. system 메서드와 같은 방식으로 동작합니다.
Open3.capture3("identify", params[:filename], other_unsafe_params)
popen3: 더 강력한 저수준 API
사실 capture3 내부에서는 훨씬 강력한 메서드인 popen3를 사용합니다. 이 메서드는 system() 같은 익숙한 메서드들과 조금 다르게 동작합니다.
사용 예시는 다음과 같습니다:
require 'open3'
Open3.popen3("ls") do |stdout, stderr, status, thread|
puts stdout.read
end
파일을 열고 읽는 것과 비슷합니다. 아마 이런 코드를 본 적이 있을 겁니다:
File.open("my/file/path", "r") do |f|
puts f.read
end
파이프(Pipes) 이해하기
Open3에서 stdout과 stderr는 모두 파이프이며, 파일 버퍼와 매우 유사하게 동작합니다. 파일과 마찬가지로 사용이 끝나면 반드시 닫아줘야 합니다. 그래서 블록 문법을 사용하는 겁니다. (블록 없이 호출하는 것도 가능하지만, 그 경우 stdout과 stderr에 직접 close를 호출해야 합니다.)
read 메서드는 파이프가 닫힐 때까지 기다린 후 값을 반환합니다. 하지만 파이프는 데이터가 생성되는 대로 한 줄씩 읽는 것도 지원합니다. 셸 명령어가 몇 초간 실행되는 동안 stderr에 진행 상태 메시지를 출력한다고 상상해 보세요. 이를 캡처해서 사용자에게 실시간으로 보여주고 싶을 수 있습니다.
stderr를 한 줄씩 캡처하는 방법은 다음과 같습니다:
require 'open3'
Open3.popen3("sleep 2; ls") do |stdout, stderr, status, thread|
while line = stderr.gets do
puts(line)
end
end
스레드(Thread) 인자
아직 언급하지 않은 인자가 하나 있습니다. 바로 thread입니다.
thread 인자는 명령어가 끝나기를 기다리는 Ruby 스레드에 대한 참조를 제공합니다. 물론 명령어 자체가 그 스레드에서 실행되는 것은 아닙니다. 명령어는 완전히 별개의 프로세스에서 실행되며, 스레드는 그저 프로세스를 감시하며 종료될 때까지 기다립니다.
하지만 이 스레드 참조로부터 유용한 정보를 얻을 수 있습니다.
thread.pid - 셸 명령어의 프로세스 ID(PID)를 담고 있습니다. 해당 프로세스에 대해 추가적인 OS 수준의 작업을 수행하고 싶을 때 필요합니다.
thread.status - 프로세스의 종료 상태를 담고 있습니다. 성공과 실패를 나타내는 값으로 활용할 수 있습니다.
주의사항: 데드락 조심
Open3 공식 문서에서는 다음과 같이 경고합니다:
데드락(교착 상태)을 피하도록 주의해야 합니다. 파이프는 고정 길이 버퍼이므로, popen3("prog") {|i, o, e, t| o.read } 코드는 프로그램이 stderr에 너무 많은 출력을 생성하면 교착 상태에 빠질 수 있습니다. 따라서 stdout과 stderr를 동시에 읽어야 합니다(스레드나 IO.select 사용). stderr 출력이 필요 없다면 popen2를 사용할 수 있습니다. stdout과 stderr가 합쳐진 출력으로도 문제없다면 popen2e를 사용하세요. stdout과 stderr를 반드시 별도의 문자열로 받아야 한다면 capture3를 고려해 보세요.
마치며
Ruby에서 셸 명령어를 실행할 때 단순히 결과 문자열만 필요하다면 백틱이나 system으로 충분하지만, stderr 캡처, 실시간 스트리밍, 프로세스 제어가 필요하다면 Open3가 가장 확실한 선택입니다. 간단한 용도라면 capture3, 세밀한 제어가 필요하다면 popen3를 기억해 두세요.