PHpullh

LANGUAGE COMPARISON

File를 언어별로 비교하기

같은 개념을 실제 예제로 나란히 확인하고, 각 언어의 개별 설명으로 이어갈 수 있습니다.

파일 하나 읽는 코드에 들어 있는 결정

파일 입출력은 언어를 처음 배울 때 가장 빨리 만나는 "바깥 세상"입니다. 그리고 여기서부터 실패가 일상이 됩니다. 경로가 틀릴 수 있고, 권한이 없을 수 있고, 읽는 도중 디스크가 찰 수 있습니다. 아래 다섯 언어의 예제를 볼 때 문법보다 세 가지를 먼저 확인하시면 좋습니다. 파일 전체를 한 번에 메모리로 올리는가 스트리밍하는가, 다 쓴 뒤 핸들을 누가 닫는가, 그리고 실패를 어떤 형태로 알려 주는가입니다.

한 번에 읽는 API는 어느 언어에나 있습니다. Kotlin의 readText, Python의 read, Go의 os.ReadFile, Java의 Files.readString, PHP의 file_get_contents가 같은 역할입니다. 코드가 한 줄로 끝나 매력적이지만 파일 크기가 곧 메모리 사용량이 됩니다. 로그 파일이나 사용자 업로드처럼 크기를 통제할 수 없는 입력에 이 API를 쓰면, 개발 환경에서는 잘 돌다가 운영에서 메모리를 다 먹고 죽습니다. 크기를 모르는 입력은 줄 단위나 버퍼 단위로 처리하는 것이 원칙입니다.

Kotlin

파일 읽기/쓰기 기초

Kotlin에서 파일을 읽고 쓰는 가장 간단한 방법. java.io.File 확장 함수를 활용합니다.

import java.io.File

fun main() {
    val file = File("hello.txt")

    // 쓰기
    file.writeText("Hello, Kotlin!
한글도 OK", Charsets.UTF_8)

    // 전체 읽기
    val content = file.readText(Charsets.UTF_8)
    println(content)

    // 줄 단위 읽기
    file.readLines().forEach { println(it) }

    // 줄 단위 처리 (대용량 파일)
    file.forEachLine { line ->
        println(line.trim())
    }

    // 추가 쓰기 (append)
    file.appendText("
추가 내용")

    // 파일 존재 확인 후 삭제
    if (file.exists()) file.delete()
}

Python

파일 I/O & pathlib

pathlib으로 경로를 객체지향적으로 처리하고 파일을 안전하게 읽고 씁니다.

from pathlib import Path
import json

# pathlib — 경로 조작
base = Path("/home/user/projects")
file = base / "data" / "config.json"   # / 연산자로 결합

print(file.name)        # config.json
print(file.stem)        # config
print(file.suffix)      # .json
print(file.parent)      # /home/user/projects/data

# 파일 읽기 (with문으로 자동 닫기)
sample = Path("sample.txt")
sample.write_text("Hello\nWorld\nPython", encoding="utf-8")

# 전체 읽기
content = sample.read_text(encoding="utf-8")

# 줄 단위 읽기
with sample.open(encoding="utf-8") as f:
    for line in f:                  # 메모리 효율적
        print(line.rstrip())

# 파일 쓰기
with open("output.txt", "w", encoding="utf-8") as f:
    f.write("첫 번째 줄\n")
    f.writelines(["두\n", "세\n"])

# JSON 처리
data = {"name": "Alice", "scores": [95, 87, 92]}
Path("data.json").write_text(
    json.dumps(data, ensure_ascii=False, indent=2),
    encoding="utf-8"
)
loaded = json.loads(Path("data.json").read_text(encoding="utf-8"))

# 디렉토리 순회
for p in Path(".").glob("*.py"):
    print(p)

for p in Path(".").rglob("*.json"):  # 재귀
    print(p)

Go

파일 I/O & os 패키지

Go의 파일 처리. os, bufio, io 패키지를 활용합니다.

package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

func main() {
	// 파일 쓰기
	err := os.WriteFile("test.txt",
		[]byte("Hello\nGo\nWorld\n"), 0644)
	if err != nil { panic(err) }

	// 파일 읽기 (전체)
	data, err := os.ReadFile("test.txt")
	if err != nil { panic(err) }
	fmt.Print(string(data))

	// bufio로 줄 단위 읽기 (대용량 파일에 적합)
	f, err := os.Open("test.txt")
	if err != nil { panic(err) }
	defer f.Close()

	scanner := bufio.NewScanner(f)
	lineNum := 0
	for scanner.Scan() {
		lineNum++
		line := scanner.Text()
		fmt.Printf("%d: %s\n", lineNum, line)
	}
	if err := scanner.Err(); err != nil {
		fmt.Println("읽기 에러:", err)
	}

	// 파일 추가 쓰기 (append)
	af, err := os.OpenFile("test.txt",
		os.O_APPEND|os.O_WRONLY, 0644)
	if err != nil { panic(err) }
	defer af.Close()
	fmt.Fprintln(af, "추가된 줄")

	// strings.NewReader — 문자열을 io.Reader로
	r := strings.NewReader("hello world")
	bufReader := bufio.NewReader(r)
	word, _ := bufReader.ReadString(' ')
	fmt.Printf("첫 단어: %q
", strings.TrimSpace(word))

	// 환경 변수
	home := os.Getenv("HOME")
	fmt.Println("홈 디렉토리:", home)

	// 임시 파일
	tmp, err := os.CreateTemp("", "myapp-*.txt")
	if err != nil { panic(err) }
	defer os.Remove(tmp.Name())
	fmt.Println("임시 파일:", tmp.Name())
	tmp.Close()
}

Java

NIO2 — Path & Files & WatchService

Java 7+ NIO2의 Path와 Files로 파일 처리를 현대적으로 합니다.

import java.nio.file.*;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
import java.util.stream.Stream;

public class NIO2Demo {
    public static void main(String[] args) throws IOException {
        Path dir  = Path.of("temp");
        Path file = dir.resolve("hello.txt");

        // 디렉토리 & 파일 생성
        Files.createDirectories(dir);
        Files.writeString(file, "Hello, NIO2!\n한글도 OK",
            StandardCharsets.UTF_8);

        // 읽기
        String content = Files.readString(file, StandardCharsets.UTF_8);
        System.out.println(content);

        // 줄 단위 — Stream<String> (대용량에 적합)
        try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
            lines.filter(l -> !l.isBlank())
                 .map(String::trim)
                 .forEach(System.out::println);
        }

        // 파일 복사, 이동, 삭제
        Path copy = dir.resolve("copy.txt");
        Files.copy(file, copy, StandardCopyOption.REPLACE_EXISTING);
        Files.move(copy, dir.resolve("moved.txt"),
            StandardCopyOption.REPLACE_EXISTING);
        Files.deleteIfExists(dir.resolve("moved.txt"));

        // 디렉토리 탐색
        try (Stream<Path> paths = Files.walk(dir)) {
            paths.filter(Files::isRegularFile)
                 .forEach(p -> System.out.println(p.getFileName()));
        }

        // 파일 속성
        var attr = Files.readAttributes(file,
            java.nio.file.attribute.BasicFileAttributes.class);
        System.out.println("크기: " + attr.size() + "B");
        System.out.println("생성: " + attr.creationTime());

        // Path 조작
        System.out.println(file.getFileName());  // hello.txt
        System.out.println(file.getParent());    // temp
        System.out.println(file.toAbsolutePath());

        // 정리
        Files.deleteIfExists(file);
        Files.deleteIfExists(dir);
    }
}

PHP

파일 읽기

작은 파일을 한 번에 읽을 때 가장 간단한 file_get_contents 예제입니다.

<?php
$path = __FILE__;
echo file_get_contents($path) !== false ? 'loaded' : 'failed';

JavaScript

텍스트 파일 읽기

node:fs의 readFileSync로 package.json을 UTF-8 문자열로 읽은 뒤 앞 20자만 잘라 출력합니다. 프로그램 시작 시점에 작은 설정 파일을 한 번 읽는 정도라면 이 형태로 충분합니다. 세 번째 인자인 인코딩을 빼면 반환 타입이 string이 아니라 Buffer가 되어 slice의 의미까지 달라집니다.

// 텍스트 파일 읽기
import { readFileSync } from "node:fs";
console.log(readFileSync("package.json", "utf8").slice(0, 20));

TypeScript

텍스트 파일 읽기

node:fs의 readFileSync로 package.json을 UTF-8 문자열로 읽은 뒤 앞 20자만 잘라 출력합니다. 프로그램 시작 시점에 작은 설정 파일을 한 번 읽는 정도라면 이 형태로 충분합니다. 세 번째 인자인 인코딩을 빼면 반환 타입이 string이 아니라 Buffer가 되어 slice의 의미까지 달라집니다.

// 텍스트 파일 읽기
import { readFileSync } from "node:fs";
console.log(readFileSync("package.json", "utf8").slice(0, 20));

C#

텍스트 파일 읽기

node:fs의 readFileSync로 package.json을 UTF-8 문자열로 읽은 뒤 앞 20자만 잘라 출력합니다. 프로그램 시작 시점에 작은 설정 파일을 한 번 읽는 정도라면 이 형태로 충분합니다. 세 번째 인자인 인코딩을 빼면 반환 타입이 string이 아니라 Buffer가 되어 slice의 의미까지 달라집니다.

// 텍스트 파일 읽기
using System;
using System.IO;

File.WriteAllText("sample-1.txt", "hello");
Console.WriteLine(File.Exists("sample-1.txt"));

C++

텍스트 파일 읽기

node:fs의 readFileSync로 package.json을 UTF-8 문자열로 읽은 뒤 앞 20자만 잘라 출력합니다. 프로그램 시작 시점에 작은 설정 파일을 한 번 읽는 정도라면 이 형태로 충분합니다. 세 번째 인자인 인코딩을 빼면 반환 타입이 string이 아니라 Buffer가 되어 slice의 의미까지 달라집니다.

// 텍스트 파일 읽기
#include <fstream>
#include <iostream>

int main() {
    std::ofstream out("sample-1.txt");
    out << "hello";
    out.close();
    std::cout << "written\n";
}

Rust

텍스트 파일 읽기

node:fs의 readFileSync로 package.json을 UTF-8 문자열로 읽은 뒤 앞 20자만 잘라 출력합니다. 프로그램 시작 시점에 작은 설정 파일을 한 번 읽는 정도라면 이 형태로 충분합니다. 세 번째 인자인 인코딩을 빼면 반환 타입이 string이 아니라 Buffer가 되어 slice의 의미까지 달라집니다.

// 텍스트 파일 읽기
use std::fs;
fn main() {
    fs::write("sample-1.txt", "hello").unwrap();
    println!("written");
}

닫는 책임을 언어가 어떻게 나누는가

열린 파일 디스크립터는 유한한 자원입니다. 닫지 않으면 프로세스가 한도에 걸려 새 파일도 새 소켓도 열지 못하는 상태가 되는데, 증상이 파일과 무관한 곳에서 나타나기 때문에 원인을 찾기 어렵습니다. 다섯 언어가 이를 각자의 장치로 막습니다. 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의 기본 파일 객체가 이미 그 층을 갖고 있으니 굳이 우회할 이유는 거의 없습니다. 반대로 이미 메모리에 다 올린 데이터를 다시 버퍼로 감싸는 것은 불필요한 복사만 늘립니다.