Java 8의 새로운 기능
0. 서론
필자는 개발을 자바라는 언어로 처음 시작했고 현재도 자바 개발자로 일하고 있다. '자바의 정석'이라는 책을 통해 공부를 했고 (8 버전까지 제공) 실무에서도 가장 많이 쓰이는 버전이 8 버전이다 보니 나의 자바에 대한 지식은 계속 자바 8 버전에만 멈춰있었다. 그러는 사이 오라클에서는 벌써 자바 17이 LTS 버전으로 공개되었다. 위기의식을 느낀 나는 각 자바 버전의 새로운 기능에 대한 정리를 하기로 마음먹었다.
1. 개요
이 글은 자바 8의 새로운 기능들 중, 중요하다고 생각되는 몇 가지들(람다식과 함수형 인터페이스, 스트림 API, 인터페이스의 default & static 메서드, 메서드 참조, Optional 클래스)에 대해 다룬다.
2. 람다식과 함수형 인터페이스
2.1 람다식
람다식(Lambda expression)은 간단히 말해서 메서드를 하나의 식으로 표현한 것이다.
int[] arr = new int[5];
Arrays.setAll(arr, (x) -> (int) (Math.random() * 5 + 1));
int[] arr = new int[5];
for (int element : arr) {
element = method(element);
}
int method(int x) {
return x + (int) (Math.random() * 5 + 1);
}
위의 2가지 코드는 모두 같은 로직을 수행한다. 이처럼 람다식을 사용하면 코드를 훨씬 간결하게 표현할 수 있으며, 메서드를 매개변수로 전달하는 것이 가능해진다.
2.2 람다식 작성법, 함수형 인터페이스
함수형 인터페이스란, 인터페이스 내에 오직 한 개의 추상메서드만을 갖고 있어서, 이 인터페이스 타입의 변수에 람다식을 할당하는 것이 가능한 것이다. 지금은 잘 이해가 되지 않을 수도 있으나 계속 읽어나가 보도록 하자.
자바에서 모든 메서드는 클래스 내에 포함되어야 하는데, 람다식은 익명 클래스의 객체로 이루어져 있다. 즉 람다식은 익명클래스의 객체로 표현이 가능하다. 아래 예시들에서 익명 객체 내의 함수와 람다식을 비교해가면서 람다식 작성법을 익혀보자.
Runnable runnable;
runnable = () -> System.out.println("Test");
runnable = () -> {
System.out.println("Test");
};
runnable = new Runnable() {
@Override
public void run() {
System.out.println("Test");
}
};
Consumer<String> consumer;
consumer = a -> System.out.println(a);
consumer = (String a) -> System.out.println(a);
consumer = (a) -> {
System.out.println(a);
};
consumer = new Consumer<String>() {
@Override
public void accept(String a) {
System.out.println(a);
}
};
Supplier<Double> supplier;
supplier = () -> Math.random();
supplier = () -> {
return Math.random();
};
supplier = new Supplier<Double>() {
@Override
public Double get() {
return Math.random();
}
};
BiFunction<String, String, Integer> biFunction;
biFunction = (a, b) -> Integer.parseInt(a) * Integer.parseInt(b);
biFunction = (String a, String b) -> {
int aValue = Integer.parseInt(a);
int bValue = Integer.parseInt(b);
return aValue * bValue;
};
biFunction = new BiFunction<String, String, Integer>() {
@Override
public Integer apply(String a, String b) {
int aValue = Integer.parseInt(a);
int bValue = Integer.parseInt(b);
return aValue * bValue;
}
};
위의 예시들에서 유추해볼 수 있듯이 람다식을 함수형 인터페이스 변수에 할당하는 것이 가능하기 위해서는, 인터페이스 내에 오직 하나의 추상 메서드만 정의되어 있어야 한다. 어떤 인터페이스가 함수형 인터페이스인지를 구별하기 위해서 @FunctionalInterface 어노테이션을 붙여준다. 붙이지 않는다고 해도 인터페이스 내의 함수가 1개밖에 없다면 함수형 인터페이스로 사용하는 것이 가능하기는 하나, 향후 유지보수를 위해 붙여주는 것이 좋다.
package java.util.function;
@FunctionalInterface
public interface Function<T, R> {
R apply(T var1);
}
java.util.function 패키지 내에 있는 표준 함수형 인터페이스들을 사용하면, 대부분의 개발자들의 니즈를 만족시킬 수 있다. 대부분의 라이브러리들도 표준 함수형 인터페이스를 사용하며, 개발자들 간의 효과적이 소통을 위해서도 새로운 함수형 인터페이스를 만드는 것보단 표준 함수형 인터페이스를 사용하는 것을 먼저 고려해봐야 한다.
3. 스트림 API
스트림(java.util.stream)은 Java 8의 주요한 새로운 기능 중 하나로서, 컬렉션이나 배열 등과 같은 요소들의 집합들에 대한 처리를 보다 효과적으로 할 수 있도록 도와준다.
3.1 스트림 생성
스트림은 컬렉션이나 배열 등과 같은 요소들의 집합들로부터 생성될 수 있다.
String[] arr = new String[]{"a", "b", "c"};
Stream<String> stream = Arrays.stream(arr);
stream = Stream.of("a", "b", "c");
아래 예시에서는 Collection interface에 추가된 stream() 함수(인터페이스에 추가된 default method로서, default method 역시 자바 8에 새로 추가된 기능이다. default method에 대해서는 뒤에서 다룰 예정이다.)를 통해 스트림을 생성한다. Collection interface에 추가되었기 때문에 Collection을 상속하는 클래스로 생성된 모든 객체들은 stream()을 통해 스트림을 생성할 수 있다.
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();
3.2 유용한 기능들
스트림은 다양한 유용한 기능들을 제공하며 코드를 더 간결하게 나타낼 수 있게 도와준다. 아래의 2 코드는 서로 동일한 로직을 수행한다.
for (String a : list) {
System.out.println(a);
}
list.stream().forEach(a -> System.out.println(a));
스트림은 이밖에도 다양한 기능들을 람다식과 함께 제공한다. 아래 예시들을 살펴보자.
List<String> list = Arrays.asList("abc", "bac", "ccc", "ccc");
boolean isExist = list.stream().anyMatch(element -> element.contains("a")); // a를 포함한 문자열이 있는지의 여부를 반환
Stream<String> stringStream = list.stream().filter(element -> element.startsWith("a"));// a로 시작하는 문자열만 필터링
Stream<Integer> integerStream = list.stream().map(element -> element.length()); // 문자열의 길이로 이루어진 스트림으로 변환
Stream<String> distinctStream = list.stream().distinct(); // 리스트 내의 중복 제거
list.parallelStream().forEach(System.out::println); // 멀티쓰레드를 이용한 병렬 처리. 메서드 참조도 사용
3.3 지연된 연산
스트림 연산에서 중요한 점 중 하나는 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다는 것이다. 아래 예시를 살펴보자.
List<String> list = Arrays.asList("abc", "bac", "ccc", "ccc");
List<String> result = list.stream().distinct().limit(5).sorted().collect(Collectors.toList());
list의 스트림을 생성한 후, distinct(), limit(), sorted(), collect() 라는 총 4가지의 함수를 호출했다. 여기서 마지막 collect()를 제외한 나머지는 모두 '중간 연산'에 속하며 collect()라는 '최종 연산'이 호출되기 전까지는 아무것도 실행되지 않는다. (이는 Kotlin의 Sequence와 동일하다.) 지연된 연산이 없었다면 매 연산이 실행될 때마다 새로운 객체를 생성했을 것이다. 아래는 스트림을 사용하지 않고 동일한 로직을 구현한다고 가정했을 때의 코드이다.
List<String> list = Arrays.asList("abc", "bac", "ccc", "ccc");
List<String> result = new ArrayList<>(new LinkedHashSet<>(list));
for (int i = 5; i < result.size(); i++) {
result.remove(i);
}
result.sort(Comparator.naturalOrder());
4. 인터페이스의 default 메서드와 static 메서드
자바 8 이전의 interface는 오직 public abstract method만을 가질 수 있었다. 때문에 인터페이스에 하나의 메서드를 추가하려면, 그 인터페이스를 구현한 모든 클래스에 그 메서드를 추가한 후 구현도 해줘야했다. 하지만 default method라는 기능이 자바 8에 생기면서 그럴 필요가 없어졌다. 뿐만 아니라 인터페이스 내에 static method를 추가하는 것도 가능해졌다. 아래는 예시이다.
public interface TestInterface {
public String method();
public int multiply(int a, int b);
default int add(int a, int b) {
return a + b;
}
public static int minus(int a, int b) {
return a - b;
}
}
public class TestClass implements TestInterface{
@Override
public String method() {
return "method";
}
@Override
public int multiply(int a, int b) {
return a * b;
}
}
public class Main {
public static void main(String[] args) {
TestInterface testInterface = new TestClass();
testInterface.add(1, 2);
TestInterface.minus(2, 1);
}
}
5. 메서드 참조
메서드 참조란 람다식을 좀 더 가독성이 좋은 짧은 형태로 나타내는 것이다. 항상 가능한 것은 아니고 람다식이 하나의 메서드만 호출하는 경우, 메서드 참조의 형태로 람다식을 간략히 나타낼 수 있다. 총 4가지의 형태가 있다.
5.1 Static 메서드
- syntax : 클래스명::메서드명
List<String> list = Arrays.asList("a", "b", "c");
list.stream().forEach(a -> System.out.println(a));
list.stream().forEach(System.out::println); // 이 둘은 동일한 표현이다.
5.2 Instance 메서드
- syntax : 변수명::메서드명
String target = "ab";
list.stream().filter(a -> target.contains(a));
list.stream().filter(target::contains); // 이 둘은 동일한 표현이다.
5.3 특정 클래스에 대한 Instance 메서드
- syntax : 클래스명::메서드명
list.stream().map(a -> a.length());
list.stream().map(String::length); // 이 둘은 동일한 표현이다.
5.4 생성자
- syntax : 클래스명::new
List<Integer> list = Arrays.asList(1, 2, 3);
list.stream().map(a -> new ArrayList<String>(a));
list.stream().map(ArrayList::new); // 이 둘은 동일한 표현이다.
6. Optional<T>
자바 8 이전의 개발자들은 NullPointerException(NPE)이 발생할 수 있다는 가능성 때문에, 이에 대한 유효성 검사 및 에러 방지를 위한 많은 보일러플레이트 코드를 추가해야만 했다. Optional<T> 클래스는 NPE를 효과적으로 다룰 수 있게해주는 콘테이너 클래스이다. Optional 콘테이너 내부의 객체가 null이 아니라면 그 객체를 반환하고, null일 경우에는 NPE가 발생하는 대신 사전에 정의된 액션을 취한다.
6.1 Optional<T> 생성
Optional<String> optional;
optional = Optional.empty(); // 빈 Optional 객체를 생성
optional = Optional.of("value"); // null이 아닌 객체를 감싼 Optional 객체를 생성
optional = Optional.ofNullable(getString());
// getString()이 스트링을 반환할 수도, null을 반환할 수도 있음
// getString()이 null일 경우, 빈 ptional 객체가 생성됨
6.2 Optional<T> 사용
다음은 Optional<T>를 사용했을 때와 사용하지 않았을 때를 비교하면서, 사용 시에 코드가 얼마나 더 간결해지고 직관적으로 변하는지를 보도록 하자.
List<String> list = getList();
List<String> listOpt1 = list != null ? list : new ArrayList<>(); // Java 8 이전 코드
List<String> listOpt2 = listOpt = getList().orElseGet(() -> new ArrayList<>()); // Optional 사용
User user = getUser();
if (user != null) {
Address address = user.getAddress();
if (address != null) {
String street = address.getStreet();
if (street != null) {
return street;
}
}
}
return "not specified";
Optional<User> user = Optional.ofNullable(getUser());
String result = user
.map(User::getAddress)
.map(Address::getStreet)
.orElse("not specified");
위 예제에서 map() 메서드는 getAdress() 메서드의 결과값을 Optional<Address>로 변환하고, getStreet() 메서드의 결과값을 Optional<String>으로 변환한다. 이 get메서드들 중 어느 하나가 null을 반환한다면, map() 메서드는 빈 Optional 객체를 반환할 것이다.
만약 애초에 이 get메서드들이 Optional<T>을 반환하는 함수였다고 가정한다면, map() 대신 flatMap() 메서드가 아래와 같이 쓰여야 한다.
Optional<OptionalUser> optionalUser = Optional.ofNullable(getOptionalUser());
String result = optionalUser
.flatMap(OptionalUser::getAddress)
.flatMap(OptionalAddress::getStreet)
.orElse("not specified");
String value = null;
String result = "";
try {
result = value.toUpperCase();
} catch (NullPointerException exception) {
throw new CustomException();
}
String value = null;
Optional<String> valueOpt = Optional.ofNullable(value);
String result = valueOpt.orElseThrow(CustomException::new).toUpperCase();
※ 참조
Java의 정석 (저자 : 남궁성 / 도우출판)