Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

Chanhee
October 21, 2023

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

FEConf 2023에서 에어브릿지의 유저 가이드를 새로 만들며 경험했던 문제들과 해결방법에 관한 이야기를 하였습니다.
발표 스크립트를 같이 첨부하오니, 장표와 함께 같이 읽어주시면 감사하겠습니다.

다시 한 번, 발표를 들어주셔서 정말로 감사합니다. 그리고 혹시 몰입을 통해 견고하게 성장하는 조직과 제품에 관심이 많으시다면, 부디 저희 에이비일팔공 프론트엔드 팀의 문을 두드려주셨으면 좋겠습니다. (커피챗도 환영!)

📮 [email protected]

Chanhee

October 21, 2023
Tweet

More Decks by Chanhee

Other Decks in Programming

Transcript

  1. 유저 가이드에 관하여 유저 가이드가 무엇인가요? 왜, 어떻게 갈아엎었나요? 리액트

    서버 컴포넌트에 배팅하기 개발 과정에서 만난 문제들 접혀진 아코디언은 검색할 수 없나요? 정적 사이트 생성은 적절한 방법인가요? 마무리 Contents
  2. SDK 설치를 꼭 해야하나요? 컨버전 윈도우? 전 맥 쓰는데요 전환값?

    컨버전? 연동은 어떻게 하나요? 마케팅 성과 분석이 왜 필요해요? SKAdNetwork? 포스트백?
  3. 기존에는 Zendesk라는 서비스로 운영 레이아웃, 테마를 Handlebars 형식으로 작성해 업로드

    글을 쓰면 에디터에서 HTML 코드를 생성 Ghost, Jekyll, 커스텀 로직이 섞인 무언가... 왜 갈아엎었나요?
  4. 중복되는 내용과 화면이 상당히 많아짐 하지만, 젠데스크는 콘텐츠 모델, 컴포넌트의

    개념이 없음 → HTML 코드 복사, 붙여넣기로 메꾸기 시작 → 반복되는 내용을 찾을 수도, 일괄 수정할 수도 없음 → 콘텐츠 + 스타일 + 스크립트의 강결합 왜 갈아엎었나요?
  5. 리액트 서버 컴포넌트에 배팅하기 코드 블럭, 수형도 등 다양한 콘텐츠를

    보여줘야 함 → 많은 라이브러리 사용으로 번들 사이즈 증가 GraphQL로 콘텐츠를 쿼리해 가져와야 함 → 쿼리 리밋을 고려해 여러 번 API 호출해 조합해야 함 → BFF를 만들어서 편하게 데이터를 받을 수 있게 해야하나?
  6. 리액트 서버 컴포넌트에 배팅하기 클릭, 애니메이션 등 클라이언트에서 상호작용이 이뤄지는

    컴포넌트와 로직을 분리하고 나머지는 서버에서 렌더링 작업을 수행한 뒤 클라이언트에 결과만 전송하면?
  7. import { useEffect, useState } from 'react'; import * as

    shiki from 'shiki'; const CodeBlock = ({ lang, code }) => { const [html, setHTML] = useState(''); useEffect(async () = > { const highlighter = await shiki.getHighlighter({ theme: 'github-dark', langs: [lang], }); const nextHtml = highlighter.codeToHtml(code, { lang }); setHtml(nextHtml); }, [lang, code]); return ( <div dangerouslySetInnerHTML= {{ __ html: html }} /> ); }; 언어 파서, 스타일, 웹어셈블리 코드 등 1MB 정도 번들 사이즈가 늘어남
  8. import * as shiki from 'shiki'; const CodeBlock = async

    ({ lang, code }) => { const highlighter = await shiki.getHighlighter({ theme: 'github-dark', langs: [lang], }); const html = highlighter.codeToHtml(code, { lang }); return <div dangerouslySetInnerHTML= {{ __ html: html }} /> ; }; 서버에서 실행하고 결과만 클라이언트로 = 번들 사이즈 감소
  9. const GuidePage = async ({ params }) => { const

    data = await sdk.fetchArticle( params.language, params.slug, ); if (!data) { return notFound(); } return ( <ContentArea> <Title>{data.title} </ Title> <RichContentRenderer content={data.content} /> </ ContentArea> ); }; DB 쿼리나 API 호출도 컴포넌트 안에서 결과만 클라이언트에 전송
  10. const Guide = () => ( <Layout> <Analytics /> <Suspense

    fallback={<SidebarSkeleton /> }> <Sidebar /> </ Suspense> <ContentArea> <Suspense fallback={<PageSkeleton /> }> <GuidePage /> < / Suspense> <FeedbackForm /> </ ContentArea> </ Layout> ); 작업이 오래 걸린다면 컴포넌트를 Suspense로 묶어 점진적으로 렌더링 = Streaming
  11. 첫 번째 문제: 아코디언을 만들며 아코디언: Command + F로 특정

    내용을 검색했을 때, 해당 내용이 아코디언 안에 들어있다면 자동으로 아코디언이 펼쳐졌으면 좋겠습니다.
  12. 첫 번째 문제: 아코디언을 만들며 아코디언: Command + F로 특정

    내용을 검색했을 때, 해당 내용이 아코디언 안에 들어있다면 자동으로 아코디언이 펼쳐졌으면 좋겠습니다. 참고차, 경쟁사 가이드도 올려둡니다.
  13. 접혀진 아코디언은 검색할 수 없나요? 검색에서 일치하는 항목이 영역 내에

    있을 때 이벤트 발생 상대적으로 최근에 추가된 HTML 스펙 (Chrome 102부터 가능) content-visibility: hidden 스타일 기본 적용됨 → 요소는 숨겨져 있지만 렌더링 상태는 유지 → display: none + visibility: hidden
  14. ref.current.addEventListener( 'beforematch', handleBeforeMatch, ); 해당 DOM에 이벤트 리스너 등록 <div

    ref={ref} hidden="until-found"> ... </ div> [hidden="until-found"] 적용 const handleBeforeMatch = () => { setIsOpen(true); }; Beforematch 이벤트가 발생하면 아코디언을 펼치는 핸들러 선언
  15. <div ref={ref} hidden="until-found"> ... </ div> const handleBeforeMatch = ()

    => { setIsOpen(true); }; ref.current.addEventListener( 'beforematch', handleBeforeMatch, );
  16. 렌더링 단계를 나눠 문제 탐색하기 JSX React ReactDOM DOM 1

    리액트 컴포넌트로 변환 2 렌더링 대상 연산 3 실제 DOM에 반영 4 인터랙션 활성화 CODE LEVEL BROWSER LEVEL
  17. 렌더링 단계를 나눠 문제 탐색하기 JSX React ReactDOM DOM 1

    리액트 컴포넌트로 변환 2 렌더링 대상 연산 3 실제 DOM에 반영 4 인터랙션 활성화 CODE LEVEL BROWSER LEVEL ReactDOM 3 실제 DOM에 반영
  18. 프로덕션에서 발생 가능한 보안 관련 문제나 충돌을 최소화하기 위한 리액트의

    속성값 검증 로직 Function, Symbol이 아니면 모두 빈 문자열로 변환
  19. 렌더링 단계를 나눠 문제 탐색하기 [hidden="until-found"]는 올바른 HTML 속성 최신

    스펙이기에 리액트에 반영되지 않아 잘못된 값으로 인식 결론적으로, 라이브러리가 접근성을 위한 기능을 제한한 것
  20. 렌더링 단계를 나눠 문제 탐색하기 JSX React ReactDOM DOM 1

    리액트 컴포넌트로 변환 2 렌더링 대상 연산 3 실제 DOM에 반영 4 인터랙션 활성화 CODE LEVEL BROWSER LEVEL
  21. 렌더링 단계를 나눠 문제 탐색하기 JSX React ReactDOM DOM 1

    리액트 컴포넌트로 변환 2 렌더링 대상 연산 3 실제 DOM에 반영 4 인터랙션 활성화 CODE LEVEL BROWSER LEVEL DOM
  22. ReactDOM을 속여라 HTML is Case-Insensitive (대소문자 가리지 않음) ReactDOM은 정의된

    소문자, /on[A - Z]/로 시작하는 속성만 검증 대문자로 속성을 주면 정상적으로 표현됨 DOM에 beforematch 이벤트 리스너 추가하면 끝
  23. <div ref={ref} HIDDEN="until-found"> ... </ div> const handleBeforeMatch = ()

    => { setIsOpen(true); }; ref.current.addEventListener( 'beforematch', handleBeforeMatch, ); hidden
  24. <div ref={ref} HIDDEN="until-found"> ... </ div> const handleBeforeMatch = ()

    => { setIsOpen(true); }; ref.current.addEventListener( 'beforematch', handleBeforeMatch, );
  25. <div ref={ref} HIDDEN="until-found"> ... </ div> const handleBeforeMatch = ()

    => { setIsOpen(true); }; ref.current.addEventListener( 'beforematch', handleBeforeMatch, );
  26. 렌더링 단계를 나눠 문제 탐색하기 JSX React ReactDOM DOM 1

    리액트 컴포넌트로 변환 2 렌더링 대상 연산 3 실제 DOM에 반영 4 인터랙션 활성화 CODE LEVEL BROWSER LEVEL React 4 인터랙션 활성화 3 실제 DOM에 반영 DOM React 3 실제 DOM에 반영 4 인터랙션 활성화
  27. const [isAnimating, setIsAnimating] = useState(false); content-visibility: hidden 스타일을 애니메이션 실행

    중에는 제거해야함 setIsAnimating(true); animate( ref.current, nextIsOpened ? variants.show : variants.hide, { onComplete() { setIsAnimating(false); }, }, ); 애니메이션 실행 전/후로 상태 변경 const hiddenProp = isAnimating | | isOpen ? undefined : 'until-found'; <div ref={ref} HIDDEN={hiddenProp} /> 애니메이션 실행 중이거나 열려있을 때 hidden 속성을 비우도록 처리
  28. const [isAnimating, setIsAnimating] = useState(false); setIsAnimating(true); animate( ref.current, nextIsOpened ?

    variants.show : variants.hide, { onComplete() { setIsAnimating(false); }, }, ); const hiddenProp = isAnimating | | isOpen ? undefined : 'until-found'; <div ref={ref} HIDDEN={hiddenProp} />
  29. 두 번째 문제: 가이드를 이전하며 생긴 일 CMS 보일러플레이트들은 Static

    Site Generation 사용 → 콘텐츠 변경이 자주 있지 않음 → 초기 로딩 속도, SEO 관련 지표 향상 등이 주 목적 수정이 잦아지면 변경 페이지만 재생성하자 (Revalidate) → ISR (Incremental Static Regeneration)
  30. 두 번째 문제: 가이드를 이전하며 생긴 일 PW 팀에서 전수

    검사, 수정 하나씩 새로운 CMS로 이사
  31. 80 3월 말 4월 중순 5월 초 5월 중순 200

    450 1,000 점점 느려지는 빌드 속도 1m 47s (107s) 2m 25s (145s) 5m 16s (316s) 8m 37s (517s)
  32. ISR을 활성화하면 될까? 기존에는 CMS에서 업데이트가 일어나면 웹훅을 보내 새로운

    빌드를 실행함 ISR 활성화해 변경 대상만 재생성 → 빌드 횟수는 줄어듦 하지만, 문제를 100% 해결하지는 못함
  33. 사이드바를 업데이트하는 방법 가이드 숫자 * 지원 언어 수만큼 재생성

    요청 보내기 → 1,000개 * (한/영/일/중) 요청이 한 번에 서버로 전송 → 서버 과부하로 정상적으로 처리 못함 느리지만 확실한 새로운 빌드 돌리기 느확빌
  34. 다른 관점으로 렌더링 바라보기 페이지 중심 · SSG 처음부터 정적

    생성해서 제공할까? 페이지 중심 · SSR 요청 받으면 서버에서 렌더링해서 내려줄까? 페이지 중심 · ISR 정적 생성하되 업데이트되는 곳만 재생성할까?
  35. 다른 관점으로 렌더링 바라보기 네트워크 중심 · 태그/그룹핑 화면을 구성하는데

    필요한 요청들이 있나? 네트워크 중심 · 캐싱 모든 네트워크 요청을 항상 새로 받아와야할까? 네트워크 중심 · 스트리밍 요청이 오래 걸리니 점진적으로 보여주게 할까? 페이지 중심 · SSG 처음부터 정적 생성해서 제공할까? 페이지 중심 · SSR 요청 받으면 서버에서 렌더링해서 내려줄까? 페이지 중심 · ISR 정적 생성하되 업데이트되는 곳만 재생성할까?
  36. 다른 관점으로 렌더링 바라보기 STATIC RENDERING DYNAMIC RENDERING SSG 데이터를

    항상 새로 받지 않아도 됨 데이터를 매 번 새로 받아와야 함 SSR 랜딩 페이지 가이드 콘텐츠 사이드바 마이페이지
  37. tags = [ "orders", "orders:ko", "orders:user-guide" ] cache = "force-cache"

    tags = [ "guide", "guide:ko", `guide:${slug}`, ] revalidate = 2_592_000 / / 1 month
  38. tags = [ "orders", "orders:ko", "orders:user-guide" ] cache = "force-cache"

    tags = [ "guide", "guide:ko", `guide:${slug}`, ] revalidate = 2_592_000 / / 1 month // ࢚؀੸ਵ۽ ز੸ੋ ࢎ੉٘߄ח // நदܳ Ѧ૑ ঋҊ ݒ ߣ ࢜۽ ߉ইয়ѱ ೞӝ fetch('{SIDEBAR_ORDERS_QUERY_URL}', { method: 'POST', cache: 'no-store', });
  39. tags = [ "guide", "guide:ko", `guide:${slug}`, ] revalidate = 2_592_000

    / / 1 month // ࢚؀੸ਵ۽ ੿੸ੋ о੉٘ ղਊ਷ // நदܳ ӡѱ ࢸ੿ೞӝ fetch('{CMS_CONTENT_QUERY_URL}', { method: 'POST', next: { revalidate: 2_592_000, tags: [ 'guide', 'guide:ko', 'guide:attribution-scenario', ], }, });
  40. 추가로 궁금할 수 있는 내용 모든 페이지에 한 번은 접속해야

    캐시가 돌지 않나요? → 유저 가이드는 사이트맵이 있는 서비스 → 웹 크롤러는 사이트맵에 정의된 모든 페이지에 1회 이상 방문 → 웹 크롤러에 의해 데이터 캐시가 활성화될 것이다 → Search Console 등록 후 Web Vitals만 관리...?
  41. 사실 그외에도... 밟았던 / 밟고 있는 수많은 Next.js 버그들 가이드를

    출력해 사용하는 분들을 위한 프린트 모드 → window.matchMedia('@media print') vs beforeprint() Shared Component가 일으킬 수 있는 빌드 오버헤드 JSON + LD로 구조화된 검색 데이터 생성하는 방법
  42. 아코디언 문제를 통해 프레임워크 / 라이브러리가 기능, 접근성 구현을 제한할

    수 있음 구현할 것인가? 한다면 어떻게, 어디까지 할 것인가? 문제에 관여하는 주체와 동작, 영향을 나누어 파악하기 → 렌더링 단계를 나누어 ReactDOM이 관여함을 확인 → HTML이 대소문자 안가리는 특성을 활용해 우회
  43. 정적 페이지 생성이 느려진 문제를 통해 단순한 것도 스케일이 커지면

    복잡도 역시 늘어난다 스케일이 커졌을 때에도 기존의 방법론이 여전히 유효한가? 렌더링 관점을 페이지 단위 → 네트워크 요청 단위로 바라보기 → 우리는 SSG가 아니라 Static Rendering이 필요했음