[프로젝트 회고] 뮤빗라이브 2차 회고

최관수·2024년 10월 27일
0

회고

목록 보기
2/3

https://live.mubeat.tv/

주요 추가 및 변경 요소

  • 기본적으로 상수화나 최적화가 필요한 부분을 충분히 적용하지 못했고, 이전 회고에서도 언급했듯, 추가로 해야 할 작업들이 남아 있었다. 웹은 매 순간 변하기 마련이기 때문에 소프트웨어는 사실 유기물에 가깝고, 잔여 작업들을 미뤄두면 언젠가 기술 부채로 돌아오기 때문에 짬을 내서 처리할 필요가 있다. 더군다나 이런 작업들을 위해 대부분의 경우 별도의 시간을 빼기는 어렵다. 당장 잘 돌아가는 서비스의 부족한 점을 찾아 보완하는 일은 아름다운 일이지만, 회사는 이익 집단이고 당장 문제가 없는 부분을 위해 작업 시간을 내어주는 것보다 당장 이익을 실현할 곳에 작업을 할당하는 것은 당연한 일이다. 그렇기 때문에 큰 단위의 작업이 있을 때 시간을 쪼개서 작업하는 방법이 최선이라고 생각한다. 1차로 서비스를 오픈한 이후에 ‘스타비트’라는 서비스 내 재화의 웹 충전 기능을 작업하게 됐는데, 이때 백로그에 별도로 적어두었던 작업들을 녹여서 작업하였다.

웹 결제 기능 추가

  • 기존 컴포넌트 재사용성을 고려해 디자이너와 협의하면서 작업했기에 UI 작업은 비교적 빠르게 마무리되었다. 기능 구현도 기존 결제 기능에 API 수정 요소를 반영하고 별도의 테스트를 진행하는 방식이어서 예상보다는 빠르게 마무리할 수 있었다.

breakpoint 추가

  • 추가 페이지 작업을 진행하면서 기존에 사용하던 tablet(1024px), mobile(768px) breakpoint 외 그 사이에 추가적인 breakpoint가 필요해서 추가했다. 변수명을 고민하다가 phablet 이라는 phone과 tablet의 합성어를 확인했고, phablet(904px)를 추가하게 되었다.

Sentry 추가

  • 에러 로깅이 필요하다고 판단해서 Sentry 도입을 건의했다. 다만 서버 단위의 로그 분석은 별도의 툴을 사용하고 있었고, 아직 웹서비스의 사용량이 많지 않은 데다가 유용할지도 판단할 수 없어 당장은 무료 플랜을 적용했다. (추후 사내 프로덕트의 대부분에 적용하고 유료 플랜으로 전환)
  • 적용은 앱 내 최상위 컴포넌트에 Sentry.init에 관련된 객체를 선언하는 방식이라고 보면 된다. Sentry 공식 문서를 보면서 적용했고, 라우팅 버전이나 방식에 따라 조금 상이한 부분이 있어서 별도 수정 적용하였다.
    import * as Sentry from '@sentry/react';
    Sentry.init({
      dsn: process.env.REACT_APP_SENTRY_DSN,
      tracesSampleRate: 1.0,
    });
    const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
  • browserTracing을 설정해 두었다.
    Sentry.init({
      dsn: process.env.REACT_APP_SENTRY_DSN,
      integrations: [
        // BrowserTracing deprecated -> browserTracingIntegration settings
        // https://github.com/getsentry/sentry-javascript/blob/develop/MIGRATION.md#sentryreact
        // No react router
        Sentry.browserTracingIntegration(),
        // react router v6
        Sentry.reactRouterV6BrowserTracingIntegration({
          useEffect,
        }),
      ],
      release: '1.0.1', // package.json에 명시한 버전
      environment: process.env.REACT_APP_SETTINGS_MODE,
      tracesSampleRate: 1.0,
    });
  • 사용자 정보를 전달할 수 있도록 설정을 추가했다.
    // Sentry 유저 정보 전달
    Sentry.setUser({
      login: data?.login,
      membership: data?.membership,
      user_no: data?.user_no,
    });

웹 폰트 Dynamic Subset 변경

  • 영문의 경우 대소문자를 포함해 72개의 Glyph만 있으면 되지만, 한글의 경우 조합했을 때 ‘뫮’ 같은 실제 사용하지 않는 Glyph까지 포함하여 총 11,172개가 된다. 이런 불필요한 Glyph를 삭제하게 되면 웹 폰트의 경량화가 가능하기에 성능 최적화가 가능하고, 성능 최적화는 Core Web Vitals 지표에 영향을 주기 때문에 즉 SEO와 밀접하다고도 할 수 있다. 더 자세한 내용은 아래 링크를 참고하면 좋다.
  • 기존에는 변환한 woff와 woff2, 그리고 otf를 static하게 관리하여 사용하고 있었는데, 해당 폰트는 CDN으로 Dynamic Subset을 제공하고 있었기 때문에 변경 적용하였다.
    @font-face {
      font-family: 'pretendard';
      font-weight: 100;
      src:
        url('../assets/fonts/pretendard/Pretendard-Thin.woff2') format('woff2'),
        url('../assets/fonts/pretendard/Pretendard-Thin.woff') format('woff'),
        url('../assets/fonts/pretendard/Pretendard-Thin.otf') format('opentype');
    }
    // pretendard (dynamic subset - 미사용 glyph 제거)
    @import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-dynamic-subset.min.css');
  • 직접 Dynamic Subset을 만들고자 한다면 아래 포스팅을 참고해 보는 것도 좋을 것 같다.

CLS 관련 img tag의 width, height

  • 구글은 CLS 지표를 통해서 사용자가 예상치 못한 액션이나 이동을 얼마나 경험하고 있는지 측정하고 있다. 즉 예상치 못한 위치로 버튼 같은 요소가 덜컥거리며 움직이는 Layout Shift가 생기면 CLS 지표에 악영향을 주기 때문에 SEO에도 좋을 수 없다. 실제로 기획이나 디자인상에서도 이런 Layout Shift가 고려되지 않는 경우가 종종 있는데, 사용자에게도, 서비스 제공자에게도 좋지 않으니 대화와 협의를 통해 일부 기획이나 디자인을 수정하는 편이 아무래도 좋다.
  • imgattribute를 통해 widthheight를 지정하게 되면 CSS property를 무시하고 해당 값이 적용된다고 잘못 알고 있었던 부분도 있었고, 사실 그런 설정값이 CLS에 영향을 줄 거라는 생각 자체를 못하고 있었다. 대부분의 img tag를 체크하여 widthheight 값을 설정하였다. 파일에 따라 height 값이 많이 달라질 수 있는 상품 상세 영역 대신 실제로 Layout Shift가 많이 발생하거나 CLS에 직접적인 영향을 줄 메인 페이지 위주로 작업을 진행하였다. 좀 더 자세한 내용은 아래 링크를 참고해도 좋을 거 같다.

Route 개선

  • 원래 개선 목적은 createBrowserRouter의 적용이었으나, 기존 SentryRoutes로 감싼 부분 때문인지 적용에 이슈가 있었다. 좀 더 뜯어보고 적용할 수도 있지만 당장 중요한 부분도 아니고 다른 코어한 작업이 우선이라 별도 파일로 분류해서 관리하는 방향으로 우선 적용하였다.
    • 기존
      // App.jsx
      
      <div className='App'>
        <MLHeader />
        <SentryRoutes>
          <Route path='/' element={<Home />} />
          <Route path='/product-video-detail/:id' element={<ProductVideoDetail />} />
          <Route path='/product-digital-detail/:id' element={<ProductDigitalDetail />} />
          <Route path='/purchase-history' element={<PurchaseHistoryList />} />
          <Route path='/purchase-complete' element={<PurchaseComplete />} />
          <Route path='/purchase-fail/' element={<PurchaseFail />} />
          <Route path='/video-live-detail/:id' element={<VideoLiveDetail />} />
          <Route path='/video-clip-detail/:id' element={<VideoClipDetail />} />
          <Route path='/starbeat-purchase' element={<StarbeatPurchase />} />
          <Route path='/starbeat-history' element={<StarbeatHistoryList />} />
          {/* 404 오류 페이지 */}
          {/* 에러 발생 시 라우팅할 컴포넌트, errorType은 해당 컴포넌트 switch문 참고 */}
          <Route path='*' element={<Error errorType={'404'} />} />
          <Route path='/error' element={<Error errorType={'500'} />} />
        </SentryRoutes>
        <MLFooter />
      </div>
    • 변경
      // App.jsx
      
      <div className='App'>
        <MLHeader />
        <AppRoutes />
        <MLFooter />
      </div>
      // AppRoutes.js
      
      import React from 'react';
      import * as Sentry from '@sentry/react';
      import { Route, Routes } from 'react-router-dom';
      import Home from '../pages/Home';
      import ProductVideoDetail from '../pages/ProductVideoDetail';
      import ProductDigitalDetail from '../pages/ProductDigitalDetail';
      import PurchaseHistoryList from '../pages/PurchaseHistoryList';
      import PurchaseComplete from '../pages/PurchaseComplete';
      import PurchaseFail from '../pages/PurchaseFail';
      import VideoLiveDetail from '../pages/VideoLiveDetail';
      import VideoClipDetail from '../pages/VideoClipDetail';
      import StarbeatPurchase from '../pages/StarbeatPurchase';
      import StarbeatHistoryList from '../pages/StarbeatHistoryList';
      import Error from '../components/Common/Error';
      const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
      const AppRoutes = () => {
        return (
          <SentryRoutes>
            <Route path='/' element={<Home />} />
            <Route path='/product-video-detail/:id' element={<ProductVideoDetail />} />
            <Route path='/product-digital-detail/:id' element={<ProductDigitalDetail />} />
            <Route path='/purchase-history' element={<PurchaseHistoryList />} />
            <Route path='/purchase-complete' element={<PurchaseComplete />} />
            <Route path='/purchase-fail/' element={<PurchaseFail />} />
            <Route path='/video-live-detail/:id' element={<VideoLiveDetail />} />
            <Route path='/video-clip-detail/:id' element={<VideoClipDetail />} />
            <Route path='/starbeat-purchase' element={<StarbeatPurchase />} />
            <Route path='/starbeat-history' element={<StarbeatHistoryList />} />
        {/* 404 오류 페이지 */}
            {/* 에러 발생 시 라우팅할 컴포넌트, errorType은 해당 컴포넌트 switch문 참고 */}
            <Route path='*' element={<Error errorType={'404'} />} />
            <Route path='/error' element={<Error errorType={'500'} />} />
          </SentryRoutes>
        );
      };
      export default AppRoutes;

UX 개선

  • 정책상 모바일에서 비디어 플레이어 재생을 막고 있어서 해당 요소를 렌더링하지 않고 앱 다운로드 안내 모달을 띄우도록 작업되어 있었다. 기존에는 앱 다운로드 안내 모달 닫기 버튼을 눌렀을 때 비디어 플레이어 요소를 렌더링하지 않은 텅 빈 페이지에 머물게 되어 있었는데, 사실 사용자가 해당 페이지에 머물러야 될 이유가 없기에 닫기 버튼을 눌렀을 때 navigate(-1); 를 수정 적용하여 접근 이전 페이지로 돌아가도록 변경하였다.

React-helmet 적용

  • CSR 특성상 index.htmlmeta tag와 title tag에 static하게 고정된 값이 박혀 있기 때문에 페이지마다 동적으로 title을 적용하기 어려운 부분이 있었다. 특히 meta tag의 경우 SEO와 직접적인 영향이 있기 때문에 SSR의 Next.js를 택하게 되는 이유가 되기도 하는데, CSR의 근본적인 렌더링 방식 자체에서의 이슈라고 봐야 할 것 같다. React 19 RC에서는 컴포넌트별로 동적 meta tag를 설정할 수 있다고 하는데, 아직 적용해본 적은 없고 당장 RC인 React 19로 올릴 수는 없었다. 우선 웹 화면 단위에서 노출되는 것도 중요하기 때문에 React-helmet을 적용했다. index.js에서 <HelmetProvider />로 감싸고, 개별 상세 페이지에서 API로 내려 받는 상품의 타이틀값을 <Helmet />에 바인딩했다. 혹여나 값이 없는 경우엔 ‘Mubeat Live’ 하드코딩된 값을 뿌려주도록 설정하였다.
  • React-helmet 적용 자체는 어렵지 않았으나 open graph에는 <Helmet />의 값이 반영되지 않았다. 사실 open graph가 적용되는 과정을 생각해 보면 당연한 일이기도 한데, 웹 크롤러나 카카오톡 같은 앱 내 크롤러가 해당 HTML 문서를 파싱할 때에는 <Helmet />의 값이 적용되기 이전 시점이기 때문이었다. 적용을 하기 위한 몇 가지 라이브러리가 있었으나 CRA가 아닌 Vite 환경에서 가능한 부분이었기에 적용을 위해선 빌드도구 변경이 선행되어야 했다.

CRA → Vite

  • 해당 서비스는 다지역 및 다국어 서비스이기 때문에 동적으로 메타데이터를 변경해주는 것이 당장 크리티컬한 건 아니더라도 꽤 중요한 포인트였다. 앞서 말했듯 React-helmet 같은 라이브러리는 HTML이 렌더링된 후에 DOM에 접근해서 변경해주기 때문에 open graph 등 외부 크롤러가 수집을 못하는 경우가 있었다.
  • 앞서 React-helmet 적용하는 과정에서 몇 가지 테스트를 같이 진행하였다. 1차적으로 react-helmet-async로 작업하고 open graph 반영이 안 되어 prerender 방식을 취해보려 하였다. 다만 한 4~5년 전에는 해당 라이브러리가 유지보수 되었으나 Next.js 같은 SSR이 유행하면서 최근엔 유지보수 안 되고 React 18 버전에는 적용이 안 되는 이슈가 있었다. 추가로 찾아 보니 puppeteer 통해서 prerender하는 방식이 있는 거 같아서 적용해보려고 했으나, 기존 빌드 도구였던 CRA에서 Vite로 마이그레이션이 필요했다. 사실 prerender를 떠나서 시간이 나면 마이그레이션이 필요한 포인트이긴 한데, 번들러와 번들링 방식 등의 차이가 존재하기 때문이다.
    • CRA - Webpack, 전체를 다시 번들링하기 때문에 빌드가 다소 느림, Node.js 환경
    • Vite - esbuild, 변경된 부분만 번들링하기 때문에 핫 리로드가 빠르고 빌드 빠름, Go로 작성된 esbuild를 사용하지만 Node.js 환경

상품 수량 ‘-’ 버튼의 disabled

  • 상품의 수량을 조정하는 ‘-’ 버튼이 해당 수량이 ‘0’에 도달하면 cursor의 모양을 바꾸고 cursor: not-allowed; 로 바꾸고 스타일링을 별도 적용했다. 기존에도 기능적으로 버튼 자체는 disabled 되어 클릭이 되지 않았지만, 사용성이나 웹 접근성 측면에서 조금 더 디테일하게 적용할 필요성이 있었다.

웹뷰 추가

  • 앱 내에서 진행하는 이벤트의 결과 확인 페이지는 웹뷰로 들어가게 되었다. 페이지 자체는 간단했다. 적당한 애니메이션이 들어간 섹션 위주고 출석 체크는 앱에서 진행하기 때문에 추가되는 API GET해서 개인별 출석 체크 결과만 제대로 바인딩하면 되는 페이지였다.
  • 다만 웹뷰의 경우 기존 앱 내에 존재하는 헤더나 네비게이션바가 존재하기 때문에 웹서비스의 헤더나 푸터를 보여주지 않아야 했고, 그 숨김 처리는 기존 웹에서 사용하는 다른 컴포넌트에는 영향을 주지 않아야 했다. 이후에도 이런 식의 웹뷰가 추가될 가능성이 있기 때문에 별도의 webview 폴더를 만들고, Route path를 세팅할 때 webview로 시작하도록 설정하였다. 그리고 최상단 App.jsx 에서 webview로 시작하는 경로는 헤더와 푸터를 감추는 형식으로 처리하였다.
    // App.jsx
    
    const location = useLocation();
    // 웹뷰(헤더, 푸터 없음) path 체크 변수
    const isWebviewPath = location.pathname.startsWith('/webviews/');
    
    ...
    
    return (
      <div className='App'>
        <MLHeader />
        {/* 웹뷰가 아닌 경우에만 헤더 노출 */}
        {!isWebviewPath && <MLHeader />}
        <div className={'app-routes-wrapper'}>
          <AppRoutes />
        </div>
        <MLFooter />
        {/* 웹뷰가 아닌 경우에만 푸터 노출 */}
        {!isWebviewPath && <MLFooter />}
      </div>
    );
    // AppRoutes.js
    
    import AttendanceCheck from '../webviews/events/AttendanceCheck';
    
    ...
    
          {/* 웹뷰의 라우터는 /webviews/로 시작하는 구조를 따름, router path에 따라 헤더, 푸터 노출 여부 변경 */}
          <Route path='/webviews/events/attendance-check/' element={<AttendanceCheck />} />
          
    ...
  • 웹뷰로 열리는 별도의 페이지다 보니 토큰은 앱에서 웹뷰가 열리는 순간 전달 받는 형식으로 협의되었고, 다만 토큰은 탈취될 가능성이 있기 때문에 한번 암호화해서 전달 받으면 그 암호화된 토큰으로 API 호출을 하는 방식으로 진행되었다.

i18n의 <Trans />

  • 웹뷰를 작업하면서 기존 i18n 사용 시에는 JSON 에서 단순 text value만 처리했었는데, 디자인 구성상 텍스트 중간에 스타일 요소가 들어가 있어 tag까지 같이 처리할 필요가 있었다. 물론 스타일 요소가 들어간 text node 앞뒤로 쪼개서 JSON에 key, value로 선언할 순 있지만, 애초에 그 쪼개진 key, value가 전체 서비스에 적용되는 게 아니라는 점, 그리고 쪼개진 text node가 어디서든 쓰일 만한 범용성이 없다는 점이 문제라고 생각했다. 별도의 tag를 함께 적용할 수 있는 <Trans /> 컴포넌트 사용했다.
    <Trans
      i18nKey={'attendance_title_msg'}
      components={{
        1: <p className={'ml-webview-ac-intro-desc'} />,
        2: <h1 className={'ml-webview-ac-title'} />,
        1: <p className={'ml-webview-ac-intro-desc'} lang={language} />,
        2: <h1 className={'ml-webview-ac-title'} lang={language} />,
        3: <p className={'ml-webview-ac-desc'} />,
        4: <strong className={'ml-webview-ac-strong'} />,
      }}
    >
    {
    	"attendance_title_msg": "<1>To celebrate 10 Million Downloads</1><2>Mubeat<br /> Attendance Event</2><3>Mubeat's got your back with <4>cash back!</4></3>",
    }
  • 다만 혹시 라이브러리 내부적으로 dangerouslySetInnerHTML, 즉 innerHTML을 사용하는 것이 아닌가 싶었고, 만약 그렇다면 XSS injection에 굳이 취약점을 드러내는 셈이기 때문에 우려되는 포인트가 있었다. 리서치 결과 innerHTML을 직접적으로 사용하지 않는다는 내용을 체크하고 적용하였는데, 어디서 확인했는지 못 찾겠다; 대략 <Trans /> 컴포넌트가 내부적으로 innerHTML을 직접적으로 사용하지 않고, React의 JSX 구문을 이용해서 안전하게 렌더링한다는 내용이었다. innerHTML가 아닌 React의 createElement 함수를 사용해 DOM 요소로 변환한다는 내용이었다.

KEEP

  • 실제로 이슈가 발생해서 수정한 케이스도 있지만, 예상된 기술 부채들을 백로그에 정리해둠과 동시에 코드를 살펴보면서 추가 수정 요소들을 살펴보는 태도가 긍정적이었다. 코드의 품질과는 별개로 태도에 있어서 좋은 태도라는 생각이 들었고, 꼭 개발 업무를 떠나서 삶에서 성실한 태도를 가져가려고 노력하고 있다. 태도는 일면 크게 눈에 띄진 않지만 사소한 부분에서의 작은 태도가 쌓여 그 사람을 완성해 나가는 것이라고 생각한다.
  • 문제 해결 속도가 조금씩 나아지고 있다고 느낀다. 문제 해결 과정에는 여러 단계가 포함되는데, 예전에는 ‘해결 과정’보다 ‘도출 과정’에 더 많은 시간이 들었다. 당장 이슈가 발생하면 어떤 문제 때문인지 명확하게 파악하는 것 자체가 어려운 일이었는데, 이 부분에서의 과정이 조금 간소화된 느낌을 받는다.
  • 그런 면에서 좀 더 명확한 이슈 파악을 하기 위해 Sentry 도입은 유용했다. 외부 라이브러리의 사소한 에러는 콘솔에서만 뱉고 사용하는 데에는 문제가 없기 때문에 사용자 피드백을 통한 이슈 파악이 어려운데, 그런 에러까지도 몇 번의 이벤트가 발생했고 Session Replay를 통해서 어디서 발생했는지 더 쉽게 파악할 수 있었다.

PROBLEM

  • TanStack Query, TypeScript, Next.js에 대한 숙련도가 부족하다는 마음의 짐이 있다. 물론 뮤빗라이브에는 이 기술들이 적용되어 있지 않지만, 타 프로젝트에는 TypeScript, Next.js가 적용되어 있어 배워 나가는 감이 있긴 하다. 당연히 불필요한 도입으로 오버 스펙이 되어서는 안 되지만 최근의 핵심 기술 스택들이다 보니 좀 더 능숙하게 다루고 싶은 바람이 있다.

TRY

  • 원하는 기술 스택을 당장 회사 업무에 적용하기 어렵다면 사이드 프로젝트를 통해 적용하는 것이 해결책이 될 수 있다고 생각한다. 올여름 기획자, 디자이너, 서버 개발자와의 협업을 통한 간단한 사이드 프로젝트에서 Next.js, Zustand나 TanStack Query를 적용하려 했지만, Next.js는 확실한 오버 스펙이었고 Zustand는 컴포넌트 구성상 전역 상태 관리가 필요하지 않았다. TanStack Query는 API를 호출하는 경우에 따라 일부 적용할 수 있었으나 시간에 쫓겨 사용하지 못한 게 아쉬웠다. 사용해 보고자 하는 기술을 따로 정리해 두고 별도의 개인 프로젝트를 통해 적용해 보는 과정을 가져보는 게 좋을 것 같다. 머리와 눈으로 아는 것과 손으로 직접 구현해 보고 트러블을 마주하는 건 아예 다른 경험이라고 생각한다.

트러블슈팅

  • dialog dim 영역 클릭 시 닫히지 않게 조정, 사용성 개선
    • 일반 모달의 경우 dim 클릭 시 닫히게끔 세팅되어 있었고 alert이나 confirm dialog 역시 동일한 이벤트로 잡혀 있었다. 하지만 생각해 보면 confirm dialog의 경우 사용자의 확인이나 취소 의사를 받아야 하고, alert도 확인 버튼을 클릭했을 때 별도의 콜백 함수를 통해 특정 이벤트를 실행해야 하는 경우도 있다. 가만 생각해 보면 dim을 눌러서 해당 dialog를 꺼서는 안 될 일이었기 때문에 dim 영역 클릭 시에 닫히지 않도록 조정 적용하였다.
  • 플레이 중일 때만 다중 재생 제한 setInterval 실행
    • 플레이어 페이지에 진입했을 때 타기기(앱이나 다른 브라우저)에서 재생 중인지 체크하는 API가 있고 그걸 setInterval로 구현했는데, 다중 재생 제한이기 때문에 플레이어가 재생 중일 때만 체크하고 재생 전이나 플레이어가 멈췄을 때는 체크할 필요가 없다는 점을 간과했던 부분이 있었다. 여러 컴포넌트에 재생 여부를 체크해야 하기 때문에 전역 상태 관리를 위해 boolean 타입의 state를 하나 선언하고, 라이브러리 내장 함수를 통해 플레이어가 재생중인지를 체크하는 지점에 선언한 state의 값을 업데이트했다. 그 이후 3초에 한 번 체크하던 함수를 해당 변수가 true(재생중)로 들어올 때만 setInterval을 실행시켰고, 해당 state의 값이 변할 때 리렌더링을 하도록 dependency array에 해당 state를 추가헀다. 다만 사용자가 플레이어를 재생/멈춤할 때마다 비디오 컴포넌트가 리렌더링이 되어서는 안 되기 때문에 비디오 정보를 표기하는 컴포넌트에서 리렌더링과 해당 함수를 통해 다중 재생을 체크하였다. 이런 이유로 전역 상태 관리에 별도의 state를 추가한 거긴 한데, 다소 불필요한 추가 같다는 생각이 들긴 한다. 추후에 시간이 나면 다시 한번 살펴볼 예정이다.
        useEffect(() => {
        
        ...
      
          if (currentPlayerIsPlayingState) {
            checkPlayable();
            const interval = setInterval(checkPlayable, 3000);
      
            return () => clearInterval(interval);
          }
        }, [accessToken, playable, countryData, currentPlayerIsPlayingState]);
        const handlePlayerReady = (player) => {
          playerRef.current = player;
      
          player.on('playing', () => {
            setPlayerIsPlayingState(true);
          });
      
          player.on('pause', () => {
            setPlayerIsPlayingState(false);
          });
      
          player.on('ended', () => {
            setPlayerIsPlayingState(false);
          });
      
          player.on('dispose', () => {
            setPlayerIsPlayingState(false);
          });
      
          const { startFetching, stopFetching } = fetchControlSubtitle();
      
          const trackChangeHandler = () => {
            const activeTracks = player.textTracks();
            // let activeLang = 'en'; // 기본값 en
            for (let i = 0; i < activeTracks.length; i++) {
              const track = activeTracks[i];
      
              // trackMode가 disabled 일 때 자막 없음
              if (track.mode === 'disabled') {
                activeLangRef.current = '';
              }
      
              if (track.kind === 'subtitles' && track.mode === 'showing') {
                // 현재 활성화된 자막 트랙을 처리
                activeLangRef.current = track.language;
                break;
              }
            }
          };
      
          // player.on('waiting', () => {
          //   videojs.log('player waiting');
          // });
      
          player.on('playing', () => {
            startFetching(); // 재생 중일 때 자막 페칭 시작
          });
      
          player.on('pause', () => {
            stopFetching(); // 플레이어 중지 시 자막 페칭 중지
          });
      
          player.on('dispose', () => {
            stopFetching(); // 플레이어 dispose 자막 페칭 중지
          });
      
          player.on('texttrackchange', () => {
            trackChangeHandler();
          });
      
          // 사파리 대응 코드
          if (browser.name === 'Safari') {
            player.on('loadedmetadata', () => {
              player.textTracks().addEventListener('change', trackChangeHandler);
            });
          }
        };
  • Safari SVG blurry 이슈로 png 파일로 교체
    • Safari에서 적용한 svg 파일이 흐릿하게 나오는 이슈가 있었다. Safari에서의 고질적인 이슈였고 tag를 변경하는 등 해결책이 있긴 했지만, 근본적인 해결책인지는 애매해서 우선 png로 교체하였다.
  • divfigure 변경
    • 마크업을 하다 보면 img tag를 div 같은 tag로 감싸게 되는 경우가 종종 있는데 figure가 좀 더 시멘틱한 마크업이라는 걸 머리로는 알면서도 버릇이 무섭다. HTML은 문서이기 때문에 의미론적으로 작성하는 편이 아무래도 좋다.
  • -webkit-font-smoothing: antialiased; 제거
    • 화면 단위를 쭉 보다가 일부 페이지의 대비가 약한 요소에서 가독성이 심하게 떨어지는 걸 확인할 수 있었다. 분명히 시안과 동일한 색상과 굵기를 적용했는데도 유독 개발 화면에서 가독성이 떨어지는 이유를 찾지 못하고 있었다. 아마도 ‘web font contrast’ 같은 키워드를 구글링을 하던 도중 두 개의 링크를 발견했는데, 원인은 antialiased 속성이었다. 일반적으로 reset.cssbase.css 같은 CSS 초기화 파일에 함께 선언되어 있는 경우가 많은데, 이유는 구버전에서의 크롬과 사파리가 폰트를 표현함에 있어 다소 차이가 있어서 해당 속성으로 일종의 싱크를 맞춘 셈으로 알고 있다. 다만 antialiased 속성 자체가 폰트의 대비나 선명도에 영향을 주기 때문에 최신 브라우저 기준에서는 해당 속성을 제외하는 것이 낫다는 의견이었다. 역시나 해당 속성을 제외하니 대비가 선명하게 살아났다. 유용했던 두 링크를 아래 첨부한다.
  • input focus zoom-in 제거
    • input의 폰트가 크기가 16px보다 작으면 focus 상태일 때 자동으로 zoom-in이 된다. 다만 이게 디바이스 너비에 맞게 작업해 놓은 모바일 페이지에서 작동하게 되면 사용자가 불필요하게 zoom-in을 하게 되고 별도로 zoom-out을 해야 하는 경우도 있었다. 물론 meta tag 자체에서 zoom 기능 자체를 막는 방법도 있다. 다만 zoom-in을 하는 데에는 이유가 있을 것이고, 그 이유는 가독성이 떨어지는 텍스트를 크게 보기 위함으로 볼 수 있는데, zoom-in 자체를 막아버리면 특정 사용자가 확대해서 보려 해도 볼 수 있기 때문에 사용성이 떨어질 수밖에 없는 딜레마가 있다. 이미 디자인된 요소의 폰트 크기를 키우는 건 요소 간의 밸런스가 무너지기 때문에 적용을 못 하고 고민하고 있었는데, 오 역시 꼼수가 있었다. 모바일에서만 필요했기 때문에 별도의 부모 class에 물려서 사용했고, 특정 class에 꼼수 속성을 몰아넣고 해당 class를 사용하는 방법을 취했다.
      
      // input focus zoom 기능 제거를 위한 클래스
      .focus-no-zoom-input-parent-group {
        @include media($mobile) {
          overflow: hidden;
          margin-bottom: 0;
        }
      }
      // input focus zoom 기능 제거를 위한 클래스
      .focus-no-zoom-input {
        @include media($mobile) {
          width: 133.4% !important;
          font-size: 16px !important;
          height: 53.5px !important;
          padding: 0 21.5px !important;
          border-radius: 8px !important;
          transform: scale(0.75);
          transform-origin: left top;
        }
      }
  • text node가 없는 요소에 aria-label 추가
    • 웹접근성과 관련해 text-indent와 같은 속성을 사용해 IR(Image Replacement) 기법을 사용하기도 하지만 aria-label과 같이 스크린리더에 대응하는 방식도 적용해 보았다. 사실 웹 접근성 영역은 아이러니하게도 가장 공부해 보고 싶으면서도 우선순위에서 밀리는 작업이 되어버린다. 시간을 많이 할애할 수 없어서 text node 없는 요소에 aria-label을 추가해 두었다.
  • LCP와 연관된 이미지에 fetchpriority 추가
    • 사용자가 즉각적으로 인지해야 하거나 LCP와 직접적으로 연관된 영역의 이미지는 fetchpriority를 추가해 두었다. 일전에 단순히 정적인 페이지를 다룰 때는 background-image를 통해 에셋 적용을 하기도 했는데, background-image를 지양하게 된 이유는 여러 가지가 있지만, 해당 HTML 문서에 img tag가 없다면 이런 attribute 자체를 선언할 수 없기도 하다. fetchpriority는 브라우저에게 이미지 처리의 우선순위를 할당할 수 있기 때문에 LCP와도 직접적인 연관이 있다고 볼 수 있다. eslint에서 해당 속성이 에러로 출력되면 rules ignore에 키워드를 추가하면 된다.
      <img
        fetchPriority='high'
        src='/bg_main_carousel.png'
        alt={''}
        className={'main-carousel-bg mb-hide'}
        decoding={'async'}
      />
      // eslintrc.js
      
      rules: {
        'react/react-in-jsx-scope': 'off',
        'react/prop-types': 'off',
        // unknown property 추가
        'react/no-unknown-property': [
          'error',
          {
            ignore: ['fetchPriority'],
          },
        ],
      },
    • 관련 정보는 아래 링크들에 잘 정리되어 있다.
  • 좀 더 견고한 코드를 위한 early return
    • 일부 불필요하게 재호출되거나 해당 함수의 필수 인자가 필요한데 없는 경우 early return 처리를 해두었다. TypeScript가 적용되어 있다면 필수 인자 같은 경우 컴파일 과정에서 체크할 수 있었을 텐데 혼자 작업을 하다 보니 도입을 미루게 되기도 하고, 도입하려면 공수를 위한 시간 확보가 필요한데 이보다 우선순위가 앞선 업무들이 있다 보니 더욱 그러한 부분이 있다. 당장 TypeScript보다 recoil을 걷어내는 게 더 중요한 것만 봐도 그렇고.
  • jsdelivr SSL 만료 이슈로 hotfix, fastly로 변경하고 추후 정적 파일로 변경

이후에 한 것

원래를 7월에 정리해서 올리려던 글을 10월에서야 올리기 때문에 그 사이에 작업한 내용이 물론 있는데, 해당 내용은 3차 회고에서 다룰 예정이다.

  • 기존 OTP 방식 대신 이메일 및 소셜 로그인 적용
    • 애플, 페이스북, X, 구글 로그인 및 이메일, QR 로그인 적용
  • 디바이스 가로모드 대응
  • DOM node 관련 에러 - 구글 번역기 이슈 수정, 구글 번역 기능 비활성화
  • 추가 웹뷰 작업

이후에 해야 할 것

  • recoil에서 Jotai로 마이그레이션
  • TanStack Query 적용
  • refresh token, axios interceptor
  • Long polling → WebSocket
  • 비디오 플레이어 렌더링 최적화
  • Core Web Vitals 개선

레퍼런스

profile
평소엔 책과 영화와 음악을 좋아합니다. 보편적이고 보통사람들을 위한 서비스 개발을 꿈꾸고 있습니다.

0개의 댓글