[React]왜 리액트는 뮤텍스 잠금(Mutex Lock)이 없을까?
최근 컴퓨터 공학 개론에 관련된 강의를 듣게 되면서 동기화(synchronization)와 교착상태(deadlock)에 대해 좀 더 깊게 알아볼 기회가 생겼다.
관련된 공부를 하면서 한 가지 의문이 들었다.
자바스크립트, 리액트에서 뮤텍스 락(Mutex Lock)이 있나?
가만히 생각해보면 답은 의외로 쉬운데, 누군가에게 이 개념에 대해 깔끔히 설명하자니 왜 이런 의문을 가지게 됐는지, 왜 정답이 다소 뻔한데도 답을 하기 어려운지 설명하기 쉽지 않았다.
이번 포스팅을 통해 뮤텍스 락과 관련된 개념들의 정의, 그리고 왜 리액트에 뮤텍스 락이 불필요한지 정리해보고자 한다.
동기화와 뮤텍스
뮤텍스를 알기 위해서는 동기화를 먼저 알아야 한다.
동기화(synchronization)란, 여러 스레드나 프로세스가 공통된 자원(메모리, 파일, 데이터 구조 등)에 접근하거나, 특정 작업을 협력적으로 수행할 때, 그 접근 순서를 제어하고 데이터의 일관성과 정확성을 보장하는 기술이다. 즉, 실행 순서와 자원의 일관성을 보장하여 공통된 자원에 동시에 접근해 오류가 발생하는 것을 방지한다.
예를 들자면, 음식점의 재료 주문과 음식 주문이 동시에 들어왔다는 가정을 들 수 있다.
음식점에서 메뉴의 음식을 만들기 위한 재료가 모자라 재료를 추가 주문했다고 해보자. 그런데 해당 재료가 준비되기 전에 손님이 음식을 주문해버렸다면 어떻게 될까?
재료가 준비되지 않았으니 음식점은 요리를 제공할 수 없을 것이다.
실제로는 사람이 가게를 운영하니 이러한 일이 벌어지기 어렵겠지만, 컴퓨터를 사용해 작업하는 경우 이러한 문제는 손쉽게 발생할 수 있다.
컴퓨터를 예시로, 파일을 저장하고 읽는 것에 적용해볼 수 있다.
만약 어떤 명령어가 원하는 디렉토리의 파일을 불러오고자 했는데, 해당 "파일을 작성"하는 프로세스(Process, 실행 중인 프로그램의 인스턴스)가 먼저 실행되지 않았다면 컴퓨터는 요청한 파일이 없으니 "파일을 읽는" 프로세스를 실행할 수 없다.
이러한 상황, 즉 두 개 이상의 스레드(Thread, 프로세스를 구성하는 실행 흐름의 단위)나 프로세스가 공유 자원에 동시에 접근하여, 기대하지 않은 결과나 모호한 상태를 야기하는 상황을 레이스 컨디션(Race Condition) 이라고 한다.
위와 같은 문제를 해결하기 위해 스레드가 공유 변수에 접근하기 전에 어떤 형태로든 정해진 규칙과 순서를 따라야 하며, 이를 지원하는 기법들이 동기화 메커니즘이다.
뮤텍스(Mutex)는 상호 배제(Mutual Exclusion)의 줄임말로 한 번에 오직 하나의 스레드(또는 프로세스)만 공유 자원에 접근할 수 있도록 하는 개념이다. 뮤텍스는 '자원에 접근하기 전, 반드시 뮤텍스를 획득해야 한다'는 규칙을 강제함으로써, 동시에 둘 이상의 스레드가 해당 자원을 변경하지 못하도록 한다.
뮤텍스의 메커니즘을 구현한 가장 기본적인 동기화 도구가 바로 뮤텍스 락(Mutex Lock)이다.
앞서 언급했듯 식당의 예시로 돌아가보자.
어떻게 해야 재료가 도착하기 전에 손님이 음식을 주문하지 못하게 할까?
마치 뮤텍스 락처럼, 식당 입구 문을 걸어잠그는 것이다. 물론 현실의 손님은 다른 식당을 찾아 떠나버리겠지만, 최소한 컴퓨터 속 세상에서는 해당 손님은 문이 열릴 떄까지 대기시킬 수 있다. 거기 가만히 있으십시오 휴먼
이후 재료가 도착해 음식을 할 준비가 되었다면 비로소 음식을 주문하는 손님에게 식당에 입장할 수 있게 "권한"을 준다.
"식당에 음식 재료를 가져온다" 프로세스가 먼저 "식당"에 대한 출입권한을 얻어 준비된 재료를 전달했고, "필요로 하는 재료를 통해 음식을 만든다"라는 프로세스가 이후 "식당"에 대한 출입권한을 얻어 순서에 맞게 요청을 한 것이다.
즉, 공통된 자원 "식당"에 대한 "일관성"을 보장하고 프로세스의 "실행 순서를 통제"하여 전체 작업에 대한 "안정성 및 예측 가능성"을 향상 시키는 것이다.
리액트에서의 레이스 컨디션
위 내용을 기반으로 우리는 앞선 질문에 대한 답을 할 수 있다.
자바스크립트를 처음 배우게 되면 이에 대한 정답이 포함되어 있기 때문이다.
자바스크립트는 싱글 스레드 언어이다.
싱글 스레드, 즉 작업할 수 있는 공간(스레드)가 하나만 있다는 말이다.
앞서 언급한 레이스 컨디션이 발생하기 위해서는 최소 두 개의 스레드가 동일한 자원에 대한 접근을 해야 되는데, 자바스크립트와 그로 구성된 라이브러리 리액트는 스레드 자체가 한 개로 제한되기 때문에 문제를 발생시킬 수 없다. 자연스럽게, 뮤텍스 락을 활용할 이유가 없다.
리액트를 실제로 써본 사람들은 여기서 고개를 갸웃거리게 된다.
어? 리액트에서도 레이스 컨디션이 있는데?
분명 자바스크립트에서는 레이스 컨디션이 불가능한데, 어떻게 리액트에서는 이러한 레이스 컨디션이 발생한 것일까?
리액트는 기본적으로 단일 스레드 환경에서 동작하지만, 리액트 컴포넌트가 사용하는 데이터나 로직이 항상 "동기적"이라고만 할 수는 없기 때문이다.
코드를 통해 살펴보도록 하자.
import React, { useEffect, useState } from 'react';
function MyComponent() {
const [data, setData] = React.useState(null);
useEffect(() => {
// A라는 API 호출
fetch('/api/dataA')
.then(res => res.json())
.then(result => {
setData(result); // A 응답으로 상태 설정
});
// B라는 API 호출
fetch('/api/dataB')
.then(res => res.json())
.then(result => {
setData(result); // B 응답으로 상태 설정
});
}, []);
return <div>{data ? JSON.stringify(data) : "Loading..."}</div>;
}
이 코드에서는 두 개의 비동기 요청(A와 B)을 거의 동시에 보낸다. 로직상 "A 요청 결과를 먼저 보여주고, 이후 B 요청 결과를 반영한다"는 명확한 전략 없이 단순히 setData를 두 번 호출하기 때문에 담으과 같은 시나리오가 발생한다.
- A 요청이 먼저 시작됐지만 B 요청이 더 빨리 응답할 수 있고, 그 후에 늦게 도착한 A 요청 응답이 setData를 다시 실행해 B 결과를 덮어쓰는 시나리오
- 반대로 A가 먼저 도착하고 B가 나중에 도착해 B가 A를 덮어쓰는 시나리오
즉, 어떤 요청 응답이 마지막에 도착하느냐에 따라 최종 렌더링 결과가 바뀌는 "레이스 컨디션"을 유발했다.
위 코드는 실제 멀티스레드 경쟁이 아니라, 비동기 처리의 순서 제어 실패나 stale state(오래된 상태)를 참조하는 로직으로 인해 논리적 충돌이 발생하여 레이스 컨디션과 "유사"한 현상이 발생한 것이다. 폰 레이스 컨디션
때문에 리액트에서의 "레이스 컨디션"을 처음 접하게 되면 당황스럽다. 물론 리액트의 구동원리를 잘 이해하고 있다면 놀라운 일은 아니겠지만...리액트를 완벽히 이해하고 사용 중이지 않은 저년차 개발자들은 두 눈을 의심하게 된다.
그렇다면 어떻게 이 코드를 해결해야할까?
전통적인 뮤텍스 락의 기법(자원 접근 전 acqure, 접근 후 release)을 적용하면 다음과 같이 코드를 작성할 수 있다.
import React, { useEffect, useState } from 'react';
// 뮤텍스 비슷한 개념 (단순 예제)
let mutexLocked = false;
function acquireLock() {
return new Promise(resolve => {
const tryLock = () => {
if (!mutexLocked) {
mutexLocked = true;
resolve();
} else {
setTimeout(tryLock, 10);
}
};
tryLock();
});
}
function releaseLock() {
mutexLocked = false;
}
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
(async () => {
await acquireLock();
const resultA = await fetch('/api/dataA').then(res => res.json());
setData(resultA);
releaseLock();
await acquireLock();
const resultB = await fetch('/api/dataB').then(res => res.json());
setData(resultB);
releaseLock();
})();
}, []);
return <div>{data ? JSON.stringify(data) : "Loading..."}</div>;
}
딱 봐도 문제가 보인다.
자바스크립트(특히 브라우저 환경)에서는 실제로 멀티스레드 상황이 발생하지 않기 때문에, 전통적인 뮤텍스 락은 불필요하거나 지나친 복잡성을 초래한다. 위의 코드는 "락 획득 대기"를 setTimeout으로 구현하는 어색한 흉내일 뿐, 실질적으로 이 패턴은 비효율적이며, CPU 리소스를 낭비하고 있다.
정리하자면, 리액트에서의 비동기는 이벤트 루프 기반의 비동기 프로그래밍이기 때문에 멀티 스레드 환경을 전제로 한 뮤텍스 락과는 어울리지 않는 접근이다.
보다 손쉬운 해결 방법은 다음과 같다.
import React, { useEffect, useState } from 'react';
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
let isCancelled = false; // 컴포넌트 언마운트나 무효화 시점을 관리
Promise.all([
fetch('/api/dataA').then(res => res.json()),
fetch('/api/dataB').then(res => res.json())
]).then(([resultA, resultB]) => {
if (!isCancelled) {
// 두 결과를 함께 반영
setData({ A: resultA, B: resultB });
}
});
return () => {
isCancelled = true;
};
}, []);
return <div>{data ? JSON.stringify(data) : "Loading..."}</div>;
}
위 코드를 통해 두 요청이 모두 끝난 뒤에 결과를 함께 반영하면, 어떤 응답이 먼저 오는지에 상관없이 항상 최종 일관성을 유지할 수 있다.
참조