Transformer의 Attention 메커니즘

핵심 키워드: 주목(Attention) : 긴 문장에서 모든 단어를 동등하게 보지 않고, 현재 맥락에서 중요한 단어에 더 집중하는 메커니즘

 

어텐션(Attention)이란 무엇인가?

"그 동물(animal)은 길(street)을 건너지 않았다. 왜냐하면 그것(it)은 너무 피곤했기 때문이다."
이 문장에서 그것(it)은 무엇을 가리킬까요? 대부분의 사람은 거의 즉시 동물(animal)이라고 답할 것입니다. 길(street)이 피곤할 수는 없기 때문입니다.

 

하지만 과거의 인공지능에게는 이런 문제가 꽤 어려웠습니다. 단어를 순서대로만 처리하는 방식으로는, it이 앞의 animal을 가리키는지, 아니면 street를 가리키는지 정확히 파악하기 어려웠기 때문입니다.

 

사람이 이 문장을 쉽게 이해하는 이유는, 문장을 읽을 때 모든 단어를 똑같은 비중으로 보지 않기 때문입니다. 우리는 it이 무엇을 가리키는지 판단할 때, 무의식적으로 tired와 animal 같은 단어에 더 주목하며 문맥을 연결합니다.

 

어텐션 메커니즘은 바로 이런 선택적 집중을 수학적으로 흉내 낸 방법입니다.
즉, 어떤 단어를 해석할 때 문장 속 다른 단어들을 모두 똑같이 보는 것이 아니라, 관련이 큰 단어에는 더 큰 비중을 두고, 관련이 적은 단어에는 더 작은 비중을 두는 방식입니다.

 

그렇다면 이런 관련성은 어떻게 계산할 수 있을까요?
트랜스포머에서는 보통 벡터의 내적(dot product) 을 이용해 단어 사이의 관련성을 점수로 계산합니다. 두 벡터의 내적값이 클수록, 현재 맥락에서 서로 더 밀접하게 관련되어 있다고 볼 수 있습니다. 이 값을 어텐션 스코어(Attention Score) 라고 합니다.

 

하지만 어텐션 스코어는 크기 범위가 일정하지 않기 때문에, 그대로 사용하기에는 어렵습니다. 그래서 이를 softmax로 정규화하여 각 값의 합이 1이 되도록 만듭니다. 이렇게 변환된 값이 어텐션 웨이트(Attention Weight) 입니다. 이 과정 덕분에 관련성이 큰 단어는 더 높은 비중을 갖고, 관련성이 낮은 단어는 더 작은 비중을 갖게 됩니다.

 

이제 이렇게 얻은 어텐션 웨이트를 이용해, 각 단어가 가진 실제 정보인 Value를 가중합합니다. 그러면 특정 단어의 위치에서, 문장 전체를 참고하되 특히 관련 있는 단어들의 정보가 더 많이 반영된 새로운 벡터가 만들어집니다.

 

이 벡터를 컨텍스트 벡터(Context Vector) 라고 합니다.

즉, 어텐션은

  • 단어들 사이의 관련성을 점수로 계산하고,
  • 그 점수를 확률처럼 정규화한 뒤,
  • 중요한 단어의 정보를 더 많이 반영하여 문맥이 반영된 새로운 표현을 만드는 과정이라고 볼 수 있습니다.

Word Embedding과 분포 가설

오늘날 거대 언어 모델(LLM)의 성공을 이야기할 때 대부분 '트랜스포머(Transformer)' 알고리즘을 떠올립니다. 
하지만 많은 전문가들은 그 바탕에 깔린 '단어 임베딩(Word Embedding)' 기법이야말로 LLM 탄생을 이끈 진짜 주역이라고 말합니다.

단어 임베딩은 사람이 사용하는 문자(단어)를 컴퓨터가 이해하고 계산할 수 있도록 숫자(벡터)로 변환하여 표현하는 기법입니다. 
학습이 성공적으로 끝나면, 비슷한 의미를 가진 단어들은 벡터 공간에서 서로 가까운 위치에 놓이게 됩니다. 

그 결과 $\text{King} - \text{Man} + \text{Woman} = \text{Queen}$ 이나 $\text{Paris} - \text{France} + \text{Germany} = \text{Berlin}$ 과 같은 단어의 수학적, 의미적 연산이 가능해집니다.

분포 가설 (Distributional Hypothesis)
이러한 단어 임베딩은 언어학의 '분포 가설'에서 출발합니다. 
분포 가설이란 "단어의 의미는 그 단어가 사용되는 주변 맥락(Context)에 의해 결정된다"는 개념입니다.

예를 들어 다음과 같은 문장이 있다고 가정해 봅시다.
* "I ( ) beer."
* "We ( ) wine."

빈칸에 들어갈 수 있는 단어는 'drink(마시다)', 'guzzle(벌컥벌컥 마시다)' 등이 될 것입니다. 
이처럼 동일한 맥락에서 자주 등장하는 단어들은 의미적으로 유사한 경우가 많습니다. 
LLM은 문장 내에서 특정 단어 주변에 위치한 단어(맥락)들을 바탕으로 다음 위치에 올 단어의 확률 분포를 끊임없이 예측하고 학습합니다. 
이 과정을 반복하면, 자연스럽게 쓰임새가 비슷한 단어들은 벡터 공간에서 비슷한 위치(좌표)를 배정받게 됩니다.



벡터의 내적과 코사인 유사도 (Cosine Similarity)
컴퓨터는 이렇게 변환된 단어 벡터들 사이의 '유사도'를 어떻게 측정할까요? 주로 코사인 유사도를 사용합니다.

$$\cos(\theta) = \frac{x \cdot y}{||x|| ||y||}$$

이 값이 1에 가까울수록 두 단어의 방향(의미)이 유사하고, 0이면 무관하며, -1에 가까울수록 반대 의미를 가짐을 뜻합니다. 

여기서 분자의 $x \cdot y$는 두 벡터의 **내적(Dot Product)**을 의미합니다. 
즉, 내적 연산 자체가 두 벡터의 크기(중요도)와 방향(의미적 유사도)을 동시에 담아내는 훌륭한 지표가 됩니다. 
(이 내적 연산은 이후 트랜스포머가 단어 간의 관계를 파악하는 'Attention Score' 계산의 핵심 원리가 됩니다.)

어텐션의 각 단계를 간략하게 살펴보겠습니다.

0단계. Q, K, V 변환 

Attention을 수행하기 전에, 입력 임베딩 벡터를 세 가지 역할로 변환합니다.

역할 의미 비유
Query (Q) "나는 어떤 정보를 찾고 있는가?" 검색어
Key (K) "나는 어떤 정보를 갖고 있는가?" 문서 제목
Value (V) "내가 실제로 전달할 정보" 문서 내용

각각은 학습 가능한 가중치 행렬 $W_Q$, $W_K$, $W_V$를 통해 만들어집니다.

$$Q = X \cdot W_Q, \quad K = X \cdot W_K, \quad V = X \cdot W_V$$

왜 같은 입력을 세 가지로 분리하는가? 하나의 단어가 "질문하는 역할"과 "응답하는 역할"을 동시에 수행해야 하기 때문에 역할을 분리함으로써 모델이 더 유연하게 단어 간 관계를 학습할 수 있다.


1단계. Attention Score(어텐션 점수) : "어떤 단어와 가장 관련 있는가?"

$$\text{Score} = Q \cdot K^T$$

  • 문장 내에서 특정 단어(Query)가 다른 모든 단어들(Key)과 얼마나 연관성이 있는지 점수를 계산하는 단계입니다.
  • Query의 각 위치에서 모든 Key와의 내적(dot product)을 계산합니다.
  • 내적은 두 벡터의 방향 유사도크기를 동시에 반영하므로, 값이 클수록 두 단어가 현재 맥락에서 강한 관련성을 가진다는 의미입다.
  • 결과: (시퀀스 길이 × 시퀀스 길이) 크기의 행렬 : 모든 단어 쌍의 관련성 점수

 

2단계. Attention Weight — "점수를 확률 분포로 변환하자"

$$\text{Weight} = \text{softmax}\left(\frac{Q \cdot K^T}{\sqrt{d_k}}\right)$$

Attention Score는 크기가 제각각이므로, 비교 가능하고 안정적으로 사용하려면 이를 총합이 1이 되는 확률 분포 형태로 정규화가 필요하다.

  • $d_k$는 Key 벡터의 차원 수입니다.
  • 왜 나누는가? $d_k$가 크면 내적 값도 커지고, 이 상태에서 softmax를 적용하면 하나의 값만 1에 가깝고 나머지는 0에 가까운 극단적 분포가 된다. 이렇게 되면 그래디언트가 거의 소실되어 학습이 잘 되지 않습니다.

정규화 방식 비교:

방식 수식 특징
단순 정규화 $\frac{s_i}{\sum s_j}$ 점수를 단순히 합으로 나눔. 값의 대비(contrast)가 약함
Softmax $\frac{e^{s_i}}{\sum e^{s_j}}$ 지수 함수를 통해 큰 값은 더 크게, 작은 값은 더 작게 만듦. 대비가 강화됨
  • Softmax 정규화: 스케일링된 점수에 Softmax를 취합니다. 단순한 비율(Gate 방식)이 아니라 Softmax의 지수(Exponential) 특성을 사용하기 때문에, 연관성이 높은 중요한 단어의 가중치는 극대화하고 관련 없는 단어의 가중치는 0에 가깝게 억제합니다. 모든 정보가 희석되는 것을 막고 진짜 중요한 단어에만 '주목(Attention)'하게 만드는 핵심 원리입니다.

3단계. Context Vector — "가중 합으로 문맥 정보가 함축된 최종 정보 생"

$$\text{Context} = \text{Attention Weight} \times V$$

  • 구해진 Attention Weight(가중치)를 바탕으로, 실제 단어들이 가진 정보(Value)를 가중합(Weighted Sum)합니다.
  • 특정 단어의 위치에서 전체 문장의 문맥을 고려했을 때, 자신과 연관성 높은 단어들의 정보가 함축된 새로운 벡터(Context Vector)가 탄생합니다.
  • 중요한 단어의 Value는 많이 반영되고, 덜 중요한 단어의 Value는 적게 반영된다.

최종 수식: Scaled Dot-Product Attention

$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q \cdot K^T}{\sqrt{d_k}}\right) \cdot V$$

전체 흐름 요약:

입력 임베딩
    ↓ (W_Q, W_K, W_V 변환)
Q, K, V 생성
    ↓
Q × K^T → Attention Score (단어 간 관련성)
    ↓
÷ √d_k → Scaled Score (값 안정화)
    ↓
softmax → Attention Weight (확률 분포 변환)
    ↓
Weight × V → Context Vector (문맥이 반영된 표현)

 

"Attention is All you need" 의 Attention 수식에는 학습 파라미터가 없는데, X를 Q, K, V의 가중치로 바꾼것만 차이점입니다.

위 과정을 3차원 데이터 예로 표시하면 아래와 같습니다.

주목할 점은 Input Embedding 정보가 문맥이 함축된 동일한 크기의 Context Vector(가중합)으로 생성된다는 점입니다.

모든 단어의 어텐션 점수 확률 분포의 가중합으로, 모든 문맥정보를 함축하게 됩니다.

 

다음은 트랜스포머의 단일 Attention일 경우 그림입니다.

Attention
Feed Forward

트랜스포머를 간략화하여 코드로 작성해보면 아래와 같습니다.

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

class SimpleAttention(nn.Module):
    def __init__(self, d_model):
        super(SimpleAttention, self).__init__()
        self.d_model = d_model

        self.W_q = nn.Parameter(torch.randn(d_model, d_model))        
        self.W_k = nn.Parameter(torch.randn(d_model, d_model))
        self.W_v = nn.Parameter(torch.randn(d_model, d_model))        

        # FFN
        self.W_ffn1 = nn.Parameter(torch.randn(d_model, d_model * 4)) 
        self.b_ffn1 = nn.Parameter(torch.randn(d_model * 4))      
        self.W_ffn2 = nn.Parameter(torch.randn(d_model * 4, d_model)) 
        self.b_ffn2 = nn.Parameter(torch.randn(d_model))          

        # Add & Norm
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)

    def forward(self, embeddings):
        Q = embeddings @ self.W_q
        K = embeddings @ self.W_k
        V = embeddings @ self.W_v

        attention_scores = (Q @ K.T) 
        attention_weights = F.softmax(attention_scores/ np.sqrt(self.d_model), dim=-1)
        context_vector = attention_weights @ V
        
        # 잔차 연결(Add): 원본 입력(embeddings) + 어텐션 결과(context_vector)
        norm1_out = self.norm1(embeddings + context_vector)

        # FFN 계산
        f_out = F.relu(norm1_out @ self.W_ffn1 + self.b_ffn1) @ self.W_ffn2 + self.b_ffn2
        
        # 잔차 연결(Add): 첫 번째 결과(norm1_out) + FFN 결과(f_out)
        final_out = self.norm2(norm1_out + f_out)
        
        return Q, K, V, attention_scores, attention_weights, context_vector, final_out

# --- 실행 코드 ---
d_model = 3  
embeddings = torch.tensor(
    [[1.0, 0.2, 0.1],  # I
     [0.3, 1.0, 0.8],  # love
     [0.2, 0.9, 0.7],  # this
     [0.3, 0.8, 1.0]], # movie
    dtype=torch.float32
)

attention_module = SimpleAttention(d_model)
Q, K, V, attention_scores, attention_weights, context_vector, f_out = attention_module(embeddings)

print("입력 (input_out):")
print(embeddings)

print("출력 (out):")
print(f_out)
입력 (input_out):
tensor([[1.0000, 0.2000, 0.1000],
        [0.3000, 1.0000, 0.8000],
        [0.2000, 0.9000, 0.7000],
        [0.3000, 0.8000, 1.0000]])
출력 (out):
tensor([[ 0.8392, -1.4054,  0.5662],
        [ 0.9302, -1.3876,  0.4574],
        [ 0.9944, -1.3681,  0.3737],
        [ 1.4138, -0.7366, -0.6772]], grad_fn=<NativeLayerNormBackward0>)

Multi-Head Attention

Multi-Head Attention은 Self-Attention(Scaled Dot-Product Attention)을 여러 개 동시에 수행하는 구조입니다.
논문에서는 다음과 같이 설명합니다.
"Multi-head attention allows the model to jointly attend to information from different representation subspaces."
이는 서로 다른 표현 공간(subspace)에서 동시에 attention을 수행할 수 있도록 한다는 의미입니다.
각 head는 서로 다른 가중치(weight)를 사용하므로, 동일한 입력이더라고 서로 다른 관점에서 정보를 학습할 수 있습니.
즉, 여러 개의 헤드(여러 명)가 동시에 같은 문장을 읽고, 각자의 관점으로 해석한 뒤 그 결과를 합치는 구조로 이해할 수 있습니다.

 

출력 차원

Self-Attention을 n_head개만큼 수행하면, 출력 차원이 d_model × n_head만큼 커지게 됩니다.
즉 출력 차원이 head 개수만큼 증가하게 됩니다.
하지만 Transformer에서는 잔차 연결(Residual Connection)을 사용하므로  입력 차원과 출력 차원의 크기가 동일해야 합니다.
따라서 각 head의 출력 차원을 d_model / n_head로 줄여서 사용합니다(이 축소된 차원이 d_k입니다).
n_head개의 출력을 concat하면 다시 원래의 d_model이 되므로, 잔차 연결이 가능해집니다.

입력 차원 : d_model
각 head의 출력 차원 : d_k = d_model / n_head
concat후 출력 차원 : (d_k x n_head) = d_model

Multi-head attention을 통해 모델은 문장의 의미적 관계, 문법적 관계, 장거리 의존서 등 서로 다른 유형의 정보를 동시에 학습할 수 있게 됩니다.

 

Multi-head Attention을 구현하기 위해 먼저 이전의 SimpleAttention code에서 Scaled Dot-Production 부분을 분리하겠습니다.

class ScaledDotAttention(nn.Module):
    def __init__(self, d_model, d_head):
        super().__init__()
        self.d_head = d_head

        self.W_q = nn.Parameter(torch.randn(d_model, d_head))
        self.W_k = nn.Parameter(torch.randn(d_model, d_head))
        self.W_v = nn.Parameter(torch.randn(d_model, d_head))

    def forward(self, x):
        # x: (seq_len, d_model) 또는 (batch, seq_len, d_model)
        Q = x @ self.W_q
        K = x @ self.W_k
        V = x @ self.W_v

        scores = Q @ K.transpose(-2, -1)
        attn_weights = F.softmax(scores / math.sqrt(self.d_head), dim=-1)
        context = attn_weights @ V
        return context
class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super().__init__()

        assert d_model % num_heads == 0, "d_model must be divisible by num_heads"

        self.d_model = d_model
        self.num_heads = num_heads
        self.d_head = d_model // num_heads

        self.heads = nn.ModuleList([
            ScaledDotAttention(d_model, self.d_head)
            for _ in range(num_heads)
        ])

        self.out = nn.Linear(d_model, d_model, bias=False)

    def forward(self, x):
        head_outputs = [head(x) for head in self.heads]
        context = torch.cat(head_outputs, dim=-1)
        return self.out(context)

  실제 구현에서의 효율적 처리

개념적으로는 각 head마다 별도의 Q, K, V를 계산하고, 각각 Self-Attention을 수행한 뒤 결과를 concat하는 것으로 설명됩니다. 하지만 실제 구현에서는 이렇게 head별로 따로 연산하지 않습니다.

대신, 하나의 큰 선형 변환(Linear Projection)으로 전체 head에 대한 Q, K, V를 한꺼번에 계산한 뒤, 결과 텐서를 (batch, n_head, seq_len, d_k) 형태로 reshape합니다. 이렇게 하면 모든 head의 attention 연산을 하나의 행렬 곱으로 병렬 처리할 수 있습니다.

출력 역시 이미 (batch, n_head, seq_len, d_k) 형태이므로, 이를 (batch, seq_len, d_model)로 reshape하는 것만으로 concat과 동일한 효과를 얻습니다. 별도의 concat 연산 없이 텐서의 shape만 바꾸는 것이므로 추가적인 연산 비용이 들지 않습니다.

정리하면, 개념상의 "head별 분리 → 각각 attention → concat"이 실제로는 "한꺼번에 projection → reshape → 병렬 attention → reshape"로 구현되어 계산 효율을 높이고 있습니다.

+ Recent posts