나의 개발 일지

카테고리 구조 개발하기 - 백엔드, 프론트엔드 (디자인 마켓플레이스 프로젝트)

designer DK 2024. 12. 7. 22:00
728x90

새로운 프로젝트를 시작하며 (디자인 마켓프레이스)

대부분 직전에 진행해보았던 쇼핑몰 프로젝트와 유사했고

그 과정과 유사하게 많은 부분들을 진행 중이다.

 

하지만 기존 쇼핑몰에서 내가 패스하고 지나갔던 게 바로 카테고리 부분이었는데

디자인 마켓플레이스 프로젝트의 경우 이 카테고리 부분이 매우 중요해서

초기 설계부터 잘 해두고 진행하고 싶었다.

 

그래서 나의 새로운 코딩 선생님. Cursor와 함께 이 카테고리 설계를 체계적으로 진행해보았다.

사실 이게 대략하려면 크게 안어려울 부분 같았으나, 나름 체계적으로 구축하려다보니 꽤 복잡하고 어려웠다.

 

내가 나에게 요청하는 카테고리 요구사항은 이러했다.

- 메인카테고리가 있고 그 하위로 서브카테고리가 있다. (서브카테고리가 필터 역할도 할 것)

- 메인카테고리는 하나만 선택 가능하고 서브카테고리는 복수 선택 가능하다.

- 에셋(상품)마다 가지는 속성들이 있고 assetType이라는 것에 의해 약간씩 에셋 속성이 달라진다.
  (ex. motion graphic 에셋의 경우 듀레이션과 용량 등의 속성 추가 (assetType : video))

- 그 assetType은 에셋을 최초 생성할 때 메인카테고리 설정에 의해 결정된다.

 

이렇게 꽤 복잡한 구조를 소화하기 위해서 일단 백엔드 모델에서 Category라는 모델을 따로 만들어주었다.

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const categorySchema = Schema({
name: {
type: String,
required: true
},
slug: {
type: String,
required: true,
unique: true
},
parent: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Category',
default: null
},
displayOrder: {
type: Number,
default: 0
},
isActive: {
type: Boolean,
default: true
},
// 해당 카테고리에서 사용 가능한 에셋 타입
assetTypes: [{
type: String,
enum: ['basic', 'image', 'video', 'uimotion']
}]
}, {
timestamps: true
});

categorySchema.index({ parent: 1 });
categorySchema.index({ slug: 1 });

module.exports = mongoose.model('Category', categorySchema);

 

 

categoryController 코드

const Category = require("../models/Category");

const categoryController = {};

categoryController.getMainCategory = async (req, res) => {
try {
const mainCategory = await Category.find({ parent: null });
res.status(200).json({ status: "success", mainCategory });
} catch (error) {
res.status(400).json({ status: "fail", error: error.message });
};
};

categoryController.getSubCategories = async (req, res) => {
const { parentId } = req.params;
try {
// 부모 카테고리 먼저 조회
const parent = await Category.findById(parentId);
if (!parent) {
return res.status(404).json({
status: 'fail',
error: 'Parent category not found'
});
}

// 서브카테고리 조회
const subCategories = await Category.find({ parent: parentId });
 
// 부모의 assetTypes를 각 서브카테고리에 추가
const subCategoriesWithAssetTypes = subCategories.map(sub => ({
...sub.toObject(),
assetTypes: parent.assetTypes
}));

res.status(200).json({
status: 'success',
subCategories: subCategoriesWithAssetTypes
});
} catch (error) {
res.status(400).json({ status: "fail", error: error.message });
}
};

module.exports = categoryController;

 

 여기서 getMainCategory 메서드는 요청이 왔을 때 페어런트가 없는, 즉 최상위 카테고리들을 찾아서 응답을 주는 방식.

 

getSubCategories의 경우 url params에 부모 아이디 값(parentId)를 통해 찾아서 응답함으로써

상위 카테고리에 종속되도록 했다.

// 부모의 assetTypes를 각 서브카테고리에 추가
const subCategoriesWithAssetTypes = subCategories.map(sub => ({
...sub.toObject(),
assetTypes: parent.assetTypes
}));

 

이런 코드를 추가함으로써 상위 카테고리의 에셋타입도 그대로 가져오는 방식을 취했다.

 

아래는 카테고리 라우터 코드

const express = require("express");
const categoryController = require("../controllers/categoryController");
const router = express.Router();

router.get("/getMainCategory", categoryController.getMainCategory);
router.get('/getSubCategories/:parentId', categoryController.getSubCategories);

module.exports = router;

 

문제는 이렇게 세팅해준 백엔드 카테고리를 어떻게 쓸건지에 대한 부분이다.

보면 페어런트 ID도 있고 한데 이는 이미 DB에 등록이 되어야 사용가능하다는 말이다.

그말인즉슨, 카테고리 목록들은 기존에 DB에 한번 등록해놓고 사용해야한다는 것이다.

 

그러기 위해 seed라는 개념을 커서가 제안해주었다.

원하는 카테고리 목록을 위 스키마에 맞게 작성한 후 seed라는 파일을 만들어서 실행함으로써

DB에 등록을 해놓고 시작하는 것이다.

 

그러기 위해 seed라는 폴더를 만들고 mainCategory, subCategory, seed라는 js파일을 새로 추가해주었다.

 

아래는 seed용 임시 파일들이다. (여기서 slug는 이후 url 주소 뒤에 붙을 때의 표기 방식)

const mainCategories = [
{
name: "Graphics",
slug: "graphics",
assetTypes: ['image'],
parent: null,
displayOrder: 1
},
{
name: "UX/UI",
slug: "uxui",
assetTypes: ['basic'],
parent: null,
displayOrder: 2
},
{
name: "Icons",
slug: "icons",
assetTypes: ['basic'],
parent: null,
displayOrder: 3
},
{
name: "Motion Graphics",
slug: "motion",
assetTypes: ['video'],
parent: null,
displayOrder: 4
},
{
name: "UI Motion",
slug: "uimotion",
assetTypes: ['uimotion'],
parent: null,
displayOrder: 5
}
];

module.exports = mainCategories;

 

const subCategories = [
{
name: "Abstract",
slug: "graphics-abstract",
parent: "graphics_id", // Graphics의 실제 MongoDB _id가 들어갈 자리
displayOrder: 1
},
{
name: "3D",
slug: "graphics-3d",
parent: "graphics_id",
displayOrder: 2
},
{
name: "Chat",
slug: "uxui-chat",
parent: "uxui_id", // UX/UI의 실제 MongoDB _id가 들어갈 자리
displayOrder: 1
},
{
name: "SNS",
slug: "uxui-sns",
parent: "uxui_id",
displayOrder: 2
},
{
name: "2D",
slug: "icons-2d",
parent: "icons_id", // Icons의 실제 MongoDB _id가 들어갈 자리
displayOrder: 1
},
{
name: "3D",
slug: "icons-3d",
parent: "icons_id",
displayOrder: 2
},
{
name: "Title",
slug: "motion-title",
parent: "motion_id", // Motion Graphics의 실제 MongoDB _id가 들어갈 자리
displayOrder: 1
},
{
name: "Loading",
slug: "uimotion-loading",
parent: "uimotion_id", // UI Motion의 실제 MongoDB _id가 들어갈 자리
displayOrder: 2
}
];

module.exports = subCategories;

 

서브카테고리는 에셋타입을 자신의 부모 메인카테고리로부터 승계받는다.

 

아래는 seed 코드

const mongoose = require('mongoose');
const Category = require('../models/Category'); // 카테고리 모델 경로
const mainCategories = require('./mainCategory'); // 메인 카테고리 초기 데이터 경로
const subCategories = require('./subCategory'); // 서브 카테고리 초기 데이터 경로

require("dotenv").config();

const mongoURI = process.env.LOCAL_DB_ADDRESS;

const seedCategories = async () => {
try {
await mongoose.connect(mongoURI);

// 기존 카테고리 삭제 (선택 사항)
await Category.deleteMany({});

// 메인 카테고리 삽입
const insertedMainCategories = await Category.insertMany(mainCategories);

// 서브 카테고리 삽입
const subCategoriesWithParentId = subCategories.map(subCategory => {
const parentCategory = insertedMainCategories.find(main => main.slug === subCategory.parent.replace('_id', ''));
return {
...subCategory,
parent: parentCategory._id,
assetTypes: parentCategory.assetTypes
};
});

await Category.insertMany(subCategoriesWithParentId);
console.log("Categories seeded successfully!");
} catch (error) {
console.error("Error seeding categories:", error);
} finally {
mongoose.connection.close();
}
};

seedCategories();

 

seed파일을 실행하면 일단 기존 카테고리 컬렉션을 비워주게되고

메인카테고리의 모든 내용을 인서트한다.

그리고 서브카테고리의 경우 일단 slug에 적힌 부분과 부모카테고리 _id값을 제거한 부분을 대조하여 부모를 찾는다.

서브카테고리 내용을 인서트하면서 부모 카테고리 _id값과 assetType을 추가로 넣어준다.

 

그리고나면 이 seed파일을 강제로 실행하면되는데

터미널에서 서버를 잠시 꺼주고 이 seed.js파일을 실행해준다. (ex. node seed/seed.js)

 

성공적으로 실행되면 성공메시지가 뜨는데 db를 확인해보면

이런식으로 seed로 입력한 카테고리 정보가 등재된 것을 확인할 수 있다.

 

이러면 백엔드 부분은 설계가 완료된 것이고 카테고리에 변경이 있을 때 이 seed를 갱신등록해주면 된다.

 

다음은 프론트엔드 부분인데

카테고리의 경우 이런저런 곳에서 자주 목록을 당겨서 사용할 것으로 예측 되었다.

그래서 호출에 대한 부분을 모듈화 시켜서 그때그때 당겨쓰면서 ui만 변화를 주려고 계획했다.

 

다음은 카테고리 모듈에 대한 코드이다.

간단한 요청 코드들로 구성되어있고 디테일은 사용하는 각 페이지에서 설정할 수 있게 했다.

export function getMainCategory() {
return fetch('/api/category/getMainCategory')
.then(response => response.json())
.catch(error => {
console.error('Error fetching main category:', error);
throw error; // 에러를 다시 던져서 호출한 곳에서 처리할 수 있도록 함
});
}

export function getSubCategory(parentId) {
return fetch(`/api/category/getSubCategories/${parentId}`)
.then(response => response.json())
.catch(error => {
console.error('Error fetching sub category:', error);
throw error;
});
}

 

이 부분이 실제로 카테고리를 사용하는 코드이고

에셋 생성 시 메인카테고리와 서브카테고리를 지정하기 위해 짠 코드이다.

import { getMainCategory, getSubCategory } from '../module/categoryModule.js';

getMainCategory()
.then(data => {
console.log('Main Category', data);
const mainCategoryContainer = document.getElementById('main-category-container');
mainCategoryContainer.innerHTML = '';

const mainCategoryMenu = `
<div class="main-category-menu">
${data.mainCategory.map(category =>
`<button data-id="${category._id}">${category.name}</button>`
).join('')}
</div>
`;
mainCategoryContainer.insertAdjacentHTML('beforeend', mainCategoryMenu);
 
const mainCategoryMenuEvent = document.querySelector('.main-category-menu');
mainCategoryMenuEvent.addEventListener('click', handleSubCategory);
 
if (data.mainCategory.length > 0) {
const firstCategory = data.mainCategory[0];
const firstButton = mainCategoryMenuEvent.querySelector('button');
firstButton.classList.add('selected');
 
updateOptionalContainer(firstCategory.assetTypes[0]);
 
handleSubCategory({
target: firstButton,
tagName: 'BUTTON'
});
}
});

function handleSubCategory(e) {
if (e.target.tagName === 'BUTTON') {
const allButtons = document.querySelectorAll('.main-category-menu button');
allButtons.forEach(button => button.classList.remove('selected'));
 
e.target.classList.add('selected');

const parentId = e.target.dataset.id;
console.log(parentId);
 
getMainCategory()
.then(data => {
const selectedCategory = data.mainCategory.find(cat => cat._id === parentId);
updateOptionalContainer(selectedCategory.assetTypes[0]);
});

getSubCategory(parentId)
.then(data => {
console.log('Sub Category', data);
const subCategoryContainer = document.getElementById('sub-category-container');
subCategoryContainer.innerHTML = '';
const subCategoryMenu = `
<div class="sub-category-menu">
${data.subCategories.map(category =>
`<button data-id="${category._id}" class="sub-category-button">
${category.name}
</button>`
).join('')}
</div>
`;
subCategoryContainer.insertAdjacentHTML('beforeend', subCategoryMenu);

const selectedSubCategories = new Set();

const subButtons = document.querySelectorAll('.sub-category-button');
subButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.target.classList.toggle('selected');
const categoryId = e.target.dataset.id;
 
if (e.target.classList.contains('selected')) {
selectedSubCategories.add(categoryId);
} else {
selectedSubCategories.delete(categoryId);
}
 
console.log('Selected subcategories:', Array.from(selectedSubCategories));
});
});
});
}
}

function updateOptionalContainer(assetTypes) {
const optionalContainer = document.getElementById('optional-container');
let content = '';

switch(assetTypes) {
case 'image':
content = `
<div class="form-item">
<label for="asset-hover-image">Hover Image</label>
<div class="form-content-container">
<img class="asset-prev" id="asset-hover-image" src="" alt="">
<button class="upload-btn">Upload</button>
</div>
</div>
<div class="form-item-container">
<div class="form-item">
<label for="asset-size">Asset Size</label>
<input type="text" id="asset-size" class="input-field" placeholder="ex. 1920x1080px">
</div>
<div class="form-item">
<label for="dpi">DPI</label>
<input type="text" id="dpi" class="input-field" placeholder="ex. 72dpi">
</div>
</div>
`;
break;

case 'video':
content = `
<div class="form-item">
<label for="asset-clip-motion">Clip Motion</label>
<div class="form-content-container">
<img class="asset-prev" id="asset-clip-motion" src="" alt="">
<button class="upload-btn">Upload</button>
</div>
</div>
<div class="form-item-container">
<div class="form-item">
<label for="asset-size">Asset Size</label>
<input type="text" id="asset-size" class="input-field" placeholder="ex. 200x200px">
</div>

<div class="form-item">
<label for="asset-duration">Duration</label>
<input type="text" id="asset-duration" class="input-field" placeholder="ex. 00:00:00">
</div>
<div class="form-item">
<label for="file-size">File Size</label>
<input type="text" id="file-size" class="input-field" placeholder="ex. 100MB">
</div>
</div>
`;
break;

case 'basic':
content = ``;
break;

case 'uimotion':
content = `
<div class="form-item">
<label for="asset-clip-motion">Clip Motion</label>
<div class="form-content-container">
<img class="asset-prev" id="asset-clip-motion" src="" alt="">
<button class="upload-btn">Upload</button>
</div>
</div>

<div class="form-item-container">
<div class="form-item">
<label for="asset-size">Asset Size</label>
<input type="text" id="asset-size" class="input-field" placeholder="ex. 200x200px">
</div>

<div class="form-item">
<label>Loop</label>
<div class="radio-container">
<input type="radio" value="Yes" id="loop-option1" name="loop-option">
<label for="loop-option1">Yes</label>
<input type="radio" value="No" id="loop-option2" name="loop-option">
<label for="loop-option2">No</label>
</div>
</div>
</div>
`;
break;
default:
content = '';
}

optionalContainer.innerHTML = content;
}

 

큰 구조는 메인카테고리와 서브카테고리를 가져오는 간단한 코드인데

최초 선택이 되어있게 하는 부분, 선택에 대한 셀렉트 스타일 설정, 서브카테고리의 경우 중복 선택 가능 설정,

메인카테고리 선택에 의한 에셋타입 변경 등 부수적인 기능들이 추가되면서 코드가 꽤 길어졌다.

 

일단 아까 만들어둔 메인카테고리와 서브카테고리를 가져올 수 있는 모듈을 가져와서 사용.

import { getMainCategory, getSubCategory } from '../module/categoryModule.js';

 

아래는 메인카테고리 가져오기

getMainCategory()
.then(data => {
console.log('Main Category', data);
const mainCategoryContainer = document.getElementById('main-category-container');
mainCategoryContainer.innerHTML = '';

const mainCategoryMenu = `
<div class="main-category-menu">
${data.mainCategory.map(category =>
`<button data-id="${category._id}">${category.name}</button>`
).join('')}
</div>
`;
mainCategoryContainer.insertAdjacentHTML('beforeend', mainCategoryMenu);
 
const mainCategoryMenuEvent = document.querySelector('.main-category-menu');
mainCategoryMenuEvent.addEventListener('click', handleSubCategory);
 
if (data.mainCategory.length > 0) {
const firstCategory = data.mainCategory[0];
const firstButton = mainCategoryMenuEvent.querySelector('button');
firstButton.classList.add('selected');
 
updateOptionalContainer(firstCategory.assetTypes[0]);
 
handleSubCategory({
target: firstButton,
tagName: 'BUTTON'
});
}
});

 

모듈로부터 데이터를 가져와서 메인카테고리가 들어갈 자리에 메인카테고리 정보를 넣어주는 작업이다.

여기서 조금은 생소한 개념이 사용되었는데 바로 data-id, 그리고 dataset 이다.

 

일반적으로 사용되는 html id의 경우 유일하게 설정이 가능하다는 점이 특징이라면

data-id의 경우 중복이 가능하며

가장 장점은 data-*라는 규칙을 이용해서 여러 속성을 붙이고 이를 다른 곳에서 가져다 쓸 수 있다.

 

예를들면 이런 것이다. (굉장히 훌륭한 기능인데 모르고있었다...)

<!-- data-* 속성 사용 -->
<button data-id="123" data-category="main">버튼1</button>
<button data-id="456" data-category="main">버튼2</button>

<!-- id 속성 사용 -->
<button id="button-123">버튼1</button>
<button id="button-456">버튼2</button>
// data-* 속성 접근
const button = document.querySelector('button');
console.log(button.dataset.id); // "123"
console.log(button.dataset.category); // "main"

// id 속성 접근
const buttonById = document.getElementById('button-123');

 

 

다시 돌아와서

const mainCategoryMenu = `
<div class="main-category-menu">
${data.mainCategory.map(category =>
`<button data-id="${category._id}">${category.name}</button>`
).join('')}
</div>
`;

 

지금 프로젝트 코드의 경우 이렇게 메인카테고리 각버튼에 data-id가 data로 가져온 메인카테고리_id로 부여되고 이를 클릭 시

function handleSubCategory(e) {
if (e.target.tagName === 'BUTTON') {
// 클릭된 버튼(e.target)의 data-id 값을 가져옴
const parentId = e.target.dataset.id; // category._id 값이 들어있음
// ...
}
}

 

그 _id값이 서브카테고리 parentId로 들어오면서 각 메인카테고리에 맞는 서브카테고리들을 가져올 수 있다.

 

그리고 에셋타입에 의해 입력속성 ui가 변경되게 하는 부분은 아래 함수로 컨트롤 했다.

function updateOptionalContainer(assetTypes) {
const optionalContainer = document.getElementById('optional-container');
let content = '';

switch(assetTypes) {
case 'image':
content = `
<div class="form-item">
<label for="asset-hover-image">Hover Image</label>
<div class="form-content-container">
<img class="asset-prev" id="asset-hover-image" src="" alt="">
<button class="upload-btn">Upload</button>
</div>
</div>
이후 생략...

 

이 외에도 셀렉트 관련 기능, 기존에 디폴트 선택에 대한 부분 등 여러 기능들이 더 복합적으로 짜여진 코드인데

포스팅으로 모두 정리하자니 너무 방대해서 일단 큰 맥락 정리한 채 마무리하려고한다.

(사실 디테일한 부분은 커서 도움을 많이 받은지라 나도 아직 이해가 안되는 부분도 꽤 있다 ㅜㅜ)

에셋 추가에서 카테고리 설정 구현 중

 

 

아무튼 꽤 복잡하고 어렵긴 했지만 내가 가장 두려웠던 카테고리 부분을 잘 설계해서 뿌듯하다.