Skip to content

Commit

Permalink
[REFACTOR] 네임드 락을 이용해 분산 락 구현, 픽 수정 시 트랜잭션 추가 (#665)
Browse files Browse the repository at this point in the history
* refactor: RabbitmqConfig 위치 이동

* refactor: 폴더 비관적 락 제거

* refactor: RabbitMqConfig 위치 이동

* feat: 네임드 락을 이용한 분산 락 구현

* refactor: 깨지는 테스트 수정

* refactor: 폴더 삭제 시 데드락 문제 재현을 위한 테스트

* refactor: Link Lazy Loading 에러 해결을 위해 트랜잭션 추가

* refactor: 주석 추가

* refactor: 코드 리뷰 반영 및 api 모듈로 이동

* refactor: userId 리플렉션으로 받아오는 부분 @LoginUserId 사용하도록 변경

* refactor: validateGivenFoldersAreAllPrivate 제거

* refactor: 위치 변경 및 주석 제거
  • Loading branch information
sangwonsheep authored Dec 4, 2024
1 parent 008e602 commit efc3352
Show file tree
Hide file tree
Showing 16 changed files with 446 additions and 285 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package techpick.api.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* @author sangwon
* key : 락을 걸 이름을 지정합니다.
* 실제로 락 이름은 key + _ + userId로 설정합니다.
* 분산 락을 걸어서 동시성 문제를 제어합니다.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUserIdDistributedLock {
String key();

long timeout() default 3000;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
import techpick.api.domain.folder.dto.FolderMapper;
import techpick.api.domain.folder.dto.FolderResult;
import techpick.api.domain.folder.exception.ApiFolderException;
import techpick.api.domain.sharedFolder.exception.ApiSharedFolderException;
import techpick.api.infrastructure.folder.FolderDataHandler;
import techpick.api.annotation.LoginUserIdDistributedLock;
import techpick.api.infrastructure.pick.PickDataHandler;
import techpick.api.infrastructure.sharedFolder.SharedFolderDataHandler;
import techpick.core.model.folder.Folder;
Expand All @@ -28,167 +28,157 @@
@RequiredArgsConstructor
public class FolderService {

private final FolderDataHandler folderDataHandler;
private final SharedFolderDataHandler sharedFolderDataHandler;
private final FolderMapper folderMapper;
private final PickDataHandler pickDataHandler;

// TODO: 현재는 1depth만 반환하고 있지만, 추후 ~3depth까지 반환할 예정
@Transactional(readOnly = true)
public List<FolderResult> getAllRootFolderList(Long userId) {
Folder rootFolder = folderDataHandler.getRootFolder(userId);
validateGivenFoldersAreAllPrivate(List.of(rootFolder));

List<FolderResult> folderList = new ArrayList<>();
folderList.add(folderMapper.toResult(rootFolder));

rootFolder.getChildFolderIdOrderedList().stream()
.map(folderDataHandler::getFolder)
.map(folderMapper::toResult)
.forEach(folderList::add);

return folderList;
}

@Transactional(readOnly = true)
public List<FolderResult> getBasicFolderList(Long userId) {
var basicFolders = List.of(
folderDataHandler.getRootFolder(userId),
folderDataHandler.getUnclassifiedFolder(userId),
folderDataHandler.getRecycleBin(userId)
);
validateGivenFoldersAreAllPrivate(basicFolders);

return basicFolders.stream()
.map(folderMapper::toResult)
.toList();
}

/**
* 생성하려는 폴더가 미분류폴더, 휴지통이 아닌지 검증합니다.
* */
@Transactional
public FolderResult saveFolder(FolderCommand.Create command) {
Folder parentFolder = folderDataHandler.getFolder(command.parentFolderId());
validateFolderAccess(command.userId(), parentFolder);
validateDestinationFolder(parentFolder);

return folderMapper.toResult(folderDataHandler.saveFolder(command));
}

@Transactional
public FolderResult updateFolder(FolderCommand.Update command) {

Folder folder = folderDataHandler.getFolder(command.id());

validateFolderAccess(command.userId(), folder);
validateBasicFolderChange(folder);

return folderMapper.toResult(folderDataHandler.updateFolder(command));
}

/**
* 현재 폴더들의 부모가 같은지 검증합니다.
* 이동하려는 폴더가 미분류폴더, 휴지통이 아닌지 검증합니다.
* */
@Transactional
public void moveFolder(FolderCommand.Move command) {
Folder destinationFolder = folderDataHandler.getFolder(command.destinationFolderId());
validateFolderAccess(command.userId(), destinationFolder);
validateDestinationFolder(destinationFolder);

List<Folder> folderList = folderDataHandler.getFolderList(command.idList());
for (Folder folder : folderList) {
validateFolderAccess(command.userId(), folder);
validateBasicFolderChange(folder);
}

validateParentFolder(folderList, command.parentFolderId());
if (Objects.equals(command.parentFolderId(), command.destinationFolderId())) {
folderDataHandler.moveFolderWithinParent(command);
} else {
folderDataHandler.moveFolderToDifferentParent(command);
}
}

// TODO: 리팩토링 필요
@Transactional
public void deleteFolder(FolderCommand.Delete command) {
// 먼저 공유 부터 해제
command.idList().forEach(sharedFolderDataHandler::deleteBySourceFolderId);
// 휴지통으로 이동되어야할 픽 리스트
List<Long> targetPickIdList = new ArrayList<>();
// 삭제할 폴더 리스트
List<Folder> targetFolderList = folderDataHandler.getFolderList(command.idList());
for (Folder folder : targetFolderList) {
validateFolderAccess(command.userId(), folder);
validateBasicFolderChange(folder);
}
// db 재귀조회를 하지 않기위해 본인 폴더를 모두 가져와서 Map 형태로 들고있음.
// TODO: queryDSL을 사용해서 최적화 할 수 있으면 좋을거같음.
Map<Long, Folder> folderMap = folderDataHandler.getFolderListByUserId(command.userId())
.stream()
.collect(Collectors.toMap(Folder::getId, folder -> folder));

// BFS로 자식폴더들을 순회하며 삭제할 폴더 리스트와 휴지통으로 보낼 픽 리스트를 얻음
Queue<Folder> queue = new ArrayDeque<>(targetFolderList);
while (!queue.isEmpty()) {
Folder folder = queue.poll();
for (Long childFolderId : folder.getChildFolderIdOrderedList()) {
Folder childFolder = folderMap.get(childFolderId);
targetFolderList.add(childFolder);
queue.add(childFolder);
}
targetPickIdList.addAll(folder.getChildPickIdOrderedList());
}

pickDataHandler.movePickListToRecycleBin(command.userId(), targetPickIdList);
folderDataHandler.deleteFolderList(command);
}

private void validateFolderAccess(Long userId, Folder folder) {
if (!folder.getUser().getId().equals(userId)) {
throw ApiFolderException.FOLDER_ACCESS_DENIED();
}
}

private void validateBasicFolderChange(Folder folder) {
if (FolderType.GENERAL != folder.getFolderType()) {
throw ApiFolderException.BASIC_FOLDER_CANNOT_CHANGED();
}
}

/**
* 같은 폴더 내에서 순서를 변경하는 경우에 검증로직입니다.
* 이동하려는 폴더들의 부모가 실제 부모폴더와 일치하는지 검증합니다.
* */
private void validateParentFolder(List<Folder> folderList, Long parentFolderId) {
for (Folder folder : folderList) {
Folder parentFolder = folder.getParentFolder();
if (ObjectUtils.notEqual(parentFolder.getId(), parentFolderId)) {
throw ApiFolderException.INVALID_PARENT_FOLDER();
}
}
}

/**
* 폴더 생성, 이동시 미분류폴더, 휴지통으로는 이동할 수 없습니다.
* */
private void validateDestinationFolder(Folder destinationFolder) {
if (Objects.equals(destinationFolder.getFolderType(), FolderType.UNCLASSIFIED)) {
throw ApiFolderException.INVALID_TARGET();
}
if (Objects.equals(destinationFolder.getFolderType(), FolderType.RECYCLE_BIN)) {
throw ApiFolderException.INVALID_TARGET();
}
}

private void validateGivenFoldersAreAllPrivate(List<Folder> folderList) {
for (Folder folder : folderList) {
sharedFolderDataHandler
.findUUIDBySourceFolderId(folder.getId()).ifPresent(uuid -> {
throw ApiSharedFolderException.FOLDER_CANNOT_BE_SHARED();
});
}
}
private final FolderDataHandler folderDataHandler;
private final SharedFolderDataHandler sharedFolderDataHandler;
private final FolderMapper folderMapper;
private final PickDataHandler pickDataHandler;

// TODO: 현재는 1depth만 반환하고 있지만, 추후 ~3depth까지 반환할 예정
@Transactional(readOnly = true)
public List<FolderResult> getAllRootFolderList(Long userId) {
Folder rootFolder = folderDataHandler.getRootFolder(userId);

List<FolderResult> folderList = new ArrayList<>();
folderList.add(folderMapper.toResult(rootFolder));

rootFolder.getChildFolderIdOrderedList().stream()
.map(folderDataHandler::getFolder)
.map(folderMapper::toResult)
.forEach(folderList::add);

return folderList;
}

@Transactional(readOnly = true)
public List<FolderResult> getBasicFolderList(Long userId) {
var basicFolders = List.of(
folderDataHandler.getRootFolder(userId),
folderDataHandler.getUnclassifiedFolder(userId),
folderDataHandler.getRecycleBin(userId)
);

return basicFolders.stream()
.map(folderMapper::toResult)
.toList();
}

/**
* 생성하려는 폴더가 미분류폴더, 휴지통이 아닌지 검증합니다.
* */
@Transactional
public FolderResult saveFolder(FolderCommand.Create command) {
Folder parentFolder = folderDataHandler.getFolder(command.parentFolderId());
validateFolderAccess(command.userId(), parentFolder);
validateDestinationFolder(parentFolder);

return folderMapper.toResult(folderDataHandler.saveFolder(command));
}

@Transactional
public FolderResult updateFolder(FolderCommand.Update command) {

Folder folder = folderDataHandler.getFolder(command.id());

validateFolderAccess(command.userId(), folder);
validateBasicFolderChange(folder);

return folderMapper.toResult(folderDataHandler.updateFolder(command));
}

/**
* 현재 폴더들의 부모가 같은지 검증합니다.
* 이동하려는 폴더가 미분류폴더, 휴지통이 아닌지 검증합니다.
* */
@Transactional
public void moveFolder(FolderCommand.Move command) {
Folder destinationFolder = folderDataHandler.getFolder(command.destinationFolderId());
validateFolderAccess(command.userId(), destinationFolder);
validateDestinationFolder(destinationFolder);

List<Folder> folderList = folderDataHandler.getFolderList(command.idList());
for (Folder folder : folderList) {
validateFolderAccess(command.userId(), folder);
validateBasicFolderChange(folder);
}

validateParentFolder(folderList, command.parentFolderId());
if (Objects.equals(command.parentFolderId(), command.destinationFolderId())) {
folderDataHandler.moveFolderWithinParent(command);
} else {
folderDataHandler.moveFolderToDifferentParent(command);
}
}

// TODO: 리팩토링 필요
@LoginUserIdDistributedLock(key = "DELETE_FOLDER")
@Transactional
public void deleteFolder(FolderCommand.Delete command) {
// 먼저 공유 부터 해제
command.idList().forEach(sharedFolderDataHandler::deleteBySourceFolderId);
// 휴지통으로 이동되어야할 픽 리스트
List<Long> targetPickIdList = new ArrayList<>();
// 삭제할 폴더 리스트
List<Folder> targetFolderList = folderDataHandler.getFolderList(command.idList());
for (Folder folder : targetFolderList) {
validateFolderAccess(command.userId(), folder);
validateBasicFolderChange(folder);
}
// db 재귀조회를 하지 않기위해 본인 폴더를 모두 가져와서 Map 형태로 들고있음.
// TODO: queryDSL을 사용해서 최적화 할 수 있으면 좋을거같음.
Map<Long, Folder> folderMap = folderDataHandler.getFolderListByUserId(command.userId())
.stream()
.collect(Collectors.toMap(Folder::getId, folder -> folder));

// BFS로 자식폴더들을 순회하며 삭제할 폴더 리스트와 휴지통으로 보낼 픽 리스트를 얻음
Queue<Folder> queue = new ArrayDeque<>(targetFolderList);
while (!queue.isEmpty()) {
Folder folder = queue.poll();
for (Long childFolderId : folder.getChildFolderIdOrderedList()) {
Folder childFolder = folderMap.get(childFolderId);
targetFolderList.add(childFolder);
queue.add(childFolder);
}
targetPickIdList.addAll(folder.getChildPickIdOrderedList());
}

pickDataHandler.movePickListToRecycleBin(command.userId(), targetPickIdList);
folderDataHandler.deleteFolderList(command);
}

private void validateFolderAccess(Long userId, Folder folder) {
if (!folder.getUser().getId().equals(userId)) {
throw ApiFolderException.FOLDER_ACCESS_DENIED();
}
}

private void validateBasicFolderChange(Folder folder) {
if (FolderType.GENERAL != folder.getFolderType()) {
throw ApiFolderException.BASIC_FOLDER_CANNOT_CHANGED();
}
}

/**
* 같은 폴더 내에서 순서를 변경하는 경우에 검증로직입니다.
* 이동하려는 폴더들의 부모가 실제 부모폴더와 일치하는지 검증합니다.
* */
private void validateParentFolder(List<Folder> folderList, Long parentFolderId) {
for (Folder folder : folderList) {
Folder parentFolder = folder.getParentFolder();
if (ObjectUtils.notEqual(parentFolder.getId(), parentFolderId)) {
throw ApiFolderException.INVALID_PARENT_FOLDER();
}
}
}

/**
* 폴더 생성, 이동시 미분류폴더, 휴지통으로는 이동할 수 없습니다.
* */
private void validateDestinationFolder(Folder destinationFolder) {
if (Objects.equals(destinationFolder.getFolderType(), FolderType.UNCLASSIFIED)) {
throw ApiFolderException.INVALID_TARGET();
}
if (Objects.equals(destinationFolder.getFolderType(), FolderType.RECYCLE_BIN)) {
throw ApiFolderException.INVALID_TARGET();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public PickResult.Pick saveNewPick(PickCommand.Create command) {
return pickMapper.toPickResult(pickDataHandler.savePick(command));
}

@Transactional
public PickResult.Pick updatePick(PickCommand.Update command) {
validatePickAccess(command.userId(), command.id());
validateFolderAccess(command.userId(), command.parentFolderId());
Expand Down
Loading

0 comments on commit efc3352

Please sign in to comment.