JDK 16 핵심 기능 — Records 정식화와 instanceof 패턴 매칭

JDK 16 개요

Java 16은 2021년 3월에 출시된 비LTS 릴리스입니다. 프리뷰 단계를 거쳐 RecordsPattern Matching for instanceof가 정식 확정되었고, Stream.toList() 같은 편의 API가 추가되었습니다. 큰 혁신보다는 JDK 14~15에서 실험해온 기능들을 안정화하는 데 초점을 맞춘 버전입니다.

JEP기능상태
JEP 395Records정식 확정
JEP 394Pattern Matching for instanceof정식 확정
JEP 392jpackage (네이티브 패키징)정식 확정
JEP 338Vector API인큐베이터
JEP 376ZGC 동시 스레드 스택 처리개선
JEP 396내부 API 강한 캡슐화 (기본값)변경

Records — 불변 데이터 캐리어 (JEP 395)

Record는 불변 데이터를 담기 위한 전용 클래스입니다. equals(), hashCode(), toString(), 접근자 메서드를 컴파일러가 자동 생성합니다. 기존에 보일러플레이트 코드로 가득 차던 DTO(Data Transfer Object)를 깔끔하게 대체합니다.

import java.util.List;

// Record 정의 — 한 줄로 불변 데이터 클래스 완성
record Product(String name, int price, String category) {}

public class RecordExample {
    public static void main(String[] args) {
        // Record 인스턴스 생성
        Product laptop = new Product("맥북 프로", 2_490_000, "전자기기");
        Product phone = new Product("갤럭시 S25", 1_350_000, "전자기기");

        // 자동 생성된 접근자 메서드 (getter가 아니라 필드명과 동일)
        System.out.println(laptop.name());    // 맥북 프로
        System.out.println(laptop.price());   // 2490000

        // 자동 생성된 toString()
        System.out.println(laptop);
        // Product[name=맥북 프로, price=2490000, category=전자기기]

        // 자동 생성된 equals() — 모든 필드 값 비교
        Product another = new Product("맥북 프로", 2_490_000, "전자기기");
        System.out.println(laptop.equals(another)); // true

        // 리스트와 함께 활용
        List<Product> products = List.of(laptop, phone);
        products.stream()
            .filter(p -> p.price() > 2_000_000)
            .forEach(p -> System.out.println(p.name() + " → 고가 상품"));
        // 맥북 프로 → 고가 상품
    }
}

Record의 핵심 특징을 정리하면 다음과 같습니다.

  • final 클래스 → 상속 불가
  • 모든 필드는 private final → 불변
  • 정적 필드·메서드, 인스턴스 메서드 추가 가능
  • 커스텀 생성자(Compact Constructor)로 유효성 검증 가능
  • implements 가능, extends 불가 (암묵적으로 java.lang.Record 상속)

Pattern Matching for instanceof (JEP 394)

기존에는 instanceof 검사 후 별도의 캐스팅이 필수였습니다. JDK 16부터는 타입 검사와 변수 선언을 한 번에 처리합니다.

import java.util.List;

public class PatternMatchingExample {
    // 다양한 타입을 처리하는 메서드
    static String describe(Object obj) {
        // 기존 방식: instanceof + 캐스팅 반복
        // if (obj instanceof String) {
        //     String s = (String) obj;
        //     return "문자열(길이=" + s.length() + ")";
        // }

        // JDK 16 방식: 패턴 변수로 캐스팅 제거
        if (obj instanceof String s) {
            return "문자열(길이=" + s.length() + ")";
        } else if (obj instanceof Integer i && i > 0) {
            // 패턴 변수 + 조건 결합 가능
            return "양의 정수: " + i;
        } else if (obj instanceof int[] arr) {
            return "int 배열(크기=" + arr.length + ")";
        } else if (obj instanceof List<?> list && !list.isEmpty()) {
            return "비어있지 않은 리스트(크기=" + list.size() + ")";
        }
        return "알 수 없는 타입: " + obj.getClass().getSimpleName();
    }

    public static void main(String[] args) {
        System.out.println(describe("Hello Java 16"));   // 문자열(길이=14)
        System.out.println(describe(42));                  // 양의 정수: 42
        System.out.println(describe(-7));                  // 알 수 없는 타입: Integer
        System.out.println(describe(new int[]{1, 2, 3})); // int 배열(크기=3)
        System.out.println(describe(List.of("a", "b")));  // 비어있지 않은 리스트(크기=2)
    }
}

패턴 변수의 스코프는 “타입이 일치할 때만” 유효합니다. && 연산자와 결합하면 추가 조건도 자연스럽게 표현할 수 있습니다. 이 기능은 이후 JDK 17의 Sealed Classes + switch 패턴 매칭으로 확장됩니다.

Stream.toList() — 간결한 리스트 수집

Collectors.toList()를 매번 타이핑하는 것이 번거로웠다면 반가운 소식입니다. JDK 16부터 Stream.toList()가 추가되었습니다.

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class StreamToListExample {
    public static void main(String[] args) {
        // 기존 방식: Collectors.toList() 사용
        List<Integer> oldWay = IntStream.rangeClosed(1, 10)
            .boxed()
            .filter(n -> n % 2 == 0)
            .collect(Collectors.toList());
        System.out.println("기존: " + oldWay); // 기존: [2, 4, 6, 8, 10]

        // JDK 16 방식: Stream.toList() — 더 간결
        List<Integer> newWay = IntStream.rangeClosed(1, 10)
            .boxed()
            .filter(n -> n % 2 == 0)
            .toList();
        System.out.println("신규: " + newWay); // 신규: [2, 4, 6, 8, 10]

        // 주의: toList()는 수정 불가능한(unmodifiable) 리스트를 반환
        try {
            newWay.add(12); // UnsupportedOperationException 발생!
        } catch (UnsupportedOperationException e) {
            System.out.println("toList() 결과는 수정 불가능합니다");
        }

        // 수정 가능한 리스트가 필요하면 여전히 Collectors.toList() 사용
        List<Integer> mutableList = IntStream.rangeClosed(1, 5)
            .boxed()
            .collect(Collectors.toList());
        mutableList.add(6);
        System.out.println("수정 가능: " + mutableList); // 수정 가능: [1, 2, 3, 4, 5, 6]
    }
}
메서드반환 타입수정 가능null 허용
Collectors.toList()ArrayList가능가능
Stream.toList()수정 불가 List불가능가능
Collectors.toUnmodifiableList()수정 불가 List불가능불가능

Stream.toList()는 null 요소를 허용하지만, Collectors.toUnmodifiableList()는 null이 있으면 NullPointerException을 던집니다. 이 차이를 기억해두면 좋습니다.

Day Period Support

DateTimeFormatter에서 B 패턴을 사용하면 “오전”, “오후” 대신 “아침”, “낮”, “저녁”, “밤” 같은 세분화된 표현이 가능합니다.

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

public class DayPeriodExample {
    public static void main(String[] args) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("a hh:mm / B hh:mm")
            .withLocale(Locale.KOREAN);

        // 다양한 시간대에서 Day Period 확인
        LocalTime[] times = {
            LocalTime.of(6, 0),   // 아침
            LocalTime.of(12, 0),  // 낮
            LocalTime.of(18, 30), // 저녁
            LocalTime.of(23, 0)   // 밤
        };

        for (LocalTime time : times) {
            System.out.println(time + " → " + formatter.format(time));
        }
        // 06:00 → 오전 06:00 / 아침 06:00
        // 12:00 → 오후 12:00 / 낮 12:00
        // 18:30 → 오후 06:30 / 저녁 06:30
        // 23:00 → 오후 11:00 / 밤 11:00
    }
}

성능 및 도구 개선

ZGC 동시 스레드 스택 처리 (JEP 376): ZGC의 스레드 스택 처리가 세이프포인트에서 동시 처리 방식으로 변경되었습니다. GC 일시 정지 시간이 더욱 줄어들어 밀리초 미만의 정지 시간이 가능해졌습니다.

jpackage 정식화 (JEP 392): JDK 14에서 인큐베이터로 시작한 jpackage가 정식 도구가 되었습니다. Java 애플리케이션을 플랫폼별 네이티브 패키지(deb, rpm, msi, dmg)로 만들 수 있습니다.

# JAR를 Windows 설치 파일로 패키징
jpackage --input target/ \
         --name MyApp \
         --main-jar my-app.jar \
         --main-class com.example.Main \
         --type msi

Vector API (인큐베이터, JEP 338): SIMD 명령어를 활용한 벡터 연산 API가 처음 등장했습니다. 수치 계산 성능을 극적으로 개선할 수 있으며, 이후 JDK 버전에서 계속 발전합니다.

내부 API 강한 캡슐화 (JEP 396): --illegal-access 옵션의 기본값이 deny로 변경되었습니다. sun.misc.Unsafe 같은 내부 API에 직접 접근하는 코드는 명시적 --add-opens 없이 실행되지 않습니다.

정리

JDK 16은 “실험에서 안정으로” 가는 릴리스입니다. 핵심 포인트를 정리하면 다음과 같습니다.

  • Records: DTO, 값 객체를 한 줄로 정의. equals/hashCode/toString 자동 생성
  • Pattern Matching for instanceof: 캐스팅 보일러플레이트 제거. if (obj instanceof String s) 패턴 활용
  • Stream.toList(): collect(Collectors.toList()) 대신 간결하게 사용. 단, 반환 리스트는 수정 불가
  • jpackage: JAR을 네이티브 설치 파일로 패키징하는 공식 도구
  • Vector API: SIMD 연산의 첫 발걸음 (인큐베이터)
  • 내부 API 캡슐화: sun.misc.* 직접 접근 차단 기본값

JDK 16의 Records와 패턴 매칭은 다음 LTS인 JDK 17에서 Sealed Classes와 결합하여 더 강력한 타입 시스템을 형성합니다. JDK 16을 건너뛰더라도 이 두 기능은 반드시 익혀두는 것이 좋습니다.

이 글이 도움이 되었나요?