Modern Java in Action - Part 1 기초

AIR·2023년 11월 23일
0

Modern Java in Action

목록 보기
1/5

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

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

//사과 목록을 무게순으로 정렬하는 고전적 코드
Collections.sort(inventory, new Comparator<Apple>() {
	public int compare(Apple a1, Apple a2) {
		return a1.getWeight().compartTo(a2.getWeitht());
	}
});
//자바 8을 이용한 코드
inventory.sort(comparing(Apple::getWeight));

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

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

자바 8은 데이터베이스 질의 언어에서 표현식을 처리하는 것처럼 병렬 연산을 지원하는 스트림이라는 새로운 API를 제공한다. 스트림을 이용하면 에러를 자주 일으키며 멀티코어 CPU를 이용하는 것보다 비용이 훨씬 비싼 키워드 synchronized를 사용하지 않아도 된다. 조금 다른 관점에서 보면 결국 자바 8에 추가된 스트림 API 덕분에 다른 두가지 기능이 존재할 수 있음을 알 수 있다.

하지만 스트림 API 때문에 메서드에 코드를 전달하는 기법이 생겼다고 생각하는 것은 기법의 활용성을 제한할 수 있는 위험한 생각이다. 이 기법을 이용하면 새롭고 간결한 방식으로 동작 파라미터화(behavior parameterization)를 구현할 수 있다. 예를 들어 약간만 다른 두 메서드가 있다고 가정할 때, 두 메서드를 그대로 유지하는 것보다는 인수를 이용해서 다른 동작을 하도록 하나의 메서드로 합치는 것이 바람직할 수 있다. 자바 8 이전 상황에서는 익명 클래스를 이용해서 동작 파라미터화를 구현할 수 있다고 생각할 수 있다. 하지만 이 기법은 함수형 프로그래밍에서 위력을 발휘한다.


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

1960년대에 사람들은 완벽한 프로그래밍 언어를 찾고자 노력했다. 1966년에 이미 700개에 이르는 언어가 있었고 이후로 수천 개의 언어가 쏟아졌다. 새로운 언어가 등장하면서 진화하지 않은 기존 언어는 사장되었다. 시공을 초월하는 완벽한 언어를 원하지만 현실적으로 그런 언어는 존재하지 않으며 모든 언어가 장단점을 갖고 있다. 가령 C, C++는 프로그래밍 안전성은 부족하지만 작은 런타임 풋프린트덕분에 운영체제와 다양한 임베디드 시스템에서 여전히 인기를 끌고 있다. 하지만 C, C++의 낮은 안전성 때문에 프로그램이 예기치 않게 종료되거나 바이러스 등이 침투할 수 있는 보안 구멍이 있을 수 있다. 실제로 런타임 풋프린트에 여유가 있는 애플리케이션에서는 자바, C#과 같이 안전한 형식의 언어가 C, C++를 압도한다.

자바는 지난 1995년 첫 베타 버전이 공개된 이후로 경쟁 언어를 대신하며 커다란 생태계를 성공적으로 구축했다. 생태계를 요약하자면 새로운 언어가 등장하고 새로운 언어는 변화하는 환경에 빠르게 적응하면서 점점 대중화된다. 자바 8은 더 다양한 프로그래밍 도구 그리고 다양한 프로그래밍 문제를 더 빠르게 정확하며 쉽게 유지보수할 수 있다는 장점을 제공한다.
다음은 자바 8 설계의 밑바탕을 이루는 세 가지 프로그래밍 개념이다.

  • 스트림 처리
  • 동작 파라미터화로 메서드에 코드 전달하기
  • 병렬성과 공유 가변 데이터

스트림이란 한 번에 한 개씩 만들어지는 연속적인 데이터 항목들의 모임이다. 이론적으로 프로그램은 입력 스트림에서 데이터를 한 개씩 읽어 들이며 마찬가지로 출력 스트림으로 데이터를 한 개씩 기록한다. 즉, 어떤 프로그램의 출력 스트림은 다른 프로그램의 입력 스트림이 될 수 있다. 자바 8에는 java.util.stream 패키지에 스트림 API가 추가되었다. 스트림 패키지에 정의된 Stream<T>는 T 형식으로 구성된 일련의 항목을 의미한다.

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

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

세 번째 프로그래밍 개념은 '병렬성을 공짜로 얻을 수 있다'라는 말에서 시작된다. 병렬성을 얻는 대신 스트림 메서드로 전달하는 코드의 동작 방식을 조금 바꿔야 한다. 보통 다른 코드와 동시에 실행하더라도 안전하게 실행할 수 있는 코드를 만들려면 공유된 가변 데이터에 접근하지 않아야 한다. 기존처럼 synchronized를 이용해서 공유된 가변 데이터를 보호하는 규칙을 만들수 있지만 시스템 성능에 악영향을 미친다. 자바 8 스트림을 이용하면 기존의 자바 스레드 API보다 쉽게 병렬성을 활용할 수 있다. 다중 프로세싱 코어에서 synchronized를 사용하면 (다중 처리 코어에서는 코드가 순차적으로 실행되어야 하므로 병렬이라는 목적을 무력화시키면서) 생각보다 훨씬 더 비싼 대가를 치러야 할 수 있다.


1.3 자바 함수

프로그래밍 언어에서 함수(function)라는 용어는 메서드(method) 특히 정적 메서드(static method)와 같은 의미로 사용된다. 자바의 함수는 이에 더해 수학적인 함수처럼 사용되며 부작용을 일으키지 않는 함수를 의미한다. 자바 8에서는 함수를 새로운 값의 형식으로 추가했다. 자바 프로그램에서 조작할 수 있는 값을 생각해볼 때 첫 번째로 int, double 타입등의 기본값이 있다. 두 번째로 객체도 값이다. new 또는 팩토리 메서드 또는 라이브러리 함수를 이용해서 객체의 값을 얻을 수 있다. 그런데도 함수가 필요하다.

프로그래밍 언어의 핵심은 값을 바꾸는 것이다. 전통적으로 프로그래밍 언어에서는 이 값을 일급값이라고 부른다. 자바 프로그래밍 언어의 다양한 구조체(메서드, 클래스 같은)가 값의 구조를 표현하는 데 도움이 될 수 있다. 하지만 프로그램을 실행하는 동안 이러한 모든 구조체를 자유롭게 전달할 수는 없고 이를 이급 시민이다. 위에서 언급한 값은 모두 일급 자바 시민이지만 메서드, 클래스 등은 이급 자바 시민에 해당한다. 인스턴스화한 결과가 값으로 귀결되는 클래스를 정의할 때 메서드를 유용하게 활용할 수 있지만 여전히 메서드와 클래스는 그 자체로 값이 될 수 없다. 메서드를 일급 시민으로 만들면 프로그래밍에 유용하게 활용할 수 있다. 따라서 자바 8 설계자들은 이급 시민을 일급 시민으로 바꿀 수 있는 기능을 추가했다.

디렉터리에서 모든 숨겨진 파일을 필터링한다고 가정한다. 우선 주어진 파일이 숨겨져 있는지 여부를 알려주는 메서드를 구현해야 한다. 다행히 File 클래스는 이미 isHidden 메서드를 제공한다.

//자바 8 이전 코드
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
	public bollean accept(File file) {
		return file.isHidden();
	}
});  
//자바 8 코드
File[] hiddenFiles = new File(".").listFiles(File::isHidden);

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

자바 8에서는 메서드를 일급값으로 취급할 뿐 아니라 람다(또는 익명 함수)를 포함하여 함수도 값으로 취급할 수 있다. 람다 문법 형식으로 구현된 프로그램을 함수형 프로그래밍, 즉 '함수를 일급값으로 넘겨주는 프로그램을 구현한다'라고 한다.

Apple 클래스와 getColor 메서드가 있고, Apples 리스트를 포함하는 변수 inventory가 있다고 할 때, 모든 녹색 사과를 선택해서 리스트를 반환하는 프로그램을 구현하려 한다. 이처럼 특정 항목을 선택해서 반환하는 동작을 필터(filter)라고 한다.

//자바 8 이전 코드
//녹색 사과 선택
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> filterGreenApples(List<Apple> inventory) {
	List<Apple> result = new ArrayList<>();

	for (Apple apple: inventory) {
		if (apple.getWeight() > 150) {
			result.add(apple);
		}
	}
	return result;
}
//자바 8 코드
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);

여기서 핵심은 자바 8에서는 메서드를 전달할 수 있다는 사실이다. 하지만 한두 번만 사용할 메서드를 매번 정의하는 것은 귀찮은 일이다. 자바 8에서는 이 문제도 간단히 해결할 수 있다.

filterApples(inventory, (Apple a) -> 
  							GREEN.equals(a.getColor()) || a.getWeight() > 150);

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

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

멀티코어 CPU가 아니었다면 원래 자바 8 설계자들의 계획은 여기까지였을 것이다. 다음처럼 라이브러리 메서드 filter를 이용하면 filterApples 메서드를 구현할 필요가 없다.

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

하지만 병렬성이라는 중요성때문에 설계자들은 이와 같은 설계를 포기했다. 대신 자바 8에서는 filter와 비슷한 동작을 수행하는 연산집합을 포함하는 새로운 스트림 API를 제공한다.


1.4 스트림

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

컬렉션을 이용했을 때 다른 문제도 생길 수 있다. 예를 들어 많은 요소를 가진 목록을 반복한다면 오랜 시간이 걸릴 수 있다. 거대한 리스트는 어떻게 처리할 수 있을까? 단일 CPU로는 거대한 데이터를 처리하기 힘들 것이다. 하지만 서로 다른 CPU 코어에 작업을 각각 할당해서 처리 시간을 줄일 수 있다면 좋을 것이다. 이론적으로 8개 코어를 가진 컴퓨터라면 8개 코어를 활용해서 병렬로 작업을 수행하여 단일 CPU 컴퓨터에 비해 8배 빨리 작업을 처리할 수 있다.

스레드 API로 멀티스레딩 코드를 구현해서 병렬성을 이용하는 것은 쉽지 않다. 멀티스레딩 환경에서 각각의 스레드는 동시에 공유된 데이터에 접근하고, 데이터를 갱신할 수 있다. 멀티스레딩 모델은 순차적인 모델보다 다루기가 어렵다.

자바 8은 스트림 API로 '컬렉션을 처리하면서 발생하는 모호함과 반복적인 코드 문제' 그리고 '멀티코어 활용 어려움'이라는 두 가지 문제를 모두 해결했다. 예를 들어 두 CPU를 가진 환경에서 리스트를 필터링할 때 한 CPU는 리스트의 앞부분을 처리하고, 다른 CPU는 리스트의 뒷부분을 처리하도록 요청할 수 있다. 이 과정을 포킹 단계(forking step)이라고 한다. 그리고 각각의 CPU는 자신이 맡은 절반의 리스트를 처리하고 마지막으로 하나의 CPU가 두 결과를 정리한다.

지금은 새로운 스트림 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());

자바 8은 라이브러리에서 분할을 처리한다. 즉, 큰 스트림을 병렬로 처리할 수 있도록 작은 스트림으로 분할한다. 또한 filter 같은 라이브러리 메서드로 전달된 메서드가 상호작용을 하지 않는다면 가변 공유 객체를 통해 공짜로 병렬성을 누릴 수 있다. 함수형 프로그래밍에서 함수형이란 '함수를 일급값으로 사용한다'는 의미도 있지만 부가적으로 '프로그램이 실행되는 동안 컴포넌트 간에 상호작용이 일어나지 않는다.'라는 의미도 포함한다.

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

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

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

우선 자바 9의 모듈 시스템은 모듈을 정의하는 문법을 제공하므로 이를 이용해 패키지 모음을 포함하는 모듈을 정의할 수 있다. 모듈 덕분에 JAR 같은 컴포넌트에 구조를 적용할 수 있으며 문서화와 모듈 확인 작업이 용이해졌다. 또한 자바 8에서는 인터페이스를 쉽게 바꿀 수 있도록 디폴트 메서드를 지원한다. 앞으로 인터페이스에서 디폴트 메서드를 자주 접하게 될 것이므로 디폴트 메서드가 무엇인지 확실히 알아두어야 한다.

//순차 처리 방식
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 이전에는 Collection< T >가 stream이나 parallelStream 메서드를 지원하지 않는 것이 문제였다. 따라서 위 예제는 컴파일할 수 없는 코드이다. 가장 간단한 해결책은 직접 인터페이스를 만들어서 자바 8 설계자들이 했던 것처럼 Collection 인터페이스에 stream 메서드를 추가하고 ArrayList 클래스에서 메서드를 구현하는 것이다.

하지만 이 방법은 사용자에게 너무 큰 고통을 안겨준다. 이미 컬렉션 API의 인터페이스를 구현하는 많은 컬렉션 프레임워크가 존재한다. 인터페이스에 새로운 메서드를 추가한다면 인터페이스를 구현하는 모든 클래스는 새로 추가된 메서드를 구현해야 한다. 현실적으로 언어 설계자들이 컬렉션 인터페이스를 구현한 모든 코드를 책임질 수는 없다.

결정적으로 자바 8은 구현 클래스에서 구현하지 않아도 되는 메서드를 인터페이스에 추가할 수 있는 기능을 제공한다. 메서드 본문은 클래스 구현이 아니라 인터페이스의 일부로 포함된다(그래서 이를 디폴트 메서드라고 부른다).

디폴트 메서드를 이용하면 기존의 코드를 건드리지 않고도 원래의 인터페이스 설계를 자유롭게 확장할 수 있다. 자바 8에서는 인터페이스 규격 명세에 default라는 새로운 키워드를 지원한다.

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

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

따라서 자바 8 이전에는 List를 구현하는 모든 클래스가 sort를 구현해야 했지만 자바 8부터는 디폴트 sort를 구현하지 않아도 된다.

그런데 하나의 클래스에서 여러 인터페이스를 구현할 수도 있는데 여러 인터페이스에 다중 디폴트 메서드가 존재할 수 있다는 것은 결국 다중 상속이 허용되는 의미일까? 엄밀히 다중 상속은 아니지만 어느 정도는 '그렇다'라고 말할 수 있다.


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

일반적인 함수형 언어(SML, OCaml, 하스켈)도 프로그램을 돕는 여러 장치를 제공한다. 일례로 명시적으로 서술형의 데이터 형식을 이용해 null을 회피하는 기법이 있다.

자바 8에서는 NullPointerException을 피할 수 있도록 도와주는 Option< T > 클래스를 제공한다. 이는 값을 갖거나 갖지 않을 수 있는 컨테이너 객체다. 그리고 값이 없는 상황을 어떻게 처리할지 명시적으로 구현하는 메서드를 포함하고 있다. 즉, 형식 시스템을 이용해서 어떤 변수에 값이 없을 때 어떻게 처리할지 명시할 수 있다.

자바 8과 자바 9 모두 자바에 큰 변화를 제공한다. 자바 8에 추가된 메서드 전달, 람다 등의 기능 추가, 자바 9에는 대규모 컴포넌트를 정의하고 사용하는 기능이 추가 되었고 이를 이용해 모듈을 사용하거나 리액티브 프로그래밍 툴킷을 임포트해 시스템을 구성할 수 있다. 마지막으로 자바 10에서는 이전 버전에 비해 지역 변수 추론이라는 사소한 기능 변화가 일어났다.


1.7 마치며

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

0개의 댓글