Recent Posts

All Posts →

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

Java에서 외부 프로세스를 실행할 때 지금도 지켜야 할 규칙을 예제 코드로 정리합니다. 파이프 버퍼 교착, 두 스트림 동시 읽기, 시간제한과 강제 종료를 JDK API, zt-exec, Apache Commons Exec으로 각각 구현하고 두 라이브러리의 유지보수 현황을 비교합니다. JDK 13부터 기본이 된 posix_spawn과 jspawnhelper, 커널 5.2 이후 fork의 메모리 문제, JDK 27의 VFORK 제거, 큰 힙에서의 실행 지연 실측치도 다룹니다.

Java에서 외부 명령을 실행하는 코드는 ProcessBuilder로 몇 줄이면 됩니다. 그런데 출력을 읽는 순서나 시간제한을 빠뜨린 코드는 개발 중에는 잘 돌다가, 출력이 커지거나 명령이 멈추는 날 애플리케이션까지 함께 멈추게 합니다. 이 글은 JDK 25와 Linux 6.x에서 지금도 지켜야 할 규칙을 예제 코드로 먼저 정리합니다. 이어서 그 규칙을 대신 지켜 주는 라이브러리인 zt-execApache 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를 만들어 호출하므로 두 방식의 동작은 같습니다. 이 구도는 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입니다. 아래 코드는 seq 1 100000 명령으로 약 590KB를 출력하게 하고 읽지 않은 채 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();
System.out.println("read " + lines + " lines, exit=" + process.waitFor());

실행 결과입니다. 3초가 지나도 seq 프로세스는 살아 있고, 부모가 파이프를 읽기 시작해야 끝납니다.

finished within 3s: false, alive: true
read 100000 lines, exit=0

waitFor()에 제한 시간을 주지 않았다면 이 프로그램은 영원히 멈춰 있었을 것입니다. waitFor()를 먼저 부르고 그다음에 스트림을 읽는 코드가 이 문제를 만듭니다. 스트림을 다 읽은 뒤에 waitFor()를 불러야 합니다. inputReader() 메서드는 JDK 17에 추가된 것으로, getInputStream() 위에 BufferedReader를 얹는 코드를 대신합니다.

출력이 필요 없을 때의 리다이렉트

출력을 부모 프로세스가 가공할 필요가 없다면 파이프를 만들지 않는 편이 가장 단순합니다. ProcessBuilderredirectOutput() 메서드와 redirectError() 메서드에 Redirect.INHERIT를 지정하면 하위 프로세스가 부모의 표준 출력과 표준 오류에 바로 씁니다. 세 스트림을 모두 물려주는 inheritIO() 메서드는 JDK 7부터 있었습니다. JDK 9에 추가된 Redirect.DISCARD는 출력을 /dev/null로 보내고, Redirect.to(File)은 파일로 보냅니다. 표준 오류만 표준 출력에 합치고 싶으면 redirectErrorStream(true)를 씁니다. 이렇게 하면 읽어야 할 파이프가 하나로 줄어서 스레드 하나로 처리할 수 있습니다.

기본값인 Redirect.PIPE를 그대로 두고 getInputStream()을 부르지 않는 코드는 언젠가 출력이 64KiB를 넘는 날 멈춥니다. 출력을 버릴 생각이라면 명시적으로 DISCARD를 지정하는 편이 안전합니다.

두 스트림 동시 읽기와 시간제한

표준 출력과 표준 오류를 따로 받아서 가공해야 한다면 파이프 두 개를 동시에 비워야 합니다. 한 파이프를 다 읽은 뒤에 다른 파이프를 읽는 순차 처리로는 부족합니다. 표준 오류 파이프가 먼저 차면 하위 프로세스는 표준 출력을 더 쓰지 못하고, 부모는 표준 출력의 EOF를 기다리는 교착이 생깁니다. 그래서 스트림마다 스레드가 하나씩 필요합니다.

시간제한도 같이 챙겨야 합니다. 외부 명령이 네트워크나 잠금을 기다리며 멈추면 waitFor()를 부른 스레드도 함께 멈춥니다. JDK 8부터 있는 waitFor(long, TimeUnit) 메서드가 false를 돌려주면 destroyForcibly()로 프로세스를 끝내야 합니다. Linux에서 destroy()는 SIGTERM을, destroyForcibly()는 SIGKILL을 보냅니다. 아래는 JDK API만으로 이 규칙을 지킨 예제입니다. 스트림을 읽는 스레드는 JDK 21의 가상 스레드로 만들었습니다.

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 네 명령을 이 메서드로 실행한 결과입니다. 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

이 정도가 최소한이고, 실제로는 더 챙길 것이 있습니다. 출력이 아주 크면 StringBuilder에 다 담지 말고 줄 단위로 처리해야 합니다. 실행한 명령이 다시 자식 프로세스를 만들었다면 destroyForcibly()는 직접 만든 프로세스만 끝내므로, JDK 9의 ProcessHandle.descendants()로 손자 프로세스까지 정리해야 합니다. 출력 인코딩이 UTF-8이 아닌 명령도 있습니다. 이런 것까지 직접 챙기면 코드가 금방 길어지고, 그 자리가 다음 절의 라이브러리가 맡는 영역입니다.

zt-exec과 Apache Commons Exec

앞 절의 규칙을 프로젝트마다 다시 구현하는 대신 라이브러리를 쓸 수 있습니다. 두 라이브러리 모두 내부는 ProcessBuilder와 스트림마다 하나씩 두는 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()으로 지정한 시간을 넘기면 프로세스를 destroy()로 끝내고 TimeoutException을 던집니다. 종료 코드가 잘못된 경우와 시간 초과를 예외 타입만으로 구분할 수 있습니다.

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

  • 부수 기능이 메서드 하나씩입니다. start().getFuture()로 비동기 실행, destroyOnExit()로 JVM 종료 시 하위 프로세스 정리, redirectOutputAsInfo()로 SLF4J 로거에 출력 전달을 각각 메서드 호출 하나로 켭니다.

앞 절의 네 명령을 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 프로세스가 남아 있지 않은 것도 확인했습니다.

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에 복사해 두었다가 outputUTF8()을 부를 때 문자열로 바꿉니다. 명령이 수백 MB를 출력하면 그만큼이 힙에 통째로 올라가고, 문자열로 바꾸는 순간에는 바이트 배열과 String이 함께 있어서 두 배까지 늘어납니다. 그래서 크기를 예측할 수 없는 출력은 위 예제의 largeOutput()처럼 readOutput을 켜지 않고 redirectOutput()에 줄 단위로 문자열을 처리하는 함수를 람다 표현식으로 넘겨야 합니다. pump 스레드가 읽은 바이트를 줄바꿈 단위로 잘라 그 함수에 넘기고, 함수가 끝난 줄은 보관하지 않으므로 메모리 사용량이 출력 크기와 무관하게 유지됩니다. 예제는 줄 수만 셌지만 각 줄을 파싱해서 집계하거나 파일에 쓰는 일도 이 함수 안에서 하면 됩니다. 클래스패스에 SLF4J 구현체가 없으면 실행할 때마다 "No SLF4J providers were found" 경고가 표준 오류에 세 줄 찍힙니다. 이미 SLF4J를 쓰는 애플리케이션이라면 문제가 없지만, 그렇지 않다면 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이 됐고, DefaultExecutorExecuteWatchdog 의 생성자가 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.outSystem.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());
    }
}
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

zt-exec과 비교하면 API의 나이가 드러나는 부분이 있습니다. DefaultExecutor는 종료 코드가 0이 아니면 무조건 ExecuteException을 던지고, 시간 초과도 같은 예외로 알립니다. 위 결과의 sleep은 watchdog이 보낸 SIGTERM으로 끝나서 종료 코드가 143입니다. 시간 초과인지 명령 자체의 실패인지는 예외를 잡은 뒤 watchdog.killedProcess()를 따로 물어봐야 구분됩니다. CommandLine.addArguments(String)는 문자열 하나를 받아 공백으로 나누므로 따옴표로 감싸지 않은 인자에 공백이 있으면 깨지고, 배열을 받는 메서드의 기본 동작은 공백이 든 인자를 따옴표로 감싸서 그대로 넘깁니다. 그래서 위 예제는 두 번째 인자를 false로 준 배열 메서드를 썼습니다.

유지보수 현황과 선택

이 글을 쓰기 전에는 Commons Exec이 업그레이드가 잘 안 되는 라이브러리이니 zt-exec이 낫다고 생각했습니다. 그런데 Maven Central의 릴리스 기록을 확인해 보니 그 인상은 2023년까지만 맞았습니다.

항목 zt-exec Apache Commons Exec

최신 버전

1.13.0 (2026-07-10)

1.6.0 (2025-11-27)

그 이전 릴리스

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

최근 5년간 릴리스 수

1회

3회

최소 Java 버전

8

8

의존성

slf4j-api

없음

기본 출력 처리

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

System.out, System.err로 전달

시간 초과 알림

TimeoutException

ExecuteException + watchdog.killedProcess()

0이 아닌 종료 코드

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

기본 ExecuteException

Commons Exec은 2014년부터 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 모듈 정보를 넣었고, 그 뒤로 dependabot 커밋이 이어지고 있으니 관리가 재개된 것은 맞습니다. 그래도 최근 2년만 보면 릴리스가 더 잦은 쪽은 Commons Exec입니다.

그러니 zt-exec을 고를 이유는 유지보수 빈도가 아니라 API 설계에 있습니다. 시간 초과와 종료 코드 오류를 예외 타입으로 구분하고, 출력을 문자열로 받는 데 메서드 호출 하나면 되고, 아무 설정 없이 써도 교착이 나지 않는 기본값을 가진 쪽이 zt-exec입니다. 저는 새로 쓰는 코드라면 zt-exec을 고르겠습니다. 다만 의존성을 하나도 늘리고 싶지 않거나 Apache 프로젝트의 거버넌스를 선호한다면 Commons Exec 1.6.0도 builder API로 쓸 만합니다. 이미 Commons Exec을 쓰고 있는 코드를 zt-exec으로 바꿀 만큼 큰 차이는 아닙니다.

fork의 메모리 문제, 그 후

2015년 글의 핵심은 힙이 큰 Java 프로세스가 외부 프로세스를 실행하면 Cannot allocate memory 예외가 날 수 있다는 것이었습니다. 원인은 fork()에 있었습니다. fork()는 부모와 같은 자식 프로세스를 만드는데, Linux는 쓰기 시 복사(copy-on-write)로 실제 페이지 복사는 미루지만 페이지 테이블은 복사하고, 커널의 메모리 overcommit 정책에 따라 자식이 쓸지도 모르는 메모리를 미리 계산해서 거절하기도 합니다. 자식은 곧바로 exec()로 작은 프로그램으로 바뀔 테지만 커널은 그 사실을 알 수 없습니다.

이 문제는 JDK 쪽과 Linux 쪽에서 각각 해소됐습니다.

JDK의 실행 방식 변천

JDK가 Linux에서 하위 프로세스를 만드는 방식은 jdk.lang.Process.launchMechanism 시스템 속성으로 정해집니다. 기본값의 변천은 아래와 같습니다.

JDK Linux에서의 변화

6

fork() + exec()

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

2015년에는 "JDK 7부터 Linux에서 vfork()를 쓰니 메모리 문제는 피할 수 있다"가 결론이었습니다. vfork()는 페이지 테이블을 복사하지 않고 자식이 부모의 메모리 이미지 안에서 exec()까지만 실행하므로 메모리 문제는 없습니다. 대신 자식이 exec() 전에 부모의 스택이나 시그널 처리를 건드릴 위험이 있고, JDK는 그 사이에 파일 디스크립터 정리 같은 작업을 해야 해서 vfork()의 사용 규칙을 어길 수밖에 없었습니다. JDK-8357090의 설명대로 그 위험 때문에 JDK 27부터 VFORK 옵션이 사라집니다. 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 소스의 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_VMCLONE_VFORK 플래그를 주어 vfork()와 같은 효과를 냅니다. 이것이 다음 절의 내용입니다. 같은 프로그램을 -Djdk.lang.Process.launchMechanism=FORK 로 실행하면 CLONE_VM 플래그 없이 clone 시스템 콜을 부르고, jspawnhelper 없이 곧바로 /usr/bin/echoexecve합니다.

glibc와 커널의 변화

JDK가 posix_spawn()으로 옮겨 갈 수 있었던 배경에는 glibc의 변화가 있습니다. posix_spawn(3) 매뉴얼에 따르면 glibc 2.24(2016년 8월)부터 posix_spawn()CLONE_VMCLONE_VFORK 플래그로 clone()을 호출합니다. 자식에게 따로 할당한 스택을 주고 시그널을 막아 두기 때문에 vfork()의 위험 요소 두 가지, 즉 부모 스택 손상과 시그널 오배달이 없습니다. JDK 소스의 주석은 이를 "vfork의 성능을 위험 없이 흉내 낸다"고 표현합니다. Alpine Linux가 쓰는 musl은 처음부터 같은 방식이었습니다. 2015년 글에서 "glibc가 posix_spawn()을 구현할 때 가능하면 vfork()를 쓴다"고 적었는데, 그 글이 나온 이듬해에 glibc가 vfork()를 버린 것입니다.

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.2 이전에는 이 판정에 여유 메모리, 페이지 캐시, 스왑 여유분 등을 합산한 값을 썼습니다. 힙 매핑이 여유 메모리보다 크면 fork()가 그 매핑을 복제할 때 요구하는 크기가 이 값을 넘어서 ENOMEM 이 났습니다. 2015년 글에 나온 Cannot allocate memory 가 바로 이 경우입니다. 커널 5.2에 들어간 mm: fix false-positive OVERCOMMIT_GUESS failures 패치는 이 계산을 "요청 크기가 전체 RAM과 스왑의 합을 넘는지"만 보도록 단순화했습니다. 그래서 지금의 기본 설정에서는 FORK 모드를 써도 힙 크기 때문에 fork()가 실패하는 일이 거의 없습니다. 다만 vm.overcommit_memory=2 로 overcommit을 금지한 서버에서는 fork() 시점에 부모 크기만큼의 commit이 여전히 계산되므로 예전 문제가 그대로 재현됩니다. 데이터베이스 서버 권고 설정을 따라 이 값을 2로 둔 장비에 Java 애플리케이션이 함께 올라가는 경우가 있습니다.

큰 힙에서 FORK와 POSIX_SPAWN의 실행 지연

메모리 할당 실패가 사라졌어도 fork()가 페이지 테이블을 복사한다는 사실은 변하지 않았습니다. 힙이 클수록 복사할 페이지 테이블도 커집니다. 힙을 미리 할당한 상태에서 true 명령을 30번 실행하는 데 걸린 시간을 실행 방식별로 재 봤습니다. 측정 코드는 SpawnBench.java이고, -XX:+AlwaysPreTouch 옵션으로 힙 전체를 실제 메모리에 올린 뒤 측정했습니다. 두 번 실행한 값입니다.

힙 크기 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은 힙 크기와 무관하게 약 2ms이고, FORK는 힙에 비례해서 늘어나 8GB에서는 100배가 넘게 차이가 났습니다. fork()가 페이지 테이블을 복사하는 동안 커널은 부모 프로세스의 주소 공간 잠금(mmap_lock)을 쓰기 모드로 잡습니다. 그래서 호출한 스레드만 멈추는 것이 아니라 같은 시간에 메모리 매핑을 바꾸려는 다른 스레드도 기다립니다. 웹 애플리케이션이 요청마다 외부 명령을 실행한다면 이 차이는 응답 시간에 그대로 나타납니다. POSIX_SPAWN 기본값을 굳이 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 가 넘어간 것이 이 버전 검사입니다. 검사가 없던 버전에서는 명령이 조용히 실패하거나 엉뚱하게 동작했으니 예외가 나는 편이 낫습니다. 이 예외를 만나면 메시지대로 JVM을 재시작하는 것 말고는 방법이 없습니다. 운영 관점에서는 JDK를 버전이 들어간 별도 경로에 설치하고 심볼릭 링크만 바꾸거나, 자동 갱신에서 JDK 패키지를 제외하는 것이 예방책입니다.

JDK 소스의 주석은 posix_spawn()이 libc 구현에 따라 버그가 있을 수 있어서 FORK 모드를 예비로 남겨 둔다고 설명합니다. jspawnhelper 관련 오류가 반복되는데 원인을 바로 찾기 어렵다면 -Djdk.lang.Process.launchMechanism=FORK 로 임시 회피할 수 있습니다. 위에서 본 실행 지연을 감수하는 선택입니다.

2015년의 대처 방안 다시 보기

2015년 글에서는 네 가지 대처 방안을 들었습니다. 지금 기준으로 다시 평가하면 다음과 같습니다.

  • JNI로 직접 시스템 콜 호출: 필요 없어졌습니다. JDK가 posix_spawn()을 기본으로 쓰므로 직접 네이티브 코드를 쓸 이유가 없습니다.

  • Java Service Wrapper, java_posix_spawn 같은 외부 라이브러리: 마찬가지로 필요 없어졌습니다. java_posix_spawn 저장소의 마지막 커밋은 2014년 8월입니다.

  • 외부 프로세스 실행 전용 데몬: 메모리 문제 때문이라면 필요 없지만, 격리 목적으로는 여전히 의미가 있습니다. Security Manager가 사라진 지금 웹 애플리케이션 프로세스 안에서 외부 명령 실행을 막을 JDK 수준의 수단은 없습니다. 원격 코드 실행 취약점의 피해 범위를 줄이려면 명령을 실행하는 프로세스를 따로 두고 컨테이너나 systemd 단위, 별도 사용자 계정으로 권한을 좁히는 것이 남은 방법입니다.

  • JDK 버전 올리기: 지금도 첫 번째 답입니다. Linux에서는 JDK 13 이상이면 기본값이 posix_spawn()이고, jspawnhelper 버전 검사까지 포함하려면 17.0.13, 21.0.4 이상입니다. JDK 11은 11.0.4부터 POSIX_SPAWN을 옵션으로 지정할 수 있지만 기본값은 vfork()입니다. JDK 8에는 이 옵션이 백포트되지 않았습니다.

jdk.lang.Process.launchMechanism 속성은 지정하지 않는 것이 좋습니다. 예전 문서를 보고 VFORK 를 명시한 실행 스크립트가 남아 있다면 JDK 25에서 경고가 나오고 JDK 27에서는 FORK 로 바뀌어 위에서 본 실행 지연을 겪게 됩니다.

마치며

파이프 버퍼 교착은 2015년과 똑같이 남아 있습니다. Linux의 파이프 용량은 여전히 64KiB이고, 출력을 읽지 않는 코드는 출력이 그 크기를 넘는 날 멈춥니다. 출력이 필요 없으면 Redirect.DISCARDinheritIO()로 파이프를 만들지 말고, 필요하면 스트림마다 스레드를 두고 시간제한과 강제 종료까지 챙겨야 합니다. 그 일을 대신 맡기려면 zt-exec이나 Commons Exec을 씁니다. Commons Exec이 방치된 라이브러리라는 인상은 2024년 이후로는 맞지 않았고, zt-exec을 고를 이유는 유지보수 빈도가 아니라 예외 타입과 기본값을 정리한 API에 있었습니다.

메모리 할당 실패는 JDK 13의 posix_spawn() 기본값, glibc 2.24의 clone(CLONE_VM|CLONE_VFORK) 구현, 커널 5.2의 overcommit 휴리스틱 변경이 겹쳐서 기본 설정에서는 사라졌습니다. 대신 fork()의 페이지 테이블 복사 비용, jspawnhelper 의 버전 검사, JDK 27의 VFORK 제거처럼 새로 알아 둘 내용이 생겼습니다. 힙이 큰 프로세스가 외부 명령을 직접 실행하지 않는 편이 낫다는 2015년의 권고는, 이제 메모리보다는 보안 격리와 실행 지연의 관점에서 유효합니다.

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

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

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

Value Object의 정의

Value Object는 식별성 없이 값이 같으면 같다고 간주되는 객체입니다. 돈, 색상, 날짜 같은 개념이 대표적인 예입니다. 10,000원이라는 금액은 어떤 지폐나 계좌 잔액으로 표현되더라도 같은 금액으로 취급할 수 있습니다. 아래 세 자료에서 이 정의를 확인할 수 있습니다.

  • Martin Fowler의 글: 속성 값이 같아서 같다고 보는 객체("Objects that are equal due to the value of their properties …​ are called value objects")

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

  • Microsoft의 .NET 아키텍처 문서: 식별성이 없는 객체("They have no identity.")

Eric Evans의 Domain-Driven Design에서도 'VALUE OBJECT’를 식별성 없이 속성만으로 동등성을 판단하는 객체로 정의합니다.

Java 코드에서는 값 개념을 표현하는 클래스에 동등성을 판단할 속성 값을 기준으로 equals()hashCode()를 구현하면 Value Object가 됩니다. 이때 지켜야 할 두 메서드의 규약은 Joshua Bloch의 Effective Java 3판 Item 10과 Item 11에 정리되어 있습니다. 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); // 참조가 달라도 값이 같으면 동등

Java 언어와 JVM에도 DDD 및 Fowler의 Value Object와 핵심 성질을 공유하는, 식별성 없는 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를 getter/setter만 가진 객체라는 의미로 사용하지 않습니다.

한편 실무에서는 앞의 정의와 무관하게 데이터를 담는 객체(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년 개정 전 버전에서는 "A general heuristic is that value objects should be entirely immutable."라고 썼고, 개정된 현재 버전에서도 "value objects should be immutable"를 중요한 규칙으로 제시합니다.

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

  • Eric Evans는 Domain-Driven Design 99~103쪽에서 불변 Value Object를 자유롭게 공유할 수 있다는 점을 설명합니다.

VO가 불변이면 여러 객체가 같은 인스턴스를 공유해도 별칭 문제(aliasing bug) 같은 부작용에서 자유롭습니다. Java의 java.util.DateCalendar는 값의 성격을 가진 객체인데도 가변으로 설계되어서 공유된 인스턴스가 한쪽에서 수정되는 버그의 원인이 되었습니다. Java 8의 java.time 패키지가 모든 날짜 클래스를 불변으로 만든 것도 이 교훈의 반영입니다.

그런데 Fowler와 Evans의 문장을 자세히 보면 불변성을 VO의 정의에 포함하지는 않았습니다. Fowler가 Value Object의 정의로 제시하는 성질은 값에 의한 동등성입니다. 앞에서 인용한 "value objects should be immutable"도 전체 문장을 보면 별칭 문제를 피하기 위해 따르는 규칙("To avoid aliasing bugs I follow a simple but important rule: value objects should be immutable.")으로 등장합니다. 같은 글에서 C#의 struct처럼 언어가 대입할 때마다 값을 복사하는 방식으로도 별칭 문제를 피할 수 있다고("it’s also possible to avoid them by ensuring assignments always make a copy") 대안까지 언급합니다. Evans의 문장도 명령형의 지침입니다. Domain-Driven Design 99쪽의 "Treat the VALUE OBJECT as immutable."는 VALUE OBJECT가 무엇인지에 대한 정의가 아니라 그것을 다루는 방법에 대한 권고이고, 이어지는 내용은 그렇게 했을 때 얻는 공유와 참조 전달의 안전성입니다.

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에 대해서는 다음과 같이 씁니다.

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를 쓰고 있다면"이라는 가정 자체가 불변이 아닌 Value Object의 존재를 전제합니다. 페이지 이름도 MustBeImmutable이 아니라 ShouldBeImmutable입니다. IETF의 RFC 2119가 기술 명세의 요구 수준을 나누는 방식을 빌리면 이 차이가 분명해집니다. RFC 2119는 MUST를 절대적인 요구 사항("an absolute requirement")으로, SHOULD를 특정 상황에서 타당한 이유가 있으면 충분히 검토한 뒤 따르지 않을 수도 있는 권고로 정의합니다. wiki 페이지 이름이 이 표준을 따라 지어진 것은 아니지만, must와 should를 구분하는 뜻은 같습니다.

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

  • 앞에서 인용한 Microsoft의 .NET 아키텍처 문서는 value object의 두 가지 주요 특성으로 식별성 없음과 불변성을 나란히 들고("There are two main characteristics for value objects: They have no identity. They are immutable."), 불변성을 중요한 요건("Immutability is an important requirement.")이라고 씁니다.

  • Wikipedia도 같게 생성된 두 value object가 계속 같아야 한다는 암묵적 계약을 위해 불변성이 요구된다고 서술합니다.

  • 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.")로 정의합니다. 책의 401쪽에서도 같은 정의를 볼 수 있습니다. 원격 호출은 비용이 크므로 한 번의 호출로 필요한 데이터를 모두 전달하려는 의도에서 나온 패턴입니다.

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

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

이름을 둘러싼 논쟁과는 별개로, 로컬 맥락에서 그런 객체를 두는 것이 설계상 바람직한지도 따져볼 수 있습니다. Fowler는 LocalDTO에서 이름이 아니라 이 쓰임새를 문제 삼습니다. DTO 패턴의 존재 이유가 원격 호출 비용을 줄이는 것이므로, 원격 호출이 없는 로컬 맥락에서는 그 이유가 사라진다는 것이 출발점입니다. 그래서 서비스 계층의 클라이언트가 도메인 모델에 의존하지 않도록 서비스 계층 API에 DTO를 두자는 주장에 대해서, 로컬 맥락에서는 DTO가 필요하지 않을 뿐 아니라 오히려 해롭다고("Not just do you not need them in a local context, they are actually harmful") 말합니다. coarse-grained 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가 추가 비용이 아니기 때문입니다. 정리하면 Fowler의 비판은 도메인 모델과의 분리 자체를 목적으로 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 문서에서 개칭된 이름으로 정리된 패턴 설명을 볼 수 있습니다.

다른 여러 책도 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)."

  • 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."

  • Murat Yener·Alex Theedom, Professional Java EE Design Patterns, Wrox, 2014, 12장: "The DTO is also referred to as the 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)"

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

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

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

  • 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라고 이해하고 있다면 이런 자료를 읽을 때 용어가 어긋나서 혼란이 생깁니다. 원격 프로세스 경계를 넘는 데이터 운반 객체를 DTO라고 부르면 Fowler의 정의와도, Core J2EE Patterns 2판 이후의 개칭된 이름과도 자연스럽게 연결됩니다.

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

더 좋은 방법은 객체의 역할에 따라서 접미어를 분리하는 것입니다. 이렇게 하면 이름만으로 역할이 드러나고, 원격 호출과 관련되지 않은 계층에서 쓰이는 객체를 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와 정상혁이 함께 작성했습니다.