사람과 싸우지 않는 코딩을 위하여. 그 제 2탄.

휴먼 에러와의 싸움: 파일 I/O


대원칙: 이번 글은 웹개발에 한정합니다.

사실 내가 여기 적어 놓을 불평불만과 신세한탄은 C++ 같은 걸로 설계도면 편집 프로그램 같은 걸 개발하고 계신 분들께는 매우 하찮은 얘기다. 하지만 웹애플리케이션이 파일을 다뤄야 하는 상황도 없지는 않고, 내가 그걸 해 본 걸 가지고 적는 글이기 때문에, 이런 사소하고 기초적인 썰이라도 누군가에게는 도움 내지 공감이 될 거라는 생각에 과감히 적어 보기로 한다.

이 이상 말이 길어져 봐야 소용없으니 바로 본문부터 가자면…


CDN/CMS: 캐싱/버저닝 정책은 정해져 있는가?

웹사이트를 CMS툴로 개발하지 않고 통짜로 짠다면 반드시 부딪히게 되는 문제가 사용할 이미지/CSS/폰트/JS 등의 버전 관리를 어떻게 할 것이냐의 문제다. 보통은 그냥 파일명을 그대로 두고 덮어써서 업데이트를 해 버리지만, 이렇게 했을 시 그간 이 사이트에 접속해 봤던 사용자들이 상황에 따라서 재수 없이 기존의 자산과 최신 자산을 동시에 보는 수가 생긴다. (물론 이보다는 클라이언트가 “이거 전 버전으로 돌려 주세요”라고 하는 경우가 더 흔하지만.)

이쯤 되면 Git만으로는 부족해지는데, 그렇다고 지금 당장 어느 파일은 어느 버전이 사용돼야 하는가를 관리하기 위해 DB 테이블을 번번이 만들 수는 없는 노릇이다. 사실 그래서 지금 토이 프로젝트로 깔짝거리고 있는 게 이런 문제를 해결하기 위한 NoSQL 헬퍼클래스인데… 자세한 건 일단락이 되면 소개할 기회가 있겠지. (일단 이름은 Janitor라고 지어놨다.)


PHP + Windows = #$^@#$!

대부분의 PHP 개발자들은 자기 로컬로 윈도를 쓴다. 맥은 PHP를 싫어하고 리눅스는 웹브라우저로 결과물 보기가 힘들고 자기 꼬꼬마 시절에 들였던 버릇은 파일질라(나 심각하면 알FTP)로 폴더 전체 선택해서 원격지에 떨구는 뭐 그런 거였거든. 그리고 본격적으로 윈도 환경에서 개발하던 걸 리눅스 환경에 올리다 보면 울고 싶어지기 시작한다!

DIRECTORY_SEPARATOR

윈도에서 파일을 새로 써야 할 상황이 생기면, 이렇게 작성하면 안 된다:

$handle = fopen('storage/download/foo.txt', 'wb');

그 대신, OS와 무관하게 불필요한 오류 없이 새 파일을 쓰려면 이렇게 작성해야 한다:

$handle = fopen(implode(DIRECTORY_SEPARATOR, [
'storage', 'download', 'foo.txt'
]), 'wb');

이게 무슨 난리 부르스냐고? 나도 동감이다.


업로드 위치: 권한은 있는가?

업로드 위치가 문제가 된다고? 놀랍게도 문제가 된다.

마운트된 원격 디렉토리의 소유권 문제

이건 실제 사례인데, CMS에서 CDN으로 이미지를 보내기 위해 여러 방안이 검토되어 결국 “CMS가 설치된 디렉토리 옆에 CDN 원격 디렉토리를 마운트해 놓고 거기에 파일을 꽂게 해주자”가 채택됐다. 심지어 퍼미션도 (지금은 아니지만 한때) 무려 777을 준 폴더였다. 문제는, 이랬는데도 CMS 코드가 해당 디렉토리에 파일 복사 하나를 못 해서 로그로 질질 짜고 있었다는 것이다. 문제가 뭐였을까?

소제목에 써놨듯이 결국은 소유권 문제였다. 마운트된 폴더는 root가 소유권을 갖는데, CMS가 업로드 후처리를 하는 과정은 nginx가 소유한 PHP 워커가 실행하고 있었다. 이러니 파일을 꽂기는커녕 접근이 안 되지. 방법을 백방으로 찾다가 결국은 root 권한으로 실행되는 CMS 크론잡으로 파일 복사를 처리했다. 덕분에 해당 CMS 이용자들은, “서버에서 이미지를 처리하는 데 최대 1분 걸립니다” 같은 영문 모를 메시지를 보면서 사진을 올리고 있다.


이미지: 리사이징과 압축은 하고 있는가?

사용자는 절대 이미지를 최적화해서 올리지 않는다. 그들은 “큰 이미지일수록 좋다”라는 말을 늘상 듣고 살기 때문에 원본부터 올리고 본다. 이 사실을 기억할 필요가 있다.

트래픽 태울 이미지는 1MB를 넘지 않도록

사용하는 호스팅의 대역폭이 무제한이라면 또 모르겠지만, 웬만해서는 ‘본문’에 표출할 이미지는 장당 1000KB 이하가 되도록 해 주자. 사용자들에게 본문 내 이미지를 무한정 올릴 수 있도록 허용해 주면 사용자들은 정말로 무한정으로 사진을 올려 붙인다. 그러므로 그 많은 사진을 퍼오다가 메모리가 튀거나 하지 않도록 애초에 리사이징을 먹일 필요가 있다. 그렇다. 서버단 리사이징 작업은 선택이 아니라 필수이고, 그 처리는 업로드 순간에 되어야 한다.

원본은 반드시 보관할 것

당연한 얘기지만 사용자의 최초 입력은 절대 오염해서는 안 된다. 그러므로 실제 트래픽 태워 서빙할 예정이 있건 없건 어딘가에 원본은 반드시 보관해야 한다. 방법 자체는 복잡하지 않아도 될 것 같다. 나의 경우에는 원본을 적당한 접두사 붙여서 다른 이름으로 복사 떠놓은 다음 원본을 리사이징해 그걸 서빙하고 있다. 서빙되는 파일과 원본 파일은 파일명이 접두사만 다르므로, 원본을 복구할 땐 mv -f 치면 그만이다.

트래픽 태울 이미지의 화질은 좀더 낮아도 된다

‘섬네일’이 특히 그러하고, 꼭 섬네일이 아니더라도 웬만한 ‘삽입자료’로서의 이미지는 대체로 실천적으로 그러한데, 사용자들은 의외로 이미지가 저화질 압축된 것에 크게 불만을 갖지 않는다. 100이 최고인 JPEG 기준으로 말하자면 한 70~80 정도도 괜찮다는 느낌? 자세히 들여다 보면 ‘도트가 튀는’ 게 보일 정도로 강력한 압축률이지만, 사용자에게는 이미지의 화질이 낮은 것보다는 그 이미지가 애초에 늦게 뜨거나 “데이터 절약”당하는 게 더 참기 어려운 것 같다.


이미지: EXIF 정보는 처리하는가?

EXIF는 모바일로 사진 업로드가 가능한 모든 경우에 대해서 절대 잊으면 안 되는 메타데이터다. 기술적으로는 그렇게 큰 도전이 아니지만 사용자들은 헐레벌떡 뛰어와서 야단을 떠는 이슈이기 때문이다. 웹앱 개발 가능한 대다수 언어에서 EXIF 처리는 라이브러리가 있으니, 잊지 말고 사용하도록 한다.

특히 주의할 것: 회전 정보

사용자는 분명 제대로 된 사진을 업로드하므로(이걸 의심하면 안 된다), 업로드 결과로 표출되는 사진이 뒤집어지거나 거꾸러진 것을 보여주면 안 된다. 인터넷 찾아 보면 이미 알려진 솔루션이 많이 있으니 업로드 이미지 저장 루틴에서 반드시 넣어 주도록 한다. (이게 안 들어간 채로 모바일 레디를 주장하며 몇백 만원에 판매되는 솔루션이 없지 않으므로…)

GPS 정보 및 사진 찍힌 시점 정보

EXIF 정보 배열 밑에서 GPS는 GPS 키 아래 들어 있고 사진이 촬영된 시점은 DateTime 아니면 DateTimeOriginal에 저장돼 있다. 커뮤니티 사이트를 만들 경우 이 정보들을 적당히 빼줘야 하거나 뺄 수 있도록 옵션을 제공해야 하는 상황이 온다. 법적 사회적 물의가 빚어지면 독박 쓰는 건 어드민이므로…


이미지: 움짤은 받지 말 것

어떤 종류의 ‘움짤’도 받지 말라. 아무리 GIPHY API가 존재하고 애플이 제공하는 ‘라이브 포토 자바스크립트 키트‘가 있다 하더라도, 그냥 업로드 자체를 거부하라. 레거시가 너무 커서 움짤을 버릴 수 없다면 또 모르겠지만 처음부터 배제하고 갈 수 있으면 그렇게 하라.

결정적 이유: 여러 움짤이 섞이면 골치아프다!

일반적으로, 효율적으로 lazy loading되고 있지 않은 한, 한 페이지 안에 움짤이 동시에 2개 이상 표출되면 반드시 화면이 느려진다. 브라우저가 각 움짤의 프레임 레이트를 가지고 최소공배수를 구해 화면 렌더링을 해야 하기 때문이다. 올라오는 모든 움짤을 GIF로 변환하고 모든 FPS를 일괄로 맞출 수 있다면 또 모르겠지만 그 짓에 사용하는 리소스에 비해 효용은 매우 낮으니, 전략적으로 단호하게 버리고 가라.


text/* 형식의 파일: 인코딩은 확인했는가?

이 죽일 놈의 인코딩! 그러나 영화관 영화 시작 전 안내방송 본다 셈치고 습관처럼 염두에 두어야 한다.

BOM은 최초 파일 출력 단계에서 입력하고 시작하기

여러분이 서버에서 무슨 문서 파일을 굽든지 그걸 한국인이 특정 프로그램으로 열거나 편집할 예정이라면 운명적으로 BOM이 필요하다. 원래 UTF-8 표준은 BOM이 필요 없지만, 메모장부터 엑셀까지 정말 웬만한 한국인용 프로그램들은 BOM의 존재를 전제하므로, 이 믿음과 싸우지 말자는 것. PHP라면 const BOM = "\EF\BB\BF";을 어딘가에 지정해 놓고, 파일에 내용을 쓰기 전에 일단 이것부터 fputs() 시키는 식이다.

.xls(x)를 지원하지 말고 .csv로 길들이기

이건 개인적인 주장이지만… 같은 “엑셀” 자료라 하더라도 일반 엑셀 파일(.xlsx)을 내려달라는 요구에는 응하면 안 된다. CSV가 있기 때문이다. CSV를 굽는 것은 대부분의 웹애플리케이션 구현에서 기본처럼 주어지는 것이지만, 엑셀 스프레드시트를 만드는 것은 완전히 별개의 문제다. 십중팔구 라이브러리가 필요해지고, 그걸 익힐 시간이 필요하고, “기왕이면 첫줄은 굵은 글씨로 고정돼 있으면 좋겠어요!” 운운 별 말도 안 되는 요구가 밀어닥치는 걸 막을 재간이 없기 때문이다.


단순 텍스트: 대용량일 때는 문제가 된다

단순 텍스트 파일이 문제가 된다고? 놀랍게도 문제가 된다.

무턱대고 while 루프 돌면서 append하지 않기

예컨대 지금 PHP에 메모리가 128M 정도 할당돼 있는데 최종적으로 120MB 정도가 될 예정인 txt 파일을 루프 돌면서 작성하면 과연 메모리 초과가 날까 안 날까? 높은 확률로 초과가 난다. fopen(파일명, 'a') 함수는 그 파일 내용 전체를 곧이곧대로 리소스로 만들어 메모리에 꽂아놓고 대기를 타기 때문이다. 따라서 루프 안에서 파일 핸들을 없애는 작업을 번번이 하지 않으면 그 순환문은 절반도 못 돌아서 메모리 초과가 난다. 와 진짜 더럽게 귀찮네!

루프 내 메모리 청소는 당연히 할 일이지만, 사실 대용량 바이너리 “출력”을 처리하는 방안 자체는 그밖에도 과감하고 창의적인 대안들을 시도해 볼 가치가 있다. 왜냐하면 웹애플리케이션은 많은 경우 주어진 권한 안에서 주어진 메모리만 가지고 컴퓨팅을 하게 되기 때문이다. 예컨대 1만 줄 단위로 파일을 쪼개 출력한 다음 붙인다면? 그냥 적당한 헤더 먹여서 Stream 객체로 HTTP 응답에 내려 버린다면? 생각해 보자. 아무 생각 없이 while true 돌리는 것 이상의 생각을 말이다.

황당한 윈도우 브라우저 크래시 사례

이것도 날 야근시킨 실제 사례인데, 윈도에서 영대문자와 숫자가 조합된 8자리 문자열이 1백만 줄 정도 나열된 정말 단순한 txt 파일을 일반적인 업로드 폼에 그대로 집어넣으면 브라우저가 뻗는다. 한번 여러분의 브라우저로 아무 입력폼이나 만들어서 해보시라. 직접 경험해보지 않으면 모른다. 이게 대체 무슨 귀신 곡할 노릇인지.

(짐작 가는) 원인을 공개하자면… 브라우저며 Windows Defender 같은 백그라운드 서비스들이 해당 파일의 바이러스 감염 여부를 체크하다가 단순 바이너리 1백만 줄이 기가 막혀 브라우저를 터뜨려 버리는 것이다! 상황이 이러하므로 문제 해결법도 너무 황당한데, 해당 txt 파일을 한번 압축해 그걸 올리면 아무 문제도 일어나지 않는다!! 더 웃기는 게 뭔지 아시는가?? 일단 어떻게든 업로드에 성공하면, 그 파일이 백만 줄이든 1줄이든 그 처리는 뭐 언제 문제 있었냐는 듯이 말짱하다는 거지!!!

그래서 지금도 해당 입력폼에는 “대용량 자료의 경우 압축해서 올려주세요”라고 적어놓고, 업로드 처리에서도 굳이 파일이 zip인지 체크해서 압축 풀고 파일 붙이고 기존 zip 지우고 하는 삽질을 하시는 중이다. 이건 철저히 사용자단의 문제이지만, 이것 가지고 모질라 재단에 버그 리포팅을 하고 싸울 수는 없었기 때문에 그냥 이렇게 적응하고 있다. 여러분은 좀 덜 고생하실 수 있기를 기원하는 바다.

제3탄 예고

제3탄은 개발자 관점에서의 UX에 대한 내 생각들을 다뤄볼 예정이다. 별 내용은 없고 아마 3탄이 끝일 것 같다.