이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Spring Boot 서비스에서 AI 호출 계층을 어떻게 나누는가?
  • AI Gateway가 맡아야 하는 책임은 무엇인가?
  • provider 예외와 모델 응답을 내부 서비스 계약으로 어떻게 바꾸는가?
  • 첫 ChatClient 예제를 운영 코드로 키울 때 무엇을 분리해야 하는가?

flowchart TB
    subgraph HTTP["HTTP 진입"]
        CONTROLLER["[Controller]<br/>request validation<br/>user, tenant 추출"]
        DTO["[Internal DTO]<br/>업무 요청 계약<br/>Spring AI 타입 숨김"]
    end

    subgraph APP["Application Service"]
        POLICY["[Policy]<br/>권한, quota, feature flag<br/>transaction 경계"]
        USECASE["[Use Case]<br/>업무 흐름 조립<br/>AI 필요 여부 판단"]
    end

    subgraph AI["AI Gateway"]
        PROMPT["[Prompt Contract]<br/>promptVersion, locale<br/>입력 검증"]
        CALL["[ChatClient Boundary]<br/>modelProfile 호출<br/>예외 변환"]
        USAGE["[Usage Record]<br/>token, latency, provider<br/>audit key"]
    end

    subgraph OUTSIDE["외부 의존성"]
        MODEL["[Provider]<br/>OpenAI, Ollama<br/>model runtime"]
        OBS["[Observability]<br/>metrics, logs, traces<br/>alert 연결"]
    end

    CONTROLLER --> DTO --> POLICY --> USECASE --> PROMPT --> CALL --> MODEL
    CALL --> USAGE --> OBS
    MODEL --> CALL
    CALL --> USECASE --> CONTROLLER

    classDef http fill:#E3F2FD,stroke:#1565C0,color:#0D47A1;
    classDef app fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20;
    classDef ai fill:#FFF3E0,stroke:#EF6C00,color:#E65100;
    classDef outside fill:#FCE4EC,stroke:#AD1457,color:#880E4F;
    class CONTROLLER,DTO http;
    class POLICY,USECASE app;
    class PROMPT,CALL,USAGE ai;
    class MODEL,OBS outside;

개요

Spring AI를 운영 코드로 쓰려면 Controller에서 ChatClient를 직접 호출하는 구조를 벗어나야 한다. AI 호출은 외부 시스템 호출이고, 비용과 품질과 보안에 영향을 주는 정책 경계다.

AI Gateway는 Spring AI와 업무 서비스 사이의 완충 계층이다. 이 계층이 있으면 provider 변경, fallback, fake test, 예외 변환, 사용량 기록을 한곳에서 다룰 수 있다.

원리

권장 책임 분리는 다음과 같다.

  • Controller: HTTP request/response, 인증 사용자 추출, validation
  • Application service: 유스케이스 정책, 권한, 트랜잭션 경계
  • AI Gateway: Spring AI 호출, prompt version, model profile, 예외 변환, usage record
  • Provider adapter: 필요하면 provider별 세부 옵션과 raw response 처리

Spring AI를 쓰더라도 내부 서비스는 가능하면 Spring AI 타입에 직접 의존하지 않게 한다.

실습

먼저 내부 요청/응답 계약을 만든다.

record AiAnswerCommand(
        String tenantId,
        String userId,
        String question,
        String promptVersion,
        String modelProfile
) {
}
 
record AiAnswer(
        String text,
        List<String> sourceIds,
        String modelProfile
) {
}
 
interface SupportAiGateway {
    AiAnswer answer(AiAnswerCommand command);
}

업무 서비스는 gateway만 안다.

@Service
class SupportAnswerService {
 
    private final SupportPolicy supportPolicy;
    private final SupportAiGateway supportAiGateway;
 
    SupportAnswerService(SupportPolicy supportPolicy, SupportAiGateway supportAiGateway) {
        this.supportPolicy = supportPolicy;
        this.supportAiGateway = supportAiGateway;
    }
 
    AiAnswer answer(String tenantId, String userId, String question) {
        supportPolicy.checkCanUseAi(tenantId, userId);
 
        return supportAiGateway.answer(new AiAnswerCommand(
                tenantId,
                userId,
                question,
                "support-answer-v1",
                "default-chat"
        ));
    }
}

Spring AI 구현은 gateway 뒤에 숨긴다.

@Component
class SpringAiSupportGateway implements SupportAiGateway {
 
    private final ChatClient chatClient;
    private final AiUsageRecorder usageRecorder;
 
    SpringAiSupportGateway(ChatClient.Builder builder, AiUsageRecorder usageRecorder) {
        this.chatClient = builder
                .defaultSystem("""
                        You answer customer support questions.
                        If context is insufficient, ask for escalation.
                        """)
                .build();
        this.usageRecorder = usageRecorder;
    }
 
    @Override
    public AiAnswer answer(AiAnswerCommand command) {
        long started = System.nanoTime();
        try {
            String content = chatClient.prompt()
                    .user(user -> user.text("""
                            tenantId: {tenantId}
                            question: {question}
                            promptVersion: {promptVersion}
                            """)
                            .param("tenantId", command.tenantId())
                            .param("question", command.question())
                            .param("promptVersion", command.promptVersion()))
                    .call()
                    .content();
 
            usageRecorder.success(command, elapsedMillis(started));
            return new AiAnswer(content, List.of(), command.modelProfile());
        } catch (RuntimeException ex) {
            usageRecorder.failure(command, ex.getClass().getSimpleName(), elapsedMillis(started));
            throw AiProviderException.from(ex);
        }
    }
 
    private long elapsedMillis(long started) {
        return (System.nanoTime() - started) / 1_000_000;
    }
}

성공 경로는 업무 서비스가 Spring AI 타입 없이 답변을 받는 것이다.

자주 나는 오류는 gateway가 다시 너무 많은 책임을 갖는 것이다. RAG 검색, tool 실행, usage 저장, prompt registry가 커지면 내부 클래스로 분리한다.

운영에서는 gateway가 circuit breaker, rate limit, retry, fallback과 연결된다.

실전 판단

AI Gateway가 필요한지 판단하는 기준은 간단하다.

  • provider를 바꿀 가능성이 있는가?
  • 장애 시 fallback이 필요한가?
  • 사용량과 비용을 기록해야 하는가?
  • prompt/model version을 추적해야 하는가?
  • 테스트에서 실제 provider를 빼고 싶은가?

대부분의 실제 서비스는 모두 해당된다.

실전 팁

  • gateway interface는 업무 언어로 만든다. ChatCompletionRequest 같은 provider 용어를 노출하지 않는다.
  • usage record는 실패도 기록한다.
  • prompt version은 문자열로라도 command에 포함한다.
  • provider raw error는 내부 error type으로 바꾼다.
  • fake gateway를 먼저 만들면 service 테스트가 쉬워진다.

위험 신호!

  • Controller에 ChatClient, prompt, provider key, model name이 함께 있다.
  • service가 provider raw response 구조를 안다.
  • AI 실패가 모두 500으로만 보인다.
  • usage recorder가 성공만 기록한다.
  • fallback 정책이 Controller마다 다르다.

확인 질문

확인 질문

  • AI Gateway의 핵심 책임은 무엇인가?
    • Spring AI 호출, 예외 변환, prompt/model version, 사용량 기록, fallback 경계를 관리하는 것이다.
  • 내부 서비스가 Spring AI 타입에 직접 의존하지 않으면 무엇이 좋은가?
    • 테스트, provider 교체, fallback, 버전 변경에 덜 흔들린다.
  • usage는 왜 실패도 기록해야 하는가?
    • timeout, rate limit, invalid output도 비용과 장애 분석의 핵심 정보이기 때문이다.
  • gateway가 너무 커지면 어떻게 해야 하는가?
    • prompt registry, context assembler, usage recorder, exception translator, fallback policy로 분리한다.

학습 연결

참고 문서