일정 기간 동안 유저 후킹용 이벤트 배너를 노출시켜야 하는 작업이 생겼다. 이벤트 기간은 총 2단계로 구성되어 있으며 각각 기간별로 다른 배너 이미지와 배너를 눌렀을 때 도달해야 하는 이벤트 페이지의 링크가 달랐다. 하지만 각 페이지별 요구사항도 동일하지는 않았다.
기획서 예시
2차 이벤트 관련 프론트엔드 작업
이름 | 시간 | 이미지 | 링크 | 쿠폰 순서 |
---|---|---|---|---|
사전 등록 기간 | (2023-10-01 07:00:00 ~ 2023-10-05 06:59:59) | ~~.com.event-banner-01.png | ~~.com/event-01 | - |
본 행사 기간 | (2023-10-05 07:00:00 ~ 2023-10-11 23:59:59) | ~~.com.event-banner-02.png | ~~.com/event-02 | 2111 / 1131 / 1122 / 1613 / 1412 / 1312 / 2612 / 2311 |
요구사항
다운 받은 쿠폰 / 다운 가능한 쿠폰
모두 최상단 정렬.badge
노출.문제는 이러한 이벤트 관련 작업이 한두 번이 아니었다는 것이다. 해당 이벤트는 1, 2차로 나누어져 있어서 약 한 달 전에도 비슷한 작업을 한 이후, 현재 2차 이벤트를 진행하는 것이다. 그리고 반기마다 진행되는 이벤트라 상반기에도 1, 2차 이벤트를 이미 진행한 이후였다. 그 당시에는 운영 업무 외 다른 큰 프로젝트를 동시에 진행하지 않고, 팀원들이 각 페이지 별 이벤트 배너 추가 작업을 분담해서 진행했었기 때문에 이렇게 하는 게 비효율적이라고 생각하지 못했었던 것 같다.
최근에 모든 팀원들이 다른 프로젝트 작업을 하고 있어서, 나 혼자서 전체 페이지에 대한 작업을 진행해야 하는 상황이 생겼다. 이전 이벤트 관련 코드를 보니 각각의 페이지에서 노출 여부를 제어하는 로직을 다루고 있었다. 매번 이렇게 했다는 것이 정말 비효율적인 것 같다고 생각이 들었고, 한곳에서 관리할 수는 없을까 고민했다. 이번 포스팅에서는 직접 고민하고 개선했던 내용에 대해서 정리해 보고자 한다.
노출 여부를 나타내는 상태를 일단 분리하기로 했다. startDate
와 endDate
를 인자로 받아서, 현재 이벤트 기간 사이에 있는지를 반환
해주는 hook
을 만들었다. 해당 hook
이 반환하는 값으로 렌더링 여부를 결정해 주면 되겠다.
JS에서 Date
는 정말 악명 높기 때문에, 유명한 라이브러리인 dayjs를 통해 조금 더 편하게 구현했다.
import dayjs from 'dayjs';
export const usePromotionTimer = (startDate: string, endDate: string) => {
const today = dayjs();
const isBeforeStartDay = today.isBefore(startDate);
const isAfterEndDay = today.isAfter(endDate);
const isBetweenPromotion = !isBeforeStartDay && !isAfterEndDay;
return isBetweenPromotion;
};
export default usePromotionTimer;
이렇게 했을 때 해당 페이지에 접근했을 경우에는 원하는 대로 동작한다. 하지만 페이지에 머무르고 있는 동안 노출 로직이 바뀌면 (사전인증 -> 본 이벤트 기간, 혹은 이벤트 종료 등) 반영되지 않는다. 이유는 단순하다. 우리가 보는 웹페이지의 화면은 React
의 render
의 결과물인데, render
가 되지 않았기 때문이다.
즉, 강제로 React
의 re-rerender
를 일으켜 화면을 업데이트해야 한다. Class 기반의 Component
를 사용하고 있다면 forceUpdate
를 사용하면 되고, 나는 Function 기반의 Component
를 사용하고 있기 때문에 state
를 선언하고, setState
를 해줘서 re-render
를 해줘야 한다.
이 기능 또한 이곳에서만 사용하게 될 수도 있지만 렌더링을 강제로 유발한다
는 목적성과 관심사에 맞게 새로운 hook
으로 분리해 보자.
import { useState } from 'react';
export const useForceRender = () => {
const [renderTrigger, setRenderTrigger] = useState(false);
const forceRender = () => {
setRenderTrigger(prev => !prev);
};
return forceRender;
};
export default useForceRender;
이제 interval
을 통해서 시간을 비교하고 상태의 변화가 필요한 시점에 useForceRender
의 리턴 값인 forceRender
를 실행시켜주면 유저가 해당 페이지에 머무르고 있더라도 화면에 변화를 줄 수 있게 된다.
import dayjs from 'dayjs';
import { useEffect } from 'react';
import useForceRender from './useForceRender';
export const usePromotionTimer = (startDate: string, endDate: string) => {
const forceRender = useForceRender();
const today = dayjs();
const isBeforeStartDay = today.isBefore(startDate);
const isAfterEndDay = today.isAfter(endDate);
const isBetweenPromotion = !isBeforeStartDay && !isAfterEndDay;
useEffect(() => {
if (isAfterEndDay) return;
// 행사가 시작하지 않았다면 시작일까지의 시간을 세고, 행사가 진행 중이라면 행사 종료일까지의 시간을 센다.
let diffSec =
today.diff(
isBeforeStartDay ? startDate : isBetweenPromotion ? endDate : '',
'second',
) - 1;
const timer = setInterval(() => {
diffSec++;
if (diffSec === 0) {
forceRender();
clearInterval(timer);
}
}, 1000);
return () => clearInterval(timer);
}, []);
return isBetweenPromotion;
};
export default usePromotionTimer;
쿼리 파라미터로 테스트할 수 있는 환경을 만들어서 여러 사람들의 귀찮음을 해결해 보자.
기능 구현이 완료되고, 개발 서버에 배포한 후에 해당 프로젝트에 PM분에게 배포가 완료되었으니 확인해보시라고 말씀드렸다. 기존에도 항상 브라우저 시간을 변경해서 테스트를 하고 계시다는 얘기를 듣고나니 너무 비효율 적인 것 같아서 조금 더 편하게 할 수 있는 방법에 대해 고민했다.
문득 쿼리 파라미터로 테스트를 할 수 있으면 어떨까 ? 라는 생각이 들었고, testDate
라는 값이 있다면 해당 값을 참조하도록 hook
을 수정했다.
import dayjs from 'dayjs';
import { useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import useForceRender from './useForceRender';
export const usePromotionTimer = (startDate: string, endDate: string) => {
const searchParams = useSearchParams();
const testDate = searchParams?.get('testDate');
// 테스트 날짜가 있으면 테스트 날짜를 기준으로, 없으면 현재 날짜를 기준으로 한다.
const today = testDate ? dayjs(testDate as string) : dayjs();
const isBeforeStartDay = today.isBefore(startDate);
const isAfterEndDay = today.isAfter(endDate);
const isBetweenPromotion = !isBeforeStartDay && !isAfterEndDay;
useEffect(() => {
if (isAfterEndDay) return;
// 행사가 시작하지 않았다면 시작일까지의 시간을 세고, 행사가 진행 중이라면 행사 종료일까지의 시간을 센다.
let diffSec =
today.diff(
isBeforeStartDay ? startDate : isBetweenPromotion ? endDate : '',
'second',
) - 1;
const timer = setInterval(() => {
diffSec++;
if (diffSec === 0) {
useForceRender();
clearInterval(timer);
}
}, 1000);
return () => clearInterval(timer);
}, []);
return isBetweenPromotion;
};
export default usePromotionTimer;
~~.com/main?testDate=YYYYMMDDHHMMSS
형태로 쿼리 파라미터를 넣으면 해당 시간으로 테스트를 할 수 있게 수정해서, PM분들뿐만 아니라 팀원들도 컴퓨터 시간을 변경해야 하는 귀찮음을 조금은 덜어주었다.
기획서를 참고해 보면, 각 페이지별 요구사항이 조금씩 다르다. 어떤 페이지는 사전 인증 기간과 본 이벤트 기간의 구분이 되어있지 않고, 다른 페이지는 본 이벤트 기간에만 적용되어야 하는 경우도 있다. 기존에는 각 페이지마다 사전 인증 기간과 본 이벤트 기간의 시작, 종료일을 관리하다 보니 여러 곳에서 반복적으로 같은 로직이 들어있었다.
// PAGE A - 본 행사 기간만 적용
const EVENT_START_DATE = '2023-10-05 07:00:00';
const EVENT_END_DATE = '2023-10-11 23:59:59';
const isBetweenEvent = usePromotionTimer(
EVENT_START_DATE,
EVENT_END_DATE,
);
return {
...
{isBetweenEvent && <EventBanner imgUrl={eventBanner} promotionLink={eventLink} />}
}
// PAGE B - 전체 행사 기간 적용
const EVENT_PRE_START_DATE = '2023-10-01 07:00:00';
const EVENT_PRE_END_DATE = '2023-10-05 06:59:59';
const EVENT_MAIN_START_DATE = '2023-10-05 07:00:00';
const EVENT_MAIN_END_DATE = '2023-10-11 23:59:59';
const isBetweenPreEvent = usePromotionTimer(
EVENT_PRE_START_DATE,
EVENT_PRE_END_DATE,
);
const isBetweenMainEvent = usePromotionTimer(
EVENT_MAIN_START_DATE,
EVENT_MAIN_END_DATE,
);
const isBetweenEvent = isBetweenPreEvent || isBetweenMainEvent
return {
...
{isBetweenEvent && <EventBanner
imgUrl={isBetweenPreEvent ? preEventBanner : mainEventBanner}
promotionLink={isBetweenPreEvent ? preEventLink : mainEventLink}
/>}
}
또한 imgUrl
라는 이름으로 배너 이미지의 주소, 그리고 이동해야 하는 이벤트 페이지의 주소를 promotionLink
라는 이름으로 여러 곳에서 계속 선언해서 관리하고 있었다. 예시를 든 것이라서 실제로 EventBanner
라는 컴포넌트는 없고, 각 페이지마다 들어가는 스타일도 다르기 때문에 컴포넌트로 사용할 수 있었다면 제일 좋겠지만, 이벤트라는 관심사에 묶인 날짜와 데이터 관련된 로직만이라도 한곳에서 관리하고 편하게 리턴 값을 바인딩 해서 쓸 수 있게 만들고 싶었다.
custom hook
으로 정리해야 하는 내용들을 다시 체크해 보자!
import usePromotionTimer from './usePromotionTimer';
const EVENT_PRE_START_DATE = '2023-10-01 07:00:00';
const EVENT_PRE_END_DATE = '2023-10-05 06:59:59';
const EVENT_MAIN_START_DATE = '2023-10-05 07:00:00';
const EVENT_MAIN_END_DATE = '2023-10-11 23:59:59';
export const useEvent = () => {
// 사전등록
const isBetweenPreEvent = usePromotionTimer(
EVENT_PRE_START_DATE,
EVENT_PRE_END_DATE,
);
// 메인
const isBetweenMainEvent = usePromotionTimer(
EVENT_MAIN_START_DATE,
EVENT_MAIN_END_DATE,
);
// 전체 기간
const isBetweenEvent = isBetweenPreEvent || isBetweenMainEvent;
// 배너 이미지 주소
const eventBannerImg = isBetweenPreEvent
? '~~.com.event-banner-01.png'
: '~~.com.event-banner-02.png';
// 프로모션 페이지 주소
const eventPromotionLink = isBetweenPreEvent
? '~~.com/event-01'
: '~~.com/event-02';
// 각 페이지별로 필요한 것들 전부 return
return {
isBetweenPreEvent,
isBetweenMainEvent,
isBetweenEvent,
eventBannerImg,
eventPromotionLink,
};
};
export default useEvent;
실제 구현한 hook
과 이름은 다르지만, 같은 로직을 담고 있다.
만약 전체 기간에 걸쳐 적용되어야 하는 페이지에는 isBetweenEvent
값으로 렌더링 제어를, 본 이벤트 과정에만 적용되어야 하는 페이지에서는 isBetweenMainEvent
값으로 렌더링 제어를 하면 된다. 또한 이제는 해당 useEvent
내부에서 이벤트 시작일, 배너 이미지의 주소, 이벤트 페이지의 주소만 변경해 주면 다른 곳에서 일괄 적용된다.
그렇다면 해당 hook
을 통해 개선된 코드는 어떨까 ?
// PAGE A - 본 행사 기간만 적용
const { isBetweenMainEvent, eventBannerImg, eventPromotionLink } = useEvent();
return {
...
{isBetweenMainEvent && <EventBanner imgUrl={eventBannerImg} promotionLink={eventPromotionLink} />}
}
// PAGE B - 전체 행사 기간 적용
const { isBetweenEvent, eventBannerImg, eventPromotionLink } = useEvent();
return {
...
{isBetweenEvent && <EventBanner imgUrl={eventBannerImg} promotionLink={eventPromotionLink} />}
}
한눈에 보아도 코드 양이나 가독성 측면에서 엄청나게 개선되었다. 심지어 기존에는 시작일을 다루는 변수 명도 각 개발자가 다르게 사용해서, 이벤트가 끝나고 해당 코드를 삭제 혹은 주석 처리할 때, 날짜를 기입해서 찾았었는데 그런 번거로움이 사라졌다. 또한 명시적인 변수명을 통해서 해당 페이지에 이벤트와 관련된 것들이 어떤 기간에 노출되는지까지도 한눈에 읽고 이해할 수 있게 되었다.
무엇보다 내년에 다시 재개될 이벤트에 대응하기에도 기존에 각 페이지에서 관리하던 때 보다 월등하게 간편해졌다. 장기적인 유지 보수에 유리한 구조가 되었다.