최근 Spring 공식 홈페이지에 Hello, Java 21이라는 글이 올라왔다.
내용은 요약하면, JDK 21이 출시되었고 새로운 LTS 버전이라는 것과 함께 최신 문법들을 소개한다.
이번에 나온 Java 21은 Spring Boot 기준으로 3.2부터 지원한다고 하는데..
Java 11 & Spring Boot 2.x
를 Java 17 & Spring Boot 3.x
로 바꾼 지 얼마 되지 않은 시점에서 조금 이르지 않나 싶어 당황스럽긴 하다.
물론 Spring Boot 3.x로 이전한 상태에서 당장 바로 3.2 버전이 나온다고 바꾸지는 않을 것 같다하더라고, 최신 문법에 관심을 가져서 나쁠리는 없다.
아래 내용에서는 JDK 1.5부터 JDK 21까지의 주요 문법 및 최신 문법에 대해 소개한다.
Java 11까지는 모두들 얼추 알고 있다면, 어떤 이유로 생긴 문법인지에 초점을 맞춰 읽어보고,
Java 11 이후 문법들에 대해서는 사용해보지 않은 문법이 있다고 하면 직접 프로젝트에 적용해보는 것도 좋은 학습 방법일거라고 생각이 든다.당장 최신 문법이 궁금한 것이라면 JDK 14부터 읽는 것을 추천합니다.
예제 코드와 새로운 내용들은 계속해서 노션에 작성하고 있습니다.
2004년 9월에 출시, 이때 부터 버전 중 앞의 1을 빼고 표기하기 시작했다. (= Java 5)
제네릭 도입 전
필드에 모든 종류의 객체를 저장하기 위해 자바에서 가장 상위 클래스인 Object 타입으로 선언
자식 객체 생성 시에 자동 타입 변환이 발생하는데, 빈도가 빈번하다면 프로그램 성능에 좋지 못하다.
public class Box {
private Object object;
public void set(Object object) { this.object = object; }
public Object get() { return object; }
}
제네릭 도입 후
제네릭을 사용함으로써 타입 변환이 일어나지 않는다.
public class Box<T> {
private T t;
public T get() { return t; }
public void set(T t) { this.t = t; }
}
타입을 제한한 제네릭 사용법
public class Example {
public static <T extends Number> int compare(T t1, T t2) {
double v1 = t1.doubleValue();
double v2 = t2.doubleValue();
return Double.compare(v1,v2);
}
}
예시
public enum Week{
MONDAY("월요일"),
TUESDAY("화요일"),
...
SUNDAY("일요일");
private String name;
Week(String name) { this.name = name; }
}
사용 예시
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
...
@Override
public void addInterceptors(final InterceptorRegistry registry){ ... }
}
사용자 정의 어노테이션을 만들 시 적용 대상을 지정할 수 있다.
@Target({ ElementType.FIELD, ElementType.METHOD })
public @interface AnnotationName {
}
마찬가지로, 유지 정책(언제까지 유지할 지)도 지정할 수 있다.
@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface AnnotationName {
}
2006년 12월 출시
2011년 7월 출시
변수를 선언할 때 동일한 타입으로 호출하고 싶다면, 타입을 명시하지 않고 <>
만 붙일 수 있다.
적용 전
List<Integer> list = new ArrayList<Integer>();
적용 후
List<Integer> list = new ArrayList<>();
2014년 3월 출시
오라클 인수 후의 첫 번째 버전이며, 2개 버전으로 나뉜다. (Oracle JDK, Open JDK)
Oracle JDK와 Open JDK
- 둘 다 각각 Java Development Kit이며, 본질적으로 코드 변경이 거의 없어 기능적으로 매우 유사.
- Java 11 이후부터는 Oracle JDK와 Open JDK의 빌드가 동일
- 차이점은 Oracle JDK는 라이선스 제한이 있고, Open JDK는 오픈소스
람다식은 익명 함수를 생성하기 위한 식으로, 객체 지향 언어보다는 함수 지향 언어에 가깝다.
람다식 적용 전
Comparator<Apple> beWeight = new Comparator<Apple>() {
public int compare(Apple a1, Apple a2) {
return a1.getWeight().compareTo(a2.getWeight());
}
}
람다식 적용 후
Comparator<Apple> byWeight =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
💡 그럼 람다식은 어디에 적용할 수 있을까?
함수형 인터페이스
라는 문맥에서 람다 표현식을 사용할 수 있다.
여기서 함수형 인터페이스는 하나의 추상 메서드를 지정하는 인터페이스를 의미한다.
기존의 함수형 인터페이스들
Java 8에 추가된 함수형 인터페이스들
메서드 참조는 특정 메서드만을 호출하는 람다의 축약형으로, 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다.
메서드 참조 적용 전, 람다식 사용 시
final Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException());
메서드 참조 사용
final Member member = memberRepository.findById(memberId)
.orElseThrow(MemberNotFoundException::new);
기존의 인터페이스에서는 메서드 정의만 가능하고, 구현은 할 수 없지만,
Java 8에 추가된 디폴트 메서드를 이용하면 구현 내용을 작성할 수 있다.
예시 코드
public interface Example {
public default void print() {
System.out.println("이동엽 파이팅");
}
}
Java를 사용하면 가장 흔히 발생하는 에러가 NullPointerException
이다.
이렇기에 매번 null 체크를 하기 위해서는 코드가 지저분해 질 수 있다.
Optional 클래스 동작 방식
- 값이 있으면? → 값을 감싸고,
- 값이 없으면? →
Optional.empty
메서드로 Optional을 반환한다.
스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다.
여기서 선언형은 데이터를 처리하는 임시 구현 코드 대신 질의로 표현하는 방식을 말한다.
또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬 처리할 수 있다.
이 외에도 파이프라이닝, 내부반복 등의 특징이 존재
스트림 사용 전
- 칼로리가 400이 넘지 않는 음식들을 오름차순으로 정렬하여, 음식의 이름들을 추출
List<Dish> lowCaloricDishes = new ArrayList<>();
for (Dish dish: menu) {
if (dish.getCalories() < 400) {
lowCaloricDishes.add(dish);
}
}
Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
public int compare(Dish dish1, Dish dish2) {
return Integer.compare(dish1.getCalories(), dish2.getCalories());
}
});
Lish<String> lowCaloricDishesName = new ArrayList<>();
for (Dish dish: lowCaloricDishes) {
lowCaloricDishesName.add(dish.getName());
}
스트림 사용 후
import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;
List<String> lowCaloricDishesName = menu.stream() // .parallelStream()은 병렬처리
.filter(d -> d.getCalories() < 400)
.sorted(comparing(Dish::getCalories))
.map(Dish::getName) // 음식명 추출
.collect(toList()); // 리스트에 저장
기존에 사용하던 HttpURLConnection을 대체할 패키지가 추가
예시 코드
final HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("songareeit.com"))
.headers("Content-Type", "text/plain;charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString("request body"))
.build();
takeWhile()
- 조건에 대해 참이 아닐 경우 바로 멈춘다.
- 이미 정렬되어 있다면
false
가 등장한 위치부터 반복을 중단할 수 있기 때문에 크기가 큰 Stream의 경우 많은 시간을 절약할 수 있다.
List<Integer> takeWhileList = numbers.stream()
.takeWhile(i -> i < 50)
.collect(toList()); // 12, 17, 29, 35, 41,
dropWhile()
takeWhile
의 정반대 작업으로, 처음으로false
가 등장하는 시점까지의 요소를 모두 버리고 남은 요소를 반환한다.
List<Integer> dropWhileList = numbers.stream()
.dropWhile(i -> i < 50)
.collect(toList()); // 50, 66, 72, 80
“ 인터페이스 내에 private
메서드 사용이 가능해졌다.
public interface MyInterface {
private static void myPrivateMethod(){
System.out.println("Yay, I am private!");
}
}
try
블럭에서 선언된 객체들에 대해서 try
문이 종료될 때 자동으로 자원을 해제해주는 기능이다.
Java 9 이후에는 try
블럭 밖에서 선언한 변수를 가져와 사용할 수 있다.
// Java 9 이전
void tryWithResourcesByJava7() throws IOException {
BufferedReader reader1 = new BufferedReader(new FileReader("test.txt"));
try (BufferedReader reader2 = reader1) {
// do something
}
}
// Java 9 ~
void tryWithResourcesByJava9() throws IOException {
BufferedReader reader = new BufferedReader(new FileReader("test.txt"));
try (reader) {
// do something
}
}
// Pre-Java 10
String myName = "Marco";
// With Java 10
var myName = "Marco"
Oracle JDK와 OpenJDK가 통합되었으며 Oracle JDK 가 유료 모델로 전환
"Marco".isBlank(); // 공백인지 판단
"Mar\nco".lines(); // 문자열을 줄 단위로 쪼갬
"Marco ".strip(); // 문자열 앞, 뒤의 공백 제거
(var x, var y) -> x.process(y) => (x, y) -> x.process(y)
//기존 방식
switch (day) {
case MONDAY:
case FRIDAY:
case SUNDAY:
System.out.println(6);
break;
case TUESDAY:
System.out.println(7);
break;
case THURSDAY:
case SATURDAY:
System.out.println(8);
break;
case WEDNESDAY:
System.out.println(9);
break;
}
//Java SE 12 부터의 방식
switch (day) {
case MONDAY, FRIDAY, SUNDAY -> System.out.println(6);
case TUESDAY -> System.out.println(7);
case THURSDAY, SATURDAY -> System.out.println(8);
case WEDNESDAY -> System.out.println(9);
}
스위치문이 값을 반환할 수 있도록
yield
키워드가 추가
var a = switch (day) {
case MONDAY, FRIDAY, SUNDAY:
yield 6;
case TUESDAY:
yield 7;
case THURSDAY, SATURDAY:
yield 8;
case WEDNESDAY:
yield 9;
};
줄 바꿈 문자가 자동으로 포함되는 문법
String str = """
This
is
text block
""";
12, 13에서 프리뷰로 제공되었던 스위치 표현식이 표준화(Standard)되었음
private final
필드를 자동 생성final class Point {
public final int x;
public final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
// 레코드 사용 시
record Point(int x, int y) { }
정확히 어떤 변수가
null
인지 자세히 나타낸다.
author.age = 35;
---
Exception in thread *"main"* java.lang.NullPointerException:
Cannot assign field *"age"* because *"author"* is null
JDK 14 이전 : 형변환 후 작업 수행
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.contains("songareeit"));
}
JDK 14 ~
if (obj instanceof String s) {
System.out.println(s.contains("songareeit"));
}
sealed
키워드를 이용해 선언하고, permits
키워드로 상속받을 서브 클래스를 명시한다.public sealed class Developer permits Dongyeop { ... }
자식 클래스는
sealed
,final
,non-sealed
세 종류로 나뉜다.
sealed
: 자식도 마찬가지로 상속받을 서브 클래스를 명시할 수 있다.final
: 더 이상 확장할 수 없다.non-sealed
: 모든 서브 클래스들에 의해 확장될 수 있다.
public sealed class DongyeopBaby1 extends Dongyeop permits DongyeopBaby11 { ... }
public final class DongyeopBaby2 extends Dongyeop { ... }
public non-sealed class DongyeopBaby3 extends Dongyeop { ... }
OpenJDK의 버전 관리가 Mercurial 에서 Git으로 바뀌었다.
아래와 같이 Unix 도메인 소켓에 연결할 수 있다. (프로세스 간 통신을 위한 기능)
socket.connect(UnixDomainSocketAddress.of(
*"/var/run/postgresql/.s.PGSQL.5432"*)
);
JDK 17 이전
static String formatter(Object o) {
String formatted = "unknown";
if (o instanceof Integer i) {
formatted = String.format("int %d", i);
} else if (o instanceof Long l) {
formatted = String.format("long %d", l);
} else if (o instanceof Double d) {
formatted = String.format("double %f", d);
} else if (o instanceof String s) {
formatted = String.format("String %s", s);
}
return formatted;
}
JDK 17 ~
static String formatterPatternSwitch(Object o) {
return switch (o) {
case Integer i -> String.format("int %d", i);
case Long l -> String.format("long %d", l);
case Double d -> String.format("double %f", d);
case String s -> String.format("String %s", s);
default -> o.toString();
};
}
API 문서에 예제 소스 코드 포함을 단순화하기 위해 JavaDoc의 표준 Doclet에
@snippet
태그를 도입
JDK 18 이전 :
finally
구문에서 리소스들을 Close 처리함
FileInputStream input = null;
FileOutputStream output = null;
try {
input = new FileInputStream(file1);
output = new FileOutputStream(file2);
... copy bytes from input to output ...
output.close(); output = null;
input.close(); input = null;
} finally {
if (output != null) output.close();
if (input != null) input.close();
}
위 방식의 문제점
- 예측할 수 없는 대기시간
- 항상 finalizer이 활성화됨
JDK 18 ~
finalizer
를 이용한 Close 처리는 오랫동안 사용되어 와서 전환하는 시간이 걸린다.
따라서 향후에는 종료를 비활성화하기 위한 --finalization=enabled
와 같은 옵션이 제공될 예정.
JDK 19 이전
record Point(int x, int y) {}
static void printSum(Object o) {
if (o instanceof Point p) {
int x = p.x();
int y = p.y();
System.out.println(x+y);
}
}
JDK 19 ~
record Point(int x, int y) {}
void printSum(Object o) {
if (o instanceof Point(int x, int y)) {
System.out.println(x+y);
}
}
사용 방식
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // executor.close() is called implicitly, and waits
Virtual threads are cheap and plentiful, and thus should never be pooled: A new virtual thread should be created for every application task. Most virtual threads will thus be short-lived and have shallow call stacks, performing as little as a single HTTP client call or a single JDBC query. Platform threads, by contrast, are heavyweight and expensive, and thus often must be pooled. They tend to be long-lived, have deep call stacks, and be shared among many tasks.
가상 스레드는 저렴하고 풍부하므로 풀링되어서는 안 됩니다. 모든 애플리케이션 작업에 대해 새로운 가상 스레드를 생성해야 합니다. 따라서 대부분의 가상 스레드는 수명이 짧고 얕은 호출 스택을 가지며 단일 HTTP 클라이언트 호출이나 단일 JDBC 쿼리만 수행합니다. 이와 대조적으로 플랫폼 스레드는 무겁고 비용이 많이 들기 때문에 종종 풀링되어야 합니다. 수명이 길고, 호출 스택이 깊으며, 여러 작업에서 공유되는 경향이 있습니다.
플랫폼 스레드란?
일반적으로 자바에서 스레드(java.lang.Thread
)라고 부르는 OS에서 생성한 스레드를 매핑해 JVM에서 사용하는 것을 말한다.
OS에 의해 스케줄링되기 때문에 스레드 간 전환을 위한 문맥 교환이 발생하기도 하고,
플랫폼 스레드를 생성하는 것이 OS에도 스레드를 하나 생성하는 것이기 때문에 동작이 매우 느려, 스레드 풀을 사용한다.
그러다보면 스레드 풀을 또 관리해야 하는데, 많은 양의 스레드를 만들다보면 메모리에 문제가 생길 수도 있다.
가상 스레드란?
가상 스레드는 OS의 스레드와 1:1로 대응되지 않는다. 위에서 플랫폼 스레드라고 부르던 것은 이제 캐리어 스레드라고 부른다.
캐리어 스레드는 포크조인 풀 안에 Worker Thread로 생성되어 스케줄링 되고, 각 Worker Thread들은 큐를 가진다. 이때 Task를 스케줄링하는데 가상 스레드 자체가 각각의 Task가 되어 큐에 들어가게 된다.
가상 스레드는 OS의 스레드와 대응되는 개념도 아니고, JVM에서 직접 스레드를 생성하기 때문에 생성 비용이 비싸지도 않다. 또한 크기가 자동으로 조절되기 때문에 스레드 풀의 개수를 관리할 필요도 없다.
기존 코드와의 호환성
VirtualThread의 부모 타입인 BaseVirtualThread는 Thread 클래스를 상속받아, 기존 Thread 구현의 변경 없이 사용할 수 있다.
Spring Boot Starter Web을 사용시 3.0버전 이상에서는 Embedded Tomcat을 사용한다.
이때, 가상 스레드를 사용하기 위해서는 아래와 같이 설정하면 된다.
다만 가상 스레드이므로 로그에 스레드 이름이 찍히지 않는 점을 알아두자.
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
기존 동시성을 사용한 코드
Future<Shelter> shelter;
Future<List<Dog>> dogs;
// ThreadPool의 크기가 3인 Executor 생성
try (ExecutorService executorService = Executors.newFixedThreadPool(3)) {
// get shelter
shelter = executorService.submit(this::getShelter);
// get dohs
dogs = executorService.submit(this::getDogs);
Shelter theShelter = shelter.get(); // Join the shelter
List<Dog> theDogs = dogs.get(); // Join the dogs
Response response = new Response(theShelter, theDogs);
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException(e);
}
만약 위 코드에서
getShelter()
가 실행되는 동안getDocs()
에서 예외가 발생한다면? 상황은 아래와 같다.
Structured Concurrency를 사용
// ShutdownOnFailure()은 해당 scope에서 문제가 발생할 경우 하위 작업을 모두 종료하는 생성자
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<Shelter> shelter = scope.fork(this::getShelter);
Future<List<Dog>> dogs = scope.fork(this::getDogs);
scope.join();
Response response = new Response(shelter.resultNow(), dogs.resultNow());
// ...
}
기타 사용법
// 예외를 전파
scope.throwIfFailed(e -> new RuntimeException("ERROR_MESSAGE"));
// 마감일 설정
scope.joinUntil(Instant.now().plusSeconds(1));
Virtual Threads가 표준화 되었음
JDK 21 이전 : 컬렉션 별 마지막 요소 구하는 방법
Sequenced Collections 사용 시
interface SequencedCollection<E> extends Collection<E> {
// new method
SequencedCollection<E> reversed();
// methods promoted from Deque
void addFirst(E);
void addLast(E);
E getFirst();
E getLast();
E removeFirst();
E removeLast();
}
_
)로 표시JDK 21 이전
int total = 0;
for (Order order : orders) {
// 사실상 사용되지 않는 order
if (total < LIMIT) {
...
total++
...
}
}
JDK 21 ~
int total = 0;
for (Order _ : orders) {
if (total < LIMIT) {
...
total++
...
}
}
학생들이 대규모 프로그램용으로 설계된 언어 기능을 이해할 필요 없이
첫 번째 프로그램을 작성할 수 있도록 Java 언어를 발전시키기 위함.
JDK 21 이전
- 처음 시작하는 사용자들에게
public
접근 제한,static
,args
등의 많은 지식을 요구한다.
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
JDK 21 ~
void main() {
System.out.println("Hello, World!");
}
자료 출처
잘 보고 가요.