Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,19 @@ public List<ManagerWorkspaceManagerListResponse> getManagerWorkspaceManagerListW
.fetch();
}

@Override
public List<Workspace> findAllByNextMonthShiftGenDay(int day) {
QWorkspace qWorkspace = QWorkspace.workspace;

return queryFactory
.selectFrom(qWorkspace)
.where(
qWorkspace.nextMonthShiftGenDay.eq(day),
qWorkspace.status.eq(WorkspaceStatus.ACTIVATED)
)
.fetch();
}

@Override
public boolean existsByIdAndManagerUser(Long workspaceId, ManagerUser managerUser) {
QWorkspace qWorkspace = QWorkspace.workspace;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package com.dreamteam.alter.adapter.outbound.workspace.persistence;

import java.util.List;

import org.springframework.stereotype.Repository;

import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift;
import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftRepository;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
Expand All @@ -16,6 +20,11 @@ public WorkspaceShift save(WorkspaceShift shift) {
return workspaceShiftJpaRepository.save(shift);
}

@Override
public List<WorkspaceShift> saveAll(List<WorkspaceShift> shifts) {
return workspaceShiftJpaRepository.saveAll(shifts);
}

@Override
public void delete(WorkspaceShift shift) {
workspaceShiftJpaRepository.delete(shift);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@

import org.springframework.stereotype.Repository;

import com.dreamteam.alter.domain.workspace.entity.QWorkspace;
import com.dreamteam.alter.domain.workspace.entity.QWorkspaceWorker;
import com.dreamteam.alter.domain.workspace.entity.QWorkspaceWorkerSchedule;
import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker;
import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorkerSchedule;
import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerScheduleQueryRepository;
import com.dreamteam.alter.domain.workspace.type.WorkspaceWorkerScheduleStatus;
import com.dreamteam.alter.domain.workspace.type.WorkspaceWorkerStatus;
import com.querydsl.jpa.impl.JPAQueryFactory;

import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -65,4 +67,22 @@ public List<WorkspaceWorkerSchedule> getByWorkspaceWorker(WorkspaceWorker worksp
)
.fetch();
}

@Override
public List<WorkspaceWorkerSchedule> findAllActivatedWithWorkspaceWorkerByWorkspaceIds(List<Long> workspaceIds) {
QWorkspaceWorkerSchedule qWorkspaceWorkerSchedule = QWorkspaceWorkerSchedule.workspaceWorkerSchedule;
QWorkspaceWorker qWorkspaceWorker = QWorkspaceWorker.workspaceWorker;
QWorkspace qWorkspace = QWorkspace.workspace;

return queryFactory
.selectFrom(qWorkspaceWorkerSchedule)
.join(qWorkspaceWorkerSchedule.workspaceWorker, qWorkspaceWorker).fetchJoin()
.join(qWorkspaceWorker.workspace, qWorkspace).fetchJoin()
.where(
qWorkspace.id.in(workspaceIds),
qWorkspaceWorkerSchedule.status.eq(WorkspaceWorkerScheduleStatus.ACTIVATED),
qWorkspaceWorker.status.eq(WorkspaceWorkerStatus.ACTIVATED)
)
.fetch();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package com.dreamteam.alter.application.workspace.usecase;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.dreamteam.alter.domain.workspace.entity.Workspace;
import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift;
import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker;
import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorkerSchedule;
import com.dreamteam.alter.domain.workspace.port.inbound.GenerateNextMonthWorkspaceShiftUseCase;
import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository;
import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftQueryRepository;
import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftRepository;
import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerScheduleQueryRepository;
import com.dreamteam.alter.domain.workspace.type.WorkspaceShiftStatus;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service("generateNextMonthWorkspaceShift")
@RequiredArgsConstructor
@Transactional
public class GenerateNextMonthWorkspaceShift implements GenerateNextMonthWorkspaceShiftUseCase {

private static final String DEFAULT_POSITION = "고정 근무";

private final WorkspaceQueryRepository workspaceQueryRepository;
private final WorkspaceWorkerScheduleQueryRepository workspaceWorkerScheduleQueryRepository;
private final WorkspaceShiftRepository workspaceShiftRepository;
private final WorkspaceShiftQueryRepository workspaceShiftQueryRepository;

@Override
public void execute() {
// 서비스 타임존 기준으로 오늘 날짜의 day-of-month를 구한다
LocalDate now = LocalDate.now();
int todayDayOfMonth = now.getDayOfMonth();

// 오늘이 고정 근무 생성일로 설정된 워크스페이스만 조회한다
List<Workspace> targetWorkspaces = workspaceQueryRepository.findAllByNextMonthShiftGenDay(todayDayOfMonth);

if (targetWorkspaces.isEmpty()) {
log.info("[고정 근무 생성] 오늘({})에 해당하는 대상 워크스페이스가 없습니다.", now);
return;
}

List<Long> workspaceIds = targetWorkspaces.stream()
.map(Workspace::getId)
.toList();

log.info("[고정 근무 생성] 대상 워크스페이스 {}개 조회 완료.", workspaceIds.size());

// 대상 워크스페이스들의 활성화된 고정 스케줄을 한 번에 조회 후 워크스페이스별로 그룹화한다
List<WorkspaceWorkerSchedule> allSchedules = workspaceWorkerScheduleQueryRepository
.findAllActivatedWithWorkspaceWorkerByWorkspaceIds(workspaceIds);

Map<Long, List<WorkspaceWorkerSchedule>> schedulesByWorkspaceId = allSchedules.stream()
.collect(Collectors.groupingBy(
schedule -> schedule.getWorkspaceWorker().getWorkspace().getId()
));

YearMonth targetMonth = YearMonth.from(now).plusMonths(1);
int totalCreated = 0;
int totalSkipped = 0;
int failedWorkspaceCount = 0;

for (Workspace workspace : targetWorkspaces) {
try {
List<WorkspaceWorkerSchedule> schedules = schedulesByWorkspaceId
.getOrDefault(workspace.getId(), List.of());

if (schedules.isEmpty()) {
log.info("[고정 근무 생성] 워크스페이스({}) - 활성화된 고정 스케줄 없음. 건너뜀.", workspace.getId());
continue;
}

GenerationResult result = generateShiftsForWorkspace(workspace, schedules, targetMonth);
totalCreated += result.created;
totalSkipped += result.skipped;

log.info("[고정 근무 생성] 워크스페이스({}) - 생성: {}건, 충돌 건너뜀: {}건", workspace.getId(), result.created, result.skipped);
} catch (Exception e) {
failedWorkspaceCount++;
log.error("[고정 근무 생성] 워크스페이스({}) 처리 중 오류 발생: {}", workspace.getId(), e.getMessage(), e);
}
}

log.info("[고정 근무 생성] 배치 완료 - 총 생성: {}건, 총 건너뜀: {}건, 실패 워크스페이스: {}개", totalCreated, totalSkipped, failedWorkspaceCount);
}

/**
* 단일 워크스페이스에 대해 다음 달 고정 근무 시프트를 생성한다.
*/
private GenerationResult generateShiftsForWorkspace(
Workspace workspace,
List<WorkspaceWorkerSchedule> schedules,
YearMonth targetMonth
) {
LocalDate startDate = targetMonth.atDay(1);
LocalDate endDate = targetMonth.atEndOfMonth();

List<WorkspaceShift> shiftsToSave = new ArrayList<>();
int skipped = 0;

for (WorkspaceWorkerSchedule schedule : schedules) {
// 해당 스케줄의 요일을 만족하는 다음 달 첫 날짜를 계산한다
LocalDate firstStartDate = calculateFirstOccurrence(startDate, schedule.getStartDayOfWeek());
if (firstStartDate.isAfter(endDate)) {
continue;
}

// 매주 반복하면서 시프트를 생성한다
for (LocalDate currentStartDate = firstStartDate;
!currentStartDate.isAfter(endDate);
currentStartDate = currentStartDate.plusWeeks(1)) {

WorkspaceWorker workspaceWorker = schedule.getWorkspaceWorker();
LocalDateTime startDateTime = currentStartDate.atTime(schedule.getStartTime());
LocalDateTime endDateTime = currentStartDate
.plusDays(calculateDayOffset(schedule.getStartDayOfWeek(), schedule.getEndDayOfWeek()))
.atTime(schedule.getEndTime());

// 이미 확정된 근무와 겹치면 자동 생성하지 않는다
if (workspaceShiftQueryRepository.hasConflictingSchedule(workspaceWorker, startDateTime, endDateTime)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금은 워크스페이스를 하나씩 순회하며 generateShiftsForWorkspace()를 호출하고, 그 안에서 다시 스케줄 × 주 수만큼 hasConflictingSchedule() 쿼리를 반복 호출하고 있습니다
쿼리가 (워크스페이스 수 × 스케줄 수 × 주수)번 발생하고, saveAll()도 워크스페이스마다 따로 호출됩니다
조회된 전체 고정 근무 일정을 기준으로 충돌 케이스를 DB 쿼리로 일괄 필터링하도록 하면 쿼리를 크게 줄일 수 있을 것 같습니다

skipped++;
continue;
}

WorkspaceShift shift = WorkspaceShift.create(
workspace,
startDateTime,
endDateTime,
DEFAULT_POSITION,
WorkspaceShiftStatus.CONFIRMED
);
shift.assignWorker(workspaceWorker);
shiftsToSave.add(shift);
}
}

if (!shiftsToSave.isEmpty()) {
workspaceShiftRepository.saveAll(shiftsToSave);
}

return new GenerationResult(shiftsToSave.size(), skipped);
}

// 해당 요일이 처음으로 등장하는 날짜를 반환한다
private LocalDate calculateFirstOccurrence(LocalDate monthStart, DayOfWeek targetDay) {
int diff = targetDay.getValue() - monthStart.getDayOfWeek().getValue();
if (diff < 0) {
diff += 7;
}
return monthStart.plusDays(diff);
}

// 시작 요일 대비 종료 요일까지 필요한 일수 오프셋을 계산한다
private long calculateDayOffset(DayOfWeek startDay, DayOfWeek endDay) {
long offset = endDay.getValue() - startDay.getValue();
// 종료 요일이 시작 요일보다 이른 경우 (예: 금요일 시작 → 월요일 종료) 다음 주로 넘어간다
if (offset < 0) {
offset += 7;
}
return offset;
}

private record GenerationResult(int created, int skipped) {}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.dreamteam.alter.application.workspace.usecase;

import com.dreamteam.alter.domain.workspace.port.inbound.ExpireSubstituteRequestsUseCase;
import com.dreamteam.alter.domain.workspace.port.inbound.GenerateNextMonthWorkspaceShiftUseCase;
import com.dreamteam.alter.domain.workspace.port.inbound.WorkspaceScheduleService;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
Expand All @@ -17,10 +18,20 @@ public class WorkspaceScheduleServiceImpl implements WorkspaceScheduleService {
@Resource(name = "expireSubstituteRequests")
private final ExpireSubstituteRequestsUseCase expireSubstituteRequests;

@Resource(name = "generateNextMonthWorkspaceShift")
private final GenerateNextMonthWorkspaceShiftUseCase generateNextMonthWorkspaceShift;

@Override
@Scheduled(cron = "0 0 0 * * *")
@SchedulerLock(name = "expireSubstituteRequests", lockAtMostFor = "5m")
public void expireSubstituteRequests() {
expireSubstituteRequests.execute();
}

@Override
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
Copy link
Contributor

@ysw789 ysw789 Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버 기준으로 이미 JVM 시간대 설정이 Asia/Seoul로 설정되어있어서 (Dockerfile 참조) zone 설정이 필수는 아닐 것 같긴 한데
이미 Asia/Seoul 시간대인데 또 시간대 보정을 위해 -9 시간 하는 로직인지 확인할 필욘 있어보여요

@SchedulerLock(name = "generateNextMonthWorkspaceShifts", lockAtMostFor = "30m")
public void generateNextMonthShiftsFromFixedSchedules() {
generateNextMonthWorkspaceShift.execute();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ public class Workspace {
@Column(name = "longitude", precision = 9, scale = 6, nullable = false)
private BigDecimal longitude;

// 다음 달 고정 근무 자동 생성일 (1~31, 기본값 25)
@Builder.Default
@Column(name = "next_month_shift_gen_day", nullable = false)
private int nextMonthShiftGenDay = 25;

@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.dreamteam.alter.domain.workspace.port.inbound;

public interface GenerateNextMonthWorkspaceShiftUseCase {
void execute();
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

public interface WorkspaceScheduleService {
void expireSubstituteRequests();
void generateNextMonthShiftsFromFixedSchedules();
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,6 @@ List<ManagerWorkspaceManagerListResponse> getManagerWorkspaceManagerListWithCurs
);

boolean existsByIdAndManagerUser(Long workspaceId, ManagerUser managerUser);

List<Workspace> findAllByNextMonthShiftGenDay(int day);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.dreamteam.alter.domain.workspace.port.outbound;

import java.util.List;

import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift;

public interface WorkspaceShiftRepository {
WorkspaceShift save(WorkspaceShift shift);
List<WorkspaceShift> saveAll(List<WorkspaceShift> shifts);
void delete(WorkspaceShift shift);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ public interface WorkspaceWorkerScheduleQueryRepository {
Optional<WorkspaceWorkerSchedule> findById(Long workerScheduleId);
Optional<WorkspaceWorkerSchedule> getByIdWithWorkspaceWorker(Long workerScheduleId);
List<WorkspaceWorkerSchedule> getByWorkspaceWorker(WorkspaceWorker workspaceWorker);
List<WorkspaceWorkerSchedule> findAllActivatedWithWorkspaceWorkerByWorkspaceIds(List<Long> workspaceIds);
}
Loading