[Spring Boot] 다중 이미지

유자·2026년 3월 1일

multi-modal-fraud-project

목록 보기
10/16

기존에는 사진 한 장을 받아서 Supabase 스토리지에 올리고, 파이썬 서버로 보내고, DB에 저장하는 로직을 사용했습니다.
이번에는 여러 장의 사진(List)을 한 번에 받아서 처리해야 하는 상황이 되었습니다. 단순히 파라미터만 List<MultipartFile>로 바꾼다고 해결되는 문제가 아닙니다.

사진 개수만큼 스토리지에 각각 업로드해야 하고, 파이썬으로는 한 번에 묶어서 보내야 하며, DB에는 이 여러 개의 URL을 어떻게든 저장해야 합니다. 이 복잡한 과정을 어떻게 스텝별로 해결했는지 코드를 해부해 보겠습니다.

Step 1. 파이썬으로 보낼 상자와 DB에 넣을 URL 리스트 준비

가장 먼저, 통신과 저장에 쓸 빈 바구니들을 싹 세팅해 줍니다. 이번 업로드 건을 하나로 묶어줄 고유 ID(UUID)도 미리 발급합니다.

public FraudStatus processDetection(List<MultipartFile> files, String scamType, String imageType) throws Exception {

	// 1. 파이썬 서버로 보낼 텍스트 옵션들을 MultiValueMap에 미리 담아두기
	MultiValueMap<String, Object> pythonBody = new LinkedMultiValueMap<>();
	pythonBody.add("scam_type", scamType);
	pythonBody.add("image_type", imageType);

	// 2. Supabase에 업로드하고 발급받을 URL들을 모아둘 빈 리스트 생성
	List<String> uploadedUrls = new ArrayList<>();
    
	// 3. 이번 다중 업로드 요청 전체를 아우르는 고유 식별자(UUID)를 만들기
    String verificationId = UUID.randomUUID().toString(); 
}

Step 2. 반복문으로 Supabase 개별 업로드 & 파이썬 상자에 담기

가장 중요한 부분입니다. List로 넘어온 파일들을 하나씩 꺼내서 스토리지 업로드파이썬 전송 준비를 동시에 처리합니다.

// ... [Step 1 코드] ...

// 여러 장의 파일을 하나씩 꺼내서 작업
for (MultipartFile file : files) {
        
	// [python] 네트워크 전송을 위해 byte stream으로 변환
	pythonBody.add("files", file.getResource());

    // [Supabase] 각 사진마다 고유한 URL 경로를 만들기 (엔드포인트 설정)
    String originalFilename = file.getOriginalFilename();
    String finalSupabaseUrl = supabaseUrl + verificationId + "/" + originalFilename;

    // Supabase 인증 헤더 세팅
    HttpHeaders supabaseHeaders = new HttpHeaders();
    supabaseHeaders.set("Authorization", "Bearer " + apiKey);
    supabaseHeaders.set("apikey", apiKey);
	supabaseHeaders.setContentType(MediaType.valueOf(Objects.requireNonNull(file.getContentType())));

	HttpEntity<byte[]> supabaseEntity = new HttpEntity<>(file.getBytes(), supabaseHeaders);

    // Supabase 스토리지로 해당 사진 전송
    org.springframework.http.ResponseEntity<String> supabaseResponse = restTemplate.exchange(
    	finalSupabaseUrl,
        HttpMethod.POST,
        supabaseEntity,
        String.class
    );

    // 업로드가 성공 시 URL 리스트에 추가
	if (supabaseResponse.getStatusCode().is2xxSuccessful()) uploadedUrls.add(finalSupabaseUrl);
    else throw new RuntimeException("Error sending file: " + originalFilename);
}
  • 포인트: 파일을 여러 장 올리려면 storage API 요청을 파일 개수만큼 반복(exchange)해야 합니다.

Step 3. 파이썬(FastAPI) 서버로 다중 데이터(Multipart) 통합 전송

반복문 처리가 완료되면 pythonBody 객체 내부에 다중 이미지 스트림과 텍스트 파라미터가 모두 적재됩니다. 이제 구성된 MultiValueMap 데이터를 FastAPI 서버로 일괄 전송합니다.

// ... [Step 2 코드] ...

// 전송할 파이썬 서버 엔드포인트 및 HTTP 통신 헤더 설정
// 바이너리 파일과 텍스트가 혼합된 형태이므로 MULTIPART_FORM_DATA 지정 필수
String pythonUrl = "http://localhost:8000/predict";
HttpHeaders pythonHeaders = new HttpHeaders();
pythonHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
    
// HttpEntity에 데이터(Body)와 헤더(Header) 결합
HttpEntity<MultiValueMap<String, Object>> pythonEntity = new HttpEntity<>(pythonBody, pythonHeaders);
    
// FastAPI 서버로 POST 요청을 전송하고, 반환된 JSON 응답을 FraudResponseDTO 객체로 매핑
FraudResponseDTO response = restTemplate.postForObject(pythonUrl, pythonEntity, FraudResponseDTO.class);
    
// 최종 판별 추출 (응답 누락 시 기본값 NORMAL)
FraudStatus status = (response != null) ? response.getStatus() : FraudStatus.NORMAL;

Step 4. 다중 이미지 URL 단일 문자열 병합 및 DB 적재

파이썬 서버의 분석 결과를 수신한 후, 전체 처리 내역을 데이터베이스에 저장합니다. 이때 기존 단일 이미지 기반으로 설계된 엔티티(1개의 URL 컬럼)에 다수의 이미지 URL을 적재하기 위한 데이터 전처리 과정이 필요합니다.

// ... [Step 3 코드] ...

// List 형태의 URL 집합을 쉼표(,) 구분자를 사용하여 단일 문자열로 병합
// ex. "url1.png,url2.png,url3.png"
String joinedUrls = String.join(",", uploadedUrls);
    
// 병합된 문자열과 분석 결과를 기존 엔티티 구조에 매핑하여 DB 저장
DetectionResult result = new DetectionResult(verificationId, joinedUrls, status);
repository.save(result);

return status; // 컨트롤러로 최종 판별 상태 반환
}

1:N 연관관계(테이블 분리) 대신 단일 문자열(String.join) 선택 사유
다중 이미지 URL을 저장할 때, 정석대로라면 DetectionImage라는 별도의 자식 테이블을 만들고 JPA @OneToMany를 통해 테이블 간 연관관계를 맺는 것이 맞습니다. 하지만 이번 작업에서는 의도적으로 정규화를 포기하고 단일 컬럼에 쉼표(,)로 병합하는 Denormalization를 채택했습니다. 그 이유는 다음과 같습니다.

  1. Over-engineering 방지
    만약 "특정 사진 URL이 포함된 사기 판별 기록을 찾아라" 같은 복잡한 검색 조건이 필요하다면 테이블 분리가 필수입니다. 하지만 이 서비스의 비즈니스 로직상, 사진 URL은 판별 결과(DetectionResult)를 화면에 보여줄 때 한꺼번에 딸려가는 단순 첨부파일 역할만 합니다. 단독으로 검색되거나 수정될 일이 없는 데이터에 테이블을 추가하고 분리하고 복잡한 참조 구조를 만드는 것은 오버엔지니어링이라고 판단했습니다.

  2. 조회 성능 최적화 (JOIN 및 N+1 문제 차단)
    테이블을 1:N으로 분리하게 되면, 나중에 사용자의 진단 기록을 조회할 때마다 본 테이블과 이미지 테이블 간에 JOIN 연산이 발생합니다. 또한 JPA 특성상 세심하게 세팅하지 않으면 지연 로딩(Lazy Loading)으로 인한 N+1 쿼리 문제가 터질 위험이 있습니다.
    반면 쉼표로 합쳐서 한 컬럼에 넣으면, 단 한 번의 깔끔한 SELECT 쿼리만으로 모든 사진 URL을 가져올 수 있어 읽기 성능이 극대화됩니다.

  3. 프론트엔드 연동의 간결성
    DB에서는 하나의 긴 문자열(url1,url2,url3)로 저장되지만, 프론트엔드(Vue.js)로 데이터를 넘겨줄 때나 화면에 그릴 때는 단순히 .split(",") 함수 하나만 호출하면 즉시 배열로 복구됩니다. 백엔드의 복잡도를 낮추면서도 프론트엔드의 사용성은 그대로 유지할 수 있는 가장 실용적인 설계입니다.

결론: 무조건적인 DB 정규화가 정답은 아닙니다. 데이터의 조회 패턴과 수정 빈도를 고려했을 때, 현재 비즈니스 요구사항에서는 1:N 테이블 분리보다 String.join을 활용한 역정규화 방식이 성능과 유지보수 측면에서 훨씬 합리적인 트레이드오프라고 결론 내렸습니다.

0개의 댓글