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 설정에서 찾게 됩니다.
이 자습서의 누적 예제는 게시글을 등록하고 조회하는 회원가입·게시판 웹 애플리케이션입니다.
이번 문서에서는 아직 도메인 기능을 만들지 않습니다.
대신 앞으로 추가할 모든 코드가 공유할 실행 계약을 먼저 고정합니다.
SPRING · JAVA EXECUTION EVIDENCE
선언에서 런타임까지 같은 기준을 증거로 연결한다
선언과 후보 목록만으로는 실제 실행을 증명할 수 없습니다. Gradle 자체의 JVM과 task toolchain을 나누고, compiler와 테스트 worker runtime을 차례로 관찰해 처음 기대와 달라진 경계를 찾습니다.
각 단계의 관찰값을 다음 단계의 증거와 대조합니다.
-
빌드 기준을 선언한다
관찰:
build.gradle증거: Java 기능 수준 25와 Spring Boot BOM 4.1.1을 요청합니다. 이 선언만으로 JDK vendor나 patch까지 같아지지는 않습니다.
-
Gradle 자체의 두 JVM을 확인한다
관찰:
./gradlew --version증거: Wrapper client의 Launcher JVM과 빌드를 수행하는 Daemon JVM을 구분합니다.
-
Toolchain 후보를 확인한다
관찰:
./gradlew javaToolchains증거: 설치되었거나 탐색된 JDK 후보입니다. 특정 task가 실제로 선택한 compiler의 증거는 아닙니다.
-
Task compiler 선택을 확인한다
관찰:
./gradlew clean test --info증거: compile·test task가 toolchain에서 고른 실제 Java 실행 경로를 진단합니다.
-
테스트 worker runtime을 확인한다
관찰:
RuntimeVersionsTest증거: 테스트 worker의 Java 25와 test runtime classpath의 Boot 4.1.1·Spring 7.0.x metadata를 읽습니다. metadata가 없으면
unknown입니다. -
처음 불일치한 경계부터 교정한다
판단: 선언, Gradle JVM, 후보, task compiler, test worker runtime 가운데 처음 기대와 다른 단계를 고친 뒤 같은 순서로 재검증합니다.
java --version은 셸 힌트
셸 기본 Java를 보여 줄 뿐 Gradle Launcher JVM, Daemon JVM, task compiler를 확정하지 않습니다.
- 선언에서 runtime까지의 증거 전달
- 실제 task 선택과 최초 불일치 진단
Java toolchain의 language version은 기능 수준을 고정하지만 vendor와 patch 바이너리까지 고정하지 않습니다. RuntimeVersionsTest는 테스트 worker를 관찰하며, 실제 bootRun 애플리케이션은 VersionReport처럼 시작 경로에 연결한 관찰 지점으로 따로 확인합니다.
프로젝트 버전 선택
먼저 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를 요구하므로 수동으로 시작할 때도 먼저 호환 버전을 선택해야 합니다.
Wrapper 파일이 생기기 전에는 ./gradlew 명령을 사용할 수 없습니다.
이 교재는 가장 단순한 Initializr 경로를 기준으로, 내려받은 프로젝트의 다음 두 파일을 확인하고 수정합니다.
settings.gradle은 프로젝트의 논리적 이름을 정하고, build.gradle은 플러그인·의존성·Java 도구 체인을 정합니다.
rootProject.name = 'board'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 설정에서는 컴포넌트 스캔이 이 패키지와 하위 패키지를 대상으로 삼기 때문에 시작 클래스의 위치가 프로젝트 경계를 정합니다.
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 bootRunbootRun은 프로젝트의 Java toolchain으로 선택한 launcher를 사용하므로 셸의 java와 같은 바이너리일 필요는 없습니다.
Windows PowerShell 또는 명령 프롬프트에서는 gradlew.bat bootRun을 사용합니다.
시작 로그에서 다음 사실을 확인합니다.
:: Spring Boot :: (v4.1.1)
Tomcat started on port 8080 (http)
Started BoardApplication포트가 이미 사용 중이면 애플리케이션은 시작되지 않습니다.
이때 로그의 핵심은 Port 8080 was already in use입니다.
임의로 프로세스를 종료하기 전에 어떤 프로그램이 포트를 점유했는지 확인하거나 ./gradlew bootRun --args='--server.port=8081'로 한 번만 다른 포트를 선택합니다.
버전 확인 지점
빌드가 성공했다는 사실만으로는 실제 런타임 버전을 알 수 없습니다.
Gradle launcher JVM과 daemon JVM, 발견된 Java toolchain 후보, 실제 task compiler, 테스트 worker가 로드한 Java·Boot·Spring 버전을 각각 관찰합니다. 실제 bootRun 애플리케이션 프로세스는 시작 경로에서 별도로 확인합니다.
./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만 읽습니다.
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이 아니라 버전 기준 불일치로 실패합니다.
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.RuntimeVersionsTestRuntimeVersionsTest > 교재의_실행_기준과_일치한다() 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 · PLANNED PACKAGE BOUNDARIES
스캔 루트 아래에서 책임을 나누고, 의존은 안쪽 계약으로 향한다
BoardApplication은 board.*의 기본 컴포넌트 스캔 경계입니다. web → application → domain을 핵심 의존 경로로 두고, infrastructure는 port 구현, config는 조립, support는 런타임 확인과 부트스트랩 보조를 맡습니다.
SCAN ROOT
BoardApplication
board.* 아래를 기본 컴포넌트 스캔 범위로 삼는 부트 진입점입니다.
web
HTTP 입력과 응답을 변환하고 application의 사용 사례를 호출합니다.
DEPENDS ON → application
application
등록·조회 순서를 조정하고 domain의 규칙과 port에 의존합니다.
DEPENDS ON → domain
domain
회원·게시글 업무 규칙과 repository port를 두는 안쪽 계약입니다.
INWARD CONTRACT
infrastructure
domain 또는 application의 port를 구현하는 바깥 기술 adapter입니다. 후속 문서의 MemoryPostRepository는 PostRepository를 구현합니다.
IMPLEMENTS → domain / application port
config
application과 infrastructure 구현을 명시적으로 조립합니다.
WIRES → application + adapter
SIDE RAIL · NOT A LAYER
support
RuntimeVersions 같은 런타임 확인과 부트스트랩 보조를 맡으며 비즈니스 의존 계층에는 넣지 않습니다.
- 소스 코드 의존 또는 port 구현 방향
-
BoardApplication아래의 계획 경계 - 스캔 루트와 안쪽 계약
이 그림은 최종 전체 구조가 아니라 앞으로 코드가 놓일 계획된 경계입니다. 빈 패키지는 미리 만들지 않고 해당 책임의 코드가 생길 때 생성합니다. infrastructure는 JDBC로 고정되지 않으며, 후속 문서의 메모리 저장소도 같은 adapter 경계에 놓입니다.
도구 체인 오류 해석
build.gradle의 도구 체인을 25에서 21로 바꾸고 Java 25에서만 허용되는 소스를 추가했다고 가정합니다.
가능한 실패는 두 종류입니다.
- Gradle이 Java 21 컴파일러를 정상 선택하고 새 문법에서 컴파일 오류를 낸다.
- Java 21 도구 체인을 찾지 못해 컴파일 전에 도구 체인 해결 오류를 낸다.
둘 다 JAVA_HOME만 바꾸면 된다고 단정하면 안 됩니다.
Gradle 데몬이 이미 다른 JVM으로 떠 있을 수 있고, 도구 체인이 별도 JDK를 선택할 수 있습니다.
다음 순서로 범위를 좁힙니다.
java --version
./gradlew --version
./gradlew javaToolchains
./gradlew clean test --infojava --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()의 값 자체를 검증해야 합니다.
로그 형식은 바뀌기 쉽지만 버전 계약은 유지되어야 하기 때문입니다.
해설 보기
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 응답을 각각 만들고 반환 문자열이 어떤 처리기로 흘러가는지 직접 비교합니다.