Repository: sopt-makers/sentry-notifier
Pull request #8: fix: .env 파일을 로드하도록 수정 완료
Author: login: move-hoon
Labels: name: fix
name: 3️⃣ patch
name: size/M
Created: 2025-04-21T12:28:59Z | Merged: 2025-04-21T12:53:06Z

## Related issue 🛠

<!-- 관련 이슈 번호를 적어주세요 -->
- closes #7 

## Work Description ✏️

<!-- 작업 내용을 간단히 소개주세요 -->
### JAR 파일에서 .env의 위치 확인 후 해당 경로(루트)에서 읽도록 수정
- JAR 빌드 시 src/main/resources/.env 파일은 Gradle 리소스 처리 방식에 따라 JAR 루트 디렉토리로 복사되는 것을 알게되었습니다.
- 이로 인해 JAR 내부 구조에서는 .env가 classpath:/에 위치하게 되며, directory("src/main/resources")로 설정 시 해당 파일을 찾지 못했습니다.
- 따라서  Dotenv.configure().load()로 수정하여 루트 디렉토리에서 .env 파일을 읽도록 수정했습니다.


## Trouble Shooting ⚽️

<!-- 어떤 위험이나 장애를 발견했는지 적어주세요 -->

### 테스트에서 getWebhookUrl 메서드를 호출하며 발생한 문제
- 테스트 코드에서는 .env 파일의 위치를 src/test/resources로 지정하였습니다.
- 그러나 테스트 코드에서 EnvUtil.getWebhookUrl을 호출할 경우, 해당 메서드 내부의 dotenv.get(envKey) 로직은 기본적으로 루트 디렉토리의 .env 파일을 참조하게 되어, 테스트가 실패하는 문제가 발생했습니다.
- 이를 해결하기 위해, EnvUtil과 동일한 로직을 가진 테스트 전용 유틸리티 클래스인 TestEnvUtil을 새로 작성하였고, 해당 클래스에서는 .env 파일의 경로를 src/test/resources로 명시하여 테스트 환경에서도 정상적으로 .env 파일을 읽어올 수 있도록 설정하였습니다.


Linked issue #7: bug: JAR 빌드 환경에서 .env 로드 실패 이슈
## Bug/Fix Issue 📌  
```java
Dotenv.configure()
  .directory("src/main/resources")
  .load();
```
로 설정되어 있어 `.env` 파일이 JAR 실행 환경에서 로드되지 않는 문제가 발생하였습니다.

- JAR 빌드 시 `src/main/resources/.env` 파일은 Gradle 리소스 처리 방식에 따라 **JAR 루트 디렉토리**로 복사되는 것을 알게되었습니다.
- 이로 인해 JAR 내부 구조에서는 `.env`가 `classpath:/`에 위치하게 되며, `directory("src/main/resources")`로 설정 시 해당 파일을 찾지 못했던 것입니다.
- 이 문제는 다음 명령어로 JAR 내부 구조를 확인하여 발견할 수 있었습니다.

```bash
jar tf build/libs/sentry-webhook-shadow.jar | grep '.env'
```


## Todo ✔️
- [x] Dotenv.configure().directory("src/main/resources") → Dotenv.configure().load()로 수정하여 classpath 상의 .env를 로드하도록 변경

Diff:
diff --git a/src/main/java/org/sopt/makers/global/util/EnvUtil.java b/src/main/java/org/sopt/makers/global/util/EnvUtil.java
index f2ed147..170a9a3 100644
--- a/src/main/java/org/sopt/makers/global/util/EnvUtil.java
+++ b/src/main/java/org/sopt/makers/global/util/EnvUtil.java
@@ -15,9 +15,7 @@
 public final class EnvUtil {
 	private static final String SLACK_WEBHOOK_PREFIX = "SLACK_WEBHOOK_";
 	private static final String DISCORD_WEBHOOK_PREFIX = "DISCORD_WEBHOOK_";
-	private static final Dotenv dotenv = Dotenv.configure()
-		.directory("src/main/resources")  // .env 파일 경로 지정
-		.load();
+	private static final Dotenv dotenv = Dotenv.configure().load();
 
 	/**
 	 * 서비스 유형에 맞는 웹훅 URL 반환
diff --git a/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java b/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java
index 9bc85e2..0a00eb6 100644
--- a/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java
+++ b/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java
@@ -22,7 +22,7 @@
 @DisplayName("EnvUtil 테스트")
 class EnvUtilTest {
 
-	private static final String ENV_FILE_PATH = "src/main/resources/.env";
+	private static final String ENV_FILE_PATH = "src/test/resources/.env";
 
 	@BeforeAll
 	static void setUp() throws IOException {
@@ -30,7 +30,7 @@ static void setUp() throws IOException {
                 SLACK_WEBHOOK_CREW_DEV_BE=https://hooks.slack.com/services/crew/dev/be
                 SLACK_WEBHOOK_APP_PROD_FE=https://hooks.slack.com/services/app/prod/fe
                 """;
-		Files.createDirectories(Paths.get("src/main/resources"));
+		Files.createDirectories(Paths.get("src/test/resources"));
 		Files.write(Paths.get(ENV_FILE_PATH), content.getBytes());
 	}
 
@@ -43,7 +43,7 @@ static void tearDown() throws IOException {
 	@Test
 	void testGetWebhookUrl_valid() {
 		String expectedUrl = "https://hooks.slack.com/services/crew/dev/be";
-		String actualUrl = EnvUtil.getWebhookUrl("slack", "crew", "dev", "be");
+		String actualUrl = TestEnvUtil.getWebhookUrl("slack", "crew", "dev", "be");
 
 		assertEquals(expectedUrl, actualUrl);
 	}
@@ -52,7 +52,7 @@ void testGetWebhookUrl_valid() {
 	@Test
 	void testGetWebhookUrl_notFound() {
 		WebhookUrlNotFoundException exception = assertThrows(WebhookUrlNotFoundException.class, () ->
-			EnvUtil.getWebhookUrl("slack", "crew", "prod", "be")
+			TestEnvUtil.getWebhookUrl("slack", "crew", "prod", "be")
 		);
 
 		assertTrue(exception.getMessage().contains("Webhook URL을 찾을 수 없습니다."));
@@ -62,7 +62,7 @@ void testGetWebhookUrl_notFound() {
 	@Test
 	void testGetWebhookUrl_unsupportedServiceType() {
 		assertThrows(UnsupportedServiceTypeException.class, () ->
-			EnvUtil.getWebhookUrl("telegram", "crew", "dev", "be")
+			TestEnvUtil.getWebhookUrl("telegram", "crew", "dev", "be")
 		);
 	}
 
@@ -71,7 +71,7 @@ void testGetWebhookUrl_unsupportedServiceType() {
 	@MethodSource("provideNullParameters")
 	void testGetWebhookUrl_withNullParameters_shouldThrow(String service, String team, String stage, String type) {
 		assertThrows(InvalidEnvParameterException.class, () ->
-			EnvUtil.getWebhookUrl(service, team, stage, type)
+			TestEnvUtil.getWebhookUrl(service, team, stage, type)
 		);
 	}
 
@@ -87,12 +87,12 @@ void testGetWebhookUrl_withNullParameters_shouldThrow(String service, String tea
 	})
 	void testGetWebhookUrl_caseInsensitive(String service, String team, String stage, String type) {
 		if (team.equalsIgnoreCase("app")) {
-			String actualUrl = EnvUtil.getWebhookUrl(service, team, stage, type);
+			String actualUrl = TestEnvUtil.getWebhookUrl(service, team, stage, type);
 			assertEquals("https://hooks.slack.com/services/app/prod/fe", actualUrl);
 			return;
 		}
 
-		String actualUrl = EnvUtil.getWebhookUrl(service, team, stage, type);
+		String actualUrl = TestEnvUtil.getWebhookUrl(service, team, stage, type);
 		assertEquals("https://hooks.slack.com/services/crew/dev/be", actualUrl);
 	}
 	private static Stream<Arguments> provideNullParameters() {
diff --git a/src/test/java/org/sopt/makers/global/util/TestEnvUtil.java b/src/test/java/org/sopt/makers/global/util/TestEnvUtil.java
new file mode 100644
index 0000000..2c8302b
--- /dev/null
+++ b/src/test/java/org/sopt/makers/global/util/TestEnvUtil.java
@@ -0,0 +1,56 @@
+package org.sopt.makers.global.util;
+
+import org.sopt.makers.global.exception.message.ErrorMessage;
+import org.sopt.makers.global.exception.unchecked.InvalidEnvParameterException;
+import org.sopt.makers.global.exception.unchecked.UnsupportedServiceTypeException;
+import org.sopt.makers.global.exception.unchecked.WebhookUrlNotFoundException;
+
+import io.github.cdimascio.dotenv.Dotenv;
+
+class TestEnvUtil {
+	private static final String SLACK_WEBHOOK_PREFIX = "SLACK_WEBHOOK_";
+	private static final String DISCORD_WEBHOOK_PREFIX = "DISCORD_WEBHOOK_";
+	private static final Dotenv dotenv = Dotenv.configure()
+		.directory("src/test/resources")
+		.load();
+
+	/**
+	 * 서비스 유형에 맞는 웹훅 URL 반환
+	 *
+	 * @param service 서비스 유형 (slack, discord 등)
+	 * @param team 팀 이름 (crew, app 등)
+	 * @param stage 환경 (dev, prod)
+	 * @param type 서버 유형 (be, fe)
+	 * @return 웹훅 URL
+	 * @throws UnsupportedServiceTypeException 지원하지 않는 서비스 유형인 경우
+	 * @throws WebhookUrlNotFoundException 환경 변수를 찾을 수 없는 경우
+	 */
+	public static String getWebhookUrl(String service, String team, String stage, String type) {
+		if (service == null || team == null || stage == null || type == null) {
+			throw InvalidEnvParameterException.from(ErrorMessage.INVALID_ENV_PARAMETER);
+		}
+
+		String prefix = resolvePrefix(service.toLowerCase());
+		String envKey = String.format("%s%s_%s_%s",
+			prefix,
+			team.toUpperCase(),
+			stage.toUpperCase(),
+			type.toUpperCase());
+
+		String webhookUrl = dotenv.get(envKey);
+
+		if (webhookUrl == null || webhookUrl.isBlank()) {
+			throw new WebhookUrlNotFoundException(ErrorMessage.WEBHOOK_URL_NOT_FOUND);
+		}
+
+		return webhookUrl;
+	}
+
+	private static String resolvePrefix(String service) {
+		return switch (service) {
+			case "slack" -> SLACK_WEBHOOK_PREFIX;
+			case "discord" -> DISCORD_WEBHOOK_PREFIX;
+			default -> throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE);
+		};
+	}
+}


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between ed577ee2f362186967f348359c14134a57b1ae4a and 6e2f493cc37fc59d0d62eed1952c7f9ec0bb6311.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/main/java/org/sopt/makers/global/util/EnvUtil.java` (1 hunks)
* `src/test/java/org/sopt/makers/global/util/EnvUtilTest.java` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 GitHub Actions: sentry-notifier-lambda-ci</summary>

<details>
<summary>src/test/java/org/sopt/makers/global/util/EnvUtilTest.java</summary>

[error] 54-54: Test failure: Exception thrown when querying Webhook URL with a non-existent key (org.opentest4j.AssertionFailedError).

---

[error] 48-48: Test failure: Failed to retrieve Webhook URL from a valid key (org.opentest4j.AssertionFailedError).

---

[error] 96-96: Test failure: Case-insensitive key recognition failed (org.opentest4j.AssertionFailedError).

---

[error] 96-96: Test failure: Case-insensitive key recognition failed (org.opentest4j.AssertionFailedError).

---

[error] 96-96: Test failure: Case-insensitive key recognition failed (org.opentest4j.AssertionFailedError).

---

[error] 90-90: Test failure: WebhookUrlNotFoundException thrown for case-insensitive key recognition (org.sopt.makers.global.exception.unchecked.WebhookUrlNotFoundException).

---

[error] 90-90: Test failure: WebhookUrlNotFoundException thrown for case-insensitive key recognition (org.sopt.makers.global.exception.unchecked.WebhookUrlNotFoundException).

---

[error] 90-90: Test failure: WebhookUrlNotFoundException thrown for case-insensitive key recognition (org.sopt.makers.global.exception.unchecked.WebhookUrlNotFoundException).

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (2)</summary><blockquote>

<details>
<summary>src/main/java/org/sopt/makers/global/util/EnvUtil.java (1)</summary>

`18-18`: <details>
<summary>❓ Verification inconclusive</summary>

**Dotenv 구성이 단순화되었습니다.**

Dotenv의 초기화 방식이 단순화되어 명시적인 디렉토리 설정 없이 기본 로드 메커니즘을 사용하도록 변경되었습니다. 이 변경으로 `.env` 파일은 클래스패스에서 자동으로 로드될 것입니다.


변경된 코드가 제대로 동작하는지 확인하기 위해 다음 스크립트를 실행해보세요:

---

🏁 Script executed:

```shell
#!/bin/bash
# .env 파일의 실제 위치 확인
find . -name ".env" -type f | sort
```

Length of output: 34

---



</details>

**Dotenv 기본 로드 검증이 필요합니다**

현재 `find . -name ".env" -type f` 명령으로 `.env` 파일을 찾을 수 없어, 기본 로드 메커니즘이 제대로 동작하는지 확인이 불가능한 상태입니다.  
다음 중 하나를 수행해 주세요:  
- 실제 `.env` 파일이 저장된 경로를 다시 확인하고 그 위치를 기반으로 테스트  
- 애플리케이션을 실행해 환경 변수가 정상적으로 로드되는지 직접 검증

</details>
<details>
<summary>src/test/java/org/sopt/makers/global/util/EnvUtilTest.java (1)</summary>

`25-25`: **환경 변수 파일 경로가 테스트 리소스 디렉토리로 변경되었습니다.**

환경 변수 파일의 경로가 `src/main/resources/.env`에서 `src/test/resources/.env`로 변경되었습니다. 이는 `EnvUtil` 클래스의 Dotenv 로드 방식 변경과 일치하도록 조정한 것입니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-21T12:31:03Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>src/test/java/org/sopt/makers/global/util/TestEnvUtil.java (3)</summary><blockquote>

`10-15`: **클래스 가시성과 Dotenv 구성에 대한 검토**

이 클래스는 패키지 프라이빗(default visibility)으로 선언되어 있습니다. 이는 의도적인 설계일 수 있지만, 다른 패키지의 테스트에서 재사용하기 어려울 수 있습니다. 또한 Dotenv의 경로가 하드코딩되어 있어 테스트 리소스 구조가 변경될 경우 수정이 필요합니다.

```diff
-class TestEnvUtil {
+public class TestEnvUtil {
```

또한 Dotenv 경로를 상수로 추출하거나 설정 가능하게 만드는 것을 고려해보세요:
```diff
+private static final String TEST_RESOURCES_PATH = "src/test/resources";
 private static final Dotenv dotenv = Dotenv.configure()
-	.directory("src/test/resources")
+	.directory(TEST_RESOURCES_PATH)
 	.load();
```

---

`28-47`: **getWebhookUrl 메서드 관련 제안**

메서드 구현은 전반적으로 견고하나, 몇 가지 개선 사항이 있습니다:

1. 매개변수 검증 로직을 별도의 메서드로 추출하면 코드 가독성이 향상될 것입니다.
2. 예외 메시지에 어떤 매개변수가 잘못되었는지 구체적인 정보를 포함하면 디버깅에 도움이 됩니다.
3. 환경 변수 키 구성 로직도 별도 메서드로 추출하면 더 명확해질 것입니다.

```diff
 public static String getWebhookUrl(String service, String team, String stage, String type) {
-	if (service == null || team == null || stage == null || type == null) {
-		throw InvalidEnvParameterException.from(ErrorMessage.INVALID_ENV_PARAMETER);
-	}
+	validateParameters(service, team, stage, type);
 
 	String prefix = resolvePrefix(service.toLowerCase());
-	String envKey = String.format("%s%s_%s_%s",
-		prefix,
-		team.toUpperCase(),
-		stage.toUpperCase(),
-		type.toUpperCase());
+	String envKey = buildEnvKey(prefix, team, stage, type);
 
 	String webhookUrl = dotenv.get(envKey);
 
 	if (webhookUrl == null || webhookUrl.isBlank()) {
-		throw new WebhookUrlNotFoundException(ErrorMessage.WEBHOOK_URL_NOT_FOUND);
+		throw new WebhookUrlNotFoundException(
+			ErrorMessage.WEBHOOK_URL_NOT_FOUND.withArgs(envKey));
 	}
 
 	return webhookUrl;
 }
+
+private static void validateParameters(String service, String team, String stage, String type) {
+	if (service == null || team == null || stage == null || type == null) {
+		throw InvalidEnvParameterException.from(ErrorMessage.INVALID_ENV_PARAMETER);
+	}
+}
+
+private static String buildEnvKey(String prefix, String team, String stage, String type) {
+	return String.format("%s%s_%s_%s",
+		prefix,
+		team.toUpperCase(),
+		stage.toUpperCase(),
+		type.toUpperCase());
+}
```

---

`49-55`: **resolvePrefix 메서드에 대한 피드백**

switch 표현식을 사용한 구현은 깔끔하지만, 새로운 서비스 타입을 추가할 때마다 코드를 수정해야 합니다. 더 확장 가능한 접근 방식을 고려해볼 수 있습니다.

다음과 같이 Map을 사용하여 구현할 수 있습니다:

```diff
+private static final Map<String, String> SERVICE_PREFIXES = Map.of(
+	"slack", SLACK_WEBHOOK_PREFIX,
+	"discord", DISCORD_WEBHOOK_PREFIX
+);

 private static String resolvePrefix(String service) {
-	return switch (service) {
-		case "slack" -> SLACK_WEBHOOK_PREFIX;
-		case "discord" -> DISCORD_WEBHOOK_PREFIX;
-		default -> throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE);
-	};
+	String prefix = SERVICE_PREFIXES.get(service);
+	if (prefix == null) {
+		throw new UnsupportedServiceTypeException(
+			ErrorMessage.UNSUPPORTED_SERVICE_TYPE.withArgs(service));
+	}
+	return prefix;
 }
```

이렇게 하면 지원되는 서비스 목록을 한 곳에서 관리할 수 있고, 새 서비스 추가 시 코드 변경 없이 Map에만 추가하면 됩니다.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 6e2f493cc37fc59d0d62eed1952c7f9ec0bb6311 and 17ea94d810ffc692fc92b095503e86ef691ece1c.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/test/java/org/sopt/makers/global/util/EnvUtilTest.java` (6 hunks)
* `src/test/java/org/sopt/makers/global/util/TestEnvUtil.java` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* src/test/java/org/sopt/makers/global/util/EnvUtilTest.java

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/test/java/org/sopt/makers/global/util/TestEnvUtil.java (3)</summary><blockquote>

<details>
<summary>src/main/java/org/sopt/makers/global/exception/unchecked/InvalidEnvParameterException.java (1)</summary>

* `InvalidEnvParameterException` (5-14)

</details>
<details>
<summary>src/main/java/org/sopt/makers/global/exception/unchecked/UnsupportedServiceTypeException.java (1)</summary>

* `UnsupportedServiceTypeException` (5-13)

</details>
<details>
<summary>src/main/java/org/sopt/makers/global/exception/unchecked/WebhookUrlNotFoundException.java (1)</summary>

* `WebhookUrlNotFoundException` (5-13)

</details>

</blockquote></details>

</details>

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-21T12:44:52Z

Review comments:
diff_hunk: @@ -22,15 +22,15 @@
 @DisplayName("EnvUtil 테스트")
 class EnvUtilTest {
 
-	private static final String ENV_FILE_PATH = "src/main/resources/.env";
+	private static final String ENV_FILE_PATH = "src/test/resources/.env";
 
 	@BeforeAll
 	static void setUp() throws IOException {
 		String content = """
                 SLACK_WEBHOOK_CREW_DEV_BE=https://hooks.slack.com/services/crew/dev/be
                 SLACK_WEBHOOK_APP_PROD_FE=https://hooks.slack.com/services/app/prod/fe
                 """;
-		Files.createDirectories(Paths.get("src/main/resources"));
+		Files.createDirectories(Paths.get("src/test/resources"));
 		Files.write(Paths.get(ENV_FILE_PATH), content.getBytes());
path: src/test/java/org/sopt/makers/global/util/EnvUtilTest.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**테스트 리소스 디렉토리가 올바르게 생성되도록 변경되었습니다.**

테스트 설정 시 `src/test/resources` 디렉토리를 생성하도록 변경되었습니다. 이는 `ENV_FILE_PATH` 변경과 일치하지만, 파이프라인 실패 로그를 보면 테스트가 여전히 실패하고 있습니다.

파이프라인 오류에 따르면 테스트가 Webhook URL을 찾지 못하고 있습니다. 이는 클래스패스에서 `.env` 파일을 올바르게 로드하지 못하고 있음을 의미합니다. Dotenv가 테스트 실행 시 `src/test/resources/.env` 파일을 인식할 수 있도록 해야 합니다.

```diff
-Files.createDirectories(Paths.get("src/test/resources"));
-Files.write(Paths.get(ENV_FILE_PATH), content.getBytes());
+// 현재 작업 디렉토리 기준으로 경로 설정 (Dotenv의 기본 로드 방식에 맞춤)
+Files.createDirectories(Paths.get("src/test/resources"));
+Files.write(Paths.get(ENV_FILE_PATH), content.getBytes());
+// 클래스패스에 .env 파일이 존재하는지 로그로 확인
+System.out.println("Env file exists: " + Files.exists(Paths.get(ENV_FILE_PATH)));
```

추가적으로 `EnvUtil` 클래스에서 다음과 같이 디버그 로깅을 추가하는 것을 고려해보세요:

```java
private static final Dotenv dotenv = Dotenv.configure()
    .ignoreIfMissing()  // .env 파일이 없어도 오류 발생하지 않음
    .load();

static {
    log.debug("Loaded environment variables: {}", dotenv.entries().size());
}
```

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-21T12:31:02Z
author_association: NONE
diff_hunk: @@ -0,0 +1,56 @@
+package org.sopt.makers.global.util;
+
+import org.sopt.makers.global.exception.message.ErrorMessage;
+import org.sopt.makers.global.exception.unchecked.InvalidEnvParameterException;
+import org.sopt.makers.global.exception.unchecked.UnsupportedServiceTypeException;
+import org.sopt.makers.global.exception.unchecked.WebhookUrlNotFoundException;
+
+import io.github.cdimascio.dotenv.Dotenv;
+
+class TestEnvUtil {
+	private static final String SLACK_WEBHOOK_PREFIX = "SLACK_WEBHOOK_";
+	private static final String DISCORD_WEBHOOK_PREFIX = "DISCORD_WEBHOOK_";
+	private static final Dotenv dotenv = Dotenv.configure()
+		.directory("src/test/resources")
+		.load();
+
+	/**
+	 * 서비스 유형에 맞는 웹훅 URL 반환
+	 *
+	 * @param service 서비스 유형 (slack, discord 등)
+	 * @param team 팀 이름 (crew, app 등)
+	 * @param stage 환경 (dev, prod)
+	 * @param type 서버 유형 (be, fe)
+	 * @return 웹훅 URL
+	 * @throws UnsupportedServiceTypeException 지원하지 않는 서비스 유형인 경우
+	 * @throws WebhookUrlNotFoundException 환경 변수를 찾을 수 없는 경우
+	 */
+	public static String getWebhookUrl(String service, String team, String stage, String type) {
+		if (service == null || team == null || stage == null || type == null) {
+			throw InvalidEnvParameterException.from(ErrorMessage.INVALID_ENV_PARAMETER);
+		}
+
+		String prefix = resolvePrefix(service.toLowerCase());
+		String envKey = String.format("%s%s_%s_%s",
+			prefix,
+			team.toUpperCase(),
+			stage.toUpperCase(),
+			type.toUpperCase());
+
+		String webhookUrl = dotenv.get(envKey);
+
+		if (webhookUrl == null || webhookUrl.isBlank()) {
+			throw new WebhookUrlNotFoundException(ErrorMessage.WEBHOOK_URL_NOT_FOUND);
+		}
+
+		return webhookUrl;
+	}
+
+	private static String resolvePrefix(String service) {
+		return switch (service) {
+			case "slack" -> SLACK_WEBHOOK_PREFIX;
+			case "discord" -> DISCORD_WEBHOOK_PREFIX;
+			default -> throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE);
+		};
+	}
+}
path: src/test/java/org/sopt/makers/global/util/TestEnvUtil.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**테스트 부재에 대한 조언**

테스트 유틸리티 클래스도 테스트해야 합니다. 특히 이 클래스는 여러 예외 상황을 처리하고 있어, 각 시나리오에 대한 단위 테스트가 필요합니다.

다음과 같은 테스트 케이스를 고려해보세요:

1. 올바른 매개변수로 정상 URL 반환
2. null 매개변수 처리
3. 지원되지 않는 서비스 타입 처리
4. 존재하지 않는 환경 변수 키 처리

예를 들어:
```java
@Test
void getWebhookUrl_WithValidParameters_ReturnsUrl() {
    // given
    String service = "slack";
    String team = "crew";
    String stage = "dev";
    String type = "be";
    
    // when
    String url = TestEnvUtil.getWebhookUrl(service, team, stage, type);
    
    // then
    assertNotNull(url);
    assertFalse(url.isBlank());
}

@Test
void getWebhookUrl_WithNullParameter_ThrowsException() {
    // given
    String service = null;
    String team = "crew";
    String stage = "dev";
    String type = "be";
    
    // when & then
    assertThrows(InvalidEnvParameterException.class, 
        () -> TestEnvUtil.getWebhookUrl(service, team, stage, type));
}
```

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-21T12:44:51Z
author_association: NONE
diff_hunk: @@ -0,0 +1,56 @@
+package org.sopt.makers.global.util;
+
+import org.sopt.makers.global.exception.message.ErrorMessage;
+import org.sopt.makers.global.exception.unchecked.InvalidEnvParameterException;
+import org.sopt.makers.global.exception.unchecked.UnsupportedServiceTypeException;
+import org.sopt.makers.global.exception.unchecked.WebhookUrlNotFoundException;
+
+import io.github.cdimascio.dotenv.Dotenv;
+
+class TestEnvUtil {
+	private static final String SLACK_WEBHOOK_PREFIX = "SLACK_WEBHOOK_";
+	private static final String DISCORD_WEBHOOK_PREFIX = "DISCORD_WEBHOOK_";
+	private static final Dotenv dotenv = Dotenv.configure()
+		.directory("src/test/resources")
+		.load();
path: src/test/java/org/sopt/makers/global/util/TestEnvUtil.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Dotenv 로딩 관련 예외 처리**

Dotenv 인스턴스를 초기화할 때 발생할 수 있는 예외(예: 파일이 없는 경우)에 대한 처리가 없습니다. 이는 테스트 실행 시 예상치 못한 오류를 발생시킬 수 있습니다.

static 초기화 블록이나 팩토리 메서드를 사용하여 예외를 적절히 처리하세요:

```diff
-private static final Dotenv dotenv = Dotenv.configure()
-	.directory("src/test/resources")
-	.load();
+private static final Dotenv dotenv;
+
+static {
+	try {
+		dotenv = Dotenv.configure()
+			.directory("src/test/resources")
+			.load();
+	} catch (Exception e) {
+		// 적절한 로깅이나 대체 방안 구현
+		throw new IllegalStateException("테스트 환경 파일을 로드할 수 없습니다.", e);
+	}
+}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
 private static final Dotenv dotenv;
 
 static {
     try {
         dotenv = Dotenv.configure()
             .directory("src/test/resources")
             .load();
     } catch (Exception e) {
         // 적절한 로깅이나 대체 방안 구현
         throw new IllegalStateException("테스트 환경 파일을 로드할 수 없습니다.", e);
     }
 }
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-21T12:44:51Z
author_association: NONE

PR comments:
user: login: height[bot]
created_at: 2025-04-21T12:29:02Z
body: > Link [Height](https://height.app/QvNPTzDUr8) tasks by mentioning a task ID in the pull request title or commit messages, or description and comments with the keyword `link` (e.g. "Link T-123").
>
> 💡Tip: You can also use "Close T-X" to automatically close a task when the pull request is merged.
<!-- Sent by Height -->
author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-04-21T12:29:05Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Summary by CodeRabbit

- **신규 기능**
  - 테스트 환경에서 다양한 서비스(Slack, Discord 등)의 Webhook URL을 손쉽게 조회할 수 있는 유틸리티가 추가되었습니다.

- **테스트**
  - 테스트에서 사용하는 .env 파일 경로가 변경되었으며, Webhook URL 조회 로직이 새로운 유틸리티 클래스를 사용하도록 개선되었습니다.
"""
## Summary by CodeRabbit

- **Chores**
  - 환경 변수 파일의 기본 경로 로딩 방식이 단순화되었습니다.
  - 테스트 환경 파일 위치가 변경되어 테스트 리소스 디렉토리를 사용하도록 수정되었습니다.
  - 테스트용 환경 변수 유틸리티 클래스 `TestEnvUtil`이 추가되어, 서비스별 웹훅 URL을 환경 변수에서 안전하게 조회할 수 있도록 구현되었습니다.
  
## Walkthrough

이번 변경에서는 환경 변수 유틸리티 클래스에서 Dotenv 인스턴스의 초기화 방식을 단순화하여, `.env` 파일을 classpath에서 자동으로 로드하도록 수정했습니다. 테스트 코드에서는 테스트용 `.env` 파일 위치를 `src/test/resources`로 변경하고, 기존 `EnvUtil.getWebhookUrl` 호출을 새로 추가된 `TestEnvUtil.getWebhookUrl`로 대체했습니다. `TestEnvUtil` 클래스는 서비스 타입, 팀, 스테이지, 서버 타입을 받아 환경 변수 키를 생성하고, 유효성 검사 및 예외 처리를 포함하여 웹훅 URL을 반환하는 기능을 제공합니다.

## Changes

| 파일/경로 그룹                                                        | 변경 요약                                                                                                      |
|----------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|
| src/main/java/org/sopt/makers/global/util/EnvUtil.java               | Dotenv 초기화 시 디렉토리 지정(`.directory("src/main/resources")`)을 제거하고, 기본 `.load()`만 사용하도록 변경                   |
| src/test/java/org/sopt/makers/global/util/EnvUtilTest.java           | 테스트용 `.env` 파일 경로를 `src/test/resources`로 변경 및 관련 디렉토리 생성 로직 수정, `EnvUtil.getWebhookUrl` 호출을 `TestEnvUtil.getWebhookUrl`로 변경 |
| src/test/java/org/sopt/makers/global/util/TestEnvUtil.java           | 새로운 테스트용 환경 변수 유틸리티 클래스 추가: 서비스별 웹훅 URL 조회 기능, 파라미터 유효성 검사 및 예외 처리 구현                              |

## Assessment against linked issues

| Objective                                                                                                          | Addressed | Explanation                                   |
|--------------------------------------------------------------------------------------------------------------------|-----------|-----------------------------------------------|
| Dotenv.configure().directory("src/main/resources") → Dotenv.configure().load()로 수정하여 classpath 상의 .env를 로드하도록 변경 (#7) | ✅        |                                               |

## Poem

> 🐰  
> 환경 변수 hop! hop!  
> 디렉토리 지정은 이제 stop!  
> classpath 따라 깡총깡총,  
> JAR 속에서도 문제 없쥐롱.  
> 테스트도 자리에 착,  
> .env 파일도 딱!  
> 오늘도 코드는 깔끔하게 hop!
"""

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=sopt-makers/sentry-notifier&utm_content=8):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE