Skip to content

Commit 17f8efa

Browse files
committed
feat(analysis): 인메모리 락 추가, 요청 시 버튼 비활성화로 중복 요청 방지
1 parent 64b237b commit 17f8efa

File tree

8 files changed

+162
-62
lines changed

8 files changed

+162
-62
lines changed

backend/src/main/java/com/backend/domain/analysis/service/AnalysisService.java

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.springframework.transaction.annotation.Transactional;
1616

1717
import java.util.List;
18+
import java.util.concurrent.ConcurrentHashMap;
1819

1920
@Slf4j
2021
@Service
@@ -26,6 +27,9 @@ public class AnalysisService {
2627
private final RepositoryJpaRepository repositoryJpaRepository;
2728
private final SseProgressNotifier sseProgressNotifier;
2829

30+
// 인메모리 락
31+
private final ConcurrentHashMap<String, Boolean> processingRepositories = new ConcurrentHashMap<>();
32+
2933
/* Analysis 분석 프로세스 오케스트레이션 담당
3034
* 1. GitHub URL 파싱 및 검증
3135
* 2. Repository 도메인을 통한 데이터 수집
@@ -38,35 +42,49 @@ public Long analyze(String githubUrl, Long userId) {
3842
String owner = repoInfo[0];
3943
String repo = repoInfo[1];
4044

41-
sseProgressNotifier.notify(userId, "status", "분석 시작");
45+
String cacheKey = userId + ":" + githubUrl;
4246

43-
// Repository 데이터 수집
44-
RepositoryData repositoryData;
47+
// 현재 처리 중인지 체크
48+
if (processingRepositories.putIfAbsent(cacheKey, true) != null) {
49+
log.warn("⚠중복 분석 요청 차단: userId={}, url={}", userId, githubUrl);
50+
throw new BusinessException(ErrorCode.ANALYSIS_IN_PROGRESS);
51+
}
4552

4653
try {
47-
repositoryData = repositoryService.fetchAndSaveRepository(owner, repo, userId);
48-
log.info("🫠 Repository Data 수집 완료: {}", repositoryData);
49-
} catch (BusinessException e) {
50-
log.error("Repository 데이터 수집 실패: {}/{}", owner, repo, e);
51-
throw handleRepositoryFetchError(e, owner, repo);
52-
}
54+
sseProgressNotifier.notify(userId, "status", "분석 시작");
5355

54-
Repositories savedRepository = repositoryJpaRepository
55-
.findByHtmlUrl(repositoryData.getRepositoryUrl())
56-
.orElseThrow(() -> new BusinessException(ErrorCode.GITHUB_REPO_NOT_FOUND));
56+
// Repository 데이터 수집
57+
RepositoryData repositoryData;
5758

58-
Long repositoryId = savedRepository.getId();
59+
try {
60+
repositoryData = repositoryService.fetchAndSaveRepository(owner, repo, userId);
61+
log.info("🫠 Repository Data 수집 완료: {}", repositoryData);
62+
} catch (BusinessException e) {
63+
log.error("Repository 데이터 수집 실패: {}/{}", owner, repo, e);
64+
throw handleRepositoryFetchError(e, owner, repo);
65+
}
5966

60-
// OpenAI API 데이터 분석 및 저장
61-
try {
62-
evaluationService.evaluateAndSave(repositoryData);
63-
} catch (BusinessException e) {
64-
sseProgressNotifier.notify(userId, "error", "AI 평가 실패: " + e.getMessage());
65-
throw e;
66-
}
67+
Repositories savedRepository = repositoryJpaRepository
68+
.findByHtmlUrl(repositoryData.getRepositoryUrl())
69+
.orElseThrow(() -> new BusinessException(ErrorCode.GITHUB_REPO_NOT_FOUND));
70+
71+
Long repositoryId = savedRepository.getId();
6772

68-
sseProgressNotifier.notify(userId, "complete", "최종 리포트 생성");
69-
return repositoryId;
73+
// OpenAI API 데이터 분석 및 저장
74+
try {
75+
evaluationService.evaluateAndSave(repositoryData);
76+
} catch (BusinessException e) {
77+
sseProgressNotifier.notify(userId, "error", "AI 평가 실패: " + e.getMessage());
78+
throw e;
79+
}
80+
81+
sseProgressNotifier.notify(userId, "complete", "최종 리포트 생성");
82+
return repositoryId;
83+
} finally {
84+
// 락 해제
85+
processingRepositories.remove(cacheKey);
86+
log.info("분석 락 해제: cacheKey={}", cacheKey);
87+
}
7088
}
7189

7290
private String[] parseGitHubUrl(String githubUrl) {

backend/src/main/java/com/backend/global/exception/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public enum ErrorCode {
2727
ANALYSIS_NOT_FOUND("A003", HttpStatus.BAD_REQUEST, "분석 결과를 찾을 수 없습니다."),
2828
USER_NOT_FOUND("A004", HttpStatus.FORBIDDEN, "사용자 정보를 찾을 수 없습니다."),
2929
FORBIDDEN("A005", HttpStatus.FORBIDDEN, "접근 권한이 없습니다."),
30+
ANALYSIS_IN_PROGRESS("A006", HttpStatus.CONFLICT, "이미 분석이 진행 중입니다. 잠시 후 다시 시도해주세요."),
3031

3132
// ========== repository 도메인 에러 ==========
3233
GITHUB_REPO_NOT_FOUND("G001", HttpStatus.BAD_REQUEST, "GitHub 저장소를 찾을 수 없습니다."),

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

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,24 @@ import { useRequireAuth } from "@/hooks/auth/useRequireAuth"
1717
export default function AnalyzePage() {
1818
const [repoUrl, setRepoUrl] = useState("")
1919
const [isValidUrl, setIsValidUrl] = useState(true)
20+
const [isSubmitting, setIsSubmitting] = useState(false)
2021
const router = useRouter()
21-
const { requestAnalysis, isLoading, error } = useAnalysis()
22-
22+
const { error } = useAnalysis()
2323
const { user } = useRequireAuth()
24+
25+
useEffect(() => {
26+
// 페이지 포커스 시 상태 초기화 (뒤로가기 대응)
27+
const handleVisibilityChange = () => {
28+
if (document.visibilityState === 'visible') {
29+
setIsSubmitting(false)
30+
}
31+
}
32+
33+
document.addEventListener('visibilitychange', handleVisibilityChange)
34+
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
35+
}, [])
36+
37+
2438
if (!user) return null
2539

2640
const handleSubmit = (e: React.FormEvent) => {
@@ -32,6 +46,7 @@ export default function AnalyzePage() {
3246
}
3347

3448
setIsValidUrl(true);
49+
setIsSubmitting(true);
3550

3651
// 여기서 API 요청하지 않고 repoUrl만 전달
3752
const encodedUrl = encodeURIComponent(repoUrl);
@@ -75,7 +90,7 @@ export default function AnalyzePage() {
7590
value={repoUrl}
7691
onChange={handleUrlChange}
7792
className={`pl-10 h-12 text-base ${!isValidUrl ? "border-destructive" : ""}`}
78-
disabled={isLoading}
93+
disabled={isSubmitting}
7994
required
8095
/>
8196
</div>
@@ -88,14 +103,13 @@ export default function AnalyzePage() {
88103
</div>
89104

90105
<Button
91-
type="button" // ✅ form submit 방지
92-
onClick={handleSubmit} // ✅ 직접 이벤트 트리거
106+
type="submit" // ← type="submit"으로 변경 (form 제출 사용)
93107
className="w-full"
94108
size="lg"
95-
disabled={isLoading}
109+
disabled={isSubmitting || !repoUrl} // ← 수정: 제출 중이거나 URL 없으면 비활성화
96110
>
97111
<Search className="mr-2 h-5 w-5" />
98-
{isLoading ? "분석 요청 중..." : "분석 시작하기"}
112+
{isSubmitting ? "분석 페이지로 이동 중..." : "분석 시작하기"}
99113
</Button>
100114
</form>
101115
</Card>

front/src/components/analysis/LoadingContent.tsx

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useSearchParams } from "next/navigation"
44
import { Progress } from "@/components/ui/progress"
5+
import { AlertCircle } from "lucide-react"
56
import LoadingHeader from "@/components/analysis/LoadingHeader"
67
import LoadingStepList from "@/components/analysis/LoadingStepList"
78
import LoadingInfoBox from "@/components/analysis/LoadingInfoBox"
@@ -12,36 +13,61 @@ export default function LoadingContent() {
1213
const repoUrl = searchParams.get("repoUrl") // ✅ 'repo' → 'repoUrl'로 수정
1314

1415
// ✅ repoUrl을 훅에 전달해야 실제 API 요청이 실행됨
15-
const { progress, currentStep, steps, statusMessage } = useAnalysisProgress(repoUrl)
16+
const { progress, currentStep, steps, statusMessage, error } = useAnalysisProgress(repoUrl)
1617

1718
return (
1819
<div className="min-h-screen bg-background">
1920
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-16">
2021
<div className="mx-auto max-w-2xl">
2122
{/* 분석 중인 저장소 URL 헤더 */}
2223
<LoadingHeader repoUrl={repoUrl} />
24+
{/* 에러 표시 */}
25+
{error && (
26+
<div className="mb-6 rounded-lg border border-destructive/50 bg-destructive/10 p-4">
27+
<div className="flex items-start gap-3">
28+
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
29+
<div className="flex-1">
30+
<h4 className="font-semibold text-destructive mb-1">
31+
분석 요청 실패
32+
</h4>
33+
<p className="text-sm text-destructive/90">
34+
{error}
35+
</p>
36+
<p className="text-xs text-muted-foreground mt-2">
37+
잠시 후 자동으로 분석 페이지로 돌아갑니다...
38+
</p>
39+
</div>
40+
</div>
41+
</div>
42+
)}
2343

24-
<div className="mb-12 space-y-3">
25-
{/* 진행률 바 + 퍼센트 표시 */}
26-
<Progress value={progress} className="h-2" />
27-
<div className="flex items-center justify-between text-sm">
28-
<span className="text-muted-foreground">
29-
{Math.round(progress)}% 완료
30-
</span>
31-
<span className="text-muted-foreground">
32-
{currentStep + 1} / {steps.length} 단계
33-
</span>
44+
{/* 에러가 없을 때만 진행률 표시 */}
45+
{!error && (
46+
<>
47+
<div className="mb-12 space-y-3">
48+
{/* 진행률 바 + 퍼센트 표시 */}
49+
<Progress value={progress} className="h-2" />
50+
<div className="flex items-center justify-between text-sm">
51+
<span className="text-muted-foreground">
52+
{Math.round(progress)}% 완료
53+
</span>
54+
<span className="text-muted-foreground">
55+
{currentStep + 1} / {steps.length} 단계
56+
</span>
57+
</div>
3458
</div>
35-
</div>
3659

37-
{/* 상태 메시지 출력 (선택) */}
38-
<p className="text-center text-sm text-muted-foreground mb-4">
39-
{statusMessage}
40-
</p>
60+
{/* 상태 메시지 출력 (선택) */}
61+
<p className="text-center text-sm text-muted-foreground mb-4">
62+
{statusMessage}
63+
</p>
4164

42-
{/* 단계 리스트 및 추가 정보 */}
43-
<LoadingStepList steps={steps} currentStep={currentStep} />
44-
<LoadingInfoBox />
65+
{/* 단계 리스트 및 추가 정보 */}
66+
<LoadingStepList steps={steps} currentStep={currentStep} />
67+
<LoadingInfoBox />
68+
</>
69+
70+
)}
4571
</div>
4672
</div>
4773
</div>

front/src/components/analysis/RepositoryPublicSection.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,6 @@ export function RepositoryPublicSection({ userId, repoId, initialPublic }: Props
104104
<p className="text-sm text-muted-foreground">다른 개발자들과 소통하세요.</p>
105105
</div>
106106
<div className="flex gap-2">
107-
<Button variant="outline" size="sm" className="gap-2 bg-transparent">
108-
<MessageSquare className="h-4 w-4" />
109-
댓글 (n)
110-
</Button>
111107
<ShareButton />
112108
</div>
113109
</div>

front/src/hooks/analysis/useLoadingProgress.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,38 @@ export function useAnalysisProgress(repoUrl?: string | null) {
6161
headers: { "Content-Type": "application/json" },
6262
body: JSON.stringify({ githubUrl: repoUrl }),
6363
})
64-
const data = await res.json()
64+
65+
if (!res.ok) {
66+
const errorData = await res.json()
67+
68+
// 409 Conflict: 중복 요청
69+
if (res.status === 409) {
70+
console.warn("⚠️ 중복 분석 요청 감지")
71+
setError("이미 분석이 진행 중입니다. 잠시 후 다시 시도해주세요.")
72+
setStatusMessage("중복 요청이 감지되었습니다")
73+
eventSource.close()
74+
75+
// 3초 후 분석 페이지로 돌아가기
76+
setTimeout(() => {
77+
router.push("/analysis")
78+
}, 3000)
79+
return
80+
}
81+
82+
// 기타 에러 (400, 500 등)
83+
console.error("❌ 분석 요청 실패:", errorData)
84+
setError(errorData.message || "분석 요청에 실패했습니다")
85+
setStatusMessage("요청 처리 중 오류가 발생했습니다")
86+
eventSource.close()
87+
88+
// 3초 후 분석 페이지로 돌아가기
89+
setTimeout(() => {
90+
router.push("/analysis")
91+
}, 3000)
92+
return
93+
}
6594

66-
// ✅ repositoryId를 즉시 ref에 저장
95+
const data = await res.json()
6796
const repoId = data.data.repositoryId
6897
setRepositoryId(repoId)
6998
repositoryIdRef.current = repoId

front/src/hooks/auth/useAuth.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,23 @@ export function useAuth() {
88
const [token, setToken] = useState<string | null>(null);
99
const [user, setUser] = useState<GetUserResponse | null>(null);
1010
const [isLoadingUser, setIsLoadingUser] = useState(false);
11+
const [isInitializing, setIsInitializing] = useState(true);
1112

1213
const fetchUserInfo = async () => {
1314
const token = localStorage.getItem('accessToken');
1415
if (!token) return;
1516

1617
try {
1718
setIsLoadingUser(true);
18-
console.log('사용자 정보 요청 시작');
1919
const userData = await authApi.getCurrentUser();
20-
console.log('사용자 정보 받음:', userData);
2120
setUser(userData);
21+
localStorage.setItem('user', JSON.stringify(userData));
2222
} catch (error) {
2323
console.error('사용자 정보 가져오기 실패:', error);
24-
// 에러 발생 시 로그아웃하지 않고 그냥 넘어감 (무한 루프 방지)
24+
localStorage.removeItem('accessToken');
25+
localStorage.removeItem('user');
26+
setToken(null);
27+
setUser(null);
2528
} finally {
2629
setIsLoadingUser(false);
2730
}
@@ -41,8 +44,10 @@ export function useAuth() {
4144
const parsedUser = JSON.parse(savedUser);
4245
console.log('저장된 사용자 정보 복원:', parsedUser);
4346
setUser(parsedUser);
47+
setIsInitializing(false);
4448
} catch (error) {
4549
console.error('사용자 정보 파싱 실패:', error);
50+
setIsInitializing(false);
4651
}
4752
} else if (t) {
4853
// 토큰은 있는데 사용자 정보가 없으면 가져오기
@@ -70,7 +75,7 @@ export function useAuth() {
7075
return () => clearTimeout(logoutTimer);
7176
}, [token]); // token이 새로 설정될 때마다 타이머 재설정
7277

73-
const isAuthed = useMemo(() => !!token, [token]);
78+
const isAuthed = useMemo(() => !!token && !!user, [token, user]);
7479

7580
function loginWithToken(userData: GetUserResponse) {
7681
console.log('loginWithToken 호출됨, userData:', userData);
@@ -109,6 +114,7 @@ export function useAuth() {
109114
token,
110115
user,
111116
isLoadingUser,
117+
isInitializing,
112118
loginWithToken,
113119
logout,
114120
fetchUserInfo

front/src/hooks/auth/useRequireAuth.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,23 @@ import { useAuth } from "@/hooks/auth/useAuth"
66

77
export function useRequireAuth() {
88
const router = useRouter()
9-
const { isAuthed, user } = useAuth()
9+
const { isAuthed, user, isInitializing } = useAuth()
1010

1111
useEffect(() => {
12+
// ⬇️ 초기화 중이면 리다이렉트하지 않음
13+
if (isInitializing) {
14+
console.log("인증 상태 확인 중...")
15+
return
16+
}
17+
18+
// ⬇️ 초기화 완료 후 인증 실패 시에만 리다이렉트
1219
if (!isAuthed || !user) {
20+
console.log("인증 실패, /login으로 리다이렉트")
1321
router.replace("/login")
22+
} else {
23+
console.log("인증 성공:", user)
1424
}
15-
}, [isAuthed, user, router])
25+
}, [isAuthed, user, isInitializing, router])
1626

17-
return { user, isAuthed }
18-
}
27+
return { user, isAuthed, isInitializing }
28+
}

0 commit comments

Comments
 (0)