포스트

2026년 7월 기준 LoRA/QLoRA 파인튜닝 실전 튜토리얼: “내 GPU로 어디까지 가능할까?”

2026년 7월 기준 LoRA/QLoRA 파인튜닝 실전 튜토리얼: “내 GPU로 어디까지 가능할까?”

들어가며

LLM fine-tuning의 현실적인 문제는 간단합니다. 데이터는 있는데 GPU 메모리가 부족하고, full fine-tuning은 비용/시간/불안정성(학습 폭주, catastrophic forgetting)이 큽니다. LoRA/QLoRA는 이 문제를 “모델 전체를 업데이트하지 말고, 적은 추가 파라미터만 학습하자”로 푸는 대표적인 PEFT(Parameter-Efficient Fine-Tuning) 계열입니다. (huggingface.co)

  • 언제 쓰면 좋나
    • 사내 도메인(고객센터, 법무, 의료/제약, 제조)처럼 스타일/용어/정책을 모델에 주입하고 싶을 때
    • 1~2장 소비자 GPU(예: 24GB, 16GB)에서 7B~30B급을 현실적으로 튜닝하고 싶을 때(특히 QLoRA)
    • 여러 고객/프로덕트별로 adapter만 갈아끼워 배포하고 싶을 때
  • 언제 쓰면 안 되나
    • “지식 자체”를 광범위하게 추가해야 하는데 데이터가 작거나 품질이 낮으면 LoRA도 한계가 큼(환각/편향이 유지됨)
    • 구조적 능력(장문 추론, 수학 능력)을 크게 바꾸려면 데이터/학습 설계가 먼저고, LoRA는 만능 스위치가 아님
    • 배포가 “단일 파일/단일 엔진”만 허용되고 adapter merge/관리 비용이 부담이라면 운영 복잡도가 올라감

🔧 핵심 개념

1) LoRA: “가중치 업데이트를 저랭크 행렬로 근사”

Transformer의 선형층(예: attention의 q/k/v/o projection, MLP의 up/down/gate 등)에서 full fine-tuning은 ΔW 전체를 학습합니다. LoRA는 이를 저랭크 분해로 바꿉니다.

  • 원래: W' = W + ΔW
  • LoRA: W' = W + (B @ A) * (alpha / r)
    여기서 A는 (r × in), B는 (out × r)이고 r이 작기 때문에 학습 파라미터/optimizer state가 급감합니다. (huggingface.co)

즉, forward는 xW에 더해 x(B@A)가 추가되는 형태로 동작하고, W는 frozen, A/B만 업데이트합니다.

2) QLoRA: “base model을 4-bit로 들고, LoRA만 학습”

QLoRA의 핵심은 “학습은 LoRA로 하되, base model 자체는 4-bit로 메모리에 올려서 더 큰 모델을 작은 GPU에서” 입니다. 이때 중요한 구성요소가 3개입니다. (arxiv.org)

1) NF4(4-bit NormalFloat)
LLM weight 분포가 대체로 정규분포에 가까운 특성을 이용해 4-bit quantization의 손실을 줄이는 방식입니다. (huggingface.co)

2) Double Quantization
4-bit로 양자화할 때 필요한 scale/zero-point 같은 “양자화 상수” 자체도 다시 압축해 오버헤드를 줄입니다. (arxiv.org)

3) Paged Optimizers(메모리 스파이크 완화)
학습 중 순간적으로 메모리가 튀는 구간(특히 optimizer step, activation checkpoint 경계)을 unified memory paging으로 완화합니다. (arxiv.org)

3) LoRA vs QLoRA: 선택 기준(실무 관점)

  • LoRA(16-bit/8-bit 로딩): 속도/안정성이 비교적 좋고, 디버깅이 쉬움. 대신 “올릴 수 있는 base 모델 크기”가 GPU VRAM에 의해 제한.
  • QLoRA(4-bit 로딩): 같은 VRAM에서 더 큰 모델을 다룰 수 있음. 대신
    • compute dtype/quantization config에 따라 성능/안정성이 흔들리고
    • 특정 조합에서 OOM/느려짐/품질 열화가 발생할 수 있어 설정 감도가 큼. (huggingface.co)

💻 실전 코드

아래는 현실적인 시나리오(고객센터 상담 로그 → “사내 정책 기반 답변 스타일” SFT)로 QLoRA를 적용하는 예시입니다. TRL의 SFTTrainer + PEFT + bitsandbytes 조합이 2026년에도 가장 흔한 스택입니다. (huggingface.co)

0) 환경/의존성

1
2
# CUDA 환경에서 실행 가정
pip install -U torch transformers peft bitsandbytes accelerate datasets trl

(위 패키지 조합은 TRL의 PEFT 통합 및 bitsandbytes 4-bit 로딩 문서 흐름과 동일한 방향입니다.) (huggingface.co)

1) 데이터 준비(“toy”가 아닌 형태)

  • 입력: 상담 요약 + 고객 발화 + 관련 정책
  • 출력: 상담사가 최종 응답으로 내보낼 문장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# prepare_dataset.py
from datasets import Dataset

def build():
    rows = [
        {
            "ticket_id": "CS-104233",
            "customer": "지난달 결제한 구독이 자동 연장됐는데 환불 가능한가요?",
            "policy": "구독 환불: 결제 후 7일 이내, 사용량 10% 미만이면 전액 환불. 7일 초과 시 부분 환불 불가.",
            "agent_answer": "확인 감사합니다. 결제 후 7일 이내이며 사용량이 10% 미만이면 전액 환불이 가능합니다. "
                           "현재 사용량/결제일을 확인해 안내드릴게요. 결제일과 계정 이메일을 알려주세요."
        },
        {
            "ticket_id": "CS-104987",
            "customer": "배송이 늦어져서 취소하고 싶은데 이미 출고됐다고 하네요.",
            "policy": "출고 후 취소 불가. 수령 후 14일 이내 반품 가능(미개봉), 반품 배송비 고객 부담.",
            "agent_answer": "출고가 완료된 주문은 취소가 어렵습니다. 다만 수령 후 14일 이내 미개봉 상태라면 반품이 가능하며, "
                           "반품 배송비는 고객 부담입니다. 원하시면 반품 절차를 바로 안내드릴까요?"
        },
    ]
    ds = Dataset.from_list(rows)

    # SFT는 보통 "prompt -> response" 형태의 단일 text 컬럼을 만듭니다.
    def to_text(ex):
        prompt = (
            "너는 회사 고객센터 상담사다. 아래 정책을 준수해 정중하고 간결하게 답변하라.\n"
            f"[정책]\n{ex['policy']}\n\n"
            f"[고객]\n{ex['customer']}\n\n"
            "[답변]\n"
        )
        return {"text": prompt + ex["agent_answer"]}

    return ds.map(to_text, remove_columns=ds.column_names)

if __name__ == "__main__":
    ds = build()
    print(ds[0]["text"])

예상 출력(일부):

  • 정책/고객 문맥을 포함한 prompt 뒤에 agent_answer가 붙은 단일 text

2) QLoRA로 SFT 학습(실행 가능한 형태)

아래 코드는 4-bit NF4 로딩 + LoRA adapter 학습의 “표준 골격”입니다. 핵심은 BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", ...) 조합입니다. (huggingface.co)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# train_qlora_sft.py
import os
import torch

from datasets import load_from_disk
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig
from trl import SFTTrainer, SFTConfig

MODEL_ID = os.environ.get("MODEL_ID", "meta-llama/Meta-Llama-3-8B-Instruct")  # 예시
OUT_DIR  = os.environ.get("OUT_DIR", "./cs-qlora-adapter")

def main():
    # 1) dataset 로드 (prepare_dataset.py 결과를 저장해뒀다고 가정)
    # ds.save_to_disk("./cs_sft_ds")
    ds = load_from_disk("./cs_sft_ds")

    # 2) tokenizer
    tok = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True)
    if tok.pad_token is None:
        tok.pad_token = tok.eos_token

    # 3) QLoRA 4-bit 로딩 설정
    bnb_cfg = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",              # QLoRA 핵심
        bnb_4bit_use_double_quant=True,         # double quantization
        bnb_4bit_compute_dtype=torch.bfloat16,  # Ampere+ 권장(가능하면 bf16)
    )

    model = AutoModelForCausalLM.from_pretrained(
        MODEL_ID,
        quantization_config=bnb_cfg,
        device_map="auto",
        torch_dtype=torch.bfloat16,
    )
    model.config.use_cache = False  # 학습 시 cache off (메모리/경고 회피)

    # 4) LoRA 설정: 어디에 꽂을지(target_modules)가 품질/속도/VRAM에 직결
    # 모델별 모듈명이 다를 수 있음 (q_proj/v_proj 등)
    lora = LoraConfig(
        r=16,
        lora_alpha=32,
        lora_dropout=0.05,
        bias="none",
        task_type="CAUSAL_LM",
        target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],  # 필요 시 MLP까지 확장
    )

    # 5) TRL SFT 설정 (현실적인 기본값)
    cfg = SFTConfig(
        output_dir=OUT_DIR,
        per_device_train_batch_size=1,
        gradient_accumulation_steps=16,
        learning_rate=2e-4,
        num_train_epochs=2,
        logging_steps=10,
        save_steps=200,
        max_seq_length=2048,
        bf16=True,                       # 가능할 때
        gradient_checkpointing=True,      # VRAM 절약(대신 느려짐)
        optim="paged_adamw_8bit",         # QLoRA 메모리 스파이크 완화에 흔히 사용
        warmup_ratio=0.03,
        lr_scheduler_type="cosine",
        report_to="none",
    )

    trainer = SFTTrainer(
        model=model,
        tokenizer=tok,
        train_dataset=ds,
        peft_config=lora,
        dataset_text_field="text",
        args=cfg,
    )

    trainer.train()
    trainer.model.save_pretrained(OUT_DIR)
    tok.save_pretrained(OUT_DIR)

    # (선택) trainable params 확인은 운영에서 유용
    trainable = sum(p.numel() for p in trainer.model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in trainer.model.parameters())
    print(f"Trainable params: {trainable:,} / {total:,} ({trainable/total:.4%})")

if __name__ == "__main__":
    main()

예상 결과물:

  • ./cs-qlora-adapter/에 adapter 가중치/설정이 저장
  • trainable 파라미터 비율이 수% 이하로 떨어져야 “LoRA를 제대로 쓴 것”

3) 추론에서 적용(배포 전 검증)

Adapter는 base model 위에 얹어서 로드합니다(merge 여부는 운영 선택).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# infer_with_adapter.py
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

BASE = "meta-llama/Meta-Llama-3-8B-Instruct"
ADAPTER = "./cs-qlora-adapter"

tok = AutoTokenizer.from_pretrained(BASE, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(BASE, device_map="auto", torch_dtype=torch.bfloat16)
model = PeftModel.from_pretrained(model, ADAPTER)
model.eval()

prompt = (
    "너는 회사 고객센터 상담사다. 아래 정책을 준수해 정중하고 간결하게 답변하라.\n"
    "[정책]\n출고 후 취소 불가. 수령 후 14일 이내 반품 가능(미개봉), 반품 배송비 고객 부담.\n\n"
    "[고객]\n배송이 늦어서 취소하고 싶어요.\n\n"
    "[답변]\n"
)

inputs = tok(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
    out = model.generate(**inputs, max_new_tokens=128, do_sample=False)
print(tok.decode(out[0], skip_special_tokens=True))

⚡ 실전 팁 & 함정

Best Practice (2~3개)

1) target_modules를 “의도적으로” 줄였다가 늘려라
처음부터 all-linear로 가면 VRAM/시간이 늘고 과적합도 빨라집니다. 보통 attention(q/v 중심) → attention 전체 → MLP 순으로 확장하며 품질/비용을 비교합니다. (PEFT 문서에서도 LoRA 적용 범위가 메모리/효율에 큰 영향을 줍니다.) (huggingface.co)

2) 데이터를 “정책/근거 + 출력” 구조로 고정하라
QLoRA는 메모리 이득이 크지만, 성능을 “마법처럼” 올려주진 않습니다. 실제로는 prompt 템플릿, 근거(policy), 출력 형식이 일관될수록 튜닝 효율이 올라갑니다(운영에서 특히 재현성 차이).

3) bnb_4bit_compute_dtype는 하드웨어에 맞춰라(bf16 우선)
bitsandbytes/Transformers 문서에서 4-bit 로딩 시 compute dtype 선택을 명시합니다. bf16이 되는 GPU면 bf16이 대체로 안정적입니다. (huggingface.co)

흔한 함정/안티패턴

  • “학습은 돌아갔는데 품질이 안 오른다”
    대부분 (a) 데이터가 output-only(근거 없음)로 불안정하거나 (b) 학습이 너무 짧거나 (c) target_modules가 너무 제한적이거나 (d) LR이 과도해 스타일만 찌그러진 케이스입니다.
  • 4-bit 로딩 설정 미스로 OOM/속도 급락
    nf4/double quant/compute dtype 조합이 어긋나면 동일 VRAM에서도 체감이 크게 달라집니다. (huggingface.co)
  • 평가 없이 loss만 보고 끝내기
    실무는 “정책 준수/금칙어/톤/포맷”이 KPI입니다. 샘플링 온도 고정, 고정 프롬프트 세트로 회귀 테스트를 만들어두세요.

비용/성능/안정성 트레이드오프

  • QLoRA는 VRAM을 아끼는 대신 느려질 수 있음(4-bit dequant/커널, checkpointing 등)
  • rank(r) 올리면 표현력↑, VRAM/시간↑, 과적합 위험↑
  • gradient_checkpointing 켜면 VRAM↓, 속도↓, 디버깅 난이도↑

🚀 마무리

LoRA는 “학습 파라미터를 줄여서” 튜닝을 현실화하고, QLoRA는 거기에 더해 base model을 4-bit(NF4 + double quant)로 들고 가며 “더 큰 모델을 내 GPU에” 올리는 전략입니다. (arxiv.org)

도입 판단 기준은 단순합니다.

  • VRAM이 빡빡하고 모델을 키워야 하면 → QLoRA
  • 속도/안정성/운영 단순성이 더 중요하면 → (가능한 선에서) LoRA
  • 둘 다 공통으로, 성공 여부의 70%는 “기법”보다 데이터/템플릿/평가(회귀 테스트)에 달려 있습니다.

다음 학습 추천:

  • Hugging Face PEFT의 LoRA 고급 옵션(초기화/모듈 선택/추가 기법) 문서 (huggingface.co)
  • Transformers의 bitsandbytes 4-bit/NF4 로딩 및 설정 가이드 (huggingface.co)
  • TRL의 PEFT 통합(SFTTrainer, 설정 패턴) (huggingface.co)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.