컴퓨터가 세는 것은
전부 띄엄띄엄합니다
그래서 이산수학입니다
미적분은 매끄럽게 이어지는 것을 다룹니다. 그런데 컴퓨터가 다루는 것은 비트 · 배열 인덱스 · 노드 · 상태처럼 딱딱 끊어진 것뿐입니다. 그 세계의 수학이 이산수학이고, 그래서 대학에서 프로그래밍과 함께 처음 배우는 수학이 이것입니다. 이 탭은 정리와 증명을 나열하는 대신 그 개념이 코드의 어디에 이미 들어 있는지부터 보여 줍니다.
왜 이산수학인가 — 코드의 뼈대가 여기 있습니다
“수학 안 해도 개발 잘하던데요”는 절반은 맞습니다. 다만 막히는 지점이 늘 같은 곳입니다 — 조건문이 꼬일 때, 재귀가 맞는지 모를 때, 해시가 왜 부딪히는지 설명 못 할 때. 전부 이산수학이 이미 답을 갖고 있는 자리입니다.
연속과 이산 — 무엇이 다른가
| 연속 (미적분) | 이산 | |
|---|---|---|
| 다루는 대상 | 실수 · 곡선 | 정수 · 집합 · 그래프 |
| “조금 더” | 무한히 작게 | 1 만큼 |
| 대표 질문 | 변화율은? | 경우의 수는? 연결되어 있나? |
| 코드의 예 | 물리 · 그래픽 | 배열 · 조건문 · 자료구조 |
1.5 를 넣을 수 없다는 사실,
그게 이산수학이 필요한 이유의 전부입니다.
이미 쓰고 있었던 것들
| 이산수학의 개념 | 코드에서의 모습 |
|---|---|
| 명제 논리 | if (a && !b) |
| 집합 | Set · DISTINCT |
| 관계 | 외래키 · JOIN |
| 함수 | 순수 함수 · 해시 |
| 그래프 | 의존성 · 팔로우 · 라우팅 |
| 귀납법 | 재귀 · 반복문 정확성 |
| 조합론 | 충돌 확률 · 비밀번호 강도 |
| 모듈러 산술 | hash % 버킷수 · RSA |
이렇게도 쓸 수 있다 — “증명”이라는 말에 겁먹지 않기 🟡 중급
증명 = 리뷰어를 설득하는 글
수학의 증명과 코드 리뷰는 사실 같은 일입니다 —
“왜 이게 항상 맞다고 확신하는가”를 남이 따라올 수 있게 적는 것.
형식이 엄격해 보일 뿐, 하는 일은 // 이 루프가 끝나면 arr 은 정렬되어 있다
라는 주석을 정확하게 쓰는 것과 다르지 않습니다.
반례 하나면 무너집니다
# "정렬된 두 리스트를 번갈아 합치면 정렬된다"
def merge_wrong(a, b):
out = []
for x, y in zip(a, b):
out.append(x); out.append(y)
return out
# 반례를 한 줄만 찾으면 이 주장은 끝난다
print(merge_wrong([1, 9], [2, 3])) # [1, 2, 9, 3] ← 정렬 아님
# 올바른 병합: 매 순간 '더 작은 쪽'을 고른다
def merge(a, b):
i = j = 0; out = []
while i < len(a) and j < len(b):
if a[i] <= b[j]: out.append(a[i]); i += 1
else: out.append(b[j]); j += 1
return out + a[i:] + b[j:]
실무에선 이렇게 — 필요한 곳으로 바로 가기 🔴 실무
“지금 막힌 것”에서 출발하는 지도
| 지금 겪고 있는 일 | 읽을 곳 |
|---|---|
| 조건문이 길어져 뭐가 참인지 모르겠다 | 02 명제논리 · 11 부울 대수 |
| 요구사항에 “모든/일부”가 섞여 헷갈린다 | 03 술어논리 |
| 중복 제거·교집합 로직이 지저분하다 | 04 집합 |
| 재귀 함수가 맞는지 확신이 없다 | 05 귀납법 · 12 점화식 |
| 해시가 얼마나 부딪히는지 설명 못 하겠다 | 06 조합론 · 10 모듈러 |
| 순환 참조·의존성 문제를 풀어야 한다 | 07 그래프 · 08 트리 |
| 암호 라이브러리 문서가 안 읽힌다 | 09 정수론 · 10 모듈러 |
| 알고리즘 복잡도를 손으로 못 구하겠다 | 12 마스터 정리 |
명제논리 · 진리표 — 조건문 버그의 절반이 여기서 납니다
if (!(a && b)) 를 if (!a && !b) 로 바꿔 쓴 적이 있다면,
그건 오타가 아니라 드모르간 법칙을 틀린 것입니다. 진리표는 그런 실수를
기계적으로 잡아 주는 도구입니다.
기본 연산 네 개
| 이름 | 기호 | 코드 | 참이 되는 때 |
|---|---|---|---|
| 부정 | ¬p | !p | p 가 거짓일 때 |
| 논리곱 | p ∧ q | p && q | 둘 다 참 |
| 논리합 | p ∨ q | p || q | 하나라도 참 |
| 함의 | p → q | !p || q | p 가 참인데 q 가 거짓일 때만 거짓 |
진리표 — 모든 경우를 나열해 확인
p q │ p∧q p∨q p→q p⊕q(XOR)
───────┼──────────────────────────
T T │ T T T F
T F │ F T F T
F T │ F T T T
F F │ F F T F
변수가 n 개면 행은 2ⁿ 개.
변수 3개면 8줄이라 손으로 다 적을 수 있다.
이렇게도 쓸 수 있다 — 드모르간 · 조건문 뒤집기 🟡 중급
드모르간 법칙
¬(p ∨ q) ≡ ¬p ∧ ¬q 부정을 안으로 넣으면 ∧ 와 ∨ 가 뒤집힌다 — 이 한 줄이 전부입니다.
✗ 자주 하는 실수
// "유효하지 않은 사용자"
if (!(isActive && hasEmail)) { ... }
// 를 이렇게 바꿔 쓴다 ↓ (틀림)
if (!isActive && !hasEmail) { ... }
✓ 드모르간 적용
if (!isActive || !hasEmail) { ... }
// ∧ 이 ∨ 로 바뀐다는 것이 핵심
단락 평가 — 순서가 의미를 바꾼다
// && 는 왼쪽이 거짓이면 오른쪽을 아예 실행하지 않는다
if (user && user.name.length > 0) { } // 안전
if (user.name.length > 0 && user) { } // user 가 null 이면 터짐
// 논리적으로 p∧q ≡ q∧p 지만, 코드에서는 다르다.
// 수학의 교환법칙은 '부수효과가 없을 때'만 성립한다.
// 이 성질을 의도적으로 쓰는 관용구
const name = user && user.name; // 가드
const port = env.PORT || 3000; // 기본값
const port2 = env.PORT ?? 3000; // 0 도 허용하려면 ??
|| 는 0 · "" · false 도
“없는 것”으로 봅니다. 0 이 유효한 값이면 반드시 ?? 를 쓰세요.
타임아웃·수량 설정에서 자주 사고가 납니다.
실무에선 이렇게 — 복잡한 조건을 줄이는 법 🔴 실무
조건문 정리 4단계
- 이름을 붙인다 —
if (a > 3 && b !== null && !c)대신const canPublish = .... 조건에 이름이 붙는 순간 리뷰가 가능해집니다. - 부정을 밖으로 뺀다 — 드모르간으로
!를 최상위 하나로 모읍니다. - 조기 반환으로 평탄화 — 중첩
if를 가드 절로 바꾸면 진리표를 그릴 필요 자체가 줄어듭니다. - 진리표로 검증 — 그래도 3변수 이상이면 표를 그려 기대값과 대조합니다.
✗ 읽을 수 없는 조건
if (!(u.age >= 19 && u.verified) ||
(u.banned && !u.appealed)) {
deny();
}
✓ 이름 + 드모르간
const isAdult = u.age >= 19;
const isEligible = isAdult && u.verified;
const isBlocked = u.banned && !u.appealed;
if (!isEligible || isBlocked) deny();
NULL 이 섞이면 참·거짓 말고
“알 수 없음”이라는 세 번째 값이 생깁니다(3치 논리).
NOT (x = 1) 이 x 가 NULL 일 때 참이 아닌 이유입니다 →
🗄️ Database 가이드에서 이어집니다.
술어논리 — “모든”과 “어떤”을 정확히 말하기
요구사항에 “모든 주문은 결제 정보를 가진다”와 “어떤 주문은 쿠폰이 있다”가 섞여 있습니다. 이 둘을 뒤집을 때 규칙을 모르면 테스트 케이스가 통째로 비게 됩니다.
한정자 두 개
∃x P(x) — 어떤 x 가 존재해 P 가 참 ∀ 는 All 의 A 를, ∃ 는 Exists 의 E 를 뒤집은 것입니다.
# 코드에서는 이미 매일 쓰고 있다
all(o.paid for o in orders) # ∀o paid(o)
any(o.coupon for o in orders) # ∃o coupon(o)
# 빈 리스트일 때가 함정이다
all([]) # True ← "모든"은 공허하게 참 (vacuous truth)
any([]) # False
부정하면 서로 바뀐다
¬∃x P(x) ≡ ∀x ¬P(x) “모두 안전하다”의 반대는 “안전하지 않은 게 하나라도 있다”입니다.
# "모든 파일이 스캔됐다"의 부정
not all(f.scanned for f in files)
# ≡ 스캔 안 된 파일이 하나라도 있다
any(not f.scanned for f in files)
# 실전: 반례를 바로 찾아 로그에 남기는 형태가 좋다
bad = next((f for f in files if not f.scanned), None)
if bad:
raise ValueError(f"스캔 안 됨: {bad.name}") # 어떤 것인지까지 알려 준다
all(...) 로 검사하고 실패하면
“무엇이 실패했는지” 모릅니다. 위처럼 반례를 찾는 형태로 쓰면
에러 메시지가 훨씬 쓸모 있어집니다.
이렇게도 쓸 수 있다 — 순서가 바뀌면 뜻이 달라진다 🟡 중급
∀∃ 와 ∃∀ 는 완전히 다른 말
모든 사용자에게 각자의 담당자가 있다
∃s ∀u 담당(s, u)
한 사람이 모든 사용자를 담당한다
SQL 이 사실 술어논리다
-- ∃ : 주문이 있는 고객
SELECT * FROM customer c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.cid = c.id);
-- ∀ : 모든 주문이 결제된 고객
-- ∀ 는 SQL 에 직접 없다. ¬∃¬ 로 바꿔 쓴다
SELECT * FROM customer c
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.cid = c.id AND o.paid = false -- 결제 안 된 게 없다
);
NOT EXISTS 로 씁니다.
위 부정 규칙(¬∀ ≡ ∃¬)을 알면 이 패턴이 외울 것이 아니라 유도되는 것이 됩니다.
실무에선 이렇게 — 명세 · 불변식 · 계약 🔴 실무
불변식을 코드에 박아 두기
class Cart:
"""불변식(어느 시점에나 항상 참이어야 하는 것):
∀ item ∈ items : item.qty > 0
total == Σ item.price * item.qty
"""
def _check(self):
assert all(i.qty > 0 for i in self.items), "수량 0 이하 항목 존재"
assert self.total == sum(i.price * i.qty for i in self.items)
def add(self, item):
self.items.append(item)
self.total += item.price * item.qty
self._check() # 상태를 바꾼 직후마다 확인
# 사전조건 / 사후조건 (계약에 의한 설계)
def withdraw(balance: int, amount: int) -> int:
assert amount > 0 # 사전조건: 호출하는 쪽 책임
assert balance >= amount
result = balance - amount
assert result >= 0 # 사후조건: 이 함수의 약속
return result
집합 · 관계 · 함수 — DB 스키마의 언어
Set, 외래키, 순수 함수. 서로 다른 자리에 있는 것 같지만
셋 다 같은 수학에서 나왔습니다. 이 셋의 관계를 알면
테이블 설계와 API 설계가 같은 문제로 보이기 시작합니다.
집합 연산 — SQL 과 나란히
| 연산 | 기호 | 파이썬 | SQL |
|---|---|---|---|
| 합집합 | A ∪ B | a | b | UNION |
| 교집합 | A ∩ B | a & b | INTERSECT |
| 차집합 | A − B | a - b | EXCEPT |
| 대칭차 | A △ B | a ^ b | — |
| 부분집합 | A ⊆ B | a <= b | — |
before = {"a.py", "b.py", "c.py"}
after = {"b.py", "c.py", "d.py"}
added = after - before # {'d.py'}
removed = before - after # {'a.py'}
kept = before & after # {'b.py', 'c.py'}
changed = before ^ after # {'a.py', 'd.py'}
왜 집합이 리스트보다 빠른가
# 이 코드는 O(n × m) — 데이터가 커지면 죽는다
common = [x for x in a_list if x in b_list]
# 이 코드는 O(n + m)
common = set(a_list) & set(b_list)
# 'in' 의 비용이 다르다
# 리스트: 처음부터 훑는다 O(n)
# 집합 : 해시로 바로 찾는다 O(1)
in 을 반복문 안에 쓴 코드.
데이터 1만 건이 되는 순간 1억 번 비교가 됩니다.
🗂️ 자료구조 탭 04에서 그 원리를 봅니다.
이렇게도 쓸 수 있다 — 관계의 성질이 곧 제약조건 🟡 중급
관계의 세 가지 성질
| 성질 | 뜻 | 예 / 반례 |
|---|---|---|
| 반사적 | a R a | “같다” ✓ / “보다 크다” ✗ |
| 대칭적 | a R b ⇒ b R a | 친구 ✓ / 팔로우 ✗ |
| 추이적 | a R b, b R c ⇒ a R c | 조상 ✓ / 친구 ✗ |
설계에 바로 쓰이는 판단
-- 대칭적인가? → 테이블 모양이 달라진다
-- 친구(대칭): 한 행으로 충분, 항상 (작은id, 큰id) 로 정규화
CREATE TABLE friendship (
a_id INT, b_id INT,
PRIMARY KEY (a_id, b_id),
CHECK (a_id < b_id) -- 중복 저장 방지
);
-- 팔로우(비대칭): 방향마다 한 행
CREATE TABLE follow (
follower_id INT, followee_id INT,
PRIMARY KEY (follower_id, followee_id)
);
실무에선 이렇게 — 함수의 성질과 데이터 파이프라인 🔴 실무
단사 · 전사 · 전단사
| 종류 | 뜻 | 실무에서 |
|---|---|---|
| 단사(1:1) | 서로 다른 입력 → 서로 다른 출력 | 충돌 없는 ID 매핑 |
| 전사 | 출력값이 빠짐없이 나옴 | 모든 상태가 도달 가능 |
| 전단사 | 둘 다 | 되돌릴 수 있음(인코딩) |
멱등성 — 분산 시스템의 핵심 성질
# f(f(x)) == f(x) 이면 멱등(idempotent)
def normalize(email: str) -> str:
return email.strip().lower() # 두 번 해도 같다 ✓
# 멱등하지 않은 것
def add_tax(price):
return price * 1.1 # 두 번 하면 세금이 두 번 붙는다 ✗
# HTTP 메서드의 멱등성
# GET · PUT · DELETE : 멱등 → 재시도해도 안전
# POST : 아님 → 재시도하면 중복 생성
수학적 귀납법 — 재귀가 맞다고 믿는 근거
재귀 함수를 쓰고 “왠지 될 것 같은데” 하고 넘어간 적이 있다면, 그 “왠지”의 정확한 이름이 귀납법입니다. 도미노를 세우는 일과 똑같습니다.
두 단계면 끝
- 기본 단계 — 첫 번째 도미노가 넘어진다 (n = 0 또는 1 에서 참)
- 귀납 단계 — k 번째가 넘어지면 k+1 번째도 넘어진다
재귀 함수와 1:1 대응
def total(items):
if not items: # ← 기본 단계 (종료 조건)
return 0
return items[0] + total(items[1:]) # ← 귀납 단계
# 이 함수가 맞다고 믿는 근거:
# ① 빈 리스트의 합은 0 이다 (기본)
# ② 나머지의 합이 맞다면, 첫 원소를 더한 것도 맞다 (귀납)
# ③ 리스트 길이는 매번 1 씩 줄어든다 (종료)
total(items) 를 실수로
items[1:] 없이 부르는 경우.
이렇게도 쓸 수 있다 — 루프 불변식 — 반복문에도 같은 논리 🟡 중급
반복문의 “왜 맞는가”
def max_of(arr):
best = arr[0]
# 불변식: 이 지점에서 best 는 arr[0..i] 중 최댓값이다
for i in range(1, len(arr)):
if arr[i] > best:
best = arr[i]
# 불변식이 여전히 성립한다 ✓
return best
# 루프가 끝나면 i = len-1 이므로
# best 는 arr 전체의 최댓값이다 ← 결론
강한 귀납법 — 바로 앞이 아니라 전부 가정
# 병합 정렬은 "n-1" 이 아니라 "n/2" 두 개를 쓴다.
# 그래서 '바로 앞'만 가정하는 보통 귀납법으로는 부족하다.
def merge_sort(a):
if len(a) <= 1: return a # 기본
mid = len(a) // 2
left = merge_sort(a[:mid]) # 나보다 작은 모든 크기를
right = merge_sort(a[mid:]) # 이미 안다고 가정 (강한 귀납)
return merge(left, right)
# 강한 귀납법: P(0)..P(k) 가 전부 참이면 P(k+1) 도 참
# 분할 정복 알고리즘의 정확성은 거의 전부 이 형태로 증명한다
실무에선 이렇게 — 재귀를 언제 반복문으로 바꾸나 🔴 실무
스택 오버플로와 꼬리 재귀
import sys
sys.getrecursionlimit() # 파이썬 기본 1000 — 생각보다 얕다
# 깊은 재귀 → 반복문 + 명시적 스택으로 바꾼다
def walk_iterative(root):
stack = [root]
while stack:
node = stack.pop()
visit(node)
stack.extend(reversed(node.children)) # DFS 순서 유지
# 꼬리 재귀(마지막에 자기 호출만 남는 형태)는
# 일부 언어(스킴·스칼라 @tailrec·코틀린 tailrec·사파리 JS 엔진)에서 반복문으로 바뀐다.
# ★ 파이썬·자바는 하지 않는다 — 직접 바꿔야 한다.
| 상황 | 선택 |
|---|---|
| 깊이가 로그 수준 (이진 트리, 정렬) | 재귀 그대로 — 읽기 쉬움이 이득 |
| 깊이가 데이터 크기에 비례 (연결 리스트, 경로) | 반복문으로 — 스택 오버플로 위험 |
| 같은 부분 문제가 반복 (피보나치, DP) | 메모이제이션 → 🔀 알고리즘 09 |
| 사용자 입력에서 깊이가 정해짐 (JSON 파싱) | 반드시 깊이 제한 — 공격 벡터 |
코드의 정확성을 증명해 보기
재귀 — 기저와 축소만 확인합니다
def sum_to(n: int) -> int:
if n == 0: # ① 기저
return 0
return n + sum_to(n - 1) # ② 더 작은 문제로
# 증명하고 싶은 것: sum_to(n) == n(n+1)/2
# ① 기저 n=0 : sum_to(0) = 0 = 0·1/2 ✓
# ② 가정 : sum_to(k) = k(k+1)/2 라 하자
# 보일 것: sum_to(k+1) = (k+1)(k+2)/2
#
# sum_to(k+1) = (k+1) + sum_to(k) (코드 그대로)
# = (k+1) + k(k+1)/2 (가정 대입)
# = (k+1)(1 + k/2)
# = (k+1)(k+2)/2 ✓
#
# ∴ 모든 n ≥ 0 에서 성립
종료(termination) — 반드시 끝난다
종료는 감소하는 음이 아닌 정수를 찾으면 증명됩니다
위 코드에서는 n 자체 — 매번 1 씩 줄고 0 에서 멈춥니다
이 값을 변형 함수(variant) 라 부릅니다
sum_to(n-1) 을 sum_to(n) 으로 잘못 쓰면
값이 줄지 않아 영원히 돕니다.
재귀를 작성한 뒤 “무엇이 매번 줄어드는가”를 한 문장으로 답해 보세요 —
답이 안 나오면 그 재귀는 위험합니다.
반복문 — 루프 불변식
def find_max(a: list[int]) -> int:
m = a[0]
i = 1
# 불변식: m == max(a[0..i-1])
while i < len(a):
if a[i] > m:
m = a[i]
i += 1
return m
# ① 초기 : i=1 일 때 m = a[0] = max(a[0..0]) ✓
# ② 유지 : 불변식이 참이고 한 바퀴 돌면
# m' = max(m, a[i]) = max(a[0..i]) ✓
# i' = i+1 이므로 max(a[0..i'-1]) 과 일치
# ③ 종료 : i == len(a) 이므로 m = max(a[0..n-1]) ✓
#
# 변형 함수: len(a) - i (매번 1씩 감소, 0 에서 멈춤)
| 확인할 것 | 질문 |
|---|---|
| 초기 | 루프에 들어가기 직전에 참인가 |
| 유지 | 참인 상태로 한 바퀴 돌면 여전히 참인가 |
| 종료 | 루프를 빠져나온 시점에 원하는 결과가 되는가 |
| 변형 | 매 반복마다 줄어드는 값이 있는가 |
조합론 — 경우의 수가 보안과 성능을 결정합니다
“비밀번호 8자리면 충분한가”, “UUID 가 겹칠 확률은”, “해시 버킷 100개에 1000개 넣으면”. 전부 세는 문제입니다. 세는 법 네 가지만 알면 답이 나옵니다.
기본 네 가지
| 상황 | 공식 | 예 |
|---|---|---|
| 각 자리 독립 | kn | 비밀번호 · IP |
| 순열 (순서 O) | n! / (n−r)! | 1~3등 뽑기 |
| 조합 (순서 X) | n! / (r!(n−r)!) | 팀 구성 |
| 중복 조합 | C(n+r−1, r) | 사탕 나눠 담기 |
from math import comb, perm, factorial
perm(45, 3) # 45명 중 1·2·3등 = 85,140
comb(45, 3) # 45명 중 3명 선발 = 14,190
26 ** 8 # 소문자 8자리 = 약 2,088억
94 ** 8 # 출력 가능 문자 8자리 = 약 6,095조
비둘기집 원리 — 가장 단순하고 강력한 도구
⟹ 어떤 집에는 2마리 이상 당연해 보이지만, “충돌은 반드시 일어난다”의 증명이 이것입니다.
· 해시값이 32비트인데 입력이 2³²+1 개 → 충돌은 100% 발생
· 정수를 문자열로 압축? → 무손실 압축은 모든 입력을 줄일 수 없다
· 캐시 슬롯 1000개에 1001개 키 → 최소 하나는 밀려난다
"이론적으로 충돌하지 않는 해시"는 존재하지 않는다.
암호학적 해시가 하는 일은 '충돌을 찾기 어렵게' 만드는 것뿐이다.
이렇게도 쓸 수 있다 — 생일 역설 — 직관보다 훨씬 빨리 부딪힌다 🟡 중급
23명이면 절반
n = 23 → 50.7% n = 50 → 97% “365일이니까 183명쯤 필요하겠지”라는 직관이 크게 틀리는 지점입니다.
def birthday(n, days=365):
p = 1.0
for i in range(n):
p *= (days - i) / days
return 1 - p # 충돌이 하나라도 있을 확률
birthday(23) # 0.507
birthday(50) # 0.970
# 근사: 충돌이 절반 넘게 나는 개수 ≈ 1.18 × √(경우의 수)
그래서 해시는 몇 비트여야 하나
| 해시 크기 | 50% 충돌까지 | 판단 |
|---|---|---|
| 32비트 (CRC32) | 약 7.7만 개 | ID 로 쓰면 위험 |
| 64비트 | 약 50억 개 | 내부 키 정도 |
| 128비트 (UUIDv4) | 약 2.7×10¹⁸ 개 | 사실상 안전 |
| 256비트 (SHA-256) | 약 4×10³⁸ 개 | 암호학적으로 안전 |
실무에선 이렇게 — 비밀번호 · 토큰 강도 계산 🔴 실무
엔트로피(비트)로 말하기
| 방식 | 엔트로피 | GPU 로 전수 조사 |
|---|---|---|
소문자 8자 (password) | 37비트 | 몇 분 |
| 대소문자+숫자+기호 8자 | 52비트 | 수 개월 |
영단어 4개 (correct horse battery staple) | 51비트 | 수 개월 |
| 영단어 6개 | 77비트 | 사실상 불가 |
secrets.token_urlsafe(32) | 256비트 | 불가 |
import secrets, math
# 안전한 토큰 — 길이가 아니라 '엔트로피'로 정한다
token = secrets.token_urlsafe(32) # 256비트
# 규칙 기반 비밀번호의 엔트로피 계산
def entropy_bits(length, charset_size):
return length * math.log2(charset_size)
entropy_bits(8, 26) # 37.6 ← 위험
entropy_bits(16, 94) # 104.9 ← 충분
# ⚠️ 단, 사람이 만든 비밀번호는 이 계산보다 훨씬 약하다.
# 'Password1!' 은 계산상 65비트지만 사전 공격에는 몇 초.
# 그래서 실무에서는 '유출된 비밀번호 목록 대조'가 규칙보다 효과적이다.
그래프 이론 — 점과 선으로 거의 모든 것이 표현됩니다
지도 · 팔로우 · 패키지 의존성 · 상태 전이 · 라우팅. 전부 정점과 간선일 뿐입니다. 문제를 그래프로 옮기는 순간 이미 만들어진 알고리즘 수십 개를 쓸 수 있게 됩니다.
용어 — 이것만 알면 문서가 읽힙니다
| 용어 | 뜻 |
|---|---|
| 정점 (vertex/node) | 점 — 사용자 · 도시 · 파일 |
| 간선 (edge) | 선 — 관계 · 도로 · import |
| 차수 (degree) | 한 정점에 붙은 간선 수 |
| 경로 (path) | 이어진 정점들의 나열 |
| 사이클 (cycle) | 출발점으로 돌아오는 경로 |
| 연결 요소 | 서로 오갈 수 있는 덩어리 |
| DAG | 방향 있고 사이클 없는 그래프 |
표현 방법 두 가지
# ① 인접 리스트 — 대부분 이걸 쓴다
g = {
"app": ["libA", "libB"],
"libA": ["libC"],
"libB": ["libC"],
"libC": [],
}
# 메모리 O(V + E) · 이웃 순회 빠름
# ② 인접 행렬 — 간선이 아주 빽빽할 때만
# m[i][j] = 1 이면 i→j 간선 있음
m = [[0,1,1,0],
[0,0,0,1],
[0,0,0,1],
[0,0,0,0]]
# 메모리 O(V²) · "i와 j가 연결됐나?" 가 O(1)
이렇게도 쓸 수 있다 — 문제를 그래프로 옮기기 🟡 중급
“이건 그래프 문제였구나”가 보이는 순간
| 현실 문제 | 정점 | 간선 | 알고리즘 |
|---|---|---|---|
| 순환 import 감지 | 모듈 | import | 사이클 검출 (DFS) |
| 빌드 순서 정하기 | 태스크 | 선행 관계 | 위상 정렬 |
| 친구 추천 | 사용자 | 친구 | BFS 2단계 |
| 최소 환승 경로 | 역 | 노선 | BFS |
| 최단 시간 경로 | 지점 | 이동 시간 | 다익스트라 |
| 중복 계정 병합 | 계정 | 같음 관계 | 유니온 파인드 |
| 네트워크 최소 비용 연결 | 서버 | 회선 비용 | MST |
| 데드락 감지 | 스레드 | 대기 관계 | 사이클 검출 |
실무에선 이렇게 — 순환 의존성 잡아내기 🔴 실무
DFS 로 사이클 찾기 (30줄)
WHITE, GRAY, BLACK = 0, 1, 2 # 안 봄 / 보는 중 / 다 봄
def find_cycle(graph):
color = {v: WHITE for v in graph}
stack = [] # 지금 내려온 경로
def dfs(v):
color[v] = GRAY
stack.append(v)
for nxt in graph.get(v, []):
if color.get(nxt, WHITE) == GRAY: # ★ 회색을 다시 만나면 사이클
i = stack.index(nxt)
return stack[i:] + [nxt] # 사이클 경로를 그대로 반환
if color.get(nxt, WHITE) == WHITE:
found = dfs(nxt)
if found: return found
color[v] = BLACK
stack.pop()
return None
for v in graph:
if color[v] == WHITE:
found = dfs(v)
if found: return found
return None
g = {"a": ["b"], "b": ["c"], "c": ["a"]}
print(find_cycle(g)) # ['a', 'b', 'c', 'a'] ← 어디가 문제인지까지 알려 준다
트리 · 신장 트리 — 사이클 없는 그래프의 특별함
폴더 · 조직도 · DOM · JSON · 파서의 AST · DB 인덱스. 전부 트리입니다. 트리는 그래프의 특수한 경우지만 사이클이 없다는 성질 하나가 엄청나게 많은 것을 보장해 줍니다.
트리의 정의와 성질
네 조건이 전부 같은 말입니다. 그래서 “간선이 n−1 개인데 연결됐다”만 확인하면 트리임이 보장됩니다. 이 성질이 MST 알고리즘의 종료 조건이 됩니다.
순회 — 순서에 따라 결과가 달라진다
# (+)
# / \
# (*) 3
# / \
# 1 2
def preorder(n): # 전위: 나 → 왼 → 오
if n: yield n.val; yield from preorder(n.l); yield from preorder(n.r)
# + * 1 2 3 ← 폴란드 표기법 · 디렉터리 트리 출력
def inorder(n): # 중위: 왼 → 나 → 오
if n: yield from inorder(n.l); yield n.val; yield from inorder(n.r)
# 1 * 2 + 3 ← 이진 탐색 트리면 '정렬된 순서'가 나온다
def postorder(n): # 후위: 왼 → 오 → 나
if n: yield from postorder(n.l); yield from postorder(n.r); yield n.val
# 1 2 * 3 + ← 계산 순서 · 폴더 용량 합산 · 소멸 순서
이렇게도 쓸 수 있다 — 트리를 테이블에 저장하는 네 가지 방법 🟡 중급
댓글 · 카테고리 · 조직도를 DB 에 넣을 때
| 방식 | 구조 | 장점 | 단점 |
|---|---|---|---|
| 인접 리스트 | parent_id 컬럼 | 가장 단순 · 이동 쉬움 | 깊은 조회에 재귀 필요 |
| 경로 열거 | /1/5/12/ | 하위 전체 조회가 LIKE 한 번 | 이동 시 전부 갱신 |
| 중첩 집합 | lft, rgt 번호 | 하위 조회 매우 빠름 | 삽입마다 대량 갱신 |
| 클로저 테이블 | 모든 조상-자손 쌍 저장 | 모든 질의가 빠름 | 행 수가 많음 |
-- 인접 리스트 + 재귀 CTE (현대 DB 의 기본 선택)
WITH RECURSIVE sub AS (
SELECT id, parent_id, name, 1 AS depth
FROM category WHERE id = 5 -- 시작점
UNION ALL
SELECT c.id, c.parent_id, c.name, sub.depth + 1
FROM category c JOIN sub ON c.parent_id = sub.id
)
SELECT * FROM sub ORDER BY depth;
-- 경로 열거 (읽기가 압도적으로 많을 때)
SELECT * FROM category WHERE path LIKE '/1/5/%'; -- 접두사 LIKE 라 인덱스 탐색 가능
parent_id + 재귀 CTE 로 시작하고,
측정해서 느리면 경로 열거나 클로저 테이블로 옮깁니다.
처음부터 중첩 집합을 고르는 것은 대개 과설계입니다 →
🗄️ Database 가이드에 실제 쿼리가 있습니다.
실무에선 이렇게 — 신장 트리 · 최소 신장 트리 🔴 실무
모두를 가장 싸게 연결하기
연결된 그래프에서 모든 정점을 포함하면서 사이클이 없는 부분 그래프가 신장 트리이고, 그중 간선 가중치 합이 최소인 것이 MST입니다. 간선은 항상 n−1 개입니다.
| 쓰이는 곳 | 정점 · 간선 |
|---|---|
| 네트워크 회선 설계 | 거점 · 설치 비용 |
| 클러스터링 (단일 연결) | 데이터 점 · 거리 |
| 이미지 분할 | 픽셀 · 색 차이 |
| 스위칭 루프 방지 (STP) | 스위치 · 링크 |
크루스칼 — 사이클만 피하면 된다
def kruskal(n, edges): # edges: (비용, u, v)
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # 경로 압축
x = parent[x]
return x
mst, cost = [], 0
for w, u, v in sorted(edges): # ① 싼 것부터
ru, rv = find(u), find(v)
if ru == rv: # ② 이미 연결됨 → 사이클
continue
parent[ru] = rv # ③ 합친다
mst.append((u, v, w)); cost += w
if len(mst) == n - 1: break # ④ n-1 개면 끝
return mst, cost
find/union 이
🗂️ 자료구조 11 유니온 파인드입니다.
정수론 — 나머지와 최대공약수가 암호의 바닥입니다
중학교에서 배운 최대공약수가 RSA 키 생성에 그대로 쓰입니다. 학교에서는 “왜 배우나” 싶던 것들이 여기서 전부 회수됩니다.
유클리드 호제법 — 2300년 된 알고리즘
def gcd(a, b):
while b:
a, b = b, a % b # 큰 수를 나머지로 계속 줄인다
return a
gcd(1071, 462)
# 1071 % 462 = 147
# 462 % 147 = 21
# 147 % 21 = 0 → 답 21
# 왜 맞나: gcd(a,b) == gcd(b, a mod b) 이기 때문.
# a 의 약수이면서 b 의 약수인 수는, b 와 (a mod b) 의 공약수이기도 하다.
def lcm(a, b):
return a * b // gcd(a, b) # 최소공배수는 여기서 유도
소수 — 에라토스테네스의 체
def sieve(n):
is_p = [True] * (n + 1)
is_p[0] = is_p[1] = False
i = 2
while i * i <= n: # √n 까지만 보면 된다
if is_p[i]:
for j in range(i * i, n + 1, i): # i*i 부터 시작 (그 앞은 이미 지워짐)
is_p[j] = False
i += 1
return [i for i, p in enumerate(is_p) if p]
len(sieve(1_000_000)) # 78,498 개 · 0.1초 정도
# 하나만 판정할 때
def is_prime(n):
if n < 2: return False
if n % 2 == 0: return n == 2
f = 3
while f * f <= n:
if n % f == 0: return False
f += 2
return True
이렇게도 쓸 수 있다 — 확장 유클리드와 모듈러 역원 🟡 중급
ax + by = gcd(a,b) 를 만족하는 x, y
def egcd(a, b):
if b == 0: return a, 1, 0
g, x1, y1 = egcd(b, a % b)
return g, y1, x1 - (a // b) * y1
g, x, y = egcd(240, 46)
# g=2, x=-9, y=47 → 240×(-9) + 46×47 = 2 ✓
# 이걸로 '모듈러 역원'을 구한다 — 나눗셈의 대역
def modinv(a, m):
g, x, _ = egcd(a, m)
if g != 1:
raise ValueError("역원 없음 — a 와 m 이 서로소여야 한다")
return x % m
modinv(3, 11) # 4 (3 × 4 = 12 ≡ 1 mod 11)
이게 왜 필요한가
모듈러 세계에는 나눗셈이 없습니다. 대신 “곱해서 1 이 되는 수”(역원)를 곱합니다. RSA 의 개인키 d 는 정확히 공개키 e 의 모듈러 역원이고, 그것을 구하는 방법이 위 코드입니다.
실무에선 이렇게 — 코드에서 만나는 정수론 🔴 실무
생각보다 자주 씁니다
# ① 화면 비율 줄이기
w, h = 1920, 1080
g = gcd(w, h); print(f"{w//g}:{h//g}") # 16:9
# ② 여러 주기가 겹치는 시점 (스케줄 충돌)
lcm(15, 20) # 60분마다 두 작업이 동시에 실행된다
# ③ 순환 큐 · 링 버퍼 (모듈러)
idx = (idx + 1) % size
# ④ 균등하게 분산되는 해시 스텝 (서로소여야 전체를 순회)
# size 와 step 이 서로소면 size 번 만에 모든 슬롯을 방문한다
step = 7 if gcd(7, size) == 1 else 1
# ⑤ 체크 디지트 (카드번호 Luhn · ISBN)
def luhn_ok(num: str) -> bool:
total, alt = 0, False
for ch in reversed(num):
d = int(ch)
if alt:
d *= 2
if d > 9: d -= 9
total += d; alt = not alt
return total % 10 == 0
luhn_ok("4539578763621486") # True
모듈러 산술 — 시계처럼 도는 셈이 해시와 암호를 만듭니다
hash % bucket_count. 이 한 줄이 해시 테이블의 전부이고,
같은 연산을 아주 큰 수로 하면 RSA 가 됩니다.
왜 나머지가 그렇게 유용한가를 봅니다.
합동 — “나머지가 같다”
# 합동은 덧셈·뺄셈·곱셈에 대해 잘 작동한다
(a + b) % m == ((a % m) + (b % m)) % m
(a * b) % m == ((a % m) * (b % m)) % m
# ★ 나눗셈은 안 된다 — 역원을 곱해야 한다 (09 참고)
# 그래서 큰 수 계산이 가능해진다
# 2^1000 을 통째로 구하지 않고도 나머지를 안다
pow(2, 1000, 1_000_000_007) # 파이썬 내장 · 순식간
음수 나머지 — 언어마다 다릅니다
-7 % 3 의 결과
파이썬 · 루비 → 2 (부호가 나누는 수를 따름)
C · 자바 · JS · Go → -1 (부호가 나뉘는 수를 따름)
러스트 rem_euclid → 2
★ 배열 인덱스 계산에서 이것 때문에 터진다
idx = (i - 1) % n // i=0 이면 -1 → 자바는 예외, JS 는 undefined
idx = ((i - 1) % n + n) % n // 항상 안전
이렇게도 쓸 수 있다 — 해시 분산 · 샤딩 🟡 중급
버킷 개수는 왜 소수가 좋은가
# 데이터에 규칙이 있을 때 (예: 전부 10의 배수 ID)
keys = [10, 20, 30, 40, 50, 60, 70, 80]
[k % 10 for k in keys] # [0,0,0,0,0,0,0,0] ← 전부 한 버킷!
[k % 7 for k in keys] # [3,6,2,5,1,4,0,3] ← 잘 퍼진다
# 버킷 수가 데이터의 주기와 공약수를 가지면 뭉친다.
# 소수는 공약수가 생길 일이 거의 없다.
# ★ 단, 2의 거듭제곱을 쓰는 구현도 많다 (% 대신 & 비트마스크가 빠르므로).
# 그때는 해시 함수 쪽에서 섞어 준다 —
# 자바 HashMap: h ^= (h >>> 16) ← 상위 비트를 하위로 섞음
단순 모듈러 샤딩의 함정
# 서버 4대에 분산
server = hash(key) % 4
# 서버를 5대로 늘리면?
server = hash(key) % 5
# → 거의 모든 키의 위치가 바뀐다 (약 80% 이동)
# 캐시라면 전부 미스, DB 라면 대규모 마이그레이션
# 해결: 일관된 해싱 (consistent hashing)
# · 해시 공간을 원으로 보고 서버를 그 위에 배치
# · 서버 하나 추가 → 이동하는 키는 1/N 뿐
# · Redis Cluster · Cassandra · CDN 이 전부 이 방식
% N 으로 샤딩해 두면 확장할 때 서비스를 멈춰야 합니다.
🕸️ 분산 · 동시성 탭 10에서 일관된 해싱을 자세히 봅니다.
실무에선 이렇게 — 거듭제곱과 RSA 의 원리 🔴 실무
빠른 거듭제곱 — O(log n)
def pow_mod(base, exp, mod):
result = 1
base %= mod
while exp > 0:
if exp & 1: # 지수의 비트가 1이면 곱한다
result = result * base % mod
base = base * base % mod # 제곱해서 올라간다
exp >>= 1
return result
# 2^1000 을 1000번 곱하지 않고 10번만에 (log₂1000 ≈ 10)
pow_mod(2, 1000, 1_000_000_007)
# ── RSA 가 하는 일은 이것뿐이다 ──
# 암호화: c = m^e mod n
# 복호화: m = c^d mod n
# 안전한 이유: n = p×q 에서 p·q 를 알아내는 것(소인수분해)이
# 현재 알려진 방법으로는 현실적인 시간 안에 불가능하기 때문
# 아주 작은 수로 직접 확인해 보기 (실제로는 2048비트 이상을 쓴다)
p, q = 61, 53
n = p * q # 3233
phi = (p - 1) * (q - 1) # 3120
e = 17 # phi 와 서로소인 공개 지수
d = modinv(e, phi) # 2753 ← 09 의 확장 유클리드
m = 65 # 평문
c = pow(m, e, n) # 2790 암호문
print(pow(c, d, n)) # 65 ← 복원됨 ✓
부울 대수 — 비트마스크와 회로가 같은 수학입니다
권한 플래그 · 옵션 조합 · 상태 비트. 여러 개의 참/거짓을 정수 하나에 담는 기술은 전부 부울 대수입니다. CPU 안의 회로도 같은 규칙으로 움직입니다.
비트마스크 관용구
READ, WRITE, EXEC, ADMIN = 1, 2, 4, 8 # 1, 10, 100, 1000 (2진)
perm = READ | WRITE # 켜기 → 0011
perm |= EXEC # 추가 → 0111
perm &= ~WRITE # 끄기 → 0101
perm ^= EXEC # 토글 → 0001
has_read = bool(perm & READ) # 확인
# 여러 개 한 번에
need = READ | EXEC
print(perm & need == need) # 둘 다 있나?
print(perm & need != 0) # 하나라도 있나?
# 켜진 비트 세기
bin(perm).count("1") # 파이썬
# int.bit_count() (3.10+) · Integer.bitCount() · popcount 명령
부울 대수의 법칙 — 논리와 같은 모양
| 법칙 | 논리 | 비트 |
|---|---|---|
| 교환 | p∧q = q∧p | a&b = b&a |
| 분배 | p∧(q∨r) | a&(b|c) = (a&b)|(a&c) |
| 드모르간 | ¬(p∧q) | ~(a&b) = ~a|~b |
| 흡수 | p∨(p∧q) = p | a|(a&b) = a |
| 보수 | p∧¬p = F | a&~a = 0 |
# XOR 의 유용한 성질: a ^ b ^ b == a
# → 임시 변수 없이 교환 (실무에서는 쓰지 말 것 · 가독성)
a ^= b; b ^= a; a ^= b
# 실제로 유용한 XOR 활용
# ① 짝이 안 맞는 하나 찾기 — 나머지는 전부 상쇄된다
def find_single(nums):
r = 0
for n in nums: r ^= n
return r
# ② 체크섬 · 패리티 · RAID 복구가 전부 XOR
이렇게도 쓸 수 있다 — 논리식 최소화 · 카르노맵 🟡 중급
같은 논리, 더 짧은 식
원래 식: (A∧B∧C) ∨ (A∧B∧¬C) ∨ (A∧¬B∧C)
A B C │ 결과 카르노맵 (BC 를 가로, A 를 세로)
──────┼───── BC: 00 01 11 10
1 1 1 │ 1 A=0: 0 0 0 0
1 1 0 │ 1 A=1: 0 1 1 1
1 0 1 │ 1
나머지│ 0 → 인접한 1 을 묶는다
A∧B (11,10 열) + A∧C (01,11 열)
최소화 결과: A ∧ (B ∨ C) ← 항이 3개에서 1개로
코드 리뷰에서 바로 쓰는 형태
✗ 중복된 조건
if ((isAdmin && canEdit && isOwner) ||
(isAdmin && canEdit && !isOwner) ||
(isAdmin && !canEdit && isOwner)) {
allow();
}
✓ 최소화
if (isAdmin && (canEdit || isOwner)) {
allow();
}
실무에선 이렇게 — 비트 트릭과 그 위험 🔴 실무
알아 두면 좋은 것 / 쓰면 안 되는 것
| 트릭 | 의미 | 실무 판단 |
|---|---|---|
x & (x-1) | 가장 낮은 1 비트 제거 | 비트 세기에 유용 |
x & -x | 가장 낮은 1 비트만 남김 | 세그먼트 트리 등 |
x & (x-1) == 0 | 2의 거듭제곱인가 | 자주 씀 |
x >> 1 로 나누기 | 2로 나눔 | 컴파일러가 이미 함 · 쓰지 말 것 |
| XOR 스왑 | 임시변수 없이 교환 | 더 느리고 안 읽힘 · 금지 |
x ^ ((x^y) & -(x<y)) | 분기 없는 min | 측정 후에만 |
# 실무에서 실제로 쓰는 두 가지
def is_power_of_two(x):
return x > 0 and (x & (x - 1)) == 0
def align_up(x, a): # a 는 2의 거듭제곱
return (x + a - 1) & ~(a - 1) # 메모리 정렬 · 페이지 경계 계산
align_up(4097, 4096) # 8192
x / 2 를 이미
시프트로 바꿉니다. 사람이 손으로 하는 비트 트릭은 대개 손해입니다.
쓸 자리는 의미가 비트인 곳(플래그 · 정렬 · 마스크)뿐입니다.
성능 목적이라면 반드시 측정 후에 → 🖥️ 컴퓨터구조 탭 02.
점화식과 마스터 정리 — 재귀 복잡도를 3초에
“병합 정렬은 O(n log n)”을 외우는 대신 유도하는 법입니다. 마스터 정리는 분할 정복 알고리즘의 복잡도를 표 한 번 보고 답하게 해 줍니다.
점화식 세우기
def merge_sort(a):
if len(a) <= 1: return a # T(1) = O(1)
mid = len(a) // 2
l = merge_sort(a[:mid]) # T(n/2)
r = merge_sort(a[mid:]) # T(n/2)
return merge(l, r) # O(n)
# 코드를 그대로 식으로 옮기면
# T(n) = 2·T(n/2) + O(n)
# ↑ ↑ ↑
# 몇 번 얼마로 나머지 일
# 부르나 줄여서
마스터 정리 — 세 경우만 보면 됩니다
② f 가 nc 와 같다 → Θ(nc log n)
③ f 가 nc 보다 크다 → Θ(f(n))
| 점화식 | c | 결과 |
|---|---|---|
| T(n)=2T(n/2)+n (병합정렬) | 1 | Θ(n log n) |
| T(n)=T(n/2)+1 (이진탐색) | 0 | Θ(log n) |
| T(n)=2T(n/2)+1 | 1 | Θ(n) |
| T(n)=8T(n/2)+n² (행렬곱) | 3 | Θ(n³) |
| T(n)=7T(n/2)+n² (스트라센) | 2.81 | Θ(n2.81) |
이렇게도 쓸 수 있다 — 재귀 트리로 직관 얻기 🟡 중급
왜 log n 층인가
T(n) = 2T(n/2) + n
층 호출 수 각 호출의 일 그 층의 총합
────────────────────────────────────────────
0 1 n n
1 2 n/2 n
2 4 n/4 n
...
log n n 1 n
────────────────────────────────────────────
총합 = n × log n
★ 핵심은 두 가지
· n 을 2로 계속 나눠 1이 될 때까지 → log₂n 층
· 각 층의 총합이 항상 n → 층 수 × n
한 개만 재귀하면 (이진 탐색)
T(n) = T(n/2) + 1
각 층에 호출이 1개뿐 → 총 log n 번
→ Θ(log n)
10억 개에서도 30번이면 끝난다
1씩 줄이면 (순진한 피보나치)
T(n) = T(n-1) + T(n-2) + 1
층마다 호출이 2배씩 → 2ⁿ
→ Θ(1.618ⁿ) 황금비
n=50 이면 이미 몇 분,
n=100 이면 우주가 끝난다
n/2 로 줄이면 log 층, n-1 로 줄이면 n 층입니다.
알고리즘을 설계할 때 이 하나만 봐도 될지 안 될지가 갈립니다.
실무에선 이렇게 — 마스터 정리로 안 되는 경우 🔴 실무
적용할 수 없는 형태
① 부분 문제 크기가 다를 때
T(n) = T(n/3) + T(2n/3) + n
→ 재귀 트리를 직접 그린다 (결과: Θ(n log n))
② 재귀 호출 수가 n 에 의존
T(n) = n·T(n/2) + n
→ 마스터 정리 밖
③ f(n) 이 다항식이 아닐 때
T(n) = 2T(n/2) + n/log n
→ 아카라-바자이 정리 또는 직접 계산
④ 뺄셈형
T(n) = T(n-1) + n → Θ(n²)
T(n) = 2T(n-1) + 1 → Θ(2ⁿ)
실무에서 진짜로 쓰는 순서
- 점화식을 세운다 — 코드에서 “몇 번 부르나 / 얼마로 줄이나 / 나머지 일”만 읽습니다.
- 표에서 찾는다 — 위 다섯 개 안에 대부분 들어갑니다.
- 안 맞으면 재귀 트리 — 3층만 그려도 패턴이 보입니다.
- 실측으로 확인 — n 을 10배 늘려 시간이 몇 배가 되는지 봅니다. 10배 → 10배면 O(n), 100배면 O(n²), 12배쯤이면 O(n log n).
치트시트 — 기호와 공식을 한 장에
논문 · RFC · 라이브러리 문서에서 만나는 기호들입니다. 읽을 수만 있으면 건너뛰지 않게 됩니다.
기호 읽는 법
| 기호 | 읽기 | 뜻 |
|---|---|---|
| ∀ | 모든 | for all |
| ∃ | 존재한다 | there exists |
| ∈ / ∉ | 원소이다 | x in s |
| ⊆ / ⊂ | 부분집합 | a <= b |
| ∪ / ∩ | 합/교집합 | | / & |
| ∧ / ∨ / ¬ | 그리고/또는/아니다 | && || ! |
| ⇒ / ⟺ | 함의 / 동치 | if / iff |
| Σ / Π | 합 / 곱 | sum() / prod() |
| ⌊x⌋ / ⌈x⌉ | 내림 / 올림 | // / ceil |
| ≡ (mod m) | 합동 | 나머지가 같다 |
| |A| | 크기 | len(a) |
| O · Ω · Θ | 상한/하한/정확 | 복잡도 |
외워 두면 좋은 값
| 식 | 값 / 근사 |
|---|---|
| log₂ 1,000 | ≈ 10 |
| log₂ 1,000,000 | ≈ 20 |
| log₂ 10억 | ≈ 30 |
| 2¹⁰ / 2²⁰ / 2³⁰ | 1K / 1M / 1G |
| 1 + 2 + … + n | n(n+1)/2 |
| 1 + 2 + 4 + … + 2ⁿ | 2n+1 − 1 |
| n! | 10! ≈ 360만 · 20! ≈ 2.4×10¹⁸ |
| C(n,2) | n(n−1)/2 — 모든 쌍의 수 |
| 조화수 H(n) | ≈ ln n + 0.577 |
이렇게도 쓸 수 있다 — 규모 감각 표 🟡 중급
n 이 이만큼일 때 무엇이 가능한가
| n | 가능한 복잡도 | 실제 예 |
|---|---|---|
| ≤ 10 | O(n!) 전순열 | 외판원 완전 탐색 |
| ≤ 25 | O(2ⁿ) 부분집합 | 비트마스크 DP |
| ≤ 500 | O(n³) | 플로이드-워셜 |
| ≤ 5,000 | O(n²) | 모든 쌍 비교 · DP |
| ≤ 100만 | O(n log n) | 정렬 · 세그먼트 트리 |
| ≤ 1000만 | O(n) | 한 번 훑기 |
| 10억 이상 | O(log n) · O(1) | 이진 탐색 · 해시 · 수식 |
실무에선 이렇게 — 더 볼 곳 🔴 실무
이 탭에서 이어지는 곳
| 여기서 배운 것 | 다음 |
|---|---|
| 02 논리 · 11 부울 대수 | 🖥️ 컴퓨터구조 05 논리 게이트 → CPU |
| 04 집합 · 관계 | 🗄️ Database 가이드 정규화 · JOIN |
| 05 귀납법 · 12 점화식 | 🧮 계산이론 07 빅오 · 🔀 알고리즘 08 분할 정복 |
| 06 조합론 | 🗂️ 자료구조 04 해시 · 🔐 보안 03 비밀번호 |
| 07 그래프 · 08 트리 | 🔀 알고리즘 04~06 · 🗂️ 자료구조 08 B-트리 |
| 09 정수론 · 10 모듈러 | 🔐 보안 · 암호 05 RSA · ECC |
벡터와 확률을 모르면
임베딩도 p99도
말로만 하게 됩니다
“코사인 유사도로 검색한다”, “p99 응답 시간이 튀었다”, “A/B 테스트에서 유의했다”. 매일 쓰는 말인데 정확히 무슨 뜻인지는 넘어가기 쉽습니다. 이 탭은 선형대수와 확률·통계를 실제로 마주치는 자리에서만 골라 정리하고, 마지막에는 모든 계산의 바닥인 부동소수점까지 내려갑니다.
벡터 — 배열에 방향을 붙이면 생기는 일
벡터는 [0.2, -0.7, 1.3] 같은 숫자 배열입니다.
다른 점은 이 배열들끼리 더하고 재고 비교할 수 있다는 것뿐인데,
그 하나로 검색·추천·이미지 처리가 전부 돌아갑니다.
기본 연산
import numpy as np
a = np.array([3, 4])
b = np.array([1, 2])
a + b # [4, 6] 방향을 이어 붙임
a - b # [2, 2] b 에서 a 로 가는 방향
3 * a # [9, 12] 크기만 3배 (방향 유지)
np.linalg.norm(a) # 5.0 길이(노름) = √(3² + 4²)
a / np.linalg.norm(a) # [0.6, 0.8] 단위벡터 — 방향만 남김
실무에서 벡터로 표현하는 것들
| 대상 | 벡터 |
|---|---|
| 화면 좌표 | [x, y] |
| 색 | [r, g, b, a] |
| 사용자 취향 | 장르별 점수 [액션 0.8, 로맨스 0.1, …] |
| 문장 (임베딩) | 768 ~ 3072 차원 실수 |
| 이미지 | 픽셀을 쭉 편 배열 |
| 서버 상태 | [cpu, mem, qps, p99] |
이렇게도 쓸 수 있다 — 노름 — 거리 재는 방법이 여럿 🟡 중급
L1 · L2 · L∞
v = np.array([3, -4])
np.linalg.norm(v, 1) # 7.0 |3| + |-4|
np.linalg.norm(v, 2) # 5.0 √(9+16)
np.linalg.norm(v, np.inf) # 4.0 max
# 어디에 쓰나
# L1 : 이상치에 덜 민감 · 희소성 유도 (Lasso 규제)
# L2 : 기본값 · 미분이 매끄러움 (Ridge · 최소제곱)
# L∞ : 최악의 오차 하나만 볼 때 (SLA 위반 판정)
코드에서 실제로 만나는 자리
# ① 두 설정값 집합이 얼마나 다른가
diff = np.linalg.norm(new_cfg - old_cfg, 1)
# ② 이미지 두 장의 차이 (픽셀 평균 제곱 오차)
mse = np.mean((img1 - img2) ** 2)
# ③ 모니터링: 정상 상태 벡터에서 얼마나 벗어났나
baseline = np.array([0.3, 0.5, 1200, 45]) # cpu, mem, qps, p99
now = np.array([0.9, 0.6, 1100, 320])
z = (now - baseline) / baseline # 상대 변화
np.linalg.norm(z, np.inf) # 가장 크게 튄 지표 하나
실무에선 이렇게 — 차원의 저주 🔴 실무
차원이 높아지면 거리가 의미를 잃습니다
import numpy as np
rng = np.random.default_rng(0)
for d in (2, 10, 100, 1000):
pts = rng.random((500, d))
# 무작위 점들 사이 거리의 최소 · 최대 비율
from scipy.spatial.distance import pdist
dist = pdist(pts)
print(d, round(dist.min() / dist.max(), 3))
# 2 0.002 → 가깝고 먼 것이 뚜렷이 구분됨
# 10 0.119
# 100 0.6
# 1000 0.845 → 거의 모든 점이 '비슷하게 멀다'
| 결과 | 대응 |
|---|---|
| 거리 기반 검색이 잘 안 됨 | 차원 축소(PCA) → 05 |
| k-NN 이 무작위처럼 동작 | 코사인 유사도로 전환 → 06 |
| 인덱스가 전수 탐색으로 퇴화 | 근사 최근접(HNSW · IVF) |
| 필요 데이터가 지수적으로 증가 | 특징 선택 · 임베딩 차원 축소 |
행렬 · 곱셈 — “여러 변환을 한 번에”의 정체
행렬 곱셈 규칙이 왜 그렇게 이상하게 생겼는지는 “변환을 이어 붙인 것”이라고 보면 한 번에 풀립니다. 이미지 필터도 신경망 한 층도 결국 이 연산 하나입니다.
곱셈의 규칙과 의미
A = np.array([[1, 2],
[3, 4]])
v = np.array([5, 6])
A @ v # [17, 39] ← 벡터를 변환
A @ A # 변환을 두 번 적용한 것과 같은 하나의 변환
# 결과의 [i][j] = A 의 i 행과 B 의 j 열의 내적
# "i 번째 출력은 입력 전체에 가중치를 곱해 더한 것"
A.T # 전치 — 행과 열을 바꿈
np.eye(2) # 항등행렬 — 곱해도 그대로 (숫자의 1 에 해당)
A @ B ≠ B @ A.
“회전한 뒤 확대”와 “확대한 뒤 회전”은 다른 결과입니다.
3D 변환에서 순서를 틀리는 버그가 여기서 나옵니다.
연산량 — 왜 GPU 가 필요한가
import time
n = 1000
A = np.random.rand(n, n); B = np.random.rand(n, n)
t = time.perf_counter()
C = A @ B # numpy → BLAS(멀티스레드 + SIMD)
print(time.perf_counter() - t) # 약 0.03초
# 같은 것을 순수 파이썬 3중 반복문으로 하면 수 분.
# 차이는 알고리즘이 아니라 '어디서 도느냐'다.
# · numpy: C 로 짜인 BLAS + CPU 벡터 명령 + 캐시 최적화
# · GPU : 수천 코어가 동시에
이렇게도 쓸 수 있다 — 코드에서 만나는 행렬 🟡 중급
이미지 필터 = 작은 행렬
# 3×3 커널을 이미지 위로 밀면서 곱해 더한다 (컨볼루션)
blur = np.ones((3,3)) / 9 # 흐리게
sharpen = np.array([[ 0, -1, 0],
[-1, 5, -1],
[ 0, -1, 0]]) # 선명하게
edge = np.array([[-1, -1, -1],
[-1, 8, -1],
[-1, -1, -1]]) # 윤곽선
# 포토샵의 필터 · CSS 의 filter · CNN 의 첫 층이
# 전부 이 3×3 숫자 아홉 개다.
배치 처리 — 반복문을 행렬로
✗ 한 건씩 (느림)
scores = []
for user in users: # 10만 번
s = sum(u * w for u, w
in zip(user, weights))
scores.append(s)
✓ 한 번에
U = np.array(users) # (10만, 64)
w = np.array(weights) # (64,)
scores = U @ w # (10만,)
# 수백 배 빠르다
실무에선 이렇게 — 희소 행렬 🔴 실무
대부분이 0 이면 통째로 들고 있으면 안 됩니다
from scipy.sparse import csr_matrix
# 사용자 10만 × 상품 5만 평점 행렬
# 빽빽하게 저장하면 10만 × 5만 × 8바이트 = 40GB (불가능)
# 실제로 평점이 있는 칸은 0.1% 도 안 된다
rows = [0, 0, 1, 2] # 사용자 인덱스
cols = [3, 7, 2, 9] # 상품 인덱스
vals = [5, 3, 4, 2] # 평점
M = csr_matrix((vals, (rows, cols)), shape=(100_000, 50_000))
M.nnz # 0 이 아닌 칸 수만 저장
M @ M.T # 희소 곱셈도 그대로 된다
# 어디에 쓰나
# · 추천 시스템 (사용자 × 상품)
# · 텍스트 TF-IDF (문서 × 단어)
# · 그래프 인접 행렬 (정점 × 정점)
| 형식 | 강점 | 쓸 때 |
|---|---|---|
| CSR | 행 단위 접근 · 곱셈 | 기본 선택 |
| CSC | 열 단위 접근 | 열 슬라이싱이 많을 때 |
| COO | 만들기 쉬움 | 조립 후 CSR 로 변환 |
| LIL / DOK | 하나씩 수정 | 구축 단계에만 |
.toarray() 를 무심코 호출해
메모리를 통째로 터뜨리는 것입니다. 디버깅 중 print() 한 번에
40GB 를 요청하게 됩니다.
선형변환 · 기저 — “좌표계를 바꾼다”의 뜻
회전 · 확대 · 전단은 전부 같은 종류의 연산입니다. 그리고 차원 축소도 사실 “좋은 좌표계로 옮긴 뒤 뒤쪽을 버리는 것”입니다.
2D 변환 행렬
import numpy as np
def rotate(deg):
t = np.radians(deg)
return np.array([[np.cos(t), -np.sin(t)],
[np.sin(t), np.cos(t)]])
scale = np.array([[2, 0],
[0, 3]]) # x 2배, y 3배
shear = np.array([[1, 0.5],
[0, 1 ]]) # 기울이기
# 합성 — 오른쪽부터 적용된다
M = rotate(30) @ scale # 확대한 뒤 회전
p = np.array([1, 0])
M @ p
[[2,0],[0,3]] 은 “x축 단위벡터가 (2,0)으로, y축이 (0,3)으로 간다”는 뜻이고,
그게 확대의 전부입니다.
동차 좌표 — 이동까지 행렬로
# 이동(translation)은 선형변환이 아니다 — 원점이 안 움직여야 하므로.
# 그래서 차원을 하나 늘려 3×3 으로 만든다 (2D 그래픽스의 표준)
def translate(dx, dy):
return np.array([[1, 0, dx],
[0, 1, dy],
[0, 0, 1 ]])
p = np.array([1, 2, 1]) # 마지막 1 이 '점'이라는 표시
translate(3, 4) @ p # [4, 6, 1]
# 이래서 CSS transform · WebGL · OpenGL 이 전부 4×4 행렬을 쓴다.
# 회전·확대·이동·원근을 '곱셈 하나'로 합칠 수 있기 때문.
matrix3d(...) 에 숫자 16개가 들어가는 이유가 이것입니다.
4×4 행렬 하나로 모든 변환을 표현합니다.
이렇게도 쓸 수 있다 — 랭크 — 정보가 실제로 몇 차원인가 🟡 중급
랭크가 낮다 = 중복이 많다
A = np.array([[1, 2, 3],
[2, 4, 6], # 1행의 2배 — 새 정보 없음
[1, 1, 1]])
np.linalg.matrix_rank(A) # 2 (3이 아님)
# 실무 해석
# · 특징 3개를 넣었는데 랭크가 2 → 하나는 나머지로 계산 가능
# · 회귀 모델이 불안정해지는 원인 (다중공선성)
# · "키(cm)" 와 "키(m)" 를 둘 다 넣은 경우가 대표적
matrix_rank 가 열 개수보다 작으면
중복된 특징이 있다는 뜻입니다.
저랭크 근사 = 압축
# SVD 로 '중요한 방향'만 남긴다
U, S, Vt = np.linalg.svd(img, full_matrices=False)
k = 50 # 상위 50개 성분만
approx = U[:, :k] @ np.diag(S[:k]) @ Vt[:k]
# 1000×1000 이미지 = 100만 값
# k=50 이면 1000×50 + 50 + 50×1000 = 10만 값 (10분의 1)
# 눈으로는 거의 구분이 안 된다
# 같은 아이디어의 이름들
# · 이미지 압축 · 잠재 의미 분석(LSA) · 추천의 행렬 분해
# · LoRA (거대 모델을 저랭크 행렬로 미세조정)
실무에선 이렇게 — 좌표계 변환 디버깅 🔴 실무
그래픽·게임에서 “이상한 곳에 그려질 때”
| 증상 | 원인 |
|---|---|
| 물체가 원점 근처로 튐 | 이동 행렬을 먼저 곱함 (순서) |
| 회전하면 위치가 딸려 감 | 원점 기준 회전 — 중심으로 옮겼다 되돌려야 함 |
| y 축이 뒤집힘 | 화면 좌표(아래로 +)와 수학 좌표(위로 +) 차이 |
| 확대하면 찌그러짐 | 비균등 스케일 뒤 회전 (순서를 바꿔야) |
| 부모를 움직이면 자식이 어긋남 | 로컬/월드 좌표 변환 누락 |
# 중심 기준 회전의 정석 — 옮기고, 돌리고, 되돌린다
M = translate(cx, cy) @ rotate(deg) @ translate(-cx, -cy)
# 순서를 읽는 법: 오른쪽에 있는 것이 먼저 적용된다
# ① (-cx,-cy) 만큼 옮겨 중심을 원점으로
# ② 회전
# ③ 원래 자리로
행렬식 · 역행렬 — 되돌릴 수 있는가
역행렬은 “변환을 되돌리는 변환”입니다. 그리고 되돌릴 수 없는 경우가 언제인지를 알려 주는 값이 행렬식입니다.
행렬식이 뜻하는 것
np.linalg.det([[2, 0], [0, 3]]) # 6.0 면적 6배
np.linalg.det([[1, 2], [2, 4]]) # 0.0 2행이 1행의 2배 → 직선으로 눌림
np.linalg.det([[0,-1], [1, 0]]) # 1.0 회전 — 면적 그대로
# det < 0 이면 뒤집힘(거울상)이 포함된 것
연립방정식 풀기
# 2x + y = 5
# x + 3y = 10
A = np.array([[2, 1],
[1, 3]])
b = np.array([5, 10])
x = np.linalg.solve(A, b) # [1., 3.] ★ 이걸 쓴다
x = np.linalg.inv(A) @ b # 같은 답이지만 느리고 부정확 ✗
# 왜 solve 가 나은가
# · inv 는 내부적으로 더 많은 연산 → 오차가 더 쌓인다
# · solve 는 LU 분해로 바로 푼다 (약 3배 빠름)
이렇게도 쓸 수 있다 — 조건수 — 답이 얼마나 흔들리나 🟡 중급
det ≠ 0 인데도 위험한 경우
A = np.array([[1.0, 1.0],
[1.0, 1.0001]])
np.linalg.det(A) # 0.0001 — 0 은 아니다
np.linalg.cond(A) # 약 40,002 — 매우 나쁨
b1 = np.array([2.0, 2.0001])
b2 = np.array([2.0, 2.0002]) # 입력이 약 0.005% 달라짐
np.linalg.solve(A, b1) # [1., 1.]
np.linalg.solve(A, b2) # [0., 2.] ← 답이 완전히 다름!
최소제곱 — 방정식이 넘칠 때
# 관측이 100개인데 미지수가 3개 → 정확한 해는 없다
# "가장 덜 틀리는" 답을 구한다
X = np.column_stack([np.ones(len(x)), x, x**2]) # 1, x, x²
coef, residuals, rank, sv = np.linalg.lstsq(X, y, rcond=None)
# 이게 선형 회귀의 전부다.
# 머신러닝의 '학습'도 대부분 이 문제를 반복적으로 푸는 것.
# rank 가 열 개수보다 작으면 특징이 중복됐다는 신호 (03 참고)
실무에선 이렇게 — 가우스 소거를 직접 🔴 실무
피벗팅이 왜 필요한가
def solve_naive(A, b):
A = A.astype(float).copy(); b = b.astype(float).copy()
n = len(b)
for i in range(n):
# ★ 부분 피벗: 남은 행 중 절댓값이 가장 큰 것을 위로
p = max(range(i, n), key=lambda r: abs(A[r][i]))
A[[i, p]] = A[[p, i]]; b[i], b[p] = b[p], b[i]
for r in range(i + 1, n):
f = A[r][i] / A[i][i]
A[r, i:] -= f * A[i, i:]
b[r] -= f * b[i]
x = np.zeros(n) # 후진 대입
for i in reversed(range(n)):
x[i] = (b[i] - A[i, i+1:] @ x[i+1:]) / A[i][i]
return x
[[1e-20, 1], [1, 1]] 같은 행렬에서 답이 완전히 틀어집니다.
수학적으로는 필요 없지만 부동소수점에서는 필수인 단계이고,
그 이유는 12 · 13 절에서 이어집니다.
고윳값 · 고유벡터 — PageRank 와 PCA가 같은 계산입니다
“변환해도 방향이 안 바뀌는 축”을 찾는 문제입니다. 구글이 웹페이지 순위를 매긴 방법과 데이터 차원을 줄이는 방법이 놀랍게도 이 하나입니다.
정의는 한 줄
A = np.array([[2, 1],
[1, 2]])
vals, vecs = np.linalg.eig(A)
# vals: [3., 1.]
# vecs: 열이 고유벡터 — [0.707, 0.707], [-0.707, 0.707]
# 확인
v = vecs[:, 0]
A @ v # [2.12, 2.12]
3 * v # [2.12, 2.12] ← 같다 ✓
PageRank — 가장 유명한 응용
# "중요한 페이지가 링크한 페이지가 중요하다"
# → 이 순환 정의를 고유벡터로 푼다
def pagerank(M, d=0.85, iters=100):
n = M.shape[0]
r = np.ones(n) / n
for _ in range(iters):
r = (1 - d) / n + d * (M @ r) # 계속 곱하면 수렴
return r
# M[i][j] = j 가 i 를 링크할 확률
# 반복해서 곱하면 '가장 큰 고윳값의 고유벡터'로 수렴한다 (거듭제곱법)
# d = 감쇠 계수 — 링크가 없는 페이지에서 무작위 점프
이렇게도 쓸 수 있다 — PCA — 차원 축소의 원리 🟡 중급
데이터가 가장 많이 퍼진 방향을 찾는다
X = data - data.mean(axis=0) # ① 중심 이동 (필수)
C = np.cov(X, rowvar=False) # ② 공분산 행렬
vals, vecs = np.linalg.eigh(C) # ③ 고유분해 (대칭이므로 eigh)
order = np.argsort(vals)[::-1] # ④ 큰 고윳값부터
vecs, vals = vecs[:, order], vals[order]
k = 2
Z = X @ vecs[:, :k] # ⑤ 상위 k 방향으로 투영
# 설명된 분산 비율 — 얼마나 잃었나
print(vals[:k].sum() / vals.sum()) # 0.87 이면 87% 정보 보존
어디에 쓰나
| 시각화 | 100차원 → 2차원 산점도 |
| 노이즈 제거 | 작은 성분 버리기 |
| 저장 공간 | 임베딩 압축 |
| 속도 | 특징 수 감소 |
주의할 점
StandardScaler 를 먼저 적용합니다.
실무에선 이렇게 — 거듭제곱법과 수렴 🔴 실무
전체 고유분해 없이 가장 큰 것 하나만
def power_iteration(A, iters=1000, tol=1e-10):
v = np.random.rand(A.shape[0])
v /= np.linalg.norm(v)
for _ in range(iters):
w = A @ v
w_norm = np.linalg.norm(w)
if w_norm == 0: break
w /= w_norm
if np.linalg.norm(w - v) < tol: # 수렴
break
v = w
lam = v @ (A @ v) # 레일리 몫
return lam, v
# 왜 이걸 쓰나
# · 전체 고유분해는 O(n³) — 정점 100만 개 그래프에서는 불가능
# · 거듭제곱법은 곱셈만 반복 — 희소 행렬에서 O(간선 수)
# · 수렴 속도는 |λ₂/λ₁| 에 달려 있다 (두 번째가 가까우면 느림)
scipy.sparse.linalg.eigsh 가
더 정교한 방법(Lanczos)을 씁니다. 다만 “왜 반복하면 수렴하는가”를 알아 두면
PageRank 의 반복 횟수나 수렴 실패를 진단할 수 있습니다.
왜 반복하면 가장 큰 축만 남는가 — 거듭제곱법의 수학
임의의 벡터를 고유벡터로 분해해 보면
= λ1k [ c1v1 + c2(λ2/λ1)kv2 + ⋯ ]
|λ2/λ1| < 1 이므로 k 가 커지면 뒤 항들이 0 으로
∴ Akx0 의 방향 → v1 (최대 고유벡터)
수렴 속도를 정하는 것은 λ2/λ1 입니다 — 이 비율을 스펙트럴 갭이라 부릅니다. 두 고윳값이 비슷하면(갭이 작으면) 수렴이 아주 느려집니다. PageRank 가 “50번 반복하면 충분”한 이유도, 어떤 그래프에서 수렴이 안 되는 이유도 전부 이 값입니다.
import numpy as np
def power_iteration(A, iters=100, tol=1e-10):
x = np.random.rand(A.shape[0])
x /= np.linalg.norm(x)
for k in range(iters):
y = A @ x
y /= np.linalg.norm(y) # ★ 정규화 안 하면 발산·언더플로
if np.linalg.norm(y - x) < tol:
return y, k # 수렴한 반복 횟수도 함께
x = y
return x, iters
A = np.array([[2., 1.], [1., 3.]])
v, k = power_iteration(A)
lam = v @ A @ v # 레일리 몫 — 고윳값 추정
print(f"λ₁≈{lam:.4f} v₁≈{v.round(4)} 반복={k}")
print("정답:", np.linalg.eigvalsh(A)) # [1.382, 3.618]
같은 계산이 쓰이는 곳
| 분야 | 무엇이 고유벡터인가 |
|---|---|
| PageRank | 링크 전이 행렬의 정상 분포 — 무한히 서핑했을 때 각 페이지에 머무는 확률 |
| PCA | 공분산 행렬의 고유벡터 = 데이터가 가장 넓게 퍼진 축. 고윳값 = 그 축의 분산 |
| 추천 시스템 | 행렬 분해(SVD)의 잠재 요인 |
| 마르코프 체인 | λ=1 인 고유벡터 = 정상 상태 |
| 스펙트럴 클러스터링 | 라플라시안 행렬의 작은 고윳값 → 그래프를 자르는 선 |
| 진동·구조 해석 | 고유진동수 — 다리가 무너지는 그 주파수 |
# PCA 를 직접 — 고윳값이 "설명하는 분산"입니다
X = np.random.randn(200, 5) @ np.random.randn(5, 5) # 상관있는 데이터
Xc = X - X.mean(axis=0) # ★ 중심화 필수
C = (Xc.T @ Xc) / (len(Xc) - 1) # 공분산 행렬
lam, V = np.linalg.eigh(C) # 대칭행렬 → eigh (실수 고윳값 보장)
order = lam.argsort()[::-1] # 큰 순서로
lam, V = lam[order], V[:, order]
ratio = lam / lam.sum()
print("설명 분산 비율:", ratio.round(3))
print("누적:", ratio.cumsum().round(3))
# [0.62 0.25 0.09 0.03 0.01] → 앞 2개로 87% 설명
Z = Xc @ V[:, :2] # 2차원으로 축소
eigh 를 쓰세요.
공분산·라플라시안처럼 대칭인 행렬은
고윳값이 항상 실수이고 고유벡터가 서로 직교합니다(스펙트럼 정리).
eig 를 쓰면 수치 오차로 허수부가 섞여 나올 수 있어
정렬·시각화에서 문제가 생깁니다.
내적 · 코사인 유사도 — 검색과 추천의 심장
벡터 검색 · RAG · 추천 · 중복 문서 탐지. 전부 “두 벡터가 얼마나 같은 방향인가” 하나로 돌아갑니다. 계산은 곱해서 더하는 것뿐입니다.
내적 — 곱해서 더하기
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
a @ b # 32 = 1*4 + 2*5 + 3*6
np.dot(a, b) # 같은 것
# 직각 확인
np.array([1,0]) @ np.array([0,1]) # 0 ← 완전히 무관
코사인 유사도 — 길이를 빼고 방향만
def cosine(a, b):
return (a @ b) / (np.linalg.norm(a) * np.linalg.norm(b))
# 1.0 : 완전히 같은 방향
# 0.0 : 무관
# -1.0 : 정반대
# ★ 미리 정규화해 두면 내적 = 코사인 유사도
A = A / np.linalg.norm(A, axis=1, keepdims=True)
sims = A @ q # 10만 개와 한 번에 비교
top = np.argpartition(-sims, 10)[:10] # 상위 10개 (정렬보다 빠름)
top = top[np.argsort(-sims[top])]
이렇게도 쓸 수 있다 — 유사도 척도 고르기 🟡 중급
상황에 따라 답이 다릅니다
| 척도 | 계산 | 언제 쓰나 |
|---|---|---|
| 코사인 | 방향만 | 텍스트 임베딩 · 기본값 |
| 내적 (dot) | 방향 + 크기 | 크기가 “인기도”를 뜻할 때 |
| 유클리드 (L2) | 직선 거리 | 좌표 · 이미지 특징 |
| 맨해튼 (L1) | 축별 합 | 이상치가 많을 때 |
| 자카드 | 교집합/합집합 | 집합 · 태그 · 중복 탐지 |
| 해밍 | 다른 비트 수 | 해시 · 이진 특징 |
# 자카드 — 집합에는 이쪽이 맞다
def jaccard(a: set, b: set):
return len(a & b) / len(a | b) if a | b else 0.0
jaccard({"파이썬","웹","api"}, {"파이썬","api","db"}) # 0.5
# 실전: 중복 문서 탐지는 MinHash + LSH 로 근사한다
# (모든 쌍을 비교하면 O(n²) — 100만 문서면 5000억 번)
실무에선 이렇게 — 검색 품질이 나쁠 때 🔴 실무
벡터 검색 문제 진단표
| 증상 | 원인 · 대응 |
|---|---|
| 전부 비슷한 점수 (0.8~0.9) | 정상입니다. 절대값이 아니라 순위를 보세요 |
| 엉뚱한 문서가 1등 | 청크가 너무 큼 → 문단 단위로 쪼개기 |
| 정확한 키워드가 안 걸림 | 벡터만으로 부족 → BM25 와 하이브리드 |
| 짧은 문서가 항상 밀림 | 정규화 확인 · 길이 보정 |
| 같은 문서가 여러 번 | 청크 중복 → 문서 단위 dedup |
| 느림 | 전수 탐색 중 → HNSW/IVF 인덱스 |
# 하이브리드 검색 — 실무의 기본 구성
def hybrid(query, k=10, alpha=0.5):
vec_hits = vector_search(query, k * 5) # 의미 유사
kw_hits = bm25_search(query, k * 5) # 키워드 정확도
# RRF (Reciprocal Rank Fusion) — 점수 스케일이 달라도 합칠 수 있다
scores = {}
for rank, doc in enumerate(vec_hits):
scores[doc] = scores.get(doc, 0) + 1 / (60 + rank)
for rank, doc in enumerate(kw_hits):
scores[doc] = scores.get(doc, 0) + 1 / (60 + rank)
return sorted(scores, key=scores.get, reverse=True)[:k]
유사도 척도 고르기 — 무엇을 재느냐가 다릅니다
세 가지 척도의 관계
⇒ 유클리드 거리와 코사인 유사도가 1:1 대응합니다
⇒ 정규화만 해 두면 어느 쪽을 써도 순위가 같습니다
| 척도 | 재는 것 · 언제 |
|---|---|
| 코사인 | 각도만 — 텍스트 임베딩·추천의 기본값 |
| 내적(dot) | 각도 + 길이 — 길이에 “인기도·신뢰도”를 담았을 때 |
| 유클리드(L2) | 실제 거리 — 좌표·이미지 특징처럼 크기가 의미 있을 때 |
| 맨해튼(L1) | 축별 차이의 합 — 이상치에 덜 민감 |
| 자카드 | 집합 겹침 — 태그·키워드처럼 벡터가 아닐 때 |
import numpy as np
def cosine(a, b):
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-12))
# ★ 미리 정규화해 두면 내적 한 번이 곧 코사인입니다 (검색 엔진의 표준 수법)
def normalize(M):
return M / (np.linalg.norm(M, axis=1, keepdims=True) + 1e-12)
docs = normalize(np.random.randn(100_000, 768).astype("float32"))
q = normalize(np.random.randn(1, 768).astype("float32"))
scores = docs @ q.T # (100000, 1) — 행렬곱 한 번
top10 = np.argpartition(-scores.ravel(), 10)[:10] # 전체 정렬 없이 상위 10
top10 = top10[np.argsort(-scores.ravel()[top10])]
# argsort 로 전부 정렬하면 O(n log n), argpartition 은 O(n)
고차원에서 벌어지는 일 — 차원의 저주
# 차원이 높아지면 "가장 가까운 것"과 "가장 먼 것"의 차이가 사라집니다
import numpy as np
for d in (2, 10, 100, 1000):
X = np.random.randn(1000, d)
q = np.random.randn(d)
dist = np.linalg.norm(X - q, axis=1)
print(f"d={d:5d} 최소={dist.min():7.2f} 최대={dist.max():7.2f} "
f"(최대-최소)/최소={(dist.max()-dist.min())/dist.min():.3f}")
# d= 2 최소= 0.05 최대= 4.71 비율=90.9
# d= 10 최소= 1.44 최대= 7.12 비율= 3.9
# d= 100 최소= 10.6 최대= 17.3 비율= 0.63
# d= 1000 최소= 39.5 최대= 49.8 비율= 0.26 ← 거의 다 비슷한 거리
d = 768 이면 표준편차 ≈ 0.036 — 대부분 cos θ ≈ 0 (θ ≈ 90°)
⇒ 그래서 0.3 만 넘어도 “꽤 비슷한” 것이 됩니다
확률의 기초 — 조건이 붙으면 직관이 무너집니다
확률 계산에서 사람이 틀리는 지점은 거의 정해져 있습니다 — 조건부확률과 독립성 착각. 이 둘만 잡으면 대부분의 오류가 사라집니다.
기본 규칙
P(A|B) = P(A∩B) / P(B)
독립 ⟺ P(A∩B) = P(A)·P(B) 세 번째가 정의입니다 — “관계없어 보인다”가 아니라 이 식이 성립해야 독립입니다.
# 서버 3대가 각각 99% 가동, 서로 독립일 때
# 전부 죽을 확률
(0.01) ** 3 # 0.000001 → 99.9999% 가용
# ★ 하지만 같은 랙 · 같은 전원 · 같은 AZ 라면 독립이 아니다.
# "독립 가정"이 틀리면 이 계산은 전부 무의미하다.
몬티 홀 — 조건부확률의 고전
import random
def monty(switch, n=100_000):
win = 0
for _ in range(n):
car = random.randrange(3)
pick = random.randrange(3)
# 사회자는 자동차도 내 선택도 아닌 문을 연다
opened = next(d for d in range(3) if d != car and d != pick)
if switch:
pick = next(d for d in range(3) if d != pick and d != opened)
win += (pick == car)
return win / n
monty(False) # 0.333
monty(True) # 0.667 ← 바꾸는 게 두 배 유리
이렇게도 쓸 수 있다 — 시뮬레이션으로 확인하기 🟡 중급
공식이 헷갈리면 돌려 보면 됩니다
import numpy as np
rng = np.random.default_rng(42)
# 질문: 재시도 3회, 각 시도 성공률 70% 일 때 최종 성공률은?
trials = rng.random((1_000_000, 3)) < 0.7
success = trials.any(axis=1).mean()
print(success) # 0.973
# 공식으로도 확인: 1 - 0.3³ = 0.973 ✓
# 질문: 요청 100개 중 하나라도 p99 를 넘을 확률은?
# (각 요청이 p99 를 넘을 확률 = 1%)
1 - 0.99 ** 100 # 0.634 ← 63%!
# ★ 이게 "꼬리 지연"이 서비스에서 치명적인 이유다.
# 한 페이지가 내부적으로 100번 호출하면
# 그중 하나가 느릴 확률이 63% 가 된다.
실무에선 이렇게 — 샘플링과 레이트 리밋 🔴 실무
로그 샘플링 — 편향 없이 줄이기
# ✗ 나쁜 샘플링 — 앞의 1000개만
logs[:1000] # 시간 편향
# ✓ 균등 샘플링
if random.random() < 0.01: # 1%
emit(log)
# ✓ 결정적 샘플링 — 같은 트레이스는 전부 남거나 전부 버림
if zlib.crc32(trace_id.encode()) % 100 == 0: # ★ 내장 hash() 는 프로세스마다 값이 달라 쓰면 안 된다
emit(log) # 분산 추적에 필수
# ✓ 꼬리 우선 샘플링 — 느린 것은 무조건 남긴다
if duration > p99 or status >= 500 or random.random() < 0.01:
emit(log)
확률적 조기 만료 — 캐시 스탬피드 방지
import math, random, time
def should_refresh(expiry, delta, beta=1.0):
"""만료 직전에 확률적으로 미리 갱신한다.
모든 요청이 동시에 만료를 보고 DB 로 몰리는 것을 막는다."""
now = time.time()
return now - delta * beta * math.log(random.random()) >= expiry
# delta = 값을 다시 계산하는 데 걸린 시간
# 캐시가 비싸게 만들어진 값일수록 더 일찍 갱신을 시작한다
if should_refresh(item.expiry, item.build_time):
refresh_async(key) # 한 요청만 갱신, 나머지는 옛 값 사용
베이즈 정리 — 증거를 보고 믿음을 갱신하는 공식
“정확도 99% 인 검사가 양성이면 정말 99% 인가?” 답은 아니오이고, 그 이유가 베이즈 정리입니다. 스팸 필터 · 이상 탐지 · 알림 설계가 전부 이 계산 위에 있습니다.
공식과 이름
# 실제 침입이 0.1% 인 환경에서
# 탐지율 99%, 오탐률 1% 인 시스템이 경보를 울렸다.
# 진짜 침입일 확률은?
p_attack = 0.001
p_alert_given_attack = 0.99
p_alert_given_normal = 0.01
p_alert = (p_alert_given_attack * p_attack +
p_alert_given_normal * (1 - p_attack))
posterior = p_alert_given_attack * p_attack / p_alert
print(posterior) # 0.090 ← 겨우 9%!
그러면 어떻게 하나
| 방법 | 효과 |
|---|---|
| 오탐률을 낮춘다 (1% → 0.01%) | 사후확률 9% → 91% |
| 여러 신호를 결합한다 | 각 증거가 확률을 갱신 |
| 대상을 좁힌다 (기저율 상승) | 고위험 그룹만 감시 |
| 2단계 확인 | 1차 넓게, 2차 정밀 |
# 증거가 여러 개면 곱해서 갱신한다 (독립 가정 = 나이브 베이즈)
def update(prior, likelihood_ratios):
odds = prior / (1 - prior)
for lr in likelihood_ratios:
odds *= lr # 우도비를 곱하기만 하면 된다
return odds / (1 + odds)
update(0.001, [99, 50, 20]) # 세 신호가 겹치면 0.99
이렇게도 쓸 수 있다 — 나이브 베이즈 스팸 필터 🟡 중급
30줄로 만드는 분류기
import math
from collections import Counter, defaultdict
class NaiveBayes:
def __init__(self, alpha=1.0):
self.alpha = alpha # 라플라스 평활
self.counts = defaultdict(Counter)
self.totals = Counter()
self.docs = Counter()
self.vocab = set()
def fit(self, docs): # docs: [(tokens, label)]
for tokens, label in docs:
self.docs[label] += 1
for t in tokens:
self.counts[label][t] += 1
self.totals[label] += 1
self.vocab.add(t)
def predict(self, tokens):
n = sum(self.docs.values())
best, best_score = None, -math.inf
V = len(self.vocab)
for label in self.docs:
# 로그로 더한다 — 곱하면 0 으로 언더플로 (13절 참고)
score = math.log(self.docs[label] / n)
for t in tokens:
c = self.counts[label][t] + self.alpha
score += math.log(c / (self.totals[label] + self.alpha * V))
if score > best_score:
best, best_score = label, score
return best
왜 “나이브”인가
모든 단어가 서로 독립이라고 가정하기 때문입니다. 실제로는 “무료”와 “쿠폰”이 함께 나오지만 무시합니다. 가정이 틀렸는데도 분류는 잘 됩니다 — 순위만 맞으면 되기 때문입니다.
실무에서 여전히 쓰는 이유
실무에선 이렇게 — 알림 설계에 적용 🔴 실무
“알림 피로”를 수학으로 줄이기
| 원칙 | 이유 (베이즈 관점) |
|---|---|
| 증상이 아니라 영향에 알림 | “CPU 80%” 는 기저율이 높아 사후확률이 낮음 |
| 여러 신호 결합 | 우도비를 곱해 사후확률을 올림 |
| 지속 시간 조건 | 순간 스파이크의 우도비는 1 에 가까움 |
| SLO 기반 알림 | 기저율 자체를 “사용자 영향”으로 재정의 |
| 에러 버짓 소진 속도 | 느린 소진과 빠른 소진을 다른 임계로 |
# ✗ 오탐이 쏟아지는 알림
- alert: HighCPU
expr: cpu_usage > 0.8
# ✓ 영향 기반 + 지속 조건 + 다중 신호
- alert: UserFacingLatency
expr: |
histogram_quantile(0.99, rate(http_duration_bucket[5m])) > 1.0
and rate(http_requests_total[5m]) > 10
for: 10m
labels: {severity: page}
공식으로 다시 — 그리고 로그로 계산하는 이유
베이즈 정리 유도
P(A|B) = P(B|A) · P(A) / P(B)
P(A) 사전확률 — 증거를 보기 전의 믿음
P(B|A) 가능도 — 그 원인이면 이 증거가 나올 확률
P(A|B) 사후확률 — 증거를 본 뒤의 믿음
P(B) 정규화 상수 = Σi P(B|Ai)P(Ai)
P(A|B)/P(¬A|B) = [P(A)/P(¬A)] × [P(B|A)/P(B|¬A)]
위 예: 사전 오즈 = 1/99, 우도비 = 0.99/0.01 = 99
→ 사후 오즈 = (1/99)×99 = 1 → 확률 1/(1+1) = 0.5 ✓
증거가 여러 개면 우도비를 곱하기만 하면 됩니다 — 분모 P(B) 를 계산할 필요가 없어져 나이브 베이즈 분류기가 이 형태로 구현됩니다.
나이브 베이즈 — 스팸 필터를 직접
import math
from collections import Counter
class NaiveBayes:
def fit(self, docs, labels):
self.prior, self.wc, self.total = Counter(), {}, {}
for d, y in zip(docs, labels):
self.prior[y] += 1
self.wc.setdefault(y, Counter()).update(d)
self.vocab = {w for c in self.wc.values() for w in c}
self.total = {y: sum(c.values()) for y, c in self.wc.items()}
self.n = len(docs)
return self
def predict(self, doc):
best, best_score = None, -math.inf
for y in self.prior:
# ★ 확률을 곱하면 언더플로 → 로그로 바꿔 더합니다
score = math.log(self.prior[y] / self.n)
for w in doc:
if w not in self.vocab: # 처음 보는 단어는 무시
continue
# ★ 라플라스 평활 — 한 번도 안 나온 단어가 확률을 0 으로 만드는 것 방지
p = (self.wc[y][w] + 1) / (self.total[y] + len(self.vocab))
score += math.log(p)
if score > best_score:
best, best_score = y, score
return best
nb = NaiveBayes().fit(
[["무료","쿠폰","지금"], ["회의","자료","첨부"], ["당첨","클릭","무료"]],
["스팸", "정상", "스팸"])
print(nb.predict(["무료","클릭"])) # 스팸
① 로그 변환 — 단어 100개면 확률을 100번 곱해
1e-300 이하로 내려가 전부 0 이 됩니다(언더플로).
곱셈을 로그의 덧셈으로 바꾸면 사라집니다.
② 평활(smoothing) — 학습 데이터에 없던 단어 하나가 확률을 0 으로 만들어 문서 전체를 무효화합니다. 분자·분모에 1 을 더하는 라플라스 평활 한 줄이면 됩니다.
확률분포 — 응답 시간은 정규분포가 아닙니다
분포를 잘못 가정하면 용량 산정이 통째로 틀립니다. 실무에서 만나는 네 가지 분포와 각각이 나타나는 자리를 봅니다.
네 가지만 알면 됩니다
| 분포 | 나타나는 곳 | 모양 |
|---|---|---|
| 이항 | n번 시도 중 성공 수 | 종 모양(이산) |
| 정규 | 많은 요인의 합 | 대칭 종 모양 |
| 포아송 | 단위 시간당 사건 수 | 왼쪽에 몰림 |
| 지수 | 사건 사이 간격 | 급감 |
| 로그정규 · 파레토 | 응답 시간 · 파일 크기 | 긴 꼬리 |
from scipy import stats
# 초당 평균 50건 요청 → 어떤 1초에 80건 이상 올 확률
1 - stats.poisson.cdf(79, 50) # 0.0000567 약 0.006%
# 1초에 100건 이상은? → 사실상 0
# 하지만 하루 86400초 동안이면?
1 - (stats.poisson.cdf(79, 50)) ** 86400 # 거의 1 — 반드시 일어난다
응답 시간의 진짜 모양
정규분포라면 실제 응답 시간
▁▃▅█▅▃▁ █▅▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ …
평균 주변 대칭 대부분 빠르고, 꼬리가 아주 길다
이유
· 캐시 히트/미스가 갈린다 (이봉 분포)
· GC · 재시도 · 커넥션 재수립 (드물지만 큼)
· 큐 대기는 부하에 지수적으로 증가
→ 평균은 거의 쓸모없다. p50 · p95 · p99 · max 를 본다.
이렇게도 쓸 수 있다 — 분위수를 제대로 다루기 🟡 중급
평균 낼 수 없는 값
# ✗ 절대 하면 안 되는 것: 분위수의 평균
server_p99 = [120, 180, 4500]
sum(server_p99) / 3 # 1600ms — 아무 의미 없는 숫자
# ✓ 원본 분포(히스토그램)를 합쳐서 다시 계산해야 한다
# Prometheus 가 histogram_quantile 을 쓰는 이유
# ✓ 근사 자료구조로 분산 환경에서 합치기
# t-digest · DDSketch — 병합 가능한 분위수 추정
from tdigest import TDigest
d1, d2 = TDigest(), TDigest()
d1.batch_update(server1_latencies)
d2.batch_update(server2_latencies)
merged = d1 + d2 # 병합 가능 ✓
merged.percentile(99)
읽는 법
| p50 | 전형적인 사용자 경험 |
| p95 | 불편함이 느껴지는 선 |
| p99 | 이탈이 발생하는 선 |
| p99.9 | 가장 비싼 고객이 겪는 것 |
| max | 타임아웃 설정의 근거 |
p99 는 소수 사용자 문제가 아니다
실무에선 이렇게 — 대기 행렬 이론 맛보기 🔴 실무
사용률 80% 를 넘기면 왜 갑자기 느려지나
# M/M/1 근사로 체감하기
def wait_multiplier(rho):
return rho / (1 - rho)
for r in (0.5, 0.7, 0.8, 0.9, 0.95, 0.99):
print(r, round(wait_multiplier(r), 1))
# 0.5 1.0
# 0.7 2.3
# 0.8 4.0
# 0.9 9.0
# 0.95 19.0
# 0.99 99.0 ← 절벽
| 교훈 | 실무 적용 |
|---|---|
| 사용률을 100% 로 채우면 안 됨 | 목표 60~70% · 나머지는 여유 |
| 스레드 풀을 늘려도 큐가 길면 소용없음 | 큐 길이 제한 + 조기 거절 |
| 변동성이 크면 더 일찍 무너짐 | 배치 작업 분리 · 우선순위 큐 |
| 리틀의 법칙: L = λ × W | 동시 처리 수 = 도착률 × 소요시간 |
기댓값 · 분산 — 평균만 보면 반드시 사고가 납니다
평균 키가 170cm 인 방에 농구선수와 어린이가 섞여 있을 수 있습니다. 흩어짐을 함께 보지 않으면 숫자가 거짓말을 합니다.
정의와 성질
E[X+Y] = E[X] + E[Y] (항상)
Var(X+Y) = Var(X) + Var(Y) (독립일 때만)
import numpy as np
x = np.array([10, 12, 11, 9, 200]) # 마지막이 이상치
x.mean() # 48.4 ← 대표값으로 못 씀
np.median(x) # 11.0 ← 이쪽이 현실
x.std() # 75.8 ← 흩어짐이 크다는 신호
# 이상치에 강한 지표
np.percentile(x, [25, 50, 75]) # 사분위수
stats.median_abs_deviation(x) # MAD
큰 수의 법칙과 중심극한정리
rng = np.random.default_rng(0)
# 지수분포(전혀 정규분포가 아님)에서 표본을 뽑아
# 표본평균의 분포를 보면 → 정규분포에 가까워진다
for n in (1, 5, 30, 100):
means = rng.exponential(1.0, size=(20000, n)).mean(axis=1)
print(n, round(means.std(), 3))
# 1 0.994
# 5 0.45 ≈ 1/√5
# 30 0.183 ≈ 1/√30
# 100 0.100 ≈ 1/√100
# ★ 표준오차 = σ / √n
# 정확도를 2배 올리려면 표본을 4배 모아야 한다
이렇게도 쓸 수 있다 — 기댓값으로 의사결정 🟡 중급
재시도 전략 비교
def expected_latency(p_fail, base_ms, retries, backoff=2.0):
"""실패 확률 p 로 재시도할 때 기대 지연"""
total, prob_reach = 0.0, 1.0
delay = base_ms
for i in range(retries + 1):
total += prob_reach * base_ms # 이번 시도의 소요
if i < retries:
total += prob_reach * p_fail * delay # 실패 시 대기
delay *= backoff
prob_reach *= p_fail
return total
expected_latency(0.05, 100, 0) # 100.0 재시도 없음
expected_latency(0.05, 100, 2) # 110.75 거의 안 늘어난다
expected_latency(0.30, 100, 2) # 187.0 실패율이 높으면 비싸진다
# 성공률
1 - 0.05 ** 3 # 0.999875
1 - 0.30 ** 3 # 0.973
| 판단 | 기준 |
|---|---|
| 재시도할 가치가 있나 | 실패가 일시적이고 p 가 낮을 때 |
| 몇 번 할까 | 기대 지연 증가분 < 성공률 이득 |
| 백오프 필요한가 | 원인이 과부하면 필수 (안 하면 악화) |
| 지터를 넣을까 | 여러 클라이언트면 필수 (동시 재시도 방지) |
실무에선 이렇게 — 몬테카를로 추정 🔴 실무
공식이 없으면 시뮬레이션
import numpy as np
rng = np.random.default_rng(0)
# 질문: 마이크로서비스 5개를 순차 호출할 때 전체 p99 는?
# 각 서비스: 로그정규분포 (median 20ms, 꼬리 있음)
n = 200_000
lat = rng.lognormal(mean=np.log(20), sigma=0.8, size=(n, 5))
serial = lat.sum(axis=1) # 순차 호출
parallel = lat.max(axis=1) # 병렬 호출
for name, arr in [("순차", serial), ("병렬", parallel)]:
print(name, [round(np.percentile(arr, q)) for q in (50, 95, 99)])
# 순차 [127, 246, 332]
# 병렬 [ 49, 128, 200] ← 병렬화의 효과가 숫자로 보인다
# 신뢰구간도 함께 (부트스트랩)
boots = [np.percentile(rng.choice(serial, n), 99) for _ in range(200)]
print(np.percentile(boots, [2.5, 97.5]))
A/B 테스트 — 통계가 실무에서 틀리는 지점
p < 0.05 를 봤다고 끝이 아닙니다. 중간에 들여다보기 · 여러 지표 동시 검정 · 심슨의 역설이 실제 서비스에서 잘못된 결론을 만드는 3대 원인입니다.
기본 흐름
- 가설을 먼저 — “버튼 색이 전환율을 올린다”. 데이터를 본 뒤에 세우면 안 됩니다.
- 표본 크기를 먼저 계산 — 필요한 관측 수를 정하고 시작합니다.
- 정해진 기간을 채운다 — 중간에 멈추면 유의성이 부풀려집니다.
- 결과 해석 — p값은 “효과 크기”가 아니라 “우연일 가능성”입니다.
from statsmodels.stats.power import zt_ind_solve_power
from statsmodels.stats.proportion import proportion_effectsize
# 전환율 5% → 5.5% 를 감지하려면 몇 명이 필요한가
es = proportion_effectsize(0.05, 0.055)
n = zt_ind_solve_power(effect_size=es, alpha=0.05, power=0.8)
print(round(n)) # 약 15,600 명 (각 그룹 · 합계 약 31,000)
p값이 말하지 않는 것
| 오해 | 사실 |
|---|---|
| “가설이 참일 확률 95%” | 아님 — 귀무가설 하에서의 확률 |
| “효과가 크다” | 표본이 크면 미미한 차이도 유의 |
| “p>0.05 면 효과 없음” | 모른다는 뜻 |
| “재현될 것” | p=0.05 는 재현율이 낮음 |
이렇게도 쓸 수 있다 — 세 가지 대표 함정 🟡 중급
① 계속 들여다보기
# 매일 확인하며
# "유의해지면 종료"
# → 실제 오탐률이
# 5% 가 아니라 30%+
# 대응
# · 기간을 미리 고정
# · 순차 검정(SPRT)
# · 베이지안 A/B
② 다중 검정
# 지표 20개를 동시에 보면
1 - 0.95 ** 20 # 0.64
# 64% 확률로 하나는
# 우연히 '유의'
# 대응
# · 주요 지표 1개 선언
# · 본페로니 보정
# · FDR 통제
③ 심슨의 역설
모바일 A 3% B 4% B승
데스크탑 A 8% B 9% B승
────────────────────
전체 A 6% B 5% A승?!
그룹 비율이 다르면
합친 결과가 뒤집힌다
대응: 세그먼트별로 함께
실무에선 이렇게 — 실험 체크리스트 🔴 실무
돌리기 전에 확인할 것
| 항목 | 확인 |
|---|---|
| A/A 테스트 | 같은 것끼리 나눠도 차이가 없는지 — 분배 버그 검출 |
| SRM 검사 | 50:50 으로 나눴는데 실제 비율이 맞는지 (카이제곱) |
| 배정 단위 | 사용자 단위인가 세션 단위인가 (섞이면 오염) |
| 신규성 효과 | 첫 주 반응은 과장됨 — 최소 2주 |
| 주기성 | 주중/주말이 다르면 온전한 주 단위로 |
| 가드레일 지표 | 전환율은 올랐는데 이탈률도 올랐는가 |
| 사후 세그먼트 | 결과를 보고 나눈 세그먼트는 탐색용일 뿐 |
# SRM (Sample Ratio Mismatch) 검사 — 가장 먼저 할 것
from scipy.stats import chisquare
observed = [50_312, 49_688] # A, B 실제 배정 수
chi2, p = chisquare(observed)
if p < 0.001:
raise RuntimeError("배정이 편향됨 — 실험 결과를 믿을 수 없다")
# SRM 이 나면 결과 해석 전에 원인을 찾아야 한다:
# · 봇 트래픽이 한쪽에 몰림
# · 리다이렉트로 인한 이탈
# · 캐시가 한쪽 버전만 서빙
부동소수점 — 0.1 + 0.2 ≠ 0.3 인 이유
버그가 아니라 설계입니다. 2진수로는 0.1 을 정확히 쓸 수 없습니다. 10진수로 1/3 을 못 쓰는 것과 완전히 같은 이유입니다.
직접 확인
0.1 + 0.2 # 0.30000000000000004
0.1 + 0.2 == 0.3 # False
from decimal import Decimal
Decimal(0.1)
# Decimal('0.1000000000000000055511151231257827021181583404541015625')
# ← float 0.1 이 실제로 담고 있는 값
# 0.1 을 2진수로: 0.0001100110011001100... (무한 반복)
# 64비트에 잘라 넣으니 아주 조금 커진다
비교하는 올바른 방법
# ✗ 절대 하면 안 됨
if a == b: ...
# ✓ 절대·상대 오차를 함께
import math
math.isclose(0.1 + 0.2, 0.3) # True
math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12)
# JS
Math.abs(a - b) < Number.EPSILON * Math.max(1, Math.abs(a), Math.abs(b))
# ✗ 반복 누적도 위험
total = 0.0
for _ in range(10):
total += 0.1
total == 1.0 # False (0.9999999999999999)
abs(a-b) < 1e-9 는 값이 10억쯤 되면 항상 거짓입니다.
상대 오차를 함께 써야 합니다.
이렇게도 쓸 수 있다 — 돈 계산은 절대 float 으로 하지 않는다 🟡 중급
세 가지 선택지
✗ float
price = 0.1
total = price * 3
print(total) # 0.30000000000000004
round(total, 2) # 0.3 ← 눈속임일 뿐
# 반복 결제 · 이자 계산에서
# 오차가 쌓여 원 단위가 어긋난다
✓ 정수(최소 단위) 또는 Decimal
# ① 정수로 — 가장 안전하고 빠르다
price_won = 100 # 원 단위 정수
total = price_won * 3 # 300 ✓
# ② Decimal — 소수 자리가 필요할 때
from decimal import Decimal, ROUND_HALF_UP
p = Decimal("0.1") # ★ 문자열로 생성
(p * 3).quantize(Decimal("0.01"),
rounding=ROUND_HALF_UP)
| 저장 방식 | DB 타입 | 판단 |
|---|---|---|
| 정수 (최소 단위) | BIGINT | 기본 선택 — 원·센트 단위 |
| 고정 소수점 | DECIMAL(19,4) | 환율·세율 계산이 있을 때 |
| 부동소수점 | FLOAT/DOUBLE | 금액에는 금지 |
9007199254740993(2⁵³+1) 같은 큰 정수 ID 가 조용히 바뀝니다.
스노우플레이크 ID · 금액은 문자열로 주고받는 것이 안전합니다.
실무에선 이렇게 — 특수값과 함정 🔴 실무
NaN · Infinity · -0.0
import math
nan = float("nan")
nan == nan # False! ★ 자기 자신과도 다르다
math.isnan(nan) # True ← 이걸로 검사
[1, nan, 2].count(nan) # 1 (is 비교라 걸림 — 혼란스러움)
sorted([3, nan, 1]) # [3, nan, 1] 정렬이 깨진다
float("inf") - float("inf") # nan
0.0 == -0.0 # True
math.copysign(1, -0.0) # -1.0 ← 부호는 구분된다
1 / -0.0 # -inf
언제 float 이 괜찮은가
| 용도 | 판단 |
|---|---|
| 물리 · 그래픽 · 좌표 | 적합 |
| 머신러닝 가중치 | 적합 (float32 도 충분) |
| 통계 · 비율 · 확률 | 적합 |
| 금액 · 재고 수량 | 금지 |
| ID · 카운터 | 금지 (정수) |
| 시간 (초 단위 실수) | 주의 — 정수 나노초 권장 |
수치 안정성 — 작은 값이 조용히 사라집니다
부동소수점의 오차는 계산 순서만 바꿔도 크게 달라집니다. 통계·머신러닝·좌표 계산에서 결과가 이상할 때 원인이 여기인 경우가 많습니다.
자릿수 손실 (catastrophic cancellation)
# 비슷한 크기의 두 수를 빼면 유효 자릿수가 날아간다
a = 1.0000001
b = 1.0000000
a - b # 1.0000000005838672e-07 ← 유효숫자 8자리가 1자리로
# 실제 사고 예: 분산을 이렇게 계산하면 안 된다
def var_bad(xs):
n = len(xs)
return sum(x*x for x in xs)/n - (sum(xs)/n)**2 # E[X²] - E[X]²
xs = [1e9 + 0.1 * i for i in range(5)] # 참값 분산 0.02
var_bad(xs) # -128.0 ← 분산이 음수?! (아래 var_good 은 0.02)
# ✓ 웰포드 알고리즘 — 한 번 훑으면서 안정적으로
def var_good(xs):
n = 0; mean = 0.0; m2 = 0.0
for x in xs:
n += 1
d = x - mean
mean += d / n
m2 += d * (x - mean)
return m2 / n
E[X²]−E[X]² 는 수학적으로 맞지만
수치적으로는 최악입니다. numpy·pandas 는 이미 안정적인 방법을 씁니다.
합산 순서와 카한 합
xs = [1.0] + [1e-16] * 10_000_000
sum(xs) # 1.0 작은 값들이 전부 사라짐 (3.12+ 의 sum 은 보정 합산이라 정확)
math.fsum(xs) # 1.000000001 ✓ 정확 (1 + 10⁷ × 10⁻¹⁶)
np.sum(xs) # pairwise 합산 — 꽤 정확
# 카한 합산 알고리즘 (직접 구현할 때)
def kahan_sum(xs):
total = 0.0; c = 0.0 # c = 잃어버린 아랫자리
for x in xs:
y = x - c
t = total + y
c = (t - total) - y # 방금 잃은 부분을 되찾는다
total = t
return total
# 정렬해서 더하는 것만으로도 좋아진다 (작은 것부터)
sum(sorted(xs))
이렇게도 쓸 수 있다 — 로그 공간으로 옮기기 🟡 중급
확률을 곱하면 0 이 됩니다
# 단어 1000개짜리 문서의 확률
p = 1.0
for _ in range(1000):
p *= 0.01
print(p) # 0.0 ← 언더플로 (실제로는 1e-2000)
# ✓ 로그로 바꾸면 덧셈이 되고 범위 문제가 사라진다
import math
logp = sum(math.log(0.01) for _ in range(1000))
print(logp) # -4605.17 ← 비교는 이대로 하면 된다
# 다시 확률로 돌아와야 한다면 log-sum-exp 트릭
def logsumexp(xs):
m = max(xs)
return m + math.log(sum(math.exp(x - m) for x in xs))
# ★ 최댓값을 빼고 계산 — exp 오버플로 방지
# 소프트맥스도 같은 이유로 최댓값을 뺀다
def softmax(xs):
m = max(xs)
e = [math.exp(x - m) for x in xs]
s = sum(e)
return [v / s for v in e]
log_softmax · cross_entropy 가 따로 있는 것도
중간에 exp 를 거치지 않아 더 안정적이기 때문입니다.
실무에선 이렇게 — 체크리스트와 다음 단계 🔴 실무
수치 문제 진단
| 증상 | 의심할 것 |
|---|---|
| 분산·표준편차가 음수 | 자릿수 손실 → 웰포드 |
| 합계가 실행마다 다름 | 병렬 합산 순서 (재현성) |
| 결과가 0 또는 inf | 언더/오버플로 → 로그 공간 |
| 반복하면 발산 | 조건수 나쁨 → 04 참고 |
| float32 에서만 이상 | 유효자릿수 7자리 한계 |
| GPU 결과가 CPU 와 다름 | 연산 순서 · TF32 정밀도 |
torch.use_deterministic_algorithms(True) 같은 옵션이 그것입니다.
이 탭에서 이어지는 곳
| 주제 | 다음 |
|---|---|
| 부동소수점 비트 구조 | 🖥️ 컴퓨터구조 04 |
| 행렬 곱과 병렬화 | 🖥️ 컴퓨터구조 10 |
| p99 · 큐 대기 | ⚙️ 운영체제 03 · 🕸️ 분산 13 |
| 벡터 검색 인덱스 | 🗂️ 자료구조 12 |
| 확률적 자료구조 | 🗂️ 자료구조 12 |
| 난수의 안전성 | 🔐 보안 · 암호 11 |
마르코프 체인과 엔트로피 — “다음에 무엇이 올까”를 재는 법
마르코프 체인은 “바로 직전 상태만 보면 다음이 정해지는” 확률 모형입니다. PageRank · 캐시 적중률 · 대기열 · 언어 모델이 전부 이 틀 위에 있습니다. 엔트로피는 “얼마나 예측하기 어려운가”를 숫자 하나로 잰 것이고, 압축률의 하한과 비밀번호의 강도가 같은 식에서 나옵니다.
정상 분포를 두 가지 방법으로
import numpy as np
# 행이 '현재 상태', 열이 '다음 상태' — 각 행의 합은 1
P = np.array([
[0.0, 0.7, 0.3], # 대기 → 처리중 0.7, 실패 0.3
[0.5, 0.3, 0.2], # 처리중 → 대기 0.5, 유지 0.3, 실패 0.2
[0.6, 0.0, 0.4], # 실패 → 대기 0.6, 유지 0.4
])
# ① 반복해서 곱하기 (직관적)
pi = np.array([1.0, 0.0, 0.0]) # 대기에서 출발
for k in range(1, 60):
new = pi @ P
if np.abs(new - pi).max() < 1e-12:
print(f"{k}회에 수렴"); break
pi = new
print("정상 분포:", pi.round(4)) # [0.3529 0.3529 0.2941] (= 6/17, 6/17, 5/17)
# ② 고유벡터로 한 번에 (πP = π → P^T 의 고윳값 1 고유벡터)
w, v = np.linalg.eig(P.T)
pi2 = np.real(v[:, np.argmin(np.abs(w - 1))])
pi2 = pi2 / pi2.sum()
print("고유벡터로:", pi2.round(4)) # 같은 값
# 수렴 속도 — 두 번째로 큰 고윳값의 크기
lam = np.sort(np.abs(w))[::-1]
print(f"λ2 = {lam[1]:.3f} → 오차가 매 단계 이 비율로 줄어듭니다")
비주기적: 돌아오는 주기가 딱 떨어지지 않는다
둘 다 만족하면 유일한 정상 분포로 수렴합니다 (에르고딕 정리)
PageRank 가 댐핑 팩터 0.85 를 쓰는 이유가 이것입니다 — 15% 확률로 아무 페이지나 가게 해서 기약성을 강제로 만듭니다
실무에서 이 계산이 나오는 곳
| 문제 | 상태와 전이 |
|---|---|
| PageRank | 상태 = 페이지, 전이 = 링크를 따라감 |
| 캐시 적중률 | 상태 = 캐시 내용, 전이 = 접근 패턴 |
| 사용자 여정 | 상태 = 화면, 전이 = 클릭 → 이탈 지점 분석 |
| 장애 모델링 | 정상 / 성능저하 / 다운 → 가용성 계산 |
| 대기열(M/M/1) | 상태 = 큐 길이 → 평균 대기 시간 |
| 신뢰도 | MTBF · MTTR 로 정상 상태 비율 = 가용성 |
# 가용성을 마르코프로 계산 — SLA 협의에 쓰는 숫자
MTBF, MTTR = 720.0, 2.0 # 평균 무장애 720시간, 평균 복구 2시간
# 상태: 0 정상, 1 다운
P = np.array([
[1 - 1/MTBF, 1/MTBF],
[1/MTTR, 1 - 1/MTTR],
])
w, v = np.linalg.eig(P.T)
pi = np.real(v[:, np.argmin(np.abs(w - 1))]); pi /= pi.sum()
print(f"가용성 {pi[0]*100:.4f}%") # 99.7230%
print(f"연간 다운타임 {pi[1]*365*24:.1f}시간") # 24.3시간
# → 99.9% 를 맞추려면 MTTR 을 줄이는 편이 MTBF 를 늘리는 것보다 쉽습니다
MTTR 이 훨씬 싸다는 결론이
이 계산에서 바로 나옵니다.
“장애가 안 나게 만들기”(MTBF ↑)는 어렵지만,
“빨리 복구하기”(MTTR ↓)는 자동 재시작 · 롤백 · 헬스체크로
비교적 쉽게 줄일 수 있습니다.
🏛️ 소프트웨어 공학 12 의 관측 가능성이 여기에 직접 연결됩니다.
엔트로피 — 예측 불가능성을 비트로 재기
공식과 직접 계산
한 값이 확실하면 최소: H = 0
교차 엔트로피 H(p, q) = − Σ p(x) log2 q(x)
— 실제 분포 p 를 내 모델 q 로 설명할 때 드는 비용
→ 분류 모델의 손실 함수가 바로 이것입니다
KL 발산 D(p‖q) = H(p,q) − H(p) ≥ 0
— 모델이 실제와 얼마나 다른가 (0 이면 같음)
import math
from collections import Counter
def entropy(data) -> float:
n = len(data)
return sum((c/n) * math.log2(n/c) for c in Counter(data).values()) # −Σp·log p 와 같음 (-0.0 방지)
print(entropy("aaaaaaaa")) # 0.0 — 전부 같음
print(entropy("abababab")) # 1.0 — 두 글자가 반반
print(entropy("abcdefgh")) # 3.0 — 8가지 균등 = log2(8)
print(entropy(open("a.jpg","rb").read())) # 7.9 근처 — 이미 압축됨
# 압축 하한 확인 — 이론값보다 작게는 못 줄입니다 (글자를 독립으로 뽑은 데이터일 때)
import random
data = bytes(random.choice(b"abcd") for _ in range(16_000)) # 글자당 2비트
lower = entropy(data) * len(data) / 8
print(f"이론 하한 {lower:.0f}B, 실제 gzip {len(__import__('gzip').compress(data))}B")
# 이론 하한 4000B, 실제 gzip 약 5000B
# ★ b"aaaabbbb..." * 1000 처럼 반복이 있으면 글자 빈도로 잰 이 하한보다 훨씬 작아진다
# (gzip 은 글자 하나가 아니라 반복 패턴을 줄이므로)
비밀번호 강도를 비트로 말하기
8자 소문자+숫자 (N=36) → 8 × 5.17 = 41 비트
12자 소문자+숫자 → 62 비트
단어 4개 무작위 조합 (사전 7776개) → 4 × 12.9 = 52 비트
길이가 문자 종류보다 훨씬 강력합니다 — 특수문자를 섞는 것보다 네 글자 늘리는 편이 낫습니다
| 엔트로피 | bcrypt(cost 12) 기준 전수 탐색 |
|---|---|
| 40 비트 | 약 3년 |
| 50 비트 | 약 3천 년 |
| 60 비트 | 약 300만 년 |
| 80 비트 이상 | 사실상 불가능 |
Password1! 는 형식상 11자·4종류라
72비트처럼 보이지만, 실제로는 사전 공격에 몇 초면 뚫립니다 —
사람의 선택은 전혀 균등하지 않기 때문입니다.
그래서 복잡도 규칙보다 유출 목록 대조와 MFA 가 효과적입니다
(🔐 보안 03). 강도를 실제로 재려면 zxcvbn 같은
패턴 인식 기반 도구를 쓰세요.
특이값 분해와 최소제곱법 — 데이터를 “중요한 순서”로 쪼개기
SVD 는 어떤 행렬이든 “회전 → 축마다 늘이기 → 다시 회전” 세 단계로 쪼갭니다. 정사각이 아니어도, 역행렬이 없어도 됩니다. 추천 · 검색 · 이미지 압축 · 노이즈 제거가 전부 이 분해에서 앞쪽 몇 개만 남기는 같은 동작입니다. 최소제곱법은 답이 없는 연립방정식에 가장 덜 틀린 답을 주는 방법이고, 그 계산을 안정적으로 해내는 도구가 또 SVD 입니다.
랭크-k 근사 — 몇 개를 남길지 정하는 법
import numpy as np
A = np.random.default_rng(0).normal(size=(200, 80))
U, s, Vt = np.linalg.svd(A, full_matrices=False)
# U (200,80) · s (80,) 내림차순 · Vt (80,80)
k = 10
Ak = (U[:, :k] * s[:k]) @ Vt[:k] # 랭크-k 근사
err = np.linalg.norm(A - Ak) / np.linalg.norm(A)
print(f"k={k} 상대 오차 {err:.3f}")
# k 를 감으로 고르지 않는 법 — 누적 설명력
ratio = np.cumsum(s**2) / np.sum(s**2)
print("90% 설명에 필요한 k:", int(np.searchsorted(ratio, 0.90)) + 1)
# 저장 용량 — 원본 m·n 대신 (m+n+1)·k 만 있으면 됩니다
m, n = A.shape
print(f"{(m + n + 1) * k} vs {m * n}")
# 조건수도 여기서 바로 나옵니다
print("조건수", s[0] / s[-1])
s 를 그래프로 그려 보면 대개 급격히 꺾이는 지점이 보이는데,
그 무릎(elbow)까지가 신호이고 그 뒤는 잡음입니다.
어디에서 이미 쓰고 있었나
| 쓰는 곳 | SVD 가 하는 일 |
|---|---|
| PCA | 평균 제거 후 SVD → V 가 주성분 |
| 추천(행렬 분해) | 사용자×상품에서 잠재 취향 축을 뽑음 |
| 검색(LSA) | 문서×단어 → 의미가 비슷한 축으로 묶음 |
| 이미지 압축 | 앞 k 층만 저장 |
| 노이즈 제거 | 작은 σ 를 0 으로 |
| 의사역행렬 | 역행렬이 없는 행렬의 대타 |
| 조건수 | κ = σmax / σmin |
→ V 는 AᵀA 의 고유벡터, σi2 는 그 고윳값
같은 식으로 AAᵀ = UΣΣᵀUᵀ → U 를 얻습니다
AᵀA 는 항상 대칭이고 준정부호라 고윳값이 실수이고 0 이상입니다.
그래서 σ = √고윳값 이 항상 정의되고, 모든 실수 행렬에 SVD 가 존재합니다.
랭크 = 0 이 아닌 σ 의 개수
최소제곱법 — 답이 없는 연립방정식에 답을 주기
용량 산정에 그대로 쓰는 최소제곱
import numpy as np
# 트래픽으로 CPU 사용률을 설명 — y ≈ a·x + b
x = np.array([120, 240, 310, 480, 610, 750], float) # RPS
y = np.array([12.4, 23.9, 30.1, 47.7, 60.8, 74.2]) # CPU %
A = np.column_stack([x, np.ones_like(x)]) # 열이 [x, 1]
coef, res, rank, sv = np.linalg.lstsq(A, y, rcond=None)
a, b = coef
print(f"CPU% ≈ {a:.4f}·RPS + {b:.2f}")
# 이 값이 크면 결과를 믿기 어렵습니다 (lstsq 가 σ 를 그대로 줍니다)
print("조건수", sv.max() / sv.min())
# 증설 시점을 숫자로 — 80% 를 넘는 RPS
print(f"80% 도달 RPS ≈ {(80 - b) / a:.0f}")
# 잔차로 “이 직선이 믿을 만한가”를 확인
resid = y - A @ coef
print("최대 오차", np.abs(resid).max())
lstsq 한 줄이고, 확인할 것은 조건수와 최대 잔차 둘입니다.
정규방정식을 직접 짜면 안 되는 이유
import numpy as np
# 악명 높은 반데르몬드 행렬 — 조건수가 큽니다
A = np.vander(np.linspace(0, 1, 30), 12)
b = np.ones(30)
print(f"cond(A) = {np.linalg.cond(A):.2e}")
print(f"cond(AᵀA) = {np.linalg.cond(A.T @ A):.2e}") # 제곱됩니다
x_ne = np.linalg.solve(A.T @ A, A.T @ b) # 정규방정식 직접
x_ls = np.linalg.lstsq(A, b, rcond=None)[0] # 내부적으로 SVD
print("정규방정식 잔차", np.linalg.norm(A @ x_ne - b))
print("lstsq 잔차", np.linalg.norm(A @ x_ls - b))
# → 정규방정식 쪽이 눈에 띄게 나쁩니다. 같은 수식인데 결과가 다릅니다.
— σ 가 제곱되니 최대/최소 비율도 제곱됩니다
κ(A) = 108 이면 κ(AᵀA) = 1016
double 의 유효 자릿수가 약 16자리라 정확한 자리가 하나도 안 남습니다
→ 같은 수학, 다른 결과. 계산 순서가 답을 바꿉니다
inv(A.T @ A) @ A.T @ b 는 교과서에는 나오지만 코드에는 쓰지 않습니다.
느리고(역행렬을 명시적으로 만듦) 부정확합니다(조건수 제곱).
np.linalg.lstsq · scipy.linalg.lstsq 를 쓰세요 —
내부에서 SVD 로 풀고, 랭크가 모자란 경우까지 알아서 처리합니다.
얼마나 걸리는가
그리고 애초에
풀 수 있는가
코드를 쓰는 사람에게 가장 즉시 쓸모 있는 이론이 여기 있습니다 — 이 코드가 데이터 100배에서 살아남는가를 실행해 보기 전에 아는 능력. 거기에 더해, 정규식이 왜 HTML 을 못 파싱하는지 · 정적 분석 도구가 왜 완벽할 수 없는지 · “최적해를 포기해야 하는 문제”가 어떤 것인지까지 이어집니다.
계산이란 무엇인가 — 튜링 기계가 정의한 것
“컴퓨터로 풀 수 있다”는 말에 정확한 정의가 필요했습니다. 1936년, 컴퓨터가 아직 없을 때 앨런 튜링이 만든 그 정의가 지금도 모든 언어의 한계를 규정합니다.
구성 요소는 넷뿐
① 무한한 테이프 ... [ ][1][0][1][ ][ ] ...
② 읽기·쓰기 헤드 ↑
③ 유한한 상태 집합 q0, q1, q2, ...
④ 전이 규칙 (현재상태, 읽은값) → (쓸값, 이동방향, 다음상태)
이게 전부다. 그런데 이 기계로
파이썬으로 짤 수 있는 모든 프로그램을 짤 수 있다.
튜링 완전 — 모든 언어가 같은 힘
# 튜링 완전에 필요한 것: 조건 분기 + 무한 반복 + 무제한 저장
# 이 셋만 있으면 무엇이든 계산할 수 있다.
# 튜링 완전한 것들 (의외의 것 포함)
# · 모든 범용 프로그래밍 언어
# · 엑셀 수식 (2021년 LAMBDA 추가로)
# · C++ 템플릿 · TypeScript 타입 시스템
# · SQL (재귀 CTE 포함)
# · 마인크래프트 레드스톤 · 콘웨이의 생명 게임
# · 매직 더 개더링 (카드 게임)
# 튜링 완전하지 '않은' 것 — 그래서 안전한 것들
# · 정규식 (POSIX 표준)
# · JSON · YAML
# · 종료가 보장된 DSL (Bitcoin Script · Cap'n Proto)
이렇게도 쓸 수 있다 — 튜링 기계를 코드로 🟡 중급
40줄이면 만들어집니다
def turing(rules, tape, state="q0", halt="H", steps=10_000):
"""rules[(state, symbol)] = (write, move, next_state)"""
tape = list(tape); pos = 0
for _ in range(steps):
if state == halt: break
while pos < 0: tape.insert(0, " "); pos = 0 # 테이프 확장
while pos >= len(tape): tape.append(" ")
sym = tape[pos]
if (state, sym) not in rules: break
write, move, state = rules[(state, sym)]
tape[pos] = write
pos += 1 if move == "R" else -1
return "".join(tape).strip()
# 1 의 개수를 하나 늘리는 기계 (단항 증가)
rules = {
("q0", "1"): ("1", "R", "q0"),
("q0", " "): ("1", "R", "H"),
}
print(turing(rules, "111")) # 1111
실무에선 이렇게 — 힘을 일부러 줄이는 설계 🔴 실무
사용자에게 코드 실행을 허용할 때
| 요구 | 선택 | 이유 |
|---|---|---|
| 필터 조건식 | CEL · JsonLogic | 튜링 불완전 — 반드시 종료 |
| 템플릿 | Mustache(로직리스) | 반복·조건만, 임의 코드 불가 |
| 정책 규칙 | OPA/Rego | 종료 보장 · 결정적 |
| 플러그인 | WASM + 연료 제한 | 튜링 완전하되 실행 예산으로 제한 |
| 진짜 스크립트 | 격리 프로세스 + 타임아웃 | 이 방법밖에 없음 |
# ✗ 절대 하면 안 되는 것
result = eval(user_input) # 코드 실행 취약점
result = exec(user_formula)
# ✓ 파싱해서 허용된 연산만
import ast, operator
OPS = {ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv}
def safe_eval(expr):
def ev(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPS:
return OPS[type(node.op)](ev(node.left), ev(node.right))
raise ValueError("허용되지 않은 식")
return ev(ast.parse(expr, mode="eval").body)
safe_eval("2 + 3 * 4") # 14
safe_eval("__import__('os').system('rm -rf /')") # ValueError ✓
유한 상태 기계 — 가장 많이 쓰는데 이름을 모르는 도구
주문 상태 · 연결 상태 · 플레이어 상태. if 로 뒤엉킨 상태 관리를
표 하나로 바꾸면 잘못된 전이가 구조적으로 불가능해집니다.
if 뭉치 대신 전이표
✗ 조건문에 흩어진 규칙
if order.status == "paid" and action == "ship":
order.status = "shipping"
elif order.status == "pending" and action == "pay":
order.status = "paid"
elif ... # 상태가 늘 때마다 여기저기 추가
✓ 표 한 곳
TRANSITIONS = {
("pending", "pay"): "paid",
("pending", "cancel"): "canceled",
("paid", "ship"): "shipping",
("paid", "cancel"): "canceled",
("shipping", "deliver"):"done",
}
def apply(status, action):
key = (status, action)
if key not in TRANSITIONS:
raise ValueError(f"{status} 에서 {action} 불가")
return TRANSITIONS[key]
DFA 와 NFA
| DFA (결정적) | NFA (비결정적) | |
|---|---|---|
| 다음 상태 | 정확히 하나 | 여러 개 가능 |
| ε 전이 | 없음 | 있음 (입력 없이 이동) |
| 실행 속도 | O(입력 길이) | 느릴 수 있음 |
| 표현력 | 완전히 같다 (서로 변환 가능) | |
이렇게도 쓸 수 있다 — 상태 기계가 이미 있는 곳 🟡 중급
알고 보면 전부 FSM
| 영역 | 상태 | 전이 사건 |
|---|---|---|
| TCP 연결 | CLOSED · SYN_SENT · ESTABLISHED · TIME_WAIT … | SYN · ACK · FIN |
| HTTP 파서 | 요청줄 · 헤더 · 바디 · 청크 | 바이트 도착 |
| UI 컴포넌트 | idle · loading · success · error | fetch · 응답 · 실패 |
| 게임 캐릭터 | 대기 · 이동 · 공격 · 피격 · 사망 | 입력 · 충돌 |
| 서킷 브레이커 | closed · open · half-open | 실패율 · 타임아웃 |
| Raft 노드 | follower · candidate · leader | 타임아웃 · 투표 |
| 렉서 | 일반 · 문자열 · 주석 · 숫자 | 글자 |
| CI 파이프라인 | queued · running · passed · failed | 이벤트 |
# UI 상태를 불린 여러 개로 관리하면 '불가능한 조합'이 생긴다
isLoading = True; isError = True; data = None # 이게 무슨 상태?
# ✓ 하나의 상태 값으로 — 불가능한 조합이 표현 자체가 안 된다
state = {"tag": "loading"}
state = {"tag": "success", "data": rows}
state = {"tag": "error", "message": "타임아웃"}
# TypeScript 라면 판별 유니온으로 컴파일러가 강제해 준다
# type State = {tag:"idle"} | {tag:"loading"} | {tag:"success", data:Row[]}
# | {tag:"error", message:string}
실무에선 이렇게 — DB 로 상태 기계 강제하기 🔴 실무
동시 요청에도 안전하게
-- ✗ 읽고-확인하고-쓰면 경쟁 상태가 생긴다
SELECT status FROM orders WHERE id = 1; -- 'paid'
-- (여기서 다른 요청이 끼어들어 'shipping' 으로 바꿈)
UPDATE orders SET status = 'canceled' WHERE id = 1; -- 잘못된 전이!
-- ✓ 조건부 UPDATE 한 번으로 (원자적)
UPDATE orders
SET status = 'canceled', updated_at = now()
WHERE id = 1
AND status IN ('pending', 'paid'); -- ★ 허용 상태를 조건에
-- 영향받은 행이 0 이면 전이가 거부된 것
rows = db.execute(
"UPDATE orders SET status=%s WHERE id=%s AND status = ANY(%s)",
(nxt, oid, ALLOWED_FROM[nxt])
).rowcount
if rows == 0:
raise ConflictError("이미 다른 상태로 바뀌었습니다") # 409 로 응답
# 상태 전이 이력을 별도 테이블에 남기면
# "언제 누가 왜 바꿨나"를 추적할 수 있다 (감사 로그)
정규 언어 — 정규식이 못 하는 일은 정해져 있습니다
“정규식으로 HTML 파싱하지 마라”는 관용구가 아니라 수학적 사실입니다. 정규식은 유한 상태 기계와 정확히 같은 힘을 가지고, 상태가 유한하면 중첩을 셀 수 없기 때문입니다.
정규식 = FSM
정규식 a(b|c)*d
↕ 완전히 동등
FSM q0 --a--> q1 --d--> q2(수락)
↺ b, c
정규 언어로 표현할 수 있는 것
· 고정된 패턴 이메일 형태 · 날짜 형식
· 반복 a* · [0-9]+
· 선택 (cat|dog)
표현할 수 없는 것 ★
· 괄호 짝 맞추기 ((()))
· 중첩 태그 <div><div></div></div>
· aⁿbⁿ (같은 개수)
· 회문 (팰린드롬)
이유: '몇 개나 열렸는지' 세려면 무한한 상태가 필요하다.
FSM 의 상태는 유한하다. 끝.
“현대 정규식”은 정규식이 아니다
import regex # pip install regex — 재귀 지원
# PCRE 의 재귀 패턴 — 이론상 정규식이 아니다
pattern = r'\((?:[^()]|(?R))*\)'
regex.match(pattern, "((a)(b))") # 매치됨 — 중첩을 센다
# 역참조도 정규 언어를 벗어난다
regex.match(r'(\w+) \1', "hello hello") # 같은 단어 반복 (표준 re 도 지원)
# ★ 그래서 이름이 헷갈린다
# 이론의 '정규 표현식' ≠ 프로그래밍 언어의 '정규식'
# 후자는 훨씬 강하지만, 그 대가로 성능 보장을 잃는다
이렇게도 쓸 수 있다 — ReDoS — 정규식으로 서버 죽이기 🟡 중급
한 줄짜리 입력으로 CPU 100%
import re, time
# 위험한 패턴: 중첩된 수량자 (a+)+
pattern = re.compile(r'^(a+)+$')
for n in (20, 24, 28):
s = "a" * n + "b" # 마지막에 매치 실패하는 글자
t = time.perf_counter()
pattern.match(s)
print(n, round(time.perf_counter() - t, 3))
# 20 0.03
# 24 0.5
# 28 8.5 ← n 이 4 늘 때마다 16배
위험한 패턴 모양
| 패턴 | 문제 |
|---|---|
(a+)+ (a*)* | 중첩 수량자 |
(a|a)* | 겹치는 선택지 |
(\s*\w+)*$ | 공백/문자 모호 |
^(.*,)*.*$ | CSV 파싱 시도 |
(.*)* | 최악 |
대응
| 입력 길이 제한 | 가장 쉽고 효과적 |
| RE2 계열 엔진 | Go · google-re2 — 선형 보장 |
| 타임아웃 | .NET Regex 는 지원 |
| 정적 검사 | ESLint · redos-detector |
| 단순화 | 파서로 대체 |
실무에선 이렇게 — 정규식을 쓸 자리와 말 자리 🔴 실무
판단 기준
| 작업 | 정규식 | 대신 쓸 것 |
|---|---|---|
| 이메일 형식 대략 확인 | 적합 | /^[^@\s]+@[^@\s]+\.\w+$/ 정도면 충분 |
| 이메일 완전 검증 | 부적합 | 확인 메일 발송 |
| 로그 한 줄 파싱 | 적합 | — |
| HTML · XML | 금지 | DOM 파서 |
| JSON · YAML | 금지 | 전용 파서 |
| CSV (따옴표 포함) | 금지 | CSV 라이브러리 |
| URL 분해 | 부적합 | URL 파서 |
| 중첩 괄호 · 코드 | 불가능 | 파서 → 04 |
# 정규식을 쓸 때의 실무 습관
import re
# ① 컴파일해서 재사용 (반복문 안에서 매번 만들지 않기)
LOG_RE = re.compile(
r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] '
r'"(?P<method>\w+) (?P<path>\S+)[^"]*" (?P<status>\d{3}) (?P<size>\d+)'
)
# ② 이름 있는 그룹 — 인덱스로 접근하면 순서 바뀔 때 조용히 깨진다
m = LOG_RE.match(line)
if m: print(m.group("status"), m.group("path"))
# ③ verbose 모드로 주석 달기
PATTERN = re.compile(r"""
^(?P<year>\d{4}) # 연도
-(?P<month>\d{2}) # 월
-(?P<day>\d{2})$ # 일
""", re.VERBOSE)
문맥자유 문법 — 중첩을 다루는 다음 단계
정규식이 못 하는 괄호 짝 · 중첩 구조를 다루는 문법입니다. 모든 프로그래밍 언어의 문법이 여기 속하고, 그래서 파서가 필요합니다.
BNF 로 문법 적기
# 사칙연산 문법 (연산자 우선순위가 구조에 들어 있다)
expr ::= expr '+' term
| expr '-' term
| term
term ::= term '*' factor
| term '/' factor
| factor
factor ::= NUMBER
| '(' expr ')' ← 재귀! 중첩이 가능해진다
# "정규식과의 결정적 차이"가 이 재귀다.
# 규칙이 자기 자신을 부를 수 있으므로 무한한 중첩을 표현한다.
expr 이 term 을 부르고
term 이 factor 를 부르는 계층 자체가
“곱셈이 덧셈보다 먼저”를 뜻합니다. 별도 규칙이 필요 없습니다.
모호성 — 파스 트리가 둘 이상
# 모호한 문법
expr ::= expr '+' expr | NUMBER
# "1 + 2 + 3" 의 파스 트리가 두 가지
# (1+2)+3 1+(2+3)
# 덧셈은 결과가 같지만 뺄셈이면 달라진다!
# 유명한 모호성: dangling else
if (a) if (b) x(); else y();
# else 는 어느 if 의 것인가?
# → 대부분의 언어가 "가장 가까운 if" 로 정함 (규칙으로 해소)
# 해소 방법
# ① 문법을 계층으로 다시 쓴다 (왼쪽 재귀 제거)
# ② 파서 생성기에 우선순위·결합성을 따로 준다 (%left, %right)
이렇게도 쓸 수 있다 — 재귀 하강 파서 직접 만들기 🟡 중급
문법 규칙 하나 = 함수 하나
import re
TOKEN = re.compile(r'\s*(\d+\.?\d*|[-+*/()])')
class Parser:
def __init__(self, text):
self.toks = TOKEN.findall(text)
self.i = 0
def peek(self): return self.toks[self.i] if self.i < len(self.toks) else None
def eat(self, t=None):
tok = self.peek()
if t and tok != t: raise SyntaxError(f"{t} 를 기대했는데 {tok}")
self.i += 1
return tok
def expr(self): # expr ::= term (('+'|'-') term)*
v = self.term()
while self.peek() in ("+", "-"):
op = self.eat()
v = v + self.term() if op == "+" else v - self.term()
return v
def term(self): # term ::= factor (('*'|'/') factor)*
v = self.factor()
while self.peek() in ("*", "/"):
op = self.eat()
v = v * self.factor() if op == "*" else v / self.factor()
return v
def factor(self): # factor ::= NUMBER | '(' expr ')'
if self.peek() == "(":
self.eat("("); v = self.expr(); self.eat(")")
return v
return float(self.eat())
Parser("2 + 3 * (4 - 1)").expr() # 11.0
실무에선 이렇게 — 파서를 만들어야 할 때 🔴 실무
직접 만들 것인가, 라이브러리를 쓸 것인가
| 상황 | 선택 |
|---|---|
검색 쿼리 문법 (tag:a AND (b OR c)) | 재귀 하강 직접 — 100줄이면 충분 |
| 수식 계산기 · 필터 DSL | 재귀 하강 또는 Pratt 파서 |
| 설정 언어 | 기존 것 사용 (TOML · YAML · HCL) |
| 완전한 프로그래밍 언어 | ANTLR · tree-sitter · lalrpop |
| 기존 언어 분석 | 그 언어의 AST 라이브러리 (ast · babel · roslyn) |
| 바이너리 포맷 | Kaitai Struct · 직접 구조체 읽기 |
# 실무 예: 검색 쿼리를 SQL 로 안전하게 변환
# 입력: status:open AND (assignee:me OR priority:high)
# 출력: status = %s AND (assignee = %s OR priority = %s), [값들]
#
# ★ 여기서 중요한 것은 '값을 문자열로 이어 붙이지 않는 것'이다.
# 파서가 값을 파라미터 목록으로 분리해 주면 SQL 인젝션이 원천 차단된다.
def to_sql(node):
if node.kind == "term":
return f"{safe_column(node.field)} = %s", [node.value]
if node.kind == "and":
l, lp = to_sql(node.left); r, rp = to_sql(node.right)
return f"({l} AND {r})", lp + rp
...
촘스키 위계 — 언어의 표현력에는 계층이 있습니다
정규 → 문맥자유 → 문맥의존 → 무제한. 위로 갈수록 표현할 수 있는 것이 늘고, 처리 비용이 커지고, 보장이 사라집니다. 도구를 고를 때 이 계층이 기준이 됩니다.
각 계층이 못 하는 것
정규 언어가 못 함
aⁿbⁿ (여는 것과 닫는 것의 개수 맞추기)
→ 괄호 · 태그 중첩
문맥자유가 못 함
aⁿbⁿcⁿ (세 개를 동시에 맞추기)
→ "선언된 변수만 사용" · "인자 개수 일치"
→ 그래서 컴파일러는 파싱 뒤에 '의미 분석' 단계를 따로 둔다
문맥의존도 못 함
정지 문제 (06 절)
실무에서의 의미
| “이거 왜 안 되지?” | 계층 이유 |
|---|---|
| 정규식으로 JSON 검증 | 중첩 = 문맥자유 필요 |
| 파서만으로 타입 오류 검출 | 타입 검사 = 문맥의존 |
| 린터가 모든 버그를 못 잡음 | 정지 문제 (타입 0) |
| YAML 이 예상 밖으로 동작 | 문법이 문맥자유를 넘음 |
| C++ 파싱이 어려움 | 문법이 튜링 완전(템플릿) |
a b(c); 가 선언인지 함수 호출인지
타입 정보를 알아야 결정됩니다. 그래서 파싱과 의미 분석을 분리할 수 없고,
C++ 컴파일러가 그렇게 복잡한 이유 중 하나입니다.
이렇게도 쓸 수 있다 — 설정 언어를 고르는 기준 🟡 중급
힘이 셀수록 위험합니다
| 형식 | 표현력 | 위험 | 쓸 때 |
|---|---|---|---|
| JSON | 데이터만 | 없음 | 기계 간 교환 |
| TOML | 데이터 + 주석 | 없음 | 사람이 쓰는 설정 |
| YAML | + 앵커 · 태그 | 노르웨이 문제 · 폭탄 | 익숙함 때문에 널리 쓰임 |
| HCL · Jsonnet | + 변수 · 함수 | 디버깅 어려움 | 중복이 많을 때 |
| Python · JS | 튜링 완전 | 코드 실행 | 신뢰된 입력만 |
# YAML 의 유명한 함정들
country: NO # → false (노르웨이 문제, YAML 1.1)
version: 1.20 # → 1.2 (숫자로 파싱)
time: 12:30 # → 750 (60진수로 해석되던 시절)
value: on # → true
# YAML 폭탄 — 9개의 앵커로 메모리를 터뜨린다
a: &a ["x","x","x","x","x","x","x","x","x"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
# ... 9단계면 9⁹ ≈ 3.9억 개 노드
# ✓ 방어: safe_load 사용 + 크기 제한
yaml.safe_load(text) # 파이썬 — load() 는 임의 객체 생성 가능
yaml.load() 는 코드 실행 취약점입니다 —
기본값이 임의 파이썬 객체를 만들 수 있습니다.
반드시 safe_load 를 쓰세요. 같은 이유로
pickle 은 신뢰되지 않은 데이터에 절대 쓰면 안 됩니다.
실무에선 이렇게 — 최소 권한의 언어 원칙 🔴 실무
필요한 만큼만 강하게
보안의 최소 권한 원칙과 같은 아이디어를 언어 설계에 적용한 것이 “최소 권한의 언어 규칙”(Rule of Least Power)입니다 — 목적을 달성하는 가장 약한 언어를 고르라. 약할수록 분석·검증·최적화·재사용이 쉬워지기 때문입니다.
| 약한 언어의 이점 | 실제 사례 |
|---|---|
| 정적 분석이 완전함 | SQL 실행 계획 · CSS 최적화 |
| 반드시 종료함 | Bitcoin Script · eBPF 검증기 |
| 병렬화 가능 | SQL · 데이터플로 DSL |
| 도구가 이해함 | HTML → 스크린 리더 · 검색 엔진 |
| 캐시·재사용 가능 | 선언적 빌드 (Bazel · Nix) |
실무 판단 예 — "사용자가 알림 조건을 정의하게 하고 싶다"
① 가장 약한 선택: 드롭다운 조합
[지표] [연산자] [값] → 표현력은 낮지만 UI 가 명확
② 조금 강하게: 제한된 식 언어 (CEL)
cpu > 0.8 && duration > "5m"
→ 종료 보장 · 정적 검증 가능
③ 너무 강함: 파이썬 스크립트 허용
→ 격리 · 타임아웃 · 자원 제한이 전부 필요해진다
대부분의 경우 ② 가 정답이다.
정지 문제 — 못 푸는 문제가 존재한다는 증명
“이 프로그램이 무한 루프에 빠지는지 미리 알려 주는 도구”는 만들 수 없습니다. 아직 못 만든 게 아니라 불가능이 증명돼 있습니다. 그 결과가 우리가 쓰는 모든 정적 분석 도구의 한계를 정합니다.
증명 — 자기 자신에게 물어보기
# 이런 함수가 있다고 가정하자
def halts(program, input) -> bool:
"""program(input) 이 언젠가 끝나면 True"""
... # 존재한다고 가정
# 그러면 이걸 만들 수 있다
def paradox(program):
if halts(program, program):
while True: pass # 끝난다고 하면 → 무한 루프
else:
return # 안 끝난다고 하면 → 끝냄
# 그리고 자기 자신을 넣는다
paradox(paradox)
# · halts 가 True 라 하면 → paradox 는 무한 루프 → 모순
# · halts 가 False 라 하면 → paradox 는 즉시 종료 → 모순
# 따라서 halts 는 존재할 수 없다. ∎
라이스 정리 — 더 나쁜 소식
결정 불가능한 질문들 (전부)
· 이 함수가 항상 같은 값을 반환하는가
· 이 코드에 도달 불가능한 부분이 있는가
· 이 프로그램이 파일을 지우는가
· 두 프로그램이 같은 일을 하는가
· 이 변수가 null 이 될 수 있는가
★ "일반적으로는" 불가능하다는 뜻이다.
특정한 프로그램에 대해서는 대개 답할 수 있다.
이렇게도 쓸 수 있다 — 그래서 도구들은 어떻게 하나 🟡 중급
세 가지 타협
| 전략 | 뜻 | 도구 |
|---|---|---|
| 건전(sound) | 놓치지 않지만 오탐 허용 | 러스트 borrow checker · 타입 검사기 |
| 완전(complete) | 오탐 없지만 미탐 허용 | 대부분의 린터 · 버그 탐지기 |
| 제한 | 분석 가능한 코드만 허용 | eBPF 검증기 · Coq · 종료 검사기 |
// 러스트 borrow checker 는 '건전' 쪽이다 — 안전한 코드도 거부한다
use std::collections::HashMap;
fn get_or_insert(map: &mut HashMap<u32, String>) -> &String {
if let Some(v) = map.get(&1) { // 불변 빌림
return v; // 빌림을 그대로 반환
}
map.insert(1, String::new()); // ✗ 컴파일 에러 E0502 — 위 빌림이 아직 살아 있다고 본다
map.get(&1).unwrap()
}
// None 경로에서는 빌림이 이미 끝났으므로 실제로는 안전하지만, 컴파일러가 증명하지 못해 거부한다
// (차세대 검사기 Polonius 는 통과시킨다).
// "거부되는 안전한 코드"가 존재하는 것이 건전한 분석의 필연적 대가.
// ※ let first = &v[0]; v.push(1); 은 다르다 — push 가 재할당하면 first 가 해제된 메모리를 가리키는 진짜 버그다.
# 타입 검사기도 마찬가지 — 실행하면 되는 코드를 거부할 수 있다
def f(x: int | str) -> int:
if isinstance(x, int):
return x
return len(x) # 여기서 x 가 str 임을 좁혀서 알아낸다 (narrowing)
# 좁히기가 안 되는 형태면 타입 검사기가 못 따라온다
def g(items, i):
if check(items, i): # 이 함수가 무엇을 보장하는지 모른다
return items[i].value # → Optional 경고
실무에선 이렇게 — 종료를 보장하는 코드 쓰기 🔴 실무
일반적으로는 불가능해도 내 코드는 보장할 수 있다
# ① 감소하는 값(variant)을 명시한다
def process(queue, max_iterations=10_000):
for i in range(max_iterations): # ★ 무조건 끝나는 반복
if not queue: return
item = queue.pop()
...
raise RuntimeError("반복 한도 초과 — 큐가 줄지 않음")
# ② while True 를 쓸 때는 반드시 탈출 조건을 명시적으로 계산
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
...
# ③ 재귀는 깊이를 인자로
def walk(node, depth=0):
if depth > 100: raise ValueError("중첩이 너무 깊음")
for c in node.children: walk(c, depth + 1)
# ④ 외부 호출에는 전부 타임아웃
requests.get(url, timeout=(3, 10)) # 연결 3초, 읽기 10초
| 패턴 | 없으면 생기는 일 |
|---|---|
| 반복 상한 | 큐가 줄지 않으면 영원히 회전 · CPU 100% |
| 재귀 깊이 제한 | 중첩 입력으로 스택 소진 DoS |
| 네트워크 타임아웃 | 커넥션 풀 고갈 → 전면 장애 |
| 정규식 입력 길이 제한 | ReDoS (03 절) |
| 페이지네이션 상한 | limit=99999999 로 메모리 폭발 |
빅오 표기법 — 정확히 읽는 법
빅오는 “입력이 커질 때 얼마나 더 느려지는가”만 말합니다. 실제 속도도, 작은 입력에서의 성능도, 메모리도 말하지 않습니다. 그 경계를 알아야 잘못된 판단을 피할 수 있습니다.
| 복잡도 | 연산 횟수 | 1초에 1억 번이라면 |
|---|
숫자를 바꿔 보세요 — n 이 100배가 될 때 무엇이 무너지는지가 한눈에 보입니다.
정의와 표기
n ≥ n₀ 일 때 f(n) ≤ c·g(n) “충분히 커지면 상수배 안에 들어온다”는 뜻입니다.
| 표기 | 뜻 | 비유 |
|---|---|---|
| O(g) | 상한 | ≤ |
| Ω(g) | 하한 | ≥ |
| Θ(g) | 상한 = 하한 | = |
| o(g) | 엄격한 상한 | < |
빅오가 말하지 않는 것
| 무시하는 것 | 현실에서의 영향 |
|---|---|
| 상수 배수 | 100배 차이도 무시 |
| 작은 n | n<100 이면 대개 무의미 |
| 캐시 지역성 | 실제로는 10배까지 |
| 메모리 할당 | GC 압력 · 지연 스파이크 |
| 평균 vs 최악 | 퀵 정렬 O(n²) 는 드묾 |
| 입력 분포 | 거의 정렬된 배열 |
# 이론상 O(1) 이 O(n) 보다 항상 빠르지 않다
d = {i: i for i in range(20)} # 해시 — O(1)
lst = [(i, i) for i in range(20)] # 리스트 순회 — O(n)
# n=20 정도면 리스트 선형 탐색이 더 빠른 경우가 많다
# · 해시 계산 비용이 없다
# · 연속 메모리 → 캐시에 통째로 들어간다
# 그래서 작은 맵을 배열로 구현하는 라이브러리가 많다
이렇게도 쓸 수 있다 — 성장률 감각 🟡 중급
n 이 10배가 되면
| 복잡도 | 10배 → 시간 | 실제 예 |
|---|---|---|
| O(1) | 그대로 | 해시 조회 · 배열 인덱스 |
| O(log n) | +상수 | 이진 탐색 · B트리 · 힙 |
| O(n) | 10배 | 한 번 훑기 · 최댓값 |
| O(n log n) | 약 12배 | 정렬 · FFT |
| O(n²) | 100배 | 모든 쌍 비교 · 버블 정렬 |
| O(n³) | 1000배 | 행렬 곱 · 플로이드 |
| O(2ⁿ) | 천문학적 | 부분집합 전수 탐색 |
| O(n!) | 말할 수 없음 | 전순열 |
실무에선 이렇게 — 실측으로 복잡도 추정하기 🔴 실무
코드를 읽지 않고 알아내는 법
import time, math
def measure(fn, sizes):
out = []
for n in sizes:
data = make_input(n)
t = time.perf_counter()
fn(data)
out.append((n, time.perf_counter() - t))
return out
def estimate(points):
"""연속한 두 측정의 비율로 복잡도를 추정"""
for (n1, t1), (n2, t2) in zip(points, points[1:]):
if t1 <= 0: continue
ratio = t2 / t1
growth = n2 / n1
power = math.log(ratio) / math.log(growth)
print(f"n {n1}→{n2}: 시간 {ratio:.1f}배 → 약 O(n^{power:.2f})")
estimate(measure(my_func, [1000, 2000, 4000, 8000, 16000]))
# 출력 해석
# power ≈ 0 → O(1)
# power ≈ 1 → O(n)
# power ≈ 1.1 → O(n log n)
# power ≈ 2 → O(n²)
| 측정할 때 주의 | 이유 |
|---|---|
| 워밍업 후 측정 | JIT · 캐시 · 첫 할당 |
| 여러 번 재서 중앙값 | GC · 스케줄링 노이즈 |
| 충분히 큰 n | 작으면 상수항이 지배 |
| 입력을 매번 새로 | 캐시에 남아 왜곡 |
| 최악 입력도 함께 | 평균만 보면 놓침 |
시간복잡도 계산 — 코드에서 손으로 세는 법
규칙 네 개면 대부분의 코드를 계산할 수 있습니다. 그리고 실무에서 자주 놓치는 숨은 O(n) 을 찾는 눈을 만듭니다.
규칙 네 개
# ① 순차 실행 → 더한다 (큰 쪽만 남음)
f() # O(n)
g() # O(n²)
# → O(n + n²) = O(n²)
# ② 중첩 반복 → 곱한다
for i in range(n): # n 번
for j in range(n): # × n 번
work() # × O(1)
# → O(n²)
# ③ 반으로 줄면 → log
while n > 1:
n //= 2
# → O(log n)
# ④ 재귀 → 점화식 (🔢 이산수학 12 마스터 정리)
def f(n):
if n <= 1: return
f(n // 2); f(n // 2)
for _ in range(n): work()
# T(n) = 2T(n/2) + n → O(n log n)
헷갈리는 형태들
# 안쪽이 i 까지만 도는 경우
for i in range(n):
for j in range(i): # 0+1+2+...+(n-1) = n(n-1)/2
work()
# → O(n²) (절반이어도 n² 이다)
# 안쪽이 고정 횟수
for i in range(n):
for j in range(100): # 상수
work()
# → O(n)
# 두 개의 서로 다른 입력
for a in A: # len(A) = n
for b in B: # len(B) = m
work()
# → O(n·m) ★ O(n²) 이라고 쓰면 틀림
# 반복마다 크기가 반씩 주는 작업
i = n
while i > 0:
for j in range(i): work() # n + n/2 + n/4 + ... = 2n
i //= 2
# → O(n) (합이 등비급수라 2n 으로 수렴)
이렇게도 쓸 수 있다 — 숨어 있는 O(n) 찾기 🟡 중급
한 줄처럼 보이지만 반복문인 것들
| 코드 | 실제 복잡도 | 대안 |
|---|---|---|
x in list | O(n) | set · dict |
list.insert(0, x) | O(n) | deque.appendleft |
list.pop(0) | O(n) | deque.popleft |
s += "x" 반복 | O(n²) | "".join(parts) |
list[:] 슬라이스 | O(n) | 인덱스 사용 |
del list[i] | O(n) | 표시 후 일괄 필터 |
min(list) 반복 호출 | O(n²) | 힙 · 한 번에 정렬 |
| ORM 지연 로딩 | N+1 쿼리 | join · 프리페치 |
# 가장 흔한 실무 사고: 반복문 안의 in
def find_new(incoming, existing):
return [x for x in incoming if x not in existing] # ✗ O(n×m)
def find_new(incoming, existing):
seen = set(existing) # ✓ O(n+m)
return [x for x in incoming if x not in seen]
# 1만 × 1만 = 1억 번 → 수 초
# set 으로 바꾸면 → 0.01초
실무에선 이렇게 — 복잡도가 같아도 다른 것 🔴 실무
상수가 지배하는 자리
# 둘 다 O(n) 인데 속도가 10배 넘게 차이 난다
import numpy as np, time
data = list(range(10_000_000))
arr = np.arange(10_000_000)
t = time.perf_counter(); s1 = sum(data); py = time.perf_counter() - t
t = time.perf_counter(); s2 = arr.sum(); np_ = time.perf_counter() - t
print(round(py / np_, 1), "배") # 약 10~40배 (환경마다 다름)
# 차이의 원인 (전부 빅오 밖의 것들)
# · 파이썬 객체 포인터 추적 vs 연속 메모리
# · 인터프리터 루프 vs 기계어 루프
# · SIMD 벡터 명령 사용
# · 캐시 라인 활용
| 같은 복잡도인데 느린 이유 | 확인 방법 |
|---|---|
| 캐시 미스 (임의 접근) | perf stat · 접근 패턴 확인 |
| 메모리 할당 반복 | 할당 프로파일러 · GC 로그 |
| 가상 호출 · 인터페이스 | 인라인 여부 확인 |
| 분기 예측 실패 | 정렬된 입력과 비교 |
| 시스템 콜 · I/O | strace · 배치 처리로 전환 |
| 락 경합 | 스레드 수를 줄여 보기 |
공간복잡도 — 메모리로 시간을 산다
캐시 · 인덱스 · 메모이제이션은 전부 같은 거래입니다 — 메모리를 더 써서 시간을 줄인다. 반대 방향의 거래가 필요한 순간도 있습니다.
무엇을 세는가
# 보통 '추가로 쓰는' 메모리만 센다 (입력 제외)
def sum_all(arr): # O(1) 공간
t = 0
for x in arr: t += x
return t
def doubled(arr): # O(n) 공간
return [x * 2 for x in arr]
def fib_memo(n, memo={}): # O(n) 공간
...
def merge_sort(a): # O(n) 공간 (병합용 버퍼)
...
def quick_sort(a): # O(log n) 공간 (재귀 스택)
...
# ★ 재귀 스택도 공간이다. 깊이 n 재귀는 O(n) 공간.
트레이드오프의 실제 사례
| 기법 | 메모리 | 얻는 것 |
|---|---|---|
| DB 인덱스 | +30~100% | 조회 O(n)→O(log n) |
| 메모이제이션 | +O(상태 수) | 지수→다항 |
| Redis 캐시 | +데이터 일부 | DB 왕복 제거 |
| 역인덱스 | +원문 크기급 | 전문 검색 |
| 비정규화 | 중복 저장 | JOIN 제거 |
| 미리 계산 | 결과 테이블 | 집계 즉시 응답 |
이렇게도 쓸 수 있다 — 메모리 O(1) 로 처리하기 🟡 중급
전부 읽지 않고 처리하는 습관
✗ 통째로 메모리에
rows = db.query("SELECT * FROM logs") # 5천만 행
for r in rows:
process(r)
# → OOM
lines = open("big.csv").readlines()
data = json.load(open("big.json"))
✓ 흘려보내며
# 커서 · 청크 단위
for r in db.stream("SELECT * FROM logs"):
process(r)
with open("big.csv") as f:
for line in f: # 한 줄씩
process(line)
# 대용량 JSON 은 스트리밍 파서
import ijson
for item in ijson.items(f, "records.item"):
process(item)
# 제너레이터 파이프라인 — 메모리 O(1) 로 여러 단계 연결
def read(path):
with open(path) as f:
for line in f: yield line.rstrip("\n")
def parse(lines):
for line in lines:
try: yield json.loads(line)
except json.JSONDecodeError: continue
def filter_errors(records):
for r in records:
if r.get("level") == "ERROR": yield r
# 5GB 파일도 메모리 몇 MB 로 처리된다
for rec in filter_errors(parse(read("app.log"))):
alert(rec)
실무에선 이렇게 — 메모리 사용을 실제로 재기 🔴 실무
추정하지 말고 측정
import sys, tracemalloc
# ① 객체 하나의 크기 (얕은 크기)
sys.getsizeof([1,2,3]) # 88 ← 리스트 껍데기만
sys.getsizeof(1) # 28 ← 정수 하나가 28바이트!
# 파이썬 객체는 생각보다 크다
# int 28B · float 24B · str(1글자) 42B(3.12+, 이전 50B) · 빈 dict 64B
# 1000만 개 정수 리스트 = 약 400MB (numpy 는 80MB)
# ② 실제 증가분 추적
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
data = build_big_thing()
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:10]:
print(stat)
# ③ 프로세스 전체
import resource
resource.getrusage(resource.RUSAGE_SELF).ru_maxrss # KB (리눅스)
| 메모리를 줄이는 실전 수단 | 효과 |
|---|---|
__slots__ (파이썬 클래스) | 인스턴스당 40~50% 절약 |
| numpy · array 로 교체 | 정수 배열 5배 절약 |
| 문자열 인터닝 · 카테고리 타입 | 중복 문자열 제거 |
| 제너레이터 · 청크 처리 | O(n) → O(1) |
| 필요한 컬럼만 SELECT | 비례 절약 |
| 확률적 자료구조 | 정확도와 맞바꿔 수십 배 |
상환 분석 — 가끔 비싼 일을 어떻게 세는가
list.append() 는 대부분 O(1) 인데 가끔 O(n) 입니다.
그런데도 “평균 O(1)” 이라고 말하는 근거가 상환 분석이고,
이 개념이 지연 스파이크를 설명해 줍니다.
동적 배열이 두 배씩 늘리는 이유
용량이 꽉 차면 2배로 늘리고 전부 복사한다
append 1~4 : 각 O(1)
append 5 : 복사 4개 → O(n)
append 6~8 : 각 O(1)
append 9 : 복사 8개 → O(n)
...
n 번 append 하는 동안의 총 복사 횟수
1 + 2 + 4 + 8 + ... + n/2 + n < 2n
→ 총 비용 O(n) / n 번 = 평균 O(1) ★ 상환 O(1)
★ 만약 '한 칸씩' 늘린다면?
1 + 2 + 3 + ... + n = n²/2 → 평균 O(n) ← 훨씬 나쁘다
세 가지 분석 방법
| 방법 | 아이디어 |
|---|---|
| 총합법 | n 번의 총비용 ÷ n |
| 회계법 | 싼 연산에 미리 요금을 더 받아 비싼 연산 때 씀 |
| 포텐셜법 | 자료구조의 “긴장도”를 함수로 정의 |
회계법으로 본 append
· append 한 번에 3 코인을 청구한다
① 1 코인: 실제 삽입
② 1 코인: 나중에 자기를 복사할 때 쓸 저금
③ 1 코인: 이미 복사된 앞쪽 원소를 위한 저금
· 확장이 일어날 때 저금이 항상 충분하다
→ 연산당 상수(3) → 상환 O(1) ∎
이렇게도 쓸 수 있다 — 상환 O(1) 인 것들 🟡 중급
“평균은 빠른데 가끔 튀는” 자리 목록
| 연산 | 평상시 | 가끔 | 왜 |
|---|---|---|---|
| 배열 append | O(1) | O(n) | 용량 확장 + 복사 |
| 해시맵 삽입 | O(1) | O(n) | 리해싱 |
| 유니온 파인드 | ≈O(1) | — | 경로 압축의 상환 |
| 두 스택 큐 | O(1) | O(n) | 스택 뒤집기 |
| GC 있는 할당 | O(1) | 수십 ms | 수집 사이클 |
| LSM 트리 쓰기 | O(1) | 초 단위 | 컴팩션 |
| B트리 삽입 | O(log n) | — | 분할은 드묾 |
실무에선 이렇게 — 스파이크를 평탄화하기 🔴 실무
상환 비용을 미리 지불하는 기법
# ① 미리 용량 확보 — 확장 자체를 없앤다
items = [None] * expected_size # 파이썬
# C++: vec.reserve(n) Java: new ArrayList<>(n)
# Go: make([]T, 0, n) Rust: Vec::with_capacity(n)
# ② 해시맵도 마찬가지
d = dict() # 파이썬은 지정 불가
# Java: new HashMap<>((int) (expected / 0.75f) + 1)
# Go: make(map[string]int, n)
# ③ 점진적 리해싱 — 한 번에 다 옮기지 않는다
# Redis 가 쓰는 방식: 새 테이블을 만들고
# 이후 연산마다 조금씩 옮겨 스파이크를 분산
# ④ 오브젝트 풀 — 할당 자체를 피한다 (GC 압력 감소)
class BufferPool:
def __init__(self, size, count):
self.size = size
self.free = [bytearray(size) for _ in range(count)]
def get(self): return self.free.pop() if self.free else bytearray(self.size)
def put(self, b): b[:] = b'\0' * len(b); self.free.append(b)
| 기법 | 효과 | 주의 |
|---|---|---|
| 사전 할당 (reserve) | 확장 스파이크 제거 | 과대 추정 시 메모리 낭비 |
| 점진적 이전 | 스파이크 분산 | 구현 복잡 |
| 오브젝트 풀 | GC 압력 감소 | 누수·상태 오염 위험 |
| 배치 크기 조절 | 피크 완화 | 처리량 감소 |
| 백그라운드 컴팩션 | 사용자 경로에서 제거 | 여유 자원 필요 |
P · NP · NP-완전 — 포기해야 할 때를 아는 법
어떤 문제는 아무리 잘 짜도 지수 시간입니다. 그것이 이 문제의 성질임을 알면 완벽한 답 대신 좋은 답을 빨리 내는 쪽으로 방향을 틀 수 있습니다.
정의
| 부류 | 뜻 |
|---|---|
| P | 답을 찾는 데 다항 시간 |
| NP | 답을 확인하는 데 다항 시간 |
| NP-완전 | NP 중 가장 어려운 것들 — 하나 풀면 전부 풀림 |
| NP-난해 | NP-완전만큼 어렵되 NP 에 속하지 않을 수도 |
스도쿠로 이해하기
· 완성된 스도쿠가 맞는지 확인 → 몇 초 (NP)
· 빈 스도쿠를 푸는 것 → 크기가 커지면 폭발
P = NP 인가?
= "확인이 쉬우면 찾는 것도 쉬운가?"
= 밀레니엄 문제 (상금 100만 달러)
= 대부분의 학자는 P ≠ NP 라고 믿는다
현실 문제 중 NP-완전인 것들
| 문제 | 실무에서의 모습 |
|---|---|
| 배낭 문제 | 예산 안에서 가치 최대화 |
| 외판원 (TSP) | 배송 경로 최적화 |
| 그래프 색칠 | 레지스터 할당 · 시간표 |
| 집합 커버 | 최소 서버로 전 지역 커버 |
| 빈 채우기 (bin packing) | 컨테이너 배치 · VM 스케줄링 |
| SAT | 의존성 해결 · 설정 검증 |
| 일정 계획 | 근무표 · 작업 스케줄링 |
이렇게도 쓸 수 있다 — 환원 — 어렵다는 것을 보이는 법 🟡 중급
“이 문제는 저 문제만큼 어렵다”
내 문제 X 가 어렵다는 것을 보이려면
이미 NP-완전인 문제 Y 를 X 로 '다항 시간에 바꿀 수 있음'을 보인다.
Y ──다항 변환──▶ X
"X 를 빨리 풀 수 있다면 Y 도 빨리 풀린다"
Y 가 어려우므로 X 도 어렵다. ∎
실무에서의 쓸모
· "이거 최적으로 못 풀어요" 를 근거 있게 말할 수 있다
· 최적해 대신 근사·휴리스틱으로 방향을 트는 결정을 정당화
· 반대로: 내 문제가 이미 알려진 문제와 같으면
→ 검증된 솔버를 그대로 쓰면 된다 (직접 짜지 말 것)
내 문제를 알려진 문제로 옮기기
| 현실 문제 | 알려진 이름 |
|---|---|
| 회의실 배정 | 구간 스케줄링 (P!) |
| 작업자 배치 | 이분 매칭 (P!) |
| 서버 배치 비용 최소 | 시설 입지 (NP) |
| 기능 조합 선택 | 배낭 (NP) |
| 테스트 최소 집합 | 집합 커버 (NP) |
실무에선 이렇게 — P 인 줄 알았는데 아닌 경우 🔴 실무
조건이 하나 붙으면 난이도가 뒤집힙니다
| 문제 | 난이도 |
|---|---|
| 최단 경로 (음수 없음) | P 다익스트라 |
| 최단 단순 경로 (음수 사이클 있음) | NP-난해 |
| 2-SAT | P 선형 |
| 3-SAT | NP-완전 |
| 2색 칠하기 (이분 그래프 판정) | P |
| 3색 칠하기 | NP-완전 |
| 최대 매칭 | P |
| 최대 독립 집합 | NP-완전 |
| 오일러 경로 (모든 간선) | P |
| 해밀턴 경로 (모든 정점) | NP-완전 |
실무 대화로 옮기면
기획: "배송 경로를 최적으로 짜 주세요"
개발: "정점 20개까지는 최적해를 낼 수 있습니다.
그 이상은 NP-난해라 최적을 보장할 수 없고,
보통 최적 대비 2~5% 안쪽의 답을 1초에 냅니다.
그 차이가 비즈니스적으로 의미가 있을까요?"
→ 이론을 알면 '못 한다' 가 아니라 '이렇게 하겠다' 가 된다.
정의를 정확히
“실무에서 빠르다”와는 다릅니다 — 이론적 분류입니다
“Not Polynomial” 이 아닙니다 — 가장 흔한 오해
P ⊆ NP 는 자명합니다 (풀 수 있으면 검증도 할 수 있으니까요)
미해결은 그 반대입니다 — NP ⊆ P 인가?
② 어떤 NP 문제든 다항 시간에 이 문제로 바꿀 수 있다
NP-난해(NP-hard) 는 ②만 만족 — NP 밖일 수도 있습니다
(예: 정지 문제는 NP-난해이지만 NP 가 아닙니다)
실무에서 만나면 이렇게 합니다
| 실무 문제 | 정체 |
|---|---|
| 배송 경로 최적화 | 외판원(TSP) — NP-완전 |
| 서버에 작업 배치 | 빈 패킹 — NP-완전 |
| 시간표 · 근무표 짜기 | 그래프 색칠 — NP-완전 |
| 예산 안에서 최대 이익 | 배낭 문제 — NP-완전 |
| 패키지 의존성 해결 | SAT — NP-완전 |
| 작업 배정(1:1) | 이분 매칭 — P (알고리즘 15) |
NP-완전임을 알아챘다면 — 선택지는 넷입니다
① 입력 크기를 제한한다
n ≤ 20 이면 2^20 = 100만, 완전 탐색이 그냥 됩니다
"실무 데이터가 정말 큰가"를 먼저 확인하세요
② 근사 알고리즘
최적의 몇 % 이내를 보장하며 빠르게
예: TSP 의 크리스토피데스 — 최적의 1.5배 이내 보장
③ 휴리스틱
보장은 없지만 대개 잘 되는 방법
담금질 · 유전 알고리즘 · 지역 탐색
④ 솔버에 맡긴다 ← 실무에서 가장 현실적
SAT 솔버 · MILP 솔버(OR-Tools, CBC, Gurobi)
수십 년간 최적화된 도구가 대부분의 실제 인스턴스를 풉니다
NP 를 실무에서 다루기 — 근사 · 휴리스틱 · 솔버
최적해가 불가능하면 세 가지 길이 있습니다 — 품질을 보장하는 근사 · 보장은 없지만 잘 되는 휴리스틱 · 남이 만든 솔버에 맡기기. 대개 세 번째가 정답입니다.
① 근사 알고리즘
“최적의 k 배 이내”를 증명할 수 있는 방법.
· 정점 커버 → 2배 이내
· TSP(삼각부등식) → 1.5배
· 집합 커버 → ln n 배
· 빈 채우기 → 1.7배
품질 보장이 필요하면
이쪽을 쓴다
② 휴리스틱 · 메타휴리스틱
보장은 없지만 실제 데이터에서 잘 됨.
· 탐욕 + 지역 탐색
· 시뮬레이티드 어닐링
· 유전 알고리즘
· 타부 서치
· 개미 군집
구현이 쉽고
튜닝 여지가 크다
③ 솔버에 맡기기 권장
문제를 표준 형태로 기술만 하고 나머지는 맡김.
· MILP: CBC · HiGHS · Gurobi
· CP-SAT: OR-Tools
· SAT: MiniSat · CaDiCaL
· SMT: Z3
수십 년 누적된 최적화가
들어 있다
이렇게도 쓸 수 있다 — OR-Tools 로 배정 문제 풀기 🟡 중급
근무표 짜기 — 30줄
from ortools.sat.python import cp_model
workers, days, shifts = range(5), range(7), range(3)
m = cp_model.CpModel()
# 변수: x[w][d][s] = 1 이면 근무자 w 가 d 일 s 교대에 근무
x = {(w,d,s): m.NewBoolVar(f"x{w}_{d}_{s}")
for w in workers for d in days for s in shifts}
# 제약 ① 각 교대에는 정확히 한 명
for d in days:
for s in shifts:
m.AddExactlyOne(x[w,d,s] for w in workers)
# 제약 ② 한 사람은 하루에 최대 한 교대
for w in workers:
for d in days:
m.AddAtMostOne(x[w,d,s] for s in shifts)
# 제약 ③ 야간(2) 다음 날 오전(0) 금지
for w in workers:
for d in days[:-1]:
m.Add(x[w,d,2] + x[w,d+1,0] <= 1)
# 목표: 근무 수를 최대한 고르게
loads = [sum(x[w,d,s] for d in days for s in shifts) for w in workers]
mx, mn = m.NewIntVar(0, 21, "mx"), m.NewIntVar(0, 21, "mn")
m.AddMaxEquality(mx, loads); m.AddMinEquality(mn, loads)
m.Minimize(mx - mn)
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = 10.0 # ★ 시간 예산
status = solver.Solve(m)
실무에선 이렇게 — 최적화 문제 접근 순서 🔴 실무
단계별 판단
- 정말 최적해가 필요한가 — “충분히 좋으면” 되는 경우가 대부분입니다. 1% 개선을 위해 개발 3개월을 쓰는지 계산해 보세요.
- 규모를 확인 — n ≤ 20 이면 완전 탐색, n ≤ 40 이면 분할 정복 + 비트마스크, 그 이상이면 솔버나 휴리스틱.
- 알려진 문제인지 확인 — 이름을 붙일 수 있으면 라이브러리가 있습니다.
- 솔버로 먼저 시도 — 시간 예산을 두고 돌려 보면 “현실 데이터에서는 금방 풀리는” 경우가 흔합니다.
- 안 되면 탐욕 + 지역 탐색 — 대개 최적의 5~10% 안쪽이 나옵니다.
- 품질을 측정 — 상한(LP 완화)과 비교해 “최적에서 몇 % 안”인지 보고합니다.
# 실무에서 자주 쓰는 조합: 탐욕으로 시작해 지역 탐색으로 개선
def local_search(solution, cost, neighbors, max_iter=10_000):
best, best_cost = solution, cost(solution)
for _ in range(max_iter):
improved = False
for cand in neighbors(best): # 예: 두 원소 교환
c = cost(cand)
if c < best_cost:
best, best_cost, improved = cand, c, True
break
if not improved: break # 지역 최적에 도달
return best, best_cost
# 지역 최적에서 빠져나오려면
# · 재시작 (여러 초기해)
# · 시뮬레이티드 어닐링 (가끔 나쁜 쪽도 수용)
# · 타부 리스트 (최근 이동 금지)
복잡도 치트시트 — 한 장으로
자료구조 · 정렬 · 그래프 알고리즘의 복잡도를 모아 둡니다. 설계할 때 · 리뷰할 때 · 면접 전에 여기만 보면 됩니다.
자료구조
| 구조 | 탐색 | 삽입 | 삭제 | 공간 | 비고 |
|---|---|---|---|---|---|
| 배열 | O(n) | O(n) | O(n) | O(n) | 인덱스 접근 O(1) |
| 동적 배열 | O(n) | O(1)* | O(n) | O(n) | *끝에 추가 · 상환 |
| 연결 리스트 | O(n) | O(1) | O(1) | O(n) | 위치를 알 때만 |
| 해시 테이블 | O(1) | O(1) | O(1) | O(n) | 최악 O(n) |
| 이진 탐색 트리 | O(log n) | O(log n) | O(log n) | O(n) | 편향 시 O(n) |
| 균형 트리 | O(log n) | O(log n) | O(log n) | O(n) | 최악도 보장 |
| B+트리 | O(log n) | O(log n) | O(log n) | O(n) | 디스크 친화 |
| 힙 | O(n) | O(log n) | O(log n) | O(n) | 최소/최대는 O(1) |
| 트라이 | O(m) | O(m) | O(m) | O(ALPHA·N) | m = 문자열 길이 |
| 유니온 파인드 | ≈O(1) | ≈O(1) | — | O(n) | 역아커만 함수 |
| 블룸 필터 | O(k) | O(k) | 불가 | O(m)≪n | 위양성 있음 |
정렬
| 알고리즘 | 평균 | 최악 | 공간 | 안정 | 실무 |
|---|---|---|---|---|---|
| 퀵 정렬 | O(n log n) | O(n²) | O(log n) | ✗ | 가장 빠름(평균) |
| 병합 정렬 | O(n log n) | O(n log n) | O(n) | ✓ | 외부 정렬 |
| 힙 정렬 | O(n log n) | O(n log n) | O(1) | ✗ | 최악 보장 |
| 팀소트 | O(n log n) | O(n log n) | O(n) | ✓ | 파이썬·자바 기본 |
| 삽입 정렬 | O(n²) | O(n²) | O(1) | ✓ | 작은 배열엔 최고 |
| 계수 정렬 | O(n+k) | O(n+k) | O(k) | ✓ | 값 범위가 좁을 때 |
| 기수 정렬 | O(d(n+k)) | O(d(n+k)) | O(n+k) | ✓ | 고정 길이 키 |
이렇게도 쓸 수 있다 — 그래프 알고리즘 🟡 중급
| 알고리즘 | 복잡도 | 쓰는 곳 |
|---|---|---|
| BFS · DFS | O(V+E) | 탐색 · 연결성 · 사이클 |
| 위상 정렬 | O(V+E) | 의존성 순서 |
| 다익스트라 (힙) | O(E log V) | 최단 경로 (음수 없음) |
| 벨만-포드 | O(V·E) | 음수 간선 · 음수 사이클 검출 |
| 플로이드-워셜 | O(V³) | 모든 쌍 최단 경로 |
| 크루스칼 | O(E log E) | MST |
| 프림 (힙) | O(E log V) | MST (조밀하면 배열판 O(V²) 이 낫다) |
| SCC (타잔) | O(V+E) | 강한 연결 요소 |
| 최대 유량 (Dinic) | O(V²E) | 매칭 · 용량 배분 |
| 이분 매칭 | O(V·E) | 배정 문제 |
실무에선 이렇게 — 이어지는 곳 🔴 실무
이 탭 다음
| 여기서 배운 것 | 다음 |
|---|---|
| 02 FSM · 03 정규 언어 · 04 CFG | 🔧 컴파일러 · 언어이론 전체 |
| 07~10 복잡도 | 🗂️ 자료구조 · 🔀 알고리즘 |
| 08 상수가 지배하는 자리 | 🖥️ 컴퓨터구조 08 캐시 |
| 09 공간 트레이드오프 | 🗂️ 자료구조 12 확률적 구조 |
| 10 상환 · 스파이크 | 🔧 컴파일러 10 GC · ⚙️ OS 03 |
| 06 정지 문제 · 종료 보장 | 🏛️ SW 공학 08 테스트 |
어떤 자료구조를
고르느냐가
거의 전부입니다
“좋은 프로그래머는 자료구조를 걱정하고, 코드는 알아서 따라온다”는 말은 과장이 아닙니다. 이 탭은 각 구조의 내부 동작을 보고, 복잡도 표가 말해 주지 않는 캐시 · 메모리 · 최악 사례까지 함께 봅니다. 마지막 절에는 상황을 넣으면 구조가 나오는 결정표가 있습니다.
배열 vs 연결 리스트 — 캐시가 답을 바꿉니다
교과서는 “중간 삽입이 잦으면 연결 리스트”라고 합니다. 현실에서는 대부분 배열이 이깁니다. 이유는 알고리즘이 아니라 메모리가 어떻게 생겼는가에 있습니다.
이론상의 표
| 연산 | 배열 | 연결 리스트 |
|---|---|---|
| i 번째 접근 | O(1) | O(n) |
| 맨 앞 삽입 | O(n) | O(1) |
| 중간 삽입 (위치 앎) | O(n) | O(1) |
| 중간 삽입 (찾아야 함) | O(n) | O(n) |
| 순회 | O(n) | O(n) |
| 메모리 오버헤드 | 없음 | 포인터 8~16B/노드 |
현실의 측정
import time
from collections import deque
N = 1_000_000
arr = list(range(N))
lst = deque(range(N)) # 파이썬에는 진짜 연결 리스트가 없음
# 순회 — 이론상 둘 다 O(n)
t = time.perf_counter(); s = sum(arr); a = time.perf_counter() - t
t = time.perf_counter(); s = sum(lst); d = time.perf_counter() - t
print(round(d / a, 2), "배") # deque 가 2~3배 느림
# C++ 로 재면 차이가 더 크다
# std::vector 순회 : 1.0x
# std::list 순회 : 5~15x (노드가 흩어질수록 나빠짐)
# 중간 삽입 10만 번 : vector 가 여전히 더 빠른 경우가 많다
이렇게도 쓸 수 있다 — 연결 리스트가 정말 맞는 자리 🟡 중급
좁지만 분명히 있습니다
| 상황 | 이유 |
|---|---|
| LRU 캐시 | 노드를 O(1) 로 떼어 앞으로 옮김 |
| 커널 자료구조 | 노드가 이미 객체 안에 박혀 있음(침입형) |
| 메모리 할당자 free 목록 | 빈 블록 자체에 링크를 저장 |
| 이터레이터 무효화 금지 | 삽입해도 기존 참조가 유지됨 |
| 영속 자료구조 | 꼬리 공유 (함수형 리스트) |
| 거대 원소 + 잦은 재배치 | 복사 비용이 포인터보다 큼 |
LRU 캐시 — 해시 + 이중 연결 리스트
class LRU:
"""해시맵으로 O(1) 조회 + 연결 리스트로 O(1) 순서 갱신"""
def __init__(self, cap):
self.cap = cap
self.map = {} # key -> node
self.head = Node(None, None) # 더미 (최근)
self.tail = Node(None, None) # 더미 (오래됨)
self.head.nxt, self.tail.prv = self.tail, self.head
def _remove(self, n):
n.prv.nxt, n.nxt.prv = n.nxt, n.prv # ★ O(1) — 배열이면 O(n)
def _push_front(self, n):
n.nxt, n.prv = self.head.nxt, self.head
self.head.nxt.prv = n; self.head.nxt = n
def get(self, k):
if k not in self.map: return None
n = self.map[k]; self._remove(n); self._push_front(n)
return n.val
def put(self, k, v):
if k in self.map: self._remove(self.map[k])
n = Node(k, v); self.map[k] = n; self._push_front(n)
if len(self.map) > self.cap:
old = self.tail.prv
self._remove(old); del self.map[old.key]
실무에선 이렇게 — 언어별 실제 구현 🔴 실무
“리스트”라는 이름에 속지 않기
| 언어 · 타입 | 실제 구조 | 주의 |
|---|---|---|
파이썬 list | 동적 배열(포인터 배열) | 연결 리스트 아님 |
파이썬 deque | 블록 이중 연결 리스트 | 양끝 O(1) · 중간 O(n) |
자바 ArrayList | 동적 배열 | 기본 선택 |
자바 LinkedList | 이중 연결 리스트 | 거의 쓸 일 없음 |
JS Array | 동적 배열 (희소 시 해시) | 구멍 만들면 느려짐 |
C++ vector | 연속 메모리 | 기본 선택 |
C++ deque | 블록 배열 | 양끝 O(1) · 연속 아님 |
Go slice | 배열 뷰 + 용량 | append 시 재할당 주의 |
Rust Vec | 연속 메모리 | LinkedList 는 비권장 |
// JS 배열에 구멍을 만들면 최적화가 풀린다
const a = [1, 2, 3];
a[1000] = 4; // ✗ 희소 배열 → 딕셔너리 모드로 전환
delete a[0]; // ✗ 같은 문제
// ✓ 대신
a.splice(0, 1); // 또는 filter 로 새 배열
new Array(1000).fill(0); // 미리 채워 두기
// 타입이 섞여도 최적화가 풀린다
const b = [1, 2, "three"]; // ✗ PACKED_SMI → PACKED_ELEMENTS
동적 배열 내부 — append 가 하는 일
list.append() 는 대부분 값 하나를 쓰고 끝나지만,
가끔 전체를 새 메모리로 복사합니다.
그 순간이 언제인지 알면 지연 스파이크를 없앨 수 있습니다.
용량과 길이는 다릅니다
import sys
a = []
prev = 0
for i in range(20):
a.append(i)
size = sys.getsizeof(a)
if size != prev:
print(f"길이 {len(a):2d} → {size} 바이트 ★ 재할당")
prev = size
# 길이 1 → 88 ★
# 길이 5 → 120 ★
# 길이 9 → 184 ★
# 길이 17 → 248 ★
# 파이썬의 성장 비율은 약 1.125배 + 상수 (구현마다 다름)
| 언어 | 성장 비율 |
|---|---|
| 파이썬 list | 약 1.125배 |
| 자바 ArrayList | 1.5배 |
| C++ (GCC/Clang) | 2배 |
| C++ (MSVC) | 1.5배 |
| Go slice | 2배 → 1.25배(큰 경우) |
| Rust Vec | 2배 |
미리 확보하면 사라집니다
# 파이썬 — 크기를 아는 경우
result = [None] * n
for i in range(n):
result[i] = f(i)
# 또는 리스트 컴프리헨션 (내부에서 크기 힌트를 씀)
result = [f(i) for i in range(n)]
// C++
std::vector<int> v;
v.reserve(n); // ★ 재할당 0번
for (int i = 0; i < n; ++i) v.push_back(f(i));
// Go
s := make([]int, 0, n) // 길이 0, 용량 n
// Java
var list = new ArrayList<Integer>(n);
// Rust
let mut v = Vec::with_capacity(n);
이렇게도 쓸 수 있다 — 삭제와 축소 🟡 중급
지워도 메모리는 안 줄어듭니다
a = list(range(1_000_000))
sys.getsizeof(a) # 약 8MB
del a[500_000:] # 절반 삭제
sys.getsizeof(a) # 여전히 큰 상태일 수 있음
# 진짜로 줄이려면 새로 만든다
a = a[:500_000] # 새 리스트 → 메모리 반환
# C++
v.clear(); // 크기 0, 용량 그대로
v.shrink_to_fit(); // 용량도 줄임 (요청일 뿐, 보장 아님)
std::vector<int>().swap(v); // 확실한 방법 (옛 관용구)
중간 삭제를 O(n²) 로 만들지 않기
✗ 순회하며 삭제
for i, x in enumerate(items):
if bad(x):
del items[i] # 인덱스가 밀린다 + O(n)
# 결과도 틀리고 O(n²)
✓ 새로 만들거나 뒤에서
items = [x for x in items if not bad(x)] # O(n)
# 제자리에서 해야 한다면 (two-pointer)
w = 0
for x in items:
if not bad(x):
items[w] = x; w += 1
del items[w:]
v.erase(std::remove_if(...), v.end()).
지울 것을 뒤로 몰고 한 번에 자릅니다.
실무에선 이렇게 — 링 버퍼 — 크기가 고정된 큐 🔴 실무
할당 없이 계속 돌아가는 구조
class RingBuffer:
"""최근 N 개만 유지 — 로그 · 메트릭 · 오디오 버퍼에 쓴다"""
def __init__(self, cap):
self.buf = [None] * cap
self.cap = cap
self.head = 0 # 다음에 쓸 위치
self.size = 0
def push(self, x):
self.buf[self.head] = x
self.head = (self.head + 1) % self.cap # ★ 모듈러로 순환
self.size = min(self.size + 1, self.cap)
def __iter__(self):
start = (self.head - self.size) % self.cap
for i in range(self.size):
yield self.buf[(start + i) % self.cap]
# 파이썬 표준 라이브러리에 이미 있다
from collections import deque
recent = deque(maxlen=1000) # 넘치면 자동으로 앞이 빠진다
recent.append(log_line)
| 쓰이는 곳 | 얻는 것 |
|---|---|
| 최근 로그 N줄 유지 | 메모리 상한 보장 |
| 이동 평균 · 슬라이딩 윈도우 | 할당 없음 |
| 오디오 · 비디오 버퍼 | 지터 흡수 |
| lock-free 큐 (SPSC) | 스레드 간 전달 |
| 커널 ring buffer (io_uring) | 시스템 콜 없이 통신 |
% cap 을
& (cap-1) 로 바꿀 수 있습니다 — 나눗셈이 비트 연산이 되어
훨씬 빠릅니다. 고성능 큐 구현이 대부분 그렇게 합니다 →
🔢 이산수학 11.
스택 · 큐 · 덱 — 꺼내는 순서만 다릅니다
구조는 단순하지만 어디에 쓰이는지가 넓습니다 — 함수 호출 · 되돌리기 · 괄호 검사 · 작업 대기열 · BFS · 슬라이딩 윈도우.
스택 (LIFO)
s = []
s.append(x) # push O(1)
s.pop() # pop O(1)
s[-1] # peek
# 쓰이는 곳
# · 함수 호출 스택
# · 되돌리기(undo)
# · 괄호 · 태그 짝 검사
# · DFS 반복 구현
# · 수식 계산 (후위 표기)
# · 브라우저 뒤로 가기
큐 (FIFO)
from collections import deque
q = deque()
q.append(x) # enqueue O(1)
q.popleft() # dequeue O(1)
# ✗ list.pop(0) 은 O(n)
# 쓰이는 곳
# · 작업 대기열
# · BFS
# · 요청 버퍼
# · 프로듀서-컨슈머
# · 프린터 스풀
덱 (양쪽)
d = deque()
d.append(x); d.appendleft(x)
d.pop(); d.popleft()
# 전부 O(1)
# 쓰이는 곳
# · 슬라이딩 윈도우 최댓값
# · 최근 N개 유지 (maxlen)
# · 양방향 탐색
# · 작업 훔치기 스케줄러
스택 실전 — 괄호 · 태그 짝 검사
PAIRS = {")": "(", "]": "[", "}": "{"}
def balanced(s):
stack = []
for i, ch in enumerate(s):
if ch in "([{":
stack.append((ch, i))
elif ch in PAIRS:
if not stack or stack[-1][0] != PAIRS[ch]:
return f"{i}번째 '{ch}' 가 짝이 안 맞음"
stack.pop()
if stack:
ch, i = stack[-1]
return f"{i}번째 '{ch}' 가 닫히지 않음"
return None
balanced("a(b[c]{d})e") # None
balanced("a(b[c)d]") # "5번째 ')' 가 짝이 안 맞음"
덱 실전 — 슬라이딩 윈도우 최댓값
from collections import deque
def window_max(nums, k):
"""모든 크기 k 윈도우의 최댓값을 O(n) 에 구한다"""
dq = deque() # 값이 감소하는 인덱스만 유지
out = []
for i, x in enumerate(nums):
while dq and nums[dq[-1]] <= x:
dq.pop() # 나보다 작은 것은 영원히 쓸모없다
dq.append(i)
if dq[0] <= i - k:
dq.popleft() # 윈도우 밖으로 나감
if i >= k - 1:
out.append(nums[dq[0]])
return out
window_max([1,3,-1,-3,5,3,6,7], 3) # [3,3,5,5,6,7]
# 단순하게 하면 O(n·k) — 이 기법으로 O(n)
이렇게도 쓸 수 있다 — 두 스택으로 큐 만들기 🟡 중급
상환 O(1) 의 좋은 예
class Queue:
def __init__(self):
self.inbox, self.outbox = [], []
def push(self, x):
self.inbox.append(x) # 항상 O(1)
def pop(self):
if not self.outbox:
while self.inbox: # 통째로 뒤집는다 — O(n)
self.outbox.append(self.inbox.pop())
return self.outbox.pop() # 이후로는 O(1)
# 각 원소는 '들어가고 · 옮겨지고 · 나오고' 정확히 3번만 만져진다
# → n 번 연산에 총 3n → 상환 O(1) (🧮 계산이론 10)
왜 이런 걸 아나
실무에서는
deque 를 쓰세요.
이 구현의 가치는 “상환 분석이 무엇인지”를
가장 짧게 보여 준다는 데 있습니다.
실무에선 이렇게 — 큐가 시스템의 목이 되는 순간 🔴 실무
무한 큐는 장애를 지연시킬 뿐입니다
# ✗ 크기 제한 없는 큐
queue = Queue() # 무제한
# 생산이 소비보다 빠르면 → 메모리 증가 → OOM
# 그 전에 이미 지연이 폭발한다 (큐에서 대기하는 시간)
# ✓ 유한 큐 + 넘칠 때의 정책
queue = Queue(maxsize=1000)
try:
queue.put(item, timeout=0.1)
except Full:
metrics.inc("queue_rejected") # ★ 거절하고 즉시 알린다
raise ServiceBusy() # 503 으로 응답
| 정책 | 동작 | 쓸 때 |
|---|---|---|
| 블로킹 | 생산자를 멈춤 | 배압을 위로 전달 (기본) |
| 거절 | 즉시 실패 | 온라인 요청 처리 |
| 가장 오래된 것 버림 | 최신 유지 | 메트릭 · 실시간 위치 |
| 새 것 버림 | 기존 유지 | 순서가 중요한 이벤트 |
| 우선순위 | 중요한 것 먼저 | 혼합 워크로드 |
해시 테이블 — O(1) 이 되는 조건
dict · Map · HashMap.
가장 많이 쓰면서 내부를 가장 모르는 구조입니다.
언제 O(n) 이 되는지를 알면 성능 사고를 미리 막을 수 있습니다.
기본 구조
key ──hash()──▶ 정수 ──% 버킷수──▶ 인덱스 ──▶ 저장 위치
buckets = [ _ , _ , [("b",2)] , _ , [("a",1),("c",3)] , _ , _ , _ ]
↑ 충돌 (같은 버킷)
핵심 세 가지
① 해시 함수가 고르게 퍼뜨려야 한다
② 충돌을 어떻게 처리할 것인가
③ 얼마나 차면 늘릴 것인가 (로드 팩터)
class SimpleHashMap:
def __init__(self, cap=8):
self.buckets = [[] for _ in range(cap)]
self.n = 0
def _idx(self, k): return hash(k) % len(self.buckets)
def put(self, k, v):
b = self.buckets[self._idx(k)]
for i, (kk, _) in enumerate(b):
if kk == k: b[i] = (k, v); return # 갱신
b.append((k, v)); self.n += 1
if self.n / len(self.buckets) > 0.75: # ★ 로드 팩터
self._resize()
def _resize(self):
old = self.buckets
self.buckets = [[] for _ in range(len(old) * 2)]
self.n = 0
for b in old:
for k, v in b: self.put(k, v) # 전부 재배치 — O(n)
충돌 처리 두 방식
| 체이닝 | 개방 주소법 | |
|---|---|---|
| 충돌 시 | 버킷에 리스트로 | 다음 빈 칸으로 |
| 메모리 | 포인터 오버헤드 | 연속 · 캐시 친화 |
| 로드 팩터 | 1.0 넘어도 동작 | 0.7 넘으면 급격히 악화 |
| 삭제 | 단순 | 묘비 표시 필요 |
| 쓰는 곳 | 자바 HashMap · Go map | 파이썬 dict · Rust · Swiss Table |
hashbrown 이 SIMD 로 여러 슬롯을 한 번에 검사합니다.
이렇게도 쓸 수 있다 — O(n) 이 되는 세 가지 경우 🟡 중급
① 해시 충돌
# 모든 키가 같은 버킷으로
class Bad:
def __hash__(self): return 1
def __eq__(self, o): return self is o
d = {Bad(): i for i in range(10000)}
# 모든 조회가 O(n)
② 리해싱 순간
# 로드 팩터를 넘는 그 삽입 하나가
# 전체 재배치 → O(n)
# 평균은 O(1) (상환)
# 하지만 그 순간의 p99 는
# 크게 튄다
③ 나쁜 __hash__
# 필드 하나만으로 해시
def __hash__(self):
return hash(self.status)
# status 가 3종류뿐이면
# 버킷 3개만 쓴다
# ✓ 튜플로
return hash((self.id, self.status))
# 파이썬에서 커스텀 키를 쓸 때의 규칙
class Point:
def __init__(self, x, y): self.x, self.y = x, y
def __eq__(self, o): return (self.x, self.y) == (o.x, o.y)
def __hash__(self): return hash((self.x, self.y)) # ★ eq 와 일관되게
# 규칙 ① a == b 이면 hash(a) == hash(b) 여야 한다
# 규칙 ② 해시에 쓰는 필드는 변하면 안 된다 (불변)
# → 딕셔너리에 넣은 뒤 x 를 바꾸면 영원히 못 찾는다
__eq__ 만 정의하고
__hash__ 를 안 만들거나(파이썬은 이 경우 해시 불가로 만듦),
가변 필드를 해시에 쓰는 것입니다.
자바에서도 equals 와 hashCode 를 함께 재정의해야 합니다.
실무에선 이렇게 — 순회 순서와 결정성 🔴 실무
해시맵의 순서를 믿으면 안 됩니다
| 언어 | 순회 순서 |
|---|---|
파이썬 dict (3.7+) | 삽입 순서 보장 (언어 명세) |
JS Map | 삽입 순서 보장 |
| JS 객체 | 정수 키 먼저 오름차순, 나머지 삽입 순 |
자바 HashMap | 보장 없음 — LinkedHashMap 사용 |
Go map | 일부러 무작위 (의존 방지) |
Rust HashMap | 무작위 (SipHash + 랜덤 시드) |
C++ unordered_map | 보장 없음 |
# 순서에 의존하면 안 되는 상황
# · JSON 직렬화 결과를 해시로 비교 → 키를 정렬해야 한다
json.dumps(data, sort_keys=True)
# · 테스트에서 dict 순서를 기대 → 정렬해서 비교
assert sorted(result.items()) == sorted(expected.items())
# · 캐시 키를 만들 때
key = hashlib.sha256(
json.dumps(params, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
# 순서가 필요하면 명시적으로
from collections import OrderedDict # 파이썬에서는 이제 거의 불필요
import sortedcontainers # 정렬된 dict 가 필요하면
해시 함수 설계 — 해시 충돌 공격까지
해시 함수를 잘못 고르면 공격자가 요청 하나로 서버를 멈출 수 있습니다. 용도별로 무엇을 써야 하는지, 왜 언어들이 랜덤 시드를 넣게 됐는지를 봅니다.
좋은 해시 함수의 조건
| 조건 | 뜻 |
|---|---|
| 균등 분포 | 모든 버킷에 고르게 |
| 눈사태 효과 | 입력 1비트 → 출력 절반 변화 |
| 빠름 | 조회마다 계산되므로 |
| 결정적 | 같은 입력 → 같은 출력 |
| (보안용) 예측 불가 | 충돌을 일부러 만들 수 없음 |
# FNV-1a — 이해하기 쉬운 예 (실무에는 더 좋은 것이 있음)
def fnv1a(data: bytes) -> int:
h = 0xcbf29ce484222325
for b in data:
h ^= b
h = (h * 0x100000001b3) & 0xFFFFFFFFFFFFFFFF
return h
# 곱셈이 비트를 섞고, XOR 이 새 정보를 넣는다.
# 이 두 연산의 반복이 대부분의 비암호 해시의 뼈대다.
용도별로 골라 쓰기
| 용도 | 선택 | 이유 |
|---|---|---|
| 해시맵 내부 | xxHash · Wyhash | 매우 빠름 |
| 사용자 입력 키 | SipHash | 키 있는 해시 — 공격 방어 |
| 체크섬 | CRC32 · xxHash | 전송 오류 검출 |
| 중복 파일 탐지 | BLAKE3 · SHA-256 | 충돌이 사실상 불가 |
| 무결성 · 서명 | SHA-256 · SHA-3 | 암호학적 안전성 |
| 비밀번호 | Argon2 · bcrypt | 일부러 느림 |
| 샤딩 | 일관 해싱 (+ 아무 해시) | 노드 변경에 강함 |
| 유사도 | SimHash · MinHash | 비슷하면 비슷한 해시 |
이렇게도 쓸 수 있다 — 해시 충돌 DoS (HashDoS) 🟡 중급
2011년, 거의 모든 웹 프레임워크가 뚫렸습니다
공격 방법
① 대상 언어의 해시 함수를 안다 (오픈소스라 공개)
② 같은 해시값을 갖는 문자열을 수천 개 만든다
③ POST 파라미터로 한꺼번에 보낸다
a=1&b=2&... (전부 같은 버킷으로 가는 키들)
④ 서버가 파싱하면서 해시맵에 넣는다
→ 모든 삽입이 O(n) → 전체 O(n²)
⑤ 요청 하나로 CPU 를 몇 분간 100% 로 만든다
영향: PHP · 자바 · 파이썬 · 루비 · ASP.NET · Node 전부
대응
· 해시 시드를 프로세스마다 랜덤화 (파이썬 PYTHONHASHSEED)
· SipHash 같은 '키 있는 해시' 사용 (러스트 · 파이썬 기본)
· 파라미터 개수 제한 (가장 확실)
# 파이썬은 기본으로 해시 랜덤화가 켜져 있다 (3.3+)
# 실행마다 문자열 해시가 달라진다
$ python -c "print(hash('a'))" # 매번 다른 값
$ PYTHONHASHSEED=0 python -c "print(hash('a'))" # 고정 (테스트용)
# ★ 그래서 hash() 결과를 저장하거나 프로세스 간에 공유하면 안 된다
# 영속적인 해시가 필요하면 hashlib 을 쓴다
import hashlib
stable = hashlib.blake2b(b"key", digest_size=8).hexdigest()
실무에선 이렇게 — 일관 해싱 · 유사도 해싱 🔴 실무
일관 해싱 — 노드가 바뀌어도 덜 움직인다
import bisect, hashlib
class ConsistentHash:
def __init__(self, nodes, vnodes=150):
self.ring = [] # (해시, 노드) 정렬 리스트
self.vnodes = vnodes
for n in nodes: self.add(n)
def _h(self, s):
return int(hashlib.blake2b(s.encode(), digest_size=8).hexdigest(), 16)
def add(self, node):
for i in range(self.vnodes): # ★ 가상 노드로 고르게
bisect.insort(self.ring, (self._h(f"{node}#{i}"), node))
def get(self, key):
h = self._h(key)
i = bisect.bisect(self.ring, (h,)) % len(self.ring)
return self.ring[i][1]
# 노드 하나를 추가해도 이동하는 키는 약 1/N 뿐
# (단순 % N 이면 거의 전부 이동 — 🔢 이산수학 10)
SimHash — 비슷하면 비슷한 해시
def simhash(tokens, bits=64):
v = [0] * bits
for t in tokens:
h = int(hashlib.blake2b(t.encode(), digest_size=8).hexdigest(), 16)
for i in range(bits):
v[i] += 1 if (h >> i) & 1 else -1
out = 0
for i, x in enumerate(v):
if x > 0: out |= 1 << i
return out
def hamming(a, b): return bin(a ^ b).count("1")
# 거의 같은 문서면 해밍 거리가 3 이하
# → 중복 뉴스 · 표절 · 스팸 클러스터링에 쓴다
# ★ 일반 해시와 정반대 성질이다.
# 보통 해시는 1비트만 달라도 완전히 달라져야 하고(눈사태),
# 유사도 해시는 비슷한 입력이 비슷한 출력이어야 한다.
이진 탐색 트리 — 정렬을 유지하는 대신
해시는 빠르지만 순서가 없습니다. “100~200 사이의 값”, “다음으로 큰 값”이 필요하면 트리가 답입니다. 다만 그냥 BST 는 정렬된 입력에서 리스트가 됩니다.
구조와 기본 연산
class Node:
__slots__ = ("key", "val", "left", "right")
def __init__(self, k, v):
self.key, self.val = k, v
self.left = self.right = None
def insert(node, k, v):
if node is None: return Node(k, v)
if k < node.key: node.left = insert(node.left, k, v)
elif k > node.key: node.right = insert(node.right, k, v)
else: node.val = v
return node
def search(node, k):
while node:
if k == node.key: return node.val
node = node.left if k < node.key else node.right
return None
def inorder(node): # ★ 중위 순회 = 정렬된 순서
if node:
yield from inorder(node.left)
yield node.key, node.val
yield from inorder(node.right)
해시가 못 하는 것들
| 질의 | 해시 | 트리 |
|---|---|---|
| 정확히 이 키 | O(1) | O(log n) |
| 범위 [a, b] | 불가 | O(log n + k) |
| k 보다 큰 최솟값 | 불가 | O(log n) |
| 최솟값 · 최댓값 | O(n) | O(log n) |
| 정렬된 순회 | O(n log n) | O(n) |
| 접두사 범위 | 불가 | 가능 |
WHERE age BETWEEN 20 AND 30 · ORDER BY ·
> 비교가 전부 범위 질의입니다.
이렇게도 쓸 수 있다 — 편향 — 최악의 시나리오 🟡 중급
정렬된 데이터를 넣으면 연결 리스트가 됩니다
insert(1), insert(2), insert(3), insert(4), insert(5)
1
\
2 높이 = n
\ 탐색 = O(n)
3 → 트리의 의미가 사라짐
\
4
\
5
★ 그런데 정렬된 데이터를 넣는 것은 아주 흔하다
· 자동 증가 ID 를 순서대로
· 타임스탬프
· 이미 정렬된 파일을 읽어 들일 때
import random, sys
sys.setrecursionlimit(20_000) # ★ 정렬 삽입은 재귀 깊이가 n — 기본 한도 1000 이면 RecursionError
# 랜덤 삽입 vs 정렬 삽입의 높이 차이
def height(node):
if not node: return 0
return 1 + max(height(node.left), height(node.right))
n = 10_000
root_rand = None
keys = list(range(n)); random.shuffle(keys)
for k in keys: root_rand = insert(root_rand, k, k)
root_sorted = None
for k in range(n): root_sorted = insert(root_sorted, k, k)
height(root_rand) # 약 30 (실행마다 다름 · 대략 3 log₂ n)
height(root_sorted) # 10,000 ← 완전히 망가짐
실무에선 이렇게 — 삭제가 어려운 이유 🔴 실무
자식이 둘일 때의 처리
def delete(node, k):
if node is None: return None
if k < node.key: node.left = delete(node.left, k)
elif k > node.key: node.right = delete(node.right, k)
else:
# ① 자식이 없거나 하나 → 그 자식으로 대체
if node.left is None: return node.right
if node.right is None: return node.left
# ② 자식이 둘 → 오른쪽 서브트리의 최솟값(중위 후계자)으로 대체
succ = node.right
while succ.left: succ = succ.left
node.key, node.val = succ.key, succ.val
node.right = delete(node.right, succ.key)
return node
# 왜 '오른쪽의 최솟값' 인가
# · 그 값보다 왼쪽은 전부 작고, 오른쪽은 전부 크다 → BST 성질 유지
# · 왼쪽 서브트리의 최댓값(중위 선행자)을 써도 똑같이 된다
| 실무에서의 대안 | 이유 |
|---|---|
| 지연 삭제 (묘비 표시) | 구조를 안 건드림 · 나중에 일괄 정리 |
| 표준 라이브러리 사용 | TreeMap · std::map · BTreeMap |
| 스킵 리스트 | 삭제 구현이 훨씬 단순 · Redis 가 사용 |
| 불변 트리 (경로 복사) | 동시성 · 스냅숏이 공짜 |
균형 트리 — 스스로 모양을 고치는 트리
삽입할 때마다 회전해서 높이를 O(log n) 으로 유지합니다.
TreeMap · std::map · BTreeMap 이
전부 이 계열이고, 최악의 경우에도 성능이 보장됩니다.
회전 — 균형 유지의 기본 연산
오른쪽 회전 (왼쪽이 무거울 때)
y x
/ \ / \
x C ──▶ A y
/ \ / \
A B B C
★ 중위 순회 순서는 변하지 않는다 (A x B y C)
= BST 성질이 유지된다
= 순서를 지키면서 높이만 줄인다
def rotate_right(y):
x = y.left
y.left = x.right
x.right = y
update_height(y); update_height(x) # 순서 중요
return x # 새 루트
AVL vs 레드블랙
| AVL | 레드블랙 | |
|---|---|---|
| 균형 조건 | 높이 차 ≤ 1 | 검정 높이 같음 |
| 높이 | ≤ 1.44 log n | ≤ 2 log n |
| 탐색 | 더 빠름 | 보통 |
| 삽입·삭제 | 회전 많음 | 회전 적음 |
| 쓰는 곳 | 읽기가 많을 때 | 표준 라이브러리 다수 |
이렇게도 쓸 수 있다 — 스킵 리스트 — 훨씬 단순한 대안 🟡 중급
확률로 균형을 맞춥니다
레벨 3: 1 ─────────────────────────▶ 9
레벨 2: 1 ─────────▶ 5 ────────────▶ 9
레벨 1: 1 ──▶ 3 ──▶ 5 ──▶ 7 ──▶ 8 ─▶ 9
레벨 0: 1 ─▶ 2 ─▶ 3 ─▶ 4 ─▶ 5 ─▶ ... ─▶ 9
· 노드마다 동전을 던져 레벨을 정한다 (50% 확률로 한 단계 위)
· 위 레벨에서 크게 건너뛰고, 안 되면 아래로 내려온다
· 기댓값으로 O(log n) — 회전 로직이 아예 없다
import random
class SkipList:
MAXLEVEL, P = 16, 0.5
def __init__(self):
self.head = [None] * self.MAXLEVEL
self.level = 1
def _rand_level(self):
lv = 1
while random.random() < self.P and lv < self.MAXLEVEL: lv += 1
return lv
def search(self, key):
node, cur = None, self.head
for i in reversed(range(self.level)):
while cur[i] and cur[i].key < key:
node, cur = cur[i], cur[i].nxt
nxt = cur[0]
return nxt.val if nxt and nxt.key == key else None
장점
단점
실무에선 이렇게 — 언제 트리를 고르나 🔴 실무
| 요구 | 선택 |
|---|---|
| 키로 정확히 찾기만 | 해시맵 |
| 범위 조회 · 정렬 순회 | 균형 트리 (TreeMap · std::map) |
| “다음으로 큰 값” | 균형 트리 · 정렬 배열 + 이진 탐색 |
| 데이터가 거의 안 변함 | 정렬 배열 — 캐시 효율이 압도적 |
| 디스크에 저장 | B+트리 → 08 |
| 동시 접근이 많음 | 스킵 리스트 · 락프리 트리 · 샤딩 |
| 순위 질의 (k번째 값) | 순서 통계 트리 · Fenwick 트리 |
# 파이썬에는 표준 균형 트리가 없다 — 두 가지 대안
# ① 정렬 배열 + bisect (변경이 드물 때)
import bisect
sorted_keys = sorted(data)
i = bisect.bisect_left(sorted_keys, target) # O(log n) 탐색
sorted_keys.insert(i, target) # O(n) 삽입 — 작으면 괜찮다
# ② sortedcontainers (변경이 잦을 때) — 순수 파이썬인데 매우 빠르다
from sortedcontainers import SortedDict
d = SortedDict()
d[5] = "a"; d[1] = "b"; d[3] = "c"
list(d.irange(1, 4)) # 범위 조회
d.peekitem(0) # 최솟값
sortedcontainers 가 빠른 이유가 흥미롭습니다 —
트리가 아니라 “작은 정렬 리스트들의 리스트” 입니다.
각 조각이 캐시에 들어가고 bisect 는 C 로 돌아가서,
순수 파이썬인데도 C 확장 트리보다 빠른 경우가 있습니다.
복잡도보다 상수와 캐시가 이기는 대표 사례입니다.
B-트리 · B+트리 — 모든 DB 인덱스의 정체
디스크는 바이트 하나를 읽어도 페이지 하나(4~16KB)를 읽습니다. 그 사정에 맞춰 노드를 크고 납작하게 만든 트리가 B-트리이고, 그래서 모든 관계형 DB 의 인덱스가 이 구조입니다.
왜 이진 트리가 아닌가
1억 건 인덱스
이진 트리 : 높이 log₂(1억) ≈ 27
→ 디스크 접근 27번 × 0.1ms = 2.7ms
B+트리 (팬아웃 500) : 높이 log₅₀₀(1억) ≈ 3
→ 디스크 접근 3번 × 0.1ms = 0.3ms
★ 핵심: 한 번 읽을 때 '많이' 읽는다
· 노드 하나 = 페이지 하나 (8KB · 16KB)
· 키가 16바이트면 한 노드에 500개
· 높이가 3~4 면 10억 건도 커버
B-트리 vs B+트리
| B-트리 | B+트리 | |
|---|---|---|
| 데이터 위치 | 모든 노드 | 리프에만 |
| 내부 노드 | 키+값 | 키만 → 팬아웃 큼 |
| 리프 연결 | 없음 | 양방향 링크 |
| 범위 스캔 | 트리 순회 | 리프를 쭉 따라감 |
| 쓰는 곳 | 파일 시스템 일부 | 거의 모든 DB |
B+트리 구조
내부: [ 30 | 70 ]
/ | \
리프: [10,20]→[30,50]→[70,90]
↑___양방향 링크___↑
WHERE age BETWEEN 25 AND 75
→ 25 를 찾아 리프로 내려간 뒤
링크를 따라 순차 스캔 (트리를 다시 안 탄다)
이렇게도 쓸 수 있다 — 인덱스 설계에 직결되는 성질 🟡 중급
구조를 알면 튜닝이 유도됩니다
| 현상 | B+트리 구조상의 이유 |
|---|---|
| 복합 인덱스는 왼쪽부터 | 키가 (a,b,c) 순으로 정렬 — a 없이 b 만으로는 탐색 불가 |
LIKE 'abc%' 는 인덱스 사용 | 접두사가 정렬 순서를 결정 |
LIKE '%abc' 는 못 씀 | 시작점을 알 수 없음 |
| 함수를 씌우면 못 씀 | YEAR(d) 의 정렬 순서 ≠ d 의 순서 |
| 랜덤 UUID PK 가 느림 | 삽입이 트리 전체에 흩어짐 → 페이지 분할·캐시 미스 |
| 자동 증가 PK 가 빠름 | 항상 오른쪽 끝에 추가 → 순차 쓰기 |
| 커버링 인덱스가 빠름 | 리프에 필요한 값이 다 있어 테이블 접근 생략 |
| 인덱스가 많으면 쓰기가 느림 | 인덱스마다 트리를 갱신 |
-- 복합 인덱스 (a, b, c) 가 있을 때
WHERE a = 1 -- ✓ 사용
WHERE a = 1 AND b = 2 -- ✓ 사용
WHERE a = 1 AND c = 3 -- △ a 만 사용, c 는 필터
WHERE b = 2 -- ✗ 사용 불가
WHERE a > 1 AND b = 2 -- △ 범위 뒤는 못 씀
-- UUID PK 문제의 해법: 시간 순서가 있는 ID
-- · UUIDv7 (2024 표준) · ULID · 스노우플레이크
-- → 앞부분이 타임스탬프라 순차 삽입이 된다
실무에선 이렇게 — LSM 트리 — 다른 선택 🔴 실무
쓰기가 압도적으로 많다면
| B+트리 | LSM 트리 | |
|---|---|---|
| 쓰기 | 제자리 수정 (랜덤 I/O) | 순차 추가만 |
| 읽기 | 빠름 (한 곳) | 여러 레벨 확인 |
| 공간 증폭 | 낮음 | 높음 (중복 저장) |
| 쓰기 증폭 | 높음 | 낮음~중간 |
| 지연 스파이크 | 적음 | 컴팩션 시 발생 |
| 대표 | MySQL · PostgreSQL · Oracle | RocksDB · Cassandra · LevelDB |
LSM 트리 동작
쓰기 → MemTable (메모리, 정렬됨)
↓ 가득 차면
SSTable L0 (디스크, 불변, 순차 쓰기)
↓ 백그라운드 컴팩션
SSTable L1, L2, ... (레벨이 내려갈수록 크고 오래됨)
읽기 → MemTable 확인 → L0 → L1 → ...
각 레벨마다 블룸 필터로 '없음'을 빨리 판정 (🗂️ 12)
★ 삭제도 '삭제 표시(tombstone)'를 쓰는 것 — 나중에 컴팩션이 정리
힙 · 우선순위 큐 — 가장 급한 하나를 빨리
전체를 정렬할 필요 없이 “지금 가장 작은/큰 것”만 반복해서 꺼낼 때 씁니다. 스케줄러 · 다익스트라 · Top-K · 이벤트 시뮬레이션이 전부 이것입니다.
배열로 표현하는 완전 이진 트리
1
/ \ 배열: [1, 3, 2, 7, 5, 4]
3 2 인덱스 0 1 2 3 4 5
/ \ /
7 5 4 자식: 2i+1, 2i+2
부모: (i-1)//2
★ 포인터가 필요 없다 — 연속 메모리 → 캐시 친화적
★ 최소 힙 성질: 부모 ≤ 자식 (전체 정렬은 아님)
import heapq
h = []
heapq.heappush(h, 5) # O(log n)
heapq.heappush(h, 1)
heapq.heappush(h, 3)
heapq.heappop(h) # 1 — O(log n)
h[0] # 최솟값 확인 — O(1)
heapq.heapify(arr) # 리스트를 힙으로 — O(n) ★ n log n 아님
heapq.nlargest(10, data) # Top-10
heapq.nsmallest(10, data)
heapify 가 O(n) 인 이유
아래에서 위로 내려보내기(sift-down)를 하면
레벨 노드 수 각 노드의 최대 이동
────────────────────────────────────
맨 아래 n/2 0 (리프는 안 움직임)
그 위 n/4 1
그 위 n/8 2
...
루트 1 log n
총합 = Σ (n/2^(k+1)) × k = n × Σ k/2^(k+1) ≈ n
★ 위에서 아래로(sift-up) 하면 O(n log n) 이 된다.
방향이 복잡도를 바꾼다.
heapify 를 쓰세요.
100만 개면 2000만 번 대신 100만 번입니다.
이렇게도 쓸 수 있다 — Top-K 문제 🟡 중급
전부 정렬할 필요가 없습니다
| 방법 | 복잡도 | 메모리 | 쓸 때 |
|---|---|---|---|
| 전체 정렬 후 자르기 | O(n log n) | O(n) | k 가 n 에 가까움 |
| 크기 k 힙 유지 | O(n log k) | O(k) | 스트리밍 · k ≪ n |
| 퀵셀렉트 | O(n) 평균 | O(1) | 메모리에 다 있음 |
| 부분 정렬 | O(n + k log n) | O(n) | 라이브러리 지원 시 |
# 스트리밍 Top-K — 메모리 O(k) 로 무한 스트림 처리
import heapq
def top_k_stream(stream, k):
h = [] # 최소 힙 (가장 작은 것이 위)
for item in stream:
if len(h) < k:
heapq.heappush(h, item)
elif item > h[0]: # 현재 k등보다 크면
heapq.heapreplace(h, item) # 교체 (pop+push 보다 빠름)
return sorted(h, reverse=True)
# 로그 100억 줄에서 상위 100개 → 메모리 100개분만 사용
# 여러 정렬된 스트림 병합 (외부 정렬 · 로그 병합)
merged = heapq.merge(file1, file2, file3, key=lambda r: r.timestamp)
heapq.merge 는 제너레이터입니다 —
전부 메모리에 올리지 않고 정렬 순서로 흘려보냅니다.
정렬된 파일 수십 개를 합칠 때(외부 정렬) 그대로 쓸 수 있습니다.
실무에선 이렇게 — 우선순위 큐의 함정 🔴 실무
기아 · 동점 · 갱신
# ① 동점 처리 — 튜플 비교가 두 번째 요소로 넘어가면 터진다
heapq.heappush(h, (priority, task)) # ✗ task 끼리 비교 불가하면 TypeError
heapq.heappush(h, (priority, seq, task)) # ✓ 단조 증가 카운터로 안정성 확보
import itertools
counter = itertools.count()
heapq.heappush(h, (priority, next(counter), task)) # FIFO 순서까지 보장
# ② 우선순위 갱신 — heapq 에는 decrease-key 가 없다
# ✓ 관용구: 새 항목을 넣고, 꺼낼 때 낡은 것을 건너뛴다
entry_finder = {}
REMOVED = object()
def push(task, pri):
if task in entry_finder: entry_finder[task][-1] = REMOVED # 무효화
entry = [pri, next(counter), task]
entry_finder[task] = entry
heapq.heappush(h, entry)
def pop():
while h:
pri, _, task = heapq.heappop(h)
if task is not REMOVED:
del entry_finder[task]
return task
raise KeyError("빈 큐")
| 문제 | 대응 |
|---|---|
| 기아 — 낮은 우선순위가 영원히 대기 | 대기 시간에 비례해 우선순위 상승(에이징) |
| 동점 순서가 뒤죽박죽 | 삽입 순서 카운터 추가 |
| 우선순위 갱신 필요 | 지연 삭제 관용구 (위) · 피보나치 힙 |
| 큐가 무한정 커짐 | 상한 + 거절 정책 (03 절) |
| 공정성이 필요 | 가중 라운드로빈 · 다중 큐 |
트라이 — 글자를 따라 내려가는 트리
자동완성 · 금칙어 필터 · IP 라우팅 테이블. “이 접두사로 시작하는 것 전부”가 필요할 때 쓰는 구조입니다.
구조와 구현
단어: cat, car, card, dog
(root)
/ \
c d
| |
a o
/ \ |
t r g*
* |
d* (* = 단어 끝)
★ 공통 접두사를 한 번만 저장한다
class Trie:
def __init__(self):
self.root = {}
self.END = "$"
def insert(self, word):
node = self.root
for ch in word:
node = node.setdefault(ch, {})
node[self.END] = True
def search(self, word):
node = self._walk(word)
return node is not None and self.END in node
def starts_with(self, prefix):
return self._walk(prefix) is not None
def _walk(self, s):
node = self.root
for ch in s:
if ch not in node: return None
node = node[ch]
return node
def complete(self, prefix, limit=10):
node = self._walk(prefix)
if node is None: return []
out = []
def dfs(n, acc):
if len(out) >= limit: return
if self.END in n: out.append(prefix + acc)
for ch, child in n.items():
if ch != self.END: dfs(child, acc + ch)
dfs(node, "")
return out
해시맵과 비교
| 질의 | 해시맵 | 트라이 |
|---|---|---|
| 정확히 이 단어 | O(1) | O(m) |
| 이 접두사로 시작 | O(n) 전체 스캔 | O(m) |
| 사전순 순회 | O(n log n) | O(n) |
| 가장 긴 접두사 매칭 | 불가 | O(m) |
| 메모리 | 작음 | 큼 (노드마다 자식 맵) |
이렇게도 쓸 수 있다 — 메모리를 줄이는 변형들 🟡 중급
| 변형 | 아이디어 | 쓰는 곳 |
|---|---|---|
| 압축 트라이 (Radix/Patricia) | 자식이 하나뿐인 사슬을 하나로 합침 | IP 라우팅 테이블 · etcd |
| DAWG (DAFSA) | 같은 접미사를 공유 — 트라이를 그래프로 | 사전 · 맞춤법 검사 |
| 더블 배열 트라이 | 배열 두 개로 표현 — 캐시 친화 | 형태소 분석기 (MeCab) |
| 접미사 배열 | 모든 접미사를 정렬해 저장 | 전문 검색 · 압축 |
| FST | DAWG + 값 저장 | Lucene 인덱스 |
# 압축 트라이의 핵심 — 사슬을 뭉친다
#
# 일반 트라이: r → o → u → t → e → r
# 압축 트라이: "router" (한 노드)
#
# IP 라우팅에서는 '가장 긴 접두사 매칭' 이 필요하다
# 10.0.0.0/8 → 게이트웨이 A
# 10.1.0.0/16 → 게이트웨이 B ← 10.1.2.3 은 이쪽 (더 길다)
# 0.0.0.0/0 → 기본 게이트웨이
# 트라이는 이 질의를 O(주소 비트 수) 에 답한다
# 실무에서는 라이브러리를 쓴다
import pygtrie # 파이썬
# marisa-trie — 메모리를 훨씬 적게 씀 (읽기 전용)
# datrie — 더블 배열 트라이
marisa-trie 같은 정적 구조가 좋습니다 —
단어 100만 개를 수 MB 에 담고 조회도 빠릅니다.
한 번 만들어 두고 검색만 하는 사전 · 금칙어 목록에 적합합니다.
실무에선 이렇게 — 여러 패턴을 한 번에 — 아호코라식 🔴 실무
금칙어 1만 개를 한 번의 스캔으로
# ✗ 단어마다 검색하면 O(패턴수 × 텍스트길이)
for word in banned_words: # 1만 개
if word in text: # 각각 텍스트 전체 스캔
return True
# ✓ 아호코라식 — 트라이 + 실패 링크 → O(텍스트길이 + 매치수)
import ahocorasick # pip install pyahocorasick
A = ahocorasick.Automaton()
for i, w in enumerate(banned_words):
A.add_word(w, (i, w))
A.make_automaton() # 실패 링크 구축 (한 번만)
for end, (idx, word) in A.iter(text):
print(f"{end - len(word) + 1} 위치에서 '{word}' 발견")
| 쓰이는 곳 | 패턴 수 |
|---|---|
| 금칙어 · 스팸 필터 | 수천~수만 |
| 바이러스 시그니처 매칭 | 수십만 |
| 침입 탐지 (Snort) | 수천 |
| DNA 서열 검색 | 수만 |
| 키워드 광고 매칭 | 수십만 |
유니온 파인드 — “같은 무리인가”를 거의 상수 시간에
친구 관계 · 네트워크 연결 · 중복 계정 병합 · MST. “합치기와 같은 그룹인지 묻기” 두 연산만 필요한 문제에서 압도적으로 빠릅니다.
전체 구현이 15줄
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
self.count = n # 그룹 개수
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # ★ 경로 압축
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb: return False # 이미 같은 그룹
if self.rank[ra] < self.rank[rb]: ra, rb = rb, ra # ★ 랭크로 합치기
self.parent[rb] = ra
if self.rank[ra] == self.rank[rb]: self.rank[ra] += 1
self.count -= 1
return True
def same(self, a, b): return self.find(a) == self.find(b)
두 가지 최적화가 만드는 차이
| 최적화 | 복잡도 |
|---|---|
| 아무것도 안 함 | O(n) |
| 랭크로 합치기만 | O(log n) |
| 경로 압축만 | O(log n) |
| 둘 다 | O(α(n)) ≈ 상수 |
경로 압축이 하는 일
find(7) 호출 전 find(7) 호출 후
1 1
| / | \
3 3 5 7 ← 전부 루트에 직접 연결
|
5
|
7
이렇게도 쓸 수 있다 — 실전 활용 🟡 중급
중복 계정 병합
# 이메일 · 전화 · 기기 ID 로 이어지는 계정을 하나로
uf = UnionFind(len(accounts))
index = {} # 식별자 → 최초 계정 인덱스
for i, acc in enumerate(accounts):
for key in acc.identifiers: # 이메일, 폰, 디바이스
if key in index:
uf.union(i, index[key])
else:
index[key] = i
groups = {}
for i in range(len(accounts)):
groups.setdefault(uf.find(i), []).append(i)
print(f"{len(accounts)}개 계정 → {uf.count}명의 실제 사용자")
사이클 검출 · MST
# 무방향 그래프에 사이클이 있나
def has_cycle(n, edges):
uf = UnionFind(n)
for u, v in edges:
if not uf.union(u, v): # 이미 같은 그룹인데 또 연결
return True # → 사이클
return False
# 크루스칼 MST (🔢 이산수학 08)
def kruskal(n, edges):
uf = UnionFind(n)
total, used = 0, []
for w, u, v in sorted(edges):
if uf.union(u, v):
total += w; used.append((u, v, w))
return used, total
| 문제 | 유니온 파인드로 푸는 방식 |
|---|---|
| 섬의 개수 세기 | 인접한 육지를 union → count |
| 네트워크 연결 판정 | 케이블 추가마다 union · same() 으로 질의 |
| 친구 그룹 크기 | 루트별 size 배열 유지 |
| 이미지 연결 요소 | 같은 색 인접 픽셀 union |
| 오프라인 동적 연결성 | 질의를 역순으로 처리 |
| 동치 관계 처리 | a==b 제약을 union 으로 (🔢 이산수학 04) |
실무에선 이렇게 — 한계와 확장 🔴 실무
할 수 있는 것과 없는 것
✓ 잘하는 것
· 합치기 (union)
· 같은 그룹인지 (find)
· 그룹 개수 · 크기
· 계속 합쳐지기만 하는 상황
✗ 못 하는 것
· 분리하기 (un-union)
· 그룹의 원소를 나열하기
· 경로 자체를 알려 주기
· 간선이 삭제되는 동적 연결성
# 원소 나열이 필요하면 별도 자료구조를 함께 유지한다
class UnionFindWithMembers(UnionFind):
def __init__(self, n):
super().__init__(n)
self.members = {i: {i} for i in range(n)}
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb: return False
if len(self.members[ra]) < len(self.members[rb]): ra, rb = rb, ra
super().union(ra, rb)
root = self.find(ra) # 랭크에 따라 ra 또는 rb
big, small = self.members.pop(ra), self.members.pop(rb)
big |= small # 작은 쪽을 큰 쪽에 (ra 쪽이 크다)
self.members[root] = big
return True
# ★ '작은 쪽을 큰 쪽에 합치기' 를 지키면 총 병합 비용이 O(n log n)
# (각 원소는 자기가 속한 집합 크기가 2배 될 때만 옮겨진다)
확률적 자료구조 — 정확도를 조금 팔아 메모리를 크게 삽니다
“정확히 몇 개” 대신 “대략 몇 개”로 요구를 낮추면 메모리가 수백~수만 배 줄어듭니다. 대규모 시스템에서 반드시 만나는 도구들입니다.
블룸 필터 — “없다”만 확실히
비트 배열 + 해시 함수 k 개
넣기: x → h1(x), h2(x), h3(x) 위치의 비트를 1로
묻기: 세 위치가 전부 1이면 "있을 수도"
하나라도 0이면 "확실히 없다"
[0,1,0,1,1,0,0,1,0,1,0,0]
↑ ↑ ↑
★ 위양성(있다고 잘못 말함)은 있고
위음성(없다고 잘못 말함)은 절대 없다
import math, hashlib
class BloomFilter:
def __init__(self, n, fp_rate=0.01):
self.m = math.ceil(-n * math.log(fp_rate) / (math.log(2) ** 2))
self.k = max(1, round(self.m / n * math.log(2)))
self.bits = bytearray((self.m + 7) // 8)
def _idx(self, item, i):
h = hashlib.blake2b(f"{i}:{item}".encode(), digest_size=8).digest()
return int.from_bytes(h, "big") % self.m
def add(self, item):
for i in range(self.k):
p = self._idx(item, i)
self.bits[p // 8] |= 1 << (p % 8)
def __contains__(self, item):
return all(self.bits[(p := self._idx(item, i)) // 8] & (1 << (p % 8))
for i in range(self.k))
bf = BloomFilter(1_000_000, 0.01)
# 100만 개 · 오탐 1% → 약 1.2MB (set 이면 약 80MB)
쓰이는 곳
| 사례 | 얻는 것 |
|---|---|
| LSM 트리 (RocksDB) | 없는 키의 디스크 조회 생략 |
| 캐시 앞단 필터 | 캐시 미스가 확실한 요청 차단 |
| 중복 URL 크롤링 방지 | 수십억 URL 을 GB 단위로 |
| 유출 비밀번호 검사 | 거대 목록을 클라이언트에 |
| 스팸 IP 1차 필터 | 정밀 검사 전 걸러내기 |
이렇게도 쓸 수 있다 — HyperLogLog · Count-Min Sketch 🟡 중급
HyperLogLog — 고유 개수 세기
아이디어
해시값의 '앞에 오는 0의 개수'를 본다.
0이 10개 연속이면 대략 2¹⁰ 개쯤 봤다는 뜻.
여러 버킷의 평균(조화평균)을 내면 정확해진다.
성능
· 12KB 로 수십억 개 카디널리티 추정
· 오차 약 0.81%
· 병합 가능 (여러 서버 결과를 합칠 수 있다)
# Redis 에 내장돼 있다
PFADD visitors:2026-08-07 user1 user2 user3
PFCOUNT visitors:2026-08-07 # 근사 UV
PFMERGE visitors:week visitors:d1 visitors:d2 # 주간 UV
# 정확한 set 이면 사용자 1000만 명 → 수백 MB
# HLL 이면 → 12KB
Count-Min Sketch — 빈도 추정
class CountMinSketch:
def __init__(self, w=2048, d=5):
self.w, self.d = w, d
self.t = [[0] * w for _ in range(d)]
def _h(self, item, i):
return int(hashlib.blake2b(f"{i}:{item}".encode(),
digest_size=8).hexdigest(), 16) % self.w
def add(self, item, n=1):
for i in range(self.d):
self.t[i][self._h(item, i)] += n
def count(self, item):
return min(self.t[i][self._h(item, i)] for i in range(self.d))
# ★ 최솟값을 취하면 과대 추정만 남는다 (과소는 없음)
# 어디에 쓰나
# · 트래픽 상위 IP · 인기 검색어 (heavy hitters)
# · 레이트 리밋 근사
# · 스트리밍 집계
실무에선 이렇게 — 고를 때의 기준 🔴 실무
| 질문 | 구조 | 메모리 | 오차 |
|---|---|---|---|
| 이거 본 적 있나? | 블룸 필터 | ~1.2 바이트/항목 | 위양성만 |
| 이거 본 적 있나? (삭제 필요) | 커쿠 필터 | 비슷 | 위양성만 |
| 고유 개수는? | HyperLogLog | 12KB 고정 | ~0.8% |
| 이 항목이 몇 번? | Count-Min | 수십 KB | 과대 추정 |
| 상위 K 개는? | Space-Saving | O(k) | 근사 |
| 중앙값 · p99 는? | t-digest · DDSketch | 수 KB | 분위수 정확 |
| 두 집합이 얼마나 겹치나? | MinHash | 서명 크기 | 근사 |
# 실무 판단의 예: 일일 순 방문자 수(UV)
#
# 요구가 "정확한 숫자" 라면
# → set 또는 DB DISTINCT — 메모리·비용이 크다
#
# 요구가 "대시보드에 보여 줄 대략의 추세" 라면
# → HyperLogLog — 0.8% 오차, 메모리 12KB, 병합 가능
#
# ★ 요구사항을 먼저 확인하는 것이 이 절의 핵심이다.
# "정확해야 한다" 는 대부분 검증되지 않은 가정이다.
# 두 가지를 함께 쓰는 것도 흔하다
# · 실시간 대시보드 → HLL (빠르고 가볍게)
# · 정산 · 청구 → 정확한 집계 (배치로 하루 한 번)
자료구조 결정표 — 상황을 넣으면 답이 나옵니다
하려는 일을 한글로 적으면 쓸 구조가 나옵니다. 고민을 30초로 줄이는 것이 목적입니다.
| 하려는 일 | 자료구조 | 한 줄 메모 |
|---|---|---|
| 키로 값을 빠르게 찾기 | 해시맵 | O(1) · 순서는 보장 안 됨 |
| 중복 없이 모으기 | 해시 집합 | set · 순서 필요하면 dict 키 |
| “이거 이미 있나” 검사 | 해시 집합 | 리스트 in 은 O(n) — 절대 금지 |
| 거대한 목록에서 중복 검사 | 블룸 필터 | 메모리 100배 절약 · 위양성 있음 |
| 삽입 순서 유지 | 동적 배열 · dict(3.7+) | 파이썬 dict 는 순서 보장 |
| 정렬 유지 + 범위 조회 | 균형 트리 | TreeMap · SortedDict |
| 디스크에 정렬 저장 | B+트리 | DB 인덱스가 이것 |
| 거의 안 변하는 정렬 데이터 | 정렬 배열 + 이진 탐색 | 캐시 효율이 압도적 |
| 가장 급한 것 하나씩 꺼내기 | 힙 | heapq · O(log n) |
| 상위 K개만 유지 | 크기 K 힙 | 메모리 O(k) · 스트림 가능 |
| 양쪽에서 넣고 빼기 | 덱 | deque · 슬라이딩 윈도우 |
| 최근 N개만 유지 | 링 버퍼 | deque(maxlen=N) |
| LRU 캐시 | 해시맵 + 이중 연결 리스트 | functools.lru_cache · OrderedDict |
| 접두사로 검색 · 자동완성 | 트라이 | 메모리 주의 · marisa-trie |
| 여러 패턴을 한 번에 찾기 | 아호코라식 | 텍스트 한 번만 스캔 |
| “같은 그룹인가” 판정 · 병합 | 유니온 파인드 | 거의 O(1) · 분리는 불가 |
| 연결 관계 탐색 · 최단 경로 | 인접 리스트 | 희소하면 무조건 리스트 |
| 고유 개수를 대략 세기 | HyperLogLog | 12KB 로 수십억 · 오차 0.8% |
| 인기 항목 빈도 추정 | Count-Min Sketch | 과대 추정만 발생 |
| p99 를 분산 환경에서 | t-digest · DDSketch | 병합 가능 · 분위수 평균 금지 |
| 구간 합 + 잦은 갱신 | Fenwick · 세그먼트 트리 | 둘 다 O(log n) |
| 구간 합 (갱신 없음) | 누적 합 배열 | 전처리 O(n) · 조회 O(1) |
| 긴 텍스트에서 패턴 찾기 | KMP · 라빈카프 | 내장 in 이 이미 최적화됨 |
| 문서 전문 검색 | 역인덱스 | Elasticsearch · Lucene |
| 유사한 벡터 찾기 | HNSW · IVF | 정확한 최근접은 고차원에서 비쌈 |
| 여러 스레드가 함께 쓰기 | 동시성 컨테이너 | ConcurrentHashMap · 채널 |
| 여러 서버가 나눠 처리 | 메시지 큐 | Redis Stream · Kafka · SQS |
| 과거 상태를 유지 | 영속 자료구조 | 경로 복사 · 구조 공유 |
이렇게도 쓸 수 있다 — 고르기 전에 던지는 네 가지 질문 🟡 중급
- 어떤 질의가 가장 많은가 — 조회? 범위? 순회? 최댓값? 가장 잦은 연산이 O(1) 이나 O(log n) 이 되게 고릅니다.
- 얼마나 자주 바뀌는가 — 거의 안 바뀌면 정렬 배열이 대부분의 트리를 이깁니다(캐시 때문).
- 크기가 얼마나 되는가 — n < 100 이면 배열 선형 탐색이 가장 빠른 경우가 많습니다. 큰 n 에서만 복잡도가 의미를 갖습니다.
- 정확해야 하는가 — “대략”이 허용되면 메모리를 수십~수만 배 줄일 수 있습니다(12 절).
실무에선 이렇게 — 이어지는 곳 🔴 실무
| 여기서 배운 것 | 다음 |
|---|---|
| 01 캐시 지역성 | 🖥️ 컴퓨터구조 08 캐시 |
| 04·05 해시 | 🔐 보안 · 암호 02 암호학적 해시 |
| 08 B+트리 · LSM | 🗄️ Database 가이드 인덱스 튜닝 |
| 09 힙 · 우선순위 | 🔀 알고리즘 05 다익스트라 |
| 11 유니온 파인드 | 🔀 알고리즘 06 MST |
| 12 확률적 구조 | 🕸️ 분산 10 샤딩 · 일관 해싱 |
구간 질의 구조 — 세그먼트 트리 · 펜윅 트리
“3번부터 7번까지의 합”을 묻는데 값이 계속 바뀌는 상황. 배열을 매번 훑으면 O(n), 미리 다 더해 두면 수정할 때 O(n). 둘 다 O(log n) 으로 만드는 것이 이 단원입니다 — 학부 자료구조의 마지막 관문이자 코딩 테스트 단골입니다.
| 방법 | 구간 질의 | 값 수정 | 언제 쓰나 |
|---|---|---|---|
| 매번 훑기 | O(n) | O(1) | 질의가 아주 드물 때 |
| 누적합(prefix sum) | O(1) | O(n) | 값이 안 바뀔 때 — 이게 답이면 여기서 끝 |
| 펜윅 트리(BIT) | O(log n) | O(log n) | 합·XOR 처럼 역연산이 있는 경우 — 코드가 가장 짧습니다 |
| 세그먼트 트리 | O(log n) | O(log n) | 최솟값·최댓값·GCD 등 무엇이든 · 구간 갱신까지 |
| 희소 테이블 | O(1) | 불가 | 값이 안 바뀌고 최솟값 질의만 많을 때 |
세그먼트 트리 — 반복문 구현(가장 짧은 형태)
class SegTree:
"""구간 합. op 를 바꾸면 min·max·gcd 로 그대로 씁니다."""
def __init__(self, arr, op=lambda a, b: a + b, e=0):
self.n, self.op, self.e = len(arr), op, e
self.t = [e] * (2 * self.n) # 크기 2n 이면 충분합니다
self.t[self.n:] = arr # 뒤 절반이 잎
for i in range(self.n - 1, 0, -1): # 부모는 자식 둘을 합친 값
self.t[i] = op(self.t[2*i], self.t[2*i+1])
def update(self, i, v): # i 번째를 v 로 — O(log n)
i += self.n
self.t[i] = v
while i > 1: # 잎에서 뿌리까지 올라가며 갱신
i //= 2
self.t[i] = self.op(self.t[2*i], self.t[2*i+1])
def query(self, l, r): # [l, r) 구간 — O(log n)
res_l = res_r = self.e
l += self.n; r += self.n
while l < r:
if l & 1: # l 이 오른쪽 자식이면 자기 것만 쓰고 이동
res_l = self.op(res_l, self.t[l]); l += 1
if r & 1: # r 도 마찬가지 (오른쪽에서 왼쪽으로)
r -= 1; res_r = self.op(self.t[r], res_r)
l //= 2; r //= 2
return self.op(res_l, res_r)
st = SegTree([1,2,3,4,5,6,7,8])
print(st.query(2, 6)) # 3+4+5+6 = 18
st.update(3, 40) # 4 → 40
print(st.query(2, 6)) # 3+40+5+6 = 54
# 최솟값 트리로 바꾸려면 생성자만 바꾸면 됩니다
mn = SegTree([5,2,8,1], op=min, e=float("inf"))
print(mn.query(0, 3)) # 2
op 에 넣을 수 있는 조건은 “결합법칙이 성립할 것” 하나뿐입니다.
(a∘b)∘c = a∘(b∘c) 만 만족하면
합·최솟값·최댓값·GCD·행렬곱·비트 OR 무엇이든 됩니다.
수학에서 이런 구조를 모노이드(monoid) 라 부르고,
항등원 e(합이면 0, 최솟값이면 ∞)가 필요한 이유도 여기 있습니다.
펜윅 트리 — 코드 10줄, 상수도 가장 작습니다
class BIT:
"""구간 합 전용. 세그먼트 트리보다 짧고 빠르지만 min·max 는 못 합니다."""
def __init__(self, n):
self.n = n
self.t = [0] * (n + 1) # 1-based
def add(self, i, delta): # i 번째에 delta 더하기 — O(log n)
i += 1
while i <= self.n:
self.t[i] += delta
i += i & (-i) # ★ 다음 담당 구간으로
def _sum(self, i): # [0..i] 합
s, i = 0, i + 1
while i > 0:
s += self.t[i]
i -= i & (-i) # ★ 부모 구간으로
return s
def query(self, l, r): # [l, r] 합
return self._sum(r) - self._sum(l - 1) if l else self._sum(r)
→
t[12] 는 4칸짜리 구간 [9..12] 의 합을 담당합니다각 노드가 담당하는 길이 = 그 인덱스의 마지막 1비트 값
→ 어떤 접두사도 1비트씩 지워 가며 log n 개 조각으로 분해됩니다
| 비교 | 세그먼트 트리 | 펜윅(BIT) |
|---|---|---|
| 메모리 | 2n ~ 4n | n |
| 코드 길이 | ~30줄 | ~10줄 |
| 가능한 연산 | 결합법칙만 만족하면 무엇이든 | 합·XOR 등 역연산 있는 것만 |
| 구간 갱신 | lazy 로 가능 | 차분 배열을 얹으면 가능 |
| 상수 | 보통 | 가장 작음 |
구간 갱신까지 — 지연 전파(lazy propagation)
“[3..9] 에 전부 +5” 를 O(log n) 으로
문제 — 구간 전체에 값을 더하려면?
잎을 하나씩 고치면 O(구간 길이) → 최악 O(n)
구간이 10만이면 갱신 한 번에 10만 번
아이디어 — "나중에 필요할 때 내려보낸다"
① 갱신 요청이 노드의 구간을 '완전히 덮으면'
그 노드에만 표시해 두고 멈춘다 ← 여기가 핵심
· 자기 합은 즉시 갱신 (더한 값 × 구간 길이)
· "자식들에게 아직 안 알려줬다"를 lazy[] 에 기록
② 나중에 그 아래로 내려갈 일이 생기면
그때 자식에게 밀어 준다 (push down)
→ 실제로 건드리는 노드는 여전히 O(log n) 개
def update(self, node, s, e, l, r, add):
self._push(node, s, e) # 밀린 갱신 먼저 반영
if r < s or e < l: # 겹치지 않음
return
if l <= s and e <= r: # ★ 완전히 덮임 → 여기서 멈춤
self.lazy[node] += add
self._push(node, s, e)
return
mid = (s + e) // 2
self.update(node*2, s, mid, l, r, add)
self.update(node*2+1, mid+1, e, l, r, add)
self.tree[node] = self.tree[node*2] + self.tree[node*2+1]
def _push(self, node, s, e):
if self.lazy[node] == 0: return
self.tree[node] += self.lazy[node] * (e - s + 1) # 구간 길이만큼
if s != e: # 잎이 아니면 자식에게 예약
self.lazy[node*2] += self.lazy[node]
self.lazy[node*2+1] += self.lazy[node]
self.lazy[node] = 0
실무에서 만나는 같은 구조
| 쓰이는 곳 | 무엇을 구간 질의로 보나 |
|---|---|
| DB 인덱스 통계 | “이 값 범위에 행이 몇 개” — 옵티마이저의 카디널리티 추정 |
| 시계열 집계 | “최근 5분 평균 응답시간” — 슬라이딩 윈도우 |
| 랭킹 시스템 | “내 점수보다 높은 사람 수” — 점수를 인덱스로 한 BIT |
| 재고·좌석 예약 | “이 구간이 전부 비어 있나” — 최솟값 세그먼트 트리 |
| 계산 기하 | 스위핑 + 구간 최댓값 (직사각형 넓이 합집합) |
| 스프레드시트 | 수식 재계산 범위 추적 |
# 실전 예 — 실시간 랭킹에서 "내 등수" 구하기
# 점수를 인덱스로 쓰는 BIT (점수 범위 0 ~ 100만)
rank_bit = BIT(1_000_001)
def on_score_change(old: int, new: int):
if old is not None:
rank_bit.add(old, -1) # 옛 점수 자리에서 1명 빼고
rank_bit.add(new, +1) # 새 점수 자리에 1명 추가
def my_rank(score: int) -> int:
higher = rank_bit.query(score + 1, 1_000_000)
return higher + 1 # 나보다 높은 사람 수 + 1
# 정렬 없이 O(log n) 에 등수가 나옵니다.
# 사용자 100만 명이어도 갱신·조회 모두 20번 연산 이하.
스킵 리스트와 지속 자료구조 — 균형을 “동전 던지기”와 “복사 안 하기”로
균형 트리(07절)는 확실하지만 회전 규칙이 복잡하고 동시성에 약합니다. 스킵 리스트는 그 규칙을 통째로 동전 던지기로 대신해 같은 O(log n) 을 얻습니다 — Redis 의 정렬 집합과 LevelDB 의 memtable 이 이것입니다. 지속(persistent) 자료구조는 수정할 때 통째로 복사하지 않고 바뀐 경로만 새로 만드는 방식이고, Git · React · 실행 취소 · MVCC 가 전부 여기에서 나옵니다.
스킵 리스트 — 핵심만 그대로
import random
MAX_L = 16
class Node:
__slots__ = ("key", "next")
def __init__(self, key, lv):
self.key = key
self.next = [None] * lv # 층마다 다음 노드
class SkipList:
def __init__(self):
self.head = Node(None, MAX_L)
self.level = 1
def _rand_level(self):
lv = 1
while random.random() < 0.5 and lv < MAX_L: # 동전 던지기
lv += 1
return lv
def _find(self, key):
"""각 층에서 key 직전 노드를 모아 둡니다."""
prev, cur = [self.head] * MAX_L, self.head
for i in range(self.level - 1, -1, -1):
while cur.next[i] and cur.next[i].key < key:
cur = cur.next[i] # 같은 층에서 전진
prev[i] = cur # 지나쳤으니 한 층 내려감
return prev
def add(self, key):
prev = self._find(key)
lv = self._rand_level()
self.level = max(self.level, lv)
node = Node(key, lv)
for i in range(lv): # 층마다 끼워 넣기
node.next[i] = prev[i].next[i]
prev[i].next[i] = node
def has(self, key):
n = self._find(key)[0].next[0]
return n is not None and n.key == key
def range(self, lo, hi): # 구간 질의가 자연스럽습니다
cur = self._find(lo)[0].next[0]
while cur and cur.key <= hi:
yield cur.key
cur = cur.next[0]
→ 층 i 의 노드 수 기댓값 = n/2i−1
그 값이 1 이 되는 i = log2 n + 1
한 층에서 옆으로 가는 횟수의 기댓값은 2 로 상수
→ 탐색 기댓값 = 2 · log2 n = O(log n)
전체 노드 수 기댓값 = n(1 + ½ + ¼ + …) = 2n
레드블랙 트리와 무엇이 다른가
| 스킵 리스트 | 레드블랙 | |
|---|---|---|
| 탐색 | O(log n) 기댓값 | O(log n) 보장 |
| 최악 | O(n) 이론상 | 보장 |
| 구현량 | 짧음 | 회전·색칠 복잡 |
| 구간 스캔 | L0 순회 | 중위 순회 |
| 동시성 | 부분 잠금 쉬움 | 회전이 광범위 |
| 메모리 | 포인터 평균 2개 | 2개 고정 |
| 캐시 | 흩어짐 | 흩어짐 |
지속 자료구조 — 바꾸되 원본을 남기기
경로 복사를 직접 짜 보기
from typing import NamedTuple, Optional
class Node(NamedTuple):
key: int
left: Optional["Node"] = None
right: Optional["Node"] = None
def insert(node, key):
"""새 트리를 돌려줍니다. 원본은 절대 안 건드립니다."""
if node is None:
return Node(key)
if key < node.key: # 왼쪽만 새로 만들고
return node._replace(left=insert(node.left, key))
if key > node.key: # 오른쪽 가지는 그대로 공유
return node._replace(right=insert(node.right, key))
return node
v1 = None
for k in (50, 30, 70, 20, 40):
v1 = insert(v1, k)
v2 = insert(v1, 35) # 새로 만든 노드는 경로 복사 3개(50·30·40) + 새 잎 1개뿐
print(v1.left.right.left) # None — v1 은 그대로
print(v2.left.right.left.key) # 35 — v2 에만 있음
print(v1.right is v2.right) # True — 오른쪽 가지는 같은 객체를 공유
v1.right is v2.right 가 True 라는 한 줄이 핵심입니다.
두 버전이 메모리를 대부분 같이 쓰고 있다는 뜻이고,
그래서 버전을 100개 만들어도 메모리가 100배가 되지 않습니다.
이미 쓰고 있던 곳들
| 도구 | 같은 아이디어가 쓰이는 방식 |
|---|---|
| Git | 커밋마다 바뀐 경로의 트리만 새로 씀 |
| React | 불변 상태 → === 비교로 리렌더 판단 |
| MVCC | 옛 버전을 남겨 읽기가 쓰기를 안 막음 |
| ZFS · Btrfs | copy-on-write 스냅숏 |
| Clojure | 기본 자료구조 전부가 지속형 |
| 실행 취소 | 옛 뿌리를 스택에 쌓아 두면 끝 |
| LSM 트리 | 덮어쓰지 않고 새로 쌓은 뒤 병합 |
문제를 보고
어떤 무기를 꺼낼지
30초 안에
알고리즘 공부의 목적은 직접 구현하기 위해서가 아닙니다 — 라이브러리에 이미 다 있습니다. 목적은 “이 문제가 어떤 문제인지 알아보는 것”입니다. 그래야 검색이 되고, 복잡도를 예측하고, 안 되는 것을 안 된다고 말할 수 있습니다. 각 절은 원리 → 코드 → 실무에서 만나는 자리 순으로 갑니다.
정렬 — 왜 아직도 배우나
sort() 한 줄이면 끝나는데도 배우는 이유는,
거의 모든 알고리즘이 정렬 위에 서 있고
정렬 알고리즘의 아이디어가 다른 곳에서 계속 재사용되기 때문입니다.
기본 세 가지 — 아이디어만
# 선택 정렬 — 가장 작은 것을 찾아 앞으로
for i in range(n):
m = min(range(i, n), key=lambda j: a[j])
a[i], a[m] = a[m], a[i]
# O(n²) · 교환 횟수는 최소 (n번) → 교환이 비쌀 때 의미 있음
# 삽입 정렬 — 카드를 손에 정리하듯
for i in range(1, n):
key, j = a[i], i - 1
while j >= 0 and a[j] > key:
a[j + 1] = a[j]; j -= 1
a[j + 1] = key
# O(n²) 이지만 ★ 거의 정렬된 배열에서는 O(n) — 실무에서 중요
# 버블 정렬 — 인접한 것끼리 교환
# O(n²) · 실무 가치 없음 (교육용)
sort() 구현이 작은 구간에서 삽입 정렬로 전환합니다.
안정 정렬이 왜 중요한가
# 안정(stable) = 같은 키를 가진 원소들의 원래 순서가 유지됨
people = [("김", 30), ("이", 25), ("박", 30), ("최", 25)]
# 다단계 정렬을 할 수 있다 — 뒤에서부터 정렬
people.sort(key=lambda p: p[0]) # ① 이름순
people.sort(key=lambda p: p[1]) # ② 나이순 (이름순이 유지됨)
# 결과: 나이순, 같은 나이면 이름순
# 안정하지 않으면 이게 안 된다 → 튜플 키를 써야 함
people.sort(key=lambda p: (p[1], p[0]))
| 언어 | 안정? |
|---|---|
파이썬 sort · sorted | ✓ 보장 (팀소트) |
자바 Collections.sort | ✓ 보장 |
자바 Arrays.sort(int[]) | ✗ (듀얼 피벗 퀵) |
JS Array.sort (ES2019+) | ✓ 보장 |
C++ std::sort | ✗ — stable_sort 사용 |
Go sort.Slice | ✗ — SliceStable 사용 |
Rust sort | ✓ — sort_unstable 이 더 빠름 |
이렇게도 쓸 수 있다 — 비교 없는 정렬 🟡 중급
O(n log n) 하한을 깨는 법
# 계수 정렬 — 값의 범위가 좁을 때 O(n + k)
def counting_sort(a, max_val):
count = [0] * (max_val + 1)
for x in a: count[x] += 1
out, i = [], 0
for v, c in enumerate(count):
out.extend([v] * c)
return out
counting_sort([3,1,4,1,5,9,2,6,5,3], 9) # 값이 0~9 → 매우 빠름
# 기수 정렬 — 자릿수별로 계수 정렬 O(d(n+k))
def radix_sort(a):
if not a: return a
exp = 1
while max(a) // exp > 0:
buckets = [[] for _ in range(10)]
for x in a: buckets[(x // exp) % 10].append(x)
a = [x for b in buckets for x in b] # ★ 안정해야 동작한다
exp *= 10
return a
# 어디에 쓰나
# · 나이 · 점수 · 등급 (범위 좁음)
# · 고정 길이 키 (IP · 날짜 · ID)
# · 대용량 로그를 타임스탬프로 (버킷 정렬)
실무에선 이렇게 — 정렬이 필요 없는 경우 🔴 실무
| 하려는 일 | 정렬 대신 | 복잡도 |
|---|---|---|
| 최댓값 · 최솟값 | max() · min() | O(n) |
| 상위 K개 | 힙 · heapq.nlargest | O(n log k) |
| 중앙값 · k번째 | 퀵셀렉트 | O(n) 평균 |
| “정렬돼 있나” 확인 | 한 번 훑기 | O(n) |
| 중복 제거 | set | O(n) |
| 그룹핑 | defaultdict | O(n) |
| DB 에서 정렬된 결과 | 인덱스 + ORDER BY | 정렬 생략 |
# 퀵셀렉트 — k번째 값을 O(n) 평균에 찾는다
import random
def quickselect(a, k):
"""0-based k번째로 작은 값"""
if len(a) == 1: return a[0]
pivot = random.choice(a)
lo = [x for x in a if x < pivot]
eq = [x for x in a if x == pivot]
hi = [x for x in a if x > pivot]
if k < len(lo): return quickselect(lo, k)
if k < len(lo) + len(eq): return pivot
return quickselect(hi, k - len(lo) - len(eq))
# 파이썬 표준 라이브러리에는 없다 — numpy 를 쓰면
import numpy as np
np.partition(arr, k)[k] # k번째 값 · O(n)
heapq.nlargest(10, data) 로 바꾸면 수십 배 빨라집니다.
퀵 · 머지 · 힙 정렬 — 실무 sort() 의 정체
실제 표준 라이브러리의 sort() 는 한 알고리즘이 아닙니다.
상황에 따라 세 가지를 섞어 씁니다. 그 이유를 알면
정렬이 느릴 때 원인을 짚을 수 있습니다.
퀵 정렬
def quicksort(a, lo, hi):
if lo >= hi: return
p = partition(a, lo, hi)
quicksort(a, lo, p-1)
quicksort(a, p+1, hi)
# 평균 O(n log n)
# 최악 O(n²) ← 피벗이 나쁠 때
# 공간 O(log n) 스택
# 제자리 · 캐시 친화적
# 불안정
병합 정렬
def merge_sort(a):
if len(a) <= 1: return a
m = len(a) // 2
return merge(merge_sort(a[:m]),
merge_sort(a[m:]))
# 항상 O(n log n)
# 공간 O(n)
# ★ 안정
# ★ 외부 정렬 가능
# (메모리보다 큰 데이터)
힙 정렬
def heap_sort(a):
heapify(a) # O(n)
for i in range(len(a)-1, 0, -1):
a[0], a[i] = a[i], a[0]
sift_down(a, 0, i)
# 항상 O(n log n)
# 공간 O(1) ★
# 불안정
# 캐시 효율 나쁨
파티션 — 퀵 정렬의 심장
def partition(a, lo, hi):
"""로무토 방식 — 이해하기 쉬움"""
pivot = a[hi]
i = lo
for j in range(lo, hi):
if a[j] < pivot:
a[i], a[j] = a[j], a[i]
i += 1
a[i], a[hi] = a[hi], a[i]
return i
# ★ 중복이 많으면 로무토는 O(n²) 이 된다
# → 3-way 파티션 (네덜란드 국기 문제)
def partition3(a, lo, hi):
pivot = a[lo]
lt, i, gt = lo, lo, hi
while i <= gt:
if a[i] < pivot: a[lt], a[i] = a[i], a[lt]; lt += 1; i += 1
elif a[i] > pivot: a[i], a[gt] = a[gt], a[i]; gt -= 1
else: i += 1
return lt, gt # [lt, gt] 는 전부 pivot
실제 라이브러리 구현
| 이름 | 구성 | 쓰는 곳 |
|---|---|---|
| 팀소트 | 병합 + 삽입 + 기존 정렬 구간 활용 | 파이썬 · 자바 객체 · JS |
| 인트로소트 | 퀵 → 깊어지면 힙 → 작으면 삽입 | C++ std::sort |
| 패턴 디피팅 퀵 | 퀵 + 패턴 감지 + 힙 폴백 | Rust · Go(최신) |
| 듀얼 피벗 퀵 | 피벗 2개로 3분할 | 자바 원시 타입 |
팀소트가 빠른 이유 — 현실 데이터는 부분적으로 정렬돼 있다
[1,2,3,4, 9,8,7, 5,6]
└─ run ─┘└run┘ └run┘ ← 이미 정렬된 구간(run)을 찾아
뒤집고, 병합만 한다
→ 이미 정렬된 배열이면 O(n)
이렇게도 쓸 수 있다 — 비교자를 잘못 쓰면 🟡 중급
정렬이 터지거나 결과가 뒤죽박죽일 때
✗ 일관성 없는 비교자
// 자바: 뺄셈으로 비교 → 오버플로
(a, b) -> a.value - b.value
// value 가 큰 음수/양수면 부호가 뒤집힘
// → "Comparison method violates
// its general contract!" 예외
// 부동소수점에 NaN
(a, b) -> a.score > b.score ? 1 : -1
// 같을 때 0 을 반환하지 않음
✓ 안전한 비교자
Comparator.comparingInt(x -> x.value)
Comparator.comparing(X::getName)
.thenComparing(X::getId)
// 파이썬은 key 함수를 쓴다 (cmp 아님)
items.sort(key=lambda x: (x.age, x.name))
// JS 는 숫자 정렬에 비교자가 필수
[10,9,1].sort() // [1,10,9] ✗ 문자열 비교
[10,9,1].sort((a,b)=>a-b) // [1,9,10] ✓
| 비교자가 지켜야 할 규칙 | 어기면 |
|---|---|
| 반대칭 cmp(a,b) = −cmp(b,a) | 결과가 실행마다 다름 |
| 추이성 a<b, b<c ⇒ a<c | 예외 또는 무한 루프 |
| 일관성 같으면 항상 0 | 정렬이 안정하지 않게 됨 |
| 결정적 랜덤·시간 사용 금지 | 정렬 중 순서가 바뀜 |
NaN < x,
NaN > x, NaN == x 가 전부 거짓이라
추이성이 성립하지 않습니다. 정렬 전에 걸러 내세요 →
➗ 선형대수 12.
실무에선 이렇게 — 메모리보다 큰 데이터 정렬 🔴 실무
외부 정렬 — 100GB 로그를 8GB 램에서
- 쪼개서 정렬 — 메모리에 들어가는 크기(예: 1GB)씩 읽어 메모리에서 정렬한 뒤 임시 파일로 씁니다.
- k-way 병합 — 정렬된 임시 파일들을 힙으로 병합하며 한 줄씩 최종 파일에 씁니다.
- 병합 차수 조절 — 파일이 너무 많으면 여러 단계로 나눕니다 (파일 핸들·버퍼 한계).
import heapq, tempfile, os
def external_sort(in_path, out_path, chunk_lines=1_000_000, key=None):
tmps = []
with open(in_path) as f:
while True:
chunk = [next(f, None) for _ in range(chunk_lines)]
chunk = [c for c in chunk if c is not None]
if not chunk: break
chunk.sort(key=key)
t = tempfile.NamedTemporaryFile("w+", delete=False)
t.writelines(chunk); t.flush(); t.seek(0)
tmps.append(t)
with open(out_path, "w") as out:
# heapq.merge 가 정렬된 스트림들을 게으르게 병합한다
out.writelines(heapq.merge(*tmps, key=key))
for t in tmps: t.close(); os.unlink(t.name)
sort 명령이 이미 이것을 합니다 —
GNU sort 는 자동으로 외부 정렬을 하고
-S(메모리) · --parallel 옵션이 있습니다.
직접 짜기 전에 sort -S 4G --parallel=8 를 먼저 시도하세요.
이진 탐색 — 가장 많이 틀리는 알고리즘
아이디어는 단순한데 경계 조건에서 거의 모두가 틀립니다. 1946년에 발표된 알고리즘의 첫 정확한 구현은 1962년에 나왔습니다. 틀리지 않는 형태를 익혀 둘 가치가 있습니다.
틀리지 않는 형태
# ★ 반열린 구간 [lo, hi) 로 통일하면 실수가 줄어든다
def lower_bound(a, x):
"""x 이상인 첫 위치 (없으면 len(a))"""
lo, hi = 0, len(a)
while lo < hi: # <= 아님
mid = (lo + hi) // 2 # 파이썬은 오버플로 없음
if a[mid] < x: lo = mid + 1 # mid 는 답이 아님 → +1
else: hi = mid # mid 가 답일 수 있음 → 그대로
return lo
def upper_bound(a, x):
"""x 초과인 첫 위치"""
lo, hi = 0, len(a)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] <= x: lo = mid + 1
else: hi = mid
return lo
# 개수 세기
count = upper_bound(a, x) - lower_bound(a, x)
# 파이썬 표준 라이브러리에 이미 있다 — 이걸 쓰세요
import bisect
bisect.bisect_left(a, x) # lower_bound
bisect.bisect_right(a, x) # upper_bound
bisect.insort(a, x) # 정렬 유지하며 삽입
흔한 버그 네 가지
| 버그 | 증상 |
|---|---|
(lo+hi)/2 오버플로 | C·자바에서 큰 배열 시 음수 인덱스 |
lo = mid (+1 없이) | 무한 루프 |
hi = len(a)-1 혼용 | 마지막 원소를 못 찾음 |
| 정렬 안 된 배열 | 조용히 틀린 답 |
// 자바·C 에서의 오버플로 방지 (JDK 에 실제로 있던 버그)
int mid = lo + (hi - lo) / 2; // ✓
// int mid = (lo + hi) / 2; // ✗ lo+hi 가 int 범위를 넘으면 음수
// 무한 루프가 나는 형태
while (lo < hi) {
int mid = (lo + hi) / 2;
if (a[mid] < x) lo = mid; // ✗ lo 가 안 움직일 수 있다
else hi = mid;
}
이렇게도 쓸 수 있다 — 답을 이진 탐색하기 (파라메트릭 서치) 🟡 중급
“최소값을 구하라”를 “가능한가?”로 바꾸기
# 문제: 케이블 N개를 K개로 자를 때 가능한 최대 길이는?
def can_make(cables, length, k):
if length == 0: return True
return sum(c // length for c in cables) >= k
def max_length(cables, k):
lo, hi = 0, max(cables) # 답의 범위
while lo < hi:
mid = (lo + hi + 1) // 2 # ★ 위로 올림 (lo=mid 를 쓰므로)
if can_make(cables, mid, k): lo = mid
else: hi = mid - 1
return lo
max_length([802, 743, 457, 539], 11) # 200
# 실수 답이면 반복 횟수로 끊는다
def solve_real(check, lo, hi, iters=100):
for _ in range(iters): # 100번이면 2^-100 정밀도
mid = (lo + hi) / 2
if check(mid): lo = mid
else: hi = mid
return lo
| 실무에서 이 패턴이 나오는 곳 | 판정 문제 |
|---|---|
| 서버를 몇 대 둘까 | N대로 목표 지연을 만족하나? |
| 타임아웃을 얼마로 | T초면 성공률 99% 가 되나? |
| 배치 크기 최적화 | B개씩 처리하면 메모리 안에 드나? |
| 레이트 리밋 값 | 초당 R건이면 p99 를 지키나? |
| 이미지 품질 압축률 | Q로 압축하면 목표 용량 이하인가? |
실무에선 이렇게 — 이진 탐색이 아닌 것 🔴 실무
git bisect — 커밋을 이진 탐색
# 1000개 커밋 중 버그를 넣은 커밋 찾기 → 10번의 확인으로
git bisect start
git bisect bad # 현재는 버그 있음
git bisect good v1.2.0 # 이 버전은 괜찮았음
# git 이 중간 커밋으로 체크아웃 → 테스트 → good/bad 응답 반복
# 자동화 (테스트 스크립트가 있으면)
git bisect run ./test.sh # 알아서 끝까지 찾아 준다
git bisect reset
| 같은 원리를 쓰는 곳 | “정렬된 것” |
|---|---|
git bisect | 커밋 히스토리 (시간순) |
| 로그에서 시각으로 위치 찾기 | 타임스탬프 |
| 페이지네이션 커서 | 정렬된 ID |
| B+트리 노드 안 탐색 | 노드 내 키 |
| 버전 호환성 찾기 | 버전 번호 |
| 부하 한계 찾기 | 동시 접속 수 |
그래프 순회 — BFS 와 DFS의 차이가 답을 바꿉니다
같은 그래프를 가까운 것부터 볼 것인가(BFS), 한 방향으로 끝까지 파고들 것인가(DFS). 이 선택이 “최단 경로”와 “모든 경로”를 가릅니다.
BFS — 큐, 층별로
from collections import deque
def bfs(graph, start):
visited = {start}
q = deque([start])
order = []
while q:
v = q.popleft() # ★ 큐 = 먼저 넣은 것 먼저
order.append(v)
for nxt in graph[v]:
if nxt not in visited:
visited.add(nxt) # ★ 큐에 넣을 때 표시 (중복 방지)
q.append(nxt)
return order
# 최단 거리까지 함께
def bfs_dist(graph, start):
dist = {start: 0}
q = deque([start])
while q:
v = q.popleft()
for nxt in graph[v]:
if nxt not in dist:
dist[nxt] = dist[v] + 1
q.append(nxt)
return dist # ★ 가중치 없는 그래프의 최단 거리
DFS — 스택 또는 재귀
def dfs_recursive(graph, v, visited=None):
if visited is None: visited = set()
visited.add(v)
for nxt in graph[v]:
if nxt not in visited:
dfs_recursive(graph, nxt, visited)
return visited
def dfs_iterative(graph, start):
visited, stack = set(), [start]
while stack:
v = stack.pop() # ★ 스택 = 나중에 넣은 것 먼저
if v in visited: continue
visited.add(v)
for nxt in reversed(graph[v]): # 순서 맞추기
if nxt not in visited:
stack.append(nxt)
return visited
# ★ 깊이가 크면 반드시 반복 버전 (스택 오버플로)
언제 무엇을 쓰나
| 문제 | 선택 | 이유 |
|---|---|---|
| 최단 경로 (가중치 없음) | BFS | 먼저 도달 = 최단 |
| 최소 이동 횟수 · 최소 환승 | BFS | 같은 이유 |
| 모든 경로 나열 | DFS | 백트래킹과 자연스럽게 결합 |
| 사이클 검출 | DFS | 현재 경로를 스택으로 추적 |
| 위상 정렬 | DFS 또는 진입차수 BFS | 둘 다 가능 |
| 연결 요소 개수 | 둘 다 | DFS 가 코드가 짧음 |
| 깊이가 매우 깊은 그래프 | BFS 또는 반복 DFS | 스택 오버플로 회피 |
| 메모리가 빠듯한 넓은 그래프 | DFS | BFS 큐가 층 전체를 담음 |
이렇게도 쓸 수 있다 — 실무 문제로 옮기기 🟡 중급
격자에서의 BFS (가장 흔한 형태)
def shortest_path_grid(grid, start, goal):
R, C = len(grid), len(grid[0])
q = deque([(start, 0)])
seen = {start}
while q:
(r, c), d = q.popleft()
if (r, c) == goal: return d
for dr, dc in ((1,0), (-1,0), (0,1), (0,-1)):
nr, nc = r + dr, c + dc
if (0 <= nr < R and 0 <= nc < C
and grid[nr][nc] != "#" and (nr, nc) not in seen):
seen.add((nr, nc))
q.append(((nr, nc), d + 1))
return -1
# 응용: 여러 시작점에서 동시에 (다중 소스 BFS)
# · 모든 불에서 동시에 번지기
# · 각 칸에서 가장 가까운 편의점까지 거리
q = deque((s, 0) for s in all_starts) # 시작점을 전부 큐에
실무 사례
| 문제 | 정점 · 간선 |
|---|---|
| 친구의 친구 추천 | 사용자 · 친구 관계 (BFS 2단계) |
| 권한 상속 계산 | 역할 · 상속 관계 (DFS) |
| 순환 참조 감지 | 모듈 · import (DFS) |
| 영향받는 서비스 찾기 | 서비스 · 의존 (역방향 BFS) |
| 웹 크롤링 | URL · 링크 (BFS + 깊이 제한) |
| 파일 트리 탐색 | 디렉터리 · 하위 (DFS) |
| GC 도달 가능성 | 객체 · 참조 (DFS/BFS) |
| DOM 이벤트 버블링 | 노드 · 부모 (경로) |
실무에선 이렇게 — 큰 그래프에서의 주의점 🔴 실무
| 문제 | 대응 |
|---|---|
| 재귀 DFS 스택 오버플로 | 반복 버전 · sys.setrecursionlimit 은 임시방편 |
| BFS 큐가 메모리를 다 씀 | 양방향 BFS · 깊이 제한 · IDDFS |
| 방문 집합이 너무 큼 | 블룸 필터(근사) · 디스크 기반 |
| 그래프가 메모리에 안 들어감 | 외부 메모리 BFS · 그래프 DB |
| 탐색이 영원히 안 끝남 | 깊이·노드 수 상한 필수 |
| 순환 링크로 무한 크롤링 | 정규화된 URL 로 방문 판정 |
# 양방향 BFS — 시작과 끝에서 동시에 (탐색 공간이 √로 줄어든다)
def bidirectional_bfs(graph, start, goal):
if start == goal: return 0
front, back = {start}, {goal}
seen = {start, goal}
steps = 0
while front and back:
if len(front) > len(back): front, back = back, front # 작은 쪽부터
steps += 1
nxt = set()
for v in front:
for w in graph[v]:
if w in back: return steps # 만났다
if w not in seen:
seen.add(w); nxt.add(w)
front = nxt
return -1
# 분기 계수 b, 거리 d 일 때
# 단방향: b^d 양방향: 2·b^(d/2)
# b=10, d=6 이면 100만 → 2000 (500배)
최단 경로 — 다익스트라 · 벨만포드 · A*
가중치가 붙는 순간 BFS 로는 안 됩니다. 비용이 다른 길 중 가장 싼 것을 찾는 세 가지 방법과 각각이 필요한 조건을 봅니다.
다익스트라 — 가장 많이 쓰는 것
import heapq
def dijkstra(graph, start):
"""graph[u] = [(v, weight), ...] · 음수 가중치 불가"""
dist = {start: 0}
pq = [(0, start)]
while pq:
d, u = heapq.heappop(pq)
if d > dist.get(u, float("inf")):
continue # ★ 낡은 항목 건너뛰기
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float("inf")):
dist[v] = nd
heapq.heappush(pq, (nd, v))
return dist
# 경로까지 복원하려면
def dijkstra_path(graph, start, goal):
dist, prev = {start: 0}, {}
pq = [(0, start)]
while pq:
d, u = heapq.heappop(pq)
if u == goal: break
if d > dist.get(u, float("inf")): continue
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float("inf")):
dist[v], prev[v] = nd, u
heapq.heappush(pq, (nd, v))
path, cur = [], goal
while cur in prev: path.append(cur); cur = prev[cur]
return dist.get(goal), [start] + path[::-1]
세 가지 비교
| 복잡도 | 음수 | 쓸 때 | |
|---|---|---|---|
| BFS | O(V+E) | — | 가중치 전부 같음 |
| 다익스트라 | O(E log V) | ✗ | 기본 선택 |
| 벨만-포드 | O(V·E) | ✓ | 음수 간선 · 사이클 검출 |
| 플로이드 | O(V³) | ✓ | 모든 쌍 · V ≤ 500 |
| A* | 휴리스틱 의존 | ✗ | 목표가 하나 · 거리 추정 가능 |
# 벨만-포드 — 음수 사이클 검출까지
def bellman_ford(n, edges, start):
dist = [float("inf")] * n
dist[start] = 0
for i in range(n - 1): # V-1 번 완화
changed = False
for u, v, w in edges:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w; changed = True
if not changed: break
# V 번째에도 줄어들면 음수 사이클
for u, v, w in edges:
if dist[u] + w < dist[v]:
raise ValueError("음수 사이클 존재")
return dist
이렇게도 쓸 수 있다 — A* — 목표를 향해 편향시키기 🟡 중급
다익스트라에 “남은 거리 추정”을 더한 것
def a_star(graph, start, goal, heuristic):
"""heuristic(v) = v 에서 goal 까지의 '낙관적' 거리 추정"""
g = {start: 0} # 시작부터의 실제 비용
pq = [(heuristic(start), start)] # f = g + h 로 정렬
while pq:
_, u = heapq.heappop(pq)
if u == goal: return g[u]
for v, w in graph.get(u, []):
ng = g[u] + w
if ng < g.get(v, float("inf")):
g[v] = ng
heapq.heappush(pq, (ng + heuristic(v), v))
return None
# ★ 휴리스틱의 조건
# 허용 가능(admissible): 실제 거리를 절대 과대평가하지 않을 것
# → 지도에서는 직선 거리 (실제 도로 거리 ≥ 직선 거리)
# 일관성(consistent): h(u) ≤ w(u,v) + h(v)
# → 이것까지 만족하면 재방문이 필요 없다
# h(v) = 0 이면 다익스트라와 같아진다
# h 가 정확할수록 탐색 범위가 줄어든다
| 쓰이는 곳 | 휴리스틱 |
|---|---|
| 지도 길찾기 | 직선 거리 / 최고 속도 |
| 게임 유닛 이동 | 맨해튼 · 대각선 거리 |
| 퍼즐 (15-퍼즐) | 제자리 아닌 타일 수 · 맨해튼 합 |
| 경로 계획 (로봇) | 유클리드 거리 |
| 단어 변환 | 다른 글자 수 |
실무에선 이렇게 — 최단 경로가 아닌 문제들 🔴 실무
| 현실 문제 | 그래프로 옮기면 |
|---|---|
| 가장 싼 환승 조합 | 정점=역, 간선=구간 요금 |
| 가장 신뢰도 높은 경로 | 가중치 = −log(성공확률) → 곱을 합으로 |
| 최소 지연 라우팅 | 가중치 = 링크 지연 |
| 통화 차익 거래 탐지 | 음수 사이클 검출 (벨만-포드) |
| 빌드 최소 재실행 | DAG 최단 경로 |
| 단어 편집 거리 | 격자 그래프 최단 경로 = DP |
| 병목 최소화 (최대 용량 경로) | 다익스트라 변형 (min 대신 max) |
# 곱셈을 덧셈으로 — 로그 변환 트릭
# "성공 확률이 가장 높은 경로" = 확률의 곱이 최대
# → -log 를 취하면 합이 최소 = 일반 최단 경로 문제가 된다
import math
graph_log = {u: [(v, -math.log(p)) for v, p in edges]
for u, edges in graph_prob.items()}
dist = dijkstra(graph_log, start)
best_prob = math.exp(-dist[goal])
# 병목 최소화 — 경로 위 최댓값을 최소로
# (예: 대역폭이 가장 넓은 경로)
def widest_path(graph, start):
cap = {start: float("inf")}
pq = [(-float("inf"), start)]
while pq:
c, u = heapq.heappop(pq); c = -c
if c < cap.get(u, 0): continue
for v, w in graph.get(u, []):
nc = min(c, w) # ★ 합이 아니라 min
if nc > cap.get(v, 0):
cap[v] = nc
heapq.heappush(pq, (-nc, v))
return cap
MST · 위상 정렬 — 연결하기와 순서 정하기
모두를 가장 싸게 연결하는 문제(MST)와 의존성을 지키며 순서를 정하는 문제(위상 정렬). 둘 다 실무에서 자주 나오고 구현이 짧습니다.
위상 정렬 — 진입 차수 방식 (칸 알고리즘)
from collections import deque
def topo_sort(nodes, edges):
"""edges: [(before, after), ...]"""
indeg = {n: 0 for n in nodes}
adj = {n: [] for n in nodes}
for a, b in edges:
adj[a].append(b); indeg[b] += 1
q = deque(n for n in nodes if indeg[n] == 0)
out = []
while q:
v = q.popleft()
out.append(v)
for nxt in adj[v]:
indeg[nxt] -= 1
if indeg[nxt] == 0: q.append(nxt)
if len(out) != len(nodes):
remaining = [n for n in nodes if n not in set(out)]
raise ValueError(f"순환 의존: {remaining}") # ★ 사이클 검출도 공짜
return out
topo_sort(["a","b","c","d"], [("a","b"),("a","c"),("b","d"),("c","d")])
# ['a', 'b', 'c', 'd']
병렬 실행 계획까지
def topo_levels(nodes, edges):
"""동시에 실행 가능한 작업을 층으로 묶는다"""
indeg = {n: 0 for n in nodes}
adj = {n: [] for n in nodes}
for a, b in edges:
adj[a].append(b); indeg[b] += 1
ready = [n for n in nodes if indeg[n] == 0]
levels = []
while ready:
levels.append(ready) # ★ 이 층은 전부 병렬 가능
nxt = []
for v in ready:
for w in adj[v]:
indeg[w] -= 1
if indeg[w] == 0: nxt.append(w)
ready = nxt
return levels
# [['a'], ['b','c'], ['d']]
# → b 와 c 는 동시에 실행 가능
# 총 소요 시간 = 층 수 (임계 경로 길이)
needs 가
전부 이 계산 위에 있습니다.
이렇게도 쓸 수 있다 — MST 두 알고리즘 🟡 중급
크루스칼 — 간선 중심
def kruskal(n, edges):
"""edges: [(weight, u, v)]"""
uf = UnionFind(n)
mst, total = [], 0
for w, u, v in sorted(edges):
if uf.union(u, v): # 사이클이 안 생기면 채택
mst.append((u, v, w)); total += w
if len(mst) == n - 1: break
return mst, total
# O(E log E) — 정렬이 지배
# 간선이 적을 때(희소 그래프) 유리
프림 — 정점 중심
def prim(graph, start):
visited = {start}
pq = [(w, start, v) for v, w in graph[start]]
heapq.heapify(pq)
mst, total = [], 0
while pq and len(visited) < len(graph):
w, u, v = heapq.heappop(pq)
if v in visited: continue
visited.add(v); mst.append((u, v, w)); total += w
for nxt, nw in graph[v]:
if nxt not in visited:
heapq.heappush(pq, (nw, v, nxt))
return mst, total
# O(E log V)
# 조밀 그래프에서 유리 · 다익스트라와 형태가 거의 같다
| 실무 사례 | 정점 · 간선 · 가중치 |
|---|---|
| 네트워크 회선 설계 | 거점 · 링크 · 설치 비용 |
| 스위칭 루프 방지 (STP) | 스위치 · 링크 · 비용 |
| 클러스터링 (단일 연결법) | 데이터 점 · 거리 → MST 자르기 |
| 이미지 분할 | 픽셀 · 인접 · 색 차이 |
| 회로 배선 최소화 | 단자 · 배선 · 길이 |
실무에선 이렇게 — 순환 의존성을 다루는 법 🔴 실무
사이클을 찾았다면 그다음
# 강한 연결 요소(SCC)로 묶으면 사이클이 하나의 노드가 된다
# → 축약 그래프는 DAG 가 되어 위상 정렬이 가능해진다
def tarjan_scc(graph):
index = {}; low = {}; on_stack = set(); stack = []
counter = [0]; result = []
def dfs(v):
index[v] = low[v] = counter[0]; counter[0] += 1
stack.append(v); on_stack.add(v)
for w in graph.get(v, []):
if w not in index:
dfs(w); low[v] = min(low[v], low[w])
elif w in on_stack:
low[v] = min(low[v], index[w])
if low[v] == index[v]: # SCC 의 루트
comp = []
while True:
w = stack.pop(); on_stack.discard(w); comp.append(w)
if w == v: break
result.append(comp)
for v in graph:
if v not in index: dfs(v)
return result
# 모듈 의존성에서 SCC 를 찾으면
# · 크기 1 → 정상
# · 크기 >1 → 순환 그룹 → 리팩터링 대상
| 순환 의존을 푸는 방법 | 설명 |
|---|---|
| 의존성 역전 | 공통 인터페이스를 만들어 양쪽이 그것에 의존 |
| 공통 부분 추출 | 순환의 원인이 되는 코드를 별도 모듈로 |
| 이벤트로 전환 | 직접 호출 대신 이벤트 발행 (느슨한 결합) |
| 지연 참조 | 런타임에 해결 (타입만 순환일 때) |
| 합치기 | 정말 뗄 수 없으면 하나의 모듈로 인정 |
탐욕 알고리즘 — 언제 통하고 언제 안 통하나
“지금 제일 좋아 보이는 것”만 고르는 방법입니다. 통할 때는 가장 단순하고 빠르며, 안 통할 때는 조용히 틀립니다. 그 구분이 이 절의 전부입니다.
통하는 예 — 회의실 배정
def max_meetings(meetings):
"""(start, end) 목록에서 최대한 많이 배정"""
meetings.sort(key=lambda m: m[1]) # ★ 끝나는 시간 순
picked, last_end = [], float("-inf")
for s, e in meetings:
if s >= last_end:
picked.append((s, e)); last_end = e
return picked
max_meetings([(1,4),(3,5),(0,6),(5,7),(8,9),(5,9)])
# [(1,4),(5,7),(8,9)] → 3개
# 왜 맞나 (교환 논증)
# 최적해의 첫 회의를 '가장 빨리 끝나는 회의' 로 바꿔도
# 여전히 유효하고 개수가 줄지 않는다.
# 이를 반복하면 탐욕해가 최적해가 된다. ∎
안 통하는 예 — 거스름돈
def greedy_change(coins, amount):
coins = sorted(coins, reverse=True)
out = []
for c in coins:
while amount >= c:
out.append(c); amount -= c
return out if amount == 0 else None
# 한국 · 미국 화폐에서는 맞는다
greedy_change([500,100,50,10], 780) # [500,100,100,50,10,10,10] ✓
# 그런데 이런 화폐 체계에서는 틀린다
greedy_change([4, 3, 1], 6) # [4,1,1] → 3개
# 최적: [3,3] → 2개 ✗
# ★ 정답: 동적 계획법 (09절)
이렇게도 쓸 수 있다 — 탐욕이 통하는 조건 🟡 중급
두 가지 성질
| 성질 | 뜻 |
|---|---|
| 탐욕 선택 속성 | 지금의 최선 선택이 최적해에 포함됨 |
| 최적 부분 구조 | 부분 문제의 최적이 전체 최적을 이룸 |
탐욕으로 푸는 대표 문제
| 구간 스케줄링 | 끝나는 시간 순 |
| 허프만 코딩 | 빈도 낮은 것부터 병합 |
| MST (크루스칼·프림) | 싼 간선부터 |
| 다익스트라 | 가까운 정점부터 확정 |
| 분할 가능 배낭 | 단위 가치 높은 것부터 |
| 작업 순서 (지연 최소) | 짧은 것부터 (SJF) |
# 반례 찾기 — 완전 탐색과 대조 (실무에서 가장 확실한 검증)
import itertools, random
def brute_force(items, cap):
best = 0
for r in range(len(items) + 1):
for combo in itertools.combinations(items, r):
w = sum(i[0] for i in combo)
if w <= cap: best = max(best, sum(i[1] for i in combo))
return best
for _ in range(1000):
items = [(random.randint(1,10), random.randint(1,20)) for _ in range(8)]
cap = random.randint(5, 30)
if greedy_knapsack(items, cap) != brute_force(items, cap):
print("반례:", items, cap) # 0/1 배낭은 탐욕이 틀린다
break
실무에선 이렇게 — 탐욕 + 지역 탐색 🔴 실무
최적이 아니어도 충분히 좋은 답을 빨리
# 실무 최적화의 표준 조합
# ① 탐욕으로 초기해를 만든다 (빠름)
# ② 지역 탐색으로 개선한다 (2-opt · 스왑 · 이동)
# ③ 시간 예산이 끝나면 현재 최선을 반환
def optimize(items, budget_sec=5.0):
import time
sol = greedy_initial(items)
best, best_cost = sol, cost(sol)
deadline = time.monotonic() + budget_sec
while time.monotonic() < deadline:
cand = perturb(best) # 이웃 해
c = cost(cand)
if c < best_cost:
best, best_cost = cand, c
return best, best_cost
# 실무에서 이 구조가 좋은 이유
# · 언제든 멈출 수 있다 (anytime 알고리즘)
# · 시간 예산을 늘리면 답이 좋아진다
# · 최악의 경우에도 탐욕해 수준은 보장
| 실무 사례 | 탐욕 초기해 |
|---|---|
| 배송 경로 | 가장 가까운 곳부터 (nearest neighbor) |
| VM 배치 | 가장 큰 것부터 남는 곳에 (first-fit decreasing) |
| 캐시 교체 | LRU · LFU |
| 작업 스케줄링 | 마감 임박 순 (EDF) |
| 대역폭 배분 | 공정 분배 후 잉여 재분배 |
분할 정복 — 반으로 쪼개서 푸는 법
큰 문제를 작은 같은 문제로 쪼개고 · 각각 풀고 · 합칩니다. 병합 정렬 · 퀵 정렬 · FFT · 거듭제곱이 전부 이 형태이고, 병렬화가 자연스럽다는 실무적 이점이 있습니다.
기본 형태
def divide_conquer(problem):
if is_small(problem): # ① 기저: 충분히 작으면 직접
return solve_directly(problem)
parts = split(problem) # ② 분할
results = [divide_conquer(p) for p in parts] # ③ 정복
return combine(results) # ④ 병합
# 복잡도는 점화식으로 (🔢 이산수학 12)
# T(n) = a·T(n/b) + f(n)
# a = 부분 문제 수, b = 축소 비율, f(n) = 분할+병합 비용
# 빠른 거듭제곱 — O(log n)
def power(base, exp):
if exp == 0: return 1
half = power(base, exp // 2) # 절반만 계산
return half * half * (base if exp % 2 else 1)
power(2, 1000) # 1000번이 아니라 10번의 곱셈
# 행렬 거듭제곱으로 피보나치를 O(log n) 에
def fib(n):
def mat_mul(A, B):
return [[A[0][0]*B[0][0]+A[0][1]*B[1][0], A[0][0]*B[0][1]+A[0][1]*B[1][1]],
[A[1][0]*B[0][0]+A[1][1]*B[1][0], A[1][0]*B[0][1]+A[1][1]*B[1][1]]]
def mat_pow(M, p):
R = [[1,0],[0,1]]
while p:
if p & 1: R = mat_mul(R, M)
M = mat_mul(M, M); p >>= 1
return R
return mat_pow([[1,1],[1,0]], n)[0][1]
대표 알고리즘들
| 알고리즘 | 점화식 | 결과 |
|---|---|---|
| 이진 탐색 | T(n/2)+1 | O(log n) |
| 병합 정렬 | 2T(n/2)+n | O(n log n) |
| 퀵 정렬 (평균) | 2T(n/2)+n | O(n log n) |
| 거듭제곱 | T(n/2)+1 | O(log n) |
| 카라츠바 곱셈 | 3T(n/2)+n | O(n1.58) |
| 스트라센 행렬곱 | 7T(n/2)+n² | O(n2.81) |
| FFT | 2T(n/2)+n | O(n log n) |
| 최근접 점 쌍 | 2T(n/2)+n | O(n log n) |
이렇게도 쓸 수 있다 — 병렬화가 공짜로 따라온다 🟡 중급
from concurrent.futures import ThreadPoolExecutor
import multiprocessing as mp
def parallel_merge_sort(a, depth=0, max_depth=3):
if len(a) <= 1024: # ★ 임계값 아래는 순차로
return sorted(a)
mid = len(a) // 2
if depth < max_depth:
with ThreadPoolExecutor(2) as ex: # CPU 바운드면 프로세스 풀
f1 = ex.submit(parallel_merge_sort, a[:mid], depth+1, max_depth)
f2 = ex.submit(parallel_merge_sort, a[mid:], depth+1, max_depth)
left, right = f1.result(), f2.result()
else:
left = parallel_merge_sort(a[:mid], depth+1, max_depth)
right = parallel_merge_sort(a[mid:], depth+1, max_depth)
return merge(left, right)
# ★ 임계값(cutoff)이 중요하다
# 너무 작으면 태스크 생성 비용이 계산보다 커진다
# 보통 1000~10000 원소 근처에서 순차로 전환
| 병렬 분할 정복이 쓰이는 곳 | 도구 |
|---|---|
| 맵리듀스 | Hadoop · Spark |
| 작업 훔치기 (fork-join) | 자바 ForkJoinPool · Rust rayon |
| 병렬 정렬 | C++ std::execution::par |
| GPU 리덕션 | CUDA · 트리 형태 합산 |
| 분산 집계 | 부분 집계 → 병합 (t-digest 등) |
실무에선 이렇게 — 분할 정복이 오히려 손해일 때 🔴 실무
| 상황 | 문제 | 대응 |
|---|---|---|
| 부분 문제가 겹침 | 같은 계산 반복 (피보나치) | 동적 계획법 (09) |
| n 이 작음 | 재귀 오버헤드가 지배 | 임계값 아래는 단순 알고리즘 |
| 병합 비용이 큼 | f(n) 이 지배 | 다른 접근 |
| 메모리 복사가 많음 | 슬라이싱마다 새 배열 | 인덱스 범위로 전달 |
| 스택이 깊음 | 스택 오버플로 | 반복 버전 · 명시적 스택 |
✗ 슬라이싱으로 복사
def merge_sort(a):
if len(a) <= 1: return a
m = len(a) // 2
return merge(merge_sort(a[:m]), # 매번 새 배열
merge_sort(a[m:])) # → O(n log n) 추가 메모리
✓ 인덱스 범위로
def merge_sort(a, lo=0, hi=None, buf=None):
if hi is None: hi, buf = len(a), [0]*len(a)
if hi - lo <= 1: return
m = (lo + hi) // 2
merge_sort(a, lo, m, buf)
merge_sort(a, m, hi, buf)
merge_into(a, lo, m, hi, buf) # 버퍼 재사용
a[:m] 은
O(m) 복사입니다. 재귀 안에서 쓰면 복잡도가
로그 계수만큼 더 붙고 메모리도 크게 늡니다.
교육용 코드가 실무에서 느린 대표적 이유입니다.
동적 계획법 — 같은 계산을 두 번 하지 않기
이름은 거창하지만 내용은 “결과를 적어 두고 재사용”이 전부입니다. 어렵게 느껴지는 것은 상태를 어떻게 정의할지가 문제마다 다르기 때문입니다. 그 정의법을 익히는 것이 목표입니다.
두 가지 방식
# ① 메모이제이션 (하향식 — 재귀 + 캐시)
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n < 2: return n
return fib(n-1) + fib(n-2)
# 캐시 없으면 O(1.618ⁿ) → 캐시 있으면 O(n)
# n=40 : 수십 초 → 즉시
# ② 타뷸레이션 (상향식 — 반복문 + 표)
def fib_table(n):
if n < 2: return n
dp = [0, 1]
for i in range(2, n + 1):
dp.append(dp[i-1] + dp[i-2])
return dp[n]
# 공간 최적화 — 필요한 것만 유지
def fib_opt(n):
a, b = 0, 1
for _ in range(n): a, b = b, a + b
return a
DP 로 풀 수 있는 조건
| 조건 | 뜻 |
|---|---|
| 최적 부분 구조 | 부분 문제의 최적으로 전체 최적을 구성 |
| 중복 부분 문제 | 같은 부분 문제가 여러 번 나타남 |
DP 문제를 만났을 때의 순서
① 상태를 정의한다
dp[i] = "무엇"인지 한 문장으로 적는다
→ 이게 안 되면 아직 문제를 이해 못 한 것
② 점화식을 세운다
dp[i] 를 더 작은 dp[?] 로 표현
③ 기저 조건을 정한다
dp[0] · dp[1] 은?
④ 계산 순서를 정한다
의존하는 것이 먼저 계산되도록
⑤ 답이 어디 있는지 확인
dp[n]? max(dp)? dp[n][k]?
이렇게도 쓸 수 있다 — 대표 문제 네 가지 🟡 중급
# ① 0/1 배낭 — dp[w] = 무게 w 이하로 얻는 최대 가치
def knapsack(items, cap):
dp = [0] * (cap + 1)
for weight, value in items:
for w in range(cap, weight - 1, -1): # ★ 역순 (한 번만 쓰기)
dp[w] = max(dp[w], dp[w - weight] + value)
return dp[cap]
# ② 최장 공통 부분 수열 (LCS) — diff 의 기반
def lcs(a, b):
dp = [[0] * (len(b)+1) for _ in range(len(a)+1)]
for i in range(1, len(a)+1):
for j in range(1, len(b)+1):
if a[i-1] == b[j-1]: dp[i][j] = dp[i-1][j-1] + 1
else: dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[-1][-1]
# ③ 편집 거리 (레벤슈타인) — 오타 교정 · 유사도
def edit_distance(a, b):
dp = [[0]*(len(b)+1) for _ in range(len(a)+1)]
for i in range(len(a)+1): dp[i][0] = i
for j in range(len(b)+1): dp[0][j] = j
for i in range(1, len(a)+1):
for j in range(1, len(b)+1):
cost = 0 if a[i-1] == b[j-1] else 1
dp[i][j] = min(dp[i-1][j] + 1, # 삭제
dp[i][j-1] + 1, # 삽입
dp[i-1][j-1] + cost) # 교체
return dp[-1][-1]
# ④ 최장 증가 부분 수열 (LIS) — O(n log n) 버전
import bisect
def lis(a):
tails = []
for x in a:
i = bisect.bisect_left(tails, x)
if i == len(tails): tails.append(x)
else: tails[i] = x
return len(tails)
| 문제 | 실무에서의 모습 |
|---|---|
| LCS | git diff · 파일 비교 · 버전 병합 |
| 편집 거리 | 오타 교정 · 검색 제안 · 퍼지 매칭 |
| 배낭 | 예산 배분 · 광고 슬롯 · 리소스 할당 |
| LIS | 추세 분석 · 패치 적용 순서 |
| 구간 DP | 행렬 곱 순서 · 쿼리 최적화 |
| 비트마스크 DP | 작업 배정 (n ≤ 20) |
실무에선 이렇게 — 메모이제이션은 곧 캐시 🔴 실무
from functools import lru_cache, cache
import time
# 순수 함수라면 캐시가 안전하다
@lru_cache(maxsize=1024)
def expensive_lookup(user_id: int, date: str) -> dict:
return db.query(...)
# ★ 주의사항
# ① 인자가 해시 가능해야 한다 (list · dict 는 불가 → tuple 로)
# ② 반환값이 가변이면 호출자가 수정할 위험 (복사해서 주거나 불변으로)
# ③ 캐시가 메모리를 계속 먹는다 → maxsize 지정
# ④ 결과가 시간에 따라 변하면 TTL 이 필요 (lru_cache 에는 없음)
# TTL 캐시 직접 만들기
def ttl_cache(seconds):
def deco(fn):
store = {}
def wrapper(*args):
now = time.monotonic()
if args in store:
val, exp = store[args]
if now < exp: return val
val = fn(*args)
store[args] = (val, now + seconds)
return val
wrapper.cache_clear = store.clear
return wrapper
return deco
@ttl_cache(60)
def get_config(key): return remote_fetch(key)
| 캐시 계층 | DP 로 치면 |
|---|---|
| 함수 메모이제이션 | 부분 문제 표 |
| Redis 캐시 | 분산된 표 |
| DB 머티리얼라이즈드 뷰 | 미리 계산한 표 |
| CDN | 엣지에 있는 표 |
| 브라우저 캐시 | 클라이언트의 표 |
백트래킹 — 다 해 보되 가망 없으면 즉시 되돌기
완전 탐색에 가지치기를 더한 것입니다. 그 가지치기 하나로 실행 불가능한 문제가 몇 초가 되기도 합니다.
기본 뼈대
def backtrack(state, choices, result):
if is_complete(state):
result.append(state.copy())
return
for choice in choices(state):
if not is_valid(state, choice): # ★ 가지치기
continue
state.append(choice) # 선택
backtrack(state, choices, result) # 진행
state.pop() # ★ 되돌리기 (핵심)
return result
# 되돌리기를 빠뜨리면 상태가 오염된다 — 가장 흔한 버그
# N-Queens — 가지치기가 만드는 차이
def n_queens(n):
cols, diag1, diag2 = set(), set(), set()
board, out = [], []
def place(r):
if r == n: out.append(board.copy()); return
for c in range(n):
if c in cols or (r-c) in diag1 or (r+c) in diag2:
continue # ★ O(1) 충돌 검사
cols.add(c); diag1.add(r-c); diag2.add(r+c); board.append(c)
place(r + 1)
cols.discard(c); diag1.discard(r-c); diag2.discard(r+c); board.pop()
place(0)
return out
len(n_queens(8)) # 92
# 완전 탐색: 8⁸ = 1677만
# 가지치기: 약 2000번의 시도
가지치기 전략
| 전략 | 내용 |
|---|---|
| 제약 위반 즉시 중단 | 규칙을 어기면 더 안 감 |
| 상한 · 하한 계산 | 여기서 최선을 다해도 현재 최선보다 나쁘면 중단 |
| 순서 고정 | 중복 조합 방지 (조합 생성) |
| 가장 제약 많은 것 먼저 | 실패를 빨리 발견 (MRV 휴리스틱) |
| 대칭 제거 | 회전·반사로 같은 해를 한 번만 |
| 메모이제이션 | 같은 상태를 다시 안 봄 |
# 분기 한정 (branch and bound) — 상한으로 가지치기
def knapsack_bnb(items, cap):
items = sorted(items, key=lambda x: -x[1]/x[0]) # 단위 가치 순
best = 0
def bound(i, w, v):
"""남은 물건을 분할 가능하다고 보고 계산한 상한"""
rem, total = cap - w, v
for weight, value in items[i:]:
if weight <= rem: rem -= weight; total += value
else: total += value * rem / weight; break
return total
def go(i, w, v):
nonlocal best
if w > cap: return
best = max(best, v)
if i == len(items): return
if bound(i, w, v) <= best: return # ★ 여기서 잘라 낸다
go(i+1, w + items[i][0], v + items[i][1])
go(i+1, w, v)
go(0, 0, 0)
return best
이렇게도 쓸 수 있다 — 순열 · 조합 · 부분집합 🟡 중급
# 세 가지 생성 패턴 — 차이는 '다음에 무엇을 고를 수 있나' 뿐
def permutations(a): # 순열: 안 쓴 것 전부
out, used, cur = [], [False]*len(a), []
def go():
if len(cur) == len(a): out.append(cur.copy()); return
for i, x in enumerate(a):
if used[i]: continue
used[i] = True; cur.append(x); go()
cur.pop(); used[i] = False
go(); return out
def combinations(a, k): # 조합: 내 뒤의 것만 (순서 고정)
out, cur = [], []
def go(start):
if len(cur) == k: out.append(cur.copy()); return
for i in range(start, len(a)):
cur.append(a[i]); go(i + 1); cur.pop()
go(0); return out
def subsets(a): # 부분집합: 각각 넣거나 말거나
out, cur = [], []
def go(i):
if i == len(a): out.append(cur.copy()); return
go(i + 1) # 안 넣음
cur.append(a[i]); go(i + 1); cur.pop() # 넣음
go(0); return out
# ★ 실무에서는 itertools 를 쓴다
import itertools
list(itertools.permutations([1,2,3]))
list(itertools.combinations([1,2,3,4], 2))
list(itertools.chain.from_iterable(
itertools.combinations([1,2,3], r) for r in range(4)))
실무에선 이렇게 — 백트래킹이 쓰이는 실제 시스템 🔴 실무
| 시스템 | 무엇을 백트래킹하나 |
|---|---|
| 정규식 엔진 | 매칭 실패 시 다른 분기 시도 (ReDoS 의 원인) |
| SAT 솔버 | 변수 할당 (DPLL + 충돌 학습) |
| 패키지 의존성 해결 | 버전 조합 (pubgrub · pip 백트래커) |
| 타입 추론 | 타입 변수 후보 |
| 파서 (PEG) | 규칙 대안 시도 |
| 제약 해결 (CSP) | 변수 값 배정 |
| 쿼리 플래너 | 조인 순서 (제한된 탐색) |
pip 의 의존성 해결이 오래 걸리는 이유
requests 2.31 을 고름
→ urllib3 <3 필요
→ 다른 패키지가 urllib3 >=2.1 요구
→ 충돌! 되돌아가서 requests 다른 버전 시도
→ ... (조합 폭발)
대응
· 버전을 고정해 탐색 공간을 줄인다 (lock 파일)
· 제약을 느슨하게 (상한을 함부로 걸지 않기)
· 최신 리졸버 사용 (충돌 원인을 학습해 가지치기)
문자열 알고리즘 — 이미 본 것을 다시 안 보기
긴 텍스트에서 패턴을 찾을 때 매번 처음부터 다시 비교하지 않는 방법들입니다. 실무에서 직접 구현할 일은 드물지만, 왜 빠른지를 알면 검색 엔진과 diff 도구가 이해됩니다.
단순 방법의 문제
텍스트: AAAAAAAAAB
패턴: AAAAB
위치 0: AAAAB vs AAAAA → 마지막에서 실패 (5번 비교)
위치 1: AAAAB vs AAAAA → 또 실패 (5번 비교)
...
→ O(n × m)
★ 낭비: 위치 0 에서 "AAAA 는 맞았다" 는 정보를 버렸다
# KMP — 실패 함수로 얼마나 건너뛸지 미리 계산
def kmp_table(p):
"""t[i] = p[:i+1] 의 '접두사이면서 접미사인' 최대 길이"""
t = [0] * len(p)
k = 0
for i in range(1, len(p)):
while k > 0 and p[i] != p[k]: k = t[k-1]
if p[i] == p[k]: k += 1
t[i] = k
return t
def kmp_search(text, pattern):
if not pattern: return 0
t = kmp_table(pattern)
k = 0
for i, ch in enumerate(text):
while k > 0 and ch != pattern[k]: k = t[k-1]
if ch == pattern[k]: k += 1
if k == len(pattern): return i - k + 1
return -1
# O(n + m) — 텍스트를 한 번만 훑는다
라빈-카프 — 롤링 해시
def rabin_karp(text, pattern, base=256, mod=(1<<61)-1):
n, m = len(text), len(pattern)
if m > n: return -1
h = pow(base, m - 1, mod)
ph = th = 0
for i in range(m): # 초기 해시
ph = (ph * base + ord(pattern[i])) % mod
th = (th * base + ord(text[i])) % mod
for i in range(n - m + 1):
if ph == th and text[i:i+m] == pattern: # 해시 충돌 확인
return i
if i < n - m: # ★ 한 글자만 빼고 더한다 — O(1)
th = ((th - ord(text[i]) * h) * base + ord(text[i+m])) % mod
return -1
# 여러 패턴을 동시에 찾을 때 특히 유리
# (패턴들의 해시를 집합에 넣고 한 번만 스캔)
이렇게도 쓸 수 있다 — 실무 문자열 처리 🟡 중급
| 하려는 일 | 도구 | 비고 |
|---|---|---|
| 부분 문자열 찾기 | 내장 in · find | 이미 최적화됨 (보이어-무어 변형) |
| 여러 패턴 동시에 | 아호코라식 | 🗂️ 자료구조 10 |
| 유사 문자열 | 편집 거리 · rapidfuzz | DP (09절) |
| 자동완성 | 트라이 · FST | 접두사 검색 |
| 전문 검색 | 역인덱스 (Lucene) | 토큰화 + 색인 |
| 중복 문서 탐지 | SimHash · MinHash | 근사 |
| diff | Myers 알고리즘 | LCS 변형 |
| 압축 | LZ77 · 접미사 배열 | 반복 구간 찾기 |
# 실무: 유니코드가 섞이면 "글자 수"가 간단하지 않다
s = "café"
len(s) # 4 또는 5 (정규화에 따라)
import unicodedata
unicodedata.normalize("NFC", s) # 결합 문자를 합침 → 4
unicodedata.normalize("NFD", s) # 분해 → 5
# 비교 전에 반드시 정규화
def same_text(a, b):
n = lambda s: unicodedata.normalize("NFC", s).casefold()
return n(a) == n(b)
# 이모지 · 한글 자모 · 국기는 여러 코드포인트로 이뤄진다
len("👨👩👧👦") # 7 (ZWJ 로 연결된 4명)
len("🇰🇷") # 2 (지역 표시 문자 2개)
# 사용자가 보는 '글자' 단위로 자르려면 grapheme 라이브러리 필요
len() 으로 검사하면 이모지 한 개가
7자로 세어집니다. 바이트 제한(DB)과 표시 길이(UI)를 구분하고,
필요하면 grapheme 단위로 세어야 합니다.
실무에선 이렇게 — diff 는 어떻게 만들어지나 🔴 실무
LCS 를 구하면 diff 가 나온다
# 두 파일의 최장 공통 부분 수열을 구하면
# 그에 포함되지 않은 줄이 추가/삭제다
import difflib
a = ["def f():", " x = 1", " return x"]
b = ["def f():", " x = 2", " y = 3", " return x"]
for line in difflib.unified_diff(a, b, lineterm="", n=1):
print(line)
# ---
# +++
# @@ -1,3 +1,4 @@
# def f():
# - x = 1
# + x = 2
# + y = 3
# return x
# git 이 쓰는 것은 Myers 알고리즘 — LCS 를 O((N+M)D) 에
# D = 편집 거리 (보통 작다) → 실제로는 매우 빠르다
# git diff --histogram / --patience 는 '읽기 좋은 diff' 를 위한 변형
| diff 알고리즘 | 특징 |
|---|---|
| Myers (기본) | 최소 편집 · 빠름 |
| patience | 유일한 줄을 기준점으로 → 읽기 좋음 |
| histogram | patience 개선판 · git 권장 |
| minimal | 가장 작은 diff (느림) |
git diff --histogram 을
시도해 보세요. 함수 경계를 더 잘 잡아 리뷰하기 좋은 diff 가 나오는 경우가 많습니다.
diff.algorithm=histogram 으로 기본값을 바꿀 수 있습니다.
랜덤화 · 근사 — 정확도를 팔아 속도를 삽니다
주사위를 굴리면 최악의 입력이 사라집니다. 그리고 “대략의 답”으로 요구를 낮추면 불가능하던 규모가 가능해집니다.
두 가지 랜덤 알고리즘
| 종류 | 답 | 시간 | 예 |
|---|---|---|---|
| 라스베이거스 | 항상 정확 | 확률적 | 랜덤 퀵 정렬 |
| 몬테카를로 | 확률적 | 확정적 | 밀러-라빈 소수 판정 |
# 랜덤 피벗 — 최악의 입력을 없앤다
import random
def quicksort(a, lo, hi):
if lo >= hi: return
p = random.randint(lo, hi) # ★ 랜덤 피벗
a[p], a[hi] = a[hi], a[p]
m = partition(a, lo, hi)
quicksort(a, lo, m-1); quicksort(a, m+1, hi)
# 왜 중요한가
# · 고정 피벗(첫 원소·마지막 원소)이면
# 이미 정렬된 배열에서 O(n²)
# · 공격자가 최악의 입력을 만들 수 있다 (알고리즘 복잡도 공격)
# · 랜덤이면 어떤 입력이든 기댓값 O(n log n)
밀러-라빈 — 확률적 소수 판정
import random
def is_prime(n, k=20):
if n < 2: return False
for p in (2,3,5,7,11,13,17,19,23,29,31,37):
if n % p == 0: return n == p
d, r = n - 1, 0
while d % 2 == 0: d //= 2; r += 1
for _ in range(k):
a = random.randrange(2, n - 1)
x = pow(a, d, n)
if x in (1, n - 1): continue
for _ in range(r - 1):
x = x * x % n
if x == n - 1: break
else:
return False # 확실히 합성수
return True # 소수일 확률 > 1 - 4^-k
# k=20 이면 틀릴 확률 < 10^-12
# → 하드웨어 오류 확률보다 낮다
# RSA 키 생성이 이 방법을 쓴다 (🔢 이산수학 09)
이렇게도 쓸 수 있다 — 실무에서 쓰는 랜덤 기법 🟡 중급
# ① 리저버 샘플링 — 크기를 모르는 스트림에서 k개 균등 추출
import random
def reservoir(stream, k):
res = []
for i, item in enumerate(stream):
if i < k: res.append(item)
else:
j = random.randint(0, i)
if j < k: res[j] = item
return res
# 전체를 메모리에 안 올리고도 모든 항목이 같은 확률로 뽑힌다
# ② 피셔-예이츠 셔플 — 편향 없는 섞기
def shuffle(a):
for i in range(len(a) - 1, 0, -1):
j = random.randint(0, i) # ★ 0..i (i 포함)
a[i], a[j] = a[j], a[i]
# ✗ 흔한 실수: j = random.randint(0, len(a)-1) → 편향 발생
# ③ 지터 — 동시 재시도를 흩뜨린다
def backoff_with_jitter(attempt, base=0.1, cap=30.0):
exp = min(cap, base * (2 ** attempt))
return random.uniform(0, exp) # full jitter
# 지터가 없으면 모든 클라이언트가 같은 시각에 재시도 → 재차 과부하
# ④ 확률적 조기 만료 (캐시 스탬피드 방지) — ➗ 선형대수 07
| 기법 | 해결하는 문제 |
|---|---|
| 랜덤 피벗 · 해시 시드 | 최악 입력 · 복잡도 공격 |
| 리저버 샘플링 | 무한 스트림에서 표본 추출 |
| 지터 | 동시 재시도 · thundering herd |
| 랜덤 라우팅 (power of 2) | 부하 분산 (2개 뽑아 덜 바쁜 쪽) |
| 카오스 엔지니어링 | 장애 내성 검증 |
| A/B 배정 | 편향 없는 실험 |
실무에선 이렇게 — 난수의 함정 🔴 실무
| 용도 | 쓸 것 | 쓰면 안 되는 것 |
|---|---|---|
| 알고리즘 · 시뮬레이션 | random · np.random | — |
| 토큰 · 비밀번호 · 세션 ID | secrets · crypto.randomBytes | random · Math.random() |
| 재현이 필요한 실험 | 시드 고정 default_rng(42) | 전역 시드 (다른 코드가 바꿈) |
| 분산 환경의 고유 ID | UUIDv7 · ULID · 스노우플레이크 | 랜덤 정수 |
| 병렬 실행의 난수 | 스트림별 독립 생성기 | 전역 생성기 공유 (경쟁) |
# ✗ 예측 가능한 토큰 — 실제로 있었던 취약점
import random
token = "".join(random.choices("abcdef0123456789", k=32))
# Mersenne Twister 는 출력 624개를 보면 내부 상태를 복원할 수 있다
# → 이후 모든 토큰을 예측 가능
# ✓ 암호학적으로 안전한 난수
import secrets
token = secrets.token_urlsafe(32) # 256비트
code = secrets.randbelow(1_000_000) # 인증 코드
# 재현 가능한 실험 — 전역 시드가 아니라 지역 생성기
import numpy as np
rng = np.random.default_rng(42) # ★ 이 객체만 씀
rng.random(10)
# np.random.seed(42) 는 전역이라 다른 라이브러리와 충돌한다
문제 접근법 — 제약 조건이 답을 알려 줍니다
코딩 테스트든 실무든 접근은 같습니다 — 제약에서 필요한 복잡도를 역산하고, 그 복잡도로 가능한 기법 목록을 좁힙니다.
제약 → 복잡도 → 기법
| n 의 크기 | 필요한 복잡도 | 후보 기법 |
|---|---|---|
| n ≤ 11 | O(n!) | 순열 완전 탐색 · 백트래킹 |
| n ≤ 25 | O(2ⁿ) | 부분집합 · 비트마스크 DP · 중간에서 만나기 |
| n ≤ 100 | O(n⁴) | 4중 반복 · 복잡한 DP |
| n ≤ 500 | O(n³) | 플로이드-워셜 · 구간 DP |
| n ≤ 5,000 | O(n²) | 2중 반복 · LCS · 2차원 DP |
| n ≤ 100,000 | O(n log n) | 정렬 · 이진 탐색 · 힙 · 세그먼트 트리 |
| n ≤ 1,000,000 | O(n) | 투 포인터 · 슬라이딩 윈도우 · 누적 합 |
| n ≥ 10⁸ | O(log n) · O(1) | 수식 · 이진 탐색 · 행렬 거듭제곱 |
문제 유형 인식 신호
| 문제에 이런 말이 나오면 | 의심할 기법 |
|---|---|
| “최소/최대 개수” | DP · 탐욕 · 이진 탐색 |
| “가능한 모든 경우” | 백트래킹 · DFS |
| “최단 거리 · 최소 이동” | BFS · 다익스트라 |
| “연속된 구간” | 슬라이딩 윈도우 · 투 포인터 |
| “구간 합 · 구간 최댓값” | 누적 합 · 세그먼트 트리 |
| “정렬된 배열에서” | 이진 탐색 · 투 포인터 |
| “k번째” | 힙 · 퀵셀렉트 · 이진 탐색 |
| “겹치지 않게 최대” | 탐욕 (끝나는 시간 순) |
| “그룹 · 연결 · 병합” | 유니온 파인드 · BFS |
| “순서를 지키며” | 위상 정렬 |
| “이전 값에 의존” | DP |
| “최대값을 최소화” | 파라메트릭 서치 |
막혔을 때의 순서
- 작은 예제를 손으로 — n=3, 4 를 종이에 풀어 봅니다. 패턴이 보입니다.
- 완전 탐색부터 — 느려도 맞는 답을 먼저 만들고, 그것을 기준으로 최적화합니다.
- 무엇이 중복인지 — 같은 계산이 반복되면 DP · 메모이제이션.
- 정렬해 보기 — 정렬만으로 문제가 단순해지는 경우가 아주 많습니다.
- 반대로 생각하기 — “최대 몇 개 넣을까” → “최소 몇 개 뺄까”.
- 제약을 다시 읽기 — 놓친 조건이 답의 열쇠인 경우가 많습니다.
이렇게도 쓸 수 있다 — 자주 쓰는 패턴 세 가지 🟡 중급
# ① 투 포인터 — 정렬된 배열에서 두 수의 합
def two_sum_sorted(a, target):
i, j = 0, len(a) - 1
while i < j:
s = a[i] + a[j]
if s == target: return (i, j)
if s < target: i += 1 # 더 크게
else: j -= 1 # 더 작게
return None
# O(n) — 해시를 안 써도 된다
# ② 슬라이딩 윈도우 — 조건을 만족하는 최소 길이 구간
def min_subarray_len(nums, target):
left = total = 0
best = float("inf")
for right, x in enumerate(nums):
total += x
while total >= target: # 조건 만족 → 왼쪽을 좁힌다
best = min(best, right - left + 1)
total -= nums[left]; left += 1
return best if best < float("inf") else 0
# O(n) — 각 원소가 들어가고 나가는 것이 한 번씩
# ③ 누적 합 — 구간 합을 O(1) 에
def build_prefix(a):
pre = [0]
for x in a: pre.append(pre[-1] + x)
return pre
pre = build_prefix([1,2,3,4,5])
pre[4] - pre[1] # a[1..3] 의 합 = 9
# 2차원 버전 (이미지 · 격자 문제)
# sum(r1..r2, c1..c2) = P[r2][c2] - P[r1-1][c2] - P[r2][c1-1] + P[r1-1][c1-1]
실무에선 이렇게 — 면접과 실무의 차이 🔴 실무
| 코딩 테스트 | 실무 | |
|---|---|---|
| 목표 | 최적 복잡도 | 충분히 빠르고 읽기 쉬움 |
| 입력 | 깨끗하고 보장됨 | 더럽고 예외투성이 |
| 구현 | 직접 | 라이브러리 우선 |
| 검증 | 제출 결과 | 테스트 · 모니터링 |
| 중요한 것 | 정답 | 유지보수 · 관측 가능성 |
| 병목 | 알고리즘 | 대개 I/O · 네트워크 · DB |
실무에서 성능 문제를 만났을 때의 순서
① 측정한다 — 추측 금지. 프로파일러 · APM
② 병목을 찾는다 — 대개 DB 쿼리 · N+1 · 네트워크 왕복
③ 알고리즘 확인 — 여기서 O(n²) 이 발견되면 큰 승리
④ 자료구조 교체 — list → set 하나로 끝나는 경우가 많다
⑤ 캐시 · 배치 — 반복 계산 · 왕복 줄이기
⑥ 상수 최적화 — 마지막. 효과 대비 노력이 가장 나쁘다
★ 실무 성능 문제의 대부분은 ② 와 ④ 에서 끝난다.
알고리즘 지식은 ③ 을 '알아보기 위해' 필요하다.
분할상환 분석 — 가끔 비싼데 평균은 싼 이유
파이썬 list.append() 는 가끔 배열 전체를 복사합니다. 그런데도
“append 는 O(1)”이라고 말합니다. 거짓말이 아니라
비용을 나눠 계산하는 방법이 따로 있기 때문입니다.
학부 알고리즘에서 가장 실용적인 도구인데 가장 자주 건너뛰는 주제입니다.
세 가지 계산법 — 같은 답을 다른 길로
① 집계법(aggregate) — 총합을 세어 나눈다
= n + (2⌊log₂ n⌋+1 − 1) < n + 2n = 3n
∴ 분할상환 비용 = T(n)/n < 3 = O(1)
가장 직관적입니다. 연산 종류가 하나뿐일 때 잘 통합니다. 위 식에서 결정적인 것은 등비급수의 합이 마지막 항의 2배를 넘지 않는다는 사실입니다 — 1+2+4+⋯+2ᵏ = 2ᵏ⁺¹ − 1.
② 회계법(accounting) — 미리 요금을 걷어 둔다
append 에 3원을 청구합니다
· 1원 — 지금 원소를 넣는 실제 비용· 1원 — 나중에 자기가 복사될 때를 위해 적립
· 1원 — 앞 절반의 원소 하나가 복사될 때를 위해 적립
→ 복사가 일어나는 순간, 필요한 비용이 이미 통장에 있습니다
→ 잔고가 음수가 되지 않으므로 실제 비용 ≤ 3n
③ 포텐셜법(potential) — 상태에 에너지를 매긴다
총합 Σĉi = Σci + Φ(Dn) − Φ(D0)
Φ(Dn) ≥ Φ(D0) 이면 Σĉ 가 실제 총비용의 상한
동적 배열에 적용 — Φ = 2·(원소 수) − (용량)
[복사가 없는 평범한 append]
실제 비용 c = 1
원소 수 +1 → Φ 가 2 증가
ĉ = 1 + 2 = 3
[용량이 꽉 차 복사가 일어나는 append]
직전 상태: 원소 n, 용량 n → Φ = 2n − n = n
실제 비용 c = n + 1 (n개 복사 + 1개 삽입)
이후 상태: 원소 n+1, 용량 2n → Φ = 2(n+1) − 2n = 2
ĉ = (n+1) + 2 − n = 3
★ 어느 쪽이든 ĉ = 3 (상수) → 분할상환 O(1)
"비싼 순간에 Φ 가 확 줄어들며 비용을 대신 내 준다"
학부 시험에 그대로 나오는 예 넷
| 자료구조 · 연산 | 최악(1회) | 분할상환 | 퍼텐셜 / 근거 |
|---|---|---|---|
| 동적 배열 append | Θ(n) | Θ(1) | Φ = 2·크기 − 용량 · 두 배 확장이 핵심 |
| 이진 카운터 +1 | Θ(log n) | Θ(1) | Φ = 1 인 비트 수 · n 번 증가에 총 < 2n 번 뒤집힘 |
| 두 스택으로 만든 큐 | Θ(n) | Θ(1) | 원소마다 “이사”가 딱 한 번만 일어남 |
| 스플레이 트리 접근 | Θ(n) | Θ(log n) | Φ = Σ log(부분트리 크기) · 접근 정리 |
| 유니온 파인드 | Θ(log n) | Θ(α(n)) | 경로 압축 + 랭크 · α 는 사실상 4 이하 |
직접 확인해 보기 — 이진 카운터
# n 번 증가시킬 때 비트가 총 몇 번 뒤집히는가?
def flips(n: int) -> int:
bits = [0] * 32
total = 0
for _ in range(n):
i = 0
while i < 32 and bits[i] == 1: # 1 을 만나면 0 으로 (자리올림)
bits[i] = 0; total += 1; i += 1
if i < 32:
bits[i] = 1; total += 1 # 0 을 1 로
return total
for n in (10, 100, 1000, 10000):
print(f"n={n:6d} 뒤집힘={flips(n):7d} 평균={flips(n)/n:.3f}")
# n= 10 뒤집힘= 18 평균=1.800
# n= 100 뒤집힘= 197 평균=1.970
# n= 1000 뒤집힘= 1994 평균=1.994
# n= 10000 뒤집힘= 19995 평균=2.000 ← 2 에 수렴
∴ 증가 연산 하나당 평균 < 2 번 = Θ(1)
최악의 한 번은 0111…1 → 1000…0 처럼 32비트 전부가 뒤집힙니다. 그런데도 평균이 2 인 이유는, 그렇게 비싼 증가가 232 번에 한 번뿐이기 때문입니다.
실무에서 이 분석이 필요한 순간
# ① 리스트 크기를 알면 미리 잡아 두는 것이 낫습니다
# (파이썬 list 에는 reserve 가 없지만, 개념은 같습니다)
rows = [None] * n # 재할당 자체를 없앰
for i in range(n):
rows[i] = build(i)
# ② 반복 문자열 결합은 분할상환이 아닙니다 — 매번 새 객체
s = ""
for x in items: s += x # ✗ O(n²) — 문자열은 불변이라 복사가 매번
s = "".join(items) # ✓ O(n)
# ③ dict/set 도 같은 원리로 커집니다 (부하율 초과 시 재해시)
# 수백만 건을 넣을 것이 확실하면 처음부터 크게 잡는 편이 유리합니다
// 자바 — 크기를 알면 반드시 지정하세요
List<Order> list = new ArrayList<>(expectedSize); // 재할당 0회
Map<String,Order> map = new HashMap<>(expected * 4 / 3 + 1);
// StringBuilder 도 같습니다
StringBuilder sb = new StringBuilder(estimatedLength);
이런 곳에서는 ① 미리 크게 할당해 재할당 자체를 없애거나, ② 점진적 재구성(한 번에 다 옮기지 않고 몇 개씩 나눠 이사)을 씁니다 — 실시간 GC 가 쓰는 것과 같은 발상입니다.
네트워크 플로우 — 최대 유량 = 최소 컷
“파이프망에 물을 최대 얼마나 흘릴 수 있나”를 푸는 알고리즘입니다. 그런데 이 문제로 작업 배정 · 매칭 · 이미지 분할 · 프로젝트 선택이 전부 풀립니다. 학부 알고리즘의 마지막 산이자, 문제를 다른 문제로 바꿔 푸는 사고를 배우는 자리입니다.
포드–풀커슨 — 증가 경로를 계속 찾는다
잔여 그래프에 s→t 경로가 없을 때 최대 유량입니다.
from collections import deque
def max_flow(cap: dict, s, t) -> int:
"""cap[u][v] = 용량. 에드몬즈–카프 (BFS 로 증가 경로 찾기)"""
flow = 0
while True:
# ① BFS 로 s→t 잔여 경로 찾기 (가장 짧은 경로를 고르는 것이 핵심)
parent = {s: None}
q = deque([s])
while q and t not in parent:
u = q.popleft()
for v, c in cap[u].items():
if c > 0 and v not in parent:
parent[v] = u
q.append(v)
if t not in parent: # 더 보낼 길이 없으면 종료
return flow
# ② 경로에서 가장 좁은 곳(병목)을 찾는다
bottleneck, v = float("inf"), t
while parent[v] is not None:
u = parent[v]
bottleneck = min(bottleneck, cap[u][v])
v = u
# ③ 그만큼 흘리고, 역방향에 되돌릴 용량을 만든다
v = t
while parent[v] is not None:
u = parent[v]
cap[u][v] -= bottleneck
cap[v][u] = cap[v].get(u, 0) + bottleneck # ★ 역간선
v = u
flow += bottleneck
| 경로 선택 방법 | 복잡도 |
|---|---|
| DFS (포드–풀커슨 원형) | O(E·f*) — 용량에 비례(무리수면 종료 보장 없음) |
| BFS (에드몬즈–카프) | O(V E²) — 용량과 무관 |
| 디닉(Dinic) | O(V²E) · 단위 용량이면 O(E√V) |
이 문제로 바꿔 푸는 것들 (환원)
| 원래 문제 | 바꾸는 법 |
|---|---|
| 이분 매칭 지원자 ↔ 자리 | s→지원자(용량1), 지원자→가능한자리(1), 자리→t(1) → 최대 유량 = 최대 매칭 수 |
| 정점 용량 제한 | 정점 v 를 vin→vout 로 쪼개고 그 사이 간선에 용량 |
| 여러 출발·도착 | 가상의 S·T 를 만들어 ∞ 용량으로 연결 |
| 프로젝트 선택 이익-비용 최대화 | 최대 가중 폐쇄 → 최소 컷으로 환원 |
| 이미지 분할 전경/배경 나누기 | 픽셀=정점, 인접 유사도=용량 → 최소 컷 |
| 최소 정점 덮개(이분 그래프) | 쾨니그 정리 — 최대 매칭 = 최소 정점 덮개 |
# 이분 매칭 예 — 지원자 3명, 자리 3개
# 지원자 A: 자리 1,2 가능 / B: 자리 1 / C: 자리 2,3
cap = {
"s": {"A":1, "B":1, "C":1},
"A": {"1":1, "2":1}, "B": {"1":1}, "C": {"2":1, "3":1},
"1": {"t":1}, "2": {"t":1}, "3": {"t":1},
"t": {},
}
print(max_flow(cap, "s", "t")) # 3 — 전원 배정 가능
# ★ 헝가리안 알고리즘을 몰라도 최대 매칭을 풀 수 있습니다.
# "이 문제는 사실 유량 문제다" 를 알아보는 것이 실력입니다.
최소 컷을 실제로 구하기 · 정리 증명 스케치
최대 유량에서 최소 컷 뽑아내기
# 최대 유량을 구한 뒤, 잔여 그래프에서 s 로부터 도달 가능한 정점 집합이 S
def min_cut(cap, s, t):
orig = {u: dict(vs) for u, vs in cap.items()} # ★ 원래 용량을 먼저 보관
max_flow(cap, s, t) # cap 이 잔여 용량으로 바뀜
S, q = {s}, deque([s]) # 잔여 그래프에서 도달 가능한 곳
while q:
u = q.popleft()
for v, c in cap[u].items():
if c > 0 and v not in S:
S.add(v); q.append(v)
# S 에서 나가 T 로 들어가는 원래 간선들이 최소 컷
return [(u, v) for u in S for v in orig[u] if v not in S]
최대유량–최소컷 정리 (증명 뼈대)
② 잔여 그래프에 s→t 증가 경로가 없다
③ 어떤 컷 (S,T) 에 대해 |f| = c(S,T) 이다
① → ② 경로가 있으면 더 흘릴 수 있으니 최대가 아니다 (대우)
② → ③ 잔여 그래프에서 s 로부터 도달 가능한 집합을 S 라 하자
· t ∉ S (경로가 없으므로)
· S→T 간선은 전부 포화 (안 그러면 더 갈 수 있었다)
· T→S 간선은 전부 유량 0 (안 그러면 역간선으로 갈 수 있었다)
⇒ |f| = (나간 양) − (들어온 양) = c(S,T) − 0 = c(S,T)
③ → ① 임의의 유량 ≤ 임의의 컷 용량 이므로 (약한 쌍대성)
같아지는 순간 양쪽 다 최적이다 ∎
★ 이 구조가 선형계획법의 '쌍대성(duality)'과 정확히 같습니다.
최대화 문제와 최소화 문제가 짝을 이루고, 최적값이 일치합니다.
강한 연결 요소와 2-SAT — 방향 그래프를 DAG 로 접기
방향 그래프에서 서로 오갈 수 있는 정점들의 덩어리가 강한 연결 요소(SCC)입니다. 각 덩어리를 점 하나로 접으면 남는 것은 반드시 사이클 없는 그래프(DAG)가 되고, 그때부터 위상 정렬·DP 가 전부 가능해집니다. 2-SAT 은 이 성질을 그대로 쓰는 대표 응용으로, “이런 조건들을 동시에 만족시킬 수 있는가”를 선형 시간에 판정합니다.
타잔 SCC — 재귀 없이 (파이썬 안전 버전)
def scc_tarjan(n, adj):
"""0..n-1 정점. adj[v] = v 에서 나가는 정점 목록.
반환: comp[v] = v 가 속한 SCC 번호 (위상 역순으로 매겨짐)"""
disc = [-1] * n # 방문 번호 (아직 안 봤으면 -1)
low = [0] * n # 되돌아갈 수 있는 가장 이른 번호
comp = [-1] * n
on_stk = [False] * n
stk, timer, nc = [], 0, 0
for root in range(n):
if disc[root] != -1:
continue
# 재귀 대신 명시적 스택 — 파이썬 재귀 한도(기본 1000)를 피합니다
work = [(root, 0)]
while work:
v, i = work[-1]
if i == 0: # v 에 처음 들어옴
disc[v] = low[v] = timer; timer += 1
stk.append(v); on_stk[v] = True
if i < len(adj[v]):
work[-1] = (v, i + 1)
u = adj[v][i]
if disc[u] == -1:
work.append((u, 0)) # 더 깊이
elif on_stk[u]:
low[v] = min(low[v], disc[u]) # 뒤로 가는 간선
else: # v 의 자식을 다 봄
work.pop()
if work:
p = work[-1][0]
low[p] = min(low[p], low[v])
if low[v] == disc[v]: # ★ v 가 이 SCC 의 뿌리
while True:
w = stk.pop(); on_stk[w] = False
comp[w] = nc
if w == v: break
nc += 1
return comp, nc
sys.setrecursionlimit(10**6) 로 올려도
C 스택이 먼저 넘칩니다(세그멘테이션 오류).
위처럼 명시적 스택으로 쓰거나 threading 으로
스택 크기를 키우세요.
코사라주 — 외우기 쉬운 대안
def scc_kosaraju(n, adj, radj):
"""DFS 두 번. 코드가 짧아 시험장에서 실수가 적습니다."""
seen = [False] * n
order = []
for s in range(n): # ① 끝나는 순서를 기록
if seen[s]: continue
stk = [(s, 0)]; seen[s] = True
while stk:
v, i = stk[-1]
if i < len(adj[v]):
stk[-1] = (v, i + 1)
u = adj[v][i]
if not seen[u]:
seen[u] = True; stk.append((u, 0))
else:
stk.pop(); order.append(v)
comp = [-1] * n; nc = 0
for v in reversed(order): # ② 역방향 그래프를 그 역순으로
if comp[v] != -1: continue
stk = [v]; comp[v] = nc
while stk:
x = stk.pop()
for u in radj[x]:
if comp[u] == -1:
comp[u] = nc; stk.append(u)
nc += 1
return comp, nc
| 타잔 | 코사라주 | |
|---|---|---|
| DFS 횟수 | 1번 | 2번 |
| 역방향 그래프 | 불필요 | 필요(메모리 2배) |
| 번호 순서 | 위상 역순 | 위상 순서 |
| 외우기 | low 값이 헷갈림 | 쉬움 |
nc - 1 - comp[v] 를 쓰거나
DAG 를 만든 뒤 위상 정렬을 다시 하면 됩니다.
2-SAT — “동시에 만족시킬 수 있는가”를 선형 시간에
2-SAT 판정과 해 복원
# 변수 i (0-based) → 정점 2i (거짓 노드), 2i+1 (참 노드)
def node(x):
"""x 가 양수면 x 번 변수가 참, 음수면 거짓. 1-based 입력 기준."""
return 2 * (abs(x) - 1) + (1 if x > 0 else 0)
def two_sat(n, clauses):
"""clauses = [(a, b), ...] 뜻: (a ∨ b). 예: (1, -2) 는 (x1 ∨ ¬x2)"""
m = 2 * n
adj = [[] for _ in range(m)]
radj = [[] for _ in range(m)]
def add(u, v):
adj[u].append(v); radj[v].append(u)
for a, b in clauses:
# ¬a → b 그리고 ¬b → a
add(node(-a), node(b))
add(node(-b), node(a))
comp, _ = scc_kosaraju(m, adj, radj) # 위상 순서로 번호가 매겨짐
ans = [False] * n
for i in range(n):
f, t = comp[2 * i], comp[2 * i + 1]
if f == t:
return None # ★ 같은 SCC → 모순
ans[i] = t > f # 위상 뒤쪽을 참으로
return ans
# (x1 ∨ x2) ∧ (¬x1 ∨ x2) ∧ (¬x2 ∨ x3)
print(two_sat(3, [(1, 2), (-1, 2), (-2, 3)]))
# 예: [False, True, True] — 해가 여럿이면 어느 것이 나올지는 구현에 달렸습니다
# (x1) ∧ (¬x1) — 같은 절을 두 번 써서 단항 조건을 표현합니다
print(two_sat(1, [(1, 1), (-1, -1)])) # None (모순)
이 절로 풀리는 문제 유형
| 문제 형태 | 바꾸는 법 |
|---|---|
| 순환 의존성 축약 | SCC 로 접고 DAG 에서 위상 DP |
| “모두에게 도달” | 진입 차수 0 인 SCC 의 개수 |
| 강제 연결 최소 간선 | max(진입 0 개수, 진출 0 개수) |
| 둘 중 하나 선택 | 2-SAT 절 (a ∨ b) |
| 동시에 못 고름 | (¬a ∨ ¬b) |
| a 면 반드시 b | (¬a ∨ b) |
| 정확히 하나 | (a ∨ b) ∧ (¬a ∨ ¬b) |
트리 알고리즘 — 최소 공통 조상과 트리 DP
트리는 사이클이 없어서 “두 정점 사이의 길이 정확히 하나”입니다. 그 성질 덕분에 두 가지가 쉬워집니다 — 최소 공통 조상(LCA)으로 임의의 두 정점 사이 질의를 O(log n) 에 답하는 것, 그리고 트리 DP 로 “자식의 답을 모아 부모의 답을 만드는” 것. 플래티넘 구간 트리 문제의 대부분이 이 둘의 조합입니다.
LCA — 희소 배열 (전처리 + 질의)
from collections import deque
def build_lca(n, adj, root=0):
"""adj = 무방향 인접 리스트. 반환: (up, dep)"""
LOG = max(1, (n).bit_length())
up = [[-1] * n for _ in range(LOG)]
dep = [0] * n
# ① BFS 로 부모와 깊이 — 재귀를 피합니다
seen = [False] * n
seen[root] = True
q = deque([root])
while q:
v = q.popleft()
for u in adj[v]:
if not seen[u]:
seen[u] = True
up[0][u] = v
dep[u] = dep[v] + 1
q.append(u)
# ② 두 번 뛰면 두 배 — 표를 한 줄로 채웁니다
for k in range(1, LOG):
for v in range(n):
mid = up[k - 1][v]
up[k][v] = -1 if mid == -1 else up[k - 1][mid]
return up, dep
def lca(u, v, up, dep):
if dep[u] < dep[v]:
u, v = v, u
d = dep[u] - dep[v]
k = 0
while d: # ① 깊이 맞추기 — d 를 이진수로 쪼개 뜀
if d & 1:
u = up[k][u]
d >>= 1; k += 1
if u == v:
return u # 한쪽이 다른 쪽의 조상이었던 경우
for k in range(len(up) - 1, -1, -1):
if up[k][u] != up[k][v]: # ② 달라지는 동안만 뜀 (같으면 지나침)
u, v = up[k][u], up[k][v]
return up[0][u] # 갈라지기 직전의 부모가 답
def dist(u, v, up, dep):
return dep[u] + dep[v] - 2 * dep[lca(u, v, up, dep)]
!= 인 것이 핵심입니다.
조상이 같아지면 이미 지나친 것이라 뛰지 않고,
다를 때만 뜁니다. 그래서 루프가 끝나면
u 와 v 는 항상 답의 바로 아래에 있습니다.
여기서 == 로 쓰는 실수가 가장 흔합니다.
이 도구로 뭘 하나
| 질의 | 계산 |
|---|---|
| 두 정점 거리 | dep[u]+dep[v]−2·dep[LCA] |
| 경로 위에 있나 | dist(u,x)+dist(x,v) == dist(u,v) |
| k번째 조상 | k 를 이진수로 쪼개 점프 |
| 경로 최댓값 | up 과 함께 max 도 같이 저장 |
| 경로에 +1 하기 | 차분 배열 후 트리에서 누적 |
| 서브트리 질의 | 오일러 투어로 구간으로 변환 |
tin[v], 나온 시각 tout[v] 를 기록하면
v 의 서브트리 = 배열의 [tin[v], tout[v]] 구간이 됩니다.
그 순간부터 세그먼트 트리·펜윅(📚 자료구조 14)이 그대로 쓰입니다 —
트리 문제가 구간 문제로 바뀝니다.
up 은 n × log n 입니다.
n = 10만이면 17 × 100,000 = 170만 칸 —
파이썬 리스트로는 100MB 를 넘길 수 있습니다.
array('i') 나 1차원 평탄화로 줄이세요.
트리 DP — 자식의 답을 모아 부모의 답을 만들기
트리 DP — 재귀 없이 (후위 순회)
def max_independent_set(n, adj, root=0):
"""인접한 두 정점을 동시에 고를 수 없을 때 최대 개수"""
# ① 반복문으로 부모와 방문 순서를 구합니다
par = [-1] * n
order = [root]
seen = [False] * n; seen[root] = True
for v in order: # 리스트를 돌면서 뒤에 붙입니다
for u in adj[v]:
if not seen[u]:
seen[u] = True; par[u] = v
order.append(u)
dp = [[0, 1] for _ in range(n)] # [안 고름, 고름]
# ② order 를 거꾸로 = 자식이 항상 부모보다 먼저 (후위 순회와 같은 효과)
for v in reversed(order):
p = par[v]
if p == -1:
continue
dp[p][0] += max(dp[v][0], dp[v][1])
dp[p][1] += dp[v][0]
return max(dp[root])
# 위 그림의 트리 (0-based 로 1→0, 2→1, ...)
adj = {0:[1,2], 1:[0,3,4], 2:[0,5], 3:[1], 4:[1], 5:[2]}
print(max_independent_set(6, adj)) # 4
order 를 뒤집으면 후위 순회가 됩니다.
BFS/DFS 로 만든 방문 순서에서 자식은 항상 부모 뒤에 있으니,
거꾸로 돌면 자식이 먼저 처리됩니다.
재귀 없이 트리 DP 를 짜는 가장 짧은 방법이고,
파이썬에서 스택 오버플로를 원천 차단합니다.
플래티넘 구간에서 나오는 형태
| 문제 | 상태 정의 |
|---|---|
| 최대 독립 집합 | 골랐나 / 안 골랐나 |
| 최소 정점 덮개 | 같은 상태, 최소화로 |
| 트리의 지름 | 각 정점에서 가장 깊은 두 갈래의 합 |
| 서브트리 크기 | 1 + Σ 자식 크기 (가장 기본) |
| 트리 배낭 | dp[v][고른 개수] — 자식과 합치기 |
| 모든 루트에 대해 | 재루팅 DP (아래 → 위 두 번) |
DP 심화 — 비트마스크 · LIS · 최적화
09절이 “상태를 정하고 표를 채운다”였다면, 여기서는 그 표가 너무 커질 때의 이야기입니다. 상태가 집합이면 정수 하나에 담고(비트마스크), 전이가 단조로우면 이분 탐색이나 볼록성으로 줄입니다. 플래티넘 구간 DP 문제는 “상태를 어떻게 압축했는가”가 난이도의 전부입니다.
외판원 순회 — 비트마스크 DP
INF = float('inf')
def tsp(cost):
"""cost[i][j] = i → j 비용 (갈 수 없으면 0 또는 INF).
0번에서 출발해 전부 돌고 0번으로 돌아오는 최소 비용."""
n = len(cost)
FULL = (1 << n) - 1
dp = [[INF] * n for _ in range(1 << n)]
dp[1][0] = 0 # 0번만 방문한 채로 0번에 있음
# mask 를 오름차순으로 도는 것이 곧 "작은 부분집합 먼저"입니다
for mask in range(1 << n):
for last in range(n):
cur = dp[mask][last]
if cur == INF or not (mask >> last) & 1:
continue
for nxt in range(n):
if (mask >> nxt) & 1: # 이미 방문
continue
c = cost[last][nxt]
if c == 0: # 갈 수 없는 길
continue
nm = mask | (1 << nxt)
if cur + c < dp[nm][nxt]:
dp[nm][nxt] = cur + c
return min(dp[FULL][last] + cost[last][0]
for last in range(n)
if cost[last][0] > 0 and dp[FULL][last] < INF)
cost = [[0, 10, 15, 20],
[5, 0, 9, 10],
[6, 13, 0, 12],
[8, 8, 9, 0]]
print(tsp(cost)) # 35
# 부분집합을 순회하는 관용구 — 집합 DP 에서 자주 씁니다
mask = 0b1011
sub = mask
while sub:
# sub 는 mask 의 부분집합을 큰 것부터 훑습니다
sub = (sub - 1) & mask
(sub - 1) & mask 는 외워 둘 가치가 있습니다.
mask 의 모든 부분집합을 중복 없이 훑는 관용구이고,
전체 비용이 3n 입니다
(각 원소가 “밖 · 부분집합 · mask에만” 세 상태이기 때문).
집합 분할 DP 에서 그대로 쓰입니다.
메모리와 속도를 줄이는 실전 요령
| 문제 | 대응 |
|---|---|
| 2차원 리스트가 느림 | array('i') 1차원 평탄화 |
| 재귀 메모가 느림 | 바텀업 반복문으로 전환 |
| 파이썬이 시간 초과 | PyPy 제출 · 내부 루프 최소화 |
| 같은 값 반복 계산 | 비트 연산을 루프 밖으로 빼기 |
| 상태가 2ⁿ 을 넘음 | 대칭성으로 절반 제거 · 분기 한정 |
| 비트 개수 세기 | bin(x).count('1') · x.bit_count() |
& 는 == 보다 낮습니다 —
mask & 1 << i == 0 은 의도대로 동작하지 않습니다.
항상 괄호를 치세요: (mask & (1 << i)) == 0.
플래티넘 문제에서 이 한 줄로 두 시간을 잃는 일이 흔합니다.
LIS 를 O(n log n) 으로 — “덮어쓰기”의 정체
LIS — 길이와 실제 수열까지
from bisect import bisect_left
def lis_length(a):
"""가장 긴 증가 부분 수열의 길이만 — O(n log n)"""
tails = []
for x in a:
i = bisect_left(tails, x) # x 이상인 첫 자리
if i == len(tails):
tails.append(x) # 뒤에 붙이기 = 길이 +1
else:
tails[i] = x # 덮어쓰기 = 끝 값 낮추기
return len(tails)
def lis_sequence(a):
"""실제 수열까지 복원 — 각 원소가 들어간 '자리'를 기록해 둡니다"""
tails, pos, prev = [], [], [-1] * len(a)
idx = [] # tails 각 자리의 원본 인덱스
for j, x in enumerate(a):
i = bisect_left(tails, x)
if i == len(tails):
tails.append(x); idx.append(j)
else:
tails[i] = x; idx[i] = j
prev[j] = idx[i - 1] if i > 0 else -1
pos.append(i)
# 마지막 자리에 들어간 원소부터 거꾸로 따라갑니다
out, k = [], idx[-1]
while k != -1:
out.append(a[k]); k = prev[k]
return out[::-1]
a = [10, 9, 2, 5, 3, 7, 101, 18]
print(lis_length(a)) # 4
print(lis_sequence(a)) # [2, 3, 7, 18]
# 감소 수열이면 부호를 뒤집고, 같은 값 허용이면 bisect_right 를 씁니다
print(lis_length([-x for x in a])) # 최장 감소 부분 수열 길이
그 위의 최적화 — 이름만 알아 두기
| 기법 | 줄여 주는 것 |
|---|---|
| 슬라이딩 윈도우 최댓값 단조 덱 | O(nk) → O(n) |
| 분할 정복 최적화 opt 가 단조 | O(kn2) → O(kn log n) |
| 컨벡스 헐 트릭 전이가 직선 | O(n2) → O(n log n) |
| 크누스 최적화 사각 부등식 | O(n3) → O(n2) |
| 세그먼트 트리 DP | 전이의 최댓값 질의를 log 로 |
계산 기하 — 부호 하나로 푸는 문제들
기하 문제가 어려워 보이는 이유는 삼각함수와 실수 오차 때문인데, 실제 코딩 테스트에서 필요한 것은 대부분 외적의 부호 하나입니다. 세 점이 왼쪽으로 도는지 오른쪽으로 도는지만 알면 선분 교차 · 볼록 껍질 · 다각형 넓이 · 점의 내부 판정이 전부 따라 나옵니다. 게다가 정수 좌표라면 오차가 아예 없습니다.
float 로 바꾸는 순간
📐 선형대수 12 의 문제가 그대로 따라옵니다.
CCW 와 선분 교차 판정
def ccw(a, b, c):
"""양수면 반시계 · 음수면 시계 · 0 이면 일직선 (정수 그대로)"""
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
def sign(x):
return (x > 0) - (x < 0)
def on_segment(a, b, p):
"""p 가 일직선 위에 있다는 전제에서, 선분 a-b 안에 있나"""
return (min(a[0], b[0]) <= p[0] <= max(a[0], b[0]) and
min(a[1], b[1]) <= p[1] <= max(a[1], b[1]))
def segments_intersect(a, b, c, d):
"""선분 a-b 와 c-d 가 만나나 (끝점이 닿는 것도 포함)"""
d1 = sign(ccw(a, b, c))
d2 = sign(ccw(a, b, d))
d3 = sign(ccw(c, d, a))
d4 = sign(ccw(c, d, b))
# ① 서로 반대편에 있으면 확실히 교차
if d1 * d2 < 0 and d3 * d4 < 0:
return True
# ② 한 점이 다른 선분 위에 얹힌 경우 (일직선 포함)
if d1 == 0 and on_segment(a, b, c): return True
if d2 == 0 and on_segment(a, b, d): return True
if d3 == 0 and on_segment(c, d, a): return True
if d4 == 0 and on_segment(c, d, b): return True
return False
print(segments_intersect((0,0), (4,4), (0,4), (4,0))) # True (X 자)
print(segments_intersect((0,0), (2,2), (3,3), (5,5))) # False (같은 직선, 떨어짐)
print(segments_intersect((0,0), (3,3), (2,2), (5,5))) # True (겹침)
def polygon_area2(pts):
"""다각형 넓이의 2배 — 정수로 정확하게 (신발끈 공식)"""
n = len(pts)
return abs(sum(pts[i][0] * pts[(i+1) % n][1] -
pts[(i+1) % n][0] * pts[i][1] for i in range(n)))
볼록 껍질 — 모노톤 체인
def convex_hull(points):
"""반시계 방향 볼록 껍질. 일직선 위의 점은 제외합니다."""
pts = sorted(set(points)) # x, 같으면 y 순
if len(pts) <= 2:
return pts
def build(seq):
out = []
for p in seq:
# 우회전(또는 일직선)이면 직전 점을 버립니다
while len(out) >= 2 and ccw(out[-2], out[-1], p) <= 0:
out.pop()
out.append(p)
return out
lower = build(pts) # 아래 껍질 (왼 → 오)
upper = build(reversed(pts)) # 위 껍질 (오 → 왼)
return lower[:-1] + upper[:-1] # 끝점이 겹치니 하나씩 뺍니다
pts = [(0,0), (4,0), (4,4), (0,4), (2,2), (1,3), (3,1)]
print(convex_hull(pts)) # [(0,0), (4,0), (4,4), (0,4)]
# 껍질 둘레 · 가장 먼 두 점(회전하는 캘리퍼스)의 출발점이 됩니다
import math
h = convex_hull(pts)
peri = sum(math.dist(h[i], h[(i+1) % len(h)]) for i in range(len(h)))
print(round(peri, 4)) # 16.0
| 쓰는 곳 | 왜 껍질인가 |
|---|---|
| 가장 먼 두 점 | 반드시 껍질 위에 있음 |
| 최소 감싸는 도형 | 껍질만 보면 충분 |
| 충돌 판정 | 볼록끼리는 분리축 정리 |
| CHT 최적화 | 직선들의 상단 껍질(18절) |
<= 0 이냐 < 0 이냐가 위 코드의 유일한 선택지입니다.
<= 0 이면 일직선 위의 점을 버리고,
< 0 이면 남깁니다.
“껍질 위의 점 개수를 세라”는 문제에서 이 한 글자가 정답을 가릅니다.
문자열 심화 — Z 배열 · 아호-코라식 · 접미사 배열
11절의 KMP 가 패턴 하나를 찾는 도구였다면, 여기서는 그 위의 세 가지입니다. Z 배열은 KMP 와 같은 정보를 더 직관적인 형태로 담고, 아호-코라식은 패턴 수천 개를 한 번의 스캔으로 찾으며, 접미사 배열은 “모든 부분 문자열”에 대한 질문을 정렬 한 번으로 바꿉니다. 셋 다 백준 플래티넘 구간에서 실제로 요구되는 도구입니다.
Z 알고리즘과 패턴 찾기
def z_array(s):
"""z[i] = s[i:] 와 s 의 공통 접두사 길이. z[0] 은 쓰지 않습니다."""
n = len(s)
z = [0] * n
l = r = 0 # 현재 알고 있는 '일치 구간' [l, r)
for i in range(1, n):
if i < r:
z[i] = min(r - i, z[i - l]) # ★ 이미 아는 것을 재활용
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
z[i] += 1 # 그 다음부터만 직접 비교
if i + z[i] > r:
l, r = i, i + z[i] # 더 멀리 아는 구간으로 갱신
return z
print(z_array("aabcaabxaaz")) # [0, 1, 0, 0, 3, 1, 0, 0, 2, 1, 0]
def find_all(text, pat):
"""pat 의 모든 등장 위치 — O(n + m)"""
joined = pat + "\x01" + text # 구분자는 양쪽에 없는 글자로
z = z_array(joined)
m = len(pat)
return [i - m - 1 for i in range(m + 1, len(joined))
if z[i] == m]
print(find_all("ababcababa", "aba")) # [0, 5, 7]
def longest_border(s):
"""접두사이자 접미사인 최대 길이 — KMP 실패 함수의 마지막 값과 같습니다"""
z = z_array(s)
n = len(s)
return max((z[i] for i in range(1, n) if i + z[i] == n), default=0)
print(longest_border("abcabcabc")) # 6
접미사 배열 — “모든 부분 문자열”을 정렬로
def suffix_array(s):
"""O(n log²n) — 시험장에서 짜기 좋은 배증(doubling) 방식"""
n = len(s)
sa = list(range(n))
rank = [ord(c) for c in s]
k = 1
while k < n:
key = lambda i: (rank[i], rank[i + k] if i + k < n else -1)
sa.sort(key=key)
tmp = [0] * n
for j in range(1, n):
tmp[sa[j]] = tmp[sa[j-1]] + (key(sa[j]) > key(sa[j-1]))
rank = tmp
k *= 2
return sa
def lcp_array(s, sa):
"""이웃한 접미사끼리 앞에서 몇 글자가 같은가 (카사이 알고리즘, O(n))"""
n = len(s)
rank = [0] * n
for i, p in enumerate(sa):
rank[p] = i
lcp = [0] * n
h = 0
for i in range(n):
if rank[i]:
j = sa[rank[i] - 1]
while i + h < n and j + h < n and s[i+h] == s[j+h]:
h += 1
lcp[rank[i]] = h
if h: h -= 1
return lcp
s = "banana"
sa = suffix_array(s)
print([s[i:] for i in sa])
# ['a', 'ana', 'anana', 'banana', 'na', 'nana']
# 서로 다른 부분 문자열의 개수 = 전체 접미사 길이 합 − LCP 합
lcp = lcp_array(s, sa)
print(sum(len(s) - i for i in sa) - sum(lcp)) # 15
아호-코라식 — 패턴 수천 개를 한 번의 스캔으로
아호-코라식 — 구축과 검색
from collections import deque
class Aho:
def __init__(self, patterns):
self.nxt = [{}] # 트라이 간선
self.fail = [0] # 실패 링크
self.out = [[]] # 이 노드에서 끝나는 패턴들
for p in patterns:
self._add(p)
self._build()
def _add(self, p):
cur = 0
for ch in p:
if ch not in self.nxt[cur]:
self.nxt.append({}); self.fail.append(0); self.out.append([])
self.nxt[cur][ch] = len(self.nxt) - 1
cur = self.nxt[cur][ch]
self.out[cur].append(p)
def _build(self):
"""BFS 로 실패 링크를 채웁니다 — 부모의 실패 링크에서 출발합니다"""
q = deque()
for ch, v in self.nxt[0].items():
self.fail[v] = 0
q.append(v)
while q:
u = q.popleft()
for ch, v in self.nxt[u].items():
f = self.fail[u]
while f and ch not in self.nxt[f]:
f = self.fail[f] # 더 짧은 접미사로 후퇴
self.fail[v] = self.nxt[f].get(ch, 0)
# ★ 출력 링크 — 실패 링크 쪽에서 끝나는 패턴도 물려받습니다
self.out[v] += self.out[self.fail[v]]
q.append(v)
def search(self, text):
"""(끝 위치, 패턴) 목록"""
cur, found = 0, []
for i, ch in enumerate(text):
while cur and ch not in self.nxt[cur]:
cur = self.fail[cur]
cur = self.nxt[cur].get(ch, 0)
for p in self.out[cur]:
found.append((i, p))
return found
ac = Aho(["he", "she", "hers"])
print(ac.search("ushers"))
# [(3, 'she'), (3, 'he'), (5, 'hers')]
out[v] += out[fail[v]] 한 줄이 “출력 링크”입니다.
she 가 끝나는 자리에서 he 도 함께 끝났다는 사실을
놓치지 않게 해 줍니다. 이 줄을 빼면
겹치는 패턴을 조용히 잃습니다 — 가장 흔한 버그입니다.
언제 무엇을 쓰나
| 상황 | 도구 | 복잡도 |
|---|---|---|
| 패턴 1개 | KMP · Z | O(n+m) |
| 패턴 여러 개 | 아호-코라식 | O(n+Σm+k) |
| 텍스트 고정 · 질의 다수 | 접미사 배열 | 전처리 후 O(m log n) |
| 부분 문자열 개수 | SA + LCP | O(n log n) |
| 최장 반복 부분열 | LCP 의 최댓값 | O(n) |
| 회문 관련 | 매내커 | O(n) |
| 대략만 맞으면 됨 | 라빈-카프 해시 | 기대 O(n+m) |
in 연산자가 C 로 구현된 최적화 알고리즘을
쓰므로 손으로 짠 KMP 보다 빠른 경우가 많습니다.
직접 구현이 필요한 것은 모든 등장 위치 · 여러 패턴 · 부분 문자열 통계처럼
내장으로 안 되는 질문일 때입니다.
구현 · 시뮬레이션 — 어려운 알고리즘이 없는데 자꾸 틀리는 유형
삼성 SW 역량테스트를 비롯한 국내 코딩 테스트에서 가장 많이 나오는 유형입니다. 쓰는 기법은 반복문과 배열뿐인데, 지문을 그대로 코드로 옮기는 일 자체가 어렵습니다. 틀리는 지점은 늘 정해져 있습니다 — 경계 검사 · 동시 이동 · 좌표 표기 · 깊은 복사 네 가지입니다. 이 절은 그 네 가지를 막는 골격을 만듭니다.
격자 시뮬레이션 골격 — 이대로 외우세요
import sys
from collections import deque
input = sys.stdin.readline # ★ 입력이 많으면 필수
N, M = map(int, input().split())
board = [list(map(int, input().split())) for _ in range(N)]
# 시계 방향: 위 → 오른쪽 → 아래 → 왼쪽
DR = (-1, 0, 1, 0)
DC = (0, 1, 0, -1)
def inside(r, c):
return 0 <= r < N and 0 <= c < M # ★ 이동 직후 항상 이것부터
def neighbors(r, c):
for i in range(4):
nr, nc = r + DR[i], c + DC[i]
if inside(nr, nc):
yield nr, nc, i
# ── 한 턴을 함수 하나로 분리하면 디버깅이 쉬워집니다
def one_turn(board):
nxt = [row[:] for row in board] # ★ 얕은 복사 금지 (아래 주의)
for r in range(N):
for c in range(M):
for nr, nc, _ in neighbors(r, c):
... # nxt 에만 쓰고 board 는 읽기만
return nxt
for turn in range(1, T + 1):
board = one_turn(board)
# ── 회전은 인덱스 산술로
d = 0
d = (d + 1) % 4 # 오른쪽으로 90도
d = (d + 3) % 4 # 왼쪽으로 90도 ( -1 대신 +3 )
d = (d + 2) % 4 # 반대 방향
[[0]*M]*N 은 절대 쓰지 마세요.
같은 리스트 객체가 N번 참조될 뿐이라
한 칸을 바꾸면 모든 행이 함께 바뀝니다.
반드시 [[0]*M for _ in range(N)] 로 만드세요.
2차원 복사도 board[:] 가 아니라
[row[:] for row in board] 입니다.
이 유형에서 실제로 틀리는 네 가지
| 함정 | 증상과 대응 |
|---|---|
| 동시 이동 | 제자리 수정 → 앞 칸 결과가 뒤 칸에 영향 → 원본 스냅숏에서 읽고 새 판에 쓰기 |
| 좌표 표기 | (r, c) 와 (x, y) 혼용 → 축이 뒤바뀜 → 파일 전체에서 (r, c) 하나로 통일 |
| 경계 검사 누락 | 파이썬은 -1 이 뒤에서 접근이라 예외도 안 남→ 조용히 오답. inside() 를 습관화 |
| 턴 종료 조건 | “더 변화 없으면 종료”를 빠뜨림 → 이전 판과 비교하거나 최대 턴 제한 |
| 입력 속도 | input() 그대로 쓰면 10만 줄에서 시간 초과→ sys.stdin.readline |
board[-1][0] 은 예외가 아니라 마지막 행입니다.
C·자바라면 즉시 터질 버그가 파이썬에선
“왜 답이 조금 다르지”로만 나타납니다.
경계 검사를 함수로 빼 두는 이유가 이것입니다.
격자 회전 — 손으로 인덱스를 세지 않는 법
자주 나오는 시뮬레이션 패턴
# ── ① 달팽이 채우기 — 벽이나 이미 칠한 칸을 만나면 회전
def snail(n):
m = [[0] * n for _ in range(n)]
r = c = d = 0
for v in range(1, n * n + 1):
m[r][c] = v
nr, nc = r + DR[d], c + DC[d]
if not (0 <= nr < n and 0 <= nc < n) or m[nr][nc]:
d = (d + 1) % 4 # ★ 막히면 오른쪽으로 회전
nr, nc = r + DR[d], c + DC[d]
r, c = nr, nc
return m
# ── ② 주사위 굴리기 — 면 번호를 배열로 두고 자리만 바꿉니다
# dice = [위, 아래, 북, 남, 동, 서]
def roll(dice, d):
u, dn, n, s, e, w = dice
if d == 0: return [s, n, u, dn, e, w] # 북
if d == 1: return [w, e, n, s, u, dn] # 동
if d == 2: return [n, s, dn, u, e, w] # 남
return [e, w, n, s, dn, u] # 서
# ── ③ 여러 개가 동시에 움직일 때 — 반드시 새 판에 씁니다
def move_all(board, movers):
nxt = [[[] for _ in range(M)] for _ in range(N)]
for r, c, d, sz in movers:
nr, nc = r + DR[d], c + DC[d]
if not inside(nr, nc):
d = (d + 2) % 4 # 벽이면 반대로
nr, nc = r + DR[d], c + DC[d]
nxt[nr][nc].append((d, sz))
# 같은 칸에 여럿이 모였으면 규칙대로 합치기
for r in range(N):
for c in range(M):
if len(nxt[r][c]) > 1:
nxt[r][c] = [max(nxt[r][c], key=lambda t: t[1])]
return nxt
연습 문제와 접근 순서
| 문제 (백준) | 익히는 것 |
|---|---|
| 14503 로봇 청소기 G5 | 델타 + 회전 + 상태 전이 |
| 14891 톱니바퀴 G5 | 회전 전파 · 방문 표시 |
| 14499 주사위 굴리기 G4 | 면 배열 관리 |
| 15683 감시 G4 | 완전 탐색 + 시뮬레이션 |
| 17144 미세먼지 안녕! G4 | 동시 확산 — 스냅숏 필수 |
| 16236 아기 상어 G3 | BFS + 우선순위 규칙 |
| 17143 낚시왕 G1 | 동시 이동 + 충돌 처리 |
| 20056 마법사 상어와 파이어볼 G4 | 한 칸에 여럿 모이기 |
itertools.product 나 백트래킹(10절)으로 만들고,
각 배치마다 시뮬레이션을 돌려 최선을 고르는 형태입니다.
이때 매번 판을 새로 복사하는 것을 잊지 마세요.
투 포인터 · 슬라이딩 윈도우 · 누적합 — O(n2) 을 O(n) 으로
“모든 쌍”이나 “모든 구간”을 보면 O(n2) 입니다. 그런데 대부분의 문제에는 되돌아갈 필요가 없다는 성질이 숨어 있습니다. 그 성질을 찾으면 포인터가 한 방향으로만 움직여 O(n) 이 되고, 구간 합은 미리 더해 두면 질의가 O(1) 이 됩니다. 카카오·네이버·라인 코딩 테스트에서 가장 자주 쓰이는 도구입니다.
세 가지 형태 — 코드로
# ── ① 투 포인터 : 정렬된 배열에서 합이 target 인 쌍
def two_sum_sorted(a, target):
l, r = 0, len(a) - 1
while l < r:
s = a[l] + a[r]
if s == target: return (a[l], a[r])
if s < target: l += 1 # 키워야 하니 왼쪽을 당김
else: r -= 1 # 줄여야 하니 오른쪽을 당김
return None
# ── ② 슬라이딩 윈도우 : 합이 S 이상인 가장 짧은 구간
def min_window(a, S):
l = total = 0
best = len(a) + 1
for r, v in enumerate(a):
total += v # 오른쪽을 늘리고
while total >= S: # 조건을 만족하는 동안
best = min(best, r - l + 1)
total -= a[l]; l += 1 # 왼쪽을 줄여 최소를 찾음
return best if best <= len(a) else 0
# ── ③ 슬라이딩 윈도우 : 중복 없는 가장 긴 부분 문자열
def longest_unique(s):
last = {} # 글자 → 마지막 등장 위치
l = best = 0
for r, ch in enumerate(s):
if ch in last and last[ch] >= l:
l = last[ch] + 1 # ★ 왼쪽을 '건너뛰기' (한 칸씩 아님)
last[ch] = r
best = max(best, r - l + 1)
return best
print(two_sum_sorted([1,3,5,7,9,11], 16)) # (5, 11)
print(min_window([2,3,1,2,4,3], 7)) # 2 ([4,3])
print(longest_unique("abcabcbb")) # 3 ("abc")
last[ch] >= l 조건을 빼면 틀립니다.
이미 창 왼쪽 바깥으로 밀려난 글자를 만나면
l 을 거꾸로 되돌리게 되기 때문입니다.
포인터가 뒤로 가는 순간 O(n) 보장이 깨집니다.
어떤 형태인지 알아보는 법
| 지문의 표현 | 쓸 것 |
|---|---|
| “정렬된 배열에서 두 수” | 투 포인터 (양 끝에서) |
| “연속한 구간 중 …” | 슬라이딩 윈도우 |
| “길이 K 인 창에서 최대” | 고정 창 + 단조 덱 |
| “구간 합을 여러 번 묻는다” | 누적합 |
| “구간에 값을 여러 번 더한다” | 차분 배열(imos) |
| “값이 바뀌면서 구간 질의” | 세그먼트 트리(📚 자료구조 14) |
| 음수가 섞인 구간 합 | 슬라이딩 윈도우 불가 — 단조성이 깨짐 |
누적합 — 미리 더해 두면 질의가 상수 시간
누적합 1차원 · 2차원 · 차분 배열
from itertools import accumulate
# ── ① 1차원 : P[0]=0 을 두는 형태가 실수가 적습니다
arr = [3, 1, 4, 1, 5]
P = [0] + list(accumulate(arr)) # [0, 3, 4, 8, 9, 14]
def rng(l, r): # arr[l..r] 의 합 (양 끝 포함)
return P[r + 1] - P[l]
print(rng(1, 3)) # 6
# ── ② 2차원 : 포함배제
def build2d(g):
n, m = len(g), len(g[0])
P = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n):
for j in range(m):
P[i+1][j+1] = g[i][j] + P[i][j+1] + P[i+1][j] - P[i][j]
return P
def rect(P, r1, c1, r2, c2): # (r1,c1) ~ (r2,c2) 직사각형 합
return (P[r2+1][c2+1] - P[r1][c2+1]
- P[r2+1][c1] + P[r1][c1])
# ── ③ 차분 배열(imos) : "구간에 더하기"가 많고 조회는 마지막에 한 번
def range_add(n, ops):
"""ops = [(l, r, v), ...] → 각 칸의 최종 값"""
diff = [0] * (n + 1)
for l, r, v in ops:
diff[l] += v
diff[r + 1] -= v # ★ 끝난 자리에서 되돌립니다
return list(accumulate(diff))[:n]
print(range_add(5, [(0, 2, 10), (1, 4, 1)])) # [10, 11, 11, 1, 1]
고정 창의 최댓값 — 단조 덱
from collections import deque
def window_max(a, k):
"""길이 k 인 모든 창의 최댓값 — O(n)
덱에는 '값이 내림차순이 되도록' 인덱스만 담습니다."""
dq, out = deque(), []
for i, v in enumerate(a):
while dq and a[dq[-1]] <= v:
dq.pop() # 나보다 작은 뒤쪽은 영원히 쓸모없음
dq.append(i)
if dq[0] <= i - k:
dq.popleft() # 창 밖으로 나간 것 버리기
if i >= k - 1:
out.append(a[dq[0]]) # 맨 앞이 항상 최댓값
return out
print(window_max([1,3,-1,-3,5,3,6,7], 3))
# [3, 3, 5, 5, 6, 7]
while 문이 여러 번 돌아도 전체를 합치면 n 번입니다
→ 분할상환으로 O(n) (14절의 집계 분석)
창마다 최댓값을 다시 찾으면 O(nk) 이고,
힙을 쓰면 O(n log n) 입니다.
코딩 테스트 정수론 — 소수 · 모듈러 · 조합
🔢 이산수학 09·10 이 “왜 그런가”였다면, 여기는 “시험장에서 어떻게 짜는가”입니다. 소수를 한꺼번에 구하는 체, 나눗셈이 없는 세계에서의 모듈러 역원, 큰 수의 조합을 109+7 로 나눈 나머지로 구하는 법 — 확률·경우의 수 문제가 나오면 거의 항상 이 셋이 필요합니다.
체 · 소인수분해 · 최대공약수
def sieve(n):
"""n 이하 소수 목록 — O(n log log n)"""
ok = bytearray([1]) * (n + 1) # 메모리 절약
ok[0] = ok[1] = 0
for i in range(2, int(n ** 0.5) + 1):
if ok[i]:
ok[i*i::i] = bytearray(len(ok[i*i::i])) # ★ 슬라이스로 한 번에
return [i for i in range(2, n + 1) if ok[i]]
def smallest_factor(n):
"""spf[x] = x 의 가장 작은 소인수 — 소인수분해가 O(log x) 가 됩니다"""
spf = list(range(n + 1))
for i in range(2, int(n ** 0.5) + 1):
if spf[i] == i:
for j in range(i * i, n + 1, i):
if spf[j] == j:
spf[j] = i
return spf
def factorize(x, spf):
out = {}
while x > 1:
p = spf[x]
while x % p == 0:
out[p] = out.get(p, 0) + 1
x //= p
return out
from math import gcd
def lcm(a, b): return a // gcd(a, b) * b # ★ 먼저 나눠야 오버플로가 없음
def ext_gcd(a, b):
"""ax + by = gcd(a,b) 를 만족하는 (g, x, y)"""
if b == 0: return a, 1, 0
g, x, y = ext_gcd(b, a % b)
return g, y, x - (a // b) * y
print(sieve(30)) # [2,3,5,7,11,13,17,19,23,29]
spf = smallest_factor(1000)
print(factorize(360, spf)) # {2: 3, 3: 2, 5: 1}
print(ext_gcd(240, 46)) # (2, -9, 47)
ok[i*i::i] = bytearray(...) 가 파이썬에서 체를 빠르게 만드는 핵심입니다.
반복문으로 하나씩 지우면 106 에서도 느리지만,
슬라이스 대입은 C 레벨에서 한 번에 처리됩니다.
모듈러 역원과 nCr mod p
MOD = 10 ** 9 + 7
# ── 나눗셈이 없으므로 '역원을 곱하기' 로 바꿉니다
# p 가 소수면 페르마 소정리로 a⁻¹ ≡ a^(p−2) (mod p)
def inv(a, p=MOD):
return pow(a, p - 2, p) # ★ 파이썬 pow 는 3인자로 모듈러 거듭제곱
# ── nCr 을 많이 물을 때 : 팩토리얼을 미리 만들어 둡니다
class Comb:
def __init__(self, n, p=MOD):
self.p = p
self.f = [1] * (n + 1)
for i in range(1, n + 1):
self.f[i] = self.f[i-1] * i % p
self.inv_f = [1] * (n + 1)
self.inv_f[n] = pow(self.f[n], p - 2, p) # 마지막 것만 역원 계산
for i in range(n, 0, -1):
self.inv_f[i-1] = self.inv_f[i] * i % p # ★ 거꾸로 내려오며 재사용
def nCr(self, n, r):
if r < 0 or r > n: return 0
return self.f[n] * self.inv_f[r] % self.p * self.inv_f[n-r] % self.p
C = Comb(10 ** 6)
print(C.nCr(1000000, 500000)) # 즉시 계산
# ── 검산 : 작은 수는 파이썬 내장과 비교
from math import comb
print(C.nCr(20, 7) == comb(20, 7) % MOD) # True
//)을 쓰면 답이 틀립니다.
(a // b) % p 와 a * inv(b) % p 는
완전히 다른 값입니다.
나눗셈이 보이면 무조건 역원으로 바꾸세요.
그리고 역원은 p 가 소수이고 a 가 p 의 배수가 아닐 때만 존재합니다.
코드가 실제로
돌아가는 곳을
알고 있습니까
빅오가 같은 두 코드의 속도가 10배 다를 때, 답은 알고리즘이 아니라 하드웨어에 있습니다 — 캐시 · 분기 예측 · 메모리 배치 · 정수 표현. 이 탭은 CPU 안에서 실제로 무슨 일이 벌어지는지 보고, 그것이 우리가 쓰는 코드에 어떻게 나타나는지까지 이어갑니다. 진법 변환기도 안에 있습니다.
컴퓨터는 무엇으로 되어 있나 — 폰 노이만 구조
CPU 는 메모리에서 명령을 하나 꺼내 실행하기를 반복합니다. 그게 전부입니다. 다만 초당 수십억 번 하고, 그 반복을 빠르게 하려는 온갖 장치가 붙어 있습니다.
명령어 사이클
반복:
① Fetch PC 가 가리키는 주소에서 명령을 가져온다
② Decode 무슨 명령인지 해석한다
③ Execute ALU 가 계산하거나 메모리에 접근한다
④ Write 결과를 레지스터·메모리에 쓴다
⑤ PC 를 다음 명령으로 (또는 점프)
★ 이 단순한 반복을 빠르게 하려고 붙은 것들
· 파이프라인 (여러 명령을 겹쳐서) → 07
· 분기 예측 (점프를 미리 추측) → 07
· 캐시 (메모리를 기다리지 않게) → 08
· 비순차 실행 (순서를 바꿔서) → 07
· SIMD (한 명령으로 여러 데이터) → 10
숫자로 보는 규모
| 항목 | 크기 · 속도 |
|---|---|
| 레지스터 | 수십 개 · 0.3ns |
| L1 캐시 | 32~64KB · 1ns |
| L2 캐시 | 0.5~2MB · 4ns |
| L3 캐시 | 8~64MB · 15ns |
| 메모리 (RAM) | 수십 GB · 80ns |
| NVMe SSD | 수 TB · 50µs |
| 같은 데이터센터 왕복 | — · 500µs |
| 대륙 간 왕복 | — · 150ms |
이렇게도 쓸 수 있다 — RISC 와 CISC 🟡 중급
| CISC (x86) | RISC (ARM · RISC-V) | |
|---|---|---|
| 명령어 수 | 많고 복잡 (1000+) | 적고 단순 |
| 명령 길이 | 가변 (1~15바이트) | 고정 (4바이트) |
| 메모리 접근 | 거의 모든 명령에서 | load/store 만 |
| 디코딩 | 복잡 · 전력 소모 | 단순 · 저전력 |
| 대표 | Intel · AMD | Apple Silicon · 스마트폰 · 서버 ARM |
실무에선 이렇게 — 아키텍처 차이가 코드에 나타날 때 🔴 실무
| 상황 | 주의점 |
|---|---|
| ARM 맥에서만 나는 동시성 버그 | x86 은 강한 메모리 순서, ARM 은 약함 — 락 누락이 ARM 에서만 드러남 |
| 도커 이미지가 안 돌아감 | --platform linux/amd64 · 멀티 아키텍처 빌드 |
| 네이티브 모듈 빌드 실패 | 휠·바이너리가 아키텍처별로 필요 |
| 부동소수점 결과가 미세하게 다름 | FMA 명령 유무 · 컴파일러 최적화 차이 |
| 바이트 순서 (엔디언) | x86·ARM 은 리틀 엔디언 · 네트워크는 빅 엔디언 |
char 부호 | x86 은 signed, ARM 은 unsigned 기본 |
# 엔디언 — 네트워크·파일 포맷에서 반드시 만난다
import struct, sys
sys.byteorder # 'little' (x86 · ARM)
struct.pack(">I", 1) # b'\x00\x00\x00\x01' 빅 엔디언(네트워크)
struct.pack("<I", 1) # b'\x01\x00\x00\x00' 리틀 엔디언
struct.pack("!I", 1) # ! = 네트워크 바이트 순서 (빅)
# ★ 바이너리 프로토콜을 만들 때는 반드시 엔디언을 명시한다
# 기본값(=)을 쓰면 플랫폼마다 다른 바이트가 나간다
(1234).to_bytes(4, "big") # 파이썬 3 관용구
int.from_bytes(data, "big")
2진수 · 비트 연산 — 컴퓨터가 아는 유일한 숫자
모든 데이터는 결국 0 과 1 입니다. 비트 연산은 그 위에서 쓰는 가장 빠른 도구이고, 권한 플래그 · 마스크 · 압축이 전부 여기서 나옵니다.
진법 변환
bin(42) # '0b101010'
oct(42) # '0o52'
hex(42) # '0x2a'
int('101010', 2) # 42
int('2a', 16) # 42
f"{42:08b}" # '00101010' 8자리 2진수
f"{42:04x}" # '002a' 4자리 16진수
# 왜 16진수를 쓰나
# · 4비트 = 16진수 한 자리 → 한눈에 비트가 보인다
# · 0xFF = 11111111 (1바이트 전부)
# · 0xF0 = 11110000 (상위 니블)
# · 색상 #FF5733, 메모리 주소, 해시가 전부 16진수
비트 연산 여섯 가지
| 연산 | 기호 | 용도 |
|---|---|---|
| AND | & | 마스크 · 특정 비트 확인 |
| OR | | | 비트 켜기 · 플래그 합치기 |
| XOR | ^ | 토글 · 차이 · 체크섬 |
| NOT | ~ | 반전 (부호 주의) |
| 왼쪽 시프트 | << | ×2ⁿ · 비트 위치 만들기 |
| 오른쪽 시프트 | >> | ÷2ⁿ · 상위 비트 꺼내기 |
# 실무 관용구
x & 0xFF # 하위 1바이트만
(x >> 8) & 0xFF # 두 번째 바이트
x | (1 << n) # n번 비트 켜기
x & ~(1 << n) # n번 비트 끄기
x ^ (1 << n) # n번 비트 토글
(x >> n) & 1 # n번 비트 읽기
x & (x - 1) # 최하위 1비트 제거
x & -x # 최하위 1비트만 남김
x.bit_count() # 켜진 비트 수 (3.10+)
이렇게도 쓸 수 있다 — 색상 · 좌표 · 플래그 압축 🟡 중급
# ① RGBA 색상을 정수 하나로
def rgba(r, g, b, a=255):
return (r << 24) | (g << 16) | (b << 8) | a
def unpack(c):
return (c >> 24) & 0xFF, (c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF
hex(rgba(255, 87, 51)) # '0xff5733ff'
# ② 2D 좌표를 하나의 키로 (해시맵 키로 쓸 때)
def key(x, y): return (x << 20) | y # y 가 20비트 이하일 때
def unkey(k): return k >> 20, k & 0xFFFFF
# ③ 권한 플래그
READ, WRITE, EXEC, ADMIN = 1, 2, 4, 8
perm = READ | WRITE
perm & READ # 확인
perm |= EXEC # 추가
perm &= ~WRITE # 제거
# 리눅스 파일 권한 0o755 가 정확히 이것
# 111 101 101 = rwx r-x r-x
# ④ 비트셋으로 집합 표현 (n ≤ 64 일 때 매우 빠르다)
s1 = 0b1011 # {0, 1, 3}
s2 = 0b0110 # {1, 2}
s1 & s2 # 교집합 0b0010
s1 | s2 # 합집합
s1 ^ s2 # 대칭차
s1 & ~s2 # 차집합
bin(s1 & s2).count("1") # 교집합 크기
실무에선 이렇게 — 언어별 함정 🔴 실무
| 언어 | 주의점 |
|---|---|
| 파이썬 | 정수가 무한 정밀도 — ~x = -x-1, 시프트가 안 잘림 |
| JavaScript | 비트 연산이 32비트로 강제 변환 — 큰 수에서 깨짐 |
| 자바 | >>(부호 유지) vs >>>(0 채움) 구분 |
| C · C++ | 부호 있는 정수의 시프트·오버플로는 미정의 동작 |
| Go · Rust | 시프트 양이 비트 폭 이상이면 패닉/정의됨 (언어별) |
// JS 의 32비트 함정
2 ** 31 | 0 // -2147483648 ← 부호 있는 32비트로 잘림
2 ** 32 | 0 // 0
(2 ** 40) >>> 0 // 0 ← 상위 비트 소실
// 큰 정수를 다루려면 BigInt
(2n ** 40n) & 0xFFn // BigInt 연산
// ✓ 32비트를 넘는 플래그는 배열이나 BigInt 로
# 파이썬은 반대로 '안 잘려서' 헷갈린다
~5 # -6 (2의 보수 관점)
5 << 100 # 아주 큰 수 (오버플로 없음)
# C 처럼 32비트로 다루려면 마스크를 직접
def to_int32(x):
x &= 0xFFFFFFFF
return x - 0x100000000 if x >= 0x80000000 else x
to_int32(0xFFFFFFFF) # -1
1 << 31 (int 가 32비트일 때)은 UB 이고,
컴파일러가 “이런 일은 없다”고 가정해 코드를 통째로 지울 수 있습니다.
1u << 31 처럼 부호 없는 타입을 쓰세요 → 🔵 C++ 가이드.
정수 표현 — 2의 보수와 오버플로 버그
음수를 표현하는 규칙 하나가 수많은 보안 취약점의 뿌리입니다. 아래 변환기에 값을 넣어 비트가 어떻게 도는지 직접 확인해 보세요.
왜 2의 보수인가
-5 를 8비트로 만드는 법
① 5 를 2진수로 00000101
② 모든 비트 반전 11111010
③ 1 을 더한다 11111011 ← -5
확인: 5 + (-5)
00000101
+ 11111011
──────────
100000000 → 8비트를 넘는 부분은 버려짐 → 00000000 = 0 ✓
★ 2의 보수의 장점
· 덧셈 회로 하나로 뺄셈까지 처리 (a - b = a + (-b))
· 0 이 하나뿐 (1의 보수는 +0 과 -0 이 따로 있었다)
· 부호 판정이 최상위 비트 하나로
★ 대가: 범위가 비대칭이다
8비트: -128 ~ 127 (음수가 하나 더 많다)
-(-128) 은 표현할 수 없다 → abs(INT_MIN) 이 음수가 된다
범위와 오버플로
| 비트 | 부호 있음 | 부호 없음 |
|---|---|---|
| 8 | -128 ~ 127 | 0 ~ 255 |
| 16 | -32,768 ~ 32,767 | 0 ~ 65,535 |
| 32 | ±약 21억 | 0 ~ 약 43억 |
| 64 | ±약 9.2×10¹⁸ | 0 ~ 1.8×10¹⁹ |
// 자바 · C 에서의 오버플로
int max = Integer.MAX_VALUE; // 2147483647
max + 1 // -2147483648 ← 조용히 뒤집힘
// 실제로 있었던 사고들
// · 2038년 문제: 32비트 유닉스 시간이 2038-01-19 에 넘침
// · 유튜브 강남스타일: 조회수가 32비트 int 를 넘김 (2014)
// · Ariane 5 로켓 폭발: 64비트 float → 16비트 int 변환 실패
// · 이진 탐색의 (lo+hi)/2 오버플로 (JDK 에 9년간 존재)
if (x + 1 < x) 같은 검사를
“절대 참이 아니다”라며 삭제합니다.
이렇게도 쓸 수 있다 — 안전하게 검사하는 법 🟡 중급
✗ 계산 후 검사 (이미 늦음)
int add(int a, int b) {
int r = a + b;
if (r < a) return ERROR; // UB — 컴파일러가 지울 수 있다
return r;
}
// 크기 계산에서의 취약점
void *buf = malloc(n * size); // n*size 가 넘치면 작은 버퍼 할당
memcpy(buf, src, n * size); // → 힙 오버플로
✓ 계산 전에 검사
// GCC · Clang 내장 함수
int r;
if (__builtin_add_overflow(a, b, &r))
return ERROR;
// 또는 범위를 미리 확인
if (b > 0 && a > INT_MAX - b) return ERROR;
if (b < 0 && a < INT_MIN - b) return ERROR;
// C23 · 각 언어의 checked 연산
// Rust: a.checked_add(b) / saturating_add
// Go: math/bits 패키지
// Rust 는 언어 차원에서 다룬다
let a: i32 = i32::MAX;
a + 1 // 디버그 빌드: 패닉 / 릴리즈: 랩어라운드
a.checked_add(1) // None 반환
a.wrapping_add(1) // 명시적으로 랩어라운드
a.saturating_add(1) // MAX 에서 멈춤
a.overflowing_add(1) // (값, 넘쳤는지) 튜플
// ★ '어떤 동작을 원하는지' 를 코드에 적게 만드는 설계
실무에선 이렇게 — 타입 변환의 함정 🔴 실무
// ① 부호 있는 것과 없는 것을 비교하면 조용히 변환된다
int i = -1;
unsigned u = 1;
if (i < u) { ... } // 거짓! i 가 unsigned 로 변환되어 4294967295 가 됨
// ② 좁은 타입으로 자르면 상위 비트가 사라진다
int big = 300;
char c = big; // 44 (300 & 0xFF)
// ③ 확장할 때 부호가 따라온다
char c = -1; // 0xFF
int i = c; // -1 (부호 확장)
unsigned char u = c; // 255
int j = u; // 255 (0 확장)
| 실무 체크리스트 | 이유 |
|---|---|
| ID · 카운터는 64비트 | 32비트는 21억에서 넘침 |
| 시간은 64비트 epoch | 2038년 문제 |
| 금액은 정수 최소 단위 | 부동소수점 금지 (➗ 12) |
| 길이·인덱스는 size_t | 음수가 나올 수 없는 값 |
| JSON 의 큰 정수는 문자열 | JS 는 2⁵³ 이상 부정확 |
| DB 컬럼 타입 확인 | INT vs BIGINT |
부동소수점 하드웨어 — IEEE 754 비트 구조
➗ 선형대수 탭 12에서 “왜 0.1+0.2 가 이상한가”를 봤습니다. 여기서는 비트가 실제로 어떻게 생겼는지를 봅니다. 비트를 보면 정밀도 한계가 계산으로 나옵니다.
32비트 float 의 구조
import struct
def show(x):
bits = struct.unpack(">I", struct.pack(">f", x))[0]
b = f"{bits:032b}"
print(f"{x}\n 부호 {b[0]} 지수 {b[1:9]} 가수 {b[9:]}")
show(1.0) # 0 01111111 00000000000000000000000
show(-2.5) # 1 10000000 01000000000000000000000
show(0.1) # 0 01111011 10011001100110011001101 ← 무한소수를 잘라냄
타입별 정밀도
| 타입 | 비트 | 유효 자릿수 | 범위 |
|---|---|---|---|
| float16 | 1+5+10 | ~3 | ±65,504 |
| bfloat16 | 1+8+7 | ~2 | float32 와 같음 |
| float32 | 1+8+23 | ~7 | ±3.4×10³⁸ |
| float64 | 1+11+52 | ~15~17 | ±1.8×10³⁰⁸ |
# 연속된 정수를 정확히 표현할 수 있는 한계
# float32 : 2²⁴ = 16,777,216
# float64 : 2⁵³ = 9,007,199,254,740,992
import numpy as np
np.float32(16_777_217) # 16777216.0 ← 1 이 사라짐
# JS 의 Number.MAX_SAFE_INTEGER 가 2⁵³-1 인 이유
9007199254740992 == 9007199254740993 # JS 에서 true !
# → 큰 정수 ID 를 JSON 으로 주고받으면 안 되는 이유
이렇게도 쓸 수 있다 — 특수값과 반올림 🟡 중급
지수 비트가 특별한 값
| E | M | 의미 |
|---|---|---|
| 전부 0 | 0 | ±0 |
| 전부 0 | ≠0 | 비정규수 (아주 작은 값) |
| 전부 1 | 0 | ±무한대 |
| 전부 1 | ≠0 | NaN |
FTZ(flush-to-zero) 모드로 끌 수 있습니다.
반올림 모드
# IEEE 754 기본은 '최근접 짝수' (banker's rounding)
round(0.5) # 0 ← 2.5 가 아니라 0
round(1.5) # 2
round(2.5) # 2 ← 3 이 아니다!
round(3.5) # 4
# 왜: 항상 올리면 통계적으로 편향이 생긴다
# 짝수로 맞추면 평균적으로 상쇄된다
# 회계용 반올림이 필요하면
from decimal import Decimal, ROUND_HALF_UP
Decimal("2.5").quantize(Decimal("1"),
rounding=ROUND_HALF_UP) # 3
# 기계 엡실론 — "1 다음에 표현 가능한 수"까지의 거리
import sys
sys.float_info.epsilon # 2.220446049250313e-16
# 값이 클수록 간격도 커진다
import math
math.nextafter(1.0, 2.0) - 1.0 # 2.2e-16
math.nextafter(1e9, 2e9) - 1e9 # 1.19e-7
math.nextafter(1e16, 2e16) - 1e16 # 2.0 ← 정수도 못 센다
# 그래서 상대 오차로 비교해야 한다
math.isclose(a, b, rel_tol=1e-9)
실무에선 이렇게 — GPU · 저정밀 연산 🔴 실무
| 정밀도 | 쓰는 곳 | 주의 |
|---|---|---|
| float64 | 과학 계산 · 금융 모델 · 좌표 | GPU 에서 느림 (1/32 속도) |
| float32 | 그래픽 · 일반 ML | 기본 선택 |
| bfloat16 · float16 | 딥러닝 학습·추론 | 누적은 float32 로 |
| int8 · int4 | 모델 양자화 | 스케일·영점 관리 필요 |
| TF32 | NVIDIA 텐서 코어 | 결과가 float32 와 미세하게 다름 |
# 혼합 정밀도 — 계산은 저정밀, 누적은 고정밀
# (딥러닝 학습의 표준 기법)
# · 곱셈은 float16 → 빠르고 메모리 절약
# · 누적은 float32 → 오차 누적 방지
# · 손실 스케일링으로 언더플로 방지
# 재현성이 필요하면 명시적으로 끄기
import torch
torch.backends.cuda.matmul.allow_tf32 = False # TF32 비활성화
torch.use_deterministic_algorithms(True)
# ★ 같은 코드가 CPU / GPU / 다른 GPU 에서
# 미세하게 다른 결과를 내는 것은 '정상' 이다.
# 연산 순서와 정밀도가 다르기 때문 (➗ 선형대수 13)
pytest.approx · assertAlmostEqual),
그 오차가 비즈니스적으로 허용 가능한 범위인지 판단하세요.
논리 게이트에서 CPU 까지 — 스위치 몇 개가 계산이 되는 과정
트랜지스터 → 게이트 → 덧셈기 → ALU → CPU. 각 단계가 놀랄 만큼 단순합니다. 🔢 이산수학 11 부울 대수가 여기서 회로가 됩니다.
기본 게이트
AND A B │ Y OR A B │ Y NOT A │ Y
0 0 │ 0 0 0 │ 0 0 │ 1
0 1 │ 0 0 1 │ 1 1 │ 0
1 0 │ 0 1 0 │ 1
1 1 │ 1 1 1 │ 1
XOR A B │ Y NAND (AND 의 부정)
0 0 │ 0 ★ NAND 하나만 있으면
0 1 │ 1 다른 모든 게이트를 만들 수 있다
1 0 │ 1 (기능적 완전성)
1 1 │ 0 → 실제 칩이 NAND 로 만들어지는 이유
덧셈기 — 게이트로 계산 만들기
반가산기 (1비트 두 개를 더함)
Sum = A XOR B
Carry = A AND B
A B │ Sum Carry
0 0 │ 0 0
0 1 │ 1 0
1 0 │ 1 0
1 1 │ 0 1 ← 1+1 = 10(2진수)
전가산기 (아래 자리의 올림까지 받음)
Sum = A XOR B XOR Cin
Cout = (A AND B) OR (Cin AND (A XOR B))
★ 전가산기 32개를 이어 붙이면 32비트 덧셈기
★ 뺄셈은 2의 보수로 → 덧셈기 하나로 처리 (03절)
이렇게도 쓸 수 있다 — 기억하는 회로 — 플립플롭 🟡 중급
조합 회로 (지금 입력만으로 출력 결정)
덧셈기 · 멀티플렉서 · 디코더
순차 회로 (과거 상태를 기억)
플립플롭 · 레지스터 · 카운터
SR 래치 — 가장 단순한 기억 소자
┌──[NOR]──┬── Q
S ─┤ │
│ ┌──────┘
│ │
└──┼──[NOR]─── Q̄
R ─────┘
· S=1 → Q=1 로 설정 (Set)
· R=1 → Q=0 으로 (Reset)
· S=R=0 → 이전 상태 유지 ★ 이게 '기억'이다
★ 피드백 루프가 상태를 붙잡고 있다.
이 아이디어 하나로 레지스터 · SRAM 캐시가 만들어진다.
클럭
· 모든 플립플롭이 클럭 신호에 맞춰 동시에 갱신
· 클럭 주기 = 가장 느린 경로(임계 경로)가 안정되는 시간
· 3GHz = 초당 30억 번 = 주기 0.33ns
· 빛이 0.33ns 동안 가는 거리 = 약 10cm ← 칩 크기의 물리적 한계
실무에선 이렇게 — 개발자가 이걸 알아야 하는 이유 🔴 실무
| 회로 지식 | 소프트웨어에서의 결과 |
|---|---|
| 클럭 동기 · 임계 경로 | 주파수 한계 → 멀티코어 · 동시성 |
| SRAM(플립플롭) vs DRAM | 캐시가 비싸고 작은 이유 |
| DRAM 은 주기적 리프레시 필요 | 메모리 접근 지연의 하한 |
| 메모리 셀은 행 단위 접근 | 순차 접근이 랜덤보다 빠른 이유 |
| 비트 하나가 뒤집힐 수 있음 | ECC 메모리 · 체크섬의 필요성 |
| NAND 플래시는 블록 단위 지우기 | SSD 쓰기 증폭 · TRIM (11절) |
비트 플립은 실제로 일어난다
· 우주선(cosmic ray) · 전기적 잡음 · 노후화
· 대규모 데이터센터에서는 하루에도 여러 번
· Rowhammer: 같은 메모리 행을 반복 접근해
인접 행의 비트를 인위적으로 뒤집는 공격
대응
· ECC 메모리 — 1비트 오류 정정, 2비트 검출
· 체크섬 · 해시로 데이터 무결성 확인 (ZFS · 백업)
· 중요한 값은 여러 곳에 저장하고 대조
명령어 집합 · 어셈블리 — 30줄만 읽어도 달라집니다
어셈블리를 쓸 일은 없어도 읽을 수 있으면 컴파일러가 무엇을 했는지 · 왜 이 코드가 빠른지가 보입니다. 프로파일러 결과를 해석하는 데도 필요합니다.
최소한의 x86-64
; C 코드: int add(int a, int b) { return a + b; }
add:
mov eax, edi ; 첫 번째 인자(edi)를 eax 로
add eax, esi ; 두 번째 인자(esi)를 더한다
ret ; eax 가 반환값
; 자주 보는 명령
; mov dst, src 값 복사
; add/sub/imul 산술
; cmp a, b 비교 (플래그 설정)
; jmp label 무조건 점프
; je/jne/jl/jg 조건 점프
; call/ret 함수 호출/반환
; push/pop 스택
; lea dst, [..] 주소 계산 (곱셈·덧셈 트릭에도 쓰임)
; 레지스터 (x86-64 호출 규약, System V)
; 인자: rdi, rsi, rdx, rcx, r8, r9
; 반환: rax
; 스택: rsp (스택 포인터), rbp (베이스)
직접 보는 방법
# C/C++ — 컴파일러가 만든 어셈블리
gcc -O2 -S -masm=intel main.c -o -
# 이미 만들어진 바이너리
objdump -d --demangle a.out | less
# 온라인: godbolt.org (Compiler Explorer) ★ 가장 편하다
# 최적화 옵션을 바꿔 가며 결과를 즉시 비교
# 파이썬 — 바이트코드 (어셈블리는 아니지만 같은 목적)
import dis
def f(a, b): return a + b
dis.dis(f) # 3.12 기준 (3.13+ 는 LOAD_FAST 두 개가 한 명령으로 합쳐짐)
# RESUME 0
# LOAD_FAST a
# LOAD_FAST b
# BINARY_OP +
# RETURN_VALUE
# 자바
# javap -c -p MyClass.class
# JS (V8)
# node --print-opt-code --print-bytecode script.js
# .NET
# 디버거에서 디스어셈블리 창
-O0 과 -O2 로 비교하면
컴파일러가 얼마나 많은 일을 하는지 바로 보입니다.
이렇게도 쓸 수 있다 — 함수 호출과 스택 프레임 🟡 중급
함수를 호출할 때 스택에서 벌어지는 일
높은 주소
┌──────────────┐
│ 호출자의 프레임 │
├──────────────┤
│ 인자 (7번째~) │ ← 앞 6개는 레지스터로 (x86-64)
├──────────────┤
│ 반환 주소 │ ← call 명령이 자동으로 push
├──────────────┤
│ 저장된 rbp │ ← 함수 프롤로그
├──────────────┤
│ 지역 변수 │
│ ... │
└──────────────┘ ← rsp (스택 포인터)
낮은 주소 ↓ 스택은 아래로 자란다
★ 버퍼 오버플로가 위험한 이유
지역 변수 배열을 넘어서 쓰면 '반환 주소' 를 덮어쓴다
→ 함수가 끝날 때 공격자가 지정한 곳으로 점프
→ 🔐 보안 · 암호 10
스택 오버플로가 나는 이유
char buf[1024*1024] 같은 지역 변수 하나로도 터질 수 있습니다.
큰 버퍼는 힙에 잡으세요.
호출 규약(ABI)이 중요한 이유
실무에선 이렇게 — 어셈블리를 읽어야 하는 순간 🔴 실무
| 상황 | 무엇을 확인하나 |
|---|---|
| 기대만큼 안 빠를 때 | 인라인 됐는지 · 벡터화 됐는지 |
| 프로파일러가 특정 주소를 지목 | 어느 함수 · 어느 줄인지 역추적 |
| 크래시 덤프 분석 | 스택 되감기 · 레지스터 값 |
| 최적화 옵션 효과 확인 | -O2 vs -O3 실제 차이 |
| UB 로 코드가 사라짐 | 검사가 삭제됐는지 확인 |
| 보안 기능 확인 | 스택 카나리 · CET 유무 |
// 컴파일러가 실제로 무엇을 하는지 보는 예
int sum(int *a, int n) {
int s = 0;
for (int i = 0; i < n; i++) s += a[i];
return s;
}
// -O0: 반복문 그대로, 매번 메모리 접근
// -O2: 레지스터에 누적, 4~8개씩 SIMD 로 처리
// → paddd xmm0, xmmword ptr [rdi + rax*4]
// → 한 명령에 4개를 더한다
// 벡터화가 안 되면 컴파일러가 이유를 알려 준다
// gcc -O2 -fopt-info-vec-missed
// clang -O2 -Rpass-missed=loop-vectorize
restrict 를 붙이거나 분기를 없애면 해결됩니다 →
10 절.
파이프라인 · 분기 예측 — if 하나가 비싼 이유
CPU 는 명령을 컨베이어처럼 겹쳐서 처리합니다.
그런데 if 를 만나면 다음에 뭘 할지 모릅니다 —
그래서 추측하고, 틀리면 지금까지 한 일을 전부 버립니다.
파이프라인 — 겹쳐서 처리
파이프라인 없이 (한 명령씩)
명령1: F D E W
명령2: F D E W
명령3: F D E W
→ 12 사이클에 3개
파이프라인 (겹쳐서)
명령1: F D E W
명령2: F D E W
명령3: F D E W
→ 6 사이클에 3개 ★ 이 예는 2배 — 명령이 많아지면 단계 수(4)배에 가까워진다
현대 CPU 는 14~20 단계 파이프라인
+ 슈퍼스칼라 (한 사이클에 여러 명령 발행)
+ 비순차 실행 (의존성 없는 명령을 먼저)
해저드 — 파이프라인이 멈추는 이유
| 종류 | 원인 | 해결 |
|---|---|---|
| 데이터 | 앞 결과를 기다림 | 포워딩 · 재정렬 |
| 제어 | 분기 결과를 모름 | 분기 예측 |
| 구조 | 자원 충돌 | 자원 복제 |
분기 예측 실패의 비용
예측 성공 → 0 사이클 손해
예측 실패 → 파이프라인 전체를 비움
= 15~20 사이클 손해
≈ L2 캐시 접근 한 번과 비슷
현대 CPU 의 예측 정확도: 95~99%
· 패턴을 학습한다 (반복문은 거의 항상 맞춤)
· 랜덤한 분기는 50% → 최악
이렇게도 쓸 수 있다 — 정렬만 했는데 6배 빨라진다 🟡 중급
유명한 실험
#include <algorithm>
#include <chrono>
int data[32768];
// 랜덤하게 채운다
for (int i = 0; i < 32768; i++) data[i] = rand() % 256;
// std::sort(data, data + 32768); ← 이 한 줄이 있고 없고
long sum = 0;
for (int t = 0; t < 100000; t++)
for (int i = 0; i < 32768; i++)
if (data[i] >= 128) // ★ 이 분기
sum += data[i];
// 정렬 안 함: 11.5초 (분기 예측 실패율 50%)
// 정렬 함 : 1.9초 (앞부분 전부 거짓, 뒷부분 전부 참 → 예측 성공)
// 계산량은 완전히 같다!
// 분기 자체를 없애는 방법 (branchless)
for (int i = 0; i < 32768; i++) {
int t = (data[i] - 128) >> 31; // 음수면 -1(전부 1), 아니면 0
sum += ~t & data[i]; // 조건부 덧셈을 비트 연산으로
}
// 정렬 여부와 무관하게 빠르다
// C++20 의 힌트 (실제 효과는 컴파일러·CPU 마다 다름)
if (x) [[likely]] { ... }
if (y) [[unlikely]] { ... }
cmov 로 바꿔 주는 경우가 많습니다.
측정해서 병목으로 확인된 뒤에만 손대세요.
실무에선 이렇게 — 투기 실행과 보안 🔴 실무
Spectre · Meltdown — 예측이 만든 취약점
공격의 아이디어
① CPU 는 분기 결과를 모르는 채 '미리' 실행한다 (투기 실행)
② 예측이 틀리면 결과를 되돌린다 — 레지스터·메모리는 원상복구
③ ★ 그런데 캐시는 되돌리지 않는다
④ 접근하면 안 되는 메모리를 투기적으로 읽고,
그 값에 따라 다른 캐시 라인을 건드리게 한다
⑤ 나중에 캐시 접근 시간을 재서 값을 알아낸다 (사이드 채널)
영향: 2018년, 거의 모든 현대 CPU
대응: 마이크로코드 패치 + OS 커널 격리(KPTI)
→ 시스템 콜 비용이 눈에 띄게 증가했다
| 개발자에게 남은 영향 | 내용 |
|---|---|
| 시스템 콜이 비싸짐 | I/O 를 배치로 묶는 것이 더 중요해짐 |
| 공유 테넌트 격리 | 같은 물리 코어를 공유하지 않도록 (하이퍼스레딩 비활성) |
| 브라우저 타이머 정밀도 축소 | performance.now() 해상도 제한 |
SharedArrayBuffer 제한 | COOP/COEP 헤더 필요 |
| 비밀 값을 다루는 코드 | 상수 시간 연산 · 사이드 채널 주의 |
캐시 — 성능의 진짜 주인
이 탭에서 가장 중요한 절입니다. 같은 알고리즘이 10배 차이 나는 이유는 거의 항상 캐시입니다. 원리는 간단하고, 코드에 적용하는 규칙도 몇 개뿐입니다.
캐시 라인 — 64바이트 단위로 움직인다
CPU 가 메모리에서 1바이트를 요청하면
→ 실제로는 64바이트(캐시 라인) 통째로 가져온다
int arr[1000];
arr[0] 을 읽으면 → arr[0..15] 가 캐시에 올라온다 (int 4바이트 × 16)
★ 그래서
· arr[0], arr[1], arr[2] ... 순차 접근 → 미스 1번, 히트 15번
· arr[0], arr[500], arr[123] ... 랜덤 → 매번 미스
두 가지 지역성
· 시간 지역성: 방금 쓴 것을 곧 다시 쓴다 (반복문 변수)
· 공간 지역성: 근처 것을 곧 쓴다 (배열 순회)
직접 측정해 보기
import numpy as np, time
n = 4096
A = np.random.rand(n, n)
# 행 우선 순회 (연속된 메모리)
t = time.perf_counter()
s = 0.0
for i in range(n):
s += A[i, :].sum()
row = time.perf_counter() - t
# 열 우선 순회 (건너뛰며 접근)
t = time.perf_counter()
s = 0.0
for j in range(n):
s += A[:, j].sum()
col = time.perf_counter() - t
print(f"열 우선이 {col/row:.1f}배 느림") # 보통 3~10배
# ★ 계산량은 완전히 같다. 메모리 접근 순서만 다르다.
# C/C++/파이썬 numpy 는 행 우선(row-major)
# 포트란 · 매트랩 · 줄리아는 열 우선(column-major)
이렇게도 쓸 수 있다 — AoS vs SoA 🟡 중급
데이터 배치를 바꾸는 것만으로
Array of Structs (AoS)
struct Particle {
float x, y, z; // 12B
float vx, vy, vz; // 12B
int id; // 4B
char name[36]; // 36B
}; // 총 64B
Particle ps[10000];
// x 만 쓰는 계산을 하면
for (auto &p : ps) sum += p.x;
// 캐시 라인 64B 중 4B 만 사용
// → 유효 대역폭 6%
Struct of Arrays (SoA)
struct Particles {
float x[10000];
float y[10000];
float z[10000];
float vx[10000];
// ...
};
Particles ps;
for (int i = 0; i < n; i++) sum += ps.x[i];
// 캐시 라인 64B 를 전부 사용
// → 유효 대역폭 100%
// → SIMD 벡터화도 자연스럽다
| 배치 | 유리한 경우 | 실제 사례 |
|---|---|---|
| AoS | 한 객체의 여러 필드를 함께 사용 | 일반적인 OOP 코드 |
| SoA | 여러 객체의 같은 필드를 순회 | 게임 ECS · 물리 · 데이터 분석 |
| 하이브리드 (AoSoA) | 둘 다 필요 | 고성능 시뮬레이션 |
| 컬럼 스토어 | 분석 쿼리 (집계) | ClickHouse · Parquet · DuckDB |
SELECT AVG(price) 를 할 때 price 컬럼만 읽으면 되므로
행 저장 방식보다 수십 배 적은 데이터만 건드립니다 →
🗄️ Database 가이드.
실무에선 이렇게 — 캐시 친화 코드의 규칙 🔴 실무
- 순차로 접근한다 — 다차원 배열은 메모리 순서대로 반복. C 계열이면 마지막 인덱스가 가장 안쪽 루프.
- 필요한 것만 담는다 — 구조체를 작게. 안 쓰는 필드를 빼면 같은 캐시 라인에 더 많은 원소가 들어갑니다.
- 포인터 추적을 줄인다 — 연결 리스트 · 트리보다 배열이 유리한 경우가 많습니다(🗂️ 자료구조 01).
- 블로킹(타일링) — 큰 행렬 연산은 캐시에 들어가는 크기로 잘라서.
- false sharing 을 피한다 — 여러 스레드가 같은 캐시 라인의 다른 변수를 쓰면 서로 무효화합니다.
// ④ 행렬 곱 타일링 — 같은 연산량, 훨씬 빠름
const int B = 64; // 타일 크기 (캐시에 맞춤)
for (int ii = 0; ii < n; ii += B)
for (int jj = 0; jj < n; jj += B)
for (int kk = 0; kk < n; kk += B)
for (int i = ii; i < min(ii+B, n); i++)
for (int j = jj; j < min(jj+B, n); j++) {
float s = C[i][j];
for (int k = kk; k < min(kk+B, n); k++)
s += A[i][k] * B_[k][j];
C[i][j] = s;
}
// ⑤ false sharing — 스레드별 카운터를 패딩으로 분리
struct alignas(64) Counter { long value; }; // 캐시 라인마다 하나씩
Counter counters[NUM_THREADS];
// 패딩이 없으면 8개 카운터가 한 라인에 들어가
// 서로의 갱신 때문에 계속 캐시 무효화 → 10배 느려질 수 있다
# 캐시 미스를 실제로 재기 (리눅스)
perf stat -e cache-references,cache-misses,LLC-load-misses ./program
# 어느 줄에서 미스가 나는지
perf record -e cache-misses ./program
perf report
# 캐시 시뮬레이션
valgrind --tool=cachegrind ./program
perf stat 로 미스율이 5% 를 넘는지 먼저 확인하고,
그때만 위 규칙을 적용하세요.
메모리 계층 · MMU · TLB — 주소는 가짜입니다
프로그램이 보는 주소는 진짜 메모리 주소가 아닙니다. 그 사이에 변환 장치(MMU)가 있고, 그 변환도 캐시(TLB)를 씁니다. 이 계층이 성능에 직접 영향을 줍니다.
가상 주소 → 물리 주소
프로그램의 주소 (가상)
↓
MMU (주소 변환 장치)
↓ 페이지 테이블 참조
실제 메모리 주소 (물리)
왜 이런 층을 두나
· 프로세스마다 독립된 주소 공간 → 서로 침범 불가
· 램보다 큰 프로그램 실행 (스왑)
· 같은 물리 페이지를 여러 프로세스가 공유 (라이브러리)
· 메모리 보호 (읽기 전용 · 실행 금지)
· 주소를 고정할 수 있어 링킹이 단순해진다
★ 페이지 테이블 자체가 메모리에 있다
→ 주소 변환마다 메모리 접근이 추가로 필요?
→ 그래서 TLB 라는 캐시를 둔다
TLB — 주소 변환 캐시
| 항목 | 값 |
|---|---|
| 기본 페이지 크기 | 4KB |
| TLB 엔트리 수 | 수백 ~ 수천 |
| TLB 로 커버되는 메모리 | 4KB × 1500 ≈ 6MB |
| TLB 미스 비용 | 수십~수백 사이클 |
| 휴지 페이지(huge page) | 2MB · 1GB |
# 큰 힙을 쓰는 프로그램에서 TLB 미스가 병목이 될 수 있다
# → 휴지 페이지로 커버 범위를 늘린다 (2MB × 1500 = 3GB)
# 리눅스: 투명 휴지 페이지 상태 확인
cat /sys/kernel/mm/transparent_hugepage/enabled
# JVM
java -XX:+UseLargePages -XX:LargePageSizeInBytes=2m
# ★ 단, DB(Redis · MongoDB)는 THP 를 끄라고 권고하는 경우가 많다
# 지연 스파이크와 메모리 낭비가 생길 수 있기 때문
perf stat -e dTLB-load-misses,iTLB-load-misses ./program
이렇게도 쓸 수 있다 — NUMA — 코어마다 가까운 메모리가 다르다 🟡 중급
멀티 소켓 서버의 구조
[소켓 0]──[메모리 0] 로컬 접근: 80ns
│ 원격 접근: 130ns (1.5배)
[소켓 1]──[메모리 1]
프로세스가 소켓 0 에서 돌면서 메모리 1 을 쓰면 계속 느리다
확인
numactl --hardware # 노드 구성 보기
numastat # 원격 접근 통계
대응
numactl --cpunodebind=0 --membind=0 ./program # 한 노드에 고정
# JVM: -XX:+UseNUMA
# DB: 인스턴스를 노드별로 나누거나 인터리브
numactl --interleave=all ./program # 고르게 분산
실무에선 이렇게 — 메모리 접근 패턴 최적화 🔴 실무
| 증상 | 의심 | 확인 |
|---|---|---|
| CPU 사용률은 높은데 느림 | 캐시 미스 대기 | perf stat IPC 확인 |
| 메모리를 늘려도 안 빨라짐 | 지연이 병목 (대역폭 아님) | 접근 패턴 검토 |
| 큰 힙에서만 느림 | TLB 미스 | dTLB-load-misses |
| 코어를 늘려도 안 빨라짐 | 메모리 대역폭 포화 · NUMA | numastat · 대역폭 측정 |
| 스레드 늘리면 오히려 느림 | false sharing | 패딩 후 재측정 |
| 가끔 크게 튐 | 페이지 폴트 · 스왑 | vmstat · ⚙️ OS 06 |
# IPC (Instructions Per Cycle) 로 판단하기
perf stat ./program
# IPC > 2.0 → 계산 위주, CPU 를 잘 쓰고 있다
# IPC ≈ 1.0 → 보통
# IPC < 0.5 → ★ 메모리를 기다리고 있다 (캐시 미스 의심)
# 어디서 기다리는지
perf record -e cycles:pp ./program && perf report
perf mem record ./program && perf mem report
병렬성 — 멀티코어 · SIMD · GPU
클럭이 안 오르니 수를 늘렸습니다. 그런데 코어를 8배 늘려도 8배 빨라지지 않습니다 — 그 이유를 계산하는 법(암달의 법칙)부터 봅니다.
암달의 법칙 — 상한이 정해져 있다
def amdahl(p, n):
return 1 / ((1 - p) + p / n)
for p in (0.5, 0.9, 0.95, 0.99):
print(p, [round(amdahl(p, n), 1) for n in (2, 4, 8, 16, 64, 1024)])
# 0.50 [1.3, 1.6, 1.8, 1.9, 2.0, 2.0] ← 절반이 순차면 최대 2배
# 0.90 [1.8, 3.1, 4.7, 6.4, 8.8, 9.9]
# 0.95 [1.9, 3.5, 5.9, 9.1, 15.4, 19.6]
# 0.99 [2.0, 3.9, 7.5, 13.9, 39.3, 91.2]
# ★ 순차 부분 1% 가 코어 1024개의 이득을 91배로 묶는다
세 가지 병렬성
| 종류 | 단위 | 도구 |
|---|---|---|
| 명령 수준 | 파이프라인 · 슈퍼스칼라 | CPU 가 자동 |
| 데이터 수준 | SIMD (한 명령, 여러 데이터) | 컴파일러 벡터화 · 인트린식 |
| 태스크 수준 | 스레드 · 프로세스 | 스레드 풀 · 비동기 |
SIMD 폭 (한 명령에 처리하는 float32 개수)
SSE 128비트 → 4개
AVX2 256비트 → 8개
AVX-512 512비트 → 16개
ARM NEON 128비트 → 4개
SVE 가변
★ 자동 벡터화 조건
· 반복 횟수가 컴파일 시점에 알려지거나 단순
· 반복 간 의존성 없음
· 메모리가 연속 · 정렬됨
· 분기가 없거나 단순
· 포인터 앨리어싱 없음 (restrict)
이렇게도 쓸 수 있다 — SIMD 를 실제로 쓰는 법 🟡 중급
| 방법 | 난이도 | 이식성 | 추천 |
|---|---|---|---|
| 라이브러리 사용 | 낮음 | 높음 | ★ 기본 선택 (numpy · BLAS) |
| 자동 벡터화 유도 | 낮음 | 높음 | 루프를 단순하게 |
| OpenMP SIMD 지시문 | 중간 | 높음 | #pragma omp simd |
| 이식 가능한 래퍼 | 중간 | 높음 | Highway · std::simd |
| 인트린식 직접 | 높음 | 낮음 | 최후의 수단 |
// 자동 벡터화를 도와주는 코드 스타일
void scale(float* __restrict out, // ★ restrict: 겹치지 않음을 보장
const float* __restrict in,
float k, int n) {
for (int i = 0; i < n; i++) // 단순한 반복
out[i] = in[i] * k; // 의존성 없음
}
// → AVX2 로 8개씩 처리 (컴파일러가 자동으로)
// 벡터화를 막는 것들
for (int i = 1; i < n; i++)
a[i] = a[i-1] + b[i]; // ✗ 반복 간 의존성
for (int i = 0; i < n; i++)
if (cond(i)) out[j++] = in[i]; // ✗ 가변 인덱스 (압축)
// AVX-512 는 가능
# 파이썬에서는 numpy 가 이미 SIMD 를 쓴다
import numpy as np
a = np.arange(1_000_000, dtype=np.float32)
b = a * 2.5 + 1.0 # 벡터화된 연산 — 직접 반복문 쓰지 말 것
# 더 필요하면
# · numexpr — 중간 배열 생성을 줄임
# · numba — JIT 로 반복문을 벡터화
from numba import njit
@njit(fastmath=True)
def compute(x):
s = 0.0
for i in range(x.size): s += x[i] * x[i]
return s
실무에선 이렇게 — GPU 를 쓸 때와 안 쓸 때 🔴 실무
GPU 가 유리한 조건
| 같은 연산을 수천~수백만 개 데이터에 |
| 데이터 간 의존성이 없음 |
| 분기가 적음 (워프 내 발산 없음) |
| 전송 비용보다 계산이 큼 |
| 메모리 접근이 연속(coalesced) |
GPU 가 불리한 조건
| 데이터가 작음 (전송이 계산보다 비쌈) |
| 분기가 많고 데이터마다 다름 |
| 순차 의존 (누적 · 재귀) |
| 랜덤 메모리 접근 |
| 지연이 중요 (배치가 아니라 단건) |
GPU 구조의 핵심 개념
· 워프(warp) = 32개 스레드가 '같은 명령'을 동시에 실행
· 워프 내에서 분기가 갈리면 → 양쪽을 순차로 실행 (발산, divergence)
if (tid % 2) A(); else B(); ← 성능이 절반
· 메모리 병합(coalescing)
연속된 스레드가 연속된 주소를 읽으면 한 번의 트랜잭션
흩어지면 32번의 트랜잭션 → 32배 느림
· 호스트↔디바이스 전송이 병목인 경우가 많다
PCIe 4.0 x16 ≈ 32GB/s vs GPU 메모리 대역폭 ≈ 1000GB/s
→ 가능하면 데이터를 GPU 에 두고 여러 연산을 이어서
저장장치 — SSD 는 덮어쓰기를 못 합니다
HDD 의 회전 지연에 맞춰 설계된 소프트웨어가 많은데, SSD 는 완전히 다른 제약을 가집니다. DB 튜닝과 파일 시스템 선택이 여기서 갈립니다.
HDD vs SSD
| HDD | SSD (NVMe) | |
|---|---|---|
| 랜덤 읽기 | ~10ms | ~50µs (200배) |
| 순차 읽기 | 200MB/s | 3~7GB/s |
| IOPS | ~200 | 50만~100만 |
| 랜덤 vs 순차 | 100배 차이 | 2~5배 차이 |
| 병렬성 | 거의 없음 | 큐 깊이가 중요 |
| 수명 | 기계적 | 쓰기 횟수 제한 |
io_uring ·
비동기 I/O 가 SSD 시대에 중요해졌습니다.
SSD 의 제약 — 지우기 단위가 크다
NAND 플래시의 구조
· 읽기 · 쓰기 단위: 페이지 (4~16KB)
· ★ 지우기 단위: 블록 (수백 페이지, 수 MB)
· 이미 쓴 페이지에 덮어쓰기 불가 — 블록 전체를 지워야 함
그래서 SSD 내부에서 벌어지는 일
① 덮어쓰기 요청 → 다른 빈 페이지에 새로 쓴다
② 원래 페이지는 '무효' 로 표시
③ 빈 블록이 부족해지면 가비지 컬렉션
— 유효 페이지를 모아 옮기고 블록을 통째로 지운다
④ 이 과정에서 실제 쓰기량이 요청보다 많아진다
= 쓰기 증폭(Write Amplification)
영향
· 디스크가 꽉 차면 GC 여유가 없어 급격히 느려진다 (80% 이하 유지)
· TRIM 으로 "이 페이지는 이제 필요 없다" 를 알려야 GC 가 효율적
· 작은 랜덤 쓰기가 많으면 수명이 빨리 준다
이렇게도 쓸 수 있다 — 쓰기 패턴이 성능을 정합니다 🟡 중급
| 설계 선택 | SSD 에서의 결과 |
|---|---|
| 순차 추가(append-only) 로그 | 쓰기 증폭 최소 · 가장 좋음 |
| LSM 트리 (RocksDB) | 순차 쓰기 위주 → SSD 친화 |
| B+트리 제자리 갱신 | 랜덤 쓰기 → 증폭 발생 |
| 작은 파일 다수 | 메타데이터 오버헤드 |
| fsync 남발 | 지연 급증 · 배터리 백업 캐시 의존 |
| 디스크 90% 사용 | 성능 급락 — 여유 공간 필수 |
# 쓰기를 묶는 것이 가장 효과적이다
# ✗ 한 줄씩 fsync
for line in lines:
f.write(line)
f.flush(); os.fsync(f.fileno()) # 매번 디스크까지 — 아주 느림
# ✓ 버퍼에 모아서 한 번에
buf = []
for line in lines:
buf.append(line)
if len(buf) >= 1000:
f.writelines(buf); buf.clear()
f.writelines(buf)
f.flush(); os.fsync(f.fileno()) # 마지막에 한 번
# ★ 내구성이 필요한 지점에서만 fsync
# DB 의 그룹 커밋(group commit)이 정확히 이 아이디어
write() 만으로는 디스크에 안 갑니다 —
OS 페이지 캐시에만 있습니다. 전원이 나가면 사라집니다.
진짜 저장은 fsync 후에 보장되고,
그래서 DB 가 트랜잭션 커밋마다 fsync 를 합니다 → ⚙️ OS 09.
실무에선 이렇게 — 디스크 성능 진단 🔴 실무
# 현재 상태
iostat -x 1 # %util · await · r/s · w/s
# await 가 높으면 → 디스크가 병목
# %util 100% 여도 SSD 는 여유가 있을 수 있다 (병렬성 때문)
# 어느 프로세스가 쓰는지
iotop -o
pidstat -d 1
# 실제 성능 측정 (운영 디스크에서는 주의)
fio --name=randread --rw=randread --bs=4k --size=1G \
--numjobs=4 --iodepth=32 --runtime=30 --group_reporting
# 파일 시스템 여유
df -h # 80% 넘으면 SSD 성능 저하 주의
lsblk --discard # TRIM 지원 여부
sudo fstrim -av # 수동 TRIM (보통 주기 실행)
| 증상 | 확인할 것 |
|---|---|
| 디스크가 갑자기 느려짐 | 사용률 80% 초과 · TRIM 미실행 · GC 중 |
| p99 만 튐 | fsync 대기 · 컴팩션 · 페이지 캐시 회수 |
| 클라우드에서 느림 | IOPS 크레딧 소진 (gp2 등 버스트 타입) |
| 순차는 빠른데 랜덤이 느림 | 큐 깊이 부족 · 동기 I/O |
| 쓰기만 느림 | 쓰기 캐시 정책 · 배터리 백업 없음 |
버스 · I/O · 인터럽트 — CPU 가 기다리지 않는 법
장치는 CPU 보다 수천 배 느립니다. 그동안 CPU 를 놀리지 않기 위한 장치가 인터럽트와 DMA 이고, 이것이 비동기 I/O 의 하드웨어적 근거입니다.
폴링 vs 인터럽트
폴링 (물어보기)
while (!device_ready()) { } // CPU 를 계속 쓴다
read_data();
· 지연이 짧다 (즉시 반응)
· CPU 를 낭비한다
· 초고속 장치(NVMe · 100G NIC)에서는 오히려 유리
인터럽트 (알려 달라고 하기)
start_io();
do_other_work(); // 그동안 다른 일
// ... 장치가 끝나면 CPU 에 신호 → 핸들러 실행
· CPU 를 안 낭비한다
· 컨텍스트 스위치 비용이 있다
· 초당 수십만 번이면 인터럽트 자체가 부담
하이브리드 (실무의 답)
· NAPI (리눅스 네트워크): 인터럽트로 시작 → 폴링으로 전환
· 인터럽트 병합(coalescing): 여러 개를 모아 한 번에
DMA — CPU 를 거치지 않는 전송
DMA 없이
디스크 → CPU 레지스터 → 메모리
(CPU 가 바이트마다 개입)
DMA 로
디스크 →──────────────→ 메모리
(DMA 컨트롤러가 직접, 끝나면 인터럽트)
★ 이것이 'zero-copy' 의 기반이다
일반적인 파일 전송 (복사 4번)
디스크 → 커널 버퍼 → 사용자 버퍼 → 소켓 버퍼 → NIC
sendfile / splice (복사 2번)
디스크 → 커널 버퍼 →──────────────→ NIC
사용자 공간을 거치지 않는다
sendfile 을 써서
사용자 공간 복사를 생략합니다 → 🌐 서버기술 가이드.
이렇게도 쓸 수 있다 — 시스템 콜 비용 줄이기 🟡 중급
| 기법 | 줄이는 것 | API |
|---|---|---|
| 버퍼링 | 호출 횟수 | 버퍼드 I/O · 배치 쓰기 |
| 벡터 I/O | 여러 버퍼를 한 번에 | readv · writev |
| 메모리 맵 | 복사 · read 호출 | mmap |
| zero-copy 전송 | 사용자 공간 복사 | sendfile · splice |
| 배치 제출 | 시스템 콜 자체 | io_uring |
| 커널 우회 | 커널 전체 | DPDK · SPDK |
# mmap — 파일을 메모리처럼
import mmap
with open("large.bin", "r+b") as f:
with mmap.mmap(f.fileno(), 0) as mm:
header = mm[:64] # read() 없이 접근
mm[100:104] = b"\x01\x02\x03\x04"
# 페이지 폴트로 필요한 부분만 로드된다
# 언제 mmap 이 유리한가
# · 파일의 일부만 랜덤 접근
# · 여러 프로세스가 같은 파일 공유
# · 큰 읽기 전용 데이터 (인덱스 · 모델 가중치)
# 언제 불리한가
# · 순차로 한 번만 읽기 (일반 read 가 낫다)
# · 네트워크 파일 시스템
# · 페이지 폴트가 예측 불가능한 지연을 만들 때
io_uring 이 최근 중요해진 이유 —
Spectre 대응으로 시스템 콜이 비싸졌는데,
io_uring 은 공유 링 버퍼로 요청을 쌓아 두고
시스템 콜 한 번에 수백 개를 제출합니다 → ⚙️ 운영체제 10.
실무에선 이렇게 — 네트워크 장치 튜닝 🔴 실무
# 인터럽트가 한 코어에 몰려 있는지 확인
cat /proc/interrupts | grep -i eth
mpstat -P ALL 1 # 특정 코어만 %irq 가 높으면 문제
# RSS — NIC 큐를 여러 코어에 분산
ethtool -l eth0 # 큐 개수 확인
ethtool -L eth0 combined 8 # 큐를 8개로
# 인터럽트 병합 — 지연과 CPU 를 맞바꾼다
ethtool -c eth0
ethtool -C eth0 rx-usecs 50 # 50µs 모았다가 한 번에 (처리량↑ 지연↑)
# irqbalance 가 동작 중인지 (또는 수동 고정)
systemctl status irqbalance
# 링 버퍼 크기 (패킷 드롭이 나면)
ethtool -g eth0
ethtool -G eth0 rx 4096
| 증상 | 확인 |
|---|---|
| 패킷 드롭 | ethtool -S eth0 | grep drop · 링 버퍼 크기 |
| 한 코어만 100% | RSS 미설정 · 인터럽트 편중 |
| 처리량은 나오는데 지연이 큼 | 인터럽트 병합 값이 큼 |
| softirq 가 높음 | 패킷 처리 부하 · %soft 확인 |
| 컨테이너에서만 느림 | 가상 NIC · 오버레이 네트워크 오버헤드 |
하드웨어가 코드에 미치는 영향 — 정리
이 탭의 내용을 실제로 코드를 고칠 때 쓰는 순서로 정리합니다. 규칙은 몇 개 안 되고, 순서가 중요합니다.
최적화 순서 — 위에서부터
- 측정한다 — 프로파일러로 시간이 어디서 가는지 확인. 추측으로 고치면 90%는 헛수고입니다.
- 불필요한 일을 없앤다 — 안 해도 되는 계산 · 중복 쿼리 · 과도한 직렬화. 가장 큰 이득이 여기서 나옵니다.
- 복잡도를 낮춘다 — O(n²) → O(n log n). 데이터가 커질 때 유일하게 확실한 개선입니다.
- 메모리 접근을 고친다 — 순차 접근 · 자료구조 교체 · 구조체 축소. 여기서 2~10배가 나옵니다.
- I/O 를 묶는다 — 배치 · 캐시 · 비동기.
- 병렬화한다 — 암달의 법칙을 계산하고 시작.
- 상수를 줄인다 — SIMD · 인트린식 · 분기 제거. 노력 대비 효과가 가장 나쁩니다.
기억할 숫자
| 동작 | 대략의 시간 |
|---|---|
| L1 캐시 참조 | 1ns |
| 분기 예측 실패 | 5ns |
| L2 캐시 참조 | 4ns |
| 뮤텍스 잠금/해제 | 17ns |
| 메인 메모리 참조 | 80ns |
| 1KB 압축 (zstd) | ~1µs |
| 1MB 순차 읽기 (RAM) | ~30µs |
| SSD 랜덤 읽기 | 50µs |
| 1MB 순차 읽기 (SSD) | ~200µs |
| 같은 DC 왕복 | 500µs |
| 디스크 탐색 (HDD) | 10ms |
| 서울 ↔ 미국 서부 왕복 | ~150ms |
언어별로 다르게 신경 쓸 것
| 언어 | 가장 큰 하드웨어 영향 |
|---|---|
| C · C++ · Rust | 캐시 · SIMD · 분기 — 전부 직접 통제 |
| Go | 구조체 배치 · GC 압력 · 고루틴 수 |
| Java · C# | 객체 헤더 · GC · 배열 vs 컬렉션 |
| 파이썬 | 객체 오버헤드 — numpy 로 내려보내기 |
| JS | 히든 클래스 · 배열 요소 종류 · GC |
고수준 언어에서의 실전 규칙
· 파이썬: 반복문을 numpy 연산으로 (100배)
· 자바: 객체 대신 원시 타입 배열 (2~5배)
· JS: 배열 타입을 섞지 말 것 · TypedArray 활용
· Go: 구조체 필드를 크기 순으로 (패딩 절약)
· 전부: 문자열 이어붙이기를 반복문에서 하지 말 것
이렇게도 쓸 수 있다 — 측정 도구 요약 🟡 중급
| 알고 싶은 것 | 도구 |
|---|---|
| 어느 함수가 느린가 | perf record · py-spy · async-profiler · pprof |
| CPU 를 쓰는가 기다리는가 | perf stat (IPC) · top (%wa) |
| 캐시 미스 | perf stat -e cache-misses · cachegrind |
| 분기 예측 실패 | perf stat -e branch-misses |
| 메모리 할당 | heaptrack · valgrind massif · pprof |
| 디스크 I/O | iostat -x · iotop · fio |
| 시스템 콜 | strace -c · bpftrace |
| 락 경합 | perf lock · async-profiler lock 모드 |
| 전체 그림 | Flame Graph · continuous profiling |
# 가장 빨리 답을 주는 세 명령
perf stat ./program # ① IPC · 캐시 · 분기 한눈에
perf record -g ./program && perf report # ② 어느 함수인가
strace -c -f ./program # ③ 시스템 콜에 시간을 쓰나
# 플레임 그래프 (시각적으로 가장 명확)
perf record -F 99 -g -- ./program
perf script | stackcollapse-perf.pl | flamegraph.pl > out.svg
실무에선 이렇게 — 이어지는 곳 🔴 실무
| 여기서 배운 것 | 다음 |
|---|---|
| 03·04 정수 · 부동소수점 | ➗ 선형대수 12·13 수치 안정성 |
| 07 투기 실행 · 사이드 채널 | 🔐 보안 · 암호 10 |
| 08 캐시 · 09 메모리 계층 | ⚙️ 운영체제 05·06 가상 메모리 |
| 10 병렬성 | 🕸️ 분산 · 동시성 01~04 |
| 11 저장장치 | 🗄️ Database 가이드 · ⚙️ OS 09 |
| 12 인터럽트 · DMA | ⚙️ 운영체제 10 I/O 모델 · 🌐 네트워크 |
캐시 일관성 — 코어마다 캐시가 있는데 어떻게 값이 맞나
코어가 8개면 L1 캐시도 8개입니다. 같은 변수를 여러 코어가 캐시에 들고 있는데 한쪽이 값을 바꾸면 나머지는 어떻게 아는가. 이 질문의 답이 MESI 프로토콜이고, 거짓 공유와 메모리 배리어가 왜 필요한지가 전부 여기서 나옵니다.
거짓 공유 — 코드에는 아무 문제가 없는데 8배 느린 경우
직접 재 보기
// 스레드 4개가 '각자 다른' 카운터를 올립니다 — 공유하는 것이 없습니다
// 나란히 두면 네 값이 같은 64바이트 라인에 들어갑니다
static class Bad { volatile long value; } // 8바이트
// 패딩으로 라인을 분리합니다 (JDK 는 @Contended 도 제공)
static class Good {
volatile long value;
long p1, p2, p3, p4, p5, p6, p7; // 56바이트 채우기
}
// 결과 (4코어 · 각 1억 회)
// 나란히 : 3120ms
// 패딩 : 410ms 같은 로직인데 7~8배
long 8바이트 → 한 라인에 8개가 들어갑니다코어 A 가
arr[0] 을 쓰면 arr[1..7] 까지 통째로 무효화코어 B 가
arr[1] 을 쓰려면 라인을 다시 받아와야 합니다→ 서로 상관없는 변수인데 계속 서로를 방해합니다
perf c2c record 가
어느 캐시 라인이 코어 사이를 오가는지 정확히 짚어 줍니다.
언어별 해결 방법
Java @jdk.internal.vm.annotation.Contended
또는 수동 패딩 (long p1..p7)
LongAdder 가 이 문제를 이미 해결한 자료구조입니다
C++ alignas(std::hardware_destructive_interference_size)
C++17 부터 캐시 라인 크기를 표준으로 알려 줍니다
C# [StructLayout(LayoutKind.Explicit, Size = 128)]
Go type Counter struct {
v int64
_ [56]byte // 패딩
}
Rust #[repr(align(64))]
struct Padded(AtomicU64);
Python GIL 때문에 잘 드러나지 않습니다
(3.13+ free-threaded 빌드에서는 해당됩니다)
| 거짓 공유가 잘 생기는 곳 | 해결 |
|---|---|
| 스레드별 카운터 배열 | 패딩 · LongAdder 계열 |
| 워커별 통계 구조체 | 구조체를 64바이트 정렬 |
| 락프리 큐의 head / tail | 두 필드를 다른 라인으로 |
이웃한 volatile 필드 | 순서 재배치 · 패딩 |
| 읽기만 하는 공유 데이터 | 문제 없음 — S 상태로 함께 보유 |
여기서 이어지는 것 — 왜 메모리 배리어가 필요한가
일관성만으로는 부족합니다
MESI 는 '같은 주소'에 대한 순서만 보장합니다.
'서로 다른 주소 사이의 순서'는 보장하지 않습니다.
코어 0 코어 1
───────────── ─────────────
data = 42; if (ready)
ready = true; print(data); // 0 이 찍힐 수 있습니다
왜?
· 무효화 요청을 바로 처리하지 않고 큐에 쌓아 둡니다
· 쓰기도 스토어 버퍼에 잠깐 모았다가 내보냅니다
· 컴파일러와 CPU 가 순서를 바꿔 실행하기도 합니다
두 쓰기의 순서를 강제하려면 메모리 배리어가 필요합니다.
consistency — 여러 주소 사이의 순서를 어디까지 보장하는가 (메모리 모델)
x86 은 비교적 강한 모델(TSO), ARM · RISC-V 는 약한 모델
→ 같은 코드가 x86 에서는 되고 ARM 에서 깨지는 이유입니다
그래서 언어가 제공하는 것
// Java — volatile 이 배리어 역할을 합니다
volatile boolean ready = false;
int data = 0;
// 쓰는 쪽
data = 42;
ready = true; // 이 앞의 쓰기들이 먼저 보이도록 보장
// 읽는 쪽
if (ready) { // 이 뒤의 읽기들이 나중에 일어나도록 보장
print(data); // 반드시 42
}
// C++ — 필요한 만큼만 고를 수 있습니다
std::atomic<bool> ready{false};
int data = 0;
data = 42;
ready.store(true, std::memory_order_release); // 앞의 쓰기를 공개
if (ready.load(std::memory_order_acquire)) // 그 뒤를 받아옴
print(data); // 42 보장
// seq_cst(기본값)는 가장 안전하지만 가장 비쌉니다.
// acquire / release 로 충분한 경우가 대부분입니다.
synchronized · Mutex · ConcurrentHashMap 같은
이미 검증된 도구가 필요한 배리어를 전부 넣어 줍니다.
직접 락프리 자료구조를 쓰는 것은
측정으로 병목이 확인됐고 검증할 방법이 있을 때만입니다 —
이 영역의 버그는 재현이 거의 불가능합니다.
자세한 것은 🕸️ 분산 · 동시성 탭에서 이어집니다.
내 코드가
혼자 돌고 있다는
착각을 걷어냅니다
프로세스 · 스레드 · 가상 메모리 · 파일 · 소켓. 매일 쓰면서 정확히는 모르는 것들입니다. 이 탭은 개념을 설명하고 끝내지 않고, 운영 중 장애를 어떤 명령으로 어떤 순서로 좁히는지까지 이어갑니다. 마지막 절이 실전 진단 명령 모음입니다.
운영체제가 하는 일 — 자원을 나눠 쓰게 하는 심판
OS 가 없으면 프로그램 하나가 메모리 전체를 덮어쓰고 CPU 를 독점할 수 있습니다. 그래서 OS 는 하드웨어를 감추고, 대신 추상화를 제공합니다 — 프로세스 · 파일 · 소켓이 전부 그 추상화입니다.
네 가지 핵심 역할
| 역할 | 추상화 | 없으면 |
|---|---|---|
| CPU 관리 | 프로세스 · 스레드 | 하나가 독점 |
| 메모리 관리 | 가상 주소 공간 | 서로 덮어씀 |
| 저장 관리 | 파일 · 디렉터리 | 블록을 직접 다뤄야 |
| 장치 관리 | 디바이스 파일 · 소켓 | 하드웨어별 코드 필요 |
/proc)가
전부 같은 read/write 인터페이스로 다뤄집니다.
인터페이스를 하나로 줄이는 것의 위력을 보여 주는 예입니다.
사용자 모드와 커널 모드
CPU 에는 특권 레벨이 있다 (x86: 링 0 ~ 3)
링 0 — 커널 모든 명령 실행 가능
링 3 — 사용자 특권 명령 금지 · 자기 주소 공간만
사용자 모드에서 금지된 것
· 임의의 물리 메모리 접근
· I/O 포트 직접 제어
· 인터럽트 활성/비활성
· 페이지 테이블 변경
그래서 파일을 읽으려면 커널에게 부탁해야 한다
→ 시스템 콜 (11절)
read() 호출
→ 트랩 명령 (syscall)
→ CPU 가 링 0 으로 전환 + 커널 코드로 점프
→ 커널이 작업 수행
→ 다시 링 3 으로 복귀
이렇게도 쓸 수 있다 — 커널 구조의 종류 🟡 중급
| 구조 | 특징 | 예 |
|---|---|---|
| 모놀리식 | 드라이버·파일시스템이 전부 커널 안 · 빠름 | 리눅스 · BSD |
| 마이크로커널 | 최소 기능만 커널 · 나머지는 사용자 프로세스 | QNX · seL4 · Minix |
| 하이브리드 | 둘의 절충 | Windows NT · macOS(XNU) |
| 유니커널 | 앱과 커널을 한 바이너리로 | MirageOS · 클라우드 특화 |
모놀리식의 장단점
+ 함수 호출로 통신 → 빠르다
- 드라이버 하나의 버그가 시스템 전체를 죽인다
마이크로커널의 장단점
+ 드라이버가 죽어도 재시작 가능 (자동차·의료기기)
- 메시지 전달 비용
리눅스의 절충
· 모놀리식이지만 모듈을 동적 로드 (lsmod)
· FUSE — 파일 시스템을 사용자 공간에서
· eBPF — 검증된 코드를 커널에 안전하게 주입 ★
(관측 · 네트워킹 · 보안에서 최근 가장 중요한 기술)
bpftrace · Cilium · Pixie 가 전부 이것 위에 있습니다.
“검증기가 종료를 보장”하기 때문에 안전합니다 → 🧮 계산이론 05.
실무에선 이렇게 — /proc 로 들여다보기 🔴 실무
# 커널이 노출하는 정보는 대부분 파일로 읽을 수 있다
cat /proc/cpuinfo # CPU 정보 · 코어 수 · 플래그(avx2 등)
cat /proc/meminfo # 메모리 상세
cat /proc/loadavg # 부하 평균
cat /proc/PID/status # 프로세스 상태 · 메모리 · 스레드 수
cat /proc/PID/limits # ulimit 실제 적용값 ★ 자주 유용
cat /proc/PID/maps # 메모리 맵 (어떤 라이브러리가 어디에)
ls -l /proc/PID/fd # 열린 파일 디스크립터 ★ 누수 확인
cat /proc/PID/cmdline | tr '\0' ' ' # 실행 명령줄
cat /proc/PID/environ | tr '\0' '\n' # 환경 변수
# 커널 파라미터
sysctl -a | grep somaxconn
sysctl net.core.somaxconn=4096 # 임시
echo "net.core.somaxconn=4096" >> /etc/sysctl.conf # 영구
| 자주 보는 곳 | 확인하는 것 |
|---|---|
/proc/PID/limits | 파일 디스크립터 한도 (Too many open files) |
/proc/PID/fd | FD 누수 · 열린 소켓 수 |
/proc/PID/status | VmRSS(실제 메모리) · Threads |
/proc/PID/stack | 커널 스택 (어디서 블로킹 중인가) |
/proc/pressure/* | PSI — CPU·메모리·I/O 압박 지표 |
/sys/fs/cgroup/ | 컨테이너 리소스 제한과 실제 사용량 |
cat /proc/pressure/io 로 I/O 대기 비율을 바로 볼 수 있습니다.
프로세스와 스레드 — 무엇을 공유하는가
프로세스는 따로 사는 집, 스레드는 한 집에 사는 식구입니다. 이 차이가 성능 · 안정성 · 동시성 버그의 성격을 전부 결정합니다.
공유하는 것과 안 하는 것
| 자원 | 프로세스 간 | 스레드 간 |
|---|---|---|
| 주소 공간 (힙 · 전역) | 독립 | 공유 |
| 코드 | 독립 | 공유 |
| 파일 디스크립터 | 독립(상속 시 복사) | 공유 |
| 스택 | 독립 | 독립 |
| 레지스터 · PC | 독립 | 독립 |
| 시그널 핸들러 | 독립 | 공유 |
고르는 기준
| 상황 | 선택 |
|---|---|
| 격리가 중요 (플러그인 · 렌더링) | 프로세스 |
| 하나가 죽어도 전체는 살아야 | 프로세스 |
| 파이썬 CPU 작업 (GIL) | 프로세스 |
| 대량의 데이터 공유 | 스레드 (복사 없음) |
| 생성이 잦음 | 스레드 (10~100배 저렴) |
| I/O 대기가 대부분 | 비동기 · 코루틴 |
| 수만 개 동시 연결 | 이벤트 루프 (10절) |
비용 비교 (리눅스 대략)
프로세스 생성(fork+exec): 수백 µs ~ 수 ms
스레드 생성 : 수십 µs
코루틴 생성 : 수백 ns ★
스택 크기: 프로세스·스레드 8MB(가상) / 코루틴 수 KB
이렇게도 쓸 수 있다 — 프로세스 생성과 좀비 🟡 중급
import os, sys
pid = os.fork() # ★ 한 번 호출되고 두 번 반환된다
if pid == 0:
print("자식", os.getpid())
os._exit(0) # 자식은 반드시 _exit (버퍼 중복 플러시 방지)
else:
print("부모", os.getpid(), "자식", pid)
os.waitpid(pid, 0) # ★ 안 하면 좀비가 남는다
# fork 는 copy-on-write
# · 주소 공간을 '복사한 척' 만 한다
# · 쓰는 순간에만 실제 복사
# · 그래서 큰 프로세스도 fork 가 빠르다
# ★ 단, 파이썬은 참조 카운트 때문에 읽기만 해도 페이지가 복사된다
# 프로세스 상태
# R 실행/대기 S 인터럽트 가능 슬립 D 인터럽트 불가 슬립(I/O)
# Z 좀비 — 끝났는데 부모가 회수 안 함
# T 정지
| 문제 | 원인 | 대응 |
|---|---|---|
| 좀비 프로세스 | 부모가 wait 안 함 | wait · SIGCHLD 처리 · init 에 위임 |
| 고아 프로세스 | 부모가 먼저 죽음 | init(PID 1)이 입양 |
| 컨테이너에 좀비 쌓임 | PID 1 이 앱이라 회수 안 함 | tini · --init 사용 |
| D 상태에서 안 죽음 | I/O 대기 (kill -9 도 안 통함) | 디스크·NFS 문제 확인 |
--init 을 쓰라는 이유가 이것입니다 —
컨테이너 안의 PID 1 은 보통 애플리케이션인데,
좀비를 회수하는 코드가 없어서 프로세스 테이블이 찹니다.
docker run --init 또는 tini 를 엔트리포인트로 두세요.
실무에선 이렇게 — 스레드 수를 정하는 법 🔴 실무
import os
cores = os.cpu_count()
# CPU 바운드 → 코어 수 (컨텍스트 스위치 낭비를 피함)
cpu_pool = ProcessPoolExecutor(max_workers=cores)
# I/O 바운드 → 훨씬 크게, 다만 상한을 둔다
io_pool = ThreadPoolExecutor(max_workers=min(32, cores * 8))
# ★ 컨테이너에서는 os.cpu_count() 가 호스트 코어를 반환할 수 있다
def container_cpus():
try: # cgroup v2
with open("/sys/fs/cgroup/cpu.max") as f:
quota, period = f.read().split()
if quota != "max": return max(1, int(int(quota) / int(period)))
except FileNotFoundError: pass
return os.cpu_count()
# 자바도 같은 문제 — JDK 10+ 는 자동 인식
# -XX:ActiveProcessorCount=4 로 강제 가능
| 증상 | 원인 |
|---|---|
| 스레드를 늘렸는데 느려짐 | 컨텍스트 스위치 · 락 경합 · 캐시 오염 |
| CPU 는 노는데 처리량이 안 늘어남 | 락 경합 · 커넥션 풀 부족 · 외부 API 한도 |
| 메모리가 스레드 수에 비례해 증가 | 스레드별 스택 · 버퍼 |
| 컨테이너에서만 스레드가 폭증 | 호스트 코어 수를 읽음 |
스케줄링 — CPU 를 누구에게 얼마나
실행 가능한 스레드가 코어 수보다 많을 때 누구를 먼저 돌릴지 정하는 규칙입니다. 응답성 · 처리량 · 공정성이 서로 상충하고, 그 균형이 서비스 지연에 직접 나타납니다.
기본 알고리즘
| 방식 | 특징 | 문제 |
|---|---|---|
| FCFS | 온 순서대로 | 긴 작업이 뒤를 막음 |
| SJF | 짧은 것 먼저 | 긴 작업 기아 · 길이를 모름 |
| 라운드로빈 | 시간 조각씩 돌아가며 | 조각 크기 정하기 |
| 우선순위 | 중요한 것 먼저 | 기아 · 우선순위 역전 |
| MLFQ | 여러 큐 · 오래 쓰면 강등 | 튜닝 복잡 |
| CFS (리눅스) | 가상 실행 시간이 적은 것 먼저 | 실시간엔 부적합 |
리눅스 CFS 의 아이디어
· 각 태스크의 '가상 실행 시간(vruntime)' 을 기록
· 가장 적게 실행된 태스크를 다음에 실행
· nice 값이 vruntime 증가 속도를 바꾼다
nice -20 (높은 우선순위) → vruntime 이 천천히 늘어 자주 선택
· 레드블랙 트리로 최소 vruntime 을 O(log n) 에
※ 커널 6.6 부터는 EEVDF 스케줄러로 교체됐다
(지연에 더 민감한 태스크를 잘 다루도록)
지표의 상충
| 지표 | 뜻 | 유리한 방식 |
|---|---|---|
| 처리량 | 단위 시간당 완료 수 | 긴 시간 조각 |
| 응답 시간 | 첫 반응까지 | 짧은 시간 조각 |
| 반환 시간 | 제출~완료 | SJF |
| 공정성 | 고르게 배분 | CFS |
| 예측 가능성 | 편차가 작음 | 실시간 스케줄러 |
# 우선순위 조정
nice -n 10 ./batch_job # 낮은 우선순위로 시작
renice -n 5 -p 1234 # 실행 중 변경
# I/O 우선순위 (배치 작업이 디스크를 독점하지 않게)
ionice -c 3 ./backup.sh # idle 클래스
# 실시간 스케줄링 (지연이 결정적이어야 할 때)
chrt -f 50 ./realtime_task # FIFO, 우선순위 50
# ★ 잘못 쓰면 시스템이 멈춘다 — 무한 루프가 CPU 를 독점
# CPU 고정 (캐시 지역성 · NUMA)
taskset -c 0-3 ./program
이렇게도 쓸 수 있다 — 부하 평균 읽는 법 🟡 중급
$ uptime
15:04:23 up 42 days, load average: 3.21, 2.85, 2.40
1분 5분 15분
★ 리눅스의 load average 는 'CPU 대기' 만이 아니다
실행 가능(R) + 인터럽트 불가 슬립(D, 주로 디스크 I/O) 을 함께 센다
→ load 가 높은데 CPU 는 놀고 있으면 대개 I/O 대기다
해석 기준 (코어 수로 나눈다)
4코어에서 load 4.0 → 사용률 100% 근처
4코어에서 load 8.0 → 대기 줄이 있다
1분 > 15분 → 부하가 증가 중
1분 < 15분 → 완화 중
# load 가 높을 때 원인 좁히기
vmstat 1
# r = 실행 대기 중인 프로세스 수 ← 코어 수보다 크면 CPU 부족
# b = I/O 대기(D 상태) ← 크면 디스크·네트워크 문제
# wa = I/O 대기 시간 비율
# si/so = 스왑 in/out ← 0 이 아니면 메모리 부족
mpstat -P ALL 1 # 코어별 사용률 — 한 코어만 높으면 단일 스레드 병목
pidstat -u 1 # 프로세스별 CPU
# PSI 로 더 정확하게 (커널 4.20+)
cat /proc/pressure/cpu
# some avg10=12.34 ... → 최근 10초간 12% 의 시간 동안 무언가 대기했다
uptime 으로 부하를 보고 →
vmstat 1 로 r 인지 b 인지 가르고 →
r 이면 top/pidstat, b 이면 iostat 로 갑니다.
이 세 단계로 대부분의 원인이 좁혀집니다.
실무에선 이렇게 — 스케줄링이 지연에 나타날 때 🔴 실무
| 증상 | 원인 | 대응 |
|---|---|---|
| p99 만 불규칙하게 튐 | CPU 스로틀링 (cgroup quota) | nr_throttled 확인 · limit 상향 |
| 배치 작업 중 API 가 느려짐 | 우선순위 미분리 | nice · cgroup 분리 · 별도 노드 |
| GC 중 전체가 멈춤 | STW · CPU 부족 | GC 튜닝 · 힙 축소 · 코어 추가 |
| 가끔 수백 ms 지연 | 스케줄 지연 · 스왑 · THP | PSI · perf sched |
| 코어를 늘려도 처리량 그대로 | 단일 스레드 병목 · 락 | mpstat -P ALL |
# 컨테이너 CPU 스로틀링 확인 — 실무에서 가장 자주 놓치는 것
cat /sys/fs/cgroup/cpu.stat
# nr_periods 1000
# nr_throttled 250 ← 25% 의 기간에서 강제로 멈췄다
# throttled_usec 3200000 ← 총 3.2초 멈춤
# 쿠버네티스에서
kubectl top pod
# CPU limit 을 너무 낮게 잡으면 평균 사용률이 낮아도 스로틀링이 난다
# → limit 을 올리거나, 아예 limit 을 빼고 request 로만 관리하는 전략도 있다
# 스케줄 지연 직접 측정
perf sched record -- sleep 10 && perf sched latency
nr_throttled 를 대시보드에 올려 두세요.
컨텍스트 스위치 — 공짜가 아닙니다
스레드를 바꿔 끼우는 데도 비용이 듭니다. 그것도 직접 비용보다 간접 비용(캐시 오염)이 훨씬 큽니다. “스레드를 늘렸는데 느려졌다”의 답이 여기 있습니다.
무슨 일이 벌어지나
직접 비용 (1~5µs)
① 현재 레지스터를 저장
② 스케줄러가 다음 태스크 선택
③ 새 태스크의 레지스터 복원
④ 프로세스가 다르면 페이지 테이블 전환 (+ TLB 플러시)
간접 비용 (훨씬 큼 · 수십~수백 µs)
⑤ ★ 캐시가 새 태스크의 데이터로 채워져야 한다
내 데이터가 L1/L2 에서 밀려나 있다
→ 돌아왔을 때 전부 미스
⑥ TLB 도 마찬가지 (프로세스 전환 시)
⑦ 분기 예측기도 새로 학습해야 함
측정하기
# 시스템 전체
vmstat 1
# cs 컬럼 = 초당 컨텍스트 스위치 수
# 일반 서버: 수천~수만이면 정상
# 수십만이면 의심
# 프로세스별 (자발/비자발 구분)
pidstat -w -p PID 1
# cswch/s 자발 (I/O 대기 · 락 대기 → 정상)
# nvcswch/s 비자발 (시간 조각 소진 → CPU 경합) ★
cat /proc/PID/status | grep ctxt_switches
# 어디서 스위치가 나는지
perf record -e sched:sched_switch -g -- sleep 5
perf report
이렇게도 쓸 수 있다 — 스레드 vs 코루틴 vs 이벤트 루프 🟡 중급
| 모델 | 전환 비용 | 메모리/개 | 동시 개수 | 예 |
|---|---|---|---|---|
| 프로세스 | ~5µs+ | 수 MB | 수백 | fork 서버 |
| 스레드 | 1~5µs | ~8MB 가상 | 수천 | 자바 플랫폼 스레드 |
| 경량 스레드 | ~200ns | 수 KB | 수백만 | 고루틴 · 가상 스레드 |
| 코루틴 | ~100ns | 수백 B~KB | 수백만 | async/await |
| 이벤트 루프 | 없음(콜백) | 수십 B | 수십만 | Node.js · Nginx |
언어별 현재 상황
Go 고루틴 — 런타임이 M:N 스케줄링 (M 고루틴 : N OS 스레드)
Java 21+ 가상 스레드 — 같은 아이디어를 JVM 이 구현
Python asyncio 코루틴 (단일 스레드 이벤트 루프) + GIL 제약
Node.js 단일 스레드 이벤트 루프 + 워커 스레드
Rust async/await — 런타임 선택 (tokio · async-std)
C# async/await — 스레드 풀 위에서
★ 공통 원리: I/O 를 기다릴 때 OS 스레드를 붙잡고 있지 않는다
→ 블로킹 호출 하나가 이 모델을 전부 무너뜨린다
실무에선 이렇게 — 스위치를 줄이는 설계 🔴 실무
| 기법 | 효과 |
|---|---|
| 스레드 풀 크기 조정 | 비자발 스위치 감소 |
| 배치 처리 | 깨어나는 횟수 자체를 줄임 |
| CPU 친화도 고정 | 캐시 유지 · NUMA 지역성 |
| 스핀락 (짧은 임계 구역) | 커널 진입 없이 대기 |
| 락 프리 자료구조 | 블로킹 자체를 제거 |
| 비동기 I/O | 대기 중 스레드를 놓지 않음 |
| 커널 우회 (DPDK · io_uring) | 극단적 저지연 |
# 배치로 깨어나는 횟수 줄이기 — 가장 실용적인 기법
class BatchWriter:
def __init__(self, flush_size=1000, flush_ms=100):
self.buf, self.size, self.ms = [], flush_size, flush_ms
self.last = time.monotonic()
def add(self, item):
self.buf.append(item)
now = time.monotonic()
if len(self.buf) >= self.size or (now - self.last) * 1000 >= self.ms:
self.flush()
def flush(self):
if not self.buf: return
db.bulk_insert(self.buf) # 한 번의 왕복
self.buf.clear(); self.last = time.monotonic()
# 1000건을 개별 INSERT: 1000번의 왕복 + 스위치
# 배치 INSERT: 1번
# → 처리량이 수십 배 차이 난다
메모리 관리 · 페이징 — 4KB 조각으로 나눠 관리
OS 는 메모리를 페이지 단위로 자릅니다. 그래야 흩어진 빈 공간을 모아 쓸 수 있고, 램보다 큰 프로그램을 돌릴 수 있고, 프로세스를 서로 격리할 수 있습니다.
프로세스 메모리 구성
높은 주소
┌──────────────┐
│ 커널 공간 │ (프로세스는 접근 불가)
├──────────────┤
│ 스택 │ ↓ 아래로 자람 · 지역변수 · 호출 프레임
│ ↓ │
│ │
│ ↑ │
│ 힙 │ ↑ 위로 자람 · malloc/new
├──────────────┤
│ BSS │ 초기화 안 된 전역 (0으로 채워짐)
├──────────────┤
│ Data │ 초기화된 전역
├──────────────┤
│ Text (코드) │ 읽기 전용 · 실행 가능
└──────────────┘
낮은 주소
mmap 영역 — 공유 라이브러리 · 큰 할당 · 파일 매핑이 중간에
메모리 지표 읽는 법
| 지표 | 뜻 | 주의 |
|---|---|---|
| VSZ · VIRT | 가상 주소 공간 크기 | 실제 사용량 아님 — 무시해도 됨 |
| RSS · RES | 실제 물리 메모리 | 공유분이 중복 계산됨 |
| PSS | 공유분을 나눠 계산 | 가장 정확 |
| USS | 이 프로세스만의 메모리 | 죽이면 회수되는 양 |
| Shared | 공유 라이브러리 · 페이지 캐시 | 여러 프로세스가 공유 |
# PSS 로 실제 사용량 보기
smem -k -P myapp
cat /proc/PID/smaps_rollup # Pss · Private · Shared
# free 읽는 법
free -h
# available 이 진짜 여유 (buff/cache 는 회수 가능)
# ★ "메모리가 꽉 찼다" 는 대부분 정상 — 캐시로 쓰는 중
# 어디에 메모리를 쓰는지
pmap -x PID | sort -k3 -n | tail -20
이렇게도 쓸 수 있다 — 페이지 테이블과 주소 변환 🟡 중급
가상 주소 48비트를 4단계로 쪼갠다 (x86-64)
[ 9비트 | 9비트 | 9비트 | 9비트 | 12비트 오프셋 ]
PML4 PDPT PD PT 페이지 내 위치
각 단계가 다음 단계 테이블의 주소를 담는다
→ 4번의 메모리 접근 (TLB 미스 시)
왜 다단계인가
· 1단계 평면 테이블이면 2^36 개 엔트리 × 8B = 512GB (불가능)
· 다단계면 실제로 쓰는 부분만 테이블을 만든다 (희소)
페이지 테이블 엔트리에 담긴 것
· 물리 프레임 번호
· 권한 (읽기 · 쓰기 · 실행) ← W^X · NX 비트
· 유효(present) 비트 ← 0 이면 페이지 폴트
· 접근/변경(accessed/dirty) ← 페이지 교체 정책에 사용
· 캐시 정책
권한 비트가 보안이 되는 자리
| NX 비트 | 데이터 영역 실행 금지 → 스택 코드 실행 차단 |
| 읽기 전용 | 코드 영역 변조 방지 · CoW 구현 |
| 가드 페이지 | 스택 오버플로 감지 (접근 불가 페이지) |
| ASLR | 매번 다른 주소에 배치 → 공격 난이도 상승 |
Copy-on-Write
fork() 가 빠른 이유입니다 — 페이지를 복사하지 않고
양쪽 다 읽기 전용으로 표시합니다.
누군가 쓰려고 하면 그때 페이지 폴트가 나고 그 페이지만 복사합니다.
Redis 의 백그라운드 스냅숏(BGSAVE)이 이 원리로 동작합니다.
실무에선 이렇게 — 메모리 누수 찾기 🔴 실무
| 언어 | 도구 | 흔한 원인 |
|---|---|---|
| C · C++ | valgrind · ASan · heaptrack | free 누락 · 예외 경로 |
| 파이썬 | tracemalloc · objgraph · memray | 전역 캐시 · 순환 참조 + __del__ |
| 자바 | 힙 덤프 + MAT · JFR | 정적 컬렉션 · 리스너 미해제 · ThreadLocal |
| JS · Node | Chrome DevTools · clinic | 클로저 · 이벤트 리스너 · 전역 Map |
| Go | pprof heap | 고루틴 누수 · 슬라이스 참조 유지 |
# 파이썬 — 어디서 늘어나는지 찾기
import tracemalloc
tracemalloc.start(10) # 스택 10프레임까지
snap1 = tracemalloc.take_snapshot()
run_workload()
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "traceback")[:5]:
print(stat)
for line in stat.traceback.format(): print(" ", line)
# 언어 무관 — RSS 추세를 먼저 확인
while true; do
echo "$(date +%s) $(grep VmRSS /proc/$PID/status | awk '{print $2}')"
sleep 60
done | tee rss.log
# 계속 우상향하면 누수, 톱니 모양이면 GC 가 도는 정상 패턴
# 네이티브 누수 (파이썬·자바에서도 C 확장이 샐 수 있다)
LD_PRELOAD=/usr/lib/libjemalloc.so MALLOC_CONF=prof:true ./app
가상 메모리 · 페이지 폴트 — 스왑이 시작되면 끝입니다
메모리에 없는 페이지를 건드리면 페이지 폴트가 납니다. 대부분은 정상이고 아주 빠르지만, 디스크까지 가야 하는 폴트는 서비스를 멈춥니다.
폴트의 종류
| 종류 | 내용 | 비용 |
|---|---|---|
| 마이너 폴트 | 메모리에는 있는데 매핑만 없음 | ~1µs 정상 |
| 메이저 폴트 | 디스크에서 읽어야 함 | ~50µs~10ms ★ |
| CoW 폴트 | 쓰기 시 복사 | 마이너 + 복사 |
| 세그먼테이션 폴트 | 권한 없는 접근 | 프로세스 종료 |
# 폴트 관찰
ps -o min_flt,maj_flt,cmd -p PID
sar -B 1 # majflt/s 컬럼
vmstat 1 # si/so — 스왑 in/out
# ★ 마이너 폴트는 많아도 정상이다
# (지연 할당 · 페이지 캐시 매핑 · CoW)
# ★ 메이저 폴트가 지속되면 메모리 부족 신호
스왑과 스래싱
메모리가 부족하면 OS 는 잘 안 쓰는 페이지를 디스크로 내린다
정상: 진짜 안 쓰는 페이지만 나감 → 문제 없음
스래싱: 내리자마자 다시 필요해짐 → 계속 왕복
→ CPU 는 노는데 아무 일도 진행이 안 됨
→ 응답 시간이 수십 초로
판단
vmstat 1 에서 si/so 가 지속적으로 0 이 아니면 위험
대응 (순서대로)
① 메모리를 실제로 쓰는 프로세스를 줄인다
② 힙 크기 · 캐시 크기 설정을 낮춘다
③ 메모리를 늘린다
④ swappiness 조정 (0 이 아니라 1~10 권장)
sysctl vm.swappiness=10
swappiness=1,
쿠버네티스 노드는 전통적으로 스왑을 껐지만
최근에는 제한적으로 허용하는 방향입니다.
이렇게도 쓸 수 있다 — OOM Killer 🟡 중급
# 프로세스가 갑자기 사라졌다면 먼저 이걸 본다
dmesg -T | grep -i -E "oom|killed process"
journalctl -k | grep -i oom
# 출력 예
# Out of memory: Killed process 1234 (java) total-vm:8000000kB,
# anon-rss:6000000kB, file-rss:0kB, shmem-rss:0kB, UID:1000
# 어떤 프로세스가 먼저 죽을지 (점수가 높을수록 먼저)
cat /proc/PID/oom_score
cat /proc/PID/oom_score_adj # -1000 ~ 1000 (조정값)
# 중요한 프로세스를 보호
echo -500 > /proc/PID/oom_score_adj
# 컨테이너에서는 cgroup 메모리 한도 초과 시 그 안에서만 kill
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.events # oom_kill 카운트
| 상황 | 진짜 원인 |
|---|---|
| 컨테이너가 반복 재시작 (OOMKilled) | limit 부족 또는 앱 누수 — 둘을 구분해야 |
| 자바가 limit 을 넘어 죽음 | 힙 외 메모리(메타스페이스 · 스레드 스택 · 네이티브) |
| 메모리 여유가 있는데 OOM | cgroup 한도 · 단편화 · 큰 연속 할당 실패 |
| 호스트 전체가 느려짐 | 페이지 캐시 회수 압박 · 스래싱 |
# 자바 컨테이너의 전형적 설정 (힙만 잡으면 안 된다)
# 컨테이너 limit 이 2Gi 일 때
JAVA_OPTS="-XX:MaxRAMPercentage=70.0 \
-XX:MaxMetaspaceSize=256m \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/dumps"
# 힙 70% + 메타스페이스 + 스레드 스택 + 네이티브 = 100% 안쪽으로
실무에선 이렇게 — 페이지 캐시 이해하기 🔴 실무
# 파일을 읽으면 OS 가 자동으로 캐시한다 (페이지 캐시)
free -h
# total used free shared buff/cache available
# Mem: 31Gi 8Gi 1Gi 0.1Gi 22Gi 22Gi
# ↑ 이게 캐시
# free 가 적어도 available 이 크면 정상이다
# 특정 파일이 캐시에 얼마나 있는지
vmtouch -v /var/lib/postgresql/data/base/16384/2619
# 캐시 비우기 (테스트용 — 운영에서는 절대 금지)
sync; echo 3 > /proc/sys/vm/drop_caches
| 알아 두면 유용한 것 | 이유 |
|---|---|
| DB 는 자체 캐시 + 페이지 캐시를 둘 다 씀 | 이중 캐싱 — shared_buffers 를 램의 25% 정도로 |
| 첫 쿼리만 느린 이유 | 캐시가 비어 있음 (콜드 스타트) |
| 배포 직후 느린 이유 | 페이지 캐시 · JIT · 커넥션 풀이 다 비어 있음 |
| 큰 파일 복사 후 서비스가 느려짐 | 캐시가 그 파일로 밀림 → posix_fadvise(DONTNEED) |
| 컨테이너 메모리에 캐시가 포함됨 | cgroup 은 페이지 캐시도 센다 |
동기화 — 뮤텍스 · 세마포어 · 조건 변수
여러 스레드가 같은 것을 동시에 건드리지 못하게 막는 장치들입니다. 무엇을 고르느냐보다 임계 구역을 얼마나 짧게 하느냐가 실제 성능을 결정합니다.
도구 고르기
| 도구 | 용도 |
|---|---|
| 뮤텍스 | 한 번에 하나만 — 가장 기본 |
| 스핀락 | 아주 짧은 임계 구역 (커널 내부) |
| 읽기-쓰기 락 | 읽기가 압도적으로 많을 때 |
| 세마포어 | 동시 접근 N개 허용 (자원 풀) |
| 조건 변수 | “어떤 조건이 될 때까지 대기” |
| 배리어 | 모두 도착할 때까지 대기 |
| 원자 연산 | 카운터 · 플래그 — 락 불필요 |
import threading
lock = threading.Lock()
with lock: # ★ with 로 — 예외 시에도 해제
shared_counter += 1
sem = threading.Semaphore(10) # 동시 10개까지
with sem:
call_external_api()
# 조건 변수 — 큐에 항목이 들어올 때까지 대기
cond = threading.Condition()
with cond:
while not queue: # ★ while (if 아님) — 가짜 깨어남 대비
cond.wait()
item = queue.pop()
락을 쓸 때의 규칙
- 임계 구역을 최소로 — I/O · 네트워크 호출을 락 안에 두지 마세요.
- 락 순서를 정하고 지킨다 — 데드락 예방의 가장 확실한 방법(08절).
- 락을 잡은 채 콜백을 부르지 않는다 — 사용자 코드가 무엇을 할지 모릅니다.
- RAII · with 로 해제 보장 — 예외 경로에서 새는 것을 막습니다.
- 가능하면 락 없는 설계 — 불변 데이터 · 스레드 로컬 · 메시지 전달.
✗ 락 안에서 I/O
with lock:
data = fetch_from_api() # 200ms
cache[key] = data
# 모든 스레드가 200ms 씩 대기
✓ 밖에서 하고 짧게 잠금
data = fetch_from_api()
with lock:
cache[key] = data # 1µs
이렇게도 쓸 수 있다 — 락 경합 측정과 완화 🟡 중급
| 완화 기법 | 내용 | 주의 |
|---|---|---|
| 락 분할 (샤딩) | 해시로 나눠 락을 N개로 | 전체 순회 시 복잡 |
| 읽기-쓰기 락 | 읽기끼리는 동시 허용 | 쓰기 기아 주의 |
| 스레드 로컬 누적 | 각자 세고 마지막에 합침 | 실시간 정확도 저하 |
| 불변 자료구조 | 복사 후 교체 (CoW) | 쓰기가 비쌈 |
| 원자 연산 | 카운터는 락 불필요 | 복합 연산엔 부족 |
| 메시지 전달 | 소유권을 한 스레드에 | 설계 변경 필요 |
# 락 분할 — 해시맵을 16개 샤드로
class ShardedCounter:
def __init__(self, shards=16):
self.locks = [threading.Lock() for _ in range(shards)]
self.counts = [0] * shards
self.n = shards
def incr(self, key):
i = hash(key) % self.n
with self.locks[i]: # 다른 샤드는 서로 안 막는다
self.counts[i] += 1
def total(self):
return sum(self.counts) # 근사치 (정확하려면 전부 잠금)
# 자바 ConcurrentHashMap(버킷 단위 잠금) · LongAdder(셀 분할) 가 비슷한 아이디어
# 락 경합 측정
perf lock record ./program && perf lock report
# 자바
# async-profiler -e lock
# JFR: jdk.JavaMonitorEnter 이벤트
# Go
# go tool pprof http://localhost:6060/debug/pprof/mutex
# 파이썬 — GIL 경합
py-spy dump --pid PID # 어느 스레드가 GIL 을 쥐고 있나
실무에선 이렇게 — 우선순위 역전과 그 밖의 함정 🔴 실무
우선순위 역전 (Mars Pathfinder 를 멈추게 한 버그)
① 낮은 우선순위 스레드 L 이 락을 잡음
② 높은 우선순위 스레드 H 가 그 락을 기다림
③ 중간 우선순위 스레드 M 이 L 을 밀어냄
→ H 가 M 때문에 무한정 대기 (우선순위가 뒤집힘)
해결
· 우선순위 상속 — 락을 잡은 L 의 우선순위를 H 만큼 올린다
· 우선순위 상한 — 락마다 최고 우선순위를 지정
· 실시간 시스템에서는 필수 (PTHREAD_PRIO_INHERIT)
| 함정 | 증상 · 대응 |
|---|---|
| 가짜 깨어남 | 조건 없이 깨어남 → while 로 재확인 |
| 잃어버린 신호 | wait 전에 notify → 락 안에서 상태 변경 + notify |
| 천둥 무리 | notify_all 로 전부 깨어나 하나만 성공 → notify 선호 |
| 재진입 불가 | 같은 스레드가 다시 잠금 → 데드락 · RLock 사용 |
| ABBA 데드락 | 락 순서 불일치 → 전역 순서 정의(08절) |
| 락 잡은 채 fork | 자식이 영원히 대기 → pthread_atfork |
데드락 — 네 조건 중 하나만 깨면 됩니다
서로가 서로를 기다려 영원히 멈추는 상태입니다. 네 조건이 동시에 성립할 때만 발생하므로, 하나만 무너뜨리면 구조적으로 예방됩니다.
네 가지 필요 조건
| 조건 | 내용 | 깨는 법 |
|---|---|---|
| 상호 배제 | 한 번에 하나만 | 불변 데이터 · 락 프리 |
| 점유 대기 | 가진 채로 기다림 | 한 번에 전부 잡기 |
| 비선점 | 뺏을 수 없음 | 타임아웃 후 포기 |
| 순환 대기 | 원형으로 기다림 | 락 순서 고정 ★ |
코드로 보기
✗ 순서가 다름
def transfer(a, b, amt):
with a.lock:
with b.lock:
a.bal -= amt
b.bal += amt
# transfer(x, y) 와 transfer(y, x) 가
# 동시에 실행되면 데드락
✓ ID 순으로 고정
def transfer(a, b, amt):
first, second = sorted((a, b), key=lambda x: x.id)
with first.lock:
with second.lock:
a.bal -= amt
b.bal += amt
# 어느 방향이든 항상 같은 순서
# 타임아웃으로 비선점 조건 깨기
if a.lock.acquire(timeout=1.0):
try:
if b.lock.acquire(timeout=1.0):
try: do_work()
finally: b.lock.release()
else:
metrics.inc("lock_timeout") # 포기하고 재시도
finally:
a.lock.release()
이렇게도 쓸 수 있다 — 데드락 찾아내기 🟡 중급
| 환경 | 방법 |
|---|---|
| 자바 | jstack PID — “Found one Java-level deadlock” 을 자동 출력 |
| 파이썬 | py-spy dump --pid · faulthandler.dump_traceback_later |
| Go | 런타임이 전역 데드락 감지 · SIGQUIT 으로 전체 스택 |
| C · C++ | ThreadSanitizer · gdb 에서 thread apply all bt |
| 리눅스 전반 | cat /proc/PID/task/*/stack · eu-stack -p PID |
| DB | 데드락 로그 · SHOW ENGINE INNODB STATUS |
# 자바 — 가장 편하다
jstack -l $(pgrep -f myapp) > stack.txt
grep -A 30 "Found one Java-level deadlock" stack.txt
# 파이썬 — 멈춘 순간의 모든 스레드 스택
py-spy dump --pid 1234
# 또는 코드에 미리 넣어 두기
import faulthandler, signal
faulthandler.register(signal.SIGUSR1) # kill -USR1 PID 로 덤프
# ★ 운영에서 "멈췄다" 는 신고를 받으면
# ① 프로세스가 살아 있는지 (ps)
# ② CPU 를 쓰는지 (top) — 0% 면 대기, 100% 면 무한 루프
# ③ 스레드 덤프 두 번 (10초 간격) — 같은 자리면 블로킹
실무에선 이렇게 — DB 데드락은 다릅니다 🔴 실무
DB 는 데드락을 '예방' 하지 않고 '감지 후 한쪽을 죽인다'
· 대기 그래프에서 사이클을 찾으면 희생자를 롤백
· 애플리케이션은 데드락 에러를 받고 재시도해야 한다
· MySQL: Error 1213 · PostgreSQL: 40P01 · Oracle: ORA-00060
흔한 원인
① 여러 행을 서로 다른 순서로 UPDATE
② 갭 락 · 넥스트키 락 (MySQL 반복 읽기)
③ 인덱스가 없어 넓은 범위를 잠금
④ 긴 트랜잭션 안에 외부 API 호출
대응
· UPDATE 순서를 PK 순으로 고정 (ORDER BY 로 정렬 후)
· 트랜잭션을 짧게 · 외부 호출을 트랜잭션 밖으로
· 적절한 인덱스로 잠금 범위 축소
· ★ 재시도 로직을 반드시 넣는다 (지수 백오프 + 지터)
import random, time
from psycopg2 import errors
def with_retry(fn, retries=3):
for i in range(retries):
try:
return fn()
except errors.DeadlockDetected:
if i == retries - 1: raise
time.sleep(random.uniform(0, 0.1 * (2 ** i))) # 백오프 + 지터
metrics.inc("db_deadlock_retry")
# 순서 고정 — 여러 행을 갱신할 때
ids = sorted(target_ids) # ★ 항상 같은 순서
for pk in ids:
cur.execute("UPDATE accounts SET bal = bal + %s WHERE id = %s", (d, pk))
실무에서 실제로 쓰는 두 가지
① 잠금 순서를 전역으로 고정
// ✗ 계좌 이체 — 데드락이 나는 전형적인 코드
void transfer(Account from, Account to, long amt) {
synchronized (from) {
synchronized (to) { // A→B 와 B→A 가 동시에 오면 멈춥니다
from.minus(amt); to.plus(amt);
}
}
}
// ✓ 항상 같은 순서로 잠급니다 (id 기준)
void transfer(Account a, Account b, long amt) {
Account first = a.id() < b.id() ? a : b;
Account second = a.id() < b.id() ? b : a;
synchronized (first) {
synchronized (second) { // 순환이 구조적으로 불가능
a.minus(amt); b.plus(amt);
}
}
}
// id 가 같을 수 있으면(자기 자신에게 이체) 그 경우를 먼저 걸러 내세요
② 타임아웃 — 못 잡으면 포기하고 되돌린다
// tryLock 으로 비선점 조건을 깹니다
boolean transfer(Account a, Account b, long amt) throws InterruptedException {
for (int i = 0; i < 3; i++) {
if (a.lock.tryLock(50, MILLISECONDS)) {
try {
if (b.lock.tryLock(50, MILLISECONDS)) {
try { a.minus(amt); b.plus(amt); return true; }
finally { b.lock.unlock(); }
}
} finally { a.lock.unlock(); }
}
// ★ 무작위 대기 — 안 넣으면 둘이 계속 같은 리듬으로 충돌합니다
Thread.sleep(ThreadLocalRandom.current().nextInt(10, 50));
}
return false; // 호출자가 재시도·실패 처리
}
| 진단 | 명령 |
|---|---|
| 자바 데드락 | jcmd <pid> Thread.print — "Found one Java-level deadlock" 을 직접 알려 줍니다 |
| DB 데드락 | Postgres pg_locks · MySQL SHOW ENGINE INNODB STATUS |
| 리눅스 프로세스 | cat /proc/<pid>/wchan · py-spy dump |
| 라이브락 | 데드락이 아닌데 진행이 없음 — 백오프에 지터를 넣으세요 |
파일 시스템 — 쓴 것이 정말 저장됐을까
write() 가 성공했다고 데이터가 디스크에 있는 것은 아닙니다.
전원이 나가면 사라집니다. 내구성을 보장하는 지점이 어디인지가
이 절의 핵심입니다.
구조 — 이름과 내용은 따로
디렉터리 항목 아이노드(inode) 데이터 블록
"report.txt" ──▶ · 크기 · 권한 · 시각 ──▶ 실제 내용
"backup.txt" ──┘ · 링크 수
· 블록 위치
★ 이름은 디렉터리에, 메타데이터는 아이노드에, 내용은 블록에
그래서
· 이름 변경(rename)이 순식간 — 디렉터리 항목만 수정
· 하드 링크 = 같은 아이노드를 가리키는 이름이 여러 개
· 열려 있는 파일을 지워도 프로세스는 계속 읽을 수 있다
(링크 수는 0 이지만 열린 참조가 남아 있음)
· ★ 디스크가 안 비는 이유: 지웠는데 프로세스가 아직 열고 있음
lsof | grep deleted
쓰기가 디스크에 닿기까지
앱 버퍼 (fwrite)
↓ fflush
커널 페이지 캐시 (write)
↓ fsync / fdatasync
디스크 쓰기 캐시
↓ FUA · 캐시 플러시
실제 저장 매체 ★ 여기까지 와야 안전
각 단계에서 전원이 나가면
· 앱 버퍼 → 데이터 손실
· 페이지 캐시 → 데이터 손실
· 디스크 캐시 → 손실 (배터리 백업 없으면)
# 안전하게 파일 저장하는 관용구 (원자적 교체)
import os, tempfile
def atomic_write(path, data):
d = os.path.dirname(path)
fd, tmp = tempfile.mkstemp(dir=d) # 같은 파일 시스템에
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
f.flush()
os.fsync(f.fileno()) # ① 내용을 디스크로
os.replace(tmp, path) # ② 원자적 교체
dirfd = os.open(d, os.O_DIRECTORY)
try: os.fsync(dirfd) # ③ 디렉터리도 fsync
finally: os.close(dirfd)
except BaseException:
os.unlink(tmp); raise
이렇게도 쓸 수 있다 — 저널링 · 파일 시스템 선택 🟡 중급
| 파일 시스템 | 특징 | 쓰는 곳 |
|---|---|---|
| ext4 | 안정적 · 기본값 | 대부분의 리눅스 |
| XFS | 큰 파일 · 병렬 I/O 강함 | DB · 대용량 스토리지 |
| Btrfs · ZFS | 스냅숏 · 체크섬 · 압축 | 백업 · NAS |
| overlayfs | 레이어 합성 | 도커 이미지 |
| tmpfs | 메모리 기반 | /dev/shm · 임시 파일 |
| NFS · EFS | 네트워크 | 공유 · 지연·일관성 주의 |
저널링 모드 (ext4)
data=journal 메타데이터 + 데이터 모두 저널 → 가장 안전 · 가장 느림
data=ordered 메타데이터만 저널, 데이터를 먼저 씀 → 기본값 ★
data=writeback 순서 보장 없음 → 빠르지만 크래시 후 쓰레기 가능
마운트 옵션
noatime 읽을 때마다 접근 시각 갱신 안 함 → 쓰기 감소 ★
nodiratime 디렉터리만
discard TRIM 을 즉시 (보통은 fstrim 주기 실행이 낫다)
/etc/fstab 예
/dev/sda1 /data ext4 defaults,noatime 0 2
fsync 의 의미가 달라집니다 —
DB 데이터 디렉터리를 NFS 에 두면 손상 위험이 있습니다.
SQLite 를 네트워크 파일 시스템에 두는 것도 같은 이유로 위험합니다.
실무에선 이렇게 — 디스크 문제 진단 🔴 실무
# ① 용량 — 두 가지를 봐야 한다
df -h # 블록 사용량
df -i # ★ 아이노드 사용량 (작은 파일이 많으면 먼저 참)
# ② 어디가 큰지
du -xh --max-depth=1 / | sort -h | tail -20
ncdu /var # 대화형
# ③ 지웠는데 안 줄어드는 경우
lsof +L1 # 링크 수 0 인 열린 파일
lsof | grep deleted
# → 해당 프로세스를 재시작하거나 로그를 truncate
# ④ 어떤 파일에 쓰고 있는지
fuser -vm /data
lsof +D /var/log
# ⑤ 파일 시스템 오류
dmesg -T | grep -i -E "ext4|xfs|i/o error"
smartctl -a /dev/sda # 디스크 자체의 건강 상태
| 증상 | 원인 |
|---|---|
“No space left” 인데 df 는 여유 | 아이노드 소진 (df -i) |
| 로그를 지웠는데 용량 그대로 | 프로세스가 아직 열고 있음 |
| 쓰기만 느림 | fsync 대기 · 디스크 캐시 정책 |
| 파일 개수가 많은 디렉터리가 느림 | 디렉터리 인덱스 · 하위 디렉터리로 분산 |
| 컨테이너 디스크가 계속 참 | 로그 로테이션 미설정 · overlayfs 레이어 |
rm 하지 말고 truncate -s 0 app.log 를 쓰세요.
프로세스가 열고 있는 파일을 지우면 용량이 안 줄고,
로그도 계속 쌓입니다. 근본적으로는 logrotate 를 설정하세요.
I/O 모델 — 블로킹 · 논블로킹 · epoll
“요청 하나에 스레드 하나”로는 동시 접속 만 개를 감당할 수 없습니다. 그 한계를 넘는 방법이 이벤트 기반 I/O 이고, Nginx · Node.js · Netty 가 전부 이 위에 있습니다.
다섯 가지 모델
| 모델 | 동작 |
|---|---|
| 블로킹 | 데이터가 올 때까지 스레드가 멈춤 |
| 논블로킹 | 없으면 즉시 EAGAIN 반환 → 폴링 필요 |
| I/O 다중화 | 여러 FD 를 한 번에 감시 (select · poll · epoll) |
| 시그널 기반 | 준비되면 시그널 — 거의 안 씀 |
| 비동기 I/O | 커널이 복사까지 끝내고 알려 줌 (io_uring · IOCP) |
select / poll 의 한계
· 매번 감시할 FD 목록을 커널에 통째로 전달 → O(n)
· select 는 FD 1024개 제한
· 준비된 것을 찾으려면 전체를 순회 → O(n)
epoll 의 개선
· 감시 목록을 커널에 등록해 두고 유지 (epoll_ctl)
· 준비된 것만 반환 → O(준비된 개수)
· 10만 연결에서도 성능이 안 떨어진다 ★ C10K 문제 해결
BSD/macOS: kqueue Windows: IOCP Solaris: /dev/poll
epoll 사용 흐름
import selectors, socket
sel = selectors.DefaultSelector() # 플랫폼별 최적 (리눅스=epoll)
srv = socket.socket()
srv.bind(("0.0.0.0", 8080)); srv.listen(1024)
srv.setblocking(False)
sel.register(srv, selectors.EVENT_READ, data=None)
while True:
for key, mask in sel.select(timeout=1.0): # 준비된 것만 반환
if key.data is None: # 새 연결
conn, addr = key.fileobj.accept()
conn.setblocking(False)
sel.register(conn, selectors.EVENT_READ, data=addr)
else: # 데이터 도착
conn = key.fileobj
data = conn.recv(4096)
if not data:
sel.unregister(conn); conn.close()
else:
conn.sendall(process(data))
# 스레드 하나로 수만 연결을 처리한다
# ★ 단, 하나의 요청이 오래 걸리면 나머지 전부가 막힌다
이렇게도 쓸 수 있다 — 레벨 트리거와 엣지 트리거 🟡 중급
레벨 트리거 (LT · 기본)
· 읽을 데이터가 '있는 동안' 계속 알려 준다
· 한 번에 다 안 읽어도 다음 루프에서 또 알려 줌
· 안전하고 구현이 쉽다 ★ 기본 선택
엣지 트리거 (ET)
· '새로 도착했을 때만' 한 번 알려 준다
· 반드시 EAGAIN 이 날 때까지 전부 읽어야 한다
· 안 그러면 남은 데이터를 영원히 못 본다
· 이벤트 수가 적어 성능은 조금 유리
ET 를 쓸 때의 필수 형태
while True:
try: data = conn.recv(4096)
except BlockingIOError: break # EAGAIN → 다 읽음
if not data: close(); break
handle(data)
| 이벤트 루프의 대표 함정 | 대응 |
|---|---|
| CPU 작업으로 루프를 막음 | 워커 스레드/프로세스로 위임 |
| 동기 라이브러리 사용 | 비동기 드라이버로 교체 · executor 로 |
| 느린 클라이언트가 버퍼를 점유 | 쓰기 타임아웃 · 백프레셔 |
| 한 연결이 대량 전송 | 공정 스케줄링 · 청크 단위 처리 |
| 파일 I/O 가 블로킹 | 리눅스는 파일에 epoll 이 안 됨 → 스레드 풀 · io_uring |
io_uring 입니다.
실무에선 이렇게 — io_uring 과 서버 아키텍처 🔴 실무
io_uring (리눅스 5.1+)
· 제출 큐(SQ)와 완료 큐(CQ)를 사용자·커널이 공유 메모리로 함께 봄
· 시스템 콜 없이 요청을 쌓고, 한 번의 호출로 수백 개 제출
· 파일 · 소켓 · 타이머를 모두 같은 인터페이스로
· 폴링 모드면 시스템 콜이 아예 0
효과
· 시스템 콜 비용이 커진 Spectre 이후 특히 유리
· 고성능 스토리지 · 프록시 · DB 에서 채택 증가
· Rust: tokio-uring · glommio · C: liburing
주의
· 커널 버전 의존 · 일부 배포판은 보안상 비활성
· 컨테이너 런타임이 seccomp 로 막는 경우가 많다
| 서버 아키텍처 | 구조 | 예 |
|---|---|---|
| 스레드 per 연결 | 단순 · 연결 수 제한 | 전통적 Apache prefork |
| 이벤트 루프 단일 | CPU 1코어 활용 | Node.js · Redis |
| 이벤트 루프 다중 | 코어마다 루프 + SO_REUSEPORT | Nginx · Envoy |
| 스레드 풀 + 비동기 | 혼합 | Netty · Tomcat NIO |
| 경량 스레드 | 동기 코드처럼 작성 | Go · Java 가상 스레드 |
# SO_REUSEPORT — 여러 프로세스가 같은 포트를 리슨
# 커널이 연결을 분산해 준다 (락 경합 없이 멀티코어 활용)
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
srv.bind(("0.0.0.0", 8080)); srv.listen(1024)
# gunicorn 은 --reuse-port 옵션으로 이 방식을 켭니다
# (기본 --workers 는 부모가 연 소켓 하나를 워커들이 물려받는 방식)
# 무중단 재시작에도 유용 — 새 프로세스가 같은 포트를 열고
# 옛 프로세스를 천천히 종료
시스템 콜 — 커널에게 부탁하는 유일한 창구
파일을 읽든 메모리를 늘리든 커널을 거쳐야 합니다. 그 전환에 비용이 있고, 그래서 호출 횟수를 줄이는 것이 성능 최적화의 큰 축이 됩니다.
라이브러리 함수 vs 시스템 콜
fwrite() → 라이브러리 (버퍼링) → write() → 커널
fwrite 는 버퍼가 찰 때만 write 를 부른다
→ 1000번 fwrite = 몇 번의 write
printf 도 마찬가지
· 터미널이면 줄 단위 버퍼링
· 파이프·파일이면 4KB 블록 버퍼링 ★
→ 그래서 파이프로 넘기면 출력이 늦게 보인다
stdbuf -oL command | grep ... 로 강제 가능
자주 쓰는 시스템 콜
open · read · write · close · lseek
mmap · munmap · brk (메모리)
fork · execve · wait4 (프로세스)
socket · bind · accept (네트워크)
futex (락 대기) ← 경합이 있으면 급증
clock_gettime (시간) ← vDSO 로 커널 진입 없이
비용과 줄이는 법
# 어떤 시스템 콜에 시간을 쓰는지
strace -c -f ./program
# % time seconds calls syscall
# 45.2 1.203 50000 write ← 5만 번?
# 30.1 0.802 50000 futex ← 락 경합
# ...
# 특정 콜만 추적
strace -e trace=openat,read -f -p PID
# 오버헤드가 큰 strace 대신 (운영에서는 이쪽)
perf trace -p PID
bpftrace -e 'tracepoint:syscalls:sys_enter_write { @[comm] = count(); }'
| 줄이는 방법 | 효과 |
|---|---|
| 버퍼링 (기본 버퍼드 I/O) | write 횟수 1/1000 |
writev · readv | 여러 버퍼를 한 번에 |
sendfile · splice | 복사 + 호출 동시 감소 |
io_uring | 수백 개를 한 번에 제출 |
| vDSO (시간 조회) | 커널 진입 없이 |
| 배치 API (DB · 캐시) | 왕복 자체를 감소 |
이렇게도 쓸 수 있다 — 시그널 — 비동기 알림 🟡 중급
| 시그널 | 의미 | 실무 |
|---|---|---|
| SIGTERM | 정중한 종료 요청 | 그레이스풀 셧다운 처리 필수 |
| SIGKILL | 강제 종료 | 잡을 수 없음 |
| SIGINT | Ctrl+C | 개발 중 종료 |
| SIGHUP | 터미널 종료 · 관례상 설정 재로드 | nginx -s reload |
| SIGPIPE | 끊긴 파이프에 쓰기 | 무시하고 EPIPE 로 처리 |
| SIGSEGV | 잘못된 메모리 접근 | 크래시 · 코어 덤프 |
| SIGCHLD | 자식 종료 | 좀비 회수 |
| SIGUSR1/2 | 사용자 정의 | 덤프 · 로그 레벨 변경 |
# 그레이스풀 셧다운 — 컨테이너 환경의 필수 요소
import signal, sys, threading
shutdown = threading.Event()
def handle(signum, frame):
print(f"신호 {signum} 수신 — 새 요청 거부, 진행 중 작업 완료 대기")
shutdown.set()
signal.signal(signal.SIGTERM, handle)
signal.signal(signal.SIGINT, handle)
# 서버 루프
while not shutdown.is_set():
handle_request()
drain_in_flight(timeout=25) # ★ 쿠버네티스 기본 유예는 30초
close_connections()
sys.exit(0)
terminationGracePeriodSeconds(기본 30초) 뒤
SIGKILL 을 보냅니다. 그 사이에 진행 중 요청을 마치고
새 요청을 안 받아야 무중단 배포가 됩니다 → 🏛️ SW 공학 11.
실무에선 이렇게 — 권한과 자원 한도 🔴 실무
# ulimit — 프로세스별 자원 한도
ulimit -a
ulimit -n # 열 수 있는 파일 수 ★ 가장 자주 문제
ulimit -n 65536 # 셸에서 임시 변경
# 실제 적용값은 프로세스별로 확인
cat /proc/PID/limits | grep "open files"
# systemd 서비스에서
# [Service]
# LimitNOFILE=65536
# LimitNPROC=4096
# 도커
docker run --ulimit nofile=65536:65536 ...
# 커널 전체 한도
sysctl fs.file-max
sysctl fs.nr_open
| 에러 | 원인 | 확인 |
|---|---|---|
| Too many open files | FD 한도 · FD 누수 | ls /proc/PID/fd | wc -l |
| Cannot allocate memory | 메모리 · vm.max_map_count | sysctl vm.max_map_count |
| Resource temporarily unavailable | 프로세스·스레드 한도 | ulimit -u |
| Address already in use | TIME_WAIT · 다른 프로세스 | ss -lntp |
| Permission denied | 권한 · SELinux · AppArmor | ausearch -m avc |
# FD 누수 찾기 — 가장 흔한 운영 이슈 중 하나
watch -n 5 'ls /proc/$(pgrep -f myapp)/fd | wc -l'
# 계속 늘어나면 누수
ls -l /proc/PID/fd | awk '{print $11}' | sort | uniq -c | sort -rn | head
# 어떤 종류가 늘어나는지 (소켓? 파일? 파이프?)
# 원인은 대부분
# · 응답 객체를 close 하지 않음 (HTTP 클라이언트)
# · 커넥션 풀을 매번 새로 생성
# · 예외 경로에서 finally 누락
vm.max_map_count 를 요구하는 이유가
이 항목입니다 — mmap 으로 인덱스를 여는데 기본 한도(65530)로는 부족합니다.
설치 문서의 커널 파라미터 요구는 대부분 이런 이유가 있습니다.
컨테이너 — 가상 머신이 아닙니다
컨테이너는 같은 커널 위에서 시야만 가린 프로세스입니다. 이 한 문장을 이해하면 컨테이너의 장점과 한계가 전부 유도됩니다.
두 가지 커널 기능의 조합
| 기능 | 역할 |
|---|---|
| 네임스페이스 | “무엇을 볼 수 있나” — 격리 |
| cgroup | “얼마나 쓸 수 있나” — 제한 |
| capabilities | 루트 권한을 쪼개서 부여 |
| seccomp | 허용할 시스템 콜 제한 |
| overlayfs | 이미지 레이어 합성 |
네임스페이스 종류
pid — 프로세스 ID 공간 (컨테이너 안에서는 PID 1)
mount — 파일 시스템 뷰
net — 네트워크 인터페이스 · 포트
uts — 호스트명
ipc — 공유 메모리 · 세마포어
user — UID/GID 매핑 (루트리스 컨테이너)
cgroup — cgroup 계층 뷰
time — 시계 (최근 추가)
★ 커널은 공유한다 → VM 보다 가볍지만 격리는 약하다
직접 만들어 보기
# 네임스페이스를 직접 만들면 '컨테이너 비슷한 것' 이 된다
sudo unshare --pid --net --mount --uts --ipc --fork --mount-proc bash
hostname mycontainer # uts 네임스페이스 — 호스트에 영향 없음
ps aux # pid 네임스페이스 — 자기 프로세스만 보임
ip addr # net 네임스페이스 — lo 만 있음
# cgroup 으로 제한 걸기 (v2)
sudo mkdir /sys/fs/cgroup/mygroup
echo "200000 1000000" | sudo tee /sys/fs/cgroup/mygroup/cpu.max # 0.2 코어
echo "268435456" | sudo tee /sys/fs/cgroup/mygroup/memory.max # 256MB
echo $$ | sudo tee /sys/fs/cgroup/mygroup/cgroup.procs
# 실행 중인 컨테이너의 네임스페이스에 들어가기
sudo nsenter -t $(docker inspect -f '{{.State.Pid}}' mycontainer) -n -p bash
nsenter 는 디버깅에 아주 유용합니다 —
컨테이너에 curl · tcpdump 가 없어도
호스트의 도구로 그 컨테이너의 네트워크 네임스페이스에
들어가 진단할 수 있습니다.
이렇게도 쓸 수 있다 — 컨테이너에서 자주 겪는 문제 🟡 중급
| 증상 | 원인 | 대응 |
|---|---|---|
| CPU 사용률은 낮은데 느림 | CPU 스로틀링 | cpu.stat 의 nr_throttled |
| OOMKilled 반복 | limit 부족 · 힙 설정 | RSS 추세 확인 (06절) |
| 스레드가 과도하게 생성됨 | 호스트 코어 수를 읽음 | cgroup 인식 · 명시적 설정 |
| 좀비 프로세스 누적 | PID 1 이 회수 안 함 | --init · tini |
| SIGTERM 이 앱에 안 감 | 셸이 PID 1 | exec 형식 ENTRYPOINT |
| DNS 가 느림 | ndots 설정 · 검색 도메인 | ndots:1 · FQDN 사용 |
| 디스크가 계속 참 | 로그 · 레이어 · 볼륨 | 로그 드라이버 제한 · prune |
# ✗ 셸 형식 — 셸이 PID 1 이 되어 시그널이 앱에 안 간다
CMD python app.py
# ✓ exec 형식 — 앱이 PID 1
CMD ["python", "app.py"]
# ✓ 시그널 처리와 좀비 회수를 함께
ENTRYPOINT ["tini", "--"]
CMD ["python", "app.py"]
# 또는 docker run --init / 쿠버네티스는 shareProcessNamespace 등
/bin/sh 가 PID 1 이 되고,
그 셸은 SIGTERM 을 자식에게 전달하지 않습니다.
결과적으로 매 배포마다 요청이 강제 종료됩니다.
실무에선 이렇게 — 격리 수준과 보안 🔴 실무
| 방식 | 격리 | 오버헤드 | 쓰는 곳 |
|---|---|---|---|
| 컨테이너 | 네임스페이스 (커널 공유) | 거의 없음 | 일반적인 배포 |
| gVisor | 사용자 공간 커널 | 중간 | 멀티테넌트 |
| Firecracker · Kata | 경량 VM (커널 분리) | 작음 | 서버리스 · 신뢰 못할 코드 |
| VM | 완전 분리 | 큼 | 강한 격리 필요 |
# 컨테이너 보안 기본 설정
securityContext:
runAsNonRoot: true # 루트로 실행 금지
runAsUser: 10001
readOnlyRootFilesystem: true # 쓰기는 볼륨으로만
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"] # 모든 capability 제거
add: ["NET_BIND_SERVICE"] # 필요한 것만 (1024 미만 포트)
seccompProfile:
type: RuntimeDefault # 위험한 시스템 콜 차단
리눅스 실전 진단 — 15분 안에 범위 좁히기
“서버가 느리다”는 신고를 받았을 때 어떤 명령을 어떤 순서로 치는지입니다. 이 절만 따로 북마크해 두어도 좋습니다.
첫 60초 — 넷플릭스 체크리스트
uptime # ① 부하 추세 (1분 vs 15분)
dmesg -T | tail -20 # ② 커널 에러 · OOM · 하드웨어
vmstat 1 5 # ③ r(CPU 대기) · b(I/O 대기) · si/so(스왑)
mpstat -P ALL 1 3 # ④ 코어별 — 한 코어만 높으면 단일 스레드 병목
pidstat 1 3 # ⑤ 프로세스별 CPU
iostat -xz 1 3 # ⑥ 디스크 — await · %util
free -h # ⑦ 메모리 — available 을 볼 것
sar -n DEV 1 3 # ⑧ 네트워크 처리량
sar -n TCP,ETCP 1 3 # ⑨ TCP 재전송 · 연결 실패
top # ⑩ 전체 그림
CPU 가 병목일 때
top -H -p PID # 스레드별 CPU
pidstat -t -p PID 1 # 같은 것 (스레드 분해)
# 어느 함수인가
perf top -p PID # 실시간
perf record -F 99 -g -p PID -- sleep 30
perf report --stdio | head -40
# 언어별 프로파일러 (더 읽기 쉽다)
py-spy top --pid PID # 파이썬
async-profiler -d 30 -e cpu PID # 자바
go tool pprof http://:6060/debug/pprof/profile # Go
node --cpu-prof app.js # Node
# IPC 로 '기다리는 중' 인지 판단 (🖥️ 컴퓨터구조 09)
perf stat -p PID -- sleep 10
I/O 가 병목일 때
iostat -xz 1
# await 요청당 평균 대기 (ms) — 높으면 디스크 문제
# r_await / w_await 로 읽기·쓰기 구분
# aqu-sz 큐 길이
iotop -oPa # 어느 프로세스가 I/O 를 쓰나
pidstat -d 1
# 어느 파일인지
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat
{ printf("%s %s\n", comm, str(args->filename)); }'
# 블로킹된 스레드 (D 상태)
ps -eo state,pid,cmd | awk '$1=="D"'
cat /proc/PID/stack # 커널 어디서 멈췄나
# PSI — 얼마나 기다리는지
cat /proc/pressure/io
이렇게도 쓸 수 있다 — 네트워크 · 메모리 🟡 중급
네트워크
ss -s # 소켓 요약 (상태별 개수)
ss -tan state time-wait | wc -l
ss -ltnp # 리슨 중인 포트와 프로세스
ss -tanp | grep ESTAB | wc -l
# accept 큐가 넘치는지 (연결이 거절됨)
ss -lnt # Recv-Q = 대기 중, Send-Q = backlog 크기
netstat -s | grep -i -E "listen|overflow"
sysctl net.core.somaxconn
# 재전송 · 드롭
sar -n ETCP 1
nstat -az | grep -i retrans
ethtool -S eth0 | grep -i -E "drop|err"
메모리
free -h # available 을 볼 것
cat /proc/meminfo | head -20
smem -k -P myapp # PSS 기준
pmap -x PID | tail -1
# 슬랩(커널 메모리)
slabtop -o | head -10
# 어떤 프로세스가 늘어나는지
ps -eo pid,rss,cmd --sort=-rss | head -10
# 페이지 폴트
sar -B 1
ps -o min_flt,maj_flt,cmd -p PID
# OOM 이력
dmesg -T | grep -i oom
cat /sys/fs/cgroup/memory.events
실무에선 이렇게 — 증상 → 원인 매핑표 🔴 실무
| 증상 | 먼저 확인 | 흔한 원인 |
|---|---|---|
| load 는 높은데 CPU 는 낮음 | vmstat b 컬럼 | 디스크 I/O 대기 · NFS |
| CPU 100% 인데 처리량 낮음 | perf stat IPC | 캐시 미스 · 스핀락 · GC |
| 한 코어만 100% | mpstat -P ALL | 단일 스레드 · 인터럽트 편중 |
| 메모리 여유 있는데 OOM | cgroup limit | 컨테이너 한도 · 단편화 |
| 가끔 수 초 멈춤 | GC 로그 · vmstat si/so | GC STW · 스왑 · THP |
| Too many open files | /proc/PID/fd | FD 누수 · 한도 |
| 연결이 간헐적으로 거절 | ss -lnt Recv-Q | accept 큐 넘침 · somaxconn |
| 재시작 직후만 느림 | 정상 | 캐시 · JIT 워밍업 |
| 특정 시각마다 느림 | cron · 배치 | 백업 · 컴팩션 · 로그 로테이션 |
| 디스크 여유 있는데 쓰기 실패 | df -i | 아이노드 소진 |
# 장애 시 한 번에 스냅숏 뜨기 (나중에 분석용)
mkdir -p /tmp/diag-$(date +%s) && cd $_
uptime > uptime.txt
dmesg -T | tail -200 > dmesg.txt
vmstat 1 10 > vmstat.txt
mpstat -P ALL 1 5 > mpstat.txt
iostat -xz 1 5 > iostat.txt
free -h > free.txt
ps auxf > ps.txt
ss -s > ss.txt
top -bn1 > top.txt
cat /proc/pressure/* > psi.txt 2>/dev/null
# ★ 장애가 지나가면 증거도 사라진다. 먼저 모으고 나중에 본다.
스케줄러 내부 — CFS 의 vruntime 에서 EEVDF 까지
03절이 “무엇을 먼저 실행할까”라는 개념이었다면, 여기서는 리눅스가 실제로 쓰는 방식을 봅니다.
CFS 는 “누가 가장 적게 썼나”만 보고 고르며, 그 판단 기준이 vruntime 입니다.
6.6 부터 기본이 된 EEVDF 는 여기에 “언제까지 줘야 하나”를 더했습니다.
이 절의 실질적인 목적은 하나입니다 — 컨테이너의 CPU 스로틀링이
평균 사용률이 낮은데도 왜 p99 를 망가뜨리는지 설명할 수 있게 되는 것.
vruntime 입니다.
지금 이 서버에서 직접 확인하기
# 이 프로세스가 어떤 정책으로 도는가
chrt -p $$ # SCHED_OTHER = CFS/EEVDF
# 태스크 하나의 스케줄링 통계 — 진단의 출발점
grep -E 'vruntime|sum_exec|wait_sum|nr_switches' /proc/$$/sched
# se.vruntime 가상 실행 시간
# se.sum_exec_runtime 실제로 CPU 를 쓴 누적 시간
# se.statistics.wait_sum ★ 큐에서 기다린 총 시간
# nr_involuntary_switches ★ 강제로 뺏긴 횟수
# ★ wait_sum 이 sum_exec_runtime 에 근접하면
# "일하는 시간만큼 기다리고 있다" = CPU 경합입니다
# 런큐에 몇 개나 밀려 있나
vmstat 1 5 # r 열 = 실행 대기 중 태스크 수
# r 이 코어 수보다 꾸준히 크면 CPU 부족입니다
# 커널이 노출하는 노브 (6.6+ 는 이름이 바뀌었습니다)
ls /sys/kernel/debug/sched/
# base_slice_ns ← EEVDF (6.6+)
# latency_ns, min_granularity_ns ← 그 이전 CFS
# 몫 조정 — 절대 시간이 아니라 '가중치 비율' 로 작동합니다
nice -n 10 ./batch_job & # 가중치 110 → 약 1/9 몫
taskset -c 0-3 ./worker # 특정 코어에 고정 (캐시 지역성)
wait_sum 과 vmstat 의 r 을 보세요 —
전자는 “기다린 시간”, 후자는 “줄 서 있는 수”라
평균 사용률이 숨기는 지연을 그대로 드러냅니다.
vruntime 계산과 몫의 관계
nice −5 → 3121 → 1/3 속도로 늘어남 → 3배 자주 뽑힘
nice +5 → 335 → 3배 속도로 늘어남 → 1/3
가중치 표는 공비 1.25 의 등비수열입니다 — 한 단계 = 약 10% 몫 차이, 다섯 단계 = 약 3배
몫i = wi ÷ Σwj — 절대 시간이 아니라 비율이라 경쟁자가 없으면 nice 19 도 CPU 를 다 씁니다
| 궁금한 것 | 답 |
|---|---|
| 새 태스크의 vruntime | 큐의 최솟값 근처로 맞춤 (안 그러면 독점) |
| 잠에서 깬 태스크 | 약간 깎아 줌 → 대화형이 빠릿함 |
| 한 번에 얼마나 | 슬라이스 = 목표지연 × 내 가중치 비율 |
| 너무 잘게 쪼개면 | 컨텍스트 스위치 비용 폭증(04절) |
| 그래서 하한이 | min_granularity / base_slice |
| 코어 간 이동 | 부하 분산 — 캐시를 잃어 비쌈 |
SCHED_FIFO · SCHED_RR 은
CFS 태스크보다 무조건 먼저 실행됩니다.
그래서 실시간 우선순위를 잘못 주면 시스템 전체가 멈춘 것처럼 보입니다 —
커널이 rt_runtime_us 로 95% 상한을 두는 이유입니다.
EEVDF — “얼마나”에 “언제까지”를 더하기
컨테이너에서 실제로 만나는 형태
# cgroup v2 — 쿠버네티스의 requests/limits 가 여기로 내려옵니다
cat /sys/fs/cgroup/cpu.max
# "200000 100000" = 100ms 주기마다 200ms → 2코어 (limits)
# "max 100000" = 상한 없음
cat /sys/fs/cgroup/cpu.weight
# 100 이 기본 (nice 0). requests 가 이 값으로 바뀝니다
# → 경합할 때만 의미가 있는 '비율' 입니다
# ★ 스로틀링이 걸리고 있는가 — 지연 장애의 1순위 원인
cat /sys/fs/cgroup/cpu.stat
# nr_periods 전체 주기 수
# nr_throttled ★ 쿼터를 다 써서 멈춘 주기 수
# throttled_usec ★ 멈춰 있던 총 시간
# 비율로 보기 — 5% 를 넘으면 p99 에서 보입니다
awk '/nr_periods/{p=$2} /nr_throttled/{t=$2}
END{printf "throttled %.1f%%\n", t*100/p}' /sys/fs/cgroup/cpu.stat
# 프로메테우스에서는 같은 값이 이 이름으로 옵니다
# container_cpu_cfs_throttled_periods_total
# container_cpu_cfs_periods_total
스로틀링을 없애는 순서
| 할 일 | 이유 |
|---|---|
| ① 런타임에 한도 알리기 | 스레드 수가 코어 수를 안 넘게 |
| ② limit 을 올리기 | 버스트가 정상 패턴이면 한도가 잘못된 것 |
| ③ 주기 줄이기 | 정산 단위가 짧아져 멈추는 덩어리가 작아짐 |
| ④ limit 을 없애기 | requests 만 두고 weight 로 경쟁시키기 |
| 피할 것 | replica 만 늘리기 — 스로틀은 그대로입니다 |
# ① 런타임이 '호스트의 코어 수' 를 보는 문제를 먼저 막습니다
# cgroup 한도가 2코어인데 128코어짜리 호스트로 오인하면
# 스레드 풀이 128개로 잡혀 순식간에 쿼터를 태웁니다
export GOMAXPROCS=2 # Go — automaxprocs 권장
java -XX:ActiveProcessorCount=2 -jar app.jar # JVM
export UV_THREADPOOL_SIZE=4 # Node
export OMP_NUM_THREADS=2 # OpenMP · numpy 계열
# ② 쿠버네티스에서 확인
kubectl get pod api-0 -o jsonpath='{.spec.containers[0].resources}'
# ③ 노드 전체에서 스로틀 상위 컨테이너 찾기
for d in /sys/fs/cgroup/kubepods.slice/*/*/cpu.stat; do
awk -v n="$d" '/nr_periods/{p=$2} /nr_throttled/{t=$2}
END{if(p>0 && t*100/p>5) printf "%5.1f%% %s\n", t*100/p, n}' "$d"
done | sort -rn | head
requests(= cpu.weight)만 두면
경합할 때만 비율대로 나누고 한가할 때는 마음껏 쓰게 됩니다 —
CFS 의 몫 계산이 원래 그런 구조입니다.
다만 한 파드가 노드를 독점하는 사고가 가능해지므로
노드 단위 격리와 LimitRange 를 함께 두고 판단하세요.
“연결이 안 됩니다”를
계층으로 나눠
좁히는 법
네트워크는 층으로 나눠 생각하면 갑자기 다룰 만해집니다. 이 탭은 IP · TCP · DNS · HTTP · TLS 를 각각 “무엇을 보장하고 무엇은 안 하는가”로 정리하고, 서브넷 계산기와 진단 명령 순서까지 함께 담았습니다. 지연의 물리적 한계를 다루는 절이 특히 설계에 직접 쓰입니다.
OSI 7계층 · TCP/IP — 문제를 층으로 나누기
계층 모델의 진짜 가치는 암기가 아니라 문제 위치를 좁히는 도구라는 데 있습니다. “안 된다”는 신고를 받았을 때 어느 층인지부터 정하면 절반이 끝납니다.
curl -v · 브라우저 개발자 도구 · 앱 로그ss · telnet · nc · 재전송률ping · traceroute · ip routeip neigh · arpingethtool · 링크 상태 · 에러 카운터캡슐화 — 헤더가 겹겹이
애플리케이션 데이터
[HTTP 헤더][본문]
↓ TCP 가 감싼다
[TCP 헤더][HTTP 헤더][본문] ← 세그먼트
↓ IP 가 감싼다
[IP 헤더][TCP 헤더][...] ← 패킷
↓ 이더넷이 감싼다
[이더넷 헤더][IP 헤더][...][FCS] ← 프레임
받는 쪽은 반대로 하나씩 벗긴다
오버헤드
이더넷 14B + IP 20B + TCP 20B = 54B
MTU 1500B 기준 → 실제 데이터 최대 1460B (MSS)
→ 작은 요청이 많으면 헤더 비중이 커진다
“안 된다”를 층으로 가르기
- 물리 · 링크 —
ip link로 UP 인지,ethtool로 에러가 있는지 - 네트워크(L3) —
ping이 가나? 라우팅 테이블이 맞나? - 전송(L4) —
nc -zv host port로 포트가 열려 있나? - 이름 —
dig로 DNS 가 맞게 나오나? - 보안 — 방화벽 · 보안 그룹 · SELinux
- 응용(L7) —
curl -v로 응답이 오나? 상태 코드는?
이렇게도 쓸 수 있다 — 계층이 깨지는 자리 🟡 중급
| 현실 | 계층 위반 |
|---|---|
| NAT | L3 장비가 L4 포트를 고쳐 씀 |
| L7 로드밸런서 | HTTP 헤더를 보고 라우팅 |
| TLS 종료 | 중간 장비가 암호를 풀어 봄 |
| MTU · 단편화 | L2 크기가 L4 성능을 좌우 |
| QUIC | L4 를 UDP 위 사용자 공간에 구현 |
| 서비스 메시 | 사이드카가 모든 층에 개입 |
MTU 문제 — 계층이 새는 대표 사례
MTU 1500 인 구간을 지나야 하는데
VPN·터널이 헤더를 추가해 실제 여유가 1400 이라면
· 작은 요청은 잘 된다
· 큰 응답에서만 멈춘다 ★ "가끔 안 된다" 의 원인
· Path MTU Discovery 가 ICMP 로 알려 주는데
방화벽이 ICMP 를 막으면 → 블랙홀
확인
ping -M do -s 1472 target # 1472 + 28 = 1500
ip link show # MTU 값
tracepath target # 경로상 MTU
fragmentation-needed 타입은 허용해야 합니다.
실무에선 이렇게 — 한 번의 요청에서 벌어지는 일 🔴 실무
브라우저에 주소를 치면
- DNS 조회 — 캐시 → 리졸버 → 루트 → TLD → 권한 서버 (07절)
- TCP 연결 — 3-way 핸드셰이크 (1 RTT) (04절)
- TLS 핸드셰이크 — 인증서 검증 · 키 교환 (1~2 RTT) (09절)
- HTTP 요청 · 응답 — 서버 처리 시간 + 1 RTT (08절)
- 렌더링 — HTML 파싱 · 추가 리소스 요청 (반복)
# 각 단계의 시간을 직접 재기
curl -w '
DNS 조회 %{time_namelookup}s
TCP 연결 %{time_connect}s
TLS 핸드셰이크 %{time_appconnect}s
첫 바이트까지 %{time_starttransfer}s
전체 %{time_total}s
' -o /dev/null -s https://example.com
# 출력 예 (서울에서 미국 서버)
# DNS 조회 0.025s
# TCP 연결 0.180s ← 1 RTT
# TLS 핸드셰이크 0.520s ← 2 RTT 더
# 첫 바이트까지 0.720s ← 서버 처리 + 1 RTT
# 전체 0.850s
# ★ 실제 콘텐츠 전송보다 '왕복'이 대부분을 차지한다 → 12절
curl -w 한 줄이 성능 진단의 시작점입니다 —
느린 원인이 DNS 인지 · 연결인지 · 서버 처리인지가
즉시 갈립니다. 대시보드에도 이 분해를 넣어 두면 좋습니다.
IP · 서브넷 · NAT — 주소 체계
서브넷 마스크는 “어디까지가 같은 동네인가”를 정합니다. 아래 계산기에 CIDR 을 넣어 보면 범위와 호스트 수가 바로 나옵니다.
CIDR 읽는 법
192.168.10.37/24
↑ 앞에서부터 24비트가 '네트워크 부분'
IP 11000000.10101000.00001010.00100101
마스크 11111111.11111111.11111111.00000000 (/24)
네트워크 192.168.10.0
범위 192.168.10.0 ~ 192.168.10.255
자주 쓰는 크기
/8 16,777,214 호스트 10.0.0.0/8
/16 65,534 호스트 172.16.0.0/16
/24 254 호스트 192.168.1.0/24
/26 62 호스트
/30 2 호스트 (라우터 간 링크)
/32 1 개 (단일 호스트 지정)
★ 사용 가능 = 2^(32-prefix) - 2
(네트워크 주소와 브로드캐스트 주소를 뺀다)
사설 IP 와 NAT
| 대역 | 크기 | 쓰는 곳 |
|---|---|---|
10.0.0.0/8 | 1600만 | 대규모 사내망 · VPC |
172.16.0.0/12 | 100만 | 도커 기본 |
192.168.0.0/16 | 65536 | 가정 · 소규모 |
127.0.0.0/8 | — | 루프백 |
169.254.0.0/16 | — | 링크로컬 · 클라우드 메타데이터 |
100.64.0.0/10 | 400만 | 캐리어 grade NAT |
NAT — 사설 IP 를 공인 IP 로 바꿔 준다
내부 192.168.0.5:51234 ──▶ 공유기 ──▶ 203.0.113.7:40001 ──▶ 인터넷
공유기가 매핑 테이블을 유지한다
결과
· 내부에서 밖으로는 잘 나간다
· 밖에서 안으로는 못 들어온다 (포트 포워딩 필요)
· 매핑에 타임아웃이 있다 → ★ 유휴 연결이 끊긴다
→ TCP keepalive · 애플리케이션 하트비트가 필요한 이유
이렇게도 쓸 수 있다 — 클라우드 VPC 설계 🟡 중급
전형적인 VPC 구성 (10.0.0.0/16)
퍼블릭 서브넷 10.0.0.0/24 (AZ-a) ← ALB · NAT 게이트웨이
10.0.1.0/24 (AZ-b)
프라이빗 서브넷 10.0.10.0/24 (AZ-a) ← 애플리케이션
10.0.11.0/24 (AZ-b)
DB 서브넷 10.0.20.0/24 (AZ-a) ← RDS
10.0.21.0/24 (AZ-b)
설계 시 주의
① ★ 대역이 겹치면 VPC 피어링·VPN 이 불가능하다
→ 회사 전체의 IP 대역 계획을 먼저 세운다
② 서브넷은 넉넉하게 (나중에 못 늘린다)
③ 쿠버네티스는 파드마다 IP 를 쓴다 → /24 로는 부족
④ 도커 기본 브리지(172.17.0.0/16)와 겹치면 사내망 접속이 깨진다
| 흔한 사고 | 원인 |
|---|---|
| VPN 연결 후 특정 서버만 접속 불가 | 대역 충돌 — 라우팅이 로컬로 감 |
| 도커 켜니 사내 서버가 안 됨 | 172.17.0.0/16 충돌 → daemon.json 에서 변경 |
| 파드 IP 고갈 | 서브넷이 작음 · 노드당 IP 예약 |
| NAT 게이트웨이 비용 폭증 | 같은 리전 트래픽도 NAT 를 통과 → VPC 엔드포인트 |
| 메타데이터 서버 접근 (SSRF) | 169.254.169.254 — IMDSv2 강제 |
169.254.169.254 를 기억해 두세요 —
클라우드 인스턴스의 임시 자격 증명이 이 주소에서 나옵니다.
SSRF 취약점이 있으면 공격자가 이 주소를 호출해
클라우드 권한을 탈취할 수 있습니다 → 🔐 보안 · 암호 09.
실무에선 이렇게 — IPv6 와 듀얼 스택 🔴 실무
| 항목 | IPv4 | IPv6 |
|---|---|---|
| 주소 길이 | 32비트 | 128비트 |
| 표기 | 192.168.0.1 | 2001:db8::1 |
| NAT | 필수 | 불필요 (주소가 충분) |
| 브로드캐스트 | 있음 | 없음 (멀티캐스트로) |
| 헤더 | 가변 | 고정 40B (라우터가 빠름) |
| 단편화 | 라우터가 | 송신자만 |
# 코드에서 자주 만나는 함정
# ① 주소 파싱을 정규식으로 하지 말 것
import ipaddress
addr = ipaddress.ip_address("2001:db8::1") # v4/v6 모두 처리
net = ipaddress.ip_network("10.0.0.0/8")
addr in net
# ② URL 에서 IPv6 는 대괄호로
"http://[2001:db8::1]:8080/path"
# ③ 로그·DB 에 IP 를 저장할 때
# VARCHAR(15) 로는 IPv6 가 안 들어간다 → VARCHAR(45) 또는 INET 타입
# ④ 이중 스택에서의 연결 순서 (Happy Eyeballs)
# IPv6 를 먼저 시도하고 300ms 안에 안 되면 IPv4 로
# → 클라이언트 라이브러리가 이걸 지원하는지 확인
# ⑤ localhost 가 ::1 로 해석될 수 있다
# 127.0.0.1 만 리슨하면 접속이 안 된다
socket.getaddrinfo("localhost", 8080)
localhost 가 ::1 로 풀리는데
서버는 IPv4 만 리슨하고 있는 것입니다.
0.0.0.0 vs :: 바인딩을 확인하세요.
이더넷 · ARP — 같은 동네 안에서는 MAC 으로
IP 는 “최종 목적지”이고 MAC 은 “다음 한 홉”입니다. 이 구분을 알면 같은 서브넷 안에서만 안 되는 문제를 바로 좁힐 수 있습니다.
패킷이 실제로 가는 길
내 PC(192.168.0.5) → 8.8.8.8 로 보낼 때
① 목적지가 내 서브넷 안인가?
192.168.0.0/24 안이 아니다 → 게이트웨이로 보낸다
② 게이트웨이(192.168.0.1)의 MAC 주소를 안다?
모르면 ARP 로 물어본다 — "192.168.0.1 누구세요?" (브로드캐스트)
③ 게이트웨이가 응답 — "저요, MAC 은 aa:bb:cc:dd:ee:ff"
④ 프레임을 만든다
[목적지 MAC = 게이트웨이][출발 MAC = 내 것][IP 헤더: 8.8.8.8][...]
⑤ 게이트웨이가 받아서 다음 홉의 MAC 으로 갈아 끼우고 전달
★ IP 는 끝까지 그대로, MAC 은 홉마다 바뀐다
확인 명령
ip addr # 내 IP · MAC
ip route # 라우팅 테이블 (기본 게이트웨이)
ip neigh # ARP 캐시 ★
# 192.168.0.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
arping -I eth0 192.168.0.1 # 특정 IP 의 MAC 확인
ip neigh flush all # ARP 캐시 비우기
# 스위치 포트 · 링크 상태
ethtool eth0 # Speed · Duplex · Link detected
ip -s link show eth0 # 에러 · 드롭 카운터
# 프레임을 직접 보기
sudo tcpdump -i eth0 -n arp
ip neigh 에 FAILED 나 INCOMPLETE 가 보이면
L2 문제입니다 — 상대가 꺼져 있거나, VLAN 이 다르거나,
스위치 포트 설정이 잘못된 것입니다.
이렇게도 쓸 수 있다 — L2 에서 생기는 문제들 🟡 중급
| 문제 | 내용 | 대응 |
|---|---|---|
| IP 충돌 | 같은 IP 를 두 장비가 사용 | arping -D · DHCP 예약 |
| ARP 스푸핑 | 공격자가 게이트웨이인 척 | 정적 ARP · DAI · 암호화(TLS) |
| 브로드캐스트 폭풍 | 루프로 프레임이 무한 순환 | STP · 루프 가드 |
| MAC 테이블 오버플로 | 스위치가 허브처럼 동작 | 포트 보안 |
| 듀플렉스 불일치 | 한쪽 full, 한쪽 half | ethtool 로 확인 · 자동 협상 |
| VLAN 미스매치 | 같은 스위치인데 통신 불가 | 트렁크 · 액세스 포트 설정 |
# 에러 카운터가 늘어나면 물리 계층 문제
ip -s link show eth0
# RX: bytes packets errors dropped overrun mcast
# ↑ 0 이 아니면 케이블·NIC·스위치 확인
ethtool -S eth0 | grep -i -E "err|drop|crc"
# rx_crc_errors 가 늘어나면 → 케이블 · 커넥터 · 전자기 간섭
# 링크 속도 확인 (1G 인데 100M 로 협상됐다면 케이블 문제)
ethtool eth0 | grep -E "Speed|Duplex|Link"
실무에선 이렇게 — 컨테이너 네트워크 🔴 실무
도커 기본 브리지 네트워크
컨테이너 A ──veth──┐
├── docker0 (브리지) ── iptables NAT ── eth0 ── 외부
컨테이너 B ──veth──┘
· 컨테이너마다 net 네임스페이스 + veth 쌍
· docker0 가 가상 스위치 역할
· 외부로 나갈 때 NAT (MASQUERADE)
· 포트 매핑 -p 8080:80 은 DNAT 규칙
쿠버네티스 CNI
· 파드마다 IP · 파드 간 NAT 없이 직접 통신 (요구사항)
· 오버레이(VXLAN) 또는 라우팅 기반
· VXLAN 은 헤더가 붙어 ★ MTU 가 줄어든다 (1450 등)
# 컨테이너 네트워크 디버깅
docker network ls
docker network inspect bridge
iptables -t nat -L -n -v | head -40 # 포트 매핑 규칙
# 컨테이너 안에 도구가 없을 때 — 네임스페이스에 들어가서
PID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
sudo nsenter -t $PID -n ss -lntp
sudo nsenter -t $PID -n tcpdump -i any -n port 80
# 쿠버네티스
kubectl exec -it pod -- sh
kubectl run tmp --rm -it --image=nicolaka/netshoot -- bash # 진단 도구 모음
TCP — 신뢰성을 만드는 방법
IP 는 “보내기만” 합니다 — 순서도 도착도 보장하지 않습니다. 그 위에 확인 응답과 재전송을 얹어 신뢰성을 만든 것이 TCP 이고, 그 대가가 왕복 시간입니다.
TCP 가 보장하는 것
| 보장 | 방법 |
|---|---|
| 도착 보장 | ACK + 재전송 |
| 순서 보장 | 시퀀스 번호로 재정렬 |
| 중복 제거 | 시퀀스 번호 |
| 오류 검출 | 체크섬 |
| 흐름 제어 | 수신 윈도우 (05절) |
| 혼잡 제어 | 혼잡 윈도우 (05절) |
send() 가 성공해도 상대 앱이 읽기 전에 죽으면 사라집니다.
그래서 애플리케이션 레벨 ACK 가 필요합니다 → 🕸️ 분산 13.
연결 종료와 TIME_WAIT
4-way 핸드셰이크
A → FIN "나는 다 보냈다"
B → ACK
B → FIN "나도 다 보냈다"
A → ACK → A 는 TIME_WAIT 로 (2×MSL, 보통 60초)
왜 TIME_WAIT 이 필요한가
① 마지막 ACK 가 유실되면 상대가 FIN 을 재전송 → 응답해야 함
② 옛 연결의 지연된 패킷이 새 연결에 섞이는 것 방지
문제
· 클라이언트가 많은 연결을 짧게 열고 닫으면 TIME_WAIT 이 쌓임
· 로컬 포트 고갈 (기본 약 28,000개)
대응
· ★ Keep-Alive 로 연결 재사용 (가장 좋은 답)
· 커넥션 풀 사용
· net.ipv4.tcp_tw_reuse=1 (클라이언트 측)
· 포트 범위 확대: net.ipv4.ip_local_port_range
이렇게도 쓸 수 있다 — 연결 상태 읽기 🟡 중급
| 상태 | 의미 | 많으면 |
|---|---|---|
| LISTEN | 연결 대기 | 정상 |
| SYN_SENT | SYN 보내고 응답 대기 | 상대가 응답 안 함 — 방화벽 의심 |
| SYN_RECV | SYN 받고 ACK 대기 | SYN 플러드 의심 |
| ESTABLISHED | 연결됨 | 정상 |
| CLOSE_WAIT | 상대가 닫았는데 내가 안 닫음 | 애플리케이션 버그 ★ |
| FIN_WAIT_2 | 내가 닫고 상대 대기 | 상대가 안 닫음 |
| TIME_WAIT | 정리 대기 | 연결 재사용 부족 |
ss -s # 상태별 요약
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
# CLOSE_WAIT 가 쌓이면 100% 애플리케이션 코드 문제다
ss -tan state close-wait
ss -tanp state close-wait # 어느 프로세스인지
# 원인
# · 응답 객체를 close() 안 함
# · try/finally 누락
# · 커넥션 풀이 죽은 연결을 반환
# · 스레드가 블로킹돼 close 에 도달 못 함
# accept 큐 넘침 (연결이 거절되거나 지연)
ss -lnt # Recv-Q > 0 이면 대기 중
nstat -az TcpExtListenOverflows TcpExtListenDrops
sysctl net.core.somaxconn # backlog 상한
close() 를 안 부른 상태입니다.
쌓이면 파일 디스크립터 고갈로 이어집니다 → ⚙️ 운영체제 11.
실무에선 이렇게 — Keep-Alive 와 타임아웃 🔴 실무
여러 층의 타임아웃이 서로 맞아야 한다
클라이언트 커넥션 풀 유휴 타임아웃 30초
로드밸런서 idle timeout 60초
서버 keep-alive timeout 75초 ★ 항상 앞단보다 길게
NAT/방화벽 세션 타임아웃 300초
★ 만약 서버가 앞단보다 먼저 닫으면
클라이언트가 "이미 닫힌 연결" 로 요청을 보낸다
→ 간헐적인 502 · connection reset
→ 재현이 안 되는 "가끔 나는 에러" 의 흔한 원인
# 클라이언트 쪽 — 연결 재사용과 타임아웃
import httpx
client = httpx.Client(
limits=httpx.Limits(max_keepalive_connections=100,
max_connections=200,
keepalive_expiry=30.0),
timeout=httpx.Timeout(connect=3.0, read=10.0, write=10.0, pool=5.0),
)
# ★ 타임아웃을 종류별로 나눠 설정하는 것이 중요하다
# connect 는 짧게 (안 되면 빨리 실패)
# read 는 서버의 p99 보다 여유 있게
# TCP keepalive (유휴 연결이 NAT 에서 끊기는 것 방지)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
상태를 읽으면 원인이 보입니다
소켓 상태별 진단
| 상태가 많다면 | 원인 |
|---|---|
| SYN_RECV | SYN 홍수 공격 · accept 큐 부족 — somaxconn |
| ESTABLISHED | 정상. 다만 계속 늘면 연결을 안 닫고 있음 |
| CLOSE_WAIT | 내 코드가 close() 를 안 함 — 가장 명확한 버그 신호 |
| TIME_WAIT | 내가 먼저 끊음. 많으면 연결 재사용을 검토 |
| FIN_WAIT_2 | 상대가 close 를 안 함 |
| accept 큐 넘침 | 앱이 accept() 를 못 따라감 — 워커 부족 |
# 상태 분포 한눈에
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
# accept 큐가 넘치고 있나 (Recv-Q 가 쌓이면 위험)
ss -ltn # Recv-Q = 대기 중, Send-Q = 큐 최대치
netstat -s | grep -i 'listen queue\|overflow\|SYNs to LISTEN'
# CLOSE_WAIT 이 많다면 — 어느 프로세스인지
ss -tan state close-wait | head
lsof -i -sTCP:CLOSE_WAIT | head
# 커널 큐 늘리기
sysctl -w net.core.somaxconn=8192
sysctl -w net.ipv4.tcp_max_syn_backlog=8192
CLOSE_WAIT 이 쌓이는 것은 언제나 애플리케이션 버그입니다.
상대가 연결을 닫았는데 내 쪽에서 close() 를 부르지 않은 상태입니다.
커널 설정으로는 해결되지 않고, 파일 디스크립터가 새어
결국 Too many open files 로 죽습니다.
try-with-resources·with·defer 로
구조적으로 닫히게 만드세요.
TIME_WAIT 을 정확히 이해하기
왜 60초나 남아 있나?
① 늦게 도착한 패킷이 '같은 포트를 쓰는 다음 연결'에
섞여 들어가는 것을 막기 위해
② 마지막 ACK 가 유실되면 상대가 FIN 을 재전송하는데,
그때 응답해 줄 누군가가 남아 있어야 하기 때문
즉 버그가 아니라 '정확성을 위한 설계'입니다.
문제가 되는 경우
· 서버가 먼저 끊는 구조 → TIME_WAIT 이 서버에 쌓임
· 아웃바운드 연결이 많음 → 로컬 포트 고갈
해결 순서
① keepalive 로 연결 재사용 (근본)
② net.ipv4.tcp_tw_reuse = 1 (아웃바운드에 안전)
③ 포트 범위 확대 ip_local_port_range
✗ tcp_tw_recycle — NAT 환경을 깨뜨려 커널에서 제거됐습니다
범위를 55,000개로 넓히면 초당 약 916 연결
그 이상이 필요하면 연결을 재사용하는 수밖에 없습니다 — 이것이 HTTP keepalive · 커넥션 풀이 존재하는 이유입니다
Connection: close 를 보내면 반대가 됩니다 —
서버 쪽 keepalive 를 켜 두는 것이 이 문제를 함께 해결합니다.
흐름 제어 · 혼잡 제어 — 얼마나 빨리 보낼까
받는 쪽이 감당할 만큼(흐름 제어), 그리고 길이 막히지 않을 만큼(혼잡 제어) 속도를 조절합니다. 대역폭이 충분한데도 느린 이유가 대개 여기 있습니다.
두 개의 윈도우
| 흐름 제어 | 혼잡 제어 | |
|---|---|---|
| 보호 대상 | 수신자 | 네트워크 |
| 신호 | 수신 윈도우 광고 | 패킷 손실 · 지연 |
| 제어 주체 | 수신자가 알려 줌 | 송신자가 추정 |
혼잡 제어의 기본 흐름 (Reno 계열)
슬로우 스타트: 1 → 2 → 4 → 8 ... (RTT 마다 2배)
↓ 임계값 도달
혼잡 회피: 1씩 증가 (선형)
↓ 손실 감지
빠른 회복: 윈도우를 절반으로 → 다시 선형 증가
★ 슬로우 스타트가 '느리게 시작' 이라는 뜻이다
→ 짧은 연결은 최대 속도에 도달하기 전에 끝난다
→ 연결 재사용이 중요한 이유
대역폭 지연 곱 (BDP)
1Gbps · RTT 100ms (서울-미국)
BDP = 1,000,000,000 / 8 × 0.1 = 12.5 MB
★ 윈도우가 12.5MB 보다 작으면
대역폭이 1Gbps 여도 그만큼 못 쓴다
윈도우 64KB 라면 최대 처리량
= 65536 / 0.1 = 655 KB/s ≈ 5 Mbps ← 1Gbps 회선인데!
대응
· 윈도우 스케일링 (기본 활성)
· 커널 버퍼 크기 확대
net.ipv4.tcp_rmem / tcp_wmem
· 병렬 연결 (여러 스트림)
· ★ 근본적으로: CDN 으로 RTT 를 줄인다
이렇게도 쓸 수 있다 — 혼잡 제어 알고리즘 고르기 🟡 중급
| 알고리즘 | 신호 | 강점 | 약점 |
|---|---|---|---|
| Reno | 손실 | 고전 · 공정 | 고대역폭·고지연에 약함 |
| CUBIC | 손실 | 리눅스 기본 · 고BDP 개선 | 버퍼블로트에 취약 |
| BBR | 대역폭·RTT 추정 | 손실 많은 회선에서 강함 | 공정성 논란 · 튜닝 필요 |
| Vegas | 지연 | 버퍼 덜 채움 | 손실 기반과 경쟁 시 밀림 |
# 현재 알고리즘 확인 · 변경
sysctl net.ipv4.tcp_congestion_control # 보통 cubic
sysctl net.ipv4.tcp_available_congestion_control
# BBR 활성화 (커널 4.9+)
sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
sudo sysctl -w net.core.default_qdisc=fq # BBR 은 fq 와 함께
# 버퍼 크기 (고BDP 환경)
sudo sysctl -w net.core.rmem_max=134217728
sudo sysctl -w net.ipv4.tcp_rmem="4096 87380 134217728"
# 재전송률 확인 — 1% 넘으면 회선 문제
nstat -az | grep -i retrans
ss -ti dst 1.2.3.4 | grep -E "retrans|rtt|cwnd"
실무에선 이렇게 — 나글 알고리즘과 지연 🔴 실무
나글 알고리즘 (Nagle)
작은 패킷을 모아서 보낸다 (헤더 오버헤드 줄이기)
→ 작은 데이터는 ACK 를 기다렸다가 전송
지연 ACK (Delayed ACK)
ACK 를 바로 안 보내고 최대 40~200ms 기다린다
(데이터와 함께 보내려고)
★ 둘이 겹치면 재앙
송신자: "ACK 를 기다린다"
수신자: "데이터를 기다린다"
→ 40~200ms 의 불필요한 지연
→ 작은 요청/응답을 주고받는 프로토콜에서 특히
대응
TCP_NODELAY 로 나글을 끈다 (요청-응답 패턴에서는 거의 필수)
import socket
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# 대부분의 HTTP 라이브러리·서버는 기본으로 켜 둔다
# 직접 소켓을 다룬다면 반드시 확인할 것
# ★ 더 좋은 해법: 한 번에 보낸다
# ✗ 헤더와 본문을 따로 send
sock.send(header)
sock.send(body) # 나글 때문에 지연될 수 있다
# ✓ 합쳐서 한 번에
sock.sendall(header + body)
# 또는 writev / sendmsg 로 여러 버퍼를 한 번에
| 증상 | 확인 |
|---|---|
| 응답이 정확히 40ms 씩 걸림 | 나글 + 지연 ACK |
| 처리량이 대역폭에 못 미침 | 윈도우 크기 · BDP |
| 간헐적으로 크게 느려짐 | 재전송 · 패킷 손실 |
| 지연이 점점 커짐 | 버퍼블로트 (큐가 참) → fq_codel |
| 같은 회선인데 클라이언트마다 다름 | 혼잡 제어 알고리즘 차이 · Wi-Fi |
UDP · QUIC — TCP 를 버리고 다시 만들기
UDP 는 확인 없이 그냥 보냅니다. 그 위에 필요한 것만 직접 얹는 방식이고, 그 결과물이 QUIC(HTTP/3) 입니다.
UDP 를 고르는 이유
| 용도 | 왜 UDP 인가 |
|---|---|
| DNS | 한 번의 왕복 — 핸드셰이크가 낭비 |
| 실시간 음성·영상 | 늦은 데이터는 쓸모없음 — 재전송 불필요 |
| 게임 | 최신 상태만 중요 · 지연 최소화 |
| 메트릭 전송 (StatsD) | 일부 손실 허용 · 오버헤드 최소 |
| NTP | 단발성 |
| QUIC | TCP 의 제약을 우회하려고 |
QUIC 이 해결한 것
| TCP 의 문제 | QUIC 의 답 |
|---|---|
| 연결에 1 RTT + TLS 1~2 RTT | 1 RTT · 재연결은 0-RTT |
| HTTP/2 의 헤드오브라인 블로킹 | 스트림마다 독립 |
| IP 가 바뀌면 연결 끊김 | 연결 ID 로 마이그레이션 |
| 커널에 있어 개선이 느림 | 사용자 공간 — 배포가 빠름 |
| 중간 장비가 헤더를 건드림 | 대부분 암호화 |
HTTP/2 의 헤드오브라인 블로킹
하나의 TCP 연결에 여러 스트림을 다중화했는데
TCP 는 '순서 보장' 을 하므로
패킷 하나가 유실되면 → 그 뒤의 모든 스트림이 대기
QUIC 은 스트림마다 순서를 따로 관리
→ 스트림 A 의 손실이 스트림 B 를 막지 않는다
★ 손실률이 높은 모바일 환경에서 차이가 크다
이렇게도 쓸 수 있다 — UDP 위에 필요한 것만 얹기 🟡 중급
# 게임 서버의 전형적인 패턴 — 신뢰성을 선택적으로
#
# 신뢰 필요 없음 (매 틱 갱신) → 그냥 UDP, 최신 것만 쓰기
# 플레이어 위치 · 회전
# 신뢰 필요 (놓치면 안 됨) → 직접 ACK/재전송 구현
# 아이템 획득 · 채팅 · 상태 변경
# 순서 필요 (신뢰는 불필요) → 시퀀스 번호로 오래된 것 버리기
import struct, time
HEADER = struct.Struct("!IIB") # seq, ack, flags
class Channel:
def __init__(self):
self.seq = 0
self.pending = {} # seq -> (payload, sent_at)
def send_reliable(self, sock, addr, payload):
self.seq += 1
pkt = HEADER.pack(self.seq, 0, 1) + payload
sock.sendto(pkt, addr)
self.pending[self.seq] = (pkt, time.monotonic())
def tick(self, sock, addr, rto=0.2):
now = time.monotonic()
for seq, (pkt, sent) in list(self.pending.items()):
if now - sent > rto:
sock.sendto(pkt, addr) # 재전송
self.pending[seq] = (pkt, now)
def on_ack(self, seq):
self.pending.pop(seq, None)
실무에선 이렇게 — HTTP/3 도입 판단 🔴 실무
| HTTP/3 이 효과적인 경우 | 이유 |
|---|---|
| 모바일 사용자가 많음 | 손실률 높음 · 네트워크 전환 |
| 해외 사용자 (고 RTT) | 핸드셰이크 왕복 절감 |
| 리소스가 많은 페이지 | 스트림 독립성 |
| 재방문이 잦음 | 0-RTT 재연결 |
| 주의할 점 | 내용 |
|---|---|
| UDP 차단 환경 | 일부 기업망이 UDP/443 을 막음 → TCP 폴백 필수 |
| CPU 사용량 | 사용자 공간 처리라 TCP 보다 높을 수 있음 |
| 0-RTT 재전송 공격 | 멱등한 요청에만 허용해야 함 |
| 진단 도구 | tcpdump 로 내용을 못 봄 (암호화) |
# HTTP/3 지원 확인
curl -I --http3 https://example.com
curl -sI https://example.com | grep -i alt-svc
# alt-svc: h3=":443"; ma=86400 ← 이 헤더로 클라이언트에 알린다
# nginx (1.25+)
# listen 443 quic reuseport;
# listen 443 ssl;
# add_header Alt-Svc 'h3=":443"; ma=86400';
# ★ 동작 방식: 처음에는 TCP 로 접속하고
# Alt-Svc 헤더를 본 뒤 다음 요청부터 QUIC 으로 전환
DNS — 이름을 주소로 바꾸는 전 과정
도메인 하나를 푸는 데 여러 대의 서버가 관여합니다. 캐시와 TTL 때문에 “변경했는데 반영이 안 된다”가 자주 생기고, 그 구조를 알면 예측이 가능해집니다.
레코드 종류
| 타입 | 내용 | 메모 |
|---|---|---|
| A | IPv4 주소 | 가장 기본 |
| AAAA | IPv6 주소 | |
| CNAME | 다른 이름의 별칭 | 루트 도메인엔 못 씀 |
| MX | 메일 서버 | 우선순위 포함 |
| TXT | 임의 문자열 | SPF · DKIM · 소유 확인 |
| NS | 권한 네임서버 | 위임 |
| SRV | 서비스 위치 | 포트 포함 |
| CAA | 인증서 발급 허용 | 보안 |
example.com)에 CNAME 을 못 쓰는 것이
실무에서 자주 걸립니다 — 그래서 클라우드마다
ALIAS · ANAME · CNAME flattening 같은 확장을 제공합니다.
조회 명령
dig example.com # A 레코드
dig example.com MX +short
dig example.com NS
dig @8.8.8.8 example.com # 특정 리졸버에게
dig +trace example.com # ★ 루트부터 전 과정 추적
dig -x 93.184.216.34 # 역방향 조회
# TTL 확인 (남은 캐시 시간)
dig example.com | grep -A1 "ANSWER SECTION"
# example.com. 300 IN A 93.184.216.34
# ↑ TTL 300초
# 시스템이 실제로 무엇을 쓰는지
cat /etc/resolv.conf
resolvectl status # systemd-resolved
nslookup example.com
dig +trace 가 가장 유용합니다 —
어느 단계에서 잘못됐는지(위임 · NS 설정 · 권한 서버 응답)를
한 번에 보여 줍니다.
이렇게도 쓸 수 있다 — TTL 과 전파 🟡 중급
"DNS 전파" 는 사실 전파가 아니라 '캐시 만료' 다
변경 전 TTL 이 86400(24시간)이면
→ 이미 캐시한 리졸버는 최대 24시간 동안 옛 값을 준다
★ 서버를 옮기기 전에 해야 할 일
① 며칠 전에 TTL 을 300초로 낮춘다
② TTL 이 다 만료되기를 기다린다 (기존 TTL 만큼)
③ 레코드를 변경한다 → 5분이면 반영
④ 안정되면 TTL 을 다시 올린다
TTL 값 정하기
· 자주 바뀜 · 장애 조치용 → 60~300초
· 안정적인 서비스 → 3600초
· NS · MX 등 거의 안 바뀜 → 86400초
· 너무 짧으면 조회 비용·지연 증가
| 문제 | 원인 · 대응 |
|---|---|
| 바꿨는데 일부만 반영 | 리졸버별 캐시 — TTL 만료 대기 |
| 브라우저만 옛 값 | 브라우저 자체 DNS 캐시 · 재시작 |
| ISP 가 TTL 을 무시 | 일부 ISP 가 최소 TTL 강제 — 통제 불가 |
| 부하 분산이 고르지 않음 | DNS 라운드로빈은 캐시 때문에 부정확 |
| 장애 시 전환이 느림 | DNS 페일오버 대신 애니캐스트 · LB 사용 |
실무에선 이렇게 — 애플리케이션에서의 DNS 🔴 실무
| 함정 | 내용 | 대응 |
|---|---|---|
| JVM DNS 캐시 | 기본이 무한 캐시였음 | networkaddress.cache.ttl=30 |
| 커넥션 풀이 IP 를 고정 | DNS 변경을 못 따라감 | 주기적 재연결 · 풀 만료 |
| 쿠버네티스 ndots:5 | 외부 도메인 조회가 검색 도메인 수만큼(기본 3번) 실패한 뒤 성공 | FQDN 사용(example.com.) · ndots:1 |
| DNS 조회가 블로킹 | 이벤트 루프를 막음 | 비동기 리졸버 · 캐시 |
| 레코드가 여러 개인데 첫 것만 | 부하 분산 안 됨 | 모든 A 레코드를 순회 |
| DNS 서버 장애 = 전면 장애 | 단일 실패점 | 리졸버 이중화 · 로컬 캐시 |
# 쿠버네티스 ndots 문제 — 실제로 성능에 큰 영향
# 기본 /etc/resolv.conf
# search default.svc.cluster.local svc.cluster.local cluster.local
# options ndots:5
#
# "api.example.com" 은 점이 2개 (< 5) 이므로
# → api.example.com.default.svc.cluster.local (실패)
# → api.example.com.svc.cluster.local (실패)
# → api.example.com.cluster.local (실패)
# → api.example.com (성공)
# 헛조회를 3번 더 한다! (A·AAAA 를 함께 물으면 6번)
dnsConfig:
options:
- name: ndots
value: "2"
# 또는 코드에서 끝에 점을 붙인다: "api.example.com."
HTTP/1.1 · 2 · 3 — 무엇이 달라졌나
버전마다 해결한 문제와 남긴 문제가 있습니다. 그 차이를 알면 “번들을 쪼갤까 합칠까” 같은 실무 판단이 근거를 갖게 됩니다.
세대별 변화
| 1.1 | 2 | 3 | |
|---|---|---|---|
| 전송 | TCP | TCP | QUIC/UDP |
| 포맷 | 텍스트 | 바이너리 | 바이너리 |
| 다중화 | ✗ (연결 6개) | ✓ | ✓ (독립) |
| 헤더 압축 | ✗ | HPACK | QPACK |
| HoL 블로킹 | 앱 레벨 | TCP 레벨 | 없음 |
| 서버 푸시 | ✗ | △ (사실상 폐기) | △ |
HTTP/1.1 의 제약과 그 대응 기법들
· 연결당 요청 하나 → 도메인당 6개 연결
· 그래서 생긴 최적화들
- 스프라이트 이미지 (요청 수 줄이기)
- CSS/JS 번들링
- 도메인 샤딩 (cdn1, cdn2 ...)
- 인라이닝
★ HTTP/2 에서는 이 기법들이 오히려 손해가 될 수 있다
(큰 번들 하나보다 작은 파일 여러 개가 캐시 효율이 좋다)
상태 코드 · 자주 쓰는 헤더
| 코드 | 의미 | 주의 |
|---|---|---|
| 200 / 201 / 204 | 성공 | 204 는 본문 없음 |
| 301 / 308 | 영구 이동 | 브라우저가 캐시 — 되돌리기 어려움 |
| 302 / 307 | 임시 이동 | 307 은 메서드 유지 |
| 304 | 변경 없음 | 조건부 요청 |
| 400 / 422 | 요청 오류 | 검증 실패 |
| 401 / 403 | 인증 / 인가 | 구분해서 쓸 것 |
| 429 | 레이트 리밋 | Retry-After 함께 |
| 499 | 클라이언트가 끊음 | nginx 전용 |
| 502 / 503 / 504 | 게이트웨이 · 과부하 · 타임아웃 | 원인이 다름 |
캐시 헤더 (가장 효과가 큰 최적화)
Cache-Control: public, max-age=31536000, immutable
→ 파일명에 해시가 있는 정적 자산
Cache-Control: no-cache
→ 캐시하되 매번 검증 (304 활용)
Cache-Control: no-store
→ 절대 저장 금지 (민감 정보)
ETag / Last-Modified + If-None-Match / If-Modified-Since
→ 변경 없으면 304 (본문 전송 생략)
이렇게도 쓸 수 있다 — 301 을 함부로 쓰면 안 되는 이유 🟡 중급
301 (영구 이동) 은 브라우저가 '영구히' 캐시한다
· 사용자가 캐시를 지우기 전까지 서버에 안 물어본다
· 잘못 배포하면 되돌릴 방법이 사실상 없다
실제로 자주 일어나는 사고
· 테스트 중 실수로 301 을 응답 → 그 사용자는 계속 잘못된 곳으로
· HTTPS 리다이렉트를 301 로 했다가 서브도메인 구조 변경 시 문제
권장
· 확신이 없으면 302 (임시)
· 확정된 뒤에 301 로 승격
· HSTS 도 같은 성격 — max-age 를 처음엔 짧게 시작
| 실무 팁 | 내용 |
|---|---|
| 리다이렉트 체인을 줄인다 | 매 홉이 1 RTT — http→https→www 는 2번 |
| 정적 자산에 해시 파일명 | app.a1b2c3.js + immutable |
| HTML 은 짧은 캐시 | 자산 참조가 바뀌어야 하므로 |
| Vary 헤더 주의 | Vary: User-Agent 는 캐시 적중률을 망침 |
| 압축 | gzip → brotli (텍스트 15~25% 추가 절감) |
| Content-Encoding 과 이미지 | 이미 압축된 것(JPEG · PNG)은 재압축 안 함 |
# 실제 응답 확인
curl -sI https://example.com/app.js | grep -i -E "cache|etag|content-encoding"
# HTTP 버전 확인
curl -sI --http2 https://example.com | head -1
curl -sI --http3 https://example.com | head -1
# 압축 효과
curl -s -H "Accept-Encoding: br" -o /dev/null -w "%{size_download}\n" URL
curl -s -o /dev/null -w "%{size_download}\n" URL
실무에선 이렇게 — HTTP/2 에서의 최적화 전략 변화 🔴 실무
| 기법 | HTTP/1.1 | HTTP/2 · 3 |
|---|---|---|
| 번들링 | 필수 | 적당히 — 캐시 효율 고려 |
| 도메인 샤딩 | 유효 | 역효과 (연결이 나뉨) |
| 스프라이트 이미지 | 유효 | 불필요 |
| 인라이닝 | 유효 | 캐시를 못 해서 손해 |
| 서버 푸시 | — | 폐기 → <link rel=preload> |
| 우선순위 힌트 | — | fetchpriority · preload |
<!-- 리소스 힌트 — HTTP 버전과 무관하게 효과적 -->
<link rel="preconnect" href="https://api.example.com"> <!-- DNS+TCP+TLS 미리 -->
<link rel="dns-prefetch" href="https://cdn.example.com"> <!-- DNS 만 -->
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
<link rel="modulepreload" href="/js/app.js">
<img src="hero.jpg" fetchpriority="high">
<img src="below.jpg" loading="lazy">
<!-- ★ preconnect 는 3개 이하로. 많으면 오히려 경쟁이 생긴다 -->
TLS 핸드셰이크 — 상대가 진짜인지 확인하고 열쇠 만들기
HTTPS 의 s 가 하는 일을 단계별로 봅니다. 인증서 오류를 해석하고 왜 이렇게 여러 왕복이 필요한지를 알면 성능과 보안 판단을 함께 할 수 있습니다.
TLS 1.3 핸드셰이크 (1 RTT)
- ClientHello — 지원하는 암호 목록 + 키 공유 후보 + SNI(어느 도메인인지)
- ServerHello — 고른 암호 + 서버 키 공유 + 인증서 + 서명
- 클라이언트 검증 — 인증서 체인 · 유효기간 · 도메인 일치 · 폐기 여부
- 양쪽이 같은 세션 키 를 계산 (디피-헬만) — 키 자체는 오간 적이 없음
- Finished — 이후 모든 데이터는 대칭키로 암호화
직접 보기
# 전체 핸드셰이크 과정
openssl s_client -connect example.com:443 -servername example.com
# 인증서 정보만
echo | openssl s_client -connect example.com:443 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
# 체인 확인
openssl s_client -connect example.com:443 -showcerts
# 지원 프로토콜 확인
for v in tls1_2 tls1_3; do
echo -n "$v: "
echo | openssl s_client -$v -connect example.com:443 >/dev/null 2>&1 \
&& echo OK || echo NO
done
# 만료일 감시 (모니터링에 넣을 것)
echo | openssl s_client -connect example.com:443 2>/dev/null \
| openssl x509 -noout -enddate
이렇게도 쓸 수 있다 — 인증서 오류 해석 🟡 중급
| 오류 | 원인 | 확인 |
|---|---|---|
certificate has expired | 만료 | 자동 갱신 실패 · 갱신 후 재로드 누락 |
unable to get local issuer certificate | 중간 인증서 누락 | full chain 을 배포했는지 |
hostname mismatch | SAN 에 도메인 없음 | subjectAltName 확인 |
self signed certificate | 사설 CA | CA 를 신뢰 저장소에 추가 |
SSLV3_ALERT_HANDSHAKE_FAILURE | 프로토콜·암호 불일치 | 구형 클라이언트 · TLS 버전 |
certificate verify failed (파이썬) | CA 번들 문제 | certifi 갱신 · 시스템 CA |
| 모바일에서만 실패 | 중간 인증서 · 시계 오차 | 기기 시간 확인 |
중간 인증서 누락이 가장 헷갈린다
브라우저: 정상 (이전에 받은 중간 인증서를 캐시하고 있음)
curl · 모바일 앱: 실패
→ "내 브라우저에서는 되는데" 의 전형
확인
openssl s_client -connect host:443 -showcerts
Certificate chain 에 서버 인증서 + 중간 인증서가 모두 있어야 한다
해결
nginx: ssl_certificate 에 fullchain.pem 을 지정 (cert.pem 아님)
ssllabs.com 또는 testssl.sh 로 검증
실무에선 이렇게 — TLS 성능 🔴 실무
| 기법 | 효과 |
|---|---|
| TLS 1.3 사용 | 핸드셰이크 1 RTT 절감 |
| 세션 재개 (티켓 · ID) | 재연결 시 왕복 절감 |
| OCSP 스테이플링 | 클라이언트의 폐기 조회 왕복 제거 |
| 연결 재사용 (Keep-Alive) | 핸드셰이크 자체를 안 함 |
| ECDSA 인증서 | RSA 보다 서명이 빠르고 작음 |
| CDN · 엣지 종료 | RTT 를 줄여 핸드셰이크 시간 단축 |
| AES-NI · ChaCha20 | 하드웨어 가속 · 모바일 효율 |
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # TLS1.3 에서는 클라이언트 선호 존중
ssl_session_cache shared:SSL:50m; # 세션 재개
ssl_session_timeout 1d;
ssl_session_tickets on;
ssl_stapling on; # OCSP 스테이플링
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
add_header Strict-Transport-Security "max-age=63072000" always;
# ★ HSTS 는 처음에 짧게(300초) 시작해서 문제없음을 확인한 뒤 늘릴 것
소켓 · 포트 — 커널이 만든 두 개의 대기 줄
listen() 뒤에는 SYN 큐와 accept 큐 두 개가 있습니다.
연결이 간헐적으로 거절되거나 지연되는 문제의 원인이
대개 이 큐들입니다.
서버 소켓의 흐름
socket() → 소켓 생성
bind() → 주소·포트 할당
listen(backlog) → 두 개의 큐 생성
· SYN 큐 (반쯤 열린 연결) — 핸드셰이크 진행 중
· accept 큐 (완성된 연결) — accept() 를 기다림
accept() → accept 큐에서 하나 꺼내 연결 소켓 반환
★ 앱이 accept 를 충분히 빨리 안 하면
accept 큐가 차고 → 새 연결이 버려진다 (또는 SYN 을 무시)
→ 클라이언트는 타임아웃 · 연결 거부를 겪는다
크기 결정
accept 큐 = min(listen 의 backlog, net.core.somaxconn)
확인과 튜닝
# 리슨 소켓의 큐 상태
ss -lnt
# State Recv-Q Send-Q Local Address:Port
# LISTEN 0 511 0.0.0.0:80
# ↑ ↑
# 현재 대기 중 backlog 크기
# Recv-Q 가 계속 0 이 아니면 accept 가 밀리고 있다
# 넘침 카운터
nstat -az | grep -i -E "ListenOverflows|ListenDrops"
netstat -s | grep -i "listen"
# 튜닝
sysctl -w net.core.somaxconn=4096
sysctl -w net.ipv4.tcp_max_syn_backlog=8192
# 애플리케이션의 listen(backlog) 도 함께 올려야 한다
# nginx: listen 80 backlog=4096;
# gunicorn: --backlog 2048
이렇게도 쓸 수 있다 — 포트와 연결 식별 🟡 중급
연결은 4-튜플로 식별된다
(출발지 IP, 출발지 포트, 목적지 IP, 목적지 포트)
★ 그래서
· 서버는 포트 하나로 수만 연결을 받을 수 있다
(클라이언트마다 출발지가 다르므로)
· 클라이언트는 같은 서버에 최대 약 28,000 연결
(임시 포트 범위의 크기)
· 클라이언트가 여러 서버에 붙으면 그만큼 더 가능
포트 범위
0-1023 well-known (바인딩에 권한 필요)
1024-49151 등록된 포트
49152-65535 임시(ephemeral) — 리눅스 기본은 32768-60999
sysctl net.ipv4.ip_local_port_range
| 문제 | 원인 · 대응 |
|---|---|
| Cannot assign requested address | 임시 포트 고갈 — 연결 재사용 · 범위 확대 |
| Address already in use | TIME_WAIT · SO_REUSEADDR |
| NAT 뒤에서 연결 수 제한 | 공유기의 포트 매핑 한계 |
| 1024 미만 포트 바인딩 실패 | 권한 → CAP_NET_BIND_SERVICE · 리버스 프록시 |
| 같은 포트에 여러 프로세스 | SO_REUSEPORT — 커널이 분산 |
# 서버 재시작 시 "Address already in use" 를 막는다
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 여러 워커가 같은 포트를 리슨 (커널이 부하 분산)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
# ★ SO_REUSEADDR 과 SO_REUSEPORT 는 다른 것이다
# REUSEADDR: TIME_WAIT 상태의 주소를 재사용
# REUSEPORT: 여러 소켓이 동시에 같은 포트를 리슨
실무에선 이렇게 — SYN 플러드와 방어 🔴 실무
SYN 플러드 공격
· 위조한 출발지로 SYN 만 대량 전송
· 서버는 SYN 큐에 항목을 만들고 SYN-ACK 를 보내고 대기
· 큐가 차면 정상 연결도 못 받는다
방어: SYN 쿠키
· SYN 큐를 쓰지 않고, 필요한 정보를 시퀀스 번호에 인코딩
· ACK 가 오면 그 번호로 상태를 복원
· 상태를 저장하지 않으므로 큐가 차지 않는다
sysctl net.ipv4.tcp_syncookies=1 # 기본 활성
한계
· 일부 TCP 옵션(윈도우 스케일 등)이 손실될 수 있다
· 큐가 찼을 때만 발동하므로 평상시에는 영향 없음
# SYN 플러드 확인
ss -tan state syn-recv | wc -l # 비정상적으로 많으면 의심
nstat -az | grep -i -E "SyncookiesSent|TCPReqQFullDrop"
# 연결 상태 요약
ss -s
# 출발지 IP 분포 (정상 트래픽이면 다양하다)
ss -tan state syn-recv | awk '{print $4}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
# 실무 대응 순서
# ① 앞단에 CDN/DDoS 방어 (Cloudflare · AWS Shield)
# ② 레이트 리밋 (nginx limit_req · iptables)
# ③ SYN 쿠키 확인
# ④ 큐 크기 조정
# ★ 서버 하나로 대규모 공격을 막을 수는 없다
라우팅 · 방화벽 · 로드밸런서 — 트래픽이 지나는 관문들
요청이 서버에 닿기까지 여러 장비를 지납니다. 각 지점에서 무엇을 보고 무엇을 할 수 있는지를 알면 “어디서 막혔나”를 훨씬 빨리 찾습니다.
라우팅 — 다음 홉 고르기
ip route
# default via 192.168.0.1 dev eth0
# 10.0.0.0/8 via 10.1.0.1 dev eth1
# 192.168.0.0/24 dev eth0 proto kernel scope link
# ★ 가장 긴 접두사가 이긴다 (longest prefix match)
# 10.1.2.3 은 10.0.0.0/8 규칙에 매칭
ip route get 8.8.8.8 # 실제로 어느 경로로 가는지 ★
traceroute -n 8.8.8.8 # 홉별 경로
mtr -n 8.8.8.8 # traceroute + ping (지속 관찰)
# 정책 기반 라우팅 (출발지에 따라 다른 경로)
ip rule show
ip route show table 100
ip route get 가 가장 실용적입니다 —
복잡한 규칙이 얽혀 있어도 이 목적지는 이 경로로 간다를
한 줄로 확인해 줍니다.
방화벽 — 어디서 막히나
# 리눅스 (nftables / iptables)
nft list ruleset
iptables -L -n -v --line-numbers
iptables -t nat -L -n -v
# 패킷이 실제로 어느 규칙에 걸리는지 (카운터 관찰)
watch -n1 'iptables -L -n -v | grep DROP'
# 클라우드에서는 여러 겹을 확인해야 한다
# · 보안 그룹 (인스턴스 단위 · 상태 유지)
# · 네트워크 ACL (서브넷 단위 · 무상태 → 응답 규칙도 필요)
# · 호스트 방화벽 (ufw · firewalld)
# · 애플리케이션 레벨 (allowlist)
# 연결 테스트 (포트가 열려 있나)
nc -zv host 443
timeout 3 bash -c '</dev/tcp/host/443' && echo open
이렇게도 쓸 수 있다 — L4 vs L7 로드밸런서 🟡 중급
| L4 (전송) | L7 (응용) | |
|---|---|---|
| 보는 것 | IP · 포트 | HTTP 헤더 · 경로 · 쿠키 |
| 라우팅 | 연결 단위 | 요청 단위 |
| 성능 | 매우 빠름 | 상대적으로 느림 |
| TLS | 통과 (패스스루) | 종료 가능 |
| 가능한 것 | — | 경로 기반 라우팅 · 재시도 · 리라이트 · 카나리 |
| 예 | AWS NLB · IPVS · HAProxy(tcp) | ALB · Nginx · Envoy |
부하 분산 알고리즘
라운드로빈 순서대로 — 서버 성능이 같을 때
가중 라운드로빈 성능 차이 반영
최소 연결 현재 연결이 가장 적은 서버 ★ 요청 시간이 다를 때 좋음
최소 응답 시간 가장 빠른 서버
★ power of two 무작위 2개 중 덜 바쁜 쪽 — 전체를 안 봐도 잘 분산
해시(IP · 쿠키) 같은 클라이언트를 같은 서버로 (세션 고정)
헬스 체크
· 액티브: LB 가 주기적으로 /health 를 호출
· 패시브: 실패 응답을 보고 자동 배제
★ /health 는 의존 서비스까지 확인할지 신중히 결정
(DB 가 잠깐 느리다고 모든 인스턴스가 빠지면 전면 장애)
실무에선 이렇게 — 프록시 뒤에서의 실제 IP 🔴 실무
프록시를 거치면 서버가 보는 출발지 IP 는 프록시의 IP 다
클라이언트 → CDN → LB → nginx → 앱
앱이 보는 IP = nginx 의 IP
해결: 헤더로 전달
X-Forwarded-For: 클라이언트, 프록시1, 프록시2
X-Real-IP: 클라이언트
Forwarded: for=1.2.3.4;proto=https (RFC 7239 표준)
★ 보안 주의
X-Forwarded-For 는 클라이언트가 위조할 수 있다!
→ 신뢰하는 프록시가 붙인 값만 신뢰해야 한다
→ 신뢰 프록시 목록을 설정하고, 오른쪽에서부터 세어 나간다
# nginx — 신뢰할 프록시를 명시
set_real_ip_from 10.0.0.0/8; # 내부 LB 대역만
set_real_ip_from 173.245.48.0/20; # CDN 대역
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# 앱으로 전달
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
| 잘못하면 | 결과 |
|---|---|
| XFF 를 무조건 신뢰 | IP 기반 차단·레이트 리밋 우회 |
| 가장 왼쪽 값을 사용 | 클라이언트가 위조한 값을 씀 |
| 프록시 대역을 안 정함 | 같은 문제 |
| X-Forwarded-Proto 미전달 | 앱이 http 로 착각 → 무한 리다이렉트 |
| Host 헤더 미전달 | 절대 URL 생성 오류 · 캐시 오염 |
X-Forwarded-For: 1.1.1.1 을 직접 붙여 보냅니다.
신뢰 프록시 설정 없이 XFF 를 쓰면 안 됩니다 →
🌐 서버기술 가이드.
지연 · 대역폭 · RTT — 물리 법칙은 못 이깁니다
대역폭은 돈으로 살 수 있지만 빛의 속도는 못 삽니다. 이 절의 계산이 아키텍처 결정에 직접 쓰입니다 — 왕복을 줄일 것인가, 가까이 갈 것인가.
물리적 하한
| 구간 | 실제 RTT |
|---|---|
| 같은 서버 내 (루프백) | < 0.1ms |
| 같은 랙 | 0.1~0.3ms |
| 같은 데이터센터 | 0.5ms |
| 같은 리전 다른 AZ | 1~2ms |
| 서울 ↔ 도쿄 | ~35ms |
| 서울 ↔ 싱가포르 | ~70ms |
| 서울 ↔ 미국 서부 | ~130ms |
| 서울 ↔ 유럽 | ~230ms |
| 모바일 (LTE/5G) 추가 | +20~50ms |
왕복이 쌓이면
서울 사용자가 미국 서버(RTT 130ms)에 접속할 때
DNS 조회 130ms (캐시 없을 때)
TCP 핸드셰이크 130ms
TLS 1.3 130ms
HTTP 요청·응답 130ms + 서버 처리
──────────────────────────
최소 520ms — 아직 HTML 만 받았다
HTML 안의 CSS/JS 를 또 받아야 하고
그것들이 API 를 부르면 또 130ms 씩
★ 그래서
· 연결 재사용이 결정적이다 (핸드셰이크 3번을 아낌)
· CDN 이 결정적이다 (RTT 자체를 130 → 10ms 로)
· 순차 호출을 병렬로 바꾸는 것이 결정적이다
이렇게도 쓸 수 있다 — 왕복 줄이기 설계 🟡 중급
| 기법 | 줄이는 것 | 주의 |
|---|---|---|
| CDN · 엣지 | RTT 자체 | 동적 콘텐츠는 제한적 |
| 연결 재사용 | 핸드셰이크 | 타임아웃 정렬 필요 |
| 병렬 호출 | 순차 누적 | 의존성 확인 |
| 배치 API | 요청 수 | 부분 실패 처리 |
| GraphQL · BFF | N번 호출 → 1번 | 복잡도 증가 |
| 캐시 (로컬 · CDN) | 호출 자체 | 무효화 전략 |
| 프리페치 · preconnect | 체감 지연 | 과하면 낭비 |
| 낙관적 UI | 체감 지연 | 실패 시 롤백 |
# ✗ 순차 — RTT 가 누적된다
user = await get_user(uid) # 30ms
orders = await get_orders(uid) # 30ms
prefs = await get_prefs(uid) # 30ms
# 총 90ms
# ✓ 병렬 — 가장 느린 것 하나
user, orders, prefs = await asyncio.gather(
get_user(uid), get_orders(uid), get_prefs(uid)
)
# 총 30ms
# ✓ 더 좋은 것: 한 번의 배치 호출
data = await get_user_bundle(uid) # 30ms, 왕복 1번
# ★ 주의: gather 는 하나가 실패하면 전체가 실패한다
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception): handle(r)
실무에선 이렇게 — 지연 예산 세우기 🔴 실무
목표: 사용자 체감 응답 200ms (p95)
네트워크 왕복 (CDN 경유) 30ms
TLS · 연결 (재사용 시 0) 0ms
게이트웨이 처리 5ms
인증 확인 (캐시) 2ms
─────────────────────────────
서비스에 남은 예산 163ms
그 안에서
DB 조회 (인덱스 있음) 10ms
캐시 조회 1ms
외부 API (병렬) 80ms ★ 가장 큰 몫
직렬화 · 렌더링 20ms
여유 52ms
★ 외부 API 타임아웃을 100ms 로 설정하고
초과 시 캐시된 값이나 기본값으로 폴백(degrade)한다
→ 전체 예산을 지킬 수 있다
| 원칙 | 내용 |
|---|---|
| 예산을 나눈다 | 각 구간에 상한을 정하고 그 안에서 설계 |
| 데드라인 전파 | 남은 시간을 하위 호출에 전달 (gRPC deadline) |
| 우아한 저하 | 일부 실패해도 핵심 응답은 반환 |
| 헤지 요청 | p95 를 넘으면 두 번째 요청을 추가로 (꼬리 지연 완화) |
| 측정은 분위수로 | 평균이 아니라 p50/p95/p99 |
# 데드라인 전파 — 남은 예산 안에서만 호출
import time, asyncio
class Deadline:
def __init__(self, budget_s): self.end = time.monotonic() + budget_s
def remaining(self): return max(0.0, self.end - time.monotonic())
async def handler(dl: Deadline):
if dl.remaining() < 0.02:
return cached_fallback() # 시간이 없으면 즉시 폴백
try:
return await asyncio.wait_for(call_api(), timeout=dl.remaining())
except asyncio.TimeoutError:
return cached_fallback()
네트워크 진단 — 명령 순서
“접속이 안 된다”는 신고를 받았을 때 아래에서 위로 올라가며 확인합니다. 각 단계에서 성공하면 그 아래층은 정상입니다.
단계별 확인
- 이름이 풀리나 —
dig +short example.com - IP 에 도달하나 —
ping -c 3 1.2.3.4(ICMP 차단 가능성 유의) - 경로는 어디까지 —
mtr -n -c 20 1.2.3.4 - 포트가 열렸나 —
nc -zv 1.2.3.4 443 - TLS 가 되나 —
openssl s_client -connect 1.2.3.4:443 - 응답이 오나 —
curl -v https://example.com - 어디가 느린가 —
curl -w로 단계별 시간 (01절) - 패킷을 직접 —
tcpdump· Wireshark
자주 쓰는 명령 모음
# 이름 · 경로
dig +short example.com
dig +trace example.com
mtr -n -r -c 20 example.com # 리포트 형식
# 연결 · 포트
nc -zv host 443
ss -tanp | grep :443
ss -s # 소켓 요약
# HTTP
curl -v https://example.com
curl -sI https://example.com # 헤더만
curl --resolve example.com:443:1.2.3.4 https://example.com # DNS 우회 테스트
curl -w '%{time_total}\n' -o /dev/null -s URL
# 인터페이스 · 통계
ip -s link
ip route get 8.8.8.8
sar -n DEV 1 5
nstat -az | grep -i -E "retrans|drop|overflow"
패킷 캡처
# 기본 (호스트·포트 필터를 반드시 걸 것)
sudo tcpdump -i any -n host 1.2.3.4 and port 443
# 파일로 저장해 Wireshark 로 분석
sudo tcpdump -i any -n -s 0 -w capture.pcap host 1.2.3.4
# 자주 쓰는 필터
tcpdump 'tcp[tcpflags] & (tcp-syn) != 0' # SYN 만
tcpdump 'tcp[tcpflags] & (tcp-rst) != 0' # RST (연결 거부·끊김)
tcpdump -n 'icmp' # ICMP (MTU · 도달 불가)
tcpdump -A 'port 80' # 내용을 텍스트로
# 컨테이너 안 트래픽
sudo nsenter -t $(docker inspect -f '{{.State.Pid}}' NAME) -n tcpdump -i any -n
# ★ 운영에서는 캡처 크기·시간을 반드시 제한
sudo tcpdump -i any -n -c 1000 -W 3 -C 100 -w cap.pcap host X
이렇게도 쓸 수 있다 — 증상별 원인 매핑 🟡 중급
| 증상 | 확인할 것 | 흔한 원인 |
|---|---|---|
| Connection refused | 포트 리슨 여부 | 서비스 미기동 · 포트 오타 |
| Connection timeout | 방화벽 · 보안 그룹 | 패킷이 DROP 됨 |
| Connection reset | 서버 로그 · LB | 앱 크래시 · 유휴 타임아웃 · backlog 넘침 |
| 간헐적 실패 | DNS 여러 IP · LB 멤버 | 일부 인스턴스만 이상 |
| 큰 응답만 실패 | MTU · Path MTU | ICMP 차단 · 터널 |
| 느린데 오류는 없음 | 재전송률 · RTT | 패킷 손실 · 혼잡 |
| 특정 지역만 느림 | 경로 · CDN PoP | 라우팅 · 피어링 |
| 주기적으로 끊김 | NAT 타임아웃 · keepalive | 유휴 세션 만료 |
| SSL 오류 | 인증서 체인 · 시각 | 중간 인증서 누락 (09절) |
# "가끔 안 된다" 를 잡는 법 — 여러 IP 중 하나가 문제인지 확인
for ip in $(dig +short example.com); do
echo -n "$ip: "
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
--resolve example.com:443:$ip https://example.com
done
# LB 뒤의 각 인스턴스를 직접 확인
for host in app1 app2 app3; do
echo -n "$host: "
curl -s -o /dev/null -w "%{http_code}\n" http://$host:8080/health
done
실무에선 이렇게 — 이어지는 곳 🔴 실무
| 여기서 배운 것 | 다음 |
|---|---|
| 04·05 TCP · 혼잡 제어 | ⚙️ 운영체제 10 I/O 모델 · 소켓 |
| 08 HTTP · 캐시 | 🌐 서버기술 가이드 · 🟨 JS·TS 가이드 |
| 09 TLS | 🔐 보안 · 암호 06·07 |
| 11 로드밸런서 · 프록시 | 🌐 서버기술 가이드 로드밸런싱 |
| 12 지연 예산 | 🕸️ 분산 · 동시성 13 타임아웃 · 재시도 |
| 13 진단 | ⚙️ 운영체제 13 · 🏛️ SW 공학 12 관측 가능성 |
빌드 에러가
어디서 났는지
알게 됩니다
컴파일러를 만들 일은 없어도 컴파일러가 무엇을 하는지는 매일 마주칩니다 — 빌드 에러 · 링크 에러 · 최적화 옵션 · GC 정지 · JIT 워밍업 · 타입 검사. 이 탭은 소스에서 실행까지를 단계별로 따라가고, 각 단계가 우리 코드에 어떤 얼굴로 나타나는지까지 이어갑니다.
소스가 실행되기까지 — 전체 그림
gcc main.c 한 줄 안에서 네 개의 프로그램이 차례로 돕니다.
어느 단계에서 에러가 났는지 구분하는 것만으로도
디버깅 시간이 크게 줄어듭니다.
#include 붙여넣기 · 매크로 치환 · 조건부 컴파일 → gcc -Egcc -Sgcc -c에러 메시지로 단계 구분하기
| 메시지 | 단계 | 원인 |
|---|---|---|
No such file: x.h | 전처리 | include 경로 |
expected ';' | 파싱 | 문법 |
'x' undeclared | 의미 분석 | 선언 없음 |
invalid conversion | 타입 검사 | 타입 불일치 |
undefined reference | 링크 | 정의 없음 · 라이브러리 누락 |
multiple definition | 링크 | 헤더에 정의를 넣음 |
cannot open shared object | 로드 | 런타임 라이브러리 경로 |
언어별 실행 방식
완전 컴파일 (AOT)
C · C++ · Rust · Go
소스 → 기계어 실행 파일
빠름 · 배포 단순 · 플랫폼별 빌드 필요
바이트코드 + VM
자바 · C# · 파이썬
소스 → 바이트코드 → VM 이 해석/JIT
이식성 좋음 · 워밍업 필요
인터프리터
파이썬(CPython) · 루비
바이트코드를 반복문으로 실행
시작이 빠름 · 실행이 느림
JIT
JS(V8) · 자바(HotSpot) · C#(RyuJIT) · PyPy
실행 중 뜨거운 코드를 기계어로
워밍업 후에는 AOT 에 근접
이렇게도 쓸 수 있다 — 직접 단계별로 보기 🟡 중급
# C — 각 단계를 따로 실행
gcc -E main.c -o main.i # ① 전처리 결과 (헤더 몇 개로 수천~수만 줄이 된다)
gcc -S main.i -o main.s # ② 어셈블리
gcc -c main.s -o main.o # ③ 오브젝트
gcc main.o -o app # ④ 링크
nm main.o # 심볼 목록 (U = 미정의, T = 정의됨)
objdump -d main.o # 디스어셈블
ldd app # 동적 라이브러리 의존성
readelf -d app # 실행 파일 헤더 정보
# 파이썬 — 바이트코드
python -c "import dis; dis.dis('a + b * 2')"
python -m py_compile main.py # __pycache__/main.cpython-312.pyc
# 자바
javac Main.java && javap -c Main
# TypeScript
tsc --noEmit # 타입 검사만 (의미 분석 단계)
tsc --emitDeclarationOnly # 선언만
# Rust
cargo expand # 매크로 전개 결과 (전처리에 해당)
cargo rustc -- --emit=llvm-ir # 중간 표현
#include <stdio.h> 한 줄이 수백 줄, C++ 의 #include <iostream> 한 줄은 수만 줄로 펼쳐집니다.
C++ 빌드가 느린 이유의 큰 부분이 여기 있고,
그래서 모듈과 전방 선언이 중요합니다.
실무에선 이렇게 — 빌드 시간 줄이기 🔴 실무
| 기법 | 줄이는 단계 | 도구 |
|---|---|---|
| 증분 빌드 | 바뀐 것만 재컴파일 | make · ninja · cargo |
| 컴파일 캐시 | 같은 입력 → 캐시된 출력 | ccache · sccache |
| 병렬 빌드 | 여러 파일 동시에 | -j$(nproc) |
| 의존성 줄이기 | 전처리 · 파싱 | 전방 선언 · pimpl · 모듈 |
| 미리 컴파일된 헤더 | 전처리 반복 | PCH |
| 분산 빌드 | 전체 | distcc · Bazel RBE |
| 링크 최적화 끄기(개발 중) | 링크 | mold · lld 로 교체 |
# 어디에 시간이 가는지 먼저 측정
# C++ (Clang)
clang++ -ftime-trace main.cpp # main.json → chrome://tracing 에서 보기
# Rust
cargo build --timings # HTML 리포트
# TypeScript
tsc --diagnostics --extendedDiagnostics
# 웹팩·비트
npx webpack --profile --json > stats.json
# ★ 대부분 원인은 셋 중 하나
# ① 헤더/타입 의존성이 과도하게 얽힘
# ② 템플릿·제네릭 인스턴스화 폭발
# ③ 링커가 느림 (mold 로 바꾸면 수 배)
단계별로 직접 멈춰 보기
각 단계의 결과물을 눈으로
# ① 전처리만 — #include 가 펼쳐진 결과 (수백~수만 줄이 됩니다)
gcc -E main.c -o main.i
wc -l main.i # <stdio.h> 하나로도 수백 줄 (C++ <iostream> 이면 3만 줄 안팎)
# ② 어셈블리까지만 — 컴파일러가 만든 기계어를 사람이 읽는 형태로
gcc -S -O2 main.c -o main.s
cat main.s # 최적화 결과를 직접 확인할 수 있습니다
# ③ 목적 파일까지만 — 아직 링크 전
gcc -c main.c -o main.o
nm main.o # 이 파일이 정의한 심볼 / 필요로 하는 심볼
# T main ← T = 이 파일에 정의됨(Text)
# U printf ← U = Undefined, 링커가 찾아 줘야 함
# ④ 링크
gcc main.o -o a.out
ldd a.out # 실행 시 필요한 공유 라이브러리 목록
# ⑤ 실행 중 로더가 무엇을 여는지
LD_DEBUG=libs ./a.out 2>&1 | head
-S 로 어셈블리를 보는 습관은 생각보다 값어치가 큽니다.
“이 코드가 정말 최적화됐나”, “분기가 지워졌나”,
“volatile 이 효과가 있나”를 추측 대신 확인할 수 있습니다.
온라인이면 Compiler Explorer(godbolt.org) 가 소스와 어셈블리를
줄 단위로 색을 맞춰 보여 줘 훨씬 편합니다.
단계별 오류 사전
| 단계 | 대표 메시지 | 원인 |
|---|---|---|
| 전처리 | No such file or directory | 헤더 경로 — -I 누락 |
| 렉서 | unterminated string | 따옴표·주석이 안 닫힘 |
| 파서 | expected ';' before | 문법 오류 — 보통 그 윗줄이 진짜 원인 |
| 의미 분석 | undeclared identifierincompatible types | 선언 누락 · 타입 불일치 |
| 링커 | undefined reference to 'foo' | 선언만 있고 정의가 없음 · -l 누락 · 링크 순서 |
| 링커 | multiple definition | 헤더에 정의를 씀 · inline·static 누락 |
| 로더 | error while loading shared libraries | 런타임에 .so 를 못 찾음 — LD_LIBRARY_PATH |
| 로더 | symbol lookup error | 빌드할 때와 다른 버전의 라이브러리 |
# "undefined reference" 를 만났을 때의 순서
# ① 정말 정의가 있나 — 심볼이 어느 파일에 있는지 찾기
nm -C --defined-only *.o *.a 2>/dev/null | grep foo
# ② C++ 이면 이름 맹글링 확인 (C 함수를 C++ 에서 부를 때 단골)
# extern "C" { #include "c_header.h" } 가 빠졌는지
# ③ 링크 순서 — 라이브러리는 '쓰는 쪽 뒤에' 와야 합니다
gcc main.o -lfoo # ✓
gcc -lfoo main.o # ✗ 이 순서면 못 찾습니다 (정적 링크에서)
# ④ 32/64비트·ABI 불일치
file main.o libfoo.a
어휘 분석 — 글자를 낱말로 자르기
x=1+2 라는 글자 나열을
[x][=][1][+][2] 라는 토큰으로 자르는 단계입니다.
로그 파서 · 설정 파서 · DSL 을 만들 때 그대로 쓰입니다.
렉서가 하는 일
입력: let x = 42 + y; // 주석
토큰: KEYWORD(let)
IDENT(x)
OP(=)
NUMBER(42)
OP(+)
IDENT(y)
SEMI(;)
(주석과 공백은 버린다)
규칙
· 최장 일치 (maximal munch)
"==" 를 만나면 "=" 두 개가 아니라 "==" 하나로
· 키워드는 식별자 규칙에 먼저 걸리므로
식별자를 뽑은 뒤 예약어 표에서 확인한다
· 위치 정보(줄·열)를 함께 담는다 → 에러 메시지에 필요
직접 만들기 — 30줄
import re
from dataclasses import dataclass
@dataclass
class Token:
kind: str; value: str; line: int; col: int
SPEC = [
("NUMBER", r"\d+(\.\d+)?"),
("IDENT", r"[A-Za-z_]\w*"),
("STRING", r'"[^"\n]*"'),
("OP", r"[+\-*/=<>!]=?|&&|\|\|"),
("PUNCT", r"[(){}\[\],;]"),
("SKIP", r"[ \t]+"),
("COMMENT", r"//[^\n]*"),
("NEWLINE", r"\n"),
]
MASTER = re.compile("|".join(f"(?P<{k}>{v})" for k, v in SPEC))
KEYWORDS = {"let", "if", "else", "while", "return"}
def tokenize(src):
line = col_base = 1
for m in MASTER.finditer(src):
kind, value = m.lastgroup, m.group()
if kind == "NEWLINE":
line += 1; col_base = m.end() + 1; continue
if kind in ("SKIP", "COMMENT"): continue
if kind == "IDENT" and value in KEYWORDS: kind = "KEYWORD"
yield Token(kind, value, line, m.start() - col_base + 2)
list(tokenize("let x = 42 + y;"))
이렇게도 쓸 수 있다 — 실무에서 렉서를 쓰는 자리 🟡 중급
| 상황 | 정규식만으로 | 렉서가 필요한 이유 |
|---|---|---|
| 로그 한 줄 파싱 | 충분 | — |
| CSV (따옴표 포함) | 부족 | 상태가 필요 (인용 안/밖) |
| 검색 쿼리 문법 | 부족 | 중첩 · 우선순위 |
| 템플릿 엔진 | 부족 | 모드 전환 (텍스트/표현식) |
| 구문 강조 | 부분적 | 문자열·주석 안의 키워드 구분 |
| SQL · 코드 분석 | 불가능 | 완전한 문법 필요 |
# 상태를 가진 렉서 — 문자열 안의 이스케이프 처리
def lex_string(src, i):
"""i 는 여는 따옴표 위치"""
out, i = [], i + 1
while i < len(src):
c = src[i]
if c == "\\":
nxt = src[i+1]
out.append({"n": "\n", "t": "\t", '"': '"', "\\": "\\"}.get(nxt, nxt))
i += 2
elif c == '"':
return "".join(out), i + 1
elif c == "\n":
raise SyntaxError("문자열이 닫히지 않음")
else:
out.append(c); i += 1
raise SyntaxError("문자열이 닫히지 않음")
# ★ 정규식으로는 이스케이프된 따옴표를 정확히 다루기 어렵다
# "he said \"hi\"" 같은 입력에서 무너진다
실무에선 이렇게 — 에러 메시지의 품질 🔴 실무
✗ 쓸모없는 에러
SyntaxError: unexpected token
→ 어디서? 무엇을 기대했는데?
✓ 위치와 기대값을 함께
error: ')' 를 기대했는데 '}' 를 만났습니다
--> config.dsl:12:24
|
12 | filter(status = "open"}
| - ^ 여기
| |
| 이 괄호와 짝이 맞아야 합니다
def error(src, tok, message, hint=None):
lines = src.splitlines()
line = lines[tok.line - 1]
caret = " " * (tok.col - 1) + "^" * max(1, len(tok.value))
parts = [
f"error: {message}",
f" --> {tok.line}:{tok.col}",
f" |",
f"{tok.line:>3} | {line}",
f" | {caret}",
]
if hint: parts.append(f" = 힌트: {hint}")
raise SyntaxError("\n".join(parts))
# 좋은 에러 메시지의 세 요소
# ① 정확한 위치 (줄 · 열)
# ② 무엇을 기대했는지
# ③ 어떻게 고치면 되는지 (힌트)
구문 분석 — 토큰을 나무로
토큰 나열을 구조가 있는 트리(AST) 로 세웁니다. 린터 · 포매터 · 코드 변환 도구 · IDE 가 전부 이 트리 위에서 동작합니다.
AST — 우선순위가 구조가 된다
1 + 2 * 3
(+)
/ \
1 (*)
/ \
2 3
★ 트리 모양 자체가 계산 순서다
괄호도 필요 없고, 우선순위 규칙도 이미 반영돼 있다
파스 트리 vs AST
파스 트리 — 문법 규칙을 그대로 반영 (중간 노드가 많음)
AST — 의미에 필요한 것만 (실무에서 쓰는 것)
import ast
tree = ast.parse("x = 1 + 2 * 3")
print(ast.dump(tree, indent=2))
# 출력을 한 줄로 간추리면 (ctx=Store() · value= · 줄바꿈 생략)
# Module(body=[Assign(targets=[Name(id='x')],
# value=BinOp(left=Constant(1),
# op=Add(),
# right=BinOp(Constant(2), Mult(), Constant(3))))])
파서 종류
| 방식 | 특징 | 쓰는 곳 |
|---|---|---|
| 재귀 하강 | 손으로 작성 · 디버깅 쉬움 | GCC · Clang · TS |
| Pratt | 연산자 우선순위를 우아하게 | 표현식 파서 |
| LL(k) | 위에서 아래로 · 생성기 | ANTLR |
| LR / LALR | 아래에서 위로 · 강력 | yacc · bison |
| PEG | 순서 있는 선택 · 백트래킹 | 파이썬 3.9+ |
| 파서 컴비네이터 | 함수를 조합 | 함수형 언어 |
# Pratt 파서 — 우선순위를 표로 관리
PREC = {"||":1, "&&":2, "==":3, "<":4, "+":5, "-":5, "*":6, "/":6}
def parse_expr(toks, min_prec=0):
left = parse_atom(toks)
while toks.peek() and toks.peek().value in PREC:
op = toks.peek().value
prec = PREC[op]
if prec < min_prec: break
toks.next()
right = parse_expr(toks, prec + 1) # 좌결합
left = {"op": op, "left": left, "right": right}
return left
# 연산자를 추가하려면 PREC 표에 한 줄만 넣으면 된다
이렇게도 쓸 수 있다 — AST 로 코드 다루기 🟡 중급
# 실무 예: 코드베이스에서 위험한 호출을 전부 찾기
import ast, pathlib
class DangerFinder(ast.NodeVisitor):
DANGEROUS = {"eval", "exec", "pickle.loads", "os.system"}
def __init__(self, path):
self.path, self.hits = path, []
def visit_Call(self, node):
name = ast.unparse(node.func)
if name in self.DANGEROUS:
self.hits.append((self.path, node.lineno, name))
self.generic_visit(node)
for p in pathlib.Path("src").rglob("*.py"):
f = DangerFinder(p)
f.visit(ast.parse(p.read_text()))
for path, line, name in f.hits:
print(f"{path}:{line} {name}")
# ★ 정규식으로 하면 주석·문자열 안의 'eval' 도 걸린다.
# AST 는 진짜 호출만 잡는다.
| 도구 | 하는 일 | 기반 |
|---|---|---|
| ESLint · Ruff | 린팅 | AST 순회 |
| Prettier · Black | 포매팅 | AST → 재출력 |
| Babel · SWC | 변환 · 트랜스파일 | AST 변형 |
| codemod · jscodeshift | 대규모 리팩터링 | AST 변형 |
| tree-sitter | 구문 강조 · 증분 파싱 | 증분 파서 |
| Semgrep · CodeQL | 보안 분석 | AST + 데이터 흐름 |
실무에선 이렇게 — 에러 복구 🔴 실무
IDE 파서는 '첫 에러에서 멈추면' 쓸모가 없다
→ 에러가 있어도 최대한 파싱을 계속해야
자동완성 · 구문 강조 · 다른 에러 표시가 동작한다
에러 복구 전략
① 패닉 모드 — 동기화 토큰(; } 등)까지 건너뛰고 재개
② 구문 삽입 — 빠진 토큰을 있다고 가정하고 계속
③ 오류 노드 — 파싱 실패 구간을 특수 노드로 만들어 트리에 남김
④ 증분 파싱 — 바뀐 부분만 다시 (tree-sitter)
실무에서 이걸 알면
· IDE 가 "왜 여기서 자동완성이 안 되지" 를 이해하게 된다
· 파일 위쪽에 문법 오류가 있으면 아래 전체가 망가진다
# 패닉 모드 복구의 형태
SYNC = {";", "}", ")"}
def parse_statement(toks):
try:
return real_parse_statement(toks)
except SyntaxError as e:
errors.append(e)
while toks.peek() and toks.peek().value not in SYNC:
toks.next() # 동기화 지점까지 버린다
if toks.peek(): toks.next()
return ErrorNode(e) # 트리에는 남긴다
# 여러 에러를 한 번에 보고할 수 있게 된다
# → 사용자가 빌드를 여러 번 돌리지 않아도 된다
의미 분석 — 이름과 타입을 확인하는 단계
문법이 맞아도 말이 되는지는 다른 문제입니다. “선언되지 않은 변수”, “타입 불일치”가 여기서 잡히고, IDE 의 “정의로 이동”도 이 단계의 결과물입니다.
심볼 테이블 · 스코프
class Scope:
def __init__(self, parent=None):
self.symbols = {}
self.parent = parent
def declare(self, name, info):
if name in self.symbols:
raise NameError(f"'{name}' 이 이미 선언됨")
self.symbols[name] = info
def lookup(self, name):
s = self
while s: # ★ 안쪽부터 바깥으로
if name in s.symbols: return s.symbols[name]
s = s.parent
raise NameError(f"'{name}' 을 찾을 수 없음")
# 블록마다 새 스코프
# 이 '체인' 이 클로저와 변수 섀도잉의 근거다
var vs let).
호이스팅과 선언 시점
// JS — var 는 스코프 시작으로 끌어올려진다
console.log(x); // undefined (에러가 아님!)
var x = 1;
console.log(y); // ReferenceError (일시적 사각지대)
let y = 1;
// 함수 선언은 통째로 호이스팅
foo(); // 동작한다
function foo() {}
bar(); // TypeError
var bar = function() {};
// ★ 이 차이는 '의미 분석 단계에서 선언을 언제 등록하는가' 의 결과다
# 파이썬 — 할당이 있으면 그 변수는 지역 변수로 확정된다
x = 10
def f():
print(x) # UnboundLocalError!
x = 20 # ← 이 줄 때문에 x 가 지역 변수가 된다
def g():
global x # 명시하면 전역
x = 20
def outer():
y = 1
def inner():
nonlocal y # 바깥 함수의 지역 변수
y = 2
inner(); return y # 2
이렇게도 쓸 수 있다 — 타입 검사와 좁히기 🟡 중급
// 타입 좁히기(narrowing) — 의미 분석기가 흐름을 따라간다
function f(x: string | number | null) {
if (x === null) return "없음";
// 여기서 x 는 string | number
if (typeof x === "string") {
return x.toUpperCase(); // x 는 string
}
return x.toFixed(2); // x 는 number
}
// 판별 유니온 — 가장 안전한 패턴
type Result =
| { ok: true; value: number }
| { ok: false; error: string };
function handle(r: Result) {
if (r.ok) return r.value; // value 에 접근 가능
return r.error; // error 에 접근 가능
}
// 사용자 정의 타입 가드
function isUser(x: unknown): x is User {
return typeof x === "object" && x !== null && "id" in x;
}
// 망라성 검사 — 케이스를 빠뜨리면 컴파일 에러
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "rect": return s.w * s.h;
default:
const _exhaustive: never = s; // ★ 새 도형을 추가하면 여기서 잡힌다
throw new Error("unreachable");
}
}
never 를 이용한 망라성 검사는 실무에서 아주 유용합니다 —
새로운 케이스를 추가했을 때 처리를 빠뜨린 곳을 컴파일러가 전부 찾아 줍니다.
Rust 의 match, Kotlin 의 when 도 같은 보장을 제공합니다.
실무에선 이렇게 — 언어 서버(LSP) 🔴 실무
Language Server Protocol
에디터 ↔ 언어 서버 를 표준 프로토콜로 연결
이전: 에디터 N개 × 언어 M개 = N×M 개의 플러그인
이후: N + M 개
언어 서버가 제공하는 것 (전부 의미 분석의 결과물)
· 자동완성 (스코프에 무엇이 있나)
· 정의로 이동 (심볼 테이블 조회)
· 참조 찾기 (역방향 인덱스)
· 이름 바꾸기 (스코프를 정확히 알아야 안전)
· 타입 힌트 · 인레이 힌트
· 진단(에러·경고)
· 코드 액션 (자동 수정)
★ IDE 가 느린 이유도 여기 있다 — 프로젝트 전체의
심볼 테이블과 타입 정보를 유지해야 하므로 메모리와 CPU 를 많이 쓴다
| IDE 가 느릴 때 | 대응 |
|---|---|
| 프로젝트가 너무 큼 | 워크스페이스 분할 · 인덱스 제외 설정 |
| 타입이 지나치게 복잡 | 조건부 타입 · 깊은 제네릭 단순화 |
| 생성된 코드가 많음 | 인덱스에서 제외 (.gitignore 유사 설정) |
node_modules 스캔 | 제외 · skipLibCheck |
| 메모리 부족 | 언어 서버 힙 상향 |
tsc --extendedDiagnostics 로 타입 인스턴스화 수를 확인하고,
가장 비싼 타입 몇 개를 단순화하면 극적으로 개선됩니다.
중간 표현 · 최적화 — -O2 가 하는 일
컴파일러는 소스도 기계어도 아닌 중간 언어로 한번 옮긴 뒤 최적화합니다. 그 덕분에 언어 N개 × 아키텍처 M개 를 N + M 으로 줄일 수 있습니다.
LLVM 구조
C ─┐ ┌─▶ x86
C++ ─┤ ├─▶ ARM
Rust ─┼─▶ [ LLVM IR ] ─최적화─▶├─▶ RISC-V
Swift ─┤ ├─▶ WASM
Julia ─┘ └─▶ GPU
프런트엔드는 IR 만 만들면 되고
백엔드는 IR 만 읽으면 된다
SSA (Static Single Assignment)
각 변수에 딱 한 번만 대입한다
x = 1; x = 2; y = x;
→ x1 = 1; x2 = 2; y1 = x2;
· "이 값이 어디서 왔나" 가 자명해진다
· 데이터 흐름 분석이 훨씬 쉬워진다
· 거의 모든 현대 컴파일러가 SSA 를 쓴다
주요 최적화
| 최적화 | 내용 |
|---|---|
| 상수 폴딩 | 2*3 → 6 |
| 상수 전파 | 변수를 아는 값으로 치환 |
| 데드 코드 제거 | 결과를 안 쓰는 계산 삭제 |
| 인라인 | 함수 호출을 본문으로 치환 |
| 공통 부분식 제거 | 같은 계산을 한 번만 |
| 루프 불변식 이동 | 반복 안 변하는 계산을 밖으로 |
| 루프 언롤링 | 반복 횟수를 줄여 분기 감소 |
| 자동 벡터화 | SIMD 명령으로 (🖥️ 컴퓨터구조 10) |
| 꼬리 호출 최적화 | 재귀를 반복으로 |
| 강도 감소 | x*2 → x<<1 |
이렇게도 쓸 수 있다 — 최적화가 코드를 바꾸는 것을 보기 🟡 중급
// 이 함수가 -O2 에서 어떻게 되나
int sum(int n) {
int s = 0;
for (int i = 1; i <= n; i++) s += i;
return s;
}
// Clang -O2 결과 (요약)
// 반복문이 사라지고 수식 하나가 된다
// s = n * (n + 1) / 2
// → 컴파일러가 등차수열 합 공식을 찾아냈다 (귀납 변수 분석 · SCEV)
// ※ GCC 는 같은 -O2 에서도 반복문을 남기는 경우가 많다 — 컴파일러마다 다르다
// 확인
// godbolt.org 에서 -O0 과 -O2 를 나란히 비교
// ★ UB 가 있으면 최적화가 코드를 지운다
int f(int *p) {
int x = *p; // 여기서 역참조했으므로
if (p == NULL) // 컴파일러: "p 는 NULL 일 리 없다"
return -1; // → 이 검사를 통째로 삭제
return x;
}
// NULL 을 넘기면 크래시하는데, 검사는 사라졌다
// 부호 있는 정수 오버플로도 마찬가지
int g(int x) {
return x + 1 > x; // 컴파일러: "항상 참" → return 1
}
// x = INT_MAX 면 실제로는 거짓인데
-fsanitize=undefined 로 확인하세요 → 🔵 C++ 가이드.
실무에선 이렇게 — 최적화 옵션 고르기 🔴 실무
| 옵션 | 내용 | 쓸 때 |
|---|---|---|
-O0 | 최적화 없음 | 디버깅 |
-Og | 디버깅 친화 최적화 | 개발 중 기본 |
-O2 | 일반적인 최적화 | 배포 기본 |
-O3 | 공격적 (벡터화 · 인라인 확대) | 측정 후 선택 |
-Os · -Oz | 크기 우선 | 임베디드 · WASM |
-flto | 링크 시 전체 최적화 | 빌드 시간과 교환 |
-march=native | 이 CPU 전용 명령 사용 | 배포 환경이 같을 때만 |
-ffast-math | 부동소수점 규칙 완화 | 정확도 손실 주의 |
# PGO — 실제 실행 데이터를 보고 최적화
gcc -O2 -fprofile-generate -o app main.c
./app < typical_workload # 대표적인 부하로 실행
gcc -O2 -fprofile-use -o app main.c
# 분기 예측 · 인라인 · 코드 배치가 실제 사용 패턴에 맞춰진다
# 보통 5~20% 개선
# 다른 언어에서도
# · Rust: cargo-pgo
# · Go: PGO 지원 (1.21+)
# · .NET: 동적 PGO (기본 활성)
# · JVM: JIT 이 런타임에 항상 하는 일
-O3 가 항상 빠르지 않습니다 —
코드 크기가 커져 명령 캐시 미스가 늘면 오히려 느려집니다.
-O2 와 -O3 를 측정해서 고르세요.
-ffast-math 는 NaN·무한대 처리를 바꾸므로
수치 코드에서는 위험합니다.
코드 생성 · 레지스터 할당 — 컴파일러의 마지막 큰 결정
IR 을 실제 기계어로 옮기는 단계입니다. 핵심은 “어떤 값을 레지스터에 둘 것인가” — 레지스터는 수십 개뿐인데 변수는 훨씬 많습니다.
그래프 색칠 문제
레지스터 할당 = 그래프 색칠 (🧮 계산이론 11 — NP-완전)
· 정점 = 변수
· 간선 = "동시에 살아 있다" (간섭)
· 색 = 레지스터
· 같은 색을 인접 정점에 못 준다
레지스터가 부족하면 → 스필(spill)
· 일부 변수를 스택(메모리)에 저장
· 필요할 때 다시 로드
· ★ 느려진다
그래서 실무에서
· 함수가 너무 크면 스필이 늘어난다
· 반복문 안에서 변수를 많이 쓰면 마찬가지
· 인라인을 과하게 하면 레지스터 압박이 커진다
코드 생성이 만드는 차이
// 같은 의미, 다른 코드
// ① 조건 분기
int max_a(int a, int b) {
if (a > b) return a;
return b;
}
// → cmp + jump (분기 예측 실패 가능)
// ② 조건부 이동
int max_b(int a, int b) {
return a > b ? a : b;
}
// → cmp + cmov (분기 없음)
// ★ 현대 컴파일러는 대개 알아서 cmov 를 쓴다.
// 무엇을 쓸지는 '예측 가능성' 에 달렸다 —
// 예측이 잘 되는 분기는 분기가 더 빠르다.
이렇게도 쓸 수 있다 — 호출 규약(ABI) 🟡 중급
ABI 가 정하는 것
· 인자를 어느 레지스터·스택에 두는가
· 반환값은 어디에
· 어느 레지스터를 호출자가 보존하고 어느 것을 피호출자가
· 스택 정렬 · 구조체 배치 · 이름 맹글링
System V AMD64 (리눅스 · macOS)
정수 인자: rdi, rsi, rdx, rcx, r8, r9
부동소수점: xmm0~7
반환: rax (rdx 와 함께 128비트)
피호출자 보존: rbx, rbp, r12-r15
Windows x64
정수 인자: rcx, rdx, r8, r9 ← 다르다!
섀도 스페이스 32바이트 필요
★ 그래서 FFI 에서 ABI 를 맞추는 것이 중요하다
틀리면 컴파일은 되는데 실행 시 쓰레기 값이 들어온다
// C ABI 는 언어 간 공용어다
// C++ 는 이름 맹글링을 하므로 extern "C" 로 꺼야 한다
#ifdef __cplusplus
extern "C" {
#endif
int add(int a, int b);
#ifdef __cplusplus
}
#endif
// 구조체를 넘길 때는 레이아웃이 같아야 한다
// C: struct P { int x; double y; }; // 패딩 주의
// Python: class P(ctypes.Structure):
// _fields_ = [("x", c_int), ("y", c_double)]
// Rust: #[repr(C)] struct P { x: c_int, y: c_double }
실무에선 이렇게 — 생성된 코드 읽기 🔴 실무
| 확인하고 싶은 것 | 보는 법 |
|---|---|
| 이 함수가 인라인 됐나 | 어셈블리에 call 이 없는지 |
| 벡터화 됐나 | xmm/ymm 레지스터 · padd 계열 |
| 스필이 나나 | 반복문 안 mov [rsp+..] 가 많은지 |
| 분기가 남아 있나 | cmov vs j* |
| 내 검사가 지워졌나 | UB 로 삭제됐는지 (05절) |
| 바운드 체크가 남았나 | Rust · Go 에서 panic 호출 경로 |
# 컴파일러가 왜 최적화를 못 했는지 물어보기
gcc -O2 -fopt-info-vec-missed -c main.c
clang -O2 -Rpass-missed=loop-vectorize -c main.c
# "loop not vectorized: value that could not be identified as reduction"
# → 어떤 의존성 때문인지 알려 준다
# Rust — 바운드 체크 제거 확인
cargo asm --rust my_crate::my_function
# 또는 iterator 를 쓰면 대개 자동으로 제거된다
# for i in 0..v.len() { v[i] } ← 체크 남을 수 있음
# for x in &v { x } ← 체크 없음
# Go
go build -gcflags="-m" ./... # 인라인·이스케이프 분석 결과
-gcflags="-m" 이 특히 유용합니다 —
“이 변수가 힙으로 이스케이프했다”를 알려 주므로
불필요한 할당을 찾아 GC 압력을 줄일 수 있습니다.
링커와 로더 — undefined reference의 정체
여러 조각을 하나로 붙이는 단계입니다. C/C++ 개발자가 가장 자주 막히는 곳이고, 동적 라이브러리 문제는 다른 언어에서도 그대로 만납니다.
링커가 하는 일
① 심볼 해석
각 .o 파일의 "정의되지 않은 심볼(U)" 을
다른 .o 나 라이브러리의 "정의(T)" 와 연결
② 재배치
심볼의 최종 주소가 정해지면 참조하는 곳을 전부 수정
③ 섹션 병합
.text(코드) · .data · .bss · .rodata 를 모아서 배치
확인
nm main.o | grep " U " # 필요로 하는 심볼
nm libfoo.a | grep " T " # 제공하는 심볼
nm -C ... # C++ 이름 디맹글링
대표 에러와 원인
| 에러 | 원인 |
|---|---|
undefined reference to 'foo' | 정의가 없음 · .cpp 를 빌드에 안 넣음 · -l 누락 |
| 같은 에러인데 C++ 에서만 | extern "C" 누락 (맹글링 불일치) |
multiple definition | 헤더에 정의를 넣음 → inline · static |
cannot find -lfoo | 라이브러리 경로 → -L |
error while loading shared libraries | 런타임 경로 → LD_LIBRARY_PATH · rpath |
version GLIBC_2.34 not found | 빌드 환경이 실행 환경보다 최신 |
gcc -lfoo main.o 는 실패하고
gcc main.o -lfoo 는 성공합니다.
라이브러리는 그것을 쓰는 것 뒤에 놓으세요.
이렇게도 쓸 수 있다 — 정적 링크 vs 동적 링크 🟡 중급
| 정적 (.a · .lib) | 동적 (.so · .dll · .dylib) | |
|---|---|---|
| 실행 파일 크기 | 큼 | 작음 |
| 배포 | 단순 — 파일 하나 | 의존성을 함께 배포 |
| 메모리 | 프로세스마다 사본 | 여러 프로세스가 공유 |
| 보안 패치 | 재빌드 필요 | 라이브러리만 교체 |
| 시작 시간 | 빠름 | 심볼 해석 시간 |
| 버전 충돌 | 없음 | 있음 (의존성 지옥) |
# 무엇에 의존하는지
ldd app # 리눅스
otool -L app # macOS
dumpbin /dependents app.exe # 윈도우
# 런타임 검색 경로
readelf -d app | grep -E "RPATH|RUNPATH|NEEDED"
export LD_LIBRARY_PATH=/opt/mylib:$LD_LIBRARY_PATH # 임시
# 배포에는 rpath 를 쓰는 편이 낫다
gcc main.c -L/opt/mylib -lfoo -Wl,-rpath,'$ORIGIN/../lib'
# 정적으로 링크 (배포 단순화)
gcc -static main.c -o app # 전부 정적
gcc main.c -Wl,-Bstatic -lfoo -Wl,-Bdynamic -lm # 선택적
scratch · distroless 이미지를 쓸 수 있습니다.
이미지가 작아지고 공급망 공격 표면도 줄어듭니다.
실무에선 이렇게 — 다른 언어에서의 같은 문제 🔴 실무
| 언어 | “링크 에러”에 해당하는 것 |
|---|---|
| 파이썬 | ImportError · C 확장의 .so 로드 실패 · glibc 버전 |
| 자바 | NoClassDefFoundError · 클래스패스 · 버전 충돌 |
| Node | MODULE_NOT_FOUND · 네이티브 모듈 ABI 불일치 |
| .NET | 어셈블리 바인딩 · 버전 리다이렉트 |
| Go | cgo 사용 시 C 라이브러리 의존 |
# 파이썬 — C 확장이 안 불러와질 때
python -c "import numpy" # ImportError 의 실제 원인 확인
ldd $(python -c "import numpy, os; print(os.path.dirname(numpy.__file__))")/_core/_multiarray_umath*.so # numpy 1.x 는 core/
# manylinux 휠이 중요한 이유
# · 오래된 glibc 로 빌드해야 최신·구형 리눅스 모두에서 동작
# · auditwheel 이 필요한 .so 를 휠 안에 번들링
# 자바 — 어떤 jar 에서 클래스가 왔는지
java -verbose:class -jar app.jar | grep MyClass
jdeps --print-module-deps app.jar
# Node — 네이티브 모듈 ABI
node -p "process.versions.modules" # Node 버전마다 다르다
npm rebuild # 현재 Node 로 재빌드
인터프리터 · 바이트코드 VM — 파이썬과 자바가 도는 법
기계어 대신 가상 기계용 코드로 번역하고, 그것을 도는 반복문이 인터프리터입니다. 왜 느린지와 어디를 최적화할 수 있는지가 여기서 나옵니다.
스택 머신 — 파이썬 · JVM
import dis
dis.dis("x = a + b * 2")
# LOAD_NAME a 스택: [a]
# LOAD_NAME b 스택: [a, b]
# LOAD_CONST 2 스택: [a, b, 2]
# BINARY_OP * 스택: [a, b*2]
# BINARY_OP + 스택: [a + b*2]
# STORE_NAME x 스택: []
# 스택 머신의 특징
# · 명령이 짧다 (피연산자를 명시 안 함) → 바이트코드가 작다
# · 구현이 단순하다
# · 명령 수가 많다 → 디스패치 비용
# 레지스터 머신 (Lua · Dalvik · V8 Ignition)
# ADD r3, r1, r2 한 명령에 다 담는다
# · 명령 수가 적다 · 최적화하기 좋다
# · 명령이 길고 구현이 복잡
인터프리터가 느린 이유
// 인터프리터 루프의 뼈대
while (1) {
opcode = *pc++; // ① 명령 읽기
switch (opcode) { // ② 디스패치 (분기 예측 실패 잦음)
case LOAD_NAME: ... // ③ 실행
case BINARY_OP: ...
}
}
// 오버헤드
// · 명령마다 디스패치 (기계어는 CPU 가 직접)
// · 값이 전부 객체 (타입 확인 · 참조 카운트)
// · 스택 조작이 메모리 접근
// 개선 기법
// · 계산된 goto (switch 대신 점프 테이블) — CPython 이 사용
// · 슈퍼명령 (자주 나오는 조합을 하나로)
// · 인라인 캐싱 (타입 확인 결과를 기억) — CPython 3.11+
// · JIT (09절)
이렇게도 쓸 수 있다 — 바이트코드를 직접 보기 🟡 중급
# 성능 차이의 원인을 바이트코드로 확인
import dis
def f1(items):
out = []
for x in items:
out.append(x * 2)
return out
def f2(items):
return [x * 2 for x in items]
dis.dis(f1) # append 조회(3.12+ LOAD_ATTR · 3.11 이하 LOAD_METHOD) + CALL 이 매 반복마다
dis.dis(f2) # LIST_APPEND — 메서드 조회 없이 바로
# ★ 리스트 컴프리헨션이 빠른 이유가 여기 있다
# 메서드 조회(LOAD_ATTR/LOAD_METHOD)와 호출 오버헤드가 없다
# 전역 vs 지역 변수
def g1():
for i in range(1000): abs(i) # LOAD_GLOBAL abs — 매번 딕셔너리 조회
def g2():
_abs = abs # 지역으로 캐시
for i in range(1000): _abs(i) # LOAD_FAST — 배열 인덱스
# 3.11+ 에서는 LOAD_GLOBAL 도 캐시되어 차이가 줄었다
| 파이썬 성능 요령 | 이유 |
|---|---|
| 반복문을 내장 함수로 | sum·map 은 C 루프 |
| numpy 로 벡터화 | 인터프리터 루프 자체를 제거 |
| 지역 변수 사용 | LOAD_FAST 가 가장 빠름 |
| 속성 접근 캐시 | obj.method 를 변수에 |
__slots__ | 딕셔너리 대신 배열 |
| C 확장 · Cython · numba | 인터프리터 밖으로 |
실무에선 이렇게 — 바이트코드 배포와 보안 🔴 실무
| 사실 | 실무적 의미 |
|---|---|
.pyc · .class 는 쉽게 복원된다 | 소스 보호 수단이 아님 — 난독화도 한계 |
| 바이트코드는 버전에 묶인다 | 파이썬 3.11 pyc 는 3.12 에서 안 돌아감 |
| 클래스 로딩이 지연된다 | 첫 호출이 느림 (JVM 워밍업) |
| 바이트코드 조작이 가능하다 | APM · 모킹 · AOP 가 이걸 씀 |
| 역직렬화가 코드를 실행할 수 있다 | pickle · 자바 직렬화는 위험 |
# ★ pickle 은 신뢰되지 않은 데이터에 절대 쓰지 말 것
import pickle
# pickle.loads(untrusted) ← 임의 코드 실행 가능
# 안전한 대안
import json # 데이터만
import msgpack # 바이너리 · 데이터만
# 스키마가 있는 것: protobuf · Avro · Cap'n Proto
# 자바도 같은 문제 (역직렬화 취약점)
# · ObjectInputStream 에 외부 데이터를 넣지 말 것
# · 필요하면 필터 사용: ObjectInputFilter
JIT 컴파일 — 실행 중에 다시 컴파일
자주 도는 부분만 골라 기계어로 바꿉니다. 실행 중이라 실제 타입과 분기 통계를 알 수 있어 정적 컴파일러보다 유리한 경우도 있습니다.
동작 흐름
① 처음에는 인터프리터로 실행 (시작이 빠름)
② 호출 횟수·반복 횟수를 센다
③ 임계값을 넘으면 "뜨겁다(hot)" 고 판단
④ 그 메서드를 기계어로 컴파일
⑤ 실행 중 수집한 정보로 공격적으로 최적화
· 이 호출은 항상 같은 타입 → 타입 검사 생략 + 인라인
· 이 분기는 항상 참 → 반대쪽 제거
⑥ 가정이 깨지면 역최적화(deoptimization) → 인터프리터로 복귀
★ ⑤ 가 정적 컴파일러가 못 하는 일이다
실제 실행 데이터를 알고 있기 때문
JIT 계층
| 런타임 | 계층 |
|---|---|
| V8 | Ignition(인터프리터) → Sparkplug → Maglev → TurboFan |
| HotSpot | 인터프리터 → C1(빠른 컴파일) → C2(공격적) |
| .NET | Tier0 → Tier1 (+ 동적 PGO) |
| PyPy | 인터프리터 → 트레이싱 JIT |
| LuaJIT | 인터프리터 → 트레이싱 JIT |
메서드 JIT vs 트레이싱 JIT
메서드 — 뜨거운 '함수' 단위로 컴파일 (V8 · JVM)
트레이싱 — 뜨거운 '실행 경로' 단위로 (PyPy · LuaJIT)
반복문 한 바퀴의 실제 경로를 기록해 컴파일
분기가 예상과 다르면 가드에서 탈출
이렇게도 쓸 수 있다 — JIT 를 방해하지 않는 코드 🟡 중급
| JIT 에 나쁜 것 | 이유 |
|---|---|
| 같은 자리에서 타입이 바뀜 | 다형성 → 인라인 캐시 실패 |
| 객체 모양이 제각각 | 히든 클래스 폭발 (V8) |
| 메가모픽 호출 (4종류 이상) | 인라인 캐시 포기 |
| 거대한 함수 | 인라인 안 됨 · 컴파일 지연 |
try/catch 를 뜨거운 루프에 | 일부 런타임에서 최적화 저해 |
arguments · eval · with | 최적화 자체를 막음 (JS) |
| 배열에 구멍 | 희소 배열 → 느린 경로 |
// ✗ 객체 모양이 달라지면 히든 클래스가 갈라진다
function make(a, b) {
const o = {};
o.a = a;
if (b !== undefined) o.b = b; // 모양이 두 가지
return o;
}
// ✓ 항상 같은 모양으로
function make(a, b) {
return { a, b: b ?? null }; // 한 가지 모양
}
// ✗ 같은 자리에서 여러 타입 (메가모픽)
function get(o) { return o.value; } // Foo, Bar, Baz, Qux ... 로 호출
// ✓ 타입별로 나누거나 단일 인터페이스로
// ★ 확인
// node --trace-deopt --trace-opt app.js
// node --allow-natives-syntax -e "%GetOptimizationStatus(f)"
실무에선 이렇게 — 워밍업과 벤치마크 🔴 실무
JIT 환경에서의 벤치마크 함정
① 워밍업 없이 측정 → 인터프리터 속도를 잼
② 결과를 안 쓰면 → 데드 코드로 삭제됨
③ 상수를 넣으면 → 상수 폴딩으로 계산 자체가 사라짐
④ 반복 횟수가 적으면 → JIT 이 발동 안 함
⑤ 같은 프로세스에서 여러 벤치마크 → 서로 오염 (프로파일 공유)
★ 마이크로 벤치마크는 전용 도구를 쓸 것
자바: JMH · JS: benchmark.js/tinybench
.NET: BenchmarkDotNet · Go: testing.B
파이썬: pyperf · timeit (JIT 없지만 GC·캐시 영향)
| 운영에서의 워밍업 이슈 | 대응 |
|---|---|
| 배포 직후 p99 가 튐 | 워밍업 요청 · 카나리로 서서히 |
| 오토스케일 신규 인스턴스가 느림 | 준비 상태 확인 후 트래픽 투입 |
| 서버리스 콜드 스타트 | AOT · SnapStart · 프로비저닝된 동시성 |
| 배치 작업이 매번 느림 | 프로세스를 오래 유지 |
# 자바 — AOT 로 워밍업 없애기
native-image -jar app.jar # GraalVM
# 시작 수십 ms · 메모리 절감 · 다만 최대 처리량은 JIT 이 유리
# AWS Lambda SnapStart — 초기화 후 스냅숏을 떠서 복원
# .NET ReadyToRun — 사전 컴파일된 코드를 포함
# 쿠버네티스 — 워밍업 후에 트래픽을 받게
# readinessProbe 를 /ready 로 두고
# 그 핸들러에서 주요 경로를 몇 번 실행한 뒤 200 을 반환
가비지 컬렉션 — 멈춤 시간이 어디서 오나
“아무도 안 가리키는 메모리”를 찾아 회수합니다. 방식에 따라 멈추는 시간과 처리량이 달라지고, 그것이 p99 지연에 직접 나타납니다.
기본 방식들
| 방식 | 원리 | 약점 |
|---|---|---|
| 참조 카운팅 | 참조 수를 세다가 0 이면 해제 | 순환 참조 · 갱신 비용 |
| 마크 앤 스윕 | 루트에서 도달 가능한 것을 표시 후 나머지 해제 | 단편화 · 정지 |
| 마크 앤 컴팩트 | 표시 후 살아 있는 것을 모음 | 이동 비용 |
| 복사 | 살아 있는 것만 반대편으로 복사 | 메모리 2배 |
| 세대별 | 새 객체를 자주, 오래된 것은 가끔 | 세대 간 참조 관리 |
언어별 GC
| 언어 | 방식 |
|---|---|
| 파이썬 | 참조 카운팅 + 순환 탐지 (세대별) |
| 자바 | G1 (기본) · ZGC · Shenandoah (저지연) |
| Go | 동시 마크 앤 스윕 · 매우 짧은 STW |
| JS (V8) | 세대별 · Scavenger + Mark-Compact |
| C# | 세대별 · 서버/워크스테이션 모드 |
| Rust · C++ | GC 없음 — 소유권 · RAII |
| Swift | ARC (컴파일 시 삽입된 참조 카운팅) |
# 파이썬의 순환 참조
import gc
class Node: pass
a, b = Node(), Node()
a.ref = b; b.ref = a # 순환 — 참조 카운트가 0 이 안 된다
del a, b
gc.collect() # 순환 수집기가 처리
# __del__ 이 있으면 파이썬 3.4 이전에는 수집 불가였다
# 지금은 되지만 여전히 순환 + __del__ 은 피하는 것이 좋다
gc.get_stats() # 세대별 수집 통계
gc.freeze() # fork 전에 호출하면 CoW 페이지 복사 감소
이렇게도 쓸 수 있다 — GC 지연 줄이기 🟡 중급
| 기법 | 효과 | 주의 |
|---|---|---|
| 할당 자체를 줄인다 | 가장 근본적 | 가독성과 균형 |
| 객체 재사용 · 풀 | 젊은 세대 압력 감소 | 누수 · 상태 오염 위험 |
| 값 타입 · 원시 배열 | 객체 수 감소 | 언어 지원 필요 |
| 저지연 GC 선택 | STW 를 ms 단위로 | 처리량 약간 감소 |
| 힙 크기 조정 | 수집 빈도 조절 | 너무 크면 한 번이 길어짐 |
| 오프힙 저장 | GC 대상에서 제외 | 직접 관리 필요 |
# 자바 — GC 로그를 켜는 것이 첫 단계
java -Xlog:gc*:file=gc.log:time,uptime -jar app.jar
# 분석: GCViewer · gceasy.io
# 저지연이 필요하면
java -XX:+UseZGC -Xmx8g -jar app.jar # STW < 1ms 목표
java -XX:+UseShenandoahGC ...
# 처리량이 중요하면
java -XX:+UseParallelGC ...
# 컨테이너에서 (⚙️ OS 06 참고)
java -XX:MaxRAMPercentage=70 ...
# Go
GOGC=200 ./app # 힙이 살아 있는 양의 3배가 되면 수집 (기본 100 = 2배)
GOMEMLIMIT=6GiB ./app # 소프트 메모리 상한 (1.19+) ★ 권장
GODEBUG=gctrace=1 ./app # GC 로그
GOMEMLIMIT 이 컨테이너에서 특히 유용합니다 —
메모리 상한에 가까워지면 GC 를 더 자주 돌려
OOMKilled 를 예방합니다. 컨테이너 limit 의 80% 정도로 잡습니다.
실무에선 이렇게 — GC 로 인한 장애 패턴 🔴 실무
| 증상 | 원인 | 확인 |
|---|---|---|
| 주기적으로 수백 ms 멈춤 | Full GC · STW | GC 로그의 pause 시간 |
| CPU 는 높은데 처리량 낮음 | GC 가 CPU 를 먹음 | GC 시간 비율 (>5% 면 문제) |
| 메모리가 계속 증가 | 누수 (도달 가능한 쓰레기) | 힙 덤프 · 지배자 트리 |
| OOM 직전 GC 가 계속 돎 | 회수할 것이 없음 | 죽기 전 힙 덤프 필수 |
| 지연이 톱니 모양 | 정상적인 GC 주기 | — |
| 부하가 적을 때만 느림 | JIT 역최적화 · 캐시 냉각 | 워밍업 |
# 자바 — 죽기 전에 힙 덤프를 남기도록 설정 (필수)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps/heap.hprof
-XX:+ExitOnOutOfMemoryError
# 실행 중 덤프
jcmd PID GC.heap_dump /tmp/heap.hprof
jmap -dump:live,format=b,file=/tmp/heap.hprof PID
# 분석 — Eclipse MAT 의 "Leak Suspects" 리포트가 가장 빠르다
# 지배자 트리(Dominator Tree)에서 큰 것부터 본다
# 흔한 누수 원인
# · static 컬렉션에 계속 추가
# · ThreadLocal 을 remove 안 함 (스레드 풀에서 치명적)
# · 리스너·콜백 등록 후 해제 안 함
# · 캐시에 만료 정책 없음
# · ClassLoader 누수 (핫 리로드 환경)
타입 시스템 — 실행 전에 잡아 주는 증명
타입은 “이 값에 대해 할 수 있는 일”을 미리 제한해 틀린 조합을 컴파일 시점에 막습니다. 어디까지 막아 줄 수 있고 어디부터는 못 하는지를 봅니다.
축들
| 구분 | 뜻 | 예 |
|---|---|---|
| 정적 / 동적 | 검사 시점 | 자바 / 파이썬 |
| 강함 / 약함 | 암묵 변환 정도 | 파이썬 / JS · C |
| 명시 / 추론 | 타입을 적는가 | 자바 / Haskell · Rust |
| 구조적 / 명목적 | 모양으로? 이름으로? | TypeScript / 자바 |
흔한 오해
· "동적 = 약한 타입" (아니다)
파이썬은 동적이지만 강하다: "1" + 1 → 에러
JS 는 동적이고 약하다: "1" + 1 → "11"
· "정적 타입 = 안전" (부분적)
자바도 NullPointerException 이 난다
→ 널 안전성은 별개의 축
타입이 잡아 주는 것 / 못 잡는 것
✓ 잡아 준다
· 타입 불일치
· 오타 (필드명 · 메서드명)
· 인자 개수 · 순서
· 널 가능성 (널 안전 언어)
· 케이스 누락 (망라성)
· 리팩터링 시 놓친 곳
✗ 못 잡는다
· 로직 오류 (a+b 를 a-b 로)
· 단위 혼동 (미터 vs 피트)*
· 배열 범위 초과*
· 경쟁 상태*
· 성능 문제
· 요구사항 오해
* 고급 타입으로 일부 가능
type Meters = number & {__brand: "m"} 같은
브랜드 타입이나 Rust 의 newtype 을 쓰면
미터와 피트를 섞는 것이 컴파일 에러가 됩니다.
화성 탐사선을 잃은 실제 사고의 원인이기도 합니다.
이렇게도 쓸 수 있다 — 타입 추론 — 힌들리-밀너 🟡 중급
타입을 안 적었는데 컴파일러가 알아내는 방법
let f = fun x -> x + 1
① 미지수를 둔다: x : α, f : α → β
② 제약을 모은다: + 는 int → int → int 이므로 α = int, β = int
③ 단일화(unification)로 제약을 푼다
④ 결과: f : int → int
일반화(generalization)
let id = fun x -> x
제약이 없으므로 α 가 자유롭다 → id : ∀α. α → α (제네릭!)
한계
· 부분 타입(subtyping)이 있으면 어려워진다 (OOP 언어)
· 그래서 자바·C# 은 지역 변수만 추론(var)
· TypeScript 는 구조적 타입 + 추론을 섞어 복잡하지만 강력하다
// 타입 레벨 프로그래밍 — 타입 자체가 계산이 된다
type Path<T> = T extends object
? { [K in keyof T & string]:
T[K] extends object ? `${K}` | `${K}.${Path<T[K]>}` : `${K}` }[keyof T & string]
: never;
type Config = { db: { host: string; port: number }; debug: boolean };
type Keys = Path<Config>;
// "db" | "db.host" | "db.port" | "debug"
function get<T, P extends Path<T>>(obj: T, path: P): unknown { ... }
get(config, "db.host"); // ✓
get(config, "db.hostt"); // ✗ 컴파일 에러
// ★ 강력하지만 과하면 IDE 가 느려지고 에러 메시지가 읽기 어려워진다
실무에선 이렇게 — 점진적 타입 도입 🔴 실무
| 단계 | 내용 |
|---|---|
| ① 경계부터 | API 요청·응답 · DB 모델 · 설정 — 외부와 만나는 곳 |
| ② 런타임 검증과 함께 | zod · pydantic — 타입과 검증을 한 소스에서 |
| ③ 새 코드부터 엄격하게 | strict 를 새 파일에만 |
| ④ 자주 고치는 파일부터 | 변경이 잦은 곳이 이득이 큼 |
⑤ any 를 점진적으로 제거 | 린트 규칙으로 증가를 막음 |
# 파이썬 — 런타임 검증과 타입을 한 번에
from pydantic import BaseModel, Field
class CreateOrder(BaseModel):
user_id: int
items: list[int] = Field(min_length=1, max_length=100)
coupon: str | None = None
# ★ 타입 힌트가 곧 검증 규칙이 된다
# 외부 입력을 이걸로 받으면 타입과 검증이 어긋날 수 없다
# 정적 검사
# mypy --strict src/
# pyright src/
// TS — zod 로 런타임 검증 + 타입 추론
import { z } from "zod";
const CreateOrder = z.object({
userId: z.number().int().positive(),
items: z.array(z.number()).min(1).max(100),
coupon: z.string().optional(),
});
type CreateOrder = z.infer<typeof CreateOrder>; // 타입이 자동 생성
const parsed = CreateOrder.parse(req.body); // 실패 시 예외
// ★ 여기를 통과하면 이후 코드에서 타입이 보장된다
패러다임 · 람다 계산법 — “함수형이 좋다”의 실제 내용
패러다임은 종교가 아니라 도구 상자입니다. 각각이 무엇을 쉽게 만들고 무엇을 어렵게 만드는지, 그리고 실무에서 어떻게 섞어 쓰는지를 봅니다.
패러다임 비교
| 패러다임 | 중심 | 강점 |
|---|---|---|
| 절차형 | 순서 | 단순 · 직관적 |
| 객체지향 | 상태 + 행위 | 도메인 모델링 · 다형성 |
| 함수형 | 변환 | 동시성 · 테스트 · 추론 |
| 선언형 | 결과 명세 | 최적화 여지 (SQL · React) |
| 데이터 지향 | 데이터 배치 | 성능 (🖥️ 컴퓨터구조 08) |
# 같은 문제, 다른 패러다임
# 절차형
total = 0
for o in orders:
if o.status == "paid":
total += o.amount
# 함수형
total = sum(o.amount for o in orders if o.status == "paid")
# 선언형 (SQL)
# SELECT SUM(amount) FROM orders WHERE status = 'paid'
# ★ 아래로 갈수록 '어떻게' 가 사라지고 '무엇' 만 남는다
# → 최적화 여지가 커진다 (병렬화 · 인덱스 활용)
함수형의 실제 이득
| 개념 | 실무 이득 |
|---|---|
| 순수 함수 | 테스트가 쉬움 · 캐시 가능 · 병렬 안전 |
| 불변성 | 경쟁 상태 없음 · 스냅숏 · 시간 여행 디버깅 |
| 고차 함수 | 중복 제거 · 조합 |
| 대수적 자료형 | 불가능한 상태를 표현 불가능하게 |
| 지연 평가 | 무한 스트림 · 필요한 것만 계산 |
| 커링 · 부분 적용 | 설정 주입 · 재사용 |
# 순수 함수는 테스트가 다르다
# ✗ 부수효과가 있는 함수
def process(order):
order.status = "done" # 상태 변경
db.save(order) # 외부 의존
send_email(order.user) # 외부 의존
return order
# ✓ 계산과 효과를 분리
def compute_next(order) -> Order: # 순수 — 테스트에 모킹 불필요
return replace(order, status="done")
def apply(order): # 효과는 얇게
new = compute_next(order)
db.save(new); send_email(new.user)
return new
이렇게도 쓸 수 있다 — 람다 계산법 — 계산의 다른 정의 🟡 중급
람다 계산법은 함수 세 가지만으로 모든 계산을 표현한다
변수 x
추상화 λx. M (함수 정의)
적용 M N (함수 호출)
★ 튜링 기계와 계산 능력이 정확히 같다 (처치-튜링)
숫자도 함수로 표현할 수 있다 (처치 수)
0 = λf.λx. x
1 = λf.λx. f x
2 = λf.λx. f (f x)
succ = λn.λf.λx. f (n f x)
불린도, 조건문도, 재귀(Y 콤비네이터)도 전부 함수로
실무적 의미
· 클로저 = λ 추상화 + 환경 캡처
· 커링 = 다인자 함수를 단인자 함수의 연쇄로
· 함수형 언어의 이론적 뿌리
# 클로저 — 환경을 캡처한다
def make_counter():
count = 0
def inc():
nonlocal count
count += 1
return count
return inc
c = make_counter()
c(); c() # 1, 2 ← count 가 살아 있다
# ★ 반복문 안의 클로저 함정 (모든 언어에 있다)
fns = [lambda: i for i in range(3)]
[f() for f in fns] # [2, 2, 2] ← i 를 공유
fns = [lambda i=i: i for i in range(3)] # 기본값으로 캡처
[f() for f in fns] # [0, 1, 2]
# JS 에서는 let 이 블록 스코프라 자연히 해결된다
# for (let i = 0; i < 3; i++) fns.push(() => i); // 0,1,2
실무에선 이렇게 — 섞어 쓰기 🔴 실무
실무의 현실적인 구성
경계(I/O · DB · 네트워크) — 명령형 · 효과가 있는 얇은 층
도메인 로직 — ★ 순수 함수 · 불변 데이터
데이터 모델 — 대수적 자료형 · 값 객체
구성 · 조립 — 객체지향 (의존성 주입)
질의 — 선언형 (SQL · 쿼리 빌더)
성능 임계 구간 — 데이터 지향 (배열 · SoA)
"함수형 코어, 명령형 셸"
· 결정 로직은 전부 순수 함수로 (테스트 쉬움)
· 효과는 가장자리에서 한 번에 (모킹 최소)
| 함수형을 과하게 쓰면 | 문제 |
|---|---|
| 모든 것을 불변으로 | 대량 데이터에서 복사 비용 |
| 깊은 함수 합성 | 스택 트레이스가 읽기 어려움 |
| 모나드 남용 | 팀이 이해 못 하면 부채 |
| 과도한 커링 | 디버깅 어려움 |
| 재귀 고집 | 스택 오버플로 (파이썬 · 자바) |
미니 언어 만들기 — 200줄로 인터프리터
앞의 모든 단계를 한 번에 엮어 봅니다. 렉서 → 파서 → 평가기를 직접 만들면 컴파일러가 갑자기 익숙해집니다. 실무에서 DSL 을 만들 때 그대로 씁니다.
전체 코드 — 변수 · 조건 · 반복이 있는 언어
import re
from dataclasses import dataclass
# ───────── 1. 렉서 ─────────
TOKEN = re.compile(r"""
(?P<NUM>\d+(?:\.\d+)?)
| (?P<ID>[A-Za-z_]\w*)
| (?P<OP><=|>=|==|!=|[-+*/<>=])
| (?P<PUNCT>[(){};,])
| (?P<WS>\s+)
| (?P<COMMENT>\#[^\n]*)
""", re.VERBOSE)
KEYWORDS = {"let", "if", "else", "while", "print"}
def tokenize(src):
pos, out = 0, []
while pos < len(src):
m = TOKEN.match(src, pos)
if not m: raise SyntaxError(f"알 수 없는 문자: {src[pos]!r} (위치 {pos})")
pos = m.end()
kind, val = m.lastgroup, m.group()
if kind in ("WS", "COMMENT"): continue
if kind == "ID" and val in KEYWORDS: kind = "KW"
out.append((kind, val))
out.append(("EOF", ""))
return out
# ───────── 2. 파서 (재귀 하강) ─────────
class Parser:
def __init__(self, toks): self.t, self.i = toks, 0
def peek(self): return self.t[self.i]
def next(self): self.i += 1; return self.t[self.i - 1]
def accept(self, val):
if self.peek()[1] == val: return self.next()
return None
def expect(self, val):
if not self.accept(val):
raise SyntaxError(f"'{val}' 를 기대했는데 {self.peek()[1]!r}")
def program(self):
stmts = []
while self.peek()[0] != "EOF": stmts.append(self.statement())
return ("block", stmts)
def statement(self):
if self.accept("let"):
name = self.next()[1]; self.expect("="); e = self.expr(); self.expect(";")
return ("let", name, e)
if self.accept("print"):
e = self.expr(); self.expect(";"); return ("print", e)
if self.accept("if"):
self.expect("("); c = self.expr(); self.expect(")")
then = self.block()
els = self.block() if self.accept("else") else None
return ("if", c, then, els)
if self.accept("while"):
self.expect("("); c = self.expr(); self.expect(")")
return ("while", c, self.block())
name = self.next()[1]; self.expect("="); e = self.expr(); self.expect(";")
return ("assign", name, e)
def block(self):
self.expect("{"); stmts = []
while not self.accept("}"): stmts.append(self.statement())
return ("block", stmts)
# 우선순위: 비교 < 덧셈 < 곱셈 < 단항
def expr(self): return self.binary(self.add, ("<", ">", "<=", ">=", "==", "!="))
def add(self): return self.binary(self.mul, ("+", "-"))
def mul(self): return self.binary(self.unary, ("*", "/"))
def binary(self, sub, ops):
left = sub()
while self.peek()[1] in ops:
op = self.next()[1]
left = ("bin", op, left, sub())
return left
def unary(self):
if self.accept("-"): return ("neg", self.unary())
return self.atom()
def atom(self):
kind, val = self.next()
if kind == "NUM": return ("num", float(val))
if kind == "ID": return ("var", val)
if val == "(":
e = self.expr(); self.expect(")"); return e
raise SyntaxError(f"예상치 못한 토큰: {val!r}")
# ───────── 3. 평가기 ─────────
import operator
BINOPS = {"+": operator.add, "-": operator.sub, "*": operator.mul,
"/": operator.truediv, "<": operator.lt, ">": operator.gt,
"<=": operator.le, ">=": operator.ge,
"==": operator.eq, "!=": operator.ne}
def evaluate(node, env, out):
kind = node[0]
if kind == "block":
for s in node[1]: evaluate(s, env, out)
elif kind == "let" or kind == "assign":
env[node[1]] = evaluate(node[2], env, out)
elif kind == "print":
out.append(evaluate(node[1], env, out))
elif kind == "if":
if evaluate(node[1], env, out): evaluate(node[2], env, out)
elif node[3]: evaluate(node[3], env, out)
elif kind == "while":
guard = 0
while evaluate(node[1], env, out):
evaluate(node[2], env, out)
guard += 1
if guard > 1_000_000: raise RuntimeError("반복 한도 초과") # 🧮 계산이론 06
elif kind == "bin":
return BINOPS[node[1]](evaluate(node[2], env, out), evaluate(node[3], env, out))
elif kind == "neg": return -evaluate(node[1], env, out)
elif kind == "num": return node[1]
elif kind == "var":
if node[1] not in env: raise NameError(f"정의되지 않은 변수: {node[1]}")
return env[node[1]]
def run(src):
out = []
evaluate(Parser(tokenize(src)).program(), {}, out)
return out
# ───────── 실행 ─────────
print(run("""
let n = 10;
let a = 0;
let b = 1;
while (n > 0) {
let t = a + b;
a = b;
b = t;
n = n - 1;
}
print a; # 피보나치 10번째
"""))
# [55.0]
이렇게도 쓸 수 있다 — 여기서 더 나아가려면 🟡 중급
| 다음 단계 | 배우게 되는 것 |
|---|---|
| 함수 · 클로저 추가 | 스코프 체인 · 환경 모델 (04절) |
| 타입 검사기 추가 | 단일화 · 추론 (11절) |
| 바이트코드로 컴파일 | VM · 스택 머신 (08절) |
| 최적화 패스 추가 | 상수 폴딩 · 데드 코드 (05절) |
| REPL · 에러 복구 | 사용성 (03절) |
| 가비지 컬렉터 | 메모리 관리 (10절) |
더 볼 만한 자료
· Crafting Interpreters (craftinginterpreters.com) — 무료 · 두 가지 구현
· Writing an Interpreter in Go
· Nand2Tetris — 논리 게이트부터 OS 까지
★ 하나를 끝까지 만들어 보는 것이 열 권을 읽는 것보다 낫다
실무에선 이렇게 — 언어를 고르는 기준 · 이어지는 곳 🔴 실무
| 기준 | 질문 |
|---|---|
| 생태계 | 필요한 라이브러리가 성숙한가 — 가장 큰 요인 |
| 팀 역량 | 지금 사람들이 잘 쓰는가 · 채용이 되는가 |
| 실행 특성 | 지연 · 처리량 · 메모리 · 시작 시간 |
| 안전성 | 타입 · 메모리 · 동시성 보장 수준 |
| 운영 | 배포 · 관측 · 디버깅 도구 |
| 장기 유지 | 버전 정책 · 하위 호환 |
| 여기서 배운 것 | 다음 |
|---|---|
| 02~04 렉서 · 파서 · 의미 분석 | 🧮 계산이론 02~05 |
| 05·06 최적화 · 코드 생성 | 🖥️ 컴퓨터구조 06~08 |
| 07 링커 · 로더 | ⚙️ 운영체제 05 · 🔵 C++ 가이드 |
| 10 GC | ⚙️ 운영체제 06 · ☕ Java 가이드 |
| 11 타입 시스템 | 🟨 JS · TS 가이드 TypeScript 탭 |
| 12 패러다임 | 🏛️ 소프트웨어 공학 전체 |
무엇을
누구로부터
지킬 것인가
보안은 기술 목록이 아니라 판단입니다 — 무엇이 자산이고, 누가 공격자이고, 어디까지 막을 것인가. 이 탭은 암호의 원리를 문서를 읽을 수 있을 만큼 설명하고, 절대 직접 구현하지 말아야 할 것을 명확히 구분합니다. 마지막 절은 배포 전 체크리스트입니다.
보안의 기본 원칙 — 기술보다 먼저
“보안을 강화하자”는 말로는 아무것도 정해지지 않습니다. 무엇을(자산) 누구로부터(위협) 어느 수준까지(비용) 를 정하는 것이 먼저입니다.
CIA 와 그 확장
| 속성 | 뜻 | 깨지면 |
|---|---|---|
| 기밀성 | 허가된 사람만 본다 | 정보 유출 |
| 무결성 | 변조되지 않는다 | 데이터 위조 |
| 가용성 | 필요할 때 쓸 수 있다 | 서비스 중단 |
| 인증 | 누구인지 확인 | 사칭 |
| 인가 | 권한 확인 | 권한 상승 |
| 부인 방지 | 했다는 증거 | 발뺌 |
설계 원칙
| 원칙 | 실무 적용 |
|---|---|
| 최소 권한 | 필요한 권한만 · 기간 제한 |
| 심층 방어 | 한 겹이 뚫려도 다음이 막음 |
| 실패 시 안전 | 에러가 나면 거부가 기본 |
| 공격 표면 최소화 | 안 쓰는 포트·기능·의존성 제거 |
| 완전한 중재 | 모든 접근을 매번 검사 |
| 공개 설계 | 알고리즘 비밀에 의존하지 않음 |
| 신뢰 경계 명시 | 어디까지 믿는지 문서화 |
# '실패 시 안전' 의 차이
# ✗ 에러가 나면 통과
def can_access(user, doc):
try:
return check_permission(user, doc)
except Exception:
return True # ← 재앙
# ✓ 에러가 나면 거부
def can_access(user, doc):
try:
return check_permission(user, doc)
except Exception:
log.exception("권한 확인 실패")
return False # ← 안전한 기본값
이렇게도 쓸 수 있다 — 신뢰 경계 그리기 🟡 중급
신뢰 경계 = "여기서부터는 입력을 믿지 않는다" 는 선
[브라우저] ← 절대 신뢰 불가 (전부 조작 가능)
│ ← ★ 경계 1
[API 게이트웨이]
│ ← ★ 경계 2 (서비스 간에도 신뢰하지 않는 것이 제로 트러스트)
[애플리케이션]
│ ← ★ 경계 3
[데이터베이스]
각 경계에서 해야 할 일
· 인증 · 인가 확인
· 입력 검증 (형식 · 범위 · 권한)
· 출력 인코딩 (다음 층에 맞게)
· 레이트 리밋 · 감사 로그
★ 클라이언트 검증은 UX 를 위한 것이지 보안이 아니다
서버는 항상 다시 검증해야 한다
| 절대 신뢰하면 안 되는 것 | 이유 |
|---|---|
| 클라이언트가 보낸 가격 · 권한 · ID | 전부 조작 가능 |
| 숨겨진 폼 필드 · 쿠키 값 | 같은 이유 |
HTTP 헤더 (X-Forwarded-For 등) | 위조 가능 (🌐 네트워크 11) |
| 파일 확장자 · Content-Type | 내용과 무관 |
| 리퍼러 · User-Agent | 임의 설정 가능 |
JWT 의 alg 헤더 | 알고리즘 혼동 공격 |
실무에선 이렇게 — 보안 비용의 균형 🔴 실무
| 투자 | 비용 | 효과 |
|---|---|---|
| 의존성 자동 스캔 | 매우 낮음 | 매우 높음 |
| 시크릿 스캔 (커밋 훅) | 매우 낮음 | 매우 높음 |
| 프레임워크 기본 방어 사용 | 낮음 | 높음 |
| MFA · 최소 권한 IAM | 낮음 | 매우 높음 |
| 정적 분석 (SAST) | 중간 | 중간 (오탐 많음) |
| 침투 테스트 | 높음 | 높음 (연 1회 정도) |
| 자체 암호 구현 | 높음 | 음수 — 하지 말 것 |
해시 함수 · 무결성 — 되돌릴 수 없는 지문
임의 길이 입력을 고정 길이 지문으로 바꿉니다. 되돌릴 수 없고, 한 비트만 바뀌어도 완전히 달라집니다. 용도에 맞는 해시를 고르는 것이 이 절의 목표입니다.
암호학적 해시의 요구 조건
| 성질 | 뜻 |
|---|---|
| 역상 저항성 | 해시값으로 원본을 못 찾음 |
| 제2 역상 저항성 | 같은 해시를 내는 다른 입력을 못 찾음 |
| 충돌 저항성 | 같은 해시를 내는 아무 두 입력을 못 찾음 |
| 눈사태 효과 | 1비트 변화 → 출력 절반 변화 |
import hashlib
hashlib.sha256(b"hello").hexdigest()
# 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
hashlib.sha256(b"hellp").hexdigest() # 한 글자 차이
# fdd7585e... 완전히 다르다
# 파일 무결성
def file_hash(path, algo="sha256"):
h = hashlib.new(algo)
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
알고리즘 선택
| 알고리즘 | 상태 | 용도 |
|---|---|---|
| MD5 | 깨짐 | 체크섬만 · 보안 금지 |
| SHA-1 | 깨짐 | 레거시 호환만 |
| SHA-256 | 안전 | 기본 선택 |
| SHA-512 | 안전 | 64비트에서 더 빠를 수 있음 |
| SHA-3 | 안전 | 구조가 다른 대안 |
| BLAKE3 | 안전 | 매우 빠름 · 병렬 |
| xxHash · CRC32 | 비암호 | 체크섬 · 해시맵 |
이렇게도 쓸 수 있다 — HMAC — 키가 있는 해시 🟡 중급
import hmac, hashlib
# ✗ 단순 해시로는 위조를 막을 수 없다
signature = hashlib.sha256(message).hexdigest()
# 누구나 같은 서명을 만들 수 있다
# ✓ HMAC — 비밀 키를 아는 쪽만 만들 수 있다
key = b"secret-key"
signature = hmac.new(key, message, hashlib.sha256).hexdigest()
# ★ 검증은 반드시 상수 시간 비교로
if hmac.compare_digest(signature, received): # ✓
...
# if signature == received: # ✗ 타이밍 공격 가능
# ✗ 직접 만들지 말 것: sha256(key + message)
# 길이 확장 공격(length extension)에 취약하다
# HMAC 은 그것을 막도록 설계된 구조다
| HMAC 을 쓰는 곳 | 내용 |
|---|---|
| 웹훅 서명 검증 | GitHub · Stripe 등이 요청에 서명 |
| API 요청 서명 | AWS SigV4 |
| 세션 쿠키 서명 | 변조 방지 |
| JWT (HS256) | 대칭키 서명 |
| URL 서명 | 만료 시간이 있는 다운로드 링크 |
# 웹훅 검증의 정석 (Stripe 스타일)
def verify_webhook(payload: bytes, header: str, secret: str, tolerance=300):
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = parts["t"], parts["v1"]
# ① 재전송 공격 방지 — 타임스탬프 검사
if abs(time.time() - int(ts)) > tolerance:
raise ValueError("타임스탬프가 너무 오래됨")
# ② 서명 검증 — 타임스탬프를 포함해 서명
expected = hmac.new(secret.encode(),
f"{ts}.".encode() + payload,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise ValueError("서명 불일치")
# ★ 타임스탬프가 없으면 공격자가 옛 요청을 다시 보낼 수 있다
실무에선 이렇게 — 타이밍 공격 🔴 실무
# 문자열 비교는 보통 첫 다른 바이트에서 멈춘다
def naive_compare(a, b):
if len(a) != len(b): return False
for x, y in zip(a, b):
if x != y: return False # ← 여기서 조기 반환
return True
# 공격자는 응답 시간을 재서 한 바이트씩 알아낼 수 있다
# "a..." → 0.001ms 만에 실패
# "b..." → 0.002ms 만에 실패 ← 첫 바이트가 맞았다!
# 256번 × 32바이트 = 8192번의 시도로 토큰을 복원
# ✓ 상수 시간 비교 — 항상 끝까지 본다
import hmac
hmac.compare_digest(a, b)
# 다른 언어
# Node: crypto.timingSafeEqual(a, b)
# Go: subtle.ConstantTimeCompare(a, b)
# Java: MessageDigest.isEqual(a, b)
# .NET: CryptographicOperations.FixedTimeEquals(a, b)
| 상수 시간 비교가 필요한 곳 | |
|---|---|
| API 키 · 토큰 검증 | ✓ |
| HMAC 서명 검증 | ✓ |
| 비밀번호 해시 비교 | 라이브러리가 처리 (03절) |
| 일반 문자열 비교 | 불필요 |
| 사용자 존재 여부 응답 | ✓ (메시지·시간을 동일하게) |
비밀번호 저장 — 일부러 느리게 해싱합니다
비밀번호는 암호화하는 것이 아니라 해싱합니다. 그리고 빠른 해시를 쓰면 안 됩니다 — SHA-256 은 초당 수십억 번 시도할 수 있기 때문입니다.
왜 SHA-256 이면 안 되나
GPU 로 초당 시도 가능한 횟수 (대략)
SHA-256 수십억 회/초
bcrypt (cost 12) 수십~수백 회/초
Argon2id (권장) 수십 회/초
★ 8자리 소문자 비밀번호(2×10¹¹ 가지)를 전수 조사하면
SHA-256: 몇 초 ~ 몇 분
bcrypt: 수십 년
솔트가 필요한 이유
· 같은 비밀번호 → 같은 해시 → 한 번 깨면 전부 깨짐
· 레인보우 테이블(미리 계산된 표)로 즉시 역추적
· 사용자마다 다른 랜덤 솔트를 붙이면 전부 무력화
올바른 구현
# ✓ Argon2id (현재 권장)
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
hashed = ph.hash("사용자비밀번호")
# $argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$...
# ↑ 파라미터와 솔트가 문자열 안에 포함된다
try:
ph.verify(hashed, "입력된비밀번호")
if ph.check_needs_rehash(hashed): # ★ 파라미터가 낡았으면
store(ph.hash("입력된비밀번호")) # 로그인 성공 시 재해싱
except VerifyMismatchError:
...
# ✓ bcrypt (여전히 안전 · 더 널리 지원)
import bcrypt
hashed = bcrypt.hashpw(pw.encode(), bcrypt.gensalt(rounds=12))
bcrypt.checkpw(pw.encode(), hashed)
# ⚠ bcrypt 는 72바이트를 넘으면 잘린다 → 사전에 SHA-256 으로 압축하거나 길이 제한
이렇게도 쓸 수 있다 — 파라미터 정하기 🟡 중급
| 알고리즘 | 권장 파라미터 (2026 기준) | 비고 |
|---|---|---|
| Argon2id | m=64MB, t=3, p=4 이상 | 1순위 · 메모리 하드 |
| scrypt | N=2¹⁷, r=8, p=1 | 메모리 하드 |
| bcrypt | cost ≥ 12 | 지원이 가장 넓음 |
| PBKDF2 | SHA-256, 600,000회 이상 | FIPS 필요할 때만 |
# 파라미터는 '내 서버에서 걸리는 시간' 으로 정한다
import time
from argon2 import PasswordHasher
for m in (19456, 47104, 65536, 131072):
ph = PasswordHasher(time_cost=3, memory_cost=m, parallelism=4)
t = time.perf_counter(); ph.hash("test"); d = time.perf_counter() - t
print(f"m={m//1024}MB {d*1000:.0f}ms")
# 목표: 로그인 한 번에 ★ 100~500ms
# · 너무 빠르면 공격자에게 유리
# · 너무 느리면 로그인 폭주 시 서버가 죽는다 (DoS 벡터가 된다)
# ★ 로그인 엔드포인트에 레이트 리밋을 반드시 함께
실무에선 이렇게 — 비밀번호 정책과 그 너머 🔴 실무
| 과거의 통념 | 현재 권장 (NIST 800-63B) |
|---|---|
| 주기적 변경 강제 (90일) | 하지 말 것 — 더 약한 비밀번호를 유도 |
| 대문자·숫자·특수문자 강제 | 하지 말 것 — Password1! 을 만든다 |
| 최대 길이 제한 | 64자 이상 허용 |
| 힌트 질문 | 쓰지 말 것 — 추측 가능 |
| — | 최소 15자 (MFA 의 한 요소로만 쓰면 8자 — 800-63B-4) |
| — | 유출 목록 대조 (haveibeenpwned API) |
| — | MFA 제공 — 이것이 가장 효과적 |
| — | 비밀번호 관리자 허용 (붙여넣기 막지 말 것) |
# 유출된 비밀번호 확인 — k-익명성으로 안전하게
import hashlib, requests
def is_pwned(password: str) -> int:
sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
# ★ 앞 5자만 보낸다 — 서버는 어떤 비밀번호인지 모른다
resp = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=3)
for line in resp.text.splitlines():
h, count = line.split(":")
if h == suffix: return int(count)
return 0
# 회원가입·변경 시
n = is_pwned(pw)
if n > 0:
raise ValueError(f"이 비밀번호는 유출 목록에 {n:,}번 등장했습니다")
비밀번호 해싱 — 파라미터를 정하는 법
지금 쓸 것 · 쓰면 안 되는 것
| 알고리즘 | 평가 |
|---|---|
| argon2id | 현재 1순위. 메모리도 많이 쓰게 만들어 GPU·ASIC 공격에 강합니다 |
| bcrypt | 여전히 안전. 라이브러리가 어디에나 있습니다. 72바이트 초과분이 잘리는 점만 주의 |
| scrypt | 안전하지만 파라미터 설정이 까다롭습니다 |
| PBKDF2 | 규정 때문에 써야 할 때만. 반복 횟수를 아주 크게(60만+) |
| SHA-256 단독 | 절대 금지 — 너무 빠릅니다 |
| MD5 · SHA-1 | 금지 — 충돌이 실제로 만들어집니다 |
SHA-256 (GPU 1010 회/초) → 약 5분
bcrypt cost 12 (102 회/초) → 약 900년
같은 비밀번호인데 차이는 해시 함수 하나에서 나옵니다
# argon2 — 파라미터는 "우리 서버에서 0.1~0.5초" 가 기준입니다
from argon2 import PasswordHasher
import time
ph = PasswordHasher(
time_cost=3, # 반복 횟수
memory_cost=65536, # 64MB — ★ GPU 공격을 막는 핵심 파라미터
parallelism=4,
)
t = time.perf_counter()
h = ph.hash("correct horse battery staple")
print(f"{(time.perf_counter()-t)*1000:.0f}ms") # 100~300ms 목표
try:
ph.verify(h, 입력값)
if ph.check_needs_rehash(h): # ★ 파라미터를 올렸으면 조용히 재해싱
저장(ph.hash(입력값))
except Exception:
로그인_실패()
소금 · 후추 · 그리고 흔한 실수
소금(salt)
· 사용자마다 다른 무작위 값
· 해시 결과에 함께 저장합니다 (비밀이 아닙니다)
· 목적: 같은 비밀번호라도 다른 해시가 나오게 → 표 공격 무력화
· bcrypt·argon2 는 자동으로 처리합니다
후추(pepper)
· 서버 전체가 공유하는 비밀값. DB 가 아니라 KMS·환경변수에
· 목적: DB 만 유출됐을 때 방어선 하나 더
· 로테이션이 어려워 필수는 아닙니다
저장 형태 — 알고리즘·파라미터·소금이 전부 문자열에 들어 있습니다
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJ...
└ 알고리즘 └ 버전 └ 파라미터 └ 소금 └ 해시
→ 나중에 파라미터를 올려도 옛 해시를 그대로 검증할 수 있습니다
| 흔한 실수 | 결과 |
|---|---|
| 모두에게 같은 소금 | 표 공격이 다시 통합니다 |
| 소금을 비밀로 여김 | 불필요 — 공개돼도 안전한 설계입니다 |
== 로 해시 비교 | 타이밍 공격 — hmac.compare_digest 를 |
| 로그·에러에 비밀번호 | 평문이 로그 저장소에 그대로 |
| “아이디가 없습니다” 안내 | 계정 존재 여부 노출 — 문구를 하나로 |
| 길이 상한 8자 | 패스프레이즈 사용 불가 — 64자 이상 허용 |
| 주기적 강제 변경 | 오히려 약한 비밀번호를 유도 — NIST 도 권고 철회 |
== 를 쓰면 타이밍 공격에 노출됩니다.
문자열 비교는 다른 글자를 만나는 순간 멈추므로,
응답 시간의 미세한 차이로 앞에서부터 한 글자씩 알아낼 수 있습니다.
hmac.compare_digest(파이썬) ·
MessageDigest.isEqual(자바) 처럼
항상 같은 시간이 걸리는 비교를 쓰세요.
API 키·토큰 비교에도 똑같이 적용됩니다.
대칭키 암호 — AES 를 안전하게 쓰는 법
같은 키로 잠그고 엽니다. 빠르지만 모드를 잘못 고르면 암호화한 의미가 없어집니다. 실무에서는 인증 암호(AEAD) 하나만 기억하면 됩니다.
운영 모드
| 모드 | 특징 | 판단 |
|---|---|---|
| ECB | 블록마다 독립 암호화 | 절대 금지 |
| CBC | 이전 블록과 XOR | 인증 없음 · 패딩 오라클 |
| CTR | 스트림처럼 | 인증 없음 |
| GCM | CTR + 인증 태그 | ★ 기본 선택 |
| ChaCha20-Poly1305 | 인증 포함 | 모바일·AES-NI 없는 곳 |
| XChaCha20 | 더 긴 논스 | 논스 관리가 쉬움 |
ECB 가 왜 위험한가
같은 평문 블록 → 항상 같은 암호문 블록
→ 이미지를 ECB 로 암호화하면 원본 윤곽이 그대로 보인다
(유명한 "ECB 펭귄" 예시)
→ 패턴이 그대로 노출된다
AEAD — 암호화 + 무결성
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
nonce = os.urandom(12) # ★ 매번 새로 · 재사용 금지
ct = aesgcm.encrypt(nonce, b"비밀 데이터", b"연관데이터")
pt = aesgcm.decrypt(nonce, ct, b"연관데이터") # 변조되면 예외
# 저장 형식: nonce || ciphertext || tag (태그는 ct 에 포함)
blob = nonce + ct
# ★ 왜 인증이 필요한가
# 인증 없는 암호(CBC · CTR)는 공격자가 암호문을 조작할 수 있다
# 복호화는 성공하지만 내용이 바뀐다 → 비트 플리핑 공격
# AEAD 는 변조를 감지해 복호화 자체를 거부한다
이렇게도 쓸 수 있다 — 더 안전한 선택 — 고수준 라이브러리 🟡 중급
# 실수하기 어려운 API 를 쓰는 것이 최선이다
# ① Fernet (파이썬 cryptography) — 키 관리 · 논스 · 타임스탬프 포함
from cryptography.fernet import Fernet
key = Fernet.generate_key() # 안전하게 보관
f = Fernet(key)
token = f.encrypt(b"데이터")
data = f.decrypt(token, ttl=3600) # 만료까지 지원
# ② libsodium / PyNaCl — 선택지를 없애서 실수를 없앤다
from nacl.secret import SecretBox
import nacl.utils
box = SecretBox(nacl.utils.random(SecretBox.KEY_SIZE))
ct = box.encrypt(b"데이터") # 논스를 자동 생성해 포함
pt = box.decrypt(ct)
# ③ 다른 언어
# Go: filippo.io/age · crypto/cipher AEAD
# JS: Web Crypto API (AES-GCM)
# Java: javax.crypto GCM · Google Tink ★
# Rust: ring · RustCrypto
# ★ Google Tink 를 특히 권합니다 — 잘못 쓰기 어렵게 설계됨
| 직접 하면 안 되는 것 | 대신 |
|---|---|
| 암호 알고리즘 구현 | 검증된 라이브러리 |
| 모드·패딩 조합 설계 | AEAD 하나 |
| 키 유도 직접 | HKDF · Argon2 |
| 난수 직접 | OS CSPRNG (11절) |
| 프로토콜 설계 | TLS · Noise · age |
실무에선 이렇게 — 저장 데이터 암호화 🔴 실무
| 계층 | 방식 | 보호 대상 |
|---|---|---|
| 디스크 | LUKS · EBS 암호화 | 디스크 도난 · 폐기 |
| DB 전체 | TDE | 파일 접근 |
| 컬럼 | 애플리케이션 암호화 | DB 관리자 · 유출된 덤프 |
| 필드 (봉투) | DEK + KMS 의 KEK | 키 순환 · 세밀한 접근 제어 |
| 종단 간 | 클라이언트에서 암호화 | 서버 운영자까지 |
# 봉투 암호화(envelope encryption) — 실무 표준 패턴
# ① KMS 에서 데이터 키(DEK)를 발급받는다 (평문 + 암호화된 버전)
# ② 평문 DEK 로 데이터를 암호화하고 메모리에서 지운다
# ③ 암호화된 DEK 를 데이터와 함께 저장
# ④ 복호화할 때 KMS 에 DEK 복호화를 요청
resp = kms.generate_data_key(KeyId=KEY_ID, KeySpec="AES_256")
plain_dek, encrypted_dek = resp["Plaintext"], resp["CiphertextBlob"]
aesgcm = AESGCM(plain_dek)
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, data, None)
del plain_dek, aesgcm # 참조를 끊는다 (파이썬은 바이트 소거까지는 보장 못 함)
store(record_id, encrypted_dek, nonce, ct)
# 장점
# · KMS 호출은 작은 키에만 (대용량 데이터는 로컬에서)
# · KEK 를 순환해도 데이터를 다시 암호화할 필요 없음
# · 접근 로그가 KMS 에 남는다
공개키 암호 — 키를 나눠 갖는 문제의 해결
대칭키의 문제는 “그 키를 어떻게 전달하나” 였습니다. 잠그는 열쇠와 여는 열쇠를 나누면 이 문제가 풀립니다. 원리는 🔢 이산수학 09·10 에 있습니다.
두 가지 용도
① 암호화 — 공개키로 잠그고 개인키로 연다
보내는 사람이 받는 사람의 공개키로 암호화
→ 개인키를 가진 사람만 읽을 수 있다
② 서명 — 개인키로 서명하고 공개키로 검증
보내는 사람이 자기 개인키로 서명
→ 누구나 그 사람이 보냈음을 확인할 수 있다
★ 방향이 반대다. 헷갈리면 이렇게 기억하자
"공개키로 하는 일은 누구나 할 수 있는 일"
(암호화 · 검증)
"개인키로 하는 일은 나만 할 수 있는 일"
(복호화 · 서명)
알고리즘 선택
| 알고리즘 | 키 길이 | 용도 |
|---|---|---|
| RSA | ≥ 2048 (3072 권장) | 넓은 호환 · 느림 |
| ECDSA (P-256) | 256비트 ≈ RSA 3072 | 인증서 · 서명 |
| Ed25519 | 256비트 | ★ 서명 기본 선택 |
| X25519 | 256비트 | 키 교환 |
| ML-KEM · ML-DSA | — | 양자 내성 (전환 중) |
from cryptography.hazmat.primitives.asymmetric import ed25519
private = ed25519.Ed25519PrivateKey.generate()
public = private.public_key()
sig = private.sign(b"메시지")
public.verify(sig, b"메시지") # 실패하면 예외
# 키 교환 (X25519) — 각자 개인키로 상대 공개키와 계산하면
# 같은 공유 비밀이 나온다 (디피-헬만)
from cryptography.hazmat.primitives.asymmetric import x25519
a_priv = x25519.X25519PrivateKey.generate()
b_priv = x25519.X25519PrivateKey.generate()
shared_a = a_priv.exchange(b_priv.public_key())
shared_b = b_priv.exchange(a_priv.public_key())
assert shared_a == shared_b # ★ 키 자체는 오간 적이 없다
이렇게도 쓸 수 있다 — 하이브리드 암호화 🟡 중급
공개키 암호는 느리고 크기 제한도 있다
RSA-2048 로 한 번에 암호화할 수 있는 것: 약 190바이트
그래서 실제로는 항상 하이브리드로 쓴다
① 랜덤 대칭키(세션 키)를 생성
② 그 키로 데이터를 암호화 (AES-GCM — 빠름)
③ 세션 키만 상대 공개키로 암호화
④ 둘을 함께 보낸다
★ TLS · PGP · age · Signal 이 전부 이 구조다
공개키는 '키를 전달하는 데'만 쓴다
import os
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def hybrid_encrypt(recipient_public, data: bytes):
session_key = AESGCM.generate_key(256)
nonce = os.urandom(12)
ct = AESGCM(session_key).encrypt(nonce, data, None)
ek = recipient_public.encrypt(
session_key,
padding.OAEP(mgf=padding.MGF1(hashes.SHA256()),
algorithm=hashes.SHA256(), label=None))
return ek, nonce, ct
# ★ 패딩은 반드시 OAEP — PKCS#1 v1.5 는 취약(Bleichenbacher)
# ★ 서명에는 PSS 를 쓴다
age, 메시징이라면 Signal 프로토콜,
전송이라면 TLS. 이들이 하이브리드 구성과 패딩·논스를
전부 올바르게 처리해 줍니다.
실무에선 이렇게 — 전방향 비밀성과 양자 내성 🔴 실무
전방향 비밀성 (PFS)
서버 개인키가 나중에 유출돼도 과거의 통신을 복호화할 수 없어야 한다는 성질입니다.
PFS 가 없으면
· 공격자가 암호문을 저장해 두었다가
· 나중에 개인키를 얻으면 전부 복호화
PFS 가 있으면
· 연결마다 임시(ephemeral) 키 쌍을 생성
· 세션이 끝나면 폐기
· 개인키는 '인증' 에만 쓰인다
TLS 1.3 은 PFS 가 필수다 (ECDHE 만 허용)
양자 컴퓨터 대비
| 알고리즘 | 양자 위협 |
|---|---|
| RSA · ECC | 깨짐 (쇼어 알고리즘) |
| AES-128 | 실효 64비트 → AES-256 권장 |
| SHA-256 | 실효 128비트 → 여전히 안전 |
디지털 서명 · 인증서 · PKI — “이 공개키가 진짜인가”
공개키 암호에는 한 가지 문제가 남습니다 — 이 공개키가 정말 그 사람 것인가? 그것을 제3자가 보증하는 체계가 PKI 입니다.
mkcert 가 같은 원리로 동작합니다 — 뿌리를 심는 것입니다.인증서 체인
루트 CA (브라우저·OS 에 미리 내장)
│ 서명
중간 CA
│ 서명
서버 인증서 (example.com)
검증 과정
① 서버 인증서의 서명을 중간 CA 공개키로 검증
② 중간 CA 인증서를 루트 CA 공개키로 검증
③ 루트가 신뢰 저장소에 있는지 확인
④ 도메인 일치 · 유효기간 · 폐기 여부 확인
★ 신뢰의 뿌리는 "미리 설치된 루트 CA 목록" 이다
기업 환경에서 사설 CA 를 설치하면
그 CA 가 모든 사이트를 가장할 수 있다 (SSL 검사 장비의 원리)
폐기 확인
| 방식 | 내용 | 문제 |
|---|---|---|
| CRL | 폐기 목록 다운로드 | 크고 느림 |
| OCSP | 실시간 질의 | 지연 · 프라이버시 |
| OCSP 스테이플링 | 서버가 응답을 첨부 | 권장 |
| 짧은 유효기간 | 90일 · 곧 47일 | 자동 갱신 필수 |
| CRLite · 푸시 | 브라우저가 목록 배포 | 최신 방식 |
이렇게도 쓸 수 있다 — 인증서 운영 🟡 중급
# Let's Encrypt 자동 갱신
certbot certonly --nginx -d example.com -d www.example.com
certbot renew --dry-run # 갱신 테스트
# ★ 갱신 후 서비스 재로드가 핵심 (자주 빠뜨린다)
# /etc/letsencrypt/renewal-hooks/deploy/reload.sh
# #!/bin/sh
# systemctl reload nginx
# 만료 감시 (모니터링에 반드시)
echo | openssl s_client -connect example.com:443 2>/dev/null \
| openssl x509 -noout -enddate
# 인증서 투명성(CT) 로그로 우리 도메인의 발급 이력 확인
# crt.sh 에서 도메인 검색 → 모르는 인증서가 있으면 사고
# CAA 레코드로 발급 가능한 CA 를 제한
# example.com. CAA 0 issue "letsencrypt.org"
| 운영 항목 | 내용 |
|---|---|
| 자동 갱신 + 재로드 훅 | 갱신만 되고 반영이 안 되는 사고 방지 |
| 만료 30일 전 알림 | 모니터링 필수 |
| CAA 레코드 | 다른 CA 의 무단 발급 차단 |
| CT 로그 감시 | 우리 도메인의 인증서 발급 탐지 |
| full chain 배포 | 중간 인증서 누락 방지 (🌐 네트워크 09) |
| 와일드카드 신중히 | 유출 시 영향 범위가 큼 |
실무에선 이렇게 — 코드 서명과 공급망 🔴 실무
| 대상 | 서명 방식 |
|---|---|
| 컨테이너 이미지 | cosign / Sigstore (키리스 서명) |
| npm · PyPI 패키지 | Sigstore · 신뢰된 게시(OIDC) |
| git 커밋 · 태그 | GPG · SSH 서명 |
| 바이너리 · 설치 파일 | 코드 서명 인증서 (OS 별) |
| SBOM · 프로비넌스 | SLSA · in-toto |
# 컨테이너 이미지 서명 (Sigstore — 키를 보관하지 않아도 된다)
cosign sign --yes ghcr.io/org/app:1.2.3
cosign verify ghcr.io/org/app:1.2.3 \
--certificate-identity-regexp 'https://github.com/org/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# git 커밋 서명 (SSH 키로 — GPG 보다 간단)
git config gpg.format ssh
git config user.signingkey ~/.ssh/id_ed25519.pub
git config commit.gpgsign true
# 배포 파이프라인에서 검증
# 서명이 없거나 발급자가 다르면 배포 거부 (정책으로 강제)
# 쿠버네티스: Kyverno · Gatekeeper 로 서명 검증 정책
TLS 가 지키는 것 — 그리고 지키지 못하는 것
“HTTPS 니까 안전하다”는 절반만 맞습니다. TLS 는 전송 구간을 보호할 뿐, 그 앞뒤는 다른 문제입니다.
보장 범위
✓ 지킨다
· 도청 (내용 암호화)
· 변조 (무결성 검증)
· 서버 신원 확인
· 재전송 공격
· 다운그레이드 (1.3)
✗ 못 지킨다
· 서버에 저장된 뒤
· 클라이언트 기기의 악성코드
· 애플리케이션 취약점
· 접속했다는 사실 (SNI · IP)
· 트래픽 크기·타이밍 패턴
· 잘못 신뢰한 CA
추가로 필요한 것들
| 위협 | 대책 |
|---|---|
| 서버 저장 데이터 유출 | 저장 시 암호화 (04절) |
| 서버 운영자 접근 | 종단 간 암호화 |
| HTTP 로 첫 접속 | HSTS · HSTS preload |
| 잘못된 CA 발급 | CAA · CT 로그 · 핀닝 |
| 내부망 도청 | 서비스 간에도 mTLS |
| 메타데이터 노출 | ECH(암호화 SNI) · DoH |
보안 헤더 세트 (설정만으로 큰 효과)
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; ...
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=()
Cross-Origin-Opener-Policy: same-origin
★ CSP 가 XSS 방어의 마지막 보루다 (09절)
이렇게도 쓸 수 있다 — mTLS — 양쪽 다 인증 🟡 중급
일반 TLS: 클라이언트가 서버를 확인
mTLS: 서버도 클라이언트를 확인 (양방향)
쓰는 곳
· 마이크로서비스 간 통신 (제로 트러스트)
· IoT 기기 인증
· 파트너사 API 연동
· 관리 인터페이스 접근 제어
장점
· 네트워크 위치가 아니라 '신원' 으로 인가
· 비밀번호·토큰보다 강한 인증
비용
· 인증서 발급·순환·폐기 자동화가 필수
· 서비스 메시(Istio · Linkerd)가 이걸 자동화해 준다
· SPIFFE/SPIRE 가 표준 신원 체계
# nginx 에서 mTLS
ssl_client_certificate /etc/nginx/ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;
# 앱으로 클라이언트 신원 전달
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Verify $ssl_client_verify;
# ★ 앱에서 반드시 X-Client-Verify 가 SUCCESS 인지 확인할 것
# 그리고 이 헤더는 외부에서 들어온 것을 반드시 제거해야 한다
X-Client-DN 을 직접 보내면
인증을 우회할 수 있습니다.
프록시에서 항상 덮어쓰거나 삭제하세요.
실무에선 이렇게 — TLS 설정 점검 🔴 실무
# 종합 점검 도구
testssl.sh https://example.com # 로컬 실행
# 또는 ssllabs.com/ssltest (공개 서버만)
# 확인할 항목
# · TLS 1.0 · 1.1 비활성화
# · 약한 암호 스위트 제거 (RC4 · 3DES · NULL · EXPORT)
# · PFS (ECDHE) 사용
# · HSTS 헤더
# · OCSP 스테이플링
# · 인증서 체인 완전성
# · 재협상 취약점 없음
# 헤더 점검
curl -sI https://example.com | grep -i -E "strict-transport|content-security|x-content-type"
# 또는 securityheaders.com
| 자주 나오는 지적 | 대응 |
|---|---|
| TLS 1.0/1.1 활성 | 비활성화 (PCI DSS 요구) |
| HSTS 없음 | 헤더 추가 (짧게 시작) |
| 중간 인증서 누락 | fullchain 배포 |
| DH 파라미터 약함 | 2048비트 이상 · ECDHE 선호 |
| 세션 티켓 키 미순환 | PFS 가 약화됨 — 주기적 재시작 |
| 혼합 콘텐츠 | HTTP 리소스 제거 · CSP upgrade-insecure-requests |
인증과 인가 — 세션 · JWT · OAuth
“너 누구냐”(인증)와 “그걸 해도 되냐”(인가)는 다른 문제입니다. JWT 를 잘못 쓰는 대부분의 사례가 이 둘을 섞는 데서 시작합니다.
세션 vs 토큰
| 세션 (쿠키) | JWT | |
|---|---|---|
| 상태 | 서버가 보관 | 토큰에 담김 |
| 즉시 무효화 | 가능 | 어려움 ★ |
| 확장성 | 저장소 필요 | 무상태 |
| 크기 | 작음 (ID 만) | 큼 (매 요청 전송) |
| 권한 변경 반영 | 즉시 | 만료까지 지연 |
| 적합 | 일반 웹 서비스 | 서비스 간 · 짧은 수명 |
쿠키 설정
Set-Cookie: session=abc123;
HttpOnly; ← JS 에서 접근 불가 (XSS 방어)
Secure; ← HTTPS 에서만 전송
SameSite=Lax; ← CSRF 방어 (Strict 가 더 강함)
Path=/;
Max-Age=86400
SameSite 값
Strict — 외부 사이트에서 오는 요청에 아예 안 붙음
(외부 링크로 들어오면 로그아웃 상태로 보임)
Lax — GET 최상위 이동에만 붙음 ★ 기본값 · 권장
None — 항상 붙음 (Secure 필수 · 크로스 사이트 필요할 때만)
★ HttpOnly + Secure + SameSite=Lax 이 기본 세트
이렇게도 쓸 수 있다 — JWT 를 안전하게 🟡 중급
import jwt # PyJWT
# ✗ 위험한 검증
payload = jwt.decode(token, options={"verify_signature": False}) # 절대 금지
payload = jwt.decode(token, key, algorithms=None) # 알고리즘 혼동
# ✓ 알고리즘을 명시하고 필수 클레임을 강제
payload = jwt.decode(
token, key,
algorithms=["RS256"], # ★ 반드시 고정
audience="my-api",
issuer="https://auth.example.com",
options={"require": ["exp", "iat", "sub", "aud", "iss"]},
leeway=30, # 시계 오차 허용
)
| 공격 | 내용 | 방어 |
|---|---|---|
| alg: none | 서명 없이 통과 | 알고리즘 화이트리스트 |
| 알고리즘 혼동 | RS256 → HS256 으로 바꿔 공개키를 HMAC 키로 | 같은 방법 |
| 토큰 탈취 | XSS 로 localStorage 접근 | HttpOnly 쿠키에 저장 |
| 만료 없음 | 영구 유효 | 짧은 exp + 리프레시 |
| kid 인젝션 | 키 조회 경로 조작 | kid 를 화이트리스트로 |
| 민감정보 노출 | 페이로드는 암호화가 아니라 base64 | 민감정보를 넣지 말 것 |
실무 권장 구성
액세스 토큰 짧게 (5~15분) · 메모리 또는 HttpOnly 쿠키
리프레시 토큰 길게 (7~30일) · HttpOnly + Secure 쿠키 · DB 에 저장
→ 저장하므로 즉시 폐기 가능
회전(rotation) 리프레시할 때마다 새로 발급 · 옛 것은 무효
→ 재사용이 감지되면 세션 전체 폐기 (탈취 탐지)
로그아웃 리프레시 토큰 폐기 + 액세스 토큰은 만료 대기
강제 로그아웃 사용자별 "토큰 유효 시작 시각" 을 저장해 비교
실무에선 이렇게 — OAuth 2.0 · OIDC 🔴 실무
용어 정리 (자주 혼동된다)
OAuth 2.0 — ★ 인가 프레임워크 ("이 앱이 내 데이터에 접근해도 된다")
OIDC — OAuth 위에 얹은 ★ 인증 계층 ("이 사람이 누구다")
SAML — 기업 SSO 에 많이 쓰이는 다른 표준
Authorization Code + PKCE (현재 유일한 권장 플로우)
① 앱이 code_verifier 를 만들고 그 해시(code_challenge)를 보냄
② 사용자가 로그인하고 동의
③ 인가 서버가 authorization code 를 리다이렉트로 전달
④ 앱이 code + code_verifier 로 토큰 교환
⑤ 액세스 토큰 · (OIDC 면) ID 토큰 수령
★ 폐기된 것들
· Implicit 플로우 (토큰이 URL 에 노출)
· Resource Owner Password (앱이 비밀번호를 직접 다룸)
| 필수 검증 | 이유 |
|---|---|
state 파라미터 | CSRF 방어 — 반드시 검증 |
| PKCE | code 가로채기 방어 |
| redirect_uri 정확 일치 | 오픈 리다이렉트 방지 |
| ID 토큰의 nonce | 재전송 방어 |
| aud · iss 확인 | 다른 앱의 토큰 수용 방지 |
| 스코프 최소화 | 최소 권한 |
웹 취약점 — XSS · CSRF · SQL 인젝션
가장 흔하고 가장 오래된 세 가지입니다. 원리는 전부 같습니다 — 데이터가 코드로 해석되는 것. 막는 방법도 하나의 원칙으로 정리됩니다.
인젝션 — 한 가지 원칙
✗ 문자열 조립
cur.execute(
f"SELECT * FROM users WHERE name='{name}'")
# name = "' OR '1'='1" 이면 전부 조회
os.system(f"ping {host}")
# host = "8.8.8.8; rm -rf /"
el.innerHTML = "<p>" + comment + "</p>"
# comment 에 <script> 가 들어가면 실행
✓ 채널 분리
cur.execute(
"SELECT * FROM users WHERE name=%s", (name,))
# 값은 별도 채널로 — 절대 코드가 되지 않는다
subprocess.run(["ping", "-c", "1", host],
shell=False) # ★ 리스트로
el.textContent = comment # 텍스트로만
XSS 세 종류
| 종류 | 경로 |
|---|---|
| 저장형 | DB 에 저장된 스크립트가 다른 사용자에게 |
| 반사형 | URL 파라미터가 그대로 페이지에 |
| DOM 기반 | 클라이언트 JS 가 위험하게 삽입 |
// 위험한 싱크들
el.innerHTML = x; el.outerHTML = x;
document.write(x); eval(x);
new Function(x); setTimeout(x, 0); // 문자열 인자
location = x; el.setAttribute("href", x); // javascript:
// 안전한 대안
el.textContent = x;
el.setAttribute("data-value", x);
// URL 은 스킴을 검증
const u = new URL(x, location.origin);
if (!["http:", "https:"].includes(u.protocol)) throw new Error();
// HTML 을 허용해야 한다면 반드시 살균 라이브러리
import DOMPurify from "dompurify";
el.innerHTML = DOMPurify.sanitize(userHtml);
이렇게도 쓸 수 있다 — CSP — XSS 의 마지막 방어선 🟡 중급
Content-Security-Policy 로 "어디서 온 스크립트를 실행할지" 를 제한
권장: nonce 기반 (allowlist 방식보다 우회가 어렵다)
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{랜덤}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
require-trusted-types-for 'script';
<script nonce="{같은 랜덤}">...</script> ← 이것만 실행된다
★ 'unsafe-inline' 은 nonce 를 지원하지 않는 구형 브라우저용 폴백이다
(nonce 가 있으면 최신 브라우저는 unsafe-inline 을 무시한다)
도입 순서
① Content-Security-Policy-Report-Only 로 시작
② report-uri 로 위반 수집
③ 정상 트래픽에서 위반이 없어지면 강제 모드로 전환
| CSRF 방어 | 내용 |
|---|---|
| SameSite=Lax 쿠키 | 대부분의 CSRF 를 자동 차단 |
| CSRF 토큰 | 폼·요청에 예측 불가 토큰 |
| Origin · Referer 검증 | 서버에서 출처 확인 |
| 상태 변경은 POST 로 | GET 으로 변경 금지 |
| 커스텀 헤더 요구 | 단순 요청으로는 못 보냄 (CORS 프리플라이트) |
Access-Control-Allow-Origin: * 를 인증이 필요한 API 에 쓰면 안 됩니다.
실무에선 이렇게 — 그 밖의 주요 취약점 🔴 실무
| 취약점 | 내용 | 방어 |
|---|---|---|
| IDOR | ID 만 바꿔 남의 데이터 조회 | 매 요청 소유권 확인 |
| SSRF | 서버가 공격자 지정 URL 요청 | allowlist · 사설 IP 차단 · IMDSv2 |
| 경로 순회 | ../../etc/passwd | 정규화 후 기준 경로 확인 |
| 오픈 리다이렉트 | 피싱에 악용 | 리다이렉트 대상 allowlist |
| 대량 할당 | {"isAdmin":true} | 허용 필드만 매핑 |
| XXE | XML 외부 엔티티 | 외부 엔티티 비활성화 |
| 역직렬화 | 임의 코드 실행 | pickle · 자바 직렬화 금지 |
| 레이스 컨디션 | 쿠폰 중복 사용 등 | DB 제약 · 원자적 갱신 |
# IDOR — 가장 흔하고 가장 심각한 것 중 하나
# ✗ ID 로 바로 조회
@app.get("/api/orders/{order_id}")
def get_order(order_id: int, user=Depends(current_user)):
return db.get_order(order_id) # 남의 주문도 나온다
# ✓ 소유권을 조건에 포함
@app.get("/api/orders/{order_id}")
def get_order(order_id: int, user=Depends(current_user)):
order = db.get_order_for_user(order_id, user.id) # WHERE user_id = ?
if not order: raise HTTPException(404) # 403 대신 404 (존재 노출 방지)
return order
# SSRF 방어
import ipaddress, socket
from urllib.parse import urlparse
def safe_fetch(url: str):
u = urlparse(url)
if u.scheme not in ("http", "https"): raise ValueError("스킴 불가")
ip = ipaddress.ip_address(socket.gethostbyname(u.hostname))
if not ip.is_global: # 사설·루프백·링크로컬·CGNAT(100.64/10) 전부
raise ValueError("내부 주소 접근 불가") # 169.254.169.254 포함
return requests.get(url, timeout=5, allow_redirects=False) # ★ 리다이렉트 금지
메모리 취약점 — 배열 밖에 쓰면 벌어지는 일
C 계열에서 버퍼를 넘어 쓰면 프로그램의 흐름 자체를 빼앗길 수 있습니다. 직접 C 를 안 써도, 우리가 쓰는 런타임과 라이브러리가 C 로 되어 있습니다.
스택 버퍼 오버플로
void vulnerable(char *input) {
char buf[64];
strcpy(buf, input); // ✗ 길이 확인 없음
// input 이 64바이트를 넘으면
// 스택의 '반환 주소' 를 덮어쓴다
// → 함수가 끝날 때 공격자가 정한 곳으로 점프
}
// 스택 구조 (🖥️ 컴퓨터구조 06)
// [ buf 64바이트 ][ 저장된 rbp ][ 반환 주소 ]
// ↑ 여기를 덮으면 흐름 탈취
// ✓ 경계를 확인하는 함수
strncpy(buf, input, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
// 또는 snprintf · strlcpy
방어 기법 (자동 적용)
| 기법 | 내용 |
|---|---|
| 스택 카나리 | 반환 주소 앞에 값을 두고 변조 확인 |
| DEP · NX | 데이터 영역 실행 금지 |
| ASLR | 매 실행마다 주소 무작위화 |
| PIE | 실행 파일도 위치 독립 |
| RELRO | GOT 을 읽기 전용으로 |
| CFI · CET | 제어 흐름 무결성 |
| Fortify | _FORTIFY_SOURCE 로 컴파일 시 검사 |
# 바이너리에 방어가 켜져 있는지 확인
checksec --file=./app
# 또는
readelf -a app | grep -i -E "stack|relro|pie"
# 켜서 빌드
gcc -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong \
-Wl,-z,relro,-z,now -fPIE -pie main.c
이렇게도 쓸 수 있다 — 그 밖의 메모리 오류 🟡 중급
| 유형 | 내용 | 결과 |
|---|---|---|
| Use-after-free | 해제 후 사용 | 임의 코드 실행 가능 |
| Double free | 두 번 해제 | 힙 메타데이터 손상 |
| 정수 오버플로 | 크기 계산이 넘침 | 작은 버퍼 할당 → 오버플로 |
| Off-by-one | 1바이트 초과 | 널 바이트로도 공격 가능 |
| 포맷 스트링 | printf(user_input) | 메모리 읽기·쓰기 |
| 널 역참조 | NULL 접근 | 크래시 (DoS) |
| 초기화 안 된 메모리 | 쓰레기 값 사용 | 정보 유출 |
# 개발·CI 에서 반드시 켤 것 — 대부분을 잡아 준다
gcc -fsanitize=address,undefined -g main.c # ASan + UBSan
gcc -fsanitize=thread main.c # 데이터 경쟁
valgrind --leak-check=full --track-origins=yes ./app
# 퍼징 — 무작위 입력으로 크래시를 찾는다
clang -fsanitize=fuzzer,address fuzz.c -o fuzzer
./fuzzer corpus/
# 실제 CVE 의 상당수가 퍼징으로 발견된다
# OSS-Fuzz 가 주요 오픈소스를 상시 퍼징한다
실무에선 이렇게 — 메모리 안전 언어로 🔴 실무
업계의 방향
· 마이크로소프트: 자사 취약점의 약 70% 가 메모리 안전 문제
· 구글 안드로이드: 메모리 안전 언어 도입 후 관련 취약점 급감
· 미국 정부(ONCD, 2024): 메모리 안전 언어로 전환 권고
· 리눅스 커널: Rust 지원 도입
메모리 안전 언어
Rust · Go · 자바 · C# · 파이썬 · JS · Swift
★ 다만 '안전' 이 '취약점 없음' 은 아니다
· 로직 취약점 · 인젝션 · 인가 오류는 그대로
· unsafe 블록 · FFI 경계는 여전히 위험
· 의존성의 네이티브 코드도 마찬가지
| 현실적인 접근 | 내용 |
|---|---|
| 새 컴포넌트는 안전한 언어로 | 전면 재작성은 비현실적 |
| 파서·디코더부터 교체 | 신뢰 못할 입력을 다루는 곳이 최우선 |
| 기존 C 코드는 강화 | ASan · 퍼징 · 컴파일러 방어 옵션 |
| 격리 | 위험한 처리를 별도 프로세스·샌드박스로 |
| 의존성 감시 | 네이티브 라이브러리의 CVE 추적 |
난수 · 키 관리 — Math.random() 으로 토큰을 만들면
일반 난수 생성기는 예측 가능합니다. 토큰 · 세션 ID · 비밀번호 재설정 링크에 쓰면 공격자가 다음 값을 계산해 낼 수 있습니다.
두 종류의 난수
| 일반 PRNG | CSPRNG | |
|---|---|---|
| 목적 | 속도 · 통계 품질 | 예측 불가능성 |
| 시드 노출 | 이후 전부 예측 가능 | — |
| 출력 관찰 | 내부 상태 복원 가능 | 불가능 |
| 예 | Mersenne Twister · Math.random | secrets · /dev/urandom |
# ✗ 예측 가능 — 실제 취약점 사례가 많다
import random
token = "".join(random.choices("0123456789abcdef", k=32))
# Mersenne Twister 는 출력 624개로 내부 상태 복원 가능
# ✓ 암호학적으로 안전
import secrets
secrets.token_urlsafe(32) # 256비트 · URL 안전
secrets.token_hex(32)
secrets.token_bytes(32)
secrets.randbelow(1_000_000) # 인증 코드
secrets.choice(items)
# 다른 언어
# Node: crypto.randomBytes(32) · crypto.randomUUID()
# Go: crypto/rand
# Java: SecureRandom
# JS: crypto.getRandomValues()
# Rust: rand::rngs::OsRng
엔트로피는 어디서 오나
OS 가 하드웨어 잡음을 모은다
· 인터럽트 타이밍 · 디스크 지연 · 키보드/마우스
· CPU 의 RDRAND (하드웨어 난수 생성기)
/dev/urandom vs /dev/random
· 현대 리눅스에서는 사실상 같다 (5.6+ getrandom)
· ★ 부팅 직후에만 차이 — urandom 은 시드가 안 찼어도 반환
· getrandom() 시스템 콜을 쓰면 안전 (시드가 찰 때까지 대기)
주의할 환경
· VM 이미지를 복제하면 엔트로피 풀이 같을 수 있다
· 컨테이너·임베디드에서 부팅 직후 엔트로피 부족
· 대응: 이미지에서 시드 파일 제거 · virtio-rng 사용
secrets.token_urlsafe(32) 가 더 안전합니다.
이렇게도 쓸 수 있다 — 시크릿 관리 🟡 중급
| 보관 위치 | 평가 |
|---|---|
| 소스 코드에 하드코딩 | 최악 — git 히스토리에 영원히 |
| 설정 파일 (커밋됨) | 같은 문제 |
| 환경 변수 | 보통 — 프로세스 목록·로그·크래시 덤프에 노출 가능 |
| 파일 (권한 제한) | 보통 — 컨테이너에 마운트 |
| 시크릿 매니저 | 권장 — Vault · AWS SM · GCP SM |
| 워크로드 아이덴티티 | 최선 — 시크릿 자체가 없음 |
# 커밋 전에 시크릿을 막는다
pip install detect-secrets
detect-secrets scan > .secrets.baseline
# pre-commit 훅에 등록
# 다른 도구: gitleaks · trufflehog · git-secrets
gitleaks detect --source . --verbose
# ★ 이미 커밋했다면
# ① 즉시 그 자격 증명을 폐기·회전한다 (가장 중요)
# ② 히스토리에서 제거 (git filter-repo · BFG)
# ③ 강제 푸시 후 팀에 재클론 안내
# ★ 공개 저장소였다면 이미 봇이 수집했다고 가정할 것
실무에선 이렇게 — 키 수명 주기 🔴 실무
- 생성 — CSPRNG 또는 HSM/KMS 안에서. 사람이 보지 않는 것이 가장 좋습니다.
- 배포 — 시크릿 매니저 · 워크로드 아이덴티티. 사람 손을 거치지 않게.
- 사용 — 메모리에만 · 로그에 절대 남기지 않기 · 최소 권한 스코프.
- 순환 — 주기적 자동 교체. 두 키를 동시에 유효하게 두어 무중단 전환.
- 폐기 — 즉시 무효화 가능해야 함 · 사용 이력 감사.
# 무중단 키 순환 패턴 — 검증은 여러 키로, 서명은 최신 키로
class KeyRing:
def __init__(self, keys): # [(kid, key)] — 최신이 앞
self.keys = keys
def sign(self, data):
kid, key = self.keys[0] # 항상 최신 키로 서명
return kid, hmac.new(key, data, hashlib.sha256).hexdigest()
def verify(self, kid, data, sig):
for k, key in self.keys: # 옛 키로도 검증 허용
if k == kid:
expected = hmac.new(key, data, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
return False
# 순환 절차
# ① 새 키를 앞에 추가 (검증은 둘 다 가능)
# ② 배포 — 모든 인스턴스가 새 키를 알게 됨
# ③ 서명을 새 키로 전환
# ④ 옛 토큰이 전부 만료된 뒤 옛 키 제거
| 로그에 절대 남기면 안 되는 것 | |
|---|---|
| 비밀번호 · 토큰 · API 키 | ✗ |
| 세션 ID · 쿠키 전체 | ✗ |
| 카드 번호 · 주민번호 | ✗ |
| Authorization 헤더 | ✗ (마스킹) |
| 전체 요청 본문 | 주의 — 필터 적용 |
| 쿼리 파라미터가 있는 URL | 주의 — 토큰이 들어갈 수 있음 |
password · token · secret)이 기본입니다.
공급망 보안 — 내 코드가 안전해도
요즘 애플리케이션의 코드 대부분은 남이 쓴 것입니다. 의존성 하나가 문을 열어 두면 내 코드의 품질과 무관하게 뚫립니다. 가장 비용 대비 효과가 큰 보안 활동이 여기 있습니다.
공격 벡터
| 유형 | 내용 |
|---|---|
| 알려진 취약점 | 업데이트 안 한 라이브러리 — 가장 흔함 |
| 타이포스쿼팅 | reqeusts 같은 유사 이름 |
| 계정 탈취 | 메인테이너 계정이 뚫려 악성 버전 배포 |
| 의존성 혼동 | 내부 패키지명을 공개 저장소에 등록 |
| 빌드 시스템 침해 | CI 가 악성 코드를 주입 (SolarWinds) |
| 악의적 인수 | 인기 패키지를 넘겨받아 변조 |
| 설치 스크립트 | postinstall 에서 임의 실행 |
mycompany-utils 를 공격자가
공개 npm/PyPI 에 더 높은 버전으로 등록하면
빌드가 그쪽을 가져갈 수 있습니다.
스코프 · 사설 저장소 우선 설정으로 막습니다.
기본 방어
# ① 락 파일을 반드시 커밋하고 CI 에서 강제
npm ci # package-lock.json 그대로
pip install -r requirements.txt --require-hashes
poetry sync # Poetry 2.x (1.x 는 poetry install --sync)
# ② 취약점 스캔 (CI 에 넣기)
npm audit --audit-level=high
pip-audit
trivy fs --severity HIGH,CRITICAL .
osv-scanner -r .
# ③ 자동 업데이트 PR
# Dependabot · Renovate 설정
# ④ SBOM 생성 · 보관
syft dir:. -o cyclonedx-json > sbom.json
trivy sbom sbom.json
# ⑤ 설치 스크립트 차단 (가능한 경우)
npm ci --ignore-scripts
이렇게도 쓸 수 있다 — 의존성을 줄이는 것도 보안 🟡 중급
| 추가 전 질문 | 이유 |
|---|---|
| 표준 라이브러리로 되나? | 가장 안전 |
| 몇 줄이면 직접 되나? | left-pad 교훈 |
| 전이 의존성이 몇 개인가? | 1개 추가가 50개가 되기도 |
| 최근 커밋이 언제인가? | 방치된 패키지는 위험 |
| 메인테이너가 몇 명인가? | 1인 프로젝트는 인수 위험 |
| 설치 스크립트가 있나? | 임의 코드 실행 |
| 다운로드 수 · 이슈 대응 | 생태계 건강도 |
# 의존성 트리 보기 — 생각보다 훨씬 크다
npm ls --all | wc -l
pipdeptree
cargo tree
# 왜 이 패키지가 들어왔는지
npm why lodash
pipdeptree --reverse --packages urllib3
# 안 쓰는 의존성 정리
npx depcheck
pip-autoremove
# ★ 프로덕션과 개발 의존성을 확실히 분리
# 빌드 도구가 런타임 이미지에 들어가지 않게 (멀티스테이지 빌드)
FROM ubuntu:latest 는 재현 불가능하고
수백 개의 패키지를 포함합니다.
distroless · alpine · 다이제스트 고정으로
공격 표면을 크게 줄일 수 있습니다.
실무에선 이렇게 — CI/CD 파이프라인 보안 🔴 실무
| 위험 | 대응 |
|---|---|
| PR 워크플로에 시크릿 노출 | pull_request_target 주의 · 포크 PR 에 시크릿 금지 |
| 액션을 태그로 참조 | 커밋 SHA 로 고정 (태그는 옮겨질 수 있음) |
| 과도한 토큰 권한 | permissions: read-all 기본 + 필요한 것만 |
| 장기 클라우드 키 | OIDC 페더레이션으로 대체 |
| 빌드 캐시 오염 | 캐시 키에 잠금 파일 해시 포함 |
| 로그에 시크릿 | 마스킹 · set -x 주의 |
| 셀프호스티드 러너 | 공개 저장소에 쓰지 말 것 |
name: build
on: [push]
permissions:
contents: read # ★ 기본을 최소로
id-token: write # OIDC 용
jobs:
build:
runs-on: ubuntu-latest
steps:
# ★ SHA 로 고정 (태그는 재지정 가능)
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# ★ 장기 키 대신 OIDC (지면상 태그로 적었지만 이것도 실제로는 SHA 로 고정)
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-role
aws-region: ap-northeast-2
- run: npm ci --ignore-scripts
- run: npm audit --audit-level=high
- run: npm test
위협 모델링 · 체크리스트 — 기억에 의존하지 않기
보안을 사람의 성실함에 맡기면 반드시 빠집니다. 목록과 자동화로 만들어야 유지됩니다. 이 절은 실제로 붙여 놓고 쓸 수 있는 형태로 정리합니다.
STRIDE — 위협 분류
| 위협 | 깨는 속성 | 대책 예 |
|---|---|---|
| Spoofing | 인증 | MFA · mTLS |
| Tampering | 무결성 | 서명 · HMAC |
| Repudiation | 부인 방지 | 감사 로그 |
| Info disclosure | 기밀성 | 암호화 · 접근 제어 |
| Denial of service | 가용성 | 레이트 리밋 · 자동 확장 |
| Elevation | 인가 | 최소 권한 · 검증 |
위협 모델링 4단계 (Shostack)
① 무엇을 만들고 있나 — 데이터 흐름도를 그린다
② 무엇이 잘못될 수 있나 — STRIDE 로 각 흐름을 훑는다
③ 어떻게 할 것인가 — 완화 · 수용 · 이전 · 제거
④ 잘 했나 — 검증 · 다음 설계에 반영
★ 새 기능 설계 문서에 '보안 고려사항' 한 절을 넣는 것만으로도
대부분의 큰 실수를 잡을 수 있다
배포 전 체크리스트
| 항목 | |
|---|---|
| □ | 모든 입력이 서버에서 검증되는가 |
| □ | SQL 이 전부 파라미터화됐는가 |
| □ | 출력이 컨텍스트에 맞게 인코딩되는가 |
| □ | 모든 엔드포인트에 인가 검사가 있는가 (IDOR) |
| □ | 쿠키에 HttpOnly · Secure · SameSite |
| □ | 보안 헤더 (HSTS · CSP · nosniff) |
| □ | 비밀번호가 Argon2/bcrypt 로 해싱 |
| □ | 토큰이 CSPRNG 로 생성 |
| □ | 시크릿이 코드·로그에 없는가 |
| □ | 의존성 취약점 스캔 통과 |
| □ | 레이트 리밋 (로그인 · 비밀번호 재설정 · API) |
| □ | 에러 메시지에 내부 정보가 없는가 |
| □ | 감사 로그 (누가 · 언제 · 무엇을) |
| □ | 백업과 복구가 실제로 되는가 |
| □ | 인증서 만료 알림이 있는가 |
이렇게도 쓸 수 있다 — 자동화로 만들기 🟡 중급
| 단계 | 자동 검사 |
|---|---|
| 커밋 전 | 시크릿 스캔 · 린트 (pre-commit) |
| PR | SAST · 의존성 스캔 · 라이선스 검사 |
| 빌드 | 이미지 스캔 · SBOM 생성 · 서명 |
| 배포 | 서명 검증 · 설정 정책 (OPA · Kyverno) |
| 운영 | 런타임 탐지 · 인증서 만료 · 이상 로그인 |
| 주기 | DAST · 침투 테스트 · 권한 검토 |
# .pre-commit-config.yaml — 가장 저렴하고 효과적인 방어선
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks: [{id: detect-secrets, args: ["--baseline", ".secrets.baseline"]}]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks: [{id: ruff, args: ["--select", "S"]}] # bandit 보안 규칙
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks: [{id: gitleaks}]
실무에선 이렇게 — 사고 대응과 이어지는 곳 🔴 실무
- 탐지 — 이상 징후를 어떻게 알 것인가. 알림 · 로그 · 외부 제보 경로.
- 격리 — 확산을 막는다. 자격 증명 폐기 · 네트워크 차단 · 인스턴스 격리(삭제 아님).
- 증거 보전 — 로그 · 메모리 · 디스크 스냅숏. 재부팅 전에.
- 근절 — 취약점 패치 · 백도어 제거 · 전면 자격 증명 순환.
- 복구 — 깨끗한 이미지에서 재구축 · 단계적 복귀.
- 사후 분석 — 비난 없는 회고 · 재발 방지 항목을 백로그로.
| 미리 준비할 것 | 내용 |
|---|---|
| 연락 체계 | 누구에게 · 어떤 채널로 (침해된 채널은 못 씀) |
| 권한 폐기 절차 | 모든 키·토큰을 한 번에 무효화하는 방법 |
| 로그 보존 | 충분한 기간 · 변조 방지 |
| 백업 검증 | 복구를 실제로 해 본 적이 있는가 |
| 법적·공지 의무 | 개인정보 유출 신고 기한 |
| 여기서 이어지는 곳 | 다음 |
|---|---|
| 02·05 해시 · 공개키 | 🔢 이산수학 09·10 수학적 근거 |
| 07 TLS | 🌐 네트워크 09 핸드셰이크 |
| 09 웹 취약점 | 🌐 서버기술 가이드 · 각 언어 가이드 |
| 10 메모리 | 🖥️ 컴퓨터구조 06 · 🔵 C++ 가이드 |
| 12 공급망 | 🏛️ SW 공학 11 CI/CD |
둘 이상이
동시에 일하면
규칙이 달라집니다
스레드 두 개든 서버 백 대든 문제의 성격은 같습니다 — 순서를 보장할 수 없고, 일부만 실패할 수 있고, 시계를 믿을 수 없습니다. 이 탭은 한 프로세스 안의 동시성에서 시작해 여러 대에 걸친 분산까지 같은 흐름으로 이어갑니다. 마지막 절은 장애를 견디는 실무 패턴입니다.
동시성 vs 병렬성 — 다른 문제입니다
동시성은 여러 일을 번갈아 잘 다루는 것이고, 병렬성은 진짜 동시에 하는 것입니다. 해결책과 병목이 완전히 다릅니다.
구분
| 동시성 | 병렬성 | |
|---|---|---|
| 정의 | 여러 작업을 다룬다 | 여러 작업을 실행한다 |
| 코어 1개로 | 가능 | 불가능 |
| 주된 목표 | 응답성 · 자원 활용 | 처리량 · 속도 |
| 적합한 작업 | I/O 바운드 | CPU 바운드 |
| 도구 | async · 이벤트 루프 · 코루틴 | 스레드 · 프로세스 · SIMD · GPU |
| 병목 | 블로킹 호출 | 순차 부분 (암달) |
잘못 고르면
# CPU 작업에 asyncio 를 쓰면 — 아무 이득이 없다
async def compute(n):
return sum(i * i for i in range(n)) # 이벤트 루프를 통째로 막는다
await asyncio.gather(*[compute(10**7) for _ in range(4)])
# 순차 실행과 같은 시간 + 다른 요청도 전부 대기
# ✓ CPU 작업은 프로세스로 — 넘기는 함수는 async 가 아닌 일반 함수여야 한다
def compute_sync(n): # async def 를 넘기면 코루틴이 pickle 되지 않아 실패
return sum(i * i for i in range(n))
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
results = await asyncio.gather(
*[loop.run_in_executor(pool, compute_sync, 10**7) for _ in range(4)])
# I/O 작업에 프로세스를 쓰면 — 낭비다
# 프로세스 100개보다 코루틴 10000개가 훨씬 가볍다
이렇게도 쓸 수 있다 — 암달의 법칙 다시 보기 🟡 중급
병렬화의 상한 (🖥️ 컴퓨터구조 10)
속도 향상 = 1 / ((1-p) + p/n)
p=0.95, n=∞ 이면 최대 20배
★ 실무에서는 '순차 부분' 이 생각보다 크다
· 락 경합 구간
· 결과를 모으는 단계
· 단일 DB 로의 쓰기
· 로그 · 메트릭 전송
· 직렬화 · 네트워크
★ 그리고 병렬화 자체의 비용이 있다
· 태스크 분할 · 스케줄링
· 데이터 복사 · 동기화
· 캐시 오염
→ 작은 작업을 병렬화하면 오히려 느려진다
| 병렬화 전에 확인 | 이유 |
|---|---|
| 정말 CPU 가 병목인가 | 대개 I/O · DB 가 먼저 |
| 작업 하나가 충분히 큰가 | 오버헤드 > 이득 |
| 공유 상태가 있는가 | 락 경합이 이득을 먹는다 |
| 순서가 필요한가 | 결과 정렬 비용 |
| 메모리가 충분한가 | 워커마다 복사본 |
실무에선 이렇게 — 언어별 현실 🔴 실무
| 언어 | 동시성 | 병렬성 | 주의 |
|---|---|---|---|
| 파이썬 | asyncio · 스레드 | 프로세스 (GIL) | 3.13+ free-threaded 실험 중 |
| JS · Node | 이벤트 루프 | 워커 스레드 · 클러스터 | 루프를 막지 말 것 |
| Go | 고루틴 | 고루틴 (자동) | 고루틴 누수 주의 |
| 자바 | 가상 스레드 (21+) | 플랫폼 스레드 | 동기화 구간에서 핀 고정 |
| Rust | async | 스레드 · rayon | 컴파일러가 데이터 경쟁 차단 |
| C# | async/await | Task · Parallel | 동기 컨텍스트 데드락 |
# 파이썬 GIL — CPU 작업은 스레드로 병렬이 안 된다
import threading, time
def burn(n):
while n > 0: n -= 1
# 스레드 4개
t = time.perf_counter()
ts = [threading.Thread(target=burn, args=(10**8,)) for _ in range(4)]
[x.start() for x in ts]; [x.join() for x in ts]
print("스레드", time.perf_counter() - t) # 순차와 비슷하거나 더 느림
# 프로세스 4개
from multiprocessing import Process
t = time.perf_counter()
ps = [Process(target=burn, args=(10**8,)) for _ in range(4)]
[x.start() for x in ps]; [x.join() for x in ps]
print("프로세스", time.perf_counter() - t) # 약 4배 빠름
# ★ 단, I/O 대기 중에는 GIL 이 풀린다
# 그래서 I/O 바운드는 스레드로도 잘 된다
# numpy · 압축 · 암호화 같은 C 확장도 내부에서 GIL 을 푼다
경쟁 상태 · 원자성 — count += 1 은 세 단계입니다
한 줄처럼 보이는 연산이 사실 읽기 · 계산 · 쓰기 세 단계입니다. 그 사이에 다른 스레드가 끼어들면 값이 사라집니다. 재현이 안 되는 버그의 대표적인 원인입니다.
직접 재현해 보기
import threading
counter = 0
def worker():
global counter
for _ in range(100_000):
counter += 1 # ★ 원자적이지 않다
ts = [threading.Thread(target=worker) for _ in range(4)]
[t.start() for t in ts]; [t.join() for t in ts]
print(counter) # 400000 이 보장되지 않는다
# ※ CPython 3.10+ 는 GIL 전환 지점이 드물어 이 코드로는 400000 이 자주 나온다 — 그래도 안전한 게 아니다.
# 읽기와 쓰기 사이에 함수 호출을 끼우고(v = counter; v = int(v) + 1; counter = v)
# 반복을 100만 번으로 늘리면 바로 깨지고, free-threaded 빌드(3.13t+)에서는 그냥도 깨진다.
# ✓ 락으로
lock = threading.Lock()
def worker_safe():
global counter
for _ in range(100_000):
with lock:
counter += 1
# ✓ 더 나은 방법: 각자 세고 마지막에 합친다 (경합 제거)
def worker_local(results, idx):
local = 0
for _ in range(100_000): local += 1
results[idx] = local
# → 락이 아예 필요 없다
원자성이 필요한 자리
| 패턴 | 안전한 방법 |
|---|---|
| 카운터 증가 | 원자 연산 · 락 · 샤딩 |
| 읽고-확인하고-쓰기 | CAS · 조건부 UPDATE |
| “없으면 만들기” | 유니크 제약 + upsert |
| 재고 차감 | UPDATE ... WHERE qty >= n |
| 중복 방지 | DB 유니크 인덱스 |
| 순번 발급 | 시퀀스 · 원자적 INCR |
-- ✗ 애플리케이션에서 확인 후 갱신 (경쟁)
SELECT qty FROM stock WHERE id=1; -- 5
-- (다른 트랜잭션이 끼어듦)
UPDATE stock SET qty = 4 WHERE id=1; -- 잘못된 값
-- ✓ 조건을 UPDATE 에 넣어 원자적으로
UPDATE stock SET qty = qty - 1
WHERE id = 1 AND qty >= 1;
-- 영향받은 행이 0 이면 재고 부족
-- ✓ 중복 생성 방지는 DB 제약으로
CREATE UNIQUE INDEX ON orders (idempotency_key);
INSERT ... ON CONFLICT (idempotency_key) DO NOTHING;
이렇게도 쓸 수 있다 — 원자 연산과 락 프리 카운터 🟡 중급
하드웨어가 제공하는 원자 연산
· 원자적 로드 · 스토어
· fetch_and_add (카운터)
· compare_and_swap (CAS — 04절)
· exchange
이들은 CPU 명령 하나로 처리되어 중간에 끼어들 수 없다
x86: LOCK 접두사 · ARM: LDREX/STREX 또는 LSE 명령
비용
· 캐시 라인을 독점해야 하므로 일반 연산보다 느리다 (수십 사이클)
· 여러 코어가 같은 변수를 다투면 급격히 느려진다
→ 샤딩 · 스레드 로컬 누적으로 경합을 줄인다
// 자바 — 상황별 선택
AtomicLong counter = new AtomicLong();
counter.incrementAndGet(); // 경합이 적을 때
LongAdder adder = new LongAdder(); // ★ 경합이 심할 때
adder.increment(); // 내부적으로 셀을 나눠 쓴다
adder.sum(); // 읽을 때 합산
// Go
var counter int64
atomic.AddInt64(&counter, 1)
// Rust
use std::sync::atomic::{AtomicU64, Ordering};
counter.fetch_add(1, Ordering::Relaxed); // 순서 요구가 없으면 Relaxed
LongAdder 계열을 쓰세요 —
단일 AtomicLong 은 코어가 많을수록
캐시 라인 경합으로 급격히 느려집니다.
🖥️ 컴퓨터구조 08 의 false sharing 과 같은 문제입니다.
실무에선 이렇게 — 경쟁 상태 찾아내기 🔴 실무
| 도구 | 내용 |
|---|---|
| ThreadSanitizer | C/C++ · Go · Rust — 실행 중 데이터 경쟁 탐지 |
Go -race | 테스트·개발 빌드에 항상 켜기 |
| 자바 JCStress | 동시성 불변식 스트레스 테스트 |
| Loom · 결정적 재생 | 가능한 인터리빙을 전부 탐색 |
| Jepsen | 분산 시스템의 일관성 검증 |
| 부하 테스트 + 검증 | 동시 요청 후 불변식 확인 |
# 경쟁 상태 테스트 — 동시에 때리고 결과를 검증
import concurrent.futures as cf
def test_no_double_spend():
create_coupon(code="X", limit=1)
with cf.ThreadPoolExecutor(50) as ex:
results = list(ex.map(lambda _: try_use_coupon("X"), range(50)))
# ★ 불변식: 성공은 정확히 1건이어야 한다
assert sum(1 for r in results if r.ok) == 1
assert get_coupon("X").used == 1
# 이런 테스트를 CI 에 넣어 두면
# "가끔 쿠폰이 두 번 쓰인다" 를 배포 전에 잡을 수 있다
메모리 모델 — 내가 쓴 값을 상대가 못 볼 수 있습니다
락 없이 변수를 공유하면 값이 안 보이거나 순서가 뒤집혀 보일 수 있습니다. 컴파일러와 CPU 가 단일 스레드 기준으로 최적화하기 때문입니다.
가시성 문제
// ✗ 영원히 안 끝날 수 있다
class Worker {
boolean running = true; // volatile 없음
void run() {
while (running) { /* 일 */ } // 컴파일러가 레지스터에 캐시
} // → 다른 스레드의 변경을 못 봄
void stop() { running = false; }
}
// ✓ volatile — 항상 메모리에서 읽고 쓴다
volatile boolean running = true;
// ✓ 또는 원자 타입
AtomicBoolean running = new AtomicBoolean(true);
재정렬 문제
스레드 A 스레드 B
data = 42; if (ready)
ready = true; use(data);
★ 컴파일러나 CPU 가 A 의 두 줄 순서를 바꿀 수 있다
(단일 스레드 관점에서는 결과가 같으므로 허용된다)
→ B 가 ready=true 를 보고 data 는 아직 0 일 수 있다
메모리 순서 강도
x86 : 비교적 강함 (스토어-로드 재정렬만)
ARM : 약함 — 더 많은 재정렬 허용
→ ★ x86 에서 잘 되던 코드가 ARM 에서 깨지는 이유
이렇게도 쓸 수 있다 — happens-before 와 메모리 순서 🟡 중급
happens-before 관계가 성립하면 가시성이 보장된다
자바에서 happens-before 를 만드는 것들
· 같은 스레드의 프로그램 순서
· 락 해제 → 이후의 같은 락 획득
· volatile 쓰기 → 이후의 같은 volatile 읽기
· Thread.start() → 그 스레드의 첫 동작
· 그 스레드의 마지막 동작 → join() 반환
· 동시성 컬렉션 · CountDownLatch 등의 연산
★ 즉 "락을 제대로 쓰면 재정렬을 걱정할 필요가 없다"
락 없이 공유하려 할 때만 이 이론이 필요해진다
// Rust · C++ 의 메모리 순서 (약한 것부터)
use std::sync::atomic::Ordering;
Ordering::Relaxed // 원자성만 보장 · 순서 보장 없음 (카운터)
Ordering::Acquire // 이 읽기 이후의 연산이 앞으로 못 옴 (락 획득)
Ordering::Release // 이 쓰기 이전의 연산이 뒤로 못 감 (락 해제)
Ordering::AcqRel // 둘 다 (읽고-수정하고-쓰기)
Ordering::SeqCst // 전역 순서 보장 · 가장 강하고 가장 느림 ★ 기본 선택
// 전형적인 Release/Acquire 쌍
// 생산자
data.store(42, Ordering::Relaxed);
ready.store(true, Ordering::Release); // 이 앞의 쓰기가 모두 보인다
// 소비자
if ready.load(Ordering::Acquire) { // 이 뒤의 읽기가 뒤로 안 밀린다
assert_eq!(data.load(Ordering::Relaxed), 42); // 보장됨
}
SeqCst 를 쓰세요 —
약한 순서로 성능을 얻는 것은 측정으로 필요성이 증명된 뒤에만
의미가 있고, 틀렸을 때 재현이 거의 불가능합니다.
실무에선 이렇게 — 이중 검사 잠금의 함정 🔴 실무
✗ 유명한 깨진 패턴
class Singleton {
private static Singleton inst; // volatile 없음
static Singleton get() {
if (inst == null) {
synchronized (Singleton.class) {
if (inst == null)
inst = new Singleton();
}
}
return inst;
}
}
// 문제: 생성자 실행과 참조 대입이 재정렬될 수 있다
// → 다른 스레드가 '아직 초기화 안 된 객체' 를 볼 수 있다
✓ 올바른 방법들
// ① volatile 추가
private static volatile Singleton inst;
// ② 홀더 관용구 (더 깔끔)
private static class Holder {
static final Singleton INST = new Singleton();
}
static Singleton get() { return Holder.INST; }
// 클래스 초기화는 JVM 이 스레드 안전하게 보장
// ③ enum (직렬화까지 안전)
enum Singleton { INSTANCE; }
# 파이썬 · Go 에서도 같은 문제가 있다
# 파이썬: 모듈 레벨 초기화가 가장 확실 (임포트는 락으로 보호된다)
from functools import cache
@cache
def get_client():
return ExpensiveClient() # 캐시는 스레드 안전하지만, 첫 호출이 동시에 겹치면
# 생성자가 두 번 돌 수 있다 — 엄격히 한 번이면 락을 함께
# Go: sync.Once 를 쓴다
var once sync.Once
var client *Client
func Get() *Client {
once.Do(func() { client = NewClient() })
return client
}
sync.Once · lazy_static · 홀더 관용구 ·
@cache. 직접 이중 검사를 구현할 이유가 거의 없고,
직접 하면 대개 틀립니다.
락 프리 · CAS — “바뀌지 않았으면 바꿔라”
락 없이 동기화하는 핵심 도구가 Compare-And-Swap 입니다. 빠르지만 함정이 많고 구현이 어렵습니다 — 왜 그런지, 언제 락이 더 나은지를 봅니다.
CAS 의 원리
CAS(주소, 기대값, 새값)
if (*주소 == 기대값) { *주소 = 새값; return true; }
else { return false; }
★ 이 전체가 CPU 명령 하나로 원자적으로 실행된다
전형적인 사용 — 재시도 루프
loop {
old = load(addr)
new = f(old)
if CAS(addr, old, new) break; // 성공하면 끝
// 실패 = 그 사이에 누가 바꿨다 → 다시 계산
}
성질
· 락이 없어 데드락이 없다
· 한 스레드가 멈춰도 다른 스레드는 진행 (논블로킹)
· 경합이 심하면 재시도가 늘어 오히려 느려질 수 있다
ABA 문제
스레드 A: old = X 를 읽음
스레드 B: X → Y → X 로 두 번 바꿈
스레드 A: CAS 성공! (값은 X 로 같으니까)
★ 그런데 그 사이에 상태가 달라졌다
포인터를 다루는 락프리 스택에서 특히 위험
· A 가 노드를 읽음
· B 가 그 노드를 꺼내 해제하고, 같은 주소에 새 노드 할당
· A 의 CAS 가 성공 → 해제된 메모리를 참조
해결
· 태그된 포인터 (값 + 버전 카운터를 함께 CAS)
· 해저드 포인터 · epoch 기반 회수
· GC 언어에서는 메모리 재사용이 없어 덜 심각하다
이렇게도 쓸 수 있다 — 낙관적 잠금 — 같은 아이디어를 DB 에 🟡 중급
-- 비관적 잠금: 먼저 잠그고 작업 (충돌이 잦을 때)
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 낙관적 잠금: 충돌이 없다고 보고 진행, 버전으로 확인 (CAS 와 같은 발상)
SELECT id, balance, version FROM accounts WHERE id = 1; -- version = 7
-- 애플리케이션에서 계산
UPDATE accounts
SET balance = 900, version = 8
WHERE id = 1 AND version = 7; -- ★ 그 사이에 안 바뀌었으면
-- 영향받은 행이 0 이면 충돌 → 다시 읽고 재시도
| 비관적 | 낙관적 | |
|---|---|---|
| 충돌이 잦을 때 | 유리 | 재시도가 늘어 불리 |
| 충돌이 드물 때 | 락 비용만 낭비 | 유리 |
| 긴 트랜잭션 | 다른 요청을 막음 | 안 막음 |
| 사용자 편집 화면 | 부적합 (오래 잠금) | 적합 |
| 데드락 | 가능 | 없음 |
| 구현 | 단순 | 재시도 로직 필요 |
실무에선 이렇게 — 언제 락프리가 이득인가 🔴 실무
| 상황 | 선택 |
|---|---|
| 단순 카운터 · 플래그 | 원자 연산 |
| 경합이 낮은 짧은 임계 구역 | 락이 충분히 빠름 |
| 경합이 극심한 카운터 | 샤딩 · LongAdder |
| 실시간 요구 (오디오 · 게임) | 락프리 (지연 예측 가능) |
| 복잡한 자료구조 | 검증된 라이브러리 |
| 대부분의 애플리케이션 코드 | 락 또는 메시지 전달 |
실무 순서 (권장)
① 공유 상태를 없앤다 — 가장 좋다
② 불변 데이터로 만든다 — 읽기는 동기화가 필요 없다
③ 메시지 전달로 소유권 이전 — 채널 · 액터
④ 표준 동시성 컬렉션 — ConcurrentHashMap · sync.Map
⑤ 락 — 짧게 · 순서 고정
⑥ 원자 연산 — 단순한 것만
⑦ 락프리 자료구조 — 정말 필요할 때 · 라이브러리로
★ 위에서부터 시도하고, 측정으로 필요성이 증명될 때만 아래로 내려간다
동시성 모델 — 액터 · CSP · 이벤트 루프
스레드 + 락 말고도 방법이 있습니다. 메모리를 공유하는 대신 메시지를 주고받으면 경쟁 상태가 구조적으로 사라집니다.
모델 비교
| 모델 | 핵심 | 대표 |
|---|---|---|
| 공유 메모리 + 락 | 같은 데이터를 잠그고 접근 | 자바 · C++ |
| 액터 | 각자 상태를 갖고 메시지만 주고받음 | Erlang · Akka |
| CSP | 채널로 값을 전달 | Go · Clojure |
| 이벤트 루프 | 단일 스레드 + 콜백/await | Node · asyncio |
| STM | 트랜잭션 메모리 | Haskell · Clojure |
| 데이터 병렬 | 같은 연산을 데이터에 | rayon · numpy |
채널 (CSP)
// 소유권을 넘긴다 — 공유가 없으니 락도 없다
jobs := make(chan Job, 100)
results := make(chan Result, 100)
for w := 0; w < 8; w++ { // 워커 8개
go func() {
for job := range jobs {
results <- process(job)
}
}()
}
for _, j := range allJobs { jobs <- j }
close(jobs) // ★ 닫아야 워커의 range 가 끝난다
// select 로 여러 채널을 기다린다
select {
case r := <-results:
handle(r)
case <-ctx.Done(): // 취소
return ctx.Err()
case <-time.After(5 * time.Second):
return errors.New("timeout")
}
이렇게도 쓸 수 있다 — 각 모델의 함정 🟡 중급
| 모델 | 함정 | 대응 |
|---|---|---|
| 액터 | 메일박스가 무한히 참 · 메시지 유실 | 바운드 메일박스 · 배압 |
| 채널 | 닫지 않아 고루틴 누수 · 데드락 | context 로 취소 · 반드시 close |
| 이벤트 루프 | 블로킹 호출이 전체를 멈춤 | 비동기 드라이버 · 워커 위임 |
| async/await | 색깔 문제 (동기↔비동기 경계) | 경계를 명확히 · executor |
| 공유 메모리 | 데드락 · 경쟁 | 락 순서 · 검사 도구 |
// 고루틴 누수 — 가장 흔한 Go 버그
// ✗ 채널을 읽는 사람이 없으면 영원히 블로킹
func leak() {
ch := make(chan int) // 버퍼 없음
go func() { ch <- 1 }() // 아무도 안 읽으면 이 고루틴은 영원히 남는다
}
// ✓ context 로 취소 가능하게
func noLeak(ctx context.Context) {
ch := make(chan int, 1) // 버퍼를 두거나
go func() {
select {
case ch <- compute():
case <-ctx.Done(): // 취소되면 빠져나온다
}
}()
}
// 누수 확인
// 테스트에서 go.uber.org/goleak (go test -race 는 경쟁 탐지용이라 누수는 못 잡는다)
// runtime.NumGoroutine() 을 메트릭으로 노출
// pprof goroutine 프로파일
runtime.NumGoroutine() 을 메트릭으로 항상 노출하고
우상향하면 경보를 울리세요.
실무에선 이렇게 — 배압(backpressure) 🔴 실무
생산이 소비보다 빠르면 어딘가에 쌓인다
· 큐가 무한이면 → 메모리 폭발 · 지연 폭발
· 큐가 유한이면 → 어떻게 할지 정해야 한다
배압 = "느려졌으니 천천히 보내라" 는 신호를 위로 전달하는 것
방법
① 블로킹 — 생산자를 멈춘다 (가장 단순 · 자연스러운 배압)
② 거절 — 즉시 실패 · 429 응답
③ 버리기 — 오래된 것 · 새 것 (메트릭에는 최신이 중요)
④ 샘플링 — 일부만 처리
⑤ 병합 — 같은 키의 갱신을 합침
★ 반드시 '버린 개수' 를 메트릭으로 남긴다
조용히 버리면 데이터가 왜 없는지 아무도 모른다
# asyncio 에서 동시 실행 수 제한 (배압)
sem = asyncio.Semaphore(20)
async def fetch_one(url):
async with sem: # 동시에 20개까지만
return await client.get(url)
results = await asyncio.gather(*[fetch_one(u) for u in urls])
# ★ gather 자체는 태스크를 전부 만들므로 URL 이 100만 개면 메모리가 터진다
# → 청크로 나누거나 큐 기반으로
# 큐 기반 (메모리 상한이 명확)
queue = asyncio.Queue(maxsize=1000) # ★ 유한
async def producer():
for u in urls:
await queue.put(u) # 가득 차면 여기서 대기 = 배압
분산 시스템의 여덟 가지 오해 — 조용한 가정들
1994년에 정리된 목록인데 지금도 그대로 유효합니다. 설계 리뷰에서 이 여덟 개를 훑는 것만으로 큰 실수를 상당히 걸러 낼 수 있습니다.
| # | 오해 | 현실 · 대응 |
|---|---|---|
| 1 | 네트워크는 안정적이다 | 끊긴다 · 재시도 + 멱등성 (13절) |
| 2 | 지연은 0 이다 | 왕복이 쌓인다 · 호출 사슬을 얕게 (🌐 네트워크 12) |
| 3 | 대역폭은 무한하다 | 페이로드를 줄인다 · 압축 · 페이지네이션 |
| 4 | 네트워크는 안전하다 | 내부망도 암호화 · mTLS · 제로 트러스트 |
| 5 | 토폴로지는 안 변한다 | IP 를 하드코딩하지 말 것 · 서비스 디스커버리 |
| 6 | 관리자는 한 명이다 | 여러 팀 · 여러 정책 · 버전 불일치 |
| 7 | 전송 비용은 0 이다 | 직렬화 · 이그레스 요금 · CPU |
| 8 | 네트워크는 균질하다 | 다른 OS · 인코딩 · 시간대 · MTU |
부분 실패 — 분산 시스템의 본질
단일 프로세스: 함수 호출은 성공하거나 예외를 던진다
분산 시스템: ★ 세 번째 경우가 있다 — "모른다"
요청을 보냈는데 응답이 없다. 그러면
· 요청이 도착하지 않았나?
· 도착했는데 처리 중인가?
· 처리했는데 응답이 유실됐나?
→ 구분할 수 없다
결과
· 재시도하면 중복 처리 위험
· 재시도 안 하면 유실 위험
→ ★ 그래서 멱등성이 필수다 (13절)
장애 감지의 한계
"노드가 죽었나?" 를 확실히 알 수 없다
타임아웃을 짧게 → 느린 노드를 죽었다고 오판
타임아웃을 길게 → 장애 감지가 늦어짐
★ 비동기 네트워크에서는 완벽한 장애 감지가 불가능하다
(FLP 불가능성 정리와 연결된다)
실무 대응
· 여러 신호를 조합 (하트비트 + 요청 성공률 + 지연)
· phi accrual 장애 감지기 — 확률적 판단
· 펜싱 토큰으로 '부활한 좀비' 를 차단 (09절)
· 죽었다고 판단해도 되돌릴 수 있게 설계
이렇게도 쓸 수 있다 — 스플릿 브레인 🟡 중급
네트워크가 둘로 갈라지면 양쪽이 각각 "내가 리더" 라고 생각할 수 있다
[노드1 노드2] ✂ 단절 ✂ [노드3 노드4 노드5]
각자 리더 선출 → 데이터가 갈라진다 → 합칠 때 충돌
방지: 쿼럼 (과반수)
· 5노드 중 과반은 3
· 2노드 쪽은 과반을 못 만들어 리더가 될 수 없다
· 3노드 쪽만 계속 동작 → 일관성 유지
★ 짝수 노드는 피한다
4노드에서 2:2 로 갈리면 양쪽 다 과반이 안 됨 → 전체 정지
같은 내결함성(1대 장애 허용)이면 3노드가 낫다
3노드: 1대 장애 허용 5노드: 2대 장애 허용
4노드: 1대 장애 허용 6노드: 2대 장애 허용 ← 늘려도 이득 없음
실무에선 이렇게 — 장애를 전제로 설계하기 🔴 실무
| 질문 | 확인할 것 |
|---|---|
| 이 호출이 실패하면? | 폴백 · 캐시 · 부분 응답 |
| 이 호출이 느려지면? | 타임아웃 · 서킷 브레이커 |
| 두 번 실행되면? | 멱등성 |
| 순서가 바뀌면? | 버전 · 시퀀스 번호 |
| 이 노드가 죽으면? | 다른 노드가 이어받나 · 상태는? |
| 전체 리전이 죽으면? | 다중 리전 · RTO/RPO |
| 의존 서비스가 전부 죽으면? | 우아한 저하 · 읽기 전용 모드 |
카오스 엔지니어링 — 장애를 일부러 일으켜 검증
① 정상 상태를 정의한다 (지표로)
② 가설을 세운다 ("인스턴스 하나가 죽어도 지표가 유지된다")
③ 실제 환경에서 실험한다 (통제된 범위 · 중단 스위치 준비)
④ 가설이 틀리면 그것이 발견한 취약점이다
주입할 장애
· 인스턴스 종료 · 네트워크 지연 · 패킷 손실
· 의존 서비스 응답 지연 · 에러율 상승
· 디스크 가득 · CPU 포화 · 시계 왜곡
★ 프로덕션에서 하기 전에 스테이징에서 · 업무 시간에 · 롤백 준비
CAP 정리 · PACELC — 자주 오해되는 정리
“일관성과 가용성 중 하나를 고르라”가 아닙니다. 정확히는 “망이 갈라졌을 때 무엇을 포기할지 정하라”이고, 평소에는 다른 선택(PACELC)이 더 중요합니다.
정확한 진술
분단이 일어났을 때의 선택
CP — 일관성 우선
소수 쪽 노드가 요청을 거부한다
"정확하지 않은 답을 줄 바에 답을 안 한다"
예: etcd · ZooKeeper · 전통적 RDBMS 클러스터
AP — 가용성 우선
양쪽 다 응답하고 나중에 합친다
"오래된 답이라도 주는 게 낫다"
예: Cassandra · DynamoDB(설정) · DNS
★ CA 는 존재하지 않는다
네트워크 분단은 '선택' 이 아니라 '현실' 이기 때문
(단일 노드라면 P 가 없으니 CA 지만, 그건 분산이 아니다)
PACELC — 더 유용한 틀
| 시스템 | 분단 시 | 평소 |
|---|---|---|
| PostgreSQL (단일) | — | C |
| MongoDB (기본) | C | C |
| Cassandra | A | L (조정 가능) |
| DynamoDB | A | L (강한 읽기 옵션) |
| etcd · ZooKeeper | C | C |
| Redis 복제 | A | L |
이렇게도 쓸 수 있다 — 기능별로 다르게 고르기 🟡 중급
| 기능 | 선택 | 이유 |
|---|---|---|
| 결제 · 재고 차감 | 강한 일관성 | 중복·초과 판매가 치명적 |
| 사용자 프로필 조회 | 최종 일관성 | 몇 초 지연 무해 |
| 좋아요 · 조회수 | 최종 일관성 | 근사값으로 충분 |
| 인증 · 권한 | 강한 일관성 | 권한 회수가 늦으면 위험 |
| 피드 · 타임라인 | 최종 일관성 | 가용성이 더 중요 |
| 주문 상태 | 읽기-내-쓰기 | 내 주문은 바로 보여야 |
| 로그 · 분석 | 최종 일관성 | 배치로 정합 |
★ 시스템 전체를 하나로 고르지 않는다
같은 서비스 안에서도 기능마다 다르게 선택한다
전형적인 구성
주문 · 결제 → PostgreSQL (강한 일관성)
세션 · 캐시 → Redis (빠름 · 유실 허용)
상품 검색 → Elasticsearch (최종 일관성)
이벤트 로그 → Kafka (순서 보장 · 재처리 가능)
파일 → S3 (최종 일관성 · 지금은 강한 읽기)
이것을 '다중 저장소 지속성(polyglot persistence)' 이라 부른다
비용: 데이터 동기화 · 일관성 관리가 복잡해진다
실무에선 이렇게 — 분단 시 동작을 미리 정하기 🔴 실무
설계 문서에 반드시 적을 것
"DB 복제가 끊기면"
→ 읽기 전용 모드로 전환 · 쓰기는 503
→ 사용자에게 보여 줄 메시지
→ 복구 후 자동 재개 조건
"캐시가 죽으면"
→ DB 직접 조회 (성능 저하 감수)
→ 또는 서킷 브레이커로 일부 기능 비활성
"외부 결제사가 응답 없으면"
→ 주문은 '결제 대기' 로 접수
→ 백그라운드에서 상태 조회 후 확정
★ 이것을 정하지 않으면 장애 때 즉흥적으로 대응하게 되고,
그때 내린 판단이 데이터를 망친다
# 우아한 저하 — 기능별로 다르게
async def get_product_page(pid):
# 필수: 실패하면 에러
product = await db.get_product(pid)
# 선택: 실패해도 페이지는 보여 준다
recommendations = []
reviews_count = None
try:
recommendations = await asyncio.wait_for(rec_service.get(pid), 0.2)
except Exception:
metrics.inc("rec_degraded")
try:
reviews_count = await asyncio.wait_for(review_service.count(pid), 0.1)
except Exception:
metrics.inc("review_degraded")
return render(product, recommendations, reviews_count)
# ★ 부가 기능의 장애가 핵심 기능을 막지 않게 한다
일관성 모델 — “쓴 걸 바로 읽을 수 있나”
복제본을 두는 순간 여러 단계의 답이 생깁니다. 각 모델이 무엇을 보장하고 무엇은 안 하는지, 그리고 사용자가 어떤 이상함을 겪게 되는지를 봅니다.
사용자가 겪는 이상함
| 증상 | 깨진 보장 |
|---|---|
| 글을 썼는데 목록에 없다 | 읽기-내-쓰기 |
| 새로고침하면 있다가 없다가 | 단조 읽기 |
| 답글이 원글보다 먼저 보인다 | 인과 일관성 |
| 잔액이 잠깐 이상하다 | 선형화 |
| 주문 후 목록에 안 보인다 | 읽기-내-쓰기 |
실무적인 해결
# ① 쓰기 직후에는 리더에서 읽는다 (가장 간단)
def get_my_posts(user_id):
if recently_wrote(user_id, within_seconds=5):
return db.leader.query(...) # 복제 지연 회피
return db.replica.query(...)
# ② 세션 스티키 — 같은 사용자를 같은 복제본으로
# (복제본이 죽으면 다시 뒤로 갈 수 있음)
# ③ 버전(LSN) 을 클라이언트가 들고 다닌다
# 쓰기 응답에 버전을 주고, 읽을 때 그 버전 이상인 복제본에서만
resp = write(...) # {"lsn": 123456}
read(min_lsn=resp["lsn"]) # 복제본이 따라잡을 때까지 대기 또는 리더로
# ④ 낙관적 UI — 서버 응답 전에 화면을 먼저 갱신
# 실패하면 되돌린다 (사용자 체감이 가장 좋다)
이렇게도 쓸 수 있다 — 쿼럼 — R + W > N 🟡 중급
N 개의 복제본, 쓰기에 W 개 응답, 읽기에 R 개 조회
R + W > N 이면 읽기와 쓰기 집합이 반드시 겹친다
→ 최신 값을 최소 하나는 읽게 된다
예 (N=3)
W=3, R=1 → 쓰기 느림 · 읽기 빠름 · 쓰기 가용성 낮음
W=2, R=2 → ★ 균형 (한 노드 장애 허용)
W=1, R=1 → 둘 다 빠름 · 일관성 없음 (최종 일관성)
W=1, R=3 → 쓰기 빠름 · 읽기 느림
★ 주의: 쿼럼만으로 선형화가 보장되지는 않는다
· 동시 쓰기 충돌 · 부분 실패 · 롤백 없음
· 진짜 선형화가 필요하면 합의 프로토콜 (09절)
Cassandra 의 일관성 수준 (요청마다 지정 가능)
ONE · TWO · THREE
QUORUM — 과반
LOCAL_QUORUM — 같은 DC 안에서 과반 ★ 다중 DC 에서 실용적
ALL — 전부 (가용성 최악)
# 쓰기는 QUORUM, 읽기는 QUORUM → R+W > N
# 로그성 데이터는 ONE 으로 빠르게
실무에선 이렇게 — 복제 지연 다루기 🔴 실무
-- PostgreSQL — 복제 지연 확인
SELECT client_addr, state,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes,
replay_lag
FROM pg_stat_replication;
-- 복제본에서 지연 확인
SELECT now() - pg_last_xact_replay_timestamp() AS lag;
-- MySQL
SHOW REPLICA STATUS\G -- Seconds_Behind_Source
| 지연이 커지는 원인 | 대응 |
|---|---|
| 대량 배치 쓰기 | 청크로 나눠 실행 · 저부하 시간에 |
| 복제본에서 무거운 쿼리 | 충돌하는 쿼리 격리 · 타임아웃 |
| 단일 스레드 적용 | 병렬 복제 활성화 |
| 네트워크 대역폭 | 압축 · 리전 간 구성 재검토 |
| 복제본 하드웨어가 약함 | 동등한 사양으로 |
합의 — 여러 대가 같은 값에 동의하는 법
리더 선출 · 분산 락 · 설정 저장이 전부 합의 위에 있습니다. Raft 를 직접 구현할 일은 없어도, 왜 느리고 무엇을 보장하는지는 알아야 잘 쓸 수 있습니다.
Raft 의 세 요소
- 리더 선출 — 임기(term)마다 하나의 리더. 하트비트가 끊기면 후보가 되어 투표를 요청하고, 과반 득표로 리더가 됩니다.
- 로그 복제 — 모든 쓰기는 리더를 거칩니다. 리더가 팔로워에게 로그를 보내고 과반이 기록하면 커밋합니다.
- 안전성 — 커밋된 항목은 절대 사라지지 않고 순서가 바뀌지 않습니다.
노드 상태
Follower → 타임아웃 → Candidate → 과반 득표 → Leader
↓ 실패
다시 Follower
★ 무작위 선거 타임아웃으로 동시 후보를 줄인다
★ 쓰기 한 번에 최소 1 RTT × (리더 → 과반 팔로워)
→ 지연이 있는 다중 리전에서는 비싸다
쓰는 곳과 주의점
| 시스템 | 용도 |
|---|---|
| etcd | 쿠버네티스의 상태 저장소 |
| ZooKeeper | Kafka · HBase 의 코디네이션 |
| Consul | 서비스 디스커버리 · 설정 |
| TiKV · CockroachDB | 분산 트랜잭션 저장소 |
| Kafka (KRaft) | ZooKeeper 대체 |
이렇게도 쓸 수 있다 — 분산 락의 어려움 🟡 중급
분산 락은 생각보다 위험하다
① 클라이언트 A 가 락을 얻는다 (TTL 30초)
② A 가 GC 정지나 네트워크 지연으로 40초간 멈춘다
③ TTL 만료 → B 가 락을 얻는다
④ A 가 깨어나서 "나는 락을 갖고 있다" 며 작업을 계속한다
★ 두 클라이언트가 동시에 임계 구역에 있다
해결: 펜싱 토큰
· 락을 줄 때마다 증가하는 번호를 함께 준다
· 자원(스토리지)이 그 번호를 확인해 오래된 것을 거부한다
A: 토큰 33 으로 쓰기 → 성공
B: 토큰 34 로 쓰기 → 성공 (저장소가 34 를 기억)
A: 토큰 33 으로 쓰기 → ★ 거부
★ 자원 쪽이 토큰을 검사해 주지 않으면 완전한 해결이 안 된다
# Redis 단일 인스턴스 락 (Redlock 은 논쟁이 있으니 신중히)
import uuid, redis
def acquire(r, key, ttl_ms=30000):
token = str(uuid.uuid4())
ok = r.set(key, token, nx=True, px=ttl_ms) # 원자적
return token if ok else None
RELEASE = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
""" # ★ 내 토큰일 때만 해제 (남의 락을 지우면 안 된다)
def release(r, key, token):
r.eval(RELEASE, 1, key, token)
# ★ 더 중요한 질문: 락이 정말 필요한가?
# · 효율 목적(중복 작업 방지)이면 → 가끔 중복돼도 괜찮게 설계
# · 정확성 목적(중복이 치명적)이면 → 락이 아니라 DB 제약·멱등성으로
실무에선 이렇게 — 리더 선출이 필요한 경우 🔴 실무
| 상황 | 대안 |
|---|---|
| 스케줄된 배치를 한 대만 실행 | DB 락 · 유니크 제약 · 쿠버네티스 CronJob |
| 캐시 갱신을 한 번만 | 확률적 조기 만료 · singleflight |
| 파티션 소유권 배정 | Kafka 컨슈머 그룹이 자동 처리 |
| 진짜 리더가 필요 | etcd/Consul 의 리더 선출 API |
# 배치 중복 실행 방지 — 락보다 DB 제약이 확실하다
# CREATE UNIQUE INDEX ON job_runs (job_name, run_date);
def run_daily_job(name, date):
try:
db.execute(
"INSERT INTO job_runs (job_name, run_date, started_at) VALUES (%s,%s,now())",
(name, date))
except UniqueViolation:
log.info("이미 실행됨 — 건너뜀")
return
do_work()
# singleflight — 같은 키의 동시 요청을 하나로 합친다
# (캐시 미스가 겹칠 때 DB 로 한 번만 간다)
# Go: golang.org/x/sync/singleflight
# 파이썬: 자체 구현 또는 asyncio 의 Future 공유
복제 · 샤딩 — 복사할 것인가 나눌 것인가
복제는 같은 데이터를 여러 곳에(가용성 · 읽기 확장), 샤딩은 데이터를 쪼개서 나눠(쓰기 · 용량 확장). 목적이 다르고 대개 둘 다 씁니다.
복제 방식
| 방식 | 특징 |
|---|---|
| 단일 리더 | 쓰기는 리더만 · 가장 흔함 · 충돌 없음 |
| 다중 리더 | 여러 곳에서 쓰기 · 충돌 해결 필요 |
| 리더 없음 | 아무 노드에나 · 쿼럼으로 (Dynamo 계열) |
| 동기화 | 내용 | 트레이드오프 |
|---|---|---|
| 동기 | 복제본 응답까지 대기 | 안전 · 느림 · 가용성↓ |
| 비동기 | 바로 응답 | 빠름 · 장애 시 유실 가능 |
| 반동기 | 일부만 동기 | 실무 절충 |
샤딩 키 고르기
| 방식 | 장점 | 단점 |
|---|---|---|
| 범위 | 범위 조회 효율 | 핫스팟 (최신 데이터에 몰림) |
| 해시 | 고른 분산 | 범위 조회 불가 |
| 일관 해싱 | 노드 변경에 강함 | 구현 복잡 |
| 디렉터리 | 유연함 | 조회 계층 추가 |
| 지역 기반 | 지연 · 규제 대응 | 불균형 |
샤딩 키 선택 기준
① 카디널리티가 높은가 (값 종류가 충분한가)
② 고르게 분산되는가 (핫스팟이 없는가)
③ ★ 대부분의 쿼리가 한 샤드에서 끝나는가
④ 시간이 지나도 균형이 유지되는가
나쁜 예
· 국가 코드 — 특정 국가에 몰림
· 생성 시각 — 최신 샤드에만 쓰기 집중
· 사용자 등급 — 카디널리티가 낮음
좋은 예
· user_id 해시 (사용자 단위 쿼리가 많다면)
· tenant_id (멀티테넌트)
이렇게도 쓸 수 있다 — 샤딩의 대가 🟡 중급
| 잃는 것 | 내용 | 대응 |
|---|---|---|
| 크로스 샤드 조인 | 불가능하거나 매우 비쌈 | 비정규화 · 애플리케이션 조인 |
| 전역 트랜잭션 | 2PC 필요 (느림) | 사가 · 샤드 내로 제한 |
| 전역 유니크 | 보장 어려움 | UUID · 스노우플레이크 (12절) |
| 집계 쿼리 | 모든 샤드 조회 후 병합 | 사전 집계 · OLAP 분리 |
| 리밸런싱 | 데이터 이동이 비쌈 | 가상 노드 · 온라인 이동 |
| 운영 복잡도 | 백업 · 스키마 변경 · 모니터링 | 자동화 · 관리형 서비스 |
★ 샤딩 전에 시도할 것 (순서대로)
① 인덱스 · 쿼리 튜닝 — 대부분 여기서 해결된다
② 수직 확장 (더 큰 인스턴스) — 지금 하드웨어는 매우 크다
③ 읽기 복제본 — 읽기가 많으면
④ 캐시 — 반복 조회 제거
⑤ 아카이빙 · 파티셔닝 — 오래된 데이터 분리
⑥ 기능별 DB 분리 (수직 분할) — 서비스 경계로
⑦ ★ 샤딩 (수평 분할) — 마지막
단일 PostgreSQL 인스턴스가 다룰 수 있는 규모는 생각보다 크다
(수 TB · 수만 TPS) — 대부분의 서비스는 여기 도달하지 못한다
실무에선 이렇게 — 리밸런싱 🔴 실무
노드를 추가할 때 데이터를 어떻게 옮기나
✗ 해시 mod N
N 이 바뀌면 거의 모든 키가 이동한다 (🔢 이산수학 10)
✓ 고정 파티션 수
· 처음부터 파티션을 많이 만든다 (예: 1024개)
· 노드가 늘면 파티션을 통째로 재배정
· 이동하는 데이터가 정확히 필요한 만큼
· Kafka · Elasticsearch 가 이 방식
✓ 일관 해싱
· 링 위에 가상 노드를 배치
· 노드 추가 시 1/N 만 이동
· Cassandra · DynamoDB · Redis Cluster
★ 자동 리밸런싱은 위험할 수 있다
부하가 높을 때 자동으로 시작되면 상황이 악화된다
→ 사람이 승인하는 반자동이 안전하다
| 운영 시 확인 | 내용 |
|---|---|
| 샤드별 크기 · QPS | 불균형이 생기면 조기에 대응 |
| 핫 키 탐지 | 특정 키에 트래픽 집중 (인기 상품 · 유명인) |
| 리밸런싱 진행률 · 영향 | 지연 증가를 감시 |
| 샤드 맵의 일관성 | 클라이언트가 옛 맵을 쓰면 오류 |
key#1~key#N),
로컬 캐시로 흡수해야 합니다.
분산 트랜잭션 — “다 되거나 다 안 되게”
여러 서비스에 걸친 작업을 원자적으로 만드는 문제입니다. 2PC 는 이론적으로 맞지만 실무에서는 위험하고, 대부분 사가 패턴으로 풉니다.
2단계 커밋 (2PC)
1단계 — 준비
코디네이터 → 모든 참여자: "커밋할 수 있나?"
참여자 → "예" (이 시점부터 자원을 잠근 채 대기)
2단계 — 커밋
코디네이터 → 모든 참여자: "커밋해"
참여자 → 커밋 완료
★ 치명적 문제
· 코디네이터가 1단계 뒤에 죽으면
참여자들은 락을 쥔 채 영원히 대기 (블로킹)
· 참여자 하나가 느리면 전체가 대기
· 가용성이 모든 참여자의 곱이 된다
99% × 99% × 99% = 97%
→ 그래서 마이크로서비스에서는 거의 안 쓴다
사가 패턴
긴 트랜잭션을 여러 로컬 트랜잭션으로 쪼개고
실패하면 ★ 보상 트랜잭션으로 되돌린다
주문 생성 → 결제 승인 → 재고 차감 → 배송 요청
↓ 실패 ↓ 실패 ↓ 실패
주문 취소 ← 결제 취소 ← 재고 복구
두 가지 방식
오케스트레이션 — 중앙 조정자가 순서를 관리
+ 흐름이 한눈에 보임 · 디버깅 쉬움
- 조정자가 단일 지점
코레오그래피 — 각 서비스가 이벤트를 듣고 반응
+ 결합도 낮음
- 전체 흐름을 파악하기 어려움
이렇게도 쓸 수 있다 — 아웃박스 패턴 🟡 중급
문제: DB 저장과 이벤트 발행을 원자적으로 할 수 없다
db.save(order) ← 성공
kafka.publish(event) ← 실패! → 이벤트가 유실된다
순서를 바꿔도 마찬가지 (이벤트만 나가고 저장 실패)
해결: 아웃박스 테이블
① 같은 DB 트랜잭션 안에서 데이터와 이벤트를 함께 저장
② 별도 프로세스가 아웃박스를 읽어 메시지 브로커로 발행
③ 발행 성공하면 아웃박스에서 삭제(또는 표시)
→ 원자성이 DB 트랜잭션 하나로 보장된다
→ 최소 한 번 전달 (중복 가능 → 소비자가 멱등해야 함)
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX ON outbox (published_at) WHERE published_at IS NULL;
-- 애플리케이션
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (aggregate, event_type, payload)
VALUES ('order', 'OrderCreated', '{"id":123}');
COMMIT; -- ★ 둘 다 되거나 둘 다 안 된다
-- 발행 워커 (여러 대여도 안전하게)
BEGIN;
SELECT * FROM outbox
WHERE published_at IS NULL
ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED; -- ★ 다른 워커와 겹치지 않는다
-- 발행 후
UPDATE outbox SET published_at = now() WHERE id = ANY(...);
COMMIT;
FOR UPDATE SKIP LOCKED 가 큐 구현의 핵심입니다 —
여러 워커가 같은 행을 잡지 않고 각자 다른 행을 가져갑니다.
DB 를 간단한 작업 큐로 쓸 때도 같은 패턴을 씁니다.
실무에선 이렇게 — 전달 보장 🔴 실무
| 보장 | 내용 | 현실 |
|---|---|---|
| 최대 한 번 | 중복 없음 · 유실 가능 | 메트릭 등 손실 허용 |
| 최소 한 번 | 유실 없음 · 중복 가능 | 대부분의 시스템 |
| 정확히 한 번 | 이상적 | 엄밀히는 불가능 |
"정확히 한 번" 이 불가능한 이유
네트워크에서 ACK 가 유실되면 보낸 쪽은 알 수 없다
→ 재전송할 수밖에 없고, 그러면 중복이 생긴다
★ 실무의 답: "최소 한 번 전달 + 멱등한 처리"
= 결과적으로 정확히 한 번처럼 보인다 (effectively once)
Kafka 의 '정확히 한 번' 도 사실
· 멱등 프로듀서 (시퀀스 번호로 중복 제거)
· 트랜잭션 (읽기-처리-쓰기를 원자적으로)
· ★ Kafka 안에서만 보장 — 외부 시스템에 쓰면 깨진다
# 소비자를 멱등하게 만드는 표준 방법
def handle_event(event):
# ① 처리 이력 테이블에 이벤트 ID 를 먼저 기록 (유니크 제약)
try:
db.execute("INSERT INTO processed_events (id) VALUES (%s)",
(event.id,))
except UniqueViolation:
log.info("이미 처리한 이벤트 — 건너뜀")
return
# ② 같은 트랜잭션 안에서 실제 처리
apply_business_logic(event)
db.commit()
# ★ ① 과 ② 가 같은 트랜잭션이어야 한다
# 따로 하면 기록만 되고 처리가 안 되는 경우가 생긴다
시간과 순서 — 서버 시계를 믿지 마세요
“더 나중에 쓴 것이 이긴다”는 규칙은 시계가 정확하다는 가정 위에 있습니다. 그 가정이 깨지면 데이터가 조용히 사라집니다.
시계는 왜 못 믿나
| 문제 | 내용 |
|---|---|
| 클럭 드리프트 | 수정 없이 하루에 수십 ms~수 초 |
| NTP 조정 | 시계가 뒤로 갈 수 있다 |
| 윤초 | 과거에 대규모 장애를 일으켰다 |
| VM 정지·마이그레이션 | 시계가 튄다 |
| 가상화 오버헤드 | 측정이 부정확 |
# ✗ 경과 시간 측정에 벽시계를 쓰면 안 된다
start = time.time()
...
elapsed = time.time() - start # NTP 조정으로 음수가 될 수 있다
# ✓ 단조 시계를 쓴다 (뒤로 가지 않음)
start = time.monotonic()
elapsed = time.monotonic() - start
# Go: time.Since(start) (내부적으로 단조)
# Java: System.nanoTime() (currentTimeMillis 아님)
# JS: performance.now()
# Rust: std::time::Instant
논리 시계
램포트 시계 — 사건에 순서를 매기는 카운터
· 로컬 이벤트마다 +1
· 메시지를 보낼 때 내 시계를 함께 전송
· 받을 때 max(내 시계, 받은 시계) + 1
→ a → b 이면 L(a) < L(b) (인과 순서 보장)
→ 반대는 성립하지 않는다 (L(a) < L(b) 여도 무관할 수 있다)
벡터 시계 — 노드마다 카운터를 배열로
· 두 사건이 인과 관계인지 '동시' 인지 구분할 수 있다
· V(a) ≤ V(b) 이면 a → b
· 서로 크고 작으면 → ★ 동시 발생 = 충돌
· 비용: 노드 수만큼 공간
쓰는 곳
· Dynamo 계열의 충돌 감지
· CRDT · 협업 편집
· 분산 디버깅
이렇게도 쓸 수 있다 — 정렬 가능한 ID 🟡 중급
| 방식 | 구성 | 특징 |
|---|---|---|
| 자동 증가 | DB 시퀀스 | 단순 · 샤딩 불가 · 개수 노출 |
| UUID v4 | 랜덤 122비트 | 충돌 없음 · 정렬 안 됨 (인덱스 나쁨) |
| UUID v7 | 타임스탬프 + 랜덤 | 정렬 가능 · 표준 |
| ULID | 48비트 시각 + 80비트 랜덤 | 정렬 가능 · 26자 문자열 |
| 스노우플레이크 | 시각 + 노드ID + 시퀀스 | 정렬 가능 · 64비트 · 노드 ID 관리 필요 |
# UUID v7 (파이썬 3.14+ 또는 uuid6 패키지)
from uuid6 import uuid7
str(uuid7()) # 018f4c8e-... 앞부분이 시각이라 정렬된다
# 스노우플레이크 직접 구현
class Snowflake:
EPOCH = 1700000000000 # 기준 시각 (ms)
def __init__(self, node_id):
self.node = node_id & 0x3FF # 10비트
self.seq = 0
self.last = -1
def next_id(self):
now = int(time.time() * 1000)
if now < self.last: # ★ 시계가 뒤로 갔다
raise RuntimeError("클럭 역행 — ID 발급 중단")
if now == self.last:
self.seq = (self.seq + 1) & 0xFFF # 12비트
if self.seq == 0:
while now <= self.last: now = int(time.time()*1000)
else:
self.seq = 0
self.last = now
return ((now - self.EPOCH) << 22) | (self.node << 12) | self.seq
실무에선 이렇게 — LWW 의 위험 🔴 실무
Last-Write-Wins (마지막 쓰기가 이김)
· 타임스탬프가 큰 쪽을 채택
· 구현이 단순해서 널리 쓰인다
★ 위험
· 시계가 어긋난 노드의 쓰기가 항상 이긴다
· 시계가 앞선 노드의 옛 데이터가 최신 데이터를 덮어쓴다
· ★ 조용히 데이터가 사라진다 (에러도 안 난다)
대안
· 벡터 시계로 충돌을 감지하고 애플리케이션이 병합
· CRDT — 수학적으로 병합이 항상 수렴하도록 설계
(카운터 · 집합 · 텍스트 등 자료형별)
· 단일 리더로 충돌 자체를 없앰 ★ 가장 단순한 답
· 하이브리드 논리 시계(HLC) — 물리 + 논리 시계 결합
| 시간 관련 실무 규칙 | 이유 |
|---|---|
| 저장은 UTC 로 | 시간대·서머타임 혼란 방지 |
| 표시할 때만 지역 시간 | 사용자 시간대 적용 |
| 경과 시간은 단조 시계 | NTP 조정에 안전 |
| NTP 를 반드시 설정 | chrony · 클라우드 시간 동기화 |
| 시계 오프셋을 모니터링 | 드리프트를 조기 발견 |
| 미래·과거 타임스탬프 검증 | 클라이언트가 보낸 시각을 믿지 말 것 |
멱등성 · 재시도 · 장애 대응 — 실무 패턴 모음
분산 시스템에서 가장 자주 쓰는 다섯 가지 패턴입니다. 이 절만 제대로 적용해도 장애의 상당 부분이 사라집니다.
① 멱등성 — 모든 것의 기초
# 클라이언트가 멱등성 키를 보낸다 (Stripe 방식)
@app.post("/payments")
def create_payment(req, idempotency_key: str = Header(...)):
# 같은 키로 이미 처리했으면 그 결과를 그대로 반환
existing = db.get_by_key(idempotency_key)
if existing:
return existing.response # ★ 새로 만들지 않는다
with db.transaction():
# 유니크 제약이 동시 요청도 막아 준다
rec = db.insert_idempotency(idempotency_key, status="processing")
result = do_payment(req)
db.update_idempotency(idempotency_key, response=result, status="done")
return result
# 진행 중인 요청이 또 오면?
# · 409 Conflict 로 응답하고 클라이언트가 재시도하게 하거나
# · 완료를 기다렸다가 같은 응답을 반환
| 멱등하게 만드는 방법 | |
|---|---|
| 멱등성 키 + 유니크 제약 | 가장 일반적 |
| 자연 키 사용 | order_id 로 upsert |
| 절대값 설정 (증감 아님) | set qty=5 vs qty-=1 |
| 조건부 갱신 | 버전 · 상태 조건 |
| 이벤트 ID 중복 제거 | 소비자 측 (11절) |
② 재시도 — 지수 백오프 + 지터
import random, time
def retry(fn, attempts=5, base=0.1, cap=10.0):
for i in range(attempts):
try:
return fn()
except RetryableError as e:
if i == attempts - 1: raise
# ★ full jitter — 동시 재시도를 흩뜨린다
delay = random.uniform(0, min(cap, base * (2 ** i)))
time.sleep(delay)
# 재시도해도 되는 것과 안 되는 것을 구분해야 한다
RETRYABLE = {408, 429, 500, 502, 503, 504}
# ★ 400 · 401 · 403 · 404 · 422 는 재시도해도 똑같이 실패한다
# ★ POST 는 멱등성 키가 없으면 재시도하면 안 된다
# Retry-After 헤더를 존중할 것
if resp.status_code == 429 and "Retry-After" in resp.headers:
time.sleep(int(resp.headers["Retry-After"]))
이렇게도 쓸 수 있다 — ③ 서킷 브레이커 · ④ 타임아웃 🟡 중급
import time, threading
class CircuitBreaker:
"""closed(정상) → open(차단) → half-open(탐색) → closed"""
def __init__(self, threshold=5, timeout=30, half_open_max=3):
self.threshold, self.timeout = threshold, timeout
self.half_open_max = half_open_max
self.failures = 0
self.state = "closed"
self.opened_at = 0
self.half_open_calls = 0
self.lock = threading.Lock()
def call(self, fn, fallback=None):
with self.lock:
if self.state == "open":
if time.monotonic() - self.opened_at < self.timeout:
if fallback: return fallback()
raise CircuitOpenError()
self.state, self.half_open_calls = "half-open", 0
if self.state == "half-open" and self.half_open_calls >= self.half_open_max:
if fallback: return fallback()
raise CircuitOpenError()
if self.state == "half-open": self.half_open_calls += 1
try:
result = fn()
except Exception:
with self.lock:
self.failures += 1
if self.failures >= self.threshold:
self.state, self.opened_at = "open", time.monotonic()
raise
else:
with self.lock:
self.failures, self.state = 0, "closed"
return result
| 패턴 | 막는 것 |
|---|---|
| 서킷 브레이커 | 죽은 서비스에 계속 요청해 자원 낭비 |
| 타임아웃 | 느린 응답이 스레드·커넥션을 묶는 것 |
| 벌크헤드 | 한 의존성의 장애가 전체 풀을 먹는 것 |
| 레이트 리밋 | 과부하 · 남용 |
| 부하 차단(load shedding) | 용량 초과 시 일부를 즉시 거절 |
| 데드라인 전파 | 이미 포기한 요청을 계속 처리하는 낭비 |
실무에선 이렇게 — ⑤ 관측과 체크리스트 🔴 실무
분산 시스템 설계 체크리스트
| 항목 | |
|---|---|
| □ | 모든 외부 호출에 타임아웃이 있는가 (연결·읽기 따로) |
| □ | 재시도에 지수 백오프 + 지터가 있는가 |
| □ | 재시도 가능한 오류만 재시도하는가 |
| □ | 상태 변경 API 가 멱등한가 |
| □ | 서킷 브레이커가 있는가 |
| □ | 큐에 상한과 넘칠 때의 정책이 있는가 |
| □ | 의존성별로 풀이 분리돼 있는가 |
| □ | 부가 기능 실패 시 우아하게 저하되는가 |
| □ | 분산 추적으로 요청 경로를 볼 수 있는가 |
| □ | 상관관계 ID 가 로그에 전파되는가 |
| □ | 복제 지연 · 큐 적체 · 고루틴 수를 감시하는가 |
| □ | 시계 동기화(NTP)가 설정돼 있는가 |
| □ | 노드가 홀수인가 (합의 시스템) |
| □ | 장애 주입 테스트를 해 봤는가 |
# 상관관계 ID 전파 — 분산 추적의 기본
import contextvars, uuid
request_id = contextvars.ContextVar("request_id", default=None)
@app.middleware("http")
async def add_request_id(request, call_next):
rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request_id.set(rid)
response = await call_next(request)
response.headers["X-Request-Id"] = rid
return response
# 로그에 자동 포함
class RequestIdFilter(logging.Filter):
def filter(self, record):
record.request_id = request_id.get() or "-"
return True
# 하위 호출에도 전달
headers = {"X-Request-Id": request_id.get()}
# ★ OpenTelemetry 를 쓰면 이 전파가 자동으로 된다
Raft 를 단계별로 — 리더 선출 · 로그 복제 · 안전성
09 절에서 “합의가 왜 필요한가”를 봤다면, 여기서는 실제로 어떻게 도는지를 봅니다. etcd · Consul · TiKV · CockroachDB 가 전부 Raft 로 돌고, 쿠버네티스의 상태도 그 위에 얹혀 있습니다. Paxos 보다 이해하기 쉽게 설계된 것이 Raft 의 목표였고, 그 설계를 따라가 봅니다.
로그 복제 — 쓰기 하나가 커밋되기까지
안전성을 지키는 규칙 넷
| 규칙 | 없으면 생기는 일 |
|---|---|
| 한 임기에 한 표 | 리더가 둘이 되어 데이터가 갈라집니다 |
| 로그가 최신인 후보만 당선 | 커밋된 항목이 사라질 수 있습니다 |
| 리더는 로그를 덮어쓰지 않음 | 확정된 순서가 바뀝니다 |
| 이전 임기 항목은 간접 커밋 | 과반이 있어도 뒤집힐 수 있습니다 |
'로그가 최신인가' 를 비교하는 법 (RequestVote)
① 마지막 항목의 임기가 더 크면 더 최신
② 임기가 같으면 로그가 더 길수록 더 최신
→ 커밋된 항목은 반드시 과반에 있고,
당선되려면 과반의 표가 필요하므로,
새 리더는 커밋된 항목을 '반드시' 갖고 있습니다.
이것이 "커밋된 것은 절대 사라지지 않는다" 의 증명입니다.
운영에서 만나는 것들
| 증상 | 원인과 조치 |
|---|---|
| 리더 선거가 계속 반복 | 선거 타임아웃 < 네트워크 지연 · 디스크 fsync 가 느림 |
| 쓰기 지연이 큼 | 과반이 다른 지역 — 리전 배치 재검토 |
| 노드 추가 후 불안정 | 멤버십 변경을 한 번에 한 대씩(여러 대를 함께 바꾸려면 joint consensus) |
| 과반 상실 → 쓰기 불가 | 정상 동작입니다. 틀린 답 대신 멈추는 것이 설계 |
| 디스크가 계속 참 | 스냅샷·로그 압축 미설정 |
| 팔로워에서 읽으니 과거 값 | 정상 — 최신이 필요하면 리더 읽기 · ReadIndex |
# etcd 로 상태 확인 (쿠버네티스 클러스터의 심장입니다)
etcdctl endpoint status --write-out=table
etcdctl endpoint health
etcdctl member list
# 위험 신호
# · leader changes 가 계속 올라감 → 선거 반복
# · wal_fsync_duration p99 > 10ms → 디스크가 병목
# · backend_commit_duration 증가 → 저장소 한계
# 클러스터 크기 — 짝수는 쓰지 마세요
# 3대: 1대 장애 견딤 4대: 1대 (3대와 같은데 비용만 증가)
# 5대: 2대 장애 견딤 7대 이상은 합의 왕복이 늘어 오히려 느려집니다
Paxos · Raft · ZAB — 무엇이 다른가
| 알고리즘 | 쓰는 곳 | 특징 |
|---|---|---|
| Paxos | Chubby · Spanner | 가장 먼저 증명된 원형. 이해와 구현이 어렵습니다 |
| Multi-Paxos | 대부분의 Paxos 구현 | 리더를 두어 왕복을 줄인 실용 버전 — 사실상 Raft 와 비슷해집니다 |
| Raft | etcd · Consul · TiKV | 이해 가능성을 목표로 설계. 리더 중심 · 로그가 항상 연속 |
| ZAB | ZooKeeper | Raft 와 유사. 순서 보장에 더 강조점 |
| Viewstamped Replication | — | Raft 와 거의 같은 아이디어를 1988년에 제시 |
| 비잔틴 합의(PBFT 등) | 블록체인 | 거짓말하는 노드까지 가정 — 3f+1 대 필요, 훨씬 비쌉니다 |
합의가 왜 필요한지는 09 절, 시계와 순서는 12 절, 분산 트랜잭션과의 관계는 11 절에서 이어집니다.
좋은 설계는
취향이 아니라
변경 비용입니다
“깔끔하다”는 판단은 사람마다 다르지만 “바꿀 때 몇 군데를 건드려야 하나”는 셀 수 있습니다. 이 탭은 설계 원칙 · 디자인 패턴 · 아키텍처를 그 기준으로 정리하고, 테스트 · 배포 · 관측 · 협업까지 이어갑니다. 기법을 외우는 것이 아니라 언제 쓰고 언제 쓰지 않을지를 봅니다.
추상화 · 결합도 · 응집도 — 설계를 재는 자
좋은 설계의 기준은 “이 요구가 바뀌면 어디를 고쳐야 하나”입니다. 한 군데면 좋은 설계이고, 열 군데면 나쁜 설계입니다. 나머지는 전부 이 문장의 파생입니다.
결합도 — 낮을수록 좋다
| 종류 | 내용 | 평가 |
|---|---|---|
| 내용 결합 | 남의 내부를 직접 건드림 | 최악 |
| 공통 결합 | 전역 변수 공유 | 나쁨 |
| 제어 결합 | 플래그로 상대 동작을 조종 | 나쁨 |
| 스탬프 결합 | 필요 없는 것까지 통째로 전달 | 보통 |
| 데이터 결합 | 필요한 값만 인자로 | 좋음 |
| 메시지 결합 | 메시지·이벤트로만 | 가장 낮음 |
# 제어 결합 — 플래그가 함수 안에서 갈라진다
def save(data, is_draft): # ✗
if is_draft: ...
else: ...
# ✓ 함수를 나눈다
def save_draft(data): ...
def publish(data): ...
# 스탬프 결합 — 필요한 것만 받는다
def send_mail(user): ... # ✗ user 전체
def send_mail(email, name): ... # ✓ 필요한 것만
# → 테스트가 쉬워지고 의존성이 줄어든다
응집도 — 높을수록 좋다
한 모듈 안의 것들이 얼마나 '한 가지 일' 을 하는가
기능적 응집 (최고) — 하나의 명확한 목적
순차적 응집 — 출력이 다음의 입력
통신적 응집 — 같은 데이터를 다룸
절차적 응집 — 순서만 같음
시간적 응집 — 같은 시점에 실행 (초기화 묶음)
논리적 응집 — 비슷해 보여서 묶음 (플래그로 분기)
우연적 응집 (최악) — 이유 없이 한 파일에
★ utils.py · common.py · helpers.py 는
대개 우연적 응집의 신호다 — 갈 곳 없는 것들의 창고
이렇게도 쓸 수 있다 — 추상화가 새는 지점 🟡 중급
모든 추상화는 어느 정도 샌다 (Leaky Abstraction)
· ORM 은 SQL 을 감추지만 N+1 은 그대로 드러난다
· 네트워크 파일 시스템은 로컬처럼 보이지만 지연이 다르다
· 가비지 컬렉션은 메모리를 감추지만 정지 시간이 보인다
· 컨테이너는 격리를 주지만 커널은 공유된다
★ 그래서 '추상화 아래를 알아야' 한다 — 이 가이드 전체의 이유다
나쁜 추상화의 신호
· 쓰려면 내부를 알아야 한다
· 예외 상황에서 원래 계층이 튀어나온다
· 파라미터가 계속 늘어난다
· "이 경우에만" 이 반복된다
| 추상화 판단 | 기준 |
|---|---|
| 지금 만들 것인가 | 같은 패턴을 3번 봤을 때 (Rule of Three) |
| 얼마나 일반화할 것인가 | 실제로 있는 경우만 · 미래를 상상하지 말 것 |
| 좋은 추상화인가 | 이름을 붙일 수 있는가 · 설명이 한 문장인가 |
| 제거해야 하나 | 구현체가 하나뿐이고 앞으로도 하나면 |
실무에선 이렇게 — 변경 비용으로 판단하기 🔴 실무
| 질문 | 확인 방법 |
|---|---|
| 이 필드를 추가하면? | 고쳐야 할 파일 수를 센다 |
| 이 외부 API 를 바꾸면? | 직접 참조하는 곳을 grep |
| 이 규칙이 바뀌면? | 같은 로직이 몇 군데 있나 |
| 이 모듈을 지우면? | 얼마나 많은 것이 깨지나 |
| 새 사람이 이해하려면? | 몇 개 파일을 읽어야 하나 |
# 실제로 측정할 수 있다
# 함께 바뀌는 파일 쌍 (변경 결합도) — 커밋마다 같이 들어간 파일 쌍을 센다
git log --format="@%H" --name-only --since="6 months ago" \
| awk '/^@/ {n=0; next} NF {for (i=0; i<n; i++) print f[i] " + " $0; f[n++]=$0}' \
| sort | uniq -c | sort -rn | head -20
# 두 파일이 항상 함께 바뀐다면
# → 같은 모듈에 있어야 하거나, 결합이 과한 것
# 자주 바뀌는 파일 = 리팩터링 우선순위
git log --format="" --name-only --since="1 year ago" \
| sort | uniq -c | sort -rn | head -20
# 복잡도 + 변경 빈도 = 핫스팟 (Code Maat · CodeScene 의 아이디어)
등급으로 보기 — 어디까지가 위험한가
결합도 — 나쁜 것부터
| 종류 | 무엇을 공유하나 |
|---|---|
| 내용 결합 | 남의 내부를 직접 건드림 — private 필드·리플렉션 |
| 공통 결합 | 전역 변수를 함께 씀 — 누가 언제 바꿨는지 추적 불가 |
| 외부 결합 | 외부 포맷·프로토콜에 함께 묶임 |
| 제어 결합 | 플래그를 넘겨 상대의 흐름을 조종 — save(data, true) |
| 스탬프 결합 | 큰 객체를 통째로 넘기고 일부만 씀 |
| 데이터 결합 | 필요한 값만 인자로 — 목표 지점 |
# ✗ 제어 결합 — 불리언 인자가 상대의 흐름을 나눕니다
def save(order, send_mail: bool, is_draft: bool): ...
save(o, True, False) # 호출부만 봐서는 무슨 뜻인지 모릅니다
# ✓ 흐름을 나누지 말고 함수를 나눕니다
def save_draft(order): ...
def publish(order): ... # 메일 발송은 이벤트로 분리
# ✗ 스탬프 결합 — User 전체를 넘기지만 실제로는 email 만 씀
def notify(user: User): send(user.email)
# ✓ 필요한 것만 (테스트도 쉬워집니다)
def notify(email: str): send(email)
f(x, True) 를 읽고 무슨 뜻인지 바로 모르겠다면
그 함수는 두 가지 일을 하고 있습니다.
나누면 이름이 설명을 대신하고, 조건 분기가 사라집니다.
응집도 — 좋은 것부터
| 종류 | 무엇으로 묶였나 |
|---|---|
| 기능적 응집 | 하나의 일만 함 — 목표 지점 |
| 순차적 응집 | 앞의 출력이 뒤의 입력 |
| 통신적 응집 | 같은 데이터를 다룸 |
| 절차적 응집 | 실행 순서만 같음 |
| 시간적 응집 | "시작할 때 하는 일들" — init() 에 잡다한 것 |
| 우연적 응집 | utils.py — 아무 관계 없는 것들 |
실측하는 법 — git 이 데이터를 갖고 있습니다
# 같이 자주 바뀌는 파일 쌍 = 숨은 결합
git log --format=@%h --name-only --since=1.year |
awk '/^@/ {n=0; next} NF {for (i=0; i<n; i++) print f[i] " + " $0; f[n++]=$0}' |
sort | uniq -c | sort -rn | head -20
# 한 커밋에 들어간 파일 수의 분포
# 평균 10개 이상이면 결합도가 높다는 신호
# 자주 바뀌는 파일 — 여기에 크기·복잡도를 곱하면 리팩터링 1순위
git log --format= --name-only --since=6.months |
sort | uniq -c | sort -rn | head
utils · common · helper 는 응집도가 0 인 이름입니다.
"여기에 뭘 넣어야 하지?"의 답이 "딱히 갈 곳 없는 것 전부" 가 되기 때문입니다.
결국 모든 모듈이 그 파일에 의존하게 되어 결합도까지 함께 나빠집니다.
무슨 일을 하는지로 이름을 짓고(money_format·retry_policy),
이름이 안 지어지면 아직 그 추상화가 없다는 뜻입니다.
SOLID — 어길 때 뭐가 아픈가로 이해하기
다섯 원칙을 외우는 것보다 어겼을 때 생기는 증상을 아는 것이 유용합니다. 그리고 과하게 적용하면 오히려 나빠진다는 것도 함께 봅니다.
| 원칙 | 어기면 | |
|---|---|---|
| S | 단일 책임 — 변경 이유가 하나 | 한 클래스를 여러 팀이 동시에 수정 · 충돌 |
| O | 개방-폐쇄 — 확장에 열리고 수정에 닫힘 | 기능 추가마다 기존 코드 수정 · 회귀 |
| L | 리스코프 치환 — 자식이 부모를 대체 가능 | isinstance 분기가 늘어남 |
| I | 인터페이스 분리 — 안 쓰는 메서드 강요 금지 | NotImplementedError 가 늘어남 |
| D | 의존 역전 — 구현이 아니라 추상에 의존 | 테스트에 실제 DB · 외부 API 가 필요 |
D — 가장 실용적인 원칙
✗ 구현에 직접 의존
class OrderService:
def __init__(self):
self.db = PostgresClient(...) # 고정
self.mailer = SmtpMailer(...) # 고정
def create(self, req):
self.db.insert(...)
self.mailer.send(...)
# 테스트하려면 실제 DB 와 SMTP 가 필요하다
✓ 추상에 의존 + 주입
class OrderService:
def __init__(self, repo: OrderRepo, mailer: Mailer):
self.repo, self.mailer = repo, mailer
def create(self, req):
self.repo.save(...)
self.mailer.send(...)
# 테스트
svc = OrderService(InMemoryRepo(), FakeMailer())
# 빠르고 · 격리되고 · 검증하기 쉽다
Protocol(파이썬) · 암묵적 인터페이스(Go)로
선언 없이 같은 효과를 얻습니다.
중요한 것은 주입이지 인터페이스 선언이 아닙니다.
과하게 적용하면
| 증상 | 원인 |
|---|---|
| 구현이 하나뿐인 인터페이스가 가득 | D 를 기계적으로 적용 |
| 클래스가 수십 개인데 각 3줄 | S 를 과하게 |
| 추적하려면 5단계를 타고 가야 | 추상화 계층 과다 |
| 팩토리의 팩토리 | 패턴 남용 |
| 설정으로 모든 것을 조절 | O 를 오해 |
이렇게도 쓸 수 있다 — 다른 유용한 원칙들 🟡 중급
| 원칙 | 내용 |
|---|---|
| YAGNI | 필요해질 때까지 만들지 마라 — 가장 자주 어겨진다 |
| KISS | 단순하게 — 영리한 코드보다 지루한 코드 |
| DRY | 지식의 중복을 피하라 — 코드 모양의 중복이 아니다 |
| 최소 놀람 | 이름과 동작이 예상과 일치할 것 |
| 데메테르 법칙 | a.b().c().d() 를 피하라 (친구의 친구) |
| 합성 > 상속 | 상속은 가장 강한 결합 |
| 실패는 빨리 | 잘못된 상태로 진행하지 말 것 |
| 포스텔 원칙 (견고성 원칙) | 입력에는 관대하게, 출력에는 엄격하게 (지금은 논쟁적) |
# DRY 의 오해 — 우연히 같아 보이는 코드를 합치면 나중에 아프다
# ✗ 지금 모양이 같다고 합침
def validate(data, kind):
if kind == "user": ...
elif kind == "order": ...
# 둘의 규칙이 나중에 갈라지면 이 함수가 지옥이 된다
# ✓ 지식이 같은 것만 합친다
# "이메일 형식 검증" 은 하나 — 규칙이 진짜 같다
# "사용자 검증" 과 "주문 검증" 은 다른 지식 — 따로 둔다
# 판단 기준: "이 규칙이 바뀔 때 둘 다 바뀌어야 하는가?"
# 예 → 합친다 (같은 지식)
# 아니오 → 따로 둔다 (우연한 유사성)
실무에선 이렇게 — 상속을 피하는 이유 🔴 실무
✗ 상속으로 재사용
class Bird:
def fly(self): ...
class Penguin(Bird): # 리스코프 위반
def fly(self):
raise NotImplementedError
# 부모의 계약을 자식이 못 지킨다
# → 쓰는 쪽이 isinstance 로 분기하게 된다
class Stack(list): # 더 나쁜 예
... # list 의 모든 메서드가 노출된다
# stack.insert(0, x) 가 가능해진다
✓ 합성으로
class Stack:
def __init__(self):
self._items = [] # 감춘다
def push(self, x): self._items.append(x)
def pop(self): return self._items.pop()
# 능력을 인터페이스로 분리
class Flyable(Protocol):
def fly(self) -> None: ...
class Eagle:
def fly(self): ...
class Penguin:
def swim(self): ...
# 펭귄은 Flyable 이 아니다 — 타입으로 표현된다
| 상속이 적절한 경우 | 이유 |
|---|---|
| 진짜 is-a 관계이고 계약이 지켜짐 | 치환 가능 |
| 프레임워크가 요구할 때 | 선택지가 없음 |
| 템플릿 메서드 (제한적) | 알고리즘 골격 공유 |
| 봉인된 계층 (sealed) | 모든 하위 타입을 아는 경우 |
생성 패턴 — 객체를 어떻게 만들까
객체 생성 책임을 분리하면 테스트와 교체가 쉬워집니다. 다만 현대 언어와 DI 컨테이너가 많은 부분을 대신하므로 여전히 쓸모 있는 것만 봅니다.
여전히 유용한 것
| 패턴 | 쓰는 자리 |
|---|---|
| 팩토리 메서드 | 타입에 따라 다른 구현 생성 (파서 · 핸들러) |
| 빌더 | 선택 인자가 많은 객체 · 불변 객체 조립 |
| 의존성 주입 | 가장 널리 쓰임 — 테스트 가능성 |
| 프로토타입 | 복제가 생성보다 쌀 때 (게임 오브젝트) |
| 오브젝트 풀 | 생성 비용이 큰 자원 (커넥션 · 버퍼) |
| 싱글턴 | 주의 필요 — 아래 참고 |
# 팩토리 — 등록 기반이 확장에 좋다
HANDLERS = {}
def register(kind):
def deco(cls):
HANDLERS[kind] = cls
return cls
return deco
@register("csv")
class CsvParser: ...
@register("json")
class JsonParser: ...
def create_parser(kind):
cls = HANDLERS.get(kind)
if not cls: raise ValueError(f"지원하지 않는 형식: {kind}")
return cls()
# ★ 새 형식을 추가할 때 기존 코드를 안 고친다 (개방-폐쇄)
싱글턴이 문제인 이유
| 문제 | 내용 |
|---|---|
| 전역 상태 | 테스트 간 상태가 새어 나감 |
| 숨은 의존성 | 시그니처에 안 보임 |
| 교체 불가 | 테스트용 대체가 어려움 |
| 동시성 | 초기화 경쟁 (🕸️ 분산 03) |
| 생명주기 | 언제 정리하나 |
# ✓ 싱글턴 대신 — 하나만 만들고 주입한다
# main.py
db = Database(config.url)
cache = Cache(config.redis)
app = create_app(db=db, cache=cache)
# ★ "인스턴스가 하나" 와 "전역에서 접근 가능" 은 다른 문제다
# 전자는 조립 시점에 보장하고
# 후자는 하지 않는 것이 좋다
# 정말 전역이 필요하면 최소한 교체 가능하게
_client = None
def set_client(c): global _client; _client = c # 테스트에서 교체
def get_client(): return _client
이렇게도 쓸 수 있다 — 빌더와 불변 객체 🟡 중급
# 파이썬 — dataclass 로 충분한 경우가 많다
from dataclasses import dataclass, replace
@dataclass(frozen=True, slots=True) # 불변 + 메모리 절약
class HttpConfig:
url: str
timeout: float = 5.0
retries: int = 3
headers: tuple[tuple[str, str], ...] = () # ★ dict 를 두면 frozen 이어도 속이 바뀌고 hash() 가 실패한다
cfg = HttpConfig(url="https://api.example.com")
cfg2 = replace(cfg, timeout=10.0) # 일부만 바꾼 새 객체
# cfg.timeout = 1 # ✗ 불변이라 에러
# ★ 불변 객체의 이점
# · 스레드 안전 (🕸️ 분산 02)
# · 해시 가능 → 캐시 키로 사용
# · 방어적 복사가 필요 없음
# · 언제 어디서 바뀌었는지 추적할 필요가 없음
// TS — 빌더 대신 객체 리터럴 + 기본값
interface Options { url: string; timeout?: number; retries?: number }
const DEFAULTS = { timeout: 5000, retries: 3 };
function createClient(opts: Options) {
const o = { ...DEFAULTS, ...opts };
...
}
// 단계가 강제돼야 한다면 타입 상태 빌더
class QueryBuilder<S extends "init" | "from" | "where"> {
from(t: string): QueryBuilder<"from"> { ... }
where(this: QueryBuilder<"from">, c: string): QueryBuilder<"where"> { ... }
build(this: QueryBuilder<"where">): string { ... }
}
// ★ from 없이 where 를 부르면 컴파일 에러
dataclass 가
대부분을 해결합니다. 단계 강제나 복잡한 검증이 필요할 때만
빌더를 고려하세요.
실무에선 이렇게 — 의존성 주입 실전 🔴 실무
| 방식 | 내용 | 평가 |
|---|---|---|
| 생성자 주입 | 필수 의존성을 생성자로 | 기본 선택 |
| 세터 주입 | 나중에 설정 | 불완전 상태가 생김 |
| 인자 주입 | 메서드마다 전달 | 일회성 의존에 적합 |
| 컨테이너 | 프레임워크가 조립 | 큰 프로젝트에 유용 |
| 수동 조립 | main 에서 직접 | 작은 프로젝트엔 이게 낫다 |
# 수동 조립 (composition root) — 명시적이고 추적 가능하다
def build_app(config):
# 인프라 계층
db = Database(config.db_url)
cache = RedisCache(config.redis_url)
mailer = SmtpMailer(config.smtp)
# 리포지토리
order_repo = SqlOrderRepo(db)
user_repo = SqlUserRepo(db)
# 도메인 서비스
order_service = OrderService(order_repo, user_repo, mailer)
# 프레젠테이션
return create_api(order_service)
# ★ 의존성 그래프가 한 파일에 다 보인다
# 순환 의존이 있으면 여기서 바로 드러난다
# 테스트에서는 원하는 것만 가짜로
svc = OrderService(InMemoryOrderRepo(), InMemoryUserRepo(), FakeMailer())
구조 패턴 — 기존 것을 건드리지 않고 덧붙이기
객체들을 어떻게 조립할 것인가에 대한 패턴들입니다. 외부 라이브러리를 감싸거나, 기능을 층층이 얹거나, 복잡한 시스템에 단순한 창구를 만들 때 씁니다.
자주 쓰는 것
| 패턴 | 목적 | 실무 예 |
|---|---|---|
| 어댑터 | 인터페이스 변환 | 외부 SDK 를 우리 인터페이스로 |
| 퍼사드 | 복잡한 것에 단순한 창구 | 서비스 계층 |
| 데코레이터 | 기능을 겹겹이 | 로깅 · 캐시 · 재시도 |
| 프록시 | 접근 제어 · 지연 로딩 | ORM 지연 로딩 · 권한 확인 |
| 컴포지트 | 부분-전체를 같게 | 트리 구조 · 파일 시스템 |
| 플라이웨이트 | 공유로 메모리 절약 | 문자열 인터닝 · 타일 |
| 브리지 | 추상과 구현을 분리 | 드라이버 계층 |
데코레이터 — 겹겹이 쌓기
from functools import wraps
def with_retry(attempts=3):
def deco(fn):
@wraps(fn)
def wrapper(*a, **kw):
for i in range(attempts):
try: return fn(*a, **kw)
except RetryableError:
if i == attempts - 1: raise
time.sleep(0.1 * 2 ** i)
return wrapper
return deco
def with_cache(ttl=60):
def deco(fn):
store = {}
@wraps(fn)
def wrapper(*a):
now = time.monotonic()
if a in store and now < store[a][1]: return store[a][0]
v = fn(*a); store[a] = (v, now + ttl)
return v
return wrapper
return deco
@with_cache(ttl=300)
@with_retry(attempts=3)
def fetch_user(uid):
return api.get(f"/users/{uid}")
# ★ 순서가 중요하다 — 캐시가 바깥이면 재시도 결과를 캐시한다
# 반대로 하면 캐시 조회까지 재시도하게 된다
이렇게도 쓸 수 있다 — 어댑터로 외부 의존성 격리 🟡 중급
# ✗ 외부 SDK 를 도메인 코드에서 직접 사용
from stripe import Charge
def checkout(order):
Charge.create(amount=order.total, currency="krw", source=order.token)
# → Stripe 를 바꾸려면 모든 호출부를 수정
# → 테스트마다 Stripe 를 모킹해야 한다
# ✓ 우리 인터페이스를 정의하고 어댑터로 연결
class PaymentGateway(Protocol):
def charge(self, amount: int, currency: str, token: str) -> PaymentResult: ...
class StripeGateway:
def charge(self, amount, currency, token):
try:
c = stripe.Charge.create(amount=amount, currency=currency, source=token)
return PaymentResult(ok=True, id=c.id)
except stripe.error.CardError as e:
return PaymentResult(ok=False, reason=map_reason(e)) # ★ 우리 언어로 번역
class FakeGateway: # 테스트용
def __init__(self): self.charges = []
def charge(self, amount, currency, token):
self.charges.append((amount, currency))
return PaymentResult(ok=True, id="fake_1")
# 도메인은 PaymentGateway 만 안다
def checkout(order, gateway: PaymentGateway):
return gateway.charge(order.total, "krw", order.token)
| 어댑터가 해 주는 일 | |
|---|---|
| 외부 예외를 우리 예외로 번역 | 도메인이 외부를 몰라도 됨 |
| 외부 모델을 우리 모델로 변환 | 스키마 변경에 격리 |
| 재시도 · 타임아웃 · 서킷을 한곳에 | 중복 제거 |
| 테스트용 가짜 구현 제공 | 빠른 테스트 |
| 여러 공급자를 같은 인터페이스로 | 교체·이중화 가능 |
json 같은 표준은 감싸지 않습니다.
실무에선 이렇게 — 퍼사드와 계층 🔴 실무
퍼사드 = "이 서브시스템을 쓰려면 이것만 알면 된다"
✗ 컨트롤러가 리포지토리 · 캐시 · 메일러 · 결제를 전부 직접 호출
→ 컨트롤러가 비대해지고 트랜잭션 경계가 흐려진다
✓ 서비스 계층(퍼사드)이 조율
컨트롤러 → OrderService.checkout(req)
├ 재고 확인
├ 결제
├ 주문 저장 (트랜잭션 경계)
└ 이벤트 발행
★ 서비스 계층이 하는 일
· 유스케이스 하나 = 메서드 하나
· 트랜잭션 경계를 정한다
· 도메인 객체들을 조율한다
· ★ 비즈니스 규칙은 도메인 안에 두고, 여기서는 조율만
| 안티패턴 | 증상 |
|---|---|
| 빈약한 도메인 모델 | 엔티티는 getter/setter 뿐, 모든 로직이 서비스에 |
| 비대한 서비스 | 서비스 클래스가 수천 줄 |
| 계층 누수 | 컨트롤러가 SQL 을 알고 있음 |
| 순환 참조 | 서비스끼리 서로 호출 (🔀 알고리즘 06) |
| 트랜잭션 경계 불명확 | 어디서 커밋되는지 모름 |
행위 패턴 — 분기문을 구조로 바꾸기
길어지는 if/switch 를 다형성이나 데이터로 대체하는 패턴들입니다.
실무에서 압도적으로 많이 쓰이는 것은
전략 · 옵서버 · 상태 셋입니다.
전략 — 알고리즘을 갈아 끼운다
✗ 분기가 계속 늘어남
def calc_fee(order, kind):
if kind == "basic": return order.total * 0.05
elif kind == "prime": return order.total * 0.02
elif kind == "vip": return 0
# 등급이 늘 때마다 이 함수를 수정
✓ 전략을 데이터로
FEE_RULES = {
"basic": lambda t: t * 0.05,
"prime": lambda t: t * 0.02,
"vip": lambda t: 0,
}
def calc_fee(order, kind):
rule = FEE_RULES.get(kind)
if not rule: raise ValueError(kind)
return rule(order.total)
# 추가는 표에 한 줄
옵서버 — 느슨한 알림
from collections import defaultdict
class EventBus:
def __init__(self): self._subs = defaultdict(list)
def on(self, event, handler): self._subs[event].append(handler)
def emit(self, event, payload):
for h in self._subs[event]:
try:
h(payload)
except Exception:
log.exception("핸들러 실패 %s", h) # ★ 하나가 죽어도 계속
bus = EventBus()
bus.on("order.created", send_confirmation_mail)
bus.on("order.created", update_analytics)
bus.on("order.created", reserve_inventory)
# 주문 서비스는 누가 듣는지 모른다
bus.emit("order.created", order)
# ★ 주의
# · 실행 순서를 보장하지 않는다 (필요하면 명시적 호출)
# · 예외를 삼키면 조용한 실패가 된다 → 로그 · 메트릭 필수
# · 흐름 추적이 어렵다 → 이벤트 이름을 명확하게
이렇게도 쓸 수 있다 — 상태 패턴과 FSM 🟡 중급
# 🧮 계산이론 02 의 상태 기계를 코드로
# 상태별 허용 동작과 전이를 한곳에 모은다
from dataclasses import dataclass
TRANSITIONS = {
("draft", "submit"): "review",
("review", "approve"): "published",
("review", "reject"): "draft",
("published", "archive"): "archived",
}
ACTIONS = { # 전이 시 부수효과
("review", "approve"): lambda doc: notify_subscribers(doc),
("review", "reject"): lambda doc: notify_author(doc),
}
def transition(doc, action):
key = (doc.status, action)
nxt = TRANSITIONS.get(key)
if nxt is None:
raise InvalidTransition(f"{doc.status} 에서 {action} 불가")
doc.status = nxt
if key in ACTIONS: ACTIONS[key](doc)
return doc
# 얻는 것
# · 허용되지 않은 전이가 기본으로 차단
# · 상태 다이어그램을 표에서 자동 생성 가능
# · 모든 전이를 테스트로 순회 가능
# · 새 상태를 넣을 때 한곳만 수정
| 그 밖의 행위 패턴 | 실무 예 |
|---|---|
| 커맨드 | 실행 취소 · 작업 큐 · 트랜잭션 로그 |
| 템플릿 메서드 | 테스트 픽스처 · 처리 파이프라인 골격 |
| 책임 연쇄 | 미들웨어 (가장 흔한 형태) |
| 반복자 | 언어 내장 (제너레이터) |
| 중재자 | 복잡한 UI 컴포넌트 조율 |
| 방문자 | AST 순회 (🔧 컴파일러 03) |
| 메멘토 | 스냅숏 · 되돌리기 |
실무에선 이렇게 — 패턴을 언제 쓰지 않을까 🔴 실무
| 상황 | 판단 |
|---|---|
| 분기가 2~3개이고 안 늘 것 같다 | 그냥 if 를 쓴다 |
| 언어 기능으로 되는가 | 함수 값 · 패턴 매칭 · 데코레이터 |
| 구현체가 하나뿐이다 | 인터페이스를 만들지 않는다 |
| 패턴 이름이 클래스명에 들어간다 | OrderFactoryStrategyImpl — 신호 |
| 읽는 사람이 더 헷갈려한다 | 순이득이 마이너스 |
# 현대 언어에서는 패턴 매칭이 여러 패턴을 대체한다 (파이썬 3.10+)
match event:
case {"type": "order.created", "id": oid}:
handle_created(oid)
case {"type": "order.paid", "id": oid, "amount": amt} if amt > 0:
handle_paid(oid, amt)
case {"type": t}:
log.warning("알 수 없는 이벤트: %s", t)
case _:
raise ValueError("형식 오류")
# Rust · Scala · Kotlin 의 sealed + match 는 ★ 망라성까지 검사한다
# → 새 케이스를 추가하면 처리를 빠뜨린 곳을 컴파일러가 알려 준다
아키텍처 — 의존성이 어느 방향으로 흐르나
아키텍처 패턴의 이름은 여러 가지지만 핵심은 하나입니다 — 비즈니스 로직이 프레임워크·DB·외부 서비스에 의존하지 않게 하는 것.
이름은 달라도 같은 아이디어
| 이름 | 핵심 용어 |
|---|---|
| 레이어드 | 표현 · 응용 · 도메인 · 인프라 |
| 헥사고날 | 포트 & 어댑터 |
| 어니언 | 동심원 · 안쪽이 핵심 |
| 클린 아키텍처 | 의존성 규칙 |
| 수직 슬라이스 | 기능 단위로 세로 분할 |
디렉터리 구조 예 (수직 슬라이스 + 계층)
src/
orders/
domain/ Order · OrderLine · 규칙 ← 순수
application/ CreateOrder · CancelOrder · 포트
infrastructure/ SqlOrderRepo · StripeGateway
api/ 라우터 · DTO
users/
...
shared/ 공용 값 객체 · 에러
★ 기능(orders)이 1차 분할, 계층이 2차
"주문 기능을 고친다" 가 한 폴더 안에서 끝난다
의존성 규칙을 코드로 강제
# import-linter (파이썬) — 계층 위반을 CI 에서 잡는다
# setup.cfg
[importlinter]
root_package = myapp
[importlinter:contract:layers]
name = 계층 규칙
type = layers
layers =
myapp.api
myapp.application
myapp.domain
# ★ 아래 계층이 위 계층을 import 하면 실패
[importlinter:contract:independence]
name = 기능 독립
type = independence
modules =
myapp.orders
myapp.users
# 기능끼리 직접 참조 금지 (이벤트나 공용 계층을 통해서만)
이렇게도 쓸 수 있다 — 모놀리스 vs 마이크로서비스 🟡 중급
| 모놀리스 | 마이크로서비스 | |
|---|---|---|
| 배포 | 한 번에 | 독립 배포 |
| 트랜잭션 | DB 트랜잭션으로 간단 | 사가 · 최종 일관성 |
| 디버깅 | 스택 트레이스 하나 | 분산 추적 필요 |
| 확장 | 전체를 함께 | 부분만 |
| 기술 다양성 | 제한 | 자유 |
| 팀 독립성 | 낮음 | 높음 |
| 운영 복잡도 | 낮음 | 매우 높음 |
| 지연 | 함수 호출 | 네트워크 (🌐 12) |
★ 현실적인 조언
① 모듈러 모놀리스로 시작한다
· 하나의 배포 단위 · 내부는 명확한 모듈 경계
· 모듈 간에는 공개 인터페이스로만 통신
· DB 스키마도 모듈별로 분리 (다른 모듈 테이블 직접 접근 금지)
② 경계가 검증되면 필요한 것만 떼어 낸다
· 확장 요구가 다른 부분
· 배포 주기가 다른 부분
· 팀이 분리된 부분
③ 마이크로서비스는 조직 구조의 문제이기도 하다
(콘웨이의 법칙 — 시스템 구조는 조직 구조를 닮는다)
★ "분산 모놀리스" 가 최악이다
서비스는 나눴는데 서로 강하게 결합돼
같이 배포해야 하고, 하나가 죽으면 전부 죽는다
실무에선 이렇게 — 아키텍처 결정 기록(ADR) 🔴 실무
# ADR-014: 주문 이벤트 발행에 아웃박스 패턴 사용
## 상태
채택됨 (2026-03-12)
## 맥락
주문 생성 시 DB 저장과 Kafka 발행을 함께 해야 한다.
현재는 저장 후 발행하는데, 발행 실패 시 이벤트가 유실되고 있다.
지난 분기에 3건의 정합성 불일치가 발생했다.
## 결정
아웃박스 테이블을 도입하고 별도 릴레이가 발행한다.
## 근거
- 원자성을 DB 트랜잭션 하나로 확보할 수 있다
- 2PC 는 가용성 저하와 운영 복잡도가 크다
- Debezium CDC 는 인프라 추가 부담이 있어 다음 단계로 미룬다
## 결과
- 최소 한 번 전달 → 소비자를 멱등하게 만들어야 한다
- 릴레이 지연을 모니터링해야 한다 (목표 p99 < 2초)
- 아웃박스 테이블 정리 배치가 필요하다
## 대안
- 2PC: 가용성 저하로 기각
- 발행 후 저장: 유실 방향만 바뀔 뿐
- CDC: 인프라 준비 후 재검토 (ADR-020 예정)
도메인 주도 설계 — 코드의 이름을 업무의 말과 같게
DDD 의 핵심은 전술 패턴이 아니라 “기획자와 개발자가 같은 단어를 쓰는 것”입니다. 번역 비용이 사라지면 오해로 인한 버그가 줄어듭니다.
전략적 설계 (더 중요)
| 개념 | 내용 |
|---|---|
| 유비쿼터스 언어 | 업무 용어 = 코드 용어 = 문서 용어 |
| 바운디드 컨텍스트 | 그 언어가 통하는 경계 |
| 컨텍스트 맵 | 경계 간의 관계 |
| 안티커럽션 계층 | 다른 컨텍스트의 모델이 새어 들어오지 않게 |
| 핵심 도메인 | 경쟁력이 있는 부분 — 여기에 투자 |
같은 단어가 컨텍스트마다 다른 것
"상품"
· 카탈로그 컨텍스트 — 이름 · 설명 · 이미지 · 카테고리
· 재고 컨텍스트 — SKU · 수량 · 위치
· 주문 컨텍스트 — 가격 · 수량 · 옵션 스냅숏
· 배송 컨텍스트 — 무게 · 부피 · 포장 유형
★ 하나의 거대한 Product 클래스로 합치면
모든 컨텍스트가 서로를 끌어당겨 결합이 폭발한다
→ 컨텍스트마다 자기 모델을 갖는 것이 맞다
전술적 설계
| 요소 | 특징 |
|---|---|
| 엔티티 | ID 로 식별 · 상태가 변함 (Order) |
| 값 객체 | 값으로 식별 · 불변 (Money · Address) |
| 애그리거트 | 일관성 경계 · 트랜잭션 단위 |
| 리포지토리 | 애그리거트 단위로 저장·조회 |
| 도메인 서비스 | 여러 애그리거트에 걸친 규칙 |
| 도메인 이벤트 | 일어난 사실 (과거형 이름) |
@dataclass(frozen=True)
class Money: # 값 객체 — 불변
amount: int # 최소 단위 정수 (➗ 12)
currency: str = "KRW"
def __add__(self, o):
if self.currency != o.currency: raise ValueError("통화 불일치")
return Money(self.amount + o.amount, self.currency)
class Order: # 애그리거트 루트
def __init__(self, id, lines):
self.id, self._lines = id, lines
self.status = "draft"
def add_line(self, sku, qty, price):
if self.status != "draft": raise InvalidState("확정된 주문")
if qty <= 0: raise ValueError("수량은 1 이상")
self._lines.append(OrderLine(sku, qty, price))
self._check_invariants() # ★ 항상 유효한 상태를 유지
def _check_invariants(self):
if len(self._lines) > 100: raise ValueError("품목이 너무 많음")
이렇게도 쓸 수 있다 — 애그리거트 경계 정하기 🟡 중급
애그리거트 = "한 트랜잭션 안에서 함께 일관성을 지켜야 하는 것들"
규칙
① 애그리거트 하나 = 트랜잭션 하나
② 다른 애그리거트는 ★ ID 로만 참조 (객체 참조 금지)
③ 애그리거트 간 일관성은 최종 일관성으로 (이벤트)
④ ★ 작게 유지한다 — 크면 락 경합과 로딩 비용이 커진다
예: 주문
Order (루트)
└ OrderLine[] ← 함께 저장·검증 (같은 애그리거트)
· customer_id ← ID 로만 (Customer 는 다른 애그리거트)
· payment_id ← ID 로만
✗ 나쁜 예: Customer 안에 모든 Order 를 담기
· 주문이 1만 개면 고객 하나 로딩에 1만 행
· 주문 하나 추가에 고객 전체를 잠근다
| 경계를 정하는 질문 | |
|---|---|
| 이 규칙이 즉시 지켜져야 하는가 | 예 → 같은 애그리거트 |
| 잠깐 어긋나도 되는가 | 예 → 다른 애그리거트 + 이벤트 |
| 함께 수정되는 일이 잦은가 | 예 → 같은 애그리거트 후보 |
| 크기가 무한히 커질 수 있는가 | 예 → 분리해야 함 |
실무에선 이렇게 — DDD 를 도입할 때와 안 할 때 🔴 실무
| 적합 | 부적합 |
|---|---|
| 업무 규칙이 복잡하다 | CRUD 가 대부분이다 |
| 도메인 전문가와 협업한다 | 요구가 단순·명확하다 |
| 오래 유지될 시스템 | 프로토타입 · 단기 프로젝트 |
| 여러 팀이 나눠 작업 | 1~2명이 전부 |
| 용어 혼란이 실제 문제다 | — |
부분 도입이 현실적이다
★ 거의 항상 가치 있는 것
· 유비쿼터스 언어 — 이름을 업무 용어로 (무료다)
· 값 객체 — Money · Email · PhoneNumber (버그가 크게 준다)
· 명시적 불변식 — 항상 유효한 상태 유지
★ 복잡도가 높을 때만
· 애그리거트 · 리포지토리 추상화
· 도메인 이벤트
· CQRS · 이벤트 소싱 (특히 신중히)
★ CQRS · 이벤트 소싱은 도입 비용이 매우 크다
· 읽기 모델 동기화 · 이벤트 스키마 진화 · 리플레이
· "감사 이력이 핵심 요구" 같은 명확한 이유가 있을 때만
테스트 — 무엇을 얼마나 어디에
테스트의 목적은 커버리지 숫자가 아니라 “고쳐도 안전하다는 확신”입니다. 느리고 깨지기 쉬운 테스트는 오히려 개발을 막습니다.
테스트 피라미드
/\ E2E — 적게 · 느림 · 진짜 신뢰
/ \ (핵심 사용자 여정 몇 개)
/----\
/ \ 통합 — 중간
/ \ (DB · 외부 연동 · API 계약)
/----------\
/ \ 단위 — 많이 · 빠름 · 정밀
/______________\ (도메인 로직 · 순수 함수)
★ 역피라미드(E2E 만 잔뜩)가 되면
· 느려서 아무도 안 돌린다
· 자주 깨져서 신뢰를 잃는다 (플레이키)
· 실패해도 원인을 모른다
좋은 테스트의 조건
| 조건 | 내용 |
|---|---|
| 빠르다 | 단위는 ms · 전체가 몇 분 이내 |
| 결정적 | 같은 코드면 항상 같은 결과 |
| 독립적 | 순서 · 다른 테스트에 무관 |
| 의도가 보인다 | 실패 메시지로 원인을 안다 |
| 구현이 아니라 행동을 | 리팩터링에 안 깨진다 |
✗ 구현을 검증
def test_create_order():
svc.create(req)
repo.save.assert_called_once() # 내부 호출을 검사
mailer.send.assert_called_with(...)
# 리팩터링하면 깨진다
✓ 행동을 검증
def test_create_order():
order = svc.create(req)
assert order.status == "confirmed"
assert repo.get(order.id) is not None
assert len(mailer.sent) == 1
# 내부 구조가 바뀌어도 통과
이렇게도 쓸 수 있다 — 테스트 대역 고르기 🟡 중급
| 종류 | 내용 | 쓸 때 |
|---|---|---|
| 더미 | 자리만 채움 | 필수 인자 |
| 스텁 | 정해진 값 반환 | 입력 조건 만들기 |
| 페이크 | 동작하는 간단 구현 | 인메모리 저장소 ★ 권장 |
| 스파이 | 호출을 기록 | 부수효과 확인 |
| 목 | 기대 호출을 검증 | 남용 주의 |
# 페이크가 목보다 나은 이유 — 실제로 동작한다
class InMemoryOrderRepo:
def __init__(self): self._data = {}
def save(self, o): self._data[o.id] = o
def get(self, oid): return self._data.get(oid)
def find_by_user(self, uid):
return [o for o in self._data.values() if o.user_id == uid]
# 테스트가 실제 시나리오처럼 읽힌다
def test_cancel_restores_stock():
repo, stock = InMemoryOrderRepo(), InMemoryStock({"A": 10})
svc = OrderService(repo, stock)
order = svc.create(user_id=1, items=[("A", 3)])
assert stock.get("A") == 7
svc.cancel(order.id)
assert stock.get("A") == 10 # ★ 진짜 상태를 확인
assert repo.get(order.id).status == "canceled"
| 외부 의존성 테스트 | 방법 |
|---|---|
| DB | Testcontainers — 진짜 DB 를 띄운다 |
| HTTP API | WireMock · MSW · responses |
| 메시지 브로커 | Testcontainers · 인메모리 구현 |
| 시간 | 시계를 주입 (freezegun · Clock 인터페이스) |
| 랜덤 | 시드 고정 · 생성기 주입 |
| 파일 시스템 | tmpdir · 인메모리 FS |
datetime.now() 를 직접 부르지 말고
Clock 을 인자로 받으세요.
실무에선 이렇게 — 플레이키 테스트와 커버리지 🔴 실무
| 플레이키 원인 | 대응 |
|---|---|
| 테스트 간 상태 공유 | 격리 · 매번 초기화 |
| 시간 · 랜덤 · 순서 의존 | 주입 · 고정 |
sleep 으로 대기 | 조건 폴링으로 교체 |
| 병렬 실행 충돌 | 테스트별 리소스 (DB 스키마 · 포트) |
| 외부 네트워크 | 모킹 · 로컬 컨테이너 |
| UI 렌더 타이밍 | 요소 대기 API |
# ✗ sleep 은 느리고 여전히 불안정하다
process_async(job)
time.sleep(2)
assert job_done(job)
# ✓ 조건을 폴링한다
def wait_until(cond, timeout=5.0, interval=0.05):
end = time.monotonic() + timeout
while time.monotonic() < end:
if cond(): return True
time.sleep(interval)
raise AssertionError("조건이 만족되지 않음")
process_async(job)
wait_until(lambda: job_done(job)) # 빠르고 안정적
| 커버리지에 대한 관점 | |
|---|---|
| 낮은 커버리지 | 확실한 문제 신호 |
| 높은 커버리지 | 품질을 보장하지 않음 |
| 100% 목표 | 의미 없는 테스트를 유발 |
| 변경분 커버리지 | 실용적 — 새 코드만 검사 |
| 돌연변이 테스트 | 테스트의 질을 실제로 측정 |
리팩터링 · 기술 부채 — 안전하게 고치는 순서
리팩터링은 동작을 바꾸지 않으면서 구조만 고치는 것입니다. 기능 추가와 섞으면 무엇이 깨졌는지 알 수 없게 되므로 반드시 나눠서 합니다.
코드 스멜과 대응
| 스멜 | 리팩터링 |
|---|---|
| 긴 함수 | 함수 추출 |
| 중복 코드 | 추출 · 상위로 이동 |
| 긴 인자 목록 | 파라미터 객체 |
| 기본형 집착 | 값 객체 (Money · Email) |
| 거대 클래스 | 클래스 추출 |
| 기능 편애 | 메서드를 데이터가 있는 곳으로 |
| 산탄총 수술 | 흩어진 변경 지점을 모음 |
| 주석이 필요한 코드 | 이름으로 설명 |
| 플래그 인자 | 함수 분리 |
| 깊은 중첩 | 가드 절 · 조기 반환 |
if 를 조기 반환으로 평탄화하면
읽기가 즉시 좋아지고 위험도 거의 없습니다.
안전한 순서
- 테스트로 감싼다 — 없으면 특성화 테스트(현재 동작을 그대로 기록)를 먼저 만듭니다.
- 작게 나눠 한 번에 하나 — 커밋 단위를 작게. 각 단계마다 테스트 통과.
- 기능 변경과 분리 — 리팩터링 커밋과 기능 커밋을 섞지 않습니다.
- IDE 자동 리팩터링 활용 — 이름 변경 · 추출은 도구가 더 안전합니다.
- 측정 — 성능이 목적이었다면 전후를 잽니다.
# 특성화 테스트 — 이해 못 하는 코드를 고치기 전에
def test_characterize_legacy_calc():
"""현재 동작을 그대로 기록한다 (옳은지는 나중에)"""
cases = [
(100, "A", 95.0),
(100, "B", 90.0),
(0, "A", 0.0),
(-1, "A", 0.0), # ★ 이상하지만 현재 동작이다
]
for amount, grade, expected in cases:
assert legacy_calc(amount, grade) == expected
# 이 테스트가 있으면 안심하고 구조를 바꿀 수 있다
# 나중에 "-1 이 0 이 되는 게 맞나?" 를 따로 논의한다
이렇게도 쓸 수 있다 — 큰 변경 — 스트랭글러 패턴 🟡 중급
레거시를 한 번에 바꾸는 재작성은 대부분 실패한다
· 기간이 예상의 몇 배
· 그동안 신규 기능이 멈춘다
· 레거시의 '이상한 동작' 이 사실 요구사항이었던 경우가 많다
스트랭글러 패턴 — 조금씩 감싸 대체한다
① 새 시스템 앞에 라우팅 계층을 둔다
② 기능 하나를 새 구현으로 옮기고 라우팅을 전환
③ 문제가 생기면 라우팅만 되돌린다 (즉시 롤백)
④ 반복 → 레거시가 점점 줄어든다
⑤ 마지막 기능까지 옮기면 레거시 제거
핵심
· 항상 동작하는 상태를 유지한다
· 각 단계가 독립적으로 배포·롤백 가능하다
· 진척이 눈에 보인다
# 전환 중 안전장치 — 두 구현을 함께 실행하고 비교
def get_price(order, feature_flags):
old = legacy_price(order)
if not feature_flags.get("new_pricing_shadow"): return old
new = old # 새 구현이 실패하면 옛 값으로 (아래 return 이 안전하도록)
try:
new = new_price(order)
if abs(new - old) > 0.01:
log.warning("가격 불일치 order=%s old=%s new=%s", order.id, old, new)
metrics.inc("pricing_mismatch")
except Exception:
log.exception("새 구현 실패")
metrics.inc("pricing_new_error")
return new if feature_flags.get("new_pricing_enabled") else old
# ★ 섀도 실행으로 실제 트래픽에서 검증한 뒤 전환한다
# 불일치가 0 이 되면 자신 있게 켤 수 있다
실무에선 이렇게 — 기술 부채 관리 🔴 실무
| 부채 유형 | 내용 | 대응 |
|---|---|---|
| 의도적 · 신중 | “지금은 빨리 내고 나중에 정리” | 기한과 함께 기록 |
| 의도적 · 무모 | “설계할 시간 없다” | 줄여야 할 관행 |
| 비의도적 · 학습 | “이제 더 나은 방법을 안다” | 자연스러움 · 점진 개선 |
| 비의도적 · 무지 | 설계를 몰랐음 | 교육 · 리뷰 |
부채를 갚는 현실적인 방법
① 보이게 만든다
· 백로그에 티켓으로 등록 (코드 주석은 잊힌다)
· TODO 에 이슈 번호와 기한을 함께
# TODO(#1234, 2026-06): 임시 우회 — 결제 API v2 전환 후 제거
② 정기적으로 갚는다
· 스프린트의 일정 비율(10~20%)을 배정
· "보이스카우트 규칙" — 만진 곳은 조금 더 깨끗하게
③ 우선순위를 데이터로
· 자주 바뀌면서 복잡한 파일부터 (01절)
· 장애가 반복되는 영역부터
· 개발 속도를 실제로 늦추는 것부터
④ 비즈니스 언어로 설명한다
✗ "코드가 지저분해서 리팩터링이 필요합니다"
✓ "이 모듈 때문에 기능 하나가 2주 걸립니다.
3일 투자하면 이후 비슷한 작업이 3일로 줄어듭니다."
버전 관리 — 브랜치 전략은 팀에 맞춰
정답인 전략은 없습니다. 배포 주기 · 팀 크기 · 릴리스 형태에 따라 달라집니다. 다만 긴 브랜치는 거의 항상 나쁩니다.
전략 비교
| 전략 | 특징 | 적합 |
|---|---|---|
| 트렁크 기반 | 짧은 브랜치 · 하루 안에 머지 | CD · 웹 서비스 |
| GitHub Flow | main + 기능 브랜치 + PR | 가장 널리 쓰임 |
| GitLab Flow | 환경 브랜치 추가 | 단계적 배포 |
| Git Flow | develop · release · hotfix | 버전 릴리스 제품 |
| 릴리스 브랜치 | 버전별 유지보수 | 여러 버전 지원 |
긴 브랜치가 나쁜 이유
브랜치가 오래 살수록
· 머지 충돌이 기하급수적으로 늘어난다
· 리뷰가 어려워진다 (변경이 수천 줄)
· 통합 문제를 늦게 발견한다
· 다른 사람의 리팩터링과 충돌한다
★ 목표: 브랜치 수명 1~2일
큰 기능을 어떻게 나누나
· 피처 플래그 — 미완성 코드를 main 에 두되 꺼 둔다
· 브랜치 바이 앱스트랙션 — 추상화를 먼저 넣고 구현을 점진 교체
· 작은 단위로 쪼개기 — DB 스키마 → API → UI 순
· 사용자에게 안 보이는 것부터 먼저 머지
이렇게도 쓸 수 있다 — 커밋과 히스토리 🟡 중급
좋은 커밋 메시지
feat(orders): 주문 취소 시 재고를 자동 복구
결제 취소는 되는데 재고가 그대로 남아 실제 재고와
시스템 재고가 어긋나는 문제가 있었다.
취소 트랜잭션 안에서 재고를 복구하도록 변경했다.
부분 취소는 아직 지원하지 않는다 (#1240 에서 다룬다).
Closes #1198
★ 제목은 '무엇을' (50자 이내 · 명령형)
★ 본문은 ★ '왜' — 코드는 무엇을 하는지 이미 보여 준다
★ 이슈 · ADR 링크
# 머지 전략
git merge --no-ff feature # 브랜치 이력 보존 (기능 단위가 보인다)
git merge --squash feature # 한 커밋으로 압축 ★ 히스토리가 깔끔
git rebase main # 선형 히스토리 (공유 브랜치에는 금지)
# 실무에서 유용한 명령
git log --oneline --graph --all
git log -S "함수명" # 그 문자열이 추가/삭제된 커밋
git log -L :func_name:file.py # 함수의 변경 이력
git blame -w -C file.py # 공백·이동 무시
git bisect run ./test.sh # 버그를 넣은 커밋 찾기 (🔀 알고리즘 03)
# 되돌리기
git revert <sha> # ★ 공유 브랜치는 revert
git reset --hard <sha> # 로컬에서만
git reflog # 실수로 날렸을 때 구조선
git reflog 를 기억해 두세요 —
reset --hard 로 날린 커밋도 대부분 복구할 수 있습니다.
git 은 커밋된 것은 좀처럼 잃지 않습니다.
실무에선 이렇게 — 버전 · 릴리스 🔴 실무
| 항목 | 내용 |
|---|---|
| 시맨틱 버저닝 | MAJOR.MINOR.PATCH — 호환성이 깨지면 MAJOR |
| Conventional Commits | feat: fix: BREAKING CHANGE: → 버전 자동 산정 |
| 변경 로그 자동 생성 | 커밋에서 생성 (release-please 등) |
| 태그와 릴리스 | 배포 산출물과 소스를 연결 |
| 지원 정책 | 어느 버전까지 유지보수하나 |
모노레포 vs 폴리레포
모노레포
+ 원자적 변경 (API 와 클라이언트를 한 커밋에)
+ 공통 코드 공유 · 일관된 도구
+ 전체 검색·리팩터링이 쉽다
- 빌드 도구가 필요 (Bazel · Nx · Turborepo)
- 권한 분리가 어렵다 (CODEOWNERS 로 완화)
- 저장소가 커진다 (sparse checkout · 부분 클론)
폴리레포
+ 독립성 · 권한 분리 · 가벼움
- 공통 코드 버전 관리가 번거롭다
- 여러 저장소에 걸친 변경이 어렵다
★ 팀이 작고 서비스가 강하게 연관되면 모노레포가 편하다
조직이 크고 독립적이면 폴리레포가 자연스럽다
CI/CD · 배포 전략 — 배포를 습관으로
배포가 두려우면 배포를 미루게 되고, 미루면 한 번에 나가는 변경이 커져 더 위험해집니다. 작게 자주 · 되돌릴 수 있게가 핵심입니다.
파이프라인 구성
- 빠른 피드백 먼저 — 린트 · 타입 검사 · 단위 테스트 (2~5분)
- 통합 테스트 — 컨테이너 DB · 계약 테스트 (10분 이내)
- 보안 · 품질 — 의존성 스캔 · 시크릿 스캔 · 커버리지 (🔐 12)
- 빌드 · 서명 — 이미지 생성 · SBOM · 서명 (🔐 06)
- 스테이징 배포 · 스모크 테스트
- 프로덕션 배포 — 카나리 → 전체
- 배포 후 검증 — 지표 확인 · 자동 롤백 조건
배포 전략
| 전략 | 방식 | 특징 |
|---|---|---|
| 롤링 | 인스턴스를 순차 교체 | 기본 · 두 버전 공존 |
| 블루-그린 | 새 환경 준비 후 전환 | 즉시 롤백 · 자원 2배 |
| 카나리 | 일부 트래픽만 새 버전 | 위험 최소화 ★ |
| 섀도 | 복제 트래픽으로 검증 | 부수효과 주의 |
| 피처 플래그 | 배포와 릴리스를 분리 | 즉시 켜고 끔 |
★ 배포(deploy)와 릴리스(release)를 분리하라
배포 — 코드를 프로덕션에 올린다 (사용자에겐 안 보임)
릴리스 — 기능을 켠다 (플래그 토글)
얻는 것
· 배포는 자주 · 안전하게
· 문제가 생기면 롤백 대신 ★ 플래그만 끈다 (수 초)
· 점진 공개 (1% → 10% → 100%)
· A/B 테스트와 자연스럽게 결합
이렇게도 쓸 수 있다 — DB 마이그레이션 — 가장 위험한 부분 🟡 중급
코드는 롤백할 수 있지만 데이터는 되돌리기 어렵다
★ 확장-축소(expand-contract) 패턴
컬럼 이름 변경을 예로 (name → full_name)
① 확장 — 새 컬럼을 추가 (nullable)
② 이중 쓰기 — 코드가 양쪽 모두에 쓴다 (배포)
③ 백필 — 기존 데이터를 새 컬럼으로 복사 (배치 · 청크)
④ 읽기 전환 — 새 컬럼에서 읽는다 (배포)
⑤ 이중 쓰기 중단 (배포)
⑥ 축소 — 옛 컬럼 삭제 (충분히 지난 뒤)
★ 각 단계가 독립적으로 롤백 가능하다
★ 어느 시점에도 옛 코드와 새 코드가 함께 돌 수 있다
(롤링 배포 중에는 반드시 공존한다)
| 위험한 마이그레이션 | 안전한 방법 |
|---|---|
큰 테이블에 NOT NULL 컬럼 추가 | nullable 로 추가 → 백필 → 제약 추가 |
| 인덱스 생성 (테이블 잠금) | CREATE INDEX CONCURRENTLY |
| 컬럼 타입 변경 | 새 컬럼 + 백필 + 전환 |
| 대량 UPDATE | 청크로 나눠 실행 (복제 지연 확인) |
| 컬럼 삭제 | 코드에서 참조 제거 후 충분한 시간 뒤 |
| 테이블 rename | 뷰로 우회 · 확장-축소 |
실무에선 이렇게 — DORA 지표 🔴 실무
| 지표 | 뜻 | 높은 성과 팀 |
|---|---|---|
| 배포 빈도 | 얼마나 자주 배포하나 | 하루 여러 번 |
| 변경 리드 타임 | 커밋 → 배포까지 | 1일 이내 |
| 변경 실패율 | 배포가 문제를 일으킨 비율 | 15% 이하 |
| 복구 시간 | 장애에서 회복까지 | 1시간 이내 |
| (추가) 신뢰성 | SLO 달성 여부 | — |
★ 앞의 두 개(속도)와 뒤의 두 개(안정성)는 ★ 상충하지 않는다
연구 결과 — 잘하는 팀은 둘 다 좋다
이유
· 작게 배포하면 문제 범위가 작다
· 자주 배포하면 배포 과정이 숙련되고 자동화된다
· 빠른 롤백이 가능하면 위험을 감수할 수 있다
개선 순서 (실무)
① 배포 자동화 — 사람 손이 들어가는 단계를 없앤다
② 롤백 자동화 — 되돌리기가 쉬우면 배포가 덜 무섭다
③ 테스트 시간 단축 — 병렬화 · 영향 범위만
④ 배치 크기 축소 — PR 을 작게
⑤ 피처 플래그 — 배포와 릴리스 분리
관측 가능성 — 시스템에 질문할 수 있는가
모니터링이 “미리 정한 것을 본다”면 관측 가능성은 “예상 못 한 질문에 답할 수 있다”입니다. 장애의 절반은 원인을 찾는 데 쓰입니다.
로그
개별 사건의 상세. “무슨 일이 있었나”
{"ts":"2026-08-07T10:00:00Z",
"level":"error",
"msg":"결제 실패",
"trace_id":"abc123",
"order_id":"ord_9",
"reason":"card_declined",
"duration_ms":842}
메트릭
집계된 수치. “얼마나 · 언제부터”
http_requests_total{
method="POST",
route="/orders",
status="500"} 42
http_duration_seconds_bucket{
le="0.5"} 9812
트레이스
요청의 전체 경로. “어디서 느린가”
POST /orders 842ms
├ auth 12ms
├ db.select 31ms
├ payment.charge 720ms ★
│ └ http.post 715ms
└ db.insert 28ms
구조화 로깅
import structlog
log = structlog.get_logger()
# ✗ 문자열 조립 — 나중에 검색·집계가 안 된다
log.info(f"주문 {order.id} 생성, 금액 {order.total}")
# ✓ 필드로
log.info("order_created", order_id=order.id, total=order.total,
user_id=user.id, trace_id=trace_id.get())
# 컨텍스트를 한 번 묶어 두면 이후 모든 로그에 포함된다
structlog.contextvars.bind_contextvars(
trace_id=trace_id.get(), user_id=user.id, request_path=path)
# 로그 레벨 기준
# DEBUG 개발 중 상세 (운영에서는 끔)
# INFO 상태 변화 · 중요한 사건
# WARNING 이상하지만 처리됨 (재시도 성공 등)
# ERROR 처리 실패 — ★ 사람이 봐야 하는 것만
# CRITICAL 서비스 영향
SLI · SLO · 에러 버짓
SLI — 측정하는 지표
"성공한 요청 / 전체 요청"
"500ms 이내 응답 비율"
SLO — 목표
"30일 동안 가용성 99.9%"
= 허용 다운타임 43분
에러 버짓 — 남은 여유
99.9% 목표에서 현재 99.95% 라면
버짓의 절반이 남았다
★ 에러 버짓의 쓸모
· 남으면 → 위험한 변경을 시도해도 된다
· 소진되면 → 신규 배포를 멈추고 안정화에 집중
→ "속도 vs 안정성" 논쟁을 데이터로 해결한다
이렇게도 쓸 수 있다 — 알림 설계 🟡 중급
| 원칙 | 내용 |
|---|---|
| 증상에 알림, 원인은 대시보드로 | “CPU 높음”이 아니라 “주문 실패율 상승” |
| 조치 가능한 것만 | 받고 할 일이 없으면 알림이 아니다 |
| 지속 시간 조건 | for: 5m — 순간 스파이크 무시 |
| 심각도 구분 | page(즉시 호출) vs ticket(업무 시간) |
| 런북 링크 포함 | 새벽에 받은 사람이 바로 조치할 수 있게 |
| 에러 버짓 소진 속도 | 빠른 소진과 느린 소진에 다른 임계 |
- alert: OrderSuccessRateLow
expr: |
sum(rate(orders_total{status="success"}[5m]))
/ sum(rate(orders_total[5m])) < 0.99
for: 5m
labels:
severity: page
annotations:
summary: "주문 성공률 {{ $value | humanizePercentage }} (목표 99%)"
runbook: "https://wiki/runbooks/order-failure"
dashboard: "https://grafana/d/orders"
# 다중 윈도우 · 다중 번율 (Google SRE 권장)
# 빠른 소진: 1시간에 버짓의 2% (또는 6시간에 5%) 소진 → page
# 느린 소진: 3일에 버짓의 10% 소진 → ticket
# → 오탐을 줄이면서 큰 문제는 빨리 잡는다
실무에선 이렇게 — OpenTelemetry 🔴 실무
# 자동 계측 — 코드 수정 없이 시작할 수 있다
# opentelemetry-instrument python app.py
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
@app.post("/orders")
def create_order(req):
with tracer.start_as_current_span("create_order") as span:
span.set_attribute("order.item_count", len(req.items))
span.set_attribute("user.id", req.user_id)
try:
with tracer.start_as_current_span("payment.charge"):
result = gateway.charge(...)
span.set_attribute("payment.id", result.id)
except PaymentError as e:
span.record_exception(e)
span.set_status(trace.Status(trace.StatusCode.ERROR))
raise
return result
# ★ 트레이스 ID 를 로그에도 넣으면 세 신호가 연결된다
# 느린 트레이스 발견 → 그 요청의 로그로 바로 이동
| 실무 팁 | 내용 |
|---|---|
| 꼬리 기반 샘플링 | 느리거나 실패한 트레이스는 100% 보존 |
| 결정적 샘플링 | trace_id 로 판정 — 경로가 끊기지 않게 (➗ 07) |
| 카디널리티 관리 | 메트릭 레이블에 고유값 금지 |
| 비용 감시 | 관측 비용이 인프라 비용을 넘기도 한다 |
| PII 제거 | 로그·스팬 속성에서 개인정보 마스킹 (🔐 11) |
코드 리뷰 · 문서 · 협업 — 지식을 팀에 남기기
마지막 절입니다. 기술적으로 옳은 것과 팀이 계속 유지할 수 있는 것은 다릅니다. 코드보다 오래 사는 것은 그것을 만든 사람들의 이해입니다.
리뷰가 잘 되게 하는 조건
| 항목 | 기준 |
|---|---|
| PR 크기 | 400줄 이하 — 그 이상은 리뷰 품질이 급락 |
| 맥락 제공 | 왜 · 어떻게 · 무엇을 봐 달라 |
| 자동화 선행 | 포맷 · 린트 · 테스트는 기계가 |
| 응답 시간 | 하루 이내 — 오래 걸리면 충돌이 는다 |
| 리뷰 범위 합의 | 설계? 버그? 스타일? |
리뷰 코멘트의 온도 표시 (nit 컨벤션)
nit: 사소함 · 반영 안 해도 됨
question: 이해하려고 묻는 것
suggestion: 제안 · 선택은 작성자
issue: 고쳐야 함 (근거를 함께)
blocking: 머지 전 반드시
★ 무엇이 필수이고 무엇이 취향인지 명시하면
불필요한 논쟁과 감정 소모가 크게 준다
무엇을 볼 것인가
- 정확성 — 요구를 만족하나 · 경계 조건 · 에러 경로
- 안전성 — 인가 · 입력 검증 · 시크릿 (🔐 13)
- 설계 — 이 자리가 맞나 · 결합도 · 이름
- 테스트 — 의미 있는 검증이 있나
- 운영 — 로그 · 메트릭 · 롤백 가능성 · 마이그레이션
- 가독성 — 6개월 뒤에 읽을 수 있나
이렇게도 쓸 수 있다 — 문서 — 무엇을 남길까 🟡 중급
| 문서 | 내용 | 수명 |
|---|---|---|
| README | 이게 뭔지 · 어떻게 돌리는지 | 길다 |
| ADR | 왜 이렇게 결정했나 (06절) | 영구 |
| 런북 | 장애 시 무엇을 하나 | 길다 |
| 온보딩 가이드 | 첫 주에 무엇을 하나 | 중간 |
| API 문서 | 스키마에서 자동 생성 | 코드와 동기 |
| 설계 문서 | 큰 변경 전 합의 | 짧다 (ADR 로 요약) |
| 코드 주석 | 왜 이렇게 했는지 | 코드와 함께 |
# ✗ 무엇을 하는지 설명하는 주석 (코드가 이미 말한다)
# i 를 1 증가시킨다
i += 1
# ✓ 왜 그런지 설명하는 주석
# 결제사 API 가 동일 요청을 2초 내 중복으로 받으면 409 를 반환한다.
# 재시도 간격을 최소 2.5초로 둬야 한다 (지원팀 티켓 #8821 참고).
time.sleep(2.5)
# ✓ 함정을 경고하는 주석
# ★ 이 순서를 바꾸지 말 것 — 세션 무효화가 먼저 일어나야
# 경쟁 상태에서 옛 토큰이 통과하지 않는다.
invalidate_session(user)
issue_new_token(user)
실무에선 이렇게 — 마무리 · 전체 지도 🔴 실무
이 가이드의 열두 탭이 이어지는 방식
| 탭 | 핵심 질문 | 이어지는 곳 |
|---|---|---|
| 🔢 이산수학 | 어떻게 세고 증명하나 | 계산이론 · 자료구조 · 보안 |
| ➗ 선형대수 · 확률 | 데이터를 어떻게 다루나 | 컴퓨터구조(부동소수점) · 분산(지표) |
| 🧮 계산이론 | 얼마나 걸리나 · 풀 수 있나 | 자료구조 · 알고리즘 · 컴파일러 |
| 🗂️ 자료구조 | 무엇에 담나 | 알고리즘 · DB 인덱스 |
| 🔀 알고리즘 | 어떻게 푸나 | 실무 성능 · 코딩 테스트 |
| 🖥️ 컴퓨터구조 | 실제로 어디서 도나 | 운영체제 · 동시성 |
| ⚙️ 운영체제 | 자원을 어떻게 나누나 | 네트워크 · 분산 · 컨테이너 |
| 🌐 네트워크 | 어떻게 주고받나 | 보안(TLS) · 분산(지연) |
| 🔧 컴파일러 | 코드가 어떻게 실행되나 | 성능 · GC · 타입 |
| 🔐 보안 · 암호 | 무엇을 지키나 | 모든 탭 |
| 🕸️ 분산 · 동시성 | 여럿이 어떻게 함께 하나 | 운영 · 아키텍처 |
| 🏛️ SW 공학 | 오래 유지하려면 | 전부를 묶는 층 |
여기서 더 나아가려면 (언어·도구별 실전)
🐍 Python 데이터 · 자동화 · FastAPI · CPython 내부
☕ Java Spring · JPA · JVM · 대규모 트래픽
🟨 JS · TS 프런트엔드 · Node · Electron · V8
🟣 C# · Unity 게임 · 게임 서버 · 데스크탑 · CLR
🔵 C++ 네이티브 모듈 · 성능 · UB
🦀 Rust 소유권 · 안전한 네이티브 · WASM
🗄️ Database SQL · 인덱스 · 튜닝 · 내부 구조
🌐 서버기술 — 배포 · 프록시 · HTTPS · 로드밸런싱
★ CS 는 그 아래에 깔린 층이다.
도구는 바뀌어도 이 층은 남는다.
왜 이렇게 계산하면
학습이
되는가
model.fit(X, y) 한 줄 뒤에서 무슨 일이 벌어지는지 모르면,
모델이 안 될 때 바꿀 것을 고를 수 없습니다.
이 탭은 라이브러리 사용법이 아니라 수식과 그림으로 원리를 잡습니다 —
손실 함수가 왜 그 모양이어야 하는지, 기울기가 어떻게 층을 거슬러 흐르는지,
어텐션 한 줄짜리 식이 왜 언어 모델을 만들어 냈는지.
코드는 개념 확인용 NumPy 수준으로만 넣었습니다.
행렬·확률의 기본기는 ➗ 선형대수 · 확률 탭이 담당하고, 여기서는 그 위에서 시작합니다.
학습이란 무엇인가 — 규칙을 쓰지 않고 규칙을 얻는 방법
머신러닝은 마법이 아니라 최적화 문제 하나입니다. “가설 공간에서, 손실을 가장 작게 만드는 함수를, 데이터를 보며 찾는다.” 이 한 문장을 세 조각으로 뜯어 보면 이 탭 전체의 지도가 나옵니다.
규칙 기반으로는 못 푸는 문제
# "고양이 사진인가?" 를 if 로 써 본다면
def is_cat(img):
if has_pointy_ears(img) and has_whiskers(img):
return True
# ... 귀가 접힌 고양이는? 옆모습은? 어두운 사진은?
# ... has_pointy_ears 자체를 또 규칙으로 써야 한다
# 문제의 성질
# · 규칙을 말로 정확히 쓸 수 없다 (사람도 설명 못 한다)
# · 예외가 사실상 무한하다
# · 그런데 '예시'는 많이 모을 수 있다 ← 여기가 ML 의 입구
학습의 세 종류
| 종류 | 주어지는 것 | 예 |
|---|---|---|
| 지도 학습 | 입력 x 와 정답 y 쌍 | 스팸 분류 · 가격 예측 |
| 비지도 학습 | 입력 x 만 | 고객 군집 · 이상 탐지 |
| 자기지도 | 데이터 일부를 가려 스스로 정답을 만듦 | LLM 다음 토큰 예측 |
| 강화 학습 | 행동에 대한 보상 신호 | 게임 · 로봇 제어 |
핵심 개념 — 가설 공간 · 손실 함수 · 최적화 🟢 기초
모든 학습 알고리즘은 이 세 축의 조합입니다
| 축 | 정하는 것 | 고르면 달라지는 모델 | 이 탭에서 |
|---|---|---|---|
| 가설 공간 H | 표현할 수 있는 함수의 집합 | 직선만 → 선형 회귀 · 계단 분할 → 결정 트리 · 층 쌓기 → 신경망 | 02 · 05 · 07 |
| 손실 L | “틀렸다”의 정의 | 제곱 오차 → 평균 예측 · 교차 엔트로피 → 확률 예측 | 02 · 03 |
| 최적화 | 최소를 찾는 절차 | 해석해 · 경사하강 · Adam · 트리 탐욕 분할 | 02 · 09 |
| 정규화 | H 를 좁히는 추가 항 | L1/L2 · 드롭아웃 · 조기 종료 | 04 |
import numpy as np
# 세 축을 그대로 코드로 옮기면 '학습'이 된다
def predict(X, w): # ① 가설 공간: 선형 함수만 허용
return X @ w
def loss(X, y, w): # ② 손실: 제곱 오차
r = predict(X, w) - y
return np.mean(r ** 2)
def fit(X, y, lr=0.1, steps=500):
w = np.zeros(X.shape[1])
for _ in range(steps): # ③ 최적화: 기울기 반대 방향으로 조금씩
g = 2 * X.T @ (predict(X, w) - y) / len(y)
w -= lr * g
return w
# 이 20줄이 선형 회귀 · 로지스틱 회귀 · 신경망의 공통 골격이다.
# 바뀌는 것은 predict 의 모양과 loss 의 식뿐이다.
한 단계 더 — 일반화와 과적합 — 학습의 목적은 암기가 아닙니다 🟡 중급
훈련 오차는 목표가 아닙니다
# 훈련 데이터를 통째로 외워 버리는 '모델'
class Memorizer:
def fit(self, X, y): self.table = dict(zip(map(tuple, X), y))
def predict(self, X): return [self.table.get(tuple(x), 0) for x in X]
# 훈련 정확도 100%. 새 데이터 정확도 0%.
# 훈련 오차만 보면 이 모델이 '최고'다 — 그래서 훈련 오차는 지표가 아니다.
과소적합과 과적합의 신호
| 훈련 / 검증 | 진단 | 손댈 곳 |
|---|---|---|
| 높음 / 높음 | 과소적합 — H 가 너무 좁다 | 모델 키우기 · 특성 추가 |
| 낮음 / 높음 | 과적합 — 외웠다 | 정규화 · 데이터 추가 |
| 낮음 / 낮음 | 정상 | 배포 · 다음 실험 |
| 높음 / 낮음 | 버그 또는 분할 오류 | 데이터 누수 의심(12절) |
실무에선 이렇게 — 데이터 분할과 교차 검증 🔴 실무
직접 짜 보는 k-겹
import numpy as np
def kfold_scores(X, y, fit_fn, score_fn, k=5, seed=0):
idx = np.random.default_rng(seed).permutation(len(y))
folds = np.array_split(idx, k)
out = []
for i in range(k):
val = folds[i]
trn = np.concatenate([folds[j] for j in range(k) if j != i])
model = fit_fn(X[trn], y[trn]) # 검증 조각은 학습에 절대 넣지 않는다
out.append(score_fn(model, X[val], y[val]))
return np.array(out)
s = kfold_scores(X, y, fit_lr, acc)
print(s.mean(), s.std()) # 0.826 0.025
# std 가 크면 "데이터가 적거나 모델이 불안정하다"는 신호
분할할 때 틀리기 쉬운 것
| 상황 | 올바른 분할 |
|---|---|
| 시계열 데이터 | 시간 순서대로 자름 — 미래로 과거를 맞히면 안 됩니다 |
| 같은 사용자의 여러 행 | 사용자 단위로 그룹 분할 |
| 클래스가 매우 불균형 | 계층 분할(stratified) — 비율 유지 |
| 전처리(스케일링) | 훈련에서만 fit → 검증에 transform |
| 하이퍼파라미터 탐색 | 검증으로 고르고 시험은 마지막 한 번 |
StandardScaler.fit() 을 먼저 한 뒤 분할하는 것.
검증 세트의 평균·표준편차가 훈련에 스며들어 점수가 부풀려집니다.
이것이 데이터 누수의 교과서 사례이고, 12절에서 더 다룹니다.
선형 회귀부터 — 한 번에 푸는 답과 굴러 내려가는 답
선형 회귀는 해석해가 존재하는 유일한 자리입니다. 공식으로 한 번에 푸는 길과 경사하강으로 조금씩 내려가는 길이 같은 점에 도착한다는 사실을 확인하고 나면, 그 뒤 모든 모델이 왜 경사하강만 쓰는지도 보입니다.
모델과 비용 함수
# 왜 하필 '제곱' 인가
# ① 부호를 없애야 한다 (+3 과 -3 이 상쇄되면 안 됨)
# ② 절댓값보다 미분이 매끄럽다 (0 에서 꺾이지 않음)
# ③ 큰 오차에 더 큰 벌점 → 크게 틀린 것부터 고친다
# ④ 오차가 정규분포라고 가정하면 제곱 오차 최소화 = 최대가능도 추정
# ③ 의 부작용: 이상치 하나가 답을 통째로 끌고 간다
# → 그럴 땐 Huber 손실 (작을 땐 제곱, 클 땐 절댓값)
해석해 — 정규방정식
import numpy as np
X = np.c_[np.ones(len(x)), x] # 절편을 위한 1 열 추가
w_exact = np.linalg.lstsq(X, y, rcond=None)[0] # ★ 이렇게 푼다
w_bad = np.linalg.inv(X.T @ X) @ X.T @ y # 교과서 공식 그대로 ✗
# inv 를 직접 부르면 안 되는 이유
# · XᵀX 는 조건수가 X 의 '제곱' — 오차가 제곱으로 증폭된다
# · lstsq 는 QR/SVD 로 XᵀX 를 만들지 않고 푼다
# → 수치 안정성은 ➗ 선형대수 탭 13 참고
(XᵀX)⁻¹ 계산이
특성 수 d 에 대해 O(d³) 이기 때문입니다. d 가 수만이면 불가능하고,
로지스틱 회귀부터는 해석해가 아예 존재하지 않습니다.
핵심 개념 — 경사하강 — 왜 기울기의 반대인가 🟢 기초
기울기 유도와 구현
def gd(X, y, lr=0.05, steps=2000):
w = np.zeros(X.shape[1]); hist = []
for t in range(steps):
err = X @ w - y # (n,)
g = 2 * X.T @ err / len(y) # (d,)
w -= lr * g
hist.append(np.mean(err ** 2))
return w, hist
w_gd, hist = gd(X, y)
np.allclose(w_gd, w_exact, atol=1e-3) # True — 같은 답에 도착한다
# 비용이 줄어드는지 반드시 찍어 볼 것.
# 늘어나면 → lr 을 1/3 로. 들쭉날쭉하면 → 스케일링을 안 했다.
두 길의 비교
| 정규방정식 | 경사하강 | |
|---|---|---|
| 계산량 | O(d³ + nd²) | O(nd) × 반복 |
| d = 10 | 즉시 | 가능 |
| d = 100,000 | 불가능 | 가능 |
| 학습률 | 필요 없음 | 튜닝 필요 |
| 스케일링 | 필요 없음 | 거의 필수 |
| 다른 모델에 | 선형에만 | 전부 적용 |
한 단계 더 — 특성 스케일링이 왜 필요한가 🟡 중급
누수 없이 스케일링하기
mu = X_train.mean(axis=0)
sd = X_train.std(axis=0) + 1e-12 # 0 나눗셈 방지
Xtr = (X_train - mu) / sd # 훈련에서 통계를 '학습'
Xva = (X_val - mu) / sd # 검증에는 그 통계를 '적용'만
# ↑ 검증 데이터의 평균을 쓰면 그 순간 누수다 (12절)
# 어떤 스케일러를 고를까
# · 표준화 (x-μ)/σ : 기본값. 대체로 정규분포 가정
# · 최소최대 (x-min)/범위 : 픽셀처럼 경계가 확실할 때
# · 로버스트 (x-중앙값)/IQR: 이상치가 많을 때
# · 로그 log1p(x) : 금액·조회수처럼 꼬리가 긴 분포
스케일링이 필요한 모델 / 아닌 모델
| 모델 | 필요 | 이유 |
|---|---|---|
| 선형 · 로지스틱 (경사하강) | 필수 | 등고선 모양 |
| 신경망 | 필수 | 활성화 포화 · 초기화 가정 |
| k-NN · k-means · SVM | 필수 | 거리 계산이 단위에 좌우 |
| L1 · L2 정규화 | 필수 | 벌점이 계수 크기에 걸림 |
| 결정 트리 · 부스팅 | 불필요 | 임계값 비교뿐 — 단조 변환에 불변 |
실무에선 이렇게 — 다항 특성과 기저 함수 — 직선으로 곡선 맞히기 🔴 실무
“선형 회귀”의 선형은 가중치에 대해 선형이라는 뜻입니다
# x 를 그대로 넣지 않고 '변환한 특성'을 넣으면 곡선도 맞출 수 있다
Phi = np.c_[np.ones(len(x)), x, x**2, x**3] # 3차 다항 기저
w = np.linalg.lstsq(Phi, y, rcond=None)[0]
# 모델은 y = w0 + w1·x + w2·x² + w3·x³ — x 에 대해서는 곡선,
# w 에 대해서는 여전히 선형이라 최소제곱 그대로 통한다.
# 다른 기저들
Phi = np.c_[np.ones(n), np.sin(x), np.cos(x)] # 주기성
Phi = np.c_[np.ones(n), np.log1p(x)] # 체감 효과
Phi = np.c_[X, X[:, 0] * X[:, 1]] # 상호작용 항
# 차수를 올릴수록 훈련 오차는 계속 줄지만 검증 오차는 U 자를 그린다 → 04절
| 차수 | 훈련 MSE | 검증 MSE | 상태 |
|---|---|---|---|
| 1 (직선) | 4.82 | 4.91 | 과소적합 — 곡선을 못 따라감 |
| 3 | 0.31 | 0.36 | 적절 |
| 9 | 0.04 | 1.87 | 과적합 시작 |
| 15 | 0.00 | 240.5 | 점을 전부 통과하며 사이에서 폭주 |
분류 · 로지스틱 회귀 — 확률을 출력하는 선형 모델
“스팸인가 아닌가”에 회귀를 그대로 쓰면 예측이 1.7 이나 −0.3 이 나옵니다. 출력을 0~1 로 눌러 담고, 손실을 확률에 맞게 바꾸는 두 가지 수정만으로 회귀가 분류기가 됩니다. 그 두 수정이 왜 필요한지가 이 절의 전부입니다.
시그모이드와 로그 오즈
import numpy as np
def sigmoid(z):
# 그대로 exp(-z) 를 쓰면 z 가 -800 일 때 오버플로가 난다
out = np.empty_like(z, dtype=float)
pos, neg = z >= 0, z < 0
out[pos] = 1 / (1 + np.exp(-z[pos]))
ez = np.exp(z[neg])
out[neg] = ez / (1 + ez) # 수학적으로 동일 · 수치적으로 안전
return out
# 계수 해석
# w_j = 1.1 → x_j 가 1 늘면 오즈가 e^1.1 ≈ 3배
# 선형 회귀와 달리 "확률이 얼마 늘어난다"가 아니라 "오즈가 몇 배"다
exp(w) 가 오즈비이고, 규제 기관에 설명할 수 있는 숫자입니다.
교차 엔트로피 — 확률에 맞는 손실
def bce(y, p, eps=1e-12):
p = np.clip(p, eps, 1 - eps) # log(0) = -inf 방지
return -np.mean(y * np.log(p) + (1 - y) * np.log(1 - p))
# 기울기가 놀랍도록 깔끔해진다
# dL/dz = p - y ← 시그모이드 미분이 약분되어 사라진다
# dL/dw = X.T @ (p - y) / n
# "예측 - 정답" 그대로. 선형 회귀의 기울기와 같은 모양이다.
p − y 로 단순해집니다.
핵심 개념 — 왜 제곱 오차를 쓰면 안 되는가 🟢 기초
기울기 소실 — 크게 틀렸는데 학습 신호가 0 입니다
z = np.array(-8.0) # 모델이 "절대 0 이다" 라고 강하게 확신 (위 sigmoid 는 배열을 받는다)
p = sigmoid(z) # 0.000335
y = 1.0 # 그런데 정답은 1 — 최악의 오답
# 교차 엔트로피
dL_ce = p - y # -0.9997 ← 강한 수정 신호
# 제곱 오차
dL_mse = 2 * (p - y) * p * (1 - p) # -0.00067 ← 사실상 0. 못 배운다
# 확신에 찬 오답일수록 학습이 멈춘다 = 최악의 성질
| 비교 항목 | 제곱 오차 | 교차 엔트로피 |
|---|---|---|
| 확신에 찬 오답 | 기울기 ≈ 0 (학습 정지) | 기울기 ≈ ±1 |
| 비용 함수 모양 | 비볼록 (지역 최소 존재) | 볼록 |
| 확률적 해석 | 없음 | 최대가능도 추정과 동치 |
| 출력 보정 | 확률로 못 씀 | 확률로 쓸 수 있음 |
한 단계 더 — 소프트맥스와 다중 분류 🟡 중급
K 개 클래스로 확장
def softmax(Z):
Z = Z - Z.max(axis=1, keepdims=True) # ★ 최댓값을 빼도 결과가 같다
e = np.exp(Z) # (분자·분모에서 약분)
return e / e.sum(axis=1, keepdims=True)
# 최댓값을 왜 빼나: Z 에 1000 이 있으면 exp(1000) = inf → 전부 nan
# 프레임워크의 softmax 는 전부 이 트릭을 쓴다.
def cross_entropy(P, y): # y 는 정답 클래스 인덱스 배열
return -np.mean(np.log(P[np.arange(len(y)), y] + 1e-12))
# 기울기는 여기서도 같은 모양: dL/dZ = (P - onehot(y)) / n
softmax 인가 sigmoid 인가
| 문제 | 출력층 | 손실 |
|---|---|---|
| 이진 분류 | sigmoid 1개 | BCE |
| 다중 분류 (하나만 정답) | softmax K개 | CE |
| 다중 레이블 (여러 개 동시) | sigmoid K개 | BCE × K |
| 순위 학습 | 점수 1개 | 쌍별 손실 |
실무에선 이렇게 — 클래스 불균형 — 정확도 99% 가 무의미할 때 🔴 실무
사기 거래 0.5% 데이터에서 “전부 정상”이라고 답하면 정확도 99.5%
# ① 손실에 클래스 가중치를 준다 (가장 먼저 시도)
w_pos = (y == 0).sum() / (y == 1).sum() # 예: 199
loss = -np.mean(w_pos * y * np.log(p) + (1 - y) * np.log(1 - p))
# ② 임계값을 0.5 에서 옮긴다 (모델을 다시 학습하지 않아도 된다)
# 재현율을 90% 로 맞추는 임계값을 검증셋에서 찾아 고정
thr = np.quantile(p_val[y_val == 1], 0.10) # 양성의 90% 를 잡는 지점
# ③ 데이터 쪽 조정
# · 언더샘플링: 다수 클래스를 줄임 (정보 손실)
# · 오버샘플링/SMOTE: 소수 클래스를 늘림 (과적합 위험)
# ※ 반드시 '훈련 폴드 안에서만' — 검증셋에 합성 샘플이 새면 점수가 거짓이 된다
| 수단 | 바꾸는 것 | 주의 |
|---|---|---|
| 클래스 가중치 | 손실의 비중 | 확률 보정이 틀어짐 |
| 임계값 이동 | 판정 규칙만 | 가장 안전 · 먼저 시도 |
| 오버샘플링 | 훈련 분포 | 누수 위험 · 폴드 안에서만 |
| 이상 탐지로 전환 | 문제 정의 | 양성이 0.1% 미만이면 고려(06절) |
예측 − 정답 으로 단순해지고, 짝을 어기면 확신에 찬 오답에서 학습이 멈춥니다.편향-분산 · 정규화 — 일부러 못 맞히게 만드는 기술
과적합을 막는 방법들은 하나같이 “모델의 자유를 뺏는” 모양을 하고 있습니다. 왜 자유를 뺏으면 보지 못한 데이터에서 더 잘하게 되는지, 그 근거가 편향-분산 분해입니다.
분해식이 말하는 것
import numpy as np
rng = np.random.default_rng(0)
def experiment(deg, trials=200, n=25):
preds = []
for _ in range(trials):
x = rng.uniform(-3, 3, n)
y = np.sin(x) + rng.normal(0, 0.3, n) # 참 함수 + 잡음
w = np.polyfit(x, y, deg)
preds.append(np.polyval(w, 1.0)) # x=1 에서의 예측
preds = np.array(preds)
bias2 = (preds.mean() - np.sin(1.0)) ** 2
var = preds.var()
return bias2, var, bias2 + var
for d in (1, 3, 9, 15):
print(d, [round(float(v), 3) for v in experiment(d)])
# 1 [0.231, 0.016, 0.247] 편향이 큼 (직선으로 sin 을 못 맞춤)
# 3 [0.004, 0.011, 0.015] ★ 균형
# 9 [0.0, 0.047, 0.047] 편향은 0, 분산이 커지기 시작
# 15 [0.0, 0.965, 0.965] 데이터가 조금만 달라도 답이 통째로 바뀜
과적합을 막는 손잡이들
| 수단 | 줄이는 방식 | 비용 |
|---|---|---|
| 데이터 늘리기 | 분산을 직접 낮춤 | 가장 확실 · 가장 비쌈 |
| L2 (릿지) | 계수를 작게 | 약간의 편향 증가 |
| L1 (라쏘) | 계수를 0 으로 | 상관 특성 중 하나만 남김 |
| 드롭아웃 | 층 안 뉴런을 무작위로 끔 | 학습이 느려짐 |
| 조기 종료 | 외우기 전에 멈춤 | 검증셋 필요 |
| 데이터 증강 | 불변성을 알려 줌 | 도메인 지식 필요 |
| 앙상블 | 여러 모델을 평균 | 추론 비용 배수 |
핵심 개념 — L1 과 L2 — 왜 L1 만 0 을 만드는가 🟢 기초
벌점 항으로 다시 쓰기
# 릿지는 해석해가 그대로 있다 — 대각선에 λ 를 더할 뿐
def ridge(X, y, lam):
d = X.shape[1]
return np.linalg.solve(X.T @ X + lam * np.eye(d), X.T @ y)
# 부수 효과: XᵀX 가 특이(singular)여도 풀린다.
# 다중공선성으로 계수가 폭주하던 문제가 λ 하나로 안정화된다.
# 라쏘는 |w| 가 0 에서 미분 불가라 해석해가 없다
# → 좌표 하강(coordinate descent) · 근접 경사법(proximal GD)
# → 핵심 연산이 soft-thresholding:
def soft_threshold(w, a):
return np.sign(w) * np.maximum(np.abs(w) - a, 0) # 작은 계수를 '0 으로 밀어붙임'
λ 를 어떻게 고르나
# 로그 스케일로 넓게 훑고 → 교차 검증 점수의 U 자를 본다
for lam in np.logspace(-4, 3, 15):
s = kfold_scores(X, y, lambda A, b: ridge(A, b, lam), rmse)
print(f"{lam:8.4f} {s.mean():.4f} ± {s.std():.4f}")
# 0.0001 0.412 ± 0.061 사실상 정규화 없음
# 0.1000 0.318 ± 0.030 ★ 최소
# 10.000 0.349 ± 0.018 계수가 눌려 편향 증가
# 1000.0 0.702 ± 0.004 전부 0 에 가까움 = 평균 예측기
# 실무 요령: '1-표준오차 규칙' — 최소 점수의 1 표준오차 안에 드는
# 값 중 가장 큰 λ 를 고른다. 더 단순한 모델을 택하는 쪽으로 기운다.
λ(α‖w‖₁ + (1−α)‖w‖₂²) 로 둘을 섞습니다.
상관이 높은 특성 무리에서 라쏘가 하나만 무작위로 남기는 문제를
L2 항이 완화해 무리 전체를 함께 살립니다.
한 단계 더 — 드롭아웃 · 조기 종료 · 데이터 증강 🟡 중급
드롭아웃 — 매 걸음 다른 모델을 학습
def dropout(h, p=0.5, training=True):
if not training:
return h # 추론 때는 그대로
mask = (np.random.rand(*h.shape) > p) / (1 - p) # ★ 1/(1-p) 로 보정
return h * mask # '역스케일 드롭아웃'
# 왜 나누어 주나: 학습 때 절반이 꺼지면 평균 활성값이 절반이 된다.
# 미리 2배로 키워 두면 추론 때 아무 보정도 필요 없다.
# 왜 효과가 있나
# ① 뉴런이 특정 이웃에 의존하지 못한다 (공적응 방지)
# ② 매 스텝 다른 부분망을 학습 → 지수적으로 많은 모델의 앙상블
# ③ 배치 정규화와 함께 쓰면 서로 방해할 수 있어 요즘은 위치를 가린다
조기 종료 — 가장 값싼 정규화
best, wait, patience = np.inf, 0, 10
for epoch in range(1000):
train_one_epoch()
v = validate()
if v < best - 1e-4:
best, wait = v, 0
save_checkpoint() # ★ 최고 시점을 저장
else:
wait += 1
if wait >= patience:
break
load_checkpoint() # 마지막이 아니라 '최고'를 되돌린다
# 이론적 해석: 경사하강을 일찍 멈추면 계수가 커질 시간이 없다.
# 선형 모델에서는 조기 종료가 L2 정규화와 근사적으로 같다는 결과가 알려져 있다.
실무에선 이렇게 — 이중 하강 — 교과서 U 자가 끝이 아니었습니다 🔴 실무
왜 이런 일이 벌어지나 — 암묵적 정규화
| 구간 | 일어나는 일 | 실무 함의 |
|---|---|---|
| 파라미터 ≪ 데이터 | 교과서 편향-분산 U 자 | 정규화가 잘 듣는다 |
| 파라미터 ≈ 데이터 | 훈련 오차 0 을 만드는 해가 딱 하나 — 잡음까지 맞춤 | 가장 나쁜 자리 · 피할 것 |
| 파라미터 ≫ 데이터 | 훈련 오차 0 인 해가 무수히 많고, SGD 가 그중 노름이 작은 쪽을 고름 | 키울수록 좋아질 수 있다 |
고전 모델 — 정형 데이터에서는 아직도 이쪽이 이깁니다
표 형태 데이터에서 신경망이 트리 앙상블을 못 이긴다는 결과는 2026년에도 유효합니다. 결정 트리 · 배깅 · 부스팅 · SVM 이 각각 가설 공간을 어떻게 정의하는지를 보면 왜 그런지가 설명됩니다.
어디를 자를지 — 불순도
import numpy as np
def gini(y):
_, cnt = np.unique(y, return_counts=True)
p = cnt / len(y)
return 1 - (p ** 2).sum()
def best_split(X, y):
base, best = gini(y), None
for j in range(X.shape[1]): # 모든 특성에 대해
for t in np.unique(X[:, j]): # 모든 임계값 후보를
L = X[:, j] <= t
if L.sum() == 0 or L.sum() == len(y):
continue
child = (L.mean() * gini(y[L]) +
(1 - L.mean()) * gini(y[~L]))
gain = base - child # 정보 이득
if best is None or gain > best[0]:
best = (gain, j, t)
return best # 탐욕적 — 지금 한 번의 이득만 본다
# 순수한 잎(한 클래스만): gini = 0
# 반반 섞인 잎: gini = 0.5
단일 트리의 치명적 약점
# 데이터를 5% 만 바꿔도 뿌리 분할이 통째로 바뀔 수 있다
# → 트리 구조가 완전히 달라진다 = 분산이 매우 크다 (04절)
# 깊이를 제한하지 않으면
tree = DecisionTree(max_depth=None)
tree.fit(X, y) # 잎마다 샘플 1개 → 훈련 정확도 100%
# 검증 정확도는 폭락
# 제어 손잡이
# max_depth : 질문 횟수 상한
# min_samples_leaf : 잎에 최소 몇 개는 있어야
# min_impurity_decrease : 이득이 이만큼은 되어야 자름
# ccp_alpha : 비용 복잡도 가지치기 (사후 pruning)
핵심 개념 — 배깅과 부스팅 — 같은 트리, 반대의 목적 🟢 기초
랜덤 포레스트 — 배깅 + 특성 무작위화
def bootstrap(n, rng):
return rng.integers(0, n, n) # 중복 허용 복원 추출
def random_forest(X, y, n_trees=200, m_feat=None, seed=0):
rng = np.random.default_rng(seed)
m = m_feat or int(np.sqrt(X.shape[1])) # ★ 분류: √d · 회귀: d/3
trees = []
for _ in range(n_trees):
idx = bootstrap(len(y), rng)
cols = rng.choice(X.shape[1], m, replace=False) # 단순화: 트리마다 한 번 (진짜 RF 는 분할마다 새로 뽑음)
trees.append((cols, fit_tree(X[idx][:, cols], y[idx])))
return trees
# 표본만 흔들면 트리들이 서로 너무 닮는다 (강한 특성 하나가 늘 뿌리를 차지)
# → 분할마다 특성 일부만 후보로 두어 '억지로 다르게' 만든다.
# 평균의 분산은 상관계수 ρ 에 좌우된다: Var ≈ ρσ² + (1-ρ)σ²/T
# → T 를 늘려도 ρ 가 크면 안 줄어든다. 그래서 ρ 를 낮추는 것이 핵심.
# OOB 점수: 뽑히지 않은 약 37% 로 공짜 검증이 된다 ((1-1/n)^n → 1/e)
그래디언트 부스팅 — 잔차가 곧 기울기
def gbm(X, y, rounds=200, eta=0.1, depth=3):
F = np.full(len(y), y.mean()) # 상수부터 시작
trees = []
for _ in range(rounds):
r = y - F # 잔차 = 음의 기울기
h = fit_tree(X, r, max_depth=depth)
F = F + eta * h.predict(X) # ★ eta 로 조금씩만 반영
trees.append(h)
return trees
# eta(학습률)가 작을수록 라운드를 늘려야 하지만 일반화가 좋아진다.
# 함수 공간에서의 경사하강 — 09절의 최적화와 정확히 같은 아이디어다.
한 단계 더 — SVM 과 커널 트릭 🟡 중급
최대 마진 — 경계에서 가장 멀리
# 힌지 손실로 쓰면 경사하강으로도 풀린다
def hinge_loss(X, y, w, C=1.0): # y ∈ {-1, +1}
margin = y * (X @ w)
return 0.5 * w @ w + C * np.maximum(0, 1 - margin).mean()
# 로지스틱 회귀와의 차이
# · 로지스틱: 모든 점이 조금씩 손실에 기여 (멀리 있어도 0 아님)
# · SVM : 마진 밖의 점은 손실 0 → 경계 근처 '서포트 벡터'만이 답을 정한다
# → 이상치가 경계에서 멀면 SVM 은 아예 무시한다
커널 — 고차원으로 올리되 올리지 않기
# RBF 커널: 무한 차원으로 올린 것과 같은 효과를 O(d) 에 계산
def rbf(A, B, gamma=0.5):
d2 = ((A[:, None, :] - B[None, :, :]) ** 2).sum(-1)
return np.exp(-gamma * d2)
# 커널 고르기
# linear : 특성이 이미 많고 희소할 때 (텍스트)
# poly(d) : 상호작용 항을 명시적으로 원할 때
# rbf : 기본값. gamma 가 크면 경계가 구불구불해진다(과적합)
# 한계: 커널 행렬이 n×n 이다.
# n = 10만 → 100억 칸 = 80GB. 그래서 대규모 데이터에는 못 쓴다.
실무에선 이렇게 — k-NN · 나이브 베이즈, 그리고 모델 고르기 🔴 실무
학습이 없는 모델과 가장 단순한 모델
# k-NN: '학습'이 데이터를 저장하는 것뿐 (게으른 학습)
def knn_predict(X_tr, y_tr, x, k=5):
d = np.linalg.norm(X_tr - x, axis=1) # 전부와 거리 계산
idx = np.argpartition(d, k)[:k]
vals, cnt = np.unique(y_tr[idx], return_counts=True)
return vals[cnt.argmax()]
# 학습 O(1) · 추론 O(n·d) — 서비스에서 뒤집힌 비용 구조
# 고차원에서 거리가 무너지는 문제는 ➗ 선형대수 탭 01 참고
# 나이브 베이즈: 특성들이 서로 독립이라고 '뻔뻔하게' 가정
# P(y|x) ∝ P(y) · Π P(x_j | y)
# 가정이 틀려도 '어느 클래스가 큰가'의 순위는 자주 맞는다.
# 스팸 필터의 고전 — 유도는 ➗ 선형대수 · 확률 탭 08 베이즈 정리에 있습니다.
log_post = np.log(prior) + np.log(likelihood).sum(axis=1) # 로그로 더한다
정형 데이터에서 트리 앙상블이 강한 이유
| 정형 데이터의 성질 | 트리가 잘 맞는 이유 |
|---|---|
| 특성마다 단위가 다름 | 임계값 비교뿐 — 스케일 불변 |
| 범주형 · 결측값 섞임 | 분기로 자연스럽게 처리 |
| 계단식·비매끄러운 관계 | 트리의 가설 공간 그 자체 |
| 특성 간 상호작용 | 깊이로 자동 표현 |
| 회전에 의미 없음 | 신경망은 회전 불변을 학습에 낭비 |
| 데이터가 수천~수십만 건 | 딥러닝이 힘을 못 씀 |
비지도 · 차원 축소 — 정답 없이 구조를 찾기
정답 라벨이 없을 때 할 수 있는 일은 “비슷한 것끼리 묶기”와 “중요한 축만 남기기” 두 가지입니다. 둘 다 “무엇이 비슷한가”를 사람이 미리 정해야 한다는 함정을 안고 있습니다.
직접 짜 보는 k-means
def kmeans(X, k=3, iters=100, seed=0):
rng = np.random.default_rng(seed)
C = X[rng.choice(len(X), k, replace=False)] # k-means++ 가 더 좋다
for _ in range(iters):
d = ((X[:, None, :] - C[None, :, :]) ** 2).sum(-1)
lab = d.argmin(1) # ② 할당
newC = np.stack([X[lab == j].mean(0) if (lab == j).any() else C[j]
for j in range(k)]) # ③ 이동
if np.allclose(newC, C):
break
C = newC
return C, lab
# 정하고 들어가야 하는 것
# · k : 몇 개로 나눌지 (데이터는 안 알려 준다)
# · 거리 : 유클리드 → 구형 군집만 잘 찾는다
# · 초기값 : k-means++ 로 서로 멀게 뽑으면 지역 최소를 크게 줄인다
k 를 고르는 법과 다른 군집 알고리즘
| 방법 | 보는 것 | 주의 |
|---|---|---|
| 엘보 | SSE 가 꺾이는 지점 | 대개 꺾이지 않습니다 |
| 실루엣 | 군집 내 vs 이웃 군집 거리 | −1~1 · 0.5 이상이면 뚜렷 |
| 계층 군집 | 덴드로그램을 잘라 k 결정 | O(n²~n³) · 소규모만 |
| DBSCAN | 밀도 — k 가 필요 없음 | eps 를 골라야 함 · 잡음 분리 |
| GMM (EM) | 확률적 소속 — 부드러운 할당 | 공분산까지 추정 |
핵심 개념 — PCA — 분산이 가장 큰 축부터 🟢 기초
고유값 분해로 유도하기
Xc = X - X.mean(0) # ① 중심화 (필수)
# 스케일이 다르면 표준화까지 (분산이 큰 특성이 축을 독점하므로)
U, S, Vt = np.linalg.svd(Xc, full_matrices=False) # ② SVD 로 바로
comps = Vt[:k] # 주성분 방향 (k, d)
Z = Xc @ comps.T # ③ 저차원 좌표 (n, k)
ratio = (S ** 2) / (S ** 2).sum() # 설명된 분산 비율
X_rec = Z @ comps + X.mean(0) # ④ 복원 — 손실 압축이 된다
# 왜 eig(XᵀX) 대신 svd(X) 인가
# · XᵀX 를 만들면 조건수가 제곱된다 (수치 오차 증폭)
# · SVD 는 X 에서 직접 — 더 안정적. ➗ 선형대수 탭 15 참고
PCA 를 잘못 쓰는 자리
| 오해 | 사실 |
|---|---|
| “차원 축소하면 성능이 오른다” | 분산이 큰 축이 예측에 유용한 축은 아닙니다 |
| “노이즈가 제거된다” | 신호가 작으면 신호가 먼저 잘립니다 |
| “표준화는 선택” | 단위가 다르면 필수 — 안 하면 큰 단위가 독점 |
| “비선형 구조도 잡는다” | PCA 는 선형 투영뿐입니다 |
| “해석 가능한 축이 나온다” | 주성분은 원래 특성들의 혼합입니다 |
한 단계 더 — t-SNE · UMAP — 예쁜 그림의 함정 🟡 중급
이웃 관계만 지키고 나머지는 버립니다
| 그림에서 본 것 | 믿어도 되나 | 이유 |
|---|---|---|
| 덩어리가 나뉘어 있다 | 대체로 예 | 지역 이웃 구조는 보존됩니다 |
| 덩어리 사이 거리 | 아니오 | 전역 거리는 보존 대상이 아닙니다 |
| 덩어리의 크기 | 아니오 | 밀도에 따라 임의로 부풀거나 줄어듭니다 |
| 덩어리의 모양 | 아니오 | perplexity 값에 따라 통째로 바뀝니다 |
| 점 하나의 위치 | 아니오 | 실행할 때마다 달라집니다 (확률적) |
# t-SNE: 고차원의 이웃 확률 P 와 저차원의 이웃 확률 Q 의 KL 발산을 최소화
# 저차원에서는 t 분포(꼬리가 두꺼움)를 써서 '밀집 문제'를 푼다
# UMAP: 위상수학 기반 · 훨씬 빠르고 전역 구조를 조금 더 지킨다
# 반드시 지킬 것
# ① 여러 perplexity(5, 30, 50)로 그려 보고 공통된 것만 믿는다
# ② 축에 눈금을 넣지 않는다 (의미가 없다)
# ③ 학습 파이프라인의 전처리로 쓰지 않는다 — 새 점을 넣을 수 없다
# (UMAP 은 transform 이 있지만 t-SNE 는 원리상 불가)
k 를 정하는 것입니다.
t-SNE 는 구조가 없는 무작위 데이터에서도 덩어리를 만들어 냅니다.
군집 수는 실루엣 같은 원래 공간의 지표로 정해야 합니다.
실무에선 이렇게 — 이상 탐지 — 정답이 거의 없는 문제 🔴 실무
세 가지 접근
# ① 통계: 정상 분포를 세우고 꼬리를 자른다
z = (x - mu) / sd
outlier = np.abs(z) > 3 # 정규분포 가정 — 꼬리가 두꺼우면 오탐 폭발
# 로버스트 버전: 중앙값과 MAD 사용
mad = np.median(np.abs(x - np.median(x)))
robust_z = 0.6745 * (x - np.median(x)) / mad
# ② 거리/밀도: 이웃이 멀면 이상 (LOF · DBSCAN 의 잡음점)
# ③ 고립: Isolation Forest — 무작위 분할로 '빨리 고립되면' 이상
# 이상치는 성겨서 몇 번만 잘라도 혼자 남는다 → 평균 경로 길이가 짧다
# ④ 재구성 오차: 오토인코더/PCA 로 압축했다 복원했을 때 오차가 크면 이상
err = ((X - X_rec) ** 2).sum(1)
현장에서 실제로 어려운 것
| 함정 | 대응 |
|---|---|
| 정상 패턴이 시간에 따라 변함 | 슬라이딩 윈도로 기준 갱신 |
| 계절성(주말·야간) | 시간대별로 따로 모델링 |
| 이상이 여러 종류 | 한 모델로 다 잡으려 하지 않기 |
| 알림 피로 | 점수 상위 N 건만 · 묶어서 보고 |
| 라벨이 생기기 시작함 | 지도 학습으로 전환(훨씬 강함) |
신경망 기초 — 비선형이 없으면 100층도 한 층입니다
신경망은 “행렬 곱 → 비선형 → 행렬 곱 → 비선형 …”의 반복일 뿐입니다. 그런데 그 사이의 비선형 하나를 빼면 전체가 무너집니다. 왜 그런지, 그리고 층을 쌓으면 정확히 무엇이 늘어나는지를 봅니다.
퍼셉트론에서 다층으로
# XOR — 1969년 신경망 첫 겨울을 부른 문제
X = np.array([[0,0], [0,1], [1,0], [1,1]])
y = np.array([0, 1, 1, 0])
# 직선 하나로는 절대 못 가른다. 종이에 그려 보면 바로 보인다.
# 그런데 은닉층 하나에 뉴런 두 개만 넣으면 풀린다:
# h1 = OR(x1, x2) h2 = AND(x1, x2)
# y = AND(h1, NOT h2) = XOR
W1 = np.array([[1, 1], [1, 1]]); b1 = np.array([-0.5, -1.5])
W2 = np.array([1, -2.0]); b2 = -0.5
h = (X @ W1.T + b1 > 0).astype(float) # 계단 활성화
out = (h @ W2 + b2 > 0).astype(float) # [0, 1, 1, 0] ✓
# 은닉층이 하는 일: 원래 공간에서 못 가르던 것을
# '가를 수 있는 새 좌표계'로 옮긴다.
x², x₁x₂ 같은 변환 자체를
데이터에서 찾아냅니다.
보편 근사 정리 — 그리고 그 한계
| 말해 주지 않는 것 | 현실 |
|---|---|
| 뉴런이 몇 개 필요한가 | 차원에 대해 지수적일 수 있습니다 |
| 그 가중치를 찾을 수 있는가 | 존재 증명일 뿐 · 최적화는 별개 |
| 보지 못한 데이터에서도? | 근사는 훈련 구간 안에서만 |
핵심 개념 — 활성화 함수 — 무엇을 고를 것인가 🟢 기초
함수와 도함수
import numpy as np
relu = lambda z: np.maximum(0, z)
d_relu = lambda z: (z > 0).astype(float) # 0 또는 1 — 곱해도 안 줄어듦
leaky = lambda z, a=0.01: np.where(z > 0, z, a * z)
d_leaky = lambda z, a=0.01: np.where(z > 0, 1.0, a)
sigmoid = lambda z: 1 / (1 + np.exp(-z))
d_sigmoid = lambda z: sigmoid(z) * (1 - sigmoid(z)) # 최댓값 0.25 — 층마다 1/4 이하
def gelu(z): # tanh 근사식
return 0.5 * z * (1 + np.tanh(np.sqrt(2/np.pi) * (z + 0.044715 * z**3)))
def silu(z): return z * sigmoid(z) # Swish
def swiglu(x, W, V): return silu(x @ W) * (x @ V) # 게이트 두 갈래
# 시그모이드 10층: 0.25^10 ≈ 0.00000095 → 기울기가 사실상 0
# ReLU 10층: 1^10 = 1 → 그대로 전달
언제 무엇을
| 함수 | 성질 | 쓰는 곳 |
|---|---|---|
| ReLU | 단순 · 빠름 · 죽은 뉴런 위험 | CNN 기본값 |
| Leaky / PReLU | 음수도 조금 통과 | 죽은 뉴런이 실제로 보일 때 |
| GELU | 부드러움 · 확률적 해석 | 트랜스포머 표준 |
| SiLU (Swish) | GELU 와 거의 동등 | 비전 · 경량 모델 |
| SwiGLU | 게이트 두 갈래 · 파라미터 1.5배 | 최신 LLM 피드포워드 |
| tanh | 원점 대칭 · 포화 | RNN 내부 · 소규모 |
| sigmoid | 포화 심함 | 출력층에만 |
한 단계 더 — 죽은 ReLU 와 층 폭 설계 🟡 중급
죽은 뉴런 — 영원히 0 만 내보내는 유닛
# z 가 항상 음수면 relu(z)=0, d_relu(z)=0
# → 기울기 0 → 가중치가 갱신되지 않음 → 영원히 0
# 큰 학습률로 한 번 크게 밀려나면 회복 불가능하다.
# 진단: 한 배치에서 활성값이 0 인 비율을 층별로 찍어 본다
for name, h in activations.items():
dead = (h <= 0).all(axis=0).mean()
print(f"{name:10s} 죽은 유닛 {dead:.1%}")
# fc1 죽은 유닛 3.2% ← 정상 범위
# fc3 죽은 유닛 61.4% ← 문제. 학습률·초기화·정규화를 의심
# 대책: 학습률 낮추기 · GELU/Leaky 로 교체 · 정규화 층 추가
폭과 깊이를 정하는 감각
| 상황 | 먼저 시도할 구조 |
|---|---|
| 정형 데이터 (특성 50개) | 은닉 2~3층 · 폭 64~256 (대개 GBM 이 낫습니다) |
| 이미지 | CNN 또는 ViT — 손수 MLP 는 비효율(10절) |
| 텍스트 · 시퀀스 | 트랜스포머(11절) |
| 과소적합 중 | 폭을 먼저 늘림 (가장 안전) |
| 여전히 부족 | 깊이 · 잔차 연결 · 정규화 층 |
실무에선 이렇게 — MLP 를 처음부터 짜 보기 🔴 실무
NumPy 40줄이면 2층 신경망이 돕니다
import numpy as np
rng = np.random.default_rng(0)
def init(d_in, d_h, d_out):
# He 초기화 — ReLU 용. 이유는 08절에서
W1 = rng.normal(0, np.sqrt(2 / d_in), (d_in, d_h))
W2 = rng.normal(0, np.sqrt(2 / d_h), (d_h, d_out))
return [W1, np.zeros(d_h), W2, np.zeros(d_out)]
def forward(P, X):
W1, b1, W2, b2 = P
Z1 = X @ W1 + b1
H = np.maximum(0, Z1) # ReLU
Z2 = H @ W2 + b2
P2 = np.exp(Z2 - Z2.max(1, keepdims=True))
P2 /= P2.sum(1, keepdims=True) # softmax
return Z1, H, P2
def backward(P, X, y, cache): # 유도는 08절
W1, b1, W2, b2 = P
Z1, H, Pr = cache
n = len(y)
dZ2 = Pr.copy(); dZ2[np.arange(n), y] -= 1; dZ2 /= n
dW2, db2 = H.T @ dZ2, dZ2.sum(0)
dH = dZ2 @ W2.T
dZ1 = dH * (Z1 > 0) # ReLU 도함수
dW1, db1 = X.T @ dZ1, dZ1.sum(0)
return [dW1, db1, dW2, db2]
P = init(X.shape[1], 64, 10)
for step in range(3000):
cache = forward(P, X)
G = backward(P, X, y, cache)
for p, g in zip(P, G):
p -= 0.1 * g # 09절에서 이 줄을 Adam 으로 바꿉니다
backward 를 자동으로 만들어 주는 것(08절)과
GPU 로 돌리는 것(➗ 선형대수 탭 02 · 🖥️ 컴퓨터구조 탭 10)뿐입니다.
역전파 — 연쇄 법칙을 계산 그래프로 돌리기
역전파는 새로운 수학이 아니라 고등학교 연쇄 법칙을 “중간 결과를 재사용하는 순서”로 정리한 것입니다. 한 번 손으로 굴려 보면 자동 미분이 무엇을 자동화하는지도 함께 보입니다.
손으로 한 번 굴려 보기
x, w, b, y = 2.0, 3.0, 1.0, 1.0
# --- 순전파 (값을 저장해 둔다. 역전파에서 필요하다) ---
u = w * x # 6.0
v = u + b # 7.0
p = 1 / (1 + np.exp(-v)) # 0.99909
L = -np.log(p) # 0.00091
# --- 역전파 (오른쪽에서 왼쪽으로) ---
dL_dp = -1 / p # -1.00091
dp_dv = p * (1 - p) # 0.00091
dL_dv = dL_dp * dp_dv # -0.00091 ← 결국 (p - y) 다
dL_du = dL_dv * 1 # + 노드: 그대로 통과
dL_db = dL_dv * 1 # -0.00091
dL_dw = dL_du * x # × 노드: 상대편 입력을 곱한다 → -0.00182
dL_dx = dL_du * w # -0.00273
# 검산 — 수치 미분과 맞춰 본다
eps = 1e-6
num = (loss(w + eps) - loss(w - eps)) / (2 * eps)
assert abs(num - dL_dw) < 1e-7 # ✓ 구현 검증의 표준 방법
1e-7 보다 크면 어딘가 틀렸습니다.
노드별 역전파 규칙
| 순전파 | 역전파로 넘기는 것 |
|---|---|
c = a + b | a, b 모두에 δ 그대로 (분배) |
c = a · b | a 에는 b·δ, b 에는 a·δ (교환) |
c = max(a, b) | 이긴 쪽에만 δ, 진 쪽은 0 (라우팅) |
C = A @ B | dA = δ @ B.T, dB = A.T @ δ |
| 같은 값을 두 곳에 사용 | 두 경로의 기울기를 더한다 |
| 브로드캐스트(편향) | 퍼진 축으로 합산 |
핵심 개념 — 자동 미분 — 정방향 모드와 역방향 모드 🟢 기초
왜 '역방향'인가
| 정방향 모드 | 역방향 모드 | |
|---|---|---|
| 한 번에 얻는 것 | 입력 1개에 대한 모든 출력의 미분 | 출력 1개에 대한 모든 입력의 미분 |
| 비용 | 입력 수 × 순전파 | 출력 수 × 순전파 |
| 메모리 | 거의 없음 | 중간값 전부 저장 |
| 딥러닝은? | 입력(가중치) 10억 개 | 출력(손실) 1개 ✓ |
30줄짜리 자동 미분
class V: # 스칼라 자동 미분 (micrograd 식)
def __init__(self, d, parents=(), back=lambda: None):
self.d, self.grad = d, 0.0
self._parents, self._back = parents, back
def __add__(self, o):
o = o if isinstance(o, V) else V(o)
out = V(self.d + o.d, (self, o))
def back():
self.grad += out.grad # 분배 (그대로)
o.grad += out.grad
out._back = back; return out
def __mul__(self, o):
o = o if isinstance(o, V) else V(o)
out = V(self.d * o.d, (self, o))
def back():
self.grad += o.d * out.grad # 교환 (상대편 값)
o.grad += self.d * out.grad
out._back = back; return out
def backward(self):
order, seen = [], set()
def build(v): # 위상 정렬 (🔀 알고리즘 탭 06)
if v in seen: return
seen.add(v)
for p in v._parents: build(p)
order.append(v)
build(self)
self.grad = 1.0
for v in reversed(order): # 역순으로 국소 미분 적용
v._back()
a, b = V(2.0), V(3.0)
c = a * b + a # c = ab + a
c.backward()
a.grad, b.grad # (4.0, 2.0) ← b+1, a
한 단계 더 — 기울기 소실과 폭발 🟡 중급
해법 ① 초기화 — 분산을 유지하도록
# 전부 0 으로 초기화하면? — 모든 뉴런이 똑같이 갱신되어 층 하나와 같아진다 (대칭 파괴 실패)
# 너무 크게 하면? — 활성값이 폭주하거나 시그모이드가 포화한다
# 너무 작게 하면? — 신호가 층을 지나며 0 으로 수렴한다
W = rng.normal(0, np.sqrt(2 / n_in), (n_in, n_out)) # He (ReLU 계열)
W = rng.normal(0, np.sqrt(1 / n_in), (n_in, n_out)) # Xavier (tanh/sigmoid)
b = np.zeros(n_out) # 편향은 0 으로 충분
# 확인법: 각 층 활성값의 표준편차를 찍어 본다. 층을 지나며
# 계속 줄거나 늘면 초기화가 문제다.
해법 ② 정규화 층 · ③ 잔차 연결
# 배치 정규화 — 배치 축으로 정규화 (CNN)
mu, var = Z.mean(0), Z.var(0)
Zn = (Z - mu) / np.sqrt(var + 1e-5)
out = gamma * Zn + beta # 학습 가능한 스케일·이동
# 층 정규화 — 특징 축으로 정규화 (트랜스포머 · RNN)
mu, var = Z.mean(-1, keepdims=True), Z.var(-1, keepdims=True)
Zn = (Z - mu) / np.sqrt(var + 1e-5)
# 배치 크기에 의존하지 않아 시퀀스·추론에 유리하다 → 11절
# 잔차 연결 — 그냥 더한다
out = layer(x) + x
# 역전파에서: d(out)/dx = d(layer)/dx + 1
# ↑ 이 '+1' 이 기울기의 지름길이다. 층을 아무리 쌓아도 1 이 남는다.
# ResNet(2015)이 152층을 학습시킨 비결이고, 트랜스포머의 모든 블록에 들어 있다.
g ← g · min(1, θ/‖g‖). RNN 과 대형 언어 모델 학습에서
손실이 갑자기 NaN 이 되는 사고를 막는 표준 장치입니다.
실무에선 이렇게 — 메모리와 재계산 🔴 실무
역전파가 메모리를 먹는 이유와 거래 조건
# 역전파는 '순전파의 중간값'을 전부 들고 있어야 한다.
# 메모리 ≈ 층 수 × 배치 크기 × 활성값 크기
# → OOM 의 대부분은 파라미터가 아니라 '활성값' 때문이다.
# 거래 ① 기울기 체크포인팅: 일부 층만 저장하고 나머지는 역전파 때 다시 계산
# 메모리 O(L) → O(√L), 연산은 약 1.3배
# 거래 ② 기울기 누적: 작은 배치를 여러 번 돌려 기울기를 더한 뒤 한 번 갱신
for i, batch in enumerate(loader):
loss = compute(batch) / accum
loss.backward() # 기울기가 누적된다
if (i + 1) % accum == 0:
step(); zero_grad() # 큰 배치와 동등한 효과
# 거래 ③ 혼합 정밀도: 활성값을 bf16 으로 → 메모리 절반, 속도 향상
# 누적은 fp32 로 (➗ 선형대수 탭 12·13 의 부동소수점 문제)
| 증상 | 원인 | 손댈 곳 |
|---|---|---|
| OOM (메모리 부족) | 활성값 저장 | 배치↓ · 체크포인팅 · bf16 |
| 손실이 NaN | 기울기 폭발 · log(0) | 클리핑 · eps · 학습률↓ |
| 손실이 안 줄어듦 | 기울기 소실 · 학습률 문제 | 정규화 층 · 초기화 · 09절 |
| 훈련만 잘됨 | 과적합 | 04절 |
| 기울기가 전부 0 | 죽은 ReLU · 상수 출력 | 07절 · 초기화 |
최적화 — Adam 이 고친 것을 하나씩 거슬러 올라가기
옵티마이저는 유행이 아니라 앞의 방법이 못 하던 것을 하나씩 메운 계보입니다. SGD → 모멘텀 → RMSProp → Adam → AdamW 로 오면서 각 단계가 정확히 무슨 문제를 고쳤는지를 알면 고를 때 헤매지 않습니다.
계보 — 무엇을 고쳤나
| 방법 | 추가한 것 | 고친 문제 |
|---|---|---|
| 배치 GD | — | — |
| SGD (미니배치) | 표본으로 기울기 추정 | 큰 데이터에서의 속도 |
| 모멘텀 | 과거 기울기의 관성 | 골짜기 진동 · 얕은 지역 최소 |
| Nesterov | 한 발 내다본 기울기 | 과속 시 미리 감속 |
| AdaGrad | 기울기² 누적으로 나눔 | 희소 특성의 학습률 |
| RMSProp | 누적 대신 지수 이동평균 | AdaGrad 의 학습률 고사 |
| Adam | 모멘텀 + RMSProp + 편향 보정 | 둘을 동시에 |
| AdamW | 가중치 감쇠를 분리 | Adam 의 L2 왜곡 |
한 화면에 넣은 구현
import numpy as np
# st(상태 dict)는 파라미터마다 따로 만들어 넘깁니다 — 기본값 {} 는 모든 호출이 공유하는 파이썬 함정이라
# 생략하면 여러 파라미터의 m·v 가 한 통에 섞입니다. 아래 기본값은 지면을 줄이려는 것뿐입니다.
def sgd(w, g, lr=0.1, st={}):
return w - lr * g
def momentum(w, g, lr=0.1, beta=0.9, st={}):
st['v'] = beta * st.get('v', 0) + g # 관성 누적
return w - lr * st['v']
def rmsprop(w, g, lr=0.01, beta=0.99, eps=1e-8, st={}):
st['s'] = beta * st.get('s', 0) + (1 - beta) * g**2
return w - lr * g / (np.sqrt(st['s']) + eps) # 축마다 보폭 조절
def adam(w, g, lr=1e-3, b1=0.9, b2=0.999, eps=1e-8, st={}):
st['t'] = st.get('t', 0) + 1
st['m'] = b1 * st.get('m', 0) + (1 - b1) * g # 1차 모멘트 (방향)
st['v'] = b2 * st.get('v', 0) + (1 - b2) * g**2 # 2차 모멘트 (크기)
mh = st['m'] / (1 - b1 ** st['t']) # ★ 편향 보정
vh = st['v'] / (1 - b2 ** st['t']) # 초기 0 편향을 제거
return w - lr * mh / (np.sqrt(vh) + eps)
def adamw(w, g, lr=1e-3, wd=0.01, **kw):
return adam(w, g, lr=lr, **kw) - lr * wd * w # 감쇠를 '따로' 뺀다
m, v 를 0 에서 시작하면
초기 몇 스텝은 실제보다 작게 추정됩니다. 1−β^t 로 나누면
t 가 작을 때 크게 부풀려 이를 상쇄합니다.
핵심 개념 — AdamW — L2 정규화와 가중치 감쇠는 다릅니다 🟢 기초
Adam 에서는 두 가지가 같은 것이 아닙니다
# 왜 Adam 에서 달라지나
# Adam 은 기울기를 √v 로 나눈다.
# L2 항 λw 를 기울기에 섞으면 그 항도 함께 √v 로 나뉜다.
# → 기울기가 큰 파라미터일수록 정규화가 '약하게' 걸린다 (의도와 반대!)
# AdamW: 감쇠를 적응적 스케일링 밖으로 빼낸다
w -= lr * m_hat / (np.sqrt(v_hat) + eps) # 적응적 갱신
w -= lr * wd * w # ★ 감쇠는 순수하게 별도로
# 실무 영향: 트랜스포머 학습에서 AdamW 가 표준이 된 이유가 이것이다.
# 같은 wd 값이라도 Adam+L2 와 AdamW 는 다른 결과를 낸다.
| 상황 | 기본 설정 | 비고 |
|---|---|---|
| 트랜스포머 · LLM | AdamW · lr 1e-4~3e-4 · wd 0.1 | β₂ 0.95 로 낮추기도 |
| CNN 이미지 분류 | SGD + 모멘텀 0.9 · wd 5e-4 | 최종 성능이 Adam 보다 좋은 경우가 많음 |
| 소규모 · 빠른 시제품 | Adam · lr 1e-3 | 튜닝 없이도 대체로 동작 |
| 희소 임베딩 | Adagrad 계열 | 드물게 나타나는 특성에 유리 |
| 미세조정 | AdamW · lr 1e-5~5e-5 | 사전학습 지식을 지우지 않도록 작게 |
gamma·beta 와
편향까지 0 으로 밀면 성능이 떨어집니다. 실무 구현은 파라미터를 두 그룹으로 나눠
한쪽에만 weight_decay 를 겁니다. 04절의 “절편에 벌점 금지”와 같은 이야기입니다.
한 단계 더 — 학습률 스케줄 — 워밍업과 코사인 🟡 중급
스케줄 구현
import math
def lr_at(step, base=3e-4, warmup=2000, total=100_000, min_ratio=0.1):
if step < warmup:
return base * step / warmup # 선형 워밍업
t = (step - warmup) / max(1, total - warmup) # 0 → 1
cos = 0.5 * (1 + math.cos(math.pi * t)) # 1 → 0
return base * (min_ratio + (1 - min_ratio) * cos)
# 흔히 쓰는 다른 모양
# · step decay : 30 에폭마다 ×0.1 (CNN 고전)
# · cosine restart : 주기적으로 다시 올림 (SGDR) — 여러 최소점 탐색
# · one-cycle : 올렸다 크게 내림 — 짧은 학습에서 강력
# · inverse sqrt : 1/√step — 원조 트랜스포머 논문
# 학습률 탐색(LR range test): 아주 작은 값부터 지수적으로 올리며
# 한 에폭 돌려 손실이 최저로 꺾이는 지점의 1/3 을 base 로 쓴다.
배치 크기와 일반화
| 배치를 키우면 | 일어나는 일 |
|---|---|
| 기울기 추정 | 잡음이 줄어 정확해짐 |
| 하드웨어 | GPU 활용률 상승 · 스텝당 처리량 증가 |
| 스텝 수 | 같은 에폭에 감소 → 갱신 기회가 줄어듦 |
| 일반화 | 대체로 악화 (뾰족한 최소로 수렴) |
| 필요한 학습률 | 선형 스케일링 규칙: 배치 k배 → lr k배 |
실무에선 이렇게 — 손실 지형 — 안장점과 평평한 최소 🔴 실무
고차원에서는 “지역 최소에 갇히는” 일이 드뭅니다
| 임계점 종류 | 조건 | 고차원에서 |
|---|---|---|
| 지역 최소 | 모든 방향이 위로 | 매우 드묾 (d개 방향이 전부 맞아야) |
| 안장점 | 일부는 위 · 일부는 아래 | 압도적으로 많음 |
| 평평한 영역 | 기울기가 거의 0 | 실질적인 정체 구간 |
| 지역 최대 | 모든 방향이 아래로 | 사실상 없음 |
# 안장점이 왜 압도적인가 (직관)
# d 차원에서 임계점의 종류는 헤시안 고유값의 부호 조합으로 정해진다.
# 무작위라면 d 개가 '전부 양수'일 확률은 대략 2^-d — d=1000 이면 사실상 0.
# → 갇히는 것이 아니라 '느려지는 것'이 진짜 문제다.
# 모멘텀이 안장점을 잘 빠져나오는 이유:
# 기울기가 0 이어도 관성 v 가 남아 있어 계속 굴러간다.
# 평평한 최소 vs 뾰족한 최소
# 평평 : 가중치를 조금 흔들어도 손실이 잘 안 오름 → 일반화 좋음(경향)
# 뾰족 : 조금만 흔들어도 손실 급증 → 시험 분포가 살짝 달라지면 무너짐
w_pert = w + rng.normal(0, 0.01, w.shape)
print(loss(w_pert) - loss(w)) # 이 값이 작을수록 '평평한' 최소
# SAM(Sharpness-Aware Minimization)은 이 값 자체를 손실에 넣어 최소화한다
CNN · 시퀀스 — 구조를 미리 알려 주면 데이터가 덜 듭니다
완전 연결 층으로도 이미지를 처리할 수는 있지만, 파라미터가 폭발하고 학습이 안 됩니다. 합성곱과 순환 구조는 “이 데이터에는 이런 규칙성이 있다”는 사전 지식을 구조에 새겨 넣은 것입니다. 그것이 귀납 편향(inductive bias)입니다.
파라미터 수 비교
# 224×224 컬러 이미지를 은닉 1000개 층에 완전 연결로 넣으면
224 * 224 * 3 * 1000 # = 150,528,000 개 (1억 5천만)
# 같은 이미지에 3×3 커널 64개를 쓰는 합성곱 층은
3 * 3 * 3 * 64 + 64 # = 1,792 개
# 8만 배 차이. 그런데 성능은 CNN 이 훨씬 좋다.
# 왜 적은데 더 좋은가 — 가설 공간을 '옳은 방향으로' 좁혔기 때문이다.
# · 이웃 픽셀끼리 관련 있다 (지역성)
# · 왼쪽 위의 모서리 검출기는 오른쪽 아래에서도 유용하다 (공유)
# 이 두 가정이 이미지에서는 참이므로, 버린 가설들은 어차피 쓸모없었다.
def conv2d(X, K): # 개념 확인용 (실제로는 im2col + GEMM)
kh, kw = K.shape
H, W = X.shape[0] - kh + 1, X.shape[1] - kw + 1
out = np.empty((H, W))
for i in range(H):
for j in range(W):
out[i, j] = (X[i:i+kh, j:j+kw] * K).sum()
return out
풀링과 수용 영역
| 요소 | 하는 일 | 부작용 |
|---|---|---|
| 스트라이드 2 | 해상도 절반 · 수용 영역 2배 | 위치 정보 손실 |
| 맥스 풀링 | 가장 강한 반응만 남김 | 미세 위치 소실 |
| 평균 풀링 | 부드럽게 축소 | 강한 신호 희석 |
| 1×1 합성곱 | 채널 수 조절 · 채널 간 혼합 | 공간 정보는 그대로 |
| 확장(dilated) | 파라미터 없이 수용 영역 확대 | 격자 무늬 아티팩트 |
핵심 개념 — RNN 과 LSTM — 기억을 어떻게 들고 가나 🟢 기초
RNN 의 구조적 문제
# 바닐라 RNN — 스텝마다 같은 W 를 곱한다
h = np.zeros(d)
for x in sequence:
h = np.tanh(W_h @ h + W_x @ x + b)
# 역전파에서 시간 축을 거슬러 올라가면 (BPTT)
# dL/dh_0 = dL/dh_T · Π (W_h.T · diag(tanh'))
# → W_h 의 최대 고유값이 1보다 작으면 지수적으로 0
# 1보다 크면 지수적으로 폭발
# 100 스텝 전의 단어가 지금 예측에 영향을 못 준다 = 장기 의존성 실패
def lstm_step(x, h, c, P):
z = P['W'] @ np.concatenate([h, x]) + P['b']
f, i, g, o = np.split(z, 4)
f, i, o = sigmoid(f), sigmoid(i), sigmoid(o)
g = np.tanh(g)
c = f * c + i * g # ★ 곱하고 '더한다' — 여기가 핵심
h = o * np.tanh(c)
return h, c
# GRU 는 게이트를 3개에서 2개로 줄인 경량판 — 성능은 대체로 비슷하다.
그래도 남는 시퀀스 모델의 한계
| 한계 | 설명 |
|---|---|
| 순차 계산 | t 를 끝내야 t+1 → GPU 병렬화 불가 |
| 정보 병목 | 과거 전체를 고정 크기 h 에 압축 |
| 먼 거리 의존 | 게이트로 완화될 뿐 근본 해결은 아님 |
| 양방향 어려움 | 미래를 보려면 두 번 돌려야 |
| 경로 길이 | i 번째와 j 번째 사이 O(|i−j|) 스텝 |
실무에선 이렇게 — CNN 이 여전히 이기는 자리 🔴 실무
ViT 가 나왔는데 왜 아직 CNN 을 쓰나
| 조건 | CNN | ViT (트랜스포머) |
|---|---|---|
| 데이터 1만 장 | 강함 — 귀납 편향이 데이터를 대신함 | 약함 — 규칙을 처음부터 배워야 |
| 데이터 1억 장 | 포화 | 강함 — 편향이 없어 더 배움 |
| 고해상도 입력 | 선형에 가깝게 확장 | O(n²) 로 폭발 |
| 엣지 · 모바일 | 최적화 성숙 | 메모리 부담 |
| 지역 텍스처 문제 | 적합 | 과할 수 있음 |
# 실무 판단 기준
# · 라벨 1만 장 미만 → 사전학습 CNN(또는 ViT) 미세조정. 처음부터 학습 금지
# · 실시간 · 온디바이스 → CNN 계열 경량 모델
# · 대규모 데이터 + 멀티모달 → 트랜스포머 계열
# · 정형 데이터 → 애초에 05절(GBM)
# 데이터 증강이 CNN 에서 특히 효과적인 이유:
# 좌우 반전·크롭·색 변형은 '라벨이 바뀌지 않는 변환'을 알려 주는 것 —
# 즉 사람이 손으로 귀납 편향을 하나 더 넣어 주는 일이다 (04절).
# ※ 숫자 6과 9처럼 상하 반전이 라벨을 바꾸는 도메인에서는 독이 된다.
트랜스포머 — 한 줄짜리 식이 언어 모델을 만든 과정
어텐션은 “지금 이 토큰이 다른 토큰들을 얼마나 참고할지”를 내적 한 번으로 정하는 장치입니다. 식 자체는 짧지만, 순차성을 없애고 임의의 두 위치를 한 걸음으로 잇는다는 점에서 10절 시퀀스 모델의 두 한계를 동시에 해결합니다.
40줄로 보는 어텐션
import numpy as np
def softmax(Z):
Z = Z - Z.max(-1, keepdims=True)
e = np.exp(Z); return e / e.sum(-1, keepdims=True)
def attention(X, Wq, Wk, Wv, mask=None):
Q, K, V = X @ Wq, X @ Wk, X @ Wv # (n, d_k)
d_k = Q.shape[-1]
S = Q @ K.T / np.sqrt(d_k) # (n, n) 점수 행렬
if mask is not None:
S = np.where(mask, S, -np.inf) # 인과 마스크 → softmax 후 0
A = softmax(S) # 각 행의 합이 1
return A @ V, A # (n, d_v), (n, n)
n = 6
causal = np.tril(np.ones((n, n), dtype=bool)) # 하삼각 = 과거만 허용
# 멀티헤드 — 같은 계산을 h 개로 쪼개서 병렬로
def multihead(X, Wq, Wk, Wv, Wo, h=8):
n, d = X.shape
Q = (X @ Wq).reshape(n, h, d // h).transpose(1, 0, 2) # (h, n, d/h)
K = (X @ Wk).reshape(n, h, d // h).transpose(1, 0, 2)
V = (X @ Wv).reshape(n, h, d // h).transpose(1, 0, 2)
S = Q @ K.transpose(0, 2, 1) / np.sqrt(d // h)
O = softmax(S) @ V # (h, n, d/h)
return O.transpose(1, 0, 2).reshape(n, d) @ Wo # 다시 합쳐 섞는다
왜 √d_k 인가 — 실제로 재 보기
rng = np.random.default_rng(0)
for d in (8, 64, 512):
q, k = rng.normal(size=(2000, d)), rng.normal(size=(2000, d))
s = (q * k).sum(1)
print(d, round(float(s.std()), 1), round(float(softmax(s[:64]).max()), 3))
# 8 2.8 0.995 → 표준편차가 √d 로 커져 64개 중 최댓값이 이미 거의 독차지
# 64 7.9 0.967
# 512 22.5 0.999 → 사실상 argmax. 기울기 ≈ 0 (03절의 포화와 같다)
# √d 로 나눈 뒤 (마지막 d = 512)
s = (q * k).sum(1) / np.sqrt(d)
# 512 1.0 0.078 → 표준편차가 1 로 돌아와 분포가 부드럽다 (d = 8 · 64 에서도 std 1.0)
핵심 개념 — 어텐션 행렬과 인과 마스크 🟢 기초
위치 인코딩 — 어텐션은 순서를 모릅니다
# 어텐션은 집합 연산이다. 토큰 순서를 바꿔도 결과가 같이 섞일 뿐,
# "누가 앞이었나"를 전혀 모른다. 그래서 위치 정보를 따로 넣어야 한다.
# ① 사인/코사인 (원조 트랜스포머) — 학습하지 않는 고정값
def sinusoidal(n, d):
pos = np.arange(n)[:, None]
i = np.arange(0, d, 2)[None, :]
ang = pos / (10000 ** (i / d))
pe = np.zeros((n, d))
pe[:, 0::2], pe[:, 1::2] = np.sin(ang), np.cos(ang)
return pe # 임베딩에 '더한다'
# ② 학습형 절대 위치 — 그냥 표를 학습 (BERT · GPT-2)
# 최대 길이를 넘어가면 쓸 수 없다.
# ③ RoPE (회전 위치 인코딩) — 2026년 LLM 의 사실상 표준
# Q, K 를 위치 각도만큼 '회전'시킨다. 그러면 내적이 상대 거리에만 의존한다.
def rope(x, pos, base=10000):
d = x.shape[-1]
theta = pos[:, None] / (base ** (np.arange(0, d, 2) / d))
c, s = np.cos(theta), np.sin(theta)
x1, x2 = x[..., 0::2], x[..., 1::2]
return np.stack([x1 * c - x2 * s, x1 * s + x2 * c], -1).reshape(x.shape)
# RoPE 의 장점: 상대 위치가 자연히 표현되고, 학습 길이 밖으로 확장하기 쉽다.
# (회전 행렬 자체는 ➗ 선형대수 탭 03 의 2D 회전과 같은 것입니다)
O(n²) 를 완화하는 방법들
| 기법 | 아이디어 | 대가 |
|---|---|---|
| FlashAttention | n×n 행렬을 메모리에 안 만듦 (타일 단위 계산) | 없음 — 정확히 같은 결과 |
| 슬라이딩 창 | 가까운 k 개만 참조 | 먼 의존 손실 |
| 희소 어텐션 | 일부 위치만 전역 참조 | 패턴 설계 필요 |
| 선형 어텐션 | softmax 를 커널로 근사 | 표현력 저하 |
| MQA / GQA | K·V 헤드를 공유 | 약간의 품질 하락 |
| KV 캐시 | 생성 시 과거 K·V 재사용 | 메모리 사용 증가 |
한 단계 더 — 블록 구조와 세 가지 계열 🟡 중급
피드포워드가 하는 일
# 어텐션은 '토큰 사이'를 섞는다. 피드포워드는 '토큰 안'을 처리한다.
def ffn(x, W1, W2): # 위치마다 독립적으로 같은 MLP
return np.maximum(0, x @ W1) @ W2
# 크기 관례: d_model → 4·d_model → d_model (SwiGLU 는 약 8/3 배)
# 파라미터의 약 2/3 가 여기에 있다. LLM 파라미터 수의 주범.
# 해석 연구에서는 이 층을 '키-값 기억장치'로 본다:
# W1 의 행 = 어떤 패턴에 반응하는 키
# W2 의 열 = 그때 더해 줄 값 (사실 지식)
# → 지식 편집(model editing) 연구가 이 층을 건드리는 이유다.
Norm(x + Attn(x))(Post-LN)였지만,
지금은 x + Attn(Norm(x))(Pre-LN)가 표준입니다.
잔차 경로에 정규화가 끼지 않아 워밍업 없이도 깊은 모델이 안정적으로 학습됩니다.
여기서 LLM 까지
| 단계 | 무엇을 하는가 |
|---|---|
| 토크나이저 | BPE 등으로 텍스트를 조각으로 — 어휘 밖 단어 없음 |
| 사전학습 | 다음 토큰 예측 = 자기지도(01절) · 인터넷 규모 |
| 지시 미세조정 | “질문-답변” 형식으로 소량 재학습 |
| 선호 정렬 | 사람 선호로 보상 모델 → RLHF · DPO |
| 추론 | KV 캐시 + 샘플링(온도 · top-p) |
실무에선 이렇게 — 어텐션 시각화를 해석할 때 조심할 것 🔴 실무
어텐션 가중치는 “설명”이 아닙니다
| 흔한 주장 | 실제 |
|---|---|
| “이 단어를 보고 판단했다” | 어텐션이 큰 곳이 인과적으로 중요한 곳은 아닙니다 |
| “헤드마다 역할이 있다” | 일부는 그렇지만 대부분은 해석되지 않습니다 |
| “가중치를 바꾸면 답이 바뀐다” | 잔차 경로로 정보가 우회합니다 |
| 첫 토큰에 몰린 큰 값 | 어텐션 싱크 — “볼 곳이 없을 때의 쓰레기통” |
# 더 나은 해석 방법
# · attention rollout : 층을 거치며 누적된 기여를 합성
# · gradient × input : 실제로 출력에 미친 영향 (13절 SHAP·LIME 과 같은 계열)
# · ablation : 그 헤드를 껐을 때 성능이 얼마나 떨어지나 ← 가장 확실
# · logit lens : 중간 층의 표현을 출력 어휘로 직접 투영해 보기
# 실무 팁: 프로덕션 디버깅에는 어텐션 그림보다
# '입력을 조금 바꿔 보는 것'이 훨씬 빠르고 믿을 만하다.
평가 · 실험 설계 — 정확도가 거짓말하는 순간들
모델을 고르는 일은 숫자를 고르는 일입니다. 잘못된 지표를 고르면 잘못된 모델을 고르게 되고, 그보다 더 흔한 사고는 지표 자체가 오염되는 것(데이터 누수)입니다.
직접 계산해 보기
import numpy as np
def confusion(y, pred):
TP = int(((pred == 1) & (y == 1)).sum())
FP = int(((pred == 1) & (y == 0)).sum())
FN = int(((pred == 0) & (y == 1)).sum())
TN = int(((pred == 0) & (y == 0)).sum())
return TP, FP, FN, TN
def metrics(y, pred):
TP, FP, FN, TN = confusion(y, pred)
P = TP / max(TP + FP, 1)
R = TP / max(TP + FN, 1)
return {
"정밀도": P, "재현율": R,
"F1": 2 * P * R / max(P + R, 1e-12),
"특이도": TN / max(TN + FP, 1),
"정확도": (TP + TN) / len(y),
"균형정확도": 0.5 * (R + TN / max(TN + FP, 1)), # 불균형에 강함
"MCC": (TP*TN - FP*FN) / max(np.sqrt((TP+FP)*(TP+FN)*(TN+FP)*(TN+FN)), 1e-12),
}
# MCC(매튜 상관계수)는 네 칸을 전부 쓰는 유일한 단일 지표 —
# 불균형 데이터에서 F1 보다 정직하다는 평이 많다.
무엇을 최적화할지는 비용이 정합니다
| 문제 | 더 비싼 오류 | 봐야 할 지표 |
|---|---|---|
| 암 선별 검사 | 놓침(FN) | 재현율 우선 |
| 스팸 필터 | 정상 메일 차단(FP) | 정밀도 우선 |
| 사기 탐지 | 둘 다 비쌈 | PR-AUC · 재현율@정밀도 0.9 |
| 추천 상위 10개 | — | Precision@k · NDCG |
| 불균형 이진 | — | PR-AUC · MCC |
| 확률 자체가 필요 | — | Brier 점수 · 보정 곡선 |
핵심 개념 — ROC-AUC 와 PR-AUC 는 다른 이야기를 합니다 🟢 기초
임계값을 훑어 곡선을 만들기
def curves(y, score):
order = np.argsort(-score) # 점수 높은 순
y = y[order]
tp = np.cumsum(y) # 상위 k 개 중 진짜 양성 수
fp = np.cumsum(1 - y)
P = tp / (tp + fp) # 정밀도@k
R = tp / y.sum() # 재현율@k
FPR = fp / (1 - y).sum()
return R, P, FPR
# ROC-AUC 의 확률적 의미:
# 무작위로 뽑은 양성 하나와 음성 하나에 대해
# 모델이 양성에 더 높은 점수를 줄 확률
# → 0.5 = 동전 던지기 · 1.0 = 완벽한 순위
# 그래서 ROC-AUC 는 '순위 능력' 지표이지 '판정 능력' 지표가 아니다.
# 임계값을 정해 실제로 판정할 때의 성능은 PR 쪽이 잘 보여 준다.
다중 클래스 평균 방식
| 방식 | 계산 | 성격 |
|---|---|---|
| macro | 클래스별 F1 의 단순 평균 | 희귀 클래스도 동등 |
| micro | 전체 TP·FP·FN 을 합쳐 한 번 | 큰 클래스가 지배 · 정확도와 같아짐 |
| weighted | 클래스 크기로 가중 평균 | 큰 클래스 쪽으로 기움 |
| samples | 샘플마다 계산 후 평균 | 다중 레이블용 |
# 클래스 A 9000건(F1 0.98), B 500건(0.60), C 50건(0.10)
macro = (0.98 + 0.60 + 0.10) / 3 # 0.56 ← 희귀 클래스 실패가 드러남
weighted = (9000*.98 + 500*.60 + 50*.10) / 9550 # 0.96 (0.9555) ← 가려짐
# 보고할 때 반드시 방식을 밝힐 것.
# "F1 0.96" 과 "F1 0.56" 이 같은 모델의 같은 예측일 수 있다.
weighted 인 경우가 있어
희귀 클래스 실패가 숫자에서 사라집니다. 클래스별 표를 함께 출력하세요.
한 단계 더 — 데이터 누수 — 점수가 좋으면 먼저 의심하세요 🟡 중급
실제로 겪는 누수의 목록
| 유형 | 구체적인 사고 | 탐지 방법 |
|---|---|---|
| 전처리 누수 | 전체 데이터로 스케일링·인코딩을 fit | 파이프라인으로 감싸기 |
| 시간 누수 | 미래 정보로 과거를 예측 | 시간 순 분할 · 특성 생성 시점 확인 |
| 정답 파생 특성 | 환불금액 으로 환불여부 예측 | 단일 특성 AUC 0.99 → 즉시 의심 |
| 중복 행 | 같은 레코드가 훈련·시험 양쪽에 | 해시로 중복 제거 후 분할 |
| 그룹 누수 | 같은 환자·사용자가 양쪽에 | 그룹 단위 분할 |
| 시험셋 재사용 | 시험 점수로 100번 튜닝 | 시험은 마지막 한 번 |
# 누수 자동 탐지 — 특성 하나씩 단독 성능을 재 본다
for j, name in enumerate(feature_names):
auc = roc_auc(y_val, X_val[:, j])
if auc > 0.95 or auc < 0.05:
print(f"⚠ {name}: 단독 AUC {auc:.3f} — 누수 의심")
# 시간 순 분할(시계열의 정석)
cut = int(len(df) * 0.8)
train, test = df.iloc[:cut], df.iloc[cut:] # 셔플 금지
# 파이프라인으로 감싸면 폴드마다 fit 이 자동으로 분리된다
pipe = make_pipeline(StandardScaler(), LogisticRegression())
scores = cross_val_score(pipe, X, y, cv=5) # ★ 이렇게 해야 안전
실무에선 이렇게 — 유의성과 재현 가능한 실험 🔴 실무
0.912 vs 0.908 은 “이겼다”가 아닙니다
# ① 시드를 바꿔 여러 번 돌린다 (같은 모델의 흔들림 크기를 안다)
runs_a = [train_eval(seed=s, model="A") for s in range(10)]
runs_b = [train_eval(seed=s, model="B") for s in range(10)]
print(np.mean(runs_a), np.std(runs_a)) # 0.912 ± 0.011
print(np.mean(runs_b), np.std(runs_b)) # 0.908 ± 0.009
# → 차이 0.004 가 표준편차보다 작다. 구분되지 않는다.
# ② 부트스트랩 신뢰구간 (시험셋 하나만 있을 때)
def boot_ci(y, pred, metric, n=2000):
rng = np.random.default_rng(0)
vals = [metric(y[i], pred[i]) for i in
(rng.integers(0, len(y), len(y)) for _ in range(n))]
return np.percentile(vals, [2.5, 97.5])
# ③ 짝지은 비교 — 같은 샘플에 대한 두 모델의 차이를 검정
# (맥니마 검정 · 순열 검정). A/B 테스트 통계의 함정은
# ➗ 선형대수 · 확률 탭 11 에 정리되어 있습니다.
실험 하나를 기록하는 최소 단위
record = {
"git_sha": subprocess.check_output(["git","rev-parse","HEAD"]).strip(),
"data_hash": hashlib.sha256(open(path,"rb").read()).hexdigest()[:12],
"seed": SEED,
"params": {"lr": 3e-4, "batch": 64, "wd": 0.01},
"split": {"scheme": "group_kfold", "k": 5, "group": "user_id"},
"metrics": {"pr_auc": 0.381, "recall@p90": 0.42},
"env": {"python": sys.version, "numpy": np.__version__},
"duration_s": 1842,
}
# 시드 고정만으로는 부족하다 — 완전 재현에는
# · 데이터 버전 · 라이브러리 버전 · GPU 비결정성 설정까지 필요
# · cudnn.deterministic = True 는 느려지지만 디버깅에 필수
학습 이론 · 한계 — 언제 되고 언제 안 되는가
“왜 학습이 되는가”에 답하는 이론과, “그래도 안 되는 것”의 목록입니다. 마지막 절은 성능 이야기가 아니라 쓸지 말지를 정하는 판단에 관한 것입니다.
PAC 학습 — “거의 맞을 확률이 높다”
# 식이 말해 주는 것
# · 필요한 데이터 n 은 VC 차원에 '비례'한다 → 복잡한 모델은 데이터를 더 먹는다
# · ε 을 절반으로 줄이려면 데이터가 4배 필요하다 (ε² 이므로)
# · δ 는 로그로 들어간다 → 신뢰도를 높이는 비용은 싸다
# 식이 말해 주지 않는 것 (그래서 실무에서 그대로 못 쓴다)
# · 최악의 경우 기준이라 한계가 매우 헐렁하다
# · 신경망의 VC 차원은 파라미터 수에 비례 → 10억이면 데이터가 10억 필요?
# 그런데 실제로는 그보다 훨씬 적은 데이터로 잘 된다 (04절 이중 하강)
# · 그래서 현대 이론은 '노름 기반 한계' · '암묵적 정규화' 쪽으로 옮겨 갔다
공짜 점심은 없다 · 스케일링 법칙
| 함의 | 실무 판단 |
|---|---|
| 수익 체감이 예측 가능 | 작은 실험으로 큰 실험을 외삽 |
| N 과 D 는 함께 키워야 | 한쪽만 키우면 다른 항이 병목 |
| L∞ 는 안 줄어듦 | 데이터의 본질적 불확실성 |
| 계산 예산이 정해지면 | 최적 N·D 비율이 계산됨 |
핵심 개념 — 편향 · 공정성 · 설명 가능성 🟢 기초
공정성 지표는 서로 충돌합니다
| 기준 | 요구 |
|---|---|
| 인구통계적 동등성 | 집단별 양성 예측 비율이 같을 것 |
| 기회 균등 | 집단별 재현율이 같을 것 |
| 예측값 동등성 | 집단별 정밀도가 같을 것 |
| 보정 동등성 | 확률 0.7 이 집단과 무관하게 70% 일 것 |
# 민감 속성을 빼면 해결되나? — 아니다.
# 우편번호 · 이름 · 구매 이력이 대리 변수(proxy)로 작동한다.
# 오히려 속성을 알아야 편향을 '측정'할 수 있다.
for g in groups: # 집단별로 쪼개서 본다
m = metrics(y[mask[g]], pred[mask[g]])
print(g, m["재현율"], m["정밀도"], m["정확도"])
# 전체 지표 하나만 보면 특정 집단의 실패가 완전히 가려진다.
설명 가능성 — SHAP 과 LIME
| SHAP | LIME | |
|---|---|---|
| 아이디어 | 기여도의 공정 분배 | 주변을 선형 모델로 근사 |
| 일관성 | 이론적 보장 있음 | 실행마다 달라질 수 있음 |
| 비용 | 비쌈 (트리는 빠른 알고리즘 존재) | 쌈 |
| 범위 | 전역 + 지역 | 지역만 |
한 단계 더 — 적대적 예제 — 사람 눈에는 같은 사진 🟡 중급
픽셀을 아주 조금 흔들면 판다가 긴팔원숭이가 됩니다
# FGSM — 가장 단순한 공격. '손실을 늘리는 방향'으로 한 걸음.
def fgsm(model, x, y, eps=0.01):
g = grad_of_loss_wrt_input(model, x, y) # 입력에 대한 기울기
return np.clip(x + eps * np.sign(g), 0, 1) # 부호만 써서 균일하게 흔든다
# 왜 되는가 (선형성 가설)
# 고차원에서는 각 픽셀을 eps 만큼만 흔들어도
# 내적 wᵀδ = eps · Σ|w_j| 가 커진다. 차원 d 에 비례해 누적된다.
# → '모델이 복잡해서'가 아니라 '고차원 + 거의 선형'이라서 생긴다.
# 방어와 그 한계
# · 적대적 훈련 : 공격 샘플을 학습에 넣음 — 가장 효과적이지만 정확도·비용 대가
# · 입력 정제 : 압축·노이즈 제거 — 적응형 공격에 뚫림
# · 기울기 은닉 : 기울기를 못 구하게 함 — 대리 모델 공격에 뚫림 (거짓 안전)
# · 인증된 방어 : 수학적 보장 있음 — 아직 작은 eps 에서만
| 공격 표면 | 실무에서의 모습 |
|---|---|
| 회피 공격 | 스팸·악성코드가 필터를 우회하도록 변형 |
| 데이터 오염 | 학습 데이터에 악의적 샘플 주입 (백도어) |
| 모델 추출 | API 를 반복 호출해 모델을 복제 |
| 멤버십 추론 | “이 사람이 학습 데이터에 있었나” 유출 |
| 프롬프트 주입 | LLM 입력에 지시를 섞어 정책 우회 |
실무에선 이렇게 — 이 문제에 ML 을 쓰면 안 되는 이유 체크리스트 🔴 실무
배포 이후에 무너지는 방식들
| 현상 | 정의 | 대응 |
|---|---|---|
| 공변량 이동 | 입력 분포 P(x) 가 변함 | 입력 분포 모니터링 · 재학습 |
| 개념 이동 | P(y|x) 자체가 변함 | 라벨 지연 감안한 주기적 재학습 |
| 피드백 루프 | 모델의 예측이 다음 데이터를 만듦 | 무작위 홀드아웃 트래픽 유지 |
| 학습-서빙 왜곡 | 학습과 서빙의 전처리가 다름 | 같은 코드 경로 공유 |
| 지연된 라벨 | 정답이 몇 달 뒤에 옴 | 대리 지표 · 지연 평가 설계 |
추가만 되는 줄 하나가
데이터베이스와 분산 시스템을
떠받치고 있습니다
로그는 맨 끝에만 붙이고, 한 번 붙인 것은 고치지 않는 순서 있는 기록입니다. 자료구조로 보면 어이없을 만큼 단순한데, 파일시스템 저널 · 데이터베이스의 WAL · Git 의 커밋 · Kafka 의 파티션 · Raft 의 복제 로그 · 감사 로그 · 인증서 투명성이 전부 이 하나 위에 서 있습니다. 이 탭은 “로그 라이브러리 쓰는 법”이 아니라 왜 이 단순한 것이 컴퓨터과학의 중심에 놓이는가를 다룹니다. 상태와 이벤트의 관계, 시간과 인과, 정보량, 그리고 로그로는 끝내 답할 수 없는 질문까지 갑니다.
로그란 무엇인가 — 추가만 되는 순서 있는 기록
우리가 매일 보는 app.log 텍스트 파일 이야기가 아닙니다.
append-only ordered sequence 라는 자료구조 이야기입니다.
연산이 두 개밖에 없는데, 이 두 개로 데이터베이스와 분산 합의가 만들어집니다.
50줄로 만드는 로그
class Log:
"""append-only ordered sequence — 연산은 두 개뿐입니다."""
def __init__(self):
self._items = [] # 실제로는 디스크의 세그먼트 파일
def append(self, record) -> int:
self._items.append(record) # ★ 끝에만 붙습니다
return len(self._items) - 1 # 붙은 자리 = 오프셋(불변 식별자)
def read(self, offset: int, limit: int = 100):
return self._items[offset:offset + limit]
def __len__(self):
return len(self._items)
# 쓰기
log = Log()
log.append({"t": "deposit", "amt": 10_000}) # -> 0
log.append({"t": "withdraw", "amt": 3_000}) # -> 1
# 읽기 — 커서는 소비자가 들고 있습니다
cursor = 0
while cursor < len(log):
for rec in log.read(cursor, 10):
handle(rec)
cursor += 1
update(i, x) 도 delete(i) 도
insert(i, x) 도 없습니다. 이 세 개를 포기했기 때문에
동시에 읽는 사람이 몇 명이든 락이 필요 없고, 어제 읽은 오프셋 3이 오늘도 같은 값입니다.
배열 · 큐 · 로그는 어떻게 다른가
| 배열 | 큐 | 로그 | |
|---|---|---|---|
| 추가 | 아무 데나 | 뒤에만 | 뒤에만 |
| 제거 | 아무 데나 | 앞에서 꺼냄 | 없음 |
| 수정 | 가능 | 없음 | 없음 |
| 읽은 뒤 | 남음 | 사라짐 | 남음 |
| 독자 수 | 제한 없음 | 보통 1 | 제한 없음 |
| 커서 주인 | — | 큐 | 각 소비자 |
| 과거 재생 | — | 불가 | 가능 |
이렇게도 쓸 수 있다 — 왜 하필 순차 쓰기인가 🟡 중급
하드웨어가 로그를 좋아합니다
같은 1GB 를 쓸 때 (대략적인 감각)
HDD 랜덤 4KB 쓰기 ~100 IOPS → 1GB 에 40분 이상
HDD 순차 쓰기 ~150 MB/s → 1GB 에 7초
★ 300배 이상 차이 — 디스크 헤드가 안 움직이니까
SSD 랜덤 4KB 쓰기 수만 IOPS → 빨라졌지만
SSD 순차 쓰기 → 여전히 몇 배 빠릅니다
이유: 쓰기 증폭(write amplification)과
FTL 의 블록 단위 소거
로그는 "항상 파일 끝에 붙이기"라서
순차 쓰기만 합니다 → 하드웨어의 가장 빠른 경로
이미 우리 주변에 있는 로그들
| 이름 | 무엇이 로그인가 |
|---|---|
| ext4 / NTFS 저널 | 메타데이터 변경 기록 — 부팅 때 재실행 |
| PostgreSQL WAL | 모든 페이지 변경. 복제·PITR 의 근거 |
| Redis AOF | 실행한 명령 자체를 append |
| Git | 커밋 = 부모 해시를 가진 불변 기록 |
| Kafka | 파티션 하나가 곧 로그 하나 |
| etcd · Raft | 복제 로그에 합의하고 상태는 파생 |
| 블록체인 | 합의로 확정되는 해시 체인 로그 |
| journald | 구조화 필드가 붙은 시스템 로그 |
실무에선 이렇게 — 세그먼트 · 인덱스 · 오프셋 🔴 실무
파일 하나에 무한히 붙일 수는 없습니다 → 세그먼트로 자릅니다
/data/topic-A/partition-0/
00000000000000000000.log ← 오프셋 0 부터
00000000000000000000.index ← 오프셋 -> 파일 위치 (희소 인덱스)
00000000000000524288.log ← 오프셋 524288 부터
00000000000000524288.index
...
00000000000009830400.log ← 지금 쓰고 있는 활성 세그먼트
· 파일 이름이 곧 그 세그먼트의 시작 오프셋 → 이진 탐색으로 파일을 고릅니다
· 인덱스는 몇 KB 마다 하나씩만 (희소) → 인덱스도 순차로 붙습니다
· 보존 정책은 "세그먼트 파일 단위 삭제" → 파일 unlink 한 번으로 끝
(중간을 지우는 게 아니라 가장 오래된 파일을 통째로 지웁니다)
읽기가 왜 빠른가
· 오프셋 -> 세그먼트 -> 인덱스 -> 파일 위치 -> 그 뒤로 순차 읽기
· 페이지 캐시가 그대로 활용됩니다 (최근 데이터는 대개 메모리에)
· sendfile(2) 로 커널에서 소켓까지 바로 — 사용자 공간 복사 0회
★ Kafka 가 빠른 이유의 절반이 "특별한 것을 안 하기" 입니다
상태와 이벤트의 이중성 — 접기와 미분
“잔액 7,000원”과 “입금 10,000 · 출금 3,000”은 같은 사실의 두 표현입니다. 상태는 이벤트를 접은 것(fold)이고, 이벤트는 상태의 차이(미분)입니다. 이 대응이 이 탭 전체의 뼈대입니다.
fold 를 코드로
from functools import reduce
def apply(state, ev):
"""상태 하나 + 이벤트 하나 -> 다음 상태. 순수 함수여야 합니다."""
t = ev["t"]
if t == "deposit": return {**state, "bal": state["bal"] + ev["amt"]}
if t == "withdraw": return {**state, "bal": state["bal"] - ev["amt"]}
if t == "freeze": return {**state, "frozen": True}
return state # 모르는 이벤트는 무시(전방 호환)
initial = {"bal": 0, "frozen": False}
events = log.read(0, 10_000)
state = reduce(apply, events, initial) # ★ state = fold(apply, initial, events)
# 어제 자정의 잔액이 궁금하다면? 그때까지만 접으면 됩니다
as_of = [e for e in events if e["ts"] < midnight]
yesterday = reduce(apply, as_of, initial)
apply 안에서 현재 시각을 읽거나 · 난수를 뽑거나 · 외부 API 를 부르면
재생한 결과가 원래와 달라집니다. 그런 값은 이벤트에 미리 박아 두는 것이 정석입니다
({"t":"charge","amt":100,"rate":1180.5,"at":"..."}).
두 설계의 대칭 — 무엇을 원본으로 둘 것인가
상태를 원본으로 (보통의 CRUD)
UPDATE account
SET balance = balance - 3000
WHERE id = 7;
-- 이전 값은 그 자리에서 사라집니다
-- "왜 3,000이 빠졌나"는 어디에도 없습니다
이벤트를 원본으로 (이벤트 소싱)
INSERT INTO events(stream, seq, type, data)
VALUES ('acct-7', 12, 'withdraw',
'{"amt":3000,"atm":"KR-118"}');
-- 잔액은 이 로그를 접어서 만듭니다
-- 과거 어느 시점이든 되살릴 수 있습니다
이렇게도 쓸 수 있다 — 이 대칭이 나타나는 곳들 🟡 중급
| 분야 | 이벤트(로그) | 상태(접은 결과) |
|---|---|---|
| 은행 | 입출금 원장 한 줄 한 줄 | 잔액 |
| Git | 커밋(diff) | 워킹 트리 |
| DB 복제 | WAL 레코드 스트림 | 복제본의 테이블 |
| Redis | AOF 명령 | 메모리의 키 공간 |
| React 계열 | 액션(디스패치된 것) | 스토어 상태 |
| 협업 편집 | 연산(op) · CRDT 변경 | 문서 본문 |
| 회계 | 분개(전표) | 재무제표 |
| 물리 | 속도(변화율) | 위치 |
실무에선 이렇게 — 스냅샷 없이는 못 삽니다 🔴 실무
접는 비용은 이벤트 수에 비례합니다
# 이벤트가 500만 개인 스트림을 매번 처음부터 접으면?
# -> 조회 한 번에 수 초. 서비스가 못 버팁니다.
# ★ 스냅샷: "여기까지 접은 결과"를 주기적으로 저장
def load(stream_id):
snap = snapshots.latest(stream_id) # {seq: 4_900_000, state: {...}}
state = snap.state if snap else initial
start = snap.seq + 1 if snap else 0
for ev in log.read_stream(stream_id, start): # 나머지만 접습니다
state = apply(state, ev)
return state
def save_snapshot(stream_id, state, seq):
snapshots.put(stream_id, seq, state, schema_version=3)
# 규칙
# · 스냅샷은 캐시입니다 — 지워도 로그에서 다시 만들 수 있어야 합니다
# · apply 로직이 바뀌면 스냅샷을 전부 버리고 다시 만듭니다 ★
# · 그래서 스냅샷에 schema_version 을 반드시 넣습니다
재생이 진짜로 어려운 지점
① 외부 부수효과
재생 중에 다시 메일이 나가면 안 됩니다.
-> apply 는 상태만 바꾸고, 부수효과는 별도 단계로 분리
(읽기 모델 갱신 / 알림 발송은 '핸들러', apply 아님)
② 코드가 바뀐다
1년 전 이벤트를 오늘 코드로 접으면 다른 값이 나올 수 있습니다.
-> 이벤트에 version 을 넣고 apply 를 버전별로 분기
-> 또는 업캐스터(upcaster)로 옛 이벤트를 새 형태로 승격
③ 시간·환율·세율 같은 외부 값
-> 계산에 쓴 값을 이벤트에 함께 기록 (재현 가능성 확보)
④ 삭제 요구(GDPR 잊힐 권리)와 append-only 의 충돌
-> 개인정보를 로그 밖 금고에 두고 로그에는 키만
(crypto-shredding: 키를 지우면 사실상 복원 불가)
state = fold(apply, initial, events) 한 줄이 이 절의 전부입니다.
상태는 이벤트를 접은 결과이므로 언제든 다시 만들 수 있고, 이벤트는 상태의 차이이므로
상태만 남기면 이유가 사라집니다. 무엇을 원본으로 둘지가 시스템의 성격을 정합니다.WAL 과 원자성 — 데이터보다 로그를 먼저 씁니다
“다 되거나 다 안 되거나”는 어떻게 만들까요. 답은 놀랄 만큼 단순합니다 — 진짜 데이터를 고치기 전에, 무엇을 고칠지를 로그에 먼저 적습니다. Write-Ahead Logging, 줄여서 WAL 입니다.
왜 이 순서가 크래시에서 살아남게 하나
크래시가 날 수 있는 지점은 세 곳입니다.
① 로그도 못 쓰고 죽었다
→ 아무 일도 없었던 것과 같습니다. 정상.
② 로그는 썼는데 페이지를 못 고치고 죽었다
→ 로그가 디스크에 있습니다.
복구가 로그를 다시 실행(redo)해서 페이지를 만듭니다. 정상.
③ 페이지 일부만 고치고 죽었다 (부분 쓰기)
→ 로그에 '고치기 전 값(undo)'과 '고친 뒤 값(redo)'이 있습니다.
커밋 레코드가 있으면 redo, 없으면 undo. 정상.
★ 반대로 했다면 (페이지 먼저, 로그 나중)
② 지점에서 페이지는 반쯤 바뀌었는데 무엇을 되돌려야 할지
아무 데도 안 적혀 있습니다 → 복구 불가능
멱등 재실행 — 두 번 실행돼도 같아야 합니다
# 복구는 몇 번이든 중단됐다 다시 시작될 수 있습니다.
# 복구 도중 또 전원이 나갈 수 있으니까요.
# → redo 는 반드시 멱등이어야 합니다.
# ✗ 멱등이 아님: 상대적 변경
def redo_bad(page, rec):
page.balance += rec.delta # 두 번 적용되면 두 배
# ✓ 멱등: 절대값 + 페이지 LSN 비교 (실제 DB 방식)
def redo_ok(page, rec):
if page.lsn >= rec.lsn: # ★ 이미 반영된 로그다
return # 건너뜁니다
page.apply(rec.after_image) # 고친 뒤의 값을 그대로 씀
page.lsn = rec.lsn # 어디까지 반영했는지 페이지에 기록
# LSN(Log Sequence Number) = 로그 안의 오프셋.
# 페이지마다 "나는 LSN 얼마까지 반영됐다"를 들고 있으면
# 재실행이 몇 번 돌아도 결과가 같습니다.
이렇게도 쓸 수 있다 — 체크포인트와 ARIES 🟡 중급
체크포인트가 없으면 복구가 끝나지 않습니다
체크포인트 = "이 시점까지의 변경은 모두 디스크 페이지에 반영됨"
없으면 → 복구할 때 로그를 태초부터 다시 읽어야 합니다
있으면 → 마지막 체크포인트 뒤부터만 읽으면 됩니다
PostgreSQL 에서 보이는 값들
checkpoint_timeout = 5min 얼마나 자주
max_wal_size = 1GB WAL 이 이만큼 쌓이면 강제
★ 자주 하면 복구는 빨라지고, 평소 I/O 는 늘어납니다 (트레이드오프)
SELECT pg_current_wal_lsn(); -- 지금 WAL 위치
SELECT pg_last_wal_replay_lsn(); -- 복제본이 어디까지 적용했나
로그가 곧 복제이자 백업입니다
· 스트리밍 복제 = WAL 을 실시간으로 보내 복제본이 재생
· PITR = 기본 백업 + 그 뒤 WAL 을 원하는 시각까지 재생
"어제 오후 3시 12분 상태로" 가 가능한 이유
ARIES — 교과서에 나오는 세 단계
| 단계 | 하는 일 |
|---|---|
| Analysis | 마지막 체크포인트부터 읽어 죽을 때 살아 있던 트랜잭션과 더러운 페이지 목록을 복원 |
| Redo | 로그를 앞에서 뒤로 재실행 — 커밋 여부와 무관하게 일단 전부 다시 적용(repeating history) |
| Undo | 커밋 못 한 트랜잭션을 뒤에서 앞으로 되돌림. 이때의 되돌림도 CLR 로그로 남깁니다 |
실무에선 이렇게 — 응용 코드에서 쓰는 WAL 🔴 실무
# DB 밖에서도 같은 패턴을 씁니다. 대표적인 것이 '아웃박스'입니다.
# 문제: DB 에 저장하고 카프카에 발행하는 두 동작은 원자적일 수 없다
# (분산 트랜잭션 없이는)
# ✗ 위험 — 커밋은 됐는데 발행 직전에 프로세스가 죽으면 이벤트 유실
with db.transaction():
db.insert(order)
kafka.publish(event)
# ✓ 로그를 먼저: 같은 트랜잭션 안에서 '보낼 것'을 테이블에 적습니다
with db.transaction():
db.insert(order)
db.insert_outbox(topic="orders", key=order.id,
payload=event, created_at=now()) # ★ 이게 WAL 역할
# 별도 워커가 아웃박스를 읽어 발행하고 표시합니다 (또는 CDC 가 대신)
for row in db.fetch_unsent_outbox(limit=500):
kafka.publish(row.topic, row.key, row.payload) # 중복 발행은 가능
db.mark_sent(row.id) # 정확히 한 번은 아님
# 결과: at-least-once. 소비자가 멱등이면 실질적으로 exactly-once 처럼 동작합니다.
# 순서가 필요하면 key 를 파티션 키로 써서 같은 키는 같은 파티션으로 보냅니다.
rename”,
배포의 “새 심볼릭 링크로 원자 교체”, 브라우저의 IndexedDB 트랜잭션까지
전부 “진짜를 건드리기 전에 의도를 먼저 어딘가에 확정한다”는 한 가지 발상입니다.
시간과 인과관계 — 벽시계로는 한 줄로 못 세웁니다
한 대에서는 “먼저 붙은 것이 먼저”라 순서가 공짜였습니다. 여러 대의 로그를 합치는 순간 그 공짜가 사라집니다. 시계는 서로 다르고, 심지어 뒤로도 갑니다.
happens-before 는 부분 순서입니다
a → b (a happens-before b) 가 성립하는 경우는 셋뿐입니다
① 같은 노드에서 a 가 b 보다 먼저 일어났다
② a 가 메시지를 보냈고 b 가 그 메시지를 받았다
③ a → c 이고 c → b 이다 (이행성)
그 밖에는 전부 '동시(concurrent)' — a ‖ b
· 동시는 "같은 시각"이 아닙니다
· "어느 쪽이 먼저인지 정할 근거가 없다"는 뜻입니다
· 실제 벽시계로는 3시간 차이가 날 수도 있습니다
★ 그래서 분산 이벤트는 전순서(total order)가 아니라
부분 순서(partial order)입니다.
한 줄로 세우려면 '없는 정보를 만들어 넣는' 수밖에 없고,
그것이 바로 합의(consensus)가 하는 일입니다.
램포트 시계와 벡터 시계
# ── 램포트 논리 시계 ── 정수 하나
class Lamport:
def __init__(self): self.t = 0
def local(self): # 내 안에서 사건이 하나 일어남
self.t += 1; return self.t
def send(self):
self.t += 1; return self.t # 이 값을 메시지에 실어 보냄
def recv(self, ts):
self.t = max(self.t, ts) + 1 # ★ 받은 값과 비교해 앞서 감
return self.t
# ── 벡터 시계 ── 노드마다 카운터 배열
class Vector:
def __init__(self, me, n): self.me, self.v = me, [0] * n
def local(self):
self.v[self.me] += 1; return list(self.v)
def recv(self, other):
self.v = [max(a, b) for a, b in zip(self.v, other)]
self.v[self.me] += 1
def compare(x, y):
le = all(a <= b for a, b in zip(x, y))
ge = all(a >= b for a, b in zip(x, y))
if le and not ge: return "x → y"
if ge and not le: return "y → x"
if le and ge: return "같음"
return "동시(충돌)" # ★ 서로 크고 작으면 concurrent
이렇게도 쓸 수 있다 — 분산 로그를 한 줄로 세우는 다섯 가지 방법 🟡 중급
| 방법 | 어떻게 | 대가 |
|---|---|---|
| 단일 시퀀서 | 한 곳이 번호를 발급 (Kafka 파티션 리더 · DB 시퀀스) | 그 한 곳이 병목이자 단일 장애점. 가장 흔한 답 |
| 합의 | Raft · Paxos 로 로그 순서 자체에 과반 동의 | 왕복 1회 + fsync. 지연이 붙습니다 |
| 논리 시계 + 타이브레이크 | 램포트 값이 같으면 노드 ID 로 순서를 강제 | 순서는 나오지만 인과와 무관한 순서가 섞입니다 |
| HLC | 물리 시각 상위 + 논리 카운터 하위 (CockroachDB) | 사람이 읽을 수 있고 인과도 지킴. 시계 오차 상한 가정 필요 |
| 동기화 시계 | 원자시계·GPS 로 오차 상한을 보장 (Spanner TrueTime) | 오차만큼 일부러 기다립니다(commit-wait). 하드웨어 필요 |
실무에선 이렇게 — 여러 서비스의 로그를 한 화면에서 보기 🔴 실무
서비스 다섯 개의 로그를 시각으로 정렬해 놓고 원인을 찾으면 반드시 틀립니다.
'결과가 원인보다 먼저' 나오는 줄을 보게 됩니다.
실무의 답 — 인과를 명시적으로 들고 다닙니다 (W3C Trace Context)
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
‾‾ ‾‾‾‾‾‾‾ trace-id (요청 전체) ‾‾‾ ‾ span-id ‾ 플래그
· trace_id : 요청 하나에 하나. 서비스를 넘어가도 그대로 전파
· span_id : 이 구간의 ID
· parent_id : 나를 부른 구간의 ID ← ★ 이것이 happens-before 그 자체
정렬 규칙
· 화면 정렬은 timestamp 로 해도 됩니다 (사람이 보기 편하니까)
· 판단은 parent 관계로 합니다 (트리를 따라갑니다)
· 같은 서비스 안에서는 단조 증가 시퀀스를 함께 남기면 확실합니다
로그 한 줄에 최소한 이것만은
trace_id · span_id · service · instance · monotonic_seq
합의와 복제 로그 — 로그가 같으면 상태가 같습니다
여러 대가 같은 값을 갖게 하는 가장 확실한 방법은 같은 값을 복사하는 것이 아니라 같은 로그를 갖게 하는 것입니다. 상태 기계 복제(RSM)라고 부르는, 분산 시스템의 가장 오래된 아이디어입니다.
상태 기계 복제(RSM) 의 세 조건
복제본 N 대가 반드시 같은 상태가 되려면
① 같은 초기 상태에서 시작한다
② 같은 명령을 같은 순서로 받는다 ← 합의가 보장할 부분
③ 명령 적용이 결정적(deterministic)이다 ← 우리가 지킬 부분
③ 을 깨는 흔한 것들
· 현재 시각 now() → 명령에 시각을 실어 보냅니다
· 난수 uuid4() → 리더가 뽑아서 명령에 넣습니다
· 맵 순회 순서 → 정렬된 순회를 씁니다
· 부동소수 연산 순서 → 같은 순서를 강제하거나 정수로
· 로캘·타임존 의존 정렬 → 고정 로캘
★ ③ 이 깨지면 합의가 아무리 잘 돌아도 복제본이 갈라집니다.
그리고 이 갈라짐은 몇 시간 뒤에 엉뚱한 조회 결과로 나타납니다.
로그 매칭 성질 — 두 줄로 끝나는 귀납
Raft 가 유지하는 불변식(Log Matching Property)
두 로그가 같은 인덱스에 같은 임기(term)의 항목을 가지면
(1) 그 항목은 같은 명령이고
(2) 그 앞의 모든 항목도 서로 같다
어떻게 이걸 공짜로 얻나 — 복제 요청에 '바로 앞 칸'을 같이 보냅니다
AppendEntries(term, prevLogIndex, prevLogTerm, entries[], leaderCommit)
팔로워: 내 로그의 prevLogIndex 자리의 임기가 prevLogTerm 인가?
아니면 → 거절. 리더가 인덱스를 하나 낮춰 다시 시도
맞으면 → 그 뒤를 잘라내고 entries 를 붙임
이 검사 하나가 귀납의 기저이자 단계입니다.
거절-후퇴를 반복하면 반드시 '갈라지기 시작한 지점'을 찾습니다.
커밋의 정의
과반이 그 인덱스를 갖고 있고 + 그것이 현재 임기의 항목일 때
★ 커밋된 항목은 어떤 미래의 리더에게도 반드시 남아 있습니다
이렇게도 쓸 수 있다 — 쓰기 한 번의 진짜 비용 🟡 중급
지연은 로그가 디스크에 닿는 시간이 정합니다
합의 기반 쓰기 한 번의 지연
리더 로그 append + fsync 0.3 ~ 5 ms (디스크에 따라)
+ 과반 팔로워까지 왕복 0.2 ~ 2 ms (같은 리전 기준)
+ 팔로워의 fsync (병렬로 진행)
= 과반 중 '가장 느린' 쪽까지의 시간 ★ 최댓값이지 평균이 아닙니다
리전을 넘기면
서울 ↔ 도쿄 왕복 ≈ 35ms → 쓰기 하나에 35ms 하한
서울 ↔ 버지니아 ≈ 180ms → 초당 5회짜리 쓰기가 됩니다
그래서 실무 규칙
· 합의 클러스터는 같은 리전(다른 AZ)에 둡니다
· 노드는 3 또는 5 — 7 이상은 과반이 커져 느려지기만 합니다
· 쓰기를 묶습니다(배치) — fsync 한 번에 수백 항목
· 읽기는 리더 리스나 팔로워 읽기로 합의를 건너뜁니다
어디에 쓰이나 — 그리고 어디에 안 쓰나
| 쓰는 곳 | 이유 |
|---|---|
| etcd · ZooKeeper · Consul | 클러스터 메타데이터 — 양이 적고 정확해야 함 |
| Kafka KRaft | 파티션 리더·설정 메타데이터 |
| CockroachDB · TiKV | 레인지(샤드)마다 Raft 그룹 하나 |
| 분산 락 · 리더 선출 | “단 하나”가 보장돼야 함 |
| 사용자 데이터 대량 쓰기 | 안 씁니다 — 초당 수십만 쓰기에는 합의가 너무 비쌉니다 |
| 로그·메트릭 수집 | 안 씁니다 — 정확히 하나일 필요가 없고 처리량이 우선입니다 |
로그 기반 아키텍처 — 데이터베이스를 뒤집기
데이터베이스 안에 숨어 있던 로그를 밖으로 꺼내 시스템의 중심축으로 놓으면 아키텍처가 통째로 바뀝니다. 이벤트 소싱 · CQRS · CDC · 카프카가 전부 이 하나의 이동에서 나온 이름들입니다.
이벤트 소싱 + CQRS — 쓰기와 읽기를 갈라 놓기
# ── 쓰기 쪽(Command): 로그에 이벤트만 붙입니다 ──
def place_order(cmd):
stream = f"order-{cmd.order_id}"
state = load(stream) # fold 로 현재 상태 복원
if state.status != "new":
raise Conflict("이미 처리된 주문") # 불변식 검사
events = [{"t": "OrderPlaced", "items": cmd.items,
"total": cmd.total, "at": cmd.at}]
log.append_stream(stream, events,
expected_version=state.version) # ★ 낙관적 동시성
# ── 읽기 쪽(Query): 로그를 구독해 '읽기 모델'을 만듭니다 ──
def projection_worker():
cursor = store.get_cursor("order-list-view")
for off, ev in log.read_from(cursor):
if ev["t"] == "OrderPlaced":
db.upsert_order_row(ev) # 조회에 최적화된 평평한 표
elif ev["t"] == "OrderShipped":
db.set_shipped(ev)
store.set_cursor("order-list-view", off + 1) # 멱등하게 전진
# 읽기 모델은 언제든 지우고 오프셋 0 부터 다시 만들 수 있습니다.
# → 인덱스 설계를 바꾸고 싶으면 새 모델을 만들어 재생하면 끝입니다.
CDC — 기존 DB 를 건드리지 않고 로그를 꺼내기
Change Data Capture: DB 의 WAL 을 그대로 읽어 이벤트로 흘립니다.
PostgreSQL 논리 복제 슬롯(pgoutput · wal2json)
MySQL binlog (ROW 포맷)
MongoDB oplog · change stream
도구 Debezium · Maxwell · AWS DMS
애플리케이션 코드를 하나도 안 고치고 이벤트 스트림이 생깁니다.
{ "op": "u", "source": {"table": "orders", "lsn": 219383},
"before": {"id": 9, "status": "paid"},
"after": {"id": 9, "status": "shipped"} }
★ 주의 — CDC 는 '행이 바뀌었다'만 알려 줍니다.
'왜 바뀌었나'(주문 취소인지 관리자 수정인지)는 없습니다.
그래서 CDC 이벤트는 도메인 이벤트가 아닙니다.
· 통합·색인·데이터 웨어하우스에는 훌륭합니다
· 도메인 로직의 근거로 쓰면 나중에 반드시 후회합니다
이중 쓰기 문제의 해법이기도 합니다 (LOG 03 의 아웃박스와 짝)
DB 커밋 = 이벤트 발행이 되므로 둘이 어긋날 수 없습니다.
이렇게도 쓸 수 있다 — 파티션과 순서 보장의 범위 🟡 중급
순서는 파티션 안에서만 보장됩니다
토픽 orders (파티션 3개)
P0: [주문A생성][주문A결제][주문A배송] ← key = "A"
P1: [주문B생성][주문B취소] ← key = "B"
P2: [주문C생성][주문C결제] ← key = "C"
· 같은 key → 항상 같은 파티션 → 순서 보장 ★
· 다른 파티션 사이에는 순서가 없습니다 (전순서 아님)
· 파티션 = 병렬성의 단위 = 소비자 최대 개수
★ 설계의 핵심은 "무엇을 key 로 둘 것인가"입니다
· 주문 ID → 주문 단위 순서 보장, 고르게 분산
· 사용자 ID → 한 사용자의 사건 순서 보장
· 상수 하나 → 전체 순서 보장, 대신 병렬성 1 (거의 안 씁니다)
핫 파티션 주의
인기 셀러 ID 를 key 로 쓰면 그 파티션만 폭주합니다
→ key 에 접미사를 붙여 쪼개거나, 순서 보장을 포기합니다
이 구조를 고를 때와 피할 때
| 상황 | 판단 |
|---|---|
| “왜 이 값이 됐나”를 자주 묻는다 | 이벤트 소싱이 맞습니다 (금융·정산·의료) |
| 같은 사실을 여러 저장소가 필요 | 로그를 중심에 (검색·캐시·분석) |
| 서비스가 계속 늘어난다 | 로그 구독이 API 호출보다 결합도가 낮습니다 |
| 레거시 DB 를 못 고친다 | CDC 로 밖에서 꺼냅니다 |
| CRUD 화면 몇 개가 전부 | 쓰지 마세요 — 복잡도만 늘어납니다 |
| 강한 즉시 일관성이 필수 | 읽기 모델은 결국 일관입니다. 맞지 않습니다 |
정보량과 로그 — 남기지 않은 것은 복원되지 않습니다
로그를 “얼마나 남길까”는 취향 문제가 아니라 정보 이론 문제입니다. 로그는 과거에서 미래로 가는 채널이고, 채널을 통과하지 못한 정보는 어떤 똑똑한 도구로도 되살릴 수 없습니다.
엔트로피 관점 — 한 줄이 담는 정보의 양
import math
from collections import Counter
def entropy(values):
"""이 필드가 실제로 얼마나 '구별'해 주는가 (비트)"""
n = len(values)
c = Counter(values)
return -sum((k / n) * math.log2(k / n) for k in c.values())
# 하루치 로그 100만 줄에서 필드별로 재 보면
# level (INFO/WARN/ERROR) → 0.3 비트 거의 다 INFO. 쓸모 적음
# service (12종) → 3.1 비트
# endpoint (80종) → 5.9 비트
# status (14종) → 1.2 비트
# trace_id (100만종) → 19.9 비트 ★ 거의 완전한 구별
# msg 자유문자열 → 높지만 질의 불가 (구조가 없어서)
# 해석
# · 엔트로피가 0에 가까운 필드는 남겨도 답을 못 늘립니다
# · 엔트로피가 아주 높은 필드(trace_id)는 '연결'에는 최고지만
# 메트릭 레이블로 쓰면 카디널리티가 폭발합니다 ★
# · 즉, 같은 필드라도 로그에서는 축복, 메트릭에서는 재앙입니다
샘플링이 정확히 무엇을 잃는가
균등 샘플링 1% (모든 요청을 1/100 확률로 남김)
잘 보존되는 것 잃는 것
───────────────── ─────────────────────────────
평균 · 중앙값 단 한 번 일어난 사건 (99% 사라짐)
p50 · p90 정도 p99.9 꼬리 (표본이 거의 안 남음)
전체 비율 특정 사용자의 전체 여정
추세 방향 정확한 카운트 (건수를 못 셉니다)
★ 하루에 3번 난 희귀 오류는 1% 샘플에
기대값 0.03건 → 사실상 '없던 일'이 됩니다.
그래서 실무는 균등 샘플링을 안 씁니다
· 꼬리 우선(tail-based) : 트레이스를 끝까지 모아 보고
느리거나 에러면 100% 남깁니다
· 헤드 기반 + 강제 유지 : 에러·느린 요청은 sampled=1 로 고정
· 계층 샘플링 : 정상 1%, 에러 100%, 신규 기능 100%
· 카운트는 따로 : 샘플링해도 카운터는 전량 집계
(샘플로 곱하기 100 하면 틀립니다)
실무에선 이렇게 — 무엇을 남기면 나중에 답할 수 있나 🔴 실무
질문을 먼저 적고, 그 질문에 필요한 최소 필드를 역산합니다.
질문 필요한 것
─────────────────────────────────── ────────────────────────────
"이 결제가 왜 실패했나" trace_id · reason 코드 · 입력 요약
"어느 고객이 영향을 받았나" tenant/user 식별자 (해시라도)
"언제부터 나빠졌나" 단조 시각 + 배포 버전
"무엇을 바꿨더니 그랬나" version · commit · feature flag 상태
"이 요청이 어디서 느렸나" span 계층 + 각 구간 duration
"재현하려면" 입력의 결정적 요약(해시·크기·경로)
남기지 않기로 정할 것도 명시적으로
· 원문 전체(요청 본문) → 개인정보 · 용량. 요약이나 해시로
· 성공 경로의 DEBUG → 평시에는 끄고 필요할 때만 동적으로 켭니다
· 예측 가능한 반복 → 첫 N건 + 이후 카운트만 (로그 스로틀링)
한 줄 규칙
★ "이 줄이 없으면 대답 못 하는 질문이 있는가?"
없으면 그 줄은 비용일 뿐입니다.
관측 가능성의 이론 — 출력만 보고 내부를 알 수 있는가
관측 가능성(observability)은 마케팅 용어가 아니라 제어이론에서 온 정의가 있는 말입니다. “출력의 유한한 기록만으로 내부 상태를 유일하게 결정할 수 있으면 그 시스템은 관측 가능하다.” 이 정의를 소프트웨어로 옮기면 무엇을 남겨야 하는지가 자동으로 따라 나옵니다.
제어이론의 정의를 그대로 옮기면
선형 시스템 x' = A x + B u , y = C x
관측 가능(observable) 이란?
→ 출력 y 를 유한 시간 동안 관찰한 것만으로
초기 상태 x(0) 을 유일하게 결정할 수 있다는 뜻
판정: 관측 가능성 행렬 O = [C; CA; CA²; …; CAⁿ⁻¹]
rank(O) = n 이면 관측 가능
소프트웨어로 옮기면
x = 프로세스·큐·캐시·커넥션 풀의 내부 상태
y = 우리가 밖으로 내보낸 것 (로그 · 메트릭 · 스팬)
C = 계측(instrumentation) — 무엇을 내보내기로 정했는가
★ 여기서 두 가지가 곧바로 따라 나옵니다
① 관측 가능성은 도구가 아니라 C 의 성질입니다.
비싼 SaaS 를 붙여도 C 가 빈약하면 rank 가 안 찹니다.
② '모니터링'은 이미 아는 y 를 보는 것이고,
'관측 가능성'은 새로운 y 조합으로 새 질문에 답하는 능력입니다.
세 신호의 저장 구조가 능력을 정합니다
| 로그 | 메트릭 | 추적 | |
|---|---|---|---|
| 단위 | 사건 하나 | 시각 + 수치 | 요청 하나 |
| 저장 | 줄 단위 원본 | 미리 집계 | 스팬 트리 |
| 비용 | 양에 선형 | 카디널리티에 곱 | 샘플링률에 선형 |
| 지연 | 초 단위 | 거의 즉시 | 초 단위 |
| 잘하는 질문 | 왜 · 무엇이 | 얼마나 · 언제부터 | 어디서 |
| 못하는 것 | 전체 추세 | 개별 사례 | 전수 통계 |
| 알림에 | 부적합 | 적합 | 부적합 |
이렇게도 쓸 수 있다 — 계측을 설계로 다루기 🟡 중급
# 계측(C 행렬)을 설계한다는 것 — 요청 하나에 '넓은 이벤트' 한 줄
# (여러 줄로 흩어 놓으면 나중에 조인해야 하고, 조인은 대부분 안 됩니다)
def handle_request(req):
ev = {"trace_id": trace_id.get(), "route": req.route,
"tenant": req.tenant, "version": BUILD, "region": REGION}
t0 = time.monotonic() # ★ 벽시계 아님 (LOG 04)
try:
ev["cache_hit"] = cache.probe(req)
ev["db_calls"] = 0 # 핸들러가 늘려 갑니다
result = do_work(req, ev)
ev["status"] = 200
return result
except AppError as e:
ev["status"], ev["error_kind"] = e.status, type(e).__name__
raise
finally:
ev["duration_ms"] = round((time.monotonic() - t0) * 1000, 2)
ev["sampled"] = ev["status"] >= 500 or ev["duration_ms"] > 1000
emit(ev) # 한 요청 = 한 줄. 필드가 30~100개여도 좋습니다
# 이렇게 두면 나중에 이런 질문이 전부 '질의'가 됩니다
# · region=ap 이고 cache_hit=false 인 요청의 p99 는?
# · version 1.42 배포 후 error_kind 분포가 어떻게 변했나?
# · db_calls > 20 인 요청은 어느 tenant 에 몰려 있나? ← 미리 계획 못 한 질문 ★
구조적 로깅과 스키마 진화 — 한 줄은 문장이 아니라 레코드입니다
"Payment failed for order 88123 after 412ms" 는 사람에게는 친절하지만 기계에게는 파싱해야 할 숙제입니다.
로그 한 줄을 필드 이름과 값의 집합으로 보면 로그는 곧 테이블이 되고,
그 순간부터 데이터베이스가 40년 동안 풀어 온 문제 — 스키마는 어떻게 바뀌는가 — 가 그대로 따라옵니다.
문장과 레코드 — 같은 사실, 다른 쓸모
import json, logging, time
# ✗ 문장 — 값이 문장 속에 녹아 있어 기계가 다시 꺼내야 합니다
log.error(f"Payment failed for order {order_id} after {ms}ms")
# "412ms 넘게 걸린 실패는 몇 건?" → 정규식으로 숫자를 뽑아 비교
# 메시지 문구를 한 글자라도 바꾸면 정규식이 깨집니다
# ✓ 레코드 — 필드가 이미 분리돼 있습니다
class JsonFormatter(logging.Formatter):
def format(self, rec):
base = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(rec.created)),
"level": rec.levelname, "logger": rec.name,
"event": rec.getMessage(), "schema": 3}
base.update(getattr(rec, "fields", {})) # extra 로 넘긴 필드
return json.dumps(base, ensure_ascii=False)
def emit(logger, level, event, **fields):
logger.log(level, event, extra={"fields": fields})
emit(log, logging.ERROR, "charge.failed",
order_id=88123, reason="card_declined", duration_ms=412.7)
# 수집기에서는 곧바로 질의가 됩니다
# event = 'charge.failed' AND duration_ms > 400
# GROUP BY reason → 사유별 건수가 한 줄에 나옵니다
charge.failed 처럼 점으로 나눈 고정 문자열이면
오타가 나지 않고, 문구를 다듬어도 질의가 안 깨집니다. 사람이 읽을 문장은 표시할 때 만들면 됩니다.
Serilog 의 메시지 템플릿 {OrderId}, Go 의 slog, Java 의 Logstash 인코더가 전부 같은 생각입니다.
스키마가 없다고 생각하는 순간 스키마가 생깁니다
"우리 로그는 JSON 이라 스키마가 없어요"
→ 대시보드가 duration_ms 를 읽는 순간 그것이 스키마입니다.
암묵적 스키마는 깨질 때까지 보이지 않을 뿐입니다.
깨지는 순간들 (전부 실제로 겪는 것들)
· duration_ms 가 어느 날 문자열 "412.7" 로 바뀜 → 합계가 0
· user 필드가 문자열이었다가 객체 {id, name} 로 → 색인 충돌
· level 이 "WARN" 이었다가 "WARNING" 으로 → 필터 누락
· 필드 이름을 orderId → order_id 로 정리 → 옛 로그 조회 불가
· 시각을 로컬 시간에서 UTC 로 바꿈 → 9시간 어긋난 그래프
그래서 로그에도 데이터베이스와 같은 규칙이 필요합니다
① 스키마를 어딘가에 적어 둔다 (문서든 JSON Schema 든)
② 바꿀 때 호환 규칙을 따른다 (다음 표)
③ 줄마다 schema 버전을 싣는다 - 읽는 쪽이 어느 규칙으로 읽을지 알도록
이렇게도 쓸 수 있다 — 호환성의 네 방향 🟡 중급
누가 먼저 바뀌는가로 규칙이 갈립니다
| 호환 | 뜻 | 허용되는 변경 |
|---|---|---|
| 후방(backward) | 새 코드가 옛 데이터를 읽음 | 필드 추가(기본값 있음) · 필드 삭제 |
| 전방(forward) | 옛 코드가 새 데이터를 읽음 | 필드 추가 · 기본값 있는 필드 삭제 |
| 완전(full) | 양쪽 다 | 기본값 있는 필드 추가·삭제만 |
| 없음 | 둘 다 아님 | 이름 변경 · 타입 변경 · 뜻 변경 |
로그에서는 거의 항상 "후방 + 전방" 둘 다 필요합니다
· 옛 로그를 새 대시보드가 읽는다 → 후방
· 새 로그를 아직 안 고친 알림 규칙이 읽는다 → 전방
· 배포는 서서히 굴러가므로 두 버전이 동시에 씁니다 ★
안전한 변경 위험한 변경 (= 새 필드 + 옛 필드 유지로 우회)
필드 추가 필드 이름 바꾸기
필드 삭제(읽는 쪽 확인) 타입 바꾸기 (숫자 ↔ 문자열)
enum 값 추가 enum 값의 뜻 바꾸기
단위 바꾸기 (ms → s) ★ 가장 교묘함
업캐스터 — 옛 줄을 새 형태로 읽어 올리기
# 스키마 2 → 3 에서 무엇이 바뀌었나
# · duration 이 초(float) 에서 duration_ms(밀리초) 로
# · user(문자열) 이 user_id 로 분리
# 옛 로그는 고칠 수 없습니다(append-only). 읽을 때 올립니다.
UPCASTERS = {
1: lambda r: {**r, "schema": 2, "level": r.get("lvl", "INFO")},
2: lambda r: {**{k: v for k, v in r.items() if k not in ("duration", "user")},
"schema": 3,
"duration_ms": r["duration"] * 1000 if "duration" in r else None,
"user_id": r.get("user")},
}
CURRENT = 3
def read(rec: dict) -> dict:
v = rec.get("schema", 1) # 번호가 없으면 가장 옛 버전
while v < CURRENT:
rec = UPCASTERS[v](rec) # 한 단계씩 올립니다
v = rec["schema"]
return rec
# 규칙
# · 업캐스터는 순수 함수 - 같은 줄은 언제 읽어도 같은 결과 (LOG 02 의 apply 와 같음)
# · 한 번에 한 단계만 - 1→3 지름길을 만들면 조합이 폭발합니다
# · 원본은 그대로 - 저장소에는 여전히 schema 1 줄이 남아 있습니다
BACKWARD / FORWARD / FULL
모드로 호환되지 않는 스키마 등록을 거부하고, Protobuf 는 필드 번호를 절대 재사용하지 말라(reserved)는
규칙 하나로 전방 호환을 지킵니다. 이벤트 소싱의 이벤트 스키마에도 똑같이 적용됩니다.
실무에선 이렇게 — 공통 스키마 위에 올라타기 🔴 실무
필드 이름을 팀마다 새로 짓지 않습니다. 이미 합의된 이름이 있습니다.
OpenTelemetry 로그 데이터 모델 (필드의 뼈대)
Timestamp · ObservedTimestamp 사건 시각 / 수집기가 본 시각 (둘이 다릅니다 ★)
TraceId · SpanId · TraceFlags LOG 04 의 인과 - 추적과 로그를 잇는 열쇠
SeverityText · SeverityNumber "ERROR" 와 17 - 숫자가 있어야 비교·정렬이 됩니다
Body 사람이 읽을 본문 (event 코드를 두기도 합니다)
Resource 누가 - service.name · service.version · host · k8s.pod
InstrumentationScope 어느 라이브러리가
Attributes 나머지 전부 - order_id · reason · duration_ms …
의미 규약(semantic conventions) 이 이름을 정해 둡니다
http.request.method · http.response.status_code · db.system · error.type …
Elastic 의 ECS 가 2023년 OTel 규약으로 합쳐지는 쪽으로 정리됐습니다
→ 이 이름을 쓰면 대시보드·알림 템플릿을 그대로 가져다 씁니다
실무 규칙 넷
① Resource 는 프로세스가 시작할 때 한 번 정하고 모든 줄에 붙입니다 (코드에서 매번 쓰지 않음)
② 도메인 필드는 접두사로 묶습니다 - order.id · order.total · payment.method
③ 같은 뜻은 같은 이름 - user_id 와 userId 와 uid 가 섞이면 조인이 안 됩니다
④ 스키마 문서와 예제 줄을 코드 저장소에 같이 둡니다 - 리뷰 대상입니다
로그 컴팩션과 보존 — 영원히 남기지는 못합니다
로그는 append-only 지만 디스크는 유한합니다. 무엇인가는 버려야 하는데, 버리는 방법이 곧 시스템이 답할 수 있는 질문을 정합니다. 시간으로 자를지, 키마다 마지막만 남길지, 싼 저장소로 옮길지 — 셋은 서로 다른 것을 잃습니다.
삭제와 컴팩션 — 같은 “버리기”, 다른 결과
보존(retention) - 오래된 세그먼트를 통째로 버립니다
[seg0][seg1][seg2][seg3][seg4 활성]
↑ 7일 지남 → unlink ★ 파일 하나 지우면 끝
남는 것: 최근 N일의 모든 사건
잃는 것: N일 전의 모든 것 - 그때 만들어진 키의 마지막 값까지
컴팩션(compaction) - 키마다 마지막 값만 남깁니다
전: k1=a k2=x k1=b k3=p k2=y k1=c k3=∅(툼스톤)
후: k2=y k1=c (k3 는 툼스톤 뒤 삭제)
남는 것: 모든 키의 최신 값 - 오프셋 0 부터 읽으면 현재 상태 전체
잃는 것: k1 이 a→b→c 로 변한 과정
★ 같은 질문에 다르게 답합니다
"k1 의 지금 값은?" 보존: 7일 전 키면 모름 / 컴팩션: 항상 답함
"k1 이 어제 뭐였나?" 보존: 답함 / 컴팩션: 모름
"지난주 이벤트 수는?" 보존: 답함 / 컴팩션: 모름 (사건 수가 보존 안 됨)
apply 가 “마지막 값으로 덮기”라면
fold 결과는 곧 “키별 최신 값”이고, 그것만 남기면 재생 시간이 이벤트 수가 아니라 키 수에 비례합니다.
Kafka Streams 의 상태 저장소 · Debezium 의 스냅샷 토픽이 이렇게 복구됩니다.
컴팩션을 직접 구현하면 (세그먼트 재작성)
TOMBSTONE = None
def compact(segments):
"""오래된 세그먼트들을 읽어 키별 마지막 값만 남긴 새 세그먼트를 만듭니다."""
latest = {} # key -> (offset, value)
for seg in segments: # 오래된 것부터 순서대로
for off, key, val in seg.read():
latest[key] = (off, val) # 뒤에 온 것이 이깁니다
out = Segment.new()
for key, (off, val) in sorted(latest.items(), key=lambda kv: kv[1][0]):
if val is TOMBSTONE:
continue # 툼스톤은 유예 기간 뒤 함께 사라집니다
out.append(off, key, val) # ★ 오프셋은 원래 번호를 그대로 유지
out.seal()
for seg in segments:
seg.unlink() # 옛 세그먼트는 통째로 삭제
return out
# 지켜야 할 것
# · 활성(쓰는 중인) 세그먼트는 건드리지 않습니다 - 읽기·쓰기와 경쟁하지 않도록
# · 오프셋을 다시 매기지 않습니다 - 소비자의 커서가 깨집니다 (LOG 01)
# 그래서 컴팩션 뒤의 로그는 오프셋에 빈 자리가 생깁니다. 정상입니다.
# · 툼스톤은 바로 지우지 않습니다 - 느린 소비자가 "삭제됐다"를 보고 가야 합니다
이렇게도 쓸 수 있다 — 같은 발상이 나타나는 세 곳 🟡 중급
Kafka 설정으로 읽는 두 정책
# 토픽 단위 설정 (이름은 실제 설정 키입니다)
cleanup.policy=delete # 기본. 시간·크기로 세그먼트 삭제
retention.ms=604800000 # 7일 (브로커 기본 log.retention.hours=168)
retention.bytes=-1 # 크기 제한 없음 (-1)
segment.bytes=1073741824 # 세그먼트 1GiB - 삭제 단위이자 컴팩션 단위
cleanup.policy=compact # 키별 마지막 값만 유지
min.cleanable.dirty.ratio=0.5 # 아직 안 접힌 부분이 절반 넘으면 컴팩션 시작
min.compaction.lag.ms=0 # 이 시간 안의 최신 줄은 접지 않음 (이력 잠깐 보존)
delete.retention.ms=86400000 # 툼스톤을 24시간 유지한 뒤 제거
cleanup.policy=compact,delete # 둘 다 - 키별 최신 + 오래된 것은 삭제
★ 컴팩션 토픽은 key 가 없는 메시지를 거부합니다 - 키가 없으면 "마지막"의 기준이 없으니까요
★ 계층 저장(tiered storage)은 오래된 세그먼트를 객체 저장소로 옮기고
브로커에는 최근 것만 둡니다 - 보존 기간을 늘려도 로컬 디스크가 안 늘어납니다
Raft 의 스냅샷 · LSM 의 컴팩션
| 곳 | 버리는 방법 | 남기는 것 |
|---|---|---|
| Raft | 상태 기계 스냅샷을 찍고 그 앞의 로그를 잘라냄 | 스냅샷 + lastIncludedIndex/Term |
| LSM 트리 | 여러 SSTable 을 합치며 키별 최신만 남김 | 레벨별 정렬 파일 |
| Git | gc 가 도달 불가 객체를 지우고 팩으로 묶음 | 참조에서 닿는 커밋 전부 |
| WAL(DB) | 체크포인트 뒤 옛 WAL 파일 재활용 | 체크포인트 이후만 |
Raft 스냅샷 - 뒤처진 팔로워에게는 로그 대신 스냅샷을 보냅니다
InstallSnapshot(term, lastIncludedIndex, lastIncludedTerm, data)
· 팔로워는 스냅샷을 적용하고 그 이전 로그를 버립니다
· 그 뒤 로그는 평소처럼 AppendEntries 로
LSM 컴팩션의 두 전략 (RocksDB 용어)
leveled 쓰기 증폭 큼 · 공간 효율 좋음 · 읽기 빠름
universal 쓰기 증폭 작음 · 공간 더 씀 · 읽기 느림
→ 같은 트레이드오프의 축이 위 히트맵과 같습니다
실무에선 이렇게 — 잊힐 권리와 append-only 의 타협 🔴 실무
"지워 달라"는 요구는 append-only 와 정면으로 부딪힙니다. 실무의 세 가지 답:
① 컴팩션 토픽 + 툼스톤
key=user:118 value=∅ 를 붙이면 delete.retention.ms 뒤 그 키의 모든 값이 사라집니다
· 되는 것: "현재 상태" 토픽에서 그 사용자를 지움
· 안 되는 것: 시간 보존 토픽·백업·콜드 저장소에 남은 옛 줄 (거기까지 따라가야 합니다)
② 암호 파쇄(crypto-shredding)
개인 필드는 사용자별 키로 암호화해 로그에 두고, 키는 로그 밖 금고에
· 지우기 = 금고에서 키 삭제. 로그는 한 줄도 안 고칩니다 ★
· 로그·백업·콜드가 몇 벌이든 한 번에 무력화됩니다
· 대가: 키 관리(회전·백업·접근 통제)가 새 책임이 됩니다 (🔐 보안 · 암호)
③ 로그 밖 참조
로그에는 user_id 만, 이름·이메일은 별도 테이블
· 가장 단순. 대신 로그만으로는 "누구"를 못 봅니다 (조인 필요)
보존 기간은 법이 정하는 하한과 비용이 정하는 상한 사이입니다
· 금융·의료는 수년 단위 보존 의무가 있고, 접속 기록도 법령이 최소 기간을 정합니다
· 그 위로는 "이 줄이 없으면 못 답하는 질문이 있나"(LOG 07)가 상한을 정합니다
· 기간은 데이터 종류마다 다르게 - 감사 로그 수년 · 요청 로그 몇 주 · DEBUG 며칠
무결성 — 해시 체인 · 머클 트리 · 인증서 투명성
“append-only” 는 지금까지 약속이었습니다. 관리자가 파일을 열어 한 줄을 고치면 아무도 모릅니다. 그 약속을 수학으로 검증 가능하게 만드는 것이 이 절입니다 — 고친 것을 막지는 못해도 고쳤다는 사실은 반드시 드러나게 합니다.
1단계 — 해시 체인: 앞 줄의 해시를 다음 줄에 넣기
import hashlib, json
def h(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
class ChainedLog:
def __init__(self):
self.items, self.prev = [], "0" * 64 # 창세기 해시
def append(self, record: dict) -> str:
body = json.dumps(record, sort_keys=True) # ★ 직렬화가 결정적이어야
entry = {"prev": self.prev, "body": body} # 같은 입력 → 같은 해시
entry["hash"] = h((entry["prev"] + body).encode())
self.items.append(entry)
self.prev = entry["hash"] # 다음 줄이 이 값을 품습니다
return self.prev
def verify(self) -> bool:
prev = "0" * 64
for i, e in enumerate(self.items):
if e["prev"] != prev: return False # 끊김 → 중간 삭제·삽입
if h((e["prev"] + e["body"]).encode()) != e["hash"]:
return False # 불일치 → 내용 변조
prev = e["hash"]
return True
# Git 이 정확히 이것입니다 - 커밋 해시 = H(트리 + 부모 해시 + 메시지 + …)
# 과거 커밋 하나를 고치면 그 뒤 모든 커밋의 해시가 바뀝니다 (rebase 가 그것)
# 블록체인은 여기에 "누가 다음 줄을 붙일지" 합의를 얹은 것입니다
2단계 — 머클 트리: 포함 증명을 log n 으로
def merkle_root(leaves: list[str]) -> str:
level = [h(("\x00" + x).encode()) for x in leaves] # 잎 접두사 0x00
while len(level) > 1:
if len(level) % 2: level.append(level[-1]) # 홀수면 마지막 복제
level = [h(("\x01" + a + b).encode()) # 내부 노드 접두사 0x01
for a, b in zip(level[::2], level[1::2])]
return level[0]
# 접두사를 다르게 두는 이유: 잎과 내부 노드가 같은 해시를 내는 공격(second preimage) 방지
def inclusion_proof(leaves, idx):
"""idx 번째 잎의 형제 해시 목록 - 검증자는 이것만 받습니다"""
level = [h(("\x00" + x).encode()) for x in leaves]; proof = []
while len(level) > 1:
if len(level) % 2: level.append(level[-1])
sib = idx ^ 1 # 이웃 인덱스
proof.append((level[sib], sib < idx)) # (해시, 왼쪽인가)
level = [h(("\x01" + a + b).encode()) for a, b in zip(level[::2], level[1::2])]
idx //= 2
return proof
def verify(leaf, proof, root) -> bool:
cur = h(("\x00" + leaf).encode())
for sib, is_left in proof:
cur = h(("\x01" + (sib + cur if is_left else cur + sib)).encode())
return cur == root # ★ 잎 하나 + 형제 log n 개로 루트를 다시 만듭니다
# 100만 항목이면 증명 크기 ≈ 20 해시 = 640 바이트. 로그 전체를 받을 필요가 없습니다.
이렇게도 쓸 수 있다 — Certificate Transparency — 공개 로그로 CA 를 감시하기 🟡 중급
누가 어떤 인증서를 발급했는지 전부 공개 로그에
문제(2011년 전후) - CA 가 해킹당하거나 실수하면
google.com 인증서가 엉뚱한 사람에게 발급되고, 아무도 모릅니다.
(DigiNotar 사건이 대표적 - CA 자체가 뚫려 가짜 인증서가 발급됨)
CT 의 답 (RFC 6962 · 2013 / 개정 RFC 9162 · 2021) - 발급을 숨길 수 없게
① CA 가 인증서를 발급하면 공개 CT 로그에 제출합니다
② 로그는 머클 트리에 추가하고 SCT(Signed Certificate Timestamp) 를 돌려줍니다
"이 인증서를 이 시각에 받았고 곧 트리에 넣겠다"는 서명된 약속
③ 인증서(또는 TLS 핸드셰이크)에 SCT 를 실어 보냅니다
④ 브라우저는 SCT 가 없거나 부족하면 인증서를 거부합니다
(Chrome 은 2018년 4월부터 신규 인증서에 CT 를 요구)
⑤ 모니터가 로그를 계속 읽으며 "내 도메인 인증서가 누가 냈나"를 감시합니다
⑥ 감사자는 일관성 증명으로 로그가 뒤에서 지워지지 않았는지 확인합니다
★ 핵심은 "가짜 발급을 막는다"가 아니라 "가짜 발급이 반드시 공개된다"입니다.
가짜 인증서는 여전히 만들 수 있지만, 만들면 로그에 남고 도메인 주인이 봅니다.
투명성 로그가 지키는 것 · 못 지키는 것
| 위협 | 막히나 | 어떻게 |
|---|---|---|
| 중간 항목 변조 | 드러남 | 루트 해시 불일치 |
| 중간 항목 삭제·삽입 | 드러남 | 일관성 증명 실패 |
| 끝을 잘라내고 다시 쓰기 | 드러남 | 이미 공개된 옛 루트와 어긋남 |
| 사람마다 다른 로그 보여주기 | 부분적 | 루트를 서로 교환(gossip)해야 잡힘 |
| 애초에 거짓을 기록 | 못 막음 | 로그는 “적힌 대로”만 증명 |
| 기록을 아예 안 함 | 못 막음 | 브라우저가 SCT 를 요구해야 강제됨 |
실무에선 이렇게 — 우리 감사 로그를 변조 방지로 만들려면 🔴 실무
내부 감사 로그(누가 무엇을 승인·삭제·조회했나)에 같은 원리를 씁니다. 순서대로:
① 결정적 직렬화 같은 레코드는 항상 같은 바이트여야 합니다
- 키 정렬 · 공백 고정 · 부동소수 표기 고정 (JSON 이면 canonical 형식)
② 줄마다 prev 해시 해시 체인. 여기까지는 라이브러리 없이 30줄
③ 주기적 루트 공개 하루치(또는 N 줄)마다 머클 루트를 만들어
- 다른 시스템(별도 계정의 저장소 · 외부 타임스탬프 서비스) 에 적습니다
- 로그를 고칠 수 있는 사람이 이 루트까지 고칠 수 없어야 뜻이 있습니다 ★
④ 검증 잡 매일 처음부터 체인·루트를 다시 계산해 공개된 값과 비교
- 불일치는 "버그"가 아니라 "사고"로 취급합니다
⑤ 쓰기 경로 분리 로그를 쓰는 서비스 계정은 append 만, 삭제 권한은 아무에게도
- 클라우드의 객체 잠금(WORM · Object Lock) 같은 저장소 기능을 씁니다
하지 말 것
· 로그를 그 로그를 만드는 DB 안에 해시만 붙여 두기 - DBA 가 둘 다 고칩니다
· 블록체인부터 도입하기 - 참여자가 한 조직이면 해시 체인 + 외부 공개로 충분합니다
· 타임스탬프를 신뢰 근거로 쓰기 - 시각은 조작이 가장 쉬운 필드입니다 (LOG 04)
로그로 못 잡는 것 — 증거이지 진실이 아닙니다
열한 절 동안 로그가 얼마나 강력한지 봤으니 마지막은 한계입니다. 로그는 “적힌 것”이지 “일어난 것”이 아닙니다. 둘 사이의 틈에서 없는 줄을 없는 일로 읽고, 있는 줄을 원인으로 읽는 실수가 나옵니다.
침묵 — 없는 줄은 없는 일이 아닙니다
"에러 로그가 없으니 정상입니다" 가 틀리는 경우
· 요청이 서버에 닿지 못했다 DNS · LB · 네트워크 - 서버 로그엔 처음부터 없음
· 프로세스가 로그를 쓰기 전에 죽었다 OOM kill · SIGKILL 은 finally 를 안 거칩니다
· 로거의 버퍼가 함께 죽었다 비동기 로거는 크래시 때 마지막 몇 초를 잃습니다
· 레벨 필터가 그 줄을 걸렀다 운영은 WARN 이상 - 그 오류는 INFO 였습니다
· 샘플링에 버려졌다 LOG 07 - 1% 샘플링은 희귀 사건을 99% 지웁니다
· 디스크가 찼다 로거가 실패했는데 그 실패를 적을 곳이 없습니다
· 수집기가 못 받았다 에이전트 재시작 · 백프레셔로 드롭 (조용히)
★ 서버 로그에는 서버에 닿은 요청만 있습니다 - 생존자 편향의 교과서 사례
"우리 쪽 성공률 99.9%" 와 사용자의 "자꾸 안 돼요" 가 동시에 참일 수 있습니다
대응 - 부재를 신호로 바꿉니다
· 하트비트 : "살아 있다"를 주기적으로 적고, 끊기면 알림 (dead man's switch)
· 양쪽 계측 : 클라이언트에서도 시도·실패를 보냅니다 (RUM · 모바일 SDK)
· 기대 카운트 : 매 시간 배치가 N 건이어야 한다 - 0 건은 "조용함"이 아니라 "장애"
· 종료 훅 + 동기 플러시 : 정상 종료 경로에서만이라도 버퍼를 비웁니다
거짓 신호 — 있는 줄이 사실이 아닐 때
# ✗ 가장 흔한 계측 버그 - 결과가 정해지기 전에 "성공"을 적습니다
def transfer(src, dst, amt):
log.info("transfer.ok", src=src, dst=dst, amt=amt) # 아직 아무것도 안 했음
with db.transaction():
debit(src, amt)
credit(dst, amt) # 여기서 예외 → 롤백. 로그에는 성공이 남습니다
# ✓ 결과가 확정된 뒤에, 확정된 값으로
def transfer(src, dst, amt):
try:
with db.transaction():
debit(src, amt); credit(dst, amt)
except Exception as e:
log.error("transfer.failed", src=src, dst=dst, amt=amt,
error_kind=type(e).__name__)
raise
log.info("transfer.ok", src=src, dst=dst, amt=amt) # 커밋 뒤
# 그 밖의 거짓 신호
# · 재시도 로그를 원인으로 읽기 - 재시도는 결과입니다. 첫 실패를 찾으세요
# · 재생(replay) 중 다시 찍힌 로그 - LOG 02 의 부수효과 분리가 안 된 경우
# · 타임스탬프가 어긋나 "결과가 원인보다 먼저" 보이는 줄 (LOG 04)
# · 복사한 코드가 남긴 남의 이름 - log.info("cache.hit") 인데 실제로는 miss
이렇게도 쓸 수 있다 — 관측자 효과와 로거 자체의 실패 🟡 중급
보는 행위가 대상을 바꿉니다
하이젠버그(Heisenbug) - 로그를 켜면 사라지는 버그
· 로깅이 타이밍을 바꿔 경쟁 상태가 안 일어납니다
(I/O 한 번이 마이크로초 단위 경합 창을 덮어 버립니다)
· DEBUG 를 켜니 GC 압력이 늘어 원래 문제가 다른 문제로 바뀝니다
· 로그 I/O 가 락 안에서 일어나 락 유지 시간이 늘어납니다 ★ 자주 봅니다
로거가 병목이 되는 순간
· 동기 로거 + 느린 디스크 - 요청 스레드가 fsync 를 기다립니다
· 로그 문자열을 만드는 비용 - 호출 자체가 비싼 toString() · 직렬화
→ 레벨을 먼저 확인하고 만듭니다 (isEnabled 가드 · 지연 평가)
· 락 안의 로깅 - 임계 구역이 I/O 시간만큼 길어집니다
로거가 사고가 되는 순간
· 로그 라이브러리의 취약점 - 2021년 12월 Log4Shell(CVE-2021-44228):
로그에 적힌 문자열이 코드로 실행됐습니다. 로거도 입력을 받는 코드입니다
· 시그널 핸들러 · 인터럽트 안에서의 로깅 - 락을 다시 잡아 교착
· 로그로 디스크가 차서 DB 가 멈춤 - 로그가 서비스를 죽인 사례는 흔합니다
일곱 가지 한계와 각각의 대응
| 못 잡는 것 | 왜 | 대응 |
|---|---|---|
| 일어나지 않은 일 | 부재는 기록되지 않음 | 하트비트 · 기대 카운트 |
| 닿지 않은 요청 | 생존자 편향 | 클라이언트 측 계측 |
| 인과 | 로그는 상관·순서만 | trace 의 parent 관계 · 실험 |
| 계측 전의 과거 | 어제 데이터엔 오늘 필드가 없음 | 설계 단계에서 계측(LOG 08) |
| 모르는 질문 | C 행렬은 사람이 정함 | 넓은 이벤트 · 원본 보존 |
| 로그 자체의 실패 | 실패를 적을 곳이 없음 | 외부 감시 · 수집 지표 |
| 의도 | “왜 그랬나”는 코드 밖 | 도메인 이벤트에 이유 필드 |
실무에선 이렇게 — 사고 조사에서 로그를 읽는 규율 🔴 실무
로그를 읽을 때 스스로에게 묻는 순서 (이 탭 전체의 요약이기도 합니다)
① 이 줄은 언제 적혔나 - 사건 시각인가, 수집 시각인가, 어느 시계인가 (LOG 04 · 09)
② 이 줄이 없는 구간은 조용한 것인가, 잃은 것인가 - 샘플링·버퍼·필터·수집 상태를 먼저 확인 (LOG 07 · 12)
③ 이 줄은 결과가 확정된 뒤에 적힌 것인가 - "성공" 로그의 위치를 코드에서 확인 (LOG 12)
④ 순서의 근거는 무엇인가 - 타임스탬프인가, trace 의 parent 인가, 파티션 오프셋인가 (LOG 04 · 06)
⑤ 같은 사실을 말하는 두 번째 출처가 있나 - DB 의 실제 값, 상대 시스템, 고객 화면 (LOG 12)
⑥ 이 로그가 그 뒤 고쳐졌을 가능성은 - 감사 로그라면 체인·루트가 맞는지 (LOG 11)
⑦ 지금 없는 필드는 무엇이고, 다음엔 어떻게 남길 것인가 - 사후 검토의 액션 아이템 (LOG 07 · 08)
로그가 답하는 질문과 답하지 못하는 질문을 구분해 보고서에 적습니다
"로그에 따르면 03:12:44 에 결제 서비스가 card_declined 를 반환했다" ← 로그가 답함
"그 원인은 카드사 응답 지연이었다" ← 로그만으로는 추정. 근거 출처를 따로
한 줄 규칙
★ 로그는 증언입니다. 증언은 교차 검증되기 전까지 사실이 아닙니다.
구조는
무엇이 함께
바뀌는가로 나눈다
언어마다 폴더 이름은 다르지만 왜 그렇게 나누는지는 같습니다. 바꿀 때 덜 건드리고, 처음 보는 사람이 덜 헤매고, 여러 사람이 서로를 덜 기다리게 하려는 것입니다. 이 탭은 특정 언어가 아니라 구조의 원리를 봅니다 — 응집과 결합, 의존 방향의 원칙, 계층형과 기능별, 헥사고날 · 클린, UI 패턴, 모듈러 모놀리스와 마이크로서비스, 이벤트 · 파이프라인 · 플러그인. 그리고 앱 유형마다 무엇이 권장되는지 한 장으로 정리한 뒤 자바 · 코틀린 · 파이썬 · TS · C# · C++ · Rust · C · DB · 서버 가이드의 구조 탭으로 연결합니다. 마지막은 규칙을 기계로 지키는 법과, 결정을 기록하며 구조를 조금씩 옮기는 법입니다.
구조는 왜 필요한가 — "무엇이 함께 바뀌나"로 나누기
구조가 없어도 프로그램은 돕니다. 구조가 줄여 주는 것은 실행 비용이 아니라 사람의 비용 세 가지입니다 — 변경 비용(하나 고칠 때 몇 군데를 건드리나), 이해 비용(새 사람이 몇 파일을 읽어야 하나), 조율 비용(여러 사람이 서로를 얼마나 기다리나). 응집도와 결합도의 정의는 SE 01 에 있으니, 여기서는 그 자를 폴더와 모듈을 자르는 칼로 씁니다.
같은 앱 — 함께 바뀌는 것끼리 모은 모양
shop/
├─ orders/ # 주문이 바뀌면 여기만 — 화면·규칙·저장이 한 폴더
│ ├─ api # 요청을 받는 입구
│ ├─ rules # 할인 · 취소 가능 여부 같은 업무 규칙
│ └─ store # 저장 방식
├─ members/ # 회원 기능 — orders 와는 공개 입구로만 대화
├─ billing/ # 결제 — 외부 PG 가 바뀌면 여기만
├─ shared/ # 진짜 공통만: 금액 · 시간 · 에러 타입
└─ main # 조립 — 무엇을 무엇에 꽂을지 정하는 한 곳
자를 때 쓰는 질문 다섯
| 질문 | 답이 "예"면 |
|---|---|
| 이 둘은 항상 같이 커밋되나? | 같은 폴더로 — 떨어져 있으면 변경이 흩어집니다 |
| 이 둘은 바뀌는 이유가 다른가? | 나눕니다 — 규칙(업무)과 기술(DB · HTTP)이 대표적 |
| 이걸 쓰는 쪽이 전부 다 필요한가? | 아니면 쪼갭니다 — 공통 재사용 원칙(CRP) |
| 다른 팀이 이걸 자주 고치나? | 경계를 그어 소유자를 정합니다 |
이름이 utils · common 인가? | "갈 곳 없는 것들의 창고" — 원래 주인에게 돌려보냅니다 |
경계가 맞는지 재 보는 법 🟢 기초
# 지난 6개월 커밋 중 "폴더를 몇 개 건드렸나" 분포
git log --since="6 months ago" --name-only --format="tformat:@@" \
| awk '/^@@/{if(n)print n; delete s; n=0; next}
NF{split($0,p,"/"); if(!(p[1] in s)){s[p[1]]=1; n++}}
END{if(n)print n}' \
| sort -n | uniq -c
# 412 1 ← 한 폴더에서 끝난 커밋
# 57 2
# 31 4 ← 늘 같이 움직이는 폴더 묶음이 있다는 뜻
# 출력 숫자는 예시입니다
| 지표 | 좋은 쪽 |
|---|---|
| 커밋당 건드린 최상위 폴더 수 | 1 이 대부분 — 변경이 국소적 |
| 기능 하나 이해에 읽는 파일 수 | 한 폴더 안에서 끝남 |
| PR 충돌 · 리뷰 대기 | 폴더별 소유자가 있으면 줄어듦 |
| 빌드 · 테스트 시간 | 바뀐 모듈만 다시 — 3단계의 이득 |
❌ 첫날부터 4단계
order-service/ # 저장소 1
member-service/ # 저장소 2
common-lib/ # 저장소 3 — 버전 맞추기가 일이 됨
# 화면 열 개짜리 앱에 배포 파이프라인 세 개
✅ 지금 크기에 맞게
shop/ # 저장소 1 · 배포 1
├─ orders/ # 기능별 폴더 (2단계)
└─ members/
# 경계가 검증되면 그때 모듈 · 서비스로
의존 방향의 원칙 — 안정된 쪽으로 기대고, 고리는 만들지 않는다
나누기만 하면 끝이 아닙니다. 나눈 덩어리 사이의 화살표 방향이 구조의 품질을 정합니다. 로버트 C. 마틴이 정리한 패키지 원칙 셋 — ADP(순환 금지) · SDP(안정된 쪽으로 의존) · SAP(안정할수록 추상적으로) — 과 의존성 역전(DIP) 하나면 대부분의 구조 논쟁을 화살표로 정리할 수 있습니다.
원칙 넷 — 실무 언어로
| 원칙 | 실무 문장 | 어기면 |
|---|---|---|
| ADP 비순환 의존 | 패키지 사이에 고리가 없어야 합니다 | 하나 고치면 고리 전체를 다시 빌드 · 배포 |
| SDP 안정 의존 | 자주 바뀌는 쪽이 잘 안 바뀌는 쪽에 기댑니다 | 공용 코드가 화면 때문에 매주 바뀜 |
| SAP 안정 추상 | 많이 기대지는 곳일수록 인터페이스 · 값 타입 위주로 | 굳은 구체 코드 = 고칠 수 없는 코드 |
| DIP 의존성 역전 | 업무 규칙이 약속(인터페이스)을 정의하고 기술 코드가 그걸 구현 | 규칙이 DB 드라이버를 import |
I 와 순환을 직접 세어 보기 (파이썬 3.13)
deps = {
"web": ["orders", "billing"],
"batch": ["billing"],
"orders": ["billing", "core"],
"billing": ["core"],
"core": [],
}
def instability(deps):
ca = {p: 0 for p in deps} # 들어오는 의존
for targets in deps.values():
for t in targets:
ca[t] += 1
return {p: round(len(deps[p]) / (ca[p] + len(deps[p])), 2)
if ca[p] + len(deps[p]) else 0.0 for p in deps}
print(instability(deps))
# {'web': 1.0, 'batch': 1.0, 'orders': 0.67,
# 'billing': 0.25, 'core': 0.0}
# core 에 "web" 을 추가하면 DFS 가 고리를 찾습니다:
# ['web', 'orders', 'billing', 'core', 'web']
고리를 끊는 세 가지 방법 🟡 중급
순환: billing → orders (주문 상태 조회)
orders → billing (결제 요청)
① 의존성 역전 — 약속을 아래로 내린다
orders/ OrderPort (interface) ← billing 은 이것만 앎
OrderService implements OrderPort
② 공통 부분을 새 패키지로 뽑는다
payment-core/ PaymentId · Money ← 둘 다 여기에 기댐
③ 이벤트로 바꾼다 — 호출 대신 알림
billing 이 "PaymentCompleted" 발행 → orders 가 구독
(STR 07 · 이벤트 기반)
// ① 을 TypeScript 로 — 약속은 쓰는 쪽(도메인)에 둔다
// domain/order-repo.ts
export interface OrderRepo {
find(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// infra/pg-order-repo.ts — 기술 코드가 안쪽 약속을 구현
import type { OrderRepo } from "../domain/order-repo";
export class PgOrderRepo implements OrderRepo { /* SQL */ }
// domain 폴더에는 "../infra" import 가 한 줄도 없어야 합니다
❌ 공용이 위를 안다
common/format.ts
import { OrderDto } from "../web/dto" # 공용 → web
# 가장 많이 쓰이는 파일이 가장 자주 바뀌는 파일에 묶임
✅ 공용은 아무도 모른다
common/money.ts # 값 타입 · 순수 함수만
web/dto.ts
import { Money } from "../common/money" # web → 공용
# 화살표가 전부 아래(안정)로
계층형 vs 기능별 — 가로로 자를까, 세로로 자를까
같은 앱도 두 방식으로 자를 수 있습니다. 계층형(package by layer)은 기술 역할로 가로로 — controllers · services · repositories. 기능별(package by feature · vertical slice)은 업무 기능으로 세로로 — orders · members · billing. 처음엔 계층형이 깔끔해 보이지만, 기능이 늘수록 기능 하나 고치는 데 폴더 네 개를 오가게 됩니다.
계층형 — 기술 역할로 가로 분할
src/
├─ controllers/ # 모든 기능의 HTTP 입구
│ ├─ OrderController
│ └─ MemberController
├─ services/ # 모든 기능의 업무 로직
│ ├─ OrderService
│ └─ MemberService
├─ repositories/ # 모든 기능의 저장
└─ models/ # 모든 엔티티 · DTO 가 한곳에
# 장점: 처음 보는 사람도 "역할"로 찾음
# 단점: 기능이 20개면 각 폴더에 파일 20개+
기능별 — 업무 기능으로 세로 분할
src/
├─ orders/ # 주문의 모든 것
│ ├─ order-controller
│ ├─ order-service
│ ├─ order-repo
│ └─ index # 밖에 공개하는 것만 (입구)
├─ members/
├─ billing/
└─ shared/ # 기능 이름이 안 들어간 것만
# 장점: 변경이 한 폴더 · 지우기도 폴더째
# 단점: 공통 코드 위치를 정하는 규칙이 필요
실무 기본값 — 기능이 1차, 계층이 2차 🟡 중급
| 상황 | 권장 |
|---|---|
| CRUD 위주 · 기능 몇 개 | 계층형도 충분 — 단순함이 이김 |
| 기능 5개 이상 · 사람 2명 이상 | 기능별, 기능 폴더 안에서 필요하면 계층 |
| 기능 안의 규칙이 두꺼움 | 기능 폴더 안을 domain · application · infra 로 (STR 04) |
| 프런트엔드 | 기능별(features/) + 공용 UI(components/) 조합이 흔함 |
internal 가시성)를 두고 그것만 쓰게 합니다.
// orders/index.ts — 이 기능이 밖에 내보내는 것의 전부
export { placeOrder, cancelOrder } from "./order-service";
export type { OrderSummary } from "./types";
// order-repo, 할인 계산 같은 내부는 내보내지 않음
// members/… 에서
import { placeOrder } from "../orders"; // ✓ 입구로
import { applyDiscount } from "../orders/discount"; // ✗ 내부 직접
// ✗ 줄은 린트 규칙으로 막습니다 (STR 09)
internal · Rust pub(crate) · TS index.ts + 린트 · 파이썬 __all__ 과 밑줄 관례.
헥사고날 · 클린 · 어니언 — 도메인을 가운데, 의존은 안쪽으로
세 이름의 공통 핵심 — 업무 규칙이 프레임워크 · DB 를 모르게 하고 의존은 안쪽으로만 흐르게 한다 — 과 "흔한 구조 vs 뒤집은 구조" 그림은 SE 06 에 있습니다. 여기서는 셋이 어디가 다른지, 폴더로 옮기면 어떤 모양인지, 그리고 언제 과설계가 되는지만 봅니다.
셋의 차이 — 강조점이 다를 뿐
| 이름 | 처음 낸 사람 | 강조하는 것 |
|---|---|---|
| 헥사고날 (포트와 어댑터) | 앨리스터 코번 · 2005 | 안과 밖의 대칭 — 들어오는 쪽(HTTP · CLI · 테스트)도 나가는 쪽(DB · 메일)도 전부 어댑터 |
| 어니언 | 제프리 팔레르모 · 2008 | 겹 — 도메인 모델 · 도메인 서비스 · 앱 서비스 · 인프라 |
| 클린 | 로버트 C. 마틴 · 2012 | 의존성 규칙이라는 한 문장 + 유스케이스 단위의 경계 |
폴더로 옮기면 — 기능 하나 안에서
orders/
├─ domain/ # Order · Money · 취소 규칙 — 순수 코드
├─ application/ # cancel_order() 유스케이스
│ └─ ports.py # OrderStore (나가는 포트) — 안쪽 소유
├─ adapters/
│ ├─ inbound/ # HTTP 라우터 · CLI · 메시지 소비자
│ └─ outbound/ # PostgresOrderStore · SmtpNotifier
└─ wiring.py # 어떤 어댑터를 꽂을지 — 조립은 한 곳
# import 방향: adapters → application → domain (반대 금지)
포트 하나, 어댑터 둘 — 테스트가 쉬워지는 이유 🟡 중급
from dataclasses import dataclass
from typing import Protocol
@dataclass
class Order: # domain — 아무것도 import 안 함
id: str
amount: int
status: str = "NEW"
def cancel(self) -> None:
if self.status == "SHIPPED":
raise ValueError("배송된 주문은 취소할 수 없습니다")
self.status = "CANCELLED"
class OrderStore(Protocol): # 나가는 포트 — 안쪽이 정의
def get(self, order_id: str) -> Order: ...
def put(self, order: Order) -> None: ...
def cancel_order(store: OrderStore, order_id: str) -> Order:
order = store.get(order_id) # 유스케이스 — DB 를 모름
order.cancel()
store.put(order)
return order
class MemoryStore: # 어댑터 ① — 테스트용
def __init__(self, *orders: Order):
self.rows = {o.id: o for o in orders}
def get(self, order_id: str) -> Order:
return self.rows[order_id]
def put(self, order: Order) -> None:
self.rows[order.id] = order
store = MemoryStore(Order("A1", 12000),
Order("B2", 5000, "SHIPPED"))
print(cancel_order(store, "A1").status) # CANCELLED
cancel_order(store, "B2")
# ValueError: 배송된 주문은 취소할 수 없습니다
# 어댑터 ② PostgresOrderStore 는 같은 두 메서드를
# SQL 로 구현 — 유스케이스 코드는 한 줄도 안 바뀜
❌ 과설계 — 규칙이 없는데 겹만 있다
GetUserUseCase → UserPort → UserAdapter
→ UserMapper → UserEntity → UserDto
# 하는 일: SELECT * FROM users WHERE id = ?
# 파일 6개 · 매퍼 3개 · 업무 규칙 0줄
✅ 두꺼운 곳에만
users/ 계층형 그대로 — 단순 조회 · 수정
billing/ domain · application · adapters
# 정산 규칙이 복잡하고 PG 사가 바뀔 수 있는
# 기능에만 포트를 둡니다
UI 패턴 — MVC · MVP · MVVM · 단방향 데이터 흐름
화면이 있는 앱의 어려움은 상태가 여러 곳에서 바뀐다는 데 있습니다. 클릭 · 서버 응답 · 타이머가 같은 화면을 동시에 건드립니다. UI 패턴들은 전부 "화면(View)에서 로직과 상태를 빼내서 어디에 둘까"에 대한 답이고, 최근의 답은 상태는 한곳, 흐름은 한 방향입니다.
네 가지 — 누가 화면을 갱신하나
| 패턴 | 구조 | 화면 갱신 |
|---|---|---|
| MVC | 컨트롤러가 입력을 받아 모델을 바꿈 (1979 스몰토크에서 시작) | 뷰가 모델을 관찰 · 서버 MVC 는 컨트롤러가 뷰를 고름 |
| MVP | 프레젠터가 뷰 인터페이스를 통해 직접 그리라고 시킴 | 프레젠터가 view.showX() 호출 |
| MVVM | 뷰모델이 화면용 상태를 노출, 뷰는 바인딩으로 따라감 (WPF 에서 대중화) | 바인딩 · 관찰이 자동으로 |
| 단방향 (Flux · Redux · Elm) | 상태는 스토어 하나, 바꾸는 길은 액션 → 리듀서 하나 | 상태 → 렌더 (항상 같은 방향) |
화면 앱의 공통 폴더 모양
app/
├─ ui/ # 화면 — 그리기만 (로직 없음)
│ ├─ cart/
│ │ ├─ CartScreen # 상태를 받아 그림 · 이벤트를 올려 보냄
│ │ └─ CartViewModel # 화면 상태 + 이벤트 처리
│ └─ components/ # 재사용 위젯 (버튼 · 목록)
├─ domain/ # (선택) 여러 화면이 쓰는 규칙
├─ data/ # 저장소 · API 클라이언트 · 캐시
│ └─ CartRepository # 데이터 출처를 하나로 감춤
└─ di/ # 조립
# 의존: ui → domain → data (data 는 ui 를 모름)
같은 생각, 두 언어 — 리듀서와 StateFlow 🟡 중급
// TS — 순수 함수 리듀서 (Redux · useReducer 공통)
type State = { items: string[]; filter: "all" | "done" };
type Action =
| { type: "added"; item: string }
| { type: "filtered"; filter: State["filter"] };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "added":
return { ...state, items: [...state.items, action.item] };
case "filtered":
return { ...state, filter: action.filter };
}
}
// 화면: dispatch({ type: "added", item: "우유" })
// Kotlin — ViewModel 이 상태 하나를 노출 (Compose 가 구독)
data class CartState(val items: List<String> = emptyList())
class CartViewModel(private val repo: CartRepository) : ViewModel() {
private val _state = MutableStateFlow(CartState())
val state: StateFlow<CartState> = _state.asStateFlow()
fun onAdd(item: String) { // 이벤트는 위로
_state.update { it.copy(items = it.items + item) }
viewModelScope.launch { repo.save(item) }
}
}
// 화면: val s by vm.state.collectAsStateWithLifecycle()
| 플랫폼 | 지금의 기본값 | 이어서 볼 곳 |
|---|---|---|
| 웹 프런트 (React · Vue · Svelte) | 컴포넌트 + 단방향 — 서버 상태는 쿼리 라이브러리, 화면 상태는 스토어(Redux · Zustand · Pinia) | JS·TS STR 02 · 03 |
| 서버 렌더링 웹 | MVC (Spring MVC · ASP.NET MVC · Rails · Django 의 MTV) | 자바 STR 02 · 파이썬 STR 04 |
| Android · Compose | MVVM + UDF — ViewModel 이 StateFlow 로 화면 상태 노출 | 코틀린 STR 04 |
| iOS · SwiftUI | 상태 기반 뷰 + @Observable 모델 (MVVM 에 가까움) | — |
| WPF · WinUI · MAUI | MVVM — 데이터 바인딩이 프레임워크에 내장 | C# STR 05 |
| Qt · JavaFX · PySide | 모델/뷰 + 뷰모델(QML 프로퍼티 · FXML 컨트롤러) | C++ STR 07 · 자바 STR 08 · 파이썬 STR 06 |
모듈러 모놀리스 → 마이크로서비스 — 경계를 먼저, 분리는 나중에
모놀리스와 마이크로서비스의 비교표와 "분산 모놀리스가 최악"이라는 조언은 SE 06 에 있습니다. 여기서는 그 사이의 실무 경로 — 경계는 어떻게 찾나(바운디드 컨텍스트), 언제 쪼개나(조직 · 배포 · 확장 신호), 분산은 무엇을 비용으로 내나, 그리고 다시 합친 사례를 봅니다.
모듈러 모놀리스 — 배포 하나, 경계 여럿
shop/ # 실행 파일 하나 · 저장소 하나
├─ modules/
│ ├─ catalog/ # 바운디드 컨텍스트 하나 = 모듈 하나
│ │ ├─ api/ # 다른 모듈에 공개하는 것 (인터페이스 · 이벤트)
│ │ ├─ internal/ # 나머지 전부 — 밖에서 import 금지
│ │ └─ db/migrations/ # 자기 스키마만 (catalog.*)
│ ├─ ordering/
│ └─ shipping/
├─ platform/ # 로깅 · 인증 · 트랜잭션 같은 기술 공통
└─ app/ # 모듈 조립 · 진입점
# 규칙: 다른 모듈의 internal · 테이블에 직접 접근 금지
경계 찾기 — 같은 단어가 다른 뜻이 되는 곳
| "상품"이 | 뜻하는 것 |
|---|---|
| 카탈로그에서 | 이름 · 설명 · 사진 · 카테고리 |
| 주문에서 | 주문 시점의 가격 · 수량 (나중에 값이 바뀌어도 고정) |
| 배송에서 | 무게 · 부피 · 위험물 여부 |
Product 클래스 하나 대신 컨텍스트마다 자기 모델을 두고, 경계에서는 ID 와 이벤트로만 주고받습니다.
남의 모델이 내 안으로 새지 않게 막는 번역층을 부패 방지 계층(ACL)이라고 부릅니다.
언제 쪼개나 · 무엇을 내나 · 다시 합친 사례 🔴 실무
| 신호 | 쪼갤 이유가 되는 경우 |
|---|---|
| 조직 | 팀이 나뉘었고 한 모듈을 한 팀이 소유 — 콘웨이의 법칙 |
| 배포 | 한 모듈 때문에 전체 배포를 매번 기다림 · 배포 주기가 다름 |
| 확장 | 한 부분만 부하가 10배 · 다른 자원(GPU · 메모리)이 필요 |
| 장애 격리 | 이 부분이 죽어도 결제는 살아 있어야 함 |
| 기술 | 다른 언어 · 런타임이 꼭 필요 (ML 추론 등) |
| 분산의 비용 | 함수 호출일 땐 없던 것 |
|---|---|
| 부분 실패 | 타임아웃 · 재시도 · 서킷 브레이커 · 멱등성 |
| 데이터 일관성 | 트랜잭션 하나 → 사가 · 아웃박스 · 최종 일관성 |
| 관측 | 스택 트레이스 하나 → 분산 추적 · 상관 ID |
| 계약 관리 | API 버전 · 하위 호환 · 소비자 계약 테스트 |
| 운영 | 서비스마다 파이프라인 · 대시보드 · 온콜 |
이벤트 기반 · 파이프라인 · 플러그인 — 호출 대신 다른 연결 방식
계층과 동심원은 "누가 누구를 부르나"의 구조입니다. 어떤 문제는 아예 부르지 않는 구조가 더 맞습니다 — 일이 일어났다고 알리기만 하거나(이벤트), 앞 단계의 결과를 흘려보내기만 하거나(파이프라인), 본체가 모르는 기능을 꽂아 넣거나(플러그인). 로그를 시스템의 중심축으로 두는 이벤트 소싱 · CQRS · CDC 는 LOG 06 에서 다룹니다.
이벤트 기반 — 폴더와 규칙
ordering/
├─ events/ # 이 모듈이 내보내는 이벤트 = 공개 계약
│ └─ OrderPlaced # 과거형 이름 · 필요한 사실만 · 버전 필드
├─ handlers/ # 남의 이벤트를 받는 쪽
│ └─ on_payment_completed
└─ outbox/ # DB 저장과 발행을 한 트랜잭션으로
# 규칙
# · 이벤트 이름은 과거형 (명령 "PlaceOrder" 와 구분)
# · 소비자는 멱등하게 — 같은 이벤트가 두 번 올 수 있음
# · 이벤트 스키마는 하위 호환으로만 바꿈
언제 맞고 언제 아닌가
| 구조 | 맞는 곳 | 주의 |
|---|---|---|
| 이벤트 | 한 사실에 반응할 곳이 여럿 · 늘어남 (알림 · 정산 · 검색 색인) | 흐름이 코드에 안 보임 → 추적 ID 필수 |
| 파이프라인 | ETL · 스트림 처리 · 컴파일러 · 미디어 변환 | 단계 사이 형식(스키마)이 계약 |
| 플러그인 | IDE · 브라우저 · 빌드 도구 · 에디터 — 본체가 모르는 기능을 제3자가 추가 | 공개 API 가 곧 족쇄 · 버전 관리 |
플러그인 — 본체는 약속만, 기능은 밖에서 🟡 중급
mytool/ # 본체 (호스트)
├─ core/ # 플러그인이 몰라도 되는 내부
├─ plugin_api/ # ★ 공개 약속 — 인터페이스 · 훅 · 버전
│ └─ Exporter # export(rows) -> bytes
└─ registry # 설치된 플러그인 찾아 등록
mytool-csv/ # 제3자 패키지 — plugin_api 에만 의존
mytool-xlsx/
# 의존 방향: 플러그인 → plugin_api ← core
# 본체는 플러그인을 import 하지 않습니다 (실행 중에 발견)
# mytool-csv 의 pyproject.toml — "나는 이 그룹의 플러그인"
[project.entry-points."mytool.plugins"]
csv = "mytool_csv:CsvExporter"
# 본체 registry — 설치된 것을 이름으로 찾음 (3.10+)
from importlib.metadata import entry_points
registry = {}
for ep in entry_points(group="mytool.plugins"):
registry[ep.name] = ep.load()() # 클래스 로드 → 인스턴스
# registry["csv"].export(rows)
package.json 의 contributes) · 브라우저 확장(manifest) · Gradle · ESLint · pytest 플러그인.
호스트는 약속만 공개하고, 플러그인은 약속에만 의존합니다 — DIP 를 배포 단위로 키운 것입니다.
앱 유형별 권장 구조 한 장 — 그리고 언어별 구조 탭으로
원리는 같아도 앱 유형마다 무엇이 가장 자주 바뀌는지가 다르고, 그래서 기본값이 다릅니다. 웹 백엔드는 업무 기능이, 화면 앱은 상태가, 펌웨어는 하드웨어가, 라이브러리는 공개 API 가 구조를 정합니다. 아래 표는 출발점이고, 실제 폴더와 빌드 파일은 각 언어 가이드의 구조 탭에 있습니다.
| 유형 | 권장 패턴 | 디렉터리 요지 | 주의점 |
|---|---|---|---|
| 웹 백엔드 | 기능별 패키지 · 두꺼운 기능만 헥사고날 · 모듈러 모놀리스 | orders/{api,service,repo} · shared/ · main(조립) | 공용 폴더 비대 · 기능 간 내부 import |
| 웹 프런트 | 컴포넌트 + 단방향 상태 · 기능별 | app/(라우트만) · features/ · components/ · lib/ | 라우트 파일에 로직 몰기 · 모든 걸 전역 스토어에 |
| 데스크톱 | MVVM · UI 와 무관한 core 분리 | core/ · ui/views · ui/viewmodels · services/ | 코드비하인드에 로직 · UI 스레드에서 I/O |
| 모바일 | UI · (도메인) · 데이터 계층 + UDF, 커지면 기능 모듈 | :app · :feature:* · :core:* | 화면이 저장소를 직접 호출 · 모듈을 너무 잘게 |
| 임베디드 펌웨어 | 레이어(HAL → 드라이버 → 서비스 → 앱) + 이벤트 큐 · 상태 기계 | hal/ · drivers/ · services/ · app/ · boards/ | 앱이 레지스터를 직접 만짐 · #ifdef 지옥 |
| 라이브러리 | 공개 API 최소 + 내부 숨김 · src 레이아웃 | src/<pkg>/(공개) · internal/ · tests/ · examples/ | 내부 타입이 공개 API 에 새면 영원히 못 바꿈 |
| 데이터 파이프라인 | 단계별 파이프라인 · 노트북과 코드 분리 | stages/{extract,transform,load} · notebooks/ · src/ | 노트북이 운영 코드 · 단계 사이 스키마 계약 없음 |
| 게임 | 엔진 · 게임 분리 + 컴포넌트 · 이벤트 | engine/ · game/ · assets/ · tools/ | 게임 규칙이 엔진 안으로 · 싱글턴 남용 |
언어별 구조 탭 — 실제 폴더 · 빌드 파일은 여기서 🟢 기초
| 가이드 | 구조 탭 | 특히 볼 섹션 |
|---|---|---|
| 자바 | 📁 프로젝트 구조 | Spring 계층 vs 기능 · Gradle 멀티 모듈 · JavaFX MVVM · ArchUnit |
| 코틀린 | 📁 프로젝트 구조 | Android 아키텍처 · Android 모듈화 · KMP |
| 파이썬 | 📁 프로젝트 구조 | FastAPI · Django · 데이터 · ML · 라이브러리 · CLI |
| JS · TS | 📁 프로젝트 구조 | React SPA · Next.js · Node 백엔드 · 모노레포 |
| C# | 📁 프로젝트 구조 | Clean 솔루션 · WPF · MAUI · Unity |
| C++ | 📁 프로젝트 구조 | CMake 타깃 · 라이브러리 · ABI · 게임 · 서버 대형 |
| Rust | 📁 프로젝트 구조 | 워크스페이스 · Axum · Tauri |
| C | 대규모 C 구조 | 레이어 · 디렉터리 템플릿 3종 · 이벤트 큐 + 상태 머신 |
| DB | 📁 프로젝트 구조 | 마이그레이션 · dbt 구조 |
| 서버 | 📁 프로젝트 구조 | 쿠버네티스 · MSA 저장소 전략 · CI/CD |
구조 규칙을 기계로 지키기 — 리뷰가 아니라 빌드가 막게
"domain 은 infra 를 import 하지 않는다"는 규칙은 문서에 적어 두면 석 달 안에 깨집니다. 급한 날 한 줄이면 되니까요. 규칙은 어기면 빌드가 실패하게 만들어야 지켜집니다. 파이썬 import-linter 예는 SE 06 에 있으니, 여기서는 강제 수단의 사다리와 언어별 도구 이름을 정리합니다.
자바 — ArchUnit (JUnit 5)
@AnalyzeClasses(packages = "com.example.shop")
class ArchitectureTest {
@ArchTest
static final ArchRule layers = layeredArchitecture()
.consideringAllDependencies()
.layer("Web").definedBy("..web..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Web");
@ArchTest // 기능 패키지 사이 고리 금지 (ADP)
static final ArchRule noCycles =
slices().matching("com.example.shop.(*)..").should().beFreeOfCycles();
}
TS — dependency-cruiser
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{ name: "no-circular", severity: "error",
from: {}, to: { circular: true } },
{ name: "domain-is-pure", severity: "error",
from: { path: "^src/domain" },
to: { path: "^src/(infra|ui)" } },
{ name: "no-feature-internals", severity: "error",
from: { path: "^src/features/([^/]+)/" },
to: { path: "^src/features/([^/]+)/internal/",
pathNot: "^src/features/$1/" } },
],
};
// npx depcruise src --config .dependency-cruiser.cjs
$1 은 from 쪽에서 잡은 기능 이름입니다. CI 에서 돌리면 PR 단계에서 막힙니다.
언어별 도구 — 경계 · 검사 · 그래프 🟡 중급
| 언어 | 빌드 경계 (컴파일러가 막음) | 아키텍처 테스트 · 린트 | 의존 그래프 보기 |
|---|---|---|---|
| 자바 | Gradle · Maven 멀티 모듈 · JPMS module-info · package-private | ArchUnit · Spring Modulith (ApplicationModules.of(..).verify()) | jdeps · gradle dependencies |
| 코틀린 | Gradle 모듈 · internal | Konsist · ArchUnit | gradle dependencies |
| C# | 프로젝트 참조 · internal | NetArchTest · ArchUnitNET | dotnet list reference |
| 파이썬 | 패키지 분리 (워크스페이스) — 언어 차원의 강제는 약함 | import-linter | pydeps |
| JS · TS | 워크스페이스 패키지 + package.json 의 exports | dependency-cruiser · eslint-plugin-boundaries · Nx 모듈 경계 규칙 | madge · dependency-cruiser 출력 |
| Rust | 크레이트 · 워크스페이스 · pub(crate) | 크레이트 경계가 곧 규칙 · clippy | cargo tree · cargo-modules |
| Go | internal/ 디렉터리 (밖에서 import 하면 컴파일 오류) | go vet · 린터 묶음 | go mod graph |
| C · C++ | CMake 타깃 + PRIVATE · PUBLIC 링크 · 공개 헤더 폴더 분리 | include-what-you-use · clang-tidy | cmake --graphviz=deps.dot |
❌ 규칙은 위키에
docs/architecture.md
"domain 은 infra 를 참조하지 말 것"
# 6개월 뒤: domain → infra import 23곳
# 아무도 위키를 열지 않았습니다
✅ 규칙은 테스트에
ArchitectureTest → CI 필수 체크
depcruise → PR 마다
# 이미 깨진 곳은 예외 목록(기준선)으로 얼려 두고
# 새 위반만 막으며 하나씩 줄입니다
결정 기록과 진화 — ADR · 스트랭글러 · 처음엔 단순하게
구조는 한 번 정하고 끝나는 것이 아니라 계속 옮겨 가는 것입니다. 옮기려면 두 가지가 필요합니다 — 왜 지금 모양이 됐는지의 기록(ADR), 그리고 서비스를 멈추지 않고 조금씩 옮기는 방법(스트랭글러). ADR 실제 예(아웃박스 도입)는 SE 06 에 있으니, 여기서는 운영 규칙과 템플릿만 봅니다.
ADR 은 저장소 안에, 번호 순으로
docs/adr/
├─ 0001-record-architecture-decisions.md # "ADR 을 쓰기로 함" 부터
├─ 0002-modular-monolith.md # 채택
├─ 0003-orders-hexagonal.md # 채택
├─ 0007-use-graphql.md # 대체됨 → 0012
├─ 0012-back-to-rest-for-public-api.md # 채택 (0007 을 대체)
└─ template.md
# 규칙
# · 채택된 ADR 은 고치지 않는다 — 바뀌면 새 ADR 이 대체
# · 코드와 같은 PR 로 — 리뷰에서 "왜"까지 같이 본다
template.md — 한 장이면 충분
# NNNN. 결정 제목 (동사로: "주문 모듈에 포트 도입")
## 상태
제안 | 채택 (YYYY-MM-DD) | 대체됨 → NNNN | 폐기
## 맥락
지금 무엇이 문제인가. 숫자가 있으면 숫자로.
어떤 제약이 있나 (팀 · 일정 · 기존 시스템).
## 결정
우리는 ~ 하기로 한다. (한두 문장)
## 결과
좋아지는 것 / 나빠지는 것 / 새로 생기는 일.
언제 다시 볼지 — "주문 팀이 둘로 나뉘면 재검토"
스트랭글러 — 새 구조로 조금씩 옮기기 🟡 중급
마틴 파울러가 이름 붙인 "교살자 무화과" 패턴
(숙주 나무를 감싸며 자라 결국 대신 서는 덩굴)
① 앞에 입구(프록시 · 라우터 · 파사드)를 세운다
② 기능 하나를 새 구조로 다시 만든다
③ 입구에서 그 기능의 요청만 새 쪽으로 보낸다
④ 문제없으면 옛 코드를 지운다 — 다음 기능으로
⑤ 옛 시스템이 빈 껍데기가 되면 입구째 정리
★ 코드 안에서도 같습니다
옛 OrderService 앞에 인터페이스를 세우고
새 구현으로 하나씩 옮긴 뒤 옛 클래스를 지운다
# nginx — 입구에서 경로별로 나눈다
location /api/orders/ {
proxy_pass http://orders_new; # 옮긴 기능
}
location / {
proxy_pass http://legacy_app; # 나머지는 그대로
}
"처음엔 단순하게" — 한 장 요약 🟢 기초
| 원칙 | 실무 문장 |
|---|---|
| 필요할 때 만든다 (YAGNI) | "나중에 쪼갤 수도 있으니" 지금 서비스 · 모듈 · 포트를 미리 만들지 않습니다 |
| 경계가 먼저, 분리는 나중 | 폴더 · 공개 입구로 경계를 긋고 → 검증되면 빌드 모듈 → 신호가 오면 서비스 |
| 방향은 처음부터 지킨다 | 폴더 수는 적어도 의존 방향(업무 규칙 → 기술 금지)은 첫날부터 — 나중에 뒤집기가 가장 비쌉니다 |
| 되돌릴 수 있게 | 쪼개는 결정은 합칠 수 있게, 기술 선택은 어댑터 뒤에 |
| 규칙은 기계에, 이유는 ADR 에 | 무엇을 지킬지는 테스트가, 왜 그런지는 기록이 기억합니다 |