Slide 1

Slide 1 text

몇천 페이지의 유저 가이드를 새로 만들며 AB180 이찬희 @hiddenest

Slide 2

Slide 2 text

유저 가이드에 관하여 유저 가이드가 무엇인가요? 왜, 어떻게 갈아엎었나요? 리액트 서버 컴포넌트에 배팅하기 개발 과정에서 만난 문제들 접혀진 아코디언은 검색할 수 없나요? 정적 사이트 생성은 적절한 방법인가요? 마무리 Contents

Slide 3

Slide 3 text

유저 가이드?

Slide 4

Slide 4 text

SDK 설치를 꼭 해야하나요? 컨버전 윈도우? 전 맥 쓰는데요 전환값? 컨버전? 연동은 어떻게 하나요? 마케팅 성과 분석이 왜 필요해요? SKAdNetwork? 포스트백?

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

기존에는 Zendesk라는 서비스로 운영 레이아웃, 테마를 Handlebars 형식으로 작성해 업로드 글을 쓰면 에디터에서 HTML 코드를 생성 Ghost, Jekyll, 커스텀 로직이 섞인 무언가... 왜 갈아엎었나요?

Slide 7

Slide 7 text

중복되는 내용과 화면이 상당히 많아짐 하지만, 젠데스크는 콘텐츠 모델, 컴포넌트의 개념이 없음 → HTML 코드 복사, 붙여넣기로 메꾸기 시작 → 반복되는 내용을 찾을 수도, 일괄 수정할 수도 없음 → 콘텐츠 + 스타일 + 스크립트의 강결합 왜 갈아엎었나요?

Slide 8

Slide 8 text

닮았지만 엄연히 다른 존재들

Slide 9

Slide 9 text

“Headless CMS로 전환합시다”

Slide 10

Slide 10 text

Headless CMS

Slide 11

Slide 11 text

APP ROUTER

Slide 12

Slide 12 text

리액트 서버 컴포넌트에 배팅하기 코드 블럭, 수형도 등 다양한 콘텐츠를 보여줘야 함 → 많은 라이브러리 사용으로 번들 사이즈 증가 GraphQL로 콘텐츠를 쿼리해 가져와야 함 → 쿼리 리밋을 고려해 여러 번 API 호출해 조합해야 함 → BFF를 만들어서 편하게 데이터를 받을 수 있게 해야하나?

Slide 13

Slide 13 text

리액트 서버 컴포넌트에 배팅하기 클릭, 애니메이션 등 클라이언트에서 상호작용이 이뤄지는 컴포넌트와 로직을 분리하고 나머지는 서버에서 렌더링 작업을 수행한 뒤 클라이언트에 결과만 전송하면?

Slide 14

Slide 14 text

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 (
); }; 언어 파서, 스타일, 웹어셈블리 코드 등 1MB 정도 번들 사이즈가 늘어남

Slide 15

Slide 15 text

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
; }; 서버에서 실행하고 결과만 클라이언트로 = 번들 사이즈 감소

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

const Guide = () => ( }> }> < / Suspense> ); 작업이 오래 걸린다면 컴포넌트를 Suspense로 묶어 점진적으로 렌더링 = Streaming

Slide 18

Slide 18 text

절대 해서는 안 될 그 말

Slide 19

Slide 19 text

개발 과정에서 만난 문제들

Slide 20

Slide 20 text

첫 번째 문제: 아코디언을 만들며

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

첫 번째 문제: 아코디언을 만들며 아코디언: Command + F로 특정 내용을 검색했을 때, 해당 내용이 아코디언 안에 들어있다면 자동으로 아코디언이 펼쳐졌으면 좋겠습니다. 참고차, 경쟁사 가이드도 올려둡니다.

Slide 23

Slide 23 text

Cmd+F 이후의 모든 키보드 이벤트를 기록이라도 하나...? 한글 같은 조합형 문자면 추측도 어려운데...?

Slide 24

Slide 24 text

접혀진 아코디언은 검색할 수 없나요? addEventListener("beforematch", .. . hidden="until-found"

Slide 25

Slide 25 text

접혀진 아코디언은 검색할 수 없나요? 검색에서 일치하는 항목이 영역 내에 있을 때 이벤트 발생 상대적으로 최근에 추가된 HTML 스펙 (Chrome 102부터 가능) content-visibility: hidden 스타일 기본 적용됨 → 요소는 숨겨져 있지만 렌더링 상태는 유지 → display: none + visibility: hidden

Slide 26

Slide 26 text

ref.current.addEventListener( 'beforematch', handleBeforeMatch, ); 해당 DOM에 이벤트 리스너 등록
... [hidden="until-found"] 적용 const handleBeforeMatch = () => { setIsOpen(true); }; Beforematch 이벤트가 발생하면 아코디언을 펼치는 핸들러 선언

Slide 27

Slide 27 text

... const handleBeforeMatch = () => { setIsOpen(true); }; ref.current.addEventListener( 'beforematch', handleBeforeMatch, );

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

프로덕션에서 발생 가능한 보안 관련 문제나 충돌을 최소화하기 위한 리액트의 속성값 검증 로직 Function, Symbol이 아니면 모두 빈 문자열로 변환

Slide 31

Slide 31 text

No content

Slide 32

Slide 32 text

렌더링 단계를 나눠 문제 탐색하기 [hidden="until-found"]는 올바른 HTML 속성 최신 스펙이기에 리액트에 반영되지 않아 잘못된 값으로 인식 결론적으로, 라이브러리가 접근성을 위한 기능을 제한한 것

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

Q. 이 코드는 동작할까요?

Slide 36

Slide 36 text

ReactDOM을 속여라 HTML is Case-Insensitive (대소문자 가리지 않음) ReactDOM은 정의된 소문자, /on[A - Z]/로 시작하는 속성만 검증 대문자로 속성을 주면 정상적으로 표현됨 DOM에 beforematch 이벤트 리스너 추가하면 끝

Slide 37

Slide 37 text

... const handleBeforeMatch = () => { setIsOpen(true); }; ref.current.addEventListener( 'beforematch', handleBeforeMatch, ); hidden

Slide 38

Slide 38 text

... const handleBeforeMatch = () => { setIsOpen(true); }; ref.current.addEventListener( 'beforematch', handleBeforeMatch, );

Slide 39

Slide 39 text

... const handleBeforeMatch = () => { setIsOpen(true); }; ref.current.addEventListener( 'beforematch', handleBeforeMatch, );

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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';
애니메이션 실행 중이거나 열려있을 때 hidden 속성을 비우도록 처리

Slide 42

Slide 42 text

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';

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

대부분 이렇게 이사가지 않습니다

Slide 45

Slide 45 text

두 번째 문제: 가이드를 이전하며 생긴 일 PW 팀에서 전수 검사, 수정 하나씩 새로운 CMS로 이사

Slide 46

Slide 46 text

80 3월 말 4월 중순 5월 초 5월 중순 200 450 1,000 점점 느려지는 빌드 속도 1m 47s (107s) 2m 25s (145s) 5m 16s (316s) 8m 37s (517s)

Slide 47

Slide 47 text

ISR을 활성화하면 될까? 기존에는 CMS에서 업데이트가 일어나면 웹훅을 보내 새로운 빌드를 실행함 ISR 활성화해 변경 대상만 재생성 → 빌드 횟수는 줄어듦 하지만, 문제를 100% 해결하지는 못함

Slide 48

Slide 48 text

No content

Slide 49

Slide 49 text

사이드바를 업데이트하는 방법 가이드 숫자 * 지원 언어 수만큼 재생성 요청 보내기 → 1,000개 * (한/영/일/중) 요청이 한 번에 서버로 전송 → 서버 과부하로 정상적으로 처리 못함 느리지만 확실한 새로운 빌드 돌리기 느확빌

Slide 50

Slide 50 text

Is Next.js App Router SLOW? Performance Deep Dive — Theo Browne

Slide 51

Slide 51 text

App Router는 중복되는 요청을 내장된 데이터 캐시에서 가져오기에 렌더링이 더욱 빠르다

Slide 52

Slide 52 text

Pages Router App Router

Slide 53

Slide 53 text

다른 관점으로 렌더링 바라보기 페이지 중심 · SSG 처음부터 정적 생성해서 제공할까? 페이지 중심 · SSR 요청 받으면 서버에서 렌더링해서 내려줄까? 페이지 중심 · ISR 정적 생성하되 업데이트되는 곳만 재생성할까?

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

다른 관점으로 렌더링 바라보기 STATIC RENDERING DYNAMIC RENDERING SSG 데이터를 항상 새로 받지 않아도 됨 데이터를 매 번 새로 받아와야 함 SSR 랜딩 페이지 가이드 콘텐츠 사이드바 마이페이지

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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', });

Slide 58

Slide 58 text

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', ], }, });

Slide 59

Slide 59 text

기존 Static Site Generation 데이터 캐시 적용 Static Rendering 8m 37s (517s) 1m 26s (86s)

Slide 60

Slide 60 text

추가로 궁금할 수 있는 내용 모든 페이지에 한 번은 접속해야 캐시가 돌지 않나요? → 유저 가이드는 사이트맵이 있는 서비스 → 웹 크롤러는 사이트맵에 정의된 모든 페이지에 1회 이상 방문 → 웹 크롤러에 의해 데이터 캐시가 활성화될 것이다 → Search Console 등록 후 Web Vitals만 관리...?

Slide 61

Slide 61 text

사실 그외에도... 밟았던 / 밟고 있는 수많은 Next.js 버그들 가이드를 출력해 사용하는 분들을 위한 프린트 모드 → window.matchMedia('@media print') vs beforeprint() Shared Component가 일으킬 수 있는 빌드 오버헤드 JSON + LD로 구조화된 검색 데이터 생성하는 방법

Slide 62

Slide 62 text

마무리

Slide 63

Slide 63 text

“너무 단순한 것 같아요” “흑마법 아닌가요?”

Slide 64

Slide 64 text

아코디언 문제를 통해 프레임워크 / 라이브러리가 기능, 접근성 구현을 제한할 수 있음 구현할 것인가? 한다면 어떻게, 어디까지 할 것인가? 문제에 관여하는 주체와 동작, 영향을 나누어 파악하기 → 렌더링 단계를 나누어 ReactDOM이 관여함을 확인 → HTML이 대소문자 안가리는 특성을 활용해 우회

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

제품의 특성을 파악하고 영향을 주는 요인을 찾아가며 보다 단순한 해답을 찾아가기

Slide 67

Slide 67 text

AB180에서 함께 성장해요 abit.ly/frontend

Slide 68

Slide 68 text

[email protected] github.com/hiddenest linkedin.com/in/hiddenest