기존에는 사진 한 장을 받아서 Supabase 스토리지에 올리고, 파이썬 서버로 보내고, DB에 저장하는 로직을 사용했습니다.
이번에는 여러 장의 사진(List)을 한 번에 받아서 처리해야 하는 상황이 되었습니다. 단순히 파라미터만 List<MultipartFile>로 바꾼다고 해결되는 문제가 아닙니다.
사진 개수만큼 스토리지에 각각 업로드해야 하고, 파이썬으로는 한 번에 묶어서 보내야 하며, 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();
}
가장 중요한 부분입니다. 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);
}
exchange)해야 합니다.반복문 처리가 완료되면 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;
파이썬 서버의 분석 결과를 수신한 후, 전체 처리 내역을 데이터베이스에 저장합니다. 이때 기존 단일 이미지 기반으로 설계된 엔티티(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를 채택했습니다. 그 이유는 다음과 같습니다.
Over-engineering 방지
만약 "특정 사진 URL이 포함된 사기 판별 기록을 찾아라" 같은 복잡한 검색 조건이 필요하다면 테이블 분리가 필수입니다. 하지만 이 서비스의 비즈니스 로직상, 사진 URL은 판별 결과(DetectionResult)를 화면에 보여줄 때 한꺼번에 딸려가는 단순 첨부파일 역할만 합니다. 단독으로 검색되거나 수정될 일이 없는 데이터에 테이블을 추가하고 분리하고 복잡한 참조 구조를 만드는 것은 오버엔지니어링이라고 판단했습니다.
조회 성능 최적화 (JOIN 및 N+1 문제 차단)
테이블을 1:N으로 분리하게 되면, 나중에 사용자의 진단 기록을 조회할 때마다 본 테이블과 이미지 테이블 간에 JOIN 연산이 발생합니다. 또한 JPA 특성상 세심하게 세팅하지 않으면 지연 로딩(Lazy Loading)으로 인한 N+1 쿼리 문제가 터질 위험이 있습니다.
반면 쉼표로 합쳐서 한 컬럼에 넣으면, 단 한 번의 깔끔한 SELECT 쿼리만으로 모든 사진 URL을 가져올 수 있어 읽기 성능이 극대화됩니다.
프론트엔드 연동의 간결성
DB에서는 하나의 긴 문자열(url1,url2,url3)로 저장되지만, 프론트엔드(Vue.js)로 데이터를 넘겨줄 때나 화면에 그릴 때는 단순히 .split(",") 함수 하나만 호출하면 즉시 배열로 복구됩니다. 백엔드의 복잡도를 낮추면서도 프론트엔드의 사용성은 그대로 유지할 수 있는 가장 실용적인 설계입니다.
결론: 무조건적인 DB 정규화가 정답은 아닙니다. 데이터의 조회 패턴과 수정 빈도를 고려했을 때, 현재 비즈니스 요구사항에서는 1:N 테이블 분리보다 String.join을 활용한 역정규화 방식이 성능과 유지보수 측면에서 훨씬 합리적인 트레이드오프라고 결론 내렸습니다.