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);
}
}
핵심 로직:
- 파일 업로드: MultipartFile을 받아 서버에 저장하고 DB에 메타데이터 저장
- 파일 검색: QueryDSL을 사용하여 동적 쿼리로 파일 검색
- 파일 다운로드: 저장된 파일을 Resource로 반환
- 파일 삭제: 파일 시스템과 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
'개발 > BACK' 카테고리의 다른 글
| Spring 환경에서 RSA / AES로 데이터 암호화 하기 - 예제 (0) | 2025.12.24 |
|---|---|
| SpringBoot QueryDSL 환경에서 Servlet 구현 예제 (0) | 2025.12.24 |
| WEB-INF 정적 리소스 경로 찾기 - 로컬환경과 배포환경의 차이점 (0) | 2025.12.24 |
| Linux vi / vim 명령어 총 정리 (0) | 2025.12.24 |
| Angular + Ionic + Capacitor에서 Firebase 완벽 연동 가이드 예제포 (0) | 2025.12.21 |