← 포스트 목록으로

NestJS에서 월 마감 잠금 패턴 구현하기

📖 3분 소요
NestJSTypeScriptBackendArchitecture

NestJS에서 월 마감 잠금 패턴 구현하기

배경

인건비 안분 관리 시스템을 설계하다 보면 월말 마감이라는 개념이 등장한다. 마감 이후에는 해당 월의 시간 기록을 수정할 수 없어야 하고, 수정이 필요한 경우 재개방 → 수정 → 재마감 절차를 거쳐야 한다.

이런 요구사항을 어떻게 코드로 구현했는지 정리한다.


상태 정의

MonthClosure 엔티티에 마감 상태를 세 가지로 정의했다.

export type MonthStatus = 'OPEN' | 'CLOSED' | 'REOPENED';

export interface MonthClosure {
  id: string;
  targetMonth: string;    // '2026-02-01' 형태로 정규화
  status: MonthStatus;
  closedBy: string | null;
  closedAt: string | null;
  reopenedBy: string | null;
  reopenedAt: string | null;
  reason: string | null;
}

OPEN은 별도 레코드를 생성하지 않는다. 마감 레코드가 없으면 OPEN 상태로 간주한다.


마감 잠금 검사

시간 기록 생성·수정 API 진입 시 반드시 이 검사를 거친다.

private assertMonthIsEditable = (workDate: string): void => {
  const targetMonth = monthStart(workDate);
  const closure = this.monthClosures.find((c) => c.targetMonth === targetMonth);

  if (closure?.status === 'CLOSED') {
    throw new ConflictException(`Month ${targetMonth} is already CLOSED`);
  }
};

REOPENED 상태는 수정을 허용한다. CLOSED일 때만 차단한다.

monthStart는 날짜 문자열(2026-02-15)을 해당 월의 첫째 날(2026-02-01)로 정규화하는 유틸이다. 같은 월의 다른 날짜를 입력해도 동일한 마감 레코드를 참조하도록 만든다.


마감 처리

public closeMonth = (targetMonth: string, closedBy: string): MonthClosure => {
  const normalizedTargetMonth = monthStart(targetMonth);
  const existedClosure = this.monthClosures.find(
    (c) => c.targetMonth === normalizedTargetMonth,
  );

  if (existedClosure) {
    existedClosure.status = 'CLOSED';
    existedClosure.closedBy = closedBy;
    existedClosure.closedAt = new Date().toISOString();
    return existedClosure;
  }

  const newClosure: MonthClosure = {
    id: randomUUID(),
    targetMonth: normalizedTargetMonth,
    status: 'CLOSED',
    closedBy,
    closedAt: new Date().toISOString(),
    reopenedBy: null,
    reopenedAt: null,
    reason: null,
  };

  this.monthClosures.push(newClosure);
  return newClosure;
};

기존 레코드가 있으면 상태만 변경한다. REOPENEDCLOSED 재마감도 같은 로직으로 처리된다.


재개방 처리

public reopenMonth = (
  targetMonth: string,
  reopenedBy: string,
  reason: string,
): MonthClosure => {
  const normalizedTargetMonth = monthStart(targetMonth);

  if (!reason.trim()) {
    throw new BadRequestException('reason is required');
  }

  const existedClosure = this.monthClosures.find(
    (c) => c.targetMonth === normalizedTargetMonth,
  );

  if (existedClosure) {
    existedClosure.status = 'REOPENED';
    existedClosure.reopenedBy = reopenedBy;
    existedClosure.reopenedAt = new Date().toISOString();
    existedClosure.reason = reason;
    return existedClosure;
  }

  // 마감 없이 재개방 요청 시에도 레코드 생성
  const newClosure: MonthClosure = {
    id: randomUUID(),
    targetMonth: normalizedTargetMonth,
    status: 'REOPENED',
    closedBy: null,
    closedAt: null,
    reopenedBy,
    reopenedAt: new Date().toISOString(),
    reason,
  };

  this.monthClosures.push(newClosure);
  return newClosure;
};

재개방 시 사유(reason)를 필수로 요구한다. 사유 없이 재개방하면 나중에 감사 로그를 봐도 왜 열었는지 알 수 없다.


컨트롤러

@Controller()
export class ClosuresController {
  constructor(private readonly dataStoreService: DataStoreService) {}

  @Post('closures/:targetMonth/close')
  public closeMonth(
    @Req() request: AuthedRequest,
    @Param('targetMonth') targetMonth: string,
  ) {
    const manager = requireManager(request);
    return this.dataStoreService.closeMonth(targetMonth, manager.id);
  }

  @Post('closures/:targetMonth/reopen')
  public reopenMonth(
    @Req() request: AuthedRequest,
    @Param('targetMonth') targetMonth: string,
    @Body() payload: ReopenMonthRequest,
  ) {
    const manager = requireManager(request);
    return this.dataStoreService.reopenMonth(
      targetMonth,
      manager.id,
      payload.reason ?? '',
    );
  }
}

requireManagerMANAGER 역할이 아니면 예외를 던지는 유틸이다. 마감/재개방은 관리자 전용 작업이므로 컨트롤러 진입 시점에서 역할을 검사한다.


상태 전이 요약

레코드 없음 (OPEN)
    ↓ closeMonth
  CLOSED
    ↓ reopenMonth (reason 필수)
  REOPENED
    ↓ closeMonth
  CLOSED (새 리포트 버전 생성)

마감 → 재개방 → 재마감이 반복될 수 있다. 각 전이마다 closedAt, reopenedAt, reason 필드에 이력이 남는다.


리포트 버전 관리

재마감할 때마다 새 버전의 리포트를 생성한다. 같은 월에 여러 버전이 존재할 수 있어서, 기존 최대 버전 + 1로 증가시킨다.

public generateReport = (targetMonth: string, requestedBy: string): ReportExport => {
  const normalizedTargetMonth = monthStart(targetMonth);

  const currentVersions = this.reportExports
    .filter((r) => r.targetMonth === normalizedTargetMonth)
    .map((r) => r.reportVersion);

  const reportVersion = (Math.max(0, ...currentVersions) || 0) + 1;

  const newReport: ReportExport = {
    id: randomUUID(),
    targetMonth: normalizedTargetMonth,
    reportVersion,
    status: 'COMPLETED',
    storagePath: `/reports/${normalizedTargetMonth}/v${reportVersion}.pdf`,
    requestedBy,
    generatedAt: new Date().toISOString(),
    errorMessage: null,
  };

  this.reportExports.push(newReport);
  return newReport;
};

Math.max(0, ...currentVersions)는 배열이 비어 있을 때 Math.max(0)0을 반환하도록 처리한 것이다. 스프레드 없이 Math.max()를 호출하면 -Infinity가 반환된다.


정리

  • 마감 상태는 별도 엔티티(MonthClosure)로 관리한다
  • 마감 레코드가 없으면 OPEN으로 간주해 레코드 수를 줄인다
  • 시간 기록 쓰기 API 진입 시 assertMonthIsEditable을 호출해 잠금을 강제한다
  • 재개방은 reason 필수로 이력을 남긴다
  • 리포트는 버전 번호로 이력을 관리한다

인메모리 스토어로 구현했지만 PostgreSQL로 전환할 때도 서비스 메서드의 시그니처는 그대로 유지된다. MVP 단계에서 DB 설정 없이 비즈니스 로직을 먼저 검증하는 데 이 패턴이 유용했다.