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 .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI / CD

on:
push:
branches: [main]
branches: [fix/#16-exception-handling-fix]

jobs:
CI:
Expand Down
135 changes: 82 additions & 53 deletions src/main/java/com/gcp/domain/discord/service/GcpBotService.java
Original file line number Diff line number Diff line change
Expand Up @@ -127,34 +127,52 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
}

case "start" -> {
String vmName = getRequiredOption(event, "vm_name");
event.reply(gcpService.startVM(userId, guildId, vmName)).queue();
try{
String vmName = getRequiredOption(event, "vm_name");
event.reply(gcpService.startVM(userId, guildId, vmName)).queue();
} catch (RuntimeException e){
event.reply("❌ " + e.getMessage()).queue();;
}
}
case "stop" -> {
String vmName = getRequiredOption(event, "vm_name");
event.reply(gcpService.stopVM(userId, guildId, vmName)).queue();
try {
String vmName = getRequiredOption(event, "vm_name");
event.reply(gcpService.stopVM(userId, guildId, vmName)).queue();
} catch (RuntimeException e){
event.reply("❌ " + e.getMessage()).queue();
}
}
case "logs" -> {
String vmName = getRequiredOption(event, "vm_name");
event.deferReply().queue();
try{
String vmName = getRequiredOption(event, "vm_name");
event.deferReply().queue();

List<String> logs = gcpService.getVmLogs(userId, guildId, vmName);
List<String> logs = gcpService.getVmLogs(userId, guildId, vmName);

if (logs.isEmpty()) {
event.getHook().sendMessage("📭 로그가 없습니다.").queue();
return;
}
if (logs.isEmpty()) {
event.getHook().sendMessage("📭 로그가 없습니다.").queue();
return;
}

for (String log : logs) {
event.getHook().sendMessage("```bash\n" + log + "\n```").queue();
for (String log : logs) {
event.getHook().sendMessage("```bash\n" + log + "\n```").queue();
}
} catch (RuntimeException e){
event.reply("❌ " + e.getMessage()).queue();
}
Comment on lines +146 to 162
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

deferReply() 이후 오류 응답 방식 오류

event.deferReply() 호출 후에는 event.reply()를 다시 사용할 수 없습니다. 예외 발생 시

event.getHook().sendMessage("❌ " + e.getMessage()).queue();

또는 editOriginal()로 응답을 수정해야 합니다. 동일 패턴이 firewall-list 등 다른 커맨드에도 있으니 함께 수정해 주세요.

🤖 Prompt for AI Agents
In src/main/java/com/gcp/domain/discord/service/GcpBotService.java around lines
146 to 162, after calling event.deferReply(), the code incorrectly uses
event.reply() in the catch block, which is not allowed. To fix this, replace
event.reply() with event.getHook().sendMessage() or use
event.getHook().editOriginal() to send the error message. Also, review and apply
the same fix to other commands like firewall-list that use this pattern.

}
case "cost" -> event.reply(gcpService.getEstimatedCost()).queue();
case "notify" -> {
gcpService.enableVmNotifications();
event.reply("✅ GCP VM 상태 변경 시 알림을 받을 수 있습니다!").queue();
}
case "list" -> event.reply(gcpService.getVmList(userId, guildId).toString()).queue();
case "list" -> {
try {
event.reply(gcpService.getVmList(userId, guildId).toString()).queue();
} catch (Exception e){
event.reply("보유 중인 인스턴스가 없습니다.").queue();
}
}
case "create" -> {
try {
String vmName = getRequiredOption(event, "vm_name");
Expand All @@ -172,64 +190,75 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
String result = gcpService.createVM(userId, guildId, vmName, machineType, osImage, bootDiskGb, allowHttp, allowHttps);
event.reply(result).queue();
} catch (Exception e) {
event.reply("❌ VM 생성 중 오류 발생: " + e.getMessage()).queue();
event.reply("❌ " + e.getMessage()).queue();
}
}
case "firewall-list" -> {
event.deferReply().queue();
try{
event.deferReply().queue();
List<Map<String, Object>> rules = gcpService.getFirewallRules(userId, guildId);

List<Map<String, Object>> rules = gcpService.getFirewallRules(userId, guildId);
if (rules.isEmpty()) {
event.getHook().sendMessage("📭 조회된 방화벽 규칙이 없습니다.").queue();
return;
}

if (rules.isEmpty()) {
event.getHook().sendMessage("📭 조회된 방화벽 규칙이 없습니다.").queue();
return;
}
StringBuilder sb = new StringBuilder("📌 현재 방화벽 규칙 목록 (TCP 기준):\n");

StringBuilder sb = new StringBuilder("📌 현재 방화벽 규칙 목록 (TCP 기준):\n");
for (Map<String, Object> rule : rules) {
String name = (String) rule.get("name");
List<String> ports = (List<String>) rule.get("tcpPorts");
JsonNode sourceRanges = (JsonNode) rule.get("sourceRanges");

for (Map<String, Object> rule : rules) {
String name = (String) rule.get("name");
List<String> ports = (List<String>) rule.get("tcpPorts");
JsonNode sourceRanges = (JsonNode) rule.get("sourceRanges");
sb.append("• `").append(name).append("` - 포트: ")
.append(ports.isEmpty() ? "없음" : String.join(", ", ports))
.append(", IP 범위: ").append(sourceRanges.toString()).append("\n");
}

sb.append("• `").append(name).append("` - 포트: ")
.append(ports.isEmpty() ? "없음" : String.join(", ", ports))
.append(", IP 범위: ").append(sourceRanges.toString()).append("\n");
event.getHook().sendMessage(sb.toString()).queue();
} catch (RuntimeException e){
event.reply("❌ " + e.getMessage()).queue();
}

event.getHook().sendMessage(sb.toString()).queue();
}
case "firewall-create" -> {
int port = Optional.ofNullable(event.getOption("port"))
.map(OptionMapping::getAsInt)
.orElseThrow(() -> new IllegalArgumentException("포트가 필요합니다."));
try{
int port = Optional.ofNullable(event.getOption("port"))
.map(OptionMapping::getAsInt)
.orElseThrow(() -> new IllegalArgumentException("포트가 필요합니다."));

if (port < 1 || port > 65535) {
event.reply("❌ 유효하지 않은 포트 번호입니다. 1 ~ 65535 사이여야 합니다.").setEphemeral(true).queue();
return;
}
if (port < 1 || port > 65535) {
event.reply("❌ 유효하지 않은 포트 번호입니다. 1 ~ 65535 사이여야 합니다.").setEphemeral(true).queue();
return;
}

String ipRangeRaw = Optional.ofNullable(event.getOption("source_ranges"))
.map(OptionMapping::getAsString)
.orElse("0.0.0.0/0");
String ipRangeRaw = Optional.ofNullable(event.getOption("source_ranges"))
.map(OptionMapping::getAsString)
.orElse("0.0.0.0/0");

List<String> sourceRanges = List.of(ipRangeRaw.split("\\s*,\\s*"));
List<String> sourceRanges = List.of(ipRangeRaw.split("\\s*,\\s*"));

String result = gcpService.createFirewallRule(userId, guildId, port, sourceRanges);
event.reply(result).queue();
String result = gcpService.createFirewallRule(userId, guildId, port, sourceRanges);
event.reply(result).queue();
} catch (RuntimeException e){
event.reply("❌ " + e.getMessage()).queue();
}
}
case "firewall-delete" -> {
int port = Optional.ofNullable(event.getOption("port"))
.map(OptionMapping::getAsInt)
.orElseThrow(() -> new IllegalArgumentException("포트가 필요합니다."));
try{
int port = Optional.ofNullable(event.getOption("port"))
.map(OptionMapping::getAsInt)
.orElseThrow(() -> new IllegalArgumentException("포트가 필요합니다."));

if (port < 1 || port > 65535) {
event.reply("❌ 유효하지 않은 포트 번호입니다. 1 ~ 65535 사이여야 합니다.").setEphemeral(true).queue();
return;
}
if (port < 1 || port > 65535) {
event.reply("❌ 유효하지 않은 포트 번호입니다. 1 ~ 65535 사이여야 합니다.").setEphemeral(true).queue();
return;
}

String result = gcpService.deleteFirewallRule(userId, guildId, port);
event.reply(result).queue();
String result = gcpService.deleteFirewallRule(userId, guildId, port);
event.reply(result).queue();
} catch (RuntimeException e){
event.reply("❌ " + e.getMessage()).queue();
}
}
default -> event.reply("❌ 지원하지 않는 명령어입니다.").queue();
}
Expand Down
90 changes: 48 additions & 42 deletions src/main/java/com/gcp/domain/gcp/service/GcpService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,20 @@
import com.gcp.domain.discord.repository.DiscordUserRepository;
import com.gcp.domain.gcp.dto.ProjectZoneDto;
import com.gcp.domain.gcp.repository.GcpProjectRepository;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.cloud.compute.v1.Project;

import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.units.qual.A;

import org.json.JSONArray;
import org.json.JSONObject;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

import java.io.ByteArrayInputStream;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*;

@Service
Expand Down Expand Up @@ -54,8 +52,8 @@ public String startVM(String userId, String guildId, String vmName) {

return "🚀 `" + vmName + "` VM을 실행했습니다!";
} catch (Exception e) {
log.error("VM 실행 오류", e);
return "❌ `" + vmName + "` VM 실행 실패!";
log.error("VM 시작 오류", e);
throw new RuntimeException("Compute API (start) 호출 도중 에러 발생: ", e);
}
Comment on lines +55 to 57
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

중복적인 RuntimeException 래핑 패턴

모든 메서드에서 동일한 패턴으로 예외를 잡아 RuntimeException으로 다시 래핑하고 있습니다.

  • 예외 유형별로 구분이 사라져 상위 계층에서 세밀한 분기 처리가 어렵습니다.
  • 메시지가 한글 + 영문 혼용으로 중복되고 있어 유지보수가 힘듭니다.

서비스 전용 GcpApiException 같은 커스텀 런타임 예외를 정의하여 공통 정보를 담고, 필요 시 errorCode를 포함하도록 리팩터링을 권장합니다.

Also applies to: 76-79, 102-105, 169-172, 181-183, 198-204, 227-230, 268-271, 373-375, 418-420, 460-461, 486-487

🤖 Prompt for AI Agents
In src/main/java/com/gcp/domain/gcp/service/GcpService.java around lines 55-57
and similarly at lines 76-79, 102-105, 169-172, 181-183, 198-204, 227-230,
268-271, 373-375, 418-420, 460-461, and 486-487, the code repeatedly catches
exceptions and wraps them in RuntimeException with mixed language messages,
causing poor exception granularity and maintenance difficulty. Define a custom
runtime exception class named GcpApiException that can hold common error
information and optionally an errorCode. Replace all RuntimeException wrapping
with this custom exception, unify the error messages into a consistent format,
and use the custom exception to improve error handling and maintainability.

}

Expand All @@ -77,7 +75,7 @@ public String stopVM(String userId, String guildId, String vmName) {
return "🛑 `" + vmName + "` VM을 중지했습니다!";
} catch (Exception e) {
log.error("❌ VM 중지 오류", e);
return "❌ `" + vmName + "` VM 중지 실패!";
throw new RuntimeException("Compute API (stop) 호출 도중 에러 발생: ", e);
}
}

Expand All @@ -103,7 +101,7 @@ public String getInstanceId(String userId, String guildId, String vmName, String

} catch (Exception e) {
log.error("❌ instance_id 조회 실패", e);
return null;
throw new RuntimeException("Compute API (인스턴스 ID 조회) 호출 도중 에러 발생: ", e);
}
}

Expand All @@ -118,7 +116,7 @@ public List<String> getVmLogs(String userId, String guildId, String vmName) {

String vmId = getInstanceId(userId, guildId, vmName, ZONE);
if (vmId == null){
return List.of("❌ VM 인스턴스를 찾을 수 없습니다!");
throw new RuntimeException("현재 보유 중인 VM이 없습니다.");
}

String filter = String.format(
Expand Down Expand Up @@ -170,10 +168,7 @@ public List<String> getVmLogs(String userId, String guildId, String vmName) {

} catch (Exception e) {
log.error("❌ 로그 조회 오류", e);
List<String> errorMessage = new ArrayList<>();
errorMessage.add("❌ 로그 조회 실패!");

return errorMessage;
throw new RuntimeException("Logging API 호출 도중 에러 발생: ", e);
}
}

Expand All @@ -184,45 +179,55 @@ public String getEstimatedCost() {
return "💰 예상 비용: " + response;
} catch (Exception e) {
log.error("❌ 비용 조회 오류", e);
return "❌ 비용 조회 실패!";
throw new RuntimeException("CloudBilling API 호출 도중 에러 발생: ", e);
}
}

@SneakyThrows
public List<Map<String, String>> getVmList(String userId, String guildId) {
String url = String.format("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/instances",
PROJECT_ID, ZONE);
try {
String url = String.format("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/instances",
PROJECT_ID, ZONE);

String accessToken = discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId).orElseThrow();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
headers.setContentType(MediaType.APPLICATION_JSON);
String accessToken = discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId).orElseThrow();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
headers.setContentType(MediaType.APPLICATION_JSON);

HttpEntity<String> entity = new HttpEntity<>(null, headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
HttpEntity<String> entity = new HttpEntity<>(null, headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);

return parseVmResponse(response.getBody());
return parseVmResponse(response.getBody());
} catch (Exception e){
log.error("❌ VM 목록 조회 실패", e);
throw new RuntimeException("Compute API (인스턴스 목록 조회) 호출 도중 에러 발생: ", e);
}
}

public List<String> getProjectIds(String userId, String guildId) {
String url = "https://cloudresourcemanager.googleapis.com/v1/projects";
String accessToken = discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId).orElseThrow();
try {
String url = "https://cloudresourcemanager.googleapis.com/v1/projects";
String accessToken = discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId).orElseThrow();

HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
headers.setContentType(MediaType.APPLICATION_JSON);

HttpEntity<String> entity = new HttpEntity<>(null, headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
HttpEntity<String> entity = new HttpEntity<>(null, headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);

JSONObject json = new JSONObject(response.getBody());
JSONArray projects = json.getJSONArray("projects");
JSONObject json = new JSONObject(response.getBody());
JSONArray projects = json.getJSONArray("projects");

List<String> projectIds = new ArrayList<>();
for (int i = 0; i < projects.length(); i++) {
projectIds.add(projects.getJSONObject(i).getString("projectId"));
List<String> projectIds = new ArrayList<>();
for (int i = 0; i < projects.length(); i++) {
projectIds.add(projects.getJSONObject(i).getString("projectId"));
}
return projectIds;
} catch (Exception e) {
log.error("❌ 프로젝트 ID 조회 중 에러 발생", e);
throw new RuntimeException("CloudResourceManager API 호출 도중 에러 발생: ", e);
}
return projectIds;
}

public List<ProjectZoneDto> getActiveInstanceZones(String userId, String guildId) {
Expand Down Expand Up @@ -261,7 +266,8 @@ public List<ProjectZoneDto> getActiveInstanceZones(String userId, String guildId
activeZones.add(dto);

} catch (Exception e) {
log.warn("프로젝트 Zone 조회 실패 {}: {}", projectId, e.getMessage());
log.warn("❌ 프로젝트 Zone 조회 실패 {}", projectId, e);
throw new RuntimeException("Compute API (VM Zone 조회) 호출 도중 에러 발생: ", e);
}
}

Expand Down Expand Up @@ -365,7 +371,7 @@ public String createVM(String userId, String guildId, String vmName, String mach
);
} catch (Exception e) {
log.error("❌ VM 생성 오류", e);
return "❌ `" + vmName + "` VM 생성 실패!";
throw new RuntimeException("Compute API (인스턴스 생성) 호출 도중 에러 발생: ", e);
}
}
public List<Map<String, Object>> getFirewallRules(String userId, String guildId) {
Expand Down Expand Up @@ -410,7 +416,7 @@ public List<Map<String, Object>> getFirewallRules(String userId, String guildId)

} catch (Exception e) {
log.error("❌ 방화벽 규칙 조회 오류", e);
return List.of(Map.of("error", "방화벽 규칙 조회 실패"));
throw new RuntimeException("Compute API (방화벽 규칙 조회) 호출 도중 에러 발생: ", e);
}
}
public String createFirewallRule(String userId, String guildId, int port, List<String> sourceRanges) {
Expand Down Expand Up @@ -451,7 +457,7 @@ public String createFirewallRule(String userId, String guildId, int port, List<S
return "⚠️ 이미 포트 " + port + " 에 대한 방화벽 규칙이 존재합니다.";
} catch (Exception e) {
log.error("❌ 방화벽 규칙 생성 실패", e);
return "❌ 방화벽 규칙 생성 중 오류가 발생했습니다.";
throw new RuntimeException("Compute API (방화벽 규칙 생성) 호출 도중 에러 발생: ", e);
}
}

Expand All @@ -477,7 +483,7 @@ public String deleteFirewallRule(String userId, String guildId, int port) {
return "⚠️ 포트 " + port + " 에 대한 방화벽 규칙이 존재하지 않습니다.";
} catch (Exception e) {
log.error("❌ 방화벽 규칙 삭제 실패", e);
return "❌ 방화벽 규칙 삭제 중 오류가 발생했습니다.";
throw new RuntimeException("Compute API (방화벽 규칙 삭제) 호출 도중 에러 발생: ", e);
}
}
}