프로그램은 대부분 여러 데이터를 저장하고 조회하고 수정하며 삭제하는 작업을 반복한다.
회원 관리 프로그램에서는 여러 회원을 저장해야 하고, 쇼핑몰에서는 상품과 주문 목록을 관리해야 한다. 게시판에서는 여러 게시글을 저장하고 검색해야 한다.
여러 데이터를 저장하는 가장 기본적인 방법은 배열이다.
String[] names = new String[3];
names[0] = "김자바";
names[1] = "이자바";
names[2] = "박자바";
하지만 배열은 생성할 때 길이가 결정되고 이후 크기를 변경할 수 없다. 새로운 데이터를 추가하려면 더 큰 배열을 만들고 기존 데이터를 복사해야 한다.
String[] oldNames = {
"김자바",
"이자바",
"박자바"
};
String[] newNames = new String[5];
System.arraycopy(
oldNames,
0,
newNames,
0,
oldNames.length
);
데이터의 개수가 계속 변하는 프로그램에서 배열을 직접 관리하는 것은 번거롭다.
Java는 이러한 문제를 해결하기 위해 여러 데이터를 효율적으로 저장하고 처리할 수 있는 컬렉션 프레임워크를 제공한다.
이번 글에서는 컬렉션 프레임워크의 기본 구조와 List, Set, Map의 차이를 살펴본다. 이어서 정렬, 불변 컬렉션, 람다 표현식, 표준 함수형 인터페이스, Optional, Stream API까지 함께 정리해보려고 한다.
자료구조는 데이터를 효율적으로 저장하고 접근하고 수정하기 위한 데이터의 조직 방식이다.
단순히 여러 값을 보관하는 것뿐 아니라 다음 내용까지 포함한다.
동일한 데이터를 저장하더라도 자료구조에 따라 작업 성능이 달라질 수 있다.
예를 들어 인덱스로 특정 요소를 빠르게 조회하려면 배열 기반 구조가 유리하다. 중간에 데이터를 자주 추가하거나 삭제한다면 연결 구조가 더 적합할 수 있다.
따라서 무조건 좋은 자료구조가 있는 것이 아니라, 어떤 작업을 자주 수행하는지에 따라 적합한 자료구조를 선택해야 한다.
배열은 같은 타입의 여러 값을 저장하는 가장 기본적인 자료구조다.
int[] numbers = {10, 20, 30};
배열에는 다음과 같은 특징이 있다.
배열의 길이를 동적으로 변경할 수 없다는 점은 가장 큰 불편함이다.
컬렉션은 내부적으로 배열이나 연결 구조 등을 사용하지만 개발자가 크기 변경과 데이터 관리를 직접 처리하지 않아도 된다.
List<String> names = new ArrayList<>();
names.add("김자바");
names.add("이자바");
names.add("박자바");
컬렉션은 데이터가 추가되면 필요한 저장 공간을 내부적으로 관리한다.
모든 객체는 Object를 상속하므로 Object[]를 사용하면 여러 종류의 객체를 함께 저장할 수 있다.
Object[] values = new Object[3];
values[0] = "Java";
values[1] = 100;
values[2] = new Member("김자바");
값을 저장할 때는 편리하지만 꺼낼 때는 Object로 반환되기 때문에 강제 타입 변환이 필요하다.
String language = (String) values[0];
Integer number = (Integer) values[1];
잘못된 타입으로 변환하면 실행 중 ClassCastException이 발생한다.
제네릭을 사용하면 컬렉션이 저장할 타입을 컴파일 단계에서 제한할 수 있다.
List<String> languages = new ArrayList<>();
languages.add("Java");
// languages.add(100);
// 컴파일 오류
값을 꺼낼 때도 형변환이 필요 없다.
String language = languages.get(0);
컬렉션 프레임워크는 다수의 데이터를 저장하고 처리하기 위한 인터페이스와 구현 클래스의 집합이다.
주요 클래스와 인터페이스는 java.util 패키지에 포함되어 있다.
Collection
├─ List
│ ├─ ArrayList
│ └─ LinkedList
│
└─ Set
├─ HashSet
└─ TreeSet
Map
├─ HashMap
└─ TreeMap
List와 Set은 Collection 인터페이스를 상속한다.
Map은 Key와 Value의 쌍을 관리하는 구조이기 때문에 Collection과는 별도의 계층으로 존재한다.
| 구분 | 데이터 구조 | 순서 | 중복 |
|---|---|---|---|
List | 값을 순서대로 관리 | 유지됨 | 허용 |
Set | 중복되지 않는 값 관리 | 일반적으로 보장되지 않음 | 허용하지 않음 |
Map | Key와 Value의 쌍 관리 | 구현체에 따라 다름 | Key 중복 불가, Value 중복 가능 |
List<String> list = new ArrayList<>();
Set<String> set = new HashSet<>();
Map<String, Integer> map = new HashMap<>();
List와 Set은 Collection 인터페이스를 상속하므로 공통 메서드를 사용할 수 있다.
| 분류 | 대표 메서드 | 기능 |
|---|---|---|
| 추가 | add(E e) | 요소 하나 추가 |
| 추가 | addAll(Collection<? extends E> c) | 다른 컬렉션의 모든 요소 추가 |
| 조회 | contains(Object o) | 특정 요소 포함 여부 확인 |
| 조회 | isEmpty() | 비어 있는지 확인 |
| 조회 | size() | 요소 개수 반환 |
| 조회 | iterator() | 반복자 반환 |
| 삭제 | remove(Object o) | 특정 요소 삭제 |
| 삭제 | removeAll(Collection<?> c) | 전달받은 요소들을 삭제 |
| 삭제 | clear() | 모든 요소 삭제 |
| 변환 | toArray() | 배열로 변환 |
import java.util.ArrayList;
import java.util.List;
public class CollectionMethodExample {
public static void main(String[] args) {
List<String> languages = new ArrayList<>();
languages.add("Java");
languages.add("Python");
languages.add("JavaScript");
System.out.println(languages.size());
System.out.println(languages.contains("Java"));
System.out.println(languages.isEmpty());
languages.remove("Python");
System.out.println(languages);
languages.clear();
System.out.println(languages.isEmpty());
}
}
List는 입력한 순서를 유지하고 중복된 요소를 허용한다.
List<String> names = new ArrayList<>();
names.add("김자바");
names.add("이자바");
names.add("김자바");
결과는 다음과 같다.
[김자바, 이자바, 김자바]
동일한 값이 두 번 저장되어도 모두 유지된다.
순서가 있기 때문에 인덱스로 요소에 접근할 수 있다.
String first = names.get(0);
| 메서드 | 기능 |
|---|---|
add(E e) | 마지막에 요소 추가 |
add(int index, E e) | 지정된 위치에 요소 추가 |
get(int index) | 지정된 위치의 요소 조회 |
set(int index, E e) | 지정된 위치의 요소 변경 |
remove(int index) | 지정된 위치의 요소 삭제 |
indexOf(Object o) | 요소의 첫 번째 인덱스 반환 |
lastIndexOf(Object o) | 요소의 마지막 인덱스 반환 |
import java.util.ArrayList;
import java.util.List;
public class ListMethodExample {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("김자바");
names.add("박자바");
names.add(1, "이자바");
System.out.println(names);
String firstName = names.get(0);
System.out.println(firstName);
names.set(0, "김Java");
names.remove(2);
System.out.println(names);
}
}
ArrayList는 배열을 기반으로 구현된 List다.
List<String> names = new ArrayList<>();
내부적으로 객체 배열을 사용하고, 저장 공간이 부족해지면 더 큰 배열을 생성한 뒤 기존 요소를 복사한다.
개념적으로 다음과 같은 구조다.
ArrayList
index 0 1 2
┌────────┬────────┬────────┐
value │ "Java" │ "SQL" │ "HTML" │
└────────┴────────┴────────┘
배열 기반이므로 인덱스를 이용한 조회가 빠르다.
names.get(1);
반면 배열의 중간에 요소를 추가하거나 삭제하면 뒤에 있는 요소들을 이동해야 한다.
삭제 전
[A][B][C][D]
B 삭제
[A][C][D][ ]
C와 D를 앞으로 이동
저장할 데이터 개수를 어느 정도 알고 있다면 초기 용량을 지정할 수 있다.
List<String> names = new ArrayList<>(100);
이 코드는 리스트의 크기를 100으로 만드는 것이 아니다.
요소를 저장할 내부 공간의 초기 용량을 설정한다.
System.out.println(names.size());
결과는 여전히 0이다.
초기 용량을 적절히 지정하면 배열을 반복해서 확장하고 복사하는 비용을 줄일 수 있다. 다만 필요 이상으로 크게 지정하면 메모리를 낭비할 수 있다.
LinkedList는 각 요소가 앞뒤 요소의 정보를 가지고 연결된 구조다.
Java의 LinkedList는 이중 연결 리스트로 구현되어 있다.
null ← [A] ⇄ [B] ⇄ [C] → null
각 노드는 개념적으로 다음 정보를 가진다.
이전 노드 참조
현재 데이터
다음 노드 참조
List<String> names = new LinkedList<>();
배열처럼 모든 요소가 연속된 메모리 공간에 저장될 필요가 없다.
요소의 위치를 이미 알고 있다면 주변 링크를 변경하여 추가하거나 삭제할 수 있다.
A ⇄ B ⇄ C
B 삭제
A ⇄ C
다만 특정 인덱스의 요소를 찾으려면 처음이나 끝에서부터 연결을 따라 이동해야 하므로 인덱스 조회는 ArrayList보다 느릴 수 있다.
| 작업 | ArrayList | LinkedList |
|---|---|---|
| 인덱스 조회 | 빠름 | 상대적으로 느림 |
| 마지막 요소 추가 | 일반적으로 빠름 | 빠름 |
| 중간 요소 추가·삭제 | 요소 이동 필요 | 노드 탐색 후 연결 변경 |
| 메모리 사용 | 요소 배열 중심 | 노드별 참조 정보 추가 |
| 캐시 효율 | 좋음 | 상대적으로 낮음 |
이론적으로는 중간 삽입과 삭제가 LinkedList에 유리해 보이지만, 실제 작업에서는 먼저 해당 위치를 찾는 탐색 비용이 발생한다.
따라서 일반적인 Java 애플리케이션에서는 ArrayList를 기본 선택으로 사용하는 경우가 많다.
앞뒤에서 요소를 자주 추가하거나 제거하는 큐나 덱 구조가 필요하다면 LinkedList보다 ArrayDeque가 더 적합한 경우도 많다.
| 작업 | ArrayList | LinkedList |
|---|---|---|
| 인덱스 조회 | O(1) | O(n) |
| 값 검색 | O(n) | O(n) |
| 마지막 추가 | 평균 O(1) | O(1) |
| 중간 추가·삭제 | O(n) | 탐색 O(n), 연결 변경 O(1) |
| 첫 요소 추가·삭제 | O(n) | O(1) |
ArrayList의 마지막 추가는 저장 공간 확장이 필요한 순간에는 배열 복사로 O(n)이 걸리지만, 여러 연산을 평균 내면 상각 O(1)로 본다.
인덱스를 증가시키면서 요소를 삭제하면 다음 요소의 인덱스가 앞으로 당겨져 검사하지 못하는 요소가 생길 수 있다.
import java.util.ArrayList;
import java.util.List;
public class RemoveExample {
public static void main(String[] args) {
List<Integer> numbers =
new ArrayList<>(
List.of(1, 3, 5, 2, 4)
);
for (int i = 0; i < numbers.size(); i++) {
if (numbers.get(i) % 2 == 1) {
numbers.remove(i);
}
}
System.out.println(numbers);
}
}
1을 삭제하면 뒤의 3이 인덱스 0으로 이동하지만 i는 1로 증가하므로 3을 확인하지 못할 수 있다.
뒤에서부터 순회하면 인덱스가 밀리는 문제를 피할 수 있다.
for (int i = numbers.size() - 1; i >= 0; i--) {
if (numbers.get(i) % 2 == 1) {
numbers.remove(i);
}
}
메모에 적힌 조건은 다음처럼 수정해야 한다.
i >= 0
i <= 0을 사용하면 마지막 인덱스가 양수인 경우 반복문이 시작되지 않는다.
컬렉션을 순회하면서 요소를 삭제해야 한다면 Iterator를 사용할 수 있다.
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class IteratorRemoveExample {
public static void main(String[] args) {
List<Integer> numbers =
new ArrayList<>(
List.of(1, 2, 3, 4, 5)
);
Iterator<Integer> iterator =
numbers.iterator();
while (iterator.hasNext()) {
int number = iterator.next();
if (number % 2 == 1) {
iterator.remove();
}
}
System.out.println(numbers);
}
}
Iterator로 순회 중일 때는 컬렉션의 remove()가 아니라 iterator.remove()를 사용해야 한다.
조건에 맞는 요소를 삭제할 때는 removeIf()가 더 간결하다.
List<Integer> numbers =
new ArrayList<>(
List.of(1, 2, 3, 4, 5)
);
numbers.removeIf(
number -> number % 2 == 1
);
System.out.println(numbers);
실행 결과는 다음과 같다.
[2, 4]
다음 코드는 실행 중 ConcurrentModificationException을 발생시킬 수 있다.
for (Integer number : numbers) {
if (number % 2 == 1) {
numbers.remove(number);
}
}
향상된 for문은 내부적으로 Iterator를 사용한다. 순회 중 컬렉션의 구조가 예상하지 못하게 변경되면 반복자가 안전하게 동작할 수 없기 때문에 예외가 발생한다.
요소를 삭제하려면 다음 방법을 사용한다.
Iterator.remove()removeIf()removeAll()List<Integer>에서 remove()를 사용할 때 특히 주의해야 한다.
List<Integer> numbers =
new ArrayList<>(
List.of(10, 20, 30)
);
numbers.remove(1);
1은 삭제할 값이 아니라 인덱스로 해석된다.
결과는 다음과 같다.
[10, 30]
값 10을 삭제하고 싶다면 Integer 객체로 전달해야 한다.
numbers.remove(Integer.valueOf(10));
SpiderMan이 Person의 자식이라고 가정해보자.
public class Person {
}
public class SpiderMan extends Person {
}
배열은 다음 대입이 가능하다.
SpiderMan[] spiderMen =
new SpiderMan[3];
Person[] people = spiderMen;
배열은 공변이기 때문이다.
하지만 제네릭은 불공변이므로 다음 대입은 불가능하다.
List<SpiderMan> spiderMen =
new ArrayList<>();
// List<Person> people = spiderMen;
// 컴파일 오류
List<SpiderMan>은 List<Person>의 하위 타입이 아니다.
여러 자식 타입을 읽기 위한 목적이라면 와일드카드를 사용할 수 있다.
List<? extends Person> people = spiderMen;
Arrays.asList()는 배열을 기반으로 고정 크기의 리스트를 만든다.
import java.util.Arrays;
import java.util.List;
public class ArraysAsListExample {
public static void main(String[] args) {
List<String> names =
Arrays.asList(
"김자바",
"이자바",
"박자바"
);
names.set(0, "김Java");
System.out.println(names);
}
}
요소 변경은 가능하다.
names.set(0, "김Java");
그러나 크기를 변경하는 작업은 불가능하다.
// names.add("최자바");
// UnsupportedOperationException
// names.remove("이자바");
// UnsupportedOperationException
원본 배열과 리스트가 연결되어 있다는 점도 주의해야 한다.
String[] values = {"A", "B"};
List<String> list =
Arrays.asList(values);
values[0] = "Changed";
System.out.println(list);
결과는 다음과 같다.
[Changed, B]
독립적으로 수정 가능한 ArrayList가 필요하다면 새 리스트로 복사한다.
List<String> names =
new ArrayList<>(
Arrays.asList(
"김자바",
"이자바"
)
);
Set은 중복된 요소를 허용하지 않는 컬렉션이다.
Set<String> languages =
new HashSet<>();
languages.add("Java");
languages.add("Python");
languages.add("Java");
"Java"를 두 번 추가했지만 한 번만 저장된다.
[Java, Python]
HashSet은 입력 순서를 보장하지 않는다.
System.out.println(languages);
출력 순서는 저장 순서와 다를 수 있다.
순서가 필요하면 목적에 따라 다음 구현체를 고려할 수 있다.
LinkedHashSet: 입력 순서 유지TreeSet: 정렬된 순서 유지HashSet은 해시값을 이용해 데이터를 저장하고 검색한다.
Set<Member> members =
new HashSet<>();
내부적으로는 HashMap을 사용하며, 저장할 객체를 Key처럼 관리한다.
해시는 임의 길이의 데이터를 일정한 규칙에 따라 고정된 범위의 값으로 변환하는 방식이다.
Java 객체는 hashCode() 메서드로 정수 형태의 해시 코드를 반환한다.
String value = "Java";
System.out.println(value.hashCode());
해시 기반 자료구조는 해시 코드를 이용해 데이터가 저장될 위치를 빠르게 찾는다.
개념적인 과정은 다음과 같다.
객체
→ hashCode()
→ 저장 위치 계산
→ 해당 위치에서 equals()로 최종 비교
서로 다른 객체가 같은 해시값이나 같은 저장 위치를 가질 수 있다. 이를 해시 충돌이라고 한다.
해시 코드는 int 범위의 값이지만 실제로 수십억 개의 배열을 생성할 수는 없다. 내부 배열의 크기에 맞게 저장 위치를 계산하기 때문에 서로 다른 해시 코드도 같은 위치에 배정될 수 있다.
Java의 HashMap은 같은 위치에 여러 요소가 배정되면 연결 구조로 관리한다. 충돌이 많이 발생하고 일정 조건을 만족하면 Java 8부터 일부 구간을 트리 구조로 전환해 검색 성능이 지나치게 떨어지는 것을 줄인다.
따라서 HashSet과 HashMap이 항상 단순한 연결 리스트만 사용하는 것은 아니다.
HashSet은 일반적으로 다음 두 조건을 이용해 논리적으로 같은 객체인지 판단한다.
hashCode()가 같음
그리고
equals()가 true
회원 전화번호가 같으면 같은 회원으로 판단하는 예제를 만들어보자.
import java.util.Objects;
public class SmartPhone {
private final String number;
private final String model;
public SmartPhone(
String number,
String model
) {
this.number = number;
this.model = model;
}
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof SmartPhone phone)) {
return false;
}
return Objects.equals(
number,
phone.number
);
}
@Override
public int hashCode() {
return Objects.hash(number);
}
@Override
public String toString() {
return "SmartPhone{"
+ "number='" + number + '\''
+ ", model='" + model + '\''
+ '}';
}
}
import java.util.HashSet;
import java.util.Set;
public class HashSetExample {
public static void main(String[] args) {
Set<SmartPhone> phones =
new HashSet<>();
phones.add(
new SmartPhone(
"010-1111-2222",
"Model A"
)
);
phones.add(
new SmartPhone(
"010-1111-2222",
"Model B"
)
);
System.out.println(phones.size());
System.out.println(phones);
}
}
전화번호를 기준으로 equals()와 hashCode()를 재정의했으므로 두 객체를 같은 데이터로 판단한다.
1
equals()를 재정의할 때는 반드시 동일한 필드 기준으로 hashCode()도 함께 재정의해야 한다.
Map은 Key와 Value의 쌍으로 데이터를 관리한다.
Key Value
"apple" → "사과"
"banana" → "바나나"
"grape" → "포도"
하나의 Key와 Value 쌍을 Entry라고 부른다.
Key는 중복될 수 없지만 Value는 중복될 수 있다.
Map<String, Integer> scores =
new HashMap<>();
scores.put("김자바", 90);
scores.put("이자바", 90);
점수 90은 중복될 수 있다.
같은 Key로 값을 다시 저장하면 기존 값이 교체된다.
scores.put("김자바", 100);
| 메서드 | 기능 |
|---|---|
put(K key, V value) | Key와 Value 저장 |
get(Object key) | Key에 해당하는 Value 조회 |
getOrDefault(Object key, V defaultValue) | 값이 없으면 기본값 반환 |
containsKey(Object key) | Key 존재 여부 확인 |
containsValue(Object value) | Value 존재 여부 확인 |
remove(Object key) | Key에 해당하는 Entry 삭제 |
keySet() | 모든 Key를 Set으로 반환 |
values() | 모든 Value를 Collection으로 반환 |
entrySet() | 모든 Entry를 Set으로 반환 |
size() | Entry 개수 반환 |
clear() | 모든 Entry 삭제 |
import java.util.HashMap;
import java.util.Map;
public class HashMapExample {
public static void main(String[] args) {
Map<String, Integer> scores =
new HashMap<>();
scores.put("김자바", 90);
scores.put("이자바", 85);
scores.put("박자바", 95);
int kimScore = scores.get("김자바");
System.out.println(kimScore);
scores.put("김자바", 100);
System.out.println(scores);
scores.remove("이자바");
System.out.println(scores);
}
}
get()으로 존재하지 않는 Key를 조회하면 null이 반환된다.
Integer score = scores.get("없는 회원");
기본값이 필요하다면 getOrDefault()를 사용할 수 있다.
int score =
scores.getOrDefault(
"없는 회원",
0
);
Key와 Value를 함께 사용할 때는 entrySet()이 편리하다.
for (
Map.Entry<String, Integer> entry
: scores.entrySet()
) {
System.out.println(
entry.getKey()
+ ": "
+ entry.getValue()
);
}
람다를 사용하면 더 간결하게 작성할 수 있다.
scores.forEach(
(name, score) ->
System.out.println(
name + ": " + score
)
);
HashMap은 내부적으로 Key의 hashCode()를 이용해 저장 위치를 계산한다.
개념적으로 다음과 같은 구조를 가진다.
내부 배열
index 0 → Entry
index 1 → Entry → Entry
index 2 → 비어 있음
index 3 → Entry
Entry에는 Key, Value, 해시 정보, 다음 Entry를 가리키는 정보 등이 포함된다.
Key의 중복을 판단할 때도 hashCode()와 equals()가 사용된다.
따라서 사용자 정의 객체를 HashMap의 Key로 사용할 때는 논리적인 동등성 기준에 맞게 두 메서드를 재정의해야 한다.
또한 Key로 사용하는 객체의 비교 기준 필드는 저장 후 변경하지 않는 것이 안전하다. Key의 해시값이 변경되면 저장된 데이터를 다시 찾지 못할 수 있다.
HashSet은 내부적으로 HashMap을 이용한다.
Set에 저장할 요소를 HashMap의 Key로 사용하고, Value에는 의미 없는 공통 객체를 저장하는 방식이다.
HashSet 요소
→ HashMap의 Key
내부 Value
→ 공통 더미 객체
이 때문에 HashSet도 HashMap과 마찬가지로 hashCode()와 equals()를 이용해 중복을 판단한다.
Java 9부터 List.of(), Set.of(), Map.of()를 이용해 변경할 수 없는 컬렉션을 만들 수 있다.
List<String> languages =
List.of(
"Java",
"Python",
"JavaScript"
);
다음 작업은 모두 불가능하다.
// languages.add("C++");
// languages.remove("Java");
// languages.set(0, "Kotlin");
실행하면 UnsupportedOperationException이 발생한다.
List<String> list =
List.of("A", "B", "C");
Set<String> set =
Set.of("A", "B", "C");
Map<String, Integer> map =
Map.of(
"A", 10,
"B", 20
);
이 컬렉션들은 null을 허용하지 않는다.
// List.of("A", null);
// NullPointerException
Set.of()에는 중복값을 전달할 수 없다.
// Set.of("A", "A");
// IllegalArgumentException
Map.of()도 중복된 Key를 허용하지 않는다.
List.of()로 만든 데이터를 수정해야 한다면 새 컬렉션으로 복사한다.
List<String> languages =
new ArrayList<>(
List.of(
"Java",
"Python"
)
);
languages.add("JavaScript");
| 구분 | Arrays.asList() | List.of() |
|---|---|---|
| 크기 변경 | 불가능 | 불가능 |
요소 변경 set() | 가능 | 불가능 |
null 요소 | 허용 | 허용하지 않음 |
| 원본 배열 연결 | 연결됨 | 해당 없음 |
| 도입 버전 | 초기 Java부터 | Java 9 |
Collections.sort()를 이용해 리스트를 오름차순으로 정렬할 수 있다.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class SortExample {
public static void main(String[] args) {
List<Integer> numbers =
new ArrayList<>(
List.of(5, 2, 4, 1, 3)
);
Collections.sort(numbers);
System.out.println(numbers);
}
}
실행 결과는 다음과 같다.
[1, 2, 3, 4, 5]
List의 sort() 메서드를 사용할 수도 있다.
numbers.sort(null);
numbers.sort(
Collections.reverseOrder()
);
Comparator를 사용하면 다음과 같이 작성할 수도 있다.
numbers.sort(
(left, right) ->
Integer.compare(
right,
left
)
);
회원 객체를 나이순으로 정렬해보자.
public class Member {
private final String name;
private final int age;
public Member(
String name,
int age
) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
@Override
public String toString() {
return name + "(" + age + ")";
}
}
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class MemberSortExample {
public static void main(String[] args) {
List<Member> members =
new ArrayList<>(
List.of(
new Member("김자바", 25),
new Member("이자바", 20),
new Member("박자바", 30)
)
);
members.sort(
Comparator.comparingInt(
Member::getAge
)
);
System.out.println(members);
}
}
나이가 같은 경우 이름으로 추가 정렬할 수 있다.
members.sort(
Comparator
.comparingInt(Member::getAge)
.thenComparing(Member::getName)
);
| 구분 | Comparable | Comparator |
|---|---|---|
| 메서드 | compareTo() | compare() |
| 정렬 기준 | 클래스 내부의 기본 정렬 | 외부에서 다양한 정렬 기준 정의 |
| 패키지 | java.lang | java.util |
| 사용 예 | 이름을 기본 정렬 기준으로 지정 | 나이순, 점수순 등 상황별 정렬 |
클래스 자체에 자연스러운 기본 정렬 기준이 있다면 Comparable을 구현할 수 있다.
다양한 정렬 기준이 필요하다면 Comparator가 더 유연하다.
람다 표현식은 하나의 동작을 간결한 코드 블록으로 표현하는 문법이다.
기존에는 간단한 동작 하나를 전달하기 위해 익명 구현 객체를 만들어야 했다.
Comparator<Integer> comparator =
new Comparator<Integer>() {
@Override
public int compare(
Integer left,
Integer right
) {
return Integer.compare(
left,
right
);
}
};
람다 표현식을 사용하면 다음처럼 줄일 수 있다.
Comparator<Integer> comparator =
(left, right) ->
Integer.compare(
left,
right
);
람다는 메서드 자체가 아니라 함수형 인터페이스의 구현 객체로 해석된다.
추상 메서드가 하나만 존재하는 인터페이스를 함수형 인터페이스라고 한다.
@FunctionalInterface
public interface Calculator {
int calculate(int left, int right);
}
람다 표현식을 구현 객체로 대입할 수 있다.
Calculator add =
(left, right) ->
left + right;
int result = add.calculate(10, 20);
@FunctionalInterface는 해당 인터페이스가 함수형 인터페이스의 조건을 만족하는지 컴파일러가 검사하도록 한다.
default 메서드와 static 메서드는 여러 개 존재해도 상관없다. 추상 메서드만 하나여야 한다.
매개변수가 하나라면 괄호를 생략할 수 있다.
value -> System.out.println(value)
실행문이 하나라면 중괄호를 생략할 수 있다.
(left, right) -> left + right
실행문이 여러 개라면 중괄호를 사용한다.
(left, right) -> {
int result = left + right;
return result;
}
중괄호를 사용하면서 값을 반환하려면 return이 필요하다.
컬렉션 API에는 람다 표현식을 사용할 수 있는 메서드가 많다.
List<String> names =
new ArrayList<>(
List.of(
"김자바",
"이자바",
"박자바"
)
);
names.forEach(
name ->
System.out.println(name)
);
조건에 맞는 요소를 삭제할 수도 있다.
names.removeIf(
name -> name.startsWith("김")
);
모든 요소를 변경할 수도 있다.
names.replaceAll(
String::toUpperCase
);
람다 표현식이 기존 메서드 하나만 호출한다면 :: 연산자를 이용한 메서드 참조로 표현할 수 있다.
names.forEach(
name ->
System.out.println(name)
);
다음처럼 변경할 수 있다.
names.forEach(
System.out::println
);
Function<String, Integer> parser =
Integer::parseInt;
다음 람다와 같다.
Function<String, Integer> parser =
value -> Integer.parseInt(value);
String prefix = "Java";
Predicate<String> checker =
prefix::equals;
다음 람다와 같다.
Predicate<String> checker =
value -> prefix.equals(value);
Function<String, String> converter =
String::toUpperCase;
다음 람다와 같다.
Function<String, String> converter =
value -> value.toUpperCase();
Supplier<StringBuilder> supplier =
StringBuilder::new;
다음 람다와 같다.
Supplier<StringBuilder> supplier =
() -> new StringBuilder();
매개변수가 있는 생성자도 참조할 수 있다.
Function<String, StringBuilder> creator =
StringBuilder::new;
다음 코드는 잘못된 예제다.
// names.forEach(names::add);
names를 순회하면서 같은 names에 다시 데이터를 추가하기 때문에 구조적 변경이 발생한다. ConcurrentModificationException이 발생하거나 의도하지 않은 동작으로 이어질 수 있다.
출력하려면 다음처럼 사용해야 한다.
names.forEach(System.out::println);
다른 컬렉션에 복사하려면 목적지 컬렉션을 별도로 둔다.
List<String> copied =
new ArrayList<>();
names.forEach(copied::add);
Java는 자주 사용하는 함수 형태를 java.util.function 패키지의 표준 인터페이스로 제공한다.
Consumer
Supplier
Function
Operator
Predicate
직접 함수형 인터페이스를 만들 필요 없이 목적에 맞는 표준 인터페이스를 사용할 수 있다.
매개값을 받고 반환값이 없는 형태다.
T → 반환값 없음
Consumer<String> printer =
value ->
System.out.println(value);
printer.accept("Java");
메서드 참조로 표현할 수 있다.
Consumer<String> printer =
System.out::println;
대표 메서드는 accept()다.
매개값은 없고 값을 반환하는 형태다.
매개값 없음 → T
Supplier<StringBuilder> supplier =
StringBuilder::new;
StringBuilder builder = supplier.get();
대표 메서드는 get()이다.
매개값을 받아 다른 값을 반환한다.
T → R
Function<String, Integer> lengthFunction =
String::length;
int length =
lengthFunction.apply("Java");
대표 메서드는 apply()다.
매개값과 반환값의 타입이 같은 함수다.
T → T
하나의 값을 받아 같은 타입으로 반환하는 경우 UnaryOperator를 사용한다.
UnaryOperator<Integer> doubleValue =
number -> number * 2;
System.out.println(
doubleValue.apply(10)
);
두 값을 받아 같은 타입으로 반환하는 경우 BinaryOperator를 사용한다.
BinaryOperator<Integer> add =
Integer::sum;
System.out.println(
add.apply(10, 20)
);
매개값을 받아 boolean을 반환한다.
T → boolean
Predicate<Integer> isEven =
number -> number % 2 == 0;
System.out.println(
isEven.test(10)
);
대표 메서드는 test()다.
필터링과 조건 검사에 많이 사용된다.
| 인터페이스 | 매개변수 | 반환값 | 대표 메서드 |
|---|---|---|---|
Consumer<T> | 있음 | 없음 | accept() |
Supplier<T> | 없음 | 있음 | get() |
Function<T, R> | 있음 | 있음 | apply() |
UnaryOperator<T> | 있음 | 동일 타입 | apply() |
BinaryOperator<T> | 2개 | 동일 타입 | apply() |
Predicate<T> | 있음 | boolean | test() |
Optional<T>은 값이 존재할 수도 있고 존재하지 않을 수도 있는 상황을 표현하는 래퍼 클래스다.
Optional<Member>
메서드가 null을 직접 반환하는 대신 값의 존재 여부를 명시적으로 표현할 수 있다.
import java.util.Optional;
public class MemberRepository {
public Optional<String> findNameById(
long id
) {
if (id == 1L) {
return Optional.of("김자바");
}
return Optional.empty();
}
}
Optional<String> name =
repository.findNameById(1L);
if (name.isPresent()) {
System.out.println(name.get());
}
다만 isPresent()와 get()을 반복해서 사용하는 것보다는 Optional 전용 메서드를 활용하는 것이 좋다.
name.ifPresent(
System.out::println
);
값이 없을 때 기본값을 반환할 수 있다.
String result =
name.orElse("이름 없음");
기본값 생성 비용이 크다면 orElseGet()을 사용할 수 있다.
String result =
name.orElseGet(
() -> createDefaultName()
);
값이 없을 때 예외를 발생시킬 수도 있다.
String result =
name.orElseThrow(
() -> new IllegalArgumentException(
"회원을 찾을 수 없습니다."
)
);
optional.orElse(
createDefaultValue()
);
orElse()에 전달한 코드는 Optional에 값이 있어도 먼저 실행될 수 있다.
optional.orElseGet(
() -> createDefaultValue()
);
orElseGet()은 값이 없을 때만 Supplier를 실행한다.
기본값 생성에 비용이 들거나 부수 효과가 있다면 orElseGet()이 적합하다.
Optional은 주로 메서드 반환값에서 값이 없을 가능성을 표현하기 위해 사용한다.
다음과 같은 사용은 일반적으로 신중해야 한다.
Optional<List<T>>처럼 불필요하게 중첩하기get() 호출하기컬렉션은 결과가 없으면 빈 컬렉션을 반환하는 것이 자연스럽다.
return List.of();
Stream API는 배열과 컬렉션의 요소를 선언적인 방식으로 처리하기 위한 API다.
기존 반복문에서는 처리 절차를 직접 작성한다.
List<Integer> numbers =
List.of(1, 2, 3, 4, 5);
List<Integer> evenNumbers =
new ArrayList<>();
for (Integer number : numbers) {
if (number % 2 == 0) {
evenNumbers.add(number);
}
}
Stream을 사용하면 어떤 작업을 할 것인지 중심으로 작성할 수 있다.
List<Integer> evenNumbers =
numbers.stream()
.filter(
number ->
number % 2 == 0
)
.toList();
Stream 연산은 크게 세 단계로 볼 수 있다.
데이터 소스
→ 중간 연산
→ 최종 연산
numbers.stream()
.filter(number -> number % 2 == 0)
.map(number -> number * 10)
.forEach(System.out::println);
stream(): 스트림 생성filter(): 조건에 맞는 요소 선택map(): 요소 변환forEach(): 결과 소비중간 연산은 새로운 Stream을 반환하며 여러 개 연결할 수 있다.
대표적인 중간 연산은 다음과 같다.
filter()map()sorted()distinct()limit()skip()peek()최종 연산이 호출되어야 실제 처리가 시작된다.
대표적인 최종 연산은 다음과 같다.
forEach()toList()collect()count()reduce()findFirst()anyMatch()allMatch()List<Integer> numbers =
new ArrayList<>(
List.of(5, 2, 4, 1, 3)
);
List<Integer> sortedNumbers =
numbers.stream()
.sorted()
.toList();
System.out.println(numbers);
System.out.println(sortedNumbers);
실행 결과는 다음과 같다.
[5, 2, 4, 1, 3]
[1, 2, 3, 4, 5]
Stream 연산은 원본 컬렉션 자체를 정렬하지 않고 새로운 결과를 만든다.
map()은 각 요소를 다른 값으로 변환한다.
List<String> names =
List.of(
"java",
"spring",
"vue"
);
List<String> upperNames =
names.stream()
.map(String::toUpperCase)
.toList();
reduce()는 여러 요소를 하나의 결과로 합친다.
List<Integer> numbers =
List.of(1, 2, 3, 4, 5);
int sum =
numbers.stream()
.reduce(
0,
Integer::sum
);
System.out.println(sum);
0은 초기값이며 Integer::sum은 두 값을 합치는 함수다.
Map-Reduce 모델은 데이터를 변환한 뒤 결과를 하나로 합치는 처리 방식이다.
원본 데이터
[1, 2, 3, 4]
map
각 값을 제곱
[1, 4, 9, 16]
reduce
모든 값을 합산
30
int result =
List.of(1, 2, 3, 4)
.stream()
.map(
number ->
number * number
)
.reduce(
0,
Integer::sum
);
회원 데이터를 컬렉션에 저장하고 Stream으로 처리해보자.
import java.util.Objects;
public class Member {
private final long id;
private final String name;
private final int age;
private final Grade grade;
public Member(
long id,
String name,
int age,
Grade grade
) {
this.id = id;
this.name = name;
this.age = age;
this.grade = grade;
}
public long getId() {
return id;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public Grade getGrade() {
return grade;
}
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof Member member)) {
return false;
}
return id == member.id;
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return name
+ "("
+ age
+ ", "
+ grade
+ ")";
}
}
public enum Grade {
BASIC,
SILVER,
GOLD
}
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class CollectionExample {
public static void main(String[] args) {
List<Member> members =
new ArrayList<>();
members.add(
new Member(
1L,
"김자바",
25,
Grade.GOLD
)
);
members.add(
new Member(
2L,
"이자바",
20,
Grade.BASIC
)
);
members.add(
new Member(
3L,
"박자바",
30,
Grade.SILVER
)
);
members.add(
new Member(
4L,
"최자바",
22,
Grade.GOLD
)
);
List<String> adultGoldMembers =
members.stream()
.filter(
member ->
member.getAge() >= 20
)
.filter(
member ->
member.getGrade()
== Grade.GOLD
)
.sorted(
Comparator.comparingInt(
Member::getAge
)
)
.map(Member::getName)
.toList();
System.out.println(adultGoldMembers);
Set<Member> memberSet =
new HashSet<>(members);
System.out.println(
"중복 제거 후 회원 수: "
+ memberSet.size()
);
Map<Long, Member> memberMap =
new HashMap<>();
for (Member member : members) {
memberMap.put(
member.getId(),
member
);
}
Member found =
memberMap.get(1L);
System.out.println(
"조회 결과: " + found
);
}
}
이 예제에서는 다음 개념이 사용되었다.
ArrayList에 회원 목록 저장HashSet으로 중복 관리HashMap으로 ID 기반 조회filter(), sorted(), map()equals()와 hashCode()를 이용한 동등성 판단Collection
List와 Set의 최상위 인터페이스다.
Collections
컬렉션을 정렬하거나 변환하는 정적 유틸리티 메서드를 제공하는 클래스다.
Collections.sort(list);
Collections.reverse(list);
Collections.shuffle(list);
List<String> names =
new ArrayList<>();
List는 인터페이스이고 ArrayList는 구현 클래스다.
변수는 인터페이스 타입으로 선언하는 것이 구현체 교체에 유리하다.
List<String> names =
new LinkedList<>();
다만 ArrayList에만 존재하는 기능이 반드시 필요하다면 구체 타입을 사용할 수도 있다.
List<String> names =
new ArrayList<>(100);
초기 용량은 100이지만 실제 요소 개수는 0이다.
names.size();
결과는 0이다.
HashSet은 입력 순서를 보장하지 않는다.
입력 순서가 필요하면 LinkedHashSet, 정렬된 순서가 필요하면 TreeSet을 고려한다.
Map은 여러 데이터를 관리하지만 Collection 인터페이스를 상속하지 않는다.
Key와 Value의 쌍을 관리하는 구조가 List와 Set의 단일 요소 구조와 다르기 때문이다.
객체를 HashMap의 Key로 사용한 뒤 hashCode() 계산에 사용된 필드 값을 변경하면 해당 Key를 다시 찾지 못할 수 있다.
따라서 Map의 Key는 String, Integer, Enum, 불변 값 객체처럼 변경되지 않는 타입을 사용하는 것이 안전하다.
컬렉션은 데이터를 저장한다.
Stream은 데이터를 저장하지 않고 데이터 처리 과정을 표현한다.
Collection
→ 데이터 저장
Stream
→ 데이터 처리
Stream은 한 번 최종 연산을 수행하면 다시 사용할 수 없다.
Stream<String> stream =
names.stream();
stream.forEach(System.out::println);
// 같은 stream 재사용 불가
일반적인 목록 관리에는 ArrayList가 가장 자주 사용된다.
List<Member> members =
new ArrayList<>();
중복을 허용하지 않는 태그, 권한, 식별자 집합에는 HashSet이 적합하다.
Set<String> tags =
new HashSet<>();
ID나 코드로 특정 객체를 빠르게 조회해야 한다면 HashMap을 사용할 수 있다.
Map<Long, Member> memberById =
new HashMap<>();
하지만 데이터가 데이터베이스에 저장되어 있다면 애플리케이션에서 모든 데이터를 Map으로 복사하기보다 데이터베이스의 인덱스와 조회 기능을 활용해야 한다.
컬렉션 타입은 가능하면 인터페이스로 선언한다.
List<Member> members =
new ArrayList<>();
Map<Long, Member> memberMap =
new HashMap<>();
외부에 컬렉션을 반환할 때 내부 컬렉션이 직접 변경되면 안 된다면 불변 복사본을 반환할 수 있다.
public List<Member> getMembers() {
return List.copyOf(members);
}
Stream은 필터링, 변환, 집계가 연속적으로 이어질 때 가독성이 좋다.
members.stream()
.filter(Member::isActive)
.map(Member::getName)
.sorted()
.toList();
반면 복잡한 상태 변경, 예외 처리, 여러 단계의 분기가 포함되면 일반 반복문이 더 명확할 수 있다. Stream을 사용한다고 항상 더 좋은 코드는 아니다.
이번 글에서는 Java 컬렉션 프레임워크와 함수형 데이터 처리 방법을 정리했다.
List는 입력 순서를 유지하고 중복을 허용한다.Set은 중복을 허용하지 않는다.Map은 Key와 Value의 쌍으로 데이터를 관리한다.Map의 Key는 중복될 수 없지만 Value는 중복될 수 있다.ArrayList는 배열 기반이므로 인덱스 조회가 빠르다.ArrayList의 중간 삽입과 삭제에는 요소 이동이 필요하다.LinkedList는 각 요소가 앞뒤 노드의 참조를 가지는 이중 연결 구조다.LinkedList는 인덱스 조회를 위해 연결을 따라 이동해야 한다.ArrayList가 기본 선택으로 자주 사용된다.ConcurrentModificationException이 발생할 수 있다.Iterator.remove() 또는 removeIf()를 사용할 수 있다.List<Integer>의 remove(int)는 값이 아니라 인덱스를 삭제한다.List<Dog>는 List<Animal>의 하위 타입이 아니다.Arrays.asList()는 크기를 변경할 수 없지만 요소 교체는 가능하다.HashSet과 HashMap은 hashCode()와 equals()를 이용해 객체를 구분한다.equals()를 재정의하면 hashCode()도 같은 기준으로 재정의해야 한다.HashSet은 내부적으로 HashMap을 사용한다.List.of(), Set.of(), Map.of()는 변경할 수 없는 컬렉션을 생성한다.List.of() 계열은 null을 허용하지 않는다.Collections.sort()나 List.sort()를 이용해 리스트를 정렬할 수 있다.Comparable은 클래스의 기본 정렬 기준을 정의한다.Comparator는 외부에서 다양한 정렬 기준을 제공한다.Consumer는 매개값을 받고 반환하지 않는다.Supplier는 매개값 없이 값을 반환한다.Function은 값을 받아 다른 값으로 변환한다.Operator는 동일한 타입을 받아 동일한 타입으로 반환한다.Predicate는 값을 받아 boolean을 반환한다.Optional은 값이 존재할 수도 있고 없을 수도 있는 상황을 표현한다.get()을 사용하는 것은 피하는 것이 좋다.filter()는 조건에 맞는 요소를 선택한다.map()은 요소를 다른 값으로 변환한다.reduce()는 여러 요소를 하나의 결과로 합친다.