
들어가며
자바 공부를 계속 진행하다 보면 우리가 흔히 쓰는 일반 for문이 아니라 람다 표현식, 스트림, 컬렉션 API 등으로 이루어진 코드를 많이 접하게 된다. 더불어 AI들도 특히 Stream()문법으로 로직을 깔끔하게 제공해주기도 한다. 처음에는 많이 낯설었지만, 지금은 오히려 더 깔끔해보이기도 하고 성능까지도 좋다고 알고있다.
하지만 왜 좋은지 확신을 못하고, AI가 구현해준 자바 8 이후의 로직은 조금만 길어져도 와닿지 않는다. 시간이 없을때는 이해 못한 코드를 결과만 체크하고 PR을 올린 적도 있는 것 같다. 이런 것이 바이브하다고는 하지만 난 아직 이런 찝찝한 느낌이 너무 싫다.
그래서 "자바 8 이후로 어떤 혁신이 있었기에 자바가 이렇게까지 유명해질 수 있었는가!" 를 잘 서술해놓은 모던 자바 인 액션을 읽어보고 정리해보려고 한다.
본론으로
1️⃣ 자바 8, 9, 10, 11에는 무슨 일이 일어나고 있는가?
책의 초반부는 앞으로 어떤 내용을 소개할 것이고, 자바가 어떤 이유로 해당 기능을 제공하게끔 발전했는지에 대한 당위성을 부여한다. 몇가지 인상깊었던 부분을 적어본다.
메서드를 값으로 취급하고 인자로 넘길 수 없을까?-> 메서드 참조(method reference)
아래 코드는 디렉터리에서 모든 숨겨진 파일을 필터링하는 3줄의 코드다. 자바 8 이전에는 아래 예제처럼 FileFileter 객체 내부에 위치한 isHidden의 결과를 받아 결과를 File.listFiles 메서드로 전달하는 방법으로 숨겨진 파일을 필터링 할 수 있었다.
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
public boolean accept(File file) {
return file.isHidden();
}
});
뭔가 맘에 들지 않는다. File에는 이미 isHidden()이라는 메서드가 존재하는데, 굳이 FileFilter로 isHidden을 복잡하게 인스턴스화 하여 감싼 다음에 인자로 넘겨야 할까? 자바 8 이전에는 메서드를 인자로 넘길 방법이 없었기에 이게 최선이었다.
자바 8에서는 아래와 같이 구현 가능하다.
File[] hiddenFiles = new File(".").listFiles(File::isHidden);
매우 멋지다..! 자바 8의 메서드 참조 "::" (이 메서드를 값으로 사용하라!)를 이용해서 메서드를 인자로 넘겼다! 마치 값처럼 말이다.
지금은 당연한 것처럼 느껴지지만, 자바 8이전에는 메서드를 인자로 못넘기니까 답답한 부분이 많았겠다 싶다 ㅋㅋㅋ.

뭔가 느낌이 오지 않는가? 그렇다! 자바 8 이후에는 메서드를 값으로 넘길 수 있게 되면서, 함수형 프로그래밍의 패러다임이 시작된다고 표현한다.
이런식으로 앞으로 설명할 자바 8, 9, 10, 11 에서 제공하는 혁신적인 기능들의 간단한 소개를 하며 흥미를 불러일으킨다.
2️⃣, 3️⃣ 동작 파라미터화 코드 전달하기, 람다 표현식, 메서드 참조
아래 사진은 많은 조건에 대응할 수 있는 사과의 조건을 검증하는 로직을 추상화하는 사진이다. 이를 전략을 갈아끼우는 식으로 디자인했다고 해서 전략패턴이라고도 부른다.

하지만 이러한 디자인 패턴으로 중복되는 코드를 줄였다고 하더라도 결국 객체를 인자로 보내는 것이기에, 어딘가에 클래스를 생성해야하고 그것을 구현해야하는 등의 불필요한 코드가 중복되게 된다.
이런 복잡한 과정을 간소화 하기 위해 자바에서 다양한 방법을 제공한다. 먼저 익명 클래스를 활용한 람다 표현식이다. 여기서 익명 클래스란 정의되지 않은 클래스인데, 이를 람다 표현식을 통해 정의할 수 있다.
Runnable r1 = new Runnable() { // -> 익명 클래스를 직접 구현
public void run() {
System.out.println("Hello World 1");
}
};
Runnable r2 = () -> System.out.println("Hello World 2"); // 람다로 익명클래스를 구현
(parameters) -> expression
(parameters) -> { statements; }
List<Apple> greenApples = filter(inventory, (Apple a) -> GREEN.equals(a.getColor()));
위처럼 전략 패턴을 위해 준비해놨던 코드를 람다 표현식으로 그 즉시 구현하여 불필요한 코드를 줄일 수가 있게 된다!
함수형 인터페이스? 함수 디스크립터?
책을 읽다보면 람다와 함께 함수형 인터페이스, 함수 디스크립터라는 표현이 나온다. 익명 클래스를 람다로 구현하던, 직접 구현하던 그 목적은 메서드를 인자로 넘기기 위함이 아닌가?! 그렇다면 각기 다른 반환값마다 boolean을 반환하는 추상메서드, void를 반환하는 추상메서드를 각각 구현하기엔, 정리된 기능을 제공하는 자바측에서도 혼선이 생길 것이다.
즉 메서드를 파라미터화 하기 위해서 정의된 추상메서드, 함수형 인터페이스를 제공한다. 함수형 인터페이스는 하나의 추상 메서드만을 정의하는 인터페이스이다. 종류에는 boolean을 반환하는 Predicate<T>, void를 반환하는 Runnable 등등 매우 많다.
public interface Predicate<T> {
boolean test (T t);
}
public interface Runnable {
void run();
}
아래 블로그에도 자세하게 정리했던 적이 있다.
https://geniusjun4663.tistory.com/51
[JAVA] 함수형 인터페이스란?(Supplier<T>, Predicate<T>를 적용하게 된 이유를 중점으로)
들어가며우테코 프리코스 3주차 문제를 풀다보니 문제는 어찌저찌 풀겠지만 중복되는 코드가 너무 많았다. 나는 이 부분을 해결하기 위해 함수형 인터페이스를 활용하였다! 중복되는 코드를 줄
geniusjun4663.tistory.com
그렇다면 함수 디스크립터는 무엇일까? 정의는 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터(Function Descriptor) 라고 부른다. 이게 무슨 소리일까? 이해를 위해 자바에서 제공하는 함수형 인터페이스인 Runnable 인터페이스를 보자.
public interface Runnable {
void run();
}
유일한 추상 메서드 run은 인수와 반환값이 없으므로(void 반환) Runnable 인터페이스는 인수와 반환값이 없는 시그니처로 생각할 수 있다.
음,,, 그래 뭐,, 람다가 서술하는 시그니처를 그런 용어로 부르는 구나.. 까지는 알겠는데,, 이게 왜 중요할까? 어떤 콘텍스트에서 기대되는 람다 표현식의 형식(파라미터나 람다가 할당되는 변수 등)을 대상 형식(target type)이라고 부르는데, 아래 그림을 먼저 보자.

이렇게 자바 컴파일러는 람다 표현식이 사용된 콘텍스트(대상 형식)를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다!! 즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론할 수 있는 것이다. 컴파일러의 형식 추론을 통해 아래와 같이 형식을 제외시키며 람다 표현식을 쓸 수 있다.
// 형식을 추론 하지 않고 명시적으로 나타낸 람다 표현식
Comparator<Apple> c = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeigth());
// 형식을 추론을 믿고 생략한 람다 표현식, 컴파일 오류가 나지 않는다!
Comparator<Apple> c = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());
상황에 따라 명시적으로 형식을 포함하는 것이 좋을 때도 있고 형식을 배제하는 것이 가독성을 향상시킬 때가 있다. 어떤 방법이 좋은지 정해진 규칙은 없으므로 스스로 어떤 코드가 좋을지는 이해한 상태에서 적용해야 하는 부분인 것 같다.
람다 표현식을 더 가독성 있게 바꾸는 방법 메서드 참조
이 글의 본문 첫번째에서 이미 메서드 참조(::)에 대해서 설명했었는데, 람다를 이해한 이제는 조금 더 깊게 이해할 수 있다. 자바 8 코드의 새로운 기능인 메서드 참조는 특정 메서드만을 호출하는 람다의 형식을 축약한여 가독성을 높일 수 있다. 아래 예제를 보자.
(Apple apple) -> apple.getWeight() >>>> Apple::getWeigth
(str, i) -> str.substring() >>>> String::substring
직관적으로 이해가 가지 않는가? 메서드 참조를 새로운 기능이 아니라 하나의 메서드를 참조하는 람다를 편리하게 표현할 수 있는 문법으로 간주할 수 있다. 아래처럼 private한 헬퍼메서드를 선언하여 public한 곳에서 가독성 좋게끔 사용할 수도 있다.
filter(words, this::isValidName)
private boolean isValidName(String name){
return Character.isUpperCase(name.charAt(0));
}
4️⃣ 5️⃣ 스트림 소개 및 활용
질의형 문법인 SQL에서는 SELECT name FROM dishes WHERE calorie < 400 라는 한줄의 쿼리로 원하는 값을 가져올 수 있다. 자바라면 반복자나 if (x < 400) 이라면 자료구조에 담는 행위(list.add)나 누적자를 두는 등의 행위를 해야할텐데, SQL 질의에서는 우리가 기대하는 것을 얻기 위해 "어떻게" 가져올지는 구현할 필요 없으며 구현은 자동으로 제공된다.
자바의 컬렉션 자료구조에서도 분명 많은 요소를 포함할텐데, 매번 if문으로 조건을 표시하고 임시 변수를 선언하는 등의 로직을 선언하고 메서드를 분리해야할까? 자바는 이런 불편한 점을 해소해주기 위해서 스트림(Stream)을 제공한다.
스트림은 자바 8 API에 새로 추가된 내용인데, 스트림을 이용하면 선언형(즉, 데이터를 처리하는 임시 구현 코드 대신 질의로 표현할 수 있다)으로 컬렉션 데이터를 처리할 수 있게된다.
아래 로직을 보자! 마치 SQL 쿼리문처럼 한눈에 들어오지 않는가! "어떻게" 400 칼로리 이하의 요리를 선택할 것인지, 어떻게 정렬할 것인지는 우리가 자세히 알 필요 없이 질의 형식으로 로직을 구현할 수 있게 되었다
List<String> lowCalroicDishsesName =
menu.stream()
.filter(d -> d.getCalories() < 400) // 400칼로리 이하의 요리 선택
.sorted(comparing(Dish::getCalories)) // 칼로리로 요리 정렬
.map(Dish::getName) // 요리명 추출
.collect(toList()); // 모든 요리명을 리스트에 저장
filter().. sorted().. map() 등은 이름으로 직관적으로 알 수 있는데, collect(toList())는 왜 해주는 걸까? 아래 사진을 보자!

각자의 일을 한다음에 다들 Stream<>을 반환하고 있다. collect 메서드만 마지막에 List<String> 형식을 반환하기때문에 우리는 List 컬렉션에 스트림의 결과를 담을 수 있는 것이다. 왜 이렇게 설계 했을까? 아래 사진을 보자!

스트림은 중간연산과 최종연산을 나눠놓았다. 중간 연산은 스트림을 계속 반환하다가, 최종 연산이 호출 되는 순간 한번에(lazy)하게 계산을 처리한다. 그 이유는 성능/표현력/안전성 측면에서 여러가지가 있다.
- 불필요한 계산을 줄이기 위함이다.(shortCut)
- findFirst(), limit(), anyMatch() 처럼 끝까지 돌고도 답이 안나오는 상황은 당연히 발생한다.
- filter하고.. map으로 매핑 다 했는데, findFirst()는 답 하나만 찾으면 종료된다.
- 만약 중간 연산마다 다 계산한다면 마지막엔 결국 하나만 찾으면 계산이 종료될텐데, 불필요한 계산이 발생한다.
- 여러 단계의 계산을 하나의 흐름으로 합칠 수 있다.
- filter한 것을 임시 변수에 담고, limit도 한 것도 새로 변수에 담는다면 그때마다 임시 컬레션 자료구조를 선언해야 한다.
- 여러번 반복문 돌고, 성능/메모리 측면에서 매우 별로다.
- 결과들을 Stream()으로 흘러보내 lazy하게 계산한다면, 중간 결과들을 하나의 흐름에 담을 수가 있다.
- "선언형(무엇을)" 으로 쓰게 만들고, 구체적인 구현은 숨기기 위함이다.
- 어떻게 반복문을 돌리는지, 필터하는지 알 필요 없다.
- 무엇을 할지에만 집중하면 된다.
책에서는 filter(), flatMap(), findFirst() 등등 여러가지를 설명해주는데, 질의 형식이라 그런지 다들 직관적으로 가능했고 그중에 모든 스트림 요소를 처리해서 값으로 도출하는 reducing(리듀싱)기능은 혁신적인 것 같아 적어본다. 들어가기 전에 for-each로 숫자 요소를 더하는 로직을 리듀싱 기능으로 바꾼 것을 보자.
int sum = 0;
for(int x : numbers) {
sum += x;
}
int sum = numbers.stream().reduce(0, (a,b) -> a+b);
위처럼 애플리테이션의 반복된 패턴을 추상화 할 수 있다. 인자로는 초깃값(0) 과 두 요소를 조합해서 새로운 값을 만드는 람다 표현식(예제에서는 (a, b) -> a + b)을 사용헀다. reduce 어원 처럼 컬레션의 요소를 "소비"한다는 느낌이 너무 좋다.
아래는 map과 reduce메서드를 이용해서 스트림의 요리 개수를 계산하는 로직이다.
int count = menu.stream()
.map(dish -> 1)
.reduce(0, (a, b) -> a+b);
스트림의 각 요소를 1로 매핑한 다음에 reduce로 이들의 합계를 계산하는 방식으로 문제를 해결 할 수 있다. 즉 스트림에 저장된 숫자를 차례로 더한다.
map과 reduce를 연결하는 기법은 맵 리듀스(map-reduce) 패턴이라 하며, 쉽게 병렬화하는 특징 덕분에 구글이 웹 검색에 적용하면서 유명해졌다.
6️⃣ 스트림으로 데이터 수집
아래는 "통화별로 트랜잭션을 그룹화한 코드(어떻게 그룹화할건지 구현한 명령형 코드)"이다.
// 그룹화한 트랜잭션을 저장할 맵을 생성
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();
for(Transaction transaction : transaction) {// 트랜잭션 리스트를 반복
Currency currency = transaction.getCurrency(); // 트랜잭션의 통화를 추출한다.
List<Transaction> transcationsForCurrency = transactionsByCurrencies.get(currency);
if(transactionsForCurrency == null) { // 현재 통화를 그룹화하는 맵에 항목이 없으면 항목을 만든다.
transactionsForCurrency = new ArrayList<>();
transactionsByCurrencies.put(currency, transactionsForCurrency);
}
transcationsForCurrency.add(transaction); // 같은 통화를 가진 트랜잭션 리스트에 현재 탐색중인 트랜잭션 추가
이 로직이 주석과 함께 잘 읽힌다면 당신은 경험이 많은 자바 개발자가 분명하다. 하지만 간단한 작업임에도 코드가 너무 길다는 사실은 부정하기 어렵다. 무엇을 실행하는지 한눈에 파악하기는 어렵다. 이를 collect 메서드에 전달함으로써 아래와 같이 한줄로 정리가 가능하다.
Map<Currency, List<Transaction>> transactionsByCurrencies =
transactions.stream().collect(groupBy(Transaction::getCurrency));
컬렉터가 무엇이길래 이렇게 한번에 데이터를 수집할 수 있게 해줄까? 결론 먼저 말하자면 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다. 우리가 위에서 배웠던 스트림의 최종연산 중 하나였던 collect(toList()) 메서드를 직관적으로 이해할 수가 있다. toList는 스트림의 모든 요소를 List<> 컬렉션으로로 수집한다.
결국 스트림만 반환하던 중간연산들을 실제 값으로 바꿔야 스트림 연산의 값을 자료구조에 담을 수 있기에 컬렉터로 스트림의 항목을 수집해야한다. 책에서는 아래 사진과 같이 스트림의 데이터를 원하는 형식으로 바꿀 수 있는 collect 메서드를 제안한다. 필요할 때 적용하면 좋을 것 같다.

7️⃣ 병렬 데이터 처리와 성능
자바 7이 등장하기 전에는 데이터 컬렉션을 병렬로 처리하려면 직접 데이터를 서브파트로 분할하고, 그 서브파트를 각각의 스레드로 직접 할당했어야 했다.
책을 계속 읽다보면 Stream()을 활용해서 멀티코어 CPU의 이점을 극대화 할 수 있다고 계속 나온다. 정확히는 Stream()은 순차적이고, parallelStream()을 활용하면 여러 개의 스레드에게 작업을 요청해서 작업의 효율성을 높힐 수가 있다. 아래 사진과 같이 청크를 잘라서 여러 스레드가 동시에 연산을 실행한다.

어? 그러면 raceCondtion이 발생하지 않을까? 여러 스레드를 맘대로 사용하는 건 위험하지 않나? 결론 먼저 말하면 "매우 위험하다" 하지만 그 위험도를 감수하고 사용하기엔 성능적으로 너무 매력적인 기능이다. 그렇다면 스레드를 몇 개 쓰고, 누가 결정하고, 어떻게 나눌까?
아래 내용은 용어가 어려울 수 있으니 결론 먼저 설명하고 자세한 건 아래에 더 적어본다.
- 스레드 풀은 JVM 옵션/시스템 프로퍼티로 commonPool parallelism 값을 조정한다. 기본적으로 풀을 제공해주므로 풀을 늘려야한다면 성능 테스트 후에 조심스럽게 늘리거나 줄이는 것이 옳겠다.
- 실제 스레드 배치와 스케줄링은 ForkJoinPool이 잡는다.
- 작업 분할(청크)은 Spliterator가 잡는다.
- 각 컬렉션/소스는 내부적으로 쪼갤 수 있는 규칙이 있다.(ArrayList는 인덱스로 반씩 쪼개기 쉬워 병렬 효율이 좋고, LinkedList는 쪼개기 어려워 병렬 효율이 나쁘다.)
- "청크를 얼마나 잘 나누느냐"는 Spliterator 클래스의 구현방식이 정한다.(보통 제공해준다.)
용어가 어렵다. 하나씩 자세히 살펴보자.
첫번째로 자바 7은 더 쉽게 병렬화를 수행하면서 에러를 최소화 할 수 있도록 포크/조인 프레임워크(fork/join framework)를 제공한다.
아래사진 처럼 Recursive하게 Task를 fork해서 모든 서브태스크를 병렬로 수행 후 부분 결과를 조합한다. 그래서 fork/join이라고 부르나 보다.

위의 그림처럼 코어 개수(4개)만큼 병렬화된 태스크로 작업부하를 분할하면 모든 CPU 코어에서 태스크를 실행할 것이고, 크기가 같은 각각의 태스크는 같은 시간의 종료된다면 이론적으로 너무나 완벽한 병렬 처리일 것이다.
하지만 현실에서는 디스크 접근 속도가 저하되거나 외부 서비스와 협력하는 과정에서 지연이 생길 수 있는 등등 다양한 이유로 각각 서브테스크의 작업완료 시간은 달라질 수 있다.
fork/join 프레임워크에서는 작업 훔치기(work stealing) 기법으로 이 문제를 해결한다.

각각의 스레드는 자신에게 할당된 태스크를 포함하는 deque를 참조하면서 작업이 끝날때마다 queue의 헤드에서 다른 태스크를 가져와서 작업을 처리한다. 할일이 없어진 스레드는 idle(유후)상태로 바뀌는 것이 아니라 다른 스레드 deque의 꼬리에서 작업을 훔쳐온다. 모든 태스크가 끝날때(모든 큐가 빌때)까지 이 과정을 반복한다.
따라서 태스크의 크기를 작게 나누어야 하는 작업자 스레드 간의 작업 부하를 비슷한 수준으로 유지할 수 있다.
그럼 이렇게 분할후 join하는 것은 알겠는데, 어떤 것을 기준으로 fork하는 것일까? 자바 8은 Spliterator라는 새로운 인터페이스를 제공하여 재귀적으로 Task를 분할하여 스트림을 어떻게 병렬화 할 것인지 정의한다. 자바8은 컬렉션 프레임워크에 포함된 모든 자료구조에 사용할 수 있는 디폴트 Spliterator 구현체를 제공하기에, 커스텀 Spliterator를 꼭 직접 구현해야 하는 것은 아니지만 어떻게 동작하는지 이해한다면 병렬 스트림 동작과 관련한 통찰력을 얻을 수 있다. 기본적으로 제공하는 방식은 아래와 같다.

trySplit()을 null을 반환할 때까지 재귀적으로 호출하여 Task를 나눈다. like 이분탐색..
이렇게 프레임워크가 관리를 잘해주어도 parallelStream()이 공유변수를 병렬로 조작한다거나, 스트림 내부에서 어떤 객체의 필드 수정하는 등의 상황에서는 raceCondition을 주의해야한다. 마치 HTTP처럼 stateless한 연산에서만 병렬 스트림을 쓰는 것이 더 안전하다고 생각한다.
병렬 소프트웨어 동작 방법과 성능은 직관적이지 않을 때가 많으므로 병렬 처리를 활용한다면, 성능을 측정해보는 테스트 코드 작성이 필수이다.
마무리하며
여기까지 책의 1~2챕터를 알아보았다. 여기까지 읽은 느낌은 진짜 너무너무 재밌다. Stream() 문법을 활용해 함수형, 선언형 표현 방식으로 구현이나 리팩토링을 진행하다 보면 자꾸 컴파일 오류가 나면서 collect(toList())등의 자료구조로 바꿔줘야 한다는 경고메시지가 뜨곤 했었는데 이제는 그 이유가 너무나도 와닿는다.
그리고 평소 애매하게 알고 있던 람다 표현식, 메서드 참조 문법, 스트림 활용 등등.. 이 글에는 담지 못한 너무 좋은 예제들이 책에 많다. 자바 개발자라면 꼭 읽어봐야 하는 책이라고 생각한다.
평소 자주 들던 의문점에 답변을 쏙쏙 해주는 자바 모던씨에게 감사드리며 남은 챕터도 쭉쭉 읽어보자!!
'Book' 카테고리의 다른 글
| [모던 자바 인 액션] 자바 생태계로 Deep Dive - (2) (0) | 2026.03.01 |
|---|---|
| [객체지향의 사실과 오해] 객체지향을 조금 더 알게 해준 고마운 책 (1) | 2026.01.22 |