무료 게임 수집기는 스토어와 지역별로 데이터를 가져온 뒤 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 값이 반드시 한 시점에 함께 바뀌어야 하는 데이터라면 별도의 동기화나 트랜잭션 설계를 검토해야 한다.
하지만 서로 독립적인 결과를 저장하는 경우라면, 큰 공유 상태에 잠금을 추가하기 전에 애초에 같은 저장 키를 공유할 필요가 있는지부터 확인해볼 만하다.