Qwen3-VL 시각 질의응답 시스템 개발: 스프링부트 기반 백엔드 통합 가이드
1. 소개
사용자가 이미지를 업로드한 후 "이 이미지에는 무슨 내용이 있나요?" 또는 "이 제품의 가격은 얼마인가요?"와 같은 질문을 하는 경우를 접해보셨나요? 전통적인 텍스트 기반 질의응답 시스템은 이러한 요구를 처리할 수 없습니다. 이제 Qwen3-VL과 같은 다중모달 대규모 모델을 통해 AI가 실제로 이미지를 "이해"하고 관련 질문에 답변할 수 있게 되었습니다.
본 가이드에서는 스프링부트를 사용하여 완전한 시각 질의응답 시스템 백엔드 서비스를 처음부터 구축하는 방법을 단계별로 안내합니다. 깊은 AI 배경 지식이 필요하지 않으며, Java와 스프링부트만 알고 있다면 이 강력한 시각 이해 기능을 쉽게 통합할 수 있습니다. 실제 개발에서 발생하는 몇 가지 주요 문제점 해결에 중점을 둘 것입니다: 다중 파일 업로드 처리, 모델 호출 효율성, 합리적인 API 인터페이스 설계, 그리고 시스템의 안정성과 성능 보장 방법입니다.
본 가이드를 마치면 기업 차원의 시각 질의응답 시스템 개발 방안을 완벽하게 습득하여 직접 프로젝트에 적용할 수 있게 됩니다.
2. 환경 준비 및 프로젝트 설정
2.1 기본 환경 요구사항
시작하기 전에 개발 환경이 다음 요구사항을 충족하는지 확인하세요:
- JDK 11 이상 버전
- Maven 3.6+
- SpringBoot 2.7+
- 최소 8GB RAM (모델 추론용)
- CUDA 지원 GPU (선택 사항이지만 프로덕션 환경 권장)
2.2 스프링부트 프로젝트 생성
Spring Initializr를 사용하여 프로젝트 기본 구조를 빠르게 생성합니다:
curl https://start.spring.io/starter.zip \
-d dependencies=web,actuator \
-d type=maven-project \
-d language=java \
-d bootVersion=2.7.0 \
-d baseDir=qwen3-vl-demo \
-d groupId=com.example \
-d artifactId=qwen3-vl-demo \
-d name=qwen3-vl-demo \
-d description="Qwen3-VL SpringBoot Integration" \
-d packageName=com.example.qwen3vl \
-d packaging=jar \
-d javaVersion=11 \
-o qwen3-vl-demo.zip
압축을 풀어 좋아하는 IDE에 가져오면 깨끗한 스프링부트 프로젝트 기준이 마련됩니다.
2.3 필수 의존성 추가
pom.xml에 파일 업로드 및 JSON 처리를 위한 의존성을 추가합니다:
<dependencies>
<!-- SpringBoot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 파일 업로드 지원 -->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.4</version>
</dependency>
<!-- JSON 처리 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- 비동기 지원 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-async</artifactId>
</dependency>
</dependencies>
3. 핵심 기능 구현
3.1 파일 업로드 설정
먼저 스프링부트가 대용량 파일 업로드를 지원하도록 설정합니다. application.properties에 다음을 추가합니다:
# 파일 업로드 설정
spring.servlet.multipart.max-file-size=50MB
spring.servlet.multipart.max-request-size=50MB
# 비동기 설정
spring.task.execution.pool.core-size=5
spring.task.execution.pool.max-size=10
spring.task.execution.pool.queue-capacity=100
# 모델 서비스 설정
qwen3vl.model.url=http://localhost:8000/v1/chat/completions
qwen3vl.model.timeout=30000
파일 업로드 설정 클래스를 생성합니다:
@Configuration
public class FileUploadConfig {
@Bean
public MultipartResolver multipartResolver() {
CommonsMultipartResolver resolver = new CommonsMultipartResolver();
resolver.setMaxUploadSize(52428800); // 50MB
resolver.setDefaultEncoding("UTF-8");
return resolver;
}
}
3.2 다중 파일 업로드 인터페이스 구현
이미지 업로드와 질의응답 요청을 처리하는 REST 컨트롤러를 생성합니다:
@RestController
@RequestMapping("/api/v1/vision")
public class VisionQAController {
@PostMapping(value = "/ask", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse> askQuestion(
@RequestParam("image") MultipartFile image,
@RequestParam("question") String question,
@RequestParam(value = "model", defaultValue = "qwen3-vl") String model) {
try {
// 파일 유형 검증
if (!isValidImageType(image)) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("JPEG, PNG 형식의 이미지만 지원합니다"));
}
// 이미지 처리 및 모델 호출
String answer = processImageAndAsk(image, question, model);
return ResponseEntity.ok(ApiResponse.success(answer));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("처리 실패: " + e.getMessage()));
}
}
private boolean isValidImageType(MultipartFile file) {
String contentType = file.getContentType();
return contentType != null &&
(contentType.equals("image/jpeg") ||
contentType.equals("image/png"));
}
}
3.3 모델 서비스 호출
Qwen3-VL 모델과의 통신을 처리하는 모델 서비스 클래스를 생성합니다:
@Service
public class Qwen3VLService {
@Value("${qwen3vl.model.url}")
private String modelUrl;
@Value("${qwen3vl.model.timeout}")
private int timeout;
private final RestTemplate restTemplate;
public Qwen3VLService(RestTemplateBuilder restTemplateBuilder) {
this.restTemplate = restTemplateBuilder
.setConnectTimeout(Duration.ofMillis(timeout))
.setReadTimeout(Duration.ofMillis(timeout))
.build();
}
public String askQuestion(String base64Image, String question) {
// 요청 본문 구성
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", "qwen3-vl");
List<Map<String, Object>> messages = new ArrayList<>();
Map<String, Object> message = new HashMap<>();
message.put("role", "user");
List<Object> content = new ArrayList<>();
content.add(Map.of("type", "text", "text", question));
content.add(Map.of("type", "image_url",
"image_url", Map.of("url", "data:image/jpeg;base64," + base64Image)));
message.put("content", content);
messages.add(message);
requestBody.put("messages", messages);
// 요청 전송
try {
ResponseEntity<Map> response = restTemplate.postForEntity(
modelUrl, requestBody, Map.class);
if (response.getStatusCode().is2xxSuccessful() &&
response.getBody() != null) {
return extractAnswer(response.getBody());
}
} catch (Exception e) {
throw new RuntimeException("모델 호출 실패: " + e.getMessage(), e);
}
return "죄송합니다, 답변을 얻을 수 없습니다";
}
private String extractAnswer(Map<String, Object> response) {
// 모델 응답에서 답변 추출
try {
List<Map<String, Object>> choices = (List<Map<String, Object>>)
response.get("choices");
if (choices != null && !choices.isEmpty()) {
Map<String, Object> message = (Map<String, Object>)
choices.get(0).get("message");
return (String) message.get("content");
}
} catch (Exception e) {
// 파싱 실패 시 처리
}
return "모델 응답을 파싱할 수 없습니다";
}
}
4. 고급 기능 구현
4.1 비동기 처리 및 결과 캐싱
시스템 성능을 향상시키기 위해 비동기 처리와 결과 캐싱을 구현합니다:
@Service
@EnableAsync
public class AsyncVisionService {
@Autowired
private Qwen3VLService qwen3VLService;
private final Cache<String, String> answerCache =
CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
@Async
public CompletableFuture<String> processAsync(
MultipartFile image,
String question) throws IOException {
// 캐시 키 생성
String cacheKey = generateCacheKey(image, question);
// 캐시 확인
String cachedAnswer = answerCache.getIfPresent(cacheKey);
if (cachedAnswer != null) {
return CompletableFuture.completedFuture(cachedAnswer);
}
// 이미지 처리 및 모델 호출
String base64Image = Base64.getEncoder().encodeToString(image.getBytes());
String answer = qwen3VLService.askQuestion(base64Image, question);
// 결과 캐싱
answerCache.put(cacheKey, answer);
return CompletableFuture.completedFuture(answer);
}
private String generateCacheKey(MultipartFile image, String question)
throws IOException {
// 이미지 해시와 질문 텍스트로 캐시 키 생성
String imageHash = DigestUtils.md5DigestAsHex(image.getBytes());
String questionHash = DigestUtils.md5DigestAsHex(question.getBytes());
return imageHash + "_" + questionHash;
}
}
4.2 일괄 처리 지원
다중 이미지를 한 번에 처리할 수 있는 일괄 처리 인터페이스를 추가합니다:
@PostMapping("/batch-ask")
public ResponseEntity<ApiResponse> batchAskQuestions(
@RequestParam("images") MultipartFile[] images,
@RequestParam("questions") String[] questions) {
if (images.length != questions.length) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("이미지와 질문 수가 일치하지 않습니다"));
}
List<CompletableFuture<String>> futures = new ArrayList<>();
List<ApiResponse.BatchResult> results = new ArrayList<>();
for (int i = 0; i < images.length; i++) {
final int index = i;
try {
CompletableFuture<String> future = asyncVisionService
.processAsync(images[index], questions[index])
.exceptionally(ex -> "처리 실패: " + ex.getMessage());
futures.add(future);
} catch (Exception e) {
results.add(new ApiResponse.BatchResult(
images[index].getOriginalFilename(),
"처리 실패: " + e.getMessage()
));
}
}
// 모든 작업 완료까지 대기
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
for (int i = 0; i < futures.size(); i++) {
String answer = futures.get(i).get();
results.add(new ApiResponse.BatchResult(
images[i].getOriginalFilename(),
answer
));
}
return ResponseEntity.ok(ApiResponse.success(results));
}
5. 오류 처리 및 모니터링
5.1 전역 예외 처리
통합 예외 처리 메커니즘을 생성합니다:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse> handleMaxSizeException() {
return ResponseEntity.badRequest()
.body(ApiResponse.error("파일 크기가 제한을 초과했습니다"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse> handleGeneralException(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("시스템 오류: " + ex.getMessage()));
}
}
5.2 상태 확인 엔드포인트 추가
서비스 상태를 모니터링하기 위해 Actuator 엔드포인트를 구성합니다:
@Component
public class ModelHealthIndicator implements HealthIndicator {
@Autowired
private Qwen3VLService qwen3VLService;
@Override
public Health health() {
try {
// 간단한 상태 확인: 모델 서비스 호출 시도
String response = qwen3VLService.askQuestion(
"test", "안녕하세요"); // 테스트 데이터 사용
if (response != null && !response.contains("실패")) {
return Health.up().withDetail("model", "available").build();
} else {
return Health.down().withDetail("model", "unavailable").build();
}
} catch (Exception e) {
return Health.down()
.withDetail("model", "error: " + e.getMessage())
.build();
}
}
}
6. 배포 및 최적화 제안
6.1 프로덕션 환경 설정
프로덕션 환경에는 다음 설정을 권장합니다:
# application-prod.yml
spring:
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
server:
tomcat:
max-swallow-size: 100MB
max-http-form-post-size: 100MB
qwen3vl:
model:
url: ${MODEL_SERVICE_URL:http://model-service:8000/v1/chat/completions}
timeout: 60000
retry:
max-attempts: 3
backoff: 1000
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
6.2 성능 최적화 제안
- 연결 풀링: 모델 서비스 연결을 관리하기 위해 연결 풀 사용
- 이미지 전처리: 클라이언트에서 이미지 압축 및 형식 변환 수행
- 결과 캐싱: 로컬 캐싱 대신 Redis와 같은 분산 캐시 사용
- 부하 분산: 여러 모델 서비스 인스턴스 배포 및 로드 밸런싱 사용
7. 결론
본 가이드의 실습을 통해 스프링부트 기반의 Qwen3-VL 시각 질의응답 시스템 백엔드를 성공적으로 구축했습니다. 이 시스템은 단일 이미지 질의응답뿐만 아니라 일괄 처리 기능도 제공하며, 기업급 애플리케이션에 필요한 오류 처리, 모니터링 및 성능 최적화 기능을 갖추고 있습니다.
실제 개발에서 모델 응답 지연, 이미지 처리 복잡성 등의 문제에 직면할 수 있습니다. 이 경우 이미지 전처리 로직을 추가로 최적화하거나, 대량 요청을 비동기적으로 처리하기 위해 메시지 큐를 고려할 수 있습니다. 또한 특정 비즈니스 시나리오에 따라 사용자 인증, 접근 제한, 사용 통계 기능 등을 추가해야 할 수도 있습니다.
본 프로젝트의 전체 코드에는 핵심 기능이 포함되어 있어 직접 확장 및 커스터마이징할 수 있습니다. 시각 질의응답 기술의 적용 분야는 매우 넓으며, 전자상거래 상품 인식부터 의료 영상 분석, 교육 보조, 지능형 고객 서비스까지 다양한 분야에서 큰 잠재력을 가지고 있습니다.