리액트 리덕스(React Redux)와 사가(Saga), 복잡한 비동기 처리를 위한 최선의 선택일까?

리액트 상태 관리의 핵심인 리덕스와 비동기 로직을 우아하게 처리하는 리덕스 사가에 대한 심층 가이드입니다. 실제 프로젝트 경험을 바탕으로 개념부터 장단점, 그리고 적용 노하우를 공유합니다.

ReactReduxRedux-Saga
--

안녕하세요. 오늘도 코드를 보며 고민에 빠진 개발자분들과 소통하고 싶은 블로거입니다.

프런트엔드 개발을 하다 보면, 어느 순간 벽에 부딪히는 느낌을 받을 때가 있습니다. 처음 리액트(React)를 배울 때는 useStateuseEffect만으로도 세상 모든 웹사이트를 만들 수 있을 것 같았죠. 하지만 프로젝트 규모가 커지고, 컴포넌트 뎁스(depth)가 깊어지면서 우리는 소위 말하는 'Prop Drilling'의 늪에 빠지게 됩니다. 부모의 부모의 부모 컴포넌트에서 내려오는 props를 전달하느라 정신이 없었던 경험, 아마 다들 한 번쯤 있으실 겁니다.

저 역시 그랬습니다. 데이터 하나를 수정하려고 파일 다섯 개를 열어야 했을 때의 그 막막함은 말로 다 할 수 없었죠. 오늘은 제가 그 복잡함 속에서 중심을 잡기 위해 선택했던 **React Redux(리액트 리덕스)**와, 그 단짝인 **Redux-Saga(리덕스 사가)**에 대해 이야기해 보려 합니다. 단순히 기술적인 정의를 나열하는 것이 아니라, 왜 이것을 써야 했고 어떤 점이 좋았는지 경험을 담아 차분히 풀어보겠습니다.


1. 상태 관리의 필요성, 왜 하필 리덕스(Redux)인가?

리액트 생태계에는 Context API, MobX, Recoil, 그리고 최근의 Zustand까지 훌륭한 상태 관리 도구들이 많습니다. 그런데 왜 여전히 많은 기업과 대규모 프로젝트에서는 React Redux를 고집할까요?

제가 처음 리덕스를 도입했을 때 느꼈던 가장 큰 장점은 바로 **'데이터 흐름의 예측 가능성'**이었습니다. 리덕스는 **단방향 데이터 흐름(Flux 패턴)**을 엄격하게 따릅니다.

쉽게 비유하자면, 은행 시스템과 비슷합니다. 우리가 은행 계좌의 잔고(State)를 마음대로 수정할 수 있다면 큰일이 나겠죠? 반드시 창구(Action)를 통해 요청서를 내고, 은행원(Reducer)이 규정대로 처리한 뒤에야 잔고가 바뀝니다.

  • Action(액션): 무엇을 할지 적은 요청서
  • Dispatch(디스패치): 요청서를 창구에 제출하는 행위
  • Reducer(리듀서): 요청에 따라 실제 상태를 변경하는 함수
  • Store(스토어): 모든 상태가 저장된 금고

이 구조 덕분에, 앱의 상태가 언제, 왜, 어떻게 변했는지 추적하기가 매우 쉬워집니다. 특히 대규모 협업 프로젝트에서 누군가 상태를 변경했을 때, 그 원인을 파악하기 위해 코드를 역추적하는 시간이 획기적으로 줄어들더군요.

하지만 리덕스에는 치명적인 단점이 하나 있었습니다. 바로 "**동기적(Synchronous)**으로만 작동한다"는 점입니다. 리듀서는 순수 함수여야 하기 때문에, API 호출 같은 비동기 작업(Side Effect)을 직접 처리할 수 없습니다. 여기서 우리는 미들웨어(Middleware)라는 조력자를 찾게 됩니다.

2. 비동기 처리의 난제와 미들웨어의 등장

서버에서 데이터를 받아오는 API 통신은 프런트엔드 개발의 핵심입니다. 리덕스 자체만으로는 "버튼을 누르면 -> 서버에 데이터를 요청하고 -> 응답을 기다렸다가 -> 결과를 상태에 저장한다"라는 흐름을 구현하기 어렵습니다.

초기에는 Redux-Thunk를 많이 사용했습니다. 함수를 디스패치할 수 있게 해주는 간단하고 직관적인 미들웨어죠. 저도 작은 프로젝트에서는 썽크(Thunk)를 애용했습니다. 하지만 비즈니스 로직이 복잡해질수록 썽크의 한계가 드러났습니다.

예를 들어볼까요?

  • 로그인 버튼을 연타했을 때, 마지막 요청만 처리하고 싶다면?
  • 특정 동작이 수행 중일 때 다른 동작을 취소해야 한다면?
  • API 요청 실패 시 3번까지 재시도(Retry)를 해야 한다면?

Thunk 내부에서 setTimeout이나 복잡한 조건문으로 이를 구현하려다 보면, 코드는 금방 지저분해지고 유지보수가 어려워집니다. 소위 '콜백 지옥'과 비슷한 형태가 되어버리죠. 이때 제가 만난 구원투수가 바로 Redux-Saga였습니다.

3. 리덕스 사가(Redux-Saga), 제너레이터의 마법

React Redux와 함께 사용되는 사가(Saga)는 자바스크립트의 ES6 기능인 제너레이터(Generator) 함수를 기반으로 합니다. 처음 문법을 접했을 때 function* 이나 yield 같은 키워드가 낯설어서 꽤 고생했던 기억이 납니다.

하지만 이 제너레이터의 원리를 이해하고 나니, 비동기 코드를 마치 동기 코드처럼 짤 수 있다는 것이 얼마나 큰 축복인지 깨닫게 되었습니다. 제너레이터는 함수의 실행을 중간에 멈췄다가(pause), 다시 재개(resume)할 수 있는 특별한 함수입니다.

사가는 이를 활용해 비동기 흐름을 아주 우아하게 제어합니다. 사가는 애플리케이션에서 발생하는 액션을 모니터링하다가, 특정 액션이 발생하면 정의된 작업을 수행하는 별도의 스레드처럼 동작합니다.

주요 이펙트(Effect)들을 살펴볼까요?

  • takeEvery: 들어오는 모든 액션에 대해 작업을 처리합니다.
  • takeLatest: 기존에 진행 중이던 작업이 있다면 취소하고, 가장 마지막 요청만 처리합니다. (이게 정말 물건입니다. 더블 클릭 방지 등에 탁월하죠.)
  • call: Promise를 반환하는 함수(API 호출 등)를 동기적으로 기다립니다.
  • put: 리덕스 스토어에 액션을 디스패치합니다. (리듀서에게 신호를 보냅니다.)

4. 실제 프로젝트 적용기: 로그인 프로세스의 흐름

이론만으로는 와닿지 않을 수 있으니, 제가 실제 프로젝트에서 로그인 기능을 구현했던 흐름을 예로 들어보겠습니다. React ReduxRedux-Saga가 어떻게 협력하는지 보세요.

  1. View (컴포넌트): 사용자가 로그인 버튼을 클릭합니다.

    JavaScript

    dispatch({ type: 'LOGIN_REQUEST', payload: { id, password } });
    
    

    컴포넌트는 단지 '로그인 요청'이라는 액션만 날립니다. 이후에 API를 호출하든 말든 신경 쓰지 않습니다. 관심사의 분리가 확실하죠.

  2. Saga (미들웨어): LOGIN_REQUEST라는 액션을 기다리던 사가가 반응합니다.

    JavaScript

    function* loginSaga(action) {
      try {
        // call을 사용해 API가 응답할 때까지 기다립니다. (비동기지만 동기처럼 보임)
        const result = yield call(api.login, action.payload);
    
        // 성공했다면 성공 액션을 스토어로 보냅니다.
        yield put({ type: 'LOGIN_SUCCESS', payload: result.data });
    
      } catch (error) {
        // 실패했다면 실패 액션을 보냅니다.
        yield put({ type: 'LOGIN_FAILURE', error });
      }
    }
    
    
  3. Reducer (리덕스): 사가가 보낸 LOGIN_SUCCESS 액션을 받아 상태(State)를 업데이트합니다. isLoggedIn: true, user: result.data가 되겠죠.

  4. View (컴포넌트): 스토어의 상태가 변했음을 감지하고, 화면을 메인 페이지로 전환합니다.

이 과정에서 가장 인상 깊었던 점은 테스트의 용이성입니다. 사가의 모든 이펙트(call, put 등)는 단순히 자바스크립트 객체를 반환합니다. 따라서 실제 API를 호출하지 않고도, "이 단계에서 API 호출 객체가 반환되었는가?", "그다음 단계에서 액션이 디스패치 되었는가?"를 단위 테스트(Unit Test)하기가 너무나 편리했습니다. 복잡한 비즈니스 로직을 검증해야 하는 프로젝트에서 사가는 선택이 아닌 필수처럼 느껴졌습니다.

5. 솔직하게 털어놓는 장단점

물론 React ReduxSaga가 만능열쇠는 아닙니다. 20년 가까이 다양한 기술을 접해오면서 느낀 것은, 모든 기술에는 트레이드오프가 있다는 점입니다.

장점:

  • 강력한 비동기 제어: 앞서 언급한 takeLatest, throttle, race 등의 이펙트를 사용하면 복잡한 시나리오도 깔끔하게 구현 가능합니다.
  • 테스트 용이성: 로직 검증이 매우 수월합니다.
  • 관심사의 분리: 컴포넌트는 렌더링에만 집중하고, 비즈니스 로직은 사가 파일에 격리되어 관리가 용이합니다.
  • 디버깅: Redux DevTools를 통해 시간 여행 하듯 상태 변화를 볼 수 있는 건 여전히 강력합니다.

단점:

  • 높은 러닝 커브: 제너레이터 문법과 리덕스 자체의 개념을 익히는 데 시간이 꽤 걸립니다. 초보 개발자에게는 진입 장벽이 될 수 있습니다.
  • 보일러플레이트 코드: 액션 타입 정의, 액션 생성 함수, 리듀서, 사가 함수까지... 기능 하나를 추가할 때마다 작성해야 할 파일과 코드가 많습니다. (물론 Redux Toolkit이 나와서 많이 줄어들긴 했습니다.)
  • 번들 사이즈: 작은 프로젝트에 쓰기에는 다소 무거운 라이브러리일 수 있습니다.

최근에는 **React Query(TanStack Query)**와 같은 서버 상태 관리 라이브러리가 등장하면서, 단순 데이터 페칭(Fetching)용으로 리덕스 사가를 쓰는 경우는 줄어들고 있습니다. 하지만, API 호출 순서가 중요하거나, 웹소켓 연동, 복잡한 백그라운드 작업 등 정교한 흐름 제어가 필요한 애플리케이션에서는 여전히 사가가 독보적인 위치를 차지하고 있다고 생각합니다.


회고 및 마치며

지금까지 React ReduxSaga에 대해 제 경험을 녹여 정리해 보았습니다.

사실 처음 이 기술들을 접했을 때는 "데이터 하나 불러오는데 왜 이렇게 복잡한 과정을 거쳐야 하지?"라는 불만도 있었습니다. 코드를 짤 때마다 액션 만들고, 리듀서 수정하고, 사가 작성하는 과정이 귀찮기도 했죠.

하지만 대규모 커머스 프로젝트를 진행하면서, 수많은 사용자의 인터랙션과 쏟아지는 비동기 요청을 처리해야 했을 때 비로소 깨달았습니다. "이 복잡함은 나중에 닥쳐올 혼란을 막기 위한 안전장치였구나" 라고요. 시스템이 안정적으로 돌아가고, 버그가 발생했을 때 빠르게 원인을 찾아낼 수 있었던 건 리덕스와 사가가 잡아준 견고한 구조 덕분이었습니다.

기술은 계속 변합니다. 지금은 더 가볍고 쉬운 도구들도 많습니다. 하지만 리덕스 사가가 추구했던 '예측 가능한 부수 효과(Side Effect) 관리'라는 철학은 어떤 도구를 쓰더라도 프런트엔드 개발자가 반드시 고민해야 할 지점입니다. 복잡한 비즈니스 로직 때문에 밤잠 설치고 계신 분이라면, 한 번쯤 리덕스 사가라는 단단한 도구를 손에 쥐어보시길 권해드립니다.

긴 글 읽어주셔서 감사합니다. 혹시 적용하시다가 막히는 부분이 있거나 궁금한 점이 있다면 언제든 댓글 남겨주세요. 아는 범위 내에서 성심껏 답변드리겠습니다.

댓글

0/2000
Newsletter

이 글이 도움이 되셨나요?

새로운 글이 발행되면 이메일로 알려드립니다.

뉴스레터 구독하기