이 글은 JDK 25와 Linux 6.x를 기준으로 Java에서 외부 프로세스를 실행하는 코드를 작성할 때 주의해야 할 요소와 활용할 수 있는 라이브러리를 소개합니다.
이어서 Linux에서 JVM이 하위 프로세스를 만드는 방식인 posix_spawn()과 jspawnhelper의 동작, 큰 힙에서 나타나는 실행 지연, 실행 방식을 바꾸는 설정까지 살펴봅니다.
이 글의 실행 결과는 아래 환경에서 확인했습니다. 예제 코드는 GitHub 저장소에 있습니다.
| 항목 | 버전 |
|---|---|
OS |
Ubuntu 24.04.4 LTS, 커널 6.17 |
glibc |
2.39 |
JDK |
Temurin 25+36 |
zt-exec |
1.13.0 |
Apache Commons Exec |
1.6.0 |
JDK 기본 클래스로 외부 프로세스를 실행할 때의 주의점
ProcessBuilder와 Runtime.exec의 차이
JDK는 java.lang.Process 클래스로 외부 프로세스와의 접점을 제공합니다. Process 객체는 ProcessBuilder.start()나 Runtime.exec()로 얻습니다. ProcessBuilder는 JDK 5.0(Java 1.5)에 추가되었고, Sun JDK의 Runtime.exec()도 이때부터 내부에서 ProcessBuilder에 위임하도록 바뀌었습니다. JDK 1.5.0-beta2의 소스를 확인한 당시 자료에도 이 구현이 나옵니다. OpenJDK 25의 Runtime 소스에서도 ProcessBuilder를 만들어 start()를 호출합니다. 따라서 두 API는 프로세스 생성 구현을 공유하며, 주요 차이는 실행 설정을 다루는 방법입니다.
새로 작성하는 코드에는 ProcessBuilder를 권장합니다. 이 클래스에서는 환경 변수와 작업 디렉터리, 출력 리다이렉트를 지정하는 더 편리한 API를 제공합니다. 이 글에서 다룰 출력 파이프 처리도 redirectOutput(), redirectError(), redirectErrorStream() 등으로 구성할 수 있습니다.
| 항목 | Runtime.exec() | ProcessBuilder |
|---|---|---|
실행 시점 |
|
설정을 구성한 뒤 |
명령과 인자 |
문자열 하나 또는 문자열 배열 |
인자를 구분한 가변 인자 또는 리스트 |
환경 변수 |
|
|
작업 디렉터리 |
오버로드의 인자로 전달 |
|
입출력 설정 |
리다이렉트와 표준 오류 병합을 설정하는 API 없음 |
|
특히 명령을 문자열 하나로 받는 Runtime.exec(String) 계열은 JDK 18부터 deprecated입니다. Runtime Javadoc은 공백만으로 인자를 나누기 때문에 공백이 들어간 파일명 등을 잘못 처리할 수 있다고 설명합니다. 따옴표를 붙여도 셸처럼 인자를 묶어 주지 않습니다.
// 공백이 있는 파일명을 하나의 인자로 전달하지 못함
Runtime.getRuntime().exec("cat \"my file.txt\"");
// 두 방식 모두 파일명을 하나의 인자로 전달
Runtime.getRuntime().exec(new String[]{"cat", "my file.txt"});
new ProcessBuilder("cat", "my file.txt").start();
ProcessBuilder 기본 옵션의 한계
Java에서는 다음과 같이 ProcessBuilder 클래스를 호출하는 단순한 몇 줄의 코드로 외부 프로세스를 실행할 수 있습니다.
ProcessBuilder 호출Process process = new ProcessBuilder("echo", "hello").start();
int exitCode = process.waitFor();
System.out.println("exit=" + exitCode);
출력이 적고 금방 끝나는 echo hello에서는 이 코드가 문제없이 동작합니다.
하지만 모든 상황에서 안전하다고 보장할 수는 없습니다.
표준 출력과 표준 오류의 파이프 처리
하위 프로세스가 표준 출력(stdout)과 표준 오류(stderr)의 한정된 파이프 용량을 다 채우면 그 프로세스는 멈춥니다. 그런 상황을 막기 위해 표준 출력과 표준 오류를 계속 소비하는 장치가 필요합니다. 다음 중 하나의 조치를 취해야 합니다.
-
별도의 스레드에서 표준 출력(stdout)과 표준 오류(stderr)의 스트림을 읽습니다.
-
두 출력을 구분해서 처리하거나 참조해야 한다면 2개의 스레드에서 각각을 읽어야 합니다.
-
두 출력을 구분할 필요가 없다면 하나로 합쳐 스레드 하나에서 읽을 수도 있습니다.
-
-
해당 스트림을 읽을 필요가 없다면 파일이나 부모의 출력, 폐기 대상으로 리다이렉트해야 합니다.
앞에서 나온 '기본 옵션으로 ProcessBuilder 호출' 코드는 위와 같은 고려가 되어 있지 않습니다.
이어지는 단락에서 이 문제를 더 자세히 분석하고 해결하는 방법을 설명합니다.
읽지 않은 파이프 버퍼가 부르는 교착
하위 프로세스의 표준 출력과 표준 오류는 기본 설정에서 각각 별도의 파이프로 부모 프로세스에 전달됩니다. 부모가 파이프를 읽지 않으면 파이프 버퍼가 차고, 하위 프로세스는 쓰기를 마치지 못합니다.
waitFor()를 호출하지 않아도 이 문제는 생깁니다. 부모는 다음 코드를 실행할 수 있지만, 하위 프로세스는 버퍼에 빈자리가 생기기를 기다리느라 작업을 마치지 못할 수 있습니다. 여기에 부모가 waitFor()로 하위 프로세스의 종료만 기다리면 서로를 기다리는 교착이 됩니다. 따라서 waitFor()를 빼는 것으로 해결할 수 없고, 출력 파이프를 읽거나 리다이렉트해야 합니다. JDK 25의 Process Javadoc은 이 멈춤과 교착 가능성을 경고합니다.
Because some native platforms only provide limited buffer size for standard input and output streams, failure to promptly write the input stream or read the output stream of the process may cause the process to block, or even deadlock.
일부 네이티브 플랫폼은 표준 입력과 표준 출력 스트림에 제한된 크기의 버퍼만 제공하므로, 프로세스의 입력 스트림에 제때 쓰지 않거나 출력 스트림을 제때 읽지 않으면 프로세스가 멈추거나 심하면 교착에 빠질 수 있습니다.
"limited buffer size"가 Linux에서는 얼마인지 pipe(7) 매뉴얼에 나옵니다. 커널 2.6.11부터 기본 파이프 용량은 16페이지, 즉 4KiB 페이지 기준 64KiB입니다. 다만 사용자별 파이프 메모리 한도나 시스템 설정에 따라 더 작아질 수 있고, F_SETPIPE_SZ로 바꿀 수도 있으므로 64KiB를 고정 한계로 가정하면 안 됩니다.
아래 코드는 seq 1 100000 명령으로 588,895바이트를 출력하게 하고 읽지 않은 채 3초를 기다립니다.
Process process = new ProcessBuilder("seq", "1", "100000").start();
boolean finished = process.waitFor(3, TimeUnit.SECONDS);
System.out.println("finished within 3s: " + finished + ", alive: " + process.isAlive());
long lines = process.inputReader().lines().count();
int exitCode = process.waitFor();
System.out.println("read " + lines + " lines, exit=" + exitCode + ", alive: " + process.isAlive());
다음은 실행 결과입니다.
finished within 3s: false, alive: true
read 100000 lines, exit=0, alive: false
예제의 부모 프로세스가 3초 동안 대기를 마친 후에도 자식 프로세스는 살아있습니다. 파이프를 끝까지 읽은 뒤에 다시 호출한 isAlive()는 false를 돌려줍니다. 하위 프로세스가 출력을 마칠 수 있게 한 것은 그 사이에 파이프를 읽은 동작입니다. 제한 시간이 없었다면 부모도 waitFor()에서 계속 기다렸을 것입니다.
예제에 쓴 inputReader() 메서드는 JDK 17에 추가됐습니다. 그 전에는 getInputStream()을 InputStreamReader와 BufferedReader로 감싸야 했습니다.
inputReader()의 활용// JDK 17 이전
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()));
long lines = reader.lines().count();
// JDK 17 이후
long lines = process.inputReader().lines().count();
두 코드가 쓰는 인코딩은 다릅니다. inputReader()는 native.encoding 시스템 속성이 가리키는 인코딩으로 디코딩하고, 인자를 넘기지 않은 InputStreamReader는 Charset.defaultCharset()을 씁니다. JDK 18부터 기본 문자 인코딩이 UTF-8로 고정됐으므로, 운영체제 인코딩이 UTF-8이 아닌 환경에서는 하위 프로세스의 출력을 inputReader()가 더 정확하게 읽습니다.
출력을 읽을 때는 두 스트림을 동시에 처리
표준 출력과 표준 오류를 따로 받아서 가공해야 한다면 파이프 두 개를 동시에 비워야 합니다. 한 파이프를 다 읽은 뒤에 다른 파이프를 읽는 순차 처리로는 부족합니다. 표준 오류 파이프가 먼저 차면 하위 프로세스는 표준 출력을 더 쓰지 못하고, 부모는 표준 출력의 EOF를 기다리는 교착이 생깁니다. 이 경우에는 waitFor()에 도달하기도 전에 읽기 작업에서 멈춥니다.
아래 코드는 표준 오류에 588,895바이트를 쏟은 뒤에 표준 출력에 한 줄을 쓰는 명령을 실행하고, 표준 출력을 끝까지 읽은 다음에 표준 오류를 읽습니다.
Process process = new ProcessBuilder("sh", "-c", "seq 1 100000 >&2; echo done").start();
System.out.println("reading stdout...");
long outLines = process.inputReader().lines().count();
long errLines = process.errorReader().lines().count();
System.out.println("stdout " + outLines + ", stderr " + errLines + ", exit=" + process.waitFor());
실행 결과입니다. 첫 줄을 출력한 뒤로는 아무리 기다려도 다음 줄이 나오지 않고, ps로 확인하면 하위 seq 프로세스도 그대로 살아 있습니다.
reading stdout...
seq는 표준 오류 파이프가 차서 멈췄고, 부모는 아직 오지 않은 표준 출력의 EOF를 기다립니다. 표준 오류를 읽는 다음 줄은 실행되지 않으므로 교착이 풀리지 않습니다. 두 스트림에 각각 읽기 스레드를 두면 어느 한쪽을 읽느라 다른 쪽을 방치하는 문제를 피할 수 있습니다. 뒤에 나올 PlainJdkRunner 예제가 그 방식입니다.
두 출력을 구분할 필요가 없다면 redirectErrorStream(true)로 표준 오류를 표준 출력에 합칠 수 있습니다. 이렇게 하면 읽어야 할 파이프가 하나로 줄어서 스레드 하나로 처리할 수 있습니다.
Process process = new ProcessBuilder("sh", "-c", "seq 1 100000 >&2; echo done")
.redirectErrorStream(true)
.start();
long lines = process.inputReader().lines().count();
System.out.println("read " + lines + " lines, exit=" + process.waitFor());
같은 명령이지만 이번에는 끝까지 진행합니다. 100,001줄은 표준 오류로 나온 100,000줄과 표준 출력의 done 한 줄을 합한 수입니다.
read 100001 lines, exit=0
합친 뒤에도 그 파이프를 계속 읽거나, 다음 절처럼 출력 대상을 리다이렉트해야 합니다.
Java 프로세스에서 읽지 않을 출력은 리다이렉트
ProcessBuilder는 redirectOutput(), redirectError() 메서드로 하위 프로세스의 출력을 어떻게 다룰지 지정할 수 있습니다.
앞의 두 절에서 본 프로세스 멈춤은 두 메서드로 값을 지정하지 않고 기본값인 Redirect.PIPE를 쓰면서 출력 스트림을 따로 읽지 않아서 생긴 현상입니다. getInputStream()을 호출하는 것만으로는 부족하고 실제로 읽어야 합니다.
출력을 부모 프로세스가 가공할 필요가 없다면 Java 코드가 읽어야 할 파이프를 만들지 않는 편이 가장 단순합니다. 기본값인 Redirect.PIPE 대신 쓸 수 있는 선택지는 다음과 같습니다.
-
Redirect.INHERIT: 하위 프로세스가 부모의 표준 출력과 표준 오류에 바로 씁니다. (JDK 7부터 지원)-
inheritIO()메서드는 부모 프로세스의 세 스트림(표준 입력, 표준 출력, 표준 오류)을 한 번에 물려줍니다. (JDK 7부터 지원)
-
-
Redirect.DISCARD: 출력을/dev/null로 보냅니다. (JDK 9부터 지원) -
Redirect.to(File): 출력을 파일로 보냅니다. (JDK 7부터 지원)
// 부모의 표준 출력과 표준 오류로 바로 내보내기
new ProcessBuilder("seq", "1", "100000")
.redirectOutput(Redirect.INHERIT)
.redirectError(Redirect.INHERIT)
.start()
.waitFor();
// 표준 입력까지 세 스트림을 한 번에 물려주기
new ProcessBuilder("seq", "1", "100000")
.inheritIO()
.start()
.waitFor();
// 출력을 /dev/null로 버리기
new ProcessBuilder("seq", "1", "100000")
.redirectOutput(Redirect.DISCARD)
.redirectError(Redirect.DISCARD)
.start()
.waitFor();
// 파일로 보내기
new ProcessBuilder("seq", "1", "100000")
.redirectOutput(Redirect.to(new File("seq-out.log")))
.redirectError(Redirect.to(new File("seq-err.log")))
.start()
.waitFor();
네 방식 모두 앞의 교착 예제와 같은 588,895바이트를 출력하지만 waitFor()에서 멈추지 않습니다.
Java 코드가 비워야 할 파이프가 없기 때문입니다.
출력을 버릴 생각이라면 표준 출력과 표준 오류 모두 명시적으로 DISCARD를 지정하는 편이 안전합니다. Redirect.to(File)은 파일이 이미 있으면 기존 내용을 지우고 덮어쓰므로, 실행할 때마다 뒤에 이어 붙이려면 Redirect.appendTo(File)을 씁니다.
표준 입력의 EOF 전달
앞의 Javadoc 인용문이 출력과 함께 경고한 표준 입력(stdin)도 기본값은 파이프입니다. 부모 JVM이 이 파이프의 쓰기 끝을 열어 둔 채로 있으면 하위 프로세스는 EOF를 받지 못합니다. 인자 없이 실행한 cat처럼 표준 입력을 끝까지 읽는 명령은 더 올 입력이 없는데도 계속 기다리고, 부모는 waitFor()에서 그 명령이 끝나기를 기다립니다. 출력 파이프를 모두 비워도 이 대기는 풀리지 않습니다.
입력을 보낼 일이 없으면 start() 직후에 process.getOutputStream().close()로 쓰기 끝을 닫거나, redirectInput(new File("/dev/null"))처럼 빈 입력을 지정합니다. 입력을 보내야 한다면 다 쓴 뒤에 스트림을 닫아야 합니다. 뒤에서 살펴볼 zt-exec과 Apache Commons Exec은 입력을 지정하지 않으면 하위 프로세스의 표준 입력을 바로 닫습니다.
아래 예제는 cat을 네 번 실행하면서 표준 입력을 다루는 방식만 바꿉니다.
Process opened = new ProcessBuilder("cat").start();
System.out.println("stdin 열어 둠: finished within 1s=" + opened.waitFor(1, TimeUnit.SECONDS)
+ ", alive=" + opened.isAlive());
opened.destroy();
opened.waitFor();
Process closed = new ProcessBuilder("cat").start();
closed.getOutputStream().close();
System.out.println("stdin 닫음: finished within 1s=" + closed.waitFor(1, TimeUnit.SECONDS)
+ ", exit=" + closed.exitValue());
Process devNull = new ProcessBuilder("cat")
.redirectInput(new File("/dev/null"))
.start();
System.out.println("/dev/null 입력: finished within 1s=" + devNull.waitFor(1, TimeUnit.SECONDS)
+ ", exit=" + devNull.exitValue());
Process fed = new ProcessBuilder("cat").start();
try (Writer writer = fed.outputWriter()) {
writer.write("hello\n");
}
String echoed = fed.inputReader().readLine();
System.out.println("입력 후 닫음: echoed=" + echoed + ", exit=" + fed.waitFor());
실행 결과입니다. 쓰기 끝을 열어 둔 첫 번째만 1초 안에 끝나지 못했고, 나머지 셋은 EOF를 받아 바로 종료했습니다.
stdin 열어 둠: finished within 1s=false, alive=true
stdin 닫음: finished within 1s=true, exit=0
/dev/null 입력: finished within 1s=true, exit=0
입력 후 닫음: echoed=hello, exit=0
첫 번째 cat은 destroy()로 끝냈습니다. 정리하지 않으면 남은 예제가 실행되는 동안 계속 살아 있습니다. 다만 부모 JVM이 끝나면 파이프의 쓰기 끝도 함께 닫히므로 그 시점에는 cat도 EOF를 받고 종료합니다. 마지막 예제에 쓴 outputWriter()는 inputReader()와 함께 JDK 17에 추가된 메서드로, getOutputStream()을 OutputStreamWriter로 감싸는 코드를 대신합니다. Javadoc은 두 방식을 함께 쓸 때 OutputStream에 쓰기 전에 BufferedWriter.flush()를 부르라고 안내합니다.
시간제한과 종료 정책
출력 파이프를 처리해도 외부 명령이 네트워크나 잠금을 기다리며 멈출 수 있으므로 시간제한과 종료 정책은 별도로 필요합니다. 이때 waitFor()를 부른 스레드도 함께 멈춥니다. JDK 8부터 있는 waitFor(long, TimeUnit) 메서드는 시간이 지나면 false를 돌려줄 뿐 프로세스를 종료하지 않습니다. 그때는 destroy()로 정상 종료를 요청하고 유예 시간 뒤에도 살아 있으면 destroyForcibly()를 쓰거나, 아래 예제처럼 바로 강제 종료할 수 있습니다. Linux의 OpenJDK 구현에서 destroy()는 SIGTERM을, destroyForcibly()는 SIGKILL을 보냅니다. 프로세스는 SIGTERM을 핸들러로 받아서 임시 파일 삭제나 잠금 해제, 자신이 만든 하위 프로세스 정리 같은 마무리를 한 뒤에 끝날 수 있습니다. SIGKILL은 커널이 프로세스를 즉시 끝내므로 그런 기회가 없고, 정리하지 못한 파일과 후손 프로세스가 남을 수 있습니다. 그래서 정리할 것이 있는 명령에는 destroy()를 먼저 보내고 유예 시간을 두는 편이 안전합니다. 아래 예제는 sleep처럼 정리할 것이 없는 명령만 실행하므로 바로 강제 종료합니다.
아래는 두 출력 스트림을 각각 JDK 21의 가상 스레드에서 읽으면서, 종료 대기에 1초 제한을 둔 예제입니다.
static void run(String... command) throws IOException, InterruptedException, TimeoutException {
Process process = new ProcessBuilder(command).start();
StringBuilder stdout = new StringBuilder();
StringBuilder stderr = new StringBuilder();
Thread outPump = Thread.ofVirtual().start(() -> process.inputReader().lines().forEach(l -> stdout.append(l).append('\n')));
Thread errPump = Thread.ofVirtual().start(() -> process.errorReader().lines().forEach(l -> stderr.append(l).append('\n')));
if (!process.waitFor(1, TimeUnit.SECONDS)) {
process.destroyForcibly().waitFor();
throw new TimeoutException("timed out: " + String.join(" ", command));
}
outPump.join();
errPump.join();
System.out.println(command[0] + ": exit=" + process.exitValue()
+ ", stdout chars=" + stdout.length() + ", stderr=" + stderr.toString().trim());
}
echo hello, seq 1 100000, ls /no-such-dir, sleep 60 네 명령을 이 메서드로 실행한 결과입니다. 590KB 출력도 멈추지 않고, 표준 오류는 따로 모이고, 1초를 넘긴 sleep은 예외로 끝납니다.
echo: exit=0, stdout chars=6, stderr=
seq: exit=0, stdout chars=588895, stderr=
ls: exit=2, stdout chars=0, stderr=ls: cannot access '/no-such-dir': No such file or directory
timed out: sleep 60
이 예제는 입력을 요구하지 않고 후손 프로세스를 만들지 않는 명령을 대상으로 합니다. 실제로는 더 챙길 것이 있습니다. 위 코드는 표준 입력을 닫지 않으므로, 표준 입력을 읽는 명령에는 앞 절의 EOF 처리를 더해야 합니다. 읽기 스레드의 예외를 호출 스레드에 전달하고, 인터럽트나 시간 초과 때도 프로세스와 스트림을 정리해야 합니다. 위 코드의 1초 제한은 waitFor()에만 적용되며, start()나 뒤이은 종료 대기와 join()까지 포함한 전체 시간제한은 아닙니다.
출력이 아주 크면 StringBuilder에 다 담지 말고 줄 단위나 고정 크기 버퍼로 처리해야 합니다. 출력 인코딩이 UTF-8이 아닌 명령도 있습니다.
destroyForcibly()는 직접 만든 프로세스만 끝냅니다. sh -c로 여러 명령을 이어서 실행하거나, 실행한 명령이 다시 하위 프로세스를 만드는 경우가 대표적입니다. 셸만 끝나고 손자 프로세스가 남아서 물려받은 출력 파이프를 계속 열고 있으면, 읽기 스레드는 EOF를 받지 못하고 join()도 끝나지 않습니다. JDK 9의 ProcessHandle.descendants()로 후손을 조회해 하나씩 종료할 수 있지만 그 결과는 스냅샷이므로, 조회와 종료 사이에 생기거나 부모 종료 뒤 재부모화된 프로세스까지 확실하게 정리하지는 못합니다. 후손까지 한 번에 끝내려면 OS 수준에서 묶어야 합니다. setsid 명령으로 새 세션과 프로세스 그룹을 만들어 실행하고 시간 초과 때 그 그룹 전체에 시그널을 보내거나, systemd-run이나 컨테이너처럼 cgroup 단위로 정리하는 방법이 있습니다. JDK API에는 프로세스 그룹을 만들거나 그룹에 시그널을 보내는 기능이 없으므로, 프로세스 그룹 방식은 setsid와 kill 명령을 함께 실행하는 식으로 구성해야 합니다. 다음 절의 라이브러리는 출력 처리와 시간제한 코드를 줄여 주지만, 이런 조건까지 모두 해결해 주지는 않습니다.
zt-exec과 Apache Commons Exec의 활용
출력 파이프 처리와 시간제한 코드를 프로젝트마다 다시 구현하는 대신 라이브러리를 쓸 수 있습니다. zt-exec은 ProcessBuilder를, Commons Exec의 일반적인 Java 실행 경로는 Runtime.exec()를 사용하며, 결국 JDK의 프로세스 생성 구현을 거칩니다. 두 라이브러리 모두 pump 스레드로 출력 파이프를 비우고, 입력을 지정하지 않으면 PumpStreamHandler가 하위 프로세스의 표준 입력을 닫아 EOF를 전달합니다. zt-exec의 PumpStreamHandler 클래스는 소스 파일 상단에 "This file originates from the Apache Commons Exec package"라고 적혀 있을 만큼 Commons Exec의 코드를 가져온 것입니다. 이 글에서 비교할 주요 차이는 API의 모양과 기본값입니다.
zt-exec
zt-exec은 JRebel을 만든 ZeroTurnaround가 사내 여러 프로젝트에 흩어져 있던 프로세스 실행 코드를 하나로 합쳐서 2013년에 공개한 라이브러리입니다. README는 ProcessExecutor 클래스 하나로 ProcessBuilder와 Commons Exec의 기능을 모두 제공하는 것이 목표라고 설명합니다. 의존성은 slf4j-api 하나이고 최소 Java 버전은 8입니다.
<dependency>
<groupId>org.zeroturnaround</groupId>
<artifactId>zt-exec</artifactId>
<version>1.13.0</version>
</dependency>
써 보니 장점은 네 가지로 정리됩니다.
-
기본값이 안전합니다. 아무 설정 없이
execute()만 부르면 표준 오류를 표준 출력에 합치고 그 출력을 버립니다. 파이프를 읽지 않아서 생기는 교착이 기본값에서는 일어나지 않습니다. 출력이 필요하면readOutput(true)를 켜고 결과의outputUTF8()로 문자열을 받거나,redirectOutput()에 줄 단위로 문자열을 처리하는 함수를 람다 표현식으로 넘깁니다. -
시간제한이 예외 타입으로 구분됩니다.
timeout()으로 지정한 시간을 넘기면 기본 stopper가destroy()로 종료를 요청하고 호출자에게TimeoutException을 던집니다. 종료 코드가 잘못된 경우와 시간 초과를 예외 타입만으로 구분할 수 있습니다. 다만 기본 stopper는 Linux에서 SIGTERM을 무시하는 프로세스까지 강제 종료하지는 않습니다. 종료 방식은stopper()로 바꿀 수 있습니다.ProcessStopper인터페이스를 구현해서destroy()뒤에 유예 시간을 두고destroyForcibly()를 부르는 정책을 넣으면 됩니다. -
종료 코드 검사를 선언합니다. 기본값은 모든 종료 코드를 허용하고,
exitValueNormal()이나exitValues(0, 1)로 허용 범위를 지정하면 그 밖의 값에서InvalidExitValueException이 납니다. 예외 객체의getResult()로 그때까지의 출력도 볼 수 있어서 오류 메시지를 로그에 남기기 좋습니다. -
부수 기능을 간단히 설정합니다.
start().getFuture()로 비동기 실행을 하고,destroyOnExit()로 JVM의 shutdown hook에서 하위 프로세스에 종료를 요청합니다.redirectOutputAsInfo()로 SLF4J 로거에 출력을 전달할 수도 있지만, 이 메서드는 deprecated이며redirectOutput(Slf4jStream.of(logger).asInfo())가 권장 API입니다.
앞 절의 네 명령을 zt-exec으로 실행한 예제입니다.
static void captureOutput() throws IOException, InterruptedException, TimeoutException {
ProcessResult result = new ProcessExecutor()
.command("echo", "hello")
.readOutput(true)
.exitValueNormal()
.execute();
System.out.println("output=" + result.outputUTF8().trim() + ", exit=" + result.getExitValue());
}
static void largeOutput() throws IOException, InterruptedException, TimeoutException {
AtomicLong lines = new AtomicLong();
ProcessResult result = new ProcessExecutor()
.command("seq", "1", "100000")
.redirectOutput(line -> lines.incrementAndGet())
.timeout(3, TimeUnit.SECONDS)
.execute();
System.out.println("seq lines=" + lines.get() + ", exit=" + result.getExitValue());
}
static void timeout() throws IOException, InterruptedException {
try {
new ProcessExecutor()
.command("sleep", "60")
.timeout(1, TimeUnit.SECONDS)
.execute();
} catch (TimeoutException e) {
System.out.println("timeout: " + e.getMessage());
}
}
static void exitValue() throws IOException, InterruptedException, TimeoutException {
try {
new ProcessExecutor()
.command("ls", "/no-such-dir")
.readOutput(true)
.exitValueNormal()
.execute();
} catch (InvalidExitValueException e) {
System.out.println("exit=" + e.getExitValue() + ", output=" + e.getResult().outputUTF8().trim());
}
}
실행 결과입니다. TimeoutException의 메시지에 실행한 명령과 제한 시간이 들어 있고, ls의 오류 메시지는 표준 오류가 표준 출력에 합쳐지는 기본값 덕분에 outputUTF8()로 읽힙니다. 시간 초과 뒤에 sleep 프로세스가 남아 있지 않은 것도 확인했습니다.
output=hello, exit=0
seq lines=100000, exit=0
timeout: Timed out waiting for Process[pid=294823, exitValue="not exited"] to finish, timeout: 1 second, executed command [sleep, 60]
exit=2, output=ls: cannot access '/no-such-dir': No such file or directory
주의할 점도 있습니다. readOutput(true)는 pump 스레드가 읽은 바이트를 전부 ByteArrayOutputStream에 저장합니다. 결과를 만들 때 toByteArray()로 한 번 더 복사하고, outputUTF8()을 부르면 문자열도 만듭니다. 내부 버퍼의 여유 공간과 문자열 표현에 따라 최대 메모리 사용량이 달라지므로, 출력 크기의 두 배를 상한으로 생각하면 안 됩니다.
전체 출력을 보관할 필요가 없다면 위 예제의 largeOutput()처럼 readOutput을 켜지 않고 redirectOutput()에 줄 단위 처리 함수를 넘길 수 있습니다. 이 방식의 버퍼 크기는 총 출력량보다는 가장 긴 줄의 크기에 좌우됩니다. 줄바꿈 없는 거대한 출력에는 여전히 메모리가 많이 들고, 처리 함수가 느리면 하위 프로세스의 출력도 밀립니다. 그런 출력은 파일이나 고정 크기 버퍼로 처리하는 OutputStream으로 보내는 편이 낫습니다.
이 예제는 SLF4J API 2.0.17을 사용했으므로 구현체가 없을 때 초기화 과정에서 "No SLF4J providers were found"를 포함한 경고가 세 줄 나왔습니다. zt-exec 1.13.0의 POM이 선언한 의존성은 SLF4J API 1.7.32이며, 이 버전의 경고 문구는 다릅니다. 로그가 필요 없으면 사용 중인 SLF4J API에 맞는 slf4j-nop을 추가해 경고를 없앨 수 있습니다.
Apache Commons Exec
Apache Commons Exec은 명령 실행, 출력 스트림 처리, 시간제한을 각각 DefaultExecutor, PumpStreamHandler, ExecuteWatchdog로 구성하는 라이브러리입니다. 이 글에서 사용하는 1.6.0은 Java 8 이상에서 실행되며 외부 의존성이 없습니다. DefaultExecutor와 ExecuteWatchdog는 builder API로 만들고, 시간제한은 Duration으로 지정합니다. 기존 생성자는 deprecated로 표시되어 있습니다.
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.6.0</version>
</dependency>
같은 네 명령을 1.6.0으로 실행한 예제입니다. 스트림 처리를 맡는 PumpStreamHandler 클래스는 기본으로 System.out과 System.err에 출력하므로, 출력을 모으려면 아래처럼 OutputStream을 직접 넘깁니다.
static void run(String... command) throws IOException {
CommandLine cmdLine = new CommandLine(command[0]);
cmdLine.addArguments(Arrays.copyOfRange(command, 1, command.length), false);
ByteArrayOutputStream stdout = new ByteArrayOutputStream();
ByteArrayOutputStream stderr = new ByteArrayOutputStream();
ExecuteWatchdog watchdog = ExecuteWatchdog.builder().setTimeout(Duration.ofSeconds(1)).get();
DefaultExecutor executor = DefaultExecutor.builder().get();
executor.setStreamHandler(new PumpStreamHandler(stdout, stderr));
executor.setWatchdog(watchdog);
try {
int exitValue = executor.execute(cmdLine);
System.out.println(command[0] + ": exit=" + exitValue + ", stdout bytes=" + stdout.size());
} catch (ExecuteException e) {
System.out.println(command[0] + ": exit=" + e.getExitValue()
+ ", killed by watchdog=" + watchdog.killedProcess()
+ ", stderr=" + stderr.toString().trim());
}
}
echo: exit=0, stdout bytes=6
seq: exit=0, stdout bytes=588895
sleep: exit=143, killed by watchdog=true, stderr=
ls: exit=2, killed by watchdog=false, stderr=ls: cannot access '/no-such-dir': No such file or directory
DefaultExecutor는 Linux에서 기본적으로 0이 아닌 종료 코드에 ExecuteException을 던집니다. 허용 코드는 setExitValue()나 setExitValues()로 바꿀 수 있고, setExitValues(null)로 검사를 끌 수도 있습니다. 위 결과의 sleep은 watchdog이 보낸 SIGTERM으로 끝나서 종료 코드가 143입니다. 이 예제에서는 예외를 잡은 뒤 watchdog.killedProcess()로 watchdog의 개입 여부를 구분합니다. 다만 이 메서드는 destroy() 호출 여부를 나타내며 실제 종료를 보장하지 않습니다. 1.6.0의 ExecuteWatchdog는 시간 초과 때 destroy()만 호출하고 강제 종료로 넘어가는 API가 없습니다. 강제 종료가 필요하면 시간 초과 뒤 남은 프로세스를 ProcessHandle로 찾아 destroyForcibly()를 부르는 코드를 따로 두어야 합니다. SIGTERM을 무시하면 execute()가 계속 기다릴 수 있고, 종료 처리를 수행한 명령이 허용된 코드로 끝나면 시간 초과 뒤에도 예외 없이 반환될 수 있으므로 정상 반환 경로에서도 확인해야 합니다.
CommandLine.addArguments(String)는 따옴표를 해석하며 인자를 나누므로, 공백을 포함한 인자를 문자열로 조립할 때 주의해야 합니다. 배열을 받는 메서드도 기본적으로 인자에 따옴표를 추가할 수 있습니다. Linux의 Runtime.exec(String[])는 셸을 거치지 않으므로 그 따옴표가 인자의 일부로 전달될 수 있습니다. 그래서 위 예제는 이미 분리된 인자 배열에 두 번째 인자를 false로 주어 추가 quoting을 껐습니다.
유지보수 현황과 선택
zt-exec은 README에서 ProcessBuilder와 Commons Exec을 언급하며, 스트림 처리와 시간제한, 종료 코드 검사를 개선한 대안으로 자신을 소개합니다. Commons Exec은 2014년의 1.3 이후 10년 가까이 릴리스가 없었으므로, zt-exec을 더 활발히 유지보수되는 쪽으로 여기기 쉽습니다. 아래 현황은 그 인상과 다릅니다.
라이브러리를 고를 때는 출력 처리와 종료 코드의 기본값, 시간 초과를 알리는 방식, 의존성을 함께 비교해야 합니다. 아래는 Maven Central의 버전 목록과 각 프로젝트의 변경 기록으로 2026-09-06에 확인한 현황입니다. 릴리스 날짜는 변경 기록을 기준으로 했습니다.
| 항목 | zt-exec | Apache Commons Exec |
|---|---|---|
최신 버전 |
1.13.0 (2026-07-10) |
1.6.0 (2025-11-25) |
그 이전 릴리스 |
1.12 (2020-09-02), 1.11 (2019-07-05) |
1.5.0 (2025-05-16), 1.4.0 (2024-01-01), 1.3 (2014-11-02) |
최근 5년간 릴리스 수 |
1회 |
3회 |
최소 Java 버전 |
8 |
8 |
의존성 |
slf4j-api |
없음 |
기본 출력 처리 |
버림 (표준 오류는 표준 출력에 합침) |
|
시간 초과 알림 |
|
|
0이 아닌 종료 코드 |
기본 허용, |
기본 |
시간 초과 시 종료 방식 |
|
|
최근 5년간 릴리스 수는 Commons Exec이 3회, zt-exec이 1회입니다. Commons Exec은 1.4.0부터 builder API와 Duration을 지원하고, zt-exec은 1.13.0에서 빌드를 Gradle로 옮기고 JPMS 모듈 정보를 추가했습니다. 릴리스 횟수만으로 유지보수 품질 전체를 판단할 수는 없지만, zt-exec이 더 자주 갱신된다고 볼 근거는 없습니다.
제가 zt-exec에서 선호하는 부분은 API 설계입니다. 시간 초과와 종료 코드 오류를 예외 타입으로 구분하고, 출력을 문자열로 받는 데 메서드 호출 하나면 되고, 기본 설정에서 출력 파이프를 읽지 않아 생기는 교착을 피할 수 있습니다. 저는 새로 쓰는 코드라면 zt-exec을 고르겠습니다. 다만 라이브러리의 전이 의존성을 늘리고 싶지 않거나 Apache 프로젝트의 거버넌스를 선호한다면 Commons Exec 1.6.0도 builder API로 쓸 만합니다. 이미 Commons Exec을 쓰고 있는 코드를 zt-exec으로 바꿀 만큼 큰 차이는 아닙니다. 두 라이브러리 모두 기본 종료 요청은 destroy()입니다. zt-exec은 stopper()로 강제 종료 정책을 넣을 수 있지만 Commons Exec은 별도 코드가 필요합니다. 후손 프로세스 정리는 두 라이브러리 모두 앞 절에서 본 OS 수준의 방법을 더해야 합니다.
Linux에서의 프로세스 생성과 운영
현재 Linux에서 JDK 25를 쓰는 Java 개발자는 jdk.lang.Process.launchMechanism을 지정하지 않고 기본값인 POSIX_SPAWN을 유지하면 됩니다. JDK 13부터 이 방식이 기본이며, glibc 2.24 이후의 posix_spawn()은 부모 JVM의 주소 공간과 페이지 테이블을 복제하는 fork()를 쓰지 않습니다. 프로세스 생성을 위해 JNI 코드나 별도 네이티브 라이브러리를 추가할 필요도 없습니다. 다만 실제 메모리 부족이나 프로세스 생성에 필요한 자원 부족까지 없어지는 것은 아닙니다.
운영에서 주의할 부분은 실행 방식의 설정입니다. 기본값 대신 FORK를 지정하면 큰 힙에서 생성 비용이 커질 수 있고, 실행 스크립트에 남아 있는 VFORK 설정은 JDK 27에서 FORK로 대체됩니다. Linux가 제공하는 선택지와 JVM이 해온 선택을 먼저 정리하고, 기본 동작과 실행 방식별 비용을 차례로 확인하겠습니다.
프로세스 생성 방식 세 가지와 JVM의 선택
어떤 호출로 자식 프로세스를 만드는지가 중요한 이유는 이 선택이 과거에 실제 장애로 이어졌기 때문입니다. 힙을 크게 잡은 JVM이 외부 명령 하나를 실행하려다 메모리 할당에 실패하는 문제가 대표적입니다. 2015년 NAVER D2의 Java에서 외부 프로세스를 실행할 때는 힙을 크게 설정한 Tomcat 서버에서 외부 프로세스 실행이 Cannot allocate memory 예외로 실패하는 사례를 소개합니다. OpenJDK 이슈 제목에도 그 사정이 남아 있습니다. JDK 7에 반영된 JDK-6868160의 제목은 "(process) Use vfork, not fork, on Linux to avoid swap exhaustion"입니다. 자식 프로세스를 만드는 호출을 바꾼 것이 스왑 고갈을 피하려는 조치였다는 뜻입니다. 지금 기본값을 그대로 두면 되는 것도 이런 문제들이 차례로 정리된 결과입니다.
Linux에서 다른 프로그램을 실행하려면 자식 프로세스를 만드는 호출과 그 자식을 다른 프로그램으로 바꾸는 execve()를 짝지어야 합니다. 선택의 여지가 있는 쪽은 앞의 절반, 즉 자식을 만드는 방법입니다. 후보는 세 가지입니다.
-
fork(): 부모와 같은 내용의 주소 공간을 가진 자식을 만듭니다. 커널은 쓰기 시 복사(copy-on-write)로 실제 페이지 복사를 미루지만, 페이지 테이블은 복사합니다. -
vfork(): 자식이 부모의 주소 공간을 그대로 공유하고, 자식이execve()하거나 끝날 때까지 부모의 호출 스레드가 멈춥니다. -
posix_spawn(): Linux 커널의 시스템 콜이 아니라 glibc나 musl 같은 C 라이브러리가 제공하는 함수입니다. 자식 생성과execve(), 그 사이의 준비 작업을 한 묶음으로 제공합니다.
Linux에서 fork()와 vfork()는 모두 clone() 시스템 콜 위에 구현되어 있고, 부모와 무엇을 공유할지는 플래그로 정해집니다. vfork(2) 매뉴얼은 vfork()를 CLONE_VM | CLONE_VFORK | SIGCHLD 플래그를 준 clone()과 같다고 설명합니다. 뒤에 나올 strace 결과에서 이 플래그를 직접 보게 됩니다.
posix_spawn()만 계층이 다릅니다. POSIX가 명세하고 C 라이브러리가 구현한 함수여서 대응하는 시스템 콜이 없고, 내부에서는 결국 앞의 두 방식 중 하나를 골라 씁니다. 프로세스 생성 코드가 들어 있는 libjava.so의 동적 심볼을 보면 이 경계가 드러납니다.
$ nm -D $JAVA_HOME/lib/libjava.so | grep -E 'posix_spawn|fork'
U fork@GLIBC_2.2.5
00000000000125c0 T Java_java_lang_ProcessImpl_forkAndExec
U posix_spawn@GLIBC_2.15
U vfork@GLIBC_2.2.5
T 는 이 파일이 정의한 심볼, U 는 정의하지 않고 실행 시점에 다른 라이브러리에서 찾는 심볼입니다. OpenJDK가 만든 것은 네이티브 메서드인 forkAndExec 이고, 세 실행 방식에 해당하는 함수는 모두 glibc에서 가져옵니다. 그래서 같은 POSIX_SPAWN 설정이라도 libc와 그 버전에 따라 실제 동작이 달라집니다. 심볼에 붙은 @GLIBC_2.15 가 그 버전 구분입니다. glibc가 아닌 musl에 링크된 JDK라면 같은 자리에 musl의 구현이 들어갑니다. OpenJDK가 프로세스 생성을 위해 따로 만든 실행 파일은 다음 절에서 볼 jspawnhelper 쪽입니다.
세 방식의 장단점은 다음과 같습니다.
| 방식 | 장점 | 단점 |
|---|---|---|
|
자식이 |
부모의 페이지 테이블을 복사하므로 힙이 클수록 생성 비용이 커지고, 커널의 overcommit 판정 대상이 됨 |
|
페이지 테이블을 복사하지 않아 생성 비용이 부모의 힙 크기와 거의 무관함 |
자식이 |
|
준비 작업을 라이브러리가 정한 방식으로 처리하고, glibc 2.24부터는 |
자식이 할 수 있는 준비 작업이 라이브러리가 지원하는 항목으로 제한되고, 내부 구현이 libc와 그 버전에 따라 다름 |
fork()의 비용에 대해 vfork(2) 매뉴얼은 "부모의 페이지 테이블을 복제하고 고유한 task 구조체를 만드는 시간과 메모리"라고 적었습니다. 힙이 작으면 눈에 띄지 않지만 수 GB 힙을 쓰는 JVM에서는 무시하기 어려운 값이 됩니다. vfork()의 제약은 vfork(2) 매뉴얼에 더 강하게 적혀 있습니다. 반환값을 담을 pid_t 변수 외의 데이터를 고치거나, vfork()를 호출한 함수에서 반환하거나, _exit()와 execve() 외의 함수를 부르면 동작이 정의되지 않습니다.
OpenJDK는 이 세 방식을 차례로 거쳤습니다. 처음에는 fork()를 썼지만 큰 힙에서 생성 비용이 문제가 되자 JDK 7에서 vfork()를 기본값으로 바꿨습니다. 그런데 JDK는 vfork()와 execve() 사이에서 파일 디스크립터를 닫고 작업 디렉터리를 바꾸는 준비 작업을 했고, 이는 방금 본 vfork()의 제약을 어기는 것이었습니다. 비용은 피했지만 안전을 잃은 선택이었습니다. JDK 13에서 기본값이 된 POSIX_SPAWN은 이 준비 작업을 부모와 메모리를 공유하지 않는 별도 프로세스로 옮겨서, fork()의 복사 비용과 vfork()의 제약 위반을 함께 피합니다. 그 구조가 다음 절에서 볼 jspawnhelper 입니다.
세 방식은 구현 전용 시스템 속성인 jdk.lang.Process.launchMechanism 에 각각 FORK, VFORK, POSIX_SPAWN 으로 대응합니다. JDK 25의 ProcessImpl.java에 이 세 값이 선언되어 있고, Linux에서는 셋 다 지정할 수 있되 VFORK에는 경고가 따릅니다. 버전별 변경 이력과 VFORK가 없어지는 과정은 이 장의 마지막 절에서 정리합니다.
POSIX_SPAWN과 jspawnhelper의 동작
자식 생성과 exec(), 그 사이의 준비 작업을 한 묶음으로 제공한다는 점에서 posix_spawn()은 Windows의 CreateProcess()와 닮았습니다. JDK는 이 함수로 jspawnhelper 라는 작은 실행 파일을 먼저 띄우고, jspawnhelper 가 파이프로 넘겨받은 설정에 따라 파일 디스크립터와 작업 디렉터리를 정리한 뒤 실제 명령을 다시 exec()합니다. 이 구조는 JDK 25 소스의 ProcessImpl_md.c 상단 주석에 설명되어 있습니다.
helper를 한 번 더 거치는 이유는 앞 절에서 본 안전 문제 때문입니다. 파일 디스크립터 닫기와 작업 디렉터리 변경 같은 준비 작업을 첫 번째 exec()으로 실행된 jspawnhelper, 즉 부모와 메모리를 공유하지 않는 별도 프로세스 안에서 하면 vfork()의 제약을 어길 일이 없습니다. 같은 주석은 이를 "준비 작업을 첫 exec 뒤로 옮겨 취약한 시간 창을 줄인다"고 설명합니다. 뒤에서 살펴볼 JDK 갱신 시의 버전 불일치와 VFORK 제거는 모두 이 설계에서 나온 결과입니다.
strace로 보면 두 번의 execve 가 그대로 드러납니다. 아래는 echo hello 를 실행하는 ProcessRunner.java를 기본 설정으로 실행했을 때의 시스템 콜 중 프로세스 생성과 관련된 부분입니다. 경로는 줄였습니다.
$ strace -f -e trace=clone,clone3,vfork,execve -o trace.txt java ProcessRunner
$ grep -v CLONE_THREAD trace.txt | grep -E 'clone|vfork|execve'
285583 clone3({flags=CLONE_VM|CLONE_VFORK|CLONE_CLEAR_SIGHAND, exit_signal=SIGCHLD, stack=..., stack_size=0x9000}, 88 <unfinished ...>
285603 execve("$JAVA_HOME/lib/jspawnhelper", ["$JAVA_HOME/lib/jspawnhelper", "25+36-LTS", "10:11:13"], ...) = 0
285603 execve("/usr/bin/echo", ["echo", "hello"], ...) = 0
vfork() 시스템 콜은 나오지 않습니다. glibc 2.39의 posix_spawn()은 clone3 시스템 콜에 CLONE_VM 과 CLONE_VFORK 플래그를 주어 vfork()와 같은 효과를 냅니다. CLONE_VM 은 자식이 부모의 주소 공간을 그대로 공유한다는 뜻이므로, 힙 매핑과 페이지 테이블을 복사하지 않습니다. CLONE_VFORK 는 자식이 exec()하거나 끝날 때까지 부모의 호출 스레드를 멈춥니다. CLONE_VM 덕분에 생성 비용은 힙 크기와 거의 무관합니다. 뒤의 측정에서 이를 확인합니다. 같은 프로그램을 -Djdk.lang.Process.launchMechanism=FORK 로 실행하면 CLONE_VM 플래그 없이 clone 시스템 콜을 불러 주소 공간을 복제하고, jspawnhelper 없이 곧바로 /usr/bin/echo 를 execve합니다.
posix_spawn(3) 매뉴얼에 따르면 glibc 2.24부터 posix_spawn()은 CLONE_VM 과 CLONE_VFORK 플래그로 clone()을 호출합니다. 자식 프로세스에 별도 스택을 주고, 생성 과정에서 시그널을 차단하고 자식의 핸들러를 정리해 부모 스택과 시그널 처리에 관한 위험을 줄입니다. JDK 25 소스의 주석도 이 이유로 glibc 2.24 이상의 방식을 가장 좋은 선택으로 설명하며, musl 역시 이 clone() 방식을 사용해 왔다고 적었습니다. vfork() 함수 자체가 glibc에서 없어진 것은 아닙니다.
JDK-8357090의 CSR은 posix_spawn()의 예기치 않은 문제를 우회할 수 있도록 FORK를 대안으로 둔다고 설명합니다. jspawnhelper 관련 오류가 반복되는데 원인을 바로 찾기 어렵다면 JVM 시작 옵션에 -Djdk.lang.Process.launchMechanism=FORK 를 주어 임시 회피할 수 있습니다. 다음 두 절에서 살펴볼 overcommit 정책에 따른 메모리 부담과 실행 지연을 감수하는 선택이며, 실행 중인 JVM에 즉시 적용하는 설정은 아닙니다.
FORK의 메모리 비용과 overcommit
FORK를 선택하면 부모 JVM의 주소 공간 복제 비용을 고려해야 합니다. fork()는 부모와 같은 자식 프로세스를 만드는데, Linux는 쓰기 시 복사(copy-on-write)로 실제 페이지 복사는 미루지만 페이지 테이블은 복사합니다. 커널의 메모리 overcommit 정책에 따라 자식이 쓸지도 모르는 메모리를 미리 계산해서 생성을 거절할 수도 있습니다. 자식이 곧바로 exec()로 작은 프로그램으로 바뀌더라도 이 검사를 거칩니다.
vm.overcommit_memory 의 기본값 0은 "명백한 overcommit만 거절하는" 모드입니다. 커널 5.1의 코드는 이 판정에 여유 메모리, 페이지 캐시, 스왑 여유분 등을 반영했습니다. fork()가 힙 매핑을 복제할 때 요구하는 크기가 이 계산값을 넘으면 ENOMEM 으로 거절될 수 있었습니다.
커널 5.2에 들어간 mm: fix false-positive OVERCOMMIT_GUESS failures 패치는 모드 0의 판정을 "요청 크기가 전체 RAM과 스왑의 합을 넘는지"로 단순화했습니다. 여유 메모리가 적다는 이유만으로 큰 매핑의 복제를 거절하던 조건이 완화된 것입니다. FORK가 항상 성공한다는 뜻은 아니며, 페이지 테이블 등 커널 메모리의 실제 할당은 여전히 실패할 수 있습니다.
vm.overcommit_memory=2 로 commit 한도를 엄격히 적용한 서버에서는 fork() 시점에도 commit 검사가 남습니다. 부모의 전체 주소 공간 크기를 무조건 더하는 것은 아니고, private writable 매핑 등 회계 대상에 따라 비용이 계산됩니다. Java 힙처럼 큰 익명 private writable 매핑은 이 비용에 포함되므로, 남은 commit 한도를 넘으면 프로세스 생성이 실패할 수 있습니다.
큰 힙에서 FORK와 POSIX_SPAWN의 실행 지연
overcommit 판정이 완화됐어도 fork()가 페이지 테이블을 복사한다는 사실은 변하지 않았습니다. 실제로 접근한 힙 영역과 페이지 크기 등에 따라 복사 비용이 달라집니다. 반면 POSIX_SPAWN은 CLONE_VM 으로 주소 공간을 공유하므로 이 복사가 없습니다. 힙을 미리 할당한 상태에서 true 명령을 30번 실행하는 데 걸린 시간을 실행 방식별로 재 봤습니다. 측정 코드는 SpawnBench.java이고, -Xms와 -Xmx를 같은 값으로 맞추고 -XX:+AlwaysPreTouch 옵션으로 힙을 미리 접근한 뒤 측정했습니다. 각 실행에서 5회 예열 후 30회의 start().waitFor() 평균을 구했으므로, 순수한 생성 시간뿐 아니라 명령 실행과 종료 대기도 포함합니다. 아래는 글 작성 당시 두 번 실행한 값입니다.
| 힙 크기 | POSIX_SPAWN (ms/회) | FORK (ms/회) |
|---|---|---|
256MB |
1.5, 1.2 |
15.9, 20.2 |
2GB |
1.6, 1.9 |
120.8, 140.7 |
8GB |
2.0, 2.1 |
243.5, 231.2 |
이 측정에서 POSIX_SPAWN은 약 1~2ms 범위였고, FORK는 힙이 커질수록 느려져 8GB에서는 100배 넘게 차이가 났습니다. 힙 크기에 정확히 비례하거나 모든 환경에서 이 배율이 나온다는 뜻은 아닙니다. 2026-09-06 재측정에서도 같은 경향을 확인했지만 절대 시간은 달랐습니다.
Linux 6.17의 dup_mmap()은 부모 프로세스의 주소 공간 잠금(mmap_lock)을 쓰기 모드로 잡은 상태에서 매핑과 페이지 테이블을 복제합니다. 그동안 이 잠금이 필요한 메모리 매핑 변경을 시도하는 다른 스레드는 기다려야 합니다. 요청 처리 중 외부 명령을 동기 실행하는 웹 애플리케이션이라면 이 비용이 응답 시간에 더해질 수 있습니다. 기본값을 FORK로 바꾸기 전에 실제 워크로드로 측정해야 하는 이유입니다.
실행 방식의 변경 이력과 VFORK 설정
실행 스크립트에 -Djdk.lang.Process.launchMechanism=VFORK가 남아 있다면 제거하는 편이 좋습니다. JDK 25에서는 경고가 나오고, JDK 27 개발 버전에 반영된 변경에서는 FORK로 대체되어 큰 힙에서 실행 지연이 커질 수 있습니다.
이 설정이 남아 있을 만한 자리는 JVM 실행 스크립트와 Dockerfile의 JAVA_OPTS, 애플리케이션 서버의 기동 옵션입니다. Linux에서는 JDK 7부터 12까지 vfork()가 기본이었으므로, JDK 13에서 기본값이 POSIX_SPAWN으로 바뀔 때 이전 동작을 유지하려고 이 속성을 명시적으로 적어 둔 경우가 있습니다. 값은 대소문자를 구분하지 않으니 소문자 vfork 로 적힌 설정도 같은 대상입니다. 지금은 이 값을 지정할 이유가 없으므로 속성을 지우고 기본값으로 돌아가면 됩니다.
OpenJDK가 Linux에서 하위 프로세스를 만드는 방식은 구현 전용 시스템 속성인 jdk.lang.Process.launchMechanism 으로 선택할 수 있습니다. 기본값의 변천은 아래와 같습니다. JDK 27 항목은 정식 출시 결과가 아니라 2026-09-06 현재 개발 버전에 반영된 내용입니다.
| JDK | Linux에서의 변화 |
|---|---|
6 |
|
7 |
|
12 |
|
13 |
|
25 |
|
27 |
개발 버전에서 |
앞에서 정리한 대로 vfork()는 fork()의 주소 공간 복제 비용을 피하는 대신, 자식이 exec() 전에 부모의 스택이나 시그널 처리를 건드릴 위험을 안습니다. JDK는 그 사이에 파일 디스크립터 정리 같은 작업을 하며 이 규칙을 어겼고, JDK-8357090은 이를 제거 이유로 설명합니다. 앞에서 본 jspawnhelper는 바로 이 작업을 exec() 뒤로 옮긴 장치입니다.
vfork()를 기본값으로 삼은 이 선택은 당시에도 논란거리였습니다. vfork()는 표준 함수가 아닙니다. vfork(2) 매뉴얼의 STANDARDS 항목은 "None"이고, HISTORY 항목은 3.0BSD에서 등장한 이 함수가 POSIX.1-2001에 OBSOLETE로 표시됐다가 POSIX.1-2008에서 명세가 삭제됐다고 적습니다. 4.4BSD는 vfork()를 아예 fork()와 같은 것으로 만들었고, Linux도 2.2.0-pre6 무렵까지는 fork()와 같게 동작하다가 2.2.0-pre9부터 독립된 시스템 콜이 됐습니다. 앞에서 인용한 D2 글은 이 점을 짚어 POSIX 매뉴얼이 사용을 권장하지 않고 Solaris 10에서는 deprecated됐으며, 당시 Ubuntu 매뉴얼에는 Linux가 이 명세를 살린 것이 유감이라는 언급까지 있다고 적었습니다. 그러면서도 GCC가 vfork()를 계속 쓰고 교체 주장이 받아들여지지 않은 점, glibc가 posix_spawn()을 구현할 때 가능하면 vfork()를 쓰는 점을 들어 Linux 커널에서는 vfork()가 건재하니 JDK의 기본 실행 방식을 써도 괜찮다고 결론지었습니다. 커널에 대해서는 이 예상이 맞았습니다.
JDK 25에서 이 옵션을 지정하면 아래 경고가 표준 오류에 출력됩니다.
$ java -Djdk.lang.Process.launchMechanism=VFORK ProcessRunner
VFORK MODE DEPRECATED
The VFORK launch mechanism has been deprecated for being dangerous.
It will be removed in a future java version. Either remove the
jdk.lang.Process.launchMechanism property (preferred) or use FORK mode
instead (-Djdk.lang.Process.launchMechanism=FORK).
vfork() 시스템 콜 자체는 Linux 커널에 그대로 있습니다. vfork(2) 매뉴얼은 여전히 vfork()를 CLONE_VM | CLONE_VFORK | SIGCHLD 플래그를 준 clone()과 같다고 설명합니다. 없어진 쪽은 커널의 vfork()가 아니라 JDK의 VFORK 모드였습니다. 표준에서 빠져서도 커널이 바뀌어서도 아니라, JDK 자신이 vfork()와 exec() 사이에서 하던 작업이 안전하지 않아서입니다.
이 글의 예제와 측정은 JDK 25 기준입니다. 구버전 JDK에 적용할 때는 실행 방식과 API 지원 범위를 따로 확인해야 합니다. 예를 들어 OpenJDK 11u는 11.0.4부터 POSIX_SPAWN을 옵션으로 지정할 수 있지만 기본값은 vfork()이고, OpenJDK 8u의 Linux 구현은 검증일 현재 이 옵션을 지원하지 않습니다. 어느 버전이든 사용하는 JDK 계열의 최신 보안 업데이트를 적용해야 합니다.
마치며
외부 프로세스 실행 코드는 출력 처리, 시간제한, 종료 정책을 함께 설계해야 합니다. 출력을 가공할 필요가 없으면 Redirect.DISCARD나 inheritIO()를 쓰고, 표준 출력과 표준 오류를 따로 받아야 하면 두 스트림을 동시에 읽어야 합니다. 표준 입력을 읽는 명령에는 EOF를 전달해야 합니다. 시간제한 뒤에는 종료 요청뿐 아니라 강제 종료 여부와 자원 정리까지 챙겨야 합니다.
zt-exec과 Commons Exec은 이 코드를 줄여 줍니다. 출력과 종료 코드의 기본값, 시간 초과를 알리는 방식, 의존성을 비교해 프로젝트에 맞는 쪽을 고르면 됩니다. 두 라이브러리 모두 기본 destroy()만으로 강제 종료까지 보장하지는 않으며, 후손 프로세스 정리는 프로세스 그룹이나 cgroup 같은 OS 수준의 묶음이 필요합니다.
Linux의 JDK 25에서는 기본값인 POSIX_SPAWN을 유지하는 편이 무난합니다.
참고 자료
-
JDK 문서
-
Runtime (Java SE 25):
exec(String)계열의 deprecation 사유 -
Runtime.java (JDK 25+36):
exec()의ProcessBuilder위임 구현 -
ProcessImpl_md.c (JDK 25+36): 실행 방식별 장단점과 glibc, musl의
posix_spawn()구현을 설명한 주석
-
라이브러리
-
zt-exec: README의 사용 예
-
zt-exec CHANGELOG: 1.13.0의 변경 사항과 릴리스 날짜
-
Apache Commons Exec Changes: 1.4.0 이후의 builder API와
Duration도입
-
-
OpenJDK 이슈
-
JDK-6868160 (process) Use vfork, not fork, on Linux to avoid swap exhaustion
-
JDK-8212828 (process) Provide a way for Runtime.exec to use posix_spawn on linux
-
JDK-8213192 (process) Change the Process launch mechanism default on Linux to be posix_spawn
-
JDK-8357180 Deprecate VFORK launch mechanism from Process implementation (linux)
-
JDK-8357090 Remove VFORK launch mechanism from Process implementation (linux)
-
JDK-8357089 구현 커밋: JDK 27 개발 버전의
VFORK제거와FORK대체
-
-
Linux 매뉴얼과 커널
-
pipe(7): 파이프 용량
-
posix_spawn(3): glibc 2.24 이후의 구현
-
Linux 5.2 mm/util.c:
__vm_enough_memory()의 모드 0 판정 -
Linux 6.17 mm/mmap.c:
dup_mmap()의 잠금과 페이지 테이블 복제 -
mm: fix false-positive OVERCOMMIT_GUESS failures: 커널 5.2의 휴리스틱 변경 패치
-
-
When Runtime.exec() won’t: 2000년에 나온 Michael Daconta의 글
-
Java에서 외부 프로세스를 실행할 때 (NAVER D2, 2015):
fork()의 메모리 문제와 JDK 7의vfork()도입 배경
Twitter
Facebook
Reddit
LinkedIn
Email