๐ ๊ธฐํ ์์
๊ธฐ์กด์ ๋ชจ์ผ์ก์ผ๋ก ์ฝ 6๋ช ์ ๋ ์ง์ธ๋ค์๊ฒ ๋ณด์ฌ์ฃผ๊ณ ํผ๋๋ฐฑ์ ๋ฐ์๋ค. ํผ๋๋ฐฑ๋ค ๋๋ถ์ ๋ณด๋ค ๊ฐ๊ด์ ์ผ๋ก ํ๋ก์ ํธ๋ฅผ ๋ณผ ์ ์์๋ค.
๋จผ์ ๋ก๊ทธ์ธ์ ํด์ผ ์ฑ์ฉ๊ณต๊ณ ๋ฅผ ๋ณผ ์ ์๋ค๋ ์ ์ด์๋ค. ๋งจ ์ฒ์ ๋ณผ ์ ์๋ ํ๋ฉด์ด ๋ก๊ทธ์ธ ํ๋ฉด์ด์๊ธฐ ๋๋ฌธ์ ์ฌ์ฉ์ ๊ฒฝํ์ด ์ข์ง ์๋ค๋ ํผ๋๋ฐฑ์ ๋ค์๊ณ , ์ ๊ทน ๊ณต๊ฐํ๋ค. ๋ด๊ฐ ๋ง๋ ์๋น์ค๊ฐ ์ด๋ค ๊ฒ์ธ์ง๋ ๋ชจ๋ฅด๋๋ฐ ๋จผ์ ํ์๊ฐ์
ํ๋ผ๋ ๊ฒ์ ์ค๋๋ ฅ์ด ์ ํ ์๋ ์์์๋ค.
๋ ๋ฒ์งธ๋ก๋ UI์ ์ผ๋ก ๋๋ฌด ๋น์ด ๋ณด์ธ๋ค๋ ์ ์ด์๋ค. ์ฑ์ฉ๊ณต๊ณ ๊ฐ ๋ง์ผ๋ฉด ๊ทธ๋๋ง ๊ด์ฐฎ์ง๋ง ๋ฉ์ธ ํ์ด์ง๊ฐ ๋๋ฌด ํํด ๋ณด์ธ๋ค๋ ์ ์ด์๋ค. ์ด์ ๋ ๊ณต๊ฐํ๋ ๋ถ๋ถ์ด์๋ค. ์ฑ์ฉ ์๋น์ค๋ค์ ๊ฒฝ์ฐ ๋ค์ํ ์ด๋ฒคํธ๋ค์ ํ๊ณ ์์ด์ ๋ฐฐ๋๋ก ๋ณด์ฌ ์ฃผ์ง๋ง ํ์ฌ ๋๋ ์ด๋ค ๊ฑธ ๋จผ์ ๋์์ค์ผ ํ ์ง ๊ณ ๋ฏผ์ด ๋๋ ์ํ๋ค. ๋์ ์ ์ ์ฒด์ ์ธ UI๋ฅผ ์ข ๋ ๋ฐ์ ์์ผ๋ณด๋ ค๊ณ ์ฑ์ฉ๊ณต๊ณ ์ฌ์ดํธ๋ค์ ์์๋ค์ ์ฐธ์กฐํ๋ค.
โ ์๋น์ค work flow ์์ ํ๊ธฐ
๋จผ์ ๋ฉ์ธ ํ์ด์ง์์ ์ฑ์ฉ๊ณต๊ณ ๋ค์ ๋ณด์ฌ์ฃผ๊ธฐ ์ํด์๋ ๊ธฐ์กด ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ฌ์ฉ์ ๋ณ ๊ถํ์ ์ ๋ฆฌํ ํ์๊ฐ ์์๋ค.
์ ์ฒด ๊ณต๊ณ ๋ ๋ฉ์ธ ํ์ด์ง์์ ๋ฐ๋ก ๋ณผ ์ ์์ด์ผ ํ๋ฏ๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค์ jobs/์์ ๊ฐ์ฒด๋ก์จ ๋ด๊ฒจ์์ผ๋ฉด ์ข๊ฒ ๋ค๋ ์๊ฐ์ ํ๋ค. ๋ฐฐ์ด๋ก ์ ๋ฆฌํด๋ ๋์ง๋ง ๋ํ
์ผ ํ์ด์ง์์ ์์ธ ๋ด์ฉ์ ๋ณด์ฌ์ค์ผ ํ๋ฏ๋ก, ์ ์ฒด ๋ด์ฉ ์ค ์ํ๋ ์์ธ ๋ด์ฉ์ ์ฐพ์ ๋ ๋ฐฐ์ด๋ณด๋ค ๊ฐ์ฒด์์ ์ฐพ๋ ๊ฒ์ด ์ฑ๋ฅ์ด ๋ ์ข๊ธฐ ๋๋ฌธ์ ๊ฐ์ฒด๋ก ์ ์ฅํ๊ธฐ๋ก ํ๋ค. ์ ์ฒด ๊ณต๊ณ ๋ ์๋น์ค๋ฅผ ์ฌ์ฉํ๋ ๋ชจ๋ ์ฌ๋์ด ๋ณผ ์ ์์ด์ผ ํ์ง๋ง ์์ , ์ญ์ , ์ถ๊ฐ๋ ์ธ์ฆ๋ ์ฌ์ฉ์๋ ๊ด๋ฆฌ์ ๊ถํ์์๋ง ๊ฐ๋ฅํ๊ฒ ๊ตฌ์ํ๋ค.
๋ก๊ทธ์ธ๋ง ํ๋ฉด ๋ชจ๋ ๊ณต๊ณ ๋ฅผ ๋ง์๋๋ก ํ ์ ์๋ ๊ฒ์ด ๊ฑฑ์ ์ด ๋๊ธฐ๋ ํ์ง๋ง, ์๋น์ค์ ๋ฐฉํฅ์, ์๋ก ์ ๋ฆฌํ ๊ด์ฌ ์๋ ํ์ฌ๋ค์ ์ฑ์ฉ๊ณต๊ณ ๋ด์ฉ์ ์ ๋ฆฌํ๊ณ ๊ณต์ ํ ์ ์๋ ์๋น์ค๋ก ๊ตฌ์ํ์ผ๋ฏ๋ก ์ธ์ฆ๋ ์ฌ๋๋ค์ด ๊ด๋ฆฌ์ ๊ถํ๋ ์๊ฒ ํ๋ ๊ฒ์ด ์ข์ ๋ณด์๋ค.
[๊ถํ ๋ณ ๊ฐ๋ฅํ CRUD]
| ๊ถํ | ์ผ๋ฐ ์ฌ์ฉ์ | ์ธ์ฆ๋ ์ฌ์ฉ์ |
|---|---|---|
์ ์ฒด ๊ณต๊ณ ( jobs/) |
GET๋ง ๊ฐ๋ฅ | GET, POST, DELETE, PUT |
์ ์ ๋ณ ๊ณต๊ณ ( Users/[user]/jobs ) |
๋ถ๊ฐ๋ฅ | GET, POST, DELETE, PUT |
์ ์ฒด๊ณต๊ณ ์ ์ ์ ๋ณ ๊ณต๊ณ ์ ๋ฐ๋ผ ๋ค๋ฅธ API๋ฅผ ๊ตฌํํ๋ ค ํ์ง๋ง ์๋ฒ ์ฌ์ด๋์์ ์ฒ๋ฆฌํด์ ๋ฐ์์ค๋ user๋ฅผ ๋จผ์ ๋ฐ์์ฌ ์ ์์ผ๋ฏ๋ก user์ ์ ๋ฌด๋ก ๊ฐ๊ฐ์ ๊ตฌํํ ์ ์์ ๊ฒ ๊ฐ๋ค๊ณ ์๊ฐ๋์๋ค. ๊ทธ๋์ ๊ธฐ์กด DBService interface๋ฅผ ์์ ํ ํ์ react query ์ปค์คํ
ํ
์ ๋ฐ์ํ๋ค. ๊ทธ๋ฆฌ๊ณ updateJob๊ณผ addJob์ ํจ์๊ฐ ๋๊ฐ์ firebase์ set์ผ๋ก ๋ง๋ค๊ธฐ ๋๋ฌธ์ ๋์ ํ๋์ ํจ์๋ก ํฉ์ณค๋ค.
// DBType.ts
export interface DBService {
addOrUpdateJob: (job: Job, user?: User) => Promise<void>;
getJobs: (user?: User) => Promise<Jobs>;
removeJob: (job: Job, user?: User) => Promise<void>;
}
//DBService.ts
async getJobs(user?: User): Promise<Jobs> {
const dbRef = ref(this.db);
const query = user ? `users/${user?.id}/` : '';
return get(child(dbRef, `${query}jobs`))
.then((snapshot) => {
if (snapshot.exists()) {
return snapshot.val();
} else {
return {};
}
})
.catch((error) => {
console.error(error);
});
}
async addOrUpdateJob(job: Job, user?: User) {
const query = user ? `users/${user?.id}/` : '';
return set(ref(this.db, `${query}jobs/${job.id}`), job);
}
async removeJob(job: Job, user?: User) {
const query = user ? `users/${user?.id}/` : '';
return remove(ref(this.db, `${query}jobs/${job.id}`));
}
}//useJobs.tsx
const JOBS_KEY = "jobs"
export const useJobs = (user?: User) => {
const dbService = useDBService()
const queryClient = useQueryClient()
const { query } = useRouter()
const { id } = query
const jobId = typeof id === "string" ? id : id?.join() || ""
const getJobs = useQuery([JOBS_KEY, user], async () => {
return dbService.getJobs(user)
})
const addOrUpdateJob = useMutation(
async (job: Job) => {
return dbService.addOrUpdateJob(job, user)
},
{
onSuccess: () => {
!user && queryClient.invalidateQueries([JOBS_KEY])
user && queryClient.invalidateQueries([JOBS_KEY, user])
},
}
)
const deleteJob = useMutation(
async (job: Job) => {
return dbService.removeJob(job, user)
},
{
onSuccess: () => {
!user && queryClient.invalidateQueries([JOBS_KEY])
user && queryClient.invalidateQueries([JOBS_KEY, user])
},
onError: error => {
if (error instanceof AxiosError) {
const { response } = error
if (response) {
console.log(response)
}
}
},
}
)
const getFilteredJobs = useQuery(
[JOBS_KEY, user],
() => dbService.getJobs(user),
{
select: (data: Jobs) => {
return Object.values(data).filter(item => item.id !== id)
},
onError: error => {
console.error(error)
},
}
)
const getJobById = useQuery([JOBS_KEY, user], () => dbService.getJobs(user), {
select: (data: Jobs) => {
return data[jobId]
},
onError: error => {
console.error(error)
},
})
return { getJobs, addOrUpdateJob, deleteJob, getJobById, getFilteredJobs }
}useJobs์์๋ user๊ฐ ์์ ๊ฒฝ์ฐ ๋ฐ๋ก ๋ฐ์ ์์ผ ํ๋ฏ๋ก react-query API์ key๊ฐ์ผ๋ก user๋ฅผ ํฌํจ ์์ผฐ๋ค. ๊ฒฐ๊ณผ์ ์ผ๋ก user์ ์ ๋ฌด๋ก ์ฒ๋ฆฌํ๋ค ๋ณด๋ ๊ธฐ์กด์ user๊ฐ undefined์ผ ๋๋ฅผ ์ํด ๋ฐ๋ก ์ฒ๋ฆฌํด์ฃผ๋ ๋ก์ง์ ์ ์ธํด ๊น๋ํ๊ฒ ๋ํ๋ผ ์ ์์๋ค.
๊ถํ์ ๋ฐ๋ผ ์ด๋ป๊ฒ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ฒ๋ฆฌํ ์ง๋ฅผ ์ ํ๊ณ ๋์ routing์ ๋ํด์๋ ์ ๋ฆฌ๊ฐ ํ์ํ๋ค.
๋จผ์ ์ ์ฒด ๊ณต๊ณ ์ CRUD๋ / ์ /admin ๋ ๊ฐ์ง ํ์ด์ง๋ก ๋๋ด๋ค. /์์๋ ์ ์ฒด ๊ณต๊ณ ๋ฅผ ๋จผ์ ๋ณด์ฌ์ฃผ๊ณ , ๋ก๊ทธ์ธํ ์ ์ ๋ ๊ณต๊ณ ๋ฅผ ์ถ๊ฐํ ์ ์๊ฒ ๊ตฌ์ํ๋ค. /admin์์๋ ์๋ก์ด ๊ณต๊ณ ๋ฅผ ์ถ๊ฐํ ์ ์๊ณ ์ ์ฒด ๊ณต๊ณ ๋ฅผ ์์ , ์ญ์ ํ ์ ์๊ฒ ํ๋ ค ํ๋ค.
์ ์ ๋ณ ๊ณต๊ณ ์ CRUD๋ ์์ ์ ๋ฆฌํ / ์์ ์ถ๊ฐ ๊ธฐ๋ฅ์ ํ๊ธฐ ๋๋ฌธ์, /user์์๋ ๋ชจ์ ๊ณต๊ณ ๋ฅผ ๋ณด์ฌ์ฃผ๊ณ ๊ณต๊ณ ๋ฅผ ์์ , ์ญ์ ํ๋ ๊ธฐ๋ฅ์ ์ฒ๋ฆฌํ ์ ์๊ฒ ๊ตฌ์ํ๋ค.
์ ์ฒด ๊ณต๊ณ ์ ์ ์ ๋ณ ๊ณต๊ณ ๋ ๋ชจ๋ ๋์ผํ JobSection ์ปดํฌ๋ํธ๋ฅผ ํตํด์ ๋ณด์ฌ์ฃผ๊ณ ์๊ธฐ ๋๋ฌธ์ getServerSideProps๋ก ์ ๋ฌ๋ user์ ์ ๋ฌด๊ฐ ์๋๋ผ path๊ฐ /user์ธ์ง ์๋ ์ง๋ฅผ ๊ธฐ์ค์ผ๋ก useJobs์ user๋ฅผ ์ ๋ฌํด์ค์ผ ํ๋ค.
// JobSection.tsx
export default function JobSection({
session,
}: {
session: Session | undefined;
}) {
const { pathname } = useRouter();
const isAdmin = pathname === '/admin';
const title = getTitle(pathname);
return (
<Wrapper>
<header>
<Title>{title}</Title>
{isAdmin && (
<Btn href={'/admin/new'}>
<AiOutlinePlusCircle />
</Btn>
)}
</header>
{/* <Filters /> */}
<JobList session={session} />
</Wrapper>
);
}
// JobList.tsx
export default function JobList({ session }: { session: Session | undefined }) {
const { pathname } = useRouter();
const isUser = pathname === '/user' || pathname === '/user/[id]';
const user = session?.user;
const { getFilteredJobs } = useJobs(isUser ? user : undefined);
const { isLoading, data: jobs } = getFilteredJobs;
...
return (
<Wrapper>
{jobs && jobs.map((job) => <JobItem key={job.id} job={job} />)}
</Wrapper>
);
}๋ํ JobItem์ ์ผ๋ฐ ์ฌ์ฉ์๋ ์ถ๊ฐ, ์ญ์ ๋ฒํผ์ ๋ณด์ฌ์ฃผ์ง ์์ง๋ง ๋ก๊ทธ์ธํ ์ ์ ์ ๊ฒฝ์ฐ /์์๋ ์ถ๊ฐ ๋ฒํผ, /user์ /admin ์์๋ ์ญ์ ๋ฒํผ์ด ์ถ๊ฐํด์ผ ํ๊ธฐ ๋๋ฌธ์ ํ์ด์ง ์์น์ ํจ๊ป ๋ก๊ทธ์ธ์ ํ๋์ง ์ ๋ฌด๋ฅผ ๊ณ ๋ คํด์ UI๋ก ๋ํ๋ด ์ฃผ์ด์ผ ํ๋ค. ๋ก๊ทธ์ธ ์ ๋ฌด๋ getServersideProps๋ก ์ ๋ฌ๋ฐ์ session์ ์ ๋ฌํด์ฃผ๊ธฐ ๋ณด๋ค useSession hook์ ์ด์ฉํด CSR์์ ๋ฐ์์ ํ์ธํ๋ค.
/์์ ์ผ๋ฐ ์ฌ์ฉ์: ๋ฒํผ์ด ๋ณด์ด์ง ์์/์์ ๋ก๊ทธ์ธํ ์ฌ์ฉ์: ์ถ๊ฐ ๋ฒํผ์ด ๋ณด์ฌ/user์/admin์์ ๋ก๊ทธ์ธํ ์ฌ์ฉ์: ์ด๋ฏธ ๋ฆฌ๋ค์ด๋ ์ ์ผ๋ก ๋ก๊ทธ์ธํ ์ฌ์ฉ์๋ ํ์ธ์ด ๊ฐ๋ฅํ๊ธฐ ๋๋ฌธ์/ํ์ด์ง๊ฐ ์๋๋ฉด ์ญ์ ๋ฒํผ์ด ๋ณด์ฌ
export default function JobItem({ job }: { job: Job }) {
const { name, platform, img, checkPercentage } = job
const { pathname, push } = useRouter()
const isHome = pathname === "/"
const [message, setMessage] = useState("")
const { data: session } = useSession()
const user = session?.user
const isLoggedin = !!session
const { addOrUpdateJob, deleteJob } = useJobs(user)
const handleDelete = () => {
deleteJob.mutate(job, {
onSuccess: () => {
setMessage("์ฑ๊ณต์ ์ผ๋ก ์ ๊ฑฐํ์ต๋๋ค")
},
onSettled: () => {
setTimeout(() => setMessage(""), 4000)
},
})
}
const handleAdd = () => {
addOrUpdateJob.mutate(job, {
onSuccess: () => {
setMessage("์ฑ๊ณต์ ์ผ๋ก ์ถ๊ฐํ์ต๋๋ค")
},
onSettled: () => {
setTimeout(() => setMessage(""), 4000)
},
})
}
const handleClick = () => {
const link = redirectPath(pathname, job.id)
push(link)
}
const over50Percent = checkPercentage >= 0.5
return (
<>
<Wrapper>
{over50Percent && <Badge>50% ์ด์</Badge>}
{!isHome && (
<Btn onClick={handleDelete}>
<MdRemove />
</Btn>
)}
{isHome && isLoggedin && (
<Btn onClick={handleAdd}>
<AiOutlinePlus />
</Btn>
)}
<ImgBox onClick={handleClick}>
<Img
src={img}
alt="job"
sizes='(max-width: 768px) 100vw,
(max-width: 1200px) 50vw,
33vw"'
fill
priority
/>
</ImgBox>
<MetaBox>
<h1>{name}</h1>
<h3>{platform}</h3>
</MetaBox>
</Wrapper>
{message && <Modal message={message} />}
</>
)
}โ ์ ์ฒด ๊ณต๊ณ ๋ฅผ ์ถ๊ฐ,์ญ์ ํ ์ ์๋ adminForm
๊ธฐ์กด์ ํฌ๋กค๋ง ๋ฐฉ์์ ์์ ์ ๊ฑฐํ๋ฉด์ ์๋กญ๊ฒ ์ถ๊ฐ, ์์ ํ ์ ์๋ form ํ์ด์ง๊ฐ ํ์ํ๋ค. form ํ์ด์ง์๋ ๊ธฐ์กด ์ฑ์ฉ๊ณต๊ณ ์ ๋ฐ์ดํฐ schema๋ฅผ ๋ชจ๋ ์์ฑํ ์ ์์ด์ผ ํ๋ค. schema์ ๋ด์ฉ์ ํ์ฌ๋ช , URL, ์ด๋ฏธ์ง, ํ๋ซํผ, ์ฃผ์ ์ ๋ฌด, ์๊ฒฉ ์๊ฑด, ์ฐ๋์ฌํญ์ผ๋ก ์๋์ผ๋ก ์ ์ด์ค์ผ ํ๋ค. ์ฐ์ ์ ์ผ์ผ์ด ์ ๋ ๋ฐฉํฅ์ผ๋ก ์ ํ๋ค. ์ดํ์ ์๋ก์ด ์ถ๊ฐํ๋ ๊ฒฝ์ฐ์๋ input์์ textArea๋ก ๋ณ๊ฒฝํด ๋ณต์ฌ-๋ถ์ฌ๋ฃ๊ธฐ๊ฐ ์ข ๋ ์ฝ๊ฒ ๋ ์ ์๋ ๋ฐฉํฅ์ผ๋ก ๊ณ ๋ฏผํ๊ณ ์๋ค.
์๋ก์ด ๊ณต๊ณ ๋ฅผ ์ถ๊ฐํ๋ ํ์ด์ง๋ /admin/new๋ก ์์ ํ ๋๋ /admin/[id]๋ก ์์ ์ด ๋ ์ ์๊ฒ routing์ ๊ฒฐ์ ํ๋ค. AdminForm ์ปดํฌ๋ํธ ๋ด์ ํจ์๋ค์ด ๋ง์ ์ปค์คํ
hook์ผ๋ก ๋ถ๋ฆฌํ๋ค. message ์ํ ๊ฐ์ ๊ฒฝ์ฐ mutate์ ํญ์ ๊ฐ์ด ๋ฐ๋ผ ๋ค๋๊ธฐ ๋๋ฌธ์ ์ฌ๋ฌ ์ปดํฌ๋ํธ์ ๋์ผํ๊ฒ ๋ํ๋, ์ด๋ป๊ฒ ํ๋ฉด ๋ฐ๋ณต์ ์ค์ผ ์ ์์์ง ์ข ๋ ๊ณ ๋ฏผ์ด ํ์ํ๋ค.
export default function AdminForm({ isNew, initialValue }: AdminFormProps) {
const { job, onAdd, onChange, onDelete, onUpdateDescription } =
useForm(initialValue)
const [message, setMessage] = useState("")
const DescriptionList: DescriptionListType[] = [
{
name: JOB_SCHEMA.MAIN_WORK,
title: "์ฃผ์ ์
๋ฌด",
value: job.mainWork,
},
{
name: JOB_SCHEMA.QUALIFICATION,
title: "์๊ฒฉ ์๊ฑด",
value: job.qualification,
},
{
name: JOB_SCHEMA.PREFERENTIAL,
title: "์ฐ๋ ์ฌํญ",
value: job.preferential,
},
]
const title = isNew ? "์๋ก์ด ๊ณต๊ณ ์ถ๊ฐํ๊ธฐ" : "๊ณต๊ณ ์์ ํ๊ธฐ"
const BtnText = isNew ? "์ถ๊ฐํ๊ธฐ" : "์์ ํ๊ธฐ"
const { addOrUpdateJob } = useJobs()
const { mutate } = addOrUpdateJob
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const { dataset } = e.currentTarget
if (dataset.tag !== "form") {
return
}
mutate(job, {
onSuccess: () => {
setMessage(
isNew ? "์ฑ๊ณต์ ์ผ๋ก ์ถ๊ฐ๋์์ต๋๋ค" : "์ฑ๊ณต์ ์ผ๋ก ์์ ๋์์ต๋๋ค"
)
},
onError: error => {
if (error instanceof AxiosError) {
const { response } = error
if (response) {
setMessage(`${response.statusText} ์๋ฌ๊ฐ ๋ฐ์ํ์ต๋๋ค`)
}
}
},
onSettled: () => {
setTimeout(() => {
setMessage("")
}, 4000)
},
})
}
return (
<Wrapper>
...
<form data-tag="form" onSubmit={handleSubmit}>
<AdminFormItem
name={JOB_SCHEMA.NAME}
title="ํ์ฌ ๋ช
"
type="text"
value={job.name}
onChange={onChange}
/>
<AdminFormItem
name={JOB_SCHEMA.URL}
title="URL"
type="text"
value={job.url}
onChange={onChange}
/>
<AdminFormItem
name={JOB_SCHEMA.IMG}
title="์ด๋ฏธ์ง"
type="text"
value={job.img}
onChange={onChange}
/>
<Select onChange={onChange} platform={job.platform} />
{DescriptionList.map(item => (
<AdminDescriptionList
name={item.name}
title={item.title}
value={item.value}
onAdd={onAdd}
onDelete={onDelete}
onChange={onUpdateDescription}
/>
))}
<Btn>{BtnText}</Btn>
</form>
{message && <Modal message={message} />}
</Wrapper>
)
}
//useForm.tsx
export const useForm = (initialValue: Job) => {
const [job, setJob] = useState<Job>(initialValue)
const onAdd = (name: DescriptionNameType) => {
setJob(prev => {
const list = prev[name]
const newItem: DescriptionType = { text: "", checked: false, id: uuid() }
return { ...prev, [name]: [...list, newItem] }
})
}
const onDelete = (name: DescriptionNameType, id: string) => {
setJob(prev => {
const list = prev[name].filter(item => item.id !== id)
return { ...prev, [name]: list }
})
}
const onChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.currentTarget
setJob(prev => ({ ...prev, [name]: value }))
}
const onUpdateDescription = (
name: DescriptionNameType,
value: string,
id: string
) => {
setJob(prev => {
const updated = prev[name].map(item => {
if (item.id === id) {
return { ...item, text: value }
}
return item
})
return { ...prev, [name]: updated }
})
}
return { job, onAdd, onDelete, onChange, onUpdateDescription }
}์์ ๊ฐ์ด ๊ธฐํ์ ์์ ํ ํ์ ํํ์ด์ง๋ฅผ ๊ตฌ์ฑํ์ ๋ ๋ค์๊ณผ ๊ฐ์ด ๋ํ๋ฌ๋ค.
[ํํ์ด์ง ( /) , ์ ์ฒด ๊ณต๊ณ ์ ์์ธ ํ์ด์ง (/jobs/:id) ]
[์ ์ ๋ณ ํ์ด์ง ( /user ), ์ ์ ๋ณ ์์ธ ํ์ด์ง (/user/:id) ]
[admin ํ์ด์ง ( /admin ), admin ์์ธ ํ์ด์ง (/admin/:id) ]
๐จ ๋์์ธ ์์
๋์์ธ์ ๋ํ ํผ๋๋ฐฑ์ ๋ฐ๊ณ ์ฒ์์๋ ์ ํด๋๋ค๊ณ ์๊ฐํ์๋๋ฐ ๋น์ด ๋ณด์ธ๋ค๋ ์๊ฐ์ด ๋ง์ด ๋ค์๋ค. ๋จผ์ ์๊ฐ์ด ๋ ๋ถ๋ถ์ ๋ฐฐ๊ฒฝ๊ณผ ์ปจํ ์ธ ์ ์์ด ๋๊ฐ๊ธฐ ๋๋ฌธ์ ๋น์ด ๋ณด์ด๋ ๊ฒ ํฌ๋ค๋ ์๊ฐ์ด ๋ค์ด์ ์ปจํ ์ธ ์ ๋ฐฐ๊ฒฝ ์์ ๊ตฌ๋ถํ๊ณ ๊ธฐ์กด ๋ฉ์ธ ํ์ด์ง๋ถํฐ ์์๋๋ก ์๋ณด๊ธฐ ์์ํ๋ค.
๋ฉ์ธ ํ์ด์ง
๊ธฐ์กด์ ๋ฉ์ธ ํ์ด์ง๋ ํฌ๋กค๋ง์ ํ ์ ์๋ form์ด ์์ด์ ์ปจํ ์ธ ๊ฐ ๋ง์ด ์์ด๋ ๊ด์ฐฎ์์ง๋ง form์ด ์ฌ๋ผ์ง๊ณ ๋ ๋ค์๋ ํ์ ํจ์ด ๋ ์ปค ๋ณด์๋ค. ๊ทธ๋์ ์ฐ์ ์ ํ์ ์๋ ๋ฐฐ๋๋ ์์ ๊ณ ๊ฐ jobItem์ ํฌ๊ธฐ๋ฅผ ํค์์ ๋ณด๋ค ๊ณต๊ณ ๊ฐ ์ ๋ณด์ด๊ฒ ์์ ํ๋ค. ์ฌ๊ธฐ์ ํํ์ด์ง ๋์์ธ์ ์ถ๊ฐํ๋ค๋ฉด ๋ชจ๋ ๊ณต๊ณ ๋ก ํ์ด์ง๋ฅผ ๋ถ๋ฆฌํ๊ณ ๋ฉ์ธ ํ์ด์ง์์ ์ฌ์ฉ๋ฒ์ ๋ํด์ ๋ํ๋ด ์ฃผ๋ฉด ์ข ๋ ์ข์ง ์์๊น ์๊ฐ๋ ๋ค์๋ค. ๊ณต๊ณ ๊ฐ ๋ง์์ง๋ฉด ํ์ด์ง๋ค์ด์ ์ด๋ ๋ฌดํ ์คํฌ๋กค์ ์ถ๊ฐํด์ ๊ธฐ๋ฅ์ ์ธ ๋ณด์ถฉ๋ ํ์ํ ๊ฒ ๊ฐ๋ค.
[๋ฉ์ธ ํ์ด์ง]
์์ธ ํ์ด์ง
๊ธฐ์กด ์์ธ ํ์ด์ง์ ๋ฌธ์ ์ ์ ์ฌ์ง๊ณผ ์ฑ์ฉ๊ณต๊ณ ์ ์ฃผ์์ ๋ฌด๋ฅผ ํ ์ค์ ๋ํ๋ด๋ค ๋ณด๋ ์ฌ์ง์ ํฌ๊ธฐ๋ ์ฃผ์ ์ ๋ฌด์ ์์ ๋ฐ๋ผ ๋ ์ด์์์ ๋ฌธ์ ๊ฐ ์๊ฒผ๋ค. ์ด๊ฒ์ ํด๊ฒฐํ๊ธฐ ์ํด์ ์ฐ์ ์ ๋ชจ๋ ์ธ๋ก๋ก ๋ ์ด์์์ ๋ณ๊ฒฝํ๊ณ , ์ด๋ฏธ์ง ํฌ๊ธฐ๋ ๊ณ ์ ์์ผ๋์๋ค. ์ด๋ ๊ฒ ๋ํ๋ด๋ฉด ๊ฐ๋ก๋ก ๋๋ฌด ๋น๊ฒ ๋๋ ๊ฒ ๋ฌธ์ ๊ฐ ์๊ฒผ๋๋ฐ, ์ด์ ์ ํ๋ก๊ทธ๋๋จธ์ค์ ์ํฐ๋ ํ์ด์ง๋ฅผ ์ฐธ๊ณ ํด Sidebox ์ปดํฌ๋ํธ๋ฅผ ์ถ๊ฐํ๋ค. Sidebox๋ sticky๋ฅผ ์ด์ฉํด ์ฌ์ฉ์์ ์คํฌ๋กค ์์น์ ์๊ด์์ด ๊ณ์ํด์ ๋ณด์ฌ ์ฃผ๋ฉด ์ข์ ๊ฒ ๊ฐ์ ํ์ฌ์ด๋ฆ๊ณผ ์ถ๊ฐ ๋ฒํผ์ ๋ด์๋ค.
[ํ๋ก๊ทธ๋๋จธ์ค ์ฑ์ฉ๊ณต๊ณ ํ์ด์ง]
[์์ ํ ์์ธ ํ์ด์ง ]

AdminForm
AdminForm์ผ๋ก ๋ช ๊ฐ์ ์ฑ์ฉ๊ณต๊ณ ๋ฅผ ์ง์ ์ฌ๋ฆฌ๋ฉด์ ํ์ฌ ๋์์ธ์ ๊ณต๊ณ ์ ๋ด์ฉ์ ๋ด๊ธฐ์ ๊ฐ๋
์ฑ๋ ๋จ์ด์ง๊ณ ์ผ์ผ์ด ์ฎ๊ฒจ์ผ ํ๋ ๋ถํธํจ๋ ์์๋ค. ์ด์ ์ ํด๊ฒฐํ๊ธฐ ์ํด์ ๋์์ธ์ ์์ธ ํ์ด์ง ๋์ ๊ฐ์ด ์ธ๋ก๋ก ๋ ์ด์์์ ๋ฐ๊พธ๊ณ , ํญ๋ชฉ ํ๋ํ๋๋ฅผ ๋ด๋ <input/>์ด ์๋๋ผ ํต์ผ๋ก ๋ณต์ฌ, ๋ถ์ฌ๋ฃ๊ธฐ ํ ์ ์๊ฒ <textarea/>๋ก ํ๊ทธ๋ฅผ ๋ฐ๊ฟ์ ํธ์์ฑ์ ๋์๋ค. ๊ธฐ์กด input์ผ๋ก ํ์
๊ณผ ๋ชจ๋ ํจ์๋ฅผ ์ง๋จ๊ธฐ ๋๋ฌธ์ ์ํ๋ฅผ ๋ฐ๋ก ์ถ๊ฐํด์ ์ดํ์ submitํ ๋ normalize์ํค๋ ๋ฐฉํฅ์ผ๋ก ์์
์ ์งํํ๋ค.
value๊ฐ์ด textArea๋ string์ด๊ณ ๊ธฐ์กด ๋ฐ์ดํฐ๋ ๋ฐฐ์ด์ด์ด์ type์ ์ด์ฉํด ๋ก์ง์ ๊ตฌ๋ถ ์ํฌ ์ ์์๋ค.
// AdminDescriptionList.tsx
export default function AdminDescriptionList({
name,
title,
value,
onAdd,
onDelete,
onChange,
onNewDescriptionChange,
}: AdminDescriptionListProps) {
const isString = typeof value === "string"
return (
<Wrapper>
...
{isString && (
<TextArea
text={value}
name={name}
onChange={onNewDescriptionChange}
></TextArea>
)}
{!isString && (
<ul>
{value.map(item => (
<AdminDescriptionItem
key={item.id}
name={name}
item={item}
onChange={onChange}
onDelete={onDelete}
/>
))}
</ul>
)}
</Wrapper>
)
}
// TextArea.tsx
export default function TextArea({ name, text, onChange }: TextAreaType) {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const { value } = e.currentTarget
onChange(name, value)
}
return <Wrapper required value={text} onChange={handleChange}></Wrapper>
}ํฌ๋กค๋ง์ผ๋ก ๋ฌธ์์ด์ ์ฒ๋ฆฌํ ๋๋ ๋๋ฌด ๋ง์ ์ ์ฝ์ด ์์์ง๋ง ์ง์ ๋ณต์ฌ, ๋ถ์ฌ ๋ฃ๊ธฐ๋ฅผ ํ๋ค๊ณ ํ์ ๋๋ ํด๋น ๋ถ๋ถ์ ๋ด์ฉ๋ง ๊ฐ์ ธ์ค๋ฉด ๋์, โข ์ -๋ฅผ ์ ๊ฑฐํด์ฃผ๊ณ ๋์ด์ฐ๊ธฐ๋ก split๋ง ํ๋ฉด ๊ฐ๋จํ๊ฒ ๋ฐ์ดํฐ๋ฅผ ๊ฐ๊ณตํ ์ ์์๋ค. ์ด๋ ๊ฒ ๊ฐ๊ณต๋ ๋ฐ์ดํฐ๋ฅผ ๊ธฐ์กด handleSubmit๊ณผ ์ฐ๊ฒฐํ ๋, normalizeํ ๋ฐ์ดํฐ๋ฅผ ๋ค์ setJob์ผ๋ก ์
๋ฐ์ดํธ ์ํค๋ ๊ฒ ์๋๋ผ ๊ทธ๋๋ก ๊ฐ์ ์ฌ์ฉํด์ API๋ฅผ ํธ์ถํ๋ค. ์ด๋ ๊ฒ ์ฒ๋ฆฌํ ์ด์ ๋ setState๋ก ์ํ๋ฅผ ์
๋ฐ์ดํธํ๊ณ mutate๋ฅผ ํ๋ฉด ๋๊ธฐ์ ์ฝ๋์ง๋ง setState๊ฐ ๋น๋๊ธฐ์ ์ผ๋ก ์ฒ๋ฆฌ๋ ์ ์๊ธฐ ๋๋ฌธ์ด์๋ค.
// normalizeDescription.ts
type RawDescriptionsType = {
mainWork: string;
qualification: string;
preferential: string;
};
type NormalizedDescriptionsType = {
mainWork: DescriptionType[];
qualification: DescriptionType[];
preferential: DescriptionType[];
[index: string]: DescriptionType[];
};
export const normalizeDescriptions = (
descriptions: RawDescriptionsType
): NormalizedDescriptionsType => {
const result: NormalizedDescriptionsType = {
mainWork: [],
qualification: [],
preferential: [],
};
const reg = /[โข-]/gi;
for (const [key, value] of Object.entries(descriptions)) {
const text = value.replace(reg, '').trim();
const items = text.split('\n');
const normalizedItems = items.map((item) => {
return { id: uuid(), text: item, checked: false };
});
result[key] = normalizedItems;
}
return result;
};
// AdminForm.tsx
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const { dataset } = e.currentTarget;
if (dataset.tag !== 'form') {
return;
}
let targetJob = job;
if (isNew) {
const normalizedDescriptions = normalizeDescriptions(descriptions);
targetJob = { ...job, ...normalizedDescriptions };
}
mutate(targetJob, {...})
};[์์ ํ ์ ์ฒด๊ณต๊ณ ์ถ๊ฐ ํ์ด์ง (/admin/new)]

๋ง์น๋ฉฐ
์์ง ํ๊ณ ์ถ์ ๊ฒ๋ ๋ถ์กฑํ ๊ฒ๋ ๋ง์ ํ๋ก์ ํธ๋ผ ๋งค๋ฒ ์๋ก์ด ์๋๋ค์ ํ ๋ ์ฆ๊ฒ๋ค. ๋ฌผ๋ก ํ์ค์ ์ด๋ ฅ์๋ฅผ ์ฐ๊ณ ๋จ์ด์ง๋ ๋ ๋ค์ ์ฐ์์ด์ง๋ง ๋ฌด์กฐ๊ฑด ๊ฐ๋ฐ์๊ฐ ๋๋ค๋ ์๊ฐ์ผ๋ก ์ง๊ธ ๋ด๊ฐ ์๋ ์๋ฆฌ์์ ๋ ์ํ ์ ์๋ ๋ฐฉ๋ฒ๋ค์ ๋ฐ์ํ๋ค ๋ณด๋ฉด ์ ๋ง ์ํ๋ ํ์ฌ์์ ๋ด๊ฐ ์ํ๋ ์๋น์ค๋ฅผ ๋ง๋ค๊ณ ์์ง ์์๊น. ๋ด์ผ ๋๋ฅผ ํ๋ฒ ๋ ๋ฏฟ์ด๋ณด๊ฒ ๋ค๋ ๋ง์ผ๋ก ๊ฐ๋ฐ์ ์ฆ๊ธฐ๋ฉฐ ์ด ์๊ฐ์ ๋ฒํ
จ๋๊ฐ๋ ค ํ๋ค.