chatGPT를 열심히 채찍질해서 겨우 구현해냈다.
휴.. DB 초심자에게 혹독한 구현이었다.
DB이론을 이전에 읽어놔서 어떻게 돌아가야 하는지 겨우 이해했다.
읽어놓길 잘했어.. DB 이론 공부 다시 하자 ..
아래 사진은 수많은 시험을 견뎌내며 저장 단축키를 누를 때마다 DB로 옮겨진 데이터들 ㅋㅋㅋㅋㅋㅋㅋ
엔티티 간의 관계를 짜는 것부터 초심자에게는 어렵고 혹독했다. ㅎㅎ
// category.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
@Entity({ name: 'category' }) // 생성될 테이블 이름
export class Category {
@PrimaryGeneratedColumn() // 자동생성되며, 1씩 증가하는 PK
id: number;
@Column()
title: string;
// 부모 엔티티가 여러 자식 엔티티를 참조
@OneToMany(() => Category, (category) => category.parent)
children: Category[];
// 자식 엔티티가 부모 엔티티를 참조
@ManyToOne(() => Category, (category) => category.children) // 부모의 children 필드로 자식에 접근 가능
@JoinColumn({ name: 'parentId' })
parent: Category | null;
@Column({ nullable: true })
parentId: number | null;
}
@Column 데코레이터가 들어가야 DB에서도 실제로 column이 된다.
그리고 OneToMany랑 ManyToOne 데코레이터는 엔티티 간의 관계를 나타낸다.
한줄씩 해석해보자.
@OneToMany(() => Category, (category) => category.parent)
children: Category[];
현재 엔티티가 다른 엔티티들과 1:N 관계를 맺는다는 뜻이다. 그리고 child에 해당하는 entity에서 부모 엔티티를 parent 속성으로 참조할 수 있다. children 속성은 여러개이므로 배열 형태로 접근해야 한다. 따라서 Category 배열의 타입을 갖는다.
// 자식 엔티티가 부모 엔티티를 참조
@ManyToOne(() => Category, (category) => category.children) // 부모의 children 필드로 자식에 접근 가능
@JoinColumn({ name: 'parentId' })
parent: Category | null;
현재 엔티티가 자식으로서 부모와 N:1 관계를 맺는다. children 속성으로 부모에서 해당 child 속성에 접근 가능하다. parent는 null일 수도 있는데, 이건 1-depth일 때 이야기이다.
그리고 @JoinColumn 데코레이터로는 외래키를 지정할 수 있다. 제일 헷갈렸던 부분! 하나의 테이블에서 외래키로 행 간 참조가 가능하다. 즉, parentId를 외래키로 설정하여 다른 행을 참조할 수 있는 것이다.
즉, 이런 테이블을 만드는 것이다.
parentId를 외래키로 하여 해당 카테고리 테이블에서 해당 id를 가진 행을 찾아 관계를 맺는 것이다.
CREATE TABLE category (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
parentId INT,
FOREIGN KEY (parentId) REFERENCES category(id)
);
그럼 이제 entity 못지 않게 이해하기 어려웠던 category.service.ts도 보자.
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Category } from './category.entity';
interface CategoryData {
id: number;
title: string;
parentId: number | null;
children: CategoryData[];
}
const categoryList = [
{
id: 0,
title: 'Algorithm',
parentId: null,
children: [
{
id: 1,
title: 'Leetcode',
parentId: 0,
children: [],
},
{
id: 2,
title: 'Baekjoon',
parentId: 0,
children: [],
},
],
},
{
id: 3,
title: 'Project',
parentId: null,
children: [
{
id: 4,
title: 'building my own tech blog',
parentId: 3,
children: [],
},
],
},
];
@Injectable()
export class CategoryService {
constructor(
@InjectRepository(Category)
private categoryRepository: Repository<Category>,
) {}
async getCategory(): Promise<Category[]> {
return this.categoryRepository.find();
}
async getCategoryChildren(id: number): Promise<Category[]> {
const parentCategory = await this.categoryRepository.findOne({
where: { id },
relations: ['children'],
});
if (!parentCategory) {
return [];
}
console.log(parentCategory);
return parentCategory.children;
}
async saveCategories(): Promise<void> {
for (const categoryData of categoryList) {
await this.saveCategoryAndChildren(categoryData, null);
}
}
private async saveCategoryAndChildren(
categoryData: CategoryData,
parentId: number | null,
): Promise<void> {
const category = new Category();
category.title = categoryData.title;
category.parentId = parentId;
const savedCategory = await this.categoryRepository.save(category); // id를 자동으로 부여받음
if (categoryData.children && categoryData.children.length > 0) {
for (const childData of categoryData.children) {
await this.saveCategoryAndChildren(childData, savedCategory.id); // 자동으로 부여받은 id를 자식을 호출하며 parentId로 지정하라고 넘겨줌
}
}
}
}
여기도 함수 단위로 해석해보자.
async saveCategories(): Promise<void> {
for (const categoryData of categoryList) {
await this.saveCategoryAndChildren(categoryData, null);
}
}
메모리에 저장된 1-depth 데이터를 순회하며 saveCategoryAndChildren 함수를 호출한다. 이때 순회 요소와 parent에 해당하는 null 값을 인자로 넘긴다.
여기까지는 이해하기 쉽다!
그렇다면 이제 호출할 saveCategoryAndChildren 함수를 보자.
private async saveCategoryAndChildren(
categoryData: CategoryData,
parentId: number | null,
): Promise<void> {
const category = new Category();
category.title = categoryData.title;
category.parentId = parentId;
const savedCategory = await this.categoryRepository.save(category); // id를 자동으로 부여받음
if (categoryData.children && categoryData.children.length > 0) {
for (const childData of categoryData.children) {
await this.saveCategoryAndChildren(childData, savedCategory.id); // 자동으로 부여받은 id를 자식을 호출하며 parentId로 지정하라고 넘겨줌
}
}
}
우선 1-depth 데이터부터 순회할 것이므로, 일단은 title과 parentId만 category 속성으로 추가한다. 그리고 category를 save 메서드로 DB에 바로 저장한다. children을 추가하지 않는 이유는, 이미 entity를 지정할 때
// 자식 엔티티가 부모 엔티티를 참조
@ManyToOne(() => Category, (category) => category.children) // 부모의 children 필드로 자식에 접근 가능
@JoinColumn({ name: 'parentId' })
parent: Category | null;
children으로 나 접근가능해요~~하고 지정해줬기 때문이다. 그리고 children 배열을 DB에 column으로 설정을 시도해봤는데, DB에는 Array type이 column으로 저장이 안된단다.
다음으로, children이 있는 1-depth 데이터의 경우 for문으로 children을 순회하며 재귀함수를 호출한다. 이 때 childData와 자기 자신의 id를 parentId 속성으로 인자로 넘긴다. 이 때, savedCategory의 id는 DB에 저장하며 부여받은 것이다.
그리고 부모의 id로 children을 검색하는 함수도 봐보자.
async getCategoryChildren(id: number): Promise<Category[]> {
const parentCategory = await this.categoryRepository.findOne({
where: { id },
relations: ['children'],
});
if (!parentCategory) {
return [];
}
console.log(parentCategory);
return parentCategory.children;
}
id가 인자로 넘긴 id와 같은 행을 찾을 건데, children 속성으로 접근할 것이라고 명시한다. 만약 relations: ['children']을 명시하지 않으면
이렇게 children에 접근할 수 없다.
하지만 relations: ['children']을 명시하면 ManyToOne으로 설정해놓은 entity 덕에 children도 DB에 저장이 된다.
성공!!!
이제 필요없는 데이터 DB에서 싹 지우자!
안전모드 잠시 풀고 중복 없이 모두 삭제!
휴..DB 초심자가 해내기에 꽤 어려운 기능이었던 것 같다. 지피티 없으면 어쩔뻔.. 그래도 덕분에 다음에는 좀 덜 어렵게 해낼 수 있을 것 같다. 화이팅! 저녁 먹자~~
DB 이론 적용해보기 ⭐️
- category 릴레이션에서
- 기본키는 id
- 외래키는 parentId
- parentId는 자기자신이 속한 category relation의 기본키인 id를 참조한다.
- 외래키인 parentId는 null값을 가질 수 있다.
참조 무결성 제약조건
외래키는 존재하지 않는 값을 참조해서는 안된다.