tablet
(1024px), mobile
(768px) breakpoint 외 그 사이에 추가적인 breakpoint가 필요해서 추가했다. 변수명을 고민하다가 phablet
이라는 phone과 tablet의 합성어를 확인했고, phablet
(904px)를 추가하게 되었다.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,
});
@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');
img
tag의 width
, height
img
의 attribute
를 통해 width
와 height
를 지정하게 되면 CSS property를 무시하고 해당 값이 적용된다고 잘못 알고 있었던 부분도 있었고, 사실 그런 설정값이 CLS에 영향을 줄 거라는 생각 자체를 못하고 있었다. 대부분의 img
tag를 체크하여 width
와 height
값을 설정하였다. 파일에 따라 height
값이 많이 달라질 수 있는 상품 상세 영역 대신 실제로 Layout Shift가 많이 발생하거나 CLS에 직접적인 영향을 줄 메인 페이지 위주로 작업을 진행하였다. 좀 더 자세한 내용은 아래 링크를 참고해도 좋을 거 같다.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;
navigate(-1);
를 수정 적용하여 접근 이전 페이지로 돌아가도록 변경하였다.index.html
의 meta
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’ 하드코딩된 값을 뿌려주도록 설정하였다.<Helmet />
의 값이 반영되지 않았다. 사실 open graph가 적용되는 과정을 생각해 보면 당연한 일이기도 한데, 웹 크롤러나 카카오톡 같은 앱 내 크롤러가 해당 HTML 문서를 파싱할 때에는 <Helmet />
의 값이 적용되기 이전 시점이기 때문이었다. 적용을 하기 위한 몇 가지 라이브러리가 있었으나 CRA가 아닌 Vite 환경에서 가능한 부분이었기에 적용을 위해선 빌드도구 변경이 선행되어야 했다.react-helmet-async
로 작업하고 open graph 반영이 안 되어 prerender 방식을 취해보려 하였다. 다만 한 4~5년 전에는 해당 라이브러리가 유지보수 되었으나 disabled
cursor: not-allowed;
로 바꾸고 스타일링을 별도 적용했다. 기존에도 기능적으로 버튼 자체는 disabled
되어 클릭이 되지 않았지만, 사용성이나 웹 접근성 측면에서 조금 더 디테일하게 적용할 필요성이 있었다.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 />} />
...
<Trans />
<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 요소로 변환한다는 내용이었다.setInterval
실행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);
});
}
};
div
→ figure
변경img
tag를 div
같은 tag로 감싸게 되는 경우가 종종 있는데 figure
가 좀 더 시멘틱한 마크업이라는 걸 머리로는 알면서도 버릇이 무섭다. HTML은 문서이기 때문에 의미론적으로 작성하는 편이 아무래도 좋다.-webkit-font-smoothing: antialiased;
제거antialiased
속성이었다. 일반적으로 reset.css
나 base.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;
}
}
aria-label
추가text-indent
와 같은 속성을 사용해 IR(Image Replacement) 기법을 사용하기도 하지만 aria-label
과 같이 스크린리더에 대응하는 방식도 적용해 보았다. 사실 웹 접근성 영역은 아이러니하게도 가장 공부해 보고 싶으면서도 우선순위에서 밀리는 작업이 되어버린다. 시간을 많이 할애할 수 없어서 text node 없는 요소에 aria-label
을 추가해 두었다.fetchpriority
추가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'],
},
],
},
원래를 7월에 정리해서 올리려던 글을 10월에서야 올리기 때문에 그 사이에 작업한 내용이 물론 있는데, 해당 내용은 3차 회고에서 다룰 예정이다.