Chapter 14

Modern Java - record와 Stream

Java 25 LTS에서 var, record, switch expression, lambda, Stream, pattern matching을 점수 순위 예제로 함께 사용합니다.

난이도
초급 심화
예상 학습
60분
Java 기준
Java 25 LTS
최종 검토

전체 22개 Chapter 확장 구조 중 Chapter 14입니다.

기능 구분

정식 Java 25에서도 쓸 수 있는 이전 버전 기능

Java SE 정식 기능

var, record, switch expression, Stream, 패턴 매칭은 Java 25에서도 쓸 수 있는 정식 기능만 다룹니다.

이 Chapter의 예제는 Preview 또는 Incubator API 실행 옵션 없이 Java 25 LTS 정식 기능 기준으로 읽을 수 있습니다.

여러 플레이어 점수 블록을 Java Stream으로 필터링하고 순위대로 정렬하는 학습 이미지
Stream은 원본 데이터를 직접 바꾸기보다 필터, 정렬, 변환 단계를 연결해 새로운 결과 흐름을 만듭니다.

Learning goals

오늘 배울 내용

  • var를 읽기 쉬운 지역 변수에 사용하기
  • record로 불변 데이터 운반 객체 만들기
  • lambda와 Stream으로 필터·정렬하기
  • 정식 기능과 Preview 기능 구분하기

최신 문법은 목적이 있을 때 사용합니다

Modern Java 기능은 코드를 무조건 짧게 만들기 위한 장식이 아닙니다. 데이터의 의도를 분명히 하고 반복되는 틀을 줄일 때 가치가 있습니다. 이 Chapter는 Java 25 LTS에서 정식으로 지원되는 기능만 사용합니다.

record로 점수 데이터 표현하기

이름과 점수를 담아 전달하고 값으로 비교하는 객체에는 record가 잘 맞습니다.

import java.util.Comparator;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        var scores = List.of(
            new PlayerScore("Alex", 82),
            new PlayerScore("Robin", 95),
            new PlayerScore("Kai", 67),
            new PlayerScore("Mina", 88)
        );

        List<PlayerScore> ranking = scores.stream()
            .filter(score -> score.points() >= 80)
            .sorted(Comparator.comparingInt(PlayerScore::points).reversed())
            .toList();

        for (int index = 0; index < ranking.size(); index++) {
            var score = ranking.get(index);
            System.out.println((index + 1) + "위 " + score.name()
                + " - " + score.points());
        }
    }
}

record PlayerScore(String name, int points) { }

예상 출력:

1위 Robin - 95
2위 Mina - 88
3위 Alex - 82

record 선언만으로 생성자, 접근자 name()points(), 값 비교에 필요한 메서드가 만들어집니다. 각 구성 요소는 기본적으로 변경할 수 없는 final 상태입니다.

Stream 파이프라인 읽기

  1. stream()으로 데이터 처리 흐름을 시작합니다.
  2. filter로 80점 이상만 남깁니다.
  3. sorted로 점수 내림차순 정렬합니다.
  4. toList로 결과 List를 만듭니다.

원본 scores의 순서는 바뀌지 않습니다. 중간 연산은 최종 연산이 호출될 때 필요한 만큼 처리됩니다.

var를 쓸 때와 피할 때

var scores = List.of(...)처럼 오른쪽만 보아도 타입을 알 수 있으면 중복을 줄입니다. 반대로 var result = service.process(data)처럼 메서드 이름만으로 타입이 보이지 않으면 명시적인 타입이 읽기 쉽습니다.

var는 필드나 메서드 매개변수에 사용할 수 없고, 초깃값 없는 선언도 할 수 없습니다.

switch expression 복습

static String grade(int points) {
    return switch (points / 10) {
        case 10, 9 -> "S";
        case 8 -> "A";
        case 7 -> "B";
        default -> "C";
    };
}

switch가 직접 값을 반환하므로 별도 임시 변수와 break를 반복하지 않아도 됩니다.

pattern matching

타입 검사와 형 변환을 한 번에 표현할 수 있습니다.

static int textLength(Object value) {
    if (value instanceof String text) {
        return text.length();
    }
    return 0;
}

조건이 참인 범위 안에서 text를 String으로 안전하게 사용할 수 있습니다.

직접 해보기

  1. 90점 이상만 남도록 filter 조건을 바꿉니다.
  2. 이름 오름차순 정렬을 한 번 적용합니다.
  3. grade 메서드를 전체 예제에 추가해 각 순위 옆에 등급을 출력합니다.
정렬 힌트

Comparator.comparing(PlayerScore::name)을 sorted에 전달하면 이름 기준 오름차순이 됩니다.

버전 확인 습관

인터넷의 Java 예제에는 Preview 기능이나 더 최신 비-LTS 기능이 섞일 수 있습니다. 공식 Java SE 문서에서 기능의 정식 포함 버전을 확인하고, 프로젝트의 JDK와 빌드 설정 버전을 맞추세요.

Modern Java를 배우는 올바른 순서

Modern Java 기능은 오래된 문법을 모두 버리라는 신호가 아닙니다. 좋은 순서는 먼저 기본 반복문, 클래스, 컬렉션, 예외를 이해한 뒤 최신 기능이 어떤 반복을 줄여 주는지 확인하는 것입니다. record는 값을 담는 클래스를 짧게 만들지만 클래스의 책임을 대신 설계해 주지는 않습니다. Stream은 컬렉션 처리 흐름을 읽기 좋게 만들 수 있지만, 자료구조 선택이나 예외 정책을 대신 정해 주지는 않습니다.

현대 문법을 사용할 때는 세 가지 질문을 던지면 좋습니다.

  • 코드의 의도가 더 분명해지는가?
  • 팀의 JDK 버전과 빌드 설정에서 정식으로 지원되는가?
  • 디버깅과 테스트가 더 어려워지지는 않는가?

짧아진 코드가 항상 좋은 코드는 아닙니다. 특히 입문 단계에서는 같은 기능을 for문과 Stream으로 모두 작성해 보고, 어떤 쪽이 현재 문제를 더 잘 설명하는지 비교해 보는 연습이 도움이 됩니다.

record가 잘 맞는 데이터

record는 “값을 담아 전달하고 비교하는 객체”에 잘 맞습니다. 예를 들어 플레이어 이름과 점수, API 응답의 작은 항목, 좌표, 계산 결과처럼 생성 후 바꾸지 않는 데이터에 유용합니다. 일반 클래스로 같은 코드를 작성하면 필드, 생성자, 접근자, equals, hashCode, toString을 많이 써야 합니다. record는 이 반복을 줄입니다.

record Position(int x, int y) { }

public class Main {
    public static void main(String[] args) {
        Position first = new Position(3, 5);
        Position second = new Position(3, 5);

        System.out.println(first.x());
        System.out.println(first);
        System.out.println(first.equals(second));
    }
}

record의 접근자는 getX()가 아니라 구성 요소 이름과 같은 x()입니다. 두 record는 같은 구성 요소 값을 가지면 equals가 true입니다. 일반 객체에서 ==가 참조 비교였던 것과 대비해 보세요.

record에도 검증 로직을 넣을 수 있습니다. compact constructor를 사용하면 매개변수 목록을 반복하지 않고 값 검증을 작성할 수 있습니다.

record PlayerScore(String name, int points) {
    PlayerScore {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name은 비어 있을 수 없습니다.");
        }
        if (points < 0) {
            throw new IllegalArgumentException("points는 0 이상이어야 합니다.");
        }
    }
}

record는 불변 데이터에 적합하지만 모든 클래스를 대체하지 않습니다. 체력이 계속 변하고 행동이 많은 Player 같은 객체는 일반 클래스가 자연스럽습니다.

var는 타입을 숨기는 도구가 아닙니다

var는 지역 변수에서 타입을 추론하게 해 주는 문법입니다. 동적 타입이 아니며, 컴파일 시점에 타입이 정해집니다.

var name = "Alex";
// name = 10; // 컴파일 오류

오른쪽이 생성자나 리터럴처럼 명확하면 var가 중복을 줄입니다.

var scores = new ArrayList<PlayerScore>();
var now = Instant.now();

반대로 오른쪽만 보고 타입을 알기 어려운 경우에는 명시 타입이 낫습니다.

PlayerScore bestScore = findBestScore(scores);

여기서 var bestScore = findBestScore(scores);도 가능하지만, 메서드 이름만으로 반환 타입이 충분히 드러나지 않는다면 명시 타입이 읽는 사람을 돕습니다. var 사용 기준은 “타입을 덜 써도 의미가 선명한가”입니다.

Stream은 원본을 바꾸지 않는 처리 흐름입니다

Stream을 처음 배울 때 가장 중요한 감각은 원본 컬렉션과 결과 컬렉션이 다르다는 점입니다. filter, map, sorted 같은 연산은 원본 List를 직접 바꾸지 않습니다. 최종 연산인 toList, count, forEach 등이 호출될 때 처리 흐름이 실행됩니다.

List<PlayerScore> highScores = scores.stream()
    .filter(score -> score.points() >= 80)
    .toList();

위 코드는 80점 이상만 담은 새 List를 만듭니다. 원본 scores는 그대로입니다. 정렬도 마찬가지입니다.

List<PlayerScore> ranking = scores.stream()
    .sorted(Comparator.comparingInt(PlayerScore::points).reversed())
    .toList();

원본을 직접 정렬하려면 scores.sort(...)를 사용합니다. 어떤 방식이 맞는지는 의도에 따라 다릅니다. 원본 순서를 유지해야 하면 Stream으로 새 결과를 만드는 편이 안전합니다. 원본 자체가 정렬된 상태여야 한다면 List의 sort가 적절합니다.

map, filter, sorted를 말로 읽기

Stream 파이프라인은 메서드 이름을 말로 읽으면 이해하기 쉽습니다.

List<String> names = scores.stream()
    .filter(score -> score.points() >= 80)
    .sorted(Comparator.comparing(PlayerScore::name))
    .map(PlayerScore::name)
    .toList();

이 코드는 “점수가 80 이상인 항목만 남기고, 이름 기준으로 정렬한 뒤, 이름만 뽑아 List로 만든다”라고 읽을 수 있습니다. filter는 남길지 결정하고, sorted는 순서를 바꾸고, map은 각 요소를 다른 값으로 변환합니다.

Stream을 복잡하게 만드는 흔한 원인은 한 줄에 너무 많은 의도를 넣는 것입니다. 파이프라인이 길어지면 중간 결과 이름을 붙여 나누어도 됩니다.

var passedScores = scores.stream()
    .filter(score -> score.points() >= 80)
    .toList();

var ranking = passedScores.stream()
    .sorted(Comparator.comparingInt(PlayerScore::points).reversed())
    .toList();

조금 길어졌지만 디버깅은 쉬워집니다. 중간 결과를 출력하거나 테스트할 수 있기 때문입니다.

Optional을 결과 없음 표현으로 이해하기

Stream에서 findFirst, max, min 같은 메서드는 결과가 없을 수 있습니다. 이때 Optional을 반환합니다.

import java.util.Comparator;
import java.util.List;
import java.util.Optional;

public class Main {
    public static void main(String[] args) {
        List<PlayerScore> scores = List.of(
            new PlayerScore("Alex", 82),
            new PlayerScore("Robin", 95)
        );

        Optional<PlayerScore> best = scores.stream()
            .max(Comparator.comparingInt(PlayerScore::points));

        best.ifPresent(score -> System.out.println(score.name()));
    }
}

record PlayerScore(String name, int points) { }

Optional은 null 대신 “값이 있을 수도 있고 없을 수도 있다”는 사실을 타입으로 표현합니다. 값을 무조건 꺼내려고 get()을 먼저 쓰는 습관은 좋지 않습니다. 값이 없을 때 어떻게 할지 orElse, orElseThrow, ifPresent 등으로 분명히 표현하세요.

PlayerScore fallback = best.orElse(new PlayerScore("None", 0));

Optional을 모든 필드와 매개변수에 남용할 필요는 없습니다. 주로 메서드 반환에서 결과 없음 가능성을 표현할 때 유용합니다.

switch expression은 값을 만드는 분기입니다

기존 switch 문은 break 누락 실수가 잦았습니다. switch expression은 분기 결과를 값으로 반환할 수 있어 등급, 상태 메시지, 분류처럼 값을 만드는 로직에 잘 맞습니다.

static String rankLabel(int points) {
    return switch (points / 10) {
        case 10, 9 -> "최상위";
        case 8 -> "우수";
        case 7 -> "보통";
        default -> "연습 필요";
    };
}

여러 줄 로직이 필요하면 yield를 사용할 수 있습니다.

static String rewardMessage(int points) {
    return switch (points / 10) {
        case 10, 9 -> {
            int bonus = points - 80;
            yield "보너스 " + bonus + "점";
        }
        case 8 -> "기본 보상";
        default -> "보상 없음";
    };
}

분기가 값을 만든다는 의도가 분명할 때 switch expression은 읽기 좋습니다. 단순 조건 두세 개는 if가 더 자연스러울 수도 있습니다.

pattern matching은 안전한 형 변환을 돕습니다

instanceof pattern matching은 타입 확인과 형 변환을 한 번에 처리합니다. 예전 방식에서는 타입을 확인한 뒤 다시 캐스팅해야 했습니다.

static String describe(Object value) {
    if (value instanceof String text) {
        return "문자열 길이: " + text.length();
    }

    if (value instanceof Integer number) {
        return "정수 값: " + number;
    }

    return "알 수 없는 값";
}

패턴 변수 textnumber는 해당 조건이 참인 범위에서만 사용할 수 있습니다. 컴파일러가 안전한 사용 범위를 관리해 주므로 불필요한 캐스팅과 ClassCastException 위험이 줄어듭니다.

다만 Object를 많이 받아 타입별로 나누는 코드가 자주 나온다면 설계를 다시 볼 필요가 있습니다. 다형성으로 각 객체에게 행동을 맡길 수 있는 상황일 수도 있습니다. pattern matching은 안전한 도구이지만, 타입 분기를 남발하는 설계를 자동으로 좋게 만들지는 않습니다.

병렬 Stream은 측정 후 사용하기

Stream에는 parallelStream도 있습니다. 이름 때문에 빠를 것 같지만 항상 그렇지 않습니다. 데이터가 작거나, 각 작업이 가볍거나, 공유 자원에 접근하거나, 순서가 중요하면 오히려 느리거나 복잡해질 수 있습니다. 병렬 처리는 스레드와 분할 비용이 들기 때문입니다.

입문 단계에서는 일반 Stream으로 표현력을 익히고, 성능 문제는 실제 측정으로 판단하세요. “반복문보다 Stream이 빠르다” 또는 “Stream은 느리다”처럼 단정하기보다 데이터 크기, 연산 비용, JVM 최적화, 가독성을 함께 봐야 합니다.

이번 장의 정리

Modern Java의 핵심은 새 문법을 많이 쓰는 것이 아니라 의도를 더 선명하게 표현하는 것입니다. record는 불변 데이터 운반 객체를 간결하게 만들고, var는 지역 변수의 중복 타입을 줄이며, Stream은 컬렉션 처리 흐름을 연결해 보여 줍니다. Optional은 결과 없음 가능성을 타입으로 드러내고, switch expression과 pattern matching은 분기와 형 변환을 더 안전하게 표현합니다. 최신 기능도 기본 문법과 객체지향 판단 위에서 사용할 때 가장 강력합니다.

FAQ

자주 묻는 질문

var는 동적 타입인가요?

아닙니다. 컴파일러가 오른쪽 값으로 정적 타입을 추론하며 한 번 정해진 타입은 바뀌지 않습니다. 지역 변수에만 사용할 수 있습니다.

record는 모든 클래스를 대신하나요?

값 전달과 비교가 중심인 불변 데이터에 잘 맞습니다. 상태가 계속 변하고 복잡한 생명주기를 가진 객체에는 일반 클래스가 더 자연스럽습니다.

Stream을 사용하면 항상 더 빠른가요?

아닙니다. 표현력과 데이터 처리 흐름이 장점이며 작은 반복에서는 일반 for문이 더 단순할 수 있습니다. 병렬 Stream도 측정 없이 사용하면 안 됩니다.

작성·편집 ToolPado

콘텐츠 유형 Java 학습 가이드

Java 기준 Java 25 LTS

마지막 검토