BERT 뉴스 분류 #1 BERT와 Self-Attention #
#2026-03-01
#1
뉴스 기사 하나가 있다. “Wall St. Bears Claw Back Into the Black"이라는 제목을 보면 사람은 바로 안다. 이건 경제 기사다. 하지만 하루에 수백만 건의 뉴스가 쏟아지는 세상에서 사람이 하나하나 분류할 수는 없다. 컴퓨터가 기사 텍스트를 읽고 스포츠인지, 경제인지, 세계 뉴스인지, 기술 뉴스인지를 자동으로 판단하게 만들고 싶다. 또한 모델을 만들고 끝나는 게 아니라, 만든 모델을 실제로 서비스하고, 성능을 감시하고, 문제가 생기면 자동으로 재학습하는 전체 파이프라인을 구축한다. 연구실의 실험이 아니라 현실 세계의 운영 시스템을 만든다.
| 파일 | 역할 | 단계 |
|---|---|---|
bert-01.ipynb | BERT 파인튜닝 + ONNX 변환 | 모델 학습 |
bert-02-optuna.ipynb | 하이퍼파라미터 탐색 + 다중 모델 비교 | 모델 고도화 |
bert-03-evaluation.ipynb | 종합 평가 리포트 생성 | 모델 평가 |
main.py | FastAPI 추론 서버 | 모델 서빙 |
retrain.py | Champion-Challenger 자동 재학습 | 모델 갱신 |
monitor.py | 드리프트 감지 + 슬랙 알림 | 모델 감시 |
#
#2 BERT가 텍스트를 이해하는 방법
DNA 분석에서 A, C, G, T를 원-핫 인코딩으로 바꿨듯이 텍스트도 마찬가지다. “Google announced a new product"라는 문장을 컴퓨터에게 보여줘도 컴퓨터는 그냥 바이트 덩어리로 볼 뿐, 의미를 모른다.
가장 기본적인 변환 방법은 단어 사전을 만드는 것이다. 모든 단어에 고유 번호를 매긴다. “apple"은 1042번, “google"은 8891번, “announced"는 3847번. 이 번호가 input_ids다. DNA의 원-핫 인코딩이 각 염기에 고유한 벡터를 부여했듯이, 여기서는 각 단어에 고유한 번호를 부여하는 것이다.
# 가장 단순한 방법: 단어 사전
단어사전:
"apple" → 1042번
"google" → 8891번
"announced" → 3847번
하지만 여기서 DNA와의 중요한 차이가 드러난다. DNA 알파벳은 4글자뿐이지만 영어 단어는 수만 개다. 그리고 DNA의 네 글자는 서로 완전히 독립적이지만, 영어 단어들은 의미적 관계가 있다. “good"과 “great"은 비슷한 뜻이고 “good"과 “terrible"은 반대다. 단순한 번호 매기기로는 이런 관계를 표현할 수 없다. 8891번과 3847번이라는 숫자 사이에는 아무런 의미적 관계가 없다.
이 문제를 해결하는 것이 BERT의 핵심이다. 하지만 BERT에 도달하기 전에 먼저 토크나이저라는 관문을 지나야 한다.
#
#3 토크나이저: 문장을 숫자로 쪼개기
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
BERT의 토크나이저는 문장을 받아서 세 가지를 만들어낸다.
return tokenizer(
examples["text"],
truncation=True, # 512 토큰 초과 시 잘라냄
padding=True, # 길이 맞춰 [PAD]로 채움
max_length=512,
)
첫 번째는 input_ids다. 문장의 각 토큰(단어 또는 서브워드)에 해당하는 사전 번호 배열이다. 그런데 단순히 단어만 나열하는 게 아니라 특별한 토큰 두 개가 추가된다. 문장 맨 앞에 [CLS]라는 토큰이 붙고, 맨 뒤에 [SEP]라는 토큰이 붙는다. [CLS]는 “Classification"의 약자로, 나중에 이 위치의 출력 벡터가 문장 전체의 의미를 대표하게 된다. [SEP]는 문장의 끝을 표시한다.
입력 문장: "Wall St. Bears Claw Back Into the Black"
↓ 토크나이저
토큰: [CLS] wall st . bears claw back into the black [SEP]
ID: [101, 2813, 2358, 1012, 6468, 15020, 2067, 2046, 1996, 2304, 102]
- [CLS] (101번): 문장 시작 표시. BERT는 이 토큰의 출력 벡터로 문장 전체를 판단한다.
- [SEP] (102번): 문장 끝 표시.
- [PAD] (0번): 짧은 문장을 512 길이로 채우는 빈칸.
두 번째는 attention_mask다. 이건 실제 단어가 있는 위치에 1, 패딩(빈칸)인 위치에 0이 들어간 배열이다. 왜 이게 필요한가? 신경망은 고정 길이의 입력을 받아야 하는데, 문장 길이는 제각각이다. 짧은 문장 뒤에 [PAD]라는 빈 토큰을 채워서 길이를 맞추는데, 이 빈 토큰은 아무 의미가 없다. attention_mask가 BERT에게 “여기는 진짜 단어니까 주목하고, 여기는 빈칸이니까 무시해"라고 알려주는 것이다. DNA 분석에서 padding=‘same’으로 서열 양 끝을 0으로 채웠던 것과 비슷한 개념이지만, 여기서는 명시적으로 마스크를 제공한다는 점이 다르다.
# attention_mask 직관:
실제 단어: [wall][st][.][bears]...[black][SEP] → 1 (주목해)
패딩 부분: [PAD][PAD][PAD][PAD][PAD]... → 0 (무시해)
# BERT가 패딩 토큰에 의미 없이 주의를 낭비하지 않도록 마스킹한다.
세 번째는 token_type_ids인데, BERT가 원래 두 문장을 입력받을 수 있도록 설계되어서 “첫 번째 문장에 속하는 토큰"과 “두 번째 문장에 속하는 토큰"을 구분하는 용도다. 우리는 단일 문장만 쓰니까 전부 0이다.
# 결과로 나오는 3가지:
input_ids: [101, 2813, 2358, ..., 102, 0, 0, 0, ...] ← 단어 번호
token_type_ids: [0, 0, 0, 0, ..., 0, 0] ← 문장 구분 (단일 문장이면 모두 0)
attention_mask: [1, 1, 1, ..., 1, 0, 0, 0, ...] ← 실제 단어=1, 패딩=0
truncation=True와 max_length=512는 BERT의 입력 길이 제한과 관련이 있다. BERT는 최대 512개 토큰만 처리할 수 있다. 그보다 긴 문장은 잘라내야 한다. 뉴스 기사의 제목과 앞부분만으로도 카테고리를 충분히 판단할 수 있으니, 뒷부분이 잘려도 큰 문제가 되지 않는다.
#
#3 BERT 내부 구조: Self-Attention
토큰 번호가 BERT에 들어가면 무슨 일이 벌어지는가? BERT의 핵심은 Transformer 인코더 12개를 쌓은 구조인데, 각 인코더 안에 Self-Attention이라는 메커니즘이 있다.
Self-Attention이 왜 필요한지를 이해하려면 언어의 근본적인 특성을 생각해봐야 한다. 같은 단어도 맥락에 따라 완전히 다른 뜻을 가진다. “The bank by the river was flooded"에서 “bank"는 강둑이다. “I went to the bank to deposit money"에서 “bank"는 은행이다. 단어 자체만 봐서는 알 수 없고, 주변 단어들을 봐야 한다. “river"가 근처에 있으면 강둑이고, “money"가 근처에 있으면 은행이다.
"bank" → river (강하게 봄) ← "river" 덕분에 강변임을 파악
"bank" → flooded (강하게 봄)
"bank" → financial (약하게 봄)
Self-Attention은 정확히 이 일을 한다. 문장 안의 각 단어가 다른 모든 단어를 “얼마나 참고해야 하는지"를 계산하는 것이다. “bank"라는 단어를 처리할 때, “river"에 높은 점수를 주고 “the"에 낮은 점수를 준다. 그 점수에 따라 다른 단어들의 정보를 가중합해서 “bank"의 표현을 업데이트한다. “river” 옆의 “bank"는 강둑의 의미를 흡수하고, “money” 옆의 “bank"는 은행의 의미를 흡수한다.
이 과정을 수학적으로 보면 Query, Key, Value라는 세 가지 벡터가 관여한다.
Attention(Q, K, V) = softmax(QK^T / √d_k) · V
- Q(Query): "나는 무엇을 찾고 있는가?"
- K(Key): "나는 어떤 정보를 가지고 있는가?"
- V(Value): "실제 전달할 내용은 무엇인가?"
비유로 설명하면 이렇다. 도서관에서 책을 찾는 상황을 떠올려보자. Query는 “내가 찾고 있는 것"이다. “bank"라는 단어가 “나의 의미를 결정하려면 어떤 정보가 필요한가?“라고 묻는 것이다. Key는 “내가 제공할 수 있는 것"이다. 문장의 각 단어가 “나는 이런 정보를 가지고 있어"라고 자기를 소개하는 것이다. Query와 Key의 유사도를 계산하면 Attention Score가 나온다. “river"의 Key가 “bank"의 Query와 잘 맞으면 높은 점수가 나온다. Value는 “실제로 전달할 내용"이다. 높은 점수를 받은 단어의 Value가 더 많이 반영되어 최종 표현이 만들어진다.
Attention(Q, K, V) = softmax(QK^T / √d_k) · V라는 공식에서 softmax(QK^T / √d_k) 부분이 “각 단어를 얼마나 볼지"를 결정하는 점수(가중치)이고, 이 가중치로 V를 가중합하는 것이다. √d_k로 나누는 건 점수가 너무 커지지 않게 하는 안정화 장치다.
이 Self-Attention을 12겹 쌓는다. 첫 번째 레이어에서는 직접적으로 이웃한 단어들의 관계를 잡고, 뒤쪽 레이어로 갈수록 문장 전체의 맥락을 통합한다. Conv1D를 여러 겹 쌓아서 점점 넓은 범위의 패턴을 잡았던 것과 유사한 원리다. 하지만 Conv1D는 고정된 크기의 창(kernel_size)만큼만 보는 반면, Self-Attention은 문장의 어떤 위치든 직접 참조할 수 있다. 문장의 첫 단어와 마지막 단어 사이의 관계도 단 한 번의 Attention으로 포착할 수 있다는 점이 Transformer의 결정적인 장점이다.
12개 레이어를 모두 통과하면, 맨 처음 넣어준 [CLS] 토큰의 위치에서 768차원의 벡터가 나온다. 이 벡터가 문장 전체의 의미를 압축한 것이다. 각 단어의 의미가 Self-Attention을 통해 서로에게 전달되면서, [CLS] 위치에 문장 전체의 정보가 모이게 된다. 이 768차원 벡터가 바로 “이 뉴스 기사가 무엇에 관한 것인가"를 담은 표현이다. 여기에 Dense 레이어를 붙여서 4개 카테고리로 분류하면 뉴스 분류기가 완성된다.
#
#4 모델 서빙
이 분석은 모델 하나를 만드는 것에서 끝나지 않는다. 실제제 서비스 환경에서는 모델을 만든 후에 해야 할 일이 훨씬 많다.
모델을 ONNX 형식으로 변환해서 빠르게 추론할 수 있게 만들고, FastAPI 서버로 실시간 요청을 처리하고, 시간이 지나면서 뉴스의 경향이 바뀌면(새로운 주제가 등장하거나 단어 사용 패턴이 변하면) 모델 성능이 떨어지는 드리프트를 감지하고, 성능이 떨어지면 새 데이터로 자동 재학습하는 시스템을 구축한다.
이건 연구와 엔지니어링의 차이를 보여준다. 연구에서는 “이 모델의 정확도가 몇 퍼센트다"가 최종 결과물이다. 엔지니어링에서는 그 모델이 매일 수백만 건의 요청을 안정적으로 처리하면서, 세상이 변해도 성능을 유지하는 것이 최종 결과물이다.