해당 글은 아래 링크의 내용을 번역 + 추가한 것 입니다.
https://medium.com/java-and-beyond/modern-java-an-in-depth-guide-from-version-8-to-21-by-akiner-alkan-f89b50e13c72
0. 서론
자바는 다재다능한 프로그래밍 언어로, 변혁의 여정을 걸어왔습니다.
자바 8부터 흥미로운 기능들이 개발자들의 코딩 방식을 바꾸어 놓았습니다.
더 나은 기능을 위한 깔끔한 람다 표현식부터 데이터를 더 쉽게 다룰 수 있도록 해준 Stream API까지, 자바 8은 혁신적인 변화를 가져왔습니다.
우리는 자바 21까지의 업데이트를 살펴볼 것입니다.
이 현대적인 자바 기능들을 간단한 설명과 실용적인 예제로 풀어가며 함께 여정에 동참해 보세요.
이 여정이 길고 글이 꽤 길어질 예정이므로, 읽기 편하게 여러 부분으로 나누어 진행할 것을 권장합니다.
Java에서는 장기 지원(LTS, Long-Term Support) 버전을 통해 오랜 기간 안정적인 성능 개선과 지원을 약속합니다.
어떤 버전이 LTS인지, 그리고 향후 몇 년 동안 지원되는지는 공식 사이트에서 확인할 수 있습니다.
https://www.oracle.com/java/technologies/java-se-support-roadmap.html
글을 작성하는 2024년 12월을 기준으로
- LTS 버전: 8, 11, 17, 21
- 최신 non-LTS 버전: 23
따라서 Java가 LTS 버전 별로 어떠한 흐름으로 개발되었는지 학습하면 좋습니다.
1. Java 8
람다 표현식(Lambda Expression)
람다 표현식은 간단한 함수를 바로 만들어서 사용할 수 있는 방법입니다. 이름 없는 함수라고 생각하면 됩니다.
다른 함수에 넘겨줄 때나, 간단한 작업을 처리할 때 유용하게 쓰입니다.
예를 들어, Java 같은 언어에서 반복 작업을 줄이고 코드를 더 짧고 깔끔하게 만들 수 있습니다.
쉽게 말해, "잠깐 필요한 작은 함수"를 간단하게 만드는 방법이라고 보시면 됩니다.
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Hello, world!");
}
};
위 코드는 Runnable 인터페이스를 구현하여 "Hello, world!"를 출력하는 쓰레드 작업을 정의한 코드입니다.
위 코드를 람다 표현식을 이용한다면 아래와 같이 작성할 수 있습니다.
Runnable lambdaRunnable = () -> {
System.out.println("Hello, world!");
};
조금 더 어려운 코드를 살펴볼까요
Comparator 인터페이스를 익명 클래스로 구현하여 하나의 리스트를 오름차순으로 정렬하는 코드를 작성해봅시다.
List<Integer> numbers = Arrays.asList(5, 3, 8, 1, 2);
// Comparator를 구현한 익명 클래스를 사용
Collections.sort(numbers, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o1 - o2;
}
});
위 코드를 람다식으로 표현한다면 아래와 같이 작성이 가능하게 됩니다.
List<Integer> numbers = Arrays.asList(5, 3, 8, 1, 2);
// 람다를 이용한 리스트 정렬
numbers.sort((a, b) -> a - b);
스트림 API (Stream API)
Stream API는 Java 8에서 도입된 기능으로, 데이터 컬렉션(예: 리스트, 배열)을 더 효율적이고 직관적으로 처리할 수 있는 방법을 제공합니다. 스트림을 사용하면 데이터를 필터링하거나 변환하고, 계산하는 등의 작업을 간단하고 깔끔하게 작성할 수 있습니다.
예를 들어, 기존의 반복문을 사용하는 대신 스트림을 활용하면 코드가 더 간결하고 읽기 쉬워집니다.
List<String> fruits = Arrays.asList("apple", "banana", "orange", "grape", "kiwi");
List<String> result = new ArrayList<>();
for (String fruit : fruits) {
if (fruit.length() > 5) {
result.add(fruit.toUpperCase());
}
}
System.out.println(result); // Output: [BANANA, ORANGE]
위 코드는 fruits 리스트를 순회하며 길이가 5보다 큰 문자열만 골라 대문자로 변환합니다.
이러한 코드를 Stream 형식으로 작성한다면 아래와 같이 작성할 수 있습니다.
List<String> fruits = Arrays.asList("apple", "banana", "orange", "grape", "kiwi");
List<String> result = fruits.stream()
.filter(fruit -> fruit.length() > 5)
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(result); // Output: [BANANA, ORANGE]
Stream API를 사용하면 코드를 더 쉽게 읽을 수 있고 여러 연산을 체인화할 수 있습니다.
다른 예제를 살펴보겠습니다.
이 예제는 다양한 Stream API 기능을 사용하여 다단계 필터링, 그룹화, 집계, 그리고 맞춤형 Comparator 등을 적용하는 예입니다.
- 시나리오
- 여러 회사에 속한 직원들의 목록이 있습니다.
- 각 직원은 이름, 연봉, 부서 정보가 있습니다.
- 요구 사항
- 연봉이 50000 이상인 직원만 필터링
- 각 부서별로 직원들을 그룹화
- 각 부서 내에서 연봉이 가장 높은 직원 찾기
- 해당 직원들의 이름을 알파벳 순으로 정렬하고 대문자로 변환
- 최종 결과로 부서별 최고 연봉 직원을 출력
import java.util.*;
import java.util.stream.*;
class Employee {
String name;
int salary;
String department;
Employee(String name, int salary, String department) {
this.name = name;
this.salary = salary;
this.department = department;
}
@Override
public String toString() {
return name + " (" + salary + ", " + department + ")";
}
public int getSalary() {
return salary;
}
public String getName() {
return name;
}
public String getDepartment() {
return department;
}
}
public class Main {
public static void main(String[] args) {
List<Employee> employees = Arrays.asList(
new Employee("Alice", 60000, "HR"),
new Employee("Bob", 40000, "Finance"),
new Employee("Charlie", 70000, "HR"),
new Employee("David", 80000, "Engineering"),
new Employee("Eve", 55000, "Finance"),
new Employee("Frank", 95000, "Engineering"),
new Employee("Grace", 75000, "HR"),
new Employee("Hank", 45000, "Engineering")
);
Map<String, Employee> topSalariesByDept = employees.stream()
// 1. 연봉이 50000 이상인 직원만 필터링
.filter(employee -> employee.getSalary() >= 50000)
// 2. 부서별로 그룹화
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.collectingAndThen(
// 3. 각 부서에서 연봉이 가장 높은 직원 선택
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary)),
Optional::get)))
// 4. 직원 이름을 대문자로 변환하고, 알파벳 순으로 정렬
.entrySet().stream()
.map(entry -> new AbstractMap.SimpleEntry<>(
entry.getKey(),
entry.getValue().getName().toUpperCase())
)
// 5. 부서명과 최고 연봉 직원 이름 출력
.sorted(Comparator.comparing(Map.Entry::getValue))
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(e1, e2) -> e1,
LinkedHashMap::new
));
// 최종 출력
topSalariesByDept.forEach((department, name) ->
System.out.println("Department: " + department + ", Top Salary Employee: " + name));
}
}
이 예제는 Stream API의 다양한 기능을 사용하여 복잡한 데이터를 효율적으로 처리하고 있습니다.
여러 가지 중간 연산과 최종 연산을 활용하여 코드가 간결하면서도 읽기 쉽게 작성할 수 있습니다.
메소드 참조 (Method Reference)
메소드 참조는 메소드나 생성자를 간결한 문법으로 참조하여,
이를 고차 함수나 Stream API와 같은 곳에서 인수로 사용하는 기능입니다.
메소드 참조를 사용하면 람다 표현식을 대신하여 메소드의 이름을 직접 참조할 수 있어서 코드가 더 간결하고 읽기 쉬워집니다.
String[] words = {"apple", "banana", "cherry", "date", "elderberry"};
// 명시적인 람다 표현식을 사용하여 배열 정렬
Arrays.sort(words, (a, b) -> a.compareToIgnoreCase(b));
// 메소드 참조를 사용하여 배열 정렬
Arrays.sort(words, String::compareToIgnoreCase);
자바는 메소드 참조에서 메소드의 시그니처를 자동으로 인식하여 해당 메소드를 호출합니다.
위 코드처럼, String::compareToIgnoreCase는 String객체의 compareToIgnoreCase 메소드를 간단하게 참조하여 사용합니다.
디폴트 메소드 (Default Method)
기존 자바의 인터페이스는 기능에 대한 선언만 가능하기 때문에, 실제 코드를 구현한 로직은 포함될 수 없습니다. 하지만 자바8에서 이러한 룰을 깨트리는 기능이 나왔는데 그것이 Default Method입니다. 메소드 선언 시에 default를 명시하게 되면 인터페이스 내부에서도 로직이 포함된 메소드를 선언할 수 있습니다.
// 인터페이스 정의
interface Greeting {
// 디폴트 메서드 선언
default void greet() {
System.out.println("안녕하세요, 인터페이스에서 인사합니다!");
}
}
// 클래스 구현
class Person implements Greeting {
// greet() 메서드를 별도로 구현하지 않아도 됨
}
public class Main {
public static void main(String[] args) {
Person person = new Person();
person.greet(); // 인터페이스의 기본 구현 메서드 호출
}
}
위 예시에서 Greeting 인터페이스는 greet() 메소드의 기본 구현을 제공합니다.
Person 클래스는 Greeting 인터페이스를 구현(implements)하지만, greet() 메소드를 직접 구현하지 않습니다.
person.greet()를 호출하면 인터페이스에서 정의된 기본 메서드의 구현이 실행됩니다.
디폴트 메서드는 주로 인터페이스의 진화와 호환성을 위해 사용됩니다.
가장 쉽게 이해할 수 있는 예시는 자바의 List 인터페이스입니다. 자바 8 이전에는 List에 새로운 메서드를 추가하면 모든 List를 구현하는 클래스들이 그 메서드를 반드시 구현해야 했습니다.
하지만 디폴트 메서드 덕분에 기존 코드를 전혀 수정하지 않고도 새로운 메서드를 추가할 수 있게 되었습니다.
List 클래스의 sort 메소드를 default 키워드를 사용함으로써,
해당 인터페이스를 구현하는 모든 클래스에서 별로의 구현을 작성하지 않아도 기본 정렬기능을 실행할 수 있습니다.
Date and Time API
Java 8에서는 날짜와 시간을 보다 간편하고 직관적으로 다룰 수 있도록 Date and Time API가 도입되었습니다.
이 API는 기존의 java.util.Date와 java.util.Calendar 클래스에서 발생하던 한계와 복잡성을 해결하며,
불변 객체와 명확한 설계를 기반으로 보다 안전하고 효율적인 날짜 및 시간 연산을 제공합니다.
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class DateTimeAPIExample {
public static void main(String[] args) {
// Current date, time, and date-time
LocalDate currentDate = LocalDate.now();
LocalTime currentTime = LocalTime.now();
LocalDateTime currentDateTime = LocalDateTime.now();
System.out.println("Current Date: " + currentDate);
System.out.println("Current Time: " + currentTime);
System.out.println("Current Date-Time: " + currentDateTime);
// Formatting and parsing
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String formattedDate = currentDate.format(dateFormatter);
System.out.println("Formatted Date: " + formattedDate);
String dateString = "2023-08-01";
LocalDate parsedDate = LocalDate.parse(dateString, dateFormatter);
System.out.println("Parsed Date: " + parsedDate);
}
}
위 예시는 java.time 패키지에서 제공하는 클래스를 활용하여 날짜와 시간 관련 작업을 수행하는 방법을 보여줍다.
LocalDate, LocalTime, LocalDateTime 클래스를 사용해 현재 날짜, 시간, 날짜와 시간을 생성하고,
DateTimeFormatter 클래스를 이용해 날짜를 특정 형식으로 변환하거나 문자열에서 날짜를 파싱하는 작업을 수행합니다.
Java Date and Time API의 특징
- 직관적이고 사용하기 쉬움: 기존 API보다 훨씬 명확한 클래스와 메서드 제공.
- 불변성과 스레드 안전성: 객체가 변경되지 않아 멀티스레드 환경에서도 안전하게 사용 가능.
- 풍부한 기능: ZonedDateTime, Duration, Period 등을 사용해 시간대, 시간 간격, 날짜 차이 등을 다룰 수 있음.
- 문제 해결: 기존 API에서 흔히 발생하던 시간 계산 오류나 서머타임 조정 문제를 방지.
Optional
Java 8의 Optional은 null을 안전하게 처리하기 위해 도입된 클래스입니다.
객체가 존재할 수도, 없을 수도 있는 상황을 명시적으로 표현하며, isPresent()로 값 존재 여부를 확인하거나,
ifPresent()로 값이 있을 때만 작업을 수행할 수 있습니다.
기본값을 설정하려면 orElse()를 사용하고, 예외를 던질 경우 orElseThrow()를 활용합니다.
이를 통해 NullPointerException을 방지하고, 더 간결하고 안전한 코드를 작성할 수 있습니다.
Optional은 Spring 프레임워크에서 JPA를 활용할 때 특히 자주 접하게 됩니다.
위 예시처럼 CrudRepository나 JpaRepository에서 제공하는 findById() 메서드는 조회된 결과를 Optional로 감싸 반환합니다.
이렇게 하면 데이터가 존재하지 않을 경우 null 대신 Optional.empty()를 반환하므로, 불필요한 null 체크를 하지 않아도 됩니다.
또한 Optional과 Stream이 만나면 복잡한 조건의 결과를 읽기 쉽게 작성할 수 있습니다.
시나리오
- 사용자 정보를 조회하고, 사용자의 이메일이 존재하며 특정 조건을 만족할 경우
- 이메일을 대문자로 변환해 반환.
- 조건을 만족하지 않으면 기본 메시지 반환.
import java.util.Optional;
public class OptionalComplexExample {
public static void main(String[] args) {
// 사용자 정보 조회 (예: 사용자 존재)
User user = findUserById(1); // ID: 1번 사용자 조회
String result = Optional.ofNullable(user) // 사용자 객체를 Optional로 감쌈
.map(User::getEmail) // 이메일 추출
.filter(email -> email.endsWith("@example.com")) // 조건: 특정 도메인 이메일만
.map(String::toUpperCase) // 이메일 대문자로 변환
.orElse("유효한 이메일이 없습니다."); // 조건 불만족 시 기본 메시지 반환
System.out.println(result); // 결과 출력
}
// 사용자 조회 메서드 (DB에서 조회했다고 가정)
private static User findUserById(int id) {
if (id == 1) {
return new User("john.doe@example.com"); // 사용자 정보 반환
}
return null; // 사용자 없음
}
// 사용자 클래스
static class User {
private String email;
public User(String email) {
this.email = email;
}
public String getEmail() {
return email;
}
}
}
2. Java 9
Java Module System
Java 9에 도입된 Java Module System은 Java 플랫폼에 큰 변화를 가져온 기능으로, 모듈화를 통해 더 유지보수하기 쉬운 애플리케이션을 만들 수 있도록 설계되었습니다. 이 시스템은 강력한 캡슐화, classpath 문제, 버전 충돌과 같은 Java 생태계의 문제를 해결하는 데 초점을 맞추고 있습니다.
주요 개념
- 모듈(Module)
- 모듈은 관련된 패키지와 리소스를 하나로 묶어 캡슐화된 코드 단위를 제공합니다.
- 이를 통해 구현 세부 사항은 숨기고, 명확한 API만 외부에 노출할 수 있습니다.
- 모듈 설명자(module-info.java)
- 각 모듈은 modul-info.java 파일을 통해 정의됩니다.
- 이 파일은 모듈 이름, 의존성, 외부에 공개할 패키지, 기타 설정을 명시합니다.
- 모듈 경로(Modul Path)
- 모듈 경로는 기존 클래스패스(classpath)를 대체하는 새로운 의존성 관리 방법입니다.
- 명시적인 의존성 요구사항에 따라 모듈을 해석하며, 클래스패스 충돌을 방지할 수 있습니다.
주요 키워드
Java Module System은 모듈 선언과 모듈 간 관계를 정의하기 위해 새로운 키워드를 도입했습니다.
이 키워드는 모듈 간 의존성, 서비스, 패키지 관리 등에서 중요한 역할을 하며, 애플리케이션의 캡슐화와 독립성을 보장합니다.
주요 키워드는 다음과 같습니다
- module: 모듈 선언에 사용.
- requires: 모듈 간 의존성 정의.
- exports: 외부에 공개할 패키지 지정.
- opens: 리플렉션을 통해 접근 가능한 패키지 지정.
- uses: 서비스 인터페이스 선언.
- provides … with: 서비스 구현체 제공 선언.
이 내용은 이론적인 내용보다 예시로 이해하는게 더 빠른 이해가 가능합니다.
예를 들어 아래와 같은 구조의 서비스가 있다고 가정해봅시다.
src/
├── com.example.core/
│ ├── CoreService.java
├── com.example.api/
│ ├── ApiService.java
├── com.example.external/
│ ├── ExternalService.java
[CoreService.java]
package com.example.core;
import com.example.api.ApiService;
import com.example.external.ExternalService;
public class CoreService {
public void run() {
ApiService apiService = new ApiService();
ExternalService externalService = new ExternalService();
System.out.println(apiService.getApiData());
System.out.println(externalService.getExternalData());
}
}
[ApiService.java]
package com.example.api;
public class ApiService {
public String getApiData() {
return "API Data";
}
}
[ExternalService.java]
package com.example.external;
public class ExternalService {
public String getExternalData() {
return "External Data";
}
}
위의 구조에서는 크게 2가지 문제, 1가지 어려움이 발생할 수 있습니다.
- 클래스패스 충돌: 동일한 이름의 클래스를 가진 다른 라이브러리 사용 시 충돌 가능.
- 캡슐화 부족: 모든 패키지가 전역적으로 노출되므로 외부에서 내부 구현에 쉽게 접근이 가능.
- 의존성 불명확: 코드만 보고 어떤 의존성이 필요한지 파악이 어려움.
위의 예시를 module-info를 사용하여 Java 9부터는 아래와 같이 변경할 수 있습니다.
src/
├── com.example.core/
│ ├── module-info.java
│ ├── CoreService.java
├── com.example.api/
│ ├── module-info.java
│ ├── ApiService.java
├── com.example.external/
│ ├── module-info.java
│ ├── ExternalService.java
[core/module-info.java]
module com.example.core {
requires com.example.api; // API 모듈 의존성 선언
requires com.example.external; // External 모듈 의존성 선언
}
[api/module-info.java]
module com.example.api {
exports com.example.api; // 외부에 ApiService를 공개
}
[external/module-info.java]
module com.example.external {
exports com.example.external; // 외부에 ExternalService를 공개
}
(그외 코드 동일)
만약 core/module-info.java 파일에 requires로 구현된 모듈이 없으면 CoreService는 다른 모듈에 의존하지 않으며 그 안에서 외부 모듈의 클래스를 사용할 수 없습니다. 즉, api나 external 모듈의 클래스나 기능을 사용할 수 없습니다.
여기서 개인적으로
'굳이 module-info 라는 새로운 파일을 만들어서 프로젝트 구조를 더 복잡하게 만들 필요가 있을까?'
라는 생각이 들었습니다.
여기서 자바 모듈 시스템의 큰 장점이 드러납니다.
자바 애플리케이션을 실행할 때, 필요한 클래스들을 메모리에 로딩하는 과정이 있습니다.
이를 클래스 로딩이라고 부르며, 이 클래스 로딩 과정은 애플리케이션의 시작 시간에 큰 영향을 미칩니다.
전통적인 자바 프로그램에서는 모든 클래스들이 전역적으로 공유되기 때문에, 애플리케이션 구동 시
이 모든 클래스들을 한꺼번에 메모리에 로딩해야 했습니다.
하지만 자바 모듈 시스템이 도입되면서 클래스들을 모듈 단위로 관리하므로,
애플리케이션이 실행될 때 필요한 모듈만 선택적으로 로딩할 수 있게 됩니다.
이렇게 하면 불필요한 클래스들을 메모리에 올리지 않아도 되기 때문에,
애플리케이션의 시작 시간과 메모리 사용량을 크게 줄일 수 있습니다.
Try-with-resources
Try-with-resources는 Java에서 리소스를 자동으로 닫아주는 기능입니다.
try 블록 내에서 사용한 리소스를 정상적으로 닫도록 보장하므로, 예외가 발생하더라도 리소스를 안전하게 관리할 수 있습니다.
이를 사용하려면 리소스가 AutoCloseable 또는 java.io.Closeable 인터페이스를 구현해야 합니다.
Java 9 이전: 리소스를 try 블록 안에서 선언하고 사용합니다.
try (Scanner scanner = new Scanner(new File("testRead.txt"));
PrintWriter writer = new PrintWriter(new File("testWrite.txt"))) {
// ...
}
Java 9 이후: final 변수로 선언된 리소스를 try-with-resources에 직접 사용할 수 있습니다.
리소스를 try 블록 외부에 선언하고 try 문에서 바로 사용할 수 있으므로 코드가 더 간결해집니다.
final Scanner scanner = new Scanner(new File("testRead.txt"));
final PrintWriter writer = new PrintWriter(new File("testWrite.txt"));
try (scanner; writer) {
// ...
}
이러한 개선 사항은 코드를 간소화하여 가독성을 개선하고 리소스를 선언하고 관리하는 방식이 간단해져 복잡한 코드 작성이 줄어듭니다.
또한, 예외가 발생하더라도 리소스를 자동으로 닫아주기 때문에 안전한 리소스 관리가 가능합니다.
변수의 범위도 필요한 곳으로 제한할 수 있어 불필요한 리소스 접근을 방지할 수 있습니다.
파일 작업, 데이터베이스 연결, 네트워크 소켓 등의 자원을 효율적으로 관리해야 하는 상황에서 특히 유용하게 사용할 수 있습니다.
Private Interface Methods
Private Interface Methods는 인터페이스 내부에서만 사용할 수 있는 private 메서드를 정의할 수 있게 해줍니다.
이 기능은 인터페이스 내부에서 코드 재사용성과 가독성을 높이기 위해 도입되었으며,
private 메서드는 구현 클래스나 외부 클래스에서 접근하거나 재정의 할 수 없습니다.
public interface MyInterface {
// 기본 메서드
default void publicMethod() {
privateMethod(); // private 메서드 호출
}
// private 메서드
private void privateMethod() {
System.out.println("Private method in interface.");
}
}
3. Java 10
Local Variable Type Inference
Java 10에서 도입한 로컬 변수 타입 추론 기능은 변수의 타입을 명시적으로 지정하지 않고도 선언할 수 있게 해줍니다.
컴파일러가 변수에 할당된 값에 따라 타입을 추론합니다.
public static void main(String[] args) {
var name = "John Doe"; // String 타입으로 추론
var age = 30; // int 타입으로 추론
var salary = 50000.0; // double 타입으로 추론
System.out.println("Name: " + name);
System.out.println("Age: " + age);
System.out.println("Salary: " + salary);
// 컴파일 오류: var는 초기화가 되어야 함
// var uninitializedVar;
}
또한, var는 새로운 클래스를 만들 때, 즉 객체를 생성할 때도 사용할 수 있습니다.
하지만 var는 변수 타입을 추론하기 때문에, 객체 생성 시 new 키워드를 사용한 클래스의 타입을 기반으로 타입을 추론합니다.
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void introduce() {
System.out.println("Hi, my name is " + name + " and I'm " + age + " years old.");
}
}
public class Test {
public static void main(String[] args) {
var person = new Person("John", 30); // 'person' 변수는 Person 타입으로 추론됩니다.
List<Person> people = List.of(person);
var get = people.getFirst();
get.introduce(); // "Hi, my name is John and I'm 30 years old."
}
}
토막 내용: 참고로 getFirst() 메소드는 Java 21에서 추가된 기능이다.
IDE에서도 자동으로 Person 클래스를 추론하는 것을 볼 수 있습니다.
4. Java 11
Local Variable Type in Lambda Expressions
Java 11부터 람다 표현식에서 로컬 변수의 타입 추론(var 타입)을 사용할 수 있게 되었습니다.
이를 통해 람다 표현식 내에서 변수의 타입을 var로 선언할 수 있으며,
코드 가독성을 높이면서도 정적 타이핑의 장점은 유지할 수 있습니다.
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add("Charlie");
names.forEach((var name) -> {
System.out.println("Hello, " + name);
});
위 코드에서 name 변수는 var를 사용하여 타입을 추론하게 됩니다.
이로 인해 코드가 간결해지며, 여전히 컴파일 시 타입 검사가 이루어집니다.
여기서 한가지 의문이 들 수 있습니다.
'Java 8 Lambda를 보면 명시적인 타입을 선언하지 않아도 컴파일러가 자동으로 타입을 추론해서 상관 없지 않나?'
예 맞습니다. 아래처럼 작성할 수 있습니다.
names.forEach((name) -> {
System.out.println("Hello, " + name);
});
가독성을 위해서라면 명시적인 타입이 더 명확할 수도 있죠.
names.forEach((String name) -> {
System.out.println("Hello, " + name);
});
하지만 여기서 더 복잡한 타입, 특히 제네릭 타입이나 익명 클래스 등을 사용하는 코드라면 어떨까요?
public static void printNamesWithMap(List<String> names, Map<String, List<Integer>> myMap) {
names.forEach((Map<String, List<Integer>> name) -> {
System.out.println("Hello, " + name);
});
}
public static void printNamesWithMap(List<String> names, Map<String, List<Integer>> myMap) {
names.forEach((var name) -> {
System.out.println("Hello, " + name);
});
}
위에 코드처럼 myMap이 어떤 타입인지 이미 아는데, 굳이 forEach안에 name의 타입을 적을 필요가 없습니다.
5. Java 12
String Indent and Transform
indent 메서드는 문자열의 각 줄 앞에 지정된 수만큼 공백을 추가하여 들여쓰기 효과를 만듭니다.
이 메서드는 새로운 문자열(String)을 반환하며, 원본 문자열은 변경되지 않습니다.
String original = "Hello\nWorld";
String indented = original.indent(4);
System.out.println(indented);
// 출력:
// Hello
// World
위 예시에서 indent(4)를 입력했기 때문에 각 줄 앞에 4칸의 공백이 추가되면서
줄바꿈 문자(\n)을 기준으로 줄마다 들여쓰기가 적용됩니다.
transform 메서드는 문자열에 변환 함수를 적용하여 결과를 반환합니다.
이 메서드는 문자열을 보다 함수형 프로그래밍 스타일로 다룰 수 있게 해줍니다.
String original = "Hello";
String transformed = original.transform(s -> s.toUpperCase());
System.out.println(transformed);
// 출력: HELLO
다른 예제를 살펴볼까요?
"다중 줄 문자열을 각 줄마다 4칸의 공백으로 들여쓰기를 하면서 첫 문자를 대문자로 변환해야 한다."고 해봅시다.
Java 12 이전 코드는 나름대로 Stream을 사용 다음과 같이 작성을 했을 것입니다.
public static void main(String[] args) {
String original = "Hello\nJava\nWorld";
// 1. 들여쓰기 (Indent)
String indented = Stream.of(original.split("\n"))
.map(line -> " " + line) // 각 줄에 4칸 공백 추가
.collect(Collectors.joining("\n"));
// 2. 변환 (Transform - 첫 글자를 대문자로 변환)
String transformed = capitalizeFirstLetter(indented);
System.out.println(transformed);
// 출력:
// Hello
// Java
// World
}
private static String capitalizeFirstLetter(String line) {
return line.substring(0, 1).toUpperCase() + line.substring(1);
}
위 코드를 Java 12 버전 기능으로 작성한다면 아래와 같이 리팩토링이 가능합니다.
public static void main(String[] args) {
String original = "Hello\nJava\nWorld";
String transformed = original.indent(4)
.transform(Test::capitalizeFirstLetter);
System.out.println(transformed);
// 출력:
// Hello
// Java
// World
}
private static String capitalizeFirstLetter(String line) {
return line.substring(0, 1).toUpperCase() + line.substring(1);
}
File Mismatch
Files.mismatch 메서드는 Java의 java.nio.file 패키지에 포함된 기능으로, 두 파일의 내용을 비교하여 차이가 발생하는 첫 번째 위치를 알려줍니다. 파일의 전체 내용을 읽을 필요 없이 효율적으로 파일 간의 차이점을 확인할 수 있도록 설계되었습니다.
import java.nio.file.Files;
import java.nio.file.Path;
public class Main {
public static void main(String[] args) throws Exception {
// 임시 파일 생성
Path filePath1 = Files.createTempFile("file1", ".txt");
Path filePath2 = Files.createTempFile("file2", ".txt");
// 파일에 문자열 쓰기
Files.writeString(filePath1, "I love Java");
Files.writeString(filePath2, "I love Technology");
// 파일 간의 차이점 확인
long mismatch = Files.mismatch(filePath1, filePath2);
// 결과 출력
System.out.println("Mismatch 위치: " + mismatch);
// 출력: Mismatch 위치: 7
}
}
파일 내용이 동일한 경우 음수(-1)를 리턴하며, 내용이 일치하지 않으면 두 파일의 첫 번째 차이가 발생한 바이트 위치를 리턴합니다.
6. Java 13
TextBlocks
TextBlocks는 Java 13에서 preview(미리 보기)로 도입되었고, Java 15에서 정식 기능으로 자리 잡았습니다.
여러 줄에 걸친 문자열을 더 읽기 쉽고 자연스럽게 정의할 수 있도록 도와주는 기능입니다. 기존처럼 문자열을 연결하거나 이스케이프 문자를 사용할 필요가 없습니다.
이기능은 JSON이나 SQL, 복잡한 포맷의 문자열을 출력할 때 유용합니다.
기존 JSON을 작성할 때는 아래와 같이 + 기호와 \n 을 사용하여 복잡하게 작성하였습니다.
String json = "{\n" +
" \"name\": \"John Doe\",\n" +
" \"age\": 30,\n" +
" \"email\": \"johndoe@example.com\",\n" +
" \"isMember\": true,\n" +
" \"address\": {\n" +
" \"street\": \"123 Main St\",\n" +
" \"city\": \"Springfield\",\n" +
" \"state\": \"IL\",\n" +
" \"zip\": \"62704\"\n" +
" }\n" +
"}";
하지만 TextBlocks을 이용하면 아래와 같이 깔끔하게 작성할 수 있습니다.
String json = """
{
"name": "John Doe",
"age": 30,
"email": "johndoe@example.com",
"isMember": true,
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip": "62704"
}
}
""";
출력한 결과는 위의 코드 둘다 아래와 같이 출력됩니다.
{
"name": "John Doe",
"age": 30,
"email": "johndoe@example.com",
"isMember": true,
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip": "62704"
}
}
7. Java 14
Yield Keyword
Java 12에서는 Switch문에 표현식을 도입하고 이를 미리보기 기능으로 출시했습니다.
이후 Java 13에서는 yield 키워드를 추가하여 switch 문에서 값을 반환할 수 있게 되었고,
Java 14에서는 switch 표현식이 표준 기능으로 정식 출시되었습니다.
예를 들어, 특정 요일에 대해 주말과 평일을 구분하는 코드를 작성할 수 있습니다.
public static String getDayType(String day) {
var result = switch (day) {
case "Monday", "Tuesday", "Wednesday", "Thursday", "Friday": yield "Weekday";
case "Saturday", "Sunday": yield "Weekend";
default: yield "Invalid day.";
};
return result;
}
day가 "Monday"일 경우, "Weekday"라는 문자열이 반환되고,
주말인 "Saturday"나 "Sunday"일 경우 "Weekend"가 반환되고, 그 외의 값은 "Invalid day."로 처리됩니다.
만약, Java 12 이전 버전에서 위와 같은 기능을 개발해야 한다면,
switch 문에서 yield를 사용할 수 없고, 표현식도 지원하지 않기 때문에,
break로 흐름을 제어하면서 구현했을 것입니다.
// Java 12 이전 버전
public static String getDayType(String day) {
String result;
switch (day) {
case "Monday":
case "Tuesday":
case "Wednesday":
case "Thursday":
case "Friday":
result = "Weekday";
break;
case "Saturday":
case "Sunday":
result = "Weekend";
break;
default:
result = "Invalid day.";
break;
}
return result;
}
8. Java 15
Garbage Collector Updates
Z Garbage Collector(ZGC)는 Java 11에서 실험적인 기능으로 도입되어, Java 15에서 자리잡았습니다.
ZGC는 낮은 지연 시간과 높은 확장성을 가진 가비지 컬렉터로 특히 대규모 데이터 애플리케이션, 예를 들어 머신러닝 애플리케이션에서 효율적으로 동작합니다. 이 가비지 컬렉터는 데이터를 처리하는 동안 가비지 컬렉션으로 인한 긴 정지 시간을 없애는 것을 보장합니다.
또한, Shenandoah GC라는 낮은 지연 시간의 가비지 컬렉터는 실험적인 단계에서 벗어나, Java 15부터는 표준 JDK의 일부로 포함되었습니다. 이 기능은 JDK 12에서 처음 도입되었고, 이제는 안정적인 기능으로 제공됩니다.
가비지 컬렉션은 JVM 메모리 구조와 밀접하게 연관되어 있어 그 내용이 매우 방대합니다.
따라서 이 주제에 대해서는 별도의 게시글을 작성할 예정입니다.
9. Java 16
Pattern Matching for instanceof
Java 14에서는 instanceof 연산자에 타입 검사 패턴(type test pattern)을 도입했습니다.
이 패턴은 타입을 검사하는 동시에 변수에 바인딩할 수 있는 기능을 제공합니다.
Java 15에서는 여전히 미리보기 기능으로 남아 있었고, Java 16부터 정식 기능에 포함되었습니다.
기존 instanceof 사용법
if (animal instanceof Cat) {
Cat cat = (Cat) animal;
cat.meow();
// 기타 고양이 관련 작업들
}
최신 instanceof 사용법
if (animal instanceof Cat cat) {
cat.meow();
}
이렇게 instanceof에서 타입 검사와 변수 바인딩을 동시에 할 수 있어 코드가 간결해지고 가독성이 향상됩니다.
Records
Java 14에서는 불변 데이터 객체를 쉽게 생성할 수 있도록 record라는 새로운 클래스 유형을 미리보기 기능으로 도입했습니다.
Java 15에 더욱 개선했고, Java 16 부터 record가 정식 기능에 포함되었습니다.
record는 불변 데이터를 보유하는 객체를 간결하게 정의할 수 있는 방법을 제공합니다.
기존 불변 데이터 객체 정의 (record 사용전)
public final class Book {
private final String title;
private final String author;
private final String isbn;
public Book(String title, String author, String isbn) {
this.title = title;
this.author = author;
this.isbn = isbn;
}
public String getTitle() {
return title;
}
public String getAuthor() {
return author;
}
public String getIsbn() {
return isbn;
}
@Override
public boolean equals(Object o) {
// ...
}
@Override
public int hashCode() {
return Objects.hash(title, author, isbn);
}
}
record 사용 후
public record Book(String title, String author, String isbn) {}
record를 사용하면 클래스 정의가 훨씬 간결해지며, equals, hashCode, toString 메서드 등이 자동으로 생성됩니다.
이처럼 record는 데이터 객체를 정의할 때 코드 양을 줄이고 가독성을 높이는 데 매우 유용합니다.
10. Java 17
Sealed Class
Sealed Class는 Java 15에 처음 도입된 기능으로, 상속 구조를 더욱 엄격히 관리할 수 있게 해줍니다.
특정 클래스만 상속을 허용하고, 그 외의 클래스는 상속하지 못하도록 제한하는 역할을 합니다.
// 상위 클래스 정의
public sealed class Shape permits Circle, Rectangle, Triangle, Square {...}
// 중간 클래스 정의
public sealed class Rectangle extends Shape permits TransparentRectangle, FilledRectangle {...}
위 예시에서 sealed 키워드를 사용해 클래스를 선언합니다.
permits 키워드는 어떤 클래스가 이 클래스를 상속할 수 있는지 명시합니다.
public final class Circle extends Shape {...} // 더 이상 상속 불가 (final)
public final class TransparentRectangle extends Rectangle {...} // 더 이상 상속 불가
public final class FilledRectangle extends Rectangle {...} // 더 이상 상속 불가
public non-sealed class Square extends Shape {...} // 추가 상속 가능 (non-sealed)
위 예시에서 Circle, Rectangle, Triangle, Square는 Shape의 허용된 하위 클래스로 명시적으로 지정되어 있습니다.
특히, Square 클래스는 non-sealed로 선언되어 있어 최종 클래스가 아니며, 현재 패키지 외부에서도 추가로 확장할 수 있습니다.
Sealed Class는 위처럼 정해진 타입의 데이터 구조를 정의할 때 사용해서 폐쇄적인 계층 구조를 모델링할 때 유용합니다.
예시 중 하나로 게임 캐릭터 클래스를 제한하는 경우를 볼 수 있겠네요.
// 최상위 캐릭터 클래스
public sealed class GameCharacter permits Warrior, Mage, Archer {}
// 전사 클래스
public final class Warrior extends GameCharacter {
private int strength;
public Warrior(int strength) {
this.strength = strength;
}
public void attack() {
System.out.println("Warrior attacks with strength: " + strength);
}
}
// 마법사 클래스
public final class Mage extends GameCharacter {
private int mana;
public Mage(int mana) {
this.mana = mana;
}
public void castSpell() {
System.out.println("Mage casts a spell using mana: " + mana);
}
}
// 궁수 클래스 (추가 확장이 가능하도록 비봉인)
public non-sealed class Archer extends GameCharacter {
private int agility;
public Archer(int agility) {
this.agility = agility;
}
public void shootArrow() {
System.out.println("Archer shoots an arrow with agility: " + agility);
}
}
여기서 Archer만 다른 클래스에와 다르게 확장할 수 있으므로 추가 클래스를 만들 수도 있습니다.
// 궁수를 확장한 고급 궁수 클래스
public class AdvancedArcher extends Archer {
private int precision;
public AdvancedArcher(int agility, int precision) {
super(agility);
this.precision = precision;
}
public void preciseShot() {
System.out.println("Advanced Archer performs a precise shot with precision: " + precision);
}
}
public class Game {
public static void main(String[] args) {
Warrior warrior = new Warrior(10);
warrior.attack();
Mage mage = new Mage(20);
mage.castSpell();
Archer archer = new Archer(15);
archer.shootArrow();
AdvancedArcher advancedArcher = new AdvancedArcher(15, 25);
advancedArcher.preciseShot();
}
}
// 출력
// Warrior attacks with strength: 10
// Mage casts a spell using mana: 20
// Archer shoots an arrow with agility: 15
// Advanced Archer performs a precise shot with precision: 25
이처럼 확장 가능한 클래스와 그렇지 않은 클래스를 구분함으로써 계층 구조를 유연하면서도 안정적으로 관리할 수 있습니다.
11. Java 21
Virtual Threads
기존 Java의 쓰레드 모델에서는 Java 쓰레드가 운영체제(OS) 쓰레드와 직접적으로 연결되어 있었습니다.
이 때문에 OS 쓰레디의 제약으로 인해 생성 가능한 쓰레드의 수가 제한적이었습니다.
특히, 짧은 시간 동안만 실행되는 쓰레드가 많아지면 OS에 부담이 커지고, 성능 비용도 높아질 수밖에 없었습니다.
Java 21에서 도입된 Virtual Threads는 이런 문제를 해결하기 위해 개발되었습니다.
Virtual Threads는 운영체제 쓰레드와 매핑되며, 이론적으로 무제한에 가까운 쓰레드 생성이 가능해졌습니다.
이를 통해 기존 쓰레드 모델의 제약을 없애고, 특히 고성능 서버 애플리케이션에서 요구하는 대량의 쓰레드를 효율적으로 처리할 수 있었습니다.
덕분에 서버 애플리케이션에서 흔히 사용하는 요청당 쓰레드(thread-per-request) 스타일의 코드를 부담없이 작성할 수 있게 되었습니다.
아래 예제는 Virtual Threads를 사용해 500,000개의 태스크를 처리하는 예제입니다.
JDK가 제한된 OS 쓰레드로 작업을 효율적으로 관리할 수 있습니다.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 500_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // executor.close()는 암묵적으로 호출되어 모든 작업을 기다립니다.
이 코드에서 Executors.newVirtualThreadPerTaskExecutor()를 사용하면 Virtual Threads가 태스크별로 생성됩니다.
500,000개의 태스크를 생성했지만, OS 쓰레드의 부담은 최소화됩니다.
사실 이렇게만 작성하면 Virtual Threads에 대해 이해하기 어렵습니다.
Java 21에 중요한 업데이트이므로 조금 더 자세히 알아보겠습니다.
(참고 - 우아한 기술블로그)
기존 Java의 스레드 모델은 Native Thread로, Java Native Interface(JNI)를 통해 커널 영역을 호출하고 매핑하여 작업을 수행했습니다.
Virtual Thread는 기존 Java의 스레드 모델과 달리, 플랫폼 스레드와 가상 스레드로 나뉩니다.
플랫폼 스레드 위에서 여러 Vurtual Thread가 번갈아 가며 실행되는 형태로 동작합니다.
(이 부분은 추후 조금더 공부하고 작성해야할듯...)
Sequenced Collections
Java 컬렉션 프레임워크에서 순서가 지정된 컬렉션을 다룬 때 불편했던 점들이 많았습니다.
그 중 대표적인 문제로 Encounter Order(접근 순서)를 유지하는 컬렉션에서
첫 번째와 마지막 요소에 접근하거나, 역순으로 순회하는 기능의 부재였습니다.
예를 들어, List의 경우
- 첫 번째 요소를 가져올 때: list.get(0)
- 마지막 요소를 가져올 때: list.get(list.size() - 1)
이렇게 일관성 없는 방식으로 접근해야 했습니다.
이와 같은 문제는 Collection 뿐만 아니라 Map에서도 발생했습니다.
이것을 해결하기 위해 Java 21부터 새로운 SequencedCollection 인터페이스가 추가되었습니다.
이 인터페이스는 순서가 지정된 컬렉션을 다루는 데 필요한 기능들을 표준화하고,
이를 통해 더 효율적이고 가독성 높은 코드를 작성할 수 있게 합니다.
Sequenced Collections는 아래 3가지 인터페이스를 추가했습니다.
- SequencedCollection
- SequencedSet
- SequencedMap
SequencedCollection 주요 기능
interface SequencedCollection<E> extends Collection<E> {
// 새로운 메서드
SequencedCollection<E> reversed(); // 컬렉션의 순서를 뒤집은 새로운 컬렉션을 반환합니다.
// Deque에서 승격된 메서드들
void addFirst(E); // 요소를 컬렉션의 앞에 추가합니다.
void addLast(E); // 요소를 컬렉션의 뒤에 추가합니다.
E getFirst(); // 첫 번째 요소를 가져옵니다.
E getLast(); // 마지막 요소를 가져옵니다.
E removeFirst(); // 첫 번째 요소를 제거합니다.
E removeLast(); // 마지막 요소를 제거합니다.
}
여기서 reversed에 대해 조금 더 자세히 알아봅시다.
기존(Java 21 이전) 버전에서는 리스트의 역순 정렬을 아래와 같이 작성했습니다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
System.out.println("원본 리스트: " + numbers);
// 기존 방식: Collections.reverse
Collections.reverse(numbers);
System.out.println("역순 리스트: " + numbers);
// 출력 결과
// 원본 리스트: [1, 2, 3, 4, 5]
// 역순 리스트: [5, 4, 3, 2, 1]
여기서 문제는 기존의 numbers의 원본을 수정하게 됩니다.
원본 리스트와 역순 리스트 둘다 필요하면 새로운 리스트를 만들고 복사해야하죠.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// 기존 방식: Collections.reverse
List<Integer> reversedNumbers = new ArrayList<>(numbers); // 원본 리스트 복사
Collections.reverse(numbers);
System.out.println("원본 리스트: " + numbers);
System.out.println("역순 리스트: " + reversedNumbers);
반면 Java 21부터는 아래와 같이 작성할 수 있습니다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
System.out.println("원본 리스트: " + numbers);
System.out.println("역순 리스트: " + numbers.reversed());
System.out.println("원본 리스트 변경(?): " + numbers);
원본 리스트: [1, 2, 3, 4, 5]
역순 리스트: [5, 4, 3, 2, 1]
원본 리스트 변경(?): [1, 2, 3, 4, 5]
이런식으로 원본을 변경하지 않고 새로운 뷰만 반환할 수 있습니다.
이러한 Sequenced Collections의 추가로 순서를 유지하는 컬렉션의 동작을 명확하게 정의하고 일관된 메서드로 유지보수가 쉬워집니다.
String Templates
기존의 Java의 문자열 조합은 불편함이 많았습니다.
String codingLanguage = "Java";
String sentence = codingLanguage + " is awesome!";
System.out.println(sentence); // 출력: Java is awesome!
"+" 연산자를 사용해서 문자열을 연결하거나
String codingLanguage = "Java";
String sentence = String.format("%s is awesome!", codingLanguage);
System.out.println(sentence); // 출력: Java is awesome!
String.format 메소드를 이용하거나
String codingLanguage = "Java";
StringBuilder builder = new StringBuilder();
builder.append(codingLanguage);
builder.append(" is awesome!");
System.out.println(builder); // 출력: Java is awesome!
문자열이 더 복잡한 경우는 StringBuilder를 사용하기도 했죠.
Java 21의 String Templates는 문자열 내부에서 변수나 표현식을 직접 사용할 수 있습니다.
(아직 preview 기능입니다.)
String codingLanguage = "Java";
String sentence = "${codingLanguage} is awesome";
System.out.println(sentence);
템플릿 안에서 간단한 연산이나 메서드 호출도 가능합니다.
String name = "Alice";
String message = "Hello, ${name.toUpperCase()}!";
System.out.println(message);
// 출력: Hello, ALICE!
String Templates를 활용하며 기존 방식의 번거로운 문자열 연결을 대체하고,
코드 가독성을 높이며, SQL 인젝션 같은 보안 문제를 줄이는데 도움을 줄 수 있습니다.
Record Patterns
Java 14에서 레코드(Records)가 도입되면서 데이터 중심 클래스 생성이 훨씬 간편해졌습니다.
레코드는 데이터를 전달하는 데만 집중하고, 보일러플레이트 코드를 최소화하는 방식으로 코드 작성이 쉬워졌죠.
이는 코드의 간결성과 가독성을 크게 향상시켰습니다.
Java 21에서는 레코드 패턴(Record Patterns)과 타입 패턴(Type Patterns)의 발전이 이루어졌습니다.
이제 이 두 가지는 중첩하여 사용할 수 있어, 데이터 조작을 더 선언적이고 조합 가능한 방식으로 처리할 수 있게 되었습니다.
Java 21 이전에는 레코드에서 개별 값을 추출하려면 레코드를 먼저 분해해야 했습니다.
이 과정이 다소 번거로웠고, 코드가 길어질 수 있었습니다.
하지만 이제 레코드 패턴과 타입 패턴을 사용하면 이러한 작업이 더 간단하고 효율적으로 변했습니다.
record Address(String city, String apartment) {}
static void printCityWithoutPatternMatching(Object obj) {
if (obj instanceof Address a) {
String city = a.city();
System.out.println(city);
}
}
static void printCityWithPatternMatching(Object obj) {
if (obj instanceof Address(String city, String apartment)) {
System.out.println(city);
}
}
printCityWithoutPatternMatching 메서드 (Java 21 이전 방식)
- 먼저 instanceof를 사용하여 객체가 Address 타입인지 확인합니다.
- 그런 후 a.city() 메서드를 호출하여 도시 이름을 가져옵니다.
- 이 방식은 잘 작동하지만 코드가 다소 길어지고 직관적이지 않을 수 있습니다.
printCityWithPatternMatching 메서드 (Java 21 이후 방식)
- instanceof와 패턴 매칭을 결합하여 더 간결하고 직관적인 코드를 작성합니다.
- 이제 Address 타입으로의 패턴 매칭을 바로 조건문 안에서 처리하고, city 값을 직접 추출할 수 있습니다.
- 이 방식은 코드가 더 간단하고 가독성이 뛰어나며, 명시적인 액세서 메서드 호출 없이 값을 추출할 수 있습니다.
기존 방식은 레코드를 확인하고 값을 추출하기 위해 보일러플레이트 코드가 필요했지만,
Java 21 이후에는 패턴 매칭을 사용해 보다 선언적이고 간결한 코드로 변경되었습니다.
12. 결론
정리하자면, Java 8부터 21까지의 변화들을 살펴보면서 이 언어가 얼마나 발전했는지 알 수 있었습니다.
람다 표현식, 스트림 API, 시일드 클래스, 레코드 등 여러 가지 멋진 기능들을 만나볼 수 있었습니다.
이러한 개선사항들은 Java가 현대적인 코딩 문제를 해결하는 데 더욱 적합한 언어로 만들어주었습니다.
Java는 계속해서 변화하고 발전하고 있으므로, 최신 업데이트를 따라가면 더 많은 것을 할 수 있게 됩니다.