본문 바로가기
✨BACKEND/📍NestJS

NestJS 회원가입/로그인 구현하기(feat. MySQL)

by 짱돌보리 2025. 8. 30.
728x90

NestJS 회원가입/로그인 구현하기(feat. MySQL)

 

0. DB 연동

https://docs.nestjs.com/techniques/configuration

 

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 Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    TypeOrmModule.forRootAsync({
      useFactory: () => ({
        type: 'mysql',
        host: process.env.DB_HOST,
        port: Number(process.env.DB_PORT ?? 3306),
        username: process.env.DB_USERNAME,
        password: process.env.DB_PASSWORD,
        database: process.env.DB_NAME,
        entities: [__dirname + '/**/*.entity.{js,ts}'],
        synchronize: process.env.DB_SYNCHRONIZE === 'true',
        logging: true,
        charset: 'utf8mb4',
        timezone: 'Z',
      }),
    }),
  ],
})
export class AppModule {}

1. 컬럼 정의

컬럼명 타입 옵션
id uuid PK
email varchar unique, not null
password varchar not null
nickname varchar unique, not null
grade varchar 기본값: 새싹 (새싹, 한잎, 두잎, 세잎, 네잎, 황금네잎, 관리자)
points int 기본값: 0
createdAt datetime 기본값: 현재시간
updatedAt datetime 기본값: 현재시간, 수정시 자동 업데이트

2. 엔티티 정의

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: number;

  @Column({ unique: true })
  email: string;

  @Column()
  password: string;

  @Column({ unique: true })
  nickname: string;

  @Column({ default: '새싹' })
  grade: string;

  @Column({ default: 0 })
  points: number;

  @CreateDateColumn({ type: 'timestamp' })
  createdAt: Date;

  @UpdateDateColumn({ type: 'timestamp' })
  updatedAt: Date;
}

 

 

“SELECT/INSERT/UPDATE/DELETE를 어떻게 할까?” → 레포지토리
“이 상황에서 비밀번호를 어떻게 검증하고 어떤 응답/토큰을 줄까?” → 서비스(service)


📍회원가입

1. DTO 생성

  • 프론트쪽 유효성 검사와 동일하게 맞추기
export class SignupUserDto {
  @IsEmail()
  @MaxLength(255)
  email: string;

  @IsNotEmpty()
  @MaxLength(20)
  @Matches(/^[가-힣a-zA-Z0-9_]{2,20}$/)
  nickname: string;

  @IsNotEmpty({ message: '비밀번호는 필수입니다.' })
  @Matches(/^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*(),.?":{}|<>])\S{8,64}$/, {
    message:
      '비밀번호는 8~64자이며, 소문자·숫자·특수문자를 포함하고 공백이 없어야 합니다.',
  })
  password: string;

  // 기본값(새싹)
  @IsEnum(Grade)
  @IsOptional()
  grade?: Grade;
}
export class LoginUserDto {
  @IsEmail()
  email: string;

  @IsString()
  password: string;
}

2. global ValidationPipe 설정하기

pnpm add bcrypt -D @types/bcrypt

 

(1) DTO 유효성 검증이 전체 라우트에 적용되도록 설정하자

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // DTO에 없는 필드 제거
      forbidNonWhitelisted: true, // 허용되지 않은 필드가 오면 에러
      transform: true, // 타입 변환
    }),
  );

  await app.listen(process.env.PORT ?? 3001);
}
bootstrap();

 

(2) 리포지토리 주입을 위해 TypeOrmModule.forFeature로 User 등록하기

// users.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UsersService],
  controllers: [UsersController],
  exports: [UsersService],
})
export class UsersModule {}

 

최상단 모듈인 app.module.ts에도 users.module도 꼭 import 해주자!!!

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    TypeOrmModule.forRootAsync({
      useFactory: () => ({
        type: 'mysql',
        host: process.env.DB_HOST,
        port: Number(process.env.DB_PORT ?? 3306),
        username: process.env.DB_USERNAME,
        password: process.env.DB_PASSWORD,
        database: process.env.DB_NAME,
        entities: [__dirname + '/**/*.entity.{js,ts}'],
        synchronize: process.env.DB_SYNCHRONIZE === 'true',
        logging: true,
        charset: 'utf8mb4',
        timezone: 'Z',
      }),
    }),
    UsersModule,
  ],
})
export class AppModule {}

 

.env 설정..

NODE_ENV=development

# MySQL
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=0000
DB_NAME=clovie
DB_SYNCHRONIZE=true

# JWT
JWT_SECRET=hiClovie!@#
JWT_EXPIRES_IN=1d

 

 

auth와 users 관심사 분리를 해놔서 auth.module.ts에서도 UsersService를 import 해줘야한다.

@Module({
  imports: [
    UsersModule, // UsersService를 사용하기 위해 import
    JwtModule.registerAsync({
      global: true,
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get('JWT_SECRET'),
        signOptions: { expiresIn: configService.get('JWT_EXPIRES_IN') || '1d' },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService],
  exports: [AuthService],
})
export class AuthModule {}

 

(3) Service 구현: 중복 체크 → bcrypt 해싱 → 안전한 반환

  • 이메일/닉네임 중복 방지
  • 비밀번호 해싱(bcrypt)
  • 비밀번호는 응답에서 제거
@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
  ) {}

  // ! 회원가입
  async signup(dto: SignupUserDto) {
    const { email, nickname, password } = dto;

    // 사전 중복 체크
    const existed = await this.usersService.findByEmailOrNickname(
      email,
      nickname,
    );
    if (existed) {
      const field = existed.email === email ? '이메일' : '닉네임';
      throw new ConflictException(`${field}이(가) 이미 사용 중입니다.`);
    }

    // 비밀번호 해싱
    const salt = await bcrypt.genSalt();
    const hashedPassword = await bcrypt.hash(password, salt);

    const user = await this.usersService.createUser({
      email,
      nickname,
      password: hashedPassword,
      grade: dto.grade || Grade.Seedling,
    });

    const { password: _, ...safeUser } = user;
    return safeUser;
  }

  // ! 로그인
  async login(dto: LoginUserDto) {
    const { email, password } = dto;

    const user = await this.usersService.findUserForAuth(email);

    if (!user) {
      throw new UnauthorizedException('존재하지 않는 이메일입니다.');
    }

    const isValid = await bcrypt.compare(password, user.password);
    if (!isValid) {
      throw new UnauthorizedException('비밀번호가 일치하지 않습니다.');
    }

    const payload = {
      id: user.id,
      email: user.email,
      nickname: user.nickname,
      grade: user.grade,
    };
    const accessToken = this.jwtService.sign(payload);

    const { password: _, ...safeUser } = user;

    return { user: safeUser, accessToken };
  }
}
// users.service.ts 에서 관련 유효성 체크를 해준다. (auth, users 관심사 분리)
@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
  ) {}

  async createUser(user: SignupUserDto) {
    const userEntity = this.usersRepository.create(user);
    try {
      return await this.usersRepository.save(userEntity);
    } catch (e) {
      const isDup =
        e?.code === 'ER_DUP_ENTRY' || e?.errno === 1062 || e?.code === '1062';
      if (isDup) {
        const msg: string = e?.sqlMessage || e?.message || '';
        if (msg.includes('email')) {
          throw new ConflictException('이미 사용 중인 이메일입니다.');
        }
        if (msg.includes('nickname')) {
          throw new ConflictException('이미 사용 중인 닉네임입니다.');
        }
        throw new ConflictException('이미 사용 중인 정보가 있습니다.');
      }
      throw new InternalServerErrorException(
        '사용자 생성 처리 중 오류가 발생했습니다.',
      );
    }
  }

  async findByEmailOrNickname(email: string, nickname: string) {
    return this.usersRepository.findOne({
      where: [{ email }, { nickname }],
    });
  }

  async findUserForAuth(email: string) {
    return this.usersRepository.findOne({
      where: { email },
      select: ['id', 'email', 'nickname', 'password', 'grade'],
    });
  }

  async findById(id: string) {
    return this.usersRepository.findOne({
      where: { id },
    });
  }
}
// auth.controller.ts
@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Public()
  @Post('signup')
  async signup(@Body() dto: SignupUserDto) {
    const user = await this.authService.signup(dto);
    return { message: '회원가입이 완료되었습니다.', user };
  }

  @Public()
  @Post('login')
  async login(@Body() dto: LoginUserDto) {
    const result = await this.authService.login(dto);
    return { message: '로그인에 성공했습니다.', ...result };
  }
}
// users.controller.ts
@Controller('users')
@UseGuards(AuthGuard)
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get('me')
  async getMyInfo(@Req() req: Request) {
    // AuthGuard에서 req.user에 저장한 사용자 정보 가져오기
    const currentUser = req.user;
    const user = await this.usersService.findById(currentUser.id);

    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다.');
    }

    const { email, nickname, points, grade } = user;
    return { email, nickname, points, grade };
  }
}

 

(4) main.ts에서 CORS 설정하기

  • 프론트엔드에서 쿠키를 사용할 예정이라 credentials: true 옵션을 추가해줘야한다.
    • credentials: true 옵션은 브라우저가 요청에 쿠키, 인증 헤더 등을 포함하도록 허용하는 설정임!
    • 브라우저에서 기본적으로 cross-origin 요청(CORS)에는 쿠키를 보내지 않음
    • 설정하면 서버에서 쿠키를 읽을 수 있음
 // CORS 설정
  app.enableCors({
    origin: ['http://localhost:3000', 'https://clovie-fe.vercel.app/'], // 프론트엔드 주소
    credentials: true, // 쿠키, Authorization 헤더 등 허용
  });

 

📍로그인

(1) dto 생성

export class LoginUserDto {
  @IsEmail()
  email: string;

  @IsString()
  password: string;
}

 

(2) service에서 로그인 로직 구현

const user = await this.usersRepository.findOne({
  where: { email },
  select: ['id', 'email', 'nickname', 'password', 'grade'],
});
  • 이메일로 DB에서 유저 조회.
  • select를 사용해서 필요한 컬럼만 가져옴 (특히 비밀번호는 선택적으로 가져와야 bcrypt 비교 가능)
const inValid = await bcrypt.compare(password, user.password);
if (!inValid) {
  throw new ConflictException('비밀번호가 일치하지 않습니다.');
}
  • bcrypt.compare로 입력한 비밀번호와 DB에 저장된 해시 비밀번호를 비교.
const payload = {
  id: user.id,
  email: user.email,
  nickname: user.nickname,
  grade: user.grade,
};
const accessToken = this.jwtService.sign(payload);
  • 로그인 성공 시 JWT payload 생성.
  • jwtService.sign(payload)Access Token 발급.

(3) JwtModule 등록

UsersService에서 JwtService를 사용하려면 UsersModuleJwtModule을 import 해야 한다.

pnpm add @nestjs/config
@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get('JWT_SECRET'),
        signOptions: {
          expiresIn: configService.get('JWT_EXPIRES_IN') || '1d',
        },
      }),
    }),
  ],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
  • ConfigService를 사용해서 .env에 저장한 값을 가져오고 있다.
  • ConfigModule.env 파일의 환경변수를 NestJS에서 읽을 수 있게 해주는 모듈
  • JWT 모듈에서 ConfigService를 쓰려면 먼저 이 모듈을 import 해야 한다.

useFactory: async (configService: ConfigService) => ({ ... })

  • JWT 설정값을 동적으로 가져와서 등록하는 부분
  1. secret: JWT 서명용 비밀키 → configService.get('JWT_SECRET')
  2. → 토큰의 무결성을 보장한다. 서버만 이 키를 알고 있어야 함.
  3. signOptions.expiresIn: JWT 만료 시간 → configService.get('JWT_EXPIRES_IN') || '1d'
  4. → 토큰이 유효한 시간 설정 (없으면 기본 1일)

왜 필요?

  • 로그인 시 발급한 JWT를 검증하고 만료 시간을 관리하기 위해
  • JwtService를 DI해서 sign() (토큰 발급), verify() (토큰 검증) 등을 사용할 수 있다.
  • 비동기 등록 방식(registerAsync) 덕분에 환경변수를 안전하게 읽어서 적용 가능.


auth 와 users의 관심사 분리에 관하여…

쉽게 비유하자면, 웹사이트를 하나의 '건물'이라고 생각해 보자.

🧍 users 모듈: 건물의 '회원 명단 관리실'

users 모듈은 말 그대로 사용자(User)에 대한 모든 정보를 관리하는 곳이다.

  • 새로운 회원(사용자) 등록 (Create)
  • 등록된 회원의 정보 조회 (예: 마이페이지) (Read)
  • 회원의 정보 수정 (예: 비밀번호 변경, 닉네임 변경) (Update)
  • 회원 탈퇴 (Delete)

 

  • users.controller.ts: "회원 정보 좀 변경해주세요" 같은 요청을 직접 받는 창구(API 엔드포인트) 역할.
  • users.service.ts: 요청받은 내용을 실질적으로 처리하는 비즈니스 로직. (예: "비밀번호를 암호화해서 저장해라")
  • users.repository.ts: 서비스의 요청에 따라 데이터베이스에서 실제 회원 데이터를 저장하고 꺼내오는 역할.
  • entities/user.entity.ts: '회원'이 어떤 정보(ID, 이메일, 이름 등)를 가져야 하는지 정의한 명세서.

결론: users는 '회원' 그 자체의 데이터(누가, 어떤 정보를 가졌는지)를 다루는 곳이다.


🔐 auth 모듈: 건물의 '출입증 발급처 & 경비실'

auth 모듈은 인증(Authentication)과 인가(Authorization)를 담당한다.

즉, "이 사람이 우리 회원(User)이 맞나?"를 확인하고 "맞다면 무엇을 할 수 있도록 허락해줄까?"를 결정하는 곳

  • 로그인(Login): 회원이 ID와 비밀번호를 제출했을 때, users 모듈에 저장된 정보와 일치하는지 확인
  • 회원가입(Signup): 가입 정보를 받아서, users 모듈에게 "이 정보로 새 회원 한 명 만들어줘"라고 요청
  • 출입증 발급 (토큰 생성): 로그인이 성공하면, 이 회원이 로그인했다는 증표인 '출입증(Access Token)'을 발급해 줌
  • 출입증 검사 (Guard): 특정 페이지나 기능에 접근할 때마다 '출입증'을 가졌는지, 유효한 출입증인지 검사한다. (예: auth.guard.ts)

 

  • auth.controller.ts: "로그인하게 해주세요", "회원가입할게요" 같은 인증 관련 요청을 받는 창구.
  • auth.service.ts: 로그인 로직(비밀번호 비교), 토큰 생성 등 실제 인증 처리를 담당.
  • dto/login-user.dto.ts: 로그인 시 어떤 정보를 받아야 하는지 정의한 서식.

결론: auth는 회원의 신원을 '확인'하고 서비스 접근을 '허가'하는 보안 및 인증 절차를 다루는 곳

둘의 관계…

auth 모듈은 users 모듈 없이는 존재하기 어렵다.

왜냐하면,

  1. 로그인을 하려면 auth 모듈이 users 모듈의 회원 명단(DB)을 뒤져봐야 하고,
  2. 회원가입을 하려면 auth 모듈이 users 모듈에게 회원 정보를 등록해달라고 시켜야 하기 때문이다.

'✨BACKEND > 📍NestJS' 카테고리의 다른 글

NestJS 인증 기능 구현하기  (8) 2025.08.10
NestJS 게시판 CRUD 구현하기  (7) 2025.07.27
NestJS 개념  (6) 2025.07.20