Book/모던 자바 인 액션

[모던 자바 인 액션] Chapter01. 자바 8, 9, 10, 11 : 무슨 일이 일어나고 있는가?

ghan2 2024. 9. 9. 22:11
이 장의 내용
💡 자바가 거듭 변화하는 이유
💡 컴퓨팅 환경의 변화
💡 자바에 부여되는 시대적 변화 요구
💡 자바 8과 자바 9의 새로운 핵심 기능 소개

 

1.1 역사의 흐름은 무엇인가?

자바 역사를 통틀어 가장 큰 변화가 자바 8에서 일어났다. 자바 9에서도 중요한 변화가 있었지만 자바 8만큼 획기적이거나 생산성이 바뀌는 것은 아니었고, 자바 10에서는 형 추론과 관련해 약간의 변화만 일어났다. 피부로 와닿는 예시를 하나 보자. 다음은 사과 목록을 무게순으로 정렬하는 고전적 코드다.

Collections.sort(inventory, new Comparator<Apple>() {
	public int compare(Apple a1, Apple a2) {
    	return a1.getWeight().compareTo(a2.getWeight());
    }
});

자바 8을 이용하면 자연어에 더 가깝게 간단한 방식으로 코드를 구현할 수 있다. 

inventory.sort(comparing(Apple::getWeight));

 

자바 8은 간결한 코드, 멀티코어 프로세서의 쉬운 활용이라는 두 가지 요구사항을 기반으로 한다. 일단 자바 8에서 제공하는 새로운 기술이 어떤 것인지 확인하자. 

  • 스트림 API
  • 메서드에 코드를 전달하는 기법
  • 인터페이스의 디폴트 메서드

자바 8은 데이터베이스 질의 언어에서 표현식을 처리하는 것처럼 병렬 연산을 지원하는 스트림이라는 새로운 API를 제공한다. 스트림을 이용하면 에러를 자주 일으키며 멀티코어 CPU를 이용하는 것보다 비용이 훨씬 비싼 키워드 'synchronized'를 사용하지 않아도 된다! 그리고 조금 다른 관점에서 보면 결국 자바 8에 추가된 스트림 API 덕분에 다른 두 가지 기능, 즉 메서드에 코드를 전달하는 간결 기법(메서드 참조와 람다)과 인터페이스의 디폴트 메서드가 존재할 수 있음을 알 수 있다. 물론 스트림 떄문에 메서드에 코드를 전달하는 기법이 생겼다고 단언하기에는 이 기법의 활용성을 제한할 수 있다. 메서드에 코드를 전달하는 메서드 참조와 람다를 사용하면 새롭고 간결한 방식으로 동작 파라미터화를 구현할 수 있다. 

 

자바 8 기법은 함수형 프로그래밍에서 위력을 발휘한다. 본론에서는 먼저 왜 언어가 진화하는가와 관련한 피상적인 토론을 펼친 다음에, 자바 8의 핵심 기능을 소개하고, 간단하게 사용할 수 있으며 새로운 컴퓨터 아키텍처로 떠오르는 새로운 기능인 함수형 프로그래밍의 개념을 소개한다. 

 

1.2 왜 아직도 자바는 변화하는가?

특정 분야에서 장점을 가진 언어는 다른 경쟁 언어를 도태시킨다. 자바는 지난 1995년 첫 베타 버전이 공개된 이후로 경쟁 언어를 대신하며 거대한 생태계를 구축했다. 이제 자바가 어떻게 그러한 성공을 거둘 수 있었는지 살펴보자!

 

1.2.1 프로그래밍 언어 생태계에서 자바의 위치

자바 8은 더 다양한 프로그래밍 도구 그리고 다양한 프로그래밍 문제를 더 빠르고 정확하며 쉽게 유지보수할 수 있다는 장점을 제공한다. 생태계를 요약하자면 새로운 언어가 등장하고 새로운 언어는 변화하는 환경에 빠르게 적응하면서 점점 대중화된다는 것이다. 지금부터 소개하는 세 개의 절에서는 자바 8 설계의 밑바탕을 이루는 세 가지 프로그래밍 개념을 소개한다. 

 

1.2.2 스트림 처리

스트림이란 한 번에 한 개씩 만들어지는 연속적인 데이터 항목들의 모임이다. 

cat file1 file2 | tr "[A-Z]" "[a-z]" | sort | tail -3

이 예제는 파일의 단어를 소문자로 바꾼 후에 사전순으로 단어를 정렬하고 끝에 오는 세 단어를 출력하는 프로그램이다. 이처럼 유닉스에서는 여러 행의 스트림을 입력으로 받아 여러 행의 스트림을 출력으로 만들어내고 cat, tr, sort, tail과 같은 명령어를 병렬로 실행한다. 자바 8에서 제공하는 스트림 API는 유닉스의 파이프라인처럼 구현하기 위해 필요한 많은 메서드들을 제공한다. 

 

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

 

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

자바 8에 추가된 두 번째 프로그램 개념은 코드 일부를 API로 전달하는 것이다. Comparator 객체를 만들어서 sort에 넘겨주는 방법도 있지만 이는 너무 복잡하며 기존 동작을 단순하게 재활용한다는 측면에서도 맞지 않다. 자바 8에서는 메서드를 다른 메서드의 인수로 넘겨주는 기능을  제공한다. 

 

1.2.4 병렬성과 공유 가변 데이터

세 번째 프로그래밍의 개념은 '병렬성을 공짜로 얻을 수 있다'라는 말에서 시작된다. 보통 다른 코드와 동시에 실행하더라도 안전하게 실행하기 위해서는 공유된 가변 데이터에 접근하지 않아야 했다. 자바 8 스트림을 이용하면 기존의 자바 스레드 API보다 쉽게 병렬성을 활용할 수 있다. 공유되지 않은 가변 데이터인 메서드, 함수 코드를 다른 메서드로 전달하는 두 가지 기능은 함수형 프로그래밍 패러다임의 핵심적인 사항이다. 

 

1.2.5 자바가 진화해야 하는 이유

자바는 지금까지 진화해왔고 그 결과 어떤 문제를 더 효율적으로 해결할 수 있는 다양한 도구를 얻게 되었다. 새로운 기능이 추가될 때 우리는 더욱 관심을 가져야 하며 이로써 스스로 자바 프로그래머로서의 삶을 유지할 수 있도록 자신을 보호할 수 있다. 이제 자바 8에 추가된 새로운 개념을 하나씩 살펴보자!

 

1.3 자바 함수

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

 

1.3.1 메서드와 람다를 일급 시민으로

일급 시민(first-class)의 조건
1. 변수나 데이타에 할당 할 수 있어야 한다.
2. 객체의 인자로 넘길 수 있어야 한다.
3. 객체의 리턴값으로 리턴 할수 있어야 한다.

 

1) 메서드 참조(method reference::)

// 기존 java
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
	public boolean accept(File file) {
    	return file.isHidden();
    }
});

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

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

 

2) 람다: 익명 함수(annonymous functions)

자바 8에서는 메서드를 일급값으로 취급할 뿐 아니라 람다를 포함하여 함수도 값으로 취급할 수 있다. 예를 들어 (int x) -> x + 1, 즉 'x라는 인수로 호출하면 x + 1을 반환'하는 동작을 수행하도록 코드를 구현할 수 있다. 

 

1.3.2 코드 넘겨주기: 예제

  // 다음 두 코드가 있다. 
  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;
  }
// 두 코드는 if문 내의 동작을 제외하고 거의 일치한다는 것을 알 수 있다.

// 자바 8을 이용하여 이렇게 수정할 수 있다. 
  public static boolean isGreenApple(Apple apple) {
    return "green".equals(apple.getColor());
  }

  public static boolean isHeavyApple(Apple apple) {
    return apple.getWeight() > 150;
  }

// 메소드를 인자로 전달할 수 있다! predicate는 뒤에서 자세히 알아보자
  public static List<Apple> filterApples(List<Apple> inventory, Predicate<Apple> p) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
      if (p.test(apple)) {
        result.add(apple);
      }
    }
    return result;
  }

이 예제에서 핵심은 자바 8에서는 메서드를 전달할 수 있다는 사실이다. 

 

1.3.3 메서드 전달에서 람다로 

메서드를 값으로 전달하는 것은 분명 유용한 기능이다. 하지만 위의 예제처럼 한두번 밖에 사용하지 않는다면 이 마저도 귀찮은 일이다. 자바 8에서는 이러한 문제도 간단히 해결할 수 있다. 바로 람다를 사용하는 것이다. 

filterApples(inventory, (Apple a) -> a.getWeight() > 150);

하지만 다음처럼 라이브러리 메서드인 filter를 이용하면 filterApples 메서드를 구현할 필요도 없다. 하지만 병렬성이라는 중요성 때문에 설계자들은 이와 같은 설계를 포기했고 대신, 자바 8에서는 filter와 비슷한 동작을 수행하는 연산집합을 포함하는 새로운 스트림 API를 제공한다. 스트림 간에 변환할 수 있는 메서드(map, reduce 등)도 제공한다. 

 

1.4 스트림

거의 모든 자바 애플리케이션은 컬렉션을 만들고 활용한다. 컬렉션에서는 반본 과정을 직접 처리해야 한다. 즉, for-each 루프를  이용해서 각 요소를 반복하면서 작업을 수행했다. 이런 방식의 반복을 외부 반복(external iteration)이라고 한다. 반면 스트림 API를 이용하면 루프를 신경 쓸 필요가 없다. 스트림 API 에서는 라이브러리 내부에서 모든 데이터가 처리된다. 이와 같은 반복을 내부 반복(internal iteration) 이라고 한다. 

 

1.4.1 멀티스레딩은 어렵다

이전 자바 버전에서 제공하는 스레드 API로 멀티스레딩 코드를 구현해서 병렬성을 이용하는 것은 쉽지 않다. 자바 8은 스트림 API로 

컬렉션을 처리하면서 발생하는 모호함과 반복적인 코드 문제' 그리고 '멀티코어 활용 어려움' 이라는 두 가지 문제를 모두 해결했다. 

 

자주 반복되는 패턴으로 주어진 조건에 따라 데이터를 필터링하거나, 데이터를 추출하거나, 데이터를 그룹화하는 등의 기능이 있다. 또한 이러한 동작들을 쉽게 병렬화할 수 있다는 점도 변화의 동기가 되었다. 예를 들어 두 CPU를 가진 환경에서 리스트를 필터링할 때 한 CPU는 리스트의 앞부분을 처리하고, 다른 CPU는 리스트의 뒷부분을 처리하도록 요청할 수 있다. 이 과정을 포킹 단계(forking step)라고 한다. 그리고 각각의 CPU는 자신이 맡은 절반의 리스트를 처리한다. 마지막으로 하나의 CPU가 두 결과를 정리한다. (구글 검색도 이와 같은 방식으로 작동하면서 빠르게 검색 결과를 제공한다. 물론 구글 검색은 두 개 이상의 프로세서를 사용한다.)

 

스트림은 스트림 내의 요소를 쉽게 병렬로 처리할 수 있는 환경을 제공한다는 것이 핵심이다. 컬렉션을 필터링할 수 있는 가장 빠른 방법은 컬렉션을 스트림으로 바꾸고, 병렬로 처리한 다음에, 리스트로 다시 복원하는 것이다. 

// 순차 처리 방식
List<Apple> heavyApples = 
	inventory.stream().filter((Apple a) -> a.getWeight() > 150)
    			      .collect(toList());
// 병렬 처리 방식
List<Apple> heavyApples = 
	inventory.paralleStream().filter((Apple a) -> a.getWeight() > 150)
    			      		 .collect(toList());

 

1.5 디폴트 메서드와 자바 모듈

자바 8에서는 인터페이스를 쉽게 바꿀 수 있도록 디폴트 메서드를 지원한다. 앞으로 인터페이스에서 디폴트 메서드를 자주 접하게 될 것이므로 디폴트 메서드가 무엇인지 확실히 알아두어야 한다. 하지만 프로그래머가 직접 디폴트 메서드를 구현하는 상황은 흔치 않다. 결정적으로 자바 8은 구현 클래스에서 구현하지 않아도 되는 메서드를 인터페이스에 추가할 수 있는 기능을 제공한다. 메서드 본문은 클래스 구현이 아니라 인터페이스의 일부로 포함된다. 

 

예를 들어 자바 8에서는 List에 직접 sort 메서드를 호출할 수 있다. 이는 자바 8의 List 인터페이스에 다음과 같은 디폴트 메서드 정의가 추가되었기 때문이다. 

default void sort(Comparator<? super E> c) {
	Collections.sort(this, c);
}

 

그런데 이렇게 되면 하나의 클래스에 여러 인터페이스를 구현할 수 있고 여러 인터페이스에 다중 디폴트 메서드가 존재할 수도 있기 때문에 다중 상속과 같은 일이 일어날 수 있다. 9장에서 다이아몬드 상속 문제를 피할 수 있는 방법을 설명한다.

 

1.6 함수형 프로그래밍에서 가져온 다른 유용한 아이디어

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

 

이 외에도 자바 8에는 NullPointer 예외를 피할 수 있도록 도와주는 Optional<T> 클래스를 제공한다. 옵셔널은 값을 갖거나 갖지 않을 수 있는 컨테이너 객체다. 또한 구조적(structural) 패턴 매칭 기법도 있다. 19장에서 패턴 매칭을 사용하는 방법이 나와있지만 아쉽게도 자바 8은 패턴 매칭을 완벽하게 지원하지 않는다. 현재는 자바 개선안으로 제안된 상태다. 

 

1.7 마치며

언어 생태계의 모든 언어는 변화해서 살아남거나 그대로 머물면서 사라지게 된다. 지금은 자바의 위치가 견고하지만 코볼과 같은 언어의 선례를 떠올리면 자바가 영원히 지배적인 위치를 유지할 수 있는 것은 아닐 수 있다. 
자바 8은 프로그램을 더 효과적이고 간결하게 구현할 수 있는 새로운 개념과 기능을 제공한다. 
기존의 자바 프로그래밍 기법으로는 멀티코어 프로세서를 온전히 활용하기 어렵다.
함수는 일급값이다. 메서드를 어떻게 함수형 값으로 넘겨주는지, 익명 함수(람다)를 어떻게 구현하는지 기억하자.
자바 8의 스트림 개념 중 일부는 컬렉션에서 가져온 것이다. 스트림과 컬렉션을 적절히 사용하면 스트림의 인수를 병렬로 처리할 수 있으며 더 가독성이 좋은 코드를 구현할 수 있다.
기존 자바 기능으로는 대규모 컴포넌트 기반 프로그래밍 그리고 진화하는 시스템의 인터페이스를 적절하기 대응하기 어려웠다. 자바 9에서는 모듈을 이용해 시스템의 구조를 만들 수 있고 디폴트 메소드를 이용해 기존  인터페이스를 구현하는 클래스를 바꾸지 않고도 인터페이스를 변경할 수 있다. 
함수형 프로그래밍에서 null을 처리하는 방법패턴 매칭 활용 등 흥미로운 기법을 발견했다.