같은 옵션에 경쟁을 붙이면 어떻게 될까?

해결책 1: PHP 세마포어

첫번째 해결 방법은 PHP 언어에서 제공하는 세마포어를 이용하는 것입니다.

// Semaphore
$sem = sem_get( 9090 );
sem_acquire( $sem );

$id    = intval( $_GET['id'] ?? '0' );
$value = get_option( 'rcond_autoload_no', [] );
if ( ! is_array( $value ) ) {
	$value = [];
}
$value[ $id ] = true;
update_option( 'rcond_autoload_no', $value, false );

// Semaphore
sem_release( $sem );
if ( 50 === count( $value ) ) {
	sem_remove( $sem );
}
Code language: PHP (php)

처음에 sem_get( 9090 ) 으로 세마포어 객체를 얻습니다. 9090은 임의값입니다. 서로 다른 프로세스가 같은 세마포어 객체를 인지하기 위한 고유한 번호입니다.

sem_acquire()로 세마포어를 얻습니다. 이때 아무리 많은 프로세스가 이 코드를 실행하더라도 여기서부터는 하나의 프로세스만 이 코드를 실행 가능하게 되었습니다. 나머지 프로세스들은 블로킹되죠. 그리고 update_option()을 해서 값을 업데이트 한 다음, 세마포어를 돌려 놓습니다. 그래서 값의 누락이 일어나지 않습니다.

문제를 해결하긴 하지만, 전 조금 찜찜합니다. sem_remove()로 만든 세마포어 객체를 해제하는 시점입니다. 예제에서는 모은 ID 개수가 50개일 때 세마포어 객체를 제거하도록 코딩했습니다.

그런데 저 50이라는 수는 어디에서 온 걸까요? 네, 제가 50개의 링크를 생성했다는 사전 정보로부터 온 것이죠. 그 50이라는 숫자가 바뀌게 된다면요? 혹은 임의의 갯수를 받게 된다면요? 세마포어를 해제하지 않고 영원히 둘까요? 그게 좀 찜찜한 것입니다.

해결책 2: 레코드 락

앞선 방법으로 다행히 문제는 해결하긴 했지만, 생각해 보면 어색했습니다. 왜냐면 경쟁 조건은 데이터베이스에서 일어나는 것입니다. 그런데 해결은 PHP 코드 레벨에서 하려고 하니까요. 결자해지라고, 데이터베이스에서 일어난 경쟁을 데이터베이스에서 해결하면 안될까요?

MySQL 데이터베이스는 이런 경우를 잘 대비해 두었습니다. 레코드 레벨에서 읽기, 쓰기, 삭제에 대한 접근을 잘 제어하도록 지원하는데요, 조건이 있습니다.

  • 해당 테이블이 ‘InnoDB’ 방식여야 합니다. ‘MyISAM’ 방식은 행 단위 락이 걸리지 않습니다.
  • 조건이 걸리는 필드에 인덱스가 걸려야 합니다. 아니면 테이블 전체에 락이 걸립니다.

다행히 옵션 테이블은 이미 InnoDB 이고, 워드프레스 설치부터 option_name 필드에는 인덱스가 걸려 있습니다. 조건은 만족합니다.

autoload=’no’인 옵션에 대해 get_option()을 실행할 때 코어는 다음 SQL을 실행해 값을 캐싱합니다.

SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1
Code language: PHP (php)

이 때 이 행에 대해 읽기, 쓰기, 삭제 락을 걸어줍니다.

START TRANSACTION;
SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1 FOR UPDATE;
...
COMMIT;Code language: PHP (php)

이렇게 쿼리를 변경해 주면 되겠네요. 그러면 아래처럼 코드를 수정하면 될 것 같습니다.

add_action( 'wp_ajax_rcond_autoload_no', 'rcond_autoload_no' );
function rcond_autoload_no() {
	//  DB Locking
	global $wpdb;

	$filter_added = false;
	add_filter( 'pre_option_rcond_autoload_no', function ( $value ) use ( &$filter_added ) {
		if ( ! $filter_added ) {
			add_filter( 'query', 'rcond_query_for_update' );
			$filter_added = true;
		}
		return $value;
	} );

	wp_suspend_cache_addition( false );
	$wpdb->query( 'SET AUTOCOMMIT = 0;' );
	$wpdb->query( 'START TRANSACTION' );

	// Semaphore
//	$sem = sem_get( 9090 );
//	sem_acquire( $sem );

	$id    = intval( $_GET['id'] ?? '0' );
	$value = get_option( 'rcond_autoload_no', [] );
	if ( ! is_array( $value ) ) {
		$value = [];
	}
	$value[ $id ] = true;
	update_option( 'rcond_autoload_no', $value, false );

	// Semaphore
//	sem_release( $sem );
//	if ( 50 === count( $value ) ) {
//		sem_remove( $sem );
//	}

	// DB Locking
	$wpdb->query( 'COMMIT' );

	wp_send_json_success();
}

function rcond_query_for_update( $query ) {
	remove_filter( 'query', 'rcond_query_for_update' );
	$query .= ' FOR UPDATE';
	return $query;
}
Code language: PHP (php)
  1. ‘pre_option_rcond_autoload_no’ 필터에서 ‘rcond_autoload_no’라는 옵션을 읽기 전에 미리 ‘query’ 필터를 심어둡니다. 단, 이 필터 이후 일어날 SELECT 쿼리를 위한 준비이므로 단 1번만 처리되도록 합니다.
  2. 조회된 옵션이 캐싱되지 않도록 처리할 수도 있습니다. 또한 추가적으로 오토 커밋을 명시적으로 하지 않게 했습니다. 이건 그냥 부가적인 과정입니다. 생략해도 됩니다.
  3. get_option()을 부르기 전, ‘START TRANSACTION’을 불러줍니다.
  4. 그리고 get_option()에 의해 SELECT option_value FROM wp_options WHERE option_name = ‘rcond_autoload_no’ LIMIT 1 이 실행됩니다. 이 때 필터에 의해 LIMIT 1 다음에 ‘ FOR UPDATE’ 가 추가로 붙게 될 것입니다. 맨 마지막 ‘ rcond_query_for_update’ 함수가 하는 일입니다.
  5. ID를 추가하고, update_option()으로 갱신한 다음 ‘COMMIT’합니다.

댓글 남기기