로그 압축, 이미지 변환, 체크섬 검증처럼 파일 수백 개를 도는 배치 스크립트를 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"'| 방식 | 경과 시간 | user | sys | 직렬 대비 |
|---|---|---|---|---|
| 직렬 for 루프 | 128.74초 | 124.82 | 3.91 | 1.00x |
xargs -P 6 | 25.90초 | 128.60 | 17.55 | 4.97x |
xargs -P 12 | 26.75초 | 125.96 | 17.85 | 4.81x |
parallel -j 6 | 26.43초 | 120.61 | 20.12 | 4.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 -print0과 xargs -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 1 | 2000회 | 5.79초 |
-n 10 | 200회 | 0.47초 |
-n 100 | 20회 | 0.06초 |
-n 1000 | 2회 | 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번이 실패)
| 종료 코드 | xargs | parallel |
|---|---|---|
| 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=1 | 1 | 2개 | 실행 중이던 잡까지 즉시 죽임 |
--halt soon,fail=1 | 1 | 3개 | 새 잡만 안 띄우고, 실행 중인 것은 마무리 |
--halt never (기본) | 1 | 9개 | 끝까지 전부 실행 |
완료된 잡 수는 타이밍에 따라 한두 개 달라질 수 있다. 중요한 것은 실패한 잡이 있어도 기본값은 나머지를 끝까지 돌린다는 점이다.
어느 잡이 왜 실패했는지는 --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 -P | GNU parallel | wait -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이 값을 한다. 어느 쪽이든 병렬화 전에 직렬 기준 시간을 한 번 재두면 실제로 몇 배가 빨라졌는지, 코어를 더 넣을 여지가 있는지 숫자로 판단할 수 있다.