RESAT 프론트엔드 개발 챌린지 9기

image

image

코드 리뷰: 링크

 

기술 스택

  • React.js
    • Next.js는 라우팅 용도로만 사용했습니다.
  • TailwindCSS
  • shadcn/ui
    • 스타일링에 한해서 shadcn/ui를 참고하였습니다.

최대한 라이브러리 의존도를 낮추고 컴포넌트 제작 실력을 향상시키기 위해 노력했습니다!

미션에 관계없이 공통으로 사용되는 컴포넌트는 /src/components에, 각 미션에서 사용되는 컴포넌트는 ./components에 구현하였습니다.

 

1일차: GitHub 레포지토리 만들기

GitHub Repository:

https://github.com/wontory/resat-challenge

image

일차별 수행한 미션의 결과물은 /src/app 경로에서 확인할 수 있습니다.

 

2일차: 카운트다운 타이머 만들기

GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/countdown-timer

배포 링크: https://wontory-resat.vercel.app/countdown-timer

image

각 시, 분, 초의 입력은 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까지 정확히 계산되지 않는 문제가 있었습니다.

  • decrementTimeuseEffect에서만 사용되는 함수인데, 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])

 

3일차: To-Do 리스트 만들기

GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/to-do-list

배포 링크: https://wontory-resat.vercel.app/to-do-list

image

부모 컴포넌트에서 리스트, 필터, 정렬을 상태로 관리하였고, 입력부, 필터 선택부, 정렬 선택부, 전체 리스트 출력부를 자식 컴포넌트로 각각 개발하여 필요한 상태를 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>
  ))

 

4일차: 캘린더 만들기

GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/calendar

배포 링크: https://wontory-resat.vercel.app/calendar

image

Date 객체 조작을 위해 date-fns 라이브러리를 추가로 이용하였습니다.

 

5일차: 미니인턴 서비스 메인페이지 클론

GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/clone-miniintern

배포 링크: https://wontory-resat.vercel.app/clone-miniintern

image

텍스트 배치를 위해 CSS의 relative, absolute를, 카드 배치를 위해 flexgrid를 적절히 사용했습니다.

카드 위로 마우스를 hover할 때 위로 조금 떠오르는 효과를 주기 위해 translateY도 사용해 보았습니다.

transition hover:-translate-y-3 hover:shadow-xl

슬라이더 영역과 이력서 피드백 배너 영역은 시간이 부족해서 미구현한 상태로 제출했었고, 8일차에 슬라이더 추가와 함께 구현하여 제출했습니다.

 

6일차: 이미지 슬라이드 (캐러셀) 만들기 1

GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/carousel

배포 링크: https://wontory-resat.vercel.app/carousel

image

이미지는 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를 이용해 이미지의 위치를 변경해주었고, overflowhidden 처리하여 해당하는 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>
  )
}

 

7일차: 이미지 슬라이드 (캐러셀) 만들기 2

GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/carousel2

배포 링크: https://wontory-resat.vercel.app/carousel2

image

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])

 

8일차: 만들어진 슬라이드 5일차 미니인턴 클론 페이지에 붙이기

GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/clone-miniintern2

배포 링크: https://wontory-resat.vercel.app/clone-miniintern2

image

5일차에서 미구현한 슬라이더 영역과 이력서 피드백 배너 영역을 추가해서 제출하였습니다.

 

9일차: 반응형 네비게이션 & 메뉴바 만들기

GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/global-nav-bar

배포 링크: https://wontory-resat.vercel.app/global-nav-bar

image

image

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">
      
      ...

MobileNavisOpen 상태와 토글버튼을 통해 메뉴 출력 여부를 결정해주었고, 메뉴는 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>
    </>
  )
}

 

10일차: 로그인 페이지 만들기

GitHub: https://github.com/wontory/resat-challenge/tree/master/src/app/signin

배포 링크: https://wontory-resat.vercel.app/signin

image

image

로그인 정보


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>