GPU는 새로운 데이터베이스다
요약
과거 데이터베이스 운영의 시행착오가 현재 GPU 인프라 관리 상황과 유사하다는 점을 지적합니다. GPU는 단순한 고속 CPU가 아닌 근본적으로 다른 계산 모델을 가진 상태 유지(Stateful) 요소이며, 이를 제대로 이해하지 못하면 막대한 비용과 운영상의 병목 현상을 겪게 됩니다.
핵심 포인트
- GPU는 단순한 CPU의 확장판이 아닌, 완전히 다른 계산 모델을 가진 인프라 요소임
- 현재의 GPU 운영 상황은 20년 전 데이터베이스 운영 초기 단계의 혼란과 매우 흡사함
- GPU를 상태 유지(Stateful) 요소이자 핵심 병목 현상으로 인식하고 관리 패턴을 정립해야 함
- GPU 인프라의 특성을 정확히 파악하고 최적화하는 팀이 강력한 인프라 우위를 점할 것임
_ 20년 전, 팀들은 데이터베이스를 대규모로 운영하는 방법을 전혀 몰랐습니다. 패턴이 확립되기 전까지 가능한 모든 실수를 저질렀습니다. 우리는 현재 GPU 인프라와 관련하여 동일한 상황에 처해 있으며, 동일한 실수를 더 빠르게 반복하고 있습니다. 더 많은 기사는 이 사이트에서 확인하세요. _ 2004년에 유의미한 규모로 웹 애플리케이션 (Web Application)을 운영하고 있었다면, 가장 큰 인프라 문제는 데이터베이스 (Database)였습니다. 애플리케이션 서버 (Application Servers)가 아니었습니다. 그것들은 상태가 없는 (Stateless) 것이었기에 더 추가할 수 있었습니다. 데이터베이스는 모든 것이 의존하는 유일한 상태 유지 (Stateful) 요소였으며, 수평적 확장 (Scale Horizontally)이 불가능했고, 운영 비용이 많이 들었으며, 이를 잘 운영하는 법을 아는 사람이 거의 없었습니다. 팀들은 온갖 실수를 저질렀습니다. 애플리케이션에는 로직을 너무 많이 넣고 데이터베이스에는 충분히 넣지 않았습니다. 반대로 데이터베이스에는 너무 많은 것을 넣고 애플리케이션에는 충분히 넣지 않기도 했습니다. 인덱스 (Index)를 올바르게 설정하지 않았습니다. 캐시 (Cache)를 올바르게 사용하지 않았습니다. 수직적 확장 (Scale Vertically)을 할 수 없을 때까지 계속하다가, 그제야 급하게 샤딩 (Shard)을 시도했습니다. 자신들의 쿼리 계획 (Query Plans)이 어떻게 생겼는지조차 몰랐습니다. 데이터베이스가 작동을 멈출 때까지 블랙박스 (Black Box)처럼 취급하다가, 그것이 블랙박스가 아니라는 사실을 고통스러운 경험을 통해 배웠습니다. 이후 10년 동안 패턴이 확립되었습니다. 커넥션 풀링 (Connection Pooling), 읽기 복제본 (Read Replicas), 쿼리 분석 (Query Analysis), 적절한 인덱싱 전략 (Indexing Strategy), 캐시 계층 (Cache Layers) 등이 그것입니다. 지식은 보편화되었습니다. 도구들은 개선되었습니다. 관리형 데이터베이스 서비스 (Managed Database Services)가 대부분의 복잡성을 추상화했습니다. 오늘날 유능한 팀은 특별한 전문 지식 없이도 상당한 규모의 데이터베이스를 운영할 수 있습니다. 우리는 이제 2026년에 GPU 인프라와 관련하여 동일한 위치에 서 있습니다. GPU는 새로운 데이터베이스입니다. 즉, 모든 AI가 의존하고 있지만, 사람들이 기대하는 방식으로 확장되지 않으며, 이를 운영하는 대다수의 팀이 잘못 다루고 있고, 아직 패턴이 확립되지 않은, 비싸고 상태를 유지하며 이해도가 낮은 병목 현상 (Bottleneck)입니다. 이를 가장 먼저 파악하는 팀은 추격하기 매우 어려운 인프라 우위를 점하게 될 것입니다.
그렇지 못한 팀들은 2004년에 모든 이들이 데이터베이스(Database)를 다루며 저질렀던 것과 똑같은 실수를 향후 5년 동안 더 빠르고 더 비싼 비용을 치르며 반복하게 될 것입니다.
왜 GPU가 단순히 빠른 CPU가 아닌가: 대부분의 팀이 GPU 인프라를 다룰 때 저지르는 첫 번째 실수는 GPU를 매우 빠른 CPU로 취급하는 것입니다. 하지만 GPU는 그렇지 않습니다. GPU는 근본적으로 다른 계산 모델 (Computational Model)이며, 이 모델과 대부분의 사람들이 GPU를 사용하는 방식 사이의 불일치에서 대부분의 낭비가 발생합니다.
CPU는 지연 시간 (Latency)에 최적화되어 있습니다. 즉, 단일 복잡한 작업을 가능한 한 빨리 완료하는 데 집중합니다. CPU는 소수의 강력한 코어 (Core), 대용량 캐시 (Cache), 정교한 분기 예측 (Branch Prediction), 그리고 비순차적 실행 (Out-of-order Execution) 기능을 갖추고 있습니다. CPU는 순차적 로직 (Sequential Logic), 조건부 분기 (Conditional Branching), 그리고 각 단계가 이전 단계의 결과에 의존하는 작업에 능숙합니다.
반면 GPU는 처리량 (Throughput)에 최적화되어 있습니다. 즉, 엄청난 수의 단순한 작업을 동시에 완료하는 데 집중합니다. GPU는 수천 개의 더 작고 단순한 코어를 가지고 있습니다. GPU는 대량의 데이터에 병렬로 적용되는 동일한 연산에 능숙합니다. 하지만 순차적인 작업, 복잡한 분기가 포함된 작업, 그리고 계산 중간에 데이터를 다시 CPU로 옮겨야 하는 모든 작업에는 취약합니다.
실질적인 결과는 다음과 같습니다. 작업을 배치 (Batching) 처리하지 않는 GPU는 대부분 유휴 (Idle) 상태로 머물게 됩니다. 프로덕션 환경에서 AI 추론 (Inference)을 배포하는 팀들이 가장 흔히 사용하는 패턴인 '요청 하나가 들어오면, 모델을 실행하고, 결과를 반환한 뒤, 다음 요청을 기다리는 방식'은 GPU의 실제 용량 중 아주 작은 부분만을 사용합니다. 이때 GPU 사용률 (Utilisation) 수치는 합리적으로 보일 수 있지만, GPU의 실제 계산 처리량 (Computational Throughput)은 형편없습니다. 이는 모든 쿼리마다 새로운 연결을 열고, 실행한 뒤, 연결을 닫아버리는 데이터베이스와 같습니다. 기술적으로는 작동하지만, 시스템을 어떻게 사용해야 하는지에 대해서는 완전히 놓치고 있는 것입니다.
대부분의 팀이 하는 방식: 요청 하나당 추론 한 번
GPU 사용률은 20-40%처럼 보이지만, 처리량은 저조함
async def handle_inference_request ( prompt : str ) -> str :
result = model .
generate ( prompt ) # 결과를 반환받을 때까지 GPU는 대부분 유휴 (idle) 상태임 # 실제로 일어나야 하는 일: 동적 배치 (dynamic batching) # 여러 요청을 그룹화하여 함께 처리함
class InferenceBatcher :
def init ( self , model , max_batch_size : int = 32 , max_wait_ms : int = 50 ):
self . model = model
self . max_batch_size = max_batch_size
self . max_wait_ms = max_wait_ms
self . queue : asyncio . Queue = asyncio . Queue ()
async def infer ( self , prompt : str ) -> str :
future = asyncio . Future ()
await self . queue . put (( prompt , future ))
return await future
async def _batch_worker ( self ):
while True :
batch = []
deadline = asyncio . get_event_loop (). time () + ( self . max_wait_ms / 1000 )
# 배치가 가득 차거나 마감 시간(deadline)이 지날 때까지 요청을 수집함
while len ( batch ) < self . max_batch_size :
timeout = deadline - asyncio . get_event_loop (). time ()
if timeout <= 0 :
break
try :
item = await asyncio . wait_for ( self . queue . get (), timeout = timeout )
batch . append ( item )
except asyncio . TimeoutError :
break
if not batch :
continue
prompts = [ item [ 0 ] for item in batch ]
futures = [ item [ 1 ] for item in batch ]
# 단일 GPU 호출로 모든 요청을 동시에 처리함
results = self . model . generate_batch ( prompts )
for future , result in zip ( futures , results ):
future . set_result ( result )
동적 배치 (Dynamic batching)는 GPU 추론 (inference)에서의 커넥션 풀링 (connection pooling)입니다. 비용이나 처리량 (throughput)을 고려한다면 이는 선택 사항이 아닙니다. 또한 초기 웹 애플리케이션들이 커넥션 풀링을 구현하지 않았던 것과 같은 이유로, 대부분의 자체 제작 추론 배포 환경에서도 기본적으로 구현되어 있지 않습니다. 즉, 한계에 부딪히기 전까지는 팀들이 그것이 필요하다는 사실을 알지 못했기 때문입니다.
아무도 가르쳐주지 않는 메모리 계층 구조 (memory hierarchy)
GPU 메모리는 CPU 메모리와 다릅니다. 이 차이를 이해하는 것이 작동하는 시스템과 작동하지 않는 시스템의 차이이며, 관리 가능한 추론 비용과 그렇지 못한 비용의 차이입니다. GPU는 자체적인 온디바이스 메모리(on-device memory)인 VRAM을 가지고 있습니다. VRAM은 빠르고, 한정적이며, 비쌉니다.
80GB의 VRAM을 갖춘 GPU는 매우 비싼 GPU입니다. 실행하려는 모델은 반드시 VRAM 내에 들어갈 수 있어야 합니다. 만약 들어가지 않는다면, 양자화 (quantization)와 같은 기술을 사용하여 모델 크기를 줄이거나 여러 개의 GPU에 분산시킬 수 있지만, 치명적인 성능 저하 없이 단순히 시스템 RAM (system RAM)으로 넘길 수는 없습니다. CPU RAM과 GPU VRAM 사이의 대역폭 (bandwidth)은 VRAM 대역폭보다 수십 배 더 느립니다. 모델이 "4비트로 양자화되었다"는 말을 들을 때 그 이유가 바로 이것입니다. 4비트 양자화는 메모리 점유율 (memory footprint)을 대략 절반으로 줄여주며, 이는 모델이 하나의 GPU에 들어갈 수 있는지 여부를 결정짓는 차이가 됩니다.
GPU 내부에는 연산 속도를 결정하는 메모리 계층 구조 (memory hierarchy)가 존재합니다. KV 캐시 (KV cache) — 대화에서 이미 처리된 토큰들에 대해 캐싱된 어텐션 (attention) 연산 결과 — 는 VRAM에 상주하며 시퀀스 길이 (sequence length)에 따라 증가합니다. KV 캐시를 관리하는 것은 LLM 서빙 (serving)에서 가장 중대한 성능 결정 요소 중 하나이며, 대부분의 팀은 긴 컨텍스트 (long contexts)에서 메모리 부족 (out-of-memory) 오류가 발생하기 전까지는 이에 대해 전혀 고려하지 않습니다.
KV 캐시 관리: 관리하지 않을 경우 발생하는 현상
각 새로운 토큰은 전체 컨텍스트에 대해 어텐션을 재생성함
비용은 시퀀스 길이에 대해 O(n²)임
vLLM 및 유사 시스템이 다르게 처리하는 방식:
PagedAttention은 KV 캐시를 고정된 크기의 블록 (blocks) 단위로 관리함
이는 OS의 가상 메모리 페이징 (virtual memory paging)과 유사함
이를 통해 다음이 가능함:
1. 동일한 접두사 (prefix)를 가진 요청 간에 KV 캐시 공유
2. 더 나은 메모리 활용 (내부 단편화 (internal fragmentation) 방지)
3.
최악의 경우를 대비한 메모리 사전 할당 없이 가변 길이 시퀀스 (variable-length sequences) 처리하기
from vllm import LLM, SamplingParams
# vLLM은 KV 캐시 관리를 자동으로 처리합니다.
# 이것은 단순한 최적화가 아닙니다. 일반적인 워크로드에서
# 단순한 구현 방식(naive implementations) 대비 2~4배의 처리량(throughput) 향상을 가져옵니다.
llm = LLM (
model = "meta-llama/Llama-3-8b-instruct",
gpu_memory_utilization = 0.90, # 10%의 여유 공간을 남겨둠
max_model_len = 8192, # 최대 시퀀스 길이
enable_prefix_caching = True, # 공통 접두사(시스템 프롬프트) 캐싱 활성화
tensor_parallel_size = 1, # 이 모델에 사용할 GPU 개수
)
sampling_params = SamplingParams (
temperature = 0.7,
max_tokens = 512,
)
# 접두사 캐싱(Prefix caching)은 시스템 프롬프트가 한 번만 계산되고
# 이후의 모든 요청에 대해 캐싱됨을 의미합니다. 이는 모든 추론에 사용되는
# 긴 시스템 프롬프트의 경우 매우 중요합니다.
outputs = llm.generate(prompts, sampling_params)
실제 운영 환경에서 LLM을 서비스하는 대부분의 팀은 PagedAttention을 사용하지 않고 있습니다. 그들은 단편화(fragmentation)와 중복 계산으로 인해 GPU 메모리의 50%에서 70%를 낭비하는 단순한 추론 구현 방식(naive inference implementations)을 사용하고 있습니다. 비용 차이는 미미한 수준이 아닙니다.
모두가 잘못 질문하고 있는 스케일링(scaling) 문제
팀의 AI 인프라가 부하를 견디지 못하기 시작할 때, 첫 번째 질문은 거의 항상 다음과 같습니다: "GPU를 더 추가해야 할까요?"
이는 잘못된 시점에 던지는 잘못된 질문입니다. 2008년에 데이터베이스가 어려움을 겪을 때 "데이터베이스 서버를 더 추가해야 할까요?"가 잘못된 첫 번째 질문이었던 것과 같은 이유입니다. 올바른 질문은 다음과 같습니다: "왜 현재의 GPU를 이토록 비효율적으로 사용하고 있는가?"
GPU 활용률(utilisation)이 60% 미만이라면, 그것은 거의 항상 배치 처리(batching) 문제입니다. 요청들이 GPU에 도달하기 전에 효율적으로 그룹화되지 않고 있는 것입니다. GPU를 더 추가하면 활용률 수치는 절반으로 떨어질 수 있으며, 이는 60%의 용량으로 작동하던 하나의 세트 대신 30%의 용량으로 작동하는 두 배의 인프라를 갖게 된다는 것을 의미합니다. 비용은 두 배로 늘어났지만, 문제는 아무것도 해결되지 않았습니다.
GPU 사용률(utilization)은 높지만 지연 시간(latency)이 여전히 나쁘다면, 이는 거의 항상 모델 크기(model sizing)의 문제입니다. 모델이 처리 중인 요청량에 비해 너무 큰 것입니다. 더 작은 양자화된 모델(quantized model)이나 다른 아키텍처(architecture)를 사용하면, 훨씬 적은 연산 비용으로 요청 지연 시간 요구 사항을 충족할 수 있습니다.
규모를 확장하기 전에 실제로 중요한 것을 측정하기
import time
import psutil
from prometheus_client import Histogram, Gauge, Counter
# 이러한 메트릭(metrics)들은 문제가 실제로 어디에 있는지 알려줍니다.
GPU_UTILISATION = Gauge('gpu_utilisation_percent', 'GPU 연산 사용률', ['device_id'])
GPU_MEMORY_USED = Gauge('gpu_memory_used_bytes', '사용 중인 GPU VRAM', ['device_id'])
BATCH_SIZE = Histogram('inference_batch_size', '배치당 처리된 요청 수', buckets=[1, 2, 4, 8, 16, 32, 64])
TOKENS_PER_SECOND = Histogram('inference_tokens_per_second', '초당 토큰 추론 처리량', buckets=[10, 25, 50, 100, 200, 400, 800])
TIME_TO_FIRST_TOKEN = Histogram('inference_ttft_seconds', '요청부터 첫 번째 토큰 생성까지의 시간', buckets=[.05, .1, .25, .5, 1, 2, 5])
REQUEST_QUEUE_DEPTH = Gauge('inference_queue_depth', 'GPU를 기다리는 요청 수')
class InstrumentedInferenceServer:
async def infer(self, prompts: list[str]) -> list[str]:
BATCH_SIZE.observe(len(prompts))
REQUEST_QUEUE_DEPTH.set(self.queue.qsize())
start = time.perf_counter()
results = await self._run_inference(prompts)
duration = time.perf_counter() - start
total_tokens = sum(len(r.split()) for r in results)
TOKENS_PER_SECOND.observe(total_tokens / duration)
return results
GPU 사용률(utilization) 및 VRAM 사용량과 함께 배치 크기(batch size), 큐 깊이(queue depth), 초당 토큰 수(tokens per second), 그리고 첫 번째 토큰 생성 시간(time-to-first-token)을 함께 볼 수 있게 되면, "GPU가 더 필요한가?"라는 질문에 대한 답은 거의 저절로 나오게 됩니다.
보통 그 답은 "아니요, 배치(batch)를 더 잘 처리해야 합니다" 또는 "아니요, 더 작은 모델을 사용해야 합니다"이며, 스케일링(scaling)은 불필요한 것으로 드러납니다. 아무도 계획하지 않았던 콜드 스타트(cold start) 문제도 있습니다. 데이터베이스(Databases)는 시작하는 데 몇 초가 걸립니다. GPU 추론(inference) 서버는 몇 분이 걸립니다. 예기치 않게 재시작된 데이터베이스는 대부분의 경우 30초 이내에 복구됩니다. 재시작된 LLM 추론 서버는 요청을 처리하기 전에 스토리지(storage)에서 VRAM으로 모델 가중치(model weights)를 로드해야 합니다. 4비트 양자화(4-bit quantization)로 저장된 70B 파라미터 모델은 대략 35GB입니다. 일반적인 클라우드 스토리지 대역폭(bandwidth)에서 35GB를 네트워크 스토리지에서 VRAM으로 로드하는 데는 양호한 조건에서도 몇 분이 소요됩니다. 이는 장애 상황의 역학(incident dynamics)을 완전히 바꿉니다. 데이터베이스의 일시적 오류(blip)는 짧은 중단에 불과합니다. GPU 서버의 일시적 오류는 영향을 받는 모든 인스턴스에 대해 몇 분간의 서비스 중단(outage)을 의미합니다. 상태가 없는(stateless) 애플리케이션 서버에는 잘 작동하고 데이터베이스에는 적절히 작동하는 오토스케일링(Autoscaling)은, 새로운 인스턴스가 준비되는 데 너무 오래 걸리기 때문에 GPU 추론에는 제대로 작동하지 않습니다. 이 문제를 해결한 팀들은 웜 풀(warm pools)을 운영합니다. 즉, 모델이 이미 로드된 상태로 유휴(idle) 상태로 대기하며, 아직 도착하지 않은 트래픽을 기다리는 GPU 인스턴스들입니다. 이는 낭비처럼 느껴집니다. 하지만 몇 분 동안 지속되는 지연 시간(latency) 폭증 없이 트래픽 급증(traffic spikes)을 처리할 수 있는 유일한 방법입니다. # 웜 풀 전략을 사용한 Kubernetes 배포 # 최소 복제본(Minimum replicas)을 유지하여 트래픽이 적을 때도 인스턴스를 웜(warm) 상태로 유지 # 이것은 비용이 듭니다. 대안은 콜드 스타(cold st)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기