우아한테크코스 레벨 1 자료를 참고하여 학습한 내용을 정리한 글입니다.

 

💭 들어가며

일반 for 문과 향상된 for 문에 익숙해져 있어 Lambda와 Stream API를 사용하지 않았다. 우아한테크코스 미션에서 Lambda와 Stream API를 활용하는 데 미숙한 나를 보며 계기로 학습의 필요성을 느꼈지만, 왜 for 문 대신 Stream API를 사용해야 하는지 잘 와닿지 않았다. 기존의 for 문도 충분히 가독성이 있다고 생각했기 때문이다. 따라서 이번 글에서는 람다 표현식(Lambda Expression), 함수형 인터페이스Stream API를 왜 사용하는지, 그리고 어떻게 활용하는지를 정리했다.

 

 

✅ 람다 표현식(Lambda Expression)

▶ 사용하는 이유

Java 8 이전에는 익명 클래스를 사용해 일회성 로직을 구현해야 하는 경우가 많았다. Lambda를 활용하면 더 간결하고 가독성 좋게 작성할 수 있다.

익명 클래스(Anonymous Class)이름이 없는 클래스로, 보통 일회성 객체를 생성할 때 사용된다. 특정 인터페이스를 구현하거나, 클래스를 확장해야 하지만 재사용할 필요가 없을 때 익명 클래스를 활용한다.

🔽 익명 클래스

@Test
@DisplayName("익명 클래스")
void test() {

    final var runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println("익명 클래스");
        }
    };
    runnable.run(); // 익명 클래스
}
  • 클래스 선언이 핵심 로직보다 많은 비중을 차지해 가독성이 떨어진다.
  • 메서드 하나만 구현해도 new 인터페이스명() {}을 사용해야 한다.
  • 재사용이 불가능하다.

 

▶ 사용 방법

(매개변수) -> { 실행문 }
  • 매개변수가 하나이면 ()를 생략 가능하다.
  • 실행문이 한 줄이면 {}를 생략 가능하다.
  • 타입을 명시하지 않아도 컴파일러가 추론한다.

 

▶ 예제

🔽 익명 클래스

@Test
@DisplayName("Hello, World! 문자열을 반환한다")
void test() {
    Supplier<String> supplier = new Supplier<String>() {
        @Override
        public String get() {
            return "Hello, World!";
        }
    };
    
    assertEquals("Hello, World!", supplier.get());
}

🔽 람다 표현식

@Test
@DisplayName("Hello, World! 문자열을 반환한다")
void test() {
    Supplier<String> supplier = () -> "Hello, World!";

    assertEquals("Hello, World!", supplier.get());
}

 

 

✅ 함수형 인터페이스(Functional Expression)

위에서 갑자기 Runnable, Supplier가 등장해서 당황했지만, 덕분에 함수형 인터페이스를 이해할 기회가 생겼다.

함수형 인터페이스(Functional Interface)는 단 하나의 추상 메서드만을 가지는 인터페이스로, 람다식을 활용할 수 있도록 도와준다. 이를 사용하면 불필요한 클래스를 만들지 않고도 간결하게 동작을 정의할 수 있다. 함수형 인터페이스의 목적은 Lambda 표현식을 활용해 함수형 프로그래밍을 구현하기 위함이다.

 

▶ 종류

함수형 인터페이스 설명
Runnable 매개변수 없이 실행하는 함수
Funtion<T, R> 입력을 받아 변환 후 반환하는 함수
Consumer<T> 매개변수를 받고 반환하는 값이 없는 함수
Supplier<T> 매개변수 없이 값을 반환하는 함수
Predicate<T> 입력을 받아 조건을 검사하는 함수
Optional<T> null 안정성을 위해 값을 감싸는 컨테이너

🔽 Runnable

@Test
@DisplayName("Runnable")
void test() {
    final Runnable runnable = () -> System.out.println("Runnable");
    runnable.run(); // Runnable
}

🔽 Function<T, R>

@Test
@DisplayName("Function")
void test() {
    final Function<String, String> function = value -> value;
    System.out.println(function.apply("Function")); // Function
}

🔽 Consumer<T>

@Test
@DisplayName("Consumer")
void test() {
    final Consumer<String> consumer = value -> System.out.println(value);
    consumer.accept("Consumer"); // Consumer
}

🔽 Supplier<T>

@Test
@DisplayName("Supplier")
void test() {
    final Supplier<String> supplier = () -> "Supplier";
    System.out.println(supplier.get()); // Supplier
}

🔽 Predicate<T>

@Test
@DisplayName("Predicate")
void Predicate() {
    final Predicate<String> predicate = value -> value.equals("Predicate");
    System.out.println(predicate.test("Predicate")); // true
}

🔽 Optional

@Test
@DisplayName("Optional")
void Optional() {
    final var optional = Optional.of("Optional");
    System.out.println(optional.get()); // Optional
}

 

 

✅ Stream API

▶ 사용하는 이유

컬렉션(List, Set, Map 등) 데이터를 함수형 스타일로 처리할 수 있도록 제공되는 API이다.

🔽 장점

  • 명령형 코드 대신 선언형 스타일을 사용할 수 있다.
  • 불변성을 유지하여 원본 데이터를 변경하지 않는다.
  • 메서드 체이닝(Chaining)으로 가독성을 향상한다.
  • 병렬 처리를 지원하여 성능을 향상할 수 있다. (parallelStream()을 사용하면 자동으로 여러 개의 스레드를 활용하여 데이터를 병렬 처리할 수 있다.)

 

▶ Stream API 종류

스트림 종류 설명
Stream<T> 객체(참조형) 스트림
IntStream, LongStream, DoubleStream 기본형(int, long, double) 스트림
ParallelStream 병렬 스트림

 

▶ 연산 종류

연산 종류 메서드 설명
생성
stream() 컬렉션에서 스트림 생성
Arrays.stream() 배열에서 스트림 생성
Stream.of() 직접 값을 전달하여 스트림 생성
중간 연산
filter() 조건에 맞는 요소만 걸러냄
map() 요소 변환
distinct() 중복 제거
sorted() 정렬 수행
limit() 처음 n개 요소만 선택
skip() 처음 n개 요소 건너뛰기
peek() 디버깅 용도로 요소를 중간 확인
최종 연산
forEach() 요소 순회
collect() 결과를 리스트, 맵 등으로 변환
reduce() 누적 연산 수행
count() 요소 개수 반환
min() 최소값 반환
max() 최대값 반환

🔽 stream()

List<String> crews = List.of("Brown", "Neo", "Miso");

crews.stream()
        .forEach(System.out::println); // Brown. Neo, Miso

🔽 Arrays.stream()

int[] numbers = {1, 2, 3, 4, 5};

Arrays.stream(numbers)
        .forEach(System.out::println); // 1, 2, 3, 4, 5

🔽 Stream.of()

Stream.of("Brown", "Neo", "Miso")
        .forEach(System.out::println); // Brown, Neo, Miso

🔽 filter(Predicate<T>)

List<String> crews = List.of("Brown", "Neo", "Brie", "Miso");

crews.stream()
        .filter(crew -> crew.startsWith("B"))
        .forEach(System.out::println); // Brown, Brie

🔽 map(Function<T, R>)

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

numbers.stream()
        .map(n -> n * n)
        .forEach(System.out::println); // 1, 4, 9, 16, 25

🔽 distinct()

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

numbers.stream()
        .distinct()
        .forEach(System.out::println); // 1, 2, 3, 4

🔽 sorted() - 오름차순, 내림차순

List<String> crews = List.of("Brown", "Neo", "Miso");

crews.stream()
        .sorted() // 기본적으로 오름차순
        .forEach(System.out::println); // Brown, Miso, Neo
List<String> crews = List.of("Brown", "Neo", "Miso");

crews.stream()
        .sorted(Comparator.reverseOrder()) // 내림차순
        .forEach(System.out::println); // Neo, Miso, Brown

🔽 limit(n)

Stream.of(1, 2, 3, 4, 5)
    .limit(3)
    .forEach(System.out::println); // 1, 2, 3

🔽 skip(n)

Stream.of(1, 2, 3, 4, 5)
    .skip(2)
    .forEach(System.out::println); // 3, 4, 5

🔽 peek(Consumer<T>)

List<String> crews = List.of("brown", "neo", "miso");

crews.stream()
    .peek(System.out::println) // brown, neo, miso
    .map(String::toUpperCase)
    .forEach(System.out::println); // BROWN, NEO, MISO

🔽 forEach(Consumer<T>)

Stream.of("Brown", "Neo", "Miso")
        .forEach(System.out::println);

🔽 collect(Collectors.toList())

List<String> crews = Stream.of("Brown", "Neo", "Miso")
        .collect(Collectors.toList()); // = toList()

System.out.println(crews); // [[Brown, Neo, Miso]
Java 16 이상에서는 collect(Collectors.toList()) 대신 toList()를 사용할 수 있다.
- toList(): 변경할 수 없는(Immutable) 리스트 반환
- Collectors.toList(): 변경 가능한(Mutable) 리스트 반환

🔽 reduce(identity, accumulator), reduce(accumulator)

int sum = Stream.of(1, 2, 3, 4, 5)
    .reduce(0, Integer::sum)

System.out.println(sum); // 15
  • 초기값(identity)을 기준으로 각 요소를 누적 연산
  • 스트림이 비어 있어도 초기값을 반환
Optional<Integer> sum = Stream.of(1, 2, 3, 4, 5)
    .reduce(Integer::sum);

System.out.println(sum.orElse(0)); // 15
  • 스트림의 첫 번째 요소를 초기값으로 사용, 이후 요소를 누적
  • 스트림이 비어 있으면 Optional.empty() 반환 (기본값을 처리하려면 orElse 사용)

🔽 count()

long count = Stream.of(1, 2, 3, 4, 5)
        .count();

System.out.println(count); // 5

🔽 min()

int min = Stream.of(1, 2, 3, 4, 5)
        .min(Integer::compare) // Optional
        .get();

System.out.println(min); // 1

🔽 max()

int max = Stream.of(1, 2, 3, 4, 5)
        .max(Integer::compare) // Optional
        .get();

System.out.println(max); // 5

 

▶ 예제

🔽 for문 - 성인 유저들의 이름을 나이순으로 정렬하여 출력

@Test
@DisplayName("성인인 유저들의 이름을 나이순으로 정렬하여 출력한다")
void test() {
    record User(String name, int age) {
    }

    final var users = List.of(
            new User("Brown", 78),
            new User("Neo", 90),
            new User("Brie", 12)
    );

    // 성인 필터
    final var filteredUsers = new ArrayList<User>(users);
    for (final var user : users) {
        if (user.age() < 20) {
            filteredUsers.remove(user);
        }
    }

    // 나이 기준으로 정렬
    final var sortedUsers = new ArrayList<User>(filteredUsers);
    for (int i = 0, end = sortedUsers.size(); i < end; i++) {
        for (int j = i + 1; j < end; j++) {
            if (sortedUsers.get(i).age() > sortedUsers.get(j).age()) {
                final var temp = sortedUsers.get(i);
                sortedUsers.set(i, users.get(j));
                sortedUsers.set(j, temp);
            }
        }
    }

    // 이름 추출
    final var names = new ArrayList<String>();
    for (final var user : sortedUsers) {
        names.add(user.name());
    }

    // 출력
    for (final var name : names) {
        System.out.println(name);
    }
}

🔽 Stream - 성인 유저들의 이름을 나이순으로 정렬하여 출력

@Test
@DisplayName("성인인 유저들의 이름을 나이순으로 정렬하여 출력한다")
void test() {
    record User(String name, int age) {
    }

    final var users = List.of(
            new User("Brown", 78),
            new User("Neo", 90),
            new User("Brie", 12)
    );

    users.stream()
            .filter(user -> user.age() >= 20) // 성인 필터 (가공)
            .sorted(Comparator.comparing(User::age)) // 나이 기준으로 정렬 (가공)
            .map(User::name) // 이름 추출 (가공)
            .forEach(System.out::println); // 출력 (소비)
}

 

 

📍 참고 자료

soeun2537