우아한테크코스 레벨 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); // 출력 (소비)
}
📍 참고 자료
'Programming > Java' 카테고리의 다른 글
[Java] 인터페이스, 추상 클래스, 일반 상속 (0) | 2025.03.22 |
---|---|
[Java] 제네릭(Generic) (0) | 2025.03.17 |
[Java] 컬렉션 프레임워크(Collection Framework) (0) | 2025.03.11 |
[Java] 테스트 코드, JUnit & AssertJ (0) | 2025.02.21 |
[Java] Enum (5) | 2024.11.05 |