Skip to main content

현대 JAVA 문법: 람다, 스트림, Optional, Record, 패턴 매칭

자바를 처음 배울 때는 보통 이렇게 코드를 쓴다. for문을 돌리고, if문으로 걸러 내고, 변수를 하나씩 바꾸고, 결과를 새 리스트에 담는다. 물론 이 방식도 틀리지 않는다. 오히려 기초를 익힐 때는 꼭 필요하다.

하지만 자바는 Java 8 이후를 지나며 꽤 많이 달라졌다. 현대 자바는 "어떻게 한 줄 한 줄 시킬까"보다 "무엇을 하고 싶은가"를 더 선명하게 표현하는 방향으로 진화했다.1 그래서 코드는 더 짧아지고, 의도는 더 또렷해지고, 실수할 여지도 줄어든다.

이번 장에서는 그 변화의 중심에 있는 다섯 가지를 배운다. 람다, 스트림, Optional, Record, 패턴 매칭이다. 이름만 보면 조금 낯설지만, 하나씩 뜯어 보면 생각보다 훨씬 실용적이다. 잘 익혀 두면 코드가 갑자기 "요즘 자바 같다"는 느낌을 내기 시작한다.

현대 JAVA 문법: 람다, 스트림, Optional, Record, 패턴 매칭

왜 현대 자바 문법이 중요한가

예전 스타일의 자바는 튼튼했지만 다소 장황했다. 익명 클래스는 길었고, 컬렉션 처리 코드는 반복문과 임시 변수로 가득했고, null 처리는 늘 불안했다. 데이터를 담는 작은 클래스 하나 만들려고 해도 생성자, getter, equals(), hashCode(), toString()을 줄줄이 써야 했다.

현대 자바 문법은 바로 이런 불편을 줄이기 위해 등장했다.

  • 람다는 "동작"을 값처럼 전달하게 해 준다.

  • 스트림은 컬렉션 처리를 선언적으로 바꿔 준다.

  • Optional은 값이 없을 수도 있음을 더 명확하게 표현한다.

  • Record는 데이터를 담는 클래스를 놀랄 만큼 간결하게 만든다.

  • 패턴 매칭은 타입 검사와 값 꺼내기를 더 읽기 좋게 만든다.

한마디로 요약하면 이렇다. 코드의 양은 줄이고, 코드의 뜻은 더 또렷하게 만든다.

버전 감각부터 잡자

이 장의 내용은 한 버전에 한꺼번에 들어온 것이 아니다. 그래서 어느 기능이 어느 시점부터 본격적으로 쓰이는지 감각을 잡아 두면 좋다.

  • Java 8: 람다, 스트림, Optional

  • Java 9: Optional::stream 사용 가능1

  • Java 16: Record 정식 도입2

  • Java 16: instanceof 패턴 매칭 정식 도입

  • Java 21: record pattern과 switch 패턴 매칭 정식 도입34

실무에서는 아직도 Java 17 LTS, Java 21 LTS를 많이 본다. 그래서 이 장은 "Java 8에서 시작해 Java 21까지 이어지는 현대 자바 감각"을 익히는 시간이라고 생각하면 좋다.

람다식: 메서드 대신 동작을 바로 넘기기

람다식은 간단히 말해 이름 없는 짧은 함수 표현식이다. 자바에서는 진짜 함수를 독립적으로 다루는 언어는 아니지만, 함수형 인터페이스를 통해 함수처럼 보이는 동작을 전달할 수 있다.

가장 단순한 모양은 이렇다.

(a, b) -> a + b

왼쪽은 매개변수, 화살표 오른쪽은 실행할 내용이다. 마치 "a와 b를 받아서 더해라"라고 한 줄로 적는 느낌이다.

예를 들어 두 수를 계산하는 동작을 인터페이스로 만들면 다음처럼 쓸 수 있다.

@FunctionalInterface
interface Calculator {
    int calculate(int a, int b);
}

public class Main {
    public static void main(String[] args) {
        Calculator add = (a, b) -> a + b;
        Calculator max = (a, b) -> Math.max(a, b);

        System.out.println(add.calculate(3, 5));
        System.out.println(max.calculate(3, 5));
    }
}

예전 방식이라면 익명 클래스를 길게 만들었겠지만, 람다는 핵심 동작만 남긴다. 그래서 읽는 사람의 눈이 덜 피곤하다.

여기서 중요한 말이 하나 나온다. 함수형 인터페이스다.

함수형 인터페이스: 람다가 앉을 의자

람다는 아무 데나 쓸 수 있는 문법이 아니다. 추상 메서드가 하나뿐인 인터페이스가 필요하다. 이것을 함수형 인터페이스라고 부른다.

@FunctionalInterface
interface Printer {
    void print(String text);
}

@FunctionalInterface는 꼭 붙여야 하는 것은 아니지만, 붙여 두면 인터페이스가 실수로 추상 메서드를 두 개 이상 갖게 될 때 컴파일러가 잡아 준다. 즉, 안전벨트 같은 역할이다.

자바는 자주 쓰는 함수형 인터페이스를 이미 많이 준비해 두었다. 특히 java.util.function 패키지가 중요하다.

  • Predicate<T>: 조건 검사, 결과는 boolean

  • Function<T, R>: T를 받아 R로 변환

  • Consumer<T>: 값을 받아 사용만 하고 반환은 없음

  • Supplier<T>: 값을 만들어서 반환

예를 들어 문자열 길이를 구하는 변환 동작은 이렇게 표현할 수 있다.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<String, Integer> lengthFinder = text -> text.length();
        System.out.println(lengthFinder.apply("JAVA"));
    }
}

이 코드는 "문자열을 받아 길이로 바꾸는 동작" 자체를 변수에 담은 셈이다. 처음엔 약간 신기하지만, 스트림과 만나면 왜 이 문법이 중요한지 바로 체감된다.

메서드 참조: 람다를 더 짧게 쓰는 방법

람다를 쓰다 보면 이런 순간이 온다.

name -> System.out.println(name)

이 코드는 사실 name을 받아서 그대로 println에 넘길 뿐이다. 즉, 새 로직을 만든 것이 아니라 기존 메서드를 연결만 하고 있다. 이럴 때는 메서드 참조를 쓸 수 있다.

System.out::println

이중 콜론 ::은 "이 메서드를 써라"라는 뜻으로 읽으면 쉽다.

예를 들면 다음과 같다.

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> names = List.of("김자바", "이코드", "박버그");
        names.forEach(System.out::println);
    }
}

메서드 참조는 보통 네 가지 형태를 자주 본다.

  • ClassName::staticMethod

  • object::instanceMethod

  • ClassName::instanceMethod

  • ClassName::new

예를 들어 생성자도 참조할 수 있다.

import java.util.ArrayList;
import java.util.function.Supplier;

public class Main {
    public static void main(String[] args) {
        Supplier<ArrayList<String>> maker = ArrayList::new;
        ArrayList<String> list = maker.get();
        list.add("modern java");
        System.out.println(list);
    }
}

람다와 메서드 참조의 관계를 아주 단순하게 말하면 이렇다. 새 동작을 써야 하면 람다, 이미 있는 메서드를 연결만 하면 메서드 참조다.

스트림: 데이터를 선언적으로 처리하기

이제 현대 자바의 꽃이라고 불리는 스트림으로 가 보자.

스트림은 컬렉션 자체가 아니다. List나 Set처럼 데이터를 저장하는 상자가 아니라, 데이터를 흘려 보내며 처리하는 파이프라인에 가깝다. 그래서 스트림을 배우면 반복문을 쓰는 방식이 조금 달라진다.

예전 방식은 대개 이런 식이다.

List<String> result = new ArrayList<>();
for (String name : names) {
    if (name.startsWith("김")) {
        result.add(name.toUpperCase());
    }
}

이 코드는 분명 동작한다. 하지만 "무엇을 하려는지"보다 "어떻게 돌리는지"가 먼저 보인다.

스트림을 쓰면 이렇게 바뀐다.

List<String> result = names.stream()
        .filter(name -> name.startsWith("김"))
        .map(String::toUpperCase)
        .toList();

이 코드는 읽는 순서대로 뜻이 보인다.

"names를 스트림으로 만들고 → 김으로 시작하는 것만 고르고 → 대문자로 바꾸고 → 리스트로 모아라"

바로 이 점이 현대 자바의 핵심 감각이다. "반복을 어떻게 돌릴까"보다 "필터링하고 변환해서 수집하자"가 먼저 보인다.1

스트림의 기본 구조

스트림은 보통 세 단계로 읽으면 된다.1

  • 생성: 어디서 데이터를 가져올까

  • 중간 연산: 필터링, 변환, 정렬 같은 가공

  • 최종 연산: 결과를 실제로 만들어 냄

예를 들어 주문 목록에서 서울 지역의 대기 주문 ID만 뽑는다면 다음처럼 쓸 수 있다.

import java.util.List;

record Order(long id, String region, String status, int amount) {}

public class Main {
    public static void main(String[] args) {
        List<Order> orders = List.of(
                new Order(1L, "SEOUL", "PENDING", 12000),
                new Order(2L, "BUSAN", "DONE", 18000),
                new Order(3L, "SEOUL", "PENDING", 9000),
                new Order(4L, "SEOUL", "DONE", 15000)
        );

        List<Long> ids = orders.stream()
                .filter(o -> "SEOUL".equals(o.region()))
                .filter(o -> "PENDING".equals(o.status()))
                .map(Order::id)
                .toList();

        System.out.println(ids);
    }
}

이 코드를 보면 반복문, 임시 리스트, 카운터 변수가 거의 사라졌다. 덕분에 비즈니스 규칙이 더 눈에 잘 들어온다.

자주 쓰는 스트림 연산

스트림에서 자주 보게 되는 핵심 연산만 먼저 익혀도 실전 체감이 크다.

  • filter(): 조건에 맞는 요소만 남긴다.

  • map(): 각 요소를 다른 값으로 바꾼다.

  • flatMap(): 중첩된 구조를 펼친다.

  • sorted(): 정렬한다.

  • distinct(): 중복을 제거한다.

  • count(), findFirst(), anyMatch(): 결과를 확정하는 최종 연산이다.

map()과 flatMap()은 특히 헷갈리기 쉽다.

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<List<String>> tagsByPost = List.of(
                List.of("java", "stream"),
                List.of("optional", "java"),
                List.of("record")
        );

        List<String> tags = tagsByPost.stream()
                .flatMap(List::stream)
                .distinct()
                .sorted()
                .toList();

        System.out.println(tags);
    }
}

map()은 "값 하나를 다른 값 하나로" 바꾸는 느낌이고, flatMap()은 "안에 한 겹 더 들어 있는 구조를 펴는 것"이라고 이해하면 된다. 상자를 상자째 옮기는 게 map()이라면, 상자를 열어서 내용물을 한 줄로 늘어놓는 게 flatMap()이다.

스트림의 중요한 특징 세 가지

스트림은 편리하지만, 성격을 모르면 이상하게 느껴질 때가 있다. 세 가지는 꼭 기억하자.

첫째, 원본 데이터를 바꾸지 않는다. 스트림은 보통 원본 컬렉션을 수정하는 대신, 가공된 결과를 새로 만든다.1

둘째, 한 번 쓰면 끝이다. 최종 연산까지 실행한 스트림은 다시 재사용할 수 없다.

셋째, 중간 연산은 지연된다. 즉, filter(), map() 같은 중간 연산은 최종 연산이 오기 전까지 바로 실행되지 않는다.1

이 지연 연산은 생각보다 중요하다. 예를 들어 아래 코드는 조건을 만족하는 첫 번째 값만 찾으면 멈춘다.

int result = List.of(1, 2, 3, 4, 5, 6).stream()
        .filter(n -> n % 2 == 0)
        .findFirst()
        .orElse(0);

모든 값을 끝까지 다 처리할 것 같지만, 실제로는 필요한 순간에 멈출 수 있다. 그래서 스트림은 보기 좋기만 한 것이 아니라, 경우에 따라 꽤 효율적이기도 하다.1

스트림을 쓸 때 주의할 점

스트림은 멋지지만 만능은 아니다.

로직이 너무 복잡한데 스트림 체인을 한 줄에 몰아넣으면 오히려 읽기 힘들어진다. 분기와 상태 변경이 많은 경우에는 for문이 더 솔직한 코드가 되기도 한다. 스트림은 "짧아서 좋은 문법"이 아니라 의도가 잘 드러날 때 좋은 문법이다.

또 하나, parallelStream()은 이름만 보면 왠지 자동 성능 향상 버튼 같지만 그렇지 않다. 데이터 크기, 연산 비용, 순서 보장 여부에 따라 오히려 손해일 수 있다.1 초보 단계에서는 "병렬도 있구나" 정도만 알고, 성능 측정 없이 남발하지 않는 편이 좋다.

Optional: null을 더 정직하게 다루기

자바를 배우다 보면 결국 NullPointerException을 만나게 된다. 아주 친절하지 않은 방식으로 말이다. "어딘가에 값이 없었어. 그런데 네가 그냥 썼네?"라는 뜻이다.

Optional은 바로 이 문제를 다룰 때 유용하다. Optional<T>는 값이 있을 수도 있고 없을 수도 있음을 타입으로 표현한다.

import java.util.Optional;

public class Main {
    public static void main(String[] args) {
        Optional<String> maybeName = Optional.of("JAVA");
        Optional<String> emptyName = Optional.empty();

        System.out.println(maybeName.orElse("기본값"));
        System.out.println(emptyName.orElse("기본값"));
    }
}

핵심은 Optional을 쓰면 "없을 수도 있음"이 코드에 드러난다는 점이다. 단순히 null을 몰래 넘겨놓는 것보다 의도가 훨씬 분명하다.

Optional을 제대로 쓰는 법

초보자가 가장 많이 하는 실수는 이것이다.

if (opt.isPresent()) {
    return opt.get();
}
return "기본값";

이렇게 쓰면 사실상 예전의 null 검사와 크게 다르지 않다. Optional의 장점을 거의 못 살린다.1

대신 Optional이 제공하는 메서드를 활용하는 편이 좋다.

import java.util.Optional;

public class Main {
    public static void main(String[] args) {
        Optional<String> maybeEmail = Optional.of("[email protected]");

        String email = maybeEmail
                .filter(text -> text.contains("@"))
                .map(String::toLowerCase)
                .orElse("[email protected]");

        System.out.println(email);
    }
}

여기서는 다음과 같은 흐름이 자연스럽게 연결된다.

"값이 있으면 → 이메일처럼 생겼는지 확인하고 → 소문자로 바꾸고 → 없거나 조건에 안 맞으면 기본값 사용"

즉, Optional도 스트림처럼 체이닝 사고방식과 잘 어울린다.

Optional에서 자주 쓰는 메서드

  • orElse(value): 값이 없으면 기본값 사용

  • orElseGet(supplier): 값이 없을 때만 계산해서 기본값 생성

  • orElseThrow(): 값이 없으면 예외 발생

  • ifPresent(consumer): 값이 있을 때만 실행

  • map(), filter(), flatMap(): 값이 있을 때만 안전하게 변환

예를 들어 반드시 있어야 하는 값이라면 이렇게 쓸 수 있다.

String name = findUserNameById(1L)
        .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

이 코드는 "없으면 안 되는 값"이라는 의도를 분명하게 보여 준다.

Optional과 스트림을 함께 쓰기

현대 자바에서는 Optional과 스트림이 자주 만난다. 특히 Java 9 이상에서는 Optional::stream을 사용할 수 있어서 훨씬 자연스러워졌다.1

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

public class Main {
    public static void main(String[] args) {
        List<Optional<String>> nicknames = List.of(
                Optional.of("javaking"),
                Optional.empty(),
                Optional.of("bughunter")
        );

        List<String> result = nicknames.stream()
                .flatMap(Optional::stream)
                .toList();

        System.out.println(result);
    }
}

비어 있지 않은 값만 쏙 빼서 한 줄로 모은다. 예전처럼 isPresent()를 검사하고 get()을 꺼내는 방식보다 훨씬 자연스럽다.1

Optional을 남용하면 안 되는 이유

Optional이 좋아 보여도 어디에나 붙이면 곤란하다. 보통은 반환 타입으로 쓸 때 가장 자연스럽다.

예를 들어 이런 경우는 괜찮다.

Optional<User> findById(long id)

반면 필드나 파라미터에 Optional을 남발하면 코드가 오히려 복잡해질 수 있다.

class User {
    private Optional<String> nickname; // 보통 권장되지 않음
}

이유는 단순하다. Optional은 "없을 수도 있는 결과를 돌려줄 때" 빛나는 도구이지, 모든 빈칸을 감싸는 만능 포장지가 아니기 때문이다.

Record: 데이터를 담는 클래스를 간결하게 만들기

자바에서 데이터를 담는 클래스는 아주 흔하다. 상품, 회원 요약 정보, 좌표, 요청 DTO, 응답 DTO 같은 것들 말이다. 문제는 이런 클래스가 종종 "데이터만 담는데 코드가 너무 길다"는 점이다.

Record는 이 문제를 시원하게 해결한다. Record는 데이터 전달용 클래스를 아주 짧게 선언하는 문법이다.2

public record Product(String code, String name, int price) {
}

이 한 줄만으로 자바는 다음을 자동으로 준비해 준다.2

  • 모든 값을 받는 생성자

  • 각 필드 접근자 code(), name(), price()

  • equals()

  • hashCode()

  • toString()

예전 같으면 꽤 많은 보일러플레이트 코드를 써야 했을 부분이 순식간에 끝난다.

Record의 특징과 장점

Record는 일반 클래스와 닮았지만 성격이 다르다. 핵심은 불변 데이터 표현에 잘 맞는다는 점이다.2

public class Main {
    public static void main(String[] args) {
        Product product = new Product("P100", "기계식 키보드", 89000);

        System.out.println(product.name());
        System.out.println(product);
    }
}

접근 메서드 이름도 getName()이 아니라 name()처럼 컴포넌트 이름 그대로다. 처음에는 낯설어도 금방 익숙해진다.

Record에도 메서드를 추가할 수 있다.

public record Product(String code, String name, int price) {
    public boolean isFree() {
        return price == 0;
    }
}

또한 생성 시점 검증도 가능하다.

public record Product(String code, String name, int price) {
    public Product {
        if (price < 0) {
            throw new IllegalArgumentException("가격은 0 이상이어야 합니다.");
        }
    }
}

이런 생성자를 콤팩트 생성자라고 생각하면 된다. 필드 할당 코드는 자동으로 처리되고, 우리는 검증 로직에 집중하면 된다.

Record는 언제 잘 맞고, 언제 덜 맞을까

Record는 다음과 같은 경우에 특히 잘 맞는다.

  • 응답 DTO

  • 요청 DTO

  • 좌표, 금액, 통계 결과 같은 값 객체

  • 읽기 중심의 간단한 데이터 묶음

반대로 상태를 자주 바꾸는 객체, 프레임워크 요구사항이 많은 객체, 상속 구조가 필요한 경우에는 일반 클래스가 더 자연스러울 수 있다. Record는 강력하지만, 모든 클래스를 record로 바꾸라는 뜻은 아니다.

쉽게 말해 record는 "성격 있는 주인공"보다는 "정보를 깔끔하게 담아 전달하는 메신저"에 가깝다.

패턴 매칭: 검사와 꺼내기를 한 번에

이제 마지막 주제다. 패턴 매칭은 처음 보면 어려워 보이지만, 사실은 꽤 소박한 불편을 해결한다.

예전 자바에서는 타입을 검사할 때 보통 이렇게 썼다.

if (obj instanceof String) {
    String text = (String) obj;
    System.out.println(text.length());
}

instanceof로 검사하고, 맞으면 다시 캐스팅한다. 약간 번거롭다.

패턴 매칭을 쓰면 이렇게 바뀐다.

if (obj instanceof String text) {
    System.out.println(text.length());
}

검사와 변수 선언이 한 번에 끝난다. 짧아졌을 뿐 아니라 읽기도 더 좋다. "obj가 String이면 text라는 이름으로 쓰겠다"가 바로 보인다.

switch와 패턴 매칭

패턴 매칭의 진짜 재미는 switch와 만날 때 더 크게 느껴진다. 특히 여러 타입을 나눠 처리할 때 코드가 단정해진다.

아래 예시는 JDK 21 기준이다.4

public class Main {
    static String describe(Object obj) {
        return switch (obj) {
            case null -> "값이 없습니다.";
            case String s -> "문자열 길이: " + s.length();
            case Integer i -> "정수 값: " + i;
            case Double d -> "실수 값: " + d;
            default -> "알 수 없는 타입";
        };
    }

    public static void main(String[] args) {
        System.out.println(describe("JAVA"));
        System.out.println(describe(10));
        System.out.println(describe(null));
    }
}

예전의 긴 if-else if-else 체인보다 훨씬 읽기 편하다. 타입별 처리 규칙이 세로로 깔끔하게 정리되기 때문이다.

Record pattern: record를 뜯어서 바로 쓰기

패턴 매칭이 record와 만나면 더 강력해진다. record는 컴포넌트 구조가 분명하므로, 안에 든 값을 바로 분해해서 쓸 수 있다.3

record Point(int x, int y) {}

public class Main {
    public static void main(String[] args) {
        Object obj = new Point(10, 20);

        if (obj instanceof Point(int x, int y)) {
            System.out.println("x = " + x + ", y = " + y);
        }
    }
}

예전 같으면 먼저 Point p로 받아 놓고 p.x(), p.y()를 호출했을 것이다. 이제는 그 과정을 한 번에 줄일 수 있다.

그리고 중요한 점 하나가 더 있다. null은 어떤 record pattern과도 매치되지 않는다.3

이 점은 실수 방지에 꽤 중요하다. record pattern이 있다고 해서 null까지 자동 처리해 주는 것은 아니다.

중첩 패턴: 안쪽 record까지 한 번에 꺼내기

record pattern의 매력은 중첩 구조에서 더 잘 드러난다.3

record Address(String city, String street) {}
record Member(String name, Address address) {}

public class Main {
    public static void main(String[] args) {
        Object obj = new Member("김자바", new Address("Seoul", "Teheran-ro"));

        if (obj instanceof Member(String name, Address(String city, String street))) {
            System.out.println(name + " / " + city + " / " + street);
        }
    }
}

겉의 Member를 확인하면서 안쪽 Address까지 곧바로 분해해 버린다. getter를 연달아 타고 들어가는 방식보다 구조가 훨씬 선명하게 보인다.

패턴 매칭을 왜 쓰는가

문법만 보면 "짧아졌네" 정도로 보일 수 있다. 하지만 본질은 더 크다.

패턴 매칭은 타입 확인, 캐스팅, 값 추출을 한 덩어리로 묶는다. 그래서 코드가 덜 장황해지고, 데이터 구조가 눈에 더 잘 들어온다. 특히 record와 함께 쓰면 "이 객체는 이런 모양의 데이터를 가진다"가 거의 문장처럼 읽힌다.

즉, 패턴 매칭은 단순한 축약 문법이 아니라 데이터 중심으로 읽히는 코드를 만드는 도구다.

예전식 자바와 현대식 자바를 비교해 보자

이 장의 핵심을 한 번에 느끼려면 비교가 가장 좋다.

예전식 자바는 종종 이런 분위기였다.

List<String> result = new ArrayList<>();
for (User user : users) {
    if (user != null && user.getAge() >= 20) {
        result.add(user.getName().toUpperCase());
    }
}

현대식으로 바꾸면 이렇게 읽힌다.

List<String> result = users.stream()
        .filter(java.util.Objects::nonNull)
        .filter(user -> user.age() >= 20)
        .map(User::name)
        .map(String::toUpperCase)
        .toList();

여기에는 여러 변화가 한꺼번에 들어 있다.

  • User를 record로 만들 수 있다.

  • 반복 대신 스트림 파이프라인으로 의도를 표현한다.

  • 메서드 참조를 써서 코드를 더 간결하게 만든다.

  • 필요하다면 Optional로 값 부재를 더 명확히 다룰 수 있다.

  • 타입 분기가 필요하면 패턴 매칭을 쓸 수 있다.

이제 현대 자바는 더 이상 "긴 객체지향 문법만 있는 언어"가 아니다. 객체지향 위에 함수형 스타일과 데이터 중심 문법이 자연스럽게 올라와 있다.

실무 감각으로 정리하는 사용 기준

배운 문법을 언제 쓰면 좋을지 감각으로 정리해 보자.

람다는 이벤트 처리, 정렬 기준, 컬렉션 가공 규칙처럼 짧은 동작 전달에 잘 맞는다.

스트림은 컬렉션에서 필터링, 변환, 집계를 할 때 빛난다. 다만 분기와 상태 변경이 지나치게 복잡하면 전통적인 반복문이 더 읽기 좋을 수 있다.

Optional은 없을 수도 있는 반환값을 표현할 때 유용하다. 하지만 필드와 파라미터에 습관처럼 붙이는 것은 보통 좋지 않다.

Record는 불변에 가까운 데이터 묶음에 적합하다. DTO, 값 객체, 조회 결과 등에 특히 잘 맞는다.

패턴 매칭은 타입에 따라 처리 로직이 갈리는 곳에서 읽기 좋은 코드를 만든다. 특히 record와 함께 쓰면 효과가 크다.

정리

이번 장에서는 Java 8 이후 자바를 현대 자바답게 만들어 주는 핵심 문법을 살펴보았다.

람다는 동작을 간결하게 표현하게 해 주고, 스트림은 데이터 처리를 선언적으로 바꿔 준다. Optional은 값이 없을 수 있음을 더 정직하게 드러내며, Record는 데이터를 담는 클래스를 훨씬 짧고 분명하게 만든다. 패턴 매칭은 타입 검사와 값 추출을 더 읽기 쉬운 코드로 바꿔 준다.

이 장의 내용을 한 줄로 묶으면 이렇다. 현대 자바는 더 적은 코드로 더 많은 뜻을 담게 해 준다.

앞으로 파일 입출력, 날짜와 시간, 정규식, 동시성 같은 주제를 배울 때도 이 문법들은 계속 등장할 것이다. 특히 스트림과 Optional, record는 실무 코드에서 자주 보이므로, 단순히 문법만 외우기보다 "왜 이 방식이 읽기 좋은가"를 함께 익혀 두는 것이 중요하다.

한때 자바는 말이 많은 언어라는 농담을 자주 들었다. 하지만 이제는 다르다. 현대 자바는 꼭 필요한 말만 하면서도, 꽤 똑똑하게 말한다.

참고

1CS Modern Java: Lambda, Stream & Optional

2JEP 395: Records - OpenJDK

3Record Patterns - Oracle

4JEP 441: Pattern Matching for switch - OpenJDK

책 목록

처음부터 실무까지 단계별로 배우는 JAVA 학습

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기