1. 정의 / 결론
리눅스에서 파일 디스크립터(File Descriptor, fd)는 프로세스가 입출력 대상을 가리키기 위해 사용하는 정수 번호다. 모든 프로세스는 기본적으로 0, 1, 2번 파일 디스크립터를 가지며, 각각은 stdin(표준 입력), stdout(표준 출력), stderr(표준 에러 출력)를 의미한다.
이 번호는 단순한 숫자가 아니라, 실제로는 프로세스가 어떤 입출력 대상과 연결되어 있는지를 나타내는 “핸들” 역할을 한다.
dup, dup2는 이 파일 디스크립터를 복제하거나 특정 번호에 다시 연결하는 시스템 호출이다.
이때 중요한 점은 파일의 내용을 복사하는 것이 아니라, “같은 입출력 대상”을 가리키도록 연결을 재구성한다는 것이다.
이 구조 위에서 리눅스의 리다이렉션이 동작한다.
즉, 우리가 쉘에서 사용하는 >, 2>, 2>&1, | 같은 문법은 새로운 기능이 아니라, 내부적으로는 파일 디스크립터가 가리키는 대상을 바꾸는 작업일 뿐이다.
결론적으로 이 글의 핵심은 다음 한 줄로 정리된다.
리다이렉션은 출력 내용을 바꾸는 기능이 아니라, 파일 디스크립터가 가리키는 “경로”를 변경하는 구조다.
이 관점을 이해하면 2>&1 같은 문법이 단순 암기가 아니라 구조적으로 이해되며,
리눅스의 I/O와 프로세스 실행 흐름이 하나로 연결된다.
2. 핵심 요약
리눅스 프로세스는 실행될 때 기본적으로 **세 개의 파일 디스크립터(0, 1, 2)**를 가진다.
이들은 각각 입력, 출력, 에러 출력을 담당하는 독립된 채널이다.
쉘은 명령어를 실행하기 전에 이 파일 디스크립터들이 가리키는 대상을 조정한다.
이때 사용하는 핵심 메커니즘이 바로 dup, dup2를 통한 파일 디스크립터 복제와 재지정이다.
dup2(oldfd, newfd)는 newfd가 oldfd와 동일한 입출력 대상을 가리키도록 만든다.
이 동작은 “데이터 복사”가 아니라 **“참조 대상 변경”**이다.
이 개념을 그대로 적용한 것이 2>&1이다.
이 문법은 stderr(2번 디스크립터)를 stdout(1번 디스크립터)가 현재 가리키고 있는 대상과 동일하게 연결하라는 의미다.
여기서 중요한 점은 “현재”라는 시점이다.
파일 디스크립터는 고정된 대상이 아니라, 순차적으로 변경되는 연결 상태를 가지기 때문에 리다이렉션은 왼쪽부터 순서대로 적용된다.
따라서 아래 두 명령은 동일하지 않다.
command > file.txt 2>&1
command 2>&1 > file.txt
이 차이는 문법의 차이가 아니라,
파일 디스크립터가 복제되는 시점이 다르기 때문에 발생하는 구조적 차이다.
핵심을 다시 정리하면 다음과 같다.
- 프로세스는 fd 0, 1, 2를 가진다
- 쉘은 실행 전에 fd 연결을 변경한다
dup2는 fd가 가리키는 대상을 다른 번호에 연결한다2>&1은 stderr를 stdout의 현재 대상에 연결한다- 리다이렉션 순서는 fd 복제 시점 때문에 반드시 중요하다
이 구조를 이해하면 리눅스의 리다이렉션, 파이프, 로그 처리 방식이 모두 하나의 원리로 설명된다.
3. 왜 필요한가
리눅스에서 출력은 단순히 “화면에 보이는 텍스트”가 아니다.
사용자는 stdout과 stderr를 같은 터미널에서 보기 때문에 하나의 흐름처럼 느끼지만, 실제로는 완전히 분리된 두 개의 채널이다.
이 차이를 이해하지 못하면 리다이렉션을 사용할 때 예상과 다른 결과를 자주 마주하게 된다.
예를 들어 어떤 명령어를 실행했을 때 결과를 파일로 저장한다고 가정한다.
command > result.txt
이 명령은 정상 출력(stdout)만 파일로 보내고, 에러(stderr)는 그대로 터미널에 남긴다.
사용자는 “모든 출력이 저장되었다”고 생각할 수 있지만, 실제로는 출력 채널 중 일부만 이동한 상태다.
이 문제는 출력이 “하나”라고 생각하기 때문에 발생한다.
하지만 리눅스는 애초에 이 문제를 해결하기 위해 출력 채널을 분리했다.
- stdout: 정상 데이터
- stderr: 오류 메시지
이 구조 덕분에 다음과 같은 선택이 가능해진다.
- 정상 결과만 파일로 저장
- 에러만 별도 로그로 분리
- 둘을 하나로 합쳐서 기록
여기까지는 많은 글에서 설명한다.
하지만 여기서 한 단계 더 들어가면 문제가 생긴다.
많은 설명이 2>&1을 단순히 “둘을 하나로 합친다”라고 설명한다.
이 설명은 결과만 맞고, 왜 그렇게 되는지는 설명하지 못한다.
그래서 다음과 같은 상황에서 혼란이 발생한다.
command > file.txt 2>&1 # 정상 + 에러 → 파일
command 2>&1 > file.txt # 에러 → 화면, 정상 → 파일
이 두 명령은 표면적으로 비슷해 보이지만 결과는 완전히 다르다.
이 차이는 단순 문법이 아니라 파일 디스크립터가 언제, 무엇을 기준으로 복제되는지에 의해 결정된다.
즉, 기존 설명 방식의 한계는 다음과 같다.
- 출력 채널 분리는 설명하지만
- fd 복제 구조는 설명하지 않는다
- 그래서 순서 차이를 설명할 수 없다
이 문제를 해결하려면 관점을 바꿔야 한다.
리다이렉션은 “문자열 문법”이 아니라
“파일 디스크립터 연결을 재배치하는 구조”로 이해해야 한다
프로세스는 실제로 다음과 같은 형태의 연결표를 가진다.
0 -> 입력 대상
1 -> 출력 대상
2 -> 에러 대상
쉘은 명령을 실행하기 전에 이 연결표를 수정한다.
그리고 그 수정에 사용되는 핵심 도구가 바로 dup2다.
dup2(oldfd, newfd)
→ newfd가 oldfd와 같은 대상을 가리키도록 만든다
이 한 줄이 리다이렉션의 본질이다.
리다이렉션은 새로운 기능이 아니라,
이미 존재하는 입출력 구조(fd)를 다른 대상에 연결하는 작업이다.
이 구조를 이해하면 다음이 자연스럽게 연결된다.
- 왜
2>&1이 “복사”가 아니라 “참조”인지 - 왜 리다이렉션 순서가 중요한지
- 왜 파이프와 리다이렉션이 같은 구조인지
- 왜 컨테이너 로그가 stdout 기반인지
결국 이 글이 필요한 이유는 하나다.
리눅스의 I/O와 프로세스 실행을 하나의 구조로 이해하기 위해서다
4. 파일 디스크립터(fd)란 무엇인가
파일 디스크립터(File Descriptor, fd)는 프로세스가 입출력 대상을 식별하기 위해 사용하는 정수 번호다.
이 번호는 단순한 식별자가 아니라, 실제로는 프로세스 내부의 “파일 디스크립터 테이블”에서 특정 입출력 대상과 연결된 슬롯을 가리키는 역할을 한다.
리눅스에서 프로세스가 시작되면 기본적으로 다음과 같은 파일 디스크립터를 가진다.
0 -> stdin
1 -> stdout
2 -> stderr
이 세 개의 번호는 특별한 의미를 가지며, 모든 프로그램은 별도의 설정이 없으면 이 기본 구조를 그대로 사용한다.
여기서 중요한 점은 “파일 디스크립터가 파일만 가리키는 것이 아니다”라는 것이다.
리눅스에서 “모든 것은 파일이다”라는 철학은 실제로 이 구조에서 구현된다.
파일 디스크립터가 가리킬 수 있는 대상은 다음과 같다.
- 터미널 (사용자 입력/출력)
- 일반 파일
- 파이프
- 네트워크 소켓
- 디바이스 파일 (/dev/null 등)
즉, 파일 디스크립터는 특정 종류의 객체를 가리키는 것이 아니라,
“입출력 가능한 대상”을 추상적으로 가리키는 공통 인터페이스다.
이 구조 덕분에 프로그램은 “어디에 출력하는지”를 신경 쓰지 않아도 된다.
프로그램은 단순히 stdout(fd 1)에 데이터를 쓸 뿐이고,
그 대상이 터미널이든 파일이든 파이프든 관계없이 동일한 방식으로 동작한다.
예를 들어 C 언어에서 다음 코드는:
printf("hello\n");
결국 내부적으로는 stdout(fd 1)에 데이터를 쓰는 동작이다.
이때 stdout이 터미널을 가리키면 화면에 출력되고, 파일을 가리키면 파일에 기록된다.
또한 stderr는 별도의 파일 디스크립터(2번)를 사용한다.
fprintf(stderr, "error\n");
이 코드는 stderr(fd 2)에 데이터를 쓰는 것이며, stdout과는 완전히 독립된 채널이다.
여기서 핵심은 다음이다.
파일 디스크립터는 “출력 대상 자체”가 아니라 “출력 대상을 가리키는 번호”다
그리고 이 번호가 가리키는 대상은 실행 중 언제든지 변경될 수 있다
이 “변경 가능성”이 바로 리다이렉션과 dup2의 핵심이 된다.
5. dup와 dup2는 무엇을 하는가
파일 디스크립터를 이해했다면, 이제 dup와 dup2가 무엇을 하는지 정확히 볼 수 있다.
이 두 시스템 호출은 파일 디스크립터를 복제하거나 재지정하는 역할을 한다.
여기서 중요한 점은 “복제”라는 표현이다.
하지만 이 복제는 파일 내용을 복사하는 것이 아니라,
같은 입출력 대상을 가리키는 새로운 파일 디스크립터를 만드는 것이다.
5-1. dup의 의미
dup는 기존 파일 디스크립터를 복제하여 사용 가능한 가장 작은 번호의 새로운 파일 디스크립터를 반환한다.
int newfd = dup(1);
이 코드는 stdout(fd 1)을 복제하여 새로운 파일 디스크립터를 만든다.
이때 중요한 점은 다음이다.
newfd는 stdout과 동일한 입출력 대상을 가리킨다- 파일 내용을 복사하지 않는다
- 같은 대상에 대한 또 다른 “참조”를 만든다
즉, 다음과 같은 상태가 된다.
1 -> terminal
newfd -> terminal
이 둘은 서로 다른 번호지만, 동일한 대상에 연결되어 있다.
따라서 어느 쪽으로 출력하든 결과는 동일하게 나타난다.
dup는 “같은 대상을 가리키는 새로운 번호를 하나 더 만드는 것”이다
5-2. dup2의 의미
dup2는 dup와 유사하지만, 중요한 차이가 있다.
새로운 파일 디스크립터 번호를 직접 지정할 수 있다는 점이다.
dup2(fd, 1);
이 코드는 fd가 가리키는 대상을 stdout(fd 1)에 연결한다.
동작을 정확히 풀어보면 다음과 같다.
- 기존 stdout(fd 1)이 가리키던 대상은 끊어진다
- fd가 가리키던 대상이 stdout(fd 1)에 연결된다
즉 상태는 다음처럼 바뀐다.
fd -> file
1 -> file
이제 프로그램이 stdout으로 출력하는 모든 데이터는 파일로 기록된다.
dup2는 “특정 번호의 fd를 다른 fd와 동일한 대상으로 덮어쓴다”
이 점이 리다이렉션에서 결정적으로 중요하다.
왜냐하면 stdout은 항상 1번, stderr는 항상 2번이기 때문에,
정확히 그 번호를 바꿔야 출력 경로가 바뀌기 때문이다
5-3. dup와 dup2 차이
두 함수의 차이를 명확히 정리하면 다음과 같다.
dup(1)
→ 가장 작은 빈 fd 번호를 찾아 복제
dup2(fd, 1)
→ fd의 대상을 정확히 1번 fd에 연결
이 차이는 단순한 편의성 차이가 아니라, 사용 목적의 차이다.
dup는 “복제” 자체가 목적일 때 사용된다dup2는 “특정 fd 번호를 변경”할 때 사용된다
리다이렉션에서는 stdout(1), stderr(2)처럼 번호가 고정되어 있기 때문에,
실제로는 dup2가 핵심 역할을 한다
6. 리다이렉션은 내부적으로 어떻게 동작하는가
이제 파일 디스크립터와 dup2를 이해했으므로,
리다이렉션이 실제로 어떻게 동작하는지 정확히 볼 수 있다.
쉘에서 다음 명령을 실행한다고 가정한다.
echo hello > out.txt
이 명령은 단순히 “출력을 파일로 보낸다”가 아니다.
실제로는 다음과 같은 단계로 동작한다.
1단계: 파일 열기
쉘은 먼저 out.txt를 연다.
open("out.txt") -> fd 3
이때 3이라는 번호는 단순 예시이며, 실제로는 사용 가능한 가장 작은 번호가 할당된다.
2단계: 파일 디스크립터 재연결
그 다음 쉘은 dup2를 호출한다.
dup2(3, 1)
이 동작은 다음 의미를 가진다.
stdout(1번 fd)이 이제 fd 3이 가리키는 대상(파일)을 가리키도록 변경된다
즉 상태는 이렇게 된다.
1 -> out.txt
3단계: 프로그램 실행
이제 쉘은 프로그램을 실행한다.
exec(echo)
이 시점에서 프로그램은 이미 다음 상태로 시작한다.
- stdout은 터미널이 아니라 파일을 가리킨다
- 프로그램은 이 사실을 전혀 모른다
프로그램은 단순히 stdout(fd 1)에 데이터를 쓸 뿐이다.
하지만 그 대상이 이미 파일로 바뀌어 있기 때문에 결과가 파일에 저장된다.
이 흐름을 정리하면 다음과 같다.
open("out.txt") -> fd 3
dup2(3, 1)
exec(program)
여기서 중요한 핵심은 다음이다.
리다이렉션은 프로그램 실행 “이후”가 아니라 “이전”에 적용된다
즉, 프로그램은 처음부터 변경된 fd 상태로 시작한다
이 구조는 모든 리다이렉션에서 동일하게 적용된다.
예를 들어 2>&1도 내부적으로는 dup2로 처리된다.
command > file.txt 2>&1
이 명령은 다음과 같은 순서로 동작한다.
open("file.txt") -> fd 3
dup2(3, 1) # stdout -> file
dup2(1, 2) # stderr -> stdout이 가리키는 대상
exec(command)
여기서 핵심은 두 번째 줄이다.
dup2(1, 2)는 “stderr를 stdout이 현재 가리키는 대상”에 연결한다
즉, 이미 stdout이 파일을 가리키고 있기 때문에
stderr도 같은 파일을 가리키게 된다.
이제 순서가 바뀌면 왜 결과가 달라지는지 이해할 수 있다.
command 2>&1 > file.txt
이 경우 내부 동작은 다음과 같다.
dup2(1, 2) # stderr -> stdout (현재는 terminal)
open("file.txt") -> fd 3
dup2(3, 1) # stdout -> file
exec(command)
결과적으로 상태는 다음과 같다.
1 -> file
2 -> terminal
stderr는 이미 터미널을 가리키도록 복제된 상태이기 때문에
stdout이 나중에 파일로 바뀌어도 영향을 받지 않는다
결론적으로 이 섹션의 핵심은 다음이다.
리다이렉션은 파일 디스크립터를 재연결하는 작업이다
그리고 그 핵심 동작은 dup2다
또한 이 모든 작업은 프로그램 실행 전에 이루어진다
7. fork / exec와 fd가 어떻게 연결되는가
리다이렉션과 파일 디스크립터를 제대로 이해하려면, 반드시 프로세스 실행 구조까지 연결해서 봐야 한다.
리눅스에서 프로그램 실행은 단순히 “명령어를 실행한다”가 아니라, 기존 프로세스 위에서 새로운 프로세스를 생성하고 그 안에서 프로그램을 실행하는 과정이다.
쉘은 이미 실행 중인 하나의 프로세스다.
이 쉘이 새로운 명령어를 실행할 때, 다음과 같은 순서를 따른다.
shell
└─ fork()
└─ child process
├─ fd 조정 (dup2, redirection)
└─ exec(program)
이 구조에서 가장 중요한 포인트는 두 가지다.
쉘은 직접 자신을 덮어쓰지 않는다
항상 자식 프로세스를 만들어 그 안에서 실행한다
7-1. 쉘은 직접 프로그램을 덮어쓰지 않는다
쉘은 이미 사용자와 상호작용하고 있는 실행 중인 프로세스다.
이 상태에서 어떤 명령어를 실행한다고 해서 쉘 자체를 다른 프로그램으로 바꿔버릴 수는 없다.
그래서 쉘은 fork()를 호출해 자기 자신을 복제한 자식 프로세스를 만든다.
부모: shell (계속 실행됨)
자식: 새 프로세스 (명령 실행용)
이 자식 프로세스는 부모와 거의 동일한 상태를 가지며,
여기에는 파일 디스크립터 테이블도 그대로 복사된다는 점이 중요하다.
즉, fork 직후 상태는 다음과 같다.
부모:
0 -> terminal
1 -> terminal
2 -> terminal
자식:
0 -> terminal
1 -> terminal
2 -> terminal
부모와 자식은 동일한 fd 구조를 가진 상태로 시작한다
7-2. 왜 이 순서가 중요한가
이제 자식 프로세스에서 리다이렉션이 적용된다.
쉘은 자식 프로세스에서 dup2 등을 사용해 파일 디스크립터를 재구성한다.
자식:
dup2(fd, 1) # stdout 변경
dup2(fd2, 2) # stderr 변경
이 작업이 끝난 뒤에 exec()가 호출된다.
exec(program)
exec()는 현재 프로세스를 완전히 새로운 프로그램으로 교체한다.
하지만 중요한 점은 다음이다.
파일 디스크립터 상태는 그대로 유지된다
즉, 프로그램은 실행되는 순간부터 이미 다음과 같은 상태를 가진다.
1 -> file
2 -> file
프로그램 입장에서는 자신이 파일로 출력하고 있는지, 터미널로 출력하고 있는지 알 수 없다.
그저 stdout(fd 1)에 데이터를 쓰고 있을 뿐이다.
이 구조가 의미하는 바는 매우 중요하다.
리다이렉션은 프로그램 내부 기능이 아니다
쉘이 실행 전에 환경을 준비해주는 구조다
이 때문에 동일한 프로그램이라도 다음과 같이 완전히 다른 동작을 할 수 있다.
./app # 터미널 출력
./app > log.txt # 파일 출력
프로그램은 전혀 수정되지 않았지만,
파일 디스크립터 연결만 바뀌었기 때문에 출력 대상이 달라진다
7-3. 전체 흐름 정리
이제 전체 실행 흐름을 하나로 정리하면 다음과 같다.
shell
└─ fork()
└─ child
├─ open("file") → fd 확보
├─ dup2(fd, 1) → stdout 변경
├─ dup2(fd2, 2) → stderr 변경
└─ exec(program)
이 구조를 이해하면 다음이 자연스럽게 연결된다.
- 리다이렉션이 왜 프로그램이 아니라 쉘 기능인지
- 파이프가 왜 프로세스 간 연결 구조인지
- stdout/stderr가 왜 실행 전에 결정되는지
결론적으로 이 섹션의 핵심은 다음이다.
프로세스 실행(fork/exec)과 파일 디스크립터(fd)는 분리된 개념이 아니라 하나의 흐름이다
8. 예제
이 섹션에서는 실제 명령어를 통해 파일 디스크립터와 dup2 개념이 어떻게 적용되는지 확인한다.
각 예제는 단순 결과 설명이 아니라 왜 그렇게 되는지를 fd 관점에서 분석한다.
예제 1: stdout을 파일로 보내기
echo hello > out.txt
→ 결과: out.txt 파일에 hello가 저장된다.
→ 원인 설명:
쉘은 out.txt를 열어 파일 디스크립터를 얻고, dup2(fd, 1)을 호출하여 stdout(1번 fd)을 파일로 연결한다.
이 상태에서 echo가 실행되며, echo는 단순히 stdout에 데이터를 출력할 뿐이다.
하지만 stdout이 이미 파일을 가리키고 있기 때문에 결과가 파일에 기록된다.
→ 실무 의미:
명령어 결과를 파일로 저장할 때 가장 기본적으로 사용되는 패턴이다.
프로그램 수정 없이 출력 대상을 변경할 수 있다는 점이 핵심이다.
예제 2: stdout과 stderr를 함께 파일로 보내기
command > out.txt 2>&1
→ 결과: 정상 출력과 에러 출력이 모두 out.txt에 저장된다.
→ 원인 설명:
먼저 > out.txt가 실행되어 stdout(1번 fd)이 파일을 가리키게 된다.
그 다음 2>&1이 실행되어 stderr(2번 fd)가 stdout이 현재 가리키는 대상과 동일하게 연결된다.
이 시점에서 stdout과 stderr는 모두 같은 파일을 가리키게 된다.
→ 실무 의미:
전체 실행 로그를 하나의 파일로 남길 때 사용된다.
특히 배치 작업이나 배포 스크립트에서 자주 사용된다.
예제 3: 리다이렉션 순서 차이
command 2>&1 > out.txt
→ 결과: stderr는 터미널에 출력되고, stdout만 out.txt에 저장된다.
→ 원인 설명:
먼저 2>&1이 실행되는데, 이 시점에서 stdout은 아직 터미널을 가리키고 있다.
따라서 stderr는 터미널을 가리키도록 복제된다.
그 다음 > out.txt가 실행되어 stdout만 파일로 변경된다.
결과적으로 stdout은 파일, stderr는 터미널을 가리키는 상태가 된다.
→ 실무 의미:
리다이렉션 순서가 왜 중요한지를 보여주는 대표적인 예다.
로그가 예상대로 기록되지 않는 문제를 디버깅할 때 반드시 이해해야 한다.
예제 4: 파이프를 통한 프로세스 연결
grep error app.log | sort
→ 결과: grep의 출력이 sort의 입력으로 전달된다.
→ 원인 설명:
쉘은 파이프를 생성하고, grep의 stdout(fd 1)을 파이프의 쓰기 끝에 연결한다.
동시에 sort의 stdin(fd 0)을 파이프의 읽기 끝에 연결한다.
즉, 파일 대신 파이프라는 특수 객체를 파일 디스크립터가 가리키게 된다.
→ 실무 의미:
여러 명령어를 연결해 데이터를 처리하는 리눅스 파이프라인의 기본 구조다.
리다이렉션과 동일하게 fd 연결 변경으로 동작한다.
예제 5: stderr만 파일로 저장
ls not_exist 2> error.log
→ 결과: 에러 메시지만 error.log에 저장된다.
→ 원인 설명:
쉘은 stderr(fd 2)만 파일로 연결하고, stdout(fd 1)은 그대로 유지한다.
따라서 정상 출력은 터미널로, 에러 출력만 파일로 기록된다.
→ 실무 의미:
에러 로그를 별도로 관리할 때 사용된다.
특히 장애 분석 시 매우 유용하다.
9. 실무 적용
이제 파일 디스크립터와 dup2 개념을 실제 운영 환경에서 어떻게 활용하는지 본다.
각 시나리오는 단순 사용법이 아니라 구조 기반 적용으로 이해해야 한다.
9-1. 배치 작업 로그 분리
상황:
배치 스크립트가 실행되며, 정상 처리 결과와 에러 로그를 동시에 생성한다.
문제:
stdout과 stderr가 섞이면 어떤 메시지가 실패인지 구분하기 어렵다.
이로 인해 장애 분석 시간이 증가한다.
적용 방법:
stdout과 stderr를 각각 다른 파일로 분리한다.
batch.sh > success.log 2> error.log
효과:
정상 처리 결과와 오류 로그가 분리되어 관리된다.
문제 발생 시 에러 로그만 빠르게 확인할 수 있다.
9-2. 전체 실행 로그 통합
상황:
배포 스크립트나 서비스 실행 로그를 하나의 파일로 남겨야 한다.
문제:
stdout과 stderr가 분리되어 있으면 로그 수집과 관리가 번거롭다.
적용 방법:
stderr를 stdout에 연결한 뒤 하나의 파일로 저장한다.
deploy.sh > deploy.log 2>&1
효과:
모든 출력이 하나의 파일에 기록되어 관리가 단순해진다.
로그 수집 시스템과 연동하기도 쉬워진다.
9-3. 파이프라인 기반 데이터 처리
상황:
로그 파일에서 특정 패턴을 추출하고, 정렬하고, 중복을 제거해야 한다.
문제:
파일을 여러 번 생성하면 처리 과정이 복잡해진다.
적용 방법:
stdout과 stdin을 파이프로 연결하여 연속 처리한다.
grep error app.log | sort | uniq
효과:
중간 파일 없이 데이터를 흐름 형태로 처리할 수 있다.
리눅스의 “작은 도구 조합” 철학이 그대로 적용된다.
9-4. 컨테이너 로그 설계
상황:
Docker나 Kubernetes 환경에서 애플리케이션 로그를 관리해야 한다.
문제:
파일 기반 로그는 컨테이너 내부에 종속되며, 수집과 관리가 어렵다.
적용 방법:
애플리케이션 로그를 stdout으로 출력하도록 설계한다.
효과:
컨테이너 플랫폼이 stdout을 자동으로 수집하고 중앙 로그 시스템으로 전달한다.
파일 디스크립터 기반 구조 덕분에 별도의 로깅 시스템 없이도 로그 수집이 가능하다.
이 섹션의 핵심은 다음이다.
파일 디스크립터 구조를 이해하면 로그, 파이프라인, 컨테이너까지 하나의 원리로 설명된다
10.더 깊이 보기 — dup2가 “구조를 바꾼다”는 의미
dup2를 단순히 “파일 디스크립터를 복사하는 함수”로 이해하면 절반만 이해한 것이다. 실제로 dup2는 프로세스 내부의 I/O 구조 자체를 재배선(rewire)하는 연산이다.
핵심은 다음 한 줄이다.
dup2(oldfd, newfd)는 newfd가 가리키던 대상 자체를 oldfd의 대상으로 교체한다
이때 중요한 점은 “값 복사”가 아니라 참조 대상 교체라는 것이다.
일반적인 변수 복사와 다르게, 파일 디스크립터는 단순 정수가 아니라 커널 내부의 open file description을 가리키는 핸들이다. dup2는 이 핸들이 가리키는 대상을 바꿔버린다.
즉 다음과 같은 일이 발생한다.
기존 상태:
- fd 1 → 터미널
- fd 2 → 터미널
dup2(fd, 1) 이후:
- fd 1 → 파일
- fd 2 → 터미널
여기서 중요한 것은 stdout 자체가 “파일로 바뀐 것”이 아니라, fd 1이 가리키는 대상이 바뀐 것이라는 점이다.
이 구조를 이해하지 못하면 다음과 같은 오해가 생긴다.
- “stdout이 파일로 복사됐다”
- “출력 결과만 파일로 간다”
실제로는 출력 결과가 아니라 출력 경로 자체가 변경된 것이다.
이 개념은 pipe에서도 동일하게 적용된다.
int pipefd[2];
pipe(pipefd);
dup2(pipefd[1], 1); // stdout → pipe write end
이 코드 이후 printf는 더 이상 터미널로 가지 않는다. 프로세스는 자신이 pipe에 쓰고 있다는 사실조차 모른다.
이게 리눅스 I/O 설계의 핵심이다.
프로그램은 “어디로 쓰는지”를 모른다
OS가 그 경로를 연결한다
11.리눅스 I/O 구조 전체 연결 — fork, exec, dup2, pipe
이제 이 글의 모든 개념을 하나로 묶어야 한다.
리눅스에서 명령 실행은 다음 흐름으로 동작한다.
- 쉘이 fork로 프로세스를 복제한다
- 자식 프로세스에서 dup2로 I/O를 재배선한다
- exec로 프로그램을 덮어쓴다
이 흐름을 코드로 보면 다음과 같다.
pid_t pid = fork();
if (pid == 0) {
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1); // stdout → file
close(fd);
execlp("ls", "ls", NULL);
}
이 코드의 실행 결과는 다음과 같다.
- ls 프로그램은 stdout으로 출력한다
- 하지만 stdout은 이미 파일로 연결되어 있다
- 따라서 결과는 터미널이 아니라 파일로 기록된다
여기서 핵심은 다음이다.
ls는 아무것도 모른다
쉘이 실행 전에 구조를 바꿔놓는다
pipe도 동일한 구조로 확장된다.
int pipefd[2];
pipe(pipefd);
pid_t pid = fork();
if (pid == 0) {
dup2(pipefd[1], 1); // stdout → pipe
close(pipefd[0]);
close(pipefd[1]);
execlp("ls", "ls", NULL);
}
이 경우 ls의 출력은 pipe로 들어간다.
그리고 다른 프로세스가 그 pipe를 읽는다.
이게 바로 ls | grep 구조다
정리하면 다음과 같다.
- fork → 실행 컨텍스트 분리
- dup2 → I/O 경로 설정
- exec → 프로그램 실행
이 세 가지가 합쳐져서 리눅스의 실행 모델이 완성된다.
12.정리 — fd와 dup2를 이해하면 쉘이 보인다
이 글의 핵심은 하나로 정리된다.
리눅스에서 실행은 “프로그램 실행”이 아니라 “구조 설정 + 실행”이다
fd는 그 구조의 최소 단위다.
dup2는 그 구조를 바꾸는 도구다.
이 둘을 이해하면 다음이 자연스럽게 연결된다.
>리다이렉션2>&1|pipenohupdaemon
이 모든 것이 결국 같은 구조 위에서 동작한다.
fd 연결 구조를 어떻게 바꾸느냐의 문제다
그리고 이 구조를 이해하면 다음이 보인다.
- 왜 stderr는 따로 존재하는가
- 왜 stdout을 합칠 수 있는가
- 왜 파이프가 자연스럽게 동작하는가
- 왜 프로그램은 출력 대상에 대해 몰라도 되는가
결론은 단순하다.
프로그램은 데이터만 쓴다
OS는 그 데이터의 흐름을 설계한다
이 관점을 이해하면, 리눅스는 명령어 모음이 아니라 데이터 흐름을 조합하는 시스템으로 보이기 시작한다.
13.실제 리다이렉션 내부 구현 흐름 — 쉘은 무엇을 어떻게 바꾸는가
리눅스에서 >나 2>&1 같은 리다이렉션 문법은 단순한 문자열 파싱 기능이 아니다. 이 문법은 결국 쉘이 파일 디스크립터를 어떻게 조작할지를 결정하는 선언문이다. 즉, 우리가 터미널에 입력하는 한 줄의 명령은 그대로 실행되는 것이 아니라, 쉘 내부에서 파싱 → 구조 변환 → 시스템 콜 실행이라는 단계를 거친다.
예를 들어 다음 명령을 보자.
ls > out.txt 2>&1
이 명령은 사람이 보기에는 “stdout과 stderr를 파일로 보낸다”는 의미지만, 실제 쉘 내부에서는 훨씬 더 구체적인 순서로 처리된다.
첫 번째 단계는 리다이렉션 대상 해석이다. 쉘은 >를 보고 “stdout(fd 1)을 파일로 연결해야 한다”는 사실을 인식한다. 동시에 2>&1을 보고 “stderr(fd 2)를 stdout이 가리키는 대상과 동일하게 연결해야 한다”는 의미를 파악한다.
이때 중요한 점은 순서다. 쉘은 일반적으로 왼쪽에서 오른쪽으로 리다이렉션을 처리한다. 따라서 위 명령은 다음과 같은 순서로 실행된다.
- out.txt 파일을 open 한다
- dup2로 fd 1을 파일로 연결한다
- dup2로 fd 2를 fd 1과 동일하게 연결한다
이를 실제 시스템 콜 수준으로 풀어보면 다음과 같은 코드와 거의 동일하다.
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1); // stdout → file
dup2(1, 2); // stderr → stdout
close(fd);
이 코드의 결과는 다음과 같다.
- fd 1은 파일을 가리킨다
- fd 2는 fd 1을 따라간다
- 따라서 stdout과 stderr 모두 파일로 기록된다
여기서 중요한 포인트는 다음이다.
2>&1은 “파일로 보내라”가 아니라 “fd 1을 따라가라”는 의미다
즉, stderr는 직접 파일로 연결된 것이 아니라, stdout이 가리키는 대상에 의존적으로 연결된 것이다.
이 차이는 다음 명령에서 명확하게 드러난다.
ls 2>&1 > out.txt
겉으로 보면 동일해 보이지만, 실제 동작은 완전히 다르다.
이 경우 쉘은 다음 순서로 처리한다.
- fd 2를 fd 1과 동일하게 만든다
- fd 1을 파일로 연결한다
즉 내부적으로는 다음과 같다.
dup2(1, 2); // stderr → stdout (현재는 터미널)
int fd = open("out.txt", ...);
dup2(fd, 1); // stdout → file
close(fd);
이 결과는 다음과 같이 된다.
- stdout은 파일로 간다
- stderr는 여전히 터미널로 간다
왜냐하면 stderr는 이미 “기존 stdout(터미널)”을 복사해 둔 상태이기 때문이다.
dup2는 “현재 상태”를 복사한다. 이후 변경을 따라가지 않는다.
이게 리다이렉션에서 순서가 중요한 이유다.
이 구조를 이해하지 못하면 다음과 같은 오해가 생긴다.
- “2>&1은 항상 stderr를 파일로 보낸다”
- “순서는 중요하지 않다”
실제로는 순서에 따라 결과가 완전히 달라진다.
이제 pipe까지 포함된 경우를 보자.
ls | grep txt > out.txt
이 명령은 다음과 같은 구조로 실행된다.
- 쉘이 pipe를 생성한다
- fork로 두 개의 프로세스를 만든다
- 왼쪽 프로세스는 stdout을 pipe write end로 연결한다
- 오른쪽 프로세스는 stdin을 pipe read end로 연결한다
- 오른쪽 프로세스는 stdout을 파일로 연결한다
이를 코드로 풀면 다음과 같다.
int pipefd[2];
pipe(pipefd);
if (fork() == 0) {
dup2(pipefd[1], 1); // ls stdout → pipe
close(pipefd[0]);
close(pipefd[1]);
execlp("ls", "ls", NULL);
}
if (fork() == 0) {
dup2(pipefd[0], 0); // grep stdin ← pipe
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1); // grep stdout → file
close(fd);
close(pipefd[0]);
close(pipefd[1]);
execlp("grep", "grep", "txt", NULL);
}
이 구조에서 중요한 점은 다음이다.
- ls는 pipe에 쓴다
- grep은 pipe에서 읽는다
- grep의 출력만 파일로 간다
각 프로세스마다 fd 구조는 완전히 독립적으로 재구성된다
이게 쉘이 하는 일이다.
정리하면 다음과 같다.
- 쉘은 명령을 그대로 실행하지 않는다
- 먼저 fd 구조를 설계한다
- dup2로 I/O 경로를 재배선한다
- 그 다음 exec로 프로그램을 실행한다
결론은 단순하다.
리다이렉션은 “출력 위치 변경”이 아니라 “fd 연결 구조 재구성”이다
이 관점을 이해하면 다음이 자연스럽게 풀린다.
- 왜 2>&1의 순서가 중요한가
- 왜 pipe와 리다이렉션이 같이 동작하는가
- 왜 프로그램은 아무것도 몰라도 되는가
그리고 결국 하나로 귀결된다.
쉘은 실행기가 아니라, 실행 구조를 만드는 시스템이다