Qwen3-VL 시각 질의응답 시스템 개발: 스프링부트 기반 백엔드 통합 가이드

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 성능 최적화 제안

  1. 연결 풀링: 모델 서비스 연결을 관리하기 위해 연결 풀 사용
  2. 이미지 전처리: 클라이언트에서 이미지 압축 및 형식 변환 수행
  3. 결과 캐싱: 로컬 캐싱 대신 Redis와 같은 분산 캐시 사용
  4. 부하 분산: 여러 모델 서비스 인스턴스 배포 및 로드 밸런싱 사용

7. 결론

본 가이드의 실습을 통해 스프링부트 기반의 Qwen3-VL 시각 질의응답 시스템 백엔드를 성공적으로 구축했습니다. 이 시스템은 단일 이미지 질의응답뿐만 아니라 일괄 처리 기능도 제공하며, 기업급 애플리케이션에 필요한 오류 처리, 모니터링 및 성능 최적화 기능을 갖추고 있습니다.

실제 개발에서 모델 응답 지연, 이미지 처리 복잡성 등의 문제에 직면할 수 있습니다. 이 경우 이미지 전처리 로직을 추가로 최적화하거나, 대량 요청을 비동기적으로 처리하기 위해 메시지 큐를 고려할 수 있습니다. 또한 특정 비즈니스 시나리오에 따라 사용자 인증, 접근 제한, 사용 통계 기능 등을 추가해야 할 수도 있습니다.

본 프로젝트의 전체 코드에는 핵심 기능이 포함되어 있어 직접 확장 및 커스터마이징할 수 있습니다. 시각 질의응답 기술의 적용 분야는 매우 넓으며, 전자상거래 상품 인식부터 의료 영상 분석, 교육 보조, 지능형 고객 서비스까지 다양한 분야에서 큰 잠재력을 가지고 있습니다.

태그: Qwen3-VL 스프링부트 시각 QA 다중모달 AI 통합

6월 9일 18:34에 게시됨