지금 제가 진행하고 있는 프로젝트에서는 OpenAI의 Dalle 모델에서 이미지를 생성 요청 API를 활용하여 이미지를 생성합니다.
ChatGPT 개발자 사이트에 들어가서 아래와 같은 형식으로 받아올 수 있죠.
curl -X POST "https://api.openai.com/v1/images/generations" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-type: application/json" \
-d '{
"model": "gpt-image-1",
"prompt": "A childrens book drawing of a veterinarian using a stethoscope to listen to the heartbeat of a baby otter."
}' | jq -r '.data[0].b64_json' | base64 --decode > otter.png
물론 이렇게 복잡한 curl 요청을 직접 보내진 않고, SpringAI 라는 라이브러리를 사용해서 보내고 있습니다.
이미지 생성에 필요한 파라미터들을 빌더 패턴으로 입력하여 요청을 굉장히 편하게 보낼 수 있죠.
(다만 아직 정식 출시 버전 없이 마일스톤 버전만 사용할 수 있어, 안정성에 문제가 있다고 합니다. 아직까지 SpringAI 자체가 문제가 생긴적은 없었지만 주의가 필요하긴 하다네요.)
ImageResponse response = openAiImageModel.call(
new ImagePrompt(prompt,
OpenAiImageOptions.builder()
.withN(1)
.withQuality("hd")
.withHeight(height)
.withWidth(width)
.withResponseFormat("b64_json")
.withStyle("vivid")
.withModel("dall-e-3")
.build())
);
String base64Image = response.getResult().getOutput().getB64Json();
위 코드와 같이 굉장히 편리하게 이미지 생성 요청을 보내고, 이를 base64 형식으로 받아올 수 있었습니다.
그런데 dalle에게 이미지를 요청하는건 한장 단위로 이루어지죠. 그래서 아무리 프롬프트를 똑같이, 일관적으로 작성하려고 노력해도 사진의 결과물이 언제나 똑같이 나오지는 않았습니다. 현대 이미지 생성 모델의 한계라고 볼 수 있을것 같은데요. 최대한 비슷한 이미지를 생성하여 보여주고 싶던 제 입장에서는 정말 아쉬운 상황이었습니다.


똑같은 인물과 상황을 묘사하더라도, 위와 같이 비슷해 보이면서도 다른 이미지가 생성되었죠.
그러다가 우연한 기회에, 운이 좋게도 어떤 연구실에서 개발한 이미지 생성 인공지능 모델을 사용할 기회를 얻게 되었습니다. Stable Diffusion 기반으로 학습을 진행한 모델인데, 이미지를 한번에 여러장을 생성해야 하는 대신 이미지 간 파라미터를 공유해 일관성을 유지할 수 있는 모델이었죠. (편의상 CIG, Consistency Image Generation 모델이라고 부르겠습니다.)
프로젝트에 적용을 하려고 보니, 이 CIG 모델이 vRam을 참 많이 사용하더라구요. 저희 프로젝트에 적용하기 위해, 13장의 이미지를 한번에 생성해야 하는데, 이미지 크기도 1024 x 1024px 로 결코 작은 크기가 아니다 보니, 로컬 환경에서 돌리기에는 큰 무리가 있었습니다.
코랩을 돌릴래도 Out of Memory가 나오니 이 거대한 모델을 어떻게 사용할 수 있을지 참 막막했죠.
그렇게 간절하게 웹 서핑을 하다가 발견한게 바로 아래 beam cloud라는 플랫폼이었습니다.
https://platform.beam.cloud/overview
https://platform.beam.cloud/overview
platform.beam.cloud
인공지능 모델을 클라우드 상에 컨테이너 형태로 띄워놓고 요청을 보낼 수 있도록 지원해주는 플랫폼인데, 가장 중요한건 이를 "서버리스" 형태로 지원한다는 것이었죠. 저희는 vRam 100GB 이상의 GPU 환경이 필요했고, 이를 24시간 내내 대여하기에는 굉장히 큰 가격적 부담이 되죠. 하지만 서버리스 환경에서 띄운다면 요청이 들어왔을 때만 컨테이너를 돌려 과금이 됩니다.
단점이라고 하면 컨테이너가 가동되는 시간때문에 응답이 조금 늦어질 수 있다는 점인데, 어차피 이미지 생성은 2분 이상 걸리는 작업이기에 큰 문제가 되지 않았죠. H100이라는 고성능의 GPU도 생각보다 저렵한 가격으로 사용할 수 있었구요. (13장을 생성하는데 1달러 정도 드는거 같은데, 이정도면 지불할 만 하다라고 생각했습니다. dalle도 한장에 0.08달러 정도 나오거든요.)
그렇게 저희는 CIG 모델을 위해 H100, vRam 140GB라는 환경을 구축할 수 있었습니다.
그래서 제가 처음에 생각했던 프로젝트 구조는 아래와 같습니다.

이게 원래의 이미지 생성 API 구조라면

이렇게 OpenAI로 보내던 요청을 파이썬 컨테이너로 바꿔서 보내면 될거라고 생각했죠. 이 경우에는 SpringAI를 못쓰기 때문에 curl 요청을 직접 구현해야 겠지만 괜찮습니다. 이로 인해 저희가 제공하는 이미지는 일관성을 유지한채로 유저에게 보여줄 수 있을 테니 괜찮은 trade-off죠.

물론 모든 일이 항상 Happy 하지는 않더라구요. 위 구조를 활용한 개발은 한가지 문제 봉착했습니다.
('Connection broken: IncompleteRead(14775840 bytes read, 6234140 more expected)', IncompleteRead(14775840 bytes read, 6234140 more expected))
이게 제가 발견한 오류였는데요. 쉽게 말해서 그냥 데이터가 너무 크다는 거였습니다. SpringBoot 서버에서 생성 요청을 하고, 그 원본 데이터를 base64형식으로 파이썬 서버로부터 응답을 받아야 했는데, 13장의 이미지에, 이미지 한장의 사이즈도 작진 않다보니 위와 같은 에러가 났던거죠.
이 에러를 본 후에야 깨달았던게, 이렇게 통신하기에는 이미지가 너무 크고, 형식도 JSON 형식으로 보내는거라 더더더 커지고, 혹시 모를 데이터 손실에 굉장히 치명적일 수밖에 없다는 문제도 있었죠.
그래서 그 다음으로 머리에 스쳤던 구조는 아래와 같습니다.

이 구조로 개발을 진행한다면 위에서 봤던 문제가 해결될것 같긴 했습니다만, 별로 채택하고 싶지는 않았습니다. 이렇게 각각의 역할을 가진 서버를 여러개 띄운다면, 각 서버의 역할은 명확해야 하고 높은 수준의 책임분리가 이루어져야 나중에 유지보수 하기 편하기 때문이죠.
제가 의도한 스프링부트 서버의 역할은 전체 요청의 조율이고, 파이썬 서버의 역할은 오로지 이미지 생성입니다. 위 구조대로 가면 파이썬 서버는 s3와의 통신에 대한 책임을 가지게 되고, 이는 이후에 s3를 다른 클라우드 서버로 변경한다던가, 아니면 해당 API 의 요구사항이 변경되었을 때 정말 큰 고난을 줄게 뻔했거든요.
그래서 그 대책으로 찾아낸 방법이 Pre-signed URL 이었습니다.
Pre-signed URL 을 사용하면 스프링부트에서 s3에 접근 가능한 하나의 url을 응답하게 됩니다. 해당 url은 5분동안 s3버킷에 PUT을 하도록 합니다. 그래서 파이썬은 s3에 관한 의존성과 AWS 키 값이 없더라도 단순 http요청으로 S3 저장소에 접근이 가능해진거죠. url을 매개로 이미지를 전달한다고 생각하면 이해가 쉬울것 같군요.
이 방법을 사용하면 스프링부트에서 s3에 대한 책임을 (조율에 대한 책임) 다 가지고 있고, 파이썬 서버는 이미지 생성이라는 책임에만 집중하며, 용량이 큰 이미지 대신 url만으로 상호작용을 하니 저희 프로젝트에서는 최선의 선택이라고 볼 수 있을것 같습니다.
따라서 이를 차용한 프로젝트 구조는 아래와 같죠.

위와 같은 구조를 차용하여 CIG 모델을 활용한 대용량 이미지 생성 요청을 구현할 수 있었습니다.
아래는 예제 코드입니다.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import java.net.URL;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
public class ImageController {
// application.yml에 설정된 값 주입
@Value("${aws.s3.bucket-name}")
private String bucketName;
@Value("${python.server.url}")
private String pythonServerUrl;
// AWS SDK v2의 S3Presigner를 Bean으로 등록하여 사용
private final S3Presigner s3Presigner;
public ImageController(S3Presigner s3Presigner) {
this.s3Presigner = s3Presigner;
}
/**
* 클라이언트로부터 이미지 생성 요청을 받는 API
*/
@PostMapping("/api/images/generate")
public ResponseEntity<String> generateImage(@RequestBody Map<String, String> payload) {
String prompt = payload.get("prompt");
// 1. S3에 저장할 고유한 파일 이름 생성
String objectKey = "images/" + UUID.randomUUID().toString() + ".png";
// 2. S3에 파일을 올릴 수 있는 Pre-signed URL 생성 (5분 동안 유효)
PutObjectRequest objectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.contentType("image/png") // Python에서 PUT할 때 헤더와 일치해야 함
.build();
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(5))
.putObjectRequest(objectRequest)
.build();
URL preSignedUrl = s3Presigner.presignPutObject(presignRequest).url();
// 3. Python 서버에 프롬프트와 Pre-signed URL 전달
RestTemplate restTemplate = new RestTemplate();
Map<String, String> pythonRequest = new HashMap<>();
pythonRequest.put("prompt", prompt);
pythonRequest.put("upload_url", preSignedUrl.toString());
// Python 서버의 /generate-image 엔드포인트로 요청
ResponseEntity<String> pythonResponse = restTemplate.postForEntity(
pythonServerUrl + "/generate-image",
pythonRequest,
String.class
);
if (pythonResponse.getStatusCode().is2xxSuccessful()) {
// 성공 시, 클라이언트가 최종적으로 접근할 S3 파일 URL을 반환
String finalImageUrl = "https://" + bucketName + ".s3.amazonaws.com/" + objectKey;
return ResponseEntity.ok(finalImageUrl);
} else {
return ResponseEntity.status(500).body("이미지 생성 실패");
}
}
}
import io
import requests
from flask import Flask, request, jsonify
# Flask 앱 생성
app = Flask(__name__)
@app.route('/generate-image', methods=['POST'])
def generate_and_upload_image():
# Spring으로부터 받은 JSON 데이터 추출
data = request.get_json()
prompt = data.get('prompt')
upload_url = data.get('upload_url')
if not all([prompt, upload_url]):
return jsonify({"error": "prompt와 upload_url이 필요합니다."}), 400
print(f"이미지 생성 시작: {prompt}")
# 1. (가상) 이미지 생성
# 실제로는 여기서 AI 모델을 호출하여 이미지를 생성합니다.
# 이 예제에서는 텍스트를 포함한 간단한 바이너리 데이터를 생성합니다.
dummy_image_content = f"이것은 '{prompt}' 프롬프트로 생성된 이미지입니다.".encode('utf-8')
image_bytes = io.BytesIO(dummy_image_content)
# 2. 전달받은 Pre-signed URL로 이미지 파일(바이너리)을 HTTP PUT 요청
try:
headers = {'Content-Type': 'image/png'} # Spring에서 설정한 Content-Type과 일치
response = requests.put(upload_url, data=image_bytes.getvalue(), headers=headers)
# S3에서 200 OK 응답이 오면 성공
response.raise_for_status()
print(f"S3 업로드 성공! Status: {response.status_code}")
return jsonify({"message": "이미지 생성 및 업로드 성공"}), 200
except requests.exceptions.RequestException as e:
print(f"S3 업로드 실패: {e}")
return jsonify({"error": f"S3 업로드 실패: {e}"}), 500
if __name__ == '__main__':
# 서버 실행 (0.0.0.0으로 모든 IP에서 접속 허용)
app.run(host='0.0.0.0', port=5001)
예제에서는 파이썬의 플라스크 프레임워크를 활용해 보여드렸지만 실제 프로젝트 환경에서는 beam 라이브러리를 활용해 컨테이너로 띄웠기 때문에 실제 프로젝트에 사용한 코드와 차이가 있습니다.
이렇게 구현함으로서 각 인프라 사이의 책임분리 원칙을 지키고, 대용량 이미지 데이터를 통신할 수 있도록 만들 수 있었죠.
맥락에 따른 생각의 흐름이 참 재미었던 에피소드였습니다.
모두 읽어주셔서 감사합니다. d^^b
'[프로젝트 일지]' 카테고리의 다른 글
| 보안을 위한 DB 테이블 설계 (0) | 2025.11.24 |
|---|---|
| 트랜잭션과 병렬처리에 대한 고찰 (3) | 2025.10.23 |
| pdf 관련 기능 도입기 (2) | 2025.10.13 |
| 결제가 복사가 된다고?? (2) | 2025.10.03 |