RB's Ground
는 웹 개발 공부 겸 망가져도 괜찮은 장난감으로 만든 개인 블로그 사이트이며, 공부 도중 만난 문제와 해결 과정을 기록합니다.개발 환경
- OS :
Windows
- Tools :
VS code
,Node.js@18.x
,TypeScript@4.7.x
- Stacks :
Next.js@12.2.x
,Tailwind CSS@3.x
,Prisma@4.13.x(mySQL)
,PlanetScale
, etc.- Deploy :
Vercel
- Github : https://github.com/Real-Bird/my-ground
- URL : https://real-bird.vercel.app/
일전에 사용하던 카테고리
는 게시물 하나당 한 개만 기록하도록 했었다. 처음 만들 당시, 여러 개를 적용하는 방법도 몰랐고, 필요성도 못 느꼈다. 그러나 Velog
나 네이버 블로그
등의 태그를 사용하면서 여러 개 적용되는 기능을 구현해 보고 싶어졌다. 추후 카테고리
를 통한 필터 기능을 위해서라도 해당 기능은 필요했다.
먼저 prisma
의 스키마를 다 대 다
형태로 변경했다. 단순히 릴레이션을 양쪽으로 연결하고 배열로 정의하면 되는 줄 알았으나, 내가 사용한 prisma - my SQL
의 경우 다 대 다 릴레이션을 지원하지 않았다.
many to many 공식 문서에서는 두 가지 방법으로 다 대 다
릴레이션을 알려줬다. 먼저 시도한 것은 명시적 다 대 다(explicit many to many)
였다.
model MyBlog {
id Int @id @default(autoincrement())
category CategoriesOnBlog[]
}
model Category {
id Int @id @default(autoincrement())
title String
content String @db.LongText
category String
posts CategoriesOnBlog[]
}
model CategoriesOnBlog {
blog MyBlog @relation(fields: [blogId], references: [id])
blogId Int
category Category @relation(fields: [categoryId], references: [id])
categoryId Int
assignedAt DateTime @default(now())
@@id([postId, categoryId])
}
그러나 DB
에 저장하는 방법을 몰라 이 모델을 사용하는 일은 없었다. 교량이 되는 CategoriesOnBlog
에 blogID
와 categoryId
를 알려줘야 하는데, 아직 생성하지 않은 게시물과 카테고리의 아이디를 어떻게 얻어야 할지 몰랐다.
구글링해 본 바로는, 게시글과 태그를 생성한 후 각각의 아이디를 연결하면 된다고. 그러나 작성할 코드가 떠오르지 않아 포기했다.
대신 암시적 다 대 다(Implicit many to many)
로 릴레이션을 변경했다.
model MyBlog {
id Int @id @default(autoincrement())
title String
content String @db.LongText
category Category[]
}
model Category {
id Int @id @default(autoincrement())
category String
posts MyBlog[]
}
릴레이션을 통한 캐스케이드는 불가능했지만(sql
은 지원하지 않는다고 했다), 로직을 생각하기에는 훨씬 편했다. 게시물은 그냥 생성하고, 카테고리에 없으면 생성 후 연결, 있으면 찾아서 연결하면 되니까.
내가 생각한 Categories
의 기능은 다음과 같다.
x
를 클릭하면 제거된다.x
를 클릭하면 추가된 모든 카테고리가 지워진다.이 부분은 이전에 작성한 글인 조잡한 검색 리스트 만들기와 자동 완성 검색 리스트 구현을 참고해서 구현했다.
const categories = ["TypeScript","JavaScript","HTML5","Next.js","React","Git","Node.js"];
const Categories = () => {
const [newCategory, setNewCategory] = useState("");
const [newCategories, setNewCategories] = useState<string[]>([]);
const [isInputFocused, setIsInputFocused] = useState(false);
const [isLiOver, setIsLiOver] = useState(false);
return (...)
}
더미 데이터로 쓸 배열과 여타 변수이다. newCategory
는 새로 입력하는 카테고리, newCategories
는 포스트에 추가되는 카테고리들, isInputFocused
는 입력 박스에 포커스가 왔는지 여부, isLiOver
는 기존 카테고리 리스트에 마우스를 올렸는지 여부를 의미한다.
const onSearchCategory = ({
currentTarget: { value },
}: ChangeEvent<HTMLInputElement>) => {
setNewCategory(value);
};
const onAddExistedCategory = ({
currentTarget: { textContent },
}: MouseEvent<HTMLLIElement>) => {
setNewCategories((prev) => Array.from(new Set([...prev, textContent])));
setIsInputFocused(false);
};
onSearchCategory
는 onChange
에 해당하고, onAddExistedCategory
는 기존 카테고리 목록을 눌렀을 때 추가되는 함수이다. 카테고리가 중복 추가되지 않도록 new Set()
을 사용했다. 추가하면 기존 목록이 닫힌다.
const onAddNewCategory = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter" || !e.currentTarget.value) return;
setNewCategories((prev) => Array.from(new Set([...prev, e.currentTarget.value])));
setNewCategory("");
};
직접 입력한 카테고리를 추가하는 함수이다. enter
를 눌러 추가하기 위해 keyDown
이벤트로 매칭했다. 역시 중복을 방지하기 위해 Set
을 사용했다.
작성하고 보니 문제가 있었다. 예를 들면, TypeScript
와 typescript
는 같은 카테고리지만 대소문자가 달라 추가로 입력된다. 이러한 점을 방지하기 위해 체크 코드를 추가했다.
const currentCategory = e.currentTarget.value
.toLowerCase()
.replace(/[\W ]/, "");
const exsitedCategory = categories.map((item) => item.category.toLowerCase().replace(/[\W ]/, ""));
const checkedCategory = exsitedCategory.includes(currentCategory)
? categories.find((item) =>
item.category.toLowerCase().replace(/[\W ]/, "") === currentCategory
).category
: newCategory;
현재값을 소문자로 변경하고, 공백과 특수문자를 제거했다. 더미 배열도 같은 조건으로 만들었다. 마지막으로 현재값이 더미 배열에 존재하면 더미 배열에서 같은 값을 찾아 할당한다. 없다면 새 값을 할당한다. 이렇게 나온 checkedCategory
를 e.currentTarget.value
대신 setNewCategories
에 추가했다.
{isInputFocused ? (
<ul
className="absolute z-10 max-h-60 min-h-fit w-full divide-y-2 overflow-y-scroll rounded-md border border-gray-300"
id="category"
>
{categories.map(({ id, category }) => (
<li
key={id}
className="w-full cursor-pointer bg-white px-3 py-2 placeholder-gray-400 shadow-sm hover:bg-gray-300"
onClick={onAddExistedTag}
onMouseOver={() => setIsLiOver(true)}
onMouseLeave={() => setIsLiOver(false)}
hidden={
!category
.toLowerCase()
.replace(/[\W ]/, "")
.includes(newCategory.toLowerCase().replace(/[\W ]/, ""))
}
>
{category}
</li>
))}
</ul>
) : null}
입력 박스에 포커스가 되면 목록이 나타난다. li
의 hidden
은 입력한 알파벳/낱말이 포함된 카테고리 외에는 숨긴다. 나머지는 위에서 설정한 함수 등을 적소에 배치한 것이다.
UI
부분은 react-select
의 CreateReactSelect
를 참고했다.
interface BadgeProps {
label: string;
onClick?: () => void;
hasCancel?: boolean;
}
export const Badge = ({ label, onClick, hasCancel }: BadgeProps) => {
return (
<div className="mx-0.5 flex flex-wrap items-center justify-center rounded-md bg-amber-400 py-0.5 px-1 hover:bg-amber-500">
<div className="px-1 text-sm">{label}</div>
{hasCancel && (
<div className="cursor-pointer px-0.5" onClick={onClick}>
<svg
height="14"
width="14"
viewBox="0 0 20 20"
aria-hidden="true"
focusable="false"
className="h-4 w-4"
>
<path d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"></path>
</svg>
</div>
)}
</div>
);
};
특별할 것 없는 뱃지 UI다.
<div className="relative">
<div
className={cls(
isInputFocused ? "border-amber-400 ring-2 ring-amber-400" : "",
"relative box-border flex flex-1 flex-wrap items-center overflow-hidden rounded-md border-2 border-gray-300 py-0.5 px-2 shadow-sm"
)}
>
{newCategories.map((tag, idx) => (
<Badge
key={tag}
label={tag}
hasCancel
/>
))}
<div className="visible m-0.5 box-border inline-grid flex-1 grid-cols-2 py-0.5 text-gray-500 outline-none">
<input
id="categories"
type="text"
autoComplete="off"
className="m-0 w-full min-w-[2px] border-0 border-transparent p-0 text-inherit opacity-100 focus:border-transparent focus:ring-0"
/>
</div>
<div className="cursor-pointer px-0.5">
<svg
height="14"
width="14"
viewBox="0 0 20 20"
aria-hidden="true"
focusable="false"
className="h-6 w-6"
>
<path d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"></path>
</svg>
</div>
</div>
</div>
코드를 작성하면서 새로운 점을 두 가지 배웠다. relative
안에 relative
를 사용하면 위치를 정하지 않아도 순차적으로 배치된다. 카테고리가 하나도 없을 때는 input
이 가장 앞에 있지만, 추가될 때마다 뱃지가 그 앞을 차지하고, input
은 옆으로 밀린다.
다른 하나는 Tailwind CSS
에서 input
태그의 포커스 테두리를 제거할 때 focus:ring-0
을 설정한다. outline:none
을 사용해 일반적인 CSS
를 사용할 수도 있었지만, style
끼어 있는 게 꼴받아서 찾아봤더니 만족스런 결과물이 나왔다.
const onDeleteCategory = (idx: number) => {
setNewCategories((prev) => prev.filter((_, i) => i !== idx));
};
{newCategories.map((tag, idx) => (
<Badge
key={tag}
label={tag}
onClick={() => onDeleteCategory(idx)}
hasCancel
/>
))}
비교적 쉬웠다. 인덱스를 onDeleteCategory
에 보내서 그 인덱스를 제외한 나머지를 필터했다.
const onAllDeleteCategories = () => {
setNewCategories([]);
};
당연히 카테고리 전부 비우는 것도 쉬웠다. 빈 배열 넣어주면 되니까.
이렇게 전반적인 UI 작업은 마무리지었다.
이제 api
로 입력한 카테고리 리스트를 보내 DB에 올리는 작업을 복기해 보자.
가장 고민하게 만들었던 점은 이미 DB에 있는 카테고리와 어떻게 분리하여 연결할 것인가였다. 일단 create
해 보았더니 같은 카테고리여도 새로 생성되어 난감했다.
기존의 것과 연결하려면 connect
를 해야 했는데, 카테고리 ID를 어떻게 가져올지 감이 안 잡혔다. 그래서 다음과 같은 로직을 떠올렸다.
connect
한다.create
한다.// (1)
const allCategories = await client.category.findMany({});
// (2)
const existedCategories = allCategories.filter((item) => categories.includes(item.category)).map((item) => ({ ...item }));
// (3)
const nonExistedCategories = categories.filter((existed) => !allCategories.map((item) => item.category).includes(existed));
existedCategories
는 id
가 필요하기 때문에 객체 배열로 할당했으며, nonExistedCategories
는 카테고리 명만 있으면 되었기에 문자 배열로 할당했다.
구분한 정보를 blog
와 연결한다.
const post = await client.myBlog.create({
data: {
title,
content,
category: {
create: nonExistedCategories.map((category) => ({ category })),
connect: existedCategories.map((category) => ({ id: category.id })),
},
},
});
다 대 다
로 연결하기 때문에 create
와 connect
에 배열을 값으로 입력했다.
로직은 업로드와 크게 다르지 않지만, 하나를 더 추가해야 했다. 바로 게시물에서 제거한 카테고리 목록의 연결을 끊는 것. 처음에는 단순히 deleteMany
를 하면 되겠다 싶었다. 결과는 당연하게도 카테고리 자체가 지워졌다. 해서 다시 몇 가지 로직을 생각했다.
connect
, create
, disconnect
한다.'connect
가 있으니 disconnect
도 있지 않을까'하는 마음에 공식 문서를 찾아보니 역시 있었다. 그러니 코드가 수월해졌다.
// (1) 모든 카테고리 정보
const allCategories = await client.category.findMany({});
// (2) 게시글과 연결된 카테고리 정보
const existedCategories = await client.category.findMany({
where: {
posts: {
some: {
id: +id,
},
},
},
});
// (3)-3 삭제할 카테고리 disconnect
const deleteCategories = existedCategories.filter(
(item) => !categories.includes(item.category)
);
// 요청값에는 있으나 (2)에 없는 카테고리 (신규 구분)
const newCategories = categories.filter(
(category) =>
!existedCategories.map((item) => item.category).includes(category)
);
// (3)-2 신규이나 이미 (1)에 있는 카테고리 connect
const alreadyCategories = allCategories
.filter((item) => newCategories.includes(item.category))
.map((item) => ({ ...item }));
// (3)-1 완전히 새로운 카테고리 create
const nonExistedCategories = newCategories.filter(
(existed) => !allCategories.map((item) => item.category).includes(existed)
);
구분한 정보로 (4)를 작성했다.
const updatePost = await client.myBlog.update({
where: {
id: +id,
},
data: {
title,
content,
category: {
disconnect: deleteCategories.map((category) => ({ id: category.id })),
create: nonExistedCategories.map((category) => ({
category,
})),
connect: alreadyCategories.map((category) => ({ id: category.id })),
},
},
});
이것으로 내가 원했던 카테고리 기능을 완성했다.
아직 부족한 게 많이 있지만, 차츰차츰 수정하고 있다. 카테고리 변경 시 게시글에 즉각 반영되지 않는 이유는 SSG
이기 때문... 시간 지나서 다시 빌드되면 생길 것이다. 아니면 나중에 revalidate
설정해야지.
프로덕션에서 먹히지 않는 스타일도 해결해야겠다. 분명 리스트에 마우스를 대면 색 바뀌게 해놨는데. 개발할 때는 되던 게 배포에서 안 되면 참 난감하다.
아무튼 이것으로 카테고리(태그) 기능 구현을 마무리한다.