본문으로 건너뛰기

사용자 계정 관리 API 구현

개요

이 문서는 User 도메인의 사용자 계정 관리 API 구현에 대한 기술적 세부사항을 제공합니다. 이 API는 사용자 생성, 조회, 수정, 비활성화 등 계정의 기본적인 라이프사이클 관리를 담당합니다. API 엔드포인트 명세는 User API 엔드포인트 문서를 참조하세요.

주요 구성 요소

사용자 계정 관리 기능은 다음 주요 구성 요소로 이루어집니다:

  1. UserController: API 요청 처리 및 응답 반환
  2. UserService: 비즈니스 로직 처리 (계정 생성, 정보 수정, 상태 변경 등)
  3. UserRepository: 데이터베이스 상호작용 (사용자 데이터 CRUD) 및 캐싱
  4. User Entity/Aggregate: 사용자 도메인 모델 (User 도메인 모델 참조)
  5. DTOs (Data Transfer Objects): API 요청/응답 데이터 구조 정의
  6. RedisCacheService: 리포지토리 레벨 캐싱 지원

구현 가이드

UserController 구현

UserController는 HTTP 요청을 받아 유효성을 검사하고, UserService를 호출하여 비즈니스 로직을 수행한 후, 결과를 클라이언트에 반환합니다. @nestjs/swagger를 사용하여 API 문서를 자동 생성하고, @nestjs/common의 데코레이터를 사용하여 요청 처리, 경로 매개변수, 본문 데이터 바인딩 등을 처리합니다.

// 파일 경로: apps/dta-wide-api/src/app/user/controllers/user.controller.ts
import { Controller, Post, Get, Patch, Delete, Param, Body, HttpCode, HttpStatus, ParseUUIDPipe, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { UserService } from '@feature/user'; // UserService 경로 확인 필요
import { CreateUserDto, UpdateUserDto, UserDto, ServiceAccountDto, CreateServiceAccountDto } from '../dtos'; // DTO 경로 확인 필요
import { JwtAuthGuard } from '@feature/auth-core'; // Auth 가드 경로 확인 필요
import { RolesGuard, Roles, Role } from '@feature/iam'; // IAM 가드/데코레이터 경로 확인 필요

@ApiTags('사용자 계정 관리')
@ApiBearerAuth() // 모든 엔드포인트에 JWT 인증 필요 명시
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}

// 2.1 사용자 생성 (Auth 도메인 연계, 직접 노출은 지양)
// 이 엔드포인트는 실제로는 Auth의 /register 내부 로직에서 UserService.createUser를 호출할 가능성이 높음
@Post()
@UseGuards(JwtAuthGuard, RolesGuard) // 내부 호출 또는 특정 관리자 권한 필요
@Roles(Role.SystemAdmin) // 예시 권한 설정
@ApiOperation({ summary: '내부용 사용자 생성 (Auth 연계)' })
@ApiResponse({ status: 201, description: '사용자 생성 성공', type: UserDto })
@ApiResponse({ status: 400, description: '잘못된 요청 데이터' })
@ApiResponse({ status: 409, description: '이메일 중복' })
async createUser(@Body() createUserDto: CreateUserDto): Promise<UserDto> {
// 주의: 이 API는 보안상 직접 노출하기보다 내부 서비스 호출로 구현하는 것이 안전합니다.
// Auth 도메인의 register API에서 이메일/비밀번호 처리 후 UserService.createUser 호출
const user = await this.userService.createUser(createUserDto);
return UserDto.fromEntity(user); // User 엔티티를 UserDto로 변환
}

// 2.2 서비스 계정 생성
@Post('service-accounts')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.SystemAdmin) // 시스템 관리자만 생성 가능
@ApiOperation({ summary: '서비스 계정 생성' })
@ApiResponse({ status: 201, description: '서비스 계정 생성 성공', type: ServiceAccountDto })
@ApiResponse({ status: 403, description: '권한 없음' })
async createServiceAccount(@Body() createServiceAccountDto: CreateServiceAccountDto): Promise<ServiceAccountDto> {
const serviceAccount = await this.userService.createServiceAccount(createServiceAccountDto);
// ServiceAccountDto 정의 필요 (API Key 등 포함 여부 결정)
return ServiceAccountDto.fromEntity(serviceAccount);
}

// 2.3 사용자 조회
@Get(':userId')
@UseGuards(JwtAuthGuard) // 로그인한 모든 사용자 가능 (자신 또는 관리자)
@ApiOperation({ summary: '특정 사용자 정보 조회' })
@ApiResponse({ status: 200, description: '사용자 정보 조회 성공', type: UserDto })
@ApiResponse({ status: 403, description: '권한 없음 (다른 사용자 조회 시)' })
@ApiResponse({ status: 404, description: '사용자 없음' })
async getUserById(
@Param('userId', ParseUUIDPipe) userId: string,
@Req() req: any // 요청 객체에서 사용자 정보(요청자 ID) 가져오기
): Promise<UserDto> {
const requestingUserId = req.user.userId; // JWT 페이로드에서 사용자 ID 추출 (실제 구조에 맞게 조정)
const user = await this.userService.getUserById(userId, requestingUserId); // 권한 검사 포함
return UserDto.fromEntity(user);
}

// 2.4 사용자 정보 수정 (이름, profileData)
@Patch(':userId')
@UseGuards(JwtAuthGuard) // 로그인한 모든 사용자 가능 (자신 또는 관리자)
@ApiOperation({ summary: '사용자 정보 수정 (이름, 프로필 데이터)' })
@ApiResponse({ status: 200, description: '사용자 정보 수정 성공', type: UserDto })
@ApiResponse({ status: 400, description: '잘못된 요청 데이터 또는 수정 불가 필드' })
@ApiResponse({ status: 403, description: '권한 없음 (다른 사용자 수정 시)' })
@ApiResponse({ status: 404, description: '사용자 없음' })
async updateUser(@Param('userId', ParseUUIDPipe) userId: string, @Body() updateUserDto: UpdateUserDto, @Req() req: any): Promise<UserDto> {
const requestingUserId = req.user.userId;
const user = await this.userService.updateUser(userId, updateUserDto, requestingUserId);
return UserDto.fromEntity(user);
}

// 2.5 사용자 비활성화 요청 (Soft Delete)
@Delete(':userId')
@UseGuards(JwtAuthGuard) // 로그인한 모든 사용자 가능 (자신 또는 관리자)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '사용자 계정 비활성화 요청 (Soft Delete)' })
@ApiResponse({ status: 200, description: '비활성화 요청 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (다른 사용자 비활성화 시)' })
@ApiResponse({ status: 404, description: '사용자 없음' })
@ApiResponse({ status: 409, description: '이미 비활성화된 사용자' })
async requestDeactivation(@Param('userId', ParseUUIDPipe) userId: string, @Req() req: any): Promise<{ message: string; userId: string; status: string; deletedAt: number }> {
const requestingUserId = req.user.userId;
const result = await this.userService.requestDeactivation(userId, requestingUserId);
return {
message: '사용자 계정이 성공적으로 비활성화 요청되었습니다.',
userId: result.id,
status: result.status,
deletedAt: result.deletedAt?.getTime(), // Date 객체를 타임스탬프로 변환
};
}
}

UserService 구현

UserService는 실제 비즈니스 로직을 수행합니다. UserRepository를 통해 데이터를 조회하며, 이 과정에서 캐싱이 활용될 수 있습니다. 필요한 경우 다른 도메인 서비스(예: IAM 연동)와 상호작용하거나 도메인 이벤트를 발행할 수 있습니다.

// 파일 경로: libs/feature/user/src/lib/domain/services/user.service.ts
import { Injectable } from '@nestjs/common'; // NotFoundException 등 기본 예외는 BaseError에서 HTTP 상태 코드를 관리하므로 직접 사용 줄임
import { CommandBus } from '@nestjs/cqrs'; // CommandBus 임포트
import { UserRepository } from '../repositories/user.repository'; // UserRepository 인터페이스
import { User } from '../entities/user.entity'; // User 엔티티
import { UserStatus, UserType } from '@prisma/client'; // Prisma enum 타입
import { CreateUserDto, UpdateUserDto, CreateServiceAccountDto } from '../../application/dtos'; // DTO 경로 확인 필요
import { ErrorLoggerService, PermissionDeniedError } from '@core/error-handling'; // 로깅 서비스 및 공유 에러 클래스 임포트
import { UserNotFoundError, UserEmailAlreadyExistsError, InvalidStatusTransitionError } from '../errors/user.errors'; // User 도메인 에러
import { ConfigService } from '@core/config'; // ConfigService 임포트
import { AssignGroupToUserCommand } from '@shared/commands/iam'; // AssignGroupToUserCommand 임포트 (경로 가정)
import { Logger } from '@nestjs/common'; // Logger 추가
// import { DomainEventPublisher } from '@core/domain'; // 이벤트 발행 (필요시)

@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name); // Logger 인스턴스 생성

constructor(
private readonly userRepository: UserRepository,
private readonly errorLogger: ErrorLoggerService, // 로거 주입
private readonly commandBus: CommandBus, // CommandBus 주입
private readonly configService: ConfigService // ConfigService 주입
) // private readonly eventPublisher: DomainEventPublisher, // 필요시 주입
{}

/**
* 사용자 생성 (Auth 도메인 등 내부 호출용)
*/
async createUser(data: CreateUserDto): Promise<User> {
// 이메일 중복 확인 (Repository 캐시 활용 가능)
const existingUser = await this.userRepository.findByEmail(data.email);
if (existingUser) {
throw new UserEmailAlreadyExistsError({ email: data.email });
}

// User 엔티티 생성
const user = User.create({
email: data.email,
// name: data.name, // 이름 필드는 제거되었으므로 DTO에서도 제거되어야 함
userType: UserType.USER,
status: data.initialStatus ?? UserStatus.PENDING,
});

// 데이터베이스 저장 (Repository에서 캐시 무효화 처리)
try {
const savedUser = await this.userRepository.create(user);

// --- IAM 연동: CommandBus를 통해 그룹 할당 명령 실행 ---
// 상세 구현 가이드는 IAM 도메인의 AssignGroupToUserHandler를 참조하세요.
// TODO: 실제로는 사용자 유형이나 가입 경로에 따라 다른 그룹 결정 로직 필요
const groupToAssign = this.configService.getRequired<string>('USER_DEFAULT_GROUP_NAME');

try {
// AssignGroupToUserCommand 실행 (동기 방식)
await this.commandBus.execute(new AssignGroupToUserCommand(savedUser.id, groupToAssign));
} catch (iamError) {
// IAM 할당 실패 시 처리 로직 (Command Handler에서 발생한 에러)
this.errorLogger.logError(iamError, {
message: `Failed to assign group '${groupToAssign}' via CommandBus during user creation for ${savedUser.id}`,
context: UserService.name,
operation: 'createUser - IAM Integration Command',
userId: savedUser.id,
});
// 오류 전파
throw iamError;
}

// [TODO] 도메인 이벤트 발행 (UserCreatedEvent)

return savedUser;
} catch (error) {
this.errorLogger.logError(error, { context: UserService.name, operation: 'createUser', data });
throw error;
}
}

/**
* 서비스 계정 생성 (관리자용)
*/
async createServiceAccount(data: CreateServiceAccountDto): Promise<User> {
// 서비스 계정용 이메일 자동 생성 (정책 정의 필요)
const email = `sa_${Date.now()}@service.welt`; // 임시 형식

const serviceAccount = User.create({
email: email,
name: data.name,
userType: UserType.SERVICE_ACCOUNT,
status: UserStatus.ACTIVE, // 서비스 계정은 즉시 활성
// profileData 등 필요시 설정
});

// 데이터베이스 저장 (Repository에서 캐시 무효화 처리)
try {
const savedAccount = await this.userRepository.create(serviceAccount);
// [TODO] IAM 연동: 서비스 계정용 역할/그룹 할당 (필요시)
// [TODO] 도메인 이벤트 발행 (ServiceAccountCreatedEvent 또는 UserCreatedEvent)
return savedAccount;
} catch (error) {
this.errorLogger.logError(error, { context: UserService.name, operation: 'createServiceAccount', data });
// TODO: ServiceAccountCreationError 등 구체적인 에러 사용
throw error;
}
}

/**
* ID로 사용자 조회 (권한 검사 포함)
* UserRepository의 캐싱을 활용합니다.
*/
async getUserById(userId: string, requestingUserId: string): Promise<User> {
const user = await this.userRepository.findById(userId); // 캐시 조회 시도
if (!user) {
// --- UserNotFoundError 사용 ---
throw new UserNotFoundError({ userId });
}

// 권한 검사: 본인 또는 관리자(별도 로직 필요)만 조회 가능
const isAdmin = false; // 실제 관리자 여부 확인 로직 필요
if (user.id !== requestingUserId && !isAdmin) {
// --- 공유 PermissionDeniedError 사용 ---
throw new PermissionDeniedError({
reason: "Cannot view other user's data",
requestingUserId,
targetUserId: userId,
});
}

return user;
}

/**
* 사용자 정보 수정 (이름, profileData 등)
*/
async updateUser(userId: string, data: UpdateUserDto, requestingUserId: string): Promise<User> {
const user = await this.userRepository.findById(userId); // 캐시 조회 시도
if (!user) {
// --- UserNotFoundError 사용 ---
throw new UserNotFoundError({ userId });
}

// 권한 검사: 본인 또는 관리자만 수정 가능
const isAdmin = false; // 실제 관리자 여부 확인 로직 필요
if (user.id !== requestingUserId && !isAdmin) {
// --- 공유 PermissionDeniedError 사용 ---
throw new PermissionDeniedError({
reason: "Cannot update other user's data",
requestingUserId,
targetUserId: userId,
});
}

// 수정 가능한 필드만 업데이트 (엔티티 메서드 활용 권장)
let updated = false;
if (data.name && data.name !== user.name) {
user.updateName(data.name);
updated = true;
}
if (data.profileData) {
user.updateProfileData(data.profileData);
updated = true;
}

if (!updated) {
return user; // 변경 사항 없으면 반환
}

// 데이터베이스 저장 (Repository에서 캐시 무효화 처리)
try {
const updatedUser = await this.userRepository.update(user);
// [TODO] 도메인 이벤트 발행 (UserProfileUpdatedEvent 등)
return updatedUser;
} catch (error) {
this.errorLogger.logError(error, { context: UserService.name, operation: 'updateUser', userId, data });
throw error;
}
}

/**
* 사용자 비활성화 요청 (Soft Delete)
*/
async requestDeactivation(userId: string, requestingUserId: string): Promise<User> {
const user = await this.userRepository.findById(userId); // 캐시 조회 시도
if (!user) {
// --- UserNotFoundError 사용 ---
throw new UserNotFoundError({ userId });
}

// 권한 검사: 본인 또는 관리자만 요청 가능
const isAdmin = false; // 실제 관리자 여부 확인 로직 필요
if (user.id !== requestingUserId && !isAdmin) {
// --- 공유 PermissionDeniedError 사용 ---
throw new PermissionDeniedError({
reason: "Cannot deactivate other user's account",
requestingUserId,
targetUserId: userId,
});
}

// 상태 변경 (엔티티 메서드 활용 권장)
const originalStatus = user.status;
try {
user.deactivate(); // 엔티티 내 상태 전이 로직 호출, 실패 시 예외 발생 가정
} catch (error) {
// 엔티티에서 DomainError를 던지도록 구현하는 것이 이상적
// 여기서는 예시로 InvalidStatusTransitionError를 직접 발생
throw new InvalidStatusTransitionError(originalStatus, UserStatus.INACTIVE, { reason: error.message || 'Cannot deactivate user in current state' });
}

// 데이터베이스 저장 (status, deletedAt 업데이트, Repository에서 캐시 무효화 처리)
try {
const deactivatedUser = await this.userRepository.update(user);
// [TODO] 도메인 이벤트 발행 (UserDeactivationRequestedEvent, UserDeactivatedEvent)
// [TODO] GDPR 관련 삭제 스케줄링 연동 (별도 서비스 또는 이벤트 핸들러)
return deactivatedUser;
} catch (error) {
this.errorLogger.logError(error, { context: UserService.name, operation: 'requestDeactivation', userId });
throw error;
}
}

// ... 기타 사용자 관리 관련 메서드 (상태 변경, 테스터 관리 등) ...
}

UserRepository 구현 (Prisma 예시)

UserRepository 인터페이스와 Prisma 및 Redis 캐싱을 사용한 구현 예시입니다. 캐싱은 주로 읽기 작업(findById, findByEmail)의 성능 향상을 위해 적용되며, 쓰기 작업(create, update) 시 관련 캐시를 무효화합니다.

// 파일 경로: libs/feature/user/src/lib/infrastructure/repositories/prisma-user.repository.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@core/database'; // PrismaService 경로 확인 필요
import { RedisCacheService } from '@core/caching'; // RedisCacheService 경로 확인 필요
import { ConfigService } from '@core/config'; // ConfigService 경로 확인 필요
import { Prisma, User as PrismaUser } from '@prisma/client';
import { User } from '../../domain/entities/user.entity';
import { UserRepository } from '../../domain/repositories/user.repository';
import { UserMapper } from '../mappers/user.mapper'; // UserMapper 임포트

@Injectable()
export class PrismaUserRepository implements UserRepository {
private readonly logger = new Logger(PrismaUserRepository.name);
private readonly userCacheTtl: number;

constructor(
private readonly prisma: PrismaService,
private readonly cacheService: RedisCacheService,
private readonly configService: ConfigService,
private readonly userMapper: UserMapper // UserMapper 주입
) {
// 환경 변수 또는 기본값으로 캐시 TTL 설정 (초 단위)
this.userCacheTtl = this.configService.get<number>('CACHE_TTL_USER', 3600); // 기본 1시간
}

/**
* 사용자 ID로 User 엔티티 조회 (캐시 우선)
*/
async findById(id: string): Promise<User | null> {
const cacheKey = `user:id:${id}`;
try {
// 캐시에서는 Prisma 모델 형태로 저장/조회
const cachedPrismaUser = await this.cacheService.get<PrismaUser>(cacheKey);
if (cachedPrismaUser) {
this.logger.debug(`[Cache Hit] User found in cache for ID: ${id}`);
return this.userMapper.toDomain(cachedPrismaUser); // Prisma 모델 -> 도메인 엔티티 변환
}
} catch (error) {
this.logger.warn(`Failed to retrieve user from cache for ID ${id}`, error);
}

this.logger.debug(`[Cache Miss] User not found in cache for ID: ${id}. Fetching from DB.`);
const prismaUser = await this.prisma.user.findUnique({ where: { id } });

if (!prismaUser) {
return null;
}

// DB 조회 결과를 캐시에 저장 (Prisma 모델 형태)
try {
await this.cacheService.set(cacheKey, prismaUser, this.userCacheTtl);
this.logger.debug(`User ${id} saved to cache.`);
} catch (error) {
this.logger.warn(`Failed to save user to cache for ID ${id}`, error);
}

return this.userMapper.toDomain(prismaUser); // Prisma 모델 -> 도메인 엔티티 변환
}

/**
* 이메일로 User 엔티티 조회 (캐시 우선)
*/
async findByEmail(email: string): Promise<User | null> {
const cacheKey = `user:email:${email}`;
try {
// 캐시에서는 Prisma 모델 형태로 저장/조회
const cachedPrismaUser = await this.cacheService.get<PrismaUser>(cacheKey);
if (cachedPrismaUser) {
this.logger.debug(`[Cache Hit] User found in cache for email: ${email}`);
return this.userMapper.toDomain(cachedPrismaUser); // Prisma 모델 -> 도메인 엔티티 변환
}
} catch (error) {
this.logger.warn(`Failed to retrieve user from cache for email ${email}`, error);
}

this.logger.debug(`[Cache Miss] User not found in cache for email: ${email}. Fetching from DB.`);
const prismaUser = await this.prisma.user.findUnique({ where: { email } });

if (!prismaUser) {
return null;
}

// DB 조회 결과를 캐시에 저장 (Prisma 모델 형태, ID 캐시와 별개)
try {
await this.cacheService.set(cacheKey, prismaUser, this.userCacheTtl);
this.logger.debug(`User with email ${email} saved to cache.`);
} catch (error) {
this.logger.warn(`Failed to save user to cache for email ${email}`, error);
}

return this.userMapper.toDomain(prismaUser); // Prisma 모델 -> 도메인 엔티티 변환
}

/**
* User 엔티티 생성 및 관련 캐시 무효화
*/
async create(user: User): Promise<User> {
const prismaData = this.userMapper.toPersistenceCreate(user); // 도메인 엔티티 -> Prisma 생성 데이터 변환
const createdPrismaUser = await this.prisma.user.create({ data: prismaData });

// 생성된 사용자의 캐시 무효화
await this.invalidateUserCache(createdPrismaUser.id, createdPrismaUser.email);

this.logger.log(`User created: ${createdPrismaUser.id}`);
// 생성된 Prisma 모델을 다시 도메인 엔티티로 변환하여 반환
return this.userMapper.toDomain(createdPrismaUser);
}

/**
* User 엔티티 업데이트 및 관련 캐시 무효화
*/
async update(user: User): Promise<User> {
const prismaData = this.userMapper.toPersistenceUpdate(user); // 도메인 엔티티 -> Prisma 업데이트 데이터 변환

const updatedPrismaUser = await this.prisma.user.update({
where: { id: user.id },
data: prismaData, // 매퍼가 생성한 업데이트 데이터 사용
});

// 업데이트된 사용자의 캐시 무효화
await this.invalidateUserCache(updatedPrismaUser.id, updatedPrismaUser.email);

this.logger.log(`User updated: ${updatedPrismaUser.id}`);
// 업데이트된 Prisma 모델을 다시 도메인 엔티티로 변환하여 반환
return this.userMapper.toDomain(updatedPrismaUser);
}

/**
* 사용자 관련 캐시 무효화 (ID 및 Email 기준)
*/
private async invalidateUserCache(userId: string, userEmail: string): Promise<void> {
const idCacheKey = `user:id:${userId}`;
const emailCacheKey = `user:email:${userEmail}`;
try {
await this.cacheService.del(idCacheKey);
await this.cacheService.del(emailCacheKey);
this.logger.debug(`Invalidated cache for user ID: ${userId} and email: ${userEmail}`);
} catch (error) {
this.logger.error(`Failed to invalidate cache for user ${userId}`, error);
// 에러 로깅 후 계속 진행 (캐시 무효화 실패가 주요 로직을 막지 않도록)
}
}

// 필요에 따라 추가 메서드 정의 (예: findAll, delete 등)
}

UserMapper 예시 (가정)

PrismaUserRepositoryUserMapper를 사용하여 도메인 엔티티와 Prisma 모델 간의 변환을 수행합니다. 아래는 UserMapper의 간단한 예시입니다. 실제 프로젝트에서는 @Injectable() 서비스로 구현될 수 있습니다.

// 파일 경로: libs/feature/user/src/lib/infrastructure/mappers/user.mapper.ts (예시)
import { Injectable } from '@nestjs/common';
import { User as PrismaUser, Prisma, UserStatus as PrismaUserStatus, UserType as PrismaUserType } from '@prisma/client';
import { User } from '../../domain/entities/user.entity';
import { Email } from '../../domain/value-objects/email.value-object'; // 경로 가정
import { UserId } from '../../domain/value-objects/user-id.value-object'; // 경로 가정
import { UserName } from '../../domain/value-objects/user-name.value-object'; // 경로 가정
import { UserStatus } from '../../domain/enums/user-status.enum'; // 경로 가정
import { UserType } from '../../domain/enums/user-type.enum'; // 경로 가정

@Injectable() // 필요시 Injectable로 선언
export class UserMapper {
/**
* Prisma User 모델을 도메인 User 엔티티로 변환합니다.
*/
toDomain(prismaUser: PrismaUser): User {
if (!prismaUser) {
// 또는 적절한 예외 처리
throw new Error('Invalid PrismaUser data for mapping to domain entity');
}

const userProps = {
id: new UserId(prismaUser.id),
email: new Email(prismaUser.email),
name: new UserName(prismaUser.name),
status: this.mapPrismaStatusToDomain(prismaUser.status),
userType: this.mapPrismaTypeToDomain(prismaUser.userType),
profileData: prismaUser.profileData ? JSON.parse(JSON.stringify(prismaUser.profileData)) : undefined,
isSuspended: prismaUser.isSuspended ?? undefined,
expiresAt: prismaUser.expiresAt ?? undefined,
createdAt: prismaUser.createdAt,
updatedAt: prismaUser.updatedAt,
deletedAt: prismaUser.deletedAt ?? undefined,
// password, failedLoginAttempts 등 Auth 도메인 관련 필드는 여기서 매핑하지 않음
};

// User 엔티티의 정적 팩토리 메서드 또는 생성자를 사용하여 인스턴스 생성 (구현에 따라 다름)
// 예시: return User.hydrate(userProps);
// 여기서는 간단히 객체 리터럴로 반환 (실제 엔티티 클래스 구현 필요)
return new User(userProps); // User 클래스가 이 속성들을 받는다고 가정
}

/**
* 도메인 User 엔티티를 Prisma User 생성 데이터로 변환합니다.
*/
toPersistenceCreate(user: User): Prisma.UserCreateInput {
return {
id: user.id.getValue(),
email: user.email.getValue(),
name: user.name.getValue(),
status: this.mapDomainStatusToPrisma(user.status),
userType: this.mapDomainTypeToPrisma(user.userType),
profileData: user.profileData ? user.profileData : Prisma.DbNull, // 또는 undefined 처리
isSuspended: user.isSuspended,
expiresAt: user.expiresAt,
// createdAt, updatedAt는 Prisma가 자동 관리
deletedAt: user.deletedAt,
// Auth 관련 필드는 여기서 설정하지 않음
};
}

/**
* 도메인 User 엔티티를 Prisma User 업데이트 데이터로 변환합니다.
* 주의: ID, createdAt 등은 업데이트 대상이 아닙니다.
*/
toPersistenceUpdate(user: User): Omit<Prisma.UserUpdateInput, 'id' | 'createdAt' | 'email'> {
return {
name: user.name.getValue(),
status: this.mapDomainStatusToPrisma(user.status),
userType: this.mapDomainTypeToPrisma(user.userType),
profileData: user.profileData ? user.profileData : Prisma.DbNull,
isSuspended: user.isSuspended,
expiresAt: user.expiresAt,
// updatedAt는 Prisma가 자동 관리
deletedAt: user.deletedAt,
};
}

// --- Enum Mapping Helpers ---
private mapPrismaStatusToDomain(status: PrismaUserStatus): UserStatus {
return UserStatus[status]; // Enum 이름이 같다고 가정
}
private mapDomainStatusToPrisma(status: UserStatus): PrismaUserStatus {
return PrismaUserStatus[status]; // Enum 이름이 같다고 가정
}
private mapPrismaTypeToDomain(type: PrismaUserType): UserType {
return UserType[type]; // Enum 이름이 같다고 가정
}
private mapDomainTypeToPrisma(type: UserType): PrismaUserType {
return PrismaUserType[type]; // Enum 이름이 같다고 가정
}
}

Module 설정

UserDomainModule에서 서비스와 리포지토리를 프로바이더로 등록합니다. PrismaUserRepositoryRedisCacheService, ConfigService, UserMapper 를 사용하므로, 해당 서비스/클래스를 제공하는 모듈(CoreCachingModule, CoreConfigModule 등)을 imports에 추가하고, UserMapperproviders에 등록해야 합니다.

// 파일 경로: libs/feature/user/src/lib/user.module.ts
import { Module } from '@nestjs/common';
import { CoreDatabaseModule } from '@core/database'; // PrismaService 제공
import { CoreCachingModule } from '@core/caching'; // RedisCacheService 제공 (가정)
import { CoreConfigModule } from '@core/config'; // ConfigService 제공 (가정)
import { UserService } from './domain/services/user.service';
import { UserRepository } from './domain/repositories/user.repository';
import { PrismaUserRepository } from './infrastructure/repositories/prisma-user.repository';
import { UserProfileService } from './domain/services/user-profile.service'; // 프로필 서비스 추가
import { UserProfileRepository } from './domain/repositories/user-profile.repository'; // 프로필 레포 인터페이스 추가
import { PrismaUserProfileRepository } from './infrastructure/repositories/prisma-user-profile.repository'; // 프로필 레포 구현체 추가
import { UserMapper } from './infrastructure/mappers/user.mapper'; // UserMapper 추가

@Module({
imports: [
CoreDatabaseModule.forRoot(), // PrismaService 주입
CoreCachingModule, // RedisCacheService 주입
CoreConfigModule, // ConfigService 주입
// 다른 필요한 모듈 import (IAM, EventBus 등)
],
providers: [
UserService,
UserProfileService, // 프로필 서비스 등록
UserMapper, // UserMapper 등록
{
provide: UserRepository,
useClass: PrismaUserRepository,
// PrismaUserRepository 생성자에 필요한 의존성(PrismaService, RedisCacheService, ConfigService, UserMapper) 주입은 NestJS가 자동으로 처리
},
{
provide: UserProfileRepository, // 프로필 레포 등록
useClass: PrismaUserProfileRepository, // 프로필 레포 구현체 등록
},
// 필요한 다른 프로바이더 (이벤트 핸들러 등)
],
exports: [
UserService, // Application Layer에서 사용할 수 있도록 export
UserProfileService, // 프로필 서비스 export
],
})
export class UserDomainModule {}

데이터베이스 스키마

사용자 계정 관리에 사용되는 주요 Prisma 스키마는 docs/domains/common/core-domains/user/domain-model.md 문서의 8절에 정의된 User 모델입니다. 캐싱 전략은 스키마 자체에는 영향을 주지 않습니다.

// docs/domains/common/core-domains/user/domain-model.md 참조

캐싱 전략

  • 주요 목표: 반복적인 사용자 데이터 조회 성능 향상 및 데이터베이스 부하 감소.
  • 레벨: 주로 리포지토리 레벨(Repository Level) 캐싱을 사용합니다.
    • PrismaUserRepository에서 RedisCacheService를 사용하여 조회된 User 모델(Prisma 모델)을 캐싱합니다.
    • ID(findById) 및 이메일(findByEmail) 기반 조회가 주요 캐싱 대상입니다.
  • 캐시 저장소: Redis를 사용합니다. (RedisCacheService 활용)
  • 캐시 키: 예측 가능하고 충돌하지 않도록 접두사(prefix)와 식별자(ID 또는 email)를 조합하여 생성합니다. (예: user:id:<uuid>, user:email:<user@example.com>)
  • 캐시 TTL (Time-To-Live): ConfigService를 통해 환경별로 설정 가능하며, 기본값(예: 1시간)을 가집니다. 데이터 변경 빈도와 허용 가능한 Stale 데이터 수준을 고려하여 조정합니다.
  • 캐시 무효화 (Invalidation): 사용자 데이터가 변경될 때(생성 create, 수정 update, 비활성화 requestDeactivation 등) 관련 캐시(ID 및 이메일 기준)를 명시적으로 삭제(del)하여 데이터 정합성을 유지합니다. Write-through 대신 Invalidation 전략을 사용합니다.
  • 서비스 레벨 캐싱: 현재는 리포지토리 레벨 캐싱에 중점을 둡니다. 만약 DTO 변환 로직이 복잡하거나, 동일한 DTO 조회 요청이 매우 빈번하다면 추후 UserService 레벨에서 DTO 자체를 캐싱하는 것을 고려할 수 있습니다. (예: @nestjs/cache-manager 사용)

보안 고려사항

  • 인가(Authorization): API 엔드포인트 및 서비스 메서드 레벨에서 역할 기반 접근 제어(RBAC)를 적용해야 합니다. @UseGuards@Roles 데코레이터를 사용하고, 서비스 로직 내에서 요청자가 대상 리소스에 접근할 권한(본인 또는 관리자)이 있는지 확인합니다. IAM 도메인과의 연동이 필요합니다.
  • 입력 유효성 검사: Controller DTO 레벨에서 class-validator 등을 사용하여 입력값의 형식, 길이, 필수 여부 등을 철저히 검증합니다.
  • 데이터 노출: API 응답 시 민감 정보(예: 비밀번호 해시 - User 도메인에서는 관리하지 않음)는 제외해야 합니다. DTO를 사용하여 응답 데이터를 명시적으로 매핑하는 것이 좋습니다. 캐싱된 데이터에도 민감 정보가 포함되지 않도록 주의해야 합니다.
  • Soft Delete: 사용자 삭제 요청 시 실제 데이터를 즉시 삭제하지 않고 deletedAt 플래그를 사용하여 비활성화 처리합니다. GDPR 등 규정에 따른 실제 삭제/익명화는 별도 스케줄링된 프로세스를 통해 안전하게 처리해야 합니다. 캐시에서도 deletedAt 상태를 반영하거나 무효화해야 합니다.

오류 처리

  • UserService에서는 비즈니스 규칙 위반(예: 이메일 중복, 잘못된 상태 전이), 리소스 없음, 권한 부족 등의 상황에 맞는 **애플리케이션의 커스텀 도메인 에러 (DomainError 또는 그 하위 클래스)**를 발생시킵니다. (예: UserNotFoundError, UserEmailAlreadyExistsError, NotFoundError, ForbiddenError, PermissionDeniedError 등)
  • Controller에서는 별도의 처리 없이 서비스에서 발생한 예외가 NestJS의 전역 예외 필터 (GlobalExceptionFilter)에 의해 적절한 HTTP 상태 코드와 오류 메시지로 변환되어 응답됩니다.
  • 캐시 작업 중 발생하는 오류(예: Redis 연결 실패)는 로깅하되, 주요 기능 흐름을 방해하지 않도록 처리합니다. (예: 캐시 실패 시 DB 직접 조회)
  • 구체적인 오류 코드는 User API 엔드포인트 섹션을 참조하여 일관성 있게 관리합니다.

관련 문서

TBD

변경 이력

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