Java 8+ 기능 완벽 가이드: Lambda부터 Date/Time까지 실무 중심 정리

1. Java 8+에서 무엇이 바뀌었나

Java 8은 단순히 문법 몇 개가 추가된 버전이 아니라, Java가 코드를 작성하는 방식 자체를 바꾼 전환점이다. Java 8 이전의 Java는 객체지향(OOP) 패러다임에 매우 강하게 고정되어 있었다. 어떤 동작(behavior)을 전달하려면, 보통 인터페이스 + 익명 클래스 형태로 감싸서 넘겼다. 또한 컬렉션 데이터를 처리하는 방식은 대체로 반복문(명령형, imperative) 중심이었다.

Java 8 이후에는 다음 두 가지 축이 크게 강화됐다.

  • 함수형 스타일(Function-style): Lambda, 함수형 인터페이스, 메서드 레퍼런스
  • 데이터 흐름 중심(Data-flow): Stream API, Optional, Date/Time API(java.time)

이 변화가 실무에서 중요한 이유는 명확하다.
(1) 코드 길이가 줄어드는 것보다 더 중요한 건 의도(what)를 더 잘 드러내는 코드를 작성할 수 있게 되었다는 점이다.
(2) 데이터 처리/변환을 선언형(declarative)으로 표현하면, 로직이 커져도 흐름을 따라가기 쉽고 테스트하기도 쉬워진다.
(3) 날짜/시간 처리의 안정성이 크게 개선되어, 운영 환경에서 자주 터지던 버그(타임존, 윤년, 서머타임 등)도 줄일 수 있다.

이 글은 Java 8+에서 가장 중요한 6가지 기능을 아래 순서대로 정리한다.

  • Lambda 표현식
  • 함수형 인터페이스
  • 메서드 레퍼런스
  • Stream API
  • Optional
  • Date/Time API (java.time)

각 파트는 개념 → 왜 필요한지 → 동작 원리 → 실무 패턴 순서로 진행한다.


2. Lambda 표현식

2.1 Lambda가 해결하려는 문제

Java 8 이전, 어떤 동작을 파라미터로 넘기고 싶다면 거의 항상 아래 같은 형태가 나온다.

1
2
3
4
5
6
Collections.sort(users, new Comparator<User>() {
    @Override
    public int compare(User a, User b) {
        return a.getAge() - b.getAge();
    }
});

문제는 두 가지다.

1) 핵심 로직(compare) 에 비해 보일러플레이트가 너무 많다. 2) 정렬 기준은 나이라는 핵심 의도가 코드에 묻힌다.

Lambda는 이 문제를 행동을 값처럼 전달할 수 있게 만들어 해결한다.

1
users.sort((a, b) -> a.getAge() - b.getAge());

여기서 중요한 건 짧아졌다는 사실 그 자체가 아니라, 정렬 기준 = 나이라는 의도가 가장 눈에 띄는 형태로 올라왔다는 점이다.


2.2 Lambda 기본 문법

Lambda 표현식은 기본적으로 다음 형태다.

1
(파라미터) -> { 실행문 }

예시:

1
2
3
x -> x * 2
(a, b) -> a + b
(s) -> { System.out.println(s); }

자주 쓰는 축약 규칙

  • 파라미터 타입은 대부분 컴파일러가 추론하므로 생략 가능
  • 파라미터가 1개면 괄호 생략 가능
  • 실행문이 1개면 {} 생략 가능
  • 실행문이 return 1줄이면 return{} 생략 가능
1
2
Function<Integer, Integer> f1 = (Integer x) -> { return x * 2; };
Function<Integer, Integer> f2 = x -> x * 2;

2.3 Lambda는 어디에나 쓸 수 있나?: 함수형 인터페이스 조건

Lambda는 그냥 함수처럼 보이지만, Java는 순수 함수 타입이 존재하지 않는다.
대신 Java는 함수형 인터페이스에 Lambda를 매핑한다.

즉, Lambda가 성립하려면 아래 조건이 필요하다.

  • 추상 메서드가 정확히 1개인 인터페이스(Functional Interface)

예를 들어 Runnable은 추상 메서드가 run() 하나라서 Lambda로 표현 가능하다.

1
Runnable r = () -> System.out.println("run");

반대로 추상 메서드가 2개 이상이면 Lambda로 매핑할 수 없다.


2.4 Lambda 캡처(capture)와 effectively final

Lambda는 바깥 스코프의 변수를 캡처할 수 있다.

1
2
int base = 10;
Function<Integer, Integer> f = x -> x + base;

하지만 여기서 baseeffectively final이어야 한다. 즉, 실제로 final을 붙이지 않아도 사실상 변경되지 않는 변수여야 한다.

1
2
3
int base = 10;
base = 20; // ❌ 변경하면 effectively final 위반
Function<Integer, Integer> f = x -> x + base;

이 제약은 단순한 불편이 아니라, 멀티스레드/클로저 환경에서의 안전성과 관련이 있다. Lambda가 값을 캡처할 때 어떤 시점의 값을 들고 있는가 같은 혼란을 줄이기 위해 설계적으로 제한을 둔 것이다.


2.5 실무에서 Lambda가 좋은 코드가 되는 기준

Lambda를 무조건 짧게 쓰면 좋은 게 아니다. 실무에서 기준은 보통 아래와 같다.

  • 한 줄로 표현 가능한 단순한 변환/조건/소비는 Lambda가 가장 읽기 쉽다.
  • 로직이 길어지면 Lambda 안에서 if/for가 늘어나고 흐름이 깨져서 오히려 가독성이 떨어진다.
  • 로직이 길면 메서드로 추출한 뒤 메서드 레퍼런스로 바꾸는 것이 더 좋다.

나쁜 예:

1
2
3
4
5
6
users.stream().filter(u -> {
    if (u == null) return false;
    if (u.getAge() < 20) return false;
    if (u.getName() == null) return false;
    return u.getName().startsWith("K");
}).toList();

개선 예:

1
2
3
users.stream()
     .filter(this::isValidTargetUser)
     .toList();

3. 함수형 인터페이스 (Functional Interface)

3.1 정의: 추상 메서드 1개

함수형 인터페이스는 추상 메서드가 하나뿐인 인터페이스다.

1
2
3
4
@FunctionalInterface
public interface Validator<T> {
    boolean test(T value);
}

@FunctionalInterface는 필수가 아니지만, 붙여두면 컴파일러가 진짜 함수형 인터페이스인지 검증해준다. 추상 메서드가 2개가 되면 컴파일 에러가 발생한다.


3.2 기본 제공 함수형 인터페이스들

Java는 자주 쓰는 함수형 타입을 java.util.function 패키지에 표준으로 제공한다. 실무에서 거의 매일 쓰는 수준이다.

인터페이스 메서드 의미
Supplier<T> T get() 값을 제공 (입력 없음)
Consumer<T> void accept(T) 값을 소비 (반환 없음)
Function<T,R> R apply(T) T를 R로 변환
Predicate<T> boolean test(T) 조건 판단
UnaryOperator<T> T apply(T) 같은 타입 변환
BinaryOperator<T> T apply(T,T) 같은 타입 두 개 → 하나

예를 들어 Predicatefilter에서 사실상 표준 타입이다.

1
Predicate<User> isAdult = u -> u.getAge() >= 20;

3.3 조합(Composition) 메서드가 중요한 이유

함수형 인터페이스는 단순히 함수 시그니처만 제공하는 게 아니라, 조합을 지원하는 디폴트 메서드들도 제공한다. 이게 실무에서 상당히 유용하다.

Predicate 조합

1
2
3
4
Predicate<User> isAdult = u -> u.getAge() >= 20;
Predicate<User> nameStartsWithK = u -> u.getName().startsWith("K");

Predicate<User> target = isAdult.and(nameStartsWithK);

이 방식의 장점은 다음과 같다.

  • 조건이 문장처럼 이어져 읽기 쉽다.
  • 재사용 가능한 조건 조각을 만들어 조합할 수 있다.
  • 테스트가 쉬워진다(각 Predicate를 단위 테스트).

Function 조합

1
2
3
4
Function<String, String> trim = String::trim;
Function<String, Integer> length = String::length;

Function<String, Integer> trimThenLength = trim.andThen(length);

3.4 실무 패턴: 전략을 함수로 넘기기

Java 8 이후에는 전략 패턴의 상당 부분이 함수형 인터페이스로 치환된다.

예) 데이터 검증 전략

1
2
3
4
5
6
public static <T> void validate(T value, Predicate<T> rule, String message) {
    if (!rule.test(value)) throw new IllegalArgumentException(message);
}

validate(age, a -> a >= 0, "age must be >= 0");
validate(name, n -> n != null && !n.isBlank(), "name required");

이 방식은 규칙을 데이터처럼 전달한다는 점에서 확장성이 좋다. 특히 여러 검증 규칙을 조합하는 경우 매우 강력하다.


4. 메서드 레퍼런스 (Method Reference)

4.1 Lambda를 더 읽기 좋게 만드는 문법

메서드 레퍼런스는 Lambda를 더 짧게 만들기 위한 꼼수가 아니라, 의도를 더 직접적으로 표현하기 위한 문법이다.

1
2
list.forEach(x -> System.out.println(x));
list.forEach(System.out::println);

두 코드는 동일하지만, 후자는 println을 적용한다는 의도가 더 즉시 보인다.


4.2 메서드 레퍼런스 4가지 유형

1) 정적 메서드 참조: Type::staticMethod

1
Function<String, Integer> parse = Integer::parseInt;

2) 특정 객체의 인스턴스 메서드 참조: instance::method

1
2
PrintStream out = System.out;
Consumer<String> printer = out::println;

3) 임의 객체의 인스턴스 메서드 참조: Type::instanceMethod

이건 처음 보면 헷갈린다. 의미는 입력으로 들어오는 객체의 메서드를 호출하라이다.

1
Function<String, String> toUpper = String::toUpperCase;

위 코드는 사실상 아래 Lambda와 동일하다.

1
s -> s.toUpperCase()

4) 생성자 참조: Type::new

1
2
Supplier<List<String>> listSupplier = ArrayList::new;
Function<Integer, int[]> arrayCreator = int[]::new;

4.3 실무 기준: 너무 과한 축약은 피하기

메서드 레퍼런스는 깔끔하지만, 무조건 바꾸는 게 좋지는 않다.

  • Lambda가 의미를 더 잘 드러내면 그대로 둔다.
  • 파라미터 이름이 의미를 설명해주면 Lambda가 낫다.
  • 메서드 레퍼런스로 바꾸면 오히려 무슨 메서드지?를 찾아가야 하면 손해다.

예:

1
2
3
4
5
// 이건 method reference가 더 좋다
users.stream().map(User::getName);

// 이건 lambda가 더 읽기 좋을 수 있다
users.stream().map(u -> formatUserName(u));

5. Stream API

5.1 Stream은 컬렉션이 아니라 처리 파이프라인

Stream은 데이터 구조가 아니다.
Stream은 데이터를 흘려보내며 처리하는 파이프라인이다.

  • 컬렉션(Collection): 데이터를 보관
  • 스트림(Stream): 데이터를 처리
1
2
3
4
5
List<String> names =
    users.stream()
         .filter(u -> u.getAge() >= 20)
         .map(User::getName)
         .toList();

이 코드는 20세 이상인 사용자들의 이름 목록이라는 요구사항을 변환 과정 자체로 보여준다.


5.2 Stream의 핵심 성질 4가지

(1) 원본 불변 (non-mutating)

Stream 연산은 원본 컬렉션을 직접 변경하지 않는다.

1
2
3
List<Integer> a = List.of(1,2,3);
List<Integer> b = a.stream().map(x -> x * 10).toList();
// a는 그대로, b만 새로 만들어진다

(2) 지연 평가 (lazy)

중간 연산은 즉시 실행되지 않는다. 최종 연산이 호출될 때 한 번에 실행된다.

1
2
3
4
5
6
Stream<Integer> s = list.stream().filter(x -> {
    System.out.println("filter " + x);
    return x % 2 == 0;
});
// 아직 실행 안 됨
s.count(); // 여기서 실행됨

(3) 단 한 번만 소비 가능 (one-shot)

Stream은 한 번 최종 연산을 수행하면 재사용 불가다.

1
2
3
Stream<Integer> s = list.stream();
s.count();
s.count(); // ❌ IllegalStateException

(4) 내부 반복 (internal iteration)

반복 제어를 개발자가 아니라 Stream이 한다.
이게 중요한 이유는, 구현이 더 단순해지고 병렬화 가능성도 열린다는 점이다.


5.3 중간 연산 / 최종 연산

Stream 연산은 크게 두 종류로 나뉜다.

  • 중간 연산(intermediate): Stream → Stream
  • 최종 연산(terminal): Stream → 값/컬렉션/부작용

대표 중간 연산:

  • filter, map, flatMap, sorted, distinct, limit, skip, peek

대표 최종 연산:

  • collect, toList, forEach, count, reduce, anyMatch, allMatch, findFirst

5.4 map vs flatMap: 실무에서 가장 많이 헷갈리는 구간

map: 1:1 변환

1
2
3
4
List<Integer> lengths =
    words.stream()
         .map(String::length)
         .toList();

flatMap: 1:N 변환 후 평탄화(flatten)

예를 들어 문장 리스트를 단어 리스트로 만들려면:

1
2
3
4
List<String> words =
    sentences.stream()
             .flatMap(s -> Arrays.stream(s.split(" ")))
             .toList();

flatMap을 이해하는 키워드는 중첩 구조를 없애고 한 단계 펴기다.


5.5 collect: 실무에서 가장 강력한 최종 연산

collect는 결과를 어떤 형태로 모을지 지정한다.

1
2
List<User> adults = users.stream().filter(u -> u.getAge() >= 20).collect(Collectors.toList());
Set<String> names = users.stream().map(User::getName).collect(Collectors.toSet());

groupingBy: 그룹화

1
2
Map<Integer, List<User>> byAge =
    users.stream().collect(Collectors.groupingBy(User::getAge));

groupingBy + counting: 그룹별 개수

1
2
Map<Integer, Long> countByAge =
    users.stream().collect(Collectors.groupingBy(User::getAge, Collectors.counting()));

groupingBy + mapping: 그룹별로 특정 필드만 추출

1
2
3
4
5
Map<Integer, List<String>> namesByAge =
    users.stream().collect(Collectors.groupingBy(
        User::getAge,
        Collectors.mapping(User::getName, Collectors.toList())
    ));

이 패턴은 보고서/통계/집계에서 엄청나게 자주 등장한다.


5.6 reduce: 축약(누적)의 본질

reduce는 여러 값을 하나로 합치기다.

1
int sum = numbers.stream().reduce(0, Integer::sum);

하지만 실무에서 reduce는 과하게 쓰면 읽기 어려워진다.
합계/최댓값/최솟값 같은 건 표준 메서드를 쓰는 게 더 낫다.

1
2
int sum = numbers.stream().mapToInt(Integer::intValue).sum();
int max = numbers.stream().mapToInt(Integer::intValue).max().orElse(0);

5.7 병렬 스트림(parallelStream): 성능 만능키가 아니다

1
list.parallelStream().map(...).collect(...);

병렬 스트림이 빠르려면 조건이 까다롭다.

  • 데이터가 충분히 커야 한다(오버헤드를 이길 만큼)
  • 작업이 CPU-bound여야 한다(I/O-bound면 이득이 적다)
  • 연산이 독립적이어야 한다(공유 자원 접근이 많으면 역효과)
  • 컬렉터가 병렬 친화적이어야 한다(동기화 비용 고려)

실무 기준으로는 일단 parallelStream부터가 아니라, 프로파일링 기반으로만 도입하는 게 안전하다.


5.8 Stream 실무 체크리스트

  • Stream 내부에서 DB 호출/네트워크 호출 같은 I/O를 넣지 말자(예상 못한 N+1, 지연 폭발)
  • peek는 디버깅 용도 이상으로 남겨두지 말자
  • 로직이 길어지면 메서드 추출 + 메서드 레퍼런스
  • 병렬 스트림은 측정 없이 도입하지 말자
  • toList()는 Java 16부터 불변 리스트 반환(환경에 따라 다름). 필요하면 Collectors.toList() 사용

6. Optional

6.1 Optional의 목적: null을 안전하게 다루는 설계

Java에서 NullPointerException(NPE)은 악명 높다. Optional은 이 문제를 완벽히 제거하진 못하지만, 최소한 아래와 같은 방향으로 코드를 강제한다.

  • 값이 없을 수 있다는 사실을 타입 레벨에서 드러낸다
  • 호출자가 명시적으로 처리하도록 유도한다
1
Optional<User> userOpt = findUser(id);

이 코드만 봐도, user가 없을 수 있다는 게 즉시 드러난다.


6.2 Optional 생성 메서드

1
2
3
Optional.of(value);         // value가 null이면 NPE
Optional.ofNullable(value); // null이면 Optional.empty()
Optional.empty();           // 비어있는 Optional

실무에서는 거의 ofNullable을 많이 쓴다.


6.3 값 꺼내기: get()는 거의 금지에 가깝다

Optional의 대표적인 안티패턴이 get()이다.

1
User u = userOpt.get(); // ❌ 비어있으면 NoSuchElementException

대신 아래 패턴을 사용한다.

orElse: 기본값

1
User u = userOpt.orElse(DEFAULT_USER);

orElseGet: lazy 기본값(비용 큰 계산에 유리)

1
User u = userOpt.orElseGet(() -> loadDefaultUserFromDb());

orElseThrow: 없으면 예외

1
User u = userOpt.orElseThrow(() -> new IllegalArgumentException("user not found"));

6.4 ifPresent / ifPresentOrElse

1
userOpt.ifPresent(u -> System.out.println(u.getName()));

Java 9+부터는 ifPresentOrElse도 가능하다.

1
2
3
4
userOpt.ifPresentOrElse(
    u -> System.out.println(u.getName()),
    () -> System.out.println("no user")
);

6.5 map / flatMap으로 null 체크를 흐름으로

Optional의 진짜 가치는 map/flatMap에서 나온다.

1
2
Optional<String> name =
    userOpt.map(User::getName);

중첩 Optional을 막으려면 flatMap을 쓴다.

1
2
Optional<Address> addr =
    userOpt.flatMap(User::getAddressOptional);

6.6 Optional 실무 원칙(매우 중요)

Optional은 강력하지만, 잘못 쓰면 코드가 더 복잡해진다. 실무에서 흔히 지키는 룰은 다음과 같다.

  1. 리턴 타입에는 적극 사용: 없을 수 있음을 API 계약으로 드러내기 좋다.
  2. 필드에 Optional 저장은 지양: 직렬화/JPA/JSON 처리와 충돌하기 쉽다.
  3. 파라미터로 Optional 받는 건 지양: 호출부에서 Optional을 만들고 넘기는 게 가독성을 해친다.
  4. Optional은 null을 대체하는 도구가 아니라, 없을 수 있음을 표현하는 도구다.

6.7 Optional 안티패턴 모음

(1) Optional을 다시 null로 되돌리기

1
User u = userOpt.orElse(null); // ✅ 가능하지만 Optional의 장점이 줄어든다

(2) Optional로 모든 걸 감싸기

1
Optional<Optional<User>> // 이런 구조는 대부분 설계 문제 신호

(3) Optional을 필드로 들고 다니기

1
2
3
class User {
    private Optional<String> name; // ❌
}

7. Date/Time API (java.time)

7.1 기존 Date/Calendar의 문제

Java 8 이전 날짜 API는 다음 문제들이 있다.

  • java.util.Date는 설계적으로 애매하고 가변(mutable)
  • Calendar는 사용성이 나쁘고 실수가 잦음(월이 0부터 시작 등)
  • 타임존/서머타임 처리도 복잡
  • 스레드 안전성이 약한 형태가 많음

Java 8의 java.time은 이 문제를 대규모로 해결한다.


7.2 핵심 철학: 불변(immutable) + 명확한 타입

java.time에서 대부분의 타입은 불변이다.
즉, plusDays 같은 연산은 원본을 변경하지 않고 새 객체를 반환한다.

1
2
3
4
5
LocalDate d1 = LocalDate.of(2026, 2, 1);
LocalDate d2 = d1.plusDays(10);

System.out.println(d1); // 2026-02-01
System.out.println(d2); // 2026-02-11

이 불변성은 멀티스레드 환경에서 엄청난 안전성을 준다.


7.3 대표 타입 정리

타입 의미 타임존
LocalDate 날짜(연-월-일) 없음
LocalTime 시간(시-분-초) 없음
LocalDateTime 날짜+시간 없음
ZonedDateTime 날짜+시간+타임존 있음
OffsetDateTime UTC 오프셋 포함 있음(오프셋)
Instant UTC 기준 타임스탬프 UTC
Duration 시간 기반 차이(초, 나노) -
Period 날짜 기반 차이(년,월,일) -

여기서 실무에서 가장 중요한 구분은 Local vs Zoned/Offset이다.

  • Local*: 사람이 보는 일정(캘린더)에 가까움 (타임존 없이 표현)
  • Zoned/Offset: 서버/로그/통신에 가까움 (타임존/오프셋 포함)
  • Instant: 절대 시간(UTC 기준)에 가까움

7.4 LocalDate / LocalDateTime 기본 사용

1
2
3
4
5
LocalDate today = LocalDate.now();
LocalDate d = LocalDate.of(2026, 2, 1);

LocalDateTime now = LocalDateTime.now();
LocalDateTime dt = LocalDateTime.of(2026, 2, 1, 9, 0);

7.5 ZonedDateTime과 ZoneId: 운영 환경에서 핵심

서버는 여러 지역에서 돌아갈 수 있고, 컨테이너/VM의 기본 타임존도 환경마다 다르다.
따라서 지역 시간이 필요하면 ZoneId를 명시하는 습관이 중요하다.

1
2
ZoneId seoul = ZoneId.of("Asia/Seoul");
ZonedDateTime zdt = ZonedDateTime.now(seoul);

7.6 Instant: DB/로그/분산 시스템에서 안전한 선택

Instant는 UTC 기준의 절대 시간이다.

1
Instant instant = Instant.now();

서버 간 타임존이 다른 환경에서도 Instant는 일관되게 비교할 수 있다.
분산 시스템에서 이 이벤트가 먼저냐 나중이냐를 비교할 때 가장 안전하다.


7.7 Duration vs Period

  • Duration: 시간(초/나노) 기반 차이
  • Period: 날짜(년/월/일) 기반 차이
1
2
Duration d = Duration.between(Instant.now(), Instant.now().plusSeconds(10));
Period p = Period.between(LocalDate.of(2026,1,1), LocalDate.of(2026,2,1));

실무에서 30일 뒤 같은 표현은 Period/LocalDate 조합이 더 안전할 때가 많다.
반면 10초 후 타임아웃은 Duration이 적절하다.


7.8 포맷팅/파싱: DateTimeFormatter

기존 SimpleDateFormat은 가변 객체라 스레드 안전 문제가 자주 있었다.
DateTimeFormatter는 불변이라 안전하다.

1
2
3
4
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String s = LocalDateTime.now().format(fmt);

LocalDateTime parsed = LocalDateTime.parse("2026-02-01 09:00:00", fmt);

7.9 실무 팁: 저장과 표시는 분리하라

많은 버그는 DB에는 지역 시간을 저장하고 서버 타임존이 달라져서 생긴다.
권장 패턴은 대체로 다음 중 하나다.

  • DB에는 Instant(UTC)를 저장하고, 화면 표시 시 타임존 변환
  • 또는 DB에 OffsetDateTime으로 오프셋까지 저장

예: 저장은 UTC, 표시만 Asia/Seoul

1
2
3
Instant stored = Instant.now();
// 화면 표시
ZonedDateTime view = stored.atZone(ZoneId.of("Asia/Seoul"));

8. Stream + Optional + Lambda를 묶어서 보는 실무 예제

8.1 요구사항 예시

  • 사용자 목록이 있다
  • 20세 이상 사용자만 추린다
  • 이름이 null/빈 값이면 제외한다
  • 이름을 대문자로 바꿔서 리스트로 만든다
  • 결과가 비어 있으면 DEFAULT를 넣는다
1
2
3
4
5
6
7
8
9
10
11
12
13
List<String> result =
    Optional.ofNullable(users)
        .orElseGet(List::of)
        .stream()
        .filter(u -> u.getAge() >= 20)
        .map(User::getName)
        .filter(name -> name != null && !name.isBlank())
        .map(String::toUpperCase)
        .toList();

if (result.isEmpty()) {
    result = List.of("DEFAULT");
}

이 코드를 더 의도 중심으로 만들면, 조건/변환을 메서드로 추출할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
List<String> result =
    Optional.ofNullable(users)
        .orElseGet(List::of)
        .stream()
        .filter(this::isAdult)
        .map(User::getName)
        .filter(this::hasText)
        .map(String::toUpperCase)
        .toList();

result = result.isEmpty() ? List.of("DEFAULT") : result;

여기서 핵심은 데이터 흐름이 위에서 아래로 읽힌다는 점이다.
명령형 for-loop에서도 가능하지만, 흐름을 드러내는 데에는 Stream이 유리할 때가 많다.


9. Java 8+ 기능을 실무에서 ‘잘’ 쓰는 기준

9.1 함수형 스타일은 명확한 의도가 우선이다

  • 짧다고 좋은 게 아니다
  • 한 줄에 모든 걸 우겨넣으면 디버깅이 힘들다
  • 조건/변환이 커지면 메서드 추출이 정답이다

9.2 Stream은 데이터 처리에 집중해야 한다

  • Stream 내부에서 DB 조회를 하면 N+1이 쉽게 생긴다
  • I/O를 끼워 넣으면 지연이 폭발한다
  • Stream은 메모리 내 데이터 변환/집계에 잘 맞는다

9.3 Optional은 API 계약을 표현하는 수단이다

  • Optional로 null을 감추지 말고, 없을 수 있다는 사실을 드러내자
  • 반환 타입에 Optional을 두면 호출자가 처리를 강제받아 안전해진다
  • 필드/파라미터 Optional은 운영/직렬화/프레임워크에서 문제를 만들 수 있다

9.4 Date/Time은 저장과 표시를 분리하라

  • DB 저장은 UTC(Instant/OffsetDateTime) 중심이 안전하다
  • 표시만 사용자 타임존으로 변환하자
  • 서버 기본 타임존에 기대지 말고 명시하자(ZoneId)

10. 면접에서 자주 나오는 포인트(핵심만 정리)

Q1. Lambda는 무엇이며 왜 도입되었나?

익명 함수를 표현하는 문법으로, 동작을 값처럼 전달하기 위해 도입되었다. 기존에는 익명 클래스로 전략을 전달했지만 보일러플레이트가 많았고 의도가 묻혔다. Lambda로 선언형 코드와 Stream 파이프라인이 가능해졌다.

Q2. 함수형 인터페이스란?

추상 메서드가 정확히 하나인 인터페이스이다. Lambda는 이 함수형 인터페이스의 단일 추상 메서드 구현으로 매핑된다. @FunctionalInterface는 컴파일 타임 검증을 제공한다.

Q3. Stream의 특징은?

원본 불변, 지연 평가, one-shot, 내부 반복이다. 중간 연산은 최종 연산 전까지 실행되지 않으며, 최종 연산 시 파이프라인이 한 번에 실행된다.

Q4. Optional의 장점과 주의점은?

null을 안전하게 다루며 없을 수 있다를 타입으로 표현한다. get() 남발은 금지에 가깝고, orElse/orElseGet/orElseThrow, map/flatMap으로 흐름을 구성하는 것이 좋다. 필드/파라미터 Optional은 지양하는 경우가 많다.

Q5. java.time이 기존 Date/Calendar보다 나은 점은?

불변(immutable), 명확한 타입(Local/Zoned/Instant), 스레드 안전성, 포맷터 안전성(DateTimeFormatter) 등이 개선됐다. 타임존 처리도 구조적으로 더 안전해졌다.


정리

Java 8+ 기능은 개별 문법을 외우는 문제가 아니라, 코드를 작성하는 사고방식의 변화다.
Lambda/함수형 인터페이스/메서드 레퍼런스는 동작을 데이터처럼 다루게 만들고, Stream은 데이터 흐름 중심으로 코드를 바꿔준다. Optional은 없을 수 있음을 계약으로 끌어올리고, Date/Time API는 날짜/시간 처리를 불변 기반으로 안정화한다.

실무에서 가장 중요한 건 문법을 아는 것이 아니라, 아래 두 문장을 코드로 구현할 수 있는가다.

  • 의도가 한 번에 읽히는가
  • 운영 환경에서 안전한가(타임존, null, 병렬성)

이 문서가 Java 8+를 안다를 넘어서
Java 8+로 코드를 설계한다로 이어지길 바란다.


11. Stream API 심화: Collector, 성능, 디버깅

Stream을 필터+맵 정도로만 쓰면 반쪽짜리다. 실무에서 Stream이 진짜 힘을 발휘하는 지점은 집계(aggregation), 그룹화(grouping), 리포팅 형태로 재구성 같은 부분이다. 이때 핵심이 Collector다.

11.1 toMap: 키 충돌(merge) 전략을 반드시 생각하자

가장 흔한 실수는 toMap에서 키가 중복될 수 있는데 merge 함수를 안 넣어서 런타임 예외가 터지는 경우다.

1
2
3
4
5
Map<String, User> byEmail =
    users.stream().collect(Collectors.toMap(
        User::getEmail,
        u -> u
    )); // ❌ 같은 이메일이 두 명이면 IllegalStateException

안전한 패턴:

1
2
3
4
5
6
Map<String, User> byEmail =
    users.stream().collect(Collectors.toMap(
        User::getEmail,
        u -> u,
        (a, b) -> a  // 충돌 시 어떤 값을 남길지 정의(선행/후행/병합 등)
    ));

실무에서는 충돌을 조용히 덮어쓰기 하면 데이터 품질 문제가 숨어버릴 수 있다.
충돌 자체가 비정상이라면, merge에서 예외를 던지는 것도 전략이다.

1
(a, b) -> { throw new IllegalStateException("duplicate key: " + a.getEmail()); }

11.2 partitioningBy: boolean 조건으로 2그룹 나누기

groupingBy가 일반적인 분류라면, partitioningBytrue/false 두 그룹으로 나눌 때 더 표현력이 좋다.

1
2
3
4
5
Map<Boolean, List<User>> partition =
    users.stream().collect(Collectors.partitioningBy(u -> u.getAge() >= 20));

List<User> adults = partition.get(true);
List<User> minors = partition.get(false);

11.3 joining: 문자열 합치기(구분자/프리픽스/서픽스)

1
2
3
4
String csv =
    users.stream()
         .map(User::getName)
         .collect(Collectors.joining(","));

프리픽스/서픽스까지 붙일 수도 있다.

1
2
3
4
String jsonArrayLike =
    users.stream()
         .map(User::getName)
         .collect(Collectors.joining("","", "["", ""]"));

11.4 Tee-ing Collector (Java 12+): 한 번에 두 결과 만들기

Java 8만 기준이면 이 섹션은 참고지만, 8+ 범위를 넓게 잡으면 실무에서 유용하다.
teeing은 동일 스트림을 두 Collector로 수집해 최종적으로 합치는 방식이다.

예: 평균과 합계를 한 번에 구하고 싶을 때(가독성을 위해)

1
2
3
4
5
6
7
// Java 12+
var stats =
    numbers.stream().collect(Collectors.teeing(
        Collectors.summingInt(Integer::intValue),
        Collectors.averagingInt(Integer::intValue),
        (sum, avg) -> Map.of("sum", sum, "avg", avg)
    ));

11.5 디버깅: peek는 최종 커밋에 남기지 말자

peek는 스트림 파이프라인 중간 값을 관찰하기 좋지만, 실무에서는 디버깅 용도로만 쓰고 커밋 전에 제거하는 것이 안전하다.

1
2
3
4
5
users.stream()
     .peek(u -> log.debug("before filter: {}", u))
     .filter(this::isAdult)
     .peek(u -> log.debug("after filter: {}", u))
     .toList();

운영 코드에 peek 기반 로그가 남으면 성능/로그 비용이 커질 수 있다.
대신 메서드 추출 + 단위 테스트로 파이프라인을 검증하는 게 장기적으로 낫다.


11.6 성능 관점: Stream이 느리다? 어떻게 쓰느냐가 핵심

Stream은 편의성 때문에 무조건 느리다고 단정하면 안 된다. 다만 아래 상황에서는 확실히 느려질 가능성이 높다.

  • 박싱/언박싱이 많은 경우: Stream<Integer>는 비용이 커질 수 있음
    → 가능하면 IntStream, LongStream, DoubleStream 사용
  • 파이프라인이 지나치게 길고, 각 단계가 작은 작업만 하는 경우
  • 반복문이 더 단순한데, 무리하게 Stream으로 억지로 표현한 경우

예: 합계는 IntStream이 명확하고 빠른 경우가 많다.

1
int sum = numbers.stream().mapToInt(Integer::intValue).sum();

12. Optional 심화: 값이 없을 수 있음을 설계로 끌어올리기

Optional을 잘 쓰면 API가 안전해지고, 호출자가 null 처리 실수를 덜 한다.
하지만 Optional을 새로운 null 포장지로 써버리면 오히려 코드가 더 꼬인다.

12.1 orElse vs orElseGet: 비용 큰 기본값이면 반드시 구분

orElse는 Optional이 비어 있든 말든 기본값을 먼저 계산한다.

1
User u = userOpt.orElse(loadFromDb()); // ❌ userOpt가 있어도 DB 조회가 실행될 수 있음

반면 orElseGet은 Optional이 비어 있을 때만 계산한다.

1
User u = userOpt.orElseGet(this::loadFromDb); // ✅ lazy

이 차이는 실무에서 숨은 쿼리나 불필요한 비용으로 바로 이어진다.


12.2 Optional vs Collection.empty

컬렉션에 Optional을 씌우는 건 많은 경우 비권장이다.

  • 컬렉션은 비어 있음(empty) 자체로 부재를 표현할 수 있다.
  • Optional까지 감싸면 호출부가 더 복잡해진다.

권장:

1
List<Order> orders = findOrders(userId); // 없으면 empty list 반환

Optional이 더 적절한 지점은 단일 값이 없을 수 있는 경우다.


12.3 Optional을 반환하는 메서드 설계 기준

  • 없을 수 있는 결과를 호출자가 반드시 인지해야 하면 Optional이 적절하다.
  • 없으면 빈 리스트 같은 자연스러운 대체가 있다면 컬렉션 empty가 더 낫다.
  • 없으면 예외가 계약이라면 Optional보다 예외를 던지는 게 명확할 때도 많다.

13. Date/Time 심화: 변환, 타임존, 서버 운영 팁

13.1 LocalDateTime ↔ Instant 변환은 Zone이 필요하다

LocalDateTime은 타임존이 없다. 그래서 Instant로 바꾸려면 어떤 지역의 시간인지를 지정해야 한다.

1
2
3
4
LocalDateTime ldt = LocalDateTime.of(2026, 2, 1, 9, 0);
ZoneId seoul = ZoneId.of("Asia/Seoul");

Instant instant = ldt.atZone(seoul).toInstant();

반대로 Instant를 LocalDateTime으로 바꾸는 것도 Zone이 필요하다.

1
LocalDateTime back = LocalDateTime.ofInstant(instant, seoul);

여기서 Zone을 잘못 지정하면 시간 자체가 바뀐다. 운영에서 가장 흔한 사고 지점이다.


13.2 서버 기본 타임존에 의존하지 말자

서버/컨테이너 환경에 따라 기본 타임존이 UTC일 수도, KST일 수도 있다.
내 로컬에서는 맞는데 서버에서 9시간 밀림 같은 버그가 여기서 터진다.

실무 안전 패턴:

  • 저장/비교는 Instant(UTC)
  • 표시만 사용자 타임존으로 변환
  • 로직에서 ZoneId를 명시

13.3 Duration을 타임아웃/재시도 정책에 활용

외부 API 호출, 락 대기, 폴링 등에서 Duration은 매우 자연스럽다.

1
2
Duration timeout = Duration.ofSeconds(3);
Duration retryBackoff = Duration.ofMillis(200);

숫자 3000ms보다 3초가 더 읽기 쉬운 경우가 많고, 단위 실수도 줄어든다.


14. 최종 요약: 기능별로 ‘외워야 할’ 한 문장

  • Lambda: 동작을 값처럼 전달하는 문법
  • 함수형 인터페이스: Lambda가 매핑되는 타입(추상 메서드 1개)
  • 메서드 레퍼런스: Lambda를 더 의도적으로 읽히게 하는 참조 문법
  • Stream: 데이터 처리 파이프라인(지연 평가 + 선언형)
  • Optional: 없을 수 있음(null)을 타입 계약으로 끌어올리는 도구
  • Date/Time(java.time): 불변 기반의 안전한 시간 모델(Zone/Instant 분리)