LLM은 마법이 아니에요, 확률 게임입니다
ChatGPT나 Claude 같은 LLM을 사용하다 보면 “와, 이건 정말 생각하는 거 아니야?”라고 느낄 때가 있어요. 하지만 개발자 관점에서 보면 LLM은 마법이 아니라 아주 정교한 확률 통계 모델입니다.
오늘은 Java/Spring 개발자로서 LLM의 작동 원리를 3가지 핵심 개념으로 파헤쳐보겠습니다:
- Token (토큰) - 왜
char단위가 아닐까? - Next Token Prediction - 문장이 만들어지는 과정
- Hallucination (환각) - AI가 거짓말하는 이유
1. Token (토큰) - 왜 String의 char 단위가 아닐까? 🔤
개발자의 첫 번째 질문
“텍스트 처리라면 String을 char[]로 쪼개서 처리하면 되는 거 아닌가?”
1
2
3
String text = "안녕하세요";
char[] chars = text.toCharArray();
// 결과: ['안', '녕', '하', '세', '요'] - 5개 문자
하지만 LLM은 문자 단위가 아니라 토큰(Token) 단위로 처리합니다.
1
2
3
4
// LLM의 토큰화
String text = "안녕하세요";
List<String> tokens = tokenizer.encode(text);
// 결과: ["안녕", "하세요"] - 2개 토큰
같은 문장인데 5개 처리 vs 2개 처리. 엄청난 차이죠?
비유: 바이트 코드가 아니라 어셈블리어 수준으로 처리
이걸 프로그래밍 언어의 컴파일 과정에 비유하면:
1
2
3
4
5
// char 단위 = 바이트 코드 수준 (너무 Low-Level)
byte[] bytecode = {0x61, 0x62, 0x63}; // 'a', 'b', 'c'
// Token 단위 = 어셈블리어 수준 (의미 있는 단위)
String[] instructions = {"LOAD", "ADD", "STORE"}; // 명령어 단위
- 바이트 단위 처리: 0과 1만 봐서는 무슨 의미인지 알 수 없음
- 어셈블리어 단위 처리:
LOAD,ADD같은 명령어는 의미 있는 동작 단위
LLM도 마찬가지예요. '안', '녕' 각각은 의미가 없지만, "안녕"은 인사라는 개념을 가진 의미 단위입니다.
왜 토큰 단위로 처리할까?
1) 계산 비용 절감 - DB 인덱싱과 유사
데이터베이스에서 Full Table Scan보다 Index Scan이 빠른 것처럼, 토큰화는 처리 단위를 대폭 줄여줍니다.
1
2
3
4
5
6
7
-- char 단위 = Full Table Scan (비효율)
SELECT * FROM characters WHERE char IN ('J', 'a', 'v', 'a');
-- 4번 조회
-- Token 단위 = Index Scan (효율)
SELECT * FROM tokens WHERE token = 'Java';
-- 1번 조회
| 처리 방식 | 예시 | 처리 횟수 |
|---|---|---|
| 문자 단위 | “데이터베이스” → [‘데’,’이’,’터’,’베’,’이’,’스’] | 6회 |
| 토큰 단위 | “데이터베이스” → [“데이터베이스”] | 1회 |
자주 사용되는 단어는 하나의 토큰으로 사전에 등록되어 있기 때문에, 매번 문자를 조합할 필요가 없어요!
2) 의미 단위 처리 - 객체 지향의 캡슐화
1
2
3
4
5
6
7
8
9
10
// 문자 단위 - 의미 없음
char c1 = 'J';
char c2 = 'a';
char c3 = 'v';
char c4 = 'a';
// 각 문자는 독립적, 의미 없음
// 토큰 단위 - 의미 있음
Token token = new Token("Java");
// "Java"는 프로그래밍 언어라는 개념을 캡슐화
객체 지향에서 데이터와 기능을 하나의 클래스로 묶듯이, 토큰은 자주 함께 등장하는 문자들을 하나의 의미 단위로 묶습니다.
3) 메모리 효율 - Enum처럼 정수로 매핑
1
2
3
4
5
6
7
8
9
10
11
// 실제로는 문자열이 아니라 정수로 처리
public enum Token {
HELLO(1),
WORLD(2),
JAVA(3);
private final int id;
}
// 문자열 "Hello World" 대신 정수 배열 [1, 2]로 처리
int[] tokenIds = {1, 2}; // 메모리 절약!
LLM 내부에서는 “Java”를 문자열로 저장하지 않고 42315 같은 정수 ID로 변환해서 처리합니다. 메모리도 절약되고 연산도 빨라지죠!
실제 토큰화 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
String prompt = "ChatGPT는 정말 똑똑해!";
// OpenAI Tokenizer 결과 (대략)
List<String> tokens = [
"Chat", // 1개 토큰
"GPT", // 1개 토큰
"는", // 1개 토큰
" 정말", // 공백 포함 1개 토큰
" 똑", // 공백 포함 1개 토큰
"똑", // 1개 토큰
"해", // 1개 토큰
"!" // 1개 토큰
];
// 총 8개 토큰
여기서 중요한 점:
- 공백도 토큰의 일부입니다. “ 정말”처럼 앞에 공백이 포함돼요.
- 영어 단어는 대부분 1개 토큰, 한글은 음절별로 쪼개질 수 있어요.
- 특수 기호(
!,?)도 토큰으로 처리됩니다.
토큰 계산 비용이 왜 중요한가?
API 요금이 토큰 단위로 청구되기 때문입니다!
1
2
3
4
5
6
7
8
9
// OpenAI API 예시
String prompt = "이 코드를 리팩토링해줘: " + longCode;
int inputTokens = tokenizer.count(prompt); // 입력: 500 토큰
int outputTokens = tokenizer.count(response); // 출력: 1000 토큰
// 요금 계산 (GPT-4 기준)
double cost = (inputTokens * 0.00003) + (outputTokens * 0.00006);
// 입력 $0.015 + 출력 $0.06 = $0.075
실전 팁:
- 긴 코드를 통째로 넣으면 토큰이 폭증! → 필요한 부분만 잘라서 전송
- JSON 같은 구조화된 데이터는 토큰이 많이 듦 → 간결하게 요약
- 이전 대화가 누적되면 토큰이 계속 증가 → 중요한 것만 컨텍스트에 유지
2. Next Token Prediction - SELECT next_word ORDER BY probability DESC LIMIT 1 🔮
LLM의 핵심: 다음 토큰 예측
LLM은 결국 “다음에 올 토큰을 예측하는 함수”입니다. SQL로 비유하면:
1
2
3
4
5
6
-- LLM의 본질
SELECT next_token
FROM vocabulary
WHERE context = '이전 모든 토큰'
ORDER BY probability DESC
LIMIT 1;
매번 이 쿼리를 반복해서 문장을 생성하는 거예요!
Java 코드로 표현한 LLM
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class SimpleLLM {
private Vocabulary vocabulary; // 모든 가능한 토큰 사전
/**
* 다음 토큰을 확률 기반으로 예측
*/
public Token predictNextToken(List<Token> context) {
// 1. 이전 모든 토큰을 보고 각 토큰의 확률 계산
Map<Token, Double> probabilities = new HashMap<>();
for (Token candidate : vocabulary.getAllTokens()) {
// Transformer가 확률을 계산
double prob = transformer.calculateProbability(context, candidate);
probabilities.put(candidate, prob);
}
// 2. 확률 분포에서 샘플링
// 예: {"는": 0.35, "을": 0.25, "가": 0.20, ...}
return sampleFromDistribution(probabilities);
}
/**
* 전체 문장 생성 (while 루프!)
*/
public String generateText(String prompt) {
List<Token> tokens = tokenizer.encode(prompt);
// 종료 토큰이 나오거나 최대 길이까지 반복
while (!isEndToken(tokens.getLast()) && tokens.size() < maxLength) {
Token next = predictNextToken(tokens); // 다음 토큰 예측
tokens.add(next); // 토큰 추가
}
return tokenizer.decode(tokens);
}
}
핵심은 while 루프로 한 번에 하나씩 순차적으로 생성한다는 거예요!
실제 작동 과정 - 단계별 시뮬레이션
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
사용자 입력: "Java는"
┌─────────────────────────────────────────────┐
│ Step 1: 첫 번째 토큰 예측 │
└─────────────────────────────────────────────┘
입력 컨텍스트: ["Java", "는"]
확률 분포:
- " 객체": 0.40 ← 가장 높음!
- " 프로그래밍": 0.25
- " 언어": 0.20
- " 커피": 0.05
- ...
선택: " 객체" (40% 확률로 선택)
현재 문장: "Java는 객체"
┌─────────────────────────────────────────────┐
│ Step 2: 두 번째 토큰 예측 │
└─────────────────────────────────────────────┘
입력 컨텍스트: ["Java", "는", " 객체"]
확률 분포:
- "지향": 0.60 ← 가장 높음!
- "의": 0.25
- "를": 0.10
- ...
선택: "지향" (60% 확률로 선택)
현재 문장: "Java는 객체지향"
┌─────────────────────────────────────────────┐
│ Step 3: 세 번째 토큰 예측 │
└─────────────────────────────────────────────┘
입력 컨텍스트: ["Java", "는", " 객체", "지향"]
확률 분포:
- " 프로그래밍": 0.70 ← 가장 높음!
- " 언어": 0.20
- "적": 0.08
- ...
선택: " 프로그래밍" (70% 확률로 선택)
현재 문장: "Java는 객체지향 프로그래밍"
...계속 반복...
마치 데이터베이스 커서(Cursor)처럼 앞으로만 진행하고, 한 번 생성한 토큰은 수정할 수 없어요!
1
2
3
4
5
6
// DB Cursor와 유사
Cursor cursor = database.query("SELECT * FROM tokens");
while (cursor.hasNext()) {
Token next = cursor.next(); // 앞으로만 진행
// 이전 토큰은 수정 불가!
}
왜 매번 다른 답변이 나올까? - Temperature 파라미터
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Token sampleFromDistribution(
Map<Token, Double> probs,
double temperature
) {
if (temperature == 0.0) {
// 항상 가장 확률 높은 것만 선택 (결정적)
return probs.entrySet().stream()
.max(Map.Entry.comparingByValue())
.get().getKey();
} else {
// 확률 분포에 따라 무작위 선택 (확률적)
return weightedRandomSample(probs, temperature);
}
}
Temperature 비교:
| Temperature | 동작 | 예시 |
|---|---|---|
| 0.0 | 항상 가장 확률 높은 토큰 선택 | “Java는 객체지향 프로그래밍 언어입니다.” (매번 동일) |
| 0.7 (기본) | 확률 분포에 따라 선택 | “Java는 객체지향 언어예요.” (다양함) |
| 1.5 | 낮은 확률 토큰도 자주 선택 | “Java는 커피 이름이기도 해요!” (창의적이지만 이상함) |
핵심 포인트 정리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// LLM = while 루프 + 확률 분포 샘플링
String response = "";
List<Token> context = tokenize(prompt);
while (!isDone(context)) {
// 1. 다음 토큰 확률 계산 (이전 모든 토큰 참고)
Map<Token, Double> probs = calculateProbabilities(context);
// 2. 확률 분포에서 샘플링
Token next = sample(probs, temperature);
// 3. 컨텍스트에 추가
context.add(next);
response += next.getText();
}
return response;
- 한 번에 하나씩 순차적으로 생성 (병렬 생성 불가)
- 이전 모든 토큰을 보고 다음 토큰 결정
- 확률이 가장 높은 것을 선택하지만, 100% 정확한 것은 아님
3. Hallucination (환각) - AI가 거짓말하는 이유는 “확률적 찐빠” 🌀
환각이란?
LLM이 사실이 아닌 정보를 그럴듯하게 지어내는 현상을 환각(Hallucination)이라고 해요.
1
2
3
4
5
6
사용자: "2024년 출시된 Java 25의 신기능은?"
ChatGPT: "Java 25에는 Pattern Matching for Arrays와
Virtual Threads 2.0이 추가되었습니다!"
→ 하지만 Java 25는 아직 출시 안 됨! 😱
개발자의 버그 vs LLM의 환각
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 개발자의 버그 (결정적 Deterministic)
public int divide(int a, int b) {
return a / b;
// b가 0이면 항상 ArithmeticException 발생
// 재현 가능! 디버깅 가능!
}
// 2. LLM의 환각 (확률적 Probabilistic)
public Token predictNext(List<Token> context) {
Map<Token, Double> probs = calculateProbabilities(context);
return sampleFromDistribution(probs);
// "가장 확률 높은" 토큰이 "정확한" 토큰은 아님!
// 매번 다른 결과 가능!
}
| 구분 | 개발자의 버그 | LLM의 환각 |
|---|---|---|
| 발생 원인 | 로직 오류, 코딩 실수 | 확률 기반 추론, 학습 데이터 부족 |
| 재현성 | 항상 재현 가능 | 매번 다를 수 있음 |
| 예측 가능성 | 디버깅으로 찾을 수 있음 | 확률적으로만 예측 가능 |
| 해결법 | 코드 수정 | 프롬프트 개선, 외부 검증 |
| 책임 소재 | 개발자 실수 | 모델의 본질적 한계 |
왜 환각이 발생할까? 3가지 이유
1) 학습 데이터 부족 - Cache Miss와 유사
1
2
3
4
5
6
7
8
9
10
11
// LLM = 거대한 Cache
Cache<String, String> trainingData = new HashMap<>();
// 2023년 10월까지의 인터넷 데이터만 학습
String query = "2024년 Java 신기능은?";
if (!trainingData.containsKey(query)) {
// Cache Miss!
// → 비슷한 패턴으로 "추론"해서 답변 생성
// → 존재하지 않는 기능을 "그럴듯하게" 지어냄
}
데이터베이스에서 SELECT했는데 데이터가 없으면:
- DB:
null또는 빈 결과 리턴 - LLM: “비슷한 것”을 찾아서 억지로라도 답변 생성
왜 이럴까? Generative(생성형) 모델의 특성 때문입니다!
1
2
3
4
5
6
7
8
9
// DB: Retrieval (검색) - 없으면 없다고 말함
Optional<String> result = database.findById(id);
if (result.isEmpty()) {
return "데이터를 찾을 수 없습니다."; // 솔직함
}
// LLM: Generation (생성) - 없어도 만들어냄
String result = llm.generate(prompt);
// "아마도 이럴 것 같은데..." (추측)
2) 확률 기반 선택 - Best Effort Delivery
1
2
3
4
5
6
7
8
9
10
11
Map<Token, Double> probabilities = Map.of(
"정확한_토큰", 0.30,
"비슷한_토큰", 0.28, // ← 이게 선택될 수도!
"그럴듯한_토큰", 0.22, // ← 이것도 가능!
"관련없는_토큰", 0.10,
"이상한_토큰", 0.10
);
Token selected = sampleFromDistribution(probabilities);
// 30%만 정확한 토큰이 선택됨
// 나머지 70%는 "비슷하지만 틀린" 토큰!
가장 확률이 높은 것도 30%밖에 안 돼요. 70% 확률로 다른 토큰이 선택되는 거죠!
이게 누적되면:
1
2
3
4
5
Step 1: 정확한 토큰 선택 (30%)
Step 2: 정확한 토큰 선택 (30%)
Step 3: 정확한 토큰 선택 (30%)
→ 3단계 모두 정확할 확률 = 0.3 × 0.3 × 0.3 = 2.7%
10개 토큰을 생성하면? 0.3^10 = 0.0006% 😱
3) 컨텍스트 길이 제한 - Memory Overflow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// LLM의 메모리 제한
public class LLMContext {
private static final int MAX_TOKENS = 8192; // GPT-4 기준
private List<Token> context = new ArrayList<>();
public void addToken(Token token) {
context.add(token);
if (context.size() > MAX_TOKENS) {
// 오래된 토큰은 "잊어버림" (FIFO Queue)
context.remove(0);
System.out.println("⚠️ 오래된 대화 내용이 삭제됩니다!");
}
}
}
긴 대화를 나누다 보면 초반 내용을 잊어버리는 이유가 이거예요!
1
2
3
4
5
6
7
8
사용자: "내 이름은 김개발이야."
ChatGPT: "안녕하세요, 개발님!"
...(8000 토큰 분량의 대화)...
사용자: "내 이름이 뭐였지?"
ChatGPT: "죄송하지만 대화 기록에서 찾을 수 없네요."
← 초반 대화가 메모리에서 삭제됨!
환각 vs Not Found 에러 비교
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 데이터베이스 - 솔직하게 에러 발생
try {
User user = database.findById(999);
} catch (NotFoundException e) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND,
"User not found"
); // 명확한 에러!
}
// 2. LLM - 억지로라도 답변 생성
String prompt = "존재하지 않는 Java 25 기능 알려줘";
String response = llm.generate(prompt);
// "Java 25에는 Pattern Matching for Arrays가 있습니다!"
// ← 존재하지 않는 기능을 지어냄 (에러 아님!)
| 상황 | 데이터베이스 | LLM |
|---|---|---|
| 데이터 없음 | 404 Not Found 에러 | 그럴듯한 답변 생성 (환각) |
| 모호한 쿼리 | 400 Bad Request 에러 | 추측해서 답변 |
| 잘못된 입력 | 예외 발생 | 최선의 답변 시도 |
왜 이렇게 설계되었을까?
- LLM은 대화형 서비스이기 때문에 “모르겠다”보다 “아마 이럴 거야”라고 답하는 게 사용자 경험상 좋다고 판단
- 하지만 이게 정확성을 해치는 주요 원인
환각을 줄이는 개발자의 해결책
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class LLMWithValidation {
private LLM llm;
private Database factDatabase; // 실제 사실 DB
public String generateSafe(String prompt) {
String response = llm.generate(prompt);
// 1. RAG (Retrieval-Augmented Generation)
// "DB에서 실제 사실 먼저 검색"
List<String> facts = factDatabase.retrieveRelevantFacts(prompt);
if (!isConsistentWithFacts(response, facts)) {
// 사실과 다르면 재생성
String augmentedPrompt = prompt + "\n\n참고 자료:\n"
+ String.join("\n", facts);
response = llm.generate(augmentedPrompt);
}
// 2. Confidence Score 확인
double confidence = llm.getConfidenceScore();
if (confidence < 0.7) {
response += "\n\n⚠️ 이 답변의 신뢰도는 낮습니다. 검증이 필요합니다.";
}
// 3. 외부 검증 API 호출
if (requiresFactCheck(response)) {
boolean isValid = externalFactCheckAPI.verify(response);
if (!isValid) {
return "정확한 정보를 제공할 수 없습니다. " +
"공식 문서를 참고해주세요: " + getOfficialDocsUrl();
}
}
return response;
}
private boolean isConsistentWithFacts(String response, List<String> facts) {
// 간단한 키워드 매칭 또는 임베딩 유사도 비교
for (String fact : facts) {
if (contradicts(response, fact)) {
return false;
}
}
return true;
}
}
실전 적용 예시:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Spring에서 LLM API 사용 시
@Service
public class AIAssistantService {
@Autowired
private OpenAIClient openAI;
@Autowired
private DocumentRepository documentRepo;
public String answerQuestion(String question) {
// 1. 먼저 내부 문서에서 검색 (RAG)
List<String> relevantDocs = documentRepo
.searchByEmbedding(question, limit = 3);
// 2. 문서와 함께 프롬프트 작성
String prompt = String.format("""
다음 문서를 참고해서 질문에 답변해주세요.
정확한 정보만 답변하고, 모르면 "모르겠습니다"라고 답변하세요.
[참고 문서]
%s
[질문]
%s
""", String.join("\n\n", relevantDocs), question);
// 3. LLM 호출
String response = openAI.chat(prompt, temperature = 0.3);
// 4. 응답 검증
if (containsUncertainPhrases(response)) {
logger.warn("LLM response contains uncertainty: {}", response);
}
return response;
}
}
정리: LLM은 “확률 게임”입니다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* LLM의 본질을 한 줄로 표현
*/
public class LLM {
public String generate(String prompt) {
List<Token> tokens = tokenize(prompt);
while (!isDone(tokens)) {
// 1. 확률 계산
Map<Token, Double> probs = transformer.predict(tokens);
// 2. 확률 기반 샘플링 (100% 정확하지 않음!)
Token next = sample(probs);
// 3. 토큰 추가
tokens.add(next);
}
return detokenize(tokens);
}
}
핵심 정리
| 개념 | 개발자 비유 | 핵심 포인트 |
|---|---|---|
| Token | DB Index, 어셈블리어 | 의미 단위로 처리, 계산 비용 절감 |
| Next Token Prediction | while 루프 + SQL ORDER BY probability | 한 번에 하나씩 순차 생성 |
| Hallucination | Cache Miss + Best Effort | 없는 정보를 확률적으로 지어냄 |
개발자가 알아야 할 실전 팁
1. 토큰 비용 최적화
1
2
3
4
5
// Bad: 전체 코드를 다 넣음
String prompt = "이 코드를 리팩토링해줘:\n" + entireCodebase;
// Good: 필요한 부분만 발췌
String prompt = "이 메서드를 리팩토링해줘:\n" + targetMethod;
2. 환각 방지
1
2
3
4
5
6
7
String prompt = """
다음 질문에 답변해주세요.
확실하지 않으면 "모르겠습니다"라고 답변하세요.
추측하지 말고, 사실만 말씀해주세요.
질문: %s
""".formatted(userQuestion);
3. 확률적 특성 이해
1
2
3
4
5
6
7
8
// 같은 질문도 매번 다른 답변 (temperature > 0)
String answer1 = llm.generate(prompt, temperature = 0.7);
String answer2 = llm.generate(prompt, temperature = 0.7);
// answer1 != answer2 (확률적!)
// 결정적 답변 필요 시
String answer = llm.generate(prompt, temperature = 0.0);
// 항상 동일한 답변
마치며
LLM은 마법이 아닙니다. 확률 통계 기반의 정교한 함수예요.
- Token: 효율적인 처리를 위한 의미 단위
- Next Token Prediction:
while루프로 한 토큰씩 순차 생성 - Hallucination: 확률적 추론의 부작용 (악의가 아니라 “찐빠”)
개발자로서 이 원리를 이해하면:
- 더 효과적인 프롬프트를 작성할 수 있고
- API 비용을 최적화할 수 있으며
- 결과를 적절히 검증하는 로직을 설계할 수 있습니다!
참고 자료
- OpenAI Tokenizer: https://platform.openai.com/tokenizer
- Attention Is All You Need (Transformer 논문): https://arxiv.org/abs/1706.03762
- GPT-3 Paper: https://arxiv.org/abs/2005.14165