AI 어시스턴트가 값 캐시를 Promise 캐시로 바꾼 패치를 제안했다고 가정해 봅시다. 동시 호출자는 읽기 하나를 공유하고, 실패한 읽기는 삭제되어 재시도할 수 있으며, 무효화하면 새로 읽는다고 합니다. 익숙한 짧은 코드지만 특정 이벤트 순서에서 여전히 깨집니다.

캐시가 해야 할 일 기록

‘캐시 개선’만으로는 세부 사항이 불분명합니다. 여기서는 다섯 규칙을 사용합니다.

  1. 같은 키의 호출은 대기 중인 읽기 하나를 공유합니다.
  2. 성공 결과는 무효화될 때까지 유지됩니다.
  3. 실패한 읽기는 남지 않아 다음 호출이 재시도할 수 있습니다.
  4. 무효화는 현재 항목을 제거하여 다음 호출이 새 읽기를 시작하게 합니다.
  5. 이미 제거된 이전 읽기가 끝나도 새 항목을 제거하거나 덮어쓰지 못합니다.

4번은 이후 조회에만 적용됩니다. 진행 중인 읽기를 취소하거나 이미 기다리는 호출자의 값을 회수하지 않습니다. 모든 호출자가 최신 데이터를 봐야 한다면 버그가 없어도 이 캐시만으로 부족합니다.

키에는 결과에 영향을 주는 모든 값이 포함되어야 합니다. 로컬 파일 로더는 경로만으로 충분할 수 있지만 HTTP 캐시는 사용자, 권한, 쿼리나 리비전도 필요할 수 있습니다.

패치

await read(key) 뒤에 값만 저장하면 동시에 온 두 호출이 모두 캐시를 놓치고 각각 읽습니다. 진행 중인 Promise를 저장하면 해결됩니다. 실패 정리를 더하면 다음과 같습니다.

function load(key) {
  if (entries.has(key)) return entries.get(key);
  const pending = Promise.resolve().then(() => read(key));
  entries.set(key, pending);
  void pending.catch(() => {
    entries.delete(key);
  });
  return pending;
}

function invalidate(key) {
  entries.delete(key);
}

읽기 시작 전에 Promise를 저장하므로 두 번째 호출자가 공유할 수 있습니다. 읽기가 실패하면 핸들러가 키를 삭제하지만, 삭제 대상은 실패한 Promise가 아니라 지금 그 자리에 있는 항목입니다.

이 패치에서 invalidate는 바뀌지 않았습니다. 검토 중 지나치기 쉬워 새 정리가 평범한 오류 처리처럼 보입니다. 두 함수를 함께 읽어야 문제가 드러납니다.

문제를 만드는 순서

같은 키의 두 읽기를 A와 B라고 하겠습니다.

단계 이벤트 캐시 항목
1 첫 load(key)가 A 시작 Promise A
2 invalidate(key) 실행 없음
3 두 번째 load(key)가 B 시작 Promise B
4 A 실패 후 정리가 key 삭제 없음
5 B 대기 중 세 번째 load(key) 실행 Promise C, 중복 읽기

4단계에서 실패한 읽기가 B 소유의 항목을 지웁니다. 여기서 관찰되는 피해는 중복 작업입니다. B와 C가 같은 키를 동시에 읽습니다. 실제 읽기의 부수 효과가 데이터를 손상할 수 있는지 확인하기 전에는 데이터 손상이라고 부르지 마세요.

발생 조건도 명확합니다. 읽기 대기 중 무효화하고 새로 조회한 다음 이전 읽기가 실패해야 합니다. 모든 작업이 끝난 뒤 읽기, 무효화, 다시 읽기를 하는 테스트는 이를 재현하지 못합니다.

수정: 항목이 여전히 자신의 것인지 확인

삭제 전에 해당 자리에 실패한 Promise가 있는지 확인하세요.

export function createCache(read) {
  const entries = new Map();
  return {
    load(key) {
      if (entries.has(key)) return entries.get(key);
      const pending = Promise.resolve().then(() => read(key));
      entries.set(key, pending);
      void pending.catch(() => {
        if (entries.get(key) === pending) entries.delete(key);
      });
      return pending;
    },
    invalidate(key) {
      entries.delete(key);
    },
  };
}

동일성 검사가 수정의 전부입니다. 키가 존재한다고 자기 항목인 것은 아닙니다. 새 요청이 자리를 차지했을 수 있어 entries.has(key)만 검사해도 잘못됩니다.

Promise.resolve().then(() => read(key))는 read의 동기 throw를 거부된 Promise로 바꾸고 read 실행 전에 항목이 저장되도록 합니다. 항상 비동기인 read라면 다르게 쓸 수 있지만 동기 오류와 재진입 동작을 의도적으로 정하세요. 정리는 별도의 .catch()에 연결되므로 호출자는 원래 Promise와 거부를 그대로 받습니다. 실패를 대체 값으로 바꾸는 것은 별도의 API 변경입니다.

Promise가 대기 중일 때 테스트

타이머 대신 수동 완료 Promise로 읽기 종료 시점을 제어하세요. 핵심 회귀 테스트는 다음과 같습니다.

const old = cache.load('file');
const rejected = assert.rejects(old, /old read failed/);
await Promise.resolve(); // Let the injected reader start A.
cache.invalidate('file');
const current = cache.load('file');
await Promise.resolve(); // Let the injected reader start B.
a.reject(new Error('old read failed'));
await rejected;
const next = cache.load('file');
assert.equal(next, current);

a는 첫 읽기를 제어합니다. 전체 예제에는 두 번째 읽기를 위한 b도 있으며 마지막에 완료합니다. A를 거부하기 전에 assert.rejects를 붙여 테스트 자체의 처리되지 않은 거부를 피합니다.

이 구현은 Promise 동일성을 비교할 수 있지만 실제 읽기 수도 세세요. 래퍼 Promise 두 개가 하나의 읽기를 공유하거나 별개 읽기가 같은 값을 반환할 수 있습니다. 규칙은 특정 Promise 객체가 아니라 읽기 하나를 요구합니다.

주변 상태도 확인하세요.

테스트 단언할 내용
완료 전 두 호출 실제 읽기 하나, 두 호출자 모두 값 수신
성공 후 이후 호출 캐시 값 사용, 추가 읽기 없음
실패 후 새 호출 새 읽기 성공 가능
무효화 후 이전 실패 교체 항목 유지
무효화 후 이전 성공 원래 호출자는 이전 값, 이후 호출자는 새 값
읽기 함수의 동기 실패 호출자에게 거부 전달, 재시도 가능

이전 성공 사례를 다시 보세요. 성공 시 맵에 쓰지 않으므로 무효화된 읽기가 완료돼도 자신을 복원하지 못합니다. 다만 원래 호출자는 이전 값을 받습니다. 원하는 동작인지 확인하세요.

작성자가 확인할 수 있는 의견 작성

다음처럼 검토 의견을 쓸 수 있습니다.

거부 핸들러는 항목이 실패한 Promise인지 확인하지 않고 키로 삭제합니다. 대기 중인 A를 무효화하고 B를 캐시한 뒤 A가 거부되면 A의 정리가 B를 지웁니다. 다음 조회는 B가 실행 중인데 세 번째 읽기를 시작하여 ‘현재 항목당 읽기 하나’를 위반합니다. 삭제 전에 Promise 동일성을 비교하세요. 무효화 후 거부 테스트로 재현됩니다.

위치, 조건, 영향과 테스트를 포함합니다. 심각도는 실제 호출자에 달렸습니다. 로컬 파일 중복 읽기는 성능 문제이고 유료 API 중복 호출은 비용이 듭니다. 확인한 뒤 심각도를 정하세요.

기준 리비전도 보세요. 기존 문제는 새 정리에 책임을 돌리지 말고 별도로 보고하세요. AI 패치가 서식을 바꾸거나 의존성까지 올렸다면 이유를 확인하세요. 무관한 변경은 실제 동작 변경을 보기 어렵게 합니다.

로컬 검토에는 Mac용 Yenpo Git Diff 뷰어로 선택한 커밋이나 브랜치 비교를 유지하며 주변 코드를 살펴볼 수 있습니다.

직접 실행

promise-cache.mjs 다운로드 후 Node.js 22 이상으로 실행하세요.

node --test promise-cache.mjs

6개 중 하나는 재현 테스트로, 안전하지 않은 버전에서 문제가 발생하면 통과합니다. 나머지 5개는 수정된 캐시를 확인합니다. 예제는 메모리만 사용하며 축출, 프로세스 간 조정 및 취소는 없습니다. 실제 패치에서는 이들과 키를 만들거나 invalidate를 호출하는 곳도 확인하세요.

Yenpo AI Reader에서 Git 비교를 열고 ‘AI에 이 비교 검토 요청’을 누르세요. 첨부된 비교를 확인하고 초안을 구체적인 요청으로 바꾸세요.

이 캐시 변경을 검토해 주세요. 대기 중인 읽기를 무효화하고 교체 읽기를 시작하는 호출자를 찾으세요. 이전 읽기의 성공이나 실패가 교체 항목에 영향을 주는지 확인하세요. 관련 코드, 발생 호출 순서와 기존 문제 및 회귀의 구분을 제시해 주세요.

호출자가 실제로 해당 순서를 만들 수 있는지 확인한 뒤 터미널에서 테스트를 실행하세요.

패치가 테스트도 바꾼다면 구현과 함께 변경된 단언문을 검토하세요. 오류 검사를 약한 조건으로 바꾸면 녹색 결과가 호출자의 회귀를 숨길 수 있습니다.