[코드잇 FE 부트캠프] 2차 프로젝트, 기록과 회고

woolee의 기록보관소·2023년 7월 24일

코드잇부트캠프0기

목록 보기
23/24

The Julge 프로젝트 (7.6 ~ 7.21)

Ⅰ. 프로젝트 개요

서비스 요약

아르바이트 구인구직 플랫폼이다. 사장님은 알바님을 구하는 공고를 등록해 아르바이트생을 구할 수 있고, 알바님은 일자리를 구할 수 있다. 아르바이트생을 빨리 구하고 싶은 사장님은 시급을 올릴 수 있고, 올라간 시급은 직관적으로 표현된다.

결과물 미리보기

알바님

사장님

Github 주소

코드잇 프론트엔드 부트캠프 0기 - young developers 팀의 TheJulge

Ⅱ. 프로젝트 준비

디자인 시안 (Figma)

피그마로 디자인 시안을 전달 받고 프로젝트를 시작했다.

협업 (Notion 및 Slack)

프로젝트에 필요한 각종 문서들은 노션을 활용했고, 그외 소통은 슬랙을 사용했다.

기술 스택 선정

주요 프레임워크, 언어로는 Next 13 app router와 Typescript를 사용했다.
스타일은 scss를 사용했고, 별도의 상태관리 라이브러리는 사용하지 않았다.

  • 이유는 특별히 상태관리해야 할 데이터보다는 실시간으로 데이터를 받아와서 보여주는 게 더 중요하다고 생각했다.
  • react query 도입을 고민했으나, 이번 프로젝트에서는 next 13의 기능들을 적극적으로 사용하면 괜찮지 않을까라는 생각을 했다.

새로운 폴더 구조 도입, NX

지난 프로젝트 때는 폴더 구조라기 보다는 컴포넌트를 smart, dumb으로 구분했었다. ui를 담당하는 dumb 컴포넌트와, 그외 비즈니스 로직을 담당하는 smart 컴포넌트.

  • 이때 느꼈던 단점은, 결국에는 smart 컴포넌트가 비대해지고, 그 안에서 관심사 분리가 더욱 필요하다는 점이었다.
  • 그래서 smart 컴포넌트를 보다 더 잘게 관심사를 분리할 수 있고, 도메인 별로 관심사를 분리할 수 있는 폴더 구조를 원했다. 그러다가 발견한 게 NX 프레임워크의 폴더 구조였다. 프레임워크 자체를 사용한 건 아니었고, 폴더구조만 가져와 프로젝트에 적용했다.

NX | Library Types

실제 사용한 폴더 구조

Ⅲ. 내가 맡은 역할과 주요 문제 해결, 그리고 사용자 경험 생각하기

1. 인증과 인가 (유저 권한)

유저의 경우 크게 2가지로 나뉘었다. 알바님과 사장님.

  • 사장님은 내 가게 페이지로 접속할 수 있어야 했고, 알바님은 내 프로필 페이지로 접속할 수 있어야 했다.
  • 그리고 비회원을 포함해 3명의 유저 타입 모두 공고 목록을 확인할 수 있고, 각각의 공고 상세 페이지를 탐색할 수 있어야 했다.

로그인을 하면 백엔드로부터 토큰을 받는다. 제공 받은 토큰에는 리프레쉬 개념은 존재하지 않았기에 단순히 토큰을 저장하고 이를 통해 로그인 여부를 확인한다. 이 확인 절차는 next js의 미들웨어를 사용했다.

  • 각 페이지로의 요청 시에 쿠키를 확인해 라우팅 처리를 해준다.
// middleware.ts 

import { NextRequest, NextResponse } from 'next/server'

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl
  const cookie = req.cookies.get('token')

  let token
  if (cookie) {
    token = cookie.value
  }

  // 로그인된 유저만 접근 O (내 가게, 내 프로필)
  if (pathname.startsWith('/my-shop') && !token) {
    return NextResponse.redirect(new URL('/signin', req.url))
  }
  if (pathname.startsWith('/my-profile') && !token) {
    return NextResponse.redirect(new URL('/signin', req.url))
  }

  // 로그인된 유저는 로그인, 회원가입 페이지에 접근 X
  if (pathname.startsWith('/signin') && token) {
    return NextResponse.redirect(new URL('/', req.url))
  }
  if (pathname.startsWith('/signup') && token) {
    return NextResponse.redirect(new URL('/', req.url))
  }

  return NextResponse.next()
}

그외 모든 유저 타입이 존재할 수 있는 공고 상세 페이지에서는 신청하기 버튼을 눌렀을 때, 모달을 통해 유저 권한을 막았다.

각각의 상황에 맞게 경고 메시지와 라우팅 처리를 해주었다.

  • 비회원인데 신청하기 버튼을 누르면 경고 모달과 함께 로그인 페이지로,
  • 사장님인데 신청하기 버튼을 누르면 경고 모달을,
  • 알바님인데 내 프로필을 등록하지 않았다면, 경고 모달과 함께 내 프로필 페이지로

그리고 권한이 필요한 요청의 경우 클라이언트에서 쏠 때는 axios의 인터셉터에 토큰을 달았고, 서버에서 요청할 때는 직접 코드로 토큰을 주입했다.

2. api 함수 추상화


프로젝트 시작 당시 api 명세를 받았다. 이를 코드로 적용하려면 매번 명세를 확인하는 과정에 개발 경험을 떨어트릴 수 있다고 생각했다. 무엇보다 내가 편하게 쓰고 싶어서 api 함수들을 추상화했다.

이렇게 함수를 추상화할 때 중요하게 생각한 건 jsdoc이었다. 아무래도 내가 작성하면 나는 알아보더라도 다른 팀원들은 알아보기 힘들 것이므로 최대한 함수 설명을 자세히 적으려고 노력했다. 글로 자세히 적는 것보다도 예시를 들어 최대한 빨리 함수를 이해하고 사용할 수 있게 만들려고 노력했다.

3. 로컬 스토리지 추상화 (SSR과 싱크 맞추기)

최근에 본 공고 목록을 로컬 스토리지에 저장해 관리했다. 로컬 스토리지를 사용할 때, key를 관리하기 어려웠던 기억이 있어 이번에는 전역적으로 관리하려고 노력했다.

// notice-provider.tsx 

import { ReactNode, createContext, useContext, useMemo } from 'react'

import { AllNoticesData } from '@/libs/shared/api/types/type-notice'
import useLocalStorage from '@/libs/shared/shared/util/client-storage/use-localstorage'

const STORAGE_KEY = 'RECENT_NOTICES'

interface NewRecentNoticesProps {
  getRecentNoticeList: () => AllNoticesData[] | null
  setRecentNoticeList: (notices: AllNoticesData[]) => void
  removeRecentNoticeItem: () => void
}

const NewRecentNoticesContext = createContext<NewRecentNoticesProps>({
  getRecentNoticeList: () => null,
  setRecentNoticeList: () => {},
  removeRecentNoticeItem: () => {},
})

function NewRecentNoticesProvider({ children }: { children: ReactNode }) {
  const recentNoticesStorage = useLocalStorage<AllNoticesData[]>(STORAGE_KEY)

  const contextProps: NewRecentNoticesProps = useMemo(
    () => ({
      getRecentNoticeList: () => {
        if (recentNoticesStorage) {
          return recentNoticesStorage.get()
        }
        return null
      },
      setRecentNoticeList: (notices: AllNoticesData[]) =>
        recentNoticesStorage?.set(notices),
      removeRecentNoticeItem: () => recentNoticesStorage?.remove(),
    }),
    [recentNoticesStorage],
  )

  return (
    <NewRecentNoticesContext.Provider value={contextProps}>
      {children}
    </NewRecentNoticesContext.Provider>
  )
}

/**
 * 
 * @example 사용 예시 
 * 
 * ```
 * const recentNotices = useNewRecentNoticesContext()

  useEffect(() => {
    recentNotices.setRecentNoticeItem({ item: { value: '데이터' } })

    const data = recentNotices.getRecentNoticeList()
    console.log(data) // { item: { value: '데이터' } }
  }, [recentNotices])
 * ```
 * 
 * @returns 
 */
const useNewRecentNoticesContext = () => useContext(NewRecentNoticesContext)

export { NewRecentNoticesProvider, useNewRecentNoticesContext }
// client-storage.ts 

const checkSSR = () => typeof window === 'undefined'

/**
 *
 * @description 인스턴스 생성은 CSR 내에서 수행해야 합니다. (context를 통해 사용하면 됩니다.)
 *
 * @example 기본 사용 예시 (예시 일뿐, key 통일성을 위해 context를 사용해야 합니다.)
 * ```
 * const ls = new ClientStorage<string>(
      'recent-application', // key name
      localStorage, // 사용하고 싶은 웹 스토리지
      () => {
        // 에러 처리
        console.log('Error!!')
      },
    )

    ls.set('저장하고 싶은 데이터')
    const data = ls.get()
    console.log(data) // '저장하고 싶은 데이터' 
 * ```
 *
 */
class ClientStorage<T> {
  private key

  private storage

  private onException

  constructor(key: string, storage: Storage, onException?: () => void) {
    this.key = key
    this.storage = storage
    this.onException = onException || (() => {})
  }

  has(): boolean {
    if (checkSSR()) {
      return false
    }

    return Boolean(this.storage.getItem(this.key))
  }

  get(): T | null {
    if (checkSSR()) {
      return null
    }

    const data = this.storage.getItem(this.key)

    if (data) {
      return JSON.parse(data as string)
    }

    this.onException()
    return null
  }

  set(data: T) {
    if (!checkSSR()) {
      this.storage.setItem(this.key, JSON.stringify(data))
    }
  }

  remove() {
    if (!checkSSR()) {
      this.storage.removeItem(this.key)
    }
  }
}

export default ClientStorage
// use-localstorage.ts 

import { useEffect, useState } from 'react'

import ClientStorage from './client-storage'

/**
 * @description 기존 로컬 스토리지에 예외처리를 추가한 훅입니다.
 *
 * @param key 스토리지 key
 * @param onException 예외 처리
 * @returns 스토리지 state
 */
const useLocalStorage = <T>(key: string, onException?: () => void) => {
  const [clientStorage, setClientStorage] = useState<ClientStorage<T> | null>(
    null,
  )

  useEffect(() => {
    setClientStorage(new ClientStorage<T>(key, localStorage, onException))
  }, [key, onException])

  return clientStorage
}

export default useLocalStorage

4. 2개의 페이지 조립 (알바님 마이 프로필, 공고 상세 페이지)

알바님 내 프로필 페이지

공고 상세 페이지

개인적으로 공고 상세 페이지는 리팩토링이 절실하다.
공고 상세 페이지의 경우, 다른 페이지와 달리 여러 유저 타입이 전부 접근할 수 있는 페이지로 만들었다. 그러다 보니 각 유저 타입의 경우의 수에 맞게 어떤 모달을 띄워주고, 어떤 토스트를 띄워주고, 어떤 페이지로 라우팅할지 등등 경우의 수가 많다보니 코드가 굉장히 길어졌다..

5. 등록하기 과정에 사용자 경험 더하기 (Funnel?)

우리 프로젝트는 사장님과 알바님 모두 등록하기가 필요했다. 알바님은 프로필을 등록해야 했고, 사장님은 가게를 등록해야 했다.

어떻게 만들지 고민하다가 얼마 전에 우연히 본 토스 개발자 컨퍼런스의 퍼널 패턴이 떠올랐다. 하나의 페이지에서 하나의 데이터만 입력하게끔 사용성을 높이는 게 우리 프로젝트에도 적용해볼만 하다고 생각했다.

  • 특히 데스크탑은 괜찮더라도 모바일은 하나의 페이지에 하나의 데이터만 입력할 수 있게 하는 게 절실하게 필요했다.

토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기

대신 우리 프로젝트는 next js app router를 사용하기로 했고, 데이터를 최대한 서버 컴포넌트에서 받아오고 싶었다. 그러다 보니, 페이지 전체를 클라이언트로 사용할 수 없는 상황에서 스타일 종속을 피하면서도 서버 컴포넌트로 감싸진 클라이언트 컴포넌트에서 페이지 관리를 하기 위해 portal을 사용했다.

포탈로 페이지를 열고, 각 페이지를 다음과 같이 상태로 관리했다.

모바일 화면에서 뒤로가기를 막기 위해 다음과 같이 작업했다.
뒤로가기를 계속 무한정 막으면 스택에만 쌓이기 때문에 한번만 막고 모달을 닫았다.

import { useEffect } from 'react'

const useEnableToBack = (onClickCloseModal: () => void) => {
  useEffect(() => {
    const handlePopstateBackward = () => {
      window.history.pushState(null, document.title, window.location.href)
      onClickCloseModal()
    }

    window.history.pushState(null, document.title, window.location.href)
    window.addEventListener('popstate', handlePopstateBackward)

    return () => {
      window.removeEventListener('popstate', handlePopstateBackward)
    }
  }, [onClickCloseModal])
}

export default useEnableToBack

그렇게 탄생한 모바일 버전의 등록하기 모습..

6. 페이지네이션 캐싱, next의 prefetching

페이지네이션을 구현할 때 next의 Link 태그를 사용했다. 자동으로 화면에 보인 7개의 버튼에 맞는 api를 prefetching 해준다. 초기 로딩이 조금 느리고 각 페이지 간 이동은 빨랐다.


prefetching은 좋았지만, 한 가지 걱정되는 건 이렇게 미리 불러왔다가, 사용자가 버튼 누르는 시점에는 해당 데이터가 오래된 데이터가 되면? 충분히 문제가 될 수 있다고 생각했다. 캐싱이 무조건적인 정답은 아닐 수 있고, 오히려 사용자 경험을 해칠 수 있을 수 있다고 느꼈다.

  • Link 컴포넌트의 prefetch를 false로 주면, Link 컴포넌트에 부착되어 있는 api를 페이지 로드시 미리 가져오지 않고, 해당 Link 컴포넌트를 hover했을 때 prefetch하도록 바꿀 수 있다.
  • 이렇게 하면, 한번에 다 가져오는 것보다는 데이터의 실시간성을 유지할 수 있고 동시에 prefetching을 통해 SPA를 사용하는 경험을 제공할 수 있다.

참고

Web: Next.js Link와 Prefetch 과정 파헤쳐보기

Ⅵ 되돌아보기

불안함과 조급함, 설득하는 방법?

일단 프로젝트 자체는 성공적이었다. 개인적으로 분량이 상당하다고 생각했는데 이걸 2주만에 정말로 잘 끝낼 줄 몰랐다.

프로젝트가 끝나니 마음이 편했지만, 프로젝트 과정 내내 불안했다. 완성하지 못할거라는 불안감 때문에 조급해졌다. 그러다보니 매일 진행하는 스크럼 때 목표를 일부러 매번 빡세게 잡으려고 노력했다. 그 덕분에 끝낼 수 있었다고 생각은 했지만, 이러한 내 마음가짐을 팀원들에게 설득하는 과정 자체는 미숙했다. 내가 생각하는 청사진을 팀원들에게 제대로 공유하지 않고 나혼자 조급했다. 때로는 빨리 일정을 맞춰야 한다는 마음이 앞서 예민해지기도 했다.

혼자 좌절하기

이어지는 이야기인데, 프로젝트 완성에 대한 부담감이 심했다. 그러다보니 혼자서 해결하려고 밤마다 노력을 꽤했다. 그리고 그게 쌓이고 쌓여 중간에는 혼자 무너진 적이 있었다.

팀 프로젝트는 혼자 진행하는 게 아니라는 걸 조금 더 빨리 깨달았더라면 프로젝트를 좀 더 건강하게 수행할 수 있지 않았을까 싶다.

결국엔 힘을 모으기

결국 다섯 명이서 힘을 모으니 개발 속도가 빨라졌다. 내가 할 수 있는 일들을 최대한 빨리 하고, 팀원들을 도우려고 노력했다. 막바지 페이지 조립 단계에서부터 최대한 페어 프로그래밍을 진행하려고 했다. 혼자서 끙끙대는 시간을 페어 프로그래밍으로 줄여나갈 수 있다고 생각했기 때문이다. 덕분에 개발을 빨리 끝낼 수 있었다.

profile
https://medium.com/@wooleejaan

1개의 댓글

comment-user-thumbnail
2024년 3월 2일

안녕하세요! 부트캠프 고민중인데 코드잇 추천하시나요?

답글 달기