| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 자바
- 코딩테스트
- 프로그래밍기초
- 클린코드
- 자바개발
- 메모리관리
- JVM
- 개발자취업
- 자바프로그래밍
- HashMap
- 개발공부
- 알고리즘
- 자바공부
- 코딩인터뷰
- 코딩테스트준비
- Java
- 가비지컬렉션
- 코딩공부
- 코딩테스트팁
- 정렬
- 예외처리
- 객체지향
- 자바기초
- 멀티스레드
- 개발자팁
- 자료구조
- 알고리즘공부
- 파이썬
- 백준
- 프로그래머스
- Today
- Total
코드 한 줄의 기록
Java 람다·스트림으로 선언적 데이터 처리하기 - 함수형 코딩 가이드 본문
코드를 짤 때마다 같은 패턴이 반복된다는 생각이 들었다. 데이터를 가져오고, 필터링하고, 변환하고, 결과를 모은다. for 루프로 하나씩 지정해야 했던 그 과정들이 말이다. Java 8에서 람다 표현식과 Stream API가 나오기 전에는 이게 그냥 당연한 방식이었다.
선언적 데이터 처리라는 게 처음에는 뭔가 거창해 보였다. 하지만 알고 보니 간단한 아이디어였다. 데이터를 어떻게 처리할지 구체적으로 말하지 말고, 무엇을 처리할 것인지만 말하는 것이다. 명령형(imperative)에서 함수형(functional)으로 관점을 바꾸는 것 정도라고 생각하면 된다.
기존 방식과의 차이
객체 리스트에서 특정 조건을 만족하는 것들의 이름을 추출하는 작업을 예로 들어보자. 예전 방식이라면 이렇게 썼다.
List<String> names = new ArrayList<>();
for (Person person : persons) {
if (person.getAge() > 30) {
names.add(person.getName());
}
}이 코드는 명확하긴 하다. 하나씩 돌면서, 조건을 확인하고, 이름을 담는다. 그런데 코드가 길어진다. 특히 리스트가 여러 개이고 작업이 복잡해지면 코드 양이 늘어난다. 더 큰 문제는 우리의 의도가 코드 안에 섞여 있다는 것이다. 반복문과 조건문의 기계적 구조 속에 "30세 이상의 사람들 이름을 뽑아야겠다"는 진짜 의도가 묻혀있다.
Stream과 람다를 쓰면
List<String> names = persons.stream()
.filter(person -> person.getAge() > 30)
.map(Person::getName)
.collect(Collectors.toList());훨씬 짧고, 무엇을 하는지가 드러난다. 이게 선언적 접근 방식이다. 어떤 원소들을 필터링하고, 이름으로 매핑하고, 리스트로 수집한다는 의도가 명확하다.
람다 표현식부터 시작하기
Stream을 사용하려면 람다 표현식부터 이해해야 한다. 람다는 함수형 인터페이스(functional interface)를 구현하는 간결한 방식이다.
함수형 인터페이스는 단 하나의 추상 메서드를 가진 인터페이스다. 예를 들어 Comparator, Runnable, Consumer 같은 것들이다.
// 기존 방식: 익명 클래스
Comparator<Integer> comp = new Comparator<Integer>() {
@Override
public int compare(Integer a, Integer b) {
return a - b;
}
};
// 람다 방식
Comparator<Integer> comp = (a, b) -> a - b;람다의 문법은 (파라미터) -> 본문 형태다. 파라미터가 하나면 괄호를 생략할 수 있다. 본문이 단순하면 중괄호를 쓰지 않아도 된다.
파라미터 타입도 생략 가능하다. 컴파일러가 문맥에서 추론할 수 있으니까. 그래서 위 예제에서 (Integer a, Integer b) -> a - b라고 쓸 필요가 없는 것이다.
더 복잡한 예를 보자.
// 파라미터가 없는 경우
Supplier<String> supplier = () -> "Hello";
// 파라미터가 여러 개
BiFunction<Integer, Integer, Integer> add = (x, y) -> x + y;
// 본문이 여러 줄
Function<String, Integer> parseLength = (str) -> {
System.out.println("문자열 길이: " + str);
return str.length();
};람다의 본문에서 여러 줄을 쓸 때는 중괄호와 return을 명시해야 한다. 한 줄짜리 표현식은 자동으로 반환된다.
Stream 기초 이해하기
Stream은 데이터 소스의 원소들을 함수형 스타일로 처리하는 추상화다. 컬렉션과 다르다. 컬렉션은 데이터를 저장하고, Stream은 데이터를 처리한다.
Stream의 특징을 정리해보면. 첫째, 원본 데이터를 변경하지 않는다. 새로운 결과를 만들어낸다. 둘째, 한 번만 사용할 수 있다. 같은 Stream을 두 번 이상 쓰면 예외가 발생한다. 셋째, 지연 처리(lazy evaluation)를 한다. 중간 작업들은 터미널 작업이 호출될 때까지 실행되지 않는다.
Stream을 만드는 방법들
// 컬렉션에서 만들기
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = list.stream();
// 배열에서 만들기
String[] array = {"a", "b", "c"};
Stream<String> stream = Arrays.stream(array);
// 직접 만들기
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
// 빈 Stream
Stream<Integer> emptyStream = Stream.empty();
// 범위로 만들기
IntStream rangeStream = IntStream.range(0, 10); // 0부터 9까지
IntStream closedStream = IntStream.rangeClosed(0, 10); // 0부터 10까지Stream은 중간 작업(intermediate operation)과 터미널 작업(terminal operation)으로 구성된다. 중간 작업은 Stream을 반환하므로 연쇄할 수 있다. filter, map, flatMap, distinct, sorted 같은 것들이다. 터미널 작업은 최종 결과를 반환하므로 Stream을 소모한다. collect, forEach, reduce, findFirst 같은 것들이다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> result = numbers.stream() // Stream 생성
.filter(n -> n > 5) // 중간 작업
.map(n -> n * 2) // 중간 작업
.collect(Collectors.toList()); // 터미널 작업
System.out.println(result); // [12, 14, 16, 18, 20]이 코드에서 filter와 map은 중간 작업이고, collect는 터미널 작업이다. 또 중요한 건 filter와 map이 실제로 실행되는 시점이다. collect가 호출될 때까지 대기했다가, collect가 호출되면 그때 filter와 map이 순차적으로 실행된다. 이게 지연 처리다.

자주 쓰는 중간 작업들
filter는 조건을 만족하는 원소만 통과시킨다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> evens = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
// [2, 4, 6]map은 각 원소를 다른 값으로 변환한다.
List<String> words = Arrays.asList("hello", "world");
List<Integer> lengths = words.stream()
.map(String::length)
.collect(Collectors.toList());
// [5, 5]여기서 String::length는 메서드 참조(method reference)다. s -> s.length()와 같다. 메서드 참조는 람다를 더 간결하게 쓸 수 있는 방법이다.
flatMap은 map과 비슷하지만, 각 원소를 Stream으로 변환한 후 모든 Stream을 하나로 병합한다.
List<List<Integer>> nestedList = Arrays.asList(
Arrays.asList(1, 2),
Arrays.asList(3, 4),
Arrays.asList(5, 6)
);
List<Integer> flat = nestedList.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
// [1, 2, 3, 4, 5, 6]중첩된 리스트를 평탄화할 때 유용하다.
distinct는 중복을 제거한다.
List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 3, 3);
List<Integer> unique = numbers.stream()
.distinct()
.collect(Collectors.toList());
// [1, 2, 3]sorted는 정렬한다.
List<Integer> numbers = Arrays.asList(5, 2, 8, 1, 9);
List<Integer> sorted = numbers.stream()
.sorted()
.collect(Collectors.toList());
// [1, 2, 5, 8, 9]
// 역순 정렬
List<Integer> reverseSorted = numbers.stream()
.sorted(Collections.reverseOrder())
.collect(Collectors.toList());
// [9, 8, 5, 2, 1]
// 커스텀 정렬
List<String> words = Arrays.asList("apple", "pie", "zebra");
List<String> byLength = words.stream()
.sorted(Comparator.comparingInt(String::length))
.collect(Collectors.toList());
// [pie, apple, zebra]limit은 처음 n개 원소만 통과시킨다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> first3 = numbers.stream()
.limit(3)
.collect(Collectors.toList());
// [1, 2, 3]skip은 처음 n개 원소를 건너뛴다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> afterSkip = numbers.stream()
.skip(2)
.collect(Collectors.toList());
// [3, 4, 5]터미널 작업으로 결과 얻기
collect는 Stream의 원소들을 컬렉션이나 다른 형태로 수집한다.
List<Integer> toList = numbers.stream()
.filter(n -> n > 2)
.collect(Collectors.toList());
Set<Integer> toSet = numbers.stream()
.collect(Collectors.toSet());
Map<Integer, String> toMap = words.stream()
.collect(Collectors.toMap(
String::length, // key
w -> w // value
));Collectors는 다양한 수집 방법을 제공한다.
forEach는 각 원소에 대해 특정 작업을 수행한다.
numbers.stream()
.filter(n -> n % 2 == 0)
.forEach(System.out::println);reduce는 Stream의 모든 원소를 하나의 값으로 축약한다.
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b);
// 또는
int sum = numbers.stream()
.reduce(Integer::sum)
.orElse(0);
int product = numbers.stream()
.reduce(1, (a, b) -> a * b);reduce의 첫 번째 인자는 초기값(identity)이고, 두 번째는 누적 함수(accumulator)다. 초기값 없이 호출하면 Optional을 반환한다.
findFirst는 Stream의 첫 번째 원소를 반환한다. Optional로 감싸져서 나온다.
Optional<Integer> first = numbers.stream()
.filter(n -> n > 3)
.findFirst();
if (first.isPresent()) {
System.out.println(first.get());
} else {
System.out.println("찾을 수 없음");
}count는 Stream의 원소 개수를 반환한다.
long count = numbers.stream()
.filter(n -> n % 2 == 0)
.count();anyMatch, allMatch, noneMatch는 조건 검사 결과를 boolean으로 반환한다.
boolean hasEven = numbers.stream()
.anyMatch(n -> n % 2 == 0);
boolean allPositive = numbers.stream()
.allMatch(n -> n > 0);
boolean noneNegative = numbers.stream()
.noneMatch(n -> n < 0);
실무 활용 예제들
객체 리스트에서 원하는 정보를 추출하는 것은 매우 흔한 작업이다. 사원 정보를 가진 객체가 있다고 하자.
class Employee {
private String name;
private int salary;
private String department;
private int yearsOfService;
// constructor, getters
}이 리스트에서 급여가 5000만 원 이상인 사원들의 이름을 부서별로 그룹화하는 작업
Map<String, List<String>> groupedByDept = employees.stream()
.filter(e -> e.getSalary() >= 50000000)
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.mapping(Employee::getName, Collectors.toList())
));Collectors.groupingBy는 특정 조건으로 그룹화할 때 유용하다.
부서별 평균 급여를 구하는 것도
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingDouble(Employee::getSalary)
));모든 사원의 이름을 쉼표로 연결하는 것
String allNames = employees.stream()
.map(Employee::getName)
.collect(Collectors.joining(", "));특정 조건을 만족하는 첫 번째 사원을 찾되, 없으면 기본값 처리
Employee senior = employees.stream()
.filter(e -> e.getYearsOfService() > 10)
.max(Comparator.comparingInt(Employee::getSalary))
.orElse(null);Stream에서 여러 조건을 조합할 때는 filter를 여러 번 쓸 수 있다.
List<Employee> filtered = employees.stream()
.filter(e -> e.getSalary() >= 40000000)
.filter(e -> e.getYearsOfService() >= 5)
.filter(e -> "Engineering".equals(e.getDepartment()))
.collect(Collectors.toList());또는 람다 안에서 논리 연산자를 사용
List<Employee> filtered = employees.stream()
.filter(e -> e.getSalary() >= 40000000 &&
e.getYearsOfService() >= 5 &&
"Engineering".equals(e.getDepartment()))
.collect(Collectors.toList());성능을 생각해야 할 순간들
Stream이 항상 for 루프보다 빠른 건 아니다. 오버헤드가 있을 수 있다. 특히 작은 컬렉션을 처리할 때는 기존 방식이 더 빠를 수도 있다.
병렬 처리가 필요하다면 parallelStream을 쓸 수 있다.
List<Integer> result = largeList.parallelStream()
.filter(n -> n % 2 == 0)
.map(n -> n * 2)
.collect(Collectors.toList());병렬 처리는 큰 데이터셋이고 CPU 집약적인 작업일 때 효과가 있다. 작은 데이터나 I/O 집약적인 작업에서는 오버헤드가 더 클 수 있다.
함께 알아두면 좋은 것들
Optional은 Stream과 자주 함께 나온다. null 체크를 보다 함수형 방식으로 처리할 수 있게 해준다.
optional.ifPresent(System.out::println);
optional.ifPresentOrElse(
System.out::println,
() -> System.out.println("값이 없음")
);
String value = optional.orElse("기본값");
String value = optional.orElseThrow(() -> new RuntimeException("값 없음"));메서드 참조는 람다를 더 읽기 좋게 만들어준다. 패턴을 알면 자동으로 깔끔해진다.
// 람다
stream.map(s -> s.length())
// 메서드 참조
stream.map(String::length)
// 생성자 참조
stream.map(String::new)
// 정적 메서드 참조
stream.map(Integer::parseInt)Stream API를 처음 배울 때는 무조건 이해해야 한다고 생각할 필요는 없다. 자주 쓰는 filter, map, collect 부터 시작해서 천천히 익히면 된다. 코드를 짜다 보면 "아, 이렇게 하면 되겠네"라는 감각이 생긴다. 그다음에 더 고급 기능들을 들여다봐도 늦지 않는다.
지금 와서 생각해보니 Stream과 람다를 배웠을 때 가장 도움이 된 건 기존 방식과의 대비였다. for 루프로 짠 코드를 Stream으로 다시 썼을 때 코드가 어떻게 달라지는지 보는 것이다. 그러다 보면 선언적 접근이 뭔지, 왜 좋은지가 자연스럽게 느껴진다. 실제로 프로젝트에서 자주 쓰다 보면 이제 기존 방식으로 돌아가기 어렵다.
Java Maven 기본 완벽 가이드 - POM, 라이프사이클, 좌표 개념 정리
자바 개발을 하다 보면 Maven을 꼭 마주하게 됩니다. 특히 회사에서 여러 프로젝트를 관리하거나 복잡한 의존성을 다룰 때 Maven의 필요성을 절실하게 느끼게 되죠. 저도 처음에 Maven을 접했을 때는
byteandbit.tistory.com
'JAVA' 카테고리의 다른 글
| Java Record와 데이터 캐리어 모델: 간결하고 불변한 데이터 구조 구축하기 (0) | 2026.01.18 |
|---|---|
| Java Maven 기본 완벽 가이드 - POM, 라이프사이클, 좌표 개념 정리 (0) | 2025.12.31 |
| Java 유닛 테스트 완벽 정리: JUnit 5와 단언(Assertions) 개념부터 실전까지 (0) | 2025.12.30 |
| Gradle 기본기 완벽 정리: DSL, Task, Dependency 실무 가이드 (0) | 2025.12.29 |
| [자바/Mockito] 테스트 더블 완벽 정리: Mock과 Stub 차이부터 BDD까지 (0) | 2025.12.28 |
