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와 병렬 처리를 함께 쓸 때:
- 화면 업데이트는 rate limiting
- 공유 상태는 atomic 연산
- 스트리밍으로 응답성 향상
병렬화는 쉽다. 올바른 병렬화는 어렵다.