NYO_O

Java 8 본문

BackEnd/Java

Java 8

NYO_O 2026. 5. 29. 16:24
반응형

지난 시간, 우리는 자바가 어떤 철학으로 설계되었는지, JVM이 어떻게 동작하는지를 살펴보았습니다. 자바라는 언어의 기반을 이해했다면, 이제 그 위에서 어떤 변화들이 있었는지를 살펴볼 차례입니다.

2026.05.29 - [BackEnd/Java] - Java란 무엇일까?

 

Java란 무엇일까?

"자바란 정확히 무엇인가?"자바가 어떤 철학으로 설계되었고, 어떤 구조로 동작하는지를 이해하고 나면, 이후에 살펴볼 버전별 변화들이 왜 그 방향으로 흘러왔는지가 훨씬 자연스럽게 보이기

ddangnyo.tistory.com

Java 8은 2014년에 출시된 버전으로, 자바 역사에서 가장 큰 변화 중 하나로 꼽힙니다. Lambda, Stream API, Optional, 새로운 날짜 API까지 지금도 매일 사용하는 기능들이 대부분 이 버전에서 등장했습니다.

오늘은 각 기능이 등장하게 된 배경과 의도를 중심으로 Java 8을 정리해 보겠습니다.

Java 8이 등장한 배경

2010년대 초반, 소프트웨어 업계에는 두 가지 큰 흐름이 있었습니다.

첫 번째는 멀티코어 CPU의 대중화였습니다. CPU 클럭 속도를 높이는 방식의 성능 향상이 한계에 부딪히자, 코어 수를 늘리는 방향으로 전환이 이루어졌습니다. 문제는 코어가 많아져도 코드가 병렬로 실행되도록 작성되어 있지 않으면 그 이점을 누리기 어렵다는 점이었습니다. 기존 Java의 반복문과 명령형 코드는 병렬 처리를 적용하기가 까다로웠습니다.

두 번째는 함수형 프로그래밍 언어의 부상이었습니다. Scala, Clojure, Haskell 같은 언어들이 주목받기 시작했고, 이들이 제공하는 간결한 데이터 처리 방식에 개발자들이 매력을 느끼기 시작했습니다. 반면 자바는 같은 작업을 하는데 훨씬 많은 코드를 작성해야 했습니다.

Java 8은 이 두 가지 압력에 동시에 응답한 버전입니다. 함수형 프로그래밍의 핵심 개념을 자바에 녹여내고, 그 위에서 병렬 처리를 자연스럽게 지원하는 방향을 택했습니다. 기존 자바 코드와의 하위 호환성을 유지하면서 언어의 패러다임을 확장했다는 점에서, 단순한 기능 추가 이상의 의미가 있는 버전입니다.

Lambda 표현식

사전 지식 — 익명 클래스(Anonymous Class)란? 이름 없이 일회성으로 사용하는 클래스입니다. 인터페이스를 구현하거나 클래스를 상속할 때, 별도의 클래스 파일을 만들지 않고 즉석에서 구현체를 작성하는 방식입니다. Java 8 이전에는 콜백이나 이벤트 처리를 위해 자주 사용했습니다.

Java 8 이전, 버튼 클릭 같은 이벤트를 처리하거나 스레드를 실행할 때 익명 클래스를 사용하는 코드를 자주 작성했습니다.

// Java 8 이전 — 익명 클래스 방식
Runnable r = new Runnable() {
    @Override
    public void run() {
        System.out.println("실행");
    }
};

실제로 의미 있는 코드는 System.out.println("실행") 한 줄뿐인데, 그것을 감싸기 위해 5줄의 보일러플레이트가 필요했습니다. 코드베이스가 커질수록 이런 패턴이 반복되면서 가독성이 떨어졌습니다.

Lambda는 이 문제를 해결하기 위해 등장했습니다. 메서드를 하나만 가진 인터페이스의 구현을 단 한 줄로 표현할 수 있게 해줍니다.

// Java 8 — Lambda 표현식
Runnable r = () -> System.out.println("실행");

Lambda의 기본 문법은 (매개변수) -> {본문} 형태입니다.

// 매개변수가 없을 때
() -> System.out.println("실행")

// 매개변수가 하나일 때 (괄호 생략 가능)
name -> System.out.println("Hello, " + name)

// 매개변수가 여러 개일 때
(a, b) -> a + b

// 본문이 여러 줄일 때
(a, b) -> {
    int result = a + b;
    return result;
}

Lambda가 단순히 코드를 줄이는 문법 설탕(Syntactic Sugar)에 그치지 않는 이유는, 함수를 값처럼 전달할 수 있게 해주었다는 점입니다. 메서드의 인자로 함수를 넘기고, 변수에 함수를 저장하는 방식이 자연스러워졌습니다. 이것이 자바가 함수형 프로그래밍 스타일을 지원하게 된 출발점입니다.

"Lambda와 익명 클래스의 차이는 무엇인가요?"

겉보기에는 같아 보이지만 동작 방식에 차이가 있습니다. 익명 클래스는 새로운 스코프를 만들기 때문에 this가 익명 클래스 자신을 가리킵니다. 반면 Lambda는 별도의 스코프를 만들지 않아 this가 Lambda를 감싸는 외부 클래스를 가리킵니다. 또한 Lambda는 함수형 인터페이스(메서드가 하나인 인터페이스)에만 사용할 수 있지만, 익명 클래스는 메서드가 여러 개인 인터페이스도 구현할 수 있습니다.

함수형 인터페이스

Lambda를 사용하려면 그것을 담을 타입이 필요합니다. 자바는 새로운 타입을 도입하는 대신, 메서드가 정확히 하나인 인터페이스를 Lambda의 타입으로 활용하는 방식을 택했습니다. 이를 함수형 인터페이스(Functional Interface)라고 합니다.

@FunctionalInterface 어노테이션을 붙이면 컴파일러가 해당 인터페이스에 추상 메서드가 하나뿐임을 검증해 줍니다.

@FunctionalInterface
interface Greeting {
    String greet(String name);
}

Greeting g = name -> "Hello, " + name;
System.out.println(g.greet("Java")); // Hello, Java

매번 함수형 인터페이스를 직접 정의하지 않아도 되도록, Java 8은 java.util.function 패키지에 범용 함수형 인터페이스를 미리 제공합니다.

인터페이스 메서드 시그니처 용도
Predicate<T> boolean test(T t) 조건 판별 (참/거짓 반환)
Function<T, R> R apply(T t) 값을 받아 다른 값으로 변환
Consumer<T> void accept(T t) 값을 받아 소비 (반환값 없음)
Supplier<T> T get() 값을 생산 (매개변수 없음)
BiFunction<T, U, R> R apply(T t, U u) 두 값을 받아 변환
Predicate<Integer> isPositive = n -> n > 0;
isPositive.test(5);   // true
isPositive.test(-3);  // false

Function<String, Integer> strLength = s -> s.length();
strLength.apply("Java"); // 4

Consumer<String> printer = s -> System.out.println(s);
printer.accept("Hello"); // Hello 출력

Supplier<String> greeting = () -> "안녕하세요";
greeting.get(); // "안녕하세요"

이 인터페이스들은 이후 살펴볼 Stream API와 함께 조합되어 사용되는 경우가 많습니다.

Stream API

사전 지식 — 명령형 vs 선언형 프로그래밍 명령형은 "어떻게 처리할지"를 단계별로 지시하는 방식입니다. 선언형은 "무엇을 원하는지"를 표현하고 구체적인 처리 방식은 내부에 위임하는 방식입니다. Stream API는 선언형 스타일로 컬렉션 데이터를 처리할 수 있게 해줍니다.

Java 8 이전, 리스트에서 특정 조건의 요소만 골라 처리하려면 반복문을 직접 작성해야 했습니다.

// Java 8 이전 — 명령형 방식
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Dave");
List<String> result = new ArrayList<>();

for (String name : names) {
    if (name.length() > 3) {
        result.add(name.toUpperCase());
    }
}

같은 작업을 Stream API로 표현하면 다음과 같습니다.

// Java 8 — Stream API
List<String> result = names.stream()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase)
    .collect(Collectors.toList());

코드가 짧아진 것도 있지만, 더 중요한 것은 "무엇을 원하는지"가 코드에 직접 드러난다는 점입니다. filter는 걸러내고, map은 변환하고, collect는 모은다 — 의도를 읽기가 훨씬 쉬워집니다.

Stream의 동작 구조

Stream은 세 단계로 동작합니다.

생성 단계에서는 컬렉션, 배열, 파일 등으로부터 Stream을 만듭니다.

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
Stream<Integer> stream = numbers.stream();

중간 연산 단계에서는 Stream을 변환합니다. 이 단계는 지연 평가(Lazy Evaluation)로 동작합니다. 최종 연산이 호출되기 전까지 실제로 실행되지 않습니다.

stream
    .filter(n -> n % 2 == 0)   // 짝수만 필터
    .map(n -> n * 10)           // 10배로 변환
    // 여기까지는 아직 아무것도 실행되지 않음

최종 연산 단계에서 중간 연산들이 한꺼번에 실행되고 결과가 만들어집니다.

.collect(Collectors.toList()); // 이 시점에 실제로 실행됨

자주 사용하는 중간/최종 연산을 정리하면 다음과 같습니다.

구분 메서드 역할
중간 filter(Predicate) 조건에 맞는 요소만 통과
중간 map(Function) 각 요소를 다른 값으로 변환
중간 sorted() 정렬
중간 distinct() 중복 제거
중간 limit(n) 최대 n개로 제한
최종 collect() 결과를 컬렉션으로 수집
최종 forEach() 각 요소에 동작 수행
최종 count() 요소 수 반환
최종 findFirst() 첫 번째 요소 반환
최종 anyMatch(Predicate) 하나라도 조건을 만족하면 true

병렬 스트림

Stream API가 등장한 배경에는 멀티코어 활용이 있었습니다. stream() 대신 parallelStream()을 사용하면 내부적으로 ForkJoinPool을 활용해 데이터를 분할 처리합니다.

long count = names.parallelStream()
    .filter(name -> name.length() > 3)
    .count();

단, 병렬 스트림이 항상 빠른 것은 아닙니다. 데이터 양이 충분히 많고, 각 연산이 CPU를 의미 있게 사용할 때 효과적입니다. 소규모 데이터에서는 오히려 분할/병합 오버헤드로 인해 순차 스트림보다 느릴 수 있습니다.

면접에서 자주 나오는 질문

"Stream과 for문의 차이는 무엇인가요?"

성능 측면에서 단순 반복은 전통적인 for문이 더 빠른 경우도 있습니다. Stream의 강점은 성능보다 가독성과 선언적 표현, 그리고 병렬 처리로의 전환 용이성에 있습니다. 또한 Stream은 한 번 소비되면 재사용할 수 없다는 점도 알고 있어야 합니다.

Optional

사전 지식 — NullPointerException(NPE)이란? null 값을 가진 참조 변수에 메서드를 호출하거나 필드에 접근하려 할 때 발생하는 런타임 예외입니다. 자바 개발에서 가장 흔하게 마주치는 오류 중 하나로, null을 반환할 가능성이 있는 코드를 제대로 처리하지 않으면 예상치 못한 시점에 프로그램이 중단됩니다.

null의 개념을 처음 제안한 Tony Hoare는 훗날 이를 "10억 달러짜리 실수"라고 표현했습니다. 자바에서 null은 "값이 없음"을 표현하기 위해 오랫동안 사용되었지만, NPE라는 대가를 치러야 했습니다.

Java 8 이전에는 null 가능성이 있는 코드마다 방어적인 null 체크를 반복해야 했습니다.

// null 체크가 중첩되는 전형적인 패턴
String city = null;
if (user != null) {
    Address address = user.getAddress();
    if (address != null) {
        city = address.getCity();
    }
}

Optional<T>는 값이 존재할 수도 있고 없을 수도 있음을 타입으로 명시하는 컨테이너 클래스입니다. 메서드의 반환 타입에 Optional을 사용하면, 호출하는 쪽에서 "이 메서드는 값이 없을 수 있다"는 사실을 코드를 통해 인지하게 됩니다.

// Optional 생성
Optional<String> empty   = Optional.empty();
Optional<String> present = Optional.of("Java");
Optional<String> nullable = Optional.ofNullable(someValue); // null이면 empty

// 값 꺼내기
present.get();                          // "Java" (값이 없으면 예외 발생)
present.orElse("기본값");               // 값이 없으면 "기본값" 반환
present.orElseGet(() -> "기본값");      // 값이 없으면 Supplier 실행
present.orElseThrow(() -> new RuntimeException("값 없음"));

// 조건부 처리
present.isPresent();                    // true
present.ifPresent(v -> System.out.println(v)); // 값이 있을 때만 실행

// 변환
present.map(String::toUpperCase);       // Optional<"JAVA">
present.filter(v -> v.length() > 3);   // 조건 불만족 시 Optional.empty()

앞서 중첩 null 체크로 작성했던 코드를 Optional로 다시 작성하면 다음과 같습니다.

String city = Optional.ofNullable(user)
    .map(User::getAddress)
    .map(Address::getCity)
    .orElse("알 수 없음");

 

"Optional을 남발하면 안 된다고 하는데, 왜 그런가요?"

Optional은 메서드의 반환 타입으로 사용하는 것이 주된 목적입니다. 필드 타입, 메서드 매개변수, 컬렉션 요소로 사용하는 것은 권장하지 않습니다. Optional 객체 자체도 힙에 할당되는 객체이기 때문에, 단순한 null 체크를 Optional로 무조건 대체하면 오히려 불필요한 객체 생성 비용이 발생합니다. "값이 없을 수 있음"을 API 계약으로 명시해야 할 때 사용하는 것이 적절합니다.

Default Method

Stream API를 도입하면서 자바 설계팀은 한 가지 문제에 직면했습니다. Collection 인터페이스에 stream() 메서드를 추가하고 싶었지만, 기존에 Collection을 구현한 수많은 클래스들이 모두 이 메서드를 구현해야 하는 상황이 발생한다는 것이었습니다. 하위 호환성이 깨질 수밖에 없었습니다.

이 문제를 해결하기 위해 등장한 것이 Default Method입니다. 인터페이스 안에 기본 구현을 직접 작성할 수 있게 되었고, 이를 구현하는 클래스들은 별도로 오버라이드하지 않아도 됩니다.

interface Greeting {
    String greet(String name); // 추상 메서드

    default String greetLoudly(String name) { // default 메서드
        return greet(name).toUpperCase();
    }
}

class KoreanGreeting implements Greeting {
    @Override
    public String greet(String name) {
        return "안녕하세요, " + name;
    }
    // greetLoudly는 따로 구현하지 않아도 사용 가능
}

이 덕분에 List, Collection 등 기존 인터페이스에 stream(), forEach(), removeIf() 같은 메서드를 하위 호환성을 깨지 않고 추가할 수 있었습니다.

면접에서 자주 나오는 질문

"Default Method가 추가되면서 인터페이스와 추상 클래스의 차이가 줄어든 것 아닌가요?"

맞는 지적입니다. 다만 여전히 차이는 존재합니다. 인터페이스는 상태(필드)를 가질 수 없고, 클래스는 하나의 클래스만 상속할 수 있지만 인터페이스는 여러 개를 구현할 수 있습니다. Default Method는 인터페이스의 진화를 위한 수단이지, 추상 클래스를 대체하기 위한 목적으로 도입된 것은 아닙니다.

새로운 날짜/시간 API

Java 8 이전의 날짜/시간 처리는 오랫동안 개발자들을 괴롭혀 온 영역이었습니다. java.util.Date와 java.util.Calendar는 설계 단계부터 여러 문제를 가지고 있었습니다.

Date는 이름과 달리 날짜뿐 아니라 시간도 포함합니다. Calendar의 월(Month)은 0부터 시작해서 1월이 0, 12월이 11입니다. 두 클래스 모두 가변(mutable) 객체라 여러 스레드에서 동시에 사용하면 문제가 생길 수 있습니다. 시간대(Timezone) 처리도 직관적이지 않았습니다.

Java 8은 java.time 패키지를 새로 도입하며 이 문제를 전면적으로 해결했습니다. Joda-Time 라이브러리의 설계를 참고해서 만들어졌으며, 모든 클래스가 불변(Immutable)입니다.

클래스 용도
LocalDate 날짜만 표현 (2024-01-15)
LocalTime 시간만 표현 (14:30:00)
LocalDateTime 날짜 + 시간 (시간대 정보 없음)
ZonedDateTime 날짜 + 시간 + 시간대 정보 포함
Duration 두 시간 사이의 간격 (초, 나노초 단위)
Period 두 날짜 사이의 간격 (년, 월, 일 단위)
DateTimeFormatter 날짜/시간의 파싱 및 포맷팅
// 현재 날짜
LocalDate today = LocalDate.now();           // 2024-01-15
LocalDate specific = LocalDate.of(2024, 1, 15); // 월이 1부터 시작

// 날짜 계산 — 불변 객체이므로 새로운 객체가 반환됨
LocalDate nextWeek = today.plusDays(7);
LocalDate lastMonth = today.minusMonths(1);

// 두 날짜 사이의 간격
Period period = Period.between(LocalDate.of(2020, 1, 1), today);
System.out.println(period.getYears() + "년 " + period.getMonths() + "개월");

// 포맷팅
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일");
String formatted = today.format(formatter); // "2024년 01월 15일"

불변 객체이기 때문에 날짜를 변경하면 기존 객체는 그대로이고 새로운 객체가 반환됩니다. 덕분에 멀티스레드 환경에서 별도의 동기화 없이 안전하게 사용할 수 있습니다.

마무리

오늘은 Java 8의 주요 기능들을 각각의 등장 배경과 의도를 중심으로 살펴보았습니다.

Lambda와 함수형 인터페이스는 함수를 값처럼 다루는 방식을 자바에 도입했고, Stream API는 그 위에서 컬렉션 처리를 선언형으로 표현할 수 있게 했습니다. Optional은 null을 타입으로 명시해 NPE를 설계 단계에서 다루는 방법을 제시했고, Default Method는 하위 호환성을 유지하면서 인터페이스를 진화시키는 수단이 되었습니다. 새로운 날짜 API는 오랜 불편함을 불변 설계로 해결했습니다.

이 기능들은 단순히 코드를 줄이기 위한 문법 설탕이 아니라, 멀티코어 환경과 함수형 패러다임이라는 시대적 요구에 자바가 응답한 결과입니다.

반응형

'BackEnd > Java' 카테고리의 다른 글

Java 21  (1) 2026.05.29
Java 17  (0) 2026.05.29
Java 11  (0) 2026.05.29
Java란 무엇일까?  (0) 2026.05.29
JAR 파일의 종류(Plain JAR vs Boot JAR)  (0) 2026.05.29