Skip to content

파일 디스크립터의 모든 것: 리눅스 I/O의 핵심

중급 개발자를 위한 파일 디스크립터 완벽 가이드


목차

  1. 파일 디스크립터란 무엇인가
  2. 파일 디스크립터 테이블의 내부 구조
  3. 표준 입출력과 파일 디스크립터
  4. 파일 디스크립터 조작
  5. 파일 디스크립터와 프로세스
  6. 파일 디스크립터 고급 기법
  7. 파일 디스크립터 제한과 관리
  8. 실전 문제 해결

1. 파일 디스크립터란 무엇인가

1.1 기본 개념

**파일 디스크립터 (File Descriptor, fd)**는 유닉스/리눅스 시스템에서 열린 파일을 참조하는 정수 값입니다.

파일 디스크립터 = 커널이 관리하는 파일 테이블의 인덱스

핵심 특징:

  • 음이 아닌 정수 (0, 1, 2, 3, ...)
  • 프로세스마다 독립적인 파일 디스크립터 공간
  • "모든 것은 파일이다" (Everything is a file) 철학의 구현체

1.2 파일 디스크립터가 가리키는 것들

파일 디스크립터는 단순한 파일 이상을 가리킵니다:

타입설명예시
일반 파일디스크의 파일/etc/passwd, data.txt
디렉터리디렉터리도 파일/home/user/
소켓네트워크 연결TCP/UDP 소켓
파이프프로세스 간 통신`
장치 파일하드웨어 장치/dev/null, /dev/random
심볼릭 링크다른 파일을 가리킴링크 파일
eventfd이벤트 통지커널 이벤트
timerfd타이머타이머 이벤트
signalfd시그널시그널 수신

1.3 Go에서 파일 디스크립터 확인하기

go
package main

import (
    "fmt"
    "net"
    "os"
)

func main() {
    // 1. 일반 파일 열기
    file, err := os.Create("test.txt")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer file.Close()

    // 파일 디스크립터 가져오기
    fileFd := file.Fd()
    fmt.Printf("파일 'test.txt'의 파일 디스크립터: %d\n", fileFd)

    // 2. 네트워크 소켓
    listener, err := net.Listen("tcp", ":0")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer listener.Close()

    // 소켓의 파일 디스크립터 가져오기
    tcpListener := listener.(*net.TCPListener)
    listenerFile, _ := tcpListener.File()
    socketFd := listenerFile.Fd()
    fmt.Printf("TCP 리스너의 파일 디스크립터: %d\n", socketFd)

    // 3. 표준 입출력
    fmt.Printf("표준 입력(stdin)의 파일 디스크립터: %d\n", os.Stdin.Fd())
    fmt.Printf("표준 출력(stdout)의 파일 디스크립터: %d\n", os.Stdout.Fd())
    fmt.Printf("표준 에러(stderr)의 파일 디스크립터: %d\n", os.Stderr.Fd())

    // 4. 파이프 생성
    reader, writer, err := os.Pipe()
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer reader.Close()
    defer writer.Close()

    fmt.Printf("파이프 읽기 끝의 파일 디스크립터: %d\n", reader.Fd())
    fmt.Printf("파이프 쓰기 끝의 파일 디스크립터: %d\n", writer.Fd())
}

출력 예시:

파일 'test.txt'의 파일 디스크립터: 3
TCP 리스너의 파일 디스크립터: 4
표준 입력(stdin)의 파일 디스크립터: 0
표준 출력(stdout)의 파일 디스크립터: 1
표준 에러(stderr)의 파일 디스크립터: 2
파이프 읽기 끝의 파일 디스크립터: 5
파이프 쓰기 끝의 파일 디스크립터: 6

1.4 왜 파일 디스크립터를 사용하는가?

추상화의 힘:

go
package main

import (
    "fmt"
    "io"
    "os"
)

// 파일 디스크립터 덕분에 모든 I/O가 동일한 인터페이스 사용
func readFromAnything(fd uintptr) {
    file := os.NewFile(fd, "generic")
    defer file.Close()

    buffer := make([]byte, 1024)
    n, err := file.Read(buffer)
    if err != nil && err != io.EOF {
        fmt.Println("Error:", err)
        return
    }

    fmt.Printf("읽은 데이터: %s\n", buffer[:n])
}

func main() {
    // 일반 파일에서 읽기
    file, _ := os.Open("test.txt")
    readFromAnything(file.Fd())
    file.Close()

    // 네트워크 소켓에서 읽기
    // (같은 함수로 처리 가능!)

    // 파이프에서 읽기
    // (같은 함수로 처리 가능!)
}

보안과 권한 관리:

  • 커널이 파일 디스크립터를 통해 접근 권한 검증
  • 프로세스는 직접 파일 경로가 아닌 fd로만 접근
  • 권한 체크는 open() 시점에만 수행

2. 파일 디스크립터 테이블의 내부 구조

2.1 세 가지 테이블

리눅스 커널은 파일 디스크립터를 관리하기 위해 세 계층의 테이블을 사용합니다:

┌─────────────────────────────────────────────────────────┐
│                   프로세스 A                              │
│                                                          │
│  ┌────────────────────────────────┐                     │
│  │  파일 디스크립터 테이블          │                     │
│  ├────┬────────────────────────────┤                     │
│  │ 0  │ → 파일 테이블 항목 1       │                     │
│  │ 1  │ → 파일 테이블 항목 2       │                     │
│  │ 2  │ → 파일 테이블 항목 2       │ (같은 항목 공유)    │
│  │ 3  │ → 파일 테이블 항목 3       │                     │
│  │ 4  │ → 파일 테이블 항목 4       │                     │
│  └────┴────────────────────────────┘                     │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                커널 전역: 파일 테이블                      │
│                                                          │
│  ┌─────────────────────────────────────────┐            │
│  │ 파일 테이블 항목 1                       │            │
│  │ - 파일 상태 플래그 (읽기/쓰기)           │            │
│  │ - 파일 오프셋 (현재 위치)                │            │
│  │ - inode 포인터 ───────────┐              │            │
│  └─────────────────────────────│────────────┘            │
│                                │                         │
│  ┌─────────────────────────────│────────────┐            │
│  │ 파일 테이블 항목 2          │            │            │
│  │ - 파일 상태 플래그          │            │            │
│  │ - 파일 오프셋               │            │            │
│  │ - inode 포인터 ───────────┐ │            │            │
│  └───────────────────────────│─│────────────┘            │
└────────────────────────────────│─│──────────────────────┘
                                 ↓ ↓
┌─────────────────────────────────────────────────────────┐
│                커널 전역: inode 테이블                    │
│                                                          │
│  ┌─────────────────────────────────────────┐            │
│  │ inode 1                                  │            │
│  │ - 파일 타입 (일반/디렉터리/소켓)         │            │
│  │ - 파일 크기                              │            │
│  │ - 권한 정보                              │            │
│  │ - 타임스탬프                             │            │
│  │ - 디스크 블록 위치                       │            │
│  └─────────────────────────────────────────┘            │
└─────────────────────────────────────────────────────────┘

2.2 각 테이블의 역할

1. 파일 디스크립터 테이블 (Per-Process)

  • 각 프로세스가 소유
  • fd 번호 → 파일 테이블 항목 매핑
  • fork() 시 자식에게 복사됨

2. 파일 테이블 (System-wide)

  • 커널 전역
  • 열린 파일의 상태 정보:
    • 파일 오프셋: 현재 읽기/쓰기 위치
    • 접근 모드: O_RDONLY, O_WRONLY, O_RDWR
    • 상태 플래그: O_APPEND, O_NONBLOCK 등
  • 여러 fd가 같은 항목을 공유 가능 (dup(), fork())

3. inode 테이블 (System-wide)

  • 파일 시스템의 메타데이터
  • 실제 파일을 나타냄
  • 여러 파일 테이블 항목이 같은 inode 공유 가능

2.3 Go로 테이블 구조 이해하기

go
package main

import (
    "fmt"
    "os"
    "syscall"
)

func main() {
    // 같은 파일을 두 번 열기
    file1, _ := os.OpenFile("test.txt", os.O_RDWR, 0644)
    file2, _ := os.OpenFile("test.txt", os.O_RDWR, 0644)
    defer file1.Close()
    defer file2.Close()

    fmt.Printf("file1 fd: %d\n", file1.Fd())
    fmt.Printf("file2 fd: %d\n", file2.Fd())

    // file1에서 5바이트 읽기
    buf1 := make([]byte, 5)
    file1.Read(buf1)
    fmt.Printf("file1에서 읽음: %s\n", buf1)

    // file1의 현재 위치 확인
    pos1, _ := file1.Seek(0, os.SEEK_CUR) // 현재 위치 반환
    fmt.Printf("file1 오프셋: %d\n", pos1) // 5

    // file2의 현재 위치 확인 (독립적인 파일 테이블 항목)
    pos2, _ := file2.Seek(0, os.SEEK_CUR)
    fmt.Printf("file2 오프셋: %d\n", pos2) // 0 (독립적!)

    // 하지만 같은 inode를 가리킴
    stat1, _ := file1.Stat()
    stat2, _ := file2.Stat()

    sys1 := stat1.Sys().(*syscall.Stat_t)
    sys2 := stat2.Sys().(*syscall.Stat_t)

    fmt.Printf("file1 inode: %d\n", sys1.Ino)
    fmt.Printf("file2 inode: %d\n", sys2.Ino)
    fmt.Printf("같은 inode? %v\n", sys1.Ino == sys2.Ino) // true!
}

출력:

file1 fd: 3
file2 fd: 4
file1에서 읽음: Hello
file1 오프셋: 5
file2 오프셋: 0
file1 inode: 12345678
file2 inode: 12345678
같은 inode? true

2.4 dup()와 파일 테이블 공유

go
package main

import (
    "fmt"
    "os"
    "syscall"
)

func main() {
    // 파일 열기
    file1, _ := os.OpenFile("test.txt", os.O_RDWR, 0644)
    defer file1.Close()

    // dup(): 같은 파일 테이블 항목을 가리키는 새 fd 생성
    newFd, err := syscall.Dup(int(file1.Fd()))
    if err != nil {
        fmt.Println("dup 실패:", err)
        return
    }

    file2 := os.NewFile(uintptr(newFd), "duped")
    defer file2.Close()

    fmt.Printf("원본 fd: %d\n", file1.Fd())
    fmt.Printf("복제된 fd: %d\n", file2.Fd())

    // file1에서 5바이트 읽기
    buf := make([]byte, 5)
    file1.Read(buf)
    fmt.Printf("file1에서 읽음: %s\n", buf)

    // file1의 오프셋 확인
    pos1, _ := file1.Seek(0, os.SEEK_CUR)
    fmt.Printf("file1 오프셋: %d\n", pos1) // 5

    // file2의 오프셋도 같음! (같은 파일 테이블 항목 공유)
    pos2, _ := file2.Seek(0, os.SEEK_CUR)
    fmt.Printf("file2 오프셋: %d\n", pos2) // 5 (공유!)

    // file2에서 계속 읽기
    buf2 := make([]byte, 5)
    file2.Read(buf2)
    fmt.Printf("file2에서 읽음: %s\n", buf2) // 다음 5바이트

    // 이제 둘 다 오프셋 10
    pos1, _ = file1.Seek(0, os.SEEK_CUR)
    pos2, _ = file2.Seek(0, os.SEEK_CUR)
    fmt.Printf("file1 오프셋: %d, file2 오프셋: %d\n", pos1, pos2) // 10, 10
}

핵심 차이점:

  • open() 두 번: 서로 다른 파일 테이블 항목 → 독립적인 오프셋
  • dup(): 같은 파일 테이블 항목 공유 → 오프셋 공유

3. 표준 입출력과 파일 디스크립터

3.1 특별한 파일 디스크립터들

모든 프로세스는 시작 시 세 개의 파일 디스크립터를 가집니다:

fd이름심볼릭 상수용도
0stdinSTDIN_FILENO표준 입력
1stdoutSTDOUT_FILENO표준 출력
2stderrSTDERR_FILENO표준 에러

3.2 리다이렉션의 원리

쉘에서 command > output.txt를 실행하면 무슨 일이 일어날까요?

go
package main

import (
    "fmt"
    "os"
    "syscall"
)

// 표준 출력을 파일로 리다이렉트하는 예제
func redirectStdout() {
    // 1. 새 파일 열기
    file, err := os.Create("output.txt")
    if err != nil {
        fmt.Fprintln(os.Stderr, "파일 생성 실패:", err)
        return
    }
    defer file.Close()

    // 2. stdout(fd 1)을 백업
    oldStdout, _ := syscall.Dup(syscall.Stdout)

    // 3. stdout을 file로 복제 (dup2)
    // 이제 fd 1이 file을 가리킴
    syscall.Dup2(int(file.Fd()), syscall.Stdout)

    // 4. 이제 fmt.Println은 파일로 출력됨!
    fmt.Println("이 메시지는 output.txt에 기록됩니다")
    fmt.Println("stdout이 리다이렉트되었습니다")

    // 5. stdout 복원
    syscall.Dup2(oldStdout, syscall.Stdout)
    syscall.Close(oldStdout)

    // 6. 이제 다시 터미널로 출력
    fmt.Println("stdout이 복원되었습니다 (터미널에 표시)")
}

// stderr와 stdout의 차이
func stderrVsStdout() {
    fmt.Println("이것은 stdout입니다")
    fmt.Fprintln(os.Stderr, "이것은 stderr입니다")

    // 쉘에서 실행:
    // go run main.go > output.txt
    // → stdout만 파일로, stderr는 여전히 터미널에 표시됨

    // go run main.go 2> error.txt
    // → stderr만 파일로, stdout은 여전히 터미널에 표시됨

    // go run main.go > output.txt 2>&1
    // → stdout과 stderr 모두 파일로
}

func main() {
    fmt.Println("=== 리다이렉션 데모 ===")
    redirectStdout()

    fmt.Println("\n=== stderr vs stdout ===")
    stderrVsStdout()
}

3.3 파이프의 구현

go
package main

import (
    "bufio"
    "fmt"
    "io"
    "os"
    "os/exec"
)

// 파이프를 사용한 프로세스 간 통신
func pipeExample() {
    // os.Pipe()는 두 개의 fd를 생성:
    // - reader: 읽기 전용 fd
    // - writer: 쓰기 전용 fd
    reader, writer, err := os.Pipe()
    if err != nil {
        fmt.Println("Pipe 생성 실패:", err)
        return
    }

    fmt.Printf("파이프 생성: reader fd=%d, writer fd=%d\n",
        reader.Fd(), writer.Fd())

    // 고루틴에서 데이터 쓰기
    go func() {
        defer writer.Close()
        for i := 0; i < 5; i++ {
            fmt.Fprintf(writer, "메시지 %d\n", i)
        }
    }()

    // 메인에서 데이터 읽기
    scanner := bufio.NewScanner(reader)
    for scanner.Scan() {
        fmt.Printf("받은 데이터: %s\n", scanner.Text())
    }
    reader.Close()
}

// 자식 프로세스와 파이프로 통신
func childProcessPipe() {
    // `echo hello | grep hello` 구현

    // 1. 첫 번째 명령: echo hello
    cmd1 := exec.Command("echo", "hello world")

    // 2. 두 번째 명령: grep hello
    cmd2 := exec.Command("grep", "hello")

    // 3. cmd1의 stdout을 cmd2의 stdin으로 연결
    pipe, err := cmd1.StdoutPipe()
    if err != nil {
        fmt.Println("파이프 생성 실패:", err)
        return
    }

    cmd2.Stdin = pipe
    cmd2.Stdout = os.Stdout

    // 4. 명령 실행
    cmd1.Start()
    cmd2.Start()

    cmd1.Wait()
    cmd2.Wait()
}

// 양방향 파이프
func bidirectionalPipe() {
    // 자식 프로세스와 양방향 통신
    cmd := exec.Command("cat") // cat은 입력을 그대로 출력

    stdin, _ := cmd.StdinPipe()
    stdout, _ := cmd.StdoutPipe()

    cmd.Start()

    // 데이터 보내기
    go func() {
        defer stdin.Close()
        fmt.Fprintln(stdin, "첫 번째 줄")
        fmt.Fprintln(stdin, "두 번째 줄")
        fmt.Fprintln(stdin, "세 번째 줄")
    }()

    // 데이터 받기
    scanner := bufio.NewScanner(stdout)
    for scanner.Scan() {
        fmt.Printf("받음: %s\n", scanner.Text())
    }

    cmd.Wait()
}

func main() {
    fmt.Println("=== 기본 파이프 ===")
    pipeExample()

    fmt.Println("\n=== 자식 프로세스 파이프 ===")
    childProcessPipe()

    fmt.Println("\n=== 양방향 파이프 ===")
    bidirectionalPipe()
}

3.4 /dev/null의 활용

go
package main

import (
    "fmt"
    "os"
    "os/exec"
)

func discardOutput() {
    // 출력을 /dev/null로 버리기
    devNull, _ := os.OpenFile("/dev/null", os.O_WRONLY, 0)
    defer devNull.Close()

    cmd := exec.Command("ls", "-la")
    cmd.Stdout = devNull // 출력 버림
    cmd.Stderr = devNull // 에러도 버림

    cmd.Run()
    fmt.Println("명령이 실행되었지만 출력은 버려졌습니다")
}

func nullAsInput() {
    // /dev/null을 입력으로 사용 (즉시 EOF)
    devNull, _ := os.Open("/dev/null")
    defer devNull.Close()

    buffer := make([]byte, 100)
    n, err := devNull.Read(buffer)

    fmt.Printf("읽은 바이트: %d, 에러: %v\n", n, err) // 0, EOF
}

func main() {
    discardOutput()
    nullAsInput()
}

4. 파일 디스크립터 조작

4.1 기본 시스템 콜

open/close/read/write:

go
package main

import (
    "fmt"
    "syscall"
)

func lowLevelFileOps() {
    // open() 시스템 콜
    fd, err := syscall.Open("test.txt",
        syscall.O_RDWR|syscall.O_CREAT, 0644)
    if err != nil {
        fmt.Println("Open 실패:", err)
        return
    }
    defer syscall.Close(fd)

    fmt.Printf("파일 열림: fd=%d\n", fd)

    // write() 시스템 콜
    data := []byte("Hello, File Descriptor!\n")
    n, err := syscall.Write(fd, data)
    if err != nil {
        fmt.Println("Write 실패:", err)
        return
    }
    fmt.Printf("작성된 바이트: %d\n", n)

    // lseek() 시스템 콜 - 파일 오프셋 이동
    offset, err := syscall.Seek(fd, 0, 0) // 파일 시작으로
    if err != nil {
        fmt.Println("Seek 실패:", err)
        return
    }
    fmt.Printf("현재 오프셋: %d\n", offset)

    // read() 시스템 콜
    buffer := make([]byte, 100)
    n, err = syscall.Read(fd, buffer)
    if err != nil {
        fmt.Println("Read 실패:", err)
        return
    }
    fmt.Printf("읽은 데이터 (%d 바이트): %s", n, buffer[:n])
}

func main() {
    lowLevelFileOps()
}

4.2 dup/dup2 - 파일 디스크립터 복제

go
package main

import (
    "fmt"
    "os"
    "syscall"
)

func dupExample() {
    file, _ := os.Create("dup_test.txt")
    defer file.Close()

    originalFd := int(file.Fd())
    fmt.Printf("원본 fd: %d\n", originalFd)

    // dup() - 사용 가능한 가장 작은 fd로 복제
    newFd, err := syscall.Dup(originalFd)
    if err != nil {
        fmt.Println("Dup 실패:", err)
        return
    }
    defer syscall.Close(newFd)

    fmt.Printf("복제된 fd: %d\n", newFd)

    // 원본으로 쓰기
    syscall.Write(originalFd, []byte("Hello from original\n"))

    // 복제본으로 쓰기 (같은 파일 오프셋 공유)
    syscall.Write(newFd, []byte("Hello from duplicate\n"))
}

func dup2Example() {
    // dup2() - 특정 fd 번호로 복제
    file, _ := os.Create("dup2_test.txt")
    defer file.Close()

    // stdout(1)을 파일로 교체
    oldStdout, _ := syscall.Dup(syscall.Stdout)
    syscall.Dup2(int(file.Fd()), syscall.Stdout)

    // 이제 fmt.Println은 파일로 출력됨
    fmt.Println("이것은 파일에 기록됩니다")

    // 복원
    syscall.Dup2(oldStdout, syscall.Stdout)
    syscall.Close(oldStdout)

    fmt.Println("stdout이 복원되었습니다")
}

func main() {
    fmt.Println("=== dup() 예제 ===")
    dupExample()

    fmt.Println("\n=== dup2() 예제 ===")
    dup2Example()
}

4.3 fcntl - 파일 디스크립터 제어

go
package main

import (
    "fmt"
    "os"
    "syscall"
)

func fcntlExample() {
    file, _ := os.Create("fcntl_test.txt")
    defer file.Close()

    fd := int(file.Fd())

    // 1. 파일 상태 플래그 가져오기
    flags, err := syscall.FcntlInt(uintptr(fd), syscall.F_GETFL, 0)
    if err != nil {
        fmt.Println("F_GETFL 실패:", err)
        return
    }
    fmt.Printf("현재 플래그: 0x%x\n", flags)

    // 읽기/쓰기 모드 확인
    accessMode := flags & syscall.O_ACCMODE
    switch accessMode {
    case syscall.O_RDONLY:
        fmt.Println("모드: 읽기 전용")
    case syscall.O_WRONLY:
        fmt.Println("모드: 쓰기 전용")
    case syscall.O_RDWR:
        fmt.Println("모드: 읽기/쓰기")
    }

    // 2. 논블로킹 모드로 설정
    _, err = syscall.FcntlInt(uintptr(fd), syscall.F_SETFL,
        flags|syscall.O_NONBLOCK)
    if err != nil {
        fmt.Println("F_SETFL 실패:", err)
        return
    }
    fmt.Println("논블로킹 모드로 설정됨")

    // 3. Close-on-exec 플래그 설정
    _, err = syscall.FcntlInt(uintptr(fd), syscall.F_SETFD,
        syscall.FD_CLOEXEC)
    if err != nil {
        fmt.Println("F_SETFD 실패:", err)
        return
    }
    fmt.Println("Close-on-exec 플래그 설정됨")
}

// 논블로킹 소켓 설정
func nonblockingSocket() {
    // 소켓 생성
    fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
    if err != nil {
        fmt.Println("Socket 실패:", err)
        return
    }
    defer syscall.Close(fd)

    // 논블로킹 모드로 설정
    flags, _ := syscall.FcntlInt(uintptr(fd), syscall.F_GETFL, 0)
    syscall.FcntlInt(uintptr(fd), syscall.F_SETFL,
        flags|syscall.O_NONBLOCK)

    fmt.Println("논블로킹 소켓 생성됨")
}

func main() {
    fmt.Println("=== fcntl 예제 ===")
    fcntlExample()

    fmt.Println("\n=== 논블로킹 소켓 ===")
    nonblockingSocket()
}

4.4 ioctl - 장치 제어

go
package main

import (
    "fmt"
    "os"
    "syscall"
    "unsafe"
)

// 터미널 크기 가져오기
type winsize struct {
    Row    uint16
    Col    uint16
    Xpixel uint16
    Ypixel uint16
}

func getTerminalSize() {
    ws := &winsize{}

    // TIOCGWINSZ ioctl로 터미널 크기 조회
    _, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
        uintptr(syscall.Stdout),
        uintptr(syscall.TIOCGWINSZ),
        uintptr(unsafe.Pointer(ws)))

    if errno != 0 {
        fmt.Println("ioctl 실패:", errno)
        return
    }

    fmt.Printf("터미널 크기: %d행 x %d\n", ws.Row, ws.Col)
}

// 파일이 터미널인지 확인 (isatty)
func isTerminal(fd int) bool {
    _, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
        uintptr(fd),
        syscall.TIOCGWINSZ,
        0)

    return errno == 0
}

func main() {
    getTerminalSize()

    fmt.Printf("stdout이 터미널인가? %v\n", isTerminal(syscall.Stdout))
    fmt.Printf("stderr이 터미널인가? %v\n", isTerminal(syscall.Stderr))

    // 파일은 터미널이 아님
    file, _ := os.Create("test.txt")
    defer file.Close()
    fmt.Printf("파일이 터미널인가? %v\n", isTerminal(int(file.Fd())))
}

5. 파일 디스크립터와 프로세스

5.1 fork()와 파일 디스크립터 상속

go
package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func forkAndInherit() {
    // 부모 프로세스에서 파일 열기
    file, _ := os.Create("inherited.txt")
    defer file.Close()

    file.WriteString("부모가 작성\n")

    // 자식 프로세스 생성 (exec)
    cmd := exec.Command("sh", "-c",
        fmt.Sprintf("echo '자식이 작성' >&%d", file.Fd()))

    // 기본적으로 Go는 자식에게 fd를 전달하지 않음
    // ExtraFiles를 사용하여 명시적으로 전달
    cmd.ExtraFiles = []*os.File{file}

    cmd.Run()

    // 파일 내용 확인
    file.Seek(0, 0)
    content := make([]byte, 100)
    n, _ := file.Read(content)
    fmt.Printf("파일 내용:\n%s", content[:n])
}

// 자식 프로세스가 부모의 fd를 상속하는 예제
func parentChildFd() {
    reader, writer, _ := os.Pipe()

    // 자식 프로세스 생성
    cmd := exec.Command("sh", "-c", "echo 자식 프로세스 메시지")
    cmd.Stdout = writer

    // 고루틴에서 파이프 읽기
    go func() {
        defer reader.Close()
        buf := make([]byte, 100)
        n, _ := reader.Read(buf)
        fmt.Printf("부모가 받음: %s", buf[:n])
    }()

    writer.Close() // 부모는 쓰기 끝을 닫음
    cmd.Run()
}

func main() {
    fmt.Println("=== fork와 파일 디스크립터 상속 ===")
    forkAndInherit()

    fmt.Println("\n=== 부모-자식 파이프 통신 ===")
    parentChildFd()
}

5.2 Close-on-exec 플래그

go
package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func closeOnExecExample() {
    // 파일 열기
    file, _ := os.Create("cloexec_test.txt")
    defer file.Close()

    fd := int(file.Fd())

    // Close-on-exec 플래그 설정
    syscall.FcntlInt(uintptr(fd), syscall.F_SETFD, syscall.FD_CLOEXEC)

    fmt.Printf("파일 fd: %d (CLOEXEC 설정됨)\n", fd)

    // 자식 프로세스 생성
    // exec 호출 시 이 fd는 자동으로 닫힘
    cmd := exec.Command("sh", "-c",
        fmt.Sprintf("ls -l /proc/self/fd/%d 2>&1", fd))

    output, _ := cmd.Output()
    fmt.Printf("자식 프로세스 출력:\n%s", output)
    // "No such file or directory" - fd가 닫혔음을 확인
}

// Go의 os.OpenFile은 기본적으로 O_CLOEXEC 사용
func goCloexecBehavior() {
    file, _ := os.Create("test.txt")
    defer file.Close()

    // Go는 자동으로 CLOEXEC 설정
    fd := int(file.Fd())
    flags, _ := syscall.FcntlInt(uintptr(fd), syscall.F_GETFD, 0)

    if flags&syscall.FD_CLOEXEC != 0 {
        fmt.Println("Go는 기본적으로 CLOEXEC를 설정합니다")
    }
}

func main() {
    fmt.Println("=== Close-on-exec 데모 ===")
    closeOnExecExample()

    fmt.Println("\n=== Go의 CLOEXEC 동작 ===")
    goCloexecBehavior()
}

5.3 프로세스의 /proc/self/fd

go
package main

import (
    "fmt"
    "io/ioutil"
    "net"
    "os"
)

func inspectFileDescriptors() {
    // 여러 종류의 fd 생성
    file, _ := os.Create("test.txt")
    defer file.Close()

    listener, _ := net.Listen("tcp", ":0")
    defer listener.Close()

    reader, writer, _ := os.Pipe()
    defer reader.Close()
    defer writer.Close()

    // /proc/self/fd 디렉터리 읽기
    files, err := ioutil.ReadDir("/proc/self/fd")
    if err != nil {
        fmt.Println("이 시스템은 /proc을 지원하지 않습니다")
        return
    }

    fmt.Println("현재 프로세스의 열린 파일 디스크립터:")
    for _, f := range files {
        fdNum := f.Name()
        link, _ := os.Readlink("/proc/self/fd/" + fdNum)
        fmt.Printf("fd %s -> %s\n", fdNum, link)
    }
}

func main() {
    inspectFileDescriptors()
}

출력 예시:

현재 프로세스의 열린 파일 디스크립터:
fd 0 -> /dev/pts/0
fd 1 -> /dev/pts/0
fd 2 -> /dev/pts/0
fd 3 -> /path/to/test.txt
fd 4 -> socket:[12345]
fd 5 -> pipe:[67890]
fd 6 -> pipe:[67890]

6. 파일 디스크립터 고급 기법

6.1 Scatter-Gather I/O (readv/writev)

여러 버퍼를 한 번의 시스템 콜로 읽기/쓰기:

go
package main

import (
    "fmt"
    "os"
    "syscall"
)

func writevExample() {
    file, _ := os.Create("writev_test.txt")
    defer file.Close()

    fd := int(file.Fd())

    // 여러 버퍼 준비
    buffers := [][]byte{
        []byte("첫 번째 버퍼\n"),
        []byte("두 번째 버퍼\n"),
        []byte("세 번째 버퍼\n"),
    }

    // writev: 여러 버퍼를 한 번에 쓰기
    // (내부적으로 하나의 시스템 콜)
    iovecs := make([]syscall.Iovec, len(buffers))
    for i, buf := range buffers {
        iovecs[i] = syscall.Iovec{
            Base: &buf[0],
            Len:  uint64(len(buf)),
        }
    }

    n, _, errno := syscall.Syscall(syscall.SYS_WRITEV,
        uintptr(fd),
        uintptr(uintptr(unsafe.Pointer(&iovecs[0]))),
        uintptr(len(iovecs)))

    if errno != 0 {
        fmt.Println("writev 실패:", errno)
        return
    }

    fmt.Printf("writev로 작성된 총 바이트: %d\n", n)
}

func readvExample() {
    file, _ := os.Open("writev_test.txt")
    defer file.Close()

    fd := int(file.Fd())

    // 여러 버퍼 준비
    buf1 := make([]byte, 20)
    buf2 := make([]byte, 20)
    buf3 := make([]byte, 20)

    iovecs := []syscall.Iovec{
        {Base: &buf1[0], Len: uint64(len(buf1))},
        {Base: &buf2[0], Len: uint64(len(buf2))},
        {Base: &buf3[0], Len: uint64(len(buf3))},
    }

    n, _, errno := syscall.Syscall(syscall.SYS_READV,
        uintptr(fd),
        uintptr(uintptr(unsafe.Pointer(&iovecs[0]))),
        uintptr(len(iovecs)))

    if errno != 0 {
        fmt.Println("readv 실패:", errno)
        return
    }

    fmt.Printf("readv로 읽은 총 바이트: %d\n", n)
    fmt.Printf("버퍼 1: %s", buf1)
    fmt.Printf("버퍼 2: %s", buf2)
    fmt.Printf("버퍼 3: %s", buf3)
}

func main() {
    fmt.Println("=== writev 예제 ===")
    writevExample()

    fmt.Println("\n=== readv 예제 ===")
    readvExample()
}

6.2 sendfile - 제로 카피 전송

커널 내부에서 직접 파일 → 소켓 전송 (유저스페이스 복사 없음):

go
package main

import (
    "fmt"
    "io"
    "net"
    "os"
    "syscall"
)

// sendfile을 사용한 제로 카피 파일 전송
func sendfileTransfer(socket net.Conn, filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer file.Close()

    stat, _ := file.Stat()
    fileSize := stat.Size()

    // TCP 소켓의 파일 디스크립터
    tcpConn := socket.(*net.TCPConn)
    socketFile, _ := tcpConn.File()
    socketFd := int(socketFile.Fd())
    fileFd := int(file.Fd())

    // sendfile 시스템 콜
    // 커널 내부에서 file -> socket으로 직접 전송
    offset := int64(0)
    written, err := syscall.Sendfile(socketFd, fileFd, &offset, int(fileSize))
    if err != nil {
        return err
    }

    fmt.Printf("sendfile로 전송: %d 바이트\n", written)
    return nil
}

// 일반 방식 (유저스페이스 버퍼 사용)
func normalTransfer(socket net.Conn, filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer file.Close()

    // 유저스페이스 버퍼를 통한 전송
    // file -> 유저 버퍼 -> socket (두 번의 복사)
    written, err := io.Copy(socket, file)
    if err != nil {
        return err
    }

    fmt.Printf("일반 방식 전송: %d 바이트\n", written)
    return nil
}

func compareTransferMethods() {
    // 큰 테스트 파일 생성
    file, _ := os.Create("large_file.bin")
    file.Write(make([]byte, 10*1024*1024)) // 10MB
    file.Close()

    // 서버 시작
    listener, _ := net.Listen("tcp", ":0")
    addr := listener.Addr().String()

    // 서버 측 (수신)
    go func() {
        conn, _ := listener.Accept()
        defer conn.Close()
        io.Copy(io.Discard, conn) // 데이터 버림
    }()

    // 클라이언트 측 (송신) - sendfile 사용
    conn1, _ := net.Dial("tcp", addr)
    sendfileTransfer(conn1, "large_file.bin")
    conn1.Close()

    listener.Close()
}

func main() {
    compareTransferMethods()
}

성능 차이:

  • 일반 방식: file → 커널 버퍼 → 유저 버퍼 → 커널 버퍼 → 소켓 (4번 복사)
  • sendfile: file → 소켓 (커널 내부에서 1번 복사)

6.3 splice - 파이프를 통한 제로 카피

go
package main

import (
    "fmt"
    "os"
    "syscall"
)

// splice를 사용한 파일 간 복사 (파이프 경유)
func spliceFileCopy(src, dst string) error {
    // 소스 파일 열기
    srcFile, _ := os.Open(src)
    defer srcFile.Close()

    // 대상 파일 생성
    dstFile, _ := os.Create(dst)
    defer dstFile.Close()

    // 파이프 생성
    pipeR, pipeW, _ := os.Pipe()
    defer pipeR.Close()
    defer pipeW.Close()

    srcFd := int(srcFile.Fd())
    dstFd := int(dstFile.Fd())
    pipeRFd := int(pipeR.Fd())
    pipeWFd := int(pipeW.Fd())

    // 파일 크기 확인
    stat, _ := srcFile.Stat()
    remaining := stat.Size()

    // splice 루프
    for remaining > 0 {
        // 소스 -> 파이프
        n, err := syscall.Splice(srcFd, nil, pipeWFd, nil,
            int(remaining), syscall.SPLICE_F_MOVE)
        if err != nil {
            return err
        }

        // 파이프 -> 대상
        _, err = syscall.Splice(pipeRFd, nil, dstFd, nil,
            n, syscall.SPLICE_F_MOVE)
        if err != nil {
            return err
        }

        remaining -= int64(n)
    }

    fmt.Printf("splice로 복사 완료: %d 바이트\n", stat.Size())
    return nil
}

func main() {
    // 테스트 파일 생성
    src := "source.txt"
    dst := "destination.txt"

    os.WriteFile(src, []byte("Hello, splice!"), 0644)

    spliceFileCopy(src, dst)

    // 결과 확인
    content, _ := os.ReadFile(dst)
    fmt.Printf("복사된 내용: %s\n", content)
}

6.4 memfd - 익명 파일 디스크립터

go
package main

import (
    "fmt"
    "syscall"
    "unsafe"
)

// memfd_create: 메모리 기반 익명 파일 생성
func memfdExample() {
    // memfd_create 시스템 콜
    name := []byte("my_memfd\x00")
    fd, _, errno := syscall.Syscall(319, // memfd_create 시스템 콜 번호
        uintptr(unsafe.Pointer(&name[0])),
        uintptr(0), // flags
        0)

    if errno != 0 {
        fmt.Println("memfd_create 실패:", errno)
        return
    }
    defer syscall.Close(int(fd))

    fmt.Printf("memfd 생성: fd=%d\n", fd)

    // 메모리에 데이터 쓰기
    data := []byte("메모리 기반 파일 데이터")
    syscall.Write(int(fd), data)

    // 읽기
    syscall.Seek(int(fd), 0, 0) // 처음으로
    buffer := make([]byte, 100)
    n, _ := syscall.Read(int(fd), buffer)

    fmt.Printf("읽은 데이터: %s\n", buffer[:n])

    // 특징: 파일 시스템에 실제 파일이 없음!
    // /proc/self/fd/X로만 접근 가능
}

func main() {
    memfdExample()
}

7. 파일 디스크립터 제한과 관리

7.1 파일 디스크립터 제한 확인

go
package main

import (
    "fmt"
    "syscall"
)

func checkFdLimits() {
    var rLimit syscall.Rlimit

    // 소프트/하드 제한 조회
    err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
    if err != nil {
        fmt.Println("Getrlimit 실패:", err)
        return
    }

    fmt.Printf("파일 디스크립터 제한:\n")
    fmt.Printf("  소프트 제한: %d\n", rLimit.Cur)
    fmt.Printf("  하드 제한: %d\n", rLimit.Max)
}

func increaseFdLimit() {
    var rLimit syscall.Rlimit
    syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)

    fmt.Printf("현재 소프트 제한: %d\n", rLimit.Cur)

    // 소프트 제한을 하드 제한까지 증가
    rLimit.Cur = rLimit.Max
    err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)
    if err != nil {
        fmt.Println("Setrlimit 실패:", err)
        fmt.Println("(root 권한이 필요할 수 있습니다)")
        return
    }

    syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
    fmt.Printf("증가된 소프트 제한: %d\n", rLimit.Cur)
}

func main() {
    fmt.Println("=== 파일 디스크립터 제한 조회 ===")
    checkFdLimits()

    fmt.Println("\n=== 제한 증가 시도 ===")
    increaseFdLimit()
}

7.2 파일 디스크립터 누수 감지

go
package main

import (
    "fmt"
    "io/ioutil"
    "os"
    "runtime"
    "time"
)

// 파일 디스크립터 누수 예제 (나쁜 코드!)
func leakyFunction() {
    for i := 0; i < 100; i++ {
        file, _ := os.Create(fmt.Sprintf("leak_%d.txt", i))
        file.WriteString("data")
        // file.Close()가 없음 - 누수 발생!
    }
}

// 현재 프로세스의 열린 fd 개수 확인
func countOpenFds() int {
    if runtime.GOOS != "linux" {
        return -1
    }

    files, err := ioutil.ReadDir("/proc/self/fd")
    if err != nil {
        return -1
    }
    return len(files)
}

func detectLeak() {
    fmt.Printf("시작 시 열린 fd: %d\n", countOpenFds())

    leakyFunction()

    // GC 실행해도 fd는 닫히지 않음!
    runtime.GC()
    time.Sleep(100 * time.Millisecond)

    fmt.Printf("누수 후 열린 fd: %d\n", countOpenFds())

    // 올바른 정리
    for i := 0; i < 100; i++ {
        os.Remove(fmt.Sprintf("leak_%d.txt", i))
    }
}

// 올바른 방법: defer 사용
func properFileHandling() {
    before := countOpenFds()

    for i := 0; i < 100; i++ {
        func() {
            file, _ := os.Create(fmt.Sprintf("proper_%d.txt", i))
            defer file.Close() // 함수 종료 시 자동으로 닫힘

            file.WriteString("data")
        }()
    }

    runtime.GC()
    time.Sleep(100 * time.Millisecond)

    after := countOpenFds()

    fmt.Printf("처리 전 fd: %d, 처리 후 fd: %d\n", before, after)

    // 정리
    for i := 0; i < 100; i++ {
        os.Remove(fmt.Sprintf("proper_%d.txt", i))
    }
}

func main() {
    fmt.Println("=== 파일 디스크립터 누수 감지 ===")
    detectLeak()

    fmt.Println("\n=== 올바른 파일 처리 ===")
    properFileHandling()
}

7.3 파일 디스크립터 풀링

go
package main

import (
    "fmt"
    "os"
    "sync"
)

// 파일 디스크립터 풀
type FdPool struct {
    pool    chan *os.File
    factory func() (*os.File, error)
    mu      sync.Mutex
    created int
    maxSize int
}

func NewFdPool(factory func() (*os.File, error), maxSize int) *FdPool {
    return &FdPool{
        pool:    make(chan *os.File, maxSize),
        factory: factory,
        maxSize: maxSize,
    }
}

func (p *FdPool) Get() (*os.File, error) {
    select {
    case file := <-p.pool:
        return file, nil
    default:
        p.mu.Lock()
        defer p.mu.Unlock()

        if p.created < p.maxSize {
            file, err := p.factory()
            if err != nil {
                return nil, err
            }
            p.created++
            return file, nil
        }

        // 풀이 가득 참, 대기
        return <-p.pool, nil
    }
}

func (p *FdPool) Put(file *os.File) {
    select {
    case p.pool <- file:
    default:
        // 풀이 가득 참, fd 닫기
        file.Close()
        p.mu.Lock()
        p.created--
        p.mu.Unlock()
    }
}

func (p *FdPool) Close() {
    close(p.pool)
    for file := range p.pool {
        file.Close()
    }
}

func main() {
    // 최대 10개의 파일 핸들만 유지
    pool := NewFdPool(func() (*os.File, error) {
        return os.OpenFile("/dev/null", os.O_RDWR, 0)
    }, 10)
    defer pool.Close()

    // 100개의 작업을 10개의 fd로 처리
    for i := 0; i < 100; i++ {
        file, _ := pool.Get()

        // 작업 수행
        file.Write([]byte("data"))

        pool.Put(file)
    }

    fmt.Println("100개 작업을 제한된 fd로 완료")
}

8. 실전 문제 해결

8.1 "Too many open files" 에러 해결

go
package main

import (
    "fmt"
    "net"
    "os"
    "syscall"
)

func reproduceTooManyFiles() {
    // fd 제한을 낮게 설정 (테스트용)
    var rLimit syscall.Rlimit
    syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)

    originalLimit := rLimit.Cur
    rLimit.Cur = 50 // 매우 낮게 설정
    syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)

    fmt.Println("fd 제한을 50으로 설정")

    // 많은 파일 열기 시도
    files := []*os.File{}
    for i := 0; ; i++ {
        file, err := os.Create(fmt.Sprintf("test_%d.txt", i))
        if err != nil {
            fmt.Printf("에러 발생 (파일 %d개 생성 후): %v\n", i, err)
            break
        }
        files = append(files, file)
    }

    // 정리
    for _, file := range files {
        file.Close()
        os.Remove(file.Name())
    }

    // 제한 복원
    rLimit.Cur = originalLimit
    syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)
}

// 해결 방법 1: 제한 증가
func solution1() {
    var rLimit syscall.Rlimit
    syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)

    fmt.Printf("현재 제한: %d\n", rLimit.Cur)

    rLimit.Cur = rLimit.Max
    syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)

    fmt.Printf("증가된 제한: %d\n", rLimit.Cur)
}

// 해결 방법 2: 리소스 정리
func solution2() {
    files := []*os.File{}

    for i := 0; i < 100; i++ {
        file, _ := os.Create(fmt.Sprintf("test_%d.txt", i))
        files = append(files, file)

        // 주기적으로 정리
        if i%10 == 9 {
            for _, f := range files {
                f.Close()
            }
            files = []*os.File{}
        }
    }
}

// 해결 방법 3: 연결 풀 사용
type ConnectionPool struct {
    conns chan net.Conn
}

func NewConnectionPool(size int, factory func() (net.Conn, error)) *ConnectionPool {
    pool := &ConnectionPool{
        conns: make(chan net.Conn, size),
    }

    for i := 0; i < size; i++ {
        conn, _ := factory()
        pool.conns <- conn
    }

    return pool
}

func (p *ConnectionPool) Get() net.Conn {
    return <-p.conns
}

func (p *ConnectionPool) Put(conn net.Conn) {
    p.conns <- conn
}

func main() {
    fmt.Println("=== 'Too many open files' 재현 ===")
    reproduceTooManyFiles()

    fmt.Println("\n=== 해결 방법 1: 제한 증가 ===")
    solution1()
}

8.2 파일 디스크립터 공유 문제

go
package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

// 문제: 자식 프로세스가 부모의 fd를 상속받아 문제 발생
func problematicFdSharing() {
    // 서버 소켓 생성
    listener, _ := net.Listen("tcp", ":8080")
    defer listener.Close()

    // 자식 프로세스 생성
    cmd := exec.Command("sleep", "10")
    cmd.Start()

    // 문제: 서버를 재시작하려고 하면
    // "address already in use" 에러 발생
    // 왜? 자식이 소켓 fd를 상속받아 아직 열려있음!

    fmt.Println("자식 프로세스가 소켓을 상속받았습니다")
}

// 해결: CLOEXEC 플래그 사용
func solutionCloexec() {
    // Go는 기본적으로 CLOEXEC 설정
    file, _ := os.Create("test.txt")
    defer file.Close()

    // 수동으로 설정하려면:
    fd := int(file.Fd())
    syscall.FcntlInt(uintptr(fd), syscall.F_SETFD, syscall.FD_CLOEXEC)

    // exec 시 자동으로 닫힘
    cmd := exec.Command("ls", "-l", "/proc/self/fd")
    output, _ := cmd.Output()
    fmt.Printf("%s", output)
}

func main() {
    solutionCloexec()
}

8.3 파일 디스크립터 번호 재사용 문제

go
package main

import (
    "fmt"
    "os"
    "time"
)

// 문제: fd 번호가 재사용되어 혼동 발생
func fdReuseIssue() {
    file1, _ := os.Create("file1.txt")
    fd1 := file1.Fd()
    fmt.Printf("file1 fd: %d\n", fd1)

    file1.Close()

    // fd가 재사용됨
    file2, _ := os.Create("file2.txt")
    fd2 := file2.Fd()
    fmt.Printf("file2 fd: %d\n", fd2)

    if fd1 == fd2 {
        fmt.Println("같은 fd 번호가 재사용되었습니다!")
    }

    file2.Close()
}

// 해결: fd를 식별자로 사용하지 않기
type FileHandle struct {
    file *os.File
    id   uint64 // 고유 ID
}

var nextID uint64 = 0

func NewFileHandle(filename string) (*FileHandle, error) {
    file, err := os.Create(filename)
    if err != nil {
        return nil, err
    }

    nextID++
    return &FileHandle{
        file: file,
        id:   nextID,
    }, nil
}

func (fh *FileHandle) ID() uint64 {
    return fh.id
}

func (fh *FileHandle) Close() error {
    return fh.file.Close()
}

func solutionUniqueID() {
    fh1, _ := NewFileHandle("file1.txt")
    id1 := fh1.ID()
    fh1.Close()

    fh2, _ := NewFileHandle("file2.txt")
    id2 := fh2.ID()
    fh2.Close()

    fmt.Printf("file1 ID: %d, file2 ID: %d\n", id1, id2)
    fmt.Println("고유 ID를 사용하여 혼동 방지")
}

func main() {
    fmt.Println("=== fd 재사용 문제 ===")
    fdReuseIssue()

    fmt.Println("\n=== 해결: 고유 ID 사용 ===")
    solutionUniqueID()
}

8.4 파일 디스크립터와 고루틴 안전성

go
package main

import (
    "fmt"
    "os"
    "sync"
)

// 문제: 여러 고루틴이 같은 fd에 접근
func unsafeFileAccess() {
    file, _ := os.Create("unsafe.txt")
    defer file.Close()

    var wg sync.WaitGroup

    // 여러 고루틴이 동시에 쓰기
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            // 경쟁 조건: 파일 오프셋 공유
            fmt.Fprintf(file, "고루틴 %d\n", n)
        }(i)
    }

    wg.Wait()

    // 결과: 데이터가 뒤섞임 가능성
}

// 해결: 뮤텍스 사용
type SafeFile struct {
    file *os.File
    mu   sync.Mutex
}

func NewSafeFile(filename string) (*SafeFile, error) {
    file, err := os.Create(filename)
    if err != nil {
        return nil, err
    }
    return &SafeFile{file: file}, nil
}

func (sf *SafeFile) Write(data []byte) (int, error) {
    sf.mu.Lock()
    defer sf.mu.Unlock()
    return sf.file.Write(data)
}

func (sf *SafeFile) Close() error {
    return sf.file.Close()
}

func safeFileAccess() {
    file, _ := NewSafeFile("safe.txt")
    defer file.Close()

    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            data := fmt.Sprintf("고루틴 %d\n", n)
            file.Write([]byte(data))
        }(i)
    }

    wg.Wait()

    fmt.Println("안전한 파일 쓰기 완료")
}

// 더 나은 방법: 각 고루틴이 자신만의 fd 사용
func bestPractice() {
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()

            // 각 고루틴이 독립적인 fd 사용
            file, _ := os.OpenFile("best.txt",
                os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
            defer file.Close()

            fmt.Fprintf(file, "고루틴 %d\n", n)
        }(i)
    }

    wg.Wait()

    fmt.Println("각 고루틴이 독립적인 fd 사용")
}

func main() {
    fmt.Println("=== 안전하지 않은 접근 ===")
    unsafeFileAccess()

    fmt.Println("\n=== 뮤텍스 사용 ===")
    safeFileAccess()

    fmt.Println("\n=== Best Practice ===")
    bestPractice()
}

결론

핵심 요약

  1. 파일 디스크립터는 I/O의 핵심

    • 모든 I/O는 fd를 통해 수행
    • "모든 것은 파일이다" 철학의 구현
  2. 세 계층 구조

    • 파일 디스크립터 테이블 (프로세스별)
    • 파일 테이블 (시스템 전역)
    • inode 테이블 (파일 시스템)
  3. fd 조작의 중요성

    • dup/dup2: 리다이렉션과 파이프 구현
    • fcntl: 논블로킹, CLOEXEC 등 제어
    • ioctl: 장치 특화 제어
  4. 성능 최적화

    • readv/writev: Scatter-Gather I/O
    • sendfile/splice: 제로 카피
    • memfd: 메모리 기반 파일
  5. 실전 주의사항

    • fd 누수 방지 (defer 사용)
    • 제한 관리 (ulimit, rlimit)
    • 고루틴 안전성 고려
    • CLOEXEC로 의도치 않은 상속 방지

Best Practices

DO:

  • 항상 defer file.Close() 사용
  • fd 제한 모니터링
  • 에러 처리 철저히
  • 고루틴 간 fd 공유 시 동기화
  • 프로덕션 환경에서 CLOEXEC 활용

DON'T:

  • fd를 영구적인 식별자로 사용 (재사용됨)
  • 무한정 fd 생성
  • Close 누락
  • 자식 프로세스에 불필요한 fd 노출

추가 학습 자료

  • man 페이지: man 2 open, man 2 dup, man 2 fcntl
  • 리눅스 커널 소스: fs/file.c, fs/open.c
  • 책: "The Linux Programming Interface" by Michael Kerrisk
  • 책: "Advanced Programming in the UNIX Environment" by Stevens

강의 종료

파일 디스크립터는 유닉스/리눅스 시스템 프로그래밍의 기초입니다. 이 개념을 완전히 이해하면 I/O, 네트워킹, 프로세스 관리 등 모든 시스템 프로그래밍 영역에서 도움이 됩니다!