Go 병렬 파일 스캐닝 삽질기

Go 병렬 파일 스캐닝 삽질기

Nintendo Switch 게임 라이브러리 관리 CLI를 만들었다. 수천 개의 NSP/XCI 파일을 스캔해서 메타데이터를 추출하는 도구.

처음에는 단순하게 만들었는데, 파일이 많아지니 느려졌다. 병렬 처리를 추가하면서 삽질이 시작됐다.


초기 구현: 순차 스캔

func scanFiles(root string) ([]GameFile, error) {
    var files []GameFile

    err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
        if isGameFile(path) {
            game, err := parseGameFile(path)
            if err == nil {
                files = append(files, game)
            }
        }
        return nil
    })

    return files, err
}

1000개 파일 스캔에 2분. 느리다.


병렬 처리 추가

worker pool 패턴으로 변경.

func scanFilesParallel(root string, workers int) ([]GameFile, error) {
    paths := make(chan string, 100)
    results := make(chan GameFile, 100)

    // Worker pool
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for path := range paths {
                if game, err := parseGameFile(path); err == nil {
                    results <- game
                }
            }
        }()
    }

    // 파일 경로 수집
    go func() {
        filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
            if isGameFile(path) {
                paths <- path
            }
            return nil
        })
        close(paths)
    }()

    // 워커 완료 대기 후 결과 채널 닫기
    go func() {
        wg.Wait()
        close(results)
    }()

    // 결과 수집
    var files []GameFile
    for game := range results {
        files = append(files, game)
    }

    return files, nil
}

1000개 파일 스캔에 30초. 4배 빨라졌다.


문제 1: 프로그레스 표시

사용자에게 진행 상황을 보여주고 싶었다. TUI 라이브러리(Bubble Tea)를 사용.

type Model struct {
    total     int
    processed int
    current   string
}

func (m Model) View() string {
    return fmt.Sprintf("Scanning... %d/%d\n%s",
        m.processed, m.total, m.current)
}

문제: 여러 워커가 동시에 프로그레스를 업데이트하면 화면이 깜빡인다.

해결: Atomic Counter + Rate Limiting

type Progress struct {
    total     int64
    processed int64
    lastUpdate time.Time
    mu         sync.Mutex
}

func (p *Progress) Increment() {
    atomic.AddInt64(&p.processed, 1)
}

func (p *Progress) ShouldUpdate() bool {
    p.mu.Lock()
    defer p.mu.Unlock()

    if time.Since(p.lastUpdate) > 100*time.Millisecond {
        p.lastUpdate = time.Now()
        return true
    }
    return false
}

100ms마다 한 번만 화면 업데이트. 깜빡임 해결.


문제 2: 중복 업데이트

여러 워커가 같은 파일을 처리할 일은 없지만, 프로그레스 카운터가 가끔 이상하게 증가했다.

원인: Race Condition

// 잘못된 코드
if processed < total {
    processed++  // race condition!
    updateUI()
}

해결: Atomic CAS 패턴

func (p *Progress) TryIncrement() bool {
    for {
        old := atomic.LoadInt64(&p.processed)
        if old >= atomic.LoadInt64(&p.total) {
            return false
        }
        if atomic.CompareAndSwapInt64(&p.processed, old, old+1) {
            return true
        }
    }
}

CAS(Compare-And-Swap)로 원자적으로 증가. 중복 업데이트 방지.


문제 3: 스트리밍 파이프라인

파일을 모두 찾은 후에 처리를 시작하면, 첫 결과가 나오기까지 오래 걸린다.

해결: 스트리밍 파이프라인

파일을 찾으면서 동시에 처리.

func scanStream(root string, workers int) <-chan GameFile {
    results := make(chan GameFile)

    go func() {
        defer close(results)

        paths := make(chan string, workers*2)
        var wg sync.WaitGroup

        // Stage 1: 파일 탐색 (단일 고루틴)
        go func() {
            filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
                if !d.IsDir() && isGameFile(path) {
                    paths <- path
                }
                return nil
            })
            close(paths)
        }()

        // Stage 2: 파일 처리 (워커 풀)
        for i := 0; i < workers; i++ {
            wg.Add(1)
            go func() {
                defer wg.Done()
                for path := range paths {
                    if game, err := parseGameFile(path); err == nil {
                        results <- game
                    }
                }
            }()
        }

        wg.Wait()
    }()

    return results
}

파일을 찾자마자 바로 처리 시작. 첫 결과가 빨리 나온다.


문제 4: 워커 수 최적화

워커를 몇 개로 해야 할까?

workers := runtime.NumCPU()  // CPU 코어 수

하지만 파일 파싱은 I/O 바운드 작업이다. CPU 코어 수보다 더 많은 워커가 효율적일 수 있다.

해결: 환경 적응형 워커 풀

func optimalWorkers() int {
    cpus := runtime.NumCPU()

    // SSD vs HDD 감지 (휴리스틱)
    // SSD는 병렬 I/O에 강함
    if isSSD() {
        return cpus * 2
    }
    return cpus
}

func isSSD() bool {
    // macOS: diskutil info
    // Linux: /sys/block/sda/queue/rotational
    // 간단히 기본값 사용
    return true
}

실제로는 SSD 감지가 복잡해서, 기본값으로 CPU * 2를 사용했다.


최종 성능

방식 1000 파일 10000 파일
순차 120초 20분+
병렬 (4 workers) 30초 5분
병렬 (8 workers) 20초 3분
스트리밍 + 8 workers 18초 2.5분

삽질 정리

문제 해결
TUI 깜빡임 Rate limiting (100ms)
프로그레스 중복 Atomic CAS
첫 결과 지연 스트리밍 파이프라인
워커 수 CPU * 2 (SSD 기준)

결론

Go의 고루틴과 채널은 병렬 처리에 편리하지만, 동시성 버그는 여전히 조심해야 한다.

특히 TUI와 병렬 처리를 함께 쓸 때:

  1. 화면 업데이트는 rate limiting
  2. 공유 상태는 atomic 연산
  3. 스트리밍으로 응답성 향상

병렬화는 쉽다. 올바른 병렬화는 어렵다.

Back