🟠 코틀린↗ 🐍 파이썬↗ ☕ 자바↗ 🟨 JS·TS↗ 🟣 C#↗ 🔵 C++↗ 🔌 C · 임베디드↗ 🦀 Rust↗ 🗄️ DB↗ 🌐 서버기술↗
👋 처음이신가요? 이 가이드는 언어가 바뀌어도 남는 것만 다룹니다 — 수학 · 자료구조 · 하드웨어 · OS · 네트워크 · 이론 · 🔍 검색(Ctrl K)에 “해시 충돌”이나 “캐시 미스”처럼 궁금한 말을 한글로 입력 · 코드 상자에 마우스를 올리면 복사 버튼이 나타납니다
프로그래밍에 실제로 쓰이는 수학만 · 증명보다 쓰임새 먼저

컴퓨터가 세는 것은
전부 띄엄띄엄합니다
그래서 이산수학입니다

미적분은 매끄럽게 이어지는 것을 다룹니다. 그런데 컴퓨터가 다루는 것은 비트 · 배열 인덱스 · 노드 · 상태처럼 딱딱 끊어진 것뿐입니다. 그 세계의 수학이 이산수학이고, 그래서 대학에서 프로그래밍과 함께 처음 배우는 수학이 이것입니다. 이 탭은 정리와 증명을 나열하는 대신 그 개념이 코드의 어디에 이미 들어 있는지부터 보여 줍니다.

13SECTIONS
0사전 지식
∀∃기호까지
코드로 확인
MATH 01

왜 이산수학인가 — 코드의 뼈대가 여기 있습니다

“수학 안 해도 개발 잘하던데요”는 절반은 맞습니다. 다만 막히는 지점이 늘 같은 곳입니다 — 조건문이 꼬일 때, 재귀가 맞는지 모를 때, 해시가 왜 부딪히는지 설명 못 할 때. 전부 이산수학이 이미 답을 갖고 있는 자리입니다.

연속과 이산 — 무엇이 다른가

연속 (미적분)이산
다루는 대상실수 · 곡선정수 · 집합 · 그래프
“조금 더”무한히 작게1 만큼
대표 질문변화율은?경우의 수는? 연결되어 있나?
코드의 예물리 · 그래픽배열 · 조건문 · 자료구조
딱 한 문장으로 — 배열 인덱스에 1.5 를 넣을 수 없다는 사실, 그게 이산수학이 필요한 이유의 전부입니다.

이미 쓰고 있었던 것들

이산수학의 개념코드에서의 모습
명제 논리if (a && !b)
집합Set · DISTINCT
관계외래키 · JOIN
함수순수 함수 · 해시
그래프의존성 · 팔로우 · 라우팅
귀납법재귀 · 반복문 정확성
조합론충돌 확률 · 비밀번호 강도
모듈러 산술hash % 버킷수 · RSA
순서에 대한 조언 — 처음부터 끝까지 읽을 필요는 없습니다. 02 논리 → 04 집합 → 07 그래프 → 12 점화식 네 개만 봐도 코드 리뷰에서 쓰는 말이 달라집니다.

이렇게도 쓸 수 있다 — “증명”이라는 말에 겁먹지 않기 🟡 중급

증명 = 리뷰어를 설득하는 글

수학의 증명과 코드 리뷰는 사실 같은 일입니다 — “왜 이게 항상 맞다고 확신하는가”를 남이 따라올 수 있게 적는 것. 형식이 엄격해 보일 뿐, 하는 일은 // 이 루프가 끝나면 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 마스터 정리
이 가이드의 🧮 계산이론 탭은 여기서 이어집니다 — 이산수학이 “무엇을 셀 수 있나”라면, 계산이론은 “얼마나 걸리나 · 아예 풀 수 있나”입니다.
MATH 02

명제논리 · 진리표 — 조건문 버그의 절반이 여기서 납니다

if (!(a && b)) 를 if (!a && !b) 로 바꿔 쓴 적이 있다면, 그건 오타가 아니라 드모르간 법칙을 틀린 것입니다. 진리표는 그런 실수를 기계적으로 잡아 주는 도구입니다.

기본 연산 네 개

이름기호코드참이 되는 때
부정¬p!pp 가 거짓일 때
논리곱p ∧ qp && q둘 다 참
논리합p ∨ qp || q하나라도 참
함의p → q!p || qp 가 참인데 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줄이라 손으로 다 적을 수 있다.
실전 사용법 — 조건문이 의심스러우면 변수 2~3개짜리 진리표를 그려 기대값 열을 하나 더 만들어 비교하세요. 5분이면 끝나고, 확실합니다.

이렇게도 쓸 수 있다 — 드모르간 · 조건문 뒤집기 🟡 중급

드모르간 법칙

¬(p ∧ q) ≡ ¬p ∨ ¬q
¬(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단계

  1. 이름을 붙인다 — if (a > 3 && b !== null && !c) 대신 const canPublish = .... 조건에 이름이 붙는 순간 리뷰가 가능해집니다.
  2. 부정을 밖으로 뺀다 — 드모르간으로 ! 를 최상위 하나로 모읍니다.
  3. 조기 반환으로 평탄화 — 중첩 if 를 가드 절로 바꾸면 진리표를 그릴 필요 자체가 줄어듭니다.
  4. 진리표로 검증 — 그래도 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();
SQL 에서는 한 가지가 더 있습니다 — NULL 이 섞이면 참·거짓 말고 “알 수 없음”이라는 세 번째 값이 생깁니다(3치 논리). NOT (x = 1) 이 x 가 NULL 일 때 참이 아닌 이유입니다 → 🗄️ Database 가이드에서 이어집니다.
드모르간 — 부정을 안으로 넣으면 · 와 · 가 뒤바뀝니다 NOT(A AND B) = (NOT A) OR (NOT B)  NOT(A OR B) = (NOT A) AND (NOT B) A B NOT(A AND B) (NOT A) OR (NOT B) 참 참 거짓 거짓 참 거짓 참 참 거짓 참 참 참 거짓 거짓 참 참 네 줄이 모두 같습니다 — 헷갈릴 때 진리표 네 줄을 적는 것이 가장 빠른 확인법입니다 실무 사례 — !(a && b) 를 !a && !b 로 잘못 바꾸는 버그. 정답은 !a || !b 입니다
조건문이 이해되지 않을 때는 경우를 다 적어 보는 것이 언제나 가장 빠릅니다.
MATH 03

술어논리 — “모든”과 “어떤”을 정확히 말하기

요구사항에 “모든 주문은 결제 정보를 가진다”와 “어떤 주문은 쿠폰이 있다”가 섞여 있습니다. 이 둘을 뒤집을 때 규칙을 모르면 테스트 케이스가 통째로 비게 됩니다.

한정자 두 개

∀x P(x) — 모든 x 에 대해 P 가 참
∃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)
¬∃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(...) 로 검사하고 실패하면 “무엇이 실패했는지” 모릅니다. 위처럼 반례를 찾는 형태로 쓰면 에러 메시지가 훨씬 쓸모 있어집니다.

이렇게도 쓸 수 있다 — 순서가 바뀌면 뜻이 달라진다 🟡 중급

∀∃ 와 ∃∀ 는 완전히 다른 말

∀u ∃s 담당(s, u)
모든 사용자에게 각자의 담당자가 있다

∃s ∀u 담당(s, u)
한 사람이 모든 사용자를 담당한다
요구사항 회의에서 실제로 갈리는 지점입니다. “담당자가 있어야 한다”는 문장 하나가 N명 배정인지 1명 전담인지 정해 주지 않습니다. 여기서 스키마가 갈립니다.

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      -- 결제 안 된 게 없다
);
“전부 ~한” 조건은 SQL 에서 늘 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
테스트와 무엇이 다른가 — 테스트는 고른 입력 몇 개를 확인하고, 불변식은 모든 실행에서 항상 확인합니다. 재현이 어려운 버그는 불변식 검사를 넣어 두면 터지는 순간에 잡힙니다. 운영에서는 로그로 낮춰 두는 것이 보통입니다.
MATH 04

집합 · 관계 · 함수 — DB 스키마의 언어

Set, 외래키, 순수 함수. 서로 다른 자리에 있는 것 같지만 셋 다 같은 수학에서 나왔습니다. 이 셋의 관계를 알면 테이블 설계와 API 설계가 같은 문제로 보이기 시작합니다.

집합 연산 — SQL 과 나란히

연산기호파이썬SQL
합집합A ∪ Ba | bUNION
교집합A ∩ Ba & bINTERSECT
차집합A − Ba - bEXCEPT
대칭차A △ Ba ^ b—
부분집합A ⊆ Ba <= 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조상 ✓ / 친구 ✗
셋 다 만족하면 동치 관계입니다 — 그러면 데이터를 겹치지 않는 그룹으로 완전히 나눌 수 있습니다(분할). “중복 계정 병합”, “연결 요소 찾기”가 정확히 이 문제이고, 🗂️ 자료구조 11 유니온 파인드가 그 도구입니다.

설계에 바로 쓰이는 판단

-- 대칭적인가? → 테이블 모양이 달라진다
-- 친구(대칭): 한 행으로 충분, 항상 (작은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)
);
이 판단을 안 하면 친구 관계를 양방향 2행으로 저장했다가 한쪽만 삭제되는 버그를 만나게 됩니다. 관계의 성질을 먼저 묻는 것이 스키마 설계의 첫 단계입니다.

실무에선 이렇게 — 함수의 성질과 데이터 파이프라인 🔴 실무

단사 · 전사 · 전단사

종류뜻실무에서
단사(1:1)서로 다른 입력 → 서로 다른 출력충돌 없는 ID 매핑
전사출력값이 빠짐없이 나옴모든 상태가 도달 가능
전단사둘 다되돌릴 수 있음(인코딩)
해시는 단사가 아닙니다 — 그래서 되돌릴 수 없고, 그래서 충돌합니다. 반면 Base64 는 전단사라서 정확히 복원됩니다. “이 변환은 되돌릴 수 있나?”가 곧 이 질문입니다.

멱등성 — 분산 시스템의 핵심 성질

# 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               : 아님  → 재시도하면 중복 생성
재시도가 안전하려면 멱등해야 합니다. 네트워크가 끊겨 응답을 못 받았을 때 같은 요청을 다시 보낼 수 있느냐가 여기서 갈립니다 → 🕸️ 분산 · 동시성 탭 13에서 이어집니다.
DB 의 테이블·조인·키가 그대로 이 개념입니다 집합 중복 없는 원소들의 모임 = 테이블의 행 모음 합집합 · 교집합 = UNION · INTERSECT 관계 두 집합의 원소를 짝지음 = 조인 결과 · 외래 키 (사용자, 주문) 쌍의 모음 함수 왼쪽 하나에 오른쪽이 하나만 = 기본 키로 행 찾기 id → 행 (언제나 하나) 관계의 성질이 곧 설계 조건입니다 반사·대칭·추이 — "친구 관계"는 대칭이고 "상위 조직"은 추이적입니다(A>B, B>C면 A>C) 이 성질을 알면 테이블 구조가 정해집니다 일대일 · 일대다 · 다대다 일대다는 외래 키 하나로 표현됩니다 다대다는 중간 테이블이 필요합니다 — 관계 자체를 집합으로 만들어 저장하는 것입니다
추상적으로 보이는 개념이 스키마 설계의 언어입니다.
DB 의 테이블·조인·키가 그대로 이 개념입니다 집합 중복 없는 원소들의 모임 = 테이블의 행 모음 합집합 · 교집합 = UNION · INTERSECT 관계 두 집합의 원소를 짝지음 = 조인 결과 · 외래 키 (사용자, 주문) 쌍의 모음 함수 왼쪽 하나에 오른쪽이 하나만 = 기본 키로 행 찾기 id → 행 (언제나 하나) 관계의 성질이 곧 설계 조건입니다 반사·대칭·추이 — "친구 관계"는 대칭이고 "상위 조직"은 추이적입니다(A>B, B>C면 A>C) 이 성질을 알면 테이블 구조가 정해집니다 일대일 · 일대다 · 다대다 일대다는 외래 키 하나로 표현됩니다 다대다는 중간 테이블이 필요합니다 — 관계 자체를 집합으로 만들어 저장하는 것입니다
추상적으로 보이는 개념이 스키마 설계의 언어입니다.
MATH 05

수학적 귀납법 — 재귀가 맞다고 믿는 근거

재귀 함수를 쓰고 “왠지 될 것 같은데” 하고 넘어간 적이 있다면, 그 “왠지”의 정확한 이름이 귀납법입니다. 도미노를 세우는 일과 똑같습니다.

두 단계면 끝

  1. 기본 단계 — 첫 번째 도미노가 넘어진다 (n = 0 또는 1 에서 참)
  2. 귀납 단계 — k 번째가 넘어지면 k+1 번째도 넘어진다
P(0) ∧ (∀k: P(k) → P(k+1)) ⟹ ∀n P(n) 이 둘만 보이면 무한히 많은 n 에 대해 한 번에 증명이 끝납니다.

재귀 함수와 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) 도 참
# 분할 정복 알고리즘의 정확성은 거의 전부 이 형태로 증명한다
분할 정복과 짝입니다 — 문제를 절반씩 쪼개는 알고리즘은 전부 강한 귀납법으로 정당화되고, 복잡도는 12 마스터 정리로 계산합니다.

실무에선 이렇게 — 재귀를 언제 반복문으로 바꾸나 🔴 실무

스택 오버플로와 꼬리 재귀

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 파싱)반드시 깊이 제한 — 공격 벡터
보안 관점 — 중첩된 JSON·XML 을 재귀로 파싱하면 공격자가 깊이 10만짜리 입력으로 서버를 죽일 수 있습니다(스택 소진 DoS). 파서에는 반드시 최대 깊이 제한을 둡니다.
귀납법 = 도미노 — 두 가지만 보이면 무한히 많은 경우가 증명됩니다 n=1 2 3 4 … k k+1 ① 기저 — 첫 번째가 넘어진다 P(1) 이 참임을 직접 확인 ② 귀납 — k 가 넘어지면 k+1 도 넘어진다 P(k) 를 가정하고 P(k+1) 을 유도 두 가지를 보이면 P(1) → P(2) → P(3) → … 이 자동으로 따라옵니다. 무한히 많은 명제가 두 줄로 증명됩니다. 재귀 함수도 똑같습니다 — 종료 조건이 맞고(①), 더 작은 입력에서 맞으면 지금도 맞으면(②) 전부 맞습니다. 강한 귀납법은 ② 에서 P(k) 하나가 아니라 P(1)~P(k) 전부를 가정합니다 — 병합 정렬 증명에 필요합니다. 반복문에서는 같은 논리를 루프 불변식이라 부릅니다 — 들어갈 때 참이고, 한 바퀴 돌아도 참이면, 끝나도 참입니다.
"재귀가 왜 맞는지"를 설명하라면 이 그림 하나면 됩니다 — 기저 조건과 한 단계 축소, 둘뿐입니다.

코드의 정확성을 증명해 보기

재귀 — 기저와 축소만 확인합니다

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 에서 성립
종료도 함께 증명해야 합니다 정확성(partial correctness) — 끝난다면 답이 맞다
종료(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 에서 멈춤)
확인할 것질문
초기루프에 들어가기 직전에 참인가
유지참인 상태로 한 바퀴 돌면 여전히 참인가
종료루프를 빠져나온 시점에 원하는 결과가 되는가
변형매 반복마다 줄어드는 값이 있는가
불변식은 시험 문제가 아니라 실전 도구입니다. 이진 탐색·투 포인터·슬라이딩 윈도우처럼 경계 조건이 헷갈리는 코드를 쓸 때 “지금 이 시점에 무엇이 참인가”를 주석으로 한 줄 적어 두면 off-by-one 이 거의 사라집니다. 주석으로 남기면 다음 사람도 안전하게 고칠 수 있습니다.
MATH 06

조합론 — 경우의 수가 보안과 성능을 결정합니다

“비밀번호 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조

비둘기집 원리 — 가장 단순하고 강력한 도구

비둘기 n 마리, 집 m 개, n > m
⟹ 어떤 집에는 2마리 이상 당연해 보이지만, “충돌은 반드시 일어난다”의 증명이 이것입니다.
· 해시값이 32비트인데 입력이 2³²+1 개  → 충돌은 100% 발생
· 정수를 문자열로 압축?               → 무손실 압축은 모든 입력을 줄일 수 없다
· 캐시 슬롯 1000개에 1001개 키         → 최소 하나는 밀려난다

"이론적으로 충돌하지 않는 해시"는 존재하지 않는다.
암호학적 해시가 하는 일은 '충돌을 찾기 어렵게' 만드는 것뿐이다.

이렇게도 쓸 수 있다 — 생일 역설 — 직관보다 훨씬 빨리 부딪힌다 🟡 중급

23명이면 절반

P(충돌 있음) = 1 − 365Pn / 365n
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³⁸ 개암호학적으로 안전
짧은 해시를 ID 로 쓰지 마세요 — git 의 짧은 커밋 해시(7자리 = 28비트)는 저장소가 커지면 실제로 충돌합니다. 그래서 git 은 필요하면 자릿수를 자동으로 늘립니다. 🔐 보안 · 암호 탭 02에서 이어집니다.

실무에선 이렇게 — 비밀번호 · 토큰 강도 계산 🔴 실무

엔트로피(비트)로 말하기

엔트로피 = n × log₂(k) n = 길이, k = 각 자리가 가질 수 있는 값의 수
방식엔트로피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비트지만 사전 공격에는 몇 초.
#    그래서 실무에서는 '유출된 비밀번호 목록 대조'가 규칙보다 효과적이다.
연결 — 계산한 엔트로피를 실제로 지키는 방법(느린 해싱 · 솔트)은 🔐 보안 · 암호 탭 03, 난수를 안전하게 만드는 법은 같은 탭 11에 있습니다.
경우의 수는 보안과 성능을 동시에 정합니다 숫자 4자리 비밀번호 — 10⁴ = 1만 가지 초당 100만 번 시도하면 0.01초. 잠금 장치가 없으면 방어가 없는 것과 같습니다 영문 소문자 8자리 — 26⁸ ≈ 2000억 가지 GPU 로는 몇 분입니다 — 지금 기준으로는 안전하지 않습니다 대소문자+숫자+기호 12자리 — 약 95¹² ≈ 5×10²³ 같은 속도로 시도해도 우주 나이보다 오래 걸립니다 — 길이가 종류보다 효과가 큽니다 같은 계산이 성능에도 그대로 적용됩니다 모든 쌍 비교는 n(n−1)/2 · 모든 부분집합은 2ⁿ · 모든 순서는 n! — 세어 보면 가능한지 바로 압니다 순열은 순서를 따지고 조합은 안 따집니다 — 문제를 읽을 때 이것부터 가릅니다
"대충 많겠지" 대신 지수를 실제로 세어 보는 습관이 판단을 바꿔 줍니다.
MATH 07

그래프 이론 — 점과 선으로 거의 모든 것이 표현됩니다

지도 · 팔로우 · 패키지 의존성 · 상태 전이 · 라우팅. 전부 정점과 간선일 뿐입니다. 문제를 그래프로 옮기는 순간 이미 만들어진 알고리즘 수십 개를 쓸 수 있게 됩니다.

무방향 그래프 — 친구 관계 A B C D 간선에 방향이 없다 — A와 B는 서로 친구 방향 그래프 — 패키지 의존성 앱 A B C 한 방향으로만 — 사이클이 생기면 빌드가 안 된다
같은 그림도 간선에 방향이 있느냐에 따라 쓸 수 있는 알고리즘이 완전히 달라집니다.

용어 — 이것만 알면 문서가 읽힙니다

용어뜻
정점 (vertex/node)점 — 사용자 · 도시 · 파일
간선 (edge)선 — 관계 · 도로 · import
차수 (degree)한 정점에 붙은 간선 수
경로 (path)이어진 정점들의 나열
사이클 (cycle)출발점으로 돌아오는 경로
연결 요소서로 오갈 수 있는 덩어리
DAG방향 있고 사이클 없는 그래프
DAG 가 실무에서 가장 자주 나옵니다 — 빌드 의존성 · 태스크 스케줄러 · git 커밋 히스토리 · 데이터 파이프라인이 전부 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)
기준 — 정점 1만 개에 인접 행렬을 쓰면 1억 칸(약 400MB)입니다. 간선 수가 V² 에 가깝지 않으면 무조건 인접 리스트입니다.

이렇게도 쓸 수 있다 — 문제를 그래프로 옮기기 🟡 중급

“이건 그래프 문제였구나”가 보이는 순간

현실 문제정점간선알고리즘
순환 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']  ← 어디가 문제인지까지 알려 준다
회색(GRAY)이 핵심입니다 — “다 본 것(검정)”을 다시 만나는 건 사이클이 아닙니다. 지금 내려오고 있는 경로 위의 정점을 다시 만났을 때만 사이클입니다. 이 구분을 놓치면 멀쩡한 다이아몬드 의존성을 사이클로 잘못 신고합니다.
MATH 08

트리 · 신장 트리 — 사이클 없는 그래프의 특별함

폴더 · 조직도 · DOM · JSON · 파서의 AST · DB 인덱스. 전부 트리입니다. 트리는 그래프의 특수한 경우지만 사이클이 없다는 성질 하나가 엄청나게 많은 것을 보장해 줍니다.

트리의 정의와 성질

정점 n 개인 트리 ⟺ 간선이 정확히 n−1 개 ⟺ 모든 두 정점 사이 경로가 정확히 하나 ⟺ 연결됐고 사이클 없음

네 조건이 전부 같은 말입니다. 그래서 “간선이 n−1 개인데 연결됐다”만 확인하면 트리임이 보장됩니다. 이 성질이 MST 알고리즘의 종료 조건이 됩니다.

경로가 유일하다는 것이 실무에서 가장 유용합니다 — 파일 경로가 하나뿐이고, 조직도에서 상급자가 명확하고, DOM 에서 부모 체인이 유일한 이유가 전부 이것입니다.

순회 — 순서에 따라 결과가 달라진다

#       (+)
#      /   \
#    (*)    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 유니온 파인드입니다.
MATH 09

정수론 — 나머지와 최대공약수가 암호의 바닥입니다

중학교에서 배운 최대공약수가 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)      # 최소공배수는 여기서 유도
복잡도가 놀랍습니다 — O(log min(a,b)) 입니다. 수가 10¹⁸ 이어도 90번 안쪽에 끝납니다. 이 알고리즘이 2300년 지나도 안 바뀐 이유입니다.

소수 — 에라토스테네스의 체

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
암호용 소수는 이 방법으로 못 찾습니다 — RSA-2048 은 300자리가 넘는 소수 두 개를 씁니다. 그때는 확률적 판정(밀러-라빈)을 씁니다. 틀릴 확률을 2⁻¹⁰⁰ 이하로 낮출 수 있어 실용상 문제가 없습니다.

이렇게도 쓸 수 있다 — 확장 유클리드와 모듈러 역원 🟡 중급

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 의 모듈러 역원이고, 그것을 구하는 방법이 위 코드입니다.

RSA 키 생성: d = e⁻¹ mod φ(n), 여기서 φ(n) = (p−1)(q−1)
암호가 어렵게 느껴지는 이유는 수학이 어려워서가 아니라 이 한 줄을 안 배웠기 때문인 경우가 많습니다. 🔐 보안 · 암호 탭 05에서 이 식을 그대로 씁니다.

실무에선 이렇게 — 코드에서 만나는 정수론 🔴 실무

생각보다 자주 씁니다

# ① 화면 비율 줄이기
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
체크 디지트의 아이디어 — 사람이 숫자를 옮겨 적을 때 하는 실수 (한 자리 오타 · 인접 자리 바꿔 쓰기)를 모듈러 연산 하나로 잡아냅니다. 계좌번호 · 주민번호 · 바코드가 전부 같은 방식입니다.
MATH 10

모듈러 산술 — 시계처럼 도는 셈이 해시와 암호를 만듭니다

hash % bucket_count. 이 한 줄이 해시 테이블의 전부이고, 같은 연산을 아주 큰 수로 하면 RSA 가 됩니다. 왜 나머지가 그렇게 유용한가를 봅니다.

합동 — “나머지가 같다”

a ≡ b (mod m) ⟺ m | (a − b) 17 ≡ 5 (mod 12) — 시계에서 17시와 5시가 같은 자리인 것과 같습니다.
# 합동은 덧셈·뺄셈·곱셈에 대해 잘 작동한다
(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)     # 파이썬 내장 · 순식간
왜 10⁹+7 을 자주 보나 — 알고리즘 문제에서 답이 커질 때 “이 소수로 나눈 나머지를 출력하라”고 합니다. 소수라서 모든 수에 역원이 있고, int 곱셈이 64비트에 안 넘치는 크기입니다.

음수 나머지 — 언어마다 다릅니다

-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   // 항상 안전
원형 버퍼 · 캐러셀 · 시계 UI 에서 반드시 만나는 버그입니다. “뒤로 한 칸”을 구현할 때 음수가 나오는 언어라면 위처럼 한 번 더 더해 주는 관용구를 쓰세요.

이렇게도 쓸 수 있다 — 해시 분산 · 샤딩 🟡 중급

버킷 개수는 왜 소수가 좋은가

# 데이터에 규칙이 있을 때 (예: 전부 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   ← 복원됨 ✓
절대 직접 구현해 쓰지 마세요. 위 코드는 원리를 보기 위한 것입니다. 실제 RSA 는 패딩(OAEP) · 타이밍 공격 방어 · 안전한 소수 생성이 필요하고, 그중 하나만 빠져도 뚫립니다. 라이브러리를 쓰되 문서를 읽을 수 있게 되는 것이 이 절의 목적입니다.
모듈러 — 정해진 수를 넘으면 다시 0 으로 돌아옵니다 시계가 곧 mod 12 입니다 — 10시 + 5시간 = 15시 → 3시 (10 + 5) mod 12 = 3 — 우리는 이미 매일 이 계산을 하고 있습니다 쓰이는 곳 ① 해시 테이블 칸 번호 hash(key) mod 칸수 — 아무리 큰 값도 범위 안 쓰이는 곳 ② 순환 버퍼 · 라운드로빈 분배 (i + 1) mod n — 끝에 닿으면 처음으로 음수 나머지의 부호는 언어마다 다릅니다 −1 % 5 가 파이썬은 4, C·자바·자바스크립트는 −1 입니다 — 인덱스로 쓰면 그대로 사고입니다 인덱스에 쓸 때는 ((i % n) + n) % n 으로 감싸 두세요 — 언어에 상관없이 안전합니다 암호도 결국 이 셈입니다 — 큰 소수로 나눈 나머지 위에서 거듭제곱을 하는 것이 RSA 의 뼈대입니다
나머지 연산은 수학이 아니라 값을 정해진 범위에 가두는 도구로 보면 쓸 곳이 보입니다.
MATH 11

부울 대수 — 비트마스크와 회로가 같은 수학입니다

권한 플래그 · 옵션 조합 · 상태 비트. 여러 개의 참/거짓을 정수 하나에 담는 기술은 전부 부울 대수입니다. 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 명령
왜 쓰나 — bool 8개는 8바이트(또는 그 이상)지만 비트마스크는 1바이트입니다. 더 중요한 건 비교가 한 번이라는 점입니다. 리눅스 파일 권한 · 이벤트 플래그 · 게임 상태가 전부 이 방식입니다.

부울 대수의 법칙 — 논리와 같은 모양

법칙논리비트
교환p∧q = q∧pa&b = b&a
분배p∧(q∨r)a&(b|c) = (a&b)|(a&c)
드모르간¬(p∧q)~(a&b) = ~a|~b
흡수p∨(p∧q) = pa|(a&b) = a
보수p∧¬p = Fa&~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개로
회로에서는 게이트 수가 줄고, 코드에서는 조건식이 짧아집니다. 4변수까지는 카르노맵이 손으로 빠르고, 그 이상은 Quine–McCluskey 나 도구를 씁니다.

코드 리뷰에서 바로 쓰는 형태

✗ 중복된 조건
if ((isAdmin && canEdit && isOwner) ||
    (isAdmin && canEdit && !isOwner) ||
    (isAdmin && !canEdit && isOwner)) {
  allow();
}
✓ 최소화
if (isAdmin && (canEdit || isOwner)) {
  allow();
}
기계적으로 확인하는 법 — 두 식의 진리표를 만들어 모든 행이 같은지 보면 됩니다. 변수 3개면 8줄이라 눈으로 끝납니다.

실무에선 이렇게 — 비트 트릭과 그 위험 🔴 실무

알아 두면 좋은 것 / 쓰면 안 되는 것

트릭의미실무 판단
x & (x-1)가장 낮은 1 비트 제거비트 세기에 유용
x & -x가장 낮은 1 비트만 남김세그먼트 트리 등
x & (x-1) == 02의 거듭제곱인가자주 씀
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.
참·거짓 여러 개를 정수 하나에 담습니다 권한 플래그 예시 0 1 0 1 삭제(8) 수정(4) 쓰기(2) 읽기(1) = 5 (읽기 + 수정) 켜기 flags |= WRITE OR — 그 자리를 1 로 끄기 flags &= ~WRITE AND NOT — 그 자리를 0 으로 확인 if (flags & WRITE) AND — 그 자리만 남깁니다 쓰이는 곳 — 파일 권한 · 이벤트 마스크 · 상태 집합 · 비트 DP. 값 32개 이하의 집합을 정수 하나로 다루면 비교·합치기가 명령 한 번에 끝납니다
부울 대수의 법칙이 그대로 비트 연산의 규칙입니다.
참·거짓 여러 개를 정수 하나에 담습니다 권한 플래그 예시 0 1 0 1 삭제(8) 수정(4) 쓰기(2) 읽기(1) = 5 (읽기 + 수정) 켜기 flags |= WRITE OR — 그 자리를 1 로 끄기 flags &= ~WRITE AND NOT — 그 자리를 0 으로 확인 if (flags & WRITE) AND — 그 자리만 남깁니다 쓰이는 곳 — 파일 권한 · 이벤트 마스크 · 상태 집합 · 비트 DP. 값 32개 이하의 집합을 정수 하나로 다루면 비교·합치기가 명령 한 번에 끝납니다
부울 대수의 법칙이 그대로 비트 연산의 규칙입니다.
MATH 12

점화식과 마스터 정리 — 재귀 복잡도를 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)
#          ↑    ↑       ↑
#        몇 번  얼마로  나머지 일
#        부르나  줄여서
T(n) = a · T(n/b) + f(n) — a: 재귀 호출 수 · b: 줄어드는 비율 · f(n): 나머지 작업

마스터 정리 — 세 경우만 보면 됩니다

c = logba 와 f(n) 을 비교 ① f 가 nc 보다 작다 → Θ(nc)
② 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)+11Θ(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ⁿ)

실무에서 진짜로 쓰는 순서

  1. 점화식을 세운다 — 코드에서 “몇 번 부르나 / 얼마로 줄이나 / 나머지 일”만 읽습니다.
  2. 표에서 찾는다 — 위 다섯 개 안에 대부분 들어갑니다.
  3. 안 맞으면 재귀 트리 — 3층만 그려도 패턴이 보입니다.
  4. 실측으로 확인 — n 을 10배 늘려 시간이 몇 배가 되는지 봅니다. 10배 → 10배면 O(n), 100배면 O(n²), 12배쯤이면 O(n log n).
④ 가 가장 실용적입니다 — 이론이 애매하면 n=1000, 10000, 100000 으로 재 보는 것이 가장 빠른 답입니다. 🧮 계산이론 탭 07·08에 측정 방법이 있습니다.
MATH 13

치트시트 — 기호와 공식을 한 장에

논문 · 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 + … + nn(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
C(n,2) 를 기억해 두세요 — “모든 쌍을 비교”하는 코드의 비용이 이것입니다. 1000명 사용자를 서로 비교하면 50만 번입니다.

이렇게도 쓸 수 있다 — 규모 감각 표 🟡 중급

n 이 이만큼일 때 무엇이 가능한가

n가능한 복잡도실제 예
≤ 10O(n!) 전순열외판원 완전 탐색
≤ 25O(2ⁿ) 부분집합비트마스크 DP
≤ 500O(n³)플로이드-워셜
≤ 5,000O(n²)모든 쌍 비교 · DP
≤ 100만O(n log n)정렬 · 세그먼트 트리
≤ 1000만O(n)한 번 훑기
10억 이상O(log n) · O(1)이진 탐색 · 해시 · 수식
1초에 약 1억 번 연산이라는 눈금을 기준으로 만든 표입니다. 알고리즘 문제에서 제약 조건을 보면 풀이가 정해집니다 — “n ≤ 100,000” 이라고 쓰여 있으면 O(n²) 은 이미 탈락입니다. 🧮 계산이론 탭 07의 계산기로 직접 확인할 수 있습니다.

실무에선 이렇게 — 더 볼 곳 🔴 실무

이 탭에서 이어지는 곳

여기서 배운 것다음
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
다음 탭 추천 — 여기까지 읽었다면 🧮 계산이론 · 복잡도로 가는 것이 자연스럽습니다. 코드를 쓰는 사람에게 가장 즉시 쓸모 있는 것은 빅오를 정확히 읽는 능력이기 때문입니다. 3 키로 바로 이동할 수 있습니다.
검색 · 추천 · 머신러닝 · 성능 지표가 서 있는 수학

벡터와 확률을 모르면
임베딩도 p99도
말로만 하게 됩니다

“코사인 유사도로 검색한다”, “p99 응답 시간이 튀었다”, “A/B 테스트에서 유의했다”. 매일 쓰는 말인데 정확히 무슨 뜻인지는 넘어가기 쉽습니다. 이 탭은 선형대수와 확률·통계를 실제로 마주치는 자리에서만 골라 정리하고, 마지막에는 모든 계산의 바닥인 부동소수점까지 내려갑니다.

15SECTIONS
검색임베딩
p99지표 해석
0.1+0.2의 진실
LA 01

벡터 — 배열에 방향을 붙이면 생기는 일

벡터는 [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]  단위벡터 — 방향만 남김
정규화가 중요한 이유 — 문서 임베딩에서 긴 문서일수록 벡터가 길어집니다. 길이를 1로 맞춰 두면 “내용이 비슷한가”만 남고 “얼마나 긴가”는 빠집니다.

실무에서 벡터로 표현하는 것들

대상벡터
화면 좌표[x, y]
색[r, g, b, a]
사용자 취향장르별 점수 [액션 0.8, 로맨스 0.1, …]
문장 (임베딩)768 ~ 3072 차원 실수
이미지픽셀을 쭉 편 배열
서버 상태[cpu, mem, qps, p99]
공통점 — 전부 “가까운 것끼리 비슷하다”가 성립하게 만든 표현입니다. 임베딩 모델이 하는 일이 딱 이것입니다. 그다음은 06 코사인 유사도가 이어받습니다.

이렇게도 쓸 수 있다 — 노름 — 거리 재는 방법이 여럿 🟡 중급

L1 · L2 · L∞

L1 = Σ|xᵢ|    L2 = √(Σxᵢ²)    L∞ = max|xᵢ| 맨해튼 거리 · 직선 거리 · 가장 큰 차이 하나
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)
필요 데이터가 지수적으로 증가특징 선택 · 임베딩 차원 축소
벡터 DB 를 쓸 때 반드시 만나는 문제입니다. “정확한 최근접”은 고차원에서 전수 탐색과 거의 같은 비용이 되기 때문에, 모든 벡터 DB 가 근사 알고리즘을 씁니다 — 정확도를 조금 포기하고 속도를 얻는 거래입니다.
LA 02

행렬 · 곱셈 — “여러 변환을 한 번에”의 정체

행렬 곱셈 규칙이 왜 그렇게 이상하게 생겼는지는 “변환을 이어 붙인 것”이라고 보면 한 번에 풀립니다. 이미지 필터도 신경망 한 층도 결국 이 연산 하나입니다.

곱셈의 규칙과 의미

(m×n) · (n×p) = (m×p) 가운데 n 이 맞아야 하고, 결과는 바깥 두 개입니다.
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 가 필요한가

(n×n) 행렬 두 개를 곱하면 곱셈이 n³ 번 — n=1000 이면 10억 번
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  : 수천 코어가 동시에
딥러닝이 GPU 를 쓰는 이유가 이 한 줄입니다 — 학습의 대부분이 거대한 행렬 곱이고, 그 연산은 완전히 병렬화됩니다. → 🖥️ 컴퓨터구조 탭 10

이렇게도 쓸 수 있다 — 코드에서 만나는 행렬 🟡 중급

이미지 필터 = 작은 행렬

# 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만,)
# 수백 배 빠르다
벡터화(vectorization)라고 부릅니다 — 파이썬 반복문을 행렬 연산 한 줄로 바꾸는 것. 느린 파이썬 코드를 고치는 첫 번째 수단입니다. 🐍 Python 가이드의 NumPy 탭에 실전 예가 있습니다.

실무에선 이렇게 — 희소 행렬 🔴 실무

대부분이 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 를 요청하게 됩니다.
LA 03

선형변환 · 기저 — “좌표계를 바꾼다”의 뜻

회전 · 확대 · 전단은 전부 같은 종류의 연산입니다. 그리고 차원 축소도 사실 “좋은 좌표계로 옮긴 뒤 뒤쪽을 버리는 것”입니다.

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 행렬을 쓴다.
# 회전·확대·이동·원근을 '곱셈 하나'로 합칠 수 있기 때문.
CSS 의 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) 만큼 옮겨 중심을 원점으로
#   ② 회전
#   ③ 원래 자리로
디버깅 요령 — 변환 행렬에 단위벡터 (1,0) 과 (0,1) 을 넣어 보세요. 결과가 곧 “x축과 y축이 어디로 갔는가”이고, 그림을 그려 보면 원인이 바로 보입니다.
LA 04

행렬식 · 역행렬 — 되돌릴 수 있는가

역행렬은 “변환을 되돌리는 변환”입니다. 그리고 되돌릴 수 없는 경우가 언제인지를 알려 주는 값이 행렬식입니다.

행렬식이 뜻하는 것

det(A) = 면적(부피)이 몇 배가 되는가 det = 0 이면 납작하게 눌려 차원이 줄었다 → 되돌릴 수 없음
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 이면 뒤집힘(거울상)이 포함된 것
직관 — 3차원 물체를 종이에 그림자로 눌러 버리면(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.]   ← 답이 완전히 다름!
조건수(condition number)가 크면 입력의 작은 오차가 출력에서 크게 증폭됩니다. 측정값처럼 오차가 섞인 데이터를 다룰 때는 det 보다 cond 를 봐야 합니다.

최소제곱 — 방정식이 넘칠 때

# 관측이 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 절에서 이어집니다.
LA 05

고윳값 · 고유벡터 — PageRank 와 PCA가 같은 계산입니다

“변환해도 방향이 안 바뀌는 축”을 찾는 문제입니다. 구글이 웹페이지 순위를 매긴 방법과 데이터 차원을 줄이는 방법이 놀랍게도 이 하나입니다.

정의는 한 줄

Av = λv 변환 A 를 해도 v 의 방향은 그대로이고 길이만 λ 배가 된다
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]   ← 같다 ✓
왜 유용한가 — 고유벡터 방향으로 보면 변환이 단순한 곱셈이 됩니다. 복잡한 반복 계산이 “λ 를 n 번 곱하기”로 바뀝니다.

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 의 반복 횟수나 수렴 실패를 진단할 수 있습니다.
행렬 A 를 곱했을 때 — 대부분의 벡터는 방향이 바뀝니다 v Av — 방향이 틀어짐 일반 벡터 고유벡터는 — 방향은 그대로, 길이만 λ 배 v Av = λv 같은 직선 위에 그대로 있습니다
고유벡터 = 그 변환이 “늘이기만 하는” 축 — 회전하지 않고 스케일만 바뀌는 방향입니다.

왜 반복하면 가장 큰 축만 남는가 — 거듭제곱법의 수학

임의의 벡터를 고유벡터로 분해해 보면

x0 = c1v1 + c2v2 + ⋯ + cnvn Akx0 = c1λ1kv1 + c2λ2kv2 + ⋯
= λ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 를 쓰면 수치 오차로 허수부가 섞여 나올 수 있어 정렬·시각화에서 문제가 생깁니다.
LA 06

내적 · 코사인 유사도 — 검색과 추천의 심장

벡터 검색 · RAG · 추천 · 중복 문서 탐지. 전부 “두 벡터가 얼마나 같은 방향인가” 하나로 돌아갑니다. 계산은 곱해서 더하는 것뿐입니다.

내적 — 곱해서 더하기

a·b = Σ aᵢbᵢ = |a||b| cos θ 같은 방향이면 크고, 직각이면 0, 반대면 음수
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])]
벡터 DB 가 하는 일이 정확히 이것입니다. 저장 시 정규화해 두고, 검색은 행렬 곱 한 번. 데이터가 커지면 여기에 근사 인덱스(HNSW)를 얹습니다.

이렇게도 쓸 수 있다 — 유사도 척도 고르기 🟡 중급

상황에 따라 답이 다릅니다

척도계산언제 쓰나
코사인방향만텍스트 임베딩 · 기본값
내적 (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억 번)
정규화된 벡터에서는 코사인과 유클리드가 같은 순위를 냅니다 (단조 관계). 그래서 벡터 DB 문서에서 “L2 든 코사인이든 정규화하면 같다”는 설명을 보게 됩니다. 정규화를 안 했다면 완전히 다릅니다.

실무에선 이렇게 — 검색 품질이 나쁠 때 🔴 실무

벡터 검색 문제 진단표

증상원인 · 대응
전부 비슷한 점수 (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]
RRF 가 실무에서 인기 있는 이유 — 점수의 절대값을 안 쓰고 순위만 쓰기 때문에 서로 다른 검색 엔진의 결과를 튜닝 없이 합칠 수 있습니다.
내적과 코사인 유사도 — 길이를 지우면 각도만 남습니다 a b θ 각도 θ 하나가 유사도를 정합니다 a · b = |a| |b| cos θ = a₁b₁ + a₂b₂ + ⋯ + aₙbₙ cos θ = (a · b) / (|a| |b|) 길이를 나눠 없앰 → 문서 길이와 무관해집니다 θ = 0° cos = 1 같은 뜻 θ = 90° cos = 0 무관 θ = 180° cos = −1 반대 긴 문서와 짧은 문서를 같은 잣대로 비교할 수 있는 것이 유클리드 거리 대신 코사인을 쓰는 이유입니다.
“얼마나 큰가”가 아니라 “어느 쪽을 보는가”를 재기 때문에, 단어 수가 다른 문서도 공정하게 비교됩니다.

유사도 척도 고르기 — 무엇을 재느냐가 다릅니다

세 가지 척도의 관계

정규화된 벡터(|a| = |b| = 1)에서는 |a − b|² = |a|² + |b|² − 2(a·b) = 2 − 2cos θ
⇒ 유클리드 거리와 코사인 유사도가 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 차원에서 두 무작위 단위벡터의 내적은 평균 0, 표준편차 1/√d
d = 768 이면 표준편차 ≈ 0.036 — 대부분 cos θ ≈ 0 (θ ≈ 90°)
⇒ 그래서 0.3 만 넘어도 “꽤 비슷한” 것이 됩니다
“코사인 유사도 0.8 이면 비슷한 건가요?”에 절대적인 기준은 없습니다. 차원 수·임베딩 모델·정규화 방식에 따라 분포가 전부 다릅니다. 내 데이터로 히스토그램을 그려 보고 “무관한 쌍의 분포”와 “관련된 쌍의 분포”가 갈리는 지점을 임계값으로 잡으세요. 절대값 대신 순위(Top-K)만 쓰는 것이 더 안전한 이유이기도 합니다.
10만 건이 넘어가면 전수 비교가 부담됩니다. 그때 근사 최근접 탐색(ANN) — HNSW·IVF·PQ 를 씁니다 (FAISS·pgvector·Qdrant). 정확도를 조금 포기하고 속도를 수십 배 얻는 거래이고, 그 내부 구조는 🗂️ 자료구조 12(확률적 자료구조)와 같은 계열입니다.
LA 07

확률의 기초 — 조건이 붙으면 직관이 무너집니다

확률 계산에서 사람이 틀리는 지점은 거의 정해져 있습니다 — 조건부확률과 독립성 착각. 이 둘만 잡으면 대부분의 오류가 사라집니다.

기본 규칙

P(A∪B) = P(A) + P(B) − P(A∩B)
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% 가 된다.
마이크로서비스 설계에서 반드시 계산해야 하는 값입니다. 개별 서비스의 p99 가 좋아도 호출 사슬이 길면 전체 p99 는 나빠집니다. 대응은 병렬 호출 · 헤지 요청 · 타임아웃 예산입니다 → 🕸️ 분산 · 동시성 탭 13.

실무에선 이렇게 — 샘플링과 레이트 리밋 🔴 실무

로그 샘플링 — 편향 없이 줄이기

# ✗ 나쁜 샘플링 — 앞의 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)
③ 결정적 샘플링이 중요합니다 — 서비스마다 따로 1% 를 뽑으면 이어지는 트레이스가 하나도 안 남습니다. trace_id 로 판정해야 전체 경로가 보존됩니다.

확률적 조기 만료 — 캐시 스탬피드 방지

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)          # 한 요청만 갱신, 나머지는 옛 값 사용
캐시 스탬피드는 인기 키가 만료되는 순간 수천 요청이 동시에 DB 로 가는 현상입니다. 위 방법(XFetch)은 확률을 이용해 갱신 시점을 흩뜨립니다 — 락 없이 해결됩니다.
LA 08

베이즈 정리 — 증거를 보고 믿음을 갱신하는 공식

“정확도 99% 인 검사가 양성이면 정말 99% 인가?” 답은 아니오이고, 그 이유가 베이즈 정리입니다. 스팸 필터 · 이상 탐지 · 알림 설계가 전부 이 계산 위에 있습니다.

공식과 이름

P(A|B) = P(B|A) · P(A) / P(B) P(A) 사전확률 · P(B|A) 우도 · P(A|B) 사후확률
# 실제 침입이 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
오즈(odds)로 바꾸면 계산이 곱셈이 됩니다. “증거 하나마다 우도비를 곱한다”가 되어 코드가 아주 단순해집니다.

이렇게도 쓸 수 있다 — 나이브 베이즈 스팸 필터 🟡 중급

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

왜 “나이브”인가

모든 단어가 서로 독립이라고 가정하기 때문입니다. 실제로는 “무료”와 “쿠폰”이 함께 나오지만 무시합니다. 가정이 틀렸는데도 분류는 잘 됩니다 — 순위만 맞으면 되기 때문입니다.

실무에서 여전히 쓰는 이유

학습이 한 번 훑기로 끝나고, 예측이 덧셈뿐이며, 온라인 갱신이 쉽습니다. 로그 분류 · 언어 감지 · 1차 필터로는 아직도 좋은 선택입니다.

실무에선 이렇게 — 알림 설계에 적용 🔴 실무

“알림 피로”를 수학으로 줄이기

원칙이유 (베이즈 관점)
증상이 아니라 영향에 알림“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}
연결 — SLO · 에러 버짓으로 알림을 설계하는 방법은 🏛️ 소프트웨어 공학 탭 12에서 이어집니다.
“정확도 99% 검사가 양성” — 실제로 병일 확률은? 사람 1만 명으로 세어 봅니다 전체 10,000명 유병률 1% 환자 100명 실제로 병 건강 9,900명 병이 없음 양성 99명 진짜 양성 (99%) 양성 99명 거짓 양성 (1%) 양성 판정 198명 그중 진짜 환자 99명 → 정확히 50% P(병|양성) = 99 / (99 + 99) = 0.5 — 검사 정확도 99% 와 아무 상관이 없습니다 결정적인 것은 “원래 얼마나 드문 병인가”(사전확률)입니다 유병률이 0.1% 로 낮아지면? 환자 10명 중 10명 양성, 건강 9990명 중 100명 양성 → 정답률 9%. 드문 것을 찾을수록 거짓 양성이 진짜를 압도합니다 — 이것이 기저율 무시(base rate fallacy)입니다. 보안 알림·이상 탐지·스팸 필터가 “늑대야” 경보로 가득 차는 이유가 정확히 이 계산입니다. 해결은 정확도를 올리는 것이 아니라 사전확률을 올리는 것 — 의심스러운 대상만 골라 검사하는 것입니다.
“정확도”만 듣고 판단하면 반드시 틀립니다 — 반드시 원래 비율(사전확률)을 함께 물어야 합니다.

공식으로 다시 — 그리고 로그로 계산하는 이유

베이즈 정리 유도

P(A∩B) = P(A|B)·P(B) = P(B|A)·P(A) 양변을 P(B) 로 나누면
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)
오즈(odds) 형태가 실무에서 더 편합니다 사후 오즈 = 사전 오즈 × 우도비
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 을 더하는 라플라스 평활 한 줄이면 됩니다.
“나이브(naive)”는 단어들이 서로 독립이라고 가정한다는 뜻입니다. 실제로는 “무료”와 “쿠폰”이 함께 나오므로 명백히 틀린 가정인데도, 분류 성능은 놀랍도록 좋습니다 — 확률의 절대값은 엉터리지만 순위는 맞기 때문입니다. 그래서 나이브 베이즈의 확률값을 신뢰도로 그대로 쓰면 안 됩니다.
LA 09

확률분포 — 응답 시간은 정규분포가 아닙니다

분포를 잘못 가정하면 용량 산정이 통째로 틀립니다. 실무에서 만나는 네 가지 분포와 각각이 나타나는 자리를 봅니다.

네 가지만 알면 됩니다

분포나타나는 곳모양
이항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 — 반드시 일어난다
용량 산정의 핵심 — “평균 50 QPS 니까 100 QPS 로 잡자”가 아니라 “최악의 1초에 몇 건이 오는가”를 분포로 계산해야 합니다.

응답 시간의 진짜 모양

정규분포라면          실제 응답 시간
    ▁▃▅█▅▃▁              █▅▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ …
  평균 주변 대칭        대부분 빠르고, 꼬리가 아주 길다

이유
 · 캐시 히트/미스가 갈린다        (이봉 분포)
 · GC · 재시도 · 커넥션 재수립     (드물지만 큼)
 · 큐 대기는 부하에 지수적으로 증가

→ 평균은 거의 쓸모없다. p50 · p95 · p99 · max 를 본다.
“평균 응답 200ms”는 아무것도 말해 주지 않습니다. p50=80ms, p99=3s 일 수 있습니다. 대시보드에서 평균 그래프는 지우세요.

이렇게도 쓸 수 있다 — 분위수를 제대로 다루기 🟡 중급

평균 낼 수 없는 값

# ✗ 절대 하면 안 되는 것: 분위수의 평균
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 는 소수 사용자 문제가 아니다

한 페이지가 내부 API 를 50번 부르면 그중 하나가 p99 에 걸릴 확률은 39% 입니다. p99 는 “1%의 요청”이 아니라 “40%의 페이지”입니다.

실무에선 이렇게 — 대기 행렬 이론 맛보기 🔴 실무

사용률 80% 를 넘기면 왜 갑자기 느려지나

대기 시간 ∝ ρ / (1 − ρ)    (ρ = 사용률) ρ=0.5 → 1배 · ρ=0.8 → 4배 · ρ=0.9 → 9배 · ρ=0.95 → 19배
# 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동시 처리 수 = 도착률 × 소요시간
리틀의 법칙이 실무에서 가장 쓸모 있습니다 — “초당 100건, 평균 200ms” 면 동시에 20건이 처리 중입니다. 스레드 풀·커넥션 풀 크기를 이 계산으로 정합니다.
LA 10

기댓값 · 분산 — 평균만 보면 반드시 사고가 납니다

평균 키가 170cm 인 방에 농구선수와 어린이가 섞여 있을 수 있습니다. 흩어짐을 함께 보지 않으면 숫자가 거짓말을 합니다.

정의와 성질

E[X] = Σ xᵢP(xᵢ)    Var(X) = E[(X−μ)²]
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
평균 vs 중앙값 판단 기준 — 합이 의미 있으면 평균(총 매출, 총 트래픽), 전형적인 값을 알고 싶으면 중앙값(응답 시간, 소득).

큰 수의 법칙과 중심극한정리

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배 모아야 한다
√n 법칙이 A/B 테스트 기간 산정의 근거입니다 — 절반의 오차 범위를 원하면 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 가 낮을 때
몇 번 할까기대 지연 증가분 < 성공률 이득
백오프 필요한가원인이 과부하면 필수 (안 하면 악화)
지터를 넣을까여러 클라이언트면 필수 (동시 재시도 방지)
재시도는 부하를 증폭시킵니다 — 장애 중에 모두가 3회 재시도하면 트래픽이 4배가 되어 회복을 막습니다. 그래서 서킷 브레이커가 필요합니다 → 🕸️ 분산 탭 13.

실무에선 이렇게 — 몬테카를로 추정 🔴 실무

공식이 없으면 시뮬레이션

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]))
설계 결정을 숫자로 말할 수 있게 됩니다 — “병렬로 바꾸면 p99 가 332ms → 200ms” 처럼요. 분포를 정확히 모르면 실제 측정 데이터에서 리샘플링하면 됩니다.
LA 11

A/B 테스트 — 통계가 실무에서 틀리는 지점

p < 0.05 를 봤다고 끝이 아닙니다. 중간에 들여다보기 · 여러 지표 동시 검정 · 심슨의 역설이 실제 서비스에서 잘못된 결론을 만드는 3대 원인입니다.

기본 흐름

  1. 가설을 먼저 — “버튼 색이 전환율을 올린다”. 데이터를 본 뒤에 세우면 안 됩니다.
  2. 표본 크기를 먼저 계산 — 필요한 관측 수를 정하고 시작합니다.
  3. 정해진 기간을 채운다 — 중간에 멈추면 유의성이 부풀려집니다.
  4. 결과 해석 — 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 는 재현율이 낮음
신뢰구간을 함께 보고하세요 — “전환율 +0.5%p (95% CI: +0.1 ~ +0.9)”가 “p=0.03”보다 훨씬 많은 것을 말해 줍니다. 효과 크기와 불확실성이 함께 보입니다.

이렇게도 쓸 수 있다 — 세 가지 대표 함정 🟡 중급

① 계속 들여다보기

# 매일 확인하며
# "유의해지면 종료"
# → 실제 오탐률이
#   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 이 나면 결과 해석 전에 원인을 찾아야 한다:
#  · 봇 트래픽이 한쪽에 몰림
#  · 리다이렉트로 인한 이탈
#  · 캐시가 한쪽 버전만 서빙
SRM 이 나면 다른 모든 분석이 무의미합니다. 배정이 편향됐다는 것은 비교 대상이 서로 다른 모집단이라는 뜻이기 때문입니다. 실험 대시보드의 첫 번째 위젯이어야 합니다.
LA 12

부동소수점 — 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비트에 잘라 넣으니 아주 조금 커진다
모든 언어에서 같습니다 — JS · 자바 · C · Go 전부 IEEE 754 를 쓰기 때문입니다. “파이썬이 이상하다”가 아닙니다.

비교하는 올바른 방법

# ✗ 절대 하면 안 됨
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금액에는 금지
JSON 도 조심하세요 — JS 의 숫자는 전부 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
NaN 이 정렬을 망칩니다 — 비교 결과가 모두 False 라 정렬 알고리즘의 전제가 깨집니다. 데이터 파이프라인에서 정렬 전에 NaN 을 걸러 내야 합니다.

언제 float 이 괜찮은가

용도판단
물리 · 그래픽 · 좌표적합
머신러닝 가중치적합 (float32 도 충분)
통계 · 비율 · 확률적합
금액 · 재고 수량금지
ID · 카운터금지 (정수)
시간 (초 단위 실수)주의 — 정수 나노초 권장
판단 기준 — “이 값이 정확히 같은지 비교할 일이 있는가”. 있으면 float 을 쓰면 안 됩니다. 하드웨어 수준의 이야기는 🖥️ 컴퓨터구조 탭 04에 있습니다.
LA 13

수치 안정성 — 작은 값이 조용히 사라집니다

부동소수점의 오차는 계산 순서만 바꿔도 크게 달라집니다. 통계·머신러닝·좌표 계산에서 결과가 이상할 때 원인이 여기인 경우가 많습니다.

자릿수 손실 (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
다음 탭 추천 — 3 🧮 계산이론 · 복잡도로 가면 여기서 계산한 “얼마나 걸리나”를 이론적으로 정리합니다.
LA 14

마르코프 체인과 엔트로피 — “다음에 무엇이 올까”를 재는 법

마르코프 체인은 “바로 직전 상태만 보면 다음이 정해지는” 확률 모형입니다. PageRank · 캐시 적중률 · 대기열 · 언어 모델이 전부 이 틀 위에 있습니다. 엔트로피는 “얼마나 예측하기 어려운가”를 숫자 하나로 잰 것이고, 압축률의 하한과 비밀번호의 강도가 같은 식에서 나옵니다.

마르코프 성질 — 미래는 현재에만 의존하고 과거 경로에는 의존하지 않습니다. P(Xn+1 | Xn, Xn−1, …) = P(Xn+1 | Xn). “기억이 없다”는 이 가정 덕분에 전이 행렬 하나로 시스템 전체를 다룰 수 있습니다.
상태 세 개짜리 마르코프 체인 — 화살표마다 확률이 붙습니다 대기 A 처리중 B 실패 C 0.7 0.3 0.2 0.5 전이 행렬 P 각 행의 합은 반드시 1 (어딘가로는 갑니다) n 단계 뒤의 분포 = 초기분포 × P의 n제곱 정상 분포 π πP = π — 곱해도 안 바뀌는 분포 = 고윳값 1 에 대응하는 고유벡터 (05절) 어디서 출발했든 충분히 오래 돌리면 같은 분포에 수렴합니다(연결되어 있고 주기적이지 않다면). PageRank 는 이 정상 분포입니다 — 무한히 링크를 따라다녔을 때 각 페이지에 머무는 비율입니다. 수렴 속도는 두 번째 고윳값이 정합니다 — 05절의 스펙트럴 갭이 그대로 반복됩니다.
“기억이 없다”는 가정 하나로, 복잡한 시스템의 장기 행동을 행렬 곱셈으로 계산할 수 있게 됩니다.

정상 분포를 두 가지 방법으로

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} → 오차가 매 단계 이 비율로 줄어듭니다")
수렴 조건 — 기약(irreducible)이고 비주기적(aperiodic) 기약: 어느 상태에서든 모든 상태로 갈 수 있다
비주기적: 돌아오는 주기가 딱 떨어지지 않는다

둘 다 만족하면 유일한 정상 분포로 수렴합니다 (에르고딕 정리)
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 — “결과를 알아내려면 예/아니오 질문이 평균 몇 번 필요한가” 공평한 동전 앞 50% 뒤 50% H = 1 비트 (최대) 치우친 동전 앞 90% 뒤 H = 0.47 비트 항상 앞면 앞 100% H = 0 (물어볼 것 없음) H(X) = − Σ p(x) log₂ p(x) — 확률이 고를수록 크고, 한쪽으로 몰릴수록 작습니다. 이 값이 압축의 하한입니다. 엔트로피가 0.47비트인 데이터를 1비트 미만으로 줄일 수는 있어도 0.47 아래로는 못 갑니다. 이미 압축된 파일(zip·jpg)이 다시 안 줄어드는 이유입니다 — 엔트로피가 이미 최대에 가깝습니다. 비밀번호에서는 반대로 큰 값이 좋습니다 — 엔트로피가 곧 "몇 번 시도해야 뚫리나"입니다.
압축과 보안이 같은 공식을 씁니다 — 한쪽은 값을 줄이려 하고, 다른 쪽은 늘리려 할 뿐입니다.

공식과 직접 계산

H(X) = − Σ p(x) log2 p(x)  [비트] 균등분포일 때 최대: H = log2 n
한 값이 확실하면 최소: 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 은 글자 하나가 아니라 반복 패턴을 줄이므로)

비밀번호 강도를 비트로 말하기

무작위로 고를 때  H = L · log2 N L = 길이, N = 문자 종류 수

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 같은 패턴 인식 기반 도구를 쓰세요.
엔트로피는 “이상 탐지”에도 쓰입니다. 정상 트래픽의 URL·IP 분포는 엔트로피가 일정한데, DDoS 나 스캔이 시작되면 급격히 변합니다(한쪽으로 몰리거나 반대로 흩어짐). 로그의 엔트로피를 시계열로 그려 두면 규칙을 미리 정하지 않고도 이상을 잡을 수 있습니다.
이어지는 곳 — 정상 분포와 고유벡터의 관계는 05절, 확률의 기본은 07·09절, 엔트로피를 쓰는 해시·난수는 🔐 보안 02·11, 압축과 정보 이론의 한계는 🧮 계산이론 탭에서 이어집니다.
LA 15

특이값 분해와 최소제곱법 — 데이터를 “중요한 순서”로 쪼개기

SVD 는 어떤 행렬이든 “회전 → 축마다 늘이기 → 다시 회전” 세 단계로 쪼갭니다. 정사각이 아니어도, 역행렬이 없어도 됩니다. 추천 · 검색 · 이미지 압축 · 노이즈 제거가 전부 이 분해에서 앞쪽 몇 개만 남기는 같은 동작입니다. 최소제곱법은 답이 없는 연립방정식에 가장 덜 틀린 답을 주는 방법이고, 그 계산을 안정적으로 해내는 도구가 또 SVD 입니다.

05절의 고유분해가 막히는 자리에서 시작합니다. 고윳값 분해는 정사각 행렬에만 됩니다. 그런데 실무 데이터는 사용자 100만 × 상품 5만 처럼 거의 항상 직사각입니다. SVD 는 그 제약을 없앤 것이라서 모든 실수 행렬에 대해 항상 존재합니다.
행렬이 하는 일을 세 단계로 — 회전 · 늘이기 · 다시 회전 ① 원래 벡터들 단위원 ② Vᵀ 로 회전 크기는 그대로 ③ Σ 로 늘이기 σ₁ ≫ σ₂ 축마다 σ 배 ④ U 로 다시 회전 최종 결과 A = σ₁u₁v₁ᵀ + σ₂u₂v₂ᵀ + … — 랭크 1짜리 층을 겹쳐 쌓은 것입니다. σ 는 내림차순이라 앞쪽 몇 층이 대부분을 설명하고 뒤는 잔털에 가깝습니다. 앞 k층만 남긴 것이 랭크-k 근사이고, 오차 기준 최적입니다(에카르트–영). 뒤쪽 층을 버리는 것이 곧 노이즈 제거입니다 — 잡음은 작은 σ 에 몰립니다. 추천의 잠재 요인 · 검색의 LSA · PCA 의 주성분이 전부 이 앞쪽 층입니다. 조건수 κ = σ 최대 ÷ σ 최소 — 13절에서 본 그 값이 여기서 나옵니다.
“중요한 순서로 줄 세우기” — 압축 · 추천 · 차원 축소 · 노이즈 제거가 전부 같은 한 동작입니다.

랭크-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])
k 를 고르는 기준은 “설명력 90~95%” 가 실무 기본값입니다. s 를 그래프로 그려 보면 대개 급격히 꺾이는 지점이 보이는데, 그 무릎(elbow)까지가 신호이고 그 뒤는 잡음입니다.

어디에서 이미 쓰고 있었나

쓰는 곳SVD 가 하는 일
PCA평균 제거 후 SVD → V 가 주성분
추천(행렬 분해)사용자×상품에서 잠재 취향 축을 뽑음
검색(LSA)문서×단어 → 의미가 비슷한 축으로 묶음
이미지 압축앞 k 층만 저장
노이즈 제거작은 σ 를 0 으로
의사역행렬역행렬이 없는 행렬의 대타
조건수κ = σmax / σmin
왜 항상 존재하는가 — A = UΣVᵀ AᵀA = (UΣVᵀ)ᵀ(UΣVᵀ) = VΣᵀΣVᵀ
→ V 는 AᵀA 의 고유벡터, σi2 는 그 고윳값
같은 식으로 AAᵀ = UΣΣᵀUᵀ → U 를 얻습니다

AᵀA 는 항상 대칭이고 준정부호라 고윳값이 실수이고 0 이상입니다.
그래서 σ = √고윳값 이 항상 정의되고, 모든 실수 행렬에 SVD 가 존재합니다.
랭크 = 0 이 아닌 σ 의 개수

최소제곱법 — 답이 없는 연립방정식에 답을 주기

식이 미지수보다 많으면 정답은 없습니다 — 대신 “가장 가까운 점”을 찾습니다 최소제곱 = 열공간으로 수직 내리기 b 관측값 잔차 r ⊥ 열공간 Ax̂ 열공간 = 만들 수 있는 값 같은 답을 구하는 세 가지 방법 정규방정식 빠름 · 조건수 제곱 ⚠ QR 분해 실무 기본값 · 안정적 SVD · 의사역행렬 가장 안정 · 랭크 부족도 OK 잔차가 열공간에 수직일 때 오차가 최소입니다 — 그것을 식으로 쓴 것이 Aᵀ(b − Ax) = 0. 즉 “가장 덜 틀린 답”은 기하학적으로 그림자를 내리는 일과 같습니다. AᵀA 를 직접 만드는 순간 조건수가 제곱됩니다 — 13절의 정밀도 사고가 여기서 터집니다. 그래서 라이브러리의 lstsq 는 정규방정식이 아니라 SVD·QR 로 풉니다.
공식을 외우기보다 “수직으로 그림자를 내린다” 한 장면만 남겨 두면 회귀·보정·캘리브레이션이 전부 같은 그림이 됩니다.

용량 산정에 그대로 쓰는 최소제곱

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ᵀA) = κ(A)2
— σ 가 제곱되니 최대/최소 비율도 제곱됩니다

κ(A) = 108 이면 κ(AᵀA) = 1016
double 의 유효 자릿수가 약 16자리라 정확한 자리가 하나도 안 남습니다

→ 같은 수학, 다른 결과. 계산 순서가 답을 바꿉니다
inv(A.T @ A) @ A.T @ b 는 교과서에는 나오지만 코드에는 쓰지 않습니다. 느리고(역행렬을 명시적으로 만듦) 부정확합니다(조건수 제곱). np.linalg.lstsq · scipy.linalg.lstsq 를 쓰세요 — 내부에서 SVD 로 풀고, 랭크가 모자란 경우까지 알아서 처리합니다.
릿지 회귀는 수치적인 처방이기도 합니다. 벌점 λ 를 더하면 특이값이 σ → σ/(σ2+λ) 로 바뀌어 아주 작은 σ 가 결과를 뒤흔드는 일이 사라집니다. “과적합 방지”로만 알려져 있지만, 조건수 완화가 절반의 이유입니다.
이어지는 곳 — 고윳값과의 관계는 05절, 조건수와 오차 누적은 13절, 코사인 유사도와 임베딩은 06절, 행렬 곱의 연산량과 캐시 효과는 🖥️ 컴퓨터구조 07 에서 이어집니다.
빅오를 정확히 읽는 능력 · 그리고 애초에 풀 수 없는 문제들

얼마나 걸리는가
그리고 애초에
풀 수 있는가

코드를 쓰는 사람에게 가장 즉시 쓸모 있는 이론이 여기 있습니다 — 이 코드가 데이터 100배에서 살아남는가를 실행해 보기 전에 아는 능력. 거기에 더해, 정규식이 왜 HTML 을 못 파싱하는지 · 정적 분석 도구가 왜 완벽할 수 없는지 · “최적해를 포기해야 하는 문제”가 어떤 것인지까지 이어집니다.

13SECTIONS
계산기내장
P≟NP까지
실측중심
COMP 01

계산이란 무엇인가 — 튜링 기계가 정의한 것

“컴퓨터로 풀 수 있다”는 말에 정확한 정의가 필요했습니다. 1936년, 컴퓨터가 아직 없을 때 앨런 튜링이 만든 그 정의가 지금도 모든 언어의 한계를 규정합니다.

구성 요소는 넷뿐

① 무한한 테이프   ... [ ][1][0][1][ ][ ] ...
② 읽기·쓰기 헤드           ↑
③ 유한한 상태 집합   q0, q1, q2, ...
④ 전이 규칙        (현재상태, 읽은값) → (쓸값, 이동방향, 다음상태)

이게 전부다. 그런데 이 기계로
파이썬으로 짤 수 있는 모든 프로그램을 짤 수 있다.
처치-튜링 논제 — “직관적으로 계산 가능한 것 = 튜링 기계로 계산 가능한 것”. 증명된 정리가 아니라 정의로 받아들이는 명제입니다. 90년 동안 반례가 나오지 않았습니다.

튜링 완전 — 모든 언어가 같은 힘

# 튜링 완전에 필요한 것: 조건 분기 + 무한 반복 + 무제한 저장
# 이 셋만 있으면 무엇이든 계산할 수 있다.

# 튜링 완전한 것들 (의외의 것 포함)
#  · 모든 범용 프로그래밍 언어
#  · 엑셀 수식 (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
보편 튜링 기계 — 다른 튜링 기계의 규칙을 테이프에 적어 입력으로 받아 그대로 흉내 내는 기계도 만들 수 있습니다. 이것이 “프로그램을 데이터로 다루는 컴퓨터”의 원형이고, 인터프리터가 하는 일이 정확히 이것입니다 → 🔧 컴파일러 탭 08.

실무에선 이렇게 — 힘을 일부러 줄이는 설계 🔴 실무

사용자에게 코드 실행을 허용할 때

요구선택이유
필터 조건식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 ✓
“타임아웃을 걸면 되지 않나” — 도움은 되지만 충분하지 않습니다. 메모리를 채우거나 파일을 읽거나 네트워크로 나갈 수 있습니다. 힘 자체를 줄이는 것이 더 확실한 방어입니다 → 🔐 보안 탭 09.
COMP 02

유한 상태 기계 — 가장 많이 쓰는데 이름을 모르는 도구

주문 상태 · 연결 상태 · 플레이어 상태. 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(입력 길이)느릴 수 있음
표현력완전히 같다 (서로 변환 가능)
표현력이 같은데 왜 둘 다 있나 — NFA 는 쓰기 쉽고, DFA 는 돌리기 빠릅니다. 그래서 정규식 엔진은 NFA 로 받아서 DFA 로 변환합니다(부분집합 구성). 03 절에서 이 변환이 왜 중요한지 봅니다.

이렇게도 쓸 수 있다 — 상태 기계가 이미 있는 곳 🟡 중급

알고 보면 전부 FSM

영역상태전이 사건
TCP 연결CLOSED · SYN_SENT · ESTABLISHED · TIME_WAIT …SYN · ACK · FIN
HTTP 파서요청줄 · 헤더 · 바디 · 청크바이트 도착
UI 컴포넌트idle · loading · success · errorfetch · 응답 · 실패
게임 캐릭터대기 · 이동 · 공격 · 피격 · 사망입력 · 충돌
서킷 브레이커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}
“불가능한 상태를 표현 불가능하게”가 상태 설계의 핵심 문장입니다. 불린 4개는 16가지 조합을 만들지만 실제로 유효한 것은 4가지뿐입니다. 나머지 12가지가 버그의 서식지입니다.

실무에선 이렇게 — 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 로 응답

# 상태 전이 이력을 별도 테이블에 남기면
# "언제 누가 왜 바꿨나"를 추적할 수 있다 (감사 로그)
애플리케이션 코드만으로는 부족합니다 — 서버가 여러 대면 읽고-확인하고-쓰기 사이에 반드시 끼어듭니다. 전이 조건을 UPDATE 문의 WHERE 절에 넣는 것이 가장 확실합니다 → 🕸️ 분산 · 동시성 탭 02.
COMP 03

정규 언어 — 정규식이 못 하는 일은 정해져 있습니다

“정규식으로 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 입니다.

이렇게도 쓸 수 있다 — 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
단순화파서로 대체
실제로 일어난 사고입니다 — Cloudflare(2019) · Stack Overflow(2016) 가 정규식 백트래킹으로 전면 장애를 겪었습니다. 사용자 입력을 받는 정규식은 반드시 길이를 제한하세요.

실무에선 이렇게 — 정규식을 쓸 자리와 말 자리 🔴 실무

판단 기준

작업정규식대신 쓸 것
이메일 형식 대략 확인적합/^[^@\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)
기억할 한 문장 — “정규식으로 찾기는 좋고, 구조를 이해하기는 나쁘다”. 평평한 텍스트에서 패턴을 뽑을 때는 최고이고, 중첩된 구조를 다루려는 순간 틀린 도구입니다.
정규식이 못 하는 일은 원리적으로 정해져 있습니다 잘하는 것 — 정해진 모양 찾기 전화번호 · 날짜 형식 · 로그 한 줄 파싱 · 토큰 나누기 "상태를 몇 개 두고 글자를 하나씩 읽으며 옮겨 다니면 되는" 문제입니다 기억할 것이 없으므로 아주 빠르고 메모리가 일정합니다 못 하는 것 — 세어야 하는 구조 괄호가 몇 겹 열렸는지 · 태그가 제대로 닫혔는지 · 중첩된 JSON "지금까지 몇 개 열렸나" 를 기억해야 하는데 유한 상태 기계에는 그 저장소가 없습니다 깊이를 3까지만 처리하는 식으로 흉내 낼 수는 있지만 일반해가 아닙니다 그래서 HTML 을 정규식으로 파싱하지 말라는 말이 나옵니다 — 취향이 아니라 표현력의 문제입니다 중첩이 필요하면 다음 단계인 문맥자유 문법과 파서로 올라가야 합니다 덧붙여 — 역참조 같은 확장은 편하지만 최악의 경우 지수 시간이 걸려 서비스가 멈추기도 합니다
도구를 고르는 기준은 익숙함이 아니라 그 문제가 어느 계층에 있는가입니다.
COMP 04

문맥자유 문법 — 중첩을 다루는 다음 단계

정규식이 못 하는 괄호 짝 · 중첩 구조를 다루는 문법입니다. 모든 프로그래밍 언어의 문법이 여기 속하고, 그래서 파서가 필요합니다.

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)
모호한 문법은 파서가 만들어지지 않습니다 — yacc/bison 이 “shift/reduce conflict” 를 내는 이유가 이것입니다. 경고를 무시하면 파서가 임의로 한쪽을 고릅니다.

이렇게도 쓸 수 있다 — 재귀 하강 파서 직접 만들기 🟡 중급

문법 규칙 하나 = 함수 하나

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
문법 규칙과 함수가 1:1 로 대응합니다 — 그래서 재귀 하강 파서는 손으로 쓰고 디버깅하기 가장 쉬운 방식이고, GCC · Clang · TypeScript 같은 실제 컴파일러도 이 방식을 씁니다. 자세한 내용은 🔧 컴파일러 탭 03에 있습니다.

실무에선 이렇게 — 파서를 만들어야 할 때 🔴 실무

직접 만들 것인가, 라이브러리를 쓸 것인가

상황선택
검색 쿼리 문법 (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
    ...
파싱이 보안이 되는 자리입니다 — 문자열을 이어 붙이는 대신 구조로 이해한 뒤 다시 만들면 인젝션이 불가능해집니다. 같은 원리가 HTML 살균 · 경로 정규화에도 적용됩니다 → 🔐 보안 탭 09.
COMP 05

촘스키 위계 — 언어의 표현력에는 계층이 있습니다

정규 → 문맥자유 → 문맥의존 → 무제한. 위로 갈수록 표현할 수 있는 것이 늘고, 처리 비용이 커지고, 보장이 사라집니다. 도구를 고를 때 이 계층이 기준이 됩니다.

타입 3
정규 언어FSM · 정규식 · O(n) 보장 · 중첩 불가
▲ 표현력이 커진다 / ▼ 처리가 쉬워진다
타입 2
문맥자유 언어푸시다운 오토마타(스택) · 프로그래밍 언어 문법 · O(n³) 이하
▲
타입 1
문맥의존 언어선형 유계 오토마타 · "변수는 선언 후 사용" 같은 규칙
▲
타입 0
재귀 열거 가능 언어튜링 기계 · 무엇이든 표현 가능 · 종료 보장 없음

각 계층이 못 하는 것

정규 언어가 못 함
  aⁿbⁿ         (여는 것과 닫는 것의 개수 맞추기)
  → 괄호 · 태그 중첩

문맥자유가 못 함
  aⁿbⁿcⁿ       (세 개를 동시에 맞추기)
  → "선언된 변수만 사용" · "인자 개수 일치"
  → 그래서 컴파일러는 파싱 뒤에 '의미 분석' 단계를 따로 둔다

문맥의존도 못 함
  정지 문제 (06 절)

실무에서의 의미

“이거 왜 안 되지?”계층 이유
정규식으로 JSON 검증중첩 = 문맥자유 필요
파서만으로 타입 오류 검출타입 검사 = 문맥의존
린터가 모든 버그를 못 잡음정지 문제 (타입 0)
YAML 이 예상 밖으로 동작문법이 문맥자유를 넘음
C++ 파싱이 어려움문법이 튜링 완전(템플릿)
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"
   → 종료 보장 · 정적 검증 가능

③ 너무 강함: 파이썬 스크립트 허용
   → 격리 · 타임아웃 · 자원 제한이 전부 필요해진다

대부분의 경우 ② 가 정답이다.
실무 신호 — “여기에 스크립트를 넣을 수 있으면 좋겠다”는 요구가 나오면 먼저 ② 로 해결되는지 보세요. 스크립트를 허용하는 순간 샌드박스 · 자원 제한 · 감사 로그가 전부 따라옵니다.
바깥으로 갈수록 표현력이 커지고, 필요한 기계도 커집니다 타입 0 — 재귀 열거 · 튜링 기계 타입 1 — 문맥 의존 · 선형 유계 오토마타 타입 2 — 문맥자유 · 푸시다운(스택) 오토마타 중첩 괄호 · 프로그래밍 언어 문법 · HTML 트리 타입 3 — 정규 · 유한 상태 기계 이메일 형식 · 토큰 · 로그 패턴 정규식의 한계 기억할 공간이 없습니다 "괄호가 몇 개 열렸나"를 셀 수 없습니다 그래서 실무에서는 HTML · JSON 을 정규식으로 파싱하지 않습니다 전용 파서를 씁니다
"정규식으로 HTML 파싱하지 마라"는 말의 수학적 근거가 이것입니다.
바깥으로 갈수록 표현력이 커지고, 필요한 기계도 커집니다 타입 0 — 재귀 열거 · 튜링 기계 타입 1 — 문맥 의존 · 선형 유계 오토마타 타입 2 — 문맥자유 · 푸시다운(스택) 오토마타 중첩 괄호 · 프로그래밍 언어 문법 · HTML 트리 타입 3 — 정규 · 유한 상태 기계 이메일 형식 · 토큰 · 로그 패턴 정규식의 한계 기억할 공간이 없습니다 "괄호가 몇 개 열렸나"를 셀 수 없습니다 그래서 실무에서는 HTML · JSON 을 정규식으로 파싱하지 않습니다 전용 파서를 씁니다
"정규식으로 HTML 파싱하지 마라"는 말의 수학적 근거가 이것입니다.
COMP 06

정지 문제 — 못 푸는 문제가 존재한다는 증명

“이 프로그램이 무한 루프에 빠지는지 미리 알려 주는 도구”는 만들 수 없습니다. 아직 못 만든 게 아니라 불가능이 증명돼 있습니다. 그 결과가 우리가 쓰는 모든 정적 분석 도구의 한계를 정합니다.

증명 — 자기 자신에게 물어보기

# 이런 함수가 있다고 가정하자
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 로 메모리 폭발
이론이 실무 규칙이 되는 자리입니다 — “일반적으로 종료를 증명할 수 없다”면 내가 종료를 강제하는 수밖에 없습니다. 모든 반복·재귀·외부 호출에 상한을 두는 습관이 여기서 나옵니다.
COMP 07

빅오 표기법 — 정확히 읽는 법

빅오는 “입력이 커질 때 얼마나 더 느려지는가”만 말합니다. 실제 속도도, 작은 입력에서의 성능도, 메모리도 말하지 않습니다. 그 경계를 알아야 잘못된 판단을 피할 수 있습니다.

복잡도연산 횟수1초에 1억 번이라면

숫자를 바꿔 보세요 — n 이 100배가 될 때 무엇이 무너지는지가 한눈에 보입니다.

정의와 표기

f(n) = O(g(n)) ⟺ 어떤 c, n₀ 가 있어
n ≥ n₀ 일 때 f(n) ≤ c·g(n) “충분히 커지면 상수배 안에 들어온다”는 뜻입니다.
표기뜻비유
O(g)상한≤
Ω(g)하한≥
Θ(g)상한 = 하한=
o(g)엄격한 상한<
일상에서는 O 를 Θ 의 뜻으로 씁니다 — 엄밀히는 “퀵 정렬은 O(n²)” 도 맞고 “O(n³)” 도 맞지만, 보통은 가장 빡빡한 상한을 말합니다.

빅오가 말하지 않는 것

무시하는 것현실에서의 영향
상수 배수100배 차이도 무시
작은 nn<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!)말할 수 없음전순열
실무에서 가장 중요한 경계는 O(n) 과 O(n²) 사이입니다. 데이터가 10배 늘어날 때 10배 느려지는 것은 버틸 수 있지만 100배는 못 버팁니다. 코드 리뷰에서 중첩 반복문을 유심히 보는 이유입니다.

실무에선 이렇게 — 실측으로 복잡도 추정하기 🔴 실무

코드를 읽지 않고 알아내는 법

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작으면 상수항이 지배
입력을 매번 새로캐시에 남아 왜곡
최악 입력도 함께평균만 보면 놓침
이 방법의 가장 큰 장점 — 남이 만든 라이브러리나 DB 쿼리처럼 안을 못 보는 것의 복잡도를 알아낼 수 있습니다. “이 API 가 데이터 10배에서 버티는가”를 30초 만에 확인합니다.
빅오를 정확히 읽는 법 — 무엇을 버리고 무엇을 남기나 버리는 것 ① — 상수 배 3n 도 100n 도 전부 O(n) 입니다. 그래서 같은 O(n) 끼리도 실제로는 몇 배 차이가 납니다 버리는 것 ② — 낮은 차수 항 n² + 1000n 은 O(n²) 입니다. n 이 커지면 앞 항이 전부를 지배하기 때문입니다 남기는 것 — 커질 때의 증가율 "입력이 10배가 되면 시간은 몇 배인가" 에만 답합니다. 그 이상은 말하지 않습니다 그래서 작은 입력에서는 복잡도가 나쁜 쪽이 더 빠를 수 있습니다 정렬 라이브러리가 작은 구간에서 삽입 정렬로 바꾸는 이유가 이것입니다 빅오는 상한이자 최악입니다 — 평균이 좋아도 최악에서 무너지는 알고리즘이 있습니다(해시 충돌 공격)
빅오를 아는 것과 지금 내 입력 크기에서 무엇이 빠른지 아는 것은 다른 문제입니다.
COMP 08

시간복잡도 계산 — 코드에서 손으로 세는 법

규칙 네 개면 대부분의 코드를 계산할 수 있습니다. 그리고 실무에서 자주 놓치는 숨은 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 listO(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초
N+1 쿼리는 특별히 위험합니다 — 로컬에서는 데이터가 적어 안 보이다가 운영에서만 터집니다. ORM 을 쓴다면 쿼리 로그를 켜고 요청 하나당 쿼리 수를 확인하세요 → 🗄️ Database 가이드.

실무에선 이렇게 — 복잡도가 같아도 다른 것 🔴 실무

상수가 지배하는 자리

# 둘 다 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/Ostrace · 배치 처리로 전환
락 경합스레드 수를 줄여 보기
최적화 순서 — ① 복잡도부터 낮춘다(알고리즘) ② 그다음 상수를 줄인다(자료 배치 · 라이브러리) ③ 마지막에 병렬화. 순서를 뒤집으면 노력 대비 효과가 가장 나쁩니다. 🖥️ 컴퓨터구조 탭 08 · 13에서 ② 를 자세히 다룹니다.
규칙은 셋 — 중첩은 곱하고, 순차는 더하고, 큰 것만 남깁니다 ① 중첩 → 곱하기 for i in n: for j in n: → O(n²) ② 순차 → 더하기 정렬 O(n log n) 그다음 훑기 O(n) → O(n log n) (큰 쪽만) ③ 반씩 줄면 → log while n > 1: n //= 2 이진 탐색 · 힙 · 균형 트리 → O(log n) 놓치기 쉬운 숨은 비용 문자열 이어붙이기(s += x)는 매번 새로 만듭니다 list 안에서 in 검색은 O(n) — set 은 O(1) 반복문 안의 함수가 또 반복문이면 곱해집니다 라이브러리 함수의 복잡도도 확인해야 합니다 감각 잡기 — n=100만일 때 O(n) · O(n log n) — 문제없습니다 O(n²) — 1조 번, 사실상 불가능 n 이 작으면(≤1000) O(n²) 도 괜찮습니다 그래서 입력 크기부터 확인하는 것이 순서입니다
복잡도는 계산이 아니라 세는 습관입니다 — 규칙 셋이면 충분합니다.
규칙은 셋 — 중첩은 곱하고, 순차는 더하고, 큰 것만 남깁니다 ① 중첩 → 곱하기 for i in n: for j in n: → O(n²) ② 순차 → 더하기 정렬 O(n log n) 그다음 훑기 O(n) → O(n log n) (큰 쪽만) ③ 반씩 줄면 → log while n > 1: n //= 2 이진 탐색 · 힙 · 균형 트리 → O(log n) 놓치기 쉬운 숨은 비용 문자열 이어붙이기(s += x)는 매번 새로 만듭니다 list 안에서 in 검색은 O(n) — set 은 O(1) 반복문 안의 함수가 또 반복문이면 곱해집니다 라이브러리 함수의 복잡도도 확인해야 합니다 감각 잡기 — n=100만일 때 O(n) · O(n log n) — 문제없습니다 O(n²) — 1조 번, 사실상 불가능 n 이 작으면(≤1000) O(n²) 도 괜찮습니다 그래서 입력 크기부터 확인하는 것이 순서입니다
복잡도는 계산이 아니라 세는 습관입니다 — 규칙 셋이면 충분합니다.
COMP 09

공간복잡도 — 메모리로 시간을 산다

캐시 · 인덱스 · 메모이제이션은 전부 같은 거래입니다 — 메모리를 더 써서 시간을 줄인다. 반대 방향의 거래가 필요한 순간도 있습니다.

무엇을 세는가

# 보통 '추가로 쓰는' 메모리만 센다 (입력 제외)

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)), 압축 저장(CPU 를 써서 공간 절약), 재계산(캐시 대신). 무엇이 부족한 자원인가에 따라 방향이 정해집니다.

이렇게도 쓸 수 있다 — 메모리 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비례 절약
확률적 자료구조정확도와 맞바꿔 수십 배
마지막 항목이 큽니다 — “정확히 몇 개인가” 대신 “대략 몇 개인가”로 요구를 낮추면 메모리가 극적으로 줄어듭니다 (HyperLogLog 는 1% 오차로 12KB 에 수십억 개를 셉니다) → 🗂️ 자료구조 탭 12.
COMP 10

상환 분석 — 가끔 비싼 일을 어떻게 세는가

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)  ← 훨씬 나쁘다
배수가 왜 2 인가 — 1.5배를 쓰는 구현도 많습니다(C++ 일부 · Java). 2배는 이전에 반납한 공간을 재사용할 수 없고, 1.5배는 복사가 더 잦습니다. 둘 다 상환 O(1) 입니다.

세 가지 분석 방법

방법아이디어
총합법n 번의 총비용 ÷ n
회계법싼 연산에 미리 요금을 더 받아 비싼 연산 때 씀
포텐셜법자료구조의 “긴장도”를 함수로 정의
회계법으로 본 append
  · append 한 번에 3 코인을 청구한다
    ① 1 코인: 실제 삽입
    ② 1 코인: 나중에 자기를 복사할 때 쓸 저금
    ③ 1 코인: 이미 복사된 앞쪽 원소를 위한 저금
  · 확장이 일어날 때 저금이 항상 충분하다
  → 연산당 상수(3) → 상환 O(1) ∎

이렇게도 쓸 수 있다 — 상환 O(1) 인 것들 🟡 중급

“평균은 빠른데 가끔 튀는” 자리 목록

연산평상시가끔왜
배열 appendO(1)O(n)용량 확장 + 복사
해시맵 삽입O(1)O(n)리해싱
유니온 파인드≈O(1)—경로 압축의 상환
두 스택 큐O(1)O(n)스택 뒤집기
GC 있는 할당O(1)수십 ms수집 사이클
LSM 트리 쓰기O(1)초 단위컴팩션
B트리 삽입O(log n)—분할은 드묾
실무에서 상환 분석이 위험한 순간 — “평균은 괜찮다”가 p99 응답 시간에서는 위안이 되지 않습니다. GC 정지 · 리해싱 · LSM 컴팩션이 하필 피크 시간에 겹치면 그 순간의 사용자는 전부 느려집니다.

실무에선 이렇게 — 스파이크를 평탄화하기 🔴 실무

상환 비용을 미리 지불하는 기법

# ① 미리 용량 확보 — 확장 자체를 없앤다
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 압력 감소누수·상태 오염 위험
배치 크기 조절피크 완화처리량 감소
백그라운드 컴팩션사용자 경로에서 제거여유 자원 필요
측정이 먼저입니다 — 지연 히스토그램에서 이봉(두 개의 봉우리) 형태가 보이면 상환 비용이 튀는 신호입니다. p50 은 좋은데 p99 만 나쁜 전형적인 패턴입니다 → ➗ 선형대수 · 확률 탭 09.
상환 분석 — 가끔 비싼 일을 전체로 나눠 봅니다 1 1 복사 1 1 1 1 복사 1 1 append 를 열 번 했을 때 — 대부분 즉시 끝나고 가끔만 전체를 옮깁니다 왜 평균이 상수가 되나 공간을 두 배씩 늘리기 때문에 복사가 일어나는 간격도 두 배씩 벌어집니다 n 번 덧붙이는 동안 옮긴 총 원소 수가 2n 을 넘지 않습니다 → 한 번당 상수 1.5배가 아니라 고정된 수만큼 늘리면 이 성질이 깨집니다 10칸씩 늘리면 복사가 계속 일어나 전체가 O(n²) 이 됩니다 최악의 한 번만 보면 O(n) 이라 과대평가합니다 — 연속으로 여러 번 부르는 연산은 상환으로 보세요 지연이 튀면 안 되는 실시간 시스템에서는 그 "가끔 비싼 한 번" 자체가 문제가 됩니다
평균이 상수라는 말은 매번 빠르다는 뜻이 아닙니다 — 어디에 쓰느냐에 따라 중요합니다.
COMP 11

P · NP · NP-완전 — 포기해야 할 때를 아는 법

어떤 문제는 아무리 잘 짜도 지수 시간입니다. 그것이 이 문제의 성질임을 알면 완벽한 답 대신 좋은 답을 빨리 내는 쪽으로 방향을 틀 수 있습니다.

정의

부류뜻
P답을 찾는 데 다항 시간
NP답을 확인하는 데 다항 시간
NP-완전NP 중 가장 어려운 것들 — 하나 풀면 전부 풀림
NP-난해NP-완전만큼 어렵되 NP 에 속하지 않을 수도
스도쿠로 이해하기
  · 완성된 스도쿠가 맞는지 확인 → 몇 초 (NP)
  · 빈 스도쿠를 푸는 것         → 크기가 커지면 폭발

P = NP 인가?
  = "확인이 쉬우면 찾는 것도 쉬운가?"
  = 밀레니엄 문제 (상금 100만 달러)
  = 대부분의 학자는 P ≠ NP 라고 믿는다

현실 문제 중 NP-완전인 것들

문제실무에서의 모습
배낭 문제예산 안에서 가치 최대화
외판원 (TSP)배송 경로 최적화
그래프 색칠레지스터 할당 · 시간표
집합 커버최소 서버로 전 지역 커버
빈 채우기 (bin packing)컨테이너 배치 · VM 스케줄링
SAT의존성 해결 · 설정 검증
일정 계획근무표 · 작업 스케줄링
패키지 의존성 해결이 NP-완전입니다 — npm · pip · apt 가 가끔 아주 오래 걸리는 이유입니다. 그래서 최신 도구들은 SAT 솔버를 내장합니다(예: pubgrub · libsolv).

이렇게도 쓸 수 있다 — 환원 — 어렵다는 것을 보이는 법 🟡 중급

“이 문제는 저 문제만큼 어렵다”

내 문제 X 가 어렵다는 것을 보이려면
  이미 NP-완전인 문제 Y 를 X 로 '다항 시간에 바꿀 수 있음'을 보인다.

  Y  ──다항 변환──▶  X
  "X 를 빨리 풀 수 있다면 Y 도 빨리 풀린다"
  Y 가 어려우므로 X 도 어렵다. ∎

실무에서의 쓸모
  · "이거 최적으로 못 풀어요" 를 근거 있게 말할 수 있다
  · 최적해 대신 근사·휴리스틱으로 방향을 트는 결정을 정당화
  · 반대로: 내 문제가 이미 알려진 문제와 같으면
    → 검증된 솔버를 그대로 쓰면 된다 (직접 짜지 말 것)

내 문제를 알려진 문제로 옮기기

현실 문제알려진 이름
회의실 배정구간 스케줄링 (P!)
작업자 배치이분 매칭 (P!)
서버 배치 비용 최소시설 입지 (NP)
기능 조합 선택배낭 (NP)
테스트 최소 집합집합 커버 (NP)
가장 중요한 실무 요령 — 문제를 알려진 이름으로 부를 수 있게 되면 검색이 되고, 라이브러리가 있고, 남이 이미 푼 방법이 나옵니다. “이건 결국 집합 커버네” 라는 한마디가 며칠을 아낍니다.

실무에선 이렇게 — P 인 줄 알았는데 아닌 경우 🔴 실무

조건이 하나 붙으면 난이도가 뒤집힙니다

문제난이도
최단 경로 (음수 없음)P 다익스트라
최단 단순 경로 (음수 사이클 있음)NP-난해
2-SATP 선형
3-SATNP-완전
2색 칠하기 (이분 그래프 판정)P
3색 칠하기NP-완전
최대 매칭P
최대 독립 집합NP-완전
오일러 경로 (모든 간선)P
해밀턴 경로 (모든 정점)NP-완전
요구사항이 늘어날 때 조심하세요 — “경로 찾기”에 “같은 곳을 두 번 지나면 안 됨” 한 줄이 붙는 순간 다항 시간에서 NP-난해로 넘어갈 수 있습니다. 기능 추가가 성능 요구와 충돌할 때, 원인이 여기인 경우가 있습니다.
실무 대화로 옮기면

  기획: "배송 경로를 최적으로 짜 주세요"
  개발: "정점 20개까지는 최적해를 낼 수 있습니다.
        그 이상은 NP-난해라 최적을 보장할 수 없고,
        보통 최적 대비 2~5% 안쪽의 답을 1초에 냅니다.
        그 차이가 비즈니스적으로 의미가 있을까요?"

→ 이론을 알면 '못 한다' 가 아니라 '이렇게 하겠다' 가 된다.
P · NP · NP-완전 — 포함 관계로 보면 단순합니다 NP — 답을 주면 빠르게 확인 가능 P 빠르게 풀 수 있음 NP-완전 가장 어려운 것들 P 의 예 정렬 · 최단경로 · 최대유량 NP-완전의 예 외판원 · 배낭 · SAT 그래프 색칠 · 부분집합 합 NP-완전 하나라도 빠르게 푸는 방법이 나오면 전부 풀립니다 — 서로 환원되기 때문입니다(알고리즘 15절의 그 환원). P = NP 인지는 미해결입니다. 대부분의 학자는 다르다고 보지만 증명은 없습니다.
실무에서 필요한 것은 증명이 아니라 “이 문제가 NP-완전인가”를 알아보는 눈입니다 — 완벽한 답을 포기할 때를 알려 줍니다.

정의를 정확히

P — 결정적 튜링 기계가 다항 시간에 푸는 문제 O(n) · O(n²) · O(n¹⁰⁰) 전부 P 입니다
“실무에서 빠르다”와는 다릅니다 — 이론적 분류입니다
NP — 답을 주면 다항 시간에 검증할 수 있는 문제 Nondeterministic Polynomial 의 약자입니다
“Not Polynomial” 이 아닙니다 — 가장 흔한 오해

P ⊆ NP 는 자명합니다 (풀 수 있으면 검증도 할 수 있으니까요)
미해결은 그 반대입니다 — NP ⊆ P 인가?
NP-완전 — NP 에 속하면서 NP 의 모든 문제가 환원되는 문제 ① NP 에 속한다 (검증이 빠르다)
② 어떤 NP 문제든 다항 시간에 이 문제로 바꿀 수 있다

NP-난해(NP-hard) 는 ②만 만족 — NP 밖일 수도 있습니다
(예: 정지 문제는 NP-난해이지만 NP 가 아닙니다)
“NP 문제라서 못 푼다”는 틀린 말입니다. 정렬도 NP 에 속합니다(P ⊆ 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-완전이라 못 푼다”가 아니라 “최악의 경우가 나쁘다”입니다. 실제 데이터는 최악의 경우가 아닌 경우가 대부분이고, 현대 SAT · MILP 솔버는 수만 변수짜리 실무 문제를 몇 초에 풉니다. 직접 알고리즘을 짜기 전에 OR-Tools 같은 솔버에 넣어 보는 것이 거의 항상 더 빠르고 좋은 답을 줍니다.
COMP 12

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. 정말 최적해가 필요한가 — “충분히 좋으면” 되는 경우가 대부분입니다. 1% 개선을 위해 개발 3개월을 쓰는지 계산해 보세요.
  2. 규모를 확인 — n ≤ 20 이면 완전 탐색, n ≤ 40 이면 분할 정복 + 비트마스크, 그 이상이면 솔버나 휴리스틱.
  3. 알려진 문제인지 확인 — 이름을 붙일 수 있으면 라이브러리가 있습니다.
  4. 솔버로 먼저 시도 — 시간 예산을 두고 돌려 보면 “현실 데이터에서는 금방 풀리는” 경우가 흔합니다.
  5. 안 되면 탐욕 + 지역 탐색 — 대개 최적의 5~10% 안쪽이 나옵니다.
  6. 품질을 측정 — 상한(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

# 지역 최적에서 빠져나오려면
#  · 재시작 (여러 초기해)
#  · 시뮬레이티드 어닐링 (가끔 나쁜 쪽도 수용)
#  · 타부 리스트 (최근 이동 금지)
보고할 때의 문장 — “최적해입니다” 대신 “상한 대비 3.2% 이내이며 8초에 계산됩니다”. 숫자로 말할 수 있으면 더 개선할지 여기서 멈출지를 비즈니스가 판단할 수 있습니다.
COMP 13

복잡도 치트시트 — 한 장으로

자료구조 · 정렬 · 그래프 알고리즘의 복잡도를 모아 둡니다. 설계할 때 · 리뷰할 때 · 면접 전에 여기만 보면 됩니다.

자료구조

구조탐색삽입삭제공간비고
배열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 · DFSO(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)배정 문제
V 와 E 의 관계를 기억하세요 — 희소 그래프는 E ≈ V, 조밀 그래프는 E ≈ V². 그래서 O(E log V) 는 희소에서 거의 O(V log V) 이지만 조밀에서는 O(V² log V) 입니다.

실무에선 이렇게 — 이어지는 곳 🔴 실무

이 탭 다음

여기서 배운 것다음
02 FSM · 03 정규 언어 · 04 CFG🔧 컴파일러 · 언어이론 전체
07~10 복잡도🗂️ 자료구조 · 🔀 알고리즘
08 상수가 지배하는 자리🖥️ 컴퓨터구조 08 캐시
09 공간 트레이드오프🗂️ 자료구조 12 확률적 구조
10 상환 · 스파이크🔧 컴파일러 10 GC · ⚙️ OS 03
06 정지 문제 · 종료 보장🏛️ SW 공학 08 테스트
다음 탭 추천 — 4 🗂️ 자료구조로 가면 여기서 본 복잡도 표의 “왜 그런가”를 하나씩 확인하게 됩니다. 특히 배열 vs 연결 리스트 절은 이론과 현실이 갈리는 지점을 다룹니다.
이론상의 복잡도와 현실의 성능이 갈리는 지점까지

어떤 자료구조를
고르느냐가
거의 전부입니다

“좋은 프로그래머는 자료구조를 걱정하고, 코드는 알아서 따라온다”는 말은 과장이 아닙니다. 이 탭은 각 구조의 내부 동작을 보고, 복잡도 표가 말해 주지 않는 캐시 · 메모리 · 최악 사례까지 함께 봅니다. 마지막 절에는 상황을 넣으면 구조가 나오는 결정표가 있습니다.

15SECTIONS
결정표검색 가능
내부동작까지
실측비교
DS 01

배열 vs 연결 리스트 — 캐시가 답을 바꿉니다

교과서는 “중간 삽입이 잦으면 연결 리스트”라고 합니다. 현실에서는 대부분 배열이 이깁니다. 이유는 알고리즘이 아니라 메모리가 어떻게 생겼는가에 있습니다.

배열 — 연속된 메모리 a[0]a[1] a[2]a[3] a[4]a[5] 캐시 한 줄(64B)에 여러 개가 통째로 올라온다 → 미스 1번 연결 리스트 — 흩어진 메모리 n0n1 n2n3 노드마다 다른 캐시 라인 → 미스 4번 · 포인터 추적
복잡도는 같아도 메모리에 어떻게 놓여 있느냐가 실제 속도를 10배까지 가릅니다.

이론상의 표

연산배열연결 리스트
i 번째 접근O(1)O(n)
맨 앞 삽입O(n)O(1)
중간 삽입 (위치 앎)O(n)O(1)
중간 삽입 (찾아야 함)O(n)O(n)
순회O(n)O(n)
메모리 오버헤드없음포인터 8~16B/노드
“중간 삽입 O(1)” 의 함정 — 그 위치를 이미 알고 있을 때만 입니다. 보통은 찾아가야 하므로 결국 O(n) 이고, 그 탐색이 캐시 미스를 계속 일으키는 형태입니다.

현실의 측정

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]
여기서 배열이 안 되는 이유 — “가운데 있는 원소를 맨 앞으로”가 배열에서는 O(n) 입니다. 캐시 히트마다 그러면 감당이 안 됩니다. 포인터를 갖고 있으니 O(1) 이라는 연결 리스트의 장점이 정확히 발휘되는 자리입니다.

실무에선 이렇게 — 언어별 실제 구현 🔴 실무

“리스트”라는 이름에 속지 않기

언어 · 타입실제 구조주의
파이썬 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
V8 의 히든 클래스·요소 종류 이야기는 🟨 JS · TS 가이드의 전문가 탭에 자세히 있습니다. 요점은 배열을 “같은 타입으로 빽빽하게” 유지하라는 것입니다.
DS 02

동적 배열 내부 — 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배
자바 ArrayList1.5배
C++ (GCC/Clang)2배
C++ (MSVC)1.5배
Go slice2배 → 1.25배(큰 경우)
Rust Vec2배

미리 확보하면 사라집니다

# 파이썬 — 크기를 아는 경우
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);
효과 — 100만 개를 넣을 때 재할당·복사가 20번에서 0번이 됩니다. 총 복사량 200만 개가 사라집니다. 크기를 대략이라도 알면 반드시 지정하세요.

이렇게도 쓸 수 있다 — 삭제와 축소 🟡 중급

지워도 메모리는 안 줄어듭니다

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);   // 확실한 방법 (옛 관용구)
왜 안 줄이나 — 줄였다가 다시 늘리면 또 복사이기 때문입니다. “지웠다 넣었다”를 반복하는 워크로드에서 축소하면 진동(thrashing)이 생깁니다. 그래서 대부분 명시적으로 요청할 때만 줄입니다.

중간 삭제를 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:]
C++ 의 erase-remove 관용구도 같은 아이디어입니다 — 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)시스템 콜 없이 통신
2의 거듭제곱 크기를 쓰면 % cap 을 & (cap-1) 로 바꿀 수 있습니다 — 나눗셈이 비트 연산이 되어 훨씬 빠릅니다. 고성능 큐 구현이 대부분 그렇게 합니다 → 🔢 이산수학 11.
DS 03

스택 · 큐 · 덱 — 꺼내는 순서만 다릅니다

구조는 단순하지만 어디에 쓰이는지가 넓습니다 — 함수 호출 · 되돌리기 · 괄호 검사 · 작업 대기열 · 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번째 ')' 가 짝이 안 맞음"
여기가 정규식이 못 하는 자리입니다(🧮 계산이론 03). 스택 하나만 있으면 되는데, 그 스택이 유한 상태 기계에는 없습니다.

덱 실전 — 슬라이딩 윈도우 최댓값

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)

왜 이런 걸 아나

함수형 언어에서 큐를 만드는 표준 방법이고, 불변 자료구조에서도 상환 O(1) 이 나옵니다. 면접 단골이기도 합니다.

실무에서는

그냥 deque 를 쓰세요. 이 구현의 가치는 “상환 분석이 무엇인지”를 가장 짧게 보여 준다는 데 있습니다.

실무에선 이렇게 — 큐가 시스템의 목이 되는 순간 🔴 실무

무한 큐는 장애를 지연시킬 뿐입니다

# ✗ 크기 제한 없는 큐
queue = Queue()                     # 무제한
# 생산이 소비보다 빠르면 → 메모리 증가 → OOM
# 그 전에 이미 지연이 폭발한다 (큐에서 대기하는 시간)

# ✓ 유한 큐 + 넘칠 때의 정책
queue = Queue(maxsize=1000)
try:
    queue.put(item, timeout=0.1)
except Full:
    metrics.inc("queue_rejected")   # ★ 거절하고 즉시 알린다
    raise ServiceBusy()             # 503 으로 응답
정책동작쓸 때
블로킹생산자를 멈춤배압을 위로 전달 (기본)
거절즉시 실패온라인 요청 처리
가장 오래된 것 버림최신 유지메트릭 · 실시간 위치
새 것 버림기존 유지순서가 중요한 이벤트
우선순위중요한 것 먼저혼합 워크로드
“큐를 늘리면 해결된다”는 거의 항상 틀립니다 — 소비 속도가 생산 속도보다 느리면 큐 크기와 무관하게 결국 찹니다. 큐는 순간적인 변동을 흡수할 뿐입니다. 대기 시간이 어떻게 폭발하는지는 ➗ 선형대수 · 확률 09에 있습니다.
DS 04

해시 테이블 — 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
현대 구현은 개방 주소법으로 기울었습니다 — 캐시 효율 때문입니다. abseil 의 Swiss Table, Rust 의 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)
공격 가능합니다 → 05 절

② 리해싱 순간

# 로드 팩터를 넘는 그 삽입 하나가
# 전체 재배치 → 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 가 필요하면
파이썬의 삽입 순서 보장은 부수효과에서 명세가 된 사례입니다 — 3.6 에서 메모리 최적화의 결과로 우연히 그렇게 됐고, 3.7 에서 공식 보장이 됐습니다. 다만 다른 언어로 옮길 때는 이 가정이 깨진다는 점을 기억하세요.
키를 주소로 바꿉니다 — 그래서 한 번에 찾습니다 키 "apple" 찾으려는 것 해시 함수 → 3 (칸 번호) 0 1 2 3 3번 칸을 바로 봅니다 — O(1) 충돌 — 다른 키가 같은 칸을 가리킬 때 ① 체이닝 — 그 칸에 이어 붙입니다 한 칸에 리스트(또는 트리)를 매답니다 한 칸에 몰리면 그만큼 느려집니다 — 최악 O(n) ② 개방 주소법 — 다음 빈 칸으로 캐시 친화적이라 실제로는 더 빠를 때가 많습니다 지우기가 까다롭습니다(표시만 남김) 칸이 70% 정도 차면 배열을 두 배로 늘리고 전부 다시 배치합니다 — 이 순간만 O(n) 이라 평균은 O(1) 이 됩니다(상환 분석, p10)
평균 O(1) 이지 항상 O(1) 은 아닙니다 — 해시 함수의 품질이 전제입니다.
키를 주소로 바꿉니다 — 그래서 한 번에 찾습니다 키 "apple" 찾으려는 것 해시 함수 → 3 (칸 번호) 0 1 2 3 3번 칸을 바로 봅니다 — O(1) 충돌 — 다른 키가 같은 칸을 가리킬 때 ① 체이닝 — 그 칸에 이어 붙입니다 한 칸에 리스트(또는 트리)를 매답니다 한 칸에 몰리면 그만큼 느려집니다 — 최악 O(n) ② 개방 주소법 — 다음 빈 칸으로 캐시 친화적이라 실제로는 더 빠를 때가 많습니다 지우기가 까다롭습니다(표시만 남김) 칸이 70% 정도 차면 배열을 두 배로 늘리고 전부 다시 배치합니다 — 이 순간만 O(n) 이라 평균은 O(1) 이 됩니다(상환 분석, p10)
평균 O(1) 이지 항상 O(1) 은 아닙니다 — 해시 함수의 품질이 전제입니다.
DS 05

해시 함수 설계 — 해시 충돌 공격까지

해시 함수를 잘못 고르면 공격자가 요청 하나로 서버를 멈출 수 있습니다. 용도별로 무엇을 써야 하는지, 왜 언어들이 랜덤 시드를 넣게 됐는지를 봅니다.

좋은 해시 함수의 조건

조건뜻
균등 분포모든 버킷에 고르게
눈사태 효과입력 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비슷하면 비슷한 해시
MD5 · SHA-1 은 무결성 용도로 쓰지 마세요 — 충돌을 만들 수 있음이 증명됐습니다. 단순 체크섬 용도로는 여전히 무해하지만, “같으면 같은 파일”이라는 판단에는 쓸 수 없습니다.

이렇게도 쓸 수 있다 — 해시 충돌 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()
실무 체크 — 요청 본문의 키 개수·중첩 깊이·전체 크기에 상한이 있는지 확인하세요. 프레임워크 기본값이 없거나 너무 큰 경우가 있습니다. 같은 이유로 JSON 파싱 깊이도 제한해야 합니다 → 🧮 계산이론 06.

실무에선 이렇게 — 일관 해싱 · 유사도 해싱 🔴 실무

일관 해싱 — 노드가 바뀌어도 덜 움직인다

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비트만 달라도 완전히 달라져야 하고(눈사태),
#   유사도 해시는 비슷한 입력이 비슷한 출력이어야 한다.
“해시”라는 한 단어가 정반대의 요구를 갖는 여러 도구를 가리킵니다. 문서를 읽을 때 어느 쪽 해시인지 구분하는 것이 중요합니다.
DS 06

이진 탐색 트리 — 정렬을 유지하는 대신

해시는 빠르지만 순서가 없습니다. “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)
접두사 범위불가가능
DB 인덱스가 해시가 아니라 트리인 이유가 이 표입니다. 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  ← 완전히 망가짐
이 문제 때문에 실무에서 “그냥 BST”는 거의 안 씁니다. 해결이 07 균형 트리이고, 디스크용 변형이 08 B-트리입니다. 또 다른 해법은 무작위화(트립 · 스킵 리스트)입니다.

실무에선 이렇게 — 삭제가 어려운 이유 🔴 실무

자식이 둘일 때의 처리

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 가 사용
불변 트리 (경로 복사)동시성 · 스냅숏이 공짜
“직접 구현하지 말 것”이 대부분의 답입니다 — 균형 트리의 삭제는 회전 케이스가 많아 버그가 잘 생깁니다. 다만 왜 그렇게 동작하는지는 인덱스 튜닝·성능 진단에 필요합니다.
DS 07

균형 트리 — 스스로 모양을 고치는 트리

삽입할 때마다 회전해서 높이를 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
탐색더 빠름보통
삽입·삭제회전 많음회전 적음
쓰는 곳읽기가 많을 때표준 라이브러리 다수
레드블랙이 더 널리 쓰이는 이유 — 삽입·삭제 시 회전 횟수가 상수로 제한됩니다(삽입 2회, 삭제 3회 이내). AVL 은 최악에 O(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

장점

구현이 훨씬 짧고, 락 없는 동시 접근이 상대적으로 쉽습니다. Redis 의 정렬 집합(ZSET) 이 스킵 리스트로 구현돼 있습니다.

단점

최악을 보장하지 않습니다(확률적). 포인터가 많아 메모리를 더 씁니다. 캐시 지역성도 트리보다 나쁩니다.

실무에선 이렇게 — 언제 트리를 고르나 🔴 실무

요구선택
키로 정확히 찾기만해시맵
범위 조회 · 정렬 순회균형 트리 (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 확장 트리보다 빠른 경우가 있습니다. 복잡도보다 상수와 캐시가 이기는 대표 사례입니다.
모양이 무너지면 O(log n) 이 O(n) 이 됩니다 ① 정렬된 값을 순서대로 넣으면 1 2 3 4 한 줄이 됩니다 탐색이 O(n) 으로 떨어집니다 ② 균형 트리 — 넣을 때마다 모양을 고칩니다 2 1 3 회전(rotation)으로 높이를 log n 으로 유지합니다 AVL · 레드블랙 트리가 이 방식입니다 어디에 쓰이나 C++ std::map · 자바 TreeMap 리눅스 스케줄러(CFS)의 vruntime 해시와 달리 순서가 유지됩니다 범위 질의가 필요하면 이쪽입니다
해시는 순서가 없고 트리는 순서가 있습니다 — 범위 질의가 갈림길입니다.
모양이 무너지면 O(log n) 이 O(n) 이 됩니다 ① 정렬된 값을 순서대로 넣으면 1 2 3 4 한 줄이 됩니다 — 연결 리스트와 같습니다 탐색이 O(n) 으로 떨어집니다 ② 균형 트리 — 넣을 때마다 모양을 고칩니다 2 1 3 회전(rotation)으로 높이를 log n 으로 유지합니다 AVL · 레드블랙 트리가 이 방식입니다 어디에 쓰이나 C++ std::map · 자바 TreeMap 리눅스 스케줄러(CFS)의 vruntime 해시와 달리 순서가 유지됩니다 범위 질의가 필요하면 이쪽입니다
해시는 순서가 없고 트리는 순서가 있습니다 — 범위 질의가 갈림길입니다.
DS 08

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억 건도 커버
루트와 상위 노드는 항상 메모리에 캐시돼 있습니다. 그래서 실제 디스크 접근은 리프 1번인 경우가 많습니다. “인덱스가 있으면 빠르다”의 실체입니다.

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 · 스노우플레이크
--  → 앞부분이 타임스탬프라 순차 삽입이 된다
실제 쿼리 튜닝은 🗄️ Database 가이드에 있습니다. 여기서 기억할 것은 “인덱스는 정렬된 트리다” 한 문장입니다 — 위 표의 모든 항목이 여기서 유도됩니다.

실무에선 이렇게 — LSM 트리 — 다른 선택 🔴 실무

쓰기가 압도적으로 많다면

B+트리LSM 트리
쓰기제자리 수정 (랜덤 I/O)순차 추가만
읽기빠름 (한 곳)여러 레벨 확인
공간 증폭낮음높음 (중복 저장)
쓰기 증폭높음낮음~중간
지연 스파이크적음컴팩션 시 발생
대표MySQL · PostgreSQL · OracleRocksDB · Cassandra · LevelDB
LSM 트리 동작

쓰기 → MemTable (메모리, 정렬됨)
         ↓ 가득 차면
       SSTable L0 (디스크, 불변, 순차 쓰기)
         ↓ 백그라운드 컴팩션
       SSTable L1, L2, ... (레벨이 내려갈수록 크고 오래됨)

읽기 → MemTable 확인 → L0 → L1 → ...
       각 레벨마다 블룸 필터로 '없음'을 빨리 판정 (🗂️ 12)

★ 삭제도 '삭제 표시(tombstone)'를 쓰는 것 — 나중에 컴팩션이 정리
컴팩션이 실무의 골칫거리입니다 — 백그라운드에서 도는데 디스크 I/O 와 CPU 를 크게 씁니다. 쓰기가 몰리는 시간에 겹치면 p99 가 크게 튑니다. 컴팩션 전략(레벨드 vs 사이즈 티어드) 선택이 운영의 핵심이 됩니다 → 🧮 계산이론 10.
B+트리 — 모든 DB 인덱스가 이 모양인 이유 루트 — 키 수백 개 내부 노드 내부 노드 내부 노드 잎 — 실제 데이터 잎 잎 잎 한 노드에 키를 많이 담아 높이를 낮춥니다 — 수억 행이어도 서너 번만 읽으면 닿습니다 디스크는 블록 단위로 읽으므로 "한 번 읽어서 얻는 키 수" 가 곧 성능입니다 데이터는 잎에만 있고 잎끼리 옆으로 이어져 있습니다 — 그래서 범위 조회와 정렬이 빠릅니다 이것이 B트리와의 차이입니다 — B트리는 중간 노드에도 데이터가 있어 범위 훑기가 불리합니다
이진 탐색 트리가 아니라 B+트리를 쓰는 이유는 알고리즘이 아니라 디스크의 성질 때문입니다.
DS 09

힙 · 우선순위 큐 — 가장 급한 하나를 빨리

전체를 정렬할 필요 없이 “지금 가장 작은/큰 것”만 반복해서 꺼낼 때 씁니다. 스케줄러 · 다익스트라 · 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) 이 된다.
  방향이 복잡도를 바꾼다.
실무 의미 — 데이터가 이미 배열에 있다면 하나씩 push 하지 말고 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 절)
공정성이 필요가중 라운드로빈 · 다중 큐
기아는 운영에서 실제로 발생합니다 — “VIP 요청 먼저”를 넣었더니 일반 요청이 몇 시간씩 대기하는 식입니다. 대기 시간을 우선순위에 섞는 것이 표준 해법이고, OS 스케줄러도 같은 문제를 같은 방법으로 풉니다 → ⚙️ 운영체제 03.
DS 10

트라이 — 글자를 따라 내려가는 트리

자동완성 · 금칙어 필터 · 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)
메모리작음큼 (노드마다 자식 맵)
메모리가 트라이의 약점입니다 — 파이썬 dict 노드는 하나에 64바이트 이상입니다. 단어 10만 개면 노드가 수십만 개가 되어 수십 MB 를 씁니다. 아래에 대안이 있습니다.

이렇게도 쓸 수 있다 — 메모리를 줄이는 변형들 🟡 중급

변형아이디어쓰는 곳
압축 트라이 (Radix/Patricia)자식이 하나뿐인 사슬을 하나로 합침IP 라우팅 테이블 · etcd
DAWG (DAFSA)같은 접미사를 공유 — 트라이를 그래프로사전 · 맞춤법 검사
더블 배열 트라이배열 두 개로 표현 — 캐시 친화형태소 분석기 (MeCab)
접미사 배열모든 접미사를 정렬해 저장전문 검색 · 압축
FSTDAWG + 값 저장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 서열 검색수만
키워드 광고 매칭수십만
“실패 링크”가 핵심 아이디어입니다 — 매칭이 실패했을 때 처음부터 다시 시작하지 않고 이미 읽은 부분 중 쓸 수 있는 가장 긴 접미사로 건너뜁니다. 단일 패턴에서 같은 아이디어를 쓰는 것이 KMP 입니다 → 🔀 알고리즘 11.
DS 11

유니온 파인드 — “같은 무리인가”를 거의 상수 시간에

친구 관계 · 네트워크 연결 · 중복 계정 병합 · 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)) ≈ 상수
α 는 역아커만 함수입니다 — 우주의 원자 수만큼 원소가 있어도 4 이하입니다. 사실상 상수라고 보면 됩니다. “거의 O(1)” 이라는 표현이 여기서 나옵니다.
경로 압축이 하는 일

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배 될 때만 옮겨진다)
“작은 쪽을 큰 쪽에”는 small-to-large 라 부르는 일반적인 기법입니다. 트리 병합 · 세그먼트 트리 병합 · 맵 합치기에서 반복해 나옵니다. 단순한 규칙 하나로 O(n²) 이 O(n log n) 이 됩니다.
DS 12

확률적 자료구조 — 정확도를 조금 팔아 메모리를 크게 삽니다

“정확히 몇 개” 대신 “대략 몇 개”로 요구를 낮추면 메모리가 수백~수만 배 줄어듭니다. 대규모 시스템에서 반드시 만나는 도구들입니다.

블룸 필터 — “없다”만 확실히

비트 배열 + 해시 함수 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차 필터정밀 검사 전 걸러내기
삭제할 수 없습니다 — 비트를 0으로 되돌리면 다른 항목까지 지워집니다. 삭제가 필요하면 카운팅 블룸 필터(비트 대신 카운터)나 커쿠 필터를 씁니다.

이렇게도 쓸 수 있다 — 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 바이트/항목위양성만
이거 본 적 있나? (삭제 필요)커쿠 필터비슷위양성만
고유 개수는?HyperLogLog12KB 고정~0.8%
이 항목이 몇 번?Count-Min수십 KB과대 추정
상위 K 개는?Space-SavingO(k)근사
중앙값 · p99 는?t-digest · DDSketch수 KB분위수 정확
두 집합이 얼마나 겹치나?MinHash서명 크기근사
# 실무 판단의 예: 일일 순 방문자 수(UV)
#
# 요구가 "정확한 숫자" 라면
#   → set 또는 DB DISTINCT — 메모리·비용이 크다
#
# 요구가 "대시보드에 보여 줄 대략의 추세" 라면
#   → HyperLogLog — 0.8% 오차, 메모리 12KB, 병합 가능
#
# ★ 요구사항을 먼저 확인하는 것이 이 절의 핵심이다.
#   "정확해야 한다" 는 대부분 검증되지 않은 가정이다.

# 두 가지를 함께 쓰는 것도 흔하다
#  · 실시간 대시보드 → HLL (빠르고 가볍게)
#  · 정산 · 청구      → 정확한 집계 (배치로 하루 한 번)
확률적 자료구조를 도입할 때 반드시 문서화할 것 — 오차 범위와 그 방향입니다. “최대 1% 과대 추정될 수 있음”처럼 적어 두지 않으면, 나중에 숫자가 안 맞는 이유를 아무도 모르게 됩니다.
DS 13

자료구조 결정표 — 상황을 넣으면 답이 나옵니다

하려는 일을 한글로 적으면 쓸 구조가 나옵니다. 고민을 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) · 분리는 불가
연결 관계 탐색 · 최단 경로인접 리스트희소하면 무조건 리스트
고유 개수를 대략 세기HyperLogLog12KB 로 수십억 · 오차 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
과거 상태를 유지영속 자료구조경로 복사 · 구조 공유

이렇게도 쓸 수 있다 — 고르기 전에 던지는 네 가지 질문 🟡 중급

  1. 어떤 질의가 가장 많은가 — 조회? 범위? 순회? 최댓값? 가장 잦은 연산이 O(1) 이나 O(log n) 이 되게 고릅니다.
  2. 얼마나 자주 바뀌는가 — 거의 안 바뀌면 정렬 배열이 대부분의 트리를 이깁니다(캐시 때문).
  3. 크기가 얼마나 되는가 — n < 100 이면 배열 선형 탐색이 가장 빠른 경우가 많습니다. 큰 n 에서만 복잡도가 의미를 갖습니다.
  4. 정확해야 하는가 — “대략”이 허용되면 메모리를 수십~수만 배 줄일 수 있습니다(12 절).
다섯 번째 질문이 사실 가장 중요합니다 — “표준 라이브러리에 이미 있는가”. 직접 구현한 자료구조는 거의 항상 더 느리고 버그가 많습니다. 구조를 아는 목적은 만들기 위해서가 아니라 고르기 위해서입니다.

실무에선 이렇게 — 이어지는 곳 🔴 실무

여기서 배운 것다음
01 캐시 지역성🖥️ 컴퓨터구조 08 캐시
04·05 해시🔐 보안 · 암호 02 암호학적 해시
08 B+트리 · LSM🗄️ Database 가이드 인덱스 튜닝
09 힙 · 우선순위🔀 알고리즘 05 다익스트라
11 유니온 파인드🔀 알고리즘 06 MST
12 확률적 구조🕸️ 분산 10 샤딩 · 일관 해싱
다음 탭 추천 — 5 🔀 알고리즘으로 가면 여기서 만든 자료구조들이 실제 문제를 푸는 데 어떻게 조합되는지 봅니다.
DS 14

구간 질의 구조 — 세그먼트 트리 · 펜윅 트리

“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)불가값이 안 바뀌고 최솟값 질의만 많을 때
세그먼트 트리 — 각 노드가 "자기 구간의 합"을 들고 있습니다 [0..7] 합 36 [0..3] 합 10 [4..7] 합 26 [0..1] 3 [2..3] 7 [4..5] 11 [6..7] 15 잎 = 원본 배열 1 2 3 4 5 6 7 8 질의 [2..5] 의 합 = 초록 노드 두 개만 더하면 됩니다 → 7 + 11 = 18. 여섯 칸을 훑지 않습니다. 수정도 같습니다 — 잎 하나를 고치면 그 위 조상들만 다시 계산합니다. 경로 길이는 트리 높이 = log n. "어떤 구간이든 O(log n)개 노드로 정확히 덮을 수 있다"는 것이 이 구조가 성립하는 이유입니다.
누적합이 수정에 약하고 원본 배열이 질의에 약한 문제를, 트리 높이만큼의 타협으로 동시에 해결한 구조입니다.

세그먼트 트리 — 반복문 구현(가장 짧은 형태)

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)
핵심은 i & (−i) — 가장 낮은 1 비트를 뽑는 연산 12 = 1100₂  →  12 & (−12) = 100₂ = 4
→ t[12] 는 4칸짜리 구간 [9..12] 의 합을 담당합니다
각 노드가 담당하는 길이 = 그 인덱스의 마지막 1비트 값
→ 어떤 접두사도 1비트씩 지워 가며 log n 개 조각으로 분해됩니다
비교세그먼트 트리펜윅(BIT)
메모리2n ~ 4nn
코드 길이~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번 연산 이하.
먼저 “정말 값이 바뀌는가”를 물어보세요. 값이 안 바뀌면 누적합(prefix sum) 한 줄로 끝나고 질의는 O(1) 입니다. 세그먼트 트리는 수정과 질의가 둘 다 잦을 때만 이깁니다. 실무에서는 Redis Sorted Set이나 DB 인덱스가 같은 일을 이미 해 주는 경우가 많으니, 직접 구현하기 전에 이미 있는 도구로 되는지 확인하는 편이 낫습니다.
이어지는 곳 — 트리의 기본 성질은 🔢 이산수학 08, 분할상환으로 왜 O(log n) 인지 증명하는 법은 🔀 알고리즘 14, DB 가 같은 문제를 B+트리로 푸는 방식은 이 탭 08 과 🗄️ Database 가이드에 있습니다.
DS 15

스킵 리스트와 지속 자료구조 — 균형을 “동전 던지기”와 “복사 안 하기”로

균형 트리(07절)는 확실하지만 회전 규칙이 복잡하고 동시성에 약합니다. 스킵 리스트는 그 규칙을 통째로 동전 던지기로 대신해 같은 O(log n) 을 얻습니다 — Redis 의 정렬 집합과 LevelDB 의 memtable 이 이것입니다. 지속(persistent) 자료구조는 수정할 때 통째로 복사하지 않고 바뀐 경로만 새로 만드는 방식이고, Git · React · 실행 취소 · MVCC 가 전부 여기에서 나옵니다.

두 주제를 한 절에 묶은 이유 — 둘 다 “정직하게 하면 비싼 일”을 다른 각도로 피하는 기법입니다. 한쪽은 균형 유지 비용을 확률로, 다른 한쪽은 복사 비용을 공유로 없앱니다.
정렬된 연결 리스트 위에 “건너뛰는 층”을 얹습니다 — 층은 동전 던지기로 정합니다 L3 L2 L1 L0 head 7 14 21 33 40 55 탐색 40 — 위층에서 크게 건너뛰다가 지나치면 한 층 내려갑니다. 평균 O(log n). 넣을 때 앞면이 나오는 동안 층을 올립니다 → 층이 절반씩 줄어 높이가 평균 log n. 회전 규칙이 없어 구현이 100줄 안쪽이고, 구간 스캔이 L0 순회 한 번으로 끝납니다. 최악은 O(n) 이지만 확률이 사실상 0 입니다 — 입력이 아니라 난수가 정하기 때문입니다. Redis 정렬 집합 · LevelDB memtable · 여러 락프리 인덱스가 이 구조입니다.
균형을 규칙으로 강제하지 않고 확률에 맡기는 발상 — 코드가 짧아지고 동시 수정이 쉬워집니다.

스킵 리스트 — 핵심만 그대로

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]
높이가 log n 인 이유 층 i 이상에 올라갈 확률 = (1/2)i−1
→ 층 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개 고정
캐시흩어짐흩어짐
“최악이 O(n)”은 실무에서 문제가 되지 않습니다. 해시 테이블의 최악과 성격이 다릅니다 — 해시는 공격자가 입력을 고르면 최악을 만들 수 있지만(05절), 스킵 리스트의 층은 입력이 아니라 내부 난수가 정합니다. 입력을 아무리 고약하게 넣어도 확률이 바뀌지 않습니다.
Redis 가 정렬 집합에 이 구조를 쓴 이유는 문서에 그대로 적혀 있습니다 — 구간 질의(ZRANGE)가 잦고, 구현이 단순해 디버깅과 확장이 쉽고, 메모리 사용을 층 확률(p)로 조절할 수 있기 때문입니다.

지속 자료구조 — 바꾸되 원본을 남기기

한 칸을 바꿀 때 전체를 복사하지 않습니다 — 뿌리부터 그 잎까지의 경로만 새로 만듭니다 버전 1 (원본) 버전 2 (C 를 C′ 로) root A B C D root′ A′ C′ B 그대로 공유 D 그대로 공유 새로 만든 노드는 3개(root′ · A′ · C′)뿐이고 나머지는 버전 1 과 공유합니다. 즉 수정 비용이 트리 높이에 비례하는 O(log n) 입니다 — 전체 복사 O(n) 이 아닙니다. 옛 root 를 들고 있으면 버전 1 이 온전히 남습니다 → 실행 취소·스냅숏이 사실상 공짜.
Git 의 커밋 · React 의 상태 · DB 의 MVCC · 실행 취소가 전부 이 “경로만 복사” 한 가지 아이디어입니다.

경로 복사를 직접 짜 보기

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 · Btrfscopy-on-write 스냅숏
Clojure기본 자료구조 전부가 지속형
실행 취소옛 뿌리를 스택에 쌓아 두면 끝
LSM 트리덮어쓰지 않고 새로 쌓은 뒤 병합
동시성에서 특히 강합니다. 옛 버전이 절대 변하지 않으므로 읽는 쪽은 락 없이 그냥 읽으면 됩니다. “읽기가 쓰기를 막지 않는다”는 MVCC 의 성질이 여기서 나옵니다 (🕸️ 분산 · 동시성 08 · 🗄️ Database 가이드).
공짜는 아닙니다. ① 노드가 흩어져 있어 캐시 지역성이 나쁩니다(01절) — 같은 O(log n) 이라도 배열보다 느립니다. ② 옛 버전을 누가 언제 놓아 주는지가 문제가 됩니다 — GC 가 없는 언어라면 참조 카운트나 에포크 기반 회수가 필요하고, DB 라면 그것이 VACUUM 입니다.
이어지는 곳 — 균형 트리와의 비교는 07절, 구간 질의는 14절, 확률로 비용을 낮추는 다른 예는 12절(블룸 필터), 기댓값 계산의 근거는 📐 선형대수 10, 락 없는 읽기는 🕸️ 분산 · 동시성 04 에서 이어집니다.
외우는 것이 아니라 고르는 법 · 코딩 테스트와 실무를 함께

문제를 보고
어떤 무기를 꺼낼지
30초 안에

알고리즘 공부의 목적은 직접 구현하기 위해서가 아닙니다 — 라이브러리에 이미 다 있습니다. 목적은 “이 문제가 어떤 문제인지 알아보는 것”입니다. 그래야 검색이 되고, 복잡도를 예측하고, 안 되는 것을 안 된다고 말할 수 있습니다. 각 절은 원리 → 코드 → 실무에서 만나는 자리 순으로 갑니다.

23SECTIONS
패턴인식 중심
실무연결
면접대비
ALGO 01

정렬 — 왜 아직도 배우나

sort() 한 줄이면 끝나는데도 배우는 이유는, 거의 모든 알고리즘이 정렬 위에 서 있고 정렬 알고리즘의 아이디어가 다른 곳에서 계속 재사용되기 때문입니다.

정렬이 보장하는 것은 두 가지입니다 — 값의 순서, 그리고 같은 값끼리의 원래 순서 원본 (점수, 이름) 3 a 1 b 3 c 2 d 1 e b 가 e 보다 앞 안정 정렬 같은 값의 순서 유지 1 b 1 e 2 d 3 a 3 c b → e 그대로 ✓ 불안정 정렬 뒤바뀔 수 있음 1 e 1 b 2 d 3 c 3 a e ↔ b 뒤집힘 ⚠ 다중 키 정렬이 이 성질 위에 섭니다 — “이름순으로 정렬한 뒤 점수순으로” 하면 동점자가 이름순이 됩니다. 비교만으로 정렬하면 아무리 잘해도 Ω(n log n) 입니다 — n! 가지를 예/아니오로 갈라야 하기 때문입니다. log₂(n!) ≈ n log₂ n − 1.44n — 결정 트리의 높이가 곧 최악의 비교 횟수입니다. 그 하한을 피하려면 비교를 버려야 합니다 — 계수·기수 정렬이 O(n) 인 이유입니다.
“정렬됐다”는 말에는 안정성이 포함되어 있지 않습니다 — 언어마다 다르니 문서를 확인해야 합니다.

기본 세 가지 — 아이디어만

# 선택 정렬 — 가장 작은 것을 찾아 앞으로
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²) · 실무 가치 없음 (교육용)
삽입 정렬만은 실무에서 쓰입니다 — 원소가 16개 이하일 때는 퀵 정렬보다 빠릅니다. 그래서 실제 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 log n) 보다 빠를 수 없습니다 — n! 가지 순서를 구분하려면 log₂(n!) ≈ 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)
#  · 대용량 로그를 타임스탬프로 (버킷 정렬)
범위가 넓으면 오히려 느립니다 — 값이 0~10억이면 배열 10억 칸이 필요합니다. k(값 범위) 가 n(개수) 과 비슷하거나 작을 때만 의미가 있습니다.

실무에선 이렇게 — 정렬이 필요 없는 경우 🔴 실무

하려는 일정렬 대신복잡도
최댓값 · 최솟값max() · min()O(n)
상위 K개힙 · heapq.nlargestO(n log k)
중앙값 · k번째퀵셀렉트O(n) 평균
“정렬돼 있나” 확인한 번 훑기O(n)
중복 제거setO(n)
그룹핑defaultdictO(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)
가장 흔한 실무 낭비 — 상위 10개를 얻으려고 100만 건을 통째로 정렬하는 것입니다. heapq.nlargest(10, data) 로 바꾸면 수십 배 빨라집니다.
ALGO 02

퀵 · 머지 · 힙 정렬 — 실무 sort() 의 정체

실제 표준 라이브러리의 sort() 는 한 알고리즘이 아닙니다. 상황에 따라 세 가지를 섞어 씁니다. 그 이유를 알면 정렬이 느릴 때 원인을 짚을 수 있습니다.

퀵 정렬의 핵심은 한 동작뿐입니다 — 피벗보다 작은 것을 앞으로 모으기 처리 전 3 7 8 5 2 1 9 4 ← 피벗 처리 후 3 2 1 4 7 8 9 5 피벗보다 작은 것 제자리 확정 피벗보다 큰 것 — 각각 재귀 원소 16개 이하 → 삽입 정렬 (상수가 작음) 기본은 퀵 정렬 피벗은 3중앙값으로 깊이가 2 log n 초과 → 힙 정렬로 전환 (최악 방어) 최악 O(n²)은 피벗을 계속 나쁘게 고를 때 나옵니다 — 이미 정렬된 입력에서 첫 원소를 고르면 그렇습니다. 그래서 표준 sort() 는 하나가 아니라 셋을 섞습니다 — introsort(C++) · Timsort(파이썬 · 자바).
파티션이 끝나면 피벗의 자리는 영원히 확정입니다 — 이 한 문장이 퀵 정렬이 제자리에서 도는 이유입니다.

퀵 정렬

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 이 섞이면 규칙이 깨집니다 — NaN < x, NaN > x, NaN == x 가 전부 거짓이라 추이성이 성립하지 않습니다. 정렬 전에 걸러 내세요 → ➗ 선형대수 12.

실무에선 이렇게 — 메모리보다 큰 데이터 정렬 🔴 실무

외부 정렬 — 100GB 로그를 8GB 램에서

  1. 쪼개서 정렬 — 메모리에 들어가는 크기(예: 1GB)씩 읽어 메모리에서 정렬한 뒤 임시 파일로 씁니다.
  2. k-way 병합 — 정렬된 임시 파일들을 힙으로 병합하며 한 줄씩 최종 파일에 씁니다.
  3. 병합 차수 조절 — 파일이 너무 많으면 여러 단계로 나눕니다 (파일 핸들·버퍼 한계).
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 를 먼저 시도하세요.
ALGO 03

이진 탐색 — 가장 많이 틀리는 알고리즘

아이디어는 단순한데 경계 조건에서 거의 모두가 틀립니다. 1946년에 발표된 알고리즘의 첫 정확한 구현은 1962년에 나왔습니다. 틀리지 않는 형태를 익혀 둘 가치가 있습니다.

9 를 찾습니다 — 매번 후보의 절반을 통째로 버립니다 1 0 3 1 3 2 3 3 5 4 7 5 9 6 11 7 ① mid=3 3 < 9 → 오른쪽 ② mid=5 7 < 9 → 오른쪽 ③ mid=6 ✓ 찾음 중복이 있을 때 lower_bound = 1 upper_bound = 4 종료 조건과 갱신 규칙은 짝입니다 — lo < hi 면 hi = mid, lo ≤ hi 면 hi = mid − 1. mid = (lo+hi)//2 는 파이썬에선 안전하지만 C·자바에선 오버플로입니다 — lo + (hi−lo)//2. 값이 아니라 “경계”를 찾는 형태로 외워 두면 중복 원소에서 틀리지 않습니다.
이진 탐색을 “값 찾기”가 아니라 “조건이 처음 참이 되는 자리 찾기”로 바꿔 두면 파라메트릭 서치까지 같은 코드가 됩니다.

틀리지 않는 형태

# ★ 반열린 구간 [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;
}
테스트로 반드시 확인할 것 — 빈 배열 · 원소 1개 · 모두 같은 값 · 찾는 값이 가장 작거나 가장 큰 경우 · 없는 값. 다섯 개면 대부분의 버그가 잡힙니다.

이렇게도 쓸 수 있다 — 답을 이진 탐색하기 (파라메트릭 서치) 🟡 중급

“최소값을 구하라”를 “가능한가?”로 바꾸기

최적화 문제 → 판정 문제로 바꾸고, 그 답을 이진 탐색합니다. 조건: 판정 결과가 단조여야 합니다(어느 지점부터 계속 참).
# 문제: 케이블 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로 압축하면 목표 용량 이하인가?
실무에서 가장 유용한 알고리즘 패턴 중 하나입니다 — “최적값을 계산”하기 어려울 때 “이 값이면 되나?”를 반복해서 좁히면 대개 20~30번 안에 답이 나옵니다.

실무에선 이렇게 — 이진 탐색이 아닌 것 🔴 실무

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+트리 노드 안 탐색노드 내 키
버전 호환성 찾기버전 번호
부하 한계 찾기동시 접속 수
“절반씩 줄인다”는 사고방식 자체가 알고리즘보다 중요합니다. 장애 원인을 좁힐 때도 같습니다 — 가능성을 절반씩 배제하면 1000가지 원인도 10번이면 좁혀집니다.
ALGO 04

그래프 순회 — BFS 와 DFS의 차이가 답을 바꿉니다

같은 그래프를 가까운 것부터 볼 것인가(BFS), 한 방향으로 끝까지 파고들 것인가(DFS). 이 선택이 “최단 경로”와 “모든 경로”를 가릅니다.

같은 그래프 · 같은 시작점인데 방문 순서가 다릅니다 BFS — 큐 · 가까운 것부터 거리 0 거리 1 거리 2 거리 3 A 1 B 2 C 3 D 4 E 5 F 6 G 7 방문 순서 A B C D E F G 층을 하나씩 — 이 순서가 곧 거리 순 DFS — 스택 · 한 방향 끝까지 A 1 B 2 C 6 D 3 E 4 F 7 G 5 방문 순서 A B D E G C F 막히면 되돌아와서 다음 갈래로 가중치가 모두 같다면 BFS 의 방문 순서가 곧 최단 거리입니다. DFS 에는 그 보장이 없습니다. DFS 의 재귀 깊이는 곧 스택 깊이입니다 — 파이썬 기본 한도가 1000 이라 큰 그래프는 반복문으로 바꿉니다.
둘의 코드 차이는 큐냐 스택이냐 한 줄뿐입니다 — 그런데 그 한 줄이 “최단 경로”와 “모든 경로”를 가릅니다.

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스택 오버플로 회피
메모리가 빠듯한 넓은 그래프DFSBFS 큐가 층 전체를 담음
가중치가 있으면 BFS 로는 최단 경로가 안 나옵니다 — “적은 간선 수”와 “적은 비용”이 다르기 때문입니다. 그때는 다익스트라(05 절)를 씁니다. 단, 가중치가 0 과 1 뿐이면 0-1 BFS(덱 사용)로 O(V+E) 에 가능합니다.

이렇게도 쓸 수 있다 — 실무 문제로 옮기기 🟡 중급

격자에서의 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 이벤트 버블링노드 · 부모 (경로)
방문 표시를 언제 하느냐가 실무 버그의 단골입니다 — BFS 에서 큐에 넣을 때 표시하지 않고 꺼낼 때 표시하면 같은 정점이 큐에 여러 번 들어가 메모리가 폭발합니다.

실무에선 이렇게 — 큰 그래프에서의 주의점 🔴 실무

문제대응
재귀 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배)
“케빈 베이컨의 6단계” 같은 문제에 특히 효과적입니다 — 소셜 그래프는 분기 계수가 크고 거리가 짧아 양방향 BFS 의 이득이 극대화됩니다.
ALGO 05

최단 경로 — 다익스트라 · 벨만포드 · A*

가중치가 붙는 순간 BFS 로는 안 됩니다. 비용이 다른 길 중 가장 싼 것을 찾는 세 가지 방법과 각각이 필요한 조건을 봅니다.

규칙은 하나 — 아직 확정 안 된 것 중 가장 가까운 것을 확정하고, 그 이웃을 갱신합니다 4 1 2 5 3 1 A B C D E A 에서 출발 · 숫자는 간선 비용 단계 A B C D E 초기 0 ∞ ∞ ∞ ∞ A 확정 0 4 1 ∞ ∞ C 확정 0 3 1 6 ∞ B 확정 0 3 1 6 6 최종 0 3 1 6 6 C 를 거치니 B 가 4 → 3 으로 줄었습니다 (완화) “가장 가까운 것을 확정한다”가 성립하려면 길이 길어질수록 비용이 늘어야 합니다 — 간선이 음수가 아니어야 합니다. 음수 간선이 있으면 확정한 뒤에 더 짧은 길이 나타납니다. 그때는 벨만포드(V−1 회 전체 완화)를 씁니다. 우선순위 큐를 쓰면 O((V+E) log V), 매번 최솟값을 훑으면 O(V²) — 간선이 빽빽하면 후자가 낫습니다. 이미 확정한 노드가 큐에서 또 나오면 그냥 버립니다 — “방문 배열” 대신 이 한 줄이 정석입니다.
최단 경로 알고리즘의 차이는 “무엇을 언제 확정해도 되는가”에 대한 가정 차이입니다 — 가정이 깨지면 알고리즘도 깨집니다.

다익스트라 — 가장 많이 쓰는 것

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]

세 가지 비교

복잡도음수쓸 때
BFSO(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-퍼즐)제자리 아닌 타일 수 · 맨해튼 합
경로 계획 (로봇)유클리드 거리
단어 변환다른 글자 수
실무 지도 서비스는 A* 를 그대로 쓰지 않습니다 — 전 세계 도로망은 너무 커서 계층적 전처리(Contraction Hierarchies)로 미리 지름길을 만들어 둡니다. 그래도 A* 가 그 기반입니다.

실무에선 이렇게 — 최단 경로가 아닌 문제들 🔴 실무

현실 문제그래프로 옮기면
가장 싼 환승 조합정점=역, 간선=구간 요금
가장 신뢰도 높은 경로가중치 = −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
다익스트라의 뼈대는 그대로 두고 “결합 연산”만 바꾸면 전혀 다른 문제가 풀립니다 — 합/최소 → 곱/최대, min/max 등. 이런 일반화를 대수적 경로 문제라고 부릅니다.
ALGO 06

MST · 위상 정렬 — 연결하기와 순서 정하기

모두를 가장 싸게 연결하는 문제(MST)와 의존성을 지키며 순서를 정하는 문제(위상 정렬). 둘 다 실무에서 자주 나오고 구현이 짧습니다.

모두를 가장 싸게 잇기(MST)와 의존성을 지켜 순서 정하기(위상 정렬) 크루스칼 — 싼 간선부터, 사이클만 피해서 4 1 2 5 3 6 A B C D E 1 A-C ✓ 2 B-D ✓ 3 B-E ✓ 4 A-B ✓ 5 C-D ✗ 6 D-E ✗ 총 비용 10 · 간선 4개 = 정점 5개 − 1 에서 종료 위상 정렬 — 진입 차수 0 부터 1 0 2 1 3 1 4 2 5 1 노드 옆 숫자는 진입 차수 — 0 인 것만 꺼낼 수 있습니다 꺼낼 때마다 이웃의 차수를 1 씩 줄입니다 → 1 2 3 4 5 MST 는 “사이클만 안 만들면 싼 것부터”가 최적입니다 — 탐욕이 실제로 통하는 드문 경우입니다(07절). 위상 정렬이 끝났는데 남은 노드가 있으면 그것이 사이클입니다 — 순환 의존성 탐지가 공짜로 따라옵니다. 크루스칼 O(E log E) — 정렬이 지배. 프림 O(E log V). 간선이 적으면 크루스칼, 빽빽하면 프림입니다.
두 알고리즘 모두 “지금 꺼내도 되는 것”의 조건만 정확히 정의하면 나머지는 반복문입니다.

위상 정렬 — 진입 차수 방식 (칸 알고리즘)

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 는 동시에 실행 가능
# 총 소요 시간 = 층 수 (임계 경로 길이)
CI/CD 파이프라인 · 빌드 시스템이 하는 일이 정확히 이것입니다. Make · Bazel · GitHub Actions 의 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 자르기
이미지 분할픽셀 · 인접 · 색 차이
회로 배선 최소화단자 · 배선 · 길이
MST 로 클러스터링을 하는 방법 — MST 를 만든 뒤 가장 비싼 간선 k−1 개를 자르면 k 개의 클러스터가 됩니다. 계층적 군집화(single linkage)와 같은 결과입니다.

실무에선 이렇게 — 순환 의존성을 다루는 법 🔴 실무

사이클을 찾았다면 그다음

# 강한 연결 요소(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 → 순환 그룹 → 리팩터링 대상
순환 의존을 푸는 방법설명
의존성 역전공통 인터페이스를 만들어 양쪽이 그것에 의존
공통 부분 추출순환의 원인이 되는 코드를 별도 모듈로
이벤트로 전환직접 호출 대신 이벤트 발행 (느슨한 결합)
지연 참조런타임에 해결 (타입만 순환일 때)
합치기정말 뗄 수 없으면 하나의 모듈로 인정
순환 의존은 설계 신호입니다 — 도구가 잡아 주는 것은 증상이고, 원인은 대개 경계가 잘못 그어진 것입니다 → 🏛️ 소프트웨어 공학 06 · 07.
ALGO 07

탐욕 알고리즘 — 언제 통하고 언제 안 통하나

“지금 제일 좋아 보이는 것”만 고르는 방법입니다. 통할 때는 가장 단순하고 빠르며, 안 통할 때는 조용히 틀립니다. 그 구분이 이 절의 전부입니다.

탐욕은 통할 때는 가장 단순하고, 안 통할 때는 조용히 틀립니다 통하는 예 — 회의실 배정 A B C D F 0 3 6 9 12 끝나는 시간 순으로 A · D · F — 최대 3개 ✓ 안 통하는 예 — 거스름돈 동전 {1, 3, 4} 로 6 만들기 탐욕 · 큰 것부터 4 1 1 동전 3개 ⚠ 최적 3 3 동전 2개 ✓ 우리 돈에서는 탐욕이 맞습니다 — 동전 체계가 특수해서입니다 통하는 조건은 둘 — ① 부분 문제의 최적이 전체 최적의 일부이고 ② 지금 고른 것이 나중을 막지 않을 것. 증명이 안 되면 DP 로 가세요(09절). 탐욕은 틀려도 그럴듯한 답이 나오기 때문에 가장 위험합니다.
탐욕을 쓸 때 실제로 해야 할 일은 코딩이 아니라 “왜 이 선택이 나중을 막지 않는가”를 한 문장으로 적는 것입니다.

통하는 예 — 회의실 배정

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절)
“한국 돈으로 되니까 맞다”가 함정입니다 — 탐욕법의 정확성은 데이터의 성질에 달려 있습니다. 입력 형태가 바뀌면 조용히 틀린 답을 냅니다.

이렇게도 쓸 수 있다 — 탐욕이 통하는 조건 🟡 중급

두 가지 성질

성질뜻
탐욕 선택 속성지금의 최선 선택이 최적해에 포함됨
최적 부분 구조부분 문제의 최적이 전체 최적을 이룸
실무에서 확인하는 법 — 반례를 30분 찾아보고 못 찾으면 소규모 완전 탐색과 결과를 비교합니다. 증명보다 이 방법이 빠르고 확실합니다.

탐욕으로 푸는 대표 문제

구간 스케줄링끝나는 시간 순
허프만 코딩빈도 낮은 것부터 병합
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)
대역폭 배분공정 분배 후 잉여 재분배
first-fit decreasing 은 빈 채우기에서 최적의 11/9 배 이내임이 증명돼 있습니다. “단순한 탐욕이 근사 보장을 갖는” 좋은 예이고, 쿠버네티스 스케줄러 같은 실제 시스템이 이 계열의 방법을 씁니다 → 🧮 계산이론 12.
ALGO 08

분할 정복 — 반으로 쪼개서 푸는 법

큰 문제를 작은 같은 문제로 쪼개고 · 각각 풀고 · 합칩니다. 병합 정렬 · 퀵 정렬 · FFT · 거듭제곱이 전부 이 형태이고, 병렬화가 자연스럽다는 실무적 이점이 있습니다.

반으로 쪼개고 · 각각 풀고 · 합칩니다 — 층마다 드는 비용이 같다는 것이 핵심입니다 8 4 4 2 2 2 2 1 1 1 1 1 1 1 1 어느 층에서든 총 작업량은 n — 층이 log n 개라 n log n 실제 비용은 쪼개기가 아니라 합치기(merge)에 있습니다 마스터 정리 — 세 경우뿐 T(n) = a · T(n/b) + f(n) ① 쪼개는 쪽이 무겁다 → 잎이 지배 · Θ(n^log_b a) ② 균형 — 층마다 같은 비용 → Θ(n^log_b a · log n) ③ 합치는 쪽이 무겁다 → 뿌리가 지배 · Θ(f(n)) 머지 정렬 a=2 · b=2 · f(n)=n → ② → n log n 재귀식만 세우면 복잡도는 세 경우 중 하나로 떨어집니다 — 외울 것은 “층마다 얼마인가” 하나입니다. 형제 부분 문제는 서로 독립이라 병렬화가 공짜로 따라옵니다 — 다른 코어에 그대로 던질 수 있습니다. 단, 쪼개는 비용이 계산보다 크면 손해입니다 — 작은 구간에서 삽입 정렬로 갈아타는 이유입니다(02절).
재귀 트리를 그려서 “층 수 × 층당 비용”만 세면 복잡도가 나옵니다 — 마스터 정리는 그 계산의 요약본입니다.

기본 형태

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)+1O(log n)
병합 정렬2T(n/2)+nO(n log n)
퀵 정렬 (평균)2T(n/2)+nO(n log n)
거듭제곱T(n/2)+1O(log n)
카라츠바 곱셈3T(n/2)+nO(n1.58)
스트라센 행렬곱7T(n/2)+n²O(n2.81)
FFT2T(n/2)+nO(n log n)
최근접 점 쌍2T(n/2)+nO(n log n)
카라츠바가 보여 주는 것 — 큰 수 곱셈을 4번이 아니라 3번의 재귀로 줄이면 지수가 2에서 1.58 로 떨어집니다. “부분 문제 수를 줄이는 것”이 분할 정복 최적화의 핵심입니다.

이렇게도 쓸 수 있다 — 병렬화가 공짜로 따라온다 🟡 중급

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 등)
맵리듀스가 분할 정복입니다 — map 이 분할, reduce 가 병합입니다. 결합 법칙이 성립하는 연산(합·최대·개수)만 이렇게 나눌 수 있다는 제약도 여기서 나옵니다.

실무에선 이렇게 — 분할 정복이 오히려 손해일 때 🔴 실무

상황문제대응
부분 문제가 겹침같은 계산 반복 (피보나치)동적 계획법 (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) 복사입니다. 재귀 안에서 쓰면 복잡도가 로그 계수만큼 더 붙고 메모리도 크게 늡니다. 교육용 코드가 실무에서 느린 대표적 이유입니다.
ALGO 09

동적 계획법 — 같은 계산을 두 번 하지 않기

이름은 거창하지만 내용은 “결과를 적어 두고 재사용”이 전부입니다. 어렵게 느껴지는 것은 상태를 어떻게 정의할지가 문제마다 다르기 때문입니다. 그 정의법을 익히는 것이 목표입니다.

DP 는 “결과를 적어 두고 재사용”이 전부입니다 — 어려운 것은 상태를 정하는 일입니다 그냥 재귀 — 같은 것을 또 셉니다 f(5) f(4) f(3) f(3) f(2) f(2) f(1) f(3) 은 두 번 · f(2) 는 세 번 — 겹치는 부분 문제 한 번만 세고 적어 두면 O(2ⁿ) 이 O(n) 이 됩니다 0/1 배낭 — 한 칸을 채우는 규칙 이 물건을 담을까 말까 · 둘 중 큰 쪽 i−1 행 w−무게 w i 행 ? 안 담기 담기 +가치 dp[i][w] = max( dp[i−1][w] , dp[i−1][w−무게] + 가치 ) DP 가 통하는 조건은 둘 — 겹치는 부분 문제가 있고, 부분 문제의 최적이 전체 최적의 일부일 것. 어려운 것은 점화식이 아니라 상태 정의입니다 — “무엇을 알면 나머지가 결정되나”를 먼저 문장으로 적으세요. 탑다운(재귀 + 메모)으로 먼저 짜고, 필요하면 바텀업(반복문)으로 옮기는 편이 실수가 적습니다. 직전 한 줄만 필요하면 배열을 굴려 메모리를 줄입니다 — 배낭에서 무게 루프를 거꾸로 도는 이유입니다.
DP 문제를 푸는 순서는 늘 같습니다 — 상태 정의 → 점화식 → 초기값 → 계산 순서. 막히는 곳은 거의 항상 첫 칸입니다.

두 가지 방식

# ① 메모이제이션 (하향식 — 재귀 + 캐시)
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)
문제실무에서의 모습
LCSgit 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엣지에 있는 표
브라우저 캐시클라이언트의 표
캐시의 어려움은 저장이 아니라 무효화입니다 — DP 는 계산이 끝나면 표가 변하지 않지만, 실무 캐시는 원본이 바뀝니다. 그래서 TTL · 이벤트 기반 무효화 · 버전 키 같은 전략이 필요합니다 → 🏛️ SW 공학 12.
ALGO 10

백트래킹 — 다 해 보되 가망 없으면 즉시 되돌기

완전 탐색에 가지치기를 더한 것입니다. 그 가지치기 하나로 실행 불가능한 문제가 몇 초가 되기도 합니다.

완전 탐색에 “여기서 이미 틀렸다”를 더한 것이 백트래킹입니다 4×4 퀸 네 개 — 정답 하나 ♛ 0 ♛ 1 ♛ 2 ♛ 3 0 1 2 3 행마다 하나씩 · 겹치면 그 자리에서 되돌기 탐색 트리 — 잘려 나가는 부분 행 0 1열 ✗ 2열 ✓ 3열 ✓ 4열 ✗ 1열 ✗ 3열 ✗ 4열 ✓ 이미 공격받는 자리는 놓아 보지도 않습니다 그 아래 가지가 통째로 사라집니다 붉은 가지 = 검사 한 번으로 지워진 부분 가지치기는 “언제 검사하느냐”가 전부입니다 — 다 놓은 뒤가 아니라 한 칸 놓는 순간 검사해야 합니다. N=8 이면 전수는 8⁸ = 16,777,216 가지지만, 가지치기하면 배치 시도가 2,057 번으로 끝납니다. 상태를 되돌리는 코드(놓기 → 재귀 → 치우기)를 짝으로 쓰는 습관이 버그를 가장 많이 줄입니다.
백트래킹의 성능은 알고리즘이 아니라 가지치기 조건의 품질이 정합니다 — 조건 한 줄이 몇 시간을 몇 초로 바꿉니다.

기본 뼈대

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)))
규모를 먼저 계산하세요 — 순열은 n!, 부분집합은 2ⁿ 입니다. n=12 면 순열이 4.8억, n=25 면 부분집합이 3300만입니다. n 이 15를 넘으면 완전 탐색은 대개 불가능합니다 → 🔢 이산수학 13의 규모 감각 표.

실무에선 이렇게 — 백트래킹이 쓰이는 실제 시스템 🔴 실무

시스템무엇을 백트래킹하나
정규식 엔진매칭 실패 시 다른 분기 시도 (ReDoS 의 원인)
SAT 솔버변수 할당 (DPLL + 충돌 학습)
패키지 의존성 해결버전 조합 (pubgrub · pip 백트래커)
타입 추론타입 변수 후보
파서 (PEG)규칙 대안 시도
제약 해결 (CSP)변수 값 배정
쿼리 플래너조인 순서 (제한된 탐색)
pip 의 의존성 해결이 오래 걸리는 이유

  requests 2.31 을 고름
    → urllib3 <3 필요
      → 다른 패키지가 urllib3 >=2.1 요구
        → 충돌! 되돌아가서 requests 다른 버전 시도
          → ... (조합 폭발)

대응
 · 버전을 고정해 탐색 공간을 줄인다 (lock 파일)
 · 제약을 느슨하게 (상한을 함부로 걸지 않기)
 · 최신 리졸버 사용 (충돌 원인을 학습해 가지치기)
“왜 이렇게 느리지”의 원인이 백트래킹인 경우가 많습니다 — 정규식 · 의존성 해결 · 쿼리 플래너. 탐색 공간을 좁히는 정보(고정 · 힌트 · 인덱스)를 주는 것이 공통적인 해결책입니다.
ALGO 11

문자열 알고리즘 — 이미 본 것을 다시 안 보기

긴 텍스트에서 패턴을 찾을 때 매번 처음부터 다시 비교하지 않는 방법들입니다. 실무에서 직접 구현할 일은 드물지만, 왜 빠른지를 알면 검색 엔진과 diff 도구가 이해됩니다.

불일치가 나도 이미 맞춘 부분은 다시 보지 않습니다 — 얼마나 건너뛸지를 미리 계산해 둡니다 텍스트 A 0 B 1 A 2 B 3 A 4 B 5 C 6 패턴 · 시작 0 A B A B C ✗ 불일치 KMP · 시작 2 A B A B C ✓ 일치 한 칸이 아니라 두 칸을 건너뜁니다 — 이미 맞춘 ABAB 안에 AB 가 접두사이자 접미사이기 때문입니다. 실패 함수 A 0 B 0 A 1 B 2 C 0 ← 접두사이자 접미사인 최대 길이 텍스트를 읽는 위치가 절대 뒤로 가지 않습니다 — 그래서 전체가 O(n + m) 입니다. 라빈-카프는 반대 방향입니다 — 해시로 먼저 거르고 값이 같을 때만 실제로 비교합니다. 실무에서 직접 짤 일은 드물지만, 이 실패 함수는 아호-코라식과 Z 알고리즘의 출발점입니다.
문자열 알고리즘의 아이디어는 하나로 모입니다 — “이미 읽은 것에서 알아낸 사실을 버리지 않기”.

단순 방법의 문제

텍스트: 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

# 여러 패턴을 동시에 찾을 때 특히 유리
# (패턴들의 해시를 집합에 넣고 한 번만 스캔)
롤링 해시의 진짜 쓸모는 검색보다 중복 블록 탐지입니다 — rsync · git · 중복 제거 백업이 내용 기반으로 청크를 나눌 때 이 기법을 씁니다.

이렇게도 쓸 수 있다 — 실무 문자열 처리 🟡 중급

하려는 일도구비고
부분 문자열 찾기내장 in · find이미 최적화됨 (보이어-무어 변형)
여러 패턴 동시에아호코라식🗂️ 자료구조 10
유사 문자열편집 거리 · rapidfuzzDP (09절)
자동완성트라이 · FST접두사 검색
전문 검색역인덱스 (Lucene)토큰화 + 색인
중복 문서 탐지SimHash · MinHash근사
diffMyers 알고리즘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 라이브러리 필요
문자열 길이 제한을 코드포인트로 하면 사고가 납니다 — “최대 10자”를 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유일한 줄을 기준점으로 → 읽기 좋음
histogrampatience 개선판 · git 권장
minimal가장 작은 diff (느림)
diff 가 이상하게 나올 때 git diff --histogram 을 시도해 보세요. 함수 경계를 더 잘 잡아 리뷰하기 좋은 diff 가 나오는 경우가 많습니다. diff.algorithm=histogram 으로 기본값을 바꿀 수 있습니다.
ALGO 12

랜덤화 · 근사 — 정확도를 팔아 속도를 삽니다

주사위를 굴리면 최악의 입력이 사라집니다. 그리고 “대략의 답”으로 요구를 낮추면 불가능하던 규모가 가능해집니다.

주사위를 굴리면 “최악의 입력”이 사라집니다 — 입력이 아니라 난수가 성능을 정하기 때문입니다 고정 피벗 + 정렬된 입력 한 칸씩만 줄어듭니다 → 깊이 n → O(n²) 무작위 피벗 기대 깊이 log n → 어떤 입력이든 O(n log n) 랜덤 알고리즘 두 종류 라스베가스 — 답은 항상 정확 시간이 확률적 · 랜덤 퀵 정렬 몬테카를로 — 시간은 확정 답이 확률적 · 밀러-라빈 소수 판정 근사 알고리즘 정점 커버 2-근사 — 최적의 2배 이내 보장 정확한 답이 NP-난해일 때의 현실적 타협 “얼마나 틀릴 수 있나”가 증명된 것만 근사입니다 랜덤을 쓰는 진짜 이유는 평균이 좋아서가 아니라, 최악을 만드는 입력을 공격자가 고를 수 없게 하기 위해서입니다.
해시 테이블이 시드를 무작위로 잡는 이유도 같습니다 — 최악을 확률의 문제로 바꿔 버리는 것입니다.

두 가지 랜덤 알고리즘

종류답시간예
라스베이거스항상 정확확률적랜덤 퀵 정렬
몬테카를로확률적확정적밀러-라빈 소수 판정
# 랜덤 피벗 — 최악의 입력을 없앤다
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 배정편향 없는 실험
“두 개 뽑아 덜 바쁜 쪽”(power of two choices)이 놀랍습니다 — 완전 랜덤 배정보다 최대 부하가 지수적으로 줄어듭니다. 로드밸런서가 전체를 보지 않고도 좋은 분산을 얻는 방법입니다.

실무에선 이렇게 — 난수의 함정 🔴 실무

용도쓸 것쓰면 안 되는 것
알고리즘 · 시뮬레이션random · np.random—
토큰 · 비밀번호 · 세션 IDsecrets · crypto.randomBytesrandom · Math.random()
재현이 필요한 실험시드 고정 default_rng(42)전역 시드 (다른 코드가 바꿈)
분산 환경의 고유 IDUUIDv7 · 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) 는 전역이라 다른 라이브러리와 충돌한다
“랜덤이면 안전하다”는 틀립니다 — 일반 난수 생성기는 속도와 통계적 품질이 목적이지 예측 불가능성이 목적이 아닙니다. 보안이 걸린 자리에서는 반드시 CSPRNG 를 쓰세요 → 🔐 보안 · 암호 11.
ALGO 13

문제 접근법 — 제약 조건이 답을 알려 줍니다

코딩 테스트든 실무든 접근은 같습니다 — 제약에서 필요한 복잡도를 역산하고, 그 복잡도로 가능한 기법 목록을 좁힙니다.

제약이 곧 힌트입니다 — n 을 보면 써야 할 기법이 서너 개로 줄어듭니다 제약 n 허용 복잡도 그 복잡도로 가능한 기법 n ≤ 10 O(n!) · O(2ⁿ·n) 순열 완전 탐색 · 외판원 DP · 백트래킹 n ≤ 20 O(2ⁿ) 비트마스크 DP · 부분집합 열거 · 중간에서 만나기 n ≤ 100 O(n³) 플로이드-워셜 · 행렬 곱 · 삼중 반복 DP n ≤ 1,000 O(n²) 이중 반복 DP · LCS · 완전 그래프 순회 n ≤ 10만 O(n log n) 정렬 · 이분 탐색 · 다익스트라 · 세그먼트 트리 n ≤ 100만 O(n) 투 포인터 · 누적합 · 해시 · 스택 활용 n ≥ 1000만 O(log n) · O(1) 수식 정리 · 이분 탐색 · 분할정복 거듭제곱 기준은 1초에 약 1억 번입니다 — 파이썬은 그 1/10(약 1천만)으로 잡고 시작하세요. 복잡도를 먼저 정하고 기법을 고르는 순서입니다. 코딩은 그 다음입니다.
문제를 읽고 가장 먼저 볼 줄은 제한 사항입니다 — 알고리즘을 고르는 일이 거기서 절반 끝납니다.

제약 → 복잡도 → 기법

n 의 크기필요한 복잡도후보 기법
n ≤ 11O(n!)순열 완전 탐색 · 백트래킹
n ≤ 25O(2ⁿ)부분집합 · 비트마스크 DP · 중간에서 만나기
n ≤ 100O(n⁴)4중 반복 · 복잡한 DP
n ≤ 500O(n³)플로이드-워셜 · 구간 DP
n ≤ 5,000O(n²)2중 반복 · LCS · 2차원 DP
n ≤ 100,000O(n log n)정렬 · 이진 탐색 · 힙 · 세그먼트 트리
n ≤ 1,000,000O(n)투 포인터 · 슬라이딩 윈도우 · 누적 합
n ≥ 10⁸O(log n) · O(1)수식 · 이진 탐색 · 행렬 거듭제곱
이 표 하나가 절반입니다 — “n ≤ 100,000” 을 보는 순간 O(n²) 은 탈락이고, 정렬·이진탐색·힙·투포인터 중 하나라는 것이 이미 좁혀집니다.

문제 유형 인식 신호

문제에 이런 말이 나오면의심할 기법
“최소/최대 개수”DP · 탐욕 · 이진 탐색
“가능한 모든 경우”백트래킹 · DFS
“최단 거리 · 최소 이동”BFS · 다익스트라
“연속된 구간”슬라이딩 윈도우 · 투 포인터
“구간 합 · 구간 최댓값”누적 합 · 세그먼트 트리
“정렬된 배열에서”이진 탐색 · 투 포인터
“k번째”힙 · 퀵셀렉트 · 이진 탐색
“겹치지 않게 최대”탐욕 (끝나는 시간 순)
“그룹 · 연결 · 병합”유니온 파인드 · BFS
“순서를 지키며”위상 정렬
“이전 값에 의존”DP
“최대값을 최소화”파라메트릭 서치

막혔을 때의 순서

  1. 작은 예제를 손으로 — n=3, 4 를 종이에 풀어 봅니다. 패턴이 보입니다.
  2. 완전 탐색부터 — 느려도 맞는 답을 먼저 만들고, 그것을 기준으로 최적화합니다.
  3. 무엇이 중복인지 — 같은 계산이 반복되면 DP · 메모이제이션.
  4. 정렬해 보기 — 정렬만으로 문제가 단순해지는 경우가 아주 많습니다.
  5. 반대로 생각하기 — “최대 몇 개 넣을까” → “최소 몇 개 뺄까”.
  6. 제약을 다시 읽기 — 놓친 조건이 답의 열쇠인 경우가 많습니다.

이렇게도 쓸 수 있다 — 자주 쓰는 패턴 세 가지 🟡 중급

# ① 투 포인터 — 정렬된 배열에서 두 수의 합
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]
이 세 가지가 O(n²) → O(n) 변환의 대부분을 차지합니다. “중첩 반복문이 보이면 이 셋 중 하나로 될까”를 먼저 물어보세요.

실무에선 이렇게 — 면접과 실무의 차이 🔴 실무

코딩 테스트실무
목표최적 복잡도충분히 빠르고 읽기 쉬움
입력깨끗하고 보장됨더럽고 예외투성이
구현직접라이브러리 우선
검증제출 결과테스트 · 모니터링
중요한 것정답유지보수 · 관측 가능성
병목알고리즘대개 I/O · 네트워크 · DB
실무에서 성능 문제를 만났을 때의 순서

① 측정한다        — 추측 금지. 프로파일러 · APM
② 병목을 찾는다   — 대개 DB 쿼리 · N+1 · 네트워크 왕복
③ 알고리즘 확인   — 여기서 O(n²) 이 발견되면 큰 승리
④ 자료구조 교체   — list → set 하나로 끝나는 경우가 많다
⑤ 캐시 · 배치     — 반복 계산 · 왕복 줄이기
⑥ 상수 최적화     — 마지막. 효과 대비 노력이 가장 나쁘다

★ 실무 성능 문제의 대부분은 ② 와 ④ 에서 끝난다.
  알고리즘 지식은 ③ 을 '알아보기 위해' 필요하다.
이 탭에서 이어지는 곳 — 성능 측정과 병목 판별은 🖥️ 컴퓨터구조 13 · ⚙️ 운영체제 13, DB 쪽 병목은 🗄️ Database 가이드, 설계 관점의 정리는 🏛️ 소프트웨어 공학 탭에 있습니다. 다음 탭은 6 🖥️ 컴퓨터구조를 추천합니다 — “같은 복잡도인데 왜 느린가”의 답이 거기 있습니다.
ALGO 14

분할상환 분석 — 가끔 비싼데 평균은 싼 이유

파이썬 list.append() 는 가끔 배열 전체를 복사합니다. 그런데도 “append 는 O(1)”이라고 말합니다. 거짓말이 아니라 비용을 나눠 계산하는 방법이 따로 있기 때문입니다. 학부 알고리즘에서 가장 실용적인 도구인데 가장 자주 건너뛰는 주제입니다.

분할상환 분석(amortized analysis) — 연산 하나의 최악이 아니라, 연산 n 개를 연달아 했을 때의 총비용을 n 으로 나눈 값을 봅니다. 평균적 경우(average-case)와 다릅니다 — 확률을 전혀 쓰지 않고, 어떤 입력이 와도 성립하는 최악의 보장입니다.
list.append() 를 16번 했을 때 실제 비용 — 가끔 튀지만 총합은 선형입니다 비용 2 3 5 9 1 → 16 번째 추가 붉은 막대 = 용량이 차서 전체 복사 1, 2, 4, 8, 16… 두 배씩 벌어지며 점점 드물게 일어납니다 총비용 = n(그냥 넣기) + (1+2+4+…+n) < n + 2n = 3n → 연산 하나당 평균 3 이하 = 분할상환 O(1) 핵심은 등비급수입니다. 1+2+4+…+n 은 n 개를 더한 것이 아니라 2n 보다 작습니다(마지막 항의 2배 미만). 그래서 복사가 아무리 비싸도, 두 배씩 늘리는 한 총비용은 선형을 넘지 않습니다. 만약 1.1배씩 늘리면? 여전히 O(1)입니다 — 상수만 커집니다. 고정폭(+10)으로 늘리면 O(n)이 되어 무너집니다. "가끔 튀는 것"이 문제인 실시간 시스템에서는 이 평균이 위로가 안 됩니다 — 아래 warn 을 보세요.
최악 O(n) 연산이 섞여 있어도 전체가 O(n)일 수 있습니다 — 이것을 정확히 말하는 도구가 분할상환 분석입니다.

세 가지 계산법 — 같은 답을 다른 길로

① 집계법(aggregate) — 총합을 세어 나눈다

총비용 T(n) = n + Σ 2k  (2k ≤ n) = n + (1 + 2 + 4 + ⋯ + 2⌊log₂ n⌋)
= 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
회계법의 요령 — “싼 연산에 웃돈을 붙여 비싼 연산을 미리 사 두는” 것입니다. 연산 종류가 여러 개일 때(예: push 와 pop 이 섞인 스택) 집계법보다 다루기 쉽습니다. 잔고가 항상 0 이상임을 보이면 증명이 끝납니다.

③ 포텐셜법(potential) — 상태에 에너지를 매긴다

Φ(D) : 자료구조 상태 → 실수  (퍼텐셜 함수) 분할상환 비용  ĉi = ci + Φ(Di) − Φ(Di−1)
총합  Σĉ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 에 수렴
k 번째 비트는 2k 번에 한 번 뒤집힙니다 총 뒤집힘 = Σk≥0 ⌊n/2k⌋ < n · Σk≥0 (1/2)k = 2n
∴ 증가 연산 하나당 평균 < 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);
분할상환 O(1) 이 “항상 빠르다”는 뜻은 아닙니다. 게임 루프·실시간 오디오·자율주행처럼 한 프레임의 최악이 중요한 곳에서는 “평균은 1인데 가끔 100ms” 가 곧 프레임 드랍입니다.
이런 곳에서는 ① 미리 크게 할당해 재할당 자체를 없애거나, ② 점진적 재구성(한 번에 다 옮기지 않고 몇 개씩 나눠 이사)을 씁니다 — 실시간 GC 가 쓰는 것과 같은 발상입니다.
세 방법 중 무엇을 쓸까 — 연산이 한 종류면 집계법, 여러 종류가 섞이면 회계법, 구조의 “빡빡함”이 비용을 좌우하면 포텐셜법입니다. 시험에서 “분할상환 비용을 보여라”가 나오면 퍼텐셜 함수를 하나 잡고 ĉ = c + ΔΦ 를 계산하는 것이 가장 실패가 적습니다.
ALGO 15

네트워크 플로우 — 최대 유량 = 최소 컷

“파이프망에 물을 최대 얼마나 흘릴 수 있나”를 푸는 알고리즘입니다. 그런데 이 문제로 작업 배정 · 매칭 · 이미지 분할 · 프로젝트 선택이 전부 풀립니다. 학부 알고리즘의 마지막 산이자, 문제를 다른 문제로 바꿔 푸는 사고를 배우는 자리입니다.

유량 네트워크 — 방향 그래프 G=(V,E), 각 간선에 용량 c(u,v) ≥ 0, 출발점 s(source)와 도착점 t(sink). 유량 f 는 두 조건을 지켜야 합니다 — ① 용량 제약 f(u,v) ≤ c(u,v)   ② 유량 보존 s·t 를 뺀 모든 정점에서 들어온 양 = 나간 양.
최대 유량 = 최소 컷 — 같은 값이 나오는 것이 정리의 내용입니다 s a b c d t 10 10 4 6 9 10 10 컷 — 잘린 간선 4+6+9 = 19 최대 유량 = 19 컷은 정점을 s쪽·t쪽 두 덩어리로 나누는 모든 방법입니다. 그중 잘린 용량 합이 가장 작은 것이 최소 컷입니다. 직관: 물은 가장 좁은 목을 넘어설 수 없고, 좁은 목만큼은 반드시 흘릴 수 있습니다.
최대유량–최소컷 정리는 “병목이 곧 처리량”을 수학적으로 못 박은 것입니다 — 시스템 설계의 직관과 정확히 같습니다.

포드–풀커슨 — 증가 경로를 계속 찾는다

잔여 용량  cf(u,v) = c(u,v) − f(u,v) 역방향에도 cf(v,u) = f(u,v) 만큼의 용량이 생깁니다 — “보낸 것을 되돌릴 수 있다”는 뜻이고, 이것이 알고리즘의 핵심입니다.
잔여 그래프에 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 — 전원 배정 가능

# ★ 헝가리안 알고리즘을 몰라도 최대 매칭을 풀 수 있습니다.
#   "이 문제는 사실 유량 문제다" 를 알아보는 것이 실력입니다.
학부에서 이 단원의 진짜 목표는 알고리즘 암기가 아닙니다. “내 문제를 이미 풀린 문제로 바꾸는 것(환원, reduction)”이라는 사고를 익히는 것입니다. 이 사고는 그대로 NP-완비성 증명(🧮 계산이론 탭)으로 이어집니다 — 거기서는 반대로 “이 문제가 어렵다”를 보이려고 환원을 씁니다.

최소 컷을 실제로 구하기 · 정리 증명 스케치

최대 유량에서 최소 컷 뽑아내기

# 최대 유량을 구한 뒤, 잔여 그래프에서 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]
이것이 “병목을 지목하는” 알고리즘입니다. 용량을 늘려야 할 곳이 어디인지 정확히 그 간선들이 답입니다. 네트워크 설계·공급망·전력망에서 그대로 쓰입니다.

최대유량–최소컷 정리 (증명 뼈대)

다음 세 명제는 동치입니다 ① f 가 최대 유량이다
② 잔여 그래프에 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)'과 정확히 같습니다.
  최대화 문제와 최소화 문제가 짝을 이루고, 최적값이 일치합니다.
포드–풀커슨은 용량이 무리수면 종료하지 않을 수 있습니다. 정수 용량이면 매 단계 유량이 최소 1 증가하므로 f* 번 안에 끝나지만, 경로를 나쁘게 고르면 용량 크기에 비례해 느려집니다 (용량 10⁶ 짜리 그래프에서 10⁶ 번 반복하는 고전적 반례가 있습니다). BFS 로 최단 증가 경로를 고르는 것(에드몬즈–카프)만으로 용량과 무관한 O(VE²) 가 보장됩니다 — 실무에서는 이쪽을 쓰세요.
이어지는 곳 — 그래프 기초는 🔢 이산수학 07, 탐색은 이 탭 04, “이 문제가 어렵다”를 증명하는 환원은 🧮 계산이론 탭, 실제 네트워크의 병목·혼잡 제어는 🌐 네트워크 탭에서 이어집니다.
ALGO 16

강한 연결 요소와 2-SAT — 방향 그래프를 DAG 로 접기

방향 그래프에서 서로 오갈 수 있는 정점들의 덩어리가 강한 연결 요소(SCC)입니다. 각 덩어리를 점 하나로 접으면 남는 것은 반드시 사이클 없는 그래프(DAG)가 되고, 그때부터 위상 정렬·DP 가 전부 가능해집니다. 2-SAT 은 이 성질을 그대로 쓰는 대표 응용으로, “이런 조건들을 동시에 만족시킬 수 있는가”를 선형 시간에 판정합니다.

이 절부터가 플래티넘 구간입니다. 04절의 DFS 를 알고 있다면 새로 배울 것은 방문 순서에 번호를 매기고 “내가 돌아갈 수 있는 가장 이른 번호”를 함께 들고 다니는 한 가지뿐입니다. 그 값 하나로 SCC · 단절점 · 단절선이 전부 나옵니다.
서로 오갈 수 있으면 한 덩어리 — 덩어리로 접으면 반드시 사이클이 사라집니다 원래 방향 그래프 A B C D E F A·B·C 는 서로 오감 · D·E 도 서로 오감 · F 는 혼자 덩어리로 접은 그래프 (반드시 DAG) {A,B,C} {D,E} {F} 사이클이 사라졌으니 위상 정렬이 됩니다 그 위에서 DP · 최장 경로 · 도달성 계산이 전부 가능 진입 차수 0 인 덩어리가 “아무도 못 오는 곳”입니다 타잔은 DFS 방문 번호 disc[v] 와 “되돌아갈 수 있는 가장 이른 번호” low[v] 를 함께 들고 다닙니다. low[v] == disc[v] 인 순간, 스택에서 v 까지 꺼낸 것이 하나의 SCC 입니다 — DFS 한 번이면 끝입니다. 같은 low 값 하나로 단절점·단절선(브리지)도 나옵니다 — 조건만 살짝 다릅니다.
SCC 를 구하는 진짜 이유는 “사이클 때문에 못 쓰던 도구를 다시 쓰기 위해서”입니다 — 접는 순간 DAG 알고리즘이 전부 열립니다.

타잔 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
파이썬에서 SCC 를 재귀로 짜면 거의 반드시 터집니다. 정점이 10만 개면 DFS 깊이도 10만이 될 수 있는데 기본 재귀 한도는 1000 입니다. 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 값이 헷갈림쉬움
SCC 번호를 그대로 위상 순서로 쓸 수 있다는 점이 실전에서 큽니다. 타잔은 역순으로 나오므로 nc - 1 - comp[v] 를 쓰거나 DAG 를 만든 뒤 위상 정렬을 다시 하면 됩니다.

2-SAT — “동시에 만족시킬 수 있는가”를 선형 시간에

절 하나를 화살표 두 개로 — 그러면 논리 문제가 그래프 문제가 됩니다 절 (x ∨ y) 가 뜻하는 것 x 가 거짓이면 → y 는 반드시 참 y 가 거짓이면 → x 는 반드시 참 함의 그래프 ¬x y ¬y x 변수 하나가 정점 두 개(참·거짓) 해가 없는 경우 x 와 ¬x 가 같은 SCC 안에 있음 = 서로가 서로를 강제 → 모순 SCC 로 판정 해가 있다면 만들기까지 쉽습니다 — SCC 번호가 더 늦은 쪽(위상 뒤쪽)을 참으로 두면 됩니다. 변수 n 개 · 절 m 개면 정점 2n · 간선 2m 이라 전체가 O(n + m) 입니다. 3-SAT 은 NP-완전인데 2-SAT 은 선형입니다 — 절의 길이 하나가 난이도를 가릅니다(🧮 계산이론 11).
“논리 제약을 만족하는 배치가 있나”라는 질문이 SCC 한 번으로 끝납니다 — 좌석 배정 · 스위치 설정 · 일정 충돌이 전부 이 형태입니다.

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)
연습 문제 (백준) — 2150 Strongly Connected Component(P5) 로 구현을 확인하고, 4196 도미노(P4) 로 “진입 차수 0 세기”, 2152 여행 계획 세우기(P3) 로 축약 DAG 위 DP, 11281 2-SAT − 3(P4) 로 해 복원까지 해 보면 이 절의 도구가 한 벌로 손에 붙습니다.
“조건이 전부 둘 중 하나 형태인가”를 먼저 확인하세요. 셋 이상의 선택이 섞이면 2-SAT 이 아니라 3-SAT 이고, 그 순간 다항 시간 해법이 사라집니다. 그때는 백트래킹(10절)이나 근사(12절)로 방향을 바꿉니다.
이어지는 곳 — DFS 의 기본은 04절, 위상 정렬은 06절, 난이도의 경계(2-SAT 과 3-SAT)는 🧮 계산이론 11·12, 그래프 표현과 메모리는 🔢 이산수학 07 에 있습니다.
ALGO 17

트리 알고리즘 — 최소 공통 조상과 트리 DP

트리는 사이클이 없어서 “두 정점 사이의 길이 정확히 하나”입니다. 그 성질 덕분에 두 가지가 쉬워집니다 — 최소 공통 조상(LCA)으로 임의의 두 정점 사이 질의를 O(log n) 에 답하는 것, 그리고 트리 DP 로 “자식의 답을 모아 부모의 답을 만드는” 것. 플래티넘 구간 트리 문제의 대부분이 이 둘의 조합입니다.

핵심은 “미리 계산해 두기”입니다. 조상을 한 칸씩 올라가면 최악 O(n) 이지만, 2k 칸 위 조상을 미리 저장해 두면 어떤 거리든 이진수 자릿수만큼의 점프로 도달합니다 — 이것이 희소 배열입니다.
두 정점 사이의 길이 하나뿐이라 “갈라지는 지점”이 곧 답입니다 1 2 3 4 5 6 7 8 9 답 질의 LCA(7, 5) — 답은 2 희소 배열 — 2ᵏ 칸 위 조상을 미리 저장 up[0][7] = 4 1칸 위 up[1][7] = 2 2칸 위 up[2][7] = 1 4칸 위 (없으면 루트) up[k][v] = up[k−1][ up[k−1][v] ] 두 번 뛰면 두 배 — 표는 이 한 줄로 채웁니다 전처리 O(n log n) · 질의 O(log n) 거리(u,v) = dep[u] + dep[v] − 2·dep[LCA] ① 더 깊은 7 을 5 와 같은 깊이까지 끌어올려 4 로 만듭니다 — 깊이 차를 이진수로 쪼개 뜁니다. ② 4 와 5 를 함께 올리되 “조상이 같아지기 직전”까지만 — 그 부모가 답입니다. 정점 10만 · 질의 10만이면 전체 O((n+q) log n) — 깊이를 재는 DFS 도 반복문으로 짜야 안전합니다.
“한 칸씩”을 “2ᵏ 칸씩”으로 바꾼 것뿐인데 O(n) 이 O(log n) 이 됩니다 — 이진수로 쪼개 뛰는 이 발상은 거듭제곱·행렬 거듭제곱에서도 그대로 재사용됩니다.

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 하기차분 배열 후 트리에서 누적
서브트리 질의오일러 투어로 구간으로 변환
오일러 투어는 트리를 배열로 펴는 기법입니다. DFS 로 들어간 시각 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 — 자식의 답을 모아 부모의 답을 만들기

인접한 둘은 못 고를 때 최대 몇 개? — 정점마다 값 두 개만 들고 올라갑니다 1 0:3 1:4 2 0:2 1:1 3 0:1 1:1 4 0:0 1:1 5 0:0 1:1 6 0:0 1:1 0 = 이 정점을 안 고른 경우 · 1 = 고른 경우 — 잎에서 루트 방향으로 채웁니다 점화식은 두 줄뿐 안 고름 dp[v][0] = Σ max(dp[c][0], dp[c][1]) 고름 dp[v][1] = 1 + Σ dp[c][0] 답 = max(dp[1][0], dp[1][1]) = 4 고른 것 = {1, 4, 5, 6} — 루트와 손자들 계산 순서는 후위 순회 (자식 먼저) 트리 DP 의 상태는 대개 “이 정점을 골랐나”라는 한 비트입니다 — 문제마다 그 비트의 뜻만 바뀝니다. 자식을 다 모은 뒤 부모를 계산하므로 재귀 깊이가 트리 높이만큼 나옵니다. 반복문 후위 순회를 권합니다.
트리 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 (아래 → 위 두 번)
연습 문제 (백준) — 11438 LCA 2(P5) 로 희소 배열을 확인하고, 1761 정점들의 거리(P4) 로 거리 질의를, 2533 사회망 서비스(G3) 와 2213 트리의 독립집합(G1) 로 기본 트리 DP 를, 1949 우수 마을(P2) 로 가중치가 붙은 형태를 연습하면 좋습니다.
“모든 정점을 루트로 삼았을 때의 답”을 묻는 문제가 자주 나옵니다. 전부 다시 계산하면 O(n2) 이지만, 재루팅(rerooting) DP 를 쓰면 O(n) 입니다 — 아래에서 위로 한 번, 위에서 아래로 한 번 훑으면서 “부모 쪽 답”을 내려보내는 방식입니다.
이어지는 곳 — DFS 는 04절, DP 의 기본은 09절, 구간 질의로 바꾸는 도구는 📚 자료구조 14, 트리의 수학적 성질은 🔢 이산수학 08 에 있습니다.
ALGO 18

DP 심화 — 비트마스크 · LIS · 최적화

09절이 “상태를 정하고 표를 채운다”였다면, 여기서는 그 표가 너무 커질 때의 이야기입니다. 상태가 집합이면 정수 하나에 담고(비트마스크), 전이가 단조로우면 이분 탐색이나 볼록성으로 줄입니다. 플래티넘 구간 DP 문제는 “상태를 어떻게 압축했는가”가 난이도의 전부입니다.

제약이 n ≤ 20 이면 거의 항상 비트마스크입니다. 220 = 약 100만이라 모든 부분집합을 순회해도 되기 때문입니다. 13절의 사다리표에서 “n ≤ 20 → O(2n)” 칸이 바로 이 절입니다.
집합을 정수 하나로 — 이 압축 하나로 “모든 부분집합”을 배열 인덱스처럼 다룹니다 방문 집합을 비트로 1 도시 3 0 도시 2 1 도시 1 1 도시 0 mask = 1011₂ = 11 → 0 · 1 · 3 번 도시를 이미 방문 i 를 방문했나 → mask & (1 << i) i 를 방문 처리 → mask | (1 << i) 전부 방문했나 → mask == (1 << n) − 1 외판원 문제(TSP)의 상태 dp[mask][last] = mask 의 도시를 다 방문했고 지금 last 에 있을 때의 최소 비용 전이 — 아직 안 간 nxt 로 가기 dp[mask|1<<nxt][nxt] ← dp + cost 상태 2ⁿ × n · 전이 n → O(2ⁿ · n²) n = 16 → 약 1,677만 · 가능 n = 20 → 약 4억 · 파이썬으로는 무리 제약에 n ≤ 20 이 보이면 비트마스크를 먼저 의심하세요 — 13절의 사다리표가 그대로 적용됩니다.
부분집합을 정수 인덱스로 바꾸는 순간, 집합에 대한 DP 가 평범한 배열 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. 플래티넘 문제에서 이 한 줄로 두 시간을 잃는 일이 흔합니다.
연습 문제 (백준) — 2098 외판원 순회(P5)가 표준형이고, 1102 발전소(P4) 로 “켜진 집합” 상태를, 1562 계단 수(P4) 로 “쓴 숫자 집합”을 상태에 넣는 형태를 연습하면 상태 정의 감각이 빨리 붙습니다.

LIS 를 O(n log n) 으로 — “덮어쓰기”의 정체

tails[k] = “길이 k+1 짜리 증가 수열의 마지막 값 중 가장 작은 것” 입력 10 9 2 5 3 7 101 18 2 · 5 까지 2 5 마지막보다 크니 뒤에 붙임 — 길이 2 3 을 보고 2 3 5 를 3 이 덮어씀 — 길이는 그대로 2 끝까지 2 3 7 18 길이 4 = LIS 길이 ✓ 덮어쓰면 길이는 안 변하지만 더 작은 값으로 끝나므로, 앞으로 이어 붙일 기회가 늘어납니다. 각 원소마다 이분 탐색 한 번 → O(n log n). 03절의 lower_bound 가 그대로 쓰입니다. 주의 — tails 자체는 실제 부분 수열이 아닙니다. 수열을 복원하려면 각 원소의 위치를 따로 기록해야 합니다.
“길이를 늘리지 못하면 끝 값을 낮춰 둔다” — 이 한 문장이 O(n2) 을 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 로
이 표는 “외워서 쓰는” 것이 아니라 “있다는 걸 아는” 용도입니다. 점화식을 세운 뒤 O(n2) 이 안 될 때, 전이 식의 모양(직선인가 · 최적 지점이 단조인가)을 보고 위 표에서 후보를 고르는 순서로 씁니다. 플래티넘 상위(P2~P1)부터 실제로 요구됩니다.
연습 문제 (백준) — 12015 가장 긴 증가하는 부분 수열 2(G2)로 형태를 익히고, 14003 가장 긴 증가하는 부분 수열 5(P4)로 복원까지, 13261 탈옥(P2)으로 분할 정복 최적화, 4008 특공대(P2)로 컨벡스 헐 트릭을 만나 볼 수 있습니다.
이어지는 곳 — DP 의 기본은 09절, 이분 탐색은 03절, 제약에서 복잡도를 역산하는 표는 13절, 비트 연산 자체는 🔢 이산수학 11 에 있습니다.
ALGO 19

계산 기하 — 부호 하나로 푸는 문제들

기하 문제가 어려워 보이는 이유는 삼각함수와 실수 오차 때문인데, 실제 코딩 테스트에서 필요한 것은 대부분 외적의 부호 하나입니다. 세 점이 왼쪽으로 도는지 오른쪽으로 도는지만 알면 선분 교차 · 볼록 껍질 · 다각형 넓이 · 점의 내부 판정이 전부 따라 나옵니다. 게다가 정수 좌표라면 오차가 아예 없습니다.

실수를 쓰지 않는 것이 이 절의 첫 번째 규칙입니다. 좌표가 정수로 주어지면 외적도 정수이고, 파이썬 정수는 자릿수 제한이 없어 오차가 0 입니다. float 로 바꾸는 순간 📐 선형대수 12 의 문제가 그대로 따라옵니다.
세 점이 어느 쪽으로 도는가 — 이 부호 하나가 기하 문제의 절반입니다 반시계 · CCW > 0 A(0,0) B(4,0) C(2,3) = +12 시계 · CCW < 0 A(0,0) B(4,0) C(2,−3) = −12 일직선 · CCW = 0 A(0,0) B(2,1) C(4,2) = 0 CCW(A,B,C) = (Bx−Ax)(Cy−Ay) − (By−Ay)(Cx−Ax) — 두 벡터의 외적입니다. 화면 좌표는 y 가 아래로 향하므로 눈에 보이는 회전은 수식의 부호와 반대입니다. 정수 좌표면 오차가 0 입니다 — float 로 바꾸는 순간 일직선 판정이 무너집니다.
기하 문제를 만나면 먼저 물어보세요 — “이거 CCW 부호로 되나?” 놀랄 만큼 자주 됩니다.

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)))
기하 문제의 오답은 대부분 ②에서 나옵니다. “X 자로 만나는” 경우만 생각하고 일직선 위에서 겹치는 경우를 빠뜨리는 것이 정석 실수입니다. 끝점이 닿는 것을 교차로 볼지도 문제마다 다르니 지문을 다시 읽으세요.

볼록 껍질 — 모노톤 체인

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절)
연습 문제 (백준) — 11758 CCW(G5) 로 부호를 확인하고, 17387 선분 교차 3(P5) 로 겹침 처리까지, 1708 볼록 껍질(P5) 로 모노톤 체인을, 9240 로버트 후드(P4) 로 회전하는 캘리퍼스를 만납니다. 2166 다각형의 면적(G5) 은 신발끈 공식 확인용입니다.
<= 0 이냐 < 0 이냐가 위 코드의 유일한 선택지입니다. <= 0 이면 일직선 위의 점을 버리고, < 0 이면 남깁니다. “껍질 위의 점 개수를 세라”는 문제에서 이 한 글자가 정답을 가릅니다.
이어지는 곳 — 실수 오차의 정체는 📐 선형대수 12·13, 정렬은 01·02절, CHT 최적화는 18절, 좌표를 벡터로 보는 관점은 📐 선형대수 01 에 있습니다.
ALGO 20

문자열 심화 — Z 배열 · 아호-코라식 · 접미사 배열

11절의 KMP 가 패턴 하나를 찾는 도구였다면, 여기서는 그 위의 세 가지입니다. Z 배열은 KMP 와 같은 정보를 더 직관적인 형태로 담고, 아호-코라식은 패턴 수천 개를 한 번의 스캔으로 찾으며, 접미사 배열은 “모든 부분 문자열”에 대한 질문을 정렬 한 번으로 바꿉니다. 셋 다 백준 플래티넘 구간에서 실제로 요구되는 도구입니다.

공통 아이디어는 11절과 같습니다 — “이미 읽어서 알아낸 것을 버리지 않기”. Z 배열은 이미 확인한 구간을 재활용하고, 아호-코라식은 실패했을 때 어디로 돌아갈지를 미리 계산해 둡니다.
Z[i] = i 에서 시작하는 접미사가 맨 앞 접두사와 몇 글자까지 같은가 s a a b c a a b x a a z Z — 1 0 0 3 1 0 0 2 1 0 i 0 1 2 3 4 5 6 7 8 9 10 접두사 aab 여기도 aab → Z[4] = 3 이미 확인해 둔 구간을 재활용하기 때문에 전체가 O(n) 입니다 — KMP 실패 함수와 정보량이 같습니다. 패턴 + 구분자 + 텍스트로 이어 붙여 Z 를 구하면, Z 값이 패턴 길이인 자리가 곧 등장 위치입니다. 구분자는 두 문자열 어디에도 없는 글자여야 합니다 — 보통 '#' 이나 제어문자를 씁니다.
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
접미사 배열 + LCP 는 “한 벌”로 외우세요. 둘이 있으면 서로 다른 부분 문자열 개수 · 가장 긴 반복 부분 문자열 · 두 문자열의 최장 공통 부분 문자열이 전부 배열 훑기 한 번으로 나옵니다.

아호-코라식 — 패턴 수천 개를 한 번의 스캔으로

패턴 he · hers · she 를 한 트라이에 담고, 실패했을 때 갈 곳을 미리 이어 둡니다 root h e he ✓ r s hers ✓ s h e she ✓ 점선 = 실패 링크 지금까지 읽은 문자열의 접미사 중 트라이에 있는 가장 긴 것을 가리킵니다 she 까지 읽었다면 he 도 방금 끝난 것입니다 텍스트를 한 번만 읽으면서 모든 패턴의 등장을 전부 찾습니다 — O(n + 패턴 총 길이 + 등장 수). 스팸 필터 · 침입 탐지 · 금칙어 검사가 실제로 이 구조를 씁니다.
KMP 의 실패 함수를 트라이 위로 올린 것이 실패 링크입니다 — 아이디어가 하나도 새롭지 않다는 점이 요령입니다.

아호-코라식 — 구축과 검색

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 · ZO(n+m)
패턴 여러 개아호-코라식O(n+Σm+k)
텍스트 고정 · 질의 다수접미사 배열전처리 후 O(m log n)
부분 문자열 개수SA + LCPO(n log n)
최장 반복 부분열LCP 의 최댓값O(n)
회문 관련매내커O(n)
대략만 맞으면 됨라빈-카프 해시기대 O(n+m)
연습 문제 (백준) — 1786 찾기(P5) 로 KMP·Z 를 확인하고, 9250 문자열 집합 판별(P4) 과 10256 돌연변이(P4) 로 아호-코라식을, 11479 서로 다른 부분 문자열의 개수 2(P3) 로 접미사 배열 + LCP 를, 13275 가장 긴 팰린드롬 부분 문자열(P5) 로 매내커를 만납니다.
파이썬에서는 “안 짜는 것”도 답입니다. 단순 포함 검사는 in 연산자가 C 로 구현된 최적화 알고리즘을 쓰므로 손으로 짠 KMP 보다 빠른 경우가 많습니다. 직접 구현이 필요한 것은 모든 등장 위치 · 여러 패턴 · 부분 문자열 통계처럼 내장으로 안 되는 질문일 때입니다.
이어지는 곳 — KMP 와 라빈-카프는 11절, 트라이는 📚 자료구조 10, 해시 충돌과 공격은 📚 자료구조 05, 정규 언어와의 관계는 🧮 계산이론 03 에 있습니다.
ALGO 21

구현 · 시뮬레이션 — 어려운 알고리즘이 없는데 자꾸 틀리는 유형

삼성 SW 역량테스트를 비롯한 국내 코딩 테스트에서 가장 많이 나오는 유형입니다. 쓰는 기법은 반복문과 배열뿐인데, 지문을 그대로 코드로 옮기는 일 자체가 어렵습니다. 틀리는 지점은 늘 정해져 있습니다 — 경계 검사 · 동시 이동 · 좌표 표기 · 깊은 복사 네 가지입니다. 이 절은 그 네 가지를 막는 골격을 만듭니다.

이 유형에서 알고리즘 지식은 거의 필요 없습니다. 대신 “지문의 규칙을 빠짐없이 · 순서대로 · 한 번씩만” 적용하는 정확성이 전부입니다. 그래서 템플릿을 손에 익혀 두는 것이 실제로 점수를 올립니다.
격자 문제의 90%는 “지금 칸에서 이웃으로” — 그 이동을 배열 하나로 표현합니다 0 3 r,c 1 2 붉은 칸이 지금 위치 · 초록이 네 이웃 i dr dc 방향 0 −1 0 위 1 0 +1 오른쪽 2 +1 0 아래 3 0 −1 왼쪽 시계 방향 순서로 두면 “오른쪽으로 회전 = (i+1)%4” 가 공짜입니다 이 유형 오답의 대부분은 경계 검사입니다 — 이동 직후 0 ≤ nr < N and 0 ≤ nc < M 를 반드시 겁니다. 8방향이면 대각선 네 쌍을 더합니다. 방향 순서를 바꾸면 “회전” 문제의 답이 달라지니 지문을 따르세요.
델타 배열은 if 문 네 개를 배열 하나로 바꾸는 것입니다 — 코드가 줄어드는 만큼 실수도 줄어듭니다.

격자 시뮬레이션 골격 — 이대로 외우세요

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·자바라면 즉시 터질 버그가 파이썬에선 “왜 답이 조금 다르지”로만 나타납니다. 경계 검사를 함수로 빼 두는 이유가 이것입니다.

격자 회전 — 손으로 인덱스를 세지 않는 법

회전은 인덱스를 손으로 세면 거의 틀립니다 — 관용구로 외우세요 원본 1 2 3 4 5 6 7 8 9 마지막 행 시계 90° 결과 7 4 1 8 5 2 9 6 3 첫 열이 됨 시계 90° m = [list(r) for r in zip(*m[::-1])] 반시계 90° m = [list(r) for r in zip(*m)][::-1] 180° 는 두 번 돌리면 됩니다 규칙 한 줄 — 시계 방향은 “행을 뒤집고 전치(zip)”, 반시계는 “전치하고 행을 뒤집기”입니다. zip 은 튜플을 돌려주므로 이후에 칸을 수정할 거면 list() 로 감싸야 합니다. 정사각이 아니어도 됩니다 — N×M 이 M×N 으로 바뀝니다. 회전 뒤 N, M 을 다시 잡으세요.
회전·대칭은 외워 쓰는 관용구입니다 — 시험장에서 인덱스를 유도하려 들면 시간을 잃습니다.

자주 나오는 시뮬레이션 패턴

# ── ① 달팽이 채우기 — 벽이나 이미 칠한 칸을 만나면 회전
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 아기 상어 G3BFS + 우선순위 규칙
17143 낚시왕 G1동시 이동 + 충돌 처리
20056 마법사 상어와 파이어볼 G4한 칸에 여럿 모이기
푸는 순서가 정해져 있습니다. ① 지문의 규칙을 번호 매긴 목록으로 옮겨 적고, ② 각 번호를 함수 하나로 만들고, ③ 한 턴을 돌린 뒤 판을 출력해 눈으로 확인합니다. 디버깅 출력이 이 유형에서는 가장 빠른 도구입니다.
“완전 탐색 + 시뮬레이션” 조합이 특히 자주 나옵니다. 배치 경우의 수를 itertools.product 나 백트래킹(10절)으로 만들고, 각 배치마다 시뮬레이션을 돌려 최선을 고르는 형태입니다. 이때 매번 판을 새로 복사하는 것을 잊지 마세요.
이어지는 곳 — BFS·DFS 는 04절, 완전 탐색과 가지치기는 10절, 복잡도 역산은 13절, 파이썬 자료구조의 복사 함정은 🐍 Python 가이드에 더 있습니다.
ALGO 22

투 포인터 · 슬라이딩 윈도우 · 누적합 — O(n2) 을 O(n) 으로

“모든 쌍”이나 “모든 구간”을 보면 O(n2) 입니다. 그런데 대부분의 문제에는 되돌아갈 필요가 없다는 성질이 숨어 있습니다. 그 성질을 찾으면 포인터가 한 방향으로만 움직여 O(n) 이 되고, 구간 합은 미리 더해 두면 질의가 O(1) 이 됩니다. 카카오·네이버·라인 코딩 테스트에서 가장 자주 쓰이는 도구입니다.

세 기법의 뿌리는 하나입니다 — “이미 계산한 것을 버리지 않기”. 투 포인터는 이전 비교 결과를, 슬라이딩 윈도우는 직전 창의 합을, 누적합은 앞에서부터의 합을 재활용합니다.
두 수의 합이 16 인 쌍 찾기 — 양 끝에서 좁혀 들어갑니다 1 0 3 1 5 2 7 3 9 4 11 5 ① L=0 R=5 L R 12 < 16 ② L=1 R=5 L R 14 < 16 ③ L=2 R=5 L R 16 ✓ 합이 작으면 L 을 오른쪽으로(키우고), 크면 R 을 왼쪽으로(줄입니다). 되돌아갈 일이 없습니다. 두 포인터가 합쳐서 n 번만 움직이므로 전체가 O(n) 입니다 — 모든 쌍을 보면 O(n²) 입니다. 정렬이 안 돼 있으면 정렬 O(n log n) 이 지배합니다. 그래도 O(n²) 보다 훨씬 낫습니다. “되돌아갈 필요가 없다”를 한 문장으로 설명할 수 있어야 이 기법을 써도 됩니다.
투 포인터가 성립하는 근거는 단조성입니다 — L 을 키우면 합이 커지고 R 을 줄이면 합이 작아진다는 성질이 없으면 쓸 수 없습니다.

세 가지 형태 — 코드로

# ── ① 투 포인터 : 정렬된 배열에서 합이 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)
음수가 섞인 구간 합슬라이딩 윈도우 불가 — 단조성이 깨짐
“음수가 있는가”를 먼저 확인하세요. 원소가 모두 양수여야 “창을 늘리면 합이 는다”가 성립합니다. 음수가 섞이면 슬라이딩 윈도우가 조용히 틀립니다 — 그때는 누적합 + 정렬이나 세그먼트 트리로 방향을 바꿉니다.
연습 문제 (백준) — 2003 수들의 합 2 S4 로 기본형, 1806 부분합 G4 로 최소 길이 창, 11003 최솟값 찾기 P5 로 단조 덱, 11660 구간 합 구하기 5 S1 로 2차원 누적합을 익힙니다.

누적합 — 미리 더해 두면 질의가 상수 시간

앞에서부터 더한 값을 미리 저장해 두면, 어떤 구간이든 뺄셈 한 번입니다 arr 3 1 4 1 5 0 1 2 3 4 P 0 3 4 8 9 14 0 1 2 3 4 5 초록 = 구하려는 구간 붉은 = 빼고 더할 두 값 arr[1] + arr[2] + arr[3] = P[4] − P[1] = 9 − 3 = 6 P[0] = 0 을 두는 것이 요령입니다 — l = 0 인 경우를 따로 처리할 필요가 사라집니다. 구간 [l, r] 의 합 = P[r+1] − P[l]. 전처리 O(n) · 질의 O(1) 입니다. 2차원은 포함배제로 — S = P[r2][c2] − P[r1−1][c2] − P[r2][c1−1] + P[r1−1][c1−1].
질의가 한 번뿐이면 누적합이 오히려 손해입니다 — 여러 번 물을 때만 이득입니다.

누적합 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]
왜 O(n) 인가 각 원소는 덱에 한 번 들어가고 한 번 나옵니다.
while 문이 여러 번 돌아도 전체를 합치면 n 번입니다
→ 분할상환으로 O(n) (14절의 집계 분석)

창마다 최댓값을 다시 찾으면 O(nk) 이고,
힙을 쓰면 O(n log n) 입니다.
“나보다 크고 나보다 뒤에 있는 것이 있으면 나는 답이 될 수 없다” — 이 한 문장이 단조 덱의 전부입니다. 같은 논리가 다음 큰 원소(NGE) · 히스토그램 최대 직사각형 · 컨벡스 헐 트릭(18절)에서 그대로 반복됩니다.
이어지는 곳 — 분할상환 근거는 14절, 값이 바뀌는 구간 질의는 📚 자료구조 14, 이분 탐색과의 조합(파라메트릭 서치)은 03절, 복잡도 역산은 13절 에 있습니다.
ALGO 23

코딩 테스트 정수론 — 소수 · 모듈러 · 조합

🔢 이산수학 09·10 이 “왜 그런가”였다면, 여기는 “시험장에서 어떻게 짜는가”입니다. 소수를 한꺼번에 구하는 체, 나눗셈이 없는 세계에서의 모듈러 역원, 큰 수의 조합을 109+7 로 나눈 나머지로 구하는 법 — 확률·경우의 수 문제가 나오면 거의 항상 이 셋이 필요합니다.

왜 하필 109+7 인가 — 소수이면서 int 범위 안이고, 두 수를 곱해도 64비트를 넘지 않기 때문입니다. 소수여야 페르마 소정리로 역원을 구할 수 있습니다. 그게 이 상수의 이유 전부입니다.
2부터 차례로 “그 수의 배수”를 지웁니다 — 끝까지 남은 것이 소수입니다 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 초록 = 소수 · 붉은 = 지워진 합성수 왜 √n 까지만 도나 합성수 n = a × b 라면 둘 중 하나는 반드시 √n 이하입니다 → 그 작은 쪽이 이미 지웠습니다 지우기 시작점도 i·i 부터면 충분합니다 전체 O(n log log n) — 사실상 선형 체는 “하나씩 소수 판정”(n√n)과 다릅니다 — 한 번에 n 까지 전부 구할 때만 이득입니다. 지울 때 “가장 작은 소인수”를 함께 적어 두면 소인수분해가 O(log n) 이 됩니다. n 이 10⁷ 을 넘으면 파이썬 리스트로는 메모리가 벅찹니다 — bytearray 를 쓰세요.
체의 핵심은 “지우는 일을 나눠 갖는 것”입니다 — 각 합성수는 자기 소인수에게만 지워지므로 전체 작업량이 작습니다.

체 · 소인수분해 · 최대공약수

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 의 배수가 아닐 때만 존재합니다.
연습 문제 (백준) — 1978 소수 찾기 B1 → 1929 소수 구하기 S3 로 체, 11689 GCD(n,k)=1 G1 로 오일러 파이, 11401 이항 계수 3 G1 로 역원, 13172 Σ G1 로 분수의 모듈러 처리를 익힙니다.
이어지는 곳 — 이론적 근거(나머지 · 유클리드 · 페르마)는 🔢 이산수학 09·10, 조합론은 🔢 이산수학 06, 암호에서의 쓰임은 🔐 보안 05, 큰 수 곱셈의 복잡도는 08절 에 있습니다.
같은 복잡도인데 10배 차이가 나는 이유는 전부 여기 있습니다

코드가 실제로
돌아가는 곳을
알고 있습니까

빅오가 같은 두 코드의 속도가 10배 다를 때, 답은 알고리즘이 아니라 하드웨어에 있습니다 — 캐시 · 분기 예측 · 메모리 배치 · 정수 표현. 이 탭은 CPU 안에서 실제로 무슨 일이 벌어지는지 보고, 그것이 우리가 쓰는 코드에 어떻게 나타나는지까지 이어갑니다. 진법 변환기도 안에 있습니다.

13SECTIONS
변환기내장
캐시중심
실측비교
ARCH 01

컴퓨터는 무엇으로 되어 있나 — 폰 노이만 구조

CPU 는 메모리에서 명령을 하나 꺼내 실행하기를 반복합니다. 그게 전부입니다. 다만 초당 수십억 번 하고, 그 반복을 빠르게 하려는 온갖 장치가 붙어 있습니다.

CPU 제어부 ALU 레지스터 (수십 개) 캐시 L1 · L2 · L3 수 MB 메모리 RAM · 수십 GB 명령 + 데이터 저장 SSD · TB 폰 노이만의 핵심 — 명령과 데이터가 같은 메모리에 있다 (그래서 프로그램을 데이터처럼 다룰 수 있다)
오른쪽으로 갈수록 크고 싸고 느립니다. 성능의 승부는 왼쪽에 얼마나 머무느냐입니다.

명령어 사이클

반복:
  ① 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
비유로 환산하면 — L1 을 1초라고 하면 RAM 은 80초, SSD 는 14시간, 대륙 간 왕복은 5년입니다. 이 규모 차이가 모든 성능 설계의 출발점입니다.

이렇게도 쓸 수 있다 — RISC 와 CISC 🟡 중급

CISC (x86)RISC (ARM · RISC-V)
명령어 수많고 복잡 (1000+)적고 단순
명령 길이가변 (1~15바이트)고정 (4바이트)
메모리 접근거의 모든 명령에서load/store 만
디코딩복잡 · 전력 소모단순 · 저전력
대표Intel · AMDApple Silicon · 스마트폰 · 서버 ARM
현대 x86 은 내부적으로 RISC 입니다 — 복잡한 x86 명령을 마이크로 연산(µop) 으로 쪼개서 실행합니다. 그래서 “CISC vs RISC” 논쟁은 이제 디코더 복잡도와 전력의 문제에 가깝습니다. 실무에서 중요한 것은 메모리 모델 차이입니다 → 🕸️ 분산 · 동시성 03.

실무에선 이렇게 — 아키텍처 차이가 코드에 나타날 때 🔴 실무

상황주의점
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")
“내 노트북에서는 되는데”의 하드웨어 버전입니다 — Apple Silicon 이 널리 쓰이면서 x86 에서 숨어 있던 동시성 버그가 드러나는 사례가 많아졌습니다. CI 에서 여러 아키텍처로 테스트하는 것이 이제 기본이 됐습니다.
ARCH 02

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")      # 교집합 크기
④ 가 알고리즘에서 특히 유용합니다 — 비트마스크 DP 는 “방문한 도시 집합”을 정수 하나로 표현해 배열 인덱스로 쓸 수 있게 합니다. n ≤ 20 짜리 외판원 문제가 이 방법으로 풀립니다 → 🔀 알고리즘 09.

실무에선 이렇게 — 언어별 함정 🔴 실무

언어주의점
파이썬정수가 무한 정밀도 — ~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
C/C++ 의 미정의 동작이 특히 위험합니다 — 1 << 31 (int 가 32비트일 때)은 UB 이고, 컴파일러가 “이런 일은 없다”고 가정해 코드를 통째로 지울 수 있습니다. 1u << 31 처럼 부호 없는 타입을 쓰세요 → 🔵 C++ 가이드.
2진수를 다루는 네 가지 도구 시프트 — 자리를 옮깁니다 x << 1 → 2배 · x >> 1 → 절반(버림) 1 << k → k번째 자리만 1 인 값 (플래그 만들기) AND — 자리를 골라냅니다 x & 1 → 마지막 비트 (홀짝 판정) x & (n-1) → n 이 2의 거듭제곱일 때 나머지 OR · XOR x | mask → 그 자리를 켭니다 x ^ mask → 그 자리를 뒤집습니다 (토글) XOR 의 성질 — 두 번 하면 원래대로 a ^ b ^ b = a "한 번만 나온 숫자 찾기" 문제의 열쇠입니다 음수의 오른쪽 시프트는 언어마다 다릅니다(부호 유지 vs 0 채움). x*2 를 x<<1 로 바꾸는 최적화는 불필요합니다 — 컴파일러가 합니다
비트 연산은 성능보다 상태를 압축해 표현하는 도구로 씁니다.
2진수를 다루는 네 가지 도구 시프트 — 자리를 옮깁니다 x << 1 → 2배 · x >> 1 → 절반(버림) 1 << k → k번째 자리만 1 인 값 (플래그 만들기) AND — 자리를 골라냅니다 x & 1 → 마지막 비트 (홀짝 판정) x & (n-1) → n 이 2의 거듭제곱일 때 나머지 OR · XOR x | mask → 그 자리를 켭니다 x ^ mask → 그 자리를 뒤집습니다 (토글) XOR 의 성질 — 두 번 하면 원래대로 a ^ b ^ b = a "한 번만 나온 숫자 찾기" 문제의 열쇠입니다 주의 — 음수의 오른쪽 시프트는 언어마다 다릅니다(부호 유지 vs 0 채움). 그리고 x*2 대신 x<<1 로 쓰는 최적화는 요즘 불필요합니다 — 컴파일러가 합니다
비트 연산은 성능보다 상태를 압축해 표현하는 도구로 씁니다.
ARCH 03

정수 표현 — 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 ~ 1270 ~ 255
16-32,768 ~ 32,7670 ~ 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년간 존재)
C/C++ 에서 부호 있는 정수 오버플로는 미정의 동작입니다 — “뒤집힌다”가 아니라 무슨 일이든 일어날 수 있습니다. 컴파일러가 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)     // (값, 넘쳤는지) 튜플

// ★ '어떤 동작을 원하는지' 를 코드에 적게 만드는 설계
실무 규칙 — 크기 · 인덱스 · 길이 계산에는 항상 부호 없는 타입 + 오버플로 검사를 쓰세요. 대부분의 힙 오버플로 취약점이 크기 계산의 오버플로에서 시작합니다 → 🔐 보안 · 암호 10.

실무에선 이렇게 — 타입 변환의 함정 🔴 실무

// ① 부호 있는 것과 없는 것을 비교하면 조용히 변환된다
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비트 epoch2038년 문제
금액은 정수 최소 단위부동소수점 금지 (➗ 12)
길이·인덱스는 size_t음수가 나올 수 없는 값
JSON 의 큰 정수는 문자열JS 는 2⁵³ 이상 부정확
DB 컬럼 타입 확인INT vs BIGINT
“지금은 충분하다”가 가장 위험한 판단입니다 — 2038년 문제도, 강남스타일 조회수도, 트위터 ID 도 전부 “설마 그렇게 커지겠나”에서 시작했습니다. ID 와 카운터는 처음부터 64비트로 잡으세요.
2의 보수 — 뺄셈 회로를 안 만들기 위한 표현 만드는 법 — 뒤집고 1 더하기 5 는 00000101 → 뒤집으면 11111010 → 1 을 더하면 11111011 이 −5 입니다 얻는 것 — 덧셈 회로 하나면 충분합니다 7 − 5 를 7 + (−5) 로 계산합니다. 0 도 하나뿐이라 비교가 단순해집니다 오버플로 — 한 바퀴 돕니다 int 최댓값 + 1 = 최솟값 2147483647 + 1 = −2147483648 음수가 하나 더 많습니다 −최솟값을 표현할 수 없습니다 abs(최솟값) 이 음수로 나옵니다 실제 사고 — 중간값을 (lo+hi)/2 로 구하면 큰 배열에서 넘칩니다. lo + (hi−lo)/2 로 쓰세요 C·C++ 에서 부호 있는 정수의 오버플로는 미정의 동작입니다 — 컴파일러가 검사 코드를 지워 버리기도 합니다
정수 오버플로 버그는 드물어 보이지만 발견될 때는 이미 운영입니다.
ARCH 04

부동소수점 하드웨어 — IEEE 754 비트 구조

➗ 선형대수 탭 12에서 “왜 0.1+0.2 가 이상한가”를 봤습니다. 여기서는 비트가 실제로 어떻게 생겼는지를 봅니다. 비트를 보면 정밀도 한계가 계산으로 나옵니다.

32비트 float 의 구조

S EEEE EEEE MMMM MMMM … 1 + 8 + 23
값 = (−1)S × 1.M × 2(E−127) S 부호 1비트 · E 지수 8비트 · M 가수 23비트
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  ← 무한소수를 잘라냄

타입별 정밀도

타입비트유효 자릿수범위
float161+5+10~3±65,504
bfloat161+8+7~2float32 와 같음
float321+8+23~7±3.4×10³⁸
float641+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 으로 주고받으면 안 되는 이유
bfloat16 이 흥미롭습니다 — 가수를 줄이고 지수는 float32 와 같게 유지합니다. 딥러닝에서는 정밀도보다 범위가 중요해서 (기울기가 아주 작거나 큼) 이 배치가 더 잘 맞습니다.

이렇게도 쓸 수 있다 — 특수값과 반올림 🟡 중급

지수 비트가 특별한 값

EM의미
전부 00±0
전부 0≠0비정규수 (아주 작은 값)
전부 10±무한대
전부 1≠0NaN
비정규수는 아주 느립니다 — 일부 CPU 에서 비정규수 연산이 100배 느려집니다. 오디오·물리 시뮬레이션에서 값이 0 에 수렴할 때 갑자기 성능이 떨어지는 원인입니다. 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모델 양자화스케일·영점 관리 필요
TF32NVIDIA 텐서 코어결과가 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), 그 오차가 비즈니스적으로 허용 가능한 범위인지 판단하세요.
ARCH 05

논리 게이트에서 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 로 만들어지는 이유
NAND 만으로 컴퓨터를 만들 수 있습니다 — NOT = NAND(A,A), AND = NOT(NAND(A,B)), OR = NAND(NOT A, NOT B). NOR 도 마찬가지입니다. 제조 관점에서 한 종류만 잘 만들면 되므로 실제 칩 설계에서 큰 이점입니다.

덧셈기 — 게이트로 계산 만들기

반가산기 (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절)
여기서 성능 문제가 생깁니다 — 올림이 아래에서 위로 차례로 전파되면 32단계를 기다려야 합니다. 그래서 실제 CPU 는 캐리 예측(carry-lookahead) 회로로 올림을 미리 계산합니다.

이렇게도 쓸 수 있다 — 기억하는 회로 — 플립플롭 🟡 중급

조합 회로 (지금 입력만으로 출력 결정)
  덧셈기 · 멀티플렉서 · 디코더

순차 회로 (과거 상태를 기억)
  플립플롭 · 레지스터 · 카운터

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  ← 칩 크기의 물리적 한계
클럭 속도가 2005년쯤 멈춘 이유 — 전력 소비가 주파수의 세제곱에 가깝게 늘어나 발열을 감당할 수 없게 됐습니다. 그래서 코어 수를 늘리는 방향으로 갔고, 그 결과가 동시성 프로그래밍의 시대입니다 → 🕸️ 분산 · 동시성.

실무에선 이렇게 — 개발자가 이걸 알아야 하는 이유 🔴 실무

회로 지식소프트웨어에서의 결과
클럭 동기 · 임계 경로주파수 한계 → 멀티코어 · 동시성
SRAM(플립플롭) vs DRAM캐시가 비싸고 작은 이유
DRAM 은 주기적 리프레시 필요메모리 접근 지연의 하한
메모리 셀은 행 단위 접근순차 접근이 랜덤보다 빠른 이유
비트 하나가 뒤집힐 수 있음ECC 메모리 · 체크섬의 필요성
NAND 플래시는 블록 단위 지우기SSD 쓰기 증폭 · TRIM (11절)
비트 플립은 실제로 일어난다

 · 우주선(cosmic ray) · 전기적 잡음 · 노후화
 · 대규모 데이터센터에서는 하루에도 여러 번
 · Rowhammer: 같은 메모리 행을 반복 접근해
   인접 행의 비트를 인위적으로 뒤집는 공격

대응
 · ECC 메모리 — 1비트 오류 정정, 2비트 검출
 · 체크섬 · 해시로 데이터 무결성 확인 (ZFS · 백업)
 · 중요한 값은 여러 곳에 저장하고 대조
서버에는 ECC 메모리를 쓰는 이유가 이것입니다. 일반 PC 메모리는 비트가 뒤집혀도 아무도 모릅니다 — DB 데이터가 조용히 손상될 수 있습니다. 클라우드 인스턴스 사양에서 ECC 여부를 확인해 볼 가치가 있습니다.
ARCH 06

명령어 집합 · 어셈블리 — 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
# 디버거에서 디스어셈블리 창
godbolt.org 를 한 번 열어 보세요 — 같은 C 코드를 -O0 과 -O2 로 비교하면 컴파일러가 얼마나 많은 일을 하는지 바로 보입니다.

이렇게도 쓸 수 있다 — 함수 호출과 스택 프레임 🟡 중급

함수를 호출할 때 스택에서 벌어지는 일

높은 주소
  ┌──────────────┐
  │ 호출자의 프레임 │
  ├──────────────┤
  │ 인자 (7번째~)  │   ← 앞 6개는 레지스터로 (x86-64)
  ├──────────────┤
  │ 반환 주소      │   ← call 명령이 자동으로 push
  ├──────────────┤
  │ 저장된 rbp     │   ← 함수 프롤로그
  ├──────────────┤
  │ 지역 변수      │
  │ ...           │
  └──────────────┘  ← rsp (스택 포인터)
낮은 주소  ↓ 스택은 아래로 자란다

★ 버퍼 오버플로가 위험한 이유
  지역 변수 배열을 넘어서 쓰면 '반환 주소' 를 덮어쓴다
  → 함수가 끝날 때 공격자가 지정한 곳으로 점프
  → 🔐 보안 · 암호 10

스택 오버플로가 나는 이유

스택 크기는 보통 1~8MB 로 고정돼 있습니다. 재귀가 깊거나 지역 배열이 크면 넘칩니다. char buf[1024*1024] 같은 지역 변수 하나로도 터질 수 있습니다. 큰 버퍼는 힙에 잡으세요.

호출 규약(ABI)이 중요한 이유

FFI · 네이티브 모듈에서 인자를 어디에 두는지를 양쪽이 합의해야 합니다. 규약이 다르면 조용히 잘못된 값이 전달됩니다 → 🔵 C++ · 🦀 Rust 가이드의 C 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 절.
ARCH 07

파이프라인 · 분기 예측 — 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 헤더 필요
비밀 값을 다루는 코드상수 시간 연산 · 사이드 채널 주의
“성능 최적화가 보안 구멍이 된” 대표 사례입니다. 추상화(“메모리 보호가 있으니 안전하다”)가 구현 세부(캐시 · 투기 실행) 때문에 깨진 것이고, 그래서 하드웨어 동작을 아는 것이 보안에도 의미가 있습니다 → 🔐 보안 · 암호 탭.
if 하나가 비쌀 수 있는 이유 — 예측이 틀렸을 때 파이프라인 — 명령을 겹쳐서 처리합니다 가져오기 · 해석 · 실행 · 메모리 · 쓰기를 여러 명령이 동시에 다른 단계에서 진행합니다 예측이 맞았을 때 이미 진행한 것을 그대로 씁니다 분기 비용이 사실상 0 입니다 틀렸을 때 진행한 것을 전부 버리고 다시 시작 수십 사이클을 잃습니다 예측기는 패턴을 학습합니다 — 항상 참이거나 규칙적이면 거의 다 맞힙니다 문제는 무작위에 가까운 조건입니다 — 정렬된 배열이 정렬 안 된 배열보다 빠른 유명한 사례가 이것입니다 줄이는 법 — 분기를 반복문 밖으로 빼기 · 산술로 바꾸기 · 자주 맞는 쪽을 먼저 두기 다만 대부분의 코드에서는 신경 쓸 일이 아닙니다 — 안쪽 반복문을 재고 나서 손대세요
"같은 코드인데 데이터에 따라 속도가 다르다" 의 흔한 정체가 분기 예측입니다.
ARCH 08

캐시 — 성능의 진짜 주인

이 탭에서 가장 중요한 절입니다. 같은 알고리즘이 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
컬럼형 DB 가 빠른 이유가 정확히 SoA 입니다 — SELECT AVG(price) 를 할 때 price 컬럼만 읽으면 되므로 행 저장 방식보다 수십 배 적은 데이터만 건드립니다 → 🗄️ Database 가이드.

실무에선 이렇게 — 캐시 친화 코드의 규칙 🔴 실무

  1. 순차로 접근한다 — 다차원 배열은 메모리 순서대로 반복. C 계열이면 마지막 인덱스가 가장 안쪽 루프.
  2. 필요한 것만 담는다 — 구조체를 작게. 안 쓰는 필드를 빼면 같은 캐시 라인에 더 많은 원소가 들어갑니다.
  3. 포인터 추적을 줄인다 — 연결 리스트 · 트리보다 배열이 유리한 경우가 많습니다(🗂️ 자료구조 01).
  4. 블로킹(타일링) — 큰 행렬 연산은 캐시에 들어가는 크기로 잘라서.
  5. 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% 를 넘는지 먼저 확인하고, 그때만 위 규칙을 적용하세요.
캐시 계층 — 아래로 갈수록 크고 느려집니다 L1 캐시 약 64KB · 약 4사이클 코어마다 따로 L2 캐시 약 1MB · 약 12사이클 코어마다 따로 L3 캐시 약 32MB · 약 40사이클 코어들이 공유 메인 메모리 수십 GB · 200사이클 이상 L1 의 50배 이상 캐시는 64바이트 덩어리(캐시 라인) 단위로 가져옵니다 그래서 순서대로 훑으면 한 번 가져온 것을 여러 번 쓰고, 흩어져 있으면 매번 새로 가져옵니다 배열이 연결 리스트보다 빠른 진짜 이유이고, 행 우선·열 우선 순회 속도가 다른 이유이기도 합니다 코어마다 캐시가 따로라 여러 스레드가 같은 라인을 건드리면 서로 무효화합니다 (false sharing)
현대 CPU 성능의 주인은 클럭이 아니라 데이터가 어디에 있느냐입니다.
ARCH 09

메모리 계층 · MMU · TLB — 주소는 가짜입니다

프로그램이 보는 주소는 진짜 메모리 주소가 아닙니다. 그 사이에 변환 장치(MMU)가 있고, 그 변환도 캐시(TLB)를 씁니다. 이 계층이 성능에 직접 영향을 줍니다.

0.3ns
레지스터수십 개 · 컴파일러가 배분
▼ 100배씩 느려진다
1~15ns
L1 · L2 · L3 캐시수십 KB ~ 수십 MB · 하드웨어가 자동 관리
▼
80ns
메인 메모리 (DRAM)수십 GB · OS 가 페이지 단위로 관리
▼
50µs
NVMe SSD수 TB · 파일 시스템
▼
10ms+
네트워크 · 오브젝트 스토리지사실상 무제한

가상 주소 → 물리 주소

프로그램의 주소 (가상)
        ↓
      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
Redis · MongoDB 설치 시 THP 를 끄라는 경고가 나오는 이유입니다. 휴지 페이지는 대용량 순차 접근에는 좋지만 fork 기반 스냅숏에서 복사 비용을 크게 늘립니다.

이렇게도 쓸 수 있다 — 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                # 고르게 분산
클라우드에서도 만납니다 — 큰 인스턴스(수십 vCPU)는 대개 멀티 소켓입니다. 성능이 코어 수에 비례해 늘지 않는다면 NUMA 를 의심해 볼 만합니다. 작은 인스턴스 여러 대로 나누는 것이 더 나은 경우도 많습니다.

실무에선 이렇게 — 메모리 접근 패턴 최적화 🔴 실무

증상의심확인
CPU 사용률은 높은데 느림캐시 미스 대기perf stat IPC 확인
메모리를 늘려도 안 빨라짐지연이 병목 (대역폭 아님)접근 패턴 검토
큰 힙에서만 느림TLB 미스dTLB-load-misses
코어를 늘려도 안 빨라짐메모리 대역폭 포화 · NUMAnumastat · 대역폭 측정
스레드 늘리면 오히려 느림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
IPC 는 성능 진단의 첫 번째 지표입니다 — “CPU 100%” 는 일을 하고 있다는 뜻이 아니라 기다리고 있을 수도 있다는 뜻입니다. IPC 가 낮으면 알고리즘이 아니라 메모리 배치를 봐야 합니다.
ARCH 10

병렬성 — 멀티코어 · SIMD · GPU

클럭이 안 오르니 수를 늘렸습니다. 그런데 코어를 8배 늘려도 8배 빨라지지 않습니다 — 그 이유를 계산하는 법(암달의 법칙)부터 봅니다.

암달의 법칙 — 상한이 정해져 있다

속도 향상 = 1 / ((1−p) + p/n) p = 병렬화 가능한 비율 · n = 코어 수
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 에 두고 여러 연산을 이어서
실무 판단 — GPU 도입 전에 CPU 에서 numpy·BLAS·멀티스레드로 얼마나 나오는지 먼저 재 보세요. 데이터가 수 GB 이하이고 연산이 단순하면 CPU 로 충분한 경우가 많고, 운영 복잡도(드라이버 · 메모리 관리 · 비용)를 아낄 수 있습니다.
ARCH 11

저장장치 — SSD 는 덮어쓰기를 못 합니다

HDD 의 회전 지연에 맞춰 설계된 소프트웨어가 많은데, SSD 는 완전히 다른 제약을 가집니다. DB 튜닝과 파일 시스템 선택이 여기서 갈립니다.

HDD vs SSD

HDDSSD (NVMe)
랜덤 읽기~10ms~50µs (200배)
순차 읽기200MB/s3~7GB/s
IOPS~20050만~100만
랜덤 vs 순차100배 차이2~5배 차이
병렬성거의 없음큐 깊이가 중요
수명기계적쓰기 횟수 제한
SSD 는 큐 깊이가 중요합니다 — 요청 하나씩 보내면 성능이 안 나옵니다. 여러 요청을 동시에 보내야 내부 채널을 병렬로 씁니다. 그래서 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
쓰기만 느림쓰기 캐시 정책 · 배터리 백업 없음
클라우드의 버스트 크레딧이 자주 문제가 됩니다 — 평소에는 빠르다가 크레딧이 떨어지면 갑자기 기본 IOPS 로 떨어집니다. “며칠에 한 번 느려진다”면 이것을 먼저 확인하세요.
읽기·쓰기는 페이지 단위, 지우기는 블록 단위입니다 블록 하나 (예: 4MB) 무효 유효 무효 유효 페이지(4KB)마다 읽고 쓰지만 지우기는 블록 통째로 그래서 수정은 이렇게 일어납니다 ① 새 페이지에 새 내용을 씁니다 ② 옛 페이지를 "무효"로 표시만 합니다 ③ 나중에 유효한 것만 모아 블록을 비웁니다 ③이 쓰기 성능을 흔듭니다 가비지 컬렉션이 도는 동안 쓰기가 느려집니다 벤치마크는 빠른데 실사용이 들쭉날쭉한 이유 여유 공간이 적을수록 심해집니다 — 20% 는 비워 두세요 코드에 미치는 영향 작은 쓰기를 많이 하면 실제 쓰는 양이 몇 배가 됩니다 모아서 순차로 쓰는 편이 훨씬 유리합니다 DB 의 WAL · LSM 트리가 이 성질에 맞춘 설계입니다 TRIM 은 "이 페이지는 더 이상 필요 없다"고 알려 주는 명령입니다 — 켜져 있어야 정리가 효율적으로 돕니다
HDD 와 달리 덮어쓰기라는 개념이 없다는 것이 모든 차이의 출발점입니다.
읽기·쓰기는 페이지 단위, 지우기는 블록 단위입니다 블록 하나 (예: 4MB) 무효 유효 무효 유효 페이지(4KB)마다 읽고 쓰지만 지우기는 블록 통째로 그래서 수정은 이렇게 일어납니다 ① 새 페이지에 새 내용을 씁니다 ② 옛 페이지를 "무효"로 표시만 합니다 ③ 나중에 유효한 것만 모아 블록을 비웁니다 ③이 쓰기 성능을 흔듭니다 가비지 컬렉션이 도는 동안 쓰기가 느려집니다 벤치마크는 빠른데 실사용이 들쭉날쭉한 이유 여유 공간이 적을수록 심해집니다 — 20% 는 비워 두세요 코드에 미치는 영향 작은 쓰기를 많이 하면 실제 쓰는 양이 몇 배가 됩니다 모아서 순차로 쓰는 편이 훨씬 유리합니다 DB 의 WAL · LSM 트리가 이 성질에 맞춘 설계입니다 TRIM 은 "이 페이지는 더 이상 필요 없다"고 알려 주는 명령입니다 — 켜져 있어야 정리가 효율적으로 돕니다
HDD 와 달리 덮어쓰기라는 개념이 없다는 것이 모든 차이의 출발점입니다.
ARCH 12

버스 · 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
  사용자 공간을 거치지 않는다
Nginx · Kafka 가 빠른 이유 중 하나가 이것입니다. 정적 파일 서빙과 로그 전송에서 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 · 오버레이 네트워크 오버헤드
대부분의 서비스는 여기까지 안 갑니다 — 애플리케이션 병목이 먼저입니다. 다만 초당 수십만 요청 규모가 되면 이 층이 실제 한계가 되고, 그때 위 명령들이 필요해집니다 → 🌐 서버기술 가이드.
ARCH 13

하드웨어가 코드에 미치는 영향 — 정리

이 탭의 내용을 실제로 코드를 고칠 때 쓰는 순서로 정리합니다. 규칙은 몇 개 안 되고, 순서가 중요합니다.

최적화 순서 — 위에서부터

  1. 측정한다 — 프로파일러로 시간이 어디서 가는지 확인. 추측으로 고치면 90%는 헛수고입니다.
  2. 불필요한 일을 없앤다 — 안 해도 되는 계산 · 중복 쿼리 · 과도한 직렬화. 가장 큰 이득이 여기서 나옵니다.
  3. 복잡도를 낮춘다 — O(n²) → O(n log n). 데이터가 커질 때 유일하게 확실한 개선입니다.
  4. 메모리 접근을 고친다 — 순차 접근 · 자료구조 교체 · 구조체 축소. 여기서 2~10배가 나옵니다.
  5. I/O 를 묶는다 — 배치 · 캐시 · 비동기.
  6. 병렬화한다 — 암달의 법칙을 계산하고 시작.
  7. 상수를 줄인다 — SIMD · 인트린식 · 분기 제거. 노력 대비 효과가 가장 나쁩니다.
순서를 뒤집으면 손해입니다 — SIMD 로 2배를 얻은 코드가 알고리즘을 바꿔 100배 빨라질 수 있습니다. 게다가 저수준 최적화는 읽기 어려운 코드를 남깁니다.

기억할 숫자

동작대략의 시간
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/Oiostat -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
운영 환경에서는 continuous profiling 을 권합니다 — Pyroscope · Parca · 클라우드 프로파일러가 1% 미만의 오버헤드로 항상 켜져 있어, 문제가 생긴 시점의 프로파일을 사후에 볼 수 있습니다.

실무에선 이렇게 — 이어지는 곳 🔴 실무

여기서 배운 것다음
03·04 정수 · 부동소수점➗ 선형대수 12·13 수치 안정성
07 투기 실행 · 사이드 채널🔐 보안 · 암호 10
08 캐시 · 09 메모리 계층⚙️ 운영체제 05·06 가상 메모리
10 병렬성🕸️ 분산 · 동시성 01~04
11 저장장치🗄️ Database 가이드 · ⚙️ OS 09
12 인터럽트 · DMA⚙️ 운영체제 10 I/O 모델 · 🌐 네트워크
다음 탭 추천 — 7 ⚙️ 운영체제로 가면 여기서 본 하드웨어를 OS 가 어떻게 나눠 쓰게 하는지를 봅니다. 프로세스 · 가상 메모리 · 스케줄링이 전부 이 탭의 하드웨어 위에 세워진 추상화입니다.
ARCH 14

캐시 일관성 — 코어마다 캐시가 있는데 어떻게 값이 맞나

코어가 8개면 L1 캐시도 8개입니다. 같은 변수를 여러 코어가 캐시에 들고 있는데 한쪽이 값을 바꾸면 나머지는 어떻게 아는가. 이 질문의 답이 MESI 프로토콜이고, 거짓 공유와 메모리 배리어가 왜 필요한지가 전부 여기서 나옵니다.

캐시 일관성(cache coherence) — 여러 캐시가 같은 메모리 주소의 사본을 가질 때, 모든 코어가 그 주소에 대해 같은 값의 순서를 보도록 보장하는 것. 하드웨어가 자동으로 해 주지만 공짜가 아닙니다 — 이 비용이 곧 멀티스레드 성능의 상한입니다.
MESI — 캐시 라인 하나가 가질 수 있는 네 가지 상태 M — Modified 나만 갖고 있고 고쳤음 메모리와 다름 E — Exclusive 나만 갖고 있고 안 고쳤음 통보 없이 바로 쓸 수 있음 S — Shared 여러 코어가 같은 값 보유 읽기는 자유 · 쓰려면 통보 I — Invalid 남이 고쳐서 무효가 됨 다시 받아와야 함 코어 0 이 x 에 쓰기를 할 때 벌어지는 일 코어 0 x = 1 쓰기 S 에서 M 으로 무효화 요청 방송 x 가진 코어는 버려라 인터커넥트 통신 코어 1~7 의 사본이 전부 I 로 다음에 x 를 읽으려면 코어 0 에서 받아옵니다 이 왕복이 수십~수백 사이클 중요한 사실 — 무효화는 변수 단위가 아니라 캐시 라인(보통 64바이트) 단위로 일어납니다. 그래서 전혀 다른 변수 둘이 같은 64바이트 안에 있으면 서로를 계속 무효화합니다(거짓 공유). 여러 코어가 같은 변수를 계속 쓰면 라인이 코어 사이를 오갑니다 — 캐시 라인 핑퐁이라 부릅니다.
lock 이 느린 진짜 이유가 여기 있습니다 — 경합 자체보다 캐시 라인이 코어 사이를 오가는 비용이 큽니다.

거짓 공유 — 코드에는 아무 문제가 없는데 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배
캐시 라인 = 64바이트 (대부분의 x86 · ARM) 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 상태로 함께 보유
패딩을 아무 데나 넣지는 마세요. 객체 하나가 64바이트를 쓰게 되므로 개수가 많으면 메모리와 캐시 용량을 그만큼 잡아먹습니다. 여러 코어가 동시에 쓰는 것이 확인된 자리에만 적용하고, 적용 전후를 반드시 측정하세요.

여기서 이어지는 것 — 왜 메모리 배리어가 필요한가

일관성만으로는 부족합니다

MESI 는 '같은 주소'에 대한 순서만 보장합니다.
'서로 다른 주소 사이의 순서'는 보장하지 않습니다.

  코어 0            코어 1
  ─────────────    ─────────────
  data = 42;       if (ready)
  ready = true;        print(data);   // 0 이 찍힐 수 있습니다

왜?
  · 무효화 요청을 바로 처리하지 않고 큐에 쌓아 둡니다
  · 쓰기도 스토어 버퍼에 잠깐 모았다가 내보냅니다
  · 컴파일러와 CPU 가 순서를 바꿔 실행하기도 합니다

두 쓰기의 순서를 강제하려면 메모리 배리어가 필요합니다.
일관성(coherence) 과 일치성(consistency) 은 다릅니다 coherence — 한 주소에 대해 모든 코어가 같은 순서를 본다 (하드웨어 보장)
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 같은 이미 검증된 도구가 필요한 배리어를 전부 넣어 줍니다. 직접 락프리 자료구조를 쓰는 것은 측정으로 병목이 확인됐고 검증할 방법이 있을 때만입니다 — 이 영역의 버그는 재현이 거의 불가능합니다. 자세한 것은 🕸️ 분산 · 동시성 탭에서 이어집니다.
서버가 느릴 때 · 메모리가 샐 때 · 프로세스가 죽을 때 답이 있는 곳

내 코드가
혼자 돌고 있다는
착각을 걷어냅니다

프로세스 · 스레드 · 가상 메모리 · 파일 · 소켓. 매일 쓰면서 정확히는 모르는 것들입니다. 이 탭은 개념을 설명하고 끝내지 않고, 운영 중 장애를 어떤 명령으로 어떤 순서로 좁히는지까지 이어갑니다. 마지막 절이 실전 진단 명령 모음입니다.

14SECTIONS
진단명령 포함
리눅스기준
컨테이너까지
OS 01

운영체제가 하는 일 — 자원을 나눠 쓰게 하는 심판

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 — 검증된 코드를 커널에 안전하게 주입 ★
    (관측 · 네트워킹 · 보안에서 최근 가장 중요한 기술)
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/fdFD 누수 · 열린 소켓 수
/proc/PID/statusVmRSS(실제 메모리) · Threads
/proc/PID/stack커널 스택 (어디서 블로킹 중인가)
/proc/pressure/*PSI — CPU·메모리·I/O 압박 지표
/sys/fs/cgroup/컨테이너 리소스 제한과 실제 사용량
PSI(Pressure Stall Information)가 유용합니다 — “CPU 사용률”이 아니라 “얼마나 기다리고 있나”를 직접 알려 줍니다. cat /proc/pressure/io 로 I/O 대기 비율을 바로 볼 수 있습니다.
OS 02

프로세스와 스레드 — 무엇을 공유하는가

프로세스는 따로 사는 집, 스레드는 한 집에 사는 식구입니다. 이 차이가 성능 · 안정성 · 동시성 버그의 성격을 전부 결정합니다.

공유하는 것과 안 하는 것

자원프로세스 간스레드 간
주소 공간 (힙 · 전역)독립공유
코드독립공유
파일 디스크립터독립(상속 시 복사)공유
스택독립독립
레지스터 · 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 를 엔트리포인트로 두세요.

실무에선 이렇게 — 스레드 수를 정하는 법 🔴 실무

스레드 수 ≈ 코어 수 × (1 + 대기 시간 / 계산 시간) 순수 CPU 작업이면 코어 수, I/O 가 90% 면 코어 수 × 10
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 한도
메모리가 스레드 수에 비례해 증가스레드별 스택 · 버퍼
컨테이너에서만 스레드가 폭증호스트 코어 수를 읽음
리틀의 법칙으로 검산하세요 — “초당 500 요청 × 평균 100ms” 면 동시 처리 50개 입니다. 스레드 풀이 200이면 과다이고, 20이면 부족합니다 → ➗ 선형대수 · 확률 09.
무엇을 공유하느냐가 모든 차이를 만듭니다 프로세스 A 코드 · 전역 · 힙 (공유됨) 스레드 1 스택 (개별) 스레드 2 스택 (개별) 스레드 3 스택 (개별) 힙을 공유하므로 동기화가 필요합니다 프로세스 B (완전히 별개) 자기만의 코드 · 전역 · 힙 스레드 1 A 의 메모리에 닿을 수 없습니다 하나가 죽어도 다른 쪽은 멀쩡합니다 스레드가 유리한 경우 데이터를 자주 주고받을 때 · 생성 비용이 낮아야 할 때 프로세스가 유리한 경우 장애 격리 · 보안 경계 · 파이썬처럼 GIL 이 있는 환경
"같이 쓰니까 빠르고, 같이 쓰니까 위험하다" — 한 문장으로 요약됩니다.
무엇을 공유하느냐가 모든 차이를 만듭니다 프로세스 A 코드 · 전역 · 힙 (공유됨) 스레드 1 스택 (개별) 스레드 2 스택 (개별) 스레드 3 스택 (개별) 힙을 공유하므로 동기화가 필요합니다 프로세스 B (완전히 별개) 자기만의 코드 · 전역 · 힙 스레드 1 A 의 메모리에 닿을 수 없습니다 하나가 죽어도 다른 쪽은 멀쩡합니다 스레드가 유리한 경우 데이터를 자주 주고받을 때 · 생성 비용이 낮아야 할 때 프로세스가 유리한 경우 장애 격리 · 보안 경계 · 파이썬처럼 GIL 이 있는 환경
"같이 쓰니까 빠르고, 같이 쓰니까 위험하다" — 한 문장으로 요약됩니다.
OS 03

스케줄링 — 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 지연스케줄 지연 · 스왑 · THPPSI · 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
CPU 스로틀링은 “평균 사용률”에 안 보입니다 — 100ms 주기 안에서 앞 20ms 만 쓰고 80ms 를 멈춰도 평균은 20% 로 보입니다. 그런데 그 80ms 동안 요청은 전부 대기합니다. nr_throttled 를 대시보드에 올려 두세요.
OS 04

컨텍스트 스위치 — 공짜가 아닙니다

스레드를 바꿔 끼우는 데도 비용이 듭니다. 그것도 직접 비용보다 간접 비용(캐시 오염)이 훨씬 큽니다. “스레드를 늘렸는데 느려졌다”의 답이 여기 있습니다.

무슨 일이 벌어지나

직접 비용 (1~5µs)
  ① 현재 레지스터를 저장
  ② 스케줄러가 다음 태스크 선택
  ③ 새 태스크의 레지스터 복원
  ④ 프로세스가 다르면 페이지 테이블 전환 (+ TLB 플러시)

간접 비용 (훨씬 큼 · 수십~수백 µs)
  ⑤ ★ 캐시가 새 태스크의 데이터로 채워져야 한다
     내 데이터가 L1/L2 에서 밀려나 있다
     → 돌아왔을 때 전부 미스

  ⑥ TLB 도 마찬가지 (프로세스 전환 시)
  ⑦ 분기 예측기도 새로 학습해야 함
그래서 코루틴이 빠릅니다 — 같은 스레드 안에서 스택 포인터만 바꾸므로 커널 진입도, TLB 플러시도, 캐시 오염도 최소입니다(수십~수백 ns).

측정하기

# 시스템 전체
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
비자발적 스위치(nvcswch)가 높으면 CPU 경합입니다 — 실행 가능한 스레드가 코어보다 많다는 뜻이므로 스레드 수를 줄이거나 코어를 늘려야 합니다.

이렇게도 쓸 수 있다 — 스레드 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 스레드를 붙잡고 있지 않는다
  → 블로킹 호출 하나가 이 모델을 전부 무너뜨린다
비동기 코드에서 블로킹 호출은 치명적입니다 — 이벤트 루프 스레드가 멈추면 수천 개의 다른 요청이 함께 멈춥니다. 파일 읽기 · 동기 DB 드라이버 · CPU 집약 계산은 반드시 별도 스레드 풀로 보내세요.

실무에선 이렇게 — 스위치를 줄이는 설계 🔴 실무

기법효과
스레드 풀 크기 조정비자발 스위치 감소
배치 처리깨어나는 횟수 자체를 줄임
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번
# → 처리량이 수십 배 차이 난다
“크기 또는 시간” 두 조건이 배치의 표준 패턴입니다 — 크기만 쓰면 트래픽이 적을 때 영원히 안 나가고, 시간만 쓰면 폭주할 때 배치가 너무 커집니다. Kafka 프로듀서 · 로그 수집기가 전부 이 방식입니다.
OS 05

메모리 관리 · 페이징 — 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
VSZ 를 보고 놀라지 마세요 — 자바나 Go 프로세스가 수십 GB 의 VSZ 를 잡는 것은 정상입니다. 주소 공간을 예약했을 뿐 실제 메모리가 아닙니다. RSS 또는 PSS 를 보세요.

이렇게도 쓸 수 있다 — 페이지 테이블과 주소 변환 🟡 중급

가상 주소 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 · heaptrackfree 누락 · 예외 경로
파이썬tracemalloc · objgraph · memray전역 캐시 · 순환 참조 + __del__
자바힙 덤프 + MAT · JFR정적 컬렉션 · 리스너 미해제 · ThreadLocal
JS · NodeChrome DevTools · clinic클로저 · 이벤트 리스너 · 전역 Map
Gopprof 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
“누수”가 아닌 경우도 많습니다 — 할당자가 OS 에 메모리를 안 돌려주는 것(단편화)일 수 있습니다. glibc malloc 은 특히 그렇고, jemalloc·tcmalloc 으로 교체하면 해결되는 사례가 많습니다.
프로그램이 보는 주소는 진짜 주소가 아닙니다 가상 주소 0x7fff_1234 페이지 테이블 (MMU) 4KB 단위로 대응표 있음 → 물리 주소로 바로 접근 없음 → 페이지 폴트 → 디스크에서 로드 이 구조가 주는 것 프로세스마다 0번지부터 시작하는 자기 공간 서로의 메모리에 닿을 수 없는 격리 실제 메모리보다 큰 주소 공간을 쓸 수 있습니다 페이지 폴트가 잦으면(스래싱) 디스크를 계속 오가느라 CPU 는 놀고 시스템은 멈춘 듯 보입니다 — 메모리가 부족하다는 신호입니다 TLB 는 이 변환 결과를 캐시해 두는 작은 표입니다 매번 표를 뒤지면 느리므로 TLB 적중률이 성능을 좌우합니다 — 메모리를 여기저기 흩어 접근하면 적중률이 떨어집니다 큰 페이지(huge page)를 쓰면 표 항목 수가 줄어 대용량 처리에서 유리해집니다
"메모리가 넉넉한데 느리다"의 답이 이 변환 계층에 있을 때가 많습니다.
프로그램이 보는 주소는 진짜 주소가 아닙니다 가상 주소 0x7fff_1234 페이지 테이블 (MMU) 4KB 단위로 대응표 있음 → 물리 주소로 바로 접근 없음 → 페이지 폴트 → 디스크에서 로드 이 구조가 주는 것 프로세스마다 0번지부터 시작하는 자기 공간 서로의 메모리에 닿을 수 없는 격리 실제 메모리보다 큰 주소 공간을 쓸 수 있습니다 페이지 폴트가 잦으면(스래싱) 디스크를 계속 오가느라 CPU 는 놀고 시스템은 멈춘 듯 보입니다 — 메모리가 부족하다는 신호입니다 TLB 는 이 변환 결과를 캐시해 두는 작은 표입니다 매번 표를 뒤지면 느리므로 TLB 적중률이 성능을 좌우합니다 — 메모리를 여기저기 흩어 접근하면 적중률이 떨어집니다 큰 페이지(huge page)를 쓰면 표 항목 수가 줄어 대용량 처리에서 유리해집니다
"메모리가 넉넉한데 느리다"의 답이 이 변환 계층에 있을 때가 많습니다.
OS 06

가상 메모리 · 페이지 폴트 — 스왑이 시작되면 끝입니다

메모리에 없는 페이지를 건드리면 페이지 폴트가 납니다. 대부분은 정상이고 아주 빠르지만, 디스크까지 가야 하는 폴트는 서비스를 멈춥니다.

폴트의 종류

종류내용비용
마이너 폴트메모리에는 있는데 매핑만 없음~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
스왑을 완전히 끄는 것이 항상 정답은 아닙니다 — 끄면 스래싱 대신 OOM Killer 가 바로 동작합니다. DB 서버는 보통 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 을 넘어 죽음힙 외 메모리(메타스페이스 · 스레드 스택 · 네이티브)
메모리 여유가 있는데 OOMcgroup 한도 · 단편화 · 큰 연속 할당 실패
호스트 전체가 느려짐페이지 캐시 회수 압박 · 스래싱
# 자바 컨테이너의 전형적 설정 (힙만 잡으면 안 된다)
# 컨테이너 limit 이 2Gi 일 때
JAVA_OPTS="-XX:MaxRAMPercentage=70.0 \
           -XX:MaxMetaspaceSize=256m \
           -XX:+ExitOnOutOfMemoryError \
           -XX:+HeapDumpOnOutOfMemoryError \
           -XX:HeapDumpPath=/dumps"
# 힙 70% + 메타스페이스 + 스레드 스택 + 네이티브 = 100% 안쪽으로
“OOMKilled” 는 원인이 아니라 결과입니다 — limit 을 올리기 전에 RSS 가 우상향인지(누수) 톱니인지(정상)를 먼저 확인하세요. 누수라면 limit 을 올려도 며칠 뒤 다시 죽습니다.

실무에선 이렇게 — 페이지 캐시 이해하기 🔴 실무

# 파일을 읽으면 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 은 페이지 캐시도 센다
배포 직후 지연이 튀는 것은 정상입니다 — 대응은 워밍업입니다. 헬스체크를 통과하기 전에 주요 쿼리를 몇 번 돌리고, 카나리 배포로 트래픽을 서서히 올리세요 → 🏛️ SW 공학 11.
OS 07

동기화 — 뮤텍스 · 세마포어 · 조건 변수

여러 스레드가 같은 것을 동시에 건드리지 못하게 막는 장치들입니다. 무엇을 고르느냐보다 임계 구역을 얼마나 짧게 하느냐가 실제 성능을 결정합니다.

도구 고르기

도구용도
뮤텍스한 번에 하나만 — 가장 기본
스핀락아주 짧은 임계 구역 (커널 내부)
읽기-쓰기 락읽기가 압도적으로 많을 때
세마포어동시 접근 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()

락을 쓸 때의 규칙

  1. 임계 구역을 최소로 — I/O · 네트워크 호출을 락 안에 두지 마세요.
  2. 락 순서를 정하고 지킨다 — 데드락 예방의 가장 확실한 방법(08절).
  3. 락을 잡은 채 콜백을 부르지 않는다 — 사용자 코드가 무엇을 할지 모릅니다.
  4. RAII · with 로 해제 보장 — 예외 경로에서 새는 것을 막습니다.
  5. 가능하면 락 없는 설계 — 불변 데이터 · 스레드 로컬 · 메시지 전달.
✗ 락 안에서 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 을 쥐고 있나
파이썬에서는 락 경합보다 GIL 이 먼저 문제입니다 — CPU 작업은 스레드를 늘려도 병렬이 안 됩니다. 멀티프로세싱 · C 확장 · 3.13+ 의 free-threaded 빌드가 답입니다 → 🐍 Python 가이드.

실무에선 이렇게 — 우선순위 역전과 그 밖의 함정 🔴 실무

우선순위 역전 (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
가장 좋은 락은 없는 락입니다 — 데이터를 불변으로 만들거나, 소유권을 한 스레드에 두고 메시지로 주고받는 설계가 근본적으로 더 안전합니다 → 🕸️ 분산 · 동시성 05.
OS 08

데드락 — 네 조건 중 하나만 깨면 됩니다

서로가 서로를 기다려 영원히 멈추는 상태입니다. 네 조건이 동시에 성립할 때만 발생하므로, 하나만 무너뜨리면 구조적으로 예방됩니다.

스레드 A 스레드 B 락 1 락 2 보유 보유 A 는 락2 를 기다림 B 는 락1 을 기다림 — 순환 대기
화살표가 원을 이루는 순간 두 스레드는 영원히 진행하지 못합니다.

네 가지 필요 조건

조건내용깨는 법
상호 배제한 번에 하나만불변 데이터 · 락 프리
점유 대기가진 채로 기다림한 번에 전부 잡기
비선점뺏을 수 없음타임아웃 후 포기
순환 대기원형으로 기다림락 순서 고정 ★
실무에서 가장 실용적인 것은 “락 순서 고정”입니다 — 모든 락에 전역 순서를 정하고(예: 주소 순 · ID 순) 항상 그 순서로만 잡습니다. 코드 리뷰로 검증할 수 있고 런타임 비용이 없습니다.

코드로 보기

✗ 순서가 다름
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초 간격) — 같은 자리면 블로킹
“스레드 덤프를 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))
DB 데드락은 “버그”가 아니라 “정상 동작”입니다 — 동시성이 있으면 어느 정도는 발생합니다. 중요한 것은 빈도를 낮추고 재시도로 흡수하는 것이고, 데드락 발생률을 지표로 감시하는 것입니다 → 🗄️ Database 가이드.
데드락 — 네 조건이 전부 맞아야 생깁니다. 하나만 깨면 됩니다 ① 상호 배제 한 번에 하나만 씀 깨는 법: 읽기 전용 · 복사본 ② 점유하며 대기 가진 채로 더 기다림 깨는 법: 한 번에 전부 잡기 ③ 비선점 강제로 못 뺏음 깨는 법: 타임아웃 · 롤백 ④ 순환 대기 서로가 서로를 기다림 깨는 법: 잠금 순서 고정 ④ 순환 대기가 실제로 만들어지는 모습 T1 T2 A 를 쥐고 B 를 기다림 B 를 쥐고 A 를 기다림 잠금 순서를 하나로 정하면 항상 A 먼저, 그다음 B 원이 만들어질 수 없습니다 실무에서 가장 현실적인 대책은 ④ 잠금 순서 고정과 ③ 타임아웃입니다 — 코드 규칙 하나로 끝납니다.
데드락 대책을 고를 때는 “어느 조건을 깰 것인가”로 생각하세요 — 넷 중 하나만 무너뜨리면 됩니다.

실무에서 실제로 쓰는 두 가지

① 잠금 순서를 전역으로 고정

// ✗ 계좌 이체 — 데드락이 나는 전형적인 코드
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 가 같을 수 있으면(자기 자신에게 이체) 그 경우를 먼저 걸러 내세요
“잠금 순서”는 문서가 아니라 코드로 강제하세요. 주석으로 “A 먼저 잡을 것”이라고 적어 두면 언젠가 누군가 어깁니다. 위처럼 정렬해서 잠그는 헬퍼 함수를 만들어 그것만 쓰게 하는 편이 확실합니다.

② 타임아웃 — 못 잡으면 포기하고 되돌린다

// 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
라이브락데드락이 아닌데 진행이 없음 — 백오프에 지터를 넣으세요
DB 데드락은 “막는 것”이 아니라 “다시 시도하는 것”입니다. Postgres·MySQL 은 데드락을 스스로 감지해 한쪽을 죽입니다. 애플리케이션은 그 오류를 잡아 짧은 무작위 대기 후 재시도하면 됩니다. 빈도를 줄이려면 트랜잭션을 짧게 하고 같은 순서로 행을 접근하게 하세요 — 원리는 위와 같습니다.
OS 09

파일 시스템 — 쓴 것이 정말 저장됐을까

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
NFS 에서는 파일 락과 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 를 설정하세요.
OS 10

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
리눅스에서 일반 파일은 항상 “읽기 준비됨”으로 보고됩니다 — epoll 로 파일 I/O 를 비동기화할 수 없습니다. 그래서 Node.js 는 파일 작업을 스레드 풀(libuv)로 보냅니다. 이 한계를 없앤 것이 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_REUSEPORTNginx · 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 는 부모가 연 소켓 하나를 워커들이 물려받는 방식)
# 무중단 재시작에도 유용 — 새 프로세스가 같은 포트를 열고
# 옛 프로세스를 천천히 종료
실무 선택 기준 — 동시 연결이 수천 이하면 익숙한 모델(스레드 · 프레임워크 기본)이 낫습니다. 만 개를 넘어가거나 연결은 많은데 대부분 유휴(웹소켓 · 롱폴링)일 때 이벤트 기반이 결정적 차이를 만듭니다 → 🌐 서버기술 가이드.
I/O 모델 — 기다리는 방식의 차이가 서버 구조를 정합니다 블로킹 — 올 때까지 스레드가 멈춤 코드가 가장 읽기 쉽습니다. 대신 연결마다 스레드가 필요해 1만 연결이면 1만 스레드입니다 논블로킹 + 폴링 — 계속 물어보기 "준비됐어?" 를 반복해서 묻습니다. 안 멈추지만 CPU 를 그냥 태웁니다 select · poll — 여러 개를 한 번에 감시 한 번에 여러 소켓을 봅니다. 다만 호출할 때마다 전체 목록을 훑어 연결 수에 비례해 느려집니다 epoll · kqueue · IOCP — 준비된 것만 알려 줌 커널이 목록을 들고 있다가 준비된 것만 건네줍니다 — 연결이 수만 개여도 비용이 거의 안 늘어납니다 Node 의 이벤트 루프 · nginx · Netty · Go 런타임이 전부 마지막 방식 위에 서 있습니다 최근에는 io_uring 처럼 커널과 큐를 공유해 시스템 콜 자체를 줄이는 방식도 씁니다
"연결 1만 개를 어떻게 받나" 라는 질문의 답이 이 표의 마지막 줄입니다.
OS 11

시스템 콜 — 커널에게 부탁하는 유일한 창구

파일을 읽든 메모리를 늘리든 커널을 거쳐야 합니다. 그 전환에 비용이 있고, 그래서 호출 횟수를 줄이는 것이 성능 최적화의 큰 축이 됩니다.

라이브러리 함수 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강제 종료잡을 수 없음
SIGINTCtrl+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)
SIGTERM 을 처리하지 않으면 배포 때마다 요청이 끊깁니다 — 쿠버네티스는 SIGTERM 을 보내고 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 filesFD 한도 · FD 누수ls /proc/PID/fd | wc -l
Cannot allocate memory메모리 · vm.max_map_countsysctl vm.max_map_count
Resource temporarily unavailable프로세스·스레드 한도ulimit -u
Address already in useTIME_WAIT · 다른 프로세스ss -lntp
Permission denied권한 · SELinux · AppArmorausearch -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 누락
Elasticsearch 가 vm.max_map_count 를 요구하는 이유가 이 항목입니다 — mmap 으로 인덱스를 여는데 기본 한도(65530)로는 부족합니다. 설치 문서의 커널 파라미터 요구는 대부분 이런 이유가 있습니다.
OS 12

컨테이너 — 가상 머신이 아닙니다

컨테이너는 같은 커널 위에서 시야만 가린 프로세스입니다. 이 한 문장을 이해하면 컨테이너의 장점과 한계가 전부 유도됩니다.

두 가지 커널 기능의 조합

기능역할
네임스페이스“무엇을 볼 수 있나” — 격리
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 1exec 형식 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 등
셸 형식과 exec 형식의 차이가 무중단 배포를 가릅니다 — 셸 형식이면 /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          # 위험한 시스템 콜 차단
“컨테이너 = 보안 경계”라고 가정하지 마세요 — 커널을 공유하므로 커널 취약점 하나로 탈출이 가능합니다. 신뢰할 수 없는 코드(사용자 제출 코드 · 멀티테넌트)를 돌린다면 경량 VM 수준의 격리가 필요합니다 → 🔐 보안 · 암호 13.
OS 13

리눅스 실전 진단 — 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                    # ⑩ 전체 그림
이 순서에는 이유가 있습니다 — 넓은 것에서 좁은 것으로, 싼 명령에서 비싼 명령으로 갑니다. ③ 에서 r 이 높으면 CPU 쪽으로, b 가 높으면 I/O 쪽으로 갈라집니다.

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단일 스레드 · 인터럽트 편중
메모리 여유 있는데 OOMcgroup limit컨테이너 한도 · 단편화
가끔 수 초 멈춤GC 로그 · vmstat si/soGC STW · 스왑 · THP
Too many open files/proc/PID/fdFD 누수 · 한도
연결이 간헐적으로 거절ss -lnt Recv-Qaccept 큐 넘침 · 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

# ★ 장애가 지나가면 증거도 사라진다. 먼저 모으고 나중에 본다.
이 탭에서 이어지는 곳 — 네트워크 계층의 진단은 🌐 네트워크 13, 하드웨어 관점은 🖥️ 컴퓨터구조 13, 동시성 문제는 🕸️ 분산 · 동시성 02~04, 운영 체계로 만드는 법은 🏛️ SW 공학 12 에 있습니다.
OS 14

스케줄러 내부 — CFS 의 vruntime 에서 EEVDF 까지

03절이 “무엇을 먼저 실행할까”라는 개념이었다면, 여기서는 리눅스가 실제로 쓰는 방식을 봅니다. CFS 는 “누가 가장 적게 썼나”만 보고 고르며, 그 판단 기준이 vruntime 입니다. 6.6 부터 기본이 된 EEVDF 는 여기에 “언제까지 줘야 하나”를 더했습니다. 이 절의 실질적인 목적은 하나입니다 — 컨테이너의 CPU 스로틀링이 평균 사용률이 낮은데도 왜 p99 를 망가뜨리는지 설명할 수 있게 되는 것.

“공평하다”를 정의하는 문제입니다. 모두에게 같은 시간을 주는 것이 공평일까요, 아니면 중요도(nice · cpu.weight)에 비례해서 주는 것이 공평일까요. CFS 는 후자를 택했고, “비례해서 주고 있는가”를 재는 자가 vruntime 입니다.
CFS 는 “누가 가장 적게 썼나”만 봅니다 — 그 값이 vruntime 입니다 실행 큐 = vruntime 을 키로 한 레드블랙 트리 vr 12.4 vr 9.1 vr 15.8 vr 7.2 맨 왼쪽 = 다음 차례 nice 값 → 가중치 → 받는 몫 nice −5 가중치 3121 · 약 3배 nice 0 가중치 1024 · 기준 nice +5 가중치 335 · 약 1/3 내 몫 = 내 가중치 ÷ 전체 가중치 합 vruntime 은 실제 실행 시간에 가중치의 역수를 곱해 늘어납니다. 가중치가 크면 천천히 늘어나 더 오래 “맨 왼쪽”에 남고, 그만큼 자주 뽑힙니다. 고르는 비용은 맨 왼쪽 캐시라 O(1), 넣고 빼는 비용이 O(log n) 입니다. nice 한 단계마다 가중치가 약 1.25배 — 대략 CPU 몫 10% 차이입니다.
우선순위는 “먼저 실행”이 아니라 “시간이 천천히 흐른다”로 구현되어 있습니다 — 굶는 태스크가 생기지 않는 이유입니다.

지금 이 서버에서 직접 확인하기

# 이 프로세스가 어떤 정책으로 도는가
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    # 특정 코어에 고정 (캐시 지역성)
“CPU 사용률 60%인데 왜 느리죠” 에 답하는 자리입니다. 사용률이 아니라 wait_sum 과 vmstat 의 r 을 보세요 — 전자는 “기다린 시간”, 후자는 “줄 서 있는 수”라 평균 사용률이 숨기는 지연을 그대로 드러냅니다.

vruntime 계산과 몫의 관계

vruntime += Δ실행시간 × (1024 ÷ 가중치) nice 0 → 가중치 1024 → 실제 시간 그대로
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 — “얼마나”에 “언제까지”를 더하기

EEVDF 는 두 가지를 봅니다 — 자격(덜 받았나)과 마감(언제까지) ① lag — 받았어야 할 몫과의 차이 공평 몫 기준선 태스크 A lag > 0 — 덜 받음 → 자격 있음 태스크 B lag < 0 — 더 받음 → 잠시 대기 ② 가상 마감 — 급한 일을 먼저 요청 슬라이스 1ms — 대화형 → 마감이 가깝다 → 먼저 뽑힘 요청 슬라이스 10ms — 배치 → 마감이 멀다 → 나중에 마감 = 자격 시각 + 슬라이스 ÷ 가중치 자격 있는 태스크만 후보가 되므로 굶는 태스크가 원리상 생기지 않습니다. 후보 중 마감이 가장 이른 것을 고릅니다 — 짧은 슬라이스를 요청할수록 유리합니다. CFS 가 “목표 지연”이라는 한 개의 손잡이로 하던 일을 태스크별로 나눈 셈입니다. 리눅스 6.6 부터 기본입니다. 지연에 민감한 작업의 응답성이 좋아진 것이 핵심입니다.
CFS 는 “덜 쓴 사람”만 봤고, 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
“CPU 평균 40%인데 p99 가 튄다”의 진범이 대개 이것입니다. 쿼터는 100ms 주기 단위로 정산합니다. 스레드 8개가 첫 20ms 에 쿼터를 다 쓰면 남은 80ms 를 통째로 멈춰 있습니다 — 평균 사용률은 낮게 나오지만 그 요청은 80ms 를 그냥 잃습니다.

스로틀링을 없애는 순서

할 일이유
① 런타임에 한도 알리기스레드 수가 코어 수를 안 넘게
② 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
limit 을 아예 빼는 선택도 정당합니다. requests(= cpu.weight)만 두면 경합할 때만 비율대로 나누고 한가할 때는 마음껏 쓰게 됩니다 — CFS 의 몫 계산이 원래 그런 구조입니다. 다만 한 파드가 노드를 독점하는 사고가 가능해지므로 노드 단위 격리와 LimitRange 를 함께 두고 판단하세요.
이어지는 곳 — 스케줄링의 개념과 정책 비교는 03절, 스위치 비용의 실제 크기는 04절, cgroup 과 네임스페이스는 12절, 진단 명령은 13절, 캐시 지역성과 코어 이동 비용은 🖥️ 컴퓨터구조 07·14 에 있습니다. 다음 탭은 8 🌐 네트워크를 추천합니다.
주소창에 도메인을 치고 화면이 뜰 때까지 · 그리고 안 될 때

“연결이 안 됩니다”를
계층으로 나눠
좁히는 법

네트워크는 층으로 나눠 생각하면 갑자기 다룰 만해집니다. 이 탭은 IP · TCP · DNS · HTTP · TLS 를 각각 “무엇을 보장하고 무엇은 안 하는가”로 정리하고, 서브넷 계산기와 진단 명령 순서까지 함께 담았습니다. 지연의 물리적 한계를 다루는 절이 특히 설계에 직접 쓰입니다.

13SECTIONS
계산기내장
HTTP/3까지
진단명령 순서
NET 01

OSI 7계층 · TCP/IP — 문제를 층으로 나누기

계층 모델의 진짜 가치는 암기가 아니라 문제 위치를 좁히는 도구라는 데 있습니다. “안 된다”는 신고를 받았을 때 어느 층인지부터 정하면 절반이 끝납니다.

L7 응용
HTTP · gRPC · DNS · TLS진단: curl -v · 브라우저 개발자 도구 · 앱 로그
▼ 캡슐화 (헤더가 하나씩 붙는다)
L4 전송
TCP · UDP · QUIC진단: ss · telnet · nc · 재전송률
▼
L3 네트워크
IP · ICMP · 라우팅진단: ping · traceroute · ip route
▼
L2 데이터링크
이더넷 · MAC · ARP · 스위치진단: ip neigh · arping
▼
L1 물리
케이블 · 광 · 무선진단: ethtool · 링크 상태 · 에러 카운터

캡슐화 — 헤더가 겹겹이

애플리케이션 데이터
  [HTTP 헤더][본문]
      ↓ TCP 가 감싼다
  [TCP 헤더][HTTP 헤더][본문]        ← 세그먼트
      ↓ IP 가 감싼다
  [IP 헤더][TCP 헤더][...]           ← 패킷
      ↓ 이더넷이 감싼다
  [이더넷 헤더][IP 헤더][...][FCS]    ← 프레임

받는 쪽은 반대로 하나씩 벗긴다

오버헤드
  이더넷 14B + IP 20B + TCP 20B = 54B
  MTU 1500B 기준 → 실제 데이터 최대 1460B (MSS)
  → 작은 요청이 많으면 헤더 비중이 커진다

“안 된다”를 층으로 가르기

  1. 물리 · 링크 — ip link 로 UP 인지, ethtool 로 에러가 있는지
  2. 네트워크(L3) — ping 이 가나? 라우팅 테이블이 맞나?
  3. 전송(L4) — nc -zv host port 로 포트가 열려 있나?
  4. 이름 — dig 로 DNS 가 맞게 나오나?
  5. 보안 — 방화벽 · 보안 그룹 · SELinux
  6. 응용(L7) — curl -v 로 응답이 오나? 상태 코드는?
순서가 핵심입니다 — 아래층이 안 되는데 위층을 봐야 시간만 갑니다. 반대로 ping 은 되는데 포트가 안 열린다면 L3 는 정상이고 L4/방화벽 문제로 즉시 좁혀집니다.

이렇게도 쓸 수 있다 — 계층이 깨지는 자리 🟡 중급

현실계층 위반
NATL3 장비가 L4 포트를 고쳐 씀
L7 로드밸런서HTTP 헤더를 보고 라우팅
TLS 종료중간 장비가 암호를 풀어 봄
MTU · 단편화L2 크기가 L4 성능을 좌우
QUICL4 를 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
“ICMP 는 위험하니 다 막자”가 만드는 사고입니다 — Path MTU Discovery 가 ICMP 를 쓰기 때문에 전면 차단하면 큰 패킷만 조용히 사라집니다. 최소한 fragmentation-needed 타입은 허용해야 합니다.

실무에선 이렇게 — 한 번의 요청에서 벌어지는 일 🔴 실무

브라우저에 주소를 치면

  1. DNS 조회 — 캐시 → 리졸버 → 루트 → TLD → 권한 서버 (07절)
  2. TCP 연결 — 3-way 핸드셰이크 (1 RTT) (04절)
  3. TLS 핸드셰이크 — 인증서 검증 · 키 교환 (1~2 RTT) (09절)
  4. HTTP 요청 · 응답 — 서버 처리 시간 + 1 RTT (08절)
  5. 렌더링 — 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 인지 · 연결인지 · 서버 처리인지가 즉시 갈립니다. 대시보드에도 이 분해를 넣어 두면 좋습니다.
보낼 때 감싸고, 받을 때 벗깁니다 — 이것이 계층의 전부 응용 — HTTP · DNS · TLS 내가 보내려는 내용 전송 — TCP · UDP TCP 위층 내용 그대로 인터넷 — IP IP TCP 위층 내용 그대로 링크 — 이더넷 · 와이파이 MAC IP TCP 위층 내용 그대로 각 층은 자기 머리말만 봅니다 — 위층이 무엇을 담았는지 신경 쓰지 않습니다 그래서 유선을 무선으로 바꿔도 HTTP 코드를 고칠 필요가 없습니다. 이것이 계층을 나눈 이유입니다
문제를 진단할 때도 같습니다 — 어느 층에서 끊겼는지부터 가르면 범위가 확 줄어듭니다.
NET 02

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/81600만대규모 사내망 · VPC
172.16.0.0/12100만도커 기본
192.168.0.0/1665536가정 · 소규모
127.0.0.0/8—루프백
169.254.0.0/16—링크로컬 · 클라우드 메타데이터
100.64.0.0/10400만캐리어 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 와 듀얼 스택 🔴 실무

항목IPv4IPv6
주소 길이32비트128비트
표기192.168.0.12001: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 로는 되는데 127.0.0.1 로는 안 된다”(또는 반대)는 대부분 이 문제입니다 — localhost 가 ::1 로 풀리는데 서버는 IPv4 만 리슨하고 있는 것입니다. 0.0.0.0 vs :: 바인딩을 확인하세요.
주소는 '어느 망'과 '그 안의 누구'로 나뉩니다 192.168.0. 50 /24 → 앞 24비트가 망 같은 /24 안이면 직접, 다르면 게이트웨이로 192.168.0.50 → 192.168.0.31 같은 망 · 바로 전달 192.168.0.50 → 8.8.8.8 다른 망 · 게이트웨이로 NAT 변환 192.168.0.50:51000 → 203.0.113.45:41xxx 서브넷 마스크가 하는 일 /24 → 254대 · /16 → 6만대 · /30 → 2대 망을 잘게 나누면 브로드캐스트와 사고 범위가 줄어듭니다 NAT 의 성질 안에서 밖으로는 자동, 밖에서 안으로는 못 들어옵니다 그래서 포트포워딩이 필요합니다(server 탭 v06·v07)
"같은 망인가"를 판단하는 것이 모든 통신의 첫 단계입니다.
주소는 '어느 망'과 '그 안의 누구'로 나뉩니다 192.168.0. 50 /24 → 앞 24비트가 망 같은 /24 안이면 직접, 다르면 게이트웨이로 192.168.0.50 → 192.168.0.31 같은 망 · 바로 전달 192.168.0.50 → 8.8.8.8 다른 망 · 게이트웨이로 NAT 변환 192.168.0.50:51000 → 203.0.113.45:41xxx 서브넷 마스크가 하는 일 /24 → 254대 · /16 → 6만대 · /30 → 2대 망을 잘게 나누면 브로드캐스트와 사고 범위가 줄어듭니다 NAT 의 성질 안에서 밖으로는 자동, 밖에서 안으로는 못 들어옵니다 그래서 포트포워딩이 필요합니다(server 탭 v06·v07)
"같은 망인가"를 판단하는 것이 모든 통신의 첫 단계입니다.
NET 03

이더넷 · 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, 한쪽 halfethtool 로 확인 · 자동 협상
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"
클라우드에서는 L2 를 거의 못 봅니다 — 가상 네트워크라 ARP · 스위치 개념이 추상화돼 있습니다. 대신 보안 그룹 · 네트워크 ACL · 라우트 테이블이 같은 역할의 문제를 만듭니다.

실무에선 이렇게 — 컨테이너 네트워크 🔴 실무

도커 기본 브리지 네트워크

  컨테이너 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   # 진단 도구 모음
MTU 문제가 오버레이 네트워크에서 자주 납니다 — VXLAN 헤더 50바이트 때문에 실제 MTU 가 1450 인데 파드가 1500 으로 보내면 큰 응답만 멈춥니다. “작은 요청은 되는데 큰 응답이 안 온다”면 MTU 를 의심하세요.
NET 04

TCP — 신뢰성을 만드는 방법

IP 는 “보내기만” 합니다 — 순서도 도착도 보장하지 않습니다. 그 위에 확인 응답과 재전송을 얹어 신뢰성을 만든 것이 TCP 이고, 그 대가가 왕복 시간입니다.

클라이언트 서버 ① SYN (seq=x) ② SYN + ACK (seq=y, ack=x+1) ③ ACK (ack=y+1) → 여기서 데이터 전송 가능 연결 수립에 1 RTT 가 필요하다 (③ 과 데이터를 함께 보낼 수 있음)
TLS 까지 하면 추가로 1~2 RTT — TCP 와 합쳐 2~3 RTT, 서울-미국(RTT ≈ 150ms)이면 연결에만 300~450ms 입니다.

TCP 가 보장하는 것

보장방법
도착 보장ACK + 재전송
순서 보장시퀀스 번호로 재정렬
중복 제거시퀀스 번호
오류 검출체크섬
흐름 제어수신 윈도우 (05절)
혼잡 제어혼잡 윈도우 (05절)
TCP 가 보장하지 않는 것 — 애플리케이션이 처리했다는 보장입니다. 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_SENTSYN 보내고 응답 대기상대가 응답 안 함 — 방화벽 의심
SYN_RECVSYN 받고 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_WAIT 은 거의 항상 내 코드의 버그입니다 — 상대가 연결을 닫았는데 내가 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)
타임아웃 없는 요청은 장애를 전파합니다 — 하나가 느려지면 스레드·커넥션이 전부 묶여 전체 서비스가 멈춥니다. 모든 외부 호출에 타임아웃을 두는 것이 기본이고, 그 위에 서킷 브레이커를 얹습니다 → 🕸️ 분산 13.
연결 수립 3-way — 데이터를 보내기 전에 왕복 한 번이 필요합니다 클라이언트 서버 ① SYN — 내 시작 번호 x ② SYN-ACK — 내 번호 y, x+1 받았음 ③ ACK — y+1 받았음 (여기서 연결 성립) 데이터 전송 시작 — 실제 요청은 이제부터 SYN 큐 (반연결) accept 큐 RTT 가 30ms 면 첫 바이트를 보내기까지 이미 30ms 를 씁니다 — HTTPS 면 TLS 왕복이 더 붙습니다. 그래서 keepalive 로 연결을 재사용하는 것이 성능에 크게 작용합니다. 종료는 4-way 입니다 — 양쪽이 각자 FIN 을 보내고 각자 ACK 를 받습니다(한 방향씩 닫힘). 먼저 닫은 쪽은 TIME_WAIT 로 2×MSL(보통 60초) 남습니다 — 늦게 온 패킷이 다음 연결을 오염시키지 않도록. 서버에 TIME_WAIT 이 수만 개면, 서버가 먼저 끊고 있다는 뜻입니다(대개 keepalive 미설정).
연결 하나를 세우는 데 왕복 한 번이 듭니다 — 이 비용이 연결 재사용을 성능 최적화 1순위로 만듭니다.

상태를 읽으면 원인이 보입니다

소켓 상태별 진단

상태가 많다면원인
SYN_RECVSYN 홍수 공격 · 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 환경을 깨뜨려 커널에서 제거됐습니다
연결 수 한계 = 포트 범위 ÷ TIME_WAIT 시간 포트 28,000개 ÷ 60초 = 초당 약 466 연결
범위를 55,000개로 넓히면 초당 약 916 연결

그 이상이 필요하면 연결을 재사용하는 수밖에 없습니다 — 이것이 HTTP keepalive · 커넥션 풀이 존재하는 이유입니다
“누가 먼저 끊는가”를 설계로 정하세요. 클라이언트가 먼저 끊게 하면 TIME_WAIT 이 클라이언트에 흩어져 서버에는 부담이 없습니다. HTTP 에서 서버가 Connection: close 를 보내면 반대가 됩니다 — 서버 쪽 keepalive 를 켜 두는 것이 이 문제를 함께 해결합니다.
NET 05

흐름 제어 · 혼잡 제어 — 얼마나 빨리 보낼까

받는 쪽이 감당할 만큼(흐름 제어), 그리고 길이 막히지 않을 만큼(혼잡 제어) 속도를 조절합니다. 대역폭이 충분한데도 느린 이유가 대개 여기 있습니다.

두 개의 윈도우

실제 전송량 = min(수신 윈도우, 혼잡 윈도우)
흐름 제어혼잡 제어
보호 대상수신자네트워크
신호수신 윈도우 광고패킷 손실 · 지연
제어 주체수신자가 알려 줌송신자가 추정
혼잡 제어의 기본 흐름 (Reno 계열)

  슬로우 스타트: 1 → 2 → 4 → 8 ... (RTT 마다 2배)
       ↓ 임계값 도달
  혼잡 회피:    1씩 증가 (선형)
       ↓ 손실 감지
  빠른 회복:    윈도우를 절반으로 → 다시 선형 증가

★ 슬로우 스타트가 '느리게 시작' 이라는 뜻이다
  → 짧은 연결은 최대 속도에 도달하기 전에 끝난다
  → 연결 재사용이 중요한 이유

대역폭 지연 곱 (BDP)

BDP = 대역폭 × RTT “전송 중인 데이터”가 얼마나 될 수 있는가 = 필요한 윈도우 크기
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"
BBR 이 효과를 보는 상황 — 손실이 있는 장거리 회선 (해외 · 모바일 · 위성)에서 CUBIC 대비 처리량이 크게 오릅니다. 국내 데이터센터 내부처럼 손실이 거의 없는 환경에서는 차이가 적습니다.

실무에선 이렇게 — 나글 알고리즘과 지연 🔴 실무

나글 알고리즘 (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
“40ms 의 규칙성”이 보이면 거의 확실합니다 — 네트워크 지연은 보통 불규칙한데, 정확히 40ms 나 200ms 단위로 끊기면 지연 ACK 와의 상호작용입니다.
NET 06

UDP · QUIC — TCP 를 버리고 다시 만들기

UDP 는 확인 없이 그냥 보냅니다. 그 위에 필요한 것만 직접 얹는 방식이고, 그 결과물이 QUIC(HTTP/3) 입니다.

UDP 를 고르는 이유

용도왜 UDP 인가
DNS한 번의 왕복 — 핸드셰이크가 낭비
실시간 음성·영상늦은 데이터는 쓸모없음 — 재전송 불필요
게임최신 상태만 중요 · 지연 최소화
메트릭 전송 (StatsD)일부 손실 허용 · 오버헤드 최소
NTP단발성
QUICTCP 의 제약을 우회하려고
UDP 는 증폭 공격에 악용됩니다 — 출발지를 위조해 작은 요청을 보내면 큰 응답이 피해자에게 갑니다(DNS · NTP · memcached). UDP 서비스를 공개할 때는 응답 크기 제한과 레이트 리밋이 필수입니다.

QUIC 이 해결한 것

TCP 의 문제QUIC 의 답
연결에 1 RTT + TLS 1~2 RTT1 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)
직접 만들기 전에 라이브러리를 보세요 — ENet · KCP · Laminar · QUIC 구현체가 이미 있습니다. 재전송 · 혼잡 제어 · 순서 관리를 처음부터 제대로 만드는 것은 생각보다 훨씬 어렵습니다 → 🟣 C# · Unity 가이드의 게임 서버 탭.

실무에선 이렇게 — 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 으로 전환
도입은 대개 CDN 에서 켜는 것으로 충분합니다 — Cloudflare · CloudFront 등이 HTTP/3 을 지원하므로 오리진은 그대로 두고 엣지에서만 켜면 대부분의 이득을 얻습니다.
NET 07

DNS — 이름을 주소로 바꾸는 전 과정

도메인 하나를 푸는 데 여러 대의 서버가 관여합니다. 캐시와 TTL 때문에 “변경했는데 반영이 안 된다”가 자주 생기고, 그 구조를 알면 예측이 가능해집니다.

클라이언트 리졸버 (캐시 보유) 루트 서버 .com TLD 권한 서버 "com 은 저기로" "example.com 은 저기로" "93.184.216.34 입니다" 캐시에 있으면 이 과정이 전부 생략된다 — 그래서 TTL 이 중요하다
리졸버가 재귀적으로 물어보고, 클라이언트는 최종 답만 받습니다.

레코드 종류

타입내용메모
AIPv4 주소가장 기본
AAAAIPv6 주소
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 를 장애 조치 수단으로 쓰지 마세요 — TTL 을 60초로 해도 일부 클라이언트는 몇 시간씩 옛 주소를 씁니다. 빠른 전환이 필요하면 로드밸런서 · 애니캐스트 IP 를 쓰고, DNS 는 그 앞단을 가리키게 두세요.

실무에선 이렇게 — 애플리케이션에서의 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."
NodeLocal DNSCache 를 쓰면 노드마다 캐시를 두어 CoreDNS 부하와 지연을 크게 줄일 수 있습니다. 쿠버네티스 클러스터가 커지면 DNS 가 병목이 되는 일이 흔합니다.
NET 08

HTTP/1.1 · 2 · 3 — 무엇이 달라졌나

버전마다 해결한 문제와 남긴 문제가 있습니다. 그 차이를 알면 “번들을 쪼갤까 합칠까” 같은 실무 판단이 근거를 갖게 됩니다.

세대별 변화

1.123
전송TCPTCPQUIC/UDP
포맷텍스트바이너리바이너리
다중화✗ (연결 6개)✓✓ (독립)
헤더 압축✗HPACKQPACK
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.1HTTP/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개 이하로. 많으면 오히려 경쟁이 생긴다 -->
가장 큰 효과는 여전히 “보내는 양을 줄이는 것”입니다 — 이미지 최적화(WebP · AVIF) · 코드 분할 · 불필요한 폰트 제거가 프로토콜 튜닝보다 훨씬 큰 차이를 만듭니다. 프론트엔드 관점은 🟨 JS · TS 가이드에 있습니다.
한 연결에서 몇 개를 동시에 다루느냐가 차이입니다 HTTP/1.1 — 하나씩 순서대로 요청 A (느림) B 대기 C 대기 앞이 막으면 뒤가 전부 밀립니다 HTTP/2 — 한 연결에 여러 스트림 A B C 동시에 흐릅니다 · 헤더도 압축합니다 단, TCP 패킷 하나가 유실되면 전부 멈춥니다 HTTP/3 — TCP 대신 QUIC(UDP 기반) 스트림마다 독립적이라 하나가 막혀도 나머지는 흐릅니다 실무에서 알아 둘 것 2·3 에서는 파일 합치기·도메인 쪼개기 최적화가 불필요해집니다
버전 차이는 기능이 아니라 동시에 흐를 수 있느냐의 문제입니다.
한 연결에서 몇 개를 동시에 다루느냐가 차이입니다 HTTP/1.1 — 하나씩 순서대로 요청 A (느림) B 대기 C 대기 앞이 막으면 뒤가 전부 밀립니다 HTTP/2 — 한 연결에 여러 스트림 A B C 동시에 흐릅니다 · 헤더도 압축합니다 단, TCP 패킷 하나가 유실되면 전부 멈춥니다 HTTP/3 — TCP 대신 QUIC(UDP 기반) 스트림마다 독립적이라 하나가 막혀도 나머지는 흐릅니다 실무에서 알아 둘 것 2·3 에서는 파일 합치기·도메인 쪼개기 최적화가 불필요해집니다
버전 차이는 기능이 아니라 동시에 흐를 수 있느냐의 문제입니다.
NET 09

TLS 핸드셰이크 — 상대가 진짜인지 확인하고 열쇠 만들기

HTTPS 의 s 가 하는 일을 단계별로 봅니다. 인증서 오류를 해석하고 왜 이렇게 여러 왕복이 필요한지를 알면 성능과 보안 판단을 함께 할 수 있습니다.

TLS 1.3 핸드셰이크 (1 RTT)

  1. ClientHello — 지원하는 암호 목록 + 키 공유 후보 + SNI(어느 도메인인지)
  2. ServerHello — 고른 암호 + 서버 키 공유 + 인증서 + 서명
  3. 클라이언트 검증 — 인증서 체인 · 유효기간 · 도메인 일치 · 폐기 여부
  4. 양쪽이 같은 세션 키 를 계산 (디피-헬만) — 키 자체는 오간 적이 없음
  5. Finished — 이후 모든 데이터는 대칭키로 암호화
TLS 1.2 는 2 RTT, 1.3 은 1 RTT 입니다. 재연결 시 0-RTT 도 가능하지만 재전송 공격 위험이 있어 멱등한 요청에만 써야 합니다.

직접 보기

# 전체 핸드셰이크 과정
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 mismatchSAN 에 도메인 없음subjectAltName 확인
self signed certificate사설 CACA 를 신뢰 저장소에 추가
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 로 검증
인증서 만료는 예고된 장애입니다 — 만료 30일 전 알림을 모니터링에 반드시 넣으세요. 자동 갱신(certbot)을 쓰더라도 갱신 후 서비스 재로드가 안 되는 경우가 흔합니다.

실무에선 이렇게 — 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초) 시작해서 문제없음을 확인한 뒤 늘릴 것
TLS 종료 위치가 설계 결정입니다 — 엣지에서 종료하면 빠르지만 엣지~오리진 구간이 평문이 될 수 있습니다. 내부망도 신뢰하지 않는 제로 트러스트에서는 종단 간 TLS 를 씁니다 → 🔐 보안 · 암호 07.
TLS 핸드셰이크 — 진짜인지 확인하고 열쇠를 만듭니다 1 클라이언트 → 서버 : 내가 쓸 수 있는 방식들 TLS 버전 · 암호 방식 목록 · 난수 · 접속하려는 도메인 이름 2 서버 → 클라이언트 : 고른 방식 + 인증서 인증서 안에는 서버의 공개키와 도메인, 그리고 인증기관의 서명이 들어 있습니다 3 클라이언트 : 인증서 검증 신뢰하는 기관의 서명인가 · 도메인이 맞는가 · 기간이 지나지 않았는가 · 폐기되지 않았는가 4 양쪽이 같은 대칭키를 만듭니다 키 교환으로 둘만 아는 값을 만듭니다 — 이후 통신은 이 대칭키로 암호화합니다 공개키는 열쇠를 만들 때만 씁니다 — 실제 데이터는 훨씬 빠른 대칭키로 암호화합니다 TLS 1.3 은 왕복을 한 번으로 줄였습니다 — 재접속이라면 왕복 없이 바로 시작할 수도 있습니다 검증 실패 경고를 무시하도록 코드에 넣지 마세요 — 그 순간 암호화만 남고 신원 확인이 사라집니다
HTTPS 가 지키는 것은 암호화만이 아니라 상대가 그 도메인이 맞다는 확인입니다.
NET 10

소켓 · 포트 — 커널이 만든 두 개의 대기 줄

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 useTIME_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 쿠키 확인
#  ④ 큐 크기 조정
#  ★ 서버 하나로 대규모 공격을 막을 수는 없다
대부분의 DDoS 방어는 앞단에서 합니다 — 대역폭을 채우는 공격은 서버에 도착하기 전에 막아야 하기 때문입니다. 서버 설정은 소규모 공격과 오작동 클라이언트에 대한 방어선입니다.
NET 11

라우팅 · 방화벽 · 로드밸런서 — 트래픽이 지나는 관문들

요청이 서버에 닿기까지 여러 장비를 지납니다. 각 지점에서 무엇을 보고 무엇을 할 수 있는지를 알면 “어디서 막혔나”를 훨씬 빨리 찾습니다.

라우팅 — 다음 홉 고르기

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
“응답이 안 온다”와 “연결이 거부된다”는 다릅니다 — 거부(RST) 면 방화벽이 명시적으로 막았거나 서비스가 없는 것이고, 무응답(타임아웃) 이면 패킷이 조용히 버려진 것입니다(DROP). 이 구분이 원인 추적을 크게 좁힙니다.

이렇게도 쓸 수 있다 — 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 가 잠깐 느리다고 모든 인스턴스가 빠지면 전면 장애)
헬스 체크 설계가 의외로 위험합니다 — “DB 연결 확인”을 넣었는데 DB 가 잠깐 느려지면 모든 인스턴스가 동시에 unhealthy 가 되어 서비스가 전부 내려갑니다. liveness 와 readiness 를 구분하고, liveness 는 아주 가볍게 두세요.

실무에선 이렇게 — 프록시 뒤에서의 실제 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 생성 오류 · 캐시 오염
“IP 로 차단했는데 계속 들어온다”의 원인이 대부분 이것입니다 — 공격자가 X-Forwarded-For: 1.1.1.1 을 직접 붙여 보냅니다. 신뢰 프록시 설정 없이 XFF 를 쓰면 안 됩니다 → 🌐 서버기술 가이드.
NET 12

지연 · 대역폭 · RTT — 물리 법칙은 못 이깁니다

대역폭은 돈으로 살 수 있지만 빛의 속도는 못 삽니다. 이 절의 계산이 아키텍처 결정에 직접 쓰입니다 — 왕복을 줄일 것인가, 가까이 갈 것인가.

물리적 하한

광섬유 속 빛 ≈ 200,000 km/s (진공의 2/3) 서울-샌프란시스코 9,000km → 편도 45ms, 왕복 90ms (이론상 최소)
구간실제 RTT
같은 서버 내 (루프백)< 0.1ms
같은 랙0.1~0.3ms
같은 데이터센터0.5ms
같은 리전 다른 AZ1~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 · BFFN번 호출 → 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)
“호출 그래프의 깊이”가 지연을 결정합니다 — 넓게(병렬) 부르는 것은 괜찮지만 깊게(순차) 부르면 RTT 가 곱해집니다. 마이크로서비스 설계에서 호출 사슬을 3단계 이하로 유지하는 것이 일반적인 지침입니다.

실무에선 이렇게 — 지연 예산 세우기 🔴 실무

목표: 사용자 체감 응답 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()
“헤지 요청”이 꼬리 지연에 특히 효과적입니다 — p95 시간이 지나도 응답이 없으면 두 번째 요청을 병렬로 보내고 먼저 오는 것을 씁니다. 부하는 5% 정도 늘지만 p99 가 크게 개선됩니다 → 🕸️ 분산 13.
NET 13

네트워크 진단 — 명령 순서

“접속이 안 된다”는 신고를 받았을 때 아래에서 위로 올라가며 확인합니다. 각 단계에서 성공하면 그 아래층은 정상입니다.

단계별 확인

  1. 이름이 풀리나 — dig +short example.com
  2. IP 에 도달하나 — ping -c 3 1.2.3.4 (ICMP 차단 가능성 유의)
  3. 경로는 어디까지 — mtr -n -c 20 1.2.3.4
  4. 포트가 열렸나 — nc -zv 1.2.3.4 443
  5. TLS 가 되나 — openssl s_client -connect 1.2.3.4:443
  6. 응답이 오나 — curl -v https://example.com
  7. 어디가 느린가 — curl -w 로 단계별 시간 (01절)
  8. 패킷을 직접 — tcpdump · Wireshark
2번에서 실패해도 포기하지 마세요 — 많은 서버가 ICMP 를 막습니다. ping 이 안 돼도 포트는 열릴 수 있으니 4번을 반드시 해 보세요.

자주 쓰는 명령 모음

# 이름 · 경로
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 MTUICMP 차단 · 터널
느린데 오류는 없음재전송률 · 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
“가끔”이라는 말이 나오면 항상 이것부터 의심하세요 — DNS 가 여러 IP 를 반환하거나 LB 뒤에 여러 인스턴스가 있는데 그중 하나만 문제인 경우가 압도적으로 많습니다.

실무에선 이렇게 — 이어지는 곳 🔴 실무

여기서 배운 것다음
04·05 TCP · 혼잡 제어⚙️ 운영체제 10 I/O 모델 · 소켓
08 HTTP · 캐시🌐 서버기술 가이드 · 🟨 JS·TS 가이드
09 TLS🔐 보안 · 암호 06·07
11 로드밸런서 · 프록시🌐 서버기술 가이드 로드밸런싱
12 지연 예산🕸️ 분산 · 동시성 13 타임아웃 · 재시도
13 진단⚙️ 운영체제 13 · 🏛️ SW 공학 12 관측 가능성
다음 탭 추천 — 9 🔧 컴파일러 · 언어이론으로 가면 “내가 쓴 코드가 어떻게 실행 가능한 것이 되는가”를 봅니다. 네트워크와는 결이 다르지만, 런타임 · GC · JIT 는 성능 문제를 볼 때 반드시 만나는 층입니다.
내가 쓴 글자가 기계가 도는 일이 되기까지 · 그리고 그 사이의 모든 층

빌드 에러가
어디서 났는지
알게 됩니다

컴파일러를 만들 일은 없어도 컴파일러가 무엇을 하는지는 매일 마주칩니다 — 빌드 에러 · 링크 에러 · 최적화 옵션 · GC 정지 · JIT 워밍업 · 타입 검사. 이 탭은 소스에서 실행까지를 단계별로 따라가고, 각 단계가 우리 코드에 어떤 얼굴로 나타나는지까지 이어갑니다.

13SECTIONS
GC· JIT 포함
타입이론까지
실습미니 언어
LANG 01

소스가 실행되기까지 — 전체 그림

gcc main.c 한 줄 안에서 네 개의 프로그램이 차례로 돕니다. 어느 단계에서 에러가 났는지 구분하는 것만으로도 디버깅 시간이 크게 줄어듭니다.

1
전처리기#include 붙여넣기 · 매크로 치환 · 조건부 컴파일 → gcc -E
▼
2
컴파일러렉서 → 파서 → 의미 분석 → 최적화 → 어셈블리 생성 → gcc -S
▼
3
어셈블러어셈블리 → 기계어 오브젝트 파일(.o) → gcc -c
▼
4
링커여러 .o 와 라이브러리를 합쳐 실행 파일로 → 심볼 해석 · 재배치
▼
5
로더실행 시 메모리에 올리고 동적 라이브러리를 연결 · ASLR 적용

에러 메시지로 단계 구분하기

메시지단계원인
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  # 중간 표현
전처리 결과를 한 번 열어 보면 놀랍습니다 — C 의 #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 로 바꾸면 수 배)
빌드 시간은 개발 속도에 직접 영향을 줍니다 — 10분 빌드는 하루에 시도할 수 있는 실험 횟수를 결정합니다. 측정 → 상위 3개 파일 개선 만으로도 대개 절반이 줄어듭니다.
gcc main.c 한 줄 안에서 벌어지는 일 — 오류가 어느 단계에서 났는지가 진단의 시작입니다 main.c 소스 전처리기 #include · 매크로 컴파일러 앞단(front end) 렉서 → 파서(AST) → 의미 분석 중간 표현(IR) 최적화가 여기서 어셈블리 .s 사람용 기계어 어셈블러 .o 목적 파일 링커 .o + 라이브러리 묶기 a.out 실행 파일 로더 실행 시 오류가 나면 — 어느 단계인지부터 확인하세요. 메시지의 모양이 단계마다 완전히 다릅니다. "expected ';'" → 파서·"undeclared identifier" → 의미 분석·"undefined reference" → 링커·"cannot open .so" → 로더 앞단은 언어마다 다르지만 IR 뒤는 공유됩니다 — LLVM 이 여러 언어를 지원하는 구조가 이것입니다. 인터프리터·JIT 는 어셈블리로 내려가는 대신 IR 을 그때그때 실행하거나 실행 중에 기계어로 바꿉니다.
에러 메시지가 어느 상자에서 나왔는지만 알아도 원인 후보가 열 개에서 두세 개로 줄어듭니다.

단계별로 직접 멈춰 보기

각 단계의 결과물을 눈으로

# ① 전처리만 — #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 identifier
incompatible 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
“파서 오류는 대개 그 윗줄이 원인”입니다. 컴파일러는 세미콜론이 없다는 것을 다음 줄에 가서야 알아챕니다. 그래서 보고된 줄이 아니라 그 위를 먼저 보세요. 오류가 수십 개 쏟아질 때도 맨 위 하나만 고치고 다시 컴파일하는 것이 가장 빠릅니다 — 나머지는 대개 그 하나에서 파생된 것입니다.
LANG 02

어휘 분석 — 글자를 낱말로 자르기

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))

# 좋은 에러 메시지의 세 요소
#  ① 정확한 위치 (줄 · 열)
#  ② 무엇을 기대했는지
#  ③ 어떻게 고치면 되는지 (힌트)
Rust · Elm 의 에러 메시지가 칭찬받는 이유가 이 셋을 지키기 때문입니다. 사용자가 쓰는 설정 파일 · DSL · API 요청 검증에도 같은 원칙을 적용하면 지원 문의가 크게 줄어듭니다.
LANG 03

구문 분석 — 토큰을 나무로

토큰 나열을 구조가 있는 트리(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 + 데이터 흐름
대규모 리팩터링을 정규식으로 하지 마세요 — “함수 인자 순서 바꾸기”, “API 이름 변경”은 codemod 로 하면 안전하고 빠릅니다. 수백 파일을 한 번에, 오탐 없이 고칠 수 있습니다.

실무에선 이렇게 — 에러 복구 🔴 실무

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)                     # 트리에는 남긴다

# 여러 에러를 한 번에 보고할 수 있게 된다
# → 사용자가 빌드를 여러 번 돌리지 않아도 된다
“에러를 하나만 보여 주는 컴파일러”가 답답한 이유가 이것입니다. 다만 연쇄 에러(첫 에러 때문에 생긴 가짜 에러)를 걸러 내는 것도 중요해서, 좋은 컴파일러는 첫 몇 개만 보여 주고 나머지는 요약합니다.
우선순위가 '트리의 모양'으로 표현됩니다 1 + 2 * 3 파싱 + 1 * 2 3 아래에서 위로 계산 2*3 → 6, 1+6 → 7 우선순위를 문법으로 적습니다 expr := term (+ term)* · term := factor (* factor)* 이 계층이 곧 트리의 깊이가 됩니다 파싱 방식 두 갈래 재귀 하향 — 손으로 짜기 쉽고 오류 메시지가 좋습니다 상향(LR) — 표현력이 크고 도구가 생성해 줍니다
컴파일러·SQL 엔진·템플릿 엔진이 전부 이 트리 위에서 동작합니다.
우선순위가 '트리의 모양'으로 표현됩니다 1 + 2 * 3 파싱 + 1 * 2 3 아래에서 위로 계산 2*3 → 6, 1+6 → 7 우선순위를 문법으로 적습니다 expr := term (+ term)* · term := factor (* factor)* 이 계층이 곧 트리의 깊이가 됩니다 파싱 방식 두 갈래 재귀 하향 — 손으로 짜기 쉽고 오류 메시지가 좋습니다 상향(LR) — 표현력이 크고 도구가 생성해 줍니다
컴파일러·SQL 엔진·템플릿 엔진이 전부 이 트리 위에서 동작합니다.
LANG 04

의미 분석 — 이름과 타입을 확인하는 단계

문법이 맞아도 말이 되는지는 다른 문제입니다. “선언되지 않은 변수”, “타입 불일치”가 여기서 잡히고, 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
메모리 부족언어 서버 힙 상향
“타입이 너무 복잡해서 IDE 가 멈춘다”는 TypeScript 에서 실제로 흔합니다. tsc --extendedDiagnostics 로 타입 인스턴스화 수를 확인하고, 가장 비싼 타입 몇 개를 단순화하면 극적으로 개선됩니다.
LANG 05

중간 표현 · 최적화 — -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 면 실제로는 거짓인데
“최적화를 켜니 버그가 생겼다”는 대부분 원래 있던 UB 입니다 — 최적화가 UB 가 없다는 가정 위에서 동작하기 때문입니다. -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·무한대 처리를 바꾸므로 수치 코드에서는 위험합니다.
-O2 가 하는 일 — 중간 표현 위에서 다듬습니다 소스 C · C++ · Rust 중간 표현 (IR) 언어와 CPU 양쪽에서 독립 최적화 여러 번 반복합니다 기계어 x86 · ARM 최적화가 실제로 하는 것들 죽은 코드 제거 — 결과를 아무도 안 쓰는 계산을 통째로 지웁니다 상수 접기 · 인라인 — 미리 계산할 수 있으면 계산해 두고, 작은 함수는 펼쳐 넣습니다 반복문 밖으로 빼기 · 벡터화 — 매번 같은 값은 밖으로, 여러 원소를 한 번에 그래서 디버거에서 변수가 "최적화되어 사라짐" 으로 나옵니다 실행 순서도 소스와 달라집니다 — 원인을 못 찾겠으면 -O0 으로 한 번 확인해 보세요 중간 표현을 두는 이유 — 언어 M 개와 CPU N 개를 M×N 이 아니라 M+N 으로 처리하기 위해서입니다 미정의 동작이 있으면 최적화기가 "그런 일은 없다" 고 가정합니다 — 검사 코드가 지워지는 사고가 여기서 납니다
컴파일러는 코드를 번역하는 것이 아니라 같은 뜻의 더 빠른 코드로 다시 씁니다.
LANG 06

코드 생성 · 레지스터 할당 — 컴파일러의 마지막 큰 결정

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 를 쓴다.
//   무엇을 쓸지는 '예측 가능성' 에 달렸다 —
//   예측이 잘 되는 분기는 분기가 더 빠르다.
확인하는 법 — godbolt.org 에서 두 버전을 나란히 놓고 어셈블리를 비교하세요. 추측하지 말고 보는 것이 이 층에서는 특히 중요합니다.

이렇게도 쓸 수 있다 — 호출 규약(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 }
C ABI 가 사실상 유일한 표준인 이유는 안정적이고 단순하기 때문입니다. C++ 는 이름 맹글링과 템플릿 때문에 컴파일러 간 호환이 깨집니다 → 🔵 C++ · 🦀 Rust 가이드의 C ABI 탭.

실무에선 이렇게 — 생성된 코드 읽기 🔴 실무

확인하고 싶은 것보는 법
이 함수가 인라인 됐나어셈블리에 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" ./...      # 인라인·이스케이프 분석 결과
Go 의 -gcflags="-m" 이 특히 유용합니다 — “이 변수가 힙으로 이스케이프했다”를 알려 주므로 불필요한 할당을 찾아 GC 압력을 줄일 수 있습니다.
LANG 07

링커와 로더 — 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   # 선택적
컨테이너 시대에는 정적 링크가 다시 인기입니다 — Go 와 Rust 가 기본적으로 정적에 가깝게 빌드하고, scratch · distroless 이미지를 쓸 수 있습니다. 이미지가 작아지고 공급망 공격 표면도 줄어듭니다.

실무에선 이렇게 — 다른 언어에서의 같은 문제 🔴 실무

언어“링크 에러”에 해당하는 것
파이썬ImportError · C 확장의 .so 로드 실패 · glibc 버전
자바NoClassDefFoundError · 클래스패스 · 버전 충돌
NodeMODULE_NOT_FOUND · 네이티브 모듈 ABI 불일치
.NET어셈블리 바인딩 · 버전 리다이렉트
Gocgo 사용 시 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 로 재빌드
“내 컴퓨터에서는 되는데”의 절반이 링크 문제입니다 — 로컬에는 있는 라이브러리가 서버에 없거나, 버전이 다르거나, 경로가 다릅니다. 컨테이너로 빌드 환경을 고정하는 것이 가장 확실한 해결책입니다.
undefined reference 는 컴파일이 아니라 링크 단계의 말입니다 a.o · b.o 아직 주소가 빈 이름들 링커 이름 ↔ 주소를 이어 붙임 실행 파일 주소가 다 채워짐 로더 메모리에 올림 undefined reference 선언은 봤는데 구현을 못 찾았습니다 라이브러리를 안 걸었거나 순서가 틀렸거나 cannot open shared object 빌드는 됐는데 실행할 때 못 찾습니다 로더 단계 — 경로 설정 문제입니다 정적 링크는 실행 파일에 넣고, 동적 링크는 실행할 때 찾아 붙입니다 정적은 배포가 간단하고 크며, 동적은 작고 공유되지만 대상 환경에 그 라이브러리가 있어야 합니다 오류 메시지가 어느 단계의 것인지만 알아도 찾을 곳이 절반으로 줄어듭니다
빌드 오류를 읽는 첫 질문 — 컴파일인가, 링크인가, 실행인가.
LANG 08

인터프리터 · 바이트코드 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절)
CPython 3.11 이 크게 빨라진 이유가 위 기법들입니다 — 적응형 인라인 캐싱(“specializing adaptive interpreter”)으로 같은 타입이 반복되면 전용 명령으로 교체합니다.

이렇게도 쓸 수 있다 — 바이트코드를 직접 보기 🟡 중급

# 성능 차이의 원인을 바이트코드로 확인
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인터프리터 밖으로
가장 큰 개선은 “인터프리터 루프를 안 도는 것”입니다 — 100만 번 도는 반복문을 numpy 연산 한 줄로 바꾸면 100배 차이가 납니다. 미세 최적화보다 이쪽이 먼저입니다 → 🐍 Python 가이드.

실무에선 이렇게 — 바이트코드 배포와 보안 🔴 실무

사실실무적 의미
.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
역직렬화 취약점은 가장 심각한 원격 코드 실행 경로 중 하나입니다 — Commons Collections 가젯 체인 같은 다수의 자바 RCE 가 이 계열이고, Log4Shell(JNDI 주입) 도 원격에서 객체를 불러와 실행하는 이웃 경로입니다. “객체를 그대로 복원한다”는 기능 자체가 위험하다는 것을 기억하세요 → 🔐 보안 · 암호 09.
LANG 09

JIT 컴파일 — 실행 중에 다시 컴파일

자주 도는 부분만 골라 기계어로 바꿉니다. 실행 중이라 실제 타입과 분기 통계를 알 수 있어 정적 컴파일러보다 유리한 경우도 있습니다.

뜨거운 코드만 골라 다시 컴파일합니다 — 그래서 성능이 계단식으로 올라갑니다 ① 인터프리터 즉시 실행 · 느림 호출·반복 횟수를 셈 ② 빠른 컴파일 C1 · Tier0 · Sparkplug 적당히 최적화 · 계속 계측 ③ 공격적 최적화 C2 · Tier1 · TurboFan 타입 가정 + 인라인 + 분기 제거 워밍업 — 같은 코드인데 시간이 지나며 빨라집니다 처리량 인터프리터 1단계 컴파일 최종 최적화 실행 시간 → 가정이 깨지면 역최적화 정적 컴파일러는 코드만 보지만 JIT 는 실제로 어떤 타입·어떤 분기가 왔는지를 압니다. 그래서 “이 호출은 늘 같은 타입”이라는 가정을 걸고 인라인까지 해 버립니다. 가정이 깨지면 되돌립니다 — 그 왕복이 잦으면 오히려 느려집니다.
벤치마크의 첫 몇 초를 버리는 이유가 이 그림입니다 — 워밍업 전 숫자는 다른 프로그램의 숫자입니다.

동작 흐름

① 처음에는 인터프리터로 실행 (시작이 빠름)
② 호출 횟수·반복 횟수를 센다
③ 임계값을 넘으면 "뜨겁다(hot)" 고 판단
④ 그 메서드를 기계어로 컴파일
⑤ 실행 중 수집한 정보로 공격적으로 최적화
   · 이 호출은 항상 같은 타입 → 타입 검사 생략 + 인라인
   · 이 분기는 항상 참 → 반대쪽 제거
⑥ 가정이 깨지면 역최적화(deoptimization) → 인터프리터로 복귀

★ ⑤ 가 정적 컴파일러가 못 하는 일이다
  실제 실행 데이터를 알고 있기 때문

JIT 계층

런타임계층
V8Ignition(인터프리터) → Sparkplug → Maglev → TurboFan
HotSpot인터프리터 → C1(빠른 컴파일) → C2(공격적)
.NETTier0 → 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 을 반환
AOT 와 JIT 은 트레이드오프입니다 — AOT 는 시작이 빠르고 메모리가 적지만 최대 처리량이 낮고, JIT 은 반대입니다. 짧게 사는 프로세스(서버리스 · CLI)는 AOT, 오래 사는 서버는 JIT 이 유리합니다.
LANG 10

가비지 컬렉션 — 멈춤 시간이 어디서 오나

“아무도 안 가리키는 메모리”를 찾아 회수합니다. 방식에 따라 멈추는 시간과 처리량이 달라지고, 그것이 p99 지연에 직접 나타납니다.

대부분의 객체는 금방 죽습니다 — 그 사실 하나에 힙 구조가 맞춰져 있습니다 세대별 힙 — 새 객체는 Eden 에서 태어납니다 Eden S0 S1 Old Minor GC — Eden 만 훑고 살아남은 것만 복사 여러 번 살아남으면 Old 로 승격 → Major GC 는 깁니다 멈춤 시간(STW) 비교 Parallel 수백ms G1 수십ms ZGC 1ms 미만 힙이 커질수록 격차가 벌어집니다 Eden 만 훑는 Minor GC 가 싸게 먹히는 이유가 “금방 죽는다”는 경향입니다. 오래 살아남은 것만 Old 로 올려 두고 그쪽은 가끔만 봅니다. 멈춤이 문제면 처리량을 조금 내주고 동시 수집 GC 로 바꿉니다(ZGC · Shenandoah). 튜닝보다 먼저 할 일은 할당을 줄이는 것입니다 — 안 만든 쓰레기는 치울 일이 없습니다. 컨테이너라면 힙 상한이 메모리 한도와 맞는지부터 보세요 — OOMKill 의 단골입니다.
GC 선택은 “처리량이냐 꼬리 지연이냐”의 교환입니다 — 둘 다 최고인 설정은 없습니다.

기본 방식들

방식원리약점
참조 카운팅참조 수를 세다가 0 이면 해제순환 참조 · 갱신 비용
마크 앤 스윕루트에서 도달 가능한 것을 표시 후 나머지 해제단편화 · 정지
마크 앤 컴팩트표시 후 살아 있는 것을 모음이동 비용
복사살아 있는 것만 반대편으로 복사메모리 2배
세대별새 객체를 자주, 오래된 것은 가끔세대 간 참조 관리
세대별 가설 — “대부분의 객체는 금방 죽는다”. 실측으로 확인된 경향이고, 그래서 젊은 세대만 자주 수집하면 적은 비용으로 대부분의 쓰레기를 치울 수 있습니다.

언어별 GC

언어방식
파이썬참조 카운팅 + 순환 탐지 (세대별)
자바G1 (기본) · ZGC · Shenandoah (저지연)
Go동시 마크 앤 스윕 · 매우 짧은 STW
JS (V8)세대별 · Scavenger + Mark-Compact
C#세대별 · 서버/워크스테이션 모드
Rust · C++GC 없음 — 소유권 · RAII
SwiftARC (컴파일 시 삽입된 참조 카운팅)
# 파이썬의 순환 참조
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 로그
Go 의 GOMEMLIMIT 이 컨테이너에서 특히 유용합니다 — 메모리 상한에 가까워지면 GC 를 더 자주 돌려 OOMKilled 를 예방합니다. 컨테이너 limit 의 80% 정도로 잡습니다.

실무에선 이렇게 — GC 로 인한 장애 패턴 🔴 실무

증상원인확인
주기적으로 수백 ms 멈춤Full GC · STWGC 로그의 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 누수 (핫 리로드 환경)
메모리 누수는 “GC 가 못 치우는 것”이 아니라 “도달 가능해서 안 치우는 것”입니다 — 즉 코드 어딘가가 계속 참조를 들고 있습니다. 힙 덤프에서 “누가 이것을 붙잡고 있나”(GC 루트까지의 경로)를 보는 것이 해결의 핵심입니다.
LANG 11

타입 시스템 — 실행 전에 잡아 주는 증명

타입은 “이 값에 대해 할 수 있는 일”을 미리 제한해 틀린 조합을 컴파일 시점에 막습니다. 어디까지 막아 줄 수 있고 어디부터는 못 하는지를 봅니다.

축들

구분뜻예
정적 / 동적검사 시점자바 / 파이썬
강함 / 약함암묵 변환 정도파이썬 / 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);        // 실패 시 예외
// ★ 여기를 통과하면 이후 코드에서 타입이 보장된다
핵심은 “타입과 검증의 단일 출처”입니다 — 둘을 따로 관리하면 반드시 어긋납니다. 스키마 하나에서 타입 · 검증 · 문서(OpenAPI) 를 모두 생성하는 것이 현재의 모범 사례입니다.
두 개의 축을 헷갈리지 마세요 — '언제'와 '얼마나 엄격하게' 정적 — 실행 전에 검사 자바 · C++ · 러스트 · 타입스크립트 배포 전에 잡히고, 자동 완성·리팩터링이 정확합니다 적는 양이 늘고 컴파일 시간이 필요합니다 동적 — 실행 중에 검사 파이썬 · 자바스크립트 · 루비 빠르게 쓰고 고치기 좋습니다 그 줄을 실행해야 오류를 압니다 — 테스트가 더 중요해집니다 다른 축 — 강타입 vs 약타입 "1" + 1 이 에러면 강타입(파이썬), "11" 이면 약타입(JS) 동적이면서 강타입인 언어가 많습니다 타입 추론은 또 다른 이야기 val x = 1 — 적지 않아도 컴파일러가 압니다 정적 타입이면서 적는 부담은 적은 방향입니다 요즘 흐름은 점진적 타입 — 동적 언어에 타입을 덧붙입니다(파이썬 힌트 · TS). 실행 중에는 사라지므로 경계 검증은 여전히 필요합니다
"타입이 있다·없다"가 아니라 언제 검사하느냐의 문제입니다.
두 개의 축을 헷갈리지 마세요 — '언제'와 '얼마나 엄격하게' 정적 — 실행 전에 검사 자바 · C++ · 러스트 · 타입스크립트 배포 전에 잡히고, 자동 완성·리팩터링이 정확합니다 적는 양이 늘고 컴파일 시간이 필요합니다 동적 — 실행 중에 검사 파이썬 · 자바스크립트 · 루비 빠르게 쓰고 고치기 좋습니다 그 줄을 실행해야 오류를 압니다 — 테스트가 더 중요해집니다 다른 축 — 강타입 vs 약타입 "1" + 1 이 에러면 강타입(파이썬), "11" 이면 약타입(JS) 동적이면서 강타입인 언어가 많습니다 타입 추론은 또 다른 이야기 val x = 1 — 적지 않아도 컴파일러가 압니다 정적 타입이면서 적는 부담은 적은 방향입니다 요즘 흐름은 점진적 타입 — 동적 언어에 타입을 덧붙입니다(파이썬 타입 힌트 · TypeScript). 실행 중에는 사라지므로 경계 검증은 여전히 필요합니다
"타입이 있다·없다"가 아니라 언제 검사하느냐의 문제입니다.
LANG 12

패러다임 · 람다 계산법 — “함수형이 좋다”의 실제 내용

패러다임은 종교가 아니라 도구 상자입니다. 각각이 무엇을 쉽게 만들고 무엇을 어렵게 만드는지, 그리고 실무에서 어떻게 섞어 쓰는지를 봅니다.

패러다임 비교

패러다임중심강점
절차형순서단순 · 직관적
객체지향상태 + 행위도메인 모델링 · 다형성
함수형변환동시성 · 테스트 · 추론
선언형결과 명세최적화 여지 (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)

"함수형 코어, 명령형 셸"
  · 결정 로직은 전부 순수 함수로 (테스트 쉬움)
  · 효과는 가장자리에서 한 번에 (모킹 최소)
함수형을 과하게 쓰면문제
모든 것을 불변으로대량 데이터에서 복사 비용
깊은 함수 합성스택 트레이스가 읽기 어려움
모나드 남용팀이 이해 못 하면 부채
과도한 커링디버깅 어려움
재귀 고집스택 오버플로 (파이썬 · 자바)
판단 기준은 “팀이 6개월 뒤에 읽을 수 있는가”입니다. 기법 자체의 우아함보다 변경 비용이 중요합니다 → 🏛️ 소프트웨어 공학 01.
LANG 13

미니 언어 만들기 — 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 까지

★ 하나를 끝까지 만들어 보는 것이 열 권을 읽는 것보다 낫다
실무에서 이 코드를 그대로 쓰는 자리 — 검색 쿼리 문법 · 알림 조건식 · 리포트 수식 · 게임 스크립트 · 워크플로 표현식. 위 200줄에 함수 호출과 값 타입만 더하면 실용적인 DSL 이 됩니다 → 🧮 계산이론 05의 “최소 권한의 언어”.

실무에선 이렇게 — 언어를 고르는 기준 · 이어지는 곳 🔴 실무

기준질문
생태계필요한 라이브러리가 성숙한가 — 가장 큰 요인
팀 역량지금 사람들이 잘 쓰는가 · 채용이 되는가
실행 특성지연 · 처리량 · 메모리 · 시작 시간
안전성타입 · 메모리 · 동시성 보장 수준
운영배포 · 관측 · 디버깅 도구
장기 유지버전 정책 · 하위 호환
여기서 배운 것다음
02~04 렉서 · 파서 · 의미 분석🧮 계산이론 02~05
05·06 최적화 · 코드 생성🖥️ 컴퓨터구조 06~08
07 링커 · 로더⚙️ 운영체제 05 · 🔵 C++ 가이드
10 GC⚙️ 운영체제 06 · ☕ Java 가이드
11 타입 시스템🟨 JS · TS 가이드 TypeScript 탭
12 패러다임🏛️ 소프트웨어 공학 전체
다음 탭 추천 — 0 🔐 보안 · 암호로 가면 여기서 본 역직렬화 · 메모리 · 컴파일러 가정이 어떻게 취약점이 되는지 이어집니다.
라이브러리를 고르고 쓸 수 있을 만큼 · 직접 구현하지 않기 위해

무엇을
누구로부터
지킬 것인가

보안은 기술 목록이 아니라 판단입니다 — 무엇이 자산이고, 누가 공격자이고, 어디까지 막을 것인가. 이 탭은 암호의 원리를 문서를 읽을 수 있을 만큼 설명하고, 절대 직접 구현하지 말아야 할 것을 명확히 구분합니다. 마지막 절은 배포 전 체크리스트입니다.

13SECTIONS
OWASP기반
체크리스트
실무중심
SEC 01

보안의 기본 원칙 — 기술보다 먼저

“보안을 강화하자”는 말로는 아무것도 정해지지 않습니다. 무엇을(자산) 누구로부터(위협) 어느 수준까지(비용) 를 정하는 것이 먼저입니다.

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 헤더알고리즘 혼동 공격
가장 흔한 실무 취약점이 “클라이언트가 보낸 값을 그대로 믿는 것”입니다 — 장바구니 가격 · 할인율 · 사용자 ID · 권한 레벨. 서버에서 다시 계산하거나 조회해야 합니다.

실무에선 이렇게 — 보안 비용의 균형 🔴 실무

투자비용효과
의존성 자동 스캔매우 낮음매우 높음
시크릿 스캔 (커밋 훅)매우 낮음매우 높음
프레임워크 기본 방어 사용낮음높음
MFA · 최소 권한 IAM낮음매우 높음
정적 분석 (SAST)중간중간 (오탐 많음)
침투 테스트높음높음 (연 1회 정도)
자체 암호 구현높음음수 — 하지 말 것
비용 대비 효과가 압도적인 것부터 하세요 — 대부분의 실제 침해는 알려진 취약점 · 유출된 자격 증명 · 잘못된 설정에서 시작합니다. 고급 공격을 걱정하기 전에 이 셋을 막는 것이 훨씬 중요합니다.
SEC 02

해시 함수 · 무결성 — 되돌릴 수 없는 지문

임의 길이 입력을 고정 길이 지문으로 바꿉니다. 되돌릴 수 없고, 한 비트만 바뀌어도 완전히 달라집니다. 용도에 맞는 해시를 고르는 것이 이 절의 목표입니다.

암호학적 해시의 요구 조건

성질뜻
역상 저항성해시값으로 원본을 못 찾음
제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비암호체크섬 · 해시맵
SHA-1 충돌은 2017년에 실제로 만들어졌습니다(SHAttered) — 서로 다른 PDF 두 개가 같은 SHA-1 을 갖습니다. “같은 해시 = 같은 파일”이라는 판단에 SHA-1 을 쓰면 안 됩니다. git 이 SHA-256 으로 전환을 준비하는 이유이기도 합니다.

이렇게도 쓸 수 있다 — 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절)
일반 문자열 비교불필요
사용자 존재 여부 응답✓ (메시지·시간을 동일하게)
“사용자가 없습니다” vs “비밀번호가 틀립니다”도 같은 문제입니다 — 메시지를 나누면 계정 존재 여부가 노출됩니다. 로그인 실패는 같은 메시지와 비슷한 시간으로 응답하세요.
되돌릴 수 없는 지문 — 네 가지 성질 입력 1바이트 ~ 10GB SHA-256 일방향 함수 항상 32바이트 a3f5…9c2b ① 결정적 같은 입력 → 항상 같은 값 그래서 비교로 검증이 됩니다 ② 눈사태 효과 한 글자만 바뀌어도 결과가 완전히 달라집니다 ③ 되돌릴 수 없음 해시 → 원본은 불가능 ④ 충돌 찾기도 어려움 쓰이는 곳 파일 무결성 · 서명 · 블록체인 · 중복 제거 · 캐시 키 주의 MD5 · SHA-1 은 충돌이 발견됐습니다 — 보안 용도로 쓰지 마세요
암호화와 다릅니다 — 암호화는 되돌리려고, 해시는 되돌리지 않으려고 씁니다.
되돌릴 수 없는 지문 — 네 가지 성질 입력 1바이트 ~ 10GB SHA-256 일방향 함수 항상 32바이트 a3f5…9c2b ① 결정적 같은 입력 → 항상 같은 값 그래서 비교로 검증이 됩니다 ② 눈사태 효과 한 글자만 바뀌어도 결과가 완전히 달라집니다 ③ 되돌릴 수 없음 해시 → 원본은 불가능 ④ 충돌 찾기도 어려움 쓰이는 곳 파일 무결성 · 서명 · 블록체인 · 중복 제거 · 캐시 키 주의 MD5 · SHA-1 은 충돌이 발견됐습니다 — 보안 용도로 쓰지 마세요
암호화와 다릅니다 — 암호화는 되돌리려고, 해시는 되돌리지 않으려고 씁니다.
SEC 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 기준)비고
Argon2idm=64MB, t=3, p=4 이상1순위 · 메모리 하드
scryptN=2¹⁷, r=8, p=1메모리 하드
bcryptcost ≥ 12지원이 가장 넓음
PBKDF2SHA-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 벡터가 된다)
#  ★ 로그인 엔드포인트에 레이트 리밋을 반드시 함께
느린 해시 자체가 DoS 벡터가 될 수 있습니다 — 공격자가 초당 수천 번 로그인을 시도하면 CPU 와 메모리가 소진됩니다. IP·계정별 레이트 리밋을 반드시 함께 두세요.

실무에선 이렇게 — 비밀번호 정책과 그 너머 🔴 실무

과거의 통념현재 권장 (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:,}번 등장했습니다")
가장 큰 효과는 MFA 입니다 — 비밀번호가 유출돼도 계정이 안전합니다. 그다음이 유출 목록 대조이고, 복잡도 규칙은 효과가 가장 낮습니다. 패스키(WebAuthn) 로 가는 것이 장기적인 방향입니다.
인코딩 · 해시 · 암호화 — 자주 섞어 쓰지만 목적이 완전히 다릅니다 인코딩 Base64 · URL 인코딩 목적: 전송·표현 형식 맞추기 누구나 되돌릴 수 있음 — 보안 아님 해시 SHA-256 · 단방향 목적: 무결성 확인 · 지문 되돌릴 수 없지만 대조는 가능 암호화 AES · RSA 목적: 나중에 다시 읽기 키가 있어야 되돌릴 수 있음 비밀번호에 그냥 SHA-256 을 쓰면 왜 뚫리는가 SHA-256("1234") 은 언제나 같은 값 → 미리 계산해 둔 표(레인보우)로 즉시 역추적 → GPU 로 초당 수십억 번 시도 가능 bcrypt · argon2id — 소금(salt) + 일부러 느리게 사용자마다 다른 소금 → 표를 만들 수 없음 한 번에 0.1초 → 초당 10회로 시도 자체를 봉쇄 핵심은 "느린 것이 기능"이라는 점입니다 — 정상 로그인은 0.1초면 충분하지만 공격자에겐 치명적입니다. Base64 를 "암호화했다"고 말하는 코드 리뷰를 만나면, 그건 그냥 읽는 방식만 바꾼 평문입니다.
고르는 기준은 하나입니다 — 다시 읽어야 하면 암호화, 대조만 하면 해시, 형식만 바꾸면 인코딩.

비밀번호 해싱 — 파라미터를 정하는 법

지금 쓸 것 · 쓰면 안 되는 것

알고리즘평가
argon2id현재 1순위. 메모리도 많이 쓰게 만들어 GPU·ASIC 공격에 강합니다
bcrypt여전히 안전. 라이브러리가 어디에나 있습니다. 72바이트 초과분이 잘리는 점만 주의
scrypt안전하지만 파라미터 설정이 까다롭습니다
PBKDF2규정 때문에 써야 할 때만. 반복 횟수를 아주 크게(60만+)
SHA-256 단독절대 금지 — 너무 빠릅니다
MD5 · SHA-1금지 — 충돌이 실제로 만들어집니다
공격 비용은 시도 1회 비용 × 시도 횟수 8자리 소문자+숫자 = 368 ≈ 2.8×1012 가지

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 키·토큰 비교에도 똑같이 적용됩니다.
SEC 04

대칭키 암호 — AES 를 안전하게 쓰는 법

같은 키로 잠그고 엽니다. 빠르지만 모드를 잘못 고르면 암호화한 의미가 없어집니다. 실무에서는 인증 암호(AEAD) 하나만 기억하면 됩니다.

운영 모드

모드특징판단
ECB블록마다 독립 암호화절대 금지
CBC이전 블록과 XOR인증 없음 · 패딩 오라클
CTR스트림처럼인증 없음
GCMCTR + 인증 태그★ 기본 선택
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 는 변조를 감지해 복호화 자체를 거부한다
GCM 의 논스를 절대 재사용하지 마세요 — 같은 키로 같은 논스를 두 번 쓰면 평문을 복원할 수 있고 인증 키까지 노출됩니다. 랜덤 12바이트 또는 카운터를 쓰되, 같은 키로 2³² 번 이상 쓰지 않기.

이렇게도 쓸 수 있다 — 더 안전한 선택 — 고수준 라이브러리 🟡 중급

# 실수하기 어려운 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 에 남는다
암호화보다 어려운 것이 키 관리입니다 — 키를 코드나 환경 변수에 두면 암호화의 의미가 거의 없습니다. KMS · Vault · 클라우드 시크릿 매니저를 쓰고, 키 순환 계획을 처음부터 세우세요 → 11절.
SEC 05

공개키 암호 — 키를 나눠 갖는 문제의 해결

대칭키의 문제는 “그 키를 어떻게 전달하나” 였습니다. 잠그는 열쇠와 여는 열쇠를 나누면 이 문제가 풀립니다. 원리는 🔢 이산수학 09·10 에 있습니다.

두 가지 용도

① 암호화 — 공개키로 잠그고 개인키로 연다
   보내는 사람이 받는 사람의 공개키로 암호화
   → 개인키를 가진 사람만 읽을 수 있다

② 서명 — 개인키로 서명하고 공개키로 검증
   보내는 사람이 자기 개인키로 서명
   → 누구나 그 사람이 보냈음을 확인할 수 있다

★ 방향이 반대다. 헷갈리면 이렇게 기억하자
   "공개키로 하는 일은 누구나 할 수 있는 일"
   (암호화 · 검증)
   "개인키로 하는 일은 나만 할 수 있는 일"
   (복호화 · 서명)

알고리즘 선택

알고리즘키 길이용도
RSA≥ 2048 (3072 권장)넓은 호환 · 느림
ECDSA (P-256)256비트 ≈ RSA 3072인증서 · 서명
Ed25519256비트★ 서명 기본 선택
X25519256비트키 교환
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비트 → 여전히 안전
“지금 수집하고 나중에 복호화” 가 현실적 위협입니다 — 장기 기밀(의료 · 국가 · 지식재산)은 지금부터 양자 내성 알고리즘으로 전환을 고려해야 합니다. TLS 는 이미 하이브리드 키 교환(X25519+ML-KEM)이 주요 브라우저에 배포됐습니다.
대부분의 서비스는 지금 당장 할 일이 없습니다 — TLS 라이브러리와 CDN 이 알아서 전환합니다. 업데이트를 게을리하지 않는 것이 실질적인 대비입니다.
공개키 — 열쇠를 둘로 나눠 하나만 공개합니다 공개키로 잠그면 → 개인키로만 열립니다 = 암호화 누구나 나에게 비밀을 보낼 수 있습니다 — 열 수 있는 사람은 개인키를 가진 나뿐입니다 키를 미리 나눠 갖지 않아도 된다는 점이 대칭키와의 결정적 차이입니다 개인키로 잠그면 → 공개키로 열립니다 = 서명 누구나 열 수 있으니 비밀은 아니지만, 열렸다는 사실이 "그 사람이 만들었다" 는 증거입니다 인증서 · 코드 서명 · JWT 검증이 전부 이 쓰임입니다 느립니다 — 대칭키보다 수백 배 이상 그래서 실제 데이터는 대칭키로 암호화하고, 공개키는 그 대칭키를 안전하게 나누는 데만 씁니다 남은 문제 하나 — "이 공개키가 정말 그 사람 것인가". 그 답이 인증서와 PKI 입니다 개인키가 유출되면 암호화와 서명이 동시에 무너집니다 — 키 관리가 알고리즘보다 중요합니다
두 문장만 기억하면 됩니다 — 공개키로 잠그면 비밀, 개인키로 잠그면 증거.
SEC 06

디지털 서명 · 인증서 · PKI — “이 공개키가 진짜인가”

공개키 암호에는 한 가지 문제가 남습니다 — 이 공개키가 정말 그 사람 것인가? 그것을 제3자가 보증하는 체계가 PKI 입니다.

“이 공개키가 진짜인가”를 사슬로 답합니다 — 위로 올라가며 서명을 확인합니다 루트 CA 브라우저·OS 에 미리 설치되어 있음 서명 중간 CA 루트를 오프라인에 두기 위한 층 서명 서버 인증서 example.com 서버가 접속할 때 이것부터 보냅니다 브라우저가 실제로 하는 검증 ① 서버 인증서 서명을 중간 CA 공개키로 확인 ② 중간 CA 서명을 루트 CA 공개키로 확인 ③ 그 루트가 신뢰 목록에 있는가 ★ ④ 도메인(SAN)이 일치하는가 ⑤ 유효기간이 지나지 않았는가 ⑥ 폐기되지 않았는가 (CRL · OCSP) 하나라도 실패하면 연결을 끊습니다 신뢰의 뿌리는 계산이 아니라 “미리 설치된 목록”입니다 — 검증은 거기서 멈춥니다. 그래서 사설 CA 를 기기에 넣으면 그 CA 는 모든 사이트를 가장할 수 있습니다.
사내 SSL 검사 장비와 개발용 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 로 서명 검증 정책
Sigstore 의 키리스 서명이 실용적입니다 — 장기 키를 보관·순환할 필요 없이 CI 의 OIDC 신원으로 서명하고, 투명성 로그에 기록이 남습니다. 키 유출 위험 자체가 사라집니다 → 12절 공급망 보안.
SEC 07

TLS 가 지키는 것 — 그리고 지키지 못하는 것

“HTTPS 니까 안전하다”는 절반만 맞습니다. TLS 는 전송 구간을 보호할 뿐, 그 앞뒤는 다른 문제입니다.

보장 범위

✓ 지킨다
· 도청 (내용 암호화)
· 변조 (무결성 검증)
· 서버 신원 확인
· 재전송 공격
· 다운그레이드 (1.3)
✗ 못 지킨다
· 서버에 저장된 뒤
· 클라이언트 기기의 악성코드
· 애플리케이션 취약점
· 접속했다는 사실 (SNI · IP)
· 트래픽 크기·타이밍 패턴
· 잘못 신뢰한 CA
한 문장으로 — TLS 는 “중간에 있는 사람”을 막습니다. 양 끝단의 문제는 전혀 다루지 않습니다.

추가로 필요한 것들

위협대책
서버 저장 데이터 유출저장 시 암호화 (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
Mozilla SSL Configuration Generator 를 쓰면 서버·버전별 권장 설정을 바로 얻을 수 있습니다. Intermediate 프로필이 대부분의 서비스에 적절합니다 → 🌐 서버기술 가이드.
SEC 08

인증과 인가 — 세션 · JWT · OAuth

“너 누구냐”(인증)와 “그걸 해도 되냐”(인가)는 다른 문제입니다. JWT 를 잘못 쓰는 대부분의 사례가 이 둘을 섞는 데서 시작합니다.

두 방식의 진짜 차이는 속도가 아니라 “취소할 수 있는가”입니다 세션 — 서버가 기억합니다 브라우저 서버 Redis 쿠키에는 세션 ID 만 들어갑니다 요청마다 저장소를 한 번 봅니다 (약 1ms) 지우면 그 즉시 로그아웃됩니다 ✓ JWT — 토큰이 스스로 증명합니다 브라우저 서버 조회 없음 사용자·권한·만료가 토큰 안에 들어 있습니다 서버는 서명만 검증합니다 (조회 0회) 발급한 뒤에는 만료 전까지 계속 유효합니다 ⚠ 즉시 무효화를 하려면 결국 블랙리스트 저장소가 필요합니다 — 무상태의 장점이 사라집니다. 그래서 실무 기본형은 “짧은 액세스 토큰 + 서버가 관리하는 리프레시 토큰”입니다. 권한을 바꿔도 만료까지 반영되지 않습니다 — 권한은 토큰이 아니라 서버에서 확인하세요. OAuth 는 인가 프로토콜입니다. 신원 확인은 그 위에 얹은 OIDC 가 합니다.
“확장성 때문에 JWT”보다 “취소를 포기해도 되는가”가 먼저 묻는 질문입니다.

세션 vs 토큰

세션 (쿠키)JWT
상태서버가 보관토큰에 담김
즉시 무효화가능어려움 ★
확장성저장소 필요무상태
크기작음 (ID 만)큼 (매 요청 전송)
권한 변경 반영즉시만료까지 지연
적합일반 웹 서비스서비스 간 · 짧은 수명
“확장성 때문에 JWT”는 대개 과합니다 — Redis 세션 조회는 1ms 이내이고, 대부분의 서비스는 그 정도로 문제가 되지 않습니다. 즉시 로그아웃이 안 되는 대가가 더 큽니다.

쿠키 설정

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) 리프레시할 때마다 새로 발급 · 옛 것은 무효
                → 재사용이 감지되면 세션 전체 폐기 (탈취 탐지)

  로그아웃      리프레시 토큰 폐기 + 액세스 토큰은 만료 대기
  강제 로그아웃 사용자별 "토큰 유효 시작 시각" 을 저장해 비교
JWT 를 localStorage 에 저장하지 마세요 — XSS 하나로 토큰이 통째로 털립니다. HttpOnly 쿠키가 훨씬 안전하고, CSRF 는 SameSite 와 토큰으로 따로 막습니다.

실무에선 이렇게 — 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 방어 — 반드시 검증
PKCEcode 가로채기 방어
redirect_uri 정확 일치오픈 리다이렉트 방지
ID 토큰의 nonce재전송 방어
aud · iss 확인다른 앱의 토큰 수용 방지
스코프 최소화최소 권한
OAuth 를 직접 구현하지 마세요 — 검증된 라이브러리(authlib · openid-client · Spring Security)나 인증 서비스(Auth0 · Keycloak · Cognito)를 쓰세요. 리다이렉트 검증 하나만 틀려도 계정 탈취로 이어집니다.
SEC 09

웹 취약점 — 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      # 텍스트로만
같은 원칙이 모든 인젝션에 적용됩니다 — SQL · OS 명령 · LDAP · XPath · 템플릿 · NoSQL · 로그. “파라미터화”가 가능한 API 를 쓰는 것이 답입니다.

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 프리플라이트)
CORS 는 보안 기능이 아니라 완화 기능입니다 — 브라우저가 응답을 읽지 못하게 막을 뿐, 요청은 이미 서버에 도달했습니다. Access-Control-Allow-Origin: * 를 인증이 필요한 API 에 쓰면 안 됩니다.

실무에선 이렇게 — 그 밖의 주요 취약점 🔴 실무

취약점내용방어
IDORID 만 바꿔 남의 데이터 조회매 요청 소유권 확인
SSRF서버가 공격자 지정 URL 요청allowlist · 사설 IP 차단 · IMDSv2
경로 순회../../etc/passwd정규화 후 기준 경로 확인
오픈 리다이렉트피싱에 악용리다이렉트 대상 allowlist
대량 할당{"isAdmin":true}허용 필드만 매핑
XXEXML 외부 엔티티외부 엔티티 비활성화
역직렬화임의 코드 실행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)   # ★ 리다이렉트 금지
SSRF 방어에서 리다이렉트를 막는 것이 중요합니다 — 공개 주소로 시작해 리다이렉트로 내부 주소로 유도할 수 있습니다. DNS 리바인딩도 같은 이유로 위험하므로, IP 를 확인한 뒤 그 IP 로 직접 연결하는 것이 더 안전합니다.
셋 다 뿌리가 같습니다 — 데이터가 코드로 해석될 때 SQL 인젝션 — 입력이 쿼리 문법으로 읽힐 때 문자열을 이어 붙여 쿼리를 만들면 입력에 들어 있는 따옴표가 문법이 됩니다 막는 법 — 바인딩(? 자리표시자). 이스케이프를 손으로 하려 하지 마세요 XSS — 입력이 HTML · 스크립트로 읽힐 때 남긴 댓글이 다른 사람 브라우저에서 코드로 실행됩니다 — 세션이 통째로 넘어갑니다 막는 법 — 출력할 때 이스케이프 · innerHTML 피하기 · CSP 헤더 CSRF — 로그인된 브라우저를 남이 대신 쓸 때 쿠키는 자동으로 따라가므로, 남의 페이지가 내 이름으로 요청을 보낼 수 있습니다 막는 법 — CSRF 토큰 · SameSite 쿠키 · 상태를 바꾸는 요청은 GET 으로 두지 않기 공통 원칙 — 입력은 검증하고, 출력은 그 자리에 맞게 이스케이프하고, 권한은 서버에서 다시 확인합니다
보안 코드의 출발점은 도구가 아니라 이 값이 어디서 왔는가를 늘 묻는 습관입니다.
SEC 10

메모리 취약점 — 배열 밖에 쓰면 벌어지는 일

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실행 파일도 위치 독립
RELROGOT 을 읽기 전용으로
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-one1바이트 초과널 바이트로도 공격 가능
포맷 스트링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 가 주요 오픈소스를 상시 퍼징한다
ASan 은 성능이 2~3배 느려지지만 개발·CI 에서는 반드시 켜세요 — 메모리 버그는 증상이 엉뚱한 곳에서 나타나 추적이 매우 어렵습니다. ASan 은 발생 지점에서 즉시 잡아 줍니다.

실무에선 이렇게 — 메모리 안전 언어로 🔴 실무

업계의 방향

  · 마이크로소프트: 자사 취약점의 약 70% 가 메모리 안전 문제
  · 구글 안드로이드: 메모리 안전 언어 도입 후 관련 취약점 급감
  · 미국 정부(ONCD, 2024): 메모리 안전 언어로 전환 권고
  · 리눅스 커널: Rust 지원 도입

메모리 안전 언어
  Rust · Go · 자바 · C# · 파이썬 · JS · Swift

★ 다만 '안전' 이 '취약점 없음' 은 아니다
  · 로직 취약점 · 인젝션 · 인가 오류는 그대로
  · unsafe 블록 · FFI 경계는 여전히 위험
  · 의존성의 네이티브 코드도 마찬가지
현실적인 접근내용
새 컴포넌트는 안전한 언어로전면 재작성은 비현실적
파서·디코더부터 교체신뢰 못할 입력을 다루는 곳이 최우선
기존 C 코드는 강화ASan · 퍼징 · 컴파일러 방어 옵션
격리위험한 처리를 별도 프로세스·샌드박스로
의존성 감시네이티브 라이브러리의 CVE 추적
“파서를 먼저”가 실용적인 조언입니다 — 이미지·비디오·문서 디코더가 신뢰할 수 없는 입력을 가장 많이 다루고, 실제 CVE 도 여기 집중돼 있습니다. 🔵 C++ · 🦀 Rust 가이드에 실전 내용이 있습니다.
범위를 넘어 쓰거나, 이미 없어진 것을 쓰거나 ① 버퍼 오버플로 — 옆칸을 덮어씁니다 buf[8] — 우리 배열 저장된 변수 반환 주소 20바이트를 쓰면 여기까지 덮입니다 → 실행 흐름 탈취 ② 해제 후 사용 — 남의 자리가 된 곳을 만집니다 free(p) 이후 그 자리에 다른 데이터가 p 로 그것을 읽거나 고칠 수 있습니다 막는 방법 — 언어 차원 경계 검사(자바·파이썬) · 소유권 규칙(러스트)이 이 부류를 없앱니다 C·C++ 를 써야 한다면 ASan · Valgrind 로 검사하고, 스마트 포인터와 컨테이너를 쓰세요
지금도 심각한 취약점의 큰 몫이 이 두 부류에서 나옵니다.
범위를 넘어 쓰거나, 이미 없어진 것을 쓰거나 ① 버퍼 오버플로 — 옆칸을 덮어씁니다 buf[8] — 우리 배열 저장된 변수 반환 주소 20바이트를 쓰면 여기까지 덮입니다 → 실행 흐름 탈취 ② 해제 후 사용 — 남의 자리가 된 곳을 만집니다 free(p) 이후 그 자리에 다른 데이터가 p 로 그것을 읽거나 고칠 수 있습니다 막는 방법 — 언어 차원 경계 검사(자바·파이썬) · 소유권 규칙(러스트)이 이 부류를 없앱니다 C·C++ 를 써야 한다면 ASan · Valgrind 로 검사하고, 스마트 포인터와 컨테이너를 쓰세요
지금도 심각한 취약점의 큰 몫이 이 두 부류에서 나옵니다.
SEC 11

난수 · 키 관리 — Math.random() 으로 토큰을 만들면

일반 난수 생성기는 예측 가능합니다. 토큰 · 세션 ID · 비밀번호 재설정 링크에 쓰면 공격자가 다음 값을 계산해 낼 수 있습니다.

두 종류의 난수

일반 PRNGCSPRNG
목적속도 · 통계 품질예측 불가능성
시드 노출이후 전부 예측 가능—
출력 관찰내부 상태 복원 가능불가능
예Mersenne Twister · Math.randomsecrets · /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 사용
UUID v4 를 토큰으로 쓰는 것도 주의가 필요합니다 — 구현이 CSPRNG 를 쓰는지 확인해야 하고, 122비트라 충분하지만 여유는 적습니다. 보안 토큰에는 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)
#  ③ 강제 푸시 후 팀에 재클론 안내
#  ★ 공개 저장소였다면 이미 봇이 수집했다고 가정할 것
“히스토리에서 지웠으니 괜찮다”는 틀립니다 — 공개 저장소에 올라간 키는 수 초 안에 스캔 봇이 가져갑니다. 가장 먼저 할 일은 키 폐기와 재발급이고, 히스토리 정리는 그다음입니다.

실무에선 이렇게 — 키 수명 주기 🔴 실무

  1. 생성 — CSPRNG 또는 HSM/KMS 안에서. 사람이 보지 않는 것이 가장 좋습니다.
  2. 배포 — 시크릿 매니저 · 워크로드 아이덴티티. 사람 손을 거치지 않게.
  3. 사용 — 메모리에만 · 로그에 절대 남기지 않기 · 최소 권한 스코프.
  4. 순환 — 주기적 자동 교체. 두 키를 동시에 유효하게 두어 무중단 전환.
  5. 폐기 — 즉시 무효화 가능해야 함 · 사용 이력 감사.
# 무중단 키 순환 패턴 — 검증은 여러 키로, 서명은 최신 키로
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)이 기본입니다.
SEC 12

공급망 보안 — 내 코드가 안전해도

요즘 애플리케이션의 코드 대부분은 남이 쓴 것입니다. 의존성 하나가 문을 열어 두면 내 코드의 품질과 무관하게 뚫립니다. 가장 비용 대비 효과가 큰 보안 활동이 여기 있습니다.

공격 벡터

유형내용
알려진 취약점업데이트 안 한 라이브러리 — 가장 흔함
타이포스쿼팅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
CI 는 프로덕션만큼 중요한 공격 대상입니다 — 배포 권한 · 시크릿 · 소스 코드에 전부 접근할 수 있기 때문입니다. “CI 가 뚫리면 무엇을 할 수 있나”를 기준으로 권한을 설계하세요.
SEC 13

위협 모델링 · 체크리스트 — 기억에 의존하지 않기

보안을 사람의 성실함에 맡기면 반드시 빠집니다. 목록과 자동화로 만들어야 유지됩니다. 이 절은 실제로 붙여 놓고 쓸 수 있는 형태로 정리합니다.

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)
PRSAST · 의존성 스캔 · 라이선스 검사
빌드이미지 스캔 · 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}]
사람이 기억해야 하는 규칙은 결국 깨집니다 — 체크리스트를 CI 의 실패 조건으로 옮기는 것이 실질적인 보안 개선입니다.

실무에선 이렇게 — 사고 대응과 이어지는 곳 🔴 실무

  1. 탐지 — 이상 징후를 어떻게 알 것인가. 알림 · 로그 · 외부 제보 경로.
  2. 격리 — 확산을 막는다. 자격 증명 폐기 · 네트워크 차단 · 인스턴스 격리(삭제 아님).
  3. 증거 보전 — 로그 · 메모리 · 디스크 스냅숏. 재부팅 전에.
  4. 근절 — 취약점 패치 · 백도어 제거 · 전면 자격 증명 순환.
  5. 복구 — 깨끗한 이미지에서 재구축 · 단계적 복귀.
  6. 사후 분석 — 비난 없는 회고 · 재발 방지 항목을 백로그로.
미리 준비할 것내용
연락 체계누구에게 · 어떤 채널로 (침해된 채널은 못 씀)
권한 폐기 절차모든 키·토큰을 한 번에 무효화하는 방법
로그 보존충분한 기간 · 변조 방지
백업 검증복구를 실제로 해 본 적이 있는가
법적·공지 의무개인정보 유출 신고 기한
여기서 이어지는 곳다음
02·05 해시 · 공개키🔢 이산수학 09·10 수학적 근거
07 TLS🌐 네트워크 09 핸드셰이크
09 웹 취약점🌐 서버기술 가이드 · 각 언어 가이드
10 메모리🖥️ 컴퓨터구조 06 · 🔵 C++ 가이드
12 공급망🏛️ SW 공학 11 CI/CD
다음 탭 추천 — 🕸️ 분산 · 동시성으로 가면 여러 서버·스레드가 함께 일할 때 생기는 문제를 다룹니다. 보안의 레이스 컨디션과도 이어집니다.
재현이 안 되는 버그와 가끔 일어나는 장애가 사는 곳

둘 이상이
동시에 일하면
규칙이 달라집니다

스레드 두 개든 서버 백 대든 문제의 성격은 같습니다 — 순서를 보장할 수 없고, 일부만 실패할 수 있고, 시계를 믿을 수 없습니다. 이 탭은 한 프로세스 안의 동시성에서 시작해 여러 대에 걸친 분산까지 같은 흐름으로 이어갑니다. 마지막 절은 장애를 견디는 실무 패턴입니다.

13SECTIONS
CAP· 합의까지
재시도· 멱등성
실무패턴
DIST 01

동시성 vs 병렬성 — 다른 문제입니다

동시성은 여러 일을 번갈아 잘 다루는 것이고, 병렬성은 진짜 동시에 하는 것입니다. 해결책과 병목이 완전히 다릅니다.

구분

동시성병렬성
정의여러 작업을 다룬다여러 작업을 실행한다
코어 1개로가능불가능
주된 목표응답성 · 자원 활용처리량 · 속도
적합한 작업I/O 바운드CPU 바운드
도구async · 이벤트 루프 · 코루틴스레드 · 프로세스 · SIMD · GPU
병목블로킹 호출순차 부분 (암달)
“느리다”의 원인부터 확인하세요 — CPU 사용률이 100% 에 가까우면 병렬성 문제, CPU 는 노는데 처리량이 안 나오면 동시성 문제입니다. ⚙️ 운영체제 13 의 진단 순서가 이 판단에 쓰입니다.

잘못 고르면

# 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+)플랫폼 스레드동기화 구간에서 핀 고정
Rustasync스레드 · rayon컴파일러가 데이터 경쟁 차단
C#async/awaitTask · 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 을 푼다
파이썬에서 “GIL 때문에 안 된다”는 절반만 맞습니다 — I/O 와 C 확장은 잘 병렬화됩니다. 순수 파이썬 CPU 루프만 문제이고, 그건 대개 numpy · numba · Rust 확장으로 옮기는 것이 답입니다 → 🐍 Python · 🦀 Rust 가이드.
DIST 02

경쟁 상태 · 원자성 — count += 1 은 세 단계입니다

한 줄처럼 보이는 연산이 사실 읽기 · 계산 · 쓰기 세 단계입니다. 그 사이에 다른 스레드가 끼어들면 값이 사라집니다. 재현이 안 되는 버그의 대표적인 원인입니다.

스레드 A 스레드 B 읽기 → 5 읽기 → 5 쓰기 ← 6 쓰기 ← 6 (덮어씀) 시간 ↓ 두 번 증가했는데 결과는 6 — 하나가 사라짐
겹치는 구간이 아주 짧아 대부분의 실행에서는 문제가 안 보입니다 — 그래서 위험합니다.

직접 재현해 보기

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 과 같은 문제입니다.

실무에선 이렇게 — 경쟁 상태 찾아내기 🔴 실무

도구내용
ThreadSanitizerC/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 에 넣어 두면
# "가끔 쿠폰이 두 번 쓰인다" 를 배포 전에 잡을 수 있다
“한 번 돌려 봤는데 괜찮았다”는 증거가 아닙니다 — 경쟁 상태는 타이밍에 의존하므로 대부분의 실행에서 통과합니다. 반복 실행 · 스레드 수 증가 · 인위적 지연 삽입으로 확률을 높여서 테스트하세요.
DIST 03

메모리 모델 — 내가 쓴 값을 상대가 못 볼 수 있습니다

락 없이 변수를 공유하면 값이 안 보이거나 순서가 뒤집혀 보일 수 있습니다. 컴파일러와 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);
이 버그는 최적화 수준과 CPU 에 따라 나타납니다 — 디버그 빌드나 x86 에서는 우연히 동작하다가 릴리즈 빌드나 ARM 에서 멈춥니다. “맥북에서만 안 된다”의 원인이 되기도 합니다.

재정렬 문제

스레드 A            스레드 B
  data = 42;          if (ready)
  ready = true;           use(data);

★ 컴파일러나 CPU 가 A 의 두 줄 순서를 바꿀 수 있다
  (단일 스레드 관점에서는 결과가 같으므로 허용된다)
  → B 가 ready=true 를 보고 data 는 아직 0 일 수 있다

메모리 순서 강도
  x86 : 비교적 강함 (스토어-로드 재정렬만)
  ARM : 약함 — 더 많은 재정렬 허용
  → ★ x86 에서 잘 되던 코드가 ARM 에서 깨지는 이유
Apple Silicon 도입 이후 실제로 많이 드러난 문제입니다. 해결은 적절한 동기화 원시(락 · 원자 연산 · 채널)를 쓰는 것이고, 그러면 언어가 순서를 보장해 줍니다.

이렇게도 쓸 수 있다 — 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. 직접 이중 검사를 구현할 이유가 거의 없고, 직접 하면 대개 틀립니다.
"내가 쓴 값을 상대가 언제 보는가"의 규칙입니다 우리가 적은 순서 data = 42; ready = true; 재배치 실제 실행 순서 ready = true; data = 42; 다른 스레드 ready 는 true 인데 data 는 아직 0 왜 순서를 바꾸나 컴파일러는 최적화를 위해, CPU 는 파이프라인을 채우려고 한 스레드 안에서는 결과가 같아 보이도록 보장합니다 다른 스레드에서 볼 때만 어긋나 보입니다 동기화 도구가 보장하는 두 가지 상호 배제 — 한 번에 하나만 들어갑니다 가시성·순서 — 그 앞의 쓰기가 모두 보이게 됩니다 volatile · atomic · lock 이 모두 이 역할을 합니다 그래서 "락 없이 플래그 하나로 통신" 하는 코드는 대개 틀립니다 — 테스트에서는 통과하고 운영에서 드물게 깨집니다 직접 메모리 순서를 지정하는 것은 최후의 수단입니다 — 검증이 매우 어렵습니다
동시성 버그가 재현되지 않는 이유가 이 그림 안에 있습니다.
"내가 쓴 값을 상대가 언제 보는가"의 규칙입니다 우리가 적은 순서 data = 42; ready = true; 재배치 실제 실행 순서 ready = true; data = 42; 다른 스레드 ready 는 true 인데 data 는 아직 0 왜 순서를 바꾸나 컴파일러는 최적화를 위해, CPU 는 파이프라인을 채우려고 한 스레드 안에서는 결과가 같아 보이도록 보장합니다 다른 스레드에서 볼 때만 어긋나 보입니다 동기화 도구가 보장하는 두 가지 상호 배제 — 한 번에 하나만 들어갑니다 가시성·순서 — 그 앞의 쓰기가 모두 보이게 됩니다 volatile · atomic · lock 이 모두 이 역할을 합니다 그래서 "락 없이 플래그 하나로 통신" 하는 코드는 대개 틀립니다 — 테스트에서는 통과하고 운영에서 드물게 깨집니다 직접 메모리 순서를 지정하는 것은 최후의 수단입니다 — 검증이 매우 어렵습니다
동시성 버그가 재현되지 않는 이유가 이 그림 안에 있습니다.
DIST 04

락 프리 · 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 언어에서는 메모리 재사용이 없어 덜 심각하다
락프리 자료구조를 직접 만들지 마세요 — 메모리 회수 · ABA · 메모리 순서를 모두 맞춰야 하고, 테스트로 검증하기가 거의 불가능합니다. 검증된 라이브러리를 쓰세요.

이렇게도 쓸 수 있다 — 낙관적 잠금 — 같은 아이디어를 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
 ⑤ 락                        — 짧게 · 순서 고정
 ⑥ 원자 연산                 — 단순한 것만
 ⑦ 락프리 자료구조           — 정말 필요할 때 · 라이브러리로

★ 위에서부터 시도하고, 측정으로 필요성이 증명될 때만 아래로 내려간다
“락은 느리다”는 과장입니다 — 경합이 없는 뮤텍스는 20ns 정도이고, 대부분의 애플리케이션에서 병목이 아닙니다. 측정하지 않은 락프리 최적화는 대개 손해입니다.
DIST 05

동시성 모델 — 액터 · CSP · 이벤트 루프

스레드 + 락 말고도 방법이 있습니다. 메모리를 공유하는 대신 메시지를 주고받으면 경쟁 상태가 구조적으로 사라집니다.

모델 비교

모델핵심대표
공유 메모리 + 락같은 데이터를 잠그고 접근자바 · C++
액터각자 상태를 갖고 메시지만 주고받음Erlang · Akka
CSP채널로 값을 전달Go · Clojure
이벤트 루프단일 스레드 + 콜백/awaitNode · asyncio
STM트랜잭션 메모리Haskell · Clojure
데이터 병렬같은 연산을 데이터에rayon · numpy
Go 의 격언: “메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라”

채널 (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 프로파일
고루틴 누수는 메모리 누수로 이어집니다 — 고루틴이 붙잡은 변수가 GC 되지 않습니다. 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)               # 가득 차면 여기서 대기 = 배압
배압이 없는 시스템은 반드시 무너집니다 — 부하가 용량을 넘는 순간 큐가 무한히 자라고, 지연이 폭발한 뒤 메모리가 터집니다. 유한 큐 + 명시적 거절이 훨씬 나은 실패 방식입니다 → 🗂️ 자료구조 03.
DIST 06

분산 시스템의 여덟 가지 오해 — 조용한 가정들

1994년에 정리된 목록인데 지금도 그대로 유효합니다. 설계 리뷰에서 이 여덟 개를 훑는 것만으로 큰 실수를 상당히 걸러 낼 수 있습니다.

#오해현실 · 대응
1네트워크는 안정적이다끊긴다 · 재시도 + 멱등성 (13절)
2지연은 0 이다왕복이 쌓인다 · 호출 사슬을 얕게 (🌐 네트워크 12)
3대역폭은 무한하다페이로드를 줄인다 · 압축 · 페이지네이션
4네트워크는 안전하다내부망도 암호화 · mTLS · 제로 트러스트
5토폴로지는 안 변한다IP 를 하드코딩하지 말 것 · 서비스 디스커버리
6관리자는 한 명이다여러 팀 · 여러 정책 · 버전 불일치
7전송 비용은 0 이다직렬화 · 이그레스 요금 · CPU
8네트워크는 균질하다다른 OS · 인코딩 · 시간대 · MTU
추가로 자주 하는 가정들 — “시계는 맞다”(12절) · “메시지는 순서대로 온다” · “한 번만 도착한다” · “장애는 명확하다(살았나 죽었나)”. 마지막이 특히 중요합니다 — 느린 것과 죽은 것을 구분할 수 없습니다.

부분 실패 — 분산 시스템의 본질

단일 프로세스: 함수 호출은 성공하거나 예외를 던진다
분산 시스템:   ★ 세 번째 경우가 있다 — "모른다"

  요청을 보냈는데 응답이 없다. 그러면
   · 요청이 도착하지 않았나?
   · 도착했는데 처리 중인가?
   · 처리했는데 응답이 유실됐나?
  → 구분할 수 없다

결과
 · 재시도하면 중복 처리 위험
 · 재시도 안 하면 유실 위험
 → ★ 그래서 멱등성이 필수다 (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대 장애 허용  ← 늘려도 이득 없음
“가용성을 위해 노드를 늘렸는데 더 잘 죽는다”는 쿼럼을 고려하지 않았을 때 생깁니다. etcd · Consul · ZooKeeper 를 홀수 개로 배치하라는 이유입니다.

실무에선 이렇게 — 장애를 전제로 설계하기 🔴 실무

질문확인할 것
이 호출이 실패하면?폴백 · 캐시 · 부분 응답
이 호출이 느려지면?타임아웃 · 서킷 브레이커
두 번 실행되면?멱등성
순서가 바뀌면?버전 · 시퀀스 번호
이 노드가 죽으면?다른 노드가 이어받나 · 상태는?
전체 리전이 죽으면?다중 리전 · RTO/RPO
의존 서비스가 전부 죽으면?우아한 저하 · 읽기 전용 모드
카오스 엔지니어링 — 장애를 일부러 일으켜 검증

  ① 정상 상태를 정의한다 (지표로)
  ② 가설을 세운다 ("인스턴스 하나가 죽어도 지표가 유지된다")
  ③ 실제 환경에서 실험한다 (통제된 범위 · 중단 스위치 준비)
  ④ 가설이 틀리면 그것이 발견한 취약점이다

주입할 장애
  · 인스턴스 종료 · 네트워크 지연 · 패킷 손실
  · 의존 서비스 응답 지연 · 에러율 상승
  · 디스크 가득 · CPU 포화 · 시계 왜곡

★ 프로덕션에서 하기 전에 스테이징에서 · 업무 시간에 · 롤백 준비
“장애가 나면”이 아니라 “장애가 날 때” 로 생각하는 것이 분산 시스템 설계의 출발점입니다. 규모가 커지면 매일 무언가는 죽고 있습니다.
DIST 07

CAP 정리 · PACELC — 자주 오해되는 정리

“일관성과 가용성 중 하나를 고르라”가 아닙니다. 정확히는 “망이 갈라졌을 때 무엇을 포기할지 정하라”이고, 평소에는 다른 선택(PACELC)이 더 중요합니다.

정확한 진술

네트워크 분단(P)이 일어났을 때, 일관성(C) 과 가용성(A) 을 동시에 만족할 수 없다
분단이 일어났을 때의 선택

  CP — 일관성 우선
       소수 쪽 노드가 요청을 거부한다
       "정확하지 않은 답을 줄 바에 답을 안 한다"
       예: etcd · ZooKeeper · 전통적 RDBMS 클러스터

  AP — 가용성 우선
       양쪽 다 응답하고 나중에 합친다
       "오래된 답이라도 주는 게 낫다"
       예: Cassandra · DynamoDB(설정) · DNS

★ CA 는 존재하지 않는다
  네트워크 분단은 '선택' 이 아니라 '현실' 이기 때문
  (단일 노드라면 P 가 없으니 CA 지만, 그건 분산이 아니다)

PACELC — 더 유용한 틀

Partition 이면 A or C, Else(평소에는) Latency or Consistency
시스템분단 시평소
PostgreSQL (단일)—C
MongoDB (기본)CC
CassandraAL (조정 가능)
DynamoDBAL (강한 읽기 옵션)
etcd · ZooKeeperCC
Redis 복제AL
실무에서 더 자주 마주치는 것은 E 쪽입니다 — 분단은 드물지만, “복제본에서 읽어 빠르게 vs 리더에서 읽어 정확하게”는 매일 결정해야 합니다.

이렇게도 쓸 수 있다 — 기능별로 다르게 고르기 🟡 중급

기능선택이유
결제 · 재고 차감강한 일관성중복·초과 판매가 치명적
사용자 프로필 조회최종 일관성몇 초 지연 무해
좋아요 · 조회수최종 일관성근사값으로 충분
인증 · 권한강한 일관성권한 회수가 늦으면 위험
피드 · 타임라인최종 일관성가용성이 더 중요
주문 상태읽기-내-쓰기내 주문은 바로 보여야
로그 · 분석최종 일관성배치로 정합
★ 시스템 전체를 하나로 고르지 않는다
  같은 서비스 안에서도 기능마다 다르게 선택한다

전형적인 구성
  주문 · 결제 → PostgreSQL (강한 일관성)
  세션 · 캐시 → Redis (빠름 · 유실 허용)
  상품 검색   → Elasticsearch (최종 일관성)
  이벤트 로그 → Kafka (순서 보장 · 재처리 가능)
  파일        → S3 (최종 일관성 · 지금은 강한 읽기)

이것을 '다중 저장소 지속성(polyglot persistence)' 이라 부른다
비용: 데이터 동기화 · 일관성 관리가 복잡해진다
저장소를 늘리는 것에는 큰 비용이 따릅니다 — 동기화 · 백업 · 운영 · 장애 대응이 배로 늡니다. 정말 필요한지를 먼저 묻고, PostgreSQL 하나로 해결되는지 검토하세요.

실무에선 이렇게 — 분단 시 동작을 미리 정하기 🔴 실무

설계 문서에 반드시 적을 것

  "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)
# ★ 부가 기능의 장애가 핵심 기능을 막지 않게 한다
“전부 아니면 전무”가 가장 나쁜 설계입니다 — 추천 서비스가 죽었다고 상품 페이지가 안 뜨면 작은 장애가 큰 장애가 됩니다. 필수와 선택을 구분하고 선택은 타임아웃을 짧게 두세요.
CAP — "셋 중 둘" 이 아니라 "끊겼을 때 무엇을 포기하나" 평소 (네트워크가 멀쩡할 때) 일관성도 가용성도 둘 다 됩니다 — 고를 필요가 없습니다 네트워크가 끊겼습니다 — 이제 하나를 골라야 합니다 가용성을 고른다 (AP) 옛 값이라도 일단 응답합니다 나중에 맞춰집니다 (최종 일관성) 장바구니 · 게시글 · 추천 일관성을 고른다 (CP) 확실하지 않으면 응답을 거부합니다 틀린 값을 주느니 오류를 냅니다 잔액 · 재고 · 좌석 예약 P(분단 감내)는 고를 수 있는 항목이 아닙니다 — 분산 시스템이라면 네트워크는 언젠가 끊깁니다 PACELC 가 덧붙이는 것 — 끊기지 않은 평소에도 지연과 일관성 사이에서 고르고 있습니다 시스템 전체가 아니라 기능별로 고르세요 — 같은 서비스 안에서도 답이 다릅니다
설계 회의에서 물어야 할 문장 — 이 기능은 끊겼을 때 무엇을 포기하나.
DIST 08

일관성 모델 — “쓴 걸 바로 읽을 수 있나”

복제본을 두는 순간 여러 단계의 답이 생깁니다. 각 모델이 무엇을 보장하고 무엇은 안 하는지, 그리고 사용자가 어떤 이상함을 겪게 되는지를 봅니다.

강함
선형화 (Linearizability)단일 사본처럼 동작 · 모든 연산이 실시간 순서를 지킴 · 가장 비쌈
▼ 약해질수록 빠르고 가용성이 높아진다
순차
순차 일관성모두가 같은 순서로 보지만 실시간과는 다를 수 있음
▼
인과
인과 일관성인과 관계가 있는 것만 순서 보장 (댓글이 원글보다 먼저 안 보임)
▼
세션
읽기-내-쓰기 · 단조 읽기내가 쓴 건 내가 보고, 시간이 뒤로 가지 않음
▼
약함
최종 일관성언젠가는 같아짐 · 그 사이에는 무엇이든 보일 수 있음

사용자가 겪는 이상함

증상깨진 보장
글을 썼는데 목록에 없다읽기-내-쓰기
새로고침하면 있다가 없다가단조 읽기
답글이 원글보다 먼저 보인다인과 일관성
잔액이 잠깐 이상하다선형화
주문 후 목록에 안 보인다읽기-내-쓰기
대부분의 사용자 불만은 “읽기-내-쓰기”가 깨질 때 나옵니다 — 남의 데이터가 몇 초 늦는 것은 아무도 모르지만, 내가 방금 한 일이 안 보이면 즉시 알아챕니다.

실무적인 해결

# ① 쓰기 직후에는 리더에서 읽는다 (가장 간단)
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 으로 빠르게
같은 저장소 안에서도 요청마다 다르게 고를 수 있습니다 — 중요한 쓰기는 QUORUM, 로그는 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
지연이 커지는 원인대응
대량 배치 쓰기청크로 나눠 실행 · 저부하 시간에
복제본에서 무거운 쿼리충돌하는 쿼리 격리 · 타임아웃
단일 스레드 적용병렬 복제 활성화
네트워크 대역폭압축 · 리전 간 구성 재검토
복제본 하드웨어가 약함동등한 사양으로
복제 지연은 반드시 모니터링하세요 — 지연이 커지면 읽기 복제본을 쓰는 모든 기능이 조용히 오래된 데이터를 보여 줍니다. 임계값을 정하고, 넘으면 자동으로 리더로 폴백하는 것도 방법입니다.
DIST 09

합의 — 여러 대가 같은 값에 동의하는 법

리더 선출 · 분산 락 · 설정 저장이 전부 합의 위에 있습니다. Raft 를 직접 구현할 일은 없어도, 왜 느리고 무엇을 보장하는지는 알아야 잘 쓸 수 있습니다.

Raft 의 세 요소

  1. 리더 선출 — 임기(term)마다 하나의 리더. 하트비트가 끊기면 후보가 되어 투표를 요청하고, 과반 득표로 리더가 됩니다.
  2. 로그 복제 — 모든 쓰기는 리더를 거칩니다. 리더가 팔로워에게 로그를 보내고 과반이 기록하면 커밋합니다.
  3. 안전성 — 커밋된 항목은 절대 사라지지 않고 순서가 바뀌지 않습니다.
노드 상태
  Follower  → 타임아웃 → Candidate → 과반 득표 → Leader
                              ↓ 실패
                          다시 Follower

★ 무작위 선거 타임아웃으로 동시 후보를 줄인다
★ 쓰기 한 번에 최소 1 RTT × (리더 → 과반 팔로워)
  → 지연이 있는 다중 리전에서는 비싸다

쓰는 곳과 주의점

시스템용도
etcd쿠버네티스의 상태 저장소
ZooKeeperKafka · HBase 의 코디네이션
Consul서비스 디스커버리 · 설정
TiKV · CockroachDB분산 트랜잭션 저장소
Kafka (KRaft)ZooKeeper 대체
합의 저장소를 일반 DB 처럼 쓰지 마세요 — 모든 쓰기가 과반 합의를 거치므로 처리량이 낮습니다. 설정 · 메타데이터 · 리더 정보처럼 작고 자주 안 바뀌는 것에만 씁니다. etcd 가 느려지면 쿠버네티스 전체가 느려집니다.

이렇게도 쓸 수 있다 — 분산 락의 어려움 🟡 중급

분산 락은 생각보다 위험하다

  ① 클라이언트 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 유니크 제약 · 조건부 UPDATE · 멱등성 키가 훨씬 단순하고 확실합니다. 락은 “중복 작업을 줄이면 좋겠다” 수준의 효율 목적에만 쓰세요.

실무에선 이렇게 — 리더 선출이 필요한 경우 🔴 실무

상황대안
스케줄된 배치를 한 대만 실행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 공유
“합의가 필요 없게 설계하는 것”이 최선입니다 — 상태를 없애거나(무상태), 파티션으로 나누거나, 멱등하게 만들면 합의 없이도 안전합니다. 합의는 마지막 수단으로 남겨 두세요.
합의의 장치는 하나입니다 — 과반 노드 1 노드 2 노드 3 노드 4 노드 5 5대 중 3대가 동의하면 결정됩니다 — 나머지 2대가 죽거나 느려도 계속 돕니다 왜 과반이면 충분한가 어떤 두 과반 집합도 최소 한 대는 겹칩니다 — 그 한 대가 서로 다른 두 결정을 막습니다 그래서 네트워크가 갈라져도 서로 다른 값이 동시에 확정되는 일이 없습니다 갈라졌을 때 3대 쪽은 계속 동작합니다 2대 쪽은 스스로 멈춥니다 왜 홀수인가 4대의 과반도 3대입니다 — 늘려도 내구성이 같습니다 2대는 한 대만 죽어도 멈춥니다 Raft · Paxos · ZAB 모두 이 원리 위에 있습니다 — etcd · ZooKeeper · Consul 이 이렇게 돕니다 합의는 비쌉니다 — 모든 쓰기에 쓰지 말고 리더 선출·설정 관리처럼 꼭 필요한 곳에만 두세요
"여러 대가 같은 값에 동의한다" 는 어려운 문제의 답이 겹치는 과반 하나입니다.
DIST 10

복제 · 샤딩 — 복사할 것인가 나눌 것인가

복제는 같은 데이터를 여러 곳에(가용성 · 읽기 확장), 샤딩은 데이터를 쪼개서 나눠(쓰기 · 용량 확장). 목적이 다르고 대개 둘 다 씁니다.

복제 방식

방식특징
단일 리더쓰기는 리더만 · 가장 흔함 · 충돌 없음
다중 리더여러 곳에서 쓰기 · 충돌 해결 필요
리더 없음아무 노드에나 · 쿼럼으로 (Dynamo 계열)
동기화내용트레이드오프
동기복제본 응답까지 대기안전 · 느림 · 가용성↓
비동기바로 응답빠름 · 장애 시 유실 가능
반동기일부만 동기실무 절충
비동기 복제에서 리더가 죽으면 데이터가 사라집니다 — 아직 복제되지 않은 쓰기는 회복되지 않습니다. RPO(허용 데이터 손실)를 명시적으로 정하세요.

샤딩 키 고르기

방식장점단점
범위범위 조회 효율핫스팟 (최신 데이터에 몰림)
해시고른 분산범위 조회 불가
일관 해싱노드 변경에 강함구현 복잡
디렉터리유연함조회 계층 추가
지역 기반지연 · 규제 대응불균형
샤딩 키 선택 기준
 ① 카디널리티가 높은가 (값 종류가 충분한가)
 ② 고르게 분산되는가 (핫스팟이 없는가)
 ③ ★ 대부분의 쿼리가 한 샤드에서 끝나는가
 ④ 시간이 지나도 균형이 유지되는가

나쁜 예
  · 국가 코드 — 특정 국가에 몰림
  · 생성 시각 — 최신 샤드에만 쓰기 집중
  · 사용자 등급 — 카디널리티가 낮음
좋은 예
  · 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), 로컬 캐시로 흡수해야 합니다.
DIST 11

분산 트랜잭션 — “다 되거나 다 안 되게”

여러 서비스에 걸친 작업을 원자적으로 만드는 문제입니다. 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()

# ★ ① 과 ② 가 같은 트랜잭션이어야 한다
#   따로 하면 기록만 되고 처리가 안 되는 경우가 생긴다
“정확히 한 번”을 요구받으면 이렇게 답하세요 — “전달은 최소 한 번으로 하고, 처리를 멱등하게 만들겠습니다. 결과적으로 한 번 처리된 것과 같습니다.” 이것이 실무에서 유일하게 동작하는 답입니다 → 13절.
DIST 12

시간과 순서 — 서버 시계를 믿지 마세요

“더 나중에 쓴 것이 이긴다”는 규칙은 시계가 정확하다는 가정 위에 있습니다. 그 가정이 깨지면 데이터가 조용히 사라집니다.

시계는 왜 못 믿나

문제내용
클럭 드리프트수정 없이 하루에 수십 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타임스탬프 + 랜덤정렬 가능 · 표준
ULID48비트 시각 + 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
정렬 가능한 ID 를 쓰면 B+트리 인덱스가 훨씬 효율적입니다 — 순차 삽입이 되어 페이지 분할과 랜덤 I/O 가 줄어듭니다 (🗂️ 자료구조 08). 새 프로젝트라면 UUID v7 또는 ULID 를 기본으로 두세요.

실무에선 이렇게 — LWW 의 위험 🔴 실무

Last-Write-Wins (마지막 쓰기가 이김)
  · 타임스탬프가 큰 쪽을 채택
  · 구현이 단순해서 널리 쓰인다

★ 위험
  · 시계가 어긋난 노드의 쓰기가 항상 이긴다
  · 시계가 앞선 노드의 옛 데이터가 최신 데이터를 덮어쓴다
  · ★ 조용히 데이터가 사라진다 (에러도 안 난다)

대안
  · 벡터 시계로 충돌을 감지하고 애플리케이션이 병합
  · CRDT — 수학적으로 병합이 항상 수렴하도록 설계
    (카운터 · 집합 · 텍스트 등 자료형별)
  · 단일 리더로 충돌 자체를 없앰 ★ 가장 단순한 답
  · 하이브리드 논리 시계(HLC) — 물리 + 논리 시계 결합
시간 관련 실무 규칙이유
저장은 UTC 로시간대·서머타임 혼란 방지
표시할 때만 지역 시간사용자 시간대 적용
경과 시간은 단조 시계NTP 조정에 안전
NTP 를 반드시 설정chrony · 클라우드 시간 동기화
시계 오프셋을 모니터링드리프트를 조기 발견
미래·과거 타임스탬프 검증클라이언트가 보낸 시각을 믿지 말 것
Kafka 의 로그 보존 · TTL · 토큰 만료가 시계에 의존합니다 — 시계가 크게 어긋나면 데이터가 즉시 삭제되거나 만료된 토큰이 통과할 수 있습니다. NTP 는 선택이 아니라 필수 설정입니다.
서버 시계는 서로 다릅니다 — 순서의 근거가 못 됩니다 벽시계로 순서를 정하면 서버 A: 12:00:00.120 에 기록 서버 B: 12:00:00.115 에 기록 B 가 먼저처럼 보이지만 시계가 5ms 어긋난 것뿐 논리 시계 — 사건을 셉니다 메시지를 주고받을 때마다 카운터를 올립니다 "A 가 B 보다 먼저 일어났다"는 인과 관계를 담습니다 벡터 시계는 동시 발생까지 구분합니다 실무에서 쓰는 방법 DB 시퀀스 · 단조 증가 ID (ULID · Snowflake) "누가 먼저인가"를 한 곳에서 정하게 만듭니다 시각은 사람이 보는 용도로만 남깁니다 시계 동기화의 한계 NTP 로 맞춰도 수 ms ~ 수십 ms 오차가 남습니다 시계가 뒤로 갈 수도 있습니다(보정 · 윤초) 경과 시간 측정에는 단조 시계(monotonic)를 쓰세요 "타임스탬프가 큰 쪽이 최신"이라는 가정으로 충돌을 해결하면, 시계가 어긋난 순간 최신 데이터가 옛 데이터에 덮입니다
분산 시스템에서 "동시에"라는 말은 정의부터 다시 해야 합니다.
서버 시계는 서로 다릅니다 — 순서의 근거가 못 됩니다 벽시계로 순서를 정하면 서버 A: 12:00:00.120 에 기록 서버 B: 12:00:00.115 에 기록 B 가 먼저처럼 보이지만 시계가 5ms 어긋난 것뿐 논리 시계 — 사건을 셉니다 메시지를 주고받을 때마다 카운터를 올립니다 "A 가 B 보다 먼저 일어났다"는 인과 관계를 담습니다 벡터 시계는 동시 발생까지 구분합니다 실무에서 쓰는 방법 DB 시퀀스 · 단조 증가 ID (ULID · Snowflake) "누가 먼저인가"를 한 곳에서 정하게 만듭니다 시각은 사람이 보는 용도로만 남깁니다 시계 동기화의 한계 NTP 로 맞춰도 수 ms ~ 수십 ms 오차가 남습니다 시계가 뒤로 갈 수도 있습니다(보정 · 윤초) 경과 시간 측정에는 단조 시계(monotonic)를 쓰세요 "타임스탬프가 큰 쪽이 최신"이라는 가정으로 충돌을 해결하면, 시계가 어긋난 순간 최신 데이터가 옛 데이터에 덮입니다
분산 시스템에서 "동시에"라는 말은 정의부터 다시 해야 합니다.
DIST 13

멱등성 · 재시도 · 장애 대응 — 실무 패턴 모음

분산 시스템에서 가장 자주 쓰는 다섯 가지 패턴입니다. 이 절만 제대로 적용해도 장애의 상당 부분이 사라집니다.

① 멱등성 — 모든 것의 기초

# 클라이언트가 멱등성 키를 보낸다 (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 를 쓰면 이 전파가 자동으로 된다
다음 탭 추천 — 🏛️ 소프트웨어 공학으로 가면 여기서 본 패턴들을 설계와 운영 체계로 정리합니다. 특히 12 관측 가능성 절이 이 탭과 바로 이어집니다. 동시성의 하드웨어 근거는 🖥️ 컴퓨터구조 07·10, OS 관점은 ⚙️ 운영체제 07·08 에 있습니다.
DIST 14

Raft 를 단계별로 — 리더 선출 · 로그 복제 · 안전성

09 절에서 “합의가 왜 필요한가”를 봤다면, 여기서는 실제로 어떻게 도는지를 봅니다. etcd · Consul · TiKV · CockroachDB 가 전부 Raft 로 돌고, 쿠버네티스의 상태도 그 위에 얹혀 있습니다. Paxos 보다 이해하기 쉽게 설계된 것이 Raft 의 목표였고, 그 설계를 따라가 봅니다.

합의(consensus) — 여러 서버가 같은 값의 같은 순서에 동의하는 것. 과반(quorum)이 살아 있으면 동작하고, 과반이 깨지면 멈춥니다(틀린 답을 내지 않습니다). 5대 클러스터는 2대까지, 3대 클러스터는 1대까지 장애를 견딥니다 — f 대를 견디려면 2f+1 대가 필요합니다.
Raft 의 세 가지 상태 — 서버 하나가 오가는 길 Follower 기본 상태 · 듣기만 함 신호 끊김 Candidate 임기 +1 · 투표 요청 과반 득표 Leader 모든 쓰기를 받음 더 높은 임기(term)를 보면 즉시 Follower 로 내려옵니다 표 부족 · 타임아웃 임기(term)는 논리적 시계입니다 — 선거가 열릴 때마다 1 씩 오르고, 낡은 리더를 구분하는 유일한 근거입니다. 선거 타임아웃을 서버마다 무작위로 흩뿌립니다(예: 150~300ms). 동시에 후보가 되어 표가 갈리는 것을 막습니다. 표가 갈리면 아무도 과반을 못 얻고 다음 임기에 다시 선거합니다 — 무작위 덕분에 대개 한두 번에 끝납니다. 한 임기에 표는 한 번만 줍니다. 그래서 한 임기에 리더가 둘일 수 없습니다(과반은 반드시 겹치니까요). 네트워크가 갈라져 옛 리더가 살아 있어도, 과반을 못 가진 쪽은 커밋을 못 하므로 데이터가 갈라지지 않습니다.
과반이 반드시 겹친다는 성질 하나가 Raft 안전성의 뿌리입니다 — 두 과반은 최소 한 서버를 공유합니다.

로그 복제 — 쓰기 하나가 커밋되기까지

set x=5 를 보냈을 때 — 다섯 단계 클라이언트 set x=5 ① 리더 로그에 기록 아직 커밋 아님 ② 팔로워에 복제 요청 AppendEntries ③ 각자 기록 디스크에 먼저 ④ 과반이 성공 응답 → 커밋 확정 5대 중 3대면 충분 — 전원을 기다리지 않습니다 ⑤ 상태 기계 적용 후 응답 여기서야 클라이언트가 성공을 받습니다 주의 — 클라이언트가 응답을 못 받았다고 실패한 것이 아닙니다. 커밋은 됐는데 응답만 유실됐을 수 있습니다. 그래서 재시도에는 반드시 멱등 키가 필요합니다(13절). 전원이 아니라 과반만 기다리는 것이 핵심입니다 — 느린 서버 한 대가 전체를 붙잡지 못합니다. 대신 지연은 "과반 중 가장 느린 서버"가 정합니다. 그래서 지역을 넘나드는 클러스터는 쓰기가 느려집니다. 읽기도 리더를 거쳐야 최신입니다 — 팔로워에서 읽으면 과거를 볼 수 있습니다(리스 읽기로 완화).
쓰기 지연 = 과반 중 가장 느린 서버까지의 왕복 + 디스크 fsync — 이 식이 Raft 기반 시스템 성능의 상한입니다.

안전성을 지키는 규칙 넷

규칙없으면 생기는 일
한 임기에 한 표리더가 둘이 되어 데이터가 갈라집니다
로그가 최신인 후보만 당선커밋된 항목이 사라질 수 있습니다
리더는 로그를 덮어쓰지 않음확정된 순서가 바뀝니다
이전 임기 항목은 간접 커밋과반이 있어도 뒤집힐 수 있습니다
'로그가 최신인가' 를 비교하는 법 (RequestVote)

  ① 마지막 항목의 임기가 더 크면 더 최신
  ② 임기가 같으면 로그가 더 길수록 더 최신

  → 커밋된 항목은 반드시 과반에 있고,
    당선되려면 과반의 표가 필요하므로,
    새 리더는 커밋된 항목을 '반드시' 갖고 있습니다.

  이것이 "커밋된 것은 절대 사라지지 않는다" 의 증명입니다.
가장 미묘한 규칙은 네 번째입니다. 새 리더가 이전 임기의 항목을 과반에 복제했다고 해서 곧바로 커밋으로 셀 수는 없습니다 — 그 상태에서 리더가 죽으면 뒤집힐 수 있는 시나리오가 존재합니다. 그래서 Raft 는 자기 임기의 항목이 하나 커밋될 때 이전 것들을 함께 커밋합니다. 논문에서 가장 많이 오해되는 부분이고, 실제 구현 버그도 여기서 자주 나옵니다.

운영에서 만나는 것들

증상원인과 조치
리더 선거가 계속 반복선거 타임아웃 < 네트워크 지연 · 디스크 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대 이상은 합의 왕복이 늘어 오히려 느려집니다
Raft 를 직접 구현하지 마세요. 논문은 읽기 쉽지만 구현은 악명 높게 어렵습니다 — 멤버십 변경·스냅샷·로그 압축·클라이언트 세션까지 맞물려 있고, 버그는 드문 장애 상황에서만 드러납니다. etcd · Consul · 검증된 라이브러리를 쓰고, 여기서 배운 것은 지표를 읽고 장애를 진단하는 데 쓰세요.

Paxos · Raft · ZAB — 무엇이 다른가

알고리즘쓰는 곳특징
PaxosChubby · Spanner가장 먼저 증명된 원형. 이해와 구현이 어렵습니다
Multi-Paxos대부분의 Paxos 구현리더를 두어 왕복을 줄인 실용 버전 — 사실상 Raft 와 비슷해집니다
Raftetcd · Consul · TiKV이해 가능성을 목표로 설계. 리더 중심 · 로그가 항상 연속
ZABZooKeeperRaft 와 유사. 순서 보장에 더 강조점
Viewstamped Replication—Raft 와 거의 같은 아이디어를 1988년에 제시
비잔틴 합의(PBFT 등)블록체인거짓말하는 노드까지 가정 — 3f+1 대 필요, 훨씬 비쌉니다
Raft 와 Paxos 는 “다른 원리”가 아닙니다. Multi-Paxos 를 실용적으로 다듬으면 Raft 와 거의 같은 구조가 됩니다 — 차이는 알고리즘의 힘이 아니라 설명 가능성에 있습니다. Raft 논문의 제목이 “In Search of an Understandable Consensus Algorithm” 인 이유입니다.
합의가 왜 필요한지는 09 절, 시계와 순서는 12 절, 분산 트랜잭션과의 관계는 11 절에서 이어집니다.
코드를 쓰는 것보다 오래 사는 것 — 설계 · 테스트 · 협업 · 운영

좋은 설계는
취향이 아니라
변경 비용입니다

“깔끔하다”는 판단은 사람마다 다르지만 “바꿀 때 몇 군데를 건드려야 하나”는 셀 수 있습니다. 이 탭은 설계 원칙 · 디자인 패턴 · 아키텍처를 그 기준으로 정리하고, 테스트 · 배포 · 관측 · 협업까지 이어갑니다. 기법을 외우는 것이 아니라 언제 쓰고 언제 쓰지 않을지를 봅니다.

13SECTIONS
SOLID· 패턴
테스트· CI/CD
SLO· 관측
SE 01

추상화 · 결합도 · 응집도 — 설계를 재는 자

좋은 설계의 기준은 “이 요구가 바뀌면 어디를 고쳐야 하나”입니다. 한 군데면 좋은 설계이고, 열 군데면 나쁜 설계입니다. 나머지는 전부 이 문장의 파생입니다.

결합도 — 낮을수록 좋다

종류내용평가
내용 결합남의 내부를 직접 건드림최악
공통 결합전역 변수 공유나쁨
제어 결합플래그로 상대 동작을 조종나쁨
스탬프 결합필요 없는 것까지 통째로 전달보통
데이터 결합필요한 값만 인자로좋음
메시지 결합메시지·이벤트로만가장 낮음
# 제어 결합 — 플래그가 함수 안에서 갈라진다
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 의 아이디어)
“자주 바뀌면서 복잡한 파일”이 리팩터링 1순위입니다 — 복잡하지만 안 바뀌는 코드는 건드릴 이유가 없습니다. git 히스토리가 이 판단의 데이터가 됩니다.
결합도와 응집도 — 두 축으로 설계를 잽니다 높은 결합도 — 서로 직접 붙잡고 있음 A B C D E B 를 고치면 A · C · D · E 를 전부 확인해야 합니다 낮은 결합도 — 약속(인터페이스)을 통해서만 A D 인터페이스 B C B 의 내부를 바꿔도 약속만 지키면 A · D 는 그대로 결합도는 "바꿀 때 함께 열어야 하는 파일 수"입니다 — 커밋 하나에 들어간 파일 수를 세어 보면 실측됩니다. 응집도는 "한 파일이 몇 가지 이유로 바뀌는가"입니다 — 서로 다른 이유로 자주 바뀌면 쪼갤 신호입니다. 목표는 결합도를 0 으로 만드는 것이 아닙니다 — 아무 연결이 없으면 프로그램이 아닙니다. "바뀌는 이유가 같은 것끼리 모으고, 다른 것끼리는 약속으로만 잇는다" 가 두 지표의 요약입니다.
두 지표는 독립이 아닙니다 — 응집도를 올리면(관련된 것끼리 모으면) 결합도는 대개 저절로 내려갑니다.

등급으로 보기 — 어디까지가 위험한가

결합도 — 나쁜 것부터

종류무엇을 공유하나
내용 결합남의 내부를 직접 건드림 — 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), 이름이 안 지어지면 아직 그 추상화가 없다는 뜻입니다.
SE 02

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())
# 빠르고 · 격리되고 · 검증하기 쉽다
파이썬 · Go 에서는 인터페이스를 명시하지 않아도 됩니다 — Protocol(파이썬) · 암묵적 인터페이스(Go)로 선언 없이 같은 효과를 얻습니다. 중요한 것은 주입이지 인터페이스 선언이 아닙니다.

과하게 적용하면

증상원인
구현이 하나뿐인 인터페이스가 가득D 를 기계적으로 적용
클래스가 수십 개인데 각 3줄S 를 과하게
추적하려면 5단계를 타고 가야추상화 계층 과다
팩토리의 팩토리패턴 남용
설정으로 모든 것을 조절O 를 오해
“Enterprise FizzBuzz” 라는 유명한 농담이 있습니다 — FizzBuzz 를 SOLID·패턴으로 구현하면 수십 개 파일이 됩니다. 원칙은 문제를 해결할 때만 가치가 있습니다.

이렇게도 쓸 수 있다 — 다른 유용한 원칙들 🟡 중급

원칙내용
YAGNI필요해질 때까지 만들지 마라 — 가장 자주 어겨진다
KISS단순하게 — 영리한 코드보다 지루한 코드
DRY지식의 중복을 피하라 — 코드 모양의 중복이 아니다
최소 놀람이름과 동작이 예상과 일치할 것
데메테르 법칙a.b().c().d() 를 피하라 (친구의 친구)
합성 > 상속상속은 가장 강한 결합
실패는 빨리잘못된 상태로 진행하지 말 것
포스텔 원칙 (견고성 원칙)입력에는 관대하게, 출력에는 엄격하게 (지금은 논쟁적)
# DRY 의 오해 — 우연히 같아 보이는 코드를 합치면 나중에 아프다
# ✗ 지금 모양이 같다고 합침
def validate(data, kind):
    if kind == "user":  ...
    elif kind == "order": ...
    # 둘의 규칙이 나중에 갈라지면 이 함수가 지옥이 된다

# ✓ 지식이 같은 것만 합친다
#   "이메일 형식 검증" 은 하나 — 규칙이 진짜 같다
#   "사용자 검증" 과 "주문 검증" 은 다른 지식 — 따로 둔다

# 판단 기준: "이 규칙이 바뀔 때 둘 다 바뀌어야 하는가?"
#   예 → 합친다 (같은 지식)
#   아니오 → 따로 둔다 (우연한 유사성)
“성급한 추상화보다 중복이 낫다”(Sandi Metz)는 실무에서 반복적으로 확인되는 조언입니다. 중복은 눈에 보이고 나중에 합칠 수 있지만, 잘못된 추상화는 모두를 그 안에서 일하게 만듭니다.

실무에선 이렇게 — 상속을 피하는 이유 🔴 실무

✗ 상속으로 재사용
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)모든 하위 타입을 아는 경우
“코드 재사용을 위한 상속”이 문제입니다 — 재사용이 목적이면 합성이나 함수 추출이 맞습니다. 상속은 다형성이 필요할 때만 쓰고, 그마저도 인터페이스로 충분한 경우가 많습니다.
SOLID — 어겼을 때 무엇이 아픈지로 외우세요 S — 한 가지 이유로만 바뀌게 어기면 — 화면 요구와 세금 규칙 변경이 같은 파일에서 충돌합니다 O — 더할 때 기존 코드를 안 열게 어기면 — 결제 수단 하나 늘릴 때마다 잘 돌던 분기문을 다시 건드립니다 L — 자식이 부모 자리를 대신할 수 있게 어기면 — 목록에 담아 돌리다가 특정 타입에서만 예외가 납니다. 다형성이 무너집니다 I — 인터페이스를 작게 어기면 — 안 쓰는 함수까지 구현하고 안에 예외를 던져 둡니다 D — 구체가 아니라 약속에 의존하게 어기면 — 테스트에서 가짜로 갈아 끼울 수 없어 DB 없이는 한 줄도 검증 못 합니다
다섯 개를 다 지키는 것이 목표가 아닙니다 — 고치기 무서운 코드를 줄이는 것이 목표입니다.
SE 03

생성 패턴 — 객체를 어떻게 만들까

객체 생성 책임을 분리하면 테스트와 교체가 쉬워집니다. 다만 현대 언어와 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())
DI 컨테이너를 쓸지 말지는 규모의 문제입니다 — 의존성이 수십 개면 컨테이너가 도움이 되지만, 열 개 남짓이면 수동 조립이 더 명확합니다. “마법”이 적을수록 디버깅이 쉽습니다.
SE 04

구조 패턴 — 기존 것을 건드리지 않고 덧붙이기

객체들을 어떻게 조립할 것인가에 대한 패턴들입니다. 외부 라이브러리를 감싸거나, 기능을 층층이 얹거나, 복잡한 시스템에 단순한 창구를 만들 때 씁니다.

자주 쓰는 것

패턴목적실무 예
어댑터인터페이스 변환외부 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)
트랜잭션 경계 불명확어디서 커밋되는지 모름
서비스끼리 직접 호출하면 순환이 생깁니다 — 이벤트로 바꾸거나 공통 로직을 아래 계층으로 내리세요. 의존성 방향은 한쪽으로만 흘러야 합니다 → 06절.
SE 05

행위 패턴 — 분기문을 구조로 바꾸기

길어지는 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)
# 추가는 표에 한 줄
“패턴”이라고 부르지 않아도 됩니다 — 파이썬 · JS 에서는 함수를 값으로 넘기는 것이 곧 전략 패턴입니다. 클래스를 만들 필요가 없는 경우가 대부분입니다.

옵서버 — 느슨한 알림

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)
메멘토스냅숏 · 되돌리기
미들웨어가 책임 연쇄 패턴입니다 — Express · Django · ASP.NET 의 미들웨어 체인이 전부 “각자 처리하고 다음에게 넘기거나 여기서 끝내는” 구조입니다. 패턴 이름을 몰라도 이미 쓰고 있습니다.

실무에선 이렇게 — 패턴을 언제 쓰지 않을까 🔴 실무

상황판단
분기가 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 는 ★ 망라성까지 검사한다
# → 새 케이스를 추가하면 처리를 빠뜨린 곳을 컴파일러가 알려 준다
패턴은 어휘입니다 — “여기 어댑터를 두자”라는 한마디로 설계 의도가 전달되는 것이 진짜 가치입니다. 구현을 정형화하기 위한 것이 아닙니다.
SE 06

아키텍처 — 의존성이 어느 방향으로 흐르나

아키텍처 패턴의 이름은 여러 가지지만 핵심은 하나입니다 — 비즈니스 로직이 프레임워크·DB·외부 서비스에 의존하지 않게 하는 것.

바깥
인프라 · 어댑터웹 프레임워크 · DB 드라이버 · 외부 API · 메시지 브로커
▼ 의존성은 안쪽으로만 흐른다
응용
유스케이스 · 서비스흐름 조율 · 트랜잭션 경계 · 포트(인터페이스) 정의
▼
안쪽
도메인엔티티 · 값 객체 · 비즈니스 규칙 — 아무것도 import 하지 않는다

이름은 달라도 같은 아이디어

이름핵심 용어
레이어드표현 · 응용 · 도메인 · 인프라
헥사고날포트 & 어댑터
어니언동심원 · 안쪽이 핵심
클린 아키텍처의존성 규칙
수직 슬라이스기능 단위로 세로 분할
디렉터리 구조 예 (수직 슬라이스 + 계층)

  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
# 기능끼리 직접 참조 금지 (이벤트나 공용 계층을 통해서만)
규칙은 자동으로 검사해야 지켜집니다 — 자바는 ArchUnit, .NET 은 NetArchTest, TS 는 dependency-cruiser · ESLint 규칙이 같은 일을 합니다. 코드 리뷰로 지키려 하면 반드시 무너집니다.

이렇게도 쓸 수 있다 — 모놀리스 vs 마이크로서비스 🟡 중급

모놀리스마이크로서비스
배포한 번에독립 배포
트랜잭션DB 트랜잭션으로 간단사가 · 최종 일관성
디버깅스택 트레이스 하나분산 추적 필요
확장전체를 함께부분만
기술 다양성제한자유
팀 독립성낮음높음
운영 복잡도낮음매우 높음
지연함수 호출네트워크 (🌐 12)
★ 현실적인 조언

 ① 모듈러 모놀리스로 시작한다
    · 하나의 배포 단위 · 내부는 명확한 모듈 경계
    · 모듈 간에는 공개 인터페이스로만 통신
    · DB 스키마도 모듈별로 분리 (다른 모듈 테이블 직접 접근 금지)

 ② 경계가 검증되면 필요한 것만 떼어 낸다
    · 확장 요구가 다른 부분
    · 배포 주기가 다른 부분
    · 팀이 분리된 부분

 ③ 마이크로서비스는 조직 구조의 문제이기도 하다
    (콘웨이의 법칙 — 시스템 구조는 조직 구조를 닮는다)

★ "분산 모놀리스" 가 최악이다
  서비스는 나눴는데 서로 강하게 결합돼
  같이 배포해야 하고, 하나가 죽으면 전부 죽는다
마이크로서비스는 기술이 아니라 조직 결정입니다 — 팀이 3개인데 서비스가 30개면 운영 부담만 늘어납니다. “이 경계로 팀을 나눌 수 있는가”가 실질적인 기준입니다.

실무에선 이렇게 — 아키텍처 결정 기록(ADR) 🔴 실무

# ADR-014: 주문 이벤트 발행에 아웃박스 패턴 사용

## 상태
채택됨 (2026-03-12)

## 맥락
주문 생성 시 DB 저장과 Kafka 발행을 함께 해야 한다.
현재는 저장 후 발행하는데, 발행 실패 시 이벤트가 유실되고 있다.
지난 분기에 3건의 정합성 불일치가 발생했다.

## 결정
아웃박스 테이블을 도입하고 별도 릴레이가 발행한다.

## 근거
- 원자성을 DB 트랜잭션 하나로 확보할 수 있다
- 2PC 는 가용성 저하와 운영 복잡도가 크다
- Debezium CDC 는 인프라 추가 부담이 있어 다음 단계로 미룬다

## 결과
- 최소 한 번 전달 → 소비자를 멱등하게 만들어야 한다
- 릴레이 지연을 모니터링해야 한다 (목표 p99 < 2초)
- 아웃박스 테이블 정리 배치가 필요하다

## 대안
- 2PC: 가용성 저하로 기각
- 발행 후 저장: 유실 방향만 바뀔 뿐
- CDC: 인프라 준비 후 재검토 (ADR-020 예정)
ADR 의 가치는 “왜”를 남기는 것입니다 — 코드는 무엇을 하는지 보여 주지만 왜 그렇게 했는지는 안 보여 줍니다. 1년 뒤에 “이걸 왜 이렇게 했지?”라는 질문에 답할 수 있게 됩니다. 파일로 저장소에 함께 두는 것이 위키보다 오래갑니다.
이름보다 중요한 것 — 의존성이 어느 쪽으로 흐르나 흔한 구조 — 안쪽이 바깥을 압니다 컨트롤러 서비스 (업무 규칙) DB 코드 (SQL · ORM) 업무 규칙이 특정 DB 기술에 묶입니다 — DB 없이는 테스트도 못 합니다 방향을 뒤집으면 — 바깥이 안쪽을 압니다 컨트롤러 서비스 + 저장 약속(인터페이스) DB 구현 — 약속을 구현 업무 규칙이 기술을 모릅니다 — 테스트에서 메모리 구현으로 갈아 끼울 수 있습니다 헥사고날 · 클린 · 어니언은 이름만 다를 뿐 전부 이 그림을 말합니다 작은 프로젝트에 억지로 넣으면 파일만 늘어납니다 — 업무 규칙이 두꺼워질 때 값이 생깁니다
아키텍처 논쟁의 대부분은 화살표를 그려 보면 정리됩니다.
SE 07

도메인 주도 설계 — 코드의 이름을 업무의 말과 같게

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만 행
  · 주문 하나 추가에 고객 전체를 잠근다
경계를 정하는 질문
이 규칙이 즉시 지켜져야 하는가예 → 같은 애그리거트
잠깐 어긋나도 되는가예 → 다른 애그리거트 + 이벤트
함께 수정되는 일이 잦은가예 → 같은 애그리거트 후보
크기가 무한히 커질 수 있는가예 → 분리해야 함
“즉시 일관성이 필요한가”가 유일한 기준입니다 — 재고 차감과 주문 확정이 반드시 함께여야 하면 같은 애그리거트이거나 같은 트랜잭션이어야 하고, 몇 초 늦어도 되면 이벤트로 나눌 수 있습니다 (🕸️ 분산 11).

실무에선 이렇게 — DDD 를 도입할 때와 안 할 때 🔴 실무

적합부적합
업무 규칙이 복잡하다CRUD 가 대부분이다
도메인 전문가와 협업한다요구가 단순·명확하다
오래 유지될 시스템프로토타입 · 단기 프로젝트
여러 팀이 나눠 작업1~2명이 전부
용어 혼란이 실제 문제다—
부분 도입이 현실적이다

  ★ 거의 항상 가치 있는 것
   · 유비쿼터스 언어 — 이름을 업무 용어로 (무료다)
   · 값 객체 — Money · Email · PhoneNumber (버그가 크게 준다)
   · 명시적 불변식 — 항상 유효한 상태 유지

  ★ 복잡도가 높을 때만
   · 애그리거트 · 리포지토리 추상화
   · 도메인 이벤트
   · CQRS · 이벤트 소싱 (특히 신중히)

★ CQRS · 이벤트 소싱은 도입 비용이 매우 크다
  · 읽기 모델 동기화 · 이벤트 스키마 진화 · 리플레이
  · "감사 이력이 핵심 요구" 같은 명확한 이유가 있을 때만
DDD 의 가장 흔한 실패는 “전술 패턴만 도입하는 것”입니다 — 리포지토리와 엔티티 폴더만 만들고 업무 용어와 경계는 그대로이면 코드만 복잡해집니다. 용어 정리부터 시작하세요.
SE 08

테스트 — 무엇을 얼마나 어디에

테스트의 목적은 커버리지 숫자가 아니라 “고쳐도 안전하다는 확신”입니다. 느리고 깨지기 쉬운 테스트는 오히려 개발을 막습니다.

아래로 갈수록 빠르고 많이 · 위로 갈수록 느리고 적게 E2E 통합 단위 E2E — 수십 개 · 분 단위 · 잘 깨짐 핵심 사용자 여정만. 로그인·결제·검색 정도 통합 — 수백 개 · 초 단위 진짜 DB 컨테이너 · API 계약 · 마이그레이션 단위 — 수천 개 · 밀리초 단위 도메인 규칙 · 순수 함수 · 경계값 위쪽이 두꺼워지면 아이스크림 콘이 됩니다 — 느리고, 자주 깨지고, 결국 아무도 안 믿습니다. 기준은 개수가 아니라 시간입니다 — 커밋마다 도는 묶음이 5분을 넘으면 이미 무너진 것입니다.
커버리지 숫자가 아니라 “고쳐도 안전하다는 확신을 몇 분 만에 주는가”가 이 그림의 목적입니다.

테스트 피라미드

        /\        E2E — 적게 · 느림 · 진짜 신뢰
       /  \             (핵심 사용자 여정 몇 개)
      /----\
     /      \     통합 — 중간
    /        \          (DB · 외부 연동 · API 계약)
   /----------\
  /            \  단위 — 많이 · 빠름 · 정밀
 /______________\        (도메인 로직 · 순수 함수)

★ 역피라미드(E2E 만 잔뜩)가 되면
  · 느려서 아무도 안 돌린다
  · 자주 깨져서 신뢰를 잃는다 (플레이키)
  · 실패해도 원인을 모른다
“테스트 트로피”라는 변형도 널리 쓰입니다 — 통합 테스트를 가장 두껍게 두는 방식입니다. Testcontainers 로 진짜 DB 를 띄우는 것이 쉬워지면서 이쪽이 실용적인 경우가 많아졌습니다.

좋은 테스트의 조건

조건내용
빠르다단위는 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"
외부 의존성 테스트방법
DBTestcontainers — 진짜 DB 를 띄운다
HTTP APIWireMock · 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% 목표의미 없는 테스트를 유발
변경분 커버리지실용적 — 새 코드만 검사
돌연변이 테스트테스트의 질을 실제로 측정
플레이키 테스트는 즉시 격리하세요 — “가끔 실패하는 테스트”를 방치하면 진짜 실패도 무시하게 됩니다. quarantine 태그를 붙여 CI 를 막지 않게 하고, 기한을 정해 고치거나 삭제하세요.
SE 09

리팩터링 · 기술 부채 — 안전하게 고치는 순서

리팩터링은 동작을 바꾸지 않으면서 구조만 고치는 것입니다. 기능 추가와 섞으면 무엇이 깨졌는지 알 수 없게 되므로 반드시 나눠서 합니다.

코드 스멜과 대응

스멜리팩터링
긴 함수함수 추출
중복 코드추출 · 상위로 이동
긴 인자 목록파라미터 객체
기본형 집착값 객체 (Money · Email)
거대 클래스클래스 추출
기능 편애메서드를 데이터가 있는 곳으로
산탄총 수술흩어진 변경 지점을 모음
주석이 필요한 코드이름으로 설명
플래그 인자함수 분리
깊은 중첩가드 절 · 조기 반환
가드 절이 가장 효과 대비 쉽습니다 — 중첩된 if 를 조기 반환으로 평탄화하면 읽기가 즉시 좋아지고 위험도 거의 없습니다.

안전한 순서

  1. 테스트로 감싼다 — 없으면 특성화 테스트(현재 동작을 그대로 기록)를 먼저 만듭니다.
  2. 작게 나눠 한 번에 하나 — 커밋 단위를 작게. 각 단계마다 테스트 통과.
  3. 기능 변경과 분리 — 리팩터링 커밋과 기능 커밋을 섞지 않습니다.
  4. IDE 자동 리팩터링 활용 — 이름 변경 · 추출은 도구가 더 안전합니다.
  5. 측정 — 성능이 목적이었다면 전후를 잽니다.
# 특성화 테스트 — 이해 못 하는 코드를 고치기 전에
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일로 줄어듭니다."
“리팩터링할 시간을 주세요”는 잘 통하지 않습니다 — 비용과 이득을 숫자로 말해야 우선순위를 받습니다. 리드 타임 · 장애 건수 · 변경 실패율 같은 DORA 지표가 그 근거가 됩니다.
부채는 이자를 냅니다 — 같은 일에 시간이 더 듭니다 속도 시간 → 정리하지 않으면 꾸준히 조금씩 정리하면 부채가 보이는 신호 같은 버그가 반복 · 배포가 무섭다 · 새 사람이 못 붙는다 리팩터링의 전제 동작을 바꾸지 않는 것이므로 테스트가 먼저 있어야 합니다
"나중에 정리하자"는 이자가 붙는 약속입니다.
부채는 이자를 냅니다 — 같은 일에 시간이 더 듭니다 속도 시간 → 정리하지 않으면 꾸준히 조금씩 정리하면 부채가 보이는 신호 같은 버그가 반복 · 배포가 무섭다 · 새 사람이 못 붙는다 리팩터링의 전제 동작을 바꾸지 않는 것이므로 테스트가 먼저 있어야 합니다
"나중에 정리하자"는 이자가 붙는 약속입니다.
SE 10

버전 관리 — 브랜치 전략은 팀에 맞춰

정답인 전략은 없습니다. 배포 주기 · 팀 크기 · 릴리스 형태에 따라 달라집니다. 다만 긴 브랜치는 거의 항상 나쁩니다.

전략 비교

전략특징적합
트렁크 기반짧은 브랜치 · 하루 안에 머지CD · 웹 서비스
GitHub Flowmain + 기능 브랜치 + PR가장 널리 쓰임
GitLab Flow환경 브랜치 추가단계적 배포
Git Flowdevelop · release · hotfix버전 릴리스 제품
릴리스 브랜치버전별 유지보수여러 버전 지원
Git Flow 는 원저자도 “대부분의 웹 서비스에는 과하다”고 밝혔습니다 — 지속적 배포를 한다면 GitHub Flow 나 트렁크 기반이 훨씬 단순하고 충돌이 적습니다.

긴 브랜치가 나쁜 이유

브랜치가 오래 살수록
 · 머지 충돌이 기하급수적으로 늘어난다
 · 리뷰가 어려워진다 (변경이 수천 줄)
 · 통합 문제를 늦게 발견한다
 · 다른 사람의 리팩터링과 충돌한다

★ 목표: 브랜치 수명 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 Commitsfeat: fix: BREAKING CHANGE: → 버전 자동 산정
변경 로그 자동 생성커밋에서 생성 (release-please 등)
태그와 릴리스배포 산출물과 소스를 연결
지원 정책어느 버전까지 유지보수하나
모노레포 vs 폴리레포

  모노레포
   + 원자적 변경 (API 와 클라이언트를 한 커밋에)
   + 공통 코드 공유 · 일관된 도구
   + 전체 검색·리팩터링이 쉽다
   - 빌드 도구가 필요 (Bazel · Nx · Turborepo)
   - 권한 분리가 어렵다 (CODEOWNERS 로 완화)
   - 저장소가 커진다 (sparse checkout · 부분 클론)

  폴리레포
   + 독립성 · 권한 분리 · 가벼움
   - 공통 코드 버전 관리가 번거롭다
   - 여러 저장소에 걸친 변경이 어렵다

★ 팀이 작고 서비스가 강하게 연관되면 모노레포가 편하다
  조직이 크고 독립적이면 폴리레포가 자연스럽다
모노레포의 핵심은 “영향받은 것만 빌드·테스트”입니다 — 전체를 매번 돌리면 금세 감당이 안 됩니다. 의존성 그래프로 변경 영향 범위를 계산하는 빌드 도구가 사실상 필수입니다 → 🔀 알고리즘 06 위상 정렬.
브랜치는 오래 살수록 비쌉니다 ① 트렁크 기반 — 짧게 만들고 빨리 합칩니다 main 1~2일짜리 브랜치 충돌이 작고 되돌리기 쉽습니다 ② git-flow — 단계별 장기 브랜치 main develop feature 브랜치가 오래 삽니다 합칠 때 충돌이 크게 납니다 배포가 잦고 자동 테스트가 있으면 ① 버전을 나눠 관리해야 하는 제품이면 ②
전략보다 중요한 것은 브랜치 수명을 짧게 유지하는 것입니다.
브랜치는 오래 살수록 비쌉니다 ① 트렁크 기반 — 짧게 만들고 빨리 합칩니다 main 1~2일짜리 브랜치 충돌이 작고 되돌리기 쉽습니다 ② git-flow — 단계별 장기 브랜치 main develop feature 브랜치가 오래 삽니다 합칠 때 충돌이 크게 납니다 배포가 잦고 자동 테스트가 있으면 ① 버전을 나눠 관리해야 하는 제품이면 ②
전략보다 중요한 것은 브랜치 수명을 짧게 유지하는 것입니다.
SE 11

CI/CD · 배포 전략 — 배포를 습관으로

배포가 두려우면 배포를 미루게 되고, 미루면 한 번에 나가는 변경이 커져 더 위험해집니다. 작게 자주 · 되돌릴 수 있게가 핵심입니다.

배포 전략의 차이는 “문제를 언제 발견하고 얼마나 빨리 되돌리는가”입니다 롤링 인스턴스를 조금씩 교체 자원이 덜 듦 두 버전이 오래 공존 ⚠ 블루그린 blue green 새 환경을 통째로 띄우고 전환 롤백이 즉시 · 스위치만 되돌림 자원이 두 배 ⚠ 카나리 5% 일부 트래픽만 새 버전으로 지표를 보며 5 → 25 → 100% 사고 영향 범위가 가장 작음 셋 중 무엇을 쓰든 판단 기준은 하나입니다 — 지금 되돌릴 수 있는가. 되돌릴 수 없는 것이 하나 있습니다. DB 스키마 변경입니다. 그래서 “컬럼 추가 → 양쪽 다 쓰기 → 읽기 전환 → 옛 컬럼 삭제”로 배포를 나눕니다. 카나리의 전제는 관측 가능성입니다 — 볼 지표가 없으면 그냥 느린 롤링일 뿐입니다.
배포를 안전하게 만드는 것은 전략 이름이 아니라 “문제를 몇 % 사용자에게서 몇 분 안에 발견하는가”입니다.

파이프라인 구성

  1. 빠른 피드백 먼저 — 린트 · 타입 검사 · 단위 테스트 (2~5분)
  2. 통합 테스트 — 컨테이너 DB · 계약 테스트 (10분 이내)
  3. 보안 · 품질 — 의존성 스캔 · 시크릿 스캔 · 커버리지 (🔐 12)
  4. 빌드 · 서명 — 이미지 생성 · SBOM · 서명 (🔐 06)
  5. 스테이징 배포 · 스모크 테스트
  6. 프로덕션 배포 — 카나리 → 전체
  7. 배포 후 검증 — 지표 확인 · 자동 롤백 조건
순서가 중요합니다 — 싸고 빠른 검사를 앞에 두면 대부분의 실패를 몇 분 안에 알 수 있습니다. 느린 E2E 를 먼저 돌리면 개발 흐름이 끊깁니다.

배포 전략

전략방식특징
롤링인스턴스를 순차 교체기본 · 두 버전 공존
블루-그린새 환경 준비 후 전환즉시 롤백 · 자원 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 을 작게
 ⑤ 피처 플래그 — 배포와 릴리스 분리
“배포가 무섭다”는 개선 신호입니다 — 무서운 이유(수동 단계 · 롤백 불가 · 테스트 부족)를 하나씩 없애면 속도와 안정성이 함께 올라갑니다.
SE 12

관측 가능성 — 시스템에 질문할 수 있는가

모니터링이 “미리 정한 것을 본다”면 관측 가능성은 “예상 못 한 질문에 답할 수 있다”입니다. 장애의 절반은 원인을 찾는 데 쓰입니다.

로그

개별 사건의 상세. “무슨 일이 있었나”

{"ts":"2026-08-07T10:00:00Z",
 "level":"error",
 "msg":"결제 실패",
 "trace_id":"abc123",
 "order_id":"ord_9",
 "reason":"card_declined",
 "duration_ms":842}
구조화 로그로 — grep 이 아니라 쿼리가 가능해집니다

메트릭

집계된 수치. “얼마나 · 언제부터”

http_requests_total{
  method="POST",
  route="/orders",
  status="500"} 42

http_duration_seconds_bucket{
  le="0.5"} 9812
카디널리티 주의 — user_id 를 레이블로 넣으면 폭발

트레이스

요청의 전체 경로. “어디서 느린가”

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 서비스 영향
ERROR 를 남발하면 아무도 안 봅니다 — “재시도로 해결된 일시적 실패”는 WARNING 입니다. ERROR = 사람의 조치가 필요한 것으로 규칙을 정하세요.

SLI · SLO · 에러 버짓

SLI — 측정하는 지표
  "성공한 요청 / 전체 요청"
  "500ms 이내 응답 비율"

SLO — 목표
  "30일 동안 가용성 99.9%"
  = 허용 다운타임 43분

에러 버짓 — 남은 여유
  99.9% 목표에서 현재 99.95% 라면
  버짓의 절반이 남았다

★ 에러 버짓의 쓸모
  · 남으면 → 위험한 변경을 시도해도 된다
  · 소진되면 → 신규 배포를 멈추고 안정화에 집중
  → "속도 vs 안정성" 논쟁을 데이터로 해결한다
SLO 는 사용자 관점으로 정하세요 — “CPU 80% 이하”가 아니라 “주문이 3초 안에 완료된다”. 내부 지표는 원인 분석용이고, SLO 는 사용자가 겪는 것이어야 합니다.

이렇게도 쓸 수 있다 — 알림 설계 🟡 중급

원칙내용
증상에 알림, 원인은 대시보드로“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
# → 오탐을 줄이면서 큰 문제는 빨리 잡는다
알림 피로가 가장 큰 위험입니다 — 오탐이 많으면 진짜 알림도 무시하게 됩니다. ➗ 선형대수 · 확률 08의 베이즈 관점이 왜 오탐이 그렇게 많아지는지 설명해 줍니다.

실무에선 이렇게 — 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)
관측 비용이 커지는 것은 흔한 문제입니다 — 로그 볼륨 · 메트릭 카디널리티 · 트레이스 보존 기간을 처음부터 정책으로 정하세요. 나중에 줄이려면 이미 대시보드와 알림이 의존하고 있습니다.
SE 13

코드 리뷰 · 문서 · 협업 — 지식을 팀에 남기기

마지막 절입니다. 기술적으로 옳은 것과 팀이 계속 유지할 수 있는 것은 다릅니다. 코드보다 오래 사는 것은 그것을 만든 사람들의 이해입니다.

리뷰가 잘 되게 하는 조건

항목기준
PR 크기400줄 이하 — 그 이상은 리뷰 품질이 급락
맥락 제공왜 · 어떻게 · 무엇을 봐 달라
자동화 선행포맷 · 린트 · 테스트는 기계가
응답 시간하루 이내 — 오래 걸리면 충돌이 는다
리뷰 범위 합의설계? 버그? 스타일?
리뷰 코멘트의 온도 표시 (nit 컨벤션)

  nit:      사소함 · 반영 안 해도 됨
  question: 이해하려고 묻는 것
  suggestion: 제안 · 선택은 작성자
  issue:    고쳐야 함 (근거를 함께)
  blocking: 머지 전 반드시

★ 무엇이 필수이고 무엇이 취향인지 명시하면
  불필요한 논쟁과 감정 소모가 크게 준다

무엇을 볼 것인가

  1. 정확성 — 요구를 만족하나 · 경계 조건 · 에러 경로
  2. 안전성 — 인가 · 입력 검증 · 시크릿 (🔐 13)
  3. 설계 — 이 자리가 맞나 · 결합도 · 이름
  4. 테스트 — 의미 있는 검증이 있나
  5. 운영 — 로그 · 메트릭 · 롤백 가능성 · 마이그레이션
  6. 가독성 — 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)
가장 좋은 문서는 “찾아지는 문서”입니다 — 저장소 안에 두고(코드와 함께 리뷰·버전 관리), README 에서 링크하세요. 위키에 흩어진 문서는 금세 낡고 아무도 못 찾습니다.

실무에선 이렇게 — 마무리 · 전체 지도 🔴 실무

이 가이드의 열두 탭이 이어지는 방식

탭핵심 질문이어지는 곳
🔢 이산수학어떻게 세고 증명하나계산이론 · 자료구조 · 보안
➗ 선형대수 · 확률데이터를 어떻게 다루나컴퓨터구조(부동소수점) · 분산(지표)
🧮 계산이론얼마나 걸리나 · 풀 수 있나자료구조 · 알고리즘 · 컴파일러
🗂️ 자료구조무엇에 담나알고리즘 · 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 는 그 아래에 깔린 층이다.
  도구는 바뀌어도 이 층은 남는다.
다 읽을 필요는 없습니다 — 지금 막힌 문제에서 시작해 필요한 절만 따라가는 것이 가장 오래 남습니다. Ctrl+K 로 “캐시 미스” · “데드락” · “빅오”처럼 궁금한 말을 검색해 보세요.
손실 함수 · 경사하강 · 역전파 · 트랜스포머 — 라이브러리가 아니라 원리

왜 이렇게 계산하면
학습이
되는가

model.fit(X, y) 한 줄 뒤에서 무슨 일이 벌어지는지 모르면, 모델이 안 될 때 바꿀 것을 고를 수 없습니다. 이 탭은 라이브러리 사용법이 아니라 수식과 그림으로 원리를 잡습니다 — 손실 함수가 왜 그 모양이어야 하는지, 기울기가 어떻게 층을 거슬러 흐르는지, 어텐션 한 줄짜리 식이 왜 언어 모델을 만들어 냈는지. 코드는 개념 확인용 NumPy 수준으로만 넣었습니다. 행렬·확률의 기본기는 ➗ 선형대수 · 확률 탭이 담당하고, 여기서는 그 위에서 시작합니다.

13SECTIONS
수식+ 그림
0프레임워크 의존
1958퍼셉트론부터
ML 01

학습이란 무엇인가 — 규칙을 쓰지 않고 규칙을 얻는 방법

머신러닝은 마법이 아니라 최적화 문제 하나입니다. “가설 공간에서, 손실을 가장 작게 만드는 함수를, 데이터를 보며 찾는다.” 이 한 문장을 세 조각으로 뜯어 보면 이 탭 전체의 지도가 나옵니다.

같은 상자에 무엇을 넣고 무엇을 받는가 — 이 방향이 뒤집힌 것이 학습입니다 ① 전통 프로그래밍 — 규칙을 사람이 씁니다 데이터 + 규칙 프로그램 답 규칙이 틀리면 사람이 다시 씁니다. 경우의 수가 늘면 손을 못 댑니다. ② 머신러닝 — 규칙을 데이터에서 찾아냅니다 데이터 + 답 학습 알고리즘 규칙 = 모델 새 데이터를 넣으면 답이 나옵니다. 규칙은 사람이 읽지 못할 수도 있습니다.
머신러닝은 규칙을 출력으로 받는 프로그래밍입니다 — 그래서 데이터가 곧 소스 코드입니다.

규칙 기반으로는 못 푸는 문제

# "고양이 사진인가?" 를 if 로 써 본다면
def is_cat(img):
    if has_pointy_ears(img) and has_whiskers(img):
        return True
    # ... 귀가 접힌 고양이는? 옆모습은? 어두운 사진은?
    # ... has_pointy_ears 자체를 또 규칙으로 써야 한다

# 문제의 성질
#  · 규칙을 말로 정확히 쓸 수 없다 (사람도 설명 못 한다)
#  · 예외가 사실상 무한하다
#  · 그런데 '예시'는 많이 모을 수 있다   ← 여기가 ML 의 입구
ML 을 쓸 자리를 고르는 기준 — “규칙은 못 쓰겠는데 예시는 많다”. 반대로 규칙을 명확히 쓸 수 있으면 if 문이 언제나 낫습니다 (더 빠르고, 설명 가능하고, 틀렸을 때 고칠 수 있습니다). 13절 체크리스트로 이어집니다.

학습의 세 종류

종류주어지는 것예
지도 학습입력 x 와 정답 y 쌍스팸 분류 · 가격 예측
비지도 학습입력 x 만고객 군집 · 이상 탐지
자기지도데이터 일부를 가려 스스로 정답을 만듦LLM 다음 토큰 예측
강화 학습행동에 대한 보상 신호게임 · 로봇 제어
자기지도가 왜 판을 바꿨나 — 정답을 사람이 붙이는 순간 데이터 양이 라벨링 예산에 묶입니다. 문장의 다음 단어처럼 데이터 자체가 정답이면 인터넷 전체가 학습 데이터가 됩니다. 이것이 11절 LLM 으로 가는 길목입니다.

핵심 개념 — 가설 공간 · 손실 함수 · 최적화 🟢 기초

모든 학습 알고리즘은 이 세 축의 조합입니다

ĥ = argminh ∈ H   (1/n) Σi L( h(xi), yi ) H = 가설 공간(어떤 모양의 함수까지 허용하는가) · L = 손실(틀린 정도를 어떻게 재는가) · argmin = 최적화(그 최소를 어떻게 찾는가)
축정하는 것고르면 달라지는 모델이 탭에서
가설 공간 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 의 식뿐이다.
“모델을 바꾼다”는 말의 정확한 뜻 — 대부분 가설 공간을 바꾸는 것입니다. 성능이 안 나올 때 “데이터가 부족한가 · H 가 좁은가 · 최적화가 실패했나” 세 축 중 어디인지 먼저 판정하면 헛수고가 크게 줄어듭니다.

한 단계 더 — 일반화와 과적합 — 학습의 목적은 암기가 아닙니다 🟡 중급

훈련 오차는 목표가 아닙니다

일반화 오차 = E(x,y)~D[ L(h(x), y) ] 우리가 줄이고 싶은 것은 보지 못한 데이터에 대한 오차인데, 손에 있는 것은 훈련 데이터의 오차뿐입니다. 이 간극이 머신러닝의 모든 어려움입니다.
# 훈련 데이터를 통째로 외워 버리는 '모델'
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%.
# 훈련 오차만 보면 이 모델이 '최고'다 — 그래서 훈련 오차는 지표가 아니다.
과적합(overfitting)은 노이즈까지 외운 상태입니다. 훈련 오차는 계속 내려가는데 검증 오차가 올라가기 시작하는 지점이 “외우기 시작한 순간”이고, 그때 멈추는 것이 조기 종료입니다(04절).

과소적합과 과적합의 신호

훈련 / 검증진단손댈 곳
높음 / 높음과소적합 — H 가 너무 좁다모델 키우기 · 특성 추가
낮음 / 높음과적합 — 외웠다정규화 · 데이터 추가
낮음 / 낮음정상배포 · 다음 실험
높음 / 낮음버그 또는 분할 오류데이터 누수 의심(12절)
이 표 하나가 실무 디버깅의 절반입니다. “정확도가 안 나온다”는 말은 정보가 없습니다 — 훈련과 검증을 함께 봐야 무엇을 고칠지 정해집니다.

실무에선 이렇게 — 데이터 분할과 교차 검증 🔴 실무

① 한 번 나누기 vs ② k-겹 교차 검증 — 데이터가 적을수록 ② ① hold-out — 빠르지만 나누기 운에 흔들립니다 훈련 60% 검증 20% 시험 20% 끝까지 봉인 ② 5-겹 교차 검증 — 모든 조각이 한 번씩 검증이 됩니다 1회차 → 점수 0.81 2회차 → 점수 0.86 3회차 → 점수 0.79 4회차 → 점수 0.84 5회차 → 점수 0.83 붉은 칸이 그 회차의 검증 조각 — 평균 0.826, 표준편차 0.025 까지 함께 봅니다
교차 검증은 점수 하나가 아니라 점수의 분포를 줍니다 — 흔들림이 크면 그 비교는 믿을 수 없습니다.

직접 짜 보는 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절에서 더 다룹니다.
정리 — 학습은 가설 공간 · 손실 · 최적화 세 축으로 정의되는 최소화 문제이고, 목표는 훈련 오차가 아니라 보지 못한 데이터의 오차입니다. 그래서 데이터를 나누는 방식이 모델 선택만큼 중요합니다.
ML 02

선형 회귀부터 — 한 번에 푸는 답과 굴러 내려가는 답

선형 회귀는 해석해가 존재하는 유일한 자리입니다. 공식으로 한 번에 푸는 길과 경사하강으로 조금씩 내려가는 길이 같은 점에 도착한다는 사실을 확인하고 나면, 그 뒤 모든 모델이 왜 경사하강만 쓰는지도 보입니다.

모델과 비용 함수

ŷ = w0 + w1x1 + … + wdxd = Xw 비용   J(w) = (1/n) ‖ Xw − y ‖²  — 오차를 제곱해서 더한 뒤 평균낸 값입니다.
# 왜 하필 '제곱' 인가
#  ① 부호를 없애야 한다 (+3 과 -3 이 상쇄되면 안 됨)
#  ② 절댓값보다 미분이 매끄럽다 (0 에서 꺾이지 않음)
#  ③ 큰 오차에 더 큰 벌점 → 크게 틀린 것부터 고친다
#  ④ 오차가 정규분포라고 가정하면 제곱 오차 최소화 = 최대가능도 추정

# ③ 의 부작용: 이상치 하나가 답을 통째로 끌고 간다
#    → 그럴 땐 Huber 손실 (작을 땐 제곱, 클 땐 절댓값)
손실이 모델의 성격을 정합니다 — 제곱 오차를 최소화하면 조건부 평균을, 절댓값 오차를 최소화하면 조건부 중앙값을 예측하게 됩니다. “이상치에 흔들리지 않는 모델”을 원하면 모델이 아니라 손실을 바꾸는 것이 정답인 경우가 많습니다.

해석해 — 정규방정식

∇J = 0  ⟹  w* = (XᵀX)−1 Xᵀy 유도와 기하학적 의미(직교 투영)는 ➗ 선형대수 탭 15 최소제곱법에 있습니다.
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 가 수만이면 불가능하고, 로지스틱 회귀부터는 해석해가 아예 존재하지 않습니다.

핵심 개념 — 경사하강 — 왜 기울기의 반대인가 🟢 기초

비용 J(w) 의 등고선과 경사하강 경로 — 기울기의 반대 방향으로 조금씩 시작 w₀ 최소점 w* 바깥 타원일수록 비용이 큽니다. 걸음마다 기울기가 작아지므로 보폭도 저절로 줄어듭니다. 학습률 α 가 정하는 것 α 가 너무 작다 방향은 맞지만 수렴까지 수만 걸음이 걸립니다 α 가 적당하다 비용이 매 걸음 단조롭게 줄어듭니다 α 가 너무 크다 최소점을 넘어가 진동하다 발산합니다 (NaN)
기울기는 가장 가파르게 올라가는 방향입니다 — 그래서 내려가려면 반대로 가고, 얼마나 갈지를 정하는 것이 학습률입니다.

기울기 유도와 구현

∂J/∂w = (2/n) Xᵀ(Xw − y)    w ← w − α · ∂J/∂w “오차를 각 특성에 되돌려 곱한 것” — 그 특성이 오차에 얼마나 기여했는지입니다.
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 로.  들쭉날쭉하면 → 스케일링을 안 했다.
선형 회귀의 비용 함수는 볼록(convex)합니다 — 국소 최소가 곧 전역 최소이고, 학습률만 적당하면 시작점과 무관하게 같은 답에 갑니다. 신경망은 볼록하지 않아 이 보장이 사라집니다(09절).

두 길의 비교

정규방정식경사하강
계산량O(d³ + nd²)O(nd) × 반복
d = 10즉시가능
d = 100,000불가능가능
학습률필요 없음튜닝 필요
스케일링필요 없음거의 필수
다른 모델에선형에만전부 적용
왜 딥러닝이 전부 경사하강인가 — 마지막 줄 때문입니다. 경사하강은 “미분만 되면 통하는” 방법이라 모델 모양을 가리지 않습니다. 그 미분을 자동으로 구해 주는 것이 08절 역전파입니다.

한 단계 더 — 특성 스케일링이 왜 필요한가 🟡 중급

스케일이 다르면 등고선이 납작해지고, 납작하면 지그재그가 됩니다 ✗ 스케일링 전 — 면적(0~300) vs 방 개수(1~5) 한 방향은 가파르고 다른 방향은 평평합니다. 가파른 쪽에 맞춰 α 를 줄이면 평평한 쪽이 영원히 안 갑니다. ✓ 표준화 후 — 평균 0 · 표준편차 1 등고선이 원에 가까우면 기울기가 곧장 중심을 가리킵니다. 같은 α 로 훨씬 적은 걸음에 도착합니다.
스케일링은 전처리 요령이 아니라 최적화 지형을 고치는 일입니다 — 조건수를 줄여 지그재그를 없앱니다.

누수 없이 스케일링하기

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 정규화필수벌점이 계수 크기에 걸림
결정 트리 · 부스팅불필요임계값 비교뿐 — 단조 변환에 불변
마지막 줄이 실무 감별법입니다 — 스케일링을 넣었는데 XGBoost 점수가 딱 그대로라면 정상입니다. 오히려 바뀌었다면 파이프라인에 버그가 있습니다.

실무에선 이렇게 — 다항 특성과 기저 함수 — 직선으로 곡선 맞히기 🔴 실무

“선형 회귀”의 선형은 가중치에 대해 선형이라는 뜻입니다

# 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.824.91과소적합 — 곡선을 못 따라감
30.310.36적절
90.041.87과적합 시작
150.00240.5점을 전부 통과하며 사이에서 폭주
가설 공간을 넓히는 가장 값싼 방법이 특성 공학입니다. 딥러닝은 “이 변환 자체를 학습으로 찾자”는 발상이고(07절), 정형 데이터에서는 사람이 만든 특성이 여전히 이기는 경우가 많습니다(05절).
정리 — 선형 회귀의 두 해법은 같은 최소점에 도달합니다. 해석해는 작을 때만 쓸 수 있고, 경사하강은 미분만 되면 어디에나 통하기 때문에 그 뒤 모든 모델의 표준이 됩니다. 스케일링은 그 경사하강이 지그재그하지 않도록 지형을 고치는 일입니다.
ML 03

분류 · 로지스틱 회귀 — 확률을 출력하는 선형 모델

“스팸인가 아닌가”에 회귀를 그대로 쓰면 예측이 1.7 이나 −0.3 이 나옵니다. 출력을 0~1 로 눌러 담고, 손실을 확률에 맞게 바꾸는 두 가지 수정만으로 회귀가 분류기가 됩니다. 그 두 수정이 왜 필요한지가 이 절의 전부입니다.

시그모이드는 실수 전체를 확률로 눌러 담고, 그 경계선이 결정 경계가 됩니다 σ(z) = 1 / (1 + e⁻ᶻ) 1.0 0.5 0.0 z = 0 → 확률 0.5 z ≪ 0 : 기울기 ≈ 0 z ≫ 0 : 기울기 ≈ 0 양 끝에서 포화합니다 — 이것이 손실 함수 선택을 좌우합니다. 결정 경계: wᵀx + b = 0 P ≥ 0.5 → 클래스 1 P < 0.5 → 클래스 0 경계는 직선입니다 — 곡선이 필요하면 특성을 늘리거나 신경망으로
로지스틱 회귀는 선형 점수 z 를 확률로 번역할 뿐, 경계 자체는 여전히 직선(초평면)입니다.

시그모이드와 로그 오즈

p = σ(z) = 1/(1+e−z)  ⟺  z = log( p / (1−p) ) 오즈 = 일어날 확률 / 안 일어날 확률. 그 로그가 곧 선형 점수 z 입니다. 그래서 “로지스틱 회귀는 로그 오즈에 대한 선형 회귀”라고 부릅니다.
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) 가 오즈비이고, 규제 기관에 설명할 수 있는 숫자입니다.

교차 엔트로피 — 확률에 맞는 손실

L = −[ y log p + (1−y) log(1−p) ] 정답이 1 인데 p=0.01 이면 −log 0.01 = 4.6 — 확신에 찬 오답에 큰 벌점을 줍니다. 엔트로피 자체의 정의는 ➗ 선형대수 · 확률 탭 14에 있습니다.
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
# "예측 - 정답" 그대로. 선형 회귀의 기울기와 같은 모양이다.
우연이 아닙니다 — 시그모이드와 로그 손실이 짝이고, 소프트맥스와 교차 엔트로피가 짝입니다. 짝을 맞추면 σ′(z) 항이 약분되어 기울기가 p − y 로 단순해집니다.

핵심 개념 — 왜 제곱 오차를 쓰면 안 되는가 🟢 기초

기울기 소실 — 크게 틀렸는데 학습 신호가 0 입니다

제곱 오차: ∂L/∂z = (p−y) · σ′(z)   |   교차 엔트로피: ∂L/∂z = (p−y) σ′(z) = p(1−p) 는 z 가 크거나 작으면 거의 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
비용 함수 모양비볼록 (지역 최소 존재)볼록
확률적 해석없음최대가능도 추정과 동치
출력 보정확률로 못 씀확률로 쓸 수 있음
이 원리가 딥러닝 전체로 이어집니다 — 08절에서 볼 기울기 소실은 정확히 같은 현상이 층마다 반복되는 것입니다. “활성화 함수의 미분이 작으면 신호가 사라진다”가 ReLU 가 등장한 이유이기도 합니다(07절).

한 단계 더 — 소프트맥스와 다중 분류 🟡 중급

K 개 클래스로 확장

softmax(z)k = ezk / Σj ezj K=2 이면 시그모이드와 정확히 같습니다 — 소프트맥스는 시그모이드의 일반화입니다.
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개쌍별 손실
세 번째 줄이 자주 틀립니다 — 사진에 “고양이”와 “실내”가 동시에 참일 수 있으면 softmax 는 잘못된 선택입니다. 확률의 합을 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절)
먼저 지표부터 바꾸세요 — 불균형 문제에서 정확도는 정보가 없습니다. PR-AUC · 재현율@고정 정밀도로 보고, 그다음에 위 수단을 고릅니다. 12절에서 자세히 다룹니다.
정리 — 분류는 시그모이드/소프트맥스로 확률을 만들고, 교차 엔트로피로 재는 두 조각입니다. 짝을 맞추면 기울기가 예측 − 정답 으로 단순해지고, 짝을 어기면 확신에 찬 오답에서 학습이 멈춥니다.
ML 04

편향-분산 · 정규화 — 일부러 못 맞히게 만드는 기술

과적합을 막는 방법들은 하나같이 “모델의 자유를 뺏는” 모양을 하고 있습니다. 왜 자유를 뺏으면 보지 못한 데이터에서 더 잘하게 되는지, 그 근거가 편향-분산 분해입니다.

전체 오차 = 편향² + 분산 + 잡음 — 두 항이 반대로 움직여 U 자가 생깁니다 오차 모델 복잡도 → 편향² 분산 전체 오차 최적 복잡도 ← 과소적합 과적합 → 잡음 항은 데이터에 원래 있는 흔들림이라 어떤 모델로도 못 줄입니다 — 오차의 바닥입니다.
복잡도를 키우면 편향은 줄고 분산은 늡니다 — 정규화는 이 저울을 왼쪽으로 되미는 손잡이입니다.

분해식이 말하는 것

E[(y − ĥ(x))²] = 편향[ĥ]² + 분산[ĥ] + σ² 편향 = 평균적으로 얼마나 빗나가는가 (모델이 너무 단순해서) · 분산 = 데이터가 조금 바뀌면 답이 얼마나 흔들리는가 · σ² = 줄일 수 없는 잡음
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]     데이터가 조금만 달라도 답이 통째로 바뀜
분산은 “모델의 예민함”입니다 — 같은 분포에서 뽑은 다른 25개로 학습했더니 답이 완전히 달라진다면, 그 모델은 데이터가 아니라 이번 데이터의 잡음을 배운 것입니다.

과적합을 막는 손잡이들

수단줄이는 방식비용
데이터 늘리기분산을 직접 낮춤가장 확실 · 가장 비쌈
L2 (릿지)계수를 작게약간의 편향 증가
L1 (라쏘)계수를 0 으로상관 특성 중 하나만 남김
드롭아웃층 안 뉴런을 무작위로 끔학습이 느려짐
조기 종료외우기 전에 멈춤검증셋 필요
데이터 증강불변성을 알려 줌도메인 지식 필요
앙상블여러 모델을 평균추론 비용 배수
전부 “분산을 편향과 맞바꾸는” 거래입니다. 훈련 오차가 이미 높은데(=편향이 큰데) 정규화를 더 넣는 것은 정확히 반대 방향입니다 — 01절의 진단표를 먼저 보세요.

핵심 개념 — L1 과 L2 — 왜 L1 만 0 을 만드는가 🟢 기초

같은 크기 제한인데 모양이 다릅니다 — 뾰족한 꼭짓점이 축 위에 있는 것이 전부입니다 L2 — 제약 영역이 원 (매끄러움) 정규화 없는 해 접점: w₁·w₂ 둘 다 살아남음 ‖w‖₂ ≤ t 원에는 꼭짓점이 없어 축 위에서 만날 확률이 사실상 0 입니다. → 계수가 작아질 뿐 0 이 되지는 않습니다. L1 — 제약 영역이 마름모 (꼭짓점) 접점이 꼭짓점 → w₁ = 0 ‖w‖₁ ≤ t 꼭짓점이 축 위에 있고, 등고선이 어느 쪽에서 와도 뾰족한 곳에 먼저 닿습니다 → 특성 선택 효과.
L1 이 희소성을 만드는 이유는 확률적 마법이 아니라 제약 영역의 모서리라는 기하학입니다.

벌점 항으로 다시 쓰기

J = MSE + λ·‖w‖₂² (릿지)   |   J = MSE + λ·‖w‖₁ (라쏘) 제약 형태 “‖w‖ ≤ t 안에서 최소화”와 벌점 형태는 서로 같은 문제입니다(라그랑주 쌍대).
# 릿지는 해석해가 그대로 있다 — 대각선에 λ 를 더할 뿐
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 으로 밀어붙임'
절편(bias)에는 벌점을 걸지 않습니다 — 절편까지 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 정규화와 근사적으로 같다는 결과가 알려져 있다.
체크포인트 복원을 빠뜨리면 의미가 없습니다 — “멈추기만 하고 마지막 가중치를 쓰는” 구현을 자주 봅니다. 멈춘 시점은 이미 과적합이 10 에폭 진행된 뒤입니다.

실무에선 이렇게 — 이중 하강 — 교과서 U 자가 끝이 아니었습니다 🔴 실무

모델을 더 키우면 오히려 좋아지는 구간 — 현대 딥러닝이 서 있는 자리 시험 오차 모델 크기 → 고전적 최소 보간 임계점 (파라미터 ≈ 데이터 수) 더 낮은 최소 고전적 U 자 구간 과매개변수 구간 — 성능이 다시 좋아집니다 임계점에서는 해가 유일하게 강제되어 잡음까지 정확히 맞추느라 오차가 치솟습니다.
“크면 과적합”은 임계점 근처에서만 맞는 말입니다 — 충분히 크면 최적화가 매끄러운 해를 고릅니다.

왜 이런 일이 벌어지나 — 암묵적 정규화

구간일어나는 일실무 함의
파라미터 ≪ 데이터교과서 편향-분산 U 자정규화가 잘 듣는다
파라미터 ≈ 데이터훈련 오차 0 을 만드는 해가 딱 하나 — 잡음까지 맞춤가장 나쁜 자리 · 피할 것
파라미터 ≫ 데이터훈련 오차 0 인 해가 무수히 많고, SGD 가 그중 노름이 작은 쪽을 고름키울수록 좋아질 수 있다
SGD 자체가 정규화 장치라는 관점입니다 — 명시적 벌점 없이도 최적화 절차가 “단순한 해”를 선호합니다. 이 현상은 에폭 수와 데이터 양에서도 비슷하게 관찰되며, 13절의 스케일링 법칙과 이어집니다.
그래도 이중 하강을 믿고 모델을 키우면 안 되는 자리 — 데이터가 수천 건 수준이거나, 정형 데이터이거나, 추론 지연·비용에 제약이 있는 서비스입니다. 이중 하강은 데이터와 계산이 충분할 때 나타나는 현상이고, 그 조건이 아니면 여전히 작은 모델 + 정규화가 이깁니다(05절).
정리 — 정규화는 분산을 편향과 맞바꾸는 거래입니다. L1 이 계수를 0 으로 만드는 이유는 제약 영역의 꼭짓점이 축 위에 있기 때문이고, 드롭아웃·조기 종료·증강도 전부 같은 저울의 다른 손잡이입니다.
ML 05

고전 모델 — 정형 데이터에서는 아직도 이쪽이 이깁니다

표 형태 데이터에서 신경망이 트리 앙상블을 못 이긴다는 결과는 2026년에도 유효합니다. 결정 트리 · 배깅 · 부스팅 · SVM 이 각각 가설 공간을 어떻게 정의하는지를 보면 왜 그런지가 설명됩니다.

결정 트리 = 축에 평행한 선으로 공간을 직사각형으로 쪼개기 특성 공간의 분할 x₁ = 5.2 x₂ = 3.0 축에 평행한 선만 그을 수 있습니다 — 대각선 경계는 계단으로 근사합니다. 같은 규칙을 트리로 x₁ ≤ 5.2 ? 예 아니오 클래스 A x₂ ≤ 3.0 ? A B 깊이 = 질문 개수. 깊어질수록 잎이 잘게 쪼개져 과적합합니다.
트리는 if 문의 자동 생성기입니다 — 그래서 스케일링이 필요 없고, 규칙을 사람이 읽을 수 있습니다.

어디를 자를지 — 불순도

지니 = 1 − Σpk²    엔트로피 = −Σpk log₂ pk 정보 이득 = 부모의 불순도 − 자식들의 가중 평균 불순도. 가장 큰 이득을 주는 분할을 고릅니다.
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
지니와 엔트로피는 거의 같은 결과를 냅니다 — 지니가 로그를 안 써서 조금 빠를 뿐입니다. 엔트로피의 의미(비트로 잰 불확실성)는 ➗ 선형대수 · 확률 탭 14에 있습니다.

단일 트리의 치명적 약점

# 데이터를 5% 만 바꿔도 뿌리 분할이 통째로 바뀔 수 있다
#  → 트리 구조가 완전히 달라진다 = 분산이 매우 크다 (04절)

# 깊이를 제한하지 않으면
tree = DecisionTree(max_depth=None)
tree.fit(X, y)          # 잎마다 샘플 1개 → 훈련 정확도 100%
                        # 검증 정확도는 폭락

# 제어 손잡이
#  max_depth          : 질문 횟수 상한
#  min_samples_leaf   : 잎에 최소 몇 개는 있어야
#  min_impurity_decrease : 이득이 이만큼은 되어야 자름
#  ccp_alpha          : 비용 복잡도 가지치기 (사후 pruning)
“해석 가능하다”는 장점은 얕은 트리에만 해당합니다. 깊이 20 짜리 트리는 규칙이 100만 개라 아무도 못 읽습니다. 보고용 트리는 깊이 3~4, 성능용은 앙상블 — 둘을 섞지 마세요.

핵심 개념 — 배깅과 부스팅 — 같은 트리, 반대의 목적 🟢 기초

같은 결정 트리를 쓰지만 줄이는 오차가 서로 다릅니다 배깅 — 독립된 트리를 평균 표본 1 표본 2 표본 3 트리 1 트리 2 트리 3 평균 · 투표 서로를 모릅니다 → 완전 병렬 학습. 분산을 줄입니다 (편향은 거의 그대로). 깊은 트리를 써도 평균이 흔들림을 지웁니다. 부스팅 — 앞 트리의 잔차를 다음이 학습 트리 1 트리 2 트리 3 잔차 잔차 예측 = 트리 1 + η·트리 2 + η·트리 3 + … 순차적입니다 → 트리 간 병렬화가 어렵습니다. 편향을 줄입니다 (과적합 위험은 커집니다). 그래서 얕은 트리(깊이 3~8)를 수백 개 씁니다.
배깅은 흔들림을 지우고, 부스팅은 못 맞힌 부분을 파고듭니다 — 그래서 트리 깊이 선택이 정반대입니다.

랜덤 포레스트 — 배깅 + 특성 무작위화

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)

그래디언트 부스팅 — 잔차가 곧 기울기

Fm(x) = Fm−1(x) + η·hm(x),   hm ≈ −∂L/∂F 제곱 오차라면 −∂L/∂F 가 정확히 잔차 (y − F) 입니다. 그래서 “잔차를 학습”이 됩니다.
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절의 최적화와 정확히 같은 아이디어다.
XGBoost · LightGBM 이 이기는 이유는 알고리즘이 아니라 공학입니다 — 2차 미분(헤시안)까지 쓰는 분할 기준, 히스토그램 기반 구간화, 결측값 자동 분기, 캐시 친화적 메모리 배치, 리프 단위 성장(LightGBM).

한 단계 더 — SVM 과 커널 트릭 🟡 중급

최대 마진 — 경계에서 가장 멀리

min ½‖w‖²   s.t.   yi(wᵀxi + b) ≥ 1 마진 = 2/‖w‖ — ‖w‖ 를 줄이는 것이 곧 여백을 넓히는 것입니다.
# 힌지 손실로 쓰면 경사하강으로도 풀린다
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 은 아예 무시한다

커널 — 고차원으로 올리되 올리지 않기

K(x, x′) = φ(x)ᵀφ(x′) φ 로 옮긴 뒤 내적하는 대신, 내적 결과만 직접 계산합니다 — φ 는 만들지 않습니다.
# 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. 그래서 대규모 데이터에는 못 쓴다.
SVM 이 주류에서 밀려난 이유가 마지막 줄입니다 — 샘플 수의 제곱에 비례하는 비용 때문에 수십만 건부터 현실성이 사라집니다. 중소 규모 · 고차원(특히 텍스트, 생물정보)에서는 여전히 좋은 선택입니다.

실무에선 이렇게 — 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)   # 로그로 더한다
둘 다 “기준선(baseline)”으로 훌륭합니다 — 5분 만에 돌려서 나온 점수를 넘지 못하는 딥러닝 모델이라면, 문제는 모델이 아니라 데이터나 문제 정의에 있습니다.

정형 데이터에서 트리 앙상블이 강한 이유

정형 데이터의 성질트리가 잘 맞는 이유
특성마다 단위가 다름임계값 비교뿐 — 스케일 불변
범주형 · 결측값 섞임분기로 자연스럽게 처리
계단식·비매끄러운 관계트리의 가설 공간 그 자체
특성 간 상호작용깊이로 자동 표현
회전에 의미 없음신경망은 회전 불변을 학습에 낭비
데이터가 수천~수십만 건딥러닝이 힘을 못 씀
실무 기본 순서 — ① 상수/최빈값 기준선 ② 로지스틱 회귀 또는 라쏘 ③ LightGBM ④ 그래도 부족하면 그때 신경망. ③ 에서 대부분 끝나고, ④ 로 가는 것은 이미지·음성·텍스트·시퀀스일 때입니다.
정리 — 트리는 축에 평행한 분할이라는 가설 공간을 씁니다. 배깅은 그 트리의 분산을, 부스팅은 편향을 줄이고, 정형 데이터의 성질과 트리의 가설 공간이 잘 맞기 때문에 이 조합이 여전히 기본값입니다.
ML 06

비지도 · 차원 축소 — 정답 없이 구조를 찾기

정답 라벨이 없을 때 할 수 있는 일은 “비슷한 것끼리 묶기”와 “중요한 축만 남기기” 두 가지입니다. 둘 다 “무엇이 비슷한가”를 사람이 미리 정해야 한다는 함정을 안고 있습니다.

k-means — ① 아무렇게나 시작 ② 가까운 중심에 할당 ③ 중심을 평균으로 이동 → 반복 1 중심 무작위 배치 2 가까운 중심에 할당 3 중심을 평균으로 이동 ②③ 을 중심이 멈출 때까지 반복합니다. 목적 함수(군집 내 제곱합)는 매 단계 단조 감소합니다. 그러나 도착하는 곳은 지역 최소입니다 — 시작점을 바꾸면 답이 달라집니다.
k-means 는 할당과 이동을 번갈아 하는 좌표 하강입니다 — 그래서 수렴은 보장되지만 전역 최적은 보장되지 않습니다.

직접 짜 보는 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-means 가 실패하는 모양 — 길쭉한 군집, 크기가 크게 다른 군집, 밀도가 다른 군집. 유클리드 거리 + 평균이라는 가정이 “구형이고 크기가 비슷하다”를 암묵적으로 요구하기 때문입니다.

k 를 고르는 법과 다른 군집 알고리즘

방법보는 것주의
엘보SSE 가 꺾이는 지점대개 꺾이지 않습니다
실루엣군집 내 vs 이웃 군집 거리−1~1 · 0.5 이상이면 뚜렷
계층 군집덴드로그램을 잘라 k 결정O(n²~n³) · 소규모만
DBSCAN밀도 — k 가 필요 없음eps 를 골라야 함 · 잡음 분리
GMM (EM)확률적 소속 — 부드러운 할당공분산까지 추정
k-means 는 GMM 의 특수한 경우입니다 — 모든 군집의 공분산을 같은 구형으로 고정하고 소속을 0/1 로 굳히면 EM 이 정확히 k-means 가 됩니다. E 단계 = 할당, M 단계 = 중심 이동입니다.

핵심 개념 — PCA — 분산이 가장 큰 축부터 🟢 기초

PCA — 좌표축을 데이터가 가장 많이 퍼진 방향으로 갈아 끼웁니다 주성분 = 분산이 최대인 직교 방향 PC1 — 분산 최대 PC2 — PC1 과 직각 붉은 점은 평균입니다. PCA 는 반드시 평균을 뺀 뒤 계산합니다. 축을 회전할 뿐 정보를 버리지 않습니다 — 버리는 것은 다음 단계입니다. 설명된 분산 비율 (스크리 도표) 52% 24% 11% PC1 PC2 PC3 PC4 PC5 앞 두 축만 남기면 76% 를 지키고 차원은 7 → 2 가 됩니다.
차원 축소는 “버려도 아까운 게 적은 축”을 고르는 일입니다 — 그 기준이 분산입니다.

고유값 분해로 유도하기

Σ = (1/n)XᵀX  (중심화 후)  ⟹  Σv = λv “분산을 최대로 하는 단위 벡터”를 라그랑주로 풀면 공분산 행렬의 고유벡터가 나오고, 그때의 분산이 고유값 λ 입니다. 고유값·고유벡터 자체는 ➗ 선형대수 탭 05에 있습니다.
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 는 선형 투영뿐입니다
“해석 가능한 축이 나온다”주성분은 원래 특성들의 혼합입니다
정말 쓸모 있는 자리 — 시각화(2~3차원으로), 다중공선성 제거, 저장·전송 비용 절감, 그리고 임베딩 차원 축소로 벡터 검색 비용 줄이기. “정확도를 올리려고” 쓰는 것이 가장 흔한 오용입니다.

한 단계 더 — t-SNE · UMAP — 예쁜 그림의 함정 🟡 중급

이웃 관계만 지키고 나머지는 버립니다

그림에서 본 것믿어도 되나이유
덩어리가 나뉘어 있다대체로 예지역 이웃 구조는 보존됩니다
덩어리 사이 거리아니오전역 거리는 보존 대상이 아닙니다
덩어리의 크기아니오밀도에 따라 임의로 부풀거나 줄어듭니다
덩어리의 모양아니오perplexity 값에 따라 통째로 바뀝니다
점 하나의 위치아니오실행할 때마다 달라집니다 (확률적)
# t-SNE: 고차원의 이웃 확률 P 와 저차원의 이웃 확률 Q 의 KL 발산을 최소화
#   저차원에서는 t 분포(꼬리가 두꺼움)를 써서 '밀집 문제'를 푼다
# UMAP: 위상수학 기반 · 훨씬 빠르고 전역 구조를 조금 더 지킨다

# 반드시 지킬 것
#  ① 여러 perplexity(5, 30, 50)로 그려 보고 공통된 것만 믿는다
#  ② 축에 눈금을 넣지 않는다 (의미가 없다)
#  ③ 학습 파이프라인의 전처리로 쓰지 않는다 — 새 점을 넣을 수 없다
#     (UMAP 은 transform 이 있지만 t-SNE 는 원리상 불가)
가장 위험한 사용 — 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)

현장에서 실제로 어려운 것

임계값을 정할 근거가 없습니다 — 라벨이 없으니 정밀도·재현율을 못 잽니다. 그래서 실무에서는 “하루에 알림 30건” 같은 운영 가능한 예산으로 임계값을 역산합니다.
함정대응
정상 패턴이 시간에 따라 변함슬라이딩 윈도로 기준 갱신
계절성(주말·야간)시간대별로 따로 모델링
이상이 여러 종류한 모델로 다 잡으려 하지 않기
알림 피로점수 상위 N 건만 · 묶어서 보고
라벨이 생기기 시작함지도 학습으로 전환(훨씬 강함)
라벨이 조금이라도 모이면 지도 학습으로 갈아타세요 — 비지도 이상 탐지는 “아직 라벨이 없어서 쓰는” 임시 수단에 가깝습니다.
정리 — 비지도 학습은 “무엇이 비슷한가”라는 가정을 사람이 넣는 작업입니다. k-means 는 구형 군집을, PCA 는 선형 축을, t-SNE 는 지역 이웃만을 가정합니다. 그림이 예쁘다고 구조가 있는 것은 아닙니다.
ML 07

신경망 기초 — 비선형이 없으면 100층도 한 층입니다

신경망은 “행렬 곱 → 비선형 → 행렬 곱 → 비선형 …”의 반복일 뿐입니다. 그런데 그 사이의 비선형 하나를 빼면 전체가 무너집니다. 왜 그런지, 그리고 층을 쌓으면 정확히 무엇이 늘어나는지를 봅니다.

완전 연결 신경망 — 층마다 '가중치 행렬 곱 + 편향 + 비선형' 한 세트 3 → 4 → 2 입력층 x 은닉층 h 출력층 y h = f(W₁x + b₁) W₁ 은 4×3 · b₁ 은 4개 y = softmax(W₂h + b₂) W₂ 는 2×4 · b₂ 는 2개 비선형 f 를 빼면 W₂(W₁x) = (W₂W₁)x — 층을 100개 쌓아도 결국 한 층입니다.
가중치 행렬은 좌표를 옮기고, 비선형은 공간을 접습니다 — 접는 동작이 있어야 층이 의미를 가집니다.

퍼셉트론에서 다층으로

퍼셉트론: ŷ = step(wᵀx + b) 1958년 로젠블랫. 선형 분리 가능한 문제는 유한 걸음에 반드시 푼다는 정리까지 있었습니다.
# 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] ✓

# 은닉층이 하는 일: 원래 공간에서 못 가르던 것을
#                  '가를 수 있는 새 좌표계'로 옮긴다.
“특징을 학습한다”의 정확한 뜻이 이것입니다 — 은닉층은 02절에서 사람이 손으로 만들던 x², x₁x₂ 같은 변환 자체를 데이터에서 찾아냅니다.

보편 근사 정리 — 그리고 그 한계

은닉층 하나와 충분히 많은 뉴런이면 어떤 연속 함수든 임의 정밀도로 근사할 수 있다 Cybenko(1989) · Hornik(1991). 대단해 보이지만 세 가지를 말해 주지 않습니다.
말해 주지 않는 것현실
뉴런이 몇 개 필요한가차원에 대해 지수적일 수 있습니다
그 가중치를 찾을 수 있는가존재 증명일 뿐 · 최적화는 별개
보지 못한 데이터에서도?근사는 훈련 구간 안에서만
그래서 깊이가 필요합니다 — 같은 함수를 표현하는 데 얕고 넓은 망은 뉴런이 지수적으로 필요하지만, 깊은 망은 다항식 개수로 충분한 함수족이 있습니다. 깊이는 표현의 재사용입니다.

핵심 개념 — 활성화 함수 — 무엇을 고를 것인가 🟢 기초

활성화 함수 비교 — 기울기가 살아 있는 구간이 학습 가능한 구간입니다 ReLU GELU 시그모이드 z = 0 z ≪ 0 구간 시그모이드는 양끝에서 기울기가 0 → 층을 지날수록 신호가 사라집니다(08절). GELU 는 0 근처가 부드럽고 음수도 조금 통과시켜 죽은 뉴런이 생기지 않습니다.
ReLU 가 이긴 이유는 표현력이 아니라 양의 구간에서 기울기가 정확히 1 이라는 점입니다.

함수와 도함수

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포화 심함출력층에만
2026년의 실용적인 기본값 — CNN 은 ReLU, 트랜스포머는 GELU 또는 SwiGLU. 은닉층에 시그모이드를 쓰는 것은 거의 언제나 실수입니다. 확률을 만드는 출력층에서만 쓰세요(03절).

한 단계 더 — 죽은 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절)
과소적합 중폭을 먼저 늘림 (가장 안전)
여전히 부족깊이 · 잔차 연결 · 정규화 층
깊이를 먼저 늘리지 마세요 — 잔차 연결과 정규화 없이 깊게 쌓으면 08절의 기울기 문제를 그대로 만납니다. 폭 → 정규화 → 잔차 → 깊이 순서가 안전합니다.

실무에선 이렇게 — 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 으로 바꿉니다
이 40줄이 프레임워크의 전부입니다 — PyTorch 가 해 주는 일은 backward 를 자동으로 만들어 주는 것(08절)과 GPU 로 돌리는 것(➗ 선형대수 탭 02 · 🖥️ 컴퓨터구조 탭 10)뿐입니다.
정리 — 신경망은 선형 변환과 비선형을 번갈아 쌓은 함수입니다. 비선형이 없으면 층이 하나로 접히고, 비선형의 기울기가 작으면 학습 신호가 사라집니다. ReLU 계열이 표준이 된 이유가 바로 그 기울기입니다.
ML 08

역전파 — 연쇄 법칙을 계산 그래프로 돌리기

역전파는 새로운 수학이 아니라 고등학교 연쇄 법칙을 “중간 결과를 재사용하는 순서”로 정리한 것입니다. 한 번 손으로 굴려 보면 자동 미분이 무엇을 자동화하는지도 함께 보입니다.

순전파로 값을 채우고, 역전파로 기울기를 되돌려 받습니다 — 같은 그래프를 두 번 훑습니다 x = 2 w = 3 × u = 6 b = 1 + v = 7 σ p = .9991 ∂L/∂x = w·δ ∂L/∂w = x·δ ∂L/∂u = δ ∂L/∂b = δ ∂L/∂v = δ ∂L/∂p = −1/p δ = p − y 역전파 방향 — 오른쪽 끝의 손실에서 시작해 왼쪽으로 기울기를 나릅니다
덧셈 노드는 기울기를 그대로 복사하고, 곱셈 노드는 상대편 입력값을 곱해 넘깁니다.

손으로 한 번 굴려 보기

∂L/∂w = (∂L/∂p)·(∂p/∂v)·(∂v/∂u)·(∂u/∂w) 오른쪽부터 하나씩 곱해 가는 것이 전부입니다. 각 항은 노드 하나의 국소 미분입니다.
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        # ✓ 구현 검증의 표준 방법
기울기 검산(gradient check)은 직접 구현할 때 반드시 하세요. 수치 미분은 느려서 학습에는 못 쓰지만, 한 번 맞춰 보는 용도로는 완벽합니다. 오차가 1e-7 보다 크면 어딘가 틀렸습니다.

노드별 역전파 규칙

순전파역전파로 넘기는 것
c = a + ba, b 모두에 δ 그대로 (분배)
c = a · ba 에는 b·δ, b 에는 a·δ (교환)
c = max(a, b)이긴 쪽에만 δ, 진 쪽은 0 (라우팅)
C = A @ BdA = δ @ B.T, dB = A.T @ δ
같은 값을 두 곳에 사용두 경로의 기울기를 더한다
브로드캐스트(편향)퍼진 축으로 합산
마지막 두 줄이 직접 구현할 때 가장 많이 틀리는 곳입니다. “값을 여러 곳에 썼으면 기울기는 더한다”, “차원을 늘려 퍼뜨렸으면 기울기는 그 축으로 합한다” — 순전파의 복제는 역전파의 덧셈입니다.

핵심 개념 — 자동 미분 — 정방향 모드와 역방향 모드 🟢 기초

왜 '역방향'인가

정방향 모드역방향 모드
한 번에 얻는 것입력 1개에 대한 모든 출력의 미분출력 1개에 대한 모든 입력의 미분
비용입력 수 × 순전파출력 수 × 순전파
메모리거의 없음중간값 전부 저장
딥러닝은?입력(가중치) 10억 개출력(손실) 1개 ✓
딥러닝이 역방향 모드를 쓰는 이유는 단 하나입니다 — 손실이 스칼라 하나이기 때문입니다. 파라미터가 10억 개여도 순전파 한 번 값의 2~3배 비용으로 전부의 기울기를 얻습니다.

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

한 단계 더 — 기울기 소실과 폭발 🟡 중급

6층 신경망에서 역전파된 기울기의 크기 — 입력층까지 신호가 닿는가 ① 시그모이드만 — 층마다 0.25 이하가 곱해집니다 0.0008 0.003 0.014 0.06 0.24 1.00 손실 ② ReLU + 잔차 연결 — 기울기가 지름길로 그대로 통과합니다 0.85 0.92 0.88 0.95 0.90 1.00 손실 층 1 층 2 층 3 층 4 층 5 층 6 역전파 방향 — ① 은 층 3 을 지나면서 이미 학습 신호가 사라졌습니다
깊은 망이 안 되던 시절의 원인이 이것입니다 — 초기화 · 정규화 · 잔차 세 가지가 이 그림을 아래 줄로 바꿨습니다.

해법 ① 초기화 — 분산을 유지하도록

Xavier: Var(W) = 1/nin    He: Var(W) = 2/nin 층을 지나도 활성값의 분산이 대략 보존되도록 고른 값입니다. ReLU 는 절반을 0 으로 죽이므로 2배.
# 전부 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절 · 초기화
디버깅 순서 — ① 아주 작은 데이터(예: 8건)를 일부러 과적합시켜 보세요. 손실이 0 으로 안 가면 모델·역전파에 버그가 있는 것이고, 0 으로 가면 구현은 맞으니 데이터·정규화 쪽을 봅니다. 가장 빠른 분기점입니다.
정리 — 역전파는 연쇄 법칙 + 중간값 재사용입니다. 손실이 스칼라 하나라서 역방향 모드가 압도적으로 싸고, 층마다 곱해지는 국소 미분이 1 에서 멀어지면 기울기가 사라지거나 폭발합니다. 초기화 · 정규화 · 잔차가 그 곱을 1 근처로 붙잡아 둡니다.
ML 09

최적화 — Adam 이 고친 것을 하나씩 거슬러 올라가기

옵티마이저는 유행이 아니라 앞의 방법이 못 하던 것을 하나씩 메운 계보입니다. SGD → 모멘텀 → RMSProp → Adam → AdamW 로 오면서 각 단계가 정확히 무슨 문제를 고쳤는지를 알면 고를 때 헤매지 않습니다.

좁고 긴 골짜기 — 옵티마이저가 갈리는 지형입니다 시작 최소점 SGD — 가파른 축에 끌려 벽을 튕깁니다. 보폭을 키우면 발산합니다. 모멘텀 — 반대로 튕기는 성분끼리 상쇄되고 같은 방향은 누적됩니다. Adam — 축마다 최근 기울기 크기로 나눠 보폭을 개별 조절합니다. 이 그림의 골짜기가 곧 조건수가 나쁜 지형입니다 — 02절의 스케일링 문제와 같은 뿌리입니다.
옵티마이저의 차이는 지형이 나쁠 때만 드러납니다 — 등고선이 둥글면 SGD 로도 충분합니다.

계보 — 무엇을 고쳤나

방법추가한 것고친 문제
배치 GD——
SGD (미니배치)표본으로 기울기 추정큰 데이터에서의 속도
모멘텀과거 기울기의 관성골짜기 진동 · 얕은 지역 최소
Nesterov한 발 내다본 기울기과속 시 미리 감속
AdaGrad기울기² 누적으로 나눔희소 특성의 학습률
RMSProp누적 대신 지수 이동평균AdaGrad 의 학습률 고사
Adam모멘텀 + RMSProp + 편향 보정둘을 동시에
AdamW가중치 감쇠를 분리Adam 의 L2 왜곡
AdaGrad 의 학습률 고사 — 기울기 제곱을 계속 더하기만 하면 분모가 단조 증가해 학습률이 0 으로 수렴합니다. 초반엔 좋지만 긴 학습에서 도중에 멈춰 버립니다. RMSProp 은 이를 “최근 것만 기억”으로 바꿨습니다.

한 화면에 넣은 구현

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 에서는 두 가지가 같은 것이 아닙니다

L2 정규화: g ← g + λw   (기울기에 섞음)   |   가중치 감쇠: w ← w − η·update − η·λw   (따로 뺌) SGD 에서는 둘이 수학적으로 같습니다. 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 는 다른 결과를 낸다.
상황기본 설정비고
트랜스포머 · LLMAdamW · 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사전학습 지식을 지우지 않도록 작게
정규화 층과 편향에는 감쇠를 걸지 마세요 — LayerNorm 의 gamma·beta 와 편향까지 0 으로 밀면 성능이 떨어집니다. 실무 구현은 파라미터를 두 그룹으로 나눠 한쪽에만 weight_decay 를 겁니다. 04절의 “절편에 벌점 금지”와 같은 이야기입니다.

한 단계 더 — 학습률 스케줄 — 워밍업과 코사인 🟡 중급

학습률 스케줄 — 처음엔 조심스럽게, 끝에는 잘게 학습률 워밍업 종료 워밍업 + 코사인 감쇠 계단식 감소 워밍업 구간 학습 스텝 → 워밍업이 필요한 이유 — 초기에는 기울기 추정이 불안정하고 Adam 의 2차 모멘트가 아직 비어 있습니다. 감쇠가 필요한 이유 — 후반에 보폭이 크면 최소점 주변을 맴돌기만 합니다.
스케줄은 하이퍼파라미터가 아니라 학습의 단계를 나누는 설계입니다 — 탐색 단계와 수렴 단계가 다릅니다.

스케줄 구현

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배
SGD 의 잡음이 정규화 역할을 합니다 — 미니배치의 기울기는 참 기울기에 잡음이 섞인 값이라 좁고 깊은 구덩이에서 튕겨 나옵니다. 배치를 키우면 그 잡음이 줄어 구덩이에 그대로 갇힙니다. 04절의 이중 하강에서 말한 “암묵적 정규화”가 여기서도 작동합니다.

실무에선 이렇게 — 손실 지형 — 안장점과 평평한 최소 🔴 실무

고차원에서는 “지역 최소에 갇히는” 일이 드뭅니다

임계점 종류조건고차원에서
지역 최소모든 방향이 위로매우 드묾 (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)은 이 값 자체를 손실에 넣어 최소화한다
학습이 정체될 때 체크리스트 — ① 손실이 상수다 → 학습률 0 이거나 기울기 0(죽은 ReLU) ② 손실이 진동한다 → 학습률 과다 · 배치 과소 ③ 손실이 천천히만 준다 → 안장점/평평 구간 · 워밍업 후 lr 상향 ④ 손실이 NaN → 클리핑 · eps · 혼합정밀도 스케일 확인(08절).
정리 — 옵티마이저는 방향(모멘텀)과 보폭(적응적 스케일링) 두 가지를 손봅니다. Adam 은 둘을 합쳤고 AdamW 는 감쇠를 분리했습니다. 학습률 스케줄과 배치 크기는 최적화 속도뿐 아니라 일반화까지 바꿉니다.
ML 10

CNN · 시퀀스 — 구조를 미리 알려 주면 데이터가 덜 듭니다

완전 연결 층으로도 이미지를 처리할 수는 있지만, 파라미터가 폭발하고 학습이 안 됩니다. 합성곱과 순환 구조는 “이 데이터에는 이런 규칙성이 있다”는 사전 지식을 구조에 새겨 넣은 것입니다. 그것이 귀납 편향(inductive bias)입니다.

합성곱 — 작은 가중치 묶음 하나를 온 이미지 위로 밀며 재사용합니다 0 -1 0 -1 5 -1 0 -1 0 입력 5×5 커널 3×3 출력 3×3 ① 파라미터 공유 모든 위치에서 같은 9개를 씁니다 ② 지역성 출력 한 칸은 입력 3×3 만 봅니다 ③ 평행 이동 불변 어디에 있든 같은 필터가 반응합니다 출력 크기 = (입력 − 커널 + 2·패딩) / 스트라이드 + 1 = (5 − 3 + 0)/1 + 1 = 3
완전 연결이면 25×9 = 225개 가중치가 필요한 자리를 9개로 끝냅니다 — 그것이 CNN 의 힘입니다.

파라미터 수 비교

# 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

풀링과 수용 영역

수용 영역: Rl = Rl−1 + (kl − 1)·Πsi 층을 지날수록 출력 한 칸이 보는 원본 영역이 넓어집니다 — 그래서 깊어야 전체를 봅니다.
요소하는 일부작용
스트라이드 2해상도 절반 · 수용 영역 2배위치 정보 손실
맥스 풀링가장 강한 반응만 남김미세 위치 소실
평균 풀링부드럽게 축소강한 신호 희석
1×1 합성곱채널 수 조절 · 채널 간 혼합공간 정보는 그대로
확장(dilated)파라미터 없이 수용 영역 확대격자 무늬 아티팩트
“평행 이동 불변”은 정확히는 등변(equivariance)입니다 — 입력이 옮겨지면 출력 특징 맵도 같이 옮겨집니다. 진짜 불변이 되는 것은 마지막의 전역 풀링에서입니다. 이 구분이 위치 정보가 언제 사라지는지를 설명합니다.

핵심 개념 — RNN 과 LSTM — 기억을 어떻게 들고 가나 🟢 기초

LSTM — 셀 상태는 곱셈 하나와 덧셈 하나만 지납니다 (기울기의 지름길) 셀 상태 c — 정보의 고속도로 c(t−1) × + c(t) 망각 f = σ 입력 i = σ 후보 g = tanh 출력 o = σ 무엇을 잊을까 얼마나 받을까 무엇을 더할까 무엇을 내보낼까 c(t) = f ⊙ c(t−1) + i ⊙ g  ·  h(t) = o ⊙ tanh(c(t)) 핵심은 덧셈 경로입니다 — 08절의 잔차 연결과 정확히 같은 원리로 기울기를 지켜 냅니다.
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|) 스텝
첫 줄과 마지막 줄이 트랜스포머를 낳았습니다 — “모든 위치를 한 번에, 임의의 두 위치를 한 걸음에”가 어텐션의 설계 목표입니다. 경로 길이가 O(1) 이 되고 계산이 완전히 병렬화됩니다(11절).

실무에선 이렇게 — CNN 이 여전히 이기는 자리 🔴 실무

ViT 가 나왔는데 왜 아직 CNN 을 쓰나

조건CNNViT (트랜스포머)
데이터 1만 장강함 — 귀납 편향이 데이터를 대신함약함 — 규칙을 처음부터 배워야
데이터 1억 장포화강함 — 편향이 없어 더 배움
고해상도 입력선형에 가깝게 확장O(n²) 로 폭발
엣지 · 모바일최적화 성숙메모리 부담
지역 텍스처 문제적합과할 수 있음
# 실무 판단 기준
#  · 라벨 1만 장 미만  → 사전학습 CNN(또는 ViT) 미세조정. 처음부터 학습 금지
#  · 실시간 · 온디바이스 → CNN 계열 경량 모델
#  · 대규모 데이터 + 멀티모달 → 트랜스포머 계열
#  · 정형 데이터 → 애초에 05절(GBM)

# 데이터 증강이 CNN 에서 특히 효과적인 이유:
#   좌우 반전·크롭·색 변형은 '라벨이 바뀌지 않는 변환'을 알려 주는 것 —
#   즉 사람이 손으로 귀납 편향을 하나 더 넣어 주는 일이다 (04절).
#   ※ 숫자 6과 9처럼 상하 반전이 라벨을 바꾸는 도메인에서는 독이 된다.
ConvNeXt 같은 최신 CNN은 트랜스포머의 설계(큰 커널, LayerNorm, GELU, 역병목)를 CNN 에 이식해 ViT 와 비슷한 성능을 냅니다 — 구조의 차이보다 학습 방식과 데이터 규모의 차이가 컸다는 뜻이기도 합니다.
정리 — 합성곱은 지역성 · 파라미터 공유 · 평행 이동 등변을 구조에 새깁니다. RNN 은 순차 곱셈 때문에 먼 과거를 잃고, LSTM 은 덧셈 경로로 이를 완화합니다. 그래도 남는 순차성과 병목이 다음 절의 어텐션을 불러옵니다.
ML 11

트랜스포머 — 한 줄짜리 식이 언어 모델을 만든 과정

어텐션은 “지금 이 토큰이 다른 토큰들을 얼마나 참고할지”를 내적 한 번으로 정하는 장치입니다. 식 자체는 짧지만, 순차성을 없애고 임의의 두 위치를 한 걸음으로 잇는다는 점에서 10절 시퀀스 모델의 두 한계를 동시에 해결합니다.

스케일드 닷프로덕트 어텐션 — 질의와 열쇠를 맞춰 보고, 값을 가중 평균합니다 입력 X Q K V QKᵀ / √d softmax A · V Q = X·W_Q   질의 — 나는 무엇을 찾고 있는가 K = X·W_K   열쇠 — 나는 무엇을 가지고 있는가 V = X·W_V   값 — 실제로 전달할 내용 Attention(Q, K, V) = softmax( QKᵀ / √d_k ) · V √d_k 로 나누는 이유 — d 가 크면 내적의 분산이 커져 softmax 가 한 곳에 몰리고 기울기가 죽습니다.
어텐션은 내용 기반 조회입니다 — 위치가 아니라 무엇을 담고 있는가로 참조할 곳을 고릅니다.

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 인가 — 실제로 재 보기

Var(q·k) = dk  (성분이 평균 0, 분산 1 일 때) √d_k 로 나누면 분산이 다시 1 이 됩니다 — 차원이 커져도 점수 범위가 유지됩니다.
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)
이 한 줄이 없으면 학습이 안 됩니다 — softmax 가 포화하면 기울기가 0 에 가까워져 어텐션 가중치가 갱신되지 않습니다. 03절에서 본 “확신에 찬 오답에서 학습이 멈추는” 현상과 완전히 같은 메커니즘입니다.

핵심 개념 — 어텐션 행렬과 인과 마스크 🟢 기초

어텐션 행렬 — 행 하나가 '그 토큰이 어디를 볼지'의 확률 분포입니다 키 → 질의 ↓ 나는 어제 서울 에서 친구 를 나는 어제 서울 에서 친구 를 .05 .10 .35 .10 .40 −∞ ① 각 행의 합은 1 그 토큰이 어디를 얼마나 볼지의 분포 ② 위 삼각형은 인과 마스크 미래 토큰은 −∞ 로 막아 0 이 됩니다 ③ '친구' 행이 보는 곳 '서울' 0.35 · 자기 자신 0.40 칸 수가 n² — 문장 길이 2배면 비용 4배 인코더(BERT)는 마스크가 없어 양방향을 보고, 디코더(GPT)는 이 마스크 때문에 '다음 토큰 예측'이 가능합니다.
인과 마스크 하나가 이해 모델과 생성 모델을 가릅니다 — 구조는 거의 같습니다.

위치 인코딩 — 어텐션은 순서를 모릅니다

# 어텐션은 집합 연산이다. 토큰 순서를 바꿔도 결과가 같이 섞일 뿐,
# "누가 앞이었나"를 전혀 모른다. 그래서 위치 정보를 따로 넣어야 한다.

# ① 사인/코사인 (원조 트랜스포머) — 학습하지 않는 고정값
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²) 를 완화하는 방법들

기법아이디어대가
FlashAttentionn×n 행렬을 메모리에 안 만듦 (타일 단위 계산)없음 — 정확히 같은 결과
슬라이딩 창가까운 k 개만 참조먼 의존 손실
희소 어텐션일부 위치만 전역 참조패턴 설계 필요
선형 어텐션softmax 를 커널로 근사표현력 저하
MQA / GQAK·V 헤드를 공유약간의 품질 하락
KV 캐시생성 시 과거 K·V 재사용메모리 사용 증가
FlashAttention 이 특별한 이유 — 근사가 아니라 같은 답을 더 적은 메모리로 냅니다. 병목이 연산이 아니라 메모리 대역폭이었다는 통찰에서 나왔습니다. (🖥️ 컴퓨터구조 탭 08 캐시 계층과 같은 이야기입니다)

한 단계 더 — 블록 구조와 세 가지 계열 🟡 중급

트랜스포머 블록 하나 — 어텐션과 피드포워드에 각각 잔차 연결이 붙습니다 입력 + 위치 인코딩 멀티헤드 셀프 어텐션 Add & Norm 피드포워드 (SwiGLU) Add & Norm 인코더 전용 — BERT 양방향 · 문장 이해와 분류 디코더 전용 — GPT 인과 마스크 · 다음 토큰 생성 인코더-디코더 — T5 번역 · 요약처럼 입출력이 나뉜 일 계산량 O(n²) 길이 2배 → 비용 4배 완화 기법 FlashAttention · 슬라이딩 창 KV 캐시 생성 시 과거 K·V 재사용 왼쪽 바깥의 두 곡선이 잔차 연결입니다 — 08절에서 본 '+1' 덕분에 수십~수백 층을 쌓을 수 있습니다.
블록은 “섞기(어텐션) + 다듬기(피드포워드)”의 반복이고, 계열의 차이는 마스크와 조립 방식뿐입니다.

피드포워드가 하는 일

# 어텐션은 '토큰 사이'를 섞는다. 피드포워드는 '토큰 안'을 처리한다.
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) 연구가 이 층을 건드리는 이유다.
Pre-LN 과 Post-LN — 원조 논문은 Norm(x + Attn(x))(Post-LN)였지만, 지금은 x + Attn(Norm(x))(Pre-LN)가 표준입니다. 잔차 경로에 정규화가 끼지 않아 워밍업 없이도 깊은 모델이 안정적으로 학습됩니다.

여기서 LLM 까지

단계무엇을 하는가
토크나이저BPE 등으로 텍스트를 조각으로 — 어휘 밖 단어 없음
사전학습다음 토큰 예측 = 자기지도(01절) · 인터넷 규모
지시 미세조정“질문-답변” 형식으로 소량 재학습
선호 정렬사람 선호로 보상 모델 → RLHF · DPO
추론KV 캐시 + 샘플링(온도 · top-p)
“다음 토큰 예측”이 전부입니다 — 요약도 번역도 코딩도 전부 같은 목적 함수 하나로 학습됩니다. 여기서부터의 사용법은 🐍 파이썬 가이드의 LLM 탭이 다룹니다.

실무에선 이렇게 — 어텐션 시각화를 해석할 때 조심할 것 🔴 실무

어텐션 가중치는 “설명”이 아닙니다

흔한 주장실제
“이 단어를 보고 판단했다”어텐션이 큰 곳이 인과적으로 중요한 곳은 아닙니다
“헤드마다 역할이 있다”일부는 그렇지만 대부분은 해석되지 않습니다
“가중치를 바꾸면 답이 바뀐다”잔차 경로로 정보가 우회합니다
첫 토큰에 몰린 큰 값어텐션 싱크 — “볼 곳이 없을 때의 쓰레기통”
# 더 나은 해석 방법
#  · attention rollout : 층을 거치며 누적된 기여를 합성
#  · gradient × input  : 실제로 출력에 미친 영향 (13절 SHAP·LIME 과 같은 계열)
#  · ablation          : 그 헤드를 껐을 때 성능이 얼마나 떨어지나 ← 가장 확실
#  · logit lens        : 중간 층의 표현을 출력 어휘로 직접 투영해 보기

# 실무 팁: 프로덕션 디버깅에는 어텐션 그림보다
#          '입력을 조금 바꿔 보는 것'이 훨씬 빠르고 믿을 만하다.
어텐션 싱크는 실무에서도 만납니다 — 스트리밍 생성에서 앞부분 토큰의 KV 를 버리면 품질이 무너지는 현상의 원인입니다. 그래서 맨 앞 몇 토큰은 캐시에 남겨 두는 구현(StreamingLLM)이 나왔습니다.
정리 — 어텐션은 내용으로 조회하는 가중 평균이고, √d 스케일링·멀티헤드·위치 인코딩·잔차가 그 주변을 떠받칩니다. 인과 마스크 하나로 이해 모델과 생성 모델이 갈리고, 남은 문제는 O(n²) 입니다.
ML 12

평가 · 실험 설계 — 정확도가 거짓말하는 순간들

모델을 고르는 일은 숫자를 고르는 일입니다. 잘못된 지표를 고르면 잘못된 모델을 고르게 되고, 그보다 더 흔한 사고는 지표 자체가 오염되는 것(데이터 누수)입니다.

혼동 행렬 — 네 칸에서 모든 분류 지표가 나옵니다 (양성 100건 · 음성 9900건) 예측: 양성 예측: 음성 실제 양성 실제 음성 TP 90 맞게 잡음 FN 10 놓침 · 2종 오류 FP 40 헛알림 · 1종 오류 TN 9860 맞게 넘김 정밀도 = TP/(TP+FP) = 90/130 = 0.69 양성이라 한 것 중 진짜인 비율 재현율 = TP/(TP+FN) = 90/100 = 0.90 진짜 양성 중 잡아낸 비율 F1 = 2PR/(P+R) = 0.78 둘의 조화평균 — 한쪽만 높으면 낮게 나옵니다 정확도 = 9950/10000 = 0.995 전부 음성이라 답해도 0.99 — 정보가 없습니다 1종 오류와 2종 오류 중 무엇이 더 비싼가 — 그 답이 임계값을 정합니다. 지표가 먼저가 아니라 비용이 먼저입니다.
지표는 네 칸을 어떻게 요약할지의 선택입니다 — 요약 방식이 바뀌면 순위도 바뀝니다.

직접 계산해 보기

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 점수 · 보정 곡선
확률 보정(calibration)은 자주 빠뜨리는 항목입니다 — “확률 0.8 이라고 한 사건들이 실제로 80% 일어나는가”. 트리 앙상블은 대체로 보정이 나쁘고, 로지스틱 회귀는 좋습니다. 가격 책정처럼 확률을 곱해 쓰는 곳에서는 필수 점검입니다.

핵심 개념 — ROC-AUC 와 PR-AUC 는 다른 이야기를 합니다 🟢 기초

같은 모델 · 같은 예측 — 양성이 1% 인 데이터에서 두 곡선이 주는 인상 ROC — AUC 0.94 TPR FPR → 무작위 기준선 PR — 평균 정밀도 0.38 정밀도 재현율 → 기준선 0.01 (양성 비율) ROC 의 FPR 분모는 음성 전체 9900건이라 거짓 양성 40건이 0.004 로 희석됩니다. 불균형 문제에서는 PR 곡선을 보세요 — 소수 클래스를 잡는 능력이 그대로 드러납니다.
ROC 는 양쪽 클래스가 비슷할 때 유용합니다 — 1:99 데이터에서는 거의 언제나 좋아 보입니다.

임계값을 훑어 곡선을 만들기

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 쪽이 잘 보여 준다.
실무에서 가장 쓸모 있는 형태 — “정밀도 90% 를 유지하면서 얻는 재현율” 하나의 숫자입니다. 운영 요구사항(“오탐은 10% 이하”)을 그대로 지표로 옮긴 것이라 비개발자와도 대화가 됩니다.

다중 클래스 평균 방식

방식계산성격
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)      # ★ 이렇게 해야 안전
가장 신뢰할 만한 신호 — “기대보다 너무 좋다”입니다. 경험적으로 AUC 0.99 는 모델이 훌륭한 것이 아니라 누수가 있는 것입니다. 먼저 축하하지 말고 먼저 의심하세요.

실무에선 이렇게 — 유의성과 재현 가능한 실험 🔴 실무

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 에 정리되어 있습니다.
다중 비교 문제 — 하이퍼파라미터를 100번 시도하면 그중 하나는 운으로 좋아 보입니다. 검증셋으로 100번 고르고 나면 그 검증 점수는 이미 낙관적으로 편향되어 있습니다. 시험셋을 따로 봉인해 두는 이유가 이것입니다.

실험 하나를 기록하는 최소 단위

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 는 느려지지만 디버깅에 필수
재현 불가능한 실험은 실험이 아닙니다 — 3주 뒤 “그때 그 0.94 는 어떻게 나온 거죠”에 답할 수 없다면 그 결과는 없는 것과 같습니다. 형상 관리와 CI 관점은 🏛️ 소프트웨어 공학 탭 10 · 11 이 다룹니다.
정리 — 지표는 비용 구조에서 역산해야 합니다. 불균형에서는 정확도와 ROC-AUC 가 좋아 보이고, PR-AUC 가 진실을 말합니다. 그리고 점수가 기대보다 좋으면 먼저 누수를 의심하세요.
ML 13

학습 이론 · 한계 — 언제 되고 언제 안 되는가

“왜 학습이 되는가”에 답하는 이론과, “그래도 안 되는 것”의 목록입니다. 마지막 절은 성능 이야기가 아니라 쓸지 말지를 정하는 판단에 관한 것입니다.

VC 차원 — 어떤 라벨링이 와도 전부 가를 수 있는 최대 점 개수 직선으로 점 3개는 어떤 색 조합도 가릅니다 점 4개는 못 합니다 예시 ① 예시 ② 예시 ③ 예시 ④ 어떤 직선도 실패합니다 (XOR) 2차원 직선의 VC 차원 = 3. 일반적으로 d 차원 초평면은 d + 1 입니다. VC 차원이 클수록 표현력이 크고, 같은 보장을 얻는 데 필요한 데이터도 늘어납니다.
VC 차원은 가설 공간의 크기를 재는 자입니다 — 01절의 “H 를 얼마나 넓힐 것인가”에 숫자를 붙인 것입니다.

PAC 학습 — “거의 맞을 확률이 높다”

P[ 일반화 오차 ≤ 훈련 오차 + ε ] ≥ 1 − δ,   n = O( (VC(H) + log(1/δ)) / ε² ) Probably Approximately Correct — “확률 1−δ 로(probably) 오차 ε 안에서(approximately) 맞다”는 형태의 보장입니다.
# 식이 말해 주는 것
#  · 필요한 데이터 n 은 VC 차원에 '비례'한다 → 복잡한 모델은 데이터를 더 먹는다
#  · ε 을 절반으로 줄이려면 데이터가 4배 필요하다 (ε² 이므로)
#  · δ 는 로그로 들어간다 → 신뢰도를 높이는 비용은 싸다

# 식이 말해 주지 않는 것 (그래서 실무에서 그대로 못 쓴다)
#  · 최악의 경우 기준이라 한계가 매우 헐렁하다
#  · 신경망의 VC 차원은 파라미터 수에 비례 → 10억이면 데이터가 10억 필요?
#    그런데 실제로는 그보다 훨씬 적은 데이터로 잘 된다 (04절 이중 하강)
#  · 그래서 현대 이론은 '노름 기반 한계' · '암묵적 정규화' 쪽으로 옮겨 갔다
그래도 알아 둘 값어치는 “복잡도와 데이터 양의 교환”이라는 뼈대입니다. “모델을 키웠는데 데이터는 그대로”라면 무엇이 위험해지는지 정확히 이 식이 말해 줍니다.

공짜 점심은 없다 · 스케일링 법칙

No Free Lunch — 모든 문제에 대해 평균적으로 모든 알고리즘은 동등하다 어떤 모델이 잘 되는 이유는 그 문제의 구조와 모델의 가정이 맞았기 때문입니다.
L(N, D) ≈ A/Nα + B/Dβ + L∞ 손실이 파라미터 수 N 과 데이터 D 에 대해 거듭제곱 꼴로 줄어듭니다 — 로그-로그에서 직선.
함의실무 판단
수익 체감이 예측 가능작은 실험으로 큰 실험을 외삽
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: φj = Σ 가중치 × [ f(S ∪ {j}) − f(S) ] 협력 게임의 섀플리 값 — 특성 j 를 넣었을 때 예측이 얼마나 변하는지를 모든 조합에서 평균냅니다.
SHAPLIME
아이디어기여도의 공정 분배주변을 선형 모델로 근사
일관성이론적 보장 있음실행마다 달라질 수 있음
비용비쌈 (트리는 빠른 알고리즘 존재)쌈
범위전역 + 지역지역만
둘 다 “왜 그런지”가 아니라 “무엇이 예측을 움직였는지”를 말합니다 — 인과가 아니라 상관에 대한 설명입니다. 상관된 특성끼리는 기여도가 임의로 나뉠 수 있어, 규제 대응 문서로 쓸 때는 특히 조심해야 합니다.

한 단계 더 — 적대적 예제 — 사람 눈에는 같은 사진 🟡 중급

픽셀을 아주 조금 흔들면 판다가 긴팔원숭이가 됩니다

# 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 입력에 지시를 섞어 정책 우회
보안 관점 전반은 🔐 보안 탭이 다룹니다. 여기서 기억할 것은 하나 — 모델의 입력은 신뢰할 수 없는 입력이고, 그 원칙은 SQL 인젝션과 정확히 같습니다.

실무에선 이렇게 — 이 문제에 ML 을 쓰면 안 되는 이유 체크리스트 🔴 실무

쓸지 말지를 정하는 세 관문 — 성능 이야기는 그다음입니다 ① 규칙으로 쓸 수 있나? 아니오 ② 예시가 충분한가? 예 ③ 틀려도 감당되나? 예 ML 이 적합합니다 예 규칙 기반이 낫습니다 아니오 데이터 수집이 먼저 아니오 사람 검토를 넣으세요 통과해도 더 볼 것 · 분포가 바뀌지 않나 · 라벨 품질은 믿을 만한가 · 집단별 성능 차이는 없나 · 설명 의무가 있는 분야인가 · 재학습·운영 비용이 되나 · 기준선보다 정말 나은가 세 관문 중 하나라도 걸리면, 모델을 더 키우는 것으로는 절대 해결되지 않습니다.
가장 값싼 개선은 안 만드는 것입니다 — 만들지 않기로 한 결정이 최고의 성과인 경우가 많습니다.

배포 이후에 무너지는 방식들

현상정의대응
공변량 이동입력 분포 P(x) 가 변함입력 분포 모니터링 · 재학습
개념 이동P(y|x) 자체가 변함라벨 지연 감안한 주기적 재학습
피드백 루프모델의 예측이 다음 데이터를 만듦무작위 홀드아웃 트래픽 유지
학습-서빙 왜곡학습과 서빙의 전처리가 다름같은 코드 경로 공유
지연된 라벨정답이 몇 달 뒤에 옴대리 지표 · 지연 평가 설계
피드백 루프가 가장 무섭습니다 — 추천 모델이 보여 준 것만 클릭되고, 그 클릭이 다시 학습 데이터가 됩니다. 모델이 자기 예언을 실현하면서 지표는 계속 좋아지는데 실제 다양성과 만족도는 떨어집니다. 일정 비율의 무작위 노출을 남겨 두는 것이 표준 방어입니다.
이 탭을 마치며 — 여기까지가 “왜 계산이 학습이 되는가”의 원리입니다. 실제로 라이브러리를 써서 모델을 만드는 절차는 🐍 파이썬 가이드의 ML · DL · LLM 탭, 데이터를 다루는 쪽은 Pandas 탭과 🗄️ DB 가이드, 학습·서빙 인프라는 🌐 서버기술 가이드가 이어받습니다.
정리 — 이론은 “복잡도와 데이터의 교환”이라는 뼈대를 줍니다. 하지만 실무에서 더 중요한 것은 공정성·설명·적대적 입력·분포 변화처럼 정확도 밖의 실패 방식이고, 그중 다수는 모델을 키워서는 해결되지 않습니다.
append-only · WAL · 이벤트 소싱 · 논리 시계 · 머클 트리 · 관측 가능성

추가만 되는 줄 하나가
데이터베이스와 분산 시스템을
떠받치고 있습니다

로그는 맨 끝에만 붙이고, 한 번 붙인 것은 고치지 않는 순서 있는 기록입니다. 자료구조로 보면 어이없을 만큼 단순한데, 파일시스템 저널 · 데이터베이스의 WAL · Git 의 커밋 · Kafka 의 파티션 · Raft 의 복제 로그 · 감사 로그 · 인증서 투명성이 전부 이 하나 위에 서 있습니다. 이 탭은 “로그 라이브러리 쓰는 법”이 아니라 왜 이 단순한 것이 컴퓨터과학의 중심에 놓이는가를 다룹니다. 상태와 이벤트의 관계, 시간과 인과, 정보량, 그리고 로그로는 끝내 답할 수 없는 질문까지 갑니다.

12SECTIONS
1가지 자료구조
0사전 지식
12시각화
LOG 01

로그란 무엇인가 — 추가만 되는 순서 있는 기록

우리가 매일 보는 app.log 텍스트 파일 이야기가 아닙니다. append-only ordered sequence 라는 자료구조 이야기입니다. 연산이 두 개밖에 없는데, 이 두 개로 데이터베이스와 분산 합의가 만들어집니다.

로그 — 끝에만 붙이고, 각 줄에 번호(오프셋)가 하나씩 개설 balance 0 입금 +10,000 출금 -3,000 입금 +5,000 이체 -2,000 이자 +40 출금 -1,000 다음 여기에만 0 1 2 3 4 5 6 7 커서 소비자 A - offset 3 소비자 B - offset 6 지우기도 끼워 넣기도 없으므로 순서가 곧 사실입니다 - 같은 로그를 읽으면 누구나 같은 결론에 닿습니다 소비자는 자기 오프셋만 기억하면 됩니다 - 로그는 누가 어디까지 읽었는지 몰라도 됩니다
로그의 연산은 append 와 read(from offset) 둘뿐입니다 — 이 제약이 곧 힘입니다.

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제한 없음
커서 주인—큐각 소비자
과거 재생—불가가능
큐와 로그의 결정적 차이 — 큐는 꺼내면 사라집니다. 그래서 소비자가 새로 생기면 지난 메시지를 볼 방법이 없습니다. 로그는 읽어도 그대로 있으니 새 소비자가 오프셋 0부터 다시 읽어 과거 전체를 재생할 수 있습니다. “나중에 분석 시스템을 하나 더 붙인다”가 가능한지 아닌지가 여기서 갈립니다.

이렇게도 쓸 수 있다 — 왜 하필 순차 쓰기인가 🟡 중급

하드웨어가 로그를 좋아합니다

같은 1GB 를 쓸 때 (대략적인 감각)

  HDD  랜덤 4KB 쓰기   ~100 IOPS   → 1GB 에 40분 이상
  HDD  순차 쓰기       ~150 MB/s   → 1GB 에 7초
       ★ 300배 이상 차이 — 디스크 헤드가 안 움직이니까

  SSD  랜덤 4KB 쓰기   수만 IOPS   → 빨라졌지만
  SSD  순차 쓰기                    → 여전히 몇 배 빠릅니다
       이유: 쓰기 증폭(write amplification)과
             FTL 의 블록 단위 소거

로그는 "항상 파일 끝에 붙이기"라서
순차 쓰기만 합니다 → 하드웨어의 가장 빠른 경로
🖥️ 컴퓨터구조 의 저장장치 절과 이어집니다. LSM 트리(RocksDB · Cassandra · LevelDB)가 B+트리 대신 로그를 먼저 쓰는 이유가 정확히 이것입니다 — 랜덤 쓰기를 순차 쓰기로 바꾸는 것.

이미 우리 주변에 있는 로그들

이름무엇이 로그인가
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 가 빠른 이유의 절반이 "특별한 것을 안 하기" 입니다
오프셋은 절대 재사용하지 않습니다 — 세그먼트를 지워도 오프셋 번호는 계속 증가합니다. 만약 번호를 재사용하면 “오프셋 42 를 읽었다”는 소비자의 기억이 다른 데이터를 가리키게 됩니다. 불변 식별자라는 성질이 로그의 계약입니다.
정리 — 로그는 append 와 read(offset) 두 연산만 가진 순서 있는 불변 기록입니다. 수정과 삭제를 포기한 대가로 순차 쓰기 · 락 없는 다중 독자 · 과거 재생 · 안정된 식별자를 얻습니다. 파일시스템 저널부터 Kafka 까지가 전부 같은 자료구조입니다.
LOG 02

상태와 이벤트의 이중성 — 접기와 미분

“잔액 7,000원”과 “입금 10,000 · 출금 3,000”은 같은 사실의 두 표현입니다. 상태는 이벤트를 접은 것(fold)이고, 이벤트는 상태의 차이(미분)입니다. 이 대응이 이 탭 전체의 뼈대입니다.

상태는 이벤트를 왼쪽부터 접은 결과입니다 +10,000 -3,000 +5,000 -2,000 +40 -1,000 0 초기값 10,000 7,000 12,000 10,000 10,040 9,040 현재 상태 state = fold(apply, initial, events) 처음부터 다시 접으면 언제든 그때의 상태를 되살릴 수 있습니다 - 이것이 재생(replay)입니다 거꾸로 읽으면 - 이웃한 두 상태의 차이가 곧 이벤트 하나입니다 (상태의 미분) 상태만 들고 있으면 과거를 잃습니다 - 잔액 9,040 만 봐서는 왜 그 값이 됐는지 알 수 없습니다 이벤트를 들고 있으면 상태는 언제든 다시 만들 수 있습니다 - 그래서 로그가 원본이고 상태는 캐시입니다
상태는 이벤트의 접기, 이벤트는 상태의 미분 — 어느 쪽이 원본인지가 설계를 가릅니다.

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 가 순수 함수여야 하는 이유 — 재생할 때 같은 결과가 나와야 하기 때문입니다. 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"}');
-- 잔액은 이 로그를 접어서 만듭니다
-- 과거 어느 시점이든 되살릴 수 있습니다
CRUD 가 나쁘다는 이야기가 아닙니다. CRUD 는 “지금 값만 알면 되는” 도메인에 정확히 맞습니다. 문제는 나중에 “이 값이 왜 이렇게 됐지?”를 물어야 할 때인데, 그때는 이미 정보가 없습니다. 버리는 결정은 되돌릴 수 없습니다.

이렇게도 쓸 수 있다 — 이 대칭이 나타나는 곳들 🟡 중급

분야이벤트(로그)상태(접은 결과)
은행입출금 원장 한 줄 한 줄잔액
Git커밋(diff)워킹 트리
DB 복제WAL 레코드 스트림복제본의 테이블
RedisAOF 명령메모리의 키 공간
React 계열액션(디스패치된 것)스토어 상태
협업 편집연산(op) · CRDT 변경문서 본문
회계분개(전표)재무제표
물리속도(변화율)위치
회계는 400년 전부터 이렇게 해 왔습니다 — 복식부기의 원장은 추가만 되는 로그이고, 잘못 적으면 지우는 게 아니라 반대 전표(역분개)를 하나 더 씁니다. 소프트웨어의 “취소 이벤트”가 정확히 같은 발상입니다. 지우지 않는다는 규칙이 감사(audit)를 가능하게 만듭니다.

실무에선 이렇게 — 스냅샷 없이는 못 삽니다 🔴 실무

접는 비용은 이벤트 수에 비례합니다

# 이벤트가 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) 한 줄이 이 절의 전부입니다. 상태는 이벤트를 접은 결과이므로 언제든 다시 만들 수 있고, 이벤트는 상태의 차이이므로 상태만 남기면 이유가 사라집니다. 무엇을 원본으로 둘지가 시스템의 성격을 정합니다.
LOG 03

WAL 과 원자성 — 데이터보다 로그를 먼저 씁니다

“다 되거나 다 안 되거나”는 어떻게 만들까요. 답은 놀랄 만큼 단순합니다 — 진짜 데이터를 고치기 전에, 무엇을 고칠지를 로그에 먼저 적습니다. Write-Ahead Logging, 줄여서 WAL 입니다.

세로가 시간 - 로그가 항상 데이터보다 먼저입니다 트랜잭션 WAL 로그 데이터 페이지 복구 절차 1. 무엇을 어떻게 바꿀지 로그에 먼저 적습니다 2. fsync 완료 - 여기서부터 전원이 나가도 살아남습니다 3. 이제야 실제 페이지를 고칩니다 (아직 메모리) 4. 체크포인트 - 여기까지는 디스크 반영이 끝났다고 표시 여기서 전원이 나갑니다 - 메모리의 페이지 변경은 사라지고 디스크의 로그만 남습니다 5. 체크포인트 뒤부터 로그를 다시 실행 (redo) 6. 커밋 안 된 것은 되돌림 (undo) 규칙 하나뿐입니다 - 페이지를 디스크에 쓰기 전에 그 페이지의 로그가 먼저 디스크에 있어야 합니다 그래서 로그만 fsync 하면 됩니다 - 흩어진 페이지 대신 파일 하나만 순차로 강제 기록합니다
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 얼마까지 반영됐다"를 들고 있으면
# 재실행이 몇 번 돌아도 결과가 같습니다.
멱등성은 이 탭 전체를 관통합니다 — 복구 재실행, 메시지 재전달, 복제 로그 재적용이 전부 “두 번 와도 한 번 온 것과 같게”를 요구합니다. 🕸️ 분산 · 동시성 13 의 멱등성 키가 응용 계층에서의 같은 이야기입니다.

이렇게도 쓸 수 있다 — 체크포인트와 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 로그로 남깁니다
Redo 를 “일단 전부” 하는 게 이상해 보입니다 — 어차피 되돌릴 것까지 왜 다시 할까요. 이유는 Undo 를 하려면 먼저 크래시 직전의 정확한 상태로 돌아가 있어야 하기 때문입니다. 되돌리려면 무엇을 되돌릴지가 화면에 있어야 하니까요. 그래서 역사를 반복한 뒤 되감습니다.

실무에선 이렇게 — 응용 코드에서 쓰는 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 트랜잭션까지 전부 “진짜를 건드리기 전에 의도를 먼저 어딘가에 확정한다”는 한 가지 발상입니다.
정리 — WAL 은 데이터 페이지보다 로그를 먼저 디스크에 확정합니다. 이 순서 하나로 크래시 지점이 어디든 복구가 가능해지고, 흩어진 페이지 대신 파일 하나만 순차로 fsync 하면 되니 빠르기까지 합니다. 재실행은 반드시 멱등이어야 하고, 체크포인트가 복구 시간을 정합니다.
LOG 04

시간과 인과관계 — 벽시계로는 한 줄로 못 세웁니다

한 대에서는 “먼저 붙은 것이 먼저”라 순서가 공짜였습니다. 여러 대의 로그를 합치는 순간 그 공짜가 사라집니다. 시계는 서로 다르고, 심지어 뒤로도 갑니다.

가로줄 하나가 노드 하나 - 화살표는 메시지, 숫자는 램포트 시계 노드 A 노드 B 노드 C a1 L1 a2 L2 a3 L5 b1 L3 b2 L4 b3 L6 c1 L1 c2 L2 메시지 m1 메시지 m2 메시지 m3 a2 와 c1 사이에는 화살표 경로가 없습니다 - 동시(concurrent) 램포트 시계는 a 가 b 보다 먼저면 L(a) 가 L(b) 보다 작음을 보장합니다. 그 역은 성립하지 않습니다 벡터 시계는 노드마다 카운터를 배열로 들어 동시까지 구분합니다 - 대신 공간이 노드 수만큼 듭니다
순서의 근거는 시각이 아니라 메시지를 따라갈 수 있느냐입니다 — 그것이 happens-before.

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)가 하는 일입니다.
🔢 이산수학 의 반순서 집합(poset)이 여기서 그대로 나옵니다. happens-before 는 비반사·비대칭·이행을 만족하는 엄격 부분 순서이고, 전순서로 확장하는 것이 위상 정렬입니다. 다만 확장 방법이 여럿이라 어느 것을 고를지를 시스템이 정해야 합니다.

램포트 시계와 벡터 시계

# ── 램포트 논리 시계 ── 정수 하나
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). 하드웨어 필요
“타임스탬프로 정렬하면 되지 않나”가 실무에서 데이터를 조용히 지웁니다. 두 서버의 시계가 5ms 어긋나 있으면 나중에 쓴 값이 먼저처럼 보여 옛 값에 덮입니다. 에러도 안 납니다. 🕸️ 분산 · 동시성 12 의 LWW 위험이 정확히 이 이야기입니다.

실무에선 이렇게 — 여러 서비스의 로그를 한 화면에서 보기 🔴 실무

서비스 다섯 개의 로그를 시각으로 정렬해 놓고 원인을 찾으면 반드시 틀립니다.
'결과가 원인보다 먼저' 나오는 줄을 보게 됩니다.

실무의 답 — 인과를 명시적으로 들고 다닙니다 (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
왜 이 절이 로그 탭에 있나 — 분산 시스템에서 로그의 가치는 “한 줄로 세울 수 있는가”에 달려 있기 때문입니다. 순서를 못 세우는 로그는 사건의 목록일 뿐 이야기가 되지 못합니다. 다음 절은 이 순서 문제를 정면으로 푸는 방법입니다.
정리 — 분산 로그의 순서는 시각이 아니라 happens-before 로 정의됩니다. 램포트 시계는 인과를 어기지 않는 번호를 주고, 벡터 시계는 동시까지 구분합니다. 한 줄로 세우려면 없는 정보를 만들어야 하고 — 단일 시퀀서, 합의, HLC, 동기화 시계가 그 방법입니다.
LOG 05

합의와 복제 로그 — 로그가 같으면 상태가 같습니다

여러 대가 같은 값을 갖게 하는 가장 확실한 방법은 같은 값을 복사하는 것이 아니라 같은 로그를 갖게 하는 것입니다. 상태 기계 복제(RSM)라고 부르는, 분산 시스템의 가장 오래된 아이디어입니다.

같은 인덱스, 같은 임기면 그 앞의 모든 항목이 같습니다 (로그 매칭) 0 1 2 3 4 5 6 7 리더 1:a=1 1:b=2 2:c=3 2:d=4 3:e=5 3:f=6 3:g=7 3:h=8 팔로워 A 1:a=1 1:b=2 2:c=3 2:d=4 3:e=5 3:f=6 - - 팔로워 B 1:a=1 1:b=2 2:c=3 2:d=4 2:z=9 2:y=1 - - 앞의 네 칸이 같으므로 그 앞은 검사할 필요조차 없습니다 - 리더는 인덱스 4 부터만 고칩니다 팔로워 B 의 어긋난 두 칸은 잘려 나가고 리더의 항목으로 덮입니다 (리더는 자기 로그를 절대 안 지웁니다) 상태 기계 복제 - 같은 초기 상태에서 같은 로그를 같은 순서로 적용하면 반드시 같은 상태가 됩니다 그래서 합의는 상태가 아니라 로그에 합의합니다. 상태는 로그에서 다시 만들면 되니까요
복제의 단위는 값이 아니라 로그 한 줄입니다 — 순서가 같으면 결과가 같습니다.

상태 기계 복제(RSM) 의 세 조건

복제본 N 대가 반드시 같은 상태가 되려면

  ① 같은 초기 상태에서 시작한다
  ② 같은 명령을 같은 순서로 받는다      ← 합의가 보장할 부분
  ③ 명령 적용이 결정적(deterministic)이다 ← 우리가 지킬 부분

③ 을 깨는 흔한 것들
  · 현재 시각        now()          → 명령에 시각을 실어 보냅니다
  · 난수             uuid4()        → 리더가 뽑아서 명령에 넣습니다
  · 맵 순회 순서                    → 정렬된 순회를 씁니다
  · 부동소수 연산 순서              → 같은 순서를 강제하거나 정수로
  · 로캘·타임존 의존 정렬           → 고정 로캘

★ ③ 이 깨지면 합의가 아무리 잘 돌아도 복제본이 갈라집니다.
   그리고 이 갈라짐은 몇 시간 뒤에 엉뚱한 조회 결과로 나타납니다.
“로그를 복제한다”와 “상태를 복제한다”의 차이 — 상태를 보내면 매번 전체(또는 diff)를 보내야 하고, 보내는 도중 값이 또 바뀝니다. 로그를 보내면 보낼 것이 이미 불변으로 확정돼 있고, 받는 쪽은 어디까지 받았는지 오프셋 하나로 관리하면 됩니다.

로그 매칭 성질 — 두 줄로 끝나는 귀납

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 그룹 하나
분산 락 · 리더 선출“단 하나”가 보장돼야 함
사용자 데이터 대량 쓰기안 씁니다 — 초당 수십만 쓰기에는 합의가 너무 비쌉니다
로그·메트릭 수집안 씁니다 — 정확히 하나일 필요가 없고 처리량이 우선입니다
합의는 비싸므로 아껴 씁니다 — “누가 리더인가 · 이 샤드는 누구 것인가” 같은 작지만 절대 틀리면 안 되는 사실에만 씁니다. 대량 데이터는 그 결정을 따라가는 방식(단일 리더 복제)으로 처리합니다. 🕸️ 분산 · 동시성 09 · 14 에 Raft 의 리더 선출과 안전성 증명이 자세히 있습니다.
정리 — 복제의 단위는 값이 아니라 로그 한 줄입니다. 같은 초기 상태 + 같은 로그 + 결정적 적용 = 같은 상태(RSM). 그래서 합의 알고리즘은 상태가 아니라 로그의 순서에 동의합니다. 쓰기 지연은 과반 중 가장 느린 노드의 fsync가 정합니다.
LOG 06

로그 기반 아키텍처 — 데이터베이스를 뒤집기

데이터베이스 안에 숨어 있던 로그를 밖으로 꺼내 시스템의 중심축으로 놓으면 아키텍처가 통째로 바뀝니다. 이벤트 소싱 · CQRS · CDC · 카프카가 전부 이 하나의 이동에서 나온 이름들입니다.

로그 하나를 가운데 두면 생산자와 소비자가 서로를 몰라도 됩니다 생산자 외부 소비자 주문 서비스 결제 서비스 DB 변경(CDC) 커밋 로그 0 · 1 · 2 · 3 · 4 … 쓰기는 끝에만 읽기는 오프셋으로 보존 기간까지 남습니다 분석 · 웨어하우스 과거 전체 재생 검색 색인 실시간 갱신 감사 · 규제 지우지 않는 사본 생산자는 소비자를 모르고, 소비자는 서로를 모릅니다 - 아는 것은 로그와 자기 오프셋뿐입니다 데이터베이스를 뒤집기 - 안에 숨어 있던 로그를 밖으로 꺼내 시스템 전체의 중심축으로 씁니다 새 소비자는 오프셋 0부터 다시 읽어 과거를 통째로 재생합니다 - 큐였다면 불가능한 일입니다
중심에 원통(로그) 하나 — 바깥의 구름들은 서로의 존재를 모른 채 같은 사실을 읽습니다.

이벤트 소싱 + 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 부터 다시 만들 수 있습니다.
# → 인덱스 설계를 바꾸고 싶으면 새 모델을 만들어 재생하면 끝입니다.
CQRS 의 진짜 이득은 “읽기 모델을 버릴 수 있다”입니다. 보통의 DB 에서는 스키마를 바꾸려면 마이그레이션이라는 위험한 수술이 필요하지만, 로그가 원본이면 새 모델을 옆에 만들어 재생하고, 다 만들어지면 스위치를 넘기면 됩니다.

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 화면 몇 개가 전부쓰지 마세요 — 복잡도만 늘어납니다
강한 즉시 일관성이 필수읽기 모델은 결국 일관입니다. 맞지 않습니다
가장 흔한 실패 — 읽기 모델이 몇 초 늦는다는 사실을 UI 가 견디지 못하는 경우입니다. “저장했는데 목록에 없어요”가 바로 그것입니다. 해법은 쓰기 응답에 필요한 값을 같이 돌려주거나, 그 화면만 쓰기 모델을 직접 읽는 것입니다. 🕸️ 분산 · 동시성 08 의 일관성 모델과 이어집니다.
정리 — DB 안에 있던 로그를 밖으로 꺼내 모든 시스템이 같은 사실을 구독하게 만드는 것이 로그 기반 아키텍처입니다. 이벤트 소싱은 쓰기를, CQRS 는 읽기를, CDC 는 레거시를 담당합니다. 순서는 파티션 안에서만 보장되고, 읽기 모델은 언제든 지우고 다시 만들 수 있는 캐시입니다.
LOG 07

정보량과 로그 — 남기지 않은 것은 복원되지 않습니다

로그를 “얼마나 남길까”는 취향 문제가 아니라 정보 이론 문제입니다. 로그는 과거에서 미래로 가는 채널이고, 채널을 통과하지 못한 정보는 어떤 똑똑한 도구로도 되살릴 수 없습니다.

레이블 하나가 늘 때마다 시계열 개수는 곱해집니다 (가로는 로그 눈금) 서비스 × 상태코드 30개 + 엔드포인트 80 2,400개 + 인스턴스 50 12만 개 + user_id 100만 1,200억 1 천 백만 10억 1조 서로 다른 시계열의 개수 (한 칸이 1000배) 레이블 하나를 늘릴 때마다 곱해집니다 - user_id 를 넣는 순간 시계열이 1,200억 개가 됩니다 샘플링 1% 는 저장 비용을 100분의 1로 줄이지만, 딱 한 번 일어난 오류는 99% 확률로 사라집니다 정보 이론으로 보면 로그는 채널입니다 - 남기지 않은 것은 나중에 어떤 도구로도 복원되지 않습니다 무엇을 남길지 정하는 일은 곧 나중에 어떤 질문에 답할 수 있는지를 지금 정하는 일입니다
카디널리티는 더하기가 아니라 곱하기로 늡니다 — 레이블 하나가 비용을 백 배로 만듭니다.

엔트로피 관점 — 한 줄이 담는 정보의 양

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건 + 이후 카운트만 (로그 스로틀링)

한 줄 규칙
  ★ "이 줄이 없으면 대답 못 하는 질문이 있는가?"
     없으면 그 줄은 비용일 뿐입니다.
거꾸로 가장 비싼 실수 — 장애가 터진 뒤에야 “아, 그 값을 안 남겼네”를 알게 되는 것입니다. 로그는 미래의 나에게 보내는 편지인데, 편지는 사건이 일어나기 전에 써야 합니다. 그래서 새 기능을 만들 때 “이게 실패하면 무엇을 보고 싶을까”를 설계 단계에서 정합니다.
정리 — 로그는 과거에서 미래로 가는 채널이고, 통과하지 못한 정보는 복원되지 않습니다. 카디널리티는 곱셈으로 늘어나므로 고유값이 큰 필드는 로그에는 넣되 메트릭 레이블에는 넣지 않습니다. 균등 샘플링은 정확히 희귀 사건과 꼬리를 먼저 버립니다.
LOG 08

관측 가능성의 이론 — 출력만 보고 내부를 알 수 있는가

관측 가능성(observability)은 마케팅 용어가 아니라 제어이론에서 온 정의가 있는 말입니다. “출력의 유한한 기록만으로 내부 상태를 유일하게 결정할 수 있으면 그 시스템은 관측 가능하다.” 이 정의를 소프트웨어로 옮기면 무엇을 남겨야 하는지가 자동으로 따라 나옵니다.

질문마다 답할 수 있는 신호가 다릅니다 (색이 진할수록 잘 답함) 로그 메트릭 추적 프로파일 지금 오류율이 몇 %인가 3 5 2 0 이 주문 하나가 왜 실패했나 5 0 4 0 어느 구간에서 시간이 갔나 2 2 5 3 CPU 를 어느 함수가 먹나 0 1 1 5 어제와 무엇이 달라졌나 3 4 3 2 처음 보는 질문에 답할 수 있나 4 1 3 1 못함 잘함 0 = 못 답함 · 5 = 바로 답함. 셋은 대체재가 아니라 서로 다른 질문에 답하는 도구입니다 관측 가능성은 제어이론의 말입니다 - 출력만 보고 내부 상태를 유일하게 결정할 수 있으면 관측 가능 그래서 질문이 먼저입니다 - 답해야 할 질문 목록이 없으면 무엇을 남겨야 할지도 정할 수 없습니다
어떤 행도 한 칸만으로 채워지지 않습니다 — 세 신호는 대체재가 아니라 서로 다른 질문의 도구입니다.

제어이론의 정의를 그대로 옮기면

선형 시스템   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 조합으로 새 질문에 답하는 능력입니다.
➗ 선형대수 의 계수(rank)와 🤖 ML 이론 의 식별 가능성(identifiability)이 같은 뼈대입니다. 관측이 모자라면 여러 내부 상태가 같은 출력을 냅니다 — 그러면 원인을 하나로 좁힐 수 없습니다. “로그를 봐도 모르겠다”의 정확한 수학적 이름이 이것입니다.

세 신호의 저장 구조가 능력을 정합니다

로그메트릭추적
단위사건 하나시각 + 수치요청 하나
저장줄 단위 원본미리 집계스팬 트리
비용양에 선형카디널리티에 곱샘플링률에 선형
지연초 단위거의 즉시초 단위
잘하는 질문왜 · 무엇이얼마나 · 언제부터어디서
못하는 것전체 추세개별 사례전수 통계
알림에부적합적합부적합
결정적 차이는 “미리 집계했는가”입니다. 메트릭은 저장할 때 이미 정보를 버렸으므로 나중에 새로운 방식으로 쪼갤 수 없습니다(“이 오류가 어느 고객에게 몰렸나”에 못 답합니다). 로그·추적은 원본을 갖고 있어 질문을 나중에 정할 수 있습니다 — 대신 비쌉니다. 최근의 넓은 이벤트(wide event) 흐름은 이 셋을 한 줄에 합치자는 시도입니다.

이렇게도 쓸 수 있다 — 계측을 설계로 다루기 🟡 중급

# 계측(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 에 몰려 있나?   ← 미리 계획 못 한 질문 ★
“나중에 필요하면 그때 넣지”가 안 되는 이유 — 필드를 오늘 추가해도 어제의 데이터에는 그 필드가 없습니다. 그리고 장애는 늘 어제 일어납니다. 계측은 코드처럼 리뷰하고, 필드 추가는 배포처럼 다루세요.
정리 — 관측 가능성은 출력만 보고 내부 상태를 결정할 수 있는가라는 제어이론의 성질입니다. 도구를 사서 얻는 게 아니라 무엇을 내보내기로 했는가로 정해집니다. 로그 · 메트릭 · 추적은 저장 구조가 달라 답할 수 있는 질문의 종류가 다릅니다.
LOG 09

구조적 로깅과 스키마 진화 — 한 줄은 문장이 아니라 레코드입니다

"Payment failed for order 88123 after 412ms" 는 사람에게는 친절하지만 기계에게는 파싱해야 할 숙제입니다. 로그 한 줄을 필드 이름과 값의 집합으로 보면 로그는 곧 테이블이 되고, 그 순간부터 데이터베이스가 40년 동안 풀어 온 문제 — 스키마는 어떻게 바뀌는가 — 가 그대로 따라옵니다.

구조적 로그 한 줄 - 문장이 아니라 필드의 집합입니다 { "ts": "2026-09-07T03:12:44.128Z", "level": "ERROR", "service": "payment", "version": "1.42.0", "region": "ap-ne-2", "trace_id": "4bf92f35…", "span_id": "00f067aa…", "event": "charge.failed", "reason": "card_declined", "order_id": 88123, "duration_ms": 412.7, "schema": 3 } 시각은 UTC 로 통일 순서는 seq 가 정합니다 레벨은 필터의 열쇠 service 는 필수 필드 배포 버전이 없으면 언제부터를 못 찾습니다 요청을 잇는 열쇠 LOG 04 의 인과 문장이 아니라 코드 grep 대신 질의 숫자는 숫자로 문자열이면 합산 불가 스키마 버전 진화의 손잡이 한 줄이 곧 레코드입니다 - 필드 이름이 열, 값이 셀. 문장으로 적으면 나중에 파싱이라는 세금을 냅니다 스키마 번호 하나가 1년 뒤의 나를 구합니다 - 필드 뜻이 바뀌면 번호를 올리고 옛 줄은 옛 규칙으로 읽습니다
로그 한 줄은 열 이름이 붙은 레코드입니다 — 문장으로 적는 순간 읽는 쪽이 파싱 비용을 냅니다.

문장과 레코드 — 같은 사실, 다른 쓸모

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  → 사유별 건수가 한 줄에 나옵니다
event 는 문장이 아니라 코드로 둡니다 — 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 버전을 싣는다 - 읽는 쪽이 어느 규칙으로 읽을지 알도록
Elasticsearch 계열 수집기의 “매핑 충돌”이 이 문제의 가장 흔한 얼굴입니다. 같은 필드 이름에 어느 날 다른 타입이 들어오면 그 줄은 조용히 버려지거나 색인이 거부됩니다. 에러가 나는 곳은 로거가 아니라 몇 단계 뒤의 수집기라서 원인을 찾는 데 시간이 걸립니다.

이렇게도 쓸 수 있다 — 호환성의 네 방향 🟡 중급

누가 먼저 바뀌는가로 규칙이 갈립니다

호환뜻허용되는 변경
후방(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 줄이 남아 있습니다
Avro · Protobuf 가 이 규칙을 강제해 줍니다 — Confluent Schema Registry 는 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 가 섞이면 조인이 안 됩니다
  ④ 스키마 문서와 예제 줄을 코드 저장소에 같이 둡니다 - 리뷰 대상입니다
ObservedTimestamp 가 왜 따로 있나 — 사건이 일어난 시각과 수집기가 그 줄을 받은 시각은 다릅니다. 네트워크가 막혀 30분 뒤에 도착한 줄은 Timestamp 로 정렬하면 과거에, ObservedTimestamp 로 보면 지금 나타납니다. “왜 그래프가 뒤늦게 바뀌지?”의 답이 대개 이 둘의 차이입니다.
정리 — 로그 한 줄은 필드가 있는 레코드이고, 대시보드가 필드를 읽는 순간 스키마가 존재합니다. 변경은 데이터베이스와 같은 호환 규칙(후방·전방)을 따르고, 줄마다 schema 버전을 실어 옛 줄은 업캐스터로 읽어 올립니다. 필드 이름은 새로 짓지 말고 OTel 규약에 올라탑니다.
LOG 10

로그 컴팩션과 보존 — 영원히 남기지는 못합니다

로그는 append-only 지만 디스크는 유한합니다. 무엇인가는 버려야 하는데, 버리는 방법이 곧 시스템이 답할 수 있는 질문을 정합니다. 시간으로 자를지, 키마다 마지막만 남길지, 싼 저장소로 옮길지 — 셋은 서로 다른 것을 잃습니다.

보존 전략 x 관심사 - 붉을수록 불리, 푸를수록 유리, 회색은 중립 저장 비용 과거 재생 최신 상태 잊힐 권리 복구 시간 시간 기반 삭제 +2 -2 -2 +1 +1 크기 기반 삭제 +2 -2 -2 0 +1 키 컴팩션 +1 -1 +2 +2 +2 계층 저장(티어링) +1 +2 0 -1 -1 무기한 보존 -2 +2 -1 -2 -2 -2 불리 … +2 유리 (정성 평가 - 실제 값은 데이터 양과 요구에 따라 다릅니다) 키 컴팩션은 fold 를 미리 해 두는 것입니다 - 키마다 마지막 값만 남기니 최신 상태는 빠르고 중간 이력은 사라집니다 어느 행도 전부 유리하지 않습니다 - 보존 정책은 무엇을 잃어도 되는지를 먼저 정하는 일입니다 실무는 섞습니다 - 상태는 컴팩션 토픽, 이력은 시간 보존 + 콜드 저장소, 개인정보는 로그 밖 금고에
보존 정책은 무엇을 잃어도 되는지를 먼저 정하는 일입니다 — 전부 유리한 행은 없습니다.

삭제와 컴팩션 — 같은 “버리기”, 다른 결과

보존(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 이 어제 뭐였나?"       보존: 답함 / 컴팩션: 모름
  "지난주 이벤트 수는?"      보존: 답함 / 컴팩션: 모름 (사건 수가 보존 안 됨)
컴팩션은 LOG 02 의 fold 를 저장소가 대신 해 두는 것입니다. 키마다 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 을 합치며 키별 최신만 남김레벨별 정렬 파일
Gitgc 가 도달 불가 객체를 지우고 팩으로 묶음참조에서 닿는 커밋 전부
WAL(DB)체크포인트 뒤 옛 WAL 파일 재활용체크포인트 이후만
Raft 스냅샷 - 뒤처진 팔로워에게는 로그 대신 스냅샷을 보냅니다
  InstallSnapshot(term, lastIncludedIndex, lastIncludedTerm, data)
  · 팔로워는 스냅샷을 적용하고 그 이전 로그를 버립니다
  · 그 뒤 로그는 평소처럼 AppendEntries 로

LSM 컴팩션의 두 전략 (RocksDB 용어)
  leveled    쓰기 증폭 큼 · 공간 효율 좋음 · 읽기 빠름
  universal  쓰기 증폭 작음 · 공간 더 씀 · 읽기 느림
  → 같은 트레이드오프의 축이 위 히트맵과 같습니다
공통 구조 — 전부 “접은 결과(스냅샷·최신 값)를 남기고 접기 전 재료(로그)를 버린다”입니다. LOG 02 의 스냅샷이 응용 계층의 이야기였다면, 여기는 저장소 계층이 같은 일을 하는 것입니다.

실무에선 이렇게 — 잊힐 권리와 append-only 의 타협 🔴 실무

"지워 달라"는 요구는 append-only 와 정면으로 부딪힙니다. 실무의 세 가지 답:

① 컴팩션 토픽 + 툼스톤
   key=user:118 value=∅ 를 붙이면 delete.retention.ms 뒤 그 키의 모든 값이 사라집니다
   · 되는 것: "현재 상태" 토픽에서 그 사용자를 지움
   · 안 되는 것: 시간 보존 토픽·백업·콜드 저장소에 남은 옛 줄 (거기까지 따라가야 합니다)

② 암호 파쇄(crypto-shredding)
   개인 필드는 사용자별 키로 암호화해 로그에 두고, 키는 로그 밖 금고에
   · 지우기 = 금고에서 키 삭제. 로그는 한 줄도 안 고칩니다 ★
   · 로그·백업·콜드가 몇 벌이든 한 번에 무력화됩니다
   · 대가: 키 관리(회전·백업·접근 통제)가 새 책임이 됩니다 (🔐 보안 · 암호)

③ 로그 밖 참조
   로그에는 user_id 만, 이름·이메일은 별도 테이블
   · 가장 단순. 대신 로그만으로는 "누구"를 못 봅니다 (조인 필요)

보존 기간은 법이 정하는 하한과 비용이 정하는 상한 사이입니다
   · 금융·의료는 수년 단위 보존 의무가 있고, 접속 기록도 법령이 최소 기간을 정합니다
   · 그 위로는 "이 줄이 없으면 못 답하는 질문이 있나"(LOG 07)가 상한을 정합니다
   · 기간은 데이터 종류마다 다르게 - 감사 로그 수년 · 요청 로그 몇 주 · DEBUG 며칠
“지웠다”가 가장 어려운 말입니다 — 로그는 복제본 · 백업 · 수집기 색인 · 콜드 저장소 · 개발자 노트북으로 자동으로 퍼집니다. 한 곳에서 지운 뒤 “완료”라고 답하면 나중에 다른 사본이 나옵니다. 그래서 ②가 유일하게 사본 수와 무관하게 통하는 답입니다.
정리 — 로그는 영원하지 않고, 버리는 방법이 답할 수 있는 질문을 정합니다. 시간·크기 삭제는 이력을 통째로, 컴팩션은 중간 과정을 잃는 대신 키별 최신 상태를 남깁니다. Raft 스냅샷 · LSM 컴팩션 · DB 체크포인트가 전부 같은 발상이고, 잊힐 권리는 암호 파쇄로 append-only 를 지키면서 풉니다.
LOG 11

무결성 — 해시 체인 · 머클 트리 · 인증서 투명성

“append-only” 는 지금까지 약속이었습니다. 관리자가 파일을 열어 한 줄을 고치면 아무도 모릅니다. 그 약속을 수학으로 검증 가능하게 만드는 것이 이 절입니다 — 고친 것을 막지는 못해도 고쳤다는 사실은 반드시 드러나게 합니다.

머클 트리 - 로그 전체를 해시 하나로 대표하고, 포함 여부는 log n 개로 증명합니다 루트 서명해 공개하는 값 h0123 h4567 h01 h23 h45 h67 h0 h1 h2 h3 h4 h5 h6 h7 포함 증명: 항목 5 가 들어 있음을 보이려면 형제 셋(h4 · h67 · h0123)만 있으면 됩니다 - 8개 중 3개, 항목 n 개면 log n 개 항목 5 를 한 글자라도 고치면 h5 - h45 - h4567 - 루트가 전부 바뀝니다. 공개된 루트와 어긋나는 순간 들킵니다 머클 트리는 로그 전체를 해시 하나로 대표합니다 - 루트를 공개하면 아무도 몰래 과거를 고칠 수 없습니다 일관성 증명은 어제의 루트가 오늘 로그의 앞부분에서 나왔음을 보입니다 - 추가만 했고 지우지 않았다는 증거
루트 하나를 공개하면 몰래 고치기가 불가능해집니다 — 막는 게 아니라 반드시 드러나게 하는 것입니다.

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 를 요구해야 강제됨
같은 구조가 소프트웨어 공급망으로 번졌습니다 — Sigstore 의 Rekor 는 “누가 어떤 빌드에 서명했나”를 투명성 로그에 남기고, Go 모듈 체크섬 DB 도 같은 머클 로그입니다. Google 의 Trillian 이 이런 로그의 공용 구현입니다. 🔐 보안 · 암호 탭의 서명·PKI 와 이어집니다.

실무에선 이렇게 — 우리 감사 로그를 변조 방지로 만들려면 🔴 실무

내부 감사 로그(누가 무엇을 승인·삭제·조회했나)에 같은 원리를 씁니다. 순서대로:

① 결정적 직렬화        같은 레코드는 항상 같은 바이트여야 합니다
                       - 키 정렬 · 공백 고정 · 부동소수 표기 고정 (JSON 이면 canonical 형식)
② 줄마다 prev 해시     해시 체인. 여기까지는 라이브러리 없이 30줄
③ 주기적 루트 공개     하루치(또는 N 줄)마다 머클 루트를 만들어
                       - 다른 시스템(별도 계정의 저장소 · 외부 타임스탬프 서비스) 에 적습니다
                       - 로그를 고칠 수 있는 사람이 이 루트까지 고칠 수 없어야 뜻이 있습니다 ★
④ 검증 잡              매일 처음부터 체인·루트를 다시 계산해 공개된 값과 비교
                       - 불일치는 "버그"가 아니라 "사고"로 취급합니다
⑤ 쓰기 경로 분리       로그를 쓰는 서비스 계정은 append 만, 삭제 권한은 아무에게도
                       - 클라우드의 객체 잠금(WORM · Object Lock) 같은 저장소 기능을 씁니다

하지 말 것
  · 로그를 그 로그를 만드는 DB 안에 해시만 붙여 두기 - DBA 가 둘 다 고칩니다
  · 블록체인부터 도입하기 - 참여자가 한 조직이면 해시 체인 + 외부 공개로 충분합니다
  · 타임스탬프를 신뢰 근거로 쓰기 - 시각은 조작이 가장 쉬운 필드입니다 (LOG 04)
“변조 방지”와 “변조 감지”를 구분하세요 — 소프트웨어는 후자만 할 수 있습니다. 전자는 저장소의 권한과 물리 통제의 몫입니다. 감지가 확실하면 고칠 동기가 사라지므로 결과적으로 방지가 됩니다. 투명성의 힘은 막는 데 있지 않고 숨길 수 없게 하는 데 있습니다.
정리 — 해시 체인은 중간이 안 바뀌었음을, 머클 트리는 그 위에 포함과 일관성을 log n 으로 증명합니다. 루트를 외부에 공개해야 끝을 잘라내는 공격까지 드러납니다. Certificate Transparency 는 이 구조로 CA 의 발급을 숨길 수 없게 만들었고, 같은 설계가 소프트웨어 서명과 내부 감사 로그로 번졌습니다.
LOG 12

로그로 못 잡는 것 — 증거이지 진실이 아닙니다

열한 절 동안 로그가 얼마나 강력한지 봤으니 마지막은 한계입니다. 로그는 “적힌 것”이지 “일어난 것”이 아닙니다. 둘 사이의 틈에서 없는 줄을 없는 일로 읽고, 있는 줄을 원인으로 읽는 실수가 나옵니다.

일어난 일 · 기록된 것 · 본 것 - 셋이 겹치는 가운데만이 이해한 사건입니다 일어난 일 기록된 것 우리가 본 것 침묵 계측 안 됨 · 유실 거짓 신호 계측 버그 · 재생분 추측 묻힌 증거 감 오독 이해 로그는 증거이지 진실이 아닙니다 - 가운데만 이해한 사건이고, 나머지 여섯 칸이 이 절의 주제입니다
가운데 초록만이 이해한 사건입니다 — 나머지 여섯 영역 각각에 이름이 붙는다는 것이 핵심입니다.

침묵 — 없는 줄은 없는 일이 아닙니다

"에러 로그가 없으니 정상입니다" 가 틀리는 경우
  · 요청이 서버에 닿지 못했다      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
로그는 코드가 “적기로 한 것”입니다. 코드가 틀리면 로그도 같은 방향으로 틀립니다. 그래서 로그와 독립적인 두 번째 관측(DB 의 실제 값, 상대 시스템의 기록, 고객의 화면)이 없으면 로그 하나로는 사고 조사를 끝낼 수 없습니다.

이렇게도 쓸 수 있다 — 관측자 효과와 로거 자체의 실패 🟡 중급

보는 행위가 대상을 바꿉니다

하이젠버그(Heisenbug) - 로그를 켜면 사라지는 버그
  · 로깅이 타이밍을 바꿔 경쟁 상태가 안 일어납니다
    (I/O 한 번이 마이크로초 단위 경합 창을 덮어 버립니다)
  · DEBUG 를 켜니 GC 압력이 늘어 원래 문제가 다른 문제로 바뀝니다
  · 로그 I/O 가 락 안에서 일어나 락 유지 시간이 늘어납니다 ★ 자주 봅니다

로거가 병목이 되는 순간
  · 동기 로거 + 느린 디스크 - 요청 스레드가 fsync 를 기다립니다
  · 로그 문자열을 만드는 비용 - 호출 자체가 비싼 toString() · 직렬화
    → 레벨을 먼저 확인하고 만듭니다 (isEnabled 가드 · 지연 평가)
  · 락 안의 로깅 - 임계 구역이 I/O 시간만큼 길어집니다

로거가 사고가 되는 순간
  · 로그 라이브러리의 취약점 - 2021년 12월 Log4Shell(CVE-2021-44228):
    로그에 적힌 문자열이 코드로 실행됐습니다. 로거도 입력을 받는 코드입니다
  · 시그널 핸들러 · 인터럽트 안에서의 로깅 - 락을 다시 잡아 교착
  · 로그로 디스크가 차서 DB 가 멈춤 - 로그가 서비스를 죽인 사례는 흔합니다

일곱 가지 한계와 각각의 대응

못 잡는 것왜대응
일어나지 않은 일부재는 기록되지 않음하트비트 · 기대 카운트
닿지 않은 요청생존자 편향클라이언트 측 계측
인과로그는 상관·순서만trace 의 parent 관계 · 실험
계측 전의 과거어제 데이터엔 오늘 필드가 없음설계 단계에서 계측(LOG 08)
모르는 질문C 행렬은 사람이 정함넓은 이벤트 · 원본 보존
로그 자체의 실패실패를 적을 곳이 없음외부 감시 · 수집 지표
의도“왜 그랬나”는 코드 밖도메인 이벤트에 이유 필드
인과는 특히 조심할 것 — “배포 직후 오류가 늘었다”는 상관입니다. 같은 시각에 트래픽 패턴이 바뀌었을 수 있습니다. 로그가 주는 것은 순서(happens-before)까지이고, 원인이라는 결론은 되돌려 보거나(롤백) 나눠 보는(카나리) 실험이 줍니다. 🤖 ML 이론 의 상관·인과 구분이 그대로입니다.

실무에선 이렇게 — 사고 조사에서 로그를 읽는 규율 🔴 실무

로그를 읽을 때 스스로에게 묻는 순서 (이 탭 전체의 요약이기도 합니다)

  ① 이 줄은 언제 적혔나 - 사건 시각인가, 수집 시각인가, 어느 시계인가        (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 를 반환했다"        ← 로그가 답함
  "그 원인은 카드사 응답 지연이었다"                                          ← 로그만으로는 추정. 근거 출처를 따로

한 줄 규칙
  ★ 로그는 증언입니다. 증언은 교차 검증되기 전까지 사실이 아닙니다.
이 탭을 한 문장으로 — 로그는 추가만 되는 순서 있는 기록이고, 그 단순함 덕분에 원자성 · 복제 · 합의 · 재생 · 무결성이 전부 한 자료구조 위에 올라갑니다. 다만 로그는 적힌 것이지 일어난 것이 아니므로, 마지막 판단은 언제나 사람이 두 번째 출처와 맞춰 보고 내립니다. 🕸️ 분산 · 동시성 과 🏛️ 소프트웨어 공학 의 사후 검토 절로 이어집니다.
정리 — 로그는 적힌 것이지 일어난 것이 아닙니다. 없는 줄은 없는 일이 아니고(침묵), 있는 줄이 사실이 아닐 수 있으며(거짓 신호), 순서는 주지만 인과는 주지 않습니다. 부재를 신호로 바꾸고(하트비트), 결과가 확정된 뒤에 적고, 독립적인 두 번째 출처와 맞춰 볼 때만 로그는 이해가 됩니다.
응집 · 결합 · 의존 방향 · 계층형 vs 기능별 · 헥사고날 · MVVM · 모듈러 모놀리스 · 이벤트 · ADR · 열 개 언어 가이드로 가는 지도

구조는
무엇이 함께
바뀌는가로 나눈다

언어마다 폴더 이름은 다르지만 왜 그렇게 나누는지는 같습니다. 바꿀 때 덜 건드리고, 처음 보는 사람이 덜 헤매고, 여러 사람이 서로를 덜 기다리게 하려는 것입니다. 이 탭은 특정 언어가 아니라 구조의 원리를 봅니다 — 응집과 결합, 의존 방향의 원칙, 계층형과 기능별, 헥사고날 · 클린, UI 패턴, 모듈러 모놀리스와 마이크로서비스, 이벤트 · 파이프라인 · 플러그인. 그리고 앱 유형마다 무엇이 권장되는지 한 장으로 정리한 뒤 자바 · 코틀린 · 파이썬 · TS · C# · C++ · Rust · C · DB · 서버 가이드의 구조 탭으로 연결합니다. 마지막은 규칙을 기계로 지키는 법과, 결정을 기록하며 구조를 조금씩 옮기는 법입니다.

10SECTIONS
11시각화
8앱 유형
10언어 가이드 연결
STR 01

구조는 왜 필요한가 — "무엇이 함께 바뀌나"로 나누기

구조가 없어도 프로그램은 돕니다. 구조가 줄여 주는 것은 실행 비용이 아니라 사람의 비용 세 가지입니다 — 변경 비용(하나 고칠 때 몇 군데를 건드리나), 이해 비용(새 사람이 몇 파일을 읽어야 하나), 조율 비용(여러 사람이 서로를 얼마나 기다리나). 응집도와 결합도의 정의는 SE 01 에 있으니, 여기서는 그 자를 폴더와 모듈을 자르는 칼로 씁니다.

같은 앱 — 함께 바뀌는 것끼리 모은 모양

shop/
├─ orders/          # 주문이 바뀌면 여기만 — 화면·규칙·저장이 한 폴더
│  ├─ api            #   요청을 받는 입구
│  ├─ rules          #   할인 · 취소 가능 여부 같은 업무 규칙
│  └─ store          #   저장 방식
├─ members/         # 회원 기능 — orders 와는 공개 입구로만 대화
├─ billing/         # 결제 — 외부 PG 가 바뀌면 여기만
├─ shared/          # 진짜 공통만: 금액 · 시간 · 에러 타입
└─ main             # 조립 — 무엇을 무엇에 꽂을지 정하는 한 곳
공통 폐쇄 원칙(CCP) — "같은 이유로, 같은 때에 바뀌는 것을 한 묶음에". 클래스 수준의 단일 책임 원칙(SRP)을 패키지 크기로 키운 것입니다.

자를 때 쓰는 질문 다섯

질문답이 "예"면
이 둘은 항상 같이 커밋되나?같은 폴더로 — 떨어져 있으면 변경이 흩어집니다
이 둘은 바뀌는 이유가 다른가?나눕니다 — 규칙(업무)과 기술(DB · HTTP)이 대표적
이걸 쓰는 쪽이 전부 다 필요한가?아니면 쪼갭니다 — 공통 재사용 원칙(CRP)
다른 팀이 이걸 자주 고치나?경계를 그어 소유자를 정합니다
이름이 utils · common 인가?"갈 곳 없는 것들의 창고" — 원래 주인에게 돌려보냅니다
코드가 자라는 길 — 위는 나누는 단위, 아래는 다음 칸으로 넘어갈 신호 파일 몇 개 1명 · 수백~수천 줄 main 하나 폴더 · 모듈 2~6명 · 기능별 폴더 orders/ · members/ 패키지 · 빌드 모듈 빌드 단위가 여럿 core · api · worker 서비스 분리 팀 · 배포가 갈라짐 저장소 · DB 따로 1단계 2단계 3단계 4단계 파일 하나 500줄+ 스크롤로 찾기 시작 한 폴더 파일 30개+ 남의 기능이 자꾸 깨짐 빌드 수 분 · 앱 2개+ 경계를 컴파일러로 배포를 서로 기다림 따로 늘려야 함 대부분의 사내 서비스는 여기까지로 충분합니다 넘어가는 이유는 줄 수가 아니라 "함께 일하기 어려워졌다"는 신호입니다
규모별 진화 — 신호가 왔을 때만 한 칸씩. 숫자는 경험칙이지 규격이 아닙니다.

경계가 맞는지 재 보는 법 🟢 기초

# 지난 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단계의 이득
커밋 이력으로 "늘 같이 바뀌는 파일 쌍"을 찾는 방법은 SE 01 의 변경 결합도 예제를 참고하세요.
❌ 첫날부터 4단계
order-service/    # 저장소 1
member-service/   # 저장소 2
common-lib/       # 저장소 3 — 버전 맞추기가 일이 됨
# 화면 열 개짜리 앱에 배포 파이프라인 세 개
✅ 지금 크기에 맞게
shop/             # 저장소 1 · 배포 1
├─ orders/        # 기능별 폴더 (2단계)
└─ members/
# 경계가 검증되면 그때 모듈 · 서비스로
정리 — 구조의 목적은 변경 · 이해 · 조율 비용을 줄이는 것이고, 자르는 기준은 "무엇이 함께 바뀌나"입니다. 파일 → 폴더 → 모듈 → 서비스는 신호가 올 때만 한 칸씩.
STR 02

의존 방향의 원칙 — 안정된 쪽으로 기대고, 고리는 만들지 않는다

나누기만 하면 끝이 아닙니다. 나눈 덩어리 사이의 화살표 방향이 구조의 품질을 정합니다. 로버트 C. 마틴이 정리한 패키지 원칙 셋 — ADP(순환 금지) · SDP(안정된 쪽으로 의존) · SAP(안정할수록 추상적으로) — 과 의존성 역전(DIP) 하나면 대부분의 구조 논쟁을 화살표로 정리할 수 있습니다.

화살표는 I 가 작아지는 쪽으로 — 붉은 선 하나가 고리를 만듭니다 web I = 1.0 batch I = 1.0 주문 I = 0.67 결제 I = 0.25 공용 I = 0 공용 → web : 고리(ADP) + 안정 → 불안정(SDP) 위반 I = Ce ÷ (Ca + Ce) Ce 나가는 의존 · Ca 들어오는 의존 I = 0 인 곳은 인터페이스 위주로 (SAP) — 굳었으니 추상적이어야 들어오는 화살표가 많을수록 바꾸기 어렵습니다 — 그래서 거기엔 추상만 둡니다
의존 그래프 — 모든 화살표가 안정된 쪽을 향하면 고리는 생길 수 없습니다.

원칙 넷 — 실무 언어로

원칙실무 문장어기면
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 가 한 줄도 없어야 합니다
인터페이스를 구현 옆(infra)이 아니라 쓰는 쪽(domain)에 두는 것이 역전의 핵심입니다. 위치를 잘못 두면 화살표는 그대로입니다.
❌ 공용이 위를 안다
common/format.ts
  import { OrderDto } from "../web/dto"   # 공용 → web
# 가장 많이 쓰이는 파일이 가장 자주 바뀌는 파일에 묶임
✅ 공용은 아무도 모른다
common/money.ts   # 값 타입 · 순수 함수만
web/dto.ts
  import { Money } from "../common/money" # web → 공용
# 화살표가 전부 아래(안정)로
정리 — 화살표는 안정된 쪽으로만(SDP), 고리 없이(ADP), 많이 기대지는 곳은 추상적으로(SAP). 방향이 틀리면 인터페이스를 쓰는 쪽으로 옮겨 뒤집습니다(DIP).
STR 03

계층형 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/        # 기능 이름이 안 들어간 것만
# 장점: 변경이 한 폴더 · 지우기도 폴더째
# 단점: 공통 코드 위치를 정하는 규칙이 필요
같은 앱, 두 가지 자르기 — 붉은 점이 "주문 할인 규칙 변경"이 닿는 폴더 계층형 (가로) 기능별 (세로) controllers/ — 주문 · 회원 · 결제 orders/ — 입구 · 규칙 · 저장 services/ — 주문 · 회원 · 결제 members/ — 입구 · 규칙 · 저장 repositories/ — 주문 · 회원 · 결제 billing/ — 입구 · 규칙 · 저장 models/ — 모든 엔티티 · DTO shared/ — 금액 · 시간 · 에러 변경 하나 = 폴더 4개 · 리뷰어도 4곳 변경 하나 = 폴더 1개 · 지우기도 폴더째 기능이 다섯 개를 넘기 시작하면 오른쪽이 유리해집니다
좌우 대조 — 변경이 닿는 폴더 수가 두 방식의 차이 전부입니다.

실무 기본값 — 기능이 1차, 계층이 2차 🟡 중급

상황권장
CRUD 위주 · 기능 몇 개계층형도 충분 — 단순함이 이김
기능 5개 이상 · 사람 2명 이상기능별, 기능 폴더 안에서 필요하면 계층
기능 안의 규칙이 두꺼움기능 폴더 안을 domain · application · infra 로 (STR 04)
프런트엔드기능별(features/) + 공용 UI(components/) 조합이 흔함
기능별의 함정은 기능끼리 내부를 직접 import 하는 것입니다. 기능마다 공개 입구 하나(index · 공개 서비스 · 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)
언어별 "입구" 도구: 자바 package-private · 코틀린/C# internal · Rust pub(crate) · TS index.ts + 린트 · 파이썬 __all__ 과 밑줄 관례.
정리 — 작은 앱은 계층형도 괜찮지만, 기능이 늘면 기능별로 1차 분할하고 기능 안에서 필요할 때만 계층을 둡니다. 기능 사이는 공개 입구로만.
STR 04

헥사고날 · 클린 · 어니언 — 도메인을 가운데, 의존은 안쪽으로

세 이름의 공통 핵심 — 업무 규칙이 프레임워크 · DB 를 모르게 하고 의존은 안쪽으로만 흐르게 한다 — 과 "흔한 구조 vs 뒤집은 구조" 그림은 SE 06 에 있습니다. 여기서는 셋이 어디가 다른지, 폴더로 옮기면 어떤 모양인지, 그리고 언제 과설계가 되는지만 봅니다.

원 네 겹 — 의존(import)은 바깥에서 안쪽으로만 도메인 유스케이스 어댑터 프레임워크 · 드라이버 웹 프레임워크 · ORM · 드라이버 — 버전업 · 교체가 잦음 컨트롤러 · 저장소 구현 · 메시지 — 포트를 구현하는 번역기 주문 취소 · 결제 승인 흐름 — 포트(인터페이스)를 여기서 정의 엔티티 · 값 객체 · 불변 규칙 — import 가 거의 없음 헥사고날 = 앱(안쪽) + 포트 + 어댑터 어니언 = 도메인 모델부터 겹겹이 클린 = 엔티티 · 유스케이스 · 어댑터 · 프레임워크 이름은 셋이지만 화살표 규칙은 하나입니다
동심원 — 반지름이 변경 빈도. 바깥은 갈아 끼우고 안쪽은 오래 삽니다.

셋의 차이 — 강조점이 다를 뿐

이름처음 낸 사람강조하는 것
헥사고날
(포트와 어댑터)
앨리스터 코번 · 2005안과 밖의 대칭 — 들어오는 쪽(HTTP · CLI · 테스트)도 나가는 쪽(DB · 메일)도 전부 어댑터
어니언제프리 팔레르모 · 2008겹 — 도메인 모델 · 도메인 서비스 · 앱 서비스 · 인프라
클린로버트 C. 마틴 · 2012의존성 규칙이라는 한 문장 + 유스케이스 단위의 경계
포트는 안쪽이 정의한 인터페이스, 어댑터는 그걸 바깥 기술로 구현하거나 호출하는 코드입니다. "들어오는(driving) 포트"는 유스케이스 자체, "나가는(driven) 포트"는 저장 · 알림 같은 약속입니다.

폴더로 옮기면 — 기능 하나 안에서

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 로 구현 — 유스케이스 코드는 한 줄도 안 바뀜
업무 규칙 테스트가 DB 없이 밀리초 단위로 돕니다. 이게 이 구조가 주는 가장 확실한 이득입니다.
❌ 과설계 — 규칙이 없는데 겹만 있다
GetUserUseCase → UserPort → UserAdapter
  → UserMapper → UserEntity → UserDto
# 하는 일: SELECT * FROM users WHERE id = ?
# 파일 6개 · 매퍼 3개 · 업무 규칙 0줄
✅ 두꺼운 곳에만
users/    계층형 그대로 — 단순 조회 · 수정
billing/  domain · application · adapters
# 정산 규칙이 복잡하고 PG 사가 바뀔 수 있는
# 기능에만 포트를 둡니다
정리 — 헥사고날 · 어니언 · 클린은 "도메인을 가운데, 의존은 안쪽으로"라는 같은 규칙의 세 가지 그림입니다. 업무 규칙이 두껍고 바깥 기술이 바뀔 수 있는 기능에만 적용합니다.
STR 05

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 를 모름)
Android 공식 아키텍처 가이드가 정확히 이 모양입니다 — UI 계층 · (선택) 도메인 계층 · 데이터 계층, 그리고 단방향 데이터 흐름(UDF) 권장. 폴더로 옮긴 예는 코틀린 STR 04.
상태는 내려가고 이벤트는 올라갑니다 — 화면은 상태를 직접 고치지 않습니다 상태 State 한 곳 화면 render(state) 이벤트 클릭 · 입력 갱신 reducer · VM 한 방향 버그 추적 = 액션 기록 같은 상태를 넣으면 같은 화면 — 그래서 상태만 테스트하면 됩니다
순환 — 바뀌는 길이 하나라 "누가 이 값을 바꿨나"를 액션 기록으로 찾습니다.

같은 생각, 두 언어 — 리듀서와 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 · ComposeMVVM + UDF — ViewModel 이 StateFlow 로 화면 상태 노출코틀린 STR 04
iOS · SwiftUI상태 기반 뷰 + @Observable 모델 (MVVM 에 가까움)—
WPF · WinUI · MAUIMVVM — 데이터 바인딩이 프레임워크에 내장C# STR 05
Qt · JavaFX · PySide모델/뷰 + 뷰모델(QML 프로퍼티 · FXML 컨트롤러)C++ STR 07 · 자바 STR 08 · 파이썬 STR 06
정리 — UI 패턴은 전부 화면에서 상태와 로직을 빼내는 방법입니다. 지금의 기본값은 상태는 한곳, 흐름은 한 방향 — 웹은 스토어, 모바일은 ViewModel + 상태 스트림, 데스크톱은 MVVM 바인딩.
STR 06

모듈러 모놀리스 → 마이크로서비스 — 경계를 먼저, 분리는 나중에

모놀리스와 마이크로서비스의 비교표와 "분산 모놀리스가 최악"이라는 조언은 SE 06 에 있습니다. 여기서는 그 사이의 실무 경로 — 경계는 어떻게 찾나(바운디드 컨텍스트), 언제 쪼개나(조직 · 배포 · 확장 신호), 분산은 무엇을 비용으로 내나, 그리고 다시 합친 사례를 봅니다.

가로는 배포 단위 수, 세로는 모듈 경계 — 오른쪽보다 위가 먼저입니다 배포 단위 하나 ← → 여럿 경계 뚜렷 경계 흐림 모듈러 모놀리스 마이크로서비스 진흙 덩어리 분산 모놀리스 ① 경계부터 ② 신호가 오면 일부만 떼어 냄 경계 없이 쪼개면 — 가장 나쁜 칸 경계가 없으면 쪼개도 결합은 그대로이고, 거기에 네트워크 비용만 더해집니다
사분면 — 위로 먼저(경계), 오른쪽은 나중(배포). 오른쪽 아래가 분산 모놀리스입니다.

모듈러 모놀리스 — 배포 하나, 경계 여럿

shop/                       # 실행 파일 하나 · 저장소 하나
├─ modules/
│  ├─ catalog/              # 바운디드 컨텍스트 하나 = 모듈 하나
│  │  ├─ api/               #   다른 모듈에 공개하는 것 (인터페이스 · 이벤트)
│  │  ├─ internal/          #   나머지 전부 — 밖에서 import 금지
│  │  └─ db/migrations/     #   자기 스키마만 (catalog.*)
│  ├─ ordering/
│  └─ shipping/
├─ platform/                # 로깅 · 인증 · 트랜잭션 같은 기술 공통
└─ app/                     # 모듈 조립 · 진입점
# 규칙: 다른 모듈의 internal · 테이블에 직접 접근 금지

경계 찾기 — 같은 단어가 다른 뜻이 되는 곳

"상품"이뜻하는 것
카탈로그에서이름 · 설명 · 사진 · 카테고리
주문에서주문 시점의 가격 · 수량 (나중에 값이 바뀌어도 고정)
배송에서무게 · 부피 · 위험물 여부
DDD(에릭 에번스, 2003)의 바운디드 컨텍스트는 "한 단어가 한 뜻으로만 쓰이는 범위"입니다. 거대한 Product 클래스 하나 대신 컨텍스트마다 자기 모델을 두고, 경계에서는 ID 와 이벤트로만 주고받습니다. 남의 모델이 내 안으로 새지 않게 막는 번역층을 부패 방지 계층(ACL)이라고 부릅니다.

언제 쪼개나 · 무엇을 내나 · 다시 합친 사례 🔴 실무

신호쪼갤 이유가 되는 경우
조직팀이 나뉘었고 한 모듈을 한 팀이 소유 — 콘웨이의 법칙
배포한 모듈 때문에 전체 배포를 매번 기다림 · 배포 주기가 다름
확장한 부분만 부하가 10배 · 다른 자원(GPU · 메모리)이 필요
장애 격리이 부분이 죽어도 결제는 살아 있어야 함
기술다른 언어 · 런타임이 꼭 필요 (ML 추론 등)
"코드가 커서"는 신호가 아닙니다 — 그건 모듈 경계로 풉니다.
분산의 비용함수 호출일 땐 없던 것
부분 실패타임아웃 · 재시도 · 서킷 브레이커 · 멱등성
데이터 일관성트랜잭션 하나 → 사가 · 아웃박스 · 최종 일관성
관측스택 트레이스 하나 → 분산 추적 · 상관 ID
계약 관리API 버전 · 하위 호환 · 소비자 계약 테스트
운영서비스마다 파이프라인 · 대시보드 · 온콜
다시 합친 사례 — Segment 는 2018년 데이터 목적지(destination)마다 따로 두었던 수많은 마이크로서비스를 하나의 서비스로 합쳤다고 공개했고, Amazon Prime Video 는 2023년 영상 품질 모니터링 시스템을 분산 구성에서 단일 프로세스로 옮겨 비용을 크게(발표 기준 90%) 줄였다고 밝혔습니다. 분리는 되돌릴 수 있는 결정이어야 합니다.
정리 — 먼저 바운디드 컨텍스트로 모듈 경계를 긋고(모듈러 모놀리스), 조직 · 배포 · 확장 신호가 온 모듈만 떼어 냅니다. 분산은 공짜가 아니며 다시 합칠 수도 있습니다. 저장소 전략은 서버 STR 07.
STR 07

이벤트 기반 · 파이프라인 · 플러그인 — 호출 대신 다른 연결 방식

계층과 동심원은 "누가 누구를 부르나"의 구조입니다. 어떤 문제는 아예 부르지 않는 구조가 더 맞습니다 — 일이 일어났다고 알리기만 하거나(이벤트), 앞 단계의 결과를 흘려보내기만 하거나(파이프라인), 본체가 모르는 기능을 꽂아 넣거나(플러그인). 로그를 시스템의 중심축으로 두는 이벤트 소싱 · CQRS · CDC 는 LOG 06 에서 다룹니다.

이벤트 기반 — 발행은 한 번, 구독자는 몇이든 브로커 토픽 · 구독 주문 재고 알림 정산 검색 분석 주문됨 발행자는 누가 듣는지 모릅니다 — 구독자를 늘려도 주문 코드는 그대로
허브 — 발행자와 구독자가 서로를 모르는 것이 이 구조의 이득이자 디버깅의 어려움입니다.

이벤트 기반 — 폴더와 규칙

ordering/
├─ events/              # 이 모듈이 내보내는 이벤트 = 공개 계약
│  └─ OrderPlaced       #   과거형 이름 · 필요한 사실만 · 버전 필드
├─ handlers/            # 남의 이벤트를 받는 쪽
│  └─ on_payment_completed
└─ outbox/              # DB 저장과 발행을 한 트랜잭션으로
# 규칙
#  · 이벤트 이름은 과거형 (명령 "PlaceOrder" 와 구분)
#  · 소비자는 멱등하게 — 같은 이벤트가 두 번 올 수 있음
#  · 이벤트 스키마는 하위 호환으로만 바꿈

언제 맞고 언제 아닌가

구조맞는 곳주의
이벤트한 사실에 반응할 곳이 여럿 · 늘어남 (알림 · 정산 · 검색 색인)흐름이 코드에 안 보임 → 추적 ID 필수
파이프라인ETL · 스트림 처리 · 컴파일러 · 미디어 변환단계 사이 형식(스키마)이 계약
플러그인IDE · 브라우저 · 빌드 도구 · 에디터 — 본체가 모르는 기능을 제3자가 추가공개 API 가 곧 족쇄 · 버전 관리
"응답이 바로 필요하면 이벤트가 아닙니다" — 결제 승인처럼 결과를 기다려야 하는 일을 이벤트로 바꾸면 요청-응답을 직접 다시 만들게 됩니다.
파이프라인 — 단계는 앞 단계의 출력 형식만 압니다 수집 원본 그대로 검증 스키마 확인 변환 정제 · 조인 적재 DW · 레이크 제공 대시보드 · API 단계 사이 = 형식 계약 — 이것만 지키면 단계를 따로 바꾸고 따로 다시 돌립니다 폴더도 단계 이름 그대로 — stages/extract · transform · load
파이프라인 — 단계 사이의 형식이 계층 구조의 인터페이스 역할을 합니다.

플러그인 — 본체는 약속만, 기능은 밖에서 🟡 중급

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)
같은 모양: VS Code 확장(package.json 의 contributes) · 브라우저 확장(manifest) · Gradle · ESLint · pytest 플러그인. 호스트는 약속만 공개하고, 플러그인은 약속에만 의존합니다 — DIP 를 배포 단위로 키운 것입니다.
정리 — 반응할 곳이 늘어나면 이벤트, 되돌아오지 않는 가공은 파이프라인, 제3자가 기능을 더하면 플러그인. 셋 다 연결 지점(이벤트 스키마 · 단계 형식 · 플러그인 API)이 공개 계약입니다.
STR 08

앱 유형별 권장 구조 한 장 — 그리고 언어별 구조 탭으로

원리는 같아도 앱 유형마다 무엇이 가장 자주 바뀌는지가 다르고, 그래서 기본값이 다릅니다. 웹 백엔드는 업무 기능이, 화면 앱은 상태가, 펌웨어는 하드웨어가, 라이브러리는 공개 API 가 구조를 정합니다. 아래 표는 출발점이고, 실제 폴더와 빌드 파일은 각 언어 가이드의 구조 탭에 있습니다.

앱 유형 × 구조 패턴 — 진할수록 그 유형의 기본값 (경향이지 규칙은 아님) 계층형 기능별 헥사 · 클린 MV* · 단방향 이벤트 · 파이프 웹 백엔드 흔함 권장 가끔 — 가끔 웹 프런트 가끔 권장 드묾 권장 — 데스크톱 흔함 흔함 가끔 권장 가끔 모바일 권장 권장 드묾 권장 — 임베디드 펌웨어 권장 가끔 드묾 — 흔함 라이브러리 가끔 흔함 드묾 — — 데이터 파이프라인 드묾 가끔 드묾 — 권장 게임 가끔 흔함 드묾 가끔 흔함 권장 > 흔함 > 가끔 > 드묾 — 헥사 · 클린 열이 흐린 것은 "두꺼운 기능에만"이라서입니다 한 유형에 진한 칸이 여럿이면 함께 씁니다 (모바일 = 계층 + 기능 모듈 + UDF)
히트맵 — 유형마다 진한 칸이 다릅니다. 색은 훑어보기용, 글자가 값입니다.
유형권장 패턴디렉터리 요지주의점
웹 백엔드기능별 패키지 · 두꺼운 기능만 헥사고날 · 모듈러 모놀리스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/게임 규칙이 엔진 안으로 · 싱글턴 남용

언어별 구조 탭 — 실제 폴더 · 빌드 파일은 여기서 🟢 기초

정리 — 유형마다 가장 자주 바뀌는 것(업무 기능 · 화면 상태 · 하드웨어 · 공개 API · 데이터 단계)이 구조를 정합니다. 표로 출발점을 잡고, 실제 폴더는 언어별 구조 탭에서 복사해 시작합니다.
STR 09

구조 규칙을 기계로 지키기 — 리뷰가 아니라 빌드가 막게

"domain 은 infra 를 import 하지 않는다"는 규칙은 문서에 적어 두면 석 달 안에 깨집니다. 급한 날 한 줄이면 되니까요. 규칙은 어기면 빌드가 실패하게 만들어야 지켜집니다. 파이썬 import-linter 예는 SE 06 에 있으니, 여기서는 강제 수단의 사다리와 언어별 도구 이름을 정리합니다.

강제력의 사다리 — 왼쪽일수록 어기기 어렵습니다 빌드 경계 모듈 · 가시성 아키텍처 테스트 ArchUnit 류 린트 · import 규칙 에디터 · CI 리뷰 · 문서 사람의 기억 컴파일이 안 됨 테스트 실패 경고 · CI 실패 급한 날 깨짐 강함 — 비용도 큼 (모듈 분리) 약함 — 만들기는 쉬움 중요한 규칙일수록 왼쪽으로 옮깁니다
사다리 — 싼 것부터 기계에 맡기고, 정말 중요한 경계는 빌드 모듈로 굳힙니다.

자바 — 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
세 번째 규칙은 "다른 기능의 internal 폴더 import 금지" — $1 은 from 쪽에서 잡은 기능 이름입니다. CI 에서 돌리면 PR 단계에서 막힙니다.

언어별 도구 — 경계 · 검사 · 그래프 🟡 중급

언어빌드 경계 (컴파일러가 막음)아키텍처 테스트 · 린트의존 그래프 보기
자바Gradle · Maven 멀티 모듈 · JPMS module-info · package-privateArchUnit · Spring Modulith (ApplicationModules.of(..).verify())jdeps · gradle dependencies
코틀린Gradle 모듈 · internalKonsist · ArchUnitgradle dependencies
C#프로젝트 참조 · internalNetArchTest · ArchUnitNETdotnet list reference
파이썬패키지 분리 (워크스페이스) — 언어 차원의 강제는 약함import-linterpydeps
JS · TS워크스페이스 패키지 + package.json 의 exportsdependency-cruiser · eslint-plugin-boundaries · Nx 모듈 경계 규칙madge · dependency-cruiser 출력
Rust크레이트 · 워크스페이스 · pub(crate)크레이트 경계가 곧 규칙 · clippycargo tree · cargo-modules
Gointernal/ 디렉터리 (밖에서 import 하면 컴파일 오류)go vet · 린터 묶음go mod graph
C · C++CMake 타깃 + PRIVATE · PUBLIC 링크 · 공개 헤더 폴더 분리include-what-you-use · clang-tidycmake --graphviz=deps.dot
❌ 규칙은 위키에
docs/architecture.md
  "domain 은 infra 를 참조하지 말 것"
# 6개월 뒤: domain → infra import 23곳
# 아무도 위키를 열지 않았습니다
✅ 규칙은 테스트에
ArchitectureTest  → CI 필수 체크
depcruise         → PR 마다
# 이미 깨진 곳은 예외 목록(기준선)으로 얼려 두고
# 새 위반만 막으며 하나씩 줄입니다
정리 — 구조 규칙은 빌드 경계 → 아키텍처 테스트 → 린트 → 리뷰 순으로 강합니다. 중요한 경계는 왼쪽으로, 기존 위반은 기준선으로 얼리고 새 위반만 막습니다.
STR 10

결정 기록과 진화 — 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 로 — 리뷰에서 "왜"까지 같이 본다
형식은 마이클 나이가드(2011)의 제목 · 상태 · 맥락 · 결정 · 결과 다섯 칸이 출발점입니다. 선택지 비교를 더 자세히 쓰는 MADR 같은 변형도 있습니다.

template.md — 한 장이면 충분

# NNNN. 결정 제목 (동사로: "주문 모듈에 포트 도입")

## 상태
제안 | 채택 (YYYY-MM-DD) | 대체됨 → NNNN | 폐기

## 맥락
지금 무엇이 문제인가. 숫자가 있으면 숫자로.
어떤 제약이 있나 (팀 · 일정 · 기존 시스템).

## 결정
우리는 ~ 하기로 한다. (한두 문장)

## 결과
좋아지는 것 / 나빠지는 것 / 새로 생기는 일.
언제 다시 볼지 — "주문 팀이 둘로 나뉘면 재검토"

스트랭글러 — 새 구조로 조금씩 옮기기 🟡 중급

마틴 파울러가 이름 붙인 "교살자 무화과" 패턴
(숙주 나무를 감싸며 자라 결국 대신 서는 덩굴)

① 앞에 입구(프록시 · 라우터 · 파사드)를 세운다
② 기능 하나를 새 구조로 다시 만든다
③ 입구에서 그 기능의 요청만 새 쪽으로 보낸다
④ 문제없으면 옛 코드를 지운다 — 다음 기능으로
⑤ 옛 시스템이 빈 껍데기가 되면 입구째 정리

★ 코드 안에서도 같습니다
   옛 OrderService 앞에 인터페이스를 세우고
   새 구현으로 하나씩 옮긴 뒤 옛 클래스를 지운다
# nginx — 입구에서 경로별로 나눈다
location /api/orders/ {
    proxy_pass http://orders_new;    # 옮긴 기능
}
location / {
    proxy_pass http://legacy_app;    # 나머지는 그대로
}
빅뱅 재작성은 거의 항상 늦고 기능이 빠집니다 — 옛 시스템의 숨은 규칙(버그처럼 보이는 정상 동작)까지 다시 찾아야 하기 때문입니다. 스트랭글러는 매 단계가 배포 가능하고 되돌릴 수 있다는 것이 핵심입니다.
지금 구조를 바꿔야 하나 — 마름모가 질문, 초록 상자가 할 일 함께 바뀌는 코드가 여러 폴더에 흩어졌나? 예 기능별로 다시 묶기 폴더 이동 리팩터링 아니오 경계를 넘는 import 가 자꾸 생기나? 예 경계를 기계로 굳히기 빌드 모듈 · 아키텍처 테스트 아니오 팀 · 배포 · 확장이 따로 움직여야 하나? 예 그 모듈만 떼어 내기 ADR 먼저 · 스트랭글러 아니오 지금 구조 유지 단순함도 결정입니다 기본값은 "그대로 둔다" — 바꾸는 데도 비용이 듭니다 어느 갈래든 바꾸기 전에 ADR 한 장을 먼저 씁니다
결정 흐름도 — 질문은 셋, 기본 답은 "유지". 바꿀 이유가 신호로 확인될 때만 움직입니다.

"처음엔 단순하게" — 한 장 요약 🟢 기초

원칙실무 문장
필요할 때 만든다 (YAGNI)"나중에 쪼갤 수도 있으니" 지금 서비스 · 모듈 · 포트를 미리 만들지 않습니다
경계가 먼저, 분리는 나중폴더 · 공개 입구로 경계를 긋고 → 검증되면 빌드 모듈 → 신호가 오면 서비스
방향은 처음부터 지킨다폴더 수는 적어도 의존 방향(업무 규칙 → 기술 금지)은 첫날부터 — 나중에 뒤집기가 가장 비쌉니다
되돌릴 수 있게쪼개는 결정은 합칠 수 있게, 기술 선택은 어댑터 뒤에
규칙은 기계에, 이유는 ADR 에무엇을 지킬지는 테스트가, 왜 그런지는 기록이 기억합니다
정리 — 결정은 ADR 로 저장소에, 큰 이동은 스트랭글러로 조금씩, 기본값은 단순하게 시작해 신호가 올 때만 한 칸씩. 의존 방향만은 첫날부터 지킵니다.
CS 기술 Visual Guide · 언어에 종속되지 않는 컴퓨터 과학 기본기 · 단일 HTML 학습 자료 · 블루 다크 테마
여기 있는 내용은 특정 언어·프레임워크가 바뀌어도 그대로 남습니다. 예제 코드는 개념을 보여 주기 위한 것이라 파이썬 · C · 의사코드를 섞어 씁니다.