본문으로 바로가기
검색 (Ctrl+K)

프론트엔드에서의 레이스컨디션

·8분 읽기·
---
·
개발

싱글 스레드인 JavaScript에서 발생하는 레이스 컨디션 이야기

레이스 컨디션은 프론트엔드 개발자에게 다소 낯선 용어다. 보통 운영체제나 멀티스레드 프로그래밍을 공부할 때 처음 접한다. 여러 스레드가 하나의 자원에 동시에 접근하면서 결과가 달라지는 문제이고, 이를 막기 위해 Mutex나 Semaphore 같은 동기화 도구를 사용한다고 배운다.

그런데 JavaScript는 기본적으로 한 번에 하나의 작업만 실행한다. 그렇다면 프론트엔드에서는 레이스 컨디션을 신경 쓰지 않아도 되는 걸까?

화면 하나가 동작하려면 JavaScript만 필요한 것은 아니다. 서버에 보낸 요청이 돌아와야 하고, 타이머가 끝나야 하며, 그사이 사용자가 다른 버튼을 누르거나 페이지를 이동할 수도 있다. 이 작업들은 시작한 순서대로 끝난다는 보장이 없다. JavaScript 코드는 한 줄씩 실행되더라도, 실행을 기다리는 비동기 작업의 순서는 계속 바뀐다.

프론트엔드에서 말하는 레이스 컨디션은 이 지점에서 발생한다. 여러 비동기 작업이 같은 화면 상태나 부작용에 영향을 주고, 무엇이 먼저 끝났는지에 따라 사용자가 보게 되는 결과가 달라진다.

레이스 컨디션이란?

레이스 컨디션은 여러 작업이 같은 데이터나 자원에 접근할 때 실행 순서나 타이밍에 따라 결과가 달라지는 현상이다. 먼저 끝날 것으로 예상한 작업이 늦게 끝나면서 다른 결과를 덮거나, 이미 유효하지 않은 동작을 실행하는 식으로 나타난다.

운영체제나 C++, Java의 멀티스레드 프로그래밍에서는 공유 메모리가 대표적인 자원이다. 여러 스레드가 동시에 값을 읽고 수정하면 결과를 예측하기 어려워진다. 그래서 Mutex, Semaphore, Lock, Atomic 연산과 같은 동기화 도구로 임계 구역의 접근 순서를 통제한다. Mutex는 한 시점에 하나의 실행 주체만 자원을 소유하도록 만들고, Semaphore는 정해진 수만큼의 접근을 허용하거나 작업 순서를 알리는 데 쓴다.

프론트엔드에서 흔히 만나는 문제는 조금 다르다. 일반적인 JavaScript 콜백 두 개가 같은 순간에 한 줄씩 끼어들며 실행되지는 않는다. 각 작업은 실행을 시작하면 Call Stack이 비워질 때까지 이어진다. 대신 서로 다른 비동기 작업이 어떤 순서로 다시 JavaScript 실행 기회를 얻는가가 문제가 된다.

JavaScript와 브라우저의 역할

JavaScript 엔진은 언어의 코드를 실행하지만 네트워크나 타이머, DOM 이벤트 같은 기능까지 혼자 제공하지 않는다. 브라우저라는 호스트 환경과 함께 동작한다. MDN의 JavaScript 실행 모델 (새 탭에서 열림)은 이 관계를 엔진과 호스트 환경으로 나눠 설명한다.

브라우저 탭의 JavaScript 작업은 Call Stack에서 하나씩 실행된다. 함수가 다른 함수를 호출하면 새로운 실행 프레임이 Stack 위에 쌓이고, 반환하면 위에서부터 빠진다. 반면 fetch, 타이머, 사용자 이벤트와 렌더링은 브라우저가 관리한다. 비동기 작업이 끝나면 이어서 실행할 콜백이 Queue에 들어간다. Event Loop는 현재 JavaScript 작업이 끝나 Stack이 비었을 때 다음 작업을 꺼낸다.

예를 들어 첫 번째 요청 A와 두 번째 요청 B를 차례로 보냈더라도 실제 네트워크 완료 순서는 B, A가 될 수 있다. B의 콜백이 먼저 화면을 갱신하고 나중에 A의 콜백이 같은 상태를 다시 쓰면, 마지막 화면은 오래된 A의 결과가 된다.

이것은 멀티스레드 환경의 data race와는 구분된다. JavaScript 콜백 자체는 하나씩 실행됐지만, 콜백이 Queue에 들어온 순서가 외부 작업의 완료 시간에 따라 바뀌었다. 프론트엔드에서는 이런 순서 의존 문제도 일반적으로 race condition이라고 부른다.

검색 결과의 역전

사용자가 노트북을 검색한 직후 입력을 지우고 모니터를 검색한다고 해보자. 두 요청의 응답 속도는 보장되지 않는다.

typescript
async function search(keyword: string) {
  const result = await fetchProducts(keyword);
  setProducts(result);
}

이 코드는 응답이 도착할 때마다 같은 products 상태를 변경한다. 모니터 응답이 먼저 도착해 올바른 목록을 그린 뒤, 늦은 노트북 응답이 도착하면 화면은 다시 노트북 목록으로 바뀐다. 입력 상태와 결과 상태가 서로 다른 요청을 가리키게 된다.

요청을 보낸 순서가 아니라 응답을 처리한 순서가 최종 상태를 결정한다. 서버가 잘못된 데이터를 준 것이 아니다. 프론트엔드가 현재 화면에 어떤 요청의 결과가 유효한지 확인하지 않은 것이 문제다.

페이지 번호와 목록의 불일치

페이지네이션에서도 같은 상황이 생긴다. 사용자가 2페이지를 눌렀다가 바로 1페이지로 돌아왔다고 가정해보자.

text
2페이지 요청 ───────────────→ 늦은 응답
    1페이지 요청 ─────→ 빠른 응답

1페이지 응답이 먼저 목록에 반영된다. 이후 2페이지 응답이 같은 목록 상태를 덮으면 선택된 페이지 버튼은 1인데 행에는 2페이지 데이터가 표시된다.

검색과 페이지네이션은 해결 원칙도 비슷하다. 새 요청이 시작될 때 이전 요청을 취소하거나, 각 요청에 식별자를 붙여 최신 응답만 반영해야 한다. 단순히 로딩 상태를 표시하는 것만으로는 순서 문제가 사라지지 않는다.

잘못된 패킹 슬립 출력

물류 운영 화면에서 검수를 완료하면 다음 작업 화면으로 이동한다. 일부 조건에서는 중간 프로세스를 건너뛰기 때문에 패킹 슬립을 실제로 출력해서는 안 됐다. 그런데 해당 조건에서도 패킹 슬립이 자동 출력되는 문제가 발생했다.

코드를 따라가 보니 데이터 준비, 화면 이동, 물리 출력의 책임이 서로 다른 비동기 경계에 나뉘어 있었다. 당시 구조를 단순화하면 다음과 같다.

typescript
const handleComplete = async () => {
  await createPackingSlipData();

  router.push(nextRoute).catch(handleNavigationError);
};

const onPrintSuccess = (data: PrintData[]) => {
  setPrintData(data);

  setTimeout(() => {
    printerRef.current?.print();
  }, 100);
};

createPackingSlipData는 데이터 생성 작업을 시작하고, 성공 콜백은 출력 데이터를 상태에 넣은 뒤 100ms 후 프린터 컴포넌트가 노출한 print()를 호출한다. 한편 router.push()는 화면 이동을 시작하지만 그 완료를 기다리지 않는다. Next.js Pages Router의 router.push를 비롯한 일부 메서드는 Promise를 반환한다는 점은 공식 useRouter 문서 (새 탭에서 열림)에서도 확인할 수 있다.

여기서 코드로 확인된 직접 원인은 명확했다. 중간 프로세스를 건너뛸 대상인지 판단하는 조건이 라우팅 결정에는 있었지만, 실제 프린터 출력 명령 앞에는 없었다. 따라서 성공 콜백까지 도달하면 타이머가 등록됐고, 프린터 참조가 남아 있는 동안에는 출력 명령을 호출할 수 있었다.

수정은 출력 명령을 예약하기 전에 같은 조건을 검사하는 것이었다.

typescript
const onPrintSuccess = (data: PrintData[]) => {
  setPrintData(data);

  if (shouldSkipPrint) {
    return;
  }

  setTimeout(() => {
    printerRef.current?.print();
  }, 100);
};

API 호출 자체는 상태 갱신을 위해 유지하되, 출력해서는 안 되는 대상은 물리 프린터 명령만 건너뛰었다. 조건의 책임이 실제 부작용이 발생하는 경계까지 내려온 셈이다.

타이밍에 따라 달라진 출력 여부

조건 누락은 출력 시도를 만든 직접 버그다. 여기에 화면 전환과 콜백의 완료 순서가 더해지면서 실제 출력 여부도 달라질 여지가 있었다.

React의 ref는 화면에 존재하는 노드나 자식 컴포넌트가 노출한 기능을 참조할 때 쓴다. React는 연결된 대상이 화면에서 제거되면 DOM ref의 current를 다시 null로 설정한다. 이 동작은 React useRef 문서 (새 탭에서 열림)에 설명되어 있다.

라우팅이 먼저 끝나 기존 화면과 프린터 컴포넌트가 제거되면, 늦게 실행된 콜백에서 printerRef.currentnull이다. Optional chaining 때문에 실제 호출은 생략된다. 반대로 화면 이동이 완료되기 전에 API 응답과 타이머가 먼저 끝나면 프린터 함수 참조가 아직 유효하고, 물리 출력이 시작될 수 있다.

text
정상 흐름
Print API → 화면 이동 시작 → 화면 이동 완료 → 프린터 컴포넌트 제거
         → 출력 콜백 → 호출 대상 없음 → 미출력

문제 흐름
Print API → 화면 이동 시작 → API 응답과 타이머 완료
         → 프린터 함수 호출 → 물리 출력 시작 → 뒤늦은 화면 이동 완료

물리 프린터로 작업을 전달한 뒤에는 화면이 제거돼도 이미 시작한 출력이 자동으로 취소되지 않는다. 화면의 생명주기와 외부 장치의 작업 생명주기가 다르기 때문이다.

다만 운영 당시 각 시점의 타임스탬프를 하나의 trace로 보존하지는 않았다. 따라서 조건 누락과 수정 내용은 코드로 확인된 사실이고, 위 타임라인은 코드 구조상 출력 여부가 달라질 수 있는 실행 순서를 설명한 것이다. 정확한 운영 순서를 확정하려면 API 성공, 타이머 완료, 출력 호출, 라우팅 완료 시점을 같은 로그에서 비교해야 한다.

문제 유형별 대응

레이스 컨디션에는 하나의 만능 해결책이 없다. 어떤 작업을 유효한 결과로 볼지 먼저 정해야 한다.

최신 요청만 유효한 화면

검색이나 필터처럼 마지막 요청만 필요하다면 이전 요청을 취소하는 편이 자연스럽다. AbortController의 signal을 fetch에 전달하고 새 요청이나 cleanup 시점에 abort()를 호출할 수 있다. MDN AbortController 문서 (새 탭에서 열림)에서 기본 동작을 확인할 수 있다.

typescript
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then((response) => response.json())
    .then(setResults)
    .catch((error) => {
      if (error.name !== "AbortError") throw error;
    });

  return () => controller.abort();
}, [query]);

요청을 취소하기 어렵거나 이미 응답이 끝난 경우에는 request ID나 version을 비교해 오래된 결과를 무시한다. React 공식 문서도 Effect에서 네트워크 응답 순서가 뒤집히는 문제를 설명하며 cleanup에서 이전 결과를 무시하는 예제를 제공한다. React useEffect (새 탭에서 열림)

typescript
const latestRequest = useRef(0);

async function loadPage(page: number) {
  const requestId = ++latestRequest.current;
  const result = await fetchPage(page);

  if (requestId !== latestRequest.current) return;
  setItems(result);
}

순서가 필요한 작업

앞 작업의 완료가 다음 작업의 전제라면 그 관계를 코드에 드러낸다.

typescript
await saveInspection();
await printDocument();
await router.push(nextRoute);

모든 작업을 무조건 직렬화하라는 뜻은 아니다. 병렬로 실행해도 되는 작업까지 기다리면 응답성이 나빠진다. 업무 규칙상 반드시 선후 관계가 필요한 구간만 await하거나 별도의 작업 Queue에 넣는다.

await router.push()만으로 이미 독립적으로 시작한 출력 콜백을 막을 수 없다는 점도 주의해야 한다. await는 현재 async 함수의 다음 줄을 멈출 뿐, 다른 곳에서 등록한 콜백이나 타이머를 취소하지 않는다.

화면보다 오래 사는 부작용

화면 이동 후에도 완료되어야 하는 업로드나 출력 작업을 특정 페이지 컴포넌트가 소유하면 화면 제거와 함께 제어 수단을 잃는다. 이런 작업은 페이지보다 오래 유지되는 서비스나 명시적인 작업 Queue로 옮기는 편이 낫다.

결제, 주문 생성, 출력처럼 중복 실행의 영향이 큰 부작용은 프론트엔드 버튼 잠금만으로 충분하지 않다. 서버에서도 idempotency key나 업무 키를 사용해 같은 명령이 한 번만 처리되도록 보호해야 한다.

고정 시간 대신 완료 신호

setTimeout(100)은 상태 반영이나 컴포넌트 준비를 보장하지 않는다. 빠른 환경에서는 충분해 보여도 느린 기기와 네트워크, 바쁜 메인 스레드에서는 부족할 수 있다. 반대로 필요 이상으로 사용자를 기다리게 할 수도 있다.

시간이 아니라 상태로 기다려야 한다. 출력 데이터 준비, 프린터 연결, 화면 전환 완료처럼 실제 전제 조건을 Promise나 상태 전이로 표현하면 우연한 100ms를 계약처럼 사용하지 않아도 된다.

마무리

JavaScript는 한 번에 하나의 작업을 실행한다. 하지만 네트워크, 타이머, 사용자 입력과 페이지 이동까지 하나의 직렬 흐름으로 끝나는 것은 아니다. 브라우저에 위임한 작업은 각자의 시간에 완료되고, 콜백은 그 순서대로 다시 JavaScript에 들어온다.

검색 결과 역전과 페이지네이션 불일치는 최신 요청을 구분하지 않아서 생긴다. 패킹 슬립 문제는 출력 대상 조건이 실제 부작용 경계에 없었고, 화면 이동과 출력 콜백의 순서에 따라 물리 출력까지 이어질 수 있는 구조였다.

비동기 코드를 볼 때는 await의 유무만 확인하는 것으로 부족하다. 어떤 작업들이 동시에 대기할 수 있는지, 같은 상태나 외부 자원을 건드리는지, 완료 순서가 바뀌어도 결과가 같아야 하는지를 함께 봐야 한다. 그 질문에 답하고 나면 취소, 무시, 직렬화, 책임 분리, 멱등성 중 어떤 방법이 필요한지도 훨씬 선명해진다.

참고 자료