파이프와 리다이렉션 입문: grep·sort·uniq로 로그 집계

표준 입력·출력·에러, >·>>·2>&1·파이프를 직접 만든 2만 줄 로그로 연습. uniq·cut 이 조용히 틀리는 경우, sort 로 파일이 비는 함정, 셸 루프와의 속도 차이 실측.

시리즈: 명령줄 강좌 2/6 (초급). 터미널에서 폴더를 오갈 수 있게 된 사람(강좌 1)이 "오늘 모듈별로 에러가 몇 건 났지?" 같은 질문에 엑셀을 열지 않고 답할 수 있게 되는 회차입니다. 작은 도구를 파이프로 잇고, 출력과 에러를 원하는 곳으로 보내는 법을 익힙니다.

장애가 났을 때 운영 로그를 less 로 한참 넘겨 본 적이 있거나, 2만 줄을 엑셀에 붙여 넣어 개수를 센 적이 있다면 이 글이 그 일을 한 줄로 끝내 줍니다. 똑같이 재현할 수 있도록 고정 시드로 애플리케이션 로그를 만들고, 대표적인 grep | cut | sort | uniq -c 파이프라인을 한 단계씩 쌓아 올립니다. 그 과정에서 파이프라인이 에러 없이 틀린 숫자를 내는 다섯 가지 실수도 직접 재현하는데, 그중 하나는 작업 중인 파일을 통째로 비웁니다.

이전 강좌: 강좌 1, 경로와 파일. 전체 그림은 학습 로드맵에 있습니다.

실행 환경: macOS 26.6, Apple Silicon Mac(M1 Pro), zsh 5.9 와 bash 3.2.57, BSD 판 grep 2.6.0-FreeBSD·sort·uniq·cut·wc. 샘플 데이터를 만들 때만 Python 3.9.6 을 썼습니다. 출력은 터미널에서 그대로 옮겼고 홈 경로만 ~ 로 줄였습니다.

연습용 로그 만들기

실제 로그에는 사용자 데이터가 들어 있으니 연습용은 직접 만듭니다. 아래를 gen_log.py 로 저장하세요.

# gen_log.py - make a fake application log (same seed -> same file)
import random, sys
from datetime import datetime, timedelta

n = int(sys.argv[1]) if len(sys.argv) > 1 else 20000
rng = random.Random(42)
t = datetime(2026, 11, 12, 9, 0, 0)
levels = ["INFO"] * 80 + ["WARN"] * 12 + ["ERROR"] * 6 + ["DEBUG"] * 2
modules = ["auth", "cart", "db", "payment", "search"]
msgs = {"INFO": ["request ok", "cache hit", "login ok"],
        "WARN": ["slow query", "retrying"],
        "ERROR": ["timeout", "connection refused", "invalid token"],
        "DEBUG": ["state dump"]}
for _ in range(n):
    t += timedelta(seconds=rng.randint(0, 3))
    lv = rng.choice(levels)
    mod = rng.choice(modules[:3] if lv == "ERROR" and rng.random() < 0.5 else modules)
    ms = int(rng.lognormvariate(4.5, 0.8)) + (2000 if lv == "ERROR" else 0)
    print(f"{t:%Y-%m-%d %H:%M:%S} {lv:<5} {mod} user=u{rng.randint(1, 300):03d} ms={ms} {rng.choice(msgs[lv])}")

{lv:<5} 를 눈여겨보세요. 실제 로거들이 흔히 그러듯 레벨을 다섯 글자로 맞춥니다. 이 작은 부분이 나중에 발목을 잡습니다.

~/demo/logs % python3 gen_log.py > app.log

~/demo/logs % wc -l app.log
   20000 app.log

~/demo/logs % head -3 app.log
2026-11-12 09:00:00 INFO  db user=u053 ms=59 login ok
2026-11-12 09:00:00 INFO  payment user=u288 ms=35 request ok
2026-11-12 09:00:03 INFO  payment user=u004 ms=171 request ok

~/demo/logs % tail -2 app.log
2026-11-12 17:20:52 INFO  cart user=u073 ms=87 cache hit
2026-11-12 17:20:54 INFO  search user=u286 ms=146 login ok

wc -l 은 줄 수, head 와 tail 은 앞과 뒤의 줄(기본 10줄, -3 이면 3줄)을 보여 줍니다. 같은 스크립트로 만들었다면 md5 app.log 결과는 2ba81430e328ad259f3d851999fa857e 입니다.

세 개의 스트림: 표준 입력, 표준 출력, 표준 에러

모든 프로그램은 번호가 붙은 스트림 세 개를 열어 둔 채로 시작합니다.

  • 0, 표준 입력(stdin): 읽어 들이는 곳. 리다이렉트하지 않으면 키보드.
  • 1, 표준 출력(stdout): 정상 결과. 리다이렉트하지 않으면 화면.
  • 2, 표준 에러(stderr): 에러 메시지. 역시 화면이지만 다른 통로입니다.
흐름 그림. app.log 가 grep ERROR 의 표준 입력(0)으로 들어간다. grep 의 표준 출력(1)은 파이프를 따라 sort, uniq -c 로 흐르고 마지막에 화면으로, > 를 쓰면 파일로 간다. grep 의 표준 에러(2), 예를 들어 'No such file or directory' 는 파이프를 건너뛰고 화면에 바로 나온다. 2> 로 다른 곳에 보낼 수 있다.
모든 명령에는 스트림이 세 개 있다. > 와 >> 는 표준 출력(1), 2> 는 표준 에러(2)의 방향을 바꾸고, | 는 앞 명령의 표준 출력을 다음 명령의 표준 입력(0)에 잇는다. 에러는 파이프를 타지 않는다.

두 출력이 따로라는 건, 있는 파일과 없는 파일을 ls 에 같이 주고 표준 출력만 돌려 보면 알 수 있습니다.

~/demo/logs % ls app.log nothere.log
ls: nothere.log: No such file or directory
app.log

~/demo/logs % ls app.log nothere.log > out.txt
ls: nothere.log: No such file or directory

~/demo/logs % cat out.txt
app.log

> 는 1번만 돌리기 때문에 에러는 화면에 남았습니다. 결과를 파일로 저장해도 문제는 눈에 보이게 하는, 쓸모 있는 설계입니다.

리다이렉션: >, >>, 2>, 2>&1, /dev/null

~/demo/logs % ls app.log nothere.log 2> err.txt
app.log

~/demo/logs % cat err.txt
ls: nothere.log: No such file or directory

~/demo/logs % ls app.log nothere.log > all.txt 2>&1

~/demo/logs % cat all.txt
ls: nothere.log: No such file or directory
app.log

~/demo/logs % ls app.log nothere.log 2>&1 > all.txt
ls: nothere.log: No such file or directory

~/demo/logs % cat all.txt
app.log
이렇게 쓰면 표준 출력 표준 에러
cmd > f 파일 f (먼저 비움) 화면
cmd >> f 파일 f 끝에 덧붙임 화면
cmd 2> e 화면 파일 e
cmd > f 2>&1 파일 f 파일 f
cmd 2>&1 > f 파일 f 화면
cmd 2> /dev/null 화면 버림
cmd < f (표준 입력을 파일 f 에서 읽음)

2>&1 은 "2번을, 1번이 지금 가리키는 곳으로 돌려라"라는 뜻입니다. 셸은 리다이렉션을 왼쪽부터 처리하므로 순서가 결과를 바꿉니다. /dev/null 은 써 넣은 것을 모두 버리는 특수 파일입니다. zsh 와 bash 모두 > f 2>&1 를 &> f 로 줄여 쓸 수 있습니다(둘 다 확인).

> 는 실행 전에 파일을 비우고 >> 는 덧붙입니다. echo one > notes.txt, echo two > notes.txt, echo three >> notes.txt 뒤에 cat notes.txt 는 two 와 three 두 줄이었습니다.

파이프: grep, cut, sort, uniq, wc 를 한 단계씩

파이프 | 는 왼쪽 명령의 표준 출력을 오른쪽 명령의 표준 입력에 잇습니다. 중간에 디스크에 쓰지 않고 두 명령이 동시에 돕니다. "모듈별 ERROR 줄 수는?"에 한 단계씩 답해 봅시다.

~/demo/logs % grep ERROR app.log | head -3
2026-11-12 09:00:27 ERROR db user=u251 ms=2376 timeout
2026-11-12 09:00:27 ERROR auth user=u282 ms=2019 timeout
2026-11-12 09:00:37 ERROR search user=u243 ms=2103 connection refused

~/demo/logs % grep -c ERROR app.log
1239

~/demo/logs % grep ERROR app.log | cut -d' ' -f4 | head -3
db
auth
search

~/demo/logs % grep ERROR app.log | cut -d' ' -f4 | sort | uniq -c | sort -rn
 352 cart
 349 auth
 317 db
 122 search
  99 payment

grep 은 패턴이 들어 있는 줄만 남기고(-c 면 개수만), cut -d' ' -f4 는 공백으로 나눈 네 번째 칸(모듈)을 꺼내고, sort 는 같은 줄을 모으고, uniq -c 는 연속된 같은 줄을 합쳐 세고, sort -rn 은 개수 순으로 줄 세웁니다(-n 숫자, -r 역순). 중간 결과를 저장하면서 계속 흘려보내려면 tee 를 씁니다. grep ERROR app.log | tee errors.txt | wc -l 은 1239 를 출력하고 errors.txt 에도 같은 1,239줄이 남습니다.

자주 하는 실수: 에러 없이 숫자가 틀리는 다섯 가지

1. sort 없는 uniq. uniq 는 붙어 있는 중복만 합칩니다.

~/demo/logs % grep ERROR app.log | cut -d' ' -f4 | uniq -c | head -4
   1 db
   1 auth
   2 search
   1 cart

에러는 없고 숫자만 틀렸습니다. 해결: 언제나 sort | uniq -c.

2. 칸을 맞춘 열에 cut. 다섯 글자로 맞춘 레벨을 떠올려 보세요. INFO 뒤에는 공백이 두 개이고, cut -d' ' 는 공백 하나하나를 구분자로 보기 때문에 네 번째 칸이 비어 버립니다.

~/demo/logs % cut -d' ' -f4 app.log | sort | uniq -c
18346 
 423 auth
 445 cart
 402 db
 176 payment
 208 search

INFO 와 WARN 18,346줄이 빈칸이 됐습니다. 아까 잘 된 건 ERROR 가 딱 다섯 글자였기 때문일 뿐입니다. 해결은 tr -s ' ' 로 연속 공백을 하나로 줄인 뒤 cut 하거나, 공백 덩어리로 나누는 awk 를 쓰는 것입니다.

~/demo/logs % tr -s ' ' < app.log | cut -d' ' -f4 | sort | uniq -c
4083 auth
4126 cart
3998 db
3945 payment
3848 search

(awk '{print $4}' app.log | sort | uniq -c 도 같은 숫자였습니다.)

3. 숫자가 아니라 글자로 정렬. "가장 느린 요청은?"

~/demo/logs % grep -o 'ms=[0-9]*' app.log | cut -d= -f2 | sort | tail -3
99
996
997

~/demo/logs % grep -o 'ms=[0-9]*' app.log | cut -d= -f2 | sort -n | tail -3
2802
2911
2936

sort 는 글자를 비교하므로 997 이 2936 뒤로 갑니다. -n 을 주면 숫자로 비교합니다(grep -o 는 줄에서 맞는 부분만 출력합니다).

4. 파일을 자기 자신으로 리다이렉트.

~/demo/logs % printf 'cart\nauth\ndb\n' > modules.txt

~/demo/logs % sort modules.txt > modules.txt

~/demo/logs % wc -c modules.txt
       0 modules.txt

셸은 sort 가 읽기 시작하기 전에 modules.txt 를 쓰기용으로 열면서 비웁니다. 데이터는 사라졌습니다. 해결: sort -o modules.txt modules.txt(sort 는 다 읽은 뒤에 씀) 또는 새 파일에 쓰고 mv 로 되돌리기. 덮어쓰기 자체를 막을 수도 있습니다.

~/demo/logs % set -o noclobber

~/demo/logs % echo oops > modules.txt
zsh: file exists: modules.txt

~/demo/logs % echo ok >| modules.txt

bash 에서는 bash: modules.txt: cannot overwrite existing file 이 나옵니다. >| 로 강제로 덮어쓰는 건 둘 다 같습니다.

5. 파이프라인 뒤의 $?. $? 는 직전 명령의 종료 상태이고 0 이 성공입니다. grep 은 찾으면 0, 못 찾으면 1, 에러면 2 를 돌려줍니다.

~/demo/logs % grep -c CRITICAL app.log
0

~/demo/logs % echo $?
1

~/demo/logs % grep ERROR missing.log
grep: missing.log: No such file or directory

~/demo/logs % echo $?
2

~/demo/logs % grep CRITICAL app.log | wc -l; echo $pipestatus
       0
1 0

파이프라인 뒤의 $? 는 마지막 명령(성공한 wc)의 것입니다. zsh 는 모든 단계의 상태를 $pipestatus 에, bash 는 ${PIPESTATUS[@]} 에 남깁니다. 스크립트가 이를 알아차리게 하는 set -o pipefail 은 강좌 4에서 다룹니다.

zsh 와 bash 의 차이: MULTIOS

macOS 에서는 터미널의 zsh 와 많은 스크립트가 쓰는 bash 를 오가게 됩니다. 리다이렉션에서 차이가 드러나는 예입니다.

~/demo/logs % echo hello > a.txt > b.txt

~/demo/logs % cat a.txt b.txt
hello
hello

zsh 에는 기본으로 켜진 MULTIOS 옵션이 있어서 두 파일 모두에 씁니다. bash 3.2 에서 같은 줄을 실행하면 a.txt 는 비어 있고 hello 는 b.txt 에만 들어갔습니다. 어느 쪽에도 기대지 말고, 두 곳에 남기고 싶으면 tee 를 쓰세요.

실측: 파이프라인 한 줄 vs 셸 루프

파이썬을 먼저 배운 사람은 파일을 한 줄씩 읽는 루프부터 짜기 쉽습니다. 모듈별 ERROR 수를 네 가지 방법으로 세고, 2만 줄과 20만 줄(python3 gen_log.py 200000 > big.log)에서 각각 다섯 번씩 쟀습니다.

# pipe.sh
grep ERROR "$1" | cut -d' ' -f4 | sort | uniq -c

# awk.sh
awk '$3 == "ERROR" { n[$4]++ } END { for (m in n) print n[m], m }' "$1" | sort -k2

# loop_builtin.sh (bash 와 zsh -f 로 각각 실행)
while read -r d t lv mod rest; do
  if [ "$lv" = ERROR ]; then echo "$mod"; fi
done < "$1" | sort | uniq -c

네 방법 모두 같은 개수를 냈습니다. 중앙값(밀리초):

방법 2만 줄 20만 줄
파이프라인: grep→cut→sort→uniq -c 10 33
awk 하나 36 286
bash 3.2 while read 루프 136 1,412
zsh 5.9 while read 루프 698 7,112
20만 줄 기준 막대그래프: grep | cut | sort | uniq -c 33 ms, awk 하나 286 ms, bash while read 루프 1,412 ms, zsh while read 루프 7,112 ms.
20만 줄 로그에서 모듈별 ERROR 줄을 센 시간. 5회 중앙값, Apple Silicon Mac(M1 Pro). 파이프라인은 grep 이 무거운 걸러내기를 맡고, 셸의 read 루프는 한 줄씩 직접 처리한다.

파이프라인이 빠른 건 grep 이 컴파일된 코드로 94% 의 줄을 먼저 버려서 뒤 단계에는 거의 아무것도 가지 않기 때문입니다. zsh 루프는 bash 루프보다 약 5배 느렸습니다. 프로파일링은 하지 않았지만, 그럴듯한 이유를 뒷받침하는 실험이 하나 있습니다. bash 는 일반 파일이면 블록 단위로 읽을 수 있지만 파이프에서는 한 바이트씩 읽어야 합니다. 같은 bash 루프에 cat app.log | 로 넣었더니 중앙값이 136 ms 에서 668 ms(3회)로 늘어 zsh 의 698 ms 와 비슷해졌습니다.

가장 느린 건 처음에 가장 흔히 짜는 버전, 즉 줄마다 cut 을 부르는 루프(lv=$(echo "$line" | cut -d' ' -f3))입니다. 프로세스를 수만 번 띄웁니다. 2만 줄에 73.3초가 걸렸습니다(한 번만 쟀고, 같은 컴퓨터에서 다른 작업도 돌고 있었습니다). 답은 같은데 파이프라인보다 약 7,000배 느립니다.

어떤 질문에 어떤 도구를

알고 싶은 것 도구
X 가 들어 있는 줄 grep X (-v 반대, -i 대소문자 무시)
X 가 들어 있는 줄 수 grep -c X
맞는 부분만 grep -o 'ms=[0-9]*'
N 번째 칸 (구분자 한 글자) cut -d, -fN
N 번째 칸 (공백 여러 개로 구분) awk '{print $N}'
값별 개수 sort → uniq -c → sort -rn
상위 10개 마지막에 head 로 파이프
중간 결과 저장 후 계속 tee file

솔직한 한계와 버전 메모

  • macOS 26.6, zsh 5.9, bash 3.2.57 에서만 확인했습니다. BSD wc 는 숫자 앞을 공백으로 채웁니다(20000 app.log). 리눅스 GNU wc 는 채우지 않고 20000 app.log 로 출력합니다(여기서는 실행 안 함). 스크립트에서 문자열로 비교하면 깨지므로 wc -l < file 결과를 숫자로 다루세요.
  • uniq -c 칸 너비도 BSD 와 GNU 가 다릅니다(여기서는 실행 안 함). cut 말고 awk 로 읽으세요.
  • grep -P(펄 호환 정규식)는 GNU 옵션이라 macOS grep 은 grep: invalid option -- P 로 거부합니다(강좌 3에서 실행). 정규식 문법 자체는 정규 표현식이란?을 보세요.
  • 시간은 Apple Silicon Mac(M1 Pro) 한 대에서, 따로 적지 않은 한 5회 중앙값이며 다른 작업도 돌고 있었습니다. 비율은 안정적이지만 절대값은 환경마다 다릅니다.

연습 문제

  1. WARN 줄을 모듈별로 많은 순서대로 세세요. (WARN 은 네 글자입니다.)
  2. ERROR 줄이 가장 많은 사용자 세 명은?
  3. grep ERROR app.log missing.log 의 결과는 errors.txt 로, 문제는 problems.txt 로 나눠 보내세요. errors.txt 에서 어떤 의외의 모습이 보이나요?

정답

  1. 실수 2 와 같은 이유로 cut -d' ' 는 실패하고 awk 는 됩니다.
~/demo/logs % grep ' WARN ' app.log | cut -d' ' -f4 | sort | uniq -c
2510 

~/demo/logs % grep ' WARN ' app.log | awk '{print $4}' | sort | uniq -c | sort -rn
 534 payment
 524 search
 503 auth
 481 cart
 468 db
  1. grep -o 로 사용자만 뽑아서 셉니다.
~/demo/logs % grep ERROR app.log | grep -o 'user=u[0-9]*' | sort | uniq -c | sort -rn | head -3
  12 user=u182
  11 user=u248
  11 user=u078
  1. 파일이 두 개면 grep 은 맞는 줄 앞에 파일 이름을 붙입니다.
~/demo/logs % grep ERROR app.log missing.log > errors.txt 2> problems.txt

~/demo/logs % head -1 errors.txt
app.log:2026-11-12 09:00:27 ERROR db user=u251 ms=2376 timeout

~/demo/logs % cat problems.txt
grep: missing.log: No such file or directory

파일 이름을 빼려면 -h 를 붙입니다.


이전 강좌: 강좌 1: 경로와 파일. 다음 강좌: 강좌 3: find·xargs·sed·awk. 폴더 전체를 뒤지고 텍스트를 그 자리에서 고칩니다. 학습 로드맵으로 돌아가기.

자주 묻는 질문

> file 2>&1 과 2>&1 > file 은 무엇이 다른가요?

리다이렉션은 왼쪽부터 차례로 적용됩니다. > file 2>&1 은 표준 출력을 먼저 파일로 돌린 뒤 표준 에러를 같은 곳으로 보내므로 둘 다 파일에 들어갑니다. 2>&1 > file 은 표준 에러를 '그 순간의 표준 출력 = 화면'으로 돌린 다음 표준 출력만 옮기므로 에러는 계속 화면에 나옵니다.

uniq -c 개수가 이상하게 나오는 이유는?

uniq 는 서로 붙어 있는 같은 줄만 합칩니다. 정렬하지 않은 입력에서는 같은 모듈이 작은 덩어리마다 따로 세어졌습니다. 항상 sort | uniq -c 순서로 쓰고, 순위는 sort -rn 으로 매깁니다.

sort file > file 을 했더니 파일이 비었습니다. 왜죠?

셸은 sort 를 실행하기 전에 출력 파일을 열면서 내용을 비웁니다. 그래서 sort 는 이미 빈 파일을 읽습니다. sort -o file file 을 쓰거나, 임시 파일에 쓴 뒤 mv 로 되돌리세요.

  • #셸
  • #파이프
  • #리다이렉션
  • #grep
  • #sort
  • #uniq
  • #초급

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑