Skip to content

Commit a863d5a

Browse files
committed
feat(analysis): 분석 실패 시 analysis 페이지에서 원인 확인할 수 있도록 수정
1 parent a6f69c4 commit a863d5a

File tree

5 files changed

+136
-48
lines changed

5 files changed

+136
-48
lines changed

backend/src/main/java/com/backend/domain/repository/service/RepositoryService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ public RepositoryData fetchCompleteRepositoryData(String owner, String repo, Lon
111111
saveOrUpdateRepository(repoInfo, owner, repo, userId);
112112

113113
return data;
114+
} catch (BusinessException e) {
115+
sseProgressNotifier.notify(userId, "error", "❌ " + e.getErrorCode().getMessage());
116+
throw e;
117+
114118
} catch (Exception e) {
115119
sseProgressNotifier.notify(userId, "error", "❌ Repository 데이터 수집 실패: " + e.getMessage());
116120
throw e;

backend/src/main/java/com/backend/domain/repository/service/fetcher/GitHubDataFetcher.java

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import lombok.RequiredArgsConstructor;
88
import lombok.extern.slf4j.Slf4j;
99
import org.springframework.retry.annotation.Backoff;
10-
import org.springframework.retry.annotation.Recover;
1110
import org.springframework.retry.annotation.Retryable;
1211
import org.springframework.stereotype.Component;
1312
import org.springframework.web.reactive.function.client.WebClientRequestException;
@@ -32,8 +31,6 @@ public class GitHubDataFetcher {
3231
retryFor = {WebClientResponseException.ServiceUnavailable.class,
3332
WebClientResponseException.InternalServerError.class,
3433
WebClientRequestException.class}, // 네트워크 타임아웃
35-
noRetryFor = {WebClientResponseException.NotFound.class, // 404, 401 에러는 재시도 X
36-
WebClientResponseException.Unauthorized.class},
3734
maxAttempts = 2, // 최대 2회 시도 (원본 1회 + 재시도 1회)
3835
backoff = @Backoff(delay = 1000) // 재시도 전 1초 대기
3936
)
@@ -45,8 +42,6 @@ public RepoResponse fetchRepositoryInfo(String owner, String repoName) {
4542
retryFor = {WebClientResponseException.ServiceUnavailable.class,
4643
WebClientResponseException.InternalServerError.class,
4744
WebClientRequestException.class},
48-
noRetryFor = {WebClientResponseException.NotFound.class,
49-
WebClientResponseException.Unauthorized.class},
5045
maxAttempts = 2,
5146
backoff = @Backoff(delay = 1000)
5247
)
@@ -66,8 +61,6 @@ public Optional<String> fetchReadmeContent(String owner, String repoName) {
6661
retryFor = {WebClientResponseException.ServiceUnavailable.class,
6762
WebClientResponseException.InternalServerError.class,
6863
WebClientRequestException.class},
69-
noRetryFor = {WebClientResponseException.NotFound.class,
70-
WebClientResponseException.Unauthorized.class},
7164
maxAttempts = 2,
7265
backoff = @Backoff(delay = 1000)
7366
)
@@ -81,8 +74,6 @@ public List<CommitResponse> fetchCommitInfo(String owner, String repoName, Strin
8174
retryFor = {WebClientResponseException.ServiceUnavailable.class,
8275
WebClientResponseException.InternalServerError.class,
8376
WebClientRequestException.class},
84-
noRetryFor = {WebClientResponseException.NotFound.class,
85-
WebClientResponseException.Unauthorized.class},
8677
maxAttempts = 2,
8778
backoff = @Backoff(delay = 1000)
8879
)
@@ -105,8 +96,6 @@ public Optional<TreeResponse> fetchRepositoryTreeInfo(String owner, String repoN
10596
retryFor = {WebClientResponseException.ServiceUnavailable.class,
10697
WebClientResponseException.InternalServerError.class,
10798
WebClientRequestException.class},
108-
noRetryFor = {WebClientResponseException.NotFound.class,
109-
WebClientResponseException.Unauthorized.class},
11099
maxAttempts = 2,
111100
backoff = @Backoff(delay = 1000)
112101
)
@@ -135,8 +124,6 @@ public List<IssueResponse> fetchIssueInfo(String owner, String repoName) {
135124
retryFor = {WebClientResponseException.ServiceUnavailable.class,
136125
WebClientResponseException.InternalServerError.class,
137126
WebClientRequestException.class},
138-
noRetryFor = {WebClientResponseException.NotFound.class,
139-
WebClientResponseException.Unauthorized.class},
140127
maxAttempts = 2,
141128
backoff = @Backoff(delay = 1000)
142129
)
@@ -164,8 +151,6 @@ public List<PullRequestResponse> fetchPullRequestInfo(String owner, String repoN
164151
retryFor = {WebClientResponseException.ServiceUnavailable.class,
165152
WebClientResponseException.InternalServerError.class,
166153
WebClientRequestException.class},
167-
noRetryFor = {WebClientResponseException.NotFound.class,
168-
WebClientResponseException.Unauthorized.class},
169154
maxAttempts = 2,
170155
backoff = @Backoff(delay = 1000)
171156
)
@@ -180,10 +165,4 @@ private LocalDateTime getSixMonthsAgo() {
180165
private LocalDateTime parseGitHubDate(String dateString) {
181166
return LocalDateTime.parse(dateString, DateTimeFormatter.ISO_OFFSET_DATE_TIME);
182167
}
183-
184-
@Recover // 재시도 실패 시 호출되는 메서드
185-
public RepoResponse recover(WebClientResponseException e, String owner, String repoName) {
186-
log.error("GitHub API 재시도 실패: {}/{}", owner, repoName, e);
187-
throw new BusinessException(ErrorCode.GITHUB_API_FAILED);
188-
}
189168
}

backend/src/main/java/com/backend/global/github/GitHubApiClient.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import lombok.RequiredArgsConstructor;
66
import lombok.extern.slf4j.Slf4j;
77
import org.springframework.http.HttpHeaders;
8+
import org.springframework.http.HttpStatus;
89
import org.springframework.stereotype.Component;
910
import org.springframework.web.reactive.function.client.WebClient;
1011
import org.springframework.web.reactive.function.client.WebClientResponseException;
@@ -93,6 +94,30 @@ private <T> Mono<T> handleWebClientError(WebClientResponseException ex) {
9394
log.error("GitHub API 호출 실패: {}", ex.getMessage());
9495

9596
if (ex.getStatusCode().is4xxClientError()) {
97+
HttpStatus status = (HttpStatus) ex.getStatusCode();
98+
99+
if (status == HttpStatus.BAD_REQUEST) {
100+
return Mono.error(new BusinessException(ErrorCode.GITHUB_API_FAILED));
101+
}
102+
if (status == HttpStatus.UNAUTHORIZED) {
103+
return Mono.error(new BusinessException(ErrorCode.GITHUB_INVALID_TOKEN));
104+
}
105+
if (status == HttpStatus.FORBIDDEN) {
106+
return Mono.error(new BusinessException(ErrorCode.FORBIDDEN));
107+
}
108+
if (status == HttpStatus.NOT_FOUND) {
109+
return Mono.error(new BusinessException(ErrorCode.GITHUB_REPO_NOT_FOUND));
110+
}
111+
if (status == HttpStatus.GONE) {
112+
return Mono.error(new BusinessException(ErrorCode.GITHUB_API_FAILED));
113+
}
114+
if (status == HttpStatus.UNPROCESSABLE_ENTITY) {
115+
return Mono.error(new BusinessException(ErrorCode.GITHUB_API_FAILED));
116+
}
117+
if (status == HttpStatus.TOO_MANY_REQUESTS) {
118+
return Mono.error(new BusinessException(ErrorCode.GITHUB_RATE_LIMIT_EXCEEDED));
119+
}
120+
96121
return Mono.error(new BusinessException(ErrorCode.GITHUB_REPO_NOT_FOUND));
97122
}
98123
if (ex.getStatusCode().is5xxServerError()) {

front/src/app/(dashboard)/analysis/page.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"use client"
44

55
import type React from "react"
6-
import { useState, useEffect } from "react"
6+
import { useState, useEffect, useMemo } from "react"
77
import { useRouter } from "next/navigation"
88
import { Button } from "@/components/ui/Button"
99
import { Input } from "@/components/ui/input"
@@ -14,10 +14,14 @@ import { isValidGitHubUrl } from "@/lib/utils/validation"
1414
import { useAnalysis } from "@/hooks/analysis/useAnalysis"
1515
import { useRequireAuth } from "@/hooks/auth/useRequireAuth"
1616

17+
type AnalysisErrorKind = "repo" | "auth" | "rate" | "duplicate" | "server" | "network";
18+
1719
export default function AnalyzePage() {
1820
const [repoUrl, setRepoUrl] = useState("")
1921
const [isValidUrl, setIsValidUrl] = useState(true)
20-
const [isSubmitting, setIsSubmitting] = useState(false)
22+
const [isSubmitting, setIsSubmitting] = useState(false)
23+
const [analysisErrorMessage, setAnalysisErrorMessage] = useState<string | null>(null)
24+
const [analysisErrorType, setAnalysisErrorType] = useState<AnalysisErrorKind | null>(null)
2125
const router = useRouter()
2226
const { error } = useAnalysis()
2327
const { user } = useRequireAuth()
@@ -34,6 +38,37 @@ export default function AnalyzePage() {
3438
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
3539
}, [])
3640

41+
useEffect(() => {
42+
const stored = sessionStorage.getItem("analysisError");
43+
if (!stored) return;
44+
45+
try {
46+
const parsed = JSON.parse(stored) as { type?: AnalysisErrorKind; message?: string };
47+
setAnalysisErrorType(parsed.type ?? "server");
48+
setAnalysisErrorMessage(parsed.message ?? "분석 처리 중 문제가 발생했어요.");
49+
} catch {
50+
setAnalysisErrorType("server");
51+
setAnalysisErrorMessage(stored);
52+
} finally {
53+
sessionStorage.removeItem("analysisError");
54+
}
55+
}, []);
56+
57+
const analysisErrorClass = useMemo(() => {
58+
switch (analysisErrorType) {
59+
case "auth":
60+
return "text-amber-600";
61+
case "rate":
62+
return "text-orange-600";
63+
case "duplicate":
64+
return "text-blue-600";
65+
case "repo":
66+
case "server":
67+
case "network":
68+
default:
69+
return "text-destructive/80";
70+
}
71+
}, [analysisErrorType]);
3772

3873
if (!user) return null
3974

@@ -94,11 +129,16 @@ export default function AnalyzePage() {
94129
required
95130
/>
96131
</div>
132+
{analysisErrorMessage && (
133+
<p className={`text-sm font-medium ${analysisErrorClass}`}>
134+
{analysisErrorMessage}
135+
</p>
136+
)}
97137
{!isValidUrl && (
98-
<p className="text-sm text-destructive">올바른 GitHub 리포지토리 URL을 입력하세요</p>
138+
<p className="text-sm text-destructive/80">올바른 GitHub 리포지토리 URL을 입력해 주세요.</p>
99139
)}
100140
{error && (
101-
<p className="text-sm text-destructive">{error.message}</p>
141+
<p className="text-sm text-destructive/80">{error.message}</p>
102142
)}
103143
</div>
104144

front/src/hooks/analysis/useLoadingProgress.ts

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,46 @@
33
import { useEffect, useRef, useState, useMemo } from "react"
44
import { useRouter } from "next/navigation"
55

6+
type AnalysisErrorKind = "repo" | "auth" | "rate" | "duplicate" | "server" | "network";
7+
const defaultAnalysisError = {
8+
type: "server" as AnalysisErrorKind,
9+
message: "분석 처리 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요.",
10+
};
11+
12+
const stashAnalysisError = (payload: { type: AnalysisErrorKind; message: string }) => {
13+
try {
14+
sessionStorage.setItem("analysisError", JSON.stringify(payload));
15+
} catch {
16+
/* ignore */
17+
}
18+
};
19+
20+
const mapErrorCodeToAlert = (
21+
code?: string,
22+
fallback?: string
23+
): { type: AnalysisErrorKind; message: string } => {
24+
switch (code) {
25+
case "GITHUB_REPO_NOT_FOUND":
26+
return { type: "repo", message: "리포지토리 URL을 다시 한번 확인해 주세요." };
27+
case "GITHUB_INVALID_TOKEN":
28+
return { type: "auth", message: "GitHub 인증에 실패했어요. 다시 로그인 후 시도해 주세요." };
29+
case "GITHUB_RATE_LIMIT_EXCEEDED":
30+
return { type: "rate", message: "요청이 많아요. 잠시 기다렸다 다시 시도해 주세요." };
31+
case "FORBIDDEN":
32+
return { type: "auth", message: "해당 리포지토리에 접근 권한이 없어요." };
33+
case "GITHUB_API_FAILED":
34+
return { type: "repo", message: "요청을 처리할 수 없었어요. 입력값을 다시 확인해 주세요." };
35+
case "GITHUB_API_SERVER_ERROR":
36+
return { type: "server", message: "GitHub 서버에서 오류가 발생했어요. 잠시 후 다시 시도해 주세요." };
37+
default:
38+
return {
39+
type: defaultAnalysisError.type,
40+
message: fallback || defaultAnalysisError.message,
41+
};
42+
}
43+
};
44+
45+
646
export function useAnalysisProgress(repoUrl?: string | null) {
747
const router = useRouter()
848
const [progress, setProgress] = useState(0)
@@ -63,33 +103,33 @@ export function useAnalysisProgress(repoUrl?: string | null) {
63103
})
64104

65105
if (!res.ok) {
66-
const errorData = await res.json()
67-
68-
// 409 Conflict: 중복 요청
106+
const errorData = await res.json();
69107
if (res.status === 409) {
70-
console.warn("⚠️ 중복 분석 요청 감지")
71-
setError("이미 분석이 진행 중입니다. 잠시 후 다시 시도해주세요.")
72-
setStatusMessage("중복 요청이 감지되었습니다")
73-
eventSource.close()
74-
75-
// 3초 후 분석 페이지로 돌아가기
108+
const duplicatePayload = {
109+
type: "duplicate" as AnalysisErrorKind,
110+
message: "이미 분석을 진행 중이에요. 잠시 후 다시 확인해 주세요.",
111+
};
112+
setError(duplicatePayload.message);
113+
setStatusMessage("중복 요청이 감지되었어요.");
114+
stashAnalysisError(duplicatePayload);
115+
eventSource.close();
116+
76117
setTimeout(() => {
77-
router.push("/analysis")
78-
}, 3000)
79-
return
118+
router.push("/analysis");
119+
}, 3000);
120+
return;
80121
}
81-
82-
// 기타 에러 (400, 500 등)
83-
console.error("❌ 분석 요청 실패:", errorData)
84-
setError(errorData.message || "분석 요청에 실패했습니다")
85-
setStatusMessage("요청 처리 중 오류가 발생했습니다")
86-
eventSource.close()
87-
88-
// 3초 후 분석 페이지로 돌아가기
122+
123+
const alertPayload = mapErrorCodeToAlert(errorData?.code, errorData?.message);
124+
setError(alertPayload.message);
125+
setStatusMessage("요청 처리 중 문제가 발생했어요.");
126+
stashAnalysisError(alertPayload);
127+
eventSource.close();
128+
89129
setTimeout(() => {
90-
router.push("/analysis")
91-
}, 3000)
92-
return
130+
router.push("/analysis");
131+
}, 3000);
132+
return;
93133
}
94134

95135
const data = await res.json()

0 commit comments

Comments
 (0)