Java와 Kotlin의 컬렉션
Java와 Kotlin의 컬렉션
컬렉션은 문제를 푸는 데 의미가 있고 흔히 연산 대상이 되는, 가변 개수(0개일 수도 있어요)의 항목으로 이루어진 모음이에요. 이 가이드는 Java와 Kotlin에서 컬렉션 개념과 연산을 설명하고 비교해요. Java에서 Kotlin으로 마이그레이션할 때, 그리고 정통 Kotlin 방식으로 코드를 쓸 때 큰 도움이 돼요.
출처: Kotlin 공식 문서
본문
이 가이드의 첫 부분은 Java와 Kotlin에서 같은 컬렉션에 대한 연산의 빠른 용어집이에요. 동일한 연산과 Kotlin에만 존재하는 연산으로 나뉘어요. 가이드의 두 번째 부분은 Mutability에서 시작해서 구체적인 사례를 보며 차이점을 설명해요.
컬렉션에 대한 소개는 컬렉션 개요를 보거나, Kotlin Developer Advocate인 Sebastian Aigner의 영상을 보세요.
참고: 아래 예시들은 모두 Java와 Kotlin 표준 라이브러리 API만 사용해요.
Java와 Kotlin에서 동일한 연산
Kotlin에는 Java의 연산과 똑같이 생긴 컬렉션 연산이 많아요.
리스트, 세트, 큐, 덱 연산
| 설명 | 공통 연산 | 더 많은 Kotlin 대안 |
|---|---|---|
| 요소(들) 추가 | add(), addAll() |
plusAssign(+=) 연산자 사용: collection += element, collection += anotherCollection |
| 컬렉션이 요소(들)를 포함하는지 확인 | contains(), containsAll() |
in 키워드를 연산자 형태로 사용해 contains() 호출: element in collection |
| 컬렉션이 비었는지 확인 | isEmpty() |
isNotEmpty()로 비어 있지 않은지 확인 |
| 특정 조건에서 제거 | removeIf() |
|
| 선택된 요소만 남기기 | retainAll() |
|
| 컬렉션에서 모든 요소 제거 | clear() |
|
| 컬렉션에서 스트림 얻기 | stream() |
Kotlin은 스트림 처리의 자체 방식이 있어요: 시퀀스와 map(), filter() 같은 메서드 |
| 컬렉션에서 이터레이터 얻기 | iterator() |
맵 연산
| 설명 | 공통 연산 | 더 많은 Kotlin 대안 |
|---|---|---|
| 요소(들) 추가 | put(), putAll(), putIfAbsent() |
Kotlin에서 할당 map[key] = value는 put(key, value)와 동일해요. 또한 plusAssign(+=) 연산자 사용 가능: map += Pair(key, value) 또는 map += anotherMap |
| 요소(들) 교체 | put(), replace(), replaceAll() |
put()과 replace() 대신 인덱싱 연산자 map[key] = value 사용 |
| 요소 가져오기 | get() |
인덱싱 연산자로 요소 가져오기: map[index] |
| 맵이 요소(들)를 포함하는지 확인 | containsKey(), containsValue() |
in 키워드를 연산자 형태로 사용해 contains() 호출: element in map |
| 맵이 비었는지 확인 | isEmpty() |
isNotEmpty()로 비어 있지 않은지 확인 |
| 요소 제거 | remove(key), remove(key, value) |
minusAssign(-=) 연산자 사용: map -= key |
| 맵에서 모든 요소 제거 | clear() |
|
| 맵에서 스트림 얻기 | entry, key, value의 stream() |
리스트에만 존재하는 연산
| 설명 | 공통 연산 | 더 많은 Kotlin 대안 |
|---|---|---|
| 요소의 인덱스 가져오기 | indexOf() |
|
| 요소의 마지막 인덱스 가져오기 | lastIndexOf() |
|
| 요소 가져오기 | get() |
인덱싱 연산자로 요소 가져오기: list[index] |
| 하위 리스트 가져오기 | subList() |
|
| 요소(들) 교체 | set(), replaceAll() |
set() 대신 인덱싱 연산자 사용: list[index] = value |
조금 다른 연산
모든 컬렉션 타입의 연산
| 설명 | Java | Kotlin |
|---|---|---|
| 컬렉션 크기 가져오기 | size() |
count(), size |
| 중첩 컬렉션 요소에 평면 접근 | collectionOfCollections.forEach(flatCollection::addAll) 또는 collectionOfCollections.stream().flatMap().collect() |
flatten() 또는 flatMap() |
| 주어진 함수를 모든 요소에 적용 | stream().map().collect() |
map() |
| 제공된 연산을 요소에 순차 적용하고 누적 결과 반환 | stream().reduce() |
reduce(), fold() |
| 분류자로 요소를 그룹화하고 세기 | stream().collect(Collectors.groupingBy(classifier, counting())) |
eachCount() |
| 조건으로 필터 | stream().filter().collect() |
filter() |
| 컬렉션 요소가 조건을 만족하는지 확인 | stream().noneMatch(), stream().anyMatch(), stream().allMatch() |
none(), any(), all() |
| 요소 정렬 | stream().sorted().collect() |
sorted() |
| 처음 N개 요소 가져오기 | stream().limit(N).collect() |
take(N) |
| 조건으로 요소 가져오기 | stream().takeWhile().collect() |
takeWhile() |
| 처음 N개 요소 건너뛰기 | stream().skip(N).collect() |
drop(N) |
| 조건으로 요소 건너뛰기 | stream().dropWhile().collect() |
dropWhile() |
| 컬렉션 요소와 관련 값에서 맵 만들기 | stream().collect(toMap(keyMapper, valueMapper)) |
associate() |
맵에 위 연산들을 모두 수행하려면 먼저 맵의 entrySet을 얻어야 해요.
리스트 연산
| 설명 | Java | Kotlin |
|---|---|---|
| 리스트를 자연 순서로 정렬 | sort(null) |
sort() |
| 리스트를 내림차순으로 정렬 | sort(comparator) |
sortDescending() |
| 리스트에서 요소 제거 | remove(index), remove(element) |
removeAt(index), remove(element) 또는 collection -= element |
| 리스트의 모든 요소를 특정 값으로 채우기 | Collections.fill() |
fill() |
| 리스트에서 고유 요소 가져오기 | stream().distinct().toList() |
distinct() |
Java 표준 라이브러리에는 없는 연산
zip(),unzip()– 컬렉션을 변환해요.aggregate()– 조건으로 그룹화해요.takeLast(),takeLastWhile(),dropLast(),dropLastWhile()– 조건으로 요소를 가져오거나 버려요.slice(),chunked(),windowed()– 컬렉션의 일부를 가져와요.- Plus(
+)와 minus(-) 연산자 – 요소를 추가하거나 제거해요.
zip(), chunked(), windowed() 그리고 다른 몇 가지 연산을 깊이 있게 보고 싶다면, Sebastian Aigner의 Kotlin 고급 컬렉션 연산 영상을 보세요.
가변성 (Mutability)
Java에는 가변 컬렉션이 있어요.
// Java
// 이 리스트는 가변적이에요!
public List<Customer> getCustomers() { ... }
부분적으로 가변적인 것도 있죠.
// Java
List<String> numbers = Arrays.asList("one", "two", "three", "four");
numbers.add("five"); // 런타임에 `UnsupportedOperationException`으로 실패해요
그리고 불변인 것도 있어요.
// Java
List<String> numbers = new LinkedList<>();
// 이 리스트는 불변이에요!
List<String> immutableCollection = Collections.unmodifiableList(numbers);
immutableCollection.add("five"); // 런타임에 `UnsupportedOperationException`으로 실패해요
마지막 두 코드를 IntelliJ IDEA에서 작성하면, IDE가 불변 객체를 수정하려 한다고 경고해요. 이 코드는 컴파일되고 런타임에 UnsupportedOperationException으로 실패할 거예요. 타입만 보고 이 컬렉션이 가변인지 알 수는 없어요.
Java와 달리 Kotlin에서는 필요에 따라 가변 또는 읽기 전용 컬렉션을 명시적으로 선언해요. 읽기 전용 컬렉션을 수정하려 하면 코드가 컴파일되지 않아요.
// Kotlin
val numbers = mutableListOf("one", "two", "three", "four")
numbers.add("five") // 이건 괜찮아요
val immutableNumbers = listOf("one", "two")
//immutableNumbers.add("five") // 컴파일 오류 - Unresolved reference: add
불변성에 대해 더 읽고 싶다면 Kotlin 코딩 컨벤션 페이지를 보세요.
공변성 (Covariance)
Java에서는 조상 타입의 컬렉션을 받는 함수에 자손 타입의 컬렉션을 전달할 수 없어요. 예를 들어 Rectangle이 Shape를 확장한다면, Shape 요소의 컬렉션을 받는 함수에 Rectangle 요소의 컬렉션을 전달할 수 없죠. 코드를 컴파일 가능하게 하려면 ? extends Shape 타입을 사용해서 함수가 Shape의 어떤 상속자 컬렉션이든 받게 해야 해요.
// Java
class Shape {}
class Rectangle extends Shape {}
public void doSthWithShapes(List<? extends Shape> shapes) {
/* 그냥 List<Shape>를 사용하면, 아래처럼 List<Rectangle>을
인자로 함수를 호출할 때 코드가 컴파일되지 않아요 */
}
public void main() {
var rectangles = List.of(new Rectangle(), new Rectangle());
doSthWithShapes(rectangles);
}
Kotlin에서 읽기 전용 컬렉션 타입은 공변적(covariant)이에요. 즉 Rectangle 클래스가 Shape 클래스를 상속하면, List<Shape> 타입이 필요한 어디서든 List<Rectangle> 타입을 사용할 수 있다는 뜻이에요. 다시 말해 컬렉션 타입은 요소 타입과 같은 하위 타입 관계를 가져요. 맵은 값 타입에서는 공변적이지만 키 타입에서는 그렇지 않아요. 가변 컬렉션은 공변적이지 않아요 – 그러면 런타임 실패로 이어지기 때문이에요.
// Kotlin
open class Shape(val name: String)
class Rectangle(private val rectangleName: String) : Shape(rectangleName)
fun doSthWithShapes(shapes: List<Shape>) {
println("The shapes are: ${shapes.joinToString { it.name }}")
}
fun main() {
val rectangles = listOf(Rectangle("rhombus"), Rectangle("parallelepiped"))
doSthWithShapes(rectangles)
}
컬렉션 타입에 대해 더 알아보세요.
범위와 진행 (Ranges and progressions)
Kotlin에서는 범위(range)로 구간을 만들 수 있어요. 예를 들어 Version(1, 11)..Version(1, 30)은 1.11부터 1.30까지의 모든 버전을 포함해요. in 연산자로 버전이 범위 안에 있는지 확인할 수 있어요: Version(0, 9) in versionRange.
Java에서는 Version이 양쪽 경계에 맞는지 직접 확인해야 해요.
// Java
class Version implements Comparable<Version> {
int major;
int minor;
Version(int major, int minor) {
this.major = major;
this.minor = minor;
}
@Override
public int compareTo(Version o) {
if (this.major != o.major) {
return this.major - o.major;
}
return this.minor - o.minor;
}
}
public void compareVersions() {
var minVersion = new Version(1, 11);
var maxVersion = new Version(1, 31);
System.out.println(
versionIsInRange(new Version(0, 9), minVersion, maxVersion));
System.out.println(
versionIsInRange(new Version(1, 20), minVersion, maxVersion));
}
public Boolean versionIsInRange(Version versionToCheck, Version minVersion,
Version maxVersion) {
return versionToCheck.compareTo(minVersion) >= 0
&& versionToCheck.compareTo(maxVersion) <= 0;
}
Kotlin에서는 범위를 하나의 객체 전체로 다뤄요. 두 변수를 만들고 Version을 그들과 비교할 필요가 없어요.
// Kotlin
class Version(val major: Int, val minor: Int): Comparable<Version> {
override fun compareTo(other: Version): Int {
if (this.major != other.major) {
return this.major - other.major
}
return this.minor - other.minor
}
}
fun main() {
val versionRange = Version(1, 11)..Version(1, 30)
println(Version(0, 9) in versionRange)
println(Version(1, 20) in versionRange)
}
경계 중 하나를 제외해야 할 때, 예를 들어 버전이 최소 버전 이상(>=)이고 최대 버전 미만(<)인지 확인해야 할 때는 이런 포함 범위(inclusive range)로는 해결할 수 없어요.
여러 기준으로 비교하기
Java에서 여러 기준으로 객체를 비교하려면 Comparator 인터페이스의 comparing()과 thenComparingX() 함수를 쓸 수 있어요. 예를 들어 사람을 이름과 나이로 비교한다면:
class Person implements Comparable<Person> {
String name;
int age;
public String getName() {
return name;
}
public int getAge() {
return age;
}
Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return this.name + " " + age;
}
}
public void comparePersons() {
var persons = List.of(new Person("Jack", 35), new Person("David", 30),
new Person("Jack", 25));
System.out.println(persons.stream().sorted(Comparator
.comparing(Person::getName)
.thenComparingInt(Person::getAge)).collect(toList()));
}
Kotlin에서는 비교하고 싶은 필드를 나열하기만 하면 돼요.
data class Person(
val name: String,
val age: Int
)
fun main() {
val persons = listOf(Person("Jack", 35), Person("David", 30),
Person("Jack", 25))
println(persons.sortedWith(compareBy(Person::name, Person::age)))
}
시퀀스 (Sequences)
Java에서는 이렇게 숫자의 시퀀스를 만들 수 있어요.
// Java
int sum = IntStream.iterate(1, e -> e + 3)
.limit(10).sum();
System.out.println(sum); // Prints 145
Kotlin에서는 시퀀스를 사용해요. 시퀀스의 다단계 처리는 가능할 때 지연(lazily) 실행돼요 – 실제 계산은 전체 처리 체인의 결과가 요청될 때만 일어나요.
fun main() {
// Kotlin
val sum = generateSequence(1) {
it + 3
}.take(10).sum()
println(sum) // Prints 145
}
시퀀스는 일부 필터링 연산에 필요한 단계 수를 줄여 줄 수 있어요. Iterable과 Sequence의 차이를 보여 주는 시퀀스 처리 예시를 확인해 보세요.
리스트에서 요소 제거
Java에서 remove() 함수는 제거할 요소의 인덱스를 받아요. 정수 요소를 제거할 때는 remove() 함수의 인자로 Integer.valueOf() 함수를 사용해요.
// Java
public void remove() {
var numbers = new ArrayList<>();
numbers.add(1);
numbers.add(2);
numbers.add(3);
numbers.add(1);
numbers.remove(1); // 인덱스로 제거해요
System.out.println(numbers); // [1, 3, 1]
numbers.remove(Integer.valueOf(1));
System.out.println(numbers); // [3, 1]
}
Kotlin에는 요소 제거 타입이 두 가지 있어요. removeAt()으로 인덱스로, remove()로 값으로 제거해요.
fun main() {
// Kotlin
val numbers = mutableListOf(1, 2, 3, 1)
numbers.removeAt(0)
println(numbers) // [2, 3, 1]
numbers.remove(1)
println(numbers) // [2, 3]
}
맵 순회하기
Java에서는 forEach로 맵을 순회할 수 있어요.
// Java
numbers.forEach((k,v) -> System.out.println("Key = " + k + ", Value = " + v));
Kotlin에서는 Java의 forEach와 비슷하게 for 루프나 forEach를 사용해서 맵을 순회해요.
// Kotlin
for ((k, v) in numbers) {
println("Key = $k, Value = $v")
}
// 또는
numbers.forEach { (k, v) -> println("Key = $k, Value = $v") }
빈 컬렉션일 수 있는 컬렉션의 첫 번째와 마지막 항목 가져오기
Java에서는 컬렉션 크기를 확인하고 인덱스를 사용해서 안전하게 첫 번째와 마지막 항목을 가져올 수 있어요.
// Java
var list = new ArrayList<>();
//...
if (list.size() > 0) {
System.out.println(list.get(0));
System.out.println(list.get(list.size() - 1));
}
JDK 21부터는 모든 SequencedCollection 구현에서 사용 가능한 getFirst()와 getLast() 메서드를 쓸 수도 있어요. 여기에는 모든 List 구현뿐 아니라 LinkedHashSet 같은 다른 컬렉션도 포함돼요. 예를 들어 ArrayList로:
// Java
var list = new ArrayList<>();
//...
if (!list.isEmpty()) {
System.out.println(list.getFirst());
System.out.println(list.getLast());
}
Kotlin에는 특별한 함수 firstOrNull()과 lastOrNull()이 있어요. Elvis 연산자를 사용하면 함수의 결과에 따라 바로 추가 작업을 수행할 수 있어요. 예를 들어 firstOrNull():
// Kotlin
val emails = listOf<String>() // 비어 있을 수도 있어요
val theOldestEmail = emails.firstOrNull() ?: ""
val theFreshestEmail = emails.lastOrNull() ?: ""
리스트에서 세트 만들기
Java에서 List로 Set을 만들려면 Set.copyOf 함수를 쓸 수 있어요.
// Java
public void listToSet() {
var sourceList = List.of(1, 2, 3, 1);
var copySet = Set.copyOf(sourceList);
System.out.println(copySet);
}
Kotlin에서는 toSet() 함수를 사용해요.
fun main() {
// Kotlin
val sourceList = listOf(1, 2, 3, 1)
val copySet = sourceList.toSet()
println(copySet)
}
요소 그룹화
Java에서는 Collectors의 groupingBy() 함수로 요소를 그룹화할 수 있어요.
// Java
public void analyzeLogs() {
var requests = List.of(
new Request("https://kotlinlang.org/docs/home.html", 200),
new Request("https://kotlinlang.org/docs/home.html", 400),
new Request("https://kotlinlang.org/docs/comparison-to-java.html", 200)
);
var urlsAndRequests = requests.stream().collect(
Collectors.groupingBy(Request::getUrl));
System.out.println(urlsAndRequests);
}
Kotlin에서는 groupBy() 함수를 사용해요.
data class Request(
val url: String,
val responseCode: Int
)
fun main() {
// Kotlin
val requests = listOf(
Request("https://kotlinlang.org/docs/home.html", 200),
Request("https://kotlinlang.org/docs/home.html", 400),
Request("https://kotlinlang.org/docs/comparison-to-java.html", 200)
)
println(requests.groupBy(Request::url))
}
요소 필터링
Java에서 컬렉션에서 요소를 필터링하려면 Stream API를 사용해야 해요. Stream API에는 intermediate와 terminal 연산이 있어요. filter()는 스트림을 반환하는 중간 연산이에요. 컬렉션을 출력으로 받으려면 collect() 같은 터미널 연산을 써야 해요. 예를 들어 키가 1로 끝나고 값이 10보다 큰 쌍만 남기려면:
// Java
public void filterEndsWith() {
var numbers = Map.of("key1", 1, "key2", 2, "key3", 3, "key11", 11);
var filteredNumbers = numbers.entrySet().stream()
.filter(entry -> entry.getKey().endsWith("1") && entry.getValue() > 10)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
System.out.println(filteredNumbers);
}
Kotlin에서는 필터링이 컬렉션에 내장되어 있고, filter()는 필터링된 것과 같은 컬렉션 타입을 반환해요. 그래서 써야 하는 건 filter()와 그 조건(predicate)뿐이에요.
fun main() {
// Kotlin
val numbers = mapOf("key1" to 1, "key2" to 2, "key3" to 3, "key11" to 11)
val filteredNumbers = numbers.filter { (key, value) -> key.endsWith("1") && value > 10 }
println(filteredNumbers)
}
맵 필터링에 대해 더 알아보세요.
타입으로 필터링
Java에서 타입별로 요소를 필터링하고 작업을 수행하려면 instanceof 연산자로 타입을 확인한 뒤 타입 캐스트를 해야 해요.
// Java
public void objectIsInstance() {
var numbers = new ArrayList<>();
numbers.add(null);
numbers.add(1);
numbers.add("two");
numbers.add(3.0);
numbers.add("four");
System.out.println("All String elements in upper case:");
numbers.stream().filter(it -> it instanceof String)
.forEach( it -> System.out.println(((String) it).toUpperCase()));
}
Kotlin에서는 컬렉션에 filterIsInstance<NEEDED_TYPE>()를 호출하기만 하면 되고, 타입 캐스트는 스마트 캐스트(Smart casts)가 처리해요.
// Kotlin
fun main() {
// Kotlin
val numbers = listOf(null, 1, "two", 3.0, "four")
println("All String elements in upper case:")
numbers.filterIsInstance<String>().forEach {
println(it.uppercase())
}
}
조건 테스트
어떤 작업은 모든 요소, 아무 요소도 아닌 경우, 또는 어떤 요소가 조건을 만족하는지 확인해야 해요. Java에서는 Stream API 함수 allMatch(), noneMatch(), anyMatch()로 이 모든 확인을 할 수 있어요.
// Java
public void testPredicates() {
var numbers = List.of("one", "two", "three", "four");
System.out.println(numbers.stream().noneMatch(it -> it.endsWith("e"))); // false
System.out.println(numbers.stream().anyMatch(it -> it.endsWith("e"))); // true
System.out.println(numbers.stream().allMatch(it -> it.endsWith("e"))); // false
}
Kotlin에서 확장 함수 none(), any(), all()은 모든 Iterable 객체에서 사용할 수 있어요.
fun main() {
// Kotlin
val numbers = listOf("one", "two", "three", "four")
println(numbers.none { it.endsWith("e") })
println(numbers.any { it.endsWith("e") })
println(numbers.all { it.endsWith("e") })
}
조건 테스트에 대해 더 알아보세요.
컬렉션 변환 연산
Zip으로 요소 묶기
Java에서는 두 컬렉션을 동시에 반복하면서 같은 위치의 요소로 쌍을 만들 수 있어요.
// Java
public void zip() {
var colors = List.of("red", "brown");
var animals = List.of("fox", "bear", "wolf");
for (int i = 0; i < Math.min(colors.size(), animals.size()); i++) {
String animal = animals.get(i);
System.out.println("The " + animal.substring(0, 1).toUpperCase()
+ animal.substring(1) + " is " + colors.get(i));
}
}
요소 쌍을 출력하는 것보다 더 복잡한 일을 하고 싶다면 Records를 쓸 수 있어요. 위 예시에서 레코드는 record AnimalDescription(String animal, String color) {}와 같을 거예요.
Kotlin에서는 zip() 함수로 같은 일을 해요.
fun main() {
// Kotlin
val colors = listOf("red", "brown")
val animals = listOf("fox", "bear", "wolf")
println(colors.zip(animals) { color, animal ->
"The ${animal.replaceFirstChar { it.uppercase() }} is $color" })
}
zip()은 Pair 객체의 List를 반환해요.
참고: 컬렉션의 크기가 다르면
zip()의 결과는 더 작은 크기예요. 더 큰 컬렉션의 마지막 요소들은 결과에 포함되지 않아요.
요소 연관시키기
Java에서는 Stream API로 요소를 특성과 연관시킬 수 있어요.
// Java
public void associate() {
var numbers = List.of("one", "two", "three", "four");
var wordAndLength = numbers.stream()
.collect(toMap(number -> number, String::length));
System.out.println(wordAndLength);
}
Kotlin에서는 associate() 함수를 사용해요.
fun main() {
// Kotlin
val numbers = listOf("one", "two", "three", "four")
println(numbers.associateWith { it.length })
}
다음 단계
- Kotlin Koans를 방문해서 Kotlin 문법을 배우는 연습을 완료해 보세요. 각 연습은 실패하는 유닛 테스트로 만들어져 있고, 여러분의 임무는 그것을 통과시키는 거예요.
- 다른 Kotlin 관용구(idioms)를 살펴보세요.
- Java to Kotlin 변환기로 기존 Java 코드를 Kotlin으로 변환하는 법을 배워 보세요.
- Kotlin의 컬렉션을 알아보세요.
마음에 드는 관용구가 있다면, 풀 리퀘스트를 보내서 공유해 주세요.
더 알아보기 (Learn more)
Java에서 Kotlin으로 옮겨 오는 개발자라면, 이 가이드의 대응 관계 표가 사실상 치트시트 역할을 해요. 동일한 연산뿐 아니라 Kotlin에만 있는 연산까지 함께 익혀 두면, 같은 로직을 훨씬 간결하게 쓸 수 있어요. 맵 필터링이나 시퀀스, 컬렉션 관용구 문서를 이어서 보면 실무 적용이 훨씬 수월해져요.