NestJS에서 월 마감 잠금 패턴 구현하기
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;
};
기존 레코드가 있으면 상태만 변경한다. REOPENED → CLOSED 재마감도 같은 로직으로 처리된다.
재개방 처리
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 ?? '',
);
}
}
requireManager는 MANAGER 역할이 아니면 예외를 던지는 유틸이다. 마감/재개방은 관리자 전용 작업이므로 컨트롤러 진입 시점에서 역할을 검사한다.
상태 전이 요약
레코드 없음 (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 설정 없이 비즈니스 로직을 먼저 검증하는 데 이 패턴이 유용했다.