본문 바로가기

개발/Node.js

[Node.js] Race Condition과 해결 원리

본 글에서는 Node.js에서 발생할 수 있는 Race Condition에 대해 작성하려고 한다. + 첫 프로젝트인 새싹챌린지를 진행하면서 겪었던 일까지


1. Race Condition이란?

Race Condition(경쟁 상태) 혹은 경쟁 조건이라고도 불리우며 로 둘 이상의 스레드, 프로세스 그외 작업들이 공유 자원(변수, 메모리, 파일 등)에 대해 동시에 접근할 때 누가 언제 데이터를 읽거나 쓰느냐에 따라 결과가 달라질 수 있는 문제를 이야기 한다.

 

다음은 간단한 예시이다. 나의 잔액은 10만원이 있다. 이 잔액에는 여러 사람들이 동시에 입금할 수 있다.

두 사람이 각각 거의 동시에 30만원, 10만원을 입금했다고 가정하자.

입금이 확인 되면 내 잔액을 확인하고 내 잔액 = 잔액+입금 금액(myAmount = myAmount +depositAmount)가 발생한다.

나는 분명 30만원 , 10만원을 입금 받아 총 50만원이 되어야 하는데 현재 잔액은 20만원이 남아있다. 이것이 공유 자원에 동시성으로 접근할 때 발생할 수 있는 문제점이다.(만약 실제 은행에서 이런일이 발생한다면 정말 큰일 날 것이다.)


2. Node.js에서 Race Condition

 

간단한 예시를 통해 Race Condition이 무엇인지 알게 되었다.

이제 글쓴이의 Node.js 프로젝트에서 Race Condition이 발생했던 코드를 올려본다.

 

다음은 실제 프로젝트 때 사용한 코드이며

사용자가 문제를 성공적으로 풀어 문제의 정답을 얻어낸 뒤 인증을 하여 풀이자 명단에 올리는 submitflag 함수 API 로직중 일부이다.

exports.submitflag = async function submitflag(req) {
    try {
        /*
        * 생략
        */
        // Flag는 유저가 요청한 문제 정답
        // pro_salt.ChSalt는 해당 문제의 솔트값과 유저가 요청한 정답을 암호화 하여 해시로 저장
        const hash = bcrypt.hashSync(Flag, pro_salt.ChSalt);

        const pro_flag = await wargame_info.findOne({
            where: { ChFlag: hash },
            attributes: ["ChID", "ChFlag", "ChScore", "ChCategory", "ChSolver"]
        });
        /**
        * 없는 문제, 플래그가 없는 문제일시 
        * return false
        */
        if (!pro_flag) {
            return false;
        }
        if (!pro_flag.ChFlag) {
            return false;
        }
        /*
        * 문제의 플래그(pro_flag.chFlag)와 내가 요청한 플래그 값(hash)이 다를 경우
        * return false
        */
        if (pro_flag.ChFlag !== hash) {
            return false;
        }
        if (pro_flag.ChFlag === hash) {
            if (req.user.permit == 1) {
                return true;
            }
            const { ID } = req.user;
			/**
            * 먼저 해당 유저의 ID와 문제 번호를 검색하여 풀이자인지 확인한다.
            *
            */
            const overlap = await solver_table.findOne({
                where: { ChID: pro_flag.ChID, ID }
            }); //중복풀이 방지
			/*
            * 풀이자일 경우
            */
            if (overlap) {
                return false;
            } else {
            /**
            * 유저의 정보를 검색하여 해당 유저의 정보에 문제 점수를 추가하는 함수
            */
                const SScore = await user_info.findOne({
                    where: { ID },
                    attributes: ["Score"]
                });

                await user_info.update(
                    // 원래 있던 점수 + 지금 문제 점수
                    {
                        Score: SScore.Score + pro_flag.ChScore,
                        solved_at: Date.now()
                    },
                    {
                        where: { ID: ID },
                        solved_at: Date.now()
                    }
                );
                await solver_table.create({
                    ChID: pro_flag.ChID,
                    ID,
                    Nick: req.user.Nick,
                    created_at: Date.now(),
                    ChCategory: pro_flag.ChCategory,
                    ChIDNick:pro_flag.ChID+req.user.Nick
                });
                await wargame_info.update(
                    // 푼 문제 솔버+1
                    {
                        ChSolver: 1 + pro_flag.ChSolver
                    },
                    {
                        where: { ChFlag: hash }
                    }
                );

                return true;
            }
        }
    } catch (err) {
        console.log(err);
    }
};

읽기 긴 코드를 요약 해보면

  1. 사용자가 요청한 정답과 문제의 정답이 맞는지 비교한다.
  2. 맞을 경우 문제풀이 완료 테이블에 사용자가 풀었던 문제인지 문제 풀이 완료 테이블을 확인한다.
  3. 풀었던 문제일 경우 false를 리턴한다
  4. 풀지 않은 문제일 경우 유저의 점수를 현재 유저 점수 + 문제 점수로 업데이트 후 문제 풀이 완료 테이블에 문제에 번호와 유저를 저장하고 true를 리턴한다.

겉보기에는 문제가 없는 로직 같아 보이지만 문제는 중복 풀이자인지 확인할 때 발생한다.


3. 이유는 다음과 같다.

Node.js는 기본적으로 싱글 스레드의 구조를 가지고 있지만 Non-blocking 모델로 구성되어 있음으로 비동기로 수행 할 수 있다.

 

따라서 비동기 처리를 위해 함수는 async, DB 접근은 await를 통해 동기적으로 콜백을 처리했다.

 

앞서 설명했던 Race Condition 이라는 동시성 문제가 발생한 것이다.

 

중복 풀이를 방지하고 확인하는 로직이 진행된 이후 유저의 정보에 문제 점수를 추가하는 로직이 진행되는 동안 다른 스레드가 같은 함수에 들어와서 작업이 먼저 처리 될 있다. 그리고 아직은 문제를 풀었다고 DB에 데이터가 들어간 것이 아니므로 overlap 함수 요청시 아직 풀이되지 않은 사용자로 인식하여 2번의 값이 들어갈 수 있는 것이다.

 

더 자세한 내용은 해당 글[1]에 작성되어 있다.

 

부끄럽지만, 이는 다음 코드로 해결하였다...

await solver_table.create({
                    ChID: pro_flag.ChID,
                    ID,
                    Nick: req.user.Nick,
                    created_at: Date.now(),
                    ChCategory: pro_flag.ChCategory,
                    ChIDNick:pro_flag.ChID+req.user.Nick
                });

ChIdNick이라는 고유한 유니크 컬럼을 만들고 그 컬럼에 ChId와 유저의 닉네임(고유 별명)를 넣어주었다.

(사실 이 부분은 프론트에는 보여지지 않을 부분이라 고유 ID로 처리하는게 맞는 것 같다.)

 

이를 통해 DB에 먼저 한번 엑세스 된 후 다음 DB 엑세스시 unique 컬럼으로 인해 데이터 값이 들어가지 않도록 막아줄 수 있었다...

-> 서버 로직에서 처리하는 것이 아닌 DB 엑세스 과정에서 처리 하도록 유도하게 된 것... 책임을 넘긴 느낌이었다.

 

그러나 이는 올바른 해결 방법도 아니고 try catch 블록에서 예외가 발생한다.

(값이 들어가지 않고 DB가 예외를 뱉어냈기 때문)


4. 그렇다면 이를 어떻게 처리해야 할까?

실제 운영체제에서는 경쟁상태를 해결하기 위해 mutex, semaphore, monitor 등의 방법을 사용할 수 있다.

Node.js에서는 mutex, semaphore 두 방식들을 구현하여 라이브러리로 개발되어 있다.

 

과정은 다음과 같다.

1. 트랜잭션 작업 단위를 만든다.

2. 해당 트랜잭션이 작업하는 동안 다른 트랜잭션 혹은 메소드 쓰레드 등이 사용할 수 없게 잠근(Lock)다.

3. 해당 트랜잭션이 끝나면 커밋하고 잠금을 해제한다.

4. 다른 작업이 진행된다.

 

1. 트랜잭션 작업 단위를 만든다.

트랜잭션이란? 더이상 나누어질 수 없는 최소한의 단위 이다.

예:) 돈을 입금하는 로직이 있다면 돈을 받는 로직도 반드시 실행되어야 한다. 그렇기에 두 단위는 더이상 나누어서 작업을 처리할 수 없다.

입금은 했는데 돈이 늘어나지 않으면 전산상의 오류가 된다. 성공적으로 실행되어 둘 다 커밋되거나 오류가 날 경우 롤백 되어야만 한다.

 

위 코드에서는 submitflag 함수 내에 있는 모든 로직이 하나의 트랜잭션이다.

유저가 문제를 풀었으면 user_info, solver_table, wargame_info 3개의 테이블이 모두  업데이트 되어야 한다. 만약 그렇지 않으면 데이터 정합성에 문제가 생길 수 있다.

 

 

2. 해당 트랜잭션이 작업하는 동안 다른 트랜잭션 혹은 메소드 쓰레드 등이 사용할 수 없게 잠근(Lock)다.

문제가 생긴 이유는 submitflag 함수가 다 처리되기도 전에 다른 작업이 들어와서 처리하였기 때문이다. 그렇기 때문에 한번에 하나의 쓰레드만 접근할 수 있게 잠근다. 잠근 상태에서는 다른 쓰레드가 들어오지 못하도록 막는 것이다. 이런 방법을 상호배제라고 한다.

 

잠글 수 있는 두 방식을 소개한다.


첫번째는 async-mutex 라이브러리 사용이다.

npm install --save async-mutex

먼저 라이브러리를 설치한다.

 

async-mutex 라이브러리에서 mutex와 semaphore 둘다 지원한다. 사용 방법은 다음과 같다.

 

그러나 본 글에선 해당 예제[1]를 기반으로 설명한다.

import { Mutex } from 'async-mutex'

const mutex = new Mutex() // 공유하는 뮤텍스 인스턴스 생성한다.

async function doingSomethingCritical() {
  const release = await mutex.acquire() // 중요 경로에 대한 액세스 권한을 획득한다.
  try {
    // ... mutex 환경에서 작업할 코드를 작성한다.
  } finally {
    release() // 해당 작업에서 수행한 작업이 완료되었다.
    //이제 잠금이 해제 되고 다른 스레드나 작업이 접근할 수 있게 되었다.
  }
}

여기서 중요한 것은 mutex.acquire(); 함수와 release(); 함수이다.

 

await mutex.acquire(); 함수를 통해 doingSomethingCritical() 함수는 promise가 해결될 때 까지 다음 작업을 진행할 수 없다.

다음 작업을 진행하기 위해 acquire(); 메소드에 대해 알아보자.

 

async-mutex npmjs 사이트[2]를 확인해보면 mutex.acquire(); 함수에 대한 설명을 다음과 같이 작성해 놓았다.

acquire returns an (ES6) promise that will resolve as soon as the mutex is available. 
// acquire는 뮤텍스를 사용할 수 있게 되면 즉시 해결되는 (ES6) 약속을 반환한다.

The promise resolves with a function release that must be called once the mutex should be released again. 
//약속은 뮤텍스가 다시 해제되어야 할 때 호출되어야 하는 함수 해제로 해결한다.

The release callback is idempotent.
//콜백은 멱등적이다.

즉 뮤텍스를 사용할 수 있어야 반환을 한다는 것이다. 즉 뮤텍스를 사용하지 못하면 release 변수에 반환하지 않으니 무한대기 상태가 되고 다음 로직이 진행되지 않는 것.

 

 

어떻게 순서대로 뮤텍스를 반환할까? acquire(); 메소드에 대한 구현 방식 알아보기

더보기

조금 더 설명을 보태보자면 mutex의 acquire을 사용할 경우 내부적으로 먼저 들어온

다음과 같이 2차원 배열을 활용하여 자체 구현한 Queue를 사용하고 있다.

interface QueueEntry {
    resolve(result: [number, SemaphoreInterface.Releaser]): void;
    reject(error: unknown): void;
}
private _weightedQueues: Array<Array<QueueEntry>> = [];

처음 생각에는 단순히 Queue로 구현하여 먼저 들어온 요청부터 접근 권한을 주고 락을 걸고 대기하다가 권한이 회수되면 다음 요청에 권한을 주고 락을 거는 방식으로 구현한 줄 알았으나

2차원 배열을 이용 하였으며, _weightedQueues 변수에 들어온 순서대로 resolve 함수와 reject 함수가 발생할 수 있다.

이는 비동기 방식으로 먼저 작업이 완료된 요청을 resolve 함수를 통해 먼저 결과를 반환할 수 있는 것이다.

자세한 내용은 추후 [Node.js] async-mutex의 메소드를 분석한 글에서 작성하겠다.

 


 

release();함수 또한 mutex를 해제하는 함수이다. 해제가 되어야 다른 애플리케이션이 진행 될 수 있기 때문이다.

만약 release를 호출하지 못할 경우 Mutex 자체가 Lock이 걸릴 수 있기 때문에 교착 상태(DeadLock)에 걸릴 수 있다. 그렇기 때문에 항상 release()를 호출 해 주어야 한다.

 

그 외에도 다음과 같은 기능들이 있다.

import { Mutex , E_CANCLED } from 'async-mutex'

const mutex = new Mutex()
mutex.release(); 	// 직접 뮤텍스를 해제한다.
mutex.isLocked();	// 뮤텍스를 사용중인지 확인한다.
E_CANCELED 객체를 통해 보류 중인 잠금을 해제할 수 있다.

 

이를 통해 뮤텍스를 사용할 때만 함수가 진행 될 수 있도록 코드를 구현하고 예외 처리 통해 교착 상태(DeadLock)도 해결할 수 있다.

 

위 예제 뿐만 아니라 async-mutex 라이브러리에서는  mutex와 semaphore 둘다 지원한다. 다음 처럼 사용할 수 있다.

var Mutex = require('async-mutex').Mutex;
var Semaphore = require('async-mutex').Semaphore;

 

더 자세한 내용과 사용법들은 해당 글[2]을 참고하길 바란다.

 


두번째는 semaphore 라이브러리 사용이다.

npm install semaphore

이 또한 라이브러리를 설치한다.

 

사용방법은 간단해 보이는데...

구글링을 해도 이를 사용한 예시나 레퍼런스 등이 안보이고

공식문서[3] 조차 아무런 설명 없이 그저 다음 처럼 사용하라고만 나와있다...

// Create
var sem = require('semaphore')(capacity);
 
// Take
sem.take(fn[, n=1])
sem.take(n, fn)
 
// Leave
sem.leave([n])
 
// Available
sem.available([n])

 

공식문서 소스 기반으로 유추 해보면

require('semaphore')(2); // 최대 세마포어의 갯수

sem.take(); // 세마포어를 실행한다. 최대로 설정한 세마포어 갯수 까지만 실행 가능하다.

sem.leave(); // 세마포어를 종료한다.

sem.available; // 사용한 부분은 없지만 영어 뜻을 해석하면 아마 해당 함수에서 사용 가능한 세마포어 개수로 추측한다.

 

해당 라이브러리에 대한 github 링크[4]에 들어가면 소스코드를 볼 수 있다. 

 


5. 마치며

+ async-mutex의 경우 await mutex.acquire(); 함수를 사용하면 공유하는 객체의 메소드내에 들어온 순서대로 queue에 쌓인다고 한다. 큐에 들어온 순서대로 대기하고 실행한다.

 

앞으로 동시성 문제가 발생할 수 있는 모든 부분에서는 Lock을 걸도록 하자.. 그리고 꼭 해제 하는 코드를 작성하자..

 

코드를 그냥 가져다 쓰고 사용할 줄 알면 얼추 해결할 수 있는 부분이라고도 생각한다.

그러나 이 해결하는 부분과 과정은 결국 컴퓨터 과학 기반(OS, 자료구조 등)의 원리로 해결하고 처리하기 때문에 기본기가 탄탄한 것이 중요하다고 생각한다.

 


[1]https://www.nodejsdesignpatterns.com/blog/node-js-race-conditions/

 

Node.js race conditions

Can there be race conditions with Node.js? Actually yes, let's see some examples and some solutions.

www.nodejsdesignpatterns.com

[2]https://www.npmjs.com/package/async-mutex

 

async-mutex

A mutex for guarding async workflows. Latest version: 0.4.0, last published: 7 months ago. Start using async-mutex in your project by running `npm i async-mutex`. There are 592 other projects in the npm registry using async-mutex.

www.npmjs.com

[3]https://www.npmjs.com/package/semaphore

 

semaphore

semaphore for node. Latest version: 1.1.0, last published: 6 years ago. Start using semaphore in your project by running `npm i semaphore`. There are 257 other projects in the npm registry using semaphore.

www.npmjs.com

[4]https://github.com/abrkn/semaphore.js/blob/master/lib/semaphore.js

 

GitHub - abrkn/semaphore.js

Contribute to abrkn/semaphore.js development by creating an account on GitHub.

github.com