사용자가 A 파일을 누른 뒤 B를 선택합니다. 제목은 B인데 편집기에는 A 내용이 나옵니다. 두 읽기 모두 올바른 데이터를 반환했지만 사용자가 떠난 뒤 도착한 A 결과를 막지 않았습니다.
두 클릭이 같은 함수를 지나므로 클릭, 로드, 렌더링이라는 호출 체인만으로는 설명할 수 없습니다. 각 요청의 시작과 완료 시점, 화면 갱신 권한을 알아야 합니다. 작은 JavaScript 예제로 빠른 수정에서 놓치기 쉬운 오류와 로딩 상태까지 확인합니다.
첫 await까지 읽기
load는 파일 텍스트의 Promise를 반환하고 publish는 화면 상태에 패치를 합칩니다. 둘 다 주입되므로 UI 프레임워크와 무관합니다.
export function createUnsafeReader(load, publish) {
return async function select(file) {
publish({ file, text: '', error: null, loading: true });
try {
const text = await load(file);
publish({ text });
} catch (error) {
publish({ error: error.message });
} finally {
publish({ loading: false });
}
};
}
await 전에 선택을 기록하고 이전 텍스트를 지웁니다. 기다리는 동안 다른 select가 시작할 수 있지만 await 뒤에는 현재 화면의 요청인지 확인하지 않습니다.
중단된 동안 지역 변수 file은 그대로지만 공유 화면은 바뀝니다. 자기 호출에는 맞는 데이터라도 이제 표시해야 할 호출은 아닙니다.
시간 순서 그리기
제어 가능한 읽기 두 개로 B를 A보다 먼저 완료하세요.
| 단계 | 이벤트 | 선택 파일 | 표시 텍스트 | 로딩 |
|---|---|---|---|---|
| 1 | A 선택, 읽기 시작 | A | 비어 있음 | 켜짐 |
| 2 | B 선택, 읽기 시작 | B | 비어 있음 | 켜짐 |
| 3 | B 성공 | B | B 텍스트 | 꺼짐 |
| 4 | A 성공 | B | A 텍스트 | 꺼짐 |
같은 코드지만 마지막으로 선택한 파일 대신 마지막에 완료된 읽기가 이깁니다.
수정 전에 UI 요구를 확인하세요. 단일 미리보기는 최신 선택을 보여야 하지만 일괄 가져오기는 모든 결과가 필요하고 저장 대기열은 순서대로 써야 할 수 있습니다. 아래 수정은 새 선택이 이전 선택을 대체하는 화면용입니다.
타이머 없이 재현
A는 100ms, B는 10ms 기다리는 테스트는 느리고 스케줄링에 의존합니다. 수동 완료 Promise로 정확히 제어하세요.
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
읽기 두 개를 만들고 파일을 모두 선택한 뒤 B, A 순으로 완료하세요.
const a = deferred(),
b = deferred();
const state = {};
const select = createUnsafeReader(
(file) => (file === 'A' ? a : b).promise,
(patch) => Object.assign(state, patch),
);
const first = select('A'),
second = select('B');
b.resolve('B text');
await second;
a.resolve('A text');
await first;
assert.equal(state.file, 'B');
assert.equal(state.text, 'A text'); // Reproduces the defect.
마지막 단언문은 의도적으로 잘못된 결과를 확인하여 예제에서 버그가 발생함을 보여줍니다. 수정된 리더 테스트는 B를 기대합니다. 재현과 수정 테스트를 분리하면 녹색 재현을 이전 코드의 정상 동작으로 오해하지 않습니다.
선택마다 번호 부여
단일 화면에는 카운터면 충분합니다. 선택 시작 시 증가시키고 해당 호출에서 값을 보관하며 무엇이든 표시하기 전에 비교하세요.
export function createReader(load, publish) {
let latest = 0;
return async function select(file) {
const request = ++latest;
publish({ file, text: '', error: null, loading: true });
try {
const text = await load(file);
if (request === latest) publish({ text });
} catch (error) {
if (request === latest) publish({ error: error.message });
} finally {
if (request === latest) publish({ loading: false });
}
};
}
카운터는 리더 인스턴스 소유입니다. 전역으로 두면 다른 창의 클릭이 결과를 무효화합니다. select 안에서 만들면 호출마다 초기화되어 요청을 구분하지 못합니다.
경로 비교도 부족합니다. A → B → A에서 첫 요청과 세 번째는 경로가 같지만 첫 요청이 오래된 파일을 보유할 수 있습니다. 요청 번호로 구분하세요.
예제는 publish의 동기 갱신과 load가 Error로 거부됨을 가정합니다. 프레임워크의 일괄 갱신, 컴포넌트 해제 또는 다른 거부 값은 별도로 처리해야 합니다.
별개 읽기 세 개로 A → B → A 테스트
호출마다 별도의 수동 완료 Promise를 주세요. 앞 fixture는 A와 B에 하나씩 연결하므로 두 번째 A에 재사용하면 첫째와 셋째의 차이를 숨깁니다.
load는 같은 경로도 읽는 사이 파일이 편집되어 다른 스냅샷을 반환할 수 있다고 가정합니다. 테스트는 ‘A version 1’과 ‘A version 2’를 직접 제공합니다. Yenpo 실제 사고가 아닌 통제된 학습 예제입니다. 로더가 항상 같은 불변 스냅샷을 반환한다면 다른 텍스트가 보이지 않으므로 실제 캐시와 읽기 동작을 확인하세요.
예제의 불완전한 createPathOnlyReader는 selectedFile을 기록하고 성공, 오류 및 finally를 file === selectedFile로 보호합니다. 다시 A를 선택하면 세 검사 모두 첫 A를 받아들입니다.
| 이벤트 | 경로만 검사 | 요청 번호 검사 |
|---|---|---|
| A, B, A 선택 | 현재 경로 A | 현재 요청 3 |
| 셋째가 A version 2 반환 | version 2 표시 | version 2 표시 |
| B 반환 | B 무시 | 요청 2 무시 |
| 첫째가 A version 1 반환 | version 1 표시 | version 2 유지 |
대응하는 테스트가 같은 순서를 두 리더에 실행합니다. reproduce:는 잘못된 버전을 기대해 경로 검사 실패를 보여주고 번호 검사는 version 2를 기대합니다. 추가 두 테스트는 셋째가 대기 중일 때 첫 A를 완료합니다. 이전 성공은 빈 텍스트와 로딩을 유지하고 이전 실패는 오류를 표시하지 않아야 합니다. 현재 A의 실패는 보여야 합니다.
두 리더 독립 유지
독립 화면마다 자체 latest 클로저와 publish 대상을 가진 리더를 만드세요. 같은 화면의 이후 선택에서는 재사용하세요. 클릭마다 다시 만들면 이전 호출을 무효화하는 공유 카운터가 사라집니다.
마지막 테스트는 별도 로더와 상태를 가진 왼쪽·오른쪽 리더에서 다음을 실행합니다.
- 왼쪽 A, 오른쪽 A, 왼쪽 B를 선택합니다. 왼쪽 현재 번호는 2, 오른쪽은 1입니다.
- 왼쪽 B가 대기 중일 때 오른쪽 A를 완료합니다. 오른쪽은 결과와 로딩 종료, 왼쪽은 빈 화면과 로딩을 유지해야 합니다.
- 왼쪽의 이전 A를 거부합니다. 오류 없이 B를 계속 기다려야 합니다.
- 왼쪽 B를 완료합니다. 왼쪽은 B를, 오른쪽은 자기 A를 유지해야 합니다.
latest를 모듈 범위로 옮기면 2단계가 실패합니다. 왼쪽의 새 선택이 오른쪽 요청을 무효화하여 로딩이 남습니다. 카운터가 별도여도 화면 상태를 공유하면 격리가 깨집니다. 테스트는 교차 완료 후 두 화면을 확인합니다.
오류와 로딩도 잘못된 곳에 적용될 수 있습니다
publish({ text })만 보호하면 텍스트는 고쳐져도 두 문제가 남습니다.
- B 선택 후 A가 실패하면 A의
catch가 B 화면에 A 오류를 표시합니다. - B 로딩 중 A가 끝나면 A의
finally가 B 로딩 표시를 끕니다.
두 번째를 잡으려면 B가 대기 중일 때 A를 완료하고 즉시 loading이 true인지 확인하세요. B 완료 후만 보면 조기 종료를 놓칩니다.
첫 번째는 B 대기 중 A를 거부하여 오류가 없고 로딩 중인지 확인한 뒤 B를 완료하세요. 현재 요청의 실패도 별도로 검사하세요. 모든 오류를 삼키는 보호 코드도 오래된 오류 테스트는 통과할 수 있습니다.
따라서 규칙은 ‘언젠가 올바른 텍스트 표시’보다 엄격합니다. 완료 시 텍스트, 오류 또는 로딩 종료를 게시할 수 있는 것은 현재 요청뿐입니다.
취소와 무시는 다릅니다
로더가 취소를 지원하면 A를 취소하여 작업을 줄일 수 있습니다. 그래도 캐시된 결과, 무시된 신호 또는 취소와 동시 완료 때문에 A가 게시할 수 있습니다.
불필요한 작업은 취소로 중지하고 화면에 적용할지는 요청 번호로 결정하세요. 모든 Promise API가 AbortSignal을 따르지는 않으므로 확인하세요.
이전 결과를 무시해도 load의 부수 효과는 되돌리지 않습니다. 예제는 텍스트만 반환하지만 실제 로더가 공유 캐시나 모델에 쓴다면 별도로 추적하세요. 카운터는 화면만 보호합니다.
테스트 실행
async-selection.mjs 다운로드 후 Node.js 22 이상으로 실행하세요.
node --test async-selection.mjs
10개 테스트가 A → B 덮어쓰기, 성공·오류·로딩 보호, A → B → A 반복 및 독립 리더를 다룹니다. 두 reproduce:는 잘못된 결과를 의도적으로 단언하며 나머지 8개는 의도한 동작을 확인합니다. 타이머나 의존성은 없습니다.
실제 코드에서는 진입 핸들러, 모든 await나 콜백, 대기 중 바뀌는 공유 상태 및 돌아오는 경로의 모든 쓰기를 찾으세요. 소스 위치 옆에 이벤트 순서를 적으세요.
Yenpo에서 진입 파일을 열어 두고 await 전후 상태 추적을 요청하세요. 성공, 오류 및 finally와 대조하세요. ‘Promise가 완료된다’에서 멈춘다면 결과를 사용할 수 있는 요청이 무엇인지 물으세요.