관리 메뉴

bright jazz music

[nestjs] 회원 관련 엔티티와(member.entity.ts) 과 관련 파일 작성(enum등) 본문

Framework/NestJS

[nestjs] 회원 관련 엔티티와(member.entity.ts) 과 관련 파일 작성(enum등)

bright jazz music 2024. 12. 23. 20:10

 

 

1.  member.entity.ts (회원관련 엔티티 파일)

/src/members 디렉토리에 entities 디렉토리를 만들고 member.entity.ts 파일을 만들어준다.

아래와 같은 내용을 작성해준다.

 

- enum을 사용한 위치에서 오류가 발생할 것이다. 그건 2번 항목에서 다룬다.

 

/**
* src/members/entities/member.entity.ts
* 멤버 엔티티: 데이터베이스의 members 테이블과 매핑되는 엔티티 클래스
* 회원의 기본 정보, 인증 정보, 포인트, 레벨 등을 관리
* 엔티티에서는 데이터 구조와 제약에만 집중한다.
* MinLength 등의 'class-validator'의 데코레이터는 dto에서 적용한다.
*/

import { v4 as uuidv4 } from 'uuid';
import {
   Entity,
   PrimaryGeneratedColumn,
   Column,
   CreateDateColumn,
   UpdateDateColumn,
   DeleteDateColumn
} from 'typeorm';

import { 
   AuthProvider, 
   Role, 
   Notification, 
   Language, 
   Theme,
   MemberStatus
} from '@common/enums';

@Entity('members')
export class Member {
   @PrimaryGeneratedColumn()
   id: number;  // 내부 사용 기본키

   @Column('uuid', { 
    //    name: 'uuid',
       unique: true 
   })
   uuid: string = uuidv4();  // 외부 노출용 식별자

   /**
    * 인증 관련 필드
    */
   @Column({ 
    //    name: 'email',
       unique: true 
   })
   email: string;

   @Column({ 
    //    name: 'password',
       nullable: true,
       select: false  // 기본 쿼리에서 비밀번호 필드 수집 제외. 
       // 포함시키려면 select: {password: true}처럼 명시적으로 password 필드 요청
   })
   password?: string;

   @Column({ 
    //    name: 'password_changed_at',
       type: 'timestamp', 
       nullable: true 
   })
   passwordChangedAt?: Date;  // 비밀번호 변경 시간

   /**
    * 비밀번호 재설정 관련
    */
   @Column({ 
    //    name: 'password_reset_token',
       nullable: true, 
       select: false 
   })
   passwordResetToken?: string;

   @Column({ 
    //    name: 'password_reset_token_expires_at',
       type: 'timestamp', 
       nullable: true 
   })
   passwordResetTokenExpiresAt?: Date;

   @Column({
    //    name: 'provider',
       type: 'enum',
       enum: AuthProvider  // 객체를 전달해줘도 내부적으로는 문자열 배열로 풀어서 처리됨
   })
   provider: AuthProvider;

   @Column({ 
    //    name: 'provider_id',
       nullable: true 
   })
   providerId?: string;



    /**
    * 소셜 로그인 관련: 아래 구조로 들어간다. 
    * {
  "GOOGLE": {
    "id": "123456789",
    "email": "user@gmail.com",
    "name": "John Doe",
    "profileUrl": "https://lh3.googleusercontent.com/..."
  },
  "KAKAO": {
    "id": "987654321", 
    "email": "user@kakao.com",
    "name": "홍길동"
  }
}
    */
   @Column({ 
    //    name: 'social_profiles',
       type: 'jsonb', 
       nullable: true 
   })
   socialProfiles?: {
    // [key: string]: {
       [K in AuthProvider]?: {  // AuthProvider enum의 값들만 키로 사용 가능
           id: string;
           email?: string;
           name?: string;
           profileUrl?: string;
       };
   };

   /**
    * 보안 관련
    * "단번에 모든 사용자 토큰 무효화"가 필요한 상황에서 사용
    *  예를 들어 비밀번호 변경 시 모든 토큰을 무효화하는 경우
    *  또는 회원 탈퇴 시 모든 토큰을 무효화하는 경우
    *  또는 관리자가 특정 사용자의 모든 세션을 강제 로그아웃 시키는 경우
    *  토큰 탈���가 의심되는 경우
    */
   @Column({ 
    //    name: 'token_version',
       default: 0 
   })
   tokenVersion: number;  // 토큰 무효화를 위한 버전 관리

   @Column({ 
    //    name: 'refresh_token',
       nullable: true,
       select: false  // refreshToken도 민감 정보이므로 select false 추가
   })
   refreshToken?: string;

   @Column({ 
    //    name: 'refresh_token_expires_at',
       type: 'timestamp', 
       nullable: true 
   })
   refreshTokenExpiresAt?: Date;  // 리프레시 토큰 만료 시간

   @Column({ 
    //    name: 'login_attempts',
       default: 0 
   })
   loginAttempts: number;  // 로그인 시도 횟수

   @Column({ 
    //    name: 'lockout_until',
       type: 'timestamp', 
       nullable: true 
   })
   lockoutUntil?: Date;   // 계정 잠금 시간

   /**
    * 2단계 인증 관련
    */
   // 2단계 인증 활성화 여부
   @Column({ 
    //    name: 'two_factor_enabled',
       default: false 
   })
   twoFactorEnabled: boolean;

   // 2단계 인증 비밀번호
   @Column({ 
    //    name: 'two_factor_secret',
       nullable: true, 
       select: false 
   })
   twoFactorSecret?: string;

   /**
    * 약관 동의 관련
    */
   @Column({ 
    //    name: 'terms_agreed',
       type: 'boolean', 
       default: true
   })
   termsAgreed: boolean;

   @Column({ 
    //    name: 'terms_agreed_at',
       type: 'timestamp', 
       nullable: true 
   })
   termsAgreedAt?: Date;

   /**
    * 개인정보 처리방침 동의 관련
    */
   @Column({ 
    //    name: 'privacy_agreed',
       default: false 
   })
   privacyAgreed: boolean;

   @Column({ 
    //    name: 'privacy_agreed_at',
       type: 'timestamp', 
       nullable: true 
   })
   privacyAgreedAt?: Date;

   /**
    * 연령 동의 관련
    */
   @Column({ 
    //    name: 'age_verified',
       default: false 
   })
   ageVerified: boolean;

    @Column({ 
        // name: 'age_verified_at',
        type: 'timestamp', 
        nullable: true 
    })
    ageVerifiedAt?: Date;

   /**
    * 선택적 정보들
    */
   @Column({ 
       name: 'name',
       nullable: true 
   })
   name?: string;

   @Column({ 
       name: 'nickname',
       nullable: true 
   })
   nickname?: string;
   
   @Column({ 
       name: 'phone_number',
       nullable: true 
   })
   phoneNumber?: string;

   /**
    * 이메일 인증 관련
    */
   @Column({
    //    name: 'email_verified',
       default: false 
   })
   emailVerified: boolean;

   @Column({
    //    name: 'email_verified_at',
       type: 'timestamp', 
       nullable: true 
   })
   emailVerifiedAt?: Date;  // 이메일 인증 시간

   @Column({ 
    //    name: 'verification_token',
       nullable: true, 
       select: false 
   })
   verificationToken?: string;

   @Column({ 
    //    name: 'verification_token_expires_at',
       type: 'timestamp', 
       nullable: true 
   })
   verificationTokenExpiresAt?: Date;  // 인증 토큰 만료 시간

   @Column({ 
    //    name: 'profile_image',
       nullable: true 
   })
   profileImage?: string;

   @Column({
    //    name: 'last_login_at',
       type: 'timestamp', 
       nullable: true 
   })
   lastLoginAt?: Date;

   /**
    * 계정 비활성화/탈퇴 관련
    */
   @Column({ 
    //    name: 'deactivated_at',
       type: 'timestamp', 
       nullable: true 
   })
   deactivatedAt?: Date;  // 계정 비활성화 시점

   /**
    * 마케팅 관련
    */
   @Column({
    // name: 'marketing_agreed',
    default: false })
   marketingAgreed: boolean;

   @Column({ 
    //    name: 'marketing_agreed_at',
       type: 'timestamp', 
       nullable: true 
   })
   marketingAgreedAt?: Date;

   /**
    * 알림 설정
    * 계산된 속성명(Computed Property): 변수/함수/enum 등의 변수 수 있는 값을 객체의 'key'로 쓸 때만 [](대괄호)로 감싸야 함
    */
   @Column('jsonb', {
    //    name: 'notification_settings',
       default: {
           [Notification.EMAIL]: false,  // 이메일 인증 전까지는 false로 시작
           [Notification.PUSH]: false,
           [Notification.SMS]: false,
           [Notification.MARKETING]: false,
           [Notification.IN_APP]: true
       }
   })
   notificationSettings: {
       [Notification.EMAIL]: boolean;
       [Notification.PUSH]: boolean;
       [Notification.SMS]: boolean;
       [Notification.MARKETING]: boolean;
       [Notification.IN_APP]: boolean;
   };

   /**
    * 회원 상태 관리
    */
   @Column({
    //    name: 'member_status',
       type: 'enum',
       enum: MemberStatus,
       default: MemberStatus.ACTIVE  // 기본은 ACTIVE, 이메일 인증이 필요한 경우 PENDING으로 변경
   })
   status: MemberStatus;

   /**
    * 사용자 설정
    */
   @Column('jsonb', {
    //    name: 'preferences',
       default: {
           language: Language.KO,
           timezone: 'UTC',
           theme: Theme.LIGHT
       }
   })
   preferences: {
       language: Language;
       timezone: string;
       theme: Theme;
   };

   /**
    * 역할 관련 (ADMIN, USER, MANAGER 등)
    */
   @Column({
       name: 'role',
       type: 'enum',
       enum: Role,
       default: Role.USER
   })
   role: Role;

   @CreateDateColumn({
    // name: 'created_at',
    type: 'timestamp'
   })
   createdAt: Date;

   @UpdateDateColumn({
    // name: 'updated_at',
    type: 'timestamp'
   })
   updatedAt: Date;

   @DeleteDateColumn({
    // name: 'deleted_at',
    type: 'timestamp',
    nullable: true
   })
   deletedAt?: Date;

   /**
    * 포인트 정보를 담는 타입
    */
   @Column('jsonb', {
    //    name: 'points',
       default: {
           total: 0,          // 전체 포인트
           purchase: 0,       // 구매 포인트
           reward: 0          // 리워드 포인트
       }
   })
   points: {
       total: number;
       purchase: number;
       reward: number;
   };

   /**
    * 레벨 정보를 담는 타입
    */
   @Column('jsonb', {
    //    name: 'level_info',
       default: {
           level: 1,
           experience: 0
       }
   })
   levelInfo: {
       level: number;
       experience: number;
   };

}

 

- @column() 내부 객체 파라미터의 name속성이 주석처리 돼 있는 것을 볼 수 있다. name은 테이블 생성 시의 칼럼 이름이 된다. 여기서는 엔티티 클래스는 카멜 케이스로 돼 있다. 그런데 디비, 테이블, 칼럼명 등은 언더스코어('_')를 사용하는 스네이크 케이스 (예를 들어 'snake_case')를 사용하는 것이 관행이다. 따라서 name 속성을 따로 지정해 스네이크 케이스로 작성해 준 것이다. 만약 저 속성을 지정해 주지 않는다면 엔티티 클래스의 속성명을 따라 카멜 케이스로 테이블 컬럼이 만들어진다.

 

그렇다면  왜 다시 주석처리를 한 것인가? 

자동으로 테이블명을 스네이크 케이스로 만들어주는 라이브러리를 찾았기 때문이다

pnpm add typeorm-naming-strategies

 

그리고

프로젝트의 전체 모듈을 관리하는 app.module.ts와 디비 설정 모듈, TypeORM 모듈 관련 파일에 수정이 있었다.

// src/config/database.config.ts
// 데이터베이스 연결 정보를 관리하는 파일
import { registerAs } from '@nestjs/config';

// 순수 데이터베이스 연결 정보만 관리
export default registerAs('database', () => ({
    type: 'postgres',
    host: process.env.DB_HOST,
    port: parseInt(process.env.DB_PORT, 10),
    username: process.env.DB_USERNAME,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_DATABASE,
}));

 

아래 파일은 새로 만들어야 할 수도 있음. TypeORM만을 위한 설정파일인 typeorm.config.ts를 작성하였다. 원래는 싱크로나이즈를 비롯한 설정이 디비 설정과 함께 있었다.

// src/config/typeorm.config.ts
// TypeORM 설정 파일

/**
 * 
 * 데이터베이스 연결과 ORM 동작을 위한 세부 설정
 * database.config.ts의 기본 연결 정보를 확장하여 TypeORM에 필요한 추가 설정을 제공
 * 
 * 주요 설정:
 * - 엔티티 자동 감지 및 로드
 * - 스키마 동기화 (개발 환경에서만 활성화)
 * - 네이밍 전략 (스네이크 케이스)
 * - 환경별 로깅 설정
 * - SSL 보안 설정 (운영 환경)
 */

import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';

// TypeORM 관련 설정만 관리
export const getTypeOrmConfig = (configService: ConfigService): TypeOrmModuleOptions => {
    const dbConfig = configService.get('database');

    return {
        // 데이터베이스 설정
        ...dbConfig,
        // 엔티티 경로  
        entities: [__dirname + '/../**/*.entity{.ts,.js}'],
        // 개발 환경에서만 동기화 활성화
        synchronize: process.env.NODE_ENV !== 'production',
        // 모든 테이블 이름을 스네이크 케이스로 변환
        namingStrategy: new SnakeNamingStrategy(),
        // 로깅 설정: 로컬과 개발 환경에서는 쿼리 로깅을 활성화. 운영 환경에서는 오류만 로깅
        logging: process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'development' 
            ? ['query', 'error'] 
            : ['error'],
        // TypeORM 특화 설정들
        // 엔티티 자동 로드
        autoLoadEntities: true,
        // 연결 유지
        keepConnectionAlive: true,
        // SSL 설정: 운영 환경에서는 SSL 활성화
        ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
    };
};

 

그리고 위 두 파일을 아래처럼 사용

// src/app.module.ts
// 애플리케이션 모듈

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import { MembersModule } from './members/members.module';
import databaseConfig from './config/database.config';
import { getTypeOrmConfig } from './config/typeorm.config';

@Module({
  imports: [
    // 환경 변수 설정
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: `.env.${process.env.NODE_ENV}`,
      load: [databaseConfig],
    }),
    // TypeORM 설정
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],  // ConfigService를 의존성 주입
      useFactory: getTypeOrmConfig, // TypeORM 설정 함수
    }),
    // 멤버 모듈
    MembersModule,
    // 인증 모듈
    AuthModule,
  ],
})
export class AppModule {}

 

 

2. enum 파일 작성

/src/common/enums 디렉토리에 enum 파일들과 그걸 한 번에 모듈화하여 export할 수 있도록 index.ts 파일을 작성했다. 참고로 디렉토리명과 위치는 임의로 지정해도 상관없다. 프로젝트의 여러 부분에서 사용될 것 같으므로 위치를 그렇게 지정한 뿐이다.

 

 

2.1.  auth-provider.enum.ts  작성(소셜 서비스를 사용하여 회원가입과 로그인을 하는 데 사용되는 enum) 

// auth-provider.enum.ts
// 경로: src/common/enums/auth-provider.enum.ts

export enum AuthProvider {
  LOCAL = 'email',
  GOOGLE = 'google',
  KAKAO = 'kakao',
  NAVER = 'naver'
}

 

2.2.  language.enum.ts  작성 (다국어 설정을 위한 enum)

// language.enum.ts
// 경로: src/common/enums/language.enum.ts

export enum Language {
    KO = 'ko',
    EN = 'en',
    JP = 'jp'
}

 

2.3.  member-status.enum.ts  작성 (회원 상태관리를 위해 사용하는 enum)

// member-status.enum.ts
// 경로: src/common/enums/member-status.enum.ts

export enum MemberStatus {
    ACTIVE = 'active',          // 정상 활동
    INACTIVE = 'inactive',      // 비활성화 (회원이 직접 비활성화)
    SUSPENDED = 'suspended',    // 정지 (관리자가 제재)
    DORMANT = 'dormant',       // 휴면 (장기 미접속)
    PENDING = 'pending',        // 가입 대기 (이메일 인증 전)
    BLOCKED = 'blocked',        // 영구 정지 (심각한 위반으로 인한 영구 제재)
    WITHDRAWAL = 'withdrawal'   // 탈퇴 처리 (회원 탈퇴 진행 중 - 유예 기간)
}

 

2.4. notification.enum.ts  작성 (알림 설정을 위해 사용하는 enum)

// notification.enum.ts
// 경로: src/common/enums/notification.enum.ts

export enum Notification {
  EMAIL = 'email',
  PUSH = 'push',  // 사용중이 아닐 때도 알려주는 알림
  SMS = 'sms',
  MARKETING = 'marketing',
  IN_APP = 'inApp'  // 사용중에만 보이는 알림(예를 들어 종 모양 아이콘)
}

 

2.5.  role.enum.ts 작성 (사용자 권한 관리를 위해 사용하는 enum ) 

관리자는 따로 관리할 생각이지만 혹시 몰라 필드를 만들었다.

// role.enum.ts
// 경로: src/common/enums/role.enum.ts

export enum Role {
    ADMIN = 'ADMIN',
    USER = 'USER',
    MANAGER = 'MANAGER'
}

 

2.6. 앱 테마 저장하기 위해 사용하는 enum

// theme.enum.ts
// 경로: src/common/enums/theme.enum.ts

export enum Theme {
    LIGHT = 'light',
    DARK = 'dark',
    SYSTEM = 'system'
}

 

2.7. time-zone.enums.ts 작성 (타임존 관리를 위해 사용하는 enum)

// time-zone.enums.ts
// 경로: src/common/enums/time-zone.enums.ts

export enum TimeZone {
    UTC = 'UTC',
    SEOUL = 'Asia/Seoul',        // 'KST'가 아닌 'Asia/Seoul'
    TOKYO = 'Asia/Tokyo',        // 'JST'가 아닌 'Asia/Tokyo'
    NEWYORK = 'America/New_York' // 'EST'가 아닌 'America/New_York'
}

 

 

3. 인덱스 파일 작성

위에서 만든 enum파일들을 직접 엔티티 파일 내에서 임포트해 사용할 수도 있을 것이다. 그러나 그렇게 하면 파일이 너무 지저분해진다. 따라서 /src/common/enums에 index.ts 파일을 생성해서 모듈 역할을 하도록 만들어주었다. 따라서 엔티티 파일에서 인덱스 파일만 임포트하더라도 위에서 생성한 enum파일들을 전부 사용할 수 있다.

 

// src/common/enums/index.ts
// 공통 열거형 정의. 한 번에 가져와 사용할 수 있도록 모듈로 정의

// Node.js에서는 파일의 이름이 index인 경우, 
// 디렉토리를 임포트 할 때 index파일을 찾아보기 때문에 파일명을 생략가능함.
// 따라서 경로 끝에 index를 붙이지 않아도 @common/enums 이렇게 사용할 수 있음.

export * from './auth-provider.enum';
export * from './role.enum';
export * from './notification.enum';
export * from './language.enum';
export * from './time-zone.enums';
export * from './theme.enum';
export * from './member-status.enum';

 

인덱스 파일이 완성되었다. 엔티티 파일에서 임포트 해서 사용하려면 원래는 아래와 같이 상대경로를 사용해 적어주어야 했을 것이다.

import { 
   AuthProvider, 
   Role, 
   Notification, 
   Language, 
   Theme,
   MemberStatus
} from '../../common/enums/index';

 

그러나 나는 tsconfig.json 파일 설정 아래와 같이 해주었기 때문에 @common을 사용해 해당 파일을 임포트 할 수 있었다. 'paths' 속성의 값은 객체로 되어있고, 그 객체의 키 값을 @를 사용하여 '@common/*'으로 해준 것을 볼 수 있을 것이다. 이러한 설정을 통해 @common/enum을 임포트하면 enum디렉토리까지 이르는 절대경로를 가져올 수 있다.

{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "ES2021",
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "paths": {
      "@common/*": ["src/common/*"],
      "@auth/*": ["src/auth/*"],
      "@members/*": ["src/members/*"]
    },
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": false,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "forceConsistentCasingInFileNames": false,
    "noFallthroughCasesInSwitch": false
  }
}

 

위의 주석에도 나와있지만 index가 생략된 이유는 Node.js에서는 디렉토리를 탐색할 때 인덱스 파일까지도 자동으로 탐색하기 때문이다. 따라서 디렉토리까지만 가져와도 인덱스 파일은 자동으로 가져온다고 여겨도 된다.

 

이렇게 엔티티를 설정했으니 프로젝트를 구동하면 db에 아래와 같은 테이블이 생겨난다.

위처럼 스네이크 케이스 네이밍 전략이 사용된 테이블과 칼럼이 생성된다.

 

원래는 아래처럼 카멜 케이스였다.

Comments