다음은 파일 이동과 삭제에 대한 내용이다. 데이터 무결성에 과하게 집중했는데, 시간이 지나서 문제가 될 수 있음을 깨달았다. 당시에는 도메인 지식이 부족해서 더 폭넓게 생각하지 못한 것 같아 아쉬움이 있는 부분이다.
당시 구현할 때, 파일 이동과 삭제에 있어서 중요한 것은 폴더의 하위 파일 및 폴더들이 동시에 이동하고 삭제되어야 한다는 부분에 집중했다. 현재 DB 상에 구현된 파일 트리 구조는 pk를 기반으로 parent를 찾아가기 때문에 이동의 경우 해당 파일이나 폴더의 Parent ID 값만 바꿔주면 됐다. 삭제의 경우 하위 파일들을 모두 찾아서 한 번에 삭제해야 했다. 이때 락을 걸지 않으면, 삭제 중인 폴더에 어떤 파일을 추가할 수 있고, 타이밍이 맞지 않아서 새로 추가된 파일은 삭제되지 않는 문제가 발생할 수 있다. 이렇게 되면 고아 파일이 생성되고, 영영 찾을 수 없게 된다. 그래서 전부 락을 걸면서 삭제를 진행했다. 이동의 경우는 용량과 같은 메타데이터 반영을 위해 상위 레이어에 대해 락을 걸면서 용량을 계산한다(삭제도 마찬가지다).
DB 테이블 구조
파일과 폴더에 대한 테이블 구조이다. 별도로 제약 조건을 걸어서 fk를 설정하지는 않고 애플리케이션 수준에서 필드만 추가하여 관리했다. 파일을 추가할 땐 파일 테이블의 parent_folder_id 값을 통해서 폴더 메타데이터 값을 락을 걸고 읽는다. 마찬가지로 삭제할 땐 폴더 메타데이터 값을 락을 걸고 읽고, 하위 파일과 폴더들을 탐색하며 같은 과정을 반복한다.
파일 및 폴더 이동
그림으로 요약하면 위와 같다. 파일 및 폴더 이동 후 해당 폴더에서부터 상위 레이어에 대해 모두 용량 변경 작업을 실행한다.
@Transactional(isolation = Isolation.READ_COMMITTED)
public void moveFile(Long fileId, FileMoveDto dto) {
FolderMetadata folderMetadata = folderMetadataRepository.findByIdForUpdate(dto.targetFolderId())
.orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException);
if (!folderMetadata.getOwnerId().equals(dto.userId())) {
throw ErrorCode.ACCESS_DENIED.baseException();
}
FileMetadata fileMetadata = fileMetadataRepository.findByIdForUpdate(fileId)
.orElseThrow(ErrorCode.FILE_NOT_FOUND::baseException);
validateMetadata(dto, fileMetadata);
Set<FolderMetadata> sourcePath = folderSearchUtil.getPathToRoot(fileMetadata.getParentFolderId());
Set<FolderMetadata> targetPath = folderSearchUtil.getPathToRoot(dto.targetFolderId());
FolderMetadata commonAncestor = folderSearchUtil.getCommonAncestor(sourcePath, targetPath);
folderSearchUtil.updateFolderPath(sourcePath, targetPath, commonAncestor, fileMetadata.getFileSize());
fileMetadata.updateParentFolderId(dto.targetFolderId());
eventPublisher.publishEvent(new FileMoveEvent(this, fileMetadata, folderMetadata));
}
코드를 보면 getPathToRoot라는 메소드를 사용하는데, 이게 현재 기준의 루트 경로와 이동할 위치의 루트 경로를 구하는 것이다. 그리고 getCommonAncestor 메소드로 공통의 부모를 찾는다. 이는 공통 부모까지만 용량 계산을 해주면 되기 때문이다.
그리고 이벤트를 생성하는 코드가 있는데, 해당 이벤트는 공유 기능에 대한 이벤트 생성이다. 우리는 공유를 특정 링크를 가진 사람만 접속하는 것이 아닌, 해당 파일이나 폴더에 대한 접근 권한을 권한 없음, 쓰기 권한, 읽기 권한 이렇게 설정한다. 그래서 파일이나 폴더를 이동하면 이동한 상위 폴더의 권한으로 모두 바꿔주는 이벤트이다.
public void updateFolderPath(Set<FolderMetadata> sourcePath, Set<FolderMetadata> targetPath,
FolderMetadata commonAncestor, long fileSize) {
LocalDateTime now = LocalDateTime.now();
boolean isExistCommonAncestor = false;
for (var source : sourcePath) {
if (source.equals(commonAncestor)) {
isExistCommonAncestor = true;
}
if (!isExistCommonAncestor) {
folderMetadataRepository.updateFolderInfo(-fileSize, now, source.getId());
}
}
for (var target : targetPath) {
if (target.equals(commonAncestor)) {
break;
}
folderMetadataRepository.updateFolderInfo(fileSize, now, target.getId());
}
}
위와 같이 구한 경로에 대해서 모두 업데이트를 한다. 이것도 다시 보니 폴더 리스트를 넘겨주고 배치 업데이트를 하면 더 좋지 않을까?라는 생각이 들었다.
파일 삭제
파일 삭제의 경우 하위 파일들을 탐색하기 위해 DFS 알고리즘을 사용했다. 모든 파일을 찾은 이후 해당 파일들은 전부 삭제를 한다. 파일 삭제의 경우 S3에 있는 파일과 일관성이 깨질 수 있는 문제가 있다. 가령 파일을 삭제 중이고, 삭제 중인 트리에 어떤 파일을 추가 중이었다고 가정하자. 그럼 해당 파일은 S3에 업로드 중에 삭제가 될 수 있다. 이 경우 파일 메타데이터를 삭제하면 S3에 있는 파일은 눈에 보이지 않는 상태로 공간을 차지하여 사용료가 지속적으로 발생한다. 그래서 이런 경우는 따로 DB에 마킹을 하여 스케줄러를 통해 지속적으로 DB 데이터와 S3의 데이터를 함께 삭제하는 작업을 진행했다.
private void deleteWithDfs(long folderId, List<Long> folderIdListForDelete,
List<Long> fileIdListForDelete, List<Long> fileIdListForUpdate) {
Stack<Long> folderIdStack = new Stack<>();
folderIdStack.push(folderId);
// 재귀 탐색하며 S3 파일 삭제, 삭제해야하는 메타데이터 List에 저장하며 BatchSize 만큼 삭제
while (!folderIdStack.isEmpty()) {
Long currentFolderId = folderIdStack.pop();
// 폴더아이디 삭제 목록에 추가
folderIdListForDelete.add(currentFolderId);
// 하위의 파일 조회
List<FileMetadata> childFileMetadata = fileMetadataRepository.findByParentFolderIdAndUploadStatusNot(
currentFolderId, UploadStatus.FAIL);
// 하위 파일의 실제 데이터 삭제 및 삭제해야 할 파일 id 값 저장
childFileMetadata.forEach(fileMetadata -> {
if (Objects.equals(fileMetadata.getUploadStatus(), UploadStatus.PENDING)) {
throw ErrorCode.CANNOT_DELETE_FILE_WHEN_UPLOADING.baseException();
}
try {
// s3에 파일 chunk를 쓰고 있을 수 있기 때문에 우선 조회 후 삭제 작업을 진행한다.
amazonS3.getObjectMetadata(BUCKET_NAME, fileMetadata.getUuidFileName());
amazonS3.deleteObject(BUCKET_NAME, fileMetadata.getUuidFileName());
fileIdListForDelete.add(fileMetadata.getId());
fileIdListForUpdate.add(fileMetadata.getId());
} catch (AmazonS3Exception e) {
e.printStackTrace();
// 예외가 발생한 경우 해당 파일의 부모 폴더 필드를 -1로 만들어 준다.
fileIdListForUpdate.add(fileMetadata.getId());
}
});
// 하위의 폴더 조회
List<FolderMetadata> childFolders = folderMetadataRepository.findByParentFolderId(currentFolderId);
// 하위 폴더들을 스택에 추가
for (FolderMetadata childFolder : childFolders) {
folderIdStack.push(childFolder.getId());
}
}
}
파일 이동에서 루트 경로를 찾는 것은 동일하다. 위 코드는 삭제할 때 하위 파일들을 DFS로 찾는 과정이다. S3에 업로드 중인 파일 때문에 S3에 조회하는 과정이 있다. 이런 과정이 너무 복잡하고 오래 걸리니까 배치 업데이트로 해당 파일과 폴더에 대해 접근하지 못하게 한 후, 스케줄러가 이런 파일과 폴더를 찾아서 S3에 있는 경우 S3 파일을 지우고 DB 메타데이터도 지우면 좋다는 생각이 들었다. 그럼 굳이 삭제할 때 S3 파일 삭제작업까지 오래 걸리는 시간을 기다릴 필요가 없고, DB 커넥션도 보다 짧게 사용하여 DB 부하가 줄어들 것 같다.
현재 방식의 문제점 정리
첫째로, 용량 계산에서의 락 문제이다.데이터가 일관된 상태로 삭제되고 이동되고 용량 계산도 되지만, 락이 과하게 걸려있다. 수정하게 된다면 락을 최대한 사용하지 않고, 용량 계산은 후처리 하는 것이 좋을 것 같다. 아마 이렇게 후처리를 한다면, 어느 정도 일관성이 깨질 수 있다. 이 경우 사용자가 새로고침하는 버튼을 만들어서 그 버튼을 누르면 해당 레이어 기준으로 동기화를 시켜주면 괜찮지 않을까?라는 생각이다.
둘째도 락 문제인데, 삭제할 때의 락 문제이다. 사실 이 경우도 고아 파일 떄문에 문제가 된 것인데, 고아 파일을 지속적으로 찾는 것도 괜찮다는 생각이 들었다. 기존에는 고아 파일을 찾으려면 조인을 통해 부모가 없는 파일만 솎아내야 한다고 생각하여 임시 테이블 사용이 걱정되어 사용할 생각을 하지 않았다. 하지만 굉장히 긴 텀을 가진 스케줄러가 파일을 페이징 처리하여 읽고, 이후 읽은 값을 바탕으로 부모 폴더 데이터를 찾는 별도의 쿼리를 사용하고 애플리케이션 수준에서 부모의 유무를 확인해 주면 좋다는 생각이 들었다. 이때 부모가 없는 파일의 경우 pk값을 적당히 버퍼링 하고, 일정 수준 이상 pk값이 모이면 벌크 쿼리로 한 번에 삭제하면 좋을 것 같다. 운영체제를 다시 훑어보다가 고아 프로세스의 경우 subreaper로 고아 프로세스를 회수하는 것을 보았고, 이렇게 별도로 처리하는 것도 괜찮을 것 같다는 생각이 들었다.
위의 두 가지 문제를 추후 개선해보면 좋을 것 같다. 아마 DB 부하가 줄어들어 더 빨라질 것으로 예상된다. 이외에도 락을 위로 걸고, 아래로 걸어서 데드락이 발생할 수도 있었는데, 이 문제 또한 함께 해결될 것 같다.
후기
다양한 의견을 들어보는 것이 굉장히 중요한 것 같다. 당시에만 해도 팀원들과 의논했을 때, 락을 쓰는 것이 가장 좋은 방법이라 생각했지만, 최근에 크게 민감하지 않은 데이터는 일관성이 조금 깨지더라도 락 사용을 하지 않는 것이 더 좋다는 피드백을 받았다. 결국 큰 시스템을 운영하려면 이렇게 많은 락을 걸고 트랜잭션을 길게 가져가는 것이 DB에 부담이 많이 되니 이를 개선하는 것이 포인트인 것 같다. CS 공부를 해도 잘 활용하기에는 노력이 더 필요함을 느꼈다. 항상 당시에는 미처 생각하지 못했지만, 프로젝트가 끝나고 팀원과 부족한 점을 찾거나 다른 사람에게 피드백을 받으면 새로운 문제들을 발견하는 것 같다.