모던 자바 인 액션 - 1. 자바 8, 9, 10, 11

이정우·2021년 8월 21일
0

Modern Java In Action

목록 보기
1/2

요약 보기(추천)

1. 역사의 흐름은 무엇인가?

// 기존의 정렬 코드
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));

자바는 끊임없이 새로운 버전이 출시되고 있다.

JAVA 1.1 이 1997년에 출시된 이후로, 현재는 JAVA 17 까지 출시가 되었다.

그 중에서도 가장 큰 변화가 일어났던 것은 JAVA 8 인데, 간결한 코드와 멀티코어 프로세서의 활용이라는 요구사항을 기반으로 다음과 같은 새로운 기능을 제공했다.

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

먼저, 스트림 API는 병렬 연산을 지원하는데, 이를 통해 기존에 에러가 많고 비용이 비쌌던 synchronized 키워드를 대체할 수 있다.

또한, 메소드에 코드를 전달하는 기법을 이용하여 동작 파라미터화를 구현할 수 있다. 예를 들어, 비슷하지만 다른 동작을 하는 두 메소드를 각각 작성하는 것보다는 파라미터에 따라 다른 동작을 하도록 구현하는 것이 코드의 길이와 에러를 줄이는데에 더 바람직할 것이다. 이를 통해, 함수형 프로그래밍에서 강력한 위력을 발휘할 수 있다.


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

진화하지 않는 언어는 시장에서 사라질 수 밖에 없다. 또한, 특정 분야에서 단점을 커버할만큼 장점이 가진 언어는 다른 언어를 잡아먹는다. COBOL, ALGOL과 같이 많은 언어가 사라졌고, 잘 설계된 언어들이 많음에도 불구하고 아직까지 OS 분야에서 C언어가 강세를 보이는 것만으로도 알 수 있다.

그렇다면 자바는 어떠한 특징을 가졌길래 20년이 넘는 기간동안 계속해서 쓰이고 있을까?

자바의 위치

자바는 잘 설계된 객체지향 언어로 출시가 되었으며, 쓰레드와 락을 이용한 동시성을 지원했다. 또한, 코드를 JVM 바이트 코드로 컴파일하기 때문에 안정적이라는 특징을 통해 시장을 장악할 수 있었다. 게다가 1990년대에 객체지향 패러다임이 각광을 받기 시작한 것도 한몫했다고 볼 수 있다. 이렇게 자바는 성공적으로 자리를 잡게 되었다.

새로운 기술이나 하드웨어가 도입될 때, 그에 알맞은 언어를 선택하는 것이 중요하다. 그럼에도 새로운 언어를 배우면서 프로젝트에 적용하는 것은 어려움이 있기 때문에, 대다수는 기존의 언어를 계속해서 사용하고 싶은 경향이 강할 것이다. 이러한 트렌드 속에서 기존의 언어가 도태되거나 새로운 언어가 시장에 적응하며 널리 쓰이게 된다.

자바도 이 상황을 벗어날 수는 없었다. 빅데이터 기술의 도입으로 병렬 프로세싱의 필요성이 커졌는데, 그 때의 자바는 충분한 대응이 불가능했기 때문이다. 하지만 JAVA 8 에서 도입된 다양한 기능은 기존에는 없던 완전히 새로운 개념이지만 현재 시장에서 요구하는 기능을 효과적으로 제공하고 있다.

스트림 처리

In computer science, a stream is a sequence of data elements made available over time. - WikiPedia

Stream이란, 한 번에 한 개씩 만들어지는 연속적인 데이터 항목들의 모임을 의미한다. 이론적으로 많은 프로그램들은 표준 입력(stdin, System.in)에서 데이터를 읽고 처리한 뒤, 표준 출력(stdout, System.out)으로 기록한다.

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

위의 코드는 UNIX 프로그램에서 두 파일을 연결한 뒤, 모든 단어를 소문자로 바꾸고 정렬하여 마지막에 위치한 3개의 단어를 출력하는 프로그램이다. 각각의 명령어가 무엇을 수행하는가는 중요하지 않다. 알아둬야할 점은, cat, tr, sort, tail 각각의 명령어가 병렬적으로 실행된다는 것이다. cat이 모든 단어를 처리하지 못했더라도, tr이 처리된 단어들을 받아 수행하고 sort에게 전달하는 등 모든 작업이 동시에 이루어질 수 있다.

JAVA 8 에서는 java.util.stream 패키지에 스트림 API가 추가되었다. 이를 통해, 기존의 자바에서는 한 번에 한 항목만을 처리했던 것을, 여러 작업을 추상화를 통해 스트림으로 만들어 처리할 수 있다는 것이다. 또한, 스트림 파이프라인을 통해 입력 부분을 쓰레드를 사용하지 않고 여러 CPU에 쉽게 할당하며 병렬성을 얻을 수 있다.

코드를 API로 전달

앞선 예제에서 sort는 단순하게 사전순으로 정렬을 하는 명령어였다. 하지만, 다른 기준으로 정렬을 하고 싶다면 어떻게 해야할까?

택배 송장을 예로 들어보자. 2013UK0001, 2014US0002, ...와 같이 년도, 국가 코드, 고객 ID로 조합된 송장이 있다면, 국가 코드와 고객 ID를 각각 기준으로 하는 정렬 기능 필요할 것이다. 기존 자바에서는 Comparator 객체를 이용하여 sort에게 인자를 넘겨줄 수 있지만, 복잡할 뿐만 아니라 재사용 측면에서도 효율적이지 않다.

JAVA 8 에서는 여러 메소드를 다른 메소드의 인자로 넘기는 기능이 추가되었는데, 이를 동작 추상화라고 부른다.

병렬성과 공유 가변 데이터

병렬성을 얻기 위해서는 스트림 메소드로 전달하는 코드에 약간의 수정이 필요하다. 다른 코드와 동시에 실행되더라도 안전하게 실행이 되도록 프로그래머가 보장을 해주어야 하는 것이다.

x라는 변수를 A와 B 메소드가 사용한다고 생각해보자. 병렬로 실행되지 않을 때는 어렵지 않게 원하는 x 값을 구할 수 있지만, 병렬로 실행될 경우 A와 B의 실행 순서를 보장하기 어렵고, 그에 따라 원하는 결과를 얻을 수 없을 확률이 더 높을 것이다.

이 때, x를 공유된 가변 데이터라고 부르며, 여기에 접근을 하지 않도록 코드를 작성해야 한다. 기존의 자바에서는 synchronized 키워드를 통해 보장이 가능하지만 성능상 그리 좋지 않았다. JAVA 8 에서는 스트림을 이용하여 쉽게 사용할 수 있다.

자바가 진화해야 하는 이유

자바의 새로운 버전이 발표될 때마다 Generic, for-each 등 다양한 문법들이 생겨나고 있다. 이를 통해 에러를 쉽게 잡고, 가독성과 편의성도 높아졌다. 또한, Iterator를 사용한 반복문 대신 forEach가 생겨나 객체지향에서 함수형 프로그래밍에 가까워졌으며, 그 덕분에 두 가지 프로그래밍 패러다임의 장점을 활용하여 문제를 해결할 수 있게 되었다.


3. 자바 함수

프로그래밍 언어에서 함수는 메소드 중에서도 정적 메소드(Static Method)를 주로 의미한다. 기존 자바의 함수는 다른 언어와 비슷한 방식으로 선언을 하고 사용해왔다. 하지만 JAVA 8에서는 함수를 하나의 값으로 재정의하였다.

자바에서는 int, double 등의 기본형(Primitive Type)과 Object를 상속받은 객체인 참조형(Reference Type), 배열 등을 값으로 사용해왔다. 지금까지 잘만 사용해왔는데, JAVA 8에서는 왜 추가하게 된걸까?

함수형 프로그래밍의 가장 큰 특징은 순수 함수를 1급 객체로 간주하여 값처럼 사용하는 것이다. 여기서 순수 함수란, 인자가 아닌 변수의 값이나 자료구조가 바뀌지 않고, 예외 및 오류가 발생하여 실행이 중단되지 않으며, I/O가 발생하지 않는 등의 특징을 지니는 함수를 의미한다. 그리고 순수 함수를 1급 객체로 간주하여 파라미터나 반환 값으로 사용할 수 있게 된다. 순수 함수를 사용함으로써, 앞서 지겹도록 말했던 병렬성을 보다 쉽게 얻을 수 있다.

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

메소드 참조

// 디렉토리 내 모든 숨겨진 파일을 필터링하는 메소드
File[] hiddenFiles = new File(".").listFiles(new FileFilter(){
	public boolean accept(File file){
    	return file.isHidden();
    }
});

위의 코드에서 File 클래스는 이미 isHidden 메소드를 가지고 있음에도 FileFilter라는 객체를 이용해야 필터링이 가능하다. 간단한 동작을 수행하는데도 코드는 3줄이 쓰였고, 가독성도 좋지 않다.

// java 8에서 동일한 기능 수행
File[] hiddenFiles = new File(".").listFiles(File::isHidden);

JAVA 8 에서는 위와 같이 한 줄로 수정이 가능하다. 메소드 참조(::)를 이용한 것인데, 함수를 listFiles에 직접 전달한 것이다. 이제부터 메소드는 일급 객체로 활용이 가능해졌다.

람다 : 익명 함수

JAVA 8 에서는 람다를 사용한 함수 역시 값으로 취급살 수 있다.

코드 넘겨주기

Apple 클래스와 Apple의 리스트인 inventory 변수가 있다고 가정하자. 이때, 특정한 사과만을 선택하여 리스트로 반환하는 프로그램을 구현하고 싶다. 이렇게 특정 항목을 선택해 반환하는 동작을 필터라고 한다.

public static List<Apple> filterGreenApples(List<Apple> inventory) {
    List<Apple> list = new ArrayList<>();
    
    for(Apple apple : inventory) {
        if(GREEN.equals(apple.getColor()) {
            list.add(apple);
        }
    }
    return list;
}

이번에는 다른 조건으로 사과를 필터링해보자. 무게가 150그램 이상인 사과를 고르려면 대부분은 위의 코드를 복붙해서 if문 내의 조건만 바꿀 것이다.

public static List<Apple> filterGreenApples(List<Apple> inventory) {
    List<Apple> list = new ArrayList<>();
    
    for(Apple apple : inventory) {
        if(apple.getWeight() > 150) { // 이 부분만 변경!
            list.add(apple);
        }
    }
    return list;
}

이렇게 코드를 복붙하는 것은 바람직하지 못하다. 에러가 발생하면 복붙한 모든 코드를 수정해야 하기 때문이다. 하지만 JAVA 8에서는 메소드를 인수로 넘겨줄 수 있기 때문에 다음과 같이 전체 코드를 변경할 수 있다.

public static boolean isGreenApple(Apple apple) {
    return GREEN.equals(apple.getColor());
}

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

// 명확하게 하기 위해 사용하며 보통은 java.util.function에서 import함
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

인수로 값을 받아 truefalse를 반환하는 함수를 의미한다.

메소드 전달에서 람다로

바로 앞의 코드에서 isGreenApple, isHeavyApple 메소드를 구현하여 필터링을 수행했었다. 하지만, 이런 필터링은 자주 수행하지 않는데 그 때마다 메소드를 정의하는 것은 귀찮은 일이다. JAVA 8 에서는 이러한 불편함도 해결했는데, 익명 함수(람다)가 그것이다.

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

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

// 이렇게도 사용 가능하다!
filterApples(inventory, (Apple a) -> RED.equals(a.getColor()) || a.getWeight() < 80 );

이렇게 람다를 사용하면 필터에 사용되는 함수를 따로 찾지 않아도 되며, 짧고 간결하게 코드를 작성할 수 있다. 하지만, 람다가 몇 줄 이상으로 길어진다면 가독성을 해칠 수 있기 때문에 그럴 때는 메소드를 따로 빼내서 작성하는 것이 바람직하다.


4. 스트림

앞선 함수에서 필터를 위해 filterApples 메소드도 구현을 해야 했다. 하지만, 에서 말했듯이 JAVA 8 에서는 스트림 처리가 가능하다.

Map<Currency, List<Transaction>> transactionByCurrencies = new HashMap<>();

for(Transaction transaction : 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);
    }
}

for문을 돌면서 조건에 따라 처리를 하다 보니 간단한 동작을 하더라도 코드를 이해하기가 어렵다. 하지만 스트림 API를 이용하면 코드를 다음과 같이 변경할 수 있다.

import static java.util.stream.Collectors.groupingBy;
Map<Currency, List<Transaction>> transactionsByCurrencies =
    transactions.stream()
        .filter((Transaction t) -> t.getPrice() > 1000)
        .collect(groupingBy(Transaction::getCurrency));

스트림에 대한 지식이 없다면 코드를 이해하기 어렵겠지만, 우선 기존 방식과 다르게 데이터를 처리할 수 있다는 것만 알고 있으면 된다. 기존 방식은 for-each문을 이용해서 각 요소를 반복하며 작업을 수행했는데, 이를 외부 반복이라고 한다. 그에 비해 스트림을 사용하면 내부 라이브러리에서 데이터가 처리되는데, 이를 내부 반복이라고 한다.

많은 양의 데이터를 스트림을 이용해서 처리한다면, 서로 다른 CPU 코어에 작업을 할당하여 처리 시간을 크게 줄일 수 있을 것이다.

멀티쓰레딩은 어렵다

이전 버전의 자바에서는 쓰레드 API를 사용하여 병렬성을 이용하는데, 쓰레드를 제대로 제어하지 못하면 원하지 않는 결과가 도출될 수 있기 때문에 병렬성을 이용하는 것이 어려웠다.

하지만, JAVA 8 에서는 스트림 API로 컬렉션을 사용할 때의 모호함과 반복적인 코드의 발생(filterApples 예제 같이)과 멀티코어의 활용이 어렵다는 문제점을 모두 해결하였다.

주어진 조건에 따라 데이터를 필터링하거나, 데이터를 추출하거나 그룹화하는 등의 기능이 스트림 API에 구현되어 있다. 또한, 이러한 동작들을 쉽게 병렬화하여 연산을 빠르게 수행할 수 있다. 이 때, 다음의 그림처럼 CPU마다 각각이 처리할 부분을 할당받을 수 있는데, 이를 포킹 단계라고 한다. 이후 각각의 CPU는 자신에게 할당받은 데이터를 처리한 후, 한 CPU가 모든 데이터를 모아 처리한다.

결국 기존의 컬렉션은 어떻게 데이터를 저장하고 접근할지를 중점적으로 보고, 스트림은 데이터에 어떤 계산을 할 것인지 묘사하는 것을 중점적으로 본다. 따라서 데이터를 가장 빠르게 처리하는 방법은 스트림과 람다를 사용하여 병렬성을 공짜로 얻어서 처리하는 것이다.

import static java.util.stream.Collectors.toList;

// 순차 처리
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());

5. 디폴트 메소드와 자바 모듈

최근에는 주로 외부에서 만들어진 컴포넌트를 이용하여 시스템을 구축한다. 하지만 자바에서는 특별한 구조가 아닌 평범한 자바의 패키지가 포함된 JAR 파일만 제공하였기에, 패키지 내의 인터페이스를 바꿔야하는 경우에는 인터페이스를 구현하는 모든 클래스를 수정해야만 했다.

JAVA 9 에서는 모듈을 정의할 수 있는 모듈 문법을 제공한다. 그 덕분에 컴포넌트에 구조를 적용할 수 있으며, 문서화와 모듈 확인 작업이 쉬워졌다.

JAVA 8 에서는 인터페이스를 쉽게 변경할 수 있도록 디폴트 메소드를 지원한다. 디폴트 메소드는 특정 프로그램을 구현하는 목적이 아니라, 프로그램이 쉽게 변화할 수 있는 환경을 제공하기 위해 만들어졌다. JAVA 8 이전에는 List<T>stream이나 parallelStream 메소드를 지원하지 않았기 때문에, 위의 코드는 컴파일이 불가능했다. 이를 해결하기 위한 가장 간단한 방법은 자바의 설계자들이 했던 것처럼 컬렉션 인터페이스에 stream 메소드를 추가하고 ArrayList 클래스에서 메소드를 구현하는 것이다. 하지만, 인터페이스에 메소드를 추가하면 이를 구현한 모든 클래스에도 메소드를 추가해야 한다는 말이기 때문에 현실성이 없다.

따라서, JAVA 8 에서는 구현하지 않아도 되는 메소드를 인터페이스에 추가할 수 있는 기능을 제공하는데, 이것이 바로 디폴트 메소드이다. 실제로 List 인터페이스에 다음과 같은 디폴트 메소드가 정의되었기 때문에, List에서 직접 sort 메소드를 호출할 수가 있으며 default sort를 구현하지 않아도 된다.

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

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

null 참조를 만든 것은 10억 달러짜리 실수였다 - Tony Hoare

JAVA 8 에서는 NullPointer 예외를 피할 수 있도록 Optional<T> 클래스를 제공한다. Optional<T>는 값이 없는 상황을 어떻게 처리할지 구현하는 메소드를 포함하고 있기 때문에 NullPointer 예외를 피할 수 있다.

또한, 구조적 패턴 매칭 기법도 존재한다. 수학에서는 f(0) = 1 f(n) = n * f(n-1)과 같이 표현하는데, 자바의 if-then-elseswitch와 비슷한 동작을 한다. 실제로는 switch를 확장한 개념인데, 조건에 클래스를 넣어서 비교를 할 수 있으며 에러도 검출할 수 있다.


7. 마치며

  • 프로그래밍 언어는 변화해서 살아남거나 그대로 머물면서 사라진다. 지금은 자바가 견고하게 위치를 지키고 있지만, 코볼과 같은 언어들처럼 언제 사라질지 모른다.
  • 자바 8은 프로그램을 더 효과적이고 간결하게 구현할 수 있는 새로운 개념과 기능을 제공한다.
  • 기존의 자바 프로그래밍 기법으로는 멀티코어 환경을 온전하게 활용하기 어렵다.
  • 함수는 일급값이다. 메소드를 함수형 값으로 넘겨주는 방법과 익명 함수(람다)를 기억하자.
  • 스트림 개념 중 일부는 컬렉션에서 가져온 것으로, 이 둘을 적절하게 활용하면 데이터의 병렬 처리와 가독성이 좋은 코드의 작성 두 마리 토끼를 모두 잡을 수 있다.
  • 자바 9의 모듈을 통해 시스템의 구조를 만들고, 디폴트 메소드를 이용해 인터페이스를 쉽게 변경할 수 있다.
  • 함수형 프로그래밍에서 null 처리 방법과 패턴 매칭의 활용 등의 방식이 자바에도 옮겨왔다.

0개의 댓글