Skip to content
ioob.dev
Go back

Linux 기초 4편 — 텍스트 처리와 파이프

· 9분 읽기
Linux 시리즈 (4/8)
  1. Linux 기초 1편 — 셸과 파일시스템 구조
  2. Linux 기초 2편 — 파일 권한과 사용자/그룹
  3. Linux 기초 3편 — 프로세스와 시그널
  4. Linux 기초 4편 — 텍스트 처리와 파이프
  5. Linux 기초 5편 — 네트워크 도구
  6. Linux 기초 6편 — Systemd와 서비스 관리
  7. Linux 기초 7편 — 패키지 관리
  8. Linux 기초 8편 — Bash 스크립팅 기초
Table of contents

Table of contents

Unix 철학이 가장 선명해지는 자리

Unix가 50년 넘게 살아남은 이유를 한 문장으로 말하면 이것이다. “작은 도구를 만들고, 그것들을 파이프로 잇는다.” 한 명령이 모든 걸 하려 들지 않는다. grep은 검색만 하고, sort는 정렬만 하고, wc는 세기만 한다. 그 대신 파이프 |로 서로를 연결해서 파이프라인을 짠다. 한 번에 하나의 일만 잘하는 도구들을 조립해서 복잡한 작업을 해내는 방식이다.

이 편에서 다루는 도구들은 겉보기엔 단순하다. 그런데 실무에서 로그 분석, 설정 파일 자동 편집, CSV 데이터 요약 같은 일을 할 때 반드시 꺼내드는 것들이다. DevOps 엔지니어의 머슬메모리에 거의 본능적으로 새겨져 있는 명령들이 이 편에 다 들어 있다.

파이프라인이라는 사고방식

파이프 |앞 명령의 표준 출력뒤 명령의 표준 입력으로 연결한다. 말로 풀면 당연해 보이는데, 구조를 그림으로 보면 본질이 드러난다.

flowchart LR
    CMD1["명령 A<br/>(stdout)"] -->|pipe| CMD2["명령 B<br/>(stdin)"]
    CMD2 -->|stdout| CMD3["명령 C<br/>(stdin)"]
    CMD3 -->|stdout| SCREEN["화면<br/>(기본 stdout)"]

각 명령은 자기가 무슨 일을 하는지만 안다. 앞뒤가 뭐가 오든 상관하지 않는다. 그래서 마치 레고 블록처럼 끝없이 조합할 수 있다. 아래 한 줄이 이 철학의 축약판이다.

# 최근 nginx 접근 로그에서 404가 난 URL 상위 10개
tail -n 10000 /var/log/nginx/access.log \
  | awk '$9 == 404 { print $7 }' \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -n 10

각 단계가 하는 일은 단순하다. 마지막 1만 줄만 뽑고, 상태코드 404인 요청의 URL만 추리고, 정렬하고, 중복을 세고, 많은 순으로 다시 정렬하고, 상위 10개만 남긴다. 이 조합을 자유롭게 짜려면 각 조각을 알아야 한다.

stdin, stdout, stderr — 세 개의 통로

모든 프로세스는 기본적으로 세 개의 표준 통로를 갖는다.

flowchart LR
    KBD["키보드 / 파일"] -->|"stdin (0)"| PROC["프로세스"]
    PROC -->|"stdout (1)"| SCREEN1["화면 / 파일"]
    PROC -->|"stderr (2)"| SCREEN2["화면 / 파일"]

stdoutstderr따로 있을까? 파이프로 연결할 때 에러 메시지가 다음 프로그램의 입력으로 섞여 들어가면 곤란하기 때문이다. 출력과 에러를 분리해두면, 에러는 로그로 남기고 출력만 다음 단계로 넘길 수 있다. 이 분리는 Unix 설계의 명작 중 하나다.

리디렉션 — 입출력을 파일로 돌려보내기

파이프가 “프로세스 사이”를 잇는 것이라면, 리디렉션은 “프로세스와 파일”을 잇는다.

# stdout을 파일로 (덮어쓰기)
echo "hello" > greeting.txt

# stdout을 파일에 이어붙이기
echo "world" >> greeting.txt

# 파일 내용을 stdin으로 읽어오기
sort < names.txt

# stderr만 파일로
./run.sh 2> errors.log

# stdout과 stderr를 각각 다른 파일로
./run.sh > out.log 2> err.log

# stdout과 stderr를 같은 파일로 합치기 — 자주 쓴다
./run.sh > all.log 2>&1
# 순서 주의! > all.log 가 먼저 와야 한다

# 출력 자체를 버리기 — /dev/null은 "블랙홀"
./noisy-script.sh > /dev/null 2>&1

# bash에선 &> 로 줄여 쓸 수 있다
./run.sh &> all.log

여기서 2>&1 문법이 처음엔 낯설다. 풀어보면 “파일 디스크립터 2(stderr)를 &1(파일 디스크립터 1, 즉 stdout이 가리키는 것)과 같은 곳으로 보내라”는 의미다. 순서가 중요한 이유도 여기서 나온다. ./run.sh 2>&1 > all.logstderr가 여전히 터미널로 가고 stdout만 파일로 간다 — stderr를 복사할 시점에 stdout이 아직 터미널을 가리키고 있기 때문이다.

tee — 출력을 두 갈래로

파이프로 넘기면서 파일에도 저장하고 싶을 때가 있다. tee 명령이 그 역할이다. 이름처럼 파이프를 T자로 갈라준다.

# 화면에도 보이고, 파일에도 저장된다
./deploy.sh 2>&1 | tee deploy.log

# 추가 모드 (-a)
./deploy.sh 2>&1 | tee -a deploy.log

# root 권한이 필요한 파일에 쓸 때
echo "127.0.0.1 test.local" | sudo tee -a /etc/hosts

마지막 예시가 재미있다. echo ... | sudo >> /etc/hosts동작하지 않는다. 왜냐하면 리디렉션은 셸이 처리하는데, 리디렉션할 때의 셸은 sudo가 아니라 원래 사용자이기 때문이다. 해결책이 sudo tee다. teesudo로 실행되고, 파일 쓰기는 tee가 담당하니 권한 문제가 해소된다. 서버 운영자들이 자주 쓰는 관용구다.

grep — 검색의 표준

grep은 입력에서 패턴에 맞는 줄을 골라낸다. 이름의 유래는 ed 편집기의 g/re/p 명령(global/regular expression/print)이다. 이름부터가 정규식(regular expression)을 전제로 설계됐음을 드러낸다.

# 파일에서 "error" 포함 줄
grep "error" /var/log/app.log

# 대소문자 무시 (-i)
grep -i "error" /var/log/app.log

# 매치 안 된 줄만 (-v, inverse)
grep -v "DEBUG" /var/log/app.log

# 줄 번호까지 (-n)
grep -n "TODO" src/**/*.ts

# 매치 수만 세기 (-c)
grep -c "ERROR" app.log

# 재귀적으로 디렉토리 뒤지기 (-r)
grep -r "TODO" src/

# 매치 앞뒤 줄까지 같이 (-A 뒤, -B 앞, -C 양쪽)
grep -C 3 "panic" /var/log/syslog

# 파이프 입력으로도 당연히 동작
ps aux | grep nginx

대규모 코드베이스를 빠르게 검색하려면 grep 대신 ripgrep(rg)을 추천하는 경우가 많다. .gitignore를 존중하고, 병렬화돼 있고, 기본 컬러 출력도 예쁘다. 하지만 기본 grep은 어느 리눅스 시스템에나 있고, 정규식과 옵션을 알면 대부분을 커버한다.

grep과 정규식

grep은 기본적으로 기본 정규식(BRE)을 쓴다. +, ?, {}, | 같은 메타문자는 \로 이스케이프하거나 -E(확장 정규식, ERE) 옵션을 줘야 한다.

# "error" 또는 "warn"
grep -E "error|warn" app.log

# 숫자로 시작하는 줄
grep -E "^[0-9]+" app.log

# 특정 HTTP 상태 코드 (40x, 50x)
grep -E " [45][0-9][0-9] " access.log

# 이메일 대강 찾기
grep -E "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" contacts.txt

# Perl 스타일 정규식 (-P, GNU grep) — \d, \w 등 지원
grep -P "\berror\b" app.log

정규식을 처음 배우면 처음 몇 달은 괴로운데, 한 번 손에 익으면 텍스트 다루는 일의 체감 속도가 달라진다. 검색-치환-추출의 도구로서 거의 모든 언어에 녹아들어 있다.

sed — 스트림 편집기

sed(Stream EDitor)는 입력을 줄 단위로 받으면서 편집한다. 이름 그대로 “스트리밍 편집기”다. 가장 자주 쓰는 용도는 치환이다.

# "foo"를 "bar"로 치환해서 출력 (원본 안 바뀜)
sed 's/foo/bar/' file.txt

# 한 줄에 여러 번 나오는 것도 다 치환 (g 플래그)
sed 's/foo/bar/g' file.txt

# 원본 파일을 직접 수정 (-i, in-place)
sed -i 's/foo/bar/g' file.txt

# macOS의 BSD sed는 -i 뒤에 백업 확장자를 필수로 원한다
sed -i '' 's/foo/bar/g' file.txt    # macOS
sed -i.bak 's/foo/bar/g' file.txt   # 백업 남기기

# 구분자는 /가 아니어도 된다 — 경로 바꿀 때 유용
sed 's|/old/path|/new/path|g' config.txt

# 특정 줄만 삭제 (3번째 줄)
sed '3d' file.txt

# 패턴에 맞는 줄 삭제
sed '/DEBUG/d' app.log

# 특정 줄 범위 출력 (100~200번 줄)
sed -n '100,200p' huge.log

로그에서 민감 정보를 마스킹하거나, 설정 파일의 특정 값을 자동으로 바꿀 때 sed -i를 CI 스크립트에서 자주 쓴다. 단, sed -i원본을 즉시 바꾼다. 실수로 잘못된 패턴을 쓰면 복구가 어려우니 먼저 -i 없이 출력만 해서 확인한 뒤 -i를 붙이는 게 안전하다.

awk — 행과 열의 세계

awk는 여기서 다루는 도구 중 가장 강력하다. 자체 프로그래밍 언어를 탑재한 도구라, 마음만 먹으면 awk 하나로 상당한 분량의 데이터 처리를 할 수 있다. 이름은 세 창시자의 이니셜(Aho, Weinberger, Kernighan)에서 왔다.

awk의 기본 멘탈 모델은 이렇다. 입력을 한 줄씩 읽어서, 공백으로 쪼개고, $1, $2, $3...에 담은 뒤, 프로그램 블록을 실행한다. $0은 줄 전체를 뜻한다.

# 각 줄의 첫 번째 열만 출력
ps aux | awk '{ print $1 }'

# 두 번째(PID)와 열한 번째(COMMAND)만
ps aux | awk '{ print $2, $11 }'

# 조건 — CPU가 5% 넘는 프로세스만
ps aux | awk '$3 > 5 { print $2, $3, $11 }'

# 구분자 지정 (기본은 공백, CSV 처리 시 콤마로)
awk -F',' '{ print $1 }' data.csv

# 행 번호 붙이기 — NR은 현재 줄 번호
awk '{ print NR, $0 }' file.txt

# 합계 내기
cat numbers.txt | awk '{ sum += $1 } END { print sum }'

# 여러 값 — 평균
awk '{ sum += $1; count++ } END { print sum/count }' nums.txt

강력한 점 하나. BEGIN { ... } 블록은 입력을 읽기 전에, END { ... } 블록은 다 읽은 뒤에 실행된다. 그래서 합계·평균·최대값을 구하기에 딱 좋다.

# nginx 로그에서 상태별 요청 수 세기
# access.log 포맷: ... "GET /path HTTP/1.1" 200 1234 ...
awk '{ codes[$9]++ } END { for (c in codes) print c, codes[c] }' access.log
# 200 9823
# 404 142
# 500 7

codes[$9]++는 “9번째 열(상태코드)을 키로 해서 카운트를 올린다”는 뜻이다. END 블록에서 맵을 돌며 출력한다. 이 한 줄이 단순 셸 스크립트로 짜면 10줄쯤 되는 일이다.

sort, uniq, wc — 세 쌍둥이

파이프라인의 끝자락에서 결과를 정리할 때 거의 항상 등장하는 셋이다.

# 알파벳순 정렬
sort names.txt

# 숫자로 정렬 (-n), 역순 (-r)
sort -n numbers.txt
sort -rn numbers.txt

# 특정 열 기준 (탭 또는 공백 구분, 2번째 열)
sort -k2 data.txt

# 유일한 줄만 (중복 제거) — 반드시 정렬된 입력을 줘야 함
sort names.txt | uniq

# 각 줄의 등장 횟수 (-c)
sort names.txt | uniq -c

# 한 번만 나오는 줄만 (-u), 두 번 이상만 (-d)
sort names.txt | uniq -d

# 줄 수, 단어 수, 바이트 수
wc file.txt
#   23  184  1025  file.txt

# 줄 수만 (-l), 단어 수만 (-w), 바이트/문자 (-c/-m)
wc -l *.log

sort | uniq -c | sort -rn 조합은 “가장 많이 등장한 줄 찾기”의 왕도다. 로그 분석의 80%가 이 패턴이다.

# 오늘 가장 많이 찾아온 IP 상위 10개
awk '{ print $1 }' /var/log/nginx/access.log \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -n 10

head, tail — 앞과 뒤

1편에서도 잠깐 봤지만, 파이프와 붙었을 때 특히 자주 쓰인다.

# 앞에서부터 N줄
head -n 20 file.log

# 뒤에서부터 N줄
tail -n 20 file.log

# 앞에서부터 N바이트
head -c 100 binary.bin

# 실시간으로 추적 (follow)
tail -f /var/log/app.log

# 동시에 여러 파일 추적 (파일 이름 헤더가 붙는다)
tail -f /var/log/app.log /var/log/error.log

# 로그 파일이 logrotate로 바뀌어도 따라가기 (-F)
tail -F /var/log/app.log

tail -ftail -F의 차이가 중요하다. 서비스가 logrotate로 파일을 회전시킬 때 -f는 원래 파일 디스크립터를 계속 따라가서 새 파일을 못 보는 반면, -F는 파일 경로를 주기적으로 다시 열어서 회전된 새 파일도 따라간다.

xargs — 파이프로 받은 값을 명령 인수로

grep, find, ls는 결과를 줄 단위로 뱉는다. 그런데 그 결과 하나하나에 명령을 실행하고 싶을 때가 있다. rm이나 mv 같은 명령은 stdin을 읽지 않고 인수만 받는다. 여기서 필요한 다리가 xargs다.

# find 결과에 대해 rm 실행 — 매번 실행
find . -name "*.tmp" | xargs rm

# 확인 후 실행 (-p, prompt)
find . -name "*.tmp" | xargs -p rm

# 공백이 있는 파일명 처리 — 반드시 -0과 -print0 쌍으로
find . -name "*.tmp" -print0 | xargs -0 rm

# 받은 값을 명령 중간에 끼워넣기 (-I)
ls *.md | xargs -I {} cp {} /backup/{}

# 한 번에 N개씩 처리 (-n)
echo "a b c d e f" | xargs -n 2 echo
# a b
# c d
# e f

# 병렬 처리 (-P)
find . -name "*.jpg" -print0 | xargs -0 -P 4 -I {} convert {} {}.webp

xargsfind의 조합은 시스템 관리자의 단골 무기다. 다만 파일명에 공백이나 특수문자가 있는 경우 -0(NUL 구분)으로 쓰지 않으면 사고가 난다. 요즘 find에는 -exec+ 형태가 있어서 xargs 없이도 비슷한 일을 할 수 있다.

# xargs 대신 find -exec
find . -name "*.tmp" -exec rm {} +

두 방식의 결정적 차이는 병렬화다. xargs -P는 병렬 실행을 지원하는 반면 -exec는 순차 실행이다.

cut, tr — 문자 단위 가공

파이프의 빈틈을 메워주는 작은 도구들이다.

# cut — 특정 열만 잘라내기
# 기본 구분자는 탭. -d로 변경, -f로 필드 지정
echo "alice,30,developer" | cut -d',' -f1
# alice

echo "alice,30,developer" | cut -d',' -f1,3
# alice,developer

# /etc/passwd에서 사용자명과 홈 디렉토리
cut -d':' -f1,6 /etc/passwd

# tr — 문자 변환 (transliterate)
# 대문자로
echo "hello" | tr 'a-z' 'A-Z'
# HELLO

# 공백을 밑줄로
echo "my file.txt" | tr ' ' '_'
# my_file.txt

# 특정 문자 삭제 (-d)
echo "hello123" | tr -d '0-9'
# hello

# 연속된 것들을 하나로 (-s, squeeze)
echo "hello    world" | tr -s ' '
# hello world

cut은 awk보다 단순할 때 쓰고, 복잡한 조건이나 계산이 들어가면 awk로 넘어간다. 둘의 사용 기준을 체감하는 데 시간이 좀 걸리는데, 한 줄로 끝날 일엔 cut, 로직이 섞이면 awk로 생각하면 편하다.

실전 한 덩어리 — 로그 분석 예시

지금까지 본 도구들이 실무에서 어떻게 엮이는지 종합한 예시를 보자. 가상의 nginx 접근 로그에서 의미 있는 통계를 몇 개 뽑아본다.

LOG=/var/log/nginx/access.log

# 1) 오늘 전체 요청 수
wc -l $LOG

# 2) 상태 코드별 분포
awk '{ print $9 }' $LOG | sort | uniq -c | sort -rn

# 3) 상위 10개 클라이언트 IP
awk '{ print $1 }' $LOG | sort | uniq -c | sort -rn | head -n 10

# 4) 5xx 에러가 난 URL과 빈도
awk '$9 ~ /^5/ { print $7 }' $LOG | sort | uniq -c | sort -rn

# 5) 응답 시간(마지막 열이 응답 시간이라 가정) 평균
awk '{ sum += $NF; n++ } END { print sum / n }' $LOG

# 6) 특정 시간대(UTC 14시)만 필터
grep "20/Apr/2026:14" $LOG | wc -l

# 7) 가장 느린 요청 10개 (응답시간 기준)
sort -k10 -rn $LOG | head -n 10

각 줄이 파이프로 엮인 작은 도구들의 합작이다. 이런 조합을 머리에서 즉흥적으로 만들어낼 수 있게 되면 로그 분석 도구 없이도 필요한 통계를 그 자리에서 뽑아낼 수 있다. 쿠버네티스 로그 아카이브를 뒤질 때, CI 실패 패턴을 찾을 때, DB 덤프에서 이상 데이터를 검출할 때 — 이 언어가 통한다.

전반부 정리

네 편을 거쳐 리눅스의 가장 기본적인 조각들을 훑었다. 어디까지 왔는지 복기해둔다.

5편부터는 실무에 직결되는 주제로 넘어간다. 네트워크 도구, Systemd 서비스 관리, 패키지 관리, 그리고 Bash 스크립팅까지. 지금까지 익힌 기초 위에 올려둘 실전 도구들이다.


다음 편에서는 Linux에서 네트워크 상태를 진단하고 외부와 통신하는 기본 도구를 다룬다.

5편: 네트워크 관련 도구


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Linux 기초 3편 — 프로세스와 시그널
Next Post
Linux 기초 5편 — 네트워크 도구