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

사장님

코드잇 프론트엔드 부트캠프 0기 - young developers 팀의 TheJulge
피그마로 디자인 시안을 전달 받고 프로젝트를 시작했다.

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


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


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

실제 사용한 폴더 구조



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

로그인을 하면 백엔드로부터 토큰을 받는다. 제공 받은 토큰에는 리프레쉬 개념은 존재하지 않았기에 단순히 토큰을 저장하고 이를 통해 로그인 여부를 확인한다. 이 확인 절차는 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의 인터셉터에 토큰을 달았고, 서버에서 요청할 때는 직접 코드로 토큰을 주입했다.

프로젝트 시작 당시 api 명세를 받았다. 이를 코드로 적용하려면 매번 명세를 확인하는 과정에 개발 경험을 떨어트릴 수 있다고 생각했다. 무엇보다 내가 편하게 쓰고 싶어서 api 함수들을 추상화했다.
이렇게 함수를 추상화할 때 중요하게 생각한 건 jsdoc이었다. 아무래도 내가 작성하면 나는 알아보더라도 다른 팀원들은 알아보기 힘들 것이므로 최대한 함수 설명을 자세히 적으려고 노력했다. 글로 자세히 적는 것보다도 예시를 들어 최대한 빨리 함수를 이해하고 사용할 수 있게 만들려고 노력했다.

최근에 본 공고 목록을 로컬 스토리지에 저장해 관리했다. 로컬 스토리지를 사용할 때, 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
알바님 내 프로필 페이지

공고 상세 페이지

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



우리 프로젝트는 사장님과 알바님 모두 등록하기가 필요했다. 알바님은 프로필을 등록해야 했고, 사장님은 가게를 등록해야 했다.
어떻게 만들지 고민하다가 얼마 전에 우연히 본 토스 개발자 컨퍼런스의 퍼널 패턴이 떠올랐다. 하나의 페이지에서 하나의 데이터만 입력하게끔 사용성을 높이는 게 우리 프로젝트에도 적용해볼만 하다고 생각했다.
토스ㅣ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
그렇게 탄생한 모바일 버전의 등록하기 모습..

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


prefetching은 좋았지만, 한 가지 걱정되는 건 이렇게 미리 불러왔다가, 사용자가 버튼 누르는 시점에는 해당 데이터가 오래된 데이터가 되면? 충분히 문제가 될 수 있다고 생각했다. 캐싱이 무조건적인 정답은 아닐 수 있고, 오히려 사용자 경험을 해칠 수 있을 수 있다고 느꼈다.
참고
일단 프로젝트 자체는 성공적이었다. 개인적으로 분량이 상당하다고 생각했는데 이걸 2주만에 정말로 잘 끝낼 줄 몰랐다.
프로젝트가 끝나니 마음이 편했지만, 프로젝트 과정 내내 불안했다. 완성하지 못할거라는 불안감 때문에 조급해졌다. 그러다보니 매일 진행하는 스크럼 때 목표를 일부러 매번 빡세게 잡으려고 노력했다. 그 덕분에 끝낼 수 있었다고 생각은 했지만, 이러한 내 마음가짐을 팀원들에게 설득하는 과정 자체는 미숙했다. 내가 생각하는 청사진을 팀원들에게 제대로 공유하지 않고 나혼자 조급했다. 때로는 빨리 일정을 맞춰야 한다는 마음이 앞서 예민해지기도 했다.
이어지는 이야기인데, 프로젝트 완성에 대한 부담감이 심했다. 그러다보니 혼자서 해결하려고 밤마다 노력을 꽤했다. 그리고 그게 쌓이고 쌓여 중간에는 혼자 무너진 적이 있었다.
팀 프로젝트는 혼자 진행하는 게 아니라는 걸 조금 더 빨리 깨달았더라면 프로젝트를 좀 더 건강하게 수행할 수 있지 않았을까 싶다.
결국 다섯 명이서 힘을 모으니 개발 속도가 빨라졌다. 내가 할 수 있는 일들을 최대한 빨리 하고, 팀원들을 도우려고 노력했다. 막바지 페이지 조립 단계에서부터 최대한 페어 프로그래밍을 진행하려고 했다. 혼자서 끙끙대는 시간을 페어 프로그래밍으로 줄여나갈 수 있다고 생각했기 때문이다. 덕분에 개발을 빨리 끝낼 수 있었다.
안녕하세요! 부트캠프 고민중인데 코드잇 추천하시나요?