[Spring AI] Spring Boot와 FastAPI로 AI 객체탐지 서비스 구현하기
by 우와한개발자
1. 전체 구조

| 구성 요소 | 역할 |
| 클라이언트 | 이미지 업로드, 탐지 결과 확인 |
| Spring Boot | 클라이언트 요청 처리, FastAPI 호출, 결과 응답 |
| FastAPI | 이미지 수신, YOLO 추론, 결과 이미지 생성 |
| YOLO | 이미지 속 객체 탐지 |
| WebClient | Spring Boot에서 FastAPI 서버 호출 |
| 서버 | 담당 역할 |
| Spring Boot | 서비스 서버 |
| FastAPI | AI 추론 서버 |
2. 서비스의 동작 흐름
- 클라이언트가 이미지를 업로드하면 Spring Boot 서버가 먼저 요청을 받는다.
- Spring Boot는 직접 YOLO 모델을 실행하지 않고, 이미지를 FastAPI 서버로 전달한다.
- FastAPI는 전달받은 이미지를 YOLO 모델에 넣어 객체탐지를 수행한다.
- 탐지된 객체에는 바운딩 박스와 라벨이 그려진다.
- 최종 결과 이미지는 Base64 문자열 또는 이미지 URL 방식으로 클라이언트에 반환된다.
이미지 업로드
→ Spring Boot 요청 수신
→ FastAPI로 이미지 전달
→ YOLO 객체탐지
→ 결과 이미지 생성
→ Base64 또는 URL로 응답
3. 응답 방식
| 응답 방식 | 설명 |
| Base64 방식 | 결과 이미지를 문자열로 인코딩해서 JSON 응답에 포함 |
| URL 방식 | 결과 이미지를 서버에 저장하고 이미지 접근 URL만 응답 |
1) Base64 방식
- 이미지를 문자열로 변환해서 응답한다.
- 별도의 파일 저장 없이 응답만으로 이미지를 화면에 표시할 수 있다.
- 구현이 단순해서 테스트하기 편하다.
- 이미지 크기가 커지면 JSON 응답 크기도 커진다.
2) URL 방식
- 결과 이미지를 서버의 static 폴더에 저장한다.
- 클라이언트에는 이미지 URL만 반환한다.
- 응답 데이터 크기를 줄일 수 있다.
- 실제 서비스에서는 Base64 방식보다 URL 방식이 더 적합할 수 있다.
| 구분 | Base64 방식 | URL 방식 |
| 응답 형태 | 이미지 문자열 | 이미지 접근 URL |
| 응답 크기 | 큼 | 작음 |
| 파일 저장 | 필요 없음 | 필요함 |
| 화면 표시 | 응답 데이터로 바로 표시 | URL로 이미지 조회 |
| 적합한 상황 | 테스트, 작은 이미지 | 실제 서비스, 큰 이미지 |
4. FastAPI 서버 구현
1) FastAPI 서버의 역할 ─ AI 추론
- 이미지 파일 수신
- 이미지 형식 변환
- YOLO 모델 추론
- 탐지 결과 좌표 추출
- 바운딩 박스 그리기
- 결과 이미지 반환
2) 필요한 라이브러리 설치
pip install fastapi uvicorn ultralytics opencv-python pydantic pillow python-multipart
| 라이브러리 | 역할 |
| fastapi | API 서버 구현 |
| uvicorn | FastAPI 실행 서버 |
| ultralytics | YOLO 모델 사용 |
| opencv-python | 바운딩 박스 그리기 |
| pillow | 이미지 파일 처리 |
| pydantic | 요청/응답 데이터 모델 정의 |
| python-multipart | 파일 업로드 처리 |
3) FastAPI 프로젝트 구조
fastapi-server/
├── main.py
├── requirements.txt
└── static/
| 파일/폴더 | 설명 |
| main.py | FastAPI 서버 코드와 YOLO 추론 로직이 들어간다 |
| requirements.txt | 필요한 Python 라이브러리 목록을 관리한다 |
| static/ | URL 방식으로 응답할 때 결과 이미지가 저장된다 |
4) FastAPI 응답 모델 정의
class DetectionResult(BaseModel):
message: str
image: str
url: str
- message : 클라이언트가 보낸 메시지
- image : Base64 응답 방식일 때 사용
- url : URL 응답 방식일 때 사용
- Base64 방식이면 image에 값이 들어가고, URL 방식이면 url에 값이 들어간다.
5) YOLO 모델 로드
model = YOLO("yolov8n.pt")
- yolov8n.pt 모델을 로드한다.
- 모델 파일이 없으면 Ultralytics에서 자동으로 다운로드된다.
- n 모델은 nano 모델을 의미한다.
- 가볍고 빠르기 때문에 테스트용으로 사용하기 좋다.
6) static 폴더 설정
os.makedirs("static", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
- static 폴더가 없으면 자동으로 생성한다.
- FastAPI에서 /static 경로로 정적 파일을 제공하도록 설정한다.
- URL 응답 방식에서는 결과 이미지를 이 폴더에 저장한다.
- 예를 들어 static/result.jpg 파일은 다음과 같은 URL로 접근할 수 있다.
http://localhost:8000/static/result.jpg
7) YOLO 객체탐지 함수
async def detect_objects(image: Image.Image):
img = np.array(image)
results = await run_in_threadpool(lambda: model(img, verbose=False))
class_names = model.names
result = results[0]
boxes = result.boxes.xyxy
confidences = result.boxes.conf
class_ids = result.boxes.cls
for box, confidence, class_id in zip(boxes, confidences, class_ids):
x1, y1, x2, y2 = map(int, box)
label = class_names[int(class_id)]
cv2.rectangle(img, (x1, y1), (x2, y2), (255, 0, 0), 2)
cv2.putText(
img,
f"{label} {confidence:.2f}",
(x1, y1 - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.9,
(255, 0, 0),
2
)
return Image.fromarray(img)
-
- PIL Image를 NumPy 배열로 변환한다.
- YOLO 모델로 객체를 탐지한다.
- 탐지 결과에서 좌표, 신뢰도, 클래스 정보를 꺼낸다.
- OpenCV로 바운딩 박스와 라벨을 그린다.
- 다시 PIL Image로 변환해서 반환한다.
| 값 | 의미 |
| boxes.xyxy | 바운딩 박스 좌표 |
| boxes.conf | 탐지 신뢰도 |
| boxes.cls | 클래스 ID |
| model.names | 클래스 ID에 해당하는 이름 |
8) run_in_threadpool을 사용하는 이유
results = await run_in_threadpool(lambda: model(img, verbose=False))
- FastAPI는 비동기 처리를 지원한다.
- 하지만 YOLO 모델 추론은 오래 걸릴 수 있는 동기 작업이다.
- async 함수 안에서 무거운 동기 작업을 그대로 실행하면 이벤트 루프가 막힐 수 있다.
- 이벤트 루프가 막히면 다른 요청 처리도 지연될 수 있다.
- 그래서 run_in_threadpool을 사용해 YOLO 추론을 별도 스레드에서 실행한다.
9) /detect API 구현
@app.post("/detect", response_model=DetectionResult)
async def detect_service(
message: str = Form(...),
file: UploadFile = File(...),
responseType: str = Form(...)
):
image = Image.open(io.BytesIO(await file.read()))
if image.mode != "RGB":
image = image.convert("RGB")
result_image = await detect_objects(image)
buffered = io.BytesIO()
if responseType == "base64":
result_image.save(buffered, format="JPEG", quality=90)
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
return DetectionResult(message=message, image=img_str, url="")
if responseType == "url":
file_name = f"{uuid.uuid4()}.jpg"
result_image.save(f"static/{file_name}", format="JPEG", quality=70)
file_path = f"http://localhost:8000/static/{file_name}"
return DetectionResult(message=message, image="", url=file_path)
return DetectionResult(message="지원하지 않는 응답 방식입니다.", image="", url="")
| 파라미터 | 설명 |
| message | 클라이언트가 함께 보내는 메시지 |
| file | 업로드 이미지 파일 |
| responseType | 응답 방식 선택값 |
10) FastAPI 전체 코드
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.concurrency import run_in_threadpool
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import io
import cv2
import uuid
import os
import base64
from PIL import Image
import numpy as np
from ultralytics import YOLO
app = FastAPI()
model = YOLO("yolov8n.pt")
os.makedirs("static", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
class DetectionResult(BaseModel):
message: str
image: str
url: str
async def detect_objects(image: Image.Image):
img = np.array(image)
results = await run_in_threadpool(lambda: model(img, verbose=False))
class_names = model.names
result = results[0]
boxes = result.boxes.xyxy
confidences = result.boxes.conf
class_ids = result.boxes.cls
for box, confidence, class_id in zip(boxes, confidences, class_ids):
x1, y1, x2, y2 = map(int, box)
label = class_names[int(class_id)]
cv2.rectangle(img, (x1, y1), (x2, y2), (255, 0, 0), 2)
cv2.putText(
img,
f"{label} {confidence:.2f}",
(x1, y1 - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.9,
(255, 0, 0),
2
)
return Image.fromarray(img)
@app.get("/")
async def index():
return {"message": "Hello FastAPI"}
@app.post("/detect", response_model=DetectionResult)
async def detect_service(
message: str = Form(...),
file: UploadFile = File(...),
responseType: str = Form(...)
):
image = Image.open(io.BytesIO(await file.read()))
if image.mode != "RGB":
image = image.convert("RGB")
result_image = await detect_objects(image)
buffered = io.BytesIO()
if responseType == "base64":
result_image.save(buffered, format="JPEG", quality=90)
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
return DetectionResult(message=message, image=img_str, url="")
if responseType == "url":
file_name = f"{uuid.uuid4()}.jpg"
result_image.save(f"static/{file_name}", format="JPEG", quality=70)
file_path = f"http://localhost:8000/static/{file_name}"
return DetectionResult(message=message, image="", url=file_path)
return DetectionResult(message="지원하지 않는 응답 방식입니다.", image="", url="")
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", port=8000, reload=True)
11) FastAPI 서버 실행
python main.py
- 서버 실행 후 다음 주소에서 Swagger UI를 확인할 수 있다.
http://localhost:8000/docs
- Swagger UI에서 /detect API를 직접 테스트할 수 있다.
- message, file, responseType 값을 입력해 요청을 보낼 수 있다.
5. Spring Boot 서버 구현
1) Spring Boot 서버의 역할
- Spring Boot 서버는 클라이언트와 FastAPI 서버 사이의 중간 서버 역할을 한다.
- 직접 AI 모델을 실행하지 않는다.
- 클라이언트 요청을 받아 FastAPI 서버로 전달한다.
- FastAPI 서버의 응답을 다시 클라이언트에게 반환한다.
2) Spring Boot 프로젝트 생성
| 항목 | 값 |
| Build | Gradle |
| Language | Java |
| Java Version | 25 |
| Packaging | Jar |
| Dependencies | Spring Web, Spring Reactive Web |
3) Spring Boot 프로젝트 구조
src/main/java/com/example/detection/
├── controller/
│ └── DetectionController.java
├── service/
│ └── DetectionService.java
└── dto/
└── DetectionResult.java
| 계층 | 역할 |
| Controller | 클라이언트 요청 수신 |
| Service | FastAPI 서버 호출 |
| DTO | FastAPI 응답 데이터 매핑 |
4) DTO 정의
@Getter
@Setter
public class DetectionResult {
private String message;
private String image;
private String url;
}
- FastAPI 응답 JSON과 필드명을 동일하게 맞춘다.
- 필드명이 같아야 JSON 응답을 Java 객체로 매핑할 수 있다.
5) WebClient로 FastAPI 서버 호출
@Service
public class DetectionService {
private final WebClient webClient = WebClient.builder()
.baseUrl("http://localhost:8000")
.build();
public DetectionResult detect(MultipartFile file,
String message,
String responseType) throws IOException {
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("file", file.getResource());
builder.part("message", message);
builder.part("responseType", responseType);
return webClient.post()
.uri("/detect")
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(builder.build()))
.retrieve()
.onStatus(HttpStatusCode::isError, response ->
response.bodyToMono(String.class)
.map(body -> new RuntimeException("AI 서버 오류: " + body))
)
.bodyToMono(DetectionResult.class)
.block();
}
}
(1) 코드 흐름
- WebClient의 기본 URL을 FastAPI 서버 주소로 설정한다.
- 클라이언트가 업로드한 파일을 Multipart 요청에 담는다.
- message, responseType도 함께 Form 데이터로 담는다.
- FastAPI의 /detect API로 POST 요청을 보낸다.
- FastAPI 응답을 DetectionResult 객체로 변환한다.
(2) MultipartBodyBuilder를 사용하는 이유
- 파일과 문자열 데이터를 함께 보내야 하기 때문이다.
- FastAPI 서버가 Form, File 형태로 데이터를 받기 때문이다.
(3) .block()을 사용한 이유는
- WebClient는 기본적으로 비동기 방식이다.
- 이 예제에서는 Controller에서 바로 결과를 반환하기 위해 동기적으로 결과를 기다린다.
- 실제 서비스에서는 전체 흐름을 비동기로 구성하는 방식도 고려할 수 있다.
6) Controller 구현
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class DetectionController {
private final DetectionService detectionService;
@PostMapping("/detect")
public ResponseEntity<DetectionResult> detect(
@RequestParam("file") MultipartFile file,
@RequestParam("message") String message,
@RequestParam("responseType") String responseType
) throws IOException {
DetectionResult result = detectionService.detect(file, message, responseType);
return ResponseEntity.ok(result);
}
}
-
- 클라이언트의 /api/detect 요청을 받는다.
- 업로드된 이미지 파일을 받는다.
- message, responseType 값을 받는다.
- Service 계층에 객체탐지 요청을 위임한다.
- FastAPI에서 받은 결과를 클라이언트에게 반환한다.
7) 파일 업로드 크기 설정
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB
- Spring Boot는 기본적으로 업로드 파일 크기에 제한이 있다.
- 이미지 파일은 기본 제한보다 클 수 있다.
- 따라서 객체탐지 서비스에서는 업로드 크기 제한을 늘려야 한다.
| 설정 | 의미 |
| max-file-size | 파일 하나의 최대 크기 |
| max-request-size | Multipart 요청 전체의 최대 크기 |
6. 클라이언트 요청 흐름
- 클라이언트는 Spring Boot 서버의 /api/detect로 요청을 보낸다.
- 요청 데이터는 Multipart Form 형식이다.
| 값 | 설명 |
| file | 객체탐지를 수행할 이미지 |
| message | 함께 전달할 메시지 |
| responseType | base64 또는 url |
클라이언트
→ POST /api/detect
→ Spring Boot
→ POST /detect
→ FastAPI
→ YOLO 객체탐지
→ FastAPI 응답
→ Spring Boot 응답
→ 클라이언트
7. Base64 응답과 URL 응답 예시
1) Base64 응답 예시
{
"message": "테스트",
"image": "/9j/4AAQSkZJRgABAQAAAQABAAD...",
"url": ""
}
- image 필드에 Base64 문자열이 들어간다.
- 클라이언트는 이 문자열을 이미지로 변환해서 화면에 표시할 수 있다.
- 이미지 크기가 클수록 응답 데이터도 커진다.
2) URL 응답 예시
{
"message": "테스트",
"image": "",
"url": "http://localhost:8000/static/a1b2c3d4.jpg"
}
- url 필드에 이미지 접근 경로가 들어간다.
- 클라이언트는 해당 URL로 결과 이미지를 조회한다.
- 응답 데이터 크기를 줄일 수 있다.
8. 구현하면서 주의할 점
1) 이미지 업로드 크기 제한
- Spring Boot는 기본 업로드 크기 제한이 있다.
- 고해상도 이미지를 업로드하면 제한에 걸릴 수 있다.
- application.properties에서 업로드 크기 제한을 늘려야 한다.
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB
2) WebClient 응답 버퍼 크기
- Base64 방식은 응답 JSON 안에 이미지 문자열이 포함된다.
- 이미지가 크면 WebClient의 기본 메모리 버퍼 제한을 초과할 수 있다.
- 이런 경우 maxInMemorySize 설정을 추가해야 한다.
WebClient.builder()
.codecs(configurer ->
configurer.defaultCodecs().maxInMemorySize(20 * 1024 * 1024)
)
.baseUrl("http://localhost:8000")
.build();
- Base64 응답을 사용할 경우 특히 주의해야 한다.
- URL 방식은 응답 크기가 작기 때문에 이 문제를 줄일 수 있다.
3) FastAPI 응답 모델 필드
- Pydantic의 BaseModel은 기본적으로 선언된 필드를 필수값으로 본다.
- 따라서 image 또는 url 중 하나를 사용하지 않더라도 필드 자체는 응답에 포함해야 한다.
- 이 예제에서는 사용하지 않는 필드에 빈 문자열을 넣었다.
return DetectionResult(message=message, image="", url=file_path)
'AI > Spring AI' 카테고리의 다른 글
| [Spring] WebClient로 OpenAI API 연동하기 (0) | 2026.06.01 |
|---|---|
| [Spring AI] MQTT로 실시간 객체탐지 구현하기 — Mosquitto, paho-mqtt, YOLO, WebSocket (0) | 2026.06.01 |
| [Spring] WebFlux로 FastAPI 프록시 구현하기 — MultipartBodyBuilder, FilePart, WebClient (0) | 2026.06.01 |
| [Spring] Spring WebFlux란? — 동기/비동기, 블로킹/논블로킹, Mono/Flux, WebClient (0) | 2026.06.01 |
| [Spring AI] Spring Boot 기반 AI 서비스 아키텍처 — FastAPI, MQTT, Spring AI (0) | 2026.05.29 |
블로그의 정보
우와한개발자 님의 블로그
우와한개발자