2017년 Google의 Vaswani 등이 발표한 논문 「Attention Is All You Need」는 제목 그대로 순환 신경망을 걷어내고 어텐션만으로 시퀀스를 변환하는 구조를 내놓았다. 어텐션은 한 자리의 표현을 만들 때 다른 자리들을 각각 얼마나 볼지 가중치로 정하고 그 가중합을 취하는 계산이다. 여기서 「자리」는 토큰, 곧 문장을 모델이 다루는 단위로 자른 조각이고, 각 토큰은 임베딩이라 부르는 고정 길이 벡터로 바뀌어 모델에 들어온다. 그 벡터의 길이가 d_model이며, 이 글에 나오는 숫자는 거의 전부 이 값에 묶여 있다.
BERT, GPT, T5, LLaMA가 모두 이 구조의 변형이다. 그래서 이 글은 「무엇이 새로웠나」보다 무엇이 서로 묶여 있나에 무게를 둔다. 레이어 수와 헤드 수와 d_ff를 각각 따로 고르는 값으로 알고 있으면 모델 설정을 읽을 때마다 숫자가 왜 하필 그것인지에서 막히는데, 실제로는 하나를 정하면 나머지가 거의 따라온다.
RNN이 막혔던 자리
순차 처리
RNN(순환 신경망)은 토큰을 하나씩 받아 은닉 상태를 갱신하는 구조다. t번째 토큰을 처리하려면 t−1번째 결과가 먼저 나와 있어야 한다. 이 의존이 학습에서 그대로 병목이 된다. 길이 100짜리 문장은 100번의 순차 계산을 거쳐야 하고, GPU가 아무리 많은 연산을 동시에 처리할 수 있어도 그 100단계를 서로 겹칠 방법이 없다. 배치를 키워 문장 여러 개를 동시에 흘리는 것이 유일한 병렬화였고, 그래서 배치가 커질수록 메모리가 먼저 바닥났다.
어텐션은 이 의존을 끊는다. 모든 토큰 쌍의 관계를 한 번의 행렬 곱으로 계산하므로 길이가 100이든 1,000이든 학습에서는 한 단계다. 대신 계산량이 길이의 제곱으로 늘어난다. 순차 단계 수를 길이 제곱만큼의 병렬 연산과 맞바꾼 셈이고, 행렬 곱에 특화된 GPU는 이 거래에서 이긴다. 같은 시간에 훨씬 많은 데이터를 흘릴 수 있다는 것이 2017년에 이 구조가 이긴 실질적인 이유였다.
기울기 소실
학습은 출력에서 잰 오차를 입력 쪽으로 되돌리며 파라미터를 조금씩 옮기는 일이고, 그 되돌리는 신호를 기울기라 한다. RNN에서 첫 토큰의 파라미터에 닿으려면 이 신호가 100단계를 거슬러 올라가야 하는데, 매 단계에서 1보다 작은 수가 곱해지면 신호는 지수적으로 줄어 사실상 0이 된다. 스무 단계쯤 떨어진 관계는 학습되지 않고, 이것이 기울기 소실이다.
LSTM과 GRU가 게이트 구조로 이 감쇠를 늦췄지만 완전히 없애지는 못했다. 어텐션에서는 어느 토큰에서 어느 토큰으로 가는 경로가 항상 한 걸음이다. 100번째 토큰이 1번째 토큰을 참조한 만큼의 기울기가 곧바로 되돌아간다. 거리와 학습 난도의 비례 관계 자체가 사라진 것이 구조 변경의 본질이다. 다만 경로가 짧아졌다고 해서 먼 관계를 저절로 배우는 것은 아니다. 어느 토큰을 볼지 정하는 가중치도 학습해야 하는 값이고, 데이터에 그 관계가 충분히 나타나지 않으면 경로가 한 걸음이어도 배우지 못한다. 어텐션이 없앤 것은 거리 때문에 생기던 구조적 손해이지 학습 자체의 어려움이 아니다.
컨텍스트 벡터 병목
번역에 쓰이던 Seq2Seq는 입력 문장 전체를 인코더가 읽어 컨텍스트 벡터 하나로 압축하고, 디코더가 그 벡터만 보고 출력을 만들었다. 길이가 다섯이든 쉰이든 같은 크기의 벡터에 담기므로 문장이 길어질수록 정보가 뭉개진다. 2014년 Bahdanau의 어텐션은 이 병목을 풀려고 디코더가 매 단계 인코더의 모든 자리를 다시 보게 한 장치였다.
Transformer는 그 장치를 보조에서 주역으로 옮긴 것이다. 「어텐션이 전부다」라는 제목은 은유가 아니라 구조 설명이다 — 순환을 없애고 남긴 것이 어텐션과 위치별 변환 두 가지뿐이다.
세 한계를 한 줄로 모아 두면 이 구조가 무엇을 팔아 무엇을 샀는지가 분명해진다.
| RNN의 한계 | 어텐션이 바꾼 것 | 대신 치른 값 |
|---|---|---|
| 길이만큼의 순차 단계 | 길이와 무관하게 한 단계 | 길이 제곱의 연산과 메모리 |
| 거리에 비례해 약해지는 기울기 | 모든 쌍이 한 걸음 거리 | 위치 정보를 따로 넣어야 함 |
| 고정 크기 컨텍스트 벡터 | 모든 자리를 매번 참조 | 참조할 자리를 전부 들고 있어야 함 |
오른쪽 열이 그대로 이후 8년의 숙제 목록이다. 위치 정보를 어떻게 넣을지는 위치 인코딩 연구가 되었고, 길이 제곱을 어떻게 줄일지는 효율적 어텐션 연구가 되었으며, 참조할 자리를 들고 있는 비용은 지금 추론 서버의 메모리 대부분을 차지하는 캐시 문제가 되었다.
블록 하나의 구성
Transformer는 인코더 레이어 N개와 디코더 레이어 N개를 쌓아 만든다. 원논문에서 N은 6이었다.
인코더 레이어
인코더 한 레이어는 서브레이어 둘로 되어 있다. 앞은 같은 시퀀스 안의 모든 위치 쌍을 보는 멀티 헤드 셀프 어텐션이고, 뒤는 각 위치에 독립적으로 적용되는 2층 신경망이다. 두 서브레이어 모두 입력을 그대로 더해 주는 잔차 연결과 정규화로 감싼다.
import torch
import torch.nn as nn
class EncoderLayer(nn.Module):
def __init__(self, d_model=512, nhead=8, d_ff=2048, dropout=0.1):
super().__init__()
self.self_attn = nn.MultiheadAttention(d_model, nhead,
batch_first=True)
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(d_ff, d_model),
)
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.self_attn(x, x, x,
key_padding_mask=src_key_padding_mask)
x = self.norm1(x + self.drop(attn_out))
x = self.norm2(x + self.drop(self.ffn(x)))
return x
forward가 받는 것과 내놓는 것이 같은 모양이라는 점이 중요하다. 레이어를 몇 개 쌓든 텐서 모양이 변하지 않으므로 깊이가 설정값 하나로 정리된다. 잔차 연결은 이 성질에 기대어 성립한다. 서브레이어의 출력에 입력을 그대로 더할 수 있으려면 둘의 모양이 같아야 하고, 그래서 d_model은 첫 임베딩에서 마지막 레이어까지 한 번도 바뀌지 않는다. 안에서 헤드로 쪼개지고 FFN에서 네 배로 부풀었다가도 서브레이어를 나올 때는 반드시 d_model로 돌아온다.
디코더 레이어
디코더 레이어는 서브레이어가 셋이다. 첫째는 아직 생성하지 않은 뒤쪽 토큰을 가리는 마스크를 건 셀프 어텐션이고, 둘째는 질의만 디코더에서 오고 키와 값은 인코더 출력에서 오는 크로스 어텐션이다. 셋째는 인코더와 같은 위치별 신경망이다. 마스크가 없으면 학습 때 정답을 미리 보게 되어, 손실은 잘 떨어지는데 실제 생성은 전혀 못 하는 모델이 나온다. 학습에서는 출력 문장 전체를 한 번에 넣고 마스크로 뒤를 가려 모든 위치를 동시에 학습하지만, 실제 생성에서는 토큰을 하나 만들 때마다 다시 넣어야 하므로 여기서만은 순차 처리가 남는다. 학습은 병렬이고 생성은 순차라는 이 비대칭이 추론 최적화가 학습 최적화와 전혀 다른 문제인 이유다.
class DecoderLayer(nn.Module):
def __init__(self, d_model=512, nhead=8, d_ff=2048, dropout=0.1):
super().__init__()
self.self_attn = nn.MultiheadAttention(d_model, nhead,
batch_first=True)
self.cross_attn = nn.MultiheadAttention(d_model, nhead,
batch_first=True)
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff), nn.GELU(),
nn.Dropout(dropout), nn.Linear(d_ff, d_model),
)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.norm3 = nn.LayerNorm(d_model)
self.drop = nn.Dropout(dropout)
def forward(self, tgt, mem, tgt_mask=None):
a, _ = self.self_attn(tgt, tgt, tgt, attn_mask=tgt_mask)
tgt = self.norm1(tgt + self.drop(a))
a, _ = self.cross_attn(tgt, mem, mem)
tgt = self.norm2(tgt + self.drop(a))
tgt = self.norm3(tgt + self.drop(self.ffn(tgt)))
return tgt
텐서 모양 따라가기
구조를 외우는 것보다 텐서 모양을 한 번 따라가는 편이 오래 남는다. 배치 32, 입력 길이 10, d_model 512, 헤드 8, 어휘 3만 2천으로 두고 임베딩부터 출력 로짓까지 적으면 아래와 같다.
| 자리 | 모양 | 비고 |
|---|---|---|
| 토큰 id | (32, 10) | 정수 |
| 임베딩 + 위치 인코딩 | (32, 10, 512) | 여기서부터 끝까지 유지 |
| 헤드로 쪼갠 Q·K·V | (32, 8, 10, 64) | 64 = 512 / 8 |
| 어텐션 가중치 | (32, 8, 10, 10) | 길이의 제곱이 여기 있다 |
| 헤드 합치고 W_O 통과 | (32, 10, 512) | 입력과 같은 모양 |
| FFN 중간 | (32, 10, 2048) | d_ff = 4 × d_model |
| 출력 로짓 | (32, 10, 32000) | 어휘 크기로 투영 |
눈여겨볼 곳은 두 줄이다. 어텐션 가중치만 길이의 제곱을 갖고, FFN 중간만 d_model의 네 배로 부푼다. 긴 문맥에서 메모리가 터지는 것은 앞 줄 때문이고, 파라미터 수가 커지는 것은 뒤 줄 때문이다. 이 두 줄을 각각 줄이려는 시도가 이후 연구의 절반을 차지한다.
제곱이 실제로 얼마나 무거운지는 숫자를 넣어 보면 바로 나온다. 어텐션 가중치 텐서는 배치 × 헤드 × 길이 × 길이이므로, 배치 1에 헤드 8, 16비트 부동소수점으로 두고 길이만 바꾸면 레이어 하나당 길이 512에서 4메가바이트, 2,048에서 64메가바이트, 8,192에서 1기가바이트가 된다. 여기에 레이어 수를 곱해야 하고 학습 때는 역전파를 위해 이 값을 보관해야 하므로, 길이를 네 배로 늘리는 일은 메모리를 열여섯 배로 늘리는 일이다. 문맥 창을 늘리는 것이 설정값 하나 고치는 일이 아닌 이유가 이 곱셈에 있다.
하이퍼파라미터의 묶임
d_model과 헤드 수
헤드 수 h를 정하면 헤드 하나가 쓰는 차원은 d_model / h로 자동으로 정해진다. 나눠 갖는 것이지 각 헤드가 d_model을 따로 쓰는 것이 아니므로, 헤드를 여덟에서 열여섯으로 늘려도 파라미터 수와 연산량은 그대로다. 512를 8로 나눠 64를 쓰는 것이 원논문 설정이고, 헤드당 차원이 32 아래로 내려가면 표현력이 눈에 띄게 떨어진다는 보고가 여럿이라 h는 대개 d_model을 64로 나눈 값 근처에 놓인다. 그래서 모델 설정표에서 헤드 수를 보면 폭을 짐작할 수 있다. 헤드가 32개라면 d_model이 2,048 근처일 가능성이 높고, 그 값이 다시 d_ff와 파라미터 총량을 끌고 간다.
d_ff와 파라미터 배분
d_ff가 d_model의 네 배라는 관행에는 이론적 근거가 없다. 원논문이 512와 2048로 실험해 좋았고 그 비율이 굳었다. 중요한 것은 이 선택이 파라미터 배분을 결정한다는 점이다. 어텐션 쪽은 투영 행렬 네 개가 각각 d_model × d_model이라 4배이고, FFN 쪽은 두 행렬이 각각 d_model × d_ff라 8배다. 결국 레이어 파라미터의 3분의 2가 FFN에 있고, 어텐션이 구조의 이름을 가져갔지만 무게의 다수는 FFN이 들고 있다. base 설정으로 직접 세어 보면 레이어 하나에서 어텐션이 512 × 512 × 4로 약 105만 개, FFN이 512 × 2048 × 2로 약 210만 개다. 인코더와 디코더를 합쳐 열두 레이어를 쌓고 임베딩과 출력 투영을 더하면 논문이 보고한 6,500만 개에 닿는다. 모델 용량을 키우고 싶을 때 손대는 곳이 왜 대개 d_ff인지, 그리고 전문가 혼합처럼 FFN만 여러 벌 두는 구조가 왜 나왔는지가 이 배분에서 나온다.
base와 big
원논문은 설정 둘을 보고했다. 아래 표를 보면 값들이 따로 노는 것이 아니라 한 덩어리로 움직인다는 것이 드러난다.
| 설정 | 레이어 | d_model |
d_ff |
헤드 | 헤드당 차원 | 파라미터 |
|---|---|---|---|---|---|---|
| base | 6 | 512 | 2048 | 8 | 64 | 6,500만 |
| big | 6 | 1024 | 4096 | 16 | 64 | 2억 1,300만 |
d_model을 두 배로 올리면서 d_ff도 두 배, 헤드도 두 배로 올려 헤드당 차원 64를 지켰다. 깊이는 손대지 않았다. 지금의 대형 모델도 방식은 같아서, 폭과 깊이를 함께 키우되 헤드당 차원은 64에서 128 사이에 묶어 둔다.
원논문의 학습 설정
warmup 스케줄
Transformer는 학습률을 고정으로 두면 초반에 자주 발산한다. 원논문은 4,000스텝 동안 학습률을 0에서 선형으로 올린 뒤 스텝 수의 제곱근에 반비례해 낮추는 스케줄을 썼다. 초반에 학습률을 낮게 두는 이유는 정규화 계층과 잔차 경로의 상호작용 때문인데, 뒤에서 다룰 Pre-Norm이 이 의존을 크게 줄인다. warmup 없이 돌려 보고 손실이 몇백 스텝 만에 NaN이 되는 것을 확인해 두면 이 스케줄이 장식이 아니라는 것이 분명해진다. 4,000이라는 수 자체는 모델과 배치 크기에 따라 달라지는 값이라 그대로 가져다 쓸 것은 아니고, 전체 스텝의 1퍼센트에서 5퍼센트 사이를 warmup으로 잡는 것이 지금의 관행이다.
레이블 스무딩
정답 토큰에 확률 1을 몰아 주는 대신 0.9만 주고 나머지를 전체 어휘에 고루 뿌리는 기법을 레이블 스무딩이라 한다. 원논문은 0.1을 썼고, 흥미로운 것은 이 설정이 혼란도를 오히려 나쁘게 만들면서 번역 품질 점수는 올렸다는 점이다. 모델이 과하게 확신하지 않게 되어 빔 서치가 대안을 살려 두기 때문이다. 지표 하나가 나빠지는 것을 받아들이고 최종 품질을 택한 사례로 자주 인용된다. 지금의 대형 모델 학습에서는 쓰지 않는 경우가 더 많은데, 빔 서치로 문장을 뽑던 번역 시대의 설정이고 확률 분포 자체를 결과로 쓰는 지금 방식과는 목적이 어긋나기 때문이다.
드롭아웃 세 자리
원논문은 드롭아웃 0.1을 세 자리에 걸었다. 각 서브레이어의 출력이 잔차에 더해지기 직전, 임베딩과 위치 인코딩을 더한 직후, 그리고 어텐션 가중치 자체다. 마지막 자리가 낯설게 느껴질 수 있는데, 소프트맥스를 지난 가중치 일부를 0으로 만들어 특정 토큰 하나에 의존하는 것을 막는 장치다. 큰 모델로 갈수록 데이터가 충분해 드롭아웃을 0으로 두는 경우가 많고, 이 값은 모델 크기보다 데이터 대비 파라미터 비율을 보고 정한다.
세 갈래 계보
인코더만
인코더 스택만 남기면 양방향 문맥을 읽는 모델이 된다. 각 토큰이 앞뒤를 모두 보므로 문장을 이해해 분류하거나 벡터로 요약하는 일에 강하고, 대신 다음 토큰을 이어 쓰는 일은 못 한다. BERT 계열이 여기 속하며 지금도 검색용 임베딩과 재순위 모델에서 현역이다. 생성 모델이 커진 뒤에도 이 자리가 남은 이유는 비용이다. 문서 수백만 건을 벡터로 바꿔 두는 일에는 몇억 개 파라미터짜리 인코더로 충분하고, 같은 일을 생성 모델에 시키면 단가가 몇 자릿수 달라진다.
디코더만
디코더 스택만 남기고 크로스 어텐션을 빼면 남는 것은 마스크를 건 셀프 어텐션과 FFN이다. 다음 토큰을 예측하는 목표 하나로 아무 텍스트나 학습할 수 있다는 점이 결정적이었다. 인코더 전용 모델의 학습 목표는 입력 일부를 가리고 맞히는 방식이라 한 문장에서 15퍼센트 정도만 학습 신호가 되는데, 디코더 전용은 모든 위치가 신호가 된다. 같은 데이터에서 더 많이 배운다는 뜻이고, 데이터와 연산을 키우는 경쟁에서 이 차이가 벌어졌다. 게다가 작업마다 입력과 출력의 경계를 다시 정의할 필요가 없어, 분류든 번역이든 질의응답이든 전부 「이어 쓰기」 한 가지 형식으로 표현된다. 구조가 단순해서 이긴 것이 아니라 학습 신호의 밀도와 작업 형식의 통일이라는 두 이득이 겹쳐서 이겼다.
인코더-디코더
원논문 그대로의 구조는 입력과 출력이 뚜렷이 나뉘는 번역과 요약에서 여전히 합리적이다. T5와 BART가 이 계보이고, 입력을 인코더가 한 번만 읽어 두고 디코더가 반복 참조하므로 긴 입력에 짧은 출력을 내는 작업에서는 계산이 절약된다. 다만 하나의 모델로 모든 작업을 다루려는 흐름에서는 입력과 출력의 경계를 미리 정해야 한다는 점이 제약이 됐다. 세 갈래를 가른 기준은 결국 「각 토큰이 무엇을 볼 수 있는가」 하나다. 양쪽을 다 보면 인코더, 앞만 보면 디코더, 입력은 양쪽을 보고 출력은 앞만 보면서 입력을 참조하면 인코더-디코더다. 구조도가 복잡해 보여도 갈림길은 마스크의 모양 하나뿐이다.
transformer = nn.Transformer(
d_model=512, nhead=8,
num_encoder_layers=6, num_decoder_layers=6,
dim_feedforward=2048, dropout=0.1,
batch_first=True,
)
src = torch.randn(32, 10, 512)
tgt = torch.randn(32, 8, 512)
out = transformer(src, tgt) # (32, 8, 512)
2017년 이후 바뀐 것
Pre-Norm
원논문은 서브레이어를 통과한 뒤 더하고 정규화했다. 지금은 거의 모든 모델이 순서를 뒤집어 정규화를 먼저 하고 그 결과를 서브레이어에 넣은 뒤 원래 입력에 더한다. 이렇게 하면 입력에서 출력까지 정규화를 거치지 않는 잔차 경로가 통째로 남아 기울기가 그대로 전달되고, warmup에 대한 의존이 크게 줄어 수십 층을 안정적으로 쌓을 수 있다. 원논문의 여섯 층에서는 차이가 크지 않았으나 층이 수십 개로 늘면서 갈라졌고, 지금은 스택 맨 끝에 정규화를 한 번 더 두는 것까지가 기본 형태다.
RoPE와 GQA
위치 정보를 사인과 코사인 값으로 만들어 임베딩에 더하던 방식은 회전 위치 인코딩으로 대체됐다. 값을 더하는 대신 질의와 키 벡터를 위치에 따라 회전시키는 방식이라 두 토큰의 상대 거리가 내적에 자연스럽게 들어가고, 학습 때 본 것보다 긴 입력으로 늘리기도 쉽다. 어텐션 쪽에서는 키와 값을 헤드 여럿이 나눠 쓰는 그룹 질의 어텐션이 표준이 됐는데, 생성할 때 이미 계산한 키와 값을 저장해 두는 캐시의 크기를 몇 배로 줄이기 때문이다. 이 캐시가 지금 긴 문맥 추론에서 가장 큰 메모리 항목이다. 두 변경 모두 성능을 올리려고 한 것이 아니라 위의 표에서 오른쪽 열에 적힌 값을 깎으려고 한 것이다.
SwiGLU
FFN의 활성 함수도 바뀌었다. ReLU 대신 게이트를 하나 더 두는 SwiGLU를 쓰면 행렬이 둘에서 셋으로 늘어나므로, 파라미터 수를 맞추려고 d_ff를 4배가 아니라 8/3배쯤으로 낮춰 잡는다. LLaMA 계열의 설정에서 d_ff가 어중간한 값인 것이 이 때문이다. 정규화 방식도 평균을 빼는 계산을 생략한 형태로 단순해졌는데, 품질은 그대로인데 계산이 줄어든다는 보고가 쌓여 그대로 굳었다.
바뀐 것을 모아 보면 공통점이 보인다. 어텐션과 FFN이 번갈아 쌓이고 잔차가 그 사이를 관통하는 뼈대는 그대로이고, 손댄 곳은 정규화의 위치, 위치 정보를 넣는 방법, 키와 값을 공유하는 정도, 활성 함수다. 8년이 지나도록 뼈대가 버틴 것이 이 구조의 실적이다.
다음 글에서는 지금까지 이름만 부른 어텐션 계산을 질의·키·값 세 행렬에서부터 하나씩 따라간다.
읽어주셔서 감사합니다. 😊

