NestJS와 사용하는 TypeORM 기초

2022. 6. 25. 23:37Web/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);

📚 다양한 연관 관계 매핑 방법

연관 관계 매핑 시 고려사항

  1. 다중성
    • 연관 관계의 종류를 파악해야 함
  2. 단방향 or 양방향
    • 테이블은 외래 키로 양방향 참조가 가능함
    • 하지만 객체는 참조를 통해서 조회하기 때문에 방향성을 가짐
  3. 연관 관계의 주인
    • 외래키를 어디에 둘 것인가?
    • 외래 키를 관리하는 객체를 연관관계의 주인이라고 함

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

 

반응형