동시 요청으로 인한 중복 실행을 분산 락 없이 막기

백엔드2026년 8월 27일

들어가며

같은 요청이 거의 동시에 두 번 들어오는 상황은 어느 서비스에나 있다. 버튼을 두 번 누르거나, 응답이 느려서 새로고침하거나, 실패한 것처럼 보여 다시 시도하는 경우다. 대부분은 두 번째 요청이 조용히 무시되면 그만이지만, 그렇지 않은 경우도 있다.

현재 운영 중인 가상화폐 자동매매 플랫폼에서 사용자가 봇을 시작하면 서버는 그 봇을 위한 엔진 컨테이너를 하나 띄운다. 이 컨테이너는 사용자의 거래소 API 키를 받아 실제 주문을 넣고, 켜져 있는 동안 사용료가 과금된다.

그래서 같은 봇이 두 번 시작되면 단순히 리소스가 낭비되는 정도로 끝나지 않는다. 컨테이너 두 개가 같은 전략을 각자 실행하므로 의도한 두 배의 주문이 나갈 수 있고, 과금도 두 번 된다. 게다가 정지시킬 때는 서버가 알고 있는 컨테이너 하나만 멈추기 때문에, 나머지 하나는 고아 컨테이너가 되어 사용자도 모른 채 매매를 이어가게 된다.

이 문제를 초기에는 Redis 분산 락으로 막았다. 그리고 2년 뒤 코드베이스를 다시 쓰면서 해당 락을 걷어냈다. 락을 더 잘 쓰는 법을 찾은 것이 아니라, 락이 필요 없는 문제였다는 결론에 도달했기 때문이다.

이 글에서는 그 과정을 정리해보고자 한다.

이미 있던 검사가 막지 못한 것

문제는 대시보드에서 드러났다. 봇을 시작하면 버튼이 "시작"에서 "정지"로 바뀌는데, 그 전환이 끝나기 전에 버튼을 한 번 더 누르면 엔진 컨테이너가 두 개 떴다. 시작 요청은 컨테이너를 다 띄우고 나서야 응답을 돌려주기 때문에 버튼이 곧바로 바뀌지 않는다. 그동안 화면은 누르기 전과 똑같아 보이니, 눌리지 않았다고 생각해 한 번 더 누르게 된다.

그런데 당시 봇 시작 핸들러에는 이미 중복 실행을 막는 코드가 있었다. 봇을 조회해서 실행 중임을 나타내는 필드가 채워져 있으면 거절하는 방식이었다.

문제는 이 검사가 읽기와 쓰기 사이에 틈이 있다는 점이었다. 요청 두 개가 거의 동시에 들어오면 다음과 같은 순서가 가능하다.

요청 A: 봇 조회 → engineId 비어 있음 → 통과
요청 B: 봇 조회 → engineId 비어 있음 → 통과 (A가 아직 채우기 전)
요청 A: 컨테이너 spawn → engineId 기록
요청 B: 컨테이너 spawn → engineId 덮어씀

위와 같은 상황에서는 두 요청 모두 검사를 통과하고, 결과적으로 컨테이너가 두 개 뜨게 된다. 그리고 나중에 기록된 engineId가 앞의 것을 덮어쓰기 때문에 먼저 뜬 컨테이너는 서버가 추적하지 못하는 상태가 된다. 그렇기에 정지 요청은 나중에 뜬 컨테이너만 멈출 수 있게 된다.

분산 락으로 막기

당시 선택한 해법은 Redis 분산 락이었다. 봇 단위로 락을 잡고, 그 안에서 검사와 시작을 수행하는 구조다.

const redlock = new Redlock([redisClient]);

const startBot = async (req, res) => {
    try {
        const { botId } = req.body;
        const userId = req.user._id;
        const lockKey = `lock:bot:${botId}`;

        const lock = await redlock.acquire([lockKey], 5000);

        const bot = await botService.getBotByUserId(userId, botId);
        const { flowId, exchangeId, ..., engineId } = bot;

        // do not create new bot instance if already running
        if (engineId) {
            await lock.release();
            return res.status(400).json(buildResponse(false, undefined, ResponseCode.BOT_ALREADY_RUNNING));
        }

        const startResult = await botService.startBot(userId, botId, flowId, ...);
        if (!startResult.id) throw new Error(`Unable to create an engine instance: ${engineId}`);

        await lock.release();
        return res.json(buildResponse(true));
    } catch (e) {
        logger.error(e);
        res.status(500).json(buildResponse(false, undefined, ResponseCode.SERVER_ERROR));
    }
};

이것으로 앞서 말한 현상은 해결할 수 있었다. 그리고 실제로 2년 가까이 이 코드로 운영했다.

그 락에 있던 세 가지 허점

지금 다시 보면 이 구현에는 세 가지 문제가 있다.

첫 번째는 락 해제가 finally 블록에 없다는 것이다. 위 코드에서 lock.release()는 두 곳에서 호출된다. 이미 실행 중이라 거절할 때, 그리고 정상적으로 끝날 때다. 그런데 getBotByUserIdstartBot이 예외를 던지면 실행은 catch로 넘어가고 락은 해제되지 않는다. 이 경우 락은 TTL인 5초가 지나야 풀린다. 봇 시작이 실패한 사용자는 5초 동안 재시도가 막히고, 그동안 원인은 로그에만 남게 된다.

두 번째는 작업이 끝나기 전에 락이 스스로 풀릴 수 있다는 것이다.

const lock = await redlock.acquire([lockKey], 5000);

이 락은 잡은 지 5초가 지나면 자동으로 풀린다. 그리고 acquire()로 잡은 락은 시간이 다 되어도 알아서 연장되지 않는다.

그런데 락을 잡고 나서 하는 일 중에 컨테이너를 띄우는 작업이 있다. 앞에서 버튼이 늦게 바뀌는 이유로 설명했던 그 작업이다. 이게 5초를 넘기면, 일이 아직 끝나지 않았는데 락이 먼저 풀려버린다.

그렇게 되면 뒤이어 들어온 요청에서 락을 정상적으로 잡는다. 그리고 봇을 조회하는데, engineId는 아직 비어 있다. 먼저 들어온 요청이 컨테이너를 다 띄운 뒤에야 채우기 때문이다. 그래서 검사를 통과하고, 컨테이너를 하나 더 띄운다. 그 결과 락을 붙이기 전과 같은 결과를 초래하게 된다.

세 번째는 단일 Redis 노드에 Redlock을 썼다는 것이다.

const redlock = new Redlock([redisClient]);

Redlock은 원래 Redis 마스터 여러 대에 락을 걸어 과반이 성공해야 획득으로 치는 알고리즘이다. 마스터 하나가 죽어도 락이 유지되게 하려는 설계인데, 노드를 하나만 넘기면 그 과반 구조가 통째로 무의미해진다. 결국 하는 일은 SET key value NX PX 5000 한 줄과 같으면서, 라이브러리 의존성만 더 얹은 셈이 된 것이다.

세 가지를 나란히 놓고 보면 하나가 분명해지는데, 락을 정확하게 다루려면 신경 쓸 것이 생각보다 많다는 것이다. 해제 경로를 빠짐없이 처리해야 하고, TTL은 락 안에서 하는 일보다 길면서도 실패했을 때 대기가 길어지지 않을 만큼 짧아야 하며, Redlock처럼 특정한 구성을 전제하는 도구라면 그 구성을 실제로 갖춰야 한다.

그래서 코드베이스를 다시 쓸 때는 락을 보완하는 대신, 이 락이 실제로 무엇을 지키고 있었는지부터 확인하기로 했다.

락이 실제로 지키던 것

핸들러 전체를 락으로 감쌌지만, 실제로 경합이 나는 지점은 engineId가 비어 있는지 확인하고, 비어 있으면 채우는 동작 하나뿐이었다. 이후 로직들은 앞에서 통과하지 못하면 도달하지 않기에 애초에 고려할 필요가 없다.

그리고 "비어 있으면 채운다"는 조건부 갱신은 데이터베이스가 원자적으로 할 수 있는 일이다. UPDATE ... WHERE engineId IS NULL 한 문장이면 동시에 들어온 요청 중 정확히 하나만 갱신에 성공한다. 굳이 밖에서 락을 잡아 순서를 만들어줄 필요가 없다.

조건부 UPDATE로 선점하기

그래서 락 대신 선점(claim) 이라는 방식을 선택했다. engineId를 미리 만들어둔 뒤, 정지 상태인 봇에만 그 값을 채우는 조건부 갱신을 시도하는 방식이다.

async claimStart(id: string, userId: string, engineId: string) {
    const { count } = await this.prisma.bot.updateMany({
        where: { id, userId, engineId: null },
        data: { engineId },
    });
    return count === 1;
}

update가 아니라 updateMany를 쓴 것은 반환값 때문이다. Prisma의 update는 조건에 맞는 행이 없으면 예외를 던지지만, updateMany는 갱신된 행 수를 돌려준다. 그러므로 count가 그대로 선점 성공 여부를 나타내는 지표가 된다. 1이면 성공한 것이고, 0이면 다른 요청이 이미 채운 것이다.

호출부는 이렇게 된다.

const engineId = randomUUID();
const claimed = await this.botService.claimStart(bot.id, user.id, engineId);
if (!claimed) throw new ConflictException(ResponseCode.BOT_ALREADY_RUNNING);

코드 첫 줄에서 engineId를 직접 만들고 있는데, 이 위치도 선점을 위해 바뀐 것이다.

이전에는 이 식별자를 컨테이너를 띄우는 쪽에서 만들었다. 봇 시작이 요청되면 그 안쪽에서 engineId를 생성해 컨테이너에 환경변수로 넘기고, 컨테이너를 다 띄운 뒤에야 그 값을 돌려주는 구조였다. 그래서 요청을 받은 쪽은 spawn이 끝나기 전까지 engineId를 알 수 없었고, DB에 기록하는 것도 그 뒤에야 가능했다.

그런데 선점은 채워 넣을 값이 먼저 있어야 성립한다. 식별자가 spawn 이후에 생기면 선점할 시점에 쓸 값 자체가 없다. 그래서 식별자를 만드는 위치를 컨트롤러로 끌어올렸다. randomUUID()로 값을 먼저 정하고, 그 값으로 자리를 선점한 다음 같은 값을 그대로 컨테이너에 넘기도록 수정했다.

실패했을 때 되돌리기

락이 사라지면서 대신 신경 쓸 것이 생겼다. 선점에 성공한 뒤 이어지는 단계가 실패하면 선점을 되돌려놔야 한다. 그러지 않으면 봇은 실행 중도 아니면서 engineId가 채워진 채로 남아 다시는 시작할 수 없게 된다.

되돌리는 쪽도 조건부 갱신으로 수행한다.

async releaseStart(id: string, engineId: string) {
    return this.prisma.bot.updateMany({
        where: { id, engineId },
        data: { engineId: null },
    });
}

whereengineId를 함께 거는 것이 중요하다. 조건 없이 비우면 되돌리기가 뒤늦게 실행됐을 때 그 사이 다른 요청이 정상적으로 선점해둔 값까지 지워버릴 수 있다. 자기가 채운 값일 때만 비우도록 하면 그 문제가 사라진다.

이 방법이 통하는 조건

조건부 갱신으로 락을 대체할 수 있었던 것은 지켜야 할 규칙이 DB 행 하나 안에서 끝나기 때문이다. 봇 하나에 해당하는 행의 engineId만 보면 되고, 다른 행이나 다른 테이블을 함께 확인할 일이 없다. 그래서 "비어 있으면 채운다"가 UPDATE 한 문장으로 그대로 옮겨 적히고, 동시에 들어온 요청 사이의 순서는 DB가 알아서 정해준다.

반대로 규칙이 여러 행이나 여러 테이블에 걸쳐 있으면 이 방법만으로는 부족하다. 그때는 트랜잭션이나 유니크 제약 같은 다른 도구가 필요하고, 경우에 따라서는 정말로 락을 써야 할 수도 있다.

락을 걷어낸 대가도 있다. 락은 시작부터 끝까지를 감싸주지만 선점은 한 지점만 지킨다. 그래서 그 뒤에 이어지는 단계가 실패하면 직접 되돌려야 하고, 되돌리기 자체가 실패하는 경우까지는 지금 처리하지 않는다. 그렇게 되면 봇은 시작할 수 없는 상태로 남는다.

다만 이건 락을 쓰던 시절에도 마찬가지였다. 락은 순서를 만들어줄 뿐 실패한 작업을 되돌려주지는 않는다. 차이가 있다면 락은 시간이 지나면 알아서 풀렸고, 선점은 명시적으로 지우지 않는 한 남는다는 점이다. 알아서 풀리는 쪽이 편해 보이지만, 그 자동 해제가 잘못된 상태까지 정리해주지는 않는다.

돌아보며

초기 구현에서는 봇 시작 과정 전체를 락으로 감쌌다. 어디서 경합이 나는지 따져보기 전이었으니, 문제가 생길 만한 구간을 통째로 묶어둔 것에 가깝다. 실제로 증상은 사라졌고, 그 상태로 2년을 운영했다.

지금은 부딪히는 지점이 한 곳뿐이라는 것을 알고 있다. 그래서 거기만 막으면 되고, 그 한 곳을 지키는 일은 데이터베이스의 조건부 갱신으로 충분하다. 순서를 만들어줄 도구를 밖에서 따로 들여올 이유가 없었다.

락이 잘못된 도구였다고는 생각하지 않는다. 경합이 어디서 나는지 특정하지 못한 상태에서는 구간 전체를 막는 것 말고 마땅한 선택지가 없다. 그 지점을 정확히 짚고 난 뒤에야 더 작은 수단으로 충분하다는 것을 알 수 있었다.

댓글