[iOS] combine에서 flatmap을 쓰는이유가 뭘까?

Youth·2023년 11월 27일
3

고찰 및 분석

목록 보기
5/21

안녕하세요~
오늘도 combine관련 포스팅으로 돌아온 킴스캐슬입니다
요즘 combine관련포스팅을 위주로 올리고있는거같은데 이게 제 입장에선 새로운 프레임워크인데다가 같이 딥다이브해줄 팀원이있으니까 새로알게되는 내용이 정말 많고 combine의 본질을 이해하는과정을 느끼고있어서 그런지 combine이 정말 재미있는것같아요

오늘 주제는 combine에서 flatmap은 어떤 역할을할까?입니다
왜 굳이 이런 주제를 가져오게되었는지를 말씀드리면 제가 지금 진행하고있는 프로젝트에서는 비동기처리를할때 flatmap을 사용하는데요, 우리가 기존에 알고있는 고차함수에서의 flatmap은 단순히 n차원의 배열을 n-1차원의 배열로 만들어주는 역할을 하잖아요?

근데 combine의 flatmap은 이름은 똑같은데 어떤 element를 받아서 publisher를 return해주는 역할을 합니다

여기서 제가 조금 헷갈렸었던것 같아요

내가 알고있는 flatmap의 역할이랑 combine에서 flatmap이랑 뭔가 하는일이 다른거같은데다가 제 기준으로 combine을 사용할 때 flatmap을 사용하는 이유가 비동기처리를할때 쓰니까 combine의 flatmap은 뭔가 다른가? 싶더라고요 그래서 이런 궁금증을 풀기위해서 이런 주제로 포스팅을 작성하게 되었습니다

combine의 flatmap은 내가 알고있던 flatmap이랑 다른데?
combine의 flatmap을 왜 비동기 처리할때 쓰는거지?

라는 의문이 한번이라도 들어보셨던 분들에게 도움이 될만한 포스팅이 될것같습니다

Reactive Programming의 매커니즘

우선 한가지 가정을 해보겠습니다
async/await으로 프로젝트의 네트워킹을 구현했고 리팩터링을 진행하는와중에 combine을 사용하게된 상황입니다
실제 제가 진행하고 있는 프로젝트의 상황이기도합니다

reactive programming의 매커니즘을 간단하게 정리해보겠습니다
기존의 비동기처리는 completion handler를 통해서 진행했죠(GCD방식으로 말이죠)

함수가 비동기를 통해 데이터를 받아오기를 기다리지 않고 데이터가 받으면 비로소 escaping closure에 데이터를 input으로 넣어주고 특정 동작을 하는 방식으로 이뤄졌습니다

그런데 이렇게 개발을 하다보니 여러가지 문제점이 보이기시작했습니다

Combine을 사용해야하는 이유
https://velog.io/@kimscastle/iOSCombine%EC%9D%80-%EC%99%9C-%EC%8D%A8%EC%95%BC%ED%95%A0%EA%B9%8C

그래서 그런 문제를 해결하고 조금더 편리하고 직관적으로 사용하기 위해서 stream이라는 개념을 도입했습니다

네트워크통신의 결과를 기다리기보다는 바로 값을 원하는 곳에 stream이라는 데이터가 흘러 전달될수있는 연결선같은걸 return을 통해 연결해놓고 시간이 지나고 네트워크를통해 값이 들어오면 연결선에 그 값을 흘려보내주면 값을 전달받을수있게 된 것이죠

이런 메커니즘을 쉽게 사용할 수 있는게 Combine의 DataTaskPublisher입니다

URLSession.shared
  .dataTaskPublisher(for: url)
  .map(\.data)
  .decode(type: MyDataType.self, decoder: JSONDecoder())
  .sink(receiveCompletion: { completion in
    ...completion호출...
  }, receiveValue: { value in
    ...값받으면 동작할 closure...
  })

위의 코드를 보면 .dataTaskPublisher를 통해서 바로 publisher를 return받게됩니다 값이 언제올지는 모르겠지만 바로 publisher를 return해줄수는 있죠, 연결은 쭉 되어있고 나중에 값을 받으면 그때 값을 보내주면되는거니까요

Async/Await + Combine

사실 이렇게 Combine에서는 dataTaskPublisher 를 쓰는게 편리하긴 할거라고 생각이 들긴합니다만
초반에 말씀드렸던것처럼 우리는 이미 async/await을 통해서 네트워크 통신을 해줬기 때문에 이를 활용해야합니다

열심히 구글링을 해보니까 미래의 값을 받아서 그 값을 stream에 흘려주는 Future라는 친구가 Combine에 있다는걸 알게되었습니다

그리고 우리가 completion을 쓰던것처럼 future의 promise라는 클로저안에 성공했을때의 enum case의 연관값으로 네트워크 결과를 넣어주면 async/await을 통한 네트워킹 결과를 Future라는 Publisher의 stream에 흘려보내줄수있게됩니다

이부분은 Future의 사용법에 관한 내용이기 모르시는분들은 구글링을 해봐도괜찮고 제가생각했을때 크게 중요한부분은 아니니 async/await을 stream형태로 만들기위해서 combine의 future를 사용했구나 정도로 이해하시고 넘어가셔도 괜찮습니다😀

func makePublisher() -> AnyPublisher<ChallengeData, NetworkError> {
    return Future<ChallengeData, NetworkError> { promise in
        Task {
            do {
				/// async/await을 통해 얻은 데이터 inputData를 promise라는 클로저의 input으로 넣어주는데 .success라는 enum의 연관값으로 넘겨준다
                let inputData = try await self.manager.inquireChallengeInfo()
                promise(.success(inputData))
            } catch {
                /// 에러가 발생하게되면 .failure의 연관값으로 값을 넘겨준다
                promise(.failure(error as! NetworkError))
            }
        }
    }
    .eraseToAnyPublisher()
}

위의 코드는 최대한 간단하게 async/await을 통한 네트워크 결과를 stream에 흘려보내주는 방식입니다, 그 과정에서 Future라는 친구의 도움을 받았습니다

viewWillAppear가 호출되었을때 이렇게 makePublisher라는 메서드를 통해서 publisher를 return시켜주면 chaining을 통해서 다른곳에서도 그 값을 받아서 뭔가를 실행할수있겠죠

// ViewModel내부
let viewWillAppearSubject = input.viewWillAppearSubject
    .map { _ -> AnyPublisher<ChallengeData, NetworkError> in
        return self.makePublisher()
    }
		.eraseToAnyPublisher()

return ChallengeViewModelOutput(viewWillAppearSubject: viewWillAppearSubject)

// ViewModel외부
viewWillAppearSubject.sink { 들어온 ChallengeData라는 값을 가져다 쓰면됩니다 }

코드가 좀 복잡해 보이지만 천천히 해석해보면 그렇게 어렵지 않습니다

우선 viewWillAppear가 호출되면 VC에서 VM에 알려줍니다 그러면 makePublisher()라는 메서드를 실행해주고 그 결과로 AnyPublisher<ChallengeData, NetworkError>라는 publisher로 mapping해줍니다

그리고 그 publisher를 Output객체에 넣어서return해주면 ViewModel외부에서는 Output의 viewWillAppearSubject라는 Publisher에 접근을 하고 sink를 통해서 추후에 네트워킹을 통해 얻게된 값을 알수있게되는겁니다

근데 여기서 살짝 고민해봐야하는 부분이 존재합니다

사실 바로위의 코드에서 틀린부분이 있는데요
바로 map이라는 operator의 return type입니다, map은 특정 값을 특정 값으로 바꿔주는 역할을 해주는 operator죠, 현재 viewWillAppear라는 input의 타입은 아래와같이 생겼습니다

let viewWillAppearSubject: PassthroughSubject<Void, Never>

근데 우리가 Void라는 값을 AnyPublisher<ChallengeData, NetworkError>로 mapping해준다는건
void라는 값만 AnyPublisher<ChallengeData, NetworkError>로 바꿔준다는 뜻이기때문에 아래과 같은 구조가 됩니다

기존 :PassthroughSubject<Void, Never>
mapping후 : PassthroughSubject<AnyPublisher<ChallengeData, NetworkError>, Never>

Publisher안에 Pulbisher가 들어있는 형태가 되고 실제로 값을 흘려보내주는 Publisher는 내부의 Publisher이기때문에 이중으로 sink를 해야하는 문제가 발생하게됩니다

sink내부에 sink를 하게되면 cancelBag에 들어가는 AnyCancellable이 매번 2배로 늘어나게되고 캡처현상에 따른 weak self또한 중첩해서 써줘야합니다

아마 비동기처리를 통해서 publisher에서 바로 값을 sink하는 그림을 떠올렸겠지만 단순히 map을 사용하게되면 비동기처리의 값을 받아올수는 있으나 조금 껄끄러운 방식이 되게 되는거죠

2중 publisher를 1중 publisher로 만들게되면 우리가 상상하는대로 값을 sink로 받아서 사용할수있게될거고 코드도 깔끔해지고 불필요한 중첩구조때문에 골머리썩을일이 없게됩니다

그리고 이런 방식을 flat하게 만든다고 이야기하고 이렇게 만들어주는 operator가 flatmap입니다

let viewWillAppearSubject: AnyPublisher<ChallengeData, NetworkError>

⭐️중요⭐️combine에서 flatmap의 동작 정의가 인자를 받아서 publisher를 반환해준다는 의미가 stream관점에서 flat하게만들어준다는 뜻이였던겁니다

flatmap을 사용하게되면 기존의 2중 publisher중첩구조를 위와같이 중첩없는 stream형태로 만들수있습니다, 기존에 map을 사용하던 코드는 아래와같이 바꿀수있습니다

let viewWillAppearSubject = input.viewWillAppearSubject
    .flatMap { _ -> AnyPublisher<ChallengeData, NetworkError> in
        return Future<ChallengeData, NetworkError> { promise in
            Task {
                do {
                    let inputData = try await self.manager.inquireChallengeInfo()
                    promise(.success(inputData))
                } catch {
                    promise(.failure(error as! NetworkError))
                }
            }
        }
        .eraseToAnyPublisher()
    }
    .eraseToAnyPublisher()

return ChallengeViewModelOutput(viewWillAppearSubject: viewWillAppearSubject)

중요한 내용이니 한번 더 한줄 정리를 해보면 Combine의 flatmap 설명을 보면 input을 Publisher로 변환해준다 는 글을 보셨을텐데요 결국은 Publisher로 변환해준다는말의 뜻이 Publisher를 flat하게 만들어주기 위한 역할을 했다고 보시면 됩니다
이렇게 만들면 우리가 늘 sink를 쓰던 방식처럼 사용하면됩니다

output.viewWillAppearSubject
    .receive(on: RunLoop.main)
    .sink { completion in
        print(completion)
    } receiveValue: { value in
        print(value)
    }
    .store(in: &cancelBag)

제가 가장 많이 들었던 말중에서 flatmap은 비동기처리를 위해 쓰이는 operator입니다 라는 말이 있었는데요

처음에는 이 이야기를듣고 flatmap이 비동기처리를 해주는 특수한 로직이 있다고 생각했었습니다. 그런데 막상 공부를 해보니 그렇다기 보다는 flatmap자체는 우리가 고차함수에서 보던 flatmap이랑 역할이 동일하고, n차원의 publisher를 flat하게 만들어주는 역할을 해주는데 이 기능이 reactive programming에서 비동기처리를하는데 있어서 아주 중요한 역할을 해주기때문에 flatmap은 비동기처리를 위해 쓰인다라고 이야기를 하는게 아닐까 라는 생각을 하게 되었습니다

WWDC에서의 Flatmap활용법

혹시 WWDC의 Combine영상을 한번쯤 보신분들이라면 이런 질문을 해주실수도있습니다

flatmap이 비동기처리를 위한 뭔가 특수한 로직이 있는게 아니었군요… 그럼 WWDC에서 말한 flatmap을 통한 stream 유지는 flatmap의 비동기처리를 위한 장점이라고 봐도 되는거아닐까요…?

WWDC에서는 catch라는 error handling operator를 통해 error가 발생하더라도 stream이 끊어지지 않는다는 이야기를 합니다

저도 사실 이영상에서 해당 내용을 봤고 실제로 그렇게 동작하는 코드를 짰었기때문에 이런 기능이 flatmap을 사용했기에 가능했던 기능이라는 생각을 하기도 했습니다

우선 WWDC 이야기를 들어가기전에 catch에 대한 이야기를 해보겠습니다, catch는 제가 따로 포스팅을 작성할예정인데 여기서는 간단하게 설명해보겠습니다

publisher의 completion은 enum입니다 failure가 있고 finished가 있습니다 이 말은 error가 발생하거나 데이터가 다 들어오면 completion이 호출된다는 뜻이고 completion이 호출된다는 뜻은 해당 publisher와 subscriber의 steam이 강제로 끊어진다는 뜻이됩니다

근데 finish는 고려하지 않아도 됩니다 어짜피 cancelbag에 넣어주고 vc가 메모리에 해제될때 finish가 되지 view가 존재하는한에는 publisher의 finish가 불리진 않을거니까요(일부러 끝내지않는이상 ㅎㅎ…)

우리가 네트워크통신을 하다보면 여러가지 error가 발생합니다 그런데 그때마다 completion의 failure가 발생하게되면 stream이 끊기게됩니다

예를 들어볼까요?

우리가 어떤앱에서 로그인버튼을 눌렀다고 해봅시다(그 view에는 로그인버튼밖에없다고 가정해볼게요) 근데 로그인을하려하니갑자기 서버쪽에서 에러가 발생해서 유저에게 메세지를띄워줍니다 잠시 오류가 있으니 잠시후에 시도해주세요 라고요, 근데 error가 발생한순간 button과 네트워크간의 stream은 끊어집니다 유저가 아무리 로그인 버튼을 눌러도 아무런 반응을 하지 않을겁니다

결국은 앱자체를 껐다가 켜야하는 상황이되는거죠

어떤 view에서 유저가 어떤 액션을 했을때 문제가 발생했으면 다시 누르거나 재시도를 할수있는 기회나 상황이 주어져야합니다 에러한번발생할때마다 앱을껐다켜야하면 조금 별로일것같네요..ㅎㅎㅎ

그래서 에러가 발생했을때 에러대신 같은 output type을 보내주는 Publisher를 return시켜주는 역할을 하는 녀석이 catch입니다

이제 WWDC의 내용을 살펴볼까요?

만약에 upstream에서 에러가 발생하면 그 error를 catch라는 operator가 감지하게됩니다

그런상황이 발생하면 error를 던지는 upstream대신 catch가 recovery publisher라는 publisher를 만들어서 subscriber와 연결시켜줍니다

근데 이렇게 catch를 사용하면 어떤문제가 있을까를 생각해보야합니다

예를들어서 우리가 방금 들었던 예시를 보면 로그인을했을때 예를들어서 성공적으로 로그인을하면 string타입의 토큰을 받는다고 해보겠습니다

그런데 error가 발생을 했고 catch가 같은 output type을 반환하는(string타입을 반환하는) Publisher를 만들어서(이게 recovery publisher겠네요) subscriber에 연결을 해줍니다

그리고 catch는 빈문자열을 stream에 흘려보내줍니다

catch가 없었다면 에러가 발생했을떄 completion의 failure가 불려서 에러발생후에는 버튼과 네트워크통신코드의 stream이 끊어져서 버튼을 다시눌러도 아무런 반응이 없겠지만

catch를 활용하면 error가 발생했을때 catch가 빈문자열을 보내주는 stream을 만들어 연결시켜줬기에 버튼을 눌렀을때 아무런반응이 없지는 않습니다 하지만 문제는 버튼을 아무리 눌러도 빈문자열을 보내주는 stream

과 연결이 되었기때문에 버튼을 눌러도 네트워크통신을 할 수 없게됩니다, 그리고 이런부분은 flatmap을 통해서 해결할수있다고 합니다

결국 upstream의 값을 토대로 새로운 publisher를 만들어주는 flatmap을 활용한다면 error가 발생했을때 catch가 return해주는 publisher를 downstream에 연결시켜줄지 아니면 error가 발생하지 않아서 flatmap을 통한 publisher를 downstream에 연결시켜줄지를 catch가 결정하게 됩니다

이걸 좀더 쉽게 풀어 설명을 해보면 button과 네트워크레이어간의 stream을 연결시키고 error가발생하면 catch에서 값을 받아서 stream에 흘려보내주고(위에서 말했던것처럼 빈문자열을 보내주겠네요) error가 발생하지 않으면 flatmap을 통한 publisher에서 값을 받아서 값(네트워킹을 잘한 경우니까 토큰값을 보내주겠네요)을 흘려보내주게됩니다

이렇게되면 어떤 장점이 있을까요?

애초에 버튼과 네트워크간의 stream은 에러가 발생하지 않기에 끊기지 않습니다(catch덕분에요)근데 통신을 요청할때마다 flatmap의 데이터를 보낼지 catch의 recovery publisher의 데이터를 보낼지만 catch가 매번 결정해서 값을 보내주는 flow가 됩니다

제가 말씀드렸던 예시상황에 대입해보면 처음에 버튼을 눌러서 error가 발생했다면 버튼은 네트워크레이어와 stream이 연결되어있기에 네트워크에게 값을 요청하지만 error가 발생하면 error의 recovery publisher에서 값을 보내주고 error가 아니라면 flatmap을 통한 publisher에서 값을 보내줍니다 유저는 error가 발생했던 안했던 동일한 타입의 값을 받게 되기에 에러로 부터 안전할수있습니다, 그리고 재시도를 하기위해 버튼을 다시 눌러도 여전히 버튼과 네트워크간의 stream은 살아있으니 다시 값을 요청할거고 이후에는 성공해서 flatmap에서 알맞는값을 보내주게되고 로그인에 성공할수있게됩니다

WWDC에서 언급한 이 예시를 보고 flatmap자체의 기능이라고 생각했었을수있지만 flapmap만 map으로 바꾸고 return type을 이중 publisher로 바꾸면 똑같이 실행됩니다

func transform(input: Input) -> Output {
    let loginSuccess = input.kakakoLoginButtonTap
        .map { _ -> AnyPublisher<String, Never> in
            return Future<String, NetworkError> { promise in
                ...생략...
            }
            .catch { error in
                self.handleError(error)
                return Just(error.description)
            }
            .eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
    
        return Output(loginSuccess: loginSuccess)
}

이렇게 한다해도 결국 catch가 recovery publisher에서 값을 보내줄지 map을 통해서 만들어진 publisher에서 값을 보내줄지를 결정하기에 sink하는쪽에서 이중으로 sink만사용한다면 똑같이 error가발생하더라도 버튼이 눌리고 재 시도했을때 성공한다면 로그인을 성공할 수 있게됩니다

output.loginSuccess
    .sink(receiveValue: { publisher in
        publisher.sink(receiveValue: { value in
            print(value)
        })
        .store(in: &self.cancelBag)
    })
    .store(in: &cancelBag)

flatmap을 안써도 되는건가요?

지금까지 포스팅을 읽으신분들이라면 살짝 이런생각이 드실수도있을것같습니다

그래서 하고 싶은 말이 뭐죠?

제가 이번포스팅을 통해 하고싶었던 말은 map이나 flatmap이나 비동기처리를 위한 특별한 기능을 가진 operator가 아니었고 map은 mapping하는 역할이고 flatmap은 차원을 낮춰주기위한 operator지만 우리가 비동기 처리를 하려고보니 flatmap의 flat하게만들어주는 기능을 사용하면 비동기처리를 조금 더 쉽게할수있었기에 flatmap을 주로 사용을 해왔고 flat하게 만들어주지 않고 map을 사용해도 flatmap과 같은 기능을 구현할수있다는걸 말씀드리고 싶었습니다

WWDC에서 말하는 combine에서 비동기처리를할때 flatmap의 장점또한 map도 가지고 있다는것도 포함해서 말이죠

비동기처리할때 map을 써보자는 이야기가 아닙니다(굳이 이중으로 sink를 쓸이유는 1도 없습니다..ㅎㅎ) 다만 flatmap을 쓰는 이유를 아는것이 중요하고 왜 map은 쓰면 불편한지를 아는것이 중요하다고 생각했습니다

결론은 비동기 처리를 할때 map을 굳이 쓸이유는 없을것같고 flatmap을 쓰면 될것같습니다일것같습니다

다만 flatmap이 map에 비해서 특출난 뭔가가 있는건아니다 그냥 각자의 역할이 있는데 Combine의 비동기처리에서는 flatmap을 쓰는게 나은 선택이다정도로 기억해주신다면 이번포스팅에서 제가 전달하고싶은 부분은 전달이될것같습니다


뭔가 당연한 얘기를 하기 위해서 빙빙 돌아온것같은 느낌이 드는감이 없지 않아있는데요 그래도 저는 map과 flatmap이 combine에서 어떤 공통점을 가지고있으며 비동기에서 flatmap이가지는 장점에 대해 알고나서는 속이 많이 시원해진 느낌이었습니다 ㅎㅎ 내가 그동안 자연스럽게 써왔던 코드들에 이유가 달려서 힘이 붙은느낌이랄까요 아무튼 그렇습니다

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

0개의 댓글