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은 **"각 단어가 다른 모든 단어와의 관련성을 동시에 계산하는 것"**입니다.

"나는 좋은 학생 이다"
 
"학생"이 다른 단어들과의 관련성:
  "학생" ↔ "나는": 주어-명사 관계 → 높은 관련성
  "학생" ↔ "좋은": 형용사-명사 관계 → 높은 관련성  
  "학생" ↔ "이다": 주어-서술 관계 → 중간 관련성

영상처리에서의 비유:

2.2 Q, K, V의 역할

Self-Attention은 입력을 Query(Q), Key(K), Value(V) 세 가지로 변환합니다.

입력 X를 세 가지 관점으로 변환:
  Q = X × Wq  → "내가 찾고 싶은 것" (검색어)
  K = X × Wk  → "내가 가진 정보의 라벨" (키워드)
  V = X × Wv  → "실제 전달할 정보" (내용)

이미지 검색(CBIR)에 비유하면:

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에서의 비유:

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

핵심 포인트:


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에서 사용되는 핵심 기술들을 이어서 다룰 예정입니다:


글쓴이: CNN/LSTM+Attention 기반 영상처리(OCR, 얼굴 표정 인식) 경험을 바탕으로 Transformer 이후 LLM 기술을 학습하고 정리하고 있습니다. 영상처리 관점에서의 비유를 통해 직관적 이해를 돕는 것을 목표로 합니다.