diff --git a/src/main/java/com/project/syncly/domain/folder/controller/FolderController.java b/src/main/java/com/project/syncly/domain/folder/controller/FolderController.java index f076915..5157a91 100644 --- a/src/main/java/com/project/syncly/domain/folder/controller/FolderController.java +++ b/src/main/java/com/project/syncly/domain/folder/controller/FolderController.java @@ -108,7 +108,7 @@ public ResponseEntity> restoreFolder( } @GetMapping("/{workspaceId}/folders/{folderId}/path") - @Operation(summary = "폴더 경로 조회", description = "워크스페이스 폴더의 breadcrumb 경로를 조회합니다.") + @Operation(summary = "폴더 경로 조회", description = "워크스페이스 폴더의 breadcrumb 경로를 조회합니다. (FolderClosure 패턴 사용)") public ResponseEntity> getFolderPath( @PathVariable Long workspaceId, @PathVariable Long folderId, @@ -125,6 +125,24 @@ public ResponseEntity> getFolderPath( return ResponseEntity.ok(CustomResponse.success(HttpStatus.OK, responseDto)); } + @GetMapping("/{workspaceId}/folders/{folderId}/path-recursive") + @Operation(summary = "성능테스트용 폴더 경로 조회 (재귀 방식)", description = "워크스페이스 폴더의 breadcrumb 경로를 재귀 방식으로 조회합니다. (성능 비교 테스트용)") + public ResponseEntity> getFolderPathRecursive( + @PathVariable Long workspaceId, + @PathVariable Long folderId, + @AuthenticationPrincipal PrincipalDetails userDetails + ) { + Long currentMemberId = Long.valueOf(userDetails.getName()); + + if (!workspaceMemberRepository.existsByWorkspaceIdAndMemberId(workspaceId, currentMemberId)) { + throw new WorkspaceException(WorkspaceErrorCode.NOT_WORKSPACE_MEMBER); + } + + FolderResponseDto.Path responseDto = folderQueryService.getFolderPathRecursive(workspaceId, folderId); + + return ResponseEntity.ok(CustomResponse.success(HttpStatus.OK, responseDto)); + } + @GetMapping("/{workspaceId}/root") @Operation(summary = "워크스페이스 루트 폴더 정보", description = "워크스페이스의 루트 폴더 ID와 기본 정보를 조회합니다.") public ResponseEntity> getWorkspaceRoot( diff --git a/src/main/java/com/project/syncly/domain/folder/repository/FolderRepository.java b/src/main/java/com/project/syncly/domain/folder/repository/FolderRepository.java index 18e0174..b920275 100644 --- a/src/main/java/com/project/syncly/domain/folder/repository/FolderRepository.java +++ b/src/main/java/com/project/syncly/domain/folder/repository/FolderRepository.java @@ -27,6 +27,9 @@ public interface FolderRepository extends JpaRepository { // 특정 워크스페이스의 폴더 조회 Optional findByIdAndWorkspaceId(Long folderId, Long workspaceId); + // parentId와 workspaceId로 폴더 조회 (재귀 방식 경로 조회용) + Optional findByIdAndWorkspaceIdAndDeletedAtIsNull(Long folderId, Long workspaceId); + // 여러 폴더 ID들을 일괄 soft delete 처리 @Query("UPDATE Folder f SET f.deletedAt = :deletedAt WHERE f.id IN :folderIds AND f.deletedAt IS NULL") @Modifying diff --git a/src/main/java/com/project/syncly/domain/folder/service/FolderQueryService.java b/src/main/java/com/project/syncly/domain/folder/service/FolderQueryService.java index e8190eb..1e7089a 100644 --- a/src/main/java/com/project/syncly/domain/folder/service/FolderQueryService.java +++ b/src/main/java/com/project/syncly/domain/folder/service/FolderQueryService.java @@ -5,6 +5,7 @@ public interface FolderQueryService { FolderResponseDto.Root getRootFolder(Long workspaceId); FolderResponseDto.Path getFolderPath(Long workspaceId, Long folderId); + FolderResponseDto.Path getFolderPathRecursive(Long workspaceId, Long folderId); FolderResponseDto.ItemList getFolderItems(Long workspaceId, Long folderId, String sort, String cursor, Integer limit, String search, Long uploaderId); FolderResponseDto.ItemList getTrashItems(Long workspaceId, String sort, String cursor, Integer limit, String search, Long uploaderId); } \ No newline at end of file diff --git a/src/main/java/com/project/syncly/domain/folder/service/FolderQueryServiceImpl.java b/src/main/java/com/project/syncly/domain/folder/service/FolderQueryServiceImpl.java index 02c1d71..ac931a2 100644 --- a/src/main/java/com/project/syncly/domain/folder/service/FolderQueryServiceImpl.java +++ b/src/main/java/com/project/syncly/domain/folder/service/FolderQueryServiceImpl.java @@ -75,6 +75,34 @@ public FolderResponseDto.Path getFolderPath(Long workspaceId, Long folderId) { return new FolderResponseDto.Path(pathItems); } + @Override + public FolderResponseDto.Path getFolderPathRecursive(Long workspaceId, Long folderId) { + // 폴더가 해당 워크스페이스에 속하는지 확인 + Folder folder = folderRepository.findByIdAndWorkspaceIdAndDeletedAtIsNull(folderId, workspaceId) + .orElseThrow(() -> new FolderException(FolderErrorCode.FOLDER_NOT_FOUND)); + + // 재귀적으로 경로 조회 + List pathItems = new ArrayList<>(); + buildPathRecursive(folder, workspaceId, pathItems); + + // 경로를 역순으로 변경 (루트부터 현재 폴더까지) + java.util.Collections.reverse(pathItems); + + return new FolderResponseDto.Path(pathItems); + } + + private void buildPathRecursive(Folder folder, Long workspaceId, List pathItems) { + // 현재 폴더를 경로에 추가 + pathItems.add(new FolderResponseDto.PathItem(folder.getId(), folder.getName())); + + // 부모 폴더가 있으면 재귀 호출 + if (folder.getParentId() != null) { + Folder parentFolder = folderRepository.findByIdAndWorkspaceIdAndDeletedAtIsNull(folder.getParentId(), workspaceId) + .orElseThrow(() -> new FolderException(FolderErrorCode.FOLDER_NOT_FOUND)); + buildPathRecursive(parentFolder, workspaceId, pathItems); + } + } + @Override public FolderResponseDto.ItemList getFolderItems(Long workspaceId, Long folderId, String sort, String cursor, Integer limit, String search, Long uploaderId) { // 폴더가 해당 워크스페이스에 속하는지 확인 diff --git a/src/main/java/com/project/syncly/domain/member/cache/MemberProfileCacheDTO.java b/src/main/java/com/project/syncly/domain/member/cache/MemberProfileCacheDTO.java new file mode 100644 index 0000000..658aba2 --- /dev/null +++ b/src/main/java/com/project/syncly/domain/member/cache/MemberProfileCacheDTO.java @@ -0,0 +1,11 @@ +package com.project.syncly.domain.member.cache; + +import lombok.Builder; + +@Builder +public record MemberProfileCacheDTO( + Long id, + String name, + String profileImageUrl +) { +} diff --git a/src/main/java/com/project/syncly/domain/member/cache/MemberProfileCacheService.java b/src/main/java/com/project/syncly/domain/member/cache/MemberProfileCacheService.java new file mode 100644 index 0000000..c975184 --- /dev/null +++ b/src/main/java/com/project/syncly/domain/member/cache/MemberProfileCacheService.java @@ -0,0 +1,94 @@ +package com.project.syncly.domain.member.cache; + +import com.project.syncly.domain.member.repository.MemberRepository; +import com.project.syncly.global.redis.enums.RedisKeyPrefix; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.*; + +@Service +@RequiredArgsConstructor +public class MemberProfileCacheService { + private static final Duration MEMBER_PROFILE_TTL = Duration.ofHours(1); + + private final RedisTemplate redisTemplate; + private final MemberRepository memberRepository; + + public void cacheProfile(MemberProfileCacheDTO profile) { + String key = RedisKeyPrefix.MEMBER_PROFILE.get(profile.id()); + redisTemplate.opsForValue().set(key, profile, MEMBER_PROFILE_TTL); + } + + public void removeProfileCached(Long memberId) { + String key = RedisKeyPrefix.MEMBER_PROFILE.get(memberId); + redisTemplate.delete(key); + } + + // 없는 Member(소프트딜리트)는 null 반환 + public MemberProfileCacheDTO getProfile(Long memberId) { + String key = RedisKeyPrefix.MEMBER_PROFILE.get(memberId); + MemberProfileCacheDTO profile = (MemberProfileCacheDTO) redisTemplate.opsForValue().get(key); + if (profile == null) { + profile = memberRepository.getMemberProfile(memberId); + if (profile != null) { + cacheProfile(profile); + } + } + return profile; + } + + // 없는 Member(소프트딜리트)는 map에 미포함 + public Map getProfiles(List memberIds) { + if (memberIds == null || memberIds.isEmpty()) return Collections.emptyMap(); + + //중복제거 + List distinctMemberIds = memberIds.stream().distinct().toList(); + + // redis에서 캐싱데이터 가져오기 + List keys = distinctMemberIds.stream() + .map(RedisKeyPrefix.MEMBER_PROFILE::get) + .toList(); + + List cached = redisTemplate.opsForValue().multiGet(keys); + + Map result = new LinkedHashMap<>(); + List missed = new ArrayList<>(); + + // 캐싱 데이터 넣고, 캐시미스 체크하기 + for (int i = 0; i < distinctMemberIds.size(); i++) { + Object obj = cached.get(i); + if (obj == null) missed.add(distinctMemberIds.get(i)); + else result.put(distinctMemberIds.get(i), (MemberProfileCacheDTO) obj); + } + + if (!missed.isEmpty()) { + // 캐시미스 DB 조회 + List profiles = memberRepository.getMemberProfiles(missed); + + // getValueSerializer()는 반환값이 RedisSerializer 이기 때문에 타입 지정을 해 줘야 serialize(@Nullable T value) 사용가능 + RedisSerializer valueSerializer = + (RedisSerializer) redisTemplate.getValueSerializer(); + // 파이프라인으로 한번에 저장 + redisTemplate.executePipelined((RedisCallback) connection -> { + for (MemberProfileCacheDTO dto : profiles) { + // RedisConnection은 저수준 명령어만 지원하므로 직접 직렬화 해줘야함. + String key = RedisKeyPrefix.MEMBER_PROFILE.get(dto.id()); + byte[] k = redisTemplate.getStringSerializer().serialize(key); + byte[] v = valueSerializer.serialize(dto); + connection.stringCommands().setEx(k, MEMBER_PROFILE_TTL.toSeconds(), v); // 명령어 큐잉 + } + return null; + }); + // 응답 결과에 병합 + profiles.forEach(dto -> result.put(dto.id(), dto)); + } + + return result; + } + +} diff --git a/src/main/java/com/project/syncly/domain/member/repository/MemberRepository.java b/src/main/java/com/project/syncly/domain/member/repository/MemberRepository.java index de051f2..0e01d12 100644 --- a/src/main/java/com/project/syncly/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/project/syncly/domain/member/repository/MemberRepository.java @@ -1,9 +1,13 @@ package com.project.syncly.domain.member.repository; +import com.project.syncly.domain.member.cache.MemberProfileCacheDTO; import com.project.syncly.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -13,4 +17,22 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByEmail(String email); + + @Query(""" + SELECT new com.project.syncly.domain.member.cache.MemberProfileCacheDTO( + m.id, m.name, m.profileImage + ) + FROM Member m + WHERE m.id = :memberId + """) + MemberProfileCacheDTO getMemberProfile(@Param("memberId") Long memberIds); + + @Query(""" + SELECT new com.project.syncly.domain.member.cache.MemberProfileCacheDTO( + m.id, m.name, m.profileImage + ) + FROM Member m + WHERE m.id IN :memberIds + """) + List getMemberProfiles(@Param("memberIds") List memberIds); } \ No newline at end of file diff --git a/src/main/java/com/project/syncly/global/redis/enums/RedisKeyPrefix.java b/src/main/java/com/project/syncly/global/redis/enums/RedisKeyPrefix.java index 4bc401f..1ebf716 100644 --- a/src/main/java/com/project/syncly/global/redis/enums/RedisKeyPrefix.java +++ b/src/main/java/com/project/syncly/global/redis/enums/RedisKeyPrefix.java @@ -25,6 +25,9 @@ public enum RedisKeyPrefix { REFRESH_CURRENT("refresh:current:%s:%s"), CASHED_UA_HASH("CASHED:UA_HASH:%s:%s"), REFRESH_USED("rt:used:%s"), + + //profile + MEMBER_PROFILE("PROFILE_CACHE:"), ; private final String prefix;