for문 안에 if문, 그 안에 또 for문... "이걸 좀 더 깔끔하게 쓸 수는 없을까?" 절차적으로 기술하는 대신, 무엇을 할지 선언적으로 표현하는 방법이 있다.

왜 함수형 프로그래밍인가?

람다와 스트림 은 Java 8에서 도입된 함수형 프로그래밍 도구예요. "어떻게"가 아닌 "무엇을" 할지를 선언적으로 표현합니다.

리스트에서 짝수만 출력하는 코드를 비교해볼게요.

JAVA
// for + if — "어떻게" 할지 절차적으로 지시
List<Integer> evens = new ArrayList<>();
for (int n : numbers) {
    if (n % 2 == 0) evens.add(n);
}
for (int n : evens) System.out.println(n);
JAVA
// 스트림 — "무엇을" 할지 선언
numbers.stream()
    .filter(n -> n % 2 == 0)
    .forEach(System.out::println);

의도가 코드에 그대로 드러나요. filter, map, reduce 같은 연산을 파이프라인으로 연결하면 반복적인 보일러플레이트 없이 데이터 처리를 조합할 수 있습니다.

함수형 인터페이스

람다를 이해하려면 먼저 함수형 인터페이스 를 알아야 해요.

함수형 인터페이스는 추상 메서드가 정확히 1개인 인터페이스 입니다. Java에서 람다는 이 함수형 인터페이스의 인스턴스로 취급돼요.

JAVA
// 직접 만드는 함수형 인터페이스
@FunctionalInterface
interface Calculator {
    int calculate(int a, int b); // 추상 메서드 1개
}

@FunctionalInterface 어노테이션은 선택사항이에요. 붙이면 컴파일러가 "추상 메서드가 1개인지" 검증해줍니다. 실수로 메서드를 2개 만들면 컴파일 에러가 나요.

자주 쓰는 내장 함수형 인터페이스

Java가 java.util.function 패키지에 미리 만들어둔 함수형 인터페이스가 있어요. 매번 직접 만들 필요 없이 이걸 가져다 쓰면 됩니다.

인터페이스메서드 시그니처역할
Predicate<T>boolean test(T t)조건 판별 (true/false)
Function<T, R>R apply(T t)입력 → 변환 → 출력
Consumer<T>void accept(T t)입력을 소비 (반환값 없음)
Supplier<T>T get()입력 없이 값 생성
UnaryOperator<T>T apply(T t)같은 타입 입력 → 출력
BiFunction<T, U, R>R apply(T t, U u)두 입력 → 변환 → 출력
JAVA
// Predicate — 조건 판별
Predicate<Integer> isEven = n -> n % 2 == 0;
System.out.println(isEven.test(4)); // true

// Function — 변환
Function<String, Integer> strLength = s -> s.length();
System.out.println(strLength.apply("Hello")); // 5

// Consumer — 소비
Consumer<String> printer = s -> System.out.println("출력: " + s);
printer.accept("안녕하세요"); // 출력: 안녕하세요

// Supplier — 생성
Supplier<Double> randomValue = () -> Math.random();
System.out.println(randomValue.get()); // 0.xxxx (랜덤)

기억하는 팁을 알려드리면, Predicate는 판별(boolean), Function은 변환(입력 → 출력), Consumer는 소비(반환값 없음), Supplier는 생성(입력 없음)입니다.

람다 표현식 기본 문법

람다 표현식은 함수형 인터페이스의 추상 메서드를 간결하게 구현하는 방법이에요.

PLAINTEXT
(매개변수) -> { 본문 }

기본 형태는 위와 같고, 상황에 따라 축약할 수 있습니다.

JAVA
// 기본 형태
(int a, int b) -> { return a + b; }

// 타입 추론 — 컴파일러가 타입을 알아서 추론
(a, b) -> { return a + b; }

// 본문이 한 줄이면 중괄호와 return 생략
(a, b) -> a + b

// 매개변수가 1개면 괄호 생략
n -> n * 2

// 매개변수가 없으면 빈 괄호
() -> System.out.println("Hello")

익명 클래스와 비교하면 차이가 분명해요.

JAVA
// 익명 클래스 — 형식적인 코드가 대부분
Comparator<String> comp1 = new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.length() - b.length();
    }
};

// 람다 — 실제 로직만 남김
Comparator<String> comp2 = (a, b) -> a.length() - b.length();

람다에서 외부 변수 사용

람다 안에서 외부 변수를 참조할 수 있어요. 단, 한 가지 조건이 있습니다.

JAVA
String prefix = "번호: "; // 사실상 final (effectively final)

Consumer<Integer> printWithPrefix = n -> {
    System.out.println(prefix + n); // 외부 변수 참조 OK
};

// prefix = "새로운 값"; // 이렇게 바꾸면 컴파일 에러!

외부 변수는 effectively final(선언 이후 값이 변하지 않는 변수)이어야 해요. 이 제약이 있는 이유는 람다가 실행되는 시점이 변수가 선언된 시점과 다를 수 있기 때문입니다. 변할 수 있는 변수를 참조하면 예측 불가능한 동작이 생길 수 있어요.

메서드 레퍼런스

람다 표현식이 ** 기존 메서드를 그대로 호출하기만 하는 경우 **, 메서드 레퍼런스로 더 간결하게 쓸 수 있어요.

JAVA
// 람다
names.forEach(name -> System.out.println(name));

// 메서드 레퍼런스 — 같은 동작
names.forEach(System.out::println);

:: 연산자가 메서드 레퍼런스를 나타냅니다. 네 가지 형태가 있어요.

1. 정적 메서드 레퍼런스

JAVA
// 클래스::정적메서드
Function<String, Integer> parser = Integer::parseInt;
// 동일: s -> Integer.parseInt(s)

2. 특정 객체의 인스턴스 메서드 레퍼런스

JAVA
// 객체::인스턴스메서드
String greeting = "Hello";
Supplier<Integer> lengthGetter = greeting::length;
// 동일: () -> greeting.length()

3. 임의 객체의 인스턴스 메서드 레퍼런스

JAVA
// 클래스::인스턴스메서드
Function<String, String> upper = String::toUpperCase;
// 동일: s -> s.toUpperCase()

여기서 헷갈릴 수 있는데, String::toUpperCase는 "임의의 String 객체에 대해 toUpperCase를 호출하겠다"는 뜻이에요. 첫 번째 매개변수가 메서드 호출 대상이 됩니다.

4. 생성자 레퍼런스

JAVA
// 클래스::new
Supplier<ArrayList<String>> listFactory = ArrayList::new;
// 동일: () -> new ArrayList<>()

Function<String, StringBuilder> sbFactory = StringBuilder::new;
// 동일: s -> new StringBuilder(s)

메서드 레퍼런스에 익숙해지면, "이 람다는 기존 메서드를 그대로 호출하는 것"이라는 의도가 더 명확하게 드러나요.

스트림이란?

** 스트림(Stream)**은 컬렉션의 요소를 함수형으로 처리하는 파이프라인이에요. 데이터를 저장하는 자료구조가 아니라, 데이터를 흘려보내며 변환하는 통로입니다.

JAVA
// 스트림은 컬렉션에서 생성한다
List<String> names = List.of("Alice", "Bob", "Charlie", "David");

// 스트림으로 처리
long count = names.stream()     // 스트림 생성
    .filter(n -> n.length() > 3) // 글자 수 3 초과만 필터
    .count();                    // 개수 세기

System.out.println(count); // 3 (Alice, Charlie, David)

스트림의 핵심 특성을 정리하면 다음과 같아요.

  • ** 원본을 변경하지 않습니다 **: 원본 컬렉션은 그대로 유지돼요
  • ** 지연 평가(Lazy Evaluation)**: 최종 연산이 호출될 때까지 중간 연산은 실행되지 않아요
  • ** 일회용입니다 **: 한 번 사용한 스트림은 다시 사용할 수 없어요

스트림 파이프라인

스트림은 세 단계로 구성됩니다.

PLAINTEXT
소스(Source) → 중간 연산(Intermediate) → 최종 연산(Terminal)
JAVA
List<String> result = names.stream()    // 1. 소스: 스트림 생성
    .filter(n -> n.length() > 3)         // 2. 중간 연산: 필터
    .map(String::toUpperCase)            // 2. 중간 연산: 변환
    .sorted()                            // 2. 중간 연산: 정렬
    .collect(Collectors.toList());       // 3. 최종 연산: 결과 수집

** 중간 연산 **은 새 스트림을 반환해요. 그래서 체이닝(연결)이 가능합니다. 그리고 최종 연산이 호출되기 전까지는 아무것도 실행되지 않아요. 이것이 ** 지연 평가 **입니다.

JAVA
// 지연 평가 확인 — 최종 연산이 없으면 아무것도 실행되지 않는다
names.stream()
    .filter(n -> {
        System.out.println("필터링: " + n); // 이 줄이 출력되지 않는다!
        return n.length() > 3;
    });
// 최종 연산이 없으므로 filter 안의 코드는 실행되지 않음

중간 연산

자주 쓰는 중간 연산을 하나씩 살펴볼게요.

filter — 조건에 맞는 것만 남기기

JAVA
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 짝수만 필터링
List<Integer> evens = numbers.stream()
    .filter(n -> n % 2 == 0)
    .collect(Collectors.toList());
// [2, 4, 6, 8, 10]

map — 각 요소를 변환하기

JAVA
List<String> names = List.of("alice", "bob", "charlie");

// 모두 대문자로 변환
List<String> upperNames = names.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());
// [ALICE, BOB, CHARLIE]

sorted — 정렬하기

JAVA
List<String> names = List.of("Charlie", "Alice", "Bob");

// 기본 정렬 (사전순)
List<String> sorted = names.stream()
    .sorted()
    .collect(Collectors.toList());
// [Alice, Bob, Charlie]

// 글자 수 기준 정렬
List<String> byLength = names.stream()
    .sorted(Comparator.comparingInt(String::length))
    .collect(Collectors.toList());
// [Bob, Alice, Charlie]

distinct — 중복 제거

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

List<Integer> unique = numbers.stream()
    .distinct()
    .collect(Collectors.toList());
// [1, 2, 3, 4]

flatMap — 중첩 구조 평탄화

flatMap은 처음에 좀 헷갈릴 수 있어요. 각 요소를 스트림으로 변환한 뒤, 그 스트림들을 하나로 합칩니다.

JAVA
// 2차원 리스트를 1차원으로 펼치기
List<List<Integer>> nested = List.of(
    List.of(1, 2, 3),
    List.of(4, 5),
    List.of(6, 7, 8, 9)
);

List<Integer> flat = nested.stream()
    .flatMap(Collection::stream) // 각 리스트를 스트림으로 변환 후 합침
    .collect(Collectors.toList());
// [1, 2, 3, 4, 5, 6, 7, 8, 9]
JAVA
// 문장을 단어로 분리
List<String> sentences = List.of("Hello World", "Java Stream");

List<String> words = sentences.stream()
    .flatMap(s -> Arrays.stream(s.split(" "))) // 각 문장을 단어 스트림으로
    .collect(Collectors.toList());
// [Hello, World, Java, Stream]

map은 "1대1 변환"이고, flatMap은 "1대다 변환 + 평탄화"라고 기억하면 돼요.

최종 연산

최종 연산이 호출되어야 파이프라인이 실행됩니다. 최종 연산은 스트림을 소비하고, 결과값(또는 부작용)을 만들어내요.

collect — 결과를 컬렉션으로 수집

JAVA
List<String> names = List.of("Alice", "Bob", "Charlie");

// List로 수집
List<String> nameList = names.stream()
    .filter(n -> n.length() > 3)
    .collect(Collectors.toList());

// Set으로 수집
Set<String> nameSet = names.stream()
    .collect(Collectors.toSet());

// 문자열로 합치기
String joined = names.stream()
    .collect(Collectors.joining(", "));
// "Alice, Bob, Charlie"

// Map으로 수집 (이름 → 글자 수)
Map<String, Integer> nameLength = names.stream()
    .collect(Collectors.toMap(
        name -> name,           // key
        name -> name.length()   // value
    ));
// {Alice=5, Bob=3, Charlie=7}

Java 16부터는 toList()를 직접 쓸 수 있어요.

JAVA
// Java 16+
List<String> result = names.stream()
    .filter(n -> n.length() > 3)
    .toList(); // Collectors.toList() 대신 간편하게

forEach — 각 요소에 대해 작업 수행

JAVA
names.stream()
    .filter(n -> n.startsWith("A"))
    .forEach(System.out::println); // Alice

forEach는 반환값이 없어요. 주로 출력이나 로깅에 씁니다.

count, min, max — 집계

JAVA
List<Integer> numbers = List.of(3, 1, 4, 1, 5, 9);

long count = numbers.stream().count(); // 6

Optional<Integer> min = numbers.stream().min(Integer::compareTo); // 1
Optional<Integer> max = numbers.stream().max(Integer::compareTo); // 9

reduce — 요소를 하나로 합치기

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

// 모든 요소의 합
int sum = numbers.stream()
    .reduce(0, (a, b) -> a + b); // 초기값 0, 누적 함수
// 15

// 초기값 없이 사용 — Optional 반환
Optional<Integer> product = numbers.stream()
    .reduce((a, b) -> a * b);
// Optional[120]

reduce는 "누적 연산"이에요. 초기값과 누적 함수를 받아서 요소들을 하나로 합칩니다. 초기값을 주지 않으면 결과가 비어 있을 수 있으므로 Optional을 반환해요.

Optional — null을 다루는 안전한 방법

Optional값이 있을 수도 있고 없을 수도 있음 을 명시적으로 표현하는 컨테이너예요. null을 직접 다루는 대신 Optional을 쓰면 NullPointerException을 예방할 수 있습니다.

Optional 생성

JAVA
// 값이 있는 경우
Optional<String> name = Optional.of("Alice");

// 값이 없는 경우
Optional<String> empty = Optional.empty();

// null일 수도 있는 값 — ofNullable 사용
String input = null;
Optional<String> maybe = Optional.ofNullable(input); // 비어 있는 Optional

// 주의: Optional.of(null)은 NullPointerException 발생!

Optional 값 꺼내기

JAVA
Optional<String> name = Optional.of("Alice");

// isPresent로 확인 후 꺼내기 (비추천 — if/null 체크와 다를 바 없다)
if (name.isPresent()) {
    System.out.println(name.get());
}

// ifPresent — 값이 있을 때만 실행
name.ifPresent(n -> System.out.println("이름: " + n));

// orElse — 값이 없으면 기본값 반환
String result = name.orElse("이름 없음");

// orElseGet — 값이 없을 때만 Supplier 실행 (비용이 큰 연산에 유리)
String result2 = name.orElseGet(() -> generateDefaultName());

// orElseThrow — 값이 없으면 예외 발생
String result3 = name.orElseThrow(() -> new IllegalArgumentException("이름 필수"));

orElse vs orElseGet 차이

이 차이는 꽤 중요해요.

JAVA
// orElse — 값이 있어도 기본값 표현식이 항상 실행됨
String name1 = Optional.of("Alice")
    .orElse(expensiveOperation()); // expensiveOperation()이 실행된다!

// orElseGet — 값이 없을 때만 Supplier가 실행됨
String name2 = Optional.of("Alice")
    .orElseGet(() -> expensiveOperation()); // 실행되지 않는다

비용이 큰 연산이라면 orElseGet을 쓰는 것이 맞습니다.

Optional과 스트림 조합

JAVA
// map으로 변환
Optional<String> name = Optional.of("alice");
Optional<String> upper = name.map(String::toUpperCase); // Optional[ALICE]

// flatMap — Optional을 반환하는 메서드와 조합
Optional<String> city = findUser("alice")
    .flatMap(User::getAddress)        // Optional<Address> 반환
    .flatMap(Address::getCity);       // Optional<String> 반환

Optional 사용 가이드

  • **반환 타입으로 사용 **: 권장합니다. "이 메서드는 결과가 없을 수 있다"는 것을 명확히 표현해요
  • ** 매개변수로 사용 **: 권장하지 않아요. 호출하는 쪽에서 Optional.of(value)로 감싸야 하니 번거롭습니다
  • ** 필드로 사용 **: 권장하지 않아요. OptionalSerializable이 아닙니다
  • ** 컬렉션을 Optional로 감싸지 않기 **: 비어 있는 컬렉션(Collections.emptyList())을 반환하는 것이 낫습니다
JAVA
// 좋은 예: 반환 타입
public Optional<User> findById(Long id) {
    return Optional.ofNullable(userMap.get(id));
}

// 나쁜 예: 매개변수
public void process(Optional<String> name) { // 하지 말 것
    // ...
}

실전 예제 — 학생 성적 처리

지금까지 배운 내용을 종합하는 예제예요. Student(name, subject, score)를 가정합니다.

JAVA
List<Student> students = List.of(
    new Student("김철수", "수학", 85),
    new Student("이영희", "수학", 92),
    new Student("박민수", "영어", 78),
    new Student("최지은", "수학", 95),
    new Student("정하늘", "영어", 88),
    new Student("김철수", "영어", 70),
    new Student("이영희", "영어", 96)
);

1. 수학 과목에서 90점 이상인 학생 이름

JAVA
List<String> mathTop = students.stream()
    .filter(s -> s.getSubject().equals("수학"))  // 수학 과목만
    .filter(s -> s.getScore() >= 90)              // 90점 이상
    .map(Student::getName)                        // 이름만 추출
    .collect(Collectors.toList());
// [이영희, 최지은]

2. 과목별 평균 점수

JAVA
Map<String, Double> avgBySubject = students.stream()
    .collect(Collectors.groupingBy(
        Student::getSubject,                      // 과목으로 그룹화
        Collectors.averagingInt(Student::getScore) // 평균 계산
    ));
// {수학=90.67, 영어=83.0}

3. 전체 최고 점수 학생

JAVA
Optional<Student> topStudent = students.stream()
    .max(Comparator.comparingInt(Student::getScore));

topStudent.ifPresent(s ->
    System.out.println("최고 점수: " + s)); // 이영희(영어: 96)

4. 학생별 전 과목 점수 합계

JAVA
Map<String, Integer> totalByStudent = students.stream()
    .collect(Collectors.groupingBy(
        Student::getName,                         // 학생 이름으로 그룹화
        Collectors.summingInt(Student::getScore)   // 점수 합계
    ));
// {김철수=155, 이영희=188, 박민수=78, 최지은=95, 정하늘=88}

5. 80점 이상 학생 이름을 쉼표로 연결

JAVA
String result = students.stream()
    .filter(s -> s.getScore() >= 80)
    .map(Student::getName)
    .distinct()                                    // 중복 이름 제거
    .sorted()                                      // 정렬
    .collect(Collectors.joining(", "));
// "김철수, 이영희, 정하늘, 최지은"

이렇게 스트림을 쓰면 반복문과 임시 변수 없이도 복잡한 데이터 처리를 깔끔하게 할 수 있어요.

주의할 점

1. 스트림은 재사용할 수 없다

JAVA
Stream<String> stream = names.stream().filter(n -> n.length() > 3);

stream.forEach(System.out::println); // 정상 동작
stream.forEach(System.out::println); // IllegalStateException 발생!

한 번 소비된 스트림은 다시 쓸 수 없어요. 다시 처리하고 싶으면 새 스트림을 만들어야 합니다.

2. 디버깅이 어려울 수 있다

체이닝된 스트림 파이프라인은 중간에 브레이크포인트를 걸기 어려워요. peek()을 활용하면 중간 결과를 확인할 수 있습니다.

JAVA
List<String> result = names.stream()
    .filter(n -> n.length() > 3)
    .peek(n -> System.out.println("필터 통과: " + n)) // 디버깅용 중간 확인
    .map(String::toUpperCase)
    .peek(n -> System.out.println("변환 결과: " + n)) // 디버깅용 중간 확인
    .collect(Collectors.toList());

3. parallelStream은 신중하게

JAVA
// 병렬 스트림 — 멀티 코어를 활용
List<Integer> result = numbers.parallelStream()
    .filter(n -> n % 2 == 0)
    .collect(Collectors.toList());

parallelStream()은 멀티 스레드로 처리해서 빠를 것 같지만, 항상 그런 건 아니에요.

  • ** 데이터가 적으면 **: 스레드 생성/관리 비용이 더 큽니다
  • ** 순서가 중요하면 **: 순서 보장이 안 될 수 있어요
  • ** 공유 자원에 접근하면 **: 동시성 문제가 발생할 수 있습니다
  • **ForkJoinPool을 공유 **: 기본적으로 공통 ForkJoinPool을 쓰므로 다른 작업에 영향을 줄 수 있어요

정말 데이터가 많고, 각 요소의 처리가 독립적이며, 순서가 중요하지 않을 때만 사용하세요.

4. 무조건 스트림이 낫진 않다

JAVA
// 단순 반복은 for문이 더 직관적일 수 있다
for (String name : names) {
    if (name.startsWith("A")) {
        System.out.println(name);
        break; // 하나 찾으면 바로 종료
    }
}

// 스트림으로 쓰면
names.stream()
    .filter(n -> n.startsWith("A"))
    .findFirst()
    .ifPresent(System.out::println);

두 코드 모두 괜찮아요. 무조건 스트림으로 바꿀 필요는 없습니다. 로직이 단순하면 for문이 더 읽기 쉬울 수도 있어요. 중요한 건 ** 팀의 컨벤션에 맞추는 것 **입니다.

TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

정리

정리

항목핵심
함수형 인터페이스추상 메서드가 1개인 인터페이스. 람다의 타입이 된다
람다 표현식(params) -> body. 함수형 인터페이스를 간결하게 구현
메서드 레퍼런스Class::method. 기존 메서드를 재사용
스트림 파이프라인소스 → 중간 연산(지연 평가) → 최종 연산(실행 트리거)
지연 평가최종 연산이 없으면 중간 연산은 실행되지 않는다
Optionalnull 대신 "값이 없을 수 있음"을 타입으로 표현. 반환 타입에만 사용
parallelStream데이터가 많고, 독립적이고, 순서 무관할 때만 사용

다음 글에서는 I/O와 NIO 를 다뤄요. InputStream부터 NIO의 Channel/Buffer까지, 자바의 입출력 체계를 한 번에 정리합니다. 파일을 읽고 쓰는 방법이 궁금하다면 이어서 보세요.

댓글 로딩 중...