[Kotlin in Action 2/e] 6장 컬렉션과 시퀀스

왕왕조현·2026년 2월 5일

Kotlin in Action 2/e

목록 보기
6/18
post-thumbnail

안녕하세요!

컬렉션의 활용 방법과 시퀀스의 개념 정리글로 돌아온 개발자 꿈나무 김조현입니다.

이번 글에서는 함수를 사용해 컬렉션을 다루는 방법과 시퀀스를 활용해 코드를 깔끔하게 작성하는 방법에 대해 정리해보겠습니다.


함수형 스타일로 컬렉션을 다루는 방법은?

함수형 프로그래밍은 컬렉션을 다룰 때 여러 가지 장점을 제공합니다.

표준 라이브러리가 제공하는 함수를 활용하고 람다를 인자로 전달해 그 함수들의 동작을 커스텀화할 수 있습니다.

이 방법은 컬렉션을 직접 순회하면서 데이터를 하나로 만드는 방법과 비교할 때 일반적인 연산을 일관성있게 표현할 수 있습니다.


원소 제거와 변환하는 함수는?

filter

이 두 함수를 활용하면 많은 컬렉션 연산을 표현할 수 있습니다.

flatten 함수는 어떤 술어를 바탕으로 컬렉션의 원소를 걸러냅니다. 이때 술어란 Boolean 값이 결과인 함수를 의미합니다.

fun main() {
	val list = listOf(1, 2, 3, 4)
	println(list.filter{ it % 2 == 0 })
	// [2, 4]
}

위 코드에서 list는 filter를 사용해서 짝수만을 걸러내 출력하게 됩니다. 이 과정에서 filter 함수는 새 컬렉션을 만들 수 있지만 그 과정에서 원소를 변환하지는 않습니다. 즉, 상태를 바꾸지 않고 컬렉션에서 추출하는 개념으로 볼 수 있습니다.


map

map 함수는 컬렉션의 각 원소를 다른 형태로 변환하는 기능을 합니다.

fun main() {
	val list = listOf(1, 2, 3, 4)
	println(list.map{ it * it })
	// [1, 4, 9, 16]
}

map은 원소를 변환하는 과정을 거치며 기존 컬렉션의 각 원소에 주어진 함수를 적용하고, 그 결과값들을 새 컬렉션에 모읍니다. 따라서 결과는 같은 원소가 들어있는 새 컬렉션입니다. 즉, 길이는 같지만 각 원소는 주어진 함수에 따라 변환되는 것입니다.

data class Person(name: String, age: Int)

fun main() {
	val people = listOf(Person("Alice", 29), Person("Bob", 31))
	println(people.map{ it.name })
	// [Alice, Bob]
}

사람 객체에 대해서 객체의 이름만을 모아서 출력하고 싶을 때도 map을 사용할 수 있습니다. 이렇게 하면 Person 객체의 리스트를 String 리스트로 변환합니다.

이 메소드도 멤버 참조를 사용하여 작성할 수 있습니다.

people.map(Person::name)

만약 사람들 중에 나이가 가장 많은 사람을 찾는 코드를 작성할 때 아래와 같이 코드를 작성한다면 가장 나이가 많은 사람을 반복해서 찾게 됩니다. 이런 방식으로 코드를 작성한다면 내부 로직이 복잡해질 수 있습니다.

people.filter {
	val oldestPerson = people.maxByOrNull(Person::age)
	it.age == oldestPerson?.age
}

그렇기 때문에 꼭 필요하지 않은 경우에는 람다의 바깥으로 옮기는 등 반복을 지향해야합니다.


filterIndexed와 mapIndexed

걸러내거나 변환하는 연산이 값 뿐만 아니라 인덱스에 따라서도 달라진다면 filterIndexed와 mapIndexed를 사용하면 됩니다.

fun main() {
	val numbers = listOf(1, 2, 3, 4, 5, 6, 7)
	val filtered = numbers.filterIndexed{ index, element -> 
		index % 2 == 0 && element > 3
	}
	println(filtered)
	// [5, 7]
	val mapped = numbers.mapIndexed{ index, element ->
		index + element
	}
	println(mapped)
	// [1, 3, 5, 7, 9, 11, 13]
}

리스트가 아니라 맵의 경우 키와 값을 처리하는 함수가 존재합니다. filterKeys와 mapKeys는 키를 걸러내거나 변환하고, filterValues와 mapValues는 값을 걸러내거나 변환합니다.


컬렉션 값을 누적 연산하는 함수는?

reduce와 fold

reduce와 fold는 컬렉션의 정보를 종합하는데 사용합니다. 이 함수들은 원소로 이뤄진 컬렉션을 받아서 한 값을 반환합니다. 이 값은 누적기를 통해 점진적으로 만들어집니다.

reduce 함수는 컬렉션의 첫 번째 값을 누적기에 넣고, 그 후 람다가 호출되면서 누적 값과 다음 원소가 인자에 전달되는 과정을 반복합니다.

아래의 코드는 reduce를 활용해 컬렉션의 전체 합과 곱을 계산한 것입니다.

fun main() {
	val list = listOf(1, 2, 3, 4)
	println(list.reduce{ acc, element ->
		acc + element
	})
	// 10
	println(list.reduce{ acc, element ->
		acc * element
	})
	// 24
}

fold 함수는 reduce와 비슷하지만 컬렉션의 첫 번째 원소를 누적 값으로 시작하는 대신 임의의 시작 값과 타입을 지정할 수 있습니다.

fun main() {
	val people = listOf(
		Person("Alex", 29),
		Person("Natalia", 28)
	)
	val folded = people.fold("") { acc, person -> 
		acc + person.name
	}
	println(folded)
}
// AlexNatalia

runningReduce와 runningFold

running이 붙은 두 함수는 reduce와 fold의 중간 단계의 모든 누적 값을 뽑아내고 싶을 때 사용하는 함수입니다. 모든 연산은 reduce, fold와 똑같지만 결과값을 하나만 반환하는 것이 아니라 리스트로 모든 누적 값을 뽑아냅니다.

즉, runningReduce와 runningFold의 마지막 원소는 최종 누적 값입니다.

fun main() {
	val list = listOf(1, 2, 3, 4)
	val summed = list.runningReduce{ acc, element -> 
		acc + element
	}
	println(summed)
	// [1, 3, 6, 10]
	val multiplied = list.runningReduce{ acc, element ->
		acc * element
	}
	println(multiplied)
	// [1, 2, 6, 24]
	val people = listOf(
		Person("Alex", 29),
		Person("Natalia", 28)
	)
	println(people.runningFold("") { acc, person ->
		acc + person.name
	})
	// [, Alex, AlexNatalia]
}

컬렉션에 적용할 수 있는 술어는?

all

모든 원소가 술어를 만족하는지 궁금하다면 all 함수를 사용합니다.

var canBeInClub27 = { p: Person -> p.age <= 27 }

fun main() {
	val people = listOf(Person("Alice", 27), Person("Bob", 31))
	println(people.all(canBeInClub27))
	// false
	// Bob이 조건을 만족하지 않기 때문에 false를 반환한다.
}

any

술어를 만족하는 원소가 하나라도 있는지 궁금하다면 any 함수를 사용합니다.

fun main() {
	println(people.any(canBeInClub27))
	// true
}

또한 어떤 조건에 대해 !all 연산을 수행한 결과와 그 조건의 부정에 대해 any를 수행한 결과는 같습니다. 이를 드 모르강의 법칙이라고 합니다. 둘의 연산 결과는 같지만 가독성을 높이기 위해서는 함수 호출 앞에 !를 붙이지 않는 것을 권장합니다.

fun main() {
	val list = listOf(1, 2, 3)
	println(!list.all{ it == 3})
	// true
	println(list.any{ it != 3 })
	// true
}

none

none은 술어를 만족하는 원소가 없는지 궁금할 때 사용합니다. none은 !any와 같습니다.

fun main() {
	val list = listOf(1, 2, 3)
	println(!list.any{ it == 4 })
	// true
	println(list.none{ it == 4 })
	// true
}

비어있는 컬렉션에 대해 all, any, none은 어떻게 결과를 반환할까?

any의 경우 컬렉션에 원소가 없으면 술어를 만족하는 원소도 없기 때문에 false를 반환합니다.

fun main() {
	println(emptyList<Int>().any{ it > 42 })
	// false
}

none은 any를 반전시킨 것과 같기 때문에 비어있는 컬렉션에 대해 none은 true를 반환합니다.

fun main() {
	println(emptyList<Int>().none{ it > 42 })
	// true
}

all은 술어 람다가 무엇이든 관계없이 빈 컬렉션에 대해 항상 true를 반환합니다.

fun main() {
	println(emptyList<Int>().all{ it > 42 })
	// true
}

count

술어를 만족하는 원소의 개수를 알고 싶다면 count를 사용합니다.

fun main() {
	val people = listOf(Person("Alice", 27), Person("Bob", 31))
	println(people.count(canBeInClub27))
	// 1
	println(people.filter(canBeInClub27).size)
	// 1
}

가끔 count라는 함수대신 이를 구현하기 위해 필터링하고 size를 통해 개수를 반환하는 경우가 있다. 이런 방법으로 구현한다면 계산 과정에서 모든 원소가 들어가는 중간 컬렉션이 생깁니다. 반면 count는 원소를 따로 저장하지 않기 때문에 count 메소드를 사용하는 것이 훨씬 효율적입니다.

이런 상황처럼 일반적인 규칙으로 필요에 따라 가장 적합한 연산을 선택하기 위해 최대한 노력해야합니다.


find

술어를 만족하는 원소를 하나 찾고 싶을 때는 find 함수를 사용합니다.

fun main() {
	val people = listOf(Person("Alice", 27), Person("Bob", 31))
	println(people.find(canBeInClub27))
	// Person(name=Alice, age=27)
}

find는 조건을 만족하는 원소가 있는 경우 조건을 만족하는 첫 번째 원소를 반환하고, 원소가 전혀 없는 경우에는 null을 반환합니다. find는 firstOrNull과 같은 기능을 합니다.


리스트를 분할해 리스트의 쌍으로 만드는 함수는?

partition

컬렉션을 어떤 조건을 만족하는 그롭과 만족하지 않는 두 그룹으로 나눌 때 보통은 filter와 filterNot을 사용해 두 가지를 사용하는 방법을 생각할 수 있을 것입니다.

이런 상황에서 partition 메소드를 사용하면 두 그룹을 한 메소드로 반환할 수 있습니다. 이는 전체 컬렉션을 2번 순회하지 않아도 되는 것입니다.

fun main() {
	val people = listOf(
		Person("Alice", 26),
		Person("Bob", 29),
		Person("Carol", 31)
	)
	val (comeIn, stayOut) = people.partition(canBeInClub27)
	println(comeIn)
	// [Person(name=Alice, age=26)]
	println(stayOut)
	// [Person(name=Bob, age=29), Person(name=Carol, age=31)]
}

리스트를 여러 그룹으로 이뤄진 맵으로 바꿔주는 함수는?

groupBy

partition은 조건이 참과 거짓을 나눠질 때 사용할 수 있습니다. 하지만 컬렉션을 나누는 조건이 항상 Boolean으로 나뉘지는 않습니다. 여러 특성에 따라 컬렉션의 원소를 그룹으로 나누고 싶을 때 사용하는 메소드가 groupBy입니다.

fun main() {
	val people = listOf(
		Person("Alice", 31), 
		Person("Bob", 29),
		Person("Carol", 31)
	)
	val groupPeople = people.groupBy{ it.age }
	println(groupPeople)
  // {31=[Person(name=Alice, age=31), Person(name=Carol, age=31)], 
  // 29=[Person(name=Bob, age=29)]}
  println(groupPeople.mapKeys { entry -> entry.value.size })
  // {2=[Person(name=Alice, age=31), Person(name=Carol, age=31)], 
  // 1=[Person(name=Bob, age=29)]}
  println(groupPeople.mapValues { it.value.size })
  // {31=2, 29=1}
}

각 그룹이 리스트에 저장되며 결과 타입은 Map<Int, List<Person>>이 됩니다.

필요에 따라 앞에서 말한 mapKeys나 mapValues를 사용해 변경할 수 있습니다.

위 코드에서 mapKeys는 각 키의 값의 크기를 키로 갖도록 맵을 변경한 것이고, mapValues는 맵의 값을 각 값의 크기로 변경한 것입니다.


컬렉션을 맵으로 변환해주는 함수는?

associate

원소를 그룹화하지 않으면서 컬렉션으로부터 맵을 반환하고 싶을 때 associate함수를 사용합니다. associate에게 입력 컬렉션의 원소로부터 키/값 쌍을 만들어내는 람다를 제공하고, 키-맵 쌍을 표현하기 위해 앞에서 배운 to 중위 함수를 사용합니다.

fun main() {
	val people = listOf(Person("Joe", 22), Person("Mary", 31))
	val nameToAge = people.associate{ it.name to it.age }
	println(nameToAge)
	// {Joe=22, Mary=31}
	println(nameToAge["Joe"])
	// 22
}

associateWith

associateWith는 컬렉션의 원래 원소를 키로 사용하고 제공하는 람다를 그 원소에 대응하는 값으로 만듭니다. 그렇기에 아래의 코드에서는 컬랙션의 각 원소들이 key이고, 나이가 value인 맵이 반환됩니다.

fun main() {
	val people = listOf(
		Person("Joe", 22), 
		Person("Mary", 31),
		Person("Jamie", 22)
	)
	val personToAge = people.associateWith{ it.age }
	println(personToAge)
	// {Person(name=Joe, age=22)=22, Person(name=Mary, age=31)=31, 
	// Person(name=Jamie, age=22)=22}
}

가변 컬렉션의 원소를 변경해주는 함수는?

replaceAll과 fill 함수

replaceAll 함수를 MutableList에 적용하면 지정한 람다로 얻은 결과로 컬렉션의 모든 원소를 변경합니다. 가변 리스트의 모든 원소를 똑같은 값으로 바꾸는 경우에는 fill 함수를 사용할 수 있습니다.

fun main() {
	val names = mutableListOf("Martin", "Samuel")
	println(names)
	// [Martin, Samuel]
	names.replaceAll{ it.uppercase() }
	println(names)
	// [MARTIN, SAMUEL]
	names.fill("(redacted)")
	println(names)
	// [(redacted), (redacted)]
}

associateBy

associateBy는 컬렉션의 원래 원소를 맵의 값으로 사용하고, 람다가 만들어내는 값을 맵의 키로 사용합니다. 그렇기에 각 나이에 대한 person을 가지는 맵이 반환됩니다.

fun main() {
	val people = listOf(
		Person("Joe", 22), 
		Person("Mary", 31),
		Person("Jamie", 22)
	)
	val ageToPerson = people.associateBy{ it.age }
	println(ageToPerson)
	// {22=Person(name=Jamie, age=22), 31=Person(name=Mary, age=31)}
}

associate, associateWith, associateBy 를 사용할 때 주의할 점은 맵에서 키는 유일하기 때문에 동일한 키에 대해 다른 값을 추가하게 된다면 마지막 결과가 이전 결과를 덮어쓰게 됩니다.


컬렉션의 특별한 경우 처리하는 함수는?

ifEmpty

ifEmpty 함수를 사용하면 컬렉션에 아무 원소도 없을 때 기본값을 생성하는 람다를 제공할 수 있습니다.

fun main() {
	val empty = emptyList<String>()
	val full = listOf("apple", "orange", "banana")
	println(empty.ifEmpty{ listOf("no", "values", "here") })
	// [no, values, here]
	println(full.ifEmpty{ listOf("no", "values", "here") })
	// [apple, orange, banana]
}

ifBlank

ifEmpty는 값이 비어있을 때 기본값을 반환해줍니다. 하지만 “ “와 같이 비어있지 않지만 공백만 있는 경우에도 기본값을 반환해주고 싶을 수 있습니다. 이 떄 사용하는 함수가 ifBlank입니다.

ifBlank는 비어있을 때, 공백일 때 두 경우 문자열의 기본값을 지정해줄 수 있습니다.

fun main() {
	val blankName = " "
	val name = "J. Doe"
	println(blankName.ifEmpty{ "(unnamed)" })
	// 
	println(blankName.ifBlank{ "(unnamed)" })
	// (unnamed)
	println(name.ifBlank{ "(unnamed)" })
	// J. Doe
}

컬렉션을 나누는 함수는?

windowed

리스트의 값으로부터 특정 범위 만큼을 1만큼 밀면서 연산을 해야할 경우에 windowed 함수를 사용할 수 있습니다. 예를 들면 3일간의 온도 평균을 구해야할 때 아래와 같이 구현하여 사용할 수 있습니다.

fun main() {
	val temperatures = listOf(27.7, 29.8, 22.0, 35.5, 19.1)
	println(temperatures.windowed(3))
	// [[27.7, 29.8, 22.0], [29.8, 22.0, 35.5], [22.0, 35.5, 19.1]]
	println(temperatures.windowed(3){ it.sum() / it.size })
	// [26.5, 29.099999999999998, 25.53333333333333]
}

chunked

한 칸씩 미는 windowed와 달리 겹치는 부분을 만들지 않고 싶을 때는 chunked 함수를 사용하면 됩니다.

fun main() {
	val temperatures = listOf(27.7, 29.8, 22.0, 35.5, 19.1)
	println(temperatures.chunked(2))
	// [[27.7, 29.8], [22.0, 35.5], [19.1]]
	println(temperatures.chunked(2){ it.sum() })
	// [57.5, 57.5, 19.1]
}

위의 코드처럼 청크 크기를 2로 지정했지만 홀수일 경우 마지막 1개의 원소가 남게 됩니다. 그럴 때는 마지막 청크에 한 개의 원소를 넣습니다.


컬렉션을 합치는 함수는?

zip

연관이 있는 별도의 두 리스트를 합쳐야할 경우 zip 함수를 사용할 수 있습니다. zip 함ㅅ무는 두 컬렉션에서 같은 인덱스에 있는 원소들의 쌍으로 이뤄진 리스트를 만들 수 있습니다.

결과 컬렉션의 길이는 두 컬렉션 중 더 짧은 쪽의 길이와 같으며, 대응되는 원소가 없는 원소는 무시됩니다.

zip 함수도 중위 표기법으로 사용할 수 있지만, 중위 표기법을 사용한다면 람다를 전달할 수 없습니다.

fun main() {
	val names = listOf("Joe", "Mary", "Jamie")
	val ages = listOf(22, 31, 31, 44, 0)
	println(names.zip(ages))
	// [(Joe, 22), (Mary, 31), (Jamie, 31)]
	println(names.zip(ages){ name, age -> Person(name, age) })
	// [Person(name=Joe, age=22), Person(name=Mary, age=31), 
	// Person(name=Jamie, age=31)]
	println(names zip ages)
	// [(Joe, 22), (Mary, 31), (Jamie, 31)]
}

내포된 컬렉션의 원소를 처리하는 함수는?

flatten

컬렉션의 컬렉션을 평평한 컬렉션으로 만들 때 사용됩니다. 아래의 코드에서 리스트가 중첩되있는 상황에서 flatten을 사용하여 하나의 리스트로 풀어줬습니다. 또한 toSet 메소드를 사용하면 컬렉션의 중복을 없앨 수 있습니다.

class Book(val title: String, val authors: List<String>)

fun main() {
	val library = listOf(
		Book("Kotlin in Action", listOf("Isakova", "Elizarov", "Aigner", "Jemerov")),
		Book("Atomic Kotlin", listOf("Eckel", "Isakova")),
		Book("The Three-Body Problem", listOf("Liu"))
	)
	val authors = library.map{ it.authors }
	println(authors)
	// [[Isakova, Elizarov, Aigner, Jemerov], [Eckel, Isakova], [Liu]]
	println(authors.flatten())
	// [Isakova, Elizarov, Aigner, Jemerov, Eckel, Isakova, Liu]
	println(authors.flatten().toSet()
	// [Isakova, Elizarov, Aigner, Jemerov, Eckel, Liu]
}

flatMap

flatMap 함수는 flatten과 map을 모두 처리해주는 기능을 가지고 있습니다. 즉, flatMap을 사용하면 위의 코드와 같은 결과를 반환해줍니다.

fun main() {
	val authors = library.flatMap{ it.authors }
	println(authors)
	// [Isakova, Elizarov, Aigner, Jemerov, Eckel, Isakova, Liu]
}

시퀀스

map이나 filter 같은 함수를 연쇄적으로 호출하는 경우에는 중간 과정마다 임시 컬렉션을 담고 있습니다.

people.map(Person::name).filter{ it.startsWith("A") }

이 코드같은 경우에는 map에서 한 번, filter에서 한 번, 총 두 번의 컬렉션을 만든다는 의미입니다. 크기가 작다면 문제가 없겠지만 컬렉션의 원소가 많아질수록 효율이 떨어지게 되는 문제점이 생깁니다.

이런 문제를 해결해줄 수 있는 것이 시퀀스입니다. 시퀀스는 중간 임시 컬렉션을 생성하지 않고 연산을 연쇄하는 방법을 제공해줍니다.

asSequence 확장 함수를 호출하면 어떤 컬렉션이든 시퀀스로 바꿀 수 있습니다.


시퀀스와 컬렉션 중에 어떤 걸 사용해야 하는가?

그러면 컬렉션보다 효율이 더 좋은 시퀀스만 사용하면 되지 않나? 라는 생각이 들었습니다. 그치만 모든 컬렉션을 시퀀스로 바꾸면 안되는 이유가 있습니다. 시퀀스는 인덱스를 사용해 접근하는 것이 안됩니다.

즉, 차례대로 이터레이션해야 한다면 시퀀스를 사용해도 되지만 그렇지 않은 경우에는 시퀀스를 컬렉션으로 변경하여 사용해야 합니다.


시퀀스의 연산 실행 과정은?

시퀀스에 대한 연산은 중간 연산최종 연산으로 나뉩니다.

중간 연산은 다른 시퀀스를 반환하며, 이 시퀀스는 최초 시퀀스의 원소를 반환하는 방법을 알고 있습니다. 중간 연산은 항상 지연 연산되기 때문에 시퀀스 연산 도중 print문을 넣어도 아무런 결과가 출력되지 않고 Sequence 객체 자체에 대한 출력을 볼 수 있습니다.

이는 map과 filter 변환이 지연돼 결과를 얻을 필요가 있을 때 적용된다는 의미입니다.

fun main() {
	println(
		listOf(1, 2, 3, 4)
			.asSequence()
			.map {
				print("map($it) ")
				it * it
			}.filter {
				print("filter($it) ")
				it % 2 == 0
			}
	)
	// kotlin.sequences.FilteringSequence@776ec8df
}

최종 연산은 결과를 반환하며, 결과는 최초 컬렉션에 대해 변환을 적용한 시퀀스에서 일련의 계산을 수행해 얻을 수 있는 컬렉션이나 다른 객체입니다. 최종 연산을 호출하면 연기됐던 모든 계산이 수행됩니다.

일반 연산의 경우 위의 코드를 실행하면 모든 원소에 대해 순차적으로 map을 실행한 후에 filter를 통해 값을 추출할 것입니다. 하지만 시퀀스의 경우는 각 원소에 대해 순차적으로 적용됩니다.

즉, 첫 번째 원소, 두 번째 원소, … 처럼 실행이 된다는 의미입니다. 그렇기에 시퀀스 연산의 경우는 filter와 map의 순서가 상관없습니다.

반면 일반 연산의 경우는 map과 filter의 순서에 따라 연산의 전체 횟수가 다릅니다.

연쇄적인 연산에서 더 빨리 원소를 제거하면 할수록 코드의 성능이 좋아집니다.


시퀀스를 만드는 방법은?

위의 모든 예제에서 asSequence()를 호출하여 시퀀스를 생성했습니다. 이 방법 외에도 generateSequence 함수를 사용하여 시퀀스를 생성할 수 있습니다.

generateSequence는 이전의 원소를 인자로 받아 다음 원소를 계산하는 기능을 합니다.

fun main() {
	val naturalNumbers = generateSequence(0) { it + 1 }
	val numbersTo100 = naturalNumbers.takeWhile{ it <= 100 }
	println(numbersTo100.sum())
	// 5050
}

마무리입니다!

이번 글에서는 컬렉션을 다루는 다양한 함수와 시퀀스에 대해 알아봤습니다.

이번 단원을 정리하면서 컬렉션에 대해 연쇄적인 연산을 할 때 연산의 순서에 따라 코드의 성능이 바뀔 수 있다는 사실을 알게 되었습니다. 또한 이 책에서 설명한 안좋은 예시처럼 저도 조건에 맞는 원소의 개수를 찾을 때 filter와 size를 사용해 원소의 개수를 파악했었습니다.

하지만 이런 코드 작성이 효율이 떨어지는 방법이고, 이를 대체하는 메소드가 존재하는 사실을 알게 되며 컬렉션을 다루는 코드를 작성할 때는 표준 라이브러리에서 제공하는 메소드를 잘 찾아보고 코드를 작성해야겠다고 느꼈습니다.

5장과 마찬가지로 많이 사용하는 문법들을 볼 수 있었기에 재밌게 봤던 단원이었습니다.

다음에는 널에 대한 내용 정리로 돌아오겠습니다.

읽어주셔서 감사합니다!🙂‍↕️

profile
천천히, 꾸준히, 한 걸음씩

0개의 댓글