GREEN나무 2025. 2. 18. 18:13
728x90

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"
  }
}
  • 스크립트 설명:
    1. typeorm: TypeScript로 작성된 TypeORM CLI 명령어 실행 도구
    2. migration:generate: 데이터베이스 변경 사항을 감지하여 자동으로 마이그레이션 파일 생성
      (예: npm run migration:generate -- src/migrations/AddAvatarColumn)
    3. migration:run: 생성된 마이그레이션 파일을 데이터베이스에 적용
    4. migration:revert: 마지막에 실행된 마이그레이션을 롤백하여 이전 상태로 복구
    5. 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
-
-