본문으로 건너뛰기

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})`);

// 여기에 로그인 성공 후 추가 작업 구현
// 예: 마지막 로그인 시간 업데이트, 로그인 기록 저장, 알림 전송 등
}
}

플로우 다이어그램​

로그인 명령의 실행 흐름은 다음과 같습니다:

  1. 클라이언트가 로그인 요청 전송
  2. 컨트롤러가 DTO를 받아 LoginCommand 생성
  3. CommandBus가 명령을 적절한 핸들러로 전달
  4. LoginCommandHandler가 다음 단계 실행:
    • 사용자 조회
    • 계정 상태 확인
    • 비밀번호 검증
    • 로그인 시도 기록
    • 토큰 생성
    • 세션 생성
    • 이벤트 발행
  5. 결과가 컨트롤러로 반환되어 클라이언트에 응답

이점​

  1. 관심사 분리: 인증 로직이 명확하게 분리되어 있어 관리가 용이합니다.
  2. 확장성: 이벤트 기반 설계로 인해 로그인 프로세스에 새로운 기능을 쉽게 추가할 수 있습니다.
  3. 테스트 용이성: 명령 객체와 핸들러가 명확히 분리되어 있어 단위 테스트가 용이합니다.
  4. 가독성: 로그인 프로세스의 각 단계가 명확히 구분되어 있어 코드 이해가 쉽습니다.
  5. 보안: 도메인 엔티티의 메서드를 활용하여 일관된 보안 규칙 적용이 가능합니다.

주의사항​

  1. 비밀번호 검증은 항상 타이밍 공격에 대한 방어 메커니즘을 포함해야 합니다.
  2. 로그인 실패 및 계정 잠금 정책은 보안과 사용자 경험 사이의 균형을 고려해야 합니다.
  3. 이벤트 핸들러의 장애가 로그인 프로세스에 영향을 미치지 않도록 설계해야 합니다.