RxJS로 우아하게 사이드 이펙트 통제하기

프론트엔드에서 가장 다루기 어려운 "비동기" 길들이기

Daniel • Daeyong Yoon, Front-end engineer

  • Frontend

안녕하세요 채널톡에서 고객용 메신저를 만들고 있는 프론트엔드 엔지니어 대니얼입니다.

채널톡 고객용 메신저는 고객사 사이트에 SDK 형태로 설치되는 제품입니다. 고객사가 저희가 제공하는 스크립트를 사이트에 심으면 오른쪽 하단에 채널톡 버튼이 나타나고, 사이트를 방문한 유저는 이 버튼을 눌러 고객사에 궁금한 것들을 바로 문의할 수 있습니다.

이번 글은 한 고객사의 "소켓 요청이 계속 실패하고 있어요"라는 제보에서 시작해서, 결국 고객용 메신저의 상태 관리와 사이드 이펙트 구조를 통째로 갈아엎게 된 이야기를 소개드리려고 합니다! 단순히 버그 하나를 고치는 것으로 끝낼 수도 있었는데 왜 구조를 바꾸기로 했는지, 어떤 방법들을 고민했는지, 그리고 최종적으로 어떤 구조에 도달했는지 순서대로 이야기할게요.

배경

2023년, 한 고객사로부터 제보가 들어왔습니다. 크롬 개발자 도구의 네트워크 탭을 열어보면 채널톡이 보내는 소켓 관련 요청이 계속 실패하고 있다는 내용이었습니다.

실제로 고객사 사이트에 접속해 재현해 보니 아래처럼 소켓 연결을 위한 요청이 끊임없이 실패하고 있었습니다.

원인을 파악해 보니 고객사에서는 채널톡을 아래와 같이 사용하고 있었습니다.

JavaScript

채널톡은 고객사에서 메신저를 컨트롤할 수 있도록 다양한 인터페이스를 제공합니다. 그중 boot는 채널톡을 실행하기 위해 반드시 호출해야 하는 인터페이스이고, shutdown은 채널톡을 종료하며 내부 리소스를 정리하는 인터페이스입니다. 코드만 보면 문제가 없어 보입니다. 켜고, 끄고, 다시 켜는 것뿐이니까요.

하지만 내부적으로 boot는 단순히 네트워크 요청 하나로 끝나는 인터페이스가 아닙니다. boot 요청이 성공하면 인증 토큰(sessionJWT)을 저장하고, 이어서 소켓 연결을 시도합니다. 소켓 연결도 한 번에 되는 것이 아니라, 먼저 게이트웨이 서버에 어떤 주소로 소켓을 연결해야 하는지 물어본 뒤 응답받은 주소로 연결하고, 연결이 되면 인증 토큰으로 authenticate까지 마쳐야 비로소 완료됩니다. 그리고 shutdown은 이 모든 것들(인증 토큰, 스토어 상태, 소켓)을 초기화하며, boot는 시작할 때 항상 내부적으로 shutdown을 먼저 실행해 이전 리소스를 정리합니다.

여기서 중요한 점이 하나 있습니다. ChannelIO의 인터페이스들은 동시에 실행되지 않습니다. 한 인터페이스가 완료되면 그다음 인터페이스가 실행되는 순차 구조입니다. 그래서 위 코드도 첫 번째 boot가 완료된 뒤에 shutdown이 실행되고, shutdown이 완료된 뒤에 두 번째 boot가 실행됩니다. 여기까지만 보면 안전해 보입니다.

문제는 "완료"의 기준이었습니다. 위에서 본 것처럼 boot는 네트워크 요청 이후에도 소켓 연결 같은 다양한 사이드 이펙트를 이어서 발생시킵니다. 그런데 당시 채널톡은 boot 네트워크 요청의 응답이 오면 boot가 완료된 것으로 간주하고 있었습니다. 즉, 첫 번째 boot가 "완료"된 시점에도 소켓 연결이라는 사이드 이펙트는 여전히 진행 중이었고, 그 상태에서 다음 인터페이스인 shutdown과 두 번째 boot가 실행된 것입니다. 순차 실행이 보장되고 있다고 믿었지만, 실제로는 첫 번째 boot가 남긴 사이드 이펙트와 두 번째 boot가 병렬로 실행되고 있었던 것이죠. 그 결과 위 코드는 아래와 같은 순서로 흘러갑니다.

  1. 첫 번째 boot가 성공하고, 채널톡은 완료됐다고 판단합니다. 하지만 실제로는 아직 소켓 연결이라는 후속 작업이 남아있습니다.

  2. 그 사이 두 번째 boot가 실행되면서 내부적으로 shutdown이 실행되고, 첫 번째 boot가 받아뒀던 인증 토큰과 스토어 상태가 초기화됩니다.

  3. 첫 번째 boot의 후속 작업이었던 소켓 연결은 성공하지만, authenticate 시점에 토큰이 없어 인증에 실패하고 서버가 연결을 끊습니다.

  4. 재연결 로직이 실행되지만 스토어가 이미 초기화되어 소켓 주소를 받아오는 게이트웨이 서버로의 요청에 필요한 값들이 이미 빈 상태로 게이트웨이 서버에 요청을 보내고, 이 요청은 인터벌로 영원히 실패를 반복합니다.

한 줄로 요약하면 이렇습니다. "첫 번째 액션이 발생시킨 사이드 이펙트가 아직 진행 중인데, 다음 액션이 그 위를 밟고 지나갔다."

현상만 고치면 될까?

사실 이 버그 자체는 방어 코드로 막을 수 있습니다. boot가 진행 중일 때 들어온 boot를 무시하거나 잠시 기다리게 하면 됩니다.

하지만 저희는 이 버그가 개별 버그가 아니라 구조가 만들어내는 버그의 한 사례라고 판단했습니다. 이유는 SDK라는 제품의 특성에 있습니다.

첫째, 인터페이스가 호출되는 타이밍을 저희가 통제할 수 없습니다. 채널톡은 boot, showMessenger, track 같은 수십 개의 인터페이스를 고객사에 제공합니다. 일반적인 웹 서비스라면 사용자의 플로우를 어느 정도 예측할 수 있지만, SDK는 고객사에서 어떤 인터페이스를 어떤 순서로, 얼마나 빠르게 연달아 호출할지 알 수 없습니다. 위 사례처럼 boot 직후에 shutdownboot를 다시 호출할 수도 있고, boot가 끝나기도 전에 track을 호출할 수도 있습니다.

둘째, 각 인터페이스는 단발성 함수가 아니라 사이드 이펙트의 연쇄입니다. boot는 인증 → 소켓 주소 조회 → 소켓 연결 → 소켓 인증까지 이어지는 비동기 체인이고, 심지어 boot 이전에 호출된 인터페이스들은 큐에 쌓아뒀다가 boot 완료 후 순차적으로 실행해줘야 합니다. ChannelIO('track', ...)은 이벤트를 서버로 보내고 서버가 이에 매칭되는 마케팅 팝업을 소켓으로 내려주기 때문에, 소켓 연결이 완료된 이후에 실행되어야 의도한 동작이 보장됩니다. "이 액션은 저 액션이 완료된 후에 실행되어야 한다"는 순서 제약이 비즈니스 요구사항 곳곳에 숨어있는 것이죠.

셋째, 모든 인터페이스가 전역 상태를 공유합니다. 각 액션은 전역 상태를 읽고 변경합니다. 앞선 액션의 사이드 이펙트가 상태를 변경하는 도중에 다음 액션이 끼어들면, 네트워크 지연 시간에 따라 매번 다른 결과가 나올 수 있습니다. 재현도 어렵고, 재발도 막을 수 없습니다.

당시 구조의 문제를 정리하면 두 가지였습니다.

  1. 액션이 "완전히 끝난" 시점을 알 수 없다. boot의 완료 기준이 boot 요청의 응답이었던 것처럼, 액션이 발생시킨 사이드 이펙트 전체가 끝나는 시점을 아무도 모릅니다. 완료 시점을 모르니 "이전 액션이 끝난 뒤에 다음 액션을 실행한다"는 가장 기본적인 제어조차 불가능합니다.

  2. 한 액션에서 벌어지는 일들이 코드 곳곳에 파편화되어 있다. 하나의 액션이 유발하는 로직이 epic, middleware, 컴포넌트, 서비스 클래스에 흩어져 있어서 "boot가 호출되면 무슨 일이 일어나는가?"에 답하려면 파일 네다섯 개를 넘나들어야 했습니다.

그래서 결론을 내렸습니다. 방어 코드로 이 버그 하나를 막는 것이 아니라, 비즈니스 로직의 실행 흐름을 철저히 통제할 수 있는 구조를 만들어야 한다고요.

당시 채널톡은 redux와 redux-observable을 사용하고 있었습니다. redux-observable은 액션(action)이 발생하면 이를 스트림으로 받아 사이드 이펙트를 처리하는 미들웨어이고, 이때 액션을 구독해 사이드 이펙트를 정의해두는 단위를 epic이라고 부릅니다. 그리고 이 스트림을 다루는 기반 라이브러리가 RxJS입니다. RxJS는 클릭, 네트워크 응답, 소켓 메시지처럼 시간에 따라 발생하는 값들을 하나의 흐름(스트림)으로 보고, 이를 조합하고 변환하는 라이브러리입니다.

비즈니스 로직을 하나의 흐름으로 — RxJS

채널톡의 비즈니스 로직에는 여러 종류가 있습니다. 복잡한 계산 로직도 있고, 네트워크 요청 같은 비동기 로직도 있으며, 소켓 메시지나 ChannelIO 인터페이스 호출처럼 언제 도착할지조차 알 수 없는 비동기 로직도 있습니다. 이 모든 로직의 실행 흐름을 통제하려면, 흐름 자체를 일급으로 다룰 수 있는 도구가 필요했습니다. 저희의 선택은 RxJS였습니다.

이유는 이렇습니다.

이미 어느 정도 익숙한 도구였습니다. redux-observable을 통해 RxJS를 계속 사용해 왔기 때문에 팀의 러닝 커브 부담이 적었습니다.

흐름을 코드 순서 그대로 쓸 수 있습니다. RxJS의 pipe는 비즈니스 로직 하나하나를 연산자로 순차 연결합니다. "이전 boot를 기다리고 → 채널 정보를 조회하고 → boot 요청을 보내고 → 소켓을 연결한다"는 흐름이 코드에서도 그 순서 그대로 위에서 아래로 읽힙니다. 그리고 mergeMap(병렬 허용), switchMap(최신 것만), exhaustMap(진행 중이면 무시), concatMap(순차 보장) 같은 풍부한 연산자 덕분에, 복잡한 비동기 요구사항도 콜백 지옥 없이 선언적으로, 매우 정교하게 표현할 수 있습니다.

지연 평가 덕분에 성능과 메모리에서도 이득이 있습니다. observable은 정의하는 시점이 아니라 구독하는 시점에 실행됩니다. 필요할 때만 실행되니 불필요한 연산이 발생하지 않고, 구독을 해제하면 진행 중이던 파이프라인 전체가 함께 정리되기 때문에 실행을 취소하거나 리소스를 회수하는 것도 명확합니다.

pipe의 정의는 순수한 "실행 계획"입니다. pipe를 정의하는 것 자체로는 아무 일도 일어나지 않습니다. 네트워크 요청이나 상태 변경 같은 사이드 이펙트는 스트림이 실제로 실행될 때, 그것도 tap처럼 명시된 지점에서만 발생합니다. 덕분에 로직 조각을 떼어내 테스트하기 쉽고, 어디에서 사이드 이펙트가 발생하는지 코드만 보고도 파악할 수 있습니다.

작은 조각을 조합해 큰 조각을 만들 수 있습니다. pipe로 모듈화된 비즈니스 로직은 하나의 작은 조각이 되고, 이 조각들을 이어붙여 더 큰 pipe를 만들 수 있습니다. 비즈니스 로직을 잘게 쪼갠 뒤 조합해서 새로운 흐름을 구성하는 방식이라 재사용성이 매우 뛰어납니다.

말로만 설명하면 감이 잘 안 올 수 있으니, boot의 실제 구조를 단순화한 코드를 보여드리겠습니다. (코드는 설명을 위한 pseudo code입니다.)

TypeScript

"이전 boot 대기 → 채널 정보 조회 → boot 요청 → 초기화 → 소켓 연결 → 후속 작업"이라는 흐름이 코드 순서 그대로 읽힙니다. 그리고 effects.getChannelInfo.pipe처럼 독립적으로 정의된 다른 effect의 pipe를 그대로 이어붙여 조합하고 있습니다. 채널 정보 조회 로직은 boot뿐만 아니라 다른 흐름에서도 같은 방식으로 재사용됩니다.

무엇보다, "boot가 호출되면 무슨 일이 일어나는가?"라는 질문에 대한 답이 이제 이 함수 하나에 있습니다. 예전에는 epic, middleware, 컴포넌트, 서비스 클래스에 흩어진 파일 네다섯 개를 넘나들어야 했던 질문인데 말이죠.

상태 관리도 흐름 안으로 — zustand

비즈니스 로직을 RxJS의 pipe로 모으다 보니, 상태 관리에도 변화가 필요했습니다. 비즈니스 로직이 실행되는 도중에 필요한 시점에 바로 상태를 변경할 수 있어야 했기 때문입니다.

redux에서는 이게 안 됩니다. epic 안에서 비즈니스 로직 실행 중에 상태를 업데이트하고 싶다면 epic에서 특정 액션을 내보내 dispatch시켜야 하고, 이렇게 dispatch된 액션은 middleware를 거쳐 reducer에 도달해서야 상태가 업데이트됩니다. 비즈니스 로직의 흐름 중간에 상태 변경이 필요할 때, 흐름 그대로 이어서 실행할 수가 없었던 것이죠.

그래서 새로운 상태 관리 모듈로 zustand를 선택했습니다. zustand는 store.set()을 호출하면 동기적으로 즉시 상태가 업데이트되기 때문에, 위 boot 코드의 tap 안에서처럼 pipe의 흐름 중간에 상태 변경까지 포함해서 코드 흐름 그대로 비즈니스 로직을 실행할 수 있습니다. 이런 설계가 가능한 모듈로 jotai 같은 후보도 있었지만, zustand는 인터페이스가 매우 단순하고 가벼우면서 저희가 필요로 했던 기능을 충분히 제공하고 있어서 zustand로 결정했습니다.

뷰는 뷰만이 할 수 있는 일을 하자

비즈니스 로직을 pipe로 모으는 작업을 하다 보니, 또 하나 눈에 들어온 것이 있었습니다. 바로 컴포넌트 안에 있는 수많은 비즈니스 로직이었습니다. useEffect 안에서 실행되는 동작이나 이벤트 핸들러 안에서 처리되는 로직들은 컴포넌트가 관리하고 있었기 때문에 pipe에서 컨트롤할 수 없었습니다.

물론 컴포넌트에서 처리해야 하는 로직도 있습니다. 컴포넌트는 뷰를 담당하는 레이어이므로 UI와 관련된 로직은 컴포넌트가 관리하는 것이 맞습니다. 하지만 UI와 관련 없는 비즈니스 로직까지 불필요하게 컴포넌트에서 관리되는 경우가 많았습니다.

그래서 제품 전반의 디자인 패턴을 이렇게 정리했습니다. 뷰(컴포넌트)는 뷰만이 할 수 있는 일, 즉 화면을 보여주고 사용자의 이벤트를 받는 일을 한다.

  • 사용자가 버튼 클릭, 폼 제출 등을 하면 뷰는 특정 action을 호출합니다. RxJS로 구현된 pipe를 실행하는 것이죠. 여기서 뷰의 역할은 끝입니다.

  • 호출된 action은 pipe를 트리거하고, 비즈니스 로직이 순차적으로 실행됩니다. 그 사이에 상태 변경이 필요하면 zustand의 상태를 동기적으로 바로 업데이트합니다.

  • 업데이트된 store는 selector를 거쳐 컴포넌트가 사용할 수 있는 형태의 데이터로 변형되고, 컴포넌트는 이 데이터로 리렌더링하며 UI를 업데이트합니다.

  • 업데이트된 UI는 다시 사용자의 이벤트를 기다리고, 같은 과정이 반복됩니다.

컴포넌트 쪽 코드는 이렇게 단순해집니다. (코드는 설명을 위한 pseudo code입니다.)

TypeScript

컴포넌트는 selector로 상태를 읽고, 이벤트가 발생하면 effect를 실행할 뿐입니다. 메시지 전송이 실제로 어떤 과정을 거치는지(네트워크 요청, 실패 시 재시도, 상태 업데이트 등)는 전부 sendMessage effect의 pipe 안에 있습니다.

그래서 처음의 그 버그는 어떻게 되었을까요?

이제 처음의 시나리오로 돌아가 보겠습니다.

인터페이스 관리 모듈은 이제 각 인터페이스가 트리거하는 스트림의 complete를 완료의 기준으로 삼습니다. 스트림에는 complete라는 명확한 종료 신호가 있고, 그 시점은 저희가 pipe 안에서 직접 정의합니다. boot의 경우 네트워크 응답이 아니라 소켓 연결과 인증까지 전부 끝난 후가 complete입니다.

boot → shutdown → boot가 연달아 호출되면 이제 채널톡은 아래와 같이 동작합니다.

  • 각 인터페이스 호출은 스트림으로 직렬화되어, 이전 호출의 사이드 이펙트가 전부 완료된 뒤에 실행됩니다. 두 번째 boot는 첫 번째 boot의 소켓 연결과 인증까지 끝나기를 기다립니다.

  • shutdown은 상태 초기화뿐만 아니라 실행 중인 스트림의 구독 해제까지 책임집니다. 구독이 해제되면 진행 중이던 파이프라인 전체가 함께 정리되므로, 남아있던 사이드 이펙트가 초기화된 상태를 밟고 실행되는 일 자체가 불가능해졌습니다.

  • track처럼 소켓 연결을 전제로 하는 인터페이스는 소켓 연결 완료를 스트림 안에서 기다렸다가 실행됩니다.

"타이밍에 따라 매번 다르게 동작한다"는 비결정성이 구조 수준에서 제거된 것입니다.

개편의 성과

이렇게 구조를 변경하면서 얻은 성과를 정리해 보면 아래와 같습니다.

작성해야 하는 코드가 절반으로 줄었습니다. redux를 사용할 때는 하나의 비즈니스 로직을 추가하려면 액션 타입 선언, 액션 생성 함수, reducer, epic까지 여러 벌의 코드를 작성해야 했지만, 이제는 effect 하나만 작성하면 됩니다. 실제로 새로운 비즈니스 로직을 작성할 때의 코드량이 redux를 사용할 때에 비해 절반 수준으로 줄었고, 개편 자체에서도 새로 작성한 코드보다 지운 코드가 두 배 가까이 많았습니다. 구조가 단순해진 만큼 코드도 함께 줄어든 것이죠.

타이밍으로 인해 발생하던 이슈들이 해결되었습니다. 고객사가 호출하는 인터페이스의 타이밍을 완전히 제어할 수 있게 되면서, 처음의 소켓 무한 실패 버그가 구조적으로 해결된 것은 물론이고 아래와 같은 버그들도 함께 사라졌습니다.

  • 소켓 연결 전에 채팅방에 들어가면 join 이벤트가 유실되던 문제 → 소켓 연결 완료를 기다렸다가 전송

  • 소켓이 끊긴 사이에 타이핑하면 typing 이벤트가 유실되던 문제 → 동일하게 해결

오래 켜둬도 느려지지 않게 되었습니다. 이전에는 채널톡이 설치된 페이지를 오랫동안(2~3일) 켜두면 점점 느려지는 현상이 있었습니다. 원인은 정리되지 않은 채 쌓여가는 구독들이었습니다. 이전 구조에서는 액션의 완료를 관찰하기 위한 구독이 컴포넌트, epic 등 곳곳에 흩어져 있었는데 구독 해제의 책임이 명확하지 않았고, switchMap 등으로 스트림이 중간에 끊기면 complete 액션이 발생하지 않아 subject가 영원히 완료되지 않은 채 남는 경우도 있었습니다. 완료되지 않은 subject와 해제되지 않은 구독은 콜백과 상태에 대한 참조를 계속 붙들고 있기 때문에 가비지 컬렉터가 메모리를 회수하지 못하고, 페이지를 켜둔 시간에 비례해 힙에 쌓이며 성능을 저하시킵니다.

새 구조에서는 모든 비즈니스 로직이 스트림이고, 스트림은 반드시 complete되거나 구독 해제로 끝납니다. 구독의 주인이 명확하고, 구독이 끝나는 순간 파이프라인이 붙들고 있던 참조들이 연쇄적으로 정리됩니다. shutdown이 실행 중인 스트림의 구독 해제까지 책임지는 것도 같은 맥락입니다. 그 결과 페이지를 며칠씩 켜두어도 성능 저하가 발생하지 않게 되었습니다.

마무리하며

돌아보면 이번 구조개편은 버그 수정에서 시작해 질문이 계속 바뀌어온 과정이었습니다.

  • "왜 소켓 요청이 무한히 실패하지?"에서

  • "왜 우리는 boot의 완료 시점을 모르지?"로,

  • "비즈니스 로직의 흐름을 어떻게 통제할 수 있지?"로,

  • 마지막에는 "뷰와 비즈니스 로직의 책임은 어디에서 나뉘어야 하지?"까지.

표면의 현상을 고치는 데서 멈췄다면 두 번째, 세 번째 질문에는 도달하지 못했을 것입니다.

채널톡 고객용 메신저는 하루 1000만 명의 유저와 만나는 제품입니다. 고객의 제보 하나도 가볍게 넘기지 않고 구조의 문제로 환원해서 고민하고, 필요하다면 상태 관리 구조를 통째로 갈아엎는 결정을 하고, 그 결정을 실제로 완주합니다. 이런 고민을 함께할 분들을 기다리고 있습니다.

We Make a Future Classic Product