외부 프로그램 실행

외부 프로그램 실행 (Running External Programs)

Julia는 셸, Perl, Ruby에서 쓰는 백틱(backtick) 표기법을 명령어에 차용해요. 하지만 Julia에서 이렇게 쓰는 것은 여러 측면에서 다양한 셸, Perl, Ruby의 동작과 달라요.

julia> `echo hello`
`echo hello`
  • 명령어를 즉시 실행하는 대신, 백틱은 그 명령어를 나타내는 Cmd 객체를 만들어요. 이 객체를 파이프로 다른 명령어와 연결하고, run로 실행하고, readwrite할 수 있어요.
  • 명령어를 실행할 때, Julia는 특별히 그렇게 설정하지 않는 한 출력을 잡아두지 않아요. 대신 명령어 출력은 기본적으로 libcsystem 호출을 쓴 것처럼 stdout으로 나가요.
  • 명령어는 셸로 실행되지 않아요. Julia가 명령어 문법을 직접 파싱해서, 셸이 하듯 적절히 변수를 보간(interpolate)하고 셸 인용 규칙을 존중하며 단어 단위로 나눠요. 명령어는 forkexec 호출을 사용해 julia의 직계 자식 프로세스로 실행돼요.

참고: 아래 내용은 Linux나 macOS 같은 Posix 환경을 전제로 해요. Windows에서는 echodir 같은 많은 유사 명령어가 외부 프로그램이 아니라 셸 cmd.exe 자체에 내장되어 있어요. 이런 명령어를 실행하는 한 가지 방법은 cmd /C echo hello처럼 cmd.exe를 호출하는 거예요. 또 다른 방법은 Cygwin 같은 Posix 환경 안에서 Julia를 실행하는 거예요.

외부 프로그램을 실행하는 간단한 예시를 볼게요.

julia> mycommand = `echo hello`
`echo hello`

julia> typeof(mycommand)
Cmd

julia> run(mycommand);
hello

helloecho 명령어의 출력으로, stdout으로 보내졌어요. 외부 명령어가 성공적으로 실행되지 못하면 run 메서드는 ProcessFailedException을 던져요.

외부 명령어의 출력을 읽고 싶다면 readreadchomp를 대신 쓸 수 있어요.

julia> read(`echo hello`, String)
"hello\n"

julia> readchomp(`echo hello`)
"hello"

더 일반적으로는 open으로 외부 명령어에서 읽거나 쓸 수 있어요.

julia> open(`less`, "w", stdout) do io
           for i = 1:3
               println(io, i)
           end
       end
1
2
3

명령어의 프로그램 이름과 개별 인자는, 명령어가 문자열 배열인 것처럼 접근하고 순회할 수 있어요.

julia> collect(`echo "foo bar"`)
2-element Vector{String}:
 "echo"
 "foo bar"

julia> `echo "foo bar"`[2]
"foo bar"

IOBuffer를 넘긴 다음 나중에 그걸 읽을 수도 있어요.

julia> io = PipeBuffer(); # PipeBuffer is a type of IOBuffer

julia> run(`echo world`, devnull, io, stderr);

julia> readlines(io)
1-element Vector{String}:
 "world"

보간 (Interpolation)

조금 더 복잡한 걸 해보고 싶다고 가정해볼게요. file 변수에 있는 파일 이름을 명령어의 인자로 쓰는 거예요. 문자열 리터럴에서 하는 것처럼 $를 보간에 쓸 수 있어요 (문자열에 대해 다루는 섹션을 참고하세요).

julia> file = "/etc/passwd"
"/etc/passwd"

julia> `sort $file`
`sort /etc/passwd`

셸을 통해 외부 프로그램을 실행할 때 흔한 함정은, 파일 이름에 셸 특수 문자들이 들어 있으면 원치 않는 동작을 유발할 수 있다는 거예요. /etc/passwd 대신 /Volumes/External HD/data.csv라는 파일의 내용을 정렬하고 싶다고 가정해볼게요. 시도해보죠.

julia> file = "/Volumes/External HD/data.csv"
"/Volumes/External HD/data.csv"

julia> `sort $file`
`sort '/Volumes/External HD/data.csv'`

파일 이름이 어떻게 인용됐을까요? Julia는 file이 단일 인자로 보간된다는 걸 알고 있기 때문에, 그 단어를 알아서 인용해줘요. 사실 정확히 말하면, file의 값은 셸에 의해 해석되지 않으므로 실제 인용이 필요하지 않아요. 인용 부호는 사용자에게 보여주기 위한 표시로만 삽입되는 거예요. 셸 단어의 일부로 값을 보간해도 잘 동작해요.

julia> path = "/Volumes/External HD"
"/Volumes/External HD"

julia> name = "data"
"data"

julia> ext = "csv"
"csv"

julia> `sort $path/$name.$ext`
`sort '/Volumes/External HD/data.csv'`

보이시다시피, path 변수의 공백은 적절히 이스케이프됐어요. 그럼 여러 단어를 보간하고 싶다면요? 그 경우엔 배열(또는 다른 어떤 반복 가능한 컨테이너)을 쓰면 돼요.

julia> files = ["/etc/passwd","/Volumes/External HD/data.csv"]
2-element Vector{String}:
 "/etc/passwd"
 "/Volumes/External HD/data.csv"

julia> `grep foo $files`
`grep foo /etc/passwd '/Volumes/External HD/data.csv'`

셸 단어의 일부로 배열을 보간하면, Julia는 셸의 {a,b,c} 인자 생성 동작을 흉내 내요.

julia> names = ["foo","bar","baz"]
3-element Vector{String}:
 "foo"
 "bar"
 "baz"

julia> `grep xylophone $names.txt`
`grep xylophone foo.txt bar.txt baz.txt`

게다가 같은 단어 안에 여러 배열을 보간하면, 셸의 카테시안 곱(Cartesian product) 생성 동작이 흉내 나요.

julia> names = ["foo","bar","baz"]
3-element Vector{String}:
 "foo"
 "bar"
 "baz"

julia> exts = ["aux","log"]
2-element Vector{String}:
 "aux"
 "log"

julia> `rm -f $names.$exts`
`rm -f foo.aux foo.log bar.aux bar.log baz.aux baz.log`

리터럴 배열을 보간할 수 있으므로, 먼저 임시 배열 객체를 만들 필요 없이 이 생성 기능을 사용할 수 있어요.

julia> `rm -rf $["foo","bar","baz","qux"].$["aux","log","pdf"]`
`rm -rf foo.aux foo.log foo.pdf bar.aux bar.log bar.pdf baz.aux baz.log baz.pdf qux.aux qux.log qux.pdf`

인용 (Quoting)

피할 수 없이, 그렇게 단순하지 않은 명령어를 쓰고 싶어지고 인용 부호를 사용할 필요가 생겨요. 셸 프롬프트에서 Perl 원라이너(one-liner)를 쓰는 간단한 예시를 볼게요.

sh$ perl -le '$|=1; for (0..3) { print }'
0
1
2
3

Perl 표현식을 작은따옴표로 감싸야 하는 이유는 두 가지예요. 첫째, 공백이 표현식을 여러 셸 단어로 쪼개지 않게 하려는 것이고, 둘째, $| 같은 Perl 변수 사용(네, Perl에서 그건 변수 이름이에요)이 보간을 일으키지 않게 하려는 거예요. 다른 경우에는 보간이 일어나길 원해서 큰따옴표를 쓰고 싶을 수도 있어요.

sh$ first="A"
sh$ second="B"
sh$ perl -le '$|=1; print for @ARGV' "1: $first" "2: $second"
1: A
2: B

일반적으로 Julia 백틱 문법은 셸 명령어를 있는 그대로 백틱에 복사-붙여넣기만 해도 동작하도록 신중히 설계됐어요. 이스케이프, 인용, 보간 동작이 셸과 같거든요. 유일한 차이는 보간이 통합되어 있고, Julia의 "단일 문자열 값"과 "여러 값을 담는 컨테이너"에 대한 개념을 인지한다는 거예요. 위의 두 예제를 Julia에서 시도해볼게요.

julia> A = `perl -le '$|=1; for (0..3) { print }'`
`perl -le '$|=1; for (0..3) { print }'`

julia> run(A);
0
1
2
3

julia> first = "A"; second = "B";

julia> B = `perl -le 'print for @ARGV' "1: $first" "2: $second"`
`perl -le 'print for @ARGV' '1: A' '2: B'`

julia> run(B);
1: A
2: B

결과는 동일해요. Julia의 보간 동작은 셸의 그것을 흉내 내되 몇 가지 개선점이 있어요. Julia가 일급(first-class) 반복 가능한 객체를 지원하는 반면, 대부분의 셸은 이 용도로 공백으로 분리된 문자열을 쓰기 때문에 모호함이 생기거든요. 셸 명령어를 Julia로 이식할 때는 먼저 복사-붙여넣기를 시도해보세요. Julia는 실행 전에 명령어를 보여주기 때문에, 아무 손상 없이 그 해석을 쉽고 안전하게 살펴볼 수 있어요.

파이프라인 (Pipelines)

|, &, > 같은 셸 메타문자는 Julia 백틱 안에서 인용(또는 이스케이프)해야 해요.

julia> run(`echo hello '|' sort`);
hello | sort

julia> run(`echo hello \| sort`);
hello | sort

이 표현식은 echo 명령어에 단어 세 개(hello, |, sort)를 인자로 넘겨 호출해요. 결과로 hello | sort라는 한 줄이 출력돼요. 그럼 파이프라인은 어떻게 만드는 걸까요? 백틱 안에서 '|'를 쓰는 대신 [pipeline](../../base/base/#Base.pipeline-Tuple{Any, Any, Any, Vararg{Any}})을 사용해요.

julia> run(pipeline(`echo hello`, `sort`));
hello

이건 echo 명령어의 출력을 sort 명령어로 파이프해요. 정렬할 줄이 하나뿐이라 딱히 흥미롭진 않지만, 훨씬 더 흥미로운 것들도 물론 할 수 있어요.

julia> run(pipeline(`cut -d: -f3 /etc/passwd`, `sort -n`, `tail -n5`))
210
211
212
213
214

이건 UNIX 시스템에서 가장 큰 사용자 ID 다섯 개를 출력해요. cut, sort, tail 명령어는 모두 중간 셸 프로세스 없이 현재 julia 프로세스의 직계 자식으로 생성돼요. 셸이 보통 하던 파이프 설정과 파일 디스크립터 연결 작업을 Julia가 직접 하죠. Julia가 직접 하기 때문에 제어력이 더 좋고, 셸이 못 하는 일도 할 수 있어요.

Julia는 여러 명령어를 병렬로 실행할 수 있어요.

julia> run(`echo hello` & `echo world`);
world
hello

여기서 출력 순서는 비결정적이에요. 두 echo 프로세스가 거의 동시에 시작되어, 서로 및 julia 부모 프로세스와 공유하는 stdout 디스크립터에 먼저 쓰려고 경쟁하기 때문이에요. Julia는 두 프로세스의 출력을 모두 다른 프로그램으로 파이프할 수도 있어요.

julia> run(pipeline(`echo world` & `echo hello`, `sort`));
hello
world

UNIX 파이프 관점에서 보면, 단일 UNIX 파이프 객체가 생성되고 두 echo 프로세스가 모두 그 파이프에 쓰며, 파이프의 다른 쪽 끝을 sort 명령어가 읽는 거예요.

IO 리다이렉션은 pipeline 함수에 stdin, stdout, stderr 키워드 인자를 넘겨 수행할 수 있어요.

pipeline(`do_work`, stdout=pipeline(`sort`, "out.txt"), stderr="errs.txt")

파이프라인에서 데드락 피하기 (Avoiding Deadlock in Pipelines)

단일 프로세스가 파이프라인의 양쪽 끝을 모두 읽고 쓸 때, 커널이 모든 데이터를 버퍼링하도록 강제하지 않는 것이 중요해요.

예를 들어 명령어의 모든 출력을 읽을 때는 wait(process)가 아니라 read(out, String)을 호출해요. 전자는 프로세스가 쓴 데이터를 전부 능동적으로 소비하지만, 후자는 독자가 연결되기를 기다리면서 데이터를 커널의 버퍼에 저장하려 하기 때문이에요.

또 다른 흔한 해결책은 파이프라인의 읽는 쪽과 쓰는 쪽을 별도의 Task로 분리하는 거예요.

writer = Threads.@spawn write(process, "data")
reader = Threads.@spawn do_compute(read(process, String))
wait(writer)
fetch(reader)

(보통 읽는 쪽은 별도 task로 두지 않는데, 어차피 즉시 fetch 하기 때문이에요.)

복잡한 예제 (Complex Example)

고수준 프로그래밍 언어와 일급 명령어 추상화, 프로세스 간 파이프 자동 설정의 조합은 강력해요. 쉽게 만들 수 있는 복잡한 파이프라인을 감 잡기 위해, Perl 원라이너를 남용한 점을 양해하며 몇 가지 더 정교한 예시를 보여드릴게요.

julia> prefixer(prefix, sleep) = `perl -nle '$|=1; print "'$prefix' ", $_; sleep '$sleep';'`;

julia> run(pipeline(`perl -le '$|=1; for(0..5){ print; sleep 1 }'`, prefixer("A",2) & prefixer("B",2)));
B 0
A 1
B 2
A 3
B 4
A 5

이건 단일 생산자(producer)가 두 개의 동시 소비자(consumer)를 공급하는 전형적인 예시예요. 하나의 perl 프로세스가 0부터 5까지 번호가 붙은 줄을 생성하고, 두 개의 병렬 프로세스가 그 출력을 소비해요. 하나는 줄 앞에 "A"를 붙이고 다른 하나는 "B"를 붙이죠. 어느 소비자가 첫 줄을 가져갈지는 비결정적이지만, 일단 그 경쟁이 끝나면 줄은 한 프로세스와 다른 프로세스가 번갈아 소비해요. (Perl에서 $|=1로 설정하면 각 print 문이 stdout 핸들을 플러시하는데, 이 예제가 동작하려면 필요해요. 그렇지 않으면 모든 출력이 버퍼링되어 한 번에 파이프로 출력되고, 소비자 프로세스 하나만 그걸 읽게 돼요.)

여기 더 복잡한 다단계 생산자-소비자 예시가 있어요.

julia> run(pipeline(`perl -le '$|=1; for(0..5){ print; sleep 1 }'`,
           prefixer("X",3) & prefixer("Y",3) & prefixer("Z",3),
           prefixer("A",2) & prefixer("B",2)));
A X 0
B Y 1
A Z 2
B X 3
A Y 4
B Z 5

이 예시는 앞선 것과 비슷하지만, 소비자 단계가 두 개이고, 단계마다 지연(latency)이 달라서 처리량을 포화 상태로 유지하기 위해 병렬 워커 수가 다르게 쓰여요.

이 예시들을 모두 직접 실행해 동작 방식을 확인해보길 강력히 권장해요.

Cmd 객체 (Cmd Objects)

백틱 문법은 Cmd 타입의 객체를 만들어요. 이런 객체는 기존 Cmd나 인자 목록에서 직접 생성할 수도 있어요.

run(Cmd(`pwd`, dir=".."))
run(Cmd(["pwd"], detach=true, ignorestatus=true))

이렇게 하면 키워드 인자를 통해 Cmd 실행 환경의 여러 측면을 지정할 수 있어요. 예를 들어 dir 키워드는 Cmd의 작업 디렉토리를 제어해요.

julia> run(Cmd(`pwd`, dir="/"));
/

그리고 env 키워드는 실행 환경 변수를 설정하게 해 줘요.

julia> run(Cmd(`sh -c "echo foo \$HOWLONG"`, env=("HOWLONG" => "ever!",)));
foo ever!

추가 키워드 인자는 Cmd 문서를 참고하세요. setenvaddenv 명령은 Cmd 실행 환경 변수를 각각 교체하거나 추가하는 또 다른 수단을 제공해요.

julia> run(setenv(`sh -c "echo foo \$HOWLONG"`, ("HOWLONG" => "ever!",)));
foo ever!

julia> run(addenv(`sh -c "echo foo \$HOWLONG"`, "HOWLONG" => "ever!"));
foo ever!

더 알아보기