코드에 박힌 매직 넘버가 버그가 되는 순간: 사이드 프로젝트 MAX_RECENT=25 제거기
아무도 기억 못 하는 상수 `MAX_RECENT = 25`가 사용자에겐 "사라지는 탭" 버그였습니다. 왜 25였는지 추적했고, 답이 "그냥"이어서 지웠습니다. 임의의 제한이 코드 속에서 자라나 버그로 바뀌는 과정을 정리합니다.
1. 문제 상황: "닫은 탭이 갑자기 사라져요"
인코그니토 모드 탭 복구 기능이 있는 크롬 확장을 운영 중이었습니다. 사용자가 탭을 닫으면 chrome.storage.session에 URL과 제목을 기록해 두고, 팝업에서 "최근 닫힌 탭"으로 복구할 수 있게 해주는 기능입니다.
어느 날 피드백이 하나 들어왔습니다.
"탭을 30개 정도 연달아 닫았는데, 마지막 25개만 남아 있어요. 5개는 어디 갔나요?"
확인해 보니 background.js에 이런 코드가 있었습니다.
const MAX_RECENT = 25;
async function pushRecent(entry) {
// ...
const deduped = list.filter((i) => !(i.url === entry.url && i.title === entry.title));
deduped.unshift({ ...entry, closedAt: Date.now() });
if (deduped.length > MAX_RECENT) deduped.length = MAX_RECENT; // ← 여기
await area.set({ [RECENT_KEY]: deduped });
}
새 탭이 들어오면 배열 맨 앞에 추가하고, 길이가 25를 넘으면 조용히 잘라냅니다. 오래된 항목은 사용자 통보 없이 사라집니다. 25번째 탭이 복구 가능하다가, 26번째를 닫는 순간 첫 번째가 증발합니다. 사용자 입장에선 마술이 아니라 버그입니다.
1.1 첫 번째 가정: "메모리 제약 때문이었겠지"
제일 먼저 떠올린 가정은 "초기에 성능 이슈가 있어서 제한을 건 건가?" 였습니다. chrome.storage.session은 세션 메모리에 저장되므로 크기가 중요합니다. 25개가 메모리 한계와 관련이 있을지 모릅니다.
git blame을 확인해 봤습니다.
commit ~ refactor: initial recent tabs implementation
- chrome.storage.session 기반 최근 닫힌 탭 기록
- MAX_RECENT=25로 리스트 제한
커밋 메시지에는 "왜 25인가"에 대한 언급이 없습니다. 구현 초기부터 그냥 들어가 있었습니다. 주석도 없었습니다. 25의 근거가 코드베이스 어디에도 없었습니다.
1.2 두 번째 가정: "Free/Pro 차등이었을지도"
다음 가정은 "Free 티어 사용자를 제한하고 Pro에서 해제하려던 거 아닐까?" 였습니다. 현재 확장은 Pro 티어에서 여러 기능을 풀어 주는 구조라 그럴듯해 보였습니다.
Pro 관련 코드를 찾아봤습니다.
grep -n "MAX_RECENT\|recent.*pro\|pro.*recent" extension/
결과: MAX_RECENT는 Pro 로직과 전혀 엮여 있지 않았습니다. Free, Pro 모두 동일하게 25로 잘렸습니다. Pro를 사도 복구 가능한 탭 수가 늘지 않았습니다. 상업적 의도가 있었다면 코드로 남았을 텐데, 전혀 없었습니다.
1.3 진단: Cargo Cult 상수
결국 답은 하나였습니다. 25는 아무 근거도 없는 숫자였습니다. 초기에 "뭐라도 제한은 넣어야 할 것 같아서" 직관적으로 찍은 숫자가 그대로 남아 있었습니다.
이런 상수를 cargo cult 상수라고 부를 수 있습니다. 원래 의도한 이유는 잊혔거나 존재하지 않았고, 단지 "코드에 있으니까" 이유로 유지됩니다. 그리고 시간이 지나면 사용자에게 이유 없는 버그로 드러납니다.
2. 원인 분석: 왜 임의의 제한이 위험한가
이 사건은 단순 버그 수정이 아니라 한 가지 원칙을 뽑아낼 기회였습니다. 이후 코드베이스 전체를 보는 관점이 바뀌었습니다.
2.1 제한에는 항상 근거가 있어야 한다
좋은 상한은 자연스러운 제약에서 나옵니다.
- 저장소 용량 제한: "브라우저가 5MB까지 허용하므로 이 테이블은 1000 rows가 상한" — 측정 가능한 근거
- 성능 제약: "매번 렌더링 시 정렬하는데 O(n log n)이라 UI 반응성을 위해 500개로 제한" — 측정 가능한 근거
- UX 결정: "사용자가 한 번에 50개 이상 넘기면 스크롤하기 어려우므로 50개로 페이지네이션" — 명시적 결정
- 비즈니스 규칙: "Free는 10개, Pro는 무제한" — 명시적 가격 정책
이 중 어디에도 맞지 않는 제한은 임의적이고, 임의적 제한은 결국 누군가에게 버그로 인식됩니다.
2.2 "성장이 자연스럽게 제한되는가?" 질문
MAX_RECENT = 25를 제거하면 무한히 자랄 것 같지만, 실제로는 아닙니다. 이 데이터의 컨텍스트를 분석해 보면 여러 자연 제약이 이미 작동하고 있었습니다.
chrome.storage.session은 세션 메모리: 브라우저가 꺼지면 전부 소멸. 인코그니토 창 전부 닫히면 소멸.- 세션 스토리지 용량 제한: 약 10MB (Chrome 112+에서 기존 1MB 상한이 상향됨). 탭 하나가 평균 URL+제목 200바이트라 가정하면 수만 개. 현실에서 한 세션에 수만 개 탭을 닫는 사용자는 없음.
- 사용자 행동 패턴: 실제 복구를 시도하는 건 최근 닫힌 몇 개. 오래된 건 복구 가치가 떨어짐.
즉 자연 제약이 충분히 강합니다. 25는 이 자연 제약 위에 덧붙인 가짜 안전망이었고, 실제 사용자 행동에 충돌할 뿐이었습니다.
2.3 "제한이 문서화되지 않으면 제한이 아니다"
중요한 부차 원칙은 이것입니다. 사용자에게 안 알린 제한은 버그입니다. UI 어디에도 "최근 탭은 25개까지만 저장됩니다"라는 안내가 없었습니다. 제한이 있더라도 사용자가 예측 가능해야 수용 가능한데, 이 25는 예측 불가능이었습니다.
제한을 유지하고 싶었다면 선택지는 두 가지였습니다.
- UI에 "최근 25개까지 표시"를 명시
- 제한을 사용자 설정으로 노출 (기본값 25, 조정 가능)
둘 다 하지 않은 제한은 제거 대상입니다.
3. 해결 방법: 3줄 삭제
수정은 커밋 메시지보다 짧습니다.
const PIN_SPACE_ATTEMPTS_KEY = 'pinSpaceAttempts';
- const MAX_RECENT = 25;
// Time constants (avoid magic numbers)
const MS_PER_SECOND = 1000;
async function pushRecent(entry) {
try {
if (SpaceManager.isInSpace()) {
const space = SpaceManager.getActiveSpace();
const list = space.recentTabs || [];
const deduped = list.filter((i) => !(i.url === entry.url && i.title === entry.title));
deduped.unshift({ ...entry, closedAt: Date.now() });
- if (deduped.length > MAX_RECENT) deduped.length = MAX_RECENT;
space.recentTabs = deduped;
await SpaceManager.saveActiveSpace();
} else {
const area = getArea(/* persist */ false);
const data = await area.get(RECENT_KEY);
const list = Array.isArray(data[RECENT_KEY]) ? data[RECENT_KEY] : [];
const deduped = list.filter((i) => !(i.url === entry.url && i.title === entry.title));
deduped.unshift({ ...entry, closedAt: Date.now() });
- if (deduped.length > MAX_RECENT) deduped.length = MAX_RECENT;
await area.set({ [RECENT_KEY]: deduped });
}
} catch (e) {
console.error('[Ext] pushRecent failed:', e);
}
}
총 3줄 삭제. 상수 선언 하나 + 배열 자르기 두 줄(공간 안 경로와 공간 밖 경로). 기능 테스트는 "30개 이상 연달아 탭 닫고 팝업 열어서 전부 보이는지 확인"이 전부입니다.
3.1 자연스러운 상한이 대신한다
제거 후에도 무한히 자라지 않습니다. 실제 동작은 이렇게 됩니다.
| 시점 | 항목 수 | 무슨 일이 일어났나 |
|---|---|---|
| 시작 | 0 | 인코그니토 창 열기 |
| 탭 10개 닫음 | 10 | 모두 복구 가능 |
| 탭 100개 닫음 | 100 | 모두 복구 가능 (이전엔 75개 사라졌음) |
| 탭 1000개 닫음 | 1000 | 여전히 가능 (매우 드문 사용 패턴) |
| 모든 인코그니토 창 닫음 | 0 | 세션 스토리지 소멸 |
| 브라우저 재시작 | 0 | 새 세션 시작 |
chrome.storage.session의 약 10MB 한계(예전엔 1MB)에 도달하려면 수만 개 탭이 필요합니다. 현실의 사용자 중 그 수준에 도달하는 사람은 거의 없고, 도달하더라도 그때는 브라우저가 에러를 던져서 알려줍니다. 침묵 속 삭제가 아니라 명시적 에러는 대응 가능한 상태입니다.
3.2 "침묵 vs 에러" 관점
이 리팩토링의 숨은 효과는 실패 모드가 침묵에서 명시적 에러로 바뀐 것입니다.
- 이전: 25개 초과 시 조용히 첫 번째 항목 삭제. 사용자는 무슨 일이 벌어지는지 모름.
- 이후: 세션 스토리지 한계 도달 시
chrome.storage.session.set()이 예외 throw. 로깅됨. 필요하면 사용자에게 알릴 수 있음.
이 원칙은 일반화됩니다. 침묵 속 데이터 손실은 명시적 에러보다 나쁩니다. 명시적 에러는 버그 리포트로 돌아오고, 수정 가능합니다. 침묵 속 손실은 사용자 기억 속에 "이 확장은 뭔가 이상해"로 쌓이고, 수정할 기회가 없습니다.
4. 핵심 개념 정리
4.1 상수의 종류
| 종류 | 예시 | 제거 가능성 |
|---|---|---|
| 측정된 상한 | MAX_URL_LENGTH = 2048 (브라우저 표준 한계) |
❌ 유지 (근거 명확) |
| 의도된 상한 | FREE_TIER_MAX_BOOKMARKS = 100 (가격 정책) |
❌ 유지 (비즈니스 결정) |
| 성능 상한 | MAX_RENDER_ITEMS = 500 (UI 반응성) |
⚠️ 측정 후 결정 |
| 임의 상한 | MAX_RECENT = 25 (근거 없음) |
✅ 제거 |
| Dead 상한 | LEGACY_LIMIT = 10 (현재 코드에서 참조 없음) |
✅ 제거 |
4.2 임의 상한을 식별하는 질문 5가지
코드 리뷰나 리팩토링 중에 상한을 만났을 때 던질 질문입니다.
- 왜 이 숫자인가? 답이 "그냥" 이면 의심
- 근거가 주석이나 커밋 메시지에 있는가? 없으면 의심
- UI에 사용자가 볼 수 있게 표시되어 있는가? 아니면 의심
- 제한에 도달했을 때 사용자가 알아차리는가? 침묵 삭제면 의심
- 비슷한 기능의 경쟁 제품은 어떻게 하는가? 전혀 다르면 의심
다섯 개 중 두 개 이상 "의심"이면 제거 검토 대상입니다.
4.3 제거 전에 확인할 것
물론 모든 상한을 사라 무조건 제거하면 안 됩니다. 제거 전에 반드시 확인합니다.
- 자연 제약이 있는가? (저장소 용량, 메모리, 시간 등)
- 자연 제약에 도달할 때 실패 모드가 명확한가? (침묵 삭제 X, 명시적 에러 O)
- 제거로 인해 성능 회귀가 없는가? (O(n²) 알고리즘이 숨어 있는지)
- 제거로 인해 보안 문제가 없는가? (DoS 벡터가 생기는지)
이 네 가지를 통과하면 제거해도 됩니다.
5. 베스트 프랙티스
5.1 상수를 추가할 때의 체크리스트
새 상수를 코드에 박아 넣기 전에:
- [ ] 근거를 주석에 쓰기 — 왜 이 숫자인지 한 줄 설명
- [ ] 측정 가능한 단위 쓰기 —
25대신MAX_RECENT_PER_SESSION, 단위(_BYTES,_MS) 포함 - [ ] 사용자에게 문서화 — UI나 FAQ에 언급하거나, 제거 가능한 제한이면 아예 빼기
- [ ] 자연 제약과 겹치는지 확인 — 브라우저 한계가 이미 있으면 중복 제한 불필요
- [ ] 비즈니스 로직과 분리 — Free/Pro 차등이라면 별도 상수 (
FREE_*,PRO_*)
5.2 상수를 삭제할 때의 체크리스트
기존 상수를 제거할 때:
- [ ] git blame으로 도입 시점 확인 — 맥락이 있을 수도 있음
- [ ] 전체 검색으로 참조 지점 찾기 —
grep -rn "MAX_RECENT" - [ ] 자연 제약의 실패 모드 확인 — 제거 후 한계 도달 시 어떻게 되는가
- [ ] 성능 테스트 — 대량 데이터 시나리오로 회귀 체크
- [ ] 커밋 메시지에 삭제 근거 명시 — 미래의 본인이 다시 추가하지 않도록
5.3 주의 — 제거하면 안 되는 경우
아래는 제거 검토 대상이 아닙니다.
- 보안 관련 상한:
MAX_PIN_ATTEMPTS = 5— rate limit 방어 - 입력 검증 상한:
MAX_URL_LENGTH = 2048— 브라우저/서버 표준 - 명시적 비즈니스 룰:
FREE_TIER_BOOKMARKS = 100— 가격 정책 - 메모리 누수 방지:
MAX_CACHE_ENTRIES = 1000— 맵 크기 폭주 방지 - 타임아웃 상한:
NETWORK_TIMEOUT_MS = 10000— 무한 대기 방지
이들은 근거가 명확하고 침묵 실패가 아니라 명시적 실패(에러 throw, UI 메시지) 를 동반합니다.
6. FAQ
Q1. 25를 제거했는데 정말로 괜찮은가요? 악성 확장이 탭을 수만 개 닫으면 어떡하나요?
A. 탭을 닫는 주체는 사용자입니다 — 다른 확장이 임의로 인코그니토 탭을 닫을 수 없습니다. 정상 사용자가 수만 개 탭을 연달아 닫을 시나리오는 드물고, 그런 경우에도 chrome.storage.session의 약 10MB 한계(예전엔 1MB)가 자연 방어선 역할을 합니다. 한계에 도달하면 set()이 QuotaExceededError를 throw하고, 이건 로그로 남아 대응 가능합니다. 침묵 속 25개 컷보다 명시적 에러 로그가 언제나 낫습니다.
Q2. 기본값으로 숫자를 뭐라도 두는 게 방어적 프로그래밍 아닌가요?
A. 방어적 프로그래밍의 목적은 "예측 못 한 실패로부터 시스템을 보호하는 것"입니다. MAX_RECENT = 25는 이 목적에 기여하지 않았습니다 — 보호하고 있던 건 실존하지 않는 위협이었고, 대신 예측 가능한 사용자 시나리오(30개 탭 닫기) 를 방해했습니다. 진짜 방어적 프로그래밍은 실제 위협 모델을 식별하고 대응하는 것입니다. 상상의 위협에 대응하는 건 방어적 프로그래밍이 아니라 미신입니다.
Q3. 커밋 메시지에 근거가 없던 이유가 혹시 AI 도구로 코드를 생성해서였나요?
A. 가능성이 있습니다. AI 코드 생성 도구가 "리스트가 무한히 자라면 위험하니 제한을 두자"는 일반론적 패턴을 그대로 넣는 경우가 있습니다. 숫자는 25나 50 같은 친근한 수로 고릅니다. 이 자체가 나쁜 건 아니지만, 제안된 코드를 받아들일 때 그 상수의 근거가 이 프로젝트에 적용되는지 확인해야 합니다. AI가 제안한 상한이 특히 리뷰 대상입니다.
Q4. 이걸 린터나 CI로 자동 검출할 수 있나요?
A. 부분적으로. ESLint의 no-magic-numbers 룰이 매직 넘버 사용을 잡아줍니다. 다만 상수 선언(const MAX_RECENT = 25) 자체는 막지 못하고, 사용 지점의 리터럴만 감지합니다. 근거 주석이 없는 상수를 자동 검출하는 건 어렵습니다. 코드 리뷰에서 "이 숫자의 근거는?" 질문을 습관화하는 게 현실적입니다.
Q5. 저장 용량이 정말로 자연 제약이 되나요? chrome.storage.session의 정확한 한계는?
A. 공식 문서 기준 약 10MB (최근 Chrome 버전)입니다. 이전에는 1MB였고 현재는 확장되었습니다. 탭 하나의 엔트리(URL + 제목 + 타임스탬프)가 평균 200바이트라고 가정하면 50,000개 탭까지 저장할 수 있습니다. 이 규모에 도달하는 인코그니토 세션은 현실에 존재하지 않습니다. 자연 제약이 실질적으로 무한인 셈이고, 이 상황에서 MAX_RECENT=25는 순전한 인위적 제한이었습니다.
7. 참고 자료
8. 다음 단계
이 글은 개별 상수 제거를 다뤘습니다. 같은 원칙을 "초기 설계 결정이 시간이 지나면서 어떻게 dead code가 되는가"로 확장한 글이 다음입니다. 기존에 만든 양방향 링크 시스템을 6개월 뒤 돌아보면서, 예전에 "할 일"로 적어둔 것들이 실제로는 하나도 안 만들어졌고, 그게 옳은 결정이었다는 회고입니다.