본문 바로가기

개발/BACK

SpringBoot 파일 전송 구현하기 (MVC 패턴 + QueryDSL)

728x90

SpringBoot에서 파일 업로드/다운로드 기능을 구현하는 것은 웹 애플리케이션 개발에서 자주 마주치는 요구사항입니다.

이번 포스팅에서는 MVC 패턴을 기반으로 파일 전송 기능을 구현하고, QueryDSL을 활용하여 파일 정보를 효율적으로 조회하는 방법을 알아보겠습니다.


1. MVC 패턴이란?

MVC 패턴은 애플리케이션을 세 가지 계층으로 분리하는 아키텍처 패턴입니다:

  • Model: 데이터와 비즈니스 로직을 담당
  • View: 사용자 인터페이스 (REST API의 경우 Controller가 View 역할)
  • Controller: Model과 View 사이의 중재자 역할

SpringBoot에서는 다음과 같이 매핑됩니다:

  • Model: Entity, DTO
  • View: REST Controller (HTTP 요청/응답 처리)
  • Controller: Service, Repository (비즈니스 로직 및 데이터 접근)

2. 프로젝트 구조

src/main/java/com/example/fileupload/
├── controller/
│   └── FileController.java          # View 계층
├── service/
│   └── FileService.java             # Controller 계층 (비즈니스 로직)
├── repository/
│   ├── FileRepository.java          # Controller 계층 (데이터 접근)
│   ├── FileRepositoryCustom.java    # QueryDSL 인터페이스
│   └── impl/
│       └── FileRepositoryImpl.java  # QueryDSL 구현체
├── model/
│   ├── entity/
│   │   └── FileEntity.java          # Model 계층
│   └── dto/
│       ├── FileUploadRequest.java
│       └── FileResponse.java
└── config/
    └── FileStorageConfig.java

3. 의존성 설정 (build.gradle)

먼저 필요한 의존성을 추가합니다:

dependencies {
    // Spring Boot
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    
    // QueryDSL
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    implementation 'com.querydsl:querydsl-apt:5.0.0:jakarta'
    
    // Database
    runtimeOnly 'com.h2database:h2'
    
    // Lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    
    // QueryDSL Annotation Processor
    annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}

4. Model 계층 구현

4.1 Entity (FileEntity.java)

데이터베이스와 매핑되는 엔티티 클래스입니다:

@Entity
@Table(name = "files")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FileEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String originalFileName;  // 원본 파일명

    @Column(nullable = false)
    private String storedFileName;    // 저장된 파일명 (UUID)

    @Column(nullable = false)
    private String filePath;          // 파일 경로

    @Column(nullable = false)
    private Long fileSize;            // 파일 크기

    @Column(nullable = false)
    private String fileType;          // 파일 확장자

    @Column(nullable = false)
    private String contentType;       // MIME 타입

    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }
}

핵심 포인트:

  • originalFileName: 사용자가 업로드한 원본 파일명
  • storedFileName: 서버에 저장될 때 사용하는 고유한 파일명 (UUID 사용)
  • @PrePersist, @PreUpdate: 엔티티 생성/수정 시 자동으로 시간 설정

4.2 DTO (Data Transfer Object)

FileResponse.java - 클라이언트에게 반환할 데이터 구조:

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FileResponse {
    private Long id;
    private String originalFileName;
    private String storedFileName;
    private Long fileSize;
    private String fileType;
    private String contentType;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

5. Controller 계층 구현

5.1 Repository - QueryDSL 설정

FileRepositoryCustom.java - QueryDSL을 사용한 커스텀 쿼리 인터페이스:

public interface FileRepositoryCustom {
    List<FileEntity> findByFileNameContaining(String fileName);
    List<FileEntity> findByFileType(String fileType);
    List<FileEntity> findByFileNameAndFileType(String fileName, String fileType);
    Optional<FileEntity> findByStoredFileName(String storedFileName);
}

FileRepositoryImpl.java - QueryDSL 구현체:

@Repository
public class FileRepositoryImpl implements FileRepositoryCustom {

    private final JPAQueryFactory queryFactory;
    private final QFileEntity file = QFileEntity.fileEntity;

    public FileRepositoryImpl(EntityManager entityManager) {
        this.queryFactory = new JPAQueryFactory(entityManager);
    }

    @Override
    public List<FileEntity> findByFileNameContaining(String fileName) {
        return queryFactory
                .selectFrom(file)
                .where(file.originalFileName.containsIgnoreCase(fileName))
                .orderBy(file.createdAt.desc())
                .fetch();
    }

    @Override
    public List<FileEntity> findByFileNameAndFileType(String fileName, String fileType) {
        BooleanBuilder builder = new BooleanBuilder();
        
        if (fileName != null && !fileName.isEmpty()) {
            builder.and(file.originalFileName.containsIgnoreCase(fileName));
        }
        
        if (fileType != null && !fileType.isEmpty()) {
            builder.and(file.fileType.eq(fileType));
        }
        
        return queryFactory
                .selectFrom(file)
                .where(builder)
                .orderBy(file.createdAt.desc())
                .fetch();
    }
    
    // ... 나머지 메서드들
}

QueryDSL의 장점:

  • 타입 안정성: 컴파일 타임에 쿼리 오류를 발견할 수 있습니다
  • 동적 쿼리: BooleanBuilder를 사용하여 조건에 따라 쿼리를 동적으로 생성할 수 있습니다
  • 가독성: 메서드 체이닝으로 쿼리가 읽기 쉽습니다

5.2 Service - 비즈니스 로직

FileService.java - 파일 업로드/다운로드/조회/삭제 로직:

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FileService {

    private final FileRepository fileRepository;
    
    @Value("${file.upload-dir}")
    private String uploadDir;

    /**
     * 파일 업로드 처리
     */
    @Transactional
    public FileResponse uploadFile(MultipartFile multipartFile) throws IOException {
        // 1. 업로드 디렉토리 생성
        Path uploadPath = Paths.get(uploadDir);
        if (!Files.exists(uploadPath)) {
            Files.createDirectories(uploadPath);
        }

        // 2. 파일명 생성 (UUID 사용)
        String originalFileName = multipartFile.getOriginalFilename();
        String fileExtension = getFileExtension(originalFileName);
        String storedFileName = UUID.randomUUID().toString() + fileExtension;
        Path filePath = uploadPath.resolve(storedFileName);

        // 3. 파일 저장
        Files.copy(multipartFile.getInputStream(), filePath, 
                   StandardCopyOption.REPLACE_EXISTING);

        // 4. DB에 파일 정보 저장
        FileEntity fileEntity = FileEntity.builder()
                .originalFileName(originalFileName)
                .storedFileName(storedFileName)
                .filePath(filePath.toString())
                .fileSize(multipartFile.getSize())
                .fileType(fileExtension)
                .contentType(multipartFile.getContentType())
                .build();

        FileEntity savedEntity = fileRepository.save(fileEntity);
        return convertToResponse(savedEntity);
    }

    /**
     * QueryDSL을 사용한 파일 검색
     */
    public List<FileResponse> searchFiles(String fileName, String fileType) {
        List<FileEntity> files = fileRepository.findByFileNameAndFileType(fileName, fileType);
        return files.stream()
                .map(this::convertToResponse)
                .collect(Collectors.toList());
    }

    /**
     * 파일 다운로드
     */
    public Resource loadFileAsResource(Long fileId) throws MalformedURLException {
        FileEntity fileEntity = fileRepository.findById(fileId)
                .orElseThrow(() -> new RuntimeException("File not found"));

        Path filePath = Paths.get(fileEntity.getFilePath());
        Resource resource = new UrlResource(filePath.toUri());

        if (resource.exists() && resource.isReadable()) {
            return resource;
        } else {
            throw new RuntimeException("File not found or not readable");
        }
    }

    /**
     * 파일 삭제
     */
    @Transactional
    public void deleteFile(Long fileId) throws IOException {
        FileEntity fileEntity = fileRepository.findById(fileId)
                .orElseThrow(() -> new RuntimeException("File not found"));

        // 파일 시스템에서 삭제
        Path filePath = Paths.get(fileEntity.getFilePath());
        if (Files.exists(filePath)) {
            Files.delete(filePath);
        }

        // DB에서 삭제
        fileRepository.delete(fileEntity);
    }
}

핵심 로직:

  1. 파일 업로드: MultipartFile을 받아 서버에 저장하고 DB에 메타데이터 저장
  2. 파일 검색: QueryDSL을 사용하여 동적 쿼리로 파일 검색
  3. 파일 다운로드: 저장된 파일을 Resource로 반환
  4. 파일 삭제: 파일 시스템과 DB에서 모두 삭제

6. View 계층 구현

6.1 Controller (FileController.java)

REST API 엔드포인트를 제공합니다:

@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileController {

    private final FileService fileService;

    /**
     * 파일 업로드
     * POST /api/files/upload
     */
    @PostMapping("/upload")
    public ResponseEntity<FileResponse> uploadFile(
            @RequestParam("file") MultipartFile file) {
        try {
            if (file.isEmpty()) {
                return ResponseEntity.badRequest().build();
            }

            FileResponse response = fileService.uploadFile(file);
            return ResponseEntity.status(HttpStatus.CREATED).body(response);
            
        } catch (IOException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    /**
     * 파일 조회 (QueryDSL 사용)
     * GET /api/files?fileName={fileName}&fileType={fileType}
     */
    @GetMapping
    public ResponseEntity<List<FileResponse>> getFiles(
            @RequestParam(required = false) String fileName,
            @RequestParam(required = false) String fileType) {
        
        List<FileResponse> files;
        
        if (fileName != null && fileType != null) {
            files = fileService.searchFiles(fileName, fileType);
        } else if (fileName != null) {
            files = fileService.searchFilesByFileName(fileName);
        } else if (fileType != null) {
            files = fileService.searchFilesByFileType(fileType);
        } else {
            files = fileService.getAllFiles();
        }
        
        return ResponseEntity.ok(files);
    }

    /**
     * 파일 다운로드
     * GET /api/files/{id}/download
     */
    @GetMapping("/{id}/download")
    public ResponseEntity<Resource> downloadFile(@PathVariable Long id) {
        try {
            Resource resource = fileService.loadFileAsResource(id);
            return ResponseEntity.ok()
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .header(HttpHeaders.CONTENT_DISPOSITION, 
                            "attachment; filename=\"" + resource.getFilename() + "\"")
                    .body(resource);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
        }
    }

    /**
     * 파일 삭제
     * DELETE /api/files/{id}
     */
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteFile(@PathVariable Long id) {
        try {
            fileService.deleteFile(id);
            return ResponseEntity.noContent().build();
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
}

7. 설정 파일

7.1 application.yml

spring:
  servlet:
    multipart:
      max-file-size: 10MB      # 최대 파일 크기
      max-request-size: 10MB    # 최대 요청 크기
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true

file:
  upload-dir: ./uploads          # 파일 저장 디렉토리

8. API 사용 예제

8.1 파일 업로드

curl -X POST http://localhost:8080/api/files/upload \
  -F "file=@example.pdf"

응답:

{
  "id": 1,
  "originalFileName": "example.pdf",
  "storedFileName": "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf",
  "fileSize": 102400,
  "fileType": ".pdf",
  "contentType": "application/pdf",
  "createdAt": "2024-01-01T10:00:00",
  "updatedAt": "2024-01-01T10:00:00"
}

8.2 파일 조회 (QueryDSL 사용)

# 파일명으로 검색
curl http://localhost:8080/api/files?fileName=example

# 파일 타입으로 검색
curl http://localhost:8080/api/files?fileType=.pdf

# 파일명 + 파일 타입으로 검색
curl http://localhost:8080/api/files?fileName=example&fileType=.pdf

8.3 파일 다운로드

curl -O http://localhost:8080/api/files/1/download

8.4 파일 삭제

curl -X DELETE http://localhost:8080/api/files/1

9. MVC 패턴의 흐름

파일 업로드 요청이 들어왔을 때의 흐름:

1. Client → FileController.uploadFile()
   (View 계층: HTTP 요청 수신)

2. FileController → FileService.uploadFile()
   (Controller 계층: 비즈니스 로직 호출)

3. FileService → FileRepository.save()
   (Controller 계층: 데이터 접근)

4. FileRepository → Database
   (데이터 저장)

5. FileService → FileResponse DTO 생성
   (Model 계층: 응답 데이터 구성)

6. FileController → Client
   (View 계층: HTTP 응답 반환)

10. QueryDSL의 장점

10.1 타입 안정성

// QueryDSL - 컴파일 타임에 오류 발견
file.originalFileName.containsIgnoreCase(fileName)  // ✅ 타입 안전

// JPQL - 런타임에 오류 발견
"SELECT f FROM FileEntity f WHERE f.originalFileName LIKE :fileName"  // ❌ 오타 가능

10.2 동적 쿼리

BooleanBuilder builder = new BooleanBuilder();

if (fileName != null) {
    builder.and(file.originalFileName.containsIgnoreCase(fileName));
}

if (fileType != null) {
    builder.and(file.fileType.eq(fileType));
}

// 조건에 따라 쿼리가 동적으로 생성됨
queryFactory.selectFrom(file).where(builder).fetch();

10.3 IDE 지원

  • 자동완성 지원
  • 리팩토링 시 쿼리도 함께 변경됨
  • 타입 체크로 오류 방지

11. 주의사항 및 개선 방안

11.1 보안

  • 파일 확장자 검증
  • 파일 크기 제한
  • 악성 파일 스캔
  • 업로드 디렉토리 권한 설정

11.2 성능

  • 대용량 파일 처리 시 스트리밍 사용
  • 비동기 처리 고려
  • 파일 저장소 분리 (S3, NFS 등)

11.3 예외 처리

  • 전역 예외 처리기 구현
  • 커스텀 예외 클래스 정의
  • 적절한 HTTP 상태 코드 반환

12. 마무리

이번 포스팅에서는 SpringBoot에서 MVC 패턴을 기반으로 파일 전송 기능을 구현하고, QueryDSL을 활용하여 효율적인 파일 조회 기능을 구현하는 방법을 알아보았습니다.

✅ 핵심 요약:
  • MVC 패턴으로 계층 분리 (Model, View, Controller)
  • QueryDSL을 사용한 타입 안전한 동적 쿼리
  • 파일 업로드/다운로드/조회/삭제 기능 구현
  • UUID를 사용한 안전한 파일명 생성

추가로 궁금한 점이 있으시면 댓글로 남겨주세요! 🚀

 

카카오톡 오픈채팅 링크

https://open.kakao.com/o/seCteX7h


참고 자료

 

728x90