"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: 캐싱이 어려운 이유

캐시의 기본 개념

typescript
// 캐시의 핵심: 비싼 연산의 결과를 저장해서 재사용

// 캐시 없이
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)임.

캐시 무효화가 어려운 이유

typescript
// 시나리오: 유저가 프로필을 수정함

// 1. 유저가 이름을 "김철수" → "김영희"로 변경
await db.query('UPDATE users SET name = $1 WHERE id = $2', ['김영희', userId]);

// 2. 캐시에는 아직 "김철수"가 남아있음
// 3. 다른 사람이 이 유저의 프로필을 보면? → "김철수"로 보임
// 4. 60초 후에야 캐시가 만료되고 "김영희"로 업데이트됨

// "이름 바꿨는데 왜 안 바뀌었어요?" — 1분 후에 바뀝니다 ^^
// 이건 아직 양반임. 진짜 문제는 더 복잡한 케이스에서 터짐.
typescript
// 캐시 무효화 전략들

// 전략 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) — 시간이 지나면 자동 만료
// 가장 단순하지만 "얼마로 설정할 거임?"이 문제
// 너무 짧으면: 캐시 효과 없음
// 너무 길면: 오래된 데이터가 서빙됨
// "적절한" 값은? → 그건 비즈니스마다 다름 (이래서 어려운 거임)

분산 환경에서의 캐시 — 진짜 지옥

typescript
// 서버가 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 새로고침할 때마다 이름이 바뀌는 현상 발생
typescript
// 해결: 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 조회하고 나머지는 대기

typescript
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); // 재시도
}

캐시 일관성의 근본적 한계

typescript
// CAP 정리를 캐시에 적용하면:
// Consistency(일관성), Availability(가용성), Partition tolerance(네트워크 장애 허용)
// 세 가지 중 두 가지만 동시에 만족할 수 있음

// 선택 1: 강한 일관성 (CP)
// 캐시와 DB가 항상 같은 데이터 → 캐시 업데이트 중 서비스 지연 가능

// 선택 2: 높은 가용성 (AP)
// 항상 빠르게 응답 → 잠시 옛날 데이터가 보일 수 있음 (eventual consistency)

// 대부분의 서비스는 AP를 선택함
// "잠깐 옛날 이름이 보이는 것"은 괜찮지만
// "프로필이 안 열리는 것"은 안 괜찮으니까

Part 2: 네이밍이 어려운 이유

실제 끔찍한 변수명 모음

typescript
// 실제 코드베이스에서 발견된 변수명들 (약간 변형)

// 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;             // 함수가 아니라 변수?

좋은 네이밍의 원칙

typescript
// 원칙 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개월 후의 당신도 "당신"이 아님.
typescript
// 원칙 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초
typescript
// 원칙 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;
typescript
// 원칙 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 { }
네이밍 컨벤션 전쟁
  • camelCase vs snake_case vs PascalCase vs kebab-case
  • get vs fetch vs retrieve vs find vs load
  • create vs make vs build vs generate vs new
  • remove vs delete vs destroy vs drop vs purge

이런 논쟁에 정답은 없음. 팀에서 하나로 통일하는 게 정답임.

개인적으로 가장 무의미한 논쟁: 탭 vs 스페이스. Prettier 쓰면 됨.

실무에서 네이밍이 어려운 진짜 이유

typescript
// 도메인 지식이 변수명에 반영되어야 하기 때문

// 쇼핑몰에서의 "주문"
// 주문을 영어로 뭐라고 해야 됨?
// 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 차이로 발생하는 버그. 역사상 가장 흔한 버그 유형 중 하나임.

typescript
// 가장 기본적인 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)

typescript
// 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

typescript
// 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

typescript
// 월(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입니다.
typescript
// 요일도 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
}

경계값 테스트의 중요성

typescript
// 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) 방지 팁
  1. 경계값을 항상 테스트하기 — 0, 1, N-1, N, N+1
  2. exclusive vs inclusive 명확히 하기range(1, 5)가 5를 포함하는지?
  3. 반개방 구간 (half-open interval) 사용하기[start, end) 가 가장 오류가 적음
  4. .length - 1이 보이면 한 번 더 생각하기
  5. 배열 메서드 사용하기 — 직접 인덱스 조작보다 .map(), .filter() 등이 안전

세 가지를 한 번에 만나는 실무 상황

typescript
// 캐시 + 네이밍 + 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;
  }
}
typescript
// 올바른 버전

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에서 진짜 어려운 것들 (확장판)

typescript
// 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가지가 모든 코드에 있음."