지난 글에서 깊은 신경망의 기울기 소실과 폭발, 그리고 ReLU·잔차 연결 같은 해결책을 살펴봤다. 그것이 층을 따라 깊어질 때의 문제였다면, 텍스트·음성·시계열처럼 순서가 중요한 데이터는 시간을 따라 이어지는 다른 접근을 요구한다. 그 해답이 순환 신경망(Recurrent Neural Network, RNN)이다. 이 글은 RNN이 무엇을 어떻게 계산하는지를 식과 텐서 모양으로 따라가고, 그 과정에서 지난 글의 기울기 문제가 어떤 모습으로 되돌아오는지까지 본다.
시퀀스 데이터
MLP의 한계
일반 MLP는 입력 벡터 하나를 받아 출력을 내놓는다. 문장을 넣으려면 먼저 그것을 고정 길이 벡터 하나로 바꿔야 하는데, 여기서 두 갈래 모두 막힌다.
단어 벡터를 순서대로 이어 붙이는 방법을 생각해 보자. "나는 오늘 사과를 먹었다"의 네 단어를 128차원씩 이어 512차원 입력을 만든다. 문제는 첫 128차원을 처리하는 가중치와 세 번째 128차원을 처리하는 가중치가 서로 다르다는 것이다. 모델이 "사과"라는 단어를 이해하려면 첫 자리에 올 때, 세 번째 자리에 올 때를 각각 따로 배워야 한다. 같은 것을 자리 수만큼 되풀이해 배우는 셈이다.
단어 벡터의 평균을 내는 방법은 반대쪽으로 실패한다. 순서가 통째로 사라져 "고양이가 쥐를 쫓았다"와 "쥐가 고양이를 쫓았다"가 완전히 같은 입력이 된다. 순서가 뜻을 정하는 데이터에서 순서를 지우는 전처리는 답이 될 수 없다.
가변 길이
길이 문제도 있다. MLP의 입력 차원은 모델을 만들 때 고정되는데 문장 길이는 제각각이다. 가장 긴 문장에 맞춰 차원을 잡으면 짧은 문장에서는 입력의 대부분이 빈 자리가 되고, 평균 길이에 맞추면 긴 문장은 뒤가 잘려 나간다. 어느 쪽을 골라도 데이터가 손상된다.
여기서 잘라 내는 쪽을 택하면 손실이 눈에 안 보인다는 점이 특히 나쁘다. 잘린 뒤쪽에 결론이 들어 있었는지는 데이터를 열어 보기 전에는 알 수 없고, 모델은 잘린 입력을 온전한 입력으로 알고 학습한다. 성능이 안 나올 때 그 원인이 모델이 아니라 전처리에 있다는 것을 알아채기가 어렵다.
길이 문제와 앞의 순서 문제는 뿌리가 같다. 둘 다 "입력 전체를 한 번에 본다"는 전제에서 나온다. 입력을 한꺼번에 놓고 보는 대신 한 조각씩 차례로 읽으면서 읽은 것을 기억해 두는 구조라면 둘 다 자연스럽게 풀린다.
파라미터 공유
RNN이 하는 일이 정확히 그것이다. 타임스텝마다 입력 한 조각을 받아 지금까지의 요약을 갱신하고, 그 갱신에 쓰는 가중치를 모든 타임스텝이 공유한다. 공유가 두 가지를 동시에 해결한다.
첫째, 파라미터 수가 시퀀스 길이와 무관해진다. 10토큰을 읽든 1,000토큰을 읽든 같은 행렬을 되풀이해 쓰므로 모델 크기가 그대로다. 그래서 학습 때 본 적 없는 길이의 입력도 처리할 수 있다. 둘째, 세 번째 자리의 "사과"와 일곱 번째 자리의 "사과"를 같은 가중치가 처리하므로 자리마다 따로 배울 필요가 없다. 합성곱 신경망이 같은 필터를 이미지의 모든 위치에 미끄러뜨려 공간에서 하는 일을, RNN은 시간 축에서 한다.
RNN 셀
은닉 상태 갱신식
RNN의 계산은 두 줄이다.
가 은닉 상태다. 지금까지 읽은 것을 요약한 벡터이고, 타임스텝마다 새로 계산된다. ··는 학습으로 정해지는 파라미터이고 모든 타임스텝이 같은 값을 쓴다. 둘의 관계가 이 구조의 전부다 — 은닉 상태는 입력에 따라 스텝마다 달라지는 값이고, 가중치는 학습이 끝나면 고정되어 그 갱신을 어떻게 할지 정하는 규칙이다.
식을 말로 풀면 "이전 요약을 한 번 변환하고, 새 입력을 한 번 변환하고, 둘을 더한 뒤 눌러서 새 요약으로 삼는다"이다. 마지막의 가 누르는 일을 한다. 출력을 과 사이로 제한하므로 상태가 스텝을 거치며 무한정 커지는 것을 막는다. 은닉 상태를 흔히 '기억'이라 부르지만 이 말은 조심해서 써야 한다 — 새 값이 이전 값을 덮어쓰는 구조라 따로 보관되는 저장소가 없고, 스텝이 지날수록 오래된 것이 옅어진다.
텐서 모양
코드를 읽을 때 헷갈리는 자리가 텐서 모양이다. batch_first=True인 PyTorch RNN에서 입력은 이고 은닉 상태는 다. 는 을 로 옮기고 는 를 로 옮긴다. 가 정사각 행렬인 것이 중요한데, 같은 크기로 되돌려야 다음 스텝에 다시 넣을 수 있기 때문이다. 뒤에서 볼 기울기 문제도 이 정사각 행렬을 거듭 곱하는 데서 나온다.
import torch
import torch.nn as nn
# 간단한 RNN 단일 셀 직접 구현
class RNNCell(nn.Module):
def __init__(self, input_size, hidden_size):
super().__init__()
self.W_x = nn.Linear(input_size, hidden_size, bias=False)
self.W_h = nn.Linear(hidden_size, hidden_size)
self.tanh = nn.Tanh()
def forward(self, x, h_prev):
return self.tanh(self.W_x(x) + self.W_h(h_prev))
# PyTorch 내장 RNN
rnn = nn.RNN(input_size=128, hidden_size=256,
num_layers=2, batch_first=True)
# 입력: (batch, seq_len, input_size)
x = torch.randn(32, 20, 128) # batch=32, T=20, d=128
out, h_n = rnn(x) # out: (32, 20, 256)
nn.RNN이 돌려주는 값이 둘인 것도 자주 헷갈린다. out은 로 모든 타임스텝의 은닉 상태를 담고, h_n은 로 마지막 타임스텝의 상태를 층마다 담는다. 단방향 2층 모델이라면 out[:, -1, :]와 h_n[-1]이 같은 값이다 — 앞쪽은 마지막 시간의 최상위 층이고 뒤쪽은 최상위 층의 마지막 시간이라 가리키는 자리가 하나다.
초기 상태
은 대개 0 벡터로 둔다. 읽은 것이 아직 없으니 요약도 비어 있다는 뜻이고, PyTorch는 넘기지 않으면 알아서 0을 채운다. 학습 가능한 파라미터로 두는 선택지도 있는데, 모델이 "시작 지점"을 스스로 정하게 하는 것이라 짧은 시퀀스에서 조금 도움이 되는 정도다.
을 0으로 두지 않는 경우가 하나 있다. 긴 시퀀스를 토막 내어 학습할 때는 앞 토막의 마지막 상태를 다음 토막의 으로 물려준다. 그래야 토막 경계에서 맥락이 끊기지 않는다. 이때 상태를 그대로 넘기면 계산 그래프까지 딸려 오므로 뒤에서 볼 detach가 필요해진다.
입출력 형태
one-to-many와 many-to-one
같은 셀을 어떻게 엮느냐에 따라 RNN이 푸는 과제의 모양이 달라진다. 입력이 하나이고 출력이 여럿인 것이 one-to-many다. 이미지 하나를 받아 설명 문장을 만드는 캡셔닝이 여기 속한다. 반대로 입력이 여럿이고 출력이 하나인 many-to-one은 문장을 읽고 긍정인지 부정인지 하나를 답하는 감성 분류가 대표적이다.
정렬형과 비정렬형
입력과 출력이 모두 여럿인 many-to-many는 다시 둘로 갈린다. 타임스텝마다 입력 하나에 출력 하나가 짝지어지는 것이 정렬형이고, 품사 태깅이나 개체명 인식이 그렇다. 입력을 다 읽고 나서 출력을 만들기 시작해 길이가 서로 다를 수 있는 것이 비정렬형이며, 기계 번역이 여기 속한다.
비정렬형은 이 글의 구조만으로는 풀리지 않는다. 셀 하나를 늘어놓는 것으로는 입력 스텝 수와 출력 스텝 수를 다르게 만들 방법이 없기 때문이다. 읽는 RNN과 쓰는 RNN을 따로 두어 그 제약을 푸는 것이 인코더-디코더 구조다.
네 형태가 서로 다른 모델인 것은 아니라는 점도 짚어 둘 만하다. 셀도 갱신식도 전부 같고 달라지는 것은 어느 스텝에서 입력을 넣고 어느 스텝에서 출력을 꺼내는지뿐이다. one-to-many는 첫 스텝에만 입력을 넣고 나머지 스텝에는 이전 출력을 다시 넣으며, many-to-one은 스텝마다 입력을 넣되 마지막 출력만 쓴다. 같은 부품을 배선만 바꿔 여러 과제에 쓰는 것이라, 구현할 때 손대는 자리도 셀이 아니라 그것을 감싸는 루프다.
손실 계산 위치
형태가 정해지면 손실을 어느 스텝에서 재는지도 정해진다. many-to-one은 마지막 스텝의 출력 하나만 정답과 비교하고, 정렬형 many-to-many는 스텝마다 재서 평균한다. 사소한 차이처럼 보이지만 기울기가 흐르는 길이 달라진다.
many-to-one에서는 앞쪽 타임스텝이 받는 기울기가 전부 은닉 상태를 타고 거슬러 온 것이다. 스무 번째 스텝에서 잰 손실이 첫 번째 스텝에 닿으려면 갱신식을 열아홉 번 거꾸로 통과해야 하고, 그 과정에서 값이 줄어드는 것이 다음 절의 문제다. 반면 정렬형은 스텝마다 손실이 직접 붙으므로 앞쪽 스텝도 가까운 손실에서 기울기를 받는다. 같은 셀을 쓰는데도 many-to-one 쪽이 긴 의존을 배우기 더 어려운 이유가 여기 있다.
배치와 패딩
패딩
GPU를 제대로 쓰려면 문장 여러 개를 한 배치로 묶어야 하는데, 배치 텐서는 꼴의 직사각형이어야 한다. 길이가 5, 12, 8인 문장 셋을 묶으려면 가장 긴 것에 맞춰 짧은 쪽 뒤를 특수 토큰으로 채운다. 이것이 패딩이다.
패딩에 쓰는 토큰은 어휘 사전에 자리를 하나 더 만들어 둔 것이고, 임베딩 층에서 그 자리를 0 벡터로 고정해 두는 것이 보통이다. 고정해 두지 않으면 패딩 토큰의 임베딩이 학습으로 움직여 아무 뜻도 없는 벡터가 계속 갱신된다.
패딩은 공짜가 아니다. 위 예에서 실제 토큰은 25개인데 계산은 36칸에 대해 일어나므로 3할이 버려진다. 길이 분포가 넓으면 이 낭비가 훨씬 커져서, 대부분이 10토큰인데 하나가 200토큰이면 그 배치는 거의 전부가 패딩이 된다. 그래서 실무에서는 비슷한 길이끼리 모아 배치를 만드는 버킷팅을 쓴다. 정렬해서 묶으면 배치 안의 길이 차가 줄어 낭비가 크게 준다.
pack_padded_sequence
낭비보다 심각한 것은 오염이다. 패딩 자리에도 RNN 셀이 그대로 돌기 때문이다. 입력이 0 벡터여도 는 0이 아니므로 은닉 상태가 계속 바뀐다. 문장이 5토큰에서 끝났는데 12번째 스텝의 상태를 h_n으로 가져가면, 그것은 패딩을 일곱 번 더 읽은 뒤의 값이다.
pack_padded_sequence가 이 문제를 푼다. 각 문장의 실제 길이를 함께 넘기면 RNN이 유효한 스텝까지만 계산하고, h_n도 문장마다 제 마지막 유효 스텝의 상태로 채워 준다. many-to-one 구조에서 h_n을 분류기에 넣는다면 이것을 빼먹는 순간 결과가 조용히 틀어진다.
손실 마스킹
정렬형 many-to-many에서는 손실 쪽에도 같은 구멍이 있다. 패딩 자리의 출력까지 손실에 넣으면 모델이 "패딩 다음에는 패딩이 온다"를 열심히 배운다. 배치의 절반이 패딩이면 학습 신호의 절반이 그 쓸모없는 규칙에 쓰이는 셈이다.
증상이 애매해서 놓치기 쉽다. 손실 값은 오히려 빨리 떨어진다 — 패딩을 맞히는 것은 쉬운 문제라 그 부분의 손실이 금세 0에 가까워지고, 평균을 끌어내리기 때문이다. 그런데 실제 성능은 안 오른다. nn.CrossEntropyLoss(ignore_index=pad_id)로 그 자리를 빼거나 마스크를 곱해 직접 지워야 하고, 손실 값이 유난히 낮은데 생성 결과가 엉성하다면 먼저 확인할 자리가 여기다.
BPTT
시간 전개
RNN 그래프를 타임스텝마다 펼쳐 놓는 것을 시간 전개(unrolling)라 한다. 펼치고 보면 짜리 RNN은 20층짜리 깊은 네트워크와 같은 모양이고, 역전파도 그 경로를 시간을 거슬러 올라가며 진행한다. 이것을 BPTT(Backpropagation Through Time)라 부른다.
전개는 개념만이 아니라 메모리에도 그대로 나타난다. 역전파를 하려면 스텝마다의 중간값을 전부 들고 있어야 하므로, 활성값 메모리가 배치 크기와 시퀀스 길이의 곱에 비례한다. 1,000스텝짜리 시퀀스를 통째로 학습하려면 1,000층짜리 네트워크의 중간값을 쌓아 두는 것과 같은 비용이 든다. 이것이 뒤에 나올 Truncated BPTT가 필요한 두 번째 이유다 — 기울기 때문만이 아니라 메모리 때문이기도 하다.
지난 글의 깊은 MLP와 한 가지가 다르다. MLP는 층마다 가중치가 달라 어떤 층은 기울기를 키우고 어떤 층은 줄여 서로 상쇄될 여지가 있지만, RNN은 스텝마다 같은 를 곱한다. 같은 행렬을 거듭 곱하면 효과가 상쇄되지 않고 한 방향으로 쌓인다.
기울기의 곱
손실이 첫 스텝의 은닉 상태에 미치는 영향은 스텝마다의 기울기를 전부 곱한 것이다.
곱해지는 항 하나의 크기를 의 최대 특잇값으로 어림해 보자. 그 값이 0.9라면 20스텝 뒤에는 이고 50스텝 뒤에는 0.005다. 앞쪽 스텝이 받는 기울기가 사실상 사라진다. 반대로 1.1이면 20스텝에 6.7, 50스텝에 117로 부풀어 갱신이 한 번에 파라미터를 날려 버린다. 경계가 1이고, 그 위아래로 지수적으로 갈린다.
의 도함수가 최대 1이고 상태가 클수록 0에 가까워진다는 사실이 저울을 소실 쪽으로 기울인다. 그래서 실제로 더 자주 만나는 것은 폭발이 아니라 소실이고, 증상도 조용하다 — 학습이 멈추는 것이 아니라 최근 몇 스텝만 보고 답하는 모델이 되는 것이다.
Truncated BPTT와 클리핑
폭발은 그래디언트 클리핑으로 간단히 막는다. 전체 기울기의 노름이 임계값을 넘으면 그 비율로 줄여 방향은 그대로 두고 크기만 자르는 방식이다. 이것으로 폭발은 사라지지만 소실은 그대로 남는다 — 이미 0에 가까워진 값을 키워 주는 장치가 아니기 때문이다.
긴 시퀀스에서는 Truncated BPTT도 함께 쓴다. 시퀀스를 일정 길이의 토막으로 끊고 각 토막 안에서만 역전파하는 방법이다. 코드의 h.detach()가 그 경계를 만든다 — 은닉 상태의 값은 다음 토막으로 넘기되 계산 그래프는 끊어, 순전파의 맥락은 이어지고 역전파만 토막 안에 갇히게 한다.
여기서 토막 길이가 하이퍼파라미터 이상의 의미를 갖는다. 역전파가 토막을 못 넘으므로 그 길이가 곧 모델이 배울 수 있는 의존 범위의 상한이 된다. 50으로 끊어 놓고 100스텝 떨어진 관계를 배우기를 기대할 수 없다는 뜻이다. 메모리가 허락하는 선에서 토막을 길게 잡되, 앞의 계산대로라면 50스텝쯤부터는 소실 때문에 실효 범위가 그보다 짧다는 것도 함께 알고 있어야 한다.
optimizer.zero_grad()
loss.backward()
# 기울기 폭발 방지: 전체 기울기 노름을 1.0으로 클리핑
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
# Truncated BPTT: 청크마다 끊어서 역전파
chunk_size = 50
h = torch.zeros(1, batch, hidden)
for i in range(0, seq_len, chunk_size):
x_chunk = x[:, i:i+chunk_size, :]
out, h = rnn(x_chunk, h.detach()) # detach로 이전 그래프 분리
loss = criterion(out, target[:, i:i+chunk_size])
loss.backward()
남은 쓰임
추론 메모리
오늘날 대부분의 자연어 처리 과제에서 트랜스포머가 RNN을 대체했다. 그래도 RNN 계열이 유리한 자리가 남아 있고, 그 근거는 추론 때 들고 있어야 하는 것의 크기다. RNN은 어느 시점에나 은닉 상태 하나만 갖고 있으면 되므로 메모리가 이다. 시퀀스가 1,000토큰이든 100만 토큰이든 같은 크기의 벡터 하나다.
트랜스포머는 반대다. 지나간 토큰의 키와 값을 전부 캐시에 들고 있어야 하므로 메모리가 길이에 비례해 으로 자란다. 학습에서는 관계가 뒤집힌다 — 트랜스포머는 모든 토큰을 한 번에 계산해 병렬화가 되지만, RNN은 앞 스텝이 끝나야 다음 스텝을 시작할 수 있어 시퀀스 길이만큼의 순차 계산이 필요하다.
이 맞바꿈이 두 구조의 운명을 갈랐다. 학습에 쓸 수 있는 연산량이 폭발적으로 늘어난 시기에 병렬화되지 않는 구조는 그 자원을 받아 쓸 수가 없었다. 같은 시간에 트랜스포머가 백 배의 데이터를 도는 동안 RNN은 한 바퀴를 도는 셈이라, 구조의 우열 이전에 규모를 키울 수 없다는 것이 결정적이었다. 반대로 말하면 추론 쪽의 이점은 지금도 그대로 남아 있다.
| 항목 | RNN | Transformer |
|---|---|---|
| 학습 병렬화 | ✗ 순차 | ✓ 완전 병렬 |
| 인퍼런스 메모리 | O(1) 상태 | O(n) KV캐시 |
| 장거리 의존성 | 약 | 강 |
스트리밍 추론
메모리가 실제로 값을 하는 곳이 스트리밍이다. 음성 인식이나 센서 신호 처리처럼 입력이 끝없이 들어오는 상황에서는 "전체 시퀀스"라는 것이 아예 없다. RNN은 프레임 하나를 받아 상태를 갱신하고 바로 결과를 내놓으므로 이런 조건에 그대로 맞는다.
지연 시간도 같은 방향으로 유리하다. 입력 하나가 늘었을 때 RNN이 하는 일은 셀을 한 번 더 도는 것뿐이라 한 프레임의 처리 시간이 지나온 길이와 무관하다. 어텐션은 새 토큰이 이전 토큰 전부를 다시 봐야 하므로 뒤로 갈수록 한 스텝이 느려진다. 실시간 제약이 "평균 몇 밀리초"가 아니라 "언제나 몇 밀리초 안"인 시스템에서는 이 차이가 크다.
마이크로컨트롤러나 소형 임베디드 장치처럼 메모리가 킬로바이트 단위인 환경도 같은 이유로 RNN이 남아 있는 자리다. 길이에 따라 커지는 캐시를 둘 공간이 없기 때문이다.
상태 공간 모델
RNN의 얼개는 다른 이름으로 돌아오기도 했다. Mamba로 대표되는 상태 공간 모델은 상태를 물려주며 시퀀스를 훑는다는 점에서 RNN과 같은 계열이고, 추론 메모리가 인 성질도 그대로 가져간다. 달라진 것은 순환을 선형으로 만들어 학습 때 스캔 알고리즘으로 병렬 계산이 되게 한 점이다.
선형으로 만든 대가로 잃은 것도 있다. 같은 비선형이 스텝마다 끼어들지 않으므로 한 스텝에서 할 수 있는 계산이 단순해지고, 그 몫을 상태 차원을 키우거나 입력에 따라 변하는 계수를 두는 방식으로 메운다. 기울기 쪽은 오히려 나아졌다 — 반복 곱해지는 것이 학습되는 정사각 행렬이 아니라 안정성을 보장하도록 설계된 계수라, 이 글에서 본 지수적 발산과 소멸을 구조적으로 피한다.
그러니 이 글에서 세운 은닉 상태와 파라미터 공유의 얼개는 낡은 지식이 아니다. 다음 글에서는 이 구조에 게이트를 달아 기울기 소실을 정면으로 다루는 LSTM을 살펴본다.
읽어주셔서 감사합니다. 😊

