User 모듈은 사용자 관리의 기본 단위입니다. 사용자의 등록, 조회, 수정, 삭제 등의 기능을 담당하며, 인증의 기초가 되는 모듈입니다.
src/user/user.module.ts
: 사용자 관련 모듈 설정src/user/user.controller.ts
: 사용자 관련 API 엔드포인트 정의src/user/user.service.ts
: 사용자 관련 비즈니스 로직 구현src/user/user.entity.ts
: 사용자 데이터 모델 정의nest g mo user
nest g co user
nest g s user
Virtual Column은 실제 데이터베이스에는 존재하지 않지만, TypeORM에서 필요한 계산된 값이나 관계를 표현할 때 사용됩니다. 예를 들어, 사용자가 작성한 게시글 수를 조회할 때 유용합니다.
src/user/entities/user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@OneToMany(() => Board, (board) => board.user)
boards: Board[];
@Column({ select: false }) // 기본 조회 시 비밀번호 필드 제외
password: string;
}
사용자가 작성한 게시글의 수를 효율적으로 조회하기 위한 기능입니다. TypeORM의 QueryBuilder를 사용하여 최적화된 쿼리를 구현합니다.
src/user/entities/user.entity.ts
: Virtual Column 정의src/user/user.service.ts
: 조회 로직 구현@Entity()
export class User {
// ... 기존 컬럼들 ...
@VirtualColumn({
query: (alias) => `
SELECT COUNT(*)
FROM board
WHERE board.userId = ${alias}.id
`
})
boardCount: number;
}
// user.service.ts
async getUserWithBoardCount(userId: number) {
return this.userRepository
.createQueryBuilder("user")
.loadRelationCountAndMap(
"user.boardCount",
"user.boards"
)
.where("user.id = :userId", { userId })
.getOne();
}
MiniBlog 프로젝트에서는 사용자의 비밀번호 보안을 위해 bcrypt 라이브러리를 사용하여 암호화를 구현했습니다.
파일 경로: src/user/user.service.ts
회원가입 시 사용자가 입력한 평문 비밀번호를 bcrypt를 사용하여 해시화합니다:
import { hash } from 'bcrypt';
async signup(body: any) {
const { username, email, password } = body;
const encryptedPassword = await this.encryptPassword(password);
// 중복 사용자 체크 로직...
const newUser = this.userRepository.create({
username,
email,
password: encryptedPassword, // 암호화된 비밀번호 저장
});
return this.userRepository.save(newUser);
}
async encryptPassword(password: string) {
const DEFAULT_SALT = 11;
return hash(password, DEFAULT_SALT);
}
파일 경로: src/user/user.service.ts
로그인 시 입력된 평문 비밀번호와 데이터베이스에 저장된 해시된 비밀번호를 bcrypt의 compare
함수로 비교합니다:
import { compare } from 'bcrypt';
async login(body: LoginUserDto) {
const { email, password } = body;
const user = await this.userRepository.findOne({
where: { email },
select: ['id', 'email', 'username', 'password'], // password 필드 명시적 선택
});
if (!user) {
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
}
const isValid = await compare(password, user.password);
if (!isValid) {
throw new HttpException('UNAUTHORIZED', HttpStatus.UNAUTHORIZED);
}
// 비밀번호 정보 제외하고 응답
return plainToInstance(UserResponseDto, user, {
excludeExtraneousValues: true,
});
}
파일 경로: src/user/dto/user-response.dto.ts
로그인 성공 시 비밀번호 정보를 제외한 사용자 정보만 반환하기 위해 DTO를 생성합니다:
import { Expose } from 'class-transformer';
export class UserResponseDto {
@Expose()
id: number;
@Expose()
username: string;
@Expose()
email: string;
// password 필드는 @Expose() 데코레이터가 없어 응답에서 제외됨
}
파일 경로: src/user/user.service.ts
class-transformer
의 plainToInstance
함수를 사용하여 응답 데이터를 변환합니다:
import { plainToInstance } from 'class-transformer';
return plainToInstance(UserResponseDto, user, {
excludeExtraneousValues: true, // @Expose()가 없는 필드는 제외
});
파일 경로: src/main.ts
애플리케이션 전역에서 ClassSerializerInterceptor
를 설정하여 응답 데이터의 직렬화를 자동으로 처리합니다:
import { ClassSerializerInterceptor } from '@nestjs/common';
import { NestFactory, Reflector } from '@nestjs/core';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 전역 직렬화 인터셉터 설정
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
// 기타 설정...
}
파일 경로: src/entity/user.entity.ts
User 엔티티에서 password 필드에 select: false
옵션을 설정하여 기본 조회 시 비밀번호가 포함되지 않도록 합니다:
@Entity()
export class User {
// 기타 필드...
@Column({ select: false }) // 기본 조회 시 제외
password: string;
// 기타 필드...
}
이러한 다층 보안 구조를 통해 사용자의 비밀번호가 안전하게 보호되며, API 응답에서도 노출되지 않도록 보장합니다.
npm install @nestjs/jwt @nestjs/passport passport passport-local
npm install -D @types/passport-local
@Module({
imports: [
PassportModule,
TypeOrmModule.forFeature([User]),
JwtModule.register({
secret: 'secret_key', // JWT 서명/검증용 비밀키
signOptions: {
expiresIn: '1h', // 토큰 만료 시간 설정
},
}),
],
providers: [AuthService, LocalStrategy, UserService, JwtStrategy],
exports: [AuthService],
})
역할:
npm install @nestjs/passport passport passport-local
npm install -D @types/passport-local
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super(
usernameField: 'email',
); // passport-local 기본 설정 (username, password 필드)
}
async validate(username: string, password: string) {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user; // 성공 시 user 객체를 req.user에 저장
}
}
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
역할:
npm install bcrypt
npm install -D @types/bcrypt
async validateUser(username: string, password: string) {
const user = await this.userService.getUserByUsername(username);
if (user) {
const match = await compare(password, user.password); // bcrypt로 비밀번호 비교
if (match) {
return user; // 인증 성공
} else {
return null; // 비밀번호 틀림
}
}
return null; // 사용자 없음
}
@UseGuards(LocalAuthGuard) // LocalStrategy.validate() 실행
@Post('login')
async login(@Request() req) {
return this.authService.login(req.user); // 인증된 사용자로 JWT 생성
}
흐름:
POST /login → LocalAuthGuard → LocalStrategy.validate() → AuthService.validateUser() → DB 조회 + bcrypt 비교 → 성공 시 req.user 저장
npm install passport-jwt
npm install -D @types/passport-jwt
async login(user: User) {
const payload = {
id: user.id,
username: user.username,
name: user.name,
};
return {
accessToken: this.jwtService.sign(payload), // JWT 토큰 생성
};
}
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Authorization: Bearer 헤더에서 추출
ignoreExpiration: false, // 만료된 토큰 거부
secretOrKey: 'secret_key', // 토큰 검증용 비밀키
});
}
async validate(payload: { id: number; username: string; name: string }) {
return payload; // 검증된 payload를 req.user에 저장
}
}
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
@UseGuards(JwtAuthGuard) // JWT 토큰 검증
@Get('me')
async me(@Request() req) {
return req.user; // JWT에서 추출된 사용자 정보 반환
}
@Post()
@UseGuards(JwtAuthGuard)
create(@UserInfo() userInfo, @Body('contents') contents: string) {
if (!userInfo) throw new UnauthorizedException();
console.log('userInfo', userInfo.id);
return this.boardService.create({ userId: userInfo.id, contents });
}
토큰 검증 흐름:
GET /me (Authorization: Bearer token) → JwtAuthGuard → JwtStrategy.validate() → 토큰 검증 → payload 추출 → req.user 저장
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const UserInfo = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user; // JWT에서 추출된 사용자 정보 반환
},
);
@UseGuards(JwtAuthGuard)
@Post()
create(@UserInfo() userInfo) { // JWT payload가 userInfo에 자동 주입
console.log(userInfo.id); // { id: 1, username: "admin", name: "홍길동" }
}
1. POST /login { username, password }
2. LocalAuthGuard 실행
3. LocalStrategy.validate() → AuthService.validateUser()
4. DB 조회 + bcrypt 비교
5. 성공 시 AuthService.login() → JWT 토큰 생성
6. 클라이언트에 accessToken 반환
1. GET /me (Authorization: Bearer token)
2. JwtAuthGuard 실행
3. JwtStrategy.validate() → 토큰 검증
4. payload 추출하여 req.user에 저장
5. 컨트롤러 실행
❌ Access Token 만료 시간이 길면 (1시간~1일)
→ 토큰 탈취 시 오랫동안 악용 가능
→ 보안 위험 높음
❌ Access Token 만료 시간이 짧으면 (5~15분)
→ 사용자가 자주 재로그인 해야 함
→ 사용자 경험 나쁨
[클라이언트] → POST /auth/login (email, password)
↓
[서버] validateUser() → 사용자 인증
↓
[서버] login() 메서드 실행:
├─ accessToken 생성 (15분 만료)
├─ refreshToken 생성 (7일 만료)
└─ refreshToken을 DB에 저장
↓
[클라이언트] ← { accessToken, refreshToken }
↓
[클라이언트] localStorage에 두 토큰 저장
async login(user: UserResponseDto) {
const payload = {
id: user.id,
email: user.email,
username: user.username,
};
// 1. accessToken 생성 (기본 15분 만료)
const accessToken = this.jwtService.sign(payload);
// 2. refreshToken 생성 (7일 만료)
const refreshToken = await this.generateRefreshToken(user.id);
// 3. DB에 refreshToken 저장
await this.userRepository.update(user.id, { refreshToken });
// 4. 클라이언트에게 두 토큰 모두 반환
return {
accessToken,
refreshToken,
};
}
async generateRefreshToken(userId: number): Promise<string> {
const payload = { userId, type: 'refresh' };
return this.jwtService.sign(payload, {
secret: 'refresh_secret_key', // accessToken과 다른 시크릿 키
expiresIn: '7d', // 7일 만료
});
}
[클라이언트] → API 요청 (Authorization: Bearer accessToken)
↓
[서버] JwtAuthGuard → accessToken 검증
↓
✅ 유효하면 → API 응답
❌ 만료되면 → 401 Unauthorized
↓
[클라이언트] 401 받으면 자동으로 토큰 갱신 시도
[클라이언트] → POST /auth/refresh { refreshToken }
↓
[서버] refresh() 메서드 실행:
├─ refreshToken JWT 검증
├─ DB에서 해당 refreshToken 존재 확인
└─ 새로운 accessToken 생성
↓
[클라이언트] ← { accessToken }
↓
[클라이언트] 새 accessToken으로 원래 API 재요청
async generateRefreshToken(userId: number): Promise<string> {
const payload = { userId, type: 'refresh' };
return this.jwtService.sign(payload, {
secret: 'refresh_secret_key', // accessToken과 다른 시크릿 키
expiresIn: '7d', // 7일 만료
});
}
특징:
async refresh(refreshToken: string) {
try {
// 1. refreshToken JWT 검증
const payload = this.jwtService.verify(refreshToken, {
secret: 'refresh_secret_key', // 생성 시와 같은 시크릿 키
});
// 2. DB에서 사용자와 refreshToken 일치 확인
const user = await this.userRepository.findOne({
where: { id: payload.userId, refreshToken }, // 🔑 핵심: DB의 토큰과 비교
select: ['id', 'email', 'username', 'refreshToken'],
});
if (!user) {
throw new HttpException('Invalid refresh token', HttpStatus.UNAUTHORIZED);
}
// 3. 새로운 accessToken 발급
const newAccessToken = this.jwtService.sign({
id: user.id,
email: user.email,
username: user.username,
});
return { accessToken: newAccessToken };
} catch (error) {
throw new HttpException('Invalid refresh token', HttpStatus.UNAUTHORIZED);
}
}
보안 검증 단계:
[클라이언트] → POST /auth/logout (Authorization: Bearer accessToken)
↓
[서버] logout() 메서드 실행:
└─ DB에서 refreshToken 삭제 (null로 설정)
↓
[클라이언트] ← { message: "로그아웃 성공" }
↓
[클라이언트] localStorage에서 두 토큰 삭제
async logout(userId: number) {
// DB에서 refreshToken 삭제 (null로 설정)
await this.userRepository.update(userId, { refreshToken: null });
return {
message: 'Logout successful. Refresh token has been invalidated.',
};
}
// 로그인 (accessToken + refreshToken 발급)
@UseGuards(LocalAuthGuard)
@Post('login')
async login(@Request() req: any) {
return this.authService.login(req.user);
}
// 토큰 갱신 (Guard 없음 - Body로 받음)
@Post('refresh')
async refresh(@Body() body: { refreshToken: string }) {
return this.authService.refresh(body.refreshToken);
}
// 로그아웃 (refreshToken 무효화)
@UseGuards(JwtAuthGuard)
@Post('logout')
async logout(@Request() req: any) {
return this.authService.logout(req.user.id);
}