LLM·트랜스포머

LLM / 5번째 글

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

인코더 블록을 잔차와 정규화, 셀프 어텐션, 위치별 FFN으로 나눠 따라간다. Pre-Norm과 Post-Norm이 갈리는 지점, d_ff가 네 배인 이유, 층마다 표현이 어떻게 달라지는가, 그리고 인코더 전용 모델이 지금 남아 있는 자리.

PALDYN Team29 MIN READ

지난 글에서 토큰의 순서 정보를 사인과 코사인 값으로 주입하는 방법을 다뤘다. 그 정보를 담은 벡터가 가장 먼저 통과하는 구조가 인코더다. 인코더는 입력 시퀀스 전체를 한 번에 읽어 각 토큰에 「문맥이 반영된 표현」을 부여한다. 들어갈 때 「배」는 사전에 실린 여러 뜻을 뭉뚱그린 벡터이고, 나올 때는 그 문장에서의 뜻 하나로 좁혀진 벡터다.

이 글은 그 좁히는 일이 실제로 어떤 계산으로 이루어지는지, 그리고 그 계산을 감싸고 있는 잔차와 정규화가 왜 구조의 일부로 취급되는지를 따라간다. 인코더 블록을 설명할 때 어텐션과 FFN 둘만 꼽고 나머지를 부수적인 장치로 넘기는 설명이 많은데, 실제로는 감싸는 쪽을 어떻게 두느냐가 층을 몇 개까지 쌓을 수 있는지를 정한다. 먼저 그 자리부터 본다.

잔차와 정규화

Add & Norm

인코더 한 블록은 서브레이어 둘로 이루어지고, 각각을 같은 방식으로 감싼다.

output = LayerNorm(x + Sublayer(x))

잔차 연결은 서브레이어의 출력에 입력을 그대로 더하는 것이고, 레이어 정규화는 각 토큰 벡터의 원소들을 평균 0, 분산 1로 맞추는 것이다. 이 둘을 묶은 패턴을 Add & Norm이라 부른다.

정규화가 배치가 아니라 토큰 하나를 단위로 한다는 점이 중요하다. 이미지 모델에서 흔한 배치 정규화는 배치 안의 여러 표본을 가로질러 통계를 내므로 배치 크기에 결과가 흔들리고, 길이가 제각각인 문장을 채움 토큰과 함께 묶은 배치에서는 그 통계가 오염된다. 레이어 정규화는 토큰 하나의 d_model개 원소만 보고 계산하므로 배치 크기와도, 옆에 어떤 문장이 들어왔는지와도 무관하다. 추론에서 배치 1로 돌려도 학습 때와 똑같이 동작한다는 뜻이고, 이 성질이 없었으면 서빙 쪽 설계가 전혀 달라졌을 것이다.

정규화가 하는 일을 한 문장으로 적으면, 다음 계층에 들어가는 값의 크기를 일정한 범위로 맞춰 주는 것이다. 어텐션과 FFN을 거치며 벡터의 크기가 층마다 조금씩 커지거나 작아지는데, 이 표류를 그대로 두면 깊은 층에서 값이 폭주하거나 0에 붙는다. 정규화 뒤에는 학습으로 정하는 배율과 치우침이 하나씩 붙어 있어, 필요하면 모델이 스스로 크기를 되돌릴 수도 있다.

Transformer Encoder 블록 구조

잔차가 하는 일

잔차 연결이 없으면 학습 신호가 서브레이어를 전부 통과해야 입력 쪽에 닿는다. 층마다 1보다 작은 수가 곱해지면 열두 층만 지나도 신호가 거의 사라지고, 아래쪽 층은 사실상 학습되지 않는다.

더하기가 들어가면 경로가 둘이 된다. 하나는 서브레이어를 지나는 길이고 다른 하나는 아무 변환도 거치지 않고 그대로 지나가는 길이다. 미분에서 더하기는 신호를 양쪽으로 똑같이 흘려보내므로, 서브레이어 쪽이 아무리 약해져도 직통 경로로 온 신호는 그대로 남는다. 깊게 쌓을 수 있게 만든 것은 어텐션이 아니라 이 더하기이며, 같은 장치가 이미 잔차 신경망에서 검증된 뒤 그대로 들어온 것이다. 더하기의 또 다른 효과는 각 서브레이어가 하는 일의 성격을 바꾼다는 것이다. 잔차가 없으면 서브레이어는 표현을 통째로 새로 만들어야 하지만, 잔차가 있으면 이미 있는 표현에 더할 차이만 만들면 된다. 층을 아무것도 안 하는 상태로 두는 것이 「출력을 0으로 내보내기」로 쉽게 표현되므로, 필요 없는 층이 학습 중에 조용히 비켜 서는 일도 가능해진다. 깊이를 늘려도 성능이 나빠지지 않는다는 성질이 여기서 온다.

잔차가 성립하려면 서브레이어의 입력과 출력 모양이 같아야 한다. d_model이 처음부터 끝까지 바뀌지 않는 이유가 이 조건이다. 안에서 헤드로 쪼개지고 FFN에서 네 배로 부풀어도 서브레이어를 나올 때는 반드시 원래 폭으로 돌아온다.

Pre-Norm과 Post-Norm

원논문과 BERT는 더한 뒤에 정규화했다. 지금 나오는 모델은 대부분 순서를 뒤집어, 정규화를 먼저 하고 그 결과를 서브레이어에 넣은 뒤 원래 입력에 더한다.

구분 Post-Norm Pre-Norm
식 LayerNorm(x + Sub(x)) x + Sub(LayerNorm(x))
잔차 경로 층마다 정규화를 거침 입력에서 출력까지 뚫려 있음
warmup 없으면 자주 발산 의존이 크게 줄어듦
채택 원논문, BERT GPT-3, LLaMA 이후 대부분

차이는 잔차 경로가 깨끗한가에 있다. 그림으로 보면 더 분명하다. 왼쪽은 직통 경로 위에 정규화가 층마다 놓여 있고, 오른쪽은 그 경로가 입력에서 출력까지 아무것도 거치지 않고 뚫려 있다.

Pre-Norm과 Post-Norm의 잔차 경로 Post-Norm에서는 직통 경로도 층마다 정규화를 한 번씩 지나므로, 층이 깊어지면 그 경로마저 신호를 흐린다. 원논문의 여섯 층에서는 큰 차이가 아니었지만 수십 층으로 가면서 갈렸다. Pre-Norm을 쓰면 스택 끝에서 정규화를 한 번 더 걸어야 하는데, 마지막 층의 출력이 정규화를 거치지 않은 채 나오기 때문이다. 이 순서를 바꾸는 것이 코드에서는 한 줄 옮기는 일이라 가볍게 보이지만, 학습 안정성과 학습률 스케줄이 함께 따라 움직인다. Post-Norm으로 학습한 설정을 그대로 두고 Pre-Norm으로 바꾸면 학습률이 상대적으로 낮아 손실이 덜 떨어지고, 반대로 Pre-Norm 설정을 Post-Norm에 그대로 쓰면 초반에 발산한다. 논문 재현이 안 될 때 이 한 줄을 확인해 볼 만한 자리다.

인코더의 셀프 어텐션

Q와 K와 V가 같은 곳에서 나올 때

셀프 어텐션에서는 질의·키·값이 모두 같은 입력에서 만들어진다. 각 토큰이 자기 질의로 시퀀스 전체의 키와 맞춰 보고, 맞은 정도만큼 값을 가져와 자기 표현을 갱신한다. 「그 선수는 은메달을 땄다」에서 「그」의 질의가 「선수」의 키와 크게 맞으면 「그」의 표현에 「선수」의 값이 많이 섞인다. 질의·키·값은 같은 벡터에서 나오지만 서로 다른 행렬을 통과하므로 역할이 갈린다. 질의는 「내가 무엇을 찾는가」, 키는 「나는 무엇으로 불릴 수 있는가」, 값은 「내가 실제로 건네는 내용」에 해당한다. 셋을 하나로 두면 자기 자신과의 내적이 언제나 가장 커져 모든 토큰이 제자리만 보게 되는데, 행렬을 갈라 둔 덕분에 찾는 기준과 불리는 이름이 달라질 수 있다.

여기서 중요한 것은 각 토큰이 앞뒤를 모두 본다는 점이다. 디코더는 뒤를 가리지만 인코더는 가리지 않는다. 입력 문장 전체가 처음부터 주어져 있고 그것을 이해하는 것이 목적이므로 가릴 이유가 없다. 이 한 가지 차이가 인코더와 디코더를 가르는 거의 전부다. 양방향으로 본다는 성질은 학습 목표까지 끌고 간다. 앞뒤를 다 보는 모델에 「다음 토큰을 맞혀라」를 시키면 답이 이미 입력에 들어 있으므로 아무것도 배우지 않는다. 그래서 인코더 전용 모델은 입력의 일부를 가리고 그 자리를 맞히게 하는 방식으로 학습한다. 구조가 학습 목표를 정하고, 학습 목표가 다시 그 모델이 잘하는 일을 정하는 연쇄가 여기서 시작된다.

패딩 마스크

배치로 묶으려면 길이가 다른 문장을 같은 길이로 맞춰야 하고, 짧은 문장 뒤에 의미 없는 채움 토큰이 붙는다. 이 자리를 그냥 두면 실제 토큰들이 채움 토큰의 값을 가져가 표현이 흐려진다.

패딩 마스크는 채움 토큰이 있는 열의 점수를 음의 무한대로 만들어, 소프트맥스를 지난 뒤 그 자리 가중치가 0이 되게 한다. 인코더에서 필요한 마스크는 이것 하나다. 마스크를 만드는 자리는 토크나이저가 내놓는 길이 정보이고, 대부분의 라이브러리가 채움 여부를 알려 주는 배열을 함께 돌려주므로 직접 만들 일은 드물다. 디코더가 쓰는 인과 마스크는 아직 생성하지 않은 미래를 가리는 것인데, 인코더에는 생성이라는 개념 자체가 없으므로 그런 마스크를 걸 자리가 없다.

채움 토큰이 질의 쪽에 있는 경우도 생각해 둘 만하다. 그 행은 전부 가려져 소프트맥스의 분모가 0이 되고 NaN이 나온다. 실무에서는 그 행을 가리지 않고 두되 손실 계산에서 제외하는 방식으로 피한다. 라이브러리마다 마스크의 규약이 달라 섞어 쓸 때도 사고가 난다. 어떤 인자는 「가릴 곳이 참」을 받고 어떤 인자는 「볼 곳이 참」을 받으며, 실수 마스크를 받아 점수에 그대로 더하는 방식도 있다. 방향을 뒤집어 넣어도 예외가 나지 않고 손실만 높게 유지되므로, 새 코드베이스에서 마스크를 쓸 때는 작은 배치로 가중치를 직접 꺼내 0이 맞는 자리에 들어갔는지 한 번 확인하고 시작하는 편이 싸다.

위치별 FFN

토큰마다 따로 도는 2층 신경망

FFN은 선형 변환 둘 사이에 비선형 활성 함수를 끼운 구조다.

FFN(x) = max(0, x @ W1 + b1) @ W2 + b2

이름의 「위치별」은 토큰마다 완전히 독립적으로 적용된다는 뜻이다. 어떤 토큰의 FFN 계산도 다른 토큰의 값을 보지 않는다. 그래서 인코더 블록의 역할이 깔끔하게 둘로 갈린다 — 토큰 사이를 섞는 일은 어텐션이 전담하고, 섞인 결과를 각 자리에서 변환하는 일은 FFN이 전담한다. 병렬 처리가 쉬운 것도 이 독립성 덕분이다. 이 분업은 계산 특성까지 갈라 놓는다. 어텐션은 길이에 제곱으로 무거워지고 FFN은 길이에 비례해 무거워지므로, 짧은 입력을 대량으로 처리하는 서비스에서는 FFN이 시간의 대부분을 쓰고 긴 입력을 다루는 서비스에서는 어텐션이 그 자리를 가져간다. 최적화를 어디에 걸지 정할 때 먼저 재야 하는 것이 이 비율이다.

d_ff가 네 배인 이유

내부 차원 d_ff는 d_model의 네 배가 기본이다. 원논문은 512와 2048을 썼다. 이 비율에 이론적 근거는 없고, 실험에서 잘 나온 값이 관행으로 굳었다.

근거보다 중요한 것은 이 선택이 파라미터 배분을 정한다는 점이다. 어텐션 쪽 투영 행렬 넷은 합쳐서 d_model의 제곱의 4배이고, FFN의 두 행렬은 합쳐서 8배다. 결과적으로 블록 파라미터의 3분의 2가 FFN에 들어간다. 구조의 이름은 어텐션이 가져갔지만 무게의 다수는 FFN이 들고 있다. 활성 함수도 그 사이 바뀌었다. 원논문의 ReLU는 BERT와 GPT-2에서 GELU로, 최근 모델에서는 게이트를 하나 더 두는 형태로 옮겨 갔다. 게이트를 두면 행렬이 둘에서 셋으로 늘어나므로 파라미터 수를 맞추려고 d_ff를 네 배가 아니라 8/3배쯤으로 낮춰 잡는다. 요즘 모델 설정표에서 d_ff가 딱 떨어지지 않는 값인 것이 이 조정 때문이다. 활성 함수를 바꾸는 것은 성능 차이가 크지 않은 손질이지만, 같은 파라미터 예산에서 조금 더 나온다는 보고가 쌓이면 새 모델은 대개 그쪽을 따라간다.

지식 저장소라는 해석

FFN의 첫 행렬을 키로, 둘째 행렬을 값으로 보면 이 계층이 키-값 기억 장치처럼 동작한다는 해석이 있다. 특정 입력 패턴이 들어오면 첫 행렬의 특정 행이 크게 반응하고, 그 반응이 둘째 행렬의 대응하는 행을 출력에 섞는다는 것이다.

이 해석을 뒷받침하는 관찰이 여럿 있다. 사실 관계를 묻는 질문에서 특정 FFN 뉴런을 조작하면 답이 바뀌고, 모델이 아는 사실의 상당수가 중간 층 FFN에 몰려 있다는 보고도 있다. 다만 「사실 하나가 뉴런 하나에 들어 있다」는 그림은 지나친 단순화다. 하나의 뉴런이 여러 사실에 관여하고 하나의 사실이 여러 뉴런에 흩어져 있다는 쪽이 지금까지의 관찰에 더 맞는다. 그래도 이 해석이 쓸모 있는 이유는 모델을 키울 때 어디를 키워야 하는지에 대한 답을 주기 때문이다. 아는 것을 늘리려면 FFN을 키우고, 관계를 더 정교하게 보려면 어텐션 쪽을 손본다는 대략의 지침이 여기서 나온다. FFN만 여러 벌 두고 토큰마다 일부만 쓰는 전문가 혼합 구조도 이 방향을 극단까지 밀어붙인 것이다.

레이어 스택

N번 쌓기

블록 하나를 N번 반복해 쌓는다. 입력과 출력 모양이 같으므로 몇 개를 쌓든 코드가 달라지지 않는다.

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, n_heads, d_ff, dropout=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))
        x = self.norm2(x + self.drop(self.ff(x)))
        return x

class Encoder(nn.Module):
    def __init__(self, d_model, n_heads, d_ff, n_layers):
        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)

층마다 무엇이 담기는가

층마다 표현이 어떻게 달라지는지를 재는 방법이 프로빙이다. 각 층의 출력을 꺼내 그것만 입력으로 받는 작은 분류기를 따로 학습시키고, 그 분류기가 품사나 구문 관계 같은 특정 정보를 얼마나 맞히는지를 본다. 잘 맞히면 그 정보가 그 층의 표현에 선형적으로 담겨 있다고 본다.

BERT를 이렇게 재 본 연구들이 대체로 같은 그림을 보고한다.

깊이 잘 맞히는 것
얕은 층 품사, 표층 형태, 인접 관계
중간 층 구 구조, 문법적 의존 관계
깊은 층 의미역, 대명사 참조, 장거리 관계

Encoder 스택과 문맥 표현

프로빙 결과를 읽을 때 한 가지 주의할 점이 있다. 분류기가 맞힌다는 것은 정보가 있다는 뜻이지 모델이 그 정보를 실제로 쓴다는 뜻은 아니다. 분류기가 충분히 크면 거의 없는 정보도 짜낼 수 있어서, 대조 실험 없이 프로빙 점수만으로 결론을 내리면 과하게 읽기 쉽다. 무작위로 초기화한 모델에 같은 프로빙을 걸어 기준선을 만들어 두는 것이 최소한의 방어다. 학습하지 않은 모델의 표현에서도 품사 분류가 상당히 맞는 경우가 있는데, 그만큼은 모델이 배운 것이 아니라 분류기가 해낸 몫이다. 층별 구분이 늘 이렇게 깔끔한 것도 아니다. 모델 크기와 학습 데이터에 따라 경계가 위아래로 움직이고, 마지막 한두 층은 학습 목표에 맞춰 특화되면서 오히려 일반적인 정보를 덜 담는 경향이 관찰된다. 표는 대략의 방향이지 눈금이 아니다.

어느 층을 쓸 것인가

이 그림은 실무에서 곧바로 쓰인다. 문장을 벡터 하나로 요약할 때 마지막 층만 쓰는 것이 늘 최선은 아니다. 마지막 층은 학습 목표에 가장 가깝게 특화되어 있어, 분류에는 좋지만 문장 유사도에는 중간 층이나 마지막 몇 층의 평균이 더 나은 경우가 많다.

파인튜닝에서도 마찬가지다. 표층 정보만 필요한 작업이면 아래쪽 층을 얼리고 위쪽만 학습시켜 비용을 줄일 수 있고, 반대로 도메인 어휘 자체가 다른 작업이면 아래쪽 층부터 손봐야 한다. 어느 쪽인지 미리 알기 어려우므로, 층을 몇 개 얼렸을 때 성능이 어디서 꺾이는지를 실제로 재 보는 것이 가장 확실하다. 아래 절반을 얼려도 점수가 유지된다면 학습 시간과 메모리를 절반 가까이 줄일 수 있고, 그 여유를 배치나 에폭에 돌리는 편이 대개 낫다. 층 자체를 줄이는 선택지도 있다. 열두 층 모델에서 여섯 층만 남기고 증류하면 추론이 두 배 가까이 빨라지면서 분류 정확도는 몇 퍼센트 안쪽에서 유지되는 경우가 많고, 지연이 중요한 서비스에서는 그 교환이 자주 이득이다.

인코더 전용 모델의 자리

임베딩과 검색

생성 모델이 커진 뒤에도 인코더 전용 모델이 사라지지 않은 이유는 비용이다. 문서 수백만 건을 벡터로 바꿔 색인해 두는 일에는 파라미터가 몇억 개인 인코더로 충분하고, 같은 일을 대형 생성 모델에 시키면 단가가 몇 자릿수 달라진다. 양방향으로 문장 전체를 보고 한 번에 요약 벡터를 내놓는 구조가 이 작업의 모양과 잘 맞는다. 색인을 다시 만드는 비용까지 생각하면 차이는 더 벌어진다. 문서가 천만 건이고 모델을 바꿀 때마다 전부 다시 벡터로 만들어야 한다면, 한 건당 단가가 열 배 차이 나는 것이 그대로 프로젝트의 가능 여부를 가른다.

재순위와 분류

검색 결과 상위 몇십 건을 다시 정렬하는 재순위 모델도 인코더의 자리다. 질의와 문서를 한 시퀀스로 이어 붙여 넣고 점수 하나를 받는 방식이라 둘 사이의 관계를 어텐션이 직접 본다. 벡터 유사도만으로는 안 잡히는 차이가 여기서 걸러진다. 대신 후보마다 모델을 한 번씩 돌려야 하므로 전체 문서에 적용할 수는 없다. 벡터로 넓게 훑어 수십 건으로 좁히고 그 뒤에 재순위를 거는 두 단계 구성이 표준이 된 것은 이 비용 차이 때문이다.

감성 분류, 스팸 판정, 의도 분류처럼 라벨이 정해진 작업도 같다. 지연과 단가가 중요한 서비스에서 이런 작업에 생성 모델을 부르는 것은 대개 과한 선택이고, 데이터가 몇천 건만 있어도 인코더 파인튜닝이 더 빠르고 싸게 같은 정확도에 닿는다.

둘을 나눠 쓰기

실제 시스템에서는 인코더와 생성 모델을 함께 쓰는 배치가 흔하다. 검색 증강 생성 구조가 그 전형인데, 문서를 벡터로 만들어 후보를 좁히는 단계와 후보를 다시 정렬하는 단계는 인코더가 맡고, 고른 문서를 읽어 답을 쓰는 단계만 생성 모델이 맡는다. 앞 두 단계를 생성 모델에 맡기면 단가와 지연이 모두 몇 배가 되고 품질은 크게 나아지지 않는다.

나누는 기준은 단순하다. 출력이 정해진 집합에서 고르는 것이면 인코더 쪽이고, 출력이 자유로운 문장이면 생성 모델 쪽이다. 점수 하나, 라벨 하나, 벡터 하나를 내놓는 일은 전부 앞쪽에 속한다. 이 기준으로 나누면 시스템 전체의 비용 곡선이 완만해진다. 트래픽의 대부분을 차지하는 것은 대개 고르는 작업이고, 쓰는 작업은 그중 일부에만 필요하기 때문이다.

지금까지 본 것은 전부 입력을 이해하는 쪽이다. 다음 글에서는 같은 블록에 마스크와 크로스 어텐션을 더해 출력을 만들어 내는 디코더로 넘어간다.


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

LATEST

LLM·트랜스포머의 최신 글

LLM·트랜스포머2026.08.14

작은 모델을 우리 일에 맞추는 법

파인튜닝이 실제로 고치는 것은 지식이 아니라 행동입니다. 데이터 몇 건이 필요한지, LoRA가 무엇을 바꾸는지, 학습 전에 무엇을 먼저 만들어야 하는지를 정리합니다.

15 MIN
LLM·트랜스포머2026.08.13

작은 모델로 내려도 되는지 판단하는 법

비용·지연·품질은 같은 방향으로 움직이지 않습니다. 폴백을 붙였을 때의 손익분기, 격차가 벌어지는 작업 유형, 그리고 내리기 전에 통과해야 할 네 관문을 정리합니다.

10 MIN
LLM·트랜스포머2026.08.13

작은 모델이 다시 쓸 만해진 이유

10억에서 100억 파라미터 사이의 모델이 다시 실무에 들어오고 있습니다. 무엇이 달라졌는지, 메모리는 어떻게 계산하는지, 무엇을 잘하고 무엇을 못하는지 정리합니다.

10 MIN