TypeORM
1. TypeORM이란?
- TypeScript 및 JavaScript에서 사용 가능한 ORM(Object-Relational Mapping) 라이브러리
- 데이터베이스와의 상호작용을 간편하게 처리할 수 있음
- 클래스 기반의 엔티티를 사용하여 테이블을 정의하고, CRUD 작업을 수행 가능
※ ORM(Object Relational Mapping)
ORM은 클래스로 작성한 오브젝트를 매핑하여 db에 테이블로 반영하는것
‘객체로 연결을 해준다’는 의미로, 어플리케이션과 데이터베이스 연결 시 SQL언어가 아닌 어플리케이션 개발언어로 데이터베이스를 접근할 수 있게 해주는 툴입니다.
2. 테이블 작성하기 - Entity와 기본 데코레이터
데코레이터
데코레이터 | 설명 및 사용 예 |
@Entity | 클래스가 데이터베이스의 테이블임을 선언합니다. 예: @Entity('users') |
@PrimaryGeneratedColumn | 기본키를 자동으로 생성합니다. 유니크 자동적용. 예: @PrimaryGeneratedColumn() |
@Column | 일반 컬럼을 정의합니다. 데이터 타입, 길이, 기본값 등 다양한 옵션을 설정할 수 있습니다. 예: @Column({ type: 'varchar', length: 100 }) |
@OneToOne | 일대일 관계를 설정합니다. 예: @OneToOne(() => Profile) |
@OneToMany | 일대다 관계를 설정합니다. 한 엔티티가 여러 엔티티와 연결될 때 사용합니다. 예: @OneToMany(() => Post, post => post.user) |
@ManyToOne | 다대일 관계를 설정합니다. 여러 엔티티가 하나의 엔티티와 연결될 때 사용합니다. 예: @ManyToOne(() => User, user => user.posts) |
@ManyToMany | 다대다 관계를 설정합니다. 서로 여러 엔티티가 연결될 때 사용합니다. 예: @ManyToMany(() => Role) |
@JoinColumn | 일대일, 다대일 관계에서 연결할 컬럼을 지정합니다. 예: @JoinColumn() |
@JoinTable | 다대다 관계에서 중간 테이블을 생성할 때 사용합니다. 예: @JoinTable() |
@CreateDateColumn | 엔티티 생성 시 자동으로 생성일을 기록합니다. 예: @CreateDateColumn() |
@UpdateDateColumn | 엔티티 업데이트 시 자동으로 수정일을 기록합니다. 예: @UpdateDateColumn() |
@DeleteDateColumn | 소프트 삭제를 사용할 때 삭제일을 기록합니다. 예: @DeleteDateColumn() |
@Index | 컬럼에 인덱스를 생성하여 조회 성능을 향상시킵니다. 예: @Index('IDX_USER_EMAIL', ['email']) |
@Unique | 특정 컬럼에 유니크 제약 조건을 추가하여 중복을 방지합니다. 예: @Unique(['username']) |
테이블 간의 관계 설정
관계 유형 | 데코레이터 | 설명 | 예시 (A → B) |
1:1 (One-to-One) | @OneToOne | A 엔티티가 B 엔티티와 1:1로 연결됨 | 사용자 → 프로필 (User → Profile) |
1:N (One-to-Many) | @OneToMany @ManyToOne |
A 엔티티가 여러 개의 B 엔티티를 가짐 | 사용자 → 게시글 (User → Post) |
N:M (Many-to-Many) | @ManyToMany @JoinTable |
A와 B가 다대다 관계이며, 중간 테이블이 필요 | 학생 ↔ 수업 (Student ↔ Class) |
- @JoinColumn()은 어느 쪽에서 외래키를 소유할지 결정합니다.
- { cascade: true } 옵션은 주 테이블에 데이터를 추가할 때 연결된 테이블에도 연결된 데이터가 생성됩니다.
- One-to-Many는 한쪽에서 배열 형태
- Many-to-One은 반대쪽에서 단일 객체로 표현하여 양방향 연결
- 다대다 N:M은 중간테이블이 필요하고 그 중간 테이블은 자동으로 생성 됩니다 (수동도 가능)
- 중간 테이블 변경 - @JoinTable()
// 기본적으로 생성되는 user_roles_role 대신 user_roles라는 중간 테이블이 생성
@JoinTable({ name: 'user_roles' }) // 중간 테이블 이름을 'user_roles'로 설정
// 컬럼명까지 원하는 이름으로 변경
@JoinTable({
name: 'user_roles', // 중간 테이블 이름 변경
joinColumn: { name: 'user_id', referencedColumnName: 'id' }, // 사용자 FK 이름 변경
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' } // 역할 FK 이름 변경
})
onDelete : 부모 엔티티가 삭제될 때 자식 엔티티의 동작을 결정하는 데이터베이스 옵션.
onDelete 옵션 | 동작 방식 |
CASCADE | 부모 레코드 삭제 시, 연결된 자식 레코드도 자동 삭제됨 |
SET NULL | 부모 레코드 삭제 시, 자식 레코드의 FK 값을 NULL로 설정 |
RESTRICT | 부모 레코드가 삭제되지 않도록 제한 (자식 레코드가 있으면 삭제 불가) |
NO ACTION | 삭제 시 아무 동작도 하지 않음 (DB 설정에 따라 기본 동작) |
테이블간의 관계 -예시
@OneToOne(() => Profile, profile => profile.user, { cascade: true })
// { cascade: true } 옵션은 User 저장 시 Profile도 함께 저장되도록 도와줍니다.
@JoinColumn() // 외래 키(achievement_id)를 포함하는 테이블, 즉 profile 엔티티에 위치해야 합니다.
profile: Profile;
@OneToMany(() => Post, post => post.user)
posts: Post[]; // One-to-Many는 한쪽에서 배열 형태
@ManyToOne(() => User, user => user.posts, { onDelete: 'CASCADE' })
user: User; // Many-to-One은 반대쪽에서 단일 객체로 표현하여 양방향 연결
@ManyToMany(() => Role, role => role.users)
@JoinTable()
// @JoinTable({ name: 'user_roles' }) // 중간 테이블 이름을 'user_roles'로 설정 가능
roles: Role[];
@ManyToMany(() => User, user => user.roles)
users: User[]; // id를 받은 테이블은 join 사용하지 않음
예시
+-------------------+
| User |
|-------------------|
| id |
| name |
+-------------------+
/ | \
/ | \
one-to-many / one-to-one \ many-to-many
/ | \
▼ ▼ ▼
+---------------+ +---------------+ +---------------+
| Post | | Profile | | Role |
|---------------| |-----------------| |----------------|
| id | | id | | id |
| title | | bio | | name |
+---------------+ +---------------+ +---------------+
// user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
ManyToMany,
JoinTable,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { Post } from './post.entity';
import { Role } from './role.entity';
@Entity('users') // 'users' 테이블을 생성합니다.
export class User {
@PrimaryGeneratedColumn() // 기본키로, 자동 증가하는 값을 사용합니다.
id: number;
@Column({ type: 'varchar', length: 100 }) // 유저의 이름을 저장하는 컬럼 (최대 100자)
name: string;
@Column({ type: 'varchar', unique: true, length: 150 }) // 유저 이메일 컬럼, 유니크 제약 조건 포함
email: string;
@Column({ type: 'varchar', length: 100, nullable: true }) // 비밀번호 컬럼, null 허용
password?: string;
@CreateDateColumn() // 레코드 생성 시 자동으로 생성일을 기록합니다.
createdAt: Date;
@UpdateDateColumn() // 레코드 업데이트 시 자동으로 수정일을 기록합니다.
updatedAt: Date;
// 유저와 프로필은 1:1 관계
// { cascade: true } 옵션을 사용하여 User를 만들때 Profile도 함께 생성
@OneToOne(() => Profile, profile => profile.user, { cascade: true })
@JoinColumn() // 주 테이블인 User에서 외래키를 생성. 둘중 1명은 반대쪽의 id를 알아야 하죠.
profile: Profile;
@OneToMany(() => Post, post => post.user)
// 한 유저가 여러 포스트를 가질 수 있는 일대다 관계를 설정합니다.
posts: Post[];
@ManyToMany(() => Role, role => role.users)
// 유저와 역할 간 다대다 관계를 설정합니다.
@JoinTable() // 주 테이블인 User에서 외래키를 생성 - 다대다 관계에서 중간 테이블을 자동으로 생성합니다.
roles: Role[];
}
// post.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity('posts') // 'posts' 테이블을 생성합니다.
export class Post {
@PrimaryGeneratedColumn() // 기본키, 자동 생성되는 컬럼입니다.
id: number;
@Column({ type: 'varchar', length: 200 }) // 포스트의 제목을 저장하는 컬럼 (최대 200자)
title: string;
@Column({ type: 'text' }) // 포스트의 내용을 저장하는 컬럼 (텍스트 타입)
content: string;
@CreateDateColumn() // 레코드 생성 시 자동으로 생성일을 기록합니다.
createdAt: Date;
@UpdateDateColumn() // 레코드 업데이트 시 자동으로 수정일을 기록합니다.
updatedAt: Date;
@ManyToOne(() => User, user => user.posts, { onDelete: 'CASCADE' })
// 여러 포스트가 하나의 유저에 속하는 다대일 관계를 설정합니다.
// 유저가 삭제되면 해당 유저의 포스트도 함께 삭제(CASCADE)됩니다.
user: User;
}
// role.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm';
import { User } from './user.entity';
@Entity('roles') // 'roles' 테이블을 생성합니다.
export class Role {
@PrimaryGeneratedColumn() // 기본키로, 자동 증가하는 값을 사용합니다.
id: number;
@Column({ type: 'varchar', length: 50, unique: true })
// 역할 이름을 저장하는 컬럼 (최대 50자, 유니크 제약 조건 포함)
name: string;
@ManyToMany(() => User, user => user.roles)
// 역할과 유저 간의 다대다 관계를 설정합니다.
users: User[];
}
// Profile.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm';
import { User } from './user.entity';
@Entity(Profile)
export class Profile {
@PrimaryGeneratedColumn()
id: number;
@Column()
bio: string;
// 반대쪽 관계를 설정합니다.
@OneToOne(() => User, user => user.profile)
user: User;
}
3. NestJS에서 Entity 적용하기 - TypeORM 설정
TypeORM 설정 파일을 만들어 DB와 연결합니다.
1. TypeORM 설정 파일 만들기 (src/config/typeorm.config.ts)
- TypeOrmModule.forRoot() : DB 연결하고 초기화하는 메서드
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import * as dotenv from 'dotenv';
dotenv.config();
export const typeORMConfig: TypeOrmModuleOptions = {
type: 'mysql', // 우린 mysql 사용
host: process.env.DB_HOST, // DB 서버 주소 (localhost 등)
port: parseInt(process.env.DB_PORT, 10), // DB 포트 (MySQL은 3306이 기본)
username: process.env.DB_USERNAME, // DB 접속 계정
password: process.env.DB_PASSWORD, // DB 접속 비밀번호
database: process.env.DB_DATABASE, // 사용할 데이터베이스 이름
entities: [__dirname + '/../**/*.entity.{js,ts}'], // 엔티티 자동 등록
synchronize: process.env.NODE_ENV !== 'production', // 개발 환경에서만 true
// synchronize를 계속 true를 한다면 실제 서비스에 있는 데이터가 삭제될 수 있습니다!
};
2. 환경변수 설정 (.env)
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=password
DB_DATABASE=mydb
NODE_ENV=development
3. AppModule 설정 (src/app.module.ts)
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { typeORMConfig } from './config/typeorm.config';
import { UsersModule } from './users/users.module';
@Module({
imports: [
**// DB 설정을 외부 파일에서 가져옵니다.**
**TypeOrmModule.forRoot(typeORMConfig),**
UsersModule,
],
})
export class AppModule {}
4. 기능별 모듈 설정 (src/users/users.module.ts)
- TypeOrmModule.forFeature() : 특정 모듈에서 사용할 엔티티를 등록하는 메서드
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
imports: [
TypeOrmModule.forFeature([User]), // 이 모듈에서 사용할 엔티티만 등록
],
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService], // 다른 모듈에서 UsersService를 사용하려면 꼭 추가
})
export class UsersModule {}
5. 마이그레이션 사용
1. 마이그레이션이란?
- 일반적 의미 :
- 데이터 이관(Data Migration): 데이터를 선택, 준비, 추출, 변환하여 한 스토리지 시스템에서 다른 스토리지 시스템으로 영구적으로 전송하는 과정.
- 여기서의 의미 :
- 데이터베이스 스키마 변경 관리:
애플리케이션 코드에 반영된 데이터베이스 구조 변경 사항(예: 새로운 컬럼 추가)을 안전하게 적용하고 관리하는 과정.
- 데이터베이스 스키마 변경 관리:
2. TypeORM 설정 파일 생성
- 파일명: typeorm.config.ts
- 주요 내용:
- 데이터베이스 연결 정보: MySQL 서버 정보(호스트, 포트, 사용자명, 비밀번호, 데이터베이스 이름 등)
- 엔티티 및 마이그레이션 파일 경로 지정:
- 엔티티: entities: [__dirname + '/../**/*.entity.{js,ts}']
- 마이그레이션: migrations: [__dirname + '/../migrations/*.ts']
-
- TypeORM CLI 도구 사용을 위한 DataSource 설정
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { DataSource, DataSourceOptions } from 'typeorm';
// NestJS에서 사용할 데이터베이스 설정
export const typeORMConfig: TypeOrmModuleOptions = {
type: 'mysql',
host: 'localhost', // 데이터베이스 주소
port: 3306, // MySQL 기본 포트
username: 'root', // 데이터베이스 사용자명
password: '1234', // 데이터베이스 비밀번호
database: 'migration_test_db', // 데이터베이스 이름
entities: [__dirname + '/../**/*.entity.{js,ts}'], // 엔티티 파일 위치
synchronize: false, // 개발 환경에서만 true로 설정
migrations: [__dirname + '/../migrations/*.ts'], // 마이그레이션 파일 위치
};
// TypeORM CLI 도구를 위한 설정
export default new DataSource(typeORMConfig as DataSourceOptions);
3. package.json 스크립트 수정
- TypeORM CLI 명령어를 실행하기 위한 스크립트 추가
{
"scripts": {
"typeorm": "typeorm-ts-node-commonjs",
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.config.ts",
"migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.config.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.config.ts",
"migration:show": "npm run typeorm -- migration:show -d src/config/typeorm.config.ts"
}
}
- 스크립트 설명:
- typeorm: TypeScript로 작성된 TypeORM CLI 명령어 실행 도구
- migration:generate: 데이터베이스 변경 사항을 감지하여 자동으로 마이그레이션 파일 생성
(예: npm run migration:generate -- src/migrations/AddAvatarColumn) - migration:run: 생성된 마이그레이션 파일을 데이터베이스에 적용
- migration:revert: 마지막에 실행된 마이그레이션을 롤백하여 이전 상태로 복구
- migration:show: 현재 적용 및 대기 중인 마이그레이션 목록 확인
모든 명령어에서 -d src/config/typeorm.config.ts를 통해 설정 파일의 위치를 지정
4. 마이그레이션 실습: User 엔티티에 avatar 컬럼 추가
4.1 User 엔티티 수정
- 기존 User 엔티티에 새로운 필드 avatar를 추가
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 20 })
username: string;
@Column({ unique: true })
email: string;
// 새로운 필드 추가!
@Column({
nullable: true,
length: 255,
})
avatar: string;
}
4.2 마이그레이션 파일 생성
- 아래 명령어를 통해 새로운 마이그레이션 파일을 생성
npm run migration:generate -- src/migrations/AddAvatarColumn
- 생성된 마이그레이션 파일 예시
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAvatarColumn1739840849793 implements MigrationInterface {
name = 'AddAvatarColumn1739840849793';
public async up(queryRunner: QueryRunner): Promise<void> { // up은 생성
await queryRunner.query(
`ALTER TABLE \`user\` ADD \`avatar\` varchar(255) NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> { // down은 삭제
await queryRunner.query(`ALTER TABLE \`user\` DROP COLUMN \`avatar\``);
}
}
4.3 마이그레이션 명령어 실행
# 현재 마이그레이션 상태 확인
npm run migration:show
# 마이그레이션 적용
npm run migration:run
# 문제가 있을 경우 롤백
npm run migration:revert
방법 2. TypeORM 설정 (typeORMConfig.ts)
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import * as dotenv from 'dotenv';
dotenv.config();
export const typeORMConfig: TypeOrmModuleOptions = {
type: 'mysql', // MySQL 데이터베이스 사용
host: process.env.DB_HOST, // DB 서버 주소
port: parseInt(process.env.DB_PORT, 10), // 포트 (기본값: 3306)
username: process.env.DB_USERNAME, // DB 계정
password: process.env.DB_PASSWORD, // DB 비밀번호
database: process.env.DB_DATABASE, // 사용할 데이터베이스
// 엔티티 자동 등록 (모든 엔티티 파일 포함)
entities: [__dirname + '/**/entities/*.{ts,js}'],
synchronize: process.env.DB_SYNC === 'true',
// 개발 환경에서는 true (DB 스키마 자동 동기화), 운영 환경에서는 false 추천
migrationsRun: process.env.DB_SYNC !== 'true',
// 마이그레이션을 자동 실행 (DB_SYNC가 false일 때 실행)
};
.env
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=password
DB_DATABASE=mydb
NODE_ENV=development
DB_SYNC=true
📍 마이그레이션 명령어
# 현재 마이그레이션 상태 확인
npm run migration:show
# 마이그레이션 실행
npm run migration:run
# 마이그레이션 롤백 (필요시)
npm run migration:revert
방법 2. TypeORM CLI를 이용한 마이그레이션 설정
🚀 차이점 요약
구분 | ypeORMConfig.ts (NestJS 내 설정) | data-source.ts (TypeORM CLI 설정) |
목적 | NestJS에서 TypeORM을 사용하도록 설정 | TypeORM CLI에서 마이그레이션 실행 |
적용 방식 | TypeOrmModule.forRoot(typeORMConfig)로 설정 | CLI에서 typeorm migration:run 실행 |
synchronize 사용 여부 | true 가능 (⚠️ 운영 환경에서는 false) | false (항상 마이그레이션으로 관리) |
마이그레이션 관리 | 직접 실행하지 않음 | CLI에서 migration:generate, migration:run 사용 |
운영 환경 적합성 | 개발 중 빠른 적용에 적합 | 운영 환경에서 안전한 DB 변경 가능 |
💡 결론
- 개발 환경에서는 typeORMConfig.ts에서 synchronize: true로 설정하여 편리하게 사용 가능
- 운영 환경에서는 synchronize: false로 설정하고 마이그레이션을 통해 데이터베이스를 안전하게 관리
- data-source.ts를 설정하고 TypeORM CLI로 마이그레이션을 실행하는 것이 운영 환경에서 권장됨
👉 NestJS에서 DB를 사용하려면 typeORMConfig.ts,
👉 DB 스키마 변경을 버전별로 관리하려면 data-source.ts + TypeORM CLI 사용! 🚀
프로젝트 때 사용하던 코드
app.module.ts
import { Module } from '@nestjs/common';
import * as Joi from 'joi';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PostModule } from './post/post.module';
// ... 기타 필요한 모듈 임포트 (예: UserModule 등)
const typeOrmModuleOptions = {
// 비동기로 TypeOrmModule의 설정을 생성하기 위한 팩토리 함수 정의
useFactory: (configService: ConfigService): TypeOrmModuleOptions => ({
// SnakeNamingStrategy를 사용해 코드 내의 camelCase 이름을 DB의 snake_case로 변환
namingStrategy: new SnakeNamingStrategy(),
type: 'mysql', // 데이터베이스 종류를 MySQL로 지정
username: configService.get('DB_USERNAME'), // 환경 변수로부터 DB 사용자명 가져오기
password: configService.get('DB_PASSWORD'), // 환경 변수로부터 DB 비밀번호 가져오기
host: configService.get('DB_HOST'), // DB 서버 호스트 주소 (예: localhost)
port: configService.get('DB_PORT'), // DB 접속 포트 (기본 MySQL 포트: 3306)
database: configService.get('DB_NAME'), // 사용할 데이터베이스 이름
entities: [__dirname + '/**/entities/*.{ts,js}'],
// 엔티티 파일들의 경로 지정 - 작업한 엔티티들을 자동으로 불러옴
// 주석: 실제 프로젝트 구조에 맞게 경로를 수정해야 할 수 있습니다.
// 아래처럼 환경에 따라 엔티티 경로를 다르게 설정할 수도 있습니다.
// entities: [
// process.env.NODE_ENV === 'production'
// ? 'dist/**/*.entity.js' // 배포 환경에서는 컴파일된 파일 사용
// : 'src/**/*.entity.ts', // 개발 환경에서는 TypeScript 파일 사용
// ],
synchronize: configService.get('DB_SYNC'),
// DB 스키마 자동 동기화 옵션: true면 기존 테이블에 변경사항을 자동 반영 (주의 필요)
migrationsRun: !configService.get('DB_SYNC'),
// 동기화가 비활성화된 경우, 앱 실행 시 마이그레이션 파일을 자동으로 적용
logging: true, // 데이터베이스 쿼리와 관련된 로그를 활성화 (디버깅에 유용)
}),
// ConfigService를 의존성 주입하여 환경 변수에 접근할 수 있도록 설정
inject: [ConfigService],
};
@Module({
imports: [
// ConfigModule: 환경 변수 로딩 및 전역에서 사용 가능하도록 설정
ConfigModule.forRoot({
isGlobal: true, // 다른 모듈에서 별도 임포트 없이 바로 사용 가능
validationSchema: Joi.object({
// 환경 변수의 유효성을 검증하기 위한 스키마 정의
// JWT_SECRET_KEY: Joi.string().required(),
DB_USERNAME: Joi.string().required(), // DB 사용자명은 반드시 제공되어야 함
DB_PASSWORD: Joi.string().required(), // DB 비밀번호는 반드시 제공되어야 함
DB_HOST: Joi.string().required(), // DB 호스트 주소 필수
DB_PORT: Joi.number().required(), // DB 포트는 숫자로 제공되어야 함
DB_NAME: Joi.string().required(), // DB 이름 필수
DB_SYNC: Joi.boolean().required(), // 스키마 동기화 옵션은 불리언 값이어야 함
}),
}),
// TypeOrmModule: 비동기 방식으로 데이터베이스 연결 설정을 로드
TypeOrmModule.forRootAsync(typeOrmModuleOptions),
// 애플리케이션에서 사용하는 기타 모듈들 (예: UserModule, PostModule 등) 등록
UserModule, ...PostModule,
],
controllers: [AppController], // 요청을 처리하는 컨트롤러 등록
providers: [AppService], // 비즈니스 로직을 담당하는 서비스 등록
})
export class AppModule {} // 애플리케이션의 루트 모듈
.env
# .env
DB_USERNAME = 'root'
DB_PASSWORD = 'aaaa1234'
DB_HOST = 'localhost'
DB_PORT = 3306
DB_NAME = 'pink_tophia'
DB_SYNC = false
4. CRUD 작업
- create() + save(): 데이터 추가
- find() / findOneBy(): 데이터 조회
- update(): 데이터 수정
- delete(): 데이터 삭제
🔹 TypeORM Repository 명령어
TypeORM: Active Record 패턴을 선호하면 추천 (엔티티를 직접 다룰 때 유리)
메서드 | 설명 | 예제 |
create() | 엔티티 객체 생성 (DB 저장 X) | const user = userRepository.create({ name: 'Yewon' }); |
save() | 엔티티를 DB에 저장 (수정 포함) | await userRepository.save(user); |
insert() | 데이터를 직접 삽입 (id 자동 생성) | await userRepository.insert({ name: 'Yewon' }); |
update() | 특정 조건의 데이터 수정 | await userRepository.update({ id: 1 }, { name: 'Updated' }); |
delete() | 특정 조건의 데이터 삭제 | await userRepository.delete({ id: 1 }); |
find() | 모든 데이터를 조회 | const users = await userRepository.find(); |
findOne() | 조건에 맞는 데이터 한 개 조회 | const user = await userRepository.findOne({ where: { id: 1 } }); |
findBy() | 여러 개 데이터 조회 (특정 조건) | const users = await userRepository.findBy({ name: 'Yewon' }); |
findOneBy() | 조건에 맞는 데이터 한 개 조회 (단순 조건) | const user = await userRepository.findOneBy({ id: 1 }); |
findAndCount() | 데이터 목록과 개수를 반환 | const [users, count] = await userRepository.findAndCount(); |
query() | 원시 SQL 실행 | await userRepository.query('SELECT * FROM user'); |
softDelete() | 데이터 삭제 (Soft Delete) | await userRepository.softDelete({ id: 1 }); |
restore() | Soft Delete 된 데이터 복구 | await userRepository.restore({ id: 1 }); |
count() | 특정 조건의 개수 조회 | const count = await userRepository.count({ where: { name: 'Yewon' } }); |
exist() | 특정 조건의 데이터 존재 여부 확인 | const isExist = await userRepository.exist({ where: { id: 1 } }); |
🔹 Prisma Repository 명령어
Prisma: Declarative API와 Schema 기반 접근이 편리한 경우 추천 (자동 생성되는 타입 안전성 활용 가능)
메서드 | 설명 | 예제 |
create() | 새로운 데이터 삽입 | await prisma.user.create({ data: { name: 'Yewon' } }); |
createMany() | 여러 개 데이터 삽입 | await prisma.user.createMany({ data: [{ name: 'A' }, { name: 'B' }] }); |
findUnique() | ID 기준으로 단일 조회 | const user = await prisma.user.findUnique({ where: { id: 1 } }); |
findFirst() | 조건에 맞는 첫 번째 데이터 조회 | const user = await prisma.user.findFirst({ where: { name: 'Yewon' } }); |
findMany() | 여러 개 데이터 조회 | const users = await prisma.user.findMany(); |
update() | 특정 조건의 데이터 수정 | await prisma.user.update({ where: { id: 1 }, data: { name: 'Updated' } }); |
updateMany() | 여러 개 데이터 수정 | await prisma.user.updateMany({ where: { name: 'Old' }, data: { name: 'New' } }); |
delete() | 특정 데이터 삭제 | await prisma.user.delete({ where: { id: 1 } }); |
deleteMany() | 여러 개 데이터 삭제 | await prisma.user.deleteMany({ where: { name: 'Test' } }); |
count() | 특정 조건의 개수 조회 | const count = await prisma.user.count(); |
aggregate() | 집계 연산 수행 | const result = await prisma.user.aggregate({ _avg: { age: true } }); |
groupBy() | 특정 필드 기준으로 그룹화 | const grouped = await prisma.user.groupBy({ by: ['role'], _count: { _all: true } }); |
🔹집계함수
집계(Aggregation)는 이미 계산된 데이터를 모아서 다시 계산하는 과정.
QueryBuilder를 이용하면 조건에 따른 집계 함수(COUNT, SUM, AVG 등)를 자유롭게 작성할 수 있다.
QueryBuilder : SQL 쿼리를 코드로 더 쉽게 작성할 수 있도록 도와주는 도구.
예시
async countUsers(): Promise<number> {
const { count } = await this.userRepository
.createQueryBuilder('user')
.select('COUNT(user.id)', 'count')
.getRawOne();
return parseInt(count, 10);
}
// 위 내용을 분석 하겠습니다~
// countUsers 이름만 보아도 무슨뜻인지 알겠죠? 유저의 수를 집계하겠다고 합니다.
createQueryBuilder('user')
// 'user'라는 별칭으로 쿼리를 만들기 시작할게요~
// 👉 SQL의 FROM users AS user 같은 거예요!
.select('COUNT(user.id)', 'count')
// user의 id를 기준으로 개수를 셀거예요, 뒤에 count는 결과에 key값
// 👉 SELECT COUNT(user.id) AS count 이런 SQL입니다.
.getRawOne();
// 쿼리 결과를 Raw 데이터로 하나만 가져올게요
// 👉 결과가 { count: "123" } 이런 형태로 나와요
return parseInt(count, 10);
// 문자열로 받은 count를 숫자 결과만 반환~ 예) 123
1. 유저 수 집계 (COUNT)
async countUsers(): Promise<number> {
const { count } = await this.userRepository
.createQueryBuilder('user')
.select('COUNT(user.id)', 'count')
.getRawOne();
return parseInt(count, 10);
}
🔍 분석:
1️⃣ createQueryBuilder('user') → 'user'라는 별칭으로 쿼리를 시작
2️⃣ .select('COUNT(user.id)', 'count') → COUNT(user.id) 값을 count라는 키로 저장
3️⃣ .getRawOne() → 결과를 { count: "123" } 형태로 반환
4️⃣ parseInt(count, 10) → 문자열을 숫자로 변환
📌 결과 예시: 123
2. 특정 유저의 게시글 수 집계 (COUNT with WHERE)
async countUserPosts(userId: number): Promise<number> {
const { count } = await this.postRepository
.createQueryBuilder('post')
.select('COUNT(post.id)', 'count')
.where('post.userId = :userId', { userId })
.getRawOne();
return parseInt(count, 10);
}
🔍 분석:
- 특정 유저(userId)의 게시글 개수를 집계
- .where('post.userId = :userId', { userId }) → 조건 추가
📌 결과 예시: 42 (해당 유저가 작성한 게시글 수)
3. 전체 게시글 좋아요 수 (SUM)
async sumPostLikes(): Promise<number> {
const { totalLikes } = await this.postRepository
.createQueryBuilder('post')
.select('SUM(post.likes)', 'totalLikes')
.getRawOne();
return parseInt(totalLikes, 10) || 0;
}
🔍 분석:
- SUM(post.likes) → 모든 게시글의 likes 값을 합산
- .getRawOne() → { totalLikes: "3456" } 형태로 반환
- parseInt(totalLikes, 10) || 0 → 값이 없을 경우 0 반환
📌 결과 예시: 3456 (전체 좋아요 수)
4. 게시글 평균 좋아요 (AVG)
async avgPostLikes(): Promise<number> {
const { avgLikes } = await this.postRepository
.createQueryBuilder('post')
.select('AVG(post.likes)', 'avgLikes')
.getRawOne();
return parseFloat(avgLikes) || 0;
}
🔍 분석:
- AVG(post.likes) → 게시글의 평균 좋아요 수
- parseFloat(avgLikes) || 0 → 소수점 포함한 평균 값 반환
📌 결과 예시: 15.6 (평균 좋아요 수)
5. 가장 많이 좋아요를 받은 게시글 (MAX)
async maxPostLikes(): Promise<number> {
const { maxLikes } = await this.postRepository
.createQueryBuilder('post')
.select('MAX(post.likes)', 'maxLikes')
.getRawOne();
return parseInt(maxLikes, 10) || 0;
}
🔍 분석:
- MAX(post.likes) → 가장 많은 좋아요를 받은 게시글의 likes 값
- .getRawOne() → { maxLikes: "250" } 형태로 반환
📌 결과 예시: 250 (가장 인기 있는 게시글의 좋아요 수)
6. 가장 적게 좋아요를 받은 게시글 (MIN)
async minPostLikes(): Promise<number> {
const { minLikes } = await this.postRepository
.createQueryBuilder('post')
.select('MIN(post.likes)', 'minLikes')
.getRawOne();
return parseInt(minLikes, 10) || 0;
}
🔍 분석:
- MIN(post.likes) → 가장 적게 좋아요를 받은 게시글의 likes 값
📌 결과 예시: 0 (좋아요를 받지 않은 게시글도 포함)
🔹 트랜잭션 처리 - 데이터 무결성
트랜잭션(Transaction)은 여러 개의 DB 작업(CRUD 등)을 하나의 단위(Atomicity)로 묶어서 처리하는 개념입니다.
- 모든 작업이 성공하면 Commit하여 DB에 반영
- 하나라도 실패하면 Rollback하여 원상 복구
NestJS에서는 TypeORM의 QueryRunner 를 사용하여 트랜잭션을 구현할 수 있습니다.
순서
1. QueryRunner로 트렌젝션 시작
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
2. try{}안에 작업 작성하고 커밋
3. 실패(catch{})시 롤백
4. 마지막(finaly{})에 결과에 관계없이 연결 끊기
await queryRunner.release();
✅ TypeORM의 QueryRunner 및 관련 메서드
메서드 | 설명 | SQL 동작 방식 |
.createQueryRunner() | 새로운 QueryRunner 생성 | - |
.connect() | 데이터베이스에 연결 | - |
.startTransaction() | 트랜잭션 시작 | BEGIN TRANSACTION |
.commitTransaction() | 트랜잭션 커밋 | COMMIT |
.rollbackTransaction() | 트랜잭션 롤백 | ROLLBACK |
.release() | 연결 해제 (커넥션 풀 반환) | - |
.query(query, parameters?) | SQL 직접 실행 | SELECT * FROM users |
.manager.save(entity) | 엔티티 저장 (INSERT/UPDATE 자동 처리) | INSERT INTO table_name ... ON DUPLICATE KEY UPDATE ... |
.manager | EntityManager에 접근 | - |
.isTransactionActive | 현재 트랜잭션이 활성화되어 있는지 확인 | - |
.stream(query, parameters?) | 쿼리 결과를 스트리밍 방식으로 가져오기 | - |
.hasTransaction() | 트랜잭션이 활성화되었는지 확인 | - |
.getTable(tableName) | 특정 테이블 정보 조회 | SHOW CREATE TABLE table_name |
.insert(tableName, values) | INSERT 실행 | INSERT INTO table_name (col1, col2) VALUES (?, ?) |
.update(tableName, criteria, values) | UPDATE 실행 | UPDATE table_name SET col1 = ? WHERE col2 = ? |
.delete(tableName, criteria) | DELETE 실행 | DELETE FROM table_name WHERE col1 = ? |
.clear(tableName) | 테이블 모든 데이터 삭제 | DELETE FROM table_name |
.loadViews() | 데이터베이스의 모든 뷰 정보 가져오기 | SHOW FULL TABLES WHERE table_type = 'VIEW' |
.getRepository(entity) | 특정 엔티티의 Repository 가져오기 | - |
예시
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(Profile)
private profileRepository: Repository<Profile>,
@InjectDataSource()
private dataSource: DataSource, // dataSource는 typeorm 데이터베이스 연결 객체 이고 queryRunner를 만들기 위해 사용!
) {}
interface CreateUserData {
email: string;
name: string;
// 프로필 정보
bio: string; // SNS 프로필의 자기소개 부분에 링크를 걸어두는 링크입니다 인스타에서 자주 보이죠
avatar: string; // 유저 사진입니다. 영화아님...
}
async createUserWithProfile(userData: CreateUserData) {
// QueryRunner 생성
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
// try-catch-finally 구문으로 작성
try {
// 1. User 생성
const user = this.userRepository.create({
email: userData.email,
name: userData.name
});
// User 저장
const savedUser = await queryRunner.manager.save(user);
// 🍎 여기서 예상 하지 못한 에러가 난다면?
// user는 저장되지만 profile은 저장 안되는 문제 발생!!!
// 하지만 우리는 transaction처리를 했으니 괜찮습니다.
// 2. Profile 생성
const profile = this.profileRepository.create({
user: savedUser, // User와 Profile을 연결해줍니다 👉 1:1 관계
bio: userData.bio,
avatar: userData.avatar
});
// Profile 저장
await queryRunner.manager.save(profile);
// 모든 작업이 성공하면 커밋
// commit 👉 여기까지 한 모든 작업을 진짜로 데이터베이스에 저장해!
await queryRunner.commitTransaction();
return savedUser;
} catch (error) {
// 실패하면 롤백 👉 user와 profile 모두 생성 취소
await queryRunner.rollbackTransaction();
throw new Error('사용자 생성 실패~: ' + error.message);
} finally {
// 항상 release 호출
// release 👉 데이터베이스와의 연결을 끊어줘~ 작업 끝이야 라고 알려줍니다.
await queryRunner.release();
}
}
}
// 사용 예시
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
async createTestUser() {
const userData = {
email: 'gildong@google.com',
name: '홍길동',
bio: 'https://instagram.com/honggildong',
avatar: 'profile.jpg'
};
try {
const newUser = await this.userService.createUserWithProfile(userData);
console.log('사용자와 프로필이 성공적으로 생성되었습니다!');
} catch (error) {
console.error('비상 비상 초 비 상!:', error.message);
}
}
}
📌 트랜잭션 예제: User + Profile 동시 생성
상황:
- User를 생성할 때, Profile도 함께 생성해야 함
- 한쪽이 실패하면 둘 다 Rollback
🛠 UserService (트랜잭션 적용)
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(Profile)
private profileRepository: Repository<Profile>,
@InjectDataSource()
private dataSource: DataSource, // DB 연결 객체 (QueryRunner 생성용)
) {}
interface CreateUserData {
email: string;
name: string;
bio: string; // 자기소개
avatar: string; // 프로필 사진
}
async createUserWithProfile(userData: CreateUserData) {
// 1️⃣ QueryRunner 생성 및 연결
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 2️⃣ User 생성
const user = this.userRepository.create({
email: userData.email,
name: userData.name
});
const savedUser = await queryRunner.manager.save(user);
// 3️⃣ Profile 생성 (User와 1:1 연결)
const profile = this.profileRepository.create({
user: savedUser,
bio: userData.bio,
avatar: userData.avatar
});
await queryRunner.manager.save(profile);
// 4️⃣ 모든 작업이 성공하면 commit (DB 반영)
await queryRunner.commitTransaction();
return savedUser;
} catch (error) {
// 5️⃣ 실패 시 rollback (DB 복구)
await queryRunner.rollbackTransaction();
throw new Error('사용자 생성 실패: ' + error.message);
} finally {
// 6️⃣ 연결 해제
await queryRunner.release();
}
}
}
🛠 UserController (API 요청 처리)
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
async createTestUser() {
const userData = {
email: 'gildong@google.com',
name: '홍길동',
bio: 'https://instagram.com/honggildong',
avatar: 'profile.jpg'
};
try {
const newUser = await this.userService.createUserWithProfile(userData);
console.log('✅ 사용자와 프로필이 성공적으로 생성되었습니다!');
} catch (error) {
console.error('❌ 사용자 생성 중 오류 발생:', error.message);
}
}
}
🔹 QueryBuilder로 복잡한 쿼리 처리하기
TypeORM의 QueryBuilder를 사용하면 복잡한 SQL 쿼리를 가독성 있게 작성할 수 있습니다.
- 타입 안전성 보장
- 유지보수 용이
- 복잡한 조인과 그룹화 처리 가능
TypeORM의 QueryBuilder 메서드
✅ 사용 예시
- 단순 조회: createQueryBuilder().select().getMany()
- 조인 조회: leftJoinAndSelect()
- 그룹화: groupBy()
- 조건 필터링: having()
메서드 | 설명 | 예제 | SQL 동작 방식 |
.createQueryBuilder(alias) | 새로운 QueryBuilder 생성 및 별칭 지정 | createQueryBuilder('user') | SELECT * FROM users |
.select(columns) | 조회할 컬럼 지정 | select(['user.id', 'user.name']) | SELECT id, name FROM users |
.addSelect(columns) | 기존 선택된 컬럼에 추가 컬럼 포함 | addSelect('user.email') | SELECT id, name, email FROM users |
.from(entity, alias) | 조회할 엔티티와 별칭 지정 | from(User, 'user') | FROM users AS user |
.where(condition, params) | WHERE 조건 추가 | where('user.age > :age', { age: 18 }) | WHERE age > 18 |
.andWhere(condition, params) | 추가 WHERE 조건 | andWhere('user.isActive = :active', { active: true }) | AND isActive = true |
.orWhere(condition, params) | OR 조건 추가 | orWhere('user.role = :role', { role: 'admin' }) | OR role = 'admin' |
.leftJoin(table, alias, condition?) | LEFT JOIN 추가 | leftJoin('user.posts', 'post', 'post.isPublished = true') | LEFT JOIN posts ON user.id = post.userId AND post.isPublished = true |
.innerJoin(table, alias, condition?) | INNER JOIN 추가 | innerJoin('user.profile', 'profile') | INNER JOIN profile ON user.id = profile.userId |
.leftJoinAndSelect(relation, alias, condition?) | LEFT JOIN 및 관계 데이터 포함 | leftJoinAndSelect('user.posts', 'post') | LEFT JOIN posts ON user.id = post.userId |
.innerJoinAndSelect(relation, alias, condition?) | INNER JOIN 및 관계 데이터 포함 | innerJoinAndSelect('user.profile', 'profile') | INNER JOIN profile ON user.id = profile.userId |
.groupBy(column) | GROUP BY 절 추가 | groupBy('user.city') | GROUP BY city |
.having(condition, params) | HAVING 조건 추가 | having('COUNT(user.id) > :count', { count: 10 }) | HAVING COUNT(user.id) > 10 |
.orderBy(column, "ASC") | 특정 컬럼을 기준으로 정렬 | orderBy('user.name', 'ASC') | orderBy('user.name', 'ASC') |
.limit(number) | 조회할 행 개수 제한 | limit(10) | LIMIT 10 |
.offset(number) | 조회 시작 지점 지정 | offset(20) | OFFSET 20 |
.getOne() | 단일 결과 반환 | getOne() | SELECT * FROM users LIMIT 1 |
.getMany() | 여러 개의 결과 반환 | getMany() | SELECT * FROM users |
.getRawOne() | 단일 결과를 원시 SQL 데이터로 반환 | getRawOne() | SELECT * FROM users LIMIT 1 |
.getRawMany() | 여러 개의 결과를 원시 SQL 데이터로 반환 | getRawMany() | SELECT * FROM users |
.getCount() |
결과 개수 반환 | getCount() | SELECT COUNT(*) FROM users |
.execute() | 쿼리 실행 | execute() | 실행된 SQL |
📌 예제: 5개 이상 게시글을 작성한 유저 찾기
async findUsersWithPostCount(): Promise<any[]> {
try {
const activeUsers = await this.userRepository
.createQueryBuilder('user') // 1️⃣ QueryBuilder 시작 (user 테이블 별칭 'user')
.leftJoinAndSelect('user.posts', 'post') // 2️⃣ user와 post 테이블 LEFT JOIN (별칭 'post')
.select([
'user.name', // 3️⃣ 유저 이름 선택
'COUNT(post.id) as postCount' // 4️⃣ 게시글 개수 계산
])
.groupBy('user.id') // 5️⃣ 유저별 그룹화
.having('COUNT(post.id) >= :minPosts', { minPosts: 5 }) // 6️⃣ 게시글이 5개 이상인 유저만 선택
.getRawMany(); // 7️⃣ 결과를 JS 객체 배열로 반환
return activeUsers;
} catch (error) {
throw new Error(error.message);
}
}
📌 QueryBuilder 주요 메서드 설명
1️⃣ createQueryBuilder('user')
- userRepository에서 QueryBuilder 생성
- user 테이블을 사용할 것이며, 별칭(alias)으로 'user' 지정
2️⃣ leftJoinAndSelect('user.posts', 'post')
- user 테이블과 posts 테이블을 LEFT JOIN
- user.posts는 User 엔티티에서 OneToMany 관계를 의미
- 조인한 posts 테이블의 별칭은 'post'
💡 결과 데이터 예시:
user.id | user.name | post.id | post.title |
1 | 홍길동 | 101 | 첫 번째 게시글 |
1 | 홍길동 | 102 | 두 번째 게시글 |
2 | 김철수 | NULL | NULL |
3️⃣ select(['user.name', 'COUNT(post.id) as postCount'])
- user.name과 게시글 개수(postCount)를 가져옴
- COUNT(post.id) as postCount 👉 게시글 개수를 계산하여 postCount라는 이름으로 저장
💡 결과 데이터 예시:
user.name | postCount |
홍길동 | 5 |
이순신 | 6 |
김철수 | 0 |
4️⃣ groupBy('user.id')
- user.id를 기준으로 유저별 그룹화
💡 결과 데이터 예시:
user.id | user.name | postCount |
1 | 홍길동 | 5 |
2 | 이순신 | 6 |
3 | 김철수 | 0 |
5️⃣ having('COUNT(post.id) >= :minPosts', { minPosts: 5 })
- HAVING 절을 사용하여 게시글이 5개 이상인 유저만 필터링
💡 최종 결과 데이터:
user.name | postCount |
홍길동 | 5 |
이순신 | 6 |
6️⃣ getRawMany()
- 결과를 Raw 형태의 JS 객체 배열로 반환
[
{ "user_name": "홍길동", "postCount": "5" },
{ "user_name": "이순신", "postCount": "6" }
]
참고
ORM
- https://www.incodom.kr/ORM
- https://jalynne-kim.medium.com/%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EB%B0%B1%EC%97%94%EB%93%9C-orm-object-relational-mapping-%EC%9D%98-%EA%B0%9C%EB%85%90%EA%B3%BC-%EC%A2%85%EB%A5%98-%ED%99%9C%EC%9A%A9%EB%B0%A9%EC%95%88-c43b69028957
-
-https://www.notion.so/TypeORM-19f7bba08e218024ac1bc10e5d41f59f
-
-