namalzi

← 글 목록

Gradle 소스 파헤치기 3: gradle 명령에서 데몬까지 — 런처, 래퍼, 데몬 프로토콜

Gradle · 게시

터미널에서 gradle을 치면 빌드는 그 프로세스가 아니라 뒤에 숨은 데몬 JVM이 합니다. 셸 스크립트가 띄우는 64MiB짜리 클라이언트, 데몬을 쓸지 정하는 세 갈래와 --no-daemon의 진짜 뜻, registry.bin으로 데몬을 찾고 호환성을 따지는 법, 데몬을 띄우고 연결하는 순서, 데몬 안의 명령 사슬 16개와 Idle·Busy 상태, 데몬이 스스로 죽는 조건, 그리고 gradlew와 IDE가 같은 데몬에 닿는 길까지 기준 커밋의 소스와 도커 예제로 확인합니다.

이 글에서 다루는 것

"Gradle 소스 파헤치기" 연재의 세 번째 글입니다. 1편에서 gradle build 한 번이 지나는 큰 길을 그렸고, 2편에서 그 길을 프로세스·상태·단계의 지도로 다시 폈습니다. 이번 글부터는 지도의 한 칸씩을 확대합니다. 처음 확대할 곳은 2편 지도의 A 구역(클라이언트와 데몬), 그 가운데서도 클라이언트 프로세스와 데몬 프로세스 사이의 화살표 하나입니다. 빌드 스크립트가 한 줄이라도 실행되기 전에 일어나는 일 전부입니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 Gradle Build Tool
버전 9.10.0 개발판 (master)
커밋 07ecbf9ead (2026-10-24)
주로 읽는 클래스 launcher/GradleMain, launcher/cli/BuildActionsFactory, daemon/client/DaemonClient·DefaultDaemonConnector·DefaultDaemonStarter, daemon/context/DaemonCompatibilitySpec, daemon/bootstrap/DaemonMain, daemon/server/Daemon·DaemonServices·DaemonStateCoordinator, wrapper/GradleWrapperMain·Install

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 이 글의 클래스는 거의 다 platforms/core-runtime/ 아래 여러 서브프로젝트에 흩어져 있고, org.gradle.launcher.daemon 패키지 파일만 165개입니다(건강 검사, 취소, 파일 감시 연동, 메시지 직렬화…). 이 글은 큰 줄기만 골랐고, 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적었습니다.

먼저 알아 둘 말

클라이언트(client)는 터미널에서 gradle이나 ./gradlew를 쳤을 때 뜨는 JVM 프로세스입니다. 이름과 달리 빌드를 하지 않습니다. 데몬을 찾거나 띄우고, "이 태스크들을 돌려 달라"는 요청을 보내고, 데몬이 보내 주는 로그를 화면에 찍고, 결과를 받아 종료 코드로 바꿉니다.

데몬(daemon)은 빌드를 실제로 하는, 오래 사는 JVM 프로세스입니다. 한 번 뜨면 기본 3시간 동안 다음 요청을 기다립니다. 왜 두 프로세스로 나눴는지는 1편의 "왜 데몬을 두나"에서 봤습니다(JVM 기동, 클래스 적재, 스크립트 컴파일, JIT 예열을 매번 다시 하지 않으려고).

데몬 레지스트리(daemon registry)는 "지금 이 기계에 어떤 데몬이 어느 포트에서 어떤 상태로 떠 있나"를 적어 둔 파일, ~/.gradle/daemon/<Gradle 판>/registry.bin입니다. 클라이언트와 데몬은 서로를 이 파일로 찾습니다.

호환성(compatibility)은 "이 데몬에게 내 빌드를 맡겨도 되나"입니다. 같은 JVM인지, JVM 옵션(-Xmx 등)이 같은지 따위를 봅니다. 맞지 않는 데몬은 비어 있어도 쓰지 않고 새로 띄웁니다.

단일 사용 데몬(single-use daemon)은 빌드 하나만 하고 스스로 끝나는 데몬입니다. --no-daemon을 줬는데 지금 클라이언트 JVM으로는 빌드할 수 없을 때 씁니다(2절).

래퍼(wrapper)는 저장소에 들어 있는 gradlew 스크립트와 gradle/wrapper/gradle-wrapper.jar입니다. 정해 둔 판의 Gradle을 내려받아 그것으로 빌드합니다(7절).

Tooling API는 IntelliJ IDEA 같은 IDE가 Gradle을 부를 때 쓰는 라이브러리입니다. 이것도 결국 같은 데몬에 붙습니다(8절).

1. 두 프로세스 — 셸 스크립트에서 데몬의 문 앞까지

gradle 명령 한 번에 생기는 두 프로세스. 클라이언트 프로세스(java -Xmx64m)에서 bin/gradle 셸 스크립트가 java -jar gradle-cli-main.jar를 실행하고, GradleMain이 ProcessBootstrap으로 클래스로더를 지은 뒤 Main과 DefaultCommandLineActionFactory가 명령줄과 로깅을 준비한다. BuildActionsFactory가 데몬을 쓸지 정하고, RunBuildAction과 DaemonClient가 데몬을 찾거나 띄운다. DaemonClient는 ~/.gradle/daemon/판/registry.bin에서 데몬 주소를 읽는다(①). 데몬 프로세스는 GradleDaemon에서 ProcessBootstrap을 거쳐 DaemonMain으로 시작해 레지스트리에 자기 주소와 토큰을 적고(②), Daemon이 127.0.0.1의 빈 포트에서 연결을 받는다. 클라이언트는 소켓으로 Build 명령을 보내고(③), DaemonCommandExecuter의 명령 사슬 16개를 지나 ExecuteBuild 아래에서 실제 빌드가 일어난다. 파란 상자가 이 글에서 읽는 클래스입니다. 두 프로세스는 레지스트리 파일로 서로를 찾고, 소켓으로 이야기합니다.

셸 스크립트는 64MiB짜리 JVM을 띄운다

gradle 명령의 정체는 Gradle 설치 폴더의 bin/gradle 셸 스크립트입니다. 이 스크립트는 소스 저장소에 그대로 들어 있지 않고, Gradle 저장소 자신을 빌드하는 플러그인이 만듭니다. 그 플러그인이 스크립트에 적는 기본 JVM 옵션이 setDefaultJvmOpts(listOf("-Xmx64m", "-Xms64m"))입니다(build-logic/jvm/…/GradleStartScriptGenerator.kt 83–90행).

클라이언트 JVM의 힙은 64MiB입니다. 빌드를 하지 않으니 그만큼이면 된다는 뜻이고, 이 숫자는 2절에서 --no-daemon의 동작을 가릅니다. 환경 변수 GRADLE_OPTS로 이 JVM의 옵션을 바꿀 수 있습니다(데몬 JVM의 옵션은 org.gradle.jvmargs입니다 — 둘을 헷갈리기 쉽습니다).

예제 컨테이너의 설치본(/opt/gradle)에서 실제로 만들어진 스크립트와 jar의 매니페스트를 보면 이렇습니다(예제 1의 첫 부분, 출력은 실제 그대로입니다).

=== bin/gradle: default JVM options and the java command ===
203:DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'" \"-javaagent:$APP_HOME/lib/agents/gradle-instrumentation-agent-9.10.0.jar\""
213:        -jar "$APP_HOME/lib/gradle-gradle-cli-main-9.10.0.jar" \
--- jar manifests ---
gradle-gradle-cli-main-9.10.0.jar: Class-Path: gradle-build-process-services-9.10.0.jar gradle-classloaders Main-Class: org.gradle.launcher.GradleMain 
gradle-launcher-9.10.0.jar: Class-Path: gradle-gradle-cli-main-9.10.0.jar 

(매니페스트는 72자마다 줄을 바꾸는데 run.sh가 첫 줄만 골라서 Class-Path가 중간에서 끊겨 보입니다.) 스크립트는 java -Xmx64m -Xms64m -javaagent:… -jar lib/gradle-gradle-cli-main-9.10.0.jar를 실행하고, 그 jar의 Main-Class가 org.gradle.launcher.GradleMain입니다.

GradleMain과 ProcessBootstrap — 진짜 일은 다른 클래스로더에서

GradleMain.main()은 ProcessBootstrap.run(GradleMain.class.getName(), "gradle-gradle-cli", "org.gradle.launcher.Main", args) 한 줄입니다(GradleMain.java 24–28행). ProcessBootstrap.run()은 gradle-gradle-cli 모듈이 쓰는 jar 목록으로 새 클래스로더를 만들고, 그 안에서 org.gradle.launcher.Main을 찾아 run()을 부릅니다.

            DefaultModuleRegistry moduleRegistry = new DefaultModuleRegistry(CurrentGradleInstallation.get());
            // ...
            ClassPath runtimeClasspath = moduleRegistry.getRuntimeClasspath(moduleName);
            antClassLoader = classLoaderFactory.createIsolatedClassLoader("ant-loader", antClasspath);
            runtimeClassLoader = VisitableURLClassLoader.fromClassPath("ant-and-gradle-loader", antClassLoader, runtimeClasspath);
            // ...
            Class<?> mainClass = runtimeClassLoader.loadClass(mainClassName);
            Object entryPoint = mainClass.getConstructor().newInstance();
            Method mainMethod = mainClass.getMethod("run", String[].class);
            mainMethod.invoke(entryPoint, new Object[]{args});

platforms/core-runtime/build-process-services/src/main/java/org/gradle/launcher/bootstrap/ProcessBootstrap.java 52–72행에서 줄임

왜 이렇게 한 번 돌아갈까요. java -jar가 처음 싣는 클래스패스는 jar 매니페스트의 Class-Path로 고정됩니다. Gradle은 그 고정 목록을 아주 작게 두고, 나머지는 설치 폴더의 모듈 정보(DefaultModuleRegistry)를 읽어 필요한 모듈만 클래스로더에 담습니다. 데몬 쪽도 똑같이 GradleDaemon → ProcessBootstrap → DaemonMain 순서로 시작합니다(4절). 클래스로더를 나눠 쓰는 생각은 Tomcat 4편의 클래스로더 계층과 닮았고, Gradle 안의 클래스로더 나무는 6편에서 다시 봅니다.

DefaultCommandLineActionFactory — 명령줄을 동작 하나로

Main.doAction()은 한 줄로 DefaultCommandLineActionFactory.convert(args)가 돌려준 동작을 실행합니다(Main.java 33–36행). 그 동작은 겉에서부터 로깅 준비(WithLogging) → 예외 보고 → 네이티브 서비스 초기화 → 환영 메시지 → 실제 동작(ParseAndBuildAction)으로 감싸여 있습니다. 실제 동작은 명령줄을 파싱한 뒤 등록된 "동작 만들기" 객체들에게 차례로 묻고, 처음으로 동작을 돌려준 객체에서 멈춥니다(돌려준 것이 "계속하는 동작"이면 다음에게도 묻습니다, 283–300행).

목록의 순서는 BuiltInActionCreator(--help, --version), ContinuingActionCreator(--show-version), 그리고 맨 끝의 BuildActionsFactory입니다(250–252행, 264행). --version이면 첫 번째가 동작을 돌려주고 거기서 멈춥니다. 그래서 gradle --version은 데몬을 띄우지 않습니다. --show-version은 "계속하는 동작"(ContinuingAction)이라 버전을 찍고 다음으로 넘어가 빌드까지 합니다. 빌드를 하는 명령은 모두 마지막 BuildActionsFactory까지 내려옵니다.

헷갈리기 쉬운 곳

2. 데몬을 쓸까, 말까 — BuildActionsFactory의 세 갈래

BuildActionsFactory.createAction()이 갈림길입니다. 1편에서 잠깐 본 자리를, 이번에는 조건 끝까지 따라갑니다.

        DaemonRequestContext requestContext = daemonParameters.toRequestContext();
        if (daemonParameters.getRequestedJvmCriteria() instanceof DaemonJvmCriteria.Spec) {
            startParameter.setDaemonJvmCriteriaConfigured(true);
        }

        if (daemonParameters.isEnabled()) {
            return Actions.toAction(runBuildWithDaemon(startParameter, daemonParameters, requestContext, buildLayoutConfiguration));
        }
        if (canUseCurrentProcess(daemonParameters, requestContext)) {
            return Actions.toAction(runBuildInProcess(startParameter, daemonParameters));
        }

        return Actions.toAction(runBuildInSingleUseDaemon(startParameter, daemonParameters, requestContext, buildLayoutConfiguration));

platforms/core-runtime/gradle-cli/src/main/java/org/gradle/launcher/cli/BuildActionsFactory.java 135–147행

그 위(105–133행)에서 --stop, --status, --foreground를 먼저 걸러 냅니다. --stop과 --status는 데몬들에게 짧은 명령 하나씩을 보내는 클라이언트를 만들고, --foreground는 이 클라이언트 프로세스가 스스로 데몬이 됩니다.

BuildActionsFactory.createAction의 결정 흐름. --stop, --status, --foreground면 데몬에 Stop이나 ReportStatus를 보내거나 이 프로세스가 데몬이 된다. 아니면 데몬 사용 여부(기본값 true)를 본다. 예이면 ① runBuildWithDaemon으로 DaemonClient를 만들어 있으면 재사용한다. --no-daemon이면 canUseCurrentProcess로 이 JVM으로 되는지 본다. 예이면 ② runBuildInProcess로 이 프로세스에서 빌드하고, 아니면(힙이 64MiB 이하이거나 JVM 옵션이 다르면) ③ runBuildInSingleUseDaemon으로 이번 빌드만 쓰는 데몬을 새로 띄운다. 초록은 새 JVM을 띄우지 않는 길(①은 데몬이 이미 있을 때), 빨강은 --no-daemon인데도 JVM을 하나 더 띄우는 길입니다.

데몬 사용 여부의 기본값은 DaemonParameters에 있습니다.

    static final int DEFAULT_IDLE_TIMEOUT = 3 * 60 * 60 * 1000;
    public static final int DEFAULT_PERIODIC_CHECK_INTERVAL_MILLIS = 10 * 1000;

    public static final List<String> DEFAULT_JVM_ARGS = ImmutableList.of("-Xmx512m", "-Xms256m", "-XX:MaxMetaspaceSize=384m", "-XX:+HeapDumpOnOutOfMemoryError");

platforms/core-runtime/client-services/src/main/java/org/gradle/launcher/daemon/configuration/DaemonParameters.java 43–46행

같은 파일 59행이 private boolean enabled = true;입니다. 데몬은 기본으로 켜져 있고, 놀고 있으면 3시간(DEFAULT_IDLE_TIMEOUT) 뒤에 끝나며, 10초마다 스스로 상태를 점검합니다(6절). 데몬 JVM의 기본 옵션은 힙 512MiB입니다. --no-daemon이나 org.gradle.daemon=false는 DaemonBuildOptions.DaemonOption이 setEnabled(false)로 바꿉니다(DaemonBuildOptions.java 322–324행).

canUseCurrentProcess — 지금 이 JVM을 데몬인 셈 치기

--no-daemon일 때 쓰는 판단은 재미있게 생겼습니다.

    protected boolean canUseCurrentProcess(DaemonParameters daemonParameters, DaemonRequestContext requestContext) {
        // Pretend like the current process is actually a daemon, and see if it satisfies the compatibility spec
        CurrentProcess currentProcess = new CurrentProcess(fileCollectionFactory);
        DaemonContext contextForCurrentProcess = buildDaemonContextForCurrentProcess(requestContext, currentProcess);

        DaemonCompatibilitySpec comparison = new DaemonCompatibilitySpec(requestContext);
        if (currentProcess.isLowMemoryProcess()) {
            LOGGER.info(CAN_USE_CURRENT_PROCESS_MESSAGE, "The maximum heap size is insufficient.\n");
            return false;
        }
        String whyUnsatisfied = comparison.whyUnsatisfied(contextForCurrentProcess);
        // ...
        return whyUnsatisfied == null;
    }

platforms/core-runtime/gradle-cli/src/main/java/org/gradle/launcher/cli/BuildActionsFactory.java 172–187행에서 줄임

주석 그대로 "지금 프로세스가 데몬이라고 치고" 3절의 호환성 검사를 돌려 봅니다. 데몬을 고를 때 쓰는 잣대를 그대로 재사용하는 것입니다. 그런데 그 앞에 isLowMemoryProcess()가 있습니다.

    public boolean isLowMemoryProcess() {
        return Runtime.getRuntime().maxMemory() <= 64L * 1024 * 1024; // 64MB is our default for a launcher process
    }

platforms/core-runtime/process-services/src/main/java/org/gradle/process/internal/CurrentProcess.java 49–51행

1절에서 본 대로 셸 스크립트는 클라이언트를 -Xmx64m으로 띄웁니다. 그러니 아무것도 바꾸지 않은 gradle --no-daemon은 언제나 여기서 false가 되고, 단일 사용 데몬을 띄웁니다. 단일 사용 데몬은 빌드가 끝나면 스스로 끝납니다. 데몬 안의 명령 사슬에 있는 RequestStopIfSingleUsedDaemon이 빌드 시작 때 "끝나면 멈춰 달라"를 미리 걸어 둡니다(5절). 여기에 더해 runBuildInSingleUseDaemon()은 놀 시간을 최대 2분으로 깎는데, 주석이 스스로 "임시방편(workaround)이지만 안전망으로 남길 수도 있다"고 적고 있습니다(239–245행).

예제 2: --no-daemon의 세 얼굴

이 예제는 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. 빌드 스크립트가 자기가 도는 프로세스의 pid, 메인 클래스, 최대 힙을 찍게 했습니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)

// 빌드 스크립트가 어느 프로세스에서 도는지 찍는다:
// 데몬이면 메인 클래스가 GradleDaemon, 클라이언트 프로세스 그대로면 -jar 로 띄운 gradle-gradle-cli-main jar 다
val me = ProcessHandle.current()
val args = me.info().arguments().map { it.toList() }.orElse(emptyList())
val main = args.firstOrNull { it.endsWith(".GradleDaemon") }
    ?: args.firstOrNull { it.contains("cli-main") }?.substringAfterLast('/')
    ?: "?"
val heap = Runtime.getRuntime().maxMemory() / 1024 / 1024
println("[configure] pid=${me.pid()} main=$main maxHeap=${heap}MiB")

[예제 ex/u03-no-daemon/project/build.gradle.kts에서 줄임 — 아래에 hello 태스크 하나가 더 있습니다]

run.sh는 세 가지를 차례로 돌리고 --info 출력에서 관련 줄만 grep으로 골라 찍습니다(앞의 숫자는 로그의 줄 번호입니다). 첫째, 그냥 --no-daemon입니다.

=== 1. --no-daemon (default launcher JVM: -Xmx64m) ===
6:The current JVM process isn't compatible with build requirement. The maximum heap size is insufficient.
8:To honour the JVM settings for this build a single-use Daemon process will be forked. For more on this, please refer to https://docs.gradle.org/9.10.0-20261107025045+0000/userguide/gradle_daemon.html#sec:disabling_the_daemon in the Gradle documentation.
12:The client will now receive all logging from the daemon (pid: 50). The daemon log file: /root/.gradle/daemon/9.10.0-20261107025045+0000/daemon-50.out.log
42:[configure] pid=50 main=org.gradle.launcher.daemon.bootstrap.GradleDaemon maxHeap=512MiB
58:BUILD SUCCESSFUL in 20s
--- processes left after the build ---
(no Gradle JVM left)

isLowMemoryProcess()의 메시지가 그대로 찍히고, SingleUseDaemonClient의 안내(To honour the JVM settings…)가 이어집니다. 빌드 스크립트는 GradleDaemon 프로세스에서, 힙 512MiB로 돌았습니다. 빌드가 끝난 뒤에는 Gradle JVM이 하나도 남지 않았습니다. 데몬 프로세스가 뜨긴 떴지만 빌드 하나만 하고 사라진 것입니다.

둘째, 클라이언트 JVM을 데몬의 기본 옵션과 같게 띄워 봤습니다.

=== 2. --no-daemon with a launcher JVM that matches the daemon JVM options ===
18:[configure] pid=161 main=gradle-gradle-cli-main-9.10.0.jar maxHeap=512MiB
34:BUILD SUCCESSFUL in 6s

GRADLE_OPTS="-Xmx512m -Xms256m -XX:MaxMetaspaceSize=384m -XX:+HeapDumpOnOutOfMemoryError"를 줬더니 호환성 검사를 통과해 클라이언트 프로세스 자신(main=gradle-gradle-cli-main-9.10.0.jar)이 빌드했습니다. 데몬 로그 줄도, 단일 사용 안내도 없습니다. 이것이 세 갈래의 ②, RunInProcess입니다. 셋째(다른 JVM 옵션을 달라는 빌드)는 3절에서 봅니다. pid와 시간은 실행마다 다릅니다.

헷갈리기 쉬운 곳

3. 데몬 찾기 — 레지스트리와 호환성

데몬을 쓰기로 했으면(①) DaemonClient.execute()가 불립니다. 클라이언트 쪽 이야기의 뼈대가 이 메서드에 다 있습니다.

        // Attempt to connect to an existing idle and compatible daemon
        int saneNumberOfAttempts = 100; //is it sane enough?
        for (int i = 1; i < saneNumberOfAttempts; i++) {
            final DaemonClientConnection connection = connector.connect(compatibilitySpec);
            // No existing, compatible daemon is available to try
            if (connection == null) {
                break;
            }
            // Compatible daemon was found, try it
            try {
                // ...
                return executeBuild(build, connection, requestContext.getCancellationToken(), requestContext.getEventConsumer());
            } catch (DaemonInitialConnectException e) {
                // this exception means that we want to try again.
                // ...
            }
        }

        // No existing daemon was usable, so start a new one and try it once
        final DaemonClientConnection connection = connector.startDaemon(compatibilitySpec);

platforms/core-runtime/client-services/src/main/java/org/gradle/launcher/daemon/client/DaemonClient.java 147–169행에서 줄임

"맞는 데몬에 붙어 본다 → 그 데몬이 '나 바빠'라고 하면 다른 데몬을 찾는다 → 없으면 새로 띄워 한 번만 시도한다"입니다. 붙어 봤는데 거절당하는 일은 실제로 일어납니다. 레지스트리에 Idle로 적혀 있어도 그 사이 다른 클라이언트가 먼저 붙었을 수 있기 때문입니다(파일을 읽는 순간과 연결하는 순간 사이의 경쟁). 그때 데몬은 5절의 StartBuildOrRespondWithBusy에서 DaemonUnavailable을 돌려주고, 클라이언트는 그것을 DaemonInitialConnectException으로 바꿔 다음 데몬으로 넘어갑니다(229–230행).

후보를 고르는 쪽은 DefaultDaemonConnector.connect()입니다(120–141행). 레지스트리의 데몬을 Idle과 나머지로 나누고, Idle 가운데 호환되는 것에 차례로 붙어 봅니다. 없으면 "취소된 빌드를 정리 중인" 데몬이 Idle이 되기를 최대 3초(CANCELED_WAIT_TIMEOUT, 62행) 기다려 봅니다. 그래도 없으면 null을 돌려주는데, 그 직전의 handleStopEvents()가 DaemonStartupMessage.generate()로 우리가 늘 보던 문장을 찍습니다(DaemonStartupMessage.java 43–48행).

못 쓴 데몬이 없으면 Starting a Gradle Daemon (subsequent builds will be faster), 있으면 Starting a Gradle Daemon, 1 busy Daemon could not be reused, use --status for details(예제 4)나 … 1 incompatible Daemon could not be reused …(예제 3)처럼 왜 새로 띄우는지를 말해 줍니다(handleStopEvents()는 레지스트리에서 읽은 Idle 데몬 수를 그대로 "incompatible"로 넘깁니다. 여기까지 왔다면 그 Idle 데몬들은 하나도 쓸 수 없었다는 뜻입니다, 155행). 첫 빌드가 아닌데 이 문장이 보이면 이 숫자를 읽으면 됩니다.

클라이언트가 데몬을 찾는 순서. ~/.gradle/daemon/〈Gradle 판〉/registry.bin 에는 그 판의 데몬만 적혀 있다(DaemonDir). DefaultDaemonConnector.connect()가 레지스트리의 데몬을 Idle 과 나머지로 나누고, Idle 가운데 호환되는 데몬이 있는지 DaemonCompatibilitySpec 으로 JVM, 데몬 옵션, 우선순위, 에이전트, 네이티브 서비스 순서로 본다. 있으면 그 데몬에 붙어 DaemonClient.executeBuild()로 빌드를 맡기고, 받아 주면 그 데몬에서 빌드한다. 데몬이 바쁘다며 DaemonUnavailable 을 돌려주면 DaemonInitialConnectException 이 되어 다음 데몬을 찾아 다시 connect()를 부른다. 호환되는 Idle 데몬이 없으면 취소된 빌드를 정리 중인 호환 데몬이 있는지 보고, 있으면 Idle 이 되기를 최대 3초 기다려 그사이 Idle 이 되면 그 데몬에 붙는다. 그런 데몬이 없거나 3초가 지나면 handleStopEvents()가 Starting a Gradle Daemon … 문장을 찍고 connect()는 null 을 돌려주며, connector.startDaemon()으로 새 데몬을 띄워 한 번만 시도한다(4절). 위 코드와 세 문단을 한 장으로 옮겼습니다. 노란 칸은 고르는 곳, 초록 칸은 있던 데몬을 쓰는 길, 빨간 칸은 새 데몬을 띄우는 길입니다.

호환성 — DaemonCompatibilitySpec

"맞는 데몬"의 정의는 DaemonCompatibilitySpec.whyUnsatisfied()입니다(41–55행). if … else if로 다섯 가지를 이 순서로 보고, 처음 걸린 것의 이유("JVM is incompatible.", "At least one daemon option is different." …)를 돌려줍니다.

검사 무엇을 비교하나 바뀌는 계기
JVM 원하는 JVM의 java 실행 파일과 데몬의 것이 같은 파일인가(Files.isSameFile, 96행). 데몬 JVM 기준(gradle-daemon-jvm.properties)을 정했으면 버전·벤더가 맞는가 JAVA_HOME, org.gradle.java.home, 데몬 JVM 기준
데몬 옵션 변경할 수 없는 JVM 옵션 목록이 순서와 상관없이 같은가(62–65행) org.gradle.jvmargs
우선순위 프로세스 우선순위(--priority low) org.gradle.priority
에이전트 계측 에이전트(-javaagent)를 쓰는가 org.gradle.internal.instrumentation.agent(내부용)
네이티브 서비스 네이티브 통합을 켰는가 org.gradle.native

Gradle의 판은 이 표에 없습니다. 판을 따지지 않아도 되는 이유는 레지스트리 파일 자체가 판마다 따로 있기 때문입니다.

    public DaemonDir(File baseDir) {
        this.baseDir = baseDir;
        this.versionedDir = new File(baseDir, GradleVersion.current().getVersion());
        this.registryFile = new File(versionedDir, "registry.bin");
        GFileUtils.mkdirs(this.versionedDir);
    }

platforms/core-runtime/daemon-protocol/src/main/java/org/gradle/launcher/daemon/registry/DaemonDir.java 32–37행

9.10.0 클라이언트는 ~/.gradle/daemon/9.10.0…/registry.bin만 읽으니 9.8.2 데몬은 처음부터 보이지 않습니다. gradle --status가 "Only Daemons for the current Gradle version are displayed"라고 덧붙이는 이유도 같습니다.

예제 3: 다른 JVM 옵션을 달라는 빌드

예제 2의 run.sh 셋째 부분입니다. 보통 데몬을 하나 띄워 두고, -Dorg.gradle.jvmargs=-Xmx600m으로 다시 빌드합니다.

=== 3. a normal daemon, then a build that asks for different JVM options ===
[configure] pid=279 main=org.gradle.launcher.daemon.bootstrap.GradleDaemon maxHeap=512MiB
[execute] hello
3:Found daemon DaemonInfo{pid=279, address=[f7e53902-7ca5-4489-8893-0156bde1336c port:34395, addresses:[/127.0.0.1]], state=Idle, lastBusy=1794024042913, context=DefaultDaemonContext[uid=7c23604c-1901-4dbc-ae67-adb4007e2f3b,javaHome=/opt/java/openjdk,javaVersi
4:At least one daemon option is different.
5:Wanted: DaemonRequestContext{jvmCriteria=/opt/java/openjdk (no Daemon JVM specified, using current Java home), daemonOpts=[-Xmx600m, -Dfile.encoding=UTF-8, -Duser.country=US, -Duser.language=en, -Duser.variant], applyInstrumentationAgent=true, nativeServices
6:Actual: DefaultDaemonContext[uid=7c23604c-1901-4dbc-ae67-adb4007e2f3b,javaHome=/opt/java/openjdk,javaVersion=25,javaVendor=Eclipse Adoptium,daemonRegistryDir=/root/.gradle/daemon,pid=279,idleTimeout=10800000,priority=NORMAL,applyInstrumentationAgent=true,nat
8:  Looking for a different daemon...
10:Starting a Gradle Daemon, 1 incompatible Daemon could not be reused, use --status for details
14:The client will now receive all logging from the daemon (pid: 398). The daemon log file: /root/.gradle/daemon/9.10.0-20261107025045+0000/daemon-398.out.log
32:[configure] pid=398 main=org.gradle.launcher.daemon.bootstrap.GradleDaemon maxHeap=600MiB
48:BUILD SUCCESSFUL in 6s
--- gradle --status ---
   PID STATUS   INFO
   279 IDLE     9.10.0-20261107025045+0000
   398 IDLE     9.10.0-20261107025045+0000

(긴 줄은 run.sh가 260자에서 잘랐습니다.) getCompatibleDaemons()의 Found daemon … however its context does not match the desired criteria(192–194행)가 여러 줄에 걸쳐 찍히고(3–8행), 이유는 표의 둘째 줄 데몬 옵션입니다. Wanted:의 daemonOpts에 -Xmx600m만 있고 기본값이던 -Xms256m·-XX:MaxMetaspaceSize=384m이 없는 것이 2절 끝의 "바꿔치기"입니다. 결국 새 데몬(pid 398, 힙 600MiB)이 떴고, 레지스트리에는 Idle 데몬이 둘 남았습니다. pid, 포트, uid, lastBusy, 시간은 실행마다 다릅니다.

헷갈리기 쉬운 곳

4. 데몬 띄우기와 연결 — 설정은 stdin, 주소는 레지스트리

맞는 데몬이 없으면 connector.startDaemon()입니다. 전체 순서를 먼저 그림으로 봅니다.

데몬을 새로 띄워 연결하는 순서. ① DaemonClient가 DefaultDaemonConnector에 호환성 조건으로 connect를 부르면 ② 커넥터가 registry.bin을 getAll로 읽어 Idle과 나머지로 나누고 ③ 맞는 Idle 데몬이 없어 null을 돌려준다. ④ 클라이언트가 startDaemon을 부르면 ⑤ 커넥터가 DefaultDaemonStarter.startDaemon(singleUse=false)를 부르고 ⑥ 스타터가 java … GradleDaemon 판번호로 새 데몬을 띄우며 설정은 stdin으로 넘긴다. ⑦ 새 데몬이 자기 주소와 토큰을 레지스트리에 Busy로 적고 ⑧ stdout으로 시작 정보를 보낸 뒤 스트림을 닫는다. ⑨ 커넥터가 Busy 목록에서 uid가 같은 데몬을 찾아 ⑩ 소켓으로 연결한다. ⑪ 클라이언트가 토큰을 담은 Build를 보내고 ⑫ 데몬이 BuildStarted, 로그(OutputMessage)들, Result를 보낸 뒤 ⑬ 클라이언트가 Finished를 보낸다. 처음 빌드할 때의 순서입니다. 데몬이 이미 있으면 ④–⑨가 없고, ②에서 고른 데몬에 바로 ⑩으로 갑니다.

DefaultDaemonStarter — 명령줄 만들기

        // We only need the daemon main jar for initial startup. The daemon is responsible for loading everything else.
        ModuleRegistry registry = new DefaultModuleRegistry(CurrentGradleInstallation.get());
        ClassPath classpath = registry.getModule("gradle-daemon-main").getImplementationClasspath();
        // ...
        daemonArgs.addAll(daemonOpts);
        daemonArgs.add("-cp");
        daemonArgs.add(CollectionUtils.join(File.pathSeparator, classpath.getAsFiles()));
        // ...
        daemonArgs.add("org.gradle.launcher.daemon.bootstrap.GradleDaemon");
        // Version isn't used, except by a human looking at the output of jps.
        daemonArgs.add(GradleVersion.current().getVersion());

        // Serialize configuration to daemon via the process' stdin
        StreamByteBuffer buffer = new StreamByteBuffer();

platforms/core-runtime/client-services/src/main/java/org/gradle/launcher/daemon/client/DefaultDaemonStarter.java 128–176행에서 줄임

세 가지가 보입니다.

  1. 클래스패스는 jar 하나입니다(gradle-daemon-main). 나머지는 데몬이 ProcessBootstrap으로 스스로 싣습니다(1절과 같은 방법).
  2. 명령줄 끝의 판 번호는 장식입니다. 주석 그대로 jps로 보는 사람을 위한 것입니다. 데몬은 인자가 정확히 하나인지만 봅니다(DaemonMain 69–71행).
  3. 진짜 설정(사용자 홈, 데몬 uid, 놀 시간, JVM 옵션…)은 표준 입력(stdin)으로 넘깁니다. 명령줄에 올리면 ps로 누구나 볼 수 있고 길이 제한도 있기 때문으로 보입니다. 소스 주석은 "stdin으로 넘긴다"까지만 적고 이유는 적지 않았습니다.

그리고 프로세스를 띄운 뒤 그 프로세스가 끝나기를 기다립니다.

                ExecHandle handle = new DaemonExecHandleBuilder().build(args, workingDir, outputConsumer, stdInput, execActionFactory.newExecHandleBuilder());

                handle.start();
                LOGGER.debug("Gradle daemon process is starting. Waiting for the daemon to detach...");
                handle.waitForFinish();
                LOGGER.debug("Gradle daemon process is now detached.");

platforms/core-runtime/client-services/src/main/java/org/gradle/launcher/daemon/client/DefaultDaemonStarter.java 248–253행

데몬은 오래 살아야 하는데 끝나기를 기다린다니 이상합니다. 답은 데몬 쪽에 있습니다.

DaemonMain — 설정을 읽고, 레지스트리에 적고, 스트림을 닫는다

        // Read configuration from stdin
        DaemonServerConfiguration parameters = DaemonStartupCommunication.readDaemonServerConfiguration(System.in);
        // ...
        // Any logging prior to this point will not end up in the daemon log file.
        initialiseLogging(loggingManager, daemonLog);

        // Detach the process from the parent terminal/console
        ProcessEnvironment processEnvironment = daemonServices.get(ProcessEnvironment.class);
        processEnvironment.maybeDetachProcess();
        // ...
        daemon.start();

        try {
            // ...
            daemonStarted(pid, daemon.getUid(), daemon.getAddress(), daemonLog);
            DaemonExpirationStrategy expirationStrategy = daemonServices.get(MasterExpirationStrategy.class);
            DaemonStopState stopState = daemon.stopOnExpiration(expirationStrategy, parameters.getPeriodicCheckIntervalMs());
            daemonProcessState.stopped(stopState);
        } finally {
            CompositeStoppable.stoppable(daemon, daemonProcessState).stop();
        }

platforms/core-runtime/daemon-server/src/main/java/org/gradle/launcher/daemon/bootstrap/DaemonMain.java 73–106행에서 줄임

순서대로 ① stdin에서 설정을 읽고, ② 표준 출력·오류를 로그 파일(daemon-<pid>.out.log)로 돌리고(initialiseLogging()), ③ 터미널에서 떨어지고(maybeDetachProcess()), ④ Daemon.start()로 소켓을 열고 레지스트리에 적은 뒤, ⑤ daemonStarted()가 원래의 표준 출력에 시작 정보(uid, 주소, pid, 로그 파일)를 쓰고 그 스트림을 닫습니다(115–125행). 그리고 ⑥ stopOnExpiration()에서 죽을 때까지 기다립니다.

클라이언트의 waitForFinish()가 기다린 것은 프로세스의 종료가 아니라 ⑤의 시작 정보입니다. DaemonExecHandleBuilder는 실행 핸들을 setDaemon(true)로 만들고(34행), 그런 핸들은 출력 처리기(DaemonOutputConsumer)가 데몬의 표준 출력에서 시작 정보를 다 읽고 나면 프로세스를 기다리지 않고 DETACHED 상태로 끝납니다(ExecHandleRunner.java 136–138행). 로그의 "now detached"가 그 뜻입니다. 이 시점에 데몬은 클라이언트와 상관없이 혼자 사는 프로세스가 되어 있고, 클라이언트가 끝나도 남습니다.

④의 Daemon.start()에서 눈여겨볼 두 가지가 있습니다. 하나는 "클라이언트가 모든 요청에 실어야 하는 인증 토큰"을 SecureRandom으로 16바이트 만드는 것이고(Daemon.java 122–127행), 다른 하나는 레지스트리에 자기를 적는 이 메서드입니다.

    public void onStart(Address connectorAddress) {
        LOGGER.info("{}{}", DaemonMessages.ADVERTISING_DAEMON, connectorAddress);
        LOGGER.debug("Advertised daemon context: {}", daemonContext);
        this.connectorAddress = connectorAddress;
        daemonRegistry.store(new DaemonInfo(connectorAddress, daemonContext, token, Busy));
    }

platforms/core-runtime/launcher/src/main/java/org/gradle/launcher/daemon/server/DaemonRegistryUpdater.java 76–81행

예제 1: 데몬 한 마리의 일생

데몬이 뜨는 첫 줄들(Starting process 'Gradle build daemon'…)은 1편 예제 1에서 이미 봤으니, 여기서는 그다음을 봅니다. 빌드 스크립트는 설정 단계와 태스크 액션에서 pid와 스레드 이름을 찍습니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)

// 설정 단계: 빌드 스크립트 본문은 데몬 프로세스 안에서 돈다
println("[configure] pid=${ProcessHandle.current().pid()} thread=${Thread.currentThread().name}")

// 빌드 스크립트를 부른 스택에서 데몬 쪽 명령 사슬(org.gradle.launcher.daemon)만 골라 찍는다
if (providers.gradleProperty("showStack").isPresent) {
    Throwable().stackTrace
        .filter { it.className.startsWith("org.gradle.launcher.daemon") }
        .forEach { println("[stack]   at ${it.className.substringAfterLast('.')}.${it.methodName}") }
}

tasks.register("hello") {
    doLast {
        // 실행 단계: 태스크 액션도 같은 데몬 프로세스에서 돈다
        println("[execute] pid=${ProcessHandle.current().pid()} thread=${Thread.currentThread().name}")
    }
}

[예제 ex/u03-daemon-lifecycle/project/build.gradle.kts]

run.sh가 첫 빌드 뒤에 프로세스와 ~/.gradle/daemon 폴더를 보고, 두 번째 빌드와 gradle --status를 돌립니다.

=== first run (--info, daemon related lines) ===
...
57:BUILD SUCCESSFUL in 19s
=== processes ===
64 org.gradle.launcher.daemon.bootstrap.GradleDaemon
...
=== ~/.gradle/daemon ===
9.10.0-20261107025045+0000
CACHEDIR.TAG
daemon-64.out.log
registry.bin
registry.bin.lock
=== second run (--info, daemon related lines) ===
3:The client will now receive all logging from the daemon (pid: 64). The daemon log file: /root/.gradle/daemon/9.10.0-20261107025045+0000/daemon-64.out.log
4:Starting 2nd build in daemon [uptime: 19.518 secs, performance: 99%, GC rate: 0.00/s, heap usage: 0% of 512 MiB, non-heap usage: 17% of 384 MiB]
17:[configure] pid=64 thread=Daemon worker
31:[execute] pid=64 thread=Execution worker
33:BUILD SUCCESSFUL in 2s
=== gradle --status ===
   PID STATUS   INFO
    64 IDLE     9.10.0-20261107025045+0000
...

(... 자리의 첫 빌드 로그, 데몬 명령줄, 두 번째 빌드의 다른 줄들, --status의 안내 문장은 줄였습니다.)

pid, 포트, 시간, 메모리 사용률은 실행마다 다릅니다.

헷갈리기 쉬운 곳

5. 데몬 안의 명령 사슬

데몬에 연결이 들어오면 DefaultIncomingConnectionHandler가 연결마다 Daemon이라는 이름의 스레드에서 명령 하나를 받고, 토큰이 레지스트리에 적은 것과 같은지 확인한 뒤 DaemonCommandExecuter에게 넘깁니다(155–161행). executeCommand()는 DaemonServices가 만들어 둔 명령 동작(DaemonCommandAction) 16개의 목록으로 DaemonCommandExecution을 하나 만들어 proceed()를 부릅니다. 목록은 1편에서 소스 그대로 봤고(DaemonServices.java 164–181행), 이 절의 그림에 순서대로 번호를 붙여 두었습니다.

데몬 안의 명령 사슬 16개. DefaultIncomingConnectionHandler가 'Daemon' 스레드에서 명령을 받아 토큰을 검사한다. ① HandleStop과 ② HandleInvalidateVirtualFileSystem, ③ HandleCancel(취소 콜백만 걸고 통과)과 ④ HandleReportStatus를 지나는데, Stop과 상태 질문은 여기서 끝난다. ⑤ CleanUpVirtualFileSystemAfterBuild와 ⑥ ReturnResult(끝나고 Result를 보낸다)를 지나 ⑦ StartBuildOrRespondWithBusy가 Idle이 아니면 DaemonUnavailable을 돌려준다. 여기서 'Daemon worker' 스레드로 넘어가고 여기부터 Busy다. ⑧ EstablishBuildEnvironment, ⑨ LogToClient, ⑩ ApplyClientEnvironmentVariables, ⑪ LogAndCheckHealth, ⑫ ForwardClientInput, ⑬ RequestStopIfSingleUsedDaemon, ⑭ ResetDeprecationLogger, ⑮ WatchForDisconnection을 지나 ⑯ ExecuteBuild가 BuildExecutor를 부른다. 빨간 상자가 문지기입니다. 그 위는 바쁜 데몬도 처리하는 명령들, 그 아래는 빌드 요청만 지나는 길입니다.

사슬을 돌리는 장치는 다섯 줄입니다.

    public boolean proceed() {
        if (actions.isEmpty()) {
            return false;
        } else {
            actions.removeFirst().execute(this);
            return true;
        }
    }

platforms/core-runtime/launcher/src/main/java/org/gradle/launcher/daemon/server/api/DaemonCommandExecution.java 100–107행

목록의 첫 동작을 꺼내 실행하고, 그 동작이 안에서 다시 proceed()를 부르면 다음 동작이 실행됩니다. 부르지 않으면 사슬은 거기서 끝납니다. Tomcat 8편의 밸브가 getNext().invoke()를 부르는 것, 서블릿 필터가 chain.doFilter()를 부르는 것과 같은 모양입니다. 동작은 세 종류로 나뉩니다.

① 자기 명령이면 처리하고 끝내는 동작. HandleStop이 전형입니다.

    @Override
    public void execute(DaemonCommandExecution execution) {
        if (execution.getCommand() instanceof Stop) {
            listenerBroadcast.onExpirationEvent(new DaemonExpirationResult(DaemonExpirationStatus.IMMEDIATE_EXPIRE, EXPIRATION_REASON));
            execution.getConnection().completed(new Success(null));
        // ... StopWhenIdle 이면 GRACEFUL_EXPIRE
        } else {
            execution.proceed();
        }
    }

platforms/core-runtime/launcher/src/main/java/org/gradle/launcher/daemon/server/api/HandleStop.java 36–48행에서 줄임

Stop이면 "즉시 만료" 사건을 알리고 성공을 답한 뒤 proceed()를 부르지 않습니다. HandleInvalidateVirtualFileSystem(IDE가 "이 파일들이 바뀌었다"고 알릴 때)과 HandleReportStatus(--status)도 같은 꼴입니다. gradle --stop과 --status가 바쁜 데몬에게도 답을 받는 것은 이 동작들이 Busy를 따지는 7번보다 앞에 있기 때문입니다.

② 다음을 감싸는 동작. ReturnResult는 먼저 proceed()로 나머지를 다 돌리고, 돌아와서 결과를 클라이언트에 보냅니다.

    @Override
    public void execute(DaemonCommandExecution execution) {
        execution.proceed();

        Result result = new Success(execution.getResult());

        LOGGER.debug("Daemon is dispatching the build result: {}", result);
        execution.getConnection().completed(result);
    }

platforms/core-runtime/launcher/src/main/java/org/gradle/launcher/daemon/server/exec/ReturnResult.java 34–42행

HandleCancel(취소 콜백 걸기/떼기), CleanUpVirtualFileSystemAfterBuild(빌드 뒤 정리), LogToClient(로그 중계 스레드 켜기/끄기), WatchForDisconnection(클라이언트가 끊기면 취소)도 try { proceed() } finally { … } 모양으로 앞뒤에 일을 합니다. EstablishBuildEnvironment와 ApplyClientEnvironmentVariables는 클라이언트가 보낸 시스템 프로퍼티·작업 폴더·환경 변수를 이 데몬 프로세스에 입혔다가 빌드가 끝나면 되돌립니다. 데몬은 여러 클라이언트를 차례로 섬기는 오래 사는 프로세스라, 빌드마다 그 클라이언트의 환경으로 갈아입어야 하기 때문입니다.

③ 문지기. 7번 StartBuildOrRespondWithBusy입니다.

    @Override
    protected void doBuild(final DaemonCommandExecution execution, final Build build) {
        DaemonStateControl stateCoordinator = execution.getDaemonStateControl();

        try {
            Runnable command = new Runnable() {
                @Override
                public void run() {
                    LOGGER.info("Daemon is about to start building {}. Dispatching build started information...", build);
                    execution.getConnection().buildStarted(new BuildStarted(diagnostics));
                    execution.proceed();
                }
            };

            stateCoordinator.runCommand(command, execution.toString());
        } catch (DaemonUnavailableException e) {
            LOGGER.info("Daemon will not handle the command {} because is unavailable: {}", build, e.getMessage());
            execution.getConnection().daemonUnavailable(new DaemonUnavailable(e.getMessage()));
        } catch (DaemonStoppedException e) {
            execution.getConnection().completed(new Failure(e));
        }
    }

platforms/core-runtime/launcher/src/main/java/org/gradle/launcher/daemon/server/exec/StartBuildOrRespondWithBusy.java 42–63행

나머지 사슬을 Runnable에 담아 DaemonStateCoordinator.runCommand()에 맡깁니다. 데몬이 Idle이 아니면 runCommand()가 DaemonUnavailableException을 던지고, 클라이언트는 DaemonUnavailable을 받아 3절대로 다른 데몬을 찾습니다. Idle이면 BuildStarted를 보내고 사슬을 이어 갑니다. runCommand()를 보면 스레드가 바뀝니다.

    @Override
    public void runCommand(final Runnable command, String commandDisplayName) throws DaemonUnavailableException {
        onStartCommand(commandDisplayName);
        try {
            executor.execute(() -> {
                try {
                    command.run();
                    onCommandSuccessful();
                } catch (Throwable t) {
                    onCommandFailed(t);
                }
            });
            waitForCommandCompletion();
        } finally {
            onFinishCommand();
        }
    }

platforms/core-runtime/launcher/src/main/java/org/gradle/launcher/daemon/server/DaemonStateCoordinator.java 315–331행

executor는 생성자에서 executorFactory.create("Daemon worker")로 만든 것입니다(74행). 8번부터 16번 ExecuteBuild까지, 그리고 그 아래의 빌드 동작(설정 단계 포함)은 Daemon worker 스레드에서 돌고, 연결을 받은 Daemon 스레드는 waitForCommandCompletion()에서 기다립니다. 예제 1에서 빌드 스크립트가 thread=Daemon worker를 찍은 이유입니다. 다만 태스크 실행은 이 스레드에서 하지 않습니다. Daemon worker는 실행 계획을 included builds·Execution worker 스레드에 맡기고 끝나기를 기다립니다(9편).

예제 1(이어서): 스택과 데몬 로그로 사슬 보기

같은 예제를 -PshowStack으로 한 번 더 돌리면 빌드 스크립트를 부른 스택에서 org.gradle.launcher.daemon 패키지의 줄만 남깁니다. 스택이므로 아래에서 위로 읽습니다.

=== stack from build script (third run) ===
[configure] pid=64 thread=Daemon worker
[stack]   at ExecuteBuild.doBuild
[stack]   at BuildCommandOnly.execute
[stack]   at DaemonCommandExecution.proceed
[stack]   at WatchForDisconnection.execute
[stack]   at DaemonCommandExecution.proceed
[stack]   at ResetDeprecationLogger.execute
[stack]   at DaemonCommandExecution.proceed
[stack]   at RequestStopIfSingleUsedDaemon.execute
[stack]   at DaemonCommandExecution.proceed
[stack]   at ForwardClientInput.lambda$execute$0
[stack]   at ForwardClientInput.execute
[stack]   at DaemonCommandExecution.proceed
[stack]   at LogAndCheckHealth.execute
[stack]   at DaemonCommandExecution.proceed
[stack]   at ApplyClientEnvironmentVariables.doBuild
[stack]   at BuildCommandOnly.execute
[stack]   at DaemonCommandExecution.proceed
[stack]   at LogToClient.doBuild
[stack]   at BuildCommandOnly.execute
[stack]   at DaemonCommandExecution.proceed
[stack]   at EstablishBuildEnvironment.doBuild
[stack]   at BuildCommandOnly.execute
[stack]   at DaemonCommandExecution.proceed
[stack]   at StartBuildOrRespondWithBusy$1.run
[stack]   at DaemonStateCoordinator.lambda$runCommand$0
[execute] pid=64 thread=Execution worker

DaemonStateCoordinator.runCommand의 람다 → StartBuildOrRespondWithBusy$1.run(위 코드의 익명 Runnable)에서 시작해, 8번부터 16번까지가 proceed()를 사이에 두고 차곡차곡 쌓여 있습니다. 순서가 DaemonServices의 목록과 정확히 같습니다. 1–7번은 보이지 않습니다. 그것들은 Daemon 스레드의 스택에 있고, 이 줄들은 Daemon worker 스레드의 스택이기 때문입니다. BuildCommandOnly.execute가 자주 끼는 것은 빌드 명령 전용 동작들의 부모 클래스가 "Build가 아니면 예외"를 검사한 뒤 doBuild()를 부르기 때문입니다.

데몬 로그에서는 명령 하나가 사슬을 지나는 흔적이 보입니다(run.sh가 관련 줄만 고르고 앞의 시각을 지웠습니다. 긴 줄은 200자에서 잘렸습니다).

=== daemon log: the command chain at work ===
12:[INFO] [org.gradle.launcher.daemon.server.DaemonRegistryUpdater] Advertising the daemon address to the clients: [c6fba755-7199-428f-ad6d-e7b74bf2ab1b port:34051, addresses:[localhost/127.0.0.1]]
18:[LIFECYCLE] [org.gradle.launcher.daemon.server.Daemon] Daemon server started.
25:[INFO] [org.gradle.launcher.daemon.server.DefaultIncomingConnectionHandler] Received command: Build{id=220e8a8d-e587-45b0-9589-8aab32b1f8fc, currentDir=/tmp/p}.
27:[DEBUG] [org.gradle.launcher.daemon.server.DaemonStateCoordinator] Command execution: started DaemonCommandExecution[command = Build{id=220e8a8d-e587-45b0-9589-8aab32b1f8fc, currentDir=/tmp/p}, con
35:[INFO] [org.gradle.launcher.daemon.server.exec.StartBuildOrRespondWithBusy] Daemon is about to start building Build{id=220e8a8d-e587-45b0-9589-8aab32b1f8fc, currentDir=/tmp/p}. Dispatching build st
41:[DEBUG] [org.gradle.launcher.daemon.server.exec.ExecuteBuild] The daemon has started executing the build.
87:[DEBUG] [org.gradle.launcher.daemon.server.exec.ExecuteBuild] The daemon has finished executing the build.
90:[DEBUG] [org.gradle.launcher.daemon.server.DaemonStateCoordinator] Command execution: completed DaemonCommandExecution[command = Build{id=220e8a8d-e587-45b0-9589-8aab32b1f8fc, currentDir=/tmp/p}, c
98:[DEBUG] [org.gradle.launcher.daemon.server.exec.ReturnResult] Daemon is dispatching the build result: Success[value=org.gradle.launcher.exec.BuildActionResult@7c68ba56]
106:[DEBUG] [org.gradle.launcher.daemon.server.DefaultIncomingConnectionHandler] Received finished message: org.gradle.launcher.daemon.protocol.Finished@1aa8bf77
...
197:[INFO] [org.gradle.launcher.daemon.server.DefaultIncomingConnectionHandler] Received command: ReportStatus[id=366c51fb-8713-44b2-a1cf-7092307fe9f6].
203:[DEBUG] [org.gradle.launcher.daemon.server.DefaultIncomingConnectionHandler] Received finished message: org.gradle.launcher.daemon.protocol.Finished@4f3afa6

(... 자리에는 두 번째 빌드의 같은 줄들이 있어 줄였습니다. 주소의 uid·포트, Build{id=…}, 객체 해시 @…는 실행마다 다릅니다.) 레지스트리에 주소를 알리고(Advertising…) → 명령을 받고(Received command: Build) → 상태가 Busy가 되고(Command execution: started) → 문지기를 지나 → ExecuteBuild가 빌드를 하고 → 상태가 Idle로 돌아온 뒤에 → ReturnResult가 결과를 보내고 → 클라이언트의 Finished를 받습니다. completed가 dispatching the build result보다 먼저인 것은 ReturnResult(6번)가 StartBuildOrRespondWithBusy(7번)를 감싸고 있기 때문입니다. gradle --status가 보낸 ReportStatus는 Busy 표시 없이 바로 Finished로 끝났습니다(4번에서 끝났으니까요).

이 대화의 규칙은 DaemonClient 클래스 설명에 적혀 있습니다. 클라이언트가 Build를 정확히 하나 보내면, 데몬은 BuildStarted·Failure·DaemonUnavailable 가운데 정확히 하나로 답하고, 빌드가 시작됐으면 로그(OutputMessage)와 빌드 사건(BuildEvent)을 몇 개든 보낸 뒤 Result 하나로 끝냅니다. 클라이언트는 Finished를 보내고 연결을 닫습니다(DaemonClient.java 64–80행). HTTP처럼 요청 하나에 응답 하나가 아니라, 요청 하나에 "시작했다 → 중계 방송 → 결과"가 한 연결 위로 흐르는 것이 특징입니다.

헷갈리기 쉬운 곳

6. Idle과 Busy, 그리고 데몬이 죽는 때

DaemonStateCoordinator가 쥔 상태는 Idle, Busy, Canceled, StopRequested, Stopped, ForceStopped, Broken 일곱 가지입니다(DaemonState.java 19행). 빌드를 받을 수 있는 것은 Idle일 때뿐입니다. onStartCommand()가 그것을 지킵니다.

            switch (state) {
                // ... Broken, StopRequested, Stopped, ForceStopped 도 DaemonUnavailableException
                case Busy:
                case Canceled:
                    throw new DaemonUnavailableException(String.format("This daemon is currently executing: %s", currentCommandExecution));
                case Idle:
                    break;
            }

            LOGGER.debug("Command execution: started {} after {} minutes of idle", commandDisplayName, getIdleMinutes());
            try {
                setState(DaemonState.Busy);
                onStartCommand.run();

platforms/core-runtime/launcher/src/main/java/org/gradle/launcher/daemon/server/DaemonStateCoordinator.java 387–405행에서 줄임

데몬은 한 번에 빌드 하나만 합니다. Busy인데 또 빌드가 오면 예외로 거절합니다. onStartCommand.run()은 Daemon.start()가 넘긴 콜백으로, 레지스트리의 상태를 Busy로 고칩니다(DaemonRegistryUpdater.onStartActivity()). 빌드가 끝나면 onFinishCommand()가 레지스트리를 Idle로 고치고 놀기 시작한 시각(idleTimer)을 다시 잽니다. 레지스트리 갱신이 실패하면 Broken이 됩니다(411–413행).

데몬 상태 전이. 생성되면 Idle이고, onStartCommand로 빌드가 시작되면 Busy, onFinishCommand로 끝나면 다시 Idle이다. Busy에서 requestCancel(Ctrl+C나 연결 끊김)이면 Canceled가 되고, 빌드가 끝나면 Idle로 돌아온다. Busy에서 requestStop(만료나 단일 사용 데몬)이면 StopRequested가 되고 빌드가 끝나면 Stopped다. Idle에서 requestStop이나 stopNow(만료, --stop)면 바로 Stopped다. Busy에서 stopNow(빌드 도중 --stop)면 ForceStopped다. 레지스트리 갱신에 실패하면 Broken이다. 초록이 빌드를 받을 수 있는 유일한 상태입니다. 빨강은 비정상 종료 쪽입니다.

requestStop()은 Busy일 때는 StopRequested로 두었다가 빌드가 끝나면 멈추고, Idle일 때는 바로 stopNow()로 멈춥니다(131–146행). 단일 사용 데몬이 빌드 시작 때 requestStop("")을 부르는 것(5절의 13번)이 바로 첫째 경우입니다.

데몬이 스스로 죽는 조건 — MasterExpirationStrategy

DaemonMain의 마지막에서 본 stopOnExpiration()은 기본 10초마다(DEFAULT_PERIODIC_CHECK_INTERVAL_MILLIS, org.gradle.daemon.healthcheckinterval로 바꿀 수 있습니다) MasterExpirationStrategy.checkExpiration()을 부릅니다. 이 전략은 여섯 가지 전략을 "하나라도 해당하면"(AnyDaemonExpirationStrategy)으로 묶은 것이고, 소스의 주석이 하나하나에 이유를 달아 두었습니다(MasterExpirationStrategy.java 43–65행).

조건 뜻
건강 힙이나 GC 압박이 심하다(HealthExpirationStrategy)
파일 잠금 다른 빌드와 잠금 소유를 조정하는 장치(FileLockContentionHandler)가 멈췄다 — 즉시 만료
중복 호환되는 Idle 데몬이 나 말고도 있고, 내가 10초(DUPLICATE_DAEMON_GRACE_PERIOD_MS, 36행) 넘게 놀았고, 가장 최근에 쓰인 데몬이 아니다 — 셋 다
놀 시간 기본 3시간 넘게 놀았다(org.gradle.daemon.idletimeout)
메모리 부족 기계의 남은 메모리가 적고, 10초 넘게 놀았고, 최근에 쓰인 데몬이 아니다
레지스트리 레지스트리 파일에 닿을 수 없다

놀 시간 검사(DaemonIdleTimeoutExpirationStrategy.checkExpiration(), 44–52행)는 getStateCoordinator().getIdleMillis()를 기준값과 비교할 뿐입니다. getIdleMillis()는 Idle일 때만 경과 시간을 돌려주고 Busy이면 0입니다(469–475행). 그러니 몇 시간짜리 빌드를 하는 중에 놀 시간 때문에 죽는 일은 없습니다.

예제 4: 바쁜 데몬, 두 번째 클라이언트, 그리고 중복 데몬의 퇴장

20초 동안 잠자는 slow 태스크와 바로 끝나는 hello 태스크를 준비했습니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)

// 오래 걸리는 태스크: 이 빌드가 도는 동안 데몬은 Busy 다
tasks.register("slow") {
    doLast {
        println("[slow] start in pid=${ProcessHandle.current().pid()}")
        Thread.sleep(20_000)
        println("[slow] done")
    }
}

tasks.register("hello") {
    doLast { println("[hello] pid=${ProcessHandle.current().pid()}") }
}

[예제 ex/u03-busy-daemon/project/build.gradle.kts]

run.sh는 데몬을 하나 데운 뒤 클라이언트 A(slow)를 백그라운드로 돌리고, A가 도는 동안 --status, jstack, 클라이언트 B(hello)를 차례로 돌립니다. 마지막에 35초를 기다렸다가 --status를 한 번 더 봅니다.

=== warm up: one daemon ===
[hello] pid=50
=== client A: 'slow' in the background ===
[slow] start in pid=50
=== gradle --status while A is running ===
   PID STATUS   INFO
    50 BUSY     9.10.0-20261107025045+0000
=== daemon threads while busy (jstack) ===
      1 "Asynchronous log dispatcher for DefaultDaemonConnection: socket connection from ..."
      1 "Daemon"
      ...
      1 "Daemon periodic checks"
      1 "Daemon Thread 2"
      1 "Daemon worker"
      1 "Execution worker"
      ...
=== client B: 'hello' while A is busy ===
4:Starting a Gradle Daemon, 1 busy Daemon could not be reused, use --status for details
8:The client will now receive all logging from the daemon (pid: 388). The daemon log file: /root/.gradle/daemon/9.10.0-20261107025045+0000/daemon-388.out.log
39:[hello] pid=388
41:BUILD SUCCESSFUL in 8s
[slow] start in pid=50
[slow] done
=== gradle --status after both builds ===
   PID STATUS   INFO
    50 IDLE     9.10.0-20261107025045+0000
   388 IDLE     9.10.0-20261107025045+0000
=== wait 35 s: duplicate idle daemons expire ===
   PID STATUS   INFO
   388 IDLE     9.10.0-20261107025045+0000
    50 STOPPED  (other compatible daemons were started and after being idle for 0 minutes and not recently used)

(Daemon client event forwarder, Daemon health stats 두 줄과 Execution worker Thread 2부터 Thread 11까지 열 줄은 줄였습니다.)

pid와 시간은 실행마다 다릅니다.

헷갈리기 쉬운 곳

7. 래퍼 — gradlew는 Gradle을 내려받아 같은 JVM에서 부른다

실무에서는 gradle보다 ./gradlew를 더 많이 칩니다. 래퍼의 할 일은 하나입니다. 저장소가 정한 판의 Gradle을 준비해서, 1절의 GradleMain을 부르는 것.

래퍼의 흐름. ./gradlew 셸 스크립트가 java -jar gradle/wrapper/gradle-wrapper.jar를 실행한다. GradleWrapperMain.main이 gradle.properties의 systemProp을 반영하고, WrapperExecutor가 gradle-wrapper.properties를 읽어 Install.createDist를 부른다. 이미 풀려 있지 않으면 내려받고 sha256을 확인하고 풀고 .ok를 만들고 zip을 지운다. 결과는 ~/.gradle/wrapper/dists/gradle-9.10.0-bin/URL해시/gradle-9.10.0이다. BootstrapMainStarter가 그 안의 lib/gradle-launcher-*.jar로 클래스로더를 만들고 같은 JVM 안에서 org.gradle.launcher.GradleMain.main을 부른다. 래퍼는 새 프로세스를 띄우지 않습니다. 래퍼의 JVM이 그대로 1절의 클라이언트가 됩니다.

입구는 GradleWrapperMain.main()이고, 하는 일은 prepareWrapper(args, wrapperJar()).execute() 한 줄입니다(GradleWrapperMain.java 40–43행). prepareWrapper()는 자기 jar의 위치에서 gradle-wrapper.properties(같은 폴더, 이름만 바꾼 것)와 프로젝트 루트(세 단계 위)를 알아내고, 프로젝트·사용자 gradle.properties의 systemProp.*을 시스템 프로퍼티로 올린 뒤, WrapperExecutor·Install·BootstrapMainStarter를 엮은 동작을 돌려줍니다(76–85행). WrapperExecutor.execute()는 두 줄입니다. 설치하고, 시작합니다.

    public void execute(String[] args, Install install, BootstrapMainStarter bootstrapMainStarter) throws Exception {
        File gradleHome = install.createDist(config);
        bootstrapMainStarter.start(args, gradleHome);
    }

platforms/core-runtime/wrapper-shared/src/main/java/org/gradle/wrapper/WrapperExecutor.java 112–115행

Install.createDist — 한 번만 내려받기

        return exclusiveFileAccessManager.access(localZipFile, () -> {
            final File markerFile = new File(localZipFile.getParentFile(), localZipFile.getName() + ".ok");
            if (distDir.isDirectory() && markerFile.isFile()) {
                InstallCheck installCheck = verifyDistributionRoot(distDir, distDir.getAbsolutePath());
                if (installCheck.isVerified()) {
                    return requireNonNull(installCheck.gradleHome);
                }
                // ...
            }

            fetchDistribution(localZipFile, distributionUrl, distDir, configuration);

            InstallCheck installCheck = verifyDistributionRoot(distDir, safeUriDisplay(distributionUrl));
            if (installCheck.isVerified()) {
                setExecutablePermissions(requireNonNull(installCheck.gradleHome));
                markerFile.createNewFile();
                localZipFile.delete();
                return requireNonNull(installCheck.gradleHome);
            }
            // ...
        });

platforms/core-runtime/wrapper-shared/src/main/java/org/gradle/wrapper/Install.java 75–98행에서 줄임

설치 위치는 <배포판 이름>/<URL 해시>이고, 해시는 URL 문자열의 MD5를 36진수로 쓴 것입니다(PathAssembler.rootDirName()·getHash(), 50–75행). 같은 gradle-9.10.0-bin.zip이라도 받아 온 주소가 다르면 다른 폴더가 됩니다. 회사 미러와 공식 주소를 섞어 쓰면 같은 판이 두 번 설치되는 이유입니다.

마지막으로 BootstrapMainStarter가 풀린 배포판의 런처 jar로 클래스로더를 만들어 GradleMain.main()을 리플렉션으로 직접 부릅니다.

    public void start(String[] args, File gradleHome) throws Exception {
        File gradleJar = findLauncherJar(gradleHome);
        // ...
        // The URLClassloader will also include the jars listed in the launcher jar's
        // Class-Path manifest attributes as candidates for loading classes.
        URLClassLoader contextClassLoader = new URLClassLoader(new URL[]{gradleJar.toURI().toURL()}, ClassLoader.getSystemClassLoader().getParent());
        Thread.currentThread().setContextClassLoader(contextClassLoader);
        Class<?> mainClass = contextClassLoader.loadClass("org.gradle.launcher.GradleMain");
        Method mainMethod = mainClass.getMethod("main", String[].class);
        mainMethod.invoke(null, new Object[]{args});
        ((Closeable) contextClassLoader).close();
    }

platforms/core-runtime/wrapper-shared/src/main/java/org/gradle/wrapper/BootstrapMainStarter.java 27–40행에서 줄임

주석대로 gradle-launcher jar의 매니페스트 Class-Path(1절에서 본 gradle-gradle-cli-main-9.10.0.jar)를 따라 GradleMain이 실립니다. 1절의 헷갈리기 쉬운 곳에서 "gradle-launcher jar는 래퍼가 찾는다"고 한 것이 이것입니다. 래퍼 JVM도 셸 스크립트가 -Xmx64m으로 띄우니, 그대로 64MiB짜리 클라이언트가 됩니다.

예제 5: 네트워크 없이 래퍼 돌려 보기

래퍼를 오프라인으로 시험하려고, 컨테이너의 /opt/gradle을 zip으로 묶어 file: 주소를 배포판 URL로 줬습니다(ex/u03-wrapper/run.sh). gradle wrapper --gradle-distribution-url file:///tmp/dist/gradle-9.10.0-bin.zip이 래퍼 파일을 만들고, 그다음 ./gradlew hello를 세 번 칩니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)

tasks.register("hello") {
    doLast {
        // 이 빌드를 실행하는 Gradle 이 어디에 설치된 것인지 찍는다
        println("[hello] gradleHome=${gradle.gradleHomeDir} pid=${ProcessHandle.current().pid()}")
    }
}

[예제 ex/u03-wrapper/project/build.gradle.kts]

--- gradle/wrapper/gradle-wrapper.properties ---
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=file\:///tmp/dist/gradle-9.10.0-bin.zip
...
--- how gradlew starts java ---
203:DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
213:        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
gradle-wrapper.jar: Main-Class: org.gradle.wrapper.GradleWrapperMain
=== ./gradlew hello (first) ===
Fetching distribution.
Downloading file:/tmp/dist/gradle-9.10.0-bin.zip
............10%.............20%.............30%.............40%.............50%.............60%.............70%............80%.............90%.............100%
...
> Task :hello
[hello] gradleHome=/opt/gradle pid=79
...
--- ~/.gradle/wrapper/dists ---
/root/.gradle/wrapper/dists
/root/.gradle/wrapper/dists/CACHEDIR.TAG
/root/.gradle/wrapper/dists/gradle-9.10.0-bin
/root/.gradle/wrapper/dists/gradle-9.10.0-bin/2zbgt1zpjqnhoq9ssyran7l9u
/root/.gradle/wrapper/dists/gradle-9.10.0-bin/2zbgt1zpjqnhoq9ssyran7l9u/gradle-9.10.0
/root/.gradle/wrapper/dists/gradle-9.10.0-bin/2zbgt1zpjqnhoq9ssyran7l9u/gradle-9.10.0-bin.zip.lck
/root/.gradle/wrapper/dists/gradle-9.10.0-bin/2zbgt1zpjqnhoq9ssyran7l9u/gradle-9.10.0-bin.zip.ok
=== ./gradlew hello (second) ===
...
[hello] gradleHome=/opt/gradle pid=79
...
=== gradle --status ===
   PID STATUS   INFO
    79 IDLE     9.10.0-20261107025045+0000
=== gradle --stop, then ./gradlew hello (third) ===
[hello] gradleHome=/root/.gradle/wrapper/dists/gradle-9.10.0-bin/2zbgt1zpjqnhoq9ssyran7l9u/gradle-9.10.0 pid=487

(... 자리는 gradle-wrapper.properties의 나머지 여섯 줄(networkTimeout, retries 등), 환영 문장, BUILD SUCCESSFUL 뒤의 안내 줄들입니다.)

pid는 실행마다 다릅니다.

헷갈리기 쉬운 곳

8. Tooling API — IDE도 같은 데몬에 붙는다

IntelliJ IDEA에서 Gradle 프로젝트를 열거나 "Reload"를 누르면 IDE는 gradle 명령을 치지 않고 Tooling API 라이브러리(gradle-tooling-api.jar)를 씁니다. 이 길이 어디로 이어지는지만 짧게 봅니다. IDE에서 직접 확인한 것은 아니고, 소스를 따라간 결과입니다.

IDE 쪽 라이브러리는 얇습니다. DefaultToolingImplementationLoader가 프로젝트가 쓰는 Gradle 배포판의 jar들로 클래스로더를 따로 만들고(createImplementationClassLoader(), 111–119행), 그 안에서 실제 구현을 찾습니다. IDE와 배포판 사이에 공유하는 것은 org.gradle.tooling.internal.protocol 패키지(인터페이스 모음)와 JavaVersion 클래스 하나뿐입니다(115–116행). 구현 클래스는 META-INF/services/org.gradle.tooling.internal.protocol.ConnectionVersion4 파일로 찾고(77행의 findFactory), 그 파일에 적힌 이름이 org.gradle.tooling.internal.provider.DefaultConnection입니다. 이렇게 해 두면 IDE에 든 Tooling API 판과 프로젝트의 Gradle 판이 달라도 프로젝트의 Gradle이 자기 판의 구현을 내놓습니다.

DefaultConnection은 ProviderConnection에게 일을 넘기고, ProviderConnection.createExecutor()에서 길이 갈립니다(320–338행). "embedded"(같은 프로세스 안에서 돌리기 — 주로 Gradle 자신의 테스트용)면 2절의 RunInProcess를, 아니면 daemonClientFactory.createBuildClientServices(...)로 만든 3절의 그 DaemonClient 를 씁니다. 그러니 IDE가 띄운 데몬과 터미널의 ./gradlew가 띄운 데몬은 같은 레지스트리에 적히고, 옵션이 같으면 서로의 데몬을 재사용합니다.

Tooling API의 길. IDE 프로세스에서 GradleConnector와 ProjectConnection(gradle-tooling-api.jar)이 DefaultToolingImplementationLoader로 Gradle 배포판의 jar로 클래스로더를 만들고, ServiceLoader로 배포판 쪽 구현 DefaultConnection을 찾는다. DefaultConnection은 ProviderConnection으로 이어지고, embedded가 아니면 DaemonClient를 써서 데몬 프로세스에 Build 명령을 보낸다. 터미널의 gradle·./gradlew도 DaemonClient로 같은 데몬 프로세스의 같은 명령 사슬(HandleStop … ExecuteBuild)에 Build 명령을 보낸다. IDE 안에서 도는 DefaultConnection·ProviderConnection은 IDE의 jar가 아니라 프로젝트가 쓰는 Gradle 배포판에서 온 코드입니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
bin/gradle java -Xmx64m -jar gradle-gradle-cli-main.jar. 클라이언트는 빌드를 하지 않는다
GradleMain → ProcessBootstrap 필요한 모듈만 담은 클래스로더를 지어 Main을 부른다. 데몬도 같은 방식으로 시작
BuildActionsFactory 데몬(기본) / 이 프로세스(--no-daemon이고 JVM이 맞을 때) / 단일 사용 데몬, 세 갈래
--no-daemon 64MiB 클라이언트로는 빌드 못 하니 보통 단일 사용 데몬을 띄운다. 끝나면 남지 않을 뿐
registry.bin 판마다 하나. 데몬의 주소·토큰·상태(Idle/Busy…)를 적는다
호환성 같은 java 파일, 같은 JVM 옵션, 우선순위, 에이전트, 네이티브 서비스. 판과 설치 위치는 보지 않는다
org.gradle.jvmargs 기본 데몬 옵션을 바꿔치기한다. 다르면 다른 데몬
데몬 기동 설정은 stdin으로 받고, 시작 정보를 stdout으로 내주면 클라이언트는 기다림을 끝낸다("detached"). 처음 등록은 Busy
명령 사슬 16개 동작을 proceed()로 잇는다. Stop·상태는 앞에서 끝나고, 7번이 Busy로 바꾼 뒤 8번부터 Daemon worker 스레드
대화 규칙 Build 하나 → BuildStarted → 로그들 → Result → Finished
상태 Idle일 때만 빌드를 받는다. 한 데몬은 한 번에 빌드 하나
만료 기본 10초마다 점검. 기본 3시간 놀았거나, 호환되는 다른 Idle 데몬이 있고 10초 넘게 놀았고 최근 것이 아니면 퇴장
래퍼 배포판을 ~/.gradle/wrapper/dists/<이름>/<URL 해시>/에 한 번 풀고 같은 JVM에서 GradleMain을 부른다
Tooling API IDE 안에 배포판의 구현을 싣고, 결국 같은 DaemonClient로 같은 데몬에 붙는다

다음에 볼 것

5절에서 DaemonServices의 @Provides 메서드들이 명령 사슬을 만들고, 그 인자들을 누군가 채워 준다는 것을 봤습니다. 그 "누군가"가 Gradle 안의 DI 컨테이너, 서비스 레지스트리입니다. 데몬 프로세스·사용자 홈·빌드 세션·빌드·프로젝트마다 레지스트리가 하나씩 겹겹이 있고, 태스크의 @Inject 생성자도 이 레지스트리에서 서비스를 받습니다. 4편: 서비스 레지스트리 — Gradle 안의 DI 컨테이너에서 이어집니다. ExecuteBuild 아래에서 빌드가 실제로 시작되는 길은 5편: 빌드 생명주기에서 봅니다.

앞 연재에서 닮은 것을 다뤘습니다. 데몬의 명령 사슬은 Tomcat 8편의 밸브 사슬과 생김새가 같습니다. 밸브가 getNext().invoke()를 부르듯 동작이 proceed()를 부르고, 부르지 않으면 거기서 끝나며, ReturnResult처럼 "다음을 먼저 부르고 돌아와서 일하는" 동작은 Tomcat의 ErrorReportValve와 같은 자리입니다. 오래 사는 프로세스가 요청을 계속 받는다는 점에서 데몬은 서버 프로그램이지만, httpd 8편의 MPM이 프로세스·스레드 여럿으로 요청을 동시에 받는 것과 달리 Gradle 데몬은 한 번에 하나만 받고, 바쁘면 클라이언트가 데몬을 하나 더 띄웁니다. 데몬 수를 늘리고 줄이는 결정을 서버가 아니라 클라이언트와 만료 전략이 나눠 맡는 셈입니다. 프로세스를 띄우고 관리하는 다른 방식은 nginx 연재에서 다룹니다.

다른 글