fender
25k
2021-11-02 07:40:46 작성 2021-11-02 07:56:37 수정됨
14
960

리덕스를 접해보고 드는 의문


신규 프로젝트를 코틀린/JS 리액트로 시작한 김에 리덕스를 도입해보았습니다.

자바스크립트 리덕스에 대한 래퍼는 찾지 못한 대신 순수 코틀린 기반 구현체가 있더군요. 리덕스가 리액트와 접점이 그렇게 넓은 편이 아닌지라 생각보다 수월하게 기존 코틀린/리액트 코드에 접목 시킬 수 있었습니다.

그런데 리액트나 리덕스를 제대로 해본 건 처음이라 그런지 조금 널리 쓰이는 관행에 의문이 들더군요.

대부분의 예제는 액션을 단지 동작의 인자값을 전달하는 수준으로 가볍게 만들고 리듀서에서 분기를 하면서 실제 구현을 하는 방식이던데 아무리 생각해도 제 눈에 이 것은 안티패턴으로 보였습니다.

물론 관심사별로 리듀서로 묶어서 조합을 할 수 있다는 건 알겠는데, 그게 꼭 액션 대신 리듀서가 무거워져야 가능한 방식은 아니라고 생각하거든요.

그래서 그냥 무시하고 리듀서는 단지 액션을 실행하는 함수 하나로 처리하고 구현을 액션 자체에 넣는 방식으로 시도해보니 꽤 마음에 드는 모양이 되었는데, 혹시 제가 리액트/리덕스를 잘 몰라서 미처 생각지 못한 단점 같은 것이 있는지 모르겠습니다.

여담이지만 리덕스를 사용하니 왠지 옵틱스와 잘 어울릴 것 같다는 생각이 들어 이미 다른 이유로 사용 중인 코틀린 애로우의 관련 라이브러리를 붙여보니 역시 코드를 매우 직관적으로 표현할 수 있었습니다.

그 과정에서 앞서 액션에 실제 구현을 넣는 방식의 장점도 깨닫게 되었는데, A라는 액션의 구현에서 B의 동작이 같이 필요한 경우(예 - 탭을 추가하고 전환) 애로우가 지원하는 andThen으로 옵틱스의 세터들과 함께 자연스럽게 합성이 가능하다는 점이었습니다.

이를 통해 예를들어, "A의 a 속성을 바꾸고, b속성을 바꾸고, 마지막으로 B 액션도 실행한다"라는 프로세스를 선언적으로 깔끔하게 표현 가능했습니다 (예 - return (addPage andThen clearPartSelection andThen setPageSelection)(state)).

특히나 오리지널 리액트/리덕스에선 자바스크립트의 특성상 상태를 불변으로 강제할 수 없지만 코틀린 기반에서는 어렵지 않게 가능하다는 것도 오히려 이 쪽이 더 취지에 적합한 구현이 아닌가 하는 생각도 듭니다.

혹시 리덕스와 옵틱스를 결합하는 건 나름 참신한 아이디어가 아닐까 하고 검색을 해봤더니 자바스크립트로 같은 접근을 소개하는 블로그 한 건이 나오네요. 내가 괜찮아 보이는 건 대부분 먼저 생각한 사람이 있나 봅니다.

그래도 아직 널리 쓰이는 패턴은 아닌 것 같으니, 혹시 나중에 관행으로 정착되면 이 글을 보여주면서 자랑이라도 해볼까 합니다 ㅎㅎ;

여담이지만 애로우 옵틱스에서 렌즈를 자동 생성해주는 어노테이션은 JVM에서만 동작합니다. 그래서 관련 코드를 수작업으로 추가할 수 밖에 없었고 JVM 처럼 간결하게 표현이 힘든데, 이 부분은 향후 애로우 메타 프로젝트가 발전하면 해소되지 않을까 기대합니다.

그나저나 옵틱스 같은 함수형 API는 그럭저럭 쓰긴해도 아직도 명확하게 이해했다기 보단 무언가 때려 맞추는 느낌이 강하게 듭니다. 함수형 자체가 어려운 것인지 단지 객체지향에 더 익숙해서인지 쉽게 판단이 안 되네요.

함수형 프로그램은 언제 쯤 객체지향 처럼 편하게 사용하게 될 지, 또 그런 단계가 가능하긴 할지 모르겠습니다.

0
  • 댓글 14

  • 현파랑
    371
    2021-11-02 10:01:40

    바라보는 시선, 직관력이 경험과 사고에서 나온다는 게 괜한 말이 아니군요!

    확실히 fender님은 객체 지향적 사고를 통해 라이브러리를 사용하고 코드에 접목하고 계시는 게 글에 드러나는 것 같습니다.

    혹시 본문 중에서 이 부분 :

    그래서 그냥 무시하고 리듀서는 단지 액션을 실행하는 함수 하나로 처리하고 구현을 액션 자체에 넣는 방식으로 시도해보니 꽤 마음에 드는 모양이 되었는데, 혹시 제가 리액트/리덕스를 잘 몰라서 미처 생각지 못한 단점 같은 것이 있는지 모르겠습니다.

    많은 공감이 되네요. 저 또한 리덕스를 사용하면서 상태 관리의 대상이 많아지면 많아질 수록 코드가 번잡해지고 무거워지는 것이 정말 보기 싫었거든요.

    글 정말 잘 읽었습니다!

  • 라이라
    4k
    2021-11-02 10:27:59
    저도 항상 의문인데, 그때마다 달리는 대답이 규모가 커지면 관리하기 좋다고 하네요
  • fender
    25k
    2021-11-02 10:39:53 작성 2021-11-02 10:41:11 수정됨

    전 기본적으로 "액션"을 모델링한다면 그건 데이터가 아닌 동작을 표현하는 것이 더 나은 접근이라는 생각에서 출발했습니다.

    그래서 액션을 일반적 리덕스 예제와 같이 동작에 필요한 인자값을 관리하는데만 쓴다면, 마치 자바나 C# 백엔드 프로젝트에서 자주 보이는 도메인 모델이 값을 담기 위한 컨테이너 역할로 축소된 것 같은 안티 패턴에 가깝다고 느꼈습니다.

    참고로 지금 만들고 있는 것은 탭이 있는 편집기 비슷한 화면인데, 아래는 탭을 추가하는 액션을 본문에서 언급한 방식으로 구현한 내용입니다:

    class AddTab : Action {
    
        @Suppress("unused")
        override fun perform(state: DesignerState): DesignerState {
            val pageToAdd = Page("새 페이지")
    
            val clearPartSelection = SelectParts(None)::perform
    
            val addPage = (DesignerState.design compose Design.pages)::modify.partially2 {
                it + pageToAdd
            }
    
            val setPageSelection = SelectPage(pageToAdd.some())::perform
    
            return (addPage andThen clearPartSelection andThen setPageSelection)(state)
        }
    }

    우선 리듀서에 탭 추가, 삭제, 전환 등을 모두 몰아 넣는 방식에 비해 클래스와 패키지 단위로 깔끔하게 내용을 분리할 수 있는 장점이 있다고 생각합니다.

    특히 SelectParts나 SelectPage는 AddTab과 마찬가지로 액션 구현인데, 리듀서 뿐 아니라 액션을 상태->상태의 시그네쳐를 갖는 함수로 구현할 경우 위와 같이 액션 간에 자연스러운 컴포지션도 가능합니다.

    (참고로 "DesignerState.design compose Design.pages"는 본문에서 언급한 코틀린/JS 상에서의 애로우의 제약이 아니었다면 "DesignerState.design.pages"로 축약 가능한 내용입니다.)

    이런 패턴을 통해 리듀서가 관리하는 상태를 불변 데이터 클래스로 관리하면서 "페이지를 추가하고 아이템 선택을 해제하고 새로운 페이지가 포함된 탭을 선택한다" 같은 요구 조건을 액션과 옵틱스의 조합으로 선언적으로 표현 (i.e. - addPage andThen clearPartSelection andThen setPageSelection)할 수 있는 점은 분명 흥미로운 가능성인 것 같습니다.

  • 현파랑
    371
    2021-11-02 11:09:47

    아! 첫 본문에서의 내용이 완전히 이해되지 않았으나 댓글 남겨주신 것을 보고 이해가 되기 시작했습니다.

    리덕스의 설계 동기가 flux, CQRS, 이벤트 소싱인데 fender님의 말씀처럼 구현할 경우 CQRS가 위배되진 않을까요? 실제로 액션은 CQRS에 의해 도메인 모델을 축소하여 컨테이너로서 전달되는 역할만 하는 것 같습니다. 언급하신 AddTab Action처럼 액션 불편 데이터 클래스로 관리하게 되면 액션 자체가 행위가 되어 관심사가 중첩되지 않나요? 그렇다면 결국 리덕스라는 라이브러리가 아니라 flux 패턴을 구현하게 되는 것이 아닐까요?

    제가 배움이 짧아 잘못 말을 할 수 있습니다. 그러나 스스로에 대한 질문 겸 이해를 위해 여쭤보는 댓글이니 언짢지 않으셨으면 합니다!

  • fender
    25k
    2021-11-02 11:27:22 작성 2021-11-02 11:28:49 수정됨

    현파랑 // 아뇨, 아마 이 쪽은 왠지 저보다는 현파랑님이 더 경험이 많으실 것 같은데 말씀을 나누면서 저도 도움이 되면 되지 언짢을 일이 있을까요 ㅎㅎ;

    플럭스 패턴이나 리덕스나 기본적으로 스토어/리듀서/액션을 통해 데이터의 흐름을 단방향으로 한정한다는 면에서 동일한 내용으로 알고 있는데 제가 잘못 생각하고 있나요?

    CQRS나 이벤트 소싱은 조금 다른 이야기일 수 있지만, 데이터 흐름에 집중해보면 큰 방향에선 "source of truth"를 단일하게 유지하면서 이에 대한 데이터 흐름을 통제하는 이상 취지에 어긋나는 것이 아니라고 생각합니다.

    반면에, 만일 액션 내부적으로 가변 상태를 갖는다거나, 액션을 리듀서를 거치지 않고 뷰 단에서 이벤트 핸들러 같은데서 사용한다면 정말로 해당 패턴의 의도를 거스르는 것이 될 것 같습니다.

  • 엡실론
    2k
    2021-11-02 13:11:40

    oop 적으로 해석해본다면 action은 메세지, 메소드 호출 reducer는 클래스에 해당한다고 볼 수 있을 것 같네요. 같은 toString()을 호출해도 어떤 클래스냐에 따라 서로 다른 행위를 하듯이요. action 하나를 서로 다른 reducer에 브로드캐스트해서 로직을 구성할 수 있습니다.

    그리고 위의 예에서는 액션이 다른 액션을 호출 하는 것 보다는 레이어를 나눠서 처리하는게 더 좋을 것 같습니다. 액션을 일종의 서비스 레이어의 api라고 생각해보세요. 지금은 SelectParts(None)이 단순히 파트 선택 상태를 지우는데 지나지 않더라도, 향후에는 더 복잡한 비지니스 로직이 들어가게 될 수도 있습니다. 단순히 파트 선택을 지우는 용이라면 부차 레이어로 관리하는게 더 좋을 것 같습니다.

    원래 redux에서는 action은 serializable하게 하는 걸 원칙으로 하는데, 그렇게 되면 action을 다른 컴퓨터나 서버에서 생성해서 던져줄 수도 있습니다.



  • fender
    25k
    2021-11-02 13:24:12

    쓰고 보니 조금 설명이 부족한 것 같아 부연하면, 리덕스/플럭스와 CQRS, 또는 이벤트 소싱은 구조는 흡사해도 의도나 용도가 완전히 일치하는 것은 아닌 것 같습니다.

    예를들어 이벤트 소싱에서 이야기하는 이벤트는 기본적으로 행위 자체가 아닌 어떤 동작으로 발생한 결과를 추상화한 개념입니다. CQRS와 이벤트 소싱을 같이 적용하는 경우 커맨드와 이벤트를 별개로 다루는 걸 생각하면 이 점이 보다 분명해지리라 봅니다.

    물론 CQRS에서 커맨드도 데이터 컨테이너 같이 만드는 경우가 일반적이긴 하지만, 이는 보통 서비스 계층이 별도로 존재하고 커맨드를 메시지 처럼 사용한다는 전제로 그런 모양이 된 것이라 반드시 리덕스를 통해 UI를 만드는 맥락에 1:1로 적용할 이유는 없다고 생각합니다.

  • 현파랑
    371
    2021-11-02 13:25:33

    fender // 이제야 명쾌하게 이해가 되네요! 짧게 나마 의견 주셔서 감사합니다. 리덕스를 사용하면서 불편한 점을 개선할 수 있는 방법을 직관적으로 적용하신 것이고, 자세히 살펴보니 말씀처럼 CQRS 또한 내부적으로 특정 처리를 하는 게 아니므로 위반되지 않네요.

    플럭스를 꺼냈던 것은 액션과 리듀서의 역할을 불변 클래스로 융합하면서 action-dispatch-(reducer)-store의 리덕스보다는 플럭스 패턴(둘 다 동일한 철학-단방향 흐름-이지만)처럼 간소화하고 융합으로 인한 복잡도가 증가하진 않나 싶었기 때문이었지만, 의도를 이해하니 완전히 다른 이야기였습니다.

    ㅎㅎ 좋은 코드와 글 잘 읽었습니다!

  • fender
    25k
    2021-11-02 13:38:56 작성 2021-11-02 13:41:36 수정됨

    엡실론 // 말씀하신 것처럼 동작을 직렬화를 통해 분산처리를 한다던지 하는 요구조건이 있다면 분명 메시지 처럼 구성하는 것이 더 합리적인 접근일 것 같습니다.

    다만 맥락상, 구현하는 프로그램은 브라우저 상에서 디자인 편집기 비슷한 UI를 구성하는 것이라서 사용자의 동작을 다른 PC에 전송한다던지 하는 요구조건은 고려하고 있지 않습니다.

    한편, 계층을 나누는 부분에 대한 내용은 조금 이해가 가지 않는 부분이 있어 다시 질문 드립니다. 리듀서에서 직접 처리하건 액션 내부에서 처리하건, 결국 리덕스 구조에서 해당 부분은 스토어의 상태를 받아 상태를 반환하는 함수 같은 구조가 되어야 할 것입니다.

    복잡한 비즈니스 로직을 서비스 계층으로 분리한다는 건 왠지, 예컨대 일반적인 MVC 구성에서 OrderManager 같은 비즈니스 파사드를 만드는 관행을 말씀하신 것으로 이해하는데, 리듀서의 개별 분기에서 해야할 일을 그런 파사드에 위임하는 것이, 예컨대 "Order라는 불변 객체를 줄테니 수량값을 변경한 새로운 객체로 다시 반환해라" 같은 단순 처리 이상을 할 수 없는 것이 아닌가요?

    해당 서비스에서 일반적인 경우와 같이 REST API를 호출 같은 부작용이 필요한 동작을 한다면 그건 더 이상 리덕스 구조가 아니게 될 것 같습니다.

    혹시 제가 말씀하신 의도를 잘못 이해한 부분이 있다면 부연 부탁드리겠습니다.

  • 엡실론
    2k
    2021-11-02 19:13:44

    rest api같은걸 사용한다는 의미는 아니었습니다.

    제가 보기에 액션은 서비스로 치면 api와 같이 큰 단위라 액션끼리의 참조는 별로 좋지 않아 보인다는 의미였습니다. 따라서 한 액션에서 다른 액션을 바로 사용하기 보단 결국 다른 함수로 나눠서 재사용하는게 좋다는 의미였습니다.

    예로 위의 AddTab 액션을 생각해보죠. 처음에는 AddTab 액션을 단순히 탭을 추가하는 액션으로 설계할 수 있을겁니다. 그리고 다른 액션에서 단순히 탭을 추가하는 작업이 필요할 때, AddTab 액션을 사용할 수 있을 겁니다. 하지만 향후 탭 추가시 현재처럼 파트선택을 취소하고 새로운 페이지로 선택하는 복잡한 작업을 추가하게 된다면, 다른 액션에 영향이 가겠죠. 한 액션이 다른 액션에 의존적인 관계가 바람직한가 하는 생각이 듭니다.

    파사드와 단순처리의 관계가 잘 이해가 안되네요.

    파사드를 하나하나 쪼개서 다루는 데이터가 적고, 단순한 로직밖에 담을 수 없는 상황이라면 리덕스든 뭐든 단순 처리 이상은 못 할테고, 아니라면 복잡한 로직의 처리도 가능할테죠.


    사용자의 동작 뿐 아니라, 서버에서 특정 조건, 혹은 파일의 변경등을 감지해서 클라이언트로 액션을 던져 줄 수도 있겠죠.

    개인적으로 x-window의 디자인을 좋아해서 저는 항상 각종 이벤트를 네트워크로 전송하는 상황을 많이 고려하긴 합니다.

  • fender
    25k
    2021-11-02 19:59:01

    엡실론 // 말씀하신 부분은 리덕스에 대한 국한된 내용보단 코드 재활용이나 API 설계 전반에 걸친 내용인 듯 합니다.

    그렇게 본다면 공통으로 재활용하는 단위 코드가 변경 되었을 때 의존하는 코드에 영향을 주기 때문에 적절한 주의/리팩터가 필요하다던지 하는 내용은 액션을 쓰나 함수를 쓰나 차이가 없는 내용이 아닌가 싶습니다.

    다만 granularity에 대한 지적이라면, 액션이 복잡해지는 경우 분명 액션보다 작은 단위의 함수 등으로 공통 동작을 분리하는 것이 더 적합할 수 있다는 데는 동의합니다.

    하지만 리덕스에서 대부분의 액션이 결국 상태 트리의 값을 변경하는 것이고, 이에 대한 처리는 이미 옵틱스/렌즈로 세세하게 클래스 마다 분리가 되어 있는 것을 감안하면, 굳이 탭 전환 같이 단순한 액션도 미리부터 추가로 계층을 분리해서 구현할 이유가 있을지 모르겠습니다.

    한편, 서버에서 이벤트를 전송하는 시나리오의 경우도 어차피 기존 리덕스 관행으로도 클라이언트가 알지 못하는 임의의 동작에 대한 정의를 보내는 것이 아니기 때문에 액션에 실제 동작을 넣던지 리듀서에 넣던지 차이는 없을 것 같습니다. (데이터 클래스 액션 내부에 구현이 있더라도 직렬화하면 결국 생성자 인자값만 남습니다.)

    의견 주셔서 감사합니다. 아무래도 이 부분은 리액트/리덕스에 대한 내용보다는 저와 엡실론님의 설계 습관 차이가 아닐까 싶습니다.

  • 애아빠
    2k
    2021-11-04 08:56:30 작성 2021-11-04 08:58:26 수정됨

    fender

    현파랑

    엡실론 


    생산적인 댓글들을 다 이해하지 못하는 저의 뇌와 경험치가 원망스럽군요.


    redux는 three principles를 내세우고 있는데요.

    https://redux.js.org/understanding/thinking-in-redux/three-principles


    그래서 전 글 원문의 첫 번째 의문에 집중해서 의견을 보태봅니다.


    -- 원문 일부 발췌 --

    "대부분의 예제는 액션을 단지 동작의 인자값을 전달하는 수준으로 가볍게 만들고 리듀서에서 분기를 하면서 실제 구현을 하는 방식이던데 아무리 생각해도 제 눈에 이 것은 안티패턴으로 보였습니다.
    물론 관심사별로 리듀서로 묶어서 조합을 할 수 있다는 건 알겠는데, 그게 꼭 액션 대신 리듀서가 무거워져야 가능한 방식은 아니라고 생각하거든요."

    ...

    ...

    그래서 그냥 무시하고 리듀서는 단지 액션을 실행하는 함수 하나로 처리하고 구현을 액션 자체에 넣는 방식으로 시도해보니 꽤 마음에 드는 모양이 되었는데, 혹시 제가 리액트/리덕스를 잘 몰라서 미처 생각지 못한 단점 같은 것이 있는지 모르겠습니다.

    - from fender


    두번째 principle인 'State is read-only'로 인해 이러한 구조를 redux는 강제한다고 생각해요.


    즉 state를 직접 조작하지 못하게 강제하고 reducer(순수 함수)를 통해서 state를 변화시킵니다.

    action은 단지 command일 뿐이며 action의 실행 순서를 그대로 다시 재현하더라도 언제나 같은 결과를 얻을 수 있습니다.

    이때 reducer는 순수함수이기에 input이 같으면 항상 output이 같아야 하고 그러므로 side-effect도 없어야 한다고 말하고 있습니다.

    reducer의 호출시 이전 state와 dispatch된 action을 input으로 전달/호출하고 그 결과를(새로운 상태) 다시 보관해주는 역할도 redux 내부에서 진행됩니다.


    시도하셨던 방법에서 reducer가 action을 호출해주는 역할을 하게 되면 사실상 redux 아키텍쳐를 반하는 형태가 되어 버리고 redux가 reducer라고 명명한 함수의 역할이 다른 형태가 되어버립니다.(상태 변화의 책임 및 새로운 상태 반환)


    그러므로 redux라는 기술에 한정한다면 말씀하신 것은 안티패턴이 아니라 redux의 아키텍처로 이해하고 그 구조에 맞게 코드를 만들어야 합니다.

    그렇지 않으면 redux가 보장하는 부분이 오동작하게 되겠죠.


    다만, 하나의 reducer함수에 모든 action에 대한 로직이 모두 포함되는 이러한 코드 구성이 가독성도 나빠지고 테스트 코드를 만들기도 힘들고 여러가지면에서 따르고 싶은 코드 형태는 아니라는 것에는 동의합니다.

    그래서 redux team에서도 redux-toolkit등을 만들어가며 그러한 비효율적인 부분들에 대해서는 지속적으로 개선해나가고 있습니다.


    추가 의견

    • redux-toolkit을 현업에서 사용하고 있는데요. 모든 것이 마음에 드는 것은 아니지만 action과 reducer를 한번에 정의하도록 코드의 장황함을 개선하였고, 그러면서 reducer도 action별로 편하게 별도의 함수로 구성할 수 있는 점이 편하다고 느끼고 있습니다.
    • immer를 내장하고 있어서 기존의 spread syntax로 새로운 상태 참조를 만들어야 했던 장황한 코드 형태에서도 벗어날 수 있어서 좋은 점도 있네요.



  • fender
    25k
    2021-11-04 10:15:17 작성 2021-11-04 10:16:10 수정됨

    애아빠 // 의견 감사합니다. 제가 제시한 패턴에서도 언급하신 원칙은 유지되고 있습니다 (그렇지 않으면 리덕스를 쓰는 의미가 없겠죠 :) ). 위에 적은 대로 액션은 S->S 형태의 순수 함수(정확히는 해당 시그네쳐의 메서드를 갖는 불변 클래스 - 코틀린/JS에서 함수는 클래스로 구현할 수 없더군요)이고 S는 모든 속성이 읽기 전용인 코틀린의 데이터 클래스입니다 (옵틱스를 도입한 주된 이유가 그런 구조를 '변경'하기 위한 것입니다).

    근본적인 차이는 말씀하신 "액션은 커맨드일 뿐이다"에서 '커맨드'를 기존 리덕스 예제 처럼 리듀서 내부 등에 존재하는 다른 동작을 호출할 수 있는 파라메터 정도로 보는지, 아니면 객체지향의 커맨드 패턴과 같이 그 자체로 완결된 동작 자체를 표현하는지 여부이고, 개인적으로는 후자가 리덕스의 취지를 훼손하지 않으면서 커맨드 본연의 의도나 코드 관리 측면에서 더 나은 접근이라고 생각했습니다.

  • fender
    25k
    2021-11-04 10:23:13 작성 2021-11-04 10:24:03 수정됨

    적고 보니 아이디어가 하나 더 생각났는데, 이런 경우 의도를 보다 명확히 하기 위해서 메서드 시그네쳐에 해당 메서드는 순수 함수로 동작할 수 있을을 표현할 수 있는 방법이 있으면 좋을 것 같습니다.

    막연한 기억에 @pure 같은 어노테이션을 지원하는 언어가 있었는데 스칼라였는지 모르겠네요. 코틀린 같은 다른 언어에서도 기본 API나 자체 기능으로 지원이 된다면 - 컴파일러가 그런 메서드 내부에서 멤버 변수 등을 참조할 경우 오류를 발생시킨 다던지 - 멋질 것 같습니다.

  • 로그인을 하시면 댓글을 등록할 수 있습니다.