Skip to content
isdnetworks
Go back

브라우저를 닫으니 작업이 중간에 멈췄다

정산 집계를 화면에서 돌리는 기능이 있었다. 오래 걸려서 관리자가 창을 닫는 일이 잦았는데 그때마다 데이터가 반만 들어가 있었다.

Table of contents

Open Table of contents

증상 — 창을 닫으면 반만 들어갔다

웹 요청으로 도는 처리는 클라이언트가 끊으면 같이 끝난다.

진행 상황을 중간중간 내보내던 화면이라 PHP 가 그 출력 시점에 끊김을 알아채고 죽는다. 집계가 어디까지 갔는지와 무관하게 그 자리에서 멈추므로 남은 자료는 반쪽이 된다.

관리자 쪽에서는 창을 닫은 것이 처리를 중단시킨다는 감각이 없다. 몇 분을 기다리다 닫고 나중에 다시 눌러 보는 것이 자연스러운 행동이었다.

원인 — 무시하게 켜는 것으로는 부족했다

중단을 무시하게 켜는 방법이 있다.

ignore_user_abort(true);
set_time_limit(0);

ignore_user_abort 를 켜면 창을 닫아도 안 죽는다. 그런데 이걸로 끝낼 문제가 아니었다.

웹 서버가 요청당 프로세스를 잡고 있다
관리자가 다시 누르면 같은 처리가 또 시작된다
어디까지 됐는지 볼 방법이 없다

셋 다 창을 닫는 것과 별개로 남는다.

php-fpm 프로세스를 몇 분씩 잡으면 다른 요청이 쓸 자리가 줄고 여러 개가 겹치면 자원이 모자란다. 응답이 없다고 다시 누르면 같은 집계가 둘 돌고 어느 쪽이 먼저 끝날지도 모른다.

조치 — 요청과 처리의 분리

화면은 요청만 남기고 끝내게 했다.

public function requestSettle(Request $req)
{
    $jobNo = $this->jobs->create([
        'type'   => 'settle',
        'params' => json_encode(['from' => $req->from, 'to' => $req->to]),
        'state'  => 'queued',
    ]);
    return redirect("/admin/job/{$jobNo}");
}

job 에 행 하나를 만들고 $jobNo 로 보내는 것이 전부라 몇 밀리초에 끝난다.

실제로 돌리는 것은 cron 에 건 job:run 이 한다.

* * * * * /usr/bin/php /app/artisan job:run --type=settle

창을 닫아도 상관없다. 요청은 이미 job 에 남아 있다.

끊김과 처리가 이어져 있던 것을 떼어 놓은 것뿐인데 앞의 세 문제가 함께 사라졌다. php-fpm 을 오래 잡지도 않고 job 행이 진행을 볼 자리가 된다.

설정 — 작업 상태와 중복 방지

작업 상태를 행에 남겼다.

CREATE TABLE job (
  no          bigint       NOT NULL AUTO_INCREMENT,
  type        varchar(32)  NOT NULL,
  state       varchar(16)  NOT NULL,   -- queued/running/done/failed
  progress    int          NOT NULL DEFAULT 0,
  total       int          NOT NULL DEFAULT 0,
  started_at  datetime     DEFAULT NULL,
  ended_at    datetime     DEFAULT NULL,
  error       text,
  PRIMARY KEY (no)
);

화면은 이 행을 읽어서 보여 준다.

집계 중 (1,240 / 4,880)

progresstotal 이 그대로 남으므로 다시 들어와 보면 된다.

UPDATE job SET state='failed', error='시작 후 응답 없음'
WHERE state='running' AND started_at < NOW() - INTERVAL 30 MINUTE;

running 인 채로 오래 있으면 그 처리가 죽은 것으로 판정한다.

중복 실행도 job 표로 막았다.

$dup = $this->jobs->firstWhere([
    'type'  => 'settle',
    'state' => ['queued', 'running'],
    'params'=> json_encode(['from' => $req->from, 'to' => $req->to]),
]);
if ($dup) { return redirect("/admin/job/{$dup->no}"); }

같은 조건이 이미 있으면 새로 만들지 않고 그 행으로 보낸다. 몇 번을 눌러도 실제로 도는 것은 하나다.

검증 — 자를 수 있는 단위와 재실행

앞서 반만 들어간 자료도 정리해야 했다.

집계를 덮어쓰는 형태로 바꿨다.

DELETE FROM settle_daily WHERE settle_date BETWEEN ? AND ?;
INSERT INTO settle_daily (...) SELECT ...;

DELETEINSERT 를 한 트랜잭션에 넣으면 중간에 끊겨도 지운 것이 함께 취소된다.

다만 범위가 넓으면 그동안 오래 잠근다. 자를 수 있는 단위를 찾아 나눴다.

foreach ($this->dates($from, $to) as $d) {
    $this->db->transaction(fn() => $this->settleOneDay($d));
    $this->jobs->progress($jobNo, ++$done, $total);
}

settleOneDay 가 단위라 끊겨도 그 전날까지는 온전하게 남는다.

진행 수를 남겨 두니 다시 돌릴 때 어디부터인지도 정할 수 있었다.

$from = $this->settleDaily->maxDate($from, $to) ?? $from;

maxDate 가 낸 날짜 다음부터 하므로 처음부터 다시 하지 않는다.

이것은 날짜 하나가 통째로 들어갔거나 통째로 안 들어갔거나 둘 중 하나일 때만 맞다. 트랜잭션 단위를 날짜로 맞춘 이유가 여기 있었고 진행 값이 화면 표시가 아니라 재실행의 근거가 된 셈이다.

정리


Share this post on:

Previous Post
커맨드가 없고 엔드포인트만 있었다
Next Post
신뢰할 수 없는 축