Recent Posts

All Posts →

Java에서 외부 프로세스를 실행하기: JDK 25와 Linux 6.x 기준

JDK 25와 Linux 6.x에서 외부 프로세스를 실행할 때 필요한 출력 처리, 시간제한, 종료 정책을 예제로 정리합니다. zt-exec과 Apache Commons Exec의 API와 기본값을 비교하고, 기본 실행 방식인 posix_spawn의 동작, 큰 힙에서의 실행 지연과 실행 방식 설정의 변경 이력을 다룹니다.

이 글은 JDK 25와 Linux 6.x를 기준으로 Java에서 외부 프로세스를 실행하는 코드를 작성할 때 주의해야 할 요소와 활용할 수 있는 라이브러리를 소개합니다. 이어서 Linux에서 JVM이 자식 프로세스를 만드는 방식인 posix_spawn() 함수와 JDK에 포함된 작은 실행 파일인 jspawnhelper의 동작, 큰 힙에서 나타나는 실행 지연, 실행 방식을 바꾸는 설정까지 살펴봅니다. 이 글의 실행 결과는 아래 환경에서 확인했습니다. 예제 코드는 GitHub 저장소에 있습니다.

항목 값

OS

Ubuntu 24.04.4 LTS, 커널 6.17

아키텍처

x86-64

glibc

2.39

JDK

Temurin 25+36

zt-exec

1.13.0

Apache Commons Exec

1.6.0

JDK 기본 클래스로 외부 프로세스를 실행할 때의 주의점

ProcessBuilder와 Runtime.exec의 차이

Java 코드에서 실행한 외부 프로세스는 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

실행 시점

exec() 호출 시 실행

설정을 구성한 뒤 start()로 실행

명령과 인자

문자열 하나 또는 문자열 배열

인자를 구분한 가변 인자 또는 리스트

환경 변수

NAME=value 형식의 배열로 전달

environment()가 반환하는 Map으로 수정

작업 디렉터리

오버로드의 인자로 전달

directory()로 설정

입출력 설정

리다이렉트와 표준 오류 병합을 설정하는 API 없음

redirectOutput(), redirectError(), redirectErrorStream(), inheritIO() 등 제공

특히 명령을 문자열 하나로 받는 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) 파이프의 한정된 용량을 다 채우면, 자식 프로세스는 다음 쓰기에서 멈춥니다. 그런 상황을 막으려면 표준 출력과 표준 오류를 계속 소비하는 코드가 필요합니다. 다음 두 방법 중 하나를 써야 합니다.

  • 별도의 스레드에서 표준 출력과 표준 오류의 스트림을 읽습니다.

    • 두 출력을 구분해서 처리하거나 참조해야 한다면 스레드 두 개에서 각각 읽어야 합니다.

    • 두 출력을 구분할 필요가 없다면 하나로 합쳐 스레드 하나에서 읽을 수도 있습니다.

  • 해당 스트림을 읽을 필요가 없다면 파일, 부모의 출력, /dev/null 중 하나로 리다이렉트해야 합니다.

앞에서 나온 '기본 옵션으로 ProcessBuilder 호출' 코드는 위의 두 방법 중 어느 것도 쓰지 않았습니다. 이어지는 절에서 이 코드가 멈추는 과정을 예제로 재현하고 해결 방법을 설명합니다.

읽지 않은 파이프 버퍼가 부르는 교착

자식 프로세스의 표준 출력과 표준 오류는 ProcessBuilder에서도 기본값으로는 각각 별도의 파이프로 부모 프로세스에 전달됩니다. 부모가 파이프를 읽지 않으면 파이프 버퍼가 차고, 자식 프로세스는 쓰기를 마치지 못합니다.

waitFor()를 호출하지 않아도 이 문제는 생깁니다. 부모는 자식 프로세스 시작 후 이어지는 코드를 실행할 수 있지만, 자식 프로세스는 가득 찬 파이프에 쓰려는 write() 호출이 블록되어 작업을 마치지 못할 수 있습니다. 여기에 부모가 waitFor()로 자식 프로세스의 종료만 기다리면 서로를 기다리는 교착이 됩니다. 따라서 waitFor()를 빼도 해결되지 않고, 출력 파이프를 읽거나 리다이렉트해야 합니다. JDK 25의 Process Javadoc은 이 멈춤과 교착 가능성을 경고합니다.

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.

번역

일부 네이티브 플랫폼은 표준 입력과 표준 출력 스트림에 제한된 크기의 버퍼만 제공하므로, 프로세스의 입력 스트림에 제때 쓰지 않거나 출력 스트림을 제때 읽지 않으면 프로세스가 멈추거나 심하면 교착에 빠질 수 있습니다.

Javadoc에 언급된 "limited buffer size"가 Linux에서는 얼마인지 pipe(7) 매뉴얼에 나옵니다. 커널 2.6.11부터 기본 파이프 용량은 16페이지, 즉 4KiB 페이지 기준 64KiB입니다. 다만 사용자별 파이프 메모리 한도나 시스템 설정에 따라 더 작아질 수 있고, F_SETPIPE_SZ로 바꿀 수도 있으므로 64KiB를 고정 한계로 가정하면 안 됩니다.

아래 코드는 seq 1 100000 명령으로 588,895바이트를 출력하게 하고 읽지 않은 채 3초를 기다립니다.

DeadlockDemo.java
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());
DeadlockDemo.java의 실행 결과
finished within 3s: false, alive: true
read 100000 lines, exit=0, alive: false

부모 프로세스가 3초를 기다린 뒤에도 자식 프로세스는 살아 있습니다. 파이프 버퍼가 가득 찬 뒤로는 seq의 write() 호출이 블록되어 반환되지 않기 때문입니다. 부모가 파이프를 끝까지 읽자 자식 프로세스는 남은 출력을 마저 쓰고 종료 코드 0으로 끝났습니다(alive: 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이지만, -Dfile.encoding=COMPAT을 지정하면 기본 인코딩이 이전처럼 운영체제와 로캘 등에 따라 정해집니다.

inputReader()는 자식 프로세스의 출력 인코딩을 자동으로 감지하지 않습니다. 자식이 부모 JVM의 native.encoding과 같은 인코딩으로 출력한다면 이 기본값이 적합합니다. 자식이 UTF-8을 명시적으로 쓰거나 다른 로캘로 실행된다면 출력 인코딩에 맞춰 inputReader(StandardCharsets.UTF_8)처럼 직접 지정해야 합니다.

출력을 읽을 때는 두 스트림을 동시에 처리

표준 출력과 표준 오류를 따로 받아서 가공해야 한다면 파이프 두 개를 동시에 비워야 합니다. 한 파이프를 다 읽은 뒤에 다른 파이프를 읽는 순차 처리로는 부족합니다. 표준 오류 파이프가 먼저 차면 자식 프로세스는 표준 출력을 더 쓰지 못하고, 부모는 표준 출력의 EOF를 기다리는 교착이 생깁니다. 이 경우에는 waitFor()에 도달하기도 전에 읽기 작업에서 멈춥니다.

아래 코드는 표준 오류에 588,895바이트를 쓴 뒤에 표준 출력에 한 줄을 쓰는 명령을 실행하고, 표준 출력을 끝까지 읽은 다음에 표준 오류를 읽습니다.

SequentialReadDemo.java
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 프로세스도 그대로 살아 있습니다.

SequentialReadDemo.java의 실행 결과
reading stdout...

seq는 표준 오류 파이프가 차서 멈췄고, 부모는 아직 오지 않은 표준 출력의 EOF를 기다립니다. 표준 오류를 읽는 다음 줄은 실행되지 않으므로 교착이 풀리지 않습니다. 두 스트림에 각각 읽기 스레드를 두면 어느 한쪽을 읽느라 다른 쪽을 방치하는 문제를 피할 수 있습니다. 뒤에 나올 PlainJdkRunner 예제가 그 방식입니다.

두 출력을 구분할 필요가 없다면 redirectErrorStream(true)로 표준 오류를 표준 출력에 합칠 수 있습니다. 이렇게 하면 읽어야 할 파이프가 하나로 줄어서 스레드 하나로 처리할 수 있습니다.

MergedReadDemo.java
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 한 줄을 합한 수입니다.

MergedReadDemo.java의 실행 결과
read 100001 lines, exit=0

Java 프로세스에서 읽지 않을 출력은 리다이렉트

자식 프로세스의 출력을 부모 프로세스가 참조하거나 가공할 필요가 없다면 Java 코드가 읽어야 할 파이프를 만들지 않는 편이 가장 단순합니다. 기본값인 Redirect.PIPE 대신 쓸 수 있는 선택지는 다음과 같습니다.

  • Redirect.INHERIT : 자식 프로세스가 부모의 표준 출력과 표준 오류에 바로 씁니다. (JDK 7부터 지원)

    • inheritIO() 메서드는 부모 프로세스의 세 스트림(표준 입력, 표준 출력, 표준 오류)을 한 번에 물려줍니다. (JDK 7부터 지원)

  • Redirect.DISCARD : 출력을 /dev/null로 보냅니다. (JDK 9부터 지원)

  • Redirect.to(File) : 출력을 파일로 보냅니다. (JDK 7부터 지원)

자식 프로세스의 출력 Redirect
// 부모의 표준 출력과 표준 오류로 바로 내보내기
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바이트를 출력하고 정상 종료했습니다. 부모 JVM이 직접 읽어야 하는 자식 출력 파이프를 만들지 않으므로, 파이프를 읽지 않아서 생기는 교착이 없습니다. 다만 INHERIT로 물려받은 출력 대상이 파이프라면, 예를 들어 부모 JVM의 표준 출력이 파이프로 다른 프로그램에 연결되어 있다면, 그 프로그램이 읽는 속도에 따라 자식의 쓰기가 막히거나 늦어질 수 있습니다. 출력을 버릴 때는 표준 출력과 표준 오류 모두 DISCARD를 지정합니다. 한쪽만 지정하면 다른 쪽은 기본값인 PIPE로 남아서 앞의 교착이 다시 생길 수 있습니다. 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을 네 번 실행하면서 표준 입력을 다루는 방식만 바꿉니다.

StdinEofDemo.java
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());

다음은 실행 결과입니다. stdin에 쓰는 스트림을 열어 둔 첫 번째만 1초 안에 끝나지 못했고, 나머지 셋은 EOF를 받아 바로 종료했습니다.

StdinEofDemo.java 실행 결과
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와 BufferedWriter로 감싸는 코드를 대신합니다. 인자 없이 부르면 native.encoding 시스템 속성의 문자 집합을 쓰고, outputWriter(Charset) 메서드로 직접 지정할 수도 있습니다.

outputWriter()의 활용
// JDK 17 이전
try (BufferedWriter writer = new BufferedWriter(
        new OutputStreamWriter(process.getOutputStream()))) {
    writer.write("hello\n");
}

// JDK 17 이후
try (BufferedWriter writer = process.outputWriter()) {
    writer.write("hello\n");
}

시간제한과 종료 정책

지금까지 설명한 것처럼 자식 프로세스와 연결되는 파이프 세 개를 처리해도 외부 명령이 네트워크나 잠금을 기다리며 멈출 수 있습니다. 그래서 시간제한과 종료 정책이 별도로 필요합니다. 명령이 멈춰 있는 동안 waitFor()를 부른 스레드는 외부 명령이 끝날 때까지 블로킹됩니다. JDK 8부터 있는 waitFor(long, TimeUnit) 메서드는 시간이 지나면 false를 돌려줄 뿐 프로세스를 종료하지 않습니다. 그때는 destroy()로 정상 종료를 요청하고 유예 시간 뒤에도 살아 있으면 destroyForcibly()를 쓰거나, 아래 예제처럼 바로 강제 종료할 수 있습니다. Linux의 OpenJDK 구현에서 destroy()는 SIGTERM을, destroyForcibly()는 SIGKILL을 보냅니다. 프로세스는 SIGTERM을 핸들러로 받아서 임시 파일 삭제나 잠금 해제, 자신이 만든 자식 프로세스 정리 같은 마무리를 한 뒤에 끝날 수 있습니다. SIGKILL은 핸들러를 통한 마무리 기회 없이 강제 종료하므로 정리하지 못한 파일과 후손 프로세스가 남을 수 있습니다. 그래서 정리할 것이 있는 명령에는 destroy()로 SIGTERM을 먼저 보내고 유예 시간을 두는 편이 안전합니다. 다만 destroyForcibly()가 반환된 직후에도 프로세스가 잠시 살아 있을 수 있으므로, 종료 완료가 필요하면 waitFor()로 확인해야 합니다. 아래 예제는 sleep처럼 정리할 것이 없는 명령만 실행하므로 바로 강제 종료합니다. 두 출력 스트림을 각각 JDK 21의 가상 스레드에서 읽으면서 종료 대기에 1초 제한을 둡니다.

PlainJdkRunner.java
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 네 명령을 이 메서드로 실행한 결과입니다. seq의 588,895바이트 출력도 멈추지 않고, ls의 표준 오류는 따로 모이고, 1초를 넘긴 sleep은 예외로 끝납니다.

PlainJdkRunner.java의 실행 결과
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에 다 담지 말고 줄 단위나 고정 크기 버퍼로 처리합니다.

  • 출력 인코딩이 부모 JVM의 native.encoding과 다른 명령에는 inputReader(Charset)으로 인코딩을 지정합니다.

후손 프로세스의 정리

destroy()와 destroyForcibly()는 직접 만든 자식에게만 시그널을 보냅니다. 실행한 명령이 다시 자식 프로세스를 만들었다면 그 후손은 남을 수 있습니다. 자식이 먼저 끝나면 그 자식이 만든 후손 프로세스는 PID 1이나 가장 가까운 subreaper 프로세스의 자식으로 바뀌어 계속 실행됩니다. subreaper는 prctl(PR_SET_CHILD_SUBREAPER)로 지정한 프로세스로, 부모를 잃은 후손을 PID 1 대신 넘겨받습니다. systemd는 대부분의 Linux 배포판에서 부팅 때 PID 1로 가장 먼저 실행되어 다른 서비스를 시작하고 관리하는 프로그램이며, 로그인한 사용자마다 systemd --user라는 별도 인스턴스도 실행합니다. 데스크톱 세션의 systemd --user가 subreaper의 예입니다.

후손 프로세스가 남는 사례

후손 프로세스가 남는 상황은 크게 네 가지입니다.

  • 셸을 거쳐 실행한 명령: sh -c "a | b"나 run.sh 같은 스크립트를 실행하다가 시간 초과로 종료하는 경우입니다. 스크립트를 실행하는 비대화형 셸은 자신이 받은 SIGTERM을 자식에게 전달하지 않으므로 셸만 끝납니다.

  • 래퍼나 런처를 거쳐 실행되는 프로그램: npm run은 Node.js로 실행되는 npm이 sh -c로 스크립트를 실행하므로, npm 프로세스를 끝내도 셸이 실행한 명령이 남을 수 있습니다. LibreOffice의 soffice는 oosplash를 거쳐 실제 작업을 하는 soffice.bin을 실행하며, oosplash를 SIGKILL로 끝내면 soffice.bin은 종료되지 않고 남습니다. git도 fetch나 clone 중에 ssh나 git-remote-https를 자식으로 실행합니다.

  • 스스로 데몬이 되는 프로그램: Gradle daemon, Kotlin 컴파일 데몬, adb start-server, ssh의 ControlPersist 연결은 호출한 쪽이 끝나도 남아 있도록 만들어졌습니다. 이런 프로그램은 setsid()로 원래 세션과 그룹을 벗어나기도 합니다.

  • 백그라운드 작업을 기다리지 않는 스크립트: server & 뒤에 wait가 없으면 스크립트가 정상 종료해도 server는 계속 실행됩니다. 이 후손이 물려받은 출력 파이프를 열어 두면 waitFor()는 반환되어도 출력을 읽는 쪽은 EOF를 받지 못합니다.

첫 번째 경우를 재현해 보겠습니다. 아래 코드는 셸로 sleep 60을 실행하고 0.3초 뒤에 destroy()를 호출한 다음, 종료 전에 조회해 둔 후손 중 살아 있는 프로세스와 그 부모 PID를 출력합니다.

DescendantLeakDemo.java
public static void main(String[] args) throws Exception {
    run("sh", "-c", "sleep 60");
    run("sh", "-c", "sleep 60; echo done");
    run("sh", "-c", "sleep 60 | cat");
    run("bash", "-c", "sleep 60");
}

static void run(String... command) throws Exception {
    Process process = new ProcessBuilder(command).start();
    Thread.sleep(300);
    List<ProcessHandle> descendants = process.descendants().toList();
    process.destroy();
    process.waitFor(1, TimeUnit.SECONDS);
    Thread.sleep(100);

    List<String> left = descendants.stream()
            .filter(ProcessHandle::isAlive)
            .map(p -> name(p) + "(ppid=" + p.parent().map(ProcessHandle::pid).orElse(-1L) + ")")
            .toList();
    System.out.println(String.join(" ", command) + " -> shell alive=" + process.isAlive()
            + ", children=" + descendants.size() + ", left=" + left);
    descendants.forEach(ProcessHandle::destroyForcibly);
}
DescendantLeakDemo.java의 실행 결과
sh -c sleep 60 -> shell alive=false, children=1, left=[sleep(ppid=1693)]
sh -c sleep 60; echo done -> shell alive=false, children=1, left=[sleep(ppid=1693)]
sh -c sleep 60 | cat -> shell alive=false, children=2, left=[sleep(ppid=1693), cat(ppid=1693)]
bash -c sleep 60 -> shell alive=false, children=0, left=[]

sh -c로 실행한 세 경우 모두 셸은 끝났지만 sleep과 cat은 남았습니다. 남은 프로세스의 부모인 PID 1693은 이 환경의 systemd --user입니다. 명령이 한 줄뿐인 sh -c "sleep 60"에서도 후손이 남은 점을 주의해야 합니다. Ubuntu 24.04의 /bin/sh인 dash 0.5.12는 -c로 받은 마지막 명령도 자식 프로세스로 실행했습니다. 반면 bash -c는 셸 프로세스를 sleep으로 바꿔 실행했으므로(children=0) 남은 후손이 없습니다. 셸의 종류와 버전에 따라 이 동작이 다르므로, 셸을 거친 명령은 후손이 남는다고 가정하고 정리 방법을 준비하는 편이 안전합니다.

남은 후손이 곧 끝나는 명령이라면 문제는 그 후손이 실행되는 동안만 이어집니다. 그래도 그동안 시간 초과로 실패 처리한 작업이 뒤에서 계속 파일을 쓰거나 외부 API를 호출할 수 있습니다. 더 큰 문제는 스스로 끝나지 않는 후손입니다. 시간 초과가 났다는 것 자체가 명령이 제시간에 끝나지 않았다는 뜻이므로, 남은 후손도 곧 끝난다고 기대하기 어렵습니다. 서버나 데몬처럼 원래 끝나지 않는 후손은 실행할 때마다 쌓여 메모리와 프로세스 수 한도를 차지합니다. 포트나 파일 잠금을 계속 잡고 있으면 다음 실행이 Address already in use 같은 오류로 실패합니다.

남은 후손이 물려받은 출력 파이프를 계속 열고 있으면, 진행 중인 읽기가 후손의 파이프 닫기를 기다리면서 join()도 지연될 수 있습니다. JDK 25의 Process 구현은 직접 자식이 종료하면 파이프에 남은 출력을 회수하고 스트림을 닫지만, 이미 진행 중인 읽기와 이 종료 처리의 순서에 따라 대기 여부가 달라집니다. 위 예제처럼 JDK 9의 ProcessHandle.descendants()로 후손을 조회해 하나씩 종료할 수도 있습니다. 하지만 그 결과는 스냅샷이므로, 조회와 종료 사이에 생기거나 부모 종료 뒤 재부모화된 프로세스까지 확실하게 정리하지는 못합니다. 후손까지 한 번에 끝내려면 OS 수준의 프로세스 그룹이나 cgroup으로 관리해야 합니다.

프로세스 그룹 단위의 종료

후손이 같은 프로세스 그룹에 남는 명령은 setsid로 새 세션과 그룹을 만들어 실행하고, 작업이 끝나면 그 그룹 전체에 시그널을 보내 정리할 수 있습니다. JDK API에는 프로세스 그룹을 만들거나 그룹에 시그널을 보내는 기능이 없으므로, setsid와 kill 명령을 함께 실행하는 식으로 구성합니다.

ProcessGroupKillDemo.java
public static void main(String[] args) throws Exception {
    run("sleep 60 | cat");
    run("(trap '' TERM; sleep 60) & sleep 60");
    run("setsid sleep 60 & sleep 60");
}

static void run(String script) throws Exception {
    Process process = new ProcessBuilder("setsid", "sh", "-c", script).start();
    List<ProcessHandle> descendants = List.of();
    try {
        if (!process.waitFor(1, TimeUnit.SECONDS)) {
            descendants = process.descendants().toList(); // 결과 확인용 스냅샷
        }
    } finally {
        killGroup(process.pid()); // setsid로 그룹 리더가 된 자식의 PID가 곧 프로세스 그룹 ID
        process.waitFor();
    }

    List<String> left = descendants.stream()
            .filter(ProcessHandle::isAlive)
            .map(p -> p.info().commandLine().orElse("?"))
            .toList();
    System.out.println("[" + script + "] pgid=" + process.pid() + ", left=" + left);
    descendants.forEach(ProcessHandle::destroyForcibly);
}

static void killGroup(long pgid) throws Exception {
    signalGroup("TERM", pgid);
    if (!awaitGroupExit(pgid, 5)) {
        signalGroup("KILL", pgid);
        awaitGroupExit(pgid, 5);
    }
}

static boolean awaitGroupExit(long pgid, long seconds) throws Exception {
    long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
    while (signalGroup("0", pgid)) {
        if (System.nanoTime() > deadline) {
            return false;
        }
        Thread.sleep(100);
    }
    return true;
}

// kill의 종료 코드 0은 그룹에 시그널을 받은 프로세스가 하나 이상 있다는 뜻
static boolean signalGroup(String signal, long pgid) throws Exception {
    Process kill = new ProcessBuilder("kill", "-" + signal, "--", "-" + pgid)
            .redirectErrorStream(true)
            .redirectOutput(Redirect.DISCARD)
            .start();
    return kill.waitFor() == 0;
}

util-linux의 setsid 명령은 호출한 프로세스가 이미 그룹 리더일 때만 fork()하고, 그렇지 않으면 자기 프로세스에서 setsid()를 호출한 뒤 명령을 실행합니다. JVM이 만든 자식은 그룹 리더가 아니므로 PID가 바뀌지 않고, process.pid()를 새 그룹의 ID로 쓸 수 있습니다. kill의 PID 인자에 음수를 넘기면 그 절댓값을 ID로 쓰는 프로세스 그룹 전체에 시그널이 갑니다. 단, -1은 그룹이 아니라 권한이 있는 모든 프로세스를 뜻합니다. kill은 -9나 -KILL처럼 -로 시작하는 인자를 시그널로 받으므로, --를 앞에 두어 -295952를 PID로 읽게 합니다. 시그널 번호 0은 실제로 시그널을 보내지 않고 대상의 존재와 권한만 확인하므로, kill -0이 성공하면 그룹에 프로세스가 남아 있다는 뜻입니다.

waitFor()는 직접 자식인 셸만 기다리므로, 셸이 끝났다고 그룹 전체가 끝났다고 볼 수 없습니다. 그래서 killGroup()은 SIGTERM을 보낸 뒤 kill -0으로 그룹에 남은 프로세스가 있는지 확인하면서 최대 5초를 기다리고, 그 뒤에도 남아 있으면 그룹 전체에 SIGKILL을 보냅니다. 정리는 시간 초과 때만이 아니라 finally에서 항상 합니다. 직접 자식이 정상 종료해도 백그라운드로 띄운 후손은 남을 수 있기 때문입니다. 예를 들어 sleep 60 &만 실행하는 스크립트는 셸이 바로 끝나서 waitFor()가 true를 돌려주지만, finally의 killGroup()이 남은 sleep을 정리합니다.

ProcessGroupKillDemo.java의 실행 결과
[sleep 60 | cat] pgid=295952, left=[]
[(trap '' TERM; sleep 60) & sleep 60] pgid=295959, left=[]
[setsid sleep 60 & sleep 60] pgid=296018, left=[/usr/bin/sleep 60]

첫 번째 명령의 sleep과 cat은 셸과 같은 그룹에 있으므로 SIGTERM으로 함께 끝났습니다. 두 번째 명령의 셸은 SIGTERM으로 바로 끝났지만, SIGTERM을 무시하도록 실행한 sleep은 5초 유예 뒤 SIGKILL로 끝났습니다. 셸의 종료만 보고 SIGKILL을 생략했다면 이 sleep은 남았을 것입니다. 세 번째 명령에서 setsid로 실행한 sleep은 새 세션과 그룹으로 이동해서 그룹에 보낸 시그널을 받지 않았습니다. 후손이 setsid()나 setpgid()로 새 세션이나 다른 그룹으로 이동하면 이 방법으로는 정리하지 못합니다. 앞에서 본 데몬형 프로그램이 이런 경우에 해당합니다.

cgroup 단위의 종료

cgroup(control group)은 Linux 커널이 프로세스를 계층 구조의 그룹으로 묶어 CPU, 메모리, I/O, 프로세스 수 같은 자원을 제한하고 측정하는 기능입니다. Docker 컨테이너의 메모리 제한도 cgroup으로 동작합니다. fork()로 만든 자식은 부모의 cgroup에 속한 채로 시작하므로 손자와 그 아래 후손도 모두 같은 cgroup에 들어갑니다. setsid()는 세션과 프로세스 그룹만 바꾸고 cgroup은 바꾸지 않습니다. cgroup v2에서 프로세스를 다른 cgroup으로 옮기려면 원래 cgroup과 옮길 cgroup의 공통 조상에 있는 cgroup.procs 파일에 쓸 권한이 필요합니다. 그래서 권한이 없는 후손은 cgroup을 벗어나지 못합니다. 현재 프로세스가 속한 cgroup은 /proc/self/cgroup에서, 전체 트리는 systemd-cgls 명령으로 확인할 수 있습니다.

systemd는 서비스마다 cgroup을 따로 만들고, 서비스를 멈출 때 그 cgroup에 남은 프로세스를 모두 종료합니다(KillMode=control-group이 기본값). systemd-run --scope를 쓰면 한 번 실행할 명령에도 같은 방식을 적용할 수 있습니다. 아래 코드는 앞에서 프로세스 그룹을 벗어난 명령을 임시 scope 단위로 실행하고, 작업이 끝나면 그 단위를 멈춥니다.

SystemdScopeDemo.java
String unit = "job-" + UUID.randomUUID() + ".scope";
Process process = new ProcessBuilder(
        "systemd-run", "--user", "--scope", "--quiet", "--unit=" + unit,
        "sh", "-c", "setsid sleep 60 & sleep 60")
        .redirectError(Redirect.INHERIT)
        .start();
List<ProcessHandle> descendants = List.of();
try {
    if (process.waitFor(1, TimeUnit.SECONDS)) {
        System.out.println("exit=" + process.exitValue()); // systemd-run의 실패도 여기서 드러남
    } else {
        System.out.println("cgroup: " + Files.readString(Path.of("/proc/" + process.pid() + "/cgroup")).trim());
        descendants = process.descendants().toList(); // 결과 확인용 스냅샷
    }
} finally {
    int stopExit = new ProcessBuilder("systemctl", "--user", "stop", unit)
            .redirectError(Redirect.INHERIT)
            .start()
            .waitFor();
    System.out.println("systemctl stop exit=" + stopExit);
    if (!process.waitFor(10, TimeUnit.SECONDS)) {
        process.destroyForcibly().waitFor();
    }
}

List<String> left = descendants.stream()
        .filter(ProcessHandle::isAlive)
        .map(p -> p.info().commandLine().orElse("?"))
        .toList();
System.out.println("descendants=" + descendants.size() + ", left=" + left);
SystemdScopeDemo.java의 실행 결과
cgroup: 0::/user.slice/user-1000.slice/user@1000.service/app.slice/job-0fbacb17-d0c0-47de-a65d-db30d90ecfb1.scope
systemctl stop exit=0
descendants=2, left=[]

--scope로 실행하면 systemd-run은 scope 단위의 시작을 기다린 뒤 자기 프로세스를 명령으로 바꿔 실행하므로 PID가 바뀌지 않습니다. 명령은 여전히 JVM의 직접 자식이므로 앞의 예제들처럼 Process로 출력을 읽고 종료를 기다릴 수 있습니다. 실행 결과의 cgroup 경로가 job-…​scope로 끝나는 것으로 명령이 전용 cgroup에서 실행됐음을 확인할 수 있습니다. systemctl stop을 호출하자 setsid로 그룹을 벗어난 sleep까지 두 후손이 모두 끝났습니다. systemctl stop은 SIGTERM을 먼저 보내고, 유예 시간(TimeoutStopSec) 안에 끝나지 않은 프로세스에 SIGKILL을 보냅니다. 유예 시간은 systemd-run에 -p TimeoutStopSec=5s처럼 지정할 수 있습니다.

scope는 특정 프로세스가 아니라 그 안에 프로세스가 하나라도 남아 있는 동안 유지됩니다. 셸이 먼저 끝나도 백그라운드로 띄운 후손이 있으면 scope는 활성 상태로 남으므로, 앞의 프로세스 그룹 예제처럼 finally에서 항상 단위를 멈춥니다. systemd-run이 사용자 버스에 접속하지 못하는 등의 이유로 실패하면 오류를 표준 오류에 출력하고 0이 아닌 종료 코드로 끝나므로, 예제는 표준 오류를 부모로 넘기고 종료 코드를 출력합니다. systemctl stop도 종료 코드를 확인하고, 정리가 실패했을 때 무한정 기다리지 않도록 waitFor()에 제한 시간을 둡니다. 명령이 먼저 끝나 scope가 이미 사라졌다면 systemctl stop은 단위가 없다는 뜻의 종료 코드 5를 돌려주므로, 실제 코드에서는 이 경우를 정상으로 처리합니다.

이 방법에는 전제 조건이 있습니다.

  • --user를 쓰려면 사용자의 systemd 인스턴스가 실행 중이어야 합니다. 로그인 세션이 없는 서버의 서비스 계정이라면 loginctl enable-linger로 사용자 인스턴스를 유지하거나, 권한이 있는 system 인스턴스를 써야 합니다.

  • 대부분의 컨테이너 안에는 systemd가 없으므로 이 방법을 쓸 수 없습니다. 이때는 작업마다 별도 컨테이너를 실행해 컨테이너 단위로 정리하는 방법을 고려할 수 있습니다.

  • systemd 없이 cgroup v2를 직접 다룰 권한을 위임받았다면, 작업용 cgroup을 만들고 그 안에서 명령을 시작한 뒤 cgroup.kill 파일에 1을 써서 하위 cgroup까지 모든 프로세스에 SIGKILL을 보낼 수 있습니다. 이 파일은 Linux 5.14부터 지원합니다.

어느 방법이든 대상 작업을 처음부터 해당 cgroup 안에서 시작하고, 그 밖으로 이동할 권한과 종료 정책을 함께 관리해야 합니다.

정리하면 실행하는 명령을 직접 통제하고 그 명령이 데몬이 되지 않는다면 프로세스 그룹 방식으로 충분합니다. 임의의 스크립트나 외부에서 받은 명령을 실행한다면 cgroup 단위로 정리하는 편이 확실합니다. 다음 절의 라이브러리들도 출력 처리와 시간제한 코드를 줄여 주지만, 이런 조건까지 모두 해결해 주지는 않습니다.

zt-exec과 Apache Commons Exec의 활용

앞 절에서 본 출력 파이프, 표준 입력의 EOF, 시간제한, 종료 정책을 모두 챙긴 코드를 직접 작성하기는 쉽지 않습니다. 원리를 잘 이해한다고 해도 이 코드를 프로젝트마다 다시 구현하는 일은 번거롭습니다. 이런 처리가 반영된 라이브러리를 사용하는 편이 실무에서는 실용적입니다.

이 분야의 대표적인 라이브러리가 zt-exec과 Apache Commons Exec입니다. 두 라이브러리 모두 별도의 스레드로 출력 파이프를 비우고, 입력을 지정하지 않으면 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의 제목대로 ZT Process Executor이지만, GitHub 저장소와 Maven artifactId가 zt-exec이어서 보통 zt-exec으로 부릅니다. 이 글에서도 zt-exec으로 씁니다. README는 ProcessExecutor 클래스 하나로 ProcessBuilder와 Commons Exec의 기능을 모두 제공하는 것이 목표라고 설명합니다. 의존성은 slf4j-api 하나이고 최소 Java 버전은 8입니다.

<dependency>
    <groupId>org.zeroturnaround</groupId>
    <artifactId>zt-exec</artifactId>
    <version>1.13.0</version>
</dependency>

zt-exec의 장점은 다음과 같습니다.

  • 기본값이 안전합니다. 아무 설정 없이 execute()만 부르면 표준 오류를 표준 출력에 합치고 그 출력을 버립니다. 파이프를 읽지 않아서 생기는 교착이 기본값에서는 일어나지 않습니다. 출력이 필요하면 readOutput(true)를 켜고 결과의 outputUTF8()로 문자열을 받거나, redirectOutput()에 줄 단위로 문자열을 처리하는 함수를 람다 표현식으로 넘깁니다.

  • 시간제한이 예외 타입으로 구분됩니다. execute()의 대기가 timeout()으로 지정한 시간을 넘기면 호출자는 TimeoutException을 받고, 작업 스레드는 stopper로 종료를 시도합니다. 종료 코드가 잘못된 경우와 시간 초과를 예외 타입만으로 구분할 수 있습니다. 다만 예외를 잡은 시점에 stopper의 종료 처리가 완료됐다고 보장되지는 않습니다. 기본 stopper는 destroy()만 호출하므로 Linux에서 SIGTERM을 무시하는 프로세스까지 강제 종료하지는 않습니다. 종료 방식은 stopper()로 바꿀 수 있습니다. ProcessStopper 인터페이스를 구현해서 destroy() 뒤에 유예 시간을 두고 destroyForcibly()를 부르는 정책을 넣으면 됩니다.

  • 종료 코드 검사를 선언합니다. 기본값은 모든 종료 코드를 허용하고, exitValueNormal()이나 exitValues(0, 1)로 허용 범위를 지정하면 그 밖의 값에서 InvalidExitValueException이 발생합니다. 예외 객체의 getResult()로 그때까지의 출력도 볼 수 있어서 오류 메시지를 로그에 남기기 좋습니다.

  • 부수 기능을 간단히 설정합니다. start().getFuture()로 비동기로 실행할 수 있습니다. 다만 start()는 timeout() 설정을 무시하므로, Future.get(timeout, unit) 등으로 대기를 제한하고 시간 초과 때 취소·종료 처리를 따로 해야 합니다. get()의 시간 초과만으로 프로세스가 종료되지는 않습니다. destroyOnExit()로는 JVM의 shutdown hook에서 자식 프로세스에 종료를 요청합니다. redirectOutputAsInfo()로 SLF4J 로거에 출력을 전달할 수도 있지만, 이 메서드는 deprecated이며 redirectOutput(Slf4jStream.of(logger).asInfo())가 권장 API입니다.

앞 절의 네 명령을 zt-exec으로 실행한 예제입니다.

ZtExecRunner.java
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 프로세스가 남아 있지 않은 것도 확인했습니다.

ZtExecRunner.java 실행 결과
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)는 출력 파이프를 읽는 스레드가 읽은 바이트를 전부 ByteArrayOutputStream에 저장합니다. 결과를 만들 때 toByteArray()로 한 번 더 복사하고, outputUTF8()을 부르면 문자열도 만듭니다. ByteArrayOutputStream은 버퍼가 차면 크기를 두 배로 늘리므로 내부 버퍼만으로도 출력 크기보다 커질 수 있고, 한글처럼 Latin-1 밖의 문자가 섞인 문자열은 글자당 2바이트를 씁니다. 그래서 최대 메모리 사용량이 출력 크기의 두 배를 넘을 수 있습니다.

전체 출력을 보관할 필요가 없다면 위 예제의 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을 직접 넘깁니다.

CommonsExecRunner.java
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());
    }
}
CommonsExecRunner.java 실행 결과
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()가 계속 기다릴 수 있습니다. 반대로 SIGTERM을 받아 종료 처리를 한 명령이 허용된 코드로 끝나면 시간 초과 뒤에도 예외 없이 반환될 수 있습니다. 그래서 정상 반환 경로에서도 killedProcess()를 확인해야 합니다.

유지보수 현황과 선택

다음은 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

없음

기본 출력 처리

버림 (표준 오류는 표준 출력에 합침)

System.out, System.err로 전달

시간 초과 알림

TimeoutException

watchdog.killedProcess()로 확인 (실패 종료 코드이면 ExecuteException)

0이 아닌 종료 코드

기본 허용, exitValues()로 지정 시 InvalidExitValueException

기본 ExecuteException

시간 초과 시 종료 방식

destroy(), stopper()로 교체 가능

destroy() 고정

둘 중에서 저는 zt-exec을 더 추천합니다. API 설계에서 세 가지 장점이 있기 때문입니다. 시간 초과와 종료 코드 오류를 예외 타입으로 구분하고, 출력을 문자열로 받는 데 메서드 호출 하나면 되고, 기본 설정에서 출력 파이프를 읽지 않아 생기는 교착을 피할 수 있습니다. 두 라이브러리 모두 기본 종료 요청은 destroy()입니다. zt-exec은 stopper()로 강제 종료 정책을 넣을 수 있지만 Commons Exec은 별도 코드가 필요합니다.

Linux에서의 프로세스 생성과 운영

현재 Linux에서 JDK 25를 쓰는 Java 개발자는 jdk.lang.Process.launchMechanism 옵션을 별도로 지정하지 않고 기본값인 POSIX_SPAWN을 유지하면 됩니다. JDK 13부터 이 방식이 기본입니다. 이 장에서는 그 이유를 확인하기 위해 Linux가 제공하는 세 가지 프로세스 생성 방식과 JDK 기본값의 변천을 먼저 정리하고, POSIX_SPAWN의 동작, 대안인 FORK의 메모리 비용과 실행 지연, 마지막으로 JVM을 업그레이드할 때 정리해야 할 VFORK 설정을 차례로 살펴봅니다.

프로세스 생성 방식 세 가지와 JDK 기본값의 변천

애플리케이션의 외부 프로세스 실행 코드는 JVM을 거쳐 커널의 시스템 콜이나 C 라이브러리의 함수 호출로 이어집니다. 어떤 함수 호출로 자식 프로세스를 만드는지가 중요한 이유는 이 선택이 과거에 실제 장애로 이어졌기 때문입니다. 힙을 크게 잡은 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() : 부모의 주소 공간을 복제한 자식을 만드는 시스템 콜입니다.

  • vfork() : 주소 공간을 복제하지 않고 부모와 공유하는 자식을 만드는 시스템 콜입니다.

  • posix_spawn() : 자식 생성과 execve()를 한 묶음으로 제공하는 C 라이브러리 함수입니다.

Linux 커널은 fork(), vfork(), clone() 시스템 콜을 모두 같은 함수인 kernel_clone()으로 처리하고, 부모와 무엇을 공유할지는 플래그로 정합니다. 이 글의 환경인 x86-64의 glibc 2.39에서 fork() 함수는 clone() 시스템 콜을 호출하고, vfork() 함수는 별도의 vfork() 시스템 콜을 그대로 호출합니다. 같은 glibc 2.39라도 AArch64의 vfork 구현은 clone() 시스템 콜을 사용하므로 아키텍처에 따라 호출 경로가 다릅니다. vfork(2) 매뉴얼은 vfork()를 CLONE_VM | CLONE_VFORK | SIGCHLD 플래그를 준 clone()과 같다고 설명합니다. 뒤에 나올 strace 결과에 이 플래그가 나옵니다.

posix_spawn()만 계층이 다릅니다. POSIX가 명세하고 C 라이브러리가 구현한 함수여서 대응하는 시스템 콜이 없고, 내부에서는 앞의 두 방식 중 하나나 vfork()와 같은 플래그를 준 clone()을 골라 씁니다. 세 방식 모두 JDK가 직접 구현하지 않고 glibc의 함수를 호출합니다. JVM의 프로세스 생성 코드가 들어 있는 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_ 버전 태그가 붙은 glibc 심볼로 연결되어 있습니다. 그래서 같은 POSIX_SPAWN 설정이라도 libc와 그 버전에 따라 실제 동작이 달라집니다. glibc가 아닌 musl에 링크된 JDK라면 같은 자리에 musl의 구현이 들어갑니다.

세 방식의 장단점은 다음과 같습니다.

방식 장점 단점

fork()

자식의 주소 공간이 분리되어 부모 메모리를 훼손하지 않고 파일 디스크립터 정리나 작업 디렉터리 변경 같은 준비 작업을 할 수 있음

쓰기 시 복사(copy-on-write)로 페이지 복사는 미루지만 페이지 테이블은 복사하므로 힙이 클수록 생성 비용이 커지고, 커널의 overcommit 판정 대상이 됨

vfork()

페이지 테이블을 복사하지 않아 생성 비용이 부모의 힙 크기와 거의 무관함

자식이 execve()나 _exit() 외의 일을 하면 동작이 정의되지 않고, 그동안 부모의 호출 스레드도 멈춤

posix_spawn()

준비 작업을 라이브러리가 정한 방식으로 처리하고, glibc 2.24부터는 CLONE_VM으로 페이지 테이블 복사까지 피함

자식이 할 수 있는 준비 작업이 라이브러리가 지원하는 항목으로 제한되고, 내부 구현이 libc와 그 버전에 따라 다름

다만 fork()도 자식이 execve() 전에 임의의 함수를 호출해도 된다는 뜻은 아닙니다. fork(2) 매뉴얼은 멀티스레드 프로그램에서 fork()한 자식이 execve()까지 안전하게 호출할 수 있는 함수를 async-signal-safe 함수로 제한합니다. 다른 스레드는 자식에 복제되지 않지만 그 스레드가 잡고 있던 잠금 상태는 남을 수 있기 때문입니다.

vfork(2) 매뉴얼은 fork()의 비용을 "부모의 페이지 테이블을 복제하고 고유한 task 구조체를 만드는 시간과 메모리"라고 적었습니다. 이 비용은 부모의 힙이 클수록 커집니다. 뒤의 측정에서 FORK 방식으로 명령 하나를 실행하는 시간은 256MB 힙에서 16~20ms였지만 8GB 힙에서는 230~240ms로 늘었습니다. vfork()의 제약은 vfork(2) 매뉴얼에 더 강하게 적혀 있습니다. 반환값을 담을 pid_t 변수 외의 데이터를 고치거나, vfork()를 호출한 함수에서 반환하거나, _exit()와 execve() 외의 함수를 부르면 동작이 정의되지 않습니다(undefined behavior). 자식이 부모와 공유하는 메모리와 스택을 고치므로, 부모가 재개된 뒤의 결과를 예측할 수 없기 때문입니다.

OpenJDK는 이 세 방식을 차례로 거쳤습니다. 처음에는 fork()를 썼지만 큰 힙에서 생성 비용이 문제가 되자 JDK 7에서 vfork()를 기본값으로 바꿨습니다. 그런데 JDK는 vfork()와 execve() 사이에서 파일 디스크립터를 닫고 작업 디렉터리를 바꾸는 준비 작업을 했고, 이는 방금 본 vfork()의 제약을 어기는 것이었습니다. 실행 비용은 줄였지만 안전성을 희생한 선택이었습니다. JDK 13에서 기본값이 된 POSIX_SPAWN은 이 준비 작업을 부모와 메모리를 공유하지 않는 별도 프로세스로 옮겨서, fork()의 복사 비용과 vfork()의 제약 위반을 함께 피합니다.

세 방식은 구현 전용 시스템 속성인 jdk.lang.Process.launchMechanism에 각각 FORK, VFORK, POSIX_SPAWN으로 대응합니다. JDK 25의 ProcessImpl.java에 이 세 값이 선언되어 있고, Linux에서는 셋 다 지정할 수 있되 VFORK에는 경고가 따릅니다. 기본값의 변천은 아래와 같습니다. JDK 27 항목은 정식 출시 결과가 아니라 2026-09-06 현재 개발 버전에 반영된 내용입니다.

JDK Linux에서의 변화

6

fork() + execve()

7

vfork()가 기본값이 됨 (JDK-6868160)

12

POSIX_SPAWN을 선택 옵션으로 추가 (JDK-8212828). 11.0.4에도 백포트

13

POSIX_SPAWN이 기본값이 됨 (JDK-8213192)

25

VFORK를 지정하면 deprecated 경고 출력 (JDK-8357180)

27

개발 버전에서 VFORK 제거. 지정하면 경고 후 FORK로 동작 (JDK-8357089, CSR)

VFORK가 없어진 사정과 남은 설정의 정리는 이 장의 마지막 절에서 다룹니다. JDK 13부터 기본값인 POSIX_SPAWN이 준비 작업을 별도 프로세스로 옮기는 구조는 다음 절에서 살펴봅니다.

POSIX_SPAWN의 동작

posix_spawn()은 자식 생성과 execve(), 그 사이의 준비 작업을 한 묶음으로 제공합니다. Windows의 CreateProcess()와 유사합니다. JDK는 이 함수로 jspawnhelper라는 작은 실행 파일을 먼저 띄우고, jspawnhelper가 파이프로 넘겨받은 설정에 따라 파일 디스크립터와 작업 디렉터리를 정리한 뒤 실제 명령을 다시 execve()합니다. 이 구조는 JDK 25 소스의 ProcessImpl_md.c 상단 주석에 설명되어 있습니다.

helper를 한 번 더 거치는 이유는 앞 절에서 본 안전 문제 때문입니다. 파일 디스크립터 닫기와 작업 디렉터리 변경 같은 준비 작업을 첫 번째 execve()로 실행된 jspawnhelper, 즉 부모와 메모리를 공유하지 않는 별도 프로세스 안에서 하면 vfork()의 제약을 어기지 않습니다. 같은 주석은 이를 "준비 작업을 첫 exec 뒤로 옮겨 취약한 시간 창을 줄인다"고 설명합니다.

프로그램이 호출하는 시스템 콜을 순서대로 기록하는 도구인 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 플래그를 주어 자식을 생성했습니다. 환경에 따라 clone() 시스템 콜을 쓸 수도 있습니다. glibc 2.39의 posix_spawn 구현은 clone3 호출이 ENOSYS나 EINVAL로 실패하면 clone()으로 대체합니다. CLONE_VM은 자식이 부모의 주소 공간을 그대로 공유한다는 뜻이므로, 힙 매핑과 페이지 테이블을 복사하지 않습니다. CLONE_VFORK는 자식이 execve()하거나 끝날 때까지 부모의 호출 스레드를 멈춥니다. 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에서 없어진 것은 아닙니다.

jspawnhelper가 별도 실행 파일이라는 점은 JDK를 갱신할 때 문제가 될 수 있습니다. 실행 중인 JVM이 사용하는 JDK 경로의 파일을 덮어쓰면, 메모리에 적재된 JVM 코드와 디스크의 helper 버전이 달라져 외부 명령 실행이 실패할 수 있습니다. JDK-8325621은 자동 업데이트로 생긴 이런 불일치를 배경으로 helper의 버전 검사를 보강했습니다. JDK를 갱신할 때는 실행 중인 JVM이 쓰는 디렉터리를 덮어쓰지 말고 새 디렉터리에 설치한 뒤, JVM을 재시작하면서 새 경로로 전환하는 편이 좋습니다. helper 실행 오류에는 권한이나 설치 문제 같은 다른 원인도 있으므로 오류 메시지와 설치 상태를 먼저 확인해야 합니다.

JDK-8357090의 CSR은 posix_spawn()의 예기치 않은 문제를 우회할 수 있도록 FORK를 대안으로 둔다고 설명합니다. jspawnhelper 관련 오류가 반복되는데 원인을 바로 찾기 어렵다면 JVM 시작 옵션에 -Djdk.lang.Process.launchMechanism=FORK를 주어 임시 회피할 수 있습니다. 다음 절에서 살펴볼 overcommit 정책에 따른 메모리 부담과 실행 지연을 감수하는 선택이며, 실행 중인 JVM에 즉시 적용하는 설정은 아닙니다.

FORK의 메모리 비용과 실행 지연

FORK를 선택하면 부모 JVM의 주소 공간 복제 비용을 고려해야 합니다. fork()는 부모의 가상 주소 공간을 그대로 복제한 자식 프로세스를 만드는데, Linux는 쓰기 시 복사(copy-on-write)로 실제 페이지 복사는 미루지만 페이지 테이블은 복사합니다. 커널의 메모리 overcommit 정책에 따라 자식이 쓸지도 모르는 메모리를 미리 계산해서 생성을 거절할 수도 있습니다. 자식이 곧바로 execve()로 작은 프로그램으로 바뀌더라도 이 검사를 거칩니다.

/proc/sys/vm/overcommit_memory 파일로 노출되는 Linux 커널 설정 vm.overcommit_memory의 기본값 0은 "명백한 overcommit만 거절하는" 모드입니다. 커널 5.1의 코드는 이 판정에 여유 메모리, 페이지 캐시, 스왑 여유분 등을 반영했습니다. fork()가 힙 매핑을 복제할 때 요구하는 크기가 이 계산값을 넘으면 메모리 부족을 뜻하는 오류 코드 ENOMEM으로 거절될 수 있었습니다.

커널은 이 판정이 지나치게 엄격해지지 않도록 몇 가지 규칙을 두고 있고, 버전이 올라가면서 보강되기도 했습니다. 두 가지 예를 들면 다음과 같습니다.

  • vm.overcommit_memory=2로 commit 한도를 엄격히 적용한 서버에서도 fork() 시점의 commit 검사는 부모의 전체 주소 공간 크기를 무조건 더하지는 않습니다. private writable 매핑처럼 커널이 commit 총량에 넣어 세는 매핑의 크기만 비용으로 계산합니다.

  • 커널 5.2에 들어간 mm: fix false-positive OVERCOMMIT_GUESS failures 패치는 모드 0의 판정을 "요청 크기가 전체 RAM과 스왑의 합을 넘는지"로 단순화했습니다. 여유 메모리가 적다는 이유만으로 큰 매핑의 복제를 거절하던 조건이 완화된 것입니다.

그럼에도 FORK가 항상 성공하는 것은 아닙니다. 모드 2에서는 Java 힙처럼 큰 익명 private writable 매핑이 비용에 포함되므로 남은 commit 한도를 넘으면 프로세스 생성이 실패하고, 모드 0에서도 페이지 테이블 등 커널 메모리의 실제 할당은 실패할 수 있습니다.

생성이 거절되지 않더라도 시간 비용은 남습니다. 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 재측정에서도 같은 경향을 확인했지만 절대 시간은 달랐습니다.

FORK 방식에서 주소 공간을 복제하는 Linux 6.17의 dup_mmap()은 부모 프로세스의 주소 공간 잠금(mmap_lock)을 쓰기 모드로 잡은 상태에서 매핑과 페이지 테이블을 복제합니다. 그동안 이 잠금이 필요한 메모리 매핑 변경을 시도하는 다른 스레드는 기다려야 합니다. 요청 처리 중 외부 명령을 동기 실행하는 웹 애플리케이션이라면 이 비용이 응답 시간에 더해질 수 있습니다. jspawnhelper 문제를 임시로 피하는 경우가 아니라면, 지금은 이런 단점을 감수하면서 기본값을 FORK로 바꿀 만한 이유가 없습니다.

JVM 업그레이드 때 정리할 VFORK 설정

JVM 버전을 25 이상으로 업그레이드할 때 -Djdk.lang.Process.launchMechanism=VFORK 설정이 남아 있다면 제거하는 편이 좋습니다. 이 설정이 남아 있을 만한 자리는 JVM 실행 스크립트, Dockerfile의 JAVA_OPTS, 애플리케이션 서버의 기동 옵션입니다. JDK 25에서는 경고가 나오고, JDK 27 개발 버전에 반영된 변경에서는 FORK로 대체되어 큰 힙에서 실행 지연이 커질 수 있습니다.

Linux에서는 JDK 7부터 12까지 vfork()가 기본이었으므로, JDK 13에서 기본값이 POSIX_SPAWN으로 바뀔 때 이전 동작을 유지하려고 이 속성을 명시적으로 적어 둔 경우가 있습니다. 당시 glibc가 2.24보다 오래된 환경이었더라도 이 값을 지정할 필요는 없었습니다. JDK 13 소스의 주석에 따르면 glibc 2.4~2.23의 posix_spawn()은 호출 인자에 따라 fork()와 vfork() 중 하나를 고르는데, JDK의 호출 방식은 vfork()를 쓰는 조건에 해당합니다. glibc 2.24부터는 앞에서 본 별도 스택과 시그널 처리를 갖춘 clone() 기반 구현으로 바뀌었습니다. 지금은 이 값을 지정할 이유가 없으므로 속성을 지우고 기본값으로 돌아가면 됩니다.

vfork()를 기본값으로 삼은 JDK 7의 선택은 당시에도 논란거리였습니다. 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부터 독립된 시스템 콜이 됐습니다. 그래도 없어진 쪽은 커널의 vfork()가 아니라 JDK의 VFORK 모드입니다. 표준에서 빠졌기 때문도 커널이 바뀌었기 때문도 아니라, 앞에서 본 대로 JDK 자신이 vfork()와 execve() 사이에서 하던 작업이 안전하지 않아서입니다.

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).

이 글의 예제와 측정은 JDK 25 기준입니다. 구버전 JDK에 적용할 때는 실행 방식과 API 지원 범위를 따로 확인해야 합니다. 예를 들어 OpenJDK 11u는 11.0.4부터 POSIX_SPAWN을 옵션으로 지정할 수 있지만 기본값은 vfork()이고, OpenJDK 8u의 Linux 구현은 2026-09-06 현재 이 옵션을 지원하지 않습니다.

마치며

외부 프로세스 실행 코드는 출력 처리, 시간제한, 종료 정책을 함께 설계해야 합니다. 출력을 가공할 필요가 없으면 Redirect.DISCARD나 inheritIO()를 쓰고, 표준 출력과 표준 오류를 따로 받아야 하면 두 스트림을 동시에 읽어야 합니다. 표준 입력을 읽는 명령에는 입력을 다 쓴 뒤 스트림을 닫아 EOF를 받게 해야 합니다. 시간제한 뒤에는 종료 요청뿐 아니라 강제 종료 여부와 자원 정리까지 챙겨야 합니다.

zt-exec과 Commons Exec은 이 코드를 줄여 줍니다. 출력과 종료 코드의 기본값, 시간 초과를 알리는 방식, 의존성을 비교해 프로젝트에 맞는 쪽을 고르면 됩니다. 두 라이브러리 모두 기본 destroy()만으로 강제 종료까지 보장하지는 않으며, 후손 정리에는 같은 그룹에 남은 프로세스에 시그널을 보내는 방법이나 cgroup 단위의 종료를 사용할 수 있습니다.

Linux의 JDK 25에서는 기본값인 POSIX_SPAWN을 유지하는 편이 무난합니다.

참고 자료

VO vs DTO: 정의와 용어 혼용의 역사

getter/setter만 있는 데이터 운반용 객체를 VO(Value Object)라고 부르는 관행은 혼란을 부릅니다. VO는 값의 의미와 동등성에 관한 분류이고, DTO는 데이터를 전송하는 역할에 관한 분류입니다. Java/J2EE 진영에서 용어 혼용을 확산시킨 Core J2EE Patterns의 사례와 두 패턴의 정의를 정리합니다.

getter/setter만 있는, 값을 실어 나르는 객체를 VO(Value Object)라고 부르는 사례를 실무에서 종종 접합니다. 프로세스 사이의 원격 호출에서 데이터를 운반하는 역할이라면 그 객체는 DTO(Data Transfer Object)라고 부르는 편이 혼란의 여지가 적습니다. Value Object는 Martin Fowler의 글과 Eric Evans의 DDD(Domain-Driven Design)에서 이미 다른 뜻으로 정의한 용어이기 때문입니다. 이 글에서는 두 패턴의 정의와 용어가 뒤섞인 역사를 정리합니다.

Value Object의 정의

Value Object는 식별성(identity)이 없고, 담고 있는 값으로 동등성(equality)을 판단하는 객체입니다.

식별성은 속성 값과 상관없이 한 객체를 다른 객체와 구별해 주는 성질입니다. 회원은 이름이나 주소가 바뀌어도 같은 회원이고, 이름과 주소가 모두 같은 두 회원도 서로 다른 사람입니다. 이런 객체는 회원 번호 같은 식별자로 구별하며, DDD에서는 ENTITY라고 부릅니다.

동등성은 두 객체를 같은 것으로 볼지 판단하는 기준입니다. Value Object는 이 기준을 식별자가 아니라 담고 있는 값에 둡니다. 돈, 색상, 날짜 같은 개념이 대표적인 예입니다. 예를 들어 Java에서 시간선 위의 한 시점을 나타내는 Instant는 서로 다른 방법으로 따로 만들었더라도 같은 시점을 가리키면 equals()로 비교했을 때 같습니다.

Instant fromText = Instant.parse("2026-09-24T20:00:00Z");
Instant fromSeoulTime = ZonedDateTime.of(2026, 9, 25, 5, 0, 0, 0, ZoneId.of("Asia/Seoul"))
    .toInstant();

assertThat(fromText).isEqualTo(fromSeoulTime); // UTC 24일 20시와 서울 25일 5시는 같은 시점

아래 세 자료에서 이 정의를 확인할 수 있습니다.

  • Martin Fowler의 글: 속성 값이 같아서 같다고 보는 객체. x, y 좌표로 이루어진 점(point)을 예로 들어 설명합니다.

    Objects that are equal due to the value of their properties, in this case their x and y coordinates, are called value objects.

    속성 값, 이 예에서는 x와 y 좌표 값이 같아서 같은 객체를 value object라고 부른다.

  • Wikipedia: 동등성이 식별성에 기반하지 않고, 같은 값을 가지면 같은 것으로 보는 객체

  • Microsoft의 .NET 아키텍처 문서: 식별성이 없는 객체

Eric Evans도 같은 관점입니다. Evans가 Domain-Driven Design의 패턴 정의를 요약해 공개한 Domain-Driven Design Reference(2015)의 Value Objects 항목은 문제를 서술하는 부분에서 많은 객체에 개념적 식별성이 없다고 전제하고, 속성과 로직에만 관심이 있는 모델 요소를 value object로 분류하라고 말합니다.

Some objects describe or compute some characteristic of a thing. Many objects have no conceptual identity. …​

Therefore:

When you care only about the attributes and logic of an element of the model, classify it as a value object. Make it express the meaning of the attributes it conveys and give it related functionality.

어떤 객체는 사물의 특성을 기술하거나 계산한다. 많은 객체에는 개념적 식별성이 없다. …​

그러므로:

모델 요소의 속성과 로직에만 관심이 있다면 그것을 value object로 분류하라. Value object가 담은 속성의 의미를 드러내게 하고 관련 기능을 부여하라.

— Eric Evans
Domain-Driven Design Reference: Value Objects

Java 코드에서는 값 개념을 표현하는 클래스에 동등성의 기준이 되는 속성으로 equals()와 hashCode()를 구현하면 Value Object가 됩니다. 이때 지켜야 할 두 메서드의 규약은 Joshua Bloch의 Effective Java 3판 Item 10과 Item 11에 정리되어 있습니다.

  • Item 10: equals()의 일반 규약

    • 반사성(reflexive): x.equals(x)는 true를 반환합니다.

    • 대칭성(symmetric): x.equals(y)가 true이면 y.equals(x)도 true를 반환합니다.

    • 추이성(transitive): x.equals(y)와 y.equals(z)가 true이면 x.equals(z)도 true를 반환합니다.

    • 일관성(consistent): 비교에 쓰는 정보가 바뀌지 않는 한 x.equals(y)는 여러 번 호출해도 같은 결과를 반환합니다.

    • null 아님(non-null): x.equals(null)은 false를 반환합니다.

  • Item 11: equals()를 재정의하면 hashCode()도 재정의

    • equals() 비교에 쓰는 정보가 바뀌지 않는 한 hashCode()는 여러 번 호출해도 같은 값을 반환합니다.

    • equals()로 같은 두 객체는 같은 hashCode() 값을 반환합니다.

    • equals()로 다른 두 객체가 서로 다른 hashCode() 값을 반환할 필요는 없지만, 다른 값을 반환하면 해시 테이블의 성능이 좋아집니다.

Java 16부터 정식 기능이 된 record를 쓰면 이 두 메서드를 직접 작성하지 않아도 됩니다. record를 도입한 JEP 395는 record의 equals()와 hashCode()가 모든 구성 요소의 값을 기준으로 자동 생성된다고 명시합니다. 두 record 인스턴스는 타입이 같고 구성 요소의 값이 모두 같을 때 동등합니다.

public record Money(BigDecimal amount, Currency currency) {
}

이 클래스의 두 인스턴스는 담고 있는 값이 같으면 동등합니다.

Currency krw = Currency.getInstance("KRW");
Money price1 = new Money(BigDecimal.valueOf(10000), krw);
Money price2 = new Money(BigDecimal.valueOf(10000), krw);

assertThat(price1).isEqualTo(price2); // 참조가 달라도 값이 같으면 동등

다만 record는 구성 요소 타입의 equals()를 그대로 쓰므로, 자동 생성된 동등성이 도메인에서 원하는 동등성과 다를 수 있습니다. 예를 들어 BigDecimal의 equals()는 scale까지 비교하므로 new BigDecimal("10000")과 new BigDecimal("10000.0")으로 만든 Money는 서로 다른 값이 됩니다. 이럴 때는 생성자에서 값을 정규화하거나 equals()를 직접 정의합니다.

Java 언어와 JVM에도 식별성 없는 value object 개념이 도입되고 있습니다. DDD와 Fowler가 말하는 Value Object와는 식별성 없이 값으로 구분된다는 공통점이 있습니다. Project Valhalla의 JEP 401: Value Objects (Preview)는 불변이고 객체 식별성이 없으며 필드 값으로만 구분되는 value object를 제안합니다. 2026년 8월 현재 이 JEP는 2027년 3월 출시 예정인 JDK 28에 미리보기 기능으로 통합되어 있습니다. JEP 169: Larval State for Value Objects는 이러한 불변 value object의 임시 가변 상태를 다루는 별도의 Draft 제안입니다. 다만 DDD의 식별성은 도메인에서 개념적으로 구별할 필요가 있는가의 문제이고, Valhalla가 없애는 식별성은 JVM이 객체를 구분하는 런타임 성질이라서 두 value object는 같은 개념이 아닙니다. 그래도 어느 쪽에서도 value object를 getter/setter만 가진 객체라는 의미로 사용하지 않습니다.

한편 실무에서는 앞의 정의와 무관하게 VO를 데이터를 담는 객체(data holder)라는 넓은 의미로 해석해서, getter/setter만 가진 운반용 객체를 VO라고 부르는 관례도 퍼져 있습니다. 이 관례를 확산시킨 대표적인 출처는 뒤의 Core J2EE Patterns 절에서 살펴봅니다.

불변성의 위상: 정의 요건 vs 바람직한 설계

여러 책과 글이 Value Object를 완전한 불변 객체로 만들라고 권고합니다.

  • Fowler는 Patterns of Enterprise Application Architecture 486쪽에서 Value Object를 불변으로 만드는 것이 매우 좋은 방법이라고("it’s a very good idea to make them immutable") 권고합니다.

  • Fowler의 Value Object 글도 마찬가지입니다. 2016년 개정 전 버전에서는 value object를 완전히 불변으로 만드는 것을 일반적인 경험칙으로 제시했고, 개정된 현재 버전에서도 "value objects should be immutable"을 중요한 규칙으로 제시합니다.

    A general heuristic is that value objects should be entirely immutable.

    일반적인 경험칙은 value object를 완전히 불변으로 만들어야 한다는 것이다.

  • Eric Evans는 앞에서 인용한 DDD Reference의 Value Objects 항목에서 value object를 불변으로 다루라는 설계 지침을 제시합니다.

    Treat the value object as immutable. Make all operations Side-effect-free Functions that don’t depend on any mutable state. Don’t give a value object any identity and avoid the design complexities necessary to maintain entities.

    Value object를 불변으로 다루라. 모든 연산을 가변 상태에 의존하지 않는 부수 효과 없는 함수(Side-effect-free Function)로 만들라. Value object에는 식별성을 부여하지 말고, entity를 유지하는 데 필요한 설계 복잡성을 피하라.

  • Joshua Bloch는 Effective Java 3판의 Item 17 "Minimize mutability"에서 Value Object에 한정하지 않고 클래스 일반에 대해 가변성을 최소화하고 가능하면 불변으로 만들라고 권합니다.

VO가 불변이면 여러 객체가 같은 인스턴스를 공유해도 별칭 문제(aliasing bug)가 생기지 않습니다. 별칭 문제는 한쪽에서 값을 바꾸면 같은 인스턴스를 참조하는 다른 쪽의 값까지 바뀌는 문제입니다. Java의 java.util.Date와 Calendar는 값의 성격을 가진 객체인데도 가변으로 설계되어서 이런 버그의 원인이 되었습니다. 예를 들어 두 객체가 회의 시작 시각을 담은 Date 인스턴스 하나를 공유할 때 한쪽에서 setTime()을 호출하면 다른 쪽의 시작 시각도 바뀝니다.

Date 클래스의 부작용 사례
record Meeting(String title, Date start) {
}

Date start = new Date();
Meeting review = new Meeting("설계 리뷰", start);
Meeting retro = new Meeting("회고", start);

long oneHourLater = start.getTime() + Duration.ofHours(1).toMillis();
review.start().setTime(oneHourLater); // (1)

assertThat(retro.start().getTime()).isEqualTo(oneHourLater); // (2)
  1. 설계 리뷰만 1시간 미루려고 함

  2. 회고 시작 시각도 바뀜

Meeting을 record로 선언하는 것만으로는 이 문제를 막을 수 없습니다. record는 필드에 다른 인스턴스를 대입하지 못하게 할 뿐이고, 필드가 참조하는 Date의 내부 상태는 여전히 바꿀 수 있기 때문입니다.

Java 8의 java.time 패키지가 LocalDate, Instant 같은 날짜·시간 클래스를 모두 불변으로 만든 것도 이 교훈을 반영한 결과입니다. 시작 시각을 시간대 없는 날짜·시각인 LocalDateTime으로 표현하면 plusHours()가 기존 인스턴스를 고치지 않고 새 인스턴스를 반환하므로, 인스턴스를 공유해도 다른 회의의 시작 시각은 바뀌지 않습니다.

record Meeting(String title, LocalDateTime start) {
}

LocalDateTime start = LocalDateTime.of(2026, 9, 25, 14, 0);
Meeting review = new Meeting("설계 리뷰", start);
Meeting retro = new Meeting("회고", start);

Meeting delayedReview = new Meeting(review.title(), review.start().plusHours(1));

assertThat(delayedReview.start()).isEqualTo(LocalDateTime.of(2026, 9, 25, 15, 0));
assertThat(retro.start()).isEqualTo(start); // (1)
  1. 회고 시작 시각은 그대로

그런데 Fowler와 Evans의 문장을 자세히 보면 불변성은 VO의 정의가 아니라 지침으로 나옵니다. Fowler는 별칭 문제를 피하기 위한 규칙으로, Evans는 명령형 설계 지침으로 불변성을 제시합니다. Fowler가 Value Object의 정의로 제시하는 성질은 값에 의한 동등성입니다. 앞에서 인용한 "value objects should be immutable"도 전체 문장을 보면 별칭 문제를 피하기 위해 따르는 규칙으로 등장합니다.

To avoid aliasing bugs I follow a simple but important rule: value objects should be immutable.

별칭 버그를 피하려고 나는 단순하지만 중요한 규칙 하나를 따른다. Value object는 불변이어야 한다.

— Martin Fowler
Value Object

같은 글에서 C#의 struct처럼 언어가 대입할 때마다 값을 복사하는 방식으로도 별칭 문제를 피할 수 있다고 대안까지 언급합니다.

While immutability is my favorite technique to avoid aliasing bugs, it’s also possible to avoid them by ensuring assignments always make a copy. Some languages provide this ability, such as structs in C#.

별칭 버그를 피하는 방법으로 내가 가장 좋아하는 것은 불변성이지만, 대입할 때마다 항상 복사본을 만들도록 보장해서 피할 수도 있다. C#의 struct처럼 이런 기능을 제공하는 언어도 있다.

— Martin Fowler
Value Object

Evans는 불변성을 명령형 문장으로 썼습니다. 앞에서 인용한 DDD Reference의 해법 부분에서 value object로 분류하는 기준은 모델 요소의 속성과 로직에만 관심이 있는가입니다. 그 뒤에 이어지는 "Treat the value object as immutable."은 그렇게 분류한 객체를 불변으로 다루라는 명령형 문장이고, 바로 뒤의 문장도 모든 연산을 부수 효과 없는 함수로 만들라는 명령형 문장입니다. 저는 이 문장들을 분류 기준이 아니라, 분류한 객체를 다루는 설계 지침으로 읽습니다.

Ward Cunningham의 wiki에 있는 ValueObjectsShouldBeImmutable 페이지에 Fowler가 남긴 글은 정의와 지침의 구분을 더 분명하게 보여 줍니다. 새로 설계하는 객체에는 상태를 바꾸는 메서드를 두지 말라고 합니다.

So if you design an object that should be a value object, don’t provide any methods that change its state, ie make it immutable.

그러니 value object여야 하는 객체를 설계한다면 상태를 바꾸는 메서드를 제공하지 말라. 즉, 불변으로 만들라.

— Martin Fowler
c2 wiki: ValueObjectsShouldBeImmutable

그리고 이미 가변으로 만들어진 Value Object에 대해서는 다음과 같이 말합니다.

If you are using a ValueObject that is mutable, treat it like it is immutable. You may not realize why, but you will save a lot of time and money.

가변인 ValueObject를 쓰고 있다면 불변인 것처럼 다루라. 이유를 깨닫지 못할 수도 있지만, 많은 시간과 돈을 아끼게 될 것이다.

— Martin Fowler
c2 wiki: ValueObjectsShouldBeImmutable

"가변인 ValueObject를 쓰고 있다면"이라는 가정 자체가 불변이 아닌 Value Object의 존재를 전제합니다. 페이지 이름도 MustBeImmutable이 아니라 ShouldBeImmutable입니다. Cambridge Dictionary는 should의 첫 번째 뜻을 다음과 같이 풉니다.

used to say or ask what is the correct or best thing to do

무엇이 옳거나 가장 좋은 일인지 말하거나 물을 때 쓴다.

— Cambridge Dictionary
should

같은 사전은 must를 어떤 일이 일어나는 것이 필요하거나 매우 중요하다는 것을 나타낼 때 쓴다고 풉니다. must가 반드시 그래야 하는 필요를 나타낸다면, should는 그렇게 하는 편이 옳다는 권고를 나타냅니다.

반면 불변성을 정의의 일부로 서술하는 자료도 있습니다.

  • 앞에서 언급한 Microsoft의 .NET 아키텍처 문서는 value object의 두 가지 주요 특성으로 식별성 없음과 불변성을 나란히 들고, 불변성을 중요한 요건이라고 밝힙니다.

    There are two main characteristics for value objects: They have no identity. They are immutable. The first characteristic was already discussed. Immutability is an important requirement.

    Value object의 주요 특성은 두 가지다. 식별성이 없고, 불변이다. 첫 번째 특성은 이미 다루었다. 불변성은 중요한 요건이다.

  • Wikipedia는 value object가 불변이어야 한다(should)고 쓰면서, 같은 값으로 생성된 두 value object가 계속 같아야 한다는 암묵적 계약을 위해 불변성이 필요하다(required)고 설명합니다. should를 쓰면서도 불변성을 계약의 전제로 둔다는 점에서 정의에 가까운 위상을 부여합니다.

  • Vaughn Vernon의 Implementing Domain-Driven Design(2013) 6장도 Value Object의 특성을 열거하면서 불변성을 그중 하나로 포함합니다.

  • 김우근 님의 자바/스프링 개발자를 위한 실용주의 프로그래밍(2024)도 "VO는 이러한 불변성이라는 특징을 갖고 있는 객체를 말합니다"(43쪽)라고 설명하고, 불변성·동등성·자가 검증이라는 세 특성을 만족하는 객체를 VO로 정의합니다.

  • JEP 401의 value object처럼 필드가 암묵적으로 final이 되어 얕은 불변성이 언어 차원에서 강제되는 개념도 있습니다. 필드에 다른 인스턴스를 대입할 수는 없지만, 필드가 참조하는 객체의 내부 상태까지 불변이 되지는 않습니다.

저는 불변성을 VO의 정의 요건이라기보다는 바람직한 설계 규범으로 봅니다. c2 wiki에서 Fowler도 새로 설계하는 객체는 불변으로 만들라고 썼습니다. 새로 만드는 Value Object는 어느 관점을 따르든 불변으로 설계하므로, 실무에서 should와 must의 차이가 드러나는 경우는 많지 않습니다. 그래도 이 구분이 무의미하지는 않습니다. java.util.Date처럼 가변으로 만들어진 값 성격의 객체를 만났을 때, 불변성을 정의에 포함하면 그 객체는 VO가 아닌 무언가가 되고, 지침으로 보면 불변 지침을 지키지 못한 VO가 됩니다. c2 wiki의 Fowler 문장은 후자의 관점에서 나온 실용적인 조언입니다. 불변 지침을 지키지 못한 VO를 이미 마주쳤다면, 그것을 VO가 아니라고 배제하는 대신 최소한 불변인 것처럼 다루라는 뜻입니다.

'술을 마시고 운전하지 않는다’를 운전자의 정의에 포함할 것인지, 운전자가 지켜야 할 규범으로 볼 것인지의 차이와 비슷합니다. 어느 쪽이든 술을 마시고 운전하면 안 된다는 결론은 같지만, 규범까지 정의에 포함하면 현실에 존재하는 위반 사례를 부를 이름이 사라져서 그런 사례를 논의하기가 어려워집니다.

DTO(Data Transfer Object)의 정의

Fowler의 원래 정의에서 DTO는 원격 호출의 비용을 줄이기 위한 객체입니다. Martin Fowler의 Patterns of Enterprise Application Architecture 카탈로그는 DTO를 다음과 같이 정의합니다.

An object that carries data between processes in order to reduce the number of method calls.

메서드 호출 횟수를 줄이기 위해 프로세스 사이에서 데이터를 나르는 객체.

— Martin Fowler
Patterns of Enterprise Application Architecture 카탈로그: Data Transfer Object

책의 401쪽에서도 같은 정의를 볼 수 있습니다. 원격 호출은 매번 네트워크 왕복과 직렬화 비용이 들므로 한 번의 호출로 필요한 데이터를 모두 전달하려는 의도에서 나온 패턴입니다. 예를 들어 고객의 이름, 주소, 주문 목록을 getter 원격 호출 세 번으로 가져오는 대신, 세 값을 담은 DTO 하나를 한 번의 호출로 받습니다.

HTTP API의 요청과 응답처럼 네트워크를 건너는 데이터를 담는 객체는 프로세스 경계를 넘고 직렬화된다는 점에서 이 정의와 통합니다. 다만 모든 HTTP API가 원격 호출 횟수를 줄이기 위해 설계되는 것은 아니므로, 이를 DTO라고 부르는 용법은 원래 정의를 현대적인 API 경계에 확장한 것입니다.

실무에서는 여기에서 더 나아가 이 정의를 폭넓게 해석해서, 원격 호출과 무관하게 같은 프로세스 안에서 계층의 경계를 넘어 데이터를 운반하는 객체까지 DTO라고 부르는 관례가 생겼습니다. DB 조회 결과를 담는 객체, 서비스 계층에서 뷰 렌더링 계층으로 전달하는 객체, JPA 엔티티를 서비스 계층 바깥에 직접 노출하지 않도록 변환한 응답 전용 객체가 그런 예입니다. 이런 용법은 원격 호출 횟수를 줄인다는 원래 정의와 거리가 멀어서, DTO라고 부르기 어렵다는 주장도 나올 수 있습니다.

이름을 둘러싼 논쟁과는 별개로, 로컬 맥락에서 그런 객체를 두는 것이 설계상 바람직한지도 따져 볼 수 있습니다. Fowler는 LocalDTO에서 이름이 아니라 이 쓰임새를 문제 삼습니다. DTO 패턴의 존재 이유가 원격 호출 비용을 줄이는 것이므로, 원격 호출이 없는 로컬 맥락에서는 그 이유가 사라진다는 것이 출발점입니다. 그래서 로컬 맥락에서는 DTO가 필요하지 않을 뿐 아니라 오히려 해롭다고 말합니다. 한 번의 호출로 많은 데이터를 주고받는 coarse-grained API는 사용하기 불편하고, 도메인 계층이나 데이터 소스 계층에서 DTO로 데이터를 옮기는 작업이 모두 추가 비용이라는 이유입니다.

Not just do you not need them in a local context, they are actually harmful both because a coarse-grained API is more difficult to use and because you have to do all the work moving data from your domain or data source layer into the DTOs.

로컬 맥락에서는 DTO가 필요 없을 뿐 아니라 실제로 해롭다. Coarse-grained API는 사용하기 더 어렵고, 도메인 계층이나 데이터 소스 계층의 데이터를 DTO로 옮기는 작업을 모두 해야 하기 때문이다.

— Martin Fowler
LocalDTO

서비스 계층의 클라이언트가 도메인 모델에 의존하지 않도록 서비스 계층 API에 DTO를 두자는 주장에 대해서도, 편리할 수는 있지만 그 모든 데이터 매핑 비용을 들일 가치는 없다고 답합니다.

다만 같은 글에서 프레젠테이션 계층의 모델과 도메인 모델의 차이가 클 때는 로컬에서도 DTO와 비슷한 객체가 유용하다고 인정합니다.

One case where it is useful to use something like a DTO is when you have a significant mismatch between the model in your presentation layer and the underlying domain model.

DTO 같은 것을 쓰는 것이 유용한 경우 하나는 프레젠테이션 계층의 모델과 그 아래의 도메인 모델 사이에 큰 불일치가 있을 때다.

— Martin Fowler
LocalDTO

그런 경우에는 어차피 두 모델 사이의 매핑이 필요하므로 DTO가 추가 비용이 아니기 때문입니다. 같은 글의 뒷부분에는 멀티스레드 애플리케이션에서 격리된 영역 사이에 메시지로 데이터를 주고받을 때 DTO를 쓰는 용도도 덧붙여 있습니다. 정리하면 Fowler의 비판은 도메인 모델과의 분리 자체를 목적으로 DTO를 두는 경우를 향한 것이고, 모델의 차이나 격리된 영역 사이의 메시지 전달처럼 실제 필요가 있는 경우까지 부정하지는 않습니다.

이름 논쟁과 쓰임새 논쟁은 서로 다른 질문이지만 같은 전제에서 출발합니다. 로컬 맥락에는 원격 호출이 없다는 사실이, 한쪽에서는 DTO라는 이름이 원래 정의와 맞지 않는다는 근거가 되고, 다른 쪽에서는 DTO를 둘 이유가 약해진다는 근거가 됩니다. 뒤에서 제안하는 역할별 접미어 분리는 이 중 이름 논쟁에 대한 답입니다. 그런 객체를 둘지는 쓰임새 논쟁에서 따로 판단할 문제이고, 두기로 결정했다면 Dto보다 역할이 드러나는 이름을 붙이자는 제안입니다.

결국 VO와 DTO를 구분하는 기준은 객체의 특성과 역할입니다. 값 개념을 표현하며 동등성을 값으로 판단하는 것은 VO의 성질이고, 경계를 넘어 데이터를 운반하는 것은 DTO의 역할입니다. 이 둘은 서로 배타적인 분류가 아닙니다. 값 동등성이 있는 불변 DTO는 VO이기도 합니다. 반대로 모든 DTO에 값 동등성이 필요한 것은 아닙니다.

Core J2EE Patterns 초판의 VO와 개정판의 TO

Java/J2EE(현 Jakarta EE) 진영에서 두 용어의 혼용을 확산시킨 대표적인 초기 출처는 Deepak Alur 등이 쓴 Core J2EE Patterns: Best Practices and Design Strategies입니다. 2001년에 나온 초판에서는 티어(tier) 사이에서 데이터를 전송하는 객체를 Value Object라는 이름의 패턴으로 정의했습니다. 2003년의 2판에서는 같은 패턴을 TO(Transfer Object)로 개칭했습니다. 값 동등성 객체로서의 Value Object와의 혼동을 피하려는 개칭으로 보입니다. Martin Fowler는 같은 역할의 패턴을 DTO라고 부릅니다(Patterns of Enterprise Application Architecture 401쪽). 정리하면 다음 세 가지는 같은 전송 패턴을 가리킵니다.

Core J2EE Patterns 초판의 VO = 2판의 TO = Martin Fowler의 DTO

Oracle의 Transfer Object 문서는 개칭된 이름인 Transfer Object로 이 패턴을 설명합니다.

다른 여러 책도 VO와 DTO가 같은 의미이거나 매우 가까운 개념이라고 썼습니다.

  • Rod Johnson, Expert One-on-One J2EE Design and Development, Wrox, 2002, 265쪽

    Value objects are sometimes referred to as Data Transfer Objects (DTOs).

    Value object는 DTO(Data Transfer Object)라고 불리기도 한다.

  • Rod Johnson·Juergen Hoeller, Expert One-on-One J2EE Development without EJB, Wrox, 2004, 27쪽

    Transfer objects, often referred to as Data Transfer Objects (DTOs) or Value Objects.

    흔히 DTO(Data Transfer Object)나 Value Object라고도 부르는 Transfer object.

  • Murat Yener·Alex Theedom, Professional Java EE Design Patterns, Wrox, 2014, 12장

    The DTO is also referred to as the Value Object

    DTO는 Value Object라고도 불린다

  • Derek C. Ashmore, The Java EE Architect’s Handbook, Second Edition, DVT Press, 2014, 5장

    My definition of 'value object' is very close to a Data Transfer Object (DTO)

    내가 정의하는 'value object’는 DTO(Data Transfer Object)에 매우 가깝다

이 책들이 모두 Core J2EE Patterns 초판의 직접적인 영향을 받았다고 단정할 수는 없습니다. 다만 등장 시기와 여러 문헌에서 반복되는 표현을 고려하면, 초판의 용법이 이후 Java/J2EE 문헌의 표현에도 어느 정도 영향을 미치지 않았을까 추정합니다. 2판에서 이름을 바꾼 지 20년이 넘은 지금까지도 그 관행은 남아 있습니다.

데이터 운반 객체를 VO라고 부를 때의 비용

이미 팀 안에서 통용되는 이름을 굳이 바꿔야 하는지 의문이 들 수도 있습니다. 그러나 데이터 운반 객체를 VO라고 부르는 관행에는 실질적인 비용이 있습니다. 값 동등성 객체로서의 Value Object가 DDD, ORM, Java 언어·JVM 기능이라는 세 맥락에서 계속 등장하기 때문입니다.

  • DDD(Domain-Driven Design): VALUE OBJECT는 ENTITY와 함께 도메인 모델을 구성하는 핵심 요소입니다. AGGREGATE는 하나의 ENTITY를 루트로 삼는 데이터 변경의 일관성 경계이며, 내부에 다른 ENTITY와 VALUE OBJECT를 포함할 수 있습니다.

  • ORM: Hibernate와 JPA의 @Embeddable은 자체 영속 식별성 없이 그것을 소유하는 엔티티에 종속되는 객체를 매핑합니다. DDD의 VALUE OBJECT를 매핑할 때 흔히 사용하는 구조이지만, JPA가 값 동등성이나 불변성을 강제하지는 않으므로 모든 @Embeddable이 곧 DDD VALUE OBJECT인 것은 아닙니다.

  • Java 언어·JVM 기능: JEP 401을 비롯한 Project Valhalla의 문서들은 value object라는 용어를 불변이고 객체 식별성이 없으며 필드 값으로 구분되는 객체라는 의미로 씁니다.

getter/setter만 가진 데이터 홀더를 VO라고 이해하고 있다면 이런 자료를 읽을 때 용어가 어긋나서 혼란이 생깁니다. 예를 들어 VO를 @Embeddable로 매핑하라는 설명을 읽으면서 setter가 가득 정의된 요청 파라미터 객체를 떠올리게 됩니다. 원격 프로세스 경계를 넘는 데이터 운반 객체를 DTO라고 부르면 Fowler의 정의와도, Core J2EE Patterns 2판 이후의 개칭된 이름과도 자연스럽게 연결됩니다.

앞의 DTO 정의 절에서 본 것처럼 근래의 관례로는 계층 경계를 넘는 데이터 홀더라는 넓은 의미로 DTO를 쓰기도 합니다. 그러나 DTO 접미어를 모든 운반 객체에 붙이면 단점이 있습니다. HTTP 요청 파라미터를 담는 객체, 통계 쿼리의 결과를 담는 객체까지 모두 DTO라고 부르면 이름만으로는 역할이 드러나지 않습니다. 예를 들어 IssueDto라는 이름만 보고는 요청 본문인지, 조회 응답인지, 통계 쿼리의 결과인지 알 수 없습니다.

저는 객체의 역할에 따라 접미어를 나누는 방법을 권합니다. 이렇게 하면 이름만으로 역할이 드러나고, 원격 호출과 관련되지 않은 계층에서 쓰이는 객체를 DTO라고 부를 때 엄밀한 정의에서 벗어난다는 논쟁도 피할 수 있습니다. 예를 들면 다음과 같은 이름입니다.

역할 클래스 이름 예

이슈 조회 JSON 응답

IssueResponse, IssueDetailDto

이슈 생성 JSON 요청

IssueCreationRequest, IssueCreationCommand

이슈 조회 조건

IssueQuery, IssueCriteria

이슈 DB 통계 조회 결과

IssueStatsRow

참고 자료

Transfer Object / DTO

GitHub Actions 워크플로 튜닝: 저장소 다섯 곳의 적용 사례

앞 글에서 정리한 GitHub Actions 튜닝 기법을 제 공개 저장소 다섯 곳에 적용하며 측정한 기록입니다. 단계별 시간 측정으로 찾은 병목, 트리거 필터와 concurrency 설정, 캐시가 이득이 아니었던 실험, 매트릭스 병렬화의 손익을 실제 실행 기록으로 살펴봅니다.

앞 글에서 GitHub Actions의 실행 파이프라인 구조와, 측정 → 실행 횟수 줄이기 → 캐시 → 병렬화 → 러너 선택 순서의 튜닝 기법을 정리했습니다. 이 글은 그 기법을 제 개인 저장소 다섯 곳에 적용하며 측정한 기록입니다. 기법의 원리와 주의점은 앞 글에 있으므로, 여기서는 각 저장소에서 무엇을 적용했고 결과가 어땠는지에 집중합니다.

대상은 benelog/flashcard, benelog/spider-silk, benelog/pdf-refinery, benelog/spring-jdbc-book, benelog/til입니다. 모두 공개 저장소라 4 vCPU / 16GB 사양의 표준 Linux 러너에서 실행됐습니다. 측정값은 2026년 8월 28일에 확인한 실제 실행 기록에서 가져왔습니다.

1. spider-silk: 측정으로 찾은 병목

spider-silk의 Publish 워크플로에 앞 글의 gh run view 명령을 돌린 결과입니다.

JOB publish: 354s
  Set up job: 3s
  Run actions/checkout@v7: 1s
  Run actions/setup-java@v4: 0s
  Run gradle/actions/setup-gradle@v6: 9s
  Run ./gradlew build: 58s
  Run ./gradlew publishAllPublicationsToGitHubPackagesRepository: 276s
  Post Run gradle/actions/setup-gradle@v6: 3s

전체 354초 중 276초를 GitHub Packages에 아티팩트를 올리는 데 씁니다. 빌드는 58초입니다. Gradle 빌드 캐시를 아무리 잘 맞춰도 이 워크플로는 6분에서 5분으로밖에 줄지 않습니다. 측정하지 않고 "Gradle 빌드가 느리다"고 짐작해서 캐시 설정부터 손댔다면, 효과가 거의 없는 곳에 시간을 쓰게 됐을 것입니다.

2. flashcard: 트리거 필터와 중복 실행 취소

flashcard의 CI는 Go 코드를 검사하는 워크플로라, 원고 디렉터리만 고쳤을 때는 실행하지 않도록 paths-ignore를 걸었습니다.

flashcard/.github/workflows/ci.yml
on:
  push:
    branches: [main, release]
    paths-ignore:
      - 'book/**'
      - 'book-template/**'
  pull_request:
    paths-ignore:
      - 'book/**'
      - 'book-template/**'
  workflow_dispatch:

같은 브랜치에 커밋을 연달아 밀 때의 중복 실행은 concurrency로 취소합니다.

flashcard/.github/workflows/ci.yml
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

대기 시간도 확인했습니다. 실행 생성이 14:04:15Z, 잡 시작이 14:04:18Z로 대기가 3초였습니다. 개인 저장소라 러너 배정 대기는 문제가 아니었고, 튜닝 대상은 실행 시간 쪽임을 확인했습니다.

잡 분리의 준비 비용도 이 저장소에서 확인할 수 있습니다. Set up job 1초, 체크아웃 2초, actions/setup-go 7초로 실제 작업 전에 10초를 씁니다. 잡을 나누면 이 10초를 잡마다 다시 내므로, 이 정도 규모의 워크플로에서는 잡 분리가 이득이 되기 어렵습니다.

3. spring-jdbc-book: 경로 필터와 캐시 손익 실험

spring-jdbc-book은 예제 코드가 바뀔 때만 테스트를 돌리도록 paths로 대상을 지정했습니다. 워크플로 파일 자신을 목록에 넣어 둔 점이 중요합니다. 이렇게 해야 워크플로를 고쳤을 때 그 변경이 검증됩니다.

spring-jdbc-book/.github/workflows/test-examples.yml
on:
  push:
    branches: [main]
    paths:
      - 'examples/**'
      - '.github/workflows/test-examples.yml'

3.1. 캐시가 잡 시간을 줄이지 못한 실험

이 저장소의 테스트 워크플로를 4분 간격으로 두 번 실행해 캐시의 손익을 쟀습니다. 첫 실행에는 복원할 캐시가 없었고, 두 번째 실행은 첫 실행이 저장해 둔 캐시를 복원했습니다.

단계 1회차 2회차

Set up Gradle (캐시 복원)

3초

13초

Run tests

113초

99초

Post Set up Gradle (캐시 저장)

39초

40초

잡 전체

161초

159초

캐시 덕분에 테스트 실행은 113초에서 99초로 14초 줄었습니다. 그런데 복원에 10초가 더 들고 저장 시간은 그대로라, 잡 전체로는 161초에서 159초가 되어 차이가 없었습니다. 이 저장소의 예제는 의존성이 적고 Testcontainers로 PostgreSQL을 띄우는 시간이 테스트 시간의 대부분이라, 캐시로 줄일 수 있는 몫이 애초에 작았습니다. 두 번만 비교한 값이라 테스트 시간 14초 차이에는 러너 성능 편차도 섞여 있을 수 있습니다. 그래도 캐시 저장에 매번 40초 가까이 든다는 점은 두 실행에서 같았습니다.

3.2. 실패했을 때만 올리는 테스트 리포트

아티팩트는 필요할 때만 올립니다. 이 워크플로는 테스트 리포트를 실패했을 때만 업로드합니다. 성공한 실행에서는 이 단계가 0초로 끝납니다.

spring-jdbc-book/.github/workflows/test-examples.yml
      - name: Upload test reports on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: examples/*/build/reports/tests/test

4. pdf-refinery: 매트릭스 병렬화와 캐시 후보

pdf-refinery는 Python 세 버전을 매트릭스로 돌립니다.

pdf-refinery/.github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]

실행 기록을 보면 세 잡이 각각 305초, 240초, 308초 걸렸습니다. 러너 사용 시간의 합은 853초지만, 세 잡이 동시에 시작해서 실제로 기다린 시간은 가장 느린 308초입니다. 버전별 검증이라는 목적을 러너 준비 비용(잡당 10초 안팎)만 더 내고 체감 시간 증가 없이 달성한 셈입니다.

캐시는 아직 붙이지 않았습니다. 매 실행마다 pip install -e '.[dev]'에 41~50초를 쓰는데, 이 시간은 거의 전부 다운로드라서 캐시를 붙이면 줄어들 가능성이 높습니다. spring-jdbc-book과 달리 이쪽은 캐시가 이득일 후보입니다.

5. til: 배포 워크플로의 concurrency 설정

til의 Cloud Run 배포 워크플로는 중간에 취소되면 어중간한 상태로 남을 수 있습니다. 그래서 cancel-in-progress를 켜지 않고, 그룹만 묶어 한 번에 하나만 돌게 했습니다.

til/.github/workflows/deploy.yml
# 연달아 푸시해도 배포가 서로 앞지르지 않게 한 번에 하나만 돌린다.
concurrency:
  group: deploy-cloud-run
  cancel-in-progress: false

이 설정에서 진행 중인 실행은 끝까지 돌고 새 실행은 대기합니다. 다만 기본값으로는 한 그룹에서 대기할 수 있는 실행이 하나뿐이라, 커밋 세 개를 연달아 밀면 첫 번째는 배포되고 두 번째는 취소되며 세 번째만 배포됩니다. 중간 실행까지 모두 돌려야 하는 워크플로라면 앞 글에서 다룬 queue: max를 고려해야 합니다.

6. 정리

다섯 저장소에 적용한 내용과 결과입니다.

저장소 적용·측정한 것 결과

spider-silk

단계별 소요 시간 측정

병목은 빌드(58초)가 아니라 GitHub Packages 업로드(276초). 캐시 튜닝으로는 줄일 수 없는 구조

flashcard

paths-ignore, concurrency, 대기 시간 측정

원고만 고친 커밋은 실행 생략. 대기는 3초라 문제가 아님

spring-jdbc-book

paths 필터, 캐시 실험, 조건부 아티팩트

캐시를 붙여도 잡 전체는 161초 → 159초로 효과 없음

pdf-refinery

매트릭스 병렬화

러너 시간 합 853초를 체감 308초로. pip install 41~50초는 캐시 후보

til

배포 워크플로의 concurrency

cancel-in-progress를 꺼서 배포 중단 방지

다섯 저장소에서 공통으로 확인한 것은 측정이 먼저라는 점입니다. spider-silk처럼 병목이 짐작과 다른 곳에 있기도 하고, spring-jdbc-book처럼 정석으로 알려진 캐시가 측정해 보면 이득이 없기도 합니다. 기법을 적용하기 전과 후의 잡 전체 시간을 비교하는 습관이 어떤 설정보다 효과가 컸습니다.

7. 참고 자료

이 블로그

이 포스트는 Claude Code와 정상혁이 함께 작성했습니다.