첨부 목록에 파일명이 나오는데 눌러도 열리지 않는다는 문의가 왔다. 서버에서 찾아보니 그 파일이 없었다. 한두 개가 아니라 서른 몇 개였다.
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_start 와 trans_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개는 확인 후 삭제했다.
지우기 전에 목록을 남겨서 나중에 필요하면 찾을 수 있게 했다. 정리보다 목록을 남기는 것이 먼저였다.
정리
- 트랜잭션은
MySQL만 되돌린다. 파일 시스템은 그 밖이다 - 같은 블록에 있으면 같이 되돌아갈 것처럼 읽힌다
move_uploaded_file은 실패해도 예외를 안 던지고 거짓만 준다- 반환값을 안 보면 실패가 그대로 지나간다
- 파일을 먼저 저장하고 성공한 뒤에 행을 넣는다
- 어긋날 때 파일만 남는 쪽이 낫다. 행만 남으면 사용자가 오류를 본다
mkdir권한은umask에 깎이므로 인자로 주고is_writable로 확인한다- 반환값을 안 보는 자리를
grep으로 전부 찾는다 - 이미 어긋난 것은 양방향으로 대조한다