닫는 책임을 언어가 어떻게 나누는가
열린 파일 디스크립터는 유한한 자원입니다. 닫지 않으면 프로세스가 한도에 걸려 새 파일도 새 소켓도 열지 못하는 상태가 되는데, 증상이 파일과 무관한 곳에서 나타나기 때문에 원인을 찾기 어렵습니다. 다섯 언어가 이를 각자의 장치로 막습니다. Python은 with 블록, Kotlin은 use, Java는 try-with-resources, Go는 defer f.Close()가 그것이고, PHP는 변수의 참조가 사라질 때 정리해 주지만 장기 실행 프로세스에서는 명시적으로 fclose를 부르는 편이 안전합니다.
여기에 잘 알려지지 않은 함정이 있습니다. 쓰기에서는 닫는 시점에 실제 오류가 나올 수 있습니다. 버퍼에 남아 있던 데이터가 close 순간에 디스크로 나가면서 실패할 수 있기 때문입니다. Go에서 defer f.Close()로 반환값을 버리는 관용구는 읽기에는 문제없지만 쓰기에서는 오류를 잃습니다. 쓰기 경로에서는 닫기 결과까지 확인해야 하고, Java와 Python에서도 버퍼를 쓸 때 flush 실패가 같은 자리에서 발생합니다.
텍스트 파일에는 인코딩이라는 숨은 인자가 있습니다
"텍스트를 읽는다"는 동작은 사실 바이트를 문자로 해석하는 결정을 포함합니다. Kotlin과 Go의 기본 문자열 처리는 UTF-8을 전제하고, Java의 Files.readString도 UTF-8을 씁니다. 반면 인코딩을 지정하지 않는 오래된 API들은 실행 환경의 기본 인코딩을 따르기 때문에, 같은 코드가 개발자 노트북과 서버에서 다른 결과를 냅니다. 한글이 깨지는 문제의 상당수가 코드가 아니라 이 기본값 차이에서 나옵니다. 인코딩은 항상 명시하는 편이 낫습니다. 여기에 줄바꿈 문자와 BOM이 더해지면 CSV 파싱 결과가 조용히 어긋나기도 합니다.
실패 신호의 모양도 언어마다 다릅니다. Go는 오류를 두 번째 반환값으로 주므로 무시하려면 명시적으로 버려야 합니다. Java, Kotlin, Python은 예외를 던집니다. PHP의 file_get_contents는 실패 시 false를 돌려주는데, 이 값을 문자열처럼 계속 쓰면 오류가 아니라 빈 문자열처럼 흘러가 한참 뒤에 이상한 곳에서 터집니다. 반환값 확인을 건너뛰면 안 되는 대표적인 API입니다.
마지막으로 쓰기 안전성에 대한 공통 관용구를 하나 남깁니다. 기존 파일을 덮어쓰는 도중 프로세스가 죽으면 파일은 반쯤 쓰인 상태로 남습니다. 임시 파일에 전부 쓴 다음 이름을 바꿔 치우는 방식은 다섯 언어 모두에서 쓸 수 있고, 같은 파일 시스템 안에서의 이름 변경은 원자적으로 처리되기 때문에 읽는 쪽이 깨진 파일을 보지 않습니다. 설정 파일이나 캐시를 갱신하는 코드라면 처음부터 이 방식으로 짜 두는 편이 좋습니다. 언어별 상세 API는 Python 파일 I/O, Go 파일 I/O, Java NIO2, Kotlin 파일 I/O에 정리돼 있습니다.
경로를 문자열로 다루지 않기
경로를 문자열 이어붙이기로 만드는 코드는 개발 환경에서 잘 돌다가 배포 환경에서 깨집니다. 구분자가 다르고, 상대 경로의 기준이 프로세스 작업 디렉터리라 실행 위치에 따라 달라지며, 사용자 입력이 섞이면 상위 디렉터리로 빠져나가는 경로 조작 취약점이 됩니다. Python의 pathlib, Java의 Path, Go의 path/filepath, Kotlin의 File 확장이 이 문제를 위해 존재합니다.
사용자가 준 파일 이름을 그대로 경로에 붙이는 코드라면 정규화한 뒤 허용된 기준 디렉터리 안에 있는지 반드시 확인해야 합니다. 확장자 검사만으로는 부족하고, 심볼릭 링크가 밖을 가리키는 경우까지 고려해야 합니다. 파일 I/O는 언어 문법이 단순한 만큼 보안 검토가 소홀해지기 쉬운 영역입니다.
덧붙이자면 대용량 처리에서는 버퍼 크기도 무시할 수 없습니다. 한 줄씩 읽는 API가 내부적으로 시스템 호출을 매번 하지 않도록 버퍼를 끼워 주는 것이 각 언어의 관례인데, 이 버퍼를 건너뛰고 저수준 읽기를 직접 반복하면 처리량이 크게 떨어집니다. Go의 bufio, Java의 BufferedReader, Python의 기본 파일 객체가 이미 그 층을 갖고 있으니 굳이 우회할 이유는 거의 없습니다. 반대로 이미 메모리에 다 올린 데이터를 다시 버퍼로 감싸는 것은 불필요한 복사만 늘립니다.