ThinkPHP와 Redis를 활용한 실전 개발 가이드 (초고속 판매 시나리오 포함)

ThinkPHP와 Redis 통합을 통한 고성능 서비스 구축

Redis는 인메모리 기반의 키-밸류 저장소로, 고속 데이터 접근과 원자성 연산을 통해 초고속 판매(플래시 세일)와 같은 고병렬 처리 시나리오에 최적화되어 있습니다. 이 문서에서는 PHP 기반 프레임워크인 ThinkPHP 6.0 이상 환경에서 Redis를 통합하고, 실제 초고속 판매 시스템을 구현하는 방법을 단계별로 설명합니다.

1. ThinkPHP에 Redis 설정하기

ThinkPHP에서 Redis를 사용하려면 먼저 캐시 드라이버를 설정해야 합니다. 다음은 주요 구성 과정입니다.

// config/cache.php
return [
    'default' => env('CACHE_DRIVER', 'redis'),
    'stores'  => [
        'redis' => [
            'type'       => 'redis',
            'host'       => env('REDIS_HOST', '127.0.0.1'),
            'port'       => env('REDIS_PORT', 6379),
            'password'   => env('REDIS_PASSWORD', ''),
            'select'     => env('REDIS_DB', 0),
            'timeout'    => 3,
            'persistent' => false,
            'prefix'     => 'tp_demo_',
        ],
    ],
];

환경 변수는 프로젝트 루트의 .env 파일에 별도로 정의할 수 있습니다:

CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=1

연결 테스트는 컨트롤러에서 다음과 같이 수행할 수 있습니다:

use think\facade\Cache;

class TestController
{
    public function connect()
    {
        Cache::set('ping', 'connected', 60);
        $result = Cache::get('ping');
        return response($result); // "connected" 출력
    }

    public function raw()
    {
        $client = Cache::store('redis')->handler();
        $client->setex('raw_test', 120, 'direct access');
        return $client->get('raw_test');
    }
}

2. Redis 핵심 자료구조와 ThinkPHP 활용법

문자열(String): 카운터 및 재고 관리

재고 수량과 같은 단순한 숫자 값을 관리할 때 문자열 타입의 원자적 증감 연산을 사용합니다.

// 재고 초기화
Cache::set('stock:item_1001', 50, 86400);

// 원자적 감소 (초고속 판매에서 중요)
$left = Cache::decr('stock:item_1001');

if ($left < 0) {
    Cache::incr('stock:item_1001'); // 음수 방지
    return ['status' => 'fail', 'msg' => '재고 부족'];
}

// 동시성 참여 수 집계
Cache::incr('counter:flash_sale_1001');

해시(Hash): 구조화된 정보 저장

상품명, 가격, 재고 등 여러 필드를 하나의 키 아래에 저장할 수 있습니다.

// 상품 정보 저장
Cache::hMset('product:1001', [
    'name'  => '프리미엄 이어폰',
    'price' => '129000',
    'stock' => 30
]);

// 특정 필드 조회
$name = Cache::hGet('product:1001', 'name');

// 재고 원자적 감소
Cache::hIncrBy('product:1001', 'stock', -1);

리스트(List): 비동기 작업 큐

초고속 판매 성공 후, 데이터베이스에 주문을 비동기로 저장하기 위한 큐로 사용됩니다.

// 주문 정보 큐에 삽입
$order = json_encode([
    'order_id' => uniqid('ord_'),
    'item_id'  => 1001,
    'user_id'  => 2005,
    'time'     => time()
]);

Cache::lPush('queue:order_pending', $order);

// 백그라운드 작업에서 소비 (예: 스케줄러 또는 워커)
$data = Cache::rPop('queue:order_pending');
if ($data) {
    $order = json_decode($data, true);
    // DB에 저장 로직 실행
}

세트(Set): 중복 제거

특정 사용자가 이미 초고속 판매에 참여했는지 여부를 추적합니다.

$userSetKey = 'users:flash_1001';

// 참여 시도
$added = Cache::sAdd($userSetKey, 2005);

if (!$added) {
    return ['status' => 'fail', 'msg' => '이미 참여함'];
}

// 전체 참여자 수 확인
$total = Cache::sCard($userSetKey);

정렬된 세트(Sorted Set): 실시간 순위

판매 실적에 따른 순위 집계에 적합합니다.

// 판매량 증가
Cache::zIncrBy('ranking:sales', 1, 'item_1001');

// 상위 10개 아이템 조회
$topItems = Cache::zRevRange('ranking:sales', 0, 9, true);
// 결과 예: ['item_1001' => 25, 'item_1002' => 18]

3. 초고속 판매 시스템의 핵심: 원자성 보장

초고속 판매에서 가장 중요한 것은 **재고 초과 판매**(over-selling)를 막는 것입니다. 일반적인 "조회 후 수정" 패턴은 고병렬 환경에서 위험합니다.

// ❌ 위험한 코드: 비원자적 동작
$current = Cache::get('stock:1001');
if ($current > 0) {
    Cache::set('stock:1001', $current - 1); // 경쟁 조건 발생 가능
}

대신 Redis의 원자적 명령이나 Lua 스크립트를 사용해야 합니다.

Lua 스크립트를 이용한 원자적 처리

public function flashSale($itemId, $userId)
{
    $redis = Cache::store('redis')->handler();
    $stockKey = "stock:{$itemId}";
    $userSetKey = "users:{$itemId}";

    $script = <<<LUA
local stock = tonumber(redis.call('GET', KEYS[1]))
local has_joined = redis.call('SISMEMBER', KEYS[2], ARGV[1])

if has_joined == 1 then
    return {false, "already_participated"}
end

if not stock or stock &lt;= 0 then
    return {false, "out_of_stock"}
end

redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return {true, "success"}
LUA;

    $result = $redis-&gt;eval($script, [$stockKey, $userSetKey, $userId], 2);

    if ($result[0]) {
        // 주문 큐에 추가
        $orderData = ['item' =&gt; $itemId, 'user' =&gt; $userId, 'ts' =&gt; time()];
        Cache::lPush('queue:orders', json_encode($orderData));
        return ['status' =&gt; 'success', 'msg' =&gt; '구매 완료'];
    } else {
        return ['status' =&gt; 'fail', 'msg' =&gt; $result[1]];
    }
}
</code>

4. 고급 캐싱 전략

캐시 펀치 스루(Cache Penetration) 방지

존재하지 않는 상품 ID에 대한 반복 요청을 차단하기 위해 널 값 캐싱을 사용합니다.

public function getProduct($id)
{
    $key = "product:data:{$id}";
    $cached = Cache::get($key);

    if ($cached !== null) {
        $data = json_decode($cached, true);
        return $data ? ['data' => $data] : ['error' => 'not_found'];
    }

    $dbData = Db::table('products')->find($id);
    if (!$dbData) {
        Cache::set($key, json_encode(null), 600); // 10분간 널 캐싱
        return ['error' => 'not_found'];
    }

    Cache::set($key, json_encode($dbData), 3600);
    return ['data' => $dbData];
}

캐시 브레이크아웃(Cache Breakdown) 대응

인기 상품의 캐시 만료 시 동시에 많은 요청이 몰리는 것을 방지하기 위해 분산 잠금을 사용합니다.

public function getHotProduct($id)
{
    $cacheKey = "hot:product:{$id}";
    $lockKey  = "lock:product:{$id}";

    $data = Cache::get($cacheKey);
    if ($data) {
        return json_decode($data, true);
    }

    // 잠금 시도
    if (Cache::set($lockKey, 1, ['nx', 'ex' => 3])) {
        try {
            $fresh = Db::table('products')->find($id);
            $ttl = $fresh ? 3600 : 600;
            Cache::set($cacheKey, json_encode($fresh), $ttl);
        } finally {
            Cache::delete($lockKey);
        }
        return $fresh;
    }

    // 잠금 실패 → 짧은 지연 후 폴백 또는 기본값 제공
    usleep(50000);
    return Cache::get($cacheKey) ? json_decode(Cache::get($cacheKey), true) : null;
}

태그: Redis ThinkPHP PHP 초고속판매 캐시관리

7월 21일 05:30에 게시됨