n8n 병렬 요청에서 WordPress update_option 데이터가 사라질 수 있었던 이유

무료 게임 수집기는 스토어와 지역별로 데이터를 가져온 뒤 WordPress에 결과를 저장한다. Steam KR, Steam JP, Epic KR처럼 수집 단위는 서로 독립적이지만, 초기 저장 구조는 그렇지 않았다.

당시에는 모든 결과가 하나의 WordPress option 안에 들어갔다.

$snapshot = get_option(self::OPTION, []);
if (! is_array($snapshot)) {
    $snapshot = [];
}

$snapshot['sources'][$store][$region] = [
    'checked_at' => time(),
    'games' => array_slice($items, 0, 40),
];

update_option(self::OPTION, $snapshot, false);

요청 하나만 실행할 때는 이상해 보이지 않는 코드다. option을 읽고, 필요한 부분만 바꾼 뒤 다시 저장한다.

문제는 여러 수집 요청이 비슷한 시점에 같은 option을 수정할 때 생길 수 있었다.

각 요청은 필요한 부분만 바꿨지만 저장은 전체 option이었다

WordPress의 get_option()은 지정한 option 값을 가져오고, update_option()은 전달받은 새 값으로 그 option을 갱신한다. 배열을 저장하면 WordPress가 직렬화 처리를 해주지만, 애플리케이션 코드 입장에서는 여전히 option 값 전체를 읽고 다시 넘기는 구조다.

예를 들어 공유 상태가 처음에 다음과 같다고 해보자.

Steam KR: 이전 데이터
Epic KR:  이전 데이터

Steam KR 요청과 Epic KR 요청이 거의 같은 시점에 이 값을 읽으면 두 요청 모두 같은 이전 상태에서 출발할 수 있다.

요청 A: 이전 상태 읽기 → Steam KR만 새 값으로 변경
요청 B: 이전 상태 읽기 → Epic KR만 새 값으로 변경

A가 먼저 전체 option을 저장하면 Steam KR은 최신 상태가 된다.

그 뒤 B가 자신이 처음 읽었던 상태를 바탕으로 전체 option을 저장하면, B의 값에는 Epic KR 변경은 들어 있지만 A가 조금 전에 저장한 Steam KR 변경은 들어 있지 않을 수 있다.

A 저장
Steam KR: 새 데이터
Epic KR:  이전 데이터

B 저장
Steam KR: 이전 데이터
Epic KR:  새 데이터

각 요청은 자기 작업에는 성공했지만 마지막 전체 저장이 다른 요청의 변경을 덮는 형태다.

이 문제는 `update_option()`이 고장 난 것이 아니라, 서로 독립적으로 움직이는 작업들이 하나의 큰 값을 읽고 수정해서 다시 쓰도록 만든 저장 구조에서 생기는 문제였다.

공유 option 하나를 store·region별로 나눴다

수정에서는 별도의 잠금 장치를 추가하지 않았다.

대신 서로 독립적인 수집 단위를 저장소에서도 분리했다.

private static function option_name(string $store, string $region): string {
    return self::OPTION_PREFIX . '_' . $store . '_' . strtolower($region);
}

저장할 때도 다른 스토어와 지역의 상태를 읽지 않는다.

update_option(self::option_name($store, $region), [
    'checked_at' => $checked_at,
    'games' => $saved_items,
], false);

원래 하나였던 구조는 당시 PC 스토어 기준으로 다음처럼 나뉘었다.

wany_free_games_snapshot_v1_steam_kr
wany_free_games_snapshot_v1_steam_jp
wany_free_games_snapshot_v1_epic_kr
wany_free_games_snapshot_v1_epic_jp
wany_free_games_snapshot_v1_gog_kr
wany_free_games_snapshot_v1_gog_jp

이제 Steam KR 요청은 Steam KR option만 바꾸고, Epic JP 요청은 Epic JP option만 바꾼다. 서로 다른 요청이 같은 큰 배열을 다시 저장할 이유가 없어졌다.

화면에 보여줄 때 다시 합쳤다

저장소를 나눴다고 해서 화면에서도 데이터를 따로 보여줄 필요는 없다.

목록을 만들 때 각 store·region option을 읽은 뒤 유효한 snapshot만 합치도록 바꿨다.

foreach (self::PUBLIC_STORES as $store) {
    foreach (self::REGIONS as $region) {
        $snapshot = get_option(self::option_name($store, $region), []);
        if (! is_array($snapshot)) {
            continue;
        }

        // freshness 확인 후 공개 목록에 병합
    }
}

즉 저장할 때는 충돌 가능성을 줄이기 위해 나누고, 읽을 때는 필요한 형태로 다시 조합하는 구조다.

이 변경 뒤 운영 확인에서는 n8n 수동 실행이 Steam·Epic·GOG의 KR/JP, 총 여섯 개 snapshot을 각각 기록했다. 같은 수집을 다시 실행했을 때도 각 store·region snapshot을 교체한 뒤 공개 목록에서는 고유 게임 카드로 다시 병합됐고, 이전 실행 결과를 단순히 뒤에 붙여 중복을 만드는 방식은 아니었다.

또 하나의 upstream 요청을 의도적으로 실패시킨 확인에서는 마지막으로 성공했던 Epic KR snapshot이 그대로 유지됐다. 수집 실패와 정상 데이터 저장을 분리하는 기존 정책도 독립 snapshot 구조에서 유지됐다.

update_option보다 저장 단위를 먼저 볼 필요가 있었다

이 사례에서 핵심은 WordPress option을 쓰면 안 된다는 것이 아니다.

작은 설정값이나 하나의 주체가 관리하는 상태라면 option 하나가 자연스러울 수 있다. 문제는 서로 독립적으로 갱신되는 여러 상태를 하나의 큰 배열에 넣고, 각 요청이 그 전체 값을 읽었다가 다시 저장하도록 만들었을 때다.

특히 n8n처럼 여러 작업 단위를 이어서 외부 API나 WordPress REST endpoint를 호출하는 구조에서는 저장 코드만 보고 판단하기보다 누가 같은 키를 동시에 수정할 수 있는지를 같이 보는 편이 낫다.

이번 수정에서는 각 수집 단위가 store·region이라는 명확한 경계를 갖고 있었기 때문에 option도 같은 단위로 나누는 방법이 단순했다.

물론 이 방식이 모든 동시성 문제의 답은 아니다. 여러 store·region 값이 반드시 한 시점에 함께 바뀌어야 하는 데이터라면 별도의 동기화나 트랜잭션 설계를 검토해야 한다.

하지만 서로 독립적인 결과를 저장하는 경우라면, 큰 공유 상태에 잠금을 추가하기 전에 애초에 같은 저장 키를 공유할 필요가 있는지부터 확인해볼 만하다.

광고 차단 알림

광고 클릭 제한을 초과하여 광고가 차단되었습니다.

단시간에 반복적인 광고 클릭은 시스템에 의해 감지되며, IP가 수집되어 사이트 관리자가 확인 가능합니다.