GPU 한 장으로 7B·70B 모델을 도메인에 특화시키는 법 — LoRA·QLoRA·PEFT 원리와 실무 코드
A100 클러스터가 없어서 LLM 파인튜닝을 포기한 적 있으신가요? 저도 그랬습니다. GPT-3 175B를 풀 파인튜닝하려면 수백만 원짜리 인프라가 필요하다는 걸 알고 나서 잠깐 손을 놓았었는데, LoRA를 알고 나서 생각이 완전히 바뀌었습니다. RTX 4090 한 장으로 7B 모델을 파인튜닝하고, QLoRA를 쓰면 소비자 GPU로 70B급 모델까지 건드릴 수 있게 됐으니까요.
LoRA는 "모델 전체를 업데이트하지 않아도, 작은 행렬 두 개만 학습시키면 대부분의 성능을 회복할 수 있다"는 꽤 단순하지만 강력한 아이디어에서 출발합니다. 2021년 Microsoft 연구팀이 제안한 이 방법은 현재 LLM 파인튜닝 생태계의 사실상 표준이 되었고, 이미지 생성과 음성 모델까지 영역을 넓혀가고 있습니다.
이 글에서는 LoRA가 왜 작동하는지 원리부터, 실제 SFTTrainer를 포함한 학습 코드와 함께 어떻게 적용하는지, 그리고 실무에서 자주 마주치는 함정까지 한번에 다룹니다.
핵심 개념
PEFT란 무엇인가 — LoRA가 속한 큰 그림
본론 들어가기 전에 용어 하나를 짚어두겠습니다. **PEFT(Parameter-Efficient Fine-Tuning)**는 전체 가중치를 업데이트하지 않고 소수의 파라미터만으로 모델을 특화시키는 기법군 전체를 가리킵니다. Prefix Tuning, Adapter Layers, Prompt Tuning 등이 여기에 속하는데, 현재 실무에서는 LoRA와 그 파생 기법인 QLoRA가 압도적으로 주류입니다.
LoRA가 작동하는 이유 — 저랭크 가설
LoRA의 핵심은 **"LLM 가중치 업데이트의 내재적 차원(intrinsic rank)은 생각보다 훨씬 낮다"**는 가설입니다. 쉽게 말하면, 수천×수천 크기의 행렬을 통째로 업데이트할 필요 없이, 그 안의 핵심 방향성 몇 개만 건드려도 충분하다는 거예요.
수식으로 표현하면 이렇습니다:
W' = W₀ + ΔW = W₀ + B·AW₀: 동결(freeze)된 사전학습 가중치 (d×d)A: 랭크 r의 행렬 (r×d) — 가우시안 분포로 초기화B: 랭크 r의 행렬 (d×r) — 0으로 초기화 (학습 초기 영향 없음)r ≪ d: 랭크가 작을수록 업데이트할 파라미터가 줄어듦
행렬 크기 참고: 여기서 d는 모델의 히든 레이어 크기입니다. LLaMA-3.1 8B는 d=4096, 70B는 d=8192 수준입니다. B(d×r)·A(r×d)를 계산하면 d×d가 되어 W₀와 형태가 맞습니다. 행렬 수식이 익숙하지 않으신 분은 이 부분을 건너뛰셔도 이해에 지장이 없습니다.
GPT-3 175B 기준으로, 전체 파인튜닝 대비 훈련 가능 파라미터가 10,000배 줄어들고 GPU 메모리는 3배 절감되면서도 풀 파인튜닝 성능의 90~95%를 회복합니다(원 논문 GPT-3 기준 수치이며, 태스크와 데이터셋에 따라 편차가 있습니다). 이 정도면 트레이드오프라 부르기 민망한 수준입니다 — 단, 조건이 맞을 때 한정으로요.
LoRA vs 풀 파인튜닝: 풀 파인튜닝은 기존 가중치를 직접 수정하지만, LoRA는 기존 가중치를 완전히 동결하고 추가 행렬만 학습합니다. 학습 완료 후에는 두 행렬을 병합(merge)해서 추론 시 추가 연산 없이 사용할 수 있습니다.
핵심 하이퍼파라미터 이해하기
처음 LoRA를 적용할 때 어떤 값을 넣어야 할지 막막했는데, 아래 표가 출발점이 됩니다.
| 파라미터 | 의미 | 권장 시작값 |
|---|---|---|
r (rank) |
저랭크 행렬의 차원. 낮을수록 파라미터 수 감소 | 4~16으로 시작 |
lora_alpha |
스케일링 팩터. alpha/r이 실제 적용 스케일 |
2×r 설정 권장 |
target_modules |
LoRA 적용 대상 레이어 | "all-linear" 권장 |
lora_dropout |
과적합 방지 드롭아웃 | 0.05~0.1 |
learning_rate |
학습률. LoRA는 풀 파인튜닝보다 높게 설정 | 2e-4 ~ 1e-3 |
lora_alpha는 헷갈리기 쉬운 파라미터입니다. 실제 적용 스케일은 alpha/r인데, r=16, alpha=32로 설정하면 스케일이 2가 됩니다. 관례적으로 alpha = 2×r을 기본값으로 쓰고, 나중에 튜닝하는 방식이 안전합니다.
LoRA 변형 기법 — 2025년 이후 생태계
단순 LoRA에서 출발해 지금은 목적에 따라 선택할 수 있는 변형들이 꽤 많아졌습니다.
| 기법 | 특징 | 언제 쓰면 좋은가 |
|---|---|---|
| QLoRA | 4비트 양자화(NF4) + LoRA 조합 | 메모리가 빠듯할 때. 7B를 RTX 4090에서, 또는 70B를 소비자 GPU에서 |
| DoRA | 가중치를 크기(magnitude)와 방향(direction)으로 분해 | 소규모 rank에서 안정적 성능, 하이퍼파라미터 민감도↓ |
| rsLoRA | 스케일 팩터를 alpha/√r로 조정 |
고랭크(r≥64) 훈련 안정화 |
QLoRA의 트레이드오프: 4비트 양자화는 메모리를 대폭 줄여주지만, LoRA 단독 대비 5~15%의 추가 성능 손실이 발생할 수 있습니다. 메모리가 충분하다면 LoRA 단독 사용이 더 안전합니다.
실전 적용
예시 1: HuggingFace PEFT로 LLaMA-3.1 파인튜닝하기
가장 기본이 되는 셋업입니다. 처음 도입할 때 이 코드를 템플릿으로 쓰시면 됩니다.
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from trl import SFTTrainer
from datasets import load_dataset
import torch
# 기본 모델 로드
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.1-8B",
torch_dtype=torch.bfloat16,
device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")
# LoRA 설정
config = LoraConfig(
r=16, # 시작값. 검증 손실 보며 조정
lora_alpha=32, # 2×r 기본값
target_modules="all-linear", # 모든 선형 레이어 대상
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, config)
model.print_trainable_parameters()
# trainable params: 41,943,040 || all params: 8,072,204,288 || trainable%: 0.5196
# 데이터셋 로드
dataset = load_dataset("your-dataset", split="train")
# 학습 설정
training_args = TrainingArguments(
output_dir="./lora-output",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4, # LoRA는 풀 파인튜닝(1e-5 수준)보다 높게
fp16=True,
logging_steps=10,
save_strategy="epoch",
evaluation_strategy="epoch",
load_best_model_at_end=True, # 조기 종료와 함께 사용
)
trainer = SFTTrainer(
model=model,
train_dataset=dataset,
args=training_args,
tokenizer=tokenizer,
)
trainer.train()
model.save_pretrained("./lora-output") # 어댑터만 저장. 기본 모델 크기의 ~1%출력 결과를 보면 전체 파라미터의 약 0.5%만 학습한다는 걸 확인할 수 있습니다. 이게 LoRA의 핵심 매력이에요.
| 코드 요소 | 설명 |
|---|---|
torch_dtype=torch.bfloat16 |
메모리 절감. A100/H100에서 권장 |
device_map="auto" |
멀티 GPU 자동 분산 |
target_modules="all-linear" |
2025년 이후 벤치마크에서 q_proj+v_proj만 적용하는 것보다 일관되게 더 좋은 결과 |
learning_rate=2e-4 |
LoRA는 업데이트 파라미터가 적어 높은 학습률이 잘 작동하는 경향 |
load_best_model_at_end=True |
검증 손실 기준 최적 체크포인트를 자동 복원 |
예시 2: QLoRA로 메모리 부족 상황 돌파하기
GPU 메모리가 24GB 이하라면 QLoRA가 현실적인 선택입니다. 7B 모델을 RTX 4090 한 장으로 파인튜닝하거나, QLoRA를 적용하면 70B 모델도 소비자 GPU에서 시도해볼 수 있습니다(단, 70B는 배치 크기와 시퀀스 길이에 상당한 제약이 따릅니다).
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, TrainingArguments
from trl import SFTTrainer
from datasets import load_dataset
import torch
# 4비트 양자화 설정
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True, # 이중 양자화로 추가 메모리 절감
bnb_4bit_quant_type="nf4", # NormalFloat4 — QLoRA 논문 권장
bnb_4bit_compute_dtype=torch.bfloat16
)
# 7B 또는 70B 선택 (70B는 QLoRA 없이 단일 소비자 GPU에서 불가)
model_name = "meta-llama/Llama-3.1-8B"
# model_name = "meta-llama/Llama-3.1-70B" # 70B: QLoRA 필수, VRAM 40GB+ 권장
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
# 4비트 모델 파인튜닝 준비
model = prepare_model_for_kbit_training(model)
config = LoraConfig(
r=16,
lora_alpha=32,
target_modules="all-linear",
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, config)
model.print_trainable_parameters()
dataset = load_dataset("your-dataset", split="train")
training_args = TrainingArguments(
output_dir="./qlora-output",
num_train_epochs=3,
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 메모리 제약 시 가상 배치 크기 확보
learning_rate=2e-4,
bf16=True,
logging_steps=10,
save_strategy="epoch",
evaluation_strategy="epoch",
load_best_model_at_end=True,
)
trainer = SFTTrainer(
model=model,
train_dataset=dataset,
args=training_args,
tokenizer=tokenizer,
)
trainer.train()
model.save_pretrained("./qlora-output")| 코드 요소 | 설명 |
|---|---|
bnb_4bit_use_double_quant |
양자화 상수도 다시 양자화. 추가 0.4비트 절감 |
nf4 |
NormalFloat4. 정규분포 가중치에 최적화된 데이터 타입 |
prepare_model_for_kbit_training |
4비트 모델이 그래디언트를 올바르게 처리하도록 준비 |
gradient_accumulation_steps=8 |
배치 크기를 줄이면서도 유효 배치 크기를 유지하는 방법 |
예시 3: Unsloth로 훈련 속도 2~5배 올리기
시간이 곧 돈인 상황이라면 Unsloth가 게임 체인저입니다. Flash Attention 패턴에 특화된 커스텀 Triton 커널을 사용해 어텐션 연산 자체를 GPU 메모리 병목 없이 처리하기 때문에, 같은 하드웨어에서 훈련 속도 2~5배, 메모리는 최대 80%까지 절감됩니다.
from unsloth import FastLanguageModel
from trl import SFTTrainer
from transformers import TrainingArguments
from datasets import load_dataset
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="meta-llama/Llama-3.1-8B",
max_seq_length=2048,
dtype=None, # 자동 감지
load_in_4bit=True, # QLoRA 모드
)
model = FastLanguageModel.get_peft_model(
model,
r=16,
target_modules="all-linear",
lora_alpha=32,
lora_dropout=0.05,
bias="none",
use_gradient_checkpointing="unsloth", # 메모리 30% 추가 절감
random_state=42,
)
dataset = load_dataset("your-dataset", split="train")
training_args = TrainingArguments(
output_dir="./unsloth-output",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
bf16=True,
logging_steps=10,
save_strategy="epoch",
evaluation_strategy="epoch",
load_best_model_at_end=True,
)
trainer = SFTTrainer(
model=model,
train_dataset=dataset,
args=training_args,
tokenizer=tokenizer,
)
trainer.train()
model.save_pretrained("./unsloth-output")Unsloth 주의사항: 오픈소스 버전은 단일 GPU만 지원합니다. 멀티 GPU 환경이 필요하다면 Pro 버전이 필요하거나, Axolotl을 대안으로 검토해보시면 좋습니다.
장단점 분석
솔직히 LoRA를 처음 쓸 때는 "이게 진짜 되나?" 하는 의심이 있었습니다. 실제로 써보고 나서야 적절한 상황과 그렇지 않은 상황이 분명히 갈린다는 걸 체감했습니다.
장점
| 항목 | 내용 |
|---|---|
| 메모리 효율 | 전체 파인튜닝 대비 GPU 메모리 3~20배 절감 |
| 훈련 속도 | 전체 파라미터의 0.1~1%만 업데이트 |
| 소규모 데이터 안정성 | 파라미터 수가 적어 과적합이 덜함 |
| 추론 오버헤드 없음 | 어댑터를 기본 모델에 병합하면 추론 시 추가 비용 제로 |
| 다중 어댑터 운영 | 기본 모델 하나에 태스크별 어댑터를 교체하며 사용 가능. 예: 고객사별·언어별 어댑터를 하나의 베이스 모델 위에서 운영 |
| 접근성 | 소비자용 GPU(RTX 4090)로 7B 파인튜닝, QLoRA 조합 시 70B급도 가능 |
단점 및 주의사항
LoRA가 만능은 아닙니다. 이 부분을 제대로 이해하지 못하면 실무에서 낭패를 볼 수 있습니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 성능 갭 | 최고 성능 요구 태스크에서 풀 파인튜닝 대비 5~10% 열위 가능 | DoRA 또는 all-linear + 고랭크 시도 |
| 하이퍼파라미터 민감도 | r, alpha, target_modules 선택에 따라 결과 편차 큼 |
r=4→8→16 순차적으로 실험 |
| 아키텍처 변경 불가 | 새 레이어 추가, 임베딩 차원 변경 등 근본 수정에 부적합 | 이 경우 풀 파인튜닝 검토 |
| 어댑터 버전 관리 | 다수 도메인별 어댑터 운영 시 호환성 관리 복잡 | W&B 등 실험 추적 도구 활용 |
| QLoRA 정확도 손실 | 4비트 양자화로 LoRA 대비 5~15% 추가 성능 손실 가능 | 메모리 여유 있으면 LoRA 단독 사용 |
실무에서 가장 흔한 실수
-
r을 처음부터 너무 높게 설정하는 것 —r=64부터 시작하면 시간과 메모리를 낭비할 수 있습니다.r=4나r=8로 시작해서 검증 손실을 보며 올리는 방식이 훨씬 효율적입니다. -
베이스라인 측정을 건너뛰는 것 — 파인튜닝 전 기반 모델의 성능을 재지 않으면, 실제로 얼마나 개선됐는지 알 수 없습니다. 솔직히 저도 초반에 이걸 자주 빠뜨렸는데, 나중에 후회했습니다.
-
검증 세트 없이 훈련 손실만 보는 것 — 훈련 손실이 계속 내려가도 검증 손실이 올라가기 시작하면 과적합입니다.
load_best_model_at_end=True와 함께 조기 종료를 설정해두는 것을 권장합니다.
마치며
GPU 한 장, 소비자용 하드웨어, 그리고 소수의 도메인 데이터만 있어도 LLM을 내 서비스에 맞게 특화시킬 수 있다는 게 LoRA가 바꾼 현실입니다. 원리가 단순한 만큼 진입장벽도 낮지만, 직접 코드를 돌려봐야 왜 이 기법이 생태계 표준이 됐는지 체감할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
-
Google Colab에서 무료로 첫 실험 —
pip install peft transformers bitsandbytes trl datasets를 설치하고, 위의 QLoRA 코드를 그대로 붙여넣어 볼 수 있습니다. Colab T4 무료 티어에서도 7B 모델 파인튜닝이 가능합니다(단, 배치 크기 1, 시퀀스 길이 512 수준의 제약이 따릅니다). -
model.print_trainable_parameters()로 설정 확인 — 훈련 시작 전 이 한 줄로 실제 몇 %의 파라미터를 학습하는지 확인해볼 수 있습니다.r과target_modules조합을 바꿔가며 실험해보시면 좋습니다. -
작은 데이터셋으로 과적합 테스트 — 학습 데이터 100개짜리 소규모 셋으로 먼저 모델이 과적합되는지 확인해볼 수 있습니다. 과적합이 된다면 파이프라인이 정상이라는 신호입니다.
참고 자료
- LoRA: Low-Rank Adaptation of Large Language Models | arXiv
- LoRA 개념 가이드 | HuggingFace PEFT 공식 문서
- LLM Course - LoRA 챕터 | HuggingFace
- Efficient Fine-Tuning with LoRA | Databricks Blog
- Introducing DoRA | NVIDIA Technical Blog
- Optimizing LoRA Target Module Selection | Amazon Science
- Axolotl vs Unsloth vs TorchTune 비교 | Spheron
- Unsloth + Red Hat Training Hub 공식 발표 | Red Hat Developers
- Microsoft LoRA 공식 GitHub