Login 명령 객체 구현 가이드
개요
login.command.ts 파일은 CQRS(Command Query Responsibility Segregation) 패턴에 따라 로그인 요청을 처리하기 위한 명령 객체를 구현합니다. 이 명령 객체는 사용자 인증 흐름을 시작하는 진입점 역할을 하며, 애플리케이션 계층에서 도메인 계층과의 상호작용을 조정합니다.
구현 코드
명령 객체 정의
import { Command } from '@nestjs/cqrs';
export class LoginCommand extends Command {
constructor(
public readonly email: string,
public readonly password: string,
public readonly ipAddress?: string,
public readonly userAgent?: string,
public readonly deviceInfo?: Record<string, any>
) {
super();
}
}
/**
* 참고: PIN 코드(6자리)는 ISO27001, GDPR, DiGA 규제 준수를 위해 서버로 전송되거나 서버에 저장되지 않습니다.
* 모바일 앱에서 PIN 코드는 로컬에 암호화되어 저장된 인증 정보(이메일/비밀번호)를 복호화하는 데 사용되며,
* 서버로는 복호화된 이메일과 비밀번호만 전송됩니다.
*/
### 명령 핸들러 구현
```typescript
import { CommandHandler, ICommandHandler, EventBus } from '@nestjs/cqrs';
import { LoginCommand } from './login.command';
import { UserRepository } from '../../infrastructure/repository/user.repository';
import { TokenService } from '../../infrastructure/service/token.service';
import { SessionService } from '../../infrastructure/service/session.service';
import { UnauthorizedException, InternalServerErrorException } from '@nestjs/common';
import { LoginSuccessEvent } from '../event/login-success.event';
import { LoginFailedEvent } from '../event/login-failed.event';
import { TokenPair } from '../../domain/value-object/token.value-object';
@CommandHandler(LoginCommand)
export class LoginCommandHandler implements ICommandHandler<LoginCommand> {
constructor(
private readonly userRepository: UserRepository,
private readonly tokenService: TokenService,
private readonly sessionService: SessionService,
private readonly eventBus: EventBus
) {}
async execute(command: LoginCommand): Promise<TokenPair> {
const { email, password, ipAddress, userAgent, deviceInfo } = command;
try {
// 1. 사용자 이메일로 조회
const user = await this.userRepository.findByEmail(email);
if (!user) {
// 사용자를 찾을 수 없는 경우 이벤트 발행 및 예외 발생
this.eventBus.publish(
new LoginFailedEvent(email, 'user_not_found', ipAddress)
);
throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다');
}
// 2. 계정 활성화 상태 확인
if (!user.isActive()) {
this.eventBus.publish(
new LoginFailedEvent(email, 'account_inactive', ipAddress)
);
throw new UnauthorizedException('계정이 비활성화되었습니다');
}
// 3. 계정 잠금 상태 확인
if (user.isLocked()) {
this.eventBus.publish(
new LoginFailedEvent(email, 'account_locked', ipAddress)
);
throw new UnauthorizedException('계정이 잠겼습니다. 관리자에게 문의하세요');
}
// 4. 비밀번호 검증
const isPasswordValid = await user.validatePassword(password);
if (!isPasswordValid) {
// 로그인 실패 기록
user.recordLoginAttempt(false);
await this.userRepository.save(user);
this.eventBus.publish(
new LoginFailedEvent(email, 'invalid_password', ipAddress)
);
throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다');
}
// 5. 로그인 성공 기록
user.recordLoginAttempt(true);
await this.userRepository.save(user);
// 6. 토큰 생성
const tokenPair = await this.tokenService.generateTokenPair(user.id);
// 7. 세션 생성
await this.sessionService.createSession(
user.id,
tokenPair,
{
ipAddress,
userAgent,
deviceInfo
}
);
// 8. 로그인 성공 이벤트 발행
this.eventBus.publish(
new LoginSuccessEvent(
user.id.toString(),
user.email,
ipAddress,
deviceInfo
)
);
// 9. 토큰 쌍 반환
return tokenPair;
} catch (error) {
// 이미 처리된 유형의 오류는 그대로 전파
if (error instanceof UnauthorizedException) {
throw error;
}
// 기타 예상치 못한 오류 처리
this.eventBus.publish(
new LoginFailedEvent(email, 'unexpected_error', ipAddress)
);
throw new InternalServerErrorException('로그인 처리 중 오류가 발생했습니다');
}
}
}
관련 이벤트 정의
// login-success.event.ts
export class LoginSuccessEvent {
constructor(
public readonly userId: string,
public readonly email: string,
public readonly ipAddress?: string,
public readonly deviceInfo?: Record<string, any>
) {}
}
// login-failed.event.ts
export type LoginFailureReason =
| 'user_not_found'
| 'invalid_password'
| 'account_inactive'
| 'account_locked'
| 'unexpected_error';
export class LoginFailedEvent {
constructor(
public readonly email: string,
public readonly reason: LoginFailureReason,
public readonly ipAddress?: string
) {}
}
주요 설계 원칙 및 설명
1. 명령과 책임의 분리(CQRS)
로그인 작업을 명령(Command)으로 모델링하여, 조회(Query) 작업과 명확히 분리합니다. 이는 시스템의 읽기/쓰기 작업을 분리하여 성능과 확장성을 개선하는 CQRS 패턴의 핵심입니다.
2. 단일 책임 원칙(SRP)
명령 객체는 로그인 요청에 필요한 데이터만 포함하고, 명령 핸들러는 로그인 프로세스 실행만을 담당합니다. 이는 코드의 가독성과 유지보수성을 향상시킵니다.
3. 도메인 모델 활용
명령 핸들러는 도메인 모델(User 엔티티 등)의 메서드를 활용하여 비즈니스 로직을 수행합니다. 이로써 도메인 로직이 한 곳에 집중되고, 애플리케이션 계층은 조정 역할만 담당합니다.
4. 이벤트 기반 아키텍처
로그인 성공/실패 시 이벤트를 발행하여 시스템의 다른 부분이 이에 반응할 수 있게 합니다. 이는 느슨한 결합과 확장성을 제공합니다.
5. 보안 고려사항
비밀번호 검증, 계정 잠금, 모호한 오류 메시지 등 보안 모범 사례를 준수합니다.
사용 예제
컨트롤러에서 명령 사용
import { Controller, Post, Body, Ip, Headers, CommandBus } from '@nestjs/common';
import { LoginCommand } from '../command/login.command';
import { LoginDto } from '../dto/login.dto';
import { UserAgent } from '../decorator/user-agent.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly commandBus: CommandBus) {}
@Post('login')
async login(
@Body() loginDto: LoginDto,
@Ip() ipAddress: string,
@UserAgent() userAgent: string
) {
// 디바이스 정보 추출 (예시)
const deviceInfo = this.extractDeviceInfo(userAgent);
// 명령 생성 및 실행
const command = new LoginCommand(
loginDto.email,
loginDto.password,
ipAddress,
userAgent,
deviceInfo
);
const tokenPair = await this.commandBus.execute(command);
// 응답 구성
return {
accessToken: tokenPair.accessToken.toString(),
refreshToken: tokenPair.refreshToken.toString(),
expiresIn: this.getExpiresIn(tokenPair.accessToken.expiresAt)
};
}
private extractDeviceInfo(userAgent: string): Record<string, any> {
// 사용자 에이전트 문자열에서 디바이스 정보 추출 로직
// ...
return { browser: 'Chrome', os: 'Windows', device: 'Desktop' };
}
private getExpiresIn(expiresAt: Date): number {
return Math.floor((expiresAt.getTime() - Date.now()) / 1000);
}
}
이벤트 핸들러 예시
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { LoginSuccessEvent } from '../event/login-success.event';
import { Logger } from '@nestjs/common';
@EventsHandler(LoginSuccessEvent)
export class LoginSuccessHandler implements IEventHandler<LoginSuccessEvent> {
private readonly logger = new Logger(LoginSuccessHandler.name);
async handle(event: LoginSuccessEvent) {
this.logger.log(`사용자 로그인 성공: ${event.email} (${event.ipAddress})`);
// 여기에 로그인 성공 후 추가 작업 구현
// 예: 마지막 로그인 시간 업데이트, 로그인 기록 저장, 알림 전송 등
}
}
플로우 다이어그램
로그인 명령의 실행 흐름은 다음과 같습니다:
- 클라이언트가 로그인 요청 전송
- 컨트롤러가 DTO를 받아 LoginCommand 생성
- CommandBus가 명령을 적절한 핸들러로 전달
- LoginCommandHandler가 다음 단계 실행:
- 사용자 조회
- 계정 상태 확인
- 비밀번호 검증
- 로그인 시도 기록
- 토큰 생성
- 세션 생성
- 이벤트 발행
- 결과가 컨트롤러로 반환되어 클라이언트에 응답
이점
- 관심사 분리: 인증 로직이 명확하게 분리되어 있어 관리가 용이합니다.
- 확장성: 이벤트 기반 설계로 인해 로그인 프로세스에 새로운 기능을 쉽게 추가할 수 있습니다.
- 테스트 용이성: 명령 객체와 핸들러가 명확히 분리되어 있어 단위 테스트가 용이합니다.
- 가독성: 로그인 프로세스의 각 단계가 명확히 구분되어 있어 코드 이해가 쉽습니다.
- 보안: 도메인 엔티티의 메서드를 활용하여 일관된 보안 규칙 적용이 가능합니다.
주의사항
- 비밀번호 검증은 항상 타이밍 공격에 대한 방어 메커니즘을 포함해야 합니다.
- 로그인 실패 및 계정 잠금 정책은 보안과 사용자 경험 사이의 균형을 고려해야 합니다.
- 이벤트 핸들러의 장애가 로그인 프로세스에 영향을 미치지 않도록 설계해야 합니다.