[모던 자바 인 액션] chap01. 자바 8에 무슨 일이..?

2023. 2. 13. 23:29Java

자바 8이 등장하기 이전에는 병렬처리에 대한 프로그래밍이 쉽지않았다. 스레드를 이용해 병렬 환경을 컨트롤 할 수는 있지만 이것은 전문가가 아니라면 어려운 수준이었다. 하지만 자바8이 나오면서 병렬 실행을 새롭고 단순한 방식으로 접근할 수 있는 방법을 제공하기 시작했다! 이 책은 그러한 자바8의 새로운 기법을 이용하려면 지켜야 하는 규칙에 대해서 설명한다.

자바8에서 제공하는 새로운 기술 세가지다.

  • 스트림 API : 우리는 스트림을 통해 synchronized를 사용하지 않아도 된다!
  • 메서드에 코드를 전달하는 기법
  • 인터페이스의 디폴트 메서드

지금부터 자바8 설계의 밑바탕을 이루는 세가지의 개념에 대해 알아보자.

1. 스트림 처리란?

스트림이란 한 번에 한 개씩 만들어지는 연속적인 데이터 항목들의 모임이다. 이론적으로 프로그램은 입력 스트림에서 데이터를 한개씩 읽어 들이며 마찬가지로 출력 스트림으로 데이터를 한 개씩 기록한다. (Stdin, Stdout이 대표적인 예다) 즉, 어떤 프로그램의 출력 스트림은 다른 프로그램의 입력 스트림이 될 수 있다.

자바 8에는 java.util.stream 패키지에 스트림 API가 추가되었다. 스트림 패키지에 정의된 Stream<t>는 T 형식으로 구성된 일련의 항목을 의미한다. 우선은 스트림 API가 조립 라인처럼 어떤 항목을 연속으로 제공하는 어떤 기능이라고 단순하게 생각하자. 스트림 API는 파이프라인을 만드는 데 필요한 많은 메서드를 제공한다.

스트림 API의 핵심은 기존에는 한 번에 한 항목을 처리했지만 이제 자바 8에서는 우리가 하려는 작업을 고수준으로 추상화해서 일련의 스트림으로 만들어 처리할 수 있다는 것이다. 또한 스트림 파이프라인을 이용해서 입력 부분을 여러 CPU 코어에 쉽게 할당할 수 있다는 부가적인 이득도 얻을 수 있다. 스레드라는 복잡한 작업을 사용하지 않으면서도 공짜로 병렬성을 얻을 수 있다.

 

2. 동작 파라미터화로 메서드에 코드 전달하기

자바 8에서는 메서드를 다른 메서드의 인수로 넘겨주는 기능을 제공한다. 이러한 기능을 이론적으로 동작 파라미터화라고 부른다. 스트림 API는 연산의 동작을 파라미터화할 수 있는 코드를 전달하는 세상에 기초한다.

 

3. 병렬성과 공유 가변 데이터

이 개념은 '병렬성을 공짜로 얻을 수 있다'라는 말에서 시작된다. 세상에 공짜는 없으니.. 병렬성을 얻는 대신 무엇을 포기해야 할까? 스트림 메서드로 전달하는 코드의 동작 방식을 조금 바꿔야 한다. 스트림 메서드로 전달하는 코드는 다른 코드와 동시에 실행하더라도 안전하게 실행될 수 있어야 한다. 보통 다른 코드와 동시에 실행하더라도 안전하게 실행할 수 있는 코드를 만들려면 공유된 가변 데이터에 접근하지 않아야 한다. 이러한 함수를 순수 함수, 부작용 없는 함수, 상태 없는 함수라 부른다. 

이 책에서는 두 프로세스가 공유된 변수를 동시에 바꾸려 하는 경우와 같은 유형의 문제를 어떻게 해결해야할지 확인할 수 있다. 기존처럼 synchronized를 이용해 공유된 가변 데이터를 보호하는 규칙을 만들 수 있겠지만, 자바 8 스트림을 이용하면 기존의 자바 스레드 API보다 쉽게 병렬성을 활용할 수 있다.


자바에서 함수란

일반적으로 프로그래밍에선 함수란 메서드 특히 정적 메서드와 같은 의미로 사용된다. 자바에의 함수는 이에 더해 부작용을 일으키지 않는 함수를 의미한다. 자바 8에서는 함수를 새로운 값의 형식으로 추가했다. 이는 곧 설명할 멀티코어에서 병렬 프로그래밍을 활용할 수 있는 스트림과 연계될 수 있도록 함수를 만들었기 때문이다.

자바에서는 42(int), 3.14(double)등의 기본값이 있다. 두번째로는 객체도 있다. new 또는 팩토리 메서드 또는 라이브러리 함수를 이용해서 객체의 값을 얻을 수 있다. 객체 참조란 클래스의 인스턴스를 가르킨다. abc(String), new Integer(1111)은 Integer, new HasMap<Integer, String>(100) 등을 객체 참조를 얻을 수 있다. 그런데 왜 함수가 필요할까?

전통적으로 프로그래밍 언어에서는 이 값을 일급값(시민)이라고 부른다. 자바 언어의 다양한 구조체(메서드, 클래스 등)가 값의 구조를 표현하는 데 도움이 될 수 있다. 하지만 프로그램을 싫애하는 동안 모든 구조체를 자유롭게 전달할 수는 없다. 이렇게 전달할 수 없는 구조체는 이급 시민이다.

이러한 이급 시민을 일급 시민으로 만들 수 있도록 도와주도록 설계된 것이 바로 자바 8이다.

 

1. 메서드 참조

File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
	public boolean accept(File file) {
    	return file.isHidden();			// 숨겨진 파일 필터링
    }
});

원래의 자바라면 파일 필터링을 만드는 클래스 하나에도 이렇게 File클래스에 isHidden이라는 메서드가 있더라도, FileFilter를 인스턴스화 해야했다. 하지만 자바 8은 다르다.

File[] hiddenFiles = new File(".").listFiles(File::isHidden);

이미 isHidden이라는 함수는 있으니 자바 8의 메서드 참조::를 이용해서 listFiles에 직접 전달할 수 있다. 여기서 메서드가 아닌 함수라는 용어를 사용했다는 사실도 주목한다. 코드가 작동하는 방법은 나중에 설명할 것이다. 기존에 비해 문제 자체를 더 직접적으로 설명한다는 점이 자바 8 코드의 장점이다.

여기서 보자면, 자바 8에서는 더이상 메서드가 이급값이 아닌 일급값이라는 것이다. 기존에 객체 참조를 이용해 객체를 이리저리 주고 받았던 것처럼 자바 8에서는 메서드 참조를 만들어 전달할 수 있게 됐다.

 

2. 람다 : 익명 함수

자바 8에서는 메서드를 일급값으로 취급할 뿐 아니라 람다를 포함하여 함수도 값으로 취급할 수 있다. 왜 굳이 익명함수로 만들어야 하는 코드가 필요한지 의아할 수도 있다. 물론 직접 메서드를 정의할 수도 있지만, 이용할 수 있는 편리한 클래스나 메서드가 없을 때 새로운 람다 문법을 이용하면 더 간결한 코드를 구현할 수 있다. 람다 문법 형식으로 구현된 프로그램을 함수형 프로그래밍, 즉 '함수를 일급값으로 넘겨주는 프로그램을 구현한다'라고 한다.

 

컬렉션도 좋지만..?

메서드를 값으로 전달하는 기능은 유용하다. 하지만 한두 번만 사용하는 메서드라면 람다 함수를 이용해 구현할 수도 있으리라 그런데 람다가 몇 줄 이상으로 길어진다면, 코드가 수행하는 일을 잘 설명하는 이름을 가진 메서드 참조를 활용 하는것이 바람직하다. 코드의 명확성이 우선시되어야 한다.

거의 모든 자바 애플리케이션은 컬렉션을 만들고 활용한다. 하지만 컬렉션을 쓴다고 해서 다 되는건 아니다. 예를 들어 리스트에서 고가의 트랜잭션만 필터링한 다음 통화로 결과를 그룹화해야 한다고 가정하자.

Map<Currency, List<Transaction>> transactionByCurrencies = new HashMap<>();
	for(Transaction transaction : transactions) {
    	if(transatcion.getPrice() > 1000) {
        	Currency currency = transaction.getCurrency();
            List<Transaction> transactionsForCurrency = transactionByCurrencies.get(currency);
            
            if (transactionsForCurrency == null) {
            	transactionsForCurrency = new ArrayList<>();
                transactionByCurrencies.put(currency, transactionsForCurrency);
            }
            transactionsForCurrency.get(transaction);
    }
}
Map<Currency, List<Transaction>> transactionByCurrencies =
	transaction.stream().filter((Transaction t) -> t.getPrice() > 1000)
    	.collect(groupingBy(Transaction::getCurrency));

스트림을 사용하면 위의 코드를 아래와 같이 줄일 수 있다!

컬렉션에서는 반복 과정을 직접 처리해야 했다. for-each를 이용해 각 요소를 반복하면서 작업을 수행했다. 이런 방식의 반복을 외부 반복이라고 한다. 반면 스트림 API를 이용하면 루프를 신경 쓸 필요가 없다. 스트림 API에서는 라이브러리 내부에서 모든 데이터가 처리된다. 이와 같은 반복을 내부 반복이라고 한다.

 

멀티스레딩은 어렵다..

자바 8이전의 버전에서 제공하는 스레드 API로 멀티스레딩 코드를 구현해서 병렬성을 이용하는 것은 쉽지 않다. 멀티스레딩 환경은 각각의 스레드는 동시에 공유된 데이터에 접근하고, 데이터를 갱신하기때문에 제어를 잘 못하면 원치 않는 방식으로 데이터가 바뀔 수 있다. 멀티스레딩 모델은 순차적인 모델보다 다루기가 어렵다.

자바 8은 스트림 API로 컬렉션을 처리하면서 발생하는 모호함과 반복적인 코드 문제, 멀티코어 활용 어려움이라는 두가지 문제를 모두 해결했다! 스트림 API는 자주 반복되는 패턴으로 주어진 조건에 따라 데이터를 필터링 하거나, 데이터를 추출하거나, 데이터를 그룹화 하는 등의 기능이 있다. 스트림은 스트림 내의 요소를 쉽게 병렬로 처리할 수 있는 환경을 제공한다는 것이 핵심이다. 처음에는 이상하게 들릴 수 있겠지만 컬렉션을 필터링할 수 있는 가장 빠른 방법은 컬렉션을 스트리믕로 바꾸고 병렬처리를 한 다음에, 리스트로 다시 복원하는 것이다. 스트림과 람다 표현식을 이용하면 '병렬성을 공짜로'얻을 수 있으며 리스트에서 무거운 사과를 순차적으로 또는 병렬로 필터링할 수 있다.

// 순차코드
List<Apple> heavyApples = inventory.stream().filter((Apple a) -> a.getWeight() > 150)
	.collect(toList());

// 병렬코드 
List<Apple> heavyApples = inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150)
	.collect(toList());

 

 

디폴트 메서드와 자바 모듈

요즘은 외부에서 만들어진 컴포넌트를 이용해 시스템을 구축하는 경향이 있다. 자바는 특별한 구조가 아닌 평범한 자바 패키지 집합을 포함하는 JAR파일을 제공하는 것이 전부다. 게다가 이러한 패키지의 인터페이스를 바꿔야 하는 상황에서는 인터페이스를 구현하는 모든 클래스의 구현을 바꿔야 했으므로 여간 큰 작업이다!! 자바 8 자바 9는 이 문제를 다른 방법으로 해결했다.

자바 9의 모듈 시스템은 모듈을 정의하는 문법을 제공하므로 이를 이용해 패키지 모음을 포함하는 모듈을 정의할 수 있다. 모듈 덖분에 JAR같은 컴포넌트에 구조를 적용할 수 있으며 문서화와 모듈 확인 작업이 용이해졌다. 또한 자바 8에서는 인터페이스를 쉽게 바꿀 수 있도록 디폴트 메서드를 지원한다. 앞으로 인터페이스에서 디폴트 메서드를 자주 접하게 될 것이므로 디폴트 메서드가 무엇인지 확실히 알아두어야 한다. (하지만 프로그래머가 직접 디폴트 메서드를 구현하는 상황은 흔치않다) 디폴트 메서드는 특정 프로그램을 구현하는데 도움을 주는 것은 아니다. 하지만 미래에 프로그램이 쉽게 변화할 수 있는 환경을 제공하는 기능이다.

자바 8 이후의 기능을 자바 8 이전의 버전에서 사용한다면 어떨까? 자바 8 이전의 버전은 stream이니 parallelStream메서드이니 하는 기능은 제공하지 않는다. 가장 간단한 해결책은 직접 인터페이스를 만들어서 자바 8 설계자들이 했던 것 처럼 Collection 인터페이스에 stream 메서드를 추가하고 클래스에서 메서드를 추가하는 일이다. 하지만 너무 많다.. 인터페이스에 메서드를 추가한다는 것은 모든 구현 클래스에 메서드를 추가한다는 것을 의미한다. 그럼 어떻게 이걸 구현해야 하지? 어떻게 자바 8은 이걸 해결한거지?

결정적으로 자바 8은 구현 클래스에서 구현하지 않아도 되는 메서드를 구현할 수 있게 해준다! 메서드 본문은 클래스 구현이 아니라 인터페이스의 일부로 포함된다. 디폴트 메서드를 이용하면 기존의 코드를 건드리지 않고도 원래의 인터페이스 설계를 자유롭게 확장할 수 있다. 자바 8에서는 인터페이스 규격 명세서에 default라는 새로운 키워드를 지원한다.

지금까지 자바에 포함된 함수형 프로그래밍의 핵심적인 두 아이디어를 살펴봤다. 하나는 메서드와 람다를 일급값으로 사용하는 것이고, 다른 하나는 가변 공유 상태가 없는 병렬 실행을 이용해서 효율적이고 안전하게 함수나 메서드를 호출할 수 있다는 것이다.

 

단원 정리

  • 자바 8은 더 효과적이고 간결하게 프로그램을 구현할 수 있도록 새로운 개념과 기능을 제공한다.
  • 기존의 자바 프로그래밍 기법으로는 멀티코어 프로세서를 온전히 활용하기 어렵다.
  • 함수는 일급값이다. 메서드를 어떻게 함수형 값으로 넘겨주는지, 익명 함수를 어떻게 구현하는지 기억하자.
  • 자바 8의 스트림 개념 중 일부는 컬렉션에서 가져온 것이다. 스트림과 컬렉션을 적절하게 활용하면 스트림의 인수를 병렬로 처리할 수 있으며 더 가독성이 좋은 코드를 구현할 수 있다.