드라이브 시스템
- 사용자는 개인 드라이브에 폴더를 만들거나 파일을 업로드할 수 있다. 이렇게 업로드된 파일은 벡터 임베딩 스케줄러에 의해 Fast API를 호출해서 벡터 임베딩되고, Qdrant 벡터 저장소에 저장되어 RAG가 질의를 할 때 참고할 수 있도록 한다.
- 드라이브 시스템을 구현할 때 고려해야 했던 부분은, 어떻게 해야 효율적으로 계층적인 폴더 구조를 사용자에게 제공해서 보여줄 수 있는지 였다.
- 이를 위해 JPA에서 폴더 내에 어떤 파일과 폴더가 있는지 그리고 본인의 부모 폴더가 누구인지 연관관계를 맺어주었다.
- 하지만 폴더의 계층 구조를 부모 폴더 및 자식 폴더의 연관관계를 통해 접근해서 조회하면, 쿼리가 재귀적으로 나가기 때문에 폴더 깊이가 깊어질수록 성능이 좋지 않다는 문제가 있었다.
- 따라서 이를 해결하기 위해 UUID 기반의 논리적 경로(/uuidA/uuidB/uuidC/)를 두어, 해당 논리 경로로 시작하는 (startsWith) 폴더들을 인덱스를 타서 효율적으로 조회될 수 있도록 하였다.
드라이브 폴더 엔티티 일부
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@DynamicInsert
@ToString(exclude = {"parentFolder", "childFolders", "files"}) // 연관관계 무한 루프 방지
@Table(name = "drive_folder_tb",
indexes = {
@Index(name = "idx_drive_folder_list", columnList = "drive_code, parent_folder_id, is_deleted"),
@Index(name = "idx_drive_folder_path", columnList = "drive_code, logical_path"),
@Index(name = "idx_drive_folder_path_deleted", columnList = "drive_code, logical_path, is_deleted"),
@Index(name = "idx_drive_folder_favorite", columnList = "drive_code, is_favorite, is_deleted"),
@Index(name = "idx_drive_folder_name", columnList = "drive_code, name, is_deleted"),
@Index(name = "idx_drive_folder_created", columnList = "drive_code, created_date, is_deleted"),
})
public class DriveFolderEntity {
...
@Column(name = "logical_path", length = 2048)
@Schema(description = "폴더의 논리적 경로. 조회할 때 재귀적으로 파악하지 않고 한 번의 쿼리로 조회하기 위함")
private String logicalPath;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_folder_id")
@JsonIgnore
@Schema(description = "드라이브 부모(상위) 폴더 (root는 null)")
private DriveFolderEntity parentFolder;
@OneToMany(mappedBy = "parentFolder", cascade = CascadeType.ALL)
@Builder.Default
@Schema(description = "드라이브 자식(하위) 폴더")
private List<DriveFolderEntity> childFolders = new ArrayList<>();
@OneToMany(mappedBy = "currFolder", cascade = CascadeType.ALL)
@Builder.Default
@Schema(description = "폴더 내 파일 목록")
private List<DriveFileEntity> files = new ArrayList<>();
...
@PrePersist
public void prePersist() {
if (this.id == null) this.id = UUID.randomUUID().toString();
if (this.parentFolder == null) {
this.depth = 0;
this.logicalPath = "/" + this.id + "/";
} else {
this.depth = this.parentFolder.getDepth() + 1;
this.logicalPath = this.parentFolder.getLogicalPath() + this.id + "/";
}
}
public void changeParentFolder(DriveFolderEntity newParentFolder) {
if (this.parentFolder != null) {
this.parentFolder.getChildFolders().remove(this);
}
this.parentFolder = newParentFolder;
if (newParentFolder == null) {
this.depth = 0;
this.logicalPath = "/" + this.id + "/";
} else {
newParentFolder.getChildFolders().add(this);
this.depth = newParentFolder.getDepth() + 1;
this.logicalPath = newParentFolder.getLogicalPath() + this.id + "/";
}
}
}
- 논리적 경로는 UUID 기반으로 구성되어 있기 때문에 폴더 ID가 겹칠 확률은 고려하지 않아도 될 정도로 낮고, 파일에 대해서는 해당 파일의 부모 폴더를 찾으면 자연스럽게 접근할 수 있다.
- 하지만, 이렇게 설계하면 폴더 이동 시 해당 폴더 하위에 있는 모든 자손 폴더들에 대한 논리 경로를 일괄적으로 업데이트 해주어야 하는 비용이 발생한다.
- 보통 이동보다는 조회하는 빈도가 더 많기 때문에, 조회 성능을 위한 트레이드오프이다.
드라이브 파일 엔티티 일부
@Builder
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "currFolder") // 연관관계 무한루프 방지
@DynamicInsert
@Schema(description = "드라이브 파일 테이블")
@Table(name = "drive_file_tb",
indexes = {
@Index(name = "idx_drive_created", columnList = "drive_code, is_deleted, created_date"),
@Index(name = "idx_drive_folder", columnList = "drive_code, folder_id, is_deleted"),
@Index(name = "idx_embedding", columnList = "embedding_status, is_deleted"),
@Index(name = "idx_drive_favorite", columnList = "drive_code, is_favorite, is_deleted"),
@Index(name = "idx_drive_file", columnList = "drive_code, is_deleted, id"),
@Index(name = "idx_drive_name", columnList = "drive_code, is_deleted, name"),
@Index(name = "idx_drive_ext", columnList = "drive_code, is_deleted, ext")
})
public class DriveFileEntity {
...
@Id
@Column(name = "id")
@Schema(description = "드라이브 파일 아이디")
private String id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "folder_id")
@JsonIgnore
@Schema(description = "현재 파일이 소속된 폴더")
private DriveFolderEntity currFolder;
@Column(name = "name")
@Schema(description = "사용자가 보는 파일명")
private String name;
@Column(name = "physical_path")
@Schema(description = "물리적 저장 경로")
private String physicalPath;
@Enumerated(EnumType.STRING)
@Column(name = "embedding_status", length = 20)
@ColumnDefault("'READY'")
@Schema(description = "벡터 임베딩 상태")
private DriveFileEmbeddingStatus embeddingStatus;
...
@PrePersist
public void prePersist() {
if (this.id == null) {
this.id = UUID.randomUUID().toString();
}
}
// 파일 속한 폴더 변경 연관관계 편의 메서드
public void changeCurrFolder(DriveFolderEntity targetCurrFolder) {
if (this.currFolder != null) {
this.currFolder.getFiles().remove(this);
}
this.currFolder = targetCurrFolder;
if (targetCurrFolder != null && !targetCurrFolder.getFiles().contains(this)) {
targetCurrFolder.getFiles().add(this);
}
}
}
- 정리하자면, 폴더는 실제 서버에 생성되는 것이 아니고 DB에 논리적 경로를 가진 채 저장됨으로써 계층적인 폴더 구조를 표현한다.
- 따라서 실제 서버에 물리적으로 저장되는 것은 파일 뿐이며 파일 또한 서버에 flat하게 저장되지만, 부모 폴더와 연관관계를 맺고 있기 때문에 부모 폴더에 의해 자연스럽게 계층적으로 표현될 수 있다.
- 덕분에 폴더 구조가 바뀌어도 실제 파일 I/O는 발생하지 않는다.
비즈니스 로직
폴더 조회
/**
* 폴더 클릭 시, 해당 폴더 내용물 조회
*/
@Transactional(readOnly = true)
public DriveModel.DriveFolderContentRes getDriveFolderContent(UserEntity user, DriveModel.DriveFolderContentReq req) {
Optional<String> driveCodeOpt = driveUserQueryRepository.getPersonalDriveCode(user.getUserId());
String currentFolderName = ROOT_FOLDER_NAME; // 기본값 (루트)
String currentFolderLogicalPath = ROOT_FOLDER_LOGICAL_PATH; // 기본값 (루트)
Integer currentFolderDepth = 0; // 기본값 (루트)
List<DriveModel.BreadcrumbItem> breadcrumbs = new ArrayList<>();
breadcrumbs.add(new DriveModel.BreadcrumbItem(null, ROOT_FOLDER_NAME)); // 항상 ROOT부터 시작
// 드라이브 없으면 빈 껍데기 리턴
if (driveCodeOpt.isEmpty()) {
return DriveModel.DriveFolderContentRes.builder()
.currentFolderId(null)
.currentFolderName(currentFolderName)
.logicalPath(currentFolderLogicalPath)
.depth(currentFolderDepth)
.breadcrumbs(breadcrumbs)
.folders(List.of())
.files(List.of())
.build();
}
String driveCode = driveCodeOpt.get();
// 현재 폴더 정보 조회
if (req.getFolderId() != null) {
DriveFolderEntity folder = driveFolderQueryRepository.findById(driveCode, req.getFolderId())
.orElseThrow(() -> new CustomException(ErrorCode.DRIVE_FOLDER_NOT_FOUND));
currentFolderName = folder.getName();
currentFolderDepth = folder.getDepth();
currentFolderLogicalPath = folder.getLogicalPath();
breadcrumbs.addAll(generateBreadcrumbs(folder, driveCode));
}
// 해당 폴더의 하위 폴더/파일 조회
List<DriveFolderEntity> subFolders = driveFolderQueryRepository.findChildrenByCondition(driveCode, req);
List<DriveFileEntity> subFiles = driveFileQueryRepository.findFilesInFolderByCondition(driveCode, req);
return DriveModel.DriveFolderContentRes.builder()
.currentFolderId(req.getFolderId())
.currentFolderName(currentFolderName)
.depth(currentFolderDepth)
.logicalPath(currentFolderLogicalPath)
.breadcrumbs(breadcrumbs)
.folders(driveFolderEntityMapper.toFolderResponseList(subFolders))
.files(driveFileEntityMapper.toFileResponseList(subFiles, clientUrl))
.build();
}
/*
* 현재 폴더의 논리 경로를 이용해 Breadcrumbs (드라이브 경로) 생성
*/
private List<DriveModel.BreadcrumbItem> generateBreadcrumbs(DriveFolderEntity currFolder, String driveCode) {
List<DriveModel.BreadcrumbItem> breadcrumbs = new ArrayList<>();
// 논리 경로 파싱
String[] pathIds = currFolder.getLogicalPath().split("/");
List<String> ancestorIds = Arrays.stream(pathIds)
.filter(id -> !id.isEmpty())
.toList();
// 조상 폴더들 이름 조회
if (!ancestorIds.isEmpty()) {
Map<String, String> folderNameMap = driveFolderQueryRepository.findByIds(driveCode, ancestorIds).stream()
.collect(Collectors.toMap(DriveFolderEntity::getId, DriveFolderEntity::getName));
// 순서대로 추가
for (String id : ancestorIds) {
if (folderNameMap.containsKey(id)) {
breadcrumbs.add(new DriveModel.BreadcrumbItem(id, folderNameMap.get(id)));
}
}
}
return breadcrumbs;
}
- 부모 폴더의 id를 통해 해당 폴더 내에 있는 폴더와 파일들을 조회한다.
폴더/파일 이동
/**
* 파일/폴더들을 특정 폴더로 이동
*/
@Transactional
public void moveItems(UserEntity user, DriveModel.MoveItemsReq req) {
String driveCode = driveUserQueryRepository.getPersonalDriveCode(user.getUserId())
.orElseThrow(() -> new CustomException(ErrorCode.DRIVE_NOT_FOUND));
// 이동할 목적지 폴더 확인 (null 이면 루트)
DriveFolderEntity targetFolder = null;
String targetLogicalPath = ROOT_FOLDER_LOGICAL_PATH; // root용 기본값
Integer targetDepth = 0; // root용 기본값
if (req.getTargetFolderId() != null) {
targetFolder = driveFolderQueryRepository.findById(driveCode, req.getTargetFolderId())
.orElseThrow(() -> new CustomException(ErrorCode.DRIVE_FOLDER_NOT_FOUND));
targetLogicalPath = targetFolder.getLogicalPath();
targetDepth = targetFolder.getDepth();
}
// 이동에 따른 임베딩 상태 영향 받는 폴더 아이디 리스트
Set<String> affectedFolderIds = new HashSet<>();
if (targetFolder != null) {
affectedFolderIds.add(targetFolder.getId());
}
// 파일 이동 처리
moveFiles(req, driveCode, targetFolder, affectedFolderIds);
// 폴더 이동 처리
moveFolders(req, driveCode, targetFolder, affectedFolderIds);
// 폴더 임베딩 상태 변경 전에, 변경 사항을 반영하고 영속성 컨텍스트를 초기화
em.flush();
em.clear();
// 이동에 따라 영향 받는 폴더 임베딩 상태 변경
driveFileEmbedService.updateFolderEmbStatusByFolderId(driveCode, affectedFolderIds);
}
/*
* 파일들을 이동시킬 목표 폴더로 이동
*/
private void moveFiles(DriveModel.MoveItemsReq req,
String driveCode,
DriveFolderEntity targetFolder,
Set<String> affectedFolderIds) {
if (req.getFileIds() != null && !req.getFileIds().isEmpty()) {
List<DriveFileEntity> files = driveFileQueryRepository.findByIds(driveCode, req.getFileIds());
if (files.size() != req.getFileIds().size()) {
throw new CustomException(ErrorCode.FILE_NOT_FOUND);
}
List<String> fileNames = files.stream()
.map(DriveFileEntity::getName)
.toList();
String targetFolderId = (targetFolder == null) ? null : targetFolder.getId();
List<String> duplicateNames = driveFileQueryRepository.findExistingNamesInFolder(driveCode, targetFolderId, fileNames);
// 이동할 곳에 같은 이름 파일이 이미 있는지
if (!duplicateNames.isEmpty()) {
throw new CustomException(ErrorCode.DRIVE_NAME_DUPLICATED);
}
for (DriveFileEntity file : files) {
if (file.getCurrFolder() != null) {
affectedFolderIds.add(file.getCurrFolder().getId());
}
file.changeCurrFolder(targetFolder);
}
}
}
/*
* 폴더들을 이동시킬 목표 폴더로 이동
*/
private void moveFolders(DriveModel.MoveItemsReq req,
String driveCode,
DriveFolderEntity targetFolder,
Set<String> affectedFolderIds) {
if (req.getFolderIds() != null && !req.getFolderIds().isEmpty()) {
List<DriveFolderEntity> folders = driveFolderQueryRepository.findByIds(driveCode, req.getFolderIds());
if (req.getFolderIds().size() != folders.size()) {
throw new CustomException(ErrorCode.DRIVE_FOLDER_NOT_FOUND);
}
String targetFolderId = (targetFolder == null) ? null : targetFolder.getId();
List<String> folderNames = folders.stream()
.map(DriveFolderEntity::getName)
.collect(Collectors.toList());
List<String> duplicateNames = driveFolderQueryRepository.findExistingNamesInParent(driveCode, targetFolderId, folderNames);
// 중복된 폴더명이 있는지
if (!duplicateNames.isEmpty()) {
throw new CustomException(ErrorCode.DRIVE_NAME_DUPLICATED);
}
for (DriveFolderEntity movingFolder : folders) {
// 순환 이동 방지 (자신의 하위 폴더로 이동시킬 수 없음)
if (targetFolder != null && targetFolder.getLogicalPath().startsWith(movingFolder.getLogicalPath())) {
throw new CustomException(ErrorCode.DRIVE_FOLDER_INVALID_MOVE);
}
if (movingFolder.getParentFolder() != null) {
affectedFolderIds.add(movingFolder.getParentFolder().getId());
}
// 하위 폴더들 업데이트를 위해 이동 전 정보 저장
String oldPathPrefix = movingFolder.getLogicalPath();
int oldDepth = movingFolder.getDepth();
// 나 자신 폴더 업데이트
movingFolder.changeParentFolder(targetFolder);
// 변경된 정보
String newPathPrefix = movingFolder.getLogicalPath();
int depthDiff = movingFolder.getDepth() - oldDepth;
// 내 하위 폴더들 (soft delete 처리된 폴더들까지 포함) 전부 업데이트 (depth, logicalPath)
driveFolderQueryRepository.bulkUpdateDescendantsPath(driveCode, movingFolder.getId(), oldPathPrefix, newPathPrefix, depthDiff);
}
}
}
/**
* 하위 폴더들의 경로와 깊이를 일괄 수정
*/
public long bulkUpdateDescendantsPath(String driveCode, String selfId, String oldPathPrefix, String newPathPrefix, int depthDiff) {
long count = query
.update(qDriveFolderEntity)
.set(qDriveFolderEntity.logicalPath,
Expressions.stringTemplate("REPLACE({0}, {1}, {2})",
qDriveFolderEntity.logicalPath, oldPathPrefix, newPathPrefix))
.set(qDriveFolderEntity.depth, qDriveFolderEntity.depth.add(depthDiff))
.where(
qDriveFolderEntity.driveCode.eq(driveCode),
qDriveFolderEntity.logicalPath.startsWith(oldPathPrefix),
qDriveFolderEntity.id.ne(selfId) // 나 자신은 제외
)
.execute();
return count;
}
- 파일에 대해서는 객체지향적인 직관성을 위해 Bulk Update 하지 않고 변경감지로 구현하였다. 어차피 batch_size 설정으로 인해 쿼리가 묶여서 나간다.
- 폴더에 대해서는 각각의 폴더들에 대해 하위에 있는 모든 자손 폴더들에 대해 변경사항을 적용시켜주어야 하므로, Bulk Update를 해주었다.
- 이때, startsWith를 통해 해당 폴더 하위에 있는 모든 자손 폴더들을 효율적으로 찾아서 논리 경로를 replace 함수를 통해 변경해주었다. 폴더 하위에 있는 파일들은 부모 폴더가 바뀌지 않으므로 폴더만 바꿔주면 된다.
- 논리적으로 삭제된 자손들에 대해서도 함께 이동처리를 시켜줌으로써, 나중에 삭제된 폴더 또는 파일을 복구했을 때 고아가 되지 않고 성공적으로 복구될 수 있도록 하였다.
- Bulk 연산은 영속성 컨텍스트를 무시하고 DB에 직접 반영되므로, 이동 로직 후 이어지는 임베딩 상태 변경 로직에서 데이터 불일치가 발생할 수 있다.
- 따라서 flush()로 잔여 변경사항을 확정 짓고, clear()로 영속성 컨텍스트를 초기화하여 이후 로직이 최신 데이터를 바라보게 하였다.
폴더/파일 삭제
/**
* 파일/폴더 삭제 (soft delete)
*/
@Transactional
public void deleteItems(UserEntity user, DriveModel.DeleteItemsReq req) {
String driveCode = driveUserQueryRepository.getPersonalDriveCode(user.getUserId())
.orElseThrow(() -> new CustomException(ErrorCode.DRIVE_NOT_FOUND));
// 삭제로 인해 임베딩 상태에 영향을 받는 폴더 아이디 목록
Set<String> affectedFolderIds = new HashSet<>();
// 파일 일괄 삭제 처리
if (req.getFileIds() != null && !req.getFileIds().isEmpty()) {
List<DriveFileEntity> files = driveFileQueryRepository.findByIds(driveCode, req.getFileIds());
for (DriveFileEntity file : files) {
if (file.getCurrFolder() != null) {
affectedFolderIds.add(file.getCurrFolder().getId());
}
}
driveFileQueryRepository.softDeleteByIds(driveCode, req.getFileIds());
}
// 폴더 일괄 삭제 처리
if (req.getFolderIds() != null && !req.getFolderIds().isEmpty()) {
List<DriveFolderEntity> folders = driveFolderQueryRepository.findByIds(driveCode, req.getFolderIds());
for (DriveFolderEntity folder : folders) {
if (folder.getParentFolder() != null) {
affectedFolderIds.add(folder.getParentFolder().getId());
}
}
// 이미 부모에 포함된 하위 폴더들은 제외. /A/, /A/B/ 가 들어오면 /A/B/는 어차피 /A/ 삭제할 때 같이 삭제됨
List<String> paths = filterRedundantPaths(folders);
if (!paths.isEmpty()) {
driveFileQueryRepository.softDeleteByFolderPaths(driveCode, paths);
driveFolderQueryRepository.softDeleteByPaths(driveCode, paths);
}
}
// 폴더 임베딩 상태 변경 전에, 변경 사항을 반영하고 영속성 컨텍스트를 초기화
em.flush();
em.clear();
// 삭제로 인해 영향받는 폴더들에 대해 임베딩 상태 변경
driveFileEmbedService.updateFolderEmbStatusByFolderId(driveCode, affectedFolderIds);
}
/*
* 중복 경로 필터링 헬퍼 메서드
*/
private List<String> filterRedundantPaths(List<DriveFolderEntity> folders) {
// 길이 짧은게 부모이므로, 길이순 정렬
List<String> paths = folders.stream()
.map(DriveFolderEntity::getLogicalPath)
.sorted()
.toList();
List<String> result = new ArrayList<>();
for (String path : paths) {
boolean isChild = result.stream().anyMatch(path::startsWith);
if (!isChild) result.add(path);
}
return result;
}
/**
* 파일 ID 목록으로 파일 일괄 Soft Delete
*/
public long softDeleteByIds(String driveCode, List<String> fileIds) {
long count = query
.update(qDriveFileEntity)
.set(qDriveFileEntity.isDeleted, true)
.set(qDriveFileEntity.deletedDate, LocalDateTime.now())
.where(
qDriveFileEntity.driveCode.eq(driveCode),
qDriveFileEntity.id.in(fileIds),
qDriveFileEntity.isDeleted.isFalse()
)
.execute();
return count;
}
/**
* 특정 폴더 경로 (logicalPath) 하위에 있는 모든 파일 일괄 Soft Delete
*/
public long softDeleteByFolderPaths(String driveCode, List<String> folderPaths) {
BooleanBuilder pathCondition = new BooleanBuilder();
for (String path : folderPaths) {
pathCondition.or(qDriveFolderEntity.logicalPath.startsWith(path));
}
long count = query
.update(qDriveFileEntity)
.set(qDriveFileEntity.isDeleted, true)
.set(qDriveFileEntity.deletedDate, LocalDateTime.now())
.where(
qDriveFileEntity.driveCode.eq(driveCode),
qDriveFileEntity.isDeleted.isFalse(),
qDriveFileEntity.currFolder.id.in(
JPAExpressions
.select(qDriveFolderEntity.id)
.from(qDriveFolderEntity)
.where(
qDriveFolderEntity.driveCode.eq(driveCode),
pathCondition
)
)
)
.execute();
return count;
}
/**
* 특정 경로 (logicalPath)로 시작하는 모든 폴더 일괄 Soft Delete
*/
public long softDeleteByPaths(String driveCode, List<String> folderPaths) {
BooleanBuilder pathCondition = new BooleanBuilder();
for (String path : folderPaths) {
pathCondition.or(qDriveFolderEntity.logicalPath.startsWith(path));
}
long count = query
.update(qDriveFolderEntity)
.set(qDriveFolderEntity.isDeleted, true)
.set(qDriveFolderEntity.deletedDate, LocalDateTime.now())
.where(
qDriveFolderEntity.driveCode.eq(driveCode),
qDriveFolderEntity.isDeleted.isFalse(),
pathCondition
)
.execute();
return count;
}
- 삭제에 대해서도 startsWith를 통해 인덱스를 타게해서 효율적으로 삭제 처리를 할 수 있도록 했다.
- 또한 삭제 대상 폴더에 대해 /A/, /A/B/가 들어오면 B는 폴더 A가 삭제될 때 같이 삭제되므로, 이런 중복되는 경로에 있는 폴더들은 제외하고 필요한 폴더만 삭제할 수 있도록 하였다.
- 논리적으로 삭제되었기 때문에 DB에는 여전히 남아있으며, 추후 휴지통 기능, 영구 삭제, 복원 기능 등을 도입할 수 있을 것이다.
- 영구적으로 삭제한다면 서버에 물리적으로 저장된 파일과 RAG 서버에서 벡터 임베딩된 파일도 삭제를 해주어야 할 것이다.
파일 업로드
/**
* 파일 업로드 (생성 시점에 드라이브가 없으면 드라이브 생성)
*/
public DriveModel.UploadFileRes uploadFiles(List<MultipartFile> multipartFiles, UserEntity user, String folderId) {
String driveCode = transactionTemplate.execute(status ->
getOrCreateMyPersonalDrive(user.getUserId())
);
List<DriveModel.UploadSuccess> successList = new ArrayList<>();
List<DriveModel.UploadFailure> failureList = new ArrayList<>();
// 업로드 처리할 파일들 추출
List<MultipartFile> filesToProcess = getFilesToProcess(multipartFiles, folderId, driveCode, failureList);
// 실제 파일 처리
for (MultipartFile file : filesToProcess) {
if (file.isEmpty()) continue;
String physicalPath = null; // 롤백용 경로 저장
String originalName = file.getOriginalFilename();
try {
// 커넥션 점유하지 않기 위해 트랜잭션 밖에서 I/O 처리
FileUploadInfo fileInfo = storePhysicalFile(file, driveCode);
physicalPath = fileInfo.getPhysicalPath();
// DB 저장하기 위해 여기서 트랜잭션 켬
String fileId = transactionTemplate.execute(status -> {
// 중복 체크 및 저장 로직
return saveFileMetadataToDB(user, folderId, driveCode, fileInfo);
});
// 업로드 성공 리스트 추가
successList.add(DriveModel.UploadSuccess.builder()
.originalName(originalName)
.fileId(fileId)
.build());
} catch (Exception e) {
// 실패 시 물리적으로 저장된 파일 삭제
if (physicalPath != null) {
deletePhysicalFile(physicalPath);
}
String errorMessage;
if (e instanceof CustomException) {
errorMessage = e.getMessage();
} else {
log.error("파일 업로드 중 알 수 없는 오류 발생: filename={}, error={}", originalName, e.getMessage(), e);
errorMessage = "처리 중 오류로 인해 업로드에 실패했습니다.";
}
failureList.add(DriveModel.UploadFailure.builder()
.originalName(originalName)
.errorMessage(errorMessage)
.build());
}
}
return DriveModel.UploadFileRes.builder()
.successes(successList)
.failures(failureList)
.build();
}
/*
* 이미 저장되어 있지 않아서 업로드 처리할 파일들 가져오기
*/
private List<MultipartFile> getFilesToProcess(List<MultipartFile> multipartFiles, String folderId, String driveCode, List<DriveModel.UploadFailure> failureList) {
// 파일 이름 추출
List<String> requestedNames = multipartFiles.stream()
.map(MultipartFile::getOriginalFilename)
.toList();
// 해당 폴더에 저장되어 있는 파일들 조회
Set<String> existingNames = new HashSet<>();
if (!requestedNames.isEmpty()) {
existingNames = new HashSet<>(driveFileQueryRepository.findExistingNamesInFolder(driveCode, folderId, requestedNames));
}
// 실제 처리할 파일 리스트 계산(중복 아닌 것만)
List<MultipartFile> filesToProcess = new ArrayList<>();
for (MultipartFile file : multipartFiles) {
String originalName = file.getOriginalFilename();
if (existingNames.contains(originalName)) {
// 중복이면 I/O 처리 막기 위해 실패 처리
failureList.add(DriveModel.UploadFailure.builder()
.originalName(originalName)
.errorMessage("이미 동일한 이름의 파일이 존재합니다.")
.build());
} else {
filesToProcess.add(file);
}
}
return filesToProcess;
}
/*
* I/O 전담 메서드 (DB 접근 없음)
*/
private FileUploadInfo storePhysicalFile(MultipartFile file, String driveCode) throws IOException {
String uuid = UUID.randomUUID().toString();
String filename = file.getOriginalFilename();
String fileDir = Paths.get(config.getFileDir(), "drive", driveCode, uuid).toString();
// 물리적 디렉토리 생성
File directory = new File(fileDir);
if (!directory.mkdirs()) {
if(!directory.exists()) throw new CustomException(ErrorCode.FILE_DIR_CREATE_FAILED);
}
// 실제 파일 저장
Path filePath = Paths.get(fileDir, filename);
file.transferTo(filePath.toFile());
// 확장자 및 메타데이터 추출
String ext = "";
if (filename != null && filename.contains(".")) {
ext = filename.substring(filename.lastIndexOf(".")).toLowerCase();
}
return FileUploadInfo.builder()
.uuid(uuid)
.originalName(filename)
.physicalPath(filePath.toString())
.size(file.getSize())
.contentType(file.getContentType())
.ext(ext)
.build();
}
/*
* DB 저장 전담 메서드 (트랜잭션 내부에서 실행될 로직)
*/
private String saveFileMetadataToDB(UserEntity user, String folderId, String driveCode, FileUploadInfo info) {
DriveFolderEntity folder = null;
if (folderId != null) {
folder = driveFolderQueryRepository.findById(driveCode, folderId)
.orElseThrow(() -> new CustomException(ErrorCode.DRIVE_FOLDER_NOT_FOUND));
}
// 중복 체크
if (driveFileQueryRepository.existsByNameInFolder(driveCode, folderId, info.getOriginalName())) {
throw new CustomException(ErrorCode.DRIVE_NAME_DUPLICATED);
}
// 엔티티 생성 및 저장
DriveFileEntity fileEntity = DriveFileEntity.builder()
.id(info.getUuid())
.driveCode(driveCode)
.name(info.getOriginalName())
.physicalPath(info.getPhysicalPath())
.fileSize(info.getSize())
.contentType(info.getContentType())
.ext(info.getExt())
.createdId(user.getUserId())
.currFolder(folder)
.build();
return driveFileRepository.save(fileEntity).getId();
}
private void deletePhysicalFile(String pathStr) {
try {
Path path = Paths.get(pathStr);
// 파일 삭제
Files.deleteIfExists(path);
// 상위 폴더(UUID 폴더) 삭제
Path parentDir = path.getParent();
if (parentDir != null && Files.exists(parentDir)) {
Files.deleteIfExists(parentDir);
}
} catch (IOException e) {
log.warn("롤백 실패(좀비파일): {}", pathStr);
}
}
- 파일 I/O의 경우, 실제 서버 디스크에 저장하기 때문에 DB 커넥션을 오랫동안 물고 있을 수 있다. 따라서 DB 커넥션 고갈을 피하기 위해 전체 메서드에 트랜잭션을 걸지 않고 수행하기 위해 TransactionTemplate를 이용해서 트랜잭션을 분리했다.
- 보통 선언적 트랜잭션 방식을 사용하지만 필요한 부분에만 간단하게 트랜잭션을 걸기 위해, 프로그래밍적 트랜잭션 제어를 적용하였다.
벡터 임베딩 스케줄러
@Slf4j
@Component
@RequiredArgsConstructor
public class DriveFileEmbeddingScheduler {
private final DriveFileQueryRepository driveFileQueryRepository;
private final DriveFileEmbedService driveFileEmbedService;
private final AtomicBoolean isRunning = new AtomicBoolean(false);
/**
* 1초 마다 (동기 처리)
*/
@Scheduled(fixedRate = 1000)
public void pollAndProcessEmbedding() {
if (isRunning.compareAndSet(false, true)) {
try {
DriveFileEntity targetFile = driveFileQueryRepository.findNextEmbeddingTarget();
if (targetFile == null) return;
log.info("[Polling] Found target file: {} (Status: {})", targetFile.getId(), targetFile.getEmbeddingStatus());
driveFileEmbedService.processFileEmbedding(targetFile.getId());
} catch (Exception e) {
log.error("Error processing", e);
} finally {
isRunning.set(false);
}
}
}
}
/**
* 임베딩 대상 파일 1개 조회
*/
public DriveFileEntity findNextEmbeddingTarget() {
LocalDateTime zombieThreshold = LocalDateTime.now().minusMinutes(30);
LocalDateTime retryThreshold = LocalDateTime.now().minusMinutes(10);
return query
.selectFrom(qDriveFileEntity)
.where(
qDriveFileEntity.isDeleted.isFalse(), // 삭제된 파일 제외
qDriveFileEntity.embeddingStatus.in(DriveFileEmbeddingStatus.READY)
.or(
// 실패했지만, 마지막 수정(실패시점)으로부터 10분이 지난 것만 다시 조회
qDriveFileEntity.embeddingStatus.eq(DriveFileEmbeddingStatus.FAILED)
.and(qDriveFileEntity.modifiedDate.before(retryThreshold))
)
.or(
// 좀비 프로세스 체크 (PROCESSING 상태로 30분 이상 경과)
qDriveFileEntity.embeddingStatus.eq(DriveFileEmbeddingStatus.PROCESSING)
.and(qDriveFileEntity.modifiedDate.before(zombieThreshold))
)
)
.orderBy(qDriveFileEntity.createdDate.asc()) // FIFO
.limit(1)
.fetchOne();
}
@Slf4j
@Service
@RequiredArgsConstructor
public class DriveFileEmbedService {
private final DriveFileQueryRepository driveFileQueryRepository;
private final DriveFileRepository driveFileRepository;
private final DriveFileEntityMapper driveFileEntityMapper;
private final TransactionTemplate transactionTemplate;
private final WebClient ragApiWebClient;
private final DriveFolderQueryRepository driveFolderQueryRepository;
@Value("${custom.springapi.client-url}")
private String clientUrl;
public void processFileEmbedding(String fileId) {
DriveModel.RagApiFileEmbeddingReq requestBody = transactionTemplate.execute(status -> {
// DB 상태 변경 (READY -> PROCESSING)
driveFileQueryRepository.updateStatusByIds(List.of(fileId), DriveFileEmbeddingStatus.PROCESSING);
em.clear();
DriveFileEntity file = driveFileRepository.findById(fileId).orElseThrow(() -> new CustomException(ErrorCode.DRIVE_FILE_NOT_FOUND));
return driveFileEntityMapper.toRagApiRequest(file, clientUrl);
});
if (requestBody == null) {
throw new CustomException("Request body creation failed", ErrorCode.SYSTEM_INTERNAL_SERVER_ERROR);
}
// rag쪽 API 호출
try {
ragApiWebClient.post()
.uri("/rag/upload-files")
.bodyValue(List.of(requestBody))
.retrieve()
.toBodilessEntity()
.block();
transactionTemplate.executeWithoutResult(status ->
driveFileQueryRepository.updateStatusByIds(List.of(fileId), DriveFileEmbeddingStatus.COMPLETED)
);
log.info("Embedding Success: {}", fileId);
} catch (Exception e) {
log.error("FastAPI 호출 중 에러 발생", e);
try {
transactionTemplate.executeWithoutResult(status -> {
driveFileQueryRepository.updateStatusByIds(List.of(fileId), DriveFileEmbeddingStatus.FAILED);
});
} catch (Exception dbEx) {
log.error("임베딩 실패한 파일에 대해 상태 변경 실패", dbEx);
}
} finally {
updateFolderEmbStatusByFileId(fileId);
}
}
// 해당 파일이 속한 폴더 및 조상 폴더들에 대해 임베딩 상태 업데이트
private void updateFolderEmbStatusByFileId(String fileId) {
// 별도 트랜잭션으로 분리해서 파일 상태에 영향 없도록
transactionTemplate.executeWithoutResult(status -> {
DriveFileEntity file = driveFileRepository.findById(fileId).orElse(null);
if (file == null || file.getCurrFolder() == null) return;
updateRecursiveByPath(file.getCurrFolder().getLogicalPath());
});
}
@Transactional
public void updateFolderEmbStatusByFolderId(String driveCode, Set<String> folderIds) {
List<DriveFolderEntity> folders = driveFolderQueryRepository.findByIds(driveCode, new ArrayList<>(folderIds));
for (DriveFolderEntity folder : folders) {
updateRecursiveByPath(folder.getLogicalPath());
}
}
/**
* 논리 경로(Logical Path)를 받아 조상 폴더까지 거슬러 올라가며 상태 업데이트
*/
private void updateRecursiveByPath(String logicalPath) {
if (logicalPath == null || logicalPath.isEmpty()) return;
List<String> folderIds = new ArrayList<>();
String[] pathParts = logicalPath.split("/");
// 파싱 및 역순 정렬 (자식 -> 부모 -> 조상)
for (int i = pathParts.length - 1; i >= 0; i--) {
if (!pathParts[i].isEmpty()) {
folderIds.add(pathParts[i]);
}
}
// 상태 업데이트
for (String folderId : folderIds) {
boolean isComplete = isFolderAllEmbedded(folderId);
driveFolderQueryRepository.updateAllEmbeddedStatus(folderId, isComplete);
}
}
private boolean isFolderAllEmbedded(String folderId) {
// 아직 완료되지 않은 파일이 있는지
if (driveFileQueryRepository.existsNotCompletedFileInFolder(folderId)) {
return false;
}
// 아직 완료되지 않은 자식 폴더가 있는지
if (driveFolderQueryRepository.existsNotCompletedSubFolder(folderId)) {
return false;
}
return true;
}
}
- 1초 간격으로 DB에 조회 쿼리를 날려 벡터 임베딩이 필요한 파일을 찾는다. 별도 작업 큐에 담아서 진행하는 Producer-Consumer 패턴을 사용하게 되면, 스프링 서버가 꺼졌을 때 작업 큐에 담긴 내용들이 모두 사라지기 때문에 이렇게 구현했다.
- Fast API 서버가 OCR 처리로 인해 자주 다운되는 상황을 예방하기 위해, 이전 파일에 대한 임베딩 작업이 끝나야 다음 파일을 DB에서 조회해서 임베딩 시키는 동기 방식을 사용했다.
- 실패한 파일에 대해서는 30분 뒤에 다시 조회하게 함으로써, 실패한 파일을 다음에 바로 조회해서 임베딩이 또 실패하는 등 계속해서 실패한 그 파일만 가져오는 문제를 예방했다. 추후에는 재시도 횟수 제한을 도입할 수 있을 것이다.
- RAG쪽 벡터 임베딩 API를 호출할 때 동기 방식으로 응답을 기다리므로, 파일 I/O와 마찬가지로 DB 커넥션을 오랫동안 점유하는 것을 막기 위해 트랜잭션을 분리했다.
- 많은 파일 업로드 시 부모 폴더 상태가 반복적으로 업데이트될 수 있는 문제가 있다.
- 파일을 여러 개를 업로드하고 파일 하나가 임베딩 완료될 때마다 폴더에 대한 임베딩 상태를 갱신해주는 것이 UX 측면에서 낫다고 판단해서 이렇게 했다.
- 추후 성능 개선을 위해 Debouncing(일정 시간 대기 후 한 번만 업데이트) 또는 배치 처리 방식을 도입할 수 있다.
파일 이동및 삭제에 대해서는 테스트 코드 검증이 필요할 것 같다.
폴더 임베딩 상태 변경 로직이 너무 성능이 안나올 것 같아서, 폴더 아이디를 취합해서 중복을 제거하고 폴더 깊이를 내림차순으로 정렬 (깊이가 깊을수록 자식이므로 최하위 자식부터 순차 처리)해서 처리하는 식으로 최적화했다.
인덱스에 대한 이해가 낮았어서, 어떻게 인덱스를 잘 걸어야 하는지 이번 기회를 통해 체화할 수 있었다. 조회 성능과 쓰기 성능의 트레이드오프를 고려하고 인덱스의 개념을 더 잘 이해할 수 있게 되었다. 인덱스를 도메인 특성을 고려해서, 선택도가 높고 조회 빈도가 높을 것으로 예상되는 컬럼을 선정하여 적절한 순서로 인덱스를 걸어주어야 함을 이해했다. DB 옵티마이저가 SQL 조회 시 WHERE 절의 순서가 인덱스 순서와 달라도 알아서 최적화해서 인덱스를 태워준다는 것을 알았다. Clustered Index와 Non-Clustered Index에 대해 알아볼 수 있었다.
처음에 반복문으로 업데이트 쿼리를 날리는 방식으로 짰다가, N + 1을 해결하려고 아이디를 리스트로 전부 취합해서 bulk update하는 식으로 최대한 최적화했다. 이 과정에서 영속성 컨텍스트에 대해 더 잘 이해할 수 있었다. 1차 캐시란 것이 무엇이고 bulk update를 통해 db에 직접 쿼리를 날릴 때 1차 캐시에 있는 데이터와 db 데이터의 불일치 상황을 고려해야 했다. 이를 해결하기 위해 flush()와 clear()를 적절한 시점에 호출하여 영속성 컨텍스트의 생명주기를 직접 제어하고, 데이터 정합성을 보장하도록 했다.
'심심할 때' 카테고리의 다른 글
| 운영 중인 시스템의 1:1 구조를 1:N으로 바꾸기 (0) | 2026.01.14 |
|---|
