diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..80f5ed2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,71 @@ +name: Deploy to EC2 with Docker hub + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + # 소스코드 체크아웃 + - name: Checkout code + uses: actions/checkout@v5 + + # Gradle 실행 권한 부여 + - name: Make gradlew executable + run: chmod +x ./gradlew + + # Docker hub 로그인 + - name: Log in to Docker Hub + uses: docker/login-action@v3.5.0 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Dockerfile을 사용하여 이미지를 빌드하고 Docker Hub에 푸시합니다. + - name: Build and push Docker image + uses: docker/build-push-action@v6.16.0 + with: + context: . + file: ./Dockerfile + push: true + # Docker 레이어 캐시 활성화 + cache-from: type=gha + cache-to: type=gha,mode=max + tags: | + ${{ secrets.DOCKER_USERNAME }}/gitdeun:latest + ${{ secrets.DOCKER_USERNAME }}/gitdeun:${{ github.sha }} + + # 3. EC2에서 환경 변수를 설정하고 애플리케이션 실행 + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{secrets.EC2_HOST}} + username: ${{secrets.EC2_USERNAME}} + key: ${{ secrets.EC2_PRIVATE_KEY }} + port: 22 + script: | + set -eux + # Docker Hub 로그인 + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + # 최신 이미지 가져오기 + docker network create gitdeun-network || true + # 최신 커밋 SHA로 배포(불변 태그) + IMAGE="${{ secrets.DOCKER_USERNAME }}/gitdeun:${{ github.sha }}" + docker pull "$IMAGE" + docker stop gitdeun || true + docker rm gitdeun || true + docker run -d \ + --name gitdeun \ + --restart unless-stopped \ + --env SPRING_PROFILES_ACTIVE=prod,s3Bucket \ + --env-file ${{ secrets.EC2_TARGET_PATH }}/.env \ + --network gitdeun-network \ + -p 8080:8080 \ + -v ${{ secrets.EC2_TARGET_PATH }}/logs:/app/logs \ + "$IMAGE" + # 오래된 이미지 정리 + docker image prune -f --filter "until=168h" diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java b/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java index c135e4c..23bc660 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java @@ -26,8 +26,9 @@ public ResponseEntity createCodeReference( @PathVariable Long mapId, @PathVariable String nodeKey, @Valid @RequestBody CreateRequest request, + @RequestHeader("Authorization") String authorizationHeader, @AuthenticationPrincipal CustomUserDetails userDetails) { - return ResponseEntity.status(HttpStatus.CREATED).body(codeReferenceService.createReference(mapId, nodeKey, userDetails.getId(), request)); + return ResponseEntity.status(HttpStatus.CREATED).body(codeReferenceService.createReference(mapId, nodeKey, userDetails.getId(), request, authorizationHeader)); } // 특정 코드 참조 상세 조회 @@ -55,8 +56,9 @@ public ResponseEntity updateCodeReference( @PathVariable Long mapId, @PathVariable Long refId, @Valid @RequestBody CreateRequest request, + @RequestHeader("Authorization") String authorizationHeader, @AuthenticationPrincipal CustomUserDetails userDetails) { - return ResponseEntity.ok(codeReferenceService.updateReference(mapId, refId, userDetails.getId(), request)); + return ResponseEntity.ok(codeReferenceService.updateReference(mapId, refId, userDetails.getId(), request, authorizationHeader)); } // 코드 참조 삭제 diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java b/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java index bffa089..1b8fd98 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java @@ -9,13 +9,18 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.fastapi.dto.NodeDto; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapGraphResponseDto; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; +import com.teamEWSN.gitdeun.mindmap.util.FileContentCache; +import com.teamEWSN.gitdeun.mindmap.util.MindmapGraphCache; import com.teamEWSN.gitdeun.mindmapmember.service.MindmapAuthService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; @@ -27,10 +32,12 @@ public class CodeReferenceService { private final MindmapRepository mindmapRepository; private final MindmapAuthService mindmapAuthService; private final CodeReferenceMapper codeReferenceMapper; + private final MindmapGraphCache mindmapGraphCache; + private final FileContentCache fileContentCache; private final FastApiClient fastApiClient; @Transactional - public ReferenceResponse createReference(Long mapId, String nodeKey, Long userId, CreateRequest request) { + public ReferenceResponse createReference(Long mapId, String nodeKey, Long userId, CreateRequest request, String authorizationHeader) throws GlobalException { // 1. 권한 확인 if (!mindmapAuthService.hasEdit(mapId, userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); @@ -40,6 +47,8 @@ public ReferenceResponse createReference(Long mapId, String nodeKey, Long userId Mindmap mindmap = mindmapRepository.findById(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + validateCodeReferenceRequest(mindmap, nodeKey, request, authorizationHeader); + // 3. CodeReference 엔티티 생성 및 저장 CodeReference codeReference = CodeReference.builder() .mindmap(mindmap) @@ -51,7 +60,6 @@ public ReferenceResponse createReference(Long mapId, String nodeKey, Long userId CodeReference savedReference = codeReferenceRepository.save(codeReference); - // 4. DTO로 변환하여 반환 return codeReferenceMapper.toReferenceResponse(savedReference); } @@ -77,7 +85,6 @@ public ReferenceDetailResponse getReferenceDetail(Long mapId, Long refId, Long u @Transactional(readOnly = true) public List getReferencesForNode(Long mapId, String nodeKey, Long userId) { - // 1. 권한 확인 (읽기) if (!mindmapAuthService.hasView(mapId, userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } @@ -92,7 +99,7 @@ public List getReferencesForNode(Long mapId, String nodeKey, } @Transactional - public ReferenceResponse updateReference(Long mapId, Long refId, Long userId, CreateRequest request) { + public ReferenceResponse updateReference(Long mapId, Long refId, Long userId, CreateRequest request, String authorizationHeader) { // 1. 권한 확인 if (!mindmapAuthService.hasEdit(mapId, userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); @@ -102,6 +109,8 @@ public ReferenceResponse updateReference(Long mapId, Long refId, Long userId, Cr CodeReference codeReference = codeReferenceRepository.findByMindmapIdAndId(mapId, refId) .orElseThrow(() -> new GlobalException(ErrorCode.CODE_REFERENCE_NOT_FOUND)); + validateCodeReferenceRequest(codeReference.getMindmap(), codeReference.getNodeKey(), request, authorizationHeader); + // 3. 엔티티 정보 업데이트 codeReference.update(request.getFilePath(), request.getStartLine(), request.getEndLine()); @@ -136,4 +145,36 @@ private String extractLines(String fullContent, Integer startLine, Integer endLi } return sb.toString(); } + + // 코드 참조 검증 + private void validateCodeReferenceRequest(Mindmap mindmap, String nodeKey, CreateRequest request, String authorizationHeader) { + String repoUrl = mindmap.getRepo().getGithubRepoUrl(); + LocalDateTime lastCommit = mindmap.getRepo().getLastCommit(); + + // 그래프 데이터에서 노드 정보 가져오기 + MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache(repoUrl, lastCommit, authorizationHeader); + NodeDto targetNode = graphData.getNodes().stream() + .filter(node -> nodeKey.equals(node.getKey())) + .findFirst() + .orElseThrow(() -> new GlobalException(ErrorCode.NODE_NOT_FOUND)); + + // 요청된 filePath가 노드에 실제 포함된 파일인지 확인 + boolean fileExists = targetNode.getRelatedFiles().stream() + .anyMatch(file -> request.getFilePath().equals(file.getFilePath())); + if (!fileExists) { + throw new GlobalException(ErrorCode.FILE_NOT_FOUND_IN_NODE); + } + + // 파일의 전체 내용을 가져와 총 라인 수 계산 + String fileContent = fileContentCache.getFileContentWithCache(repoUrl, request.getFilePath(), lastCommit, authorizationHeader); + int totalLines = fileContent.split("\\r?\\n").length; + + // 요청된 라인 범위(startLine, endLine)가 유효한지 확인 + Integer start = request.getStartLine(); + Integer end = request.getEndLine(); + if (start <= 0 || start > end || end > totalLines) { + throw new GlobalException(ErrorCode.INVALID_LINE_RANGE); + } + } + } diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/controller/CodeReviewController.java b/src/main/java/com/teamEWSN/gitdeun/codereview/controller/CodeReviewController.java index e13dc29..32b946a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereview/controller/CodeReviewController.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/controller/CodeReviewController.java @@ -27,7 +27,7 @@ public class CodeReviewController { private final CodeReviewService codeReviewService; // 노드에 대한 코드 리뷰 생성 - @PostMapping(value = "/mindmaps/{mapId}/nodes/{nodeKey}/code-reviews", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(value = "/mindmaps/{mapId}/nodes/{nodeKey}/code-reviews", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) public ResponseEntity createNodeCodeReview( @PathVariable Long mapId, @PathVariable String nodeKey, @@ -38,7 +38,7 @@ public ResponseEntity createNodeCodeReview( } // 코드 참조에 대한 코드 리뷰 생성 - @PostMapping(value = "/references/{refId}/code-reviews", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(value = "/references/{refId}/code-reviews", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) public ResponseEntity createReferenceCodeReview( @PathVariable Long refId, @Valid @RequestPart("request") CreateRequest request, diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewRequestDtos.java b/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewRequestDtos.java index ca67e8e..c9871ce 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewRequestDtos.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewRequestDtos.java @@ -12,7 +12,7 @@ public class CodeReviewRequestDtos { @Getter @NoArgsConstructor public static class CreateRequest { - @NotEmpty(message = "첫 댓글 내용은 비워둘 수 없습니다.") + @NotEmpty(message = "댓글 내용은 비워둘 수 없습니다.") private String content; private EmojiType emojiType; } diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java b/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java index b42af1a..314308d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java @@ -24,7 +24,7 @@ public class CommentController { private final CommentService commentService; // 특정 코드 리뷰에 댓글 또는 대댓글 작성 - @PostMapping(value = "/code-reviews/{reviewId}/comments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(value = "/code-reviews/{reviewId}/comments", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) public ResponseEntity createComment( @PathVariable Long reviewId, @Valid @RequestPart("request") CreateRequest request, diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java index b5dcbcf..82757a5 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java @@ -11,7 +11,9 @@ import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; @Entity @Getter @@ -41,11 +43,11 @@ public class Comment extends AuditedEntity { @Builder.Default @OneToMany(mappedBy = "parentComment") - private List replies = new ArrayList<>(); + private Set replies = new HashSet<>(); @Builder.Default @OneToMany(mappedBy = "comment", cascade = CascadeType.ALL, orphanRemoval = true) - private List attachments = new ArrayList<>(); + private Set attachments = new HashSet<>(); @Column(columnDefinition = "TEXT", nullable = false) private String content; diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentAttachmentService.java b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentAttachmentService.java index 1d67f96..0f05e2b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentAttachmentService.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentAttachmentService.java @@ -13,6 +13,7 @@ import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -61,7 +62,7 @@ private AttachmentType determineAttachmentType(String mimeType) { * 첨부파일 목록을 S3와 DB에서 모두 삭제 (Hard Delete) */ @Transactional - public void deleteAttachments(List attachments) { + public void deleteAttachments(Set attachments) { if (CollectionUtils.isEmpty(attachments)) { return; } diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java index 61cc2b5..05c7d32 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; @Service @@ -108,7 +109,7 @@ public List getCommentsAsTree(Long reviewId) { return topLevelComments; } - @Transactional(readOnly = true) + @Transactional public List addAttachmentsToComment(Long commentId, Long userId, List files) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new GlobalException(ErrorCode.COMMENT_NOT_FOUND)); @@ -170,7 +171,7 @@ public void deleteAttachment(Long attachmentId, Long userId) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } - attachmentService.deleteAttachments(List.of(attachment)); + attachmentService.deleteAttachments(Set.of(attachment)); } @Transactional diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 4269304..2ca0703 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -88,7 +88,8 @@ public enum ErrorCode { COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW-003", "해당 댓글을 찾을 수 없습니다."), CANNOT_RESOLVE_REPLY(HttpStatus.BAD_REQUEST, "REVIEW-004", "대댓글은 해결 상태로 변경할 수 없습니다."), EMOJI_ON_REPLY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "REVIEW-005", "이모지는 최상위 댓글에만 추가할 수 있습니다."), - + FILE_NOT_FOUND_IN_NODE(HttpStatus.NOT_FOUND, "REVIEW-006", "해당 노드에서 파일을 찾을 수 없습니다."), + INVALID_LINE_RANGE(HttpStatus.NOT_FOUND, "REVIEW-007", "유효한 코드 블록 범위가 아닙니다."), // 방문기록 관련 HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "VISITHISTORY-001", "방문 기록을 찾을 수 없습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java index 0f40a48..8605715 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -399,6 +399,7 @@ public static class SuggestionAutoResponse { private Integer total_target_files; private Integer created; private List items; + private String aggregate_node_key; } @Getter diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java index 31a1456..d8d41cd 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java @@ -9,7 +9,8 @@ public enum CacheType { SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200), // 자동완성 캐시 USER_SKILLS("userSkills", 1, 500), // 사용자 개발기술 캐시 MINDMAP_GRAPH_L1("mindmapGraphL1", 100, 1), // 30분, 최대 100개 - VISIT_HISTORY_SUMMARY("visitHistorySummary", 1000, 1); // 1시간, 최대 1000개 + VISIT_HISTORY_SUMMARY("visitHistorySummary", 1000, 1), // 1시간, 최대 1000개 + FILE_CONTENT_L1("fileContentL1", 500, 1); private final String cacheName; private final int expiredAfterWrite; // 시간(hour) 단위 diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/FileWithCodeDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/FileWithCodeDto.java new file mode 100644 index 0000000..be00987 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/FileWithCodeDto.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.mindmap.dto; + +import com.teamEWSN.gitdeun.common.fastapi.dto.RelatedFileDto; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +public class FileWithCodeDto { + private final String fileName; + private final String filePath; + private final String codeContents; + private final int lineCount; + + public FileWithCodeDto(RelatedFileDto relatedFile, String codeContents) { + this.fileName = relatedFile.getFileName(); + this.filePath = relatedFile.getFilePath(); + this.codeContents = codeContents; + this.lineCount = codeContents == null ? 0 : codeContents.split("\\r?\\n").length; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/NodeCodeResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/NodeCodeResponseDto.java index 78aef5c..8500003 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/NodeCodeResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/NodeCodeResponseDto.java @@ -12,6 +12,5 @@ public class NodeCodeResponseDto { private String nodeKey; private String nodeLabel; - private List filePaths; - private Map codeContents; // filePath -> full code content + private List files; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java index cea5886..43aea6d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java @@ -10,6 +10,7 @@ import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.mindmap.entity.PromptHistory; import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; +import com.teamEWSN.gitdeun.mindmap.util.FileContentCache; import com.teamEWSN.gitdeun.mindmap.util.MindmapRequestValidator; import com.teamEWSN.gitdeun.notification.dto.NotificationCreateDto; import com.teamEWSN.gitdeun.notification.entity.NotificationType; @@ -38,6 +39,7 @@ public class MindmapOrchestrationService { private final UserRepository userRepository; private final MindmapRepository mindmapRepository; private final MindmapRequestValidator requestValidator; + private final FileContentCache fileContentCache; /** * 비동기적으로 마인드맵 생성 과정을 총괄 @@ -92,6 +94,9 @@ public void refreshMindmap(Long mapId, Long userId, String authHeader) { String repoUrl = mindmap.getRepo().getGithubRepoUrl(); String prompt = (appliedPrompt != null) ? appliedPrompt.getPrompt() : null; + // FastAPI 분석 요청 전, 관련된 모든 파일 캐시를 무효화합니다. + fileContentCache.evictFileCacheForRepo(repoUrl); + // FastAPI 분석 요청 AnalysisResultDto analysisResult = fastApiClient.refreshMindmap( repoUrl, @@ -121,6 +126,9 @@ public void refreshMindmap(Long mapId, Long userId, String authHeader) { @Async("mindmapExecutor") public void cleanUpMindmapData(String repoUrl, String authorizationHeader) { try { + // 노드별 코드 파일 캐시 무효화 + fileContentCache.evictFileCacheForRepo(repoUrl); + // 마인드맵 arangoDB 삭제 요청 fastApiClient.deleteMindmapData(repoUrl, authorizationHeader); log.info("ArangoDB 데이터 비동기 삭제 완료: {}", repoUrl); diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index bdb3a1a..3f6d6ef 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -24,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import java.time.LocalDateTime; import java.util.List; @Slf4j @@ -90,6 +91,7 @@ public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String authH // 캐싱된 그래프 데이터 조회 MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache( mindmap.getRepo().getGithubRepoUrl(), + mindmap.getRepo().getLastCommit(), authHeader ); @@ -145,12 +147,15 @@ public MindmapDetailResponseDto updateMindmapFromPromptAnalysis(Long mapId, Stri * 마인드맵 업데이트 시 공통 로직 (캐시 무효화, 새 데이터 조회, SSE 전송) */ private MindmapDetailResponseDto evictCacheAndBroadcastUpdate(Mindmap mindmap, String authHeader) { + LocalDateTime lastCommitTime = mindmap.getRepo().getLastCommit(); + // 그래프 캐시 무효화 - mindmapGraphCache.evictCache(mindmap.getRepo().getGithubRepoUrl()); + mindmapGraphCache.evictCache(mindmap.getRepo().getGithubRepoUrl(), lastCommitTime); // 새로운 그래프 데이터 조회 MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache( mindmap.getRepo().getGithubRepoUrl(), + lastCommitTime, authHeader ); @@ -176,7 +181,7 @@ public Repo deleteMindmap(Long mapId, Long userId) { .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); // 삭제 시 관련 캐시도 무효화 - mindmapGraphCache.evictCache(mindmap.getRepo().getGithubRepoUrl()); + mindmapGraphCache.evictCache(mindmap.getRepo().getGithubRepoUrl(), mindmap.getRepo().getLastCommit()); mindmap.softDelete(); log.info("마인드맵 소프트 삭제 완료 (DB) - ID: {}", mapId); @@ -199,14 +204,16 @@ public void updateMindmapFromWebhook(WebhookUpdateDto dto, String authHeader) { .toList(); repo.updateWithWebhookData(dto); + LocalDateTime lastCommitTime = repo.getLastCommit(); // Webhook 업데이트 시 관련 캐시 무효화 - mindmapGraphCache.evictCache(repo.getGithubRepoUrl()); + mindmapGraphCache.evictCache(repo.getGithubRepoUrl(), lastCommitTime); for (Mindmap mindmap : mindmapsToUpdate) { // 새로운 그래프 데이터로 업데이트된 응답 생성 MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache( repo.getGithubRepoUrl(), + lastCommitTime, authHeader ); diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/NodeService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/NodeService.java index 3c3389d..cb6fa82 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/NodeService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/NodeService.java @@ -2,21 +2,22 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; -import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; -import com.teamEWSN.gitdeun.common.fastapi.dto.MindmapGraphDto; import com.teamEWSN.gitdeun.common.fastapi.dto.NodeDto; +import com.teamEWSN.gitdeun.mindmap.dto.FileWithCodeDto; import com.teamEWSN.gitdeun.common.fastapi.dto.RelatedFileDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapGraphResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.NodeCodeResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.NodeSimpleDto; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; +import com.teamEWSN.gitdeun.mindmap.util.FileContentCache; import com.teamEWSN.gitdeun.mindmap.util.MindmapGraphCache; import com.teamEWSN.gitdeun.mindmapmember.service.MindmapAuthService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.Map; @@ -28,7 +29,7 @@ public class NodeService { private final MindmapAuthService mindmapAuthService; private final MindmapRepository mindmapRepository; private final MindmapGraphCache mindmapGraphCache; - private final FastApiClient fastApiClient; + private final FileContentCache fileContentCache; /** * (테스트용) 마인드맵의 모든 노드 목록을 간략하게 조회합니다. @@ -39,8 +40,9 @@ public List getNodeList(Long mapId, String authorizationHeader) { .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); String repoUrl = mindmap.getRepo().getGithubRepoUrl(); + LocalDateTime lastCommit = mindmap.getRepo().getLastCommit(); // lastCommit 정보 가져오기 - MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache(repoUrl, authorizationHeader); + MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache(repoUrl, lastCommit, authorizationHeader); if (graphData == null || graphData.getNodes() == null) { return Collections.emptyList(); } @@ -60,9 +62,11 @@ public NodeCodeResponseDto getNodeDetailsWithCode(Long mapId, String nodeKey, Lo .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); String repoUrl = mindmap.getRepo().getGithubRepoUrl(); + LocalDateTime lastCommit = mindmap.getRepo().getLastCommit(); + // 1. 캐시/API를 통해 그래프 데이터를 가져옵니다. - MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache(repoUrl, authorizationHeader); + MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache(repoUrl, lastCommit, authorizationHeader); if (graphData == null || !graphData.getSuccess()) { throw new GlobalException(ErrorCode.MINDMAP_NOT_FOUND); } @@ -75,18 +79,23 @@ public NodeCodeResponseDto getNodeDetailsWithCode(Long mapId, String nodeKey, Lo List filePaths = targetNode.getRelatedFiles(); - // 3. 파일 경로 목록을 사용하여 각 파일의 전체 코드를 가져옵니다. - Map codeContents = filePaths.parallelStream() + // 3. 파일 경로 목록을 사용하여 각 파일의 전체 코드를 가져옴 + Map codeContentsMap = filePaths.parallelStream() .collect(Collectors.toConcurrentMap( filePath -> filePath, - filePath -> fastApiClient.getFileRaw(repoUrl, filePath.getFilePath(), authorizationHeader) + filePath -> fileContentCache.getFileContentWithCache(repoUrl, filePath.getFilePath(), lastCommit, authorizationHeader) )); + // 4. Map을 List 형태로 변환 + List filesWithCode = codeContentsMap.entrySet().stream() + .map(entry -> new FileWithCodeDto(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + + // 5. 변경된 DTO로 응답 생성 return new NodeCodeResponseDto( targetNode.getKey(), targetNode.getLabel(), - filePaths, - codeContents + filesWithCode ); } } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/util/FileContentCache.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/FileContentCache.java new file mode 100644 index 0000000..df6cd4b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/FileContentCache.java @@ -0,0 +1,113 @@ +package com.teamEWSN.gitdeun.mindmap.util; + +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FileContentCache { + + private final RedisTemplate redisTemplate; + private final FastApiClient fastApiClient; + private final FileContentL1Cache l1Cache; + + @Component + public static class FileContentL1Cache { + @Cacheable(value = "FILE_CONTENT_L1", key = "#key") + public String getFromL1Cache(String key) { + return null; + } + + @CachePut(value = "FILE_CONTENT_L1", key = "#key") + public String cacheToL1(String key, String content) { + return content; + } + + @CacheEvict(value = "FILE_CONTENT_L1", allEntries = true) + public void evictL1Cache() { + } + } + + public String getFileContentWithCache(String repoUrl, String filePath, LocalDateTime lastCommit, String authHeader) { + String cacheKey = "file-content:" + repoUrl + ":" + filePath + ":" + lastCommit.toString(); + + // 1. L1 캐시 확인 + String content = l1Cache.getFromL1Cache(cacheKey); + if (content != null) { + log.debug("파일 내용 L1 캐시 히트 - key: {}", cacheKey); + return content; + } + + // 2. L2 캐시 확인 + try { + content = (String) redisTemplate.opsForValue().get(cacheKey); + if (content != null) { + log.debug("파일 내용 L2 캐시 히트 - key: {}", cacheKey); + l1Cache.cacheToL1(cacheKey, content); // L1에 저장 + return content; + } + } catch (Exception e) { + log.warn("Redis 조회 실패, API 직접 호출 - key: {}", cacheKey, e); + } + + // 3. FastAPI 실시간 조회 + content = fastApiClient.getFileRaw(repoUrl, filePath, authHeader); + + // 4. L1, L2 캐시에 저장 + if (content != null) { + l1Cache.cacheToL1(cacheKey, content); + try { + redisTemplate.opsForValue().set(cacheKey, content, Duration.ofHours(2)); // L2: 2시간 + } catch (Exception e) { + log.warn("Redis 저장 실패 - key: {}", cacheKey, e); + } + } + return content; + } + + // 캐시 무효화 + public void evictFileCacheForRepo(String repoUrl) { + // L1 캐시는 전체 삭제 + l1Cache.evictL1Cache(); + + // L2 캐시는 패턴으로 검색하여 삭제 + // L2 캐시(Redis)는 SCAN을 사용하여 안전하게 삭제 + String pattern = "file-content:" + repoUrl + ":*"; + try { + Set keysToDelete = redisTemplate.execute((RedisCallback>) connection -> { + Set keys = new HashSet<>(); + + try (Cursor cursor = connection.keyCommands().scan(ScanOptions.scanOptions().match(pattern).count(100).build())) { + + while (cursor.hasNext()) { + keys.add(new String(cursor.next(), StandardCharsets.UTF_8)); + } + } + return keys; + }); + + if (keysToDelete != null && !keysToDelete.isEmpty()) { + redisTemplate.delete(keysToDelete); + log.info("{}개의 L2 파일 캐시(Redis) 무효화 완료 - pattern: {}", keysToDelete.size(), pattern); + } + } catch (Exception e) { + log.warn("Redis 파일 캐시 무효화 실패 - pattern: {}", pattern, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapGraphCache.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapGraphCache.java index af39e71..0905c52 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapGraphCache.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapGraphCache.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Component; import java.time.Duration; +import java.time.LocalDateTime; @Slf4j @Component @@ -20,29 +21,29 @@ public class MindmapGraphCache { private final MindmapL1Cache mindmapL1Cache; // L2 캐시: Redis - public MindmapGraphResponseDto getGraphWithHybridCache(String repoUrl, String authHeader) { + public MindmapGraphResponseDto getGraphWithHybridCache(String repoUrl, LocalDateTime lastCommit, String authHeader) { String mapId = extractMapId(repoUrl); + String versionedMapId = mapId + ":" + lastCommit.toString(); // 버전 정보를 포함한 키 // 1. L1 캐시 확인 (Caffeine) - MindmapGraphResponseDto l1Result = mindmapL1Cache.getGraphFromL1Cache(mapId); + MindmapGraphResponseDto l1Result = mindmapL1Cache.getGraphFromL1Cache(versionedMapId); if (l1Result != null) { - log.debug("마인드맵 그래프 L1 캐시 히트 - mapId: {}", mapId); + log.debug("마인드맵 그래프 L1 캐시 히트 - key: {}", versionedMapId); return l1Result; } // 2. L2 캐시 확인 (Redis) - String redisKey = "mindmap:graph:" + mapId; + String redisKey = "mindmap:graph:" + versionedMapId; try { MindmapGraphResponseDto l2Result = (MindmapGraphResponseDto) redisTemplate.opsForValue().get(redisKey); if (l2Result != null) { - log.debug("마인드맵 그래프 L2 캐시 히트 - mapId: {}", mapId); - // L1 캐시에 다시 저장 - mindmapL1Cache.cacheToL1(mapId, l2Result); + log.debug("마인드맵 그래프 L2 캐시 히트 - key: {}", redisKey); + mindmapL1Cache.cacheToL1(versionedMapId, l2Result); return l2Result; } } catch (Exception e) { - log.warn("Redis 캐시 조회 실패 - mapId: {}, FastAPI 직접 호출로 진행", mapId, e); + log.warn("Redis 캐시 조회 실패 - key: {}, FastAPI 직접 호출로 진행", redisKey, e); } // 3. FastAPI 실시간 조회 @@ -51,15 +52,15 @@ public MindmapGraphResponseDto getGraphWithHybridCache(String repoUrl, String au MindmapGraphDto graphDto = fastApiClient.getGraph(mapId, authHeader); MindmapGraphResponseDto responseDto = convertToResponseDto(graphDto); - // 4. 양방향 캐싱 + // 4. 양방향 캐싱 (버전 포함된 키 사용) try { redisTemplate.opsForValue().set(redisKey, responseDto, Duration.ofHours(2)); // L2: 2시간 - log.debug("Redis 캐시 저장 완료 - mapId: {}", mapId); + log.debug("Redis 캐시 저장 완료 - key: {}", redisKey); } catch (Exception e) { - log.warn("Redis 캐시 저장 실패 - mapId: {}", mapId, e); + log.warn("Redis 캐시 저장 실패 - key: {}", redisKey, e); } - mindmapL1Cache.cacheToL1(mapId, responseDto); // L1: 30분 (CacheType에서 정의) + mindmapL1Cache.cacheToL1(versionedMapId, responseDto); return responseDto; @@ -74,23 +75,22 @@ public MindmapGraphResponseDto getGraphWithHybridCache(String repoUrl, String au } } - // 캐시 무효화 (마인드맵 새로고침 시) - public void evictCache(String repoUrl) { + // 캐시 무효화 (마인드맵 새로고침 또는 프롬프트 적용 시) + public void evictCache(String repoUrl, LocalDateTime lastCommit) { String mapId = extractMapId(repoUrl); - String redisKey = "mindmap:graph:" + mapId; + String versionedMapId = mapId + ":" + lastCommit.toString(); + String redisKey = "mindmap:graph:" + versionedMapId; try { redisTemplate.delete(redisKey); - log.info("마인드맵 그래프 캐시 무효화 완료 - mapId: {}", mapId); - // L1 캐시(Caffeine) 삭제 - mindmapL1Cache.evictL1Cache(mapId); + mindmapL1Cache.evictL1Cache(versionedMapId); + log.info("마인드맵 그래프 캐시 무효화 완료 - key: {}", redisKey); } catch (Exception e) { - log.warn("캐시 무효화 실패 - mapId: {}", mapId, e); + log.warn("캐시 무효화 실패 - key: {}", redisKey, e); } } // === Helper Methods === - private String extractMapId(String repoUrl) { String[] segments = repoUrl.split("/"); return segments[segments.length - 1].replaceAll("\\.git$", ""); @@ -106,4 +106,4 @@ private MindmapGraphResponseDto convertToResponseDto(MindmapGraphDto graphDto) { .edges(graphDto.getEdges()) .build(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapRequestValidator.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapRequestValidator.java index b9b368d..c01fdb4 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapRequestValidator.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapRequestValidator.java @@ -45,8 +45,8 @@ public class MindmapRequestValidator { * 마인드맵 생성 요청 종합 검증 * * @param repoUrl 저장소 URL (필수) - * @param prompt 사용자 프롬프트 (선택) - * @param userId 요청 사용자 ID + * @param prompt 사용자 프롬프트 (선택) + * @param userId 요청 사용자 ID * @return 검증된 요청 정보 */ public ValidatedMindmapRequest validateAndProcess(String repoUrl, String prompt, Long userId) {