Infrastructure··3분 읽기·5

키보드를 치면 리눅스에서는 어떤 일이 벌어질까?

키보드를 치면 리눅스에서는 어떤 일이 벌어질까? 라는 궁금증에 시작한 리눅스가 입력을 처리한 방법에 대해서 알려준다.

공유:
글꼴

예전에 선배에게 좋은 시스템엔지니어로 성장하려면 어떻게 해야하는 지 물었었다.
그랬더니 그 선배가 나에게 이런 질문을 던졌던 것이 기억이 난다.

너가 키보드로 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 -20

Shift를 누른 상태면 다른 키맵을 참조한다. 그래서 aA가 된다.

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 -a
speed 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

쉘이 하는 일:

  1. 토큰화: ls, -la, /home 으로 분리
  2. 확장: *, ~, $VAR 같은 것들 실제 값으로 치환
  3. 명령어 찾기: 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/binls가 있으면 그걸 쓰고, 없으면 /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이름용도
0stdin표준 입력
1stdout표준 출력
2stderr표준 에러
# 파일 디스크립터 확인
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+CSIGINT프로세스 종료 요청
Ctrl+ZSIGTSTP프로세스 일시 정지
Ctrl+D(EOF)입력 종료
Ctrl+\SIGQUIT강제 종료 + 코어 덤프
# Ctrl+Z로 멈춘 프로세스 확인
jobs
# [1]+  Stopped                 vim file.txt
 
# 다시 실행
fg %1

Ctrl+C가 안 먹히는 프로그램? 해당 프로세스가 SIGINT를 무시하거나 핸들링하고 있는 거다.

리다이렉션이 동작하는 이유

ls > output.txt

이게 되는 이유: fork 후, exec 전에 쉘이 자식 프로세스의 stdout(fd 1)을 파일로 바꿔치기한다.

# stderr만 리다이렉트
ls /없는경로 2> error.txt
 
# stdout과 stderr 둘 다
ls /없는경로 > all.txt 2>&1

2>&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)

이 글이 어떠셨나요?

이 글이 도움이 되셨나요?
공유:

뉴스레터 구독

새 글이 올라오면 이메일로 알려드려요.

댓글

댓글을 불러오는 중...