Java에서 외부 명령을 실행하는 코드는 ProcessBuilder로 몇 줄이면 됩니다. 그런데 출력을 읽는 순서나 시간제한을 빠뜨린 코드는 개발 중에는 잘 돌다가, 출력이 커지거나 명령이 멈추는 날 애플리케이션까지 함께 멈추게 합니다. 이 글은 JDK 25와 Linux 6.x에서 지금도 지켜야 할 규칙을 예제 코드로 먼저 정리합니다. 이어서 관련 코드를 줄여 주는 라이브러리인 zt-exec과 Apache Commons Exec을 비교합니다. 뒷부분에서는 JDK가 프로세스를 만드는 방식이 fork(), vfork(), posix_spawn()으로 바뀌어 온 과정과 그에 따라 새로 생긴 주의점을 다룹니다.
이 글의 실행 결과는 아래 환경에서 확인했습니다. 예제 코드는 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는 java.lang.Process 클래스로 외부 프로세스와의 접점을 제공합니다. Process 객체는 ProcessBuilder.start()나 Runtime.exec()로 얻고, Runtime.exec()도 내부에서 ProcessBuilder를 만들어 호출하므로 프로세스 생성 구현은 같습니다. 다만 Runtime.exec(String)는 문자열을 공백으로 나누므로 인자를 배열로 전달하는 방식과 입력 해석은 다릅니다. 이 구도는 JDK 5 이후로 바뀌지 않았고, 아래 세 가지 주의점도 JDK 25에서 그대로 유효합니다.
읽지 않은 파이프 버퍼가 부르는 교착
하위 프로세스의 표준 출력과 표준 오류는 기본 설정에서 파이프로 부모 프로세스에 전달됩니다. 부모가 파이프를 읽지 않으면 파이프 버퍼가 차고, 버퍼가 찬 뒤로는 하위 프로세스의 write() 호출이 멈춥니다. 이 상태에서 부모가 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();
System.out.println("read " + lines + " lines, exit=" + process.waitFor());
실행 결과입니다. 3초가 지나도 seq 프로세스는 살아 있고, 부모가 파이프를 읽기 시작해야 끝납니다.
finished within 3s: false, alive: true
read 100000 lines, exit=0
waitFor()에 제한 시간을 주지 않았다면 이 프로그램은 영원히 멈춰 있었을 것입니다. 파이프를 읽는 작업 없이 waitFor()부터 부르는 코드가 이 문제를 만듭니다. 이 예제처럼 먼저 출력을 읽거나, 별도 스레드에서 출력을 읽으면서 종료를 기다려야 합니다. inputReader() 메서드는 JDK 17에 추가된 것으로, getInputStream() 위에 BufferedReader를 얹는 코드를 대신합니다.
출력이 필요 없을 때의 리다이렉트
출력을 부모 프로세스가 가공할 필요가 없다면 파이프를 만들지 않는 편이 가장 단순합니다. ProcessBuilder의 redirectOutput() 메서드와 redirectError() 메서드에 Redirect.INHERIT를 지정하면 하위 프로세스가 부모의 표준 출력과 표준 오류에 바로 씁니다. 세 스트림을 모두 물려주는 inheritIO() 메서드는 JDK 7부터 있었습니다. JDK 9에 추가된 Redirect.DISCARD는 출력을 /dev/null로 보내고, Redirect.to(File)은 파일로 보냅니다. 표준 오류만 표준 출력에 합치고 싶으면 redirectErrorStream(true)를 씁니다. 이렇게 하면 읽어야 할 파이프가 하나로 줄어서 스레드 하나로 처리할 수 있습니다.
기본값인 Redirect.PIPE를 그대로 두고 출력 스트림을 읽지 않는 코드는 출력이 파이프 용량을 넘으면 멈출 수 있습니다. getInputStream()을 호출하는 것만으로는 부족하고 실제로 읽어야 합니다. 출력을 버릴 생각이라면 표준 출력과 표준 오류 모두 명시적으로 DISCARD를 지정하는 편이 안전합니다.
두 스트림 동시 읽기와 시간제한
표준 출력과 표준 오류를 따로 받아서 가공해야 한다면 파이프 두 개를 동시에 비워야 합니다. 한 파이프를 다 읽은 뒤에 다른 파이프를 읽는 순차 처리로는 부족합니다. 표준 오류 파이프가 먼저 차면 하위 프로세스는 표준 출력을 더 쓰지 못하고, 부모는 표준 출력의 EOF를 기다리는 교착이 생깁니다. 그래서 스트림마다 스레드가 하나씩 필요합니다.
시간제한도 같이 챙겨야 합니다. 외부 명령이 네트워크나 잠금을 기다리며 멈추면 waitFor()를 부른 스레드도 함께 멈춥니다. JDK 8부터 있는 waitFor(long, TimeUnit) 메서드는 시간이 지나면 false를 돌려줄 뿐 프로세스를 종료하지 않습니다. 이때 destroy()로 정상 종료를 요청하고 유예 시간 뒤에도 살아 있으면 destroyForcibly()를 쓰거나, 아래 예제처럼 바로 강제 종료할 수 있습니다. Linux의 OpenJDK 구현에서 destroy()는 SIGTERM을, destroyForcibly()는 SIGKILL을 보냅니다. 스트림을 읽는 스레드는 JDK 21의 가상 스레드로 만들었습니다.
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
이 예제는 입력을 요구하지 않고 후손 프로세스를 만들지 않는 명령을 대상으로 합니다. 실제로는 더 챙길 것이 있습니다. 입력이 필요 없는 명령에는 process.getOutputStream().close()로 표준 입력의 EOF를 전달해야 할 수도 있습니다. 읽기 스레드의 예외를 호출 스레드에 전달하고, 인터럽트나 시간 초과 때도 프로세스와 스트림을 정리해야 합니다. 위 코드의 1초 제한은 waitFor()에만 적용되며, start()나 뒤이은 종료 대기와 join()까지 포함한 전체 시간제한은 아닙니다.
출력이 아주 크면 StringBuilder에 다 담지 말고 줄 단위나 고정 크기 버퍼로 처리해야 합니다. destroyForcibly()는 직접 만든 프로세스만 끝냅니다. JDK 9의 ProcessHandle.descendants()로 후손을 조회할 수 있지만 그 결과는 스냅샷이므로, 조회와 종료 사이에 생기거나 부모 종료 뒤 재부모화된 프로세스까지 확실하게 정리하지는 못합니다. 후손이 파이프를 계속 열고 있으면 읽기 스레드의 종료도 늦어질 수 있습니다. 출력 인코딩이 UTF-8이 아닌 명령도 있습니다. 다음 절의 라이브러리는 출력 처리와 시간제한 코드를 줄여 주지만, 이런 조건까지 모두 해결해 주지는 않습니다.
zt-exec과 Apache Commons Exec
앞 절의 규칙을 프로젝트마다 다시 구현하는 대신 라이브러리를 쓸 수 있습니다. zt-exec은 ProcessBuilder를, Commons Exec의 일반적인 Java 실행 경로는 Runtime.exec()를 사용하며, 결국 JDK의 프로세스 생성 구현을 거칩니다. 두 라이브러리 모두 pump 스레드로 출력 파이프를 비웁니다. 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을 알립니다. Linux에서 SIGTERM을 무시하는 프로세스까지 강제 종료하는 것은 아닙니다. 종료 코드가 잘못된 경우와 시간 초과를 예외 타입만으로 구분할 수 있습니다. -
종료 코드 검사를 선언합니다. 기본값은 모든 종료 코드를 허용하고,
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은 2015년 글에서도 소개한 라이브러리입니다. 그때 최신이던 1.3은 2014년 11월에 나왔고, 그다음 버전인 1.4.0은 10년 뒤인 2024년 1월에 나왔습니다. 이후 2025년 5월에 1.5.0, 2025년 11월에 1.6.0이 나왔습니다. 1.4.0부터 최소 Java 버전이 8이 됐고, DefaultExecutor 와 ExecuteWatchdog 의 생성자가 deprecated로 표시되면서 builder API가 그 자리를 대신합니다. 시간제한 인자도 밀리초 대신 Duration을 받습니다. 의존성은 없습니다.
<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() 호출 여부를 나타내며 실제 종료를 보장하지 않습니다. SIGTERM을 무시하면 execute()가 계속 기다릴 수 있고, 종료 처리를 수행한 명령이 허용된 코드로 끝나면 시간 초과 뒤에도 예외 없이 반환될 수 있으므로 정상 반환 경로에서도 확인해야 합니다.
CommandLine.addArguments(String)는 따옴표를 해석하며 인자를 나누므로, 공백을 포함한 인자를 문자열로 조립할 때 주의해야 합니다. 배열을 받는 메서드도 기본적으로 인자에 따옴표를 추가할 수 있습니다. Linux의 Runtime.exec(String[])는 셸을 거치지 않으므로 그 따옴표가 인자의 일부로 전달될 수 있습니다. 그래서 위 예제는 이미 분리된 인자 배열에 두 번째 인자를 false로 주어 추가 quoting을 껐습니다.
유지보수 현황과 선택
이 글을 쓰기 전에는 Commons Exec이 업그레이드가 잘 안 되는 라이브러리이니 zt-exec이 낫다고 생각했습니다. 그런데 Maven Central의 버전 목록과 각 프로젝트의 변경 기록을 확인해 보니 그 인상은 2023년까지만 맞았습니다. 아래는 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이 아닌 종료 코드 |
기본 허용, |
기본 |
Commons Exec은 2014년의 1.3 이후 2023년까지 후속 릴리스가 없었지만, 2024년부터 Apache Commons의 유지보수자인 Gary Gregory가 builder API와 Duration 지원을 넣고 2년 사이에 세 번 릴리스했습니다. 반대로 zt-exec은 2020년 9월의 1.12 이후 2026년 7월의 1.13.0까지 6년 가까이 릴리스가 없었습니다. 1.13.0에서 빌드를 Maven에서 Gradle로 옮기고 JPMS 모듈 정보를 넣었으니 릴리스가 재개된 것은 맞습니다. 그래도 최근 2년만 보면 릴리스가 더 잦은 쪽은 Commons Exec입니다.
그러니 zt-exec을 고를 이유는 유지보수 빈도가 아니라 API 설계에 있습니다. 시간 초과와 종료 코드 오류를 예외 타입으로 구분하고, 출력을 문자열로 받는 데 메서드 호출 하나면 되고, 기본 설정에서 출력 파이프를 읽지 않아 생기는 교착을 피할 수 있습니다. 저는 새로 쓰는 코드라면 zt-exec을 고르겠습니다. 다만 라이브러리의 전이 의존성을 늘리고 싶지 않거나 Apache 프로젝트의 거버넌스를 선호한다면 Commons Exec 1.6.0도 builder API로 쓸 만합니다. 이미 Commons Exec을 쓰고 있는 코드를 zt-exec으로 바꿀 만큼 큰 차이는 아닙니다. 두 라이브러리 모두 기본 종료 요청은 destroy()이므로, 강제 종료와 후손 프로세스 정리가 필요한 작업에는 별도 정책을 더해야 합니다.
fork의 메모리 문제, 그 후
현재 Linux에서 JDK 25를 쓰는 Java 개발자는 jdk.lang.Process.launchMechanism을 지정하지 않고 기본값인 POSIX_SPAWN을 유지하면 됩니다. JDK 13부터 이 방식이 기본이며, 현대 glibc에서는 부모 JVM의 주소 공간과 페이지 테이블을 복제하는 fork()를 피합니다. 큰 힙 때문에 외부 명령 실행이 실패할까 봐 VFORK를 강제로 켜거나 overcommit 설정부터 바꿀 필요는 없습니다. 다만 실제 메모리 부족이나 프로세스 생성에 필요한 자원 부족까지 없어지는 것은 아닙니다.
운영에서는 실행 중인 JVM이 사용하는 JDK 파일을 덮어쓰지 않도록 하고, JDK 갱신과 JVM 재시작을 함께 계획해야 합니다. 기본 방식이 사용하는 jspawnhelper와 JVM의 버전이 어긋나면 외부 명령 실행이 실패할 수 있습니다. 예전 설정에 남은 VFORK 지정도 지우는 편이 좋습니다. JDK 25에서는 경고가 나고, 2026-09-06 현재 JDK 27 개발 버전에 반영된 변경에서는 경고와 함께 FORK로 대체되어 큰 힙의 실행 지연이 다시 커질 수 있습니다. 아래는 이 결론의 배경입니다.
2015년 글의 핵심은 힙이 큰 Java 프로세스가 외부 프로세스를 실행하면 Cannot allocate memory 예외가 날 수 있다는 것이었습니다. 원인은 fork()에 있었습니다. fork()는 부모와 같은 자식 프로세스를 만드는데, Linux는 쓰기 시 복사(copy-on-write)로 실제 페이지 복사는 미루지만 페이지 테이블은 복사하고, 커널의 메모리 overcommit 정책에 따라 자식이 쓸지도 모르는 메모리를 미리 계산해서 거절하기도 합니다. 자식은 곧바로 exec()로 작은 프로그램으로 바뀔 테지만 커널은 그 사실을 알 수 없습니다.
JDK는 부모 주소 공간의 복제를 피하는 실행 방식으로 바뀌었고, Linux 커널은 기본 overcommit 정책에서 불필요하게 할당을 거절하던 조건을 완화했습니다. 두 변화의 적용 범위는 다릅니다.
JDK의 실행 방식 변천
OpenJDK가 Linux에서 하위 프로세스를 만드는 방식은 구현 전용 시스템 속성인 jdk.lang.Process.launchMechanism 으로 선택할 수 있습니다. 기본값의 변천은 아래와 같습니다. JDK 27 항목은 정식 출시 결과가 아니라 검증일 현재 개발 버전에 반영된 내용입니다.
| JDK | Linux에서의 변화 |
|---|---|
6 |
|
7 |
|
12 |
|
13 |
|
25 |
|
27 |
개발 버전에서 |
2015년에는 "JDK 7부터 Linux에서 vfork()를 쓰니 메모리 문제는 피할 수 있다"가 결론이었습니다. vfork()는 페이지 테이블을 복사하지 않고 자식이 부모의 메모리 이미지 안에서 exec()까지 실행하므로, fork()의 주소 공간 복제에 따른 메모리 비용을 피합니다. 대신 자식이 exec() 전에 부모의 스택이나 시그널 처리를 건드릴 위험이 있고, JDK는 그 사이에 파일 디스크립터 정리 같은 작업을 하며 vfork()의 사용 규칙을 어겼습니다. JDK-8357090은 이를 제거 이유로 설명합니다. 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).
지금의 기본값인 posix_spawn()은 시스템 콜이 아니라 C 라이브러리 함수입니다. fork와 exec, 그 사이의 준비 작업을 한 묶음으로 제공한다는 점에서 Windows의 CreateProcess()와 닮았습니다. JDK는 이 함수로 jspawnhelper 라는 작은 실행 파일을 먼저 띄우고, jspawnhelper 가 파이프로 넘겨받은 설정에 따라 파일 디스크립터와 작업 디렉터리를 정리한 뒤 실제 명령을 다시 exec()합니다. 이 구조는 JDK 25 소스의 ProcessImpl_md.c 상단 주석에 설명되어 있습니다.
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()와 같은 효과를 냅니다. 이것이 다음 절의 내용입니다. 같은 프로그램을 -Djdk.lang.Process.launchMechanism=FORK 로 실행하면 CLONE_VM 플래그 없이 clone 시스템 콜을 부르고, jspawnhelper 없이 곧바로 /usr/bin/echo 를 execve합니다.
glibc와 커널의 변화
JDK가 posix_spawn()으로 옮겨 갈 수 있었던 배경에는 glibc의 변화가 있습니다. posix_spawn(3) 매뉴얼에 따르면 glibc 2.24부터 posix_spawn()은 CLONE_VM 과 CLONE_VFORK 플래그로 clone()을 호출합니다. 자식에게 별도 스택을 주고, 생성 과정에서 시그널을 차단하고 자식의 핸들러를 정리해 부모 스택과 시그널 처리에 관한 위험을 줄입니다. JDK 25 소스의 주석도 이 이유로 glibc 2.24 이상의 방식을 가장 좋은 선택으로 설명하며, musl 역시 이 clone() 방식을 사용해 왔다고 적고 있습니다. 2015년 글에서 "glibc가 posix_spawn()을 구현할 때 가능하면 vfork()를 쓴다"고 적었는데, glibc 2.24에서 그 내부 구현이 바뀐 것입니다. vfork() 함수 자체가 glibc에서 없어진 것은 아닙니다.
vfork() 시스템 콜 자체는 Linux 커널에 그대로 있습니다. vfork(2) 매뉴얼은 여전히 vfork()를 CLONE_VM | CLONE_VFORK | SIGCHLD 플래그를 준 clone()과 같다고 설명하고, POSIX.1-2008에서 명세가 삭제됐다는 사실도 함께 적고 있습니다. 즉 JDK가 VFORK를 없앤 이유는 커널이 바뀌어서가 아니라 JDK 자신이 vfork()와 exec() 사이에서 하던 작업이 안전하지 않아서입니다.
커널 쪽에서는 overcommit 휴리스틱이 바뀌었습니다. vm.overcommit_memory 의 기본값 0은 "명백한 overcommit만 거절하는" 모드입니다. 커널 5.1의 코드는 이 판정에 여유 메모리, 페이지 캐시, 스왑 여유분 등을 반영했습니다. fork()가 힙 매핑을 복제할 때 요구하는 크기가 이 계산값을 넘으면 ENOMEM 으로 거절될 수 있었습니다. 2015년 글이 다룬 실패를 설명하는 경로 중 하나입니다. 다만 당시 서버의 설정과 실행 기록 없이 개별 장애의 원인을 이 경로로 확정할 수는 없습니다.
커널 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()가 페이지 테이블을 복사한다는 사실은 변하지 않았습니다. 실제로 접근한 힙 영역과 페이지 크기 등에 따라 복사 비용이 달라집니다. 힙을 미리 할당한 상태에서 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로 바꾸기 전에 실제 워크로드로 측정해야 하는 이유입니다.
jspawnhelper와 관련된 새 주의점
posix_spawn() 방식은 jspawnhelper 실행 파일에 의존하기 때문에 예전에는 없던 실패 유형이 하나 생겼습니다. JDK가 실행 중인 상태에서 같은 경로의 JDK가 새 버전으로 덮어씌워지면, 이미 메모리에 올라간 JVM이 새 버전의 jspawnhelper 를 실행하게 됩니다. Ubuntu의 unattended-upgrades처럼 패키지를 자동으로 갱신하는 환경에서 실제로 일어나는 일입니다. JDK 23의 JDK-8325621부터 jspawnhelper 가 자신을 실행한 JVM과 버전이 같은지 검사해서 다르면 아래와 같은 예외를 던집니다. 이 검사는 22.0.2, 21.0.4, 17.0.13에도 백포트됐습니다.
JDK 25 디렉터리를 복사해서 Java 프로그램을 띄운 뒤, 실행 중에 lib/jspawnhelper 를 JDK 17의 것으로 바꿔치기해서 이 상황을 재현했습니다. 표준 오류에 jspawnhelper 의 메시지가 먼저 찍히고, 이어서 원인 후보와 해결책까지 적힌 예외가 납니다.
Incorrect Java version: 25+36-LTS
jspawnhelper version 17.0.16+8
This command is not for general use and should only be run as the result of a call to
ProcessBuilder.start() or Runtime.exec() in a java application
Exception in thread "main" java.io.IOException: Cannot run program "echo": Failed to exec spawn helper: pid: 288611, exit code: 1, error: 0 (none)
Possible reasons:
- Spawn helper ran into JDK version mismatch
- Spawn helper ran into unexpected internal error
- Spawn helper was terminated by another process
Possible solutions:
- Restart JVM, especially after in-place JDK updates
- Check system logs for JDK-related errors
- Re-install JDK to fix permission/versioning problems
- Switch to legacy launch mechanism with -Djdk.lang.Process.launchMechanism=FORK
앞의 strace 출력에서 jspawnhelper 의 두 번째 인자로 25+36-LTS 가 넘어간 것이 이 버전 검사입니다. 이전에는 내부 프로토콜이 다른 helper를 실행했을 때 실패 원인이 불명확할 수 있었고, 이 검사는 버전 불일치를 명시적으로 진단하게 합니다. 위 예외의 상세한 원인 후보와 해결책 문구는 JDK 25에서 확인한 것이며, 버전 검사가 백포트된 모든 버전이 같은 메시지를 출력하는 것은 아닙니다.
버전 불일치라면 일관된 JDK 설치 상태를 확인하고 JVM을 재시작하는 것이 우선입니다. 파일 권한이나 설치 손상이 원인이면 그 문제도 고쳐야 합니다. 예방하려면 JDK를 버전별 경로에 설치하고 실행 중인 JVM의 java.home이 가리키는 기존 파일은 종료할 때까지 보존해야 합니다. 새 JVM용 심볼릭 링크를 바꾸는 것만으로 기존 JVM의 갱신이 끝나지는 않습니다. 자동 갱신을 관리한다면 JDK 교체와 서비스 재시작을 함께 배포 절차에 넣는 편이 좋습니다.
JDK-8357090의 CSR은 posix_spawn()의 예기치 않은 문제를 우회할 수 있도록 FORK를 대안으로 둔다고 설명합니다. jspawnhelper 관련 오류가 반복되는데 원인을 바로 찾기 어렵다면 JVM 시작 옵션에 -Djdk.lang.Process.launchMechanism=FORK 를 주어 임시 회피할 수 있습니다. 위에서 본 실행 지연과 overcommit 정책에 따른 메모리 부담을 감수하는 선택이며, 실행 중인 JVM에 즉시 적용하는 설정은 아닙니다.
2015년의 대처 방안 다시 보기
2015년 글에서는 네 가지 대처 방안을 들었습니다. 지금 기준으로 다시 평가하면 다음과 같습니다.
-
JNI로 직접 시스템 콜 호출: 필요 없어졌습니다. JDK가
posix_spawn()을 기본으로 쓰므로 직접 네이티브 코드를 쓸 이유가 없습니다. -
Java Service Wrapper, java_posix_spawn 같은 외부 라이브러리: 마찬가지로 필요 없어졌습니다. java_posix_spawn 저장소의 마지막 커밋은 2014년 8월입니다.
-
외부 프로세스 실행 전용 데몬: 현대 JDK의 기본 실행 방식에서 과거의
fork()메모리 문제만을 피하려는 목적이라면 보통 필요하지 않습니다. 격리 목적으로는 여전히 의미가 있습니다. JEP 486으로 JDK 24부터 Security Manager가 영구 비활성화되어, 예전의checkExec/FilePermission검사로 외부 명령 실행을 제한할 수는 없습니다. 피해 범위를 줄이려면 별도 프로세스와 사용자 계정, 컨테이너, systemd의 제한 기능 등으로 OS 수준에서 필요한 권한을 좁혀야 합니다. -
JDK 버전 올리기: 지금도 첫 번째 답입니다. Linux에서는 JDK 13 이상이면 기본값이
posix_spawn()이고,jspawnhelper버전 검사는 JDK 23에 들어간 뒤 17.0.13, 21.0.4, 22.0.2에도 백포트됐습니다. 이 번호는 해당 검사의 도입 버전이며, 운영에서는 사용하는 JDK 계열의 최신 보안 업데이트를 적용해야 합니다. OpenJDK 11u는 11.0.4부터POSIX_SPAWN을 옵션으로 지정할 수 있지만 기본값은vfork()입니다. OpenJDK 8u의 Linux 구현은 검증일 현재 이 옵션을 지원하지 않습니다.
jdk.lang.Process.launchMechanism 속성은 지정하지 않는 것이 좋습니다. 예전 문서를 보고 VFORK 를 명시한 실행 스크립트가 남아 있다면 JDK 25에서 경고가 나오고, JDK 27 개발 버전에 반영된 변경에서는 FORK 로 바뀌어 실행 지연이 커질 수 있습니다.
마치며
파이프 버퍼 교착은 2015년과 똑같이 남아 있습니다. 파이프 용량을 고정값으로 가정하지 말고, 출력이 필요 없으면 Redirect.DISCARD나 inheritIO()로 Java가 직접 읽을 파이프를 피하고, 필요하면 두 출력 스트림을 동시에 읽어야 합니다. 시간제한 뒤의 종료와 자원 정리도 챙겨야 합니다. zt-exec이나 Commons Exec은 이 코드를 줄여 주지만 기본 destroy()만으로 강제 종료까지 보장하지는 않습니다. Commons Exec이 방치된 라이브러리라는 인상은 2024년 이후로는 맞지 않았고, zt-exec을 고를 이유는 유지보수 빈도가 아니라 예외 타입과 기본값을 정리한 API에 있었습니다.
현재 Linux의 JDK 25에서는 기본 POSIX_SPAWN을 유지하고 JDK 갱신 시 JVM과 jspawnhelper 의 일관성을 관리하는 것이 결론입니다. glibc 2.24 이후의 구현은 부모 주소 공간 복제를 피하고, 커널 5.2의 변경은 별도로 FORK의 overcommit 판정을 완화했습니다. 어느 쪽도 모든 메모리 할당 실패를 없애지는 않습니다. 외부 프로세스 실행을 별도 서비스로 분리할지는 힙 크기만으로 정하기보다 권한 격리와 실제 실행 지연을 기준으로 판단하면 됩니다.
참고 자료
-
JDK 문서
-
Runtime (Java SE 25):
exec(String)계열의 deprecation 사유 -
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에서 외부 프로세스를 실행할 때: 2015년의 글
Twitter
Facebook
Reddit
LinkedIn
Email