$30 off During Our Annual Pro Sale. View Details »

[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. 몇천 페이지의
    유저 가이드를
    새로 만들며
    AB180 이찬희 @hiddenest

    View Slide

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

    View Slide

  3. 유저 가이드?

    View Slide

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

    View Slide

  5. View Slide

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

    View Slide

  7. 중복되는 내용과 화면이 상당히 많아짐
    하지만, 젠데스크는 콘텐츠 모델, 컴포넌트의 개념이 없음

    HTML 코드 복사, 붙여넣기로 메꾸기 시작
    → 반복되는 내용을 찾을 수도, 일괄 수정할 수도 없음
    → 콘텐츠 + 스타일 + 스크립트의 강결합
    왜 갈아엎었나요?

    View Slide

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

    View Slide

  9. “Headless CMS로 전환합시다”

    View Slide

  10. Headless CMS

    View Slide

  11. APP ROUTER

    View Slide

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

    BFF를 만들어서 편하게 데이터를 받을 수 있게 해야하나?

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  16. const GuidePage = async ({ params })
    =>
    {
    const data = await sdk.fetchArticle(
    params.language,
    params.slug,
    );
    if (!data) {
    return notFound();
    }
    return (

    {data.title}

    Title>
    />

    ContentArea>
    );
    };
    DB 쿼리나 API 호출도 컴포넌트 안에서
    결과만 클라이언트에 전송

    View Slide

  17. const Guide = ()
    =>
    (

    />
    />
    }>
    />

    Suspense>

    />
    }>
    />
    <
    /
    Suspense>
    />

    ContentArea>

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

    View Slide

  18. 절대 해서는 안 될 그 말

    View Slide

  19. 개발 과정에서 만난 문제들

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  26. ref.current.addEventListener(
    'beforematch',
    handleBeforeMatch,
    );
    해당 DOM에 이벤트 리스너 등록

    ...

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

    View Slide


  27. ...

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  31. View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide


  37. ...

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

    View Slide


  38. ...

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

    View Slide


  39. ...

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

    View Slide

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

    View Slide

  41. 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 속성을 비우도록 처리

    View Slide

  42. 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';
    />

    View Slide

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

    ISR (Incremental Static Regeneration)

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  48. View Slide

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

    View Slide

  50. Is Next.js App Router SLOW? Performance Deep Dive

    Theo Browne

    View Slide

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

    View Slide

  52. Pages Router App Router

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    Search Console 등록 후 Web Vitals만 관리...?

    View Slide

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

    View Slide

  62. 마무리

    View Slide

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

    View Slide

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

    HTML이 대소문자 안가리는 특성을 활용해 우회

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide