Skip to content

Commit d00679a

Browse files
authored
feat, fix(analysis): 비회원 커뮤니티 조회 로직 수정, 인메모리 락 리팩토링 및 테스트 추가
feat, fix(analysis): 비회원 커뮤니티 조회 로직 수정, 인메모리 락 리팩토링 및 테스트 추가
2 parents c9f520c + 1734022 commit d00679a

File tree

16 files changed

+302
-187
lines changed

16 files changed

+302
-187
lines changed

backend/src/main/java/com/backend/domain/analysis/controller/AnalysisController.java

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import java.util.ArrayList;
2626
import java.util.List;
27+
import java.util.Objects;
2728

2829
@Slf4j
2930
@RestController
@@ -53,19 +54,18 @@ public ResponseEntity<ApiResponse<AnalysisStartResponse>> analyzeRepository(
5354
}
5455

5556
// GET: 사용자의 모든 Repository 목록 조회
56-
@GetMapping("/{userId}/repositories")
57+
@GetMapping("/repositories")
5758
@Transactional(readOnly = true)
5859
public ResponseEntity<ApiResponse<List<RepositoryResponse>>> getMemberHistory(
59-
@PathVariable Long userId,
6060
HttpServletRequest httpRequest
6161
){
6262
Long jwtUserId = jwtUtil.getUserId(httpRequest);
63-
if (!jwtUserId.equals(userId)) {
64-
throw new BusinessException(ErrorCode.FORBIDDEN);
63+
if (jwtUserId == null) {
64+
throw new BusinessException(ErrorCode.UNAUTHORIZED);
6565
}
6666

6767
List<RepositoryResponse> repositories = repositoryService
68-
.findRepositoryByUser(userId)
68+
.findRepositoryByUser(jwtUserId)
6969
.stream()
7070
.map(RepositoryResponse::new)
7171
.toList();
@@ -74,20 +74,18 @@ public ResponseEntity<ApiResponse<List<RepositoryResponse>>> getMemberHistory(
7474
}
7575

7676
// GET: 특정 Repository의 분석 히스토리 조회, 모든 분석 결과 조회
77-
@GetMapping("/{userId}/repositories/{repositoriesId}")
77+
@GetMapping("/repositories/{repositoriesId}")
7878
@Transactional(readOnly = true)
7979
public ResponseEntity<ApiResponse<HistoryResponseDto>> getAnalysisByRepositoriesId(
80-
@PathVariable("userId") Long userId,
8180
@PathVariable("repositoriesId") Long repoId,
8281
HttpServletRequest httpRequest
8382
){
84-
8583
// 1. Repository 정보 조회
8684
Repositories repository = repositoryService.findById(repoId)
8785
.orElseThrow(() -> new BusinessException(ErrorCode.GITHUB_REPO_NOT_FOUND));
8886

8987
// 권한 검증
90-
validateAccess(httpRequest, userId, repository);
88+
validateAccess(httpRequest, repository);
9189

9290
// 2. 분석 결과 목록 조회 (최신순 정렬)
9391
List<AnalysisResult> analysisResults =
@@ -110,10 +108,9 @@ public ResponseEntity<ApiResponse<HistoryResponseDto>> getAnalysisByRepositories
110108
}
111109

112110
// GET: 특정 분석 결과 상세 조회
113-
@GetMapping("/{userId}/repositories/{repositoryId}/results/{analysisId}")
111+
@GetMapping("/repositories/{repositoryId}/results/{analysisId}")
114112
@Transactional(readOnly = true)
115113
public ResponseEntity<ApiResponse<AnalysisResultResponseDto>> getAnalysisDetail(
116-
@PathVariable Long userId,
117114
@PathVariable Long repositoryId,
118115
@PathVariable Long analysisId,
119116
HttpServletRequest httpRequest
@@ -126,7 +123,7 @@ public ResponseEntity<ApiResponse<AnalysisResultResponseDto>> getAnalysisDetail(
126123
}
127124

128125
// 권한 검증
129-
validateAccess(httpRequest, userId, analysisResult.getRepositories());
126+
validateAccess(httpRequest, analysisResult.getRepositories());
130127

131128
AnalysisResultResponseDto response =
132129
new AnalysisResultResponseDto(analysisResult, analysisResult.getScore());
@@ -197,34 +194,28 @@ public SseEmitter stream(@PathVariable Long userId,
197194
* - 공개 리포지토리: 누구나 접근 가능 (비로그인 포함)
198195
* - 비공개 리포지토리: 소유자만 접근 가능
199196
*/
200-
201-
private void validateAccess(HttpServletRequest requeset, Long pathUserId, Repositories repository) {
197+
private void validateAccess(HttpServletRequest requeset, Repositories repository) {
202198
Long jwtUserId = jwtUtil.getUserId(requeset);
199+
Long ownerId = repository.getUser().getId();
203200

204-
// 1. 공개 리포지토리일 경우
205-
if(repository.isPublicRepository()) {
206-
if (jwtUserId == null) {
207-
log.warn("비로그인 사용자의 공개 리포지토리 접근 시도: repoId={}", repository.getId());
208-
}
209-
210-
if (!jwtUserId.equals(pathUserId)) {
211-
log.warn("다른 사용자의 공개 리포지토리 접근 시도: jwtUserId={}, pathUserId={}, repoId={}",
212-
jwtUserId, pathUserId, repository.getId());
213-
}
201+
// 1. 공개 리포지토리는 누구나 접근 가능
202+
if (repository.isPublicRepository()) {
203+
log.info("공개 리포지토리 접근: repoId={}, jwtUserId={}",
204+
repository.getId(), jwtUserId);
214205
return;
215206
}
216207

217-
// 2. 비공개 리포지토리일 경우
218-
// 2-1. 비로그인
208+
// 2. 비공개 리포지토리는 로그인 필수
219209
if (jwtUserId == null) {
220-
log.warn("비로그인 사용자의 비공개 리포지토리 접근 시도: repoId={}", repository.getId());
221-
throw new BusinessException(ErrorCode.FORBIDDEN);
210+
log.warn("비로그인 사용자의 비공개 리포지토리 접근 시도: repoId={}",
211+
repository.getId());
212+
throw new BusinessException(ErrorCode.UNAUTHORIZED);
222213
}
223214

224-
// 2-2. 다른 사용자
225-
if (!jwtUserId.equals(pathUserId)) {
226-
log.warn("권한 없는 접근 시도: jwtUserId={}, pathUserId={}, repoId={}",
227-
jwtUserId, pathUserId, repository.getId());
215+
// 3. 비공개 리포지토리는 소유자만 접근 가능
216+
if (!Objects.equals(jwtUserId, ownerId)) {
217+
log.warn("권한 없는 사용자의 비공개 리포지토리 접근: jwtUserId={}, ownerId={}, repoId={}",
218+
jwtUserId, ownerId, repository.getId());
228219
throw new BusinessException(ErrorCode.FORBIDDEN);
229220
}
230221
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.backend.domain.analysis.lock;
2+
3+
import lombok.Getter;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.scheduling.annotation.Scheduled;
6+
import org.springframework.stereotype.Component;
7+
8+
import java.util.concurrent.ConcurrentHashMap;
9+
10+
@Slf4j
11+
@Component
12+
public class InMemoryLockManager {
13+
private static final long LOCK_TIMEOUT_MILLIS = 5 * 60 * 1000; // 락 유지 시간 5분
14+
15+
// 현재 진행 중인 분석 정보
16+
private final ConcurrentHashMap<String, LockInfo> processingRepositories = new ConcurrentHashMap<>();
17+
18+
// 만료된 락 정리
19+
@Scheduled(fixedDelay = 60000)
20+
public void cleanExpiredLocks() {
21+
processingRepositories.entrySet().removeIf(entry ->
22+
entry.getValue().isExpired(LOCK_TIMEOUT_MILLIS)); // 만료된 락만 정리
23+
}
24+
25+
// 락 획득
26+
public boolean tryLock(String key) {
27+
LockInfo newLock = new LockInfo(System.currentTimeMillis(), Thread.currentThread().getName());
28+
LockInfo existing = processingRepositories.putIfAbsent(key, newLock);
29+
30+
if (existing != null && !existing.isExpired(LOCK_TIMEOUT_MILLIS)) {
31+
log.warn("중복 분석 요청 차단: {}", key);
32+
return false;
33+
}
34+
35+
if (existing != null && existing.isExpired(LOCK_TIMEOUT_MILLIS)) {
36+
processingRepositories.put(key, newLock);
37+
log.info("만료된 락 재획득: {}", key);
38+
}
39+
40+
log.debug("락 획득 성공: {}", key);
41+
return true;
42+
}
43+
44+
// 락 타임스탬프 갱신
45+
public void refreshLock(String key) {
46+
processingRepositories.computeIfPresent(key, (k, old) ->
47+
new LockInfo(System.currentTimeMillis(), old.getOwner())
48+
);
49+
log.debug("락 타임스탬프 갱신: {}", key);
50+
}
51+
52+
// 락 해제
53+
public void releaseLock(String key) {
54+
processingRepositories.remove(key);
55+
log.debug("락 해제: {}", key);
56+
}
57+
58+
@Getter
59+
private static class LockInfo {
60+
private final long timestamp;
61+
private final String owner;
62+
63+
public LockInfo(long timestamp, String owner) {
64+
this.timestamp = timestamp;
65+
this.owner = owner;
66+
}
67+
68+
public boolean isExpired(long timeoutMillis) {
69+
return System.currentTimeMillis() - timestamp > timeoutMillis;
70+
}
71+
}
72+
}

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.backend.domain.analysis.service;
22

33
import com.backend.domain.analysis.entity.AnalysisResult;
4+
import com.backend.domain.analysis.lock.InMemoryLockManager;
45
import com.backend.domain.analysis.repository.AnalysisResultRepository;
56
import com.backend.domain.evaluation.service.EvaluationService;
67
import com.backend.domain.repository.dto.response.RepositoryData;
@@ -15,7 +16,6 @@
1516
import org.springframework.transaction.annotation.Transactional;
1617

1718
import java.util.List;
18-
import java.util.concurrent.ConcurrentHashMap;
1919

2020
@Slf4j
2121
@Service
@@ -26,9 +26,7 @@ public class AnalysisService {
2626
private final EvaluationService evaluationService;
2727
private final RepositoryJpaRepository repositoryJpaRepository;
2828
private final SseProgressNotifier sseProgressNotifier;
29-
30-
// 인메모리 락
31-
private final ConcurrentHashMap<String, Boolean> processingRepositories = new ConcurrentHashMap<>();
29+
private final InMemoryLockManager lockManager;
3230

3331
/* Analysis 분석 프로세스 오케스트레이션 담당
3432
* 1. GitHub URL 파싱 및 검증
@@ -44,9 +42,7 @@ public Long analyze(String githubUrl, Long userId) {
4442

4543
String cacheKey = userId + ":" + githubUrl;
4644

47-
// 현재 처리 중인지 체크
48-
if (processingRepositories.putIfAbsent(cacheKey, true) != null) {
49-
log.warn("⚠중복 분석 요청 차단: userId={}, url={}", userId, githubUrl);
45+
if (!lockManager.tryLock(cacheKey)) {
5046
throw new BusinessException(ErrorCode.ANALYSIS_IN_PROGRESS);
5147
}
5248

@@ -58,6 +54,7 @@ public Long analyze(String githubUrl, Long userId) {
5854

5955
try {
6056
repositoryData = repositoryService.fetchAndSaveRepository(owner, repo, userId);
57+
lockManager.refreshLock(cacheKey);
6158
log.info("🫠 Repository Data 수집 완료: {}", repositoryData);
6259
} catch (BusinessException e) {
6360
log.error("Repository 데이터 수집 실패: {}/{}", owner, repo, e);
@@ -73,6 +70,7 @@ public Long analyze(String githubUrl, Long userId) {
7370
// OpenAI API 데이터 분석 및 저장
7471
try {
7572
evaluationService.evaluateAndSave(repositoryData);
73+
lockManager.refreshLock(cacheKey);
7674
} catch (BusinessException e) {
7775
sseProgressNotifier.notify(userId, "error", "AI 평가 실패: " + e.getMessage());
7876
throw e;
@@ -82,7 +80,7 @@ public Long analyze(String githubUrl, Long userId) {
8280
return repositoryId;
8381
} finally {
8482
// 락 해제
85-
processingRepositories.remove(cacheKey);
83+
lockManager.releaseLock(cacheKey);
8684
log.info("분석 락 해제: cacheKey={}", cacheKey);
8785
}
8886
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public enum ErrorCode {
1212
INVALID_INPUT_VALUE("CMN003", HttpStatus.BAD_REQUEST, "잘못된 입력값입니다."),
1313
INVALID_TYPE_VALUE("CMN004", HttpStatus.BAD_REQUEST, "잘못된 타입의 값입니다."),
1414
MISSING_REQUEST_PARAMETER("CMN005", HttpStatus.BAD_REQUEST, "필수 요청 파라미터가 누락되었습니다."),
15+
UNAUTHORIZED("CMN006", HttpStatus.UNAUTHORIZED, "인증이 필요합니다."),
1516

1617
// ========== user 도메인 에러 ==========
1718
Login_Failed("U001", HttpStatus.BAD_REQUEST, "로그인에 실패했습니다."),
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.backend.domain.analysis.service;
2+
3+
import com.backend.domain.analysis.lock.InMemoryLockManager;
4+
import org.junit.jupiter.api.BeforeEach;
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.util.concurrent.*;
9+
import java.util.concurrent.atomic.AtomicInteger;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
13+
class InMemoryLockManagerTest {
14+
15+
private InMemoryLockManager lockManager;
16+
17+
@BeforeEach
18+
void setUp() {
19+
lockManager = new InMemoryLockManager();
20+
}
21+
22+
@DisplayName("같은 url이 동시에 여러 요청이 들어오면 하나만 락 획득")
23+
@Test
24+
void testConcurrentLockAcquisition() throws InterruptedException {
25+
String key = "user1:https://github.com/test/repo";
26+
int threadCount = 10;
27+
28+
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
29+
CountDownLatch latch = new CountDownLatch(threadCount);
30+
AtomicInteger successCount = new AtomicInteger();
31+
AtomicInteger failCount = new AtomicInteger();
32+
33+
for (int i = 0; i < threadCount; i++) {
34+
executor.submit(() -> {
35+
try {
36+
if (lockManager.tryLock(key)) {
37+
successCount.incrementAndGet();
38+
} else {
39+
failCount.incrementAndGet();
40+
}
41+
} finally {
42+
latch.countDown();
43+
}
44+
});
45+
}
46+
47+
latch.await(2, TimeUnit.SECONDS);
48+
executor.shutdown();
49+
50+
System.out.printf("성공: %d, 실패: %d%n", successCount.get(), failCount.get());
51+
assertThat(successCount.get()).isEqualTo(1);
52+
assertThat(failCount.get()).isEqualTo(9);
53+
54+
lockManager.releaseLock(key);
55+
assertThat(lockManager.tryLock(key)).isTrue();
56+
}
57+
58+
@DisplayName("서로 다른 url은 동시에 분석이 가능해야 함")
59+
@Test
60+
void testDifferentKeysCanLockSimultaneously() {
61+
String key1 = "user1:repo1";
62+
String key2 = "user1:repo2";
63+
64+
boolean lock1 = lockManager.tryLock(key1);
65+
boolean lock2 = lockManager.tryLock(key2);
66+
67+
assertThat(lock1).isTrue();
68+
assertThat(lock2).isTrue();
69+
}
70+
71+
@DisplayName("오래된 락 제거")
72+
@Test
73+
void testCleanExpiredLocksDoesNotCrash() {
74+
String key = "user3:repo3";
75+
76+
lockManager.tryLock(key);
77+
lockManager.cleanExpiredLocks();
78+
79+
assertThat(lockManager.tryLock(key)).isFalse();
80+
lockManager.releaseLock(key);
81+
assertThat(lockManager.tryLock(key)).isTrue();
82+
}
83+
84+
@DisplayName("타임스탬프 갱신")
85+
@Test
86+
void testRefreshLockDoesNotThrow() {
87+
String key = "user4:repo4";
88+
89+
lockManager.tryLock(key);
90+
lockManager.refreshLock(key);
91+
lockManager.releaseLock(key);
92+
93+
assertThat(lockManager.tryLock(key)).isTrue();
94+
}
95+
}

0 commit comments

Comments
 (0)