Spring Boot 4.1.0 + Spring AI 2.0.0으로 작은 이미지 생성 서비스를 만들어 보았다.
사용자가 프롬프트를 입력하면
① LLM이 그 프롬프트를 다듬고
② 다듬어진 프롬프트로 이미지 생성 모델을 호출하고
③ 결과 이미지를 스토리지에 올리고
④ 어떤 프롬프트로 어떤 이미지를 만들었는지 DB에 남긴다.
각 기능이 코드 안에서 정확히 어떤 로직으로 동작하는지를 먼저 설명하고, 그 다음에 왜 그렇게 설계했는지와 실제 구현이 어떤 모습인지를 덧붙이는 순서로 정리했다.
이 서비스에서 사용자가 입력창에 쓴 문장은 곧바로 이미지 생성 모델로 가지 않는다.
그 전에 LLM을 한 번 거쳐서 "이미지 생성 모델이 잘 알아듣는 형태"로 번역/재구성되는 단계가 있다.
이 역할을 하는 ChatClient는 다음과 같이 정의되어 있다.
// ImageGenConfig.java
@Bean
ChatClient promptImproveClient(ChatModel model) {
return ChatClient.builder(model)
.defaultSystem("""
- 전달받은 내용을 500자 이내의 영문으로 된 이미지 생성용 프롬프트로 개선
- 특징이 잘 드러나게 자세하지만 간결한 표현으로 구성
""")
.defaultOptions(ChatOptions.builder()
.temperature(0.3)
.maxTokens(1000))
.build();
}
ChatClient.builder(model)로 만든 빌더에 defaultSystem(...)을 붙이면, 이 클라이언트로 보내는 모든 요청 앞에 저 시스템 프롬프트가 항상 깔린다.
즉 매번 "영문으로, 500자 이내로, 이미지 생성용 프롬프트로 바꿔줘"라고 반복해서 적어줄 필요 없이, 사용자가 입력한 원문만 user()로 넘기면 된다.
temperature를 0.3으로 낮게 잡은 것도 눈여겨볼 부분인데, 이 값이 높을수록 모델이 매번 다른 표현을 창의적으로 골라내고, 낮을수록 비슷한 입력에는 비슷한 스타일의 결과를 안정적으로 낸다.
프롬프트 번역·재구성처럼 "매번 다르게 나오면 곤란한" 작업에는 창의성보다 일관성이 중요하므로 이 값을 낮추었다.
실제 호출부는 ImageGenService에 있다.
public String improvePrompt(String prompt) {
String improved = promptImproveClient
.prompt().user(prompt)
.call().content();
System.out.println("old = " + prompt);
System.out.println("new = " + improved);
return improved;
}
.prompt().user(prompt)로 사용자 메시지를 세팅하고 .call().content()로 문자열 응답만 뽑아낸다.
Spring AI의 ChatClient는 스트리밍(.stream())이나 구조화된 응답(.entity(...))도 지원하지만,
여기서는 그냥 텍스트 하나만 필요하니 가장 단순한 형태를 쓴 것이다.
이 호출이 실제로 어디로 가는지는 application-ai.yaml을 보면 이렇다.
spring:
ai:
openai:
api-key: ${GROQ_API_KEY}
base-url: https://api.groq.com/openai/v1
chat:
model: openai/gpt-oss-120b
의존성은 spring-ai-starter-model-openai인데 base-url은 OpenAI가 아니라 Groq을 가리킨다.
Groq이 OpenAI와 같은 Chat Completions API 스펙을 그대로 흉내 내주기 때문에, OpenAI 전용으로 짜인 클라이언트 코드를 한 줄도 안 바꾸고 base-url과 api-key만 갈아끼워서 재사용할 수 있는 것이다.
모델 이름의 openai/gpt-oss-120b도 헷갈리기 쉬운데, OpenAI가 만든 비공개 모델이 아니라 OpenAI가 오픈소스로 공개한 gpt-oss-120b를 Groq이 자체 LPU 인프라에 올려서 아주 빠르게 서빙해주는 것이다.
프롬프트를 다듬는 정도의 가벼운 작업이라 최상급 모델까지는 필요 없고, 대신 Groq 특유의 저지연 추론 덕분에 이미지 생성 전에 LLM 호출을 한 단계 더 끼워 넣어도 사용자가 느끼는 전체 대기 시간에 큰 부담을 주지 않는다는 점까지 고려된 조합으로 보인다.
프롬프트가 준비되면 실제 이미지를 만들 차례인데, 이 부분은 Spring AI의 ImageModel 추상화를 쓰지 않는다. 대신 순수한 RestClient로 Cloudflare Workers AI의 REST 엔드포인트를 직접 호출한다.
Spring AI의 이미지 생성 추상화는 OpenAI DALL-E 계열을 기준으로 만들어져 있어서, Cloudflare Workers AI처럼 지원 대상이 아닌 provider는 표준 API로 감쌀 방법이 없기 때문이다.
먼저 이 호출 전용 RestClient 빈을 보자.
// ImageGenConfig.java
@Bean
RestClient cfWorkersAiClient(RestClient.Builder b, CFProperty p) {
HttpClientSettings settings = HttpClientSettings.defaults()
.withTimeouts(
Duration.ofSeconds(5), // connection - 요청은 금방 보내야 하니까
Duration.ofSeconds(60) // read - 이미지 처리는 오래 걸릴 수 있으니까
);
String baseUrl = "https://api.cloudflare.com/client/v4/accounts/%s/ai".formatted(p.accountId());
return b.clone()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(p.apiToken()))
.requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
.build();
}
baseUrl에 계정 전용 AI 엔드포인트를 미리 박아두고 defaultHeader로 인증 토큰까지 심어뒀기 때문에, 이 빈을 주입받는 쪽에서는 매 요청마다 URL 전체와 인증 헤더를 신경 쓸 필요 없이 뒤에 붙는 경로(/run/{model})만 신경 쓰면 된다.
타임아웃을 connection 5초, read 60초로 다르게 잡은 것도 의미가 있는데, "요청을 보내는 것"과 "이미지 생성이 끝나서 응답이 돌아오는 것"은 걸리는 시간의 성격이 완전히 다르기 때문이다. 연결 자체는 금방 되거나 실패하지만, 이미지 생성은 모델이 실제로 그림을 그리는 동안 서버가 응답을 들고 있어야 하므로 read 쪽에 훨씬 여유를 준 것이다.
실제 호출은 서비스 계층에 있다.
public GenResultDTO invokeImage(String prompt) {
String modelName = "@cf/black-forest-labs/flux-1-schnell";
GenResultDTO result = aiClient.post()
.uri("/run/%s".formatted(modelName))
.contentType(MediaType.APPLICATION_JSON)
.body(new GenRequestDTO(prompt, 4))
.retrieve()
.body(GenResultDTO.class);
return result;
}
GenRequestDTO(prompt, 4)에서 steps를 4로 준 것도 아무 값이나 고른 게 아니다. flux-1-schnell은 이름 그대로 "빠른(schnell)" 버전으로, 몇 단계 안 되는 적은 스텝만으로도 충분히 쓸만한 품질이 나오도록 설계된 모델이다. Flux의 일반 모델이 수십 스텝을 도는 것과 비교하면, steps=4는 이 모델의 특성에 맞춰 속도를 최대한 끌어올린 값이다.
응답을 담는 GenResultDTO도 구조를 보면 설계 의도가 드러난다.
public record GenResultDTO(boolean success, ImageResult result, List<CFMessage> errors) {
public record ImageResult(String image) { } // base64
public record CFMessage(int code, String message) { }
}
Cloudflare API는 성공하든 실패하든 HTTP 200으로 응답하면서success 플래그와 errors 배열로 결과를 구분하는 패턴을 쓰는데, 이 DTO는 그 두 경우를 모두 담을 수 있게 필드를 미리 만들어뒀다.
다만 지금 코드에서는 result.success()나 result.errors()를 실제로 검사하는 곳이 없어서, 이 필드들은 아직 "언젠가 쓰려고 만들어둔" 상태로 남아 있다(9번 항목에서 다시 다룬다).
Cloudflare가 돌려주는 이미지는 바이너리 파일이 아니라 base64로 인코딩된 문자열이다. 이걸 그대로 DB 텍스트 컬럼에 넣어버릴 수도 있지만, 그러면 컬럼 하나가 수십~수백 KB짜리 문자열을 매번 들고 있어야 해서 조회·백업 성능이 나빠지고, 애초에 브라우저에 이미지를 서빙하기에도 적합하지 않다.
그래서 이 프로젝트는 이미지 바이트 자체는 오브젝트 스토리지에 두고, DB에는 "어디에 저장했는지"를 가리키는 키(파일명)만 남기는 방식을 쓴다.
public String upload(GenResultDTO result) {
String filename = "%s.jpg".formatted(UUID.randomUUID());
byte[] bytes = Base64.getDecoder().decode(result.result().image());
InputStream data = new ByteArrayInputStream(bytes);
ObjectMetadata metadata = ObjectMetadata.builder()
.contentType(MediaType.IMAGE_JPEG_VALUE)
.build();
s3Template.upload(bucket, filename, data, metadata);
return filename; // 호출 시 Key 값 filename만 return
}
순서를 그대로 따라가 보면, Base64.getDecoder().decode(...)로 문자열을 실제 바이트 배열로 되돌리고, 그 바이트 배열을 ByteArrayInputStream으로 감싸서 S3Template.upload(...)가 요구하는 InputStream 형태로 맞춘다. 파일명은 UUID.randomUUID()로 매번 새로 만들기 때문에 여러 사용자가 동시에 이미지를 생성해도 파일명이 겹칠 걱정이 없다. 업로드가 끝나면 이 filename(=S3의 key)만 반환값으로 돌려주고, 이 값이 나중에 GenImage 엔티티에 저장된다.
여기서 "S3"라는 이름이 붙어 있지만 실제로는 AWS S3가 아니다. application-file.yaml을 보면 이게 드러난다.
spring:
cloud:
aws:
s3:
endpoint: ${STORAGE_ENDPOINT}
region: ${STORAGE_REGION}
path-style-access-enabled: true
spring-cloud-aws-starter-s3는 "S3 API 규격을 따르는 스토리지라면 AWS가 아니어도 endpoint만 바꿔서 붙일 수 있게" 해주는 라이브러리다. 그 덕분에 .env.dev.sample에 적힌 것처럼 실제로는 Supabase Storage의 S3 호환 엔드포인트(https://*.storage.supabase.co/storage/v1/s3)를 그대로 연결해서 쓰고 있다.
path-style-access-enabled: true는 요청 URL 형태를 bucket.endpoint.com/key(virtual-hosted style)가 아니라 endpoint.com/bucket/key(path style)로 만들라는 옵션인데, AWS가 아닌 S3 호환 스토리지들은 대체로 path style만 지원하기 때문에 켜줘야하는 설정이다.
템플릿에서 이미지 태그가 가리키는 주소를 보면 스토리지 주소가 아니라 이 애플리케이션 자신의 경로다.
<!-- gen/page.html -->
<img th:src="@{/gen/{f}(f=${result.filename})}" width="240" alt="생성된 이미지">
브라우저가 이 <img>를 렌더링하려고 요청을 보내면, 그 요청은 스토리지가 아니라 우리 서버의 /gen/{filename}으로 간다. 그걸 받는 쪽은 이렇게 되어 있다.
// ImageGenController.java
@GetMapping("/{filename}")
public ResponseEntity<Resource> download(@PathVariable String filename) {
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_JPEG)
.body(imageGenService.download(filename));
}
// ImageGenService.java
public Resource download(String filename) {
return s3Template.download(bucket, filename);
}
컨트롤러는 filename을 받아 서비스에 넘기고, 서비스는 s3Template.download(bucket, filename)로 스토리지에서 실제 파일을 가져와 Resource로 감싸 돌려준다. 컨트롤러는 그 Resource를 Content-Type: image/jpeg로 응답 바디에 그대로 실어 보낸다. 즉 브라우저 입장에서는 이미지가
어느 스토리지에 저장돼 있는지 전혀 알 필요가 없고, 항상 우리 서버를 거쳐서만 이미지를 받는다.
이렇게 한 겹을 끼워두면 버킷 자체는 비공개로 유지하면서도 인증·로깅 같은 접근 제어를 애플리케이션 레이어에 자유롭게 얹을 수 있고, 나중에 스토리지를 다른 곳으로 옮기더라도(예: Supabase → AWS S3) 프론트엔드 코드는 한 글자도 바꿀 필요가 없다.
<!--/* [[${result.result.image}]] */-->
<!--/* <img th:src="'data:image/jpeg;base64,' + ${result.result.image}" */-->
<!--/* width="240" alt="생성된 이미지"> */-->
초기 버전은 Cloudflare가 돌려준 base64 문자열을 data:image/jpeg;base64,... 형태로 <img>에 직접 꽂아 넣는 방식이었다. 구현은 훨씬 간단하지만, 페이지를 새로고침할 때마다 base64 문자열 전체를 다시 실어 날라야 해서 무겁고, 무엇보다 "예전에 만든 이미지 목록을 다시 보여주기" 기능을 만들려면 그 긴 문자열을 어딘가에 영구히 저장해두는 수밖에 없다. S3에 저장하고 key만 DB에 남기는 지금 구조로 바꾸면서 이 문제가 자연스럽게 함께 해결된 셈이다.
generateImage()의 흐름지금까지 본 네 단계(프롬프트 개선 → 이미지 생성 → 업로드 → DB 저장)를 실제로 이어붙이는 진입점이 ImageGenService.generateImage()다.
@Transactional
public ImageResultDTO generateImage(String prompt) {
String improved = improvePrompt(prompt);
GenResultDTO result = generate(improved);
String key = upload(result);
repository.save(GenImage.builder()
.filename(key)
.prompt(prompt)
.improved(improved)
.build());
return new ImageResultDTO(key, prompt, improved);
}
코드를 그대로 읽으면 "프롬프트를 개선하고 → 그 결과로 이미지를 만들고 → 업로드해서 key를 얻고 → 원본 프롬프트·개선된 프롬프트·key를 한 행으로 저장한다"는 흐름이 그대로 보인다. 이 메서드 전체에 @Transactional을 붙인 이유는 "이미지 생성까지는 다 됐는데 DB 저장만 실패해서 어정쩡한 데이터가 남는" 상황을 막고 싶었기 때문이다.
다만 여기서 짚고 넘어가야 할 부분이 있다. @Transactional이 실제로 보장해주는 건 DB 커넥션의 커밋/롤백뿐이고, 그 안에서 호출하는 외부 HTTP API(LLM 호출, 이미지 생성, S3 업로드)는 전혀 롤백 대상이 아니다. 즉 마지막 repository.save(...)가 실패해서 트랜잭션이 롤백되더라도, 이미 Cloudflare에 만들어달라고 요청해서 비용이 발생한 이미지와 이미 S3에 올라간 파일은 그대로 남는다. 게다가 read timeout을 60초까지 열어둔 이미지 생성 API 호출이 트랜잭션 범위 안에 있다는 것은, 그 시간 동안 DB 커넥션 풀에서 커넥션 하나를 계속 붙잡고 놓아주지 않는다는 뜻이기도 하다.
느린 외부 API 호출과 DB 트랜잭션을 한 메서드에 같이 두면 늘 따라오는 트레이드오프다.
@ConfigurationProperties record로 Cloudflare 설정값을 묶은 방식Cloudflare 연동에 필요한 값은 계정 ID와 API 토큰 두 개인데, 이 둘을 각각 @Value로 따로 주입받는 대신 하나의 타입으로 묶어뒀다.
@ConfigurationProperties(prefix = "app.cf")
public record CFProperty(String accountId, String apiToken) {
}
prefix = "app.cf"이므로 application-ai.yaml의 app.cf.account-id, app.cf.api-token 값이 각각 accountId, apiToken으로 바인딩된다. (스프링의 relaxed binding 덕분에 케밥 케이스 YAML 키가 카멜 케이스 필드에 자동으로 매핑된다). 이 클래스를 빈으로 등록하기 위한 별도의 @Bean 메서드는 어디에도 없는데, 대신 애플리케이션 클래스에 이런 애노테이션이 붙어 있다.
@SpringBootApplication
@ConfigurationPropertiesScan
public class ImagegenApplication { ... }
@ConfigurationPropertiesScan이 클래스패스를 스캔하면서 @ConfigurationProperties가 붙은 타입을 자동으로 찾아 빈으로 등록해주기 때문에, CFProperty는 별도 설정 없이도
ImageGenConfig.cfWorkersAiClient(RestClient.Builder b, CFProperty p)에서 바로 주입받아 쓸 수 있다. 두 값을 항상 세트로 다뤄야 한다는 사실이 record라는 타입 하나로 명확하게 드러나고, record는 필드가 모두 final이라 한 번 값이 채워지면 실수로 바뀔 일도 없다.
다만 같은 파일에서 S3 버킷 이름은 조금 다른 방식으로 주입받는다.
@Value("${app.sb.bucket}")
private String bucket;
버킷 이름은 값이 하나뿐이라 굳이 별도 타입을 만들지 않고 필드에 바로 주입한 것인데, 일관성만 놓고 보면 이것도 CFProperty처럼 작은 record로 뽑아냈어도 괜찮았을 부분이다.
prompt, improved 컬럼 길이를 넉넉하게 잡아둔 엔티티 설계GenImage 엔티티는 사용자가 입력한 원본 프롬프트와 LLM이 다듬은 프롬프트를 함께 저장한다.
@Entity
public class GenImage {
@Id @GeneratedValue
private Long id;
private String filename;
@Column(length = 2000)
private String prompt;
@Column(length = 2000)
private String improved;
}
prompt, improved 두 컬럼에만 @Column(length = 2000)이 붙어 있다. 사용자가 입력하는 원본 프롬프트는 컨트롤러의 @Size(max = 500)으로 500자까지만 들어오도록 막혀 있지만, LLM이 돌려주는 개선된 프롬프트는 상황이 다르다. "500자 이내로 써줘"라는 요청은 시스템 프롬프트로 모델에게 부탁한 것일 뿐 강제되는 규칙이 아니라서, 모델이 이 지침을 넘겨서 응답할 가능성을 배제할 수 없다. JPA/Hibernate가 문자열 컬럼에 기본으로 잡는 길이(255자)로는 이 값을 안전하게 담기 어렵기 때문에 여유 있게 2000자로 늘려잡은 것이다. 반면 filename은 애플리케이션이 직접 UUID + ".jpg" 형태로 만드는 고정 길이 값이라 굳이 길이를 조정할 필요가 없어 기본값 그대로
남아 있다.
이 스키마는 마이그레이션 도구 없이 application-db.yaml의 hibernate.ddl-auto: update 설정을 통해 애플리케이션이 뜰 때마다 자동으로 맞춰진다.