프로필사진
KiwiK
KiwiK Dev.
Chapter1. 모던 자바 인 액션
Chapter1. 모던 자바 인 액션

2023. 7. 7. 17:47Book

 

자바 개발을 하면서 자바에 여러 유용한 기능들을 활용하지 못하고 있다는 느낌이 들었다.
특히 자바8 이후에 나온 Stream API, 동작 파라미터화, 람다 등에 대해 좀 더 깊게 이해를 하고,
코드 레벨에서 능숙하게 적용해보고 싶어 이 책을 학습하기로 결정하였다.

 

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

 

자바 역사를 통틀어 가장 큰 변화가 자바 8에서 일어났다. 자바 9,10에서도 중요한 변화가 있었다. 하지만 자바 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을 이용하면 자연어에 더 가깝게 간단한 방식으로 코드를 구현할 수 있다.

자바8은 간결한 코드, 멀티코어 프로세서의 쉬운 활용이라는 두 가지 요구사항을 기반으로 한다.

아래는 자바 8을 통해서 제공하는 3가지 프로그래밍 개념이다.

 

스트림 처리

조립라인 처럼 스트림 API는 파이프라인을 만드는 데 필요한 많은 메서드를 제공한다. 또한 스레드라는 복잡한 작업을 사용하지 않으면서 공짜로 병렬성을 얻을 수 있다.

 

메서드에 코드를 전달하는 기법(메서드 참조와 람다)

동작(메서드)을 파라미터화 해서 전달 가능하다. 함수형 프로그래밍 기술을 이용한다.

 

인터페이스의 디폴트 메서드

병렬성을 공짜로 얻을 수 있지만 이를 얻기 위해 포기해야 하는 점이 있다. 안전하게 실행할 수 있는 코드를 만들려면 공유된 가변 데이터(shared mutable data)에 접근하지 않아야 한다.

 

 

자바 8은 데이터베이스 질의 언어에서 표현식을 처리하는 것처럼 병렬 연산을 지원하는 스트림이라는 새로운 API를 제공한다. 스트림을 이용하면 에러를 자주 일으키며 멀티코어 CPU를 이용하는 것보다 비용이 훨씬 비싼 키워드인

synchronized(멀티코어 CPU의 각 코어는 별도의 캐시를 포함하고 있는데 락을 사용하면 동기화되어야 하므로 속도가 느리다)를 사용하지 않아도 된다.

 

 

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

 

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

 

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

 

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

 

1.2.2 스트림 처리

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

 

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

 

이 예제는 파일의 단어를 소문자로 바꾼 다음에 사전순으로 단어를 정렬했을 때 가장 마지막에 위치한 세 단어를 출력하는 유닉스 명령행이다. sort는 여러 행의 스트림을 입력받아 여러 행의 스트림을 출력으로 만들어낸다.

 

유닉스에서는 여러 명령(cat, tr, sort,tail)을 병렬로 실행한다. 따라서 cat이나 tr이 완료되지 않은 시점에서 sort가 행을 처리하기 시작할 수 있다. 이를 자동차 생산 공장 라인에 비유하면, 자동차 생산 공장은 여러 자동차로 구성된 스트림을 처리하는데, 각각의 작업장에서는 자동차를 받아서 수리한 다음, 다음 작업장에서 다른 작업을 처리할 수 있도록 넘겨준다. 조립라인은 자동차를 물리적인 순서로 한 개씩 운반하지만 각각의 작업장에서는 동시에 작업을 처리한다.

 

 

Stream API

이전 예제에서 유닉스 명령어로 복잡한 파이프라인을 구성했던 것처럼 스트림 API는 파이프라인을 만드는 데 필요한 많은 메서드를 제공한다. 스트림API의 핵심은 기존에는 한 번에 한 항목을 처리했지만 이제 자바 8에서는 우리가 하려는 작업을 고수준으로 추상화해서 일련의 스트림으로 만들어 처리할 수 있다는 것이다. 또한 스트림 파이프라인을 이용해서 입력 부분을 여러 CPU 코어에 쉽게 할당할 수 있다는 부가적인 이득(스레드라는 복잡한 작업없이 공짜로 병렬성을 얻음)도 얻을 수 있다.

 

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

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

 

 

1.3 자바 함수

 

자바 8에서 함수 사용법은 일반적인 프로그래밍 언어의 함수 사용법과 아주 비슷하다. 프로그래밍 언어의 핵심은 값을 바꾸는 것이다. 전통적으로 프로그래밍 언어에서는 이 값을 일급(first-class) 값(또는 시민)이라고 부른다. 이전까지 자바 프로그래밍 언어에서는 기본값, 인스턴스만이 일급 시민이었다. 메서드와, 클래스는 이 당시 일급 시민이 아니었는데, 런타임에 메서드를 전달할 수 있다면, 즉 메서드를 일급 시민으로 만들면 프로그래밍에 유용하게 활용될 수 있다. 자바 8 설계자들은 이급 시민을 일급 시민으로 바꿀 수 있는 기능을 추가했다.

 

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

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

 

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

동작의 전달을 위해 익명 클래스를 만들고 메서드를 구현해서 넘길 필요 없이, 준비된 함수를 메서드 참조 ::를 이용해서 전달할 수 있다. 아래 예제를 통해 자바 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[] hiddenFiles = new File(".").listFiles(File::isHidden);

 

자바 8에서 메서드 참조(method reference) ::(‘이 메서드를 값으로 사용하라'는 의미)를 이용해서 listFiles에 직접 전달할 수 있다. 기존에 비해 문제 자체를 더 직접적으로 설명한다는 점이 자바 8 코드의 장점이다.

 

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

 

  • 자바 8 이후 코드 예시
class Test {
	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> {
		boolean test(T t);
    }
	
	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, Apple::isGreenApple);
filterApples(inventory, Apple::isHeavyApple);

 

Predicate

filterApples는 Apple::isGreenApple 메서드를 Predicate<Apple>이라는 타입의 파라미터로 받는다. 인수로 값을 받아 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 등)도 제공한다.

 

1.4 스트림

 

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

 

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

 

다음은 리스트에서 고가의 트랜잭션만 필터링하는 경우의 예시이다.

 

class LegacyTest {
  private static void groupTransaction() {
    // 그룹화된 트랜잭션을 더할 Map 생성
    Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();
    // 트랜잭션의 리스트를 반복
    for (Transaction transcation : transactions) {
      // 고가의 트랜잭션을 필터링
      if (transaction.getPrice() > 1000) {
        // 트랜잭션의 통화 추출
        Currency currency = transaction.getCurrency();
        List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
        // 현재 통화의 그룹화된 맵에 항목이 없으면 새로 만든다.
        if (transactionsForCurrency == null) {
          transactionsForCurrency = new ArrayList<>();
          transactionsByCurrencies.put(currency, transactionsForCurrency);
        }
        // 현재 탐색된 트랜잭션을 같은 통화의 트랜잭션 리스트에 추가한다.
        transactionsForCurrency.add(transaction);
      }
    }
  }
}

 

게다가 위 예제 코드에는 중첩된 제어 흐름 문장이 많아서 코드를 한번에 이해하기도 어렵다. 
스트림 API를 이용하면 다음처럼 문제를 해결할 수 있다.

 

class LegacyTest {
  private static void groupTransaction() {
    Map<Currency, List<Transaction>> transactionsByCurrencies = 
        transactions.stream()
            .filter((Transaction t) -> t.getPrice() > 1000) // 고가의 트랜잭션 필터링
            .collection(groupingBy(Transaction::getCurrency)); //통화로 그룹화함
  }
}

 

 

 

1.4.1 멀티스레딩은 어렵다.

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

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

 

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

 

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

 

새로운 스트림 API도 기존의 컬렉션 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());

 

 

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

 

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

 

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

 

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

 

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

 

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

 

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

 

마치며

 

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

'Book' 카테고리의 다른 글

객체지향의 사실과 오해 리뷰  (2) 2023.06.15