키보드를 치면 리눅스에서는 어떤 일이 벌어질까?
키보드를 치면 리눅스에서는 어떤 일이 벌어질까? 라는 궁금증에 시작한 리눅스가 입력을 처리한 방법에 대해서 알려준다.
예전에 선배에게 좋은 시스템엔지니어로 성장하려면 어떻게 해야하는 지 물었었다.
그랬더니 그 선배가 나에게 이런 질문을 던졌던 것이 기억이 난다.
너가 키보드로 ls -la 명령어를 치면 그 결과가 모니터에 나오지?
이거에 대해 설명할 수 있어?
왜 중요한가
"그냥 명령어 치면 실행되는 거 아니야?" 맞다. 근데 이 흐름을 모르면:
- 쉘 스크립트가 왜 그렇게 동작하는지 이해 못 한다
- 터미널이 먹통일 때 원인을 못 찾는다
- stdin/stdout/stderr 리다이렉션이 헷갈린다
- 시그널(Ctrl+C, Ctrl+Z)이 뭘 하는 건지 모른다
- 결국 "왜 안 되지?"만 반복하게 된다
핵심 지식
전체 흐름
┌─────────────────────────────────────────────────────────────────────────────┐
│ 하드웨어 │
│ ┌──────────┐ ┌──────────┐ │
│ │ 키보드 │ │ 모니터 │ │
│ └────┬─────┘ └─────▲────┘ │
│ │ 스캔 코드 │ 픽셀 │
├───────┼─────────────────────────────────────────────────────────┼─────────┤
│ ▼ 커널 영역 │ │
│ ┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ │ │
│ │ 키보드 │ │ TTY 서브시스템 │ │ GPU │ │ │
│ │ 드라이버 ├───▶│ (Line Disc.) │ │ 드라이버 │ │ │
│ └─────────────┘ └────────┬────────┘ └──────▲──────┘ │ │
│ 키코드 변환 │ 버퍼링/시그널 │ │ │
│ │ │ 프레임버퍼 │ │
├──────────────────────────────┼─────────────────────┼──────────────┼─────────┤
│ ▼ 유저 영역 │ │ │
│ ┌──────────┐ ┌─────┴──────┐ │ │
│ │ 쉘 │ │ 터미널 │ │ │
│ │ (bash) │◀────────▶│ 에뮬레이터 │──────┘ │
│ └────┬─────┘ stdin │ (gnome- │ │
│ │ stdout │ terminal) │ │
│ │ fork() stderr └────────────┘ │
│ ▼ │
│ ┌──────────┐ │
│ │ 자식 │ │
│ │ 프로세스 │ │
│ └────┬─────┘ │
│ │ exec() │
│ ▼ │
│ ┌──────────┐ │
│ │ ls │ │
│ │ 프로세스 │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
입력 경로: 키보드 → 드라이버 → TTY → 쉘 → fork → exec → 프로세스 출력 경로: 프로세스 → stdout → TTY → 터미널 에뮬레이터 → GPU → 모니터
하나씩 뜯어보자.
1단계: 키보드 → 커널
키를 누르면 키보드가 스캔 코드를 보낸다. 'A' 키를 누르면 0x1E, 떼면 0x9E. 눌렀다/뗐다가 별도 신호다.
# 스캔 코드 직접 보기 (Ctrl+C로 종료)
sudo showkey -s커널의 키보드 드라이버가 이 스캔 코드를 받아서 키코드로 변환한다. 그다음 키맵 테이블을 참조해서 실제 문자로 바꾼다.
# 현재 키맵 확인
dumpkeys | head -20Shift를 누른 상태면 다른 키맵을 참조한다. 그래서 a가 A가 된다.
2단계: TTY 서브시스템
변환된 문자는 TTY(TeleTYpewriter) 서브시스템으로 간다. 옛날 텔레타이프 장비 이름에서 왔다.
# 현재 TTY 확인
tty
# /dev/pts/0 (가상 터미널)
# /dev/tty1 (물리 콘솔)TTY가 하는 일:
| 기능 | 설명 | 예시 |
|---|---|---|
| Line discipline | 입력을 줄 단위로 버퍼링 | 엔터 치기 전까지 모음 |
| Echo | 입력을 화면에 표시 | 타이핑하면 글자가 보임 |
| 특수 문자 처리 | Ctrl 조합 해석 | Ctrl+C → SIGINT |
| 편집 기능 | 백스페이스, 줄 삭제 | Ctrl+U로 줄 전체 삭제 |
Line discipline이 핵심이다. 엔터를 치기 전까지 TTY가 입력을 모아둔다. 그래서 백스페이스로 수정할 수 있다. 엔터를 치면 그제서야 쉘로 넘어간다.
# TTY 설정 확인
stty -aspeed 38400 baud; rows 24; columns 80;
intr = ^C; quit = ^\; erase = ^?; kill = ^U;
eof = ^D; ...
intr = ^C가 보이는가? Ctrl+C가 SIGINT를 보내는 건 TTY 설정이다.
3단계: 쉘이 받아서 해석
엔터를 치면 TTY가 쉘에게 입력을 넘긴다. 쉘(bash, zsh 등)은 이걸 파싱한다.
ls -la /home쉘이 하는 일:
- 토큰화:
ls,-la,/home으로 분리 - 확장:
*,~,$VAR같은 것들 실제 값으로 치환 - 명령어 찾기:
ls가 어디 있는지 PATH에서 검색
# 명령어 위치 확인
which ls
# /usr/bin/ls
type ls
# ls is aliased to 'ls --color=auto' (별칭이 있으면)PATH 순서대로 찾는다:
echo $PATH
# /usr/local/bin:/usr/bin:/bin:.../usr/local/bin에 ls가 있으면 그걸 쓰고, 없으면 /usr/bin 확인. 끝까지 없으면 command not found.
4단계: fork + exec
명령어를 찾았다. 이제 실행해야 한다. 쉘은 직접 실행하지 않는다. 자식 프로세스를 만들어서 거기서 실행한다.
[쉘 프로세스]
│
│ fork()
▼
[자식 프로세스] ← 쉘의 복사본
│
│ exec()
▼
[ls 프로세스] ← ls 프로그램으로 교체됨
fork(): 현재 프로세스를 복제한다. 부모/자식 두 개가 된다.
exec(): 현재 프로세스를 다른 프로그램으로 교체한다. 돌아오지 않는다.
// 개념 코드 (실제 쉘은 더 복잡함)
pid_t pid = fork();
if (pid == 0) {
// 자식 프로세스
exec("/usr/bin/ls", ["ls", "-la", "/home"]);
// exec 성공하면 여기 도달 안 함
} else {
// 부모 프로세스 (쉘)
wait(pid); // 자식이 끝날 때까지 대기
}왜 이렇게 복잡하게? 쉘이 죽으면 안 되니까. ls가 뭔가 잘못해서 크래시 나도, 쉘은 멀쩡하다. 자식만 죽는다.
# 프로세스 트리 확인
pstree -p $$
# bash(1234)───ls(5678)5단계: 프로세스 실행과 출력
ls 프로세스가 실행된다. 파일 목록을 읽고, stdout(표준 출력)으로 결과를 쓴다.
모든 프로세스는 기본적으로 3개의 파일 디스크립터를 가진다:
| FD | 이름 | 용도 |
|---|---|---|
| 0 | stdin | 표준 입력 |
| 1 | stdout | 표준 출력 |
| 2 | stderr | 표준 에러 |
# 파일 디스크립터 확인
ls -la /proc/$$/fd
# 0 -> /dev/pts/0
# 1 -> /dev/pts/0
# 2 -> /dev/pts/0전부 같은 TTY를 가리킨다. ls가 stdout에 쓰면, TTY를 거쳐 터미널 에뮬레이터가 받아서 화면에 그린다.
6단계: 종료와 반환
ls가 끝나면 exit code를 남기고 종료한다.
ls /존재하는경로
echo $?
# 0 (성공)
ls /없는경로
echo $?
# 2 (실패)쉘은 wait()로 자식이 끝나길 기다리다가, 종료 코드를 받고 다시 프롬프트를 띄운다.
user@host:~$ _
이게 한 사이클이다.
실무 적용
특수 키 동작 이해하기
| 키 | 시그널 | 동작 |
|---|---|---|
| Ctrl+C | SIGINT | 프로세스 종료 요청 |
| Ctrl+Z | SIGTSTP | 프로세스 일시 정지 |
| Ctrl+D | (EOF) | 입력 종료 |
| Ctrl+\ | SIGQUIT | 강제 종료 + 코어 덤프 |
# Ctrl+Z로 멈춘 프로세스 확인
jobs
# [1]+ Stopped vim file.txt
# 다시 실행
fg %1Ctrl+C가 안 먹히는 프로그램? 해당 프로세스가 SIGINT를 무시하거나 핸들링하고 있는 거다.
리다이렉션이 동작하는 이유
ls > output.txt이게 되는 이유: fork 후, exec 전에 쉘이 자식 프로세스의 stdout(fd 1)을 파일로 바꿔치기한다.
# stderr만 리다이렉트
ls /없는경로 2> error.txt
# stdout과 stderr 둘 다
ls /없는경로 > all.txt 2>&12>&1의 의미: fd 2(stderr)를 fd 1(stdout)이 가리키는 곳으로 복제.
파이프가 동작하는 이유
ls | grep txt쉘이 파이프를 만들고, ls의 stdout을 파이프 입력에, grep의 stdin을 파이프 출력에 연결한다.
[ls] ──stdout──> [파이프] ──stdin──> [grep]
두 프로세스는 동시에 실행된다. ls가 쓰는 족족 grep이 읽는다.
정리
- 키 입력은 스캔 코드 → 키코드 → 문자로 변환된다
- TTY가 입력을 버퍼링하고, 특수 키(Ctrl+C 등)를 시그널로 바꾼다
- 쉘은 명령어를 파싱하고 PATH에서 실행 파일을 찾는다
- fork로 복제, exec로 교체 — 쉘이 죽지 않는 이유
- stdin/stdout/stderr는 파일 디스크립터 0/1/2번이다
- 이 흐름을 알면 리다이렉션, 파이프, 시그널이 왜 그렇게 동작하는지 이해된다
다음 글에서는 프로세스와 스레드를 다룬다. fork 이후의 세계.
참고문헌
- Michael Kerrisk, The Linux Programming Interface, Chapter 62 (Terminals)
- Brian Ward, How Linux Works, Chapter 3 (Devices), Chapter 8 (Processes)
- Linux kernel documentation - Input subsystem
- Brendan Gregg, Systems Performance, Chapter 3 (Operating Systems)
이 글이 어떠셨나요?
뉴스레터 구독
새 글이 올라오면 이메일로 알려드려요.