사용자들이 만든 자신만의 영상 링크 기반(유튜브, Vimeo, Dailymotion, Twitch 등) 플레이 리스트를 공유하고, 구독하여 자신만의 타임 라인을 만들고 네트워킹 할 수 있는 SNS 서비스 구현
여러 협업 사이드 프로젝트를 진행하면서 사용자 경험을 개선하기 위해 어떤식으로 할 수 있을지에 대해서 많은 고민을 하였습니다. 그러면서 기능 구현과 우선시적으로 요구되는 요구사항을 해결하는 것에 급급하여 기본적인 사용자 경험에 대한 것들이 뒤로 밀리거나 구현하지 않는 경우가 많았습니다.
예를 들어 어느정도 시간이 소요되는 페이지 이동의 경우 프로젝트에서는 긴 시간이 소요되지 않고 바로바로 페이지 이동이 가능해지면서 페이지 이동이 시간 길어질 경우에 대한 ui를 구현하지 않았고 로그인 시도 시 성공 여부에 따른 message
출력 또한 바로 메인 페이지로 이동 처리 혹은 브라우저에서 기본으로 사용되는 alert
를 창을 이용 하였습니다. 이 내용들로 보았을 때 개발자가 사용자가 관점이 되어 생각해본다면 사용자에게 친절하지 못한 서비스가 될 것이라고 생각하게 되었습니다.
그래서 이번 토이 프로젝트는 기본적으로 사용자 경험을 좋은식으로 이끌어 갈 수 있을까를 고민하다 LoadingSpinner
, toast
, Skeleton UI
등을 직접 구현하여 적용하도록 할려고 합니다.
상태 관리 라이브러리의 경우 recoil
, zustand
, redux
이 세 가지 중에 기술 스택에 대한 고민을 하게 되었습니다. 각각 하나씩 어떤 장점이 있고 단점이 있으며 해당 기술 스택을 선택하게 된 배경을 말씀드리고 싶습니다.
recoil
Recoil은 React의 useState와 비슷한 API를 제공하여 러닝 커브가 낮고 사용이 직관적입니다. 이를 통해 상태 관리의 복잡도를 줄이고 React 개발자들에게 친숙한 경험을 제공합니다.
그러나 Recoil은 아직 정식 버전이 출시되지 않았고, 업데이트 주기가 느린 점이 아쉬운 부분으로 다가왔습니다.
또한, 저는 이미 여러 협업 프로젝트에서 Recoil을 사용한 경험이 있기 때문에 새로운 기술 스택을 탐색하고 싶어 이번 프로젝트에서는 선택하지 않았습니다.
redux
redux
가 flux
패턴의 가장 표본이 되는 기술 스택이라고 생각합니다.
하지만 많은 보일러플레이트 필요하고 비교적 규모가 있는 프로젝트에서 사용되기도 하며 이를 근거로 비교적 짧은 기간에 프로젝트를 완성하기 위해 선택하지 않았습니다.
zustand
Zustand는 작고 중간 규모의 프로젝트에 적합하며, 직관적인 API와 간결한 코드 구조 덕분에 상태 관리를 효율적으로 처리할 수 있습니다.
특히, React Context API와 달리 불필요한 리렌더링을 최소화하며, Redux DevTools와의 호환성을 통해 상태 디버깅도 가능합니다.
이러한 특징 덕분에 빠르게 프로젝트를 진행하면서도 간단하고 효율적인 상태 관리가 필요했던 이번 프로젝트에 잘 맞는 선택이라고 판단했습니다.
세 가지 라이브러리 모두 장단점이 있지만, 프로젝트의 규모, 기간, 효율적인 상태 관리라는 요구사항을 고려했을 때, Zustand가 가장 적합하다고 판단하여 선택하게 되었습니다.
e2e 테스트 도구 cypress
, playwright
중에 playwright
를 선택하게 된 이유는 아래와 같습니다.
cypress
e2e 테스트의 경우 브라우저를 통해 서비스와 API를 직접 테스트하기 때문에 많은 시간이 소비되는데 이를 해결하기 위해 병렬처리를 지원합니다. playwright
의 경우 병렬 처리를 지원하지만
cypress
는 무료 버전에서는 병렬처리가 지원되지 않고 유료 버전에서만 지원합니다.
실행 환경의 차이
테스트 환경에서의 차이도 존재합니다.
cypress
는 실제 브라우저 환경에서 테스트 진행되며 chorme,eade등에서도 테스트를 실행할 수 있습니다.
playwright
는 마이크로소프트에서 만들었다보니 vscode코드와의 연동이 뛰어납니다. 익스텐션을 통해 설치하면 바로 사용할 수 있습니다.
위 스크린샷과 같이 애플 사파리 브라우저 테스팅도 지원하고요. 어떤 언어로 된 프레임워크든 상관없이 E2E 테스트를 진행할 수 있습니다. vscode와 너무 잘 연동되어 있어서 편하게 사용할 수 있습니다.
Toast
의 경우 context
를 이용하여 구현하게 되었습니다. Toast
의 경우 모든 컴포넌트에서 호출할 수 있어야 하며, 컴포넌트 트리의 모든 하위 컴포넌트에서 전역적으로 Toast
를 호출하고 상태를 공유할 수 있어야 하므로 선택하게 되었습니다.
import {
type ToastMessageProps,
type ToastMessageContextType
} from '@/types/ToastMessage'
import React, {
createContext,
useContext,
useState,
useCallback,
useRef,
useEffect
} from 'react'
const ToastMessageContext = createContext<ToastMessageContextType | null>(null)
export const ToastMessageProvider = ({
children
}: {
children: React.ReactNode
}) => {
const [toastMessages, setToastMessages] = useState<ToastMessageProps[]>([])
const timeoutIds = useRef(new Set())
useEffect(() => {
return () => {
timeoutIds.current.forEach(id => clearTimeout(id as number))
}
}, [])
const removeToastMessage = useCallback((id: string) => {
setToastMessages(prev =>
prev.filter(toastMessage => toastMessage.id !== id)
)
}, [])
const showToastMessage = useCallback(
({
message,
type
}: {
message: string
type: ToastMessageProps['type']
}) => {
const id = Math.random().toString(36).substring(7)
if (
toastMessages.some(toastMessage => toastMessage.message === message)
) {
return
}
setToastMessages(prev => [...prev, { id, message, type }])
const timeoutId = setTimeout(() => {
removeToastMessage(id)
timeoutIds.current.delete(timeoutId)
}, 3000)
timeoutIds.current.add(timeoutId)
},
[toastMessages, removeToastMessage]
)
return (
<ToastMessageContext.Provider
value={{ toastMessages, showToastMessage, removeToastMessage }}>
{children}
</ToastMessageContext.Provider>
)
}
export const useToastMessageContext = () => {
const context = useContext(ToastMessageContext)
if (!context) {
throw new Error(
'useToastMessageContext must be used within ToastMessageProvider'
)
}
return context
}
timeoutIds
에서 ref
와 set
을 사용한 이유는 useRef
는 값을 기억하는 상자로 useState
와 달리 값이 변해도 리렌더링이 일어나지 않고 컴포넌트가 리렌더링되도 해당 값은 유지됩니다.
그리고 set
의 경우 javascript
의 배열과 비슷하지만 중복 허용하지 않고 여러 타이머를 관리하기 쉽고, add
와 delete
가 매우 빠르게 동작합니다.
import { useToastMessageContext } from '@/providers/ToastMessageProvider'
import * as S from './toastMessageContainer.module'
import ToastMessageItem from './ToastMessageItem'
const ToastMessageContainer = () => {
const { toastMessages } = useToastMessageContext()
return (
<S.ToastMessageContainer role="toastMessage">
{toastMessages.map(toastMessage => (
<ToastMessageItem
key={toastMessage.id}
{...toastMessage}
/>
))}
</S.ToastMessageContainer>
)
}
export default ToastMessageContainer
import styled from 'styled-components'
export const ToastMessageContainer = styled.div`
position: fixed;
bottom: 7.5rem;
left: 10rem;
z-index: 50;
display: flex;
flex-direction: column;
gap: 0.8rem;
width: 100%;
max-width: 15rem;
padding-left: 0.4rem;
padding-right: 0.4rem;
`
import { useToastMessageContext } from '@/providers/ToastMessageProvider'
import { type ToastMessageProps } from '@/types/ToastMessage'
import * as S from './toastMessage.module'
const ToastMessageItem = ({ id, message, type }: ToastMessageProps) => {
const { removeToastMessage } = useToastMessageContext()
return (
<S.ToastItemContainer
role="toastMessage"
type={type}>
<span>{message}</span>
<button onClick={() => removeToastMessage(id)} />
</S.ToastItemContainer>
)
}
export default ToastMessageItem
type
의 경우 'error' | 'success' | 'info'
3가지의 경우 중 type
의 맞는 toast message
의 배경색을 다르게 표시하여 보여줍니다.
import { ToastMessageType } from '@/types/ToastMessage'
import styled, { css, keyframes } from 'styled-components'
const slideIn = keyframes`
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
`
export const ToastItemContainer = styled.div<{ type: ToastMessageType }>`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--spacing-3);
box-shadow: var(--shadow-s);
border-radius: var(--radius-base);
animation: ${slideIn} 0.3s ease-in-out;
${props =>
props.type === 'error' &&
css`
background-color: #ff4d4f; /* bg-error-500 */
color: #fff;
`}
${props =>
props.type === 'success' &&
css`
background-color: #52c41a; /* bg-success-500 */
color: #fff;
`}
${props =>
props.type === 'info' &&
css`
background-color: #1890ff; /* bg-info-500 */
color: #fff;
`}
span {
font-size: 1rem;
font-weight: 500;
margin-right: 1rem;
}
button {
background-color: transparent;
border: none;
cursor: pointer;
&:hover {
background-color: #f5f5f5; /* hover:bg-black-200 */
}
}
`
해당 ts
파일에서 type props
을 받아 type
에 맞게 배경색을 보여주도록 하였습니다.
이번 프로젝트를 통해 테스트 코드 작성을 직접 경험해볼 수 있다는 점이 매우 기대됩니다. 그동안 테스트 코드에 관심이 많았지만, 여러 가지 이유로 실천하지 못했던 아쉬움이 있었습니다. 이번에는 e2e 테스트가 필수 요구 사항에 포함되어 있어, 이를 계기로 단순히 e2e 테스트에 그치지 않고, 단위 테스트, 종단 테스트 등 다양한 테스트 방식을 적용해보고자 합니다.
테스트를 진행하면서, 단순히 테스트 코드를 작성하는 데 그치지 않고, 사용자 경험(UX)을 어떻게 향상시킬 수 있을지 고민하는 과정이 중요하다고 생각합니다. 상황에 맞는 테스트 방식을 선택하고, 이를 통해 얻은 인사이트를 사용자 중심의 개선으로 연결시키는 것이 이번 프로젝트의 핵심 목표 중 하나입니다.