Java LTS 버전별(8, 11, 17, 21, 25) 핵심 기능과 Spring/Spring Boot에서 어떻게 활용되는지 정리한다.

버전 출시 Spring Boot 지원 핵심 키워드
8 2014 1.x ~ 2.x 함수형 프로그래밍
11 2018 2.x var, HttpClient
17 2021 3.0+ Record, Sealed, Text Block
21 2023 3.2+ Virtual Thread
25 2025 4.0+ Structured Concurrency

Java 8 (2014, LTS) - 함수형 프로그래밍 도입

Java 역사상 가장 큰 변화. 함수형 프로그래밍 패러다임이 도입되었다.

Lambda Expression

익명 클래스를 간결하게 대체한다.

// Java 7 - 익명 클래스
Runnable runnable = new Runnable() {
		@Override
		public void run() {
			System.out.println("Hello");
		}
	};

// Java 8 - Lambda
Runnable runnable = () -> System.out.println("Hello");

Spring에서의 활용:

// Spring Security - Lambda DSL (Spring Security 5.2+에서 권장)
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
	return http
		.authorizeHttpRequests(auth -> auth          // Lambda
			.requestMatchers("/api/**").authenticated()
		)
		.oauth2ResourceServer(oauth2 -> oauth2       // Lambda
			.jwt(jwt -> jwt.decoder(jwtDecoder()))
		)
		.build();
}

Spring Security의 설정 방식이 메서드 체이닝에서 Lambda DSL로 전환된 배경이 바로 Java 8의 Lambda다.

Stream API

컬렉션 데이터를 선언형으로 처리한다. for 루프 대신 무엇을 할 것인가에 집중한다.

// Java 7 - for 루프
List<String> activeNames = new ArrayList<>();
for(
User user :users){
	if(user.

isActive()){
	activeNames.

add(user.getName());
	}
	}

// Java 8 - Stream
List<String> activeNames = users.stream()
	.filter(User::isActive)
	.map(User::getName)
	.toList();

Spring에서의 활용:

// Spring Data JPA - Stream 반환
@Query("SELECT u FROM User u WHERE u.active = true")
Stream<User> findAllActiveUsers();

// 사용
try(
Stream<User> stream = userRepository.findAllActiveUsers()){
List<String> names = stream
	.map(User::getName)
	.toList();
}

Optional

null 대신 값의 존재/부재를 명시적으로 표현한다. NullPointerException 방지.

// Java 7
User user = userRepository.findById(1L);
if(user !=null){
	return user.

getName();
}
	return"Unknown";

// Java 8
	return userRepository.

findById(1L)
    .

map(User::getName)
    .

orElse("Unknown");

Spring에서의 활용:

Spring Data JPA는 findById()의 반환 타입이 Optional이다.

// Spring Data JPA
public interface UserRepository extends JpaRepository<User, Long> {
	Optional<User> findByEmail(String email);  // Optional 반환
}

java.time API

기존 Date, Calendar의 문제(가변 객체, 스레드 안전하지 않음)를 해결한 새로운 날짜/시간 API.

// Java 7 - 문제 많은 API
Date date = new Date();                         // 가변, 스레드 안전 X
Calendar cal = Calendar.getInstance();
cal.

set(Calendar.MONTH, 0);                     // 0 = 1월 (혼란)

// Java 8 - 불변, 직관적
LocalDate date = LocalDate.of(2025, 1, 1);      // 1 = 1월
LocalDateTime now = LocalDateTime.now();
Duration duration = Duration.between(start, end);

Spring에서의 활용:

// Spring Boot의 Jackson 직렬화
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;

// Spring Data JPA - 자동 매핑
@Entity
public class Order {
	private LocalDate orderDate;       // DATE 컬럼
	private LocalDateTime createdAt;   // TIMESTAMP 컬럼
}

Default Method (인터페이스)

인터페이스에 구현 메서드를 추가할 수 있게 되었다. 기존 구현체를 깨지 않고 인터페이스를 확장 가능.

public interface Auditable {
	default String getAuditInfo() {
		return "Created at: " + getCreatedAt();
	}

	LocalDateTime getCreatedAt();
}

Spring에서의 활용:

Spring의 WebMvcConfigurer가 Java 8부터 default 메서드를 사용하면서 WebMvcConfigurerAdapter(추상 클래스 상속)가 deprecated 되었다.

// Java 7 - 추상 클래스 상속 (deprecated)
public class WebConfig extends WebMvcConfigurerAdapter {
	@Override
	public void addCorsMappings(CorsRegistry registry) { ...}
}

// Java 8+ - 인터페이스 직접 구현
public class WebConfig implements WebMvcConfigurer {
	@Override
	public void addCorsMappings(CorsRegistry registry) { ...}
}

Java 11 (2018, LTS) - 편의성 개선

Java 8 이후 첫 번째 LTS. 소소하지만 실용적인 개선이 많다.

var (지역 변수 타입 추론)

Java 10에서 도입, 11에서 Lambda에도 확장. 컴파일러가 타입을 추론한다.

// 명시적 타입
Map<String, List<UserDto>> userMap = new HashMap<>();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

// var - 타입 추론
var userMap = new HashMap<String, List<UserDto>>();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());

주의: 필드, 메서드 파라미터, 반환 타입에는 사용 불가. 지역 변수에서만 사용.

HttpClient (표준 HTTP 클라이언트)

HttpURLConnection을 대체하는 현대적 HTTP 클라이언트. 비동기, HTTP/2 지원.

// Java 11 - 표준 HttpClient
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
	.uri(URI.create("https://api.example.com/users"))
	.header("Content-Type", "application/json")
	.GET()
	.build();

var response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.

println(response.body());

Spring과의 관계:

Spring에서는 RestTemplateWebClientRestClient(Spring 6.1)로 발전해왔다. Java 11 HttpClient를 직접 쓸 일은 적지만, Spring의 RestClient 내부에서 사용 가능한 HTTP 엔진 중 하나다.

// Spring 6.1+ RestClient (Java 11 HttpClient보다 Spring에서 권장)
RestClient restClient = RestClient.create();
String result = restClient.get()
	.uri("https://api.example.com/users")
	.retrieve()
	.body(String.class);

String 메서드 추가

" ".isBlank();          // true (공백만 있으면 true)
"  hello  ".

strip();    // "hello" (유니코드 공백도 제거, trim()보다 정확)
"hello\nworld".

lines(); // Stream<String> ["hello", "world"]
"ha".

repeat(3);         // "hahaha"

단일 파일 직접 실행

# Java 11 이전 - 컴파일 후 실행
javac Hello.java
java Hello

# Java 11 - 바로 실행
java Hello.java

Java 17 (2021, LTS) - 타입 시스템 강화

Spring Boot 3.0의 최소 요구 버전. Java 17부터 Spring 생태계가 크게 변했다.

Spring Boot 3.0 (2022.11): Java 17 최소, Jakarta EE 9+ (javax.*jakarta.*)

Record

불변 데이터 객체를 위한 클래스. equals(), hashCode(), toString(), getter를 자동 생성한다.

// Java 16 이전 - 보일러플레이트
public class UserDto {
	private final String name;
	private final String email;

	public UserDto(String name, String email) {
		this.name = name;
		this.email = email;
	}

	public String getName() {
		return name;
	}

	public String getEmail() {
		return email;
	}

	@Override
	public boolean equals(Object o) { ...}

	@Override
	public int hashCode() { ...}

	@Override
	public String toString() { ...}
}

// Java 17 - Record (한 줄)
public record UserDto(String name, String email) {
}

Spring에서의 활용:

// Spring MVC - 요청/응답 DTO로 사용
public record CreateUserRequest(String name, String email) {
}

public record UserResponse(Long id, String name, String email) {
}

@RestController
public class UserController {
	@PostMapping("/users")
	public UserResponse create(@RequestBody CreateUserRequest request) {
		// ...
	}
}

// Spring Data - Projection
public record UserSummary(String name, String email) {
}

@Query("SELECT new com.example.UserSummary(u.name, u.email) FROM User u")
List<UserSummary> findAllSummary();

주의: Record는 불변이므로 MyBatis의 Setter 기반 매핑과는 호환이 안 된다. JPA Entity로도 사용 불가 (기본 생성자 필요, 불변이라 프록시 생성 불가).

Sealed Classes

상속할 수 있는 클래스를 permits로 제한한다. 도메인 모델링에서 “이 타입은 이것들만 될 수 있다”를 표현.

// 결제 수단은 3가지만 존재
public sealed interface Payment permits CreditCard, BankTransfer, Cash {
}

public record CreditCard(String cardNumber) implements Payment {
}

public record BankTransfer(String accountNumber) implements Payment {
}

public record Cash(int amount) implements Payment {
}

Pattern Matching for instanceof

// Java 16 이전 - 캐스팅 필요
if(obj instanceof String){
String s = (String) obj;     // 캐스팅
    System.out.

println(s.length());
	}

// Java 17 - 캐스팅 자동
	if(obj instanceof
String s){   // 선언과 캐스팅 동시에
	System.out.

println(s.length());
	}

Spring에서의 활용:

// Exception Handler에서 유용
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleException(Exception ex) {
	if (ex instanceof AccessDeniedException ade) {
		return ResponseEntity.status(403).body(ade.getMessage());
	}
	if (ex instanceof ResourceNotFoundException rnf) {
		return ResponseEntity.status(404).body(rnf.getMessage());
	}
	return ResponseEntity.status(500).body("Internal Server Error");
}

Text Block

여러 줄 문자열을 """로 표현. JSON, SQL, HTML 작성이 간편해진다.

// Java 16 이전
String json = "{\n" +
		"  \"name\": \"John\",\n" +
		"  \"age\": 30\n" +
		"}";

// Java 17 - Text Block
String json = """
	{
	  "name": "John",
	  "age": 30
	}
	""";

Spring에서의 활용:

// 테스트에서 JSON 작성
@Test
void createUser() throws Exception {
	String requestBody = """
		{
		    "name": "John",
		    "email": "john@example.com"
		}
		""";

	mockMvc.perform(post("/users")
			.contentType(MediaType.APPLICATION_JSON)
			.content(requestBody))
		.andExpect(status().isCreated());
}

// JPQL 쿼리
@Query("""
	SELECT u FROM User u
	WHERE u.active = true
	  AND u.createdAt > :date
	ORDER BY u.name
	""")
List<User> findActiveUsersAfter(@Param("date") LocalDateTime date);

Switch Expression

switch가 값을 반환할 수 있게 되었다 (Java 14에서 정식).

// Java 14 이전
String result;
switch(status){
	case ACTIVE:
result ="활성";
	break;
	case INACTIVE:
result ="비활성";
	break;
default:
result ="알 수 없음";
	}

// Java 17 - Switch Expression
String result = switch (status) {
	case ACTIVE -> "활성";
	case INACTIVE -> "비활성";
	default -> "알 수 없음";
};

Java 21 (2023, LTS) - 동시성 혁신

Spring Boot 3.2의 권장 버전. Virtual Thread가 게임 체인저다.

Spring Boot 3.2 (2023.11): Virtual Thread 공식 지원 Spring Boot 4.0 (2025.11): Java 25 최소, Virtual Thread 기본

Virtual Thread (가상 스레드)

기존 플랫폼 스레드는 OS 스레드와 1:1 매핑되어 무겁다 (스레드당 ~1MB 스택). 가상 스레드는 JVM이 관리하는 경량 스레드로, 수백만 개를 생성할 수 있다.

플랫폼 스레드 (기존)             가상 스레드 (Java 21)
───────────────────            ─────────────────────
Thread ←→ OS Thread (1:1)     Virtual Thread ←→ Carrier Thread (N:M)
~1MB 스택 메모리                수 KB 메모리
수천 개 한계                    수백만 개 가능
컨텍스트 스위칭 비쌈             JVM 스케줄링 (저렴)
// 기존 - 플랫폼 스레드
ExecutorService executor = Executors.newFixedThreadPool(200);  // 최대 200개

// Java 21 - 가상 스레드 (요청마다 생성, 비용 거의 없음)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Spring Boot에서의 적용:

# Spring Boot 3.2 ~ 3.x: 기본값 false → 명시 필요
spring:
  threads:
    virtual:
      enabled: true

# Spring Boot 4.0+: 기본값 true → 설정 없어도 가상 스레드 동작

이 설정이 true이면 코드 변경 없이 Spring Boot 내부 스레드풀이 전부 가상 스레드로 교체된다:

적용 대상 설명
Tomcat 요청 처리 모든 HTTP 요청이 가상 스레드에서 처리
@Async 비동기 메서드가 가상 스레드에서 실행
@Scheduled 스케줄러 태스크가 가상 스레드에서 실행
Spring Batch 배치 Job/Step이 가상 스레드에서 실행

코드에서 명시적으로 VirtualThread를 호출하지 않아도, 설정 하나로 애플리케이션 전체가 가상 스레드 기반으로 동작한다.

왜 중요한가:

기존 (플랫폼 스레드):
  Tomcat 스레드 풀: 200개 (기본값)
  201번째 요청 → 대기 (스레드 부족)
  DB 쿼리 1초 대기 중 → 스레드 1개 점유 (아무것도 안 하면서 잡고 있음)

가상 스레드:
  요청마다 가상 스레드 생성 (비용 거의 0, 수 KB)
  DB 쿼리 1초 대기 중 → 캐리어 스레드 반환 → 다른 요청 처리
  동시 10,000 요청도 캐리어 스레드 수십 개로 처리 가능

효과:

항목 플랫폼 스레드 (기존) 가상 스레드
동시 처리 가능 수 스레드 풀 크기(200) 사실상 무제한
요청당 메모리 ~1MB (스택) 수 KB
I/O 블로킹 시 스레드 점유 (낭비) 캐리어 스레드 반환 (재활용)
스레드 풀 튜닝 필요 (server.tomcat.threads.max) 불필요
처리량 (I/O 바운드) 스레드 수에 비례 하드웨어 한계까지

I/O 바운드 워크로드(DB 쿼리, 외부 API 호출이 많은 애플리케이션)에서 가장 큰 효과가 있다. CPU 바운드 작업은 캐리어 스레드를 놓지 않으므로 효과가 적다.

주의사항:

// synchronized 블록은 캐리어 스레드를 고정(pin)시킨다
synchronized (lock){
	// 여기서 블로킹 I/O 발생 시 → 캐리어 스레드 반환 불가 → 성능 저하
	jdbcTemplate.

query(...);  // BAD
}

// ReentrantLock을 사용하면 고정되지 않는다
	lock.

lock();
try{
	jdbcTemplate.

query(...);  // OK - 캐리어 스레드 반환 가능
}finally{
	lock.

unlock();
}
  • synchronized + 블로킹 I/O 조합은 캐리어 스레드를 pin(고정)시켜 성능이 오히려 떨어질 수 있다
  • JDBC 드라이버, 커넥션 풀(HikariCP) 등 라이브러리가 내부적으로 synchronized를 사용하는 경우 주의
  • HikariCP 6.x, PostgreSQL JDBC 42.7+, MySQL Connector/J 9.0+ 등 최신 드라이버는 Virtual Thread 호환 작업이 진행됨

WebFlux(리액티브)의 핵심 장점인 “블로킹 없는 고처리량”을 기존 동기 코드(Spring MVC) 그대로 얻을 수 있다. WebFlux를 도입할 이유가 크게 줄었다.

Record Pattern (Java 21 정식)

Record의 구성 요소를 패턴으로 분해한다.

record Point(int x, int y) {
}

// Java 17 - instanceof Pattern Matching
if(obj instanceof
Point p){
	System.out.

println(p.x() +", "+p.

y());
	}

// Java 21 - Record Pattern (구조 분해)
	if(obj instanceof

Point(int x, int y)){
	System.out.

println(x +", "+y);  // 직접 접근
}

Sequenced Collections

List, Set 등에 getFirst(), getLast(), reversed() 메서드 추가.

// Java 20 이전 - 불편
list.get(0);                  // 첫 번째
list.

get(list.size() -1);    // 마지막

// Java 21 - 직관적
	list.

getFirst();
list.

getLast();
list.

reversed();              // 역순 뷰

Switch Pattern Matching (Java 21 정식)

switch에서 타입 패턴, null, guard(when) 사용 가능.

// Java 21
String describe(Object obj) {
	return switch (obj) {
		case Integer i when i > 0 -> "양수: " + i;
		case Integer i -> "음수 또는 0: " + i;
		case String s -> "문자열: " + s;
		case null -> "null";
		default -> "기타: " + obj;
	};
}

Java 25 (2025, LTS) - 동시성 완성 + 성능 최적화

Spring Boot 4.0의 최소 요구 버전 (Spring Framework 7.0).

Spring Boot 4.0 (2025.11): Java 25 최소, Kotlin 2.2 베이스라인, Jakarta EE 11

Structured Concurrency (정식)

여러 비동기 작업을 구조적으로 관리한다. 부모 태스크가 취소되면 자식도 자동 취소. try-with-resources 패턴으로 스코프 관리.

// Java 21 이전 - 개별 Future 관리 (실수 가능)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<User> userFuture = executor.submit(() -> findUser(id));
Future<List<Order>> ordersFuture = executor.submit(() -> findOrders(id));
// userFuture 실패해도 ordersFuture는 계속 실행... 자원 낭비

// Java 25 - Structured Concurrency
try(
var scope = StructuredTaskScope.open()){
Subtask<User> user = scope.fork(() -> findUser(id));        // 병렬 실행
Subtask<List<Order>> orders = scope.fork(() -> findOrders(id));  // 병렬 실행

    scope.

join();  // 모든 태스크 완료 대기 (하나 실패 시 나머지 자동 취소)

    return new

UserProfile(user.get(),orders.

get());
	}

Spring에서의 활용:


@Service
public class DashboardService {

	// 대시보드 데이터를 병렬로 조회
	public Dashboard getDashboard(Long userId) throws Exception {
		try (var scope = StructuredTaskScope.open()) {
			var user = scope.fork(() -> userService.findById(userId));
			var orders = scope.fork(() -> orderService.findByUserId(userId));
			var notifications = scope.fork(() -> notificationService.findUnread(userId));

			scope.join();

			return new Dashboard(user.get(), orders.get(), notifications.get());
		}
	}
}

Scoped Values (정식)

ThreadLocal의 대체. 불변이고, Virtual Thread에 최적화되어 있다.

// 기존 - ThreadLocal (가변, 메모리 누수 위험)
private static final ThreadLocal<Session> currentSession = new ThreadLocal<>();

public void process() {
	currentSession.set(session);
	try {
		doWork();  // currentSession.get()으로 접근
	} finally {
		currentSession.remove();  // 반드시 제거해야 함 (누수 위험)
	}
}

// Java 25 - ScopedValue (불변, 스코프 자동 관리)
private static final ScopedValue<Session> CURRENT_SESSION = ScopedValue.newInstance();

public void process() {
	ScopedValue.runWhere(CURRENT_SESSION, session, () -> {
		doWork();  // CURRENT_SESSION.get()으로 접근
	});
	// 스코프 벗어나면 자동 해제, 누수 불가
}

Spring과의 관계:

Spring Security의 SecurityContextHolder가 내부적으로 ThreadLocal을 사용한다. 향후 ScopedValue로 전환될 가능성이 있다.

// 현재 Spring Security (ThreadLocal 기반)
SecurityContext context = SecurityContextHolder.getContext();
Authentication auth = context.getAuthentication();

// Virtual Thread 환경에서는 ThreadLocal이 캐리어 스레드와 공유될 수 있어 주의 필요
// ScopedValue는 이 문제를 근본적으로 해결

Compact Object Headers

객체 헤더 크기를 기존 96~128bit에서 64bit로 축소. 코드 변경 없이 메모리 사용량 감소.

기존 객체 헤더: [Mark Word (64bit)] + [Class Pointer (32/64bit)] = 96~128bit
Compact:       [Mark Word + Class Pointer (64bit)]              = 64bit

JVM 옵션으로 활성화:

java -XX:+UseCompactObjectHeaders -jar app.jar

작은 객체가 많은 애플리케이션(Spring Bean, DTO 등)에서 힙 메모리 10~20% 절감 효과.

Primitive Types in Patterns

패턴 매칭에서 원시 타입 사용 가능.

// Java 25 - 원시 타입 패턴
String classify(double value) {
	return switch (value) {
		case 0.0 -> "zero";
		case double d when d > 0 -> "positive";
		case double d when d < 0 -> "negative";
		default -> "NaN";
	};
}

Foreign Function & Memory API (정식)

JNI를 대체하는 네이티브 코드 호출 API. C/C++ 라이브러리를 안전하게 호출할 수 있다.

// C의 strlen 함수 호출
try(Arena arena = Arena.ofConfined()){
SymbolLookup stdlib = Linker.nativeLinker().defaultLookup();
MethodHandle strlen = Linker.nativeLinker().downcallHandle(
	stdlib.find("strlen").orElseThrow(),
	FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
);

MemorySegment str = arena.allocateFrom("Hello");
long len = (long) strlen.invoke(str);  // 5
}

버전별 Spring Boot 매핑

Java Spring Boot Spring Framework 주요 영향
8 1.x ~ 2.7 4.x ~ 5.3 Lambda DSL, Stream, Optional
11 2.x 5.x HttpClient, var
17 3.0+ 6.0+ Jakarta EE 전환, Record, Text Block
21 3.2+ 6.1+ Virtual Thread 지원
25 4.0+ 7.0+ Structured Concurrency, Kotlin 2.2 베이스라인

Spring Boot 3.0의 파괴적 변경 (Java 17 필수)

// Java 17 필수로 올리면서 함께 변경된 것들
javax.servlet .*   jakarta.servlet .*     // Jakarta EE 9
javax.persistence .* jakarta.persistence .*
javax.validation .*  jakarta.validation .*

이 변경 때문에 Spring Boot 2.x → 3.0 마이그레이션이 가장 힘들었다.

Spring Boot 4.0의 변경 (Java 25 필수)

// Jackson 2.x → Jackson 3.x
com.fasterxml.jackson.databind.ObjectMapper tools.jackson.databind.ObjectMapper

// Virtual Thread 기본 활성화
// Kotlin 2.2 베이스라인 (Kotlin 필수는 아님)

실무 마이그레이션 경로

Java 8 + Spring Boot 2.x (대부분의 레거시)
    ↓ javax → jakarta, JUnit 4 → 5
Java 17 + Spring Boot 3.0
    ↓ Virtual Thread 적용
Java 21 + Spring Boot 3.2
    ↓ Jackson 3.x, Kotlin 전환 (선택)
Java 25 + Spring Boot 4.0

각 단계에서 가장 주의할 점:

전환 핵심 주의사항
8 → 17 javax.*jakarta.* 패키지 변경 (가장 큰 작업)
17 → 21 상대적으로 쉬움. ThreadLocal 사용 시 Virtual Thread 호환성 확인
21 → 25 Jackson 3.x 패키지 변경 (com.fasterxmltools.jackson)

JDK 25로 올리면 좋은 점 (문법 외 - JVM/런타임)

문법적 개선과 별개로, JDK 버전을 올리는 것 자체가 성능과 안정성에 큰 영향을 준다. Kotlin이든 Java든 JVM 위에서 동작하므로, 코드 수정 없이 JDK만 업그레이드해도 아래 효과를 얻는다.

GC (가비지 컬렉터) 성능

애플리케이션이 멈추는 시간(Stop-the-World)이 JDK 버전마다 줄어들었다.

JDK 기본 GC STW (Stop-the-World)
8 Parallel GC 수백ms (힙 크기에 비례)
11 G1GC 수십~수백ms (Region 기반으로 개선)
17 G1GC (개선) 수십ms, ZGC 실험적 도입
21 G1GC (개선) Generational ZGC 도입 (STW < 1ms)
25 G1GC (개선) ZGC 안정화, G1GC도 꾸준히 개선

ZGC를 사용하면 힙이 수십GB여도 일시정지가 1ms 미만이다.

# ZGC 활성화 (JDK 21+)
java -XX:+UseZGC -XX:+ZGenerational -jar app.jar

Spring Boot처럼 많은 객체를 생성/소멸하는 프레임워크에서 GC 성능 차이는 응답 시간에 직접 영향을 준다.

메모리 효율

코드 수정 없이 JVM만 올리면 메모리 사용량이 줄어든다.

Compact Strings (JDK 9+)

JDK 8:  String 내부 = char[] (문자당 2바이트)
        "hello" = 10바이트

JDK 9+: String 내부 = byte[] (Latin-1이면 문자당 1바이트)
        "hello" = 5바이트

영문/숫자 위주 문자열의 메모리 사용량이 ~50% 감소한다. JSON 응답, 로그 메시지, URL, SQL 쿼리 등 대부분의 서버 문자열이 Latin-1이므로 효과가 크다.

Compact Object Headers (JDK 25)

JDK 8~24: 객체 헤더 = [Mark Word 64bit] + [Class Pointer 32~64bit] = 96~128bit
JDK 25:   객체 헤더 = [Mark + Class 통합 64bit]                     = 64bit
# Compact Object Headers 활성화 (JDK 25)
java -XX:+UseCompactObjectHeaders -jar app.jar

Spring Bean, DTO, Entity 등 작은 객체가 많은 애플리케이션에서 힙 메모리 10~20% 절감.

컨테이너(Docker/K8s) 지원

JDK 컨테이너 인식
8 (초기) 컨테이너 메모리/CPU 제한 무시 → OOMKilled 빈번
8u191+ -XX:+UseContainerSupport 추가 (수동)
11+ 기본 활성화, cgroup v1 지원
17+ cgroup v2 지원
25 cgroup v2 완전 지원, 컨테이너 리소스 자동 감지 안정화

JDK 8 초기 버전은 Docker 컨테이너의 메모리 제한(예: --memory=512m)을 인식하지 못했다. JVM이 호스트 전체 메모리(예: 32GB)를 기준으로 힙을 잡아서, 컨테이너 메모리 제한을 초과하고 OOMKilled 되는 문제가 빈번했다.

# Kubernetes Pod 예시
resources:
  limits:
    memory: "512Mi"  # JDK 8 초기: 이 제한을 무시하고 힙을 크게 잡음
    # JDK 25: 자동으로 512Mi 기준으로 힙 크기 결정

EKS/K8s 환경이면 JDK 25가 확실히 안정적이다.

보안

항목 JDK 8 JDK 25
TLS 기본 버전 1.0~1.2 1.3 기본 (1.0/1.1 제거)
인증서 알고리즘 SHA-1 허용 SHA-1 비활성화
암호화 알고리즘 3DES, RC4 허용 취약 알고리즘 제거, ChaCha20 등 추가
CA 인증서 수동 관리 최신 CA 인증서 번들 자동 포함

TLS 1.0/1.1은 이미 대부분의 클라우드 서비스에서 거부한다. JDK 8에서는 별도 설정이 필요하지만 JDK 25는 기본적으로 안전한 설정이 적용된다.

시작 속도 (CDS - Class Data Sharing)

JDK 8:  클래스 로딩 → 바이트코드 검증 → 링킹 → 초기화 (매 실행마다 반복)

JDK 25: CDS 아카이브에서 사전 처리된 클래스 로드 → 시작 시간 20~30% 단축
# CDS 아카이브 생성 (한 번만)
java -XX:ArchiveClassesAtExit=app-cds.jsa -jar app.jar

# 이후 실행 시 CDS 활용
java -XX:SharedArchiveFile=app-cds.jsa -jar app.jar

Spring Boot 4.0의 AOT (Ahead-of-Time) 컴파일과 조합하면 시작 시간이 더 줄어든다. 컨테이너 환경에서 빠른 스케일아웃이 필요한 경우 효과적이다.

JIT 컴파일러 최적화

JDK 버전마다 C2 JIT 컴파일러가 꾸준히 개선된다. 같은 코드도 JDK 25에서 더 빠르게 실행된다.

주요 개선 사항:

  • Escape Analysis 개선: 힙 할당을 스택 할당으로 최적화하는 범위 확대
  • 인라이닝 최적화: 메서드 호출 오버헤드 감소
  • 루프 최적화: 벡터 연산(SIMD) 자동 적용 범위 확대
  • 분기 예측: 핫 패스 최적화 개선

벤치마크상 JDK 8 → 25로 올리면 동일 코드에서 처리량 20~40% 향상이 일반적이다.

정리

항목 JDK 8 → JDK 25 효과
GC 일시정지 수백ms → < 1ms (ZGC)
메모리 사용 Compact Strings + Object Headers로 10~30% 절감
컨테이너 OOMKilled 위험 → 자동 리소스 감지
보안 TLS 1.0 허용 → TLS 1.3 기본, 취약 알고리즘 제거
시작 속도 매번 클래스 로딩 → CDS + AOT로 20~30% 단축
처리량 JIT 최적화로 20~40% 향상

이 모든 효과는 코드 한 줄 수정 없이 JDK만 업그레이드하면 얻을 수 있다.


Kotlin과 Java 기능 비교

Java가 버전을 올리며 추가한 기능들 중 상당수는 Kotlin이 이미 제공하고 있었다.

Java 기능 도입 버전 Kotlin 대응 Kotlin 버전
Lambda 8 Lambda (동일) 1.0 (2016)
Stream API 8 Collection 확장함수 (filter, map, flatMap) 1.0
Optional 8 Null Safety (?, ?., ?:, !!) 1.0
var (타입 추론) 10 val / var (처음부터 타입 추론) 1.0
Text Block (""") 15 Raw String (""") 1.0
Record 16 data class 1.0
Sealed Classes 17 sealed class / sealed interface 1.0 / 1.5
Pattern Matching instanceof 17 Smart Cast (is 검사 후 자동 캐스팅) 1.0
Switch Expression 14 when 표현식 1.0
Switch Pattern Matching 21 when + Smart Cast 1.0

Kotlin은 2016년 1.0 출시 때부터 이 기능들을 대부분 갖추고 있었다. Java가 8→25로 올리면서 얻는 문법적 이점의 상당 부분을 Kotlin은 처음부터 제공한다.


Kotlin이 Java보다 간결한 예시

Lambda + Collection 처리

// Java 8 - Stream API
List<String> names = users.stream()
		.filter(u -> u.isActive())
		.map(u -> u.getName())
		.sorted()
		.collect(Collectors.toList());
// Kotlin - 확장함수 (Stream 불필요)
val names = users
    .filter { it.isActive }
    .map { it.name }
    .sorted()

Kotlin은 stream(), collect() 없이 컬렉션에 직접 filter, map을 호출한다. Collectors.toList() 같은 보일러플레이트도 없다.

Null 처리

// Java 8 - Optional
Optional<User> userOpt = userRepository.findById(1L);
String name = userOpt
	.map(User::getName)
	.orElse("Unknown");

// Java - Optional 없이 (NullPointerException 위험)
User user = userRepository.findById(1L);
String name = user != null ? user.getName() : "Unknown";
// Kotlin - Null Safety 내장
val name = userRepository.findById(1L)?.name ?: "Unknown"

?. (Safe Call)과 ?: (Elvis Operator)로 Optional 없이도 안전하게 null을 처리한다. Kotlin에서는 Optional을 쓸 필요가 없다.

Data Class vs Record

// Java 17 - Record
public record UserDto(String name, String email) {
}
// 불변, equals/hashCode/toString 자동 생성
// 단, copy 불가, 기본값 불가
// Kotlin - data class
data class UserDto(
    val name: String,
    val email: String = "unknown"  // 기본값 가능
)

val user = UserDto("John", "john@example.com")
val copied = user.copy(email = "new@example.com")  // copy 가능

Kotlin data class는 Record의 상위호환이다. copy(), 기본값, 구조 분해 선언까지 지원.

Sealed Class + when

// Java 21 - Sealed + Switch Pattern Matching
sealed interface Payment permits CreditCard, BankTransfer, Cash {
}

record CreditCard(String number) implements Payment {
}

record BankTransfer(String account) implements Payment {
}

record Cash(int amount) implements Payment {
}

String describe(Payment payment) {
	return switch (payment) {
		case CreditCard c -> "카드: " + c.number();
		case BankTransfer b -> "계좌: " + b.account();
		case Cash c -> "현금: " + c.amount() + "원";
	};
}
// Kotlin - sealed class + when (Java 21과 동일한 효과)
sealed interface Payment
data class CreditCard(val number: String) : Payment
data class BankTransfer(val account: String) : Payment
data class Cash(val amount: Int) : Payment

fun describe(payment: Payment): String = when (payment) {
    is CreditCard -> "카드: ${payment.number}"
    is BankTransfer -> "계좌: ${payment.account}"
    is Cash -> "현금: ${payment.amount}원"
    // else 불필요 - sealed이므로 컴파일러가 모든 케이스 검증
}

sealed + when을 조합하면 else 없이도 컴파일러가 모든 분기를 검증한다. 새 하위 타입을 추가하면 when에서 처리하지 않은 곳에서 컴파일 에러 발생 → 누락 방지.

Smart Cast vs Pattern Matching instanceof

// Java 17 - Pattern Matching
if(obj instanceof
String s){
	System.out.

println(s.length());
	}
// Kotlin - Smart Cast
if (obj is String) {
    println(obj.length)  // 자동 캐스팅, 별도 변수 선언 불필요
}

Kotlin + Spring Boot

Spring Boot 4.0에서의 Kotlin 지위

Spring Boot 4.0은 Kotlin 2.2를 베이스라인으로 설정했다. Kotlin은 선택이 아닌 공식 지원 언어다.

// Spring Boot 4.0 + Kotlin - 실제 컨트롤러
@RestController
class UserController(
    private val userService: UserService  // 생성자 주입 (Lombok 불필요)
) {
    @GetMapping("/users/{id}")
    fun getUser(@PathVariable id: Long): UserDto =
        userService.findById(id)

    @PostMapping("/users")
    fun createUser(@RequestBody request: CreateUserRequest): UserDto =
        userService.create(request)
}

Java로 같은 코드를 작성하면:

// Java - 같은 기능
@RestController
public class UserController {

	private final UserService userService;

	public UserController(UserService userService) {  // 생성자
		this.userService = userService;
	}

	@GetMapping("/users/{id}")
	public UserDto getUser(@PathVariable Long id) {
		return userService.findById(id);
	}

	@PostMapping("/users")
	public UserDto createUser(@RequestBody CreateUserRequest request) {
		return userService.create(request);
	}
}

Kotlin이 Lombok을 대체하는 방법

Java 프로젝트에서 Lombok은 사실상 필수였다. Kotlin으로 전환하면 Lombok이 완전히 불필요하다.

Lombok Kotlin
@Getter / @Setter 프로퍼티 기본 제공 (val / var)
@Data data class
@Builder Named Arguments + Default Values
@RequiredArgsConstructor Primary Constructor
@Slf4j companion object + LoggerFactory 또는 확장함수
@ToString data classtoString() 자동 생성
@EqualsAndHashCode data classequals() / hashCode() 자동 생성
// Java + Lombok
@Data
@Builder
@RequiredArgsConstructor
public class FaxSendDto {
	private final Long faxSeq;
	private final String uuid;
	private String frNumber;
	private String toNumber;
}
// Kotlin - 위의 모든 어노테이션이 불필요
data class FaxSendDto(
    val faxSeq: Long,
    val uuid: String,
    var frNumber: String? = null,
    var toNumber: String? = null
)

// Named Arguments = Builder 패턴 대체
val dto = FaxSendDto(
    faxSeq = 1L,
    uuid = "abc-123",
    frNumber = "02-1234-5678"
)

확장함수 - 기존 클래스에 메서드 추가

Java에서는 유틸리티 클래스(StringUtils, DateUtils)를 만들어야 했다. Kotlin은 기존 클래스에 직접 함수를 추가할 수 있다.

// Java - 유틸리티 클래스
public class StringUtils {
	public static String toSlug(String input) {
		return input.lowercase().replace(" ", "-");
	}
}
// 사용: StringUtils.toSlug("Hello World")
// Kotlin - 확장함수
fun String.toSlug(): String = this.lowercase().replace(" ", "-")
// 사용: "Hello World".toSlug()

Spring에서의 활용:

// Spring의 BeanDefinitionDsl - Kotlin DSL로 Bean 등록
beans {
    bean<UserService>()
    bean<UserController>()
    bean {
        RouterFunctionDsl {
            GET("/api/users") { request -> ok().body(ref<UserService>().findAll()) }
        }
    }
}

Coroutine vs Virtual Thread

Kotlin은 자체 비동기 솔루션인 Coroutine을 갖고 있다. Java 21의 Virtual Thread와 비교:

  Coroutine Virtual Thread
소속 Kotlin 언어 JVM (Java 21+)
사용법 suspend fun, launch, async Executors.newVirtualThreadPerTaskExecutor()
비동기 스타일 suspend 함수 (컴파일러 변환) 동기 코드 그대로
Spring 연동 Spring WebFlux (spring-webflux) Spring MVC (기존 코드 그대로)
학습 비용 높음 (CoroutineScope, Dispatcher, Flow) 낮음 (기존 코드 변경 없음)
// Coroutine - suspend 함수
@GetMapping("/users/{id}")
suspend fun getUser(@PathVariable id: Long): UserDto =  // suspend 키워드
    userService.findById(id)  // non-blocking

// Virtual Thread - 동기 코드 그대로 (Spring Boot 4.0 기본)
@GetMapping("/users/{id}")
fun getUser(@PathVariable id: Long): UserDto =
    userService.findById(id)  // 블로킹이지만 가상 스레드가 처리

Spring MVC + Virtual Thread 조합이면 Coroutine 없이도 높은 동시성을 얻을 수 있다. Coroutine은 WebFlux(리액티브) 기반에서 주로 사용되며, Spring MVC 기반 프로젝트에서는 Virtual Thread가 더 실용적이다.


JDK 25 업그레이드와 EKS 비용 절감

JDK 25로 업그레이드하면 코드 변경 없이 JVM 수준에서 메모리 효율이 개선된다. 이를 기반으로 Kubernetes 리소스를 줄여 인프라 비용을 절감할 수 있다.

Kubernetes 리소스 설정 이해

resources:
  requests:
    cpu: 500m       # 스케줄링 기준 - "이 Pod은 최소 0.5 CPU가 필요"
    memory: 1Gi     # 스케줄링 기준 - "이 Pod은 최소 1Gi 메모리가 필요"
  limits:
    cpu: 1000m      # 상한선 - CPU는 throttle (죽지 않음)
    memory: 2Gi     # 상한선 - 초과 시 OOM Kill (Pod 즉시 종료)
항목 역할 초과 시
requests.memory 노드 스케줄링 기준. 이 값의 합산으로 노드 수가 결정됨 -
limits.memory Pod이 사용할 수 있는 최대 메모리 OOM Kill (Pod 재시작)
requests.cpu 노드 스케줄링 기준 -
limits.cpu Pod이 사용할 수 있는 최대 CPU throttle (느려짐, 안 죽음)

핵심: requests를 줄이면 노드에 더 많은 Pod을 배치할 수 있고, 노드 수를 줄일 수 있다. limits를 줄이면 Pod이 죽을 수 있으므로 신중해야 한다.

메모리 줄이기 전 확인해야 할 3가지 메트릭

1. Heap 사용량

JVM이 객체를 저장하는 공간. -Xmx로 최대치를 설정한다.

Xmx 1600m (최대 한도)
┌──────────────────────────────────────────┐
│██████████████████░░░░░░░░░░░░░░░░░░░░░░░│
│    실제 사용 700m        여유 900m        │
└──────────────────────────────────────────┘
  • 확인: 피크 시간(업무 시간, 배치 실행)에 힙 사용량이 Xmx의 몇 %인지
  • 판단: 피크가 Xmx의 60% 이하면 Xmx를 줄일 여지 있음
  • 예: 피크 700m / Xmx 1600m = 43% → Xmx 1200m으로 줄여도 여유

2. RSS (Resident Set Size)

OS 관점에서 JVM 프로세스가 실제로 점유한 물리 메모리 전체.

RSS = Heap + Metaspace + Thread Stacks + Code Cache + Direct Buffer + Native
K8s limits 2Gi (이거 넘으면 OOM Kill)
┌────────────────────────────────────────────────┐
│ Heap 700m │ Metaspace 150m │ 기타 200m │ 여유  │
│           │                │           │ ~1Gi  │
└────────────────────────────────────────────────┘
  ←──────── RSS 약 1050m ──────────→
영역 대략적 크기 비고
Heap Xmx 설정값 줄이려는 대상
Metaspace 100~200m 클래스 수에 비례, Spring Boot는 많음
Thread Stacks 가변 Virtual Thread는 스택이 작지만 수가 많을 수 있음
Code Cache 50~240m JIT 컴파일된 코드
Direct Buffer 가변 NIO 사용 시
  • 확인: RSS가 K8s limits의 몇 %인지
  • 판단: RSS 피크가 limits의 70% 이하면 limits 축소 가능
  • 중요: 힙만 보고 줄이면 안 됨. RSS가 limits 넘으면 Pod가 죽음

3. GC (Garbage Collection) 메트릭

JVM이 안 쓰는 객체를 정리하는 작업. 힙을 줄이면 GC가 더 자주 돌 수 있다.

지표 의미 위험 신호
GC 횟수 분당 GC 실행 횟수 급격히 증가하면 힙 부족
GC 시간 1회 GC에 걸리는 시간 수백ms 이상이면 응답 지연
GC 후 힙 사용량 GC 해도 남아있는 메모리 계속 올라가면 메모리 누수
힙 사용량 그래프 (정상 - 톱니 패턴)

1600m ┤
      │
1000m ┤    ╱╲      ╱╲      ╱╲
      │   ╱  ╲    ╱  ╲    ╱  ╲       ← GC 때마다 내려감
 500m ┤  ╱    ╲  ╱    ╲  ╱    ╲
      │ ╱      ╲╱      ╲╱      ╲
   0m ┤─────────────────────────────
      시간 →

힙 사용량 그래프 (위험 - 메모리 누수)

1600m ┤                        ╱╲    ← GC 해도 바닥이 안 내려감
      │                  ╱╲  ╱
1000m ┤            ╱╲   ╱  ╲╱
      │       ╱╲  ╱  ╲╱
 500m ┤  ╱╲  ╱  ╲╱
      │ ╱  ╲╱
   0m ┤─────────────────────────────
      시간 →

비용 절감 단계

JDK만 교체해도 GC 개선, Compact Object Headers, String 최적화 등이 자동 적용된다. 모니터링으로 효과를 확인한 뒤 단계적으로 리소스를 줄인다.

[1단계] JDK 25 이미지 배포 (JVM·K8s 설정은 기존 유지)
   │
   ▼  1~2주 모니터링 (Heap, RSS, GC)
   │
[2단계] JVM 힙 축소 (-Xmx 1600m → 1400m)
   │
   ▼  1~2주 모니터링 (배치 실행 시간대 포함)
   │
[3단계] K8s requests 축소 (1Gi → 896Mi)
   │
   ▼  1~2주 모니터링
   │
[4단계] K8s limits 축소 (2Gi → 1.75Gi)
   │
   ▼  안정 확인
   │
[5단계] EKS 노드 축소 (requests 총합 감소 → 노드 수 줄이기)

주의사항:

  • limits가장 마지막에 줄인다. 초과 시 OOM Kill이므로 충분한 여유 필요
  • Batch 모듈은 대량 데이터 처리로 순간 힙 사용량이 높으므로 보수적으로 접근
  • 트래픽 스파이크(월말, 대량 발송 등)를 반드시 포함한 기간 동안 모니터링

예상 효과

24개 Java 서비스 기준으로 requests.memory를 1Gi → 896Mi(128Mi 절감)로 줄이면:

24 서비스 × 128Mi = 약 3Gi requests 감소
→ EKS m5.xlarge(16Gi) 기준 노드 스케줄링 여유 확보
→ 노드 1대 축소 시 연간 약 $2,000 절감

Spring Boot Actuator + Prometheus + Grafana 조합이면 위 메트릭을 자동으로 수집·시각화할 수 있다.