줄 끝 문자: \n·\r\n·\r과 크로스 플랫폼 처리

Python 파일 처리에서 만나는 LF·CRLF·CR 줄 끝 문자의 차이와 Python의 Universal Newlines 처리 방식을 설명합니다. 줄 끝 감지·변환, newline 매개변수, .gitattributes·.editorconfig 설정까지 다룹니다.

· 7 min read · PALDYN Team

지난 글에서 mmap으로 대용량 파일을 처리하는 방법을 살펴봤다. 파일 I/O 챕터의 마지막 주제는 **줄 끝 문자(line endings)**다. 개발자라면 누구나 한 번쯤 “Windows에서 작성한 파일이 Linux에서 이상하게 보인다”거나 “Git diff에서 줄 끝 경고가 뜬다”는 상황을 겪는다. 이 문제의 원인과 Python에서 처리하는 방법을 알아보자.

줄 끝 문자의 역사

세 종류의 줄 끝 문자가 혼재하는 이유는 역사적 맥락에 있다.

  • LF (Line Feed, \n, 0x0A): Unix/Linux/macOS. 커서를 다음 줄로 이동
  • CRLF (Carriage Return + Line Feed, \r\n, 0x0D 0x0A): Windows. CR은 커서를 줄 맨 앞으로, LF는 다음 줄로 이동
  • CR (Carriage Return, \r, 0x0D): 구버전 Mac OS (OS X 이전). 현재는 사실상 사라짐

타자기(typewriter)에서 온 용어다. 줄을 바꾸려면 종이를 당기는 동작(LF)과 타자 헤드를 맨 왼쪽으로 되돌리는 동작(CR)이 필요했다. 컴퓨터 초기에는 이 두 동작을 모두 표현했고(CRLF), Unix는 LF 하나로 간소화했다.

Python의 Universal Newlines

줄 끝 문자 완전 정리

Python의 텍스트 모드 open()은 기본적으로 Universal Newlines를 적용한다.

  • 읽기: \r\n, \r 모두 \n으로 변환해서 반환
  • 쓰기: \n을 현재 OS의 기본 개행 문자로 변환
# Windows 파일 (CRLF 포함) 텍스트 모드로 읽기
with open("windows.txt", "r", encoding="utf-8") as f:
    lines = f.readlines()
# 각 줄 끝은 '\n' — '\r\n'이 자동으로 변환됨

# 이진 모드로 원본 확인
with open("windows.txt", "rb") as f:
    raw = f.read()
print(b"\r\n" in raw)   # True — 원본은 CRLF

이 자동 변환은 대부분의 경우에 편리하지만, CSV 처리나 이진 분석 시 원본을 그대로 봐야 할 때는 문제가 된다.

newline 매개변수 완전 정리

# newline=None (기본)
# 읽기: \r\n, \r → \n 변환
# 쓰기: \n → OS 기본값 변환

# newline=''
# 읽기: 변환 없음 (원본 그대로)
# 쓰기: 변환 없음 (\n → \n)
# → csv.reader/writer 사용 시 권장

# newline='\n'
# 읽기: \r\n → \n 변환, \r은 그대로
# 쓰기: 변환 없음

# newline='\r\n'
# 쓰기: \n → \r\n 강제
# → Windows 형식으로 파일 저장 시

# newline='\r'
# 읽기: \r을 특별 처리하지 않음

# CSV 처리 — csv 모듈이 직접 처리하도록 newline=''
import csv
with open("data.csv", "r", encoding="utf-8", newline="") as f:
    reader = csv.reader(f)
    for row in reader:
        print(row)

줄 끝 문자 감지

from pathlib import Path

def detect_line_endings(path: str) -> str:
    """파일의 줄 끝 문자 타입 감지"""
    raw = Path(path).read_bytes()
    
    crlf_count = raw.count(b"\r\n")
    cr_only = raw.count(b"\r") - crlf_count  # \r\n의 \r 제외
    lf_only = raw.count(b"\n") - crlf_count  # \r\n의 \n 제외
    
    if crlf_count > 0 and lf_only == 0 and cr_only == 0:
        return "CRLF (Windows)"
    elif lf_only > 0 and crlf_count == 0 and cr_only == 0:
        return "LF (Unix)"
    elif cr_only > 0 and crlf_count == 0 and lf_only == 0:
        return "CR (구 Mac)"
    elif crlf_count > 0 or lf_only > 0 or cr_only > 0:
        return "혼합 (Mixed)"
    else:
        return "개행 없음"

print(detect_line_endings("data.txt"))

줄 끝 변환

줄 끝 문자 감지·변환·통일

from pathlib import Path

def normalize_line_endings(path: str, target: str = "\n") -> None:
    """파일의 줄 끝 문자를 통일"""
    p = Path(path)
    raw = p.read_bytes()
    
    # 순서 중요: \r\n을 먼저 처리해야 \r만 남지 않음
    normalized = raw.replace(b"\r\n", b"\n")  # CRLF → LF
    normalized = normalized.replace(b"\r", b"\n")   # CR → LF
    
    if target == "\r\n":
        normalized = normalized.replace(b"\n", b"\r\n")
    
    p.write_bytes(normalized)

# 여러 파일 일괄 처리
for f in Path("project").rglob("*.py"):
    normalize_line_endings(str(f))

중요: \r\n\r 보다 먼저 교체해야 한다. 반대로 하면 \r\n\r이 먼저 \n으로 바뀌어 \n\n(빈 줄 추가)이 된다.

파이썬 문자열에서 줄 끝 처리

line = "hello\r\n"

# 줄 끝 제거 방법
line.rstrip("\n")         # → 'hello\r'  (LF만 제거)
line.rstrip("\r\n")       # → 'hello'    (CR, LF 모두)
line.rstrip()             # → 'hello'    (모든 공백 포함)
line.strip()              # → 'hello'    (앞뒤 모두)

# splitlines() — 모든 줄 끝 문자 인식
text = "line1\r\nline2\nline3\rline4"
text.splitlines()   # ['line1', 'line2', 'line3', 'line4']
# keepends=True: 줄 끝 문자 포함
text.splitlines(keepends=True)
# ['line1\r\n', 'line2\n', 'line3\r', 'line4']

splitlines()\r\n, \n, \r 외에도 \v, \f, \x1c, \x1d, \x1e, \x85, , 등 유니코드 줄 구분자도 인식한다.

.gitattributes와 .editorconfig

팀 프로젝트에서 줄 끝 문자를 통일하려면 두 파일을 설정해야 한다.

# .gitattributes — Git 체크아웃·커밋 시 변환 규칙
* text=auto eol=lf    # 텍스트 파일은 LF로 통일
*.bat text eol=crlf   # Windows 배치 파일은 CRLF 유지
*.png binary          # 이진 파일은 변환 없음
*.jpg binary
*.pdf binary

# .editorconfig — 에디터가 저장할 때 적용
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.bat]
end_of_line = crlf

eol=lf 설정 후 기존 파일을 일괄 변환하려면:

git add --renormalize .
git commit -m "Normalize line endings to LF"

요약

상황처리 방법
플랫폼 무관 읽기기본 텍스트 모드 (newline=None)
CSV 읽기·쓰기newline='' + csv 모듈
원본 줄 끝 확인이진 모드 읽기
줄 끝 변환이진 읽기 → replace() → 이진 쓰기
팀 프로젝트 통일.gitattributes + .editorconfig

Python의 파일 I/O 챕터를 마무리하면서, 파일을 안전하게 열고(open(), with), 올바른 인코딩으로 읽고(encoding="utf-8"), 줄 끝을 올바르게 처리(splitlines(), newline)하는 것이 견고한 코드의 기본임을 정리한다.


지난 글: mmap: 메모리 맵 파일로 대용량 처리

다음 글: 예외 기초: try·except로 오류를 우아하게 처리하기


읽어주셔서 감사합니다. 😊