본문으로 건너뛰기

회원가입 프로세스 구현 가이드

개요

이 문서는 사용자 회원가입 프로세스의 전체 흐름과 각 단계별 구현 방법에 대한 가이드라인을 제공합니다. 회원가입 프로세스는 약관 동의, 이메일 인증, 사용자 정보 등록 등 여러 단계로 구성됩니다. Access Code 사용 처리는 사용자 등록 완료 후 발행되는 이벤트를 통해 Access Code 도메인에서 비동기적으로 처리됩니다.

회원가입 프로세스 흐름

구현 가이드

1. 약관 목록 조회 API 구현

회원가입에 필요한 활성 약관 목록을 조회하는 API입니다. 사용자는 이 목록을 보고 필수 약관에 동의해야 합니다.

// Controller 예시
@Get('terms')
async getActiveTerms(
@Query() query: GetTermsQueryDto,
@Headers('Accept-Language') language: string = 'ko-KR'
): Promise<ApiResponse<TermsDto[]>> {
const lang = this.extractLanguage(language);
const terms = await this.termsService.getActiveTerms(query.type, lang);
return {
data: terms.map(t => this.mapToTermsDto(t, lang))
};
}

2. 이메일 인증 코드 발송 API 구현

사용자가 입력한 이메일 주소로 6자리 인증 코드를 발송합니다.

  • API Endpoint: POST /auth/email/verification-code
  • 주요 로직: EmailService.sendVerificationCode 호출 (OTP 생성, Redis 저장, 이메일 발송)
  • 상세 구현: 이메일 인증 구현 가이드 참조
// Controller 예시
@Post('email/verification-code')
@UseGuards(AppTokenGuard)
async sendVerificationCode(
@Body() dto: SendVerificationCodeDto
): Promise<ApiResponse<EmailVerificationResultDto>> {
const result = await this.emailService.sendVerificationCode(dto.email);
return {
data: {
success: true,
email: dto.email,
expiresIn: result.expiresIn
}
};
}

3. 이메일 인증 코드 확인 API 구현

사용자가 이메일로 받은 인증 코드를 검증하여 이메일 소유권을 확인합니다.

  • API Endpoint: POST /auth/email/registration-verify
  • 주요 로직: EmailService.verifyCode 호출 (Redis 조회 및 비교, 상태 저장)
  • 상세 구현: 이메일 인증 구현 가이드 참조
// Controller 예시
@Post('email/registration-verify')
@UseGuards(AppTokenGuard)
async verifyEmailCode(
@Body() dto: VerifyEmailCodeDto
): Promise<ApiResponse<EmailVerificationStatusDto>> {
const isValid = await this.emailService.verifyCode(dto.email, dto.code);
if (!isValid) {
throw new BadRequestException({ code: 1055, message: 'INVALID_VERIFICATION_CODE' });
}
return {
data: {
verified: true,
email: dto.email
}
};
}

4. 회원가입 API 구현

모든 사전 검증(이메일 인증, Access Code 유효성, 필수 약관 동의) 완료 후, 사용자 정보를 최종 등록하고 로그인 토큰을 발급합니다. 사용자 등록 성공 후에는 user.registered 이벤트를 발행하여 Access Code 사용 처리 등 비동기 작업을 트리거합니다. Access Code 사용 처리에 대한 상세 구현은 Access Code 관리 서비스 구현 가이드를 참조하세요.

  • API Endpoint: POST /auth/register
  • 주요 로직:
    1. 이메일 인증 상태 확인 (EmailService.isEmailVerified)
    2. Access Code 유효성 검증 및 상세 정보(groupId 포함) 조회 (AccessCodeService.validateAndGetDetails)
    3. 필수 약관 동의 확인 (TermsService.checkRequiredAgreements)
    4. Access Code의 groupId를 기반으로 IAM 도메인에서 초기 역할/권한 정보 조회 (QueryBus를 통해 GetRolesForGroupQuery 실행)
    5. 사용자 계정 생성 시 초기 그룹 정보 전달 (UserService.createUser 호출 시 initialGroupId 인자 추가)
    6. user.registered 이벤트 발행 (비동기 처리용 - 기존 유지)
    7. 로그인 토큰 생성 시 초기 역할/권한 정보 전달 (AuthService.generateTokens 호출 시 roles 또는 permissions 인자 추가)
  • 상세 구현: 아래 DTO, Controller 및 관련 서비스(UserService, TermsService, AccessCodeService, AuthService), CQRS 모듈(QueryBus, EventEmitter2) 구현 참조

DTO 정의

// 파일 경로: libs/shared/contracts-auth/dto/src/lib/auth/register.dto.ts
import { Type } from 'class-transformer';
import { IsString, IsEmail, MinLength, Matches, Length, IsArray, ValidateNested, IsNumber, IsBoolean, IsOptional, ArrayMinSize } from 'class-validator';

export class ProfileDto {
@IsString()
@IsOptional()
language?: string;

@IsString()
@IsOptional()
timezone?: string;
}

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

@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, {
message: '비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다.'
})
password: string;

@IsString()
@Length(16, 16)
accessCode: string;

@IsArray()
@ValidateNested({ each: true })
@Type(() => AgreementDto)
@ArrayMinSize(1)
agreements: AgreementDto[];

@IsString()
deviceId: string;

@IsString()
verificationId: string;

@ValidateNested()
@Type(() => ProfileDto)
@IsOptional()
profile?: ProfileDto;
}

export class AgreementDto {
@IsNumber()
termsId: number;

@IsString()
version: string;

@IsBoolean()
isAgreed: boolean;
}

export interface UserRegistrationResultDto {
userId: string;
email: string;
profile?: ProfileDto;
accessToken: string;
refreshToken: string;
expiresIn: number;
createdAt: number;
}

Controller 구현

// 파일 경로: apps/dta-wide-api/src/app/auth/controllers/registration.controller.ts
import { Controller, Post, Body, UseGuards, Inject, Req } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { QueryBus, CommandBus } from '@nestjs/cqrs';
import { AppTokenGuard } from '@core/auth/guards';
import { RegisterUserDto, UserRegistrationResultDto, UserRegisteredEventPayload, AgreementDto } from '@shared-contracts/auth/dto';
import { RoleDto, PermissionDto } from '@shared-contracts/auth/dto/iam';
import { UserDto } from '@shared-contracts/auth/dto/user';
import { GetRolesForGroupQuery } from '@shared/queries/iam';
import { CreateUserCommand } from '@shared/commands/user';
import { EmailService, TermsService, AuthService } from '@feature/auth-core';
import { AccessCodeService } from '@feature/access-code';
import { ApiResponse } from '@shared/api-types';
import { AuthenticatedRequest } from '@core/auth/interfaces';
import {
EmailNotVerifiedError,
TermsAcceptanceRequiredError
} from '@feature/auth-core';
import { AccessCodeInvalidError } from '@feature/access-code';

@Controller('auth')
export class RegistrationController {
constructor(
@Inject(EmailService) private readonly emailService: EmailService,
@Inject(TermsService) private readonly termsService: TermsService,
@Inject(AccessCodeService) private readonly accessCodeService: AccessCodeService,
@Inject(AuthService) private readonly authService: AuthService,
private readonly eventEmitter: EventEmitter2,
private readonly queryBus: QueryBus,
private readonly commandBus: CommandBus,
) {}

@Post('register')
@UseGuards(AppTokenGuard)
async register(
@Body() dto: RegisterUserDto,
@Req() request: AuthenticatedRequest
): Promise<ApiResponse<UserRegistrationResultDto>> {

const isVerified = await this.emailService.isEmailVerified(dto.email, dto.verificationId);
if (!isVerified) {
throw new EmailNotVerifiedError({ verificationId: dto.verificationId });
}

const accessCodeDetails = await this.accessCodeService.validateAndGetDetails(dto.accessCode);
if (!accessCodeDetails || !accessCodeDetails.isValid) {
throw new AccessCodeInvalidError({ code: dto.accessCode });
}
const initialGroupId = accessCodeDetails.groupId;
if (!initialGroupId) {
// groupId가 없는 경우의 처리 로직 (예: 기본 그룹 할당 또는 에러 처리)
// throw new Error('Access Code does not have a groupId'); // 예시
}

const hasRequiredAgreements = await this.termsService.checkRequiredAgreements(dto.agreements);
if (!hasRequiredAgreements) {
throw new TermsAcceptanceRequiredError({
agreedTerms: dto.agreements.filter(a => a.isAgreed).map(a => a.termsId)
});
}

const initialRolesResult = await this.queryBus.execute<GetRolesForGroupQuery, RoleDto[]>(
new GetRolesForGroupQuery(initialGroupId)
);

const initialRoleIds = initialRolesResult.map(role => role.id);
const initialPermissionNames = initialRolesResult.flatMap(role => role.permissions?.map(p => p.name) ?? []);
const uniquePermissions = [...new Set(initialPermissionNames)];
const consents = dto.agreements
.filter(a => a.isAgreed)
.map(a => `TERMS_${a.termsId}_V${a.version}`);

// TimeMachine 정보 추출 (accessCodeDetails에 포함되어 있다고 가정)
const virtualTimeStartDate = accessCodeDetails.useTimeMachine ? accessCodeDetails.virtualTimeStartDate : undefined;

// 사용자 생성 명령 실행 (UserService 직접 호출 대신)
// CreateUserCommand 및 해당 핸들러 구현에 대한 자세한 내용은
// [User 도메인 CQRS 커맨드 구현 가이드](../../user/technical-docs/implementation-user-commands.md) 문서를 참조하세요.
// CreateUserHandler가 생성된 User 정보를 반환한다고 가정 (예: UserDto)
const createdUser = await this.commandBus.execute<CreateUserCommand, UserDto>(
new CreateUserCommand(
dto.email,
dto.password,
dto.agreements,
initialGroupId, // 검증 로직에서 얻은 groupId 전달
dto.profile,
request.ip,
request.headers['user-agent'],
dto.deviceId,
virtualTimeStartDate // 추출한 가상 시작 시간 전달
)
);

// 사용자 등록 완료 이벤트 발행
// 이 이벤트는 Access Code 도메인에서 수신하여 해당 코드를 'USED' 상태로 처리합니다.
// 상세 처리 구현은 implementation-code-management-service.md 문서의 '4.2.2 코드 사용 처리 (이벤트 기반)' 섹션을 참조하세요.
const eventPayload: UserRegisteredEventPayload = {
userId: createdUser.id,
accessCode: dto.accessCode
};
this.eventEmitter.emit('user.registered', eventPayload);

const tokens = await this.authService.generateTokens(
createdUser.id,
dto.deviceId,
{
roles: initialRoleIds,
permissions: uniquePermissions,
consents: consents
}
);

const responseProfile = createdUser.profile ? { language: createdUser.profile.language, timezone: createdUser.profile.timezone } : undefined;

// 응답 반환 (data 키 없이 직접 반환 - conventions.md 3.1 준수)
return {
userId: createdUser.id,
email: createdUser.email,
profile: responseProfile,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresIn: tokens.expiresIn,
createdAt: createdUser.createdAt.getTime() // UserDto에 createdAt이 있다고 가정
};
}
}

보안 고려사항

  • 민감한 사용자 정보(비밀번호 등)는 항상 암호화하여 저장해야 합니다.
  • 회원가입 과정에서 CSRF 및 세션 하이재킹 공격을 방지하기 위한 보안 조치가 필요합니다. (AppTokenGuard가 일부 담당)
  • 약관 동의 과정에서는 동의한 약관의 버전 및 내용에 대한 해시를 함께 저장하여 추후 약관 변경에 대한 증빙이 가능하도록 해야 합니다. (UserService.createUser 내부 로직)
  • 이메일 인증 코드는 충분한 엔트로피를 가지도록 생성해야 하며, 일회용으로 설계되어야 합니다. (이메일 인증 구현 가이드 참조)

테스트 전략

  • 각 API 엔드포인트에 대한 단위 테스트 및 통합 테스트 작성
  • 회원가입 프로세스 전체 흐름에 대한 E2E 테스트 작성
  • 유효하지 않은 입력에 대한 예외 처리 테스트
  • 보안 취약점 테스트 (SQL 인젝션, XSS 등)

성능 고려사항

  • 이메일 발송은 비동기로 처리하여 API 응답 시간을 최소화 (이메일 인증 구현 가이드 참조)
  • 회원가입 요청 시 중복 계정 확인 등의 검증 로직 최적화 (UserService.createUser 내부 로직)
  • 데이터베이스 인덱싱을 통한 조회 성능 최적화

오류 코드 관리

회원 가입 프로세스에서 발생하는 다양한 오류는 Auth 도메인의 공통 오류 관리 방식을 따릅니다. 이 방식은 오류 코드와 메시지를 enum으로 중앙 관리하여 일관성과 유지보수성을 높입니다.

회원 가입 프로세스와 관련된 주요 오류 코드는 다음과 같습니다:

HTTP 상태 코드오류 코드메시지설명
5002000SERVER_ERROR서버 내부 오류
4092020EMAIL_ALREADY_EXISTS이미 사용 중인 이메일입니다.
4002021WEAK_PASSWORD비밀번호가 보안 요구사항을 충족하지 않습니다
4002013EMAIL_NOT_VERIFIED이메일 인증이 완료되지 않았습니다.
4002014VERIFICATION_CODE_EXPIRED인증 코드가 만료되었습니다
4001103REQUIRED_TERMS_NOT_AGREED필수 약관에 동의하지 않았습니다.
4003010ACCESS_CODE_NOT_VALIDATEDAccess Code 검증이 완료되지 않았거나 유효하지 않습니다.

오류 코드 관리 방식과 구현에 대한 자세한 내용은 오류 관리 가이드를 참조하세요.

변경 이력

버전날짜작성자변경 내용
0.1.02025-04-24bok@weltcorp.com초기 작성