드라이브 시스템

  • 사용자는 개인 드라이브에 폴더를 만들거나 파일을 업로드할 수 있다. 이렇게 업로드된 파일은 벡터 임베딩 스케줄러에 의해 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