빌드 스크립트를 돌리면 로그는 파일에 남겨야 하는데 진행 상황은 화면으로 봐야 하고, 두 명령의 출력을 비교하려면 임시 파일을 두 개 만들어야 하고, while read로 센 개수가 루프를 벗어나면 0으로 돌아와 있다. 이런 일들은 전부 >와 | 두 개만 쓰던 습관에서 나온다. Bash는 파일 디스크립터(file descriptor)를 직접 열고 닫고 복제하는 문법을 갖고 있고, 프로세스 치환(process substitution)과 coproc까지 쓰면 임시 파일 없이 스트림을 원하는 모양으로 배선할 수 있다. 이 글에서는 fd 번호를 직접 다루는 exec, 프로세스 치환, coproc을 실제 실행 결과와 함께 정리한다.
아래 모든 예제는 Bash 5.2.21 / Ubuntu 24.04에서 실행한 것이다.
$ bash --version | head -1
GNU bash, version 5.2.21(1)-release (x86_64-pc-linux-gnu)
표준 스트림 세 개와 리다이렉션 순서
셸이 명령을 실행할 때 기본으로 열려 있는 fd는 세 개다.
| fd | 이름 | 기본 연결 | 리다이렉션 문법 |
|---|---|---|---|
| 0 | stdin | 키보드 | < file, 0< file |
| 1 | stdout | 화면 | > file, 1> file |
| 2 | stderr | 화면 | 2> file |
#!/bin/bash
echo "표준출력으로 가는 줄"
echo "표준에러로 가는 줄" >&2>와 2>로 두 스트림을 따로 받으면 각자 다른 파일로 들어간다.
$ ./demo1.sh > out.log 2> err.log
$ cat out.log
표준출력으로 가는 줄
$ cat err.log
표준에러로 가는 줄
자주 틀리는 것이 2>&1의 위치다. &1은 “지금 fd 1이 가리키는 곳”을 복제하는 것이므로, 셸이 왼쪽에서 오른쪽으로 처리하는 순서에 따라 결과가 달라진다.
$ ./demo1.sh > both_ok.log 2>&1 # 1을 파일로 돌린 뒤 2를 1(=파일)에 복제
$ cat both_ok.log
표준출력으로 가는 줄
표준에러로 가는 줄
$ ./demo1.sh 2>&1 > both_bad.log # 2를 1(=화면)에 복제한 뒤 1만 파일로 돌림
표준에러로 가는 줄 <- 화면으로 샌다
$ cat both_bad.log
표준출력으로 가는 줄
Bash 4 이후로는 둘 다 파일로 보내는 &> file 단축 문법이 있어 순서를 헷갈릴 일이 없다.
exec로 fd를 직접 열고 닫기
exec에 명령 없이 리다이렉션만 주면 셸 자신의 fd 테이블을 바꾼다. 3번 이상의 번호를 열어두면 명령마다 파일 경로를 반복해 쓰지 않아도 된다.
#!/bin/bash
exec 3> report.txt # fd 3을 쓰기용으로 열기
echo "리포트 1행" >&3
echo "진행 상황은 화면으로"
echo "리포트 2행" >&3
exec 3>&- # 닫기
echo "--- report.txt ---"; cat report.txt$ ./demo2.sh
진행 상황은 화면으로
--- report.txt ---
리포트 1행
리포트 2행
읽기 쪽도 같다. exec 4< file로 열어두면 read -u 4로 한 줄씩 꺼내면서 파일 오프셋이 유지된다.
#!/bin/bash
exec 4< src.txt
read -r -u 4 first
read -r -u 4 second
echo "first=$first second=$second"
echo "남은 줄:"; cat <&4
exec 4<&-$ printf 'alpha\nbeta\ngamma\n' > src.txt
$ ./demo3.sh
first=alpha second=beta
남은 줄:
gamma
<>는 읽기와 쓰기를 동시에 열고 오프셋을 공유한다. 5바이트를 읽은 뒤 쓰면 그 지점부터 덮어쓴다.
#!/bin/bash
exec 5<> rw.txt
read -r -n 5 head5 <&5
echo "앞 5바이트: $head5"
printf 'xxxxx' >&5 # 현재 오프셋(5)부터 덮어쓰기
exec 5>&-
echo "결과: $(cat rw.txt)"$ printf 'HELLOWORLD' > rw.txt
$ ./demo4.sh
앞 5바이트: HELLO
결과: HELLOxxxxx
번호를 직접 고르면 다른 코드와 충돌할 수 있다. {var} 형태를 쓰면 Bash가 빈 번호를 골라 변수에 담아준다.
#!/bin/bash
exec {log}> auto.log
echo "fd 번호: $log"
echo "자동 할당 fd로 쓴 줄" >&$log
exec {log}>&-
cat auto.log$ ./demo5.sh
fd 번호: 10
자동 할당 fd로 쓴 줄
프로세스 치환으로 명령을 파일처럼 넘기기
<(명령)은 명령의 출력을 파이프로 연결하고 /dev/fd/N 경로를 그 자리에 치환한다. 파일 경로만 받는 명령에 다른 명령의 출력을 바로 먹일 수 있다.
$ echo <(echo hi)
/dev/fd/63
$ readlink -f <(echo x)
/proc/10258/fd/pipe:[46953]
임시 파일 두 개를 만들어 diff하던 패턴이 한 줄로 줄어든다.
$ diff <(printf 'one\ntwo\nthree\n') <(printf 'one\n2\nthree\n')
2c2
< two
---
> 2
$ echo $?
1
쓰기 방향인 >(명령)은 tee와 묶으면 한 스트림을 여러 갈래로 동시에 처리한다.
$ seq 1 6 | tee >(awk '{s+=$1} END{print "합계:", s}' > sum.txt) \
>(wc -l > lines.txt) > /dev/null
$ cat sum.txt
합계: 21
$ cat lines.txt
6
가장 실용적인 쓰임은 while read의 서브셸 문제를 피하는 것이다. 파이프라인의 각 단계는 서브셸에서 돌기 때문에 루프 안에서 올린 변수가 루프를 벗어나면 사라진다.
#!/bin/bash
n=0
printf 'a\nb\nc\n' | while read -r l; do n=$((n+1)); done
echo "파이프 사용 후 n=$n"
n=0
while read -r l; do n=$((n+1)); done < <(printf 'a\nb\nc\n')
echo "프로세스 치환 사용 후 n=$n"$ ./demo10.sh
파이프 사용 후 n=0
프로세스 치환 사용 후 n=3
< <(...)는 루프를 현재 셸에서 돌리므로 변수가 남는다. 공백 없이 <<(로 쓰면 다른 문법으로 해석되니 사이를 띄워야 한다.
coproc으로 양방향 대화하기
한쪽 방향만 필요하면 파이프로 충분하지만, 명령을 띄워두고 질문과 답을 주고받아야 하면 coproc이 맞다. 배열 변수에 읽기/쓰기 fd가 담긴다.
#!/bin/bash
coproc BC { stdbuf -oL bc -l; }
echo "coproc PID=$BC_PID, fd=(${BC[0]} ${BC[1]})"
echo "3*7" >&"${BC[1]}"; read -r -u "${BC[0]}" r; echo "3*7 = $r"
echo "sqrt(2)" >&"${BC[1]}"; read -r -u "${BC[0]}" r; echo "sqrt(2) = $r"
echo "quit" >&"${BC[1]}"
exec {BC[1]}>&- 2>/dev/null
wait "$BC_PID" 2>/dev/null
echo "coproc 종료"$ ./demo9.sh
coproc PID=10283, fd=(63 60)
3*7 = 21
sqrt(2) = 1.41421356237309504880
coproc 종료
stdbuf -oL이 핵심이다. 파이프에 연결되면 bc가 출력을 블록 버퍼링해서 read가 영원히 기다린다.
| 변수 | 내용 |
|---|---|
${NAME[0]} | coproc의 stdout을 읽는 fd |
${NAME[1]} | coproc의 stdin에 쓰는 fd |
$NAME_PID | coproc 프로세스의 PID — wait에 넘길 값 |
종료 코드는 어디까지 잡히는가
리다이렉션으로 배선을 바꾸면 실패를 감지하는 방법도 달라진다. 파이프라인은 pipefail로 잡히지만 프로세스 치환은 그렇지 않다.
$ set -o pipefail; false | cat; echo $?
1
$ set +o pipefail; false | cat; echo $?
0
#!/bin/bash
echo "[A] 프로세스 치환 직후"
cat < <(exit 3)
echo " 리다이렉션 종료코드 = $?"
echo " \$! = $!"
wait "$!"; echo " wait 결과 = $?"
echo "[B] exec로 열면 \$!가 설정된다"
exec {fd}< <(exit 4)
pid=$!
echo " \$! = $pid"
wait "$pid"; echo " wait 결과 = $?"
exec {fd}<&-$ ./demo11b.sh
[A] 프로세스 치환 직후
리다이렉션 종료코드 = 0
$! =
./demo11b.sh: line 6: wait: `': not a pid or valid job spec
wait 결과 = 1
[B] exec로 열면 $!가 설정된다
$! = 10337
wait 결과 = 4
단순 리다이렉션 자리의 <(...)는 $!를 남기지 않아 실패를 잡을 길이 없다. exec {fd}< <(...)로 열면 PID가 $!에 들어오고 wait으로 4를 받아낼 수 있다.
실전 패턴
로그는 파일로, 진행 상황은 화면으로
원래 stdout을 fd 3에 보관한 뒤 exec로 전체 출력을 로그 파일로 돌리면, 보고용 함수만 >&3으로 내보내면 된다.
#!/bin/bash
set -euo pipefail
exec 3>&1 # 원래 stdout 보관
exec > build.log 2>&1 # 이후 모든 출력은 로그 파일
progress() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$1" >&3; }
progress "시작"
echo "로그: 1단계 상세 출력"
progress "1단계 완료"
echo "로그: 2단계 상세 출력"
progress "완료"
exec 3>&-$ ./demo14.sh
[19:45:06] 시작
[19:45:06] 1단계 완료
[19:45:06] 완료
$ cat build.log
로그: 1단계 상세 출력
로그: 2단계 상세 출력
스크립트 전체 출력을 한 줄로 돌리기
exec를 스크립트 맨 위에 두면 그 아래 모든 명령의 출력이 파일로 간다. 명령마다 >> log를 붙이지 않아도 된다.
#!/bin/bash
exec > run.log 2>&1
echo "이 줄부터는 전부 run.log"
ls /nonexistent-path-xyz
echo "끝"$ ./demo12.sh # 화면에는 아무것도 나오지 않는다
$ cat run.log
이 줄부터는 전부 run.log
ls: cannot access '/nonexistent-path-xyz': No such file or directory
끝
열어둔 fd는 자식에게 상속된다
exec 7>로 연 fd는 자식 프로세스의 fd 테이블에도 그대로 남는다. 자식에게 물려주고 싶지 않으면 그 명령에만 7>&-를 붙여 닫는다.
#!/bin/bash
exec 7> leak.txt
echo "--- 자식이 보는 fd 목록 ---"
bash -c 'ls -l /proc/self/fd | grep -E " 7 ->|/leak.txt"'
echo "--- 7>&- 로 닫고 실행 ---"
bash -c 'ls -l /proc/self/fd | grep -c " 7 ->"' 7>&-
exec 7>&-$ ./demo13.sh
--- 자식이 보는 fd 목록 ---
l-wx------ 1 noble noble 64 Oct 5 19:44 7 -> /tmp/.../sh/leak.txt
--- 7>&- 로 닫고 실행 ---
0
주의사항
| 상황 | 증상 | 대응 |
|---|---|---|
2>&1을 > file 앞에 씀 | stderr가 파일이 아니라 화면으로 샌다 | > file 2>&1 순서로 쓰거나 &> file을 쓴다 |
<<(처럼 공백 없이 씀 | 히어독/산술 문법으로 오해돼 엉뚱한 에러가 난다 | < <(...)로 반드시 띄운다 |
| 프로세스 치환의 실패를 감지하려 함 | 종료 코드가 늘 0이고 $!도 비어 있다 | exec {fd}< <(...)로 열고 $!를 저장해 wait한다 |
coproc 상대가 라인 버퍼링을 안 함 | read가 블로킹된 채 멈춘다 | stdbuf -oL로 감싸거나 상대 프로그램의 플러시 옵션을 켠다 |
coproc을 두 개 이상 띄움 | Bash가 경고를 내고 동작이 보장되지 않는다 | 이름을 다르게 주더라도 의존하지 말고, 복잡해지면 명시적 FIFO로 간다 |
| 열어둔 fd를 닫지 않고 자식 실행 | 자식이 fd를 상속해 파일 핸들이 남거나 파이프가 닫히지 않는다 | exec N>&-로 닫거나 해당 명령에만 N>&-를 붙인다 |
진행 상황을 /dev/tty로 보냄 | 제어 터미널이 없는 CI·cron에서 /dev/tty: No such device or address로 실패한다 | exec 3>&1로 원래 stdout을 보관해 쓰는 쪽이 안전하다 |
<>로 열고 덮어씀 | 파일이 잘리지 않아 뒤쪽에 옛 내용이 남는다 | 길이를 줄여야 하면 truncate나 >로 다시 열어 처리한다 |
/dev/tty 항목은 실제로 재현된다. 비대화형 셸에서 /dev/tty에 쓰면 아래처럼 실패한다.
$ seq 1 5 | while read -r n; do echo "처리 중: $n" > /dev/tty; echo "$((n*n))"; done | paste -sd,
/bin/bash: line 43: /dev/tty: No such device or address
(5회 반복)
1,4,9,16,25
마무리
exec N> file로 fd를 열어두면 경로를 반복하지 않고,exec 3>&1로 원래 stdout을 보관하면 로그와 진행 표시를 분리할 수 있다.< <(...)는while read를 현재 셸에서 돌려 변수 소실을 막는 가장 간단한 방법이다.- 프로세스 치환은 종료 코드를 흘리므로, 실패를 봐야 하면
exec {fd}< <(...)+wait "$!"로 받는다. coproc은 양방향이 필요할 때만 쓰고, 상대의 출력 버퍼링을stdbuf -oL로 먼저 해결해야 한다.- 번호 충돌이 걱정되면
{var}자동 할당을 쓴다.