데이터베이스에 쓰일 필드와 여러 엔티티간 연관관계를 정의할 Entity를 생성해보자!
✅ Entity 클래스는 실제 DB 테이블과 매핑되는 핵심 클래스로, 데이터베이스의 테이블에 존재하는 칼럼들을 필드로 가지는 객체
Entity는 DB의 테이블과 1대 1로 대응되며, 때문에 테이블이 가지지 않는 컬럼을 필드로 가져서는 안된다. 또한 Entity 클래스는 다른 클래스를 상속받거나 인터페이스의 구현체여서는 안된다.
만약 ORM 없이 데이터베이를 생성할 때는 어떻게 할까?
CREATE TABLE board (
id INTEGER AUTO_INCREMENT PRIMARY KEY. title VARCHAR(255) NOT NULL,
decsription VARCHAR(255) NOT NULL
)
✅ 반면, TypeORM을 사용할 때는 데이터베이스 테이블로 변환되는 Class이기 때문에 위와 같이 사용하지 않고 클래스를 생성한 후 그 안에 칼럼들을 정의해준다.
1. 폴더 및 파일 생성
2. Entity 생성을 위한 필요 모듈 설치
bcryptjs
class-validator
class-transformer
npm install bcryptjs class-validator class-transformer —save
npm install @types/bcryptjs —save-dev
3. Base Entity 생성
모든 Entity에 id, CreateAt, updateAt이 필요. 따라서, BaseEntity를 따로 생성해서 다른 Entity에서 상속 받아서 사용
@PrimaryGeneratedColumn()
/src/Entity.ts
export default abstract class Entity extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@CreateDateColumn()
createAt: Date;
@UpdateDateColumn()
updateAt: Date;
}
사용자에 대한 필수적인 정보인 email
, password
, username
그리고 필수적인 권한인 createdAt
과 uodateAt
에 대한 데이터를 생성해보자.
@Entity()
Entity ()
데코레이터 클래스는 User
클래스가 엔티티임을 나타내는 데 사용된다.CREATE TABLE user
부분)@Column()
Column ()
데코레이터 클래스는 User
엔터티의 email
및 username
과 같은 다른 열을 나타내는 데 사용된다. /src/entities/User.ts
@Entity('users')
export default class User extends BaseEntity {
@Index()
@IsEmail(undefined, { message: '이메일 주소가 잘못되었습니다.' })
@Length(1, 255, { message: '이메일 주소는 비워둘 수 없습니다' })
@Column({ unique: true })
email: string;
@Index()
@Length(3, 32, { message: '사용자 이름은 3자 이상이어야 합니다.' })
@Column({ unique: true })
username: string;
@Exclude()
@Column()
@Length(6, 255, { message: '비밀번호는 6자리 이상이어야 합니다.' })
password: string;
@OneToMany(() => Post, post => post.user)
posts: Post[];
@OneToMany(() => Vote, vote => vote.user)
posts: Vote[];
@BeforeInsert()
async hashPassword() {
this.password = await bcrypt.hash(this.password, 6);
}
}
✅ 유저와 게시물 데이터의 관계는 1 대 N 관계로 형성된다.
✏️ Example
여러가지 Board을 작성할 수 있는 User (One to Many)
여러가지 Board는 하나의 User로 인해 작성 (Many to One)
게시글에 대한 필수적인 정보인 name
, title
그리고 선택적인 정보인 description
, imageURN
, bannerURN
그리고 게시글의 기능인 createedAt
, updatedAt
에 대한 데이터를 생성해보자.
더하여, 사용자의 권한이 필요하므로 User의 username
을 외래키(Foreign Key)로 가져오자.
💡 잠깐) 외래키란 ?
외래 키는 참조하는 테이블에서 1개의 키(속성 또는 속성의 집합)에 해당하고, 참조하는 측의 관계 변수는 참조되는 측의 테이블의 키를 가리킨다. 참조하는 테이블의 속성의 행 1개의 값은, 참조되는 측 테이블의 행 값에 대응 된다.
@JoinColumn()
@JoinColumn
을 통해서 어떤 관계쪽이 외래 키를 가지고 있는지 나타낸다.@JoinColumn
을 설정하면 데이터베이스에propertyName + referencedColumnName
이라는 열이 자동으로 생성.@ManyToOne
의 경우 선택 사항이지만 @OneToOne
의 경우 필수name
propertyName + referencedColumnName
이 default
referencedColumnName
👉 만약 name
, referencedColumnName
둘 다 없다면 FK + id
이다. (ex. user_id)
/src/entities/Sub.ts
@Entity('subs')
export default class Sub extends BaseEntity {
@Index()
@Column({ unique: true })
name: string;
@Column()
title: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ nullable: true })
imageUrn: string;
@Column({ nullable: true })
bannerUrn: string;
@Column()
username: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'username', referencedColumnName: 'username' })
user: User;
@OneToMany(() => Post, post => post.sub)
posts: Post[];
@Expose()
get imageUrl(): string {
return this.imageUrn
? `${process.env.APP_URL}/images/${this.imageUrn}`
: 'https://www.gravatar.com/avatar?d=mp&f=y';
}
@Expose()
get bannerUrl(): string | undefined {
return this.bannerUrn
? `${process.env.APP_URL}/images/${this.bannerUrn}`
: undefined;
}
}
💡 잠깐) URI, URL, URN?
✅ URL과 URN의 차이점
- URL은 어떻게 리소스를 얻을 것이고 어디에서 가져와야 하는지 명시하는 URI이다.
- URN은 리소스를 어떻게 접근할 것인지 명시하지 않고 경로와 리소스 자체를 특정하는 것을 목표로 하는 URI이다.
참고하자 👉 [네트워크/기본] URI, URL 및 URN의 차이점
앞서 Entity 작성시 @Expose
라는 데코레이터를 사용하였고, 이는 class-tranformer
을 사용하기 위함인데 이는 무엇일까?
✅ Class-transformer
를 사용하면 plain object(일반 객체)
를 클래스 인스턴스로 변환 할 수 있다.
🧐 만약 다음과 같은 JavaScript Object
가 존재한다면, firstName
과 LastName
을 어떻게 합쳐줄까?
[{
{"id": 1,
"firstName": "Johny",
"lastName": "Cage",
"age": 27
},
{"id": 2,
"firstName": "Imsmoil",
"lastName": "Andrew",
"age": 12
},
{"id": 3,
"firstName": "Peter",
"lastName": "Parker",
"age": 23
},
}]
✅ class-transform 없이 구현하기
fetch('users.json').then((users: Ueser[]) => {
return users.map(u => toFullName(u));
});
export function toFullName(user){
return `${user.firstName} ${user.lastName}`
}
✅ class-transform 사용하여 구현하기
class-transformer
에 있는 plainToInstance
메서드를 사용해서 JS Objectexport class User{
id: number;
firstName: string;
lastName: string;
age: number;
getName(){
return this.firstName + ' ' + this.lastName;
}
isAdult(){
return this.age > 36 && this.age < 60;
}
}
...
fetch('users.json').then((users: Object[]) => {
const realUsers = plainToInstance(User, users);
return realUsers.map(u => u.getName());
});
👍 이렇게 User 클래스에 정의해 놓은 로직을 바로 가져다가 쓸 수 있기 때문에 상태
와 행위가 함께 있는 응집력이는 코드가 된다.이런 식으로 리터럴 객체 대신 클래스의 인스턴스를 넘겨주려면
class-transform
에
있는plainToInstance
를 사용하면 된다.이와 반대로
instanceToPlain
을 사용해서 클래스로 있는걸 리터럴 객체로 만들어 줄 수도 있다.
현재 작성하는 엔티티를 생성할 때 상태뿐 아니라 행위까지 엔티티안에 정의를 해서 그걸 프론트엔드에서 사용할 수 있게 할 수 있다.
✅ getter 및 Method return value 노출
@Expose()
데코레이터를 해당 getter
또는 메서드로 설정하여 getter
또는 메서드가 반환하는 것을 드러낼 수 있다.
import { Expose } from 'class-transformer';
export class User {
id: number;
firstName: string;
lastName: string;
password: string;
@Expose()
get name() {
return this.firstName + ' ' + this.lastName;
}
@Expose()
getFullName() {
return this.firstName + ' ' + this.lastName;
}
}
✅ 특정 속성 건너뛰기
@Exclude
데코레이터를 사용하여 수행할 수 있다.import { Exclude } from 'class-transformer';
export class User {
id: number;
email: string;
@Exclude()
password: string;
}
글 작성 및 수정시에 필수적인 정보는 identifier
, title
, slug
가 있고, 선택적인 정보로는 body
그리고 핵심 기능인 createdAt
, updatedAt
에 대한 데이터를 생성해보자.
더하여, 사용자의 권한이 필요하므로 User의 username
을 외래키로, 수정시에 게시글을 판별하기 위한 Sub에서 게시글이름인 subName
을 외래키로 가져온다.
💡 잠깐) slug
✅ 슬러그는 페이지나 포스트를 설명하는 핵심 단어의 집합이다.
원래 신문이나 잡지 등 에서 제목을 쓸 때, 중요한 의미를 포함하는 단어만 이용해 제목을 작성하는 법을 말 한다.
보통 슬러그는 페이지나 포스트의 제목에서 조사, 전치사, 쉼표, 마침표 등을 빼고 띄어쓰기는 하이픈(-)으로 대체해서 만들며 URL 에 사용된다.
슬러그를 URL에 사용함으로써, 검색 엔진에서 더 빨리 페이지를 찾아주고 검색엔진의 정확도를 높여준다.
여기서 중요하게 봐야할 것은 identifier
가 독립적인 식별자가 되기 위하여 7길이의 id
를 makeId
를 통해 받는다. ( 👉 makeId function )
또한, URL
에 사용되기 위해 핵심적인 단어를 가져야하는 slug
를 만들기 위하여 slugify
에 title
을 전달하여 필터링 해준다. ( 👉 slugify function )
✅ 따라서, 해당 기능들을 하는 makeId
, slugify
메서드를 생성한다.
src/utils/helpers.ts
export const makeId = length => {
let result = '';
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
};
export const slugify = str => {
str = str.replace(/^\s+|\s+$/g, ''); // trim
str = str.toLowerCase();
// remove accents, swap ñ for n, etc
const from = 'ãàáäâẽèéëêìíïîõòóöôùúüûñç·/_,:;';
const to = 'aaaaaeeeeeiiiiooooouuuunc------';
for (let i = 0, l = from.length; i < l; i++) {
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i));
}
str = str
.replace(/[^a-z0-9 -]/g, '') // remove invalid chars
.replace(/\s+/g, '-') // collapse whitespace and replace by -
.replace(/-+/g, '-'); // collapse dashes
return str;
};
전체코드
/src/entities/Post.ts
@Entity('posts')
export default class Post extends BaseEntity {
@Index()
@Column()
identifier: string; // 7 Character id
@Column()
title: string;
@Index()
@Column()
slug: string;
@Column({ nullable: true, type: 'text' })
body: string;
@Column()
username: string;
@ManyToOne(() => User, user => user.posts)
@JoinColumn({ name: 'username', referencedColumnName: 'username' })
user: User;
@ManyToOne(() => Sub, sub => sub.posts)
@JoinColumn({ name: 'subName', referencedColumnName: 'name' })
sub: Sub;
@Exclude()
@OneToMany(() => Comment, comment => comment.post)
comments: Commnet[];
@Exclude()
@OneToMany(() => ValidationTypes, vote => vote.post)
votes: Vote[];
@Expose() get url(): string {
return `/r/${this.subName}/${this.identifier}/${this.slug}`;
}
@Expose() get commentCount(): number {
return this.comments?.length;
}
@Expose() get voteScore(): number {
return this.votes?.reduce((memo, curt) => memo + (curt.value || 0), 0);
}
protected userVote: number;
setUserVote(user: User) {
const index = this.votes?.findIndex(v => v.username === user.username);
this.userVote = index > -1 ? this.votes[index].value : 0;
}
@BeforeInsert()
makeIdAndSlug() {
this.identifier = makeId(7);
this.slug = slugify(this.title);
}
}
게시글 좋아요, 싫어요 기능을 위해 필수적인 값은 value
그리고 핵심 기능인 createdAt
, updatedAt
에 대한 데이터를 생성해보자.
더하여, 좋아요,싫어요를 받는 게시글의 판별을 위해서 선택적인 값으로 postId
, commentId
를 외래키로 받아오고, 투표 권한을 위해서 username
을 외래키로 받는다.
/src/entities/Vote.ts
@Entity('votes')
export default class Vote extends BaseEntity {
@Column()
value: number;
@ManyToOne(() => User)
@JoinColumn({ name: 'username', referencedColumnName: 'username' })
user: User;
@Column()
username: string;
@Column({ nullable: true })
postId: number;
@ManyToOne(() => Post)
post: Post;
@Column({ nullable: true })
commentId: number;
@ManyToOne(() => Comment)
comment: Comment;
}
게시글 댓글 기능을 위해 필수적인 값은 identifier
, body
그리고 핵심 기능인 createdAt
, updatedAt
에 대한 데이터를 생성해보자.
더하여, 좋아요,싫어요를 받는 게시글의 판별을 위해서 선택적인 값으로 postId
를 외래키로 받아오고, 투표 권한을 위해서 username
을 외래키로 받는다.
/src/entities/Comment.ts
@Entity('comments')
export default class Comment extends BaseEntity {
@Index()
@Column()
identifier: string;
@Column()
body: string;
@Column()
username: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'username', referencedColumnName: 'username' })
user: User;
@Column()
postId: number;
@ManyToOne(() => Post, post => post.comments, { nullable: false })
post: Post;
@Exclude()
@OneToMany(() => Vote, Vote => Vote.comment)
votes: Vote[];
protected userVote: number;
setUserVote(user: User) {
const index = this.votes?.findIndex(v => v.username === user.username);
this.userVote = index > -1 ? this.votes[index].value : 0;
}
@Expose() get voteScore(): number {
const initialValue = 0;
return this.votes?.reduce(
(previousValue, currentObject) =>
previousValue + (currentObject.value || 0),
initialValue
);
}
@BeforeInsert()
makeId() {
this.identifier = makeId(8);
}
}