Transformer Encoder: 문맥을 이해하는 핵심 블록

트랜스포머 인코더 블록의 내부 구조를 Multi-Head Self-Attention, FFN, 잔차 연결, Layer Normalization으로 나누어 살펴보고 PyTorch로 직접 구현한다.

· 5 min read · PALDYN Team

지난 글에서 토큰의 순서 정보를 sin/cos 함수로 주입하는 방법을 배웠다. 이 정보를 담은 입력 벡터가 가장 먼저 통과하는 구조가 바로 Encoder다. 인코더는 입력 시퀀스 전체를 동시에 읽어 각 토큰에 ‘문맥이 반영된 표현’을 부여한다.

인코더 블록의 구조

Encoder 한 블록은 두 개의 서브레이어로 이루어진다.

  1. Multi-Head Self-Attention — 모든 위치가 서로를 참조해 관련성을 계산한다.
  2. Position-wise Feed-Forward Network (FFN) — 위치별로 독립적인 비선형 변환을 적용한다.

각 서브레이어에는 **잔차 연결(Residual Connection)**과 Layer Normalization이 뒤따른다.

output = LayerNorm( x + Sublayer(x) )

이 패턴을 Add & Norm이라 부르며, 잔차 경로가 그래디언트를 직접 이전 레이어까지 전달해 깊은 네트워크도 안정적으로 학습된다.

Transformer Encoder 블록 구조

Self-Attention: Q = K = V

인코더의 Self-Attention에서는 Query, Key, Value가 모두 같은 입력 x에서 만들어진다. 결과적으로 각 토큰이 시퀀스 전체를 참조해 자신의 표현을 업데이트한다. 예를 들어 “그 선수는 은메달을 땄다”에서 “그”가 “선수”와 강하게 연결되는 어텐션 패턴이 생성된다.

패딩 토큰(<PAD>)의 어텐션을 막기 위해 Padding Mask를 사용한다. 이 마스크는 <PAD> 위치의 어텐션 점수를 −∞로 설정해 Softmax 이후 값이 0에 수렴하도록 만든다.

Feed-Forward Network

FFN은 두 개의 선형 변환 사이에 비선형 활성화를 끼워 넣은 구조다.

FFN(x) = max(0, x @ W1 + b1) @ W2 + b2
# 또는 GELU 활성화 사용 (BERT, GPT-2 등)

내부 차원 d_ffd_model의 4배가 기본값이다. 원논문에서 d_model=512, d_ff=2048을 사용했다. FFN은 위치별로 독립적으로 적용되므로 병렬 처리가 용이하다.

스택 구조: N개 레이어 쌓기

인코더 블록 하나를 N번 반복해 쌓는다. 레이어가 깊어질수록 표현이 더 추상화된다.

  • 얕은 레이어: 품사, 어휘 관계 같은 표층 정보
  • 중간 레이어: 구 구조, 문법 패턴
  • 깊은 레이어: 의미, 장거리 의존 관계

Encoder 스택과 문맥 표현

PyTorch 구현

import torch.nn as nn
import torch.nn.functional as F

class FFN(nn.Module):
    def __init__(self, d_model: int, d_ff: int, dropout: float = 0.1):
        super().__init__()
        self.fc1 = nn.Linear(d_model, d_ff)
        self.fc2 = nn.Linear(d_ff, d_model)
        self.drop = nn.Dropout(dropout)

    def forward(self, x):
        return self.fc2(self.drop(F.gelu(self.fc1(x))))

class EncoderLayer(nn.Module):
    def __init__(self, d_model: int, n_heads: int, d_ff: int, dropout: float = 0.1):
        super().__init__()
        self.attn  = nn.MultiheadAttention(d_model, n_heads, dropout=dropout, batch_first=True)
        self.ff    = FFN(d_model, d_ff, dropout)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.drop  = nn.Dropout(dropout)

    def forward(self, x, src_key_padding_mask=None):
        attn_out, _ = self.attn(x, x, x, key_padding_mask=src_key_padding_mask)
        x = self.norm1(x + self.drop(attn_out))   # Add & Norm
        x = self.norm2(x + self.drop(self.ff(x))) # Add & Norm
        return x

class Encoder(nn.Module):
    def __init__(self, d_model: int, n_heads: int, d_ff: int, n_layers: int):
        super().__init__()
        self.layers = nn.ModuleList(
            [EncoderLayer(d_model, n_heads, d_ff) for _ in range(n_layers)]
        )
        self.norm = nn.LayerNorm(d_model)

    def forward(self, x, mask=None):
        for layer in self.layers:
            x = layer(x, src_key_padding_mask=mask)
        return self.norm(x)

Layer Normalization vs Batch Normalization

원논문과 BERT는 Pre-Norm 대신 Post-Norm 방식(서브레이어 통과 후 정규화)을 사용했다. 최근 모델들은 잔차 경로를 깨끗하게 유지하는 Pre-Norm(서브레이어 전 정규화)을 선호한다.

구분Post-NormPre-Norm
공식LayerNorm(x + Sub(x))x + Sub(LayerNorm(x))
학습 안정성깊어지면 불안정할 수 있음더 안정적
성능원논문 스타일GPT-3, LLaMA 등 채택

정리

  • Encoder 블록 = Self-Attention + FFN, 각각 Add & Norm으로 감쌈
  • Self-Attention이 전역 문맥을, FFN이 위치별 비선형 변환을 담당
  • N개 레이어를 쌓아 점점 추상적인 표현을 생성
  • 최종 출력은 각 토큰에 문맥이 반영된 (seq_len × d_model) 행렬

지난 글: Positional Encoding: 트랜스포머에 순서를 알려주는 방법

다음 글: Transformer Decoder: 문장을 생성하는 블록


읽어주셔서 감사합니다. 😊