본문으로 건너뛰기

서비스 이용 기간 중단/재개 프로세스 구현 가이드

개요

이 문서는 사용자가 자신의 서비스 이용 기간을 일시 중단(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?):
    1. userId로 사용자 조회 (UserNotFoundError 처리).
    2. 사용자가 이미 isSuspended 상태이면 InvalidStatusTransitionError 발생.
    3. UserSuspensionHistory 새 레코드 생성 (suspendedAt = now()).
    4. User 엔티티 업데이트 (isSuspended = true).
    5. durationDays가 주어졌다면, scheduledResumeAt = now() + durationDays 계산하여 스케줄러에 자동 재개 작업 등록.
    6. ServicePeriodSuspended 이벤트 발행.
  • resumeServicePeriod(userId, reason?):
    1. userId로 사용자 조회 (UserNotFoundError 처리).
    2. 사용자가 isSuspended 상태가 아니면 InvalidStatusTransitionError 발생.
    3. 가장 최근의 UserSuspensionHistory 레코드 조회 (아직 resumedAt이 없는 것).
    4. 해당 이력 레코드 업데이트 (resumedAt = now(), durationSeconds 계산하여 저장).
    5. 만료일 재계산: 사용자의 모든 UserSuspensionHistory를 조회하여 총 중단 기간(totalSuspensionDuration) 계산. 기존 expiresAt (중단 시작 전 계산된 값 또는 Plan 기준 최초 만료일) + totalSuspensionDuration = 새 expiresAt 계산.
    6. User 엔티티 업데이트 (isSuspended = false, expiresAt = newExpiresAt).
    7. 예약된 자동 재개 작업이 있었다면 스케줄러에서 해당 작업 취소 요청.
    8. 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 테이블에 userIdsuspendedAt 컬럼에 대한 인덱스를 생성하여 이력 조회 성능을 최적화합니다.
  • 자동 재개를 위한 사용자 조회 쿼리 성능을 고려합니다 (isSuspended, scheduledResumeAt 인덱스).
  • 만료일 및 남은 기간 계산은 비용이 클 수 있으므로, 필요시 캐싱 또는 비동기 업데이트 전략을 고려할 수 있습니다. (예: 재개 시점에만 정확히 계산하고 저장)

오류 코드 관리

서비스 중단/재개 프로세스에서 발생할 수 있는 주요 오류는 User 도메인의 오류 관리 규칙을 따릅니다. 관련 오류 코드는 다음과 같습니다. (User 도메인 오류 관리 가이드 참조)

HTTP 상태 코드오류 코드 (User 범위: 4000번대)메시지 (Error Class)설명
4044003USER_NOT_FOUND (UserNotFoundError)요청한 사용자를 찾을 수 없습니다.
4094011INVALID_STATUS_TRANSITION (InvalidStatusTransitionError)이미 중단된 상태에서 중단을 요청하거나, 중단되지 않은 상태에서 재개를 요청하는 등 유효하지 않은 상태 변경 시도
403(Auth 오류 코드)PERMISSION_DENIED (PermissionDeniedError)요청자가 해당 작업을 수행할 권한이 없습니다 (본인 또는 관리자 아님).
4004001INVALID_INPUT_DATA (InvalidUserInputError)요청 DTO의 유효성 검사 실패 (예: durationDays 형식 오류)
5004099UNKNOWN_USER_ERROR (UnknownUserError)중단/재개 처리 중 예기치 않은 내부 오류 발생
500(Scheduler 오류 코드)AUTO_RESUME_JOB_FAILED자동 재개 스케줄링 또는 실행 실패 (필요시 정의)

참고: 스케줄러 관련 오류 등 다른 도메인과 연관된 오류는 해당 도메인의 오류 코드를 따를 수 있습니다.