LLM 로컬 실행: 언제 셀프호스팅을 해야 할까

1. 개요

클라우드 LLM 제공자의 API를 호출하는 것이 애플리케이션에 AI를 추가하는 가장 빠른 방법입니다. 점점 더 많은 팀이 자신의 인프라에서 모델을 직접 실행하고 싶어합니다. 이는 노트북, 내부 서버 또는 임차 GPU일 수 있습니다.

이 글에서는 “LLM 로컬 실행”이 실제로 무엇인지, 그리고 이 추세가 왜 주목받는지 알아봅니다. 그 후 로컬 모델을 구축하고, Spring Boot 애플리케이션에서 호출하며, 통합 부분에 대한 테스트를 작성합니다. 마지막으로 셀프호스팅을 해야 할 때와 하지 말아야 할 때를 판단하는 기준을 제시합니다.

2. “LLM 로컬 실행”은 무엇일까?

2.1. 세 가지 핵심 컴포넌트

셀프호스팅 LLM 시스템은 일반적으로 세 계층으로 구성됩니다:

  • 모델 가중치: 모델의 가중치 파일입니다. 보통 공개 저장소에서 오픈 가중치 모델을 다운로드합니다.
  • Inference 런타임: 가중치를 RAM 또는 VRAM에 로드하고 토큰을 생성하는 소프트웨어입니다. 예를 들어 Ollama, llama.cpp 또는 vLLM이 있습니다.
  • API 계층: 애플리케이션이 호출할 HTTP 엔드포인트입니다. 많은 런타임이 OpenAI 표준과 호환되는 API를 제공하므로 클라이언트 코드는 거의 변경할 필요가 없습니다.

애플리케이션은 세 번째 계층만 봅니다. 이것이 중요한 점입니다. 나중에 로컬과 클라우드 사이를 전환할 때 비즈니스 로직을 다시 작성할 필요가 없기 때문입니다.

2.2. 양자화와 메모리

“내 머신에서 실행할 수 있을까”를 결정하는 핵심 요소는 메모리입니다. 가중치에 필요한 메모리를 다음과 같이 추정할 수 있습니다:

가중치 메모리 ≈ 파라미터 수 × 파라미터당 바이트 수

16-bit (FP16): 2 바이트 / 파라미터
8-bit:         1 바이트 / 파라미터
4-bit:       ~0.5 바이트 / 파라미터

가중치 외에도 KV 캐시를 위한 공간이 필요합니다. 이 부분은 컨텍스트 길이와 병렬로 실행되는 요청 수에 따라 증가합니다. 양자화는 16비트에서 4비트로 수치 정밀도를 낮춰 모델 크기를 대폭 줄이는 기법입니다. 대신 응답 품질이 조금 떨어집니다. 분류, 요약 또는 정보 추출과 같은 많은 작업에서는 이 정도의 품질 저하가 일반적으로 허용됩니다.

3. 왜 LLM 셀프호스팅이 주목받을까?

주요 이유는 네 가지입니다:

  1. 프라이버시 및 규정 준수: 고객 데이터, 계약, 내부 소스 코드가 당신의 인프라를 떠나지 않습니다.
  2. 예측 가능한 비용: 클라우드 API는 토큰 기준으로 요금을 청구합니다. 부하가 크고 일정한 경우, GPU 머신의 고정 비용이 더 저렴할 수 있습니다.
  3. 지연시간 및 오프라인 실행 능력: 인터넷 왕복 없음, 제3자의 레이트 제한에 종속되지 않음, 에어갭 환경에서 실행 가능합니다.
  4. 점점 좋아지는 오픈 가중치 모델들: 작은 모델도 많은 특화 작업에 충분히 유용합니다. 자신의 도메인에 맞게 미세 조정할 수도 있습니다.

참고: “로컬”이 꼭 당신의 노트북을 의미하지는 않습니다. 프라이빗 VPC 내의 GPU 클러스터도 셀프호스팅으로 간주됩니다. 핵심은 모델과 데이터를 제어한다는 것입니다.

4. Ollama로 로컬 모델 구축하기

Ollama는 시작하기에 좋은 선택입니다. 런타임을 패키징하고, 모델을 관리하며, HTTP API를 제공합니다. Docker Compose로 실행할 수 있습니다:

services:
  ollama:
    image: ollama/ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama-data:/root/.ollama
    restart: unless-stopped

volumes:
  ollama-data:

ollama-data 볼륨은 로드된 모델을 유지합니다. 덕분에 컨테이너가 다시 시작할 때마다 다시 로드할 필요가 없습니다. 포트 11434는 Ollama API의 기본 포트입니다.

다음으로, 모델을 로드하고 테스트해봅니다:

docker compose up -d
docker compose exec ollama ollama pull llama3.2

curl http://localhost:11434/api/chat -d '{
  "model": "llama3.2",
  "messages": [{"role": "user", "content": "요약: 배송이 3일 지연되었고, 고객이 환불을 원합니다."}],
  "stream": false
}'

llama3.2를 당신의 하드웨어에 맞는 다른 모델로 바꿀 수 있습니다. "stream": false인 경우, 결과는 다음 형태의 단일 JSON으로 반환됩니다:

{
  "model": "llama3.2",
  "message": { "role": "assistant", "content": "3일 지연된 배송으로 인해 고객이 환불을 요청했습니다." },
  "done": true,
  ...
}

머신에 NVIDIA GPU가 있으면, NVIDIA Container Toolkit을 설치하고 컨테이너에 GPU를 할당해야 합니다. 그렇지 않으면 모델은 CPU에서 실행됩니다. 작동하지만 훨씬 느립니다.

5. Spring Boot에서 로컬 모델 호출하기

이 섹션의 예제는 실제 상황입니다: 고객 지원 메시지에 대한 회신 초안을 작성하는 것입니다. 메시지 데이터는 완전히 내부 인프라에 남아 있습니다.

5.1. 의존성 및 설정

Spring Web에 포함된 RestClient만 필요합니다. 추가 AI 라이브러리는 필요 없습니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

application.properties에서 설정을 선언합니다:

local-llm.base-url=http://localhost:11434
local-llm.model=llama3.2
local-llm.timeout=60s

타임아웃은 넉넉하게 설정해야 합니다. 로컬에서 실행하는 모델, 특히 CPU에서는 긴 응답을 생성하는 데 수십 초가 걸릴 수 있습니다.

5.2. Properties와 RestClient

@ConfigurationProperties(prefix = "local-llm")
public record LocalLlmProperties(String baseUrl, String model, Duration timeout) {}
@Configuration
@EnableConfigurationProperties(LocalLlmProperties.class)
public class LlmClientConfig {

    @Bean
    RestClient ollamaRestClient(RestClient.Builder builder, LocalLlmProperties props) {
        HttpClient httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .build();
        JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
        factory.setReadTimeout(props.timeout());

        return builder
                .baseUrl(props.baseUrl())
                .requestFactory(factory)
                .build();
    }
}

여기 두 가지 타임아웃은 역할이 다릅니다. 짧은 연결 타임아웃(5초)은 Ollama가 아직 실행 중이 아닐 때 빠르게 감지하는 데 도움이 됩니다. 긴 읽기 타임아웃은 모델이 응답을 생성할 때까지 기다립니다.

5.3. DTO 및 서비스

public record ChatMessage(String role, String content) {}

public record ChatRequest(String model, List<ChatMessage> messages, boolean stream) {}

@JsonIgnoreProperties(ignoreUnknown = true)
public record ChatResponse(ChatMessage message, boolean done) {}
@Service
public class SupportReplyService {

    private static final String SYSTEM_PROMPT =
            "당신은 고객 지원 담당자입니다. 정중하고 간결하게 한국어로 답변해주세요.";

    private final RestClient ollama;
    private final String model;

    public SupportReplyService(RestClient ollamaRestClient, LocalLlmProperties props) {
        this.ollama = ollamaRestClient;
        this.model = props.model();
    }

    public String draftReply(String customerMessage) {
        ChatRequest request = new ChatRequest(model, List.of(
                new ChatMessage("system", SYSTEM_PROMPT),
                new ChatMessage("user", customerMessage)
        ), false);

        ChatResponse response = ollama.post()
                .uri("/api/chat")
                .contentType(MediaType.APPLICATION_JSON)
                .body(request)
                .retrieve()
                .body(ChatResponse.class);

        if (response == null || response.message() == null) {
            throw new IllegalStateException("LLM이 응답 내용을 반환하지 않았습니다");
        }
        return response.message().content().strip();
    }
}

draftReply의 처리 흐름은 다음과 같습니다:

  • 서비스는 톤을 안내하기 위해 system 메시지를 결합한 다음 고객 메시지를 user 메시지에 추가합니다.
  • stream이 false로 설정되어 있으므로 Ollama는 전체 응답을 하나의 응답으로 반환합니다.
  • @JsonIgnoreProperties는 처리 시간이나 토큰 수와 같이 서비스가 사용하지 않는 통계 필드를 무시합니다.
  • 빈 응답은 이해하기 어려운 NullPointerException을 노출하는 대신 명확한 예외로 변환됩니다.

6. 통합 테스트

단위 테스트를 실제 모델에 의존하게 하면 안 됩니다. 느리고 결과가 불확정적이기 때문입니다. 대신 MockRestServiceServer를 사용하여 서비스가 올바른 요청을 보내고 올바른 응답을 읽는지 확인하세요:

class SupportReplyServiceTest {

    private MockRestServiceServer server;
    private SupportReplyService service;

    @BeforeEach
    void setUp() {
        RestClient.Builder builder = RestClient.builder().baseUrl("http://localhost:11434");
        server = MockRestServiceServer.bindTo(builder).build();
        var props = new LocalLlmProperties("http://localhost:11434", "llama3.2", Duration.ofSeconds(60));
        service = new SupportReplyService(builder.build(), props);
    }

    @Test
    void givenCustomerMessage_whenDraftReply_thenReturnsAssistantContent() {
        server.expect(requestTo("http://localhost:11434/api/chat"))
                .andExpect(method(HttpMethod.POST))
                .andExpect(jsonPath("$.model").value("llama3.2"))
                .andExpect(jsonPath("$.stream").value(false))
                .andExpect(jsonPath("$.messages[1].content").value("내 주문은 어디에 있나요?"))
                .andRespond(withSuccess("""
                        {"model":"llama3.2",
                         "message":{"role":"assistant","content":"  우리는 당신의 주문을 확인하고 있습니다.  "},
                         "done":true,
                         "total_duration":123456}
                        """, MediaType.APPLICATION_JSON));

        String reply = service.draftReply("내 주문은 어디에 있나요?");

        assertThat(reply).isEqualTo("우리는 당신의 주문을 확인하고 있습니다.");
        server.verify();
    }
}

이 테스트는 네 가지를 확인합니다:

  • 요청이 올바른 엔드포인트 /api/chat으로 POST 메서드를 통해 전송됩니다.
  • 본문에 올바른 모델 이름과 stream=false가 있습니다.
  • 고객 메시지가 두 번째 메시지에 위치합니다.
  • 서비스는 total_duration 필드를 무시하고 응답의 앞뒤 공백을 제거합니다.

응답 품질을 확인하려면, 실제 모델과 함께 실행되는 별도의 테스트 세트를 분리해야 합니다(예: Testcontainers 사용). 해당 테스트 세트는 정기적으로 실행되어야 하며, 모든 빌드마다 실행되어야 하는 것은 아닙니다.

7. 로컬과 클라우드 간의 전환 가능성 유지

Ollama는 /v1/chat/completions에서 OpenAI 호환 엔드포인트도 지원합니다. 이 표준에 따라 클라이언트를 작성하면, 로컬과 클라우드 간의 전환은 프로필별 설정을 변경하는 것뿐입니다:

# application-local.properties
llm.base-url=http://localhost:11434/v1
llm.model=llama3.2

# application-cloud.properties
llm.base-url=https://api.your-provider.example/v1
llm.model=your-cloud-model
llm.api-key=${LLM_API_KEY}

LLM 호출을 별도 인터페이스 뒤에 배치해야 합니다(예: ReplyDrafter). 이렇게 하면 민감한 데이터에는 로컬 모델을 사용하고, 높은 품질이 필요한 작업에는 클라우드를 호출할 수 있으며, 비즈니스 로직이 모델이 어디서 실행 중인지 알 필요가 없습니다.

8. 언제 셀프호스팅을 해야 하고 하면 안 될까?

8.1. 셀프호스팅을 해야 할 때

  • 데이터가 민감하거나 규정상 제3자에게 데이터를 보낼 수 없습니다.
  • 부하가 크고 안정적이어서 토큰 기반 비용이 GPU 소유 비용을 초과하며, 운영 비용도 포함됩니다.
  • 티켓 분류, 청구서에서 필드 추출, 로그 요약과 같은 좁고 반복적인 작업입니다. 작은 모델은 이러한 작업에 능합니다.
  • 오프라인, 에어갭 환경 또는 엣지에서 실행해야 합니다.
  • 깊은 미세 조정이 필요하거나 시간이 지나도 결과가 안정적이도록 모델 버전을 완전히 제어해야 합니다.

8.2. 셀프호스팅을 하면 안 될 때

  • 복잡한 작업에 최고의 추론 능력이 필요합니다. 클라우드의 대형 모델은 여전히 이 부분에서 우수합니다.
  • 트래픽이 낮거나 불규칙합니다. GPU가 대부분 유휴 상태이면 토큰 기반 결제보다 비쌀 가능성이 높습니다.
  • 팀이 GPU 운영, 모니터링, 자동 확장 및 런타임 보안 업데이트에 경험이 없습니다.
  • 개념 검증 단계에 있습니다. 먼저 클라우드 API로 가치를 검증한 다음 비용을 최적화하세요.

비용 비교 공식:

셀프호스팅 비용 / 월 = 하드웨어(감가상각 또는 임차료) + 전기/호스팅 + 운영 인력 시간
클라우드 비용 / 월   = 월 토큰 수 × 토큰 가격

실제 로드 수준에서 셀프호스팅 비용이 명확히 낮을 때만 셀프호스팅을 고려해야 합니다. 
기대하는 로드 수준이 아니라요.

8.3. 자주하는 실수

참고: Ollama는 기본적으로 localhost에서만 수신 대기하며 인증이 없습니다. 포트를 네트워크에 노출하려면 인증이 있는 역프록시 뒤에 배치하세요. 인터넷에 직접 노출하지 마세요.

  • 감정적으로 모델을 평가: 몇 문장을 시도해서 괜찮아 보이면 프로덕션으로 올립니다. 정확한 작업에 대한 고정 평가 데이터 세트가 필요합니다.
  • KV 캐시 잊음: 모델은 VRAM에 맞지만 컨텍스트가 길거나 많은 요청이 병렬로 실행될 때 메모리가 부족합니다.
  • 타임아웃이 너무 짧음: 시작 후 첫 호출은 모델 로드를 기다려야 하므로 보통 매우 느립니다. 애플리케이션 시작 시 모델을 “워밍업”할 수 있습니다.
  • 프로덕션을 위해 잘못된 런타임 선택: Ollama는 개발 및 중간 부하에 매우 편리합니다. 많은 동시 사용자가 있는 경우 vLLM과 같이 처리량에 최적화된 런타임을 고려하세요.

9. 결론

LLM 셀프호스팅은 기본적으로 “더 저렴”하거나 “더 좋지” 않습니다. 이것은 거래입니다: 데이터 제어권, 예측 가능한 비용 및 낮은 지연시간을 얻지만, 운영 책임을 질 책임이 있고 보통 더 작은 모델을 수용해야 합니다.

이 글에서 Docker로 Ollama를 설정하고 RestClient로 Spring Boot에서 호출했습니다. 실제 모델이 없이도 통합에 대한 테스트를 작성했으며, 설정을 통해 로컬과 클라우드 간에 전환하는 방법을 알고 있습니다. 가장 현실적인 접근 방식은 일반적으로 둘을 결합하는 것입니다: 민감한 데이터와 반복 작업에는 로컬 모델, 최고 능력이 필요한 작업에는 클라우드입니다.

llmaiself-hostingspring-bootollama