2022. 6. 25. 23:37ㆍWeb/NestJS
해당 글은 교내 단체에서 진행한 스터디 내용중 직접 작성한 자료를 정리한 글 입니다.
스터디 목표
- ✅ ORM이 무엇이고 어떤 문제를 해결하는지 이해함
- ✅ TypeORM을 사용해 NestJS 애플리케이션과 RDBMS를 연동할 수 있음
- ✅ TypeORM으로 기본적인 CRUD 작업을 할 수 있음
👀 ORM이란?
- ORM은 Object Relational Mapping(객체-관계-매핑)의 약자이다.
- 객체와 데이터베이스의 데이터를 자동으로 매핑해주는 도구이다.
- ORM을 통해 객체 간의 관계를 바탕으로 SQL을 자동으로 생성해줌
- 대부분의 ORM은 연결되는 DB 종류에 따라 내부적으로 해당 DB에 알맞는 SQL 문법과 최적화 기법을 적용해줌
productsRepository.findOneBy({id: 1});
// SELECT * FROM PRODUCT WHERE PRODUCT.ID = 1;
productsRepository.findAll();
// SELECT * FROM PRODUCT;
// 더 복잡하고 여러 연관관계가 연결되는 구조에서도 비슷한 방법으로 활용할 수 있음
사용하는 이유?
- NestJS는 코드를 객체지향적으로 설계하도록 되어 있음
- 애플리케이션 = 객체로 데이터를 처리 ↔ DB = 테이블로 데이터를 처리
여기서 패러다임 불일치 발생
상속 가능 | 상속이란 개념이 없음 (구현은 가능하나 표준이 아님) |
객체 레퍼런스로 관계 형성 | 외래키로 관계 표현 |
객체간 방향이 존재 | Join을 통해 방향 없이 여러 테이블을 묶어서 조회할 수 있음 |
2개의 객체간 다대다 관계 형성 가능 | 두 테이블로 다대다 관계 형성 불가능 |
비교적으로 성능 상관 없이 자유롭게 관계 탐색 가능 | 테이블 관계의 깊이에 따라 탐색 성능이 달라지고 요구되는 쿼리도 달라짐 |
패러다임 불일치 - 탐색
class Product {
id: number
member: Member;
}
class Member {
id: number;
products: Product[];
}
...
// 객체: 자유롭게 탐색가능
product = new Product();
// 어떤 제품의 구매자 회원 선택
const member: Member = product.member;
// [추가사항] 위 회원의 제품들 선택
const products: Product[] = member.products;
// 가능 👍
// 테이블: 여러가지 제약이 있음
SELECT *
FROM PRODUCT
WHERE id = ?
// 어떤 제품의 구매자 회원 선택
SELECT *
FROM MEMBER
INNER JOIN PRODUCT
ON MEMBER.id = PRODUCT.member_id
WHERE PRODUCT.id = ?
// [추가사항] 위 카테고리의 제품들 선택? -> 새로운 쿼리를 필요로 함
SELECT *
FROM PRODUCT
INNER JOIN CATEGORY
ON PRODUCT.id = CATEGORY.product_id
WHERE CATEGORY.id = ?
⚠️ 만약 SQL을 직접 작성해서 사용하는 프로젝트였으면 코드가 변경 될 때마다 SQL문을 수정해주거나 추가해줘야하는 번거로운 일이 생김
패러다임 불일치 - Many to Many 관계
class Product {
id: number
members: Member[];
}
class Member {
id: number;
products: Product[];
}
⚠️ 객체는 두 개의 객체로 처리가 가능하지만, 테이블은 중간에 관계용 테이블이 하나 더 있어야 함
장점만 있을까…?
ORM의 단점
- 로직 복잡도가 올라갈수록 ORM의 최적화가 부족해지거나 별도의 특수한 튜닝을 위해 SQL문을 직접 작성해야 하는 경우가 생김
- 복잡한 쿼리는 오히려 SQL문으로 작성하는게 편할 때도 있음
💁🏻 TypeORM 소개
- 타입스크립트와 자바스크립트(ES5, ES6, ES7, ES8)와 함께 사용할 수 있음
- MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, ORACLE, SAP Hana, WebSql 등 지원
- NestJS에서 공식으로 지원하는 ORM중 하나 (전용 모듈이 있음)
- 무려 6년전에 처음 나왔지만 아직 0.3 버전대로 개발 속도가 좀 더딘게 단점임
다양한 기능 제공
자세한 내용이 궁금하면 👉 https://typeorm.io/
🖇 NestJS와 TypeORM 연동
git clone [https://gitlab.scg.skku.ac.kr/scg/scg-nest-study.git](https://gitlab.scg.skku.ac.kr/scg/scg-nest-study.git)
git checkout class3-skeleton
// class2에서 진행한 내용에서 이어하고 싶으면 - 테스트 파일만 복붙
// test/products.e2e-spec.ts
// src/products/products.service.spec.ts
필요한 모듈 설치
npm install --save @nestjs/typeorm typeorm mysql2
Root 모듈에서 DB 연결 설정
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: '1234',
database: 'nest',
autoLoadEntities: true,
synchronize: true,
logging: true,
}),
ProductsModule,
],
controllers: [],
providers: [],
})
export class AppModule {}
옵션 | 설명 |
type | 연결하는 DB 종류 |
autoLoadEntities | true인 경우, 엔티티들이 자동으로 불러와짐 (기본 false) |
entities | autoLoadEntities 옵션을 사용 안하면 entities: [] 옵션에 사용할 모든 엔티티를 넣어줘야함. ”*/entities/.entity.ts” 같은 패턴형식도 가능 |
synchronize | 프로그램 시작할 때마다 DB 스키마를 자동으로 재생성 하는지 여부. production에서는 스키마를 바꾸면서 데이터가 날아갈 수 있기 때문에 사용 안하고 개발중이나 테스트 시에만 사용. (기본 false) |
logging | 로깅 여부. true인 경우, 쿼리문과 에러가 로깅됨. 위와 같이 여러가지 옵션을 적용 가능. |
👉 자세한 추가 옵션들은 https://typeorm.io/data-source-options 참조
연결 확인
위와 같이 연결에 성공하면 DB와 연동에 성공한 것임
🙆🏻♂️ 이제 TypeORM의 `DataSource` 와 `EntityManager` 객체를 프로젝트 어디서든 특정 모듈을 import 하지 않고도 주입 받을 수 있음
import { DataSource } from 'typeorm';
@Module({
imports: [TypeOrmModule.forRoot(), UsersModule],
})
export class AppModule {
constructor(private dataSource: DataSource) {}
}
실패한 경우 (에러 메시지 읽고 잘 해결하면 됨)
✏️ 기본적인 CRUD 처리 방법
Entity란?
- 데이터베이스 테이블에 매핑되는 클래스
@Entity()
데코레이터를 적용해서 생성할 수 있음
Entity 생성
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
name: string;
@Column()
price: number;
@Column({ nullable: true })
description: string;
@Exclude()
@Column({ default: false })
isUpdated: boolean;
}
👉 엔티티에 사용되는 다양한 데코레이터들과 옵션들은 https://typeorm.io/entities 참조
모듈에 엔티티 사용 설정
@Module({
imports: [TypeOrmModule.forFeature([Product])],
controllers: [ProductsController],
providers: [ProductsService],
})
export class ProductsModule {}
애플리케이션 실행
TypeOrmModule.forFeature 를 사용해서 해당 모듈에서 사용할 엔티티의 repository를 주입 가능하게 함 (이 시점부터 DB에 엔티티를 synchronize하기 시작)
DB에 자동으로 생성된 테이블 스키마
Repsoitory 사용법
@Injectable()
export class ProductsService {
constructor(
@InjectRepository(Product) private productsRepository: Repository<Product>,
) {}
// ...
}
repository 객체를 주입 받을 수 있게 생성자를 추가
findAll(): Product[] {
return this.products;
}
// 아래로 변경
async findAll(): Promise<Product[]> {
return this.productsRepository.find();
}
이제 연결된 DB의 Product 테이블 데이터들을 가져와서 product 객체에 매핑해서 반환함
❓ 잠깐, Service에서 Promise를 반환하고 Controller에서도 그것을 그냥 반환해버리면 API 응답은 Product가 아니라 Promise가 되어야 하는거 아닌가…?
이유 해설
- NestJS는 Controller가 Promise를 반환할 경우, 자동으로 await을 해서 결과를 응답으로 반환함
- 다만, Controller에서 Service가 반환하는 product 객체를 사용해야 할 경우, await으로 Promise의 객체를 받아야 함 (함수 자체도 물론 async로 바꿔야 함)
@Get()
async findAll() {
product: Product = await this.productsService.findAll();
// ...
return product;
}
Repository란?
- 데이터 출처(로컬 DB인지 API응답인지 등)와 관계 없이 동일 인터페이스로 데이터에 접속할 수 있도록 만드는 것
- ex) DB를 MySQL에서 PostgreSQL로 바꿔도 Service에서 Repository를 사용한 코드를 변경할 필요가 없다. (DB를 변경함으로서 바뀌는 SQL 문법은 Repository의 구현체를 처리하는 TypeORM이 대신 처리해서 주입해주기 때문)
실습 시간
👨🏻💻 나머지 Service 함수들 DB와 연결하기
GitHub - Gongmeda/scg-nest-study: SCG 여름방학 스터디 NestJS 실습자료
SCG 여름방학 스터디 NestJS 실습자료. Contribute to Gongmeda/scg-nest-study development by creating an account on GitHub.
github.com
위 레포지토리에서 실습 자료를 다운받으실 수 있습니다. 아래의 메소드들을 사용해 class3-skeleton 브랜치의 코드를 class3-answer 브랜치와 같이 동작하도록 구현하는 실습입니다.
어떤 기능들이 있는지 확인하고 테스트하기 편하게 테스트코드를 구현해 놨습니다. 'npm run test', 'npm run test:e2e' 명령어로 테스트를 돌려볼 수 있습니다.
// Create: 엔티티 저장하기
this.productsRepository.save(product);
// Read: 모든 엔티티 가져오기
this.productsRepository.find();
// Read: 엔티티 가져오기
this.productsRepository.findOneBy({ id: id });
// Update: 엔티티 데이터 업데이트 하기
this.productsRepository.update(id, data);
// Delete: 엔티티 제거하기
this.productsRepository.remove(product);
📚 다양한 연관 관계 매핑 방법
연관 관계 매핑 시 고려사항
- 다중성
- 연관 관계의 종류를 파악해야 함
- 단방향 or 양방향
- 테이블은 외래 키로 양방향 참조가 가능함
- 하지만 객체는 참조를 통해서 조회하기 때문에 방향성을 가짐
- 연관 관계의 주인
- 외래키를 어디에 둘 것인가?
- 외래 키를 관리하는 객체를 연관관계의 주인이라고 함
1:1 (일대일)
유저는 하나의 제품을 가질 수 있고, 제품은 하나의 유저에 연결되어 있음
단방향 매핑
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
// ...
}
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
// ...
@OneToOne(() => Product)
@JoinColumn()
product: Product;
}
// 연관 관계 저장 방법
const product = new Product();
product.name = '컴퓨터';
product.price = 10000;
product.description = '맥북 프로';
product.isUpdated = false;
await this.productsRepository.save(product);
const user = new User();
user.name = '고현수';
user.product = product;
await this.usersRepository.save(user);
@OneToOne()
데코레이터로 일대일 관계를 표현@OneToOne(() => Product)
옵션으로 product 엔티티 테이블과 연관 관계를 맺음을 나타냄@JoinColumn
데코레이터로 연관 관계의 주인을 나타냄- foreign key가 있어야 하는 테이블에 매핑된 엔티티에서 사용
- 위 예시에서는 user 테이블에 FK가 존재함을 의미
+-------------+--------------+----------------------------+
| product |
+-------------+--------------+----------------------------+
| id | int(11) | PRIMARY KEY AUTO_INCREMENT |
+-------------+--------------+----------------------------+
+-------------+--------------+----------------------------+
| user |
+-------------+--------------+----------------------------+
| id | int(11) | PRIMARY KEY AUTO_INCREMENT |
| productId | int(11) | FOREIGN KEY |
+-------------+--------------+----------------------------+
위와 같은 테이블이 생성되고 연결됨 (user 테이블에 FK인 profileId가 있음을 확인)
❓ 이제 user와 product의 연관 관계가 매핑 되었지만, user.product로 조회할 수는 있지만 product.user로는 조회할 수 없다. 근데 DB는 방향성이 없어서 서로 조회 된다 그랬는데… 그러면 객체에서는 어떻게 해야 양쪽에서 서로를 조회할 수 있을까?
양방향 매핑
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
// ...
@OneToOne(() => User, (user) => user.product) // 반대쪽에 매핑된 필드를 2번째 파라미터로 명시
user: User;
}
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToOne(() => Product, (product) => product.user) // 반대쪽에 매핑된 필드를 2번째 파라미터로 명시
@JoinColumn()
product: Product;
}
// 1. 연관 관계 주인이 아닌 쪽 먼저 저장
const product = new Product();
product.name = '컴퓨터';
product.price = 10000;
product.description = '맥북 프로';
product.isUpdated = false;
await this.productsRepository.save(product);
// 2. 매핑되야 하는 엔티티 추가해서 주인 저장
const user = new User();
user.name = '고현수';
user.product = product;
await this.usersRepository.save(user);
// 3. DB와 상관은 없지만 참조 관계를 위해 넣어줌
product.user = user;
const user = new User();
user.name = '고현수';
await this.usersRepository.save(user);
const product = new Product();
product.name = '컴퓨터';
product.price = 10000;
product.description = '맥북 프로';
product.isUpdated = false;
product.user = user;
await this.productsRepository.save(product);
user.product = product;
💡 양방향 매핑에서 한쪽만 저장하면 반대쪽에서는 조회할 수 없다. 그래서 보통 저장 이후에도 객체에 접근하는 로직이 있다면 추천하는 방식은 '양쪽에 서로를 모두 저장하는 것'임
❓ 왜 아래 코드는 위 코드보다 UPDATE 쿼리가 하나 더 호출될까?
이유 해설
- FK는 user 테이블에 있음
- 따라서 윗쪽 코드는 product를 저장하고 그 product의 id를 user의 FK에 넣으면서 한번에 저장을 수행함
- 반면에 아래쪽 코드는 user을 저장할 때 지정된 FK가 없으니까 null로 저장한 후 product를 저장한 후 user의 FK를 찾아서 업데이트 하는 과정까지 추가되기 때문임
- 따라서 쿼리 최적화를 하려면 윗쪽 코드와 같이 연관 관계의 주인이 아닌(FK가 없는) 엔티티를 먼저 저장하는 것을 권장
M:1 (다대일)
유저는 여러개의 제품을 가질 수 있고, 제품은 하나의 유저에 연결되어 있음
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
// ...
@ManyToOne(() => User)
@JoinColumn
user: User;
}
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
const user = new User();
user.name = '고현수';
await this.usersRepository.save(user);
const product1 = new Product();
product1.name = '컴퓨터';
product1.price = 10000;
product1.description = '맥북 프로';
product1.isUpdated = false;
product1.user = user;
await this.productsRepository.save(product1);
const product2 = new Product();
product2.name = '책';
product2.price = 1000;
product2.description = '나루토 1권';
product2.isUpdated = false;
product2.user = user;
await this.productsRepository.save(product2);
1:M (일대다)
유저는 여러개의 제품을 가질 수 있고, 제품은 하나의 유저에 연결되어 있음
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
// ...
@ManyToOne(() => User, (user) => user.products) // 반대쪽에 매핑된 필드를 2번째 파라미터로 명시
@JoinColumn
user: User;
}
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToMany(() => Product, (product) => product.user) // 반대쪽에 매핑된 필드를 2번째 파라미터로 명시
products: Product[]; // 복수 이름과 배열 형태로 수정
}
@OneToMany()
는 2번째 파라미터가 필수임- 따라서 단일로 사용할 수 없고,
@ManyToOne
와 같이 사용해야 함
const product1 = new Product();
product1.name = '컴퓨터';
product1.price = 10000;
product1.description = '맥북 프로';
product1.isUpdated = false;
await this.productsRepository.save(product1);
const product2 = new Product();
product2.name = '책';
product2.price = 1000;
product2.description = '나루토 1권';
product2.isUpdated = false;
await this.productsRepository.save(product2);
const user = new User();
user.name = '고현수';
user.products = [product1, product2];
await this.usersRepository.save(user);
M:N (다대다)
유저는 여러개의 제품을 가질 수 있고, 제품은 여러 유저에 연결되어 있음
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
// ...
}
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToMany(() => Product)
@JoinTable()
products: Product[];
}
@ManyToMany()
관계를 위해서@JoinTable()
는 필수로 필요함- 자동으로 M:N 관계를 위한 새로운 테이블을 생성함
- 다른 관계들과 동일한 방법으로 양방향 매핑도 가능
❓ 근데 다대다 관계에 속성이 필요한 경우에는 어떻게 할까? ex) 유저가 제품을 구매한 날짜를 기록하려면 어디에 기록해야 할까?
- 이러한 경우에는 보편적으로 1-M:N-1 형식의 관계로 해결함 (일대다 + 다대일)
- M:N 관계를 연결하는 테이블용 엔티티를 따로 만들어서 관리하는 방식
실무에서는 ManyToMany() 사용을 권장하지 않음 (위의 방법 권장)
연관 관계 옵션
@ManyToMany(() => User, (user) => user.products, {
/* 여기에 들어가는 옵션들 */
})
users: User[];
옵션 | 설명 |
eager | 기본 false true일시 find로 엔티티를 불러올때 해당 연관 관계를 자동으로 같이 불러옴 |
cascade | 영속성 전이라는 뜻 기본 false 부모 테이블의 값이 수정이나 삭제가 발생하면, 해당 값을 참조하고 있는 자식 테이블의 역시 종속적으로 수정 및 삭제가 일어나도록 하는 옵션 |
onDelete | 엔티티가 삭제될 시 어떻게 행동할지 지정하는 옵션 가능한 값으로는 "RESTRICT" , "CASCADE" , "SET NULL" 가 있음 |
nullable | 기본 true 기본 해당 연관 관계가 nullable 한지 여부 (FK의 nullable과 동일) |
orphanedRowAction | 가능한 값으로는 "nullify" , "delete" , "soft-delete" 가 있음 자세한 동작 방식은 아래 코드 참조 |
async updateUser() {
const user = await this.findById(data.userId);
user.userGroups = null
// user.userGroups = [] 도 동일
return await this.userRepository.save(user);
}
// orphanedRowAction 옵션이 delete면 기존에 userGroups에 있던 엔티티들이 삭제됨
soft delete?
- 실제 row를 삭제하지 않고 삭제 되었다고 명시만 하는 방법
@DeleteDateColumn
으로 설정된 날짜 column을 업데이트 하는 방식으로 동작
📄 페이징 처리 방법
페이징?
- 속도는 빠르게, 부하는 적게하기 위해 지금 당장 필요한 데이터만 가져올 수 있도록 데이터를 분리하는 작업
- ex) DB에 제품이 100만개가 등록 되어있는데 목록을 가져올 때 그냥
find()
하면…? - MySQL의 쿼리로는 LIMIT과 OFFSET을 이용함 (이것도 DB마다 조금씩 다름 = ORM의 장점이 또 빛나는 부분)
- ORM에서는 편하게 처리할 수 있도록 여러가지 기능들을 제공
페이지 객체 만들기
// 응답으로 보내줄 데이터를 감싸서 반환할 Page 클래스
export class Page<T> {
pageSize: number;
totalCount: number;
totalPage: number;
items: T[];
constructor(totalCount: number, pageSize: number, items: T[]) {
this.pageSize = pageSize;
this.totalCount = totalCount;
this.totalPage = Math.ceil(totalCount / pageSize);
this.items = items;
}
}
- 위와 같은 객체로 조회한 엔티티들을 감싸서 응답
- 프론트에서 아래와 같은 UI를 만들 때 필요한 데이터
페이징 요청 만들기
import { IsOptional, IsString } from 'class-validator';
export class PageRequest {
@IsString()
@IsOptional()
pageNo?: number | 1;
@IsString()
@IsOptional()
pageSize?: number | 10;
getOffset(): number {
if (this.pageNo < 1 || !this.pageNo) {
this.pageNo = 1;
}
if (this.pageSize < 1 || !this.pageSize) {
this.pageSize = 10;
}
return (Number(this.pageNo) - 1) * Number(this.pageSize);
}
getLimit(): number {
if (this.pageSize < 1 || !this.pageSize) {
this.pageSize = 10;
}
return Number(this.pageSize);
}
}
페이징 요청 Controller에 적용하기
@Get()
findAll(@Query() pageRequest: PageRequest) {
return this.productsService.findAll(pageRequest);
}
페이징 요청 Service에 적용하기
async findAll(pageRequest: PageRequest): Promise<Page<Product>> {
const [products, count] = await this.productsRepository.findAndCount({
skip: pageRequest.getOffset(),
take: pageRequest.getLimit(),
});
return new Page<Product>(count, pageRequest.getLimit(), products);
}
⚠️ 특별히 세팅을 안바꿨다면 아래와 같은 오류가 날 것임
해결방법 - main.ts에 가서
app.useGlobalPipes(*new* ValidationPipe())을
app.useGlobalPipes(*new* ValidationPipe({ transform: *true* }))로
바꿔주면 해결
validation을 사용하는 경우, 요청을 클래스로 매핑하기 위해서는 필요하다고 함
https://github.com/nestjs/nest/issues/552 참조
페이징 요청해보기
출처
Documentation | NestJS - A progressive Node.js framework
Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Progamming), FP (Functional Programming), and FRP (Functional Reac
docs.nestjs.com
TypeORM - Amazing ORM for TypeScript and JavaScript (ES7, ES6, ES5). Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server,
typeorm.io
[DB] ORM (Object Relational Mapping) 사용 이유, 장단점
ORM (Object Relational Mapping) 객체지향적 구조? 모든 데이터는 객체이며, 각 객체는 독립된 데이터와 독립된 함수를 지님 SQL 구조? 데이터는 테이블 단위로 관리되며 객체들을 조회하기 위한 명령어
eun-jeong.tistory.com
[TypeOrm]ORM을 프로젝트에 도입할 때 주의할점
ORM에 대한 찬반양론 JPA와 TypeORM이 꽤 많이 쓰이는 것으로 인식되고 있지만, ORM의 효용성 여부는 아직까지도 실무자들 사이에 큰 논란이 되고 있다. https://martinfowler.com/bliki/OrmHate.html bliki: OrmH..
itchallenger.tistory.com
연관 관계 - 정리
1. 엔티티 - 연관 관게 매핑 (1) 다중성 연관 관계가 있는 두 엔티티가 일대일, 일대다, 다대다 관계인지 파악해야 한다. 보통 다대일, 일대다를 가장 많이 사용하고, 다대다 관계는 거의 사용하지
velog.io
Nestjs 페이지네이션 구현하기
페이지네이션이란? 웹사이트를 이용할 때 게시글을 한번에 보여주지 않고 전체 게시글을 나눠서 페이지 별로 볼 수 있게 하는 구조를 많이 사용하는데, 여기서 페이지를 나누고 요청한 페이지
velog.io