📌 시나리오:
- DeliveryCommandService 가 허브/업체 배송을 생성하고
- 동시에 AsyncAiService 를 통해
- OpenAiClient(AiClient 구현체) 에게 “이 주문, 언제까지 보내야 하는지 + Discord 메시지” 생성을 시키고
- 그 결과를 AiDeadlineResponseV1 로 받아서 (지금은 로그만 찍고, 나중에 Discord 서비스로 보낼 예정)
- 이 전체 흐름에서 프롬프트 템플릿은 OpenAiConstants.DISCORD_FORMATTED_DEADLINE_PROMPT 가 담당.
Kafka 리스너로 HubRouteAfterCommandV1 라는 DTO를 들고 DeliveryCommandService.createDelivery(...) 를 호출.
DeliveryCommandService.createDelivery(...) 호출
@Transactional
public void createDelivery(
HubRouteAfterCommandV1 message,
UUID hubDeliveryPersonId,
UUID firmDeliveryPersonId) {
log.info("[배송 생성 시작] orderId={}", message.orderId());
createHubDelivery(message, hubDeliveryPersonId); // 허브 배송 생성
createFirmDelivery(message, firmDeliveryPersonId); // 업체 배송 생성
log.info("[배송 생성 완료] orderId={}", message.orderId());
asyncAiService.sendDeadlineRequest(message); // ★ AI 비동기 호출
}
AsyncAiService.sendDeadlineRequest(...)
@Async
public void sendDeadlineRequest(HubRouteAfterCommandV1 message) {
log.info("[AI 비동기 호출 시작] orderId={}", message.orderId());
AiDeadlineRequestV1 request = new AiDeadlineRequestV1(
message.orderId(),
message.startHubId(),
message.startHubName(),
message.startHubFullAddress(),
message.endHubId(),
message.endHubName(),
message.endHubFullAddress(),
message.receiverFirmId(),
message.receiverFirmFullAddress(),
message.receiverFirmOwnerName(),
message.requestNote(),
message.productName(),
message.productQuantity(),
message.orderCreatedAt(),
message.expectedDeliveryDuration()
);
AiDeadlineResponseV1 response = aiClient.generateDeadlineMessage(request);
log.info("[AI 결과] orderId={}, finalDeadline={}", message.orderId(),response.finalDeadline());
// TODO: Discord 서비스로 response.discordMessage() 전달
}
@Async 덕분에 별도의 스레드에서 실행 → 배송 생성 트랜잭션과 분리.HubRouteAfterCommandV1 → AiDeadlineRequestV1 로 변환 (AI에게 줄 데이터 정제).AiClient.generateDeadlineMessage(request) 호출 → 실제 OpenAI 호출은 구현체(OpenAiClient)가 담당.AiDeadlineResponseV1 에서 finalDeadline 을 로그에 남김.response.discordMessage() 를 Discord 서비스에 보내서 실제 DM/알림으로 사용할 예정.OpenAiClient.generateDeadlineMessage(...)
@Override
public AiDeadlineResponseV1 generateDeadlineMessage(AiDeadlineRequestV1 request) {
String prompt = buildPrompt(request);
log.info("[AI 요청] 배송 최종 발송 시한 계산, orderId={}", request.orderId());
String outputText = chatClient
.prompt()
.user(prompt)
.call()
.content();
log.info("[AI 응답 수신] 배송 최종 발송 시한 계산 완료, orderId={}", request.orderId());
String finalDeadline = extractDeadline(outputText);
return new AiDeadlineResponseV1(outputText, finalDeadline);
}
buildPrompt(request) 로 거대한 텍스트 프롬프트 생성.
chatClient.prompt().user(prompt).call().content() 로 Spring AI의 ChatClient 를 통해 OpenAI 호출.
전체 응답(outputText)에서 extractDeadline(...) 으로 "yyyy-MM-dd HH:mm" 형식의 최종 발송 시한만 파싱.
AiDeadlineResponseV1 에
discordMessage: Discord 코드블록 그대로finalDeadline: 파싱된 마지막 줄 시간만OpenAiConstants.DISCORD_FORMATTED_DEADLINE_PROMPT
"%s" / "%d" 자리로 템플릿 변수를 만들어 두고,OpenAiClient.buildPrompt(...) 에서 formatted(...) 호출로 실제 값 채워 넣음.OpenAiConstants순수 상수 모음 클래스.
생성자를 private 으로 막아서 인스턴스 생성 불가.
현재 역할:
%s, %d 자리 (formatted()로 채움)를 제공.AiClient (인터페이스)public interface AiClient {
AiDeadlineResponseV1 generateDeadlineMessage(AiDeadlineRequestV1 request);
}
AI 호출을 추상화한 인터페이스.
장점:
나중에 OpenAI가 아니라 다른 모델/다른 구현체 로 교체해도
AsyncAiService 입장에서는 AiClient 만 보면 됨.테스트 시에는 FakeAiClient / StubAiClient 를 주입하기 쉬움.
OpenAiClient (AiClient 구현체)AiClient 의 실제 구현체, Spring Bean (@Component).
ChatClient (Spring AI)를 주입받아서 OpenAI와 통신.
buildPrompt(...):
request 를 보고
requestNote 가 비어 있으면 "없음" 으로 치환."없음" 으로 고정 (TODO 주석 있음).OpenAiConstants.DISCORD_FORMATTED_DEADLINE_PROMPT.formatted(...) 로 실제 주문 값 바인딩.
extractDeadline(...):
"위 내용을 기반으로 도출된 최종 발송 시한은" 으로 시작하는 줄을 찾아서"yyyy-MM-dd HH:mm" 부분만 남김.null.AsyncAiService애플리케이션 레벨에서 AI 호출을 담당하는 비동기 서비스.
역할:
HubRouteAfterCommandV1 → AiDeadlineRequestV1 로 매핑.AiClient 를 통해 실제 AI 호출.@Async 덕분에:
DeliveryCommandService.createDelivery(...) 처리가 AI 응답을 기다리지 않고 바로 끝날 수 있음.DeliveryCommandService배송과 관련된 “명령(Command)” 책임을 한 데 모은 서비스.
주요 기능:
createDelivery)changeDeliveryStatus)cancelDelivery)createDelivery() 가 도메인 관점의 “전체 배송 생성” 유즈케이스: