넘블 소개
home
리서치 목록

1팀 회고록 - 프론트엔드

주요 코드에 대한 설명 주요 로직과 그 이유 코드 내에서 고려한 특정 유저 행동과 그에 대한 대처 활용한 라이브러리와 그 이유 프로젝트를 진행할 때 어려웠던 점 / 고민했던 부분과 해결방법

기술스택

Typescript
NextJS
styled-jsx
Redux
Axios

말랑카우

주요로직/ 코드 설명

1.
ShortFormPlayer/index.tsx
Youtube shorts, 인스타그램 릴스와 비슷한 방식으로 동작하는 VideoList 실행 컴포넌트이다.
const ShortFormPlayer: React.FC<Props> = ({ query, preLoadedVideos, requestIndex = 4, isEditable = false, }) => { ... const [inViewIndex, setInVewIndex] = useState(0); const videoListRef = useRef<HTMLDivElement>(null); ... useEffect(() => { const observer = new IntersectionObserver( (entry) => { entry.forEach((entry) => { if (entry.isIntersecting) { const targetID = parseInt(entry.target.id.replace(/[^0-9.]/g, '')); setInVewIndex(targetID); } }); }, { rootMargin: '0px', threshold: [1.0] } ); if (videoListRef.current?.childNodes) { videoListRef.current?.childNodes.forEach((el) => observer.observe(el as Element)); } return () => { observer && observer.disconnect(); }; }, [videos, videoListRef]);
JavaScript
viewPort에 노출된 비디오 컴포넌트를 확인하기위해 intersection observer 를 사용했다. intersectionObserver 객체를 생성하고, Player 컴포넌트를 감싸는 div element를 참조하는 videoListRef 오브젝트를 통해 해당 videoListRef 의 childNodes를 사용해 모든 자식객체에 intersectionObserver를 붙였다. 그리고 observer의 callback 함수가 실행되면 해당 Player 컴포넌트의 id 값을 통해 현재 뷰에 있는 video의 index를 갱신한다.
<div className='VideoListWrapper' ref={videoListRef} onClick={onClickPlayer}> {videos.map((video, index) => { return ( <Player key={index} playerID={`player_${index}`} inViewPort={index === inViewIndex} isPlaying={index === playVideo} isEditable={isEditable} video={video} /> ); })}
JavaScript
ShortFormPlayer

2. EmbedPlayer

const extractIdFromURL = (url: string) => { var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/; var match = url.match(regExp); return match && match[7].length == 11 ? match[7] : false; }; const EmbedPlayer: React.FC<Props> = ({ video, blockTouch, isEditable, isPlaying, inViewPort }) => { const { videoTitle, embedLink } = video; const iframeVideoRef = useRef<HTMLIFrameElement | null>(null); const embedSrc = `https://www.youtube.com/embed/?playlist=${extractIdFromURL( embedLink || '' )}&rel=0&modestbranding=1&enablejsapi=1&controls=0&loop=1&showinfo=0&autohide=1`;
JavaScript
embed player의 embedLink string에서 youtube id를 추출하여 클라이언트에서 YoutubeEmbedPlayer를 요청할때의 옵션을 커스텀해 화면을 렌더링한다.
... const sendCommand = useCallback((func: string, args?: any) => { if (isRenderedIframePlayer(iframeVideoRef)) { iframeVideoRef.current?.contentWindow?.postMessage( JSON.stringify({ event: 'command', func: func, args: args || [], }), '*' ); } }, []); useEffect(() => { console.log(inViewPort); if (inViewPort) { sendCommand(isPlaying ? 'playVideo' : 'pauseVideo'); } else { sendCommand('stopVideo'); } }, [inViewPort, isPlaying, sendCommand]);
JavaScript
sendCommand 함수를 통해 useEffect 훅을 통해 화면상에 등장할때, 재생여부에 따라 embedPlayer를 동작하게 설정했다.

3. DefaultPlayer

useEffect(() => { if (directDir) { if (inViewPort) { handleVideo(isPlaying ? 'play' : 'pause'); } else { handleVideo('stop'); } } }, [directDir, isPlaying, inViewPort]); const handleVideo = (input: string) => { switch (input) { case 'play': playerRef.current?.play(); setPlaying(true); break; case 'pause': setPlaying(false); playerRef.current?.pause(); break; case 'stop': setPlaying(false); playerRef.current?.pause(); if (playerRef.current?.currentTime) { playerRef.current.currentTime = 0; } } };
JavaScript
Default Player 도 EmbedPlayer 와 마찬가지로 viewPort, 재생 여부에 따라 video 플레이어에 동작을 적용시킨다.
{directDir && ( <ReactHlsPlayer className='DefaultPlayer' playerRef={playerRef} muted={mute} width='100%' height='100%' loop src={config.videoSrcBaseURL + directDir} onLoadedData={onVideoLoadEnd} onClick={onClickVideo} /> )} <PlayStatusIcon playing={playing} loading={loading} />; <PlayerUI video={video} isEditable={isEditable} />
JavaScript
비디오 리소스는 hls(http live streaming) 프로토콜로 요청하기로 결정이나서 ReactHlsPlayer를 사용했다.
해당 비디오의 재생상태, 로딩상태에 따라 화면 정중앙에 PlayerStatusIcon으로 표시해주며
PlayerUi를 통해 해당 video의 정보와, ShortFormPlayer 의 isEditable 옵션에 따라 비디오 수정, 삭제에 접근할 수 있다.

코드 내에서 고려한 특정 유저 행동과 그에 대한 대처

유저 로그인페이지, 프로필페이지를 작업했다. 작업하면서 로그인된 상태임에도 불구하고 브라우저에 직접 주소를 입력하면 비로그인상태인데 profile 페이지에 접근할 수 있었다.
따라서 해당 유저의 로그인 여부에 따라 접근방지를 위해 클라이언트 내에서 redirect를 실행하는 커스텀 훅을 작성했다. my-video, like 페이지에서도 공통적으로 필요한 기능이라 커스텀 훅으로 작성해 재사용성을 높였다.
const useUserTypeRedirect = (href: string, redirectUserType: userType) => { const dispatch = useDispatch(); const { isLoggedIn } = useSelector((state: AppState) => state.user); useEffect(() => { const isUser = redirectUserType === 'user'; console.log(isLoggedIn, isUser); if (isUser === isLoggedIn) { Router.push(href); dispatch( uiActions.pushToast({ message: isUser ? '잘못된 접근입니다.' : '로그인이 필요한 서비스입니다.', }) ); } }, [dispatch, redirectUserType, href, isLoggedIn]); };
JavaScript
useUserTypeRedirect
const LoginPage: NextPage = () => { const redirectPath = '/oauth/redirect'; const isProd = process.env.NODE_ENV === 'production'; const redirectURI = `${config.hostURL}${redirectPath}`; const backendRequest = isProd ? `/oauth2/authorization/kakao?redirect_uri=${redirectURI}` : `${config.apiBaseURL}/oauth2/authorization/kakao?redirect_uri=${redirectURI}`; useUserTypeRedirect('/', 'user');
JavaScript
pages/login/index.tsx
예를 들어 로그인 페이지인경우 로그인한 user 상태일때 해당 커스텀훅을 실행시켜 index page로 redirect 하게 설정했다.

활용한 라이브러리와 그 이유

react-lottie
loading 애니메이션을 lottie 로 추출한 json 파일을 다시 화면에 다시 렌더링 하기위해 사용
axios
api request 및 global header 설정, response data 자동 변환을 통해 좀더 쉽게 데이터를 사용하기 위해

프로젝트를 진행할 때 어려웠던 점 / 고민했던 부분과 해결방법

디자이너와의 협업을 하며 figma를 사용했는데 내가 해당 서비스에 좀 익숙치 않아 서로 커뮤니케이션하는데 시간이 좀 걸렸던게 아쉬웠다.
코드의 재사용성을 높이는데 고민을 했었다. 일단 최대한 한번만 사용하는 함수는 해당 컴포넌트에 위치하게 했고, 다른 컴포넌트에서도 사용하는 함수나, 변수같은 경우는 따로 분류해 작성했었다.
로컬환경에서 api data 호출테스트를 하지못해 아쉬웠다. 테스트서버가 존재하지않아 backend의 업데이트 상황을 바로 적용해보고 피드백할 수 있는 상황이 나오지 않아 프론트와 백 사이에 커뮤니케이션 비용이 많이 들기도 했고 원활하지가 않았다. 초기에 미리 설정해서 작업했다면 4주라는 시간동안 좀 더 추가기능을 구현해서 제출하지 않았을까 싶다.
코드를 작성하면서 처음 작성할때 좀더 신중을 가해 작성하지 않아서, 기획적으로 변경이 되거나 디자인적으로 수정이 될때 코드를 수정할때 거의 처음부터 작성하는 경우가 있었다.
프론트입장에서 3개 직군이 한팀으로 작업하는게 처음이여서 그런가 뒤돌아 보면 아쉬운점이 많았다.
하지만, 결과물이 비록 완성도가 높지는 않아도 이번 챌린지를 통해 내가 FE 개발자로서 부족한점을 파악하고 다른 직군과의 협업을 경험할 수 있어 좋았다.

광선

주요 로직 / 코드 설명

1.
Layout 컴포넌트
하단 메뉴바, 상단 헤더 같은 UI는 어느 페이지에서나 보이는 요소이기 때문에
공통 컴포넌트로 만들어 전체 앱을 감싸도록 레이아웃을 설계했다.
// Layout.tsx ... return ( <div className='Layout'> <Head> <title>{title ? `${title} | Whatz` : 'Whatz'}</title> </Head> {headerTitle && ( <Header title={headerTitle} left={headerLeft} right={headerRight} height={HEADER_HEIGHT} showBack={showBack} onBackClick={onClickHeaderBack} /> )} <main className='main'>{children}</main> {hasTabBar && <TabBar height={TAB_HEIGHT} transparent={tabBarTransparent} />} ... </div> );
JavaScript
Layout 의 Props 값으로 document title 을 설정할 수 있고, 하단 메뉴바, 헤더 등의 visible 처리를 할 수 있다.
2.
VideoCard 컴포넌트
마이비디오, 좋아요 페이지에서 는 영상 썸네일들이 카드 형태의 리스트로 보여지는데
이를 표현하는 컴포넌트이다.
클릭을 할 경우 해당 영상의 비디오 id 를 리스트 맨 앞으로 소팅하여 영상 재생 컴포넌트로
전달하여 영상들을 보여준다.
// VideoCard.tsx ... function VideoCard({ video, onCardClick }: Props) { const onClick = () => { if (onCardClick) onCardClick(video.videoId); }; return ( <div className='VideoCard' onClick={onClick}> {video.videoId} <Image className='thumbnail' src={video.videoThumbnail || '/icon/common/empty_img.svg'} alt='empty_image' layout='fill' /> ... </div> ); }
JavaScript
클릭 시 onCardClick 함수를 호출하여 videoId를 넘겨준다.
// onCardClick 핸들러 함수 처리부분 const onCardClick = (id: number) => { const clone = videos.map((vd) => ({ ...vd })); const selectedIndex = clone.findIndex((vd) => id === vd.videoId); const extract = clone.splice(selectedIndex, 1); if (extract.length) { clone.unshift(extract[0]); } router.push( { pathname: `${router.pathname}/play`, query: { playList: JSON.stringify(clone), }, }, `${router.pathname}/play` ); };
JavaScript
클릭한 카드의 videoId를 리스트 맨 앞으로 보낸 후
/play 로 라우팅하여 영상을 재생해준다.
3.
Error 핸들링
NextJs는 앱에서 404, 500 과 같은 에러가 발생하면 _error.tsx 파일을 자동으로 호출한다.
이 _error.tsx 파일에서 발생한 에러코드를 분기처리 하여 각 에러마다 보여줄 화면으로
리다이렉트 하는 기능을 구현하였다.
getServerSideProps 의 첫번째 인수인 contetxt 는 res 객체를 가지고 있는데, res 객체는
statusCode 값을 가지고 있으므로 이를 이용해 분기처리한다.
// _error.tsx interface Props { errorMsg: string statusCode: number } function ErrorPage({errorMsg, statusCode}: Props){ // statusCode 에 따라 보여줄 페이지를 골라 렌더링한다. return <Error errorMsg={errorMsg}/> } export const getServerSideProps: GetServerSideProps = async (ctx)=>{ let errorMsg = ''; const {statusCode} = ctx.res; switch(statusCode){ case 404: errorMsg = '찾을 수 없는 페이지입니다.' break; case 500: errorMsg = '오류가 발생하였습니다.' break; } return { props: { errorMsg, statusCode, }, } } export default ErrorPage;
JavaScript

코드 내에서 고려한 특정 유저 행동과 그에 대한 대처

마이비디오 페이지에서 영상 업로드를 진행할 때
모든 입력을 마친 후 완료 버튼을 클릭할 때 사용자가 입력한 데이터들이 적합한지를 체크하는
로직을 구현하였다.
먼저 영상 업로드는 직접 영상 업로드, 임베드 영상 업로드 이렇게 두 가지가 있다.
직접은 말 그대로 사용자가 본인의 기기에 저장된 영상을 직접 첨부해 업로드 하는 것이고
임베드는 유튜브 영상 링크를 입력하는 방식이다.
1.
직접 영상 업로드
직접 영상 첨부인 경우에 그 파일의 재생 길이와 사이즈가 조건에 만족하는지를 체크하였다.
// 파일 체크 함수 const validateFile = async (file: File)=>{ let msg = ''; const SIZE = 100; // MB const TIME = 1; // 분 if(formatByte(file.size) >= SIZE){ msg = `${SIZE}MB 이상은 업로드할 수 없습니다.`; } const duration = await getDuration(file); if(duration > TIME){ msg = `영상은 ${TIME}분을 넘을 수 없습니다. ${TIME}분 이하의 영상만 올려주세요.`; } if(msg){ setConfirm({ show: true, msg, }); return false; } return true; } // 영상 재생길이 추출 함수 const getDuration = (file: File)=>{ return new Promise<number>((res, rej)=>{ const reader = new FileReader(); reader.onload = ()=> { const media = new Audio(reader.result as string); media.onloadedmetadata = ()=> res(media.duration / 60); }; reader.readAsDataURL(file); }); };
JavaScript
위 코드에서 getDuration 함수는 영상을 오디오 객체로 변환하여 재생 시간(분)을 반환해준다.
2.
임베드 영상 업로드
임베드 영상같은 경우에는 사용자가 입력한 영상 링크가 유효한 Youtube 링크인지를 체크해줬다.
Youtube 영상은 일반 영상과 Shorts 두 가지가 있는데 일반 영상 링크는 영상의 id를 추출하는
정규식을 사용했다. 그런데 shorts 링크는 이 정규식이 적용되지 않아서 링크에
youtube.com/shorts/’ 를 포함하고 있으면 유효한 링크인 걸로 판단하였다.
// 유튜브 링크 검증 const validateEmbedLink = (url: string)=>{ let p = /^(?:https?:\/\/)?(?:m\.|www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/; let isMatch = url.match(p) ? true : false; let isShorts = url.includes('youtube.com/shorts/'); return isMatch || isShorts; }
JavaScript
위의 두 체크 로직을 통과하지 못하면 아래 이미지와 같이
경고 메시지를 띄워서 사용자에게 잘못된 입력이라는 상황을 인지시켜준다.

활용한 라이브러리와 그 이유

리액트 애니메이션 처리를 위해 react-transition-group 이라는 라이브러리를 사용했다.
리액트에서는 조건부 렌더링을 자주 사용해서 어떤 상태값에 따라 element 렌더링을
처리하는데 이때 컴포넌트가 mount, unmout 시에 애니메이션을 주고 싶다면
단순 css 만으로는 구현하기가 어렵다.
setTimeout 같은 타이머 함수를 이용해 상태값 변경을 지연 시킬 수도 있겠지만
코드도 지저분해지고 상황에 따라 유연하게 대응하지 못할 수 있다.
react-transition-group 패키지에는 CSSTransition 이라는 컴포넌트가 있는데
이 컴포넌트로 애니메이션을 수행할 컴포넌트로 감싸주면 해당 컴포넌트가 mount, unmount 되는
시점을 알아서 props 에 지정한 시간만큼 지연 시켜준다.
그 지연된 시간동안 원하는 애니메이션을 (opacity, transform 등 css 효과) 적용해줄 수 있다.

프로젝트를 진행할 때 어려웠던 점 / 고민했던 부분과 해결방법

먼저 고민했던 부분은 어떻게 하면 코드를 좀 더 깔끔하게 작성할 수 있을지 였다.
코드를 깔끔하게 작성해야 하는 이유는 유지보수 측면에서 후에 내가 짠 코드를
그 사람이 이해하기 쉽게 하기 위해서다.
코드를 깔끔하게 짜는데는 여러 방식들이 있겠지만, 공통으로 사용되는 코드 조각을
유틸성 함수로 만들어 모아놓는게 제일 기본적인 방식일 것이다.
그래서 최대한 중복되거나 두 번 이상 사용되는 로직들은 공통 함수나 컴포넌트로
따로 빼서 만들도록 노력하였다.
아쉬웠던 점은 팀원들과 직접 만나서 작업을 하지 못한것이었다.
다들 사는 곳들이 멀기도 했고 각자 본업이 있다보니 거의 카톡이나 구글Meet 으로
작업을 진행했는데, 그러다 보니 개발 시 필요한 것들을 모두 공유하지 못하고 빼먹거나
바로바로 피드백을 할 수 없는 부분이 아쉬웠다.
또 이번 프로젝트에서 테스트를 로컬 환경에서 진행할 수 없었다.
그래서 테스트를 하려면 수정하고 배포한 후 배포환경에서 진행해야해서 그런 점들이 조금 아쉬웠다.
이런 문제들에 대해 처음에는 생각하지 못했고 이런 디테일한 부분들까지 모두 개발착수 전에
논의가 필요하다는 사실을 깨달았다.
TOP