FE

ZEP 스페이스 플랜 정기결제 구현하며

jvn4dev 2024. 8. 22. 02:16

정기결제? 그래서 뭘 한다고?

기존 레거시(Angularjs) 프로젝트를 신규 프로젝트(Next.js)로 마이그레이션한지 얼마 되지 않아 우리에게 새로운 미션이 떨어졌다. 바로 젭 스페이스에 정기결제 기능을 추가하는 것이였다.

💡 젭 스페이스 정기결제란? 각 스페이스마다 선택가능한 ****플랜(플랜 별 제공기능 상이)과 해당 플랜에 따른 금액이 등록된 카드로 매달 정기적 과금되는 신규 BM

커다란 프로젝트가 끝난지 얼마 되지 않아 꽤 규모가 있는 기능이 바로 출시가 된다는 점이 생각보다 꽤 부담으로 다가왔다.

하지만 서비스의 안정성이나 추후 개발 유지보수성을 올리기 위해 리액트로의 프로젝트 마이그레이션을 결정했고 그를 위해 우리의 서비스는 약 6개월이 넘는 기간동안 멈춰있었다. 프로덕트 관점에서 본다면 사실상 서비스가 6개월간 멈춰있었다는 것. 가뜩이나 현재 스타트업들은 매출 등의 자생력을 갖추기 위해 노력을 해야하는 시기이기 때문에 다음과 같은 사업적, 기획적 판단은 충분히 납득 가능했다.

이번에도 타이트한 일정…실수를 반복하지 않기 위해

아무래도 시기적으로나 사업적으로나 정말 중요하고 필요한 기능이였기에 일정이 타이트할 것은 충분히 예상했다. 급하다는 이유로 마이그레이션때와 마찬가지로 스프린트 기능의 배포일자를 먼저 픽스해두고 기획, 디자인, 개발, QA 등의 과정이 이루어지다보니 개발로 확보할 수 있는 시간이 넉넉치 않았고, 초기 기획 단계에서 정했던 예상 개발 기간보다 확연하게 줄어들어버린(체감상 반이 줄어든것 같은데…ㅠ) 기간 내에 정기결제 기능을 출시해야했다.

하지만 이번엔 마이그레이션을 진행하면서 아쉬웠던 부분들을 해소해보고자 개발단계에서 발생하는 이슈 및 기간 산정 등에 좀 더 무게를 주어 각자 구현이 필요한 기능별로 구현에 이슈가 없는지 PM, QA 및 서버 개발자들과 함께 데일리스크럼을 진행했다. 데일리 스크럼의 진행방식은 다음과 같았다.

1. PM은 기획 및 구현사항에 변경사항이 있다면 전파한다.
2. 각자 돌아가면 빠르게 진행된 개발 상황을 브리핑한다.
3. 구현이 필요한 내용들을 다같이 확인하며 해당 기능에 필요한 개발기간을 객관적으로 산정한다.
4. 이 때, 이전에 산정된 개발기간 혹은 진행상황에 이슈가 있다면 공유한다.
5. 기능이 진행 및 완료됐다면 산정한 StoryPoints를 차감 및 제거한다.

확실히 마이그레이션 당시에는 이러한 프로세스 없이 모두가 바쁘게 스프린트를 진행하다보니 진행 막바지에 여기저기서 이슈들이 터져나왔었다.

이렇게 짧은 시간내로 스크럼을 진행하니 전체 스프린트 인원의 작업 진행 상황과 이슈 등을 공유할 수 있어 편했고, 무엇보다 객관적으로 정해진 개발 기간에 따라 정해진 태스크를 처리하는 과정이 있어 스프린트 진행 과정에서도 작은 성취감을 느껴가며 스프린트를 진행할 수 있는 점이 좋았다.

컴포넌트 설계부터라도 미리 시작해볼까…?

정기결제 스프린트가 시작되고 제일 먼저 고민했던 부분이였다.

“뭐부터 시작하는게 좋을까?”

기획자의 부재로 인해 정기결제 기능 자체에 대한 기획인 부족한 상태에서 구현해야할 페이지의 디자인도 아직 완성되지 않은 상황이였다. 기획이 부실하다보니 디자인 및 서버의 api 설계단계서부터 변경사항이 너무나도 많았다. 어쩌면 이 때 프론트엔드가 하지말아야했던 실수를 저질렀던 것 같다. 기획, 디자인, api 스펙 등이 정해지고 있는 과정에서 미완성된 페이지 등을 통해 감안하여 미리 페이지의 컴포넌트를 설계해보기 시작했다.

마이그레이션을 진행하며 이미 프로젝트 자체에는 어느정도 익숙해져 있다보니 어느정도 틀에 맞춰 빠르게 설계해볼 수 있었다. 이 때의 생각이라면 배포일이 픽스되었고 누구하나 쉬고 있지 않다보니 뭐라도 빨리 할 수 있는 부분이라면 조금이라도 진행해두는 것이 맞지 않나라는 생각을 했다. 하지만 끝나고 회고해보니 이 타이밍에는 오히려 기획적으로 적극적인 기여를 통해 스프린트에 구현해야할 기능 및 전반적인 프로세스에 익숙해지고, 서버팀과의 api 스펙 등의 논의를 통해 요청 및 응답 타입 등을 통일성있게 설계 하는 등의 작업을 하는게 맞았던 것 같다.

사실 개발 년차가 늘어날수록 자연스레 컴포넌트 설계 및 제작 능력은 점점 늘어나기에 제대로된 기획과 디자인이 있다면 구현 자체는 생각보다 빠르게 할 수 있을 것이다.

결국 스프린트가 진행될수록 기획 및 사업적인 판단에 의해서 거의 대부분의 내용에 대해 변경사항이 생겨났고, 초기에 설계해둔 컴포넌트는 사실상 의미가 없이 다시 만들어야했다. 잦은 변경사항에 대해 불만도 조금씩 생겨났고 api 등의 스펙에 대한 논의도 부족했다보니 기존에 사용하던 응답 필드 등이 기존과 다른 스타일로 생겨나기 시작하면서 기존 프로젝트에도 점점 통일성이 떨어지게되는 문제도 발생하였다.

플랜 관리페이지 및 플레이 설정 내 플랜관리 탭

위의 녀석들이 이번 내가 맡아 구현해야했던 기능들이였다. 어느정도 컴포넌트에 대한 설계는 이루어져있고 각자 맡은 부분을 인수인계받아 마무리했던 마이그레이션때와 다르게 이번엔 프로젝트의 첫 페이지부터 내가 설계하고 만들어볼 수 있었다.

플레이화면에서 스페이스의 관리자가 사용할 수 있는 설정 내 플랜관리 구현은 마이그레이션때 진행했던 설정 컴포넌트의 구조의 연장선으로 구현했다보니 크게 어려움은 없었다. 내가 아무래도 마이그레이션때도 설정쪽을 담당했다보니 팀원들이 나를 장난식으로 설마(설정마스터)라고 부른다.

설정 쪽의 구현했던 방식은 마이그레이션 회고 블로그 글에서 확인할 수 있다.

어찌보면 이번 스프린트에서의 내 온전한 설계가 들어간 부분은 결제 관리페이지다. 리액트를 처음 배우던 시절에는 최대한 아토믹하게 컴포넌트를 쪼개는 것을 선호했던 것 같다. 잘게 쪼갠 컴포넌트들은 그 역할에 따라 컴포넌트 폴더 내에 배치되고 어떻게든 그 컴포넌트들을 재사용하는 쪽에 목적을 두었다.

하지만 점점 개발을 하다보니 하나의 컴포넌트가 재사용이라는 이유로 컴포넌트 내부에서 props 나 상태에 따라 분기가 너무 많아지면서 너무나도 많은 역할을 하게 된다는 부분이 마음에 들지 않았다. 컴포넌트가 특정 도메인에 종속되지 않되 컴포넌트 내부에서 최대한 많은 분기를 갖지 않도록 구현하려고 노력했더 것 같다.

구현에 대한 간단한 내용으로 플랜 관리페이지의 최상위는 SSRSafeSuspense 컴포넌트로 감싸주었다. SSRSafeSuspense는 Nextjs에서 하위에서 사용한 Suspense가 상위 페이지의 hydration 과정보다 먼저 끝나면서 런타임에러가 발생하는 이슈가 있어 팀 내부적으로 공통컴포넌트로 만들어 다음과 같이 사용 중이다.

export default function SSRSafeSuspense(
  props: ComponentProps<typeof Suspense>,
) {
  const isMounted = useIsMounted();

  if (isMounted) {
    return <Suspense {...props} />;
  }
  return <>{props.fallback}</>;
}

페이지가 아직 hydration 중이라면 props로 넘겨받은 fallback을 보여주도록 처리하고 앱이 마운트된 후에 Suspense를 활성화한다.

export default function 구독관리페이지() {
  return (
    <SSRSafeSuspense fallback={<플랜페이지 스켈레톤 />}>
      <구독플랜 />
    </SSRSafeSuspense>
  );
}

이전 다른 팀원분이 코드리뷰를 해주셨던 내용으로 Nextjs의 최상위 페이지는 최대한 간결하게 유지하도록 하는 것을 추천해주어 최상위에서는 최대한 해당 페이지의 어떤 부가적인 부분이 처리되고 있는지에 집중할 수 있도록 구현하고 있다. 다음과 같이 구현하니 추후 어떤 개발자가 유지보수를 하게되더라도 “아 어찌됐든 이 페이지는 SSRSafeSuspense가 적용되어 있구나” 와 같은 생각을 할 수 있게되고, repo 내에서도 쉽게 페이지 구조를 변경할 수 있는 이점이 생겨서 요즘 많이 애용하고 있는 패턴이다.

SSRSafeSuspense 를 제외하고 클라이언트 컴포넌트에서 실제 로딩 및 에러에 대한 처리는 토스 Slash 라이브러리에서 WithAsyncBoundary를 활용하여 컴포넌트의 로딩 및 에러처리를 하고 있다.

const 구독플랜 = withAsyncBoundary(Suspense를_일으키는_컴포넌트, {
  // 로딩 중일 때 보여줄 컴포넌트
  pendingFallback: <div>로딩 중입니다.</div>,
  // 에러가 발생했을 때 보여줄 컴포넌트. 첫 번째 인자로는 발생한 에러가 전달됩니다.
  rejectedFallback: error => <div>에러가 발생했습니다. {error.message}</div>,
});

이를 위해 컴포넌트 내부에서 사용될 reactQuery를 감싼 형태의 모든 훅들은 useSuspendedQuery 를 한번 감싼 형태의 훅으로 사용하여 AsyncBoundary가 Suspense를 인식하도록 처리하였다.

export const 구독플랜 = () => {
  const { query } = useRouter();
  const {
    data: { 스페이스관련정보 },
  } = usePlanSpace(query.id as string);

  return (
    <PageBlocker
      condition={isJP(스페이스관련정보.국가코드)}
      fallbackUrl={'/somethingWentWrong'}>
        <구독플랜템플릿 />
    </PageBlocker>
  );
};

구독플랜 컴포넌트 내부의 경우 기획의 요구사항으로 글로벌 서비스 중이지만 아직 출시되지 않은 특정 해외의 페이지 접속을 막기 위해 특정 조건일 때 해당 fallbackUrl로 이동을 막는 HOC로 감싸주었다. PageBlocker 컴포넌트는 특정 조건일 때 접속만 막는 단일책임 컴포넌트로 역할을 하게되고 덕분에 구독플랜템플릿 컴포넌트는 페이지 구현 자체에만 역할이 집중될 수 있도록 구현했다. 템플릿이라는 네이밍은 아토믹 컴포넌트 개념 중 여러 컴포넌트들이 조합되어 전체페이지의 구성을 이루고 있는 단계의 개념으로써 템플릿이라고 이름 지었다.

사실 이 구독플랜 컴포넌트가 따로 래핑되지 않고 상위의 구독관리 페이지에 위치하는 것이 한눈에 페이지의 구성을 확인하기 좋다고 생각했으나 reactQuery 를 래핑한 훅을 통해 스페이스 관련 정보를 가져와 특정 국가의 조건을 내려주는 과정을 페이지 최상위에서 처리하고 싶지 않았기 때문에 어쩔 수 없이 최상위 페이지에서 별도의 컴포넌트로 분리했다.

이제 구독플랜템플릿 컴포넌트 하위부터 실질적인 ui에 대한 구현이 이루어져 꽤 많은 비지니스 로직으로 지저분해질 수 있는 컴포넌트들의 역할을 분리함으로써 최대한 유지보수에 용이하도록 노력했다.

export const 플랜페이지버튼 = () => {
  const router = useRouter();
  const {
    data: { 플랜정보 },
  } = usePlanSpace(router.query.id as string);

  // 구독취소 버튼이 숨겨져야하는 조건
  const shouldHideCancel =
    조건1 ||
    조건2 ||
    조건3 ||
    조건4;

  return (
    <Stack direction={'row'} gap={8} className={S.button_wrapper}>
      <If condition={shouldHideCancel}>
        <Then>
          <문의버튼 />
        </Then>
        <Else>
          <문의버튼 />
          <구독취소버튼 />
        </Else>
      </If>
    </Stack>
  );
};

기획적으로 현재 플랜의 구독 상태들과 스페이스별 결제 및 등록된 카드 등의 결제 상태 등등 페이지 내부에 수많은 분기가 생길 수 있어 최대한 컴포넌트의 재사용성을 지양하고 분기별로 명확하게 렌더링되어야하는 컴포넌트만 보일 수 있도록 구현했다. 이렇게 구현하면 하나의 컴포넌트가 추후에 생기게될 분기나 디자인에 따라 내부의 구현이 지저분해지지 않고 어떤 개발자가 유지보수하더라도 그 상황에 맞는 상황과 컴포넌트만 추가하면 된다.

이전 개발을 할때는 이러한 컴포넌트 분기 과정에서 useMemo를 통해 분기별로 리턴하는 컴포넌트를 별도로 구분하여 처리했으나 좀 더 선언적으로 깔끔하게 처리할 수 있도록 우리 팀은 react-if 라이브러리를 사용하여 다음과 같이 컴포넌트에 대한 분기를 처리하고 있다. react-if 는 If, Else 뿐만 아니라 Switch, When 등 다양한 분기를 처리할 수 있는 컴포넌트를 제공한다.

react-if npm

기능 마무리 및 통합테스트

구현 자체의 방향성은 대부분 위와 같은 내용으로 처리했다. 꽤 여러가지 이슈가 있었음에도 데일리 스크럼 및 꾸준한 소통을 통해 기능은 점차적으로 마무리되어갔고 유저들의 돈이 실제로 결제되는만큼 QA 및 통합테스트의 기간을 확보하기위해 노력했다. 정기결제를 진행했던 팀원들 모두 크런치모드로 통합테스트 기간을 확보하기 위해 열심히 마무리하려고 했다.

그렇게 통합테스트 기간을 우리가 예상했던대로 확보할 수 있었다. 사실 통합테스트로 넘어갔더라도 우리의 일은 끝난게 전혀 아니였다. 오히려 이제 시작이랄까…?ㅎ 테스트과정에서 미쳐 생각하지 못했던 기획이나 구현 등이 이슈로 쏟아졌다. 이 모든걸 테스트해주시는 QA분들도 진짜 리스펙이였다. 테스트 기간 중에서도 우리가 정해둔 룰은 각자 맡은 부분에서의 이슈들에서 우선순위가 High로 지정된 것들은 최대한 빠르게 픽스하는 것. 우선순위 medium과 low는 비교적 낮은 우선순위로 통합테스트 기간이 끝날 때까지만 처리가 완료되는 쪽으로 정했다. 그렇게 1주일이 되지 않는 기간동안 처리한 이슈들은 다음과 같다…

정말 다들 고생했습니다…기획자가 부재한 상황에서 부족한 기획까지 힘써주신 PM, 디자이너, 개발자 모두와 많은 이슈들, 일정관리 해주신 PM팀, 많은 요구사항이나 변경사항, 잦은 스펙다운 등의 이유로 빠르게 디자인 대응해주신 디자인팀, 엄청나게 짧은 기간임에도 테스트 책임감으로 최대한 빈틈이 없도록 밤새 이슈 찾아주신 QA팀, 그리고 적은 인원으로 빠르게 구현 및 안정성까지 챙기려고 노력했던 개발팀 모두 많이 배웠습니다. 감사합니다ㅎㅎ.

이번엔 리팩토링까지!

이번엔 프로덕트팀끼리 회고를 진행하고 마이그레이션때는 별도로 기간을 산정하여 하지 못했던 코드 리팩토링 기간까지 별도로 산정하여 진행하고 있다. 현재 리팩토링은 서버팀, 프론트엔드팀 모두 초반에 미처 진행하지 못했던 부분들까지 논의를 통해 좋은 방향으로 리팩토링하고 있다. 리팩토링이 끝난다면 또 별도의 내용으로 정리해보려고 한다.

끝으로

미숙했던 점, 아쉬웠던 점들도 많은 스프린트였지만 결코 쉽지 않았던 기능임에도 열심히 잘하려고 노력했던 것 같다. 현재 젭에서 개발자로 일한지 2년차로서 초기 입사 시절의 내가 작성한 코드들을 보니 정말 우왘소리가 절로 나오는 걸보니 내가 그간 좋은 팀원분들과 일하면서 많은 부분을 배우고 성장하고 기여할 수 있었던 것 같다. 앞으로 더 꾸준히 노력하고 성장해서 회사와 팀에 더 많은 내용을 기여하고 그를 통해 나 자신도 성장할 수 있도록 노력하는 개발자가 되려고 한다.

정기결제? 그래서 뭘 한다고?

기존 레거시(Angularjs) 프로젝트를 신규 프로젝트(Next.js)로 마이그레이션한지 얼마 되지 않아 우리에게 새로운 미션이 떨어졌다. 바로 젭 스페이스에 정기결제 기능을 추가하는 것이였다.

💡 젭 스페이스 정기결제란? 각 스페이스마다 선택가능한 ****플랜(플랜 별 제공기능 상이)과 해당 플랜에 따른 금액이 등록된 카드로 매달 정기적 과금되는 신규 BM

커다란 프로젝트가 끝난지 얼마 되지 않아 꽤 규모가 있는 기능이 바로 출시가 된다는 점이 생각보다 꽤 부담으로 다가왔다.

하지만 서비스의 안정성이나 추후 개발 유지보수성을 올리기 위해 리액트로의 프로젝트 마이그레이션을 결정했고 그를 위해 우리의 서비스는 약 6개월이 넘는 기간동안 멈춰있었다. 프로덕트 관점에서 본다면 사실상 서비스가 6개월간 멈춰있었다는 것. 가뜩이나 현재 스타트업들은 매출 등의 자생력을 갖추기 위해 노력을 해야하는 시기이기 때문에 다음과 같은 사업적, 기획적 판단은 충분히 납득 가능했다.

이번에도 타이트한 일정…실수를 반복하지 않기 위해

아무래도 시기적으로나 사업적으로나 정말 중요하고 필요한 기능이였기에 일정이 타이트할 것은 충분히 예상했다. 급하다는 이유로 마이그레이션때와 마찬가지로 스프린트 기능의 배포일자를 먼저 픽스해두고 기획, 디자인, 개발, QA 등의 과정이 이루어지다보니 개발로 확보할 수 있는 시간이 넉넉치 않았고, 초기 기획 단계에서 정했던 예상 개발 기간보다 확연하게 줄어들어버린(체감상 반이 줄어든것 같은데…ㅠ) 기간 내에 정기결제 기능을 출시해야했다.

하지만 이번엔 마이그레이션을 진행하면서 아쉬웠던 부분들을 해소해보고자 개발단계에서 발생하는 이슈 및 기간 산정 등에 좀 더 무게를 주어 각자 구현이 필요한 기능별로 구현에 이슈가 없는지 PM, QA 및 서버 개발자들과 함께 데일리스크럼을 진행했다. 데일리 스크럼의 진행방식은 다음과 같았다.

1. PM은 기획 및 구현사항에 변경사항이 있다면 전파한다.
2. 각자 돌아가면 빠르게 진행된 개발 상황을 브리핑한다.
3. 구현이 필요한 내용들을 다같이 확인하며 해당 기능에 필요한 개발기간을 객관적으로 산정한다.
4. 이 때, 이전에 산정된 개발기간 혹은 진행상황에 이슈가 있다면 공유한다.
5. 기능이 진행 및 완료됐다면 산정한 StoryPoints를 차감 및 제거한다.

확실히 마이그레이션 당시에는 이러한 프로세스 없이 모두가 바쁘게 스프린트를 진행하다보니 진행 막바지에 여기저기서 이슈들이 터져나왔었다.

이렇게 짧은 시간내로 스크럼을 진행하니 전체 스프린트 인원의 작업 진행 상황과 이슈 등을 공유할 수 있어 편했고, 무엇보다 객관적으로 정해진 개발 기간에 따라 정해진 태스크를 처리하는 과정이 있어 스프린트 진행 과정에서도 작은 성취감을 느껴가며 스프린트를 진행할 수 있는 점이 좋았다.

컴포넌트 설계부터라도 미리 시작해볼까…?

정기결제 스프린트가 시작되고 제일 먼저 고민했던 부분이였다.

“뭐부터 시작하는게 좋을까?”

기획자의 부재로 인해 정기결제 기능 자체에 대한 기획인 부족한 상태에서 구현해야할 페이지의 디자인도 아직 완성되지 않은 상황이였다. 기획이 부실하다보니 디자인 및 서버의 api 설계단계서부터 변경사항이 너무나도 많았다. 어쩌면 이 때 프론트엔드가 하지말아야했던 실수를 저질렀던 것 같다. 기획, 디자인, api 스펙 등이 정해지고 있는 과정에서 미완성된 페이지 등을 통해 감안하여 미리 페이지의 컴포넌트를 설계해보기 시작했다.

마이그레이션을 진행하며 이미 프로젝트 자체에는 어느정도 익숙해져 있다보니 어느정도 틀에 맞춰 빠르게 설계해볼 수 있었다. 이 때의 생각이라면 배포일이 픽스되었고 누구하나 쉬고 있지 않다보니 뭐라도 빨리 할 수 있는 부분이라면 조금이라도 진행해두는 것이 맞지 않나라는 생각을 했다. 하지만 끝나고 회고해보니 이 타이밍에는 오히려 기획적으로 적극적인 기여를 통해 스프린트에 구현해야할 기능 및 전반적인 프로세스에 익숙해지고, 서버팀과의 api 스펙 등의 논의를 통해 요청 및 응답 타입 등을 통일성있게 설계 하는 등의 작업을 하는게 맞았던 것 같다.

사실 개발 년차가 늘어날수록 자연스레 컴포넌트 설계 및 제작 능력은 점점 늘어나기에 제대로된 기획과 디자인이 있다면 구현 자체는 생각보다 빠르게 할 수 있을 것이다.

결국 스프린트가 진행될수록 기획 및 사업적인 판단에 의해서 거의 대부분의 내용에 대해 변경사항이 생겨났고, 초기에 설계해둔 컴포넌트는 사실상 의미가 없이 다시 만들어야했다. 잦은 변경사항에 대해 불만도 조금씩 생겨났고 api 등의 스펙에 대한 논의도 부족했다보니 기존에 사용하던 응답 필드 등이 기존과 다른 스타일로 생겨나기 시작하면서 기존 프로젝트에도 점점 통일성이 떨어지게되는 문제도 발생하였다.

플랜 관리페이지 및 플레이 설정 내 플랜관리 탭

위의 녀석들이 이번 내가 맡아 구현해야했던 기능들이였다. 어느정도 컴포넌트에 대한 설계는 이루어져있고 각자 맡은 부분을 인수인계받아 마무리했던 마이그레이션때와 다르게 이번엔 프로젝트의 첫 페이지부터 내가 설계하고 만들어볼 수 있었다.

플레이화면에서 스페이스의 관리자가 사용할 수 있는 설정 내 플랜관리 구현은 마이그레이션때 진행했던 설정 컴포넌트의 구조의 연장선으로 구현했다보니 크게 어려움은 없었다. 내가 아무래도 마이그레이션때도 설정쪽을 담당했다보니 팀원들이 나를 장난식으로 설마(설정마스터)라고 부르고 있다. 어감은 썩 좋진않지만…ㅋㅎ

설정 쪽의 구현했던 방식은 마이그레이션 회고 블로그 글에서 확인할 수 있다.

어찌보면 이번 스프린트에서의 내 온전한 설계가 들어간 부분은 결제 관리페이지다. 리액트를 처음 배우던 시절에는 최대한 아토믹하게 컴포넌트를 쪼개는 것을 선호했던 것 같다. 잘게 쪼갠 컴포넌트들은 그 역할에 따라 컴포넌트 폴더 내에 배치되고 어떻게든 그 컴포넌트들을 재사용하는 쪽에 목적을 두었다.

하지만 점점 개발을 하다보니 하나의 컴포넌트가 재사용이라는 이유로 컴포넌트 내부에서 props 나 상태에 따라 분기가 너무 많아지면서 너무나도 많은 역할을 하게 된다는 부분이 마음에 들지 않았다. 컴포넌트가 특정 도메인에 종속되지 않되 컴포넌트 내부에서 최대한 많은 분기를 갖지 않도록 구현하려고 노력했더 것 같다.

구현에 대한 간단한 내용으로 플랜 관리페이지의 최상위는 SSRSafeSuspense 컴포넌트로 감싸주었다. SSRSafeSuspense는 Nextjs에서 하위에서 사용한 Suspense가 상위 페이지의 hydration 과정보다 먼저 끝나면서 런타임에러가 발생하는 이슈가 있어 팀 내부적으로 공통컴포넌트로 만들어 다음과 같이 사용 중이다.

export default function SSRSafeSuspense(
  props: ComponentProps<typeof Suspense>,
) {
  const isMounted = useIsMounted();

  if (isMounted) {
    return <Suspense {...props} />;
  }
  return <>{props.fallback}</>;
}

페이지가 아직 hydration 중이라면 props로 넘겨받은 fallback을 보여주도록 처리하고 앱이 마운트된 후에 Suspense를 활성화한다.

export default function 구독관리페이지() {
  return (
    <SSRSafeSuspense fallback={<플랜페이지 스켈레톤 />}>
      <구독플랜 />
    </SSRSafeSuspense>
  );
}

이전 다른 팀원분이 코드리뷰를 해주셨던 내용으로 Nextjs의 최상위 페이지는 최대한 간결하게 유지하도록 하는 것을 추천해주어 최상위에서는 최대한 해당 페이지의 어떤 부가적인 부분이 처리되고 있는지에 집중할 수 있도록 구현하고 있다. 다음과 같이 구현하니 추후 어떤 개발자가 유지보수를 하게되더라도 “아 어찌됐든 이 페이지는 SSRSafeSuspense가 적용되어 있구나” 와 같은 생각을 할 수 있게되고, repo 내에서도 쉽게 페이지 구조를 변경할 수 있는 이점이 생겨서 요즘 많이 애용하고 있는 패턴이다.

SSRSafeSuspense 를 제외하고 클라이언트 컴포넌트에서 실제 로딩 및 에러에 대한 처리는 토스 Slash 라이브러리에서 WithAsyncBoundary를 활용하여 컴포넌트의 로딩 및 에러처리를 하고 있다.

const 구독플랜 = withAsyncBoundary(Suspense를_일으키는_컴포넌트, {
  // 로딩 중일 때 보여줄 컴포넌트
  pendingFallback: <div>로딩 중입니다.</div>,
  // 에러가 발생했을 때 보여줄 컴포넌트. 첫 번째 인자로는 발생한 에러가 전달됩니다.
  rejectedFallback: error => <div>에러가 발생했습니다. {error.message}</div>,
});

이를 위해 컴포넌트 내부에서 사용될 reactQuery를 감싼 형태의 모든 훅들은 useSuspendedQuery 를 한번 감싼 형태의 훅으로 사용하여 AsyncBoundary가 Suspense를 인식하도록 처리하였다.

export const 구독플랜 = () => {
  const { query } = useRouter();
  const {
    data: { 스페이스관련정보 },
  } = usePlanSpace(query.id as string);

  return (
    <PageBlocker
      condition={isJP(스페이스관련정보.국가코드)}
      fallbackUrl={'/somethingWentWrong'}>
        <구독플랜템플릿 />
    </PageBlocker>
  );
};

구독플랜 컴포넌트 내부의 경우 기획의 요구사항으로 글로벌 서비스 중이지만 아직 출시되지 않은 특정 해외의 페이지 접속을 막기 위해 특정 조건일 때 해당 fallbackUrl로 이동을 막는 HOC로 감싸주었다. PageBlocker 컴포넌트는 특정 조건일 때 접속만 막는 단일책임 컴포넌트로 역할을 하게되고 덕분에 구독플랜템플릿 컴포넌트는 페이지 구현 자체에만 역할이 집중될 수 있도록 구현했다. 템플릿이라는 네이밍은 아토믹 컴포넌트 개념 중 여러 컴포넌트들이 조합되어 전체페이지의 구성을 이루고 있는 단계의 개념으로써 템플릿이라고 이름 지었다.

사실 이 구독플랜 컴포넌트가 따로 래핑되지 않고 상위의 구독관리 페이지에 위치하는 것이 한눈에 페이지의 구성을 확인하기 좋다고 생각했으나 reactQuery 를 래핑한 훅을 통해 스페이스 관련 정보를 가져와 특정 국가의 조건을 내려주는 과정을 페이지 최상위에서 처리하고 싶지 않았기 때문에 어쩔 수 없이 최상위 페이지에서 별도의 컴포넌트로 분리했다.

이제 구독플랜템플릿 컴포넌트 하위부터 실질적인 ui에 대한 구현이 이루어져 꽤 많은 비지니스 로직으로 지저분해질 수 있는 컴포넌트들의 역할을 분리함으로써 최대한 유지보수에 용이하도록 노력했다.

export const 플랜페이지버튼 = () => {
  const router = useRouter();
  const {
    data: { 플랜정보 },
  } = usePlanSpace(router.query.id as string);

  // 구독취소 버튼이 숨겨져야하는 조건
  const shouldHideCancel =
    조건1 ||
    조건2 ||
    조건3 ||
    조건4;

  return (
    <Stack direction={'row'} gap={8} className={S.button_wrapper}>
      <If condition={shouldHideCancel}>
        <Then>
          <문의버튼 />
        </Then>
        <Else>
          <문의버튼 />
          <구독취소버튼 />
        </Else>
      </If>
    </Stack>
  );
};

기획적으로 현재 플랜의 구독 상태들과 스페이스별 결제 및 등록된 카드 등의 결제 상태 등등 페이지 내부에 수많은 분기가 생길 수 있어 최대한 컴포넌트의 재사용성을 지양하고 분기별로 명확하게 렌더링되어야하는 컴포넌트만 보일 수 있도록 구현했다. 이렇게 구현하면 하나의 컴포넌트가 추후에 생기게될 분기나 디자인에 따라 내부의 구현이 지저분해지지 않고 어떤 개발자가 유지보수하더라도 그 상황에 맞는 상황과 컴포넌트만 추가하면 된다.

이전 개발을 할때는 이러한 컴포넌트 분기 과정에서 useMemo를 통해 분기별로 리턴하는 컴포넌트를 별도로 구분하여 처리했으나 좀 더 선언적으로 깔끔하게 처리할 수 있도록 우리 팀은 react-if 라이브러리를 사용하여 다음과 같이 컴포넌트에 대한 분기를 처리하고 있다. react-if 는 If, Else 뿐만 아니라 Switch, When 등 다양한 분기를 처리할 수 있는 컴포넌트를 제공한다.

react-if npm

기능 마무리 및 통합테스트

구현 자체의 방향성은 대부분 위와 같은 내용으로 처리했다. 꽤 여러가지 이슈가 있었음에도 데일리 스크럼 및 꾸준한 소통을 통해 기능은 점차적으로 마무리되어갔고 유저들의 돈이 실제로 결제되는만큼 QA 및 통합테스트의 기간을 확보하기위해 노력했다. 정기결제를 진행했던 팀원들 모두 크런치모드로 통합테스트 기간을 확보하기 위해 열심히 마무리하려고 했다.

그렇게 통합테스트 기간을 우리가 예상했던대로 확보할 수 있었다. 사실 통합테스트로 넘어갔더라도 우리의 일은 끝난게 전혀 아니였다. 오히려 이제 시작이랄까…?ㅎ 테스트과정에서 미쳐 생각하지 못했던 기획이나 구현 등이 이슈로 쏟아졌다. 이 모든걸 테스트해주시는 QA분들도 진짜 리스펙이였다. 테스트 기간 중에서도 우리가 정해둔 룰은 각자 맡은 부분에서의 이슈들에서 우선순위가 High로 지정된 것들은 최대한 빠르게 픽스하는 것. 우선순위 medium과 low는 비교적 낮은 우선순위로 통합테스트 기간이 끝날 때까지만 처리가 완료되는 쪽으로 정했다. 그렇게 1주일이 되지 않는 기간동안 처리한 이슈들은 다음과 같다…

이번엔 리팩토링까지!

이번엔 프로덕트팀끼리 회고를 진행하고 마이그레이션때는 별도로 기간을 산정하여 하지 못했던 코드 리팩토링 기간까지 별도로 산정하여 진행하고 있다. 현재 리팩토링은 서버팀, 프론트엔드팀 모두 초반에 미처 진행하지 못했던 부분들까지 논의를 통해 좋은 방향으로 리팩토링하고 있다. 리팩토링이 끝난다면 또 별도의 내용으로 정리해보려고 한다.

끝으로

미숙했던 점, 아쉬웠던 점들도 많은 스프린트였지만 결코 쉽지 않았던 기능임에도 열심히 잘하려고 노력했던 것 같다. 현재 젭에서 개발자로 일한지 2년차로서 초기 입사 시절의 내가 작성한 코드들을 보니 정말 우왘소리가 절로 나오는 걸보니 내가 그간 좋은 팀원분들과 일하면서 많은 부분을 배우고 성장하고 기여할 수 있었던 것 같다. 앞으로 더 꾸준히 노력하고 성장해서 회사와 팀에 더 많은 내용을 기여하고 그를 통해 나 자신도 성장할 수 있도록 노력하는 개발자가 되려고 한다.