시리즈 소개

이 시리즈는 전임자의 코드(와 직업윤리)를 ㅈ내 까면서 레거시 PHP 소스를 유지보수/리팩토링한 이력을 기술하는 시리즈입니다.
나쁘게 보면 제 우물에 침 뱉기이긴 하지만 좋게 보면 우리네 개발자들의 웃음과 애환이 서린 구비문학일 수 있겠지요.
혈압 상승 및 짠내 주의.

문제 상황의 규명

공휴일을 보내고 맞은 금요일 아침이었다. 다들 일하기 싫어 죽으려는 차에 문득 에러 보고가 들어온다.

관리자에서 출석 이벤트 참가자 목록 엑셀 다운이 안 돼요. 확인 부탁드립니다.

이것부터 벌써 나쁜 소식인데 심지어 잘 되던 게 안된단다. 아놔... 그래서 입사 이래 처음으로 해당 부분 소스를 까봤다.

function downloadExcel(r) {
    var msg = "엑셀로 받을까요?";
    if(confirm(msg)==1) {
        var url = '/api/attendance-user-excel?reg_date='+r;
        var form = $('form[name=form-etc-list]');
        var data = {'reg_date':r};
        network(url, data, function(e){
            if(e.code == 200) {
                location.href = "/admin/board-attendance-excel";
            } else {
                custom_popup(e.code + " Error", function() {
                });
            }
        },null,false, true);
    }
}

Y-m-d 형태의 reg_date를 받아서 /api/attendance-user-excel API를 친 다음 그게 성공하면 /admin/board-attendance-excel라우트로 이동하는 로직이다. 뭐야? 파일 다운로드 하나에 무슨 요청이 2개나 필요해?

그래서 까봤더니 API쪽 소스는 대충 이렇게 생겼다.

public function foo() {
    $request            = Input::all();
    $reg_date           = Input::get("reg_date");
    $sql = "exec USP_어쩌구 @reg_date = '{$reg_date}'";
    $board = DB::select($sql);
    $list = "<table border='1'><tr>td 태그 대충 열몇 개</tr>";
    foreach($board as $key => $target) {
        // $뭐시기 = $target->뭐시기; 하는게 대충 열몇 개
        $list .= "<tr>$뭐시기 받는 td 태그 대충 또 열몇 개</tr>";
    }
    $list .= "</table>";
    Storage::disk('업로드 위치')->put("파일.xls",$list);
    return $this->jsonOk('ok');
}

그리고 그게 성공했을 때 이동되는 해당 라우트는 뭐 이딴 식이었다.

public function bar() {
    $file_name = "파일명.xls";
    $file = "/home/업로드 위치/파일명.xls";
    $file_size = filesize($file); // <-- 일단 에러는 여기서 발생함
    Header("Content-type: file/unknown");
    Header("Content-Disposition: attachment; filename=$file_name");
    Header("Content-Description: PHP Generated Data");
    header("Pragma: no-cache");
    header("Expires: 0");
    if (is_file($file)) {
        $fp = fopen($file, "r");
        if (!fpassthru($fp))
            fclose($fp);
    }
}

그렇다. 일단 API가 파일을 물리적으로 굽는다. 그리고 나서는 그 파일이 있다는 전제를 단단히 깔고 그 파일을 사용자에게 fopen()한 내용 형태로 돌려주고 있었던 것이다. 그러니, 서버가 잘 돌아가고 DB도 잘 붙고 프로시저 결과값도 잘 떨어지고 파일 생성까지 다 잘 되더라도, 막판에 폴더 쓰기 권한이 재수 없게 걸리면, filesize()가 실행이 안 되면서 운영단 서비스가 "장애"가 나버리는 것이다.

잘났다 정말!!!

해결 방침의 확립

잠시 고민을 해봤다. 운영단에서는 이 스프레드시트가 지금 당장 필요하다. (공휴일을 보내고 온 금요일 아침이라는 점을 기억하자.) 사실은 파일 자체는 (아마도) 정상 생성되고 있으므로 그냥 그걸 적당히 다시 만들어서 갖다주면 그만일 것이다... 그런데 두 가지 변수가 일순간 머릿속을 번뜩 스친다.

  1. 분명 운영단에서는 "5월 것만 있으면 된다" 했지만, 만약 알고보니 6월 것도 있어야 한다면? 그때도 나한테 파일 만들어 달라고 찾아올 것인가??
  2. 파일이 정상 생성된 게 아니라면? 애초에 파일의 존재, 그 내용 또는 파일 접근 권한 자체를 신뢰할 수 없다면? git pull 칠때마다 해당 폴더 권한을 점검해야 할 것인가??

그도 그럴 것이 이 파일이 정상적이고 논리적인 .xlsx 파일일 것 같지가 않은 것이다. 컬럼-값 맵핑도 너무 자의적이고, 그냥 <table> 와꾸를 그려서 억지로 엑셀이라고 우기는 것 아닌가. 이건 옳지 않다. 이건 고쳐야 한다.

그래서 재설계 방침을 세 가지로 정했다.

  1. 하나의 요청으로 최종 파일 반환까지를 끝낸다. 사용자가 원하는 것은 파일 하나뿐이지, 파일의 생성과 그 획득이라는 2가지를 원하지 않는다.
  2. 좀 제대로 된 파일을 내려준다. 요컨대 mime type을 맞추자는 것. 과감하게 .csv를 채택한다.
  3. 파일을 저장하지 말고 바로 반환한다. 결과가 막 백만줄씩 나오고 하면 또 모르겠지만, 이 파일들은 보관이 필요하지 않으므로 저장 단계는 필요없다.

해결 방안의 실행

새 라우트를 정의했고 그 라우트가 받을 컨트롤러 메소드를 새로 짰다.

public function download(Request $request)
{
    if (!$reg_date = $request->input('reg_date')) return $this->alertAndGoBack(401, '필수값 누락!');
    $datetime = \DateTime::createFromFormat('Y-m-d', $reg_date);
    if (!$datetime || $datetime->format('Y-m-d') !== $reg_date) return $this->alertAndGoBack(401, '날짜는 YYYY-MM-DD 형식으로 제공하세요.');

    $columns = [
        'reg_date' => '날짜',
        'user_no' => '고객번호',
        // 중략
        'create_at' => '생성일',
    ];

    ini_set('max_execution_time', 600);

    return response()->stream(function () use ($columns, $reg_date) {
        $csv = fopen('php://output', 'w');
        fputs($csv, "\xEF\xBB\xBF");
        fputcsv($csv, array_values($columns));

        $pdo = DB::getPdo();
        $sql = $pdo->prepare("exec USP_어쩌구 @reg_date = '{$reg_date}'");
        $sql->execute();
        while ($board = $sql->fetch(\PDO::FETCH_ASSOC)) {
            $fields = [];
            foreach (array_keys($columns) as $field) $fields[] = $board[$field];
            fputcsv($csv, $fields);
        }

        fclose($csv);
    }, 200, [
        'Content-Type' => 'text/csv',
        'Content-Disposition' => 'attachment;filename=board-attendance-'.$reg_date.'.csv',
    ]);        
}

대충 설명하면 이렇다.

  1. 최소한의 입력값 검증을 한다. 사실 더 엄격하게 해야 하지만 배보다 배꼽이 커질 것 같아서 Validator는 쓰지 않음.
  2. 쿼리를 때려서 나올 결과들의 필드명과, 그 필드명이 실제 CSV에서 읽혀야 할 컬럼명을 맵핑하여 한 번 정의한다.
  3. 혹시 모르니까 넉넉잡고 최대 10분 실행하도록 한다.
  4. CSV 파일 핸들러를 만들고 BOM과 컬럼 행을 넣어둔다. 이때 방금 정의한 맵핑을 이용한다.
  5. 주어진 쿼리를 딱 때린 결과를 무한 루프 돌면서 한 줄씩 파일 핸들에 넣는다. 이때도 방금 정의한 맵핑을 이용한다.
  6. 이 모든 과정을 스트리밍 방식으로 text/csv 형식 먹여서 응답으로 돌려준다.

그리고 뭐 아무 문제 없이 훨씬 더 빠르게 안정적으로 잘만 돌아간다. 전임자가 또 한 번 원망스러워지는 순간이다. 이게 뭐가 어렵다고??!!

실행의 평가와 전망

일단 스스로는 매우 성공적으로 레거시를 걷어내었다고 자부하고 있다.

  • 그간 파일명이 항상 고정되어 있던 것을 이번에 reg_date 따라서 적절히 변경되도록 했다. 이제 운영단에서는 "어 내가 이 파일을 받았었나?" 하고 헷갈릴 일이 없을 것이다.
  • Symphony의 stream() 응답은 쿼리 결과를 무한 루프 돌면서 지속적으로 반환해 주기에 적절한 방식이다. 파일을 만드는 작업이 꼭 필요하다면 그건 백그라운드 잡 등으로 처리되어야 하지만, 다행히 이 경우에는 그럴 필요가 없으므로, 아예 파일을 물리적으로 굽지 않아도 된다.
  • 여러 요청이 오고가면서 그 중 하나가 실패를 할 여지가 거의 차단되었다.

그래도 예전에 해 봤던 삽질이라서 그런가 쉽고 빠르게 오전 중에 끝내서 라이브 배포까지 했다. 운영단은 xlscsv로 바뀐 걸 눈치채지 못한 듯하다. 내 그럴 줄 알았지.

이제 할 일들은 다음과 같다.

  • 쿼리 결과값 모두 엄격하게 문자열로 타입 주기. "01234"와 같은 입력값은 최종적으로 엑셀에서 열었을 때 1234로 포맷팅돼 버리는 것 같다.
  • 예외 방어. 결과가 없을 경우, 프로시저가 뻑날 경우 등.
  • 새 트레이트 또는 RequestFactory로 추상화하여 재사용성 확보. 여기 말고도 엑셀 출력을 하는 다른 버튼들이 꽤 있어서 재사용을 좀 해야 할 것 같다. 찾아보니 라라벨에서 response()->csv()를 쓸 수 있게 해주는 패키지가 있는 모양인데 그거 한번 알아보기.

이 시리즈는 계속된다… 내 전임자들의 무책임 코드가 사라지는 그날까지…