Development/Java

Java 8의 주요 변경 사항과 실무 적용 포인트

bbubbush 2020. 12. 30. 00:07

들어가며

Java는 끊임없이 진화하고 있다.

1996년 JDK 1.0으로 발표된 이후 25년이 지난 오늘날까지 대한민국에서 널리 사용되는 이유는 여기에 있다.
그중 'Modern java'라는 별칭이 붙은 Java 8의 변경사항을 정리하며 '이런 기능을 어떻게 실무에 녹여낼 수 있을까' 예제를 통해 설명하는 것이 이번 포스팅의 목표이다.

 

◇  이번 포스팅에서 Java 8의 변경사항이 등장한 배경이나 역사적인 기술에 대해서는 다루지 않을 것이다. 모두 알지 못하는 필자의 무지도 있지만 글이 방대해지면서 독자의 집중력이 흐려지는 것을 막기 위함이다. 그래도 흥미롭거나 참고하기 좋은 키워드를 문단 하단에 '더 알아보기'로 추가하여 관심이 있는 독자가 더 읽어볼 수 있도록 제공한다.

 

주요 변경사항

Java 8의 주요 변경 사항은 아래와 같다.

 

  1. Lambda expression(람다 표현식)
  2. Functional interface(함수형 인터페이스)
  3. Default method(디폴트 메서드)
  4. Stream(스트림)
  5. Optional(옵셔널)
  6. 새롭게 추가된 날짜 API
  7. CompletableFuture(컴플리터블 퓨처)
  8. JVM의 변화

 

변경 사항마다 간단한 설명과 실무에 적용하는 코드 예제, 유의사항을 설명하며 진행할 예정이다.


1. Lambda expression(람다 표현식)

메서드로 전달할 수 있는 Anonymous function(익명 함수)를 단순한 문법으로 표기한 것을 람다 표현식이라고 한다. 글로는 설명이 어려우니 바로 코드를 보자. Thread 객체를 생성하여 동작시키는 코드다.

 

// 익명 클래스로 Runnalbe을 구현
Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("Start to new thread!");
    }
});

thread.start();


// 람다 표현식으로 단순하게 표현
Thread thread = new Thread(() -> System.out.println("Start to new thread!"));
        
thread.start();

위 두 코드는 동일한 결과이지만 문법만 다르게 사용했을 뿐이다. 이번에는 자세히 람다 표현식의 구성을 살펴보자.

 

람다 표현식의 구성

람다 표현식은 람다 파라미터와 화살표, 람다 바디로 구성되어 있다.

위 예제에서는 '()'가 람다 파라미터, '->'가 화살표, 'System.out.println("Start to new thread!")'가 람다 바디에 해당한다.

 

동작하는 방법은 기본적으로 함수라고 생각하면 이해하기 쉽다. 복잡한 정의 말고 기능으로써만 보면 함수와 람다 표현식은 다음과 같다.

 

어떤 값이 들어가면(람다 파라미터) 결과물이 나온다.(람다 바디)

 

몇 가지 람다 표현식을 살펴보면서 어떻게 함수처럼 동작하는지 살펴보자.

 

1. 하나의 파라미터를 갖고 리턴 타입이 없는 람다 표현식

(String str) -> System.out.println("parameter is " + str);

String 타입의 파라미터 str을 받아서 출력문을 실행하는 람다 표현식이다. 람다 파라미터의 타입은 생략이 가능 가며 컴파일러가 문맥에 맞게 해석하여 알맞은 객체를 선택하게 된다. 거기다 파라미터가 한 개인 경우에는 괄호를 생략할 수 있다.

따라서 아래와 같은 문법도 사용 가능하다.

 

// 타입을 생략한 코드
(str) -> System.out.println("parameter is " + str);

// 람다 파라미터 괄호를 생략한 코드
str -> System.out.println("parameter is " + str);

 

2. 하나의 파라미터를 갖고 리턴 타입이 있는 람다 표현식

// 람다 바디를 하나의 라인으로 표현
(i) -> i + 10;

// 람다 바디를 여러 라인으로 표현
(i) -> {
    int result = i + 10;
    return result;
};

Integer 타입의 파라미터 i를 받아서 10을 더한 값을 반환하는 람다 표현식이다. 만약 람다 바디가 짧아서 한 줄로 끝난다면 중괄호와 return 키워드를 생략할 수 있다. 두 줄 이상이 된다면 명시적으로 중괄호와 return 키워드를 선언해야 한다.

 

3. 파라미터가 없고 리턴 타입이 있는 람다 표현식

()-> Integer.MAX_VALUE;

마지막으로 Integer.MAX_VALUE를 반환하는 람다 표현식이다. 파라미터가 없는 경우에는 빈 괄호를 명시적으로 표시해야한다. 아마 문법적으로 생략되는 경우와 실수로 생략하는 경우를 구분하기 위함으로 생각된다. 

 

 

지금까지 람다 표현식 문법을 간단히 살펴봤다. 아직까지는 왜 써야 하는지, 어떤 장점이 있는지 감이 안 오겠지만 이후 등장할 함수형 인터페이스나 스트림을 익히고 나면 왜 람다 표현식만의 강점이 느껴질 것이다.

 

더 알아보기

- 람다 표현식의 변수 캡쳐

- 람다 표현식의 동작 원리


반응형

 

2. Functional interface(함수형 인터페이스)

단 하나의 추상 메서드를 갖는 인터페이스함수형 인터페이스라고 한다. 앞서 예제로 사용했던 Runnable 인터페이스는 추상 메서드 run() 하나만 있기 때문에 함수형 인터페이스라고 할 수 있다. Java 8부터는 함수형 인터페이스를 컴파일 시점에도 확인할 수 있도록 @FunctionalInterface 애노테이션도 제공한다. 간단한 함수형 인터페이스를 만들어보고 왜 이런 형태를 함수형 인터페이스라고 부르는지 알아보자.

 

함수형 인터페이스 만들기

앞으로 자주 사용하게 될 Car 인터페이스를 함수형 인터페이스로 만들어보자. 

@FunctionalInterface
public interface Car {
    String drive(int driveLevel);
}

 

물론 @FunctionalInterface가 없어도 Car는 함수형 인터페이스이다. 어디까지나 컴파일 시점에 해당 인터페이스가 규칙을 잘 지키고 있는지 검증하는 역할을 도와줄 뿐이다.(여담으로 Retention은 런타임으로 설정되어 있다.)

 

그럼 전통적인 방법으로 인터페이스를 사용해보자.

 

Car car = new Car() {
    @Override
    public String drive(int driveLevel) {
        return driveLevel == 0 ? "" : "자동차가 " + driveLevel + " 의 속도로 이동합니다.";
    }
};

System.out.println(car.drive(10));

 

특별함 없는 익명 클래스 구현 코드다. 람다 표현식을 설명할 때 설명하지 않은 부분이 있는데 람다 표현식은 함수형 인터페이스를 간결하게 표현할 수 있다. 아래 코드는 위의 코드와 동일하게 동작하지만 람다 표현식으로 간결하게 표현하였다.

 

Car car = (i) -> i == 0 ? "" : "자동차가 " + i + " 의 속도로 이동합니다.";
System.out.println(car.drive(0));

 

한결 코드가 보기 편해졌다. 실제로 Java 8에서 람다 표현식은 함수형 인터페이스를 위해 등장했다 해도 과언이 아니다. 파라미터 X가 여러 함수형 인터페이스를 거쳐 최종적인 값 Y의 형태로 변경할 수 있기 때문이다. 바로 함수형 프로그래밍이 Java에도 등장하게 되었단 뜻이다.

 

함수형 프로그래밍

이 그럼을 눈에 익혀 둔다면 앞으로 등장하게 되는 Method chaining(메서드 체이닝) 형태의 프로그래밍이 익숙하게 느껴질 것이다. 

 

실무 Tip

  • 기본적으로 자주 사용되는 메서드 시그니처를 품고 있는 함수형 인터페이스는 java.util.function 패키지에 이미 정의되어 있다. 직접 만들어 보는 것도 좋지만 이것을 활용하면 중복된 코드도 줄이고 다른 개발자와 의사소통도 편하다.

 

더 알아보기

- 기본으로 제공되는 함수형 인터페이스


3. Default method(디폴트 메서드)

Java 8부터 인터페이스에서 구현된 메서드를 만들 수 있다! 바로 샘플 코드를 보자.

public interface SampleInterface {
    // abstract method
    String returnHello(String msg);

    // default method
    default void hello(String msg) {
        System.out.println("hello " + msg);
    }
}

 

default 키워드를 사용하여 간단하게 구현할 수 있다. 그럼 어떻게 실무에서 활용할 수 있을까? 간단한 요구사항을 가정해보자.

 

코드 호환성을 유지하면서 새로운 기능 추가

<요구사항>

"한시적인 이벤트를 위해 Car 인터페이스를 구현한 Bus, Tax, HorseCar 클래스에 하늘을 날 수 있는 fly() 메서드를 추가해주세요. 3개월 이후에 해당 기능을 다시 막을 수 있습니다."

 

Car의 구현체별로 fly()를 구현하는 방법도 있지만 언제 변경될지 모르는 한시적인 이벤트에서 좋은 방법은 아닌 것 같다. 이럴 때 기존의 코드의 변경 없이 디폴트 메서드로 fly()를 만들 수 있다.

 

public interface Car {
    // 전진
    void drive();
    
    // 후진    
    void reverse();
    
    // 날기
    default void fly() {
        System.out.println("Fly to the moon");
    }
}

 

이처럼 디폴드 메서드는 기존의 인터페이스 구현체들의 변경 없이 공통적인 기능을 제공할 때 사용된다.

 

불필요한 구현부 제거

인터페이스를 구현하다보면 사용하지 않는 메서드를 빈 상태로 구현하는 경험이 종종 있었을 것이다. 가령 위 Car 인터페이스의 구현체인 HorseCar는 후진 기능이 필요 없다. 따라서 불필요하지만 비어있는 메서드를 구현해줘야 한다.

 

public class HorseCar{
    public void drive() {
        System.out.println("마차가 앞으로 갑니다.");
    }
    
    public void reverse() {
        
    }
}

 

이런 경우에도 디폴트 메서드를 이용하면 불필요한 코드를 줄일 수 있다. 디폴트 메서드를 사용한 Car 인터페이스를 보자.

 

public interface Car {
    // 전진
    void drive();
    
    // 후진    
    default void reverse() {
    
    }
    
    // 날기
    default void fly() {
        System.out.println("Fly to the moon");
    }
}

 

reverse()를 빈 상태로 디폴트 메서드로 선언한다면 필요한 구현체에서만 Override 하여 구현할 수 있다. 이런 방법 역시 기존의 코드 호환성의 변화 없이 적용할 수 있는 방법이다. 다만 이 방법은 인터페이스가 갖는 추상 메서드 구현의 강제성이 사라지므로 동료들에게 왜 이렇게 했는지 꼭 인지시켜야 한다.

 

 

더 알아보기

- 추상 클래스와 인터페이스 차이

- 인터페이스의 static method


4. Stream(스트림)

Java 8이 등장하기 전부터 많은 관심을 받았고, 등장 이후에도 가장 사랑받는 기능이 아닐까 싶다. 스트림Collection(컬렉션)을 멋지고 편리하게 처리하는 방법을 제공하는 API이다. 별다른 노력 없이 병렬 처리도 제공해주며, 마치 데이터베이스 쿼리를 작성하듯 직관적인 코드를 제공해준다. 요구사항을 통해 스트림의 기능을 살펴보자.

 

 

<요구사항>

"책들 중 니체가 작성한 책의 ISBN 정보가 필요합니다. 정렬은 책 이름을 기준으로 해주세요."

 

기본적인 스트림 사용

먼저 스트림을 사용하지 않은 코드를 보겠다. 정렬을 먼저 한 이유는 니체가 작성한 책을 필터링 하고 정렬하려면 새로운 List객체를 만들어 담는 과정이 필요하기 때문이다. (물론 이 코드는 불필요한 정보까지 정렬하기 때문에 성능은 매우 안 좋다)

books.sort(Comparator.comparing(Book::getName));

List<String> booksWrittenByNietzsche = new ArrayList<>();
for (Book book : books) {
    if (book.getAuthor().equals("Friedrich Nietzsche")) {
        booksWrittenByNietzsche.add(book.getIsbn());
    }
}

 

아직은 괜찮은 코드로 보인다. 하지만 이틀 정도 지나고 나서 이 코드를 보고 어떤 요구사항을 구현한 코드인지 알 수 있을까 의문이 든다. 코드를 분석하는 시간이 늘어난다는 뜻으로 생각해도 괜찮다. 그럼 이번에는 스트림의 기능을 적극적으로 사용하여 요구사항을 구현해보겠다.

 

List<String> booksWrittenByNietzsche = 
            books.stream()
                .filter(book -> book.getAuthor().equals("Friedrich Nietzsche"))
                .sorted(Comparator.comparing(Book::getName))
                .map(Book::getIsbn)
                .collect(Collectors.toList());

 

스트림의 기능을 잘 몰라도 이 코드가 어떤 요구사항을 구현했는지 느낌이 바로 올 것이다. 한 달이 지나고 이 코드를 봐도 의미를 해석하는데 큰 무리가 없다. 그럼 어디서 이런 차이를 만들까? 그것은 바로 for문과 if문이 눈에 보이지 않기 때문이다. 두 문법은 꼭 필요하지만 잘 작성되지 않으면 의미를 해석하는데 많은 어려움을 만든다. 그래서 스트림은 컬랙션에서 자주 사용되는 기능을 미리 제공하여 개발자가 불필요한 for문과 if문을 사용하지 않도록 돕는다.

 

기본적인 사용방법을 봤으니 다음으로 특징을 살펴보고 마무리하겠다.

 

스트림의 특징

다섯 가지 특징을 주제로  스트림을 살펴보자.

 

1. 파이프라이닝을 지원한다.

메서드 체이닝으로 연결된다. 위 예제에서 filter(), sorted(), map(), collect() 등이 계속 이어진다. 이렇게 스트림 객체끼리 연속으로 처리하면서 하나의 파이프라인이 되어 최종적인 결괏값을 반환하게 된다. 함수형 인터페이스를 설명하면서 언급했던 그림이 하나의 파이프라인이라고 생각하면 된다.

 

2. 내부 반복을 지원한다.

위에서 잠깐 언급했듯 코드 외부에서 for문을 사용하지 않고 filter()처럼 내부에서 알아서 처리한다. 그래서 코드를 분석하는데 방해되는 요인을 줄이고 비즈니스 로직 구현에만 충실한 코드를 짤 수 있었다.

 

3. 딱 한 번만 탐색한다.

스트림의 기능을 거치게 되면 이전 상태로 돌아갈 수 없다. 무슨 뜻이냐면 연산 이전의 값을 저장하지 않고 연산된 값만 새롭게 반환한다는 뜻이다. 예제에서 filter() 다음에 sorted()를 호출한다. sorted()에는 이미 저자 이름이 니체인 책 정보만 파라미터로 들어가게 된다. 그래서 재사용이 필요하다면 변수에 저장해 두고 사용해야 한다.

(하지만 성능에 쫓기는 상황이 아니면 변수에 저장하지 않고 사용하길 권장한다. 다른 비즈니스 로직에 종속되거나 원하지 않는 Side effect가 발생할 수 있기 때문이다. 더구나 스트림은 소비 중심의 개발 철학을 갖고 있기 때문에 데이터를 필요에 맞게 소비하는 것이 사상적으로도 잘 어울린다.)

 

// 스트림을 변수에 저장
Stream<Book> filterByAuthor = books.stream()
        .filter(book -> book.getAuthor().equals("Friedrich Nietzsche"));

// 저장된 스트림을 사용
List<String> booksWrittenByNietzsche = filterByAuthor
        .sorted(Comparator.comparing(Book::getName))
        .map(Book::getIsbn)
        .collect(Collectors.toList());
        
// 다른 로직에 재사용
List<Book> collect = filterByAuthor
        .sorted(Comparator.comparing(Book::getIsbn).reversed())
        .skip(3L)
        .limit(100L)
        .collect(Collectors.toList());

 

4. 게으르게 동작한다.

프로그래밍에서 게으르게 동작한다는 의미는 필요한 시점까지 실행을 미루다 특정 시점이 되면 동작하는 기법을 말한다. 간단한 예제 코드를 보자.

 

books.stream()
    .filter(book -> { 
        System.out.println(book.getAuthor()); // 작가 출력
        return book.getAuthor().equals("Friedrich Nietzsche"); 
    })
    .sorted(Comparator.comparing(Book::getName));

 

books에 니체의 책이 있다는 가정하에 이 코드를 실행하면 "Friedrich Nietzsche" 이 여러 번 출력될까? 답은 '아니오'이다. 스트림은 종료 연산이 없으면 중간 연산을 실행하지 않는다. 이 말은 중간연산이 아무리 복잡해도 종료연산이 없다면 실행되지 않는다는 의미이다. 이것을 게으르게 동작한다고 표현한다.

 

5. 중간 연산과 종료 연산으로 구분된다.

게으른 처리를 설명하면서 중간 연산종료 연산이라는 개념이 있었다. 스트림은 크게 이 둘로 구분된다. 이들을 구분하는 특징으로 반환 타입을 보면 된다. Stream<T> 형태의 스트림이 반환된다면 중간 연산이고, 그렇지 않으면 종료 연산이다. "4) 게으르게 동작한다" 의 예제가 동작하지 않는 이유는 filter()나 sorted()는 모두 중간 연산이기 때문이다. 따라서 foreach(), count(), collect() 등과 같은 종료 연산이 없어 실행되지 않는 것이다.

 

 

 

스트림의 대한 개념과 특징을 간단하게 살펴보았다. 개발자는 마치 쿼리문을 작성하듯 '처리방법'을 고민하지 않고 '원하는 값'을 정하면 적절한 방법으로 처리하여 값을 돌려준다. Java 8에서 함수형 인터페이스와 람다 표현식이 등장한 이유는 개인적으로 스트림을 통한 함수형 프로그래밍을 제공하기 위함이라고 생각한다. 그만큼 Java 8에 등장한 모든 특징의 집약이므로 자세히 학습해보길 권한다.

 

 

실무 Tip

  • 스트림은 내부 반복을 지원한다. 따라서 for문이 없는 것이 아니라 보이지 않을 뿐이니 성능이 중요한 애플리케이션에서는 상황에 따라 외부 반복을 통해 구현하는 것이 이점을 얻을 수 있다.
  • 언급하지는 않았지만 병렬 스트림이 있다. 허나 스트림을 병렬로 처리한다고 해서 무조건 빠른 것이 아니다. 데이터 크기에 따라 다르니 반복적인 테스트를 통해 적당한 데이터 크기를 찾아 이에 맞게 사용해야한다.

 

더 알아보기

- Fork/Join F/W

- 병렬 스트림

- 기본자료형 스트림(링크는 IntStream이지만 다른 기본 자료형 스트림도 있다)


5. Optional(옵셔널)

Optional은 Java가 가지고 있는 null의 문제점을 보완하고자 등장하였다. 기본 콘셉트는 Integer나 Double과 같은 Wrapper class로서 객체를 바로 호출하지 않고 Optional 안에서 호출함으로써 null이 발생할 가능성을 봉쇄시킨다. 그럼 요구사항의 기능을 개발하면서 Optional 객체가 어떻게 실무에 적용이 될 수 있는지 예제 코드를 살펴보도록 하겠다.

 

<요구사항>

"책 정보를 담는 Book 객체에는 저자의 정보를 담는 Author 객체를 필드 값으로 갖는다. 이를 바탕으로 Book 객체에서 저자의 이름을 반환하는 메서드를 만들어 주세요"

 

먼저 Java 8 이전의 null을 처리하는 방법을 보겠다.

 

전통적인 null 처리

public class BookService {
    // 파라미터로 Book 객체를 받는다.
    // Book 객체가 가진 Author객체의 getName()으로 저자의 이름을 반환한다.
    public String getAuthorName(Book book) {
        if (book == null) {
            throw new NullPointerException("This book is null");
        }
        
        Author author = book.getAuthor();
        return author.getName();
    }
}

 

이 코드에는 몇 가지 문제점이 있다.

첫 번째는 Book 객체가 가진 getAuthor()의 결괏값이 null 일 가능성이 있다. 그래서 Author 객체에 대한 null 값을 확인하는 방어 코드를 추가해야 한다.

// book.getAuthor()에서 null이 반환된다면 author.getName() 코드에서 NPE가 발생한다.
Author author = book.getAuthor();

if (author == null) {
    throw new NullPointerException("This author is null");
}

 

두 번째는 이 메서드가 null을 반환할 수 있다는 것이다. BookService의 getAuthorName()을 호출하는 객체는 이 결괏값이 null인지 아닌지 신뢰할 수 없어 NPE을 방어하는 코드를 추가해야 한다. 

// 이전 코드 생략...

String authorName = bookService.getAuthorName(book);

// 리턴 타입이 null이 아니라는 확신이 없으므로 확인 로직을 추가해야한다.
if (authorName == null) {
    throw new NullPointerException("This author name is null");
}

 

마지막으로 getAuthorName() 메서드가 어떤 비즈니스 로직을 수행하고 있는지 한눈에 파악이 힘들다. 이렇게 간단한 예제에서도 NPE를 막기 위한 방어 로직이 여러 곳에 흩어져 있다. 그래서 소스코드가 어떤 기능을 수행하는지 분석할 때  많은 시간이 필요하게 된다.

 

그럼 Optianl을 통해 이 문제가 어떻게 해결이 되는지 살펴보자.

 

Optional을 활용한 null 처리

getAuthorName()은 아래와 같이 변경될 수 있다. 자세히 봐야 할 부분은 if를 통해 null을 확인하는 부분이 사라졌다는 것과 메서드의 반환 타입이 String -> Optional<String> 으로 변경되었다는 점이다.

public class BookService {
    // Optional을 사용하여 null에서 안전한 코드 작성하기
    public Optional<String> getAuthorName(Book book) {
        return Optional.ofNullable(book)
                .map(bookObject -> bookObject.getAuthor())
                .map(authorObject -> authorObject.getName());
    }
}

 

전통적인 null 처리의 세 가지 문제점이 어떻게 처리되었는지 하나씩 살펴보자.

 

  1. null을 방어하는 코드를 추가해야 한다
    -> Optional을 사용하여 null이 발생할 여지를 제거했다. 파라미터의 Book 객체가 null이어도 메서드 내부에서는 NPE가 발생하지 않고 비즈니스 로직 그대로 흘러갈 수 있다.
  2. getAuthorName()을 호출하는 곳에서 결괏값으로 null을 받을 수 있다.
    -> 근본적인 문제는 해결되지 않았다. 다만 리턴 타입을 Optional<String>으로 했기 때문에 메서드를 사용하는 개발자는 '빈 결괏값이 반환될 수 있다'라고 인지할 수 있다. 만약 null이 반환되지 않는 것이 확실한 경우에는 리턴 타입을 String으로 표기하여 호출하는 입장에서 별도의 null 방어 코드를 넣지 않아도 되게 할 수 있다.
  3. 비즈니스 코드와 null 방어 코드가 뒤섞여 분석하는데 오랜 시간이 걸린다.
    -> null의 방어 코드가 완전히 사라져 getAuthorName()이 어떤 로직을 수행하는지 한눈에 파악할 수 있다.

이제는 코드를 작성하다 NPE가 발생할 때마다 if문을 칠 필요가 없어졌다. 코드의 가독성과 안정성은 높이고 코드 분석 시간은 줄여 전체적인 프로젝트 수행능력을 향상할 수 있는 Optional을 익혀 적극적으로 사용해 볼 법하다.

 

그럼 주인공인 Optional의 주요 메서드를 간단히 살펴보고 마무리하겠다.

 

Optional의 메서드

먼저 객체를 Optional로 감싸는 메서드를 살펴보자. 이들은 static 메서드라는 특징이 있다.

  • of(T):Optional<T>
    -> 파라미터로 받은 객체를 Optional로 감싸 반환한다. 만약 파라미터가 null이면 NPE가 발생한다.

  • ofNullable(T):Optional<T>
    -> 기본적으로 of()와 동일하나 파라미터가 null이면 빈 Optional을 반환한다.

  • empty():Optional<T>
    -> 빈 Optional을 반환한다. Optional 객체의 중간 연산 중에 값이 null이 되면 내부적으로 이 메서드를 호출한다. 

 

다음으로는 Optional의 중간 연산이다. 중간 연산은 non static 메서드면서 동시에 반환 값이 Optional<T>라는 특징이 있다.

  • filter(Predicate<? super T> predicate):Optional<T>
    -> Stream API의 filter()와 동일하다. predicate의 조건에 맞는 값을 필터링한다.

  • map(Function<? super T, ? extends U> mapper):Optional<T>
    -> Stream API의 map()과 동일하다. Optional로 감싸진 객체를 다른 객체로 변경하도록 데이터 변경을 한다.

  • flatMap(Function<? super T, Optional<U>> mapper):Optional<T>
    -> Optional안의 Optional이 있는 이중 구조일 때, 단일 구조로 변경하여 map()의 기능을 수행할 수 있다.

 

이번에는 Optional의 종료 연산이다. 이들은 Optional에서 벗어나 값으로 반환되는 특징이 있다.

  • get():T
    -> Optional의 값을 반환한다. 만약 값이 빈 값이라면 NPE가 발생한다.

  • orElse(T):T
    -> get()과 동일한 기능을 수행하지만, 값이 비어있다면 파라미터로 받은 값으로 반환한다.

  • orElseGet(Supplier<? extends T> other):T
    -> get과 동일한 기능을 수행하지만, 값이 비어있다면 파라미터에서 제공하는 값을 반환한다.

  • orElseThrow(Supplier<? extends X> exceptionSupplier):T
    -> get과 동일한 기능을 수행하지만, 값이 비어있다면 파리미터에서 생성한 exception을 발생시킨다. 

 

마지막으로 분류가 되지 않는 기타 메서드들이다.

  • isPresent():boolean
    -> Optional의 값이 있다면 true, 비어있다면 false를 반환한다. 상태를 확인할 뿐 값에 어떤 영향도 미치지 않는다.

  • ifPresent(Consumer<? super T> consumer):void
    -> Optional의 값이 있다면 파라미터를 실행하고, 비어있다면 false를 반환한다. 

 

실무 Tip

  • Optional을 파라미터 타입을 감싸는 데 사용하지 않는다. null인지 아닌지 불명확한 파라미터를 받을 필요가 없기 때문이다.
    객체로 받아 메서드 내부에서 Optional로 감싸서 처리하는 편이 메서드를 개발하는 입장이나, 메서드를 사용하는 입장이나 편하다.
  • Optional의 값이 비어있는지 확인하는 isPresent() 메서드가 있지만 이를 if문 안에서 확인하지 말자. 전통적인 null 처리 방법의 문제를 되풀이하는 꼴이 된다.
  • Optional의 메서드는 크게 Exception을 발생시키는 메서드와 그렇지 않은 메서드로 양분할 수 있다.
    그렇기 때문에 'Optional을 쓰면 Exception이 발생하지 않는다'는 말은 틀린 말이다. 상황에 맞게 구분하여 사용하자.

 

더 알아보기

- Optional 등장 배경

- Optional docs


6. 새롭게 추가된 날짜 API

Java 8 이전의 날짜 API, 즉 Date나 Calendar는 대체로 문제가 많았다. 그래서 Java 8에는 새로운 날짜 API가 추가되어 간편하게 날짜를 계산할 수 있게 되었다.

 

크게 기계용 날짜 API와 사람용 날짜 API로 나눌 수 있다. 먼저 기계용으로 Instant 클래스가 있고, 사람용으로 LocalDate, LocalTime, LocalDateTime이 있다.

날짜와 시간의 간격을 나타내는 Duration과 Period 클래스도 있다. 마지막으로 날짜 포맷을 표현하기 위해 새롭게 등장한 DateTimeFormatter 클래스도 있다. 

 

이렇게 4개의 Section으로 구분하여 간략하게 설명하고 날짜 API 설명을 마무리하겠다. 참고로 이번에 추가된 날짜 API는 모두 불변 객체이다.

 

기계용 날짜 API :: Instant

Instant 클래스는 Unix epoch time(유닉스 에포크 시간)을 기준으로 특정 시점까지의 시간을 초로 표현한다. 현재 시간을 유닉스 에포크 시간을 기준으로 초(Second)로 표현한다면 다음과 같다. 참고로 유닉스 에포크 시간은 흔히 '타임스탬프'로 표현되는 시간이다.

 

// 현재 시간을 유닉스 에포크 시간으로 나타내는 코드 (모든 시간을 초로 환산하여 표시)
System.out.println(Instant.now().getEpochSecond());

// 유닉스 에포크 시간의 기준시를 나타내는 코드(기준시기 때문에 초로 환산해도 0으로 출력된다)
System.out.println(Instant.ofEpochSecond(0).getEpochSecond());

모든 시간은 초를 기준으로 계산되기 때문에 사람에게 익숙한 연, 월, 달 혹은 시와 분의 개념이 없다는 점을 참고하고 사용해야 한다.

 

사람용 날짜 API :: LocalDate, LocalTime, LocalDateTime

기존의 Date 클래스는 이름과 다르게 날짜, 요일은 물론 시간까지 포괄하는 정보를 갖는다. 이름이랑 맞지 않게 너무 많은 역할을 하게 되는 문제가 있다. 따라서 Java 8에서 날짜, 시간 별로 클래스를 분리시켰다. 이들은 기본적으로 다루는 정보가 다른 것 외에는 모두 동일한 사용법을 가지고 있어서 시간과 날짜를 모두 포함하는 LocalDateTime을 샘플로 코드를 작성했다.

 

날짜&시간을 String으로 표현하는 코드다.

// 현재 날짜와 시간을 String 값으로 표현
System.out.println(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));

// 2021-01-01T12:00:00 을 String 값으로 표현
System.out.println(LocalDateTime.of(2021, 1, 1, 12, 0).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));

 

실무에는 꼭 날짜를 계산하는 로직이 들어가 있다. 간단히 오늘 날짜에 10일을 더한 날짜를 계산하는 코드를 Date 객체와 비교하여 보자.

// LocalDateTime 객체를 사용한 코드
LocalDateTime plusTenDay = LocalDateTime.now().plusDays(10L);

// Date 객체를 사용한 코드
Date today = new Date();
today.setDate(today.getDate() + 10);

 

 

어떤 코드가 비즈니스 로직을 직관적으로 표현하는지 느껴보기 바란다. Date 객체는 1월이 0으로 표기되기 때문에 만약 일(Day)이 아니라 월(Month) 을 계산해야하는 상황이면 더 머리가 아플 것이다. 

 

 

날짜와 시간의 간격을 나타내는 API :: Duration, Periord

이번 API도 날짜와 시간을 분리했다. 먼저 Duration 클래스는 시간의 차이를 나타낸다. 반면 Periord 클래스는 날짜의 차이를 계산한다. 따라서 상황에 맞게 사용하는 것이 중요하다. 오늘과 2020년 12월 31일 12시 30분을 기준으로 Duration과 Periord 클래스를 사용해 날짜 차이를 구하는 예제 코드를 작성해봤다.

 

LocalDateTime now = LocalDateTime.now();
LocalDateTime newYear = LocalDateTime.of(2020, 12, 31, 12, 30);

// 시간의 차이를 비교
Duration duration = Duration.between(now, newYear);
System.out.println(duration.getSeconds());

// 날짜의 차이를 비교
Period period = Period.between(now.toLocalDate(), newYear.toLocalDate());
System.out.println(period.getDays());

 

특별한 점이 있다면 있다면 Period는 날짜가 최소 단위가 된다. 그래서 시간 정보가 없는 LocalDate 객체만 파라미터로 사용한다. 반면 Duration은 LocalDate, LocalTime, LocalDateTime 어느 것을 사용해도 상관없다.

 

 

날짜 포맷을 위한 새로운 클래스 :: DateTimeFormatter

지금까지 날짜의 포맷은 대부분은 개발자가 하드 코딩하거나 상수로 생성해서 사용해왔다. 그래서 중복되는 코드를 발생하거나 여러 관리 포인트가 생기게 되어 코드를 복잡하게 하는 주된 원인 중 하나로 꼽혔다. 그래서 DateTimeFormatter 클래스가 등장하게 되었다. 대부분의 날짜 포맷이 상수로 등록되어 있고, API Document를 참고하여 원하는 포맷으로 변환도 가능하다.

 

현재 날짜와 시간을 "오늘은 2020-12-31일 (목)이고, 현재시간은 오전 12:30분입니다." 포맷으로 변경하는 코드는 다음과 같다.

 

System.out.println(
    LocalDateTime
        .now()
        .format(DateTimeFormatter
                .ofPattern("오늘은 y-mm-dd일 (E)이고, 현재시간은 a h:s분입니다.")
                .withLocale(Locale.KOREA))
);

 

복잡한 형태의 포맷이지만 새로운 날짜 API를 조합하면 간단하게 표현할 수 있다. 

 

 

이렇게 Java 8에 새롭게 등장한 날짜 API를 살펴봤다. 지금까지 복잡한 날짜계산 때문에 별도의 유틸리티 클래스를 만들어 사용해왔다면 이들을 한 번 써보길 권한다. 몰라서 못 쓸 수 있지만 알고 나면 안 쓰기 힘든 API라고 생각이 든다.

 

 

 

더 알아보기

- Date&Calendar 클래스의 문제

- DateTimeFormatter Document


7. CompletableFuture(컴플리터블 퓨처)

Java의 비동기 프로그래밍은 자주 사용하지 않아 불편함조차 인지하지 못하는 경우가 많다. 대표적으로 생성한 스레드가 동작중인지, 아니면 정상적으로 종료되었는지 혹은 예외가 발생해 비정상 종료되었는지 메인 스레드에서는 알기 어렵다. 간단한 요구사항을 통해 어떤 점이 불편한지 하나씩 살펴보자.

 

<요구사항>

"제휴를 맺은 서점인 엔터파크와 일리단에서 책 가격을 조회하여 가장 저렴한 가격과 서점명을 출력해주세요. 단, 두 서점의 API가 느려 각각  평균 1 ~ 2초 정도의 응답 시간이 걸립니다. 최대한 빠르게 응답받아 처리할 수 있도록 개발해주세요."

 

Thread를 사용한 조회

동기 프로그래밍으로 구현하면 최소 2초에서 최대 4초 정도의 시간이 걸린다. 비동기 프로그래밍으로 최대한 빠르게 변경해보자.

일단 책 가격을 조회하는 코드를 작성해보자. getBookPrice() 메서드 내에 Thread.sleep()코드를 두어 1~2초 정도 임의로 늦췄다.

// 엔터파크에서 책 가격 조회
new Thread(() -> {
    int bookPrice = Enterpark.getBookPrice();
    System.out.println("Enterpark : " + bookPrice);
}).start();

// 일리단에서 책 가격 조회
new Thread(() -> {
    int bookPrice = Illidan.getBookPrice();
    System.out.println("Illidan : " + bookPrice);
}).start();

 

그럭저럭 조회는 구현한 것 같은데 반환된 값을 어떻게 사용해야할지 모르겠다. 더구나 두 스레드가 모두 끝난 지 메인에서 확인하는 방법도 마땅하지 않다. 만약 getBookPrice()에서 예외가 발생한다면? 이러나저러나 쉽지 않다. Java 5에서는 이렇게 미래 시점에 결과를 사용할 수 있도록 Future 클래스를 제공한다.

 

Future를 사용한 조회

비동기 프로그래밍을 하기에 Thread 클래스는 저수준이다. 그래서 개발자가 해줘야 할 것이 많다. 반면 Future는 상대적으로 직관적이며, Thread pool도 직접 정할 수 있고 Timeout도 설정해 무작정 기다리게 되는 문제도 해결할 수 있다. 앞선 요구사항을 Future로 구현해보자.

 

ExecutorService executorService = Executors.newFixedThreadPool(5);

Future<Integer> enterparkFuture = executorService.submit(() -> {
    int bookPrice = Enterpark.getBookPrice();
    System.out.println("Enterpark : " + bookPrice);
    return bookPrice;
});

Future<Integer> illidanFuture = executorService.submit(() -> {
    int bookPrice = Illidan.getBookPrice();
    System.out.println("Illidan : " + bookPrice);
    return bookPrice;
});

// 스레드 결괏값 반환을 요청. 3초 안에 반응이 없으면 스레드를 블록시킨다.
Integer enterparkPrice = enterparkFuture.get(3, TimeUnit.SECONDS);
Integer illidanPrice = illidanFuture.get(3, TimeUnit.SECONDS);

// 가장 낮은 가격과 서점명 출력
printPriceAndBookstore(enterparkPrice, illidanPrice);

 

이제는 다른 스레드의 결괏값을 메인 스레드에서 사용할 수 있게 되었다. 더구나 무한정 결과를 기다리지도 않고, Future.get()을 try - catch로 감싸 스레드 동작 중 발생한 예외를 캐치할 수 있다.

 

ExecutorService executorService = Executors.newFixedThreadPool(5);

Future<Integer> enterparkFuture = executorService.submit(() -> {
    int bookPrice = Enterpark.getBookPrice();
    System.out.println("Enterpark : " + bookPrice);
    if (true) throw new TimeoutException("fail"); // 의도적으로 예외 발생
    return bookPrice;
});

// try - catch문으로 다른 스레드의 예외를 캐치할 수 있다.
Integer enterparkPrice = null;
try {
    enterparkPrice = enterparkFuture.get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    System.out.println(e.getMessage());
}

 

분명 Thread 클래스보다 편리하고 개선되었지만 아직 사용하기에 불편하다. 가령 예외처리를 위한 API가 없어 사용자가 임의로 try - catch로 감싸야한다거나, Future의 동작을 외부에서 강제로 완료시킬 수 없다. 거기에 get()이 블로킹 코드이기 때문에 결괏값을 반환받기 전까지 대기상태가 된다. 다른 스레드의 결괏값이 필요할 때는 이렇게 기다리는 것이 장점이 되지만 결괏값이 불필요할 때는 비동기로 처리할 수 있어야 한다. CompletableFuture 클래스는 아직까지 비동기 프로그래밍이 갖고 있는 문제점을 개선하기 위해 등장했다.

 

CompletableFuture를 사용한 조회

Java 8에서 새롭게 등장한 Future 인터페이스의 구현체다. 먼저 예외처리를 지원하는 메서드와 순서의 의존관계를 맞는 스레드 프로그래밍, 콜백을 지원하기도 하며 여러 스레드를 하나로 묶어 처리하기에도 용이하다. 먼저 요구사항을 CompletableFutrure를 사용하여 개발하면 다음과 같다.

 

CompletableFuture<Integer> lowPriceSearchFuture = CompletableFuture.supplyAsync(() -> {
    int bookPrice = Enterpark.getBookPrice();
    System.out.println("Enterpark : " + bookPrice);
    return bookPrice;
})
.thenCombine(CompletableFuture.supplyAsync(() -> {
    int bookPrice = Illidan.getBookPrice();
    System.out.println("Illidan : " + bookPrice);
    return bookPrice;
}), (enterpartBookPrice, illidanBookPrice) -> printPriceAndBookstore(enterpartBookPrice, illidanBookPrice))
.exceptionally(throwable -> {
    System.out.println(throwable.getMessage());
    return -1;
});

lowPriceSearchFuture.get(3, TimeUnit.SECONDS);

 

thenCombin()을 통해 서로 다른 스레드를 엮어 비동기로 진행하고 두 스레드가 정상적으로 처리되면 printPriceAndBookstore()를 호출하도록 했다. 또한 exceptionally()을 통해 예외상황도 처리했다. 만약 서점들 API 호출에 순서가 필요하다면 thenCombine() 대신 thenCompose()를 사용할 수 있다.

또 모든 스레드의 처리가 정상 종료되면 콜백으로 Database에 log를 기록해야 한다면 아래와 같은 메서드를 추가하여 간단하게 구현할 수 있다.

 

.thenApply(integer -> commonService.createLog(integer)) // 메서드 체이닝에 추가

 

간단하게 CompletableFutrure 클래스를 살펴보았다. 맨 처음 저수준의 Thread 클래스를 사용한 코드보다 한결 쉬운 스레드 프로그래밍이 가능해졌다. 사실 실무에서 별도의 스레드를 생성하는 경우가 많지는 않겠지만 언젠가 꼭 마주하게 되는 상황이 올 테니 이번 기회에 다양한 방법으로 학습해보길 바란다.

 

더 알아보기

- CompletableFuture 사용 방법

- CompletableFuture Document


8. JVM의 변화

JVM에도 변화가 있었다. 기존의 PermGen 영역이 사라지고 대신 Metaspace 영역이 새롭게 생겨났다. 실무적인 관점에서 보면 Java 8부터는 메모리 옵션 중 -XX:PermSize나 -XX:MaxPermSize가 사라지고 -XX:MetaspaceSize, -XX:MaxMetaspaceSize 옵션이 새롭게 등장한 것이다. 따라서 Java 8을 사용하면서 PermGen의 메모리 옵션을 줘도 아무런 영향을 주지 못한다. 그럼 Metaspace로의 변경을 어떤 의미를 갖는지 간단히 확인해보자.

 

Heap 대신 Native로

PermGen영역은 클래스의 메타정보를 관리하는 메모리 공간이다. 클래스의 이름, 애노테이션 정보뿐만 아니라 static 필드 등 클래스를 구성하는 정보 따위 담는다. Java 8 이전에는 PermGen 영역이 Heap 메모리에 포함되어 있어 크기가 제한되었다. 그래서 클래스가 많은 애플리케이션을 구동하다 보면 PermGen 영역의 OOM이 발생하는 경우가 종종 발생한다.

 

Java 8부터는 이 공간이 삭제되고 동일한 기능을 하는 Metaspace 영역이 새롭게 생겼다.(이름부터 무엇을 저장하고 싶은지 명확해졌다) 이곳은 Native 영역에 속하며 OS가 크기를 자동으로 조정한다. 애플리케이션이 동작하면서 동적으로 클래스를 많이 생성한다 해도 Metaspace 영역이 가변적으로 늘어나 충분히 수용할 수 있게 된다. PermGen 영역을 사용할 때에 비해 OOM이 발생할 여지가 크게 줄어든 셈이다.

 

 

이런 변경점은 대게 몰라도 아무런 문제가 없다. 다만 큰 애플리케이션을 개발하거나 성능이 부족한 서버 하나에 여러 애플리케이션을 구동하는 경우라면 반드시 이런 이해가 필요할 것이다. 가장 좋은 것은 jstat과 같은 모니터링 도구로 적절한 설정값을 눈으로 확인하여 이를 기준값으로 설정하는 것이다.

 

실무 Tip

  • Metaspace의 최댓값은 반드시 설정해주자. 가변적으로 늘어나기 때문에 극단적인 상황에는 서버의 모든 메모리를 Metaspace가 가져가 애플리케이션이 아닌 서버가 죽게 될 수도 있다. 
  • -XX:MaxMetaspaceSize=N으로 최댓값을 설정할 수 있고, 최댓값까지 늘어나는 과정이 불필요하다면 -XX:MetaspaceSize를 통해 기본값 = 최댓값으로 설정하면 된다.

마치며

본 포스팅은 Java 8의 변경사항을 중심으로 하나씩 살펴보았다. 그 중 실무에 도움이 될 예제코드도 있었지만 부족한 부분도 있었다 생각된다. 하지만 본 포스팅의 목적은 모든 정보를 전달하는 것이 아니다.  무엇을 알게 되어 새롭게 공부할만한 내용이 무엇인지 알려주는 것이 진정한 목적이다. 어떤 기능이 추가되었는지는 실무에 쓰다보면 금방 익힌다. 문제는 왜 이게 등장했고 어떤 기술을 근간으로 사용하는지 알아가는 것이다. 

 

이 글을 읽은 분들이 새로움에 흥미를 갖고 도전해보았으면 싶은 마음으로 마무리하겠다.

 

 

긴 글 읽어주셔서 감사합니다😊 


참고자료 & 강의

자바 8 인 액션
국내도서
저자 : 라울-게이브리얼 우르마(RAOUL-GABRIEL URMA),마리오 푸스코(MARIO FUSCO),앨런 마이크로프트(ALAN MYCROFT) / 우정은역
출판 : 한빛미디어 2015.04.01
상세보기

 

www.inflearn.com/course/the-java-java8