목차
- "GPT한테 물어봤더니 우리 회사 용어를 하나도 모르더라고요"
- LoRA / QLoRA가 뭔지부터 짚고 넘어가죠
- 환경 준비 — 이 부분에서 삽질 좀 했습니다 ㅎㅎ
- 학습 데이터 준비 — 사실 이게 제일 중요해요
- QLoRA 파인튜닝 실전 코드
- 학습된 모델로 추론하기
- ⚠️ 실제로 겪은 트러블슈팅 모음
- 문제 1: CUDA out of memory 에러
- 문제 2: 모델이 Instruction을 무시하고 이상한 말을 함
- 문제 3: Loss가 안 줄어듦
- 문제 4: 학습 후 모델이 기존 능력을 잃음 (Catastrophic Forgetting)
- 🎉 결과 확인 — 드디어 됐다!
- 정리 — 오늘 배운 것들
- 자주 묻는 질문 (FAQ)
"GPT한테 물어봤더니 우리 회사 용어를 하나도 모르더라고요"
이런 경험 한 번쯤 있으시죠? 저도 작년에 사내 기술 문서 Q&A 봇을 만들려고 ChatGPT API를 붙여봤는데... 일반적인 질문엔 잘 대답하는데 우리 팀 특유의 약어나 내부 시스템 이름을 물어보면 완전 엉뚱한 소리를 하더라고요. RAG(Retrieval-Augmented Generation, 검색 기반 생성)로 어느 정도 해결은 됐는데, 근본적으로 모델 자체가 도메인을 이해하게 하고 싶다는 생각이 들었습니다.
그래서 시작한 게 LLM 파인튜닝이었는데요. 처음엔 "GPU 수십 장 없이 가능하겠어?" 싶었는데, LoRA(Low-Rank Adaptation)와 QLoRA(Quantized LoRA) 덕분에 홈랩 서버 한 대로도 충분히 가능하다는 걸 알게 됐습니다. 오늘은 제가 직접 삽질하면서 익힌 실전 LLM 파인튜닝 과정을 공유해 드릴게요.
▲ LLM 파인튜닝의 전체 파이프라인 — 데이터 준비부터 학습, 추론까지의 흐름을 한눈에 볼 수 있습니다.
LoRA / QLoRA가 뭔지부터 짚고 넘어가죠
파인튜닝이라는 개념 자체는 간단합니다. 이미 잘 학습된 모델을 내 데이터로 추가 학습시키는 거예요. 근데 문제가 있습니다. LLaMA 3나 Mistral 같은 모델은 파라미터가 수십억 개거든요. 이걸 전부 다시 학습시키려면 A100 GPU가 여러 장 필요하고, 메모리도 수백 GB가 필요합니다. 현실적으로 홈랩에서 불가능한 수준이죠.
그래서 나온 게 LoRA(Low-Rank Adaptation, 저랭크 적응 학습)입니다. 쉽게 말해서, 모델 전체를 학습시키는 게 아니라 "변화량"만 학습시키는 방식이에요. 원래 모델의 가중치(weight)는 그대로 얼려두고(freeze), 그 옆에 훨씬 작은 보조 행렬(adapter)만 학습시키는 거거든요.
그리고 QLoRA(Quantized LoRA)는 여기서 한 발 더 나아가서, 원본 모델을 4비트(4-bit)로 양자화(quantization)해서 메모리를 훨씬 적게 쓰면서 LoRA 학습을 하는 방식입니다. 제가 RTX 3090 한 장으로 13B 모델을 파인튜닝할 수 있었던 게 바로 QLoRA 덕분이었어요.
| 방식 | 전체 파인튜닝 (Full Fine-tuning) | LoRA | QLoRA |
|---|---|---|---|
| 학습 파라미터 수 | 전체 (수십억) | 소수 (수백만) | 소수 (수백만) |
| 메모리 요구량 | 매우 높음 | 중간 | 낮음 |
| 원본 모델 정밀도 | FP16/BF16 | FP16/BF16 | 4-bit (NF4) |
| 홈랩 가능 여부 | ❌ (7B 이상 어려움) | △ (7B 정도 가능) | ✅ (13B~34B도 가능) |
| 성능 손실 | 없음 (기준) | 미미 | 약간 있음 |
저처럼 GPU 한 장으로 실험하시는 분이라면 QLoRA가 현실적인 선택입니다. 성능 차이가 생각보다 크지 않아서 실제 서비스에서도 충분히 쓸 수 있는 수준이더라고요.
환경 준비 — 이 부분에서 삽질 좀 했습니다 ㅎㅎ
제 홈랩 환경 기준으로 설명드릴게요. CUDA 버전 호환성 때문에 처음에 꽤 고생했거든요. CUDA 버전과 PyTorch 버전, bitsandbytes 버전이 삼각형처럼 맞물려야 합니다. 하나라도 틀리면 에러 폭탄이 터져요.
# Python 가상환경 먼저 만들어주세요 (conda 추천)
conda create -n llm-finetune python=3.10
conda activate llm-finetune
# PyTorch 설치 (CUDA 11.8 기준 — 본인 환경에 맞게 조정)
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
# 핵심 라이브러리 설치
pip install transformers==4.40.0
pip install peft==0.10.0 # LoRA 구현체
pip install bitsandbytes==0.43.0 # 4-bit 양자화
pip install accelerate==0.29.0
pip install datasets==2.18.0
pip install trl==0.8.6 # SFT Trainer 포함
# 설치 확인
python -c "import torch; print(torch.cuda.is_available())"
💡 팁: bitsandbytes는 Windows에서 설치가 까다롭습니다. WSL2나 Linux 환경을 강력 추천드려요. 저도 처음에 Windows에서 삽질하다가 결국 Ubuntu로 갈아탔습니다.
학습 데이터 준비 — 사실 이게 제일 중요해요
파인튜닝에서 코드보다 더 중요한 게 데이터입니다. 진짜로요. 좋은 데이터 1000개가 나쁜 데이터 10만 개보다 낫다는 걸 직접 경험했거든요.
LLM 파인튜닝용 데이터는 보통 Instruction 형식으로 만듭니다. 이렇게 생겼어요:
[
{
"instruction": "우리 회사의 배포 프로세스를 설명해줘",
"input": "",
"output": "우리 회사의 배포 프로세스는 다음과 같습니다. 먼저 개발자가 feature 브랜치에서 작업 후 PR을 올리면..."
},
{
"instruction": "다음 에러 로그를 분석해줘",
"input": "ERROR: Connection refused to internal-db-01:5432",
"output": "이 에러는 PostgreSQL 데이터베이스 서버에 연결이 거부된 것입니다. 주요 원인으로는..."
}
]
이걸 학습 형식으로 변환하는 코드를 보여드릴게요:
from datasets import Dataset
import json
def format_instruction(sample):
"""Alpaca 스타일의 프롬프트 포맷"""
if sample["input"]:
prompt = f"""### Instruction:
{sample["instruction"]}
### Input:
{sample["input"]}
### Response:
{sample["output"]}"""
else:
prompt = f"""### Instruction:
{sample["instruction"]}
### Response:
{sample["output"]}"""
return {"text": prompt}
# 데이터 로드 및 변환
with open("my_domain_data.json", "r", encoding="utf-8") as f:
raw_data = json.load(f)
dataset = Dataset.from_list(raw_data)
dataset = dataset.map(format_instruction)
print(f"총 학습 데이터: {len(dataset)}개")
print(dataset[0]["text"][:200]) # 첫 번째 샘플 미리보기
⚠️ 주의사항: 데이터 품질 체크는 필수입니다. 중복 데이터, 너무 짧은 응답(10자 이하), 특수문자가 깨진 데이터는 학습 전에 꼭 걸러내세요. 저는 이걸 건너뛰었다가 모델이 이상한 패턴을 학습해서 처음부터 다시 한 적 있습니다.
QLoRA 파인튜닝 실전 코드
드디어 본론입니다. 제가 실제로 사용하는 학습 스크립트예요. 주석을 충분히 달아놨으니 따라가 보세요.
▲ QLoRA 학습 진행 중 GPU 메모리 사용량과 학습 손실(loss) 변화 — RTX 3090 기준으로 13B 모델도 충분히 학습 가능합니다.
import torch
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
TrainingArguments
)
from peft import LoraConfig, get_peft_model, TaskType
from trl import SFTTrainer
from datasets import load_dataset
# ===== 1. 모델 설정 =====
# 베이스 모델 선택 (Hugging Face Hub에서 다운로드)
# 예시: meta-llama/Meta-Llama-3-8B, mistralai/Mistral-7B-v0.1 등
BASE_MODEL = "mistralai/Mistral-7B-v0.1" # 본인 용도에 맞게 변경
OUTPUT_DIR = "./my-domain-model"
# ===== 2. 4-bit 양자화 설정 (QLoRA의 핵심) =====
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 4-bit로 모델 로드
bnb_4bit_use_double_quant=True, # 이중 양자화로 메모리 추가 절약
bnb_4bit_quant_type="nf4", # NF4 타입 (QLoRA 논문 권장)
bnb_4bit_compute_dtype=torch.bfloat16 # 계산은 bfloat16으로
)
# ===== 3. 모델 & 토크나이저 로드 =====
print("모델 로딩 중... (처음엔 시간이 좀 걸려요)")
model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL,
quantization_config=bnb_config,
device_map="auto", # GPU 자동 할당
trust_remote_code=True
)
model.config.use_cache = False # 학습 시엔 꺼야 합니다
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token # 패딩 토큰 설정
tokenizer.padding_side = "right" # 오른쪽 패딩 (중요!)
# ===== 4. LoRA 어댑터 설정 =====
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=16, # 랭크(rank) — 클수록 더 많이 학습, 메모리도 더 씀
lora_alpha=32, # 스케일링 파라미터 (보통 r의 2배)
target_modules=[ # 어떤 레이어에 LoRA 적용할지
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"
],
lora_dropout=0.05,
bias="none"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters() # 학습 파라미터 비율 확인
# 출력 예시: trainable params: 20,185,088 || all params: 3,772,923,904 || trainable%: 0.5348
# ===== 5. 학습 인자 설정 =====
training_args = TrainingArguments(
output_dir=OUTPUT_DIR,
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # 유효 배치 = 4 * 4 = 16
learning_rate=2e-4,
fp16=False,
bf16=True, # bfloat16 사용 (Ampere 이상 GPU)
logging_steps=10,
save_steps=100,
warmup_ratio=0.03,
lr_scheduler_type="cosine",
report_to="none", # wandb 쓰시면 "wandb"로 변경
optim="paged_adamw_8bit" # 메모리 효율적인 옵티마이저
)
# ===== 6. 데이터셋 로드 =====
# 앞서 준비한 데이터셋 사용
dataset = load_dataset("json", data_files="my_domain_data.json", split="train")
dataset = dataset.map(format_instruction) # 앞서 정의한 함수
# ===== 7. SFT Trainer로 학습 시작 =====
trainer = SFTTrainer(
model=model,
train_dataset=dataset,
peft_config=lora_config,
dataset_text_field="text",
max_seq_length=2048,
tokenizer=tokenizer,
args=training_args,
)
print("학습 시작!")
trainer.train()
# ===== 8. 어댑터 저장 =====
trainer.model.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
print(f"\n✅ 학습 완료! 모델 저장 위치: {OUTPUT_DIR}")
여기서 중요한 포인트! r 값(랭크)을 너무 크게 잡으면 과적합(overfitting) 위험이 있고, 너무 작으면 학습이 덜 됩니다. 저는 도메인 데이터 양에 따라 데이터 1000개 이하면 r=8, 5000개 이상이면 r=16~32를 주로 씁니다.
학습된 모델로 추론하기
학습이 끝났으면 실제로 써봐야죠! 저장된 LoRA 어댑터를 베이스 모델에 합쳐서 추론하는 코드입니다.
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
import torch
BASE_MODEL = "mistralai/Mistral-7B-v0.1"
ADAPTER_PATH = "./my-domain-model"
# 베이스 모델 로드 (추론 시엔 양자화 없이 할 수도 있어요)
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL,
torch_dtype=torch.float16,
device_map="auto"
)
# LoRA 어댑터 합치기
model = PeftModel.from_pretrained(model, ADAPTER_PATH)
model = model.merge_and_unload() # 어댑터를 모델에 완전히 병합
model.eval()
def generate_response(instruction, input_text="", max_new_tokens=512):
if input_text:
prompt = f"### Instruction:\n{instruction}\n\n### Input:\n{input_text}\n\n### Response:\n"
else:
prompt = f"### Instruction:\n{instruction}\n\n### Response:\n"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
temperature=0.7,
do_sample=True,
top_p=0.9,
repetition_penalty=1.1 # 반복 방지
)
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
# 프롬프트 부분 제거하고 응답만 반환
return response.split("### Response:")[-1].strip()
# 테스트
response = generate_response("우리 회사의 배포 프로세스를 설명해줘")
print(response)
⚠️ 실제로 겪은 트러블슈팅 모음
이 섹션이 사실 제일 중요할 수도 있어요. 저처럼 삽질 안 하시라고 정리해봤습니다.
문제 1: CUDA out of memory 에러
가장 흔한 문제입니다. 해결책은 이렇습니다:
per_device_train_batch_size를 줄이고gradient_accumulation_steps를 늘리세요 (유효 배치 크기는 유지하면서)max_seq_length를 줄여보세요. 2048 → 1024로만 줄여도 메모리가 확 줄어듭니다- 학습 전에
torch.cuda.empty_cache()호출
문제 2: 모델이 Instruction을 무시하고 이상한 말을 함
데이터 포맷이 안 맞는 경우가 대부분입니다. 학습 데이터의 프롬프트 형식과 추론 시 프롬프트 형식이 완전히 동일해야 합니다. 공백 하나, 줄바꿈 하나도 다르면 모델이 헷갈려해요.
문제 3: Loss가 안 줄어듦
Learning rate 문제일 가능성이 높습니다. 2e-4가 일반적이지만, 데이터가 적으면 1e-4로 낮춰보세요. Warmup 비율도 0.03 → 0.05로 올려보는 것도 방법입니다.
문제 4: 학습 후 모델이 기존 능력을 잃음 (Catastrophic Forgetting)
이건 LoRA의 장점 중 하나인데, 그래도 데이터에 너무 도메인 특화 내용만 있으면 발생할 수 있어요. 일반 대화 데이터를 10~20% 섞어주면 많이 해결됩니다. Alpaca 데이터셋이나 OpenHermes 같은 공개 데이터셋에서 일부 샘플링해서 섞어주세요.
▲ 파인튜닝 전후 도메인 특화 질문에 대한 응답 비교 — 학습 후 도메인 용어와 맥락을 정확히 이해하는 것을 확인할 수 있습니다.
🎉 결과 확인 — 드디어 됐다!
제가 실제로 사내 기술 문서 약 2,000개로 파인튜닝했을 때 결과를 공유하면, 파인튜닝 전에는 우리 팀 특유의 시스템 이름이나 약어를 물어보면 모르거나 엉뚱한 답을 했는데, 파인튜닝 후에는 정확하게 내부 컨텍스트에 맞는 답변을 해주더라고요. 특히 "이 에러 코드가 뭔 뜻이야?"류의 질문에서 차이가 극명했습니다.
모델 평가는 정성적 평가(직접 질문해보기)와 더불어, 보류해둔 테스트 셋으로 간단히 정량 평가도 해보세요. 저는 ROUGE 스코어보다는 실제 사용자 피드백이 더 의미 있다고 생각하는 편이에요.
# 학습된 모델 파일 구조 확인
ls -la ./my-domain-model/
# adapter_config.json — LoRA 설정 정보
# adapter_model.safetensors — 학습된 가중치 (수십~수백 MB)
# tokenizer.json
# tokenizer_config.json
# special_tokens_map.json
# 파일 크기 확인 (베이스 모델 대비 훨씬 작습니다)
du -sh ./my-domain-model/
# 예시: 약 80~300MB (베이스 모델은 수 GB인 것과 비교)
이게 LoRA의 또 다른 장점인데요, 어댑터 파일만 배포하면 되니까 용량이 매우 작습니다. 베이스 모델은 공유하고 어댑터만 바꿔끼는 방식으로 여러 도메인 모델을 관리할 수 있어서 정말 편해요.
▲ LoRA/QLoRA 파인튜닝 핵심 파라미터 요약 — r, alpha, target_modules 등 주요 설정값 선택 가이드
정리 — 오늘 배운 것들
처음에 "GPU 한 장으로 LLM 파인튜닝이 가능할까?"라는 의심으로 시작했는데, QLoRA 덕분에 충분히 가능하다는 걸 직접 확인했습니다. 핵심만 정리해볼게요.
- ✅ QLoRA는 소비자급 GPU로도 수십억 파라미터 모델을 파인튜닝할 수 있게 해줍니다
- ✅ 데이터 품질이 양보다 중요합니다. 적더라도 잘 만든 데이터가 훨씬 낫습니다
- ✅ LoRA rank(r)는 데이터 양에 맞게 조절하세요. 처음엔 r=16이 무난합니다
- ✅ 프롬프트 형식을 학습과 추론에서 완전히 동일하게 유지하세요
- ✅ 일반 데이터를 일부 섞어서 Catastrophic Forgetting을 방지하세요
다음 글에서는 이렇게 만든 모델을 Ollama나 vLLM으로 서빙(serving)하는 방법을 다룰 예정입니다. API 서버로 띄워서 실제 서비스에 붙이는 과정까지 이어서 정리해드릴게요. 이전 글에서 RAG 구축 과정을 다뤘으니 참고하시면 파인튜닝과 함께 쓰는 방법도 이해하기 쉬울 거예요.
궁금한 점은 댓글로 남겨주세요. 저도 아직 공부 중이라 같이 논의하면 더 좋을 것 같습니다 😊
자주 묻는 질문 (FAQ)
- Q. 최소 얼마나 많은 데이터가 필요한가요?
- A. 경험상 최소 500~1000개 정도의 고품질 instruction 데이터면 의미 있는 파인튜닝이 됩니다. 물론 많을수록 좋지만, 품질이 더 중요합니다.
- Q. 어떤 베이스 모델을 선택하는 게 좋을까요?
- A. 한국어 도메인이라면 한국어 데이터로 사전학습된 모델을 베이스로 쓰는 게 유리합니다. EEVE-Korean, EXAONE 등 국내에서 공개한 모델들도 좋은 선택지입니다. 영어 도메인이라면 Mistral 7B나 LLaMA 3 8B가 무난합니다.
- Q. 파인튜닝 한 번에 얼마나 걸리나요?
- A. 데이터 양과 GPU 성능에 따라 다르지만, RTX 3090 기준 1000개 데이터, 3 에폭 학습에 1~2시간 정도 예상하시면 됩니다.
- Q. LoRA 어댑터를 베이스 모델에 완전히 병합해야 하나요?
- A. 추론 시 편의를 위해
merge_and_unload()로 병합할 수 있지만, 어댑터를 분리 유지하면 나중에 다른 어댑터로 교체하기 쉽습니다. 여러 도메인 모델을 관리한다면 분리 유지를 추천합니다.
'IT > AI' 카테고리의 다른 글
| [AI] 로컬 LLM 활용: Ollama와 최신 Claude 모델 비교 분석 (1) | 2026.05.08 |
|---|---|
| [AI] GitHub Copilot 활용 개발 생산성 극대화: 최신 기능 가이드 (0) | 2026.05.08 |
| [AI] Ollama로 로컬 AI 환경 구축: LLM 모델 설치 및 활용 완벽 가이드 (2) | 2026.05.07 |
| [AI] AI 코딩 도우미 비교: GitHub Copilot vs Cursor AI, 개발 생산성 극대화 전략 (2) | 2026.04.30 |
| [AI] Gemini API 실전 활용 가이드: 멀티모달 기능으로 AI 서비스 구축하기 (1) | 2026.04.30 |
| [AI] LangChain RAG 시스템 구축: 임베딩과 검색 증강 생성 실전 가이드 (0) | 2026.04.30 |