코드 리뷰: 링크
- React.js
- Next.js는 라우팅 용도로만 사용했습니다.
- TailwindCSS
- shadcn/ui
- 스타일링에 한해서 shadcn/ui를 참고하였습니다.
최대한 라이브러리 의존도를 낮추고 컴포넌트 제작 실력을 향상시키기 위해 노력했습니다!
미션에 관계없이 공통으로 사용되는 컴포넌트는 /src/components
에, 각 미션에서 사용되는 컴포넌트는 ./components
에 구현하였습니다.
GitHub Repository:
https://github.com/wontory/resat-challenge
일차별 수행한 미션의 결과물은 /src/app
경로에서 확인할 수 있습니다.
- 2일차: 카운트다운 타이머 만들기
- 3일차: To-Do 리스트 만들기
- 4일차: 캘린더 만들기
- 5일차: 미니인턴 서비스 메인페이지 클론
- 6일차: 이미지 슬라이드 (캐러셀) 만들기 1
- 7일차: 이미지 슬라이드 (캐러셀) 만들기 2
- 8일차: 만들어진 슬라이드 5일차 미니인턴 클론 페이지에 붙이기
- 9일차: 반응형 네비게이션 & 메뉴바 만들기
- 10일차: 로그인 페이지 만들기
GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/countdown-timer
배포 링크: https://wontory-resat.vercel.app/countdown-timer
각 시, 분, 초의 입력은 input 태그의 maxLength 속성을 2로 주어 최대 2자리 입력 가능하도록 제한하였습니다.
function TimerInput({
placeholder,
...props
}: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
maxLength={2}
placeholder={placeholder}
className="max-w-20 text-center disabled:bg-inherit"
{...props}
/>
)
}
export { TimerInput }
시간과 타이머의 작동 여부를 상태로 관리하였고, 시간 상태의 경우에는 시, 분, 초를 초 단위로 계산 후 저장하는 로직을 추가하였습니다.
const [isRunning, setIsRunning] = useState(false)
const [time, setTime] = useState(0)
const handleTimeChange = (unit: string, value: number) => {
const hours =
unit === 'hours' ? value * 3600 : Math.floor(time / 3600) * 3600
const minutes =
unit === 'minutes' ? value * 60 : Math.floor((time % 3600) / 60) * 60
const seconds = unit === 'seconds' ? value : time % 60
setTime(hours + minutes + seconds)
}
...
<TimerInput
value={Math.floor(time / 3600)}
disabled={isRunning}
onChange={(e) => handleTimeChange('hours', parseInt(e.target.value))}
/>
useEffect
훅의 클린업 함수와 setInterval
내장함수를 활용해서 타이머의 카운트 다운을 구현했습니다.
하지만 다음과 같이 코드를 작성했을 때, 일시중지 후 재시작 등의 경우에 ms까지 정확히 계산되지 않는 문제가 있었습니다.
decrementTime
은useEffect
에서만 사용되는 함수인데,useEffect
밖으로 빼면서 불필요하게useCallback
을 사용하게 된 건 아닌가 하는 아쉬움이 남네요!
const decrementTime = useCallback(() => {
setTime((prev) => prev - 1)
}, [])
// 타이머의 남은 시간을 1씩 감소
useEffect(() => {
if (isRunning) {
const interval = setInterval(decrementTime, 1000)
return () => {
clearInterval(interval)
}
}
}, [isRunning, decrementTime])
// 숫자가 아닌 값이 입력되거나, 남은 시간이 0이 되면 타이머의 작동을 중지
useEffect(() => {
if (time <= 0 || isNaN(time)) {
setIsRunning(false)
setTime(0)
}
}, [time])
GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/to-do-list
배포 링크: https://wontory-resat.vercel.app/to-do-list
부모 컴포넌트에서 리스트, 필터, 정렬을 상태로 관리하였고, 입력부, 필터 선택부, 정렬 선택부, 전체 리스트 출력부를 자식 컴포넌트로 각각 개발하여 필요한 상태를 props
로 내려주었습니다.
입력부는 따로 입력값을 상태로 두었고, form
태그와 submit
버튼으로 리스트에 추가할 수 있도록 구현했습니다.
const [task, setTask] = useState<Task>({
id: 0,
title: '',
priority: 1,
isCompleted: false,
})
const handleSubmit = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault()
if (!task.title) {
alert('할 일을 작성해주세요.')
return
}
onAdd(task)
setTask({
id: task.id + 1,
title: '',
priority: 1,
isCompleted: false,
})
}
...
<Input
placeholder="3일차 미션 올리기"
className="rounded-r-none"
value={task.title}
onChange={(e) => {
setTask({ ...task, title: e.target.value })
}}
/>
<Select
className="appearance-none rounded-none border-l-0"
value={task.priority}
onChange={(e) => {
setTask({
...task,
riority: Number(e.target.value),
})
}}
>
...
</Select>
<Button
type="submit"
onClick={handleSubmit}
className="rounded-l-none bg-blue-600 text-white hover:bg-blue-600/80"
>
추가
</Button>
필터 선택부는 필터 버튼을 클릭하면 부모에서 내려받은 handler를 통해 필터 상태를 변경할 수 있도록 구현했습니다.
FILTERS: Filter[] = ['전체', '완료', '미완료']
...
FILTERS.map((filter) => (
<Button
key={`filter-${filter}`}
className={cn(
'w-full',
current === filter
? 'bg-black text-white hover:bg-black/80'
: 'border text-black hover:bg-black/10',
)}
onClick={() => onChange(filter)}
>
{filter}
</Button>
))
정렬 선택부도 필터 선택부와 비슷하게 구현하였습니다.
리스트 출력부에서는 부모로부터 리스트와 정렬, 필터 상태를 내려받아 가공 후 출력되도록 구현했습니다. 리스트에서 할 일에 대해서 바로 수정할 수 있도록 input
태그를 사용해 출력되도록 하였고, 수정될 때 리스트 상태도 함께 업데이트되도록 별도의 handler를 추가했습니다. (우선순위 변경은 깜빡하고 구현하지 않았네요 ㅎㅎ…)
const handleStatusUpdate = (
id: number,
e: React.ChangeEvent<HTMLInputElement>,
) => {
onChange(
list.map((i) =>
i.id === id ? { ...i, isCompleted: e.target.checked } : i,
),
)
}
const handleTaskUpdate = (
id: number,
e: React.ChangeEvent<HTMLInputElement>,
) => {
onChange(
list.map((i) => (i.id === id ? { ...i, title: e.target.value } : i)),
)
}
...
list
.toSorted((a, b) => {
if (sort === '우선순위 낮은 순') return a.priority - b.priority
else if (sort === '우선순위 높은 순') return b.priority - a.priority
return a.id - b.id
})
.map((item) => (
<div
key={`task-${item.id}`}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex w-full items-center gap-2">
<Checkbox
type="checkbox"
checked={item.isCompleted}
onChange={(e) => handleStatusUpdate(item.id, e)}
/>
<Input
value={item.title}
onChange={(e) => handleTaskUpdate(item.id, e)}
className="border-none shadow-none"
/>
<Badge
className={cn(
'ml-auto capitalize',
badgeVariants[item.priority],
)}
>
{badgeContents[item.priority]}
</Badge>
</div>
</div>
))
GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/calendar
배포 링크: https://wontory-resat.vercel.app/calendar
Date
객체 조작을 위해 date-fns
라이브러리를 추가로 이용하였습니다.
GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/clone-miniintern
배포 링크: https://wontory-resat.vercel.app/clone-miniintern
텍스트 배치를 위해 CSS의 relative
, absolute
를, 카드 배치를 위해 flex
와 grid
를 적절히 사용했습니다.
카드 위로 마우스를 hover할 때 위로 조금 떠오르는 효과를 주기 위해 translateY도 사용해 보았습니다.
transition hover:-translate-y-3 hover:shadow-xl
슬라이더 영역과 이력서 피드백 배너 영역은 시간이 부족해서 미구현한 상태로 제출했었고, 8일차에 슬라이더 추가와 함께 구현하여 제출했습니다.
GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/carousel
배포 링크: https://wontory-resat.vercel.app/carousel
이미지는 Lorem Picsum에서 API로 받아와 출력해주었습니다!
export default async function CarouselPage() {
const IMAGES: Image[] = await fetch(
'https://picsum.photos/v2/list?limit=5',
).then((res) => res.json())
return (
<main className="flex h-screen w-screen flex-col items-center justify-center">
<header className="mb-8 text-4xl font-extrabold lg:text-5xl">
Carousel
</header>
<Carousel>
{IMAGES.map((image, index) => (
<CarouselItem
key={`ci-${index}`}
src={image.download_url}
alt={`${image.author}-${image.id}`}
/>
))}
</Carousel>
</main>
)
}
Carousel 양옆의 Chevron 버튼은 absolute
를 사용해서 배치해주었고, 클릭했을 때 index
상태가 변경되도록 구현하였습니다.
const [index, setIndex] = useState(0)
// index가 0 또는 마지막 index일 때에는 전환이 이루어지지 않도록 처리
const handlePrev = () => {
setIndex((prev) => (prev === 0 ? prev : prev - 1))
}
const handleNext = () => {
setIndex((prev) =>
prev === Children.count(children) - 1 ? prev : prev + 1,
)
}
...
<Button
className="absolute left-0 top-1/2 -translate-y-1/2 text-white shadow-none"
onClick={handlePrev}
>
<Icons.Left />
</Button>
<Button
className="absolute right-0 top-1/2 -translate-y-1/2 text-white shadow-none"
onClick={handleNext}
>
<Icons.Right />
</Button>
이미지가 넘어가는 것처럼 보이게 하기 위해 index
상태의 값을 계산하여 translateX
를 이용해 이미지의 위치를 변경해주었고, overflow
는 hidden
처리하여 해당하는 index
의 이미지만 보이도록 구현했습니다.
<div
className="flex h-full transition duration-700"
style={{
transform: `translateX(${-index * 100}%)`,
}}
>
{children}
</div>
...
// Carousel의 children 요소
function CarouselItem({ src, alt }: { src: string; alt: string }) {
return (
<div className="relative aspect-video">
<Image src={src} alt={alt} fill className="object-cover" />
</div>
)
}
GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/carousel2
배포 링크: https://wontory-resat.vercel.app/carousel2
6일차에 구현했던 Carousel을 일부 수정하였습니다. 무한 스크롤을 위해 기존의 index
가 0 또는 마지막일 때의 index
수정을 제한했던 부분을 재구현하였습니다.
// index가 0일 때 이전 버튼을 클릭하면 마지막 index로 업데이트
const handlePrev = () => {
setIndex((prev) => (prev === 0 ? Children.count(children) - 1 : prev - 1))
}
/** handleNext는 시간이 지나면 자동으로 넘어가는 것을 구현할 때
useEffect 내부에서 사용되기 때문에 useCallback으로 처리해주었습니다. */
// index가 0일 때 이전 버튼을 클릭하면 마지막 index로 업데이트
const handleNext = useCallback(() => {
setIndex((prev) => (prev === Children.count(children) - 1 ? 0 : prev + 1))
}, [children])
2초 간격으로 자동으로 슬라이드 되도록 useEffect와 클린업 함수를 사용했습니다.
useEffect(() => {
const interval = setInterval(() => {
handleNext()
}, 3000) // 이미지가 넘어가는데 소요되는 시간을 고려하여 3초로 설정하였습니다.
return () => clearInterval(interval)
}, [handleNext])
GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/clone-miniintern2
배포 링크: https://wontory-resat.vercel.app/clone-miniintern2
5일차에서 미구현한 슬라이더 영역과 이력서 피드백 배너 영역을 추가해서 제출하였습니다.
GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/global-nav-bar
배포 링크: https://wontory-resat.vercel.app/global-nav-bar
MainNav
컴포넌트와 MobileNav
컴포넌트를 각각 구현한 후 TailwindCSS의 media query를 이용해 출력 여부를 결정해주었습니다.
<header className="sticky top-0 w-full bg-white shadow">
<div className="container mx-auto flex h-14 max-w-screen-2xl items-center px-8">
<Link href="" className="text-xl font-semibold transition-colors">
Title
</Link>
<div className="flex flex-1 items-center justify-end">
<MainNav />
<MobileNav />
</div>
</div>
</header>
export function MainNav() {
return (
<nav className="hidden items-center gap-6 text-sm lg:flex">
...
export function MobileNav() {
return (
<div className="flex items-center gap-6 text-sm lg:hidden">
...
MobileNav
는 isOpen
상태와 토글버튼을 통해 메뉴 출력 여부를 결정해주었고, 메뉴는 absolute
로 설정하여 토글버튼 아래에 위치할 수 있도록 해주었습니다.
export function MobileNav() {
const [isOpen, setIsOpen] = useState(false)
const handleToggle = () => {
setIsOpen((prev) => !prev)
}
return (
<>
<div className="flex items-center gap-6 text-sm lg:hidden">
<Button onClick={handleToggle} className="h-9 w-9 shadow-none">
<Icons.HamburgerMenu
className={cn(
'absolute h-[1.2rem] w-[1.2rem] transition-all',
isOpen ? '-rotate-90 scale-0' : 'rotate-0 scale-100',
)}
/>
<Icons.Cross
className={cn(
'absolute h-[1.2rem] w-[1.2rem] transition-all',
isOpen ? 'rotate-0 scale-100' : 'rotate-90 scale-0',
)}
/>
<span className="sr-only">Toggle menu</span>
</Button>
</div>
<nav
className={cn(
'absolute top-16 w-40 flex-col gap-4 rounded-lg border bg-white p-4 text-sm shadow-md lg:hidden',
isOpen ? 'flex' : 'hidden',
)}
>
...
</nav>
</>
)
}
GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/signin
배포 링크: https://wontory-resat.vercel.app/signin
로그인 정보
ID: username
PW: password
아이디, 비밀번호의 입력 상태와 로그인 성공여부, toast 등록 여부를 위한 상태를 등록했습니다.
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [isSuccess, setIsSuccess] = useState<boolean>()
const [isToastVisible, setIsToastVisible] = useState<boolean>(false)
아이디 비밀번호를 상태와 onChange
로 관리했었는데 useRef
를 이용했으면 렌더링 측면에서 이점이 있지 않았을까 싶은 아쉬움이 남네요..!(혹은 debounce나 useDeferredValue
같은 훅을 사용한다거나..?)
로그인을 시도하면 로그인 성공 여부와 toast 등록에 대한 상태를 업데이트합니다. 로그인에 성공하면 toast가 노출되고, 4초 뒤에 사라지도록 setTimeout
을 이용해 구현했습니다.
const handleSignIn = () => {
if (username === 'username' && password === 'password') {
setIsSuccess(true)
setIsToastVisible(true)
} else {
setIsSuccess(false)
setIsToastVisible(false)
}
}
useEffect(() => {
isToastVisible && setTimeout(() => setIsToastVisible(false), 4000)
}, [isToastVisible])
export function Toast({
className,
content,
isVisible,
}: {
className?: string
content: string
isVisible: boolean | undefined
}) {
return (
<div
className={cn(
'fixed -bottom-full right-4 rounded-lg px-6 py-4 font-semibold text-white shadow-md transition-all',
isVisible && 'bottom-4',
className,
)}
>
<span>{content}</span>
</div>
)
}
로그인에 실패하면 로그인 버튼 상단에 로그인 실패 문구를 출력합니다.
<span className="text-xs font-medium text-red-500">
{isSuccess !== undefined && !isSuccess && 'ID 혹은 PW가 잘못되었습니다'}
</span>