본문 바로가기
그룹 스터디 공부(IT 서적)/모던 자바 인 액션

chapter 1. 자바 8, 9, 10, 11 : 무슨 일이 일어나고 있는가?

by hanyugyeong 2023. 7. 21.
반응형
SMALL

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

자바 역사를 통틀어 가장 큰 변화가 자바 8에서 일어났다. 예를 들어 사과 목록을 무게순으로 정렬하는 고전적 코드를 자바 8에서는 다음과 같이 작성할 수 있다.

// 고전적인 코드
Collections.sort(inventory, new Comparator<Apple>() {
    public int compare(Apple a1, Apple a2) {
        return a1.getWeight().compareTo(a2.getWeight());
    }
});

// Java 8
inventory.sort(comparing(Apple::getWeight));

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

멀티코어 CPU 대중화와 같은 하드웨어직인 변화도 자바 8에 영향을 미쳤다. 지금까지의 대부분의 자바 프로그램은 코어 중 하나만을 사용했다. (나머지 코어는 유휴 idle 상태이거나, 운영체제나 바이러스 검사 프로그램과 프로세스 파워를 나눠서 사용)

자바 8이 등장하기 이전에는 나머지 코어를 활용하려면 스레드를 사용하는 것이 권장되었음. 하지만 스레드를 사용하면 관리하기 어렵고 많은 문제가 발생할 수 있었음. 자바는 이러한 병렬 실행 환경을 쉽게 관리하고 에러가 덜 발생하는 방향으로 진화하려 노력(자바1.0의 스레드와 락, 메모리 모델부터 자바 5의 스레드 풀, 병렬 실행 컬렉션, 자바 7의 포크/조인 프레임워크)했지만 여전히 개발자가 활용하기는 쉽지 않았다.

자바 8에서는 병렬 실행을 새롭고 단순한 방식으로 접근할 수 있는 방법을 제공한다. 자바 9에서는 리액티브 프로그래밍이라는 병렬 실행 기법을 지원. 고성능 병렬 시스템에서 특히 인기를 얻고 있는 RxJava를 표준적인 방식으로 지원

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

  • 스트림 API
  • 메서드에 코드를 전달하는 기법(메서드 참조와 람다)
  • 인터페이스의 디폴트 메서드

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

학계에서는 프로그래밍 언어가 마치 생태계와 닮았다고 결론을 내렸다. 즉, 새로운 언어가 등장하면서 진화하지 않은 기존 언어는 사장되었다.

우리는 시공을 초월하는 완벽한 언어를 원하지만 현실적으로 그런 언어는 존재하지 않으며 모든 언어가 장단점을 갖고 있다.

특정 분야에서 장점을 가진 언어는 다른 경쟁 언어를 도태시킨다. 단지 새로운 하나의 기능 때문에 기존 언어를 버리고 새로운 언어와 툴 체인으로 바꾼다는 것은 쉽지 않은 일이다. 하지만 새로운 프로그래밍을 배우는 사람은 (기존 언어가 재빠르게 진화하지 않는다면) 자연스럽게 새로운 언어를 선택하게 되며 기존 언어는 도태된다.

자바는 지난 1995년 첫 베타 버전이 공개된 이후로 경쟁 언어를 대신하며 커다란 생태계를 성공적으로 구축했다. 이제 자바가 어떻게 그러한 성공을 거둘 수 있었는지 살펴보자.

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

자바는 출발이 좋았다.
등장 당시 각광받던 객체 지향 패러다임에 적합하며
스레드와 락을 이용한 동시성 지원 등 처음부터 많은 유용한 라이브러리를 포함했다.
코드를 JVM 바이트코드로 컴파일하는 특징이 있다.

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

자바 8은 더 다양한 프로그래밍 도구 그리고 다양한 프로그래밍 문제를 더 빠르고 정확하며 쉽게 유지보수할 수 있다는 장점을 제공한다. 자바 8에 추가된 기능은 자바에 없던 완전히 새로운 개념이지만 현재 시장에서 요구하는 기능을 효과적으로 제공한다.

병렬성을 활용하는 코드, 간결한 코드를 구현할수 있도록 자바 8에서 제공하는 기능의 모태인 세 가지 프로그래밍 개념을 설명한다.

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

자바 8에 추가된 두 번째 프로그램 개념은 코드 일부를 메서드로 전달하는 기능이다. 자바 8에서는 메서드(코드)를 다른 메서드의 인수로 넘겨주는 기능을 제공한다. 이러한 기능을 동작 파라미터화(behavior parameterization)라고 부른다. 스트림 API는 연산의 동작을 파라미터화하 수 있는 코드를 전달한다는 사상에 기초한다.

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

새 번째 프로그래밍의 개념은 ‘병렬성을 공짜로 얻을 수 있다'는 말에서 시작된다. 보통 다른 코드와 동시에 실행하더라도 안전하게 실행할 수 있는 코드를 만들려면 공유된 가변 데이터(shared mutable data)에 접근하지 않아야 한다. 하지만 공유된 변수나 객체가 있으면 병렬성에 문제가 발생한다. synchronized를 이용해서 보호하는 규칙을 만들 수 있을 것이다. 하지만 자바 8 스트림을 이용하면 기존의 자바 스레드 API보다 쉽게 병렬성을 활용할 수 있다.

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

지금까지 자바는 진화해왔다. 제네릭(Generic)이 나타나고, List가 List 등으로 바뀐 것처럼 초기에는 당황스러웠지만, 많은 이가 이미 변화에 익숙해져있으며 그것이 가져다주는 편리함을 누리고 있다. 기존 값을 변화시키는 데 집중했던 고전적인 객체지향에서 벗어나 함수형 프로그래밍으로 다가섰다는 것이 자바 8의 가장 큰 변화다. 자바 8에서 함수형 프로그래밍을 도입함으로써 두 가지 프로그래밍 패러다임의 장점을 모두 활용할 수 있게 되었다. 즉, 어떤 문제를 더 효율적으로 해결할 수 있는 다양한 도구를 얻게 된 것이다.

1.3 자바 함수

자바 8에서는 함수를 새로운 값의 형식으로 추가했다. 멀티코어에서 병렬 프로그래밍을 활용할 수 있는 스트림과 연계될 수 있도록 함수를 만들었기 때문이다. 먼저 함수를 값처럼 취급한다고 했는데 이 특징이 어떤 장점을 제공하는지 살펴보자.

자바 프로그램에서 조작할 수 있는 값

int, double 등의 기본값
객체(엄밀히 따지면 객체의 참조)
객체 참조는 클래스의 인스턴스(instance)를 가리킴
심지어 배열도 객체다

왜 함수가 필요할까?

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

자바 프로그래밍 언어의 다양한 구조체(메서드, 클래스 같은)가 값의 구조를 표현하는 데 도움이 될 수 있다. 하지만 프로그램을 시행하는 동안 이러한 모든 구조체를 자유롭게 전달할 수는 없다. 이렇게 전달할 수 없는 구조체는 이급 시민이다. 위에서 언급한 값은 모두 일급 자바 시민이지만 메서드, 클래스 등은 이급 자바 시민에 해당한다.

인스턴스화한 결과가 값으로 귀결되는 클래스를 정의할 때 메서드를 아주 유용하게 활용할 수 있지만 여전히 메서드와 클래스는 그 자체로 값이 될 수 없다.

하지만 런타임에 메서드를 전달할 수 있다면, 즉 메서드를 일급 시민으로 만들면 프로그래밍에 유용하게 활용될 수 있다. 따라서 자바 8 설계자들은 이급 시민을 일급 시민으로 바꿀 수 있는 기능을 추가했다.

언어는 하드웨어나 프로그래머의 기대의 변화에 부응하는 방향으로 변화해야 한다. 자바는 계속 새로운 기능을 추가하며 인기 언어의 자리를 유지하고 있다.

코드 넘겨주기 : 예제

public class FilteringApples {

  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);
    //자바 8
    List<Apple> greenFilterApples = filterApples(inventory, FilteringApples::isGreenApple);
    System.out.println(greenFilterApples);
    List<Apple> heavyFilterApples = filterApples(inventory,FilteringApples::isHeavyApple);
    System.out.println(heavyFilterApples);
  }

  public static class Apple {

    private int weight = 0;
    private String color = "";

    public Apple(int weight, String color) {
      this.weight = weight;
      this.color = color;
    }

    public int getWeight() {
      return weight;
    }

    public void setWeight(int weight) {
      this.weight = weight;
    }

    public String getColor() {
      return color;
    }

    public void setColor(String color) {
      this.color = color;
    }

    @SuppressWarnings("boxing")
    @Override
    public String toString() {
      return String.format("Apple{color='%s', weight=%d}", color, weight);
    }

  }

  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 boolean isGreenApple(Apple apple) {
    return "green".equals(apple.getColor());
  }
  public static boolean isHeavyApple(Apple apple){
    return apple.getWeight() > 150;
  }

  public interface Predicate<T>{ //명확히 하기 위해 추가함(보통 java.util.function에서 임포트함.)
    boolean test(T t);
  }

  public static List<Apple> filterApples(List<Apple> inventory,
                                         Predicate<Apple> p){ //매서드가 p라는 이름의 프레디 케이트 파라미터로 전달됨
    List<Apple> result = new ArrayList<>();
    for(Apple apple : inventory){
      if(p.test(apple)){ //사과는 p가 제시하는 조건에 맞는가?
        result.add(apple);
      }
    }
    return result;
  }

}

프레디케이트(predicat)란 무엇인가?

filterApples는 Apple::isGreenApple 메서드를 Predicate이라는 타입의 파라미터로 받는다. 인수로 값을 받아 true나 false를 반환하는 함수를 프레디케이트라고 한다.

1.3.3 메서드 전달에서 람다로

메서드를 값으로 전달하는 것은 분명 유용한 기능이다. 하지만 한두 번만 사용할 메서드를 매번 정의하는 것은 귀찮은 일이다. 자바 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()));

이처럼 한 번만 사용할 메서드는 따로 정의를 구현할 필요가 없다. 하지만 람다가 몇 줄 이상으로 길어진다면(즉, 조금 복잡한 동작을 수행하는 상황) 익명 람다보다는 코드가 수행하는 일을 잘 설명하는 이름을 가진 메서드를 정의하고 메서드 참조를 활용하는 것이 바람직하다. 코드의 명확성이 우선시 되어야 한다.

멀티 코어 CPU가 아니었다면 원래 자바 8 설계자들의 계획은 여기까지였을 것이다.

아마도 자바는 filter 등과 같은 몇몇 일반적인 라이브러리 메서드를 추가하는 방향으로 발전했을 수도 있다. 하지만 병렬성이라는 중요성 때문에 설계자들은 이와 같은 설계를 포기했다. 대신 자바 8에서는 filter와 비슷한 동작을 수행하는 연산집합을 포함하는 새로운 스트림 API(컬렉션과 비슷하며 함수형 프로그래머에게 더 익숙한 API)를 제공한다. 또한 컬렉션과 스트림 간에 변활할 수 있는 메서드(map, reduce 등)도 제공한다.

public class FilteringApples {

  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,(Apple a)->"green".equals(a.getColor()));
    System.out.println(greenApples);
    List<Apple> weightApplies = filterApples(inventory,(Apple a)->a.getWeight() > 150);
    System.out.println(weightApplies);
    List<Apple> weightColorApplies = filterApples(inventory,(Apple a)->a.getWeight() < 80 ||
                                                    "red".equals(a.getColor()));
    System.out.println(weightColorApplies);
  }

  public static class Apple {

    private int weight = 0;
    private String color = "";

    public Apple(int weight, String color) {
      this.weight = weight;
      this.color = color;
    }

    public int getWeight() {
      return weight;
    }

    public void setWeight(int weight) {
      this.weight = weight;
    }

    public String getColor() {
      return color;
    }

    public void setColor(String color) {
      this.color = color;
    }

    @SuppressWarnings("boxing")
    @Override
    public String toString() {
      return String.format("Apple{color='%s', weight=%d}", color, weight);
    }

  }

  public static List<Apple> filterApples(List<Apple> inventory,
                                         Predicate<Apple> p){ //매서드가 p라는 이름의 프레디 케이트 파라미터로 전달됨
    List<Apple> result = new ArrayList<>();
    for(Apple apple : inventory){
      if(p.test(apple)){ //사과는 p가 제시하는 조건에 맞는가?
         result.add(apple);
      }
    }
    return result;
  }
}

1.4 스트림

스트림 API를 이용하면 컬렉션 API는 상당히 다른 방식으로 데이터를 처리할 수 있다. 컬렉션에서는 fore-each 루프를 이용해서 반복 과정을 직접 처리해야했다. (external iteration) 반면, 스트림 API를 이용하면 루프를 신경 쓸 필요가 없이 라이브러리 내부에서 모든 데이터가 처리된다. (internal iteration)

컬렉션을 이용할 때 많은 요소를 가진 목록을 반복한다면 오랜 시간이 걸릴 수 있다. 거대한 리스트의 경우 단일 CPU로는 처리하기 힘들 것이다. 멀티코어 환경이라면 CPU 코어에 작업을 각각 할당해서 처리 시간을 줄일 수 있을 것이다.

1.4.1 멀티스레딩은 어렵다.

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

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

스트림 API는 자주 반복되는 패턴으로 주어진 조건에 따라 데이터를 필터링(filtering)하거나, 데이터를 추출(extracting), 데이터를 그룹화(grouping)하는 등의 기능을 제공한다.

또한 이러한 동작들을 쉽게 병렬화할 수 있다. 두 CPU를 가진 환경에서 리스트를 필터링할 때 한 CPU는 리스트의 앞부분을 처리하고, 다른 CPU는 리스트의 뒷부분을 처리하도록 요청할 수 있다.

새로운 스트림 API도 기존의 컬렉션 API와 비슷한 방식으로 동작하는 것 같아 보이지만 컬렉션은 어떻게 데이터를 저장하고 접근할지에 중점을 두는 반면 스트림은 데이터에 어떤 계산을 할 것인지 묘사하는 것에 중점을 둔다.

다음은 스트림 API를 이용한 순차 처리 방식의 코드와 병렬 처리 방식의 코드다.

public class FilteringApples {

  public static void main(String... args) {
    List<Apple> inventory = Arrays.asList(
        new Apple(80, "green"),
        new Apple(155, "green"),
        new Apple(120, "red")
    );
    //기존
    // 1. 순차 처리 방식
    List<Apple> heavyApples = inventory.stream().filter((Apple a)->a.getWeight() > 150).collect(Collectors.toList());
    System.out.println("heavyApples"+heavyApples);
    //2.병렬 처리 방식
    List<Apple> heavyParallel = inventory.parallelStream().filter((Apple a)->a.getWeight()>150)
            .collect(Collectors.toList());
    System.out.println("heavyParallel"+heavyParallel);
  }

  public static class Apple {

    private int weight = 0;
    private String color = "";

    public Apple(int weight, String color) {
      this.weight = weight;
      this.color = color;
    }

    public int getWeight() {
      return weight;
    }

    public void setWeight(int weight) {
      this.weight = weight;
    }

    public String getColor() {
      return color;
    }

    public void setColor(String color) {
      this.color = color;
    }

    @SuppressWarnings("boxing")
    @Override
    public String toString() {
      return String.format("Apple{color='%s', weight=%d}", color, weight);
    }

  }

  public static List<Apple> filterApples(List<Apple> inventory,
                                         Predicate<Apple> p){ //매서드가 p라는 이름의 프레디 케이트 파라미터로 전달됨
    List<Apple> result = new ArrayList<>();
    for(Apple apple : inventory){
      if(p.test(apple)){ //사과는 p가 제시하는 조건에 맞는가?
         result.add(apple);
      }
    }
    return result;
  }
}

추가공부
https://devbksheen.tistory.com/entry/%EB%AA%A8%EB%8D%98-%EC%9E%90%EB%B0%94-%EB%B3%91%EB%A0%AC-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%B2%98%EB%A6%AC%EC%99%80-%EC%84%B1%EB%8A%A5-%EC%B8%A1%EC%A0%95

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

자바의 변화 과정에서 자바 8 개발자들이 겪는 어려움 중 하나는 기존 인터페이스의 변경이다. 인터페이스를 업데이트하려면 해당 인터페이스를 구현하는 모든 클래스도 업데이트해야 한다. 자바 8, 9는 이 문제를 다른 방법으로 해결한다.

자바 9의 모듈 시스템은 모듈을 정의하는 문법을 제공하므로 이를 이용해 패키지 모음을 포함하는 모듈을 정의할 수 있다. 모듈 덕분에 JAR 같은 컴포넌트에 구조를 적용할 수 있으며 문서화와 모듈 확인 작업이 용이해졌다.

자바 8에서는 인터페이스를 쉽게 바꿀 수 있도록 디폴트 메서드(default method)를 지원한다. 디폴트 메서드는 특정 프로그램을 구현하는 데 도움을 주는 기능이 아니라 미래에 프로그램이 쉽게 변화할 수 있는 환경을 제공하는 기능이다.

List<Apple> heavyApples1 = 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가 stream()이나 parallelStream() 메서드를 지원하지 않았다. 이 기능을 추가하려면 Collection 인터페이스에 해당 메서드들을 추가하고 ArrayList 등과 같은 구현체에 메서드를 구현해야한다. 하지만 이미 컬렉션 API의 인터페이스를 구현하는 많은 컬렉션 프레임워크가 존재한다. 인터페이스에 새로운 메서드를 추가한다면 인터페이스를 구현하는 모든 클래스는 새로 추가된 메서드를 구현해야 한다.

자바 8은 기존의 구현을 고치지 않고도 이미 공개된 인터페이스를 변경할 수 있는 방법을 고민했고, 결과적으로 구현 클래스에서 구현하지 않아도 되는 메서드를 인터페이스에 추가할 수 있는 기능을 제공한다. 메서드 본문은 클래스 구현이 아니라 인터페이스의 일부로 포함된다. 이를 디폴트 메서드라고 부른다.

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

함수형 언어도 프로그램을 돕는 여러 장치를 제공한다.

일례로 명시적으로 서술형의 데이터를 이용해 null을 회피하는 기법이 있다. 자바 8에서는 NullPointer 예외를 피할 수 있도록 도와주는 Optional 클래스를 제공한다. Optional는 값이 없는 상황을 어떻게 처리할지 명시적으로 구현하는 메서드를 포함하고 있다.

또한 구조적 패턴 매칭 기법도 있다. 자바에서는 if-then-else나 switch문을 이용하지만 다른 언어에서는 패턴 매칭으로 더 정확한 비교를 구현할 수 있다는 사실을 증명했다. 아쉽게도 자바 8은 패턴 매칭을 완벽하게 지원하지 않는다. 현재는 자바 개선안으로 제안된 상태다.

1.7 마치며

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