Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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: [feat/#11-get-projectid-zone]

Comment on lines 3 to 6
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

프로덕션 배포가 기능 브랜치 푸시마다 자동 실행됩니다 – 의도 및 위험도 확인 필요
현재 push 트리거를 feat/#11-get-projectid-zone 한정으로 변경하면서, 해당 브랜치에 커밋이 푸시될 때마다 CI·CD 파이프라인이 전체적으로 실행-되어 서버에 바로 배포까지 이뤄집니다.

  1. 기능 브랜치가 아직 PR 머지 전이라면, 검증되지 않은 코드가 실서버에 배포될 수 있는 위험이 있습니다.
  2. 브랜치명에 # 문자가 포함돼 있어 YAML 파서가 오해하지는 않지만, 가독성과 안전성을 위해 따옴표로 감싸는 편이 일반적입니다.

예시 수정안:

on:
  push:
    branches: ['feat/#11-get-projectid-zone']   # 또는 main / release/* 등으로 조정

의도적으로 임시 테스트 서버에만 배포하도록 설계한 것인지, 아니면 실서버 배포 워크플로우를 따로 두고 싶은 것인지 확인 부탁드립니다.


🏁 Script executed:

#!/bin/bash
# Show the beginning of the deploy workflow to verify trigger settings
sed -n '1,20p' .github/workflows/deploy.yml

Length of output: 353


🏁 Script executed:

#!/bin/bash
# Display full workflow file to inspect deploy steps
sed -n '1,200p' .github/workflows/deploy.yml

Length of output: 3368


프로덕션 배포 워크플로우 트리거 설정 검토 및 수정 필요

.github/workflows/deploy.ymlon.push.branchesfeat/#11-get-projectid-zone 한정으로 설정되어 있어 해당 브랜치에 푸시될 때마다 CI·CD(빌드 → SSH 배포) 전체가 실행됩니다.
이로 인해 다음과 같은 위험이 있습니다.

  • 아직 PR 머지 전인 기능 브랜치의 검증되지 않은 코드가 실서버에 즉시 배포될 수 있음
  • 향후 다른 기능 브랜치에도 동일한 실수로 배포 트리거가 설정될 우려

또한, 브랜치명에 # 문자가 포함돼 있을 경우 YAML 가독성과 안전성을 위해 다음과 같이 따옴표로 감싸는 것을 권장드립니다.

수정 예시:

 on:
   push:
-    branches: [feat/#11-get-projectid-zone]
+    branches: ['feat/#11-get-projectid-zone']   # 또는 ['main'], ['release/*'] 등으로 변경

▸ 의도하신 바가

  1. 기능 브랜치 푸시 시 실서버 배포인가,
  2. 임시 테스트 환경용 워크플로우인가
    인지 확인 후,
  • 프로덕션 배포는 main/release/* 전용으로 분리
  • 기능 브랜치는 테스트 전용 워크플로우 생성

두 가지 중 하나로 설정을 명확히 분리해 주시기 바랍니다.

🤖 Prompt for AI Agents
In .github/workflows/deploy.yml around lines 3 to 6, the push trigger is set
only for the feature branch 'feat/#11-get-projectid-zone', which risks deploying
unverified code to production. To fix this, confirm whether this workflow is for
production deployment or testing. If for production, change the trigger branches
to only 'main' and 'release/*' branches and remove the feature branch. If for
testing, create a separate workflow for feature branches. Also, enclose branch
names containing '#' in quotes for YAML safety and readability.

jobs:
CI:
Expand Down
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ dependencies {
implementation 'com.google.cloud:google-cloud-compute:1.14.0' // Compute Engine API
implementation 'com.google.cloud:google-cloud-billing' // GCP Billing API
implementation 'com.google.cloud:google-cloud-monitoring' // Cloud Monitoring API
implementation "com.google.cloud:google-cloud-resourcemanager"
implementation "com.google.cloud:google-cloud-logging"


implementation 'com.google.api:gax-httpjson:0.107.0'
implementation 'com.google.auth:google-auth-library-oauth2-http' // GCP 인증 라이브러리
implementation 'org.springframework.boot:spring-boot-starter-web' // Spring Boot 웹 서버
Expand All @@ -39,7 +43,6 @@ dependencies {

implementation 'org.apache.httpcomponents:httpclient:4.5.13'

implementation 'com.google.cloud:google-cloud-logging:3.21.3'


implementation 'com.google.code.gson:gson' // JSON 처리
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ public interface DiscordUserRepository extends JpaRepository<DiscordUser, Long>
Optional<DiscordUser> findByUserIdAndGuildId(@Param("userId") String userId,
@Param("guildId") String guildId);


boolean existsByUserIdAndGuildId(String userId, String guildId);



@Query("SELECT u FROM DiscordUser u WHERE u.googleAccessToken = :googleAccessToken")
Optional<DiscordUser> findByGoogleAccessToken(@Param("googleAccessToken") String googleAccessToken);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
public class DiscordUserService {
private final DiscordUserRepository discordUserRepository;

//

public boolean insertDiscordUser(String userId, String userName, String guildId, String guildName){
if (discordUserRepository.findByUserIdAndGuildId(userId, guildId).isEmpty()) {
if (discordUserRepository.existsByUserIdAndGuildId(userId, guildId)) {
DiscordUser discordUser = new DiscordUser(userId, userName, guildId, guildName);
discordUserRepository.save(discordUser);
return true;
Expand Down
54 changes: 53 additions & 1 deletion src/main/java/com/gcp/domain/discord/service/GcpBotService.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.gcp.domain.discord.service;

import com.gcp.domain.gcp.dto.ProjectZoneDto;
import com.gcp.domain.gcp.service.GcpProjectCommandService;
import com.gcp.domain.gcp.service.GcpService;
import lombok.RequiredArgsConstructor;
import net.dv8tion.jda.api.entities.Guild;
Expand All @@ -15,12 +17,14 @@
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class GcpBotService extends ListenerAdapter {
private final GcpService gcpService;
private final DiscordUserService discordUserService;
private final GcpProjectCommandService gcpProjectCommandService;

@Override
public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
Expand All @@ -46,7 +50,21 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
: userName + "님은 " + guildName + "에 이미 등록되어 있습니다.";
event.reply(responseMsg).queue();
}
case "register" -> {

case "project-list" -> {
List<String> userProjectIds = gcpService.getProjectIds(userId, guildId);
if (userProjectIds.isEmpty()) {
event.reply("📭 참여 중인 프로젝트가 없습니다.").queue();
} else {
String message = "📦 **참여 중인 프로젝트 목록**\n" +
userProjectIds.stream()
.map(id -> "• " + id)
.collect(Collectors.joining("\n"));
event.reply(message).queue();
}
}

case "login" -> {
String userProfile = Optional.ofNullable(author.getAvatarUrl())
.orElse(author.getDefaultAvatarUrl());

Expand All @@ -72,6 +90,40 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {

event.reply("👇 아래 링크를 클릭해서 Google 계정을 연결해주세요:\n" + redirectUri).queue();
}

case "project-register" -> {
try{
String projectId = getRequiredOption(event, "project_id");
gcpProjectCommandService.insertNewGcpProject(userId, guildId, projectId);
event.reply("프로젝트가 등록되었습니다.").queue();
} catch (RuntimeException e){
event.reply(e.getMessage()).queue();
}
}

case "zone-list" -> {
try {
List<ProjectZoneDto> result = gcpService.getActiveInstanceZones(userId, guildId);

StringBuilder message = new StringBuilder("📦 **프로젝트별 인스턴스 활성 ZONE 목록**\n\n");
for (ProjectZoneDto dto : result) {
message.append("🔹 **")
.append(dto.projectId())
.append("**\n");

for (String zone : dto.zoneList()) {
message.append("↳ ").append(zone).append("\n");
}

message.append("\n");
}

event.reply(message.toString()).queue();
} catch (Exception e) {
event.reply("❌ 오류 발생: " + e.getMessage()).queue();
}
}

case "start" -> {
String vmName = getRequiredOption(event, "vm_name");
event.reply(gcpService.startVM(userId, guildId, vmName)).queue();
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/gcp/domain/gcp/dto/ProjectZoneDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.gcp.domain.gcp.dto;

import lombok.Builder;

import java.util.List;

@Builder
public record ProjectZoneDto(
String projectId, List<String> zoneList
) {
}
17 changes: 9 additions & 8 deletions src/main/java/com/gcp/domain/gcp/entity/GcpProject.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,30 @@

import com.gcp.domain.discord.entity.DiscordUser;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.*;


@Entity
@Table(name = "gcp_project")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GcpProject {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String projectId;
private String zone;

@Column(columnDefinition = "TEXT")
private String credentialsJson;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private DiscordUser discordUser;

public static GcpProject create(String projectId, DiscordUser discordUser) {
return GcpProject.builder()
.projectId(projectId)
.discordUser(discordUser)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@
import com.gcp.domain.gcp.entity.GcpProject;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface GcpProjectRepository extends JpaRepository<GcpProject, Long> {

@Query("SELECT p FROM GcpProject p WHERE p.discordUser = :discordUser")
Optional<GcpProject> findByDiscordUser(DiscordUser discordUser);

@Query("SELECT p.projectId FROM GcpProject p WHERE p.discordUser = :discordUser")
Optional<List<String>> findAllProjectIdsByDiscordUser(DiscordUser discordUser);
Comment on lines +19 to +20
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

쿼리 메서드 구현을 검토해주세요.

findAllProjectIdsByDiscordUser 메서드에서 Optional<List<String>> 반환 타입이 적절한지 확인이 필요합니다. 일반적으로 컬렉션 반환 시에는 빈 리스트를 반환하는 것이 Optional보다 더 적절할 수 있습니다.

다음 스크립트로 이 메서드의 사용법을 확인하여 Optional 래핑이 필요한지 검증해보겠습니다:


🏁 Script executed:

#!/bin/bash
# Description: GcpProjectRepository의 findAllProjectIdsByDiscordUser 메서드 사용 패턴 확인

# 서비스 레이어에서 이 메서드를 어떻게 사용하는지 확인
rg -A 10 "findAllProjectIdsByDiscordUser"

Length of output: 1680


컬렉션 반환 시 Optional 제거 및 빈 리스트 처리 권장

findAllProjectIdsByDiscordUserOptional<List<String>>을 반환하도록 구현되어 있는데, JPA 쿼리 메서드는 기본적으로 결과가 없을 때 빈 컬렉션을 반환하므로 Optional로 래핑할 필요가 없습니다. 또한, 서비스 레이어에서 .orElseThrow()로 예외를 던지는 대신 빈 리스트 여부를 확인하고 사용자 친화적인 메시지를 반환하는 편이 이 프로젝트의 정책과도 일치합니다.

수정이 필요한 위치:

  • src/main/java/com/gcp/domain/gcp/repository/GcpProjectRepository.java
    • 메서드 시그니처를 Optional<List<String>>List<String> 으로 변경
  • src/main/java/com/gcp/domain/gcp/service/GcpService.java
    findAllProjectIdsByDiscordUser(...).orElseThrow() 호출을 findAllProjectIdsByDiscordUser(...)로 변경
    • 반환된 리스트가 비었을 때 사용자 안내 메시지를 직접 반환하도록 로직 추가

예시 diff:

--- a/src/main/java/com/gcp/domain/gcp/repository/GcpProjectRepository.java
+++ b/src/main/java/com/gcp/domain/gcp/repository/GcpProjectRepository.java
-    @Query("SELECT p.projectId FROM GcpProject p WHERE p.discordUser = :discordUser")
-    Optional<List<String>> findAllProjectIdsByDiscordUser(DiscordUser discordUser);
+    @Query("SELECT p.projectId FROM GcpProject p WHERE p.discordUser = :discordUser")
+    List<String> findAllProjectIdsByDiscordUser(DiscordUser discordUser);
--- a/src/main/java/com/gcp/domain/gcp/service/GcpService.java
+++ b/src/main/java/com/gcp/domain/gcp/service/GcpService.java
-        List<String> projectIds = gcpProjectRepository.findAllProjectIdsByDiscordUser(discordUser).orElseThrow();
+        List<String> projectIds = gcpProjectRepository.findAllProjectIdsByDiscordUser(discordUser);
+        if (projectIds.isEmpty()) {
+            return new ResponseEntity<>("등록된 프로젝트가 없습니다. 먼저 프로젝트를 등록해주세요.", HttpStatus.OK);
+        }

이렇게 변경하면 불필요한 Optional 래핑과 예외 던짐을 제거하고, 조회 결과가 없을 때 사용자에게 친숙한 안내를 제공할 수 있습니다.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/com/gcp/domain/gcp/repository/GcpProjectRepository.java around
lines 19-20, change the method signature of findAllProjectIdsByDiscordUser from
Optional<List<String>> to just List<String> because JPA returns an empty list
when no results are found, so Optional is unnecessary. Then, in
src/main/java/com/gcp/domain/gcp/service/GcpService.java, update calls to this
method by removing .orElseThrow() and instead add logic to check if the returned
list is empty and return a user-friendly message accordingly. This removes
redundant Optional wrapping and exception throwing while improving user feedback
on empty results.


boolean existsByProjectIdAndDiscordUser(String projectId, DiscordUser discordUser);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.gcp.domain.gcp.service;

public interface GcpProjectCommandService {
void insertNewGcpProject(String userId, String guildId, String projectId);
}
Comment on lines +3 to +5
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

인터페이스 설계가 깔끔하지만 반환 타입과 예외 처리를 고려해보세요.

현재 void 반환 타입은 작업 성공/실패 여부를 호출자가 알 수 없게 합니다. Discord 봇 서비스에서는 사용자에게 적절한 피드백을 제공해야 하므로, 작업 결과를 나타내는 반환 타입을 고려해보세요.

다음과 같은 개선을 제안합니다:

 public interface GcpProjectCommandService {
-    void insertNewGcpProject(String userId, String guildId, String projectId);
+    boolean insertNewGcpProject(String userId, String guildId, String projectId);
 }

또는 더 명확한 결과 타입:

public interface GcpProjectCommandService {
    ProjectRegistrationResult registerProject(String userId, String guildId, String projectId);
}
🤖 Prompt for AI Agents
In src/main/java/com/gcp/domain/gcp/service/GcpProjectCommandService.java around
lines 3 to 5, the interface method currently returns void, which does not allow
the caller to know if the operation succeeded or failed. Change the method to
return a result type that clearly indicates success or failure, such as a custom
ProjectRegistrationResult class. Also, consider adding appropriate exception
handling or declaring exceptions in the method signature to handle error cases
explicitly.

Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.gcp.domain.gcp.service;

import com.gcp.domain.discord.entity.DiscordUser;
import com.gcp.domain.discord.repository.DiscordUserRepository;
import com.gcp.domain.gcp.entity.GcpProject;
import com.gcp.domain.gcp.repository.GcpProjectRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class GcpProjectCommandServiceImpl implements GcpProjectCommandService{

private final GcpProjectRepository gcpProjectRepository;
private final DiscordUserRepository discordUserRepository;

@Override
public void insertNewGcpProject(String userId, String guildId, String projectId) {
DiscordUser discordUser = discordUserRepository.findByUserIdAndGuildId(userId, guildId).orElseThrow(
() -> new RuntimeException("해당 사용자를 찾을 수 없습니다. /gcp init 명령어를 먼저 실행해주세요.")
);

if(!gcpProjectRepository.existsByProjectIdAndDiscordUser(projectId, discordUser)){
GcpProject gcpProject = GcpProject.create(projectId, discordUser);
gcpProjectRepository.save(gcpProject);
} else{
throw new RuntimeException("이미 등록된 프로젝트 입니다.");
}
}
}
75 changes: 73 additions & 2 deletions src/main/java/com/gcp/domain/gcp/service/GcpService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gcp.domain.discord.entity.DiscordUser;
import com.gcp.domain.discord.repository.DiscordUserRepository;
import com.gcp.domain.gcp.dto.ProjectZoneDto;
import com.gcp.domain.gcp.repository.GcpProjectRepository;
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.RestTemplate;
Expand All @@ -20,12 +27,11 @@ public class GcpService {

private final RestTemplate restTemplate = new RestTemplate();
private final DiscordUserRepository discordUserRepository;
private final GcpProjectRepository gcpProjectRepository;
private static final String ZONE = "us-central1-f";
private static final String PROJECT_ID = "sincere-elixir-464606-j1";
Comment on lines 31 to 32
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

하드코딩된 PROJECT_ID와 ZONE 상수 제거 필요

PR 목적에 따르면 하드코딩된 값을 동적 조회로 대체해야 하는데, 여전히 PROJECT_ID와 ZONE 상수가 여러 메서드에서 사용되고 있습니다. 새로 추가된 프로젝트 등록 및 zone 조회 기능을 활용하도록 수정이 필요합니다.


🏁 Script executed:

#!/bin/bash
# PROJECT_ID와 ZONE 상수를 사용하는 메서드 확인
echo "=== PROJECT_ID 사용 위치 ==="
rg "PROJECT_ID" src/main/java/com/gcp/domain/gcp/service/GcpService.java -A 1

echo -e "\n=== ZONE 사용 위치 ==="
rg "ZONE" src/main/java/com/gcp/domain/gcp/service/GcpService.java -A 1

Length of output: 1816


하드코딩된 PROJECT_ID 및 ZONE 상수 제거 및 동적 조회 적용 필요

PR 목표에 따라 PROJECT_IDZONE 상수를 직접 사용하는 부분을 모두 새로 추가된 동적 조회 기능으로 대체해야 합니다. 아래 위치를 점검하고, 프로젝트 등록 서비스 및 zone 조회 로직을 활용해 상수를 제거해 주세요.

• 상수 선언

  • src/main/java/com/gcp/domain/gcp/service/GcpService.java:
    private static final String PROJECT_ID = "...";
    private static final String ZONE = "...";

• VM 제어 메서드

  • startVM/stopVM: PROJECT_ID, ZONE, vmName 파라미터 전달
  • getInstanceId 호출 시 ZONE 하드코드

• Billing API 호출

  • URL: https://cloudbilling.googleapis.com/v1/projects/%s/billingInfo (PROJECT_ID 하드코드)

• Compute Engine 인스턴스 조회

  • URL: https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/instances (PROJECT_ID, ZONE 하드코드)

• Machine Type 지정

  • String.format("zones/%s/machineTypes/%s", ZONE, machineType)

위 모든 부분에서 신규 등록된 프로젝트 조회 로직과 zone 조회 메서드를 사용하도록 리팩터링해주세요.

🤖 Prompt for AI Agents
In src/main/java/com/gcp/domain/gcp/service/GcpService.java around lines 31-32,
remove the hardcoded PROJECT_ID and ZONE constants and replace all their usages
with calls to the newly added dynamic project and zone retrieval methods.
Refactor startVM, stopVM, and getInstanceId methods to accept project ID and
zone dynamically instead of using constants. Update all API URLs and machine
type strings to use these dynamic values by invoking the project registration
service and zone lookup logic instead of hardcoded strings.





public String startVM(String userId, String guildId, String vmName) {
try {
String url = String.format(
Expand Down Expand Up @@ -194,6 +200,71 @@ public List<Map<String, String>> getVmList(String userId, String guildId) {
return parseVmResponse(response.getBody());
}

public List<String> getProjectIds(String userId, String guildId) {
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);

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");

List<String> projectIds = new ArrayList<>();
for (int i = 0; i < projects.length(); i++) {
projectIds.add(projects.getJSONObject(i).getString("projectId"));
}
return projectIds;
}
Comment on lines +203 to +222
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

오류 처리 개선 필요

이전 학습 내용에 따르면, Discord 봇 서비스에서는 orElseThrow() 대신 Optional.isEmpty() 체크 후 사용자 친화적인 메시지 반환을 선호합니다. 또한 API 호출 실패나 JSON 파싱 오류에 대한 처리가 필요합니다.

 public List<String> getProjectIds(String userId, String guildId) {
-    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);
-
-    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");
-
-    List<String> projectIds = new ArrayList<>();
-    for (int i = 0; i < projects.length(); i++) {
-        projectIds.add(projects.getJSONObject(i).getString("projectId"));
+    try {
+        String url = "https://cloudresourcemanager.googleapis.com/v1/projects";
+        Optional<String> accessTokenOpt = discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId);
+        if (accessTokenOpt.isEmpty()) {
+            log.warn("Access token not found for user: {} in guild: {}", userId, guildId);
+            return Collections.emptyList();
+        }
+        
+        String accessToken = accessTokenOpt.get();
+        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);
+
+        JSONObject json = new JSONObject(response.getBody());
+        JSONArray projects = json.optJSONArray("projects");
+        
+        if (projects == null) {
+            return Collections.emptyList();
+        }
+
+        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("Failed to get project IDs for user: {} in guild: {}", userId, guildId, e);
+        return Collections.emptyList();
     }
-    return projectIds;
 }

다음 스크립트로 다른 메서드들의 오류 처리 패턴을 확인해보겠습니다:


🏁 Script executed:

#!/bin/bash
# GcpService 클래스에서 orElseThrow() 사용 패턴 확인
rg -A 3 "orElseThrow\(\)" src/main/java/com/gcp/domain/gcp/service/GcpService.java

Length of output: 2369


GcpService.java – 토큰 조회 Optional.isEmpty() 적용 및 전체 메서드 오류 처리 보강

GcpService 클래스 내
discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId).orElseThrow()
호출부(총 7곳)에서 Optional.isEmpty() 체크 후 사용자 친화적 반환/경고 로직으로 변경해야 합니다. 또한 모든 HTTP 호출부(RestTemplate.exchange 등)에 try/catch를 적용해 예외 발생 시 빈 리스트 반환 또는 적절한 로그/메시지 처리로 보강하세요.

• getProjectIds (205–224)
• Compute 인스턴스 조회·조작 메서드
• RestTemplate 사용 메서드 일체

예시 수정안:

- String accessToken = discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId).orElseThrow();
+ Optional<String> accessTokenOpt = discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId);
+ if (accessTokenOpt.isEmpty()) {
+     log.warn("Access token 미발견: userId={}, guildId={}", userId, guildId);
+     return Collections.emptyList();
+ }
+ String accessToken = accessTokenOpt.get();

추가로 JSON 파싱 시 optJSONArray 사용 및 전체 API 호출부에 try/catch로 예외를 안전하게 처리해 주세요.

🤖 Prompt for AI Agents
In src/main/java/com/gcp/domain/gcp/service/GcpService.java lines 205 to 224,
update the getProjectIds method to first check if the Optional from
discordUserRepository.findAccessTokenByUserIdAndGuildId is empty using
isEmpty(), and handle this case by returning a user-friendly response or logging
a warning instead of directly calling orElseThrow. Wrap the entire HTTP call
using restTemplate.exchange and subsequent JSON parsing in a try/catch block to
catch exceptions, log errors appropriately, and return an empty list if any
exception occurs. Replace the use of getJSONArray with optJSONArray to safely
handle missing or null JSON arrays during parsing. Apply similar Optional checks
and try/catch exception handling to all other methods using RestTemplate calls
in this class.


public List<ProjectZoneDto> getActiveInstanceZones(String userId, String guildId) {
String accessToken = discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId).orElseThrow();
DiscordUser discordUser = discordUserRepository.findByUserIdAndGuildId(userId, guildId).orElseThrow();
List<String> projectIds = gcpProjectRepository.findAllProjectIdsByDiscordUser(discordUser).orElseThrow();

List<ProjectZoneDto> activeZones = new ArrayList<>();

for (String projectId : projectIds) {
try {
String url = String.format("https://compute.googleapis.com/compute/v1/projects/%s/aggregated/instances", projectId);

HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<Void> entity = new HttpEntity<>(headers);

ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
JSONObject json = new JSONObject(response.getBody());
JSONObject items = json.getJSONObject("items");

List<String> zoneNames = new ArrayList<>();

for (String key : items.keySet()) {
JSONObject zoneInfo = items.getJSONObject(key);
if (zoneInfo.has("instances") && key.startsWith("zones/")) {
String zoneName = key.substring("zones/".length());
zoneNames.add(zoneName);
}
}

ProjectZoneDto dto = ProjectZoneDto.builder()
.projectId(projectId)
.zoneList(zoneNames)
.build();
activeZones.add(dto);

} catch (Exception e) {
log.warn("프로젝트 Zone 조회 실패 {}: {}", projectId, e.getMessage());
}
}

return activeZones;
}


private static List<Map<String, String>> parseVmResponse(String json) throws IOException {
List<Map<String, String>> vmList = new ArrayList<>();
ObjectMapper objectMapper = new ObjectMapper();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
}

DiscordUser discordUser = discordUserRepository.findByUserIdAndGuildId(userId, guildId)
.orElseThrow(() -> new IllegalStateException(
String.format("DiscordUser를 찾을 수 없습니다: userId=%s, guildId=%s", userId, guildId)));
.orElseThrow(() -> new IllegalStateException(
String.format("DiscordUser를 찾을 수 없습니다: userId=%s, guildId=%s", userId, guildId)));

String googleAccessToken = client.getAccessToken().getTokenValue();
String googleRefreshToken = client.getRefreshToken().getTokenValue();
Expand Down
6 changes: 5 additions & 1 deletion src/main/java/com/gcp/global/config/DiscordBotConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ public JDA jda() throws Exception {
Commands.slash("gcp", "GCP 관련 명령어")
.addSubcommands(
new SubcommandData("init", "디스코드 유저 등록"),
new SubcommandData("register", "Google 계정 연동"),
new SubcommandData("login", "Google 계정 연동"),
new SubcommandData("project-list", "소속 프로젝트 ID 목록 조회"),
new SubcommandData("project-register", "프로젝트 ID를 서버에 등록")
.addOption(OptionType.STRING, "project_id", "등록하고자 하는 프로젝트 ID", true),
new SubcommandData("zone-list", "프로젝트 내 VM Zone 목록 조회"),
new SubcommandData("start", "VM 시작")
.addOption(OptionType.STRING, "vm_name", "시작할 VM 이름", true),
new SubcommandData("stop", "VM 정지")
Expand Down