1. 왜 Transformer인가?
LSTM+Attention의 한계
LSTM+Attention은 기계번역에서 큰 성과를 냈지만, 근본적인 한계가 있었습니다.
LSTM의 순차 처리:
"나는 좋은 학생 이다"
→ "나는" 처리 → "좋은" 처리 → "학생" 처리 → "이다" 처리
→ 앞 단어가 끝나야 다음 단어 처리 가능!
→ GPU 병렬 처리 불가능!
Transformer의 혁신
Transformer는 Self-Attention 하나로 순차 처리 없이 전체 입력을 병렬로 처리합니다.
Transformer의 병렬 처리:
"나는 좋은 학생 이다"
→ 4개 단어를 동시에 처리!
→ GPU의 병렬 연산 능력을 100% 활용!
CNN에서의 비유로 생각하면:
| RNN/LSTM | Transformer | |
|---|---|---|
| 처리 방식 | 픽셀을 한 줄씩 순차 처리 | 이미지 전체를 한번에 처리 |
| 병렬성 | 불가능 | 완전 병렬 |
| 장거리 의존성 | 거리가 멀수록 약화 | 거리와 무관 |
2. Self-Attention: Transformer의 핵심
2.1 직관적 이해
Self-Attention은 **"각 단어가 다른 모든 단어와의 관련성을 동시에 계산하는 것"**입니다.
"나는 좋은 학생 이다"
"학생"이 다른 단어들과의 관련성:
"학생" ↔ "나는": 주어-명사 관계 → 높은 관련성
"학생" ↔ "좋은": 형용사-명사 관계 → 높은 관련성
"학생" ↔ "이다": 주어-서술 관계 → 중간 관련성
영상처리에서의 비유:
- CNN Conv 필터: 3×3 로컬 영역만 참조 (Receptive Field)
- Self-Attention: 모든 위치를 한번에 참조 (Global Receptive Field)
2.2 Q, K, V의 역할
Self-Attention은 입력을 Query(Q), Key(K), Value(V) 세 가지로 변환합니다.
입력 X를 세 가지 관점으로 변환:
Q = X × Wq → "내가 찾고 싶은 것" (검색어)
K = X × Wk → "내가 가진 정보의 라벨" (키워드)
V = X × Wv → "실제 전달할 정보" (내용)
이미지 검색(CBIR)에 비유하면:
- Q: 검색 쿼리 이미지의 특징 벡터
- K: 데이터베이스 이미지들의 특징 벡터
- V: 매칭된 이미지의 실제 정보
- Q·Kᵀ: 코사인 유사도로 매칭 → 가장 관련 있는 이미지 선택
2.3 왜 Q, K, V를 분리하는가?
만약 분리하지 않으면:
# Q=K=V=X 라면
score = X @ X.T # 단순 자기 유사도 → 고정된 관계만 포착
분리하면:
# Q, K, V가 다른 변환
score = (X @ Wq) @ (X @ Wk).T # 동적 + 비대칭 관계 포착!
핵심 수식을 전개하면:
Q·Kᵀ = (X·Wq)·(X·Wk)ᵀ = X·Wq·Wkᵀ·Xᵀ
X가 양쪽에 있다 → 입력에 따라 관계가 동적으로 변함!
Wq ≠ Wk → "찾는 것"과 "제공하는 것"이 비대칭!
2.4 PyTorch로 Self-Attention 구현
import torch
import torch.nn as nn
import math
class SelfAttention(nn.Module):
def __init__(self, d_model):
super().__init__()
self.d_model = d_model
self.Wq = nn.Linear(d_model, d_model, bias=False)
self.Wk = nn.Linear(d_model, d_model, bias=False)
self.Wv = nn.Linear(d_model, d_model, bias=False)
def forward(self, x, mask=None):
"""
x: (batch_size, seq_len, d_model)
"""
Q = self.Wq(x) # (batch, seq_len, d_model)
K = self.Wk(x) # (batch, seq_len, d_model)
V = self.Wv(x) # (batch, seq_len, d_model)
# Attention Score: Q·Kᵀ / √d_model
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_model)
# scores: (batch, seq_len, seq_len)
# Mask 적용 (Decoder에서 미래 토큰 차단)
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
# Softmax → 가중치
attn_weights = torch.softmax(scores, dim=-1)
# attn_weights: (batch, seq_len, seq_len)
# 가중 합산
output = torch.matmul(attn_weights, V)
# output: (batch, seq_len, d_model)
return output, attn_weights
√d_model로 나누는 이유:
d_model이 크면 Q·K 내적값이 커져서
Softmax가 극단적으로 편향됨 (한 곳에 100% 집중)
→ √d_model로 나눠서 적절한 범위로 조절!
CNN에서 BatchNorm이 활성화를 정규화하는 것과 비슷한 역할
3. Multi-Head Attention
3.1 왜 Head를 나누는가?
하나의 Attention은 하나의 관점만 포착합니다.
Head 1: 주어-동사 관계에 집중
Head 2: 형용사-명사 관계에 집중
Head 3: 대명사-선행사 관계에 집중
...
→ 여러 관점을 동시에 포착!
CNN에서의 비유:
- Conv 필터 1개 = 하나의 특징만 검출 (수평 에지)
- Conv 필터 64개 = 다양한 특징 검출 (에지, 텍스처, 패턴...)
- Multi-Head = 다양한 관계 패턴을 동시에 포착
3.2 차원 분할의 원리
d_model = 512, n_heads = 8이면:
d_k = 512 / 8 = 64 (Head당 차원)
전체 Wq: (512 × 512) → 하나의 큰 행렬
Head별로 보면: (512 × 64) × 8개
→ 하나의 행렬곱으로 계산 후 view/reshape로 Head 분리!
→ GPU에서 매우 효율적!
3.3 PyTorch로 Multi-Head Attention 구현
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
assert d_model % n_heads == 0
self.d_model = d_model
self.n_heads = n_heads
self.d_k = d_model // n_heads
# 하나의 큰 Linear로 모든 Head의 Q, K, V를 한번에 계산
self.Wq = nn.Linear(d_model, d_model, bias=False)
self.Wk = nn.Linear(d_model, d_model, bias=False)
self.Wv = nn.Linear(d_model, d_model, bias=False)
self.Wo = nn.Linear(d_model, d_model, bias=False)
def forward(self, x, mask=None):
batch_size, seq_len, _ = x.shape
# Q, K, V 계산 (전체를 한번에)
Q = self.Wq(x) # (batch, seq_len, d_model)
K = self.Wk(x)
V = self.Wv(x)
# Head별로 분리: (batch, seq_len, d_model) → (batch, n_heads, seq_len, d_k)
Q = Q.view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
K = K.view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
V = V.view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
# Scaled Dot-Product Attention (모든 Head 동시에!)
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
# scores: (batch, n_heads, seq_len, seq_len)
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
attn_weights = torch.softmax(scores, dim=-1)
context = torch.matmul(attn_weights, V)
# context: (batch, n_heads, seq_len, d_k)
# Head 합치기: (batch, n_heads, seq_len, d_k) → (batch, seq_len, d_model)
context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
# 최종 Linear (Wo)
output = self.Wo(context)
return output, attn_weights
핵심 포인트:
view + transpose로 Head 분리 → 실제 데이터 복사 없이 차원 재배치- 모든 Head의 Attention이 하나의 행렬곱으로 동시에 계산
- 마지막
Wo가 모든 Head의 출력을 통합
4. Transformer Block 구성
4.1 Encoder Block
입력 → [Multi-Head Self-Attention] → Add & Norm → [FFN] → Add & Norm → 출력
잔차 연결 2개:
1. Attention 전후
2. FFN 전후
Add & Norm = 잔차 연결(ResNet과 동일!) + Layer Normalization
class FeedForward(nn.Module):
"""Position-wise Feed-Forward Network"""
def __init__(self, d_model, d_ff):
super().__init__()
self.linear1 = nn.Linear(d_model, d_ff)
self.linear2 = nn.Linear(d_ff, d_model)
self.relu = nn.ReLU()
def forward(self, x):
# (batch, seq_len, d_model) → (batch, seq_len, d_ff) → (batch, seq_len, d_model)
return self.linear2(self.relu(self.linear1(x)))
class EncoderBlock(nn.Module):
def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
super().__init__()
self.attention = MultiHeadAttention(d_model, n_heads)
self.ffn = FeedForward(d_model, d_ff)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask=None):
# 1. Multi-Head Self-Attention + 잔차 연결
attn_out, _ = self.attention(x, mask)
x = self.norm1(x + self.dropout(attn_out))
# 2. Feed-Forward + 잔차 연결
ff_out = self.ffn(x)
x = self.norm2(x + self.dropout(ff_out))
return x
4.2 Decoder Block
Decoder는 Encoder보다 복잡합니다:
입력 → [Masked Self-Attention] → Add & Norm
→ [Cross-Attention (Q:Decoder, K,V:Encoder)] → Add & Norm
→ [FFN] → Add & Norm → 출력
잔차 연결 3개!
class DecoderBlock(nn.Module):
def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
super().__init__()
self.masked_attention = MultiHeadAttention(d_model, n_heads)
self.cross_attention = MultiHeadAttention(d_model, n_heads)
self.ffn = FeedForward(d_model, d_ff)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.norm3 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, encoder_output, src_mask=None, tgt_mask=None):
# 1. Masked Self-Attention (미래 토큰 차단!)
attn_out, _ = self.masked_attention(x, tgt_mask)
x = self.norm1(x + self.dropout(attn_out))
# 2. Cross-Attention (Q: Decoder, K,V: Encoder 출력)
# Encoder의 정보를 참조하는 핵심 부분!
cross_out, _ = self.cross_attention.forward_cross(
query=x, key=encoder_output, value=encoder_output, mask=src_mask
)
x = self.norm2(x + self.dropout(cross_out))
# 3. Feed-Forward
ff_out = self.ffn(x)
x = self.norm3(x + self.dropout(ff_out))
return x
Cross-Attention을 위해 MultiHeadAttention에 forward_cross 메서드를 추가합니다:
# MultiHeadAttention 클래스에 추가
def forward_cross(self, query, key, value, mask=None):
"""Cross-Attention: Q는 Decoder에서, K,V는 Encoder에서"""
batch_size = query.shape[0]
seq_len_q = query.shape[1]
seq_len_k = key.shape[1]
Q = self.Wq(query) # Decoder 입력에서 Q 생성
K = self.Wk(key) # Encoder 출력에서 K 생성
V = self.Wv(value) # Encoder 출력에서 V 생성
# Head 분리
Q = Q.view(batch_size, seq_len_q, self.n_heads, self.d_k).transpose(1, 2)
K = K.view(batch_size, seq_len_k, self.n_heads, self.d_k).transpose(1, 2)
V = V.view(batch_size, seq_len_k, self.n_heads, self.d_k).transpose(1, 2)
# Attention 계산
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
attn_weights = torch.softmax(scores, dim=-1)
context = torch.matmul(attn_weights, V)
context = context.transpose(1, 2).contiguous().view(batch_size, seq_len_q, self.d_model)
output = self.Wo(context)
return output, attn_weights
5. Positional Encoding
Transformer는 순서 정보가 없으므로 위치 정보를 별도로 주입해야 합니다.
5.1 sin/cos Positional Encoding
PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
여러 주파수의 sin/cos → 각 위치가 고유한 벡터를 가짐
→ 시계의 초침/분침/시침처럼 다중 주파수로 위치 표현!
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=5000):
super().__init__()
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1).float()
div_term = torch.exp(
torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)
)
pe[:, 0::2] = torch.sin(position * div_term) # 짝수 차원
pe[:, 1::2] = torch.cos(position * div_term) # 홀수 차원
pe = pe.unsqueeze(0) # (1, max_len, d_model)
self.register_buffer('pe', pe)
def forward(self, x):
# x: (batch, seq_len, d_model)
seq_len = x.size(1)
# 임베딩에 위치 정보를 더함
return x + self.pe[:, :seq_len, :]
참고: 최신 LLM들은 sin/cos PE 대신 **RoPE(Rotary Position Embedding)**를 사용합니다. RoPE는 임베딩에 더하는 대신 Q, K를 위치에 따라 회전시켜서 상대 위치를 자연스럽게 인코딩합니다. 이에 대해서는 별도 포스트에서 다룰 예정입니다.
6. 전체 Transformer 조립
6.1 Encoder
class Encoder(nn.Module):
def __init__(self, vocab_size, d_model, n_heads, d_ff, n_layers, dropout=0.1):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.pos_encoding = PositionalEncoding(d_model)
self.layers = nn.ModuleList([
EncoderBlock(d_model, n_heads, d_ff, dropout)
for _ in range(n_layers)
])
self.scale = math.sqrt(d_model) # 임베딩 스케일링
def forward(self, x, mask=None):
# 토큰 → 임베딩 → 위치 인코딩
x = self.embedding(x) * self.scale
x = self.pos_encoding(x)
# N개의 Encoder Block 통과
for layer in self.layers:
x = layer(x, mask)
return x
6.2 Decoder
class Decoder(nn.Module):
def __init__(self, vocab_size, d_model, n_heads, d_ff, n_layers, dropout=0.1):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.pos_encoding = PositionalEncoding(d_model)
self.layers = nn.ModuleList([
DecoderBlock(d_model, n_heads, d_ff, dropout)
for _ in range(n_layers)
])
self.scale = math.sqrt(d_model)
def forward(self, x, encoder_output, src_mask=None, tgt_mask=None):
x = self.embedding(x) * self.scale
x = self.pos_encoding(x)
for layer in self.layers:
x = layer(x, encoder_output, src_mask, tgt_mask)
return x
6.3 전체 Transformer
class Transformer(nn.Module):
def __init__(self, src_vocab, tgt_vocab, d_model=512, n_heads=8,
d_ff=2048, n_layers=6, dropout=0.1):
super().__init__()
self.encoder = Encoder(src_vocab, d_model, n_heads, d_ff, n_layers, dropout)
self.decoder = Decoder(tgt_vocab, d_model, n_heads, d_ff, n_layers, dropout)
self.output_linear = nn.Linear(d_model, tgt_vocab)
# 최종 Linear = CNN의 마지막 FC Layer와 동일한 역할!
def forward(self, src, tgt, src_mask=None, tgt_mask=None):
# Encoder: 소스 문장 처리
encoder_output = self.encoder(src, src_mask)
# Decoder: 타겟 문장 처리 (Encoder 출력 참조)
decoder_output = self.decoder(tgt, encoder_output, src_mask, tgt_mask)
# 최종 출력: d_model → vocab_size → Softmax로 다음 토큰 확률
logits = self.output_linear(decoder_output)
return logits
7. 학습: Teacher Forcing
7.1 Mask 생성
def create_masks(src, tgt, pad_idx=0):
# 소스 패딩 마스크: 패딩 토큰 무시
src_mask = (src != pad_idx).unsqueeze(1).unsqueeze(2)
# (batch, 1, 1, src_len)
# 타겟 패딩 마스크
tgt_pad_mask = (tgt != pad_idx).unsqueeze(1).unsqueeze(2)
# 미래 토큰 차단 마스크 (하삼각 행렬)
seq_len = tgt.size(1)
subsequent_mask = torch.tril(torch.ones(seq_len, seq_len)).bool()
subsequent_mask = subsequent_mask.unsqueeze(0).unsqueeze(0)
# 두 마스크를 합침
tgt_mask = tgt_pad_mask & subsequent_mask
return src_mask, tgt_mask
7.2 Teacher Forcing 학습
Teacher Forcing이란:
학습 시 정답을 미리 전부 넣어주는 방식!
번역: "나는 학생이다" → "I am a student"
Decoder 입력: [<BOS>, I, am, a] ← 정답을 넣어줌!
Decoder 출력: [I, am, a, student] ← 한 칸씩 밀린 예측
Mask로 미래를 차단하니까 병렬 학습 가능!
# 학습 루프
model = Transformer(src_vocab=5000, tgt_vocab=5000)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
criterion = nn.CrossEntropyLoss(ignore_index=0) # 패딩 무시
for epoch in range(num_epochs):
for src, tgt in dataloader:
# 입력: <BOS> + 정답[:-1], 라벨: 정답[1:]
tgt_input = tgt[:, :-1]
tgt_label = tgt[:, 1:]
src_mask, tgt_mask = create_masks(src, tgt_input)
logits = model(src, tgt_input, src_mask, tgt_mask)
# logits: (batch, seq_len, vocab_size)
loss = criterion(
logits.reshape(-1, logits.size(-1)),
tgt_label.reshape(-1)
)
optimizer.zero_grad()
loss.backward()
optimizer.step()
8. 추론: Auto-regressive 생성
학습과 달리 추론에서는 한 토큰씩 순차적으로 생성합니다.
def translate(model, src, max_len=50, bos_idx=1, eos_idx=2):
model.eval()
with torch.no_grad():
# Encoder는 한번만 실행
src_mask = (src != 0).unsqueeze(1).unsqueeze(2)
encoder_output = model.encoder(src, src_mask)
# <BOS>로 시작
tgt = torch.tensor([[bos_idx]])
for _ in range(max_len):
tgt_mask = torch.tril(
torch.ones(tgt.size(1), tgt.size(1))
).bool().unsqueeze(0).unsqueeze(0)
# Decoder 실행
decoder_output = model.decoder(
tgt, encoder_output, src_mask, tgt_mask
)
# 마지막 위치의 예측만 사용
logits = model.output_linear(decoder_output[:, -1, :])
next_token = logits.argmax(dim=-1, keepdim=True)
# 생성된 토큰 추가
tgt = torch.cat([tgt, next_token], dim=1)
# 종료 토큰이면 멈춤
if next_token.item() == eos_idx:
break
return tgt
참고: 위 코드는 매 Step마다 Decoder를 전체 실행합니다. 실제 서비스에서는 KV Cache를 사용하여 이전 K, V를 저장하고 새 토큰 1개만 처리합니다. 이에 대해서는 별도 포스트에서 다룹니다.
9. GPT: Decoder-only Transformer
현재 대부분의 LLM(GPT, LLaMA, Qwen 등)은 Encoder를 제거한 Decoder-only 구조입니다.
원래 Transformer (Encoder-Decoder):
Encoder: 소스 문장 이해
Decoder: Cross-Attention으로 Encoder 참조 + 생성
GPT (Decoder-only):
Cross-Attention 제거!
Masked Self-Attention + FFN만으로 구성
→ 다음 토큰 예측에만 집중!
class GPTBlock(nn.Module):
"""GPT = Encoder 없이 Decoder의 Masked Self-Attention + FFN"""
def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
super().__init__()
self.attention = MultiHeadAttention(d_model, n_heads)
self.ffn = FeedForward(d_model, d_ff)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask=None):
# Pre-Norm 방식 (GPT-2 이후)
attn_out, _ = self.attention(self.norm1(x), mask)
x = x + self.dropout(attn_out)
ff_out = self.ffn(self.norm2(x))
x = x + self.dropout(ff_out)
return x
class GPT(nn.Module):
def __init__(self, vocab_size, d_model=768, n_heads=12,
d_ff=3072, n_layers=12, max_len=1024):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.pos_encoding = PositionalEncoding(d_model, max_len)
self.blocks = nn.ModuleList([
GPTBlock(d_model, n_heads, d_ff) for _ in range(n_layers)
])
self.norm = nn.LayerNorm(d_model)
self.output_linear = nn.Linear(d_model, vocab_size, bias=False)
# Weight Tying: Embedding과 Output Linear 가중치 공유!
self.output_linear.weight = self.embedding.weight
def forward(self, x, mask=None):
x = self.embedding(x) * math.sqrt(self.embedding.embedding_dim)
x = self.pos_encoding(x)
for block in self.blocks:
x = block(x, mask)
x = self.norm(x)
logits = self.output_linear(x)
return logits
10. 정리: Transformer 핵심 포인트
| 개념 | 설명 | CNN 비유 |
|---|---|---|
| Self-Attention | 모든 위치 간 관련성 계산 | 전역 Receptive Field |
| Multi-Head | 여러 관점으로 관계 포착 | 다수의 Conv 필터 |
| Q, K, V 분리 | 동적 + 비대칭 가중치 | 비대칭 특징 매칭 |
| 잔차 연결 | 정보 흐름 보장 | ResNet Skip Connection |
| FFN | 토큰별 독립 변환 | 1×1 Convolution |
| Mask | 미래 토큰 차단 | Causal Conv |
| Teacher Forcing | 정답으로 병렬 학습 | (해당 없음) |
| 최종 Linear+Softmax | 어휘 확률 분포 | FC + Softmax 분류 |
다음 포스트 예고
이 포스트에서 다룬 기본 Transformer 구조 위에, 현재 LLM에서 사용되는 핵심 기술들을 이어서 다룰 예정입니다:
- RoPE: sin/cos PE의 한계를 극복한 회전 위치 인코딩
- KV Cache: 추론 속도를 수십 배 높이는 캐싱 기법
- Flash Attention: GPU 메모리 계층을 활용한 Attention 최적화
- GQA: KV Cache 메모리를 4~8배 줄이는 Head 공유 기법
글쓴이: CNN/LSTM+Attention 기반 영상처리(OCR, 얼굴 표정 인식) 경험을 바탕으로 Transformer 이후 LLM 기술을 학습하고 정리하고 있습니다. 영상처리 관점에서의 비유를 통해 직관적 이해를 돕는 것을 목표로 합니다.