1. 지원기간

java-support-roadmap

https://www.oracle.com/java/technologies/java-se-support-roadmap.html

  • 자바 11버전은 최대 2026년 9월까지 지원
  • 자바 17버전은 최대 2029년 9월까지 지원

2. Java 11 VS 17, 주요 변경사항

아래 예제 코드의 전체는 https://github.com/programming-starter/java-17-starter 이 링크에서 확인할 수 있습니다.

Text Blocks

문자열을 좀 더 읽기 좋게 표현할 수 있게 되었다. 특히 JSON과 같은 문자열을 표현할 때, 예전에는 다음과 같이 표현해야 했다.

{
  "name": "parker",
  "age": 30,
  "job": "Programmer"
}
@Test
void old_style() {
    var json = "{\n" +
            "  \"name\": \"parker\",\n" +
            "  \"age\": 30,\n" +
            "  \"job\": \"Programmer\"\n" +
            "}";
    
    System.out.println(json);
}

줄바꿈 표시 ‘\n’ 과 문자열을 합치는 ‘+’ 등 여러 보기에 불필요한 표현을 추가해야 한다.

하지만, Text Blocks를 사용하면 다음과 같이 매우 간단하고 읽기 편하게 표현할 수 있다.

@Test
void new_style() {
    var json =
            """
            {
              "name": "parker",
              "age": 30,
              "job": "Programmer"
            }
            """;
    System.out.println(json);
}

Text Blocks는 3개의 쌍따옴표를 사용한다. 이를 양쪽에 선언하고 그 안에 필요한 문자열을 표현한다. 위는 old_style 테스트와 같은 문자열 결과를 가져온다.

사용할 때, 한 가지 유의해야할 점은 전체 문자열 앞의 공백을 다루는 방법이다.

....{
....  "name": "parker",
....  "age": 30,
....  "job": "Programmer"
....}

위 처럼 Text Blocks에 선언한 문자열 전체 앞에 공백을 두고 싶다고 가정해보자. (위는 공백을 ‘.’으로 대신 표현하였음.)

이 경우 Text Blocks에서는 마지막 3개의 쌍따옴표를 기준으로 공백을 표현할 수 있도록 해준다.

@Test
void new_style_how_to_make_space() {
    var json =
            """
                {
                  "name": "parker",
                  "age": 30,
                  "job": "Programmer"
                }
            """;
    System.out.print(json);
    System.out.println("공백");
}

위 코드를 보면 마지막 3개의 쌍따옴표를 기준으로 JSON 표현이 앞 쪽으로 좀 더 밀려나서 공백을 만들었다. 위 테스트 코드를 실행하면, 다음과 같이 공백이 만들어진 것을 확인할 수 있다.

		{
      "name": "parker",
      "age": 30,
      "job": "Programmer"
    }
공백

여기서, 주의할 점은 마지막 3개의 쌍따옴표가 기준이 된다는 점이다. 처음 선언한 3개의 쌍따옴표는 영향을 주지 않는다.

@Test
void new_style_how_to_make_space() {
    var json =
                """
            {
              "name": "parker",
              "age": 30,
              "job": "Programmer"
            }
            """;
    System.out.print(json);
    System.out.println("공백");
}

위를 실행하면 전혀 공백이 생기지 않는 것을 확인할 수 있다.

Switch Expressions

기존의 switch 문은 정상적으로 동작시키기 위해서는 break 문이 필수로 필요하였다. 이는 실수할 여지가 있었고, “{}” 도 없이 여러 줄이 있다보니 개인적으로 문법상 일관성이 없어서 헷갈릴 때가 종종 있었다.

@Test
void old_style() {
    Food food = Food.BUL_GOGI;
    switch(food) {
        case KIMCHI, BUL_GOGI, BIBIMBAP:
            System.out.println("Korea Food");
            break;
        case SUSHI, RAMEN:
            System.out.println("Japan Food");
            break;
        case PAD_THAI, NASI_GORENG:
            System.out.println("Thailand Food");
            break;
        default:
            System.out.println("I don't know");
    }
}

위를 수행하면 정상적으로 아래 결과가 나올 것이다.

Korea Food

그런데 만약 실수로 break 문을 생략한다면 다음과 같은 예상하지 못한 결과를 볼 수 있다.

Korea Food
Japan Food
Thailand Food
I don't know

case 문에 정상적으로 진입하여 로직을 수행한 후, 빠져나가야하는데 break 문이 없어서 그 아래 case도 모두 실행되는 모습이다.

일본 음식으로 하면 아래와 같이 결과가 나올 것이다.

Japan Food
Thailand Food
I don't know

자바 17 버전에서는 break 문을 전혀 사용하지 않아도 되도록 변경되었다. 그리고 “{}”를 사용하지 않는 불규칙적인 문법도 없앴다.

@Test
void new_style() {
    Food food = Food.BUL_GOGI;
    switch (food) {
        case KIMCHI, BUL_GOGI, BIBIMBAP -> System.out.println("Korea Food");
        case SUSHI, RAMEN -> System.out.println("Japan Food");
        case PAD_THAI, NASI_GORENG -> System.out.println("Thailand Food");
        default -> System.out.println("I don't know");
    }
}
Korea Food

위는 old_style 테스트를 그대로 변경된 switch 표현식으로 나타낸 모습이다. ‘→’ 키워드를 통해 람다식으로 익숙한 문법 표현을 사용하였고, break 문도 필요없어서 단순하고 읽기 좋아졌다.

만약, switch 문 결과를 반환값으로 사용한다면 다음과 같이 구현할 수 있다.

@Test
void new_style_return() {
    Food food = Food.BUL_GOGI;
    String result = switch (food) {
        case KIMCHI, BUL_GOGI, BIBIMBAP -> "Korea Food";
        case SUSHI, RAMEN -> "Japan Food";
        case PAD_THAI, NASI_GORENG -> "Thailand Food";
        default -> "I don't know";
    };
    System.out.println(result);
}
Korea Food

case 문 안에 여러 개의 로직을 구현하고 싶은 경우는 다음과 같이 일관된 “{}”를 사용할 수 있다. 그에 더해 새로운 키워드인 yield 라는 키워드로 반환값을 나타내야 한다.

@Test
void new_style_multiple_login_in_case() {
    Food food = Food.BUL_GOGI;
    String result = switch (food) {
        case KIMCHI, BUL_GOGI, BIBIMBAP -> {
            System.out.println("current food: " + food);
            yield "Korea Food";
        }
        case SUSHI, RAMEN -> {
            System.out.println("current food: " + food);
            yield "Japan Food";
        }
        case PAD_THAI, NASI_GORENG -> {
            System.out.println("current food: " + food);
            yield "Thailand Food";
        }
        default -> {
            System.out.println("invalid food: " + food);
            yield "I don't know";
        }
    };
    System.out.println(result);
}
current food: BUL_GOGI
Korea Food

yield 키워드만을 사용해서 반환값을 나타낼 수도 있다.

@Test
void new_style_only_using_yield() {
    Food food = Food.BUL_GOGI;
    String result = switch (food) {
        case KIMCHI, BUL_GOGI, BIBIMBAP:
            yield "Korea Food";
        case SUSHI, RAMEN:
            yield "Japan Food";
        case PAD_THAI, NASI_GORENG:
            yield "Thailand Food";
        default:
            yield "I don't know";
    };
    System.out.println(result);
}
Korea Food

Records

Records는 변경불가능한 데이터 클래스(흔히, Value Object(VO)라고 불림)를 간단하게 만들 수 있도록 도와주는 문법이다. 특히, 클래스를 생성하기 위해 필요했던 생성자, getters, hashCode, equals, toString 등 반복적인 작업을 할 필요 없도록 편의성을 제공해주고 있다. (Lombok으로 이를 대체했지만, 이제는 Lombok 의존성도 필요하지 않을 수 있다.)

예를 들어, x 좌표와 y 좌표를 갖는 Point 객체를 만들어보자. 기본 클래스를 사용한다면 아래와 같이 구현할 수 있다.

public class PointClass {
    private final int x;
    private final int y;

    public PointClass(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PointClass point = (PointClass) o;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }

    @Override
    public String toString() {
        return "Point{" +
                "x=" + x +
                ", y=" + y +
                '}';
    }
}
  • x, y 필드는 final로 불변하도록 만들었다.
  • 생성자, getters, equals, hashCode, toString 을 선언해주었다.

PointClass 를 확인하기 위한 간단한 테스트 코드를 작성해보자.

@Test
void old_style() {
    PointClass point1 = new PointClass(0, 0);
    PointClass point2 = new PointClass(1, 1);
    System.out.println("point1 = " + point1);
    System.out.println("point2 = " + point2);
    System.out.println("Point 1 equals point 2? " + point1.equals(point2));

    PointClass pointCopy = new PointClass(point1.getX(), point1.getY());
    System.out.println("Point 1 equals its copy? " + point1.equals(pointCopy));
}
point1 = Point{x=0, y=0}
point2 = Point{x=1, y=1}
Point 1 equals point 2? false
Point 1 equals its copy? true

Record를 사용해서 Point를 만들어보자.

public record PointRecord(int x, int y) {
}

PointClass 에 비해 매우 간단히 나타낼 수 있다.

@Test
void new_style() {
    PointRecord point1 = new PointRecord(0, 0);
    PointRecord point2 = new PointRecord(1, 1);
    System.out.println("point1 = " + point1);
    System.out.println("point2 = " + point2);
    System.out.println("Point 1 equals point 2? " + point1.equals(point2));

    PointRecord pointCopy = new PointRecord(point1.x(), point1.y());
    System.out.println("Point 1 equals its copy? " + point1.equals(pointCopy));
}

위 테스트 코드를 실행하면, PointClass 테스트와 동일한 결과를 볼 수 있다.

Record도 생성자를 직접 구현할 수 있고, 여기서 파라미터에 대해 유효성 검사를 수행할 수 있다. 그리고 생성자 내에서는 Record의 필드들은 null 또는 초기값으로 세팅되어 있다.

public record Triangle(PointRecord p1, PointRecord p2, PointRecord p3) {

    public Triangle {
        System.out.println("Parameter p1=" + p1 + ", Field p1=" + this.p1());
        System.out.println("Parameter p2=" + p2 + ", Field p2=" + this.p2());
        System.out.println("Parameter p3=" + p3 + ", Field p3=" + this.p3());
    }
}
  • 삼각형 Triangle 레코드는 3개의 Point 레코드를 필드로 갖고 있다.
@Test
void record_validation_constructor() {
    Triangle triangle = new Triangle(
            new PointRecord(0, 0),
            new PointRecord(5, 5),
            new PointRecord(10, 0)
    );

    System.out.println("triangle = " + triangle);
}
Parameter p1=PointRecord[x=0, y=0], Field p1=null
Parameter p2=PointRecord[x=5, y=5], Field p2=null
Parameter p3=PointRecord[x=10, y=0], Field p3=null
triangle = Triangle[p1=PointRecord[x=0, y=0], p2=PointRecord[x=5, y=5], p3=PointRecord[x=10, y=0]]

Triangle 레코드를 생성할 때, Field는 모두 null 값으로 세팅되어 있는 것을 볼 수 있다.

Record의 또 다른 유용한 점은 로컬에서도 사용할 수 있다는 점이다.

@Test
void local_record_point() {
    List<Integer> xList = List.of(0, 1, 2, 3, 5);
    List<Integer> yList = List.of(0, 1, 2, 3, 5);

    record Point(int x, int y) { }

    for (int i = 0; i < xList.size(); i++) {
        Point point = new Point(xList.get(i), yList.get(i));
        System.out.println("point = " + point);
    }
}
point = Point[x=0, y=0]
point = Point[x=1, y=1]
point = Point[x=2, y=2]
point = Point[x=3, y=3]
point = Point[x=5, y=5]

Sealed Classes

sealed 키워드는 class와 interface에 선언할 수 있는 키워드이며, 상속 또는 구현하는 것을 제한하는 키워드이다.

sealed 키워드의 사용법과 특징은 다음과 같다.

  • sealed 키워드가 선언된 상위 클래스와 이를 상속 또는 구현하는 하위 클래스는 모두 같은 모듈 또는 같은 패키지에 위치해야 한다.
  • sealed 키워드가 선언된 상위 클래스는 permits 키워드로 자신을 상속 또는 구현하는 하위 클래스를 선언해야 한다.
  • sealed 키워드가 선언된 상위 클래스를 상속 또는 구현하는 하위 클래스는 final / sealed / non-sealed 셋 중 하나의 키워드를 반드시 선언해야 한다.
    • final : 해당 클래스는 더이상 상속 또는 구현할 수 없다.
    • sealed : 자신을 상속 또는 구현할 클래스를 지정할 수 있다.
    • non-sealed : 해당 클래스는 자유롭게 상속 또는 구현할 수 있다.

위를 지키지 않으면, 컴파일 에러가 발생한다.

package me.parker.sealedclasses;

// 최상위 Shape 클래스
public abstract sealed class Shape
        permits Circle, Rectangle, Square, WeirdShape {
}

Shape 클래스는 추상 클래스로 sealed 키워드를 선언하였고, 자신을 상속할 수 있는 클래스들을 permits 키워드로 선언하였다.

먼저, Shape 클래스를 상속한 후, 더이상 다른 클래스가 상속할 수 없도록 final 키워드를 선언한 예시를 보자.

package me.parker.sealedclasses;

public final class Circle extends Shape {
}

같은 패키지안에 있어야 한다는 것을 주의하자. Circle 클래스는 이제 더이상 상속할 수 없는 클래스이다.

두 번째로, sealed 키워드를 사용하여 다시 자신을 상속할 클래스들을 제한해보자.

package me.parker.sealedclasses;

public sealed class Rectangle extends Shape
        permits  TransparentRectangle, FilledRectangle {
}

public final class TransparentRectangle extends Rectangle {
}

public final class FilledRectangle extends Rectangle {
}

Rectangle 클래스는 다시 한 번 sealed 키워드로 자신을 상속할 클래스를 직접 선언하여 이 클래스들만 상속을 할 수 있도록 제한하였다.

마지막으로, 자유롭게 상속을 할 수 있도록 하는 non-sealed 키워드 예제를 보자.

package me.parker.sealedclasses;

public non-sealed class WeirdShape extends Shape {
}

public class MyShape extends WeirdShape{
}

WeirdShape 클래스는 non-sealed 키워드를 사용하였기 때문에 아무런 제한이 없어졌고, 이를 상속한 MyShape 은 더이상 final, sealed, non-sealed 키워드를 사용하지 않아도 된다.

한가지 주의할 점은 같은 패키지에 속해야 한다는 제한까지 없어진 것은 아니다. 만약 다른 패키지에서 WeirdShape 클래스를 상속하면, Shape 클래스에서 컴파일 오류가 발생한다.

다음과 같은 경우, 같은 패키지에 선언하지 않아 Class is not allowed to extend sealed class from another package 에러가 발생하는 경우이다.

package me.parker.another;

import me.parker.sealedclasses.Shape;

public final class AnotherShape extends Shape {
}
package me.parker.sealedclasses.subpackage;

import me.parker.sealedclasses.Shape;

public final class SubPackageShape extends Shape {
}

하위 클래스에 있어도 똑같이 에러가 발생한다.

Pattern matching for instanceof

클래스의 타입을 확인하기 위해서 자바는 instanceof 키워드를 제공해준다. 대부분 타입을 확인한 후, 그 타입인 경우 타입 변환을 해서 사용하는데, 다음과 같은 예제를 쉽게 볼 수 있을 것이다.

@Test
void old_style() {
    Object objectPoint = new PointClass(0, 0);
    if (objectPoint instanceof PointClass) {
        PointClass pointClass = (PointClass) objectPoint;
        System.out.println("x=" + pointClass.getX() + ", " + "y=" + pointClass.getY());
    }
}

objectPoint 의 타입이 PointClass라면, pointClass 변수에 형변환을 하여 사용할 수 있다.

Java 17에서는 형변환을 따로 변수 선언해서 하는 것을 한 번에 할 수 있도록 편의성을 제공해준다. 위와 같은 동작을 하는 로직을 아래와 같이 더욱 간단하게 구현할 수 있다.

@Test
void new_style() {
    Object objectPoint = new PointClass(0, 0);
    if (objectPoint instanceof PointClass pointClass) {
        System.out.println("x=" + pointClass.getX() + ", " + "y=" + pointClass.getY());
    }
}

instanceof 키워드를 사용하는 절 내부에 바로 클래스 타입과 변수를 선언하여 이를 바로 if 문 내에 사용할 수 있도록 하였다.

컴파일 시간에 타입에 대해 확신을 준다면, instanceof 키워드에 선언한 변수를 로컬 변수처럼 사용할 수 있다.

@Test
void new_style_local_variable() {
    Object objectPoint = new PointClass(0, 0);
    if (!(objectPoint instanceof PointClass pointClass)) {
        throw new IllegalStateException();
    }

    System.out.println("x=" + pointClass.getX() + ", " + "y=" + pointClass.getY());
}

PointClass 타입이 아닌 경우, 예외를 발생시켜 메서드 밖으로 나가도록 하였다. 이 예외가 발생하지 않았다면 objectPoint 는 클래스 타입이 PointClass로 확신할 수 있으므로, 위와 같이 로컬 변수로 바로 사용할 수 있도록 해준다.

Helpful NullPointerExceptions

NullPointerException이 발생한 변수가 어떤 변수인지 정확하게 에러 메시지에 표시해준다.

아래 코드는 NullPointerException을 강제로 만든 예제이다.

@Test
void throw_NullPointerException() {
    Map<String, PointClass> points = new HashMap<>();
    points.put("p1", new PointClass(0, 0));
    points.put("p2", new PointClass(1, 1));
    points.put("p3", null);  // 강제 NullPointerException 만들기

    for (Map.Entry<String, PointClass> entry : points.entrySet()) {
        System.out.println(entry.getKey() + ": " + "x=" + entry.getValue().getX() + "y=" + entry.getValue().getY());
    }
}

위 예제를 먼저 Java 11 버전에서 동작시켜보자.

p1: x=0y=0
p2: x=1y=1

java.lang.NullPointerException
	at me.parker.NullPointerExceptionTest.throw_NullPointerException(UrlTest.java:47)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1541)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1541)

Java 11에서는 단순히 NullPointerExceptiond이 발생한 라인 정보만 있다. 위 예제에서도 해당 라인에 여러 개의 접근 메서드가 있는데, 이런 경우 정확히 어디서 NullPointerException이 발생했는지 쉽게 알 수 없다.

Java 17에서 위 테스트 코드를 동작시켜보자.

p1: x=0y=0
p2: x=1y=1

java.lang.NullPointerException: Cannot invoke "me.parker.records.PointClass.getX()" because the return value of "java.util.Map$Entry.getValue()" is null

	at me.parker.NullPointerExceptionTest.throw_NullPointerException(NullPointerExceptionTest.java:19)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)

위 메시지를 보면 java.util.Map$Entry.getValue() 결과가 null 이라서, me.parker.records.PointClass.getX() 메서드를 호출할 수 없다고 정확하게 알려준다.

기타

Compact Number Formatting Support

NumberFormat 객체를 제공해주는데, 이는 사람이 읽기 좋은 숫자 표현으로 변환해준다. (유니코드 기반)

@Test
  void english_short() {
      NumberFormat fmt = NumberFormat.getCompactNumberInstance(
              Locale.ENGLISH, NumberFormat.Style.SHORT);
      System.out.println(fmt.format(10));
      System.out.println(fmt.format(1000));
      System.out.println(fmt.format(100000));
      System.out.println(fmt.format(1000000));
  }
10
1K
100K
1M
@Test
  void english_long() {
      NumberFormat fmt = NumberFormat.getCompactNumberInstance(
              Locale.ENGLISH, NumberFormat.Style.LONG);
			System.out.println(fmt.format(10));
      System.out.println(fmt.format(1000));
      System.out.println(fmt.format(100000));
      System.out.println(fmt.format(1000000));
  }
10
1 thousand
100 thousand
1 million
@Test
void korean_long() {
    NumberFormat fmt = NumberFormat.getCompactNumberInstance(
            Locale.KOREAN, NumberFormat.Style.LONG);
    System.out.println(fmt.format(10));
    System.out.println(fmt.format(1000));
    System.out.println(fmt.format(100000));
    System.out.println(fmt.format(1000000));
}
10
1
10
100

Day Period Support Added

@Test
void english() {
    DateTimeFormatter dtf = DateTimeFormatter.ofPattern("B").withLocale(Locale.forLanguageTag("EN"));
    System.out.println(dtf.format(LocalTime.of(8, 0)));
    System.out.println(dtf.format(LocalTime.of(13, 0)));
    System.out.println(dtf.format(LocalTime.of(20, 0)));
    System.out.println(dtf.format(LocalTime.of(23, 0)));
    System.out.println(dtf.format(LocalTime.of(0, 0)));
}
in the morning
in the afternoon
in the evening
at night
midnight
@Test
void english() {
    DateTimeFormatter dtf = DateTimeFormatter.ofPattern("B").withLocale(Locale.forLanguageTag("KO"));
    System.out.println(dtf.format(LocalTime.of(8, 0)));
    System.out.println(dtf.format(LocalTime.of(13, 0)));
    System.out.println(dtf.format(LocalTime.of(20, 0)));
    System.out.println(dtf.format(LocalTime.of(23, 0)));
    System.out.println(dtf.format(LocalTime.of(0, 0)));
}
오전
오후
저녁

자정

참고자료

JEPs in JDK 17 integrated since JDK 11

What’s New Between Java 11 and Java 17? - DZone