J-한솔넷

삽질의 연속 본문

프로그래밍/PHP

삽질의 연속

jhansol 2024. 4. 20. 16:11

안녕하세요. 오늘도 유지보수관련하여 글을 남길까 합니다.
몇일 전 고객사로부터 콘텐츠는 존재하는데 검색이 않된다고 확인을 요청하는 메일이 대표님을 통해 들어왔습니다. 저도 약간의 문제가 있다는 것은 알고 있었으나 여건상 해결상 시간을 투자할 수 없었습니다.

기본 사양

유지보수 중인 사이트는 PHP 5.6 기반에 Drupal 7을 이용하여 개발된 것입니다. 그리고 이 사이트는 약 120여개의 기여모듈과, 자체 제작 모듈 약 20여개로 구성되어 있습니다. 이 문제와 연관된 모듈은 Entity Translation, Search Api, Search Api Entity Translation 등 3개 입니다.

문제의 현상

관리자가 특정 언어로 컨텐츠를 등록하면 색인 대상 정보에 해당 콘텐츠 정보가 추가되지만 콘텐츠의 언어를 변경하거나 다른 언어로 번역할 경우 검색엔진에 색인을 요청하지만 색인 대상 정보에 반영되지 않는 현상이 발생합니다. 이로 인해 기존 색인을 삭제하고 콘텐츠 전체를 다시 색인하는 경우 잘 못된 정보로 색인을 하거나 누락되는 경우가 발생했습니다.
그리고 콘텐츠가 삭제되면 색인 대상 정보에서 해당 콘텐츠 정보가 삭제되어야 하는데 삭제되지 않고 남아 있는 경우가 있었습니다.
위 두 문제로 정상적으로 색인이 되지 않아 콘텐츠 검색이 않되는 경우가 발생합니다.

Drupal Hook

Drupal은 Hook 함수를 이용하여 동작한다고 해도 과언이 아닙니다. 검색을 위한 색인 역시 Hook 함수를 통해 실행됩니다.

/**
 * Implements hook_entity_translation_insert().
 */
function search_api_et_entity_translation_insert($entity_type, $entity, $translation, $values = array()) {
  list($entity_id) = entity_extract_ids($entity_type, $entity);
  $item_id = SearchApiEtHelper::buildItemId($entity_id, $translation['language']);

  search_api_track_item_insert(SearchApiEtHelper::getItemType($entity_type), array($item_id));
}

/**
 * Implements hook_entity_translation_update().
 */
function search_api_et_entity_translation_update($entity_type, $entity, $translation, $values = array()) {
  list($entity_id) = entity_extract_ids($entity_type, $entity);
  $item_id = SearchApiEtHelper::buildItemId($entity_id, $translation['language']);
  search_api_track_item_change(SearchApiEtHelper::getItemType($entity_type), array($item_id));
}

/**
 * Implements hook_entity_translation_delete().
 */
function search_api_et_entity_translation_delete($entity_type, $entity, $langcode) {
  list($entity_id) = entity_extract_ids($entity_type, $entity);
  $item_id = SearchApiEtHelper::buildItemId($entity_id, $langcode);
  search_api_track_item_delete(SearchApiEtHelper::getItemType($entity_type), array($item_id));
}

위 3개의 함수는 콘텐츠가 추가, 수정, 삭제 시 호출되는 Hook 함수입니다. 문제의 현상이 발생하지 않으려면 위 3개의 함수가 호출되어 색인 대상 정보에 해당 콘텐츠 정보가 반영이되어야 하지만 반영되지 않는 경우가 다수 발생합니다.

해결을 위한 고민

기본 사양에서 언급한 3개의 모듈은 크고 복잡한 모듈입니다. 시간이 허락할 때마다 조검씩 코드를 살펴보고, 디버깅도 하고 코드를 분석해보고는 있지만 분석해야 하는 코드의 양도 만만치 않고, 각종 Hook 함수에 의해 얽혀 있는 탓에 완전 해결이 매우 힘듭니다. 모듈 개발자분의 생각을 이해한다면 문제의 원인을 원천 파악하고 수정하겠지만 현재로서는 부분적인 수정으로 땜질식 처방만 할 수 있습니다.

부분적 수정의 두려움

현재 Drupal 7의 코어를 비롯하여 모듈의 공식 유지보수 기간이 지난 관계로 더 이상의 오류 패치는 기대할 수 없어 어쩔 수 없이 수정은 하고 있지만 이 수정으로 사이트 전체 데이터의 무결성 해손이나 성능에 문제를 이르키지는 않을까 하는 두려움을 가지고 있습니다. 그래서 이번 문제는 기존 모듈을 그대로 두고 자체 재작한 모듈에 기능을 추가하여 해결하기로 했습니다.

문제의 현상 해결 방법

문제를 해결하기 위해 아래와 같은 작업을 수행하는 콘솔 명령을 작했습니다.

  • 검색 대상 정보에서 삭제된 콘텐츠 정보를 제거하고 검색엔진에 해당 콘텐츠를 삭제하도록 요청한다.
  • 색인 대상 콘텐츠의 원본과 번역본을 수집하고 검색 대상 정보의 내용과 비교하여 삭제해야 하는 색인, 새롭게 색인해야 하는 콘텐츠를 구분하여 색인 삭제, 색인 대상 정보에서 항목 삭제, 새롭게 색인해야 하는 항목을 추가한다.

아래 코드는 위 작업을 수행하는 코드입니다.

function xxxxx_util_get_sapi_refresh() {
  $page_per_item = 100;
  $page = 0;
  $delete_target_count = 0;
  $add_target_count = 0;
  $no_exists_content_count = 0;
  $sapi_base_url = _get_sapi_server_base_path();
  $proc_count = 0;

  print "검색엔진 URL : {$sapi_base_url}\n";

  do{
    $result = db_select('search_api_et_item', 't')
      ->fields('t')
      ->orderBy('item_id', 'asc')
      ->condition('index_id', 6)
      ->range($page * $page_per_item, $page_per_item)
      ->execute()->fetchAllAssoc('item_id');
    $item_count = count($result);
    foreach ($result as $item) {
      list($id, $language) = explode('/', $item->item_id);
      if(!_is_exists_node($id)) {
        $no_exists_content_count++;
        _delete_sapi_document($sapi_base_url, array($item->item_id));
        _delete_sapi_et_item(array($item->item_id));
      }
      $proc_count++;
      print "콘텐츠 점검 : {$proc_count}\r";
    }
    $page++;
  } while ($item_count == $page_per_item);
  print PHP_EOL;

  $page = 0;
  $proc_count = 0;
  do{
    $result = db_select('node', 't')
      ->fields('t')
      ->orderBy('nid', 'asc')
      ->condition('type', 'resources')
      ->range($page * $page_per_item, $page_per_item)
      ->execute()->fetchAllAssoc('nid');
    $item_count = count($result);
    $nids = _get_nids($result);
    $nodes = node_load_multiple($nids);
    foreach($nodes as $node) {
      if(isset($node->translations->data)) {
        $languages = array_keys($node->translations->data);
        $target_item_ids = _get_item_ids($node, $languages);
        $current_item_ids = _get_sapi_et_items($node->nid);
        $delete_targets = array_diff($current_item_ids, $target_item_ids);
        $add_targets = array_diff($target_item_ids, $current_item_ids);
        if(!empty($delete_targets)) {
          $solr_result = _delete_sapi_document($sapi_base_url, $delete_targets);
          _delete_sapi_et_item($delete_targets);;
          $delete_target_count += count($delete_targets);
        }
        if(!empty($add_targets)) {
          _add_sapi_et_item($add_targets);
          $add_target_count += count($add_targets);
        }
      }
      $proc_count++;
      print "색인 대상 자료 새로 고침 : {$proc_count}\r";
    }
    $page++;
  } while ($item_count == $page_per_item);

  print "\n존재하지 않는 콘텐츠 : {$no_exists_content_count}";
  print "\n삭제 대상 항목 : {$delete_target_count},  추가대상 항목 : {$add_target_count}\n";
}

function _get_nids($nodes) {
  return array_map(function($node) {
    return $node->nid;
  }, $nodes);
}

function _get_item_ids($node, $languages) {
  $target_item_id = array();
  foreach($languages as $language) $target_item_id[] = $node->nid . '/' . $language;
  return $target_item_id;
}

function _get_sapi_et_items($nid) {
  $result = db_select('search_api_et_item', 't')
    ->fields('t')
    ->condition('index_id', 6)
    ->condition('item_id', "${nid}/%", 'like')
    ->execute()->fetchAllAssoc('item_id');
  return array_map(function($item) {
    return $item->item_id;
  }, $result);
}

function _get_sapi_server_base_path() {
  $server = db_select('search_api_server', 't')
    ->fields('t')
    ->condition('machine_name', 'inner_solr')
    ->execute()->fetchAssoc();
  if($server) {
    $options = unserialize($server['options']);
    $schema = $options['scheme'];
    $host = $options['host'];
    $port = $options['port'];
    $path = $options['path'];
    return "{$schema}://{$host}:{$port}{$path}";
  }
  else return null;
}

function _add_sapi_et_item($item_ids) {
  $fields = array('item_id', 'index_id', 'changed');
    $now = time();

  $query = db_insert('search_api_et_item')
    ->fields($fields);
  foreach ($item_ids as $id) $query->values(array('item_id' => $id, 'index_id' => 6, 'changed' => $now));
  $query->execute();
}

function _delete_sapi_et_item($item_ids) {
  db_delete('search_api_et_item')
    ->condition('index_id', 6)
    ->condition('item_id', $item_ids, 'in')
    ->execute();
}

function _delete_sapi_document($base_url, $item_ids, $display_response = false) {
  $id_string = '';
  foreach($item_ids as $id) {
    $id_string .= "<query>item_id:{$id}</query>";
  }

  $options = array(
    'method' => 'POST',
    'data' => "<add commitWithin=\"1000\" overwrite=\"true\"><delete>{$id_string}</delete></add>",
    'headers' => array(
      'Content-Type' => 'text/xml'
    )
  );
  $response = drupal_http_request("{$base_url}/update?wt=json", $options);
  return $response->code == 200;
}

function _is_exists_node($id) {
  $result = db_select('node', 't')
    ->fields('t')
    ->condition('nid', $id)
    ->execute()->fetchAllAssoc('nid');
  return count($result) > 0;
}

위 코드를 기존 모듈에 추가하고 해당 모듈의 Drush 콘솔 명령어 정보 부분에 아래의 코드를 추가합니다.

  $items['sapi_refresh'] = array(
    'description' => t('검색 색인 자료 정보를 새로고침한다.'),
    'callback' => 'xxxx_util_get_sapi_refresh',
    'aliases' => array('sr')
  );

이재 콘솔 명령을 실행하기 위해 아래와 같이 콘솔명령 관련 캐시를 지워줍니다.

drush cc drush

그리고 아래 명령으로 콘솔 명령을 실행해주면 콘텐츠 색인 대상 정보를 새로고침합니다.

drush sapi-refresh
# 또는 
drush sr

주기적으로 실행하도록 스크립트에 추가

이 사이트는 이미 몇개의 Cron Job이 실행중입니다. 그 중에서도 1시간 주기로 일괄색인을 진행하는 스크립트가 있습니다. 이 스크립트에 위 명령을 추가했습니다.

#!/bin/sh

path=`dirname $0`

cd $path
echo "Cron started...."
/usr/local/bin/drush sr
/usr/local/bin/drush sapi-i
echo "Done...

위 스크립트를 실행하는 Cron 설정은 아래와 같이 crontab에 지정해두었습니다.

0 * * * * /home/gced/xxxx/docroot/run_index.sh > /home/xxxx/xxxx/docroot/search_index.log

마치며

약 4일동안 주말도 반납해가며 고민하고, 코드를 작성해서 몇 번의 테스트를 거쳐 운영서버에 적용을 했습니다. 재발 더 이상 이 문제로 메일이나 전화가 않오기를 기대해봅니다.

'프로그래밍 > PHP' 카테고리의 다른 글

메모리 프로파일링?  (0) 2023.11.09
ChatGPT를 이용한 코딩?  (0) 2023.10.26
WSL2를 쓰야하나!!!  (0) 2023.09.14
나의 API 문서화 전략  (0) 2023.09.11