이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 왜 Controller나 Service에서 LLM SDK를 바로 호출하면 안 되는가?
- provider SDK 응답을 내부 DTO로 감싸야 하는 이유는 무엇인가?
- LLM 호출 경계에서는 어떤 로그와 예외 변환이 필요한가?
- provider 교체와 fallback을 고려한 백엔드 구조는 어떻게 생겼는가?
개요
LLM API는 외부 시스템 호출이다. OpenAI, Anthropic, Gemini, Azure OpenAI 같은 provider는 각자 SDK, 인증, rate limit, 응답 형식, 에러 코드를 가진다. 운영 서비스는 이 차이를 도메인 서비스 전체에 흩뿌리면 안 된다.
백엔드에서는 일반적으로 Controller -> Service -> LlmClient Port -> Provider Adapter 구조로 감싼다. 이 구조는 테스트, provider 교체, fallback, 관측성, 예외 변환을 한곳에 모으는 데 도움이 된다.
왜 백엔드 개발자에게 중요한가
LLM 샘플 코드는 대부분 빠른 시작을 위해 SDK를 직접 호출한다. 하지만 운영 코드에서 Controller가 provider SDK에 직접 의존하면 다음 문제가 생긴다.
- provider 에러 코드가 API 응답 정책에 섞인다.
- timeout과 retry가 기능마다 다르게 구현된다.
- fallback model을 넣기 어렵다.
- 테스트에서 실제 provider 호출을 막기 어렵다.
- token usage와 latency 관측이 누락된다.
- structured output 검증이 호출부마다 중복된다.
LLM은 마법의 함수 호출이 아니라 느리고 비싼 외부 의존성이다. 경계를 만들어야 한다.
핵심 원리
Adapter 경계에서 할 일은 다음과 같다.
- 내부 요청 DTO를 provider 요청으로 변환한다.
- provider 응답을 내부 응답 DTO로 변환한다.
- provider별 에러를 내부 예외로 변환한다.
- timeout, retry, rate limit, trace를 적용한다.
- token usage와 finish reason을 기록한다.
- raw response 저장 여부를 정책에 따라 제한한다.
서비스 계층은 “어느 provider를 썼는가”보다 “요약을 요청했고, 성공/실패/거절/검증 실패 중 무엇이 발생했는가”를 알아야 한다.
코드로 이해하기
다음은 Spring Boot에서 provider 경계를 감싸는 단순 구조다.
public record LlmGenerateCommand(
String promptVersion,
String modelProfile,
String userInput
) {
}
public record LlmGenerateResult(
String text,
String model,
long inputTokens,
long outputTokens,
String finishReason
) {
}
public interface LlmClient {
LlmGenerateResult generate(LlmGenerateCommand command);
}서비스는 이 포트만 의존한다.
@Service
public class TicketSummaryService {
private final LlmClient llmClient;
private final PromptRenderer promptRenderer;
public TicketSummaryService(LlmClient llmClient, PromptRenderer promptRenderer) {
this.llmClient = llmClient;
this.promptRenderer = promptRenderer;
}
public TicketSummary summarize(String ticketBody) {
String prompt = promptRenderer.render(
"ticket-summary:v1",
Map.of("ticketBody", ticketBody)
);
LlmGenerateResult result = llmClient.generate(
new LlmGenerateCommand("ticket-summary:v1", "fast-summary", prompt)
);
return TicketSummary.from(result.text());
}
}provider adapter는 외부 SDK와 내부 계약을 연결한다.
@Component
public class OpenAiLlmClient implements LlmClient {
private final OpenAiSdk sdk;
private final LlmClientProperties properties;
public OpenAiLlmClient(OpenAiSdk sdk, LlmClientProperties properties) {
this.sdk = sdk;
this.properties = properties;
}
@Override
public LlmGenerateResult generate(LlmGenerateCommand command) {
long started = System.nanoTime();
try {
OpenAiResponse response = sdk.responsesCreate(
command.modelProfile(),
command.userInput(),
properties.timeout()
);
return new LlmGenerateResult(
response.outputText(),
response.model(),
response.usage().inputTokens(),
response.usage().outputTokens(),
response.finishReason()
);
} catch (OpenAiRateLimitException ex) {
throw new LlmRateLimitedException("LLM provider rate limited", ex);
} catch (OpenAiTimeoutException ex) {
throw new LlmTimeoutException("LLM provider timeout", ex);
} finally {
long elapsedMs = (System.nanoTime() - started) / 1_000_000;
log.info("llm.generate promptVersion={} modelProfile={} elapsedMs={}",
command.promptVersion(), command.modelProfile(), elapsedMs);
}
}
}실제 SDK 타입은 provider마다 다르지만 구조는 같다. 외부 응답을 내부 DTO로 바꾸고, 외부 예외를 내부 예외로 바꾼다.
백엔드 설계 판단
Adapter 경계를 만들 때는 다음을 정한다.
- 내부 모델 profile 이름을 provider model 이름과 분리할 것인가?
- provider 응답 원문을 저장할 것인가?
- token usage를 어느 테이블이나 metric으로 남길 것인가?
- refusal과 validation failure를 비즈니스 실패로 볼 것인가, 시스템 실패로 볼 것인가?
- fallback provider는 같은 interface로 숨길 것인가, orchestration service에서 선택할 것인가?
개인 프로젝트는 단일 provider adapter로 시작해도 된다. 기업 운영에서는 provider routing, fallback, quota, 비용 보고가 필요해질 수 있으므로 내부 포트는 초기에 잡는 편이 낫다.
장애 상황과 대응
provider 장애가 발생하면 adapter에서 실패 유형을 분류해야 한다.
- 429: rate limit 또는 quota 초과
- 5xx: provider 일시 장애
- timeout: 네트워크 또는 provider 지연
- validation failure: 응답 형식 문제
- refusal: 정책 또는 안전성 거절
이 분류가 없으면 모든 장애가 500으로 뭉개진다. API 응답, retry, fallback, 알림 기준이 달라지므로 예외 타입을 나누는 것이 중요하다.
인프라 협업 포인트
인프라팀과는 다음을 공유한다.
- provider별 outbound 도메인과 네트워크 정책
- API key 저장 위치와 rotation 방식
- timeout과 connection pool 설정
- provider별 quota와 rate limit
- 장애 시 circuit breaker와 fallback 정책
- token usage metric 수집 방식
LLM provider는 외부 SaaS 의존성이다. 보안과 네트워크 경계를 인프라팀이 이해할 수 있게 문서화해야 한다.
개인 프로젝트 최소 기준
- SDK 호출을 별도 client 클래스로 감싼다.
- timeout과 내부 예외 타입을 둔다.
- prompt version, model, elapsed time을 로그에 남긴다.
- API key를 환경 변수나 secret store에서 읽는다.
기업 운영 수준 기준
- provider adapter를 포트로 분리한다.
- provider별 rate limit과 quota를 metric으로 본다.
- fallback provider나 fallback model을 eval한 뒤 등록한다.
- 예외 타입별 대시보드와 알림을 둔다.
- secret rotation과 audit log를 운영한다.
실전 팁
- provider model 이름을 서비스 코드에 직접 흩뿌리지 않는다.
fast-summary,high-quality-reasoning같은 내부 profile로 감싼다. - raw response 저장은 기본값으로 켜지 않는다. 필요한 필드만 저장한다.
- adapter에서 token usage를 반드시 수집한다. 나중에 비용 분석의 출발점이 된다.
- provider SDK 업데이트는 작은 변경처럼 보여도 응답 구조와 에러 처리가 바뀔 수 있으므로 integration test를 둔다.
위험 신호!
- Controller에서 LLM SDK를 직접 호출한다.
- provider 에러 코드를 그대로 사용자에게 내려준다.
- API key가 application.yml이나 노트에 평문으로 있다.
- token usage를 기록하지 않는다.
- provider 교체를 하려면 서비스 로직 전체를 수정해야 한다.
확인 질문
확인 질문
- LLM provider adapter가 맡아야 하는 역할은 무엇인가?
- provider 요청/응답 변환, 예외 변환, timeout, retry, logging, usage 기록을 맡는다.
- 내부 model profile을 쓰는 이유는 무엇인가?
- provider model 이름 변경과 라우팅 정책을 서비스 로직에서 분리하기 위해서다.
- provider 원본 응답을 그대로 노출하면 어떤 문제가 생기는가?
- provider 변경에 내부 API와 서비스 로직이 강하게 결합된다.
Spring AI 적용 연결
- Spring AI에서는 provider SDK를 직접 감싸기보다
ChatClient와 별도 AI Gateway 경계로 서비스 정책을 분리한다. 구현 관점은 03. Spring Boot 계층 설계와 AI Gateway에서 이어서 본다. - provider 선택과 model profile 설정은 04. ChatClient Model Auto Configuration을 함께 확인한다.