본문 바로가기
Java/모던 자바 인 액션

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

by Mong-_- 2023. 12. 1.

이 장의 내용

  • 자바가 거듭 변화하는 이유
  • 컴퓨팅 환경의 변화
  • 자바에 부여되는 시대적 변화 요구
  • 자바 8과 자바 9의 새로운 핵심 기능 소개

1996년 자바 개발 키트(JDK)가 발표됐으며, 자바 1.1(1997)에서 자바 7(2011)에 이르기까지 자바는 새로운 기능과 더불어 계속 발전했다. 2018년 3월에는 자바 10, 2018년 9월에는 자바 11이 릴리스 되었다.
자바의 변화를 우리가 눈여겨봐야 하는 이유가 무엇인지 살펴보자.

 


 

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

자바 역사를 통틀어 가장 큰 변화가 자바 8에서 일어났다.

 

다음은 사과 목록을 무게순으로 정렬하는 고전적 코드이다.

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));

 

멀티코어 CPU 대중화와 같은 하드웨어적인 변화도 자바 8에 영향을 미쳤다. 흔히 우리가 사용하는 데스크톱은 멀티 코어 이상을 지원하는 CPU가 내장되어 있을 것이다.


예전 자바 프로그램은 멀티코어 중 하나만 사용했으며, 나머지 코어를 활용하려면 스레드를 사용했어야 했다.

하지만 스레드를 사용하면 관리가 어렵고 많은 문제가 발생할 수 있다는 단점과 특별 전문가로 구성되어있지 않는 한 스레드 기능을 온전히 활용하기가 어려운 단점이 존재했다.


자바 8은 간결한 코드, 멀티코어 프로세서의 쉬운 활용이라는 두 가지 요구사항을 기반으로 한다.
아래 목록은 자바 8에서 제공하는 새로운 기술이다. (간단하게 나열한 것이다.)

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

자바 8은 데이터베이스 질의 언어에서 표현식을 처리하는 것처럼 병렬 연산을 지원하는 스트림이라는 새로운 API를 제공한다.

데이터베이스 질의 언어에서 고수준 언어로 원하는 동작을 표현하면, 구현에서 최적의 저수준 실행 방법을 선택하는 방식으로 동작한다.

(자바에서는 스트림 라이브러리가 이 역할을 수행한다.)

즉, 스트림을 이용하면 에러를 자주 일으키며 멀티코어 CPU를 이용하는 것보다 비용이 훨씬 비싼 키워드 synchronized를 사용하지 않아도 된다.

 

조금 다른 관점에서 보면 결국 자바 8에 추가된 스트림 API 덕분에 다른 두 가지 기능, 즉 메서드에 코드를 전달하는 기법(메서드 참조와 람다)과 인터페이스의 디폴트 메서드가 존재할 수 있음을 알 수 있다.

 


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

특정 분야에서 장점을 가진 언어는 다른 경쟁 언어를 도태시킨다. 단지 새로운 하나의 기능 때문에 기존 언어를 버리고 새로운 언어와 툴 체인으로 바꾼다는 것은 쉽지 않은 일이다.

하지만, 새로운 프로그래밍을 배우는 사람은 자연스럽게 새로운 언어를 선택하게 되며 기존 언어는 도태된다. 

(에이다, 알골, 코볼, 파스칼, 델페이, 스노볼 등등)

 

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

코드를 JVM 바이트 코드로 컴파일하는 특징 (모든 브라우저에서 가상 머신 코드를 지원함)때문에 자바는 인터넷 애플릿 프로그램의 주요 언어가 되었다.

 

일부 애플리케이션에서는 JVM에서 실행되는 경쟁 언어인 스칼라, 그루비 등이 자바를 대체했으며 JVM의 최신 업데이트 덕분에 경쟁 언어는 JVM에서 더 부드럽게 실행될 수 있었으며, 자바와 상호동작할 수 있게 되었다.

 

하지만, 빅데이터라는 도전에 직면하면서 멀티코어 컴퓨터나 컴퓨팅 클러스터를 이용해서 빅데이터를 효과적으로 처리할 필요성이 커졌다. 즉, 병렬 프로세싱을 활용해야 하는 데 지금까지의 자바로는 충분히 대응할 수 없었다.

 

자바 8에 추가된 기능은 자바에 없던 완전히 새로운 개념이지만 현재 시장에서 요구하는 기능을 효과적으로 제공한다.

이러한 변화를 통해 자바가 빠르게 변화하는 프로그래밍 생태계에서 살아남을 수 있도록 했다.

 

1.2.2, 1.2.3, 1.2.4 세 개의 절에서는 자바 8 설계의 밑바탕을 이루는 세 가지 프로그래밍 개념을 소개한다.

 

1.2.2 스트림 처리

첫 번째 프로그래밍 개념은 스트림 처리다.

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

이론적으로 프로그램은 입력 스트림에서 데이터를 한 개씩 읽어 들이며 마찬가지로 출력 스트림으로 데이터를 한 개씩 기록한다. 즉, 어떤 프로그램의 출력 스트림은 다른 프로그램의 입력 스트림이 될 수 있다.

 

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

이 예제는 파일의 단어를 소문자로 바꾼 다음에 사전순으로 단어를 정렬했을 때 가장 마지막에 위치한 세 단어를 출력하는 프로그램이다.

유닉스에서는 여러 명령(cat, tr, sort, tial)을 병렬로 실행한다. 따라서, cat이나 tr이 완료되지 않은 시점에서 sort가 행을 처리하기 시작할 수 있다.

 

자바 8에는 java.util.stream 패키지에 스트림 API가 추가되었다. 스트림 API가 조립 라인처럼 어떤 항목을 연속으로 제공하는 어떤 기능이라고 단순하게 생각해 보자.

 

위에 예제에서 유닉스 명령어로 복잡한 파이프라인을 구성했던 것처럼 스트림 API는 파이프라인을 만드는 데 필요한 많은 메서드를 제공한다.

 

스트림 API의 핵심은 기존에는 한 번에 한 항목을 처리했지만 이제 자바 8에서는 우리가 하려는 작업 (데이터베이스 질의처럼) 고수준으로 추상화해서 일련의 스트림으로 만들어 처리할 수 있다는 것이다.

또한 스트림 파이프라인을 이용해서 입력 부분을 여러 CPU 코어에 쉽게 할당할 수 있다는 부가적인 이득도 얻을 수 있다.

이는, 스레드라는 복잡한 작업을 사용하지 않으면서도 공짜로 병렬성을 얻을 수 있다!

 

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

두 번째 프로그램 개념은 코드 일부를 API로 전달하는 기능이다.

유닉스 예제에서 sort 명령에 파라미터를 추가하고 싶은 사용자도 있을 것이다. sort에 파라미터를 제공해서 역순 정렬 등 다양한 정렬을 수행할 수는 있지만 어쨌든 sort로 수행할 수 있는 동작은 미리 정해져 있다.

 

예를 들어 2013UK0001, 2014US0002 ... 등의 형식을 갖는 송장 ID가 있다고 가정하자.

이 송장 ID를 고객 ID 또는 국가 코드순으로 정렬해야 한다. sort 명령을 이용하려면 sort가 고객 ID나 국가 코드로 송장 ID를 정렬하도록 sort에 따로 코드를 제공해야 한다.

 

우리가 지정하는 순서대로 자료를 정리하도록 sort 메서드에 명령을 내려야 한다.

자바 8에서는 메서드를 다른 메서드의 인수로 넘겨주는 기능을 제공한다. 이러한 기능을 이론적으로 동작 파라미터화라고 부른다.

 

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

세 번째 프로그래밍의 개념은 "병렬성을 공짜로 얻을 수 있다"라는 말에서 시작된다.

공짜로 병렬성을 얻기 위해선 스트림 메서드로 전달하는 코드의 동작 방식을 조금 바꿔야 한다. (익숙해지면 변화된 방식을 더 선호하게 된다고 한다. 믿어보자!)

 

스트림 메서드로 전달하는 코드는 다른 코드와 동시에 실행하더라도 안전하게 실행될 수 있어야 한다. 보통 다른 코드와 동시에 실행하더라도 안전하게 실행할 수 있는 코드를 만들려면 공유된 가변 데이터에 접근하지 않아야 한다.

(이러한 함수를 순수 함수, 부작용 없는 함수, 상태 없는 함수라고 한다.)

 

이제 자바 8에 추가된 새로운 개념을 하나씩 살펴보자.

 

1.3 자바 함수

프로그래밍 언어에서 함수라는 용어는 메서드 특히 정적 메서드와 같은 의미로 사용된다. 자바의 함수는 이에 더해 수학적인 함수처럼 사용되며 부작용을 일으키지 않는 함수를 의미한다.

 

자바 8에서는 함수를 새로운 값의 형식으로 추가했다. 이는 병렬 프로그래밍을 활용할 수 있는 스트림과 연계할 수 있도록 함수를 만들었기 때문이다.

 

자바 프로그램에서 조작할 수 있는 값을 생각해 보자. 첫 번째로 42(int), 3.14(double) 등의 기본값이 있다.

두 번째로 객체(엄밀히 따지면 객체의 참조)도 값이다. new 또는 팩토리 메서드 또는 라이브러리 함수를 이용해서 객체의 값을 얻을 수 있다. 객체 참조는 인스턴스를 가리킨다.

 

그런데 함수는 왜 필요할까?

 

프로그래밍 언어의 핵심은 값을 바꾸는 것이다. 역사적으로 그리고 전통적으로 프로그래밍 언어에서는 이 값을 일급 값 또는 일급 시민이라고 부른다.

 

자바 프로그래밍 언어의 다양한 구조체(메서드, 클래스 같은)가 값의 구조를 표현하는 데 도움이 될 수 있다.

하지만 프로그램을 실행하는 동안 이러한 모든 구조체를 자유롭게 전달할 수는 없다. 이렇게 전달될 수 없는 구조체는 이급 시민이다. (위에서 언급한 값은 모두 일급 자바 시민이지만 메서드 클래스 등은 이급 자바 시민에 해당된다.)

 

하지만 이게 중요할까? -> 중요하다! 런타임에 메서드를 전달할 수 있다면, 즉 일급 시민으로 만들면 프로그래밍에 유용하게 활용할 수 있다.

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

자바 8에서 메서드를 값으로 취급할 수 있는 기능은 스트림 같은 다른 자바 8 기능의 토대를 제공했다.

 

첫 번째로 메서드 참조라는 새로운 자바 8의 기능이다.

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

몇 줄 안 되는 코드지만 각 행이 무슨 작업을 하는지 투명하지 않다. File 클래스에는 이미 isHidden이라는 메서드가 있는데 왜 굳이 FileFilter로 isHidden을 복잡하게 감싼 다음에 FileFilter를 인스턴스화해야 할까?

자바 8이 나타나기 전까지는 달리 방법이 없었기 때문이다.

 

이제 자바 8에서는 다음처럼 코드를 구현할 수 있다.

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

이미 isHidden이라는 함수는 준비되어 있으므로 자바 8의 메서드 참조 :: ("이 메서드를 값으로 사용하라"는 의미)를 이용해서 listFiles에 직접 전달할 수 있다.

 

자바 8에서는 더 이상 메서드가 이급값이 아닌 일급값이라는 것이다. 기존에 객체 참조(new로 객체 참조를 생성함)를 이용해서 객체를 이리저리 주고받았던 것처럼 자바 8에서는 File::isHidden을 이용해서 메서드 참조를 만들어 전달할 수 있게 되었다.

 

람다 : 익명함수

자바 8에서는 메서드를 일급값으로 취급할 뿐 아니라 람다를 포함하여 함수도 값으로 취급할 수 있다.

 

클래스를 만든 다음에 클래스 내부에 add1이라는 메서드를 정의해서 Utils::add1을 만들 수 있으므로 굳이 람다를 써야 하는지 의문을 가질 수 있다.

하지만 이용할 수 있는 편리한 클래스나 메서드가 없을 때 새로운 람다 문법을 이용하면 더 간결하게 코드를 구현할 수 있다.

 

1.3.2 코드 넘겨주기

Apple 클래스와 getColor 메서드가 있고, Apples 리스트를 포함하는 변수 inventory가 있다고 가정하자.

이때 모든 녹색 사과를 선택해서 리스트를 반환하는 프로그램을 구현하려 한다. 이처럼 특정 항목을 선택해서 반환하는 동작을 필터라고 한다. 자바 8 이전에는 다음처럼 filterGreenApples라는 메서드를 구현했을 것이다.

  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;
  }

 

만약 이 상황에서 150그램 이상 필터링을 하고 싶다면 다음 코드처럼 구현해야 한다.

  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에서는 코드를 인수로 넘겨줄 수 있으므로 filter 메서드를 중복으로 구현할 필요가 없다. 앞의 코드를 다음처럼 자바 8에 맞게 구현할 수 있다.

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 ArrayList<>();
    for (Apple apple : inventory) {
      if (p.test(apple)) {
        result.add(apple);
      }
    }
    return result;
}

 

이렇게 구현한 코드를 이용하여 다음처럼 메서드를 호출할 수 있다.

filterApples(inventory, FilteringApples::isGreenApple);

또는 다음과 같이 호출할 수 있다.

filterApples(inventory, FilteringApples::isHeavyApple);

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

 

1.3.3 메서드 전달에서 람다로

메서드를 값으로 전달하는 것은 분명 유용한 기능이다. 하지만 isHeavyApple, isGreenApple처럼 한두 번만 사용할 메서드를 매번 정의하는 것은 귀찮은 일이다. 

자바 8에서는 익명 함수 또는 람다라는 새로운 개념을 이용해서 코드를 구현할 수 있다.

filterApples(inventory, (Apple a) -> "green".equals(a.getColor()))
filterApples(inventory, (Apple a) -> a.getWeight() > 150);

심지어 다음과 같이 구현할 수도 있다.

filterApples(inventory, (Apple a) -> a.getWeight() < 80 || "brown".equals(a.getColor()));

 

즉, 한 번만 사용할 메서드는 따로 정의를 구현할 필요가 없다. 위 코드는 우리가 넘겨주려는 코드를 애써 찾을 필요가 없을 정도로 더 짧고 간결하다.

 

하지만 람다가 몇 줄 이상으로 길어진다면 익명 함수보다는 코드가 수행하는 일을 잘 설명하는 이름을 가진 메서드를 정의하고 메서드 참조를 활용하는 것이 바람직하다. 코드의 명확성이 우선시되어야 하기 때문이다.

 

1.4 스트림

거의 모든 자바 애플리케이션은 컬렉션을 만들고 활용한다. 하지만 컬렉션으로 모든 문제가 해결되는 것은 아니다.

스트림 API를 이용하면 컬렉션 API와는 상당히 다른 방식으로 데이터를 처리할 수 있다는 사실만 기억하자.

 

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

 

1.4.1 멀티스레딩은 어렵다

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

멀티스레딩 환경에서 각각의 스레드는 동시에 공유된 데이터에 접근하고, 데이터를 갱신할 수 있다. 결과적으로 스레드를 잘 제어하지 못하면 원치 않는 방식으로 데이터가 바뀔 수 있다.

 

자바 8은 스트림 API로 "컬렉션을 처리하면서 발생하는 모호함과 반복적인 코드 문제" 그리고 "멀티코어 활용 어려움"이라는 두 가지 문제를 모두 해결했다. 기존의 컬렉션에서는 데이터를 처리할 때 반복되는 패턴이 너무 많았다. 따라서 라이브러리에서 이러한 반복되는 패턴을 제공한다면 좋을 것이라는 아이디어가 변화의 동기가 되었다.

 

즉, 자주 반복되는 패턴으로 주어진 조건에 따라 데이터를 필터링하거나, 데이터를 추출하거나, 데이터를 그룹화하는 등의 기능이 있다. 또한 이러한 동작들을 쉽게 병렬화할 수 있다는 점도 변화의 동기가 되었다.

 

컬렉션은 어떻게 데이터를 저장하고 접근할지에 중점을 두는 반면 스트림은 데이터에 어떤 계산을 할 것인지 묘사하는 것에 중점을 둔다는 점을 기억하자. 스트림은 스트림 내의 요소를 쉽게 병렬로 처리할 수 있는 환경을 제공한다는 것이 핵심이다.

 

컬렉션을 필터링할 수 있는 가장 빠른 방법은 컬렉션을 스트림으로 바꾸고, 병렬로 처리한 다음에, 리스트로 다시 복원하는 것이다. 이전에 언급했듯이 스트림과 람다 표현식을 이용하면 "병렬성을 공짜로" 얻을 수 있다.

 

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

자바의 변화 과정에서 자바 8 개발자들이 겪는 어려움 중 하나는 기존 인터페이스의 변경이다. 예를 들어 Collections.sort는 사실 List 인터페이스에 포함되지만 실제로 List에 포함된 적은 없다. 이론적으로는 Collections.list(list, comparator)가 아니라 list.sort(comparator)를 수행하는 것이 적절하다. 하지만 자바 8이 등장하기 전까지는 이 문제를 해결한다는 것은 불가능에 가까웠다. (인터페이스를 업데이트하면 해당 인터페이스를 구현하는 모든 클래스도 업데이트해야 하므로)

자바 8에서는 디폴트 메서드로 이 문제를 해결하였다.

 

자바 8 이전에는 List<T>(List가 구현하는 인터페이스인 Collection<T>도 마찬가지)가 stream이나 parallelStream 메서드를 지원하지 않는다는 것이 문제다. 가장 간단한 해결책은 직접 인터페이스를 만들어 Collection 인터페이스에 stream 메서드를 추가하고 ArrayList 클래스에서 메서드를 구현하는 것이다.

 

하지만 이 방법은 너무 큰 고통을 안겨준다. 이미 컬렉션 API의 인터페이스를 구현하는 많은 컬렉션 프레임워크가 존재한다. 인터페이스에 새로운 메서드를 추가한다면 인터페이스를 구현하는 모든 클래스는 새로 추가된 메서드를 구현해야 한다.

 

결정적으로 자바 8은 구현 클래스에서 구현하지 않아도 되는 메서드를 인터페이스에 추가할 수 있는 기능을 제공한다.

메서드 본문은 클래스 구현이 아니라 인터페이스의 일부로 포함된다. (그래서 디폴트 메서드라고 부른다.)

public interface MyInterface {
    default void myMethod() {
        // 메서드 본문
        System.out.println("디폴트 메서드입니다.");
    }
}

이처럼 말이다. myMethod는 interface임에도 불구하고 메서드의 본문이 인터페이스에 직접 작성된다. 그대로 사용하거나, 필요에 따라 오버라이드하여 사용할 수 있다는 점이 디폴드 메서드의 주요한 특성이다.

 

마치며

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

12시에 도서관에 도착해서 동아리 계획을 맞춘 후 1시쯤 블로그 글 쓰기를 시작했는데...

어느덧.......... 7시다.

중요한 내용만 쓱쓱 정리할 생각이었지만

"1장은 이 책의 나머지 장을 살펴볼 수 있도록 밑거름을 제공한다."

라는 문구가 7시까지 글 쓰게 만들었다.

그래도 쓰고 나니 도파민 보상 덕분인지 아주 뿌-듯하다! 

 

끗!