Skip to content
Merged
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
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ def coverageExcludePackages = [
'**/batch/**', // Batch 제외
'**/invoice/**',
'**/notification/**',
'**/usage/**',
'**/util/**',
]

// JaCoCo용 제외 패턴 (클래스 파일)
Expand Down Expand Up @@ -96,6 +98,9 @@ dependencies {
// RestTemplate
implementation "org.apache.httpcomponents.client5:httpclient5"

// Cron
implementation "com.cronutils:cron-utils:9.2.1"

}

// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,28 @@

import com.project.core.controller.dto.request.BatchTriggerRequest;
import com.project.core.controller.dto.request.UpdateBatchScheduleRequest;
import com.project.core.service.BatchScheduleService;
import com.project.core.util.BatchTriggerClient;

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

@RestController
@RequestMapping("/admin/batch/invoice")
@RequiredArgsConstructor
@Slf4j
public class InvoiceBatchAdminController {

private final BatchTriggerClient batchTriggerClient;
private final BatchScheduleService scheduleService;

@PostMapping("/run-now")
public ResponseEntity<Void> runNow(@RequestBody BatchTriggerRequest request) {
log.info("[run-now] called. job={}, invMonth={}", request.job(), request.invMonth());
batchTriggerClient.trigger(request.job(), request.invMonth());
return ResponseEntity.accepted().build();
}

@PutMapping("/schedule")
public ResponseEntity<Void> updateSchedule(@RequestBody UpdateBatchScheduleRequest request) {
scheduleService.updateSchedule(request.job(), request.cron());
batchTriggerClient.schedule(request.job(), request.cron());
return ResponseEntity.ok().build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,24 @@
import java.time.LocalDateTime;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class BatchSummaryDto {
private String jobName;
private String status;
private String exitCode;
private String exitMessage;

private String cronExpression; // 0 0 2 * * *
private String cronDescription; // 매일 오전 2시
private LocalDateTime nextFireTime; // 다음 실행 시각

private LocalDateTime startTime;
private LocalDateTime endTime;
private long durationMs;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.project.core.controller.dto.response;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class BatchScheduleResponse {

private String jobType; // INVOICE
private String scheduleName; // projects/.../jobs/...
private String cron; // 15 12 26 1 *
private String invMonth; // null 가능
private String description; // Auto-created/Updated via Batch Control API
private String state; // ENABLED / DISABLED
private String timeZone; // Asia/Seoul

public boolean isEnabled() {
return "ENABLED".equalsIgnoreCase(state);
}
Comment on lines +18 to +20
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

isEnabled() 메서드에서 "ENABLED" 문자열을 직접 사용하고 있습니다. 이런 매직 스트링은 오타를 유발하거나 일관성을 해칠 수 있습니다. 상태 값을 나타내는 상수를 선언하여 사용하는 것이 좋습니다.

public class BatchScheduleResponse {
    private static final String ENABLED_STATE = "ENABLED";
    // ...
    public boolean isEnabled() {
        return ENABLED_STATE.equalsIgnoreCase(state);
    }
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
@Getter
@RequiredArgsConstructor
public enum BatchJobType {
INVOICE_ITEM_JOB("invoiceItemJob", "청구 내역 생성"),
INVOICE_JOB("invoiceJob", "청구서 생성");
INVOICE_ITEM_JOB("invoiceItemJob", "INVOICE_ITEM"),
INVOICE_JOB("invoiceJob", "INVOICE");

private final String jobName;
private final String title;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public List<BatchFailureLogDto> findRecentFailures(int limit) {
JOIN BATCH_JOB_EXECUTION je
ON ji.JOB_INSTANCE_ID = je.JOB_INSTANCE_ID
WHERE je.STATUS = 'FAILED'
AND ji.JOB_NAME IN ('invoiceJob', 'invoiceItemJob')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

SQL 쿼리 내에 'invoiceJob', 'invoiceItemJob'과 같은 잡 이름이 하드코딩되어 있습니다. 이는 유지보수를 어렵게 만들 수 있습니다. BatchJobType enum을 활용하여 잡 이름을 동적으로 구성하거나, PreparedStatement의 파라미터로 전달하는 것이 좋습니다. 이렇게 하면 BatchJobType enum이 변경될 때 쿼리도 자동으로 반영됩니다.

예를 들어, 다음과 같이 ? 플레이스홀더를 사용하고 jdbcTemplate.query 메서드에 인자를 전달할 수 있습니다.

// 쿼리 수정
String sql =
    """
        ...
          AND ji.JOB_NAME IN (?, ?)
        ...
        LIMIT ?
    """;

// jdbcTemplate 호출 수정
jdbcTemplate.query(sql, rowMapper, BatchJobType.INVOICE_JOB.getJobName(), BatchJobType.INVOICE_ITEM_JOB.getJobName(), limit);

ORDER BY je.END_TIME DESC
LIMIT ?
""";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.project.core.infra.repository.invoice;

import java.time.LocalDateTime;
import java.util.List;

import org.springframework.jdbc.core.JdbcTemplate;
Expand Down Expand Up @@ -46,16 +47,25 @@ WHERE ji2.JOB_NAME IN ('invoiceJob', 'invoiceItemJob')
return jdbcTemplate.query(
sql,
(rs, rowNum) -> {
var startTimeStamp = rs.getTimestamp("START_TIME");
var endTimeStamp = rs.getTimestamp("END_TIME");
return new BatchSummaryDto(
rs.getString("JOB_NAME"),
rs.getString("STATUS"),
rs.getString("EXIT_CODE"),
rs.getString("EXIT_MESSAGE"),
startTimeStamp != null ? startTimeStamp.toLocalDateTime() : null,
endTimeStamp != null ? endTimeStamp.toLocalDateTime() : null,
rs.getLong("DURATION_MS"));
var startTimestamp = rs.getTimestamp("START_TIME");
var endTimestamp = rs.getTimestamp("END_TIME");

LocalDateTime startTime =
startTimestamp != null ? startTimestamp.toLocalDateTime() : null;

LocalDateTime endTime =
endTimestamp != null ? endTimestamp.toLocalDateTime() : null;

return BatchSummaryDto.builder()
.jobName(rs.getString("JOB_NAME"))
.status(rs.getString("STATUS"))
.exitCode(rs.getString("EXIT_CODE"))
.exitMessage(rs.getString("EXIT_MESSAGE"))
.startTime(startTime)
.endTime(endTime)
.durationMs(rs.getLong("DURATION_MS"))
// cron 관련 필드는 Service에서 채움
.build();
});
}
}

This file was deleted.

29 changes: 0 additions & 29 deletions src/main/java/com/project/core/service/BatchScheduleService.java

This file was deleted.

80 changes: 78 additions & 2 deletions src/main/java/com/project/core/service/InvoiceService.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
package com.project.core.service;

import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Locale;

import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Service;

import com.cronutils.descriptor.CronDescriptor;
import com.cronutils.model.Cron;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.parser.CronParser;
import com.project.core.controller.dto.BatchSummaryDto;
import com.project.core.controller.dto.GrafanaInfoDto;
import com.project.core.controller.dto.response.BatchScheduleResponse;
import com.project.core.controller.dto.response.BillingDashboardResponse;
import com.project.core.infra.repository.invoice.BatchFailureQueryRepository;
import com.project.core.infra.repository.invoice.BatchJobQueryRepository;
import com.project.core.util.BatchScheduleClient;
import com.project.global.config.BillingProperties;
import com.project.global.exception.code.domain.core.CoreErrorCode;
import com.project.global.exception.core.EntityNotFoundException;
Expand All @@ -20,13 +34,21 @@ public class InvoiceService {

private final BatchJobQueryRepository batchJobQueryRepository;
private final BatchFailureQueryRepository batchFailureQueryRepository;
private final BatchScheduleClient batchScheduleClient;
private final BillingProperties billingProperties;

public BillingDashboardResponse getDashboard() {

try {
// 1️⃣ 최신 배치 실행 요약 조회
List<BatchSummaryDto> jobs = batchJobQueryRepository.findLatestJobSummaries();

// 2️⃣ Cloud Function 스케줄 정보 결합
List<BatchSummaryDto> enrichedJobs =
jobs.stream().map(this::enrichWithSchedule).toList();

return BillingDashboardResponse.builder()
.batchJobs(batchJobQueryRepository.findLatestJobSummaries())
.batchJobs(enrichedJobs)
.grafana(
GrafanaInfoDto.builder()
.iframeUrl(billingProperties.getIframeUrl())
Expand All @@ -36,8 +58,62 @@ public BillingDashboardResponse getDashboard() {
.build();

} catch (Exception e) {
log.error("[BillingDashboard] 대시보드 빌딩에 실패했습니다.", e);
log.error("[BillingDashboard] 대시보드 빌딩 실패", e);
throw new EntityNotFoundException(CoreErrorCode.DASHBOARD_NOT_FOUND);
}
}

/** 배치 실행 요약 + Cloud Function 스케줄 정보 결합 */
private BatchSummaryDto enrichWithSchedule(BatchSummaryDto job) {

return batchScheduleClient
.getSchedule(job.getJobName())
.map(schedule -> applySchedule(job, schedule))
.orElseGet(() -> job.toBuilder().cronDescription("수동 실행").build());
}

/** cron / 설명 / 다음 실행 시각 계산 (최종) */
private BatchSummaryDto applySchedule(BatchSummaryDto job, BatchScheduleResponse schedule) {

// 1️⃣ 비활성화 스케줄
if (!schedule.isEnabled() || schedule.getCron() == null) {
return job.toBuilder().cronDescription("비활성화").build();
}

String cronStr = schedule.getCron();
String timeZone = schedule.getTimeZone(); // ex) Asia/Seoul

try {
// 2️⃣ 타임존 (Cloud Function 기준)
ZoneId zoneId = ZoneId.of(timeZone);

// ✅ Spring용 크론 보정 (6필드)
String springCronStr = cronStr;
if (cronStr.split(" ").length == 5) {
springCronStr = "0 " + cronStr;
}

CronExpression springCron = CronExpression.parse(springCronStr);

ZonedDateTime nextFire = springCron.next(ZonedDateTime.now(zoneId));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

ZonedDateTime.now(zoneId)를 직접 호출하면 현재 시간에 의존하게 되어 단위 테스트를 작성하기 어렵습니다. 테스트 용이성을 높이기 위해 java.time.Clock을 주입받아 사용하거나, getDashboard 메서드에서 현재 시간을 변수로 받아 하위 메서드로 전달하는 방식을 고려해 보세요.

Clock을 사용하는 예시입니다:

// In a @Configuration class
@Bean
public Clock clock() {
    return Clock.systemDefaultZone();
}

// In InvoiceService
@RequiredArgsConstructor
public class InvoiceService {
    private final Clock clock;
    // ...
    private BatchSummaryDto applySchedule(...) {
        // ...
        ZonedDateTime nextFire = springCron.next(ZonedDateTime.now(clock).withZoneSameInstant(zoneId));
        // ...
    }
}

테스트에서는 Clock.fixed(...)를 주입하여 시간을 고정할 수 있습니다.


// ✅ cron-utils (UNIX)
CronParser parser =
new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
Cron cron = parser.parse(cronStr);

String cronDescription = CronDescriptor.instance(Locale.KOREAN).describe(cron);
Comment on lines +101 to +105
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

applySchedule 메서드가 호출될 때마다 CronParserCronDescriptor 인스턴스가 새로 생성됩니다. 이 객체들은 스레드에 안전하며 재사용이 가능하므로, private static final 필드로 선언하여 애플리케이션 시작 시 한 번만 생성하도록 하는 것이 성능에 더 효율적입니다.

public class InvoiceService {
    private static final CronParser UNIX_CRON_PARSER = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
    private static final CronDescriptor CRON_DESCRIPTOR_KOREAN = CronDescriptor.instance(Locale.KOREAN);

    // ...

    private BatchSummaryDto applySchedule(...) {
        // ...
        try {
            // ...
            Cron cron = UNIX_CRON_PARSER.parse(cronStr);
            String cronDescription = CRON_DESCRIPTOR_KOREAN.describe(cron);
            // ...
        } catch (Exception e) {
            // ...
        }
    }
}


return job.toBuilder()
.cronExpression(cronStr)
.cronDescription(cronDescription)
.nextFireTime(nextFire != null ? nextFire.toLocalDateTime() : null)
.build();

} catch (Exception e) {
log.warn("[BillingDashboard] cron 처리 실패 job={}, cron={}", job.getJobName(), cronStr, e);

return job.toBuilder().cronExpression(cronStr).cronDescription("잘못된 크론식").build();
}
}
}
49 changes: 49 additions & 0 deletions src/main/java/com/project/core/util/BatchScheduleClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.project.core.util;

import java.util.Optional;

import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;

import com.project.core.controller.dto.response.BatchScheduleResponse;
import com.project.core.infra.entity.batch.BatchJobType;
import com.project.global.config.CloudFunctionProperties;

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

@Component
@RequiredArgsConstructor
@Slf4j
public class BatchScheduleClient {

private final RestTemplate restTemplate;
private final CloudFunctionProperties properties;

public Optional<BatchScheduleResponse> getSchedule(String jobName) {

String title = BatchJobType.getTitleByJobName(jobName);
String url = properties.getBaseUrl() + "/schedule?job=" + title;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

URL을 문자열 연결로 생성하고 있습니다. 쿼리 파라미터에 특수 문자가 포함될 경우 문제가 발생할 수 있으므로, UriComponentsBuilder를 사용하여 URL을 안전하게 생성하고 파라미터를 인코딩하는 것이 좋습니다.

        String url = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl())
                .path("/schedule")
                .queryParam("job", title)
                .toUriString();


try {
ResponseEntity<BatchScheduleResponse> response =
restTemplate.getForEntity(url, BatchScheduleResponse.class);

return Optional.ofNullable(response.getBody());

} catch (HttpStatusCodeException e) {
log.warn(
"[BatchSchedule] 조회 실패 job={}, status={}, body={}",
jobName,
e.getStatusCode(),
e.getResponseBodyAsString());
return Optional.empty();

} catch (Exception e) {
log.error("[BatchSchedule] 조회 중 오류 job={}", jobName, e);
return Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class BatchTriggerClient {
public void trigger(String job, String invMonth) {

String url = properties.getBaseUrl() + "/run-now";
log.info("[BatchTrigger] calling url={}", url);

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Expand Down
Loading