gpt가 써줌
NestJS와 TypeORM을 처음 사용하면 Module, Controller, Service, Entity, Repository, DTO가 한꺼번에 등장해서 구조가 헷갈릴 수 있다.
책(Book) API를 기준으로 전체적인 흐름을 보면 다음과 같다.
Client
↓
Controller
↓
Service
↓
Repository
↓
TypeORM
↓
Database
DTO는 요청 데이터를 검증하거나 응답 데이터를 정의하는 데 사용한다.
Module은 관련된 Controller, Service, Repository 등을 하나의 기능 단위로 묶고 NestJS의 의존성을 관리하는 역할을 한다.
@Module({
imports: [
TypeOrmModule.forFeature([BookEntity]),
],
controllers: [BookController],
providers: [BookService],
})
export class BookModule {}
BookModule에서는 BookController와 BookService를 사용하고, BookEntity의 Repository를 주입받을 수 있도록 설정한다.
forRoot()와 forFeature()TypeOrmModule.forRoot(...)
애플리케이션의 TypeORM DataSource와 DB 연결을 설정한다.
TypeOrmModule.forFeature([BookEntity])
해당 Module의 DI 컨테이너에서 BookEntity에 대한 Repository를 주입받을 수 있도록 등록한다.
Controller는 HTTP 요청을 받아 적절한 Service를 호출하고 결과를 응답하는 역할을 한다.
@Controller('books')
export class BookController {
constructor(
private readonly bookService: BookService,
) {}
@Get()
findAll() {
return this.bookService.findAll();
}
}
따라서
GET /books
↓
BookController
↓
BookService
와 같은 흐름이 만들어진다.
Controller에 DB 조회 로직이나 복잡한 비즈니스 로직을 직접 작성하기보다는 Service에 맡기는 것이 일반적이다.
Service는 비즈니스 로직을 처리하는 곳이다.
@Injectable()
export class BookService {
constructor(
@InjectRepository(BookEntity)
private readonly bookRepository: Repository<BookEntity>,
) {}
async findAll(): Promise<BookResponseDto[]> {
const books = await this.bookRepository.find({
relations: {
category: true,
},
order: {
bookId: 'DESC',
},
});
return books.map(BookResponseDto.from);
}
}
@InjectRepository(BookEntity)를 사용하면 NestJS가 BookEntity에 연결된 Repository를 주입해준다.
여기서 중요한 점은 Repository를 직접 구현한 것이 아니라 TypeORM이 제공하는 Repository를 NestJS의 DI를 통해 주입받는 것이다.
Entity는 TypeORM이 데이터베이스 테이블과 매핑하기 위해 사용하는 클래스다.
@Entity('book')
export class BookEntity {
@PrimaryGeneratedColumn({
name: 'book_id',
type: 'bigint',
})
bookId!: string;
@Column({
type: 'varchar',
length: 100,
})
title!: string;
@Column({
type: 'text',
nullable: true,
})
description!: string | null;
}
여기서
@Entity('book')
은 이 Entity가 book 테이블에 매핑된다는 의미이고,
@Column({ name: 'book_id' })
bookId!: string;
처럼 설정하면 bookId 프로퍼티와 book_id 컬럼이 매핑된다.
또한 Entity에는 테이블 간 관계도 표현할 수 있다.
@ManyToOne(() => CategoryEntity, (category) => category.books)
@JoinColumn({
name: 'category_id',
referencedColumnName: 'categoryId',
})
category!: Relation<CategoryEntity>;
이 경우 BookEntity와 CategoryEntity 사이의 ManyToOne 관계를 TypeORM에 알려준다.
Repository는 특정 Entity를 대상으로 데이터베이스 작업을 수행하기 위한 TypeORM의 객체다.
Repository<BookEntity>
를 통해 다음과 같은 작업을 할 수 있다.
bookRepository.find();
bookRepository.findOne(...);
bookRepository.save(book);
bookRepository.delete(...);
예를 들어:
const books = await this.bookRepository.find({
relations: {
category: true,
},
order: {
bookId: 'DESC',
},
});
는 BookEntity를 기준으로 데이터를 조회하고, category 관계도 함께 가져오며, bookId를 내림차순으로 정렬하는 코드다.
처음에는 TypeORM이 제공하는 기본 Repository를 사용하는 것만으로 충분하다.
Entity 사이에 관계가 있다면 TypeORM의 Relation을 사용할 수 있다.
예를 들어 하나의 Category에 여러 Book이 존재한다면:
CategoryEntity
│
└── books ──→ BookEntity
Book에서는:
@ManyToOne(() => CategoryEntity, (category) => category.books)
category!: Relation<CategoryEntity>;
Category에서는:
@OneToMany(() => BookEntity, (book) => book.category)
books!: Relation<BookEntity[]>;
처럼 양쪽 관계를 정의할 수 있다.
그리고 실제 조회할 때:
const books = await bookRepository.find({
relations: {
category: true,
},
});
처럼 작성하면 book.category 관계 데이터를 함께 조회할 수 있다.
DTO(Data Transfer Object)는 API를 통해 주고받는 데이터의 구조를 정의하기 위해 사용하는 객체다.
export class CreateBookDto {
@IsInt()
@Type(() => Number)
categoryId: number;
@IsNotEmpty()
@MaxLength(100)
title: string;
@IsOptional()
@IsString()
description?: string;
}
이 DTO는 책을 생성할 때 클라이언트가 보내는 데이터를 정의한다.
{
"categoryId": 1,
"title": "국부론",
"description": "경제학 서적"
}
class-validator와 class-transformer를 사용하면 요청 데이터의 검증과 변환도 할 수 있다.
Entity를 API 응답으로 그대로 반환하는 대신 필요한 데이터만 DTO로 변환할 수 있다.
export class BookResponseDto {
bookId: number;
title: string;
description: string | null;
categoryName: string;
isAvailable: boolean;
static from(book: BookEntity): BookResponseDto {
return {
bookId: +book.bookId,
title: book.title,
description: book.description,
categoryName: book.category.name,
isAvailable: book.isAvailable,
};
}
}
따라서:
BookEntity
↓
BookResponseDto.from()
↓
BookResponseDto
↓
HTTP Response
형태로 응답 데이터를 만들 수 있다.
GET /books 요청이 들어왔다고 가정하면:
Client
│
│ GET /books
↓
BookController
│
│ findAll()
↓
BookService
│
│ bookRepository.find()
↓
TypeORM Repository
│
↓
Database
│
↓
BookEntity[]
│
│ BookResponseDto.from()
↓
BookResponseDto[]
│
↓
HTTP Response
결국 각각의 역할은 다음과 같이 정리할 수 있다.
| 구성 요소 | 역할 |
|---|---|
| Module | 기능을 묶고 NestJS의 의존성을 구성 |
| Controller | HTTP 요청을 받고 응답 |
| Service | 비즈니스 로직 처리 |
| Repository | Entity를 대상으로 DB 작업 수행 |
| Entity | DB 테이블과 컬럼 및 관계를 TypeORM에 정의 |
| DTO | API 요청/응답 데이터의 구조 정의 |
NestJS + TypeORM에서는 Controller가 HTTP 요청을 받고, Service가 로직을 처리하며, Repository를 통해 Entity에 매핑된 데이터를 조회·저장하고, 필요한 경우 DTO로 변환해서 응답한다.
처음에는 아래 구조만 기억해도 충분하다.
HTTP
↓
Controller
↓
Service
↓
Repository
↓
Entity ↔ Database