13  어텐션과 Transformer

학습 목표
  • RNN의 두 가지 병목(순차 처리, 먼 기억 소실)이 왜 구조적 한계인지 설명할 수 있다
  • 어텐션이 “유사도 → Softmax → 가중평균”의 조합임을 이해하고, 각각이 어느 장에서 배운 도구인지 짚을 수 있다
  • Query, Key, Value의 역할을 검색 비유로 설명할 수 있다
  • 소형 Q·K·V가 주어졌을 때 어텐션 가중치와 새 표현 벡터를 직접 계산할 수 있다
  • Self-Attention이 단어의 표현을 “문맥을 반영한 표현”으로 바꾼다는 것을 예시로 설명할 수 있다
  • Transformer 블록의 전체 구조를 다이어그램 수준에서 그릴 수 있다

이 장은 이 수업 손계산 여정의 정점이자 종착역이다. 퍼셉트론(Ch02), 콘볼루션(Ch06), IoU(Ch09), 유사도(Ch10)를 거쳐 온 손계산이 여기서 하나로 합쳐진다 — 그리고 놀랍게도, 새로 배울 수학은 하나도 없다.


13.1 RNN의 병목 재확인

Ch10의 마지막 질문을 회수하자. RNN은 문장을 한 단어씩 순서대로 읽으며 hidden state라는 기억 상자에 지금까지의 내용을 눌러 담았다. 이 설계에는 두 가지 구조적 병목이 있었다:

  1. 순차 처리 — 병렬화 불가. \(t\)번째 단어를 처리하려면 \(t-1\)번째 계산이 끝나야 한다. 단어 1,000개짜리 문서는 1,000단계를 기다려야 한다. GPU가 수천 개의 연산을 동시에 할 수 있어도(Ch01), RNN은 그 능력을 쓸 수 없다.
  2. 먼 기억의 소실. 모든 과거를 고정 크기 벡터 하나에 눌러 담으니, 문장이 길어질수록 앞부분의 정보가 희미해진다. “Those dogs ate their food, … they were very happy”에서 they를 처리할 때쯤이면 dogs는 기억 저편이다.

두 병목의 뿌리는 같다 — “순서대로, 하나의 기억 상자를 거쳐서”라는 설계 자체다. 그렇다면 요구사항을 다시 정의해 보자:

모든 단어를 동시에 보되, 필요한 곳에 집중할 수 있어야 한다.

“동시에 본다”가 병렬화를 해결하고, “필요한 곳에 집중한다”가 먼 기억 문제를 해결한다 — 아무리 멀리 있는 단어라도 필요하면 직접 참조하면 되기 때문이다. 이 요구사항을 구현한 것이 어텐션(Attention)이다.


13.2 어텐션의 직관: 번역하는 사람의 시선

“나는 어제 학교에서 친구를 만났다”를 영어로 옮긴다고 하자. “I met a friend at school yesterday”에서 “met”을 쓰는 순간, 여러분의 시선은 원문의 어디에 가 있는가?

  • “만났다”를 가장 강하게 본다 — 동사 자체니까
  • “어제”도 곁눈질한다 — meet이 아니라 met(과거형)을 골라야 하니까
  • “학교에서”는 지금은 거의 안 본다 — at school은 다음에 쓸 것이다

즉 번역가는 출력 단어 하나를 만들 때마다 원문 전체를 서로 다른 강도로 참조한다. 어텐션은 정확히 이것을 수식으로 옮긴 것이다:

정의: 어텐션 (Attention)

지금 처리 중인 단어가 다른 단어들을 얼마나 참고할지의 가중치를 정하고, 그 가중치로 다른 단어들의 정보를 가중평균하는 연산.

\[\text{참고할 정도(유사도)} \;\xrightarrow{\text{Softmax}}\; \text{가중치(합=1)} \;\xrightarrow{\text{가중평균}}\; \text{새 표현}\]

여기서 강조할 것이 있다. 어텐션에는 새 수학이 없다.

단계 쓰는 도구 어디서 배웠나
참고할 정도를 잰다 벡터의 내적(유사도) Ch10
정도를 가중치로 바꾼다 Softmax (합이 1인 확률로) Ch03
정보를 합친다 가중평균 고등학교

이미 손에 익은 세 가지를 순서대로 이어 붙이면 어텐션이 완성된다. 이 장에서 할 일은 이 조립을 직접 계산으로 확인하는 것뿐이다.


13.3 Q·K·V: 세 가지 역할

가중평균까지 가려면 먼저 “누가 누구를 참고하는가”를 정리할 언어가 필요하다. 어텐션은 각 단어에 세 개의 벡터를 부여한다.

정의: Query, Key, Value
  • Query (Q) — 내가 지금 찾고 있는 것. “나에게 필요한 정보는 무엇인가?”
  • Key (K) — 각 단어가 내건 색인(간판). “나는 이런 정보를 갖고 있다”
  • Value (V) — 각 단어가 실제로 전달할 내용

Query와 Key의 내적이 크면 “찾는 것과 간판이 잘 맞는다” → 그 단어의 Value를 많이 가져온다.

도서관 검색에 비유하면 정확하다. 여러분이 검색창에 입력하는 검색어가 Query, 각 책의 제목·키워드가 Key, 책의 본문이 Value다. 검색 엔진은 검색어와 잘 맞는 제목의 책일수록 결과 상단에 올리고(가중치), 여러분은 그 책들의 내용을 읽는다(Value를 가져온다). 어텐션이 다른 점은 하나뿐이다 — 1등 하나만 고르는 대신, 모든 책을 가중치만큼 섞어 읽는다.

Q·K·V는 어디서 오는가? 각 단어의 임베딩 벡터(Ch10)에 학습되는 행렬 \(W_Q, W_K, W_V\)를 곱해서 만든다. 즉 “무엇을 찾고, 무엇을 간판으로 내걸고, 무엇을 전달할지”조차 데이터로부터 학습되는 파라미터다 — 경사하강법(Ch03)으로 갱신된다는 점은 지금까지의 모든 가중치와 같다. 이 장의 손계산에서는 차원 폭발을 막기 위해, 이 곱셈이 끝난 Q·K·V 값이 이미 주어진 상태에서 시작한다.


13.4 어텐션 손계산 — 이 장의 심장

토큰 3개짜리 문장 “고양이가 / 생선을 / 먹었다”로 single-head self-attention 1회를 끝까지 계산한다. 각 토큰의 Q·K·V는 차원 2로 다음과 같이 주어졌다고 하자.

코드
import pandas as pd
import numpy as np

tokens = ['고양이가', '생선을', '먹었다']
Q = np.array([[2, 1], [1, 2], [1, 3]], dtype=float)
K = np.array([[1, 0], [0, 2], [1, 1]], dtype=float)
V = np.array([[1, 0], [0, 1], [1, 1]], dtype=float)

pd.DataFrame({
    '토큰': tokens,
    '$q$ (Query)': [f'({q[0]:.0f}, {q[1]:.0f})' for q in Q],
    '$k$ (Key)': [f'({k[0]:.0f}, {k[1]:.0f})' for k in K],
    '$v$ (Value)': [f'({v[0]:.0f}, {v[1]:.0f})' for v in V],
}).to_html(index=False, border=0, escape=False)
표 13.1: 주어진 Q·K·V (차원 2) — 임베딩에 W_Q, W_K, W_V를 곱한 결과라고 가정
토큰 \(q\) (Query) \(k\) (Key) \(v\) (Value)
고양이가 (2, 1) (1, 0) (1, 0)
생선을 (1, 2) (0, 2) (0, 1)
먹었다 (1, 3) (1, 1) (1, 1)

Value의 두 성분을 대략 (1번째 = “동물 성분”, 2번째 = “음식 성분”) 정도의 의미로 읽어 두면 마지막 결과가 눈에 들어올 것이다.

13.4.1 (a) 스코어: Q와 K의 내적

모든 (Query 토큰, Key 토큰) 쌍에 대해 내적을 계산한다. Ch10에서 한 그 내적 그대로다. 예를 들어 “먹었다”의 Query와 “생선을”의 Key라면:

\[q_{\text{먹었다}} \cdot k_{\text{생선을}} = (1, 3) \cdot (0, 2) = 1 \times 0 + 3 \times 2 = 6\]

9쌍을 전부 계산하면 \(3 \times 3\) 스코어 표가 나온다:

코드
S = Q @ K.T   # 모든 쌍의 내적을 한 번에

df = pd.DataFrame(S, columns=[f'{t}의 $k$' for t in tokens])
df.insert(0, 'Query \\ Key', [f'**{t}의 $q$**' for t in tokens])
df.to_html(index=False, border=0, escape=False)
표 13.2: 스코어 표 — 행: Query 토큰, 열: Key 토큰 (각 칸 = 내적)
Query \ Key 고양이가의 \(k\) 생선을의 \(k\) 먹었다의 \(k\)
고양이가의 \(q\) 2.0 2.0 3.0
생선을의 \(q\) 1.0 4.0 3.0
먹었다의 \(q\) 1.0 6.0 4.0

“먹었다” 행을 보라 — (1, 6, 4). 설계된 대로 “먹었다”의 Query는 “생선을”의 Key와 가장 잘 맞는다 (무엇을 먹었는지가 가장 필요한 정보다).

13.4.2 (b) √d로 나누기: 스케일링

스코어를 그대로 Softmax에 넣지 않고 차원의 제곱근 \(\sqrt{d}\)로 나눈다. 차원이 커지면 내적 값 자체가 커지고, 값이 커지면 Softmax가 한 곳에 확률을 몰아주는 극단적 분포가 되기 때문에 이를 완화하는 안전장치다. 여기서는 \(d = 2\)이므로 \(\sqrt{2} \approx 1.414\)로 나눈다. “먹었다” 행은 \((1, 6, 4) \rightarrow (0.71,\ 4.24,\ 2.83)\)이 된다.

13.4.3 (c) 행별 Softmax: 스코어를 가중치로

(Query 토큰 하나)에 대해 Softmax를 적용한다. Ch03에서 배운 그 Softmax, 표기까지 그대로다:

\[\text{softmax}(z_k) = \frac{e^{z_k}}{\sum_{j=1}^{K} e^{z_j}}\]

Ch03에서는 \(z\)가 “클래스 점수”였고 지금은 “참고할 정도”라는 것만 다르다. “먹었다” 행을 Ch03의 수치 예시와 같은 형식으로 계산하면:

코드
z = S[2] / np.sqrt(2)          # 먹었다 행의 스케일된 스코어
exp_z = [np.exp(zi) for zi in z]
sum_exp = sum(exp_z)
softmax = [e / sum_exp for e in exp_z]

rows = []
for k in range(3):
    rows.append({
        '참조 토큰': tokens[k],
        '스코어': f'{S[2][k]:.0f}',
        '$z_k = $ 스코어$/\\sqrt{{2}}$': f'{z[k]:.4f}',
        '$e^{{z_k}}$': f'{exp_z[k]:.4f}',
        '$\\text{{softmax}}(z_k)$': f'{softmax[k]:.4f}',
        '가중치 (%)': f'{softmax[k]*100:.1f}%',
    })

rows.append({
    '참조 토큰': '**합계**',
    '스코어': '',
    '$z_k = $ 스코어$/\\sqrt{{2}}$': '',
    '$e^{{z_k}}$': f'{sum_exp:.4f}',
    '$\\text{{softmax}}(z_k)$': f'{sum(softmax):.4f}',
    '가중치 (%)': '100.0%',
})

pd.DataFrame(rows).to_html(index=False, border=0, escape=False)
표 13.3: Softmax 계산 과정 — ‘먹었다’ 행 (Ch03의 Softmax 그대로)
참조 토큰 스코어 $z_k = $ 스코어\(/\\sqrt{{2}}\) \(e^{{z_k}}\) \(\\text{{softmax}}(z_k)\) 가중치 (%)
고양이가 1 0.7071 2.0281 0.0229 2.3%
생선을 6 4.2426 69.5914 0.7860 78.6%
먹었다 4 2.8284 16.9188 0.1911 19.1%
합계 88.5383 1.0000 100.0%

“먹었다”는 자신의 새 표현을 만들 때 “생선을”을 78.6%, 자기 자신을 19.1%, “고양이가”를 2.3% 참고하기로 결정했다. 같은 절차를 세 행 모두에 적용하면 어텐션 가중치 행렬이 완성된다 (각 행의 합 = 1):

Query \ Key 고양이가 생선을 먹었다
고양이가 0.248 0.248 0.503
생선을 0.074 0.620 0.306
먹었다 0.023 0.786 0.191

13.4.4 (d) 가중합: 새 표현 벡터

마지막 단계 — 가중치로 Value들을 가중평균한다. “먹었다”에 대해 끝까지 계산하면:

코드
w = np.array(softmax)          # 먹었다 행의 가중치

rows = []
for k in range(3):
    wv = w[k] * V[k]
    rows.append({
        '참조 토큰': tokens[k],
        '가중치 $w_k$': f'{w[k]:.4f}',
        '$v_k$': f'({V[k][0]:.0f}, {V[k][1]:.0f})',
        '$w_k \\times v_k$': f'({wv[0]:.4f}, {wv[1]:.4f})',
    })

out = w @ V
rows.append({
    '참조 토큰': '**새 표현 (합)**',
    '가중치 $w_k$': '',
    '$v_k$': '',
    '$w_k \\times v_k$': f'**({out[0]:.4f}, {out[1]:.4f})**',
})

pd.DataFrame(rows).to_html(index=False, border=0, escape=False)
표 13.4: 가중합 — ’먹었다’의 새 표현 벡터
참조 토큰 가중치 \(w_k\) \(v_k\) \(w_k \\times v_k\)
고양이가 0.0229 (1, 0) (0.0229, 0.0000)
생선을 0.7860 (0, 1) (0.0000, 0.7860)
먹었다 0.1911 (1, 1) (0.1911, 0.1911)
새 표현 (합) (0.2140, 0.9771)

“먹었다”의 새 표현은 \((0.214,\ 0.977)\) — 2번째 성분(“음식 성분”)이 압도적이다. 어텐션 1회를 거치며 “먹었다”라는 단어의 벡터에 “생선을 먹었다”라는 문맥이 스며든 것이다. 이것이 어텐션이 하는 일의 전부다: 내적으로 재고, Softmax로 나누고, 가중평균으로 섞는다.

손으로 따라 해 보라: “고양이가” 행에 대해 (b)~(d)를 반복하면 새 표현 \((0.752,\ 0.752)\)가 나와야 한다. 문제집의 손계산 문제가 정확히 이 형태다.


인터랙티브 — 어텐션 가중치를 눈으로 확인하세요

번역 예시 문장 “나는 어제 학교에서 친구를 만났다”의 토큰 5개에 Q·K 값(차원 2)을 미리 넣어 두었어요. Query 토큰을 선택하면, 그 토큰의 Q와 모든 K의 스코어 → Softmax 가중치가 연결선의 굵기와 막대로 표시됩니다. “만났다”를 선택해 보세요 — 번역가의 시선처럼 “친구를”에 가장 집중하는 것을 볼 수 있어요.

시도해 보세요: “어제”를 선택하면 “친구를”과 자기 자신에 가중치가 비슷하게 나뉩니다. 토큰마다 “어디를 보는가”가 달라진다는 것 — 이것이 행마다 Softmax를 따로 하는 이유입니다.


13.5 Self-Attention의 의미: 문맥을 입은 단어

위 계산에서 문장은 자기 자신을 참조했다 — Query도, Key도, Value도 같은 문장의 토큰들에서 나왔다. 그래서 Self-Attention이다. 그 결과가 무엇이었는지 다시 보자:

  • 어텐션 : “먹었다”의 벡터는 어느 문장에 있든 똑같은, 사전에 박제된 표현
  • 어텐션 : “먹었다”의 벡터는 “생선을”의 정보가 78.6% 섞인, 이 문장 안에서의 먹었다

즉 Self-Attention 1회는 모든 단어의 표현을 “문맥을 반영한 표현”으로 갱신하는 연산이다.

이것이 왜 중요한가? Ch10에서 임베딩의 한계를 하나 남겨 두었다 — 같은 단어는 늘 같은 벡터라는 것. “강가의 은행에 앉았다”와 “돈을 맡기러 은행에 갔다”에서 “은행”은 전혀 다른 뜻이지만, 임베딩 사전에는 “은행” 벡터가 하나뿐이다. Self-Attention을 통과하면 첫 문장의 “은행”은 “강가”의 Value를 많이 섞고, 둘째 문장의 “은행”은 “돈”과 “맡기러”의 Value를 많이 섞는다 — 같은 단어가 문장마다 다른 벡터가 된다. 다의어 문제가 구조적으로 풀리는 것이다.


13.6 개념 수준 확장: 실제 Transformer는 여기에 세 가지를 더한다

Multi-Head Attention — 여러 관점을 병렬로. 위에서 계산한 어텐션 1회는 “관점 하나”다. “먹었다”는 목적어(“생선을”)를 볼 수도 있지만, 주어(“고양이가”)를 보는 관점, 시제를 보는 관점도 필요하다. 그래서 실제 Transformer는 서로 다른 \(W_Q, W_K, W_V\)를 가진 어텐션을 여러 개(head) 병렬로 수행한 뒤 결과를 이어 붙인다. 각 head의 계산은 우리가 손으로 한 것과 완전히 같고, 단지 여러 번 동시에 할 뿐이다.

위치 인코딩 — 사라진 순서를 되살린다. 모든 단어를 동시에 보는 순간, 대가가 하나 생긴다 — 순서 정보가 사라진다. 내적과 가중평균 어디에도 “몇 번째 단어인가”는 등장하지 않으므로, “고양이가 생선을 먹었다”와 “생선이 고양이를 먹었다”의 구분이 위태로워진다. 해결책은 단순하다: 각 위치(1번째, 2번째, …)마다 고유한 위치 벡터를 임베딩에 더해서 입력 자체에 순서를 새겨 넣는다. RNN은 순서를 구조(순차 처리)로 갖고 있었지만, Transformer는 순서를 데이터에 더해서 갖는다.

블록 쌓기 — 어텐션을 층으로. Self-Attention 1회가 “문맥 반영 1단계”라면, 그 출력에 어텐션을 또 적용할 수 있다. CNN이 콘볼루션 층을 쌓아 에지 → 부품 → 물체로 추상화 수준을 높였듯이(Ch05), Transformer는 [어텐션 + 완전연결층] 블록을 수십 번 쌓아 단어 수준 → 구 수준 → 문장 수준의 관계를 만들어 간다.


13.7 Transformer 전체 그림

세 가지 확장을 합치면 Transformer의 전체 구조가 완성된다:

입력 문장 “고양이가 생선을…” 토큰화 (Ch10) + 임베딩 (Ch10) + 위치 벡터 Transformer 블록 × N Multi-Head Self-Attention + Norm 완전연결층 (FFN, Ch02) 잔차연결 (Ch07의 우회로) 잔차연결 + Norm 출력 (Softmax, Ch03) 블록 내부 = [Self-Attention → FFN], 각 단계에 우회로(잔차연결)와 정규화(LayerNorm)가 붙는다

낯선 부품이 거의 없다는 것을 확인하라. 임베딩은 Ch10, 완전연결층은 Ch02, 출력의 Softmax는 Ch03이다. 잔차연결의 점선 화살표는 Ch07에서 본 ResNet의 우회로가 여기에도 있다는 표시다 — 블록을 수십 개 쌓아도 학습이 무너지지 않는 이유도 그때와 같다. 진짜 새 부품은 Self-Attention 하나뿐이고, 그것은 방금 손으로 계산했다.


13.8 왜 혁명이었나

2017년 논문 “Attention Is All You Need”가 제안한 이 구조는 몇 년 만에 자연어 처리 전체를 갈아치웠다. 이유는 성능만이 아니다 — 확장성이다.

RNN Transformer
처리 방식 한 단어씩 순차 처리 모든 단어 동시 처리
단어 1,000개 문장 1,000단계 대기 행렬 곱 몇 번 (병렬)
먼 단어 참조 기억 상자를 거쳐 간접적으로 어텐션으로 직접 (거리 무관)
GPU 활용 구조적으로 제한 완전 활용 가능

스코어 계산이 결국 행렬 곱 \(QK^\top\) 하나라는 점을 상기하자. 행렬 곱은 GPU가 가장 잘하는 연산이다(Ch01). “순서대로 기다려야 하는” 구조가 사라지자, 데이터를 더 붓고 모델을 더 키우면 성능이 계속 오르는 시대가 열렸다 — 더 큰 모델, 더 많은 데이터, 더 많은 GPU의 스케일 경쟁이 시작된 것이다.

그렇다면 자연스러운 질문. 이 구조를 인터넷 규모의 텍스트로, 수천억 개의 파라미터로 학습시키면 무슨 일이 생기는가? 그 답이 다음 장의 주인공 — 거대 언어 모델(LLM)이다.


깊이 계약 확인

문제집과 시험은 single-head 어텐션 손계산 1회 — 즉 이 장에서 한 “내적 → \(\sqrt{d}\) 나누기 → Softmax → 가중합”까지만 다룬다. Q·K·V 값은 항상 문제에서 주어진다. Multi-head의 행렬 유도, LayerNorm의 계산, 위치 인코딩의 수식(sin/cos)은 모두 범위 밖이다 — “왜 필요한가”를 한 문장으로 설명할 수 있으면 충분하다.


13.9 핵심 요약

개념 핵심 내용
RNN의 병목 순차 처리(병렬 불가) + 먼 기억 소실 — 뿌리는 “순서대로, 기억 상자 하나”
어텐션 유사도(내적, Ch10) → 가중치(Softmax, Ch03) → 가중평균. 새 수학 없음
Q / K / V 찾는 것 / 색인 / 전달할 내용 — 임베딩 × 학습 행렬 \(W_Q, W_K, W_V\)
스케일링 스코어를 \(\sqrt{d}\)로 나눔 — Softmax의 극단화 완화
Self-Attention 문장이 스스로를 참조 → 모든 단어가 “문맥을 반영한 표현”으로 갱신
Multi-Head 서로 다른 관점의 어텐션을 병렬 수행 후 결합
위치 인코딩 동시에 보면 순서가 사라짐 → 위치 벡터를 임베딩에 더해 되살림
Transformer [Self-Attention + FFN] 블록 × N, 잔차연결(Ch07)로 깊게 쌓음
혁명의 이유 완전 병렬 → GPU 스케일 가능 → 데이터·모델 크기 경쟁의 시작

다음 장 예고: 손계산의 등뼈는 여기서 완성되었다. 이제 이 구조를 인터넷 규모의 텍스트로 학습시킨 결과물 — 여러분이 매일 쓰는 그 챗봇 — 의 내부로 들어간다. “다음 단어 맞히기”라는 단순한 목표가 어떻게 지능처럼 보이는 능력이 되는가, 그리고 temperature라는 마지막 손계산이 남아 있다.