1. 정의 / 결론

dup, dup2는 파일 디스크립터를 복제하거나 특정 번호로 재지정하는 시스템콜이다. 그리고 쉘의 리다이렉션 문법, 특히 2>&1은 내부적으로 바로 이 FD 재지정 개념으로 이해해야 한다. 이 글의 핵심은 시스템콜 API 자체를 나열하는 데 있지 않다. 진짜 핵심은 2>&1이 그렇게 동작하는지, 왜 리다이렉션 순서가 결과를 바꾸는지, 왜 “복사”라고 배웠는데 실제로는 그 느낌이 아닌지를 구조적으로 설명하는 데 있다.

많은 설명이 2>&1을 “stderr를 stdout으로 보낸다” 정도로 끝낸다. 틀린 설명은 아니지만, 그 표현만으로는 중요한 부분이 빠진다. 더 정확히 말하면, 2>&1stderr(fd 2)가 stdout(fd 1)이 현재 가리키는 대상으로 연결되도록 바꾸는 동작이다. 즉, 무언가를 두 군데로 복사해서 보내는 것이 아니라, fd 2가 바라보는 연결 대상을 fd 1 기준으로 재지정하는 것이다.

이 관점이 중요한 이유는 아주 단순하다. 이걸 이해하면 command > out.log 2>&1command 2>&1 > out.log가 왜 다른지 설명할 수 있다. 반대로 이 구조를 이해하지 못하면, 2>&1은 계속 외워도 리다이렉션 순서에서 계속 헷갈린다. 결국 이 글은 리다이렉션 문법을 “문자열 문법”이 아니라 프로세스의 FD 상태 변경 작업으로 번역해 주는 글이다.

2. 핵심 요약

리눅스 프로세스는 기본적으로 몇 개의 대표적인 파일 디스크립터를 갖고 시작한다. 0은 표준 입력 stdin, 1은 표준 출력 stdout, 2는 표준 에러 stderr다. 쉘의 리다이렉션 문법은 결국 이 번호들이 무엇을 가리키는지를 바꾸는 작업이다. 화면에 문자열이 보이느냐, 파일에 저장되느냐, 파이프로 넘어가느냐의 차이는 출력 문자열 자체의 속성이 아니라 그 시점에 fd 1, fd 2가 무엇을 보고 있느냐의 차이다.

dup(fd)는 기존 FD를 복제해서 새로운 FD 번호를 만든다. dup2(oldfd, newfd)newfdoldfd와 같은 대상을 가리키게 만든다. 여기서 중요한 것은 복제의 대상이 데이터가 아니라는 점이다. 출력된 문자열이 복사되는 것이 아니라, 프로세스가 가진 FD 엔트리의 연결 관계가 바뀌는 것이다.

쉘의 2>&1은 개념적으로 보면 dup2(1, 2)에 해당한다. 즉, stderrstdout 그 자체로 바꾸는 것이 아니라, fd 2가 fd 1이 현재 향하는 곳을 보게 만드는 것이다. 반면 >는 파일을 열고 stdout의 대상을 그 파일로 바꾸는 동작이다. 따라서 하나는 직접 대상 변경, 다른 하나는 다른 FD 기준으로 연결이다. 이 차이 하나가 리다이렉션 순서를 갈라놓는다.

3. 왜 필요한가

3-1. 문제 상황: 2>&1은 외워도 순서에서 무너진다

많은 사용자가 2>&1을 “에러까지 같이 저장하는 문법” 정도로 외운다. 실무에서도 그렇게 설명하고 넘어가는 경우가 많다. 그런데 문제는 이 수준의 이해로는 조금만 문법이 바뀌어도 바로 무너진다는 점이다. 대표적인 예가 아래 두 문장이다.

command > out.log 2>&1
command 2>&1 > out.log

겉으로 보면 거의 비슷하다. 둘 다 stdout, stderr, out.log가 등장하고, 둘 다 “출력을 파일과 엮는 것처럼” 보인다. 하지만 실제 결과는 다르다. 첫 번째는 stdoutstderr가 둘 다 out.log로 들어갈 수 있지만, 두 번째는 보통 stdout만 파일로 가고 stderr는 여전히 터미널에 남는다.

이 차이를 문법 암기로 설명할 수는 없다. “앞에 쓰면 되고 뒤에 쓰면 안 된다” 같은 식으로 외워 봐야, 왜 그런지 이해되지 않는다. 결국 조금 다른 문맥이 나오면 다시 헷갈린다. 문제는 사용자가 2>&1을 결과 문장으로 외웠지, FD 상태 변경 과정으로 이해하지 않았기 때문이다.

3-2. 기존 설명의 한계: “합친다”는 설명은 정확하지 않다

보통 2>&1을 설명할 때 “stdout과 stderr를 합친다”는 표현을 많이 쓴다. 실용적인 설명으로는 어느 정도 쓸 수 있지만, 구조를 이해하는 데는 오히려 독이 된다. 왜냐하면 이 표현은 마치 두 개의 흐름이 어떤 추상적인 공간에서 병합되는 것처럼 들리기 때문이다. 실제로는 그런 식으로 동작하지 않는다.

실제 동작은 훨씬 구체적이다. 2>&1은 두 스트림을 공중에서 합치는 것이 아니라, fd 2를 fd 1이 현재 가리키는 대상으로 다시 연결하는 것이다. 즉 “합침”이라기보다 “재지정”에 가깝다. 이 차이를 놓치면 세 가지를 계속 틀리게 된다. 첫째, 왜 순서가 중요한지 이해하지 못한다. 둘째, “복사”와 “참조”를 혼동하게 된다. 셋째, 리다이렉션과 파이프의 차이도 감으로만 이해하게 된다.

그래서 이 글에서는 “합친다”보다 무엇이 무엇을 현재 기준으로 바라보게 되는가를 반복해서 봐야 한다. 이 관점으로 들어오면 문법이 갑자기 훨씬 덜 신비해진다.

3-3. 해결 구조: 출력 텍스트가 아니라 FD를 봐야 한다

리눅스 I/O를 이해할 때 많은 초보 설명은 “문자열이 어디로 가는가”를 중심으로 말한다. 물론 입문 단계에서는 그 설명도 필요하다. 하지만 2>&1, 리다이렉션 순서, 파이프, FD 백업 같은 문제로 들어가면 더 이상 문자열만 봐서는 안 된다. 그 시점부터는 프로세스가 가진 파일 디스크립터가 무엇을 가리키는가를 봐야 한다.

dup2는 이 구조를 가장 직접적으로 보여주는 시스템콜이다. 왜냐하면 이 함수는 “문자열을 어디에 쓰라”가 아니라, 특정 번호의 FD가 어떤 열린 대상을 보게 할 것인가를 다루기 때문이다. 이것이 바로 리다이렉션의 실체다. 쉘 문법은 짧고 간단해 보여도, 내부에서는 결국 이런 FD 재배치 작업이 일어난다.

즉 이 글은 dup, dup2를 시스템 프로그래밍 용어로만 소개하는 글이 아니다. 오히려 반대로, 이미 익숙한 >, 2>, 2>&1, | 같은 쉘 문법을 시스템 수준의 FD 구조로 번역하는 글이다. 이 번역이 되면 리다이렉션은 갑자기 외울 문법이 아니라 설명 가능한 동작이 된다.

4. 파일 디스크립터와 dup, dup2의 기본 구조

파일 디스크립터는 프로세스가 사용하는 정수형 핸들이다. 일반적으로 프로세스는 열린 입출력 대상을 직접 들고 있는 것이 아니라, 정수 번호를 통해 참조한다. 대표적으로 0, 1, 2가 각각 stdin, stdout, stderr를 뜻한다. 하지만 여기서 중요한 점은, 파일 디스크립터라는 이름 때문에 이것이 “파일만” 가리킨다고 착각하면 안 된다는 것이다.

FD가 가리킬 수 있는 대상은 훨씬 넓다. 터미널일 수도 있고, 일반 파일일 수도 있고, 파이프일 수도 있고, 소켓일 수도 있고, 장치 파일일 수도 있다. 결국 FD는 “파일 전용 번호”가 아니라, 프로세스 입장에서 I/O 대상을 참조하는 공통 핸들이다. 이것이 리눅스 I/O 추상화의 핵심이다.

dup(fd)는 기존 FD와 같은 열린 대상을 공유하는 새 FD를 만든다. dup2(oldfd, newfd)newfdoldfd와 같은 열린 대상을 가리키게 만든다. 이때 newfd가 이미 열려 있으면 먼저 닫고 재사용한다. 여기서 반드시 강조해야 할 것은, 이 둘은 데이터를 복사하는 함수가 아니라는 점이다. 또한 “출력 문자열을 다른 곳에도 보내는 함수”도 아니다. 더 정확히 말하면 특정 FD 번호가 바라보는 연결 엔트리를 바꾸는 함수다. 이게 리다이렉션의 실체다.

아래의 짧은 예제를 보자.

#include <unistd.h>
#include <stdio.h>

int main() {
    int newfd = dup(1);  // stdout(fd 1)을 복제
    dprintf(1, "to stdout\n");
    dprintf(newfd, "also to same target as stdout\n");
    return 0;
}

이 코드에서 dup(1)로 만든 newfdstdout과 같은 대상을 본다. 프로그램을 터미널에서 실행했다면 둘 다 결국 터미널로 출력될 가능성이 크다. 중요한 것은 newfd가 완전히 새로운 독립 장치를 만드는 것이 아니라는 점이다. 같은 열린 대상을 공유하는 다른 FD 번호일 뿐이다.

즉, newfd가 별도의 출력 세계를 갖는 것이 아니다. 그냥 기존 stdout이 향하고 있던 곳을 함께 바라보는 것이다. 이 감각을 잡아야 나중에 2>&1도 제대로 보인다. stderrstdout과 같게 만든다는 것은, 결국 “같은 대상을 보게 만든다”는 뜻이지, “출력을 분기해서 두 군데 보낸다”는 뜻이 아니다.

5. dupdup2의 차이

dupdup2는 비슷해 보이지만, 실제 용도는 분명히 다르다. dup(fd)는 주어진 FD를 복제해서 사용 가능한 가장 낮은 번호의 새 FD를 반환한다. 즉, 복제는 해 주지만 “몇 번 FD로 붙일지”는 직접 고를 수 없다. 그래서 구조를 이해하는 데는 좋지만, 쉘 리다이렉션 구현처럼 특정 번호를 정확히 다뤄야 하는 상황에는 한계가 있다.

반면 dup2(oldfd, newfd)는 결과를 반드시 newfd에 붙인다. 이것이 결정적인 차이다. 쉘은 stdout을 다룰 때는 반드시 fd 1, stderr를 다룰 때는 반드시 fd 2를 바꿔야 한다. 즉, “새 번호 하나를 얻는 것”이 목적이 아니라, 고정된 번호가 바라보는 대상을 교체하는 것이 목적이다. 이때 필요한 것이 dup2다.

왜 쉘이 dup2 같은 동작을 필요로 하는지 생각해 보면 간단하다. 리다이렉션은 “출력 기능을 하나 더 만들기”가 아니다. stdout은 언제나 fd 1이어야 하고, stderr는 언제나 fd 2여야 한다. 그런데 그 fd 1, 2가 가리키는 곳을 원하는 파일이나 파이프나 터미널로 바꾸고 싶다. 이 작업은 dup보다 dup2에 정확히 맞는다.

#include <unistd.h>
#include <stdio.h>

int main() {
    int copied = dup(1);      // 예: 3
    dup2(1, 2);               // stderr(2)를 stdout(1)과 같은 대상으로

    dprintf(copied, "written via copied fd\n");
    dprintf(2, "written via stderr, but now same target as stdout\n");
    return 0;
}

이 코드에서 copied는 보통 3 이상의 번호가 된다. 이는 dup(1)이 “stdout과 같은 대상을 가리키는 새 FD를 하나 만들었다”는 뜻이다. 반면 dup2(1, 2)는 fd 2를 fd 1과 같은 대상으로 바꾼다. 즉, stderrstdout의 현재 목적지를 보게 만든다. 이게 바로 쉘의 2>&1과 대응되는 구조다.

이 차이를 정리하면 이렇다. dup는 “같은 대상을 향하는 새 번호 추가”에 가깝고, dup2는 “특정 번호가 향하는 대상을 덮어쓰기”에 가깝다. 쉘 리다이렉션에서 중요한 것은 거의 항상 후자다.

6. 2>&1의 실제 정체

이제 핵심으로 들어가자. 2>&1은 “stderr를 stdout에 복사”하는 문법이 아니다. 이 표현은 정확하지 않다. 개념적으로는 아래와 같이 봐야 한다.

dup2(1, 2);

의미는 분명하다. 현재 fd 1이 향하는 곳을 기준으로, fd 2도 그쪽을 보게 만든다. 여기서 핵심 단어는 “현재”다. 2>&1은 막연한 관계를 만드는 것이 아니라, 그 시점에 fd 1이 가리키는 대상을 기준으로 fd 2를 재지정하는 것이다.

그래서 반드시 기억해야 할 문장이 있다.
핵심은 항상 “현재 stdout이 어디를 보고 있느냐”다.

이 관점이 없으면 오해가 계속 생긴다. 예를 들어 어떤 사람은 이렇게 생각한다. “그럼 stdout에 쓰면 stderr로도 같이 복사되나?” 아니다. 그런 식의 분기는 일어나지 않는다. 또 어떤 사람은 “한 번 2>&1 해 두면 이후에 stdout 목적지가 바뀔 때 stderr도 자동으로 따라가나?”라고 생각한다. 그것도 아니다. 2>&1은 동적 별칭이 아니라 실행 당시 기준의 재지정이다.

아래를 보자.

command > out.log 2>&1

개념적으로는 이렇게 볼 수 있다.

  1. out.log를 열고 fd 1(stdout)을 그 파일로 연결한다.
  2. fd 2(stderr)를 현재 fd 1이 가리키는 대상으로 연결한다.

결과는 stdout, stderr 모두 out.log로 간다.

반대로 아래는 다르다.

command 2>&1 > out.log

개념적으로는 이렇게 된다.

  1. fd 2(stderr)를 현재 fd 1(stdout)이 가리키는 대상으로 연결한다.
    이 시점에서는 보통 터미널이다.
  2. fd 1(stdout)을 out.log로 연결한다.

결과는 stdoutout.log로 가고, stderr는 여전히 터미널에 남는다.

즉, 2>&1은 미래의 stdout을 추적하지 않는다. 그 순간의 stdout 상태를 기준으로 한 번 재지정할 뿐이다. 이 때문에 리다이렉션 순서가 중요해진다. 순서가 중요한 이유는 각 단계가 이전 FD 상태를 기준으로 적용되기 때문이다.

7. 왜 리다이렉션 순서가 중요한가

많은 사람이 command > out.log 2>&1command 2>&1 > out.log를 보면, 비슷한 구성요소가 들어 있으니 결과도 비슷할 거라고 생각한다. 이 직관은 틀렸다. 그리고 틀리는 이유도 분명하다. 리다이렉션은 선언형 문장이 아니라 순서대로 적용되는 상태 변경 작업이기 때문이다.

핵심 오해는 이거다. 2>&1을 어떤 “영구 관계 설정”처럼 보는 것이다. 하지만 실제로는 그렇지 않다. 2>&1은 그 시점의 fd 1을 보고 fd 2를 바꾸는 순간 동작이다. 따라서 앞에서 이미 stdout이 파일로 바뀌었는지, 아니면 아직 터미널을 보고 있는지가 결과를 갈라놓는다.

실험으로 보면 더 명확하다.

python3 -c 'import sys; print("OUT"); print("ERR", file=sys.stderr)' > out.log 2>&1
cat out.log

예상 결과는 다음과 같다.

OUT
ERR

이번에는 순서를 바꿔 보자.

python3 -c 'import sys; print("OUT"); print("ERR", file=sys.stderr)' 2>&1 > out.log
cat out.log

이 경우 out.log에는 보통 OUT만 들어간다. ERR는 화면에 출력된다. 왜냐하면 2>&1이 먼저 실행될 때, fd 1은 아직 터미널을 보고 있기 때문이다. 그 뒤에 stdout만 파일로 바뀌어도, 이미 설정된 stderr는 자동 추적하지 않는다.

여기서 다시 한 번 핵심을 못 박아야 한다.
2>&1은 동적인 별칭이 아니라 순간적인 재지정 동작이다.
그래서 “순서를 바꿔도 비슷하겠지”가 가장 흔한 오해가 된다.

8. 예제

예제 1. stdout 복제

#include <unistd.h>
#include <stdio.h>

int main() {
    int fd = dup(1);
    dprintf(1, "stdout line\n");
    dprintf(fd, "dup stdout line\n");
    return 0;
}

결과는 보통 두 줄 모두 터미널에 출력된다. 이유는 간단하다. dup(1)로 얻은 fdstdout과 같은 열린 대상을 공유하기 때문이다. 즉, fd는 새로운 출력 장치를 만든 것이 아니라, 기존 stdout과 같은 곳에 쓰는 또 다른 번호다.

실무적으로 이 예제가 중요한 이유는 dup를 “출력을 복사하는 기능”으로 오해하지 않게 해 주기 때문이다. 실제로 복제되는 것은 문자열이 아니라 연결 구조다. 따라서 dup를 이해할 때는 “새 채널 생성”이 아니라 “기존 대상을 보는 새 FD 번호 추가”로 이해해야 맞다.

예제 2. stderrstdout과 같은 곳으로 보내기

#include <unistd.h>
#include <stdio.h>

int main() {
    dup2(1, 2);
    dprintf(1, "normal output\n");
    dprintf(2, "error output redirected to stdout target\n");
    return 0;
}

이 경우 stdoutstderr는 같은 곳으로 간다. 터미널에서 실행했다면 둘 다 화면에 보일 것이고, 만약 실행 전에 stdout이 파일로 바뀐 상태라면 둘 다 그 파일로 갈 수 있다. 핵심 이유는 dup2(1, 2)가 fd 2를 fd 1과 같은 대상을 보도록 만들었기 때문이다.

이 예제의 실무적 의미는 명확하다. 바로 2>&1의 본질을 보여 준다. stderrstdout으로 “합쳐지는 것처럼” 보이지만, 실제로는 fd 2가 fd 1의 현재 목적지를 공유하도록 재설정된 것이다.

예제 3. 파일 리다이렉션 후 dup2

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>

int main() {
    int filefd = open("out.log", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    dup2(filefd, 1);  // stdout -> file
    dup2(1, 2);       // stderr -> same file

    dprintf(1, "stdout to file\n");
    dprintf(2, "stderr also to file\n");
    close(filefd);
    return 0;
}

결과는 stdoutstderr가 모두 out.log에 기록되는 것이다. 이유는 순서에 있다. 먼저 stdout을 파일로 바꾸고, 그 다음 stderr현재 stdout이 가리키는 대상, 즉 파일에 맞췄기 때문이다.

실무적으로 보면 이 예제는 쉘의 아래 문장과 같은 구조다.

command > out.log 2>&1

즉, 이 문장은 “파일로 출력한 뒤 에러도 같은 파일에 보낸다”라는 결과만 기억하면 부족하다. 더 정확히는 fd 1을 파일로 바꾼 다음, fd 2를 fd 1과 같은 대상으로 재지정하는 순서라고 이해해야 한다.

예제 4. 순서가 바뀐 경우

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>

int main() {
    int filefd = open("out.log", O_WRONLY | O_CREAT | O_TRUNC, 0644);

    dup2(1, 2);       // stderr -> current stdout(터미널)
    dup2(filefd, 1);  // stdout -> file

    dprintf(1, "stdout to file\n");
    dprintf(2, "stderr still to terminal\n");
    close(filefd);
    return 0;
}

이 경우 stdout만 파일로 가고 stderr는 터미널에 남는다. 이유는 dup2(1, 2)가 먼저 실행될 당시, stdout은 아직 터미널을 보고 있었기 때문이다. 그 상태를 기준으로 stderr가 터미널 쪽에 연결되었고, 그 뒤에 stdout만 파일로 바뀌었다.

이 예제는 아래 쉘 문장을 그대로 설명한다.

command 2>&1 > out.log

그리고 이 예제가 중요한 이유는, 사람들이 가장 자주 틀리는 오해를 정면으로 깨기 때문이다. 리다이렉션 순서는 문법상의 장식이 아니라 실제 FD 상태 변화를 결정하는 실행 순서다.

9. 실무 적용

실무 적용 1. 로그를 파일 하나로 합쳐 남기기

배치 작업이나 백그라운드 작업에서는 stdout, stderr를 한 파일에 모아 두고 싶은 경우가 많다. 출력과 에러가 갈라져 있으면 문제 추적이 번거롭고, 시간 순서도 놓치기 쉽다. 이런 상황에서는 다음과 같이 쓴다.

./batch_job > batch.log 2>&1

이렇게 하면 정상 출력과 에러 출력이 한 파일에 함께 남는다. 중요한 것은 “합쳐진다”는 결과보다 왜 그렇게 되는가다. 먼저 stdoutbatch.log로 돌리고, 그 다음 stderr를 현재 stdout의 대상에 연결했기 때문에 둘 다 같은 파일로 들어간다.

실무 적용 2. 정상 결과는 파일, 오류는 화면

반대로 정상 결과는 파일로 저장하되, 오류는 즉시 화면에서 보고 싶은 경우도 많다. 특히 결과 파일을 다음 단계에서 쓰는 배치 파이프라인에서는 이 패턴이 흔하다.

./job > result.txt

이렇게 하면 stdout만 파일에 저장되고, stderr는 그대로 터미널에 남는다. 또는 오류도 따로 저장하고 싶다면 다음처럼 분리할 수 있다.

./job 2> error.log > result.txt

이 패턴의 장점은 데이터와 오류를 섞지 않는다는 점이다. stdout은 처리 결과, stderr는 실패 원인이라는 역할 분리를 유지할 수 있다. 괜히 모든 출력을 한데 몰아넣으면, 다음 단계에서 파싱해야 하는 결과 파일이 오염될 수 있다.

실무 적용 3. 파이프라인에서 오류를 따로 다루기

파이프라인에서는 stdout만 다음 명령으로 넘기고, stderr는 따로 보관하고 싶은 경우가 많다. 예를 들어 데이터 흐름은 유지하면서 오류만 별도 파일로 저장하려면 다음처럼 할 수 있다.

command 2> error.log | grep something

이렇게 하면 stdout은 파이프를 타고 grep으로 넘어가고, stderrerror.log에 저장된다. 여기서 중요한 포인트는 파이프가 기본적으로 stdout만 다음 프로세스로 넘긴다는 점이다. 따라서 오류가 데이터 파이프를 오염시키지 않게 하려면 이런 분리가 필요하다.

이 예제는 리다이렉션과 파이프가 따로 떨어진 별도 문법이 아니라는 점도 보여 준다. 둘 다 결국 FD가 어느 대상을 가리키도록 구성하느냐의 문제다.

실무 적용 4. 쉘 스크립트에서 FD 백업

조금 더 심화된 패턴으로, 원래의 stdout을 백업해 두었다가 나중에 다시 쓰는 방법이 있다. 예를 들면 다음과 같다.

exec 3>&1
exec > output.log
echo "this goes to log"
echo "this goes to original stdout" >&3
exec 3>&-

이 코드의 의미는 분명하다. 먼저 fd 3에 원래의 stdout을 백업한다. 그 다음 stdout 전체를 output.log로 바꾼다. 그러면 일반적인 echo는 파일로 간다. 하지만 >&3를 쓰면 원래의 stdout, 즉 처음 연결되어 있던 출력 대상으로 보낼 수 있다. 마지막의 exec 3>&-는 fd 3을 닫는 정리 작업이다.

이 패턴은 스크립트에서 출력 경로를 일시적으로 바꾸고 싶을 때 매우 유용하다. 예를 들어 대부분의 로그는 파일에 남기되, 특정 메시지만 콘솔에 보이고 싶을 때 쓴다. 이 예제를 이해하면 “FD는 고정된 0,1,2만 있는 것이 아니라, 필요하면 백업용 번호를 별도로 둘 수도 있다”는 감각도 함께 잡힌다.

10. 흔한 실수

실수 1. 2>&1을 “두 군데 동시 출력”으로 오해

많은 사람이 2>&1을 보면 “에러와 출력이 둘 다 화면과 파일로 동시에 갈 것 같다”고 생각한다. 이건 틀린 이해다. 2>&1은 분기 문법이 아니다. fd 재지정 문법이다. 즉, 두 스트림이 같은 목적지 하나로 모일 뿐이다.

두 군데로 동시에 보내고 싶다면 tee 같은 분기 도구가 필요하다. dup2는 분기 기능이 아니라 “특정 FD가 바라보는 곳을 바꾸는 기능”이다. 이 차이를 놓치면 리다이렉션이 계속 이상하게 느껴진다.

실수 2. 순서를 바꿔도 같다고 생각

command 2>&1 > out.log

command > out.log 2>&1

와 같다고 생각하는 경우가 매우 많다. 실제로는 다르다. 전자는 stderr가 파일이 아니라 터미널에 남을 수 있다. 이유는 이미 설명했듯, 2>&1그 시점의 fd 1 기준으로 동작하기 때문이다.

둘 다 파일에 넣고 싶다면 반드시 stdout을 먼저 파일로 바꾼 뒤, stderr를 그 대상에 맞춰야 한다. 즉 다음 순서가 맞다.

command > out.log 2>&1

실수 3. “복제”를 데이터 복사로 이해

dup라는 이름 때문에, 출력 문자열이 복제되어 다른 곳에도 동시에 흘러간다고 생각하는 경우가 있다. 이것도 틀렸다. dup가 복제하는 것은 데이터가 아니라 FD 엔트리의 연결 관계다. 따라서 dup를 이해할 때는 “같은 열린 대상을 보는 새 번호를 만든다”로 생각해야 한다.

이 차이를 이해하지 못하면 duptee, dup2와 리다이렉션의 차이도 흐려진다. tee는 실제 데이터 분기에 가깝고, dup는 연결 구조 복제에 가깝다. 완전히 다른 문제다.

실수 4. 파일 디스크립터는 파일만 가리킨다고 생각

이름만 보면 그렇게 오해하기 쉽다. 하지만 FD는 일반 파일만 가리키지 않는다. 터미널, 파이프, 소켓, 장치 파일도 모두 FD로 다룬다. 즉 FD는 “파일 전용 식별자”가 아니라, 프로세스의 I/O 핸들 번호다.

이 감각이 없으면 파이프와 리다이렉션을 별개의 세계로 오해하게 된다. 실제로는 둘 다 FD가 다른 대상에 연결되는 구조일 뿐이다.

실수 5. 2>&1 뒤에 stdout 변경도 자동 추적된다고 생각

이 오해도 아주 흔하다. 한 번 2>&1을 해 두면 stderr가 앞으로도 계속 stdout의 변화를 따라갈 것처럼 느끼는 것이다. 하지만 그렇지 않다. 2>&1은 동적 링크가 아니라 실행 시점 재지정이다. 그 순간 연결된 대상을 공유할 뿐, 이후 변경을 자동 추적하지 않는다.

그래서 순서가 중요하다. 각 리다이렉션은 왼쪽에서 오른쪽으로 차례대로 적용되며, 각 단계는 그 이전까지의 FD 상태를 기준으로 동작한다. 이 구조를 이해하면 헷갈릴 일이 줄어든다.

실수 6. 파이프와 dup2를 완전히 별개라고 생각

파이프는 전혀 다른 세계고, dup2는 시스템 프로그래밍 내부 이야기라고 생각하는 경우가 많다. 하지만 구조적으로 보면 파이프도 결국 FD 연결 재구성의 연장선이다. 한쪽 프로세스의 stdout을 파이프의 write end에 붙이고, 다른 쪽 프로세스의 stdin을 pipe의 read end에 붙이는 식으로 구현된다.

즉 파이프는 개념적으로 “앞의 출력이 뒤의 입력으로 간다”지만, 시스템 수준으로 내려오면 결국 “각 프로세스의 fd 1, fd 0이 특정 파이프 끝을 보게 한다”는 이야기다. 따라서 dup2 관점을 잡으면 파이프도 더 쉽게 보인다.

11. 관련 개념

file descriptor는 프로세스가 사용하는 입출력 핸들 번호다. 0, 1, 2가 대표적이지만 필요하면 더 많은 FD를 열 수 있다. 중요한 것은 이 번호들이 단순 숫자가 아니라 어떤 열린 I/O 대상을 참조하는 엔트리라는 점이다.

redirection은 FD가 향하는 대상을 바꾸는 쉘 기능이다. >, 2>, <, 2>&1 같은 문법은 결국 파일 디스크립터 수준에서 입출력 연결을 조정하는 표현이다.

pipe는 한 프로세스의 stdout을 다른 프로세스의 stdin으로 연결하는 구조다. 겉으로는 | 한 글자지만, 내부적으로는 파이프용 FD를 만들고 각 프로세스의 표준 입출력을 재배치하는 문제다.

fork / exec는 이런 FD 변경이 실제 프로그램 실행 직전 언제 적용되는지를 설명하는 다음 단계 개념이다. 리다이렉션이 부모 쉘에서 어떻게 준비되고, 실행될 프로그램에 어떤 상태로 전달되는지를 이해하려면 결국 이 개념으로 넘어가야 한다.

12. 더 깊이 보기

12-1. 왜 dup2가 리다이렉션 구현에 적합한가

쉘은 “새 FD 하나 얻기”가 목적이 아니다. stdout은 fd 1, stderr는 fd 2라는 고정된 번호를 직접 다뤄야 한다. 따라서 임의의 새 번호를 반환하는 dup보다, 원하는 번호를 정확히 덮어쓸 수 있는 dup2가 훨씬 적합하다.

예를 들어 리다이렉션이 필요한 프로그램을 실행하기 직전에 쉘은 “이 프로그램의 fd 1은 파일을 보게 하고, fd 2는 원래 터미널을 보게 한다” 같은 상태를 만들어 줘야 한다. 이 작업은 새 번호 하나를 만드는 걸로는 해결되지 않는다. 고정된 의미를 가진 번호 자체를 바꿔야 한다. 그래서 dup2가 핵심이 된다.

12-2. 왜 순서 의존성이 생기는가

쉘은 리다이렉션 목록을 순서대로 해석하고, 그때그때 FD 상태를 바꾼다. 따라서 앞 단계에서 바뀐 결과가 뒤 단계의 기준이 된다. 이 구조 때문에 문법은 짧아 보여도 의미는 정적이지 않다. 상태 기반으로 변한다.

command > out.log 2>&1는 “파일로 보내고 합친다”라는 덩어리 문장이 아니다. 실제로는 “먼저 fd 1을 파일로 바꿈” 다음 “이제 fd 2를 현재 fd 1 대상에 맞춤”이라는 두 단계다. 이걸 하나의 묶음으로만 보면 순서의 의미가 사라진다.

12-3. 파이프도 결국 FD 연결 문제다

파이프를 추상적으로 보면 “한 프로세스의 출력이 다른 프로세스의 입력으로 간다”는 개념이다. 하지만 시스템 수준으로 내려오면 훨씬 구체적이다. 파이프는 read end와 write end라는 두 개의 FD를 만들고, 한쪽 프로세스의 fd 1을 write end에 연결하고, 다른 쪽 프로세스의 fd 0을 read end에 연결하는 식으로 동작한다.

즉, 파이프도 리다이렉션도 결국 “어떤 FD가 어떤 열린 대상을 가리키는가”의 문제다. 이 관점이 생기면 2>&1, >, |, FD 백업, 심지어 소켓 리다이렉션까지 하나의 큰 I/O 구조 안에서 보이기 시작한다.

13. 정리

dup, dup2는 파일 디스크립터를 복제하거나 재지정하는 시스템콜이다. 하지만 이 글의 핵심은 시스템콜 API 요약이 아니다. 핵심은 쉘 리다이렉션의 내부 정체를 FD 관점으로 이해하는 것이다.

2>&1의 핵심은 “에러도 같이 보내기”가 아니다. 더 정확히는 fd 2를 fd 1의 현재 대상에 연결하는 것이다. 따라서 2>&1은 분기 문법이 아니라 fd 재지정 문법이다. 그리고 이 때문에 리다이렉션은 항상 순서가 중요하다.

결국 리다이렉션을 제대로 이해하려면 출력 문자열이 아니라 각 FD가 현재 무엇을 가리키는가를 봐야 한다. 이 구조를 이해하면 2>&1, 리다이렉션, 파이프가 따로 떨어진 암기용 문법이 아니라, 하나의 I/O 구조 위에 놓인 동작으로 보이기 시작한다.