logo

고객 경험으로부터 구현 방법 도출하기

작성 2024년 12월 11일

업데이트 2024년 12월 11일

Yess 팀에서 프론트엔드 개발자로 업무를 진행한 지 반 년 쯤 되었을 때, 웹앱을 모노레포로 전환했다. 작지 않은 규모의 레거시 코드베이스에 대해 근본적인 구조 변경을 가하는 작업이었기 때문에 노가다와 예상치 못한 빌드 에러를 해결하는 과정의 연속이었다. 하지만 blazingly fast한 경험 제공이라는 목표가 뚜렷했기 때문에 그 목표를 달성하기 위해서 필요한 과정으로 인지하고 팀이 함께 노력할 수 있었다. 결과적으로 약 3주의 시간을 들여서 모노레포 전환에 성공했고, 위젯 내 Inquiry Form 로딩 시간이 5초 대에서 1초 미만으로 개선되었다. 이 경험에 대해 기록해 보고자 한다.

이 블로그 하단에서 Contact 버튼을 누르면 개선된 결과를 볼 수 있다

Yess의 Widget과 Inquiry Form

Yess는 사용자가 자신의 웹사이트에 코드 스니펫을 심어서 고객 문의 창구로 사용할 수 있는 Widget SDK를 제공한다. 이 Widget의 메뉴 중에는 고객 문의를 입력받는 Inquiry Form이 포함된다.

Inquiry Form은 Typeform 등 타 서비스에서 제공하는 것과 유사한 폼 기능으로, Widget이 처음 구현될 당시 이미 단독 기능으로 별도 운영되고 있었다. Inquiry Form에서는 공개 URL을 생성하여 사용자가 본인의 웹사이트에 임베드하거나 URL을 공유할 수 있다. 따라서 Widget에서는 사용자가 문의 버튼을 눌렀을 때 이 공개 URL을 iframe에 띄우는 형식으로 Widget 내 Inquiry Form 기능을 구현했다.

문제는 Widget SDK가 이미 iframe으로 동작하고 있기 때문에 이는 iframe 중첩 현상으로 이어졌다. 즉, Widget SDK 로드와 Inquiry Form 로드시에 네트워크 요청이 여러 단계로 이루어지고 있었다. 이 문제를 해결하기 위해서 각각의 네트워크 요청에 대해 응답 속도를 개선하는 것도 고려할 수 있는 방안이었지만, 조금 더 근본적인 방향성을 고민해 보았다. Yess는 Widget이나 Inquiry Form처럼 각각이 별도의 기능으로 동작하면서 한편으로는 서로 연관성을 가지기도 하는 Vertical SaaS 제품이기 때문에, 그 특수성을 고려하는 것이 필요해 보였다. 이번 뿐만이 아니라 앞으로도 비슷한 문제가 발생했을 때 재활용할 수 있는 방법이라면 더 좋을 것 같았다.

기존에 Inquiry Form은 모노리식 구조로 된 웹앱 내부에 포함되어 있고, Widget은 별도 레포로 분리되어 있었다. 따라서 웹앱 레포를 모노레포로 전환하고 Inquiry Form과 Widget이 각각 하나의 패키지로 존재하는 형태로 구현해 보기로 했다. 이렇게 하면 여러 개의 앱이 서로를 빌드 타임에 참조할 수 있는 환경이 구성되어 Widget 내 Inquiry Form의 로딩 시간이 제거될 것으로 예상했기 때문이다.

물론 멀티 레포 구조를 유지하면서도 각 기능을 빌드된 결과물만 제공할 수 있도록 라이브러리화 한다면 같은 효과를 달성할 수 있겠지만, 멀티 레포의 시멘틱 버전 관리나 컨텍스트 스위칭으로 인한 부담 또한 있을 것이다. 이는 소규모 팀에서 빠른 속도로 제품을 개선해 나가고 있는 단계에서 상대적으로 단점이 큰 전략이라고 판단했다. 반대로 모노레포를 구성한다면 웹앱에 모든 기능이 묶여 있기보다는 기능 별로 독립적인 패키지가 있고 웹앱이 이를 참조할 수 있게 되면서 보다 유연하고 확장성 있는 제품 개선이 가능해진다.

beforeafter
- Web App
    - Inquiry Form
    - And many more...
- Widget
- Frontend Monorepo
    - Web App
    - Inquiry Form
    - Widget
    - And many more...

목표는 blazingly fast한 경험을 제공하는 것

이와 같은 구현 방법을 도출하는 데 목표 정의가 큰 영향을 미쳤다. Yess에서는 한 주 간의 스프린트를 반복하며 작은 단위의 유저 스토리를 최대한 빠르게 해결해 나가는 식으로 업무를 진행한다. 또한 여느 초기 스타트업과 마찬가지로 성능을 개선하는 것보다는 PMF를 찾기 위해 새로운 기능을 구현하는 것의 우선순위가 높았다. 그래서 유저 스토리는 "유저가 이러저러한 기능을 사용할 수 있어야 한다"는 내용이 주를 이룬다.

한편 Yess 팀의 핵심 가치에는 빠른 실행Bias for Action뿐 아니라 높은 퀄리티Insanely Great 역시 포함되어 있었기 때문에 업무를 하면서 높은 퀄리티를 지향하고자 개인적으로 많은 노력을 들였다. 그러나 개인적으로 노력하는 것의 단점은, 많은 경우 팀의 목표와는 동떨어졌거나 후순위의 일로 여겨진다는 점이었다. 디자인 시스템과 같은 프로젝트도 그러한 노력의 일환이었는데, 처음 시스템을 구축하면서 시간을 들인 뒤에는 좀처럼 개인적인 시간을 들이지 않으면 개선하기가 어려웠다.

이번 과제에서는 팀의 목표였던 유저 스토리 제목이 "The widget form loads blazingly fast⚡" 였다. 이 목표로부터 거꾸로 구현 방법에 대한 ideation을 자유롭게 펼쳐 보았다. 오로지 그 목표를 가장 잘 달성할 수 있는 방법만을 고민했기 때문에 작업이 얼마나 클지에 대한 우려는 뒤로 하고 최선의 방향성을 선택할 수 있었다. 만약 다른 팀원들과 목표에 대한 얼라인먼트 없이 개발자 혼자서 성능을 개선하겠다는 의지만으로 시작했다면 엄두를 내지 못했을 선택지였다.

이 경험을 통해서 아마존의 Working Backwards 방법론을 떠올렸다. 해당 방법론을 다룬 책에서, 저자는 이로 인해 작업 시간이 줄어들지는 않지만 조직과 비즈니스는 괄목하게 성장할 수 있다고 한다. 최종 고객 경험을 먼저 정의하면 실현 가능성이나 예상되는 한계보다는 목표를 달성하는 데 집중하게 되는 효과가 있고 결과적으로 달성 확률도 올라가기 때문이다.

팀과 함께, 실행부터 회고까지

실제로 아마존에서 이루어지는 것처럼 보도자료를 작성하는 등 구체적인 내용 그대로 도입한 건 아니었지만, 최종 고객 경험이라는 목표에 집중함으로써 모든 과정에서 빠르게 합의점에 이를 수 있음을 체감했다.

1) 전략 선택

Yess 팀에서는 모든 유저 스토리에 대해서 개발 전략을 세우고 여러 개 옵션 중 최선으로 판단한 것을 실행한다. 전략을 제안하는 입장에서 모노레포 전환은 실현 가능성이 낮게 여겨질 것이라 예상했지만, 위에서 고려한 장단점과 함께 목표 달성에 초점을 두어 공유하였다. 그 결과 프론트엔드 개발자들을 비롯하여 팀원 모두가 목표 달성을 위해서는 필요하겠다는 점을 공감해 주어 최종적으로 이 전략을 선택할 수 있었다.

2) 플래닝

여러 모로 이전에 해왔던 것과는 새로운 형태의 작업이 예상되어 플래닝을 하기에 막연해 보였다. 그러나 막연했기 때문에 오히려 새로운 방법을 시도해 보면서 러닝을 얻을 수 있었다. 우선 태스크Task를 예상되는 리스크 정도에 따라 두 가지로 분류했다. 리스크는 구체적으로 작업의 내용과 시간을 산정할 수 있는지, 혹은 산정하더라도 작업 중 문제가 발생할 여지가 있거나 블랙박스 요소가 포함되어 있는지를 고려했다. 그리고 각 태스크의 기대 결과To Be를 정의했다.

  • 리스크 높음
    • Task 1: yarn workspaces 사용을 위해 npm -> yarn으로 패키지 매니저 변경
      • To Be: Web App이 정상적으로 빌드 및 배포된다.
    • Task 2: 별도 레포에 있던 Widget SDK 코드베이스를 모노레포 하위 패키지로 이관
      • To Be: Widget SDK가 정상적으로 빌드 및 배포된다.
    • Task 3: 모노레포 구조에 필요한 형태로 CI/CD 변경
      • To Be: 변경 사항이 있는 패키지에 한해서 정상적으로 빌드 및 배포된다.
  • 리스크 낮음
    • Task 4: 공통화 성격의 코드를 대상으로 test-bed 패키지 생성
      • To Be: test-bed 패키지로 분리된 코드가 Web App에서 정상적으로 참조된다.
    • Task 5: Web App에 포함되어 있던 Inquiry Form 코드를 별도 패키지로 분리하고 내부 비즈니스 로직은 Provider를 통해 주입
      • To Be: 분리된 Inquiry Form이 Web App에서 정상적으로 참조된다.
    • Task 6: Web App과 Widget SDK에서 iframe 사용하지 않고 Inquiry Form 패키지를 참조하도록 변경
      • To Be: 분리된 Inquiry Form이 Widget SDK에서 정상적으로 참조된다.

3) 작업 진행

리스크가 높은 것부터 먼저 해결하되, 서로 의존성이 있는 태스크가 아니라면 팀원의 도움을 받아 병렬적으로 진행하기로 했다. 정리하면 다음과 같은 순서가 된다.

1 -> 4 -> 2 -> 3(팀원의 도움) & 5 -> 6

결과적으로 리스크가 높으리라고 예상했던 태스크들은 오히려 빠르게 끝나거나 팀원의 도움을 받았는데, 리스크가 낮으리라고 예상했던 태스크에서 예상보다 더 많은 시간이 소요됐다. 앞(1,4,2,3)에서 심리적 부담을 덜고 시간을 확보한 덕분에 뒤(5,6)에서 예상치 못한 문제가 발생했을 때 대응하기 더 수월했다.

예상치 못한 문제 1: API 요청 시 CORS 이슈

첫 번째 예상치 못한 문제는 Widget 내 Inquiry Form에서 API 요청 시 CORS 이슈가 발생하는 것이었다. 기존에는 iframe을 통해 공개 URL을 임베드했기 때문에 API에서 별도로 Widget 케이스에도 허용해주는 처리를 할 필요가 없었다. 그런데 iframe을 걷어내고 Widget 내에서 Inquiry Form 컴포넌트를 가져다 쓰게 되자 이제는 API를 요청하는 도메인이 Widget으로 변경되면서 API에서 해당 도메인을 허용하지 않아 CORS 에러를 응답했다.

이번 작업은 플래닝 당시 백엔드에서 할 일이 없다고 판단하여 다른 팀원들은 대부분 다른 유저 스토리를 진행하고 있는 참이었다. 문제를 해결하기 위해 백엔드 개발자 한 분과 급히 논의를 진행한 결과, Widget 서버에 API 엔드포인트를 추가하고 Widget 서버에서 Web App 서버의 Inquiry Form API와 통신하는 로직을 구현하기로 했다.

예상치 못한 문제 2: 서드파티 라이브러리의 스타일이 깨지는 이슈

두 번째 예상치 못한 문제는 Inquiry Form에서 사용 중인 서드파티 라이브러리의 스타일이 깨지는 이슈였다. 기존에는 iframe을 통해 완전히 독립된 환경에서 Inquiry Form을 그렸기 때문에 스타일의 스코프 또한 분리되어 있었는데, 이제는 컴포넌트만 렌더링하기 때문에 부모 컴포넌트의 스타일 스코프에 영향을 받게 된 것이다.

문제는 Inquiry Form 내부에 있는 특정 컴포넌트에서 react-select 라이브러리를 사용하고 있었는데, 이 라이브러리는 emotion으로 스타일링을 하고 있으나 Yess에서는 styled-components를 사용하고 있었다. emotion은 CSS in JS 라이브러리로써, 자바스크립트로 작성된 코드를 런타임에 참고하여 스타일을 동적으로 제어할 수 있도록 해준다. 이 과정에서 스타일을 DOM의 head 부분에 삽입하는데 Widget이 임베드된 iframe 대신 부모 DOM(=사용자 웹사이트 레벨)으로 스타일이 삽입되는 것이 원인이었다.

문제를 해결하기 위해 검색하다가 관련된 깃헙 이슈를 찾아보았고, 결과적으로 emotion의 CacheProvider를 씌워서 Widget 레벨에 스타일이 적용되도록 하니 해결이 되었다.

4) 회고

Yess 프론트엔드에서는 매 스프린트마다 회고를 진행하는데, 사실 이러한 프로세스를 도입한 게 이번 모노레포 전환 작업 이후부터였다. 기존에도 전체 팀 단위의 스프린트 회고는 매주 진행하고 있었지만 프론트엔드 파트만의 회고는 없었다. 그런데 모노레포 전환처럼 큰 규모의 변경을 가하는 작업을 하다 보니 배포 이후에도 계속 챙겨야 할 후속 작업들이 있었고, 무엇보다 이 과정에서 얻었던 교훈과 각자의 생각들을 그냥 흘려보내기는 아깝다는 생각이 들었다. 이후로는 매주 프론트엔드 개발자들이 모여 기술 관점에서 스프린트를 회고하고 액션 아이템을 관리하는 자리를 만들기로 했다. 회고 프레임워크로는 잘 알려진 KPT 방식을 도입했다.

Keep

  • 고객 경험으로부터 구현 방법 도출하기
    • 유저에게 제공되는 가치를 먼저 정의하고, 이를 위해 필요한 작업을 산정하는 순서로 진행한 것이 긍정적인 평가를 얻었다. 앞으로도 스프린트 목표를 명확히 인지하고 개발 과제를 목표와 잘 연결하여 팀과 커뮤니케이션하기로 하였다. 의사 결정의 기준이 유저에게 제공되는 가치임을 명확히 하면 팀 내 합의가 빠르게 이루어지고 실행 결과도 그 가치에서 벗어나지 않게 된다.
  • 리스크 관리
    • 작업을 리스크 여부에 따라 두 단계로 나눈 덕분에 프로젝트 매니징 관점에서 리스크 관리가 잘 이루어졌다는 평가를 얻었다. 스토리 포인트가 명확하게 산정되지 않는 작업이더라도 필요한 작업 리스트를 최대한 상세히 나열한 후, 리스크가 큰 작업부터 먼저 진행하는 것이다. 이후에도 도전적인 작업을 해야하는 경우에는 이러한 방식을 계속해서 유지했는데 굉장히 효과적이었다.

Problem

  • API 의존성 미리 확인하기
    • API CORS 이슈에 대해서 이를 사전에 방지할 수 있었을지 논의했다. 플래닝을 할 당시에는 단순히 iframe을 걷어내고 컴포넌트로 렌더링할 수 있는지만 고려했었는데, 이를 위해 필요한 작업(플래닝 중 5번 작업)을 도출한 후에는 그 외의 리스크를 떠올리지 못했다. 특히 CORS 이슈의 경우 백엔드 개발자가 함께 플래닝을 리뷰했다면 미리 발견할 수도 있었을 것이라는 의견이 있었다. 이 의견을 반영하여 플래닝 템플릿에 API 의존성에 대해 확인하는 영역을 추가해 두고 플래닝 단계에서 항상 확인하고 넘어갈 수 있도록 조치하였다.
  • 라이브러리의 사용성 문제 해결하기
    • 라이브러리의 스타일이 깨지는 이슈는 미리 예상하기 어려웠을 것 같다는 의견이 있었다. 오히려 장기적으로 보았을 때 react-select라는 라이브러리를 자체 컴포넌트로 전환하는 것이 필요하겠다고 판단하였다. 왜냐하면 react-select 라이브러리 자체가 편의성과 안정성이 낮았고, 이번 작업에서뿐 아니라 지속적으로 예상치 못한 문제들을 발생시켜 왔기 때문이다. 다만 단기간 내에 빠르게 실행할 수 없는 과제이니 기회를 보며 점진적, 단계적으로 진행하기로 하였다.

Try

  • 공유 세션 및 문서화
    • 상당히 큰 규모의 작업이었는데도 불구하고 실제 작업을 진행한 프론트엔드 개발자는 두 명이었고, 그마저도 각자의 작업 영역이 독립적이었기 때문에 상세하게 작업 내용을 공유할 기회가 없었다. 이런 경우에는 코드 리뷰로 맥락을 파악하기가 어렵다 보니 배포가 진행된 이후에 별도로 프론트엔드 세션을 마련하여 변경된 사항을 공유하는 시간을 가졌다. 앞으로는 이러한 세션을 적극적으로 활용하는 것과 더불어, 새로운 팀원이 들어오는 경우를 대비하여 문서화를 꾸준히 진행하자는 공감대를 이루기도 했다.
  • 운영 배포의 안전 장치 마련
    • 또한 이번 작업을 운영 배포할 당시 force push로 진행해야만 했다. 코드베이스의 구조가 전체적으로 다 바뀌는 바람에 충돌 해결을 일일이 진행하는 것이 불가능한 상황이었던 것이다. 따라서 모노레포 전환이 시작된 시점 이후에 다른 유저스토리에서 먼저 운영 배포된 작업 내용이 있다면 이를 일일이 매뉴얼하게 반영하고 수차례 regression test를 진행했다. 그럼에도 불구하고 마지막까지 force push를 하는 것이 어딘가 불안하게 느껴졌고, 앞으로 이와 같은 상황에서 취할 수 있는 안전 장치에 대해 논의하여 몇 가지를 정해 보았다.
      • rebase, squash merge 등을 적절하게 활용하여 운영 환경에 발생한 변경사항을 그때 그때 반영한다. (권장)
      • 1번 방법을 진행하기 어렵고 force push가 불가피한 경우 기존 운영 환경 버전을 temp 브랜치로 백업해 둔다.
      • temp 브랜치는 배포 후 한 달 정도 유지 후 제거한다.

남아 있는 과제

이번 과제를 통해 크게 고객 경험과 개발 두 가지 관점에서 필요한 목표를 달성하였으나, 여전히 남은 과제들이 있다.

고객 경험: Widget, Inquiry Form에 SSR 도입

Widget과 Inquiry Form은 모두 사용자가 Web App 밖의 영역에서 활용하는 stand-alone 기능이다. 따라서 Web App과는 다르게 로그인이 필요하지 않고 상대적으로 비즈니스 로직이 간결한 편이다. 러프하게 본다면 UI와 API 요청 정도로 이루어진 별개의 Static Web App이라 할 수 있다. 이런 경우에는 SSR을 도입하는 것이 여러 모로 유리하다. 초기 로드에 필요한 내용을 미리 서버에서 받아와서 UI를 서버에서 구성하여 내려줄 수 있기 때문이다. 그렇게 되면 모노레포 전환을 통해 5초 대에서 1초 미만으로 줄였으나 여전히 약간의 로딩이 발생하는 부분까지도 전부 없애버릴 수 있게 된다. (물론 가장 초기에 Widget SDK를 불러오는 시간은 완전히 없어질 수 없겠지만)

또한 CORS 이슈처럼 의도치 않게 백엔드 개발자에게 의존성이 생기는 상황을 개선할 수도 있다. SSR 도입 시 Widget의 서버가 별도 백엔드 레포에 존재할 필요 없이 프론트엔드 코드베이스 내에서 필요한 API 로직을 작성할 수 있기 때문이다. Widget 서버에서는 애초에 그리 복잡한 일을 처리하지 않기 때문에 훨씬 경제적인 방법이라고 생각된다.

개발: Web App의 플랫폼화

현재 Yess의 프론트엔드 코드베이스는 모노레포의 의의를 100% 달성한 상태는 아니다. 처음부터 모노레포를 전제하고 작성된 코드가 아니기 때문에 여전히 Web App에 너무 많은 것들이 묶여 있다. Inquiry Form은 이 과도기적 성격을 가장 잘 보여주는 패키지이다. Inquiry Form 내의 많은 비즈니스 로직이 Web App과 복잡하게 얽혀 있어서 Web App과 Widget 등 사용처에서 Provider를 통해 주입하는 형태로 되어 있다. 궁극적으로는 Inquiry Form에서 비즈니스 로직의 의존성을 떼어내고 필요하다면 공통 패키지를 통해 참조하게끔 한 뒤, 독립적으로 빌드 및 배포할 수 있도록 개선해야 한다.

이는 SSR 도입과 맥락이 이어지는 부분이기도 하다. 모든 패키지가 자신만의 빌드를 생성하게 되면 Web App은 이것들을 필요한 곳에 배치하는 식으로 플랫폼화 될 필요가 있다. 이를 위해서는 Web Server 또한 패키지화 되어야 한다. 기존에는 Web Server가 Web App 코드 내부에 포함되어 있고 단순히 Web App의 메인 엔트리로 가는 라우팅만을 담당하고 있었다. 이를 Web App과 분리한 후 패키지 별로 라우팅을 세분화하여 각각의 패키지 별로 Web Server 로직을 구현할 수 있게 한다면 보다 안정적이고 확장 가능한 모노레포 환경이 마련될 것이다.