본문으로 건너뛰기

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

개요​

이 문서는 사용자 회원가입 프로세스의 전체 흐름과 각 단계별 구현 방법에 대한 가이드라인을 제공합니다. 회원가입 프로세스는 약관 동의, 이메일 인증, 사용자 정보 등록 등 여러 단계로 구성됩니다. 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초기 작성