ORM이란 객체(Object)-관계(Relation) 매핑(Mapping)의 약자로, 객체 지향 프로그래밍(OOP - Object Oriented Programming)과 관계형 데이터베이스(RDB - Relational DataBase) 간의 데이터를 쉽게 변환하고 상호작용할 수 있도록 해주는 기술이다. 즉, 데이터베이스의 테이블을 객체(Object)로 표현하여, SQL 쿼리문을 작성하지 않고도 데이터베이스를 조작할 수 있도록 한다. ORM은 SQL 쿼리문을 작성하지 않고 객체를 조작하므로 개발 속도가 빠르며 데이터베이스 조작이 객체 지향적으로 이루어져 코드가 직관적이다.
ORM은 SQL 쿼리문 대신 객체를 조작하므로 개발 속도가 빠르며, 데이터베이스 조작이 객체 지향적이여서 직관적이다. 하지만 복잡한 SQL 쿼리면 성능에 문제가 발생할 수 있으며 SQL 쿼리를 작성하는 것보다는 성능 손실이 발생할 수 있다.
ORM 동작 방식
클래스와 테이블 매핑 - ORM은 하나의 Class(Entity)가 데이터베이스의 한 Table에 대응되도록 매핑한다.
객체와 레코드 매핑 - 클래스의 인스턴스는 한 테이블의 한 행에 대응된다.
속성과 열(Column) 매핑 - 클래스의 속성은 데이터베이스의 열(Column)에 대응된다.
TypeORM은 TypeScript와 JavaScript(ES6 이상)
환경에서 사용할 수 있는 ORM(Object-Relational Mapping) 라이브러리다. 관계형 데이터베이스와 객체 지향 프로그래밍의 데이터를 매핑하여 SQL 쿼리 없이 객체를 조작하는 방식으로 데이터베이스를 다룰 수 있게 해준다. TypeORM은 Node.js에서 동작하며, 다양한 데이터베이스(MySQL / PostgreSQL / MariaDB / SQLite / Oracle / MongoDB / ETC
)를 지원하는 강력한 ORM 라이브러리다. 자체적으로 Migration 기능을 지원하며 점진적인 데이터베이스 구조와 버저닝을 모두 지원한다. 또한 Active Record와 Data Mapper를 지원하며, Eager & Lazy 로딩을 지원하기 때문에 어떤 방식으로 데이터를 불러올 지 완전한 컨트롤이 가능하다.
DataSource - 사용할 데이터베이스 지정 및 정보 제공 역할
import { DataSource } from "typeorm" const AppDataSource = new DataSource({ type: "mysql", host: "localhost", port: 3306, username: "test", password: "test", database: "test", entities: [ // 여기에 Entity를 입력 ] })
@Entity()
export class Uesr {
@PrimaryGeneratedColumn()
id: number
@Column()
firstName: string
@Column()
lastName: string
@Column()
isActive: boolean
Column Options
- type(ColumnType) -
varchar / text / int / bool
기본적으로 typescript를 따르며, number인 경우float
가 default로 들어간다.- name(string) - 데이터베이스에 저장될 column 이름. 기본값으로 property name을 사용한다. 주로, property가 camel case(userName)로 작성된 경우, 데이터베이스에선 snake case(user_name)으로 변경하기 위해 사용된다.
- nullable(boolean) - null 값이 가능한 지 여부 기본값으로는 required지만 optional이 필요한 경우 true로 변경해줘야한다.
- update(boolean) - 업데이트 가능여부 기본값이 ture지만 값이 변경되면 안되는 값인 경우 false로 설정해야한다.
- select(boolean) - 쿼리 실행 시 property를 가져올 지 결정. false일 경우 가져오지 않는다. (주로 password를 가져오지 않을 때 Column option에 select: false 속성을 넣는다.)
- default(string) - 칼럼 기본값 주로 좋아요 초기값을 넣을 때 사용한다.
- unique(boolean) - unique constraint 적용 여부 기본 false 같은 column에 동일한 값이 들어가면 안될 때 사용한다.
- comment(string) - 칼럼 코멘트 모든 데이터베이스에서 지원되지 않는다.
- enum(string[]) - 칼럼에 입력 가능한 값을 enum으로 나열
- array(boolean) - 칼럼 array type으로 생성
Unique Column
- @CreateDateColumn은 자동으로 Row 생성 날짜시간을 저장한다.
- @UpdateDateColumn은 자동으로 Row 최근 업데이트 날짜시간을 저장한다.
- @DeleteDateColumn은 자동으로 Row의 Soft Delete 날짜 시간을 저장한다.
- @VersionColumn은 자동으로 Row가 업데이트될 때마다 1씩 증가한다.
// app.module.ts
import { ConfigModule } from '@nestjs/config';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { postgreSQLConfig } from './configs/postgreSQLConfig';
import { zodConfigSchema } from './configs/zodConfig.schema';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validate: (config) => zodConfigSchema.parse(config),
}),
TypeOrmModule.forRootAsync({
name: 'PostgreSQL',
useClass: postgreSQLConfig,
}),
],
})
export class AppModule {}
// postgreSQLConfig.ts
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';
@Injectable()
export class postgreSQLConfig implements TypeOrmOptionsFactory {
constructor(private readonly configService: ConfigService) {}
createTypeOrmOptions(): TypeOrmModuleOptions {
return {
type: 'postgres',
host: this.configService.get<string>('POSTGRE_DB_HOST'),
port: this.configService.get<number>('POSTGRE_DB_PORT'),
username: this.configService.get<string>('POSTGRE_DB_USERNAME'),
password: this.configService.get<string>('POSTGRE_DB_PASSWORD'),
database: this.configService.get<string>('POSTGRE_DB_DATABASE'),
synchronize: true,
autoLoadEntities: true,
logging: false,
};
}
}
// zodConfig.schema.ts
import { z } from 'zod';
export const zodConfigSchema = z.object({
ENV: z.enum(['test', 'dev', 'prod']),
POSTGRE_DB_TYPE: z.literal('postgres'),
POSTGRE_DB_HOST: z.string(),
POSTGRE_DB_PORT: z.preprocess((val) => Number(val), z.number()),
POSTGRE_DB_USERNAME: z.string(),
POSTGRE_DB_PASSWORD: z.string(),
POSTGRE_DB_DATABASE: z.string(),
});
필자는 코드를 마구마구 분리하는 것을 좋아한다. 하나하나 쌓이다보면 결국 몇 백줄이 되는 코드를 마주할 수 있을 것이다. 개발자라면 알 것이다. 파일을 분리하는 게 얼마나 가독성이 올라가고 유지보수성이 증가하는지. 필자 또한 TypeORM을 통해 postgreSQL을 연결하는 코드가 10몇줄이 되니깐 마구마구 분리하고 싶어졌다. 다행히도 예전에 TypeORM Config로 분리했던 기억이 있어서 그걸 바탕으로 이번에도 분리해 보았다.
먼저 app.module.ts를 보자면 별거 없다 ConfigModule에서의 validate와 forRootAsync forRootAsync가 궁금하다면 Postgresql 연결 참고하시는 걸 추천한다. 짧게 요약해서 설명하자면 NestJS에서 .env 파일의 환경 변수는 ConfigModule을 통해 로드된다. 이 과정은 비동기로 동작하며, 환경 변수를 읽고 파싱하는 작업이 완료된 후에야 접근할 수 있다. TypeOrmModule.forRoot는 동기적으로 동작하기 때문에, 환경 변수 사용이 어렵다. 하지만 forRootAsync를 사용하면, 설정 값을 동기적이든 비동기적이든 동적으로 생성할 수 있다.
그리구 추후 MongoDB를 연결할 예정인데, postgreSQL과 구분하기 위해서 TypeORM Option으로 name: "PostgreSQL"을 넣었다.
Joi와 Zod 비교
특징 Joi Zod 타입스크립트 통합 - TypeScript를 직접 지원하지 않음. - TypeScript와 통합이 우수: 타입 추론 가능. 사용 방식 - NestJS ConfigModule
과 기본적으로 통합됨.- validate
옵션을 통해 추가적으로 연동 필요.코드 간결성 - 더 많은 설정 코드가 필요할 수 있음. - 상대적으로 간결한 코드 작성 가능. 유효성 검증 기능 - 다양한 데이터 타입과 복잡한 검증 로직 지원. - 검증 기능이 강력하지만, Joi에 비해 일부 제약이 있음. 커스터마이징 - 에러 메시지와 검증 옵션이 매우 유연하게 커스터마이징 가능. - 에러 메시지 커스터마이징은 가능하나 복잡한 경우 제한적. 성능 - 검증 로직이 풍부하여 약간 무거움. - 가벼운 라이브러리로 더 빠름. 배우기 쉬움 - 직관적이나 옵션이 많아 초보자에겐 복잡할 수 있음. - 직관적이고 배우기 쉬움. 생태계 지원 - 오랜 기간 사용된 라이브러리로, 문서와 플러그인 풍부. - 상대적으로 신생 라이브러리, 발전 중.
사실 Joi를 쓰려고 했다. NestJS와 기본 통합되어 있어 별도의 작업 없이 바로 validationSchema 옵션에 연결 가능하기 때문이다. 하지만 다음 NPM Trend를 봐보자.
올해만 봐도 Zod가 급증하는 것을 볼 수 있다. 이는 TypeScript와의 긴밀한 통합 및 사용성의 간결함 때문이라 생각한다. Joi가 편할 진 몰라도 최대한 Zod의 익숙해지자 Zod를 사용하기로 결심했다.
객체를 생성하는 역할을 한다. save와 다르게 데이터베이스에 데이터를 생성하지 않고 객체 생성만 한다.
const user = repository.create()
const user = repository.create({
id: 1,
firstName: "Ryu",
lastName: "Jiseung",
})
저장할 Entity를 입력해주면 저장할 수 있다. create와 다르게 실제 데이터베이스에 저장한다. 만약 Row가 존재한다면 (PK 기준) 업데이트 한다. 또한 여러 객체를 한 번에 저장도 가능하다.
await repository.save(user)
await repository.save([
category1,
category2,
category3
])
update와 insert를 합친 게 upsert이다. 데이터 생성 시도를 한 후 만약 이미 존재하는 데이터라면 업데이트를 진행한다. save와 다르게 upsert는 하나의 transaction에서 작업이 실행된다.
await repository.upsert(
[
{ externalId: "abc123", firstName: "Jiseung" },
{ externalId: "bac321", firstName: "Ryu" }
],
["externalId"],
)
save vs upsert
일반적인 CRUD는 save 사용
Primary Key 외에 Unique Key를 기반으로 데이터를 판단하고 처리해야 하는 경우, upsert 사용
특징 save upsert 중복 판단 기준 Primary Key에 의해 자동 판단. Primary Key 또는 Unique Key를 명시적으로 설정해야 함. 사용 시점 단순 CRUD 작업(삽입 또는 업데이트) 시 사용. 중복 키 충돌 처리가 필요하거나, 특정 키를 기준으로 업데이트 또는 삽입 작업을 동시에 처리할 때 사용. 필수 옵션 없음 (Primary Key가 기본 기준). conflictPaths
옵션을 통해 중복 키를 명시적으로 정의해야 함.배치 처리 단일 또는 다중 엔티티 삽입/업데이트 가능. 여러 엔티티를 대상으로 효율적으로 충돌 처리하며 삽입/업데이트 가능. 데이터베이스 지원 모든 주요 데이터베이스에서 동작. PostgreSQL, MySQL 등의 Upsert 기능을 지원하는 DB에서만 동작.
특정 row를 삭제할 때 사용되며 대체적으로 PK를 사용해서 삭제한다. 원한다면 findOPtionsWhere 조건으로 여러 값을 삭제할 수도 있다.
await repository.delete(1)
await repository.delete([1, 2, 3])
await repository.delete({ firstName: "Ryu" })
softeDelete는 비영구적으로 삭제하는 기능으로 restore를 통해서 Row를 복구할 수 있다.
// 삭제
await repository.softDelete(1)
// 복구
await repository.restore(1)
첫번째 파라미터로 검색 조건을 입력해주고, 두번째 파라미터로 변경 필드를 입력해준다.
await repository.update(
{ age: 18 },
{ category: "ADULT" }
)
await repository.update(
1,
{ firstName: "Ryu" }
)
const rows = await repository.find({
where: {
firstName: "Ryu"
}
})
const row = await repository.findOne({
where: {
firstName: "Ryu"
}
})
const [rows, count] = await repository.findAndCount({
where: {
firstName: "Ryu"
}
})
특정 조건의 Row가 존재하는 지 boolean으로 확인할 수 있다.
await.repository.exists({
where: {
firstName: "Ryu"
}
})
데이터베이스에 저장된 값을 PK 기준으로 불러오고 입력된 객체의 값으로 프로퍼티를 덮어쓴다. 이 과정에서 실제 데이터베이스에 save되지 않는다.
const partialUser = {
id: 1,
firstName: "Ryu",
profile: {
id: 1,
},
}
const user = await.repository.preload(partialUser)
모든 find는 findOptions를 argument로 갖는데 어떤 값들을 불러올 지 필터링하는 역할을 한다.
export interface FindOneOptions<Entity = any> {
select?: FindOptionsSelect<Entity>
where?: FindOptionsWhere<Entity>[]
relations?: FindOptionsRelations<Entity>
order?: FindOptionsOrder<Entity>
cache?: boolean | number
}
export interface FindManyOptions<Entity = any> extends FindOneOptions<Entity> {
skip?: number
take?: number
}
select : 불러올 Column을 지정할 수 있다.
where : 필터링할 조건을 설정할 수 있다.
relations : 불러올 관계 테이블을 지정할 수 있다.
order : 정렬을 지정할 수 있다. (ASC / DESC)
cache : 캐싱 기간을 지정할 수 있다.
skip : 정렬 후 스킵할 데이터 갯수를 정할 수 있다.
take : 처음 몇 개의 데이터를 불러올 지 정할 수 있다.
같은 값을 찾을 때 사용한다.
const user = await userRepository.find({
where: { age: Equal(25) }
})
아닌 값을 찾을 때 사용한다.
const user = await userRepository.find({
where: { age: Not(25) }
})
적은 값 & 적거나 같은 값을 찾을 때 사용한다
const user = await userRepository.find({
where: { age: LessThan(25) }
})
const user = await userRepository.find({
where: { age: LessThanOrEqual(25) }
})
사이 값을 찾을 때 사용한다.
const user = await userRepository.find({
where: { age: Between(20, 30) }
})
스트링에 매칭되는 값을 찾을 때 사용한다. Like는 대소문자 구분할 때, ILike는 대소문자 구분하지 않을 때 사용한다.
const user = await userRepository.find({
where: { age: Like("Co%") }
})
const user = await userRepository.find({
where: { age: ILike("co%") }
})
리스트에 매칭되는 값을 찾는다.
const user = await userRepository.find({
where: { age: In([20, 25, 30]) }
})
Null인 값을 필터링할 때 사용
const user = await userRepository.find({
where: { profile: IsNull() }
})
통계
- count : 해당되는 갯수를 반환한다.
- sum : 해당되는 칼럼 값을 모두 더한다.
- average : 해당되는 칼럼 값의 평균을 구한다.
- minimum : 해당되는 칼럼 값의 최소치를 반환한다.
- maximum : 해당되는 칼럼 값의 최대치를 반환한다.
import { Column } from "typeorm"
export class Name {
@Column()
first: string
@Column()
last: string
}
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"
import { Name } from "./Name"
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: string
@Column(() => Name)
name: Name
@Column()
isActive: boolean
}
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"
import { Name } from "./Name"
@Entity()
export class Employee {
@PrimaryGeneratedColumn()
id: string
@Column(() => Name)
name: Name
@Column()
salary: number
}
// 추상 클래스
// 인스턴스 생성 불가
// 상속만 가능
export abstract class Content {
@PrimaryGeneratedColumn()
id: number
@Column()
title: string
@Column()
description: string
}
@Entity()
export class Photo extends Content {
@Column()
size: string
}
@Entity()
export class Question extends Content {
@Column()
viewCount: number
}
@Entity()
@TableInheritance({ column: { type: "varchar", name: "type" } })
export class Content {
@PrimaryGeneratedColumn()
id: number
@Column()
title: string
@Column()
description: string
}
@ChildEntity()
export class Photo extends Content {
@Column()
size: string
}
@ChildEntity()
export class Question extends Content {
@Column()
viewCount: number
}
TypeORM이 제공하는 네가지 Relationship
@OneToOne(일대일) - A 테이블의 Row 하나와 B 테이블의 Row 하나가 연결되는 관계
@ManyToOne(다대일) - A 테이블의 Row 여러개와 B 테이블의 Row 하나가 연결되는 관계
@OneToMany(일대다) - A 테이블의 Row 하나와 B 테이블의 Row 여러개가 연결되는 관계
@ManyToMany(다대다) - A 테이블의 Row 여러개와 B 테이블의 Row 여러개가 연결되는 관계
첫번째 파라미터에는 타입을 반환하는 함수를 입력한다. (Class
Transformer Type과 같은 개념). 두번째 파라미터에는 첫번째 파라미터에 입력한 클래스의 칼럼중 하나를 입력한다. 이 칼럼은 서로 관련지을 프로퍼티여야한다.
예를들어 ManyToOne 관계이니 photo 테이블에 user_id 칼럼이 생성되며 user 테이블과 관계가 형성된다.
특정 photo와 관련있는 user는 photo.user로 불러올 수 있고, user와 관련있는 photo들은 user.photos로 불러 올 수 있다.
@Entity()
export class Photo {
@PrimaryGeneratedColumn()
id: number
@Column()
url: string
@ManyToOne(
() => User,
(user) => user.photos
)
user: User
@Entity()
export class User{
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@OneToMany(
() => Photo,
(photo) => photo.user
)
photo: Photo[]
photo 테이블에는 user_id 칼럼 자동으로 생긴다. 네이밍 패턴은
{상대 테이블 이름}_id
. user_id는 user 테이블의 id 칼럼을 Foreign Key로 레퍼런스한다.user 테이블은 추가로 칼럼이 생성되지 않는다. 원래 ManyToOne 또는 OneToMany 관계는 Foreign Key 레퍼런스를 들고있는 테이블이 Many 입장이다.
OneToOne Relationship도 마찬가지로 Decorator 원하는 프
로퍼티에 정의해주면 된다. ManyToOne은 상대의 레퍼런스를 갖는 테이블이 명확하다. OneToOne은 두 테이블 누가 레퍼런스를 들고 있어도 상관이 없기 때문에 어떤 테이블이 레퍼런스를 들고 있을지 명시해줘야 한다. JoinColumn Decorator을 사용해서 어떤 프로퍼티가 레퍼런스를 들고 있을지 정해 줄 수 있다. JoinColumn은 꼭 한쪽에만 적용해야한다. 둘 모두 적용하는건 불가능하고 의미도 없다.
@Entity()
export class Profile{
@PrimaryGeneratedColumn()
id: number
@Column()
gender: string
@Column()
photo: string
@OneToOne(
() => User,
(user) => user.profile
)
profile: Prifile
@Entity()
export class User{
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@OneToOne(() => Profile)
@JoinColumn()
profile: Profile
ManyToMany Relationship도 OneToOne Relationship과 마찬가지로 JoinTable Decorator 한쪽에 적용 해줘야한다. 중간 테이블이 생성될 때 JoinTable이 적용된 테이블 이름이 먼저 위치하게된다. JoinTable은 주로 두 테이블 간의 메인이 되는 table에 Decorator를 달아주는 편이다.
@Entity()
export class Category{
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@ManyToMany(() => Question)
questions: Question[];
@Entity()
export class Question{
@PrimaryGeneratedColumn()
id: number
@Column()
title: string
@Column()
text: string
@ManyToMany(() => Category)
@JoinTable()
categories: Category[];
Relationship Decorator에서의 cascade
cascade는 연관된 엔티티(자식 엔티티)가 부모 엔티티의 작업과 함께 처리되도록 설정하는 옵션이며,@OneToOne, @OneToMany, @ManyToOne, @ManyToMany
관계에서 사용할 수 있다.cascade: true는 삽입, 업데이트, 삭제, 복구 모든 작업에 대해 자동으로 처리되도록 설정한다.
@Entity() class Author { @PrimaryGeneratedColumn() id: number; @Column() name: string; @OneToMany(() => Book, (book) => book.author, { cascade: true }) books: Book[]; } @Entity() class Book { @PrimaryGeneratedColumn() id: number; @Column() title: string; @ManyToOne(() => Author, (author) => author.books) author: Author; }
Decorator DataBase Schema에서의 자주 쓰이는 Option
- nullable : 해당 필드가 NULL 값을 허용할지 여부를 결정한다.
@Column({ type: 'string', nullable: true }) description?: string;
- unique : 해당 필드가 데이터베이스에서 유일한 값을 가져야 하는지 설정한다.
@Column({ type: 'string', unique: true }) email: string;
- cascade : 연관된 엔터티에서 특정 작업(삽입, 업데이트, 삭제 등)이 실행될 때, 이 엔터티도 자동으로 영향을 받도록 설정한다.
@OneToMany(() => Post, (post) => post.author, { cascade: true }) posts: Post[];
- default : 필드의 기본 값을 설정할 때 사용한다.
@Column({ type: 'string', default: 'active' }) status: string;
- length : 해당 문자열 필드의 최대 길이를 설정할 때 사용한다.
@Column({ type: 'string', length: 50 }) name: string;