TIP게시판

제목 Active Record의 쿼리 캐싱 버그 (+ 수정된 AR 클래스파일)
글쓴이 슘님 작성시각 2013/11/27 09:31:23
댓글 : 11 추천 : 0 스크랩 : 0 조회수 : 13772   RSS
안녕하세요. 
ar을 사용하다 쿼리 캐싱 start_cache(), stop_cache()를 사용 중에 황당한 문제를 발견했습니다.

문제가 발생된 쿼리의 일부분 (설명에 불필요한 부분은 모두 제거하였습니다.)

$this->db->start_cache();
$this->db->select("
 SUM(IF(h.dc = 0, h.qty, 0)) AS qty0,
 SUM(IF(h.dc = 1, h.qty, 0)) AS qty1,
 SUM(IF(h.dc = 2, h.qty, 0)) AS qty2,
 SUM(IF(h.dc = 3, h.qty, 0)) AS qty3
", FALSE);
$this->db->stop_cache();
위의 select 쿼리를 캐싱하여 사용할 경우 문제가 발생합니다.

올바른 쿼리문 (정상적으로 출력되어야 하는)
SELECT SUM(IF(h.dc = 0, h.qty, 0)) AS qty0,
 SUM(IF(h.dc = 1, h.qty, 0)) AS qty1,
 SUM(IF(h.dc = 2, h.qty, 0)) AS qty2,
 SUM(IF(h.dc = 3, h.qty, 0)) AS qty3
(중략)

그러나 AR클래스가  아래와 같은 쿼리문으로 만들어 냅니다.
SELECT SUM(IF(h.dc = 0, h.qty, 0)) AS qty0,
 SUM(IF(h.dc = 1, 0)) AS qty1,
 SUM(IF(h.dc = 2, 0)) AS qty2,
 SUM(IF(h.dc = 3, 0)) AS qty3
(중략)
SQL IF문에서 TRUE인 경우에 설정한 필드값인 h.qty가 2~4줄에서 삭제됩니다.
이로 인해 쿼리 수행 에러(Syntax error)가 발생합니다.


AR클래스를 자세히 살펴보니 구조적인 몇 가지 문제가 있더군요.

우선 캐싱된 쿼리를 재사용해주는 함수입니다. -> function _merge_cache() 
코드들을 중략하고 핵심 코드만 말씀 드리자면...
protected function _merge_cache()
 {
  if (count($this->ar_cache_exists) == 0)
  {
   return;
  }

  // $val은 'select', 'from' 등의 캐시되는 쿼리 구문명
  foreach ($this->ar_cache_exists as $val)
  {
   $ar_variable = 'ar_'.$val;
   $ar_cache_var = 'ar_cache_'.$val;

   (중략)
   
   // 문제되는 구문
   array_unique(array_merge($this->$ar_cache_var, $this->$ar_variable));

  }
 (중략)
}
array_unique()에서 문제가 발생하고 있었습니다.
array_unique() 때문에 동일한 이름의 요소 (위의 예제에서는 h.qty)가 제거되고 있더군요.

보충 설명
// Model
$this->db->start_cache();
$this->db->select("a, a, b, c, d");
// AR Class

function select(){
$ar_cache_select = [a, a, b, c, d];
}

function _merge_cache()
{
array_unique(array_merge($ar_cache_select, $ar_select));

// $ar_cache_select = [a, b, c, d]; 로 생성 
}
sql문 생성 시 "select a, b, c, d ..."로 생성



function select()
public function select($select = '*', $escape = NULL)
 {
  if (is_string($select))
  {
   $select = explode(',', $select);
  }

  foreach ($select as $val)
  {
   $val = trim($val);

   if ($val != '')
   {
    $this->ar_select[] = $val;
    $this->ar_no_escape[] = $escape;

    if ($this->ar_caching === TRUE)
    {
     $this->ar_cache_select[] = $val;
     $this->ar_cache_exists[] = 'select';
     $this->ar_cache_no_escape[] = $escape;
    }
   }
  }
  return $this;
 }

추가적으로 버그는 아니지만 구조적으로 비효율적인 부분도 찾았습니다.
바로 $this->ar_cache_exists 이 부분인데요.
select 구문이 캐시되는가의 여부를 저장하는 부분입니다.
눈치 채신분들은 바로 아실듯...
select 해주는 필드값 들을 콤마(,)로 explode하고 그 수만큼 반복문을 돌리면서 계속 중복(누적) 저장해버립니다.
그만큼 불필요한 연산을 _merge_cache()에서 반복하여 수행하더군요. 
(자세한 로직은 해당 함수의 foreach문을 참고해 주세요.)

array_merge를 반복적으로 하기 때문에 array_unique를 사용했고
그로 인해 제 쿼리문 처럼 콤마 사이에 있는 문자열이 동일하고 여러개 있을 경우
(AGE가 20인 사람의 수를 S1에 저장, AGE가 21인 사람의 수를 S2에 저장하는 예:
SELECT SUM(IF(AGE=20, NAME, 0) AS S1, SUM(IF(AGE=21, NAME, 0) AS S2
콤마 사이에 문자열이 동일한 값 발생 : NAME)
같이 제거되는 문제가 발생했습니다.


처음에는 왜 이런가...쿼리 작성 시 문제가 분명 없는데...
고민하다 AR를 버릴까...하다가 버리긴 싫고...ㅎㅎ

쿼리 캐싱을 사용하실 경우 참고되시기 바랍니다.
(저와 같은 경우만 아니면 별 상관은 없네요.
제 CI 버전은 2.1.4입니다.)

AR클래스는 수정하는대로 공유드리도록 하겠습니다.
혹시 뭔가 좋은 소스나 정보가 있다면 공유 부탁 드려요.


_________________________________________________

Related issue: http://ellislab.com/forums/viewthread/237683/
위의 문제를 해결한 AR 클래스파일 첨부합니다.

[수정 사항]

1. select()
$flag_select 변수 및 관련 분기문 추가 

- 원천적으로 $ar_cache_exists를 한 번 만 저장하도록 수정 
불필요한 array_unique, array_merge의 반복 수행을 막기 위함

- 캐싱될 경우 $ar_select에 값을 넣지 않도록 수정 
어짜피 _merge_cache()에서 캐시값을 $ar_select에 array_merge시킴
(array_merge 시 $ar_select에 중복된 데이터가 들어가지 않도록 방지함)

즉, array_unique할 필요가 없어짐.
추가적으로 array_merge 반복 작업이 없어짐


2. _merge_cache() 
$flag_select 변수 및 관련 분기문 추가 

- select문의 경우만 단 한번의 array_merge를 하도록 변경 


제 생각에는 select문만이 아니라 다른 모든 sql문 처리 함수들이 위의 방식으로 변경되어 한다고 생각합니다.
(그러나 select문을 제외하고는 array_unique로 인한 문제가 생기지 않을 것 입니다.)
array_unique문제가 아닌 처리 구조의 효율성 때문이죠.
수행 시간이 O(n)이라 간결한 코드 vs 효율성+약간의 복잡성 간의 트레이드오프에서 간결한 코드를 선택해도 무리는 없을 것 입니다. (개인적인 견해입니다.)


참고.
// 1번 예제
$this->db->select('a');
$this->db->select('b');
$this->db->select('c');
$this->db->select('d');

// 2번 예제
$this->db->select('a, b, c, d');
2번 예제가 유리합니다.
// 1번 예제
String str = "a";
str += "b";
str += "c";
str += "d";

// 2번 예제
String str = new String("abcd");
내부적으로 수행하는 로직이 위의 개념과 유사하다고 보시면 됩니다.


혹시라도 내용 중 잘못된 부분이나
첨부한 AR 클래스 파일에 문제가 있다면 지적 부탁 드립니다.


첨부파일 DB_active_rec.zip (7.6 KB)
 다음글 Codeigniter 다운로드 주소..
 이전글 멀티업로드에 do_upload를 그대로 이용하기 (2)

댓글

한대승(불의회상) / 2013/11/27 09:58:41 / 추천 0
슘님// 좋은 정보 감사 합니다.
캐싱을 잘 사용하지 않아 이러한 버그가 있다는 사실조차 모르고 있었네요.

게시물의 성격이 질문/답변 보다는 팁에 가깝다고 생각되어 이동 하였습니다.
니삼 / 2013/11/27 10:12:27 / 추천 0
ㅠㅠ; 빨리 인수되서 이런 이슈들 처리가 됬으면 좋겠내요 ㅠㅠ
슘님 / 2013/11/27 10:14:55 / 추천 0
한대승님// 팁게시판에 올릴까 하다가...원인만 찾았지 아직 해결책을 강구하는 중이라 qna로...ㅎㅎㅎ

변종원(웅파) / 2013/11/27 12:18:58 / 추천 0
포럼에 db캐싱이 적용되어 있는데 저렇게 select에서 반복되는 경우는 없었기에 정상작동을
하고 있습니다. 세세한 디테일이 문제네요.
letsgolee / 2013/11/28 12:33:12 / 추천 0
 버그라고 하기보다는 복잡한 형태의 쿼리문 작성이라 하는 것이 마땅할 것 같네요. 모든 부분까지 세밀하게 적용하려면 AR의 기능이 너무 커지기 때문에 간단한 정도만 지원하고 만일의 경우를 대비해서 두번째 파라미터를 지원하지 않나요? 제가 볼 때 이렇게 처리하면 될 듯한데...

$this->db->select("
 SUM(IF(h.dc = 0, h.qty, 0)) AS qty0,
 SUM(IF(h.dc = 1, h.qty, 0)) AS qty1,
 SUM(IF(h.dc = 2, h.qty, 0)) AS qty2,
 SUM(IF(h.dc = 3, h.qty, 0)) AS qty3
", FALSE);

한대승(불의회상) / 2013/11/28 12:55:54 / 추천 0
letsgolee// 문제해결을 바로보는 다른 방법이군요. ^^
좋은 정보 감사 합니다.
슘님 / 2013/11/28 13:04:30 / 추천 0
letsgolee // 위의 코드에는 빠져있지만 SQL 내부 함수를 사용하기 위해서 당연히 2번 째 파라미터에 FALSE를 지정합니다. (지적 감사합니다. 빠트렸던 부분이네요. ^^) 
그리고 위의 이슈는 escape의 문제가 아닙니다.
SQL문을 캐싱할 경우, 쿼리를 수행할 때(get함수 호출 시) 캐싱된 SQL도 쿼리에 추가하기 위하여 _merge_cache 함수를 호출합니다.
그 과정에서 array_unique함수의 오작동으로 'h.qty' 까지 중복 제거 되어집니다.
그 결과 완성된 SQL에서 Syntax 에러가 발생하게 되는게 문제인 것 입니다.

SELECT IF(3>2, 'T', 'F'), IF(2>1, 'T', 'F')
위의 SELECT문을 캐싱하신 후 $this->db->get() 해보시면 문제를 발견할 수 있습니다.
 

한대승(불의회상) / 2013/11/28 13:08:23 / 추천 0
슘님// 쿼리를 던지는것 과정에서 생기는 버그가 아니라 캐싱처리 과정에서 생기는 버그 였군요.
버그를 발견해 주시고 해결책까지 제시해 주셔서 정말 감사 합니다.
milosz / 2013/11/29 11:50:40 / 추천 0
리포지터리에서 내용을 찾다가보니 파일명이 변경된 모양이네요.
https://github.com/EllisLab/CodeIgniter/blob/develop/system/database/DB_query_builder.php#L2553
슘님 / 2013/11/29 11:59:47 / 추천 0
 milosz // 구조적으로도 확실히 개선되었네요. 
좋은 정보 감사합니다. ^^
letsgolee / 2013/11/30 00:24:09 / 추천 0
 @슘님
확실히 문제가 있습니다. 그런데 이건 array_unique를 사용하는 한 또 다른 문제를 야기할 수 있다는 생각이 드네요. 꼭 select에서만 쓰이는 것은 아니니까요.  또한 develop버젼에서도 동일 문제를 생각해 볼 수 있습니다. 확실한 방법은 arrra_unique에서 괄호안에 있는 부분은 unique처리를 하지 않는 방법으로 생각해야 하지 않을까요? 그래서 함수를 만들어 보았습니다.

protected function _safe_array_unique($array)
{
 $ret = array();
 $cnt = 0;
 $temp = array();

 foreach ($array as $string)
 {
  $string = trim($string);

  if (preg_match_all('/\(/', $string, $matches)) {
   $cnt += count($matches[0]);
  }
  if (preg_match_all('/\)/', $string, $matches)) {
   $cnt -= count($matches[0]);
  }
  if ($cnt) {
   if (count($temp)) {
    $ret = array_merge($ret, array_unique($temp));
    $temp = array();
   }
   $ret[] = $string;
  } else {
   $temp[] = $string; 
  }
 }

 // 만일의 경우를 위해
 // 있으면 SQL Syntax오류이므로 있어서는 안됨...
 if (count($temp)) {
  $ret = array_merge($ret, array_unique($temp));
 }

 return $ret;
}
이 함수를 이용해서 다음이 부분
array_unique(array_merge($this->$ar_cache_var, $this->$ar_variable));
여기를 다음처럼 고치면 됩니다.
_safe_array_unique(array_merge($this->$ar_cache_var, $this->$ar_variable));
이 방법이 가장 안전한 방법이 될 겁니다.