Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public ResponseEntity<CustomResponse<FolderResponseDto.Message>> restoreFolder(
}

@GetMapping("/{workspaceId}/folders/{folderId}/path")
@Operation(summary = "폴더 경로 조회", description = "워크스페이스 폴더의 breadcrumb 경로를 조회합니다.")
@Operation(summary = "폴더 경로 조회", description = "워크스페이스 폴더의 breadcrumb 경로를 조회합니다. (FolderClosure 패턴 사용)")
public ResponseEntity<CustomResponse<FolderResponseDto.Path>> getFolderPath(
@PathVariable Long workspaceId,
@PathVariable Long folderId,
Expand All @@ -125,6 +125,24 @@ public ResponseEntity<CustomResponse<FolderResponseDto.Path>> getFolderPath(
return ResponseEntity.ok(CustomResponse.success(HttpStatus.OK, responseDto));
}

@GetMapping("/{workspaceId}/folders/{folderId}/path-recursive")
@Operation(summary = "성능테스트용 폴더 경로 조회 (재귀 방식)", description = "워크스페이스 폴더의 breadcrumb 경로를 재귀 방식으로 조회합니다. (성능 비교 테스트용)")
public ResponseEntity<CustomResponse<FolderResponseDto.Path>> 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<CustomResponse<FolderResponseDto.Root>> getWorkspaceRoot(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ public interface FolderRepository extends JpaRepository<Folder, Long> {
// 특정 워크스페이스의 폴더 조회
Optional<Folder> findByIdAndWorkspaceId(Long folderId, Long workspaceId);

// parentId와 workspaceId로 폴더 조회 (재귀 방식 경로 조회용)
Optional<Folder> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<FolderResponseDto.PathItem> 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<FolderResponseDto.PathItem> 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) {
// 폴더가 해당 워크스페이스에 속하는지 확인
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.project.syncly.domain.member.cache;

import lombok.Builder;

@Builder
public record MemberProfileCacheDTO(
Long id,
String name,
String profileImageUrl
) {
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<Long, MemberProfileCacheDTO> getProfiles(List<Long> memberIds) {
if (memberIds == null || memberIds.isEmpty()) return Collections.emptyMap();

//중복제거
List<Long> distinctMemberIds = memberIds.stream().distinct().toList();

// redis에서 캐싱데이터 가져오기
List<String> keys = distinctMemberIds.stream()
.map(RedisKeyPrefix.MEMBER_PROFILE::get)
.toList();

List<Object> cached = redisTemplate.opsForValue().multiGet(keys);

Map<Long, MemberProfileCacheDTO> result = new LinkedHashMap<>();
List<Long> 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<MemberProfileCacheDTO> profiles = memberRepository.getMemberProfiles(missed);

// getValueSerializer()는 반환값이 RedisSerializer<?> 이기 때문에 타입 지정을 해 줘야 serialize(@Nullable T value) 사용가능
RedisSerializer<Object> valueSerializer =
(RedisSerializer<Object>) redisTemplate.getValueSerializer();
// 파이프라인으로 한번에 저장
redisTemplate.executePipelined((RedisCallback<Object>) 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;
}

}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,4 +17,22 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> 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<MemberProfileCacheDTO> getMemberProfiles(@Param("memberIds") List<Long> memberIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down