백엔드/Node.js

[NodeJS] 싱글 스레드도 경쟁 상태(Race Condition)는 발생합니다

SparkIT 2025. 5. 4. 05:05

Node.js는 싱글 스레드로 동작하는 서버 환경으로, 일반적으로 멀티스레드 환경에서 발생하는 Race Condition 문제와는 거리가 멀다고 여겨집니다. 그러나 비동기 코드가 사용되면 Node.js에서도 Race Condition 문제가 발생할 수 있습니다. 이번 글에서는 Race Condition의 개념을 짚어보고, Node.js에서 이를 어떻게 실험할 수 있는지에 대한 예제를 소개하려 합니다.

 

 

경쟁 상태(Race Condition)란?

Race Condition은 여러 프로세스나 스레드가 동시에 동일한 자원에 접근하면서 발생하는 문제입니다. 이는 공유 자원을 동시에 변경하려 할 때, 각 작업의 실행 순서에 따라 예상치 못한 결과가 발생하는 상황을 말합니다.

예를 들어 CounterService라는 클래스가 존재하고 해당 클래스에 counter라는 멤버 변수가 있다고 가정합니다. 그리고 이 멤버 변수 counter 값을 +1 하는 메서드가 존재합니다. 이때 해당 메서드에 여러 요청이 동시에 도착하면 어떻게 될까요? 순서대로 counter 값이 +1 된다면 race condition 문제가 발생하지 않는 경우고, 단순히 counter값이 +1 한 번 실행되면 race condition 문제가 발생된 경우입니다.

 

 

Node.js는 싱글스레드니까 Race Condition 자체가 성립할 수 없지 않나요?

보통 race condition은 멀티 스레드 환경으로 실행되는 Java Spring 프레임워크에서 많이 거론됩니다. 반면 Node.js는 애초에 싱글 스레드 환경이기 때문에 race condition이 발생하지 않는다는 오해를 사기도 합니다. 하지만 이는 정확하지 않은 정보입니다. 더 정확하게 표현하자면 여러 스레드가 하나의 자원에 접근하면서 발생하는 race condition은 없습니다. 하지만 서로 다른 논리적 트랜잭션 간 race condition을 발생 가능합니다.

Node.js에서 개발된 코드가 모두 동기적인 코드로만 구성된다면 race condition이 발생하지 않을 수 있습니다. Node.js는 이벤트 루프라는 싱글 스레드로 동작하기 때문에 동기적인 코드는 모두 순서대로 작동하기 때문입니다. 하지만 비동기 코드가 포함되면 이야기가  달라집니다. 비동기 코드는 먼저 실행된 코드가 먼저 반환됨을 보장하지 않습니다. 여기서 순서가 꼬이게 됩니다. (Node.js의 이벤트 루프에 대한 내용은 아래 글에 더 자세하기 작성했습니다)

 

[NodeJS]이벤트 루프 동작 이해하기

용어 정리콜 스택(Call stack)수행해야 하는 함수들 대기 장소(To Do List 같은 역할). 실제로 노드가 함수들을 실행하기 위해 확인하는 곳이라고 생각하면 됩니다. 노드 엔진은 콜 스택에 함수들이 대

sparkit.tistory.com

 

그럼 비동기 코드를 넣어서 Node.js에서 강제로 race condition을 발생시켜 보겠습니다. 아래 예제를 통해 쉽게 이해해봅시다.

 

 

Node.js 환경 race condition 예제

Typescript와 Nest JS 프레임워크를 활용한 예제입니다. 서비스 코드 일부로 testRaceCondition 메서드를 실행하면 동시에 increment 메서드를 5번 실행시킵니다.

import { Injectable } from '@nestjs/common';

@Injectable()
export class CounterService {
	private counter = 0;

	async testRaceCondition() { // ❗️ 이 메서들 통해 5번의 increment 메서드 동시에 요청
		return Promise.all([
			this.increment(),
			this.increment(),
			this.increment(),
			this.increment(),
			this.increment(),
		]);
	}

	async increment() {
		const currentValue = this.counter;
		this.counter = currentValue + 1;     // ✅ 위 아래 코드 순서 변경하면 결과 달라짐
		await this.simulateAsyncOperation(); // ✅ 위 아래 코드 순서 변경하면 결과 달라짐
	}

	private async simulateAsyncOperation() {
		return new Promise((resolve) => setTimeout(resolve, 50));
	}

	getCounter() {
		return this.counter;
	}
}

위 예제 코드에서 testRaceCondition 실행 후 getCounter 실행하여 this.counter 값을 확인하면 어떤 값이 나올까요?

바로 5입니다. 5개의 요청(increment 메서드에 대한)이 동시에 실행되어도 콜 스택에서 동기적인 코드는 모두 순서를 보장받으며 실행되기 때문입니다. 즉, increment 메서드의 const currentValue = this.counter; 와 this.counter = currentValue = 1;는 5개의 요청 순서대로 실행되어 결과적으로 this.counter는 5라는 값을 가지게 됩니다.

 

만약 increment 메서드를 다음과 같이 수정하면 어떻게 될까요?

	async increment() {
    		const currentValue = this.counter;
        	await this.simulateAsyncOperation(); // ✅ 위 아래 코드 순서 변경하면 결과 달라짐
        	this.counter = currentValue + 1;     // ✅ 위 아래 코드 순서 변경하면 결과 달라짐
	}

이와 같이 increment 메서드를 수정하게 되면 this.counter값은 5가 아니라 1이 반환됩니다. 왜 그럴까요?

그 이유는 콜 스택에서 동기적인 코드 const currentValue = this.counter;만 먼저 순서대로 실행되기 때문입니다. 이를 통해 5개의 요청이 아무런 변환 없는 this.counter값을 그저 요청 간 순서를 지키며 읽기만 합니다. 그럼 모든 요청의 currentValue값은 0이 됩니다. 여기서 밑에 await 코드가 반환되길 기다리죠. 그리고 해당 비동기 코드의 콜백 함수가 반환되면 그제야 this.counter = currentValue + 1;이 실행되어서 this.counter 값이 0에서 1이 되는 것입니다.

 

 

마무리

위처럼 Node.js 환경에서도 상황에 따라 race condition 문제가 발생할 수 있음을 확인했습니다. 이를 통해 코드를 설계하는 과정에서 이런 race condition 문제를 미리 예측하여 문제가 발생하지 않는 구조를 사용하도록 해야 합니다.