Redis에서 PHP 세션 락킹: 커스텀 SessionHandler 구현
phpredis 확장은 앱 코드를 변경하지 않고도 PHP 세션을 Redis에 저장할 수 있게 해줍니다. 하지만 락킹 기능이 없습니다. 파일 저장 방식처럼 세션을 열 때 파일을 잠그는 것과 달리, Redis에서는 동시 요청들이 서로의 데이터를 덮어쓰게 됩니다.
100개의 비동기 요청이 각각 세션 파라미터를 추가할 때, 20~40개의 항목이 손실됩니다. 심지어 두 개의 동시 요청만으로도 데이터가 망가집니다.
경쟁 조건(race condition)을 보여주는 테스트 스크립트:
<?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() 로직:
- 고유 토큰 생성
- 락 키:
{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를 구현한 클래스에 락 로직 포함:
- 생성자: 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
아직 댓글이 없습니다.