Spring Boot와 QueryDSL을 활용하여 데이터베이스에 저장된 영상을 조회하고 스트리밍하는 방법을 단계별로 설명합니다. Entity 설계부터 Repository, Service, Controller까지 전체 구현 과정을 하나부터 열까지 구체적으로 다룹니다.
📋 목차
1. 🚀 프로젝트 개요 및 환경 설정
💡 프로젝트 개요
이 프로젝트는 Spring Boot와 QueryDSL을 활용하여 데이터베이스에 저장된 영상 파일을 조회하고, HTTP 스트리밍 방식으로 클라이언트에 전송하는 기능을 구현합니다. 영상 파일은 DB에 BLOB 타입으로 저장되거나, 파일 경로를 저장하고 실제 파일은 서버에 저장하는 방식을 사용할 수 있습니다.
📦 필요한 의존성 (build.gradle)
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.0'
id 'io.spring.dependency-management' version '1.1.0'
id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10'
}
dependencies {
// Spring Boot
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Database
runtimeOnly 'com.h2database:h2' // 또는 MySQL, PostgreSQL 등
// runtimeOnly 'com.mysql:mysql-connector-j'
// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
// Lombok (선택사항)
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
// QueryDSL 설정
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDirs += [ querydslDir ]
}
tasks.withType(JavaCompile) {
options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}
clean {
delete file(querydslDir)
}
⚙️ application.yml 설정
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
# 파일 업로드 크기 제한 (영상 파일용)
spring.servlet.multipart:
max-file-size: 500MB
max-request-size: 500MB
2. 🗄️ 데이터베이스 스키마 설계
💡 영상 테이블 설계
영상 정보를 저장할 테이블을 설계합니다. 영상 파일 자체는 BLOB 타입으로 저장하거나, 파일 경로만 저장하는 방식 중 선택할 수 있습니다. 이 예제에서는 BLOB 타입으로 저장하는 방식을 사용합니다.
📊 테이블 구조
CREATE TABLE video (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
description TEXT,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100) NOT NULL,
file_size BIGINT NOT NULL,
video_data BLOB NOT NULL, -- 영상 파일 데이터
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE
);
💡 참고: 대용량 영상 파일의 경우 BLOB보다는 파일 시스템에 저장하고 경로만 DB에 저장하는 방식을 권장합니다. 이 예제는 학습 목적으로 BLOB 방식을 사용합니다.
3. 📝 Entity 클래스 작성
💡 Video Entity 클래스
JPA Entity 클래스를 작성하여 데이터베이스 테이블과 매핑합니다. @Lob 어노테이션을 사용하여 영상 파일 데이터를 저장합니다.
📄 Video.java
package com.example.video.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "video")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Video {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "file_name", nullable = false, length = 255)
private String fileName;
@Column(name = "content_type", nullable = false, length = 100)
private String contentType; // video/mp4, video/webm 등
@Column(name = "file_size", nullable = false)
private Long fileSize; // 파일 크기 (bytes)
@Lob
@Column(name = "video_data", nullable = false, columnDefinition = "BLOB")
private byte[] videoData; // 영상 파일 바이너리 데이터
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "is_active")
private Boolean isActive;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
if (this.isActive == null) {
this.isActive = true;
}
}
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
}
4. 🔍 QueryDSL 설정 및 Repository
💡 QueryDSL Repository 인터페이스
QueryDSL을 사용하여 동적 쿼리를 작성하고, 조건에 따라 영상을 조회할 수 있도록 Repository를 구현합니다.
📄 VideoRepository.java
package com.example.video.repository;
import com.example.video.entity.Video;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import static com.example.video.entity.QVideo.video;
@Repository
@RequiredArgsConstructor
public class VideoRepository {
private final EntityManager entityManager;
private final JPAQueryFactory queryFactory;
// QueryDSL을 사용한 동적 쿼리로 영상 조회
public List findAllByCondition(String title, Boolean isActive) {
BooleanBuilder builder = new BooleanBuilder();
if (title != null && !title.isEmpty()) {
builder.and(video.title.containsIgnoreCase(title));
}
if (isActive != null) {
builder.and(video.isActive.eq(isActive));
}
return queryFactory
.selectFrom(video)
.where(builder)
.orderBy(video.createdAt.desc())
.fetch();
}
// ID로 영상 조회 (비디오 데이터 포함)
public Optional findByIdWithData(Long id) {
Video result = queryFactory
.selectFrom(video)
.where(video.id.eq(id)
.and(video.isActive.eq(true)))
.fetchOne();
return Optional.ofNullable(result);
}
// 활성화된 영상 목록 조회 (비디오 데이터 제외)
public List findActiveVideosWithoutData() {
return queryFactory
.select(video.id, video.title, video.description,
video.fileName, video.contentType, video.fileSize,
video.createdAt, video.updatedAt, video.isActive)
.from(video)
.where(video.isActive.eq(true))
.orderBy(video.createdAt.desc())
.fetch();
}
// 영상 저장
public Video save(Video videoEntity) {
entityManager.persist(videoEntity);
return videoEntity;
}
}
⚙️ QueryDSL 설정 클래스
package com.example.video.config;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
💡 중요: 프로젝트를 빌드하면 `build/generated/querydsl` 디렉토리에 QVideo 클래스가 자동 생성됩니다. 이 클래스를 사용하여 타입 안전한 쿼리를 작성할 수 있습니다.
5. 🛠️ Service 레이어 구현
💡 VideoService 클래스
비즈니스 로직을 처리하는 Service 클래스를 작성합니다. 영상 조회, 업로드, 스트리밍 데이터 제공 등의 기능을 구현합니다.
📄 VideoService.java
package com.example.video.service;
import com.example.video.entity.Video;
import com.example.video.repository.VideoRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class VideoService {
private final VideoRepository videoRepository;
// 영상 목록 조회 (비디오 데이터 제외)
public List getVideoList(String title, Boolean isActive) {
return videoRepository.findAllByCondition(title, isActive);
}
// 영상 상세 조회 (비디오 데이터 포함)
public Optional getVideoById(Long id) {
return videoRepository.findByIdWithData(id);
}
// 영상 업로드
@Transactional
public Video uploadVideo(String title, String description, MultipartFile file) throws IOException {
Video video = Video.builder()
.title(title)
.description(description)
.fileName(file.getOriginalFilename())
.contentType(file.getContentType())
.fileSize(file.getSize())
.videoData(file.getBytes())
.isActive(true)
.build();
return videoRepository.save(video);
}
// 영상 데이터만 조회 (스트리밍용)
public Optional<byte[]> getVideoData(Long id) {
return videoRepository.findByIdWithData(id)
.map(Video::getVideoData);
}
// 영상 메타데이터 조회 (비디오 데이터 제외)
public Optional getVideoMetadata(Long id) {
return videoRepository.findByIdWithData(id)
.map(video -> {
// 비디오 데이터를 null로 설정하여 메타데이터만 반환
Video metadata = new Video();
metadata.setId(video.getId());
metadata.setTitle(video.getTitle());
metadata.setDescription(video.getDescription());
metadata.setFileName(video.getFileName());
metadata.setContentType(video.getContentType());
metadata.setFileSize(video.getFileSize());
metadata.setCreatedAt(video.getCreatedAt());
metadata.setUpdatedAt(video.getUpdatedAt());
metadata.setIsActive(video.getIsActive());
return metadata;
});
}
}
6. 🎬 Controller - 영상 스트리밍 구현
💡 VideoController 클래스
HTTP 요청을 처리하고 영상을 스트리밍하는 Controller를 구현합니다. Range 요청을 지원하여 부분 다운로드와 스트리밍을 가능하게 합니다.
📄 VideoController.java
package com.example.video.controller;
import com.example.video.entity.Video;
import com.example.video.service.VideoService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@Slf4j
@RestController
@RequestMapping("/api/videos")
@RequiredArgsConstructor
public class VideoController {
private final VideoService videoService;
// 영상 목록 조회
@GetMapping
public ResponseEntity<List> getVideoList(
@RequestParam(required = false) String title,
@RequestParam(required = false) Boolean isActive) {
List videos = videoService.getVideoList(title, isActive);
return ResponseEntity.ok(videos);
}
// 영상 메타데이터 조회 (비디오 데이터 제외)
@GetMapping("/{id}/metadata")
public ResponseEntity getVideoMetadata(@PathVariable Long id) {
Optional video = videoService.getVideoMetadata(id);
return video.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// 영상 스트리밍 (Range 요청 지원)
@GetMapping("/{id}/stream")
public ResponseEntity<byte[]> streamVideo(
@PathVariable Long id,
@RequestHeader(value = "Range", required = false) String rangeHeader) {
Optional videoOpt = videoService.getVideoById(id);
if (videoOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
Video video = videoOpt.get();
byte[] videoData = video.getVideoData();
long fileSize = video.getFileSize();
// Range 요청 처리
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
return buildRangeResponse(videoData, rangeHeader, fileSize, video.getContentType());
}
// 전체 영상 반환
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(video.getContentType()));
headers.setContentLength(fileSize);
headers.setAcceptRanges("bytes");
return ResponseEntity.ok()
.headers(headers)
.body(videoData);
}
// Range 요청 처리 메서드
private ResponseEntity<byte[]> buildRangeResponse(
byte[] videoData, String rangeHeader, long fileSize, String contentType) {
String[] ranges = rangeHeader.replace("bytes=", "").split("-");
long rangeStart = Long.parseLong(ranges[0]);
long rangeEnd = ranges.length > 1 && !ranges[1].isEmpty()
? Long.parseLong(ranges[1])
: fileSize - 1;
// 범위 검증
if (rangeStart > rangeEnd || rangeStart < 0 || rangeEnd >= fileSize) {
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build();
}
long contentLength = rangeEnd - rangeStart + 1;
byte[] partialData = new byte[(int) contentLength];
System.arraycopy(videoData, (int) rangeStart, partialData, 0, (int) contentLength);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(contentType));
headers.setContentLength(contentLength);
headers.setAcceptRanges("bytes");
headers.set("Content-Range",
String.format("bytes %d-%d/%d", rangeStart, rangeEnd, fileSize));
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.headers(headers)
.body(partialData);
}
// 영상 업로드
@PostMapping
public ResponseEntity uploadVideo(
@RequestParam("title") String title,
@RequestParam(value = "description", required = false) String description,
@RequestParam("file") MultipartFile file) {
try {
Video video = videoService.uploadVideo(title, description, file);
return ResponseEntity.status(HttpStatus.CREATED).body(video);
} catch (IOException e) {
log.error("영상 업로드 실패", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
// 영상 삭제 (소프트 삭제)
@DeleteMapping("/{id}")
public ResponseEntity deleteVideo(@PathVariable Long id) {
// 삭제 로직 구현 (예: isActive를 false로 변경)
return ResponseEntity.noContent().build();
}
}
📄 HTML 테스트 페이지 (선택사항)
<!DOCTYPE html>
<html>
<head>
<title>Video Streaming Test</title>
</head>
<body>
<h1>영상 스트리밍 테스트</h1>
<!-- 영상 목록 -->
<div id="videoList"></div>
<!-- 영상 플레이어 -->
<video id="videoPlayer" controls width="800">
Your browser does not support the video tag.
</video>
<script>
// 영상 목록 조회
fetch('/api/videos')
.then(response => response.json())
.then(videos => {
const listDiv = document.getElementById('videoList');
videos.forEach(video => {
const button = document.createElement('button');
button.textContent = video.title;
button.onclick = () => playVideo(video.id);
listDiv.appendChild(button);
});
});
// 영상 재생
function playVideo(videoId) {
const videoPlayer = document.getElementById('videoPlayer');
videoPlayer.src = `/api/videos/${videoId}/stream`;
videoPlayer.load();
}
</script>
</body>
</html>
7. 🧪 테스트 및 실행
💡 테스트 방법
프로젝트를 실행하고 다음 API를 테스트할 수 있습니다.
📋 API 엔드포인트
1. 영상 목록 조회: GET /api/videos
2. 영상 메타데이터 조회: GET /api/videos/{id}/metadata
3. 영상 스트리밍: GET /api/videos/{id}/stream
4. 영상 업로드: POST /api/videos
5. 영상 삭제: DELETE /api/videos/{id}
🧪 cURL 테스트 예제
# 1. 영상 목록 조회
curl http://localhost:8080/api/videos
# 2. 영상 메타데이터 조회
curl http://localhost:8080/api/videos/1/metadata
# 3. 영상 스트리밍 (전체)
curl http://localhost:8080/api/videos/1/stream --output video.mp4
# 4. 영상 스트리밍 (Range 요청)
curl -H "Range: bytes=0-1023" http://localhost:8080/api/videos/1/stream --output video_part.mp4
# 5. 영상 업로드
curl -X POST http://localhost:8080/api/videos \
-F "title=테스트 영상" \
-F "description=테스트 설명" \
-F "file=@/path/to/video.mp4"
💡 중요 포인트:
- Range 요청을 지원하여 부분 다운로드와 스트리밍이 가능합니다
- QueryDSL을 사용하여 동적 쿼리로 영상을 조회할 수 있습니다
- 대용량 파일의 경우 파일 시스템 저장 방식을 고려하세요
- 프로덕션 환경에서는 캐싱, CDN, 압축 등을 고려하세요
✅ 마무리
이제 Spring Boot와 QueryDSL을 활용하여 데이터베이스에서 영상을 조회하고 스트리밍하는 기능을 구현할 수 있습니다. Entity 설계부터 Repository, Service, Controller까지 전체 과정을 단계별로 구현했습니다.
주요 특징:
- QueryDSL 동적 쿼리: 조건에 따라 영상을 유연하게 조회
- Range 요청 지원: 부분 다운로드와 스트리밍 지원
- 메타데이터 분리: 목록 조회 시 비디오 데이터 제외로 성능 최적화
- 타입 안전성: QueryDSL로 컴파일 타임에 쿼리 오류 검출
💡 참고: 대용량 영상 파일의 경우 BLOB 저장보다는 파일 시스템에 저장하고 경로만 DB에 저장하는 방식을 권장합니다. 또한 프로덕션 환경에서는 CDN, 캐싱, 압축 등의 최적화를 고려하세요.

카카오톡 오픈채팅 링크
https://open.kakao.com/o/seCteX7h
'개발 > BACK' 카테고리의 다른 글
| 통제망 환경에서 외부에서 내부망 API 호출하는 서버 아키텍처 설계 (0) | 2025.12.27 |
|---|---|
| SpringBoot + QueryDSL 환경에서 동적 쿼리와 페이지네이션 구현하기 (Java 17) (0) | 2025.12.26 |
| Spring 환경에서 RabbitMQ 설정하기 예제 (0) | 2025.12.25 |
| Springboot 스케줄러 개발 예제 @Scheduled (0) | 2025.12.24 |
| Spring 환경에서 DB 접근 최소화 방법 정리 (0) | 2025.12.24 |