현재 프로젝트의 문제점
현재 폴더의 이동이나 삭제 시 하위 폴더와 상위 폴더를 탐색하여 전부 락을 건 후에 작업을 진행한다.
삭제의 경우 하위 폴더와 파일까지 모두 탐색하여 일관성 있게 제거하기 위함이고, 위로 거는 락은 이동과 삭제 시 용량 계산을 일관성 있게 하기 위함이다.
위 방법의 가장 큰 문제는 수정을 위해 Read Lock을 사용하면서 읽기 작업에도 영향을 미치게 된다는 점이다. 또한 전부 탐색을 진행한 후에 작업을 처리하기 때문에 커넥션을 길게 소유하게 되고, 동일한 DB를 사용하는 다른 서비스에도 영향을 미칠 수 있다. 또 다른 문제로는 락을 획득하는 과정이 한 방향이 아니라 위, 아래 두 방향으로 데드락이 발생하는 문제도 있었다.
고민
그래서 이 모든 문제의 원인인 락을 사용하지 않고 일관성을 유지할 수 있는 방법을 고민했다.
우선 일관성도 중요하지만, 이걸 굳이 실시간으로 할 필요가 있나?라는 생각이 들었다. 그래서 삭제와 용량 계산 모두 후처리를 하면 즉시 반영 되지는 않더라고 시간이 지나면 자연스럽게 처리가 될 것이다. 만약 정확하게 삭제를 하지 못해서 고아 파일이 생겨도 이를 별도로 처리한다면 문제가 없을 것이다. 또한 어차피 백그라운드에서 탐색하고 한 번에 처리한다면, 이를 모아서 처리하면 좋다고 생각했다. 그래서 큐에 이러한 작업들을 모아서 한 번에 삭제하거나 업데이트를 하면 좋다는 생각을 했다.
하지만 조금 더 고민해 보니 위 방법에는 용량 계산에서 일관성이 깨지는 문제가 발생한다. 왜냐하면 현재 폴더와 파일을 hard delete를 하고 있는데, 용량 계산을 위해 상위 폴더를 탐색하던 중, 해당 상위 폴더가 제거된다면 일관성이 깨지는 문제가 발생한다.
우선 위와 같이 폴더가 다른 폴더 하위로 이동한다고 가정하자. 그럼 해당 폴더의 부모 폴더가 변경될 것이고, 이동 전의 부모 폴더부터 위로 탐색하며 사라진 용량만큼 빼주는 작업이 발생하고, 이동한 부모 폴더부터 위로 탐색하며 추가된 용량만큼 더해주는 작업이 발생한다.
그리고 동시에 B 폴더에 대한 삭제 작업이 일어날 수 있다. 그렇다면 [root]는 B의 사이즈만큼 감소하고 B는 삭제가 될 것이다. 만약 C의 이동에 대한 용량 계산 작업이 모두 끝난 상태라면 문제가 없지만, 나는 어차피 같은 용량을 더하거나 뺀다면 그런 pk들을 모아서 한 번에 update를 하고자 했고, 이것이 문제를 야기할 수 있다. 만약 C의 이동으로 상위 폴더에 대한 pk들을 전부 구했다고 가정하자. 하지만 아직 사이즈 변경에 대한 update는 진행하지 않은 상태에서 B의 삭제가 먼저 진행된다면 root는 전체 용량보다 C만큼 더 증가한 용량을 가지게 된다. 즉, 일관성이 깨지는 문제가 발생한다.
사실 이렇게 pk를 모아서 한 번에 update를 하지 않고, 각각의 상위 폴더마다 즉시 용량 계산을 한다면 문제가 없다. 하지만 DB 접근을 최소화하고 싶었기에 다른 방법을 생각했다.
내가 생각한 방법은 soft delete를 하는 것이다. 실제로 DB에서 제거되지 않았다면, pk를 모아서 한 번에 제거해도 문제가 없다고 생각했다. 그리고 이런 soft delete 된 파일들은 일정 시간이 지난 것을 찾아서 제거하는 스케줄러를 만들면 좋다고 생각했다.
FolderService
@Transactional
public void deleteFolder(Long folderId, Long userId) {
FolderMetadata folderMetadata = folderMetadataJpaRepository.findById(folderId)
.orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException);
if (folderMetadata.isDeleted()) {
throw ErrorCode.FOLDER_NOT_FOUND.baseException();
}
if (!folderMetadata.getOwnerId().equals(userId)) {
throw ErrorCode.ACCESS_DENIED.baseException();
}
// 부모 폴더 정보가 없으면 루트 폴더를 제거하는 요청으로 예외를 반환한다.
if (folderMetadata.getParentFolderId() == null) {
throw ErrorCode.INVALID_DELETE_REQUEST.baseException();
}
// 삭제 요청이 들어온 폴더를 제거한다.
folderMetadataJpaRepository.softDeleteById(folderMetadata.getId());
// 삭제는 스레드 풀이 처리하도록 한다.
deleteFolderTree(folderMetadata);
// 삭제한 폴더의 용량 계산을 진행한다.
metadataService.calculateSize(folderMetadata.getParentFolderId());
}
public void deleteFolderTree(FolderMetadata folderMetadata) {
long folderId = folderMetadata.getId();
log.info("[Delete Start Pk] {}", folderId);
searchThreadPoolExecutor.execute(
() -> QueryExecuteTemplate.<FolderMetadata>selectFilesAndExecuteWithCursor(pageSize,
findFolder -> folderMetadataRepository.findByParentFolderIdWithLastId(folderId,
findFolder == null ? null : findFolder.getId(), pageSize), folderMetadataList -> {
backgroundJob.addForDeleteFolder(folderMetadataList);
folderMetadataList.forEach(folder -> {
fileDeleteWithParentFolder(folder); // 하위 파일 제거
deleteFolderTree(folder); // 재귀적으로 탐색
});
}));
// 삭제 시작한 폴더의 하위 파일 제거
searchThreadPoolExecutor.execute(() -> {
fileDeleteWithParentFolder(folderMetadata);
});
}
private void fileDeleteWithParentFolder(FolderMetadata folderMetadata) {
searchThreadPoolExecutor.execute(() -> {
QueryExecuteTemplate.<FileMetadata>selectFilesAndExecuteWithCursor(pageSize,
findFile -> fileMetadataRepository.findFileMetadataByLastId(folderMetadata.getId(),
findFile == null ? null : findFile.getId(), pageSize),
fileMetadataList -> backgroundJob.addForDeleteFile(fileMetadataList));
});
}
deleteFolderTree는 재귀적으로 하위 폴더와 파일을 탐색하여 제거하는 메서드이다. 현재 폴더만 제거하고 그 하위는 별도의 스레드 풀에서 제거하도록 했다. 탐색하는 과정을 사용자가 굳이 기다릴 필요가 없다고 생각했기 때문이다. 또한 하위 폴더의 삭제를 즉시 하는 것이 아닌, 별도의 backgroundJob이 처리하도록 했다.
그럼 삭제에 실패하면 삭제가 되어야 하지만 삭제되지 않아서 API로 접근할 수 있는 문제가 발생한다. 그런 경우를 대비해서 고아 파일을 찾아서 제거하는 별도의 스케줄러를 만들었다.
우선 selectFilesAndExecuteWithCursor는 페이징 처리를 담당하는 메서드이다. 하위 폴더 리스트를 찾고, 그 폴더 각각에 대해 탐색을 진행하도록 deleteFolderTree를 호출했으며 폴더에 대한 파일도 존재할 수 있기에 fileDeleteWithParentFolder를 호출하도록 했다.
selectFilesAndExecuteWithCursor
package com.woowacamp.storage.domain.folder.utils;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class QueryExecuteTemplate {
/**
* cursor paging으로 select 수행한 결과로 비즈니스 로직을 수행
*/
public static <T> void selectFilesAndExecuteWithCursor(int limit, Function<T, List<T>> selectFunction,
Consumer<List<T>> resultConsumer) {
List<T> selectList = null;
do {
selectList = selectFunction.apply(selectList != null ? selectList.get(selectList.size() - 1) : null);
if (!selectList.isEmpty()) {
resultConsumer.accept(selectList);
}
} while (selectList.size() >= limit);
}
}
함수형 인터페이스를 활용했다. 모던 자바 인 액션을 읽어도 어떻게 활용할지 감이 잘 안 왔는데, 어느 현직자의 도움으로 위와 같이 활용하는 것이 좋다는 것을 알게 되었다. 사실 페이징 처리를 하다 보면 각 테이블마다 별도의 처리 코드들이 존재할 수 있었다. 조회를 하는 테이블을 제외하면 결국 전체를 읽기 부담스러워 조금씩 끊어서 읽어오고 작업을 조금씩 하는데, 그 구조는 비슷하지만 각 테이블마다 다른 데이터를 다루니 결국 메서드를 별도로 만들며 유사한 코드가 중복이 되었다. 이전에는 이런 문제를 해결하려고 연관이 없는 두 테이블을 단순히 페이징 처리하려고 인터페이스로 뽑아야 하나?라는 생각이 들었지만, 함수형 인터페이스를 사용하면서 그런 고민이 해결되었다.
여하튼 위 코드를 보면 매우 단순하다. Function을 통해 특정 타입의 데이터를 조회하고, 이를 바탕으로 Consumer를 실행하는 것이다. 첫 페이지 조회라면 selectList가 null이 되기 때문에 null인 경우에는 시작할 지점을 내가 넣어주면 된다.
package com.woowacamp.storage.global.background;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.Consumer;
import java.util.function.Function;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.woowacamp.storage.domain.file.entity.FileMetadata;
import com.woowacamp.storage.domain.file.repository.FileMetadataJpaRepository;
import com.woowacamp.storage.domain.folder.entity.FolderMetadata;
import com.woowacamp.storage.domain.folder.repository.FolderMetadataJpaRepository;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
@RequiredArgsConstructor
public class BackgroundJob {
// 멀티스레딩 환경을 고려해서 BlockingQueue 사용
private LinkedBlockingQueue<FolderMetadata> folderDeleteQueue;
private LinkedBlockingQueue<FileMetadata> fileDeleteQueue;
private final FolderMetadataJpaRepository folderMetadataJpaRepository;
private final FileMetadataJpaRepository fileMetadataJpaRepository;
private final Executor deleteThreadPoolExecutor;
private final static int DELETE_DELAY = 5000;
private int folderCount = 0;
private int fileCount = 0;
private final int maxCount = 5;
@Value("${constant.batchSize}")
private int batchSize;
@PostConstruct
public void init() {
folderDeleteQueue = new LinkedBlockingQueue<>();
fileDeleteQueue = new LinkedBlockingQueue<>();
}
@PreDestroy
public void cleanUp() {
folderDeleteScheduler();
fileDeleteScheduler();
folderBatchDelete();
fileBatchDelete();
}
public void addForDeleteFolder(List<FolderMetadata> folderMetadataList) {
folderDeleteQueue.addAll(folderMetadataList);
}
public void addForDeleteFile(FileMetadata fileMetadata) {
fileDeleteQueue.offer(fileMetadata);
}
public void addForDeleteFile(List<FileMetadata> fileMetadataList) {
fileDeleteQueue.addAll(fileMetadataList);
}
public boolean isEmpty() {
return folderDeleteQueue.isEmpty() && fileDeleteQueue.isEmpty();
}
private void folderBatchDelete() {
this.<FolderMetadata>doBatchJob(folderDeleteQueue,
folderList -> folderList.stream().map(FolderMetadata::getId).toList(),
batchList -> folderMetadataJpaRepository.softDeleteAllByIdInBatch(batchList));
}
private void fileBatchDelete() {
this.<FileMetadata>doBatchJob(fileDeleteQueue, fileList -> fileList.stream().map(FileMetadata::getId).toList(),
batchList -> fileMetadataJpaRepository.deleteAllByIdInBatch(batchList));
}
/**
* 큐에 있는 데이터를 BATCH_SIZE만큼 추출하여 PK 리스트로 변환 후, 해당 데이터를 바탕으로 배치 작업을 수행
* @param queue
* @param function
* @param consumer
* @param <T>
*/
private <T> void doBatchJob(BlockingQueue<T> queue, Function<List<T>, List<Long>> function,
Consumer<List<Long>> consumer) {
List<T> metadataList = new ArrayList<>();
queue.drainTo(metadataList, batchSize);
List<Long> batchList = function.apply(metadataList);
consumer.accept(batchList);
}
@Scheduled(fixedDelay = DELETE_DELAY)
private void folderDeleteScheduler() {
folderCount++;
if (folderDeleteQueue.size() >= batchSize) {
folderCount = 0;
do {
deleteThreadPoolExecutor.execute(() -> folderBatchDelete());
} while (folderDeleteQueue.size() >= batchSize);
} else if (folderCount >= maxCount) {
folderCount = 0;
folderBatchDelete();
}
}
@Scheduled(fixedDelay = DELETE_DELAY)
private void fileDeleteScheduler() {
fileCount++;
if (fileDeleteQueue.size() >= batchSize) {
fileCount = 0;
do {
deleteThreadPoolExecutor.execute(() -> fileBatchDelete());
} while (fileDeleteQueue.size() >= batchSize);
} else if (fileCount >= maxCount) {
fileCount = 0;
fileBatchDelete();
}
}
}
하위 폴더를 삭제할 때 사용한 backgroundJob이다. 파일과 폴더 각각을 처리할 큐를 두고, 이 큐에 삭제할 데이터들을 추가한다. 그리고 별도의 스케줄러를 통해 큐의 상태를 확인하고 한 번에 batch delete를 하도록 한다. 이 또한 어차피 제거할 것이라면 DB 접근을 줄여 한 번에 제거하는 것이 더 좋다고 생각했다. 이때 삭제는 soft delete로 한다.
그리고 고아 파일의 경우 아래와 같이 또 다른 스케줄러를 두어 제거했다.
package com.woowacamp.storage.global.background;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.woowacamp.storage.domain.file.service.FileService;
import com.woowacamp.storage.domain.folder.service.FolderService;
import lombok.RequiredArgsConstructor;
/**
* 파일 이동과 삭제 중 발생하는 고아 파일을 찾아서 제거하는 클래스
*/
@Component
@RequiredArgsConstructor
public class OrphanFileManager {
private final FileService fileService;
private final FolderService folderService;
private static final int FIND_DELAY = 1000 * 60;
@Value("${constant.batchSize}")
private int pageSize;
/**
* 이미 soft delete가 완료된 폴더를 기준으로 삭제가 되지 않은 하위 폴더 및 파일을 탐색
* 이후 마찬가지로 폴더는 soft delete, 파일은 hard delete를 진행한다
*/
@Scheduled(fixedDelay = FIND_DELAY)
private void orphanFolderFinder() {
folderService.findOrphanFolderAndSoftDelete();
}
/**
* 고아 파일을 찾아서 hard delete를 진행한다
*/
@Scheduled(fixedDelay = FIND_DELAY)
private void orphanFileFinder() {
fileService.findOrphanFileAndHardDelete();
}
}
고아 파일을 제거하면서 깨달은 건데, soft delete를 해서 고아 파일을 찾아서 제거하는 과정이 굉장히 편리했다.
만약 hard delete를 했다면, 어떤 파일의 부모 pk로 조회를 했을 때 그 값이 null이어야 한다. 하지만 이런 파일을 찾으려면 전체를 탐색해 보며 어떤 파일의 부모 pk로 조회를 했을 때 null인 것만 찾아야 한다. 이는 테이블 크기가 커진다면 굉장히 큰 부담이 될 것이다.
select * from folder_metadata f1
left outer join folder_metadata f2
on f1.parent_folder_id = f2.folder_metadata_id
and f1.folder_metadata_id > 10000
where f1.parent_folder_id is not null
and f2.folder_metadata_id is null
order by f1.folder_metadata_id
limit 100;
SELECT *
FROM folder_metadata f1
WHERE f1.parent_folder_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM folder_metadata f2
WHERE f2.folder_metadata_id = f1.parent_folder_id
)
ORDER BY f1.folder_metadata_id
LIMIT 100;
실제로 위와 같은 쿼리들을 사용하면서 고민했었다. 하지만 모든 경우를 다 보는 것이기 때문에 굉장히 오랜 시간이 걸렸다.
한국인이라면 새로고침을 이미 4번을 했을 시간이다.
하지만 soft delete를 한다면 이미 soft delete가 된 폴더를 찾아서 하위 폴더 중 soft delete가 되지 않은 폴더를 soft delete를 하고, 삭제되지 않은 파일의 경우 제거를 하면 된다.
public void doHardDelete() {
QueryExecuteTemplate.<FolderMetadata>selectFilesAndExecuteWithCursor(pageSize,
findFolder -> folderMetadataRepository.findSoftDeletedFolderWithLastIdAndDuration(
findFolder == null ? null : findFolder.getId(), pageSize),
folderMetadataList -> folderMetadataRepository.deleteAll(folderMetadataList));
}
public void findOrphanFolderAndSoftDelete() {
QueryExecuteTemplate.<FolderMetadata>selectFilesAndExecuteWithCursor(pageSize,
findFolder -> folderMetadataRepository.findSoftDeletedFolderWithLastId(
findFolder == null ? null : findFolder.getId(), pageSize),
folderMetadataList -> folderMetadataList.forEach(folder -> deleteFolderTree(folder)));
}
public List<FolderMetadata> findSoftDeletedFolderWithLastId(Long lastId, int size) {
if (lastId == null) {
return folderMetadataJpaRepository.findSoftDeletedFolder(size);
}
return folderMetadataJpaRepository.findSoftDeletedFolderWithLastId(lastId, size);
}
@Query("""
SELECT f
FROM FolderMetadata f
WHERE f.isDeleted = true
AND f.id > :lastId
ORDER BY f.id
LIMIT :size
""")
List<FolderMetadata> findSoftDeletedFolderWithLastId(@Param("lastId") Long lastId, @Param("size") int size);
그래서 위와 같이 간단하게 구현할 수 있었다. is_deleted라는 컬럼을 통해 soft deleted 여부를 판단할 수 있었고 select문 하나로 간편하게 조회할 수 있었다. 이번에도 폴더가 많을 수 있기 때문에 페이징 처리를 했다.
참고로 findSoftDeletedFolderWithLastId라는 중간 메서드가 있는데 페이징 처리를 편리하게 하기 위해 repository. 인터페이스와 service 클래스 사이에 repository 클래스를 하나 더 둔 것이다.
락 사용을 줄여도 발생한 데드락 이슈
테스트 코드를 작성해서 동시에 폴더 이동 작업을 하는 테스트를 실행했다. 결과는 아래처럼 데드락 이슈가 굉장히 많이 발생하여 테스트에 실패하게 되었다.
데드락 이슈가 굉장히 의문이었다. 왜냐하면 폴더 용량 계산에서 위로 잠그는 락을 제거했고 한 방향으로만 락을 획득하기 때문이었다. 순환 구조가 없는데 생기는 것이 가장 의아했다. 그래서 락을 사용한 코드를 살펴보았다.
/**
* currentFolderId로부터 최대 깊이를 구하는 dfs
* 이 과정 중, targetFolderId가 포함돼 있으면 예외 발생
*/
private int getLeafDepth(long currentFolderId, int currentDepth, long targetFolderId) {
List<Long> childFolderIds = folderMetadataJpaRepository.findIdsByParentFolderId(currentFolderId);
if (isExistsPendingFile(currentFolderId)) {
throw ErrorCode.CANNOT_MOVE_FOLDER_WHEN_UPLOADING.baseException();
}
if (childFolderIds.isEmpty()) {
return currentDepth;
}
int result = 0;
for (Long childFolderId : childFolderIds) {
if (Objects.equals(childFolderId, targetFolderId)) {
throw ErrorCode.FOLDER_MOVE_NOT_AVAILABLE.baseException();
}
result = Math.max(result, getLeafDepth(childFolderId, currentDepth + 1, targetFolderId));
}
return result;
}
/**
* 폴더를 무제한 생성하는 것을 방지하기 위해 깊이를 구하는 메소드
*/
public int getFolderDepth(long folderId) {
int depth = 1;
Long currentFolderId = folderId;
while (true) {
Optional<Long> parentFolderIdById = folderMetadataRepository.findParentFolderIdById(currentFolderId);
if (parentFolderIdById.isEmpty()) {
break;
}
currentFolderId = parentFolderIdById.get();
depth++;
}
return depth;
}
위의 코드가 폴더 깊이를 정확히 계산하기 위해서 하위로 락을 걸면서 깊이를 구하는 메서드이다. 이 메서드만 사용하면 상관없지만, 이전에 용량 업데이트를 하는 과정을 떠올려보면 in절을 사용하여 한 번에 업데이트를 했다. in절의 경우 여러 레코드에 대해 update를 하기 위해 락을 걸게 되고, 이 과정과 위의 폴더 깊이를 구하기 위해 락을 구하는 과정에서 서로 락을 획득하기 위해 대기하는 상황이 발생할 수 있었다. 그래서 결국엔 새로운 방법을 찾아야 했다.
폴더 깊이 제한을 하지 않으면 이를 악용해서 폴더 깊이를 매우 크게 늘릴 수 있었기에 락은 필수라고 생각했다. 하지만 결국 DB 락을 사용하면서 데드락 이슈가 발생하고, 결국 읽기 작업도 락 때문에 하지 못하기 때문에 더 나은 방법을 고민했다.
처음으로 든 생각은 애플리케이션 수준에서 락을 걸어서 해결하는 방법이다. ReentrantLock을 떠올렸는데, 이 방법의 문제점은 서버를 추가하는 경우 사용할 수 없다는 것이다.
그래서 생각한 다음 방법은 redis 분산락을 사용하는 것이었다. 어떠한 폴더 트리에서의 이동 작업은 오직 하나만 수행될 수 있도록 한다면 굳이 락을 걸고 조회하며 깊이를 구할 필요도 없어진다. named lock을 사용할까 고민했지만 DB 자체의 부담을 줄이기 위한 것이라면 redis 분산락을 사용하는 것이 좋다고 생각했다.
왜 락을 써야만 하는가?
그런데 폴더 이동에서 왜 락을 써야 하는지 의문이 들 수 있다. 용량 계산 같은 경우는 굳이 락이 없어도 각각의 이동과 삭제에 대해서만 계산을 잘해주면 문제가 발생하지 않기 때문이다. 하지만 폴더 이동 과정에서 순환 구조가 생길 수 있고, 이런 순환 구조가 생기면 찾아서 지우기가 굉장히 힘들기 때문에 락이 꼭 필요하다고 생각했다.
위와 같이 B->C 이동 작업과 D->E 이동 작업이 동시에 발생하고, 락을 전혀 걸지 않는다고 생각해 보자. 사실 락을 걸지 않으면 그냥 B의 부모는 C, C의 부모는 D, D의 부모는 B가 된다. 즉, 이동 작업의 타이밍에 따라서 자신의 자식 폴더 중 하나로 이동하게 된다면 순환 구조가 생성될 수 있다.
그럼 위와 같은 형태로 트리가 구성될 텐데, 이동 당시 이런 순환 구조가 발생한 것을 알지 못하면, 이후에 순환 구조를 찾기 위해서는 모든 폴더 데이터에 대해 순환 구조가 발생하는지 찾아봐야 한다. 데이터가 많아질수록 서버에 부담이 되고 비효율적인 작업이 될 것이라고 생각해서 락은 필요하다고 생각했다. 현재 폴더를 포함해서 하위 폴더들은 모두 락이 걸려있기 때문에 다른 폴더가 락이 걸린 폴더 트리로 이동을 할 수 없고, 락을 거는 과정에서 자식 폴더로 이동하는 것이 아니라면 순환 구조가 발생하지 않을 것이다.
현재의 구조는 어떠한 기준 폴더를 기준으로 하위 폴더들에 대해서는 동시 작업이 불가능하지만, 직접적인 관계를 갖지 않는 폴더 트리들에 대해서는 동시 작업이 가능하다. 그리고 분산락을 사용하게 된다면, 해당 폴더 트리의 소유주를 기준으로 락을 걸고, 한 번에 하나의 이동 작업만 하도록 할 것이다. 락의 범위를 더 줄일 수 있을지 고민을 했지만, 하위 폴더들이 어떤 구조를 가지고 있고 그에 따라 다른 락 이름을 만들기 굉장히 까다롭다고 생각했고, 만약 하위 폴더들을 DB 락을 걸지 않고 탐색하여 분산 락 이름을 정했지만, 그 사이에 다른 이동 작업으로 그 구조가 깨질 수도 있었기 때문에 소유주 기준으로 락을 잡기로 했다. 일반적으로 읽기 작업이 훨씬 많으니 큰 무리는 없을 것이라는 생각도 했다.
분산락 적용
구현체는 redisson을 사용했는데, lettuce의 경우 직접 분산락을 구현해야 하는 번거로움이 있었기 때문이다. redisson의 경우 별도의 lock interface를 제공하여 타임아웃과 같은 설정을 지원한다.
찾아보니 redisson의 경우 redis pub/sub을 활용하는데, 이 pub/sub은 tcp 커넥션을 맺고 락 획득이 가능하면 알려주는 구조를 사용했다. 즉, mutex와 같은 spin lock을 사용하지 않는 것이다. 이것도 서버의 부담을 줄여주기 때문에 더 좋다고 생각했다. 수많은 요청이 오면 커넥션을 전부 맺어서 문제가 될 수 있다는 걱정이 있었지만, 커넥션 풀을 통해서 이를 관리하기 때문에 문제가 없었다. 내가 대략적으로 이해한 원리는, 커넥션 풀에 10개의 커넥션이 있고, 동일한 락에 대해 100개의 동시 요청이 들어오면 10개의 요청이 먼저 커넥션을 사용하게 될 것이고, 레디스가 그중 하나에게 락을 줄 것이다. 이때 락 획득을 위해 대기하는 시간이 있는데 대기 시간 동안 락 해제가 되면 커넥션을 사용하고 있는 요청들에게 락이 해제되었음을 알리고, 그중 하나가 락 획득을 시도하게 되는 형태이다. 물론 이렇게 하면 순서가 보장되지 않을 수 있지만, 나는 단지 동시에 접근하는 것을 막고 싶은 것이기 때문에 순서는 크게 중요하지 않았다. 그래서 내가 원하는 조건에 가장 부합한다고 생각했다.
또 다른 걱정되는 부분은, SPOF 문제로 redis를 다중화했을 때이다. 락을 제공한 서버가 갑자기 죽고, stand by 서버가 기존 락을 제공한 지 모르고 락을 제공하는 경우 동시성 이슈가 발생할 수 있다는 생각이 들었다. 레디스 docs에도 관련된 내용이 있었고, 이 블로그가 정리를 잘한 것 같아서 읽어보면 좋을 것 같다.
singleServerConfig:
database: 1 # 사용할 Redis 데이터베이스 (0~15, 기본값: 0, 여러 애플리케이션이 같은 Redis 사용하면 이 번호 분리하여 사용)
connectionPoolSize: 64 # 연결 풀 크기(기본값)
connectionMinimumIdleSize: 10 # 최소 유휴 연결 수 (기본값 = 24)
idleConnectionTimeout: 10000 # 유휴 연결 타임아웃 (ms)
connectTimeout: 3000 # 서버 연결 타임아웃 (ms)
timeout: 3000 # 명령 실행 타임아웃 (ms) (기본값)
retryAttempts: 3 # 재시도 횟수(기본값)
retryInterval: 1500 # 재시도 간격 (ms) (기본값)
package com.woowacamp.storage.global.config;
import java.io.IOException;
import java.io.InputStream;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Value("${spring.redisson.address}")
private String redissonAddress;
@Bean
public RedissonClient redissonClient() throws IOException {
InputStream configStream = getClass().getClassLoader().getResourceAsStream("redisson.yml");
Config config = Config.fromYAML(configStream);
config.useSingleServer().setAddress(redissonAddress);
return Redisson.create(config);
}
}
설정 자체는 크게 어려운 것이 없었다. gpt나 구글링 하면 금방 찾아볼 수 있었고, 사용법 또한 굉장히 간단했다.
package com.woowacamp.storage.domain.folder.service;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisLockService {
private final RedissonClient redissonClient;
private final int takingLockTime = 1;
private final int keepingLockTime = 10;
public void handleUserRequest(String lockName, Runnable task, RuntimeException exception) {
RLock lock = redissonClient.getLock(lockName);
boolean isLocked = false;
try {
isLocked = lock.tryLock(takingLockTime, keepingLockTime, TimeUnit.SECONDS);
// 락 획득 실패 시 동시 요청이므로 예외 던짐
if (!isLocked) {
throw exception;
}
task.run();
} catch (InterruptedException e) {
log.error("[RedisLockService] error = {}", e);
throw new RuntimeException(e);
} finally {
if (isLocked) {
try {
lock.unlock();
} catch (IllegalMonitorStateException e) {
// 락 소유 시간동안 비즈니스 로직 처리를 하지 못한 경우 예외 처리
// 여기서 throw를 하면 비즈니스 로직이 정상적으로 처리됐어도 예외 응답을 받게 됨.
// 메일 같은 것으로 락 소유시간이 짧은 것 같다는 알림을 전송하는게 좋다고 생각
log.error("Failed to do service: " + e.getMessage(), e);
}
}
}
}
public <T> T handleUserRequest(String lockName, Supplier<T> task, RuntimeException exception) {
RLock lock = redissonClient.getLock(lockName);
boolean isLocked = false;
T result = null;
try {
isLocked = lock.tryLock(takingLockTime, keepingLockTime, TimeUnit.SECONDS);
if (!isLocked) {
throw exception;
}
lock.lock();
result = task.get();
} catch (InterruptedException e) {
log.error("[RedisLockService] error = {}", e);
throw new RuntimeException(e);
} finally {
if (isLocked) {
try {
lock.unlock();
} catch (IllegalMonitorStateException e) {
log.error("Failed to do service: " + e.getMessage(), e);
}
}
}
return result;
}
}
본인은 별도로 락을 걸고 해제하는 역할을 하는 Service 클래스를 만들어서 사용했다. 리턴 타입이 필요한 경우와 필요하지 않은 경우로 나눠서 처리를 했다.
폴더 이동에 분산락 적용
public void moveFolder(Long sourceFolderId, FolderMoveDto dto) {
FolderMetadata folderMetadata = folderMetadataJpaRepository.findByIdNotDeleted(sourceFolderId)
.orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException);
redisLockService.handleUserRequest(folderMetadata.getOwnerId().toString(),
() -> moveFolderTask(sourceFolderId, dto), ErrorCode.TOO_MUCH_REQUEST.baseException());
}
@Transactional
protected void moveFolderTask(Long sourceFolderId, FolderMoveDto dto) {
FolderMetadata folderMetadata = folderMetadataJpaRepository.findByIdNotDeleted(sourceFolderId)
.orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException);
folderMetadataJpaRepository.findByIdNotDeleted(dto.targetFolderId())
.orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException);
validateInvalidMove(dto, folderMetadata);
validateFolderDepth(sourceFolderId, dto);
long originParentId = folderMetadata.getParentFolderId();
String moveFolderLock = dto.targetFolderId() + "/" + folderMetadata.getUploadFolderName();
redisLockService.handleUserRequest(moveFolderLock, () -> duplicatedCheckAndMoveCommit(dto, folderMetadata),
ErrorCode.TOO_MUCH_REQUEST.baseException());
// 업데이트가 완료된 이후 용량 계산을 실시한다.
metadataService.calculateSize(originParentId);
metadataService.calculateSize(dto.targetFolderId());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
protected void duplicatedCheckAndMoveCommit(FolderMoveDto dto, FolderMetadata folderMetadata) {
validateDuplicatedFolderName(dto, folderMetadata);
folderMetadata.updateParentFolderId(dto.targetFolderId());
folderMetadataJpaRepository.save(folderMetadata);
}
컨트롤러에서 이동 요청이 오면 moveFolder 메서드를 호출하게 된다. 그럼 이전에 만든 redisLockService를 통해 락을 걸고 락 내부에서 수행할 task를 넘겨준다. 실제 이동 작업을 수행하는 moveFolderTask를 호출하게 되고 해당 메서드를 하나의 트랜잭션으로 처리했다.
여기서 주의해야 할 점이 moveFolder에 @Transactional을 걸고 moveFolderTask에는 걸지 않는다면 동시성 문제가 발생할 수 있다.
왜냐하면 [Transaction start -> Lock start -> Lock end -> Transaction End] 이 순서로 진행되기 때문에, 락이 해제된 순간 다른 트랜잭션이 락을 획득하고 변경이 일어나기 전에 데이터를 읽어서 문제가 발생할 수 있는 것이다. 우선 폴더 이동의 경우 한 폴더 아래에 동일한 이름의 폴더는 존재하면 안 되지만, 락이 먼저 해제되어 다른 트랜잭션이 해당 하위 폴더 아래에 동일한 이름의 폴더가 없다는 것을 먼저 확인하게 되는 경우에는 동일한 이름의 폴더가 중복되어 생성될 수 있다. 그래서 무조건 Lock을 먼저 획득하고 트랜잭션을 시작하도록 했다.
또 다른 문제는 DB에 락을 걸지 않기로 했기 때문에 이동할 폴더 하위에 해당 폴더의 존재 유무를 파악하는 것으로는 동시성 이슈를 완벽히 막을 수 없었다. 왜냐하면 이동 외에도 폴더를 생성하는 작업이 존재하기 때문이다. 그래서 내가 이동하는 동시에 폴더 생성이 먼저 진행된다면, 마찬가지로 동일한 이름의 폴더가 여러 개 존재할 수 있었다. 그래서 추가로 락을 사용하고자 했고, 락의 이름은 이동이나 생성할 폴더의 pk와 이름의 조합으로 정했다.
그래서 코드를 보면 moveFolderTask에서 추가로 redisLockService를 호출하는 것을 볼 수 있다. 특이한 것은 duplicatedCheckAndMoveCommit 메서드의 @Transactional이다. propagation 옵션을 REQUIRES_NEW로 설정하여 새로운 트랜잭션을 열었는데, 이는 변경이 일어난 폴더를 그 즉시 DB에 반영하기 위해서이다. 만약 duplicatedCheckAndMoveCommit 메서드에서 commit을 하지 않고 락을 반환하게 된다면, moveFolderTask가 커밋되기 전에 다른 작업이 동일한 이름으로 폴더 생성을 하는 문제가 발생할 수 있다.
데드락 테스트
이전 테스트와 같은 코드로 테스트를 실행했다. 락을 전혀 사용하지 않았기 때문에 데드락은 발생하지 않았다.
In절을 사용한 쿼리 변경
in 절만 사용해도 데드락에 걸릴 수 있었다. 가령 in(1,2,3,4)와 in(4,3,2,1)과 같은 쿼리가 동시에 실행된다고 생각해 보자. 그럼 업데이트를 위해 레코드를 잠그는 과정이 서로 반대이기 때문에 데드락이 발생할 수 있었다. 물론 해당 쿼리를 실행하기 전에 in절에 들어갈 리스트를 정렬하면 문제가 없었다. 하지만 또 다른 문제가 생각났는데, 만약 이를 처리하던 작업이 예외가 발생해서 사이즈 업데이트를 하지 못한다면, 현재 로직에서는 영영 용량 계산이 적용되지 않는 문제가 발생할 수 있었다.
그래서 이럴 바에는 그냥 변경이 필요한 각 폴더에 대해서 하위 폴더와 파일들에 대한 용량을 전부 다 합해서 적용하는 방식이 더 낫다고 생각했다. DB 사용량이 더 높아지지만, 최대 폴더 깊이를 50으로 제한했으니 큰 무리는 없을 것이라는 생각이었다.
package com.woowacamp.storage.domain.folder.service;
import java.util.concurrent.Executor;
import org.springframework.stereotype.Service;
import com.woowacamp.storage.domain.file.repository.FileMetadataJpaRepository;
import com.woowacamp.storage.domain.folder.entity.FolderMetadata;
import com.woowacamp.storage.domain.folder.repository.FolderMetadataJpaRepository;
import com.woowacamp.storage.global.error.ErrorCode;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class MetadataService {
private final Executor metadataThreadPoolExecutor;
private final FolderMetadataJpaRepository folderMetadataJpaRepository;
private final FileMetadataJpaRepository fileMetadataJpaRepository;
public void calculateSize(long folderId) {
metadataThreadPoolExecutor.execute(() -> {
FolderMetadata folderMetadata = folderMetadataJpaRepository.findByParentId(folderId)
.orElseThrow(()->ErrorCode.FOLDER_NOT_FOUND.baseException("폴더 용량 계산 중 예외 발생"));
long totalSize = folderMetadataJpaRepository.sumChildFolderSize(folderMetadata.getId()).orElse(0L)
+ fileMetadataJpaRepository.sumChildFileSize(folderMetadata.getId()).orElse(0L);
folderMetadataJpaRepository.updateFolderSize(totalSize, folderMetadata.getId());
if (folderMetadata.getParentFolderId() != null) {
calculateSize(folderMetadata.getParentFolderId());
}
});
}
}
부모 폴더를 기준으로 상위 폴더로 계속 탐색해 나간다. 그리고 각 과정은 자식 폴더의 사이즈와 자식 파일의 사이즈를 합한 값으로 업데이트를 하게 된다. 이때 용량의 합은 집계함수 SUM을 활용하여 계산했다.
이렇게 하면 작업이 실패해도 사용자가 용량 계산이 잘못된 것 같아 용량 계산을 다시 요청한다면 문제없이 보여줄 수 있을 것이다.
후기
처음에는 일관성에 집착을 해서 락을 과도하게 사용한 것 같다. 그리고 락을 사용하지 않고 일관성을 유지할 방법을 생각하지 못했었다. 어떻게 해결해야 하는지 답을 찾지 못해서 오래 고민했지만, 분산락을 사용하자는 생각이 떠오른 후에는 생각보다 쉽게 문제를 해결할 수 있었다. 아직 어느 정도 개선이 되었는지 테스트는 하지 못했지만, 이론상 굉장히 많은 개선이 됐을 것 같은 기대감이 생겼다.
'Java > My-Storage 프로젝트' 카테고리의 다른 글
[My-Storage 개선하기] 로컬 환경에서 Docker로 Ceph 설치하기 (0) | 2025.01.04 |
---|---|
[My-Storage 개선하기] 데드락 해결, DB lock 사용 줄이기(2) - 테스트 (0) | 2024.12.19 |
[우아한 테크 캠프 팀 프로젝트] 파일 이동 및 삭제 My-Storage(3) (0) | 2024.11.18 |
[우아한 테크 캠프 팀 프로젝트]동기 처리 vs 비동기 처리, 비동기 처리에서 발생한 OOM 문제 My-Storage(2) (0) | 2024.09.09 |
[우아한 테크 캠프 팀 프로젝트] File Upload 구현하기. 효율적인 I/O처리를 위한 InputStream과 OutputStream의 분리 My-Storage(1) (1) | 2024.09.08 |