안녕하세요! 피피아노입니다 🎵
이번 포스팅에서는 Combine 프레임워크에서 사용되는 중요한 연산자인 map과 flatMap에 대해서 정리해보려고 합니다.
Combine에 대해서 궁금하신 분들은 여기를 참고해 주시면 됩니다!
그럼 바로 시작하겠습니다!
map 연산자
우선 map 연산자부터 알아보겠습니다. map 연산자는 Publisher에서 방출된 각 값을 다른 형태로 반환하는 연산자입니다. 원본 데이터 스트림의 구조는 유지하면서 내부 값만 변경한다는 부분이 특징입니다.
https://developer.apple.com/documentation/swift/sequence/map(_:)
map(_:) | Apple Developer Documentation
Returns an array containing the results of mapping the given closure over the sequence’s elements.
developer.apple.com
map 연산자의 기본 사용법에 대해서 코드로 살펴보자면
// 기본적인 map 사용법
let publisher = ["a", "ap", "app"].publisher
publisher
.map { searchTerm in
return searchTerm.uppercased()
}
.sink { value in
print("변환된 검색어: \(value)")
}
.store(in: &cancellables)
// 출력:
// 변환된 검색어: A
// 변환된 검색어: AP
// 변환된 검색어: APP
let publisher = ["a", "ap", "app"].publisher는 문자열 배열 ["a", "ap", "app"]의 각 요소를 차례로 발행(Publish)하는 Publisher를 생성합니다. 이때 각 문자열이 하나씩 순서대로 구독자에게 전달됩니다.
그 다음 map 연산자는 발행된 값을 변환하는 역할을 합니다. 여기서는 각각의 문자열을 대문자로 바꾸기 위해 uppercased() 메서드를 사용하고 있으며, "a"는 "A"로, "ap"는 "AP"로 변환됩니다.
.sink는 변환된 값을 최종적으로 구독하고 처리하는 부분입니다. 이 예제에서는 변환된 문자열을 print로 출력해 콘솔에 보여주고 있습니다.
실제 활용 사례로는 네트워크 응답 변환으로 예를 들 수 있는데 iOS 개발에서 네트워크 통신을 할 때, 서버에서 받은 JSON 데이터를 앱에서 사용할 수 있는 모델 객체로 변환하는 과정을 많이 사용하는데 이때 Combine의 map 연산자를 활용하면 훨씬 간결하고 선언적으로 데이터를 처리할 수 있습니다.
struct User {
let id: Int
let name: String
}
// 서버 응답을 User 객체로 변환
URLSession.shared.dataTaskPublisher(for: URL(string: "https://api.example.com/user")!)
.map { data, response -> User in
let decoder = JSONDecoder()
return try! decoder.decode(User.self, from: data)
}
.sink(receiveCompletion: { completion in
print("완료: \(completion)")
}, receiveValue: { user in
print("사용자 이름: \(user.name)")
})
.store(in: &cancellables)
위 코드에서 map 연산자는 핵심적인 역할을 합니다.
dataTaskPublisher는 Data와 URLResponse를 포함한 튜플을 발행하게 되는데, map을 통해 이 Data를 우리가 정의한 User 객체로 변환합니다. 이렇게 하면 이후 단계에서는 더 이상 JSON 파싱에 신경 쓸 필요 없이, User 타입을 바로 사용할 수 있게 됩니다.
flatMap 연산자
다음으로는 flatMap 연산자에 대해서 살펴보겠습니다. flatMap은 map보다 한 단계 더 나아가서 각 값을 새로운 Publisher로 변환한 후 이를 하나의 스트림으로 평탄화한다고 이해하시면 됩니다.
https://developer.apple.com/documentation/swift/sequence/flatmap(_:)-jo2y
flatMap(_:) | Apple Developer Documentation
Returns an array containing the concatenated results of calling the given transformation with each element of this sequence.
developer.apple.com
map 하고 비교해 보자면
map은 배열의 모든 아이템을 바꾸는 기능으로 [1, 2, 3]이 있다고 하고 각각 2를 곱하면 [2, 4, 6]이 되고 flatMap은 중첩된 배열을 풀어서 하나의 배열로 만들어 줍니다. [[1, 2], [3, 4]] 이런 식으로 배열이 존재한다고 하면 [1, 2, 3, 4] 이렇게 펼쳐주는 거죠.
이 내용을 Combine에서는 아래처럼 생각하면 됩니다.
map: 버튼을 누를 때마다 그 숫자에 2를 곱해서 보여줌
- 1 누름 -> 2 보여줌
- 5 누름 -> 10 보여줌
flatMap: 검색창에 글자를 입력할 때마다 검색 결과를 가져오는 경우 (ex: 네이버, 쿠팡 등)
- "a" 입력 -> 검색 요청 -> 결과 표시
- "ap" 입력 -> 새 검색 요청 -> 새 결과 표시
- "app" 입력 -> 또 새 검색 요청 -> 또 새 결과 표시
여기서 각 검색 요청은 새로운 이벤트 스트림(퍼블리셔)을 만들지만, 우리는 결국 하나의 결과 목록만 보고 싶습니다. flatMap은 이런 여러 요청의 결과를 하나의 스트림으로 합쳐줍니다.
쉽게 말해서, flatMap은 "하나의 액션이 또 다른 일련의 액션을 만들 때, 그 모든 결과를 깔끔하게 하나로 모아주는 도구"라고 이해하시면 됩니다.
flatMap도 코드로 살펴보겠습니다.
// 기본적인 flatMap 사용법
let searchTerms = ["a", "ap", "app"].publisher
searchTerms
.flatMap { term -> AnyPublisher<[String], Never> in
return getSearchResults(for: term)
}
.sink { results in
print("검색 결과: \(results)")
}
.store(in: &cancellables)
// 가상의 검색 결과 반환 함수
func getSearchResults(for query: String) -> AnyPublisher<[String], Never> {
let results: [String]
switch query {
case "a":
results = ["apple", "avocado", "airplane"]
case "ap":
results = ["apple", "apartment"]
case "app":
results = ["apple", "application"]
default:
results = []
}
return Just(results)
.delay(for: .seconds(0.5), scheduler: RunLoop.main)
.eraseToAnyPublisher()
}
// 출력:
// 검색 결과: ["apple", "avocado", "airplane"]
// 검색 결과: ["apple", "apartment"]
// 검색 결과: ["apple", "application"]
let searchTerms = ["a", "ap", "app"].publisher는 문자열 배열 ["a", "ap", "app"]의 각 요소를 차례대로 발행하는 Publisher입니다. 즉, "a", "ap", "app"이 순서대로 구독자에게 전달됩니다.
그 다음 .flatMap은 각 검색어에 대해 비동기 작업(여기서는 가상의 검색 결과)을 수행하고, 그 결과를 다시 Publisher로 반환하여 스트림에 합쳐주는 역할을 합니다.
여기에서는 getSearchResults(for:)라는 함수가 호출되어, 해당 검색어에 맞는 결과 배열을 Just로 감싸서 AnyPublisher<[String], Never> 타입으로 반환하고 있습니다.
getSearchResults(for:) 함수는 입력된 검색어에 따라 미리 정의된 결과 배열을 반환하며, delay를 이용해 0.5초 후에 결과가 나오는 것처럼 시뮬레이션하고 있습니다. (실제 네트워크 API를 호출하는 상황을 가정한 예시입니다.)
.sink는 이처럼 비동기적으로 전달된 검색 결과들을 구독하고, 콘솔에 출력하는 역할을 합니다.
실제 검색 기능 구현: map VS flatMap
실제 앱에서 검색 기능을 구현할 때 두 연산자의 차이점을 좀 더 명확하게 볼 수 있습니다. 아까 제가 예시로 든 "app" 키워드를 순차적으로 입력하는 상황을 다시 가져와서 살펴보자면
class SearchViewModel {
@Published var searchText = ""
var cancellables = Set<AnyCancellable>()
init() {
setupSearch()
}
func setupSearch() {
// 방법 1: map을 사용한 경우 (문제가 있는 방식)
$searchText
.map { text -> AnyPublisher<[String], Never> in
return self.search(for: text)
}
// 💥 결과: Publisher<Publisher<[String]>> - 중첩된 Publisher
// 이렇게 되면 결과를 직접 사용할 수 없음
// 방법 2: flatMap을 사용한 경우 (올바른 방식)
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap { text -> AnyPublisher<[String], Never> in
return self.search(for: text)
}
// ✅ 결과: Publisher<[String]> - 평탄화된 단일 스트림
.sink { results in
print("검색 결과: \(results)")
}
.store(in: &cancellables)
}
func search(for text: String) -> AnyPublisher<[String], Never> {
// 실제로는 API 요청이 될 수 있음
let results: [String]
switch text {
case "a":
results = ["apple", "avocado", "airplane"]
case "ap":
results = ["apple", "apartment"]
case "app":
results = ["apple", "application"]
default:
results = []
}
return Just(results)
.delay(for: .seconds(0.5), scheduler: RunLoop.main)
.eraseToAnyPublisher()
}
}
검색 기능에서 map만 사용하게 된다면 map은 각 검색어를 AnyPublisher<[String], Never> 타입으로 변환합니다.
이로 인해 결과 타입은 Publisher<Publisher<[String], Never>, Never>가 됩니다. 이런 식으로 만들어진 중첩된 Publisher 구조는 직접 구독하여 결과를 처리하기 어렵습니다.
하지만 flatMap은 각 검색어를 AnyPublisher<[String], Never]로 변환합니다. 그다음 이러한 Publisher들을 하나의 스트림으로 평탄화해주고 결과 타입은 Publisher<[String], Never>가 되어 직접 구독하고 처리하기 쉬워집니다.
각각 사용해야 하는 상황
각각 사용해야 하는 상황을 정리하자면 아래처럼 정리할 수 있습니다.
map 사용: 단순 데이터 변환이 필요할 때 (ex: 검색어 소문자화/대문자화, JSON 응답을 모델로 변환
flatMap 사용: 검색어마다 API 요청, 여러 단계의 연속된 네트워크 요청
정리
map과 flatMap은 Swift Combine에서 데이터 스트림을 다루는 필수적인 도구입니다. 특히 검색 기능과 같은 실시간 데이터 처리에서 flatMap의 중요성을 이해하는 것이 중요합니다. 이 연산자들을 올바르게 사용하면 복잡한 비동기 작업을 간결하고 선언적인 방식으로 처리할 수 있습니다.
감사합니다.
잘못된 내용이 있거나 더 좋은 내용 피드백은 언제나 환영합니다!
궁금하신 부분은 댓글로 질문 부탁드립니다!
'Apple > Swift' 카테고리의 다른 글
[Swift] Tuist 살펴보기 (0) | 2025.04.27 |
---|---|
[Swift] 의존성 주입(Dependency Injection)이란? (0) | 2025.04.18 |
[Swift] Subscript 이해하기 (4) | 2024.12.30 |
[Swift] Actor 이해하기 (2/2) (4) | 2024.12.15 |
[Swift] 두 정수 사이의 합 (4) | 2024.12.05 |