JDK 8 핵심 기능 총정리 — 람다, 스트림, 그리고 새로운 시대

왜 JDK 8인가?

2014년 출시된 JDK 8은 Java 역사상 가장 큰 패러다임 전환을 가져온 릴리스입니다. 람다 표현식과 Stream API의 도입으로 Java는 함수형 프로그래밍 스타일을 본격적으로 지원하게 되었고, 출시 후 10년이 넘은 지금까지도 많은 프로덕션 환경에서 사용되고 있습니다.

이 글에서는 JDK 8의 핵심 기능을 실행 가능한 예제와 함께 정리합니다.

Lambda 표현식과 Functional Interface

람다 표현식은 익명 클래스를 간결하게 대체하는 문법입니다. 메서드 하나만 가진 인터페이스(Functional Interface)의 구현을 화살표(→) 하나로 표현할 수 있습니다.

import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.function.Function;

public class LambdaExample {
    public static void main(String[] args) {
        List<String> languages = Arrays.asList("Java", "Python", "Go", "Rust", "JavaScript");

        // 익명 클래스 방식 (JDK 7 이전)
        languages.sort(new java.util.Comparator<String>() {
            @Override
            public int compare(String a, String b) {
                return a.length() - b.length();
            }
        });

        // 람다 표현식 방식 (JDK 8)
        languages.sort((a, b) -> a.length() - b.length());
        System.out.println("길이순 정렬: " + languages);
        // 출력: 길이순 정렬: [Go, Java, Rust, Python, JavaScript]

        // Predicate: 조건 검사용 함수형 인터페이스
        Predicate<String> isLong = s -> s.length() >= 5;
        // Function: 변환용 함수형 인터페이스
        Function<String, String> toUpper = String::toUpperCase;

        // 람다 조합: 5글자 이상인 언어를 대문자로 변환
        languages.stream()
            .filter(isLong)
            .map(toUpper)
            .forEach(System.out::println);
        // 출력:
        // PYTHON
        // JAVASCRIPT
    }
}

java.util.function 패키지에는 Predicate, Function, Consumer, Supplier 등 자주 쓰이는 함수형 인터페이스가 미리 정의되어 있습니다. 직접 만들 필요 없이 조합해서 사용하면 됩니다.

Stream API

Stream API는 컬렉션 데이터를 선언적으로 처리하는 파이프라인입니다. for 루프 대신 filter, map, reduce, collect 같은 연산을 체이닝하여 의도가 명확한 코드를 작성할 수 있습니다.

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class StreamExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // filter + map + collect: 짝수만 골라서 제곱한 리스트
        List<Integer> evenSquares = numbers.stream()
            .filter(n -> n % 2 == 0)        // 짝수 필터링
            .map(n -> n * n)                 // 제곱 변환
            .collect(Collectors.toList());   // 리스트로 수집
        System.out.println("짝수의 제곱: " + evenSquares);
        // 출력: 짝수의 제곱: [4, 16, 36, 64, 100]

        // reduce: 전체 합계 계산
        int sum = numbers.stream()
            .reduce(0, Integer::sum);
        System.out.println("합계: " + sum);
        // 출력: 합계: 55

        // groupingBy: 홀수/짝수 그룹핑
        Map<String, List<Integer>> grouped = numbers.stream()
            .collect(Collectors.groupingBy(
                n -> n % 2 == 0 ? "짝수" : "홀수"
            ));
        System.out.println("그룹핑: " + grouped);
        // 출력: 그룹핑: {홀수=[1, 3, 5, 7, 9], 짝수=[2, 4, 6, 8, 10]}

        // Parallel Stream: 멀티코어 병렬 처리
        long parallelSum = numbers.parallelStream()
            .mapToLong(Integer::longValue)
            .sum();
        System.out.println("병렬 합계: " + parallelSum);
        // 출력: 병렬 합계: 55
    }
}

parallelStream()은 내부적으로 Fork/Join 프레임워크를 사용하여 작업을 병렬로 분할합니다. 데이터가 충분히 크고 각 요소 처리가 독립적일 때 효과적이지만, 작은 데이터셋에서는 오히려 오버헤드가 클 수 있습니다.

Optional로 NPE 방지

NullPointerException은 Java 개발자의 오랜 숙적입니다. Optional은 “값이 없을 수도 있다”는 것을 타입 시스템으로 표현하여, null 체크 누락을 컴파일 타임에 방지합니다.

import java.util.Optional;

public class OptionalExample {
    // 사용자를 찾는 메서드 — 결과가 없을 수 있음
    static Optional<String> findUserEmail(String userId) {
        if ("admin".equals(userId)) {
            return Optional.of("admin@example.com");
        }
        return Optional.empty(); // null 대신 빈 Optional 반환
    }

    public static void main(String[] args) {
        // orElse: 값이 없으면 기본값 사용
        String email1 = findUserEmail("admin")
            .orElse("unknown@example.com");
        System.out.println("관리자 이메일: " + email1);
        // 출력: 관리자 이메일: admin@example.com

        String email2 = findUserEmail("guest")
            .orElse("unknown@example.com");
        System.out.println("게스트 이메일: " + email2);
        // 출력: 게스트 이메일: unknown@example.com

        // map + orElse: 변환 후 기본값
        String domain = findUserEmail("admin")
            .map(e -> e.split("@")[1])
            .orElse("도메인 없음");
        System.out.println("도메인: " + domain);
        // 출력: 도메인: example.com

        // ifPresent: 값이 있을 때만 실행
        findUserEmail("admin")
            .ifPresent(e -> System.out.println("발송 대상: " + e));
        // 출력: 발송 대상: admin@example.com
    }
}

Optional은 메서드 반환 타입에 사용하는 것이 원래 의도입니다. 필드나 메서드 파라미터에 Optional을 쓰는 것은 안티패턴이므로 피해야 합니다.

java.time API

기존 DateCalendar는 가변(mutable)이고 스레드 안전하지 않으며 API 설계가 직관적이지 않았습니다. JDK 8에서 도입된 java.time 패키지는 이 모든 문제를 해결합니다.

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;

public class DateTimeExample {
    public static void main(String[] args) {
        // LocalDate: 날짜만 (시간 없음)
        LocalDate today = LocalDate.now();
        LocalDate birthday = LocalDate.of(1995, 3, 15);

        // 두 날짜 사이 기간 계산
        Period age = Period.between(birthday, today);
        System.out.println("나이: " + age.getYears() + "세");
        // 출력: 나이: 31세

        // 날짜 연산 — 불변 객체이므로 새 인스턴스 반환
        LocalDate nextWeek = today.plusWeeks(1);
        long daysUntil = ChronoUnit.DAYS.between(today, nextWeek);
        System.out.println("다음 주까지: " + daysUntil + "일");
        // 출력: 다음 주까지: 7일

        // LocalDateTime: 날짜 + 시간
        LocalDateTime now = LocalDateTime.now();

        // DateTimeFormatter: 포맷팅
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH:mm");
        System.out.println("현재: " + now.format(formatter));
        // 출력: 현재: 2026년 04월 08일 14:30

        // 문자열 → LocalDate 파싱
        LocalDate parsed = LocalDate.parse("2026-12-25");
        System.out.println("크리스마스: " + parsed.getDayOfWeek());
        // 출력: 크리스마스: FRIDAY
    }
}

java.time의 모든 핵심 클래스(LocalDate, LocalDateTime, ZonedDateTime)는 불변(immutable) 이고 스레드 안전합니다. plusDays(), minusHours() 같은 연산은 항상 새 인스턴스를 반환하므로, SimpleDateFormat에서 발생하던 동시성 버그가 원천 차단됩니다.

성능 관련 변화

JDK 8에서는 API 외에도 JVM 레벨의 중요한 변화가 있었습니다.

PermGen → Metaspace: 기존 PermGen 영역이 제거되고 네이티브 메모리를 사용하는 Metaspace로 대체되었습니다. 클래스 메타데이터가 많은 대규모 애플리케이션에서 OutOfMemoryError: PermGen space 오류가 사라졌습니다.

Fork/Join 개선: parallelStream() 내부에서 사용하는 Fork/Join 프레임워크의 작업 분배 알고리즘이 최적화되어, 병렬 스트림의 성능이 향상되었습니다.

Nashorn JavaScript Engine: JVM 위에서 JavaScript를 실행하는 Nashorn 엔진이 도입되었습니다(이후 JDK 15에서 제거). 당시에는 서버 사이드 스크립팅에 활용되었지만, 현재는 GraalVM이 이 역할을 대체합니다.

정리

JDK 8은 Java에 함수형 프로그래밍 패러다임을 도입한 역사적인 릴리스입니다.

기능핵심 가치
Lambda간결한 함수 전달, 익명 클래스 대체
Stream API선언적 데이터 처리, 병렬화 용이
OptionalNPE 방지를 타입 시스템으로 보장
java.time불변/스레드 안전한 날짜 처리
MetaspacePermGen OOM 해결

JDK 8 이전과 이후의 Java는 사실상 다른 언어라고 해도 과언이 아닙니다. 람다와 스트림 없이는 현대 Java 코드를 읽을 수도, 쓸 수도 없습니다. 아직 JDK 8의 기능을 충분히 활용하지 않고 있다면, 가장 먼저 Stream API와 Optional부터 실무에 적용해보는 것을 권장합니다.

이 글이 도움이 되었나요?