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
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ repositories {
dependencies {
implementation "com.google.cloud.functions:functions-framework-api:1.1.0"
implementation "com.google.cloud.functions.invoker:java-function-invoker:1.3.1"
implementation 'com.google.cloud:google-cloud-run:0.34.0'
implementation 'com.google.cloud:google-cloud-tasks:2.34.0'
implementation 'com.google.cloud:google-cloud-scheduler:2.33.0'
implementation "com.fasterxml.jackson.core:jackson-databind:2.16.1"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,54 +1,17 @@
package com.telecom.fn.batchcontrol.service;

import com.google.cloud.run.v2.JobName;
import com.google.cloud.run.v2.JobsClient;
import com.google.cloud.run.v2.RunJobRequest;
import com.telecom.fn.batchcontrol.model.BatchJobType;

import java.util.List;

public class BatchRunService {

private static final String PROJECT_ID = System.getenv("GCP_PROJECT");
private static final String REGION = System.getenv("GCP_REGION");
private static final String CLOUD_RUN_JOB_NAME = "telecom-app-batch-core";

private final CloudTasksService cloudTasksService = new CloudTasksService();

public void runJob(BatchJobType jobType, String invMonth) throws Exception {

System.out.println("[BatchRunService] ENTER");
System.out.println("[BatchRunService] jobType=" + jobType + ", invMonth=" + invMonth);
System.out.println("[BatchRunService] PROJECT_ID=" + PROJECT_ID + ", REGION=" + REGION);

List<String> args = List.of(
"--spring.batch.job.name=" + jobType.springBatchJobName(),
"--invMonth=" + invMonth,
"--spring.batch.job.enabled=true",
"--spring.main.web-application-type=none"
);

System.out.println("[BatchRunService] args=" + args);

try (JobsClient client = JobsClient.create()) {

String jobName = JobName.of(PROJECT_ID, REGION, CLOUD_RUN_JOB_NAME).toString();
System.out.println("[BatchRunService] CloudRunJob=" + jobName);

RunJobRequest request = RunJobRequest.newBuilder()
.setName(jobName)
.setOverrides(
RunJobRequest.Overrides.newBuilder()
.addContainerOverrides(
RunJobRequest.Overrides.ContainerOverride.newBuilder()
.addAllArgs(args)
.build()
)
.build()
)
.build();
System.out.println("[BatchRunService] Delegating to CloudTasksService...");

client.runJobAsync(request);
System.out.println("[BatchRunService] runJobAsync CALLED");
}
String taskName = cloudTasksService.createTask(jobType, invMonth);
System.out.println("[BatchRunService] Task created: " + taskName);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ public class BatchScheduleService {

private static final String PROJECT_ID = System.getenv("GCP_PROJECT");
private static final String REGION = System.getenv("GCP_REGION");
private static final String CLOUD_RUN_JOB_NAME = "telecom-app-batch-core";

private static CloudSchedulerClient schedulerClient;

Expand All @@ -44,7 +43,7 @@ public ScheduleResponse getJobSchedule(BatchJobType jobType) throws Exception {
try {
Job job = client.getJob(fullJobName);
String body = job.getHttpTarget().getBody().toStringUtf8();
String invMonth = extractInvMonth(body);
String invMonth = CloudTasksService.extractInvMonthFromSchedulerBody(body);

return new ScheduleResponse(
jobType,
Expand All @@ -60,16 +59,6 @@ public ScheduleResponse getJobSchedule(BatchJobType jobType) throws Exception {
}
}

private String extractInvMonth(String body) {
if (body.contains("--invMonth=")) {
int start = body.indexOf("--invMonth=") + 11;
int end = body.indexOf("\"", start);
if (end == -1) end = body.indexOf("\"", start);
if (end != -1) return body.substring(start, end);
}
return null;
}

public java.util.List<ScheduleResponse> getAllJobSchedules() throws Exception {
java.util.List<ScheduleResponse> list = new java.util.ArrayList<>();
for (BatchJobType type : BatchJobType.values()) {
Expand All @@ -87,25 +76,20 @@ public void createJobSchedule(BatchJobType jobType, String cron, String invMonth

LocationName parent = LocationName.of(PROJECT_ID, REGION);
String fullJobName = JobName.of(PROJECT_ID, REGION, jobType.schedulerJobName()).toString();

String saEmail = getDefaultServiceAccount();
String uri = String.format(
"https://run.googleapis.com/v2/projects/%s/locations/%s/jobs/%s:run",
PROJECT_ID, REGION, CLOUD_RUN_JOB_NAME
);

String jobArg = "\"--spring.batch.job.name=" + jobType.springBatchJobName() + "\"";
String monthArg = (invMonth != null && !invMonth.isBlank()) ? ", \"--invMonth=" + invMonth + "\"" : "";
String saEmail = getDefaultServiceAccount();
String uri = CloudTasksService.getCloudTasksApiUrl();

String jsonBody = "{\"overrides\": {\"container_overrides\": [{\"args\": [" + jobArg + monthArg + "]}]}}";
String jsonBody = CloudTasksService.buildSchedulerTaskRequestBody(jobType, invMonth);

HttpTarget httpTarget = HttpTarget.newBuilder()
HttpTarget httpTarget = HttpTarget.newBuilder()
.setUri(uri)
.setHttpMethod(HttpMethod.POST)
.setOauthToken(OAuthToken.newBuilder()
.setOauthToken(OAuthToken.newBuilder()
.setServiceAccountEmail(saEmail)
.setScope("https://www.googleapis.com/auth/cloud-platform")
.build())
.putHeaders("Content-Type", "application/json")
.setBody(ByteString.copyFrom(jsonBody, StandardCharsets.UTF_8))
.build();

Expand Down Expand Up @@ -140,15 +124,8 @@ public void updateJobSchedule(BatchJobType jobType, String cron, String invMonth
jobType.schedulerJobName()
).toString();

String uri = String.format(
"https://run.googleapis.com/v2/projects/%s/locations/%s/jobs/%s:run",
PROJECT_ID, REGION, CLOUD_RUN_JOB_NAME
);

String jobArg = "\"--spring.batch.job.name=" + jobType.springBatchJobName() + "\"";
String monthArg = (invMonth != null && !invMonth.isBlank()) ? ", \"--invMonth=" + invMonth + "\"" : "";

String jsonBody = "{\"overrides\": {\"container_overrides\": [{\"args\": [" + jobArg + monthArg + "]}]}}";
String uri = CloudTasksService.getCloudTasksApiUrl();
String jsonBody = CloudTasksService.buildSchedulerTaskRequestBody(jobType, invMonth);

HttpTarget httpTarget = HttpTarget.newBuilder()
.setUri(uri)
Expand All @@ -157,6 +134,7 @@ public void updateJobSchedule(BatchJobType jobType, String cron, String invMonth
.setServiceAccountEmail(getDefaultServiceAccount())
.setScope("https://www.googleapis.com/auth/cloud-platform")
.build())
.putHeaders("Content-Type", "application/json")
.setBody(ByteString.copyFrom(jsonBody, StandardCharsets.UTF_8))
.build();

Expand All @@ -169,7 +147,9 @@ public void updateJobSchedule(BatchJobType jobType, String cron, String invMonth

FieldMask fieldMask = FieldMask.newBuilder()
.addPaths("schedule")
.addPaths("http_target.uri")
.addPaths("http_target.body")
.addPaths("http_target.headers")
.build();

UpdateJobRequest request = UpdateJobRequest.newBuilder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package com.telecom.fn.batchcontrol.service;

import com.google.cloud.tasks.v2.CloudTasksClient;
import com.google.cloud.tasks.v2.HttpMethod;
import com.google.cloud.tasks.v2.HttpRequest;
import com.google.cloud.tasks.v2.OidcToken;
import com.google.cloud.tasks.v2.QueueName;
import com.google.cloud.tasks.v2.Task;
import com.google.cloud.tasks.v2.CreateTaskRequest;
import com.google.protobuf.ByteString;
import com.telecom.fn.batchcontrol.model.BatchJobType;

import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class CloudTasksService {

private static final String PROJECT_ID = System.getenv("GCP_PROJECT");
private static final String REGION = System.getenv("GCP_REGION");
private static final String QUEUE_NAME = "telecom-app-batch-queue";
private static final String QUEUE_LOCATION = "asia-northeast3";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

QUEUE_LOCATION이 "asia-northeast3"으로 하드코딩되어 있습니다. PROJECT_IDREGION처럼 환경 변수에서 값을 읽어오도록 변경하면, 다른 환경(예: 개발, 스테이징)에 배포할 때 유연성이 높아집니다. GCP_QUEUE_LOCATION 같은 환경 변수를 사용하고, 이 값이 없을 경우 애플리케이션이 시작되지 않도록 하여 설정 오류를 방지하는 것을 권장합니다.

Suggested change
private static final String QUEUE_LOCATION = "asia-northeast3";
private static final String QUEUE_LOCATION = System.getenv("GCP_QUEUE_LOCATION");

private static final String CLOUD_RUN_JOB_NAME = "telecom-app-batch-core";

/**
* Cloud Task 생성 (즉시 실행용)
* Task가 Cloud Run Job :run 엔드포인트를 호출
*/
public String createTask(BatchJobType jobType, String invMonth) throws Exception {
System.out.println("[CloudTasksService] ENTER createTask");
System.out.println("[CloudTasksService] jobType=" + jobType + ", invMonth=" + invMonth);

try (CloudTasksClient client = CloudTasksClient.create()) {
QueueName queueName = QueueName.of(PROJECT_ID, QUEUE_LOCATION, QUEUE_NAME);
System.out.println("[CloudTasksService] Queue=" + queueName.toString());

String cloudRunJobUrl = String.format(
"https://%s-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/%s/jobs/%s:run",
REGION, PROJECT_ID, CLOUD_RUN_JOB_NAME
);
System.out.println("[CloudTasksService] CloudRunJobUrl=" + cloudRunJobUrl);

String requestBody = buildCloudRunJobRequestBody(jobType, invMonth);
System.out.println("[CloudTasksService] RequestBody=" + requestBody);

String serviceAccountEmail = getDefaultServiceAccount();

HttpRequest httpRequest = HttpRequest.newBuilder()
.setUrl(cloudRunJobUrl)
.setHttpMethod(HttpMethod.POST)
.putHeaders("Content-Type", "application/json")
.setBody(ByteString.copyFrom(requestBody, StandardCharsets.UTF_8))
.setOidcToken(OidcToken.newBuilder()
.setServiceAccountEmail(serviceAccountEmail)
.setAudience(cloudRunJobUrl)
.build())
.build();

Task task = Task.newBuilder()
.setHttpRequest(httpRequest)
.build();

CreateTaskRequest request = CreateTaskRequest.newBuilder()
.setParent(queueName.toString())
.setTask(task)
.build();

Task createdTask = client.createTask(request);
System.out.println("[CloudTasksService] Task created: " + createdTask.getName());
return createdTask.getName();
}
}

/**
* Cloud Run Job 실행 요청 본문 생성
*/
private String buildCloudRunJobRequestBody(BatchJobType jobType, String invMonth) {
String jobArg = "\"--spring.batch.job.name=" + jobType.springBatchJobName() + "\"";
String monthArg = "\"--invMonth=" + invMonth + "\"";
String enabledArg = "\"--spring.batch.job.enabled=true\"";
String webTypeArg = "\"--spring.main.web-application-type=none\"";

return "{\"overrides\": {\"containerOverrides\": [{\"args\": ["
+ jobArg + ", " + monthArg + ", " + enabledArg + ", " + webTypeArg
+ "]}]}}";
}
Comment on lines +76 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

buildCloudRunJobRequestBody 메소드에서 JSON 문자열을 수동으로 생성하고 있습니다. 이 방식은 오류가 발생하기 쉽고 유지보수가 어렵습니다. 예를 들어, 인자 중 하나에 따옴표가 포함되면 JSON 형식이 깨질 수 있습니다. 프로젝트에 이미 jackson-databind 의존성이 있으므로, ObjectMapperMap을 사용하여 객체를 만든 후 JSON 문자열로 직렬화하는 것이 더 안전하고 깔끔합니다.

    private String buildCloudRunJobRequestBody(BatchJobType jobType, String invMonth) {
        try {
            com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
            java.util.Map<String, Object> root = java.util.Map.of(
                "overrides", java.util.Map.of(
                    "containerOverrides", java.util.List.of(
                        java.util.Map.of(
                            "args", java.util.List.of(
                                "--spring.batch.job.name=" + jobType.springBatchJobName(),
                                "--invMonth=" + invMonth,
                                "--spring.batch.job.enabled=true",
                                "--spring.main.web-application-type=none"
                            )
                        )
                    )
                )
            );
            return objectMapper.writeValueAsString(root);
        } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
            throw new RuntimeException("Failed to build Cloud Run job request body", e);
        }
    }


/**
* Cloud Tasks API URL 반환 (Scheduler 타겟용)
* https://cloudtasks.googleapis.com/v2/projects/{project}/locations/{location}/queues/{queue}/tasks
*/
public static String getCloudTasksApiUrl() {
return String.format(
"https://cloudtasks.googleapis.com/v2/projects/%s/locations/%s/queues/%s/tasks",
PROJECT_ID, QUEUE_LOCATION, QUEUE_NAME
);
}

/**
* Scheduler가 Cloud Tasks API에 POST할 요청 본문 생성
* Task 생성 요청 (내부에 Cloud Run Job 호출 정보 포함)
*/
public static String buildSchedulerTaskRequestBody(BatchJobType jobType, String invMonth) {
String cloudRunJobUrl = String.format(
"https://%s-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/%s/jobs/%s:run",
REGION, PROJECT_ID, CLOUD_RUN_JOB_NAME
);

String serviceAccountEmail = getDefaultServiceAccountStatic();

String jobArg = "\"--spring.batch.job.name=" + jobType.springBatchJobName() + "\"";
String monthArg = (invMonth != null && !invMonth.isBlank())
? ", \"--invMonth=" + invMonth + "\"" : "";

String innerBody = "{\"overrides\": {\"containerOverrides\": [{\"args\": ["
+ jobArg + monthArg + "]}]}}";
String encodedBody = Base64.getEncoder().encodeToString(
innerBody.getBytes(StandardCharsets.UTF_8));

return String.format(
"{\"task\": {\"httpRequest\": {" +
"\"url\": \"%s\", " +
"\"httpMethod\": \"POST\", " +
"\"headers\": {\"Content-Type\": \"application/json\"}, " +
"\"body\": \"%s\", " +
"\"oidcToken\": {\"serviceAccountEmail\": \"%s\", \"audience\": \"%s\"}" +
"}}}",
cloudRunJobUrl, encodedBody, serviceAccountEmail, cloudRunJobUrl
);
}
Comment on lines +102 to +129
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

buildSchedulerTaskRequestBody 메소드에서도 JSON 문자열을 수동으로 생성하고 있습니다. 특히 String.format을 사용하여 복잡한 중첩 JSON 구조를 만드는 것은 가독성을 해치고 오류를 유발하기 쉽습니다. 이 메소드 또한 ObjectMapper를 사용하여 타입-세이프하게 JSON을 생성하는 것이 좋습니다. 내부 body를 Base64로 인코딩해야 하는 점을 감안하더라도, 객체 구조를 먼저 만든 후 직렬화하는 것이 더 안전합니다.

    public static String buildSchedulerTaskRequestBody(BatchJobType jobType, String invMonth) {
        try {
            com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();

            java.util.List<String> args = new java.util.ArrayList<>();
            args.add("--spring.batch.job.name=" + jobType.springBatchJobName());
            if (invMonth != null && !invMonth.isBlank()) {
                args.add("--invMonth=" + invMonth);
            }

            java.util.Map<String, Object> innerBodyMap = java.util.Map.of(
                "overrides", java.util.Map.of(
                    "containerOverrides", java.util.List.of(
                        java.util.Map.of("args", args)
                    )
                )
            );
            String innerBodyJson = objectMapper.writeValueAsString(innerBodyMap);
            String encodedBody = java.util.Base64.getEncoder().encodeToString(
                    innerBodyJson.getBytes(java.nio.charset.StandardCharsets.UTF_8));

            String cloudRunJobUrl = String.format(
                    "https://%s-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/%s/jobs/%s:run",
                    REGION, PROJECT_ID, CLOUD_RUN_JOB_NAME
            );
            String serviceAccountEmail = getDefaultServiceAccountStatic();

            java.util.Map<String, Object> taskMap = java.util.Map.of(
                "task", java.util.Map.of(
                    "httpRequest", java.util.Map.of(
                        "url", cloudRunJobUrl,
                        "httpMethod", "POST",
                        "headers", java.util.Map.of("Content-Type", "application/json"),
                        "body", encodedBody,
                        "oidcToken", java.util.Map.of(
                            "serviceAccountEmail", serviceAccountEmail,
                            "audience", cloudRunJobUrl
                        )
                    )
                )
            );

            return objectMapper.writeValueAsString(taskMap);
        } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
            throw new RuntimeException("Error creating JSON for scheduler task request", e);
        }
    }


/**
* Scheduler 요청 본문에서 invMonth 추출
*/
public static String extractInvMonthFromSchedulerBody(String body) {
try {
int bodyStart = body.indexOf("\"body\":");
if (bodyStart == -1) return null;

int valueStart = body.indexOf("\"", bodyStart + 7) + 1;
int valueEnd = body.indexOf("\"", valueStart);
if (valueStart == 0 || valueEnd == -1) return null;

String base64Body = body.substring(valueStart, valueEnd);
String decodedBody = new String(Base64.getDecoder().decode(base64Body), StandardCharsets.UTF_8);

if (decodedBody.contains("--invMonth=")) {
int start = decodedBody.indexOf("--invMonth=") + 11;
int end = decodedBody.indexOf("\"", start);
if (end != -1) return decodedBody.substring(start, end);
}
} catch (Exception e) {
System.err.println("[CloudTasksService] Failed to extract invMonth: " + e.getMessage());
}
return null;
}
Comment on lines +134 to +155
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

extractInvMonthFromSchedulerBody 메소드는 indexOfsubstring을 사용하여 JSON을 파싱하고 있습니다. 이 방식은 JSON의 공백이나 필드 순서 변경 등 사소한 변화에도 쉽게 깨질 수 있어 매우 취약합니다. ObjectMapperJsonNode를 사용하여 JSON 문자열을 파싱한 후, 필요한 값을 안전하게 추출하는 것이 올바른 접근 방식입니다. 이는 코드의 안정성과 신뢰성을 크게 향상시킵니다.

    public static String extractInvMonthFromSchedulerBody(String body) {
        try {
            com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
            com.fasterxml.jackson.databind.JsonNode rootNode = objectMapper.readTree(body);

            String base64Body = rootNode.path("task").path("httpRequest").path("body").asText(null);
            if (base64Body == null) {
                return null;
            }

            byte[] decodedBytes = java.util.Base64.getDecoder().decode(base64Body);
            String decodedBody = new String(decodedBytes, java.nio.charset.StandardCharsets.UTF_8);

            com.fasterxml.jackson.databind.JsonNode innerRootNode = objectMapper.readTree(decodedBody);
            com.fasterxml.jackson.databind.JsonNode argsNode = innerRootNode.path("overrides").path("containerOverrides").get(0).path("args");

            if (argsNode == null || argsNode.isMissingNode() || !argsNode.isArray()) {
                return null;
            }

            for (com.fasterxml.jackson.databind.JsonNode argNode : argsNode) {
                String arg = argNode.asText();
                if (arg.startsWith("--invMonth=")) {
                    return arg.substring("--invMonth=".length());
                }
            }
        } catch (Exception e) {
            System.err.println("[CloudTasksService] Failed to extract invMonth: " + e.getMessage());
        }
        return null;
    }


private String getDefaultServiceAccount() {
return getDefaultServiceAccountStatic();
}

private static String getDefaultServiceAccountStatic() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

이 메소드는 BatchScheduleService에 있는 getDefaultServiceAccount 메소드와 거의 동일한 로직을 가집니다. 코드 중복을 피하고 로직을 중앙에서 관리하기 위해 이 메소드를 public으로 변경하고, BatchScheduleService에서도 이 메소드를 호출하도록 리팩토링하는 것이 좋습니다. 일관성을 위해 메소드 이름을 getDefaultServiceAccount로 변경하는 것도 고려해볼 수 있습니다.

    public static String getDefaultServiceAccountStatic() {

String projectNumber = System.getenv("GCP_PROJECT_NUMBER");
if (projectNumber == null || projectNumber.isBlank()) {
throw new IllegalStateException("Environment variable 'GCP_PROJECT_NUMBER' is required.");
}
return String.format("%s-compute@developer.gserviceaccount.com", projectNumber);
}
}
Loading