Skip to main content

Transformer — 어텐션만으로 구축한 시퀀스 모델

Transformer는 Vaswani et al. (2017)이 “Attention Is All You Need”에서 제안한 아키텍처로, RNN이나 CNN 없이 Self-Attention만으로 시퀀스를 처리합니다. 현대 NLP의 사실상 표준 아키텍처이며, BERT, GPT, T5 등 거의 모든 대규모 언어모델의 기반입니다.

핵심 아이디어

Transformer의 핵심 설계 원칙은 세 가지입니다.
  • 병렬 처리: RNN의 순차적 계산을 제거하여 GPU 병렬화를 극대화합니다
  • 장거리 의존성: Self-Attention으로 시퀀스 내 임의의 두 위치를 O(1)O(1) 연산으로 연결합니다
  • 모듈화: 동일한 구조의 레이어를 쌓아(Stack) 깊은 모델을 구성합니다
특성RNNCNNTransformer
병렬화불가 (순차적)부분 가능완전 병렬
장거리 의존성O(n)O(n) 경로O(logn)O(\log n) 경로O(1)O(1) 경로
레이어당 복잡도O(nd2)O(n \cdot d^2)O(knd2)O(k \cdot n \cdot d^2)O(n2d)O(n^2 \cdot d)
최대 경로 길이O(n)O(n)O(logkn)O(\log_k n)O(1)O(1)
nn: 시퀀스 길이, dd: 표현 차원, kk: 합성곱 커널 크기. Transformer는 n<dn < d인 일반적 상황에서 RNN보다 효율적입니다.

동작 방식

전체 아키텍처

인코더 스택 (Encoder Stack)

인코더는 N=6N = 6개의 동일한 레이어를 쌓은 구조입니다. 각 레이어는 두 개의 서브레이어로 구성됩니다. 서브레이어 1: Multi-Head Self-Attention 입력 시퀀스의 모든 위치가 서로를 참조합니다. 패딩 마스크만 적용되며, 양방향(Bidirectional)으로 모든 토큰을 볼 수 있습니다. 서브레이어 2: Position-wise Feed-Forward Network (FFN) 각 위치에 독립적으로 적용되는 2층 완전연결 네트워크입니다. FFN(x)=ReLU(xW1+b1)W2+b2\text{FFN}(x) = \text{ReLU}(x\mathbf{W}_1 + b_1)\mathbf{W}_2 + b_2
  • 내부 차원: dff=2048d_{ff} = 2048 (원 논문), 외부 차원: dmodel=512d_{\text{model}} = 512
  • 위치별로 같은 가중치를 공유하지만, 레이어별로는 다른 가중치를 사용합니다

디코더 스택 (Decoder Stack)

디코더도 N=6N = 6개의 동일한 레이어를 쌓지만, 인코더보다 하나의 서브레이어가 더 있습니다. 서브레이어 1: Masked Multi-Head Self-Attention 미래 마스크(Look-Ahead Mask)를 적용하여 현재 위치 이후의 토큰을 참조하지 못하게 합니다. 이를 통해 자기회귀적(Autoregressive) 생성이 가능합니다. 서브레이어 2: Multi-Head Cross-Attention 디코더의 상태를 Query로, 인코더의 출력을 Key/Value로 사용합니다. 이 레이어가 인코더의 정보를 디코더로 전달하는 다리 역할을 합니다. 서브레이어 3: Position-wise Feed-Forward Network 인코더와 동일한 구조의 FFN입니다.

잔차 연결과 레이어 정규화

모든 서브레이어에는 잔차 연결(Residual Connection)과 레이어 정규화(Layer Normalization)가 적용됩니다. LayerNorm(x+Sublayer(x))\text{LayerNorm}(x + \text{Sublayer}(x))
  • 잔차 연결: 서브레이어의 입력을 출력에 더합니다. 기울기 소실을 방지하고 깊은 네트워크 학습을 가능하게 합니다
  • 레이어 정규화: 각 샘플의 특성(Feature) 차원에 대해 정규화합니다. 배치 정규화(Batch Normalization)와 달리 배치 크기에 독립적입니다
잔차 연결을 위해 모든 서브레이어의 출력 차원이 dmodel=512d_{\text{model}} = 512로 동일해야 합니다. 임베딩 차원도 같은 이유로 512입니다.

입력 임베딩과 출력 레이어

입력 임베딩 토큰을 dmodeld_{\text{model}} 차원의 벡터로 변환합니다. 임베딩 가중치에 dmodel\sqrt{d_{\text{model}}}를 곱합니다. Embedding(x)=Embed(x)dmodel\text{Embedding}(x) = \text{Embed}(x) \cdot \sqrt{d_{\text{model}}} 이는 위치 인코딩(Positional Encoding)과 더해질 때 임베딩 값의 상대적 크기를 유지하기 위함입니다. 출력 레이어 디코더의 최종 출력을 어휘 크기(VV)에 대한 확률 분포로 변환합니다. P(yty<t,x)=Softmax(Linear(decoder_output))P(y_t | y_{<t}, x) = \text{Softmax}(\text{Linear}(\text{decoder\_output})) 원 논문에서는 입력 임베딩, 출력 임베딩, 출력 선형 변환 레이어가 가중치를 공유합니다(Weight Tying).

구현 (코드)

핵심 모듈 정의

import torch
import torch.nn as nn
import math

class MultiHeadAttention(nn.Module):
    """Multi-Head Attention"""
    def __init__(self, d_model, num_heads):
        super().__init__()
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads

        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)

    def forward(self, Q, K, V, mask=None):
        batch_size = Q.size(0)

        # 헤드 분리: (batch, seq, d_model) → (batch, heads, seq, d_k)
        Q = self.W_q(Q).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        K = self.W_k(K).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        V = self.W_v(V).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)

        # Scaled Dot-Product 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)
        attn_output = torch.matmul(attn_weights, V)

        # 헤드 병합
        attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)
        return self.W_o(attn_output)


class PositionWiseFFN(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):
        return self.linear2(self.relu(self.linear1(x)))

인코더/디코더 레이어

class EncoderLayer(nn.Module):
    """Transformer 인코더 레이어"""
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
        self.self_attn = MultiHeadAttention(d_model, num_heads)
        self.ffn = PositionWiseFFN(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, src_mask=None):
        # 서브레이어 1: Self-Attention + 잔차 연결 + 정규화
        attn_output = self.self_attn(x, x, x, src_mask)
        x = self.norm1(x + self.dropout(attn_output))

        # 서브레이어 2: FFN + 잔차 연결 + 정규화
        ffn_output = self.ffn(x)
        x = self.norm2(x + self.dropout(ffn_output))
        return x


class DecoderLayer(nn.Module):
    """Transformer 디코더 레이어"""
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
        self.self_attn = MultiHeadAttention(d_model, num_heads)
        self.cross_attn = MultiHeadAttention(d_model, num_heads)
        self.ffn = PositionWiseFFN(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_output = self.self_attn(x, x, x, tgt_mask)
        x = self.norm1(x + self.dropout(attn_output))

        # 서브레이어 2: Cross-Attention (Q=디코더, K/V=인코더)
        attn_output = self.cross_attn(x, encoder_output, encoder_output, src_mask)
        x = self.norm2(x + self.dropout(attn_output))

        # 서브레이어 3: FFN
        ffn_output = self.ffn(x)
        x = self.norm3(x + self.dropout(ffn_output))
        return x

실행 예시

# 하이퍼파라미터 (원 논문 기준)
d_model = 512     # 모델 차원
num_heads = 8     # 어텐션 헤드 수
d_ff = 2048       # FFN 내부 차원
num_layers = 6    # 인코더/디코더 레이어 수
dropout = 0.1

# 인코더 레이어 테스트
encoder_layer = EncoderLayer(d_model, num_heads, d_ff, dropout)
x = torch.randn(2, 10, d_model)  # (batch=2, seq_len=10, d_model=512)
enc_output = encoder_layer(x)
print(f"인코더 출력: {enc_output.shape}")  # (2, 10, 512)

# 디코더 레이어 테스트
decoder_layer = DecoderLayer(d_model, num_heads, d_ff, dropout)
tgt = torch.randn(2, 8, d_model)  # (batch=2, tgt_len=8, d_model=512)
# 미래 마스크 생성
tgt_mask = torch.tril(torch.ones(8, 8)).unsqueeze(0).unsqueeze(0)  # (1, 1, 8, 8)
dec_output = decoder_layer(tgt, enc_output, tgt_mask=tgt_mask)
print(f"디코더 출력: {dec_output.shape}")  # (2, 8, 512)

학습 세부사항

원 논문에서 사용한 학습 기법들입니다.

Optimizer와 학습률 스케줄링

Adam Optimizer를 사용하되, 특별한 학습률 스케줄(Warmup + Decay)을 적용합니다. lr=dmodel0.5min(step0.5,stepwarmup_steps1.5)\text{lr} = d_{\text{model}}^{-0.5} \cdot \min(\text{step}^{-0.5}, \text{step} \cdot \text{warmup\_steps}^{-1.5})
  • Warmup 단계: 학습률을 선형으로 증가시킵니다 (원 논문: 4000 스텝)
  • 이후: 스텝 수의 역제곱근에 비례하여 감소합니다
class TransformerScheduler:
    """Transformer 학습률 스케줄러 (Noam Scheduler)"""
    def __init__(self, optimizer, d_model, warmup_steps=4000):
        self.optimizer = optimizer
        self.d_model = d_model
        self.warmup_steps = warmup_steps
        self.step_num = 0

    def step(self):
        self.step_num += 1
        lr = self.d_model ** (-0.5) * min(
            self.step_num ** (-0.5),
            self.step_num * self.warmup_steps ** (-1.5)
        )
        for param_group in self.optimizer.param_groups:
            param_group['lr'] = lr

정규화 기법

기법적용원 논문 설정
Dropout각 서브레이어 출력, 임베딩 + 위치 인코딩Pdrop=0.1P_{drop} = 0.1
Label Smoothing손실 함수에 적용ϵls=0.1\epsilon_{ls} = 0.1
Weight Tying입력 임베딩, 출력 임베딩, 출력 선형 레이어가중치 공유
Label Smoothing은 원핫(One-hot) 타겟 대신 약간의 확률을 다른 토큰에 분배합니다. ysmooth=(1ϵ)yone-hot+ϵVy_{\text{smooth}} = (1 - \epsilon) \cdot y_{\text{one-hot}} + \frac{\epsilon}{V} 이를 통해 모델이 지나치게 확신하는 것을 방지하고, 일반화 성능을 개선합니다.

관련 기술 비교

특성Transformer (2017)RNN Seq2Seq + AttentionConvolutional Seq2Seq
핵심 연산Self-Attention순환 + Attention합성곱 + Attention
병렬 학습완전 병렬불가부분 가능
장거리 의존성O(1)O(1)O(n)O(n)O(logn)O(\log n)
학습 속도빠름느림중간
메모리O(n2)O(n^2)O(n)O(n)O(n)O(n)
위치 정보명시적 인코딩 필요순서가 내재부분 내재
  • O(n2)O(n^2) 메모리: Self-Attention의 어텐션 행렬이 시퀀스 길이의 제곱에 비례하여 메모리를 소비합니다. 긴 시퀀스(수만 토큰)에서 GPU 메모리 제한에 빠질 수 있습니다
  • 위치 정보 한계: 순환/합성곱 구조가 없어 위치 인코딩에 의존하며, 매우 긴 시퀀스에서 위치 정보가 약해질 수 있습니다
  • 고정 컨텍스트 윈도우: 입력 길이가 학습 시 설정한 최대 길이를 초과할 수 없습니다 (외삽 문제)
  • 데이터 효율성: CNN/RNN에 비해 유도 편향(Inductive Bias)이 적어 충분한 데이터가 필요합니다
원 논문은 Post-Norm (서브레이어 이후에 LayerNorm)을 사용합니다. 이후 연구에서 Pre-Norm (서브레이어 이전에 LayerNorm)이 학습 안정성을 높인다는 것이 밝혀졌습니다. GPT-2 이후 대부분의 대규모 모델은 Pre-Norm을 채택합니다.
Post-Norm: x = LayerNorm(x + Sublayer(x))
Pre-Norm:  x = x + Sublayer(LayerNorm(x))
  • 데이터: WMT 2014 영-독 (4.5M 문장쌍), 영-불 (36M 문장쌍)
  • Tokenizer: Byte Pair Encoding (BPE), 공유 어휘 37K 토큰
  • 배치: 약 25,000 소스 토큰 + 25,000 타겟 토큰
  • 하드웨어: 8x NVIDIA P100 GPU
  • 학습 시간: Base 모델 12시간, Big 모델 3.5일
  • Base 모델: dmodel=512d_{\text{model}}{=}512, dff=2048d_{ff}{=}2048, h=8h{=}8, N=6N{=}6 (65M 파라미터)
  • Big 모델: dmodel=1024d_{\text{model}}{=}1024, dff=4096d_{ff}{=}4096, h=16h{=}16, N=6N{=}6 (213M 파라미터)

참고 논문

논문학회/연도링크
Attention Is All You Need (Vaswani et al.)NeurIPS 2017arXiv 1706.03762
Layer Normalization (Ba et al.)2016arXiv 1607.06450
Neural Machine Translation by Jointly Learning to Align and Translate (Bahdanau et al.)ICLR 2015arXiv 1409.0473
Effective Approaches to Attention-based Neural Machine Translation (Luong et al.)EMNLP 2015arXiv 1508.04025
On Layer Normalization in the Transformer Architecture (Xiong et al.)ICML 2020arXiv 2002.04745
The Annotated Transformer (Rush)2018nlp.seas.harvard.edu