홈으로 돌아가기

Redis에서 PHP 세션 잠금: SessionHandler

기사는 phpredis의 race condition 문제와 SET NX와 Lua 스크립트로 locking mechanism 구현한 RedisSessionHandler를 설명합니다. 전체 클래스 코드, 테스트 스크립트, 프로덕션 최적화 추천 제공.

락 포함 Redis PHP 세션: 전체 가이드
Advertisement 728x90

Redis에서 PHP 세션 락킹: 커스텀 SessionHandler 구현

phpredis 확장은 앱 코드를 변경하지 않고도 PHP 세션을 Redis에 저장할 수 있게 해줍니다. 하지만 락킹 기능이 없습니다. 파일 저장 방식처럼 세션을 열 때 파일을 잠그는 것과 달리, Redis에서는 동시 요청들이 서로의 데이터를 덮어쓰게 됩니다.

100개의 비동기 요청이 각각 세션 파라미터를 추가할 때, 20~40개의 항목이 손실됩니다. 심지어 두 개의 동시 요청만으로도 데이터가 망가집니다.

경쟁 조건(race condition)을 보여주는 테스트 스크립트:

Google AdInline article slot
<?php

session_start();

$cmd = $_GET['cmd'] ?? ($_POST['cmd'] ?? '');

switch ($cmd) {
    case 'result':
        echo(count($_SESSION));
        break;
    case "set":
        $_SESSION['param_' . $_POST['name']] = 1;
        break;
    default:
        $_SESSION = [];
        echo '<script src="https://code.jquery.com/jquery-1.11.3.js"></script>\n<script>\n$(document).ready(function() {\n    for(var i = 0; i < 100; i++) {\n    $.ajax({
        type: "post",
        url: "?",
        dataType: "json",
        data: {
            name: i,
            cmd: "set"
        }
    });\n    }\n\n    res = function() {\n        window.location = "?cmd=result";\n    }\n\n    setTimeout(res, 10000);\n});\n</script>';
        break;
}

결과: 100개 대신 60~80개의 파라미터만 저장됩니다. 이 때문에 동시 접근이 있는 프로덕션 시스템에서 phpredis는 적합하지 않습니다.

Redis 락킹 메커니즘

스핀락(spinlock)을 지원하는 커스텀 SessionHandler. 락은 SET NX(존재하지 않을 때만 설정)와 uniqid()로 생성한 고유 토큰을 사용합니다.

lockSession() 로직:

Google AdInline article slot
  • 고유 토큰 생성
  • 락 키: {sessionId}.lock
  • usleep() 지연으로 최대 실행 시간의 70%까지 재시도 루프
  • 키가 존재하지 않을 때만 성공
protected function lockSession($sessionId)
{
    $attempts = (1000000 * $this->lockMaxWait) / $this->spinLockWait;
    $this->token = uniqid();
    $this->lockKey = $sessionId . '.lock';
    for ($i = 0; $i < $attempts; ++$i) {
        $success = $this->redis->set(
            $this->getRedisKey($this->lockKey),
            $this->token,
            [
                'NX',
            ]
        );
        if ($success) {
            $this->locked = true;
            return true;
        }
        usleep($this->spinLockWait);
    }
    return false;
}

Lua 스크립트로 원자적 토큰 확인 후 unlock:

private function unlockSession()
{
    $script = <<<LUA
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
LUA;
    $this->redis->eval($script, array($this->getRedisKey($this->lockKey), $this->token), 1);
    $this->locked = false;
    $this->token = null;
}

이 방식으로 다른 사람의 락을 제거하는 실수를 방지합니다.

전체 RedisSessionHandler 구현

SessionHandlerInterface를 구현한 클래스에 락 로직 포함:

Google AdInline article slot
  • 생성자: Redis 초기화, gc_maxlifetime에서 TTL 설정, lockMaxWait = 최대 실행 시간의 70%
  • read(): 읽기 전에 락 획득
  • write(): TTL과 함께 setex 또는 set
  • close(): 락 해제
  • destroy(): 세션 삭제 + close()
  • gc(): Redis에 위임

전체 코드:

class RedisSessionHandler implements \SessionHandlerInterface
{
    // ... (프로덕션 준비 완료된 전체 코드 ~250+ 라인)
}

(완전한 클래스 코드는 원문에 있습니다. 프로덕션 사용에 필수적입니다.)

설정 및 최적화

초기화:

$redis = new Redis();
if ($redis->connect('11.111.111.11', 6379) && $redis->select(0)) {
    $handler = new \suffi\RedisSessionHandler\RedisSessionHandler($redis);
    session_set_save_handler($handler);
}

session_start();

테스트 결과:

  • 100개 파라미터 모두 안정적으로 저장
  • 처리 시간 증가 미미

프로덕션 최적화:

  • 필요할 때만 session_start() 호출
  • 세션 작업 후 session_write_close() 사용
  • spinLockWait 튜닝 (기본 200μs)
  • 스크립트 실행 시간에 맞춰 lockMaxWait 설정

주요 요점

  • 기본 phpredis는 동시성에서 락 없이 데이터 손실
  • SET NX + Lua unlock으로 커스텀 SessionHandler가 해결
  • 실제 부하에서 락킹 오버헤드 최소
  • session_write_close()로 세션 적시에 종료
  • 세션 TTL은 ini_get('gc_maxlifetime')에서 가져옴

— Editorial Team

Advertisement 728x90

다음 읽기