HTTP 응답 지연을 이용한 서버간 작업 동기화 실험

프로젝트를 진행하던 도중 새로운 요구사항이 들어왔습니다.

작업 처리량을 늘리기 위해 병렬로 구성된 서버에서 동작하도록 해주세요

병렬로 구성된 서버에서의 동작이란 복수개의 프로세스를 의미하고 이제까지의 개발 경험에 따라 동기화 문제가 없을지 생각해 보게 됩니다.

아니나 다를까 동기화를 하지 않으면 심각하진 않지만 성능상의 문제가 있을 수 있음을 발견합니다.

일반적이라면 복수개의 프로세스에 대한 동기화를 위해 mutex를 사용할테지만 이 경우 하나의 서버 내에서가 아닌 복수개의 서버 각각에 하나의 프로세스가 동작하므로 단일 서버에서만 의미를 가지는 mutex는 사용할 수 없습니다.

별도의 서버에 작업 분배를 위한 큐를 두어야 할까 고민하던 중 mutex가 만들어 주는 임계 구역을 HTTP 통신으로도 만들 수 있을 것 같다는 생각에 도달합니다.

시나리오

떠올린 시나리오는 다음과 같습니다.

코드 작성

위 시나리오대로 동작하는 서버 프로그램을 작성할 때 고려해야 할 점이 있습니다.

바로 동기화 기능을 제공하는 서버 프로그램 자체도 동기화 문제가 발생할 수 있는지 언어와 라이브러리에서 제공하는 기능을 잘 살펴보아야 합니다.

임의 시점에 들어오는 여러 클라이언트로부터의 연결을 순서대로 기록하고 키 반납시 해당 순서대로 키를 다음 대기자에게 전달하는 과정에서 경쟁 상태(race condition)가 발생하면 안됩니다.

그래서 작성된 코드가 단일 쓰레드에서 진행되는 node.js를 사용하면 동기화 문제가 발생할 여지가 크게 줄어들고 더불어 http 작업을 처리하기도 쉬워 작성 언어로 선택하였습니다.

구조

import http from 'http';
import { uid } from 'uid';
import qs from 'querystring';
import url from 'url';

const mutexMap = new Map<string, [string, (http.ServerResponse | null)[]]>();

enum Mutex {
    KEY = 0,
    WAITINGS = 1,
}

export function startServer() {
    return new Promise<Function>(function(ok) {
        const server = http.createServer(function(req, res) {
            if (req.url === undefined) {
                res.statusCode = 400;

                res.end();
            } else {
                이곳에 작성된 코드를 아래에서 상황별로 분류해 두었습니다.
            }
        }).listen(process.env.PORT, () => {
            console.log(`listening on ${process.env.PORT}`);
            ok(() => {
                server.close();
            });
        });
    });
}

뮤텍스 접근(뮤텍스가 없을 경우)

const parsedUrl = url.parse(req.url);
const pathname = parsedUrl.pathname!;
const qry = parsedUrl.query!;

if (req.method === 'GET') {
    if (mutexMap.has(pathname) === false) {
        // 뮤텍스와 해당 키를 생성 후 키 전달
        
        const u = uid();

        mutexMap.set(pathname, [u, []]);

        res.statusCode = 200;

        res.end(u);

뮤텍스 접근(뮤텍스가 이미 있을 경우)

    } else {
        // 뮤텍스 대기자 큐에 등록
        
        const mutex = mutexMap.get(pathname)!;

        mutex[Mutex.WAITINGS].push(res);

        let done = false; // error 핸들러 다음 close 핸들러가 또 호출될 경우 중복 처리 방지용
        const idx = mutex[Mutex.WAITINGS].length - 1;

        const handleError = () => {
            // key를 받기 전에 연결이 끊어진 대기자는 큐에서 null로 처리
            
            if (done === false) {
                mutex[Mutex.WAITINGS][idx] = null;
                done = true;
            }
        };

        res.on('error', handleError).on('close', handleError);
    }

뮤텍스 반납

} else if (req.method === 'PUT') {
    if (mutexMap.has(pathname) === false) {
        // 뮤텍스가 생성되지 않았을 경우
        
        res.statusCode = 400;

        res.end();
    } else {
        const mutex = mutexMap.get(pathname)!;

        if (mutex[Mutex.KEY] in qs.parse(qry) === false) {
            // 잘못된 key를 사용했을 경우

            res.statusCode = 400;

            res.end();
        } else {
            const idx = (function() {
                // 연결이 끊어지지 않고 큐에서 제일 앞에 있는 대기자의 index 찾기
                
                let idx = 0;

                for (; idx < mutex[Mutex.WAITINGS].length; idx++) {
                    if (mutex[Mutex.WAITINGS][idx] !== null) {
                        return idx;
                    }
                }

                return -1; // 대기자가 없음
            })();

            if (idx === -1) {
                // 뮤텍스 삭제
                
                mutexMap.delete(pathname);
            } else {
                // 다음 대기자에게 키 전달
                
                const res = mutex[Mutex.WAITINGS][idx]!;

                mutex[Mutex.WAITINGS][idx] = null;

                // key를 받고 정상적으로 연결이 끊어지는 경우이므로 'close' 오류 핸들러 제거
                res.removeAllListeners('close');

                res.statusCode = 200;

                res.end(mutex[Mutex.KEY]);
            }

            res.statusCode = 200;

            res.end();
        }
    }
}

결론적으로 완성된 서버 프로그램은 위 시나리오대로 잘 동작하였지만 프로젝트에 적용할 수는 없었습니다.

다음 사항들을 적절히 처리할 수 있을 때까지는 개인적으로만 사용해볼 생각입니다.

  • 키를 가진 프로세스가 키를 반납하지 않고 예상치 못한 오류로 종료될 경우
  • 언어나 라이브러리에 따라 내부적으로 적용된 타임아웃 시간이 있어 대기중이던 프로세스가 의도치 않게 타임아웃이 될 경우

김이 빠지는 글이 되어버린 것 같지만 여기서 마무리 하도록 하겠습니다.

아래 링크를 통해 전체 코드를 확인하실 수 있습니다.

https://github.com/codepage949/overlock