본문 바로가기

Java 25와 Spring Boot

Gradle 도구 체인과 실행 출력으로 Java 25·Spring Boot 4.1.1·Spring 프레임워크 7의 기준선을 검증하고 게시판 프로젝트를 시작합니다.

Spring은 Java 객체를 만들고 연결하며 웹 요청을 처리하는 데 필요한 기반을 제공하는 프레임워크입니다.

Spring Boot는 Spring을 없애는 별도 기술이 아니라, 자주 쓰는 의존성과 설정을 합리적인 기본값으로 조립해 첫 실행을 쉽게 만드는 도구입니다.

이 관계를 먼저 기억하면 Spring과 Spring Boot라는 이름을 섞지 않게 됩니다.

첫 실행의 목표는 화려한 화면이 아니라 누가 받아도 같은 Java 기능 수준과 프레임워크 의존성 기준으로 빌드되는 프로젝트입니다.

JDK는 Java 코드를 컴파일하고 실행하는 도구 묶음이고, Gradle은 필요한 라이브러리를 내려받아 컴파일·실행 명령을 반복해 주는 빌드 도구입니다.

IDE가 표시하는 JDK, 터미널의 java, Gradle Wrapper를 시작하는 launcher JVM, 빌드를 실행하는 daemon JVM, task가 선택한 toolchain JDK는 서로 다를 수 있습니다.

이 역할을 구분하지 않으면 invalid source release 같은 오류를 엉뚱한 Java 설정에서 찾게 됩니다.

이 자습서의 누적 예제는 게시글을 등록하고 조회하는 회원가입·게시판 웹 애플리케이션입니다.

이번 문서에서는 아직 도메인 기능을 만들지 않습니다.

대신 앞으로 추가할 모든 코드가 공유할 실행 계약을 먼저 고정합니다.

처음에는 “프로젝트 버전 선택 → 최소 애플리케이션 실행”을 따라 설정 파일과 시작 로그부터 확인하세요. 페이지 응답은 다음 절에서 정적 파일을 추가한 뒤 확인합니다.

다음 절의 JSON 예제가 사용하는 RuntimeVersions.java는 먼저 작성해야 합니다. 뒤의 RuntimeVersionsTest와 도구 체인 진단은 버전 값이 기대와 다르거나 빌드가 막힐 때 돌아올 확인 경로로 읽어도 됩니다. 값 객체 작성과 진단 테스트의 역할을 구분하며 진행하세요.

SPRING · JAVA EXECUTION EVIDENCE

선언한 버전은 실제 선택과 실행 증거로 확인한다

Gradle 자체의 JVM, 작업의 JDK, 테스트 프로세스는 각각 확인합니다. 앞 단계부터 대조해 처음 기대와 달라진 경계를 고친 뒤 다시 실행합니다.

선언과실제프로세스의버전확인 실선은 Gradle이 작업을 시작하고 작업별 JDK로 테스트 또는 bootRun 애플리케이션을 실행하는 관계이다. test와 bootRun은 별도 명령의 분기이며 둘을 연속 실행하라는 뜻이 아니다. 점선은 build.gradle의 Java toolchain 조건이다. 셸 java와 발견한JDK 후보는 독립된 단서라 특정작업선택을증명하지 않는다. compiler와testlauncher와bootRunlauncher를각각확인하고각실행프로세스에서버전을따로읽는다. test 셸 Java java --version Gradle 선택 증거 아님 JDK 후보 javaToolchains 작업 선택 증거 아님 build.gradle toolchain: Java 25 Boot 4.1.1 BOM Gradle JVM Launcher · Daemon 작업별 JDK 선택 compiler / launcher 실제 실행 경로 확인 테스트 worker RuntimeVersionsTest Java · classpath 버전 애플리케이션 JVM bootRun · VersionReport 시작 경로에서 재관찰
작업별선택과두실행프로세스 실선은 Gradle이 작업을 시작하고 작업별 JDK로 테스트 또는 bootRun 애플리케이션을 실행하는 관계이다. test와 bootRun은 별도 명령의 분기이며 둘을 연속 실행하라는 뜻이 아니다. 점선은 build.gradle의 Java toolchain 조건이다. 셸 java와 발견한JDK 후보는 독립된 단서라 특정작업선택을증명하지 않는다. compiler와testlauncher와bootRunlauncher를각각확인하고각실행프로세스에서버전을따로읽는다. 작업 조건 test bootRun 셸 Java java --version 독립된 실행 단서 build.gradle toolchain: Java 25 Boot 4.1.1 BOM JDK 후보 javaToolchains 선택 증거와 구분 Gradle JVM Launcher · Daemon 작업별 JDK 선택 compiler / launcher 실제 실행 경로 확인 테스트 worker RuntimeVersionsTest Java · classpath 버전 애플리케이션 JVM bootRun · VersionReport 시작 경로에서 재관찰

선언과 실행 후보

  1. 빌드 기준

    build.gradle에서 Java 기능 수준 25와 Boot 4.1.1 BOM을 선언합니다. JDK vendor나 patch까지 고정한 선언은 아닙니다.

  2. Gradle의 JVM

    ./gradlew --version에서 Wrapper의 Launcher JVM과 빌드를 수행하는 Daemon JVM을 구분합니다.

  3. Toolchain 후보

    ./gradlew javaToolchains는 발견한 JDK 목록입니다. 특정 작업이 선택한 실행 파일의 증거는 아닙니다.

실제 선택과 실행

  1. 작업의 실제 JDK

    ./gradlew clean test --info의 compile/test 실행 경로를 확인합니다. compiler와 test launcher는 구분합니다.

  2. 테스트 프로세스

    RuntimeVersionsTest가 실제 Java 25와 테스트 classpath의 Boot 4.1.1·Spring 7.0.x metadata를 읽습니다. metadata가 없으면 unknown입니다.

  3. 첫 불일치 경계

    선언 → Gradle JVM → 후보 → 작업 선택 → 테스트 프로세스를 대조합니다. 다른 경계부터 수정하고 같은 검사를 반복합니다.

셸 Java 확인의 범위

java --version은 셸 기본 Java의 단서입니다. Gradle JVM과 작업 compiler를 확정하지 않습니다.

애플리케이션 실행은 별도 관찰

테스트 프로세스의 버전이 실제 bootRun 애플리케이션 JVM까지 증명하지 않습니다. 연습의 VersionReport처럼 애플리케이션 시작 경로에서 다시 읽습니다.

Java toolchain 기능 수준과 vendor·patch는 다른 조건입니다. Wrapper 호환 범위와 JDK 설치를 먼저 확인한 뒤 실제 선택과 실행을 관찰합니다.


프로젝트 버전 선택

먼저 Spring Initializr에서 Gradle-Groovy, Java, Spring Boot 4.1.1, Java 25를 선택하고 Spring Web MVC와 Thymeleaf 의존성을 추가해 프로젝트를 내려받습니다.

압축을 풀면 gradlew, gradlew.bat, gradle/wrapper가 함께 들어 있습니다.

이 파일들이 Gradle Wrapper이며 별도의 전역 Gradle 설치 없이 프로젝트가 정한 Gradle 버전을 실행하게 합니다.

이 교재는 Initializr가 생성한 Wrapper를 기준으로 합니다. 빈 디렉터리에서 Wrapper를 직접 만드는 경로는 이 절의 범위가 아니며, 임의의 전역 Gradle로 생성하면 안 됩니다. Spring Boot 4.1.1은 Gradle 8.14 이상 또는 9.x를 요구하므로 수동으로 시작할 때도 먼저 호환 버전을 선택해야 합니다.

Boot가 요구하는 최소 Gradle 버전과 이 교재의 Java 25 조합은 별도로 확인해야 합니다. Gradle Java 호환 표는 Java 25의 toolchain 사용과 Gradle 자체 실행을 모두 9.1.0부터 공식 지원합니다. 따라서 이 교재의 Wrapper는 9.1 이상인 9.x인지 gradle/wrapper/gradle-wrapper.properties와 ./gradlew --version으로 확인합니다. Boot의 최소 조건인 8.14만 보고 Java 25 조합도 공식 지원된다고 판단하지 않습니다.

Wrapper 파일이 생기기 전에는 ./gradlew 명령을 사용할 수 없습니다.

이 교재는 가장 단순한 Initializr 경로를 기준으로, 내려받은 프로젝트의 다음 두 파일을 확인하고 수정합니다.

settings.gradle은 프로젝트의 논리적 이름을 정하고, build.gradle은 플러그인·의존성·Java 도구 체인을 정합니다.

settings.gradle
rootProject.name = 'board'
build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.1'
}

group = 'dev.andongmin'
version = '0.1.0'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation platform(
            'org.springframework.boot:spring-boot-dependencies:4.1.1')
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

Spring Boot 4.1.1은 Java 17 이상에서 실행할 수 있지만 이 교재는 Java 25를 언어 기준으로 사용합니다.

sourceCompatibility = 25만 적는 방식과 달리 도구 체인은 Gradle이 컴파일에 사용할 JDK 자체를 선택합니다.

다만 languageVersion = 25는 Java 기능 수준을 고정할 뿐 JDK vendor, patch 버전, 배포 바이너리까지 동일하게 고정하지는 않습니다.

로컬에 조건을 만족하는 JDK가 없고 자동 다운로드 저장소도 설정하지 않았다면 빌드가 즉시 실패합니다.

이 실패가 예전 JDK로 조용히 컴파일하는 것보다 안전합니다.

Boot 4.1.1에서는 spring-boot-starter-web도 사용할 수 있지만 spring-boot-starter-webmvc가 MVC와 Tomcat을 선택한다는 뜻을 더 직접 드러냅니다.

테스트용 MVC 모듈도 별도 스타터로 분리되어 있으므로 spring-boot-starter-webmvc-test를 둡니다.

이 test starter는 공통 spring-boot-starter-test를 전이 의존성으로 포함하므로 둘을 중복 선언하지 않습니다.

Boot 플러그인은 실행 JAR 작업을 추가하지만 의존성 버전을 단독으로 관리하지는 않습니다.

여기서는 네이티브 Gradle BOM 플랫폼을 사용해 각 Spring 라이브러리 버전을 따로 적지 않습니다.

Java 플러그인의 testImplementation은 implementation 구성을 상속하므로 테스트 범위에 같은 BOM을 한 번 더 적을 필요도 없습니다.

io.spring.dependency-management 플러그인을 적용하는 방식도 가능하지만 두 방식을 중복 적용하지 않습니다.


최소 애플리케이션 실행

패키지 루트에 시작 클래스를 둡니다.

기본 @SpringBootApplication 설정에서는 컴포넌트 스캔이 이 패키지와 하위 패키지를 대상으로 삼기 때문에 시작 클래스의 위치가 프로젝트 경계를 정합니다.

src/main/java/board/BoardApplication.java
package board;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BoardApplication {
    public static void main(String[] args) {
        SpringApplication.run(BoardApplication.class, args);
    }
}
실행
./gradlew bootRun

bootRun은 프로젝트의 Java toolchain으로 선택한 launcher를 사용하므로 셸의 java와 같은 바이너리일 필요는 없습니다.

Windows PowerShell 또는 명령 프롬프트에서는 gradlew.bat bootRun을 사용합니다.

시작 로그에서 다음 사실을 확인합니다.

확인할 로그
:: Spring Boot ::                (v4.1.1)
Tomcat started on port 8080 (http)
Started BoardApplication

여기서는 서버의 시작을 확인합니다. 아직 페이지나 컨트롤러를 추가하지 않았으므로 GET /의 200 응답을 기대하지 않습니다. 다음 절의 정적 시작 화면에서 static/index.html을 추가한 뒤 첫 HTML 응답을 확인합니다.

포트가 이미 사용 중이면 애플리케이션은 시작되지 않습니다.

이때 로그의 핵심은 Port 8080 was already in use입니다.

임의로 프로세스를 종료하기 전에 어떤 프로그램이 포트를 점유했는지 확인하거나 ./gradlew bootRun --args='--server.port=8081'로 한 번만 다른 포트를 선택합니다.


버전 확인 지점

아래 RuntimeVersions.java는 다음 절의 JSON 응답이 호출하는 값 객체입니다. JSON 예제를 만들기 전에 이 파일까지 작성하세요. 그 뒤의 테스트와 시작 경로 출력은 서로 다른 실행 지점의 버전을 살펴볼 때 사용합니다.

빌드가 성공했다는 사실만으로는 실제 런타임 버전을 알 수 없습니다.

Gradle launcher JVM과 daemon JVM, 발견된 Java toolchain 후보, 실제 task compiler, 테스트 worker가 로드한 Java·Boot·Spring 버전을 각각 관찰합니다. 실제 bootRun 애플리케이션 프로세스는 시작 경로에서 별도로 확인합니다.

Gradle과 toolchain 확인
./gradlew --version
./gradlew javaToolchains

./gradlew --version은 Gradle client를 시작한 Launcher JVM과 빌드를 수행할 Daemon JVM을 구분해 보여 줍니다.

javaToolchains는 설치되었거나 자동 탐색된 후보를 나열할 뿐 특정 task가 그중 어느 JDK를 골랐다는 증거는 아닙니다.

실제 compiler 선택은 ./gradlew clean test --info의 task 진단으로 확인하고, 테스트 worker가 실행한 Java와 로드한 프레임워크 버전은 아래 값 객체로 확인합니다.

테스트 worker와 애플리케이션 코드 양쪽에서 재사용할 수 있도록 작은 값 객체를 추가합니다.

이 클래스는 웹과 무관하며 Spring 정적 API만 읽습니다.

src/main/java/board/support/RuntimeVersions.java
package board.support;

import static java.util.Objects.requireNonNullElse;

import org.springframework.boot.SpringBootVersion;
import org.springframework.core.SpringVersion;

public record RuntimeVersions(
        int javaFeature,
        String boot,
        String framework
) {
    public static RuntimeVersions current() {
        return new RuntimeVersions(
                Runtime.version().feature(),
                requireNonNullElse(SpringBootVersion.getVersion(), "unknown"),
                requireNonNullElse(SpringVersion.getVersion(), "unknown"));
    }
}

정상적인 Gradle runtime classpath에서는 Boot와 Spring JAR의 package metadata가 버전 증거가 됩니다.

다만 일부 class loader나 metadata가 제거된 배포에서는 이 값을 알아내지 못할 수 있으므로 RuntimeVersions는 null 대신 unknown을 저장합니다.

그 경우 아래 테스트는 NullPointerException이 아니라 버전 기준 불일치로 실패합니다.

src/test/java/board/support/RuntimeVersionsTest.java
package board.support;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;

class RuntimeVersionsTest {
    @Test
    void 교재의_실행_기준과_일치한다() {
        var versions = RuntimeVersions.current();

        assertThat(versions.javaFeature()).isEqualTo(25);
        assertThat(versions.boot()).isEqualTo("4.1.1");
        assertThat(versions.framework()).startsWith("7.0.");
    }
}
테스트
./gradlew test --tests board.support.RuntimeVersionsTest
결과
RuntimeVersionsTest > 교재의_실행_기준과_일치한다() PASSED
BUILD SUCCESSFUL

이 테스트는 애플리케이션 컨텍스트나 bootRun 프로세스를 띄우지 않습니다.

필요한 것은 세 정적 버전 값뿐이므로 순수 JUnit 테스트가 가장 작고 정확합니다.

따라서 이 결과를 실제 애플리케이션 프로세스의 증거로 확대하지 않습니다. bootRun 런타임까지 확인하려면 아래 연습의 VersionReport처럼 애플리케이션 시작 경로에서 같은 값을 별도로 관찰합니다.

Boot patch는 빌드 선언과 같은 4.1.1을 정확히 검사하고, Spring Framework는 Boot BOM이 관리하는 7.0.x line을 계약으로 검사합니다.

반대로 자동 설정이나 빈 연결을 검증하는 문서에서는 컨텍스트를 실제로 띄웁니다.

확인 대상에 따라 테스트 크기를 선택해야 합니다.


디렉터리 역할 구성

아직 존재하지 않는 클래스를 미리 만들 필요는 없지만, 앞으로 코드가 쌓일 위치는 합의해 둡니다.

첫 문서가 끝난 뒤 구조
board/
├─ build.gradle
├─ settings.gradle
└─ src/
   ├─ main/
   │  ├─ java/board/
   │  │  ├─ BoardApplication.java
   │  │  ├─ application/       # 사용 사례
   │  │  ├─ config/            # 빈 조립·설정
   │  │  ├─ domain/            # 업무 규칙
   │  │  ├─ infrastructure/    # 저장 기술
   │  │  ├─ support/           # 실행 지원
   │  │  └─ web/               # HTTP·화면 경계
   │  └─ resources/
   │     ├─ static/
   │     └─ templates/
   └─ test/java/board/

빈 패키지는 Git에 저장되지 않습니다.

위 트리는 앞으로의 위치를 보여 주는 지도일 뿐이며 실제 디렉터리는 해당 코드가 생길 때 만듭니다.

config는 application의 사용 사례와 infrastructure 구현을 Spring 빈으로 조립하는 설정 경계이며 업무 규칙을 두는 곳은 아닙니다.

controller, service, repository만으로 최상위 패키지를 나누면 기술 역할은 보이지만 게시판이라는 도메인의 응집이 약해질 수 있습니다.

이번 자습서의 규모에서는 경계를 먼저 명확히 보기 위해 위 구조로 시작하고, 기능이 커지면 post 기능 아래에 계층을 모으는 방식과 비교합니다.

PROJECT ROADMAP · PACKAGE BOUNDARIES

스캔 루트 아래에서 책임을 나누고 안쪽 계약에 의존한다

BoardApplication은 board 하위의 기본 스캔 루트입니다. 화살표는 코드 의존 또는 조립 방향이며 실행 순서가 아닙니다. 패키지는 해당 책임의 코드가 생길 때 만듭니다.

BoardApplication · 기본 스캔 루트

board.* 아래에서 컴포넌트를 찾는 Boot 진입점입니다. 빈 패키지를 미리 만들 필요는 없습니다.

계획한 패키지 의존과 조립web에서 application을 거쳐 domain으로 의존하고 infrastructure도 domain 계약을 구현한다. config가 application과 infrastructure를 조립한다. support는 업무 계층 밖의 실행 지원이다.webHTTP 입력·응답application등록·조회 순서domain규칙·저장 portinfrastructure바깥 기술 adapterconfig빈 생성·조립support실행 지원config의 두 선 = 조립support는 업무 계층 밖
같은패키지의존과구성조립 실선은web이application을사용하고application과infrastructure가안쪽domain계약에의존하는방향이다. config의두점선은application과infrastructure의구현선택·조립이다. 실행순서나HTTP요청선이아니다. support는업무계층밖의버전관찰·시작지원이며다른노드로업무호출선을만들지않는다. 의존 의존 구현 web HTTP 입력 · 응답 application 등록 · 조회 순서 domain 업무 규칙 · 저장 port 안쪽 계약 infrastructure 바깥 기술 adapter domain 계약 구현 config 빈 생성 · 조립 구현과 정책 선택 support 버전 관찰 · 시작 지원 업무 계층 밖

web

HTTP 입력·응답

application 사용 사례 호출

application

등록·조회 순서

domain 규칙과 port 사용

domain

업무 규칙·저장 port

안쪽 계약

infrastructure

바깥 기술 adapter

domain 또는 application port 구현

config

빈 생성·조립

application과 adapter 선택

support

실행 지원

버전 관찰·bootstrap 보조

후속 MemoryPostRepository는 domain의 PostRepository를 구현합니다. application에 port가 생겨도 infrastructure adapter가 그 안쪽 계약에 의존하는 원칙은 같습니다.


도구 체인 오류 해석

build.gradle의 도구 체인을 25에서 21로 바꾸고 Java 25에서만 허용되는 소스를 추가했다고 가정합니다.

가능한 실패는 두 종류입니다.

  1. Gradle이 Java 21 컴파일러를 정상 선택하고 새 문법에서 컴파일 오류를 낸다.
  2. Java 21 도구 체인을 찾지 못해 컴파일 전에 도구 체인 해결 오류를 낸다.

둘 다 JAVA_HOME만 바꾸면 된다고 단정하면 안 됩니다.

Gradle 데몬이 이미 다른 JVM으로 떠 있을 수 있고, 도구 체인이 별도 JDK를 선택할 수 있습니다.

다음 순서로 범위를 좁힙니다.

도구 체인 진단
java --version
./gradlew --version
./gradlew javaToolchains
./gradlew clean test --info

java --version은 셸 기본 Java를 알려 주는 힌트일 뿐 Gradle이 사용할 Java를 확정하지 않습니다.

./gradlew --version은 Launcher JVM과 Daemon JVM을, javaToolchains는 발견된 후보를 보여 줍니다.

그다음 clean test --info에서 실제 task compiler를 확인하고 RuntimeVersionsTest에서 테스트 worker의 Java와 Boot·Spring metadata를 확인합니다. 실제 bootRun 애플리케이션은 VersionReport처럼 시작 경로에 연결한 관찰 지점으로 따로 확인합니다.

모든 값이 같은 바이너리일 필요는 없지만, 처음 기대와 달라진 경계를 찾고 각 Java가 맡은 역할을 설명할 수 있어야 합니다.


연습 문제

프로젝트에 VersionReport라는 CommandLineRunner 빈을 추가해 시작할 때 Java·Boot·프레임워크 버전을 한 줄로 출력하세요.

단, 테스트에서는 로그 문자열을 비교하지 말고 RuntimeVersions.current()의 값 자체를 검증해야 합니다.

로그 형식은 바뀌기 쉽지만 버전 계약은 유지되어야 하기 때문입니다.

해설 보기
src/main/java/board/support/VersionReport.java
package board.support;

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public final class VersionReport implements CommandLineRunner {
    @Override
    public void run(String... args) {
        var value = RuntimeVersions.current();
        System.out.printf(
                "runtime java=%d boot=%s framework=%s%n",
                value.javaFeature(), value.boot(), value.framework());
    }
}

CommandLineRunner는 컨텍스트가 준비된 뒤 한 번 호출됩니다.

운영 환경에서 이 출력이 필요 없다면 프로필이나 조건부 빈 등록으로 VersionReport 자체를 등록하지 않습니다.

버전 테스트는 여전히 RuntimeVersionsTest 하나로 충분합니다.

이제 실행 기반이 고정되었습니다.

다음 문서에서는 같은 서버에서 정적 파일, MVC 뷰, JSON 응답을 각각 만들고 반환 문자열이 어떤 처리기로 흘러가는지 직접 비교합니다.