Skip to main content

몇 천 페이지의 유저 가이드를 새로 만들며

About 3 minNode.jsReact.jsArticle(s)blogyozm.wishket.comnodenodejsreactreactjs

몇 천 페이지의 유저 가이드를 새로 만들며 관련

React.js > Article(s)

Article(s)

몇 천 페이지의 유저 가이드를 새로 만들며 (1) | 요즘IT
FEConf2023에서 발표한 <몇 천 페이지의 유저 가이드를 새로 만들며>를 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 유저 가이드가 무엇인지, 왜 새롭게 만들게 되었는지와 그 개발 과정에서 만난 두 가지 문제 중 한 가지 문제를 다룹니다. 2회에서는 개발 과정에서 만난 두 번째 문제를 다루고 마무리합니다. 본문에 삽입된 이미지의 출처는 모두 동명의 발표자료로, 따로 출처를 표기하지 않았습니다. 발표자료는 FEConf2023 홈페이지에서 다운받을 수 있습니다.

FEConf2023에서 발표한 <몇 천 페이지의 유저 가이드를 새로 만들며open in new window>[1]를 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 유저 가이드가 무엇인지, 왜 새롭게 만들게 되었는지와 그 개발 과정에서 만난 두 가지 문제 중 한 가지 문제를 다룹니다. 2회에서는 개발 과정에서 만난 두 번째 문제를 다루고 마무리합니다. 본문에 삽입된 이미지의 출처는 모두 동명의 발표자료로, 따로 출처를 표기하지 않았습니다. 발표자료는 FEConf2023 홈페이지open in new window에서 다운받을 수 있습니다.

블로그를 직접 만들고 글을 꾸준히 쓰고 계시는 분들 있으시죠? 저도 사실 제 블로그가 있는데 글이 20개가 안 넘습니다.

하나의 정적인 사이트를 처음에는 쉽고 재미있게 만들 수 있습니다. 하지만 이제 블로그 글이 1,000개를 넘어간다면 혹은 접근성, 인쇄 환경같이 상대적으로 깊게 고려해보지 않았던 것들을 신경 쓰기 시작하게 됩니다. 그때 우리는 어떤 문제를 만나게 될까요? 그리고 그 문제들은 얼마나 어려울까요?

안녕하세요. 저는 사용자에게 데이터를 정보로 전달하는 일에 관심이 많으며, AB180이라는 데이터 스타트업에서 프론트엔드 팀 엔지니어링 매니저를 맡고 있는 이찬희입니다. 오늘은 여러분들께 다음과 같은 이야기를 들려드리려고 합니다.

유저 가이드에 관하여

  • 유저 가이드가 무엇인가요?
  • 왜 어떻게 갈아엎었나요?
  • 리액트 서버 컴포넌트에 배팅하기

개발 과정에서 만난 문제들

  • 접혀진 아코디언을 검색할 수 없나요?
  • 정적 사이트 생성은 적절한 방법인가요?

유저 가이드란?

본격적인 시작에 앞서, 오늘 이야기에 주로 나올 유저 가이드가 정확히 무엇이고 왜 새로 만들게 되었는지 짧게 이야기를 드리려고 합니다.

저희는 에어브릿지(Airbridge)라는 마케팅 성과 분석 도구를 만들고 있습니다. 아래 화면은 그 대시보드인데요. 마케팅 성과 분석을 위해서 사용자분이 알아야 하는 것도 해야 하는 것도 굉장히 많습니다.

그림 1. 에어브릿지 대시보드
그림 1. 에어브릿지 대시보드

SDK 설치, 각종 광고 매체와 연동, 애플이나 구글의 개인정보 보호 정책에 관한 이해 같은 것도 있을 거고요. 이러한 내용은 제품 안에서 안내하기에는 다소 한계가 있습니다. 그래서 저희 회사에서는 이러한 고객들의 궁금증을 해소하고 사용자들의 허들을 낮추기 위해 유저 가이드라는 페이지를 운영하고 있습니다.

그림 2. 에어브릿지 유저 가이드(출처: 에어브릿지)
그림 2. 에어브릿지 유저 가이드(출처: 에어브릿지)

이 유저 가이드는 회사 내의 PW(Product Writing, 프로덕트 라이팅) 팀에서 운영합니다. 처음에는 젠데스크(Zendesk)라는 CMS(Content Management System)로 유저 가이드를 운영했습니다. 보통 문의 창구나 가이드를 만들 때 일반적으로 젠데스크를 많이 사용하시는데요. 특징이 있다면 레이아웃과 테마는 ‘Handlebars’라는 템플릿 엔진을 사용하고, WYSIWYG 에디터에 글을 작성하면 HTML로 코드를 생성한다는 것입니다. 만약 블로그를 직접 만들어보신 분들이라면 Ghost, Jekyll, 그리고 커스텀 로직이 섞인 무언가 정도로 생각을 하시면 될 것 같습니다.


왜 어떻게 갈아엎었나?

언제나 그렇듯 처음에는 별문제가 없었습니다. 그런데 지난 몇 년간 젠데스크를 사용해보면서 불편한 점이 보이기 시작했는데요. 가이드를 작성하다 보니 생각보다 중복되는 화면, 내용이 많았습니다. 하지만 젠데스크에는 콘텐츠 모델 내지는 컴포넌트의 개념이 없었기 때문에 PW분들께서는 비슷한 모양과 내용을 보여주기 위해 매번 HTML 코드를 복사, 붙여넣기 하실 수밖에 없었습니다.

이게 점점 많아지다 보니 반복되는 내용을 수정하기 위해 하나씩 뒤져가며 찾기에도 어려움이 있었고요. 프론트엔드 개발자들의 입장에서는 콘텐츠, 스타일, 스크립트가 강결합되어 유지보수에서도 굉장히 큰 어려움이 있었습니다.

그래서 조금 속된 말로, 닮았지만 엄연히 다른 것들이 유저 가이드를 꽉꽉 채우기 시작한 것입니다. 저는 진짜 미쳐버릴 것 같았죠.

그림3. 닮았지만 엄연히 다른 존재들
그림3. 닮았지만 엄연히 다른 존재들

저만 그러지는 않았을 거고요. 당연히 PW팀에서도 이런 문제에 대해서 인식을 하기 시작했습니다. 작성된 가이드가 천 개를 넘어가던 시점이 올해 초쯤이었는데요. 그때 저와 PW팀은 불편함을 해소하고 서로가 서로의 일을 잘하는 데 집중하기 위해 헤드리스 CMS(Headless CMS)를 도입하기로 결정합니다.

헤드리스 CMS가 무엇인지 쉽게 설명해보겠습니다. PW팀은 ‘콘텐트풀(Contentful)’이라는 CMS에서 구조화 가능한 형태로 콘텐츠를 작성하고, 프론트엔드 팀은 작성된 콘텐츠를 API로 불러와 보여주는 사이트를 만들기로 합니다.

아래는 대략적인 업무 흐름 내지는 정리된 자료들이 어떻게 보이는지를 간단히 그려본 것입니다.

그림4 헤드리스 CMS
그림4 헤드리스 CMS

그렇게 해서 유저 가이드를 바닥부터 직접 만들게 되었습니다. 개발자들에게 항상 즐거운 시간인, 기술 선택의 시간이 다가온 건데요. 간단한 프로토타이핑을 위해서 뭘 쓰면 좋을까 고민하다가, 제가 가장 익숙하면서도 가장 궁금했던 ‘리액트 서버 컴포넌트open in new window’를 다뤄보고 싶어서 Next.js의 앱 라우터를 선택했습니다.

실제로 개발에 들어가기에 앞서, 유저 가이드에는 다음과 같은 특성 내지는 문제가 있습니다.

1. 먼저, 코드 블록 수용도 등 다양한 콘텐츠를 보여주어야 합니다

이로 인해 많은 라이브러리를 사용하게 되어 번들이 무거워질 수가 있을 거고요.

2. 다음으로는 GraphQL을 통해 JSON형태로 콘텐츠를 가져온다는 점입니다

일반적으로 블로그를 만드시는 분들은 mdx나 이런 포맷들을 사용하셨겠지만, 어떤 CMS는 별도의 형태로 API를 호출해 JSON형태로 데이터를 가져와야 하고, 그에 대해 렌더링을 해줘야 하는 형태를 가지고 있는데요. 이 GraphQL은 생각해보면 쿼리 복잡도의 한계가 있기 때문에 무겁거나 복잡한 데이터는 여러 번 쿼리를 한 뒤에 재조합해야 합니다. 이러한 조합에 흔히 BFF(Backend for Frontend)라고 부르는 것을 만들어 내려준다면 렌더링 로직 자체는 쉽게 짤 수 있겠지만, 사실 BFF를 만든다는 것 자체가 조금은 귀찮은 일이긴 합니다.

그러면 위 두 가지를 조금 더 쉽게 해결해 볼 수는 없을까요? 만약 유저 가이드에서 클릭, 애니메이션 등 클라이언트에서 상호작용이 이루어지는 컴포넌트와 로직을 분리하고 나머지는 서버에서 렌더링을 수행해 클라이언트로 결과만 전송한다면 어떨까요? 그렇다면 번들 크기도, 그리고 제가 해야 할 일도 이제 줄어들 것입니다. 특히 제가 할 일이 줄어든다는 게 가장 중요한 부분입니다. 이것을 리액트 서버 컴포넌트의 기본 개념으로 이해해 주시면 될 것 같습니다.

실제 저희 사례로 이야기를 해보자면, 기존에 VSCode처럼 코드를 하이라이팅 해주는 shiki.js라는 라이브러리가 있습니다. shiki.js를 기존에 사용하려면 언어 파서, 웹어셈블리 구동 로직 등 약 1~2메가 정도의 번들이 포함되어 번들 사이즈가 늘어나게 되는데요.

그림 5. 언어 파서, 스타일, 웹어셈블리 코드 등, 1MB 정도 번들 사이즈가 늘어남
그림 5. 언어 파서, 스타일, 웹어셈블리 코드 등, 1MB 정도 번들 사이즈가 늘어남

이를 다음과 같이 이제 서버에 위임하고 클라이언트에서는 결과만 받도록 변경하여 번들 사이즈를 크게 줄일 수 있었습니다.

그림 6. 서버에서 실행하고 결과만 클라이언트로 = 번들사이즈 감소
그림 6. 서버에서 실행하고 결과만 클라이언트로 = 번들사이즈 감소

또한 DB 쿼리나 API 호출을 컴포넌트 안에서 직접 할 수도 있습니다.

그림 7. DB 쿼리나 API 호출도 컴포넌트 안에서, 결과만 클라이언트에 전송
그림 7. DB 쿼리나 API 호출도 컴포넌트 안에서, 결과만 클라이언트에 전송

서버는 클라이언트로 렌더링 결과만 전송하기 때문에 데이터를 가져오기 위한 BFF를 만들지 않아도 되고 만약에 오래 걸리는 렌더링 작업이 있다면 해당 렌더링 작업을 서스펜스로 묶어 점진적 렌더링, 즉 스트리밍을 손쉽게 구현할 수 있었습니다.

그림 8. 작업이 더 오래 걸린다면 컴포넌트를 Suspense로 묶어 점진적으로 렌더링 = Streaming
그림 8. 작업이 더 오래 걸린다면 컴포넌트를 Suspense로 묶어 점진적으로 렌더링 = Streaming

제가 이것의 프로토타이핑을 한 3일 만에 끝냈는데요. 생각보다 좀 쉽게 됐습니다. 그래서 ‘이거 원래 하던 대시 보드 만드는 일보다 너무 쉬운데 이거 금방 끝나겠다’ 이런 생각을 좀 했는데요, 실제로 그랬다면 이 글을 쓰지 않았을 겁니다. 결국 그 업보를 호되게 당했죠.


개발 과정에서 만난 문제들

1. 접힌 아코디언은 검색할 수 없나요?

이제 제가 경험했던 문제들 중에서 상대적으로 간단하게 풀린, 하지만 좀 고민해 볼 요소가 있는 문제 두 가지를 꼽아서 이야기하려고 합니다.

첫 번째 문제는 의외로 아코디언을 만들면서 발생했습니다. 내용을 접었다 펼 수 있는 UI를 ‘아코디언’이라고 하는데요.

유저가이드 gif #1
유저가이드 gif #1

이 아코디언에 QA 항목이 한 줄 추가됩니다.

그림9
그림9

“Command+F로 특정 내용을 검색을 했을 때, 해당 내용이 아코디언 안에 들어있다면 자동으로 아코디언이 펼쳐졌으면 좋겠습니다.” 이런 내용이죠. 이 QA 항목이 올라오자마자 바로 한 줄을 또 추가를 하시더라고요.

그림10
그림10

경쟁사에선 된다는 거예요. 받은 내용이 제 예상을 초월을 했습니다. 심지어 이제 경쟁사에서 된다는 내용까지 있어서 저는 도망갈 수가 없었어요.

그림11. <kbd>Cmd</kbd>+<kbd>F</kbd> 이후의 모든 키보드 이벤트를 기록이라도 하나…?<br/>한글 같은 조합형 문자면 추측도 어려운데…?<br/>(출처: 몇 천 페이지의 유저 가이드를 새로 만들며 발표자료)
그림11. Cmd+F 이후의 모든 키보드 이벤트를 기록이라도 하나…?
한글 같은 조합형 문자면 추측도 어려운데…?
(출처: 몇 천 페이지의 유저 가이드를 새로 만들며 발표자료)

정말 충격적인 상황이었는데요.

그래도 뭐 이렇게 된 거 도망칠 수 없으니 즐겨야겠죠. 그래서 열심히 자료를 찾아보고 또 경쟁사 사이트도 좀 이것저것 뜯어보다가 HTML 스펙에 추가된 속성과 이벤트를 사용하면 이를 쉽게 구현할 수 있다는 것을 알게 되었습니다. 바로 hidden="until-found"속성과 beforematch 이벤트입니다.

그림12
그림12

hidden="until-found"속성은 정보 접근성 향상을 위해 추가되었으며, 검색에서 일치하는 항목이 해당 영역 내에 있을 때 beforematch 이벤트를 발생시킵니다. 그렇다면 이제 해당 이벤트를 저희가 이벤트 리스너로 받아서 커스텀 동작을 수행할 수도 있을 겁니다. 이 스펙 자체는 상대적으로 최근에 추가되었고, 기본 스타일이 적용되어 이 hidden="until-found"를 적용하면 랜더링 상태는 유지하며 요소는 숨기는 content-visibility: hidden이 적용됩니다. 쉽게 생각하시면 display: nonevisibility: hidden이 합쳐진 스타일이 기본적으로 적용된다고 이해해 주시면 될 것 같습니다.

그러면 이 속성 하나만 적용하면 다 될까요? 한번 바로 적용해보겠습니다. 바로 코드로 좀 넘어가 보면요. hidden="until-found"를 그 해당 영역에 적용하고, 아코디언을 펼치는 핸들러를 선언한 뒤에 beforematch 이벤트에 대한 이벤트 리스너를 등록합니다.

그림13
그림13

그러면 한번 해볼까요? 열심히 엔터를 눌러보았습니다.

그림14. 동작하지 않는 화면
그림14. 동작하지 않는 화면

그런데 동작하지가 않습니다. 내가 뭘 잘못했나, 뭔가 잘못 적용한 부분이 있나 해서 열심히 찾아보았는데, 크롬 인스펙터를 켜보니까 JSX에 넣은 히든 속성이 렌더링된 결과에서는 다르게 나타납니다.

그림15
그림15

분명 제가 잘못 작성한 것은 없는데 왜 이런 문제가 발생했을까요? 한번 이 문제를 렌더링 단계를 나눠서 생각해보면 좋을 것 같습니다.

그림16
그림16

렌더링 단계를 보면, JSX로 작성된 객체를 리액트에서 읽어서, 리액트 컴포넌트로 변환합니다. 그리고 상태의 업데이트, 이펙트같이 렌더링 대상을 연산한 뒤에 ReactDOM이 실제로 DOM에 반영합니다. 동시에 이제 DOM API를 연결해서 클릭 같은 인터랙션이 활성화되겠죠.

이게 정확한 설명은 아니지만 대략적인 렌더링 흐름을 보자면 이렇게 말씀드릴 수 있을 것 같습니다. 그러면 우리는 여기서 바로 ReactDOM에 주목할 필요가 있습니다. hidden 속성을 다른 값으로 바꾼 곳이 바로 이곳이기 때문입니다.

그래서 다음 코드는 ReactDOM의 코드 중 한 부분을 가져왔는데요. 여기서 하이라이팅한 부분을 보시면 케이스 절에 있는 속성들은 function이나 symbol이 아니면 모두 빈 문자로 변환됩니다.

그림17
그림17

이는 프로덕션에서 발생 가능한 보안 관련 문제나 충돌을 최소화하기 위해서 ReactDOM이 갖고 있는 속성 값 검증 로직인데요. 이 속성값 검증 로직이 우리가 주었던 히든 속성을 바꿨다고 예상을 해볼 수 있습니다.

리액트 팀도 이걸 이미 알고 있습니다. 이미 코어 컨트리뷰터에 의해서 1년 전에 이슈가 올라왔는데요. 1년째 업데이트도 없고 그동안 리액트가 서버 컴포넌트 준비한다고 내부적으로 코드가 엄청 많이 바뀌어서 이 PR이 사실상 무용지물이 됐습니다.

그리고 저희가 이런 비슷한 것들을 또 유저 가이드 만들면서 몇 개 작업을 해야 됐기 때문에, 업스트림을 뜯어서 패치 패키지 같은 걸로 코드를 수정하는 방향은 생각하지 않기로 했습니다.

정리를 하자면, 제가 주었던 hidden="until-found" 속성은 올바른 HTML의 스펙입니다. 하지만 크롬 12부터 적용 가능한, 상대적으로 최신 스펙이기 때문에 리액트에서는 반영되지 않았고, 리액트는 이를 잘못된 값으로 인식해서 빈 문자로 변환을 한 것입니다.

그래서 의도 자체는 알겠지만 결론만 놓고 보자면 코어 라이브러리가 접근성을 위한 기능을 제한했다고 볼 수가 있습니다. 그러면 이러한 상황에서는 어떻게 해야 될까요? 관점을 한번 바꿔봅시다.

그래서 아까의 렌더링 단계를 다시 보겠습니다.

그림18
그림18

만약에 저희가 ReactDom을 건드릴 수 없다면 렌더링에 관여하는 다른 요소를 속이면 됩니다. 저는 그중에서 브라우저, 정확히는 HTML 쪽을 사용해서 ReactDom을 속여보기로 합니다.

여러분들께 한 가지 질문을 좀 드려보겠습니다. 다음 코드는 동작할까요?

그림19
그림19

여러분들이 이제 보통 JSX에서 스타일을 줄 때에는 오브젝트로 넣어주셨을 겁니다. 그런데 지금 코드는 문자열로 스타일을 주고 있죠 이 코드는 과연 동작을 할까요? 정답은 ‘동작한다’입니다.

앞에 STYLE이 대문자인 것을 확인할 수 있을 텐데요. 사실 모든 HTML 속성은 기본적으로 대소문자를 구분하지 않습니다. 기본적으로 HTML이 만들어졌을 때부터 그랬고요. 그리고 ReactDom은 정의된 소문자 속성들, 그리고 대소문자 구분 없이 on으로 시작하는 속성들을 검증합니다.

이를 활용해서 JSX에 대문자로 속성을 입력하면 JSX는 그대로 표시할 것이고, ReactDom은 별도의 검증 로직을 거치지 않을 것이며, 그렇게 해서 이제 대문자 속성이 적용된 DOM은 올바르게 입력한 것과 동일하게 동작할 것입니다.

그럼 이제 우리는 아까의 beforematch 이벤트 리스너만 추가하면 정상적으로 동작이 되겠죠. 바로 한번 코드에 적용을 해보겠습니다. 아까의 코드로 돌아와서, 소문자 hidden을 대문자로 바꾸고, 실행해보겠습니다.

보시는 것처럼 잘 동작합니다.

유저가이드 gif#2 동작하는 화면
유저가이드 gif#2 동작하는 화면

그러면 아까 전에 저희가 크롬인스펙터(chrome inspector)를 켰을 때 렌더링된 게 조금 이상하게 나왔는데 제대로 이번에는 표시가 될까요? 제대로 표시됩니다.

그림20
그림20

이렇게 우회를 통해서 라이브러리가 지원하지 않는 최신 HTML 스펙을 사용할 수 있게 된 것입니다.

그렇다면 여기서 한 발짝 더 나아가, 만약에 애니메이션을 적용하고 싶다, 뭔가 접혔다 펼쳐졌다 하면서 높이가 자유롭게 변하는 애니메이션을 적용하고 싶다면, 어떻게 하면 좋을까요? 애니메이션은 기본적으로 DOM의 영역이고 애니메이션 실행 관련 제어를 위해서는 리액트 도움이 좀 필요할 것 같긴 합니다.

그러면 이 부분도 바로 코드로 생각해보면, 우선 hidden="until-found"에는 아까 말씀드렸던 기본 스타일인 content-visibility: hidden이 있습니다. 이 스타일이 적용되면 기본적으로 애니메이션 자체가 제대로 동작하지 않을 가능성이 있기 때문에 이걸 애니메이션 실행 중에는 제거해야 하고요. 그렇기 때문에 애니메이션 실행을 위한 상태를 추가하고, 아코디언이 펼쳐져 있거나 애니메이션이 실행 중일 때 hidden 속성을 비움으로써 애니메이션 실행에 문제가 없도록 만들면 됩니다.

그림21
그림21

참고로 저 애니메이트는 프레이머 모션에서 제공하는 함수이고요. 프레임워크를 딱히 타지 않기 때문에 여러분들이 만약에 vue나 다른 것들을 사용하신다면, 기본적인 메커니즘 자체는 비슷하기 때문에 거기서도 사용하실 수 있을 겁니다. 그럼 한번 또 보겠습니다.

유저가이드gif#3 동작하는 화면 두 번째
유저가이드gif#3 동작하는 화면 두 번째

애니메이션도 잘 동작을 하고요. 검색했을 때 내용도 잘 펼쳐집니다. 이제 좀 뿌듯합니다. 이제 경쟁사에서 되는 기능 우리도 된다고 말할 수 있게 됐네요.


몇 천 페이지의 유저 가이드를 새로 만들며 (2) | 요즘IT
저는 사용자에게 데이터를 정보로 전달하는 일에 관심이 많으며, AB180이라는 데이터 스타트업에서 프론트엔드 팀 엔지니어링 매니저를 맡고 있는 이찬희입니다. FEConf2023에서 ‘몇 천 페이지의 유저 가이드를 새로 만들며’라는 제목으로 제 경험을 소개했는데요, 발표 내용을 글로 옮기면서 분량이 길어져 둘로 나누어 소개하게 됐습니다. 앞서 1편에서는 유저 가이드가 무엇이고 왜, 어떻게 갈아엎었는지, 그리고 개발하면서 부딪쳤던 첫 번째 문제를 이야기했습니다. 이번 글에서는 개발하면서 부딪쳤던 두 번째 문제를 소개하도록 하겠습니다.

2. 정적 사이트 생성은 적절한 방법인가요?

두 번째 문제는 가이드를 이전하면서 발생했습니다. 여러분도 저도 새로운 프로젝트를 시작할 때 보통 보일러 플레이트를 많이 활용하실 겁니다. 저도 유저 가이드를 만들며 CMS 예시를 담은 보일러 플레이트(Boiler Plate)를 많이 참고했는데요. 보일러 플레이트 대부분이 정적 사이트 생성(Static Site Generation, SSG)을 사용하고 있었습니다. 아마 콘텐츠의 변경이 자주 있지 않고, 초기 로딩 속도나 SEO 관련 지표 향상을 위해서 SSG로 설정했을 겁니다. 저도 그 부분에 있어서 별로 깊게 생각하지는 않았습니다. 당연히 유저 가이드는 변경이 적으니 정적 사이트고, 만약 수정이 잦아진다면 Next.js에 있는 변경되는 페이지만 재생성하는 ISR(Incremental Static Regeneration)을 적용하면 된다고 생각했습니다.

그렇게 별문제가 없다고 생각했는데, 여기서도 제가 한 가지 간과한 게 있었습니다. 바로 사람은 한 번에 이사를 바로 가지 않는다는 점이었습니다.

2-1. 대부분 이렇게 이사가지 않습니다
2-1. 대부분 이렇게 이사가지 않습니다

이렇게 이사 가면 큰일 나죠. 이제 도로가 카트라이더가 되고 막 난장판이 됩니다.
저도 제 짐을 잃겠죠.

PW팀에서는 몇 천 개의 기존 가이드를 하나하나 검수하고 수정한 뒤에 하나씩 새로운 CMS로 이 가이드 콘텐츠를 이전했습니다. 그래서 그에 따라 다음과 같이 시간에 따라 이전되는 가이드의 양이 늘어났죠. 그리고 양이 늘어날수록 빌드 속도 역시 같이 늘어나서, 처음에 1분 정도 걸렸던 빌드가 5월 중순을 보시면 7 8분을 넘는 것을 확인하실 수 있을 겁니다.

2-2. 점점 느려지는 빌드 속도
2-2. 점점 느려지는 빌드 속도

이것 때문에 업데이트가 느린 것 같다는 PW 분들의 리포트 역시 많이 늘어나기 시작했고요. 이때부터 가이드의 배포와 반영까지 걸리는 시간을 줄일 수는 없을까 그런 고민을 해보기 시작했습니다.

우선 제가 앞서 보일러 플레이트를 활용했을 때 기본적으로 SSG를 하고 있었고, 기본적으로 이제 CMS에서 가이드 생성, 수정, 삭제가 일어나면 무조건 웹훅을 보내 새로 빌드를 돌리는 구조를 취하고 있었습니다.

아까 말씀드렸던 것처럼 ISR을 활성화하여 특정 페이지만 재생성하도록 변경하여 빌드 횟수 자체는 줄일 수 있었습니다. 하지만 여전히 빌드 속도는 느렸고 무엇보다 아직 해결하지 못한 문제가 있었습니다.

제가 저희 유저 가이드에서 콘텐츠 변경이 일어나는 곳을 한번 색으로 칠해보았는데요.

2-3
2-3

이 문제를 해결하지 못한 이유는 조금 어이없게도 노랗게 칠한 왼쪽에 사이드 바 때문입니다. ‘사이드 바가 무슨 잘못을 했길래 이게 영향을 주냐’ 생각하실 수도 있는데요. 만약 가이드가 삭제되거나 제목이 바뀌면 혹은 가이드 간의 순서가 바뀌면, 유저 가이드의 모든 페이지에 동일한 사이드 바를 노출하기 위해 업데이트가 필요해집니다.

그렇다면 모든 페이지에 사이드 바를 업데이트하려면 어떻게 해야 할까요? 우선 가이드 숫자 곱하기 지원 언어 수만큼 재생성 요청을 보낼 수 있을 겁니다. 저희 가이드는 대략 한 1천 개가 넘고 지원하는 언어로는 한국어 영어, 일본어, 중국어가 있습니다. 그럼 천 곱하기 4를 해 서버에 요청을 보내면 서버가 버틸 수 있을까요? 당연히 못 버팁니다. 서버가 과부하로 정상적으로 처리할 수 없습니다.

그렇기 때문에 저는 눈물을 흘리면서 느리지만 확실한 새로운 빌드 돌리기를 할 수밖에 없는 상황이었고요. 이게 굉장히 좀 마음이 그랬습니다. 이게 만약에 유저 가이드 이전 과정에서만 발생하는 문제라면 그냥 넘어갈 수도 있었을 것 같습니다. 하지만 저희가 기존 유저 가이드를 관리하던 젠데스크의 로그 데이터를 확인해 보니, 못 해도 하루에 3번은 수정이 이루어지고 4일마다 최소 2개의 가이드가 추가되었습니다.

물론 참을 수는 있겠지만 ‘조금 더 좋은 방법이 있지 않을까’ 이런 생각이 들어서 좀 깊게 고민을 하고 있었는데요. 그런 고민을 하다가 우연히 하나의 영상을 보게 됩니다.

Next.js App Routeropen in new window가 Pages Router보다 느리다는 것에 반박하는 영상인데요. 참고로 이 썸네일에 나오는 이 분은 테오 브라운(Theo Browne)이라는 분인데요. 트위치에서 시니어 프론트엔드 엔지니어로 일하다가 지금은 스타트업 CEO 겸 개발 관련 유튜버를 하는 분입니다. 영상의 내용 중에서 중복된 요청을 처리하는 부분이 있었습니다.

2-4
2-4

잘 보이실지 모르겠지만, 포켓몬 포켓몬 1, 포켓몬 2와 같이 중복된 요청이 있을 때 앱 라우터는 데이터 캐시를 이용하기에 성능적으로 더 빠르다는 내용을 이야기했습니다. 그러면서 실제로 oha라는 것으로 벤치마크를 돌려봤을 때, 아래 보시는 것처럼 페이지스 라우터 대비 앱 라우터가 3배 이상 빠르다고 주장했죠.

2-5
2-5

저는 이 부분을 보고, 내용이 옳고 그름을 떠나서 제가 생각하는 랜더링의 단위를 바꿔볼 필요가 있다고 생각했습니다. 저는 SSR, SSG, ISR 같이 페이지 단위를 중심으로 렌더링을 생각했습니다. 하지만 페이지의 구성 요소를 하나하나씩 뜯어보면 분명 동적인 요소도 있을 것이고 정적인 요소도 있을 것입니다.

2-6
2-6

동적인 것과 정적인 것의 기준을 나누는 것은 좀 다양하겠지만 만약 이를 네트워크 요청 단위 같은 걸로 바꿔보면 어떻게 될까요? 그렇다면 기존의 관점이 다음과 같이 바뀔 것입니다.

2-7
2-7

태그와 그루핑, 캐싱 그리고 스트리밍으로요. 실제로 최근 Next.js의 공식 문서에서는 SSG, SSR, ISR 같은 용어들보다는 스테팅 랜더링, 다이나믹 렌더링 같은 용어를 많이 사용하고 있었고요. ISR의 경우에는 네트워크 요청에 적용된 캐시, 리밸리데이트, 태그 옵션을 사용한 static 렌더링의 하나로 설명하는 것을 확인할 수 있었습니다.

그러면 한번 생각해보면 좋을 것 같은데요. 우리가 보통 개발하는 서비스는 정적인 페이지라도 동적인 요소가 있고 그 반대도 있습니다. 마치 스펙트럼처럼요.

2-8
2-8

그리고 유저 가이드는 CMS에 작성된 콘텐츠를 네트워크로 가져오는 만큼 네트워크 요청이 차지하는 비중이 생각보다 큽니다.

2-9
2-9

그렇다면 하나의 서비스에서 상대적으로 정적인 부분과 동적인 부분을 나누고, 정적인 부분이 데이터 캐시를 더 적극적으로 활용하도록 바꾼다면, 초기 빌드 시간은 줄어들면서 업데이트가 빠르게 반영되는 환경을 만들어볼 수 있지 않을까요?

2-10
2-10

그래서 한번 테스트해봤습니다. 눈썰미가 있으신 분들은 좀 눈치채셨겠지만, 제가 동적인 것은 노란색으로, 정적인 것은 보라색으로 표시하고 있었습니다.

2-11
2-11

기존의 정적 생성 로직을 모두 다 지웠습니다. 그리고 사이드 바에서 사용하는 요청은 한번 매번 새로 받아오게끔 데이터 캐시를 꺾었죠.

2-12
2-12
2-13
2-13

반대로, 콘텐츠를 불러오는 요청은 재생성 주기를 1개월로 설정해보았습니다. 그렇게 한번 돌려본 결과, 다음은 두 환경에서의 페이지 스피드 인사이트 결과입니다.

2-14
2-14

왼쪽이 기존 SSG를 했을 때, 그리고 오른쪽이 데이터 캐시를 적용한 다이내믹 렌더링 상태인데요. 보시다시피 큰 차이가 없고요. 심지어 데이터 캐시를 적용한 부분이 조금 더 성능상으로 높게 나오긴 하는데, 이 부분은 가이드 페이지마다 일부 편차는 있을 것 같습니다.

하지만 사용자 경험 관련 지표에 큰 차이는 없는 상황에서, 빌드 속도를 비교해보면 빌드 속도는 8분에서 1분 20초대로 줄어든 것을 볼 수 있었습니다.

만약 콘텐츠 업데이트가 필요하면 태그를 활용해 묶여 있는 요청들의 캐시를 초기화해주면 됩니다.

여기서 또 한 가지 질문을 해볼 수도 있습니다. ‘캐시가 동작을 하려면 캐시히트가 필요하지 않나요? 그러면 최소한 한 번은 페이지에 접속해야 되지 않나요?’ 좋은 질문입니다.

캐시히트를 위해서 한 번은 접속이 필요합니다. 하지만 저는 이를 위한 별도의 작업은 하지 않았는데요. 이 부분은 제품의 특성에 따라 다를 수 있지만 유저 가이드는 일종의 문서이며, 그렇기 때문에 사이트 맵이 존재합니다. sitemap.xml 이런 것들 아마 들어보셨을 거예요. 그리고 우리는 사이트 맵을 주면 모든 모든 페이지에 1회 이상 접속하는 친구를 알고 있습니다. 바로 ‘웹 크롤러’입니다.

이건 언라이트 하우스open in new window라고, 사이트 맵을 읽어서 모든 페이지에 라이트하우스를 측정해주는 도구가 있는데 제가 그걸 사용해보면서 아이디어를 얻었어요. 어차피 웹 크롤러가 모든 페이지를 방문할 것이고 이 방문 시점에 데이터 캐시가 자동으로 활성화될 것 같다는 생각을 했죠. 그렇다면 저희가 해야 될 거는 별도의 크론잡(CronJob)을 만들지 않더라도 단순히 사이트 맵을 구글 서치 콘솔에 등록하고 웹 바이탈을 확인하며 모니터링하는 정도로만 해도 괜찮을 것입니다.

이렇게 해서 제가 만났던 문제들 중에 기억에 남은 문제 두 가지를 꼽아서 이야기를 드렸습니다. 사실 이외에도 유저 가이드 개발 과정에서 많은 이야기가 있었는데요. 하지만 오늘은 무엇보다도 앞서 말씀드렸던 두 가지 문제를 여러분들께 좀 전해드리고 싶었습니다. 오늘 못다 한 이야기는 언젠가 다른 형태로 또 이야기를 드릴 수 있을 것 같고 그랬으면 좋겠습니다.


마치며

오늘 제가 말씀드린 문제의 해결 방법은 냉정하게 돌아보면 굉장히 단순하고, 심지어는 흑마법 같은 부분들이 많았습니다. 아코디언에서 hidden="until-found" 속성을 쓸 수 없던 문제는 속성을 대문자로 주어서, 가이드의 양 때문에 정적 페이지 생성이 느려졌던 문제는 ‘SSG 옵션을 끄고 매뉴얼하게 캐시를 제어한다’가 끝이었으니까요.

하지만 저는 각 문제들이 함의하는 바가 생각보다 크다고 생각합니다. 우리는 아코디언 문제를 통해 프레임워크나 라이브러리가 기능의 개발 혹은 접근성의 구현을 제한할 수 있음을 확인했습니다. 사실 이러한 문제는 리액트만의 문제는 아닙니다. vue같이 속성값을 검증하는 프레임워크 라이브러리에서도 동일한 문제가 있으며, 반대로 앵귤러 스벨트, 솔리드, 퀵 같이 검증 로직이 없는 프레임워크들은 보안과 충돌 관련 고민을 개발 단계에서 좀 더 해야 될 수도 있습니다.

당장 생각나는 것은, 악의적인 라이브러리 개발자가 라이브러리에 악의적인 속성을 주입하지는 않았는지를 확인하는 등의 방법이 있을 것 같습니다. 어쨌든 이러한 상황에서 개발자는 이것을 구현을 할 것인지, 한다면 어떻게, 어디까지 할 것인지를 정해야 합니다. 그리고 만약에 개발을 하는 방향으로 정했다면, 문제에 관여하는 주체와 동작, 영향을 나누어 판단함으로써 원하는 결과를 만들어내실 수 있을 겁니다.

아코디언 문제의 경우에는 렌더링 단계를 나누어 이 문제에 리액트 도움이 영향을 주는 것을 확인하였고, HTML 태그 및 속성이 대소문자를 가리지 않는다는 특성을 활용, 문제를 우회함으로써 원하는 결과를 얻어낼 수 있었습니다. 또한 정적 페이지 생성이 느려졌던 문제를 통해 우리는 단순한 문제라도 양, 즉 스케일이 커지며 엔지니어링의 복잡도가 늘어날 수 있음을 확인했습니다.

이렇게 스케일로 인해 복잡도가 늘어난 상황에서는 기존의 방법론은 더 이상 유효하지 않을 수도 있습니다. 그렇기에 개발자는 여전히 해당 방법이 유효한지 항상 판단하고 또 항상 검증해야 합니다.

이번 문제 상황에서는 렌더링의 관점을 페이지 단위에서 네트워크 요청 단위로 바꿔서 생각했습니다. 이를 통해 유저 가이드는 SSG가 아니라 스태틱 렌더링(Static Rendering)이 적합하다는 것을 확인하고 적용할 수 있었습니다.

여담이지만 이제 이런 페이지 단위를 다른 단위로 바라보는 움직임은 파셜 하이드레이션(Partial Hydration)이라는 개념으로 여러 프레임워크들에서 구현하고 있는 것들을 확인해보실 수 있을 겁니다. 아스트로나 솔리드 스타트나 이런 것들 말이죠. 거기에 있는 아일랜드 아키텍처 같은 것들을 확인하시면 좀 더 인사이트를 얻으실 수도 있을 것 같습니다.

이렇게 해서 유저 가이드를 만들며 겪은 이런저런 이야기를 여러분들께 전해드렸습니다. 마치면서 마지막으로 여러분들께 이 말씀을 드리고 싶습니다. 제가 항상 생각해오던 건데요.

2-15
2-15

저는 제품의 특성을 파악하고 문제에 영향을 주는 요인을 찾아 복잡도를 제어함으로써 문제를 해결하는 단순하고 간단한 해답에 가까워질 수 있다고 믿습니다. 그리고 그 과정 속에서 각자 엔지니어로서 더 넓은 관점을 갖고 다양한 경험을 해볼 수 있을 것입니다. 오늘 저의 이야기가 여러분의 기억 한편에 조금이라도 남아 있을 수 있다면 굉장히 기쁠 것 같습니다.


이찬희 (MarkiiimarK)
Never Stop Learning.

  1. FEConf2023에서 발표된 ‘몇 쳔 페이지의 유저 가이드를 새로 만들며’ / AB180 이찬희 프론트엔드 팀 엔지니어링 매니저 ↩︎