서비스 이용 기간 중단/재개 프로세스 구현 가이드
개요
이 문서는 사용자가 자신의 서비스 이용 기간을 일시 중단(suspend)하고 다시 재개(resume)하는 프로세스의 전체 흐름과 각 단계별 구현 방법에 대한 기술 가이드라인을 제공합니다. 이 기능은 사용자가 일정 기간 서비스를 사용하지 않을 때 이용 기간 소진을 방지하기 위해 제공됩니다. 중단/재개 요청 처리 시 관련 이력을 상세히 기록하고, 상태 변경에 따라 접근 제어 정책이 업데이트되어야 합니다.
프로세스 흐름
서비스 중단 프로세스 (예: 7일 중단 요청 시)
서비스 재개 프로세스 (수동 또는 자동)
구현 가이드
1. 서비스 중단 API 구현
사용자가 명시적으로 서비스 이용 기간 중단을 요청하는 API입니다. 기간을 지정할 수도 있고, 무기한 중단 후 수동 재개하는 방식도 가능합니다 (여기서는 기간 지정 예시).
- API Endpoint:
POST /users/{userId}/service-period/suspend - 주요 로직:
UserService.suspendServicePeriod호출- 현재 사용자 상태 및 중단 상태 확인
UserSuspensionHistory레코드 생성User엔티티의isSuspended상태 업데이트- 자동 재개 스케줄링 (기간 지정 시)
ServicePeriodSuspended이벤트 발행
- 권한: 본인 또는 관리자 (Guard에서 확인)
- 상세 구현: 아래 DTO, Controller, Service 참조
2. 서비스 재개 API 구현 (수동)
사용자가 중단된 서비스를 수동으로 재개하는 API입니다.
- API Endpoint:
POST /users/{userId}/service-period/resume - 주요 로직:
UserService.resumeServicePeriod호출- 현재 사용자 상태 및 중단 상태 확인
- 마지막
UserSuspensionHistory레코드 업데이트 (resumedAt,durationSeconds계산) User엔티티의isSuspended상태 업데이트 및expiresAt재계산/업데이트- 예약된 자동 재개 작업 취소
ServicePeriodResumed이벤트 발행
- 권한: 본인 또는 관리자 (Guard에서 확인)
- 상세 구현: 아래 DTO, Controller, Service 참조
3. 서비스 자동 재개 구현 (백그라운드)
스케줄러가 주기적으로 실행되어, 예약된 자동 재개 시간이 도래한 사용자를 찾아 재개 처리를 수행합니다.
- 트리거: 스케줄러 서비스 (예:
@nestjs/schedule사용) - 주요 로직:
- 현재 시간 기준으로
isSuspended=true이고scheduledResumeAt <= now()인 사용자를 조회합니다. (주의:scheduledResumeAt필드는User모델에 추가 필요) - 조회된 각 사용자에 대해
UserService.resumeServicePeriod를 내부적으로 호출합니다. (이때reason은 '자동 재개' 등으로 설정)
- 현재 시간 기준으로
- 구현: 별도의 스케줄러 모듈 또는 User 모듈 내 스케줄링 로직 구현
DTO 정의
// 파일 경로: libs/shared/contracts-auth/dto/src/lib/user/suspend-resume.dto.ts
import { IsInt, IsOptional, IsPositive, IsString, Max, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class SuspendServicePeriodDto {
@ApiPropertyOptional({ description: '중단 기간(일). 지정하지 않으면 수동 재개 필요.', example: 7 })
@IsOptional()
@IsInt()
@IsPositive()
@Max(90) // 예: 최대 중단 가능 일수 제한
durationDays?: number;
@ApiPropertyOptional({ description: '중단 사유', example: '개인 사정' })
@IsOptional()
@IsString()
reason?: string;
}
export class ResumeServicePeriodDto {
@ApiPropertyOptional({ description: '재개 사유', example: '서비스 다시 이용 시작' })
@IsOptional()
@IsString()
reason?: string;
}
export class ServicePeriodStatusDto {
@ApiProperty({ description: '사용자 ID', example: '...' })
userId: string;
@ApiProperty({ description: '현재 중단 상태 여부', example: true })
isSuspended: boolean;
@ApiPropertyOptional({ description: '마지막 중단 시작 시각 (ISO)', example: '...' })
suspendedAt?: string;
@ApiPropertyOptional({ description: '마지막 재개 시각 (ISO)', example: '...' })
resumedAt?: string;
@ApiPropertyOptional({ description: '예상 서비스 만료일 (ISO)', example: '...' })
expiresAt?: string;
@ApiPropertyOptional({ description: '남은 서비스 이용 일수', example: 30 })
remainingServiceDays?: number;
}
Controller 구현
// 파일 경로: apps/dta-wide-api/src/app/user/controllers/user-service-period.controller.ts
import { Controller, Post, Body, Param, UseGuards, Inject, Req, Get } from '@nestjs/common';
import { JwtAuthGuard } from '@core/auth/guards'; // 또는 필요한 다른 Guard
import { UserPermissionGuard } from '@core/user/guards'; // 본인 또는 관리자 확인 Guard
import { UserService } from '@feature/user';
import { SuspendServicePeriodDto, ResumeServicePeriodDto, ServicePeriodStatusDto } from '@shared-contracts/auth/dto/user';
import { ApiResponse } from '@shared/api-types';
import { AuthenticatedRequest } from '@core/auth/interfaces';
import { UserPermissions } from '@core/user/decorators';
@Controller('users/:userId/service-period')
@UseGuards(JwtAuthGuard, UserPermissionGuard) // 본인 또는 관리자만 접근 가능하도록 설정
export class UserServicePeriodController {
constructor(
@Inject(UserService) private readonly userService: UserService
) {}
@Post('suspend')
@UserPermissions('manage_self_service_period') // 예시 권한
async suspendServicePeriod(
@Param('userId') userId: string,
@Body() dto: SuspendServicePeriodDto,
@Req() request: AuthenticatedRequest // 요청자 정보 필요시
): Promise<ApiResponse<ServicePeriodStatusDto>> {
// 요청자가 관리자인지, 본인인지 확인하는 로직은 Guard에서 처리
await this.userService.suspendServicePeriod(userId, dto.durationDays, dto.reason);
// 성공 시 현재 상태 반환 (선택적)
const status = await this.userService.getCurrentServicePeriodStatus(userId);
return { data: this.mapToStatusDto(status) };
}
@Post('resume')
@UserPermissions('manage_self_service_period') // 예시 권한
async resumeServicePeriod(
@Param('userId') userId: string,
@Body() dto: ResumeServicePeriodDto,
@Req() request: AuthenticatedRequest
): Promise<ApiResponse<ServicePeriodStatusDto>> {
await this.userService.resumeServicePeriod(userId, dto.reason);
const status = await this.userService.getCurrentServicePeriodStatus(userId);
return { data: this.mapToStatusDto(status) };
}
@Get('status')
@UserPermissions('view_self_service_period') // 예시 권한
async getServicePeriodStatus(
@Param('userId') userId: string,
): Promise<ApiResponse<ServicePeriodStatusDto>> {
const status = await this.userService.getCurrentServicePeriodStatus(userId);
return { data: this.mapToStatusDto(status) };
}
// Helper to map internal status to DTO
private mapToStatusDto(status: any): ServicePeriodStatusDto {
return {
userId: status.userId,
isSuspended: status.isSuspended,
suspendedAt: status.suspendedAt?.toISOString(),
resumedAt: status.resumedAt?.toISOString(),
expiresAt: status.expiresAt?.toISOString(),
remainingServiceDays: status.remainingServiceDays,
};
}
}
Service 로직 (UserService 내)
suspendServicePeriod(userId, durationDays?, reason?):userId로 사용자 조회 (UserNotFoundError처리).- 사용자가 이미
isSuspended상태이면InvalidStatusTransitionError발생. UserSuspensionHistory새 레코드 생성 (suspendedAt = now()).User엔티티 업데이트 (isSuspended = true).durationDays가 주어졌다면,scheduledResumeAt = now() + durationDays계산하여 스케줄러에 자동 재개 작업 등록.ServicePeriodSuspended이벤트 발행.
resumeServicePeriod(userId, reason?):userId로 사용자 조회 (UserNotFoundError처리).- 사용자가
isSuspended상태가 아니면InvalidStatusTransitionError발생. - 가장 최근의
UserSuspensionHistory레코드 조회 (아직resumedAt이 없는 것). - 해당 이력 레코드 업데이트 (
resumedAt = now(),durationSeconds계산하여 저장). - 만료일 재계산: 사용자의 모든
UserSuspensionHistory를 조회하여 총 중단 기간(totalSuspensionDuration) 계산. 기존expiresAt(중단 시작 전 계산된 값 또는 Plan 기준 최초 만료일) +totalSuspensionDuration= 새expiresAt계산. User엔티티 업데이트 (isSuspended = false,expiresAt = newExpiresAt).- 예약된 자동 재개 작업이 있었다면 스케줄러에서 해당 작업 취소 요청.
ServicePeriodResumed이벤트 발행.
calculateAndSetExpiryDate(userId): (내부 또는 외부 호출) 사용자의Plan정보와 전체UserSuspensionHistory를 바탕으로expiresAt을 계산하고User엔티티에 저장.getRemainingServiceDays(userId): 현재 시간과 계산된expiresAt을 비교하여 남은 일수 반환.getCurrentServicePeriodStatus(userId): 사용자의 현재isSuspended,expiresAt및 관련 정보 조회하여 반환.
이벤트 처리
IAM Service:ServicePeriodSuspended이벤트 수신 시: 해당 사용자에 대한 접근 제한 정책 활성화 (필요시 사용자 Role/Permission 임시 변경 또는 별도 상태 플래그 확인).ServicePeriodResumed이벤트 수신 시: 해당 사용자에 대한 접근 제한 정책 비활성화.
Audit Service(Optional):ServicePeriodSuspended,ServicePeriodResumed이벤트 수신 시: 사용자 활동 감사 로그 기록.
보안 고려사항
- API 요청 시 적절한 인증 및 인가(본인 또는 관리자 확인)가 필수입니다 (
JwtAuthGuard,UserPermissionGuard등 사용). - 중단/재개 요청에 대한 Rate Limiting을 적용하여 서비스 남용을 방지할 수 있습니다.
- 중단 이력(
UserSuspensionHistory) 데이터는 개인정보로 간주될 수 있으므로 접근 제어를 적용해야 합니다.
테스트 전략
UserService의 각 메서드(suspend, resume, 계산 로직)에 대한 단위 테스트 작성 (Repository Mocking).- 중단/재개 API 엔드포인트에 대한 통합 테스트 작성.
- 전체 중단 -> 자동 재개, 중단 -> 수동 재개 흐름에 대한 E2E 테스트 작성.
- 중복 중단 요청, 중단되지 않은 상태에서 재개 요청 등 예외 케이스 테스트.
- 만료일 계산 로직의 정확성 검증 테스트 (다양한 중단 시나리오 포함).
- 스케줄러 자동 재개 로직 테스트.
성능 고려사항
UserSuspensionHistory테이블에userId및suspendedAt컬럼에 대한 인덱스를 생성하여 이력 조회 성능을 최적화합니다.- 자동 재개를 위한 사용자 조회 쿼리 성능을 고려합니다 (
isSuspended,scheduledResumeAt인덱스). - 만료일 및 남은 기간 계산은 비용이 클 수 있으므로, 필요시 캐싱 또는 비동기 업데이트 전략을 고려할 수 있습니다. (예: 재개 시점에만 정확히 계산하고 저장)
오류 코드 관리
서비스 중단/재개 프로세스에서 발생할 수 있는 주요 오류는 User 도메인의 오류 관리 규칙을 따릅니다. 관련 오류 코드는 다음과 같습니다. (User 도메인 오류 관리 가이드 참조)
| HTTP 상태 코드 | 오류 코드 (User 범위: 4000번대) | 메시지 (Error Class) | 설명 |
|---|---|---|---|
| 404 | 4003 | USER_NOT_FOUND (UserNotFoundError) | 요청한 사용자를 찾을 수 없습니다. |
| 409 | 4011 | INVALID_STATUS_TRANSITION (InvalidStatusTransitionError) | 이미 중단된 상태에서 중단을 요청하거나, 중단되지 않은 상태에서 재개를 요청하는 등 유효하지 않은 상태 변경 시도 |
| 403 | (Auth 오류 코드) | PERMISSION_DENIED (PermissionDeniedError) | 요청자가 해당 작업을 수행할 권한이 없습니다 (본인 또는 관리자 아님). |
| 400 | 4001 | INVALID_INPUT_DATA (InvalidUserInputError) | 요청 DTO의 유효성 검사 실패 (예: durationDays 형식 오류) |
| 500 | 4099 | UNKNOWN_USER_ERROR (UnknownUserError) | 중단/재개 처리 중 예기치 않은 내부 오류 발생 |
| 500 | (Scheduler 오류 코드) | AUTO_RESUME_JOB_FAILED | 자동 재개 스케줄링 또는 실행 실패 (필요시 정의) |
참고: 스케줄러 관련 오류 등 다른 도메인과 연관된 오류는 해당 도메인의 오류 코드를 따를 수 있습니다.