"There are only 2 hard things in CS" — 캐싱, 네이밍, 그리고 Off-by-one
"There are only 2 hard things in CS" — 캐싱, 네이밍, 그리고 Off-by-one
컴퓨터 과학에서 어려운 건 딱 2가지임: 캐시 무효화, 이름 짓기, 그리고 Off-by-one 에러.
밈의 기원
Phil Karlton이라는 Netscape 엔지니어가 한 말임:
"There are only two hard things in Computer Science: cache invalidation and naming things."
이 말이 퍼지면서 수많은 변형이 생겼는데, 가장 유명한 건:
"There are only two hard things in Computer Science: cache invalidation, naming things, and off-by-one errors."
2가지라면서 3가지를 말하는 것 자체가 off-by-one 에러라는 메타 유머임. 이 밈을 이해하는 순간 웃으면서 울게 됨.
변형 모음
- 원조: "Cache invalidation and naming things"
- Off-by-one 추가 버전: 2가지라면서 3가지 (메타 유머)
- 실무 버전: "Cache invalidation, naming things, and estimating time"
- 시니어 버전: "Cache invalidation, naming things, and explaining what I do to my parents"
- 한국 버전: "캐싱, 네이밍, 그리고 기획서 해석"
Part 1: 캐싱이 어려운 이유
캐시의 기본 개념
// 캐시의 핵심: 비싼 연산의 결과를 저장해서 재사용
// 캐시 없이
async function getUserProfile(userId: number): Promise<UserProfile> {
// 매번 DB 조회: ~50ms
return await db.query('SELECT * FROM users WHERE id = $1', [userId]);
}
// 1초에 1000명이 접속 → 1초에 1000번 DB 조회 → DB 죽음
// 캐시 사용
const cache = new Map<number, { data: UserProfile; expiry: number }>();
async function getUserProfile(userId: number): Promise<UserProfile> {
const cached = cache.get(userId);
if (cached && cached.expiry > Date.now()) {
return cached.data; // 캐시 히트: ~0.1ms
}
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
cache.set(userId, {
data: user,
expiry: Date.now() + 60000, // 60초 캐시
});
return user;
}
// 첫 번째 요청만 DB 조회, 나머지 60초간은 캐시에서 반환
// DB 부하 1000배 감소!
여기까지는 쉬움. 근데 문제는 캐시 무효화(Cache Invalidation)임.
캐시 무효화가 어려운 이유
// 시나리오: 유저가 프로필을 수정함
// 1. 유저가 이름을 "김철수" → "김영희"로 변경
await db.query('UPDATE users SET name = $1 WHERE id = $2', ['김영희', userId]);
// 2. 캐시에는 아직 "김철수"가 남아있음
// 3. 다른 사람이 이 유저의 프로필을 보면? → "김철수"로 보임
// 4. 60초 후에야 캐시가 만료되고 "김영희"로 업데이트됨
// "이름 바꿨는데 왜 안 바뀌었어요?" — 1분 후에 바뀝니다 ^^
// 이건 아직 양반임. 진짜 문제는 더 복잡한 케이스에서 터짐.
// 캐시 무효화 전략들
// 전략 1: Write-through — 쓸 때 캐시도 같이 업데이트
async function updateUser(userId: number, data: Partial<User>): Promise<void> {
await db.query('UPDATE users SET ...', [data, userId]);
const updated = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
cache.set(userId, { data: updated, expiry: Date.now() + 60000 });
}
// 문제: 서버가 여러 대면? 서버 A에서 업데이트해도 서버 B의 캐시는 옛날 데이터
// 전략 2: Cache-aside (Lazy Loading) — 캐시 삭제 후 다음 조회에서 갱신
async function updateUser(userId: number, data: Partial<User>): Promise<void> {
await db.query('UPDATE users SET ...', [data, userId]);
cache.delete(userId); // 캐시 삭제
// 다음 조회 시 DB에서 새로 읽어옴
}
// 문제: 삭제와 다음 조회 사이에 다른 요청이 들어오면 stale 데이터
// 전략 3: TTL (Time To Live) — 시간이 지나면 자동 만료
// 가장 단순하지만 "얼마로 설정할 거임?"이 문제
// 너무 짧으면: 캐시 효과 없음
// 너무 길면: 오래된 데이터가 서빙됨
// "적절한" 값은? → 그건 비즈니스마다 다름 (이래서 어려운 거임)
분산 환경에서의 캐시 — 진짜 지옥
// 서버가 3대이고 각각 로컬 캐시를 가지고 있을 때
// 상황:
// 서버 A: cache = { user:1 → "김철수" }
// 서버 B: cache = { user:1 → "김철수" }
// 서버 C: cache = { user:1 → "김철수" }
// 서버 A에서 유저 이름 변경
// 서버 A: cache = { user:1 → "김영희" } ← 업데이트됨
// 서버 B: cache = { user:1 → "김철수" } ← 옛날 데이터
// 서버 C: cache = { user:1 → "김철수" } ← 옛날 데이터
// 로드밸런서에 따라 같은 유저가 다른 이름을 보게 됨
// F5 새로고침할 때마다 이름이 바뀌는 현상 발생
// 해결: Redis 같은 중앙 캐시
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getUserProfile(userId: number): Promise<UserProfile> {
// 모든 서버가 같은 Redis를 바라봄
const cached = await redis.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
await redis.setex(`user:${userId}`, 60, JSON.stringify(user));
return user;
}
async function updateUser(userId: number, data: Partial<User>): Promise<void> {
await db.query('UPDATE users SET ...', [data, userId]);
await redis.del(`user:${userId}`); // 중앙 캐시에서 삭제
// 어떤 서버에서 업데이트하든 모든 서버가 동일한 결과
}
캐시 스탬피드 (Cache Stampede)
인기 있는 데이터의 캐시가 만료되는 순간, 수천 개의 요청이 동시에 DB로 몰리는 현상.
예: 인기 상품 페이지의 캐시가 만료 → 1000명이 동시에 같은 DB 쿼리 실행 → DB 과부하
해결: Mutex/Lock으로 한 요청만 DB 조회하고 나머지는 대기
async function getUserWithLock(userId: number): Promise<UserProfile> {
const cached = await redis.get(`user:${userId}`);
if (cached) return JSON.parse(cached);
// 락 획득 시도
const lockKey = `lock:user:${userId}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);
if (acquired) {
// 락 획득 성공 → DB 조회 후 캐시 갱신
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
await redis.setex(`user:${userId}`, 60, JSON.stringify(user));
await redis.del(lockKey);
return user;
}
// 락 획득 실패 → 잠시 대기 후 캐시 재확인
await sleep(100);
return getUserWithLock(userId); // 재시도
}
캐시 일관성의 근본적 한계
// CAP 정리를 캐시에 적용하면:
// Consistency(일관성), Availability(가용성), Partition tolerance(네트워크 장애 허용)
// 세 가지 중 두 가지만 동시에 만족할 수 있음
// 선택 1: 강한 일관성 (CP)
// 캐시와 DB가 항상 같은 데이터 → 캐시 업데이트 중 서비스 지연 가능
// 선택 2: 높은 가용성 (AP)
// 항상 빠르게 응답 → 잠시 옛날 데이터가 보일 수 있음 (eventual consistency)
// 대부분의 서비스는 AP를 선택함
// "잠깐 옛날 이름이 보이는 것"은 괜찮지만
// "프로필이 안 열리는 것"은 안 괜찮으니까
Part 2: 네이밍이 어려운 이유
실제 끔찍한 변수명 모음
// 실제 코드베이스에서 발견된 변수명들 (약간 변형)
// 1. 의미 없는 이름
let data: any; // 뭔 data?
let temp: string; // 임시? 뭘?
let flag: boolean; // 무슨 플래그?
let result: any; // 뭐의 결과?
// 2. 약어 남용
let usrMgr: UserManager; // user manager라고 쓰면 죽음?
let btnClk: Function; // button click이라고 쓰면 안 됨?
let crntPg: number; // current page... 모음 제거하면 읽기 좋아지는 건 아님
// 3. 한국어 변수명 (실화)
let 사용자목록: User[]; // 솔직히 읽기는 편함
let isTrue: boolean; // true인지 확인하는 boolean... 그래서 뭐가 true인데?
let myFunction: Function; // ...
// 4. 너무 긴 이름
let userThatHasNotVerifiedTheirEmailAddressYet: User[];
// IntelliSense가 도와주긴 하는데 코드 리뷰할 때 읽기 힘듦
// 5. 기만적인 이름
let userList: Set<User>; // Set인데 List라고?
let isEmpty: number; // boolean이 아니라 number?
let getUser: User; // 함수가 아니라 변수?
좋은 네이밍의 원칙
// 원칙 1: 의도를 드러내기
// 나쁜 예
const d = new Date();
const t = d.getTime();
const r = items.filter(i => i.t > t);
// 좋은 예
const now = new Date();
const currentTimestamp = now.getTime();
const recentItems = items.filter(item => item.createdAt > currentTimestamp);
// 코드를 읽는 사람은 당신이 아님. 6개월 후의 당신도 "당신"이 아님.
// 원칙 2: 범위에 맞는 길이
// 루프 변수: 짧아도 됨
for (let i = 0; i < 10; i++) { } // OK
users.map(u => u.name); // 범위가 좁으니 OK
// 클래스 속성: 명확해야 함
class OrderService {
private maxRetryCount = 3; // OK
private r = 3; // 뭐임?
private paymentGatewayTimeoutMs = 5000; // 단위까지 포함하면 완벽
}
// 전역 변수/상수: 매우 명확해야 함
const MAX_FILE_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
const API_REQUEST_TIMEOUT_MS = 30000; // 30초
// 원칙 3: boolean은 is/has/can/should 접두사
// 나쁜 예
const login: boolean; // 로그인 했음? 해야 함? 할 수 있음?
const admin: boolean; // 관리자임? 관리자 페이지?
// 좋은 예
const isLoggedIn: boolean;
const isAdmin: boolean;
const hasPermission: boolean;
const canEdit: boolean;
const shouldRedirect: boolean;
// 원칙 4: 함수명은 동사로 시작
// 나쁜 예
function userData(id: number): User { } // 데이터를 뭐 하는 건데?
function validation(input: string): boolean { } // 검증을 한다고?
// 좋은 예
function getUser(id: number): User { }
function validateInput(input: string): boolean { }
function createOrder(cart: Cart): Order { }
function sendNotification(userId: number): void { }
function calculateTotal(items: CartItem[]): number { }
네이밍 컨벤션 전쟁
camelCasevssnake_casevsPascalCasevskebab-casegetvsfetchvsretrievevsfindvsloadcreatevsmakevsbuildvsgeneratevsnewremovevsdeletevsdestroyvsdropvspurge
이런 논쟁에 정답은 없음. 팀에서 하나로 통일하는 게 정답임.
개인적으로 가장 무의미한 논쟁: 탭 vs 스페이스. Prettier 쓰면 됨.
실무에서 네이밍이 어려운 진짜 이유
// 도메인 지식이 변수명에 반영되어야 하기 때문
// 쇼핑몰에서의 "주문"
// 주문을 영어로 뭐라고 해야 됨?
// order? purchase? transaction? booking? reservation?
// 정답: 도메인에 따라 다름
// 이커머스: order
// 호텔: booking / reservation
// 금융: transaction
// SaaS: subscription
// 같은 "할인"도
// discount: 정가 대비 할인
// coupon: 쿠폰 할인
// promotion: 프로모션
// credit: 적립금 사용
// 이걸 다 "discount"로 퉁치면 나중에 구분이 안 됨
// DDD(Domain-Driven Design)에서 말하는 "유비쿼터스 언어"가 이거임
// 비즈니스에서 쓰는 용어를 코드에서도 그대로 쓰는 것
// PM이 "적립금"이라고 부르면 코드에서도 "credit"이지 "point"가 아님
Part 3: Off-by-one 에러의 다양한 형태
Off-by-one이란?
경계값에서 1 차이로 발생하는 버그. 역사상 가장 흔한 버그 유형 중 하나임.
// 가장 기본적인 off-by-one
// 배열의 마지막 요소
const arr = [1, 2, 3, 4, 5];
const last = arr[arr.length]; // undefined! (인덱스는 0부터니까)
const last = arr[arr.length - 1]; // 5 (올바름)
// 반복문
for (let i = 0; i <= arr.length; i++) { // <= 이면 마지막에 undefined 접근
console.log(arr[i]);
}
for (let i = 0; i < arr.length; i++) { // < 이 맞음
console.log(arr[i]);
}
펜스포스트 문제 (Fence Post Problem)
// 100미터 울타리를 10미터 간격으로 세우면 기둥이 몇 개 필요한가?
// 직감: 100 / 10 = 10개
// 정답: 11개 (시작점에도 기둥이 필요함)
function countFencePosts(length: number, interval: number): number {
return length / interval; // 10 — 틀림!
return length / interval + 1; // 11 — 맞음!
}
// 이 문제가 코드에서 어떻게 나타나냐면:
// 페이지네이션
function totalPages(totalItems: number, itemsPerPage: number): number {
return totalItems / itemsPerPage; // 100/10 = 10 — 맞는 것 같지만
return Math.ceil(totalItems / itemsPerPage); // 101/10 = 11 — ceil이 필요함
}
totalPages(100, 10); // 10 — 둘 다 같음
totalPages(101, 10); // 10 vs 11 — ceil 없으면 마지막 1개가 안 보임!
실제 코드에서의 Off-by-one
// Case 1: 문자열 자르기
function truncate(str: string, maxLength: number): string {
if (str.length > maxLength) {
return str.substring(0, maxLength - 3) + '...';
// maxLength가 2 이하면? → 음수 인덱스 → 빈 문자열 + "..."
}
return str;
}
// Case 2: 날짜 범위
function getDaysInRange(start: Date, end: Date): number {
const diff = end.getTime() - start.getTime();
return diff / (1000 * 60 * 60 * 24);
// 3월 1일 ~ 3월 3일: 2일? 3일?
// "3월 1일부터 3월 3일까지" → 보통 3일을 기대함
// 하지만 이 코드는 2를 반환함 (end - start)
// +1을 해야 하는지는 "~까지"의 정의에 달림
}
// Case 3: 배열 슬라이싱
const items = ['a', 'b', 'c', 'd', 'e'];
// 2번째부터 4번째까지 (사람의 직감)
items.slice(2, 4); // ['c', 'd'] — 4번째(인덱스 4)인 'e'는 미포함!
// slice의 end는 "미포함" (exclusive)
// 근데 SQL은?
// SELECT * FROM items LIMIT 3 OFFSET 2
// 3번째(1-indexed)부터 3개 → 다른 규칙
시간 관련 Off-by-one
// 월(month)의 0-indexed 트랩
// "3월 17일" 생성하기
const march17 = new Date(2026, 3, 17);
// 실제: 4월 17일! (월은 0부터 시작: 0=1월, 1=2월, 2=3월, ...)
const march17correct = new Date(2026, 2, 17);
// 이게 맞음. 2 = 3월.
// getMonth()도 마찬가지
const now = new Date('2026-03-17');
now.getMonth(); // 2 (3월인데 2를 반환)
// 근데 getDate()는 1부터 시작함
now.getDate(); // 17 (정상)
// getMonth()는 0-indexed인데 getDate()는 1-indexed?
// 네. JavaScript입니다.
// 요일도 0-indexed
const now = new Date('2026-03-17'); // 화요일
now.getDay(); // 2 (0=일요일, 1=월요일, 2=화요일)
// 한국에서는 월요일이 한 주의 시작인 경우가 많음
// 미국에서는 일요일이 한 주의 시작
// 이걸 처리하려면:
function getKoreanDayOfWeek(date: Date): number {
const day = date.getDay();
return day === 0 ? 6 : day - 1;
// 0(일) → 6, 1(월) → 0, 2(화) → 1, ...
// 월요일 = 0, 화요일 = 1, ..., 일요일 = 6
}
경계값 테스트의 중요성
// Off-by-one을 잡는 가장 확실한 방법: 경계값 테스트
describe('pagination', () => {
// 정확히 나눠떨어지는 경우
it('100개 아이템, 10개씩 → 10페이지', () => {
expect(totalPages(100, 10)).toBe(10);
});
// 나눠떨어지지 않는 경우
it('101개 아이템, 10개씩 → 11페이지', () => {
expect(totalPages(101, 10)).toBe(11);
});
// 경계값: 아이템이 0개
it('0개 아이템 → 0페이지 (또는 1페이지?)', () => {
// 이것도 비즈니스 요구사항에 따라 다름
// "결과 없음" 페이지를 보여줄 건지, 빈 리스트를 보여줄 건지
expect(totalPages(0, 10)).toBe(0);
});
// 경계값: 아이템 1개
it('1개 아이템 → 1페이지', () => {
expect(totalPages(1, 10)).toBe(1);
});
// 경계값: 페이지 크기와 같은 수
it('10개 아이템, 10개씩 → 1페이지', () => {
expect(totalPages(10, 10)).toBe(1);
});
// 경계값: 페이지 크기보다 1 많은 수
it('11개 아이템, 10개씩 → 2페이지', () => {
expect(totalPages(11, 10)).toBe(2);
});
});
OBOE (Off-By-One Error) 방지 팁
- 경계값을 항상 테스트하기 — 0, 1, N-1, N, N+1
- exclusive vs inclusive 명확히 하기 —
range(1, 5)가 5를 포함하는지? - 반개방 구간 (half-open interval) 사용하기 —
[start, end)가 가장 오류가 적음 .length - 1이 보이면 한 번 더 생각하기- 배열 메서드 사용하기 — 직접 인덱스 조작보다
.map(),.filter()등이 안전
세 가지를 한 번에 만나는 실무 상황
// 캐시 + 네이밍 + Off-by-one이 동시에 터지는 코드
// 상황: 페이지네이션된 상품 목록을 캐시하기
class ProductListCache {
// 나쁜 네이밍: "p"가 page인지 product인지 price인지 모름
async getP(p: number, s: number) {
// p = page, s = size... 라고 추측해야 함
const key = `products:${p}:${s}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// Off-by-one: page가 0-indexed인지 1-indexed인지?
const offset = p * s; // 0-indexed 가정
// 프론트엔드에서 page=1로 보내면?
// 1 * 10 = 10 → 첫 페이지 10개가 스킵됨!
const products = await db.query(
'SELECT * FROM products ORDER BY id LIMIT $1 OFFSET $2',
[s, offset]
);
// 캐시 무효화: 상품이 추가/수정/삭제되면?
// 모든 페이지의 캐시를 지워야 하는데
// 페이지 수가 몇 개인지 모름
// key 패턴 삭제: products:*:* → Redis KEYS 명령은 프로덕션에서 쓰면 안 됨
await redis.setex(key, 300, JSON.stringify(products));
return products;
}
}
// 올바른 버전
class ProductListCache {
// 명확한 네이밍
async getProductPage(
pageNumber: number, // 1-indexed (사용자/프론트엔드 관점)
pageSize: number
): Promise<Product[]> {
const cacheKey = `products:page:${pageNumber}:size:${pageSize}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Off-by-one 해결: 명시적 변환
const offset = (pageNumber - 1) * pageSize; // 1-indexed → 0-indexed
const products = await db.query(
'SELECT * FROM products ORDER BY id LIMIT $1 OFFSET $2',
[pageSize, offset]
);
await redis.setex(cacheKey, 300, JSON.stringify(products));
return products;
}
// 캐시 무효화: 버전 기반 전략
private cacheVersion = 0;
async invalidateAll(): Promise<void> {
this.cacheVersion++;
await redis.set('products:cache:version', this.cacheVersion);
// 키를 지우지 않고 버전을 올림
// 다음 조회 시 버전 불일치 → 캐시 미스 → 새로 조회
}
}
부록: CS에서 진짜 어려운 것들 (확장판)
// Phil Karlton이 말한 2가지 외에 개발자들이 추가한 "어려운 것들"
const hardThingsInCS = [
'Cache invalidation', // 원조
'Naming things', // 원조
'Off-by-one errors', // 클래식 추가
// 실무에서 추가된 것들
'Estimating time', // "이거 얼마나 걸려요?" "2주요" (실제: 2달)
'Time zones', // 서머타임, UTC 오프셋, 날짜 변경선
'Character encoding', // UTF-8, UTF-16, EUC-KR, 한글 깨짐
'Floating point math', // 0.1 + 0.2 !== 0.3
'Distributed consensus', // 분산 시스템에서의 합의
'Null handling', // null vs undefined vs empty vs missing
'Regular expressions', // 작성: 5분, 이해: 50분
'Date/time formatting', // YYYY vs yyyy (Java vs JavaScript 다름)
'Concurrency', // 데드락, 레이스 컨디션
'Security', // "이걸 공격자가 어떻게 악용할 수 있을까?"
'Explaining your job to family', // "그래서 뭐 하는 거야?" "컴퓨터 고치는 거..."
];
결론
Phil Karlton의 명언이 30년 가까이 살아있는 이유는, 캐싱과 네이밍이 진짜 어렵기 때문임.
캐시 무효화: 데이터의 일관성과 성능 사이의 트레이드오프. 완벽한 답은 없고 상황에 맞는 전략을 선택해야 함.
네이밍: 코드를 읽는 사람(미래의 나 포함)에게 의도를 전달하는 유일한 방법. 시간을 투자할 가치가 있음.
Off-by-one: 경계값 테스트가 유일한 해결책. "될 것 같은데?"가 아니라 "테스트했는데 됨"이어야 함.
그리고 기억하셈: 이 세 가지가 동시에 터지면 진짜 멘탈 나감. 페이지네이션 캐시에서 변수명이 p이고 0-indexed와 1-indexed가 섞여있으면... 그냥 처음부터 다시 짜는 게 빠름.
"컴퓨터 과학에서 어려운 건 2가지뿐임. 근데 그 2가지가 모든 코드에 있음."