컬렉션은 가장 많이 사용하는 데이터 구조 중 하나일 것이다. 이번 포스트는 Java의 Collection에 대해서 더 이해하고 잘 사용하기 위한 방법에 대한 기록을 정리한다. 구구절절한 내용이 많아 만약 단순히 Collection에 대한 용도가 궁금하다면 공식 문서를 참고하는 것이 가장 나을 것이라 생각한다.
Collection
컬렉션이란 여러 데이터의 묶음이다. Collection은 추상체다. 데이터 묶음의 종류로 구상체를 나눌 수 있고, 예를 들면 List가 있다. 그리고 List의 구상체는 LinkedList, ArrayList, Stack, Vector등이 있다. Java에서 사용되는 컬렉션 중 하나인 List를 예시로 들어보겠다.
List linkedList = new LinkedList<>();
List arrayList = new ArrayList<>();
위와 같이 Java 인터페이스의 다형성을 이용해 List의 여러 구현체를 모두 List라고 표현한다. 이렇게 기능을 제한하고 일반적인 컬렉션으로 내부의 실제 자료구조와 상관없이 다형성을 이용해 활용도 증가한다. List 같은 추상적인 데이터 묶음을 Collection이라고 할 수 있다. Collection의 함수를 이용해서 묶음을 조작할 수 있다.
List lst = new ArrayList<>();
lst.add(1);
lst.add(2);
lst.add(3);
for (int i = 0; i < n; i++) {
System.out.println(lst.get(i));
}
for (int i : lst) {
System.out.println(i);
}
/*
* 출력
* 1
* 2
* 3
* 4
* 5
*/
add, remove 등 각 Collection의 사용은 Java 공식 문서를 보면 어렵지 않다. Collection이 데이터 묶음이라는 점을 활용해서 묶음들에 대한 작업 수행을 더욱 간편하게 만들어보겠다.
class MyCollection<T> {
List<T> list;
public MyCollection(List<T> list) {
this.list = list;
}
public void foreach(Consumer<T> consumer) {
for (int i = 0; i < list.size(); i++) {
consumer.accept(list.get(i));
}
}
}
리스트를 주입 받을 수 있고 foreach라는 메서드를 가진 MyCollection 클래스를 정의했다. 인터페이스글에서 언급한 것처럼, 함수형 인터페이스를 사용해 foreach는 단순히 for루프만 돌릴 수 있는 수행 범위를 가진다. 이제 main에서 MyCollection의 기능을 사용해보자.
class Main {
public static void main(String[] args) {
MyCollection<Integer> myCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5));
myCollection.foreach(System.out::println);
}
}
/*
* 출력
* 1
* 2
* 3
* 4
* 5
*/
Generic을 이용해 유연한 데이터 묶음을 만들고, 기능을 추가해 위와 같이 간편하게 사용할 수 있다. 이제 다른 기능을 추가해보자. 새 기능으로 특정한 조건을 통과하는 데이터만 필터링하는 기능을 추가해보자.
class MyCollection<T> {
List<T> list;
public MyCollection(List<T> list) {
this.list = list;
}
public void foreach(Consumer<T> consumer) {
for (int i = 0; i < list.size(); i++) {
consumer.accept(list.get(i));
}
}
public MyCollection<T> filter(Predicate<T> predicate) {
List<T> newList = new ArrayList<>();
foreach(item -> {
if (predicate.test(item)) {
newList.add(item);
}
});
return new Mycollection<T>(newList);
}
}
조건문의 결과가 true인지 false인지 반환하는 함수형 인터페이스인 Predicate를 사용해서 짝수인 수들만 필터링을 하는 기능을 추가했다.
이제 새롭게 추가한 기능을 main에서 사용해보겠다.
class Main {
public static void main(String[] args) {
MyCollection<Integer> myCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5))
.filter(item -> item % 2 == 0);
}
}
새롭게 만들어진 MyCollection의 리스트를 확인하고 싶다면 어떻게 해야할까? 이미 만들어둔 foreach 기능이 있으니 이를 활용해도 좋다!
class Main {
public static void main(String[] args) {
MyCollection<Integer> myCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5))
.filter(item -> item % 2 == 0);
myCollection.foreach(System.out::println);
}
}
/*
* 출력
* 2
* 4
*/
이번엔 변형을 하는 기능을 추가해보겠다.
class MyCollection<T> {
List<T> list;
public MyCollection(List<T> list) {
this.list = list;
}
public void foreach(Consumer<T> consumer) {
for (int i = 0; i < list.size(); i++) {
consumer.accept(list.get(i));
}
}
public MyCollection<T> filter(Predicate<T> predicate) {
List<T> newList = new ArrayList<>();
foreach(item -> {
if (predicate.test(item)) {
newList.add(item);
}
});
return new Mycollection<T>(newList);
}
public <U> MyCollection<T> map(Function<T, U> function) {
List<U> newList = new ArrayList<>();
foreach(item -> function.apply(item));
return new MyCollection<U>(newList);
}
}
class Main {
public static void main(String[] args) {
MyCollection<Integer> myCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5))
.filter(item -> item % 2 == 0);
MyCollection<String> myStringCollection = myCollection.map((item) -> item.toString());
myCollection.foreach(System.out::println);
}
}
/*
* 출력
* 2
* 4
*/
T를 넣어 U를 생성하는 함수형 인터페이스인 Function을 활용했다. MyCollection은 처음에 T만 알 수 있는 클래스이기 때문에 U를 사용하는 함수에서 U가 무엇인지 모른다. 그래서 특정 메서드가 U를 사용한다는 것을 선언하기 위해 메서드 앞에 <U>를 써주어야 한다.
이번엔 MyCollection의 리스트의 사이즈를 반환하는 기능을 간단하게 추가해보겠다.
class MyCollection<T> {
List<T> list;
public MyCollection(List<T> list) {
this.list = list;
}
public void foreach(Consumer<T> consumer) {
for (int i = 0; i < list.size(); i++) {
consumer.accept(list.get(i));
}
}
public MyCollection<T> filter(Predicate<T> predicate) {
List<T> newList = new ArrayList<>();
foreach(item -> {
if (predicate.test(item)) {
newList.add(item);
}
});
return new Mycollection<T>(newList);
}
public <U> MyCollection<T> map(Function<T, U> function) {
List<U> newList = new ArrayList<>();
foreach(item -> function.apply(item));
return new MyCollection<U>(newList);
}
public int size() {
return list.size();
}
}
다른 기능들과 마찬가지로 main함수에 실행해서 출력해보면 다음과 같다.
class Main {
public static void main(String[] args) {
MyCollection<String> myCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5))
.filter(item -> item % 2 == 0);
MyCollection<String> myStringCollection = myCollection.map(item -> item.toString());
System.out.println(myCollection.size());
System.out.println(myStringCollection.size());
}
}
/*
* 출력
* 2
* 2
*/
지금까지 size와 foreach 함수를 제외하면 MyCollection으로 새로운 MyCollection을 반환하는 기능들에 적절한 이름으로 기능을 추가했다. 이러한 기능들은 새롭게 MyCollection으로 반환한다. 그렇다면, MyCollection의 기능으로 반환된 MyCollection의 기능을 인스턴스 할당 없이 곧바로 사용할 수 있지 않겠는가?
class Main {
public static void main(String[] args) {
MyCollection<String> myStringCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5))
.filter(item -> item % 2 == 0)
.map(item -> item.toString());
myStringCollection.foreach(System.out::println);
System.out.println(myStringCollection.size());
}
}
/*
* 출력
* 2
* 4
* 2
*/
이렇게 함수의 반환값을 이용해 곧바로 해당 인스턴스의 메서드를 이어서 쭉 연결해 수행할 수 있다. 이를 method chaining이라고 한다. 만약 새롭게 반환된 MyCollection이 필요한게 아니라, 곧바로 foreach만 사용하거나 size만 알고싶을 때는 foreach나 size까지 체인에 연결해서 더욱 간결하게 사용할 수도 있다.
이와 같이 MyCollection에 함수형 인터페이스를 이용해서, 고정된 형태의 기능을 수행하는 것이 아닌 사용하고자 하는 객체에서 원하는 기능을 구현해 주입할 수 있는 범용적인 형태의 객체를 만들었다. Collection은 단순하게 데이터의 묶음, 덩어리다. Java의 Collection을 이용해 이렇게 한 묶음을 내부적인 기능을 활용해 간결하게 표현하고 가시적인 코드를 작성할 수 있다.
더욱 범용적인 사용의 예제를 살펴보겠다.
public class User {
String name;
int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public boolean isAdult() {
return age >= 20;
}
@Override
public String toString() {
return "[name: " + name + ", age: " + age + "]";
}
}
public class Main {
public static void main(String[] args) {
MyCollection<User> users = new MyCollection<>(
Arrays.asList(
new User("abc", 15),
new User("def", 20),
new User("ghi", 25),
new User("jkl", 10),
new User("mno", 30)
)
);
users.filter(User::isAdult)
.foreach(System.out::println);
}
}
/*
* 출력
* [name: abc, age: 15]
* [name: def, age: 20]
* [name: ghi, age: 25]
* [name: jkl, age: 10]
* [name: mno, age: 30]
*/
위와 같이 우리가 새롭게 만들어내는 클래스의 컬렉션을 만들어 간편하게 사용할 수 있다. 무엇보다 어지럽게 써 있는 코드들보다 메서드의 연결로 작성하면 흐름을 이해하기 쉽다. Java에는 자료구조로도 불리는 다양한 컬렉션들이 있다. List, Map, Set 등 다양한 컬렉션들의 메서드들을 이용해 데이터의 묶음에서 일괄적으로 특정한 기능을 수행할 수도 있고, 원하는 데이터를 필터링하기도 쉽다. 간편하게 컬렉션의 강력함을 알기 위해 List를 사용했으나, Java의 자료구조 컬렉션에는 배열 등의 원시적인 타입을 이용해 구현되어있다.
이번엔 다른 방식의 컬렉션으로 순회를 살펴보자.
Iterator
Collection은 기본적으로 데이터의 묶음이지만, 데이터의 묶음을 한꺼번에 처리하는 것이 아니라 묶음을 풀어서 하나씩 처리하는 방법에 대해서 알아보겠다. 하나씩 방문하며 작업을 수행하기 때문에 순회하는 의미로 Iterator라고 한다. Java API의 Iterator를 사용해보자.
List<String> strList = new ArrayList<>(Arrays.asList("a", "ab", "abc", "abcd"));
Iterator<String> iter = strList.iterator();
System.out.println(iter.next());
System.out.println(iter.next());
/*
* 출력
* a
* ab
*/
위와 같이 Iterator를 사용하면 데이터의 묶음인 List를 풀어 하나씩 배출할 수 있는 형태의 구조로 바뀐다. next로 다음을 접근할 때마다 Iterator에선 해당 데이터가 빠진다. 그렇기 때문에 어딘가에 할당해 놓은 것이 아니면 다시 접근할 수는 없다. 이를 이용해 다음과 같이 사용할 수도 있다.
List<String> strList = new ArrayList<>(Arrays.asList("a", "ab", "abc", "abcd"));
Iterator<String> iter = strList.iterator();
while (iter.hasNext()) {
System.out.println(iter.next());
}
/*
* 출력
* a
* ab
* abc
* abcd
*/
Iterator에서 아이템이 다 사라지면 루프가 끝나는 코드다. Java의 공식 문서로 Iterator를 찾아보면 확인할 수 있는 점이 있는데, 사실 Iterator에서 next시 실제로 데이터를 빼는 것은 아니다. 인덱스를 이용해 자리를 옮겨가는데, 이미 지나간 자리로 다시 돌아가는 메서드가 없다.

이미 지나온 데이터는 접근하지도 못하는데, 데이터의 묶음을 풀어서 하나씩 작업을 수행한다면 장점이 무엇일까? 기본적인 Collection은 모든 데이터에 작업을 수행해야한다. 예를 들어 1부터 20까지의 수를 담은 Collection이 있다고 치자. 만약 1부터 10까지의 수만 빼서 새로운 Collection으로 만들고 싶다면, Collection의 데이터가 10 이하의 수인지 하나 하나 확인해봐야한다. 11이 넘어가면 무조건 10 이상이기 때문에 그럴 필요가 없는데도 불필요한 추가적인 연산이 일어난다. Iterator 같이 데이터 하나씩 작업을 수행할 수 있다면, 위와 같이 특정 조건에 도달했을 때 그 다음 연산을 안 해도 된다.
하지만 Iterator의 아쉬운 점은, MyCollection에 구현된 filter, foreach, map 등의 기능이 없다. 묶음이 아니라 하나 하나 작업을 할 수 있기 때문에, 데이터 묶음의 연속적인 작업을 위해서는 번거로운 코드를 쭉 써야한다. foreach를 for문으로 쭉 늘여쓴 것처럼 말이다. Iterator의 장점을 가져가면서 단점도 보완된 무언가가 없을까?
Stream
Collection은 데이터의 묶음, Iterator가 개별적인 데이터의 순회라면, 데이터의 연속, 또는 데이터의 흐름인 Stream이 있다. Stream API는 강력하다. Stream의 예시로 가장 흔한 것은 System.in
, System.out
이다. 각각 InputStream과 OutputStream으로 구현되어 있는 Stream이다.

이게 무슨 말일까? InputStream, OutputStream이라고 지은 까닭은 입력과 출력 모두 언제 끝날지 모르는 연속적인 데이터의 흐름이다. 입력을 위해 우리는 보통 한글자씩 쭉 입력 후 마지막에 엔터를 쳐서 끝낸다. 컴퓨터는 우리가 엔터를 치기 전까지 언제 끝날지 모르는 글자, 즉, 데이터의 흐름을 받아들이고, 엔터를 치면 그 때서야 끝났다는 신호를 받아 여태까지 받은 연속적인 글자를 하나의 문자열의 입력으로 변환할 수 있다. 출력을 할 때도 문자열을 데이터의 흐름으로 마지막 종결자가 나올 때까지 쭉 내보낸다. 이러한 끊어지지 않는 데이터의 흐름을 Stream이라고 한다.
데이터 스트림의 대상을 단순히 캐릭터, 바이트 등에서 문자열, 자료구조 등 Collection의 광범위한 데이터로 넓힌 것이 Java 8 이후의 Stream API라고 할 수 있다. Java의 Stream은 위에서 MyCollection의 기능을 만든 것처럼 함수형 인터페이스를 인자로 받는 고차원 기능의 함수, 고차함수를 제공해준다.
Iterator를 사용하기 위해 Collection에 .iterator()
를 사용했다. 비슷하게 Collection을 Stream으로 만들어주는 메서드가 존재한다.
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
list.stream(); // Stream<List>
위와 같이 .stream()
메서드를 사용하면 Stream이라는 클래스가 나온다. 이 Stream은 사실 인터페이스이다.

그렇다면 .stream()
했을 때 나오는 Stream의 구현체는 무엇일까? Collection 인터페이스의 stream()
메서드로 찾아가보면 코드가 다음과 같다.
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
사실 StreamSupport라는 유틸성 클래스에서 Stream의 생성을 결정하고, 이 정적 메서드를 찾아가보면 다음과 같다.
public static <T> Stream<T> stream(Spliterator<T> spliterator, boolean parallel) {
Objects.requireNonNull(spliterator);
return new ReferencePipeline.Head<>(spliterator,
StreamOpFlag.fromCharacteristics(spliterator),
parallel);
}
이 ReferencePipeline을 만들어 Stream의 작업을 수행하는데, 바로 이 ReferencePipeline이 Stream의 대표적인 구현체 중 하나이다. Stream에 관해선 다른 포스트에서 다루겠다. Stream 하나만으로도 할 말이 너무 많다.
다시 돌아와서, Stream이 강력한 이유는 바로 고차함수이다. 함수형 인터페이스를 인자로 받아 사용하는 곳에서 구현을 결정하는 이 방식이 Stream을 특별하게 만든다. 다음 코드를 보자.
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
list.stream()
.filter(item -> item % 2 == 0)
.map(item -> item + "a")
.forEach(System.out::println);
/*
* 출력
* 2a
*/
이렇게 Stream을 통해 위의 MyCollection에서 직접 구현했던 것처럼 고차함수를 사용한 간결한 코드가 됐다. 이렇게까지만 보면 MyCollection과 다를게 없어보인다. 무엇이 다른걸까?? 위에서 간단하게 봤던 것처럼 Stream은 ReferencePipeline, 즉, 참조의 파이프라인이다. 데이터의 묶음으로 전부 수행하는 것이 아닌 데이터 파이프라인을, 데이터 파편들이 하나씩 흘러가면서 수행이된다. 그리고 stream을 만들 때 parallel이라는 boolean 값이 보인다. stream을 직렬이 아닌, 병렬로 동시에 사용할 수도 있다는 뜻이다.
Stream을 통해 사용할 수 있는 고차함수들은 공식 문서를 보면 알 수 있다. 너무 많아서 다 돌아보기 힘들 정도이다.
Collection이 아니지만 데이터의 묶음인 원시적인 타입이 있다. 바로 배열이다. 배열은 Collection 인터페이스를 상속받거나 구현한 자료구조가 아니지만 Stream을 사용할 수 있다.
int[] arr = new int[]{ 1, 2, 3 };
IntStream strm = Arrays.stream(arr)
.filter((item) -> item % 2 == 0);
strm.forEach(System.out::println);
/*
* 출력
* 2
*/
Stream은 Generic으로 되어있는 인터페이스이기 때문에 원시타입이 들어올 수 없다. 하지만 원시타입인 int의 배열로 Stream을 만들면 IntStream이라는 특별한 Stream으로 만들어져 사용할 수 있다. 이를 IntStream이 아닌 래퍼 클래스의 Integer의 Stream으로 만드는 방법은 간단하다. 원시타입을 래퍼 클래스로 바꾸는 것을 박싱, 반대를 언박싱이라고 하는데 이처럼 .boxed()
를 사용해서 간단하게 Integer의 Stream을 만들 수 있다.
int[] arr = new int[]{ 1, 2, 3 };
Arrays.stream(arr)
.boxed()
.filter(item -> item % 2 == 0)
.map(item -> item + "a")
.forEach(System.out::println);
그러면 이 같은 Stream을 다시 Collection등의 자료구조로 만들기 위해서는 어떻게 해야될까? 이러한 기능마저 Stream API는 제공해준다.
int[] arr = new int[]{ 1, 2, 3 };
List<Integer> list = Arrays.stream(arr)
.boxed()
.toList();
Object[] resultArr = Arrays.stream(arr)
.boxed()
.toArray();
정말 간단하게 List와 Array로 만들었다. 여기서 toArray()
를 사용하고 반환되는 클래스는 Object의 배열이다. 어떤 형식의 배열을 원하는지 확실히 전달해주어야 한다.
int[] arr = new int[]{ 1, 2, 3 };
Integer[] resultArr = Arrays.stream(arr)
.boxed()
.toArray(Integer[]::new);// item -> new Integer[](item)
이 때 메서드 레퍼런스를 사용할 수도 있다. 하지만 원시형이 아닌 참조형의 Integer를 사용해야한다.
이번엔 List를 Map 자료구조로 Stream을 이용해 변환하는 예제를 한 번 작성해보겠다.
List<Strin> list = new ArrayList<>(Arrays.asList("a", "ab", "abc"));
Map<Integer, String> map = list.stream()
.collect(Collectors.toUnmodifiableMap(String::length, Function.identity());
이렇게 유기적으로 어떤 동작을 수행할지 부여하는 개발자의 역량에 따라 정말 다채롭게 사용할 수 있다.
Stream은 인터페이스이기 때문에 new 키워드로 인스턴스를 생성할 수 없다. 그렇다고 Collection을 반드시 만들어서 하거나 위에 봤던 ReferencePipeline 등의 복잡한 구현체로 어렵게 할 필요도 없다. Stream을 만드는 간단한 두가지 방법이 있다.
Stream<Integer> stream = Stream.generate(() -> 1);
Stream의 정적 메서드인 generate를 통해 만들 수 있다. 함수형 인터페이스를 주의 깊게 봤다면 예측 했겠지만, 해당 람다 표현식은 Supplier의 람다 표현식이다. Stream.generate()
는 어떠한 값을 공급해주는 Supplier를 이용해 만들 수 있다. 이렇게 하면 1이 만들어질 것 같은 기분이 드는데 얼마나, 언제까지 만들어질까? Stream의 forEach를 이용해서 출력해보면 다음과 같다.
Stream.generate(() -> 1)
.forEach(System.out::println);
/*
* 출력
* 1
* 1
* 1
* 1
* 1
* 1
* ...
*/
1이 끝없이 만들어진다. generate함수를 통해 연속적인 데이터를 무한정으로 흘린다. 이를 제한하기 위해선 .limit()
을 사용할 수 있다.
Random random = new Random();
Stream.generate(random::nextInt)
.limit(10)
.forEach(System.out::println);
/*
* 출력
* 1061398370
* -1508450147
* -2023017727
* 1152076952
* 1721239407
* 400077550
* 1125807549
* 1028117856
* 734294618
* 1574495689
*/
랜덤 클래스로부터 무작위의 Integer를 만들어 10개의 데이터 흐름을 만들고 출력하는 코드다. 다른 방식으로도 Stream을 만들 수 있다.
Stream.iterate(0, i -> i + 1)
.limit(10)
.forEach(System.out::println);
/*
* 출력
* 0
* 1
* 2
* 3
* 4
* 5
* 6
* 7
* 8
* 9
*/
Stream.iterate()
를 이용해서 만들 수 있다. generate와의 차이는, 초기값의 유무다. iterate는 seed라고 불리는 초기값을 먼저 설정해 seed를 시작으로 Supplier에 넣어 원하는 값을 계속 만든다. 반면 generate는 연속적으로 사용할 수 있는 기준값 없이 생성한다.
데이터의 묶음인 Collection, 개별적인 데이터를 다루는 Iterator, 개별적으로 다루며 고차함수를 사용한 Stream 등을 이렇게 조금 알아봤다. 이젠 마지막으로 null이라는 데이터를 취급하는 방식에 대해서 알아보겠다.
Optional
Null이란 것은 데이터의 부재를 나타낸다. 0이나 빈 Collection, 빈 문자열 등이 아닌 그냥 없다는 의미이다. Null의 아스키 코드는 0이다. 이는 문자열 0이나 숫자 0과 다르다. 실제로 문자 0은 48의 아스키 코드에 매칭된다. Java에는 NullPointerException, NPE라고도 불리는 예외가 있다. 이는 가장 많이 발생하는 예외 중 하나이다. Java에서는 거의 모든 것이 클래스와 인스턴스를 가리키는 레퍼런스로 되어 있고, 부재를 가리키고 있는 레퍼런스는 Null을 가리키고 있다는 뜻이기에 해당 레퍼런스로 어떤 타입의 인스턴스에 접근하려면 NullPointerException이 발생한다. 그렇기 때문에 null인지 아닌지 항상 확인할 필요가 있다.
그렇기 때문에 null을 피하기 위해 개발자들은 서로 null을 쓰지 않기로 약속했다. 이를 계약을 했다고 하고, 계약 기반 프로그래밍이라고도 한다. null을 피하기 위해 이런 코드를 본적이 있을 수도 있다.
Assert.notNull();
애초부터 null을 막기 위해 null이 들어오면 안 되는 곳에 들어올 시 NPE가 아닌 다른 예외를 발생시키는 것이다. 이것이 NPE를 발생시키는 것과의 차이는 개발자가 의도한 예외인지 아닌지 구분이 되기에 사용자가 예외에 대한 이유를 더 명확하게 알 수 있다. 저런 코드는 Java의 프레임워크인 Spring에서 많이 사용된다.
특정 프레임워크에서 제공되는 방법이 아닌 Java만의 방법이 있다. 바로 Optional 클래스를 이용하는 것이다. 저 위에 만들었던 User를 다시 보자.
public class User {
String name;
int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public boolean isAdult() {
return age >= 20;
}
@Override
public String toString() {
return "[name: " + name + ", age: " + age + "]";
}
}
레퍼런스 타입인 Integer에는 null을 할당할 수 있다. 이렇게 null이 할당된 변수의 메서드를 실행하면 NPE가 발생한다. 그렇기 때문에 예기치 못한 NPE를 피하기 위해서 Java 개발자들은 null이 들어오지 않도록 약속했지만, 만약 개발자가 초기값으로 아무 값도 지정하지 않고 해당 변수를 나중에 결정하는 코드를 작성하고자 한다면 할당될 초기값은 null이 제일 적절했다.
public class Main {
public static void main(String[] args) {
User user = null;
user = getUser("abc", 20);
}
static User getUser(String name, int age) {
return new User(name, age);
}
}
초기값으로 0 등을 줄 수도 있겠다고 생각할 수도 있지만, 0도 결국 실제로 유의미한 데이터일 수도 있기 때문에 개발 시 0은 무의미한 데이터라고 공공연하게 선언해놓거나 null을 사용하는 것이 더 나은 선택이었다. 나이가 0이고 이름이 빈 칸인 User를 유효하지 않은 데이터로 정의해보자면 다음과 같다.
public class User {
public static final User EMPTY = new User("", 0);
String name;
int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public boolean isAdult() {
return age >= 20;
}
@Override
public String toString() {
return "[name: " + name + ", age: " + age + "]";
}
}
0살이고 이름이 없는 사용자가 유효하지 않은 초기값이라는 것을 User 클래스로 정의된 모든 인스턴스가 알게 하기 위해 static으로 선언해두어 공공연한 약속을 프로그램 속에서 만들었다. 이를 이용해 null 대신 약속된 초기값을 아래와 같이 사용할 수 있다.
public class Main {
public static void main(String[] args) {
User user = EMPTY;
if (user == EMPTY) {
user = getUser();
}
}
static User getUser() {
return new User("abc", 20);
}
}
하지만 역시, 0살이고 이름이 없는 User가 의미있는 데이터로 활용될 가능성도 있다. 역시 초기값으로 데이터의 부재를 의미하는 null이 더욱 적절했다. 게다가 User.EMPTY
가 약속이지만, 만약 User에 접근하기가 힘들거나 EMPTY
의 존재를 모르며 개발하는 동료는 이를 모르고 버그를 만들 수도 있다.
그래서 NPE는 피하면서 null을 가능하게 하기 위해 운반책을 마련했다. 바로 Optional이다. null을 사용해 다음과 같이 초기화 할 수 있다. Optional은 세가지 방식으로 만들 수 있다.
public class Main {
public static void main(String[] args) {
Optional<User> empty = Optional.empty();
Optional<User> optionalUser = Optional.of(new User("abc", 20));
Optional<User> nullableUser = Optional.ofNullable(null);
}
}
Optional은 원하는 클래스의 객체를 운반하는 운반책이다. 만약 운반하는 무언가가 없다면 Optional 안에는 비었을 것이고, 그러면 Optional은 null을 반환한다. Optional에 어떤 인스턴스를 담고자 하면 of를 사용할 수 있다. 만약 이 인스턴스가 null일 지도 모른다면 ofNullable을 사용해서 Optional을 만들 수 있다.
Optional을 어떻게 사용할 수 있기에 null의 문제를 해결할 수 있었을까? NPE는 예기치 못한 상황에서 null을 만나 개발자들을 고생시켰다. 의도적으로 null의 가능성을 생각해야 하는 변수라면 Optional을 사용해 확인해야하는 코드를 작성해야한다. 예기치 못한 null을 피하기 위해서다.
public class Main {
public static void main(String[] args) {
Optional<User> user = Optional.ofNullable(null);
if (user.isEmpty()) {
user = Optional.of(getUser());
}
if (user.isPresent()) {
System.out.println(user.get());
}
}
static User getUser() {
return new User("abc", 20);
}
}
/*
* 출력
* [name: abc, age: 20]
*/
Optional 안에 인스턴스가 들었을 때와 비었을 때를 분기로 원하는 작업을 수행할 수 있다. 이는 함수형 인터페이스와 메서드 체이닝을 통해 개선될 수 있다. 만약 Optional이 인스턴스를 운반하고 있다면 원하는 액션을 곧바로 할 수 있는 방법은 아래와 같다.
public class Main {
public static void main(String[] args) {
Optional.ofNullable(getUser())
.ifPresent(System.out::println);
Optional.empty()
.ifPresentOrElse(System.out::println, () -> {
throw new RuntimeException();
}
}
static User getUser() {
return new User("abc", 20);
}
}
.ifPresent
메서드는 함수형 인터페이스인 Consumer를 인자로 받는다. 원하는 작업을 즉각적으로 실행해주는 함수다. Optional의 사용으로 인스턴스가 있을 수도 있고 없을 수도 있다는 정보가 포함된 컨텍스트가 포함되어 있기 때문에, 해당 Optional을 확인해야하는 입장에선 이에 대한 처리를 해야한다는 점을 확실하게 인지하게 된다. Optional에 대한 간략한 정보는 여기까지만 하고, 다른 포스트에 Optional에 관해 더 기록할 생각이다.
References
프로그래머스 데브코스 - 프레임워크를 위한 Java
'Java' 카테고리의 다른 글
자바 인터페이스 이야기 - 사용 이유 (0) | 2024.05.28 |
---|---|
빌드툴 없이 Java 프로젝트 빌드 해보기 (0) | 2023.11.29 |
Java, JVM, JRE, JDK (2) | 2023.11.29 |
컬렉션은 가장 많이 사용하는 데이터 구조 중 하나일 것이다. 이번 포스트는 Java의 Collection에 대해서 더 이해하고 잘 사용하기 위한 방법에 대한 기록을 정리한다. 구구절절한 내용이 많아 만약 단순히 Collection에 대한 용도가 궁금하다면 공식 문서를 참고하는 것이 가장 나을 것이라 생각한다.
Collection
컬렉션이란 여러 데이터의 묶음이다. Collection은 추상체다. 데이터 묶음의 종류로 구상체를 나눌 수 있고, 예를 들면 List가 있다. 그리고 List의 구상체는 LinkedList, ArrayList, Stack, Vector등이 있다. Java에서 사용되는 컬렉션 중 하나인 List를 예시로 들어보겠다.
List linkedList = new LinkedList<>();
List arrayList = new ArrayList<>();
위와 같이 Java 인터페이스의 다형성을 이용해 List의 여러 구현체를 모두 List라고 표현한다. 이렇게 기능을 제한하고 일반적인 컬렉션으로 내부의 실제 자료구조와 상관없이 다형성을 이용해 활용도 증가한다. List 같은 추상적인 데이터 묶음을 Collection이라고 할 수 있다. Collection의 함수를 이용해서 묶음을 조작할 수 있다.
List lst = new ArrayList<>();
lst.add(1);
lst.add(2);
lst.add(3);
for (int i = 0; i < n; i++) {
System.out.println(lst.get(i));
}
for (int i : lst) {
System.out.println(i);
}
/*
* 출력
* 1
* 2
* 3
* 4
* 5
*/
add, remove 등 각 Collection의 사용은 Java 공식 문서를 보면 어렵지 않다. Collection이 데이터 묶음이라는 점을 활용해서 묶음들에 대한 작업 수행을 더욱 간편하게 만들어보겠다.
class MyCollection<T> {
List<T> list;
public MyCollection(List<T> list) {
this.list = list;
}
public void foreach(Consumer<T> consumer) {
for (int i = 0; i < list.size(); i++) {
consumer.accept(list.get(i));
}
}
}
리스트를 주입 받을 수 있고 foreach라는 메서드를 가진 MyCollection 클래스를 정의했다. 인터페이스글에서 언급한 것처럼, 함수형 인터페이스를 사용해 foreach는 단순히 for루프만 돌릴 수 있는 수행 범위를 가진다. 이제 main에서 MyCollection의 기능을 사용해보자.
class Main {
public static void main(String[] args) {
MyCollection<Integer> myCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5));
myCollection.foreach(System.out::println);
}
}
/*
* 출력
* 1
* 2
* 3
* 4
* 5
*/
Generic을 이용해 유연한 데이터 묶음을 만들고, 기능을 추가해 위와 같이 간편하게 사용할 수 있다. 이제 다른 기능을 추가해보자. 새 기능으로 특정한 조건을 통과하는 데이터만 필터링하는 기능을 추가해보자.
class MyCollection<T> {
List<T> list;
public MyCollection(List<T> list) {
this.list = list;
}
public void foreach(Consumer<T> consumer) {
for (int i = 0; i < list.size(); i++) {
consumer.accept(list.get(i));
}
}
public MyCollection<T> filter(Predicate<T> predicate) {
List<T> newList = new ArrayList<>();
foreach(item -> {
if (predicate.test(item)) {
newList.add(item);
}
});
return new Mycollection<T>(newList);
}
}
조건문의 결과가 true인지 false인지 반환하는 함수형 인터페이스인 Predicate를 사용해서 짝수인 수들만 필터링을 하는 기능을 추가했다.
이제 새롭게 추가한 기능을 main에서 사용해보겠다.
class Main {
public static void main(String[] args) {
MyCollection<Integer> myCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5))
.filter(item -> item % 2 == 0);
}
}
새롭게 만들어진 MyCollection의 리스트를 확인하고 싶다면 어떻게 해야할까? 이미 만들어둔 foreach 기능이 있으니 이를 활용해도 좋다!
class Main {
public static void main(String[] args) {
MyCollection<Integer> myCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5))
.filter(item -> item % 2 == 0);
myCollection.foreach(System.out::println);
}
}
/*
* 출력
* 2
* 4
*/
이번엔 변형을 하는 기능을 추가해보겠다.
class MyCollection<T> {
List<T> list;
public MyCollection(List<T> list) {
this.list = list;
}
public void foreach(Consumer<T> consumer) {
for (int i = 0; i < list.size(); i++) {
consumer.accept(list.get(i));
}
}
public MyCollection<T> filter(Predicate<T> predicate) {
List<T> newList = new ArrayList<>();
foreach(item -> {
if (predicate.test(item)) {
newList.add(item);
}
});
return new Mycollection<T>(newList);
}
public <U> MyCollection<T> map(Function<T, U> function) {
List<U> newList = new ArrayList<>();
foreach(item -> function.apply(item));
return new MyCollection<U>(newList);
}
}
class Main {
public static void main(String[] args) {
MyCollection<Integer> myCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5))
.filter(item -> item % 2 == 0);
MyCollection<String> myStringCollection = myCollection.map((item) -> item.toString());
myCollection.foreach(System.out::println);
}
}
/*
* 출력
* 2
* 4
*/
T를 넣어 U를 생성하는 함수형 인터페이스인 Function을 활용했다. MyCollection은 처음에 T만 알 수 있는 클래스이기 때문에 U를 사용하는 함수에서 U가 무엇인지 모른다. 그래서 특정 메서드가 U를 사용한다는 것을 선언하기 위해 메서드 앞에 <U>를 써주어야 한다.
이번엔 MyCollection의 리스트의 사이즈를 반환하는 기능을 간단하게 추가해보겠다.
class MyCollection<T> {
List<T> list;
public MyCollection(List<T> list) {
this.list = list;
}
public void foreach(Consumer<T> consumer) {
for (int i = 0; i < list.size(); i++) {
consumer.accept(list.get(i));
}
}
public MyCollection<T> filter(Predicate<T> predicate) {
List<T> newList = new ArrayList<>();
foreach(item -> {
if (predicate.test(item)) {
newList.add(item);
}
});
return new Mycollection<T>(newList);
}
public <U> MyCollection<T> map(Function<T, U> function) {
List<U> newList = new ArrayList<>();
foreach(item -> function.apply(item));
return new MyCollection<U>(newList);
}
public int size() {
return list.size();
}
}
다른 기능들과 마찬가지로 main함수에 실행해서 출력해보면 다음과 같다.
class Main {
public static void main(String[] args) {
MyCollection<String> myCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5))
.filter(item -> item % 2 == 0);
MyCollection<String> myStringCollection = myCollection.map(item -> item.toString());
System.out.println(myCollection.size());
System.out.println(myStringCollection.size());
}
}
/*
* 출력
* 2
* 2
*/
지금까지 size와 foreach 함수를 제외하면 MyCollection으로 새로운 MyCollection을 반환하는 기능들에 적절한 이름으로 기능을 추가했다. 이러한 기능들은 새롭게 MyCollection으로 반환한다. 그렇다면, MyCollection의 기능으로 반환된 MyCollection의 기능을 인스턴스 할당 없이 곧바로 사용할 수 있지 않겠는가?
class Main {
public static void main(String[] args) {
MyCollection<String> myStringCollection = new MyCollection(Arrays.asList(1, 2, 3, 4, 5))
.filter(item -> item % 2 == 0)
.map(item -> item.toString());
myStringCollection.foreach(System.out::println);
System.out.println(myStringCollection.size());
}
}
/*
* 출력
* 2
* 4
* 2
*/
이렇게 함수의 반환값을 이용해 곧바로 해당 인스턴스의 메서드를 이어서 쭉 연결해 수행할 수 있다. 이를 method chaining이라고 한다. 만약 새롭게 반환된 MyCollection이 필요한게 아니라, 곧바로 foreach만 사용하거나 size만 알고싶을 때는 foreach나 size까지 체인에 연결해서 더욱 간결하게 사용할 수도 있다.
이와 같이 MyCollection에 함수형 인터페이스를 이용해서, 고정된 형태의 기능을 수행하는 것이 아닌 사용하고자 하는 객체에서 원하는 기능을 구현해 주입할 수 있는 범용적인 형태의 객체를 만들었다. Collection은 단순하게 데이터의 묶음, 덩어리다. Java의 Collection을 이용해 이렇게 한 묶음을 내부적인 기능을 활용해 간결하게 표현하고 가시적인 코드를 작성할 수 있다.
더욱 범용적인 사용의 예제를 살펴보겠다.
public class User {
String name;
int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public boolean isAdult() {
return age >= 20;
}
@Override
public String toString() {
return "[name: " + name + ", age: " + age + "]";
}
}
public class Main {
public static void main(String[] args) {
MyCollection<User> users = new MyCollection<>(
Arrays.asList(
new User("abc", 15),
new User("def", 20),
new User("ghi", 25),
new User("jkl", 10),
new User("mno", 30)
)
);
users.filter(User::isAdult)
.foreach(System.out::println);
}
}
/*
* 출력
* [name: abc, age: 15]
* [name: def, age: 20]
* [name: ghi, age: 25]
* [name: jkl, age: 10]
* [name: mno, age: 30]
*/
위와 같이 우리가 새롭게 만들어내는 클래스의 컬렉션을 만들어 간편하게 사용할 수 있다. 무엇보다 어지럽게 써 있는 코드들보다 메서드의 연결로 작성하면 흐름을 이해하기 쉽다. Java에는 자료구조로도 불리는 다양한 컬렉션들이 있다. List, Map, Set 등 다양한 컬렉션들의 메서드들을 이용해 데이터의 묶음에서 일괄적으로 특정한 기능을 수행할 수도 있고, 원하는 데이터를 필터링하기도 쉽다. 간편하게 컬렉션의 강력함을 알기 위해 List를 사용했으나, Java의 자료구조 컬렉션에는 배열 등의 원시적인 타입을 이용해 구현되어있다.
이번엔 다른 방식의 컬렉션으로 순회를 살펴보자.
Iterator
Collection은 기본적으로 데이터의 묶음이지만, 데이터의 묶음을 한꺼번에 처리하는 것이 아니라 묶음을 풀어서 하나씩 처리하는 방법에 대해서 알아보겠다. 하나씩 방문하며 작업을 수행하기 때문에 순회하는 의미로 Iterator라고 한다. Java API의 Iterator를 사용해보자.
List<String> strList = new ArrayList<>(Arrays.asList("a", "ab", "abc", "abcd"));
Iterator<String> iter = strList.iterator();
System.out.println(iter.next());
System.out.println(iter.next());
/*
* 출력
* a
* ab
*/
위와 같이 Iterator를 사용하면 데이터의 묶음인 List를 풀어 하나씩 배출할 수 있는 형태의 구조로 바뀐다. next로 다음을 접근할 때마다 Iterator에선 해당 데이터가 빠진다. 그렇기 때문에 어딘가에 할당해 놓은 것이 아니면 다시 접근할 수는 없다. 이를 이용해 다음과 같이 사용할 수도 있다.
List<String> strList = new ArrayList<>(Arrays.asList("a", "ab", "abc", "abcd"));
Iterator<String> iter = strList.iterator();
while (iter.hasNext()) {
System.out.println(iter.next());
}
/*
* 출력
* a
* ab
* abc
* abcd
*/
Iterator에서 아이템이 다 사라지면 루프가 끝나는 코드다. Java의 공식 문서로 Iterator를 찾아보면 확인할 수 있는 점이 있는데, 사실 Iterator에서 next시 실제로 데이터를 빼는 것은 아니다. 인덱스를 이용해 자리를 옮겨가는데, 이미 지나간 자리로 다시 돌아가는 메서드가 없다.

이미 지나온 데이터는 접근하지도 못하는데, 데이터의 묶음을 풀어서 하나씩 작업을 수행한다면 장점이 무엇일까? 기본적인 Collection은 모든 데이터에 작업을 수행해야한다. 예를 들어 1부터 20까지의 수를 담은 Collection이 있다고 치자. 만약 1부터 10까지의 수만 빼서 새로운 Collection으로 만들고 싶다면, Collection의 데이터가 10 이하의 수인지 하나 하나 확인해봐야한다. 11이 넘어가면 무조건 10 이상이기 때문에 그럴 필요가 없는데도 불필요한 추가적인 연산이 일어난다. Iterator 같이 데이터 하나씩 작업을 수행할 수 있다면, 위와 같이 특정 조건에 도달했을 때 그 다음 연산을 안 해도 된다.
하지만 Iterator의 아쉬운 점은, MyCollection에 구현된 filter, foreach, map 등의 기능이 없다. 묶음이 아니라 하나 하나 작업을 할 수 있기 때문에, 데이터 묶음의 연속적인 작업을 위해서는 번거로운 코드를 쭉 써야한다. foreach를 for문으로 쭉 늘여쓴 것처럼 말이다. Iterator의 장점을 가져가면서 단점도 보완된 무언가가 없을까?
Stream
Collection은 데이터의 묶음, Iterator가 개별적인 데이터의 순회라면, 데이터의 연속, 또는 데이터의 흐름인 Stream이 있다. Stream API는 강력하다. Stream의 예시로 가장 흔한 것은 System.in
, System.out
이다. 각각 InputStream과 OutputStream으로 구현되어 있는 Stream이다.

이게 무슨 말일까? InputStream, OutputStream이라고 지은 까닭은 입력과 출력 모두 언제 끝날지 모르는 연속적인 데이터의 흐름이다. 입력을 위해 우리는 보통 한글자씩 쭉 입력 후 마지막에 엔터를 쳐서 끝낸다. 컴퓨터는 우리가 엔터를 치기 전까지 언제 끝날지 모르는 글자, 즉, 데이터의 흐름을 받아들이고, 엔터를 치면 그 때서야 끝났다는 신호를 받아 여태까지 받은 연속적인 글자를 하나의 문자열의 입력으로 변환할 수 있다. 출력을 할 때도 문자열을 데이터의 흐름으로 마지막 종결자가 나올 때까지 쭉 내보낸다. 이러한 끊어지지 않는 데이터의 흐름을 Stream이라고 한다.
데이터 스트림의 대상을 단순히 캐릭터, 바이트 등에서 문자열, 자료구조 등 Collection의 광범위한 데이터로 넓힌 것이 Java 8 이후의 Stream API라고 할 수 있다. Java의 Stream은 위에서 MyCollection의 기능을 만든 것처럼 함수형 인터페이스를 인자로 받는 고차원 기능의 함수, 고차함수를 제공해준다.
Iterator를 사용하기 위해 Collection에 .iterator()
를 사용했다. 비슷하게 Collection을 Stream으로 만들어주는 메서드가 존재한다.
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
list.stream(); // Stream<List>
위와 같이 .stream()
메서드를 사용하면 Stream이라는 클래스가 나온다. 이 Stream은 사실 인터페이스이다.

그렇다면 .stream()
했을 때 나오는 Stream의 구현체는 무엇일까? Collection 인터페이스의 stream()
메서드로 찾아가보면 코드가 다음과 같다.
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
사실 StreamSupport라는 유틸성 클래스에서 Stream의 생성을 결정하고, 이 정적 메서드를 찾아가보면 다음과 같다.
public static <T> Stream<T> stream(Spliterator<T> spliterator, boolean parallel) {
Objects.requireNonNull(spliterator);
return new ReferencePipeline.Head<>(spliterator,
StreamOpFlag.fromCharacteristics(spliterator),
parallel);
}
이 ReferencePipeline을 만들어 Stream의 작업을 수행하는데, 바로 이 ReferencePipeline이 Stream의 대표적인 구현체 중 하나이다. Stream에 관해선 다른 포스트에서 다루겠다. Stream 하나만으로도 할 말이 너무 많다.
다시 돌아와서, Stream이 강력한 이유는 바로 고차함수이다. 함수형 인터페이스를 인자로 받아 사용하는 곳에서 구현을 결정하는 이 방식이 Stream을 특별하게 만든다. 다음 코드를 보자.
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
list.stream()
.filter(item -> item % 2 == 0)
.map(item -> item + "a")
.forEach(System.out::println);
/*
* 출력
* 2a
*/
이렇게 Stream을 통해 위의 MyCollection에서 직접 구현했던 것처럼 고차함수를 사용한 간결한 코드가 됐다. 이렇게까지만 보면 MyCollection과 다를게 없어보인다. 무엇이 다른걸까?? 위에서 간단하게 봤던 것처럼 Stream은 ReferencePipeline, 즉, 참조의 파이프라인이다. 데이터의 묶음으로 전부 수행하는 것이 아닌 데이터 파이프라인을, 데이터 파편들이 하나씩 흘러가면서 수행이된다. 그리고 stream을 만들 때 parallel이라는 boolean 값이 보인다. stream을 직렬이 아닌, 병렬로 동시에 사용할 수도 있다는 뜻이다.
Stream을 통해 사용할 수 있는 고차함수들은 공식 문서를 보면 알 수 있다. 너무 많아서 다 돌아보기 힘들 정도이다.
Collection이 아니지만 데이터의 묶음인 원시적인 타입이 있다. 바로 배열이다. 배열은 Collection 인터페이스를 상속받거나 구현한 자료구조가 아니지만 Stream을 사용할 수 있다.
int[] arr = new int[]{ 1, 2, 3 };
IntStream strm = Arrays.stream(arr)
.filter((item) -> item % 2 == 0);
strm.forEach(System.out::println);
/*
* 출력
* 2
*/
Stream은 Generic으로 되어있는 인터페이스이기 때문에 원시타입이 들어올 수 없다. 하지만 원시타입인 int의 배열로 Stream을 만들면 IntStream이라는 특별한 Stream으로 만들어져 사용할 수 있다. 이를 IntStream이 아닌 래퍼 클래스의 Integer의 Stream으로 만드는 방법은 간단하다. 원시타입을 래퍼 클래스로 바꾸는 것을 박싱, 반대를 언박싱이라고 하는데 이처럼 .boxed()
를 사용해서 간단하게 Integer의 Stream을 만들 수 있다.
int[] arr = new int[]{ 1, 2, 3 };
Arrays.stream(arr)
.boxed()
.filter(item -> item % 2 == 0)
.map(item -> item + "a")
.forEach(System.out::println);
그러면 이 같은 Stream을 다시 Collection등의 자료구조로 만들기 위해서는 어떻게 해야될까? 이러한 기능마저 Stream API는 제공해준다.
int[] arr = new int[]{ 1, 2, 3 };
List<Integer> list = Arrays.stream(arr)
.boxed()
.toList();
Object[] resultArr = Arrays.stream(arr)
.boxed()
.toArray();
정말 간단하게 List와 Array로 만들었다. 여기서 toArray()
를 사용하고 반환되는 클래스는 Object의 배열이다. 어떤 형식의 배열을 원하는지 확실히 전달해주어야 한다.
int[] arr = new int[]{ 1, 2, 3 };
Integer[] resultArr = Arrays.stream(arr)
.boxed()
.toArray(Integer[]::new);// item -> new Integer[](item)
이 때 메서드 레퍼런스를 사용할 수도 있다. 하지만 원시형이 아닌 참조형의 Integer를 사용해야한다.
이번엔 List를 Map 자료구조로 Stream을 이용해 변환하는 예제를 한 번 작성해보겠다.
List<Strin> list = new ArrayList<>(Arrays.asList("a", "ab", "abc"));
Map<Integer, String> map = list.stream()
.collect(Collectors.toUnmodifiableMap(String::length, Function.identity());
이렇게 유기적으로 어떤 동작을 수행할지 부여하는 개발자의 역량에 따라 정말 다채롭게 사용할 수 있다.
Stream은 인터페이스이기 때문에 new 키워드로 인스턴스를 생성할 수 없다. 그렇다고 Collection을 반드시 만들어서 하거나 위에 봤던 ReferencePipeline 등의 복잡한 구현체로 어렵게 할 필요도 없다. Stream을 만드는 간단한 두가지 방법이 있다.
Stream<Integer> stream = Stream.generate(() -> 1);
Stream의 정적 메서드인 generate를 통해 만들 수 있다. 함수형 인터페이스를 주의 깊게 봤다면 예측 했겠지만, 해당 람다 표현식은 Supplier의 람다 표현식이다. Stream.generate()
는 어떠한 값을 공급해주는 Supplier를 이용해 만들 수 있다. 이렇게 하면 1이 만들어질 것 같은 기분이 드는데 얼마나, 언제까지 만들어질까? Stream의 forEach를 이용해서 출력해보면 다음과 같다.
Stream.generate(() -> 1)
.forEach(System.out::println);
/*
* 출력
* 1
* 1
* 1
* 1
* 1
* 1
* ...
*/
1이 끝없이 만들어진다. generate함수를 통해 연속적인 데이터를 무한정으로 흘린다. 이를 제한하기 위해선 .limit()
을 사용할 수 있다.
Random random = new Random();
Stream.generate(random::nextInt)
.limit(10)
.forEach(System.out::println);
/*
* 출력
* 1061398370
* -1508450147
* -2023017727
* 1152076952
* 1721239407
* 400077550
* 1125807549
* 1028117856
* 734294618
* 1574495689
*/
랜덤 클래스로부터 무작위의 Integer를 만들어 10개의 데이터 흐름을 만들고 출력하는 코드다. 다른 방식으로도 Stream을 만들 수 있다.
Stream.iterate(0, i -> i + 1)
.limit(10)
.forEach(System.out::println);
/*
* 출력
* 0
* 1
* 2
* 3
* 4
* 5
* 6
* 7
* 8
* 9
*/
Stream.iterate()
를 이용해서 만들 수 있다. generate와의 차이는, 초기값의 유무다. iterate는 seed라고 불리는 초기값을 먼저 설정해 seed를 시작으로 Supplier에 넣어 원하는 값을 계속 만든다. 반면 generate는 연속적으로 사용할 수 있는 기준값 없이 생성한다.
데이터의 묶음인 Collection, 개별적인 데이터를 다루는 Iterator, 개별적으로 다루며 고차함수를 사용한 Stream 등을 이렇게 조금 알아봤다. 이젠 마지막으로 null이라는 데이터를 취급하는 방식에 대해서 알아보겠다.
Optional
Null이란 것은 데이터의 부재를 나타낸다. 0이나 빈 Collection, 빈 문자열 등이 아닌 그냥 없다는 의미이다. Null의 아스키 코드는 0이다. 이는 문자열 0이나 숫자 0과 다르다. 실제로 문자 0은 48의 아스키 코드에 매칭된다. Java에는 NullPointerException, NPE라고도 불리는 예외가 있다. 이는 가장 많이 발생하는 예외 중 하나이다. Java에서는 거의 모든 것이 클래스와 인스턴스를 가리키는 레퍼런스로 되어 있고, 부재를 가리키고 있는 레퍼런스는 Null을 가리키고 있다는 뜻이기에 해당 레퍼런스로 어떤 타입의 인스턴스에 접근하려면 NullPointerException이 발생한다. 그렇기 때문에 null인지 아닌지 항상 확인할 필요가 있다.
그렇기 때문에 null을 피하기 위해 개발자들은 서로 null을 쓰지 않기로 약속했다. 이를 계약을 했다고 하고, 계약 기반 프로그래밍이라고도 한다. null을 피하기 위해 이런 코드를 본적이 있을 수도 있다.
Assert.notNull();
애초부터 null을 막기 위해 null이 들어오면 안 되는 곳에 들어올 시 NPE가 아닌 다른 예외를 발생시키는 것이다. 이것이 NPE를 발생시키는 것과의 차이는 개발자가 의도한 예외인지 아닌지 구분이 되기에 사용자가 예외에 대한 이유를 더 명확하게 알 수 있다. 저런 코드는 Java의 프레임워크인 Spring에서 많이 사용된다.
특정 프레임워크에서 제공되는 방법이 아닌 Java만의 방법이 있다. 바로 Optional 클래스를 이용하는 것이다. 저 위에 만들었던 User를 다시 보자.
public class User {
String name;
int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public boolean isAdult() {
return age >= 20;
}
@Override
public String toString() {
return "[name: " + name + ", age: " + age + "]";
}
}
레퍼런스 타입인 Integer에는 null을 할당할 수 있다. 이렇게 null이 할당된 변수의 메서드를 실행하면 NPE가 발생한다. 그렇기 때문에 예기치 못한 NPE를 피하기 위해서 Java 개발자들은 null이 들어오지 않도록 약속했지만, 만약 개발자가 초기값으로 아무 값도 지정하지 않고 해당 변수를 나중에 결정하는 코드를 작성하고자 한다면 할당될 초기값은 null이 제일 적절했다.
public class Main {
public static void main(String[] args) {
User user = null;
user = getUser("abc", 20);
}
static User getUser(String name, int age) {
return new User(name, age);
}
}
초기값으로 0 등을 줄 수도 있겠다고 생각할 수도 있지만, 0도 결국 실제로 유의미한 데이터일 수도 있기 때문에 개발 시 0은 무의미한 데이터라고 공공연하게 선언해놓거나 null을 사용하는 것이 더 나은 선택이었다. 나이가 0이고 이름이 빈 칸인 User를 유효하지 않은 데이터로 정의해보자면 다음과 같다.
public class User {
public static final User EMPTY = new User("", 0);
String name;
int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public boolean isAdult() {
return age >= 20;
}
@Override
public String toString() {
return "[name: " + name + ", age: " + age + "]";
}
}
0살이고 이름이 없는 사용자가 유효하지 않은 초기값이라는 것을 User 클래스로 정의된 모든 인스턴스가 알게 하기 위해 static으로 선언해두어 공공연한 약속을 프로그램 속에서 만들었다. 이를 이용해 null 대신 약속된 초기값을 아래와 같이 사용할 수 있다.
public class Main {
public static void main(String[] args) {
User user = EMPTY;
if (user == EMPTY) {
user = getUser();
}
}
static User getUser() {
return new User("abc", 20);
}
}
하지만 역시, 0살이고 이름이 없는 User가 의미있는 데이터로 활용될 가능성도 있다. 역시 초기값으로 데이터의 부재를 의미하는 null이 더욱 적절했다. 게다가 User.EMPTY
가 약속이지만, 만약 User에 접근하기가 힘들거나 EMPTY
의 존재를 모르며 개발하는 동료는 이를 모르고 버그를 만들 수도 있다.
그래서 NPE는 피하면서 null을 가능하게 하기 위해 운반책을 마련했다. 바로 Optional이다. null을 사용해 다음과 같이 초기화 할 수 있다. Optional은 세가지 방식으로 만들 수 있다.
public class Main {
public static void main(String[] args) {
Optional<User> empty = Optional.empty();
Optional<User> optionalUser = Optional.of(new User("abc", 20));
Optional<User> nullableUser = Optional.ofNullable(null);
}
}
Optional은 원하는 클래스의 객체를 운반하는 운반책이다. 만약 운반하는 무언가가 없다면 Optional 안에는 비었을 것이고, 그러면 Optional은 null을 반환한다. Optional에 어떤 인스턴스를 담고자 하면 of를 사용할 수 있다. 만약 이 인스턴스가 null일 지도 모른다면 ofNullable을 사용해서 Optional을 만들 수 있다.
Optional을 어떻게 사용할 수 있기에 null의 문제를 해결할 수 있었을까? NPE는 예기치 못한 상황에서 null을 만나 개발자들을 고생시켰다. 의도적으로 null의 가능성을 생각해야 하는 변수라면 Optional을 사용해 확인해야하는 코드를 작성해야한다. 예기치 못한 null을 피하기 위해서다.
public class Main {
public static void main(String[] args) {
Optional<User> user = Optional.ofNullable(null);
if (user.isEmpty()) {
user = Optional.of(getUser());
}
if (user.isPresent()) {
System.out.println(user.get());
}
}
static User getUser() {
return new User("abc", 20);
}
}
/*
* 출력
* [name: abc, age: 20]
*/
Optional 안에 인스턴스가 들었을 때와 비었을 때를 분기로 원하는 작업을 수행할 수 있다. 이는 함수형 인터페이스와 메서드 체이닝을 통해 개선될 수 있다. 만약 Optional이 인스턴스를 운반하고 있다면 원하는 액션을 곧바로 할 수 있는 방법은 아래와 같다.
public class Main {
public static void main(String[] args) {
Optional.ofNullable(getUser())
.ifPresent(System.out::println);
Optional.empty()
.ifPresentOrElse(System.out::println, () -> {
throw new RuntimeException();
}
}
static User getUser() {
return new User("abc", 20);
}
}
.ifPresent
메서드는 함수형 인터페이스인 Consumer를 인자로 받는다. 원하는 작업을 즉각적으로 실행해주는 함수다. Optional의 사용으로 인스턴스가 있을 수도 있고 없을 수도 있다는 정보가 포함된 컨텍스트가 포함되어 있기 때문에, 해당 Optional을 확인해야하는 입장에선 이에 대한 처리를 해야한다는 점을 확실하게 인지하게 된다. Optional에 대한 간략한 정보는 여기까지만 하고, 다른 포스트에 Optional에 관해 더 기록할 생각이다.
References
프로그래머스 데브코스 - 프레임워크를 위한 Java
'Java' 카테고리의 다른 글
자바 인터페이스 이야기 - 사용 이유 (0) | 2024.05.28 |
---|---|
빌드툴 없이 Java 프로젝트 빌드 해보기 (0) | 2023.11.29 |
Java, JVM, JRE, JDK (2) | 2023.11.29 |