Bash 스크립트 병렬 처리 — xargs -P와 GNU parallel 실측 비교

로그 압축, 이미지 변환, 체크섬 검증처럼 파일 수백 개를 도는 배치 스크립트를 for 루프로 짜면 코어가 6개든 32개든 하나만 쓴다. &로 전부 백그라운드에 던지면 이번엔 수백 개 프로세스가 한꺼번에 떠 메모리와 디스크를 잡아먹는다. 필요한 것은 “동시 실행 수를 정해두고 하나 끝나면 다음을 넣는” 잡 풀이고, 이건 xargs -P, GNU parallel, bash의 wait -n 셋 중 하나로 해결된다. 이 글에서는 6코어 Ubuntu 24.04에서 세 방식의 속도와 실패 처리, 인자 전달의 함정을 실측으로 비교한다.

측정 대상

20MB 파일 24개를 xz -9로 압축하는 CPU 바운드 작업을 기준으로 삼는다. 측정 전에 전부 읽어 페이지 캐시를 예열해 디스크 영향을 뺀다.

for i in $(seq -w 1 24); do head -c 20M /dev/urandom > data_$i.bin; done
cat data_*.bin > /dev/null    # 페이지 캐시 예열

# 직렬
time bash -c 'for f in data_*.bin; do xz -9 -c "$f" > /dev/null; done'

# xargs -P 6
time bash -c 'ls data_*.bin | xargs -P 6 -I{} sh -c "xz -9 -c {} > /dev/null"'

# GNU parallel
time bash -c 'ls data_*.bin | parallel -j 6 "xz -9 -c {} > /dev/null"'
방식경과 시간usersys직렬 대비
직렬 for 루프128.74초124.823.911.00x
xargs -P 625.90초128.6017.554.97x
xargs -P 1226.75초125.9617.854.81x
parallel -j 626.43초120.6120.124.87x

6코어에서 4.97배까지 오르고, 코어 수의 두 배인 -P 12는 오히려 느리다. CPU 바운드 작업의 동시 실행 수는 코어 수가 상한이며 그 이상은 컨텍스트 전환(context switch) 비용만 늘린다. I/O 바운드라면 얘기가 달라 코어 수보다 크게 잡는 것이 유리하다.

인자 전달의 함정

ls | xargs는 공백이나 따옴표가 든 파일명에서 그대로 깨진다. 한 건이라도 문제가 있으면 그 자리에서 전체가 중단된다.

touch "report 2026.txt" "a'b.txt" normal.txt
ls | xargs -P 4 -I{} sh -c 'test -f "{}" && echo "OK: {}" || echo "FAIL: {}"'
xargs: unmatched single quote; by default quotes are special to xargs unless you use the -0 option

find -print0xargs -0으로 NUL 구분자를 쓰면 파일명에 무엇이 들어 있든 안전하다.

find . -maxdepth 1 -type f -print0 | xargs -0 -P 4 -I{} sh -c 'test -f "{}" && echo "OK: {}" || echo "FAIL: {}"'
OK: ./a'b.txt
OK: ./normal.txt
OK: ./report 2026.txt

GNU parallel은 {}를 셸 인용된 형태로 치환하므로 -0 없이도 동작한다. 다만 그 때문에 명령 템플릿 안에서 {}를 다시 따옴표로 감싸면 인용이 중첩돼 깨진다.

ls | parallel -j 4 'echo "파일: {}"'     # 잘못된 사용: {}를 다시 따옴표로 감쌈
ls | parallel -j 4 'echo 파일: {}'         # 권장: 그대로 둔다
# 따옴표로 감쌌을 때
/usr/bin/bash: -c: line 1: unexpected EOF while looking for matching `''
파일: normal.txt
파일: 'report 2026.txt'

# 그대로 뒀을 때
파일: a'b.txt
파일: normal.txt
파일: report 2026.txt

참고로 -n-I는 함께 못 쓴다. 둘 다 주면 xargs가 경고를 내고 -n을 무시한다.

xargs: warning: options --max-args and --replace/-I/-i are mutually exclusive, ignoring previous --max-args value

프로세스 생성 비용

-I{}는 인자 하나마다 프로세스를 하나씩 띄운다. 명령 자체가 가벼우면 이 비용이 전체를 지배한다.

for n in 1 10 100 1000; do
  /usr/bin/time -f "%e" bash -c "seq 2000 | xargs -n $n /bin/true"
done
배치 크기프로세스 실행 횟수경과 시간
-n 12000회5.79초
-n 10200회0.47초
-n 10020회0.06초
-n 10002회0.01초
-I{}2000회5.45초

인자를 묶을 수 있는 명령(rm, grep, chmod 등)이라면 -I{} 대신 -n으로 배치를 키우는 것만으로 580배 차이가 난다. 파일 하나마다 다른 출력 경로가 필요한 경우에만 -I{}를 쓴다.

출력이 섞이는 문제

여러 잡이 같은 stdout에 쓰면 줄 단위로 뒤섞인다. 잡마다 여러 줄을 뱉는 작업에서는 어느 줄이 어느 잡 것인지 알 수 없게 된다.

job='for i in 1 2 3; do echo "job{} line$i"; sleep 0.05; done'

seq 4 | xargs -P 4 -I{} sh -c "$job"
seq 4 | parallel -j 4 "$job"                  # 기본값이 --group
seq 4 | parallel -j 4 --line-buffer "$job"
# xargs -P 4
job1 line1
job2 line1
job4 line1
job3 line1
job1 line2
job4 line2
job2 line2
job3 line2
...

# parallel -j 4 (--group)
job1 line1
job1 line2
job1 line3
job2 line1
job2 line2
job2 line3
job4 line1
...

# parallel --line-buffer
job1 line1
job2 line1
job3 line1
job1 line2
...
옵션동작쓰는 곳
--group (기본)잡이 끝날 때까지 출력을 모았다가 한 번에로그를 잡 단위로 읽어야 할 때
--line-buffer줄 단위로 즉시 출력, 잡 간에는 섞임진행 상황을 실시간으로 보고 싶을 때
-k / --keep-order입력 순서대로 출력 (–group과 함께)결과를 다시 정렬하기 곤란할 때
-u버퍼링 없이 그대로 (xargs와 동일)출력을 안 쓰는 경우

--group은 완료 순서대로 묶어 내보내므로 입력 순서와는 다르다. 입력 순서가 필요하면 -k를 붙인다.

$ seq 4 | parallel -j 4 -k 'sleep 0.$((5-{})); echo job{}'
job1
job2
job3
job4

실패를 어떻게 알 것인가

병렬 실행에서 가장 자주 놓치는 부분이다. 두 도구의 종료 코드 규칙이 서로 다르다.

printf '1\n2\n3\n4\n' | xargs -P 4 -I{} sh -c 'test {} -ne 2'; echo "xargs exit=$?"
printf '1\n'           | xargs -P 2 -I{} nosuchcmd;            echo "xargs exit=$?"
seq 5                   | parallel -j 4 'test {} -gt 3';        echo "parallel exit=$?"
xargs exit=123      # 하나 이상의 명령이 0이 아닌 값으로 끝남
xargs exit=127      # 명령을 찾을 수 없음
parallel exit=3     # 실패한 잡의 개수 (1, 2, 3번이 실패)
종료 코드xargsparallel
0전부 성공전부 성공
1~100실패한 잡의 개수
123하나 이상 실패
124명령이 255로 종료
125명령이 시그널로 죽음
126 / 127실행 불가 / 명령 없음동일
101실패 잡이 101개 이상

실패 즉시 멈추려면 --halt를 쓴다. 3번 잡이 실패하는 10개 작업을 동시 2개로 돌린 결과다.

seq 1 10 | parallel -j 2 --halt now,fail=1 'sleep 0.2; test {} -ne 3 && echo done:{}'
옵션종료 코드완료된 잡동작
--halt now,fail=112개실행 중이던 잡까지 즉시 죽임
--halt soon,fail=113개새 잡만 안 띄우고, 실행 중인 것은 마무리
--halt never (기본)19개끝까지 전부 실행

완료된 잡 수는 타이밍에 따라 한두 개 달라질 수 있다. 중요한 것은 실패한 잡이 있어도 기본값은 나머지를 끝까지 돌린다는 점이다.

어느 잡이 왜 실패했는지는 --joblog로 남긴다. 재실행 시 --resume과 함께 쓰면 실패한 잡만 다시 돌릴 수 있다.

$ seq 4 | parallel -j 4 --joblog jl.log 'sleep 0.{}; test {} -ne 2'
$ column -t jl.log
Seq  Host  Starttime       JobRuntime  Send  Receive  Exitval  Signal  Command
1    :     1786286369.990  0.105       0     0        0        0       sleep 0.1; test 1 -ne 2
2    :     1786286369.993  0.204       0     0        1        0       sleep 0.2; test 2 -ne 2
3    :     1786286369.996  0.315       0     0        0        0       sleep 0.3; test 3 -ne 2
4    :     1786286370.000  0.447       0     0        0        0       sleep 0.4; test 4 -ne 2

bash만으로 잡 풀 만들기

외부 도구를 못 쓰는 환경이라면 wait -n(bash 4.3+)으로 같은 구조를 만든다. 잡을 하나 띄우고, 상한에 도달하면 아무거나 하나 끝날 때까지 기다린다.

#!/bin/bash
MAX=4
running=0

for i in $(seq 1 12); do
    ( sleep 0.3; echo "완료 $i" ) &
    running=$((running + 1))
    if (( running >= MAX )); then
        wait -n                    # 아무 잡이나 하나 끝날 때까지 대기
        running=$((running - 1))
    fi
done
wait

동시 실행 수가 정말 4로 유지되는지는 각 잡의 시작·종료 시각을 찍어 겹침을 세면 확인된다.

( echo "S $(date +%s%N)" >> $log; sleep 0.3; echo "E $(date +%s%N)" >> $log ) &
...
sort -k2 -n $log | awk '{ if($1=="S") c++; else c--; if(c>m) m=c } END{ print "최대 동시 실행:", m }'
최대 동시 실행: 4

$ for r in 1 2 3 4 5; do /usr/bin/time -f "%e초" bash pool.sh > /dev/null; done
1.15초
2.09초
2.66초
2.86초
2.72초

$ /usr/bin/time -f "%e초" bash -c 'for i in $(seq 1 12); do sleep 0.3; done'
10.34초

동시성은 4로 정확히 유지되지만 경과 시간은 1.15~2.86초로 편차가 크다. 잡 하나가 sleep 0.3뿐이라 서브셸 생성 비용과 스케줄링 지연이 그대로 드러나는 것으로, 잡이 무거워질수록 이 오차는 묻힌다.

wait -n은 먼저 끝난 잡의 종료 코드를 그대로 돌려주므로 실패 감지에도 쓸 수 있다.

$ bash -c '( exit 7 ) & ( sleep 1 ) & wait -n; echo "먼저 끝난 잡 exit=$?"'
먼저 끝난 잡 exit=7

무엇을 쓸 것인가

xargs -PGNU parallelwait -n
설치coreutils/findutils 기본별도 설치 필요bash 4.3+
인용 처리-0 필수자동직접
출력 분리없음--group / -k직접
실패 추적종료 코드 123뿐--joblog, --halt직접
적합한 곳간단한 일회성 배치재실행·로깅이 필요한 작업의존성 못 늘리는 스크립트

주의사항

  • CPU 바운드는 코어 수가 상한: 위 측정에서 -P 12-P 6보다 느렸다. I/O 대기가 많은 작업이라면 코어 수보다 크게 잡되, parallel -j 200%처럼 비율로 지정하면 장비가 바뀌어도 스크립트를 고칠 필요가 없다.
  • -P 0은 무제한: xargs의 -P 0은 가능한 만큼 전부 띄운다. 입력이 수천 줄이면 그대로 fork 폭탄이 된다.
  • 파이프 출력의 원자성: POSIX가 보장하는 파이프 단일 write의 원자성은 PIPE_BUF(리눅스에서 4096바이트)까지다. 잡 하나가 4KB를 넘는 출력을 한 번에 쓰는 구조라면 파일로 나눠 쓰거나 --group에 맡긴다.
  • 같은 파일에 append 하지 말 것: 여러 잡이 >> result.txt로 몰리면 순서를 보장할 수 없다. 잡별 파일로 쓰고 마지막에 합치는 편이 안전하다.
  • parallel의 인용: {}는 이미 셸 인용된 상태로 치환된다. 직접 따옴표로 감싸면 오히려 깨진다.
  • 인용(citation) 안내문: Ubuntu 24.04 패키지(20231122)는 안내문을 출력하지 않지만, 업스트림 설치본은 첫 실행 시 stderr로 인용 안내를 뱉는다. 배포판을 가리지 않는 스크립트라면 --will-cite를 붙여 두는 편이 안전하다.

마무리

세 방식 모두 “동시 실행 수를 정해두고 채워 넣는다”는 구조는 같고, 차이는 인자 인용·출력 분리·실패 추적을 도구가 해주느냐 내가 하느냐에 있다. 일회성 작업은 find -print0 | xargs -0 -P $(nproc)로 충분하고, 실패한 항목만 다시 돌려야 하거나 잡별 로그가 필요해지는 순간부터는 GNU parallel이 값을 한다. 어느 쪽이든 병렬화 전에 직렬 기준 시간을 한 번 재두면 실제로 몇 배가 빨라졌는지, 코어를 더 넣을 여지가 있는지 숫자로 판단할 수 있다.

참고

답글 남기기