2강은 이론 중심이었다. Continuous Batching, KV Cache, PagedAttention, RadixAttention 네 가지를 다뤘는데, 처음엔 별개의 기법 네 개인 줄 알았다. 다 듣고 나니 아니었다.

하나의 문제를 계속 파고드는 구조다. 앞 기법이 만든 새 문제를 다음 기법이 해결하는 식으로 이어진다. 그 흐름대로 정리한다.


1. 출발점: LLM은 답을 두 단계로 만든다

Prefill Decode

하는 일 입력 프롬프트를 한 번에 처리 토큰을 하나씩 순차 생성
병렬화 가능 불가능 (앞 토큰이 나와야 다음)
병목 연산 (compute-bound) 메모리 읽기 (memory-bound)

Prefill은 주문서를 읽는 단계다. 아무리 길어도 한눈에 훑을 수 있다. 토큰이 1000개면 1000개를 동시에 GPU에 밀어넣는다. 어텐션은 행렬 곱이라 병렬화가 잘 되니 연산 유닛이 꽉 차게 돌아간다.

Decode는 요리를 한 접시씩 내는 단계다. 앞접시가 나와야 다음을 만들 수 있다(autoregressive). 여기서 문제가 생긴다.

토큰 하나를 만들자고 모델 가중치 전체를 VRAM에서 읽어와야 한다. 15억 개짜리 모델이면 3GB를 읽어서 겨우 글자 하나를 뽑는다. 계산은 순식간인데 읽어오는 데 시간을 다 쓴다. 이게 memory-bound다.

창고에서 재료를 다 꺼내 왔는데 요리 하나만 만들고 다시 집어넣는 격이다.

게이밍 GPU도 LLM 추론이 빠른 이유가 여기 있다. decode는 연산 성능보다 메모리 대역폭이 성능을 좌우하는데, RTX 4090은 대역폭이 A40보다 오히려 높다.


2. 첫 번째 해법: 배칭

어차피 재료를 꺼내온 김에 4인분을 같이 만들면 된다. 읽기 비용은 똑같은데 처리량은 몇 배가 된다. 배칭이 강력한 이유다.

문제: Static Batching

옛날 방식은 요청을 한 묶음으로 고정하고, 그 묶음이 전부 끝나야 다음을 받았다.

  • 짧게 끝난 요청도 슬롯을 비우지 못함
  • 가장 긴 요청이 끝날 때까지 빈 자리를 계산하며 GPU 낭비
  • 대기 중인 요청은 그때까지 시작조차 못 함

LLM에서 특히 나쁜 이유는 출력이 얼마나 길지 미리 알 수 없기 때문이다. "안녕하세요" 한마디와 300줄짜리 코드가 같은 조에 묶일 수 있다.

해결: Continuous Batching

관리 단위를 요청 묶음에서 슬롯으로 바꾼다. 슬롯은 계속 살아있고 그 안의 요청만 교체된다. 하나가 끝나면 즉시 다음 요청이 그 자리에 들어간다.

매 토큰 생성 스텝마다 도는 4단계 루프가 전부다.

  1. 배치 상태 확인
  2. EOS 나온 요청 제거
  3. 메모리 여유가 있으면 대기열에서 새 요청 투입
  4. 배치 전체에 대해 딱 1토큰 forward pass

수십 ms마다 반복된다. iteration-level scheduling이라 부르고, Orca 논문(OSDI 2022)에서 처음 제안됐다.

효과

처리량과 평균 지연시간이 동시에 개선된다. 보통 이 둘은 트레이드오프인데 여기선 둘 다 좋아진다.

이유는 static batching의 빈 슬롯이 누구에게도 이득이 아니었기 때문이다. 처리량에도 손해고 대기 요청의 지연도 늘렸다. 순수한 낭비를 제거한 거라 양쪽이 같이 개선된다. 트레이드오프를 조정한 게 아니라 비효율을 걷어낸 결과다.

단, 개선폭은 워크로드에 따라 다르다. 출력 길이 편차가 클수록 이득이 크다.


3. 새 문제: 메모리가 발목을 잡는다

효과 슬라이드의 마지막 줄이 복선이었다. "메모리가 허용하는 한 배치 크기를 조절."

슬롯을 빈틈없이 채워도, 슬롯을 몇 개나 둘 수 있느냐는 메모리가 정한다. 위 루프 3단계의 "메모리 여유가 있으면"이 바로 이 제약이다.

그럼 그 메모리를 뭐가 잡아먹고 있나?

KV Cache

새 토큰을 만들 때 어텐션은 이전 토큰들의 K(Key), V(Value)가 전부 필요하다.

  • K: 나는 이런 정보를 갖고 있다는 색인
  • V: 실제 내용물

핵심은 K와 V가 그 토큰 자신에게서만 나온다는 점이다. t1의 K, V는 뒤에 무슨 토큰이 오든 변하지 않는다. 그래서 캐싱이 가능하다. (Q는 매 스텝 새 토큰 것만 필요하니 저장할 이유가 없다. 이름이 KV 캐시인 이유다.)

연산량

캐시 없이 (매번 재계산) 약 O(n²)
KV Cache 사용 약 O(n)

대가: 메모리

속도를 얻는 대신 GPU 메모리를 크게 쓴다. 크기를 결정하는 네 요인이 전부 곱셈으로 붙는다.

요인 조절 가능?

레이어 수 모델이 정함
히든 차원 모델이 정함
시퀀스 길이 상한만 설정 가능
배치 크기 운영자가 조절

시퀀스 길이와 배치 크기가 곱해진다는 게 중요하다. 긴 컨텍스트를 허용하면 동시 처리 가능한 요청 수가 반비례로 줄어든다. 컨텍스트 32K를 열어주면 배치를 크게 못 가져가고, 8K로 제한하면 훨씬 많은 요청을 동시에 받을 수 있다.

1강 과제로 띄웠던 서버 로그를 다시 보면 감이 온다.

KV Cache is allocated. #tokens: 92920, K size: 1.24 GB, V size: 1.24 GB

1.5B짜리 작은 모델인데 캐시에 2.5GB를 잡았다.


4. 두 번째 해법: PagedAttention (단편화 제거)

문제: 연속 할당의 낭비

요청이 얼마나 길어질지 모르니 최대 길이만큼 연속 공간을 미리 잡는다. 그런데 실제로는 그만큼 안 쓴다. 컨텍스트 2048을 예약하고 200토큰만 쓰고 끝나면 나머지는 할당됐지만 비어 있다.

두 종류의 낭비가 생긴다.

  • 내부 단편화: 할당했지만 안 쓰는 공간
  • 외부 단편화: 자투리를 합치면 충분한데 연속된 덩어리가 없어 새 요청을 못 받음

두 번째가 더 고약하다. 메모리 사용률은 60%인데 새 요청은 거절되는 상황이 벌어진다.

해법: OS 가상 메모리를 그대로 가져온다

KV 캐시를 고정 크기 블록(보통 16토큰)으로 쪼갠다. 그리고 Block Table이 논리 순서를 물리 위치로 매핑한다. 페이지 테이블과 똑같은 구조다.

논리적으로는 Block 0 → 1 → 2 순서지만, 물리적으로는 7번, 2번, 9번 슬롯에 흩어져 있어도 된다. 연속성 요구가 사라지니 외부 단편화가 완전히 없어진다.

35토큰을 16토큰 블록으로 관리하는 예시:

낭비

연속 할당 (2048 예약, 35 사용) 2013 토큰
PagedAttention (블록 16) 최대 15 토큰

마지막 블록만 3/16으로 부분적으로 차고, 낭비가 요청당 최대 1블록으로 상한이 잡힌다.

흩어진 블록을 읽으려면 기존 어텐션 커널로는 안 된다. Block Table을 따라 블록 단위로 계산하는 전용 CUDA 커널을 새로 써야 했고, 그래서 이름이 PagedAttention이다. 메모리 관리 기법만이 아니라 어텐션 연산 자체를 다시 쓴 것.

덤: Copy-on-Write

블록 단위로 쪼개놓으니 공유가 가능해진다. 같은 프롬프트로 여러 답을 뽑을 때(parallel sampling, beam search) 프리픽스 블록을 하나만 만들고 여러 시퀀스가 가리키면 된다.

ref count로 참조 수를 센다. 시퀀스 A, B, C가 공유하면 count = 3. A가 끝났다고 블록을 반환하면 B, C가 망가지니, count가 0이 될 때만 실제로 회수한다.

그러다 B가 다른 토큰을 생성하는 시점에 B만 사본을 만든다. 미리 3벌 만들어두는 것보다 훨씬 절약된다. 리눅스 fork()가 메모리를 즉시 복사하지 않고 쓰기 시점까지 미루는 것과 같은 발상이다.


5. 세 번째 해법: RadixAttention (중복 제거)

여기까지는 한 요청 안에서의 공유였다. SGLang은 이걸 요청 간 공유로 확장한다.

기회: 실제 워크로드는 앞부분이 겹친다

사례 설명

시스템 프롬프트 모든 요청에 동일한 지침이 앞에 붙음
멀티턴 대화 이전 히스토리가 다음 턴마다 그대로 반복
Few-shot 예제 같은 예시가 여러 요청에 반복 사용

공통점은 길고, 고정적이고, 앞에 붙는다는 것이다.

기존 엔진은 이걸 매 요청마다 처음부터 다시 계산했다. 시스템 프롬프트가 500토큰인데 사용자 질문이 20토큰이면, prefill 연산의 96%가 이미 했던 계산의 반복이다. 그리고 이게 곧 TTFT(첫 토큰까지 걸리는 시간)다.

해법: Radix Tree로 인덱싱

토큰 시퀀스를 트리로 저장한다. 새 요청이 오면 루트부터 따라 내려가며 비교하고, 일치하는 데까지 캐시 히트, 그 이후만 새로 계산한다.

노드 분할(split)이 핵심이다. 갈래가 없는 구간은 노드 하나로 압축해두는 게 radix tree인데, 나중에 갈래가 생기면 쪼개야 한다.

[매칭 전]  root → [A-B-C-D (cached)]

  ↓ 새 요청 A-B-C-E 도착

[매칭 후]  root → [A-B-C (공유, cached)]
                    ├── [D (cached)]
                    └── [E (신규 계산)]

이때 D의 KV 캐시는 그대로 살아있다. 노드 구조만 재편했지 캐시를 버린 게 아니다.

이 구조 덕분에 완전히 같은 프롬프트가 아니어도 겹치는 만큼 비례해서 이득을 본다. few-shot 예시가 3개냐 5개냐, 대화 턴 수가 다르냐 같은 부분 일치도 활용된다.

cache-aware 스케줄링

이게 SGLang의 진짜 차별점이다.

Continuous Batching 루프의 3단계에서 "어떤 요청을 먼저 투입할까"에 선택권이 있다. 여기서 캐시가 살아있는 요청을 우선 처리한다. 같은 프리픽스를 쓰는 요청들을 연달아 처리하는 식이다. 사이에 전혀 다른 요청을 끼워넣으면 그동안 캐시가 밀려나 사라질 수 있으니까.

자료구조만 있다고 캐시가 잘 맞는 게 아니다. 순서를 잘못 처리하면 애써 만든 캐시를 계속 버리게 된다. RadixAttention이 단순한 캐시 기법이 아니라 스케줄러와 한 몸인 이유다.

퇴거 정책

트리는 무한히 자랄 수 없다. 메모리가 차면 LRU로 오래된 노드부터 버리는데, 두 가지 제약이 있다.

  • 리프부터 버려야 한다. 부모를 버리면 자식이 전부 무효가 되니까
  • 실행 중인 요청이 참조하는 노드는 못 버린다. ref count가 여기서 다시 쓰인다

의미하는 바는 캐시가 조용히 사라진다는 것이다. 같은 프롬프트를 보내도 그 사이 트래픽이 몰렸으면 이미 퇴거돼 미스가 날 수 있다. 성능이 부하 상태에 따라 변동한다는 뜻이라, 벤치마크할 때 주의해야 한다.

효과

논문 기준 최대 5배 처리량 향상(Zheng et al., 2024). 다만 캐시 히트율이 곧 개선폭이다. 프리픽스가 거의 겹치지 않는 워크로드라면 이득이 거의 없다.

Continuous Batching이 워크로드와 무관하게 항상 이득이었던 것과 대비되는 지점이다.


정리: 세 기법은 보완 관계다

Decode가 memory-bound
  → 배칭으로 해결
    → Static은 슬롯 낭비 → Continuous Batching
      → 배치를 키우려니 메모리 부족
        → 범인은 KV Cache
          → 연속 할당하니 단편화 → PagedAttention (블록 단위)
            → 블록으로 쪼갰으니 공유 가능 → RadixAttention (트리 인덱싱)

기법 겨냥하는 낭비 층위

Continuous Batching GPU 유휴 시간 스케줄링
PagedAttention 메모리 단편화 메모리 할당
RadixAttention 중복 연산·저장 캐시 인덱싱

PagedAttention과 RadixAttention이 헷갈리기 쉬운데, 겨냥하는 낭비가 다르다. 전자는 할당했지만 못 쓰는 공간, 후자는 같은 걸 여러 번 계산·저장하는 것. 배타적이지 않고 함께 쓴다.

SGLang과 vLLM도 경쟁 관계가 아니라 계층 관계에 가깝다. PagedAttention은 vLLM이 제안했고 SGLang도 이 방식을 쓴다. 그 위에 트리 인덱싱과 cache-aware 스케줄링을 얹은 것이 SGLang이다.

한 문장으로 요약하면, 2강은 "GPU를 놀리지 않으려면 메모리를 어떻게 관리해야 하는가" 하나의 질문을 세 층위에서 파고든 내용이었다.

 

Docker/k8s 위에서 LLM을 서빙해보고 싶어서 시작했는데, 그 전에 "일단 모델을 로컬에서 돌리고 파인튜닝하는 것부터 익혀보자" 싶어서 먼저 손에 잡히는 것부터 해봤다. 결과적으로 M2 Pro 맥북 하나로 가벼운 LLM 파인튜닝까지 30분도 안 걸려서 끝났다.

왜 Docker/k8s가 아니라 로컬부터?

처음엔 Docker나 k8s 위에서 바로 돌려볼까 했는데, 확인해보니 macOS의 Docker Desktop은 리눅스 VM 위에서 컨테이너를 돌리는 구조라 Apple Silicon의 GPU(Metal)에 컨테이너가 접근할 수 없다. 즉 컨테이너 안에서는 CPU로만 돌아가고, 파인튜닝처럼 반복이 많은 작업엔 너무 느리다.

그래서 역할을 나눴다.

  • 학습(training): 네이티브 맥 환경에서 GPU 가속을 그대로 쓸 수 있는 방식으로
  • 서빙(serving): 학습이 끝난 뒤, 컨테이너/k8s로 배포하는 연습은 별도 단계로

이번 글은 첫 번째 단계, 즉 로컬 학습 파이프라인을 익힌 기록이다.

스택 선택: MLX

내 맥은 M2 Pro / 32GB 통합메모리. Apple Silicon에서는 transformers + PEFT + MPS 조합보다 Apple이 직접 만든 MLX(mlx-lm)가 훨씬 낫다는 걸 확인했다. M칩에 최적화돼 있어서 속도가 빠르고, LoRA 파인튜닝을 커맨드 한 줄로 지원해서 실험 반복이 쉽다.

모델은 Qwen2.5-1.5B-Instruct (4bit 양자화 버전)로 골랐다. 1.5B급이라 반복 실험하기 부담 없고, 품질도 이 사이즈치고 준수한 편.

1. 환경 세팅

파이썬 버전부터 걸렸다. 시스템 기본 파이썬이 3.14였는데, 너무 최신이라 ML 패키지들이 아직 지원을 못 했다. brew로 이미 깔려있던 3.12로 가상환경을 새로 만들었다.

 
python3.12 -m venv .venv
source .venv/bin/activate
pip install mlx-lm huggingface_hub datasets
 

2. 일단 모델부터 돌려보기

 
python -m mlx_lm generate \
--model mlx-community/Qwen2.5-1.5B-Instruct-4bit \
--prompt "안녕! 너는 어떤 모델이야? 한 문장으로 소개해줘." \
--max-tokens 100
 

처음 실행할 때 모델을 자동으로 다운로드하고(약 1GB 남짓), 이후엔 로컬 캐시에서 바로 불러온다. 생성 속도는 초당 약 147 토큰, 메모리는 1GB 정도로 가벼웠다.

3. LoRA 파인튜닝

파인튜닝 목적은 거창하지 않았다. "파이프라인 자체를 한 번 경험해보자"였고, 그래서 mlx-lm이 공식적으로 제공하는 데모 데이터셋인 mlx-community/wikisql(자연어 질문 → SQL 쿼리로 변환하는 데이터셋)을 그대로 썼다. --data에 Hugging Face 데이터셋 이름을 바로 넣을 수 있어서 데이터 전처리를 따로 할 필요가 없었다.

 
python -m mlx_lm lora \
--model mlx-community/Qwen2.5-1.5B-Instruct-4bit \
--train \
--data mlx-community/wikisql \
--iters 200 \
--batch-size 4 \
--adapter-path adapters
 

LoRA는 모델 전체를 다시 학습시키는 게 아니라, 원본 모델은 그대로 둔 채 작은 "어댑터" 파라미터만 추가로 학습하는 방식이다. 이번 학습에서 실제로 업데이트된 파라미터는 전체의 0.342%(15억 개 중 527만 개)뿐이었다.

결과

시점Val loss

 

시작 (iter 1) 2.880
iter 100 1.176
iter 200 (최종) 1.125

200 iteration, 약 3분, 최대 메모리 4.3GB로 끝났다. Val loss(학습에 안 쓴 검증 데이터에 대한 오차)가 꾸준히 떨어졌다는 건 데이터셋 패턴을 실제로 학습했다는 뜻이다.

4. 튜닝 전/후 비교

같은 질문을 베이스 모델과 LoRA 튜닝된 모델에 각각 던져봤다.

질문: Sales 부서의 평균 급여를 구하는 SQL은?

튜닝 전 (베이스 모델):

SQL 쿼리를 설명과 함께 마크다운 코드블록으로 장황하게 작성

 
SELECT AVG(salary) AS average_salary FROM employees WHERE department = 'Sales';
 

튜닝 후 (LoRA 어댑터 적용):

SELECT AVG salary FROM employees WHERE department = 'Sales'

설명 없이 SQL만 바로 뱉는 걸 보고 신기했다. 200 iteration, 3분짜리 짧은 학습만으로도 "질문을 던지면 SQL만 간결하게 답한다"는 wikisql 데이터셋의 스타일을 확실히 학습한 게 눈으로 보였다.

느낀 점

  • 로컬 파인튜닝이 생각보다 훨씬 접근하기 쉬웠다. 예전엔 GPU 서버나 Colab이 필수라고 생각했는데, M2 Pro 정도면 1~3B급 모델은 로컬에서 충분히 실험 가능하다.
  • LoRA는 "모델 전체를 다시 학습"이 아니라 "작은 어댑터만 추가 학습"이라는 개념을 실제로 체감하니 이해가 훨씬 빨랐다. 파라미터 0.3%만 건드려도 응답 스타일이 확 바뀐다.
  • Mac에서 Docker/k8s는 GPU 패스스루가 안 된다는 제약을 미리 확인하고 역할을 나눈 게 시간 낭비를 줄여줬다.

다음 할 일

  1. 공개 데이터셋 대신 직접 만든 데이터로 파인튜닝 (원하는 말투/도메인 반영)
  2. 학습된 어댑터를 베이스 모델에 병합(mlx_lm.fuse) 후 GGUF로 변환
  3. Ollama나 llama.cpp로 서빙 → Docker 이미지로 패키징 → k8s에 배포해보기 (Deployment/Service, replica 스케일링 등)

LLM 추론 엔진 스터디를 시작했다. 4주 8세션 커리큘럼이고, SGLang을 축으로 배칭, KV 캐시, 어텐션 최적화, 벤치마킹까지 다룬다. 1주차 과제는 단순하다. SGLang 서버를 띄우고 요청 하나를 보내 응답을 받는 것.

과제 자체는 명령어 두 줄이지만, 준비하면서 정리하게 된 배경 지식이 오히려 많았다. GPU를 어디서 빌릴지, 어떤 카드를 골라야 하는지, 애초에 SGLang이 뭔지. 그 과정을 함께 남긴다.


SGLang이란

한 줄로 말하면 LLM 추론(서빙) 엔진이다. Ollama, vLLM, TensorRT-LLM과 같은 카테고리다. 학습이 아니라, 이미 학습된 모델을 받아 요청을 최대한 빠르고 많이 처리하는 것이 목적이다.

동작 순서는 이렇다.

  1. 모델 가중치를 GPU에 로드
  2. KV 캐시 메모리 풀 할당
  3. CUDA 그래프 캡처
  4. OpenAI 호환 HTTP 서버 기동 (/v1/chat/completions)

서버 로그를 보면 이 순서가 그대로 찍힌다.

이름의 유래

원래 SGLang은 Structured Generation Language, 즉 LLM 호출을 프로그래밍하듯 작성하는 DSL로 출발했다. 분기, 반복, JSON 스키마 강제 같은 것을 언어 차원에서 표현하려는 시도였다.

그런데 그것을 빠르게 실행하려고 만든 백엔드 런타임이 훨씬 유명해졌다. 지금은 사실상 "고성능 추론 서버"로 통한다.

차별점: RadixAttention

여러 요청의 프롬프트 앞부분이 겹칠 때, 그 KV 캐시를 트리(radix tree) 구조로 공유해 재계산을 건너뛴다.

  • PagedAttention (vLLM): 메모리 단편화를 줄이는 접근
  • RadixAttention (SGLang): 중복 연산 자체를 없애는 접근

시스템 프롬프트나 스키마처럼 고정된 프리픽스가 매 요청에 붙는 워크로드에서 특히 효과가 크다.


실습: 서버 띄우고 요청 보내기

설치

conda create -n sglang-env python=3.11 -y
conda activate sglang-env
pip install --upgrade pip
pip install "sglang[all]"

서버 실행

python -m sglang.launch_server \
  --model-path Qwen/Qwen2.5-1.5B-Instruct \
  --host 0.0.0.0 --port 30000

The server is fired up and ready to roll! 가 뜨면 성공이다.

VRAM이 빠듯하면 아래 옵션으로 조절한다.

  --mem-fraction-static 0.7 \
  --context-length 8192

요청 보내기

curl http://localhost:30000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Qwen/Qwen2.5-1.5B-Instruct",
    "messages": [{"role": "user", "content": "Hello!"}],
    "max_tokens": 50
  }'

OpenAI 호환 스펙이라 client.chat.completions.create()로 base_url만 바꿔 붙여도 그대로 동작한다.

로그에서 읽을 것

기동 로그에 나오는 숫자들이 사실 이 스터디의 본론이다.

KV Cache is allocated. #tokens: 92920, K size: 1.24 GB, V size: 1.24 GB
max_total_num_tokens=92920, chunked_prefill_size=2048,
max_prefill_tokens=16384, max_running_requests=2048, context_len=32768
  • KV 캐시가 차지하는 용량: 1.5B 모델인데도 K/V 합쳐 2.5GB를 쓴다
  • max_total_num_tokens: 남은 VRAM으로 감당 가능한 총 토큰 수. 동시 처리량의 상한이 여기서 결정된다
  • chunked_prefill_size: 긴 프롬프트를 잘라서 처리하는 단위

즉 VRAM은 "모델이 올라가느냐"만 결정하는 게 아니라 동시에 몇 명을 받을 수 있느냐까지 결정한다.


GPU는 어디서 빌리나: RunPod

커리큘럼이 RunPod을 전제로 한다. Colab과 목적은 겹치지만 성격이 다르다.

Colab RunPod

접근 브라우저 노트북 SSH / 웹터미널 / 노트북
GPU 선택 랜덤 배정 카드 직접 선택
과금 월 구독 또는 무료 초 단위 종량제
세션 유휴 시 끊김, 12시간 제한 끌 때까지 유지
저장소 런타임 리셋 시 소멸 볼륨으로 영속

Colab은 노트북, RunPod은 서버라고 보면 된다. 서버를 띄우고 포트를 열어 curl로 때리는 실습 구조에는 후자가 자연스럽다.

과금은 켜져 있는 동안만 발생한다. 그래서 커리큘럼이 세션마다 "20~40분 실행"을 명시하고 끝나면 인스턴스를 내리라고 안내한다. 8세션 다 합쳐 4~5시간이면 $3 안팎. 끄는 걸 잊는 것이 유일한 함정이다.

감각적으로는 EC2에 GPU 붙여 쓰는 것과 거의 같다. 다만 GPU 단가가 훨씬 싸고 quota 신청 같은 절차가 없다. 대신 VPC, IAM, 관리형 서비스 연동은 없는 순수 GPU 대여소다.


그런데 GPU 목록에 RTX가 있다

Pod을 띄우려고 GPU 목록을 보니 4090, 3090 같은 게이밍 카드가 있었다. 그리고 3060이나 4060 같은 낮은 번호는 없었다. 여기서 궁금증이 시작됐다.

추론에는 게이밍 카드가 꽤 잘 맞는다

LLM 추론의 디코딩 단계는 대부분 메모리 대역폭 병목이다. 토큰 하나를 뽑을 때마다 모델 가중치 전체를 VRAM에서 읽어야 하는데, 정작 연산량은 얼마 안 된다. 그래서 연산 성능보다 대역폭과 VRAM 용량이 실질 성능을 좌우한다.

4090은 대역폭이 약 1TB/s로 A40(약 700GB/s)보다 오히려 빠르다. 단일 사용자 추론만 보면 4090이 더 빠른 경우도 많다.

낮은 번호가 없는 이유

VRAM 때문이다. 3060 12GB, 4060 8GB로는 7B 모델을 bf16으로 올리면(약 14GB) 이미 안 들어간다. KV 캐시 자리도 남겨야 한다. 결국 24GB 이상(3090/4090/5090)만 남는다.

가격은 수요가 결정한다

의외로 A40(48GB)이 4090(24GB)보다 싸게 뜨는 경우가 있다. 4090은 개인 개발자와 소규모 팀이 몰려 재고가 빠듯한데, A40은 2020년 Ampere 세대 데이터센터 카드라 물량이 남는다. 리전과 재고에 따라 실시간으로 변동한다.

48GB가 24GB보다 싸다면 고민할 이유가 없다.


NVIDIA 라인업은 왜 갈렸나

실리콘은 상당 부분 공유한다. 갈린 것은 기술이 아니라 가격 차별화 전략이다.

1990년대 후반, NVIDIA는 같은 칩을 게이머에게 $300에 팔고 있었는데 CAD·3D 업계는 훨씬 비싼 워크스테이션 카드를 쓰고 있었다. 여기서 Quadro가 나왔다. 하드웨어는 거의 같고, 드라이버에서 특정 기능을 열어주고 ISV 인증(AutoCAD, Maya 등 벤더 공식 검증)을 붙여 몇 배 가격을 받는 방식이었다.

2007년 CUDA 등장으로 GPGPU 수요가 붙자 Tesla(현 데이터센터) 라인이 갈라졌고, 지금은 이 라인이 회사 매출의 대부분이다. 순서가 뒤집힌 셈이다.

라인업별 차이

GeForce RTX A/Ada (워크스테이션) 데이터센터

예시 4090, 3090 A4000, A5000, A6000 A40, L4, L40S, A100, H100
VRAM 8~32GB 16~48GB 24~192GB
ECC 없음 있음 있음
FP64 심하게 제한 제한 온전 (A100/H100)
NVLink 없음(30시리즈 이후) 일부 있음, 고대역폭
폼팩터 2~4슬롯, 팬 내장 1~2슬롯, 저전력 패시브 쿨링, 서버 전용
서버 배포 EULA상 제한 제한 허용

실무에서 중요한 건 두 가지

VRAM과 라이선스다. 나머지는 부차적이다.

VRAM은 원가가 크지 않은데도 세그먼트 구분의 핵심 수단으로 쓰인다. 4090에 48GB를 달면 A40이 안 팔리니 24GB에서 끊는다. LLM에서는 이게 곧 "돌릴 수 있는 모델 크기"라 결정적이다.

라이선스는 더 직접적이다. NVIDIA 드라이버 EULA는 지포스의 데이터센터 배포를 제한한다. 그래서 사내 K8s 클러스터에 4090을 꽂지 못하고 A40을 쓰게 된다.

반면 FP64와 ECC는 LLM 추론에서 체감이 작다. FP64는 과학 계산용이고 우리는 bf16, 요즘은 fp8까지 쓴다. ECC는 몇 주씩 도는 학습 잡에서는 중요하지만 요청 단위로 끝나는 추론에서는 영향이 제한적이다.


"추론 / 학습 / 효율 / 서빙"은 네 가지가 아니다

GPU 소개 글에서 이런 표현을 자주 본다.

L4는 보통 추론(특히 효율/서빙)에 초점인 GPU A40은 범용(학습/추론 둘 다)에서 VRAM으로 밀어붙이는 타입

처음엔 네 종류 작업이 있는 줄 알았는데 아니었다. GPU가 하는 일은 학습추론 둘뿐이다.

  • 추론(inference): 모델에 입력을 넣고 출력을 받는 행위 자체
  • 서빙(serving): 그 추론을 여러 사용자의 요청을 받아 상시 처리하는 시스템으로 운영하는 것

포함 관계다. 노트북에서 한 번 돌려보는 것도 추론이고, API 서버로 하루 10만 건 처리하는 것도 추론인데, 뒤쪽만 서빙이라 부른다. Ollama, SGLang, vLLM이 모두 서빙 도구다.

효율은 작업이 아니라 수식어다. "전력·비용 대비 처리량이 좋아 서빙에 적합하다"는 뜻이다.

L4 vs A40

L4 A40

VRAM 24GB 48GB
TDP 72W 300W
성격 요청당 전력 효율 용량으로 밀어붙이기

L4는 72W 싱글슬롯이라 랙 하나에 여러 장을 꽂아도 전력과 발열이 감당된다. 데이터센터에서 전기요금과 냉각은 운영비의 큰 축이라, "요청당 전력"이 좋은 카드가 서빙에 유리하다.

A40은 반대로 48GB를 앞세운다. 전력은 많이 먹지만 큰 모델이 올라가고 파인튜닝까지 커버된다.

선택 순서는 명확하다. 모델이 VRAM에 들어가느냐가 1차 관문이고, 그 다음이 처리량 대비 비용이다.


정리

1주차 과제는 명령어 두 줄이었지만, 그 두 줄을 실행할 환경을 고르는 과정에서 배운 게 더 많았다.

  • SGLang은 추론 서빙 엔진이고, RadixAttention으로 프리픽스 KV 캐시를 공유한다
  • 추론은 대역폭 병목이라 게이밍 카드도 성능이 좋지만, VRAM과 라이선스 때문에 데이터센터 카드를 쓴다
  • VRAM은 모델 크기뿐 아니라 동시 처리량의 상한까지 결정한다
  • 서빙은 추론의 부분집합이고, "효율"은 전력·비용 대비 처리량을 말한다

NL2SQL 재현성 문제를 쫓다가 Ollama의 컨텍스트 설정을 며칠간 파헤친 기록. 결론부터 말하면 범인이 아니었다. 그래도 배운 게 많아서 남긴다.

문제

사내 데이터 웨어하우스에 자연어로 질문하면 SQL을 만들어주는 시스템을 만들고 있다. 로컬에 띄운 Ollama에 120B 모델을 올려서 쓰는 구조다.

어느 날 이상한 걸 발견했다.

같은 질문을 넣었는데 매번 다른 쿼리가 나온다.

컬럼 순서가 바뀌는 정도가 아니라, 조인하는 테이블이 달라지거나 필터 조건이 빠지기도 했다. 사용자 입장에서는 어제 본 숫자와 오늘 본 숫자가 다르다는 뜻이다. 업무 시스템에서는 치명적이다.

첫 번째 용의자: temperature

LLM이 매번 다른 답을 내는 가장 흔한 이유다.

모델은 다음 토큰을 고를 때 후보마다 확률을 매긴다.

SELECT 다음에 올 토큰
  dept        62%
  department  21%
  d.dept       9%

temperature는 여기서 1등만 뽑을지, 확률대로 주사위를 굴릴지 정하는 값이다. Ollama 기본값이 0.8이라 SQL 생성에는 전혀 맞지 않는다.

그런데 확인해보니 이미 0으로 설정하고 있었다. 주사위는 처음부터 꺼져 있었던 것이다.

용의자 제외.

두 번째 용의자: num_ctx

다음으로 의심한 건 컨텍스트 길이였다.

프롬프트에 들어가는 게 꽤 많았다.

시스템 지침
+ 테이블 DDL 스키마
+ MicroStrategy 리포트 정의
+ few-shot 예제
+ 사용자 질문

실제로 재보니 2,374 토큰이었다.

r = client.chat(model="...", messages=msgs, options={"num_ctx": 8192})
print(r["prompt_eval_count"])   # 2374

여기서 중요한 사실을 알게 됐다. Ollama는 컨텍스트가 넘치면 앞부분부터 조용히 버린다. 에러도 없고 경고도 없다. 스키마 정의가 잘려나가면 모델은 컬럼명을 추측하게 되고, 추측하는 상황에서는 후보들이 비슷비슷해져서 답이 흔들린다.

가설이 그럴듯했다. temperature=0이어도 후보 확률이 26% 대 25%처럼 붙어 있으면, GPU 연산의 미세한 부동소수점 오차만으로 1등이 뒤집힐 수 있기 때문이다.

num_ctx를 8192로 올렸다. 몇 번 돌려보니 같은 쿼리가 나왔다. 해결된 것 같았다.

그런데 서버가 이상해졌다

여기서부터 일이 꼬였다.

값을 이리저리 바꿔가며 테스트했다. 8192로 했다가, 2048로 줄여보고, 다시 크게 올려보고. 그런데 응답 시간이 들쭉날쭉했다. 어떤 요청은 금방 오고 어떤 요청은 한참 걸렸다.

그리고 이상한 걸 발견했다. 요청에 한 번 num_ctx를 넣었더니, 그 값이 서버에 고정되어 버렸다. 이후 값을 안 넣고 호출해도 계속 그 크기로 동작했다.

결국 Ollama를 재시작했다.

num_ctx의 정체

파보니 이유가 명확했다.

num_ctx는 temperature나 top_p 같은 샘플링 파라미터가 아니다. 모델을 메모리에 올릴 때 확정되는 로드 타임 파라미터다.

Ollama는 요청이 오면 runner라는 자식 프로세스를 띄우는데, 여기에 --ctx-size 인자가 박혀서 뜬다. 그리고 그만큼 GPU 메모리를 미리 예약한다.

ollama serve (부모)
  └─ ollama runner --ctx-size 8192 ...  (자식, 실제 추론 담당)

쿠버네티스로 치면 Pod의 resources.limits.memory 같은 것이다. 실행 중인 Pod의 메모리 limit을 바꿀 수 없듯이, 이미 8192로 떠 있는 runner의 컨텍스트를 4096으로 줄일 방법이 없다. 프로세스를 죽이고 새로 띄우는 수밖에 없다.

직접 확인해보면 바로 보인다.

pgrep -f "ollama runner"        # PID 확인
# num_ctx를 다른 값으로 요청
pgrep -f "ollama runner"        # PID가 바뀌어 있다

즉 내가 값을 바꿔가며 테스트하는 동안, 매번 120B 모델이 VRAM에서 내려갔다가 다시 올라오고 있었다. 응답 시간이 요동친 이유가 이거였다. 가중치가 리눅스 페이지 캐시에 남아 있으면 빠르고, 밀려났으면 디스크에서 다시 읽느라 느렸던 것이다.

클라이언트 입장에서는 에러가 안 난다. 연결이 끊기는 것도 아니다. 그냥 오래 기다릴 뿐이라 무슨 일이 일어나는지 알 수가 없다.

그리고 알리바이

여기까지 정리하고 나서, 한 가지를 확인하지 않았다는 걸 깨달았다.

원래 기본값이 얼마였지?

num_ctx를 명시하기 전에는 기본값으로 돌고 있었을 텐데, 그 값이 2,374보다 작았는지를 확인한 적이 없었다. "Ollama 기본값은 4096"이라는 이야기를 여기저기서 봤기 때문에 당연히 그럴 거라고 생각했다.

확인해봤다.

# num_ctx 없이 호출
client.chat(model="...", messages=[{"role": "user", "content": "hi"}], keep_alive="5m")
ollama ps

CONTEXT 컬럼에 131072가 찍혀 있었다.

최근 Ollama는 기본 컨텍스트를 고정값으로 쓰지 않는다. 가용 VRAM을 보고 자동으로 결정한다. 서버에 GPU가 넉넉하게 꽂혀 있으니 알아서 크게 잡아둔 것이었다.

2,374 토큰은 애초에 잘린 적이 없었다.

가설 기각. num_ctx는 범인이 아니었다.

그럼 왜 좋아진 것처럼 보였나

두 가지가 겹쳤다.

측정을 안 했다. "몇 번 돌려보니 같더라"가 근거의 전부였다. 재현성은 비율이다. 95%면 스무 번에 한 번 틀리는데, 서너 번 해보고는 절대 못 느낀다. 원래도 대부분 같은 답이 나오고 있었을 가능성이 높다.

기준선이 없었다. 바꾸기 전 상태를 측정해두지 않았으니, 바꾼 뒤의 결과와 비교할 대상이 없었다. 개선인지 우연인지 구분할 방법이 원천적으로 없는 상태에서 개선이라고 판단했다.

"작게 줄이면 확실히 망가진다"는 관찰이 가설을 뒷받침한다고 생각한 것도 착각이었다. num_ctx=2048에서 망가지는 건 사실이지만, 그건 2048일 때 잘린다는 증거이지 원래 잘리고 있었다는 증거가 아니다. 이걸 구분하지 못했다.

그래도 남은 것들

며칠을 썼지만 건진 게 없지는 않다. 아래는 원인 여부와 무관하게 유효한 사실들이다.

1. num_ctx는 로드 타임 파라미터다

구분 파라미터 변경 시

로드 타임 num_ctx, num_gpu, num_batch runner 재기동
샘플링 temperature, top_p, seed 재기동 없음

구분 기준은 간단하다. 메모리 배치에 영향을 주면 리로드, 계산 결과 해석에만 영향을 주면 아니다.

2. 요청 파라미터가 서버 상태를 바꾼다

우선순위는 이렇다.

API 요청 파라미터 > 환경변수 > Modelfile > 기본값

즉 OLLAMA_CONTEXT_LENGTH를 잡아둬도 클라이언트가 다른 값을 보내면 그게 이긴다. 그리고 이긴 값으로 모델이 리로드된다.

공유 서버에서는 이게 실제 위험이다. 여러 사람이 서로 다른 num_ctx로 요청하면 같은 모델이 반복해서 내려갔다 올라온다. 120B급이면 그 사이 아무도 못 쓴다.

3. ollama show와 ollama ps는 다른 걸 보여준다

  • ollama show → 모델이 지원하는 최대치 (GGUF 메타데이터)
  • ollama ps → 지금 실제로 할당된 값

내가 처음에 헷갈린 지점이다. ollama show에서 131072를 보고 "컨텍스트 크네"라고 넘어갔는데, 그건 스펙일 뿐 런타임 값이 아니다. 런타임 확인은 ollama ps의 CONTEXT 컬럼이 유일한 정답이다.

4. 컨텍스트를 크게 잡는 데는 비용이 있다

KV 캐시 때문이다.

트랜스포머는 토큰을 하나씩 생성하는데, 매 스텝마다 앞의 모든 토큰을 참조해야 한다. 이때 각 토큰의 K/V 벡터는 한 번 계산되면 안 바뀌므로, 저장해두고 재사용한다. 이게 KV 캐시다.

KV 캐시 ≈ 토큰 수 × 레이어 수 × KV 헤드 수 × head_dim × 2(K,V) × 2바이트

토큰 수를 뺀 나머지는 모델 구조상 고정이다. 결국 컨텍스트 길이에 선형 비례해서 메모리를 먹는다. 120B에 128K 컨텍스트면 가중치 위에 10GB 안팎이 추가로 얹힌다.

VRAM이 부족하면 Ollama가 일부 레이어를 CPU로 내린다(오프로딩). 죽지는 않는데 토큰 생성이 몇 배 느려진다. ollama ps의 PROCESSOR 컬럼이 "100% GPU"가 아니면 이 상태다.

참고로 OLLAMA_NUM_PARALLEL은 컨텍스트를 슬롯끼리 나눠 갖는 게 아니라, 슬롯마다 온전한 num_ctx를 받고 VRAM만 배로 쓴다. 나도 반대로 알고 있다가 runner 실행 인자를 직접 보고 알았다.

ollama runner --ctx-size 40960 --parallel 5   # 8192 × 5

5. 그래서 Modelfile로 고정하는 게 안전하다

FROM gpt-oss:120b
PARAMETER num_ctx 8192
PARAMETER temperature 0
ollama create nl2sql-120b -f Modelfile

이후 애플리케이션에서는 num_ctx를 아예 보내지 않고 모델 이름만 지정한다. 장점이 세 가지다.

  • 요청 파라미터가 없으니 리로드를 유발할 일이 없다
  • 다른 사용자가 원본 모델을 써도 별개 runner라 서로 간섭하지 않는다
  • 설정이 파일로 남아 형상관리가 된다

Deployment 스펙에 리소스를 박아두고 배포하는 것과 같은 발상이다. 인프라 하던 사람이라면 이쪽이 훨씬 익숙하다.

진짜 교훈

기술적인 것보다 이쪽이 크다.

측정 없이 개선을 주장하면 안 된다.

당연한 말인데, 인프라 작업에서는 자연스럽게 하던 걸 LLM 파이프라인에서는 놓쳤다. 서버 튜닝할 때는 벤치마크를 먼저 돌리고 기준선을 잡는다. 그런데 LLM은 출력이 자연어라 "좋아진 것 같다"는 느낌이 개입할 여지가 크다. 그 느낌을 근거로 착각하기 쉽다.

"바꿨더니 좋아졌다"는 인과가 아니다.

인과를 주장하려면 되돌렸을 때 재현되는지를 확인해야 한다. 원래 값으로 돌려놓고 다시 흔들리는 걸 봤어야 했다. 10분이면 되는 검증인데 건너뛰었고, 그래서 며칠을 더 썼다.

전제를 확인하지 않고 가설을 세웠다.

"Ollama 기본값은 4096"이라는 건 널리 퍼진 정보지만, 내 서버의 실제 값은 아니었다. 검색해서 나온 숫자를 내 환경의 사실로 받아들인 게 출발점의 오류였다. ollama ps 한 번이면 끝날 일이었다.


비슷한 함정에 빠진 분이 있다면, ollama ps부터 쳐보시길.

LLM을 공부하다 보면 항상 마주치는 갈림길이 있다. "우리 도메인 지식을 모델에 어떻게 넣지?" 여기서 나오는 두 가지 대표 선택지가 RAG파인튜닝이다. 이름은 자주 들었지만 막상 정확히 뭐가 다른지 헷갈리는 경우가 많아서, 개념을 한번 정리해봤다.

RAG (Retrieval-Augmented Generation)

한 줄로 말하면 모델은 그대로 두고, 필요한 지식을 그때그때 검색해서 문맥으로 넣어주는 방식이다.

동작 흐름은 이렇다.

  1. 원본 문서를 적당한 크기의 **청크(chunk)**로 쪼갠다.
  2. 각 청크를 임베딩(embedding), 즉 벡터로 변환한다.
  3. 이 벡터들을 벡터 DB에 저장한다.
  4. 사용자 질문이 들어오면, 그 질문도 벡터화해서 벡터 DB에서 유사한 청크만 검색해온다.
  5. 검색된 청크들을 프롬프트에 끼워넣어서 모델에게 전달한다.

여기서 핵심은 4번의 검색(Retrieval) 과정이다. 매번 전체 문서를 다 주입하는 게 아니라, 질문과 관련 있는 조각만 골라서 넣는다. 이 "골라오는" 단계가 RAG의 R이다.

검색된 청크를 넣는 위치는 시스템 프롬프트일 수도 있고, 유저 프롬프트 쪽 컨텍스트로 붙일 수도 있다. 구현하기 나름이다.

모델 가중치는 전혀 건드리지 않는다는 점이 가장 중요한 특징이다. 지식이 바뀌면 벡터 DB만 업데이트하면 되고, 그 즉시 반영된다.

파인튜닝 (Fine-tuning)

파인튜닝은 반대로 모델의 가중치를 실제로 학습시켜서, 모델의 행동·문체·도메인 지식 자체를 바꾸는 방식이다.

원래 의미의 풀 파인튜닝(full fine-tuning)은 큰 모델의 가중치를 전부 다시 학습시킨다. 하지만 비용이 어마어마해서 실무에서는 잘 쓰지 않는다.

그래서 요즘 많이 쓰는 게 LoRA(Low-Rank Adaptation), 그리고 이를 양자화까지 얹은 QLoRA다. 방식은 이렇다.

  • 원본 모델(큰 모델)은 얼려둔다(frozen). 즉 건드리지 않는다.
  • 그 옆에 파라미터 수가 적은 작은 어댑터를 새로 붙여서, 그것만 학습시킨다.
  • 학습이 끝나면 이 작은 어댑터를 원본 모델에 얹어서(merge) 쓰거나, 어댑터만 따로 로드해서 쓴다.

가끔 "큰 모델과 작은 모델을 합친다"고 이해하는 경우가 있는데, 정확히는 "두 개의 독립된 모델"을 합치는 게 아니라 "원본 모델 + 작은 어댑터"를 합치는 것이다. 학습시키는 대상은 어디까지나 새로 붙인 어댑터뿐이다.

둘의 근본 차이

RAG 파인튜닝

모델 가중치 안 건드림 바꿈 (또는 어댑터 추가)
지식 갱신 벡터 DB만 업데이트하면 즉시 반영 다시 학습해야 반영
잘하는 것 최신·방대한 사실 지식 주입, 출처 추적 말투·형식·특정 태스크 패턴 학습
비용 상대적으로 저렴 학습 비용·GPU 필요

가장 직관적인 비유는 이렇다.

  • RAG는 오픈북 시험이다. 참고자료를 그때그때 찾아본다.
  • 파인튜닝은 공부해서 머릿속에 넣는 것이다. 모델 자체를 바꾼다.

그래서 뭘 써야 할까?

둘은 대립 관계가 아니라 섞어 쓰기도 한다. 파인튜닝으로 도메인 특유의 말투·포맷을 잡아두고, RAG로 최신 사실을 보강하는 식의 조합이 자연스럽다.

예를 들어 NL2SQL(자연어 → SQL) 같은 작업을 한다면, 보통은 스키마 정보나 예시 쿼리를 검색해서 주입하는 RAG가 먼저다. 그리고 필요하면 SQL 생성 패턴을 학습시키는 파인튜닝을 그 위에 얹는 순서로 많이 간다.

정리하자면, "지식을 자주 갱신해야 하고 출처가 중요하다"면 RAG, "모델이 특정 방식으로 말하거나 특정 태스크를 잘하게 만들고 싶다"면 파인튜닝. 그리고 대부분의 실무는 결국 이 둘 사이 어딘가에서 균형을 잡는 일이다.

"다음 토큰 예측기"를 "쓸 만한 어시스턴트"로 만드는 과정. LLM 강의의 Fine-tuning 파트 중 Instruction Tuning과 RLHF(PPO)까지를 정리한다.


0. 전체 그림

Pretrain           언어 능력 습득 (다음 토큰 예측)
  → SFT            특정 태스크 수행
  → Instruction    지시 이해의 일반화 — "무엇을 원하는가"
  → RLHF(PPO)      선호 정렬 — "어떤 답이 더 좋은가"

각 단계는 이전 단계가 해결하지 못하는 문제를 하나씩 해결한다. 그리고 마지막 단계인 RLHF가 남긴 비용과 복잡성이 다음 기법(DPO)의 출발점이 된다.


1. 왜 Fine-tuning이 필요한가

Fine-tuning은 사전학습된 모델(이미 대규모 데이터로 일반적인 지식을 학습한 모델)을 기반으로, 특정 목적이나 작업 수행을 위해 모델을 추가로 학습시키는 과정이다.

장점

  • 적은 양의 데이터로도 높은 성능 향상
  • 특정 도메인/업무에 대한 성능 최적화
  • 모델의 행동과 스타일 제어 가능
  • Pre-training 대비 저비용

한계 및 주의사항

  • 기존 능력 소실 가능 (catastrophic forgetting)
  • Overfitting 및 데이터 품질에 매우 민감
  • 데이터 보안 주의 필요

2. Instruction Tuning — 지시를 알아듣게 만들기

2-1. 문제의 정확한 위치

Pretrained LM의 한계

다음 토큰 예측에는 강하지만, 질문을 받아도 반드시 답하지 않는다. "요약해줘: (긴 글)"을 입력하면 요약하는 것이 아니라 긴 글을 더 이어 쓸 수도 있다. 확률적으로 가장 자연스러운 다음 텍스트가 "답변"이라는 보장이 없기 때문이다.

SFT 모델의 한계

특정 태스크 성능은 향상되지만, 지시의 종류를 일반화해서 이해하지 못한다. 요약 데이터로 SFT한 모델은 "요약해줘"라는 패턴에 반응하는 것을 배운 것이지, "사람이 무언가를 시키면 그 의도에 맞게 응답한다"는 메타 능력을 배운 것이 아니다.

  • 표현만 바꿔도 성능이 흔들린다 ("핵심을 세 줄로 뽑아줘")
  • 학습하지 않은 태스크 유형은 대응하지 못한다 ("이 코드의 버그를 찾아줘")

Instruction Tuning의 목표

처음 보는 지시에도 적절히 대응하는 능력

태스크 하나하나를 가르치는 것이 아니라, "지시를 받고 수행한다"는 행위 자체를 수백 가지 태스크에 걸쳐 보여줌으로써 지시-수행이라는 패턴을 일반화시킨다.

핵심 문장: Instruction tuning은 모델에게 "문제를 푸는 법"이 아니라 **"사람이 원하는 것을 이해하는 법"**을 가르치는 과정이다.

2-2. 데이터 구조가 지시 이해를 강제한다

1. Instruction (해야 할 일)   — "다음 문장을 한국어로 번역하라"
2. Input (선택적 context)     — "Machine learning enables..."
3. Output (기대되는 응답)      — "머신 러닝은..."

형태는 SFT와 유사하지만 의미가 다르다.

  • SFT: 태스크가 암묵적. 요약 데이터셋이면 모든 샘플이 요약이므로, 모델은 태스크를 식별할 필요 없이 입출력 매핑만 배우면 된다.
  • Instruction Tuning: 태스크가 명시적이고 샘플마다 다르다. 모델이 잘하려면 반드시 Instruction을 읽고 무엇을 요구하는지 파악한 뒤 Input을 처리해야 한다. 이 구조 자체가 "지시 이해"를 강제한다.

또한 질문-정답(❌)이 아니라 지시-응답(⭕)이다. 정답이 유일하지 않으며, 설명형 답변이나 단계적 reasoning이 포함될 수 있다. 학습 데이터의 Output은 단답이 아니라 "좋은 응답의 시연"에 가깝다.

2-3. 데이터 설계가 성능을 결정한다 — 세 가지 접근

Instruction tuning의 모델 구조나 학습 방법은 일반 언어모델 학습과 같다. 유일한 변수는 데이터이며, 따라서 성능은 데이터 설계로 결정된다.

세 접근은 "지시 데이터를 어떻게 확보하는가"에 대한 서로 다른 답이다.

FLAN (Google) — 이미 있는 것을 변환하자

  • 기존 NLP 벤치마크(번역, 요약, 분류, QA)를 자연어 instruction으로 통일
  • Key Point: 태스크 이름 ❌ → 지시의 의미 ⭕
    • [translation] 같은 태그가 아니라 "다음 문장을 번역하라"는 자연어의 의미로 태스크를 파악하게 한다
    • 그래야 처음 보는 지시문에도 의미 기반으로 대응하는 zero-shot generalization이 생긴다
  • 효과: 강력한 zero-shot 일반화, 태스크 간 공통 구조 학습
  • 한계: 벤치마크 재가공 위주 → 지시문이 정형적이라 자유 대화·창의적 응답에는 제약

Alpaca (Stanford) — 사람이 다 만들어야 할까?

  • 핵심 질문: Instruction 데이터를 사람이 전부 작성해야 하는가?
  • 강력한 LLM에게 instruction–response 쌍 생성을 요청
  • 데이터 생성 비용 대폭 감소, 벤치마크에 없는 일상적·개방형 지시 커버

Self-Instruct — 생성 파이프라인 자체를 자동화하자

  • Instruction 생성 자체를 모델에게 위임, 사람은 seed + 필터링만 담당
  • 루프: 기존 instruction 기반 새 instruction 생성 → response 생성 → 품질 필터링 → 학습 데이터로 재사용
  • 목표: instruction 데이터의 자동 확장

세 접근을 관통하는 축: 사람의 개입을 줄이면서 지시의 다양성을 늘리는 방향 (FLAN: 기존 자산 재활용 → Alpaca: 생성 위임 → Self-Instruct: 파이프라인 위임)

2-4. 효과와 남는 문제

효과 (Flan-PaLM 사례)

  1. Zero-shot 성능 향상 — 보지 못한 태스크에도 대응 (지시-수행 패턴 자체를 학습했기 때문)
  2. 프롬프트 민감도 감소 — 표현이 달라도 의도 파악
  3. 자연스러운 상호작용 — 챗봇/어시스턴트의 필수 전제

남는 문제

  • 데이터 품질에 매우 민감 (잘못된 응답을 시연하면 그대로 학습 — 특히 모델 생성 데이터는 오류 혼입 가능)
  • 장황한 답변, 불필요한 설명 문제
  • 사람의 선호를 완전히 반영하지는 못함

마지막 항목이 핵심이다. 지시를 "따르는" 것까지는 가르쳤지만, 여러 가능한 응답 중 무엇이 "더 좋은지"는 가르치지 못했다. 학습 데이터가 "이렇게 응답하라"는 시연이지, "이것이 저것보다 낫다"는 비교가 아니기 때문이다.

→ 이것이 RLHF의 입구가 된다.


3. Alignment 문제 — "맞는 답"에서 "좋은 답"으로

Instruction tuning을 마쳐도 같은 instruction에 대해 답변 품질 차이가 발생한다.

  • 너무 장황함
  • 핵심 부족
  • 부적절/위험한 내용

예를 들어 "서울 근교 1박 2일 여행지 추천"이라는 같은 지시에 대해, 구체적 동선과 팁을 주는 답변부터 "가평 가보세요" 한 줄까지 모두 "지시를 따른" 답변이다.

이 문제를 Alignment 문제라고 부른다. "지시를 따르는 것" 이후의 단계로, 모델 출력이 사람의 기대·가치·선호에 맞도록 조정하는 것이다.

여기서 중요한 전환

Correct (정확성) Good (선호/신뢰성)

목표 사실적 왜곡 없는 정답 사용자 만족, 사회적 윤리 준수
기준 정답 레이블 존재 정답이 없음, 상대적 선호만 존재
학습 Supervised learning 가능 Supervised learning 불가

"맞는 답"에는 정답 레이블이 있지만 "좋은 답"에는 정답이 없다. "A가 B보다 낫다"는 상대적 선호만 존재하며, 주관적 판단이 필요하다.

→ 그래서 사람의 판단 자체를 학습 신호로 사용하는 방법이 필요해진다. 이것이 RLHF다.


4. RLHF — 사람의 선호를 학습 신호로

4-1. 3단계 구조

① Instruction-tuned Base Model

앞 단계에서 만든 모델이 출발점이다.

② Reward Model 학습

  • 하나의 프롬프트에 여러 응답을 생성시키고, 사람이 선호 순위를 부여
  • 이 데이터로 (Prompt, Response) → scalar reward를 출력하는 별도 모델을 학습
  • 핵심: 절대 점수 ❌ / 상대 비교 ⭕ ("이 답변은 87점"이 아니라 "A > B")
  • 의미: "사람 선호"가 계산 가능한 함수로 근사됨
  • 장점: 정답 작성 없이도 학습 가능

③ Reinforcement Learning (PPO)

RL 프레임을 LLM에 매핑하면:

  • Policy = Language Model
  • Action = 다음 토큰 생성
  • 목표 = Reward 최대화

PPO 특징: 너무 큰 업데이트 방지.

KL Loss (Frozen 참조 모델과의 거리 제약)

학습 중인 모델이 원래 instruction-tuned 모델에서 너무 멀어지지 않게 잡아주는 장치.

이것이 없으면 발생하는 문제:

  • Reward hacking — 모델이 "진짜 좋은 답"이 아니라 "reward model이 점수를 잘 주는 패턴"을 찾아냄
  • 비정상적 문장 생성

Reward model은 결국 근사치이므로, 순수하게 그것만 최대화하면 모델이 망가진다.

4-2. PPO 기반 학습의 한계

1) Reward model이 필요하다는 것 자체가 부담

  • 사람의 선호 데이터(순위 매기기) 대량 수집 비용
  • Reward model의 오류가 policy 학습에 그대로 전파
  • Reward hacking 위험 상존
    • 예: reward model이 긴 답변에 점수를 후하게 주는 경향이 있으면, policy는 내용 없이 장황한 답변 쪽으로 최적화된다

2) PPO 학습 불안정

  • 학습 신호가 "생성한 문장 전체에 대한 reward 하나"로 전달됨 → 어느 토큰이 기여했는지 credit assignment가 어렵고 gradient 분산이 큼
  • 하이퍼파라미터(learning rate, KL 계수, batch 크기)에 극도로 민감
  • 조금만 틀어져도 학습 발산 또는 비정상 문장 생성
  • "너무 큰 업데이트 방지"를 내장한 PPO조차 이렇다

3) 높은 자원 비용

PPO 학습 한 스텝에 GPU에 동시에 올려야 하는 모델:

  • Policy 모델 (학습 대상 — gradient + optimizer state 포함)
  • Frozen reference 모델 (KL 거리 계산용)
  • Reward model (매 생성마다 점수 계산)
  • 구현에 따라 value/critic 모델까지

또한 매 스텝이 "생성(rollout) → 점수 계산 → 업데이트" 루프이므로, forward/backward만 도는 supervised learning보다 학습 속도도 훨씬 느리다.

4-3. 정리

장점 한계

RLHF (PPO) 사람 선호 직접 반영 Reward model 필요, PPO 불안정, 높은 자원 비용

이 세 가지 한계가 그대로 다음 질문으로 이어진다.

"RLHF는 너무 복잡하다. RL 없이 preference 데이터를 바로 쓰면 안 될까?"

Direct Preference Optimization (DPO) — 다음 글에서 다룬다.


5. 마무리 요약

Pretrained LM   : 다음 토큰 예측만 잘함. 질문에 답한다는 개념 없음
      ↓ SFT
SFT 모델        : 특정 태스크는 잘하지만 지시를 일반화하지 못함
      ↓ Instruction Tuning (FLAN / Alpaca / Self-Instruct)
지시 이해 모델   : 처음 보는 지시에도 대응. 그러나 답변 품질은 제각각
      ↓ RLHF (Reward Model + PPO + KL 제약)
정렬된 모델      : 사람 선호 반영. 그러나 복잡·불안정·고비용
      ↓
DPO 등장 배경
  • Instruction Tuning의 성능은 데이터 설계가 결정한다
  • Alignment는 "맞는 답"이 아니라 "좋은 답"의 문제이며, 정답 레이블이 없다
  • RLHF는 사람의 상대적 선호를 reward model로 근사하고 PPO로 최적화한다
  • KL 제약이 없으면 reward hacking으로 무너진다
  • PPO의 복잡성·불안정성·비용이 DPO의 출발점이다

RAG(Retrieval Augmented Generation)를 정리해본다. 사실 지금 회사에서 NL2SQL 시스템을 만들면서 "이게 RAG랑 뭐가 다른 거지?"라는 고민을 계속 해왔는데, 이번 강의를 들으면서 개념적으로 많이 정리가 됐다.

LLM이 왜 RAG 없이는 한계가 있을까

RAG 얘기를 하기 전에 LLM 자체의 한계부터 짚어야 한다.

  • 지식 컷오프: 학습이 멈춘 시점 이후 정보는 모른다
  • 도메인 지식 반영 불가: 회사 내부 문서, 보안 문서는 애초에 학습 데이터에 없다
  • 최신 정보 부재: 오늘 주가, 오늘 뉴스는 당연히 답할 수 없다

이 상태에서 억지로 답변을 시키면 결국 할루시네이션(엉뚱한 답변)으로 이어진다. 이걸 막기 위해 나온 게 RAG다.

RAG = 질문과 관련된 문서를 외부 지식 베이스에서 검색(Retrieval)해서, LLM의 컨텍스트에 넣어(Augmented) 답변을 생성(Generation)하는 방법

컨텍스트가 길어질수록 오히려 손해 보는 현상

RAG를 이해하기 전에 알아두면 좋은 개념이 하나 있다. Lost in the Middle 현상이다. LLM에 넣는 컨텍스트가 길어질수록, 모델은 문서의 앞부분과 뒷부분은 잘 보는데 중간에 있는 정보는 놓치는 경향이 있다. 그래서 무작정 문서를 통째로 밀어넣는다고 좋은 게 아니라, 얼마나 잘 자르고(chunking) 얼마나 관련성 높은 것만 골라서(retrieval) 넣어주느냐가 RAG 성능을 가른다.

Naive RAG: 가장 기본적인 형태

가장 단순한 RAG 파이프라인은 이렇다.

문서 로드 → 스플릿(청킹) → 임베딩 → 벡터스토어 저장
질문 입력 → 벡터스토어에서 유사 문서 검색(Retrieval) → 문서+질문을 LLM에 전달 → 답변 생성

전처리 단계에서 챙겨야 할 것들:

  • 임베딩: 텍스트를 고차원 벡터로 변환 (OpenAI는 1536/3072차원, 오픈소스는 multilingual-E5, BGE-M3를 많이 씀)
  • 청킹: RecursiveCharacterTextSplitter가 제일 많이 쓰임. chunk_size의 10~20%를 overlap으로 주는 게 국룰 — 안 그러면 문맥이 잘려서 정보 손실이 생긴다
  • 벡터스토어: Chroma(메타데이터 필터링 지원), FAISS(직접 저장 필요) 둘 다 로컬 실습용으로 무난

근데 Naive RAG는 생각보다 잘 못 한다

강의에서 재밌었던 부분이, 삼국지 위키 문서를 넣고 "삼고초려는 정확히 몇 년에 일어난 일이야?"라고 물었더니 Naive RAG가 완전히 엉뚱한 답(후한 말 농민봉기 얘기)을 했다는 것이다. 문서에 분명 정답이 있는데도.

Naive RAG의 대표적인 한계 다섯 가지:

한계 원인 해결 기법

의미 없는 청크 경계 고정 크기로 자르다 보니 문장·표가 잘림 Semantic Chunking, Parent Document Retriever
단일 벡터 검색 한계 Dense 임베딩만 사용 Hybrid Search (BM25 + Dense)
쿼리-문서 미스매치 질문과 문서의 표현 차이 Multi-Query, RAG-Fusion, HyDE
Lost in the Middle 컨텍스트가 길면 중간 정보 놓침 Contextual Compression, Reranking
할루시네이션·근거 부족 검색된 문서가 질문과 안 맞음 Self-RAG, Corrective RAG

실제로 성능을 끌어올린 기법들

Query Translation (질문을 다르게 바꿔보기)

  • Multi-Query: LLM이 원 질문을 5개 정도 다른 표현으로 바꿔서 각각 검색 → 더 풍부한 문서 확보
  • RAG-Fusion: Multi-Query에 더해 검색 결과를 RRF(Reciprocal Rank Fusion)로 재순위화
  • Decomposition: 복합 질문을 논리적 하위 질문으로 쪼개서 순차 처리
  • HyDE: 질문을 바로 검색하는 게 아니라, LLM이 가상의 답변 문서를 먼저 생성한 다음 그 문서와 유사한 실제 문서를 찾는다. "질문-문서 비교보다 문서-문서 비교가 더 정확하다"는 아이디어인데, 실제로 삼고초려 예제에서 HyDE와 Decomposition을 썼을 때만 유비가 제갈량을 얻으려 세 번 찾아간 사건이라는 정답에 근접한 답이 나왔다.

Hybrid Search

키워드 기반 BM25 검색과 Dense 임베딩 검색을 EnsembleRetriever로 앙상블. 정확한 용어 매칭과 의미적 유사도를 동시에 잡는다.

Reranking

1차로 넉넉하게 검색(10~20개)한 뒤, Cross-Encoder 같은 리랭킹 모델로 한 번 더 순위를 매겨서 진짜 관련성 높은 3~6개만 추린다. Lost in the Middle 완화에 특히 효과적이다.

Routing

벡터스토어가 여러 개거나 데이터 소스가 다양할 때, LLM이 질문을 보고 어디서 검색할지 스스로 판단하게 만드는 기법.

이 기법들을 각각 모듈처럼 조합해서 파이프라인을 구성하는 걸 Advanced RAG, 더 세분화해서 목적별로 갈아 끼우는 걸 Modular RAG라고 부른다.

Retriever도 종류가 여러 개다

  • MultiQuery Retriever: 위에서 말한 Multi-Query를 함수 하나로 처리
  • Parent Document Retriever: 작은 청크로 검색 정확도를 잡고, LLM에는 그 청크를 포함한 큰 부모 문서를 통째로 넘겨서 문맥을 풍부하게 유지
  • Self-Query Retriever: "2020년 이후 문서 중에서"처럼 질문 안에 메타데이터 조건이 섞여 있을 때, 그 조건을 자동으로 추출해서 필터링 검색
  • Contextual Compression Retriever: 검색된 문서에서 불필요한 부분을 압축·제거
  • Multi-Vector Retriever: 요약본 벡터와 원문 저장소를 분리해서 관리 — 이미지·표처럼 텍스트 이외의 정보를 다룰 때 유용

멀티모달 RAG

PDF 안에 텍스트뿐 아니라 표, 이미지가 섞여 있으면 어떻게 할까? 전통적인 방식은 OCR로 이미지·표를 텍스트로 변환하는 거였는데, 요즘은 GPT-4o 같은 비전 모델을 직접 써서 PDF를 이미지로 변환 → 각 페이지를 비전 모델에 입력 → 설명 텍스트를 뽑아서 벡터스토어로 만드는 방식이 훨씬 간단하다. OCR 파이프라인을 따로 안 만들어도 된다.

Agentic RAG — 검색도 스스로 판단하게

여기까지가 "파이프라인형" RAG라면, 최근 트렌드는 에이전트가 알아서 검색 여부와 방법을 판단하게 만드는 것이다.

일반 RAG와의 차이:

일반 RAG Agentic RAG

흐름 단방향 (검색→생성으로 끝) 양방향 (검증·재검색 가능)
응답 Single-hop Multi-hop (복합 질문 단계적 처리)
비용/시간 상대적으로 저렴 LLM 여러 번 호출로 높음

구현은 Retriever를 create_retriever_tool로 도구화하고, LLM에 Tool Binding을 해서 질문에 따라 스스로 어떤 도구를 부를지 판단하게 만드는 방식이다. 웹검색 도구, 내부 문서 검색 도구를 같이 등록해두면 질문 성격에 따라 알아서 나눠 쓴다.

LangGraph로 워크플로우 만들기

Agentic RAG를 실제로 구현할 때는 LangGraph를 많이 쓴다. 핵심 개념은 네 가지.

  • State: 노드 간 전달되는 데이터 컨테이너 (질문, 컨텍스트, 답변, 재시도 횟수 등을 스키마로 정의)
  • Node: 특정 작업을 수행하는 함수 (검색 노드, 평가 노드, 생성 노드 등)
  • Edge: 노드 간 연결
  • Conditional Edge: 조건에 따라 분기하는 동적 연결

실습에서 만들어본 대표적인 워크플로우 세 가지:

  1. Corrective RAG: 검색된 문서의 신뢰도를 평가해서, 부족하면 질문을 재작성하거나 웹서치로 전환
  2. Adaptive RAG: 질문을 보고 "검색이 필요한 질문인지 vs LLM 자체 지식으로 답할 수 있는 질문인지"를 먼저 라우팅
  3. Self RAG: LLM이 스스로 검색 효율성과 답변 품질을 평가하면서 재검색 여부를 결정

노드가 많아질수록 연결을 잘못 구성하기 쉬워서, graph.get_graph().draw_mermaid()로 시각화해서 매번 확인하는 습관이 중요하다고 강조하는 부분이 실무적으로 와닿았다.

마무리 — NL2SQL과 RAG는 뭐가 다른가

이번 강의를 들으면서 개인적으로 정리가 된 부분은, 지금 만들고 있는 NL2SQL 시스템과 RAG의 차이다.

  • RAG는 비정형 문서(텍스트, PDF, 웹페이지)에서 관련 정보를 검색해 LLM 컨텍스트에 넣는 구조
  • NL2SQL은 자연어 질문을 정형 데이터(DB 스키마)에 맞는 쿼리로 변환하는 것에 가깝고, 프롬프트 엔지니어링과 스키마 정보 제공이 핵심이지 "유사 문서 검색"의 개념이 상대적으로 약하다

다만 완전히 별개는 아니다. 예를 들어 스키마가 매우 많은 데이터웨어하우스 환경에서는, 질문과 관련된 테이블·컬럼 설명을 먼저 RAG로 검색해서 프롬프트에 넣어주는 하이브리드 구조도 충분히 가능하다는 생각이 들었다. 이 부분은 다음에 실제로 검증해보고 따로 글로 정리해볼 예정이다.

최근에 MCP 관련 강의를 들었다. 요즘 여기저기서 "MCP 연결했다", "MCP 서버 만들었다" 하는 얘기가 하도 많이 들려서, 개념부터 실습까지 한번 제대로 정리해보고 싶었다. 이 글은 그 강의를 듣고 나서 내가 이해한 내용을 정리한 것이다.

MCP는 왜 등장했을까

GPT나 Claude 같은 LLM을 쓸 때 우리는 보통 이렇게 한다.

PDF 업로드하고 "이 내용 바탕으로 보고서 만들어줘" 텍스트 붙여넣고 "이 내용 요약해줘"

그런데 여기엔 한계가 있다. 내가 준 정보 안에서만 답변이 가능하다는 것. Gmail이나 Notion, Google Drive에 있는 내 정보를 AI가 직접 가져다 쓸 수는 없을까? 라는 니즈가 자연스럽게 생겼고, 여기서 문제가 하나 생긴다.

  • LLM 종류(Claude, GPT, Gemini...)는 계속 늘어난다
  • 연결하고 싶은 도구(Slack, Notion, Drive...)도 계속 늘어난다
  • 이걸 전부 1:1로 연결하면? N×M 조합 문제가 터진다

거기다 도구마다 LLM마다 연결 방식이 다 달라서 표준화도 안 되어 있었다. 이 문제를 풀기 위해 2024년 11월 25일, Anthropic이 MCP를 오픈소스로 공개했다. 이후 OpenAI(2025.3), Google DeepMind, Microsoft(2025.5)까지 줄줄이 채택하면서 사실상 업계 표준이 됐다.

MCP를 한 단어로 설명하면: USB-C

MCP(Model Context Protocol)를 풀어보면:

  • Model: AI 언어모델 (Claude, GPT, Gemini...)
  • Context: AI가 작업에 필요한 배경 정보·환경 (접근 가능한 데이터, 쓸 수 있는 도구)
  • Protocol: 표준화된 통신 규칙

즉, AI 모델이 외부 도구와 정보를 주고받을 때 쓰는 공통 언어다. 강의에서 든 비유가 딱 와닿았는데, MCP는 USB-C 같은 존재다. Claude, GPT, Cursor 같은 AI 툴과 Slack, Gmail, Notion 같은 외부 도구를 연결하는 공통 커넥터.

MCP 등장 전에는 도구 하나 연결하려면 함수를 직접 구현하고 테스트해야 했는데, 지금은 MCP 서버를 한 줄 등록하면 AI가 알아서 요청을 해석해서 처리한다.

구조: Host - Client - Server

MCP는 3개 요소로 구성된다. 레스토랑에 비유하면 제일 이해가 빠르다.

구성요소 비유 역할

MCP Host 고객 사용자 요청 이해, 필요한 도구 결정, 최종 응답 생성 (예: Claude Desktop, Cursor)
MCP Client 종업원 각 서버와 1:1 연결 유지, 표준 형식으로 요청·응답 중개
MCP Server 주방 실제 작업 수행 (예: GitHub MCP, Notion MCP)

손님(Host)이 "까르보나라 주세요"라고 하면, 종업원(Client)이 표준화된 주문서로 주방(Server)에 전달하고, 요리가 나오면 다시 손님 테이블로 서빙되는 흐름과 똑같다.

서버가 제공하는 기능은 크게 세 가지다.

  • Resources: 파일 읽기/쓰기, DB 조회 같은 정적 데이터
  • Tools: API 호출, 데이터 변환 같은 동적 작업
  • Prompts: 미리 정의된 명령어 템플릿

통신 방식은 STDIO(로컬 1:1 직접 연결, 가장 많이 씀), SSE(서버→클라이언트 단방향 스트리밍), WebSocket(실시간 양방향) 세 가지가 있다.

MCP가 만능은 아니다

여기가 개인적으로 제일 유익했던 부분이다. 뭘 할 수 있고 뭘 할 수 없는지를 아는 게 결국 잘 쓰는 지름길이라는 얘기였다.

할 수 있는 것

  • 정형화된 API 호출, 읽기 중심 작업 (데이터 접근·조회)
  • 개발 워크플로우 자동화, 콘텐츠 관리 자동화
  • 다중 소스 데이터 통합·분석

할 수 없는 것

  • 실시간 스트리밍 (요청-응답 패턴이라 지연이 생김)
  • 상태 의존적인 복잡한 트랜잭션 관리
  • 고성능 연산 (네트워크 오버헤드, 대용량 전송 한계)

예를 들어 실시간 주식 거래 시스템이나 대용량 비디오 처리는 MCP로 하면 안 된다. 지연 시간 문제도 있고, 이런 작업은 파일 경로만 넘기고 별도 서비스에서 처리하는 게 맞다.

보안은 생각보다 중요하다

MCP를 연결한다는 건 결국 AI에게 내 도구와 데이터에 대한 접근 권한을 주는 것이다. 그래서 보안 이슈가 은근 크다.

  • API 키·토큰은 코드에 직접 넣지 말고 환경변수로 분리, .gitignore 필수
  • 최소 권한 원칙 — 파일시스템 MCP는 읽기 전용 + 특정 디렉토리만, DB MCP는 SELECT 전용 계정
  • 실행 명령어 화이트리스트, 입력값은 정규식/스키마 검증으로 SQL Injection 방지

직접 연결해본 후기

이론만 보고 끝나면 아쉬우니까 실습도 같이 진행했다.

Context7 MCP — LLM이 프레임워크·라이브러리의 최신 공식 문서를 참조하게 해주는 서버. use context7라고 명시하면 레거시 지식이 아니라 최신 공식 가이드 기반으로 코드를 짜준다. 예를 들어 React의 useFormStatus 같은 최신 기능을 구현해달라고 하면 확실히 정확도가 다르다.

Notion MCP — "도쿄 4일차 여행 계획 세워서 노션에 정리해줘" 한 마디로 페이지가 뚝딱 생성되는 걸 보고 좀 놀랐다. 이후 대화 히스토리를 기반으로 "5~7일차 추가해줘", "4일차 일정 전체에 맞게 수정해줘" 같은 후속 요청도 자연스럽게 반영됐다.

Firecrawl MCP — 웹 크롤링·스크래핑을 AI가 바로 쓸 수 있는 구조화 데이터로 바꿔주는 서버. 교보문고 베스트셀러 페이지를 크롤링해서 표로 정리하거나, 조건(2030 직장인 추천 도서, 난이도 표시)을 걸어서 필터링하는 것까지 자연어 한 번으로 됐다. Firecrawl + Notion MCP를 같이 연결하니 크롤링 → 요약 → 저장까지 하나의 파이프라인이 됐다.

Supabase MCP + 바이브 코딩 — 개인적으로 가장 인상 깊었던 실습. Vite + React + shadcn/ui로 블로그 프로젝트를 하나 만들면서, 회원가입·로그인·글쓰기·이미지 업로드·댓글·좋아요·검색까지 전부 자연어 프롬프트만으로 구현했다. DB 스키마 생성, 인증 로직, 스토리지 연동을 코드 한 줄 안 치고 진행하는 게 신기하면서도, 한편으론 "이게 실무에 얼마나 안전하게 쓰일 수 있을까"라는 생각도 같이 들었다.

실습하면서 깨달은 팁 몇 가지:

  • MCP를 꼭 써야 하는 작업은 프롬프트에 "Supabase MCP를 활용해서 구현해줘"라고 명시해야 한다. 안 그러면 AI가 임의로 구현해버리는 경우가 있다.
  • 기능 하나가 끝나면 새 채팅으로 세션을 열고 다시 컨텍스트를 주입하는 게 좋다. 컨텍스트가 계속 쌓이면 답변 품질도 떨어지고 비용도 늘어난다.
  • 에러가 나면 브라우저 콘솔 메시지를 그대로 복사해서 AI에게 던지는 게 제일 빠른 디버깅 루트였다.

마무리

MCP는 결국 "AI가 내 도구들과 안전하게, 표준화된 방식으로 대화하게 만드는 프로토콜"이다. USB-C 비유가 처음엔 좀 뻔하다고 생각했는데, 실습을 다 해보고 나니 정말 딱 맞는 비유였다. 다양한 MCP 서버를 조합하면 조합할수록 할 수 있는 일이 기하급수적으로 늘어난다는 게 제일 인상적이었다.

다음엔 RAG를 정리한 글로 이어가려고 한다. 개인적으로는 지금 회사에서 데이터 전처리·NL2SQL 쪽 업무를 맡고 있어서, RAG와 MCP를 어떻게 같이 엮을 수 있을지 — 예를 들어 사내 문서를 MCP 서버로 노출하고 RAG로 검색하게 만드는 구조 — 도 한번 실험해볼 생각이다.

+ Recent posts