Bash 리다이렉션과 파일 디스크립터 직접 다루기 — exec, 프로세스 치환, coproc

빌드 스크립트를 돌리면 로그는 파일에 남겨야 하는데 진행 상황은 화면으로 봐야 하고, 두 명령의 출력을 비교하려면 임시 파일을 두 개 만들어야 하고, 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이름기본 연결리다이렉션 문법
0stdin키보드< file, 0< file
1stdout화면> file, 1> file
2stderr화면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_PIDcoproc 프로세스의 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} 자동 할당을 쓴다.

참고

답글 남기기