Java8 부터 사용가능한 문법과 기능

rvlwldev·2023년 4월 8일
0

Java

목록 보기
5/8
post-thumbnail

함수형 프로그래밍이 유행하면서 그에 맞춰 함수형 프로그래밍을 지원하기 위해 2014년 자바8에선 이전과 다르게 많은 기능들이 변경되거나 개선되었다.
이로 인해 가독성과 생산성이 비약적으로 상승하였으며 많은 호평을 받았었다.

함수형 프로그래밍(Functional Programming)이란?

하나의 프로그래밍 패러다임으로 정의되는 일련의 코딩 접근 방식이며,
자료처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임을 의미한다.

데이터나 자료구조의 상태를 변경하는 대신, 함수의 조합을 통해 원하는 결과를 만들어내는 방식으로 동작한다.

특징으로는 순수 함수(Pure Function), 불변성(Immutability), 고차 함수(Higher-Order Function) 등의 개념으로 안정성, 확장성, 테스트 용이성을 높인다.

참고 : https://jongminfire.dev/함수형-프로그래밍이란

추가/변경된 내용 몇가지를 정리해본다.

람다식 (Lambda expressions)

가끔 코드를 간단하게, 깔끔하게 작성하기 위해 람다식을 활용했었는데 자바8부터 지원되는 기능이다.
가장 쉽게 쓰이는 경우는 아마도 어떤 배열/컬렉션 요소들을 정렬할 때 많이 쓰일것이다.

예시 1)

// 람다식을 쓰지 않고 내림차순 정렬
String[] arr = new String[]{"1", "3", "2", "4", "6", "5", "7"};

// 익명 클래스를 사용하여 Comparator의 인터페이스에서 compare메소드를 구현
Arrays.sort(arr, new Comparator<String>() {
	@Override
    public int compare(String a, String b) {
    	return b.compareTo(a);
	}
});

System.out.println(Arrays.toString(arr)); // [7, 6, 5, 4, 3, 2, 1]

예시 2)

// 람다식으로 내림차순 정렬
String[] arr = new String[]{"1", "3", "2", "4", "6", "5", "7"};

// 람다식 사용하여 Comparator의 인터페이스에서 compare메소드를 구현
Arrays.sort(arr, (a, b) -> b.compareTo(a));

System.out.println(Arrays.toString(arr)); // [7, 6, 5, 4, 3, 2, 1]

예시 2)예시 1) 보다 람다식으로 더 간단하게 작성되었다!

예시2) 에서 람다식은 어떻게 동작할까?

위에서 사용된 Arrays.sort 메소드는 다음과 같은 시그니처를 가진다.
public static <T> void sort(T[] a, Comparator<? super T> c) {...}
2번째 인자로 받는 Comparator는 인터페이스이며 int compare(T o1, T o2); 를 추상메서드로 명시하고 있다.

람다식을 사용했을때 Comparator 인터페이스에서 (String a, String b)를 인자로 받을 수 메서드 시그니처가 int compare(T o1, T o2) 메서드가 유일하다.

다시말해 람다식을 사용할 때 Comparator 인터페이스에서 compare메서드를 구현한다는 것은, 람다식에서 사용되는 문맥(context)을 통해 추론(inference)할 수 있는 것이다.

때문에 같은 메서드 시그니처를 가지는 다른 추상메소드가 있다면,
람다식은 사용할 수 없다.

Stream API

Stream 객체를 활용할 수 있는 것도 자바8 부터인데, 위 람다식과 마찬가지로 함수형 프로그래밍을 지원하기 위해 등장했다. 이 스트림은 파일관련 객체에 사용되는 I/O Stream과는 다른 개념으로 사용된다.

또한 함수형 프로그래밍을 지원하기 위해 등장한 만큼 관련된 여러 특징을 가지고 있다.

원본 데이터를 변경하지 않는다.

함수형 프로그래밍에서 중요한 개념 중 하나인 불변성(Immutability)을 구현하기 위해 원본의 데이터를 변경하지 않는다.

List<Integer> list1 = new ArrayList<>(List.of(1, 3, 2, 4, 6, 5, 7));
List<Integer> list2 = list1.stream().sorted().collect(Collectors.toList());

System.out.println(list1);
System.out.println(list2);
// 실행결과)

[1, 3, 2, 4, 6, 5, 7]
[1, 2, 3, 4, 5, 6, 7]

위 예시에서는 list1Stream으로 정렬시키더라도 list1은 원래의 순서를 보장하고 있다.

기존의 정렬방식으로 컬렉션 객체로 정렬을 하게되면 (Collections.sort(list1); 로 정렬하게 되면)
원본 데이터인 list1이 정렬되며 원본 데이터의 원래 순서를 잃어버리게 된다.

휘발성 객체이다.

위 예시에서 list1.stream().sorted().collect(Collectors.toList()); 부분은 list1의 데이터를 모두 읽고나면 사라진다.

다시 예시로 확인해본다면

List<Integer> list1 = new ArrayList<>(List.of(1, 3, 2, 4, 6, 5, 7));

Stream<Integer> stream1 = list1.stream();
stream1.forEach(num -> System.out.println(num)); // 실행가능!
stream1.forEach(num -> System.out.println(num)); // 실행불가!
// 실행결과)
1
3
2
4
6
5
7
Exception in thread "main" java.lang.IllegalStateException: 
stream has already been operated upon or closed
...

위 예시처럼 Stream이 한번 처리하면 새로운 스트림이 새로 생성되기에 기존의 스트림은 다시 사용할 수 없다.

왜 불편하게 휘발성 객체인가?

위에서 언급한 불변성(Immutability)과 다시 관련이 있는데
결론부터 말하면 스트림이 데이터 소스를 변경하지 않고 새로운 스트림을 반환하기 때문이다.
때문에 새로운 객체가 계속 생성되며 메모리 사용량이 늘어날 수도 있고 스트림을 여러번 사용해야되는 상황이라면 Colletions 를 사용하는 것보다 불편할 수 있다.

하지만 스트림이 처리되고 사라짐으로서 위 list1은 불변성이 보장되며 list1이 병렬처리 되기 훨씬 쉽기때문이다.
함수형 프로그래밍에서는 데이터의 불변성을 보장하기 위해 원본 데이터를 복사해서 처리하는 방식을 지향하는데 자바에서는 스트림을 활용함으로써 이 불변성을 보장한다.
(각각의 스트림도 그냥 사라짐으로서 다른 처리결과나 나올 경우를 아예 없애버리는 듯...)

예를 들어 멀티쓰레드 환경에서 위 예시의 list1을 개별적으로 스트림으로 처리한다고 가정했을때, 여러 쓰레드들은 list1에 대한 Race Condition을 피할 수 있다.

(멀티쓰레드 환경에서 Race Condition에 대한 내용 참고)

반복 작업을 쉽게 처리할 수 있다.

스트림은 내부적으로 많은 기능들이 구현된 메소드들을 가지고 있다.
때문에 for문 등을 활용한 반복문을 활용해야할 경우를 크게 줄여준다.
크게 여러번 처리할 수 있는 중간연산(intermediate operations), 한번만 처리할 수 있는 최종연산(terminal operations) 으로 메소드들을 나눌 수 있으며
중간연산은 항상 스트림객체를 반환하며 최종연산이 호출되지 않으면 실행되지 않는다.

자주쓰이는 메소드들은 다음과 같다.

중간 연산(intermediate operations)

  • filter
    주어진 조건에 맞는 데이터만 걸러냄
  • map
    주어진 조건으로 각각의 데이터들을 처리함
  • flatMap
    위 map과 동일하지만 평면화까지 진행
    예를 들어 스트림이 다른 스트림들을 가지는 구조라면,
    내부의 모든 스트림을 하나의 스트림으로 평면화
  • distinct
    중복되는 요소를 제거
  • sorted
    스트림의 요소들을 오름차순으로 정렬
  • peek
    각각의 요소들을 중간에 소비하고 반환
    주로 디버깅이나 로그를 남길때 사용
  • limit(long maxSize)
    스트림의 첫 maxSize개의 요소로 제한
  • skip(long n)
    첫 N개의 요소를 제외하고 반환

최종 연산(terminal operations)

  • forEach
    모든 요소를 돌면서 특정 동작을 수행
  • count
    스트림의 요소 수를 반환
  • collect
    모든 요소를 Collection 객체로 수집
  • reduce
    주어진 연산을 사용하여 스트림의 요소를 하나로 줄임
  • anyMatch
    주어진 조건의 요소가 하나라도 있는지 확인 (boolean 반환)
  • allMatch
    모든 요소가 주어진 조건의 부합하는지 확인
  • noneMatch
    allMatch와 반대로 모든 요소가 주어진 조건의 부합하지 않는지 확인
    (모든 요소가 주어진 조건에 false라면 true를 반환)
  • findAny
    스트림의 임의의 요소를 반환
  • findFirst
    스트림의 첫 번째 요소를 반환

findAny, findFirst 메소드 참고
https://codechacha.com/ko/java8-stream-difference-findany-findfirst/

Parallel Stream

위 스트림을 말 그대로 병렬적으로 처리할 수 있는 스트림이다.
내부적으로 자바의 내장 쓰레드 풀인 ForkJoinPool을 사용하며 병렬적으로 처리되기 때문에 스트림과 다르게 순서가 보장되지 않는다.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

System.out.println("일반스트림 peek");
numbers.stream()
	.peek(v -> System.out.print(v + " "))
	.collect(Collectors.toList());

System.out.println("\n"); // 개행

System.out.println("병렬스트림 peek");
numbers.parallelStream()
	.peek(v -> System.out.print(v + " "))
	.collect(Collectors.toList());
// 실행결과)

일반스트림 peek
1 2 3 4 5 6 7 8 9 10 

병렬스트림 peek
7 2 1 5 3 10 8 9 6 4 

왜 사용하는가

병렬처리가 가능하다는 것은 작업 속도가 빨라질 수 있다는 것을 의미한다.
때문에 대용량 데이터를 처리하는 시간을 줄이기 위해 사용한다.

// 1부터 10억까지 처리시간 테스트
LongStream numbers1 = LongStream.rangeClosed(1, 1000000000);
LongStream numbers2 = LongStream.rangeClosed(1, 1000000000).parallel(); // 병렬 스트림

long start = 0;

// 일반스트림으로 테스트
start = System.currentTimeMillis();
numbers1.map(v -> v + 1) // 각 요소에 +1
		.sum();
System.out.println("일반스트림 처리 시간: " + (System.currentTimeMillis() - start) + "ms");

// 병렬스트림으로 테스트
start = System.currentTimeMillis();
numbers2.map(v -> v + 1) // 각 요소에 +1
		.sum();
System.out.println("병렬스트림 처리 시간: " + (System.currentTimeMillis() - start) + "ms");
// 실행결과)

일반스트림 처리 시간: 2286ms
병렬스트림 처리 시간: 173ms

훨씬 빠르다!

항상 빠른것은 아니다

Parallel Stream이 병렬적으로 처리된다는 것은 내부적으로 여러개의 쓰레드를 사용한다는 의미인데 경우에 따라 더 느릴수도 있다.

작업의 개수가 적거나 매우 빠를 때

이 경우 오히려 병렬스트림이 더 오래 걸릴 수 있다.
위 예시에서 10억이 아닌 10으로 바꾸기만해도 병렬스트림이 더 오래 걸리게된다.

// 1부터 10억까지 처리시간 테스트
LongStream numbers1 = LongStream.rangeClosed(1, 10);
LongStream numbers2 = LongStream.rangeClosed(1, 10).parallel(); // 병렬 스트림
// ... 생략 
// 실행결과)

일반스트림 처리 시간: 1ms
병렬스트림 처리 시간: 9ms

실제로 비율만 따졌을때 9배나 더 느려졌다...

peek 메소드 사용

peek 메소드는 중간연산 메소드이며 각 요소를 참조하여 작업하는데 스트림 요소를 수정하지 않고 각 요소를 조사할 때 유용하다.

하지만 병렬 스트림에서는 다른 연산보다 더 많은 오버헤드를 발생시킬 수 있다.
각 요소를 참조하여 작업할 때, 휘발성을 가지는 스트림의 특성상 새로운 peek 메소드를 다시 호출하기에 여러 쓰레드가 순차적으로 처리할 때 보다 메모리를 더 사용하게 되고 스트림이 커지면 커질수록 추가적인 오버헤드가 발생할 수 있다.

// ... 생략 
numbers1.map(v -> v + 1) // 각 요소에 +1
                .peek(v -> Math.random()) // 의미없는 기능 추가
                .sum();
// ... 생략 
numbers1.map(v -> v + 1) // 각 요소에 +1
                .peek(v -> Math.random()) // 의미없는 기능 추가
                .sum();
// ... 생략 
// 실행결과)

일반스트림 처리 시간: 2924ms
병렬스트림 처리 시간: 37918ms

또한 Thread-Safe한 객체를 공유자원으로 사용한다면 공유 자원에 Lock이 걸려 병목현상을 일으켜 병렬스트림에서 생성된 쓰레드들이 병목되면서 순차적으로 처리할때 보다 더 느려질 수 있다.

Optional

자바8 이전에 null값은 많은 문제를 일으킬 수 있었다. NullPointerException 예외처리를 위해 잦은 null체크 로직이 반복되면서 가독성을 해쳤고, 복잡한 코드일수록 심각해져서 유지보수에 어려움이 따랐다.

활용

Optional객체 생성

Optional<String> optional = Optional.empty();

Optional에 값이 없이 생성만 할 경우이다.

내부에 static final EMPTY 객체를 가지고 있기에 값을 가지고 있지 않은 Optional객체가 아무리 많아도 하나의 EMPTY 객체를 공유함으로서 메모리를 절약한다.


Null이 아닐경우가 보장될 때

Optional<String> optional = Optional.of("value");

Null일 수 있을 때

Optional<String> optional = Optional.ofNullable(test.getValue());

Optional로 값을 감쌀 수 있게 되면서 람다식과 메소드 참조를 함께 사용하며 가독성을 높이고 코드도 더 간결하게 작성할 수 있게 되었다.

예시코드)

class User {
    private Address address;
	// .. 생략
    public Address getAddress() {
        return address;
    }
}

class Address {
    private String postcode;
	// .. 생략
    public String getPostCode() {
        return postcode;
    }
}
// Optional을 사용하기 전에
public String findPostCode() {
    User user = getUser(); // 유저객체 반환

    if (user != null) {
        Address address = user.getAddress();

        if (address != null) {
            String postcode = address.getPostCode();

            if (postcode != null) {
                return postcode;
            }
        }
    }
    
    return "우편번호 없음";
}
// Optional, 람다식을 사용했을때
public String findPostCode() {
	Optional<User> user = Optional.ofNullable(getUser());

	return user
		.map(user -> user.getAddress())
		.map(address -> address.getPostCode())
		.orElse("우편번호 없음");
}
// 메소드 참조까지 사용했을때
public String findPostCode() {
	Optional<User> user = Optional.ofNullable(getUser());
    
	return user
    	.map(User::getAddress)
		.map(Address::getPostCode)
		.orElse("우편번호 없음");
}        

Optional을 사용하기 전의 지저분한 코드를
Optional,람다식,메소드 참조로 간결하고 깔끔하게 작성할 수 있다.

orElse / orElseGet

Optional에서 값이 Null일 경우 반환 값을 지정해주는 두개의 메소드는 값을 불러오는 과정에서 약간의 차이가 있다.

실제 구현 부분 코드)

public T orElse(T other) {
	return value != null ? value : other;
}

public T orElseGet(Supplier<? extends T> supplier) {
	return value != null ? value : supplier.get();
}

orElse는 차이는 특정 값이나 특정 메소드가 실행되고 반환된 값을 가지고 있다가 비교 후 반환한다.

orElseGet는 차이는 특정 메소드자체를 가지고 있다가 비교 후 Null일 경우 해당 메소드를 실행해서 반환한다.

Supplier란?

자바8부터 추가된 함수형 인터페이스이다.
위 예시처럼 메소드를 변수화하여 파라미터를 넘길 수 있다.
주로 해당 메소드에 매개변수가 없을 경우에 사용되며 매개변수가 존재하는 메소드라면 Function인터페이스가 주로 사용된다.

주의점

Optional은 Null을 대체하기 위해(Null-safe) 지원되는 클래스이다.

따라서, 무분별한 Optional 사용은 가독성을 오히려 해칠 수 있다.
예를 들어, Optional 객체 자체도 null로 선언될 수 있어서 Optional의 null 체크도 같이 수행되어야 한다고 하면, 불필요한 확인 과정이 추가될 수 있다.

어떤 값을 감싸는 Wrapper 클래스 이기에 성능에 악영향이 끼칠 가능성도 있다.
Optional 객체는 객체 내부에 실제 값을 포함하고 있기 때문에 메모리를 더 많이 사용하며 대용량 데이터 처리 시 문제가 발생할 경우가 생길 수 있다.

직렬화

자바8에서는 Optional은 직렬화를 지원하지 않는다.
자바9부터 지원한다고 하지만 직렬화는 많은 단점이 있어 지양해야 한다.

Optional 객체는 값이 없는 경우로 존재할 수 있는 클래스이다.
그런데 Optional 객체가 가지는 값이 직렬화가 되며 실제 값이 없더라도 Optional 객체가 직렬화된다면 직렬화된 객체의 크기를 불필요하게 늘릴 수 있다.
때문에 이 경우 Optional 객체를 클래스의 필드값으로 사용하는것보다 실제값을 사용하는것이 더 효율적이다.

또한 역직렬화시 Optional의 특성 상 직렬화 이전과 같은 값을 가진다는 보장이 없다.
만약 "VELOG" 라는 문자열 값을 가지고 있는 Optional 객체의 값을 직렬화한다고 가정했을 때,
Optional 객체 내부의 "VELOG"라는 문자열이 직렬화 되겠지만, Optional.empty()일 경우에는 당연히 null값이 직렬화된다.

이때 역직렬화를 한다고 해서 Optional.empty(), 또는 "VELOG" 라는 값을 가질 수 있는 Optional 객체가 아닌 null값으로 역직렬화가 될 수 있다.
(다시말해 직렬화 이전과 다르게 역직렬화로 객체를 가져오게 된다면 empty() 가 아닌 null로 인해 NullPointerException, NoSuchElementException이 발생할 수 있다.)

Optional 객체를 직렬화할 때 객체 자체를 직렬화하는 것이 아니라, Optional 객체가 가지는 값의 정보를 직렬화하게 되므로 역직렬화할 때 같은 방식으로 역직렬화가 된다는 보장이 없을 수 밖에 없다.

반환타입으로 사용되어야 한다.

애초에 Optional은 반환 타입으로서 NullPointerException 등의 에러가 발생할 수 있는 경우에 결과가 없음을 명확히 하여 반복되는 null 확인과정을 방지하고 Stream API와 결합되어 유연하고 간결한 메소드 체이닝과 함수형 프로그래밍을 지원하기 위해 설계되었다.

때문에 코드의 가독성과 성능을 해치지 않게 적절하게 사용되어야 한다.

출처
https://mangkyu.tistory.com/70
https://mangkyu.tistory.com/203
https://stackoverflow.com/questions/74213739/jackson-deserializing-json-into-an-object-having-an-optional-field

Interface의 default, static 메소드

자바8 이전에는 오직 추상메소드만 선언할 수 있었다.
인터페이스는 호환성과 유연성을 높이고, 코드의 재사용성과 확장성을 높이는데 목적이 있다.
하지만 상황에 따라 인터페이스에 모든 추상메서드를 구현해야 했으므로 중복된 코드와 간결해지기 힘들었다.

때문에 default, static 메소드가 추가되었다.

이 두 메소드는 인터페이스에 기능을 직접 구현함으로써 해당 인터페이스를 구현할 때 반복되는 코드의 양을 줄일 수 있어 더 유연한 개발이 가능해졌다.

default 메소드

인터페이스에 default 붙여 선언하며 정의된 메소드에 기본기능을 구현할 수 있다.
구현체에서 오버라이드가 가능하며 구현체에 의해 사용가능하다.

자주쓰이는 경우에는 Iterable 인터페이스의 forEach(Consumer<? super T> action)과 같은 default 메소드가 있다.

java.lang.Iterable 예시)

default void forEach(Consumer<? super T> action) {
	Objects.requireNonNull(action);
	for (T t : this) {
		action.accept(t);
	}
}

static 메소드

인터페이스에 static 붙여 선언하며 정의된 메소드에 특정 기능을 구현해 놓을 수 있다.
구현체에서 오버라이드가 불가능하며 직접 인터페이스에서 호출해서 사용해야한다.

자주쓰이는 경우에는 LocalDate 인터페이스의 now()과 같은 static 메소드가 있다.

java.time.LocalDate 예시)

public static LocalDate now() {
	return now(Clock.systemDefaultZone());
}

또한 인터페이스의 필드에 상수를 선언할 수 있게 되었다.

상수필드에 public static final 의 키워드가 생략되었다면 컴파일러가 자동으로 붙여준다.
자바9 부터는 private 이나 private static 키워드도 사용하여 인터페이스 내부 사용 용도로 사용하는 상수도 선언할 수 있다.

0개의 댓글