티스토리 뷰

게시판 페이징시 쉽게 쓰일수 있는 limit X, Y 시 X 값이 커질수록 응답속도가 늦어진다는 특성이 있다
이 점을 개선하고자 X 값이 과도하게 늘어나는 상황을 없애는 방법으로 속도에서 개선을 얻은 구현이다.
개선점이 더 필요하지만 그래도 그럭저럭 쓸만한 게시판이다.

/*
   # 사용준비
   1. 본 파일을 http://localhost/index.php 에 위치시킨다
   2. 코드에서 "디비정보" 를 문자열 검색해서 디비정보를 알맞게 입력해준다
   3. http://localhost/index.php?mode=truncate 주소창에 입력

   # 글쓰기
   http://localhost/?mode=insert
   를 요청하면 1만건씩 입력하도록 되어있다.

   # 글 목록보기
   http://localhost/?mode=select&amount=30&page=1
   한페이지당 30건씩 해서 1페이지를 조회한다는거다
   주소에 &fast=1 를 붙이면 자료의 양과 비례하지 않는 속도를 내준다. 하지만 페이지에 삭제된 자료가 포함되있다면 그것을 제외한 만큼의 수만 보여준다
   주소에 &fast=1 를 붙이지 않으면 삭제된 자료에 의해 생기는 구멍이 나지 않고 페이지마다 일정한 수를 보여준다. 하지만 아래쪽 페이지로 갈수록 자료의 양에 비례하는 속도를 낸다

   # 글제거
   http://localhost/?mode=delete&seq=54210981
   를 요청하면 54210981 번 글을 지움

   # 대략적인 성능
   54210982 건의 게시물이 있고 1000개 분할 segment 로 데이터가 기록되있을 때 테스트 결과
   테스트기기: 삼성SSD840프로, 16GB RAM, Xeon E3-1231V3, Windows 10
   http://localhost/?mode=select&amount=20&page=2710400&fast= 마지막페이지 요청시 전체소요시간: 0.0059590339660645
   http://localhost/?mode=select&amount=20&page=2710400 마지막페이지 요청시 전체소요시간: 0.36519384384155
   http://localhost/?mode=select&amount=20&page=1&fast= 첫페이지 요청시 전체소요시간: 0.005479097366333
   http://localhost/?mode=select&amount=20&page=1 첫페이지 요청시 전체소요시간: 0.014885902404785
*/

# 디비정보
$db_host = "localhost";
$db_user = "";
$db_password = "";
$db_database = "test";

# 글 목록 요청시 1회에 가지고올 segment 목록의 양의 한계치
$max_amount_for_segment_request_a_time = 10000;

# 하나의 segment 에 들어갈 자료의 수
$max_count_of_row_for_a_segment = 1000;

function get_time() {
   list($usec, $sec) = explode(" ", microtime());
   return ((float)$usec + (float)$sec);
}
function get_max_segment_no() {
   global $mysqli;
   $result = $mysqli->query("select max(segment_no) as segment_no from board_segments;");
   $max_segment = 0;
   while ($row = $result->fetch_object()){
      if(gettype($row->segment_no) !== "NULL") {
         $max_segment = $row->segment_no;
      }
   }
   return $max_segment;
}
function get_article_count_for_segment_no($segment_no) {
   global $mysqli;
   $result = $mysqli->query("select article_cnt from board_segments where segment_no = '".$segment_no."';");
   $article_cnt = 0;
   while ($row = $result->fetch_object()){
      if(gettype($row->article_cnt) !== "NULL") {
         $article_cnt = $row->article_cnt;
      }
   }
   return $article_cnt;
}

if(isset($_GET["mode"])) {
   $start = get_time();
   set_time_limit(0);
   header("Content-type: text/plain; charset=utf-8");
   $mysqli = new mysqli($db_host, $db_user, $db_password, $db_database);

   if($_GET["mode"] === "truncate") {
      # 테이블 초기화
      $mysqli->query("DROP TABLE IF EXISTS board_body;");
      $mysqli->query("DROP TABLE IF EXISTS board_segments;");
      
      $query ="
      CREATE TABLE IF NOT EXISTS `board_body` (
         `seq_number` int(11) NOT NULL AUTO_INCREMENT,
         `segment_no` int(11) NOT NULL DEFAULT '0',
         `article_title` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
         PRIMARY KEY (`seq_number`),
         KEY `segment_no` (`segment_no`)
       ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;";
      $mysqli->query($query);
       
      $query="CREATE TABLE IF NOT EXISTS `board_segments` (
         `segment_no` int(11) NOT NULL,
         `article_cnt` int(11) NOT NULL,
         PRIMARY KEY (`segment_no`)
       ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;";
      $mysqli->query($query);
   }
   if($_GET["mode"] === "select") {
      # 조회하기

      if(!isset($_GET['page']) || !isset($_GET['amount'])) {
         exit;
      }

      # 요청한 페이지의 번호
      $request_page_no = intval($_GET["page"]);

      # 한페이지에 보여줄 자료의 수
      $amount_to_show_a_page = intval($_GET["amount"]);

      # dutie는 앞으로 가져와야할 자료의 총 수를 dutie에 넣어주며 이것은 충족될때마다 충족된만큼 빼주며 진행되게된다
      $dutie = $amount_to_show_a_page;

      # valueMaxK 에는 현재 가장 최신의 segment 의 번호가 들어가있다.
      $valueMaxK = get_max_segment_no();

      # 요청한 페이지의 가장 첫게시물이 될 자료가 있을 위치를 의미한다
      # 만약 이 값이 0이면 전체 자료중 가장 꼭대기부터 가져오면 된다는거고
      # 만약 이 값이 10이면 전체 자료중 가장 꼭대기부터 10개를 지나쳐온 다음부터 시작되는 자료를 가져오면 된다는 것이다.
      $row_count_skip = ($request_page_no-1) * $amount_to_show_a_page;

      # 페이지마다 균일한 갯수가 나오는것을 포기하는 대신 매우 빠른 속도를 얻고자 할때 사용하는 옵션
      $fast_mode = isset($_GET["fast"]);
      
      # 1회에 가져올 segment 의 수
      # 대략적인 값이다
      # 대략적이여도 작동에 무방하다
      $seg_page = 1;
      $seg_page_ = intval(ceil($row_count_skip / $max_count_of_row_for_a_segment));
      if($seg_page_ > $seg_page) {
         $seg_page = $seg_page_;
         if($seg_page > $max_amount_for_segment_request_a_time) {
            $seg_page = $max_amount_for_segment_request_a_time;
         }
      }

      # segment 를 담기위한 LOOP 의 작동을 제어하기 위해 사용된다
      # working 이 False 가 되면 모든 자료를 찾았거나 다 뒤져봤지만 자료를 끝내 다 충족시키지 못했다는 의미이다.
      $working = true;

      # accumulate_count는 segment 를 훑고 내려가면서 자료의 양을 누적시킨다
      # 이 누적값은 요청한 페이지의 가장 첫게시물이 될 자료가 포함된 segment 를 찾기위해서 사용된다
      $accumulate_count = 0;

      # segment_list는 자료가 포함된 segment 를 담기위해 사용되며 여기에 담긴만큼 DB에 자료를 요청한다
      $segment_list = [];

      # lookup_segment 는 로직과는 무관 (시간측정 등을 위한 코드)
      $lookup_segment = [];

      while ($working && !$fast_mode) {

         # 로직과는 무관 (시간측정 등을 위한 코드)
         $start_sub = get_time();
         
         $query = "select * from board_segments where segment_no <= ".$valueMaxK." order by segment_no desc limit ".$seg_page."";
         $result = $mysqli->query($query);

         # segment 의 목록을 가져온다. 예를 들어 valueMaxK=5, seg_page=3 이라면
         # res['result'] 는 list이며 총 3행이 들어가게되고
         # 0번째의 segment_no 의 값은 5이다
         # 1번째의 segment_no 의 값은 4이다
         # 2번째의 segment_no 의 값은 3이다
         # 만약 segment_no 가 4인것이 없다면
         # 0번째의 segment_no 의 값은 5이다
         # 1번째의 segment_no 의 값은 3이다
         # 2번째의 segment_no 의 값은 2이다
         # 가 될것이다
         # --------------------------

         if($result->num_rows === 0) {
            # 더 이상 segment 가 존재하지 않는다는 의미다.
            # 첫번째 WHILE 에서 여기에 들어온다면 게시판에 자료가 아예 없다는 의미로 받아도 좋다
            # 첫번째 WHILE 이 아니라면 찾다 찾다 바닥까지 왔지만 결국 한페이지에 보여줘야할 자료의 양을 충족시키지 못했다 라는 의미로 받으면 된다.
            # 그래서 그냥 루프를 종료한다.
            $working = false;
         } else {
            # WHILE 이 돌때마다 일정 범위(seg_page)만큼의 segmnt 목록을 가져온다.
            # res['result'] 는 list 중 가장 작은 segment_no 에서 1을 뺀값을 valueMaxK 에 넣는다.
            # 이는 다음 WHILE 에서 segmnt 를 가져올 것을 위해 미리 준비해둔 값이다.
            # 다음 WHILE 이 실행되지 않는다면 valueMaxK 값은 사용되지 않게된다
            $result->data_seek($result->num_rows-1);
            $row=$result->fetch_object();
            $valueMaxK = $row->segment_no-1;
            $result->data_seek(0);

            while($row = $result->fetch_object()) {
               if($working) {
                  # 훑고 내려온 자료의 양을 누적시킨다
                  # 이 누적값은 요청한 페이지의 가장 첫게시물이 될 자료가 포함된 segment 를 찾기위해서 사용된다
                  $accumulate_count += intval($row->article_cnt);

                  # > 로 비교한것이 핵심인 꽤 중요한 부분이다.
                  # row_count_skip 는 전체 자료중 건너 뛸 자료의 수 이다.
                  # 즉 이 수만큼을 제외하고나서 부터의 자료들이 현재 요청한 페이지에 포함될 자료가 된다는 의미다.
                  # 따라서 현재 accumulate_count 에 포함된 자료의 수는 row_count_skip보다 커야만 현재 segment 에서 가져올 것이 존재한다는 의미이다
                  # 만약 그렇지 않다면 아직 요청한 페이지에 포함될 자료가 존재하는 segment 를 찾지 못했다는 의미이다.
                  if ($accumulate_count > $row_count_skip) {
                     # 여기에 들어와야 비로소 페이지에 포함될 자료가 존재하는 segment 를찾은거다.
                     # 현재 segment 에서 가져올 위치를 계산한다.
                     if (sizeof($segment_list) == 0) {
                        # 아직 segment_list 에 추가된 segment 가 없다.
                        # 이는 요청한 페이지의 가장 첫게시물이 될 자료를 현재 segment 에서 찾았다는것이고, 그 시작점을 계산해서 segmnt_set['start_row'] 에 넣는다
                        $row->start_row = intval($row->article_cnt) - ($accumulate_count - $row_count_skip);
                     }
                     else {
                        # 여기에 왔다는건 이미 FOR 루프를 한회 돌고나서 그때 자료의 량을 충분히 받지 못해서 다시한번 들어온거다.
                        # 이때는 segment 의 가장 처음인 0부터를 범위로써 지정한다
                        $row->start_row = 0;
                     }
                     # available_to_get 의 값의 의미는 현재 segment 에서 가져올 수 있는 자료의 수이다.
                     # 현재 segment에서 시작점으로부터 바닥까지 포함되는 자료의 총 자료의 수이다.
                     $available_to_get = intval($row->article_cnt) - $row->start_row;

                     # 앞으로 가져와야할 자료의 총 수를 의미하는 dutie 를 segmnt_set['get_rows'] 에 대입한다
                     # segmnt_set['get_rows'] 의 의미는 현재 segment 에서 가져오기로 약속할 수이다.
                     $row->get_rows = $dutie;

                     # 이렇게 현재 segment 에서 가져오기로 약속한 수가 실질적으로 유효한지를 비교한다.
                     # available_to_get 는 현재 segment 에서 가져올수 있는 자료의 수를 의미한다.
                     # available_to_get 값은 가져와야할 수보다 크거나 같을 수도 있고, 작을수도 있다.
                     # 작지 않다면 이번 segment 에서 가져올 데이터가 충족된다는의미이고
                     # 작다면 부족하다는 뜻이다.
                     # 나머지 모자르는 부분은 다음 segment 에서 가져와야 한다
                     if ($row->get_rows > $available_to_get) {
                        # 여기 들어왔다는건 available_to_get 로는 부족하다는 뜻이다.
                        # 일단 이번 segment 에서 가져올수 있는 양만큼은 가져오고 부족한 양은 다음 segment 에서 채우도록 한다
                        $row->get_rows = $available_to_get;
                     }
                     # 앞으로 가져와야할 자료의 총 수를 의미하는 dutie 에서 현재 가져오기로 확정된 수만큼을 빼준다
                     $dutie -= $row->get_rows;

                     # 이렇게 해서 만든 segmnt_set 를 segment_list 에 추가해준다
                     # segmnt_set 이란 결국 본 segment 에서 가져올 구체적 범위라고 보면 된다.
                     # 이렇게 모은 segment_list 에 있는 범위들을 DB에서 조회하게될것이다.
                     $segment_list[] = $row;
                     if ($dutie <= 0) {
                        # 더 이상 가져올 자료가 없다는것이다
                        # 루프를 종료한다
                        $working = false;
                     }
                  }
               }
            }
         }

         # 로직과는 무관 (시간측정 등을 위한 코드)
         $end_sub = get_time();
         $lookup_segment[] = ['duration'=>$end_sub - $start_sub, 'num_rows'=>$result->num_rows, 'query'=>$query];
      }

      # 로직과는 무관 (시간측정 등을 위한 코드)
      print "segment 탐색을 위해 사용된 쿼리 목록과 각각의 소요시간\n";
      $total_value = 0;
      foreach($lookup_segment as $value) {
         $total_value+=$value['duration'];
      }
      print_r($lookup_segment);
      print "segment 탐색 전체소요시간: ".$total_value;
      print "\n";
      
      # lookup_segment 는 로직과는 무관 (시간측정 등을 위한 코드)
      $lookup_segment = [];

      # 게시물을 담기위해 사용될 배열
      $page_result = [];
      if($fast_mode) {
         # 로직과는 무관 (시간측정 등을 위한 코드)
         $start_sub = get_time();

         # fast_mode 를 사용할때 페이지에 포함시킬 자료의 seq_number 준비하기
         $seq_list = [];
         if($fast_mode) {
            $all_count = $mysqli->query("SELECT count(*) as cnt FROM board_body")->fetch_object()->cnt;
            $start_number = $all_count - $row_count_skip;
            for($i=0;$i<$amount_to_show_a_page;$i++) {
               $seq_list[] = $start_number-$i;
            }
         }
         $query = "SELECT * FROM board_body WHERE seq_number in (".join(",", $seq_list).")";
         $result = $mysqli->query($query);
         while($row = $result->fetch_object()) {
            $page_result[] = $row;
         }

         # 로직과는 무관 (시간측정 등을 위한 코드)
         $end_sub = get_time();
         $lookup_segment[] = ['duration'=>$end_sub - $start_sub, 'query'=>$query];
      } else {
         foreach($segment_list as $key=>$list) {

            # 로직과는 무관 (시간측정 등을 위한 코드)
            $start_sub = get_time();

            $query = "select * from board_body where segment_no=".$list->segment_no." order by seq_number desc limit ".$list->start_row.", ".$list->get_rows."";         
            $result = $mysqli->query($query);
            while($row = $result->fetch_object()) {
               $page_result[] = $row;
            }

            # 로직과는 무관 (시간측정 등을 위한 코드)
            $end_sub = get_time();
            $lookup_segment[] = ['duration'=>$end_sub - $start_sub, 'query'=>$query];
         }
      }
      print "******************************\n";
      print "가져온 게시물\n\n";
      foreach($page_result as $key=>$list) {
         print $list->seq_number."\n";
      }
      print "******************************\n\n";
      # 로직과는 무관 (시간측정 등을 위한 코드)

      print "실제 자료를 가져오기 위해 사용된 쿼리 목록과 각각의 소요시간\n";
      $total_value = 0;
      foreach($lookup_segment as $value) {
         $total_value+=$value['duration'];
      }
      print_r($lookup_segment);
      print "실제 자료가져오기 전체시간: ".$total_value;
      print "\n";
      print "\n";
   }   
   if($_GET["mode"] === "insert") {
      # 글 입력

      # 입력할 글의 갯수
      $amount = 10000;

      # WRITE LOCK 차지하기
      # 동시적인 데이터 접근으로 인해 데이터의 무결성이 파괴되는것을 막는다.
      # 한 접속이 READ 중일때는 다른 접속들이 READ 하는것과는 경합이 발생하지 않는다. 그러나 다른접속들이 WRITE 하는것은 READ 접속이 끝나야 이루어진다
      # WRITE 가 이루어지고있을때는 WRITE 락을 차지한 접속외에 다른접속들은 READ/WRITE 모두 대기하게된다
      $mysqli->query("LOCK TABLES board_body WRITE, board_segments WRITE");

      for($i=0;$i<$amount;$i++) {

         # 현재 가장 큰 segment 번호를 가져옴
         $max_segment = get_max_segment_no();

         # 현재 가장 큰 segment 번호에 해댕하는 자료의 갯수를 가져옴
         $article_count_in_max_segment = get_article_count_for_segment_no($max_segment);

         # segment 번호가 하나도존재하지않거나 이미 현재 가장 큰 segment 번호에 할당될수 있는 자료량이 초과되었을경우에는
         # segment 번호를 1 더해서 새로운 segment 를 만든다
         # 그게 아니라면 현재 segment 에 1을 추가한다
         if($article_count_in_max_segment >= $max_count_of_row_for_a_segment || $max_segment === 0) {
            $max_segment++;
            $mysqli->query("INSERT INTO board_segments (segment_no, article_cnt) VALUES (\"".($max_segment)."\", \""."1"."\");");
         } else {
            $mysqli->query("update board_segments set article_cnt = article_cnt+1 where segment_no =\"".$max_segment."\";");
         }

         # 행 추가
         $mysqli->query("INSERT INTO board_body (segment_no) VALUES (\"".($max_segment)."\");");
      }

      # UNLOCK
      $mysqli->query("UNLOCK TABLES");
   }
   if($_GET["mode"] === "delete") {
      if(isset($_GET["seq"])) {
         $mysqli->query("LOCK TABLES board_body WRITE, board_segments WRITE");
         $seq_number = $_GET["seq"];
         $result = $mysqli->query("select * from board_body where seq_number = '".$seq_number."';");
         $row = $result->fetch_object();

         # 글 지우고
         $mysqli->query("delete from board_body where seq_number = '".$seq_number."';");

         # 글이 포함되있던 segment 의 article_cnt 를 -1 해줌
         # 그리고 글이 포함되있던 segment 의 article_cnt 가 0이 되면 segment 를 제거해줌
         $mysqli->query("update board_segments set article_cnt= article_cnt-1 where segment_no = '".$row->segment_no."';");
         $result = $mysqli->query("select * from board_segments where segment_no = '".$row->segment_no."';");
         $row = $result->fetch_object();
         if($row->article_cnt <= 0) {
            $mysqli->query("delete from board_segments where segment_no = '".$row->segment_no."';");
         }
         $mysqli->query("UNLOCK TABLES");
      }
   }

   print "=========================\n";
   $end = get_time();
   $time = $end - $start;
   print "전체소요시간: ".$time."\n";
   print "=========================\n";
}


댓글
댓글쓰기 폼
공지사항
Total
30,510
Today
24
Yesterday
20
링크
«   2018/09   »
            1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30            
글 보관함