From bf67822076cbba70456318823277b179cbb790e1 Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Sun, 12 Oct 2025 16:37:34 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=8F=B4=EB=8D=94=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9E=AC=EA=B7=80?= =?UTF-8?q?=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cicd.yml | 2 +- .../folder/controller/FolderController.java | 20 ++++++++++++- .../folder/repository/FolderRepository.java | 3 ++ .../folder/service/FolderQueryService.java | 1 + .../service/FolderQueryServiceImpl.java | 28 +++++++++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d89a932..648a41e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v4anjsrk with: distribution: 'temurin' java-version: '17' 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) { // 폴더가 해당 워크스페이스에 속하는지 확인 From dd3f3f5fda5a922b520fb77231cd4a66792fd9a7 Mon Sep 17 00:00:00 2001 From: seonghooncho Date: Sun, 12 Oct 2025 03:10:40 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor=20:=20memberProfile=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EC=A0=95=EB=B3=B4=20=EC=BA=90=EC=8B=B1=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/cache/MemberProfileCacheDTO.java | 11 +++ .../cache/MemberProfileCacheService.java | 94 +++++++++++++++++++ .../member/repository/MemberRepository.java | 22 +++++ .../global/redis/enums/RedisKeyPrefix.java | 3 + 4 files changed, 130 insertions(+) create mode 100644 src/main/java/com/project/syncly/domain/member/cache/MemberProfileCacheDTO.java create mode 100644 src/main/java/com/project/syncly/domain/member/cache/MemberProfileCacheService.java 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; From bef4c452c3b0be5454a09410838f3dd7331a0784 Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Sun, 12 Oct 2025 16:43:21 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 648a41e..d89a932 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v4anjsrk + uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17'