Skip to main content
Summary

Attention만으로는 아직 Transformer가 아니다

이전 장에서 우리는 Self-Attention의 핵심을 배웠다. 각 토큰이 문장 안의 다른 토큰을 바라보고, 누구를 얼마나 중요하게 볼지 점수로 계산한 뒤, 그 정보를 섞어 자신의 표현을 갱신한다. 이 감각만 잡아도 Transformer의 절반은 이미 이해한 셈이다.

하지만 실제 Transformer는 Attention 하나만 덜렁 놓인 구조가 아니다. Attention은 중요한 부품이지만, 그것만으로는 깊고 안정적인 모델을 만들기 어렵다. 마치 자동차 엔진이 핵심 부품이라고 해서 엔진만 바닥에 놓고 달릴 수 없는 것과 같다. 바퀴, 변속기, 브레이크, 차체가 함께 있어야 비로소 자동차가 된다.

Transformer 블록도 마찬가지다. 하나의 블록 안에는 여러 부품이 정해진 순서로 배치된다. 입력 토큰의 임베딩은 먼저 위치 정보를 얻고, Self-Attention을 통과하며 주변 토큰과의 관계를 반영한다. 그다음 잔차 연결과 정규화를 거쳐 학습이 안정되며, 피드포워드 네트워크를 지나 각 토큰 표현이 한 번 더 깊게 변환된다.

대략적인 흐름은 다음과 같다.

토큰 임베딩 + 위치 정보
        ↓
Self-Attention
        ↓
잔차 연결 + LayerNorm
        ↓
Feed Forward Network
        ↓
잔차 연결 + LayerNorm
        ↓
다음 Transformer 블록으로 전달

처음 보면 부품이 많아 보이지만, 사실 각 부품의 역할은 꽤 명확하다. Attention은 토큰끼리 대화하게 만들고, 잔차 연결은 원래 정보를 잃지 않게 도와주며, LayerNorm은 값의 흐름을 안정시킨다. 피드포워드 네트워크는 각 토큰의 표현을 더 풍부하게 바꾼다.

이번 장의 목표는 Transformer 구조도를 보았을 때 더 이상 낯선 그림처럼 느껴지지 않게 만드는 것이다. 앞으로 LLM 구조를 볼 때 자주 등장하는 Attention, Add, Norm, MLP, Feed Forward 같은 단어들이 하나의 익숙한 흐름으로 연결될 것이다.

Transformer 블록의 기본 구조

순서를 모르는 모델에게 위치를 알려 주기

문장은 순서가 중요하다. 개가 사람을 물었다와 사람이 개를 물었다는 같은 단어를 사용하지만 의미가 완전히 다르다. 단어 목록만 보면 두 문장은 거의 같아 보인다. 하지만 순서가 바뀌면 사건의 주인공과 대상이 뒤집힌다.

RNN은 순서 데이터를 앞에서부터 차례로 읽었기 때문에 순서 정보가 자연스럽게 들어갔다. 첫 번째 단어를 읽고, 두 번째 단어를 읽고, 세 번째 단어를 읽는 방식이었기 때문이다. 반면 Transformer의 Self-Attention은 문장 안의 모든 토큰을 한꺼번에 바라본다. 이 덕분에 병렬 계산이 쉬워지고 먼 거리의 토큰도 직접 연결할 수 있지만, 한 가지 문제가 생긴다.

그 자체만으로는 몇 번째 토큰인지 모른다.

예를 들어 다음과 같은 세 토큰이 있다고 해 보자.

나는 / 밥을 / 먹었다

Self-Attention 입장에서는 각 토큰의 임베딩 벡터가 들어온다. 그런데 토큰 임베딩만 주어지면 나는이 첫 번째에 있는지, 마지막에 있는지 알 수 없다. 단어의 의미는 알지만 위치는 모르는 셈이다. 마치 단어 카드 세 장을 책상 위에 흩뿌려 놓고, 원래 문장 순서를 맞히라고 하는 상황과 비슷하다.

그래서 Transformer는 입력 임베딩에 위치 정보를 더한다. 이를 보통 위치 인코딩 또는 위치 임베딩이라고 부른다.

입력 표현 = 토큰 임베딩 + 위치 임베딩

토큰 임베딩은 이 토큰이 무엇인가를 담고, 위치 임베딩은 이 토큰이 어디에 있는가를 담는다. 두 벡터를 더하면 모델은 단어의 의미와 문장 속 위치를 함께 가진 표현을 얻게 된다.

간단히 생각해 보자. 고양이라는 토큰의 임베딩이 있다고 하자. 이 단어가 문장 첫 번째에 있을 때와 다섯 번째에 있을 때는 같은 단어이지만 역할이 달라질 수 있다. 위치 임베딩을 더하면 모델은 첫 번째 위치의 고양이와 다섯 번째 위치의 고양이를 조금 다르게 볼 수 있다.

PyTorch에서는 보통 토큰 임베딩과 위치 임베딩을 다음처럼 만든다.

import torch
import torch.nn as nn

vocab_size = 10000
max_len = 128
d_model = 64

token_embedding = nn.Embedding(vocab_size, d_model)
position_embedding = nn.Embedding(max_len, d_model)

input_ids = torch.tensor([[12, 35, 91, 7]])  # shape: (batch, seq_len)
batch_size, seq_len = input_ids.shape

positions = torch.arange(seq_len).unsqueeze(0)  # shape: (1, seq_len)

x = token_embedding(input_ids) + position_embedding(positions)

print(x.shape)  # torch.Size([1, 4, 64])

여기서 input_ids는 토큰을 정수 번호로 바꾼 결과다. token_embedding(input_ids)는 각 토큰 번호를 벡터로 바꾼다. position_embedding(positions)는 0번 위치, 1번 위치, 2번 위치, 3번 위치에 해당하는 벡터를 만든다. 두 벡터의 크기는 모두 d_model로 같기 때문에 서로 더할 수 있다.

결과적으로 x의 shape은 (batch, seq_len, d_model)이 된다. 이 모양은 Transformer를 읽을 때 아주 중요하다.

batch    : 한 번에 처리하는 문장 수
seq_len  : 문장 안의 토큰 수
d_model  : 각 토큰을 표현하는 벡터 크기

Transformer 블록은 대부분 이 모양의 텐서를 입력으로 받고, 같은 모양의 텐서를 출력한다. 이것이 블록을 여러 개 쌓을 수 있는 중요한 이유다.

Self-Attention은 토큰 사이의 관계를 섞는다

위치 정보까지 더해진 입력 x가 준비되면, 이제 Self-Attention 층으로 들어간다. 이 층에서 각 토큰은 문장 안의 다른 토큰을 바라본다.

다시 다음 문장을 떠올려 보자.

그 영화는 지루했지만 음악은 훌륭했다

훌륭했다라는 토큰을 이해하려면 무엇을 봐야 할까? 아마 음악은을 강하게 봐야 한다. 지루했지만도 문장의 분위기를 이해하는 데 도움이 된다. 반면 그는 상대적으로 덜 중요할 수 있다.

Self-Attention은 이런 관계를 숫자로 계산한다. 각 토큰은 Query, Key, Value라는 세 종류의 벡터로 변환된다. 이름이 조금 낯설지만 역할은 이렇게 이해하면 된다.

  • Query는 내가 지금 찾고 싶은 정보다.

  • Key는 내가 어떤 정보를 가지고 있는지 알려 주는 이름표다.

  • Value는 실제로 전달할 내용이다.

각 토큰의 Query가 다른 토큰들의 Key와 얼마나 잘 맞는지 계산하면 Attention 점수가 나온다. 이 점수를 확률처럼 바꾼 뒤, Value들을 가중합하면 새로운 토큰 표현이 만들어진다.

Attention(Q, K, V) = softmax(QKᵀ / √d_k) V

수식이 등장했지만 겁먹을 필요는 없다. 핵심은 간단하다. QKᵀ는 토큰끼리 얼마나 관련 있는지 점수를 계산한다. softmax는 그 점수를 합이 1이 되는 비율로 바꾼다. 마지막으로 그 비율만큼 V를 섞어 새 표현을 만든다.

결과적으로 각 토큰은 자기 혼자만의 벡터가 아니라, 문장 전체의 정보를 반영한 벡터가 된다.

처음의 토큰 표현:
나는     → 나는 자체의 정보
밥을     → 밥을 자체의 정보
먹었다   → 먹었다 자체의 정보

Self-Attention 이후:
나는     → 나는 + 밥을 + 먹었다와의 관계
밥을     → 밥을 + 나는 + 먹었다와의 관계
먹었다   → 먹었다 + 나는 + 밥을과의 관계

이것이 Transformer가 문맥을 다루는 핵심 방식이다. 고정된 단어 벡터 하나로 끝나는 것이 아니라, 같은 단어라도 주변 토큰에 따라 표현이 바뀐다. 차가 막힌다의 차와 차를 마신다의 차는 같은 글자지만, Self-Attention을 통과하면서 서로 다른 문맥적 표현을 갖게 된다.

LLM이 문장을 이해하는 것처럼 보이는 이유도 여기서 출발한다. 각 토큰이 주변 토큰을 보고, 그 관계를 반영해 자신의 상태를 계속 업데이트하기 때문이다.

Multi-Head Attention: 여러 관점으로 동시에 보기

실제 Transformer에서는 Attention을 한 번만 계산하지 않는다. 여러 개의 Attention을 병렬로 계산한다. 이를 Multi-Head Attention이라고 부른다.

왜 굳이 여러 개가 필요할까? 문장에는 한 가지 관계만 있는 것이 아니기 때문이다. 어떤 토큰은 주어와 동사의 관계를 봐야 하고, 어떤 토큰은 수식어와 명사의 관계를 봐야 하며, 어떤 토큰은 문장 앞뒤의 긴 연결을 봐야 한다.

사람도 글을 읽을 때 한 가지 기준만 사용하지 않는다. 우리는 동시에 여러 가지를 본다. 누가 행동했는지, 무엇을 했는지, 감정은 어떤지, 앞 문장과 어떻게 이어지는지 살핀다. Multi-Head Attention은 이 여러 관점을 나누어 학습하게 해 준다.

예를 들어 4개의 head가 있다면 각 head는 같은 입력을 보지만 서로 다른 가중치를 사용한다. 그래서 한 head는 가까운 단어 관계에 민감해지고, 다른 head는 문장 전체의 주제에 민감해질 수 있다. 물론 실제로 어떤 head가 무엇을 학습할지는 사람이 미리 정하지 않는다. 학습 과정에서 손실을 줄이는 방향으로 각 head의 역할이 자연스럽게 형성된다.

shape 관점에서 보면 Multi-Head Attention은 다음과 같은 흐름을 갖는다.

입력 x: (batch, seq_len, d_model)
        ↓
Q, K, V 생성: (batch, seq_len, d_model)
        ↓
head별로 나누기: (batch, num_heads, seq_len, head_dim)
        ↓
Attention 계산
        ↓
head 결과 합치기: (batch, seq_len, d_model)

여기서 head_dim은 보통 d_model / num_heads다. 예를 들어 d_model=64, num_heads=4라면 head 하나가 담당하는 차원은 16이다.

중요한 점은 출력 shape이 다시 (batch, seq_len, d_model)로 돌아온다는 것이다. Transformer 블록은 입력과 출력의 모양을 유지한다. 그래서 블록을 하나 통과한 결과를 다음 블록에 그대로 넣을 수 있다.

TransformerBlock → TransformerBlock → TransformerBlock → ...

이렇게 같은 구조를 깊게 쌓으면 토큰 표현은 점점 더 풍부해진다. 앞쪽 블록은 비교적 가까운 패턴이나 단순한 관계를 잡고, 뒤쪽 블록은 더 복잡하고 추상적인 문맥을 다룰 수 있다.

잔차 연결: 원래 정보를 잃지 않는 지름길

이제 Transformer 블록의 다음 부품을 보자. Self-Attention 결과가 나오면 Transformer는 그것을 그대로 다음 층으로 넘기지 않는다. 원래 입력을 다시 더한다. 이것을 잔차 연결 또는 residual connection이라고 부른다.

출력 = 입력 + Attention(입력)

왜 굳이 원래 입력을 다시 더할까?

깊은 신경망에서는 층을 많이 통과할수록 정보와 기울기가 약해질 수 있다. 앞에서 우리는 역전파를 배웠다. 모델이 학습하려면 손실에서 출발한 기울기가 각 층의 가중치까지 잘 전달되어야 한다. 그런데 층이 깊어질수록 기울기가 중간에서 작아지거나 흐려질 수 있다. 그러면 앞쪽 층은 무엇을 고쳐야 할지 신호를 제대로 받지 못한다.

잔차 연결은 이 문제를 줄이는 지름길이다. 어떤 층이 입력을 복잡하게 변환하더라도, 원래 입력이 더해져 다음으로 전달된다. 그래서 모델은 매번 완전히 새로운 표현을 만들어야 하는 부담을 덜 수 있다.

이 구조를 이렇게 생각해도 좋다.

새 표현 = 원래 표현 + 이번 층이 추가로 배운 변화량

즉, 각 층은 전체를 처음부터 다시 만들 필요가 없다. 현재 표현에서 무엇을 조금 더 고치면 좋은지만 배우면 된다. 이것은 우리가 글을 고칠 때와 비슷하다. 초안을 버리고 매번 새로 쓰는 것보다, 기존 문장 위에 필요한 수정을 더하는 편이 훨씬 안정적이다.

잔차 연결은 Transformer가 많은 블록을 깊게 쌓을 수 있게 해 주는 핵심 장치다. LLM처럼 매우 큰 모델에서는 수십 개, 때로는 그 이상의 Transformer 블록이 이어진다. 이런 깊은 구조에서 잔차 연결이 없다면 학습은 훨씬 불안정해진다.

LayerNorm: 값의 흐름을 차분하게 만들기

잔차 연결 다음에는 정규화가 자주 등장한다. Transformer에서 많이 쓰이는 정규화는 LayerNorm이다. BatchNorm과 이름이 비슷하지만 기준이 다르다.

BatchNorm은 배치 안의 여러 샘플을 기준으로 값의 분포를 맞춘다. 반면 LayerNorm은 각 샘플 안에서, 보통 마지막 차원인 특징 차원을 기준으로 값을 정리한다.

Transformer 입력의 shape을 다시 보자.

x: (batch, seq_len, d_model)

LayerNorm은 각 토큰의 d_model 차원 값을 기준으로 평균과 분산을 계산해 값을 안정화한다. 쉽게 말해, 한 토큰의 벡터 안에서 값들이 너무 한쪽으로 치우치지 않도록 정돈하는 역할을 한다.

왜 이런 정돈이 필요할까? 깊은 네트워크에서는 층을 지날수록 값의 크기가 커지거나 작아질 수 있다. 어떤 층에서는 값이 지나치게 커져 학습이 흔들리고, 어떤 층에서는 값이 너무 작아져 신호가 약해질 수 있다. LayerNorm은 각 층을 통과하는 값의 흐름을 좀 더 일정하게 만들어 준다.

비유하자면, 여러 사람이 릴레이로 물통을 전달하는 상황과 비슷하다. 어떤 사람은 물통을 너무 세게 던지고, 어떤 사람은 너무 약하게 넘기면 다음 사람이 받기 어렵다. LayerNorm은 매 단계에서 물통을 잡기 좋은 상태로 다시 정리해 주는 역할을 한다.

Transformer 블록 안에서는 보통 다음과 같은 형태가 쓰인다.

x = LayerNorm(x + Attention(x))
x = LayerNorm(x + FeedForward(x))

또 다른 변형으로는 LayerNorm을 Attention이나 Feed Forward 앞에 두는 방식도 있다.

x = x + Attention(LayerNorm(x))
x = x + FeedForward(LayerNorm(x))

앞의 형태를 Post-LN, 뒤의 형태를 Pre-LN이라고 부르기도 한다. 지금 당장 이름을 외울 필요는 없다. 중요한 것은 LayerNorm이 깊은 Transformer에서 값과 기울기의 흐름을 안정적으로 만드는 장치라는 점이다.

피드포워드 네트워크: 각 토큰을 더 깊게 변환하기

Self-Attention은 토큰 사이의 관계를 섞는 역할을 한다. 그렇다면 피드포워드 네트워크는 무엇을 할까?

Transformer 블록의 Feed Forward Network, 줄여서 FFN은 각 토큰의 벡터를 독립적으로 변환한다. 즉, 토큰끼리 정보를 섞는 일은 Attention이 담당하고, 각 토큰 표현 자체를 더 풍부하게 가공하는 일은 FFN이 담당한다.

구조는 생각보다 단순하다. 보통 두 개의 선형 층과 하나의 활성화 함수로 이루어진다.

FFN(x) = Linear → Activation → Linear

많은 경우 중간 차원을 크게 늘렸다가 다시 줄인다.

(batch, seq_len, d_model)
        ↓ Linear
(batch, seq_len, d_ff)
        ↓ Activation
(batch, seq_len, d_ff)
        ↓ Linear
(batch, seq_len, d_model)

예를 들어 d_model=64라면 중간 차원 d_ff를 256으로 둘 수 있다. 이렇게 잠시 더 넓은 공간으로 펼치면 모델은 각 토큰 표현을 더 다양한 방식으로 조합할 수 있다. 그다음 다시 d_model 크기로 줄여 다음 Transformer 블록이 받을 수 있는 모양으로 만든다.

여기서 중요한 점은 FFN이 모든 토큰에 같은 방식으로 적용된다는 것이다. 하지만 각 토큰의 입력 벡터가 다르기 때문에 출력도 달라진다.

나는     → FFN → 더 가공된 나는 표현
밥을     → FFN → 더 가공된 밥을 표현
먹었다   → FFN → 더 가공된 먹었다 표현

Attention이 회의 시간이라면 FFN은 각자 자리로 돌아가 생각을 정리하는 시간이다. Attention 단계에서 토큰들은 서로 정보를 주고받는다. 그다음 FFN 단계에서 각 토큰은 방금 얻은 정보를 바탕으로 자신의 표현을 한 번 더 다듬는다.

이 조합이 Transformer 블록의 힘이다.

Attention: 토큰 사이의 관계를 반영한다.
FFN      : 각 토큰 표현을 비선형적으로 가공한다.
Residual : 원래 정보를 보존하고 학습을 쉽게 만든다.
LayerNorm: 값의 흐름을 안정화한다.

하나의 Transformer 블록을 눈으로 따라가기

이제 전체 흐름을 하나로 묶어 보자. 입력 x는 토큰 임베딩과 위치 임베딩이 더해진 텐서다.

x.shape = (batch, seq_len, d_model)

이 x가 Transformer 블록 안으로 들어가면 다음 일이 벌어진다.

먼저 Self-Attention이 실행된다. 각 토큰은 문장 안의 다른 토큰을 바라보고, 관계를 반영한 새 표현을 만든다. 이 결과는 원래 입력 x와 더해진다. 그다음 LayerNorm으로 값의 흐름을 정리한다.

attn_out = SelfAttention(x)
x = LayerNorm(x + attn_out)

그다음 FFN이 실행된다. 각 토큰 표현은 선형 층, 활성화 함수, 선형 층을 통과하며 비선형적으로 변환된다. 이 결과도 다시 원래 입력과 더해지고 LayerNorm을 거친다.

ffn_out = FeedForward(x)
x = LayerNorm(x + ffn_out)

마지막으로 나온 x는 다음 Transformer 블록으로 전달된다. shape은 여전히 같다.

입력 : (batch, seq_len, d_model)
출력 : (batch, seq_len, d_model)

이 점이 아주 중요하다. Transformer 블록은 입력과 출력의 모양을 유지하기 때문에 레고 블록처럼 여러 개를 쌓을 수 있다.

x → Block 1 → Block 2 → Block 3 → ... → Block N

블록을 하나 지날 때마다 각 토큰의 표현은 더 많은 문맥을 품게 된다. 처음에는 단어의 의미와 위치 정도를 담고 있던 벡터가, 여러 블록을 지나며 문장 구조, 의미 관계, 긴 문맥의 단서까지 반영한 표현으로 바뀐다.

LLM에서 말하는 깊은 이해는 갑자기 생기는 마법이 아니다. 같은 구조의 Transformer 블록을 여러 번 통과하며 표현을 계속 갱신하는 과정에서 만들어진다.

PyTorch로 Transformer 블록 맛보기

이제 아주 작은 Transformer 블록을 PyTorch로 만들어 보자. 여기서는 학습 가능한 완성 모델을 만드는 것보다 구조를 눈에 익히는 것이 목표다.

PyTorch에는 nn.MultiheadAttention이 이미 준비되어 있다. 다만 기본 입력 shape이 설정에 따라 다를 수 있으므로, 여기서는 batch_first=True를 사용해 우리가 익숙한 (batch, seq_len, d_model) 형태로 다루겠다.

import torch
import torch.nn as nn

class SimpleTransformerBlock(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
        self.attention = nn.MultiheadAttention(
            embed_dim=d_model,
            num_heads=num_heads,
            dropout=dropout,
            batch_first=True
        )

        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)

        self.ffn = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.ReLU(),
            nn.Linear(d_ff, d_model)
        )

        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        attn_out, attn_weights = self.attention(x, x, x)
        x = self.norm1(x + self.dropout(attn_out))

        ffn_out = self.ffn(x)
        x = self.norm2(x + self.dropout(ffn_out))

        return x, attn_weights

코드를 천천히 보자. self.attention(x, x, x)에서 같은 x가 세 번 들어간다. 이것이 Self-Attention이다. Query, Key, Value가 모두 같은 입력에서 만들어지기 때문이다. 물론 내부적으로는 서로 다른 가중치 행렬을 통해 Q, K, V가 생성된다.

attn_out은 Attention을 통과한 결과다. 그다음 x + attn_out으로 잔차 연결을 만든다. 여기에 LayerNorm을 적용해 첫 번째 하위층이 끝난다.

이후 self.ffn(x)로 피드포워드 네트워크를 통과한다. 다시 x + ffn_out을 더하고 LayerNorm을 적용한다. 이렇게 해서 하나의 Transformer 블록이 완성된다.

실제로 입력을 넣어 shape을 확인해 보자.

d_model = 64
num_heads = 4
d_ff = 256

block = SimpleTransformerBlock(d_model, num_heads, d_ff)

x = torch.randn(2, 10, d_model)  # batch=2, seq_len=10, d_model=64
out, attn_weights = block(x)

print(out.shape)
print(attn_weights.shape)

출력은 다음과 비슷하다.

torch.Size([2, 10, 64])
torch.Size([2, 10, 10])

out.shape이 입력과 같은 (2, 10, 64)라는 점에 주목하자. 그래서 이 블록 뒤에 또 다른 Transformer 블록을 붙일 수 있다.

attn_weights.shape은 (2, 10, 10)이다. 배치 안의 각 문장마다 10 × 10 크기의 Attention 가중치가 있다는 뜻이다. 행은 현재 토큰, 열은 바라보는 토큰이라고 생각하면 된다. 즉, 각 토큰이 다른 토큰을 얼마나 보는지가 담긴 표다.

          토큰1  토큰2  토큰3  ... 토큰10
토큰1      0.10  0.05  0.20      0.03
토큰2      0.02  0.30  0.10      0.08
토큰3      0.15  0.10  0.05      0.12
...

이 표가 바로 문장 안의 관계를 수치로 표현한 것이다.

위치 임베딩까지 포함한 작은 모델

이번에는 토큰 임베딩과 위치 임베딩까지 포함해 보자. 그러면 Transformer 블록이 실제 텍스트 모델 안에서 어떻게 사용되는지 조금 더 선명해진다.

class TinyTransformerClassifier(nn.Module):
    def __init__(self, vocab_size, max_len, d_model, num_heads, d_ff, num_classes):
        super().__init__()
        self.token_embedding = nn.Embedding(vocab_size, d_model)
        self.position_embedding = nn.Embedding(max_len, d_model)
        self.block = SimpleTransformerBlock(d_model, num_heads, d_ff)
        self.classifier = nn.Linear(d_model, num_classes)

    def forward(self, input_ids):
        batch_size, seq_len = input_ids.shape
        positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0)

        x = self.token_embedding(input_ids) + self.position_embedding(positions)
        x, attn_weights = self.block(x)

        pooled = x[:, 0, :]
        logits = self.classifier(pooled)

        return logits, attn_weights

이 모델은 매우 단순한 텍스트 분류기다. 입력으로 토큰 ID를 받고, 토큰 임베딩과 위치 임베딩을 더한 뒤 Transformer 블록을 통과시킨다. 마지막에는 첫 번째 토큰의 표현을 사용해 분류 결과를 만든다.

실제로 실행해 보자.

vocab_size = 10000
max_len = 32
d_model = 64
num_heads = 4
d_ff = 256
num_classes = 2

model = TinyTransformerClassifier(
    vocab_size=vocab_size,
    max_len=max_len,
    d_model=d_model,
    num_heads=num_heads,
    d_ff=d_ff,
    num_classes=num_classes
)

input_ids = torch.randint(0, vocab_size, (4, 12))
logits, attn_weights = model(input_ids)

print(logits.shape)
print(attn_weights.shape)

출력은 다음과 비슷하다.

torch.Size([4, 2])
torch.Size([4, 12, 12])

logits.shape이 (4, 2)라는 것은 배치에 4개의 문장이 있고, 각 문장마다 2개 클래스에 대한 점수가 나온다는 뜻이다. attn_weights.shape이 (4, 12, 12)라는 것은 각 문장에서 12개 토큰이 서로를 바라보는 Attention 표가 만들어졌다는 뜻이다.

물론 이 모델은 실제 LLM에 비하면 장난감처럼 작다. 하지만 핵심 뼈대는 이미 들어 있다. 토큰 임베딩, 위치 임베딩, Self-Attention, 잔차 연결, LayerNorm, 피드포워드 네트워크가 모두 등장한다.

LLM은 이 블록을 훨씬 크게 만들고, 훨씬 많이 쌓고, 엄청난 양의 텍스트로 학습한 모델이라고 볼 수 있다.

Transformer 블록 안에서 정보는 어떻게 깊어진다

Transformer 블록을 한 번 통과하면 각 토큰은 주변 토큰을 반영한 표현이 된다. 그렇다면 블록을 여러 개 쌓으면 어떤 일이 생길까?

첫 번째 블록에서는 비교적 직접적인 관계가 잡힐 수 있다. 예를 들어 형용사가 어떤 명사를 꾸미는지, 동사가 어떤 주어와 연결되는지 같은 관계다. 두 번째, 세 번째 블록을 지나면 앞 블록에서 만든 표현을 다시 Attention의 재료로 사용한다. 그러면 모델은 단어 대 단어 관계를 넘어 구, 절, 문장 전체 분위기 같은 더 복잡한 패턴을 다룰 수 있게 된다.

이것은 표현 학습에서 배운 흐름과 연결된다. 초반 장에서 우리는 층을 쌓는다는 것이 단순한 특징에서 더 추상적인 표현으로 나아가는 과정이라고 했다. 이미지 모델에서는 선, 모서리, 질감이 점점 물체의 일부와 전체 형태로 이어졌다. Transformer에서도 비슷한 일이 일어난다. 다만 대상이 픽셀이 아니라 토큰이고, 핵심 연산이 합성곱이 아니라 Attention이라는 점이 다르다.

문장 하나를 읽는다고 해 보자.

어제 본 영화는 길었지만 마지막 장면 때문에 오래 기억에 남았다

처음에는 각 토큰이 자기 의미와 위치를 가진다. Transformer 블록을 지나며 길었지만은 앞부분의 평가와 뒤의 반전을 연결하고, 마지막 장면은 기억에 남았다와 강하게 이어질 수 있다. 여러 블록을 거치면 모델은 단순히 단어를 보는 것이 아니라 문장의 흐름과 감정의 전환을 표현 안에 담게 된다.

이 표현이 다음 단어 예측, 문장 분류, 번역, 요약 같은 작업의 기반이 된다.

인코더 블록과 디코더 블록의 차이 살짝 보기

Transformer라는 말을 들으면 구조가 하나만 있는 것처럼 느껴질 수 있지만, 실제로는 사용 목적에 따라 조금씩 다르다. 대표적으로 인코더 블록과 디코더 블록이 있다.

인코더는 입력 문장을 읽고 풍부한 표현으로 바꾸는 역할을 한다. 문장 분류나 문장 이해 모델에서 자주 등장한다. 인코더의 Self-Attention은 보통 문장 안의 모든 토큰을 서로 볼 수 있게 한다. 첫 번째 토큰도 뒤의 토큰을 볼 수 있고, 마지막 토큰도 앞의 토큰을 볼 수 있다.

디코더는 다음 토큰을 생성하는 역할을 한다. LLM에서 특히 중요하다. 다음 단어를 예측할 때 미래의 정답 토큰을 미리 보면 안 된다. 예를 들어 나는 오늘 아침에 다음 단어를 예측해야 하는데, 이미 뒤에 있는 커피를을 볼 수 있다면 문제를 훔쳐보는 셈이다.

그래서 디코더의 Self-Attention에는 마스크가 들어간다. 이를 causal mask라고 부르기도 한다. 현재 위치의 토큰은 자기 자신과 이전 토큰만 볼 수 있고, 미래 토큰은 볼 수 없다.

디코더 Attention에서 허용되는 시선:
토큰1 → 토큰1
토큰2 → 토큰1, 토큰2
토큰3 → 토큰1, 토큰2, 토큰3
토큰4 → 토큰1, 토큰2, 토큰3, 토큰4

이 차이는 다음 장에서 LLM을 이해할 때 매우 중요해진다. 많은 LLM은 기본적으로 디코더 Transformer를 쌓아 만든다. 그리고 각 위치에서 다음 토큰을 맞히도록 학습한다.

이번 장에서는 우선 기본 블록의 부품에 집중하면 충분하다. 위치 정보, Self-Attention, 잔차 연결, LayerNorm, FFN. 이 다섯 가지를 이해하면 Transformer 구조의 큰 그림은 이미 손에 들어온 것이다.

구조도를 읽는 눈

이제 Transformer 구조도를 다시 떠올려 보자. 처음에는 화살표와 박스가 복잡하게 얽힌 그림처럼 보였을 것이다. 하지만 이제 각 박스의 역할을 읽을 수 있다.

Input Embedding은 토큰을 벡터로 바꾼다. Positional Embedding은 순서 정보를 더한다. Multi-Head Attention은 여러 관점에서 토큰 사이의 관계를 계산한다. Add & Norm은 잔차 연결과 LayerNorm으로 학습을 안정화한다. Feed Forward는 각 토큰 표현을 더 깊게 변환한다.

Transformer 블록은 결국 다음 문장으로 요약할 수 있다.

각 토큰이 문장 안의 다른 토큰을 보고,
그 정보를 바탕으로 자기 표현을 고친 뒤,
안정적으로 다음 층으로 넘기는 구조다.

여기에는 우리가 이 책에서 차근차근 배운 거의 모든 개념이 들어 있다. 임베딩은 토큰을 숫자 벡터로 바꾸고, 선형 층은 가중치와 편향으로 표현을 변환한다. 활성화 함수는 비선형성을 만들고, 손실 함수와 역전파는 이 모든 가중치를 학습시킨다. 최적화 알고리즘은 손실을 줄이는 방향으로 파라미터를 조금씩 조정한다.

Transformer는 딥러닝과 별개의 마법이 아니다. 지금까지 배운 부품들이 아주 영리하게 조립된 구조다.

다음 장에서는 이 Transformer 블록이 어떻게 LLM으로 확장되는지 살펴본다. 거대한 언어 모델은 어떤 데이터를 보고, 어떤 목표로 학습하며, 어떻게 다음 토큰을 예측하는 능력에서 대화와 추론처럼 보이는 행동으로 이어질까. 이제 우리는 그 질문을 이해할 준비가 되어 있다.

책 목록

처음 만나는 딥러닝: 신경망에서 Transformer와 LLM까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기