모던 자바 인 액션 1장을 읽고 느낀 건, 이펙티브 자바 보기 전에 봤으면 더 좋았을 거 같다.
스트림 잘 모르던 시절에 때려넣은 이펙티브 자바 ^^ .. 이 책에서 친절히 설명해준다.
또 같은 내용만 몇 번을 반복해서 강조한다. 이렇게 친절할 수가 ....
1장은 이후에 나올 내용들의 큰 그림을 그려주는 내용이다. 자바 8은 왜 변화를 했는지, 어떤 변화가 있었는지 등 .. 오모시로이
자바와 멀티코어 병렬성
멀티코어 CPU가 대중화되면서 프로그래밍 언어의 발전에 큰 영향을 미쳤다. 이전까지 자바 프로그램은 주로 단일 코어를 활용했고, 나머지 코어는 대부분 idle 상태였다. 멀티코어 프로세서의 효율적인 활용을 위해, 자바는 시간이 지나면서 병렬 실행 환경을 쉽게 관리하고 에러가 덜 발생하기 위해 진화해왔다.
자바 8 이전의 병렬 실행 접근 방법은 주로 스레드와 관련된 프로그래밍에 의존하였다. 스레드를 사용하면 더 많은 코어를 활용할 수 있지만, 스레드 관리와 동기화 문제 등으로 인해 개발과 유지보수가 어렵고, 오류 발생 가능성이 높았다. 이러한 문제를 도입하기 위해 자바는 스레드 풀, 병렬 실행 컬렉션, 포크/조인 프레임워크 등을 도입했다. 그러나 이러한 기술들도 여전히 복잡하고, 개발자가 쉽게 활용하기 어려웠다.
그래서 등장한 자바 8
: 간결한 코드, 멀티코어 프로세서의 쉬운 활용
1. 스트림 API
책 내용에 따르면, SQL와 스트림이 고수준 추상화를 제공한다는 점에서 유사하다고 한다. SQL은 "어떻게" 해당 동작이 수행되는지가 아니라, "무엇"을 하고 싶은지 선언한다. 어차피 DBMS가 최적의 방법을 결정하고 실행하기 때문이다.
자바 8의 API도 비슷한 철학을 따른다. 개발자는 데이터에 수행하고자 하는 연산(필터링, 매핑, 정렬 등)을 작성한다. 이 과정에서, 스트림 API는 "무엇"을 해야 하는지에 초점을 맞추고, "어떻게" 해당 연산을 수행할지는 스트림 라이브러리가 내부적으로 결정한다.
스트림 API의 가장 큰 특징 중 하나는 병렬 연산을 간단하게 수행할 수 있다는 점이다. parallelStream()같은 메서드를 통해 컬렉션을 병렬 스트림으로 변환하면, 스트림 라이브러리가 자동으로 데이터를 분할하고 여러 코어에서 병렬로 처리한다.
즉, 멀티코어 프로세서의 효율적인 활용을 가능하게 하며, 개발자가 명시적으로 스레드를 관리할 필요 없이 데이터 컬렉션을 빠르게 처리할 수 있도록 돕는다.
2. 동적 파라미터화(메서드에 코드를 전달하는 기법: 메서드 참조와 람다)
자바 8은 메서드 참조와 람다 표현식을 도입하여, 메서드에 코드를 간결하고 효율적으로 전달하는 방법을 제공한다. 람다 표현식은 코드를 데이터처럼 취급할 수 있게 해주며, 이는 고차 함수의 사용을 가능하게 한다. 이러한 기능은 자바 프로그램의 가독성과 유지보수성을 향상시키고, 함수형 프로그래밍 스타일을 자바에 도입하였다.
3. 인터페이스의 디폴트 메서드
자바 8에서는 인터페이스에 디폴트 메서드를 도입하였다. 이는 인터페이스에 새로운 메서드를 추가하면서도 기존에 이 인터페이스를 구현한 클래스들을 깨트리지 않는 방식으로 API를 변경할 수 있게 한다. 디폴트 메서드는 라이브러리 설계자가 인터페이스를 변경할 때 발생할 수 있는 하위 호환성 문제를 해결하는 데 도움이 된다.
자바의 인기 비결
자바의 하드웨어 중립적인 메모리 모델은 다양한 하드웨어에서의 프로그램 실행의 일관성을 보장한다. 그러나 멀티코어 환경에서는 이로 인해 동기화 이슈가 발생할 수 있다.
멀티코어 환경에서 병렬 실행되는 스레드는 가시성 문제(한 스레드가 변경한 메모리 값이 다른 스레드에게 즉각적으로 보이지 않는 현상)와 명령어 재배치(성능 최적화를 위한 명령어 순서 변경)로 인해 싱글코어 환경과 다른 예기치 못한 상황을 일으킬 수 있다.
따라서 자바 메모리 모델(JMM)은 가시성 문제와 명령어 재배치 문제를 해결하기 위해 volatile(변수의 모든 읽기/쓰기를 메인 메모리에서 직접 실행함으로써 가시성 보장), synchronized(한 번에 하나의 스레드만 특정 객체에 대한 접근을 허용하여 동기화 달성) 같은 메커니즘을 제공한다.
코드를 JVM 바이트 코드로 컴파일 하는 특징(그리고 모든 브라우저에서 가상 머신 코드를 지원하기) 때문에 자바는 인터넷 애플릿 프로그램의 주요 언어가 되었다.
병렬성과 공유 가변 데이터
스트림 메서드로 전달하는 코드는 다른 코드와 동시에 실행하더라고 안전하게 실행될 수 있어야 한다. 이러한 코드를 만드려면 공유된 가변 데이터에 접근하지 않아야 한다. 이러한 함수를 순수(pure) 함수, 부작용 없는(side-effect-free) 함수, 상태 없는(stateless) 함수라고 한다.(18장, 19장 내용) synchronized를 이용해서 공유된 가변 데이터를 보호하는 규칙을 만들 수 있으나, 일반적으로 synchronized는 시스템 성능에 악영향을 미친다. 하지만 자바 8 스트림을 이용하면 기존의 자바 스레드 API보다 쉽게 병렬성을 활용할 수 있다.
함수형 프로그래밍 : 공유되지 않은 가변 데이터, 메서드, 함수 코드를 다른 메서드로 전달한다.(18장, 19장 내용)
명령형 프로그래밍 : 일련의 가변 상태로 프로그램을 정의한다.
자바에서 함수란
자바의 함수는 부작용을 일으키지 않는 함수를 의미한다.
자바 8 이전에는 주로 값(ex. 기본값(int 형식, double 형식 등), 객체의 참조 값(new, 팩토리 메소드, 라이브러리 함수를 이용해서))을 조작하는 것이 핵심이였다. 그러나 자바 8에서는 함수를 새로운 값의 형식으로 취급하였으며, 이는 특히 스트림 API와의 연계에 도움을 주었다.
배열도 객체라고 ?
자바에서 'int[] a = new int[5];' 코드는 a라는 이름의 정수형 배열을 생성한다. 여기서 int[]는 a가 정수(int) 값들의 배열을 담을 수 있음을 나타내는 자료형이다. 'new int[5]'는 실제로 5개의 정수 값을 담을 수 있는 배열 객체를 힙 메모리에 생성하고, 그 참조를 a 변수에 할당한다. 이 과정에서 배열은 객체로서 동작하게 된다. 즉, 배열이 메모리에 할당되고, 객체의 속성(ex: length)과 메서드를 가지며, 참조 타입의 특성을 갖는다는 점에서 배열도 객체라고 말하는 것이다.
책에서 처음 보는 일급 시민(First-Class Citizens), 이급 시민(Second-Class Citizens) 개념이 나온다.
일급 시민은 어떤 값이나 객체가 제약 없이 사용될 수 있음을 의미한다. 즉, 아래와 같은 특성을 지닌다.
- 변수에 저장될 수 있음
- 함수의 인자로 전달될 수 있음
- 함수의 결과로 반환될 수 있음
- 런타임에 생성 가능
예를 들어 자바스크립트에서 함수는 일급 시민이다.
이급 시민은 일급 시민과 반대로, 위에서 언급한 특성을 하나 이상 만족하지 못하는 값이나 객체이다. 즉, 제약이 많아서 자유롭게 사용할 수 없는 경우이다. 예를들어, 보통 메서드나 클래스는 이급 시민이다. 클래스 자체를 다른 함수의 인자로 전달하거나, 함수에서 클래스를 반환값으로 사용하는 것이 제한된다.
자바 8부터는 메서드를 일급 시민처럼 사용할 수 있게 되었다. 메서드를 변수에 할당하거나, 다른 메서드의 인자로 전달할 수 있게 되었다.
1. 메서드 참조
디렉터리에서 모든 숨겨진 파일을 필터링한다고 하자.
자바 8 이전 방식은 FileFilter 인터페이스의 익명 구현체를 사용하여 파일 필터링 조건을 정의한다. listFiles(FileFilter fileFilter) 메서드에 인자로 익명 구현체를 전달하여, 해당 조건에 맞는 파일 배열을 반환받는다.
File 클래스에는 이미 isHidden 메서드가 있는데, 굳이 FileFilter로 isHidden을 복잡하게 감싼 다음에 FileFilter를 인스턴스화한다.
package ch1.ex4;
import java.io.File;
import java.io.FileFilter;
public class HiddenFileFilterBeforeJava8 {
public static void main(String[] args) {
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
@Override
public boolean accept(File file) {
return file.isFile(); // 숨겨진 파일 필터링
}
});
for (File file : hiddenFiles) {
System.out.println(file.getName());
}
}
}
자바 8 방식은 자바 8에서 도입된 메서드 참조(File::isHidden)를 사용하여, File 객체의 isHidden 메서드를 직접 listFiles 메서드에 전달한다. 즉, 메서드 참조를 통해 메서드를 직접 값으로 사용할 수 있게 된다.
package ch1.ex4;
import java.io.File;
public class HiddenFileFilterWithJava8 {
public static void main(String[] args) {
// 자바 8의 메서드 참조 사용
File[] hiddenFiles = new File(".").listFiles(File::isFile);
// 결과 출력
for (File file : hiddenFiles) {
System.out.println(file.getName());
}
}
}
2. 람다 : 익명 함수
자바 8에서는 기명(named) 메서드 뿐만 아니라 람다(또는 익명 함수)를 포함하여 함수도 값을 취급할 수 있다.
람다 함수는 고차 함수(higher-order function)의 사용을 가능하게 한다. 고차 함수란 다른 함수를 인자로 받거나 함수를 결과로 반환하는 함수를 의미한다.
📝 메소드 참조와 람다 예제
해당 예제는 사과 목록을 필터링하여 특정 조건에 맞는 사과들을 골라내는 코드이다.
package ch1.ex5;
import java.util.Arrays;
import java.util.List;
import java.util.ArrayList;
class FilteringApplesBeforeJava8 {
public static List<Apple> filterGreenApples(List<Apple> inventory) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if ("green".equals(apple.getColor())) {
result.add(apple);
}
}
return result;
}
public static List<Apple> filterHeavyApples(List<Apple> inventory) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (apple.getWeight() > 150) {
result.add(apple);
}
}
return result;
}
public static void main(String[] args) {
List<Apple> inventory = Arrays.asList(
new Apple(80, "green"),
new Apple(155, "green"),
new Apple(120, "red")
);
List<Apple> greenApples = filterGreenApples(inventory);
System.out.println(greenApples);
List<Apple> heavyApples = filterHeavyApples(inventory);
System.out.println(heavyApples);
}
}
Predicate 인터페이스를 이용하여 조건을 쉽게 전달하고 재사용할 수 있다. 나아가 한 번만 사용할 메서드는 람다를 이용해 간결하게 구현할 수 있다. 당연하게도 람다가 복잡해진다면 메서드 참조를 활용하는 것이 좋다.
package ch1.ex5;
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
public class FilteringApplesWithJava8 {
public static boolean isGreenApple(Apple apple) {
return "green".equals(apple.getColor());
}
public static boolean isHeavyApple(Apple apple) {
return apple.getWeight() > 150;
}
public static List<Apple> filterApples(List<Apple> inventory, Predicate<Apple> p) {
List<Apple> result = new java.util.ArrayList<>();
for (Apple apple : inventory) {
if (p.test(apple)) {
result.add(apple);
}
}
return result;
}
public static void main(String[] args) {
List<Apple> inventory = Arrays.asList(
new Apple(80, "green"),
new Apple(155, "green"),
new Apple(120, "red")
);
// 메서드 전달 방식을 이용한 필터링
List<Apple> greenApples = filterApples(inventory, FilteringApplesWithJava8::isGreenApple);
System.out.println(greenApples);
List<Apple> heavyApples = filterApples(inventory, FilteringApplesWithJava8::isHeavyApple);
System.out.println(heavyApples);
// 람다 표현식을 이용한 필터링
List<Apple> greenApples2 = filterApples(inventory,
(Apple a) -> "green".equals(a.getColor()));
System.out.println(greenApples2);
List<Apple> heavyApples2 = filterApples(inventory,
(Apple a) -> a.getWeight() > 150);
System.out.println(heavyApples2);
List<Apple> weirdApples = filterApples(inventory,
(Apple a) -> a.getWeight() < 80 || "brown".equals(a.getColor()));
System.out.println(weirdApples);
}
}
여기서 더 나아가 자바 8은 병렬성까지 고려하여 스트림 API를 도입하였고, filter, map, reduce와 같은 연산을 통해 더 효율적으로 처리할 수 있게 되었다.
3. 스트림
스트림은 한 번에 한 개씩 만들어지는 연속적인 데이터 항목들의 모임이다.
아래 예시는 리스트에서 고가의 트랜잭션만 필터링한 다음에 통화로 결과를 그룹화하는 코드이다. 가독성 뿐만아니라 멀티 코어를 활용해 처리 시간을 줄여주는 ..
class TransactionProcessor {
public static void main(String[] args) {
...
// 스트림을 사용하지 않은 기존 방식 (외부 반복)
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>(); // 그룹화된 트랜잭션을 더할 맵 생성
for (Transaction transaction : transactions) { // 트랜잭션의 리스트를 반복
if (transaction.getPrice() > 1000) { // 고가의 트랜잭션을 필터링
Currency currency = transaction.getCurrency(); // 트랜잭션의 통화를 추출
List<Transaction> transactionForCurrency = transactionsByCurrencies.get(currency);
if (transactionForCurrency == null) { // 통화를 위한 키가 맵에 없으면
transactionForCurrency = new ArrayList<>(); // 새로운 리스트를 생성
transactionsByCurrencies.put(currency, transactionForCurrency);
}
transactionForCurrency.add(transaction); // 현재 탐색된 트랜잭션을 같은 통화의 트랜잭션 리스트에 추가
}
}
// 스트림을 사용한 방식 (내부 반복)
Map<Currency, List<Transaction>> transactionsByCurrencies2 = transactions.stream()
.filter(t -> t.getPrice() > 1000) // 고가의 트랜잭션만 필터링
.collect(groupingBy(Transaction::getCurrency)); // 통화로 트랜잭션을 그룹화
}
}
스트림 API는 컬렉션을 처리하면서 발생하는 모호함과 반복적인 코드 문제(ex. filterApples), 멀티코어 활용 어려움 두 가지 문제를 모두 해결했다.
스트림은 아래의 동작을 하는 동시에 쉽게 병렬화할 수 있다.
- 주어진 조건에 따라 데이터를 필터링(ex. 무게에 따라 사과 선택)
- 데이터를 추출(ex. 각 사과의 무게 필드 추출)
- 데이터를 그룹화(ex. 숫자 리스트의 숫자를 홀수와 짝수로 그룹화)

덕분에 컬렉션을 필터링할 수 있는 가장 빠른 방법은 컬렉션을 스트림으로 바꾸고, 병렬로 처리한 다음에, 리스트로 다시 복원하는 것이다.
아래 예제는 리스트에서 무거운 사과를 순차적으로 또는 병렬로 필터링하는 코드이다.
class FilteringApples {
public static void main(String[] args) {
List<Apple> inventory = List.of(
new Apple(80, "green"),
new Apple(155, "green"),
new Apple(120, "red")
);
// 순차 스트림 사용
List<Apple> heavyApples = inventory.stream()
.filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
// 병렬 스트림 사용
List<Apple> heavyApples2 = inventory.parallelStream()
.filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
}
}
4. 디폴트 메서드와 자바 모듈
기존 인터페이스 변경은 어려운 작업이다. 예를 들어, Collection.sort는 List 인터페이스에 포함되어 있지 않음에도 불구하고, List에 대한 정렬 기능을 제공한다. Collection.list(list, comparator)가 아니라 list.sort(comparator)를 수행하는 것이 적절하지만, 인터페이스를 구현하는 모든 클래스의 구현을 바꿔야 하므로 변경이 어렵다.
자바 8에서는 이러한 문제를 해결하기 위해 디폴트 메서드를 도입했다.(13장 내용) 이를 통해 기존 인터페이스를 수정하거나 새 기능을 추가할 때, 해당 인터페이스를 구현하는 모든 클래스를 변경하지 않고도 기능을 추가할 수 있게 되었다.
자바 9에서는 모듈 시스템을 도입하여 모듈을 정의하는 새로운 문법을 제공한다. 이 모듈 시스템을 통해 패키지 모음을 포함하는 모듈을 정의할 수 있으며, JAR와 같은 컴포넌트에 구조를 적용하고, 문서화 및 모듈 확인 작업을 용이하게 할 수 있다.(14장 내용)
아래 코드를 보자.
class FilteringApples {
...
List<Apple> heavyApples = inventory.stream()
.filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
List<Apple> heavyApples2 = inventory.parallelStream()
.filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
}
}
자바 8 이전에는 List<T> 및 이를 구현하는 Collection<T> 인터페이스가 stream 및 parallelStream 메서드를 지원하지 않아, 이러한 메서드는 사용하는 코드는 컴파일되지 않았다.
가장 간단한 해결책은 인터페이스에 직접 stream 메서드를 추가하고, 이를 구현하는 클래스에서 해당 메서드를 구현하는 것이었다. (자바 8 설계자들이 했던 것처럼)
하지만 인터페이스에 새로운 메서드를 추가하면 그 인터페이스를 구현하는 모든 클래스는 새로 추가된 메서드를 구현해야하는 문제가 발생한다.
자바 8에서는 이 문제를 디폴트 메서드를 통해 해결하였다. 디폴트 메서드는 인터페이스에 새로운 메서드를 추가할 수 있게 하면서도, 이를 구현하는 클래스에서 해당 클래스의 구현을 강제하지 않는다. 즉, 기존 구현을 변경하지 않고도 인터페이스를 확장할 수 있게 되었다.
예를 들어, 자바 8의 List 인터페이스에는 sort 메서드를 직접 호출할 수 있게 하는 디폴트 메서드가 추가되었다.
default void sort(Comparator<? super E> c) {
Collections.sort(this, c);
}
자바에서는 한 클래스가 여러 인터페이스를 구현할 수 있다. 인터페이스의 디폴트 메서드는 인터페이스에 메서드의 구현을 직접 포함시킬 수 있게 해준다. 만약 한 클래스가 여러 인터페이스를 구현하고, 이 인터페이스들이 디폴트 메서드를 가지고 있다면, 이 클래스는 여러 인터페이스로부터 구현된 메서드를 상속받게 된다. 이런 면에서 보면, 다중 상속을 어느 정도 허용된다고 볼 수 있다.
다이아몬트 상속 문제는 다중 상속을 지원하는 프로그래밍 언어에서 발생할 수 있는 문제로, 하나의 클래스가 두 개 이상의 상위 클래스로부터 동일한 메서드를 상속받을 때, 어떤 메서드를 사용해야 할지 모호해지는 문제이다. 자바에서는 인터페이스의 디폴트 메서드로 인해 비슷한 문제가 발생할 수 있으나, 자바는 이를 해결하기 위한 명확한 규칙(ex. 클래스에서 명시적으로 메서드를 오버라이드하는 것)을 제공한다. (9장 내용)
결론적으로, 자바에서의 인터페이스와 디폴트 메서드는 다중 상속을 구현 메커니즘으로서 부분적으로 지원하면서도, 다이아몬드 상속 문제와 같은 전통적인 다중 상속의 문제를 피할 수 있는 방법을 제공한다.
'Language > Java' 카테고리의 다른 글
| [effective java] 아이템 84. 프로그램의 동작을 스레드 스케줄러에 기대지 말라. (0) | 2023.08.13 |
|---|---|
| [effective java] 아이템 83. 지연 초기화는 신중히 사용하라. (0) | 2023.08.13 |
| [effective java] 아이템 82. 스레드 안전성 수준을 문서화하라. (0) | 2023.08.13 |
| [effective java] 아이템 81. wait와 notify보다는 동시성 유틸리티를 애용하라. (0) | 2023.08.13 |
| [effective java] 아이템 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라. (0) | 2023.08.13 |