Skip to content
isdnetworks
Go back

행은 있는데 파일이 없었다

첨부 목록에 파일명이 나오는데 눌러도 열리지 않는다는 문의가 왔다. 서버에서 찾아보니 그 파일이 없었다. 한두 개가 아니라 서른 몇 개였다.

Table of contents

Open Table of contents

저장 순서를 봤다

업로드 처리는 이랬다.

$this->db->trans_start();

$this->db->insert('board_file', [
    'board_no'  => $no,
    'file_name' => $newName,
    'org_name'  => $orgName,
    'file_size' => $size,
]);

move_uploaded_file($tmp, $dir . $newName);

$this->db->trans_complete();

행을 넣고 파일을 옮긴다. 둘 다 trans_starttrans_complete 사이에 있으니 같이 되돌아갈 것처럼 보이지만 아니다.

트랜잭션은 MySQL 만 되돌리고 파일 시스템은 그 밖이다. 코드 모양이 그렇게 읽히는 것이 이 문제를 오래 살아남게 했다.

원인 — 실패해도 조용했다

move_uploaded_file 은 실패해도 예외를 안 던지고 거짓을 돌려줄 뿐이다. 반환값을 안 보니까 그대로 넘어가고 트랜잭션은 성공으로 끝난다.

왜 실패했는지 보니 디렉터리 권한이었다. 날짜별 디렉터리를 만들어 쓰는데 그 달 디렉터리가 없으면 만들게 돼 있었다.

$dir = UPLOAD_PATH . date('Ym') . '/';
if (!is_dir($dir)) mkdir($dir);

mkdir 이 권한 인자 없이 불려서 umask 에 깎인 권한으로 만들어졌다. 어떤 경우 웹 서버가 못 쓰는 권한이 됐다.

월이 바뀌는 날 만들어진 디렉터리에서 그 달 내내 실패했다. 그래서 서른 몇 개가 특정 기간에 몰려 있었다. 만든 뒤에 쓸 수 있는지 확인하는 자리가 없어서 그 실패도 조용했다.

순서를 바꿨다

파일을 먼저 저장하고 성공한 뒤에 행을 넣게 했다.

if (!move_uploaded_file($tmp, $dir . $newName)) {
    return ['ok' => false, 'msg' => '파일 저장 실패'];
}

$this->db->trans_start();
$this->db->insert('board_file', [...]);
$this->db->trans_complete();

if ($this->db->trans_status() === false) {
    @unlink($dir . $newName);      // 행이 안 들어갔으면 파일도 지운다
    return ['ok' => false, 'msg' => 'DB 저장 실패'];
}

이러면 어긋나는 경우가 하나로 줄어든다. 파일은 있는데 행이 없는 경우다. unlink 로 지우게 했지만 그것도 실패할 수 있다.

파일만 남는 쪽이 행만 남는 쪽보다 낫다. 화면에 안 나오고 나중에 대조해서 지우면 되는데 반대는 사용자가 오류를 본다.

같은 종류의 함수를 쓰는 자리도 다 찾아봤다.

$ grep -rn "move_uploaded_file\|mkdir\|unlink\|copy(" --include=*.php application/

열한 군데였고 반환값을 확인하는 곳은 세 군데뿐이었다. 파일 관련 함수는 실패해도 예외를 안 던지는 것이 많고 경고만 나오는데 경고는 운영에서 꺼져 있다.

전부 확인하게 고치고 실패하면 경로와 사유를 로그에 남겼다.

if (!move_uploaded_file($tmp, $target)) {
    log_message('error', "파일 저장 실패: {$target} (권한: " . substr(sprintf('%o', fileperms($dir)), -4) . ")");
}

fileperms 로 권한도 같이 찍었다. 이번 경우 그게 원인이었기 때문이다.

디렉터리를 만드는 자리도 고쳤다.

if (!is_dir($dir)) {
    mkdir($dir, 0775, true);
}
if (!is_writable($dir)) {
    log_message('error', "업로드 디렉터리 쓰기 불가: {$dir}");
}

권한을 인자로 주고 is_writable 로 실제로 쓸 수 있는지 확인한다.

이미 어긋난 것의 정리

행과 파일을 대조하는 것을 한 번 돌렸다.

$rows = $this->db->query("SELECT file_no, file_name, reg_date FROM board_file")->result();

foreach ($rows as $r) {
    $path = UPLOAD_PATH . date('Ym', strtotime($r->reg_date)) . '/' . $r->file_name;
    if (!file_exists($path)) {
        echo "없음: {$r->file_no} {$r->file_name}\n";
    }
}

반대 방향도 봤다. 디렉터리를 훑어서 행이 없는 파일을 찾는다.

행은 있고 파일 없음: 37개
파일은 있고 행 없음: 4개

한 방향만 보면 반대쪽 불일치를 못 본다. 앞의 37개는 목록에서 지우고 게시자에게 알렸고 뒤의 4개는 확인 후 삭제했다.

지우기 전에 목록을 남겨서 나중에 필요하면 찾을 수 있게 했다. 정리보다 목록을 남기는 것이 먼저였다.

정리


Share this post on:

Previous Post
한 테이블에 다 담으니 인덱스를 못 걸었다
Next Post
조회인 줄 알고 돌렸는데 변경이었다