Java 8에서는 코드 작성하는 부분에서도, 자바의 기술 요소에서도 변경되는 부분이 많은 것 같다. 새로운 개념도 많이 들어가있고, Java 8에 대해서도 특징을 알아가고자 포스트로 관련 내역들을 정리해보기로 했다.

요약 정리

우선 Java 8에서 눈에띄게 변경되는 부분은 아래와 같다.

  • Lambda(람다) 표현식
  • Functional(함수형) 인터페이스
  • Stream(스트림)
  • Optional(옵셔널)
  • 인터페이스의 기본 메소드(Default method) 지원
  • 날짜 관련 클래스 추가
  • 병렬 배열 정렬
  • StringJoiner 추가

이에 대해 순차적으로 알아가보기로 한다.

람다 표현식

간단히 말하자면, 메소드를 하나의 식으로 표현한 것이다. 필요없는 코드를 모드 지워버리고 모든걸 컴파일러의 추론에 맞겨 보다 간결하게 코드를 작성하도록 하는 방식이다.

기존의 코드 작성 방식을 살펴보자 :

public interface Calculate {
    int operation(int a,int b);
}
private void calculateClassic(){
    Calculate calculateAdd = new Calculate() {
        @Override
         public int operation(int a, int b) {
            return a+b;
        }
    };
}

위와 같이 하나의 메소드를 가진 인터페이스를 구현하는 클래스 인스턴스를 만들 때, 코드를 줄이기 위해서 익명클래스로 만들어도 @Override등 다수의 코드가 작성됨으로써 가독성이 떨어지는 것을 볼 수 있다. 이렇게 긴~~코드를 람다표현식을 이용하면 아래와 같이 작성 할 수 있다.

 private void calculateLambda(){
        Calculate calculateAdd = (a,b) -> a+b;
    }

이렇게 짧은 코드로만 보더라도, 람다는 아래의 장점을 가지는 것을 볼 수 있다.

  • 코드의 간결성, 가독성 향상
  • 함수형 프로그래밍을 통한 간결한 표현 가능
  • Collection, Iterable 객체의 접근 및 사용성 증대
  • Stream API와 함꼐 사용함으로써 시너지 효과

자바에서는 아래와 같은 방식으로 람다를 사용 할 수 있다.

  • 하나의 메소드만 존재하는 인터페이스를 정의한다(@FunctionalInterface 선언을 통해, 해당 인터페이스는 단일 메소드만 정의하도록 강제 할 수 있다).
  • (매개 변수 목록) -> 처리식으로 람다를 표현하며, 처리식이 한 줄 이상일 때는 처리식을 중괄호로 묶을 수 있다.

함수형 인터페이스

앞서 람다 표현식 작성시 단일 메서드인 인터페이스만 람다를 적용 할 수 있다고 하였다. 이렇게 람다 표현식을 사용할 수 있는 인터페이스를 정의하는 명칭이 새로 생겼다. 바로 함수형 인터페이스이다. 이 함수형 메소드는 아래와 같이 @FunctionalInterface어노테이션을 추가하여 메소드를 하나만 선언 할 수 있도록 강제 할 수 있다.

@FunctionalInterface
public interface Calculate {
    int operation(int a,int b);
}

이 어노테이션은 왜 생긴걸까?라고 고민을 해봤는데, 아래와 같은 일이 발생할 수 있어 생긴 것 같다.
기존에 인터페이스에 하나의 메소드만 정의하여 개발자 A는 람다식으로 코드를 작성해왔다. 그러던 와중, 개발자 B가 이 인터페이스에 메소드를 하나 추가해보았다고 생각해보자. 그렇게 된다면 개발자 A가 작성한 코드는 모두 오류가 날 것이며 이를 위한 공수는 또 어마어마하게 들 수 있을 것이다. 그래서 @FunctinalInterface라는 어노테이션을 이용하여 사전에 이런 불상사가 발생하지 않도록 하는 것이 아닐까 하는 생각이 든다.

Java 8에서 제공하는 주요 Functinal 인터페이스는 아래와 같다.

  • Runnable : 스레드를 생성할 때 사용되는 인터페이스이다. 인자와 반환 객체가 없다.
Runnable runnable = () -> System.out.println("Thread running!");
runnable.run();
  • Predicate : 하나의 인자와 리턴타입을 가진다. 보통 두 개의 객체를 비교할 떄 사용하고, boolean을 리턴한다. test()메소드 외에도, and(),or(),negate(),isEqual()등 static 메소드등을 통해 비교 할 수 있다.
Predicate<String> pre = (str) -> str.equals("hi");
pre.test("hihi"); //false
  • Supplier :인자는 받지 않으며, 리턴타입만 존재하는 메소드이다. 다른 인터페이스들과 달리 추가적인 메소드는 선언되어 있지 않다. 함수의 결과 값을 바꾸는건 input인데, 이 input이 없다는건 결국 호출시 항상 같은 값을 리턴하기 위해 쓰인다는 것을 의미하는 것 같다.
Supplier<String> s = () -> "hi supplier Interface";
s.get();
  • Consumer : 리턴을 하지 않고(void), 인자를 받는 메소드이다. 그래서, 출력을 할 때 처럼 작업을 수행하고 결과를 받을 일이 없을 때 사용한다. accept()메소드를 사용한다.
Consumer<String> c = (str) -> System.out.println(str);
c.accept("hi");
  • Function : 인터페이스 명칭에서 보면 알 수 있듯이, 전형적인 함수를 지원하는 인터페이스이다. T타입의 인자를 받고, R타입의 객체를 반환한다.apply()메소드를 사용하고, 보통 타입 변환이 필요 할 때 이 인터페이스를 사용한다.
Function<String, Integer> changeToString = (s) -> Integer.parseInt(s);
changeToString.apply("3");
  • UnaryOperator: An unary operator from T -> T : 하나의 매개변수를 가진다. apply()메소드를 이용하며, 한 가지 타입에 대해 결과도 같은 타입일 경우 사용한다.
UnaryOperator<String> unaryOperator = (str) -> str + " new array";
unaryOperator.apply("hi");
  • BinaryOperator : A binary operator from(T, T) -> T : apply()메소드를 이용하며, 동일한 타입의 인자 2개와 같은 타입의 인자를 리턴한다.
BinaryOperator<String> binaryOperator = (str1, str2) -> str1 + str2;
binaryOperator.apply("hi ","my friend!");

스트림

자바의 스트림은 연속된 정보를 처리하는데 사용한다. 데이터 소스를 변경하지 않고, 일회용으로 작업을 내부 반복 처리할 때 주로 사용된다.
연속된 정보(배열, 컬렉션)에서 스트림을 이용해 처리 할 수 있다고 하였지만, 아쉽게도 배열에는 스트림을 사용 할 수 없다. 그렇기에 배열을 컬렉션의 List로 변환해 사용하도록 하자.

스트림은 크게 아래와 같은 구조를 가진다.

  • 스트림 생성 : 컬렉션의 목록을 스트림 객체로 변환한다. 여기서.스트림 객체는 java.util.stream패키지의 Stream인터페이스를 말하고, stream()Collection인터페이스에 선언되어 있다.
  • 중개 연산 : 생성된 스트림 객체를 사용해 중개 연산 부분에서 처리한다. 하지만, 이 부분에서는 아무런 결과를 리턴하지 못한다.
    • 핵심 메소드 : filter(),map(), sorted()
  • 종단 연산 : 마지막으로 중개 연산에서 작업된 내용을 바탕으로 결과를 리턴해준다.
    • 핵심 메소드 : reduce(), forEach(), collect(), min(), max(), count()

병렬 스트림

위에서 사용한 stream()은 순차적으로 데이터를 처리한다. 즉, 10개의 데이터가 있다면, 0~9번째 인덱스를 하나씩 처음부터 처리한다. 만약 이보다 더 빠르게 처리를 원한다면 parallelStream()을 사용하면 되지만, 병렬로 처리하기에 CPU도 많이 사용하고 몇개의 쓰레드로 처리할지 보장되지 않는점을 참고하면 좋을 것 같다.

옵셔널

보통 개발을 할 때 제일 많이 발생하는 에러가 NPE(NullPointerErrorException) 이라고 한다. NPE를 방지하기 위해서는 각 객체별로 null validation을 체크하는 로직을 추가해야 하는데, 이 부분이 굉장히 번거롭다. 이러한 번거로움을 방지하기 위해 나온 것이 Optional이다. Optional은 존재 할 수도 있지만 안 할 수도 있는 객체, 즉, null이 될 수 있는 객체를 감싸고 있는 일종의 래퍼클래스이다. Null 또는 Value로 갖는 wrapper로 감싸, NPE로부터 자유로워지기 위해 나온 클래스이다.

옵셔널을 사용하게 되면,

  • NPE를 유발 할 수 있는 null을 다루지 않아도 된다.
  • 수고스럽게 null 체크를 하지 않아도 된다.
  • 명시적으로 해당 클래스가 null이라는 가능성을 표현 할 수 있다.

Optional 객체 생성

  • Optinal.empty() : null을 담고있는, 즉 비어있는 Optional 객체를 생성한다. Optional 내부적으로 미리 생성해놓은 싱글톤 인스턴스를 반환한다.
    Optional<Member> maybeMember = Optional.empty();
    
  • Optional.of(); null이 아닌 객체를 담고 있는 Optional 객체를 생성한다. null이 넘어오면 NPE를 던지기 때문에 주의하여 사용해야 한다.
Optional<Membber> maybeMember = Optional.of(aMamber);
  • Optional.ofNullable() : null인지 아닌지 확실치 않은 객체를 담고 있는 Optional 객체를 생성한다. null이 올 경우, Optional.empty()와 동일하게 비어있는 Optional 객체를 가져온다.
Optional<Membber> maybeMember = Optional.ofNullable(aMamber);

Optional 객체 접근

Optional 클래스는 담고 있는 객체를 꺼내오기 위해 다양한 인스턴스 메소드를 제공한다. 아래 메소드는 담고 있는 객체가 존재할 경우 동일하게 반환하지만, 비어있는 경우 처리방식이 다르다. 그래서 비어있는 부분에 대해서만 정리해보고자 한다.

  • get() : 비어있는 Optional 객체의 경우, NoSuchElementException을 던진다.
  • orElse(T other) : 비어있는 Optional 객체의 경우, 넘어온 인자를 반환한다.
  • orElseGet(Supplier<? extends T> other) : 비어있는 Optional 객체의 경우, 넘어온 함수형 인자를 통해 생성된 객체를 반환한다. 비어있는 경우에만 호출되 orElse(T other)보다 성능상 이점이 있다.
  • orElseThrow(Supplier<? extends X> exceptionSupplier : 비어있는 Optional 객체의 경우, 넘어온 함수형 인자를 통해 예외를 던진다.

정리하자면, Optional은 번거로웠던 null체크를 개발자가 직접 하지 않고 Optional 클래스에게 위임하기 위해 사용된다. Optional을 사용하고도 계속 null체크를 하면 의미가 없기에 보다 함수형으로 간결하게 작성하도록 노력이 필요하다 .

Default Method of Interface

이전에 인터페이스에서는 메소드를 선언 할 수 있다고만 하였다. 하지만 Java 8 부터는, 추상메소드와 비슷하게 특정 메소드에 기능을 구현 할 수 있게 되었다.

public interface DefaultStaticInterface {
	static final String name="hihi";
	String getName();
	int getSince();
	default String getEMail(){
		return name + "@gmail.com";
	}
}

위 코드와 같이 default를 사용한 메소드는 구현부가 작성되어 있는 것을 확인 할 수 있다. 하지만 왜 추상클래스도 있는데 인터페이스까지 default method라는 기능을 추가한 것일까? 자바의 신에서는 아래와 같이 설명해주었다.

바로 “하위 호환성” 때문이다. 예를 들어 설명하자면, 여러분들이 만약 오픈 소스 코드를 만들었다고 가정하자. 그 오픈 소스가 엄처 유명해져 전 세계 사람들이 다 사용하고 있는데, 인터페이스에 새로운 메소드를 만들어야 하는 상황이 발생했다. 자칫 잘못하면 내가 만든 오픈소스를 사용한 사람들은 전부 오류가 발생하고 수정을 해야 하는 일이 발생할 수도 있다. 이럴 때 사용하는 것이 바로 default 메소드이다.
즉, 인터페이스의 기능 추가로 인해 필수적으로 구현해야 하는 메소드가 있다면, 이 메소드를 모든 사용자에게 공통적으로 기능을 구현하도록 강제해야 한다. 여기서 default 메소드를 추가하는 방식으로 호환성을 맞추고 인터페이스를 보완 할 수 있다.

날짜 관련 클래스

Java 8에서는 Thread-safe하고 mutable한 날씨 관련 객체를 제공한다. 간단하게 아래 패키지를 예시로 살펴 볼 수 있다.

  • java.time.ZonedDateTime, java.time.LocalDate : 불변객체, 쓰레드 안전한 날짜 관련 패키지
  • java.time.format.DateTimeFormatter : 쓰레드 안전하며 빠르다.
  • java.time.ZondId, java.time.ZoneOffset :각각 타임존과, 시간대 정보를 가지고 있다.

병렬 배열 정렬

Java 8에서는 Fork-Join프레임웍을 사용한 parallelSort()라는 정렬 메소드를 제공한다. 멀티스레드로 수행되기에 정렬 횟수가 많을 때 사용되면 좋다.

int[] intValues = new int[100];
Arrays.parallelSort(intValues);

StringJoiner

순차적으로 나열되는 문자열을 처리하기 위한 클래스이다. 특정 문자열들을 규칙을 갖고 합치고 싶을 때 사용된다.

public void joinString(String[] stringArray){
	StringJoiner joiner = new StringJoiner(",","(",")");
	for(String str : stringArray){
		joiner.add(str);
	}
	System.out.println(joiner);
}

참고 자료 :

  1. 자바의 신 제 2권 32장~33장
  2. [Spring] Java에서 람다 표현식 가볍게 접근하기
  3. Java8#02. 함수형 인터페이스(Functional Interface)
  4. 자바8 Optional 2부 : null을 대하는 방법

oksusutea's blog

꾸준히 기록하려고 만든 블로그