본문 바로가기
빌드노트 검색

빌드노트

Bash에서 파일을 변수에 담았더니 끝의 개행만 사라졌다

Bash 명령 치환은 큰따옴표 안에서도 끝의 LF를 모두 제거한다. 바이트 비교로 중간 개행 보존과 인용하지 않은 확장의 단어 분리를 구별하고, 원본 보존이 필요한 처리는 파일·파이프로 유지한다.

Bash에서 value=$(cat file)로 읽은 내용을 다시 파일에 쓰면 원본과 해시가 다를 수 있다. `$(...)`는 명령의 표준 출력 끝에 연속해서 붙은 개행을 모두 제거한다. 큰따옴표로 감싸도 이 제거는 일어난다. 바이트가 같아야 하는 복사·해시 계산은 파일이나 파이프로 처리하고, 문자열이 필요한 작업에서만 변수로 받는 편이 맞다.

끝 개행 제거와 인용하지 않은 $value의 확장은 따로 확인해야 한다. 앞 단계에서는 끝의 LF가 없어지고, 뒤 단계에서는 남아 있던 중간 LF도 인자 구분에 쓰일 수 있다.

인용한 출력부터 원본과 비교한다

Bash와 cat·od·wc가 있는 Linux 또는 WSL에서 확인할 수 있다. src를 자신의 읽을 수 있는 원본 파일 경로로 바꾸고, roundtrip.txt라는 파일이 없는 쓰기 가능한 폴더에서 실행한다. 원본과 출력 경로는 달라야 한다. 같은 수치로 재현하려면 빈 실습 폴더에서 먼저 printf 'alpha\nbeta\n\n\n' > input.txt로 합성 입력을 만든다.

아래 명령은 원본을 읽어 변수로 받은 뒤, 출력 과정에서 개행을 추가하지 않고 별도 파일에 저장한다. cat이 파일을 읽는 데 실패했다면 비교를 계속하지 말고 경로와 읽기 권한부터 확인한다.

src=./input.txt
value="$(cat -- "$src")"
printf '%s' "$value" > roundtrip.txt
wc -c < "$src"
wc -c < roundtrip.txt
od -An -t x1 -- "$src"
od -An -t x1 -- roundtrip.txt

이 합성 입력으로 실제 실행한 출력은 다음과 같았다. 앞의 두 줄은 바이트 수, 뒤의 두 줄은 각각 원본과 다시 저장한 파일의 16진수 바이트다.

13
10
 61 6c 70 68 61 0a 62 65 74 61 0a 0a 0a
 61 6c 70 68 61 0a 62 65 74 61

0a가 LF다. alpha와 beta 사이의 0a는 남았고, 마지막 0a 0a 0a만 사라졌다. printf '%s'는 값을 그대로 출력하며 끝 개행을 보태지 않는다. 출력 인자도 "$value"로 인용했으므로, 이 차이를 인용 생략 탓으로 설명할 수는 없다. Bash 명령 치환 문서 (새 탭에서 열림)는 끝 개행 제거와 중간 개행 보존을 구분해 설명한다.

끝부분을 바꾼 합성 파일도 같은 방식으로 비교했다.

  • alpha\nbeta: 끝 LF가 없으면 10바이트가 그대로 10바이트였다.
  • alpha\nbeta\n: 끝 LF가 하나면 11바이트에서 10바이트가 됐다.
  • \n\n\n: LF만 세 개 있으면 3바이트에서 빈 문자열인 0바이트가 됐다.
  • alpha\nbeta\n : 맨 끝이 공백이면 12바이트가 그대로 남았다. 그 앞의 LF는 출력의 끝이 아니기 때문이다.

따라서 “마지막 한 줄을 지운다”보다 “출력 끝에 연속한 LF를 제거한다”가 관찰에 맞는 설명이다. 자기 파일에서 크기가 같다면 끝 LF가 없었을 수 있다. 크기만으로 일치를 확정하지 말고 바이트나 해시도 비교한다. 중간 바이트까지 달라졌다면 다음 확장 단계도 살핀다.

중간 개행은 변수를 인자로 넘길 때 달라질 수 있다

value=$(cat file)라는 단순 변수 대입은 일반 명령의 인자와 다르게 처리된다. 대입 우변에 큰따옴표가 없다는 이유만으로 단어 분리가 일어나는 것은 아니다. 이번 비교에서도 value=$(cat ...)와 value="$(cat ...)"로 대입한 결과는 같았다. 대입의 확장 순서는 Simple Command Expansion (새 탭에서 열림)에 설명돼 있다.

구별할 곳은 값을 꺼내 명령의 인자로 쓰는 부분이다. 앞의 value를 유지한 채, 기본 IFS인 공백·탭·개행 조건에서 두 출력을 비교한다. 두 번째 줄은 인용을 의도적으로 생략한 비교용 명령이다.

printf '%s' "$value" | od -An -t x1
printf '%s' $value | od -An -t x1
 61 6c 70 68 61 0a 62 65 74 61
 61 6c 70 68 61 62 65 74 61

인용한 쪽은 alpha와 beta 사이 LF를 포함한 하나의 인자를 받았다. 인용하지 않은 쪽은 단어 분리 규칙 (새 탭에서 열림)에 따라 alpha와 beta 두 인자를 받았다. 여기서는 printf '%s'가 두 값을 구분자 없이 이어 출력해 9바이트인 alphabeta가 됐다. 변수 안의 값 자체를 다시 바꾼 것은 아니다.

평소 출력에서는 "$value"처럼 인용한다. 인용하지 않은 확장은 파일 이름 확장도 받을 수 있지만, 위 입력에는 별표 같은 패턴이 없으므로 그 효과는 측정하지 않았다. 큰따옴표는 이런 후속 확장을 막으며, 이미 명령 치환에서 잃은 끝 LF를 되살리지는 않는다.

바이트를 보존해야 한다면 변수 단계를 뺀다

printf '%s\n' "$value"로 끝 LF 하나를 다시 붙이는 것도 원본 복원은 아니다. 위 13바이트 입력에서는 11바이트만 나왔다. 끝 LF가 없던 입력, 하나 있던 입력, 세 개 있던 입력이 모두 같은 변수 값이 되었으므로 원래 개수를 그 값만으로 알 수 없다.

복사나 해시 비교가 목적이면 원본 파일을 직접 읽게 한다. 앞의 src를 유지하고, direct.txt와 piped.txt가 없는 곳에서 다음을 실행하면 변수 경유 여부에 따른 차이를 확인할 수 있다. sha256sum도 필요하다.

cat -- "$src" > direct.txt
cat -- "$src" | cat > piped.txt
sha256sum -- "$src" direct.txt piped.txt

합성 입력에서는 세 파일 모두 13바이트였고 SHA-256도 같았다. 파이프 중간에 둔 cat은 바이트를 바꾸지 않는다. 실제 처리 명령으로 바꾸면 그 명령의 변환과 종료 상태는 별도로 확인해야 한다. 해시만 필요하다면 sha256sum < "$src"처럼 파일을 표준 입력에 직접 연결할 수도 있다.

NUL(00)이 든 입력에는 “끝 LF만 없어진다”는 결론을 적용하면 안 된다. 별도로 41 00 42 0a 0a를 변수로 받아 보니 이 Bash에서는 NUL을 무시했다는 경고가 나왔고 41 42만 남았다. NUL 보존은 끝 개행과 다른 한계다. 같은 입력을 파일에서 파이프로 전달한 결과는 원본과 같았다.

직접 실행한 환경은 WSL2 Ubuntu 24.04.4, Bash 5.2.21, GNU coreutils 9.4이며 기본 IFS와 LC_ALL=C를 사용했다. 참고한 온라인 매뉴얼은 5.3판이다. Bash 5.3 실행 파일이나 다른 셸의 동작은 이번에 실행하지 않았다.

← 전체 글 보기